commit 7dae9b29fd06e1fd868af468eb0a08062d1e389d Author: John Coffey Date: Fri Sep 18 10:21:56 2026 -0700 Import upstream v0.16.22, stripped Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2778083 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +// Ignore everything +* + +// Allow what is needed +!crates +!tests +!resources + +!Cargo.lock +!Cargo.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8567387 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# https://EditorConfig.org +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 diff --git a/.github/DISCUSSION_TEMPLATE/community-discussions.yml b/.github/DISCUSSION_TEMPLATE/community-discussions.yml new file mode 100644 index 0000000..5e1cd21 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/community-discussions.yml @@ -0,0 +1,30 @@ +body: + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > **Stalwart support has moved to [support.stalw.art](https://support.stalw.art).** + > + > For an official response from the Stalwart maintainers, please post your question on the support portal. You can sign in there with your existing GitHub account; no separate registration is required. + > + > You are welcome to start a discussion here and other community members may still reply, but the maintainers no longer answer support questions through GitHub Discussions, so your question may go unanswered unless you also post it at [support.stalw.art](https://support.stalw.art). + > + > Before opening a new report, please review the [documentation](https://stalw.art/docs/) and the [FAQ](https://stalw.art/docs/faq). Most reported issues turn out to be configuration problems rather than actual bugs. + - type: textarea + attributes: + label: Topic + description: Describe the question, problem, or topic you want to discuss with the community. + placeholder: | + I am trying to configure SMTP relay with Stalwart and I am running into ... + validations: + required: true + - type: checkboxes + attributes: + label: Acknowledgement + options: + - label: I have reviewed the [documentation](https://stalw.art/docs/) and the [FAQ](https://stalw.art/docs/faq) and confirm that my question is not addressed there. + required: true + - label: I understand that maintainers no longer answer support questions through GitHub Discussions, and that for an official response from the Stalwart team I need to post my question at [support.stalw.art](https://support.stalw.art). + required: true + - label: I agree to follow the project's [Code of Conduct](https://github.com/stalwartlabs/.github/blob/main/CODE_OF_CONDUCT.md). + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..18d59f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Report an Issue + url: https://support.stalw.art + about: Report a potential bug at support.stalw.art. Confirmed bugs will be converted to Issues. Sign in with your GitHub account. + - name: Questions & Support + url: https://support.stalw.art + about: Get help with configuration, troubleshooting, or general questions at support.stalw.art. Sign in with your GitHub account. + - name: Feature Requests + url: https://support.stalw.art + about: Suggest new features or improvements at support.stalw.art. Sign in with your GitHub account. + - name: Join Stalwart's Reddit + url: https://www.reddit.com/r/stalwartlabs + about: Join our subreddit for community discussions and release announcements. + - name: Join Stalwart's Discord + url: https://discord.com/servers/stalwart-923615863037390889 + about: Join our Discord server for community chat and release announcements. diff --git a/.github/ISSUE_TEMPLATE/confirmed_issue.yml b/.github/ISSUE_TEMPLATE/confirmed_issue.yml new file mode 100644 index 0000000..3851955 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/confirmed_issue.yml @@ -0,0 +1,25 @@ +name: Bug Report (auto-closed) +description: Issues opened here are automatically closed. Please report bugs at support.stalw.art instead. +labels: ["bug"] +title: "🪲: " +body: + - type: markdown + attributes: + value: | + > [!CAUTION] + > **Issues opened directly in this repository are automatically closed and locked.** + > + > All bug reports must first be triaged at our support portal: **[support.stalw.art](https://support.stalw.art)**. If a maintainer confirms that your report is a genuine bug, they will create an Issue on your behalf; you do not need to (and should not) open one yourself. + > + > You can sign in to support.stalw.art with your existing GitHub account, so no separate registration is required. + > + > **What to do instead:** + > - Suspected bug, question, or feature request? Post it at [support.stalw.art](https://support.stalw.art). + > + > If you proceed and submit this form anyway, your issue will be closed automatically and a comment will be posted explaining this policy. + - type: checkboxes + attributes: + label: Acknowledgement + options: + - label: I understand that this issue will be automatically closed and that I should post my report at [support.stalw.art](https://support.stalw.art) instead. + required: true diff --git a/.github/allowed-pr-authors.txt b/.github/allowed-pr-authors.txt new file mode 100644 index 0000000..6476bd8 --- /dev/null +++ b/.github/allowed-pr-authors.txt @@ -0,0 +1,2 @@ +# GitHub usernames allowed to open pull requests directly. +mdecimus diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1d8bc93 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows` + # You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`. + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/auto-close-issues.yml b/.github/workflows/auto-close-issues.yml new file mode 100644 index 0000000..6c34762 --- /dev/null +++ b/.github/workflows/auto-close-issues.yml @@ -0,0 +1,67 @@ +name: Auto-close untriaged issues + +on: + issues: + types: [opened, reopened] + +permissions: + issues: write + +jobs: + auto-close: + runs-on: ubuntu-latest + steps: + - name: Close issues from non-allowed authors + uses: actions/github-script@v7 + with: + script: | + // Users allowed to open issues directly. All other authors will have + // their issues auto-closed. Add GitHub usernames (lowercase) here to + // grant additional contributors permission to open issues. + const allowedAuthors = [ + 'mdecimus', + ]; + + const issue = context.payload.issue; + const author = (issue.user && issue.user.login) || ''; + + if (allowedAuthors.includes(author.toLowerCase())) { + core.info(`Issue #${issue.number} opened by allowed author '${author}'. Skipping.`); + return; + } + + const comment = [ + `Hi @${author}, thanks for taking the time to file this report.`, + ``, + `This issue is being **automatically closed** because all bug reports must first be triaged at our support portal: **[support.stalw.art](https://support.stalw.art)**. Please re-post this report there so that a maintainer can review it; once confirmed as a bug, an Issue will be created on your behalf.`, + ``, + `You can sign in to support.stalw.art with your existing GitHub account, so no separate registration is required.`, + ``, + `Thank you for understanding.`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: comment, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + + try { + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + lock_reason: 'off-topic', + }); + } catch (err) { + core.warning(`Could not lock issue #${issue.number}: ${err.message}`); + } diff --git a/.github/workflows/auto-close-prs.yml b/.github/workflows/auto-close-prs.yml new file mode 100644 index 0000000..fffd048 --- /dev/null +++ b/.github/workflows/auto-close-prs.yml @@ -0,0 +1,131 @@ +name: Auto-close PRs from non-allowed authors + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + +jobs: + auto-close: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: .github/allowed-pr-authors.txt + sparse-checkout-cone-mode: false + + - name: Close PRs from non-allowed authors + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let allowedAuthors = []; + try { + allowedAuthors = fs.readFileSync('.github/allowed-pr-authors.txt', 'utf8') + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => line.toLowerCase()); + } catch (err) { + core.warning(`Could not read allowed-pr-authors.txt: ${err.message}`); + } + + const pr = context.payload.pull_request; + const author = (pr.user && pr.user.login) || ''; + const login = author.toLowerCase(); + + if (author.endsWith('[bot]')) { + core.info(`PR #${pr.number} opened by bot '${author}'. Skipping.`); + return; + } + + if (allowedAuthors.includes(login)) { + core.info(`PR #${pr.number} opened by allowed author '${author}'. Skipping.`); + return; + } + + const actor = (context.payload.sender && context.payload.sender.login) || ''; + + const isCollaborator = async (username) => { + if (!username) { + return false; + } + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return perm.permission === 'admin' || perm.permission === 'write'; + } catch (err) { + core.info(`Could not resolve collaborator permission for '${username}': ${err.message}`); + return false; + } + }; + + if (await isCollaborator(author)) { + core.info(`PR #${pr.number} author '${author}' is a collaborator. Skipping.`); + return; + } + + if (actor.toLowerCase() !== login && await isCollaborator(actor)) { + core.info(`PR #${pr.number} action triggered by collaborator '${actor}'. Skipping.`); + return; + } + + const contributingUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/HEAD/CONTRIBUTING.md`; + + const haystack = `${pr.title || ''}\n${pr.body || ''}`; + const aiPatterns = [ + /[—―]/, + ]; + const looksAiGenerated = aiPatterns.some(re => re.test(haystack)); + + const aiMessage = [ + `Hi @${author}, thanks for your interest in contributing.`, + ``, + `This pull request is being **automatically closed and locked**. The description contains strong indicators of AI-generated content, and this project does not accept AI-generated code or unsolicited machine-authored contributions.`, + ``, + `Please read [CONTRIBUTING.md](${contributingUrl}) to learn what kinds of contributions are currently accepted. If this is a genuine hand-written change that fits those guidelines, please open a discussion at **[support.stalw.art](https://support.stalw.art)** before submitting.`, + ].join('\n'); + + const standardMessage = [ + `Hi @${author}, thanks for taking the time to open this pull request.`, + ``, + `This PR is being **automatically closed** because it was submitted by an author who is not on the list of approved contributors. This policy helps us keep review capacity focused and filter out unsolicited or low-quality contributions.`, + ``, + `Please read [CONTRIBUTING.md](${contributingUrl}) to learn what kinds of contributions are currently accepted. If your change fits those guidelines, please first discuss it at our support portal: **[support.stalw.art](https://support.stalw.art)**. You can sign in with your existing GitHub account.`, + ``, + `Thank you for understanding.`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: looksAiGenerated ? aiMessage : standardMessage, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed', + }); + + if (looksAiGenerated) { + try { + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + lock_reason: 'spam', + }); + } catch (err) { + core.warning(`Could not lock PR #${pr.number}: ${err.message}`); + } + } diff --git a/.github/workflows/auto-redirect-discussions.yml b/.github/workflows/auto-redirect-discussions.yml new file mode 100644 index 0000000..3405d94 --- /dev/null +++ b/.github/workflows/auto-redirect-discussions.yml @@ -0,0 +1,47 @@ +name: Redirect new discussions to the support portal + +on: + discussion: + types: [created] + +permissions: + discussions: write + +jobs: + redirect: + runs-on: ubuntu-latest + steps: + - name: Post support portal redirect + uses: actions/github-script@v7 + with: + script: | + const discussion = context.payload.discussion; + const author = (discussion.user && discussion.user.login) || ''; + + const body = [ + `Hi @${author}, thanks for posting!`, + ``, + `Stalwart support has moved to **[support.stalw.art](https://support.stalw.art)**. The support portal is now the canonical place to ask questions, request help, and report issues for triage. Other community members may still reply here, but the maintainers no longer answer support questions through GitHub Discussions, so your question may go unanswered unless you also post it on the portal.`, + ``, + `You can sign in to support.stalw.art with your existing GitHub account, so no separate registration is required. Google, Discord, LinkedIn, and email/password sign-in are also available.`, + ``, + `**Why we are unifying our support channels**`, + ``, + `Until now, Stalwart support has been spread across GitHub Discussions, Discord, Matrix, and Reddit. As the project has grown, tracking parallel inboxes and deduplicating threads has become unsustainable; the result has been slower answers, repeated work for the people helping out, and good information buried in chat scrollback where the next person with the same question would never find it.`, + ``, + `[support.stalw.art](https://support.stalw.art) is a Discourse instance that we operate ourselves, hosted at Hetzner in Germany and GDPR-compliant.`, + ``, + `Thank you for helping us keep the conversation in one place.`, + ].join('\n'); + + await github.graphql( + `mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { id } + } + }`, + { + discussionId: discussion.node_id, + body, + } + ); diff --git a/.github/workflows/ci-retry.yml b/.github/workflows/ci-retry.yml new file mode 100644 index 0000000..83f41bf --- /dev/null +++ b/.github/workflows/ci-retry.yml @@ -0,0 +1,29 @@ +name: "CI retry" + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + actions: write + +jobs: + rerun: + name: Re-run failed jobs + if: > + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.run_attempt < 3 + runs-on: ubuntu-latest + steps: + - name: Re-run failed jobs + env: + GH_TOKEN: ${{ secrets.CI_RETRY_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + run: | + set -eu + echo "Run $RUN_ID failed on attempt $RUN_ATTEMPT, re-running failed jobs" + sleep 60 + gh run rerun "$RUN_ID" --failed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab2691f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,567 @@ +name: "CI" + +on: + workflow_dispatch: + inputs: + Docker: + required: false + default: false + type: boolean + Release: + required: false + default: false + type: boolean + push: + tags: ["v*.*.*"] + +env: + SCCACHE_GHA_ENABLED: true + RUSTC_WRAPPER: sccache + CARGO_TERM_COLOR: always + CARGO_NET_RETRY: 10 + CARGO_NET_GIT_FETCH_WITH_CLI: true + AWS_LC_SYS_PREBUILT_NASM: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + multiarch: + strategy: + fail-fast: false + matrix: + include: + - variant: gnu + - variant: musl + name: Merge image / ${{matrix.variant}} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + attestations: write + packages: write + needs: [linux] + if: github.event_name == 'push' || inputs.Docker + steps: + - name: Install Cosign + uses: sigstore/cosign-installer@v4.1.2 + - name: Log In to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{github.repository_owner}} + password: ${{github.token}} + + - name: Log In to DockerHub + uses: docker/login-action@v4 + with: + username: ${{secrets.DOCKERHUB_USERNAME}} + password: ${{secrets.DOCKERHUB_TOKEN}} + + - name: Download ${{matrix.variant}} meta bake definition + uses: actions/download-artifact@v8 + with: + name: bake-meta-${{matrix.variant}} + path: ${{ runner.temp }}/${{matrix.variant}} + + - name: Download ${{matrix.variant}} digests + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/${{matrix.variant}}/digests + pattern: digests-${{matrix.variant}}-* + merge-multiple: true + + - name: Create ${{matrix.variant}} manifest list and push + working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests + run: | + docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \ + $(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *) + docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \ + $(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *) + + - name: Inspect ${{matrix.variant}} image + id: manifest-digest + run: | + docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA + echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}" + docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA + echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}" + cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) + cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) + + - name: Attest GHCR + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/${{github.repository}} + subject-digest: ${{ env.GHCR_DIGEST_SHA }} + push-to-registry: true + + - name: Attest Dockerhub + uses: actions/attest-build-provenance@v4 + with: + subject-name: index.docker.io/${{github.repository}} + subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }} + push-to-registry: true + + linux: + permissions: + id-token: write + contents: write + attestations: write + packages: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + platform: linux/amd64 + suffix: "" + build_env: "" + - target: x86_64-unknown-linux-musl + platform: linux/amd64 + suffix: "-alpine" + build_env: "" + - target: aarch64-unknown-linux-gnu + platform: linux/arm64 + suffix: "" + build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 " + - target: aarch64-unknown-linux-musl + platform: linux/arm64 + suffix: "-alpine" + build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 " + - target: armv7-unknown-linux-gnueabihf + platform: linux/arm/v7 + suffix: "" + build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 " + - target: armv7-unknown-linux-musleabihf + platform: linux/arm/v7 + suffix: "-alpine" + build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 " + - target: arm-unknown-linux-gnueabihf + platform: linux/arm/v6 + suffix: "" + build_env: "" + - target: arm-unknown-linux-musleabihf + platform: linux/arm/v6 + suffix: "-alpine" + build_env: "" + name: Build / ${{matrix.target}} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Free disk space (heavy ARM targets) + if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64') + run: | + df -h /mnt / + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force || true + df -h /mnt / + + - name: Add swap (heavy ARM targets) + if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64') + run: | + mnt_avail=$(df --output=avail -k /mnt | tail -1) + if [ "$mnt_avail" -lt 18874368 ]; then + echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup" + exit 1 + fi + sudo fallocate -l 16G /mnt/swapfile + sudo chmod 600 /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + sudo sysctl vm.swappiness=80 + free -h + swapon --show + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: "arm64,arm" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["https://mirror.gcr.io"] + driver-opts: | + network=host + + - name: Log In to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{github.repository_owner}} + password: ${{github.token}} + + - name: Log In to DockerHub + uses: docker/login-action@v4 + with: + username: ${{secrets.DOCKERHUB_USERNAME}} + password: ${{secrets.DOCKERHUB_TOKEN}} + + - name: Calculate shasum of external deps + id: cal-dep-shasum + run: | + echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Cache apt + uses: actions/cache@v6.1.0 + id: apt-cache + with: + path: | + var-cache-apt + var-lib-apt + key: apt-cache-${{ hashFiles('Dockerfile.build') }} + + - name: Cache Cargo + uses: actions/cache@v6.1.0 + id: cargo-cache + with: + path: | + usr-local-cargo-registry + usr-local-cargo-git + key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }} + + - name: Inject cache into docker + uses: reproducible-containers/buildkit-cache-dance@v3.4.0 + with: + cache-map: | + { + "var-cache-apt": "/var/cache/apt", + "var-lib-apt": "/var/lib/apt", + "usr-local-cargo-registry": "/usr/local/cargo/registry", + "usr-local-cargo-git": "/usr/local/cargo/git" + } + skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }} + + - name: Extract Metadata for Docker + uses: docker/metadata-action@v6 + id: meta + with: + images: | + index.docker.io/${{github.repository}} + ghcr.io/${{github.repository}} + flavor: | + suffix=${{matrix.suffix}},onlatest=true + tags: | + type=ref,event=tag + type=ref,event=branch,prefix=branch- + type=edge,branch=main + type=semver,pattern=v{{major}}.{{minor}} + + - name: Build Artifact + id: bake + uses: docker/bake-action@v7 + env: + DOCKER_BUILD_RECORD_UPLOAD: false + TARGET: ${{matrix.target}} + GHCR_REPO: ghcr.io/${{github.repository}} + BUILD_ENV: ${{matrix.build_env}} + DOCKER_PLATFORM: ${{matrix.platform}} + SUFFIX: ${{matrix.suffix}} + with: + source: . + set: | + *.tags= + image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true + files: | + docker-bake.hcl + ${{ steps.meta.outputs.bake-file }} + targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}} + + - name: Upload Artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: artifact-${{matrix.target}} + path: | + artifact + !artifact/*.json + + - name: Export digest & Rename meta bake definition file + if: github.event_name == 'push' || inputs.Docker + run: | + mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json" + mkdir -p ${{ runner.temp }}/digests + digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: github.event_name == 'push' || inputs.Docker + uses: actions/upload-artifact@v7.0.1 + with: + name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Upload GNU meta bake definition + uses: actions/upload-artifact@v7.0.1 + if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86') + with: + name: bake-meta-gnu + path: ${{ runner.temp }}/bake-meta.json + if-no-files-found: error + retention-days: 1 + + - name: Upload musl meta bake definition + uses: actions/upload-artifact@v7.0.1 + if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86') + with: + name: bake-meta-musl + path: ${{ runner.temp }}/bake-meta.json + if-no-files-found: error + retention-days: 1 + + windows: + name: Build / ${{matrix.target}} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + # - target: aarch64-pc-windows-msvc + - target: x86_64-pc-windows-msvc + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + disable_annotations: true + + - name: Build + run: | + rustup target add ${{matrix.target}} + cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" + mkdir -p artifacts + mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe + + - name: Upload Artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: artifact-${{matrix.target}} + path: artifacts + + macos: + name: Build / ${{matrix.target}} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + - target: x86_64-apple-darwin + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.11 + with: + disable_annotations: true + + #- name: Build FoundationDB Edition + # env: + # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # run: | + # rustup target add ${{matrix.target}} + # # Pin FoundationDB 7.4.x (Apple publishes these as prereleases) + # curl --retry 5 -Lso foundationdb.pkg "$(gh api -X GET /repos/apple/foundationdb/releases --jq '[.[] | select(.tag_name | startswith("7.4."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("${{startsWith(matrix.target, 'x86') && 'x86_64' || 'arm64'}}" + ".pkg$")) | .browser_download_url')" + # echo "=== Package contents ===" + # pkgutil --payload-files foundationdb.pkg || true + # sudo installer -allowUntrusted -verbose -dumplog -pkg foundationdb.pkg -target / + # cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise" + # mkdir -p artifacts + # mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart-foundationdb + + - name: Build + run: | + rustup target add ${{matrix.target}} + cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" + mkdir -p artifacts + mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart + + - name: Upload Artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: artifact-${{matrix.target}} + path: artifacts + + freebsd: + name: Build / ${{matrix.target}} + runs-on: ubuntu-latest + timeout-minutes: 360 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-freebsd + arch: x86_64 + # - target: aarch64-unknown-freebsd + # arch: aarch64 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Build in FreeBSD VM + uses: vmactions/freebsd-vm@v1 + with: + release: "15.1" + arch: ${{matrix.arch}} + usesh: true + mem: 14336 + cpu: 4 + sync: rsync + copyback: true + # gmake: required by jemalloc-sys on BSD hosts + # llvm: provides libclang for bindgen (librocksdb-sys) + # rust: libsqlite3-sys 0.38 uses cfg_select!, stabilized in Rust + # 1.95. The default 'quarterly' pkg repo still ships rust 1.94, so + # switch to the 'latest' repo (currently 1.96.1). rustup is not an + # option here: aarch64-unknown-freebsd has no rustup toolchains yet. + prepare: | + set -e + mkdir -p /usr/local/etc/pkg/repos + echo 'FreeBSD: { url: "pkg+https://pkg.freebsd.org/${ABI}/latest", mirror_type: "srv" }' > /usr/local/etc/pkg/repos/FreeBSD.conf + pkg update -f + env ASSUME_ALWAYS_YES=yes pkg bootstrap -f + pkg update -f + pkg install -y rust gmake llvm rocksdb + rustc --version + run: | + set -e + export CARGO_TARGET_DIR=/tmp/target + export CARGO_TERM_COLOR=always + export CARGO_NET_RETRY=10 + cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" + mkdir -p artifacts + cp /tmp/target/release/stalwart artifacts/stalwart + + - name: Upload Artifacts + uses: actions/upload-artifact@v7.0.1 + with: + name: artifact-${{matrix.target}} + path: artifacts + + release: + name: Release + permissions: + id-token: write + contents: write + attestations: write + if: github.event_name == 'push' || inputs.Release + needs: [linux, windows, macos, freebsd] + runs-on: ubuntu-latest + steps: + # Must run before artifacts are downloaded — checkout cleans the workspace. + - name: Checkout (for CHANGELOG) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/checkout@v7 + + - name: Download Artifacts + uses: actions/download-artifact@v8 + with: + path: archive + pattern: artifact-* + + - name: Compress + run: | + set -eux + BASE_DIR="$(pwd)/archive" + compress_files() { + local dir="$1" + local archive_dir_name="${dir#artifact-}" + cd "$dir" + # Process each file in the directory + for file in `ls`; do + filename="${file%.*}" + extension="${file##*.}" + if [ "$extension" = "exe" ]; then + 7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null + else + tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file" + fi + done + cd $BASE_DIR + } + cd $BASE_DIR + for arch_dir in `ls`; do + dir_name=$(basename "$arch_dir") + compress_files "$dir_name" + done + + - name: Attest binary + id: attest + uses: actions/attest-build-provenance@v4 + with: + subject-path: | + archive/**/*.tar.gz + archive/**/*.zip + + - name: Use cosign to sign existing artifacts + uses: sigstore/gh-action-sigstore-python@v3.5.0 + with: + inputs: | + archive/**/*.tar.gz + archive/**/*.zip + + - name: Build release body + run: | + if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then + awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md + echo "" >> release_body.md + else + : > release_body.md + fi + cat >> release_body.md < + + ### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }}) + EOF + + - name: Release + uses: softprops/action-gh-release@v3 + with: + files: | + archive/**/*.tar.gz + archive/**/*.zip + archive/**/*.sigstore.json + prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}} + tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}} + # Tag-push releases are created as drafts; the `publish` job un-drafts + # them only after all build jobs succeed, so watcher notifications + # don't fire on broken builds. + draft: ${{ startsWith(github.ref, 'refs/tags/') || null }} + body_path: release_body.md + + publish: + name: Publish release + needs: [linux, windows, macos, freebsd, multiarch, release] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Un-draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}" + + cleanup: + name: Cleanup failed release + needs: [linux, windows, macos, freebsd, multiarch, release] + if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3 + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete draft release and tag + env: + GH_TOKEN: ${{ github.token }} + run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..97c4d70 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,78 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '31 6 * * 0' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@v4.37.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..daadca3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: Test + +on: + workflow_dispatch: + +jobs: + style: + name: Check Style + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Check Style + run: cargo fmt --all --check + + test: + name: Test + needs: style + runs-on: ubuntu-latest + + env: + STORE: RocksDb + RUST_MIN_STACK: "16777216" + + steps: + - name: Checkout + uses: actions/checkout@v7 + + # External services (OpenLDAP, Keycloak, PostgreSQL, MySQL, Redis, NATS, + # MinIO, OpenSearch, Meilisearch) are provisioned on demand by the test + # suite via testcontainers using the Docker daemon available on the + # runner; see tests/src/utils/containers.rs. + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: JMAP Protocol Tests + run: cargo test -p jmap_proto -- --nocapture + + - name: IMAP Protocol Tests + run: cargo test -p imap_proto -- --nocapture + + - name: Full-text search Tests + run: cargo test -p store -- --nocapture + + - name: Directory Tests + run: cargo test -p tests directory -- --nocapture + + - name: SMTP Tests + run: cargo test -p tests smtp -- --nocapture + + - name: IMAP Tests + run: cargo test -p tests imap -- --nocapture + + - name: JMAP Tests + run: cargo test -p tests jmap -- --nocapture diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 0000000..9fb5148 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,41 @@ +# trivy ci workflow +name: trivy + +on: + workflow_dispatch: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '00 12 * * *' + +permissions: + contents: read + +jobs: + build: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + name: Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4.37.4 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6d2a48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/target +*.failed +*_failed +run.sh +.* +!.gitignore +!.gitattributes +!.github +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6cf7732 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1968 @@ +# Change Log + +All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + +## [0.16.22] - 2026-09-13 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed + +## Fixed +- WebDAV: `PROPFIND` responses do not declare the namespace of every requested property on the `DAV:multistatus` element. +- MTA: + - SMTP session state obtained before `STARTTLS` is discarded once the TLS handshake completes, as required by RFC 3207. + - Inbound throttle, outbound throttle and queue quota `match` expressions evaluate their `if`/`then` conditions. + - DMARC: Messages without an aligned SPF or DKIM pass from a domain that publishes a DMARC policy are reported as `fail` instead of `none` in logs. + - DMARC: A temporary DNS error on an SPF or DKIM check whose identifier aligns with the author domain produces `temperror` instead of `fail`. + - DMARC: A policy record without a `p` tag is only applied, as `p=none`, when it contains a `rua` tag, even if it declares `sp` or `np`. +- iMIP: Notification subjects and bodies omit the event start date, time and timezone for recurring events. +- OIDC: Discovery is retried for 30 seconds before the directory is marked as unavailable, so a provider that is still starting up no longer requires a restart. +- WebUI: The cached bundle of an `Application` is discarded when its `resourceUrl` changes or the record is deleted, so the next unpack fetches the new resource. +- DNS: Append `.` to MX record hostnames. +- Recovery mode: Automatic IP banning is disabled during recovery and initial setup. +- FoundationDB: The cached read version is invalidated when a queue refresh or registry change broadcast is received. +- Sieve: Messages filed with `fileinto` by a user script are treated as ham and are no longer moved to Junk when classified as spam. +- IMAP: + - `SELECT`, `EXAMINE` and `STATUS` on a shared mailbox require the `r` (read) right. + - `AUTHENTICATE` is refused on clear-text connections unless `allowPlainTextAuth` is enabled, and the greeting and `CAPABILITY` response advertise `LOGINDISABLED` instead of the `AUTH=` mechanisms in that case. +- IMAP, POP3, ManageSieve: Partial commands received before `STARTTLS` or `STLS` are discarded once the TLS handshake completes. +- Network: Inbound TLS handshakes, both implicit and `STARTTLS`, are aborted after the listener's `tlsTimeout`, releasing the connection slot. +- OAuth: A random key is used when the OIDC provider's `encryptionKey` or HMAC `signatureKey` cannot be read, instead of an empty key. +- LDAP: The `{local}` and `{domain}` filter placeholders are escaped. +- JMAP: + - `CalendarEvent/set` stores `useDefaultAlerts` per user, and `CalendarEvent/get` returns `false` when it was never set. + - `CalendarEvent/get` returns `null` for `baseEventId` unless the id is a synthetic recurrence instance. + - `CalendarEvent/get` returns `null` for requested `recurrenceRule` and `recurrenceOverrides` properties on synthetic recurrence instances. + - `CalendarEvent/get` and `ContactCard/get` return only `id` when the `properties` argument is an empty list, instead of every property. + +## [0.16.21] - 2026-09-06 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed +- Sieve: Messages generated by user scripts are DKIM signed through the new `SieveUserInterpreter.dkimSignDomain` setting, which defaults to the account's own domain. + +## Fixed +- JMAP: + - `CalendarEvent/set` requests that ask for scheduling messages are rejected with a `forbidden` error when the account cannot send them. + - `Calendar/get` and `AddressBook/get` return every property when the `properties` argument is omitted or `null`. + - EventSource `ping` events advertise the interval in seconds rather than milliseconds. + - Calendar synthetic ids returned when expanding recurrences identify an occurrence by its recurrence id. +- IMAP: Every command in a pipelined `STATUS` or `FETCH` batch receives its tagged completion, instead of the first failing command dropping the responses for all commands queued behind it. +- WebDAV: Accounts without a storage quota no longer advertise a 4 GiB limit in `DAV:quota-available-bytes`. +- MTA: Inbound DMARC and TLS aggregate reports that a reporter sends more than once are imported again as a duplicate entry. +- Spam filter: Domain and URL blocklists are queried only for text written as a link. +- iTIP: Detaching an occurrence that the recurrence rule already generates is sent as a `METHOD:REQUEST` carrying the `RECURRENCE-ID` instead of a `METHOD:ADD`. +- Sieve: `fileinto :specialuse` and `specialuse_exists` accept special-use attributes in the `\Trash` form. +- LDAP: Active Directory servers that answer an unauthenticated bind (a non-empty DN with a zero-length password) with success no longer authenticate accounts without a password. +- Network: Listeners bound to the unspecified IPv6 address (`[::]`) fall back to IPv4 when socket creation fails with `EPROTONOSUPPORT`. +- OpenTelemetry: log exporter does not include the parent span's attributes. + +## [0.16.20] - 2026-08-30 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- System for Cross-domain Identity Management (SCIM) v2 (*Enterprise*): + - Core Schema ([RFC 7643](https://www.rfc-editor.org/rfc/rfc7643.html)) + - Protocol ([RFC 7644](https://www.rfc-editor.org/rfc/rfc7644.html)) + - Cursor-Based Pagination ([RFC 9865](https://www.rfc-editor.org/rfc/rfc9865.html)) + - Interoperability Profile ([draft-zollner-scim-interop-profile](https://datatracker.ietf.org/doc/draft-zollner-scim-interop-profile/)) + - IPSIE lifecycle profile ([draft-schreiber-scim-ipsie-profile](https://datatracker.ietf.org/doc/draft-schreiber-scim-ipsie-profile/)) +- JMAP: `CalendarEvent/set` support for updating and deleting synthetic ids (#2925). +- Calendar: + - Conference links in calendar invites and email alarms. + - Translations for Arabic, Brazilian Portuguese, Bulgarian, Chinese Simplified, Chinese Traditional, Croatian, Czech, Finnish, Hebrew, Hindi, Hungarian, Indonesian, Japanese, Korean, Lithuanian, Norwegian Bokmål, Persian, Romanian, Russian, Slovak, Slovenian, Thai, Turkish, Ukrainian and Vietnamese. + +## Changed +- Calendar: Updated HTTP RSVP page. + +## Fixed +- DANE: + - `TLSA` records are looked up whenever the MX RRset is signed, even when the MX host's own zone is not. + - Mandatory DANE failures are permanent rather than temporary, bouncing messages that should be delayed. + - Valid but unusable `TLSA` records fall back to the configured TLS strategy, permitting cleartext delivery where TLS is required. +- S3: Fix outdated upstream `af-south-1` region configuration. +- Setup wizard: SQL directories set to use the main data store are now validated against the data store being configured. +- CardDAV: Delete default address book id when deleting the default address book. +- Redis: Sentinel deployments configured with `rediss://` URLs now connect to the master over TLS instead of silently falling back to cleartext. +- Email: Generated `Message-ID` headers use the hostname of the node that built the message instead of the configured server hostname. +- MTA: + - Do not send DMARC reports to local domains. + - Messages addressed to an `inboundReportAddresses` match are only discarded when they actually contain a report (#1088). +- Directory: Impersonation using the recovery admin fails when the impersonated account has not logged in before (LDAP and SQL directories). +- WebUI: Failed logins open the browser's native credential prompt. +- Cluster: Expired node id leases are released periodically rather than only during startup, so entries for removed nodes no longer remain `Stale` or `Inactive` indefinitely. + +## [0.16.19] - 2026-08-24 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- WebUI: `oauthClientId` setting in `Application`, which allows the WebUI to use a different OAuth client than the default. +- Sieve: `env.spam.score` and `env.spam.is_spam` variables, which expose the spam filter result to system scripts running at the `DATA` stage. +- CalDAV: `vCardVersion` setting in `AddressBook`, which allows the default vCard version to be specified when the client does not request a specific version. + +## Changed +- MySQL & MariaDB: Key columns are now `VARBINARY(255)` with a full-length primary key instead of `TINYBLOB`. Note: Existing deployments should run, once per table, for each of the tables `a`, `d`, `e`, `f`, `g`, `h`, `j`, `k`, `l`, `m`, `n`, `o`, `p`, `q`, `r`, `s`, `t`, `u`, `w`, `x` and `y` the command `ALTER TABLE a MODIFY k VARBINARY(255) NOT NULL;`. + +## Fixed +- ACME: + - Order and authorization failures are never logged, so an order rejected by the CA. + - An order rejected by the CA marks the renewal task as permanently failed. +- CalDAV: + - Attendee addresses whose `mailto:` URI percent-encodes a full `name-addr` are silently dropped from the scheduling snapshot. + - Attendees whose calendar user address cannot be parsed should be flagged with `SCHEDULE-STATUS=3.7`. + - The RSVP link in an iMIP invitation stamps `PARTSTAT` on the organizer's copy of the event only, leaving a local attendee's own copy at `NEEDS-ACTION` and sending the organizer no reply. + - `MKCALENDAR`, `MKCOL` and `PROPPATCH` store the display name, description, time zone and the other per-user properties under the authenticated account rather than the account that owns the collection. +- Directory: + - An empty column, attribute or claim returned by an external directory is synchronized as an empty string rather than a missing value. + - `/api/discover` splits the account name on `@` without accounting for the `%` master user separator or the recovery administrator. +- FoundationDB: Older chunked entries are not deleted. +- IMAP: `SETACL` and `DELETEACL` fail to resolve an identifier spelled with uppercase characters. +- iMIP: Invitations, replies and cancellations reference a `TZID` parameter with no matching `VTIMEZONE` component whenever the event was stored without one. +- JMAP: + - `AddressBook/get`: A new account's default address book is never recorded. + - `Email/get` and `Email/parse` with `fetchAllBodyValues` return body values only for the parts listed in `textBody` or `htmlBody`, omitting every other `text/*` part in `bodyStructure`. + - `Email/set` writes display names as an RFC 2047 encoded-word wrapped in a quoted-string, which RFC 2047 forbids. + - `Mailbox/set`, `AddressBook/set` and `Calendar/set` store `isSubscribed` and the other per-user properties under the authenticated account rather than the account named in the request. + - `Principal/query` returns no results when the `name` or `email` filter is spelled with uppercase characters. + - `FileNode/set`: File nodes created over JMAP are returned with a `` holding the raw name over WebDAV. +- Meilisearch: + - Queries return at most 1000 results, as the `maxTotalHits` pagination setting is left at the Meilisearch default. + - Searches combining several terms return documents that match only some of them. + - A task confirmation timeout is reported as a success when `failOnTimeout` is disabled. +- Import: `--import` always aborts with the target database already containing data in the key range being imported. +- MTA: + - A domain `catchAllAddress` pointing to a mailing list or a sub-addressed mailbox is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`. + - `is_local_address()` and `is_local_domain()` expression functions do not match an address or domain spelled with uppercase characters. + - Relay routes are rejected with `host resolves loopback address`, which prevents relaying through a local proxy or tunnel. +- MySQL, MariaDB & PostgreSQL: Range scans, range deletions and store purges run as a single unbounded statement, so on servers that enforce a statement timeout they abort on large accounts and tasks such as account deletion can never complete. +- Network: `local_port` and `local_ip` report the address Stalwart is bound to rather than the address the client connected to when the connection arrives through a trusted proxy. +- Task manager: + - `totalDeadline` is not enforced on tasks that fail with a specific retry time. + - Indexing tasks are dropped after `maxAttempts` failures, so a search store that is unavailable or overloaded leaves messages permanently missing from the index. + - Indexing tasks are dropped when the document metadata read returns no data, which can happen on SQL read replicas that have not yet caught up with the primary. + - The DNS management task republishes the DKIM records of retired keys that the DKIM rotation task had already removed from the zone. +- Sieve: `spamtest` returns only `1` or `10` (and `spamtest :percent` only `0` or `100`), so scripts cannot act on intermediate spam scores. +- Spam filter: `MIME_BAD` is tagged whenever the declared `Content-Type` of an attachment is not byte identical to the type detected from its magic bytes. + +## [0.16.18] - 2026-08-17 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- Reporting: `inboundReportMaxSize` setting, which bounds the size of a decompressed inbound DMARC or TLS report (default 25MB). +- RocksDB: `cacheSize` setting, which bounds the total memory shared by the block caches of every column family (default 128MB). + +## Changed +- ASN & GeoIP: Default data source URLs now point at the ip-location-db GitHub releases, as the previously used npm packages are no longer updated. Existing installations keep their configured URLs and should update them following the [ASN and GeoIP documentation](https://stalw.art/docs/server/asn). +- JMAP: `Identity/get` keeps identities in sync with the account's e-mail addresses. +- MTA: Queue scheduler no longer rescans the queue from the earliest pending event and coalesces bursts of delivery completions into a single scan. +- RocksDB: + - Column families are tuned for the access pattern of the data they hold. + - Range iteration uses bounded iterators and no longer reads values when only keys were requested. + +## Fixed +- JMAP: + - Setting `uploadTtl` to 1ms triggers panic. + - `CalendarEvent/set` does not assign `organizerCalendarAddress` nor send scheduling messages when an event is created with participants. + - `CalendarEvent/get` omits `isOrigin` when it is listed explicitly in `properties`. + - `CalendarEventNotification/changes` and `FileNode/changes` reject with `cannotCalculateChanges` the state that `/get` returned for an account with no change history. + - `CalendarEvent/set` and `ContactCard/set` do not write a vanished tombstone for the previous CalDAV/CardDAV href when `calendarIds` or `addressBookIds` moves an item between collections. +- CalDAV: Attendee addresses that percent-encode a display name into the `mailto:` URI are queued verbatim. +- Calendar: Recurring events disappear from CalDAV time-range `REPORT`s and JMAP `CalendarEvent/query` results a few years after their first occurrence. +- WebDAV: + - When a file node references a parent folder that no longer exists, any request on a file collection panics. + - `MOVE` on a folder honors a `Depth` header of `0` or `1` instead of always moving the whole subtree. +- MTA: + - DSN bounces are emitted with a malformed `Message-ID` wrapped in doubled angle brackets. + - Delivery to any MX host whose name is an IDN A-label fails permanently. + - Queue strategy and quota expressions that branch on `source` never match at enqueue. + - MTA-STS: + - Policies in `testing` mode are enforced, turning any TLS error into a permanent failure. + - `mx` patterns published as U-labels never authorize the MX host they name. + - DMARC: + - Alignment compares identifiers in their A-label form. + - External reporting addresses published as U-labels are rejected as unauthorized. +- Spam filter: + - Some rules misfire on internationalized addresses when the envelope and the headers spell the same domain in different label forms. + - Punycode labels that do not re-encode to the label they came from are no longer decoded. +- WebPush: Validate push URL and use `application/octet-stream` as `Content-Type` for encrypted payloads. +- Directory: + - Local group membership is cleared when the external directory is configured with a group claim or attribute that it does not return. + - LDAP: Directories that store aliases as additional values of the primary address attribute provision no aliases. + - Mail addressed to a domain alias is rejected with `550 Relay not allowed`, unless the domain's primary name happened to be resolved earlier and is still cached. +- RocksDB: `bufferSize` setting was applied to the unused default column family and had no effect. +- Sieve: `include` statements fail to find system and user global scripts whose name contains uppercase characters. +- Task manager: `totalDeadline` is measured from the time a task was created instead of its first failed attempt. + +## [0.16.17] - 2026-08-10 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- IMAP: + - `UIDBATCHES` extension ([RFC 10022](https://www.rfc-editor.org/rfc/rfc10022.html)). + - `UIDONLY` extension ([RFC 9586](https://www.rfc-editor.org/rfc/rfc9586.html)). + - `MESSAGELIMIT` and `SAVELIMIT` extensions ([RFC 9738](https://www.rfc-editor.org/rfc/rfc9738.html)). +- WebDAV: `Range` and `If-Range` header support on file downloads ([RFC 7233](https://www.rfc-editor.org/rfc/rfc7233.html)) (#2377). +- Spam filter: `url_original` expression variable for `Url` rules. + +## Changed +- Memory allocator: Replaced the unmaintained `jemallocator` crate with `tikv-jemallocator` (contributed by @checkraisefold). +- ACME registry: Use `description` as label property. + +## Fixed +- MTA: + - Certificates for domains publishing an enforcing MTA-STS policy are always validated, even in the fallback TLS strategy. + - DSN delivery date uses wrong timestamp. + - `FUTURERELEASE HOLDUNTIL` uses Unix timestamps instead of RFC 3339 date-times. +- JMAP: + - `EmailSubmission/query` filtering on `undoStatus` contradicts `EmailSubmission/get`, reporting held `FUTURERELEASE` submissions as `final` instead of `pending`. + - `EmailSubmission/get` requests without an `ids` argument iterates the wrong index. +- CardDAV: `Accept: text/vcard` version negotiation is ignored whenever another parameter such as `q` or `charset` follows `version=`. +- Calendar: Server-side scheduling messages place the `text/calendar` part outside the `multipart/alternative` and disposed as an attachment. +- Sharing: Accounts holding the `impersonate` permission never have their ACL grants collected, so shared items are never listed in JMAP sessions, CalDAV/CardDAV discovery or IMAP. +- IMAP: + - `COPY`/`MOVE` into a shared folder fails with `NO [ALREADYEXISTS]` when the destination account already holds the message, leaving the message in the source mailbox and clients in a retry loop. + - `BODYSTRUCTURE` and `ENVELOPE` return MIME parameters, `Content-Description`, subjects and display names as raw UTF-8 even to sessions that never enabled `UTF8=ACCEPT`. + +## [0.16.16] - 2026-08-02 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- JMAP Email Delivery Push Notifications ([draft-ietf-jmap-emailpush-03](https://datatracker.ietf.org/doc/draft-ietf-jmap-emailpush/)) +- MTA: Allow System Sieve scripts to access `orcpt` during the `DATA` stage. + +## Changed +- S3: `accessKey` can now be read from an environment variable or file. + +## Fixed +- Meilisearch: Verify index existence using `GET` instead of creating a new task which times out on busy servers. +- Branding: Stalwart logo flashes before the per-tenant logo is loaded on the login page. +- Calendar: iMIP and alarm notification messages embed the default logo using bare `LF` line endings, producing a single 4247 octet line that strict SMTP relays reject with `line too long`. +- DMARC: Failure reports state `Identity-Alignment: none` when a mechanism authenticated successfully but against an identity that is not aligned with the `From` domain. +- Redis: Task and queue locks are never released after a worker dies, because failed lock attempts refresh the lock expiry. +- Recovery mode: Download WebUI if missing. +- Logging: The systemd journal tracer omits the parent span's fields. +- MTA: + - `BDAT` chunks sent without a valid `MAIL FROM` are answered with `552 5.3.4 Message too big for system` instead of `503 5.5.1`. + - A `maxMessageSize` of `0` rejects every message with `552 5.3.4 Message too big for system` instead of disabling the size limit. +- Windows: Listeners bound to the unspecified IPv6 address (`[::]`), including all defaults, refuse IPv4 connections such as `127.0.0.1`, since `IPV6_V6ONLY` is enabled by default on Windows. + +## [0.16.15] - 2026-07-26 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed + +## Fixed +- JMAP: + - `Email/copy` should return `alreadyExists` when copying a message to a mailbox that already contains it. + - `Email/copy` with `onSuccessDestroyOriginal` destroys the copy's creation id instead of the source Email id. + - `CalendarEvent/set` does not generate a `uid` on create when the client omits it. + - `CalendarEvent/set` does not refresh the `updated` property (iCalendar `DTSTAMP`) on create or update. + - `EmailSubmission/set` rejects valid recipients whose domain is itself a public suffix (e.g. `gov.in`, `co.uk`). + - Requests are rejected with `notRequest` when a method name contains a JSON-escaped solidus (e.g. `Core\/echo`). +- MTA: Panic when MTA-STS is disabled and a remote MTA fetched `/.well-known/mta-sts.txt`. +- Auth: Scoped credentials with `SysApiKeyCreate` or `SysApiKeyUpdate` permissions can regain its own account's full rights. +- Web Push: Valid VAPID keys are rejected when PEM-encoded with explicit EC parameters, in SEC1 (`EC PRIVATE KEY`) format, or with a leading byte-order mark. +- Encryption at rest: Appended messages are encrypted for accounts that did not opt in to `encryptOnAppend`. +- Cache: Account caches silently discard entries larger than a single `quick-cache` shard, causing constant database rebuilds. +- Registry: Id references (e.g. `#certificate-...`) fail to resolve on `defaultCertificateId`, `defaultAdminRoleIds`, `listenerIds` and `publicKey`. +- Search: `reindex` drops calendar and contact index tasks for accounts with fewer than a full batch of items. +- Migration: Abort `--import` when the target already contains data in the key range being imported. +- Cluster: Broadcast subscriber re-subscribes after every message, losing bursts of cluster broadcasts during the reconnect window. +- Enterprise: Per-tenant logo is not shown on the OAuth login password and OTP screens, which are served from the server's canonical host rather than the tenant domain. + +## [0.16.14] - 2026-07-20 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- Use of Voluntary Application Server Identification (VAPID) in JMAP Web Push ([RFC 9749](https://datatracker.ietf.org/doc/html/rfc9749)). + +## Changed + +## Fixed +- IMAP: + - Mailbox object-quota only enforced in JMAP. + - Pipelined `STORE` and `EXPUNGE` can execute out of order. +- JMAP: + - Read-only sharee cannot set `isSubscribed` on a shared mailbox. + - Web Push payloads with `Content-Encoding: aes128gcm` should not be base64-encoded but sent as raw bytes. + - Stale push subscription can block verification of a new one. + - `PushSubscription/set` rejects the unpadded base64url keys the W3C Push API produces. + - `Email/import` does not send push notifications for imported messages. + - `CalendarEvent/set` silently ignores `ifInState`. +- CalDAV: `calendar-query` REPORT returns empty calendar-data for JMAP-created events. +- MTA: + - DMARC is skipped when MAIL FROM SPF is unavailable. + - `queue_name` variable not available in rate limiter expressions. +- Calendar: + - No expanded occurrences are returned for a daily recurrences crossing DST. + - Uppercase `MAILTO` calendar addresses become invalid SMTP recipients. + - Scheduling invitations on a shared, non-owned calendar fail with `MAIL FROM unauthorized`. +- HTTP: Disable `allowedEndpoints` expression in recovery mode. +- Telemetry: Tasks are serialized to the wrong store when using separate stores for telemetry and data. + +## [0.16.13] - 2026-07-12 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- FreeBSD support. + +## Changed + +## Fixed +- OAuth resource indicators: Accept `imap`, `smtp`, `pop3` and `sieve` as valid resource indicators for OAuth access tokens. +- PostgreSQL: Incomplete channel binding implementation. +- JMAP: + - VacationResponse: `isEnabled` reset to false whenever properties are changed. + - Capabilities: Return RFC-3339-conformant UTCDate literals in capabilities: min `0001-01-01T00:00:00Z`, max `9999-12-31T23:59:59Z`. +- Directory: Update local groups only when the external directory includes a group attribute. +- DNS Management: + - CAA management deletes too third-party CAA entries. + - Multiple provider fixes (see `dns-update` crate changelog). +- Reject invalid duration values (e.g. `1h30m`). +- Branding: Custom logos for domains do not work. +- Sieve: add `Received` headers to auto-generated messages and detect loops. +- MTA: Resume queue processing does not work. +- Misconfigured in-memory store cannot be recovered in recovery mode. + +## [0.16.12] - 2026-07-06 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- DKIM2 implementation ([draft-ietf-dkim-dkim2-spec-04](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/)). +- DMARCbis implementation: + - Domain-based Message Authentication, Reporting, and Conformance (DMARC) ([RFC 9989](https://datatracker.ietf.org/doc/html/rfc9989)) + - DMARC Aggregate Reporting ([RFC 9990](https://datatracker.ietf.org/doc/html/rfc9990)) + - DMARC Failure Reporting ([RFC 9991](https://datatracker.ietf.org/doc/html/rfc9991)) + +## Changed + +## Fixed +- DANE: Treat DNSSEC `bogus` as a temporary failures to prevent downgrade attacks. +- OIDC provider: + - `ECDSA` private key support for `SEC1` format. + - Allow ports in `redirect_uri` for loopback addresses. +- OIDC directory: + - Removing a user from all groups does not sync the changes correctly. + - Fetch `name` and `group` claims from userinfo endpoint when missing from the JWT token. +- PostgreSQL: Include error chain in error messages. +- Prometheus: event counters are exported with incorrect metric names. +- Registry: Changing the type of an existing account from `user` to `group` panics. +- Masked emails: Return `UnknownRecipient` only for disabled or expired masked emails. +- IDN: `sanitize_email` rejects valid Punycode domains. +- Auto-ban: IP block expiration ignores per-reason ban durations. +- Meilisearch: Limit the text search scope using `attributesToSearchOn`. +- CalDAV: `calendar-query` REPORT returns invalid HTTP `404` when no events match the query. +- Snowflake past id generation fails when the provided duration is longer than 4 years. +- Calendar scheduling: Wrong RSVP base URL is used. +- Network listener: Accept loop spins all CPU cores with no back-off when the process hits `EMFILE` (too many open files). +- Cluster: Broadcast MTA queue refresh events to all nodes. + +## [0.16.11] - 2026-06-25 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- Encryption-at-rest: Support for `AES-256-GCM` and `ChaCha20-Poly1305` for S/MIME (#161). +- S3: Support for `allowInvalidCerts` option to allow connecting to S3 endpoints with invalid TLS certificates. +- Redis Sentinel support as an in-memory store and cluster coordinator backend (#2430). + +## Changed + +## Fixed +- DANE: Verify DNSSEC is supported by the resolver before attempting to validate TLSA records. +- TLS: Update search index when file-backed certificates are refreshed. +- JMAP: `Principal/query` returns broad results when a `name` or `email` filter cannot be resolved. +- Webhooks: event IDs collide for same event type emitted in the same second. + +## [0.16.10] - 2026-06-21 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- International Domain Names (IDN) support (#207). +- OAuth: + - OAuth Profile for Open Public Clients ([draft-ietf-mailmaint-oauth-public](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-oauth-public/)) + - Client secret verification for confidential clients. +- HTTP: Add `redirectRoot` option to `Http` object to allow redirecting requests to the root path to a different path (e.g. `/account`). +- ACME: `reuseKey` option to allow reusing private keys in renewals. +- IMAP: + - IMAP Extension for Object Identifiers ([draft-ietf-mailmaint-imap-objectid-bis](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-imap-objectid-bis/)) + - `GETJMAPACCESS` command to discover the JMAP session resource URL (#2736). + +## Changed + +## Fixed +- JMAP conformance (pass the [jmap-test-suite](https://github.com/jmapio/jmap-test-suite) tests): + - Methods are only available if their capability is in `using`. + - Reject requests that do not specify `application/json` in the `Content-Type` header. + - Require `accountId` argument on requests. + - Return unparsable ids in `notFound` / `notUpdated` / `notDestroyed` / `notCopied` instead of dropping them. + - Default calendars and address books are not subscribed by default. + - `*/set`: Unchanged immutable `id` property is rejected on update. + - `*/query` and `*/queryChanges`: null` rejected as `notRequest`. + - `Email/query`: + * Improper `anchor` handling. + * Total miscount when `collapseThreads` is enabled. + * Wrong sort order on `hasKeyword`, `allInThreadHaveKeyword`, and `someInThreadHaveKeyword` conditions. + * Non-standard header values are not searchable. + - `Email/copy`: Take the source message id from the value's `id` property. + - `Email/set`: Bump reference-resolution max_depth from 1 to 2. + - `Email/import`: Reject blobs that do not contain valid messages. + - `EmailSubmission/set`: return `sendAt` and `undoStatus` in the created response. + - `Mailbox/set`: Return `alreadyExists` instead of `invalidProperties` when creating a mailbox with an existing name. + - `SearchSnippet/get`: incorrect response structure. + - `Thread/changes`: emit a container delete when a thread becomes empty. + - `VacationResponse/set`: incorrect singleton handling. +- IMAP: Discard oversized non-synchronizing literals (#2768). +- DANE: Improper `TLSA` record validation (#2328 - credits to @vdukhovni). +- OIDC: Add default domain name to groups that are not email addresses. +- RocksDB: Enable blob garbage collection to reclaim disk space from deleted blobs. +- Sieve: `include` statements ignore capitalisation of sub-script names (#1643) +- Cache: Invalidate negative email caches when an account is created. +- Troubleshoot tool: Use the configured source IP address when connecting to remote servers (#2867). + +## [0.16.9] - 2026-06-15 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- ACME: Allow specifying a preferred certificate chain. + +## Changed + +## Fixed +- JMAP: `*/changes` methods leak ids of non-shared objects (reported by @5ud0er). +- Sieve: Do not allow invalid certs in `http_header` function. +- FoundationDB: Fix read version cache expiration logic. +- MTA: Re-scheduling or editing a queued message reports success but persists nothing for recipients in a non-`default` virtual queue. +- CardDAV: Version requests included in `address-data` are ignored. +- ACME: Add freshness check when renewing certificates. +- Autodiscover v2: Read email address from query parameters. +- Sieve: Do not keep copies of redirected messages when `keep` is not specified. +- Registry: Object ids are parsed as numbers. + +## [0.16.8] - 2026-06-06 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed +- OAuth: Rework access tokens to an `AES-256-GCM-SIV` AEAD format that carries the account name for proxy routing. +- Added more internal TLDs to the domain validation. + +## Fixed +- MTA: + - Sub-addressing with external directories returns `550 Mailbox not found`. + - Disabled aliases continue receiving messages. +- JMAP for File Storage: `FileNode/get` returns a stale state string. +- Make `SieveSystemInterpreter.defaultReturnPath` and `MtaQueueQuota.match` optional expressions. +- Rate limiter panics when periods under 1 second are used. +- CalDAV/CardDAV: Calendar events, contacts, calendars and address books deleted via JMAP do not write a vanished tombstone. +- DNS updater: bump to `dns-update-v0.5.1`. + +## [0.16.7] - 2026-05-28 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- RateLimit header fields for HTTP ([draft-ietf-httpapi-ratelimit-headers-10](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-10)) +- MTA: Implement `spamtest` in trusted Sieve scripts. + +## Changed + +## Fixed +- Log rejected messages to tracing store. +- MTA: + - Always update next DSN notify times. + - Expand lists and resolve catch-all addresses when building autogenerated messages. +- Sharing: Includes resource that themselves carry a direct ACL grant and are leaves. +- Tasks cannot be deleted in OSS builds. +- Directory: Per-domain external directory resolution fails. +- DNS updater: Keep external `TXT` records when updating RRSet. +- HTTP: Reject requests from blocked IPs when `Keep-Alive` is enabled. + +## [0.16.6] - 2026-05-20 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- Added 58 new DNS provider integrations (see [dns-update](https://github.com/stalwartlabs/dns-update/blob/main/CHANGELOG.md#dns-update-040) crate for details). +- DNS updater: Log DNS record types and values. +- Sieve: Allow User Sieve scripts to access `orcpt`. +- MTA: Log when messages are rejected or discarded by the spam classifier. + +## Changed +- Bump JMAP File Storage to [draft-ietf-jmap-filenode-14](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-filenode-14). +- Accept password hashes with `$` or `{` prefixes as secure secrets. + +## Fixed +- DAV: `acl-principal-prop-set` REPORT enforced the wrong privilege. +- JMAP: `Thread/get` did not filter by per-mailbox ACLs on shared accounts. +- IMAP: `UID FETCH N:*` could miss messages moved into a SELECTed mailbox by another connection. +- DNS updater: + - Skip `v=spf1 a -all` records for apex domains. + - RFC2136 TSIG: regression related to multiplexer. + - Route53: Chunk `TXT` records when they exceed 255 characters. +- ACME: + - Update `defaultCertificateId` when renewing a certificate that is currently set as default. + - Perform `DNS-01` authorizations sequentially to avoid race conditions in some DNS providers. +- Allow internal TLDs and special characters in e-mail addresses. +- Websocket: Perform case insensitive matching during upgrade. +- LDAP: Synchronize accounts when expanding mailing list recipients. +- Sieve: `replace` action adds an extra `From` header. +- ACL: Orphaned ACL entries for deleted accounts cause JMAP session errors. + +## [0.16.5] - 2026-05-11 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- `is_ip_in_cidr` expression function for CIDR matching. + +## Changed +- Bump `mail-auth` to 0.9 (which bumps `hickory-resolver` to 0.26). +- Deprecated RFC2136 SIG(0) support as it is no longer supported by `hickory`. + +## Fixed +- JMAP: + - Patching ids containing digits in JSON Pointers fails. + - Patching nested objects with `null` values fails. +- External directories: + - SQL: Return `Failed` instead of `Error` when the query returns no results. + - LDAP: Impersonation fails when the user has not logged in before. +- Network: Attempt binding to IPv4 when binding to IPv6 fails with `EAFNOSUPPORT` error. +- Bootstrap: Timeout after 30 seconds when probing the data store. +- HTTP: Use permissive CORS headers for `.well-known` endpoints. +- ACME: + - Include apex domains when requesting certificates for subdomains. + - Use the public suffix list to determine the zone name when no origin is provided. +- MTA: + - Allow rescheduling recipients with permanent failures. + - Process reports using original `RCPT` before rewriting. +- Autodiscover v2 endpoint unreachable. +- DNS update (via `dns-update` crate): + - OVH + Google Cloud DNS: Fix FQDN handling for `MX` and `SRV` records. + - Route53: Fix changeset error resolution. + - deSEC: Use empty `subname` for apex records instead of `@`, which the API rejects. + - Cloudflare: Wrap `TXT` record content in double quotes (RFC 1035) to suppress dashboard warnings. +- iCalendar/JSCalendar (via `calcard` crate): + - Support `STATUS:CANCELLED` mapping from `VTODO` to JSCalendar. + - Fixed duration parsing for zero duration `PT0S`. + +## [0.16.4] - 2026-05-05 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed + +## Fixed +- Live tracing in community and OSS versions. +- Timezone changes from the `AccountSettings` object return `invalidProperties`. +- `mail-parser` panic with certain messages containing corrupted attachments. +- Pagination by anchor for queued messages, tasks and metrics. +- Spam filter: Use original instead of rewritten `RCPT` on checks. +- JMAP: + - References in nested objects not resolved. + - `AddressBook/query` fetches wrong resources. +- Import tool fails to restore registry entries. +- FDB: Allow multiple FoundationDB instances in the same process. +- Autoconfig: Return `%EMAILADDRESS%` when no email address is provided. +- Quota: Include Sieve scripts in quota recalculations. + +## [0.16.3] - 2026-04-30 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed +- Replaced `STALWART_HTTPS_PORT` with `STALWART_PUBLIC_URL`. +- App Passwords now begin with `app_` instead of `app ` to avoid issues with some clients that do not support spaces in passwords. + +## Fixed +- Directory: + - Invalidate caches when group memberships change on an external directory. + - OIDC: errors instead of "failed to decode token". + - OIDC: Recovery admin access. + - User impersonation. +- Tasks: + - Delete locked tasks. + - Queue pagination by anchor. +- Log viewer: All events show as `INFO`. +- Registry: Allow changing object variants. +- Node id renewal. +- DNS Updater: Fix Route53 serialization format. + +## [0.16.2] - 2026-04-28 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- OIDC: Fallback to `userinfo` endpoint when JWT token does not contain an email claim. +- S3: `verifyAfterWrite` option to verify that objects have persisted after writing. + +## Changed +- Allow HTTP to be used for configuring the server. + +## Fixed +- LDAP: Generate valid `credentialId` when there are password changes. +- TLS: Disable cipher suited option disables wrong ciphers. +- DNS Updater: + - BunnyDNS: Use subdomain as name of record instead of FQDN. + - RFC2136: Chunk TXT records. +- Skip invalid entries in log files. + +## [0.16.1] - 2026-04-25 + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- OIDC: Extract username from JWT token. +- `system('node_hostname')` and `system('node_role')` expression variables to retrieve the local node hostname and cluster role respectively. + +## Changed + +## Fixed +- JMAP: + - Invalid `receivedAt` headers after importing (#2939). + - Sorting order issues when emails lack `receivedAt` headers. +- IMAP: Fix `BINARY` fetch responses (#2940). +- WebDAV: Fix ACL validation for target folders. +- ACME: Allow requesting apex domain certificates. +- Hostname issues: + - Accept RFC 6761 reserved TLDs during bootstrap. + - Allow hostnames without TLDs in remote server settings. +- Reverse proxy issues. +- OSS builds. +- DNS Updater: + - RFC2136: TSIG secret not base64 decoded. + - Google DNS: Chunk TXT records when they exceed 255 characters. + - Cloudflare: + - Fix `CAA` record updates. + - Check zone subdomains when finding zones + +## [0.16.0] - 2026-04-20 + +This version includes **multiple breaking changes**. If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- [Web UI](https://github.com/stalwartlabs/webui) rewritten from the ground up using the JMAP management API, featuring a refreshed design and addressing 76 enhancement requests and bug fixes. +- [CLI](https://github.com/stalwartlabs/cli) rewritten from the ground up to use the JMAP management API. +- Security enhancements: + - Password strength enforcement using the `zxcvbn` algorithm + - Password expiration, rotation policies and IP address restrictions for user accounts + - App Passwords with limited access (#1609), labels (#2255), IP address restrictions and expiration dates + - API keys with limited access, labels, IP address restrictions and expiration dates + - Auto-ban comments and details about the triggering event (#1321) + - Auto-ban expiration after a configurable time period (#964) +- DNS Management: + - Automatic DNS management of `MX`, `TXT`, `CNAME`, `SRV`, `CAA` and `TLSA` records (#463 #1017 #1419 #2438 #1370 #1406 #1371) + - Automatic update of `TLSA` records when ACME certificates change (#1664) + - RFC2136 `SIG(0)` support (#856) + - Route53 provider support (contributed by @jimmystewpot) + - Google Cloud DNS provider support (contributed by @jimmystewpot) + - Bunny provider support (contributed by @angeloanan) + - Porkbun provider support (contributed by @jeffesquivels) + - DNSimple provider support (contributed by @NelsonVides) + - Spaceship provider support (contributed by @matserix) +- DKIM: + - Automatic DKIM key generation, rotation and DNS management (#368 #961) + - Store DKIM keys in the database (#1264) + - Ignore insecure signatures when verifying DKIM (#1068 #467) +- ACME/TLS: + - `DNS-PERSIST-01` ACME challenge support (#2837) + - Renew certificates on demand, view certificate details (#675 #1162 #2566) + - `CAA` record support (#468) with `accounturi` parameter (#1933) + - `TLSA` records publishing restricted to `3 1 1` and `2 1 1` (#2193) +- OIDC and OAuth: + - JWT token validation without requesting userinfo from the OIDC provider. + - Audience (`aud`) claim (#2603) and scope validation support. + - Groups support (#1448) + - RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients +- LDAP: + - Separate filter for groups (#1841) + - Improve support for OpenLDAP schemas (#760) + - Improve and simplify LDAP settings (#2194 #2174) +- Directory: + - Masked email addresses for enhanced privacy (*Enterprise*) + - Domain aliases (#583) + - E-mail alias descriptions and option to disable aliases (#506) + - Account archiving and un-deletion (#2767) (*Enterprise*) + - Per-domain directory backends (*Enterprise*) +- Account configuration and discovery: + - Automatic Configuration of Email, Calendar, and Contact Server Settings ([draft-mailmaint-uaautoconf-04](https://datatracker.ietf.org/doc/html/draft-eggert-mailmaint-uaautoconf-04)) (#2201) + - MS Autodiscover V2 support (#679) +- Sieve: Allow deactivating scripts without deleting them (#1251). +- Tracing: Enable events only mode (#2276) +- Clustering: + - Automatic cluster node ID generation and management. + - Unified cluster management (#960) + - Outbound MTA role (#1692) + +## Changed +- Replaced REST API with JMAP API (#2262 #959 #1480) +- Removed support for Authenticated Received Chain (ARC) sealing ([learn more](https://mailarchive.ietf.org/arch/msg/dmarc/KvX3-H1SL0Gh3IDl7FuR2hoR87M/)). +- Directory: Removed `smtp`, `imap` and `memory` directory backends. +- Use `aws-lc` for cryptographic operations instead of `ring`. +- Use `rustls-platform-verifier` for TLS certificate verification instead of `webpki` (#247). + +## Fixed +- Directory: + - Cannot remove built-in "admin" role from user once it was assigned (#1467) + - Delete associated records (#963) + - Updated Role permissions not applied (#2038) + - Recreated account cannot log in until server is restarted (#1469) + - Subaddressing does not work for groups (#475) + - New LDAP aliases are rejected (#1318). + - Validate account and group names (#2209) +- MTA: + - RCPT TO stage settings improvements (#2217 #394) + - Relay to IP addresses (#838) + - Duplicate delivery inverted check + - SASL challenge responses include invalid `Go ahead` text +- JMAP: + - Fix `inMailboxOtherThan` query logic. + - Fix `hasAttachment` search field (#2778) +- IMAP: + - Increment argument max length to `8000` bytes + - ACL: Add `RIGHTS` capability (#2762) + - ACL: Fix `ACL SET` permission override. +- WebDAV: + - Return `304` `NOT_MODIFIED` on `If-None-Match` + - Use RFC 2616 instead of RFC 1123 for date formatting + - Fix ACL container/item mismatch in reports. + - CalDAV: Allow organized properties to be present in `PUT` requests if they are equal to the existing ones. + - CalDAV: Enforce cumulative iCalendar instances cap in CalDAV free-busy REPORT handler +- Configuration: Prefix parsing issues (#2495) +- OIDC: JWKS Exposes Symmetric Signing Key +- SQLite: Fix thread pool exhaustion. +- PostgreSQL: Use clean recycling method on connection pool +- Meilisearch: Make `id` sorteable. +- ACME: Fix wrong origin for subdomain updates (#2360) +- Spam filter: Skip invalid messages during training. +- Calendar: Include minutes in localized invite templates (#2828) +- HTTP: Fix `204` CORS preflight responses + +## [0.15.5] - 2026-02-14 + +If you are upgrading from v0.14.x and below, this version includes **multiple breaking changes**. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. +If you are upgrading from v0.15.x, replace the binary and update the webadmin. + +## Added + +## Changed + +## Fixed +- IMAP/JMAP: OOM when `mail-parser` returns cyclical MIME structures [CVE-2026-26312](https://github.com/stalwartlabs/stalwart/security/advisories/GHSA-jm95-876q-c9gw). +- Tracing: Fix tracing indexing when using separate stores. +- JMAP: Fix `upToId` computation in `*/queryChanges`. +- JMAP: Include createdIds when the property is present. +- JMAP: Respect query arguments in `Email/queryChanges`. +- JMAP: Return the correct container/item change id when there are no changes. + +## [0.15.4] - 2026-01-19 + +If you are upgrading from v0.14.x and below, this version includes **multiple breaking changes**. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. +If you are upgrading from v0.15.x, replace the binary and update the webadmin. + +## Added +- IMAP: Map `HEADER SUBJECT/FROM/TO` searches to `SUBJECT/FROM/TO` queries. +- Sieve: Update spam status on user scripts. + +## Changed + +## Fixed +- Search: Return all document ids when no filters are provided. +- Search: Filters not applied when a single message is in the account. +- IMAP: Return `ALREADYEXISTS` code when creating existing mailboxes. +- IMAP: Do not return quota resources if no quota is set. +- JMAP/changes: Update `newState` with last changeId if an invalid fromChangeId is provided. +- JMAP/CalendarIdentity: Do not update invalid calendar identities. +- AI API: Include request error details if available. + +## [0.15.3] - 2025-12-29 + +If you are upgrading from v0.14.x and below, this version includes **multiple breaking changes**. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. +If you are upgrading from v0.15.x, replace the binary and update the webadmin. + +## Added +- Polish locale support (contributed by @mrxkp) (#2480) + +## Changed + +## Fixed +- Meilisearch: Return correct error messages when failing to create indexes (#2574) +- PostgreSQL search: Truncate emails to 650kb for full-text search indexing. +- FoundationDB search: Batch large transactions (#2567). +- Spam filter: Fix training sample size checks +- IMAP: Fix UTF7 encoding with Emojis (contributed by @dojiong) (#2564). + +## [0.15.2] - 2025-12-22 + +If you are upgrading from v0.14.x and below, this version includes **multiple breaking changes**. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. +If you are upgrading from v0.15.x, replace the binary and update the webadmin. + +## Added +- OAuth: Add device authorization endpoint (#2225). + +## Changed +- Antispam: Only auto-learn spam from traps or multiple RBL hits. + +## Fixed +- mySQL search: Use `MEDIUMTEXT` field type for email body and attachments (#2544). +- PostgreSQL search: Truncate large text fields. +- ElasticSearch: Implement pagination (#2551). +- Antispam: Fix `NO_SPACE_IN_FROM` spam tag detection logic (#2372). +- IMAP: Fix shared folder double nesting (test suite credits to @ochnygosch) (#2358). +- JMAP: Use latest `Received` header in JMAP `Email/import` (credits to @apexskier) (#2374). +- JMAP: Return unsorted search results when the index is not ready (#2544). +- LDAP: Lowercase attribute comparison (credits to @pdf) (#2363). +- CLI: Fix same-host JMAP redirection on non-standard ports (#2271). + +## [0.15.1] - 2025-12-17 + +This version includes **multiple breaking changes**. If you are upgrading from v0.14.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed + +## Fixed +- PostgreSQL: Sanitize search index values (#2533) +- Elasticsearch: Ignore `resource_already_exists_exception` errors when creating indexes (#2535) +- Migrate 0.13.x data (#2534) + +## [0.15.0] - 2025-12-16 + +This version includes **multiple breaking changes**. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_15.md) for more information on how to upgrade from previous versions. + +## Added +- Linear spam classifier using FTRL-Proximal and feature/cuckoo hashing. +- Meilisearch store backend implementation (#1482). +- PostgreSQL and mySQL native full-text search support. +- Multiple performance improvements and database access optimizations. +- Encryption-at-rest: Spam training privacy setting. +- Enterprise: Undelete e-mail feature now includes From/Subject/Received information. +- IMAP: Implemented new keywords and mailbox attributes described in [draft-ietf-mailmaint-messageflag-mailboxattribute-13](https://datatracker.ietf.org/doc/html/draft-ietf-mailmaint-messageflag-mailboxattribute-13) + +## Changed +- IMAP: Always return special use flags in responses. + +## Fixed +- JMAP: `FileNode/set` fails to delete files (#2485). +- JMAP: Return error when using `blobId` in JSContact and JSCalendar (#2431). +- Directory: Deletion of list or domain issues (#2415). +- MTA: Headers and body stripped from mail delivery subsystem failure notifications (#2344). +- MTA: Hooks only run if sieve script, milter or rewrite is configured (#2317). +- Autodiscover: Endpoint should be case insensitive (#2440). +- Housekeeper: Panic during DST transition (#2366). +- Import/Export: Fix import/export utility (#1882). +- Enterprise: Remove tenant admin permissions when license is invalid. + +## [0.14.1] - 2025-10-28 + +If you are upgrading from v0.13.4 and below, this version includes **breaking changes** to the internal directory, calendar and contacts. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_14.md) for more information on how to upgrade from previous versions. + +## Added +- Autoconfig for CalDAV, CardDAV and WebDAV (#1937) + +## Changed +- HTTP: Remove HTTP STS `preload` directive. + +## Fixed +- Directory: Keep OTP Auth and AppPasswords unless the remote directory provides new ones (#2319) +- JMAP: Fix `ContactCard/set` and `CalendarEvent/set` destroy methods (#2308). + +## [0.14.0] - 2025-10-22 + +If you are upgrading from v0.13.4 and below, this version includes **breaking changes** to the internal directory, calendar and contacts. Please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_14.md) for more information on how to upgrade from previous versions. + +## Added +- JMAP for Calendars ([draft-ietf-jmap-calendars](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/)). +- JMAP for Contacts ([RFC 9610](https://datatracker.ietf.org/doc/rfc9610/)). +- JMAP for File Storage ([draft-ietf-jmap-filenode](https://datatracker.ietf.org/doc/draft-ietf-jmap-filenode/)). +- JMAP Sharing ([RFC 9670](https://datatracker.ietf.org/doc/rfc9670/)) +- CalDAV: support for `supported-calendar-component-set` (#1893) +- i18n: Greek language support (contributed by @infl00p) +- i18n: Swedish language support (contributed by @purung) + +## Changed +- **Breaking Database Changes** (migrated automatically on first start): + - Internal directory schema changed. + - Calendar and Contacts storage schema changed. + - Sieve scripts storage schema changed. + - Push Subscriptions storage schema changed. +- Replaced `sieve.untrusted.limits.max-scripts` and `jmap.push.max-total` with `object-quota.*` settings. +- Cluster node roles now allow sharding. + + +## Fixed +- Push Subscription: Clean-up of expired subscriptions and cluster notification of changes (#1248) +- CalDAV: Per-user CalDAV properties (#2058) + +## [0.13.4] - 2025-09-30 + +If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added + +## Changed +- JMAP: Protocol layer rewrite for zero-copy deserialization and architectural improvements. + +## Fixed +- IMAP: Unbounded memory allocation in request parser ([CVE-2025-61600 ](https://github.com/stalwartlabs/stalwart/security/advisories/GHSA-8jqj-qj5p-v5rr)). +- IMAP: Wrong permission checked for GETACL. +- JMAP: References to previous method fail when there are no results (#1507). +- JMAP: Enforce quota checks on `Blob/copy`. +- JMAP: `Mailbox/get` fails without `accountId` argument (#1936). +- JMAP: Do not return `invalidProperties` when email update doesn't contain changes (#1139) +- iTIP: Include date properties in `REPLY` (#2102). +- OIDC: Do not set `username` field if it is the same as the `email` field. +- Telemetry: Fix `calculateMetrics` housekeeper task (#2155). +- Directory: Always use `rsplit` to extract the domain part from email addresses. + +## [0.13.3] - 2025-09-10 + +If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- CLI: Health checks (contributed by @Codekloeppler) + +## Changed +- WebDAV: Assisted discovery v2 + +## Fixed +- iTIP: Do not send a REPLY when deleting an event that was not accepted. +- iTIP: Include event details in REPLY messages (#2102). +- iTIP: Add organizer to iMIP replies if missing to deal with MS Exchange 2010 bug. +- OIDC: Do not overwrite locally defined aliases (#2065). +- HTTP: Scan ban should only be triggered by HTTP parse errors. +- HTTP: Skip scanner fail2ban checks when the proxy client IP can't be parsed (#2121). +- JMAP: Do not allow roles to be removed from system mailboxes (#1977). +- JMAP WS: Fix panic when using invalid server url. +- SMTP: Do no send `EHLO` twice when `STARTTLS` is unavailable (#2050). +- IMAP: Allow `ENABLE UTF8` in IMAPrev1. +- IMAP: Include `administer` permission in ACL responses. +- IMAP: Add owner rights to ACL get responses. +- IMAP: Do not auto-train Bayes when moving messages from Junk to Trash. +- IMAP/ManageSieve: Increase maximum quoted argument size (#2039). +- CalDAV: Limit recurrence expansions in calendar reports ([CVE-2025-59045](https://github.com/stalwartlabs/stalwart/security/advisories/GHSA-xv4r-q6gr-6pfg)). +- WebDAV: Do not fix percent encoding on WebDAV FS (#2036). + +## [0.13.2] - 2025-07-28 + +If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- ACME: DeSEC cloud DNS provider support (contributed by @Tyr3al). +- ACME: OVH cloud DNS provider support (contributed by @srachner). +- CalDAV Scheduling: Catalan language support (contributed by @jolupa) (#1873). +- MTA: Allow to send e-mails as group, while member of that group (#485). +- OIDC: Allow local access tokens to be used with third-party OIDC backends (#1311 stalwartlabs/webadmin#52). + +## Changed +- IMAP: Return `OK` when moving/copying non-existent messages (#670). +- IMAP: Copy flags when copying/moving messages between accounts. + +## Fixed +- MTA: Do not convert e-mail local parts to lowercase (#1916). +- Sieve: `fileinto` should override spam filter (#1917). +- JMAP: Incorrect `accountId` used in email set and import methods (#1777). +- WebDAV: Always return `MULTISTATUS` when calendar-query yields no results. +- LDAP: Only set account name if not returned in LDAP query (#1471). +- Enterprise: Invalidate logo cache when changes are made (#1856). +- Enterprise: Fix tenant quota update API. + +## [0.13.1] - 2025-07-16 + +If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- ACME: DigitalOcean cloud DNS provider support (#1667). + +## Changed + +## Fixed +- Migration: Old queue events not deleted causing high CPU usage in some deployments (#1833). +- MTA: `mta-sts` setting parsing issue (#1830). +- JMAP: `sortOrder` should not be null (#1831). +- Allow invalid TOML when parsing database settings (#1822). + +## [0.13.0] - 2025-07-15 + +If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- MTA queue enhancements (#1246 #1035 #457). +- Danish locale support (contributed by @Fadil2k) (#1772). +- DKIM support for `stalwart-cli` (contributed by @rmsc) (#1804). + +## Changed +- Invalidate access token caches in a cluster using pub/sub (#1741). +- Allow updating secrets for all directory types. + +## Fixed +- WebDAV: Return all shared resources in `calendar-home-set` and `addressbook-home-set` (#1796). +- WebDAV ACL: Fix write permission and `multiget` reports (#1768). +- CalDAV Scheduling: Include `DTSTART`/`DTEND` properties in iMIP `CANCEL` messages (#1775). +- HTTP: Do not include `WWW-Authenticate` headers in API responses (#1795). +- API: Allow API keys to be used with external directories (#1815). +- IMAP: Fix issue creating subfolders under INBOX for group shared folder (#1817). +- IMAP: Custom Name for Shared Folders ignored (#1620). +- LDAP: `local` placeholder should return username when its not an email address (#1784). + +## [0.12.5] - 2025-06-25 + +If you are upgrading from v0.11.x, this version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- Calendar Scheduling Extensions to CalDAV - RFC6368 (#1514) +- Calendar E-Mail Notifications (#1514) +- Limited i18n support for calendaring events. +- Assisted CalDAV/CardDAV shared resource discovery (#1691). + +## Changed +- JMAP: Allow unauthenticated access to JMAP session object. + +## Fixed +- WebDAV: Return NOTFOUND error instead of MULTISTATUS on empty PROPFIND responses (#1657). +- WebDAV: Update account name when refreshing DAV caches (#1694). +- JMAP: Do not include email address in identity names (#1688). +- IMAP: Normalize `INBOX` name when creating/renaming folders (#1636). +- LDAP: Request `secret-changed` attribute in LDAP queries (#1409). +- Branding: Unable to change logos (#1652). +- Antispam: Skip `card-is-ham` override when sender does not pass DMARC (#1648). +- FoundationDB: Renew old/expired FDB read transactions after the `1007` error code is received rather than estimating expiration time. + +## [0.12.4] - 2025-06-03 + +If you are upgrading from v0.11.x, this version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- LDAP authentication enhancements (#1269 #1471 #795 #1496). +- MTA: Return Queue IDs during message acceptance (#927). + +## Changed +- LDAP: `bind.auth.enable` is now `bind.auth.method`, read the updated [LDAP documentation](https://stalw.art/docs/auth/backend/ldap) for more information. + +## Fixed +- DNS: `hickory-resolver` bug hitting 100% CPU usage when resolving DNSSEC records. +- IMAP: Return the message UID in the destination mailbox if the message already exists (#1201). +- MTA: TLS reports being issued for sent TLS reports (infinite loop) (#1301). +- WebDAV: Return `CTag` on `/dav/cal/account` resources to force iOS synchronize. +- CardDAV: Strict vCard parsing (#1607). +- WebDAV: Dead property updates (#1611). +- WebDAV: Use last change id in `CTag`. + +## [0.12.3] - 2025-05-30 + +If you are upgrading from v0.11.x, this version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- Store vanished IMAP UIDs and WebDAV paths in the changelog. + +## Changed + +## Fixed +- XML `CDATA` injection (credits to @andreymal for the report). +- Macro references are replaced with their content when writing config file (#1595). +- Double nested CalDAV and CardDAV property tags (#1591). +- Allow empty properties in PROPPATCH requests (#1580). + +## [0.12.2] - 2025-05-27 + +If you are upgrading from v0.11.x, this version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added +- CardDAV: Legacy vCard 2.1 and 3.0 serialization support. +- WebDAV: Add SRV Records to help DAV autodiscovery (closes #1565). + +## Changed + +## Fixed +- Report list attempts to deserialize empty values (#1562) +- Refresh expired FoundationDB transactions while retrieving large blobs (#1555). + +## [0.12.1] - 2025-05-26 + +If you are upgrading from v0.11.x, this version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +## Added + +## Changed + +## Fixed +- Migration tool to generate the correct next id (#1561). +- Failed to parse setting dav.lock.max-timeout (closes #1559). +- Failed to build OpenTelemetry span exporter: no http client specified (#1571). + +## [0.12.0] - 2025-05-26 + +This version includes **breaking changes** to the database layout and requires a migration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- [Collaboration](https://stalw.art/docs/collaboration/overview) features including [Calendars over CalDAV](https://stalw.art/docs/http/calendar/), [Contacts over CardDAV](https://stalw.art/docs/http/contact/) and [File Storage over WebDAV](https://stalw.art/docs/http/file-storage/). +- Peer-to-peer [cluster coordination](https://stalw.art/docs/cluster/coordination/overview) or with Apache Kafka, Redpanda, NATS or Redis. +- Incremental caching of emails, calendars, contacts and file metadata. +- Zero-copy deserialization. +- Train spam messages as ham when the sender is in the user's address book. +- `XOAUTH2` SASL mechanism support (#1194 #1369). +- Support for RFC9698, the `JMAPACCESS` Extension for IMAP. +- Search index for accounts and other principals (#1368). +- Add `description` property to OIDC ID token (#1234). + +### Changed +- Deprecated gossip protocol in favor of the new [coordinator](https://stalw.art/docs/cluster/coordination/overview) options. +- Renamed Git repository from `stalwartlabs/mail-server` to `stalwartlabs/stalwart` and the Docker image from `stalwartlabs/mail-server` to `stalwartlabs/stalwart`. +- Renamed multiple settings: + - `server.http.*` to `http.*`. + - `jmap.folders.*` to `email.folders.*`. + - `jmap.account.purge.frequency` to `account.purge.frequency`. + - `jmap.email.auto-expunge` to `email.auto-expunge`. + - `jmap.protocol.changes.max-history` to `changes.max-history`. + - `storage.encryption.*` to `email.encryption.*`. +- Deprecated `lookup.default.*` settings in favor of `server.hostname` and `report.domain`. v0.11 and before supported both, v0.12 will only support the new settings. + +### Fixed +- Allow undiscovered UIDs to be used in IMAP `COPY`/`MOVE` operations (#1201). +- Refuse loopback SMTP delivery (#1377). +- Hide the current server version (#1435). +- Use the newest `X-Spam-Status` Header (#1308). +- MySQL Driver error: Transactions couldn't be nested (#1271). +- Spawn a delivery thread for `EmailSubmission/set` requests (#1540). +- ACME: Don't restrict challenge types (#1522). +- Autoconfig: return `%EMAILADDRESS%` if no e-mail address is provided (#1537). + +## [0.11.8] - 2025-04-30 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added + +### Changed + +### Fixed +- Allow undiscovered UIDs to be used in `COPY`/`MOVE` operations (#1201). + +## [0.11.7] - 2025-03-23 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- LDAP attribute to indicate password change (#1156). + +### Changed +- Lazy DKIM key parsing (#1211). +- Enable `edns0` for system resolver by default (#1282). +- Bump FoundationDB to `7.3`. + +### Fixed +- Fix incorrect `UIDNEXT` when mailbox is empty (#1201). +- Sender variable not set when evaluating `must-match-sender` (#1294). +- Do not panic when mailboxId is not found (#1293). +- Prioritize local over span keys when serializing webhook payloads (#1250). +- Allow TLS name mismatch as per RFC7671 Section 5.1. +- Try with implicit MX when no MX records are found. +- SQL `secrets` directory query. + +## [0.11.5] - 2025-02-01 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added + +### Changed +- Open source third party OIDC support. + +### Fixed +- Case insensitive flag parsing (#1138). +- BCC not removed from JMAP EmailSubmissions (#618). +- Group pipelined IMAP FETCH and STATUS operations (#1096). + +## [0.11.4] - 2025-01-29 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- RFC 9208 - IMAP QUOTA Extension (#484). + +### Changed +- `session.throttle.*` is now `queue.limiter.inbound.*`. +- `queue.throttle.*` is now `queue.limiter.outbound.*`. +- Changed DNSBL error level to debug (#1107). + +### Fixed +- Creating a mailbox in a shared folder results in wrong hierarchy (#1128). +- IMAP LIST-STATUS (RFC 5819) returns items in wrong order (#1129). +- Avoid non-RFC SMTP status codes (#1109). +- Do not DNSBL check invalid domains (#1107). +- Sieve message flag parser (#1059). +- Sieve script import case insensitivity (#962). +- `mailto:` parsing in HTMLs. + +## [0.11.2] - 2025-01-17 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Automatic revoking of access tokens when secrets, permissions, ACLs or group memberships change (#649). +- Increased concurrency for local message delivery (configurable via `queue.threads.local`). +- Cluster node roles. +- `config_get` expression function. + +### Changed +- `queue.outbound.concurrency` is now `queue.threads.remote`. +- `lookup.default.hostname` is now `server.hostname`. +- `lookup.default.domain` is now `report.domain`. + +### Fixed +- Distributed locking issues in non-Redis stores (#1066). +- S3 incorrect backoff wait time after failures. +- Panic parsing broken HTMLs. +- Update CLI response serializer to v0.11.x (#1082). +- Histogram bucket counts (#1079). +- Do not rate limit trusted IPs (#1078). +- Avoid double encrypting PGP parts encoded as plain text (#1083). +- Return empty SASL challenge rather than "" (#1064). + +## [0.11.0] - 2025-01-06 + +This version includes breaking changes to the configuration file, please read [UPGRADING.md](UPGRADING.md) for details. +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Spam filter rewritten in Rust for a significant performance improvement. +- Multiple spam filter improvements (#947) such as training spam/ham when moving between inbox and spam folders (#819). +- Improved distributed locking and handling of large distributed SMTP queues. +- ASN and GeoIP lookups. +- Bulk operations REST endpoints (#925). +- Faster S3-FIFO caching. +- Support adding the `Delivered-To` header (#916). +- Semver compatibility checks when upgrading (#844). +- Sharded In-Memory Store. + +### Changed +- Removed authentication rate limit (no longer necessary since there is fail2ban). +- Pipes have been deprecated in favor of MTA hooks. + +### Fixed +- OpenPGP EOF error (#1024). +- Convert emails obtained from external directories to lowercase (#1004). +- LDAP: Support both name and email fields to be mapped to the same attribute. +- Admin role can't be assigned if an account with the same name exists. +- Fix macro detection in DNS record generation (#978). +- Use host FQDN in install script (#1003). + +## [0.10.7] - 2024-12-04 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Delivery and DMARC Troubleshooting (#420). +- Support for external email addresses on mailing lists (#152). +- Azure blob storage support. + +### Changed + +### Fixed +- Some mails can't be moved out of the junk folder (#670). +- Out of bound index error on Sieve script (#941). +- Missing `User-Agent` header for ACME (#937). +- UTF8 support in IMAP4rev1 (#948). +- Account alias owner leak on autodiscover. +- Include all events in OTEL traces + Include spanId in webhooks. +- Implement `todo!()` causing panic on concurrency and rate limits. +- Mark SQL store as active if used as a telemetry store. +- Discard empty form submissions. + +## [0.10.6] - 2024-11-07 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Enterprise license automatic renewals before expiration (disabled by default). +- Allow to LDAP search using bind dn instead of auth bind connection when bind auth is enabled (#873) + +### Changed + +### Fixed +- Include `preferred_username` and `email` in OIDC `id_token`. +- Verify roles and permissions when creating or modifying accounts (#874) + +## [0.10.5] - 2024-10-15 + +To upgrade replace the `stalwart-mail` binary. + +### Added +- Data store CLI. + +### Changed + +### Fixed +- Tokenizer performance issue (#863) +- Incorrect AI model endpoint setting. + +## [0.10.4] - 2024-10-08 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Detect and ban port scanners as well as other forms of abuse (#820). +- ACME External Account Binding support (#379). + +### Changed +- The settings `server.fail2ban.*` have been moved to `server.auto-ban.*`. +- The event `security.brute-force-ban` is now `security.abuse-ban`. + +### Fixed +- Do not send SPF failures reports to local domains. +- Allow `nonce` in OAuth code requests. +- Warn when there are errors migrating domains rather than aborting migration. + +## [0.10.3] - 2024-10-07 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. Enterprise users wishing to use the new LLM-powered spam filter should also upgrade the spam filter rules. + +### Added +- AI-powered Spam filtering and Sieve scripting (Enterprise feature). + +### Changed +- The untrusted Sieve interpreter now has the `vnd.stalwart.expressions` extension enabled by default. This allows Sieve users to use the `eval` function to evaluate expressions in their scripts. If you would like to disable this extension, you can do so by adding `vnd.stalwart.expressions` to `sieve.untrusted.disabled-capabilities`. + +### Fixed +- S3-compatible backends: Retry on `5xx` errors. +- OIDC: Include `nonce` parameter in `id_token` response. + +## [0.10.2] - 2024-10-02 + +To upgrade first upgrade the webadmin and then replace the `stalwart-mail` binary. If you read these instructions too late, you can upgrade to the latest web-admin using `curl -k -u admin:yourpass https://yourserver/api/update/webadmin`. + +### Added +- OpenID Connect server (#298). +- OpenID Connect backend support (Enterprise feature). +- OpenID Connect Dynamic Client Registration (#4) +- OAuth 2.0 Dynamic Client Registration Protocol ([RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)) (#136) +- OAuth 2.0 Token Introspection ([RFC7662](https://datatracker.ietf.org/doc/html/rfc7662)). +- Contact form submission handling. +- `webadmin.path` setting to override unpack directory (#792). + +### Changed + +### Fixed +- Missing `LIST-STATUS` from RFC5819 in IMAP capability responses (#816). +- Do not allow tenant domains to be deleted if they have members (#812). +- Tenant principal limits (#810). + +## [0.10.1] - 2024-09-26 + +To upgrade replace the `stalwart-mail` binary. + +### Added +- `OAUTHBEARER` SASL support in all services (#627). + +### Changed + +### Fixed +- Fixed `migrate_directory` range scan (#784). + +## [0.10.0] - 2024-09-21 + +This version includes breaking changes to how accounts are stored. Please read [UPGRADING.md](UPGRADING.md) for details. + +### Added +- Multi-tenancy (Enterprise feature). +- Branding (Enterprise feature). +- Roles and permissions. +- Full-text search re-indexing. +- Partial database backups (#497). + +### Changed + +### Fixed +- IMAP `IDLE` support for command pipelining, aka the Apple Mail iOS 18 bug (#765). +- Case insensitive INBOX `fileinto` (#763). +- Properly decode undelete account name (#761). + +## [0.9.4] - 2024-09-09 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Support for global Sieve scripts that can be used by users to filter their incoming mail. +- Allow localhost to override HTTP access controls to prevent lockouts. + +### Changed +- Sieve runtime error default log level is now `debug`. + +### Fixed +- Ignore INBOX case on Sieve's `fileinto` (#725) +- Local keys parsing and retrieval issues. +- Lookup reload does not include database settings. +- Account count is incorrect. + +## [0.9.3] - 2024-08-29 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Dashboard (Enterprise feature) +- Alerts (Enterprise feature) +- SYN Flood (session "loitering") attack protection (#482) +- Mailbox brute force protection (#688) +- Mail from is allowed (`session.mail.is-allowed`) expression (#609) + +### Changed +- `authentication.fail2ban` setting renamed to `server.fail2ban.authentication`. +- Added elapsed times to message filtering events. + +### Fixed +- Include queueId in MTA Hooks (#708) +- Do not insert empty keywords in FTS index. + +## [0.9.2] - 2024-08-21 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Message delivery history (Enterprise feature) +- Live tracing and logging (Enterprise feature) +- SQL Read Replicas (Enterprise feature) +- Distributed S3 Blob Store (Enterprise feature) + +### Changed + +### Fixed +- Autodiscover request parser issues. +- Do not create tables when using SQL as an external directory (fixes #291) +- Do not hardcode logger id (fixes #348) +- Include `Forwarded-For IP` address in `http.request-url` event (fixes #682) + +## [0.9.1] - 2024-08-08 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Metrics support (closes #478) + - OpenTelemetry Push Exporter + - Prometheus Pull Exporter (closes #275) +- HTTP endpoint access controls (closes #266 #329 #542) +- Add `options` setting to PostgreSQL driver (closes #662) +- Add `isActive` property to defaults on Sieve/get JMAP method (closes #624) + +### Changed +- Perform `must-match-sender` checks after sender rewriting (closes #394) +- Only perform email ingest duplicate check on the target mailbox (closes #632) + +### Fixed +- Properly parse `Forwarded` and `X-Forwarded-For` headers (fixes #669) +- Resolve DKIM macros when generating DNS records (fixes #666) +- Fixed `is_local_domain` Sieve function (fixes #622) + +## [0.9.0] - 2024-08-01 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. This version includes breaking changes to the Webhooks configuration and produces a slightly different log output, read [UPGRADING.md](UPGRADING.md) for details. + +### Added +- Improved and faster tracing and logging. +- Customizable event logging levels. + +### Changed + +### Fixed +- ManageSieve: Return capabilities after successful `STARTTLS` +- Do not provide `{auth_authen}` Milter macro unless the user is authenticated + +## [0.8.5] - 2024-07-07 + +To upgrade replace the `stalwart-mail` binary. + +### Added +- Restore deleted e-mails (Enterprise Edition only) +- Kubernetes (K8S) livenessProbe and readinessProbe endpoints. + +### Changed +- Avoid sending reports for DMARC/delivery reports (#173) + +### Fixed +- Refresh old FoundationDB read transactions (#520) +- Subscribing shared mailboxes doesn't work (#251) + +## [0.8.4] - 2024-07-03 + +To upgrade replace the `stalwart-mail` binary. + +### Added + +### Changed + +### Fixed +- Fix TOTP validation order. +- Increase Jemalloc page size on armv7 builds. + +## [0.8.3] - 2024-07-01 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +### Added +- Two-factor authentication with Time-based One-Time Passwords (#436) +- Application passwords (#479). +- Option to disable user accounts. + +### Changed +- DANE success on EndEntity match regardless of TrustAnchor validation. + +### Fixed +- Fix ManageSieve GETSCRIPT response: Add missing CRLF (#563) +- Do not return CAPABILITIES after ManageSieve AUTH=PLAIN SASL exchange (#548) +- POP3 QUIT must write a response (#568) + +## [0.8.2] - 2024-06-22 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin and spam filter versions. + +### Added +- Webhooks support (#480) +- MTA Hooks (like milter but over HTTP) +- Manually train and test spam classifier (#473 #264 #257 #471) +- Allow configuring default mailbox names, roles and subscriptions (#125 #290 #458 #498) +- Include `robots.txt` (#542) + +### Changed +- Milter support on all SMTP stages (#183) +- Do not announce `STARTTLS` if the listener does not support it. + +### Fixed +- Incoming reports stored in the wrong subspace (#543) +- Return `OK` after a successful ManageSieve SASL authentication flow (#187) +- Case-insensitive search in settings API (#487) +- Fix `session.rcpt.script` default variable name (#502) + +## [0.8.1] - 2024-05-23 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin and spam filter versions. + +### Added +- POP3 support. +- DKIM signature length exploit protection. +- Faster email deletion. +- Junk/Trash folder auto-expunge and changelog auto-expiry (#403) +- IP allowlists. +- HTTP Strict Transport Security option. +- Add TLS Reporting DNS entry (#464). + +### Changed +- Use separate account for master user. +- Include server hostname in SMTP greetings (#448). + +### Fixed +- IP addresses trigger `R_SUSPICIOUS_URL` false positive (#461 #419). +- JMAP identities should not return null signatures. +- Include authentication headers and check queue quotas on Sieve message forwards. +- ARC seal using just one signature. +- Remove technical subdomains from MTA-STS policies and TLS records (#429). + +## [0.8.0] - 2024-05-13 + +This version uses a different database layout which is incompatible with previous versions. Please read the [UPGRADING.md](UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- Clustering support with node auto-discovery and partition-tolerant failure detection. +- Autoconfig and MS Autodiscover support (#336) +- New variables `retry_num`, `notify_num`, `last_error` add `last_status` available in queue expressions. +- Performance improvements, in particular for FoundationDB. +- Improved full-text indexing with lower disk space usage. +- MTA-STS policy management. +- TLSA Records generation for DANE (#397) +- Queued message visualization from the web-admin. +- Master user support. + +### Changed +- Make `certificate.*` local keys by default. +- Removed `server.run-as.*` settings. +- Add Microsoft Office Macro types to bad mime types (#391) + +### Fixed +- mySQL TLS support (#415) +- Resolve file macros after dropping root privileges. +- Updated order of SPF Records (#395). +- Avoid duplicate accountIds when using case insensitive external directories (#399) +- `authenticated_as` variable not usable for must-match-sender (#372) +- Remove `StandardOutput`, `StandardError` in service (#390) +- SMTP `AUTH=LOGIN` compatibility issues with Microsoft Outlook (#400) + +## [0.7.3] - 2024-05-01 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin version. + +### Added +- Full database export and import functionality +- Add --help and --version command line arguments (#365) +- Allow catch-all addresses when validating must match sender + +### Changed +- Add `groupOfUniqueNames` to the list of LDAP object classes + +### Fixed +- Trim spaces in DNS-01 ACME secrets (#382) +- Allow only one journald tracer (#375) +- `authenticated_as` variable not usable for must-match-sender (#372) +- Fixed `BOGUS_ENCRYPTED_AND_TEXT` spam filter rule +- Fixed parsing of IPv6 DNS server addresses + +## [0.7.2] - 2024-04-17 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin version. + +### Added +- Support for `DNS-01` and `HTTP-01` ACME challenges (#226) +- Configurable external resources (#355) + +### Changed + +### Fixed +- Startup failure when Elasticsearch is down/starting up (#334) +- URL decode path elements in REST API. + +## [0.7.1] - 2024-04-12 + +To upgrade replace the `stalwart-mail` binary. + +### Added +- Make initial admin password configurable via env (#311) + +### Changed +- WebAdmin download URL. + +### Fixed +- Remove ASN.1 DER structure from DKIM ED25519 public keys. +- Filter out invalid timestamps on log entries. + +## [0.7.0] - 2024-04-09 + +This version uses a different database layout and introduces multiple breaking changes in the configuration files. Please read the [UPGRADING.md](UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- Web-based administration interface. +- REST API for management and configuration. +- Automatic RSA and ED25519 DKIM key generation. +- Support for compressing binaries in the blob store (#227). +- Improved performance accessing IMAP mailboxes with a large number of messages. +- Support for custom DNS resolvers. +- Support for multiple loggers with different levels and outputs. + +### Changed + +### Fixed +- Store quotas as `u64` rather than `u32`. +- Second IDLE connections disconnects the first one (#280). +- Use relaxed DNS parsing, allowing underscores in DNS labels (#172). +- Escape regexes within `matches()` expressions (#155). +- ManageSieve LOGOUT should reply with `OK` instead of `BYE`. + +## [0.6.0] - 2024-02-14 + +This version introduces breaking changes in the configuration file. Please read the [UPGRADING.md](UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- Distributed and fault-tolerant SMTP message queues. +- Distributed rate-limiting and fail2ban. +- Expressions in configuration files. + +### Changed + +### Fixed +- Do not include `STATUS` in IMAP `NOOP` responses (#234). +- Allow multiple SMTP `HELO` commands. +- Redirect OAuth using a `301` instead of a `307` code. + +## [0.5.3] - 2024-01-14 + +Please read the [UPGRADING.md](UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- Built-in [fail2ban](https://stalw.art/docs/server/fail2ban) and IP address/mask blocking (#164). +- CLI: Read URL and credentials from environment variables (#88). +- mySQL driver: Add `max-allowed-packet` setting (#201). + +### Changed +- Unified storage settings for all services (read the [UPGRADING.md](UPGRADING.md) for details) + +### Fixed +- IMAP retrieval of auto-encrypted emails (#203). +- mySQL driver: Parse `timeout.wait` property as duration (#202). +- `X-Forwarded-For` header on JMAP Rate-Limit does not work (#208). +- Use timeouts in install script (#138). + +## [0.5.2] - 2024-01-07 + +Please read the [UPGRADING.md](UPGRADING.md) file for more information on how to upgrade from previous versions. + +### Added +- [ACME](https://stalw.art/docs/server/tls/acme) support for automatic TLS certificate generation and renewal (#160). +- TLS certificate [hot-reloading](https://stalw.art/docs/management/database/maintenance#tls-certificate-reloading). +- [HAProxy protocol](https://stalw.art/docs/server/proxy) support (#36). + +### Changed + +### Fixed +- IMAP command `SEARCH ` is using UIDs rather than sequence numbers. +- IMAP responses to `APPEND` and `EXPUNGE` should include `HIGHESTMODSEQ` when `CONDSTORE` is enabled. + +## [0.5.1] - 2024-01-02 + +### Added +- SMTP smuggling protection: Sanitization of outgoing messages that do not use `CRLF` as line endings. +- SMTP sender validation for authenticated users: Added the `session.auth.must-match-sender` configuration option to enforce that the sender address used in the `MAIL FROM` command matches the authenticated user or any of their associated e-mail addresses. + +### Changed + +### Fixed +- Invalid DKIM signatures for empty message bodies. +- IMAP command `SEARCH BEFORE` is not properly parsed. +- IMAP command `FETCH` fails to parse single arguments without parentheses. +- IMAP command `ENABLE QRESYNC` should also enable `CONDSTORE` extension. +- IMAP response to `ENABLE` command does not include enabled capabilities list. +- IMAP response to `FETCH ENVELOPE` should not return `NIL` when the `From` header is missing. + +## [0.5.0] - 2023-12-27 + +This version requires a database migration and introduces breaking changes in the configuration file. Please read the [UPGRADING.md](UPGRADING.md) file for more information. + +### Added +- Performance enhancements: + - Messages are parsed only once and their offsets stored in the database, which avoids having to parse them on every `FETCH` request. + - Background full-text indexing. + - Optimization of database access functions. +- Storage layer improvements: + - In addition to `FoundationDB` and `SQLite`, now it is also possible to use `RocksDB`, `PostgreSQL` and `mySQL` as a storage backend. + - Blobs can now be stored in any of the supported data stores, it is no longer limited to the file system or S3/MinIO. + - Full-text searching con now be done internally or delegated to `ElasticSearch`. + - Spam databases can now be stored in any of the supported data stores or `Redis`. It is no longer necessary to have an SQL server to use the spam filter. +- Internal directory: + - User account, groups and mailing lists can now be managed directly from Stalwart without the need of an external LDAP or SQL directory. + - HTTP API to manage users, groups, domains and mailing lists. +- IMAP4rev1 `Recent` flag support, which improves compatibility with old IMAP clients. +- LDAP bind authentication, to support some LDAP servers such as `lldap` which do not expose the userPassword attribute. +- Messages marked a spam by the spam filter can now be automatically moved to the account's `Junk Mail` folder. +- Automatic creation of JMAP identities. + +### Changed + +### Fixed +- Spamhaus DNSBL return codes. +- CLI tool reports authentication errors rather than a parsing error. + +## [0.4.2] - 2023-11-01 + +### Added +- JMAP for Quotas support ([RFC9425](https://www.rfc-editor.org/rfc/rfc9425.html)) +- JMAP Blob Management Extension support ([RFC9404](https://www.rfc-editor.org/rfc/rfc9404.html)) +- Spam Filter - Empty header rules. + +### Changed + +### Fixed +- Daylight savings time support for crontabs. +- JMAP `oldState` doesn’t reflect in `*/changes` (#56) + +## [0.4.1] - 2023-10-26 + +### Added + +### Changed + +### Fixed +- Dockerfile entrypoint script. +- `bayes_is_balanced` function. + +## [0.4.0] - 2023-10-25 + +This version introduces some breaking changes in the configuration file. Please read the [UPGRADING.md](UPGRADING.md) file for more information. + +### Added +- Built-in Spam and Phishing filter. +- Scheduled queries on some directory types. +- In-memory maps and lists containing glob or regex patterns. +- Remote retrieval of in-memory list/maps with fallback mechanisms. +- Macros and support for including files from TOML config files. + +### Changed +- `config.toml` is now split in multiple TOML files for better organization. +- **BREAKING:** Configuration key prefix `jmap.sieve` (JMAP Sieve Interpreter) has been renamed to `sieve.untrusted`. +- **BREAKING:** Configuration key prefix `sieve` (SMTP Sieve Interpreter) has been renamed to `sieve.trusted`. + +### Fixed + +## [0.3.10] - 2023-10-17 + +### Added +- Option to allow invalid certificates on outbound SMTP connections. +- Option to disable ansi colors on `stdout`. + +### Changed +- SMTP reject messages are now logged as `info` rather than `debug`. + +### Fixed + +## [0.3.9] - 2023-10-07 + +### Added +- Support for reading environment variables from the configuration file using the `!ENV_VAR_NAME` special keyword. +- Option to disable ANSI color codes in logs. + +### Changed +- Querying directories from a Sieve script is now done using the `query()` method from `eval`. Your scripts will need to be updated, please refer to the [new syntax](https://stalw.art/docs/smtp/filter/sieve#directory-queries). + +### Fixed +- IPrev lookups of IPv4 mapped to IPv6 addresses. + +## [0.3.8] - 2023-09-19 + +### Added +- Journal logging support +- IMAP support for UTF8 APPEND + +### Changed +- Replaced `rpgp` with `sequoia-pgp` due to rpgp bug. + +### Fixed +- Fix: IMAP folders that contain a & can't be used (#90) +- Fix: Ignore empty lines in IMAP requests + +## [0.3.7] - 2023-09-05 + +### Added +- Option to disable IMAP All Messages folder (#68). +- Option to allow unencrypted SMTP AUTH (#72) +- Support for `rcpt-domain` key in `rcpt.relay` SMTP rule evaluation. + +### Changed + +### Fixed +- SMTP strategy `Ipv6thenIpv4` returns only IPv6 addresses (#70) +- Invalid IMAP `FETCH` responses for non-UTF-8 messages (#70) +- Allow `STATUS` and `ACL` IMAP operations on virtual mailboxes. +- IMAP `SELECT QRESYNC` without specifying a UID causes panic (#67) +- Milter `DATA` command is sent after headers which causes ClamAV to hang. +- Sieve `redirect` of unmodified messages does not work. + +## [0.3.6] - 2023-08-29 + +### Added +- Arithmetic and logical expression evaluation in Sieve scripts. +- Support for storing query results in Sieve variables. +- Results of SPF, DKIM, ARC, DMARC and IPREV checks available as environment variables in Sieve scripts. +- Configurable protocol flags for Milter filters. +- Fall-back to plain text when `STARTTLS` fails and `starttls` is set to `optional`. + +### Changed + +### Fixed +- Do not panic when `hash = 0` in reports. (#60) +- JMAP Session resource returns `EmailSubmission` capabilities using arrays rather than objects. +- ManageSieve `PUTSCRIPT` should replace existing scripts. + +## [0.3.5] - 2023-08-18 + +### Added +- TCP listener option `nodelay`. + +### Changed + +### Fixed +- SMTP: Allow disabling `STARTTLS`. +- JMAP: Support for `OPTIONS` HTTP method. + +## [0.3.4] - 2023-08-09 + +### Added +- JMAP: Support for setting custom HTTP response headers (#52) + +### Changed + +### Fixed +- SMTP: Missing envelope keys in rewrite rules (#25) +- SMTP: Remove CRLF from Milter headers +- JMAP/IMAP: Successful authentication requests should not count when rate limiting +- IMAP: Case insensitive Inbox selection +- IMAP: Automatically create Inbox for group accounts + +## [0.3.3] - 2023-08-02 + +### Added +- Encryption at rest with **S/MIME** or **OpenPGP**. +- Support for referencing context variables from dynamic values. + +### Changed + +### Fixed +- Support for PKCS8v1 ED25519 keys (#20). +- Automatic retry for import/export blob downloads (#14) + +## [0.3.2] - 2023-07-28 + +### Added +- Sender and recipient address rewriting using regular expressions and sieve scripts. +- Subaddressing and catch-all addresses using regular expressions (#10). +- Dynamic variables in SMTP rules. + +### Changed +- Added CLI to Docker container (#19). + +### Fixed +- Workaround for a bug in `sqlx` that caused SQL time-outs (#15). +- Support for ED25519 certificates in PEM files (#20). +- Better handling of concurrent IMAP UID map modifications (#17). +- LDAP domain lookups from SMTP rules. + +## [0.3.1] - 2023-07-22 + +### Added +- Milter filter support. +- Match IP address type using /0 mask (#16). + +### Changed + +### Fixed +- Support for OpenLDAP password hashing schemes between curly brackets (#8). +- Add CA certificates to Docker runtime (#5). + +## [0.3.0] - 2023-07-16 + +### Added +- **LDAP** and **SQL** authentication. +- **subaddressing** and **catch-all** addresses. +- **S3-compatible** storage. + +### Changed +- Merged the `stalwart-jmap`, `stalwart-imap` and `stalwart-smtp` repositories into + `stalwart-mail`. +- Removed clustering module and replaced it with a **FoundationDB** backend option. +- Integrated Stalwart SMTP into Stalwart JMAP. +- Rewritten JMAP protocol parser. +- Rewritten store backend. +- Rewritten IMAP server to have direct access to the message store (no more IMAP proxy). +- Replaced `actix` with `hyper`. + +### Fixed + diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..554a1af --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +get.stalw.art diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5081afa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing + +Thank you for your interest in contributing to Stalwart. We appreciate the support and enthusiasm of the open-source community. To keep the project maintainable and the review process sustainable, contributions are subject to the policies described below. Please read them in full before opening a pull request. + +## Vouched Contributors Only + +Due to the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors. Pull requests opened by anyone who is not on this list are closed automatically. + +To be added as a vouched contributor, post a message at [support.stalw.art](https://support.stalw.art) explaining the code changes you would like to submit, and include a link to the proposed change (a branch, diff, or draft). Once a maintainer has reviewed your request and vouched for you, you will be able to open pull requests directly. + +This policy lets us focus limited review capacity on contributions from people who have taken the time to understand the codebase and discuss their changes first. + +## What Contributions Are Accepted + +At this stage of the project we accept a narrow set of contributions: + +- **Bug fixes.** Corrections to existing, incorrect behavior are welcome. Please include steps to reproduce the bug and describe the fix. +- **Translations.** Additions and corrections to existing translations are welcome. + +New features are generally **not** accepted, unless they involve only a few lines of code. Larger features fall outside the scope of what we can review and integrate while the architecture is still evolving. + +If you would like to see a new feature, please request it at [support.stalw.art](https://support.stalw.art) under the **Feature Ideas** category rather than opening a pull request. This lets the community discuss and prioritize ideas before any code is written. + +## No AI-Generated Code + +AI-generated code is not accepted in this project. + +Even the most advanced models write inefficient Rust code. Beyond raw performance, AI creates technical debt by generating large amounts of code that not even the authors who submitted it can fully understand or maintain. Reviewing and untangling such contributions costs the maintainers far more time than it saves. + +Using AI as a fancy autocomplete is perfectly fine. What matters is that every line generated by a model is read, understood, and reviewed by a human before it is submitted. You are responsible for every line in your pull request, regardless of how it was produced. If you cannot explain why a change is written the way it is, it is not ready to be submitted. + +## Pull Request Process + +Once you are a vouched contributor: + +1. Keep each pull request small and focused on a single logical change. +2. Match the style and conventions of the surrounding code. +3. Make sure the project builds and the test suite passes before opening the pull request. +4. In the pull request description, explain what the change does and why, and link to the [support.stalw.art](https://support.stalw.art) discussion where the change was vouched. + +## Code of Conduct + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +You can read the full Code of Conduct [here](https://github.com/stalwartlabs/.github/blob/main/CODE_OF_CONDUCT.md). + +## Licensing + +This project is licensed under the Affero General Public License (AGPL) version 3.0. By contributing to this project, you agree that your contributions will be licensed under the AGPL-3.0 license. + +## Fiduciary Contributor License Agreement + +Before making any contributions, all contributors are required to sign the Fiduciary Contributor License Agreement (FLA). The FLA is a legal agreement that assigns the copyright of contributions to a designated fiduciary, who manages these rights on behalf of the project. This arrangement ensures that the software remains free and open, even as contributors come and go. + +Key points of the FLA: + +- Ensures the software remains free and open source +- Protects the project from potential copyright issues +- Includes a reversion clause: if the fiduciary violates Free Software principles, rights revert to the original contributors + +For more details about the FLA, please refer to the [FLA FAQ](https://fsfe.org/activities/fla/fla.en.html). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..55cb635 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,10792 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "RustyXML" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c32325128cb24de2a2d14d92da7569dae7073f9e47334521d494747fa94d902" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "polyval 0.7.3", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy 0.8.57", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2 0.11.0", + "cpufeatures 0.3.1", + "password-hash 0.6.1", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-compression" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f" +dependencies = [ + "aws-lc-rs", + "bytes", + "futures-util", + "memchr", + "pin-project", + "portable-atomic", + "rand 0.10.2", + "regex", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "url", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.5.0", + "log", + "rustls", + "serde", + "serde_json", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-creds" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3b85155d265df828f84e53886ed9e427aed979dd8a39f5b8b2162c77e142d7" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml 0.38.4", + "rust-ini", + "serde", + "thiserror 2.0.20", + "time", + "url", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-region" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b36c8dc927b6db1b6c6b8f5d05865f2213550b9e83bf92fa99ed6525472c0" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "azure_core" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b552ad43a45a746461ec3d3a51dfb6466b4759209414b439c165eb6a6b7729e" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "dyn-clone", + "futures", + "getrandom 0.2.17", + "hmac 0.12.1", + "http-types", + "once_cell", + "paste", + "pin-project", + "quick-xml 0.31.0", + "rand 0.8.8", + "reqwest 0.12.28", + "rustc_version", + "serde", + "serde_json", + "sha2 0.10.9", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "azure_storage" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f838159f4d29cb400a14d9d757578ba495ae64feb07a7516bf9e4415127126" +dependencies = [ + "RustyXML", + "async-lock", + "async-trait", + "azure_core", + "bytes", + "serde", + "serde_derive", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "azure_storage_blobs" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e83c3636ae86d9a6a7962b2112e3b19eb3903915c50ce06ff54ff0a2e6a7e4" +dependencies = [ + "RustyXML", + "azure_core", + "azure_storage", + "azure_svc_blobstorage", + "bytes", + "futures", + "serde", + "serde_derive", + "serde_json", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "azure_svc_blobstorage" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6c6f20c5611b885ba94c7bae5e02849a267381aecb8aee577e8c35ff4064c6" +dependencies = [ + "azure_core", + "bytes", + "futures", + "log", + "once_cell", + "serde", + "serde_json", + "time", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "biscuit" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dbfddc8cc5cc927876d9f138e7ded44c6116c544d3cca2c3a2759e46ba916c7" +dependencies = [ + "chrono", + "data-encoding", + "num-bigint 0.4.8", + "num-traits", + "once_cell", + "ring", + "serde", + "serde_json", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec-nom2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0" +dependencies = [ + "bitvec", + "nom", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.15", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array 0.4.15", +] + +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher 0.2.5", + "opaque-debug", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "bollard" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.2", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.10.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + +[[package]] +name = "buffered-reader" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7531a07caf83a3fa3b47a233b437ee7910cccfb688555d224338295a0a64fcc" +dependencies = [ + "libc", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "calcard" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b779382e675380a1ff8a4873acee5ba60158be7a00458fb98e6b85aad1f2ee" +dependencies = [ + "ahash", + "chrono", + "chrono-tz", + "hashify", + "jmap-tools", + "mail-builder 0.5.0", + "mail-parser", + "rkyv", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cedarwood" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20", + "cipher 0.5.2", + "poly1305", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "common" +version = "0.16.22" +dependencies = [ + "aes-gcm-siv", + "ahash", + "arc-swap", + "aws-lc-rs", + "base64 0.23.1", + "bincode", + "calcard", + "chrono", + "compact_str", + "coordinator", + "decancer", + "directory", + "dns-update", + "futures", + "hashify", + "hickory-proto", + "hostname", + "hyper", + "idna", + "imagesize", + "infer 0.22.0", + "jmap_proto", + "jsonwebtoken", + "libc", + "lz4_flex 0.14.0", + "mail-auth", + "mail-builder 1.0.0", + "mail-parser", + "md5", + "nlp", + "nohash-hasher", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "p256", + "p384", + "parking_lot", + "pem 4.0.0", + "pkcs8", + "privdrop", + "prometheus", + "proxy-header", + "psl", + "quick-xml 0.41.0", + "quick_cache", + "rasn", + "rasn-pkix", + "rcgen", + "regex", + "registry", + "reqwest 0.13.5", + "rkyv", + "rsa", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "sequoia-openpgp", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "sieve-rs", + "smtp-proto", + "socket2 0.6.5", + "store", + "tinyvec", + "tokio", + "tokio-rustls", + "trc", + "types", + "unicode-security", + "utils", + "whatlang", + "x509-parser", + "xxhash-rust", + "zip", + "zxcvbn", +] + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rkyv", + "serde", + "static_assertions", + "zmij", +] + +[[package]] +name = "compression-codecs" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "coordinator" +version = "0.16.22" +dependencies = [ + "async-nats", + "futures", + "rdkafka", + "redis", + "registry", + "store", + "tokio", + "trc", + "zenoh", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array 0.4.15", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array 0.14.7", + "subtle", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "rand_core 0.6.4", + "sha3 0.10.9", + "signature 2.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dav" +version = "0.16.22" +dependencies = [ + "calcard", + "chrono", + "common", + "compact_str", + "dav-proto", + "directory", + "groupware", + "hashify", + "http_proto", + "hyper", + "percent-encoding", + "registry", + "rkyv", + "store", + "trc", + "types", + "utils", +] + +[[package]] +name = "dav-proto" +version = "0.16.22" +dependencies = [ + "calcard", + "chrono", + "compact_str", + "hashify", + "hyper", + "mail-parser", + "quick-xml 0.41.0", + "serde", + "serde_json", + "trc", + "types", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a536565624b97fc19f758cd01b15d12908d3344425066efc8162236fbd3749" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.4.3", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "decancer" +version = "3.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint 0.4.8", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "directory" +version = "0.16.22" +dependencies = [ + "ahash", + "argon2 0.6.0", + "base64 0.23.1", + "deadpool", + "jsonwebtoken", + "ldap3", + "mail-builder 1.0.0", + "mail-parser", + "md5", + "nohash-hasher", + "pbkdf2", + "pwhash", + "registry", + "reqwest 0.13.5", + "scrypt", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "store", + "tokio", + "totp-rs", + "trc", + "utils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dns-update" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887fbd45d910c4dd5808f65c0cd686d4f0f756450b9a6c7ecce4a9f450657509" +dependencies = [ + "aws-lc-rs", + "base64 0.23.1", + "chrono", + "hex", + "hickory-net", + "hickory-proto", + "quick-xml 0.42.0", + "reqwest 0.13.5", + "rustls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2 0.10.9", + "signature 2.2.0", + "zeroize", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + +[[package]] +name = "ece" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746" +dependencies = [ + "base64 0.21.7", + "byteorder", + "hex", + "hkdf 0.12.4", + "lazy_static", + "once_cell", + "openssl", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array 0.14.7", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "email" +version = "0.16.22" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "chacha20poly1305", + "common", + "groupware", + "hashify", + "jmap-tools", + "jmap_proto", + "mail-builder 1.0.0", + "mail-parser", + "nlp", + "rand 0.8.8", + "rasn", + "rasn-cms", + "rasn-pkix", + "registry", + "rkyv", + "rsa", + "sequoia-openpgp", + "sieve-rs", + "store", + "tokio", + "trc", + "types", + "utils", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "event_macro" +version = "0.16.22" +dependencies = [ + "quote", + "syn 3.0.5", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "farmhash" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ce9c8fb9891c75ceadbc330752951a4e369b50af10775955aeb9af3eee34b" + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.2", + "web-time", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form-data" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3757d8523a60f4d3516a018e767cd3bea0829a43045d290ace16dcc92ac4f4cd" +dependencies = [ + "bytes", + "http 1.5.0", + "httparse", + "memchr", + "mime", + "serde", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foundationdb" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a0b9e89be4942ad3c6cf76789e7eaf9489fcca2b06b1449b2d9eb015424793" +dependencies = [ + "async-recursion", + "async-trait", + "foundationdb-gen", + "foundationdb-macros", + "foundationdb-sys", + "foundationdb-tuple", + "futures", + "rand 0.10.2", + "serde", + "serde_bytes", + "serde_json", + "static_assertions", + "uuid", +] + +[[package]] +name = "foundationdb-gen" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58c729fca985f56d347e5f8df5c1d3135cc8211e5688eacf695e1365449594" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "foundationdb-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51be872cad567c58cf475749b25b9bb96e9ab6063af8bbe4f965ecf40e13cb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "try_map", +] + +[[package]] +name = "foundationdb-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bac3aa47fdd7af6ac1b2a8ee4684fe4ed3d6c663c7ff5888e1846a790e6601a" +dependencies = [ + "bindgen", + "libc", +] + +[[package]] +name = "foundationdb-tuple" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5262f7a16bddd129d31d96d09de25dee2295ce7cccaa7ed6b212f391819c64" +dependencies = [ + "memchr", + "uuid", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "rustversion", + "typenum", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", +] + +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "groupware" +version = "0.16.22" +dependencies = [ + "ahash", + "calcard", + "chrono", + "common", + "compact_str", + "hashify", + "icu_datetime", + "icu_locale_core", + "icu_plurals", + "icu_provider", + "indexmap 2.14.2", + "nlp", + "percent-encoding", + "registry", + "rkyv", + "serde", + "serde_json", + "store", + "trc", + "types", + "utils", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap 2.14.2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +dependencies = [ + "async-trait", + "aws-lc-rs", + "bitflags 2.13.2", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http 1.5.0", + "idna", + "ipnet", + "jni", + "lru-cache", + "parking_lot", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "thiserror 2.0.20", + "time", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "aws-lc-rs", + "bitflags 2.13.2", + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "thiserror 2.0.20", + "time", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + +[[package]] +name = "http" +version = "0.16.22" +dependencies = [ + "async-stream", + "base64 0.23.1", + "chrono", + "common", + "dav", + "directory", + "email", + "flate2", + "form-data", + "groupware", + "hashify", + "http-body-util", + "http_proto", + "hyper", + "hyper-util", + "jmap", + "jmap_proto", + "mail-auth", + "mail-builder 1.0.0", + "mail-parser", + "mime", + "percent-encoding", + "registry", + "rkyv", + "scim", + "serde", + "serde_json", + "services", + "sha2 0.11.0", + "smtp", + "store", + "tokio", + "trc", + "types", + "utils", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64 0.13.1", + "futures-lite", + "infer 0.2.3", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + +[[package]] +name = "http_proto" +version = "0.16.22" +dependencies = [ + "common", + "compact_str", + "form_urlencoded", + "http-body-util", + "hyper", + "percent-encoding", + "registry", + "serde", + "serde_json", + "trc", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d15931895091dea5c47afa5b3c9a01ba634b311919fd4d41388fa0e3d76af" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.5.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_calendar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +dependencies = [ + "calendrical_calculations", + "chrono", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "ixdtf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_datetime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c44df99c4c5297b05653d1a1629f13bdc9fd4044acfbbaf30b15194a1eed4cd6" +dependencies = [ + "chrono", + "displaydoc", + "fixed_decimal", + "icu_calendar", + "icu_datetime_data", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_pattern", + "icu_plurals", + "icu_provider", + "icu_time", + "potential_utf", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_datetime_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb5555f6e4b42233f58564351ce16d73e9e6ebfce6bdcf29875d0cfc554d6a8" + +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" + +[[package]] +name = "icu_locale" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_time" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af37e1c3b9f3ab564b619fdf4b22de169f6381eb0f7048e3ca454290c438126b" +dependencies = [ + "calendrical_calculations", + "chrono", + "chrono-tz", + "displaydoc", + "icu_calendar", + "icu_locale_core", + "icu_provider", + "icu_time_data", + "ixdtf", + "serde", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_time_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ff86fa5851a77fc0a977b72d717f777bd9230415a247219d7898aefb1517a" + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "imap" +version = "0.16.22" +dependencies = [ + "ahash", + "common", + "compact_str", + "directory", + "email", + "imap_proto", + "mail-parser", + "md5", + "nlp", + "parking_lot", + "rand 0.10.2", + "registry", + "store", + "tokio", + "tokio-rustls", + "trc", + "types", + "utils", +] + +[[package]] +name = "imap_proto" +version = "0.16.22" +dependencies = [ + "ahash", + "base64 0.23.1", + "chrono", + "compact_str", + "hashify", + "mail-parser", + "trc", + "types", + "utils", +] + +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array 0.4.15", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ixdtf" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" + +[[package]] +name = "jieba-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a" +dependencies = [ + "phf_codegen", +] + +[[package]] +name = "jieba-rs" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" +dependencies = [ + "bytecount", + "cedarwood", + "include-flate", + "jieba-macros", + "phf 0.13.1", + "regex", + "rustc-hash", +] + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jmap" +version = "0.16.22" +dependencies = [ + "async-stream", + "base64 0.23.1", + "calcard", + "chrono", + "common", + "directory", + "email", + "futures-util", + "groupware", + "hashify", + "http-body-util", + "http_proto", + "hyper", + "hyper-util", + "jmap-tools", + "jmap_proto", + "mail-auth", + "mail-builder 1.0.0", + "mail-parser", + "nlp", + "p256", + "rand 0.10.2", + "registry", + "reqwest 0.13.5", + "rkyv", + "serde", + "serde_json", + "services", + "sha1 0.11.0", + "sha2 0.11.0", + "sieve-rs", + "smtp", + "smtp-proto", + "spam-filter", + "store", + "tokio", + "tokio-tungstenite 0.30.0", + "trc", + "tungstenite 0.30.0", + "types", + "utils", +] + +[[package]] +name = "jmap-client" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f" +dependencies = [ + "ahash", + "async-stream", + "base64 0.22.1", + "chrono", + "futures-util", + "maybe-async", + "parking_lot", + "reqwest 0.13.5", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.29.0", +] + +[[package]] +name = "jmap-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bdcd368dcd58a5efaac10c142fb8cddb4d2949af1297b624df3ff0db4cbff54" +dependencies = [ + "hashify", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "jmap_proto" +version = "0.16.22" +dependencies = [ + "ahash", + "calcard", + "compact_str", + "hashify", + "jmap-tools", + "mail-parser", + "registry", + "rkyv", + "serde", + "serde_json", + "trc", + "types", + "utils", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "keyed-set" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d255a6b6ecd77bb93ce91de984d7039bff7503f500eb4851a1269732f22baf" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap 2.14.2", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lalrpop" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a80a963123205c7157323c99611bc4abb65dcbd62ef46dc4bac74a3941bc75" +dependencies = [ + "ascii-canvas", + "bit-set 0.9.1", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax", + "sha3 0.10.9", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884f3e747ed2dcee867cda1b0c31a048f9e20de2d916a248949319921a2e666e" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.9", +] + +[[package]] +name = "lber" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5" +dependencies = [ + "bytes", + "nom", +] + +[[package]] +name = "ldap3" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lber", + "log", + "nom", + "percent-encoding", + "rustls", + "rustls-native-certs", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "url", + "x509-parser", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libflate" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561a8da1a50e1428d3c51321dafeca849df992a5bb67720c386131234caba82e" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "librocksdb-sys" +version = "0.17.3+10.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" +dependencies = [ + "bindgen", + "bzip2-sys", + "cc", + "libc", + "libz-sys", + "lz4-sys", + "zstd-sys", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8c72594ac26bfd34f2d99dfced2edfaddfe8a476e3ff2ca0eb293d925c4f83" +dependencies = [ + "twox-hash 1.6.3", +] + +[[package]] +name = "lz4_flex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +dependencies = [ + "twox-hash 2.1.4", +] + +[[package]] +name = "lzma-rust2" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "mail-auth" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11f19d98aac923fc5b7ee30c3509733a013ef546a226acb959b9202f5ca58f0" +dependencies = [ + "aws-lc-rs", + "flate2", + "getrandom 0.2.17", + "getrandom 0.4.3", + "hashify", + "hickory-resolver", + "idna", + "mail-builder 1.0.0", + "mail-parser", + "memchr", + "quick-xml 0.42.0", + "rand 0.10.2", + "rkyv", + "rsa", + "rustls-pki-types", + "serde", + "serde_json", + "similar", + "zip", +] + +[[package]] +name = "mail-builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3" +dependencies = [ + "gethostname", +] + +[[package]] +name = "mail-builder" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccbc27cb02a58a311a404769a22f5abf63fe3173b290d80be7c534f9c7023151" +dependencies = [ + "gethostname", +] + +[[package]] +name = "mail-parser" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86" +dependencies = [ + "encoding_rs", + "hashify", + "memchr", + "rkyv", + "serde", +] + +[[package]] +name = "managesieve" +version = "0.16.22" +dependencies = [ + "common", + "compact_str", + "directory", + "email", + "imap", + "imap_proto", + "jmap_proto", + "mail-parser", + "registry", + "sieve-rs", + "store", + "tokio", + "tokio-rustls", + "trc", + "types", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memory-stats" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "memsec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" + +[[package]] +name = "migration" +version = "0.16.22" +dependencies = [ + "common", + "email", + "registry", + "spam-filter", + "store", + "tokio", + "trc", + "types", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", + "hybrid-array 0.4.15", + "module-lattice", + "shake", + "signature 3.0.0", + "zeroize", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array 0.4.15", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3 0.11.0", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array 0.4.15", + "num-traits", + "zeroize", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mysql-common-derive" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" +dependencies = [ + "darling 0.23.0", + "heck", + "manyhow", + "num-bigint 0.4.8", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", + "thiserror 2.0.20", +] + +[[package]] +name = "mysql_async" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d11da0e2d9fad4640c9f9198ee431c6d68444568f83ef1f10f3367270071e4" +dependencies = [ + "arc-swap", + "bytes", + "crossbeam-queue", + "crossbeam-utils", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lru", + "mysql_common", + "percent-encoding", + "rand 0.10.2", + "rustls", + "serde", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-util", + "twox-hash 2.1.4", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "mysql_common" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "mysql-common-derive", + "num-bigint 0.4.8", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1 0.10.7", + "sha2 0.10.9", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nlp" +version = "0.16.22" +dependencies = [ + "ahash", + "hashify", + "jieba-rs", + "maplit", + "psl", + "rand 0.10.2", + "rkyv", + "rust-stemmers", + "serde", + "whatlang", + "xxhash-rust", +] + +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty-collections" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e216d0e8cf9d54fa66e5780f6e1d5dc96d1c1b3c25aeba3b6758548bcbbd8b9d" +dependencies = [ + "serde", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" +dependencies = [ + "async-trait", + "bytes", + "http 1.5.0", + "opentelemetry", + "reqwest 0.13.5", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" +dependencies = [ + "http 1.5.0", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.13.5", + "thiserror 2.0.20", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.20", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-display" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78deb158fb1d73b29efb4b7e9b9860b78059c670de06bd28df8d0b458ded0eb" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e95a50d1084dab562913062c4c34bb204b68fc6ec38a1395909ff5aaaf4f10a" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", + "password-hash 0.6.1", + "sha2 0.11.0", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", + "serde", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.5.0", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79e70ec0be163102a332e1d2d5586d362ad76b01cec86f830241f2b6452a7b7" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4643d3d4db6b08741050c2f3afa9a892c4244c085a72fcda93c9c2c9a00f4b" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "pop3" +version = "0.16.22" +dependencies = [ + "common", + "directory", + "email", + "imap", + "mail-parser", + "registry", + "store", + "tokio", + "tokio-rustls", + "trc", + "types", + "utils", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.57", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "privdrop" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70722a5a3728c9603c8d9469b64b8d1ee54dae6d74e24146da7f501b4c76540f" +dependencies = [ + "libc", + "nix", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "thiserror 2.0.20", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "proxy-header" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1493f63ddddfba840c3169e997c2905d09538ace72d64e84af6324c6e0e065" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "psl" +version = "2.1.232" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62834e308cc83aea5e30cd8c80b8aa82cdb104a3240c7f210d4f68d46e29f308" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "pwhash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419a3ad8fa9f9d445e69d9b185a24878ae6e6f55c96e4512f4a0e28cd3bc5c56" +dependencies = [ + "blowfish 0.7.0", + "byteorder", + "hmac 0.10.1", + "md-5 0.9.1", + "rand 0.8.8", + "sha-1", + "sha2 0.9.9", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick_cache" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c" +dependencies = [ + "equivalent", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rasn" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d1b71dd951343df0fe30b11bca09518f3aba8e5bd0ece4564adbc2dabd875fa" +dependencies = [ + "bitvec", + "bitvec-nom2", + "bytes", + "cfg-if", + "chrono", + "either", + "nom", + "num-bigint 0.4.8", + "num-integer", + "num-traits", + "once_cell", + "rasn-derive", + "serde_json", + "snafu", + "xml-no-std", +] + +[[package]] +name = "rasn-cms" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4abf4c2fe5537b99e2bf3099a7ad26e40bd9cf51bc003600ed58dbbff69a1c" +dependencies = [ + "rasn", + "rasn-pkix", +] + +[[package]] +name = "rasn-derive" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979eafa601d351f7f6490f1d2f4decc168e8e51cb6517c6c6ff80111951129d5" +dependencies = [ + "proc-macro2", + "rasn-derive-impl", + "syn 2.0.119", +] + +[[package]] +name = "rasn-derive-impl" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee3136c97d6f2553c0d9f84d2c2555bb87ae9b365c5c9274e8bc5c366174431" +dependencies = [ + "either", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "rasn-pkix" +version = "0.28.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c029da7ed3b94dd93b75299431984081c1eaf88ce79771c21b056f0ebf6fb964" +dependencies = [ + "rasn", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem 4.0.0", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "rdkafka" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7956f9ac12b5712e50372d9749a3102f4810a8d42481c5eae3748d36d585bcf" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "log", + "rdkafka-sys", + "serde", + "serde_derive", + "serde_json", + "slab", + "tokio", +] + +[[package]] +name = "rdkafka-sys" +version = "4.10.0+2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234cf318915c1059d4921ef7f75616b5219b10b46e9f3a511a15eb4b56a3f77" +dependencies = [ + "cmake", + "libc", + "libz-sys", + "num_enum", + "pkg-config", +] + +[[package]] +name = "redis" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed" +dependencies = [ + "arcstr", + "async-lock", + "bytes", + "cfg-if", + "combine", + "crc16", + "futures-channel", + "futures-util", + "itoa", + "log", + "num-bigint 0.5.1", + "percent-encoding", + "pin-project-lite", + "rand 0.10.2", + "rustls", + "rustls-native-certs", + "ryu", + "sha1_smol", + "socket2 0.6.5", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "registry" +version = "0.16.22" +dependencies = [ + "ahash", + "hashify", + "jmap-tools", + "lz4_flex 0.14.0", + "mail-auth", + "serde", + "tokio", + "trc", + "types", + "utils", +] + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ringbuffer-spsc" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3e7aa0a681b232e7cd7f856a53b10603df88ca74b79a8d8088845185492e35" +dependencies = [ + "array-init", + "crossbeam", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rocksdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" +dependencies = [ + "libc", + "librocksdb-sys", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rtrb" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9278fb35b3e730abe136e9b395b5b81b96d06b9f5478a50f0c8430a2237b22de" + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust-s3" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeedb13abdaa7e48d391de05b0569b37fa0a7a64a668dff6ffb2141ad0c2527e" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.22.1", + "bytes", + "cfg-if", + "futures-util", + "hex", + "hmac 0.12.1", + "http 1.5.0", + "log", + "maybe-async", + "md5", + "percent-encoding", + "quick-xml 0.38.4", + "reqwest 0.12.28", + "serde", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "sysinfo", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "url", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "either", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scim" +version = "0.16.22" +dependencies = [ + "common", + "directory", + "http_proto", + "hyper", + "icu_locale", + "jmap", + "jmap_proto", + "registry", + "scim-proto", + "serde", + "serde_json", + "store", + "trc", + "types", + "utils", + "xxhash-rust", +] + +[[package]] +name = "scim-proto" +version = "0.16.22" +dependencies = [ + "hashify", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "password-hash 0.6.1", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.7", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sequoia-openpgp" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbc8f9818a6fad141d85993777ba298ee52c80a09bd23d45dda461a6b7c83cb" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "anyhow", + "argon2 0.5.3", + "base64 0.22.1", + "block-padding 0.3.3", + "blowfish 0.9.1", + "buffered-reader", + "camellia", + "cast5", + "cbc 0.1.2", + "cfb-mode", + "chrono", + "cipher 0.4.4", + "cx448", + "des", + "digest 0.10.7", + "dsa", + "dyn-clone", + "eax", + "ecb", + "ecdsa", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "hkdf 0.12.4", + "idea", + "idna", + "lalrpop", + "lalrpop-util", + "libc", + "md-5 0.10.6", + "memsec", + "ml-dsa", + "ml-kem", + "num-bigint-dig", + "num-integer", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.9.5", + "rand_core 0.6.4", + "regex", + "regex-syntax", + "ripemd", + "rsa", + "sha1collisiondetection", + "sha2 0.10.9", + "sha3 0.10.9", + "slh-dsa", + "thiserror 2.0.20", + "twofish", + "typenum", + "x25519-dalek", + "xxhash-rust", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial_test" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "services" +version = "0.16.22" +dependencies = [ + "aes-gcm 0.11.1", + "aho-corasick", + "base64 0.23.1", + "calcard", + "common", + "dns-update", + "email", + "groupware", + "hkdf 0.13.0", + "jmap-tools", + "jmap_proto", + "mail-builder 1.0.0", + "mail-parser", + "memory-stats", + "p256", + "psl", + "registry", + "reqwest 0.13.5", + "serde_json", + "sha2 0.11.0", + "smtp", + "smtp-proto", + "spam-filter", + "store", + "tokio", + "trc", + "types", + "utils", +] + +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha1collisiondetection" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f606421e4a6012877e893c399822a4ed4b089164c5969424e1b9d1e66e6964b" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "generic-array 1.4.5", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sieve-rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd00a548fde57bd0c8e7c13ae65fc5fe30bd923ff8655c81e010f3f02a90997b" +dependencies = [ + "ahash", + "arc-swap", + "fancy-regex 0.19.2", + "hashify", + "mail-builder 0.5.0", + "mail-parser", + "rkyv", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint 0.4.8", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slh-dsa" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2f20f4049197e03db1104a6452f4d9e96665d79f880198dce4a7026ba5f267" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "hmac 0.12.1", + "hybrid-array 0.3.1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "sha3 0.10.9", + "signature 2.2.0", + "typenum", + "zerocopy 0.7.35", +] + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smtp" +version = "0.16.22" +dependencies = [ + "ahash", + "base64 0.23.1", + "blake3", + "common", + "compact_str", + "directory", + "email", + "hashify", + "mail-auth", + "mail-builder 1.0.0", + "mail-parser", + "parking_lot", + "rand 0.10.2", + "registry", + "reqwest 0.13.5", + "rkyv", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "sha2 0.11.0", + "sieve-rs", + "smtp-proto", + "spam-filter", + "store", + "tokio", + "tokio-rustls", + "trc", + "types", + "utils", + "x509-parser", +] + +[[package]] +name = "smtp-proto" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707104487221ff447b5b796b5049e5c09cf52ff9fc1c8abba4695b89ac4b0f37" +dependencies = [ + "memchr", + "rkyv", + "serde", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spam-filter" +version = "0.16.22" +dependencies = [ + "common", + "compact_str", + "decancer", + "hashify", + "hyper", + "idna", + "infer 0.22.0", + "mail-auth", + "mail-parser", + "nlp", + "psl", + "registry", + "rkyv", + "serde", + "sha1 0.11.0", + "smtp-proto", + "store", + "tokio", + "trc", + "types", + "unicode-general-category", + "unicode-normalization", + "unicode-security", + "utils", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stabby" +version = "72.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d53d2428934c46277fafd2d41e39357595aa1e47954c75db2b14ed90632f3cc" +dependencies = [ + "rustversion", + "stabby-abi", +] + +[[package]] +name = "stabby-abi" +version = "72.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f375eae680bb54203ee5e47d4cd2ae7b79c0a79ed90919279f38f500ad53f190" +dependencies = [ + "rustc_version", + "rustversion", + "sha2-const-stable", + "stabby-macros", +] + +[[package]] +name = "stabby-macros" +version = "72.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea664671a576c5f7e32fee291ac123d82af5e92b0689beb3555347c00c76eef1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stalwart" +version = "0.16.22" +dependencies = [ + "common", + "coordinator", + "dav", + "directory", + "email", + "groupware", + "http 0.16.22", + "http_proto", + "imap", + "jmap", + "managesieve", + "migration", + "pop3", + "registry", + "rustls", + "scim", + "services", + "smtp", + "smtp-proto", + "spam-filter", + "store", + "tikv-jemallocator", + "tokio", + "trc", + "types", + "utils", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "store" +version = "0.16.22" +dependencies = [ + "ahash", + "arc-swap", + "aws-lc-rs", + "azure_core", + "azure_storage", + "azure_storage_blobs", + "bitpacking", + "blake3", + "bytes", + "compact_str", + "deadpool", + "deadpool-postgres", + "flate2", + "foundationdb", + "futures", + "gethostname", + "lru-cache", + "lz4_flex 0.14.0", + "mysql_async", + "nlp", + "num_cpus", + "parking_lot", + "r2d2", + "radsort", + "rand 0.10.2", + "rayon", + "redis", + "registry", + "reqwest 0.12.28", + "reqwest 0.13.5", + "rkyv", + "roaring", + "rocksdb", + "rusqlite", + "rust-s3", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "tokio", + "tokio-postgres", + "tokio-rustls", + "trc", + "types", + "utils", + "x509-parser", + "xxhash-rust", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "testcontainers" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2bbe381afaaa58ea610c5fc3ffb2184063a32b3e358a179f0b4865dd59934a" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http 1.5.0", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "tests" +version = "0.16.22" +dependencies = [ + "ahash", + "aws-lc-rs", + "base64 0.23.1", + "biscuit", + "bytes", + "calcard", + "chrono", + "common", + "coordinator", + "csv", + "dav", + "dav-proto", + "directory", + "dns-update", + "ece", + "email", + "flate2", + "form_urlencoded", + "futures", + "groupware", + "http 0.16.22", + "http_proto", + "hyper", + "hyper-util", + "imap", + "imap_proto", + "jmap", + "jmap-client", + "jmap-tools", + "jmap_proto", + "mail-auth", + "mail-builder 1.0.0", + "mail-parser", + "managesieve", + "nlp", + "pop3", + "quick-xml 0.41.0", + "rayon", + "rcgen", + "registry", + "reqwest 0.13.5", + "rkyv", + "rust-s3", + "rustls", + "rustls-pki-types", + "scim", + "scim-proto", + "serde", + "serde_json", + "serial_test", + "services", + "sha2 0.11.0", + "sieve-rs", + "smtp", + "smtp-proto", + "spam-filter", + "store", + "testcontainers", + "tikv-jemallocator", + "time", + "tokio", + "tokio-rustls", + "trc", + "types", + "utils", + "x509-parser", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls-listener" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1461056cc1ef47003f7ee16e4cef3741068d4c7f6b627bfce49b7c00c120a530" +dependencies = [ + "futures-util", + "pin-project-lite", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "token-cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb48920ae769b58126c8c93269805011c793201f95fde28b479b81a9a531bbde" +dependencies = [ + "paste", + "portable-atomic", + "rustversion", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2 0.6.5", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.30.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.5.0", + "httparse", + "rand 0.8.8", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2 0.6.5", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "totp-rs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" +dependencies = [ + "base32", + "constant_time_eq", + "hmac 0.13.0", + "percent-encoding", + "rand 0.10.2", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "url", + "zeroize", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "trc" +version = "0.16.22" +dependencies = [ + "ahash", + "base64 0.23.1", + "compact_str", + "event_macro", + "hashify", + "mail-auth", + "mail-parser", + "parking_lot", + "reqwest 0.13.5", + "rkyv", + "rtrb", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "try_map" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1626d07cb5c1bb2cf17d94c0be4852e8a7c02b041acec9a8c5bdda99f9d580" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1 0.10.7", + "thiserror 2.0.20", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror 2.0.20", +] + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "types" +version = "0.16.22" +dependencies = [ + "blake3", + "compact_str", + "hashify", + "jmap-tools", + "rkyv", + "serde", + "trc", + "utils", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uhlc" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62a645e3e4e6c85b7abe49b086aa3204119431f42b6123b0070419fb6e9d24e" +dependencies = [ + "humantime", + "lazy_static", + "log", + "rand 0.8.8", + "serde", + "spin 0.10.1", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "unzip-n" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5bb2756c16fb66f80cfbf5fb0e0c09a7001e739f453c9ec241b9c8b1556fda" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utils" +version = "0.16.22" +dependencies = [ + "ahash", + "arcstr", + "aws-lc-rs", + "base64 0.23.1", + "chrono", + "compact_str", + "farmhash", + "form_urlencoded", + "futures", + "idna", + "mail-auth", + "nohash-hasher", + "privdrop", + "psl", + "quick_cache", + "reqwest 0.13.5", + "rkyv", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "tokio", + "tokio-rustls", + "trc", + "xxhash-rust", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validated_struct" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869a93e8a7286e339e1128630051d82babbcd75d585975af07b9f3327220e60e" +dependencies = [ + "json5", + "serde", + "serde_json", + "validated_struct_macros", +] + +[[package]] +name = "validated_struct_macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c44ce98e7227a04eeb4cf9c784109a5c9710e54849ceb4f09f8597247897f1e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unzip-n", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whatlang" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e8f38b596e2a359b755342473520a99421e43658548c79489ee221b728c107" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + +[[package]] +name = "xml-no-std" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd223bc94c615fc02bf2f4bbc22a4a9bfe489c2add3ec10b1038df3aca44cac7" + +[[package]] +name = "xml-rs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a56132a0d6ecbe77352edc10232f788fc4ceefefff4cab784a98e0e16b6b51" +dependencies = [ + "xml", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zenoh" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ba5d28a1197653aae4bb024fd74a9d5c7051d5a19a77f3a43d83f40c22584" +dependencies = [ + "ahash", + "arc-swap", + "async-trait", + "bytes", + "const_format", + "flate2", + "flume", + "futures", + "git-version", + "itertools 0.14.0", + "json5", + "lazy_static", + "nonempty-collections", + "once_cell", + "petgraph", + "phf 0.13.1", + "rand 0.8.8", + "rustc_version", + "serde", + "serde_json", + "socket2 0.5.10", + "tokio", + "tokio-util", + "tracing", + "uhlc", + "vec_map", + "zenoh-buffers", + "zenoh-codec", + "zenoh-collections", + "zenoh-config", + "zenoh-core", + "zenoh-keyexpr", + "zenoh-link", + "zenoh-link-commons", + "zenoh-macros", + "zenoh-plugin-trait", + "zenoh-protocol", + "zenoh-result", + "zenoh-runtime", + "zenoh-sync", + "zenoh-task", + "zenoh-transport", + "zenoh-util", +] + +[[package]] +name = "zenoh-buffers" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555a8169c9888fc5571f1b38d2843a63db786621e153abad1b075c40b20e83b4" +dependencies = [ + "zenoh-collections", +] + +[[package]] +name = "zenoh-codec" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc959f727893eab9b66ec051c8f4a2d6f66b4ee70bf23f2ed244aa754705e854" +dependencies = [ + "rand 0.8.8", + "tracing", + "uhlc", + "zenoh-buffers", + "zenoh-protocol", +] + +[[package]] +name = "zenoh-collections" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c05abd8dcfd2239a8dfb5b79bd4ed85094c6bb6d6b991d3529bff364cc48e29" +dependencies = [ + "ahash", +] + +[[package]] +name = "zenoh-config" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59799d556b4fac79886cb0a9c90f3bc48d0894816b99c8a90a3509b1809a02dd" +dependencies = [ + "json5", + "nonempty-collections", + "num_cpus", + "secrecy", + "serde", + "serde_json", + "serde_with", + "serde_yaml", + "tracing", + "uhlc", + "validated_struct", + "zenoh-core", + "zenoh-keyexpr", + "zenoh-macros", + "zenoh-protocol", + "zenoh-result", + "zenoh-util", +] + +[[package]] +name = "zenoh-core" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "975073c51353c1c33e81a98370bf3560863540913e5f30091923f5a328fc46d4" +dependencies = [ + "lazy_static", + "tokio", + "zenoh-result", + "zenoh-runtime", +] + +[[package]] +name = "zenoh-crypto" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281a36cfd351ba9232a24899d946a782d0ff3ac0c3d759f87f3e7cbe09a4553" +dependencies = [ + "aes 0.8.4", + "hmac 0.12.1", + "rand 0.8.8", + "rand_chacha 0.3.1", + "sha3 0.10.9", + "zenoh-result", +] + +[[package]] +name = "zenoh-keyexpr" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c6fcb7f18846b4e9b097c61ed7c656ec8d7858ab54a12d5f5025fe1514caae" +dependencies = [ + "getrandom 0.2.17", + "hashbrown 0.16.1", + "keyed-set", + "rand 0.8.8", + "schemars 1.2.2", + "serde", + "token-cell", + "zenoh-result", +] + +[[package]] +name = "zenoh-link" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55c4fbc923328f0b62b1b492723ff53b757a36421f70218e652f0aa899cde6c" +dependencies = [ + "zenoh-config", + "zenoh-link-commons", + "zenoh-link-quic", + "zenoh-link-tcp", + "zenoh-link-tls", + "zenoh-link-udp", + "zenoh-protocol", + "zenoh-result", +] + +[[package]] +name = "zenoh-link-commons" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d52520b4c22d05173c8621e3cbe18d838a17d74477891ed9c59a7aeedaa325d" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "flume", + "futures", + "quinn", + "quinn-proto", + "rcgen", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "rustls-webpki", + "secrecy", + "serde", + "socket2 0.5.10", + "time", + "tokio", + "tokio-util", + "tracing", + "webpki-roots 1.0.9", + "x509-parser", + "zenoh-buffers", + "zenoh-codec", + "zenoh-config", + "zenoh-core", + "zenoh-protocol", + "zenoh-result", + "zenoh-runtime", + "zenoh-util", +] + +[[package]] +name = "zenoh-link-quic" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9fecc8832bb943631f75d7c4e4839a414d7bebbe80c0f695f14b2cd8c84759" +dependencies = [ + "async-trait", + "rustls-webpki", + "time", + "tracing", + "zenoh-core", + "zenoh-link-commons", + "zenoh-link-quic_datagram", + "zenoh-protocol", + "zenoh-result", +] + +[[package]] +name = "zenoh-link-quic_datagram" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af3abf41c719c82ca356864b5ddeeb7c77cb29fe777776b40b31eb4d19de288" +dependencies = [ + "async-trait", + "rustls-webpki", + "time", + "tokio-util", + "tracing", + "zenoh-core", + "zenoh-link-commons", + "zenoh-protocol", + "zenoh-result", +] + +[[package]] +name = "zenoh-link-tcp" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4702d55fe9f17cbd1d3b601901f8ba0a693a7ad1ce685a1ea4c60f775552b7" +dependencies = [ + "async-trait", + "socket2 0.5.10", + "tokio", + "tokio-util", + "tracing", + "zenoh-config", + "zenoh-core", + "zenoh-link-commons", + "zenoh-protocol", + "zenoh-result", +] + +[[package]] +name = "zenoh-link-tls" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce65fd71b4631821a85058542e28faac0151af6c53cd61f2dfafc7e16c4d6c4" +dependencies = [ + "async-trait", + "base64 0.22.1", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "rustls-webpki", + "secrecy", + "socket2 0.5.10", + "time", + "tls-listener", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "webpki-roots 1.0.9", + "x509-parser", + "zenoh-config", + "zenoh-core", + "zenoh-link-commons", + "zenoh-protocol", + "zenoh-result", + "zenoh-runtime", +] + +[[package]] +name = "zenoh-link-udp" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b140e7b65715fe8b29b706b2b9fc3009fc6181346931c8926d290113fe38df4b" +dependencies = [ + "async-trait", + "libc", + "socket2 0.5.10", + "tokio", + "tokio-util", + "tracing", + "windows-sys 0.61.2", + "zenoh-buffers", + "zenoh-core", + "zenoh-link-commons", + "zenoh-link-quic_datagram", + "zenoh-protocol", + "zenoh-result", + "zenoh-sync", + "zenoh-util", +] + +[[package]] +name = "zenoh-macros" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f483b343fd79b6c1bd7d79f130b5c6c208e7fef243a4d7966a6bf9fdecbd70f8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zenoh-keyexpr", +] + +[[package]] +name = "zenoh-plugin-trait" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d57f491f63afa7413d68ee68e4de2bc9ce361c4ac89a76d5a0a0e3ca0624e21" +dependencies = [ + "git-version", + "libloading", + "serde", + "stabby", + "tracing", + "zenoh-config", + "zenoh-keyexpr", + "zenoh-macros", + "zenoh-result", + "zenoh-util", +] + +[[package]] +name = "zenoh-protocol" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4492192986df044627f33c8eeb8aa237fb4d3bbd2d8cc68bc7eee16748c2fb61" +dependencies = [ + "const_format", + "rand 0.8.8", + "serde", + "uhlc", + "zenoh-buffers", + "zenoh-keyexpr", + "zenoh-macros", + "zenoh-result", +] + +[[package]] +name = "zenoh-result" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d6d0ca24186d9cfbf14f2d08b8b0a09afac690fe5cd68899d8f896f9247a3dd" +dependencies = [ + "anyhow", +] + +[[package]] +name = "zenoh-runtime" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e8ea6ed9fb3300d02bc303b3202a21a758aa5d7a1e7dcc38610428910af9f9" +dependencies = [ + "lazy_static", + "ron", + "serde", + "tokio", + "tracing", + "zenoh-macros", + "zenoh-result", +] + +[[package]] +name = "zenoh-sync" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57ed2156b61df6e877b03fb13dd72227d3d286a7fa689120109bad289ddf12b" +dependencies = [ + "arc-swap", + "event-listener 5.4.2", + "futures", + "tokio", + "zenoh-buffers", + "zenoh-collections", + "zenoh-core", +] + +[[package]] +name = "zenoh-task" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887115c0f81c72ff8c9b99e6fccd9e1c3829d83e16b8b063174786b9fba08810" +dependencies = [ + "futures", + "tokio", + "tokio-util", + "tracing", + "zenoh-core", + "zenoh-runtime", +] + +[[package]] +name = "zenoh-transport" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72bae2e0aca56ac6431bd6c37c6ac3fcc9367446cbb356bd44137cc01d1493ee" +dependencies = [ + "async-trait", + "crossbeam-utils", + "flume", + "futures", + "lazy_static", + "lz4_flex 0.10.0", + "rand 0.8.8", + "ringbuffer-spsc", + "rsa", + "serde", + "sha3 0.10.9", + "tokio", + "tokio-util", + "tracing", + "zenoh-buffers", + "zenoh-codec", + "zenoh-config", + "zenoh-core", + "zenoh-crypto", + "zenoh-link", + "zenoh-link-commons", + "zenoh-protocol", + "zenoh-result", + "zenoh-runtime", + "zenoh-sync", + "zenoh-task", + "zenoh-util", +] + +[[package]] +name = "zenoh-util" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a473dcc62026b9d9589acc22c62b04cb7dab4070bba5543d2afac741ddf947" +dependencies = [ + "async-trait", + "const_format", + "flume", + "home", + "humantime", + "lazy_static", + "libc", + "libloading", + "pnet_datalink", + "schemars 1.2.2", + "serde", + "serde_json", + "shellexpand", + "tokio", + "tracing", + "tracing-subscriber", + "winapi", + "zenoh-core", + "zenoh-result", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive 0.8.57", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes 0.9.3", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.2", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1 0.11.0", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zxcvbn" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9eaee90f4a795d1eb4ba6c51e1c1721d4784d550e8efa7b2600f29c867365e0" +dependencies = [ + "chrono", + "derive_builder", + "fancy-regex 0.18.0", + "itertools 0.14.0", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..20b326e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,79 @@ +[workspace] +resolver = "2" +members = [ + "crates/main", + "crates/types", + "crates/http", + "crates/http-proto", + "crates/jmap", + "crates/jmap-proto", + "crates/email", + "crates/imap", + "crates/imap-proto", + "crates/smtp", + "crates/managesieve", + "crates/pop3", + "crates/dav-proto", + "crates/scim-proto", + "crates/scim", + "crates/dav", + "crates/groupware", + "crates/spam-filter", + "crates/nlp", + "crates/store", + "crates/coordinator", + "crates/directory", + "crates/registry", + "crates/services", + "crates/utils", + "crates/common", + "crates/trc", + "crates/migration", + "tests", +] + +[workspace.lints.clippy] +result_unit_err = "allow" + +[profile.dev] +opt-level = 0 +debug = 1 +#codegen-units = 4 +lto = false +incremental = true +panic = 'unwind' +debug-assertions = true +overflow-checks = false +rpath = false + +[profile.release] +opt-level = 3 +debug = false +codegen-units = 1 +lto = true +incremental = false +panic = 'unwind' +debug-assertions = false +overflow-checks = false +rpath = false +strip = true + +[profile.test] +opt-level = 0 +debug = 1 +#codegen-units = 16 +lto = false +incremental = true +debug-assertions = true +overflow-checks = true +rpath = false + +[profile.bench] +opt-level = 3 +debug = false +codegen-units = 1 +lto = true +incremental = false +debug-assertions = false +overflow-checks = false +rpath = false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1f5475 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie AS chef +WORKDIR /build + +FROM --platform=$BUILDPLATFORM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path /recipe.json + +FROM --platform=$BUILDPLATFORM chef AS builder +ARG TARGETPLATFORM +RUN case "${TARGETPLATFORM}" in \ + "linux/arm64") echo "aarch64-unknown-linux-gnu" > /target.txt && echo "-C linker=aarch64-linux-gnu-gcc" > /flags.txt ;; \ + "linux/amd64") echo "x86_64-unknown-linux-gnu" > /target.txt && echo "-C linker=x86_64-linux-gnu-gcc" > /flags.txt ;; \ + *) exit 1 ;; \ + esac +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends build-essential libclang-19-dev \ + g++-aarch64-linux-gnu binutils-aarch64-linux-gnu \ + g++-x86-64-linux-gnu binutils-x86-64-linux-gnu +RUN rustup target add "$(cat /target.txt)" +COPY --from=planner /recipe.json /recipe.json +RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" --recipe-path /recipe.json +COPY . . +RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" +RUN mv "/build/target/$(cat /target.txt)/release" "/output" + +FROM docker.io/debian:trixie-slim +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r -g 2000 stalwart && \ + useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ + mkdir -p /etc/stalwart /var/lib/stalwart && \ + chown stalwart:stalwart /etc/stalwart /var/lib/stalwart +COPY --from=builder --chmod=0755 /output/stalwart /usr/local/bin/stalwart +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart +USER stalwart +WORKDIR /var/lib/stalwart +VOLUME ["/etc/stalwart", "/var/lib/stalwart"] +EXPOSE 443 25 110 587 465 143 993 995 4190 8080 +ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 +ENTRYPOINT ["/usr/local/bin/stalwart"] +CMD ["--config", "/etc/stalwart/config.json"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..925a372 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,195 @@ +# syntax=docker/dockerfile:1 +# check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform + +# ***************** +# Base image for planner & builder +# ***************** +FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base + +ENV DEBIAN_FRONTEND="noninteractive" \ + BINSTALL_DISABLE_TELEMETRY=true \ + CARGO_TERM_COLOR=always \ + CARGO_NET_RETRY=10 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true \ + LANG=C.UTF-8 \ + TZ=UTC \ + TERM=xterm-256color \ + AWS_LC_SYS_PREBUILT_NASM=1 +# With zig, we only need libclang and make. ca-certificates is required for curl +# to verify HTTPS downloads (zig tarballs, FoundationDB debs, crates.io, etc). +RUN \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean && \ + echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl git jq xz-utils make libclang-19-dev +# Zig is pinned to 0.13.0. Zig 0.14+ changed how its default target ABI is +# resolved for `x86_64-linux-gnu`-style triples, and on Stalwart's dep tree +# (rocksdb, jemalloc, aws-lc-sys, libfdb_c.so, rust-std 1.87+) this surfaces +# as ld.lld undefined references to pthread_*/stat/pow@GLIBC_2.33+. An +# explicit glibc suffix (`.2.17`) is NOT a workaround — C deps compiled via +# zig cc against Zig 0.16 headers still emit @2.33+ symbols even when the +# linker target is 2.17. Zig 0.13 produces binaries with a max glibc ref of +# ~2.28 which stays compatible with Debian 11 / RHEL 8. Revisit this pin if +# Zig upstream stabilizes a backwards-compatible default or if Stalwart drops +# its hard C deps. +RUN \ + ZIG_VERSION=0.13.0 && \ + curl --retry 5 -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-$(uname -m)-${ZIG_VERSION}.tar.xz" | tar -J -x -C /usr/local && \ + ln -s "/usr/local/zig-linux-$(uname -m)-${ZIG_VERSION}/zig" /usr/local/bin/zig && \ + zig version +# Install cargo-binstall +RUN curl --retry 5 -fL --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +# Install cargo-chef & sccache & cargo-zigbuild +RUN cargo binstall --no-confirm cargo-chef@0.1.78 sccache@0.17.0 cargo-zigbuild@0.23.2 + +# ***************** +# Planner +# ***************** +FROM base AS planner +WORKDIR /app +COPY . . +# Generate recipe file +RUN cargo chef prepare --recipe-path recipe.json + +# ***************** +# Builder +# ***************** +FROM base AS builder +WORKDIR /app +COPY --from=planner /app/recipe.json recipe.json +ARG TARGET +ARG BUILD_ENV +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# Install toolchain and specify some env variables +RUN \ + rustup set profile minimal && \ + rustup target add ${TARGET} && \ + mkdir -p artifact && \ + touch /env-cargo && \ + if [ ! -z "${BUILD_ENV}" ]; then \ + echo "export ${BUILD_ENV}" >> /env-cargo; \ + echo "Setting up ${BUILD_ENV}"; \ + fi && \ + if [[ "${TARGET}" == *gnu ]]; then \ + base_arch="${TARGET%%-*}"; \ + case "$base_arch" in \ + x86_64) \ + echo "export FDB_ARCH=amd64" >> /env-cargo; \ + ;; \ + aarch64) \ + echo "export FDB_ARCH=aarch64" >> /env-cargo; \ + ;; \ + *) \ + exit 1; \ + ;; \ + esac; \ + fi +# Install FoundationDB (pinned to latest 7.4.x; Apple publishes 7.4 as +# prereleases on GitHub, so the release list is fetched without the +# prerelease filter and narrowed by tag name). +ARG FDB_VERSION_RANGE="7.4" +RUN \ + source /env-cargo && \ + if [ ! -z "${FDB_ARCH}" ]; then \ + curl --retry 5 -fLso fdb-client.deb "$(curl --retry 5 -fLs 'https://api.github.com/repos/apple/foundationdb/releases?per_page=100' | jq --arg FDB_ARCH "$FDB_ARCH" --arg RANGE "${FDB_VERSION_RANGE}" -r '[.[] | select(.tag_name | startswith($RANGE + "."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("foundationdb-clients.*" + $FDB_ARCH + ".deb$")) | .browser_download_url')" && \ + mkdir -p /fdb && \ + dpkg -x fdb-client.deb /fdb && \ + mv /fdb/usr/include/foundationdb /usr/include && \ + mv /fdb/usr/lib/libfdb_c.so /usr/lib && \ + rm -rf fdb-client.deb /fdb; \ + fi +# Cargo-chef Cache layer +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + if [ ! -z "${FDB_ARCH}" ]; then \ + RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise"; \ + fi +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" +# Copy the source code +COPY . . +ENV RUSTC_WRAPPER="sccache" \ + SCCACHE_GHA_ENABLED=true +# Build FoundationDB version +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + if [ ! -z "${FDB_ARCH}" ]; then \ + RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise" && \ + mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart-foundationdb; \ + fi +# Build generic version +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" && \ + mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart + +# ***************** +# Binary stage +# ***************** +FROM scratch AS binaries +COPY --from=builder /app/artifact / + +# ***************** +# Runtime image for GNU targets +# ***************** +FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim AS gnu +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r -g 2000 stalwart && \ + useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ + mkdir -p /etc/stalwart /var/lib/stalwart && \ + chown stalwart:stalwart /etc/stalwart /var/lib/stalwart +COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart +USER stalwart +WORKDIR /var/lib/stalwart +VOLUME ["/etc/stalwart", "/var/lib/stalwart"] +EXPOSE 443 25 110 587 465 143 993 995 4190 8080 +ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 +ENTRYPOINT ["/usr/local/bin/stalwart"] +CMD ["--config", "/etc/stalwart/config.json"] + +# ***************** +# Runtime image for musl targets +# ***************** +FROM --platform=$TARGETPLATFORM alpine AS musl +RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \ + rm -rf /var/cache/apk/* && \ + addgroup -S -g 2000 stalwart && \ + adduser -S -D -H -u 2000 -G stalwart -s /sbin/nologin stalwart && \ + mkdir -p /etc/stalwart /var/lib/stalwart && \ + chown stalwart:stalwart /etc/stalwart /var/lib/stalwart +COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart +USER stalwart +WORKDIR /var/lib/stalwart +VOLUME ["/etc/stalwart", "/var/lib/stalwart"] +EXPOSE 443 25 110 587 465 143 993 995 4190 8080 +ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 +ENTRYPOINT ["/usr/local/bin/stalwart"] +CMD ["--config", "/etc/stalwart/config.json"] diff --git a/Dockerfile.fdb b/Dockerfile.fdb new file mode 100644 index 0000000..fd732f8 --- /dev/null +++ b/Dockerfile.fdb @@ -0,0 +1,80 @@ +# syntax=docker/dockerfile:1 + +FROM debian:trixie-slim AS chef +ARG TARGETARCH +ARG FDB_VERSION_RANGE="7.4" +RUN apt-get update && \ + export DEBIAN_FRONTEND=noninteractive && \ + apt-get install -yq --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + clang \ + curl \ + jq \ + protobuf-compiler +ENV RUSTUP_HOME=/opt/rust/rustup \ + PATH=/home/root/.cargo/bin:/opt/rust/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +RUN curl https://sh.rustup.rs -sSf | \ + env CARGO_HOME=/opt/rust/cargo \ + sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path && \ + env CARGO_HOME=/opt/rust/cargo \ + rustup component add rustfmt +RUN \ + ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ + case "$ARCH" in \ + amd64) FDB_ARCH=amd64 ;; \ + arm64) FDB_ARCH=aarch64 ;; \ + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ + esac && \ + curl --retry 5 -fLso fdb-client.deb "$(curl --retry 5 -fLs 'https://api.github.com/repos/apple/foundationdb/releases?per_page=100' | jq --arg FDB_ARCH "$FDB_ARCH" --arg RANGE "${FDB_VERSION_RANGE}" -r '[.[] | select(.tag_name | startswith($RANGE + "."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("foundationdb-clients.*" + $FDB_ARCH + ".deb$")) | .browser_download_url')" && \ + mkdir -p /fdb && \ + dpkg -x fdb-client.deb /fdb && \ + mv /fdb/usr/include/foundationdb /usr/include && \ + mv /fdb/usr/lib/libfdb_c.so /usr/lib && \ + rm -rf fdb-client.deb /fdb +RUN env CARGO_HOME=/opt/rust/cargo cargo install cargo-chef && \ + rm -rf /opt/rust/cargo/registry/ +WORKDIR /app + +FROM chef AS planner +COPY Cargo.toml . +COPY Cargo.lock . +COPY crates/ crates/ +COPY resources/ resources/ +COPY tests/ tests/ +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY Cargo.toml . +COPY Cargo.lock . +COPY crates/ crates/ +COPY resources/ resources/ +COPY tests/ tests/ +RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats enterprise" --release + +FROM debian:trixie-slim AS runtime + +COPY --from=builder --chmod=0755 /app/target/release/stalwart /usr/local/bin/stalwart +COPY --from=builder /usr/lib/libfdb_c.so /usr/lib/libfdb_c.so +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r -g 2000 stalwart && \ + useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ + mkdir -p /etc/stalwart /var/lib/stalwart && \ + chown stalwart:stalwart /etc/stalwart /var/lib/stalwart && \ + setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart + +USER stalwart +WORKDIR /var/lib/stalwart +VOLUME ["/etc/stalwart", "/var/lib/stalwart"] +EXPOSE 443 25 110 587 465 143 993 995 4190 8080 +ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 +ENTRYPOINT ["/usr/local/bin/stalwart"] +CMD ["--config", "/etc/stalwart/config.json"] diff --git a/LICENSES/AGPL-3.0-only.txt b/LICENSES/AGPL-3.0-only.txt new file mode 100644 index 0000000..0c97efd --- /dev/null +++ b/LICENSES/AGPL-3.0-only.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/LicenseRef-SEL.txt b/LICENSES/LicenseRef-SEL.txt new file mode 100644 index 0000000..1345ee1 --- /dev/null +++ b/LICENSES/LicenseRef-SEL.txt @@ -0,0 +1,213 @@ +# Stalwart Enterprise License 2.0 (SELv2) Agreement + +*Last Update: March 29, 2026* + +PLEASE CAREFULLY READ THIS STALWART ENTERPRISE LICENSE AGREEMENT ("AGREEMENT"). THIS AGREEMENT CONSTITUTES A LEGALLY BINDING AGREEMENT BETWEEN YOU AND STALWART LABS LLC AND GOVERNS YOUR USE OF THE SOFTWARE (DEFINED BELOW). IF YOU DO NOT AGREE WITH THIS AGREEMENT, YOU MAY NOT USE THE SOFTWARE. IF YOU ARE USING THE SOFTWARE ON BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE AUTHORITY TO AGREE TO THIS AGREEMENT ON BEHALF OF SUCH ENTITY. IF YOU DO NOT HAVE SUCH AUTHORITY, DO NOT USE THE SOFTWARE IN ANY MANNER. + +This Agreement is entered into by and between Stalwart Labs LLC and you, or the legal entity on behalf of whom you are acting. + +--- + +## 1. DEFINITIONS + +1.1. "Software" refers to the Stalwart Server Enterprise Edition software, including all its versions, updates, modifications, accompanying documentation, and related materials. The Software is self-hosted by Licensee on its own infrastructure. + +1.2. "Subscription" refers to the paid access to the Software provided by Licensor to Licensee, billed on a monthly or annual basis. + +1.3. "Licensor" refers to Stalwart Labs LLC, the entity providing the Software. + +1.4. "Licensee" refers to the individual or entity installing, accessing, or using the Software with a valid Subscription. + +1.5. "License Key" refers to the unique code provided by Licensor upon purchasing a Subscription which activates the full features of the Software. Each License Key is bound to the domain name (including all subdomains) designated by Licensee at the time of purchase. + +1.6. "Source Code" refers to the human-readable version of the Software's code, as opposed to the compiled machine-readable version. + +1.7. "Mailbox" refers to each individual user account or group account provisioned within the Software. The total number of Mailboxes across all domains and tenants hosted by Licensee determines the applicable Subscription tier. + +1.8. "Confidential Information" refers to any non-public information disclosed by either party to the other in connection with this Agreement, whether in written, oral, electronic, or other form, that is designated as confidential or that a reasonable person would understand to be confidential given the nature of the information and circumstances of disclosure. + +## 2. GRANT OF LICENSE + +2.1. Licensor grants Licensee a non-exclusive, non-transferable, non-sublicensable, limited license to download, install, and use the Software during the Subscription term, subject to the terms and conditions of this Agreement. + +2.2. The use of the Software is conditioned upon Licensee maintaining an active and valid paid Subscription with Licensor. The paid Subscription covers all versions of the Software and all updates and modifications released during the Subscription term. + +2.3. This license grants Licensee the right to use the Software for both personal and commercial purposes. Licensee may install and operate the Software on an unlimited number of servers within its organization, host an unlimited number of domains, and host data for an unlimited number of external organizations (tenants) using the Software's multi-tenancy features. The Subscription tier is determined solely by the total number of Mailboxes provisioned. However, Licensee is expressly prohibited from reselling, leasing, sublicensing, or otherwise redistributing the Software itself. + +2.4. This license is further governed by the terms and conditions set forth in any licensing agreements separately executed between Licensor and Licensee. In the event of any conflict between the terms of this Agreement and the terms of a signed licensing agreement, the terms of the signed licensing agreement shall control. + +2.5. You are not granted any other rights beyond what is expressly stated herein. + +## 3. LICENSE KEYS + +3.1. The Software shall not be used without a valid License Key issued by Licensor. + +3.2. Licensee is required to use valid License Keys issued by Licensor to run the Software, including any modified versions. Any attempts to bypass the License Key requirement is a violation of this Agreement. + +3.3. Distribution or sharing of License Keys to third parties, not associated with Licensee, is strictly prohibited. + +3.4. License Keys are bound to the Subscription period. Should your Subscription expire or be cancelled, all License Keys will become invalid after fifteen (15) days from the Subscription expiration or cancellation date. + +3.5. Any instance of the Software using such an expired key will revert to the Community Edition functionality after the aforementioned fifteen (15) day period. + +## 4. SOURCE CODE USAGE + +4.1. Licensee is permitted to view, copy, and modify the Software's Source Code, as made available by Licensor, solely for Licensee's internal business use and in compliance with this Agreement's terms. + +4.2. Any modifications to the Source Code do not grant Licensee any ownership rights to the original Software or any modifications. All rights, title, and interest to the Software and its Source Code remain exclusively with Licensor. + +4.3. Licensee is strictly prohibited from altering, removing, or in any way tampering with the License Key validation system within the Software. Any such unauthorized modifications will be considered a material breach of this Agreement and may result in legal action. + +4.4. Notwithstanding the availability of the Software's Source Code for review and limited modification, the Software and its Source Code are not open source and remain proprietary to Licensor. The provision of access to the Source Code does not confer any rights typically associated with open source software, including but not limited to the right to freely sublicense, or create derivative works for public distribution. All rights not expressly granted herein are reserved by Licensor. + +4.5. Notwithstanding the foregoing, you may copy the Source Code for development and testing purposes, without requiring a Subscription. + +## 5. INTELLECTUAL PROPERTY RIGHTS + +5.1. The Licensor retains all rights, title, and interest in and to the Software, including all intellectual property rights therein. This Agreement does not transfer any ownership rights to the Licensee. + +5.2. The Licensee must not remove, alter, or obscure any proprietary notices (including copyright and trademark notices) on the Software. + +## 6. SUBSCRIPTION TERMS, RENEWAL, AND CANCELLATION + +6.1. Subscriptions are available on a monthly or annual basis. The applicable fees, Mailbox tier, and billing cycle will be as set forth at the time of purchase or as subsequently agreed in writing between the parties. + +6.2. Where Licensee has provided a valid payment method (such as a credit card) on file, the Subscription will automatically renew at the end of each billing cycle at the then-current rate, unless Licensee removes the payment method or cancels the Subscription prior to the renewal date. No advance cancellation notice period is required; Licensee may cancel at any time by removing the payment method on file or by notifying Licensor. + +6.3. Where Licensee pays by invoice (bank transfer), the Subscription will not automatically renew. Licensor will issue an invoice notification prior to the end of the billing cycle, and the Subscription will renew only upon receipt of payment. + +6.4. Upon cancellation of a Subscription by Licensee prior to the end of a paid billing cycle, Licensee is entitled to a prorated refund for the unused portion of the then-current billing period. Refunds will be calculated from the effective date of cancellation through the end of the billing cycle and will be issued within thirty (30) days of the cancellation date. + +6.5. Licensor reserves the right to modify Subscription fees upon renewal. Any fee changes will be communicated to Licensee at least thirty (30) days prior to the start of the next billing cycle. + +## 7. SUPPORT AND SERVICE LEVELS + +7.1. All Licensees with an active Subscription have access to standard community support resources, including documentation and community forums, as made available by Licensor. + +7.2. Priority email support is available exclusively to Licensees whose Subscription covers one hundred fifty (150) or more Mailboxes. Priority email support inquiries will receive an initial response within forty-eight (48) hours of receipt during Licensor's standard business hours. + +7.3. The forty-eight (48) hour response time set forth in Section 7.2 constitutes a service level commitment. In the event Licensor consistently fails to meet this commitment over a period of thirty (30) consecutive days, the affected Licensee's sole remedy shall be the right to terminate the Subscription and receive a prorated refund for the unused portion of the billing cycle. + +7.4. The Software is self-hosted by Licensee on Licensee's own infrastructure. Licensor does not provide hosting services and makes no guarantees regarding uptime, availability, or performance of Licensee's self-hosted deployment. + +## 8. TERMINATION + +8.1. Licensor may terminate this Agreement immediately upon written notice if Licensee commits a material breach of any term of this Agreement and fails to cure such breach within thirty (30) days of receiving written notice specifying the breach. + +8.2. Licensor may terminate this Agreement for convenience upon thirty (30) days' written notice to Licensee. In such event, Licensee shall receive a prorated refund for the unused portion of any prepaid Subscription fees. + +8.3. In the event of a termination, Licensee will be provided with written notice, sent to the email address used during Subscription registration, outlining the reasons for the termination. + +8.4. Upon termination, all rights granted to Licensee under this Agreement will cease, and Licensee must promptly cease all use of the Software and destroy or delete all copies in its possession, except that Licensee may retain copies of the Source Code obtained prior to termination solely for archival purposes, subject to the continuing obligations of confidentiality and intellectual property protection set forth herein. + +## 9. CONFIDENTIALITY + +9.1. Each party agrees to hold the other party's Confidential Information in strict confidence and not to disclose such information to any third party, except to employees, contractors, or agents who have a need to know and are bound by confidentiality obligations no less protective than those contained herein. + +9.2. Confidential Information does not include information that: (a) is or becomes publicly available through no fault of the receiving party; (b) was rightfully in the receiving party's possession prior to disclosure; (c) is independently developed by the receiving party without use of the disclosing party's Confidential Information; or (d) is rightfully obtained from a third party without restriction on disclosure. + +9.3. A receiving party may disclose Confidential Information to the extent required by applicable law, regulation, or court order, provided that the receiving party gives the disclosing party prompt written notice (where legally permissible) and cooperates with the disclosing party's efforts to seek protective treatment of such information. + +9.4. The obligations of confidentiality set forth in this Section shall survive the termination or expiration of this Agreement for a period of three (3) years. + +## 10. LIMITATION OF LIABILITY + +10.1. In no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from (i) your use or inability to use the Software; (ii) any unauthorized access to or use of your servers and/or any personal information stored therein. + +10.2. Except for liability arising from death or personal injury caused by negligence, fraud, willful misconduct, or a party's indemnification obligations under this Agreement, Licensor's total aggregate liability for any and all claims under this Agreement shall be limited to the total Subscription fees paid by Licensee to Licensor in the twelve (12) months immediately preceding the event giving rise to the claim. + +## 11. INDEMNIFICATION + +11.1. Licensee agrees to indemnify, defend, and hold harmless Licensor, its officers, directors, employees, agents, licensors, suppliers, and any third-party information providers from and against all claims, losses, expenses, damages, and costs, including reasonable attorneys' fees, resulting from any violation of this Agreement or any activity related to Licensee's use or misuse of the Software (including negligent or wrongful conduct). + +11.2. Licensor agrees to indemnify, defend, and hold harmless Licensee from and against any third-party claim that the Software, as provided by Licensor, infringes or misappropriates any patent, copyright, trademark, or trade secret of a third party, provided that Licensee: (a) gives Licensor prompt written notice of such claim; (b) grants Licensor sole control of the defense and settlement of such claim; and (c) provides reasonable cooperation at Licensor's expense. + +11.3. If the Software becomes, or in Licensor's opinion is likely to become, the subject of an infringement claim, Licensor may at its option and expense: (a) procure for Licensee the right to continue using the Software; (b) modify or replace the Software to make it non-infringing while maintaining substantially equivalent functionality; or (c) if neither (a) nor (b) is commercially practicable, terminate this Agreement and provide Licensee with a prorated refund of any prepaid Subscription fees. + +11.4. Licensor shall have no obligation under this Section for any claim arising from: (a) modifications to the Software made by Licensee; (b) use of the Software in combination with products, services, or technologies not provided by Licensor, where the infringement would not have occurred but for such combination; or (c) Licensee's continued use of a version of the Software after being notified of the availability of a non-infringing update. + +## 12. DATA PROTECTION AND PRIVACY + +12.1. The Software is self-hosted by Licensee, and Licensee retains sole responsibility for all data stored and processed within its deployment of the Software, including any personal data of its users or tenants. + +12.2. To the extent that Licensor processes any personal data on behalf of Licensee (for example, in connection with support services or license management), such processing shall be conducted in accordance with applicable data protection laws, including but not limited to the General Data Protection Regulation (GDPR) where applicable, the California Consumer Privacy Act (CCPA) where applicable, and any other relevant data protection legislation. + +12.3. Where required by applicable data protection law, the parties shall enter into a separate Data Processing Agreement ("DPA") that sets forth the terms and conditions governing Licensor's processing of personal data on behalf of Licensee. + +12.4. In the event of a data breach affecting personal data processed by Licensor in connection with this Agreement, Licensor shall notify Licensee without undue delay and in any event within seventy-two (72) hours of becoming aware of the breach, and shall cooperate with Licensee in investigating and remediating the breach. + +12.5. Additional details regarding Licensor's data handling practices are outlined in Licensor's Privacy Policy, which can be accessed on Licensor's website. + +## 13. EXPORT COMPLIANCE + +13.1. The Software may be subject to export control and sanctions laws of the United States and other jurisdictions. Licensee agrees to comply with all applicable export control laws, including without limitation the U.S. Export Administration Regulations (EAR) and the regulations administered by the U.S. Department of the Treasury's Office of Foreign Assets Control (OFAC). + +13.2. Licensee represents and warrants that: (a) Licensee is not located in, organized under the laws of, or a resident of any country or territory subject to comprehensive U.S. sanctions (currently including Cuba, Iran, North Korea, Syria, and the Crimea, Donetsk, and Luhansk regions of Ukraine); (b) Licensee is not listed on any U.S. government restricted party list; and (c) Licensee will not export, re-export, or transfer the Software to any prohibited destination, entity, or individual without the required governmental authorizations. + +## 14. ANTI-CORRUPTION + +14.1. Each party represents and warrants that it has not and will not, in connection with this Agreement, directly or indirectly offer, pay, promise to pay, or authorize the payment of any money or anything of value to any government official, political party, or candidate for political office for the purpose of influencing any act or decision, or securing any improper advantage. + +14.2. Each party shall comply with all applicable anti-corruption and anti-bribery laws, including without limitation the U.S. Foreign Corrupt Practices Act (FCPA) and the UK Bribery Act 2010. + +## 15. GOVERNING LAW AND DISPUTE RESOLUTION + +15.1. This Agreement shall be governed by and construed under the laws of the State of Wyoming, United States of America, without regard to its conflict of laws principles. + +15.2. Any dispute, controversy, or claim arising out of or relating to this Agreement, or the breach, termination, or invalidity thereof, shall first be attempted to be resolved through good faith negotiation between the parties for a period of thirty (30) days following written notice of the dispute. + +15.3. If the dispute is not resolved through negotiation within the thirty (30) day period, it shall be finally resolved by binding arbitration administered by the American Arbitration Association ("AAA") in accordance with its Commercial Arbitration Rules. The arbitration shall be conducted in Sheridan, Wyoming, before a single arbitrator. The language of the arbitration shall be English. + +15.4. The arbitrator's award shall be final and binding and may be entered as a judgment in any court of competent jurisdiction. Each party shall bear its own costs and attorneys' fees in connection with the arbitration, unless the arbitrator determines otherwise. + +15.5. Notwithstanding the foregoing, either party may seek injunctive or other equitable relief in any court of competent jurisdiction to protect its intellectual property rights or Confidential Information without first submitting to arbitration. + +## 16. NOTICES + +16.1. All notices required or permitted under this Agreement shall be in writing and shall be deemed effectively given: (a) upon personal delivery; (b) upon confirmed transmission by email; or (c) one (1) business day after deposit with a nationally recognized overnight courier service. + +16.2. Notices to Licensor shall be sent to the address and email set forth in Section 21 (Contact Information) of this Agreement. Notices to Licensee shall be sent to the email address provided during Subscription registration or as subsequently updated by Licensee in writing. + +## 17. ASSIGNMENT + +17.1. Licensee may not transfer or assign this Agreement or any rights or obligations hereunder without the prior written consent of Licensor, except that Licensee may assign this Agreement without consent in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, provided that the assignee agrees in writing to be bound by the terms of this Agreement. + +17.2. Licensor may assign this Agreement without restriction. Any assignment in violation of this Section shall be null and void. + +## 18. DISCLAIMERS AND WARRANTIES + +18.1. The Software is provided "AS IS" and "AS AVAILABLE", without warranty of any kind, either express or implied, including, without limitation, warranties of merchantability, fitness for a particular purpose, and non-infringement. + +18.2. Licensor does not warrant that the Software will be error-free, that access thereto will be uninterrupted, or that defects will be corrected. + +18.3. Licensor warrants that, as of the date of delivery, the Software will perform substantially in accordance with the accompanying documentation for a period of ninety (90) days. Licensee's sole remedy for breach of this warranty shall be, at Licensor's option, repair or replacement of the non-conforming Software, or a refund of the Subscription fees paid for the period during which the Software was non-conforming. + +## 19. FORCE MAJEURE + +Neither party shall be in default or otherwise liable for any delay in or failure of its performance under this Agreement if such delay or failure arises by any reason of any event beyond the reasonable control of a party, including acts of God, the elements, earthquakes, floods, fires, epidemics, riots, failures or delays in transportation or communications, or any act or failure to act by the other party or such other party's officers, employees, agents, or contractors. The affected party shall give prompt notice to the other party and shall use commercially reasonable efforts to mitigate the effects of the force majeure event. If a force majeure event continues for more than ninety (90) days, either party may terminate this Agreement upon written notice, and Licensee shall receive a prorated refund of any prepaid Subscription fees. + +## 20. SURVIVAL + +The following Sections shall survive the termination or expiration of this Agreement: Section 1 (Definitions), Section 4.2 (Ownership of Modifications), Section 4.4 (Proprietary Nature of Software), Section 5 (Intellectual Property Rights), Section 9 (Confidentiality), Section 10 (Limitation of Liability), Section 11 (Indemnification), Section 12 (Data Protection and Privacy), Section 13 (Export Compliance), Section 15 (Governing Law and Dispute Resolution), and Section 20 (Survival). + +## 21. SEVERABILITY + +If any provision of this Agreement is held to be unenforceable or invalid for any reason, that provision shall be reformed to the extent necessary to make it enforceable and consistent with the intent of the parties, and the remaining provisions shall remain in full force and effect. + +## 22. ENTIRE AGREEMENT + +This Agreement constitutes the entire agreement between the Licensor and the Licensee with respect to the subject matter hereof and supersedes all prior or contemporaneous understandings regarding such subject matter. No amendment to or modification of this Agreement will be binding unless in writing and signed by the Licensor. + +## 23. ACCEPTANCE + +By downloading, installing, or using the Software, even without explicitly clicking on an "I Agree" button or a similar mechanism, you acknowledge that you have read, understood, and agreed to be bound by the terms and conditions of this Agreement. + +## 24. CONTACT INFORMATION + +If you have any questions about this Agreement, please contact Stalwart Labs LLC at: + +Stalwart Labs LLC +1309 Coffeen Avenue STE 1200 +Sheridan, Wyoming 82801 +USA +hello@stalw.art diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fe4842 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +

+ + + +

+ +

+ Secure, scalable mail & collaboration server with comprehensive protocol support 🛡️
(IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV) +

+ +
+ +

+ continuous integration +   + License: AGPL v3 +   + Documentation +   + downloads +

+ Mastodon +   + Twitter + Discord +   + Reddit +

+ +## Features + +**Stalwart** is an open-source mail & collaboration server with JMAP, IMAP4, POP3, SMTP, CalDAV, CardDAV and WebDAV support and a wide range of modern features. It is written in Rust and designed to be secure, fast, robust and scalable. + +Key features: + +- **Email** server with complete protocol support: + - JMAP: + * [JMAP for Mail](https://datatracker.ietf.org/doc/html/rfc8621) server. + * [JMAP for Sieve Scripts](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-22.html). + * [WebSocket](https://datatracker.ietf.org/doc/html/rfc8887), [Blob Management](https://www.rfc-editor.org/rfc/rfc9404.html) and [Quotas](https://www.rfc-editor.org/rfc/rfc9425.html) extensions. + - IMAP: + * [IMAP4rev2](https://datatracker.ietf.org/doc/html/rfc9051) and [IMAP4rev1](https://datatracker.ietf.org/doc/html/rfc3501) server. + * [ManageSieve](https://datatracker.ietf.org/doc/html/rfc5804) server. + * Numerous [extensions](https://stalw.art/docs/development/rfcs#imap4-and-extensions) supported. + - POP3: + - [POP3](https://datatracker.ietf.org/doc/html/rfc1939) server. + - [STLS](https://datatracker.ietf.org/doc/html/rfc2595) and [SASL](https://datatracker.ietf.org/doc/html/rfc5034) support as well as other [extensions](https://datatracker.ietf.org/doc/html/rfc2449). + - SMTP: + * SMTP server with built-in [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), [DKIMv2](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/), [DKIMv1](https://datatracker.ietf.org/doc/html/rfc6376), [SPF](https://datatracker.ietf.org/doc/html/rfc7208) and [ARC](https://datatracker.ietf.org/doc/html/rfc8617) support for message authentication. + * Strong transport security through [DANE](https://datatracker.ietf.org/doc/html/rfc6698), [MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) and [SMTP TLS](https://datatracker.ietf.org/doc/html/rfc8460) reporting. + * Automated DKIM key rotation and management. + * Inbound throttling and filtering with granular configuration rules, sieve scripting, MTA hooks and milter integration. + * Distributed virtual queues with delayed delivery, priority delivery, quotas, routing rules and throttling support. + * Envelope rewriting and message modification. +- **Collaboration** server: + - Calendaring and scheduling: + - [CalDAV](https://datatracker.ietf.org/doc/html/rfc4791) and [CalDAV Scheduling](https://datatracker.ietf.org/doc/html/rfc6638) support. + - [JMAP for Calendars](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-calendars-24) support. + - Contact management: + - [CardDAV](https://datatracker.ietf.org/doc/html/rfc6352) support. + - [JMAP for Contacts](https://datatracker.ietf.org/doc/html/rfc9610) support. + - File storage: + - [WebDAV](https://datatracker.ietf.org/doc/html/rfc4918) support. + - [JMAP for File Storage](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-filenode-03) support. + - Sharing with fine-grained access controls: + - [WebDAV ACL](https://datatracker.ietf.org/doc/html/rfc3744) support. + - [JMAP Sharing](https://datatracker.ietf.org/doc/html/rfc9670) support. +- **Spam** and **Phishing** built-in filter: + - Comprehensive set of filtering **rules** on par with popular solutions. + - LLM-driven spam filtering and message analysis. + - Statistical **spam classifier** with collaborative filtering, automatic training capabilities and address book integration. + - DNS Blocklists (**DNSBLs**) checking of IP addresses, domains, and hashes. + - Collaborative digest-based spam filtering with **Pyzor**. + - **Phishing** protection against homographic URL attacks, sender spoofing and other techniques. + - Trusted **reply** tracking to recognize and prioritize genuine e-mail replies. + - Sender **reputation** monitoring by IP address, ASN, domain and email address. + - **Greylisting** to temporarily defer unknown senders. + - **Spam traps** to set up decoy email addresses that catch and analyze spam. +- **Flexible**: + - Pluggable storage backends with **RocksDB**, **FoundationDB**, **PostgreSQL**, **mySQL**, **SQLite**, **S3-Compatible**, **Azure** and **Redis** support. + - Full-text search available in 17 languages using the built-in search engine or via **Meilisearch**, **ElasticSearch**, **OpenSearch**, **PostgreSQL** or **mySQL** backends. + - Sieve scripting language with support for all [registered extensions](https://www.iana.org/assignments/sieve-extensions/sieve-extensions.xhtml). + - Email aliases, mailing lists, subaddressing and catch-all addresses support. + - Automated DNS management. + - Automatic account configuration and discovery with [PACC](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/), [autoconfig](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/) and [autodiscover](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019). + - Multi-tenancy support with domain and tenant isolation. + - Disk quotas per user and tenant. +- **Secure and robust**: + - Encryption at rest with **S/MIME** or **OpenPGP**. + - Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) using `TLS-ALPN-01`, `DNS-01`, `DNS-PERSIST-01` or `HTTP-01` challenges. + - Automated blocking of IP addresses that attack, abuse or scan the server for exploits. + - Rate limiting. + - Security audited (read the [report](https://stalw.art/blog/security-audit)). + - Memory safe (thanks to Rust). +- **Scalable and fault-tolerant**: + - Designed to handle growth seamlessly, from small setups to large-scale deployments of thousands of nodes. + - Built with **fault tolerance** and **high availability** in mind, recovers from hardware or software failures with minimal operational impact. + - Peer-to-peer cluster coordination or with **Kafka**, **Redpanda**, **NATS** or **Redis**. + - **Kubernetes**, **Apache Mesos** and **Docker Swarm** support for automated scaling and container orchestration. + - Read replicas, sharded blob storage and in-memory data stores for high performance and low latency. +- **Authentication and Authorization**: + - **OpenID Connect** authentication. + - OAuth 2.0 authorization with [authorization code](https://www.rfc-editor.org/rfc/rfc8628) and [device authorization](https://www.rfc-editor.org/rfc/rfc8628) flows. + - **LDAP**, **OIDC**, **SQL** or built-in authentication backend support. + - System for Cross-domain Identity Management ([SCIM](https://www.rfc-editor.org/info/rfc7643/)) v2 for automated provisioning. + - Two-factor authentication with Time-based One-Time Passwords (`2FA-TOTP`) + - Application passwords (App Passwords). + - Roles and permissions. + - Access Control Lists (ACLs). +- **Observability**: + - Logging and tracing with **OpenTelemetry**, journald, log files and console support. + - Metrics with **OpenTelemetry** and **Prometheus** integration. + - Webhooks for event-driven automation. + - Alerts with email and webhook notifications. + - Live tracing and metrics. +- **Web-based administration**: + - Dashboard with real-time statistics and monitoring. + - Account, domain, group and mailing list management. + - SMTP queue management for messages and outbound DMARC and TLS reports. + - Report visualization interface for received DMARC, TLS-RPT and Failure (ARF) reports. + - Configuration of every aspect of the mail server. + - Log viewer with search and filtering capabilities. + - Self-service portal for password reset and encryption-at-rest key management. + +## Screenshots + + + +## Presentation + +**Want a deeper dive?** Need to explain to your boss why Stalwart is the perfect fit? Whether you're evaluating options, making a case to your team, or simply curious about how it all works under the hood, these slides walk you through the key features, architecture, and benefits of Stalwart. Browse the [slides](https://stalw.art/slides) to see what makes it stand out. + +## Get Started + +Install Stalwart on your server by following the instructions for your platform: + +- [Linux / MacOS / FreeBSD](https://stalw.art/docs/install/platform/linux) +- [Windows](https://stalw.art/docs/install/platform/windows) +- [Docker](https://stalw.art/docs/install/platform/docker) + +All documentation is available at [stalw.art/docs](https://stalw.art/docs/install/get-started). + +## Support + +If you are having problems running Stalwart, found a bug, or just have a question, please head to the [Stalwart Support Portal](https://support.stalw.art) at [support.stalw.art](https://support.stalw.art). +Additionally, you may purchase an [Enterprise License](https://stalw.art/enterprise) to obtain priority support from Stalwart Labs LLC, including response-time commitments and a private Priority Support area on the portal. + +## Contributing + +We welcome contributions, but to keep the project maintainable there are a few things to know before opening a pull request. Because of the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors; to be added, post at [support.stalw.art](https://support.stalw.art) describing the change you would like to submit, together with a link to the proposed change. At this stage only bug fixes and translations are accepted, and new features are not, unless they involve just a few lines of code. +For the full guidelines, please read [CONTRIBUTING.md](CONTRIBUTING.md). + +## Roadmap + +Stalwart has reached an exciting point in its journey, it’s now **feature complete**. All the core functionality and open standard email and collaboration protocols that we set out to support are in place. In other words, Stalwart already does everything you’d expect from a modern, standards-compliant mail and collaboration platform. + +The next major milestone is all about refinement: finalizing the database schema and focusing on performance optimizations to ensure everything runs as efficiently and reliably as possible. Once that’s done, we’ll be ready to roll out version **1.0**. + +Of course, development doesn’t stop there. The community has contributed hundreds of great ideas for improvements and new features, everything from subtle usability tweaks to entirely new integrations. You can see the full list of proposals over on our [GitHub issues](https://github.com/stalwartlabs/stalwart/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement). If there’s something you’d like to see prioritized, just give it a thumbs up as we plan to implement enhancements based on the community’s votes. + +## Sponsorship + +Your support is crucial in helping us continue to improve the project, add new features, and maintain the highest level of quality. By [becoming a sponsor](https://opencollective.com/stalwart), you help fund the development and future of Stalwart. As a thank-you, sponsors who contribute $5 per month or more will automatically receive a [Enterprise edition](https://stalw.art/enterprise/) license. And, sponsors who contribute $30 per month or more, also have access to [Premium Support](https://stalw.art/support) from Stalwart Labs. + +## Funding + +Part of the development of this project was funded through: + +- [NGI0 Entrust Fund](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101069594. +- [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990. + +If you find the project useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you! + +## License + +This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v2 (SELv2)**: + +- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license. +- The [Stalwart Enterprise License v2 (SELv2)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements. + +Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory. + +## Copyright + +Copyright (C) 2020, Stalwart Labs LLC diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9d741d6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,154 @@ +# Security Policy for Stalwart + +## Supported Versions + +We provide security updates for the following versions of Stalwart: + +| Version | Supported | End of Support | +| ------- | ------------------ | -------------- | +| 0.16.x | :white_check_mark: | TBD | +| 0.15.x | :white_check_mark: | 2026-12-01 | +| < 0.14 | :x: | Ended | + +**Note**: We typically support the current major version and one previous major version. Users are strongly encouraged to upgrade to the latest version for the best security posture. + +## Reporting a Vulnerability + +We take the security of Stalwart very seriously. If you believe you've found a security vulnerability, we encourage you to inform us responsibly through coordinated disclosure. + +### How to Report + +**Do not report security vulnerabilities through public GitHub issues, discussions, or social media.** + +Instead, please use one of these secure channels: + +1. **Email** (preferred): Send details to `security@stalw.art` +2. **GitHub Security Advisories**: Use the "Report a vulnerability" button in the Security tab +3. **Backup contact**: If no response within 48 hours, email `hello@stalw.art` + +### What to Include + +To help us understand and address the issue quickly, please include: + +**Required Information:** +- Brief description of the vulnerability type +- Affected version(s) and components +- Steps to reproduce the issue +- Impact assessment (what could an attacker achieve?) + +**Helpful Additional Details:** +- Full paths of affected source files +- Specific commit/branch where the issue exists +- Required configuration to reproduce +- Proof-of-concept code (if available) +- Suggested mitigation or fix (if you have ideas) + +### Our Response Process + +**Timeline Commitments:** +- **Initial acknowledgment**: Within 24 hours +- **Detailed response**: Within 72 hours +- **Status updates**: Every 7 days until resolved +- **Resolution target**: 90 days for most issues + +**What We'll Do:** +1. Acknowledge your report and assign a tracking ID +2. Assess the vulnerability and determine severity +3. Develop and test a fix +4. Coordinate disclosure timeline with you +5. Release security update and publish advisory +6. Credit you in our security advisory (if desired) + +## Disclosure Policy + +We follow responsible disclosure principles: + +- **Coordinated disclosure**: We'll work with you to determine appropriate disclosure timing +- **Typical timeline**: 90 days from report to public disclosure +- **Early disclosure**: May occur if issue is being actively exploited +- **Delayed disclosure**: May be necessary for complex issues requiring significant changes + +## Scope + +This security policy applies to: + +**In Scope:** +- Stalwart (all supported versions) +- Official Docker images +- Documentation that could lead to insecure configurations +- Dependencies with security implications + +**Out of Scope:** +- Third-party integrations or plugins +- Issues requiring physical access to the server +- Social engineering attacks +- Attacks requiring compromised credentials (unless the vulnerability enables credential compromise) +- Theoretical vulnerabilities without practical exploitation + +## Security Measures + +**Our Commitments:** +- Regular security audits of dependencies using `cargo audit` +- Automated security scanning in CI/CD pipeline +- Following Rust security best practices +- Prompt security updates for critical dependencies +- Security-focused code review process + +**User Responsibilities:** +- Keep Stalwart updated to supported versions +- Follow security configuration guidelines +- Implement proper network security (firewalls, TLS, etc.) +- Regular security monitoring and logging +- Secure credential management + +## Legal Safe Harbor + +We support security research conducted in good faith. If you follow these guidelines: + +**We will NOT:** +- Initiate legal action against you +- Contact law enforcement about your research +- Suspend or terminate your access to Stalwart services + +**You must:** +- Only test against your own Stalwart installations +- Not access, modify, or delete user data +- Not perform testing that could degrade service availability +- Not publicly disclose the issue before coordinated disclosure +- Act in good faith and not for malicious purposes + +## Recognition + +We believe in recognizing security researchers who help keep Stalwart secure: + +- **Security Advisory Credits**: We'll credit you in our GitHub Security Advisories (unless you prefer to remain anonymous) +- **Hall of Fame**: Significant contributors may be listed in our security acknowledgments +- **Swag**: We may send Stalwart merchandise for notable contributions + +## Security Updates + +**Stay Informed:** +- Subscribe to our [GitHub releases](https://github.com/stalwartlabs/stalwart/releases) for security updates +- Join our community channels for security announcements +- Enable GitHub notifications for security advisories + +**Update Process:** +- Security updates are published as patch releases (e.g., 0.12.1 → 0.12.2) +- Critical vulnerabilities may receive out-of-band releases +- Docker images are updated simultaneously with releases +- Security advisories are published through GitHub Security Advisories + +## Contact Information + +- **Security reports**: security@stalw.art +- **General inquiries**: hello@stalw.art +- **PGP Key**: Available upon request for sensitive communications + +## Additional Resources + +- [Stalwart Security Incident Response Process](SECURITY_PROCESS.md) +- [Security Configuration Guide](https://stalw.art/docs/install/security) +- [Rust Security Advisory Database](https://rustsec.org/) + +*This security policy is effective as of June 20, 2025 and may be updated periodically. Check back regularly for updates.* + diff --git a/SECURITY_PROCESS.md b/SECURITY_PROCESS.md new file mode 100644 index 0000000..b32e96b --- /dev/null +++ b/SECURITY_PROCESS.md @@ -0,0 +1,173 @@ +# Stalwart Security Incident Response Checklist + +## Phase 1 : Initial Assessment & Validation + +### Updates + +<< Use this section to detail the report received, initial assessment, and validation results >> + +Example: + +I've reviewed the security report and confirmed this vulnerability exists in Stalwart version X.Y.Z. + +Assessment of exploitability: + +- Attack complexity: [High/Medium/Low] +- Prerequisites: [Authentication required/Network access/Specific configuration/etc.] +- User interaction required: [Yes/No] + +Potential impact: +- Email data confidentiality: [At risk/Not affected] +- Server integrity: [At risk/Not affected] +- Service availability: [At risk/Not affected] +- Estimated affected installations: [Number/Percentage] + +### Resources + +- [Stalwart Security Policy](https://github.com/stalwartlabs/stalwart/blob/main/SECURITY.md) +- [CVE Scoring Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator) +- [Rust Security Advisory Database](https://rustsec.org/) + +### Tasks + +- [ ] Reproduce the vulnerability in test environment +- [ ] Assess CVSS score and severity level +- [ ] Check if vulnerability affects current stable version +- [ ] Check if vulnerability affects LTS versions (if applicable) +- [ ] Determine if this requires immediate action or can wait for next release cycle +- [ ] Document technical details and root cause + +### Assessment Summary + +- **Severity Level**: `Critical|High|Medium|Low` +- **CVSS Score**: `X.X` +- **Affects versions**: `X.Y.Z to X.Y.Z` +- **Root cause**: Brief technical explanation +- **Introduced in commit/version**: `commit-hash` or `vX.Y.Z` +- **Attack vector**: `Network|Local|Physical` +- **Estimated timeline for fix**: `X days/weeks` + +## Phase 2: Immediate Response & Mitigation + +### Updates + +<< Document immediate actions taken and mitigation strategies >> + +Example: + +Working on hotfix for version X.Y.Z. Temporary workaround available by disabling [feature] in configuration. + +### Tasks + +- [ ] Implement immediate workaround if possible +- [ ] Update security advisory draft +- [ ] Prepare patch/hotfix +- [ ] Test fix thoroughly in development environment +- [ ] Prepare updated Docker images and binaries +- [ ] Draft security advisory for GitHub Security Advisories +- [ ] Consider if coordinated disclosure timeline needs adjustment + +### Mitigation Details + +- **Workaround available**: `Yes|No` - If yes, describe briefly +- **Fix implemented on**: `YYYY-MM-DD` +- **Patch/hotfix version**: `vX.Y.Z` +- **GitHub Security Advisory ID**: `GHSA-XXXX-XXXX-XXXX` + +## Phase 3: Impact Assessment & User Analysis + +### Updates + +<< Analysis of potential impact on the Stalwart deployments >> + +Based on telemetry data and version statistics, approximately X installations may be affected. + +### Tasks + +- [ ] Analyze version adoption from update checks (if available) +- [ ] Estimate number of vulnerable installations +- [ ] Assess if default configurations are vulnerable +- [ ] Review if vulnerability has been exploited (check logs, reports) +- [ ] Determine if any user data may have been compromised +- [ ] Check for indicators of active exploitation in the wild + +### Analysis Notes + +_Document your impact assessment process and findings_ + +### Impact Summary + +- **Estimated vulnerable installations**: `~X out of Y` +- **Default configuration vulnerable**: `Yes|No` +- **Evidence of exploitation**: `Found|Not found|Unknown` +- **User data potentially at risk**: `Email content|Credentials|Configuration|None` +- **Confidence in assessment**: `High|Medium|Low` + +## Phase 4: Communication & Release + +### Updates + +<< Communication strategy and release timeline >> + +Security release vX.Y.Z will be published on YYYY-MM-DD with coordinated disclosure. + +### Tasks + +**Pre-release preparation:** + +- [ ] Finalize security patch +- [ ] Prepare release notes with security details +- [ ] Update documentation if needed +- [ ] Test automated update mechanisms +- [ ] Prepare GitHub Security Advisory + +**Communication channels:** + +- [ ] Draft announcement for Stalwart community forum/Discord +- [ ] Prepare release announcement for GitHub +- [ ] Draft security advisory content +- [ ] Consider notification to major distributors/packagers + +**Release execution:** + +- [ ] Publish patched version to GitHub releases +- [ ] Update Docker images on Docker Hub +- [ ] Publish GitHub Security Advisory +- [ ] Post to community channels (Discord/forum) +- [ ] Update project website/documentation +- [ ] Submit CVE request if warranted (CVSS ≥ 4.0) + +**Post-release:** + +- [ ] Monitor community channels for questions +- [ ] Track adoption of security update +- [ ] Follow up on any additional reports +- [ ] Document lessons learned + +### Communication Record + +- **Security release published**: `YYYY-MM-DD HH:MM UTC` +- **GitHub Security Advisory**: `GHSA-XXXX-XXXX-XXXX` +- **CVE ID** (if applicable): `CVE-YYYY-XXXXX` +- **Community announcement**: [Link to forum/Discord post] +- **Estimated time to 50% adoption**: `X days/weeks` + +## Post-Incident Review + +### What went well? +- + +### What could be improved? +- + +### Action items for future incidents: +- [ ] +- [ ] +- [ ] + +### Process improvements: +- [ ] +- [ ] + +## Emergency Contacts +- **Primary maintainer**: hello@stalw.art diff --git a/SECURITY_TEMPLATE.md b/SECURITY_TEMPLATE.md new file mode 100644 index 0000000..059154a --- /dev/null +++ b/SECURITY_TEMPLATE.md @@ -0,0 +1,135 @@ +# Stalwart Security Advisory + +**CVE ID:** CVE-YYYY-NNNNN +**Publication Date:** YYYY-MM-DD +**Last Updated:** YYYY-MM-DD + +## Summary + +[Provide a brief, non-technical summary of the vulnerability in 1-2 sentences] + +## Affected Products and Versions + +**Product:** Stalwart Mail and Collaboration Server + +**Affected Versions:** +- Version X.X.X through Y.Y.Y +- [List specific affected version ranges] + +**Fixed Versions:** +- Version Z.Z.Z and later +- [List all versions that include the fix] + +## Vulnerability Details + +### Description + +[Detailed technical description of the vulnerability, including how it can be exploited] + +### Impact + +[Describe the potential impact if this vulnerability is exploited] + +### CVSS Score + +**CVSS v3.1 Base Score:** X.X ([SEVERITY]) +**Vector String:** CVSS:3.1/AV:X/AC:X/PR:X/UI:X/S:X/C:X/I:X/A:X + +**Severity Breakdown:** +- **Attack Vector:** [Network/Adjacent/Local/Physical] +- **Attack Complexity:** [Low/High] +- **Privileges Required:** [None/Low/High] +- **User Interaction:** [None/Required] +- **Scope:** [Unchanged/Changed] +- **Confidentiality Impact:** [None/Low/High] +- **Integrity Impact:** [None/Low/High] +- **Availability Impact:** [None/Low/High] + +### CWE Classification + +**CWE-XXX:** [Weakness Name] + +## Technical Details + +### Root Cause + +[Explain the underlying cause of the vulnerability] + +### Attack Scenario + +[Describe a realistic attack scenario or proof of concept, without providing exploit code] + +### Prerequisites + +[List any conditions that must be met for successful exploitation] + +## Remediation + +### Recommended Actions + +1. **Immediate:** Upgrade to version Z.Z.Z or later +2. **Short-term:** [Any temporary mitigation measures] +3. **Long-term:** [Any additional security hardening recommendations] + +### Upgrade Instructions + +```bash +# Example upgrade commands +[Provide specific upgrade instructions for Stalwart] +``` + +### Workarounds + +[If applicable, describe any temporary workarounds for systems that cannot be immediately upgraded] + +**Note:** Workarounds are temporary measures and do not fully resolve the vulnerability. Upgrading is strongly recommended. + +## Detection + +### Indicators of Compromise + +[List any logs, patterns, or indicators that may suggest exploitation attempts] + +### Log Entries + +``` +[Example log entries that administrators should look for] +``` + +## Timeline + +- **YYYY-MM-DD:** Vulnerability discovered [by researcher/team name] +- **YYYY-MM-DD:** Vendor notified +- **YYYY-MM-DD:** Vendor acknowledged issue +- **YYYY-MM-DD:** Fix developed and tested +- **YYYY-MM-DD:** Fixed version released +- **YYYY-MM-DD:** Public disclosure + +## Credits + +This vulnerability was discovered by [Researcher Name / Organization]. + +## References + +- Stalwart Mail Server: https://stalw.art/ +- CVE Entry: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-YYYY-NNNNN +- GitHub Advisory: [Link to GitHub Security Advisory if applicable] +- Release Notes: [Link to release notes with fix] + +## Contact Information + +For questions or concerns regarding this advisory, please contact: + +**Security Team:** hello@stalw.art +**Website:** https://stalw.art + +To report security vulnerabilities in Stalwart, please follow our [responsible disclosure policy](https://github.com/stalwartlabs/stalwart/security/policy). + +## Disclaimer + +This advisory is provided "as is" without warranty of any kind. The information contained in this advisory is subject to change without notice. + +--- + +**Document Version:** 1.0 +**Classification:** Public diff --git a/UPGRADING/v0_04.md b/UPGRADING/v0_04.md new file mode 100644 index 0000000..e316dcb --- /dev/null +++ b/UPGRADING/v0_04.md @@ -0,0 +1,49 @@ +# Upgrading from `v0.4.0` to `v0.4.x` + +- Replace the binary with the new version. +- Restart the service. + +# Upgrading from `v0.3.x` to `v0.4.0` + +## What's changed + +- **Configuration File Split:** While the `config.toml` configuration file format hasn't changed much, the new version has divided it into multiple sub-files. These sub-files are now included from the new `config.toml`. This division was implemented because the config file had grown significantly, and splitting it improves organization. + +- **Changes in the Sieve Interpreter Attribute Names:** + - The configuration key prefix `jmap.sieve` (JMAP Sieve Interpreter) has been renamed to `sieve.untrusted`. + - The configuration key prefix `sieve` (SMTP Sieve Interpreter) has been renamed to `sieve.trusted`. + +## What's been added + +- **SPAM Filter Module:** The most notable addition in this version is the SPAM filter module. It comprises: + - A TOML configuration file located at `etc/smtp/spamfilter.toml`. + - A set of Sieve scripts in `etc/spamfilter/scripts`. + - Lookup maps in `etc/spamfilter/maps`. + +- **New Configuration Key:** A new key `resolver.public-suffix` has been added. This specifies the URL of the list of public suffixes. + +## Migration Steps + +1. **Backup:** Ensure you have a backup of your current `config.toml` file. +2. **Download Configuration Bundle:** Fetch the new configuration bundle from [this link](https://get.stalw.art/resources/config.zip). Unpack it under `BASE_DIR/etc` (for example `/opt/stalwart-mail/etc`). +3. **Update Configuration Files:** Modify the following files with your domain name, host name, certificate paths, DKIM signatures, and so on: + - `etc/config.toml` + - `etc/jmap/store.toml` + - `etc/jmap/oauth.toml` + - `etc/smtp/signature.toml` + - `etc/common/tls.toml` +4. **Adjust included files:** If you are using an LDAP directory for authentication, edit `etc/config.toml` and replace the `etc/directory/sql.toml` include with `etc/directory/ldap.toml`. +5. **Configure the SPAM Filter Database:** Set up and configure the SPAM filter database. More details can be found [here](https://stalw.art/docs/spamfilter/settings/database). +6. **Review All TOML Files:** Navigate to every TOML file under the `etc/` directory and make necessary changes. +7. **Update Binary:** Download and substitute the v0.4.0 binary suitable for your platform from [here](https://github.com/stalwartlabs/mail-server/releases/tag/v0.4.0). +8. **Restart Service:** Conclude by restarting the Stalwart service. + +### Alternative Method: + +1. **Separate Installation:** Install v0.4.0 in a distinct directory. This will auto-update all configuration files and establish the spam filter database in SQLite format. +2. **Move Configuration Files:** Transfer the configuration files from `etc/` and the SQLite spam filter database from `data/` to your current installation's directory. +3. **Replace Binary:** Move the binary from the `bin/` directory to your current installation's `data/` directory. +4. **Restart Service:** Finally, restart the Stalwart service. + + +We apologize for the lack of an automated migration tool for this upgrade. However, we are planning on introducing an automated migration tool in the near future. Thank you for your understanding and patience. diff --git a/UPGRADING/v0_05.md b/UPGRADING/v0_05.md new file mode 100644 index 0000000..ef4ca39 --- /dev/null +++ b/UPGRADING/v0_05.md @@ -0,0 +1,63 @@ +# Upgrading from `v0.5.2` to `v0.5.3` + +- The following configuration attributes have been renamed, see [store.toml](https://github.com/stalwartlabs/mail-server/blob/main/resources/config/common/store.toml) for an example: + - `jmap.store.data` -> `storage.data` + - `jmap.store.fts` -> `storage.fts` + - `jmap.store.blob` -> `storage.blob` + - `jmap.encryption.*` -> `storage.encryption.*` + - `jmap.spam.header` -> `storage.spam.header` + - `jmap.fts.default-language` -> `storage.fts.default-language` + - `jmap.cluster.node-id` -> `storage.cluster.node-id` + - `management.directory` and `sieve.trusted.default.directory` -> `storage.directory` + - `sieve.trusted.default.store` -> `storage.lookup` +- Proxy networks are now configured under `server.proxy.trusted-networks` rather than `server.proxy-trusted-networks`. IP addresses/masks have to be defined within a set (`{}`) rather than a list (`[]`), see [server.toml](https://github.com/stalwartlabs/mail-server/blob/main/resources/config/common/server.toml) for an example. + + +# Upgrading from `v0.5.1` to `v0.5.2` + +- Make sure that implicit TLS is enabled for the JMAP [listener](https://stalw.art/docs/server/listener) configured under `ets/jmap/listener.toml`: + ```toml + [server.listener."jmap".tls] + implicit = true + ``` +- Optional: Enable automatic TLS with [ACME](https://stalw.art/docs/server/tls/acme). +- Replace the binary with the new version. +- Restart the service. + +# Upgrading from `v0.5.0` to `v0.5.1` + +- Replace the binary with the new version. +- Restart the service. + +# Upgrading from `v0.4.x` to `v0.5.0` + +## What's changed + +- **Database Layout**: Version 0.5.0 utilizes a different database layout which is more efficient and allows multiple backends to be supported. For this reason, the database must be migrated to the new layout. +- **Configuration file changes**: The configuration file has been updated to support multiple stores, most configuration attributes starting with `store.*` and `directory.*` need to be reviewed. +- **SPAM filter**: Sieve scripts that interact with databases need to be updated. The functions `lookup` and `lookup_map` has been renamed to `key_exists` and `key_get`. It is recommended to replace all scripts with the new versions rather than updating them manually. Additionally, the SPAM database no longer requires an SQL server, it can now be stored in Redis or any of the supported databases. +- **Directory superusers**: Due to problems and confusion with the `superuser-group` attribute, the concept of a superuser group has been removed. Instead, a new attribute `type` has been added to external directories. The value of this attribute can be `individual`, `group` or `admin`. The `admin` type is equivalent to the old superuser group. The `type` attribute is required for all principals in the directory, it defaults to `individual` if not specified. +- **Purge schedules**: The attributes `jmap.purge.schedule.db` and `jmap.purge.schedule.blobs` have been removed. Instead, the purge frequency is now specified per store in `store..purge.frequency`. The attribute `jmap.purge.schedule.sessions` has been renamed to `jmap.purge.sessions.frequency`. + +## What's been added + +- **Multiple stores**: The server now supports multiple stores to be defined in the configuration file under `store.`. Which store to use is defined in the `jmap.store.data`, `jmap.store.fts` and `jmap.store.blob` settings. +- **More backend options**: It is now possible to use `RocksDB`, `PostgreSQL` and `MySQL` as data stores. It is also now possible to store blobs in any of the supported databases instead of being limited to the filesystem or an S3-compatible storage. Full-text indexing can now be done using `Elasticsearch` and the Spam database stored in `Redis`. +- **Internal Directory**: The server now has an internal directory that can be used to store user accounts, passwords and group membership. This directory can be used instead of an external directory such as LDAP or SQL. +- **New settings**: When running Stalwart in a cluster, `jmap.cluster.node-id` allows to specify a unique identifier for each node. Messages containing the SPAM headers defined in `jmap.spam.header` are moved automatically to the user's Junk Mail folder. +- **Default Sieve stores**: For Sieve scripts such as the Spam filter that require access to a directory and a lookup store, it is now possible to configure the default lookup store and directory using the `sieve.trusted.default.directory` and `sieve.trusted.default.store` settings. + +## Migration Steps + +Rather than manually updating the configuration file, it is recommended to start with a fresh configuration file and update it with the necessary settings: + +- Install `v0.5.0` in a distinct directory. You now have the option to use an [internal directory](https://stalw.art/docs/directory/types/internal), which will allow you to manage users and groups directly from Stalwart server. Alternatively, you can continue to use an external directory such as LDAP or SQL. +- Update the configuration files with your previous settings. All configuration attributes are backward compatible, except those starting with `store.*`, `directory.*` and `jmap.purge.*`. +- Export each account following the procedure described in the [migration guide](https://stalw.art/docs/management/database/migrate). +- Stop the old `v0.4.x` server. +- If there are messages pending to be delivered in the SMTP queue, move the `queue` directory to the new installation. +- Start the new `v0.5.0` server. +- Import each account following the procedure described in the [migration guide](https://stalw.art/docs/management/database/migrate). + + +Once again, we apologize for the lack of an automated migration tool for this upgrade. However, we are planning on introducing an automated migration tool once the web-admin is released in Q1 2024. Thank you for your understanding and patience. diff --git a/UPGRADING/v0_06.md b/UPGRADING/v0_06.md new file mode 100644 index 0000000..d7c137f --- /dev/null +++ b/UPGRADING/v0_06.md @@ -0,0 +1,7 @@ +# Upgrading from `v0.5.3` to `v0.6.0` + +- In order to support [expressions](https://stalw.art/docs/configuration/expressions/overview), version `0.6.0` introduces multiple breaking changes in the SMTP server configuration file. It is recommended to download the new SMTP configuration files from the [repository](https://github.com/stalwartlabs/mail-server/tree/main/resources/config/smtp), make any necessary changes and replace the old files under `INSTALL_DIR/etc/smtp` with the new ones. +- If you are using custom subaddressing of catch-all rules, you'll need to replace these rules with expressions. Check out the updated [syntax](https://stalw.art/docs/directory/addresses). +- Message queues are now distributed and stored in the backend specified by the `storage.data` and `storage.blob` settings. Make sure to flush your SMTP message queue before upgrading to `0.6.0` to avoid losing any outgoing messages pending delivery. +- Replace the binary with the new version. +- Restart the service. diff --git a/UPGRADING/v0_07.md b/UPGRADING/v0_07.md new file mode 100644 index 0000000..3ebef59 --- /dev/null +++ b/UPGRADING/v0_07.md @@ -0,0 +1,33 @@ +# Upgrading from `v0.6.0` to `v0.7.0` + +Version `0.7.0` of Stalwart introduces significant improvements and features that enhance performance and functionality. However, it also comes with multiple breaking changes in the configuration files and a revamped database layout optimized for accessing large mailboxes. Additionally, Stalwart now supports compression for binaries stored in the blob store, further increasing efficiency. +Due to these extensive changes, the recommended approach for upgrading is to perform a clean reinstallation of Stalwart and manually migrate your accounts to the new version. + +## Pre-Upgrade Steps +- Download the `v0.7.0` mail-server and CLI binaries for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/). +- Initialize the setup on a distinct directory using the command `sudo ./stalwart-mail --init /path/to/new-install`. This command will print the administrator password required to access the web-admin. +- Create the `bin` directory using `mkdir /path/to/new-install/bin`. +- Move the downloaded binaries to the `bin` directory using the command `mv stalwart-mail stalwart-cli /path/to/new-install/bin`. +- Open `/path/to/new-install/etc/config.toml` in a text editor and comment out all listeners except the HTTP listener for port `8080`. +- Start the new installation from the terminal using the command `sudo /path/to/new-install/bin/stalwart-mail --config /path/to/new-install/etc/config.toml`. +- Point your browser to the web-admin at `http://yourserver.org:8080` and login using the auto-generated administrator password. +- Configure the new installation with your domain, hostname, certificates, and other settings following the instructions at [stalw.art/docs/get-started](https://stalw.art/docs/get-started). Ignore the part about using the installation script, we are performing a manual installation. +- Add your user accounts. +- Configure Stalwart to run as the `stalwart-mail` user and `stalwart-mail` group from `Settings` > `Server` > `System`. This is not necessary if you are using Docker. +- Stop the new installation by pressing `Ctrl+C` in the terminal. + +## Upgrade Steps +- On your `v0.6.0` installation, open in a text editor the `smtp/listener.toml`, `imap/listener.toml` files and comment out all listeners except the JMAP/HTTP listener (we are going to need it to export the user accounts) and then restart the service. +- If you are using an external store, backup the database using the appropriate method for your database system. +- Create the `~/exports` directory, here we will store the exported accounts. +- Using the existing CLI tool (not the one you just downloaded as it is not compatible), export each user account using the command `./stalwart-cli -u https://your-old-server.org -c export account ~/exports`. +- Stop the `v0.6.0` installation using the command `sudo systemctl stop stalwart-mail`. +- Move the old `v0.6.0` installation to a backup directory, for example `mv /opt/stalwart-mail /opt/stalwart-mail-backup`. +- Move the new `v0.7.0` installation to the old installation directory, for example `mv /path/to/new-install /opt/stalwart-mail`. +- Set the right permissions for the new installation using the command `sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail`. +- Start the new installation using the command `sudo systemctl start stalwart-mail`. +- Import the accounts using the new CLI tool with the command `./stalwart-cli -u http://yourserver.org:8080 -c import account ~/exports/`. +- Using the admin tool, reactivate all the necessary listener (SMTP, IMAP, etc.) +- Restart the service using the command `sudo systemctl restart stalwart-mail`. + +We apologize for the complexity of the upgrade process associated with this version of Stalwart. We understand the challenges and inconveniences that the requirement for a clean reinstallation and manual account migration poses. Moving forward, an automated migration tool will be included in any future releases that necessitate changes to the database layout, aiming to streamline the upgrade process for you. Furthermore, as we approach the milestone of version 1.0.0, we anticipate that such foundational changes will become increasingly infrequent, leading to more straightforward updates. We appreciate your patience and commitment to Stalwart during this upgrade. diff --git a/UPGRADING/v0_08.md b/UPGRADING/v0_08.md new file mode 100644 index 0000000..e240bbf --- /dev/null +++ b/UPGRADING/v0_08.md @@ -0,0 +1,86 @@ +# Upgrading from `v0.7.3` to `v0.8.0` + +Version `0.8.0` includes both performance and security enhancements that require your data to be migrated to a new database layout. Luckily version `0.7.3` includes a migration tool which should make this process much easier than previous upgrades. In addition to the new layout, you will have to change the systemd service file to use the `CAP_NET_BIND_SERVICE` capability. + +## Preparation +- Upgrade to version `0.7.3` if you haven't already. If you are on a version previous to `0.7.0`, you will have to do a manual migration of your data using the Command-line Interface. +- Create a directory where your data will be exported to, for example `/opt/stalwart-mail/export`. + +## Systemd service upgrade (Linux only) +- Stop the `v0.7.3` installation: + ```bash + $ sudo systemctl stop stalwart-mail + ``` +- Update your systemd file to include the `CAP_NET_BIND_SERVICE` capability. Open the file `/etc/systemd/system/stalwart-mail.service` in a text editor and add the following lines under the `[Service]` section: + ``` + User=stalwart-mail + Group=stalwart-mail + AmbientCapabilities=CAP_NET_BIND_SERVICE + ``` +- Reload the daemon: + ```bash + $ systemctl daemon-reload + ``` +- Do not start the service yet. + +## Data migration +- Stop Stalwart and export your data: + + ```bash + $ sudo systemctl stop stalwart-mail + $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export + ``` + + or, if you are using the Docker image: + + ```bash + $ docker stop stalwart-mail + $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + ``` +- Backup your `v0.7.3` installation: + - If you are using RocksDB or SQLite, simply rename the `data` directory to `data-backup`, for example: + ```bash + $ mv /opt/stalwart-mail/data /opt/stalwart-mail/data-backup + $ mkdir /opt/stalwart-mail/data + $ chown stalwart-mail:stalwart-mail /opt/stalwart-mail/data + ``` + - If you are using PostgreSQL, rename the database and create a blank database with the same name, for example: + ```sql + ALTER DATABASE stalwart RENAME TO stalwart_old; + CREATE database stalwart; + ``` + - If you are using MySQL, rename the database and create a blank database with the same name, for example: + ```sql + CREATE DATABASE stalwart_old; + RENAME TABLE stalwart.b TO stalwart_old.b; + RENAME TABLE stalwart.v TO stalwart_old.v; + RENAME TABLE stalwart.l TO stalwart_old.l; + RENAME TABLE stalwart.i TO stalwart_old.i; + RENAME TABLE stalwart.t TO stalwart_old.t; + RENAME TABLE stalwart.c TO stalwart_old.c; + DROP DATABASE stalwart; + CREATE database stalwart; + ``` + - If you are using FoundationDB, backup your database and clean the entire key range. +- Download the `v0.8.0` mail-server for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you are using the Docker image, pull the latest image. +- Import your data: + + ```bash + $ sudo -u stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --import /opt/stalwart-mail/export + ``` + + or, if you are using the Docker image: + + ```bash + $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --import /opt/stalwart-mail/export + ``` +- Start the service: + ```bash + $ sudo systemctl start stalwart-mail + ``` + + Or, if you are using the Docker image: + ```bash + $ docker start stalwart-mail + ``` \ No newline at end of file diff --git a/UPGRADING/v0_09.md b/UPGRADING/v0_09.md new file mode 100644 index 0000000..8c92fd4 --- /dev/null +++ b/UPGRADING/v0_09.md @@ -0,0 +1,9 @@ +# Upgrading from `v0.8.x` to `v0.9.0` + +Version `0.9.0` introduces significant internal improvements while maintaining compatibility with existing database layouts and configuration file formats from version `0.8.0`. As a result, no data or configuration migration is necessary. This release focuses on enhancing performance and functionality, particularly in logging and tracing capabilities. + +To upgrade to Stalwart version `0.9.0` from `0.8.x`, begin by downloading the latest version of the `stalwart-mail` binary. Once downloaded, replace the existing binary with the new version. Additionally, it's important to update the WebAdmin interface to the latest version to ensure compatibility and to access new features introduced in this release. + +In terms of breaking changes, this release brings significant updates to webhooks. All webhook event names have been modified, requiring a thorough review and adjustment of existing webhook configurations. Furthermore, the update introduces hundreds of new event types, enhancing the granularity and specificity of event handling capabilities. Users should familiarize themselves with these changes to effectively integrate them into their systems. + +The reason for this release being classified as a major version, despite the absence of changes to the database or configuration formats, is the complete rewrite of the logging and tracing layer. This overhaul substantially improves the efficiency and speed of generating detailed tracing and logging events, making the system more robust and facilitating easier debugging and monitoring. diff --git a/UPGRADING/v0_10.md b/UPGRADING/v0_10.md new file mode 100644 index 0000000..d0f32a6 --- /dev/null +++ b/UPGRADING/v0_10.md @@ -0,0 +1,37 @@ + +# Upgrading from `v0.9.x` to `v0.10.0` + +## Important Notes + +- In version `0.10.0` accounts are associated with roles and permissions, which define what resources they can access. The concept of administrator or super user accounts no longer exists, now there is a single account type (the `individual` principal) which can be assigned the `admin` role or custom permissions to have administrator access. +- Due to the changes in the database layout in order to support roles and permissions, the database must be migrated to the new layout. The migration is automatic and should not require any manual intervention. +- While the database migration is automatic, it's recommended to **back up your data** before upgrading. +- The webadmin must be upgraded **before** the mail server to maintain access post-upgrade. This is true even if you run Stalwart in Docker. + +## Step-by-Step Upgrade Process + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. +- Stop Stalwart and backup your data: + + ```bash + $ sudo systemctl stop stalwart-mail + $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export + ``` + + or, if you are using the Docker image: + + ```bash + $ docker stop stalwart-mail + $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /usr/local/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + ``` +- Download the `v0.10.0` mail-server for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you are using the Docker image, pull the latest image. +- Start the service: + ```bash + $ sudo systemctl start stalwart-mail + ``` + + Or, if you are using the Docker image: + ```bash + $ docker start stalwart-mail + ``` diff --git a/UPGRADING/v0_11.md b/UPGRADING/v0_11.md new file mode 100644 index 0000000..9a30390 --- /dev/null +++ b/UPGRADING/v0_11.md @@ -0,0 +1,13 @@ + +# Upgrading from `v0.10.x` to `v0.11.0` + +Version `0.11.0` introduces breaking changes to the spam filter configuration. Although no data migration is required, if changes were made to the previous spam filter, the configuration of the new spam filter should be reviewed. In particular: + +- `lookup.spam-*` settings are no longer used, these have been replaced by `spam-filter.*` settings. Review the [updated documentation](http://stalw.art/docs/spamfilter/overview). +- Previous `spam-filter` and `track-replies` Sieve scripts cannot be used with the new version. They have been replaced by a built-in spam filter written in Rust. +- Cache settings have changed, see the [documentation](https://stalw.art/docs/server/cache) for details. +- Support for Pipes was removed in favor of MTA hooks and Milter. +- `config.resource.spam-filter` is now `spam-filter.resource`. +- `config.resource.webadmin` is now `webadmin.resource`. +- `authentication.rate-limit` was removed as security is handled by fail2ban. + diff --git a/UPGRADING/v0_12.md b/UPGRADING/v0_12.md new file mode 100644 index 0000000..46d3a54 --- /dev/null +++ b/UPGRADING/v0_12.md @@ -0,0 +1,68 @@ + +# Upgrading from `v0.11.x` to `v0.12.x` + +## Important Notes + +Version `0.12.x` introduces significant improvements such as zero-copy deserialization which make the new database layout incompatible with the previous version. As a result, the database must be migrated to the new layout. The migration is done automatically on startup and should not require any manual intervention. However, it is highly recommended to **back up your data** before upgrading since it is not possible to downgrade the database once it has been migrated. You may also want to run a mock migration before upgrading to ensure that everything works as expected. + +In addition to the database layout changes, multiple settings were renamed: + +- `server.http.*` to `http.*`. +- `jmap.folders.*` to `email.folders.*`. +- `jmap.account.purge.frequency` to `account.purge.frequency`. +- `jmap.email.auto-expunge` to `email.auto-expunge`. +- `jmap.protocol.changes.max-history` to `changes.max-history`. +- `storage.encryption.*` to `email.encryption.*`. + +## Step-by-Step Upgrade Process + +- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command: + + ```bash + $ sudo systemctl stop stalwart-mail + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example: + + ```bash + $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export + ``` + +- Download the `v0.12.x` binary for your platform (which is now called `stalwart` rather than `mail-server`) from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you rename the binary from `stalwart` to `stalwart-mail`, you can keep the same systemd service file, otherwise you will need to update the service file to point to the new binary name. + +- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once. + ```bash + $ sudo systemctl start stalwart-mail + ``` + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. + +## Step-by-Step Upgrade Process (Docker) + +- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command: + + ```bash + $ docker stop stalwart-mail + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example: + + ```bash + $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /usr/local/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export + ``` + +- The Docker image location has now changed to `stalwartlabs/stalwart` instead of `stalwartlabs/mail-server`. Pull the latest image and configure it to use your existing data directory: + + ```bash + $ docker run -d -ti -p 443:443 -p 8080:8080 \ + -p 25:25 -p 587:587 -p 465:465 \ + -p 143:143 -p 993:993 -p 4190:4190 \ + -p 110:110 -p 995:995 \ + -v :/opt/stalwart \ + --name stalwart stalwartlabs/stalwart:latest + ``` + +- Since the mount point has changed from `/opt/stalwart-mail` to `/opt/stalwart`, you will need to update your Stalwart's configuration file to reflect this change. Open the file `/opt/stalwart/etc/config.toml` and update the paths accordingly. + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. diff --git a/UPGRADING/v0_13.md b/UPGRADING/v0_13.md new file mode 100644 index 0000000..ef5a039 --- /dev/null +++ b/UPGRADING/v0_13.md @@ -0,0 +1,59 @@ +# Upgrading from `v0.12.x` (and `v0.11.x`) to `v0.13.x` + +## Important Notes + +Version `0.13.x` introduces a significant redesign of the MTA’s delivery and queueing subsystem. This includes a transition to a new message queue serialization format and a move to a strategy-based configuration model for routing, scheduling, and delivery control. Upon first launch of version `0.13.0`, any messages currently in the outbound queue will be automatically migrated to the new format. This migration is handled internally and does not require manual intervention. + +However, if your deployment includes custom routing rules or queueing logic, it is important to manually reconfigure those settings using the new strategy framework. The previous configuration format for routing is no longer compatible and will need to be updated. For systems that rely solely on the default configuration, no changes are required and the upgrade should proceed without issue. + +Even if your system uses the default settings, it is strongly recommended to read the accompanying [blog announcement](https://stalw.art/blog/virtual-queues) and consult the [updated documentation](https://stalw.art/docs/mta/outbound/overview). These resources provide a full overview of the new delivery architecture and can help you determine whether any adjustments are needed for your environment. + +Before applying the upgrade to a production system, take time to familiarize yourself with the new configuration structure and validate that your delivery behavior aligns with the new model. + +## Step-by-Step Upgrade Process + +- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command: + + ```bash + $ sudo systemctl stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example: + + ```bash + $ sudo /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export + $ sudo chown -R stalwart:stalwart /opt/stalwart/export + ``` + +- Download the `v0.13.x` binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`. + +- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once. + ```bash + $ sudo systemctl start stalwart + ``` + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. + +## Step-by-Step Upgrade Process (Docker) + +- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command: + + ```bash + $ docker stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example: + + ```bash + $ docker run --rm -v :/opt/stalwart -it stalwart /usr/local/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export + ``` + +- Pull the latest image and restart the container: + + ```bash + $ docker pull stalwartlabs/stalwart:latest + $ docker start stalwart + ``` + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. + diff --git a/UPGRADING/v0_14.md b/UPGRADING/v0_14.md new file mode 100644 index 0000000..0b3c9b7 --- /dev/null +++ b/UPGRADING/v0_14.md @@ -0,0 +1,48 @@ +# Upgrading from `v0.13.x` to `v0.14.x` + +## Binary installation + +- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command: + + ```bash + $ sudo systemctl stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example: + + ```bash + $ sudo /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export + $ sudo chown -R stalwart:stalwart /opt/stalwart/export + ``` + +- Download the latest binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`. + +- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once. + ```bash + $ sudo systemctl start stalwart + ``` + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. + +## Containerized + +- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command: + + ```bash + $ docker stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example: + + ```bash + $ docker run --rm -v :/opt/stalwart -it stalwart /usr/local/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export + ``` + +- Pull the latest image and restart the container: + + ```bash + $ docker pull stalwartlabs/stalwart:latest + $ docker start stalwart + ``` + +- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`. diff --git a/UPGRADING/v0_15.md b/UPGRADING/v0_15.md new file mode 100644 index 0000000..f24a48f --- /dev/null +++ b/UPGRADING/v0_15.md @@ -0,0 +1,156 @@ +# Upgrading from `v0.14.x` to `v0.15.x` + +Stalwart `v0.15.x` introduces **breaking changes** to both the **database schema** and some **configuration options**. +Upgrading to this version **requires a schema migration**, which is performed **automatically when Stalwart starts** for the first time on `v0.15.x`. + +Because this migration modifies how data is stored and indexed, it is important to understand what will change, what will be migrated, and how the upgrade may impact your deployment—especially for larger installations. + +## What's changed + +Version `0.15.x` introduces significant internal improvements focused on performance, storage efficiency, and accuracy: + +- **Optimized database schema**: The database schema has been redesigned to use less storage space and significantly reduce the number of read and write operations required for common tasks. +- **Rewritten search layer**: The search subsystem has been completely rewritten to use a more efficient and scalable indexing strategy. +- **Native full-text search for SQL backends**: When using **PostgreSQL** or **MySQL** as the backend, Stalwart now leverages the database’s **native full-text search capabilities**, replacing the previous custom full-text search implementation. +- **New spam classifier engine** : The spam classifier has been rewritten to use the **FTRL-Proximal** algorithm instead of the previous **Naive Bayes** implementation. This change improves classification accuracy, reduces memory usage, and reduces storage requirements for training data. + + +## What will be migrated + +The migration process runs automatically at startup and will migrate the following data: + +- **E-mail metadata**, including flags, folders, and parsed message representations. *(The raw e-mail content stored in the blob store is not migrated.)* +- **Encryption-at-rest settings**, which now also include a **spam training privacy option** +- **MTA message queue metadata** *(The actual message contents are not migrated.)* +- **Maintenance tasks** +- **Blob links** *(The underlying blobs themselves are not migrated.)* +- **Search indexes**, which will be **rebuilt** using the new indexing strategy + +## Important considerations + +- For deployments with **1,000 or more mailboxes**, the migration may take a **considerable amount of time**, depending on the volume of stored data. +- During migration, **Stalwart runs in read-only mode**: + - No new e-mail can be received + - No outbound e-mail can be sent +- It is **strongly recommended** to perform this upgrade during a **maintenance window**. +- By default, the migration process is **multithreaded** and uses two threads for each available CPUs. You can control the number of threads by setting the following environment variable ``NUM_THREADS=`` + +> **Note:** If you do **not** require any of the features introduced in `v0.15.x`, consider **waiting for the next major release**, which will introduce a proxy-based architecture allowing **zero-downtime upgrades**. + +## Upgrading steps + +### Binary installation + +- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command: + + ```bash + $ sudo systemctl stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. + +- Download the latest binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`. + +- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once. + ```bash + $ sudo systemctl start stalwart + ``` + +### Containerized + +- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command: + + ```bash + $ docker stop stalwart + ``` + +- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. + +- Pull the latest image and restart the container: + + ```bash + $ docker pull stalwartlabs/stalwart:latest + $ docker start stalwart + ``` + +## Post-upgrade steps + +After the upgrade and migration complete, several follow-up steps are required or recommended: + +- **Upgrade the webadmin**: Upgrade the webadmin interface by navigating to ``Manage → Maintenance → Update Webadmin`` + +- **Update the spam rules**: Download and apply the latest spam rules from the webadmin ``Manage → Maintenance → Update Spam rules`` + +- **Update search settings**: Review the updated documentation for search settings, as some configuration options have changed. In particular, the Elasticsearch backend now uses **different authentication settings** than previous versions. + +- **Rebuild search indexes**: All search indexes must be rebuilt to take advantage of the new indexing strategy. This can be done from the webadmin interface ``Manage → Maintenance``. + +- **Recalculate disk quotas for all accounts**: This step is **not required immediately**, but it is recommended to perform it at some point after the upgrade. The new version includes additional metadata in quota calculations, so recalculating ensures accurate disk usage reporting. + ```bash + $ curl -X DELETE https://myserver.org/api/store/quota/ -u : -k + ``` + +- **Delete deprecated spam classifier keys**: Remove deprecated spam classifier keys from the memory store. These are the keys starting with the integer prefixes `12` to `16` and `17` to `18`: + - If you are using Redis: + + ```bash + $ for code in {12..18}; do + char=$(printf "\\x$(printf '%02x' $code)") + redis-cli --scan --pattern "${char}*" | xargs -r redis-cli DEL + done + ``` + - If you are using your database as the in-memory store: + + ```bash + $ /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --console + Stalwart Server v0.15.2 Data Store CLI + + > delete y\x0c\x00 y\x12\xff + > delete m\x0c\x00 m\x12\xff + > exit + ``` + - If you are using your database as the in-memory store with Docker: + + ```bash + $ docker stop stalwart + $ docker run -it --rm \ + -v :/opt/stalwart \ + --entrypoint /usr/local/bin/stalwart \ + stalwartlabs/stalwart:latest \ + --config /opt/stalwart/etc/config.toml --console + Stalwart Server v0.15.2 Data Store CLI + + > delete y\x0c\x00 y\x12\xff + > delete m\x0c\x00 m\x12\xff + > exit + + $ docker start stalwart + ``` + + +## Troubleshooting + +### Interrupted or stopped migration + +If the migration process is interrupted or stopped, it can be **resumed automatically** by simply restarting Stalwart. + +### `Data corruption detected` error + +If you see an error message similar to: ``Data corruption detected``. This indicates that **another node wrote data using the old format while the migration was in progress**. This usually happens when the cluster was **not fully stopped** before starting the upgrade. + +In order to resolve this issue, follow these steps: + +1. Stop **all** Stalwart nodes. +2. Ensure **all nodes are upgraded** to `v0.15.x`. +3. Start the nodes again. + +### Forcing a migration + +If the migration does not resume because the node responsible for it already marked it as completed, you can force migration using environment variables: + +- **Force re-migration of MTA queue metadata**: ``FORCE_MIGRATE_QUEUE=4`` +- **Force re-migration of blob links**: ``FORCE_MIGRATE_BLOBS=4`` +- **Force re-migration of a specific account**: ``FORCE_MIGRATE_ACCOUNT=`` +- **Force re-migration of all data**: ``FORCE_MIGRATE=4`` + +Use these options with care and only when necessary. diff --git a/UPGRADING/v0_16.md b/UPGRADING/v0_16.md new file mode 100644 index 0000000..9f17156 --- /dev/null +++ b/UPGRADING/v0_16.md @@ -0,0 +1,605 @@ +# Upgrading from `v0.15.x` to `v0.16.x` + +Stalwart `v0.16.x` introduces **significant breaking changes** that make its configuration and management layer **completely incompatible** with every previous release. The database layout used to store user data (emails, calendars, contacts, files, blobs, search indexes) is **not** affected by this change, so message bodies, mailboxes, calendar events, and shared files remain on disk unchanged. What does change is **how the server is configured and managed**, and because those records live inside the same database, a multi-step migration is required. + +Before continuing, please read this document in full. Skipping steps will leave the server in an unrecoverable state and will require restoring from a backup. + +If any step below raises questions, a dedicated discussion thread for the `v0.16` upgrade is open at https://support.stalw.art. The earlier design discussion that led to these changes is also public at https://github.com/stalwartlabs/stalwart/discussions/2892 and describes the user-reported problems that motivated each breaking change. + +## A note on downtime + +Email is a critical service, and we understand that a forced maintenance window is disruptive: in some environments it is simply not an option. The breaking changes in `v0.16` are not cosmetic. Stalwart has been under continuous development for close to five years; in that time the feature set and the user base have both grown well beyond what the original configuration and management layer was designed for. The gap between what users need and what the old architecture can cleanly support has widened to the point where a redesign was unavoidable: and the redesign itself unlocks a long list of frequently-requested features that were simply not implementable under the previous model. The storage layer is untouched by all of this: emails, calendars, contacts, files, and every other piece of user data stay exactly where they are. The migration is about configuration, not about data. + +Operators who cannot accept downtime should **wait**. In the next two to three weeks we plan to release two tools that work together: + +- A **zero-downtime migration utility** that moves data (accounts, mailboxes, calendars, contacts, files) from an existing (`v0.15.x` or below) deployment to a freshly-installed `v0.16.x` deployment one account at a time, while both servers are running. +- A **proxy** that sits in front of both deployments and routes each incoming connection to the server that currently owns that account. As accounts are migrated one by one, the proxy transparently shifts their traffic from the old deployment to the new one, so end users never notice a cutover. + +Together, these let operators migrate a live production deployment on an account-by-account basis with no scheduled maintenance window. When those tools are available, the instructions in this document will be superseded for most deployments. Everyone else can follow the manual steps below during a scheduled maintenance window. + +## What has changed + +### No more TOML configuration files + +The previous server used one or more TOML files, with some settings living on local disk and others living in the database. In `v0.16` there is a single small `config.json` on disk that describes **only** the datastore (the database Stalwart uses to keep everything else). Every other configuration and management setting: domains, accounts, mail routing, DKIM signatures, storage backends, rate limits, spam rules, and so on: is now stored inside that datastore as a **JMAP object**. JMAP ("JSON Meta Application Protocol") is the JSON-based API Stalwart uses to expose its data; treating configuration as JMAP objects means the same API that serves email metadata also serves server configuration. + +This change is driven by two real problems with the old model. First, in a **clustered deployment** every node had to carry its own copy of the configuration file and stay in lockstep with every other node. Divergence was easy to introduce and hard to debug, and it made distributed deployments unnecessarily fragile. Centralising everything in the database means configuration is consistent across the cluster by definition. Second, the **split between "settings in the file" and "settings in the database"** was a persistent source of user confusion: the same conceptual setting had to be documented in two places depending on where it happened to live, and administrators routinely edited the wrong one. A single unified model removes that entire category of mistake, and it gives management tooling (the WebUI and the CLI) a complete view of the system. + +**For Ansible, NixOS, Terraform, and other declarative tooling:** the small `config.json` is still a plain file and can be managed with existing tooling exactly as before. Everything that used to live in TOML is now managed through [`stalwart-cli apply`](https://stalw.art/docs/management/cli/apply), which accepts a declarative plan file and idempotently reconciles the live server state to match it, creating what is missing, updating what has changed, and removing what the plan no longer declares. This is the same pattern used by CockroachDB (cluster settings via SQL/CLI), Consul (KV store), Elasticsearch (`PUT /_cluster/settings`), and HashiCorp Vault (CLI/API for policies and secrets); infrastructure-as-code tooling targets the API rather than a file. The workflow becomes: commit the declarative plan to version control, deploy `config.json` through existing tooling, and invoke `stalwart-cli apply` as an idempotent step in a playbook or activation script. + +### REST API replaced by JMAP + +The `/api/...` endpoints from previous releases no longer exist. All management operations happen through **JMAP objects** reachable at `/jmap`. JMAP (RFC 8620) is a well-specified, transport-efficient protocol with first-class support for batch operations, push notifications, and fine-grained change tracking. Stalwart already speaks JMAP for email: extending it to administration gives operators and integrators a single consistent protocol for interacting with the entire server. In practice this means dozens of configuration changes can be applied in a single round-trip (the `apply` command uses this), any JMAP client library works against the management surface, and the same authentication flow covers both mail access and administration. Existing scripts and integrations that called the old REST endpoints must be updated; the new CLI is the straightforward replacement for most of them. + +### Account names must be email addresses + +Every user and group principal now has a **local part** (the name) and an associated **domain**. In previous releases an account could be a bare string such as `alice`; in `v0.16` it must be `alice@example.com`. The migration script handles this automatically: accounts without a domain are assigned the default domain of the deployment (chosen by scanning existing principals for the most common domain), so no users are lost during conversion. + +To avoid locking existing users out of their mail clients on the first login after the upgrade, `v0.16` **automatically appends the default domain** when a client authenticates with a bare username. Administrators running an **external directory** (LDAP, SQL, etc.), however, do need to update their directory filters to query by full email address rather than by bare account name; the old filters will no longer match. + +**CalDAV, CardDAV, and WebDAV clients need one manual adjustment.** These protocols use the account name as part of the URL path (for example `/dav/cal/alice`), and because the account name is now a full email address, that path changes. The `@` character is reserved in URLs and must be encoded as `%40`, so the equivalent path in `v0.16` becomes `/dav/cal/alice%40example.com`. Authentication itself still works (the server accepts the bare username and appends the default domain, as described above), but calendar, contact, and file sync will stop working until each client is reconfigured to point at the new path. It is a good idea to notify users before the upgrade so that they can update their calendar and contacts accounts in Apple Calendar, Thunderbird, DAVx⁵, and similar clients. + +Two reasons drove this requirement. The first is **support for multiple external directories simultaneously**: when account names are bare strings there is no reliable way to tell which directory owns a given username, whereas email addresses are naturally namespaced by domain and make that mapping unambiguous. The second, and more consequential, reason is the **PACC specification** ([draft-ietf-mailmaint-pacc](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/)): the IETF's replacement for the fragmented collection of autoconfig / autodiscover / SRV-record mechanisms that mail clients use today to discover server settings. PACC expects login names shaped like email addresses; when they are not, the server has to reveal whether a given account exists just to disambiguate the login, which is exactly the privacy leak the spec is designed to prevent. Aligning account names with email addresses is what lets Stalwart implement PACC correctly. + +PACC also brings OAuth into the autodiscovery flow, and because the draft originates from Apple, a correct PACC implementation is the path to supporting Apple Mail clients with OIDC and MFA: a long-standing user request that only becomes possible once this groundwork is in place. + +## What has been added + +- **A brand-new WebUI**, rewritten from scratch on top of the new JMAP-based management API. +- **A brand-new CLI** (`stalwart-cli`) that also uses the JMAP API and can be used for day-to-day administration, scripted deployments, and infrastructure-as-code workflows. Full documentation is available at https://stalw.art/docs/management/cli. +- **[Over one hundred feature requests and bug fixes](https://github.com/stalwartlabs/stalwart/blob/main/CHANGELOG.md#0160---2026-xx-xx)** across every subsystem. + +## Evaluate `v0.16` before migrating + +Because so much has changed, `v0.16` will feel like a different product at first contact. Concepts have been renamed, some have been removed, and several new ones have been introduced. + +It is **strongly recommended** that operators first install a fresh `v0.16` instance in a Docker container or a throwaway virtual machine, log into the new WebUI, and spend time becoming familiar with how configuration works in the new release. This avoids the situation where a critical production upgrade is the first time an operator sees the new interface. + +A second, equally important benefit: any settings created in the test deployment (directory integrations, SMTP listeners, spam rules, rate limits, TLS providers, etc.) can be exported using the [`snapshot`](https://stalw.art/docs/management/cli/overview/snapshot) command. The resulting JSON file is an `apply` plan that can be fed directly into the production instance after the migration completes. Time spent on a test deployment is not thrown away. + +## How the migration works + +The migration is a **multi-step, offline** process. At a high level: + +1. If the server is still on a version older than `v0.15.x`, it must first be upgraded to `v0.15.x`. The `v0.16` migration tooling does not support anything older. Operators who cannot upgrade to `v0.15.x` now should wait for the zero-downtime proxy described above, which will perform a direct migration from older releases. +2. A Python helper script is run against the live `v0.15.x` server. It downloads the current settings and principals, converts them to the new format, and produces two files: `config.json` (the new on-disk datastore configuration) and `export.json` (a snapshot of everything else, in a format that the new CLI can replay). +3. The `v0.15.x` server is stopped and its database is backed up. +4. The `v0.16` binary (or Docker image) is started in **recovery mode**. On first start it detects the old data, wipes the pieces that are no longer compatible, migrates the spam classifier model, and comes up listening on a single HTTP port (`8080`) exposing the management API. +5. `stalwart-cli apply` replays `export.json` (and, optionally, any snapshots from the test deployment) against the recovery-mode server. +6. Recovery mode is disabled, the service manager (systemd / init.d / Docker) is reconfigured to use the new `config.json`, and the server is restarted normally. +7. Post-migration tasks are triggered from the WebUI to recalculate disk quotas. + +The following sections describe each step in detail. + +> **Note for clustered deployments.** Before starting the migration, **every node in the cluster must be stopped**. If even one node is left running on `v0.15.x` while another is being upgraded, it will write records in the old format and cause data corruption that can only be repaired by manually deleting the offending keys. This requirement is repeated in the binary and Docker sections below, but it applies globally. + +## Step 1: Convert existing settings into a configuration snapshot + +This step is **independent of how Stalwart is deployed** and **does not require stopping the server**. The migration script talks to the running `v0.15.x` server over its management API and produces two JSON files on the machine where it is run. Running this step early is encouraged: it gives the operator a chance to review the generated files before touching the server, and to rerun the conversion with different options if needed. + +### Download the migration script + +Download the script from the Stalwart repository: + +```bash +$ curl -fLO https://raw.githubusercontent.com/stalwartlabs/stalwart/refs/heads/main/resources/scripts/migrate_v016.py +``` + +Review the script before running it. It is a single self-contained Python file and makes no changes to the running server: it only reads configuration and principal data. + +### Create a Python virtual environment + +A virtual environment (`venv`) is a self-contained Python setup that keeps installed libraries out of the system-wide Python install. This avoids polluting the host Python and lets the script run on systems where `pip` installs are restricted. + +```bash +$ python3 -m venv .venv +$ source .venv/bin/activate +(.venv) $ pip install requests urllib3 +``` + +The first command creates the environment in a `.venv/` directory. The second activates it (the shell prompt usually gains a `(.venv)` prefix). The third installs the only two libraries the script needs. + +### Dump the live `v0.15.x` settings + +The script has two subcommands. The first, `dump`, connects to the running server and downloads its settings and principals into two files on disk: + +```bash +(.venv) $ python migrate_v016.py dump \ + --url https://mail.example.com \ + --username admin \ + --password adminPassword \ + --settings settings.json \ + --principals principals.json +``` + +Replace the URL and credentials with those of the `v0.15.x` server. The admin account must have permission to read all settings and principals. Output files default to `settings.json` and `principals.json` in the current directory. These files are plain JSON: opening them in a text editor to inspect their contents is encouraged. + +### Convert the dump to the new format + +The second subcommand, `convert`, reads the two dump files and produces the two files that the new server will consume: + +```bash +(.venv) $ python migrate_v016.py convert \ + --settings settings.json \ + --principals principals.json \ + --config config.json \ + --output export.json +``` + +This produces: + +- `config.json`: the new on-disk datastore configuration. This is the file the `v0.16` server will be pointed at on startup. It is small, because it describes only the datastore (data store, blob store, search store, in-memory store). +- `export.json`: a snapshot of every other piece of state the script could convert, in the format consumed by `stalwart-cli apply`. This file will be replayed against the `v0.16` server once it is running in recovery mode. + +### What the script converts: and what it does not + +The conversion is intentionally conservative. Only the following settings are migrated, because the rest have changed enough that automatic mapping would do more harm than good: + +- **User accounts, groups, and mailing lists** (with their aliases and memberships) +- **Tenants** (in multi-tenant deployments) +- **Domain names** (including those discovered by scanning the addresses of existing accounts) +- **Data store, blob store, full-text search store, and in-memory store** settings +- **DKIM signatures** (except `rsa-sha1`, which is obsolete and not supported in `v0.16`) +- **TLS certificates** (including those issued by the built-in ACME client) + +**Everything else**: SMTP listeners, mail routing rules, rate limits, connection limits, spam filter settings, logging and telemetry configuration, authentication backends other than the ones listed above, session scripts, Sieve preludes, milter/MTA hook configuration, etc.: must be recreated on the new server. + +This is the reason the test deployment recommended above is so useful: recreating the remaining settings on a test `v0.16` instance, then using [`stalwart-cli snapshot`](https://stalw.art/docs/management/cli/snapshot) to export them, turns what would otherwise be manual post-migration work into a second `apply` run. If the production deployment is close to the defaults, this is straightforward. If it has extensive customisation, plan for the time this takes. + +## Step 2: Back up the database + +When the `v0.16` server starts for the first time, it will **wipe** the parts of the database that are no longer compatible with the new schema. No user mail is touched, but everything below is **deleted unconditionally**: + +- **Directory records.** Every user, group, tenant, OAuth client, domain, and mailing list record is removed. The mail itself stays in place on disk: it is the *directory entries* describing who owns it that are deleted. After the first `apply`, the new directory entries produced from `export.json` recreate these records with the same identities. +- **All settings.** Every stored setting is deleted. `export.json` replays what the script was able to convert; anything the script could not convert needs to be recreated manually (or via a snapshot from the test deployment). +- **DMARC, TLS, and ARF reports** (both incoming and outgoing). Report records are incompatible with the new schema. +- **Pending tasks.** Maintenance tasks queued for background execution are discarded. `v0.16` exposes a task panel in the WebUI, and the equivalent tasks can be triggered manually from there. +- **Telemetry data.** Metrics and trace spans are deleted. +- **Spam training samples** (but **not** the spam classifier model itself: the model is preserved and migrated). +- **Disk quotas.** All per-account disk-usage counters are reset to zero and must be recalculated after the migration (see Step 4). + +Because the wipe is irreversible, a full backup of the existing data must exist **before** the new server is started. + +### Embedded databases (RocksDB, SQLite) + +These store everything in a single directory on disk (typically `/var/lib/stalwart/data` or `/opt/stalwart/data`). A file-level copy while the server is stopped is sufficient: + +```bash +$ sudo systemctl stop stalwart # or the equivalent for the service manager in use +$ sudo cp -a /var/lib/stalwart /var/lib/stalwart.v015-backup +``` + +Record the path of the backup somewhere safe. If the migration fails, restoring this directory and starting the old binary returns the system to its previous state. + +### PostgreSQL / MySQL backends + +The database holds many tables, but only a subset needs to be captured to be able to undo the migration. Each table is a single ASCII character that corresponds to an internal Stalwart subspace. The destructive part of the migration touches the following tables: + +| Table | Purpose | Priority | +|:---:|---|---| +| `s` | Settings | **Critical**: contains all server configuration | +| `d` | Directory | **Critical**: users, groups, domains, tenants, mailing lists, OAuth clients | +| `r` | Incoming reports (DMARC, TLS, ARF) | Recommended | +| `h` | Outgoing reports | Recommended | +| `b` | Legacy bitmap index | Recommended | +| `g` | Legacy full-text-search index | Recommended | +| `j` | Legacy blob-extra metadata | Recommended | +| `f` | Pending task queue | Recommended | +| `u` | Quotas (partially reset) | Recommended | +| `o` | Telemetry spans (traces) | Optional: can be very large | +| `x` | Telemetry metrics | Optional: can be very large | +| `w` | Legacy telemetry/spam-sample index | Optional: can be very large | + +The telemetry tables (`o`, `x`, `w`) can grow into tens of gigabytes on busy servers. Skipping them from the backup is reasonable unless there is a specific need to preserve historical metrics or traces. + +For PostgreSQL, a per-table dump looks like this: + +```bash +$ pg_dump -U stalwart -d stalwart \ + -t s -t d -t r -t h -t b -t g -t j -t f -t u \ + -f /var/backups/stalwart-v015-critical.sql +``` + +The equivalent with `mysqldump`: + +```bash +$ mysqldump -u stalwart -p stalwart \ + s d r h b g j f u \ + > /var/backups/stalwart-v015-critical.sql +``` + +A full database dump (`pg_dump` / `mysqldump` without the `-t` flags, or `pg_dumpall`) is the safest option if disk space allows. + +## Step 3: Perform the migration + +This step is the only one that requires downtime. The sequence has moving parts, and each moving part must complete before the next begins. Reading this entire section before starting is strongly encouraged. + +> **Clustered deployments:** stop **every** node before beginning. Leaving a single `v0.15.x` node running while the migration is in progress will corrupt the database. + +### Option A: Binary deployments (systemd / init.d) + +The following instructions assume the standard FHS layout (`/usr/local/bin/stalwart`, `/etc/stalwart/config.toml`, `/var/lib/stalwart`). Operators using a custom prefix (for example `/opt/stalwart`) should substitute their paths accordingly. + +**1. Download the `v0.16` binary.** Grab the release matching the target platform from https://github.com/stalwartlabs/stalwart/releases/latest. Do **not** replace the running binary yet. + +**2. Stop the old service.** + +- On systems with systemd: + ```bash + $ sudo systemctl stop stalwart + ``` +- On SysV-style systems with init.d: + ```bash + $ sudo service stalwart stop + ``` + +Verify the process is gone with `ps` before continuing. In a cluster, repeat this on every node. + +**3. Back up the old binary and install the new one.** + +```bash +$ sudo mv /usr/local/bin/stalwart /usr/local/bin/stalwart.v015 +$ sudo mv /path/to/downloaded/stalwart /usr/local/bin/stalwart +$ sudo chmod 0755 /usr/local/bin/stalwart +$ sudo chown root:root /usr/local/bin/stalwart +``` + +**4. Install the new `config.json`.** The file produced by the migration script in Step 1 goes where the old TOML configuration used to live: + +```bash +$ sudo mv /path/to/config.json /etc/stalwart/config.json +$ sudo chown stalwart:stalwart /etc/stalwart/config.json +$ sudo chmod 0640 /etc/stalwart/config.json +``` + +The old `config.toml` can be kept as a reference but is no longer read by the server. + +**5. Start the new binary in recovery mode from the foreground.** Running the initial migration under the service manager is discouraged: if something goes wrong, the output scrolls past in `journalctl` and the restart loop masks the cause. Instead, run it directly as the `stalwart` user so that stdout and stderr are visible in the current terminal: + +```bash +$ sudo -u stalwart env \ + STALWART_RECOVERY_MODE=1 \ + STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \ + /usr/local/bin/stalwart --config=/etc/stalwart/config.json +``` + +`STALWART_RECOVERY_MODE=1` tells the server to enter the one-shot migration path: wipe the incompatible subspaces listed above, migrate the spam classifier model, and then bring up **only** the management HTTP endpoint on port `8080`. Mail ports stay closed. `STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword` provisions a temporary admin credential that the CLI can authenticate against: this is needed because the converted `export.json` does not grant admin rights to any user (that is deliberate; admin assignment is a deployment decision). Replace `someTemporaryPassword` with a strong value; this account exists only until a real admin is created. + +The migration output will scroll past. When it finishes, the process stays in the foreground, listening on port `8080`. Leave this terminal open. + +**6. Apply the exported snapshot.** From a **second terminal** (on the same host or any machine that can reach the server on port `8080`), install the new CLI (*make sure to install v1.0.2 or later*): instructions at https://stalw.art/docs/management/cli/overview: and run: + +```bash +$ export STALWART_URL=http://127.0.0.1:8080 +$ export STALWART_USER=admin +$ export STALWART_PASSWORD=someTemporaryPassword +$ stalwart-cli apply --file /path/to/export.json +``` + +A summary similar to the following should appear: + +``` +Plan: 0 destroy, 5 update, 6 create (…) +✓ created Tenant (…) +✓ created Domain (…) +✓ created Account (…) +… +Done: 0 destroyed, 5 updated, … created (0 failed) +``` + +If any operation fails, the CLI stops immediately and prints the error. Fix the root cause (usually a conflict with an object created in an earlier attempt) and rerun. `apply` is re-entrant with `--continue-on-error` when needed. + +At this point, snapshots exported from the test deployment with `stalwart-cli snapshot` can also be applied, in order: + +```bash +$ stalwart-cli apply --file /path/to/test-deployment-snapshot.json +``` + +**7. Shut down recovery mode.** Return to the terminal running the foreground server and press `Ctrl+C`. The process will exit cleanly. + +**8. Reconfigure the service manager.** The systemd unit or init.d script still references the old TOML path. Update it to point at the new JSON file: + +- For systemd (typically `/etc/systemd/system/stalwart.service`), locate the `ExecStart=` line and change the `--config=` argument: + ```ini + ExecStart=/usr/local/bin/stalwart --config=/etc/stalwart/config.json + ``` + Then reload the unit: + ```bash + $ sudo systemctl daemon-reload + ``` +- For init.d (typically `/etc/init.d/stalwart`), update the `DAEMON_ARGS` line similarly. + +**9. Decide how to handle the recovery admin.** The recovery admin credential must be available the first time a real administrator logs in to create a proper admin account. Two options: + +- **Preferred, if a test deployment was used:** the test-deployment snapshot applied in step 6 can already include an administrator account, in which case no further action is needed. Start the service normally. +- **Otherwise:** leave `STALWART_RECOVERY_ADMIN` in place until a real admin is created through the WebUI, then remove it. For systemd, set it via the environment file referenced by `EnvironmentFile=` in the service unit (the default Stalwart install creates `/etc/stalwart/stalwart.env` for exactly this purpose: uncomment the `STALWART_RECOVERY_ADMIN` line and set the value). For init.d, export the variable in `/etc/default/stalwart` or the distribution's equivalent. Do **not** set `STALWART_RECOVERY_MODE=1`: that is for the migration only and would put the server back into recovery mode at every restart. + +**10. Start the service.** + +```bash +$ sudo systemctl start stalwart # or: sudo service stalwart start +``` + +Verify it comes up cleanly and is listening on its normal ports. The deployment is now on `v0.16`. For reference on how a fresh `v0.16` Linux install is expected to look, see https://stalw.art/docs/install/platform/linux. + +### Option B: Docker deployments + +The new Docker image uses **different mount points** than the old one. Where the previous image mounted a single `/opt/stalwart` volume, the new image mounts two: + +| Volume | Purpose | +|:---|---| +| `/etc/stalwart` | Configuration directory (contains `config.json`) | +| `/var/lib/stalwart` | Persistent application data (RocksDB, local blobs, bootstrap registry) | + +The Docker migration uses the same recovery-mode pattern as the binary case: stop the old container, run a throwaway container in recovery mode, apply the snapshot, stop the throwaway, then start the real container. + +> **Clustered deployments:** stop every container running `v0.15.x` before starting the migration on any node. + +**1. Stop the old container.** + +```bash +$ docker stop stalwart +``` + +**2. Prepare the new volumes.** Two named volumes (or two host directories, if bind-mounting) are required: + +```bash +$ docker volume create stalwart-etc +$ docker volume create stalwart-data +``` + +For deployments where the embedded database holds user mail (RocksDB / SQLite), the contents of the old `/opt/stalwart/data` directory must be placed in the new `stalwart-data` volume before starting the recovery-mode container. The simplest way is a helper container: + +```bash +$ docker run --rm \ + -v :/old \ + -v stalwart-data:/new \ + alpine sh -c 'cp -a /old/data/. /new/ && chown -R 2000:2000 /new' +``` + +Replace `` with the host path that the previous container had mounted at `/opt/stalwart`. The `chown` step is required because the new image runs as UID `2000`. For deployments that use external databases (PostgreSQL, MySQL, FoundationDB, S3, Azure, Redis, NATS), skip the copy: the data already lives outside the container. + +**3. Install `config.json` in the new config volume.** + +```bash +$ docker run --rm \ + -v /path/to/local/config.json:/src/config.json:ro \ + -v stalwart-etc:/dst \ + alpine sh -c 'cp /src/config.json /dst/config.json && chown 2000:2000 /dst/config.json' +``` + +> **Update embedded paths inside `config.json` and `export.json` for the new mount points.** The migration script writes the on-disk paths it found in the v0.15 deployment, which on the previous Docker image typically pointed at `/opt/stalwart/data` (and `/opt/stalwart/data/blobs` for the filesystem [BlobStore](https://stalw.art/docs/ref/object/blob-store)). The new image mounts persistent data at `/var/lib/stalwart` instead, so any path referencing the old location must be rewritten before the recovery container is started; otherwise the container exits with `Permission denied: /opt/stalwart/data` because UID `2000` cannot create that directory inside the container's filesystem. +> +> The migration script ships with a `--patch-paths` flag that handles the rewrite during `convert`: +> +> ```bash +> $ python migrate_v016.py convert \ +> --settings settings.json --principals principals.json \ +> --config config.json --output export.json \ +> --patch-paths /opt/stalwart=/var/lib/stalwart +> ``` +> +> `--patch-paths SOURCE=DEST` walks both emitted files and rewrites any string value beginning with the source prefix. The flag may be supplied multiple times for deployments that mount data under several legacy paths. When the script detects `/opt/stalwart` in the source settings and the flag was not passed, it prints a notice with the exact command to rerun. +> +> For deployments that already produced `config.json` and `export.json` without the flag, the equivalent in-place edit is: +> +> ```bash +> $ sed -i.bak \ +> -e 's|/opt/stalwart/data/blobs|/var/lib/stalwart/blobs|g' \ +> -e 's|/opt/stalwart/data|/var/lib/stalwart|g' \ +> config.json export.json +> $ grep -n /opt/stalwart config.json export.json # verify clean +> ``` +> +> The blob-path substitution runs first so the more general data-path rewrite does not double-rewrite it. The `.bak` files left behind by `-i.bak` are the rollback if the substitution went wrong. +> +> Skip this paragraph entirely on deployments that use external databases (PostgreSQL, MySQL, FoundationDB) and external blob backends; those deployments have no on-disk paths to rewrite. + +**4. Start a temporary container in recovery mode.** This container exists only for the duration of the migration: + +```bash +$ docker run -d --name stalwart-recovery \ + -e STALWART_RECOVERY_MODE=1 \ + -e STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \ + -p 8080:8080 \ + -v stalwart-etc:/etc/stalwart \ + -v stalwart-data:/var/lib/stalwart \ + stalwartlabs/stalwart:v0.16 +``` + +Only port `8080` (management API) is published: mail ports stay closed in recovery mode. Watch the logs to confirm the migration completes successfully: + +```bash +$ docker logs -f stalwart-recovery +``` + +Wait until the logs stop scrolling and settle on the message indicating the HTTP endpoint is listening. + +**5. Apply the exported snapshot.** From the host (or any machine that can reach `http://:8080`): + +```bash +$ export STALWART_URL=http://127.0.0.1:8080 +$ export STALWART_USER=admin +$ export STALWART_PASSWORD=someTemporaryPassword +$ stalwart-cli apply --file /path/to/export.json +``` + +Follow with any snapshots captured from the test deployment: + +```bash +$ stalwart-cli apply --file /path/to/test-deployment-snapshot.json +``` + +**6. Stop and remove the temporary container.** + +```bash +$ docker stop stalwart-recovery +$ docker rm stalwart-recovery +``` + +**7. Start the production container.** Same image, without `STALWART_RECOVERY_MODE`, with all mail ports published: + +```bash +$ docker run -d --name stalwart \ + --restart unless-stopped \ + -e STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \ + -p 443:443 -p 8080:8080 \ + -p 25:25 -p 587:587 -p 465:465 \ + -p 143:143 -p 993:993 \ + -p 110:110 -p 995:995 \ + -p 4190:4190 \ + -v stalwart-etc:/etc/stalwart \ + -v stalwart-data:/var/lib/stalwart \ + stalwartlabs/stalwart:v0.16 +``` + +The `STALWART_RECOVERY_ADMIN` variable is retained deliberately so that a real administrator account can still be created through the WebUI after the first login. Once a permanent admin exists, restart the container without that environment variable to remove the back-door credential. If the test deployment snapshot applied in step 5 already includes an administrator account, the variable can be omitted from this step entirely. + +For reference on the standard Docker deployment, see https://stalw.art/docs/install/platform/docker. + +## Step 4: Post-migration tasks + +With the server running on `v0.16`, a few follow-up actions are required to complete the upgrade. + +### Log in to the admin panel + +Open a browser and navigate to: + +``` +https://mail.example.org/admin +``` + +Replace `mail.example.org` with the server's hostname. Log in either with the recovery admin credential (if it is still active) or with the administrator account created via the test-deployment snapshot. + +A few behavioural changes from `v0.15.x` are worth flagging before the first sign-in: + +- **The WebUI is reached over HTTPS on the configured hostname only.** The OAuth, OIDC, and JMAP discovery documents `v0.16` publishes use `https:///...` exclusively in normal mode. Loading the WebUI by IP address, by container name, or over plain HTTP (for example `http://192.168.1.10:8080/admin`) will appear to load the sign-in page but will fail at the OAuth callback. Use the same hostname that was entered in Step 1 of the wizard, or that already lives on `defaultHostname` from the migrated settings. +- **`http://...:8080` is no longer the right URL for day-to-day administration.** Port `8080` carries the recovery / bootstrap HTTP listener and is intended for the migration window; once the server is running normally it stops being a valid sign-in entry point. +- **When the public HTTPS port is not `443`** (for example a reverse proxy on `:8443`, or a Docker host port mapping that diverges from the container's `443`), set the [`STALWART_HTTPS_PORT`](https://stalw.art/docs/configuration/environment-variables#public-urls) environment variable to that port and restart the server. Without it, the discovery documents will publish `https:///...` (port `443` implied) and clients will be sent to a port the proxy is not listening on. +- **Plain-text mail listeners (port `587` submission, port `143` IMAP) are no longer added by default.** This is required for compliance with the PACC autoconfig draft, which only advertises implicit-TLS ports. Mail clients that were configured to connect over `587` STARTTLS will silently stop working until either the listener is recreated through the WebUI / CLI or the clients are pointed at the implicit-TLS ports (`465` for submission, `993` for IMAPS). + +#### Reverse-proxy deployments + +If the deployment sits behind a reverse proxy (NGINX, Traefik, Caddy, HAProxy, or similar), this is the part of the migration where proxy-related issues most often surface. The migrated `defaultHostname`, the proxy's public hostname, the proxy's listening port, and the proxy's TLS configuration all have to line up before the first sign-in completes; if any of them is off, the OAuth flow fails partway through with errors that are hard to relate back to the proxy. + +The most reliable way through this step is to **bypass the proxy temporarily** for the duration of the recovery-mode `apply`, the first sign-in, and the creation of a permanent administrator. Concretely: + +1. While running `stalwart-cli apply` and creating the permanent admin, point the CLI and the browser at Stalwart directly: `http://:8080` for the recovery-mode CLI session, then `https:///admin` (accepting any self-signed certificate warning) for the first WebUI sign-in. +2. Once a permanent administrator account exists and the WebUI is confirmed working, restore the reverse-proxy configuration. From this point forward, end users reach Stalwart through the proxy and the discovery documents already point at the public hostname over HTTPS. + +A full description of how `v0.16` composes the published URLs, how the proxy can talk to Stalwart on either HTTP or HTTPS, and where to set `STALWART_HTTPS_PORT` for non-standard public ports lives at https://stalw.art/docs/server/reverse-proxy/overview. + +### Recalculate disk quotas + +Disk quotas were reset to zero during the wipe and need to be rebuilt from the actual mailbox contents. Navigate to the **Tasks** section of the admin panel and trigger the **"Recalculate disk quotas"** task. This spawns one subtask per user account, each of which scans that user's storage and updates the counter. On large deployments this may take a while to complete: progress is visible in the Tasks panel. + +### Recalculate tenant quotas (multi-tenant deployments) + +Only applicable when per-tenant disk quotas are in use. After the per-account recalculation has finished for every user, trigger a second task from the Tasks panel: **"Recalculate tenant quotas"**. This rolls the per-account totals up into the tenant-level counters. + +### Create a permanent administrator + +If the migration was performed without a snapshot from a test deployment, the only administrative credential at this point is the recovery admin defined by `STALWART_RECOVERY_ADMIN`. This credential is a back door: as long as the environment variable is set, the username and password it specifies can log in regardless of directory state. Create a real administrator account through **Management → Accounts**, verify the new account can log in, and then remove `STALWART_RECOVERY_ADMIN` from: + +- the systemd environment file (for example `/etc/stalwart/stalwart.env`) and restart the service, or +- the init.d defaults file, and restart the service, or +- the Docker container's `-e` flag (redeploy the container without it). + +### Review the rest of the configuration + +The migration script converts directory, domain, storage, DKIM, and certificate state. Everything else: SMTP listeners, mail routing, spam rules, rate limits, retention policies, ACME, authentication backends other than those listed above: must be reviewed in the WebUI and either recreated by hand, applied from a test-deployment snapshot, or validated against the defaults that `v0.16` ships with. + +## Troubleshooting + +### The server fails to start after installing `config.json` + +Confirm the file is valid JSON (`python -m json.tool config.json` or `jq . config.json`) and that the datastore described in it is reachable with the provided credentials. The daemon's logs will name the offending field when a field is missing or malformed. + +### `stalwart-cli apply` fails partway through + +Most failures come from trying to create an object whose parent does not exist yet (for example, a `DkimSignature` referencing a `Domain` that is missing from the plan). The error message names the object and the missing reference. Either edit the plan to include the missing parent, or split the `apply` into two runs using the individual snapshot files produced by the script and the test deployment. + +### Recovering from a partial `apply` + +`apply` runs operations in plan order and stops on the first error. When a `create` fails halfway through, every prior `create` in the same run has already been committed to the database. Re-running the same plan now fails with `primaryKeyViolation` (the objects exist) or `invalidForeignKey` (a parent that did not get created the first time is still missing). + +> **Do not bulk-delete `Account` objects to recover.** The migration plan creates each account with its original v0.15 account id (the `restore-` mechanism), so a migrated `Account` points at the existing v0.15 mailbox data in the data store. Deleting that `Account` schedules account destruction, which unlinks and erases all mail, calendars, and contacts stored under that id. On the community edition this runs immediately, with no retention window. Never run `delete Account` against a data store that already contains v0.15 mail. + +Recovery does not require deleting accounts. An account that a partial `apply` already created is correct and is reused as-is on the next run; the only objects that need clearing are the registry-only ones that carry no mailbox data and whose re-creation would otherwise raise `primaryKeyViolation`. While the server is still in recovery mode: + +```bash +$ stalwart-cli query DkimSignature --json | jq -r '.[].id' \ + | stalwart-cli delete DkimSignature --stdin +$ stalwart-cli query Certificate --json | jq -r '.[].id' \ + | stalwart-cli delete Certificate --stdin +$ stalwart-cli query Domain --json | jq -r '.[].id' \ + | stalwart-cli delete Domain --stdin +$ stalwart-cli query Tenant --json | jq -r '.[].id' \ + | stalwart-cli delete Tenant --stdin +``` + +`Domain` and `Tenant` hold only directory metadata and are safe to delete and recreate; `Account` is deliberately omitted. Then fix the underlying cause in `export.json` (most often a domain that fails the v0.16 hostname check, an account whose local-part contains `@`, or a stale `/opt/stalwart` path embedded by the migration script), remove from `export.json` the `create` operation for `Account` (and any other object that already committed before the failure, so re-applying it does not raise `primaryKeyViolation`), and rerun: + +```bash +$ stalwart-cli apply --file export.json +``` + +If you must start over with the accounts as well, do not delete them: point the new deployment at an empty data store (or restore the v0.15 data-store backup) before re-running `apply`, so that destroying and recreating accounts cannot reach live mail. + +If the failure was caused by data that the migration script itself produced incorrectly, also rerun the script with the latest version from `main` before applying. Fixes during the v0.16.0 / v0.16.1 window addressed several edge cases (group names containing `@`, ACME base64 padding, single-URL Redis stores, paths embedded in custom storage backends, and `%{file:...}%` / `%{env:...}%` macros in DKIM private keys and certificates, which are now expanded by the script instead of being passed through verbatim and aborting the `apply`). + +For deployments where individual objects are easier to identify than to wipe wholesale, use `stalwart-cli query ` to list ids and `stalwart-cli delete --ids ` to remove a specific one. The same warning applies: deleting an `Account` destroys the mail stored under it. Only `Domain`, `Tenant`, `DkimSignature`, and `Certificate` are safe to delete and recreate during recovery. + +### Bootstrapping a real administrator from the CLI + +When the WebUI is unreachable for any reason (TLS not yet in place, reverse proxy misconfigured, OAuth callback failing), the CLI is the supported escape hatch for promoting the first real administrator. Authenticate as the recovery admin and run: + +```bash +$ export STALWART_URL='http://127.0.0.1:8080' +$ export STALWART_USER='admin' +$ export STALWART_PASSWORD='someTemporaryPassword' + +$ stalwart-cli query Domain --fields id,name +$ stalwart-cli create account/user \ + --field name=admin \ + --field domainId= +$ stalwart-cli query Account --where name=admin --fields id +$ stalwart-cli update Account \ + --field 'credentials={"0":{"@type":"Password","secret":""}}' +$ stalwart-cli update Account \ + --field 'roles={"@type":"Admin"}' +``` + +Once the new account can sign in to the WebUI, remove `STALWART_RECOVERY_ADMIN` from the service environment and restart the service. + +### Common questions + +- **`primaryKeyViolation` on a rerun of `apply`**: see *Recovering from a partial `apply`* above. +- **`Domain: create failed for create-N: invalidPatch | Invalid domain name`**: the domain in `export.json` does not pass the v0.16 hostname check (typically a missing or non-public TLD). Either correct the domain in v0.15 before redumping, or hand-edit the offending block in `export.json`. +- **`/admin` redirects to `http://:8080/`**: fixed in v0.16.x; upgrade to the latest patch release. +- **"Recalculate disk quotas" not visible in the WebUI**: open *Tasks → Scheduled → Create task*, choose the *Quota recalculation* maintenance type at the per-account scope, and pick a near-future timestamp. + +### `Data corruption detected` after migration + +This error means one node in a cluster was left running on `v0.15.x` while another was being migrated, and the old node wrote records in the obsolete format into the shared database. Stop every node in the cluster, ensure every binary is on `v0.16`, and restart. If corruption persists, the logs name the offending keys and they can be removed with the `stalwart-cli delete` command. + +### `/admin` (or `/account`) returns `404 Not Found` + +The WebUI is delivered as a downloadable [Application](https://stalw.art/docs/applications/overview) bundle that the server fetches from `https://github.com/stalwartlabs/webui/releases/latest/` on first start, and then refreshes on a schedule. When the very first download fails, no bundle has been unpacked locally and every request to a WebUI mount path returns `404 Not Found`. This is the most common cause of "the server is running, port `8080` answers, but `/admin` returns 404" reports during the migration. + +The fix is to make sure outbound HTTPS from the Stalwart host can reach GitHub's release storage (`github.com` and `objects.githubusercontent.com`). On a host that genuinely cannot reach the public internet, stage the WebUI bundle on an internal HTTPS server and update the [`resourceUrl`](https://stalw.art/docs/ref/object/application#resourceurl) field on the WebUI's [Application](https://stalw.art/docs/ref/object/application) record to point at the internal location. After the first successful download, subsequent failures are non-fatal: the previously installed bundle stays in service and `/admin` keeps working until the next successful refresh. The full description, including the precise hosts involved, is at https://stalw.art/docs/management/webui/overview#outbound-network-requirement. + +### Rolling back + +If the migration cannot be completed within the available maintenance window, the database backup captured in Step 2 can be restored and the old binary (preserved as `/usr/local/bin/stalwart.v015` in the example) started again. The `v0.16` binary will refuse to start a second time against a database that has already been migrated, so restoring the pre-migration backup is the only path back to `v0.15.x`. + +## Questions + +If any part of this migration is unclear, or if something does not behave as documented, please post in the dedicated upgrade discussion at https://support.stalw.art. Include: + +- The deployment type (binary / Docker / clustered) +- The datastore backend (RocksDB / SQLite / PostgreSQL / MySQL / FoundationDB) +- The exact version being upgraded from (`stalwart --version`) +- Any error messages from the server log or the CLI, verbatim + +We would rather answer a question than watch a deployment break. There is no such thing as an obvious question for a migration of this size. diff --git a/api/v1/openapi.yml b/api/v1/openapi.yml new file mode 100644 index 0000000..7de8b44 --- /dev/null +++ b/api/v1/openapi.yml @@ -0,0 +1,586 @@ +openapi: 3.0.3 +info: + title: Stalwart Management API + description: | + REST Management API for Stalwart server. These endpoints are helpers + that complement the JMAP API — most of the server's configuration and data + is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover + interactive login, account introspection, configuration schema retrieval and + live (Server-Sent Events) telemetry streams. + version: "1.0" + license: + name: AGPL-3.0-only OR LicenseRef-SEL +servers: + - url: https://{host} + description: Stalwart server + variables: + host: + default: mail.example.com + description: The hostname of Stalwart server +security: + - bearerAuth: [] + - basicAuth: [] +paths: + /api/auth: + post: + operationId: login + summary: Authenticate a user and obtain an authorization code + description: | + Anonymous endpoint used by the web UI and device-flow clients to exchange + user credentials (plus optional MFA token and PKCE challenge) for an + OAuth authorization `client_code` that can then be exchanged for an + access token via `POST /auth/token`. Rate-limited as an anonymous request. + security: [] + tags: [Authentication] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + examples: + authCode: + summary: Authorization-code flow (web UI) + value: + type: authCode + accountName: jane@example.com + accountSecret: s3cret + clientId: webadmin + redirectUri: https://mail.example.com/login + codeChallenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + codeChallengeMethod: S256 + authDevice: + summary: Device-flow completion + value: + type: authDevice + accountName: jane@example.com + accountSecret: s3cret + code: BDWP-HQPK + responses: + '200': + description: Result of the authentication attempt + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + examples: + authenticated: + value: + type: authenticated + clientCode: 3F7A9C1E4B2D8E6F + mfaRequired: + value: + type: mfaRequired + failure: + value: + type: failure + '400': + $ref: '#/components/responses/BadRequest' + '429': + $ref: '#/components/responses/TooManyRequests' + + /api/discover/{email}: + get: + operationId: discoverOidc + summary: Discover the OpenID Connect provider for an email address + description: | + Returns the OpenID Connect discovery document for the directory that + owns the domain part of `email`. If the domain is not bound to an + external OIDC directory, the server's own OIDC discovery document + (equivalent to `/.well-known/openid-configuration`) is returned. + Anonymous endpoint, rate-limited. + security: [] + tags: [Authentication] + parameters: + - name: email + in: path + required: true + description: Email address or account name + schema: + type: string + format: email + responses: + '200': + description: OpenID Connect discovery document + content: + application/json: + schema: + type: object + description: OIDC discovery metadata (RFC 8414) + additionalProperties: true + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + + /api/account: + get: + operationId: getAccount + summary: Return the authenticated account's permissions, edition and locale + tags: [Account] + responses: + '200': + description: Account descriptor + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/schema: + get: + operationId: getSchemaRedirect + summary: Redirect to the versioned configuration schema URL + description: | + Redirects (302) to `/api/schema/{hash}` where `{hash}` is the SHA-256 + of the current configuration schema. Use this when you do not yet know + the hash; once you have cached a schema at a given hash the immutable + cache will never require re-download. + tags: [Schema] + responses: + '302': + description: Redirect to the hashed schema URL + headers: + Location: + schema: + type: string + example: /api/schema/a1b2c3d4e5f6... + '401': + $ref: '#/components/responses/Unauthorized' + + /api/schema/{hash}: + get: + operationId: getSchema + summary: Return the configuration schema at a specific hash + description: | + Returns the JSON Schema describing the full Stalwart configuration tree. + The response is always gzip-encoded (`Content-Encoding: gzip`) and served + with an immutable cache policy — the schema for a given hash never + changes. If the hash does not match the server's current schema, the + server redirects to the correct URL. + tags: [Schema] + parameters: + - name: hash + in: path + required: true + description: SHA-256 hex digest of the configuration schema + schema: + type: string + responses: + '200': + description: Gzipped JSON Schema document + headers: + Content-Encoding: + schema: + type: string + example: gzip + Cache-Control: + schema: + type: string + example: public, max-age=31536000, immutable + content: + application/json: + schema: + type: object + description: JSON Schema document describing Stalwart config + additionalProperties: true + '302': + description: Redirect to the current schema URL when the hash is stale + '401': + $ref: '#/components/responses/Unauthorized' + + /api/token/delivery: + get: + operationId: issueDeliveryToken + summary: Issue a short-lived token for live delivery diagnostics + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/delivery/{target}` as a query parameter + (`?token=...`). Useful for EventSource clients that cannot send + `Authorization` headers. Requires `LiveDeliveryTest` permission. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived delivery token + content: + text/plain: + schema: + type: string + example: eyJhbGciOi... + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/token/tracing: + get: + operationId: issueTracingToken + summary: Issue a short-lived token for live tracing (Enterprise) + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/tracing` as a query parameter. Requires the + `LiveTracing` permission. Available only in the Enterprise edition. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived tracing token + content: + text/plain: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/token/metrics: + get: + operationId: issueMetricsToken + summary: Issue a short-lived token for live metrics (Enterprise) + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/metrics` as a query parameter. Requires the + `LiveMetrics` permission. Available only in the Enterprise edition. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived metrics token + content: + text/plain: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/live/delivery/{target}: + get: + operationId: liveDelivery + summary: Stream outbound-delivery diagnostics as Server-Sent Events + description: | + Opens a `text/event-stream` connection that streams each stage of an + outbound delivery attempt to `target` (a domain or email address): MX + lookup, MTA-STS fetch, TLSA/DANE validation, SMTP conversation, and so + on. Each SSE frame carries a JSON-encoded `DeliveryStage` wrapped in a + single-element array (`data: [{...}]`). The stream ends with a final + `completed` event. Requires `LiveDeliveryTest` permission; may also be + authenticated via the `?token=` query parameter obtained from + `/api/token/delivery`. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] + parameters: + - name: target + in: path + required: true + description: Target domain or email address to diagnose + schema: + type: string + - name: timeout + in: query + required: false + description: Maximum stream lifetime in seconds (minimum 1, default 30) + schema: + type: integer + minimum: 1 + default: 30 + responses: + '200': + description: Server-Sent Events stream of delivery-diagnose stages + content: + text/event-stream: + schema: + type: string + description: | + Series of SSE frames. Each `event: event` frame carries + `data: []`; the final frame's stage is + `{"type":"completed"}`. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/live/tracing: + get: + operationId: liveTracing + summary: Stream live tracing events (Enterprise) + description: | + Opens a `text/event-stream` connection streaming server trace events in + real time. Requires `LiveTracing` permission; may be authenticated via + the `?token=` query parameter from `/api/token/tracing`. Enterprise only. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] + responses: + '200': + description: Server-Sent Events stream of tracing events + content: + text/event-stream: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/live/metrics: + get: + operationId: liveMetrics + summary: Stream live metrics events (Enterprise) + description: | + Opens a `text/event-stream` connection streaming server metrics in real + time. Requires `LiveMetrics` permission; may be authenticated via the + `?token=` query parameter from `/api/token/metrics`. Enterprise only. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] + responses: + '200': + description: Server-Sent Events stream of metric events + content: + text/event-stream: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: OAuth2 access token issued by `POST /auth/token`. + basicAuth: + type: http + scheme: basic + description: HTTP Basic authentication using account name and secret. + liveToken: + type: apiKey + in: query + name: token + description: | + Short-lived token (60s lifetime) issued by `/api/token/{kind}` and used + to authorise Server-Sent Events streams where an `Authorization` header + cannot be set (e.g. browser `EventSource`). + + responses: + BadRequest: + description: Request payload is malformed or fails validation + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + Unauthorized: + description: Missing or invalid credentials + headers: + WWW-Authenticate: + schema: + type: string + example: Bearer realm="Stalwart Server" + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + Forbidden: + description: Authenticated principal lacks the required permission + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + NotFound: + description: Resource not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + TooManyRequests: + description: Anonymous-request rate limit exceeded + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + + schemas: + LoginRequest: + type: object + description: | + Tagged union discriminated by `type`. Use `authCode` for the standard + OAuth authorization-code flow (optionally with PKCE) and `authDevice` + to complete an OAuth device-authorization flow. + oneOf: + - $ref: '#/components/schemas/LoginRequestAuthCode' + - $ref: '#/components/schemas/LoginRequestAuthDevice' + discriminator: + propertyName: type + mapping: + authCode: '#/components/schemas/LoginRequestAuthCode' + authDevice: '#/components/schemas/LoginRequestAuthDevice' + + LoginRequestAuthCode: + type: object + required: [type, accountName, accountSecret, clientId] + properties: + type: + type: string + enum: [authCode] + accountName: + type: string + accountSecret: + type: string + format: password + mfaToken: + type: string + nullable: true + description: MFA token returned by a previous `mfaRequired` response + clientId: + type: string + description: OAuth client identifier + redirectUri: + type: string + format: uri + nullable: true + description: Must use `https://` unless the server is in recovery or dev mode + nonce: + type: string + nullable: true + scope: + type: string + nullable: true + codeChallenge: + type: string + nullable: true + description: PKCE code challenge (RFC 7636) + codeChallengeMethod: + type: string + enum: [plain, S256] + nullable: true + description: Defaults to `plain` when a `codeChallenge` is present + state: + type: string + nullable: true + + LoginRequestAuthDevice: + type: object + required: [type, accountName, accountSecret, code] + properties: + type: + type: string + enum: [authDevice] + accountName: + type: string + accountSecret: + type: string + format: password + mfaToken: + type: string + nullable: true + code: + type: string + description: User-facing device code issued by `POST /auth/device` + + LoginResponse: + type: object + description: Tagged union discriminated by `type`. + oneOf: + - $ref: '#/components/schemas/LoginResponseAuthenticated' + - $ref: '#/components/schemas/LoginResponseVerified' + - $ref: '#/components/schemas/LoginResponseMfaRequired' + - $ref: '#/components/schemas/LoginResponseFailure' + discriminator: + propertyName: type + mapping: + authenticated: '#/components/schemas/LoginResponseAuthenticated' + verified: '#/components/schemas/LoginResponseVerified' + mfaRequired: '#/components/schemas/LoginResponseMfaRequired' + failure: '#/components/schemas/LoginResponseFailure' + + LoginResponseAuthenticated: + type: object + required: [type, clientCode] + properties: + type: + type: string + enum: [authenticated] + clientCode: + type: string + description: Authorization code to exchange at `POST /auth/token` + + LoginResponseVerified: + type: object + required: [type] + properties: + type: + type: string + enum: [verified] + + LoginResponseMfaRequired: + type: object + required: [type] + properties: + type: + type: string + enum: [mfaRequired] + + LoginResponseFailure: + type: object + required: [type] + properties: + type: + type: string + enum: [failure] + + Account: + type: object + required: [permissions, edition, locale] + properties: + permissions: + type: array + description: | + Effective permissions for the authenticated principal, filtered to + exclude internal/system-only permissions. Values are from the + `Permission` enum (e.g. `authenticate`, `jmap-email-get`, + `sys-account-settings-get`). + items: + type: string + edition: + type: string + enum: [oss, community, enterprise] + description: Server edition + locale: + type: string + description: Preferred locale for the account (IETF BCP 47-style tag) + + ProblemDetails: + type: object + description: RFC 7807 problem details document + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + instance: + type: string diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..c584f85 --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,101 @@ +[package] +name = "common" +version = "0.16.22" +edition = "2024" +build = "build.rs" + +[dependencies] +utils = { path = "../utils" } +nlp = { path = "../nlp" } +store = { path = "../store" } +trc = { path = "../trc" } +directory = { path = "../directory" } +coordinator = { path = "../coordinator" } +types = { path = "../types" } +registry = { path = "../registry" } +jmap_proto = { path = "../jmap-proto" } +sieve-rs = { version = "0.7", features = ["rkyv", "serde"] } +mail-parser = { version = "0.11", features = ["full_encoding"] } +mail-builder = { version = "1.0" } +mail-auth = { version = "0.13", features = ["generate", "arc"] } +smtp-proto = { version = "0.2.3", features = ["rkyv"] } +dns-update = { version = "0.5" } +calcard = { version = "0.3", features = ["rkyv"] } +ahash = { version = "0.8.12", features = ["serde"] } +parking_lot = "0.12.5" +regex = "1.13.1" +proxy-header = { version = "0.1.2", features = ["tokio"] } +arc-swap = "1.9.2" +rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +rustls-pemfile = "2.2" +rustls-pki-types = { version = "1" } +aws-lc-rs = { version = "1" } +tokio = { version = "1.53", features = ["net", "macros"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +futures = "0.3" +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "stream"]} +serde = { version = "1.0", features = ["derive"]} +serde_json = "1.0" +base64 = "0.23" +x509-parser = "0.18" +pem = "4.0" +chrono = { version = "0.4", features = ["serde"] } +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +opentelemetry = { git = "https://github.com/stalwartlabs/opentelemetry-rust" } +opentelemetry_sdk = { git = "https://github.com/stalwartlabs/opentelemetry-rust" } +opentelemetry-otlp = { git = "https://github.com/stalwartlabs/opentelemetry-rust", default-features = false, features = ["reqwest-client", "http-proto", "trace", "metrics", "logs", "internal-logs", "grpc-tonic", "tls-aws-lc", "tls-roots", "reqwest-rustls"] } +opentelemetry-semantic-conventions = { git = "https://github.com/stalwartlabs/opentelemetry-rust" } +prometheus = { version = "0.14", default-features = false } +imagesize = "0.15" +sha1 = "0.11" +sha2 = "0.11" +md5 = "0.8.1" +whatlang = "0.18" +idna = "1.1" +decancer = "3.3.3" +unicode-security = "0.1.2" +infer = "0.22" +bincode = { version = "2.0.1", features = ["serde"] } +hostname = "0.4.2" +zip = "8.6" +xxhash-rust = { version = "0.8.18", features = ["xxh3"] } +psl = "2" +aes-gcm-siv = "0.12.1" +jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } +rsa = "0.9.10" +p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pkcs8", "pem"] } +p384 = { version = "0.13", features = ["ecdh"] } +hashify = "0.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } +tinyvec = { version = "1.12.0", features = ["alloc"] } +compact_str = { version = "0.10.0", features = ["rkyv", "serde"] } +lz4_flex = { version = "0.14", features = ["frame"], default-features = false } +hickory-proto = "0.26.3" +nohash-hasher = "0.2.0" +quick_cache = "0.7" +rasn = "0.28" +rasn-pkix = "0.28" +sequoia-openpgp = { version = "2.4", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] } +zxcvbn = "3.1.1" +pkcs8 = { version = "0.10.2", features = ["alloc", "std"] } +quick-xml = "0.41" + +[target.'cfg(unix)'.dependencies] +privdrop = "0.5.6" +libc = "0.2.189" + +[target.'cfg(windows)'.dependencies] +socket2 = "0.6" + +[features] +test_mode = [] +dev_mode = [] +enterprise = [] +foundation = [] + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/common/build.rs b/crates/common/build.rs new file mode 100644 index 0000000..c62f67d --- /dev/null +++ b/crates/common/build.rs @@ -0,0 +1,234 @@ +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("locales.rs"); + + // Read the YAML file + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let repo_root = Path::new(&manifest_dir).parent().unwrap().parent().unwrap(); + let yaml_path = repo_root.join("resources/locales/i18n.yml"); + let yaml_content = + fs::read_to_string(&yaml_path).unwrap_or_else(|_| panic!("Failed to read {yaml_path:?}")); + + let locales = parse_yaml(&yaml_content); + + let generated_code = generate_locale_code(&locales); + + fs::write(&dest_path, generated_code).expect("Failed to write generated locales."); + + println!("cargo:rerun-if-changed={}", yaml_path.display()); +} + +fn parse_yaml(content: &str) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + let mut current_key = None; + + for line in content.lines() { + if let Some((key, value)) = line.split_once(':') { + let is_translation = key + .as_bytes() + .first() + .is_some_and(|&b| b.is_ascii_whitespace()); + let key = key.trim(); + if !key.starts_with('#') && !key.is_empty() { + if !is_translation { + current_key = result.entry(key.replace('.', "_")).or_default().into(); + } else { + current_key + .as_mut() + .unwrap() + .insert(key.to_string(), value.trim().trim_matches('"').to_string()); + } + } + } + } + + result +} + +fn const_name(language: &str) -> String { + language.to_uppercase().replace('-', "_") +} + +const PLURAL_CATEGORIES: [&str; 6] = ["zero", "one", "two", "few", "many", "other"]; + +const RTL_LANGUAGES: [&str; 10] = ["ar", "ckb", "dv", "fa", "he", "ps", "sd", "ug", "ur", "yi"]; + +fn direction(language: &str) -> &'static str { + let tag = language.split(['-', '_']).next().unwrap_or(language); + if RTL_LANGUAGES.contains(&tag) { + "rtl" + } else { + "ltr" + } +} + +fn split_plural_forms(value: &str) -> Option> { + value + .split(';') + .map(|segment| { + segment + .split_once('=') + .filter(|(name, _)| PLURAL_CATEGORIES.contains(name)) + }) + .collect() +} + +fn plural_keys(locales: &HashMap>) -> HashSet { + let mut keys = HashSet::new(); + + for (key, translations) in locales { + if !translations + .values() + .any(|value| split_plural_forms(value).is_some()) + { + continue; + } + + for (language, value) in translations { + let Some(forms) = split_plural_forms(value) else { + panic!( + "{key}: {language} has no plural categories while other languages do: {value:?}" + ); + }; + let mut seen = HashSet::new(); + for (name, _) in &forms { + if !seen.insert(*name) { + panic!("{key}: {language} repeats the plural category {name:?}"); + } + } + if !seen.contains("other") { + panic!("{key}: {language} is missing the required \"other\" plural category"); + } + } + + keys.insert(key.clone()); + } + + keys +} + +fn plural_forms_literal(value: &str) -> String { + let forms = split_plural_forms(value).expect("validated above"); + let other = forms + .iter() + .find(|(name, _)| *name == "other") + .map(|(_, text)| *text) + .expect("validated above"); + + let mut literal = String::from("PluralForms {"); + for category in PLURAL_CATEGORIES { + let text = forms + .iter() + .find(|(name, _)| *name == category) + .map_or(other, |(_, text)| *text); + literal.push_str(&format!(" {category}: {text:?},")); + } + literal.push_str(" }"); + literal +} + +fn generate_locale_code(locales: &HashMap>) -> String { + let mut code = String::new(); + let plural = plural_keys(locales); + + code.push_str("#[derive(Debug, Clone, Copy)]\n"); + code.push_str("pub struct PluralForms {\n"); + for category in PLURAL_CATEGORIES { + code.push_str(&format!(" pub {category}: &'static str,\n")); + } + code.push_str("}\n\n"); + + code.push_str("#[derive(Debug, Clone)]\n"); + code.push_str("pub struct Locale {\n"); + code.push_str(" pub name: &'static str,\n"); + code.push_str(" pub direction: &'static str,\n"); + + for key in locales.keys() { + let field_type = if plural.contains(key) { + "PluralForms" + } else { + "&'static str" + }; + code.push_str(&format!(" pub {key}: {field_type},\n")); + } + + code.push_str("}\n\n"); + + let mut languages = std::collections::HashSet::new(); + for translations in locales.values() { + for lang in translations.keys() { + languages.insert(lang.clone()); + } + } + + for lang in &languages { + code.push_str(&format!( + "pub static {}_LOCALES: Locale = Locale {{\n", + const_name(lang) + )); + code.push_str(&format!(" name: {lang:?},\n")); + code.push_str(&format!(" direction: {:?},\n", direction(lang))); + + for (key, translations) in locales { + let value = translations + .get(lang) + .unwrap_or_else(|| panic!("Missing: {}", key)); + if plural.contains(key) { + code.push_str(&format!(" {key}: {},\n", plural_forms_literal(value))); + } else { + code.push_str(&format!(" {key}: {value:?},\n")); + } + } + + code.push_str("};\n\n"); + } + + let mut sorted: Vec<&String> = languages.iter().collect(); + sorted.sort_unstable(); + code.push_str(&format!( + "pub static ALL_LOCALES: [&Locale; {}] = [\n", + sorted.len() + )); + for lang in &sorted { + code.push_str(&format!(" &{}_LOCALES,\n", const_name(lang))); + } + code.push_str("];\n\n"); + + code.push_str("pub fn locale(name: &str) -> Option<&'static Locale> {\n"); + code.push_str(" hashify::tiny_map_ignore_case!(name.as_bytes(),\n"); + for lang in &languages { + code.push_str(&format!( + " \"{}\" => &{}_LOCALES,\n", + lang, + const_name(lang) + )); + } + code.push_str(" )\n"); + code.push_str("}\n\n"); + + // Maps a bare language tag onto the regional locale shipped for it + let mut by_language: Vec<(&str, &str)> = languages + .iter() + .map(|lang| (lang.split('-').next().unwrap_or(lang), lang.as_str())) + .collect(); + by_language.sort_unstable(); + by_language.dedup_by_key(|(language, _)| *language); + + code.push_str("pub fn locale_by_language(language: &str) -> Option<&'static Locale> {\n"); + code.push_str(" hashify::tiny_map_ignore_case!(language.as_bytes(),\n"); + for (language, lang) in by_language { + code.push_str(&format!( + " \"{}\" => &{}_LOCALES,\n", + language, + const_name(lang) + )); + } + code.push_str(" )\n"); + code.push_str("}\n"); + code +} diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs new file mode 100644 index 0000000..4c6ce2d --- /dev/null +++ b/crates/common/src/auth/access_token.rs @@ -0,0 +1,940 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::AccessToken; +use crate::{ + Server, + auth::{ + AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, Permissions, RECOVERY_ADMIN_ID, + permissions::{BuildPermissions, PermissionsListBuilder}, + }, + network::limiter::{ConcurrencyLimiter, LimiterResult}, +}; +use ahash::AHasher; +use registry::{ + schema::{ + enums::Permission, + structs::{self, Account, Roles, UserRoles}, + }, + types::EnumImpl, +}; +use std::{ + hash::{Hash, Hasher}, + net::IpAddr, + sync::Arc, +}; +use store::{query::acl::AclQuery, rand, write::now}; +use tinyvec::TinyVec; +use trc::{AddContext, StoreEvent}; +use types::{acl::Acl, collection::Collection}; +use utils::map::bitmap::{Bitmap, BitmapItem}; +use xxhash_rust::xxh3; + +impl Server { + async fn build_access_token( + &self, + account: Account, + account_id: u32, + revision: u64, + revision_account: u64, + ) -> trc::Result { + match account { + Account::User(account) => { + let tenant_id = account.member_tenant_id.map(|t| t.id() as u32); + let permissions = self + .effective_permissions( + &account.permissions, + match &account.roles { + UserRoles::User => { + self.core.network.security.default_role_ids_user.as_slice() + } + UserRoles::Admin => { + if tenant_id.is_none() { + self.core.network.security.default_role_ids_admin.as_slice() + } else { + self.core + .network + .security + .default_role_ids_tenant + .as_slice() + } + } + UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(), + }, + tenant_id, + ) + .await?; + + let member_of = account + .member_group_ids + .iter() + .map(|m| m.id() as u32) + .collect::>(); + let mut access_to: Vec = Vec::new(); + for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) { + for acl_item in self + .store() + .acl_query(AclQuery::HasAccess { grant_account_id }) + .await + .caused_by(trc::location!())? + { + if acl_item.to_account_id != account_id + && !member_of.contains(&acl_item.to_account_id) + { + let acl = Bitmap::::from(acl_item.permissions); + let collection = acl_item.to_collection; + if !collection.is_valid() { + return Err(trc::StoreEvent::DataCorruption + .ctx(trc::Key::Reason, "Corrupted collection found in ACL key.") + .details(format!("{acl_item:?}")) + .account_id(grant_account_id) + .caused_by(trc::location!())); + } + + let mut collections: Bitmap = Bitmap::new(); + if acl.contains(Acl::Read) { + collections.insert(collection); + } + if acl.contains(Acl::ReadItems) + && let Some(child_col) = collection.child_collection() + { + collections.insert(child_col); + } + + if !collections.is_empty() { + if let Some(idx) = access_to + .iter() + .position(|a| a.account_id == acl_item.to_account_id) + { + access_to[idx].collections.union(&collections); + } else { + access_to.push(AccessTo { + account_id: acl_item.to_account_id, + collections, + }); + } + } + } + } + } + + let now = now(); + let mut credential_version = 0; + let mut credential_scopes = Vec::with_capacity(account.credentials.len()); + + credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX)); + + for credential in account.credentials { + match credential { + structs::Credential::Password(credential) => { + credential_version = xxh3::xxh3_64(credential.secret.as_bytes()).max(1); + + if credential.expires_at.is_some() || !credential.allowed_ips.is_empty() + { + let credential_scope = &mut credential_scopes[0]; + credential_scope.expires_at = credential + .expires_at + .map(|v| v.timestamp() as u64) + .unwrap_or(u64::MAX); + credential_scope.allowed_ips = + credential.allowed_ips.into_inner().into_boxed_slice(); + } + } + structs::Credential::ApiKey(credential) + | structs::Credential::AppPassword(credential) => { + let credential_id = credential.credential_id.document_id(); + let expires_at = credential + .expires_at + .map(|v| v.timestamp() as u64) + .unwrap_or(u64::MAX); + if expires_at > now { + let permissions = &credential_scopes[0].permissions; + let permissions = match credential.permissions { + structs::CredentialPermissions::Inherit => permissions.clone(), + structs::CredentialPermissions::Disable(list) => { + let mut permissions = permissions.clone(); + permissions.clear_many(&Permissions::from_permission( + list.permissions.as_slice(), + )); + permissions + } + structs::CredentialPermissions::Replace(list) => { + let mut replace_permissions = Permissions::from_permission( + list.permissions.as_slice(), + ); + replace_permissions.intersection(permissions); + replace_permissions + } + }; + credential_scopes.push(AccessScope { + credential_id, + permissions, + expires_at, + allowed_ips: credential + .allowed_ips + .into_inner() + .into_boxed_slice(), + }) + } + } + } + } + + Ok(AccessTokenInner { + concurrent_imap_requests: self + .core + .imap + .rate_concurrent + .map(ConcurrencyLimiter::new), + concurrent_http_requests: self + .core + .jmap + .request_max_concurrent + .map(ConcurrencyLimiter::new), + concurrent_uploads: self + .core + .jmap + .upload_max_concurrent + .map(ConcurrencyLimiter::new), + obj_size: 0, + revision, + revision_account, + credential_version, + account_id, + tenant_id, + member_of, + access_to: access_to.into_boxed_slice(), + scopes: [] + .into_iter() + .chain(credential_scopes) + .collect::>(), + } + .update_size()) + } + Account::Group(account) => { + let tenant_id = account.member_tenant_id.map(|t| t.id() as u32); + let permissions = self + .effective_permissions( + &account.permissions, + account.roles.role_ids().unwrap_or( + self.core.network.security.default_role_ids_group.as_slice(), + ), + tenant_id, + ) + .await?; + + Ok(AccessTokenInner { + concurrent_imap_requests: self + .core + .imap + .rate_concurrent + .map(ConcurrencyLimiter::new), + concurrent_http_requests: self + .core + .jmap + .request_max_concurrent + .map(ConcurrencyLimiter::new), + concurrent_uploads: self + .core + .jmap + .upload_max_concurrent + .map(ConcurrencyLimiter::new), + obj_size: 0, + revision, + revision_account, + credential_version: 0, + account_id, + tenant_id, + member_of: Default::default(), + access_to: Default::default(), + scopes: Box::new([AccessScope::new(permissions.finalize(), u32::MAX)]), + } + .update_size()) + } + } + } + + pub async fn access_token(&self, account_id: u32) -> trc::Result> { + match self + .inner + .cache + .access_tokens + .get_value_or_guard_async(&account_id) + .await + { + Ok(token) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "accessToken", + ); + + Ok(token) + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "accessToken", + ); + + let token: Arc = if let Some(account) = + self.registry().object::(account_id.into()).await? + { + let revision = rand::random::(); + let revision_account = hash_account(&account); + self.build_access_token(account, account_id, revision, revision_account) + .await? + .into() + } else if account_id == RECOVERY_ADMIN_ID { + AccessTokenInner::new_admin().into() + } else { + return Err(trc::SecurityEvent::Unauthorized + .into_err() + .details("Account not found") + .account_id(account_id) + .caused_by(trc::location!())); + }; + + let _ = guard.insert(token.clone()); + Ok(token) + } + } + } + + pub(crate) async fn access_token_from_account( + &self, + account_id: u32, + account: Account, + ) -> trc::Result> { + let revision_account = hash_account(&account); + match self + .inner + .cache + .access_tokens + .get_value_or_guard_async(&account_id) + .await + { + Ok(token) => { + if token.revision_account == revision_account { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "accessToken", + ); + + Ok(token) + } else { + // Token is stale, rebuild it + trc::event!( + Store(StoreEvent::CacheStale), + Key = account_id, + Collection = "accessToken", + ); + + debug_assert!( + false, + "Token is stale, invalidation should have been triggered" + ); + let revision = rand::random::(); + let token: Arc = self + .build_access_token(account, account_id, revision, revision_account) + .await? + .into(); + self.inner + .cache + .access_tokens + .update(account_id, token.clone()); + Ok(token) + } + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "accessToken", + ); + + let revision = rand::random::(); + let token: Arc = self + .build_access_token(account, account_id, revision, revision_account) + .await? + .into(); + let _ = guard.insert(token.clone()); + Ok(token) + } + } + } +} + +impl AccessToken { + pub fn new(inner: Arc, remote_ip: IpAddr) -> trc::Result { + AccessToken { + scope_idx: 0, + inner, + } + .assert_is_valid(remote_ip) + } + + pub fn new_maybe_invalid(inner: Arc) -> Self { + AccessToken { + scope_idx: 0, + inner, + } + } + + pub fn new_scoped( + inner: Arc, + credential_id: u32, + remote_ip: IpAddr, + ) -> trc::Result { + inner + .scopes + .iter() + .position(|scope| scope.credential_id == credential_id) + .ok_or_else(|| { + trc::SecurityEvent::Unauthorized + .into_err() + .ctx(trc::Key::AccountId, inner.account_id) + .ctx(trc::Key::Id, credential_id) + .reason("Credential expired or removed.") + }) + .map(|scope_idx| AccessToken { scope_idx, inner }) + .and_then(|token| token.assert_is_valid(remote_ip)) + } + + pub fn renew( + inner: Arc, + credential_id: Option, + remote_ip: IpAddr, + ) -> trc::Result { + if let Some(credential_id) = credential_id { + Self::new_scoped(inner, credential_id, remote_ip) + } else { + AccessToken { + scope_idx: 0, + inner, + } + .assert_is_valid(remote_ip) + } + } + + pub fn state(&self) -> u32 { + // Hash state + let mut s = AHasher::default(); + self.inner.member_of.hash(&mut s); + self.inner.access_to.hash(&mut s); + s.finish() as u32 + } + + #[inline(always)] + pub fn account_id(&self) -> u32 { + self.inner.account_id + } + + #[inline(always)] + pub fn tenant_id(&self) -> Option { + self.inner.tenant_id + } + + pub fn secondary_ids(&self) -> impl Iterator { + self.inner + .member_of + .iter() + .chain(self.inner.access_to.iter().map(|a| &a.account_id)) + } + + pub fn member_ids(&self) -> impl Iterator { + [self.inner.account_id] + .into_iter() + .chain(self.inner.member_of.iter().copied()) + } + + pub fn all_ids(&self) -> impl Iterator { + [self.inner.account_id] + .into_iter() + .chain(self.inner.member_of.iter().copied()) + .chain(self.inner.access_to.iter().map(|a| a.account_id)) + } + + pub fn all_ids_by_collection(&self, collection: Collection) -> impl Iterator { + [self.inner.account_id] + .into_iter() + .chain(self.inner.member_of.iter().copied()) + .chain(self.inner.access_to.iter().filter_map(move |a| { + if a.collections.contains(collection) { + Some(a.account_id) + } else { + None + } + })) + } + + pub fn is_member(&self, account_id: u32) -> bool { + self.inner.account_id == account_id + || self.inner.member_of.contains(&account_id) + || self.has_permission(Permission::Impersonate) + } + + pub fn is_account_id(&self, account_id: u32) -> bool { + self.inner.account_id == account_id + } + + pub fn personal_id(&self, account_id: u32, collection: Collection) -> u32 { + let child_collection = collection.child_collection(); + if self.is_account_id(account_id) + || self.inner.member_of.contains(&account_id) + || self.inner.access_to.iter().any(|a| { + a.account_id == account_id + && (a.collections.contains(collection) + || child_collection.is_some_and(|child| a.collections.contains(child))) + }) + { + self.inner.account_id + } else { + account_id + } + } + + #[inline(always)] + pub fn has_permission(&self, permission: Permission) -> bool { + self.inner + .scopes + .get(self.scope_idx) + .is_some_and(|scope| scope.permissions.get(permission as usize)) + } + + pub fn assert_is_valid(self, remote_ip: IpAddr) -> trc::Result { + if let Some(scope) = self.inner.scopes.get(self.scope_idx) { + let has_expired = scope.expires_at <= now(); + let is_valid_ip = scope.allowed_ips.is_empty() + || scope + .allowed_ips + .iter() + .any(|ip_mask| ip_mask.matches(&remote_ip)); + + let mut access_token = self; + if has_expired { + if access_token.scope_idx > 0 { + return Err(trc::AuthEvent::CredentialExpired + .into_err() + .ctx(trc::Key::AccountId, access_token.inner.account_id) + .reason("Credential expired.")); + } else { + trc::event!( + Auth(trc::AuthEvent::CredentialExpired), + AccountId = access_token.inner.account_id, + Reason = "Main credential expired, downgrading permissions.", + ); + } + + // Downgrade permissions to allow password change + let mut scopes = Vec::with_capacity(access_token.inner.scopes.len()); + for (idx, scope) in access_token.inner.scopes.iter().enumerate() { + if idx == 0 { + let mut permissions = Permissions::new(); + + for permission in [ + Permission::Authenticate, + Permission::AuthenticateWithAlias, + Permission::SysAccountPasswordGet, + Permission::SysAccountPasswordUpdate, + Permission::EmailReceive, + ] { + if scope.permissions.get(permission as usize) { + permissions.set(permission as usize); + } + } + + scopes.push(AccessScope { + permissions, + credential_id: scope.credential_id, + expires_at: u64::MAX, + allowed_ips: scope.allowed_ips.clone(), + }); + } else { + scopes.push(scope.clone()); + } + } + let old_inner = &access_token.inner; + let inner = AccessTokenInner { + scopes: scopes.into_boxed_slice(), + account_id: old_inner.account_id, + tenant_id: old_inner.tenant_id, + member_of: old_inner.member_of.clone(), + access_to: old_inner.access_to.clone(), + concurrent_http_requests: old_inner.concurrent_http_requests.clone(), + concurrent_imap_requests: old_inner.concurrent_imap_requests.clone(), + concurrent_uploads: old_inner.concurrent_uploads.clone(), + revision_account: old_inner.revision_account, + revision: old_inner.revision, + credential_version: old_inner.credential_version, + obj_size: old_inner.obj_size, + }; + + access_token = AccessToken { + scope_idx: access_token.scope_idx, + inner: Arc::new(inner), + }; + } + + if is_valid_ip { + Ok(access_token) + } else { + Err(trc::SecurityEvent::IpUnauthorized + .into_err() + .ctx(trc::Key::AccountId, access_token.inner.account_id) + .reason("IP address not allowed.")) + } + } else { + Err(trc::SecurityEvent::Unauthorized + .into_err() + .ctx(trc::Key::AccountId, self.inner.account_id) + .reason("Credential not valid.")) + } + } + + #[inline(always)] + pub fn credential_id(&self) -> Option { + self.inner + .scopes + .get(self.scope_idx) + .map(|scope| scope.credential_id) + } + + #[inline(always)] + pub fn revision(&self) -> u64 { + self.inner.revision + } + + pub fn assert_has_permissions(self, permissions: &[Permission]) -> trc::Result { + for permission in permissions { + if !self.has_permission(*permission) { + return Err(trc::SecurityEvent::Unauthorized + .into_err() + .details(permission.as_str()) + .account_id(self.account_id())); + } + } + + Ok(self) + } + + pub fn assert_has_permission(self, permission: Permission) -> trc::Result { + if self.has_permission(permission) { + Ok(self) + } else { + Err(trc::SecurityEvent::Unauthorized + .into_err() + .details(permission.as_str()) + .account_id(self.account_id())) + } + } + + pub fn enforce_permission(&self, permission: Permission) -> trc::Result<()> { + if self.has_permission(permission) { + Ok(()) + } else { + Err(trc::SecurityEvent::Unauthorized + .into_err() + .details(permission.as_str()) + .account_id(self.account_id())) + } + } + + pub fn permissions(&self) -> Vec { + if let Some(scope) = self.inner.scopes.get(self.scope_idx) { + scope.permissions.build_permissions_list() + } else { + vec![] + } + } + + #[inline(always)] + pub fn access_scope(&self) -> Option<&AccessScope> { + self.inner.scopes.get(self.scope_idx) + } + + pub(crate) fn permissions_bits(&self) -> &Permissions { + &self + .inner + .scopes + .get(self.scope_idx) + .unwrap_or(&self.inner.scopes[0]) + .permissions + } + + pub fn account_permissions(&self) -> &Permissions { + &self.inner.scopes[0].permissions + } + + pub fn is_shared(&self, account_id: u32) -> bool { + !self.is_member(account_id) + && self + .inner + .access_to + .iter() + .any(|a| a.account_id == account_id) + } + + pub fn shared_accounts(&self, collection: Collection) -> impl Iterator { + self.inner + .member_of + .iter() + .chain(self.inner.access_to.iter().filter_map(move |a| { + if a.collections.contains(collection) { + Some(&a.account_id) + } else { + None + } + })) + } + + pub fn has_access(&self, to_account_id: u32, to_collection: impl Into) -> bool { + let to_collection = to_collection.into(); + self.is_member(to_account_id) + || self + .inner + .access_to + .iter() + .any(|a| a.account_id == to_account_id && a.collections.contains(to_collection)) + } + + pub fn has_account_access(&self, to_account_id: u32) -> bool { + self.is_member(to_account_id) + || self + .inner + .access_to + .iter() + .any(|a| a.account_id == to_account_id) + } + + pub fn is_http_request_allowed(&self) -> LimiterResult { + self.inner + .concurrent_http_requests + .as_ref() + .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) + } + + pub fn concurrent_http_requests(&self) -> u64 { + self.inner + .concurrent_http_requests + .as_ref() + .map(|limiter| limiter.max_concurrent()) + .unwrap_or(0) + } + + pub fn is_imap_request_allowed(&self) -> LimiterResult { + self.inner + .concurrent_imap_requests + .as_ref() + .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) + } + + pub fn is_upload_allowed(&self) -> LimiterResult { + self.inner + .concurrent_uploads + .as_ref() + .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) + } + + pub fn concurrent_uploads(&self) -> u64 { + self.inner + .concurrent_uploads + .as_ref() + .map(|limiter| limiter.max_concurrent()) + .unwrap_or(0) + } + + pub fn account_tenant_ids(&self) -> AccountTenantIds { + AccountTenantIds { + account_id: self.account_id(), + tenant_id: self.tenant_id(), + } + } + + pub fn new_admin() -> AccessToken { + AccessToken { + scope_idx: 0, + inner: Arc::new(AccessTokenInner::new_admin()), + } + } + + pub fn from_permissions( + account_id: u32, + set_permissions: impl IntoIterator, + ) -> AccessToken { + let mut permissions = Permissions::new(); + for permission in set_permissions { + permissions.set(permission as usize); + } + AccessToken { + scope_idx: 0, + inner: Arc::new(AccessTokenInner { + account_id, + tenant_id: Default::default(), + member_of: Default::default(), + access_to: Default::default(), + scopes: Box::new([AccessScope::new(permissions, u32::MAX)]), + concurrent_http_requests: Default::default(), + concurrent_imap_requests: Default::default(), + concurrent_uploads: Default::default(), + revision: Default::default(), + revision_account: Default::default(), + credential_version: Default::default(), + obj_size: Default::default(), + }), + } + } + + pub fn from_id_maybe_invalid(account_id: u32) -> Self { + AccessToken::new_maybe_invalid(Arc::new(AccessTokenInner::from_id(account_id))) + } +} + +impl AccessTokenInner { + pub fn from_id(account_id: u32) -> Self { + Self { + account_id, + ..Default::default() + } + } + + pub fn with_tenant_id(mut self, tenant_id: Option) -> Self { + self.tenant_id = tenant_id; + self + } + + pub fn update_size(mut self) -> Self { + self.obj_size = (std::mem::size_of::() + + (self.member_of.len() * std::mem::size_of::()) + + (self.access_to.len() * (std::mem::size_of::() + std::mem::size_of::())) + + (self.scopes.len() * std::mem::size_of::())) + as u64; + self + } + + pub fn new_admin() -> Self { + AccessTokenInner { + account_id: RECOVERY_ADMIN_ID, + tenant_id: Default::default(), + member_of: Default::default(), + access_to: Default::default(), + scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]), + concurrent_http_requests: Default::default(), + concurrent_imap_requests: Default::default(), + concurrent_uploads: Default::default(), + revision: Default::default(), + revision_account: Default::default(), + credential_version: Default::default(), + obj_size: Default::default(), + } + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn revision_account(&self) -> u64 { + self.revision_account + } + + pub fn credential_version(&self) -> u64 { + self.credential_version + } +} + +impl AccessScope { + pub fn new(permissions: Permissions, credential_id: u32) -> Self { + Self { + permissions, + credential_id, + expires_at: u64::MAX, + allowed_ips: Default::default(), + } + } +} + +fn hash_account(account: &Account) -> u64 { + let mut s = AHasher::default(); + + match account { + Account::User(account) => { + account.member_tenant_id.hash(&mut s); + match &account.roles { + UserRoles::User => { + 0u8.hash(&mut s); + } + UserRoles::Admin => { + 1u8.hash(&mut s); + } + UserRoles::Custom(custom_roles) => { + 2u8.hash(&mut s); + custom_roles.role_ids.as_slice().hash(&mut s); + } + } + hash_permissions(&mut s, &account.permissions); + for credential in account + .credentials + .iter() + .filter_map(|credential| credential.as_secondary_credential()) + { + credential.credential_id.hash(&mut s); + credential.expires_at.hash(&mut s); + hash_credential_permissions(&mut s, &credential.permissions); + } + for group_id in account.member_group_ids.iter() { + group_id.hash(&mut s); + } + } + Account::Group(account) => { + account.member_tenant_id.hash(&mut s); + match &account.roles { + Roles::Default => {} + Roles::Custom(custom_roles) => { + custom_roles.role_ids.as_slice().hash(&mut s); + } + } + hash_permissions(&mut s, &account.permissions); + } + } + + s.finish() +} + +fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) { + match permissions { + structs::Permissions::Inherit => { + 0u8.hash(hasher); + } + structs::Permissions::Merge(permissions) => { + 2u8.hash(hasher); + permissions.enabled_permissions.as_slice().hash(hasher); + permissions.disabled_permissions.as_slice().hash(hasher); + } + structs::Permissions::Replace(permissions) => { + 3u8.hash(hasher); + permissions.enabled_permissions.as_slice().hash(hasher); + permissions.disabled_permissions.as_slice().hash(hasher); + } + } +} + +fn hash_credential_permissions(hasher: &mut AHasher, permissions: &structs::CredentialPermissions) { + match permissions { + structs::CredentialPermissions::Inherit => { + 0u8.hash(hasher); + } + structs::CredentialPermissions::Disable(permissions) => { + 2u8.hash(hasher); + permissions.permissions.as_slice().hash(hasher); + } + structs::CredentialPermissions::Replace(permissions) => { + 3u8.hash(hasher); + permissions.permissions.as_slice().hash(hasher); + } + } +} diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs new file mode 100644 index 0000000..d24568c --- /dev/null +++ b/crates/common/src/auth/authentication.rs @@ -0,0 +1,644 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::{ + AccessToken, AuthRequest, DomainCache, + credential::{ApiKey, AppPassword}, + oauth::GrantType, + }, +}; +use base64::{Engine, engine::general_purpose}; +use directory::{ + Credentials, Directory, Recipient, + core::secret::{SecretVerificationResult, verify_mfa_secret_hash, verify_secret_hash}, +}; +use registry::schema::{ + enums::Permission, + structs::{self, Credential}, +}; +use std::{net::IpAddr, sync::Arc}; +use store::write::now; +use trc::AddContext; + +pub struct UsernameParts { + pub account: Username, + pub master_user: Option, +} + +#[derive(PartialEq, Eq)] +pub struct Username { + pub name: String, + pub domain_start: usize, +} + +impl Server { + pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result { + match Box::pin(self.route_auth_request(req)) + .await + .and_then(|token| token.assert_has_permission(Permission::Authenticate)) + { + Ok(token) => Ok(token), + Err(err) => { + // Random delay to mitigate user enumeration attacks + #[cfg(not(feature = "test_mode"))] + { + use store::rand::{self, RngExt}; + + let delay = rand::rng().random_range(50..500); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + + if matches!( + err.as_ref(), + trc::EventType::Auth(trc::AuthEvent::Failed) + | trc::EventType::Security(trc::SecurityEvent::IpUnauthorized) + ) && self.has_auth_fail2ban() + && self + .is_auth_fail2banned(req.remote_ip, req.username()) + .await? + { + Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .ctx(trc::Key::RemoteIp, req.remote_ip) + .ctx_opt(trc::Key::AccountName, req.username().map(|s| s.to_string()))) + } else { + Err(err.ctx(trc::Key::RemoteIp, req.remote_ip)) + } + } + } + } + + async fn route_auth_request(&self, req: &AuthRequest) -> trc::Result { + match &req.credentials { + Credentials::Basic { + username, + secret, + mfa_token, + } => { + let mut username = UsernameParts::new(username); + + // Try to authenticate as fallback admin if configured + if let Some((fallback_user, fallback_hash)) = &self.registry().recovery_admin() + && username.auth_as().address() == fallback_user + { + return if verify_secret_hash(fallback_hash, secret.as_bytes()).await? { + if username.is_master() { + let address = username.account().address(); + if let Some(account_id) = + self.impersonated_account_id(username.account()).await? + { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = address.to_string(), + AccountId = account_id, + SpanId = req.session_id, + Details = fallback_user.to_string(), + ); + + self.access_token(account_id) + .await + .and_then(|token| AccessToken::new(token, req.remote_ip)) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .reason("Master user account not found for fallback admin authentication")) + } + } else { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = fallback_user.to_string(), + SpanId = req.session_id, + ); + + Ok(AccessToken::new_admin()) + } + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, fallback_user.to_string()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Fallback admin authentication failed")) + }; + } + + // Add domain if missing, use the default domain + self.add_missing_domain(&mut username.account); + if let Some(master_user) = &mut username.master_user { + self.add_missing_domain(master_user); + } + + // Obtain domain + let auth_as = username.auth_as(); + let auth_as_address = auth_as.address(); + let auth_as_local = auth_as.local(); + let auth_as_domain = auth_as.domain().unwrap(); + let domain = self.resolve_domain(auth_as_domain).await?; + + // Authenticate app passwords + if let Some(app_pass) = AppPassword::parse(secret) { + if username.is_master() { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("App passwords cannot be used for impersonation")); + } + return if let Some(account_id) = + self.account_id_from_parts(auth_as_local, domain.id).await? + { + self.validate_credential( + account_id, + app_pass.credential_id, + app_pass.secret.as_ref(), + req.remote_ip, + req.session_id, + ) + .await + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .reason("App password authentication failed: account not found")) + }; + } + + // Obtain external directory, if any + let mut is_alias_login = false; + let token = if let Some(directory) = self.get_directory_for_cached_domain(&domain) { + let directory_account = if username.is_master() { + directory + .authenticate(&Credentials::Basic { + username: auth_as_address.to_string(), + secret: secret.clone(), + mfa_token: mfa_token.clone(), + }) + .await? + } else { + directory.authenticate(&req.credentials).await? + }; + + is_alias_login = directory_account.email != auth_as_address; + self.build_directory_token(directory_account, req.remote_ip) + .await + } else if let Some(account_id) = + self.account_id_from_parts(auth_as_local, domain.id).await? + { + if let Some(account) = self + .registry() + .object::(account_id.into()) + .await? + .and_then(|account| account.into_user()) + { + let Some(credential) = account.password_credential() else { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Password credential not found for account")); + }; + + match verify_mfa_secret_hash( + credential.otp_auth.as_deref(), + mfa_token.as_deref(), + credential.secret.as_str(), + secret, + ) + .await? + { + SecretVerificationResult::Valid => { + is_alias_login = account.name != auth_as_local; + self.access_token(account_id) + .await + .and_then(|token| AccessToken::new(token, req.remote_ip)) + } + SecretVerificationResult::Invalid => Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Authentication failed")), + SecretVerificationResult::MissingMfaToken => { + Err(trc::AuthEvent::MfaRequired + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, req.session_id) + .reason("MFA token required")) + } + } + } else { + Err(trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, account_id) + .reason("Account not found in registry")) + } + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .reason("Account not found")) + }?; + + // Enforce alias login restrictions + if is_alias_login && !token.has_permission(Permission::AuthenticateWithAlias) { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, auth_as_address.to_string()) + .ctx(trc::Key::AccountId, token.account_id()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); + } + + // Validate master user access + if username.is_master() { + token.assert_has_permissions(&[ + Permission::Impersonate, + Permission::Authenticate, + ])?; + let address = username.account().address(); + let master_address = auth_as_address; + if let Some(account_id) = + self.impersonated_account_id(username.account()).await? + { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = address.to_string(), + AccountId = account_id, + SpanId = req.session_id, + Details = master_address.to_string(), + ); + + self.access_token(account_id) + .await + .map(AccessToken::new_maybe_invalid) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .details(master_address.to_string()) + .reason("Master user account not found")) + } + } else { + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = auth_as_address.to_string(), + AccountId = token.account_id(), + SpanId = req.session_id, + ); + + Ok(token) + } + } + Credentials::Bearer { username, token } => { + // Handle API key authentication + if let Some(key) = ApiKey::parse(token) { + return self + .validate_credential( + key.account_id, + key.credential_id, + key.secret.as_ref(), + req.remote_ip, + req.session_id, + ) + .await; + } + + #[cfg(feature = "dev_mode")] + if std::env::var("API_TOKEN_ADMIN").is_ok_and(|admin_token| &admin_token == token) { + return Ok(AccessToken::new_admin()); + } + + // Obtain external directory, if any. When no username is supplied + // (e.g. HTTP bearer auth), peek at the JWT claims to find the + // user's domain so per-domain OIDC directories are reachable. + let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) + { + if let Some(domain_name) = username.auth_as().domain() { + self.get_directory_for_domain(domain_name).await? + } else if let Some(domain_name) = extract_jwt_domain(token) { + self.get_directory_for_domain(&domain_name).await? + } else { + self.get_default_directory() + } + } else if let Some(domain_name) = extract_jwt_domain(token) { + self.get_directory_for_domain(&domain_name).await? + } else { + self.get_default_directory() + }; + + // Try external directory authentication first if supported, then fallback to internal OAuth. + let mut external_error = None; + if let Some(directory) = directory + && directory.has_bearer_token_support() + { + match directory.authenticate(&req.credentials).await { + Ok(result) => { + return self.build_directory_token(result, req.remote_ip).await; + } + Err(err) => { + external_error = Some(err); + } + } + } + + // Internal OAuth + match self + .validate_access_token(GrantType::AccessToken.into(), token) + .await + { + Ok(token_info) => self + .access_token(token_info.account_id) + .await + .and_then(|token| AccessToken::new(token, req.remote_ip)), + Err(err) => { + if let Some(external_error) = external_error { + Err(external_error) + } else { + Err(err) + } + } + } + } + } + } + + async fn impersonated_account_id(&self, username: &Username) -> trc::Result> { + let address = username.address(); + + if let Some(account_id) = self.account_id_from_email(address, false).await? { + return Ok(Some(account_id)); + } + + if let Some(domain) = username.domain() + && let Some(domain_cache) = self.domain(domain).await? + && let Some(directory) = self.get_directory_for_cached_domain(&domain_cache) + && let Recipient::Account(account) = directory.recipient(address).await? + { + return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id)); + } + + Ok(None) + } + + async fn validate_credential( + &self, + account_id: u32, + credential_id: u32, + secret: &[u8], + remote_ip: IpAddr, + span_id: u64, + ) -> trc::Result { + if let Some(account) = self + .registry() + .object::(account_id.into()) + .await? + .and_then(|account| account.into_user()) + { + // Find credential by credential_id + let mut authenticated = false; + for (credential, credential_type) in + account.credentials.iter().filter_map(|credential| { + credential + .as_secondary_credential() + .map(|secondary_credential| (secondary_credential, credential)) + }) + { + if credential.credential_id.document_id() == credential_id { + if !verify_secret_hash(&credential.secret, secret).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account.name) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Invalid credential secret")); + } + + if credential + .expires_at + .as_ref() + .is_some_and(|exp| exp.timestamp() < now() as i64) + { + return Err(trc::AuthEvent::CredentialExpired + .into_err() + .ctx(trc::Key::AccountName, account.name) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential has expired")); + } + + trc::event!( + Auth(trc::AuthEvent::Success), + AccountName = account.name.clone(), + AccountId = account_id, + Id = credential_id, + SpanId = span_id, + Details = match credential_type { + Credential::AppPassword(_) => "Authenticated with app password", + Credential::ApiKey(_) => "Authenticated with API key", + _ => "Authenticated with credential", + } + ); + + authenticated = true; + break; + } + } + + if authenticated { + let token = self + .access_token_from_account(account_id, structs::Account::User(account)) + .await?; + + AccessToken::new_scoped(token, credential_id, remote_ip) + .add_context(|ctx| ctx.span_id(span_id)) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential not found for account")) + } + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Account not found for credential")) + } + } + + async fn resolve_domain(&self, domain_name: &str) -> trc::Result> { + if let Some(domain) = self.domain(domain_name).await? { + Ok(domain) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::Details, domain_name.to_string()) + .reason("Domain not found")) + } + } + + fn add_missing_domain(&self, address: &mut Username) { + if address.domain().is_none() { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = address.address().to_string(), + Reason = "No domain in username", + ); + address.domain_start = address.name.len() + 1; + address.name = format!("{}@{}", address.name, self.core.email.default_domain_name); + } + } + + async fn build_directory_token( + &self, + account: directory::Account, + remote_ip: IpAddr, + ) -> trc::Result { + let account = Box::pin(self.synchronize_account(account)).await?; + self.access_token_from_account(account.id, account.account) + .await + .and_then(|token| AccessToken::new(token, remote_ip)) + } + + pub async fn get_directory_for_domain( + &self, + domain_name: &str, + ) -> trc::Result>> { + + Ok(self.get_default_directory()) + } + + pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc> { + + self.get_default_directory() + } +} + +fn extract_jwt_domain(token: &str) -> Option { + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + let _signature = parts.next()?; + if parts.next().is_some() { + return None; + } + let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?; + let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + for claim in ["email", "preferred_username", "upn"] { + if let Some(val) = claims.get(claim).and_then(|v| v.as_str()) + && let Some((_, domain)) = val.rsplit_once('@') + && !domain.is_empty() + { + return Some(domain.to_ascii_lowercase()); + } + } + None +} + +impl UsernameParts { + pub fn new(address: &str) -> Self { + let mut account = Username { + name: String::with_capacity(address.len()), + domain_start: usize::MAX, + }; + let mut master_user = None; + + for ch in address.chars() { + if ch == '%' { + master_user = Some(Username { + name: String::with_capacity(address.len()), + domain_start: usize::MAX, + }); + } else { + let target = master_user.as_mut().unwrap_or(&mut account); + if ch != '@' { + for lower in ch.to_lowercase() { + target.name.push(lower); + } + } else { + target.name.push(ch); + target.domain_start = target.name.len(); + } + } + } + + UsernameParts { + master_user: master_user.filter(|u| u != &account), + account, + } + } + + pub fn auth_as(&self) -> &Username { + self.master_user.as_ref().unwrap_or(&self.account) + } + + pub fn account(&self) -> &Username { + &self.account + } + + pub fn is_master(&self) -> bool { + self.master_user.is_some() + } +} + +impl Username { + pub fn address(&self) -> &str { + self.name.as_str() + } + + pub fn local(&self) -> &str { + self.name + .get(..self.domain_start.saturating_sub(1)) + .unwrap_or_default() + } + + pub fn domain(&self) -> Option<&str> { + self.name.get(self.domain_start..) + } +} + +impl AuthRequest { + pub fn from_credentials(credentials: Credentials, session_id: u64, remote_ip: IpAddr) -> Self { + Self { + credentials, + session_id, + remote_ip, + } + } + + pub fn from_plain( + user: impl Into, + pass: impl Into, + session_id: u64, + remote_ip: IpAddr, + ) -> Self { + Self::from_credentials( + Credentials::Basic { + username: user.into(), + secret: pass.into(), + mfa_token: None, + }, + session_id, + remote_ip, + ) + } + + pub fn username(&self) -> Option<&str> { + match &self.credentials { + Credentials::Basic { username, .. } => Some(username.as_str()), + Credentials::Bearer { username, .. } => username.as_deref(), + } + } +} diff --git a/crates/common/src/auth/credential.rs b/crates/common/src/auth/credential.rs new file mode 100644 index 0000000..33123f4 --- /dev/null +++ b/crates/common/src/auth/credential.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use std::io::Write; +use store::{ + U32_LEN, + rand::{self}, +}; +use utils::codec::base32_custom::{Base32Reader, Base32Writer}; + +pub struct ApiKey { + pub account_id: u32, + pub credential_id: u32, + pub secret: [u8; 20], +} + +pub struct AppPassword { + pub credential_id: u32, + pub secret: [u8; 18], +} + +impl ApiKey { + pub fn new(account_id: u32, credential_id: u32) -> Self { + ApiKey { + account_id, + credential_id, + secret: rand::random::<[u8; 20]>(), + } + } + + pub fn parse(token: &str) -> Option { + let decoded = URL_SAFE_NO_PAD.decode(token.strip_prefix("API_")?).ok()?; + + Some(ApiKey { + account_id: u32::from_be_bytes(decoded.get(0..U32_LEN)?.try_into().ok()?), + credential_id: u32::from_be_bytes(decoded.get(U32_LEN..U32_LEN * 2)?.try_into().ok()?), + secret: decoded.get(U32_LEN * 2..)?.try_into().ok()?, + }) + } + + pub fn build(&self) -> String { + let mut bytes = Vec::with_capacity(U32_LEN * 2 + self.secret.len()); + bytes.extend_from_slice(&self.account_id.to_be_bytes()); + bytes.extend_from_slice(&self.credential_id.to_be_bytes()); + bytes.extend_from_slice(&self.secret); + format!("API_{}", URL_SAFE_NO_PAD.encode(bytes)) + } +} + +impl AppPassword { + pub fn new(credential_id: u32) -> Self { + AppPassword { + credential_id, + secret: rand::random::<[u8; 18]>(), + } + } + + pub fn parse(token: &str) -> Option { + let token = token.strip_prefix("app")?; + let mut reader = Base32Reader::new(token.as_bytes().get(1..)?); + let mut credential_id = [0u8; 4]; + let mut secret = [0u8; 18]; + + for byte in credential_id.iter_mut() { + *byte = reader.next()?; + } + + for byte in secret.iter_mut() { + *byte = reader.next()?; + } + + if reader.next().is_none() { + Some(AppPassword { + credential_id: u32::from_be_bytes(credential_id), + secret, + }) + } else { + None + } + } + + pub fn build(&self) -> String { + let mut writer = Base32Writer::with_capacity(std::mem::size_of::().div_ceil(5) * 8); + writer.push_string("app_"); + let _ = writer.write(&self.credential_id.to_be_bytes()); + let _ = writer.write_all(&self.secret); + writer.finalize() + } +} diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs new file mode 100644 index 0000000..c4e9870 --- /dev/null +++ b/crates/common/src/auth/mod.rs @@ -0,0 +1,332 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + expr::if_block::IfBlock, + network::limiter::ConcurrencyLimiter, + storage::{ObjectQuota, TenantQuota}, +}; +use directory::Credentials; +use quick_cache::Equivalent; +use registry::{ + schema::enums::{Locale, Permission}, + types::{EnumImpl, ipmask::IpAddrOrMask}, +}; +use std::{ + hash::{Hash, Hasher}, + net::IpAddr, + sync::Arc, +}; +use tinyvec::TinyVec; +use trc::ipc::bitset::Bitset; +use types::collection::Collection; +use utils::{cache::CacheItemWeight, map::bitmap::Bitmap}; + +pub mod access_token; +pub mod authentication; +pub mod credential; +pub mod oauth; +pub mod permissions; +pub mod rate_limit; + +pub const RECOVERY_ADMIN_ID: u32 = u32::MAX; +const PERMISSIONS_BITSET_SIZE: usize = Permission::COUNT.div_ceil(std::mem::size_of::()); +pub type Permissions = Bitset; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct EmailAddress { + pub local_part: Box, + pub domain_id: u32, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct EmailAddressRef<'x> { + local_part: &'x str, + domain_id: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmailCache { + Account(u32), + MailingList(u32), + DisabledAccountAddress(u32), + DisabledListAddress(u32), +} + +#[derive(Debug, Clone)] +pub struct DomainCache { + pub names: Box<[Box]>, + pub id: u32, + pub id_directory: Option, + pub id_tenant: Option, + pub catch_all: Option>, + pub sub_addressing_custom: Option>, + pub flags: u8, +} + +pub const DOMAIN_FLAG_RELAY: u8 = 1; +pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1; + + +#[derive(Debug, Clone, Default)] +pub struct AccountCache { + pub name: Box, + pub id: u32, + pub addresses: Box<[EmailAddress]>, + pub id_tenant: Option, + pub id_member_of: TinyVec<[u32; 3]>, + pub quota_disk: u64, + pub quota_objects: Option>, + pub description: Option>, + pub encryption_key: Option, + pub locale: Locale, + pub flags: u64, +} + +pub type EncryptionKeys = Box<[Box<[u8]>]>; + +pub const ACCOUNT_IS_USER: u64 = 1; +pub const ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER: u64 = 1 << 1; +pub const ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME: u64 = 1 << 2; +pub const ACCOUNT_FLAG_ENCRYPT_METHOD_PGP: u64 = 1 << 3; +pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256: u64 = 1 << 4; +pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES128: u64 = 1 << 5; +pub const ACCOUNT_FLAG_ENCRYPT_APPEND: u64 = 1 << 6; +pub const ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM: u64 = 1 << 7; +pub const ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305: u64 = 1 << 8; + +#[derive(Debug, Clone)] +pub struct RoleCache { + pub id_roles: TinyVec<[u32; 3]>, + pub permissions: PermissionsGroup, +} + +#[derive(Debug, Clone)] +pub struct MailingListCache { + pub addresses: Box<[EmailAddress]>, + pub recipients: Arc<[Box]>, +} + +#[derive(Debug, Clone)] +pub struct TenantCache { + pub id_roles: TinyVec<[u32; 3]>, + pub quota_disk: u64, + pub quota_objects: Option>, + pub permissions: Option>, +} + +#[derive(Debug, Clone, Default)] +pub struct PermissionsGroup { + pub enabled: Permissions, + pub disabled: Permissions, + pub merge: bool, +} + +#[derive(Debug, Default, Clone)] +pub struct AccessToken { + scope_idx: usize, + inner: Arc, +} + +#[derive(Debug, Default, Clone)] +pub struct AccessTokenInner { + pub(crate) account_id: u32, + pub(crate) tenant_id: Option, + pub(crate) member_of: TinyVec<[u32; 3]>, + pub(crate) access_to: Box<[AccessTo]>, + pub(crate) scopes: Box<[AccessScope]>, + pub(crate) concurrent_http_requests: Option, + pub(crate) concurrent_imap_requests: Option, + pub(crate) concurrent_uploads: Option, + pub(crate) revision_account: u64, + pub(crate) revision: u64, + pub(crate) credential_version: u64, + pub(crate) obj_size: u64, +} + +#[derive(Debug, Default, Hash, Clone)] +pub struct AccessScope { + pub permissions: Permissions, + pub credential_id: u32, + pub expires_at: u64, + pub allowed_ips: Box<[IpAddrOrMask]>, +} + +#[derive(Debug, Default, Hash, PartialEq, Eq, Clone)] +pub(crate) struct AccessTo { + pub account_id: u32, + pub collections: Bitmap, +} + +#[derive(Clone)] +pub struct AccountInfo { + pub account_id: u32, + pub account: Arc, + pub addresses: Vec, +} + +#[derive(Clone, Copy)] +pub struct AccountTenantIds { + pub account_id: u32, + pub tenant_id: Option, +} + +pub struct AuthRequest { + pub credentials: Credentials, + pub session_id: u64, + pub remote_ip: IpAddr, +} + +impl CacheItemWeight for AccessTokenInner { + fn weight(&self) -> u64 { + self.obj_size + } +} + +impl CacheItemWeight for EmailAddress { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + self.local_part.len() as u64 + } +} + +impl CacheItemWeight for EmailCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for DomainCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + + self + .names + .iter() + .map(|s| s.len() as u64 + std::mem::size_of::>() as u64) + .sum::() + + self.catch_all.as_ref().map_or(0, |s| s.len() as u64) + + self + .sub_addressing_custom + .as_ref() + .map_or(0, |s| s.weight()) + } +} + +impl Equivalent for EmailAddressRef<'_> { + fn equivalent(&self, key: &EmailAddress) -> bool { + self.local_part == &*key.local_part && self.domain_id == key.domain_id + } +} + +impl Hash for EmailAddress { + fn hash(&self, state: &mut H) { + self.local_part.as_ref().hash(state); + self.domain_id.hash(state); + } +} + +impl Hash for EmailAddressRef<'_> { + fn hash(&self, state: &mut H) { + self.local_part.hash(state); + self.domain_id.hash(state); + } +} + +impl CacheItemWeight for AccountCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + + self.name.len() as u64 + + self + .addresses + .iter() + .map(|s| s.local_part.len() as u64 + std::mem::size_of::() as u64) + .sum::() + + self.description.as_ref().map_or(0, |s| s.len() as u64) + + self.encryption_key.as_ref().map_or(0, |keys| { + keys.iter() + .map(|k| k.len() as u64 + std::mem::size_of::>() as u64) + .sum::() + }) + } +} + +impl CacheItemWeight for RoleCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for MailingListCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + + self + .addresses + .iter() + .map(|s| s.local_part.len() as u64 + std::mem::size_of::() as u64) + .sum::() + + self + .recipients + .iter() + .map(|s| s.len() as u64 + std::mem::size_of::>() as u64) + .sum::() + } +} + +impl CacheItemWeight for TenantCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + + self.permissions.as_ref().map_or(0, |p| p.weight()) + } +} + +impl CacheItemWeight for PermissionsGroup { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +pub trait BuildAccessToken { + fn build(self) -> AccessToken; +} + +impl BuildAccessToken for Arc { + fn build(self) -> AccessToken { + AccessToken { + scope_idx: 0, + inner: self, + } + } +} + +impl EmailAddress { + pub fn new(local_part: impl Into>, domain_id: u32) -> Self { + Self { + local_part: local_part.into(), + domain_id, + } + } +} + +impl<'x> EmailAddressRef<'x> { + pub fn new(local_part: &'x str, domain_id: u32) -> Self { + Self { + local_part, + domain_id, + } + } +} + +impl AccountCache { + pub fn domain_id(&self) -> Option { + self.addresses.first().map(|address| address.domain_id) + } +} + +impl DomainCache { + pub fn name(&self) -> &str { + self.names.first().map(|s| s.as_ref()).unwrap_or_default() + } + +} diff --git a/crates/common/src/auth/oauth/client_id.rs b/crates/common/src/auth/oauth/client_id.rs new file mode 100644 index 0000000..525fab3 --- /dev/null +++ b/crates/common/src/auth/oauth/client_id.rs @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + SCOPE_CALENDARS, SCOPE_CONTACTS, SCOPE_MAIL, SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, + crypto::SymmetricEncrypt, +}; +use base64::{Engine, engine::general_purpose}; +use store::blake3; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; + +const CLIENT_ID_HEADER: &str = "swc1."; +const CLIENT_ID_KEY_CONTEXT: &str = "stalwart-oauth-client-id-sw1"; +const CLIENT_ID_VERSION: u8 = 1; + +const SCOPE_BITS: &[&str] = &[ + SCOPE_OPENID, + SCOPE_OFFLINE_ACCESS, + SCOPE_MAIL, + SCOPE_CONTACTS, + SCOPE_CALENDARS, +]; + +pub fn scopes_to_mask(scope: &str) -> u64 { + let mut mask = 0u64; + for scope in scope.split_ascii_whitespace() { + if let Some(bit) = SCOPE_BITS.iter().position(|known| *known == scope) { + mask |= 1 << bit; + } + } + mask +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ClientMeta { + pub redirect_uris: Vec, + pub scope_mask: u64, + pub client_name: Option, +} + +pub fn encode_client_id(key: &[u8], meta: &ClientMeta) -> Result { + let client_name = meta.client_name.as_deref().unwrap_or_default(); + + let mut payload = Vec::with_capacity( + 24 + meta + .redirect_uris + .iter() + .map(|u| u.len() + 2) + .sum::() + + client_name.len(), + ); + payload.push(CLIENT_ID_VERSION); + payload.push_leb128(meta.redirect_uris.len()); + for uri in &meta.redirect_uris { + payload.push_leb128(uri.len()); + payload.extend_from_slice(uri.as_bytes()); + } + payload.push_leb128(meta.scope_mask); + payload.push_leb128(client_name.len()); + payload.extend_from_slice(client_name.as_bytes()); + + let digest = blake3::hash(&payload); + let nonce = &digest.as_bytes()[..SymmetricEncrypt::NONCE_LEN]; + let ciphertext = + SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT).encrypt_with_aad(&payload, nonce, &[])?; + + let mut body = Vec::with_capacity(nonce.len() + ciphertext.len()); + body.extend_from_slice(nonce); + body.extend_from_slice(&ciphertext); + + let mut out = String::with_capacity(CLIENT_ID_HEADER.len() + body.len().div_ceil(3) * 4); + out.push_str(CLIENT_ID_HEADER); + general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out); + + Ok(out) +} + +pub fn decode_client_id(key: &[u8], client_id: &str) -> Option { + let body = general_purpose::URL_SAFE_NO_PAD + .decode(client_id.strip_prefix(CLIENT_ID_HEADER)?.as_bytes()) + .ok()?; + if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN { + return None; + } + let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN); + let payload = SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT) + .decrypt_with_aad(ciphertext, nonce, &[]) + .ok()?; + + let mut bytes = payload.iter(); + if bytes.next().copied()? != CLIENT_ID_VERSION { + return None; + } + + let uri_count: usize = bytes.next_leb128()?; + if uri_count > u8::MAX as usize { + return None; + } + let mut redirect_uris = Vec::with_capacity(uri_count); + for _ in 0..uri_count { + redirect_uris.push(take_string(&mut bytes)?); + } + let scope_mask: u64 = bytes.next_leb128()?; + let client_name = take_string(&mut bytes)?; + + Some(ClientMeta { + redirect_uris, + scope_mask, + client_name: (!client_name.is_empty()).then_some(client_name), + }) +} + +fn take_string(bytes: &mut std::slice::Iter<'_, u8>) -> Option { + let len: usize = bytes.next_leb128()?; + let slice = bytes.as_slice(); + if slice.len() < len { + return None; + } + let value = String::from_utf8(slice[..len].to_vec()).ok()?; + if len > 0 { + bytes.nth(len - 1)?; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"a-test-encryption-key-of-some-length"; + + fn sample() -> ClientMeta { + ClientMeta { + redirect_uris: vec![ + "http://127.0.0.1/cb".to_string(), + "com.example.app:/oauth".to_string(), + ], + scope_mask: scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")), + client_name: Some("Example Client".to_string()), + } + } + + #[test] + fn round_trip_preserves_all_fields() { + for meta in [ + sample(), + ClientMeta { + redirect_uris: vec!["http://[::1]/".to_string()], + scope_mask: 0, + client_name: None, + }, + ClientMeta::default(), + ] { + let client_id = encode_client_id(KEY, &meta).unwrap(); + assert!(client_id.starts_with(CLIENT_ID_HEADER)); + assert_eq!(decode_client_id(KEY, &client_id), Some(meta)); + } + } + + #[test] + fn scope_mask_is_order_independent_and_drops_unknown() { + assert_eq!( + scopes_to_mask(&format!("{SCOPE_MAIL} {SCOPE_OFFLINE_ACCESS}")), + scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")) + ); + assert_eq!( + scopes_to_mask(&format!("{SCOPE_MAIL} custom:unknown")), + scopes_to_mask(SCOPE_MAIL) + ); + assert_eq!(scopes_to_mask("totally unknown"), 0); + } + + #[test] + fn identical_input_is_deterministic() { + let meta = sample(); + assert_eq!( + encode_client_id(KEY, &meta).unwrap(), + encode_client_id(KEY, &meta).unwrap() + ); + } + + #[test] + fn wrong_key_is_rejected() { + let client_id = encode_client_id(KEY, &sample()).unwrap(); + assert_eq!( + decode_client_id(b"a-completely-different-key-value!", &client_id), + None + ); + } + + #[test] + fn tampering_is_rejected() { + let client_id = encode_client_id(KEY, &sample()).unwrap(); + let (header, body_b64) = client_id.split_at(CLIENT_ID_HEADER.len()); + let mut body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap(); + for idx in 0..body.len() { + let mut tampered = body.clone(); + tampered[idx] ^= 0x01; + let forged = format!( + "{header}{}", + general_purpose::URL_SAFE_NO_PAD.encode(&tampered) + ); + assert_eq!(decode_client_id(KEY, &forged), None, "byte {idx}"); + } + body[0] ^= 0x00; + assert!(decode_client_id(KEY, &client_id).is_some()); + } + + #[test] + fn malformed_input_never_panics() { + for case in [ + "", + "swc1.", + "swc1.!!!", + "swc1.AAAA", + "wrong.AAAA", + "swc1.AAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ] { + assert_eq!(decode_client_id(KEY, case), None, "{case:?}"); + } + } +} diff --git a/crates/common/src/auth/oauth/config.rs b/crates/common/src/auth/oauth/config.rs new file mode 100644 index 0000000..598d4c9 --- /dev/null +++ b/crates/common/src/auth/oauth/config.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + config::{EcKeyCurve, build_ecdsa_pem, build_rsa_keypair}, + manager::application::Resource, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use jsonwebtoken::{ + Algorithm, EncodingKey, + jwk::{ + AlgorithmParameters, CommonParameters, EllipticCurve, EllipticCurveKeyParameters, + EllipticCurveKeyType, Jwk, JwkSet, KeyAlgorithm, OctetKeyParameters, OctetKeyType, + PublicKeyUse, RSAKeyParameters, RSAKeyType, + }, +}; +use registry::schema::{enums::JwtSignatureAlgorithm, prelude::ObjectType, structs::OidcProvider}; +use std::borrow::Cow; +use store::{ + rand::{RngExt, distr::Alphanumeric, rng}, + registry::bootstrap::Bootstrap, +}; + +#[derive(Clone)] +pub struct OAuthConfig { + pub oauth_key: String, + pub oauth_expiry_user_code: u64, + pub oauth_expiry_auth_code: u64, + pub oauth_expiry_token: u64, + pub oauth_expiry_refresh_token: u64, + pub oauth_expiry_refresh_token_renew: u64, + pub oauth_max_auth_attempts: u32, + + pub allow_anonymous_client_registration: bool, + pub require_client_authentication: bool, + + pub oidc_expiry_id_token: u64, + pub oidc_signing_secret: EncodingKey, + pub oidc_signature_algorithm: Algorithm, + pub oidc_jwks: Resource>, +} + +impl OAuthConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let auth = bp.setting_infallible::().await; + + let oidc_signature_algorithm = match auth.signature_algorithm { + JwtSignatureAlgorithm::Es256 => Algorithm::ES256, + JwtSignatureAlgorithm::Es384 => Algorithm::ES384, + JwtSignatureAlgorithm::Ps256 => Algorithm::PS256, + JwtSignatureAlgorithm::Ps384 => Algorithm::PS384, + JwtSignatureAlgorithm::Ps512 => Algorithm::PS512, + JwtSignatureAlgorithm::Rs256 => Algorithm::RS256, + JwtSignatureAlgorithm::Rs384 => Algorithm::RS384, + JwtSignatureAlgorithm::Rs512 => Algorithm::RS512, + JwtSignatureAlgorithm::Hs256 => Algorithm::HS256, + JwtSignatureAlgorithm::Hs384 => Algorithm::HS384, + JwtSignatureAlgorithm::Hs512 => Algorithm::HS512, + }; + + let rand_key = rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::(); + + let signature_key = auth + .signature_key + .secret() + .await + .map_err(|err| { + bp.build_error(ObjectType::OidcProvider.singleton(), err); + }) + .unwrap_or(Cow::Borrowed(rand_key.as_str())); + + let fallback_key = || { + ( + EncodingKey::from_secret(rand_key.as_bytes()), + AlgorithmParameters::OctetKey(OctetKeyParameters { + key_type: OctetKeyType::Octet, + value: URL_SAFE_NO_PAD.encode(&rand_key), + }) + .into(), + ) + }; + + let (oidc_signing_secret, algorithm) = match oidc_signature_algorithm { + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => { + (EncodingKey::from_secret(signature_key.as_bytes()), None) + } + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 => parse_rsa_key(&auth) + .await + .map_err(|err| { + bp.build_error(ObjectType::OidcProvider.singleton(), err); + }) + .map(|(secret, alg)| (secret, Some(alg))) + .unwrap_or_else(|_| fallback_key()), + Algorithm::ES256 | Algorithm::ES384 => parse_ecdsa_key(&auth, oidc_signature_algorithm) + .await + .map_err(|err| { + bp.build_error(ObjectType::OidcProvider.singleton(), err); + }) + .map(|(secret, alg)| (secret, Some(alg))) + .unwrap_or_else(|_| fallback_key()), + _ => { + bp.build_error( + ObjectType::OidcProvider.singleton(), + format!("Unsupported OIDC signature algorithm {oidc_signature_algorithm:?}"), + ); + fallback_key() + } + }; + + let oidc_jwks = Resource { + content_type: "application/json".into(), + contents: serde_json::to_string(&JwkSet { + keys: algorithm + .into_iter() + .map(|algorithm| Jwk { + common: CommonParameters { + public_key_use: PublicKeyUse::Signature.into(), + key_algorithm: KeyAlgorithm::from(oidc_signature_algorithm).into(), + key_id: "default".to_string().into(), + ..Default::default() + }, + algorithm, + }) + .collect(), + }) + .unwrap_or_default() + .into_bytes(), + }; + + OAuthConfig { + oauth_key: auth + .encryption_key + .secret() + .await + .map_err(|err| bp.build_error(ObjectType::OidcProvider.singleton(), err)) + .map_or_else(|_| rand_key.clone(), Cow::into_owned), + oauth_expiry_user_code: auth.user_code_expiry.as_secs(), + oauth_expiry_auth_code: auth.auth_code_expiry.as_secs(), + oauth_expiry_token: auth.access_token_expiry.as_secs(), + oauth_expiry_refresh_token: auth.refresh_token_expiry.as_secs(), + oauth_expiry_refresh_token_renew: auth.refresh_token_renewal.as_secs(), + oauth_max_auth_attempts: auth.auth_code_max_attempts as u32, + oidc_expiry_id_token: auth.id_token_expiry.as_secs(), + allow_anonymous_client_registration: auth.anonymous_client_registration, + require_client_authentication: auth.require_client_registration, + oidc_signing_secret, + oidc_signature_algorithm, + oidc_jwks, + } + } +} + +async fn parse_rsa_key(auth: &OidcProvider) -> Result<(EncodingKey, AlgorithmParameters), String> { + let rsa_key = build_rsa_keypair(auth.signature_key.secret().await?.as_ref())?; + + let rsa_key_params = RSAKeyParameters { + key_type: RSAKeyType::RSA, + n: URL_SAFE_NO_PAD.encode(&rsa_key.modulus), + e: URL_SAFE_NO_PAD.encode(&rsa_key.exponent), + }; + + Ok(( + EncodingKey::from_rsa_der(&rsa_key.pkcs1_der), + AlgorithmParameters::RSA(rsa_key_params), + )) +} + +async fn parse_ecdsa_key( + auth: &OidcProvider, + oidc_signature_algorithm: Algorithm, +) -> Result<(EncodingKey, AlgorithmParameters), String> { + let (curve, ec_curve) = match oidc_signature_algorithm { + Algorithm::ES256 => (EllipticCurve::P256, EcKeyCurve::P256), + Algorithm::ES384 => (EllipticCurve::P384, EcKeyCurve::P384), + _ => unreachable!(), + }; + + let ecdsa_key = build_ecdsa_pem(ec_curve, auth.signature_key.secret().await?.as_ref())?; + + let ecdsa_key_params = EllipticCurveKeyParameters { + key_type: EllipticCurveKeyType::EC, + curve, + x: URL_SAFE_NO_PAD.encode(&ecdsa_key.x), + y: URL_SAFE_NO_PAD.encode(&ecdsa_key.y), + }; + + Ok(( + EncodingKey::from_ec_der(&ecdsa_key.pkcs8_der), + AlgorithmParameters::EllipticCurve(ecdsa_key_params), + )) +} diff --git a/crates/common/src/auth/oauth/crypto.rs b/crates/common/src/auth/oauth/crypto.rs new file mode 100644 index 0000000..dd8927e --- /dev/null +++ b/crates/common/src/auth/oauth/crypto.rs @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use aes_gcm_siv::{ + Aes256GcmSiv, Key, KeyInit, Nonce, + aead::{Aead, Payload}, +}; +use store::blake3; + +pub struct SymmetricEncrypt { + aes: Aes256GcmSiv, +} + +impl SymmetricEncrypt { + pub const ENCRYPT_TAG_LEN: usize = 16; + pub const NONCE_LEN: usize = 12; + + pub fn new(key: &[u8], context: &str) -> Self { + SymmetricEncrypt { + aes: Aes256GcmSiv::new(&Key::::from(blake3::derive_key(context, key))), + } + } + + pub fn encrypt_with_aad( + &self, + bytes: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, String> { + self.aes + .encrypt( + <&Nonce>::try_from(nonce).map_err(|e| e.to_string())?, + Payload { msg: bytes, aad }, + ) + .map_err(|e| e.to_string()) + } + + pub fn decrypt_with_aad( + &self, + bytes: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, String> { + self.aes + .decrypt( + <&Nonce>::try_from(nonce).map_err(|e| e.to_string())?, + Payload { msg: bytes, aad }, + ) + .map_err(|e| e.to_string()) + } +} diff --git a/crates/common/src/auth/oauth/introspect.rs b/crates/common/src/auth/oauth/introspect.rs new file mode 100644 index 0000000..39a4c3b --- /dev/null +++ b/crates/common/src/auth/oauth/introspect.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, auth::AccessToken}; +use serde::{Deserialize, Serialize}; +use trc::{AddContext, AuthEvent, EventType}; + +#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct OAuthIntrospect { + #[serde(default)] + pub active: bool, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, +} + +impl Server { + pub async fn introspect_access_token( + &self, + token: &str, + access_token: &AccessToken, + ) -> trc::Result { + match self.validate_access_token(None, token).await { + Ok(token_info) => Ok(OAuthIntrospect { + active: true, + username: self + .account(access_token.account_id()) + .await + .caused_by(trc::location!())? + .name() + .to_string() + .into(), + token_type: Some("bearer".into()), + exp: Some(token_info.expiry as i64), + iat: Some(token_info.issued_at as i64), + ..Default::default() + }), + Err(err) + if matches!( + err.event_type(), + EventType::Auth(AuthEvent::Error) | EventType::Auth(AuthEvent::TokenExpired) + ) => + { + Ok(OAuthIntrospect::default()) + } + Err(err) => Err(err), + } + } +} diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs new file mode 100644 index 0000000..2beb819 --- /dev/null +++ b/crates/common/src/auth/oauth/mod.rs @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod client_id; +pub mod config; +pub mod crypto; +pub mod introspect; +pub mod oidc; +pub mod registration; +pub mod token; + +pub const DEVICE_CODE_LEN: usize = 40; +pub const USER_CODE_LEN: usize = 8; +pub const RANDOM_CODE_LEN: usize = 32; +pub const CLIENT_ID_MAX_LEN: usize = 2048; + +pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 + +pub const SCOPE_OPENID: &str = "openid"; +pub const SCOPE_OFFLINE_ACCESS: &str = "offline_access"; +pub const SCOPE_MAIL: &str = "urn:ietf:params:oauth:scope:mail"; +pub const SCOPE_CONTACTS: &str = "urn:ietf:params:oauth:scope:contacts"; +pub const SCOPE_CALENDARS: &str = "urn:ietf:params:oauth:scope:calendars"; + +pub const SUPPORTED_SCOPES: &[&str] = &[ + SCOPE_OPENID, + SCOPE_OFFLINE_ACCESS, + SCOPE_MAIL, + SCOPE_CONTACTS, + SCOPE_CALENDARS, +]; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GrantType { + AccessToken, + RefreshToken, + LiveTracing, + LiveMetrics, + LiveDelivery, + Rsvp, +} + +impl GrantType { + pub fn as_str(&self) -> &'static str { + match self { + GrantType::AccessToken => "access_token", + GrantType::RefreshToken => "refresh_token", + GrantType::LiveTracing => "live_tracing", + GrantType::LiveMetrics => "live_metrics", + GrantType::LiveDelivery => "live_delivery", + GrantType::Rsvp => "rsvp", + } + } + + pub fn id(&self) -> u8 { + match self { + GrantType::AccessToken => 0, + GrantType::RefreshToken => 1, + GrantType::LiveTracing => 2, + GrantType::LiveMetrics => 3, + GrantType::LiveDelivery => 4, + GrantType::Rsvp => 5, + } + } + + pub fn from_id(id: u8) -> Option { + match id { + 0 => Some(GrantType::AccessToken), + 1 => Some(GrantType::RefreshToken), + 2 => Some(GrantType::LiveTracing), + 3 => Some(GrantType::LiveMetrics), + 4 => Some(GrantType::LiveDelivery), + 5 => Some(GrantType::Rsvp), + _ => None, + } + } +} diff --git a/crates/common/src/auth/oauth/oidc.rs b/crates/common/src/auth/oauth/oidc.rs new file mode 100644 index 0000000..48b97db --- /dev/null +++ b/crates/common/src/auth/oauth/oidc.rs @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt; + +use jsonwebtoken::Header; + +use serde::{ + Deserialize, Deserializer, Serialize, + de::{self, Visitor}, +}; +use store::write::now; + +use crate::Server; + +#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct Userinfo { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub given_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub family_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub middle_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + + #[serde(default, deserialize_with = "any_bool")] + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub email_verified: bool, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub zoneinfo: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct StandardClaims { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub nonce: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub preferred_username: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub email: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub description: Option, +} + +#[derive(Serialize)] +struct IdTokenClaims { + iss: String, + sub: String, + aud: String, + nbf: i64, + iat: i64, + exp: i64, + + #[serde(flatten)] + private: StandardClaims, +} + +impl Server { + pub fn issue_id_token( + &self, + subject: impl Into, + issuer: impl Into, + audience: impl Into, + claims: StandardClaims, + ) -> trc::Result { + let now = now() as i64; + + jsonwebtoken::encode( + &Header { + kid: Some("default".into()), + ..Header::new(self.core.oauth.oidc_signature_algorithm) + }, + &IdTokenClaims { + iss: issuer.into(), + sub: subject.into(), + aud: audience.into(), + nbf: now, + iat: now, + exp: now + self.core.oauth.oidc_expiry_id_token as i64, + private: claims, + }, + &self.core.oauth.oidc_signing_secret, + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .reason(err) + .details("Failed to encode ID token") + }) + } +} + +fn any_bool<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct AnyBoolVisitor; + + impl Visitor<'_> for AnyBoolVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a boolean value") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(E::custom(format!("Unknown boolean: {value}"))), + } + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(value) + } + } + + deserializer.deserialize_any(AnyBoolVisitor) +} diff --git a/crates/common/src/auth/oauth/registration.rs b/crates/common/src/auth/oauth/registration.rs new file mode 100644 index 0000000..56d7711 --- /dev/null +++ b/crates/common/src/auth/oauth/registration.rs @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +pub struct ClientRegistrationRequest { + pub redirect_uris: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub response_types: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub grant_types: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub application_type: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub contacts: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks: Option, // Using serde_json::Value for flexibility + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sector_identifier_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_type: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_signed_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_encrypted_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_encrypted_response_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_signed_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_encrypted_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_encrypted_response_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_signing_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_encryption_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_encryption_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_signing_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub default_max_age: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub require_auth_time: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub default_acr_values: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub initiate_login_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub request_uris: Vec, + + #[serde(flatten)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub additional_fields: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +pub struct ClientRegistrationResponse { + // Required fields + pub client_id: String, + + // Optional fields specific to the response + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_access_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_client_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id_issued_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret_expires_at: Option, + + // Echo back the request + #[serde(flatten)] + pub request: ClientRegistrationRequest, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ApplicationType { + Web, + Native, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SubjectType { + Pairwise, + Public, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TokenEndpointAuthMethod { + ClientSecretPost, + ClientSecretBasic, + ClientSecretJwt, + PrivateKeyJwt, + None, +} + +#[derive(Serialize, Debug)] +pub struct ClientRegistrationError { + pub error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option<&'static str>, +} + +impl ClientRegistrationError { + pub fn invalid_redirect_uri(description: &'static str) -> Self { + ClientRegistrationError { + error: "invalid_redirect_uri", + error_description: Some(description), + } + } + + pub fn invalid_client_metadata(description: &'static str) -> Self { + ClientRegistrationError { + error: "invalid_client_metadata", + error_description: Some(description), + } + } +} + +pub fn loopback_redirect_parts(uri: &str) -> Option<(&str, &str)> { + let uri = uri.strip_prefix("http://")?; + + for host in ["127.0.0.1", "[::1]"] { + if let Some(rest) = uri.strip_prefix(host) { + if let Some(path) = rest.strip_prefix('/') { + return Some((host, path)); + } else if let Some(after_colon) = rest.strip_prefix(':') + && let Some((port, path)) = after_colon.split_once('/') + && !port.is_empty() + && port.bytes().all(|b| b.is_ascii_digit()) + { + return Some((host, path)); + } + } + } + None +} + +pub fn redirect_uri_matches(registered: &str, presented: &str) -> bool { + registered == presented + || matches!( + ( + loopback_redirect_parts(registered), + loopback_redirect_parts(presented), + ), + (Some(reg), Some(pres)) if reg == pres + ) +} + +pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> { + if uri.contains('#') { + return Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must not contain a fragment.", + )); + } else if uri.contains("..") { + return Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must not contain consecutive dots.", + )); + } else if uri.starts_with("https://") || loopback_redirect_parts(uri).is_some() { + return Ok(()); + } else if let Some((scheme, _)) = uri.split_once(':') + && scheme.contains('.') + && scheme + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && scheme + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+')) + { + return Ok(()); + } + + Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must be an https URL, a loopback (http://127.0.0.1/, http://[::1]/) or a private-use scheme URI.", + )) +} + +pub fn validate_grant_metadata( + request: &ClientRegistrationRequest, +) -> Result<(), ClientRegistrationError> { + if !request.response_types.is_empty() && !request.response_types.iter().any(|t| t == "code") { + return Err(ClientRegistrationError::invalid_client_metadata( + "response_types must include \"code\".", + )); + } + if !request.grant_types.is_empty() { + if !request + .grant_types + .iter() + .any(|t| t == "authorization_code") + { + return Err(ClientRegistrationError::invalid_client_metadata( + "grant_types must include \"authorization_code\".", + )); + } + if !request.grant_types.iter().any(|t| t == "refresh_token") { + return Err(ClientRegistrationError::invalid_client_metadata( + "grant_types must include \"refresh_token\".", + )); + } + } + + Ok(()) +} diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs new file mode 100644 index 0000000..cc4ef4f --- /dev/null +++ b/crates/common/src/auth/oauth/token.rs @@ -0,0 +1,418 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{GrantType, crypto::SymmetricEncrypt}; +use crate::Server; +use base64::{Engine, engine::general_purpose}; +use std::time::SystemTime; +use store::rand::{RngExt, rng}; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; + +pub const FAILED_TO_DECODE_TOKEN: &str = concat!( + "Failed to decode token. If you are using an ", + "external OIDC provider, make sure it is configured as the default directory under ", + "the Authentication object." +); + +const TOKEN_HEADER: &str = "sw1."; +const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1"; +const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 + +pub struct TokenInfo { + pub grant_type: GrantType, + pub account_id: u32, + pub claims: Option, + pub expiry: u64, + pub issued_at: u64, + pub expires_in: u64, +} + +struct RawToken { + grant_type: GrantType, + account_id: u32, + claims: Option, + issued_at: u64, + expiry: u64, + credential_version: u64, +} + +impl Server { + pub async fn encode_access_token( + &self, + grant_type: GrantType, + account_id: u32, + account_name: &str, + expiry_in: u64, + claims: Option<&str>, + credential_version: Option, + ) -> trc::Result { + let issued_at = seconds_since_oauth_epoch(); + let raw = RawToken { + grant_type, + account_id, + claims: claims.map(|claims| claims.to_string()), + issued_at, + expiry: issued_at + expiry_in, + credential_version: credential_version + .filter(|_| !matches!(grant_type, GrantType::Rsvp)) + .unwrap_or_default(), + }; + + seal_token( + self.core.oauth.oauth_key.as_bytes(), + &raw, + account_name.as_bytes(), + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, "Failed to encrypt token") + .reason(err) + .caused_by(trc::location!()) + }) + } + + pub async fn validate_access_token( + &self, + expected_grant_type: Option, + token_: &str, + ) -> trc::Result { + let token = open_token(self.core.oauth.oauth_key.as_bytes(), token_).map_err(|_| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) + .caused_by(trc::location!()) + .details(token_.to_string()) + })?; + + // Validate expiration + let now = seconds_since_oauth_epoch(); + if token.expiry <= now || token.issued_at > now { + return Err(trc::AuthEvent::TokenExpired.into_err()); + } + + // Validate grant type + if expected_grant_type.is_some_and(|g| g != token.grant_type) { + return Err(trc::AuthEvent::Error + .into_err() + .details("Invalid grant type")); + } + + // Enforce credential revocation for long lived tokens + if token.credential_version != 0 { + let current = self + .access_token(token.account_id) + .await + .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))? + .credential_version(); + if current != token.credential_version { + return Err(trc::AuthEvent::TokenExpired + .into_err() + .details("Token revoked")); + } + } + + Ok(TokenInfo { + grant_type: token.grant_type, + account_id: token.account_id, + claims: token.claims, + expiry: token.expiry + OAUTH_EPOCH, + issued_at: token.issued_at + OAUTH_EPOCH, + expires_in: token.expiry - now, + }) + } +} + +fn seal_token(key: &[u8], token: &RawToken, footer: &[u8]) -> Result { + let mut payload = Vec::with_capacity(32); + payload.push_leb128(token.account_id); + payload.push(token.grant_type.id()); + payload.push_leb128(token.issued_at); + payload.push_leb128(token.expiry); + payload.push_leb128(token.credential_version); + if let Some(claims) = token.claims.as_deref().filter(|claims| !claims.is_empty()) { + payload.extend_from_slice(claims.as_bytes()); + } + + let nonce = rng().random::<[u8; SymmetricEncrypt::NONCE_LEN]>(); + let ciphertext = + SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT).encrypt_with_aad(&payload, &nonce, footer)?; + + let mut body = Vec::with_capacity(nonce.len() + ciphertext.len()); + body.extend_from_slice(&nonce); + body.extend_from_slice(&ciphertext); + + let mut out = String::with_capacity(TOKEN_HEADER.len() + (body.len() + footer.len()) * 2); + out.push_str(TOKEN_HEADER); + general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out); + if !footer.is_empty() { + out.push('.'); + general_purpose::URL_SAFE_NO_PAD.encode_string(footer, &mut out); + } + + Ok(out) +} + +fn open_token(key: &[u8], token: &str) -> Result { + let rest = token.strip_prefix(TOKEN_HEADER).ok_or(())?; + let (body, footer) = match rest.split_once('.') { + Some((body, footer)) => ( + body, + general_purpose::URL_SAFE_NO_PAD + .decode(footer.as_bytes()) + .map_err(|_| ())?, + ), + None => (rest, Vec::new()), + }; + let body = general_purpose::URL_SAFE_NO_PAD + .decode(body.as_bytes()) + .map_err(|_| ())?; + if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN { + return Err(()); + } + let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN); + + let payload = SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT) + .decrypt_with_aad(ciphertext, nonce, &footer) + .map_err(|_| ())?; + + let mut bytes = payload.iter(); + let account_id: u32 = bytes.next_leb128().ok_or(())?; + let grant_type = GrantType::from_id(bytes.next().copied().ok_or(())?).ok_or(())?; + let issued_at: u64 = bytes.next_leb128().ok_or(())?; + let expiry: u64 = bytes.next_leb128().ok_or(())?; + let credential_version: u64 = bytes.next_leb128().ok_or(())?; + let bytes = bytes.as_slice(); + let claims = if bytes.is_empty() { + None + } else { + Some(String::from_utf8(bytes.to_vec()).map_err(|_| ())?) + }; + + Ok(RawToken { + grant_type, + account_id, + claims, + issued_at, + expiry, + credential_version, + }) +} + +#[inline(always)] +fn seconds_since_oauth_epoch() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + .saturating_sub(OAUTH_EPOCH) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"a-test-encryption-key-of-some-length"; + const NAME: &[u8] = b"user@example.org"; + + fn sample(grant_type: GrantType, claims: Option<&str>, cv: u64) -> RawToken { + RawToken { + grant_type, + account_id: 42, + claims: claims.map(|c| c.to_string()), + issued_at: 1_000, + expiry: 2_000, + credential_version: cv, + } + } + + fn assert_eq_fields(a: &RawToken, b: &RawToken) { + assert_eq!(a.account_id, b.account_id); + assert_eq!(a.grant_type, b.grant_type); + assert_eq!(a.claims, b.claims); + assert_eq!(a.issued_at, b.issued_at); + assert_eq!(a.expiry, b.expiry); + assert_eq!(a.credential_version, b.credential_version); + } + + #[test] + fn round_trip_preserves_all_fields() { + for (raw, footer) in [ + (sample(GrantType::AccessToken, None, 0), NAME), + ( + sample(GrantType::RefreshToken, None, 0xdead_beef_cafe), + NAME, + ), + ( + sample(GrantType::Rsvp, Some("attendee@x.org;7"), 0), + b"owner@example.org", + ), + (sample(GrantType::AccessToken, None, 0), b""), + ( + RawToken { + account_id: u32::MAX, + credential_version: u64::MAX, + ..sample(GrantType::AccessToken, Some("名前;1"), 1) + }, + "名字@example.org".as_bytes(), + ), + ] { + let token = seal_token(KEY, &raw, footer).unwrap(); + assert!(token.starts_with(TOKEN_HEADER)); + let opened = open_token(KEY, &token).unwrap(); + assert_eq_fields(&raw, &opened); + + // The footer (account name) round-trips in clear text for proxies + if footer.is_empty() { + assert!(!token[TOKEN_HEADER.len()..].contains('.')); + } else { + let segment = token.rsplit_once('.').unwrap().1; + assert_eq!( + general_purpose::URL_SAFE_NO_PAD.decode(segment).unwrap(), + footer + ); + } + } + } + + #[test] + fn account_name_is_readable_in_clear_text_footer() { + let token = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"route-me@example.org", + ) + .unwrap(); + let footer = token.rsplit_once('.').unwrap().1; + let decoded = general_purpose::URL_SAFE_NO_PAD.decode(footer).unwrap(); + assert_eq!(decoded, b"route-me@example.org"); + } + + #[test] + fn wrong_key_is_rejected() { + let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + assert!(open_token(b"a-different-encryption-key-entirely!", &token).is_err()); + } + + #[test] + fn tampering_with_ciphertext_is_rejected() { + let raw = sample(GrantType::AccessToken, None, 0); + let token = seal_token(KEY, &raw, NAME).unwrap(); + let (header, rest) = token.split_at(TOKEN_HEADER.len()); + let (body_b64, footer) = match rest.split_once('.') { + Some((b, f)) => (b.to_string(), Some(f.to_string())), + None => (rest.to_string(), None), + }; + let mut body = general_purpose::URL_SAFE_NO_PAD.decode(&body_b64).unwrap(); + + for idx in 0..body.len() { + let mut tampered = body.clone(); + tampered[idx] ^= 0x01; + let mut rebuilt = String::from(header); + rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&tampered)); + if let Some(footer) = &footer { + rebuilt.push('.'); + rebuilt.push_str(footer); + } + assert!( + open_token(KEY, &rebuilt).is_err(), + "flipping byte {idx} of the body must invalidate the token" + ); + } + + // Sanity: the untampered token still opens + body[0] ^= 0x00; + assert!(open_token(KEY, &token).is_ok()); + } + + #[test] + fn tampering_with_clear_text_footer_is_rejected() { + let raw = sample(GrantType::AccessToken, None, 0); + let token = seal_token(KEY, &raw, b"victim@example.org").unwrap(); + let (body, _) = token.rsplit_once('.').unwrap(); + + // An attacker rewrites the clear-text account name to impersonate another account + let forged_footer = general_purpose::URL_SAFE_NO_PAD.encode(b"attacker@example.org"); + let forged = format!("{body}.{forged_footer}"); + assert!( + open_token(KEY, &forged).is_err(), + "the footer is bound through the associated data and must be authenticated" + ); + } + + #[test] + fn swapping_footers_between_tokens_is_rejected() { + let a = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"alice@example.org", + ) + .unwrap(); + let b = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"bob@example.org", + ) + .unwrap(); + let a_body = a.rsplit_once('.').unwrap().0; + let b_footer = b.rsplit_once('.').unwrap().1; + let frankentoken = format!("{a_body}.{b_footer}"); + assert!(open_token(KEY, &frankentoken).is_err()); + } + + #[test] + fn malformed_input_never_panics_and_is_rejected() { + let valid = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + let cases = [ + String::new(), + "sw1.".to_string(), + "sw1.!!!not-base64!!!".to_string(), + "sw1...".to_string(), + "wrong-prefix.".to_string(), + "sw1.AAAA".to_string(), + "sw1.AAAA.BBBB".to_string(), + valid.replace("sw1.", "sw2."), + valid[..valid.len() / 2].to_string(), + format!("sw1.{}", "A".repeat(10_000)), + "\u{0}\u{0}\u{0}".to_string(), + ]; + for case in cases { + assert!(open_token(KEY, &case).is_err(), "must reject {case:?}"); + } + } + + #[test] + fn truncating_the_body_is_rejected() { + let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + let (header, rest) = token.split_at(TOKEN_HEADER.len()); + let body_b64 = rest.split_once('.').map(|(b, _)| b).unwrap_or(rest); + let body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap(); + for len in 0..body.len() { + let mut rebuilt = String::from(header); + rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&body[..len])); + assert!( + open_token(KEY, &rebuilt).is_err(), + "truncation to {len} must be rejected" + ); + } + } + + #[test] + fn identical_input_produces_distinct_tokens() { + let raw = sample(GrantType::AccessToken, None, 7); + let a = seal_token(KEY, &raw, NAME).unwrap(); + let b = seal_token(KEY, &raw, NAME).unwrap(); + assert_ne!(a, b, "a random nonce must make each token unique"); + assert_eq_fields(&open_token(KEY, &a).unwrap(), &open_token(KEY, &b).unwrap()); + } + + #[test] + fn claims_with_separators_round_trip_exactly() { + let raw = sample(GrantType::Rsvp, Some("a;b;c;d@e.org;999"), 0); + let token = seal_token(KEY, &raw, b"owner@example.org").unwrap(); + let opened = open_token(KEY, &token).unwrap(); + assert_eq!(opened.claims.as_deref(), Some("a;b;c;d@e.org;999")); + } +} diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs new file mode 100644 index 0000000..bf43a37 --- /dev/null +++ b/crates/common/src/auth/permissions.rs @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::{AccessToken, Permissions, PermissionsGroup}, +}; +use ahash::AHashSet; +use registry::{ + schema::{ + enums::Permission, + structs::{self, Account, PermissionsList, UserRoles}, + }, + types::EnumImpl, +}; +use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; + +impl Server { + pub async fn add_role_permissions( + &self, + mut base_permissions: PermissionsGroup, + roles: impl IntoIterator, + ) -> trc::Result { + let mut role_ids = roles.into_iter().collect::>(); + let mut fetched_role_ids = AHashSet::new(); + + while let Some(role_id) = role_ids.pop() { + if fetched_role_ids.insert(role_id) { + let role = self.role(role_id).await.caused_by(trc::location!())?; + + base_permissions.union(&role.permissions); + role_ids.extend(role.id_roles.iter().copied()); + } + } + + Ok(base_permissions) + } + + pub async fn effective_permissions( + &self, + permissions: &structs::Permissions, + role_ids: &[Id], + tenant_id: Option, + ) -> trc::Result { + // Calculate effective permissions + let (mut permissions, roles) = match permissions { + structs::Permissions::Inherit => (PermissionsGroup::default(), role_ids), + structs::Permissions::Merge(permissions) => { + (PermissionsGroup::from(permissions), role_ids) + } + structs::Permissions::Replace(permissions) => { + (PermissionsGroup::from(permissions), &[][..]) + } + }; + if !roles.is_empty() { + permissions = self + .add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32)) + .await + .caused_by(trc::location!())? + } + + + Ok(permissions) + } + + pub async fn can_set_permissions( + &self, + access_token: &AccessToken, + account: &Account, + ) -> trc::Result>> { + let (permissions, role_ids, tenant_id) = match account { + Account::User(account) => ( + &account.permissions, + match &account.roles { + UserRoles::User => self.core.network.security.default_role_ids_user.as_slice(), + UserRoles::Admin => { + if access_token.tenant_id().is_none() { + self.core.network.security.default_role_ids_admin.as_slice() + } else { + self.core + .network + .security + .default_role_ids_tenant + .as_slice() + } + } + UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(), + }, + account.member_tenant_id.map(|t| t.document_id()), + ), + Account::Group(account) => ( + &account.permissions, + account + .roles + .role_ids() + .unwrap_or(self.core.network.security.default_role_ids_group.as_slice()), + account.member_tenant_id.map(|t| t.document_id()), + ), + }; + + self.effective_permissions(permissions, role_ids, tenant_id) + .await + .map(|permissions| access_token.can_grant_permissions(permissions.finalize())) + } +} + +impl AccessToken { + pub fn can_grant_permissions( + &self, + mut requested_permissions: Permissions, + ) -> Result<(), Vec> { + requested_permissions.difference(self.permissions_bits()); + if requested_permissions.is_empty() { + Ok(()) + } else { + Err(requested_permissions.build_permissions_list()) + } + } +} + +pub trait PermissionsListBuilder { + fn build_permissions_list(&self) -> Vec; +} + +impl PermissionsListBuilder for Permissions { + fn build_permissions_list(&self) -> Vec { + const USIZE_BITS: usize = std::mem::size_of::() * 8; + const USIZE_MASK: u32 = USIZE_BITS as u32 - 1; + let mut permissions = Vec::new(); + + for (block_num, bytes) in self.inner().iter().enumerate() { + let mut bytes = *bytes; + + while bytes != 0 { + let item = USIZE_MASK - bytes.leading_zeros(); + bytes ^= 1 << item; + if let Some(permission) = + Permission::from_id(((block_num * USIZE_BITS) + item as usize) as u16) + { + permissions.push(permission); + } + } + } + permissions + } +} + +pub struct DefaultPermissions { + pub user: Vec, + pub group: Vec, + pub tenant: Vec, + pub superuser: Vec, +} + +impl PermissionsGroup { + pub fn with_merge(mut self, merge: bool) -> Self { + self.merge = merge; + self + } + + pub fn union(&mut self, other: &PermissionsGroup) { + self.enabled.union(&other.enabled); + self.disabled.union(&other.disabled); + } + + pub fn restrict(&mut self, other: &PermissionsGroup) { + self.enabled.intersection(&other.enabled); + self.disabled.union(&other.disabled); + } + + pub fn finalize(mut self) -> Permissions { + self.enabled.difference(&self.disabled); + self.enabled + } + + pub fn finalize_as_ref(&self) -> Permissions { + let mut enabled = self.enabled.clone(); + enabled.difference(&self.disabled); + enabled + } + + pub fn user() -> Self { + let mut permissions = PermissionsGroup::default(); + for permission in DefaultPermissions::default().user { + permissions.enabled.set(permission as usize); + } + + permissions + } +} + +impl Default for DefaultPermissions { + fn default() -> Self { + let mut default = Self { + user: Default::default(), + group: Default::default(), + tenant: Default::default(), + superuser: Default::default(), + }; + + for permission_id in 0..Permission::COUNT { + let permission = Permission::from_id(permission_id as u16).unwrap(); + match permission { + Permission::Authenticate + | Permission::AuthenticateWithAlias + | Permission::InteractAi => { + default.user.push(permission); + default.superuser.push(permission); + default.tenant.push(permission); + } + Permission::Impersonate + | Permission::UnlimitedRequests + | Permission::UnlimitedUploads + | Permission::LiveMetrics + | Permission::LiveTracing => { + default.superuser.push(permission); + } + Permission::FetchAnyBlob | Permission::LiveDeliveryTest => { + default.superuser.push(permission); + default.tenant.push(permission); + } + permission => { + let name = permission.as_str(); + if name.starts_with("jmap") + || name.starts_with("imap") + || name.starts_with("pop3") + || name.starts_with("calendar") + || name.starts_with("email") + || name.starts_with("dav") + || name.starts_with("sieve") + { + default.user.push(permission); + default.group.push(permission); + } else if name.starts_with("sysMaskedEmail") + || name.starts_with("sysArchivedItem") + || name.starts_with("sysAccountSettings") + || name.starts_with("sysPublicKey") + || (name.starts_with("sysSpamTrainingSample") && !name.contains("Create")) + { + default.user.push(permission); + default.group.push(permission); + default.superuser.push(permission); + } else if name.starts_with("sysAccountPassword") + || name.starts_with("sysApiKey") + || name.starts_with("sysAppPassword") + { + default.user.push(permission); + default.superuser.push(permission); + } else if name.starts_with("sysDomain") + || name.starts_with("sysDkimSignature") + || name.starts_with("sysAcmeProvider") + || name.starts_with("sysAccount") + || name.starts_with("sysRole") + || name.starts_with("sysOAuthClient") + || name.starts_with("sysMailingList") + || name.starts_with("sysExternalReport") + || name.starts_with("sysDnsServer") + || name.starts_with("sysQueuedMessage") + { + default.tenant.push(permission); + default.superuser.push(permission); + } else { + default.superuser.push(permission); + } + } + } + } + + default + } +} + +impl From for PermissionsGroup { + fn from(value: PermissionsList) -> Self { + Self::from(&value) + } +} + +impl From<&PermissionsList> for PermissionsGroup { + fn from(value: &PermissionsList) -> Self { + PermissionsGroup { + enabled: Permissions::from_permission(value.enabled_permissions.as_slice()), + disabled: Permissions::from_permission(value.disabled_permissions.as_slice()), + merge: false, + } + } +} + +impl From<&VecMap> for PermissionsGroup { + fn from(value: &VecMap) -> Self { + let mut permissions = PermissionsGroup::default(); + for (permission, is_set) in value { + if *is_set { + permissions.enabled.set(*permission as usize); + } else { + permissions.disabled.set(*permission as usize); + } + } + permissions + } +} + +pub trait BuildPermissions { + fn from_permission(list: &[Permission]) -> Permissions; +} + +impl BuildPermissions for Permissions { + fn from_permission(list: &[Permission]) -> Permissions { + let mut permission = Permissions::default(); + for p in list { + permission.set(*p as usize); + } + permission + } +} diff --git a/crates/common/src/auth/rate_limit.rs b/crates/common/src/auth/rate_limit.rs new file mode 100644 index 0000000..9202044 --- /dev/null +++ b/crates/common/src/auth/rate_limit.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::auth::AccessToken; +use crate::network::ip_to_bytes; +use crate::network::limiter::{InFlight, LimiterResult}; +use crate::{KV_RATE_LIMIT_HTTP_ANONYMOUS, KV_RATE_LIMIT_HTTP_AUTHENTICATED, Server}; +use registry::schema::enums::Permission; +use std::net::IpAddr; +use trc::AddContext; + +impl Server { + pub async fn is_http_authenticated_request_allowed( + &self, + access_token: &AccessToken, + addr: IpAddr, + ) -> trc::Result> { + let rate_reset = if let Some(rate) = &self.core.network.http.rate_authenticated { + if self.is_ip_allowed(addr) { + None + } else { + self.core + .storage + .memory + .is_rate_allowed( + KV_RATE_LIMIT_HTTP_AUTHENTICATED, + &access_token.account_id().to_be_bytes(), + rate, + false, + ) + .await + .caused_by(trc::location!())? + .map(|reset| (reset, rate.count)) + } + } else { + None + }; + + if let Some((reset, count)) = rate_reset { + if access_token.has_permission(Permission::UnlimitedRequests) { + Ok(None) + } else { + Err(trc::LimitEvent::TooManyRequests + .into_err() + .ctx(trc::Key::Expires, reset) + .ctx(trc::Key::Limit, count)) + } + } else { + match access_token.is_http_request_allowed() { + LimiterResult::Allowed(in_flight) => Ok(Some(in_flight)), + LimiterResult::Forbidden => { + if access_token.has_permission(Permission::UnlimitedRequests) { + Ok(None) + } else { + Err(trc::LimitEvent::ConcurrentRequest + .into_err() + .ctx(trc::Key::Limit, access_token.concurrent_http_requests())) + } + } + LimiterResult::Disabled => Ok(None), + } + } + } + + pub async fn is_http_anonymous_request_allowed(&self, addr: IpAddr) -> trc::Result<()> { + if let Some(rate) = &self.core.network.http.rate_anonymous + && !self.is_ip_allowed(addr) + && let Some(reset) = self + .core + .storage + .memory + .is_rate_allowed( + KV_RATE_LIMIT_HTTP_ANONYMOUS, + &ip_to_bytes(&addr), + rate, + false, + ) + .await + .caused_by(trc::location!())? + { + return Err(trc::LimitEvent::TooManyRequests + .into_err() + .ctx(trc::Key::Expires, reset) + .ctx(trc::Key::Limit, rate.count)); + } + Ok(()) + } + + pub fn is_upload_allowed(&self, access_token: &AccessToken) -> trc::Result> { + match access_token.is_upload_allowed() { + LimiterResult::Allowed(in_flight) => Ok(Some(in_flight)), + LimiterResult::Forbidden => { + if access_token.has_permission(Permission::UnlimitedRequests) { + Ok(None) + } else { + Err(trc::LimitEvent::ConcurrentUpload + .into_err() + .ctx(trc::Key::Limit, access_token.concurrent_uploads())) + } + } + LimiterResult::Disabled => Ok(None), + } + } +} diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs new file mode 100644 index 0000000..896214b --- /dev/null +++ b/crates/common/src/cache/directory.rs @@ -0,0 +1,435 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder}; +use registry::{ + schema::{ + prelude::{Object, ObjectType}, + structs::{ + Account, Credential, EmailAlias, GroupAccount, PasswordCredential, Roles, UserAccount, + UserRoles, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId, list::List}, +}; +use std::sync::Arc; +use store::registry::write::{RegistryWrite, RegistryWriteResult}; +use trc::AddContext; +use types::id::Id; + +pub struct AccountWithId { + pub id: u32, + pub account: Account, +} + +impl Server { + pub async fn synchronize_account( + &self, + account: directory::Account, + ) -> trc::Result { + let (local, domain) = self.validate_address(&account.email).await?; + + match self + .account_id_from_parts(local, domain.id) + .await + .caused_by(trc::location!())? + { + Some(account_id) => { + let current_account = self + .registry() + .get(ObjectId::new(ObjectType::Account, account_id.into())) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Account ID from directory does not exist in registry") + .ctx(trc::Key::AccountName, account.email.clone()) + .ctx(trc::Key::AccountId, account_id) + })?; + + let mut updated_account = Account::from(current_account.clone()) + .into_user() + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details( + "Account ID from directory does not correspond to a user account", + ) + .ctx(trc::Key::AccountName, account.email.clone()) + .ctx(trc::Key::AccountId, account_id) + })?; + + let mut has_changes = false; + if let Some(secret) = account.secret + && secret != updated_account.password().unwrap_or_default() + { + has_changes = true; + updated_account.set_password(secret); + } + if account.description.is_some() + && account.description != updated_account.description + { + updated_account.description = account.description; + has_changes = true; + } + for alias in account.email_aliases { + if let Some((local, alias_domain)) = self.validate_alias(&alias).await? + && alias_domain.id_tenant == domain.id_tenant + && self + .rcpt_id_from_parts(local, alias_domain.id) + .await? + .is_none() + { + updated_account.aliases.push(EmailAlias { + name: local.to_string(), + domain_id: Id::from(alias_domain.id), + enabled: true, + description: None, + }); + has_changes = true; + } + } + if let Some(groups) = account.groups { + let mut member_group_ids = Vec::with_capacity(groups.len()); + for email in groups { + member_group_ids.push( + self.synchronize_group(directory::Group { + email, + ..Default::default() + }) + .await + .caused_by(trc::location!())? + .into(), + ); + } + if updated_account.member_group_ids.len() != member_group_ids.len() + || !updated_account + .member_group_ids + .iter() + .all(|id| member_group_ids.contains(id)) + { + updated_account.member_group_ids = member_group_ids.into(); + has_changes = true; + } + } + + if has_changes { + let updated_account = Object::from(Account::User(updated_account)); + match self + .registry() + .write(RegistryWrite::update( + Id::from(account_id), + &updated_account, + ¤t_account, + )) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(id) => { + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_update(id, ¤t_account, &updated_account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; + + Ok(AccountWithId { + id: id.document_id(), + account: updated_account.into(), + }) + } + failure => Err(trc::AuthEvent::Error + .into_err() + .caused_by(trc::location!()) + .details("Failed to synchronize account with directory") + .reason(failure)), + } + } else { + Ok(AccountWithId { + id: account_id, + account: Account::User(updated_account), + }) + } + } + None => { + + let mut aliases = Vec::with_capacity(account.email_aliases.len()); + for alias in account.email_aliases { + if let Some((local, alias_domain)) = self.validate_alias(&alias).await? + && alias_domain.id_tenant == domain.id_tenant + && self + .rcpt_id_from_parts(local, alias_domain.id) + .await? + .is_none() + { + aliases.push(EmailAlias { + name: local.to_string(), + domain_id: Id::from(alias_domain.id), + enabled: true, + description: None, + }); + } + } + let mut member_group_ids = Vec::new(); + for email in account.groups.unwrap_or_default() { + member_group_ids.push( + self.synchronize_group(directory::Group { + email, + ..Default::default() + }) + .await + .caused_by(trc::location!())? + .into(), + ); + } + let account = Object::from(Account::User(UserAccount { + name: local.to_string(), + domain_id: Id::from(domain.id), + aliases: aliases.into(), + created_at: UTCDateTime::now(), + description: account.description, + member_group_ids: member_group_ids.into(), + member_tenant_id: domain.id_tenant.map(Id::from), + roles: UserRoles::User, + credentials: List::from_iter(account.secret.map(|secret| { + Credential::Password(PasswordCredential { + credential_id: 0u64.into(), + secret, + ..Default::default() + }) + })), + ..Default::default() + })); + + + match self + .registry() + .write(RegistryWrite::insert(&account)) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(id) => { + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_create(&account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; + + Ok(AccountWithId { + id: id.document_id(), + account: account.into(), + }) + } + failure => Err(trc::AuthEvent::Error + .into_err() + .caused_by(trc::location!()) + .details("Failed to create account from directory") + .reason(failure)), + } + } + } + } + + pub async fn synchronize_group(&self, group: directory::Group) -> trc::Result { + let (local, domain) = self.validate_address(&group.email).await?; + + match self + .account_id_from_parts(local, domain.id) + .await + .caused_by(trc::location!())? + { + Some(account_id) => { + let current_account = self + .registry() + .get(ObjectId::new(ObjectType::Account, account_id.into())) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Account ID from directory does not exist in registry") + .ctx(trc::Key::AccountName, group.email.clone()) + .ctx(trc::Key::AccountId, account_id) + })?; + + let mut updated_account = Account::from(current_account.clone()) + .into_group() + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details( + "Account ID from directory does not correspond to a group account", + ) + .ctx(trc::Key::AccountName, group.email.clone()) + .ctx(trc::Key::AccountId, account_id) + })?; + + let mut has_changes = false; + if group.description.is_some() && group.description != updated_account.description { + updated_account.description = group.description; + has_changes = true; + } + for alias in group.email_aliases { + if let Some((local, alias_domain)) = self.validate_alias(&alias).await? + && alias_domain.id_tenant == domain.id_tenant + && self + .rcpt_id_from_parts(local, alias_domain.id) + .await? + .is_none() + { + updated_account.aliases.push(EmailAlias { + name: local.to_string(), + domain_id: Id::from(alias_domain.id), + enabled: true, + description: None, + }); + has_changes = true; + } + } + + if has_changes { + let updated_account = Object::from(Account::Group(updated_account)); + match self + .registry() + .write(RegistryWrite::update( + Id::from(account_id), + &updated_account, + ¤t_account, + )) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(id) => { + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_update(id, ¤t_account, &updated_account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; + + Ok(id.document_id()) + } + failure => Err(trc::AuthEvent::Error + .into_err() + .caused_by(trc::location!()) + .details("Failed to synchronize account with directory") + .reason(failure)), + } + } else { + Ok(account_id) + } + } + None => { + + let mut aliases = Vec::with_capacity(group.email_aliases.len()); + for alias in group.email_aliases { + if let Some((local, alias_domain)) = self.validate_alias(&alias).await? + && alias_domain.id_tenant == domain.id_tenant + && self + .rcpt_id_from_parts(local, alias_domain.id) + .await? + .is_none() + { + aliases.push(EmailAlias { + name: local.to_string(), + domain_id: Id::from(alias_domain.id), + enabled: true, + description: None, + }); + } + } + + let account = Object::from(Account::Group(GroupAccount { + name: local.to_string(), + domain_id: Id::from(domain.id), + aliases: aliases.into(), + created_at: UTCDateTime::now(), + description: group.description, + member_tenant_id: domain.id_tenant.map(Id::from), + roles: Roles::Default, + ..Default::default() + })); + + + match self + .registry() + .write(RegistryWrite::insert(&account)) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(id) => { + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_create(&account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; + + Ok(id.document_id()) + } + failure => Err(trc::AuthEvent::Error + .into_err() + .caused_by(trc::location!()) + .details("Failed to create account from directory") + .reason(failure)), + } + } + } + } + + async fn validate_address<'x>( + &self, + email: &'x str, + ) -> trc::Result<(&'x str, Arc)> { + if email.is_empty() { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account email cannot be empty")); + } + match email.rsplit_once('@') { + Some((local, domain)) => self + .domain(domain) + .await + .caused_by(trc::location!())? + .map(|domain| (local, domain)) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Account domain does not exist or has been disabled") + .ctx(trc::Key::Domain, domain.to_string()) + }), + None => { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = email.to_string().clone(), + Details = "Directory account is not an email, appended default domain", + ); + self.domain_by_id(self.core.email.default_domain_id) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Default domain does not exist or has been disabled") + .ctx(trc::Key::Id, self.core.email.default_domain_id) + }) + .map(|domain| (email, domain)) + } + } + } + + async fn validate_alias<'x>( + &self, + email: &'x str, + ) -> trc::Result)>> { + match email.rsplit_once('@') { + Some((local, domain)) => self + .domain(domain) + .await + .caused_by(trc::location!()) + .map(|domain| domain.map(|domain| (local, domain))), + None => Ok(None), + } + } +} diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs new file mode 100644 index 0000000..f0dcd12 --- /dev/null +++ b/crates/common/src/cache/invalidate.rs @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::{EmailAddressRef, EmailCache}, + ipc::{BroadcastEvent, CacheInvalidation}, +}; +use ahash::AHashSet; +use registry::{ + schema::{ + prelude::{Object, ObjectInner, ObjectType}, + structs::{Account, EmailAlias}, + }, + types::id::ObjectId, +}; +use store::{registry::RegistryQuery, roaring::RoaringBitmap}; +use types::id::Id; + +#[derive(Debug, Default)] +pub struct CacheInvalidationBuilder { + changes: AHashSet, +} + +impl CacheInvalidationBuilder { + pub fn process_update(&mut self, id: Id, current_object: &Object, new_object: &Object) { + let id = id.document_id(); + match (¤t_object.inner, &new_object.inner) { + ( + ObjectInner::Account(Account::User(current)), + ObjectInner::Account(Account::User(new)), + ) => { + let was_renamed = + (current.name != new.name) || (current.domain_id != new.domain_id); + let quota_changed = current.quotas != new.quotas; + let permissions_changed = current.permissions != new.permissions; + let roles_changed = current.roles != new.roles; + let tenant_changed = current.member_tenant_id != new.member_tenant_id; + let details_changed = + current.locale != new.locale || current.description != new.description; + let groups_changed = current.member_group_ids != new.member_group_ids; + let aliases_changed = current.aliases != new.aliases; + let credentials_changed = current.credentials != new.credentials; + let encryption_changed = current.encryption_at_rest != new.encryption_at_rest; + + if was_renamed + || aliases_changed + || tenant_changed + || groups_changed + || quota_changed + || details_changed + || encryption_changed + { + self.invalidate(CacheInvalidation::Account(id)); + } + + if was_renamed || aliases_changed { + self.invalidate_negative_email(&new_object.inner); + } + + if tenant_changed + || groups_changed + || credentials_changed + || roles_changed + || permissions_changed + { + self.invalidate(CacheInvalidation::AccessToken(id)); + } + + if was_renamed { + self.invalidate(CacheInvalidation::DavResources(id)); + } + } + + ( + ObjectInner::Account(Account::Group(current)), + ObjectInner::Account(Account::Group(new)), + ) => { + let was_renamed = + (current.name != new.name) || (current.domain_id != new.domain_id); + let quota_changed = current.quotas != new.quotas; + let permissions_changed = current.permissions != new.permissions; + let roles_changed = current.roles != new.roles; + let tenant_changed = current.member_tenant_id != new.member_tenant_id; + let details_changed = + current.locale != new.locale || current.description != new.description; + let aliases_changed = current.aliases != new.aliases; + + if was_renamed + || aliases_changed + || tenant_changed + || quota_changed + || details_changed + { + self.invalidate(CacheInvalidation::Account(id)); + } + + if was_renamed || aliases_changed { + self.invalidate_negative_email(&new_object.inner); + } + + if tenant_changed || roles_changed || permissions_changed { + self.invalidate(CacheInvalidation::AccessToken(id)); + } + + if was_renamed { + self.invalidate(CacheInvalidation::DavResources(id)); + } + } + + (ObjectInner::Domain(current), ObjectInner::Domain(new)) => { + if (current.name != new.name) + || (current.aliases != new.aliases) + || (current.directory_id != new.directory_id) + || (current.member_tenant_id != new.member_tenant_id) + || (current.catch_all_address != new.catch_all_address) + || (current.sub_addressing != new.sub_addressing) + || (current.allow_relaying != new.allow_relaying) + || (current.is_enabled != new.is_enabled) + { + self.invalidate(CacheInvalidation::Domain(id)); + } + + if (current.name != new.name) || (current.aliases != new.aliases) { + self.invalidate(CacheInvalidation::DomainNegative); + } + + if current.logo != new.logo { + self.invalidate(CacheInvalidation::DomainLogo(id)); + } + } + + (ObjectInner::DkimSignature(current), ObjectInner::DkimSignature(new)) => { + let current_domain_id = current.domain_id().document_id(); + let new_domain_id = new.domain_id().document_id(); + self.invalidate(CacheInvalidation::DkimSignature(current_domain_id)); + if current_domain_id != new_domain_id { + self.invalidate(CacheInvalidation::DkimSignature(new_domain_id)); + } + } + + (ObjectInner::Tenant(current), ObjectInner::Tenant(new)) => { + if (current.permissions != new.permissions) + || (current.roles != new.roles) + || (current.quotas != new.quotas) + { + self.invalidate(CacheInvalidation::Tenant(id)); + } + + if current.logo != new.logo { + self.invalidate(CacheInvalidation::TenantLogo(id)); + } + } + + (ObjectInner::Role(current), ObjectInner::Role(new)) + if (current.enabled_permissions != new.enabled_permissions) + || (current.disabled_permissions != new.disabled_permissions) + || (current.member_tenant_id != new.member_tenant_id) + || (current.role_ids != new.role_ids) => + { + self.invalidate(CacheInvalidation::Role(id)); + } + + (ObjectInner::MailingList(current), ObjectInner::MailingList(new)) + if (current.aliases != new.aliases) + || (current.name != new.name) + || (current.recipients != new.recipients) + || (current.domain_id != new.domain_id) => + { + self.invalidate(CacheInvalidation::List(id)); + if (current.aliases != new.aliases) + || (current.name != new.name) + || (current.domain_id != new.domain_id) + { + self.invalidate_negative_email(&new_object.inner); + } + } + _ => {} + } + } + + pub fn process_delete(&mut self, id: Id, object: &Object) { + let id = id.document_id(); + match &object.inner { + ObjectInner::Account(_) => { + self.invalidate(CacheInvalidation::AccessToken(id)); + self.invalidate(CacheInvalidation::Account(id)); + self.invalidate(CacheInvalidation::DavResources(id)); + } + ObjectInner::Domain(_) => { + self.invalidate(CacheInvalidation::Domain(id)); + self.invalidate(CacheInvalidation::DomainLogo(id)); + } + ObjectInner::DkimSignature(object) => { + self.invalidate(CacheInvalidation::DkimSignature( + object.domain_id().document_id(), + )); + } + ObjectInner::Tenant(_) => { + self.invalidate(CacheInvalidation::Tenant(id)); + self.invalidate(CacheInvalidation::TenantLogo(id)); + } + ObjectInner::Role(_) => { + self.invalidate(CacheInvalidation::Role(id)); + } + ObjectInner::MailingList(_) => { + self.invalidate(CacheInvalidation::List(id)); + } + _ => {} + } + } + + pub fn process_create(&mut self, object: &Object) { + if matches!(&object.inner, ObjectInner::Domain(_)) { + self.invalidate(CacheInvalidation::DomainNegative); + } + self.invalidate_negative_email(&object.inner); + } + + fn invalidate_negative_email(&mut self, object: &ObjectInner) { + let (name, domain_id, aliases) = match object { + ObjectInner::Account(Account::User(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::Account(Account::Group(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::MailingList(list) => (&list.name, list.domain_id, &list.aliases), + _ => return, + }; + + self.invalidate(CacheInvalidation::EmailNegative { + domain_id: domain_id.document_id(), + local_part_hash: hash_local_part(name), + }); + for alias in aliases.iter().filter(|alias: &&EmailAlias| alias.enabled) { + self.invalidate(CacheInvalidation::EmailNegative { + domain_id: alias.domain_id.document_id(), + local_part_hash: hash_local_part(&alias.name), + }); + } + } + + pub fn invalidate(&mut self, change: CacheInvalidation) { + self.changes.insert(change); + } + + pub fn with_invalidation(mut self, change: CacheInvalidation) -> Self { + self.invalidate(change); + self + } +} + +impl Server { + pub async fn invalidate_caches(&self, changes: CacheInvalidationBuilder) -> trc::Result<()> { + let mut changes = changes.changes; + if changes.is_empty() { + return Ok(()); + } + + // Invalidate objects linking roles + let mut role_ids = changes + .iter() + .filter_map(|change| { + if let CacheInvalidation::Role(role_id) = change { + Some(*role_id) + } else { + None + } + }) + .collect::>(); + if !role_ids.is_empty() { + let mut fetched_role_ids = AHashSet::new(); + + while let Some(role_id) = role_ids.pop() { + if fetched_role_ids.insert(role_id) { + let linked_objects = self + .registry() + .linked_objects(ObjectId::new(ObjectType::Role, role_id.into())) + .await?; + for linked_object in linked_objects { + match linked_object.object() { + ObjectType::Account => { + changes.insert(CacheInvalidation::AccessToken( + linked_object.id().document_id(), + )); + } + ObjectType::Role => { + role_ids.push(linked_object.id().document_id()); + } + _ => {} + } + } + } + } + } + + let changes = changes.into_iter().collect::>(); + self.invalidate_local_caches(&changes).await; + self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes)) + .await; + Ok(()) + } + + pub fn invalidate_all_local_caches(&self) { + self.invalidate_all_local_negative_caches(); + self.inner.cache.access_tokens.clear(); + self.inner.cache.domains.clear(); + self.inner.cache.domain_names.clear(); + self.inner.cache.emails.clear(); + self.inner.cache.tenants.clear(); + self.inner.cache.files.clear(); + self.inner.cache.contacts.clear(); + self.inner.cache.events.clear(); + self.inner.cache.scheduling.clear(); + self.inner.cache.dkim_signers.clear(); + self.inner.cache.accounts.clear(); + self.inner.cache.roles.clear(); + self.inner.cache.lists.clear(); + self.inner.data.logos.lock().clear(); + } + + pub fn invalidate_all_local_negative_caches(&self) { + self.inner.cache.domain_names_negative.clear(); + self.inner.cache.emails_negative.clear(); + } + + pub fn invalidate_local_negative_account_cache( + &self, + local_part: &str, + domain_id: u32, + ) -> bool { + self.inner + .cache + .emails_negative + .remove(&EmailAddressRef::new(local_part, domain_id)) + .is_some() + } + + pub async fn invalidate_local_caches(&self, changes: &[CacheInvalidation]) { + let cache = &self.inner.cache; + let mut negative_emails: AHashSet<(u32, u32)> = AHashSet::new(); + + for change in changes { + match change { + CacheInvalidation::AccessToken(id) => { + cache.access_tokens.remove(id); + cache.http_auth.inner().retain(|_, v| v.account_id != *id); + } + CacheInvalidation::DavResources(id) => { + cache.files.remove(id); + cache.contacts.remove(id); + cache.events.remove(id); + cache.scheduling.remove(id); + } + CacheInvalidation::Domain(id) => { + cache.domains.remove(id); + cache.dkim_signers.remove(id); + cache.domain_names.inner().retain(|_, v| v != id); + } + CacheInvalidation::Account(id) => { + cache.accounts.remove(id); + cache.emails.inner().retain(|_, v| { + !matches!( + v, + EmailCache::Account(account_id) + | EmailCache::DisabledAccountAddress(account_id) + if account_id == id + ) + }); + } + CacheInvalidation::DkimSignature(id) => { + cache.dkim_signers.remove(id); + } + CacheInvalidation::Tenant(id) => { + cache.tenants.remove(id); + } + CacheInvalidation::Role(id) => { + cache.roles.remove(id); + } + CacheInvalidation::List(id) => { + cache.lists.remove(id); + cache.emails.inner().retain(|_, v| { + !matches!( + v, + EmailCache::MailingList(list_id) + | EmailCache::DisabledListAddress(list_id) + if list_id == id + ) + }); + } + CacheInvalidation::DomainLogo(id) => { + self.inner + .data + .logos + .lock() + .retain(|_, v| v.domain_id != *id); + } + CacheInvalidation::TenantLogo(id) => { + self.inner + .data + .logos + .lock() + .retain(|_, v| v.tenant_id != Some(*id)); + } + CacheInvalidation::EmailNegative { + domain_id, + local_part_hash, + } => { + negative_emails.insert((*domain_id, *local_part_hash)); + } + CacheInvalidation::DomainNegative => { + cache.domain_names_negative.clear(); + } + } + } + + if !negative_emails.is_empty() { + cache.emails_negative.retain(|key| { + !negative_emails.contains(&(key.domain_id, hash_local_part(&key.local_part))) + }); + } + } +} + +#[inline(always)] +fn hash_local_part(local_part: &str) -> u32 { + xxhash_rust::xxh3::xxh3_64(local_part.as_bytes()) as u32 +} + +impl From for CacheInvalidationBuilder { + fn from(invalidation: CacheInvalidation) -> Self { + let mut builder = CacheInvalidationBuilder::default(); + builder.invalidate(invalidation); + builder + } +} diff --git a/crates/common/src/cache/mod.rs b/crates/common/src/cache/mod.rs new file mode 100644 index 0000000..db38fdd --- /dev/null +++ b/crates/common/src/cache/mod.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{DavResources, HttpAuthCache, MailboxCache, MessageStoreCache, UpdateLock}; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::{Semaphore, SemaphorePermit}; +use utils::cache::CacheItemWeight; + +pub mod directory; +pub mod invalidate; +pub mod principals; +pub mod reload; + +impl MailboxCache { + pub fn parent_id(&self) -> Option { + if self.parent_id != u32::MAX { + Some(self.parent_id) + } else { + None + } + } + + pub fn sort_order(&self) -> Option { + if self.sort_order != u32::MAX { + Some(self.sort_order) + } else { + None + } + } + + pub fn is_root(&self) -> bool { + self.parent_id == u32::MAX + } +} + +pub enum LockResult<'x> { + Acquired(SemaphorePermit<'x>), + Stale(SemaphorePermit<'x>), +} + +impl UpdateLock { + pub fn new() -> Self { + Self { + semaphore: Semaphore::new(1), + revision: AtomicU64::new(0), + } + } + + pub async fn acquire(&self, current_revision: u64) -> trc::Result> { + let permit = self.semaphore.acquire().await.map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + .details("Failed to acquire semaphore permit") + })?; + + if self.revision.load(Ordering::Acquire) == current_revision { + Ok(LockResult::Acquired(permit)) + } else { + Ok(LockResult::Stale(permit)) + } + } + + pub fn set_revision(&self, revision: u64) { + self.revision.store(revision, Ordering::Release); + } +} + +impl Default for UpdateLock { + fn default() -> Self { + Self::new() + } +} + +impl CacheItemWeight for MessageStoreCache { + fn weight(&self) -> u64 { + self.size + } +} + +impl CacheItemWeight for HttpAuthCache { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for DavResources { + fn weight(&self) -> u64 { + self.size + } +} diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs new file mode 100644 index 0000000..568b646 --- /dev/null +++ b/crates/common/src/cache/principals.rs @@ -0,0 +1,1009 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::{ + ACCOUNT_FLAG_ENCRYPT_ALGO_AES128, ACCOUNT_FLAG_ENCRYPT_ALGO_AES256, + ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM, ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305, + ACCOUNT_FLAG_ENCRYPT_APPEND, ACCOUNT_FLAG_ENCRYPT_METHOD_PGP, + ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME, ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER, ACCOUNT_IS_USER, + AccountCache, AccountInfo, AccountTenantIds, DOMAIN_FLAG_RELAY, DOMAIN_FLAG_SUB_ADDRESSING, + DomainCache, EmailAddress, EmailAddressRef, EmailCache, MailingListCache, PermissionsGroup, + RECOVERY_ADMIN_ID, RoleCache, TenantCache, permissions::BuildPermissions, + }, + config::smtp::auth::DkimSigners, + expr::if_block::BootstrapExprExt, + network::mta::AddressResolver, + storage::{ + ObjectQuota, TenantQuota, + encryption::{EncryptionMethod, parse_public_key}, + }, +}; +use registry::{ + schema::{ + enums::{DkimRotationStage, Locale, StorageQuota, TenantStorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + Account, DkimSignature, Domain, EncryptionAtRest, MailingList, MaskedEmail, + Permissions, PublicKey, Role, SubAddressing, Tenant, + }, + }, + types::id::ObjectId, +}; +use std::{borrow::Cow, sync::Arc}; +use store::{ + U64_LEN, + registry::{RegistryQuery, bootstrap::Bootstrap}, + write::{key::KeySerializer, now}, +}; +use trc::{AddContext, StoreEvent}; +use types::id::Id; +use utils::DomainPart; + +impl Server { + pub async fn domain(&self, domain: &str) -> trc::Result>> { + let Some(domain) = domain.to_ascii_domain() else { + return Ok(None); + }; + let domain = domain.as_ref(); + let domain_names = &self.inner.cache.domain_names; + + if let Some(domain_id) = domain_names.get(domain) { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.to_string(), + Collection = "domainName", + ); + + let result = self.domain_by_id(domain_id).await?; + if result.is_none() { + // Domain no longer exists, remove from name cache + domain_names.remove(domain); + } + Ok(result) + } else { + let domain_names_negative = &self.inner.cache.domain_names_negative; + if domain_names_negative.get(domain).is_none() { + let mut object = self + .registry() + .primary_key( + ObjectType::Domain.into(), + Property::Name, + domain.as_bytes().to_vec(), + ) + .await + .caused_by(trc::location!())?; + if object.is_none() { + object = self + .registry() + .primary_key( + ObjectType::Domain.into(), + Property::Aliases, + domain.as_bytes().to_vec(), + ) + .await + .caused_by(trc::location!())?; + } + + if let Some(domain) = object { + // Cache positive result + let domain_id = domain.id().document_id(); + let domain = self.domain_by_id(domain_id).await?; + if let Some(domain) = &domain { + for name in domain.names.iter() { + domain_names.insert(name.clone(), domain_id); + } + } + + Ok(domain) + } else { + // Cache negative result + domain_names_negative.insert( + domain.into(), + (), + self.inner.cache.negative_cache_ttl, + ); + + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain.to_string(), + Collection = "domainName", + ); + + Ok(None) + } + } else { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.to_string(), + Collection = "domainNameNegative", + ); + + Ok(None) + } + } + } + + pub async fn domain_by_id(&self, domain_id: u32) -> trc::Result>> { + match self + .inner + .cache + .domains + .get_value_or_guard_async(&domain_id) + .await + { + Ok(domain) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain_id, + Collection = "domainId", + ); + + Ok(Some(domain)) + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain_id, + Collection = "domainId", + ); + let Some(domain) = self.registry().object::(domain_id.into()).await? else { + return Ok(None); + }; + let mut flags = 0; + if domain.allow_relaying { + flags |= DOMAIN_FLAG_RELAY; + } + + + let sub_addressing_custom = match domain.sub_addressing { + SubAddressing::Enabled => { + flags |= DOMAIN_FLAG_SUB_ADDRESSING; + None + } + SubAddressing::Custom(custom) => { + flags |= DOMAIN_FLAG_SUB_ADDRESSING; + let mut bp = Bootstrap::new_uninitialized(self.registry().clone()); + let custom = bp.compile_expr( + ObjectId::new(ObjectType::Domain, domain_id.into()), + &custom.ctx_custom_rule(), + ); + if bp.errors.is_empty() { + Some(Box::new(custom)) + } else { + bp.log_errors(); + None + } + } + SubAddressing::Disabled => None, + }; + + let cache = Arc::new(DomainCache { + names: [domain.name.into_boxed_str()] + .into_iter() + .chain( + domain + .aliases + .into_iter() + .map(|alias| alias.into_boxed_str()), + ) + .collect(), + id: domain_id, + id_directory: domain.directory_id.map(|id| id.document_id()), + id_tenant: domain.member_tenant_id.map(|id| id.document_id()), + catch_all: domain.catch_all_address.map(|s| s.into_boxed_str()), + sub_addressing_custom, + flags, + }); + + let _ = guard.insert(cache.clone()); + Ok(Some(cache)) + } + } + } + + pub async fn rcpt_id_from_parts( + &self, + local_part: &str, + domain_id: u32, + ) -> trc::Result> { + let emails = &self.inner.cache.emails; + + if let Some(email) = emails.get(&EmailAddressRef::new(local_part, domain_id)) { + trc::event!( + Store(StoreEvent::CacheHit), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "email", + ); + + Ok(Some(email)) + } else { + let emails_negative = &self.inner.cache.emails_negative; + if emails_negative + .get(&EmailAddressRef::new(local_part, domain_id)) + .is_none() + { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "email", + ); + + if let Some(object) = self + .registry() + .primary_key( + None, + Property::Email, + KeySerializer::new(local_part.len() + U64_LEN) + .write(local_part.as_bytes()) + .write(domain_id as u64) + .finalize(), + ) + .await + .caused_by(trc::location!())? + { + let item_id = object.id().document_id(); + let result = match object.object() { + ObjectType::Account => { + if self + .account(item_id) + .await + .caused_by(trc::location!())? + .addresses + .iter() + .any(|address| { + address.domain_id == domain_id + && address.local_part.as_ref() == local_part + }) + { + EmailCache::Account(item_id) + } else { + EmailCache::DisabledAccountAddress(item_id) + } + } + ObjectType::MailingList => { + if let Some(list) = self.try_list(item_id).await? + && !list.addresses.iter().any(|address| { + address.domain_id == domain_id + && address.local_part.as_ref() == local_part + }) + { + EmailCache::DisabledListAddress(item_id) + } else { + EmailCache::MailingList(item_id) + } + } + _ => { + return Err(trc::AuthEvent::Error + .into_err() + .details( + "Object with email property is not an account or mailing list.", + ) + .ctx(trc::Key::Id, object.to_string()) + .caused_by(trc::location!())); + } + }; + emails.insert(EmailAddress::new(local_part, domain_id), result); + + Ok(Some(result)) + } else { + // Cache negative result + emails_negative.insert( + EmailAddress::new(local_part, domain_id), + (), + self.inner.cache.negative_cache_ttl, + ); + Ok(None) + } + } else { + trc::event!( + Store(StoreEvent::CacheHit), + Key = local_part.to_string(), + Domain = domain_id, + Collection = "emailNegative", + ); + Ok(None) + } + } + } + + pub async fn rcpt_id_from_email(&self, address: &str) -> trc::Result> { + let address = address.to_canonical_address(); + if let Some((local_part, domain)) = address.split_once('@') { + if let Some(domain) = self.domain(domain).await? { + self.rcpt_id_from_parts(local_part, domain.id).await + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + pub async fn account(&self, account_id: u32) -> trc::Result> { + if let Some(account) = self.try_account(account_id).await? { + Ok(account) + } else if account_id == RECOVERY_ADMIN_ID { + Ok(Arc::new(AccountCache { + name: self + .registry() + .recovery_admin() + .map(|(name, _)| name.as_str()) + .unwrap_or("recovery-admin") + .into(), + id: RECOVERY_ADMIN_ID, + addresses: Default::default(), + id_tenant: Default::default(), + id_member_of: Default::default(), + quota_disk: Default::default(), + quota_objects: Default::default(), + description: Some("Recovery admin account".into()), + encryption_key: Default::default(), + locale: Default::default(), + flags: Default::default(), + })) + } else { + Err(trc::AuthEvent::Error + .into_err() + .details("Account not found.") + .ctx(trc::Key::AccountId, account_id) + .caused_by(trc::location!())) + } + } + + pub async fn try_account(&self, account_id: u32) -> trc::Result>> { + match self + .inner + .cache + .accounts + .get_value_or_guard_async(&account_id) + .await + { + Ok(account) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = account_id, + Collection = "account", + ); + + Ok(Some(account)) + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = account_id, + Collection = "account", + ); + + let Some(account) = self.registry().object::(account_id.into()).await? + else { + return Ok(None); + }; + + let cache = Arc::new(match account { + Account::User(account) => { + let domain = self + .domain_by_id(account.domain_id.document_id()) + .await? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Domain not found for user account.") + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, account.domain_id.document_id()) + .caused_by(trc::location!()) + })?; + let mut name = + String::with_capacity(domain.names[0].len() + account.name.len() + 1); + name.push_str(account.name.as_ref()); + name.push('@'); + name.push_str(domain.names[0].as_ref()); + + let mut quota_objects: Option = None; + let mut quota_disk = 0; + for (resource, limit) in account.quotas { + if resource == StorageQuota::MaxDiskQuota { + quota_disk = limit; + } else { + quota_objects + .get_or_insert_with(|| self.core.email.max_objects.clone()) + .set(resource, limit as u32); + } + } + + let mut flags = ACCOUNT_IS_USER; + let encryption_settings = match account.encryption_at_rest { + EncryptionAtRest::Disabled => None, + EncryptionAtRest::Aes256(settings) => { + flags |= ACCOUNT_FLAG_ENCRYPT_ALGO_AES256; + settings.into() + } + EncryptionAtRest::Aes128(settings) => { + flags |= ACCOUNT_FLAG_ENCRYPT_ALGO_AES128; + settings.into() + } + EncryptionAtRest::Aes256Gcm(settings) => { + flags |= ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM; + settings.into() + } + EncryptionAtRest::ChaCha20Poly1305(settings) => { + flags |= ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305; + settings.into() + } + }; + let encryption_key = if let Some(settings) = encryption_settings { + if settings.allow_spam_training { + flags |= ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER; + } + if settings.encrypt_on_append { + flags |= ACCOUNT_FLAG_ENCRYPT_APPEND; + } + if let Some(public_key) = self + .registry() + .object::(settings.public_key) + .await + .caused_by(trc::location!())? + { + parse_public_key(&public_key) + .unwrap_or_default() + .map(|params| { + match params.method { + EncryptionMethod::PGP => { + flags |= ACCOUNT_FLAG_ENCRYPT_METHOD_PGP + } + EncryptionMethod::SMIME => { + flags |= ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME + } + } + params.certs + }) + } else { + None + } + } else { + None + }; + + AccountCache { + id: account_id, + name: name.into_boxed_str(), + addresses: [EmailAddress { + local_part: account.name.into(), + domain_id: account.domain_id.document_id(), + }] + .into_iter() + .chain( + account + .aliases + .into_iter() + .filter(|alias| alias.enabled) + .map(|alias| EmailAddress { + local_part: alias.name.into(), + domain_id: alias.domain_id.document_id(), + }), + ) + .collect(), + id_tenant: account.member_tenant_id.map(|id| id.document_id()), + id_member_of: account + .member_group_ids + .into_iter() + .map(|id| id.document_id()) + .collect(), + quota_disk, + quota_objects: quota_objects.map(Box::new), + description: account.description.map(Into::into), + locale: account.locale, + encryption_key, + flags, + } + } + Account::Group(account) => { + let domain = self + .domain_by_id(account.domain_id.document_id()) + .await? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Domain not found for group account.") + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, account.domain_id.document_id()) + .caused_by(trc::location!()) + })?; + let mut name = + String::with_capacity(domain.names[0].len() + account.name.len() + 1); + name.push_str(account.name.as_ref()); + name.push('@'); + name.push_str(domain.names[0].as_ref()); + + let mut quota_objects: Option = None; + let mut quota_disk = 0; + for (resource, limit) in account.quotas { + if resource == StorageQuota::MaxDiskQuota { + quota_disk = limit; + } else { + quota_objects + .get_or_insert_with(|| self.core.email.max_objects.clone()) + .set(resource, limit as u32); + } + } + + AccountCache { + id: account_id, + name: name.into_boxed_str(), + addresses: [EmailAddress { + local_part: account.name.into(), + domain_id: account.domain_id.document_id(), + }] + .into_iter() + .chain( + account + .aliases + .into_iter() + .filter(|alias| alias.enabled) + .map(|alias| EmailAddress { + local_part: alias.name.into(), + domain_id: alias.domain_id.document_id(), + }), + ) + .collect(), + id_tenant: account.member_tenant_id.map(|id| id.document_id()), + id_member_of: Default::default(), + quota_disk, + quota_objects: quota_objects.map(Box::new), + description: account.description.map(Into::into), + encryption_key: None, + locale: account.locale, + flags: 0, + } + } + }); + + let _ = guard.insert(cache.clone()); + Ok(Some(cache)) + } + } + } + + pub async fn account_id_from_parts( + &self, + local_part: &str, + domain_id: u32, + ) -> trc::Result> { + self.rcpt_id_from_parts(local_part, domain_id) + .await + .map(|result| { + if let Some(EmailCache::Account(account_id)) = result { + Some(account_id) + } else { + None + } + }) + } + + pub async fn account_id_from_email( + &self, + address: &str, + resolve: bool, + ) -> trc::Result> { + let address = address.to_canonical_address(); + if let Some((local_part, domain)) = address.split_once('@') { + if let Some(domain) = self.domain(domain).await? { + let mut local_part = Cow::Borrowed(local_part); + if resolve { + if domain.flags & DOMAIN_FLAG_SUB_ADDRESSING != 0 { + if let Some(sub_addressing) = &domain.sub_addressing_custom { + // Custom sub-addressing resolution + if let Some(result) = self + .eval_if::( + sub_addressing, + &AddressResolver(local_part.as_ref()), + 0, + ) + .await + { + local_part = Cow::Owned(result); + } + } else if let Some((new_local_part, _)) = address.split_once('+') { + local_part = Cow::Borrowed(new_local_part); + } + } + } + + let mut result = self + .rcpt_id_from_parts(local_part.as_ref(), domain.id) + .await?; + if resolve + && result.is_none() + && let Some(catch_all) = &domain.catch_all + { + result = self.rcpt_id_from_email(catch_all).await?; + } + + Ok(result.and_then(|result| { + if let EmailCache::Account(account_id) = result { + Some(account_id) + } else { + None + } + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + pub async fn account_info(&self, id: u32) -> trc::Result { + let account = self.account(id).await?; + self.build_account_info(account).await + } + + pub async fn scheduling_account_info( + &self, + authenticated_account_id: u32, + owner_account_id: u32, + ) -> trc::Result { + let account = self.account(authenticated_account_id).await?; + let mut account_info = self.build_account_info(account).await?; + + if owner_account_id != authenticated_account_id { + let owner_account = self.account(owner_account_id).await?; + let owner_account_info = self.build_account_info(owner_account).await?; + + for address in owner_account_info.addresses { + if !account_info.addresses.contains(&address) { + account_info.addresses.push(address); + } + } + } + + Ok(account_info) + } + + pub async fn build_account_info(&self, account: Arc) -> trc::Result { + let mut addresses = + Vec::with_capacity(account.id_member_of.len() + account.addresses.len()); + for address in account.addresses.iter() { + if let Some(domain) = self.domain_by_id(address.domain_id).await? { + for name in domain.names.iter() { + let mut addr = String::with_capacity(name.len() + address.local_part.len() + 1); + addr.push_str(address.local_part.as_ref()); + addr.push('@'); + addr.push_str(name.as_ref()); + addresses.push(addr); + } + } + } + + for &group_id in &account.id_member_of { + if let Some(group) = self.try_account(group_id).await? { + for address in group.addresses.iter() { + if let Some(domain) = self.domain_by_id(address.domain_id).await? { + for name in domain.names.iter() { + let mut addr = + String::with_capacity(name.len() + address.local_part.len() + 1); + addr.push_str(address.local_part.as_ref()); + addr.push('@'); + addr.push_str(name.as_ref()); + addresses.push(addr); + } + } + } + } + } + + Ok(AccountInfo { + account_id: account.id, + account, + addresses, + }) + } + + pub async fn role(&self, id: u32) -> trc::Result> { + let cache = &self.inner.cache.roles; + match cache.get_value_or_guard_async(&id).await { + Ok(role) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "role"); + + Ok(role) + } + Err(guard) => { + trc::event!(Store(StoreEvent::CacheMiss), Key = id, Collection = "role"); + + let Some(role) = self.registry().object::(id.into()).await? else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Role not found.") + .ctx(trc::Key::Id, id) + .caused_by(trc::location!())); + }; + + let cache = Arc::new(RoleCache { + id_roles: role + .role_ids + .into_iter() + .map(|id| id.document_id()) + .collect(), + permissions: PermissionsGroup { + enabled: crate::auth::Permissions::from_permission( + role.enabled_permissions.as_slice(), + ), + disabled: crate::auth::Permissions::from_permission( + role.disabled_permissions.as_slice(), + ), + merge: false, + }, + }); + + let _ = guard.insert(cache.clone()); + Ok(cache) + } + } + } + + pub async fn tenant(&self, id: u32) -> trc::Result> { + let cache = &self.inner.cache.tenants; + match cache.get_value_or_guard_async(&id).await { + Ok(tenant) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "tenant"); + + Ok(tenant) + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = id, + Collection = "tenant" + ); + + let Some(tenant) = self.registry().object::(id.into()).await? else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Tenant not found.") + .ctx(trc::Key::Id, id) + .caused_by(trc::location!())); + }; + + let mut quota_objects: Option = None; + let mut quota_disk = 0; + for (resource, limit) in tenant.quotas { + if resource == TenantStorageQuota::MaxDiskQuota { + quota_disk = limit; + } else { + quota_objects + .get_or_insert_default() + .set(resource, limit as u32); + } + } + + // Calculate effective permissions + let permissions = match tenant.permissions { + Permissions::Inherit => None, + Permissions::Merge(permissions) => Some(Box::new( + PermissionsGroup::from(permissions).with_merge(true), + )), + Permissions::Replace(permissions) => Some(Box::new( + PermissionsGroup::from(permissions).with_merge(false), + )), + }; + + let cache = Arc::new(TenantCache { + id_roles: tenant + .roles + .role_ids() + .unwrap_or( + self.core + .network + .security + .default_role_ids_tenant + .as_slice(), + ) + .iter() + .map(|id| id.document_id()) + .collect(), + quota_disk, + quota_objects: quota_objects.map(Box::new), + permissions, + }); + + let _ = guard.insert(cache.clone()); + Ok(cache) + } + } + } + + pub async fn try_list(&self, id: u32) -> trc::Result>> { + let cache = &self.inner.cache.lists; + match cache.get_value_or_guard_async(&id).await { + Ok(list) => { + trc::event!(Store(StoreEvent::CacheHit), Key = id, Collection = "list"); + + Ok(Some(list)) + } + Err(guard) => { + trc::event!(Store(StoreEvent::CacheMiss), Key = id, Collection = "list"); + + let Some(list) = self.registry().object::(id.into()).await? else { + return Ok(None); + }; + let cache = + Arc::new(MailingListCache { + addresses: [EmailAddress { + local_part: list.name.into(), + domain_id: list.domain_id.document_id(), + }] + .into_iter() + .chain(list.aliases.into_iter().filter(|alias| alias.enabled).map( + |alias| EmailAddress { + local_part: alias.name.into(), + domain_id: alias.domain_id.document_id(), + }, + )) + .collect(), + recipients: list.recipients.into_iter().map(Into::into).collect(), + }); + let _ = guard.insert(cache.clone()); + Ok(Some(cache)) + } + } + } + + pub async fn dkim_signers(&self, domain: &str) -> trc::Result>> { + let Some(domain) = self.domain(domain).await? else { + return Ok(None); + }; + let cache = &self.inner.cache.dkim_signers; + match cache.get_value_or_guard_async(&domain.id).await { + Ok(signers) => { + trc::event!( + Store(StoreEvent::CacheHit), + Key = domain.id, + Collection = "dkimSigners", + ); + + Ok(Some(signers)) + } + Err(guard) => { + trc::event!( + Store(StoreEvent::CacheMiss), + Key = domain.id, + Collection = "dkimSigners", + ); + + let ids = self + .registry() + .query::>( + RegistryQuery::new(ObjectType::DkimSignature) + .equal(Property::DomainId, domain.id), + ) + .await?; + let domain_name = &domain.names[0]; + let mut signers = DkimSigners { + dkim1: Vec::with_capacity(ids.len()), + dkim2: None, + }; + for id in ids { + if let Some(signature) = self.registry().object::(id).await? + && matches!(signature.stage(), DkimRotationStage::Active) + && let Err(err) = signers.insert(domain_name.to_string(), signature).await + { + trc::error!(err.ctx(trc::Key::Id, id.id()).caused_by(trc::location!())); + } + } + + if !signers.dkim1.is_empty() || signers.dkim2.is_some() { + let signers = Arc::new(signers); + let _ = guard.insert(signers.clone()); + Ok(Some(signers)) + } else { + Ok(None) + } + } + } + } +} + +impl AccountInfo { + #[inline(always)] + pub fn account_id(&self) -> u32 { + self.account_id + } + + pub fn name(&self) -> &str { + self.account.name.as_ref() + } + + #[inline(always)] + pub fn description(&self) -> Option<&str> { + self.account.description.as_deref() + } + + #[inline(always)] + pub fn tenant_id(&self) -> Option { + self.account.id_tenant + } + + #[inline(always)] + pub fn account_tenant_ids(&self) -> AccountTenantIds { + AccountTenantIds { + account_id: self.account_id, + tenant_id: self.account.id_tenant, + } + } + + pub fn addresses(&self) -> &[String] { + &self.addresses + } + + #[inline(always)] + pub fn is_user_account(&self) -> bool { + self.account.flags & ACCOUNT_IS_USER != 0 + } + + #[inline(always)] + pub fn locale(&self) -> Locale { + self.account.locale + } + + #[inline(always)] + pub fn object_quotas(&self) -> Option<&ObjectQuota> { + self.account.quota_objects.as_deref() + } + + #[inline(always)] + pub fn account(&self) -> &AccountCache { + &self.account + } +} + +impl AccountCache { + #[inline(always)] + pub fn account_id(&self) -> u32 { + self.id + } + + #[inline(always)] + pub fn name(&self) -> &str { + self.name.as_ref() + } + + #[inline(always)] + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + #[inline(always)] + pub fn tenant_id(&self) -> Option { + self.id_tenant + } + + #[inline(always)] + pub fn is_user_account(&self) -> bool { + self.flags & ACCOUNT_IS_USER != 0 + } + + #[inline(always)] + pub fn disk_quota(&self) -> u64 { + self.quota_disk + } + + #[inline(always)] + pub fn object_quotas(&self) -> Option<&ObjectQuota> { + self.quota_objects.as_deref() + } + + #[inline(always)] + pub fn account_tenant_ids(&self) -> AccountTenantIds { + AccountTenantIds { + account_id: self.id, + tenant_id: self.id_tenant, + } + } +} diff --git a/crates/common/src/cache/reload.rs b/crates/common/src/cache/reload.rs new file mode 100644 index 0000000..7478877 --- /dev/null +++ b/crates/common/src/cache/reload.rs @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Core, Server, + config::{ + server::{Listeners, tls::parse_certificates}, + storage::Storage, + telemetry::Telemetry, + }, + ipc::{QueueEvent, RegistryChange}, + network::security::{BlockedIps, IpWithTtl}, +}; +use ahash::AHashMap; +use directory::Directories; +use registry::{ + schema::{prelude::ObjectType, structs::BlockedIp}, + types::error::{Error, Warning}, +}; +use std::sync::Arc; +use store::{LookupStores, registry::bootstrap::Bootstrap, write::now}; + +pub struct ReloadResult { + pub errors: Vec, + pub warnings: Vec, + pub replaced_core: bool, +} + +impl Server { + pub async fn reload_registry(&self, change: RegistryChange) -> trc::Result { + let mut bootstrap = Bootstrap::new(self.registry().clone()).await; + let object = match change { + RegistryChange::Insert(id) => { + if matches!(id.object(), ObjectType::BlockedIp) { + if let Some(ip) = bootstrap.get_infallible::(id.id()).await { + let expires_at = ip + .expires_at + .as_ref() + .map(|dt| dt.timestamp() as u64) + .unwrap_or(u64::MAX); + + if expires_at > now() { + let mut ips = self.inner.data.blocked_ips.write(); + if let Some(ip) = ip.address.try_to_ip() { + ips.blocked_ip_addresses + .insert(IpWithTtl::new(ip, expires_at)); + } else { + ips.blocked_ip_networks + .push(IpWithTtl::new(ip.address, expires_at)); + } + } + } + return Ok(bootstrap.into()); + } else { + id.object() + } + } + RegistryChange::Delete(id) => id.object(), + RegistryChange::Reload(object) => object, + }; + + match object { + ObjectType::Certificate => { + let mut certificates = AHashMap::new(); + parse_certificates(&mut bootstrap, &mut certificates, &mut Default::default()) + .await; + self.inner + .data + .tls_certificates + .store(Arc::new(certificates)); + } + ObjectType::MemoryLookupKey + | ObjectType::MemoryLookupKeyValue + | ObjectType::HttpLookup + | ObjectType::StoreLookup => { + let lookup = LookupStores::build(&mut bootstrap).await; + + if bootstrap.errors.is_empty() { + self.inner.data.lookup_stores.store(Arc::new(lookup.stores)); + } + } + + ObjectType::BlockedIp => { + let blocked_ips = BlockedIps::parse(&mut bootstrap).await; + if bootstrap.errors.is_empty() { + *self.inner.data.blocked_ips.write() = blocked_ips; + } + } + ObjectType::Application => { + self.inner.data.applications.reload(&mut bootstrap).await; + if bootstrap.errors.is_empty() { + self.inner.data.applications.unpack_all(self, false).await; + } + } + _ => { + // Load stores + let directory = Directories::build(&mut bootstrap).await; + let storage = &self.core.storage; + let storage = Storage { + registry: storage.registry.clone(), + data: storage.data.clone(), + blob: storage.blob.clone(), + search: storage.search.clone(), + metrics: storage.metrics.clone(), + tracing: storage.tracing.clone(), + memory: storage.memory.clone(), + coordinator: storage.coordinator.clone(), + directory: directory.default_directory, + directories: directory.directories, + }; + + // Parse tracers + let tracers = Telemetry::parse(&mut bootstrap, &storage).await; + + if bootstrap.errors.is_empty() { + let core = Box::pin(Core::parse(&mut bootstrap, storage)).await; + + if bootstrap.errors.is_empty() { + let mut servers = Listeners::parse(&mut bootstrap).await; + servers + .parse_tcp_acceptors(&mut bootstrap, self.inner.clone()) + .await; + + if bootstrap.errors.is_empty() { + // Update core + self.inner.shared_core.store(core.into()); + + // Update tracers + + #[cfg(not(feature = "enterprise"))] + tracers.update(false); + + // Reload queue settings + self.inner + .ipc + .queue_tx + .send(QueueEvent::ReloadSettings) + .await + .ok(); + + return Ok(ReloadResult { + errors: bootstrap.errors, + warnings: bootstrap.warnings, + replaced_core: true, + }); + } + } + } + } + } + + Ok(bootstrap.into()) + } +} + +impl ReloadResult { + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } + + pub fn log(&self) { + for error in &self.errors { + error.log(); + } + for warning in &self.warnings { + warning.log(); + } + } +} + +impl From for ReloadResult { + fn from(bootstrap: Bootstrap) -> Self { + Self { + errors: bootstrap.errors, + warnings: bootstrap.warnings, + replaced_core: false, + } + } +} diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs new file mode 100644 index 0000000..47c0356 --- /dev/null +++ b/crates/common/src/config/groupware.rs @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::vcard::VCardVersion; +use registry::schema::{ + enums::VCardVersion as RegistryVCardVersion, + structs::{ + AddressBook, Calendar, CalendarAlarm, CalendarScheduling, DataRetention, FileStorage, + Sharing, SystemSettings, WebDav, + }, +}; +use std::str::FromStr; +use store::registry::bootstrap::Bootstrap; +use utils::template::Template; + +#[derive(Debug, Clone, Default)] +pub struct GroupwareConfig { + // DAV settings + pub max_request_size: usize, + pub dead_property_size: Option, + pub live_property_size: usize, + pub max_lock_timeout: u64, + pub max_locks_per_user: usize, + pub max_results: usize, + pub assisted_discovery: bool, + + // Calendar settings + pub max_ical_size: usize, + pub max_ical_instances: usize, + pub max_ical_attendees_per_instance: usize, + pub default_calendar_name: Option, + pub default_calendar_display_name: Option, + pub alarms_enabled: bool, + pub alarms_minimum_interval: i64, + pub alarms_allow_external_recipients: bool, + pub alarms_from_name: String, + pub alarms_from_email: Option, + pub alarms_template: Template, + pub itip_enabled: bool, + pub itip_auto_add: bool, + pub itip_inbound_max_ical_size: usize, + pub itip_outbound_max_recipients: usize, + pub itip_http_rsvp_url: Option, + pub itip_http_rsvp_expiration: u64, + pub itip_inbox_auto_expunge: Option, + pub itip_template: Template, + + // Addressbook settings + pub max_vcard_size: usize, + pub vcard_version: VCardVersion, + pub default_addressbook_name: Option, + pub default_addressbook_display_name: Option, + + // File storage settings + pub max_file_size: usize, + + // Sharing settings + pub max_shares_per_item: usize, + pub allow_directory_query: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] +pub enum CalendarTemplateVariable { + #[default] + PageTitle, + Lang, + Dir, + Header, + Footer, + EventTitle, + EventDescription, + EventDetails, + Actions, + ActionUrl, + ActionName, + AttendeesTitle, + Attendees, + Key, + Color, + Changed, + Value, + Link, + LogoCid, + OldValue, + Rsvp, +} + +impl GroupwareConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let calendar = bp.setting_infallible::().await; + let alarm = bp.setting_infallible::().await; + let sched = bp.setting_infallible::().await; + let book = bp.setting_infallible::().await; + let dav = bp.setting_infallible::().await; + let file = bp.setting_infallible::().await; + let share = bp.setting_infallible::().await; + let dr = bp.setting_infallible::().await; + let system = bp.setting_infallible::().await; + + GroupwareConfig { + max_request_size: dav.request_max_size as usize, + dead_property_size: dav.dead_property_max_size.map(|v| v as usize), + live_property_size: dav.live_property_max_size as usize, + assisted_discovery: dav.enable_assisted_discovery, + max_lock_timeout: dav.max_lock_timeout.into_inner().as_secs(), + max_locks_per_user: dav.max_locks as usize, + max_results: dav.max_results as usize, + default_calendar_name: calendar.default_href_name, + default_calendar_display_name: calendar.default_display_name, + default_addressbook_name: book.default_href_name, + default_addressbook_display_name: book.default_display_name, + max_ical_size: calendar.max_i_calendar_size as usize, + max_ical_instances: calendar.max_recurrence_expansions as usize, + max_ical_attendees_per_instance: calendar.max_attendees as usize, + max_vcard_size: book.max_v_card_size as usize, + vcard_version: match book.v_card_version { + RegistryVCardVersion::V3 => VCardVersion::V3_0, + RegistryVCardVersion::V4 => VCardVersion::V4_0, + }, + max_file_size: file.max_size as usize, + alarms_enabled: alarm.enable, + alarms_minimum_interval: alarm.min_trigger_interval.into_inner().as_secs() as i64, + alarms_allow_external_recipients: alarm.allow_external_rcpts, + alarms_from_name: alarm.from_name, + alarms_from_email: alarm.from_email, + alarms_template: Template::parse(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-alarm.html.min" + ))) + .expect("Failed to parse calendar template"), + itip_enabled: sched.enable, + itip_auto_add: sched.auto_add_invitations, + itip_inbound_max_ical_size: sched.itip_max_size as usize, + itip_outbound_max_recipients: sched.max_recipients as usize, + itip_inbox_auto_expunge: dr + .expunge_scheduling_inbox_after + .map(|d| d.into_inner().as_secs()), + itip_http_rsvp_url: if sched.http_rsvp_enable { + if let Some(url) = sched + .http_rsvp_url + .as_deref() + .map(|v| v.trim().trim_end_matches('/')) + .filter(|v| !v.is_empty()) + { + Some(url.to_string()) + } else { + Some(format!("https://{}/calendar/rsvp", system.default_hostname)) + } + } else { + None + }, + max_shares_per_item: share.max_shares as usize, + allow_directory_query: share.allow_directory_queries, + itip_http_rsvp_expiration: sched.http_rsvp_link_expiry.into_inner().as_secs(), + itip_template: Template::parse(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-invite.html.min" + ))) + .expect("Failed to parse calendar template"), + } + } +} + +impl FromStr for CalendarTemplateVariable { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "page_title" => Ok(CalendarTemplateVariable::PageTitle), + "lang" => Ok(CalendarTemplateVariable::Lang), + "dir" => Ok(CalendarTemplateVariable::Dir), + "header" => Ok(CalendarTemplateVariable::Header), + "footer" => Ok(CalendarTemplateVariable::Footer), + "event_title" => Ok(CalendarTemplateVariable::EventTitle), + "event_description" => Ok(CalendarTemplateVariable::EventDescription), + "event_details" => Ok(CalendarTemplateVariable::EventDetails), + "action_url" => Ok(CalendarTemplateVariable::ActionUrl), + "action_name" => Ok(CalendarTemplateVariable::ActionName), + "attendees" => Ok(CalendarTemplateVariable::Attendees), + "attendees_title" => Ok(CalendarTemplateVariable::AttendeesTitle), + "key" => Ok(CalendarTemplateVariable::Key), + "value" => Ok(CalendarTemplateVariable::Value), + "link" => Ok(CalendarTemplateVariable::Link), + "logo_cid" => Ok(CalendarTemplateVariable::LogoCid), + "actions" => Ok(CalendarTemplateVariable::Actions), + "changed" => Ok(CalendarTemplateVariable::Changed), + "old_value" => Ok(CalendarTemplateVariable::OldValue), + "rsvp" => Ok(CalendarTemplateVariable::Rsvp), + "color" => Ok(CalendarTemplateVariable::Color), + _ => Err(format!("Unknown calendar template variable: {}", s)), + } + } +} + +#[cfg(test)] +mod tests { + use super::CalendarTemplateVariable; + use utils::template::Template; + + const TEMPLATES: [(&str, &str, &str); 2] = [ + ( + "calendar-invite.html", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-invite.html" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-invite.html.min" + )), + ), + ( + "calendar-alarm.html", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-alarm.html" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-alarm.html.min" + )), + ), + ]; + + // Every `{{...}}` token in a template, in order of appearance + fn tokens(contents: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + let mut rest = contents; + + while let Some((_, after)) = rest.split_once("{{") { + match after.split_once("}}") { + Some((token, tail)) => { + tokens.push(token.trim()); + rest = tail; + } + None => break, + } + } + + tokens + } + + #[test] + fn shipped_calendar_templates_parse() { + for (name, source, minified) in TEMPLATES { + Template::::parse(source) + .unwrap_or_else(|err| panic!("{name} failed to parse: {err}")); + Template::::parse(minified) + .unwrap_or_else(|err| panic!("{name}.min failed to parse: {err}")); + } + } + + #[test] + fn minified_calendar_templates_are_in_sync() { + for (name, source, minified) in TEMPLATES { + let source = tokens(source); + assert!(source.len() > 10, "{name} yielded no tokens to compare"); + assert_eq!( + source, + tokens(minified), + "{name}.min is stale, re-run resources/scripts/minify_html.sh" + ); + } + } + + #[test] + fn calendar_template_tokens_are_single_line() { + // A newline inside `{{...}}` makes the parser reject the block + for (name, source, minified) in TEMPLATES { + for (suffix, contents) in [("", source), (".min", minified)] { + for token in tokens(contents) { + assert!( + !token.contains('\n') && !token.contains('\r'), + "{name}{suffix} has a multi-line token: {token:?}" + ); + } + } + } + } +} diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs new file mode 100644 index 0000000..2d6efe6 --- /dev/null +++ b/crates/common/src/config/inner.rs @@ -0,0 +1,249 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::server::tls::build_self_signed_cert; +use crate::{ + Caches, Data, DavResource, DavResources, MailboxCache, MessageStoreCache, MessageUidCache, + TlsConnectors, + auth::{AccessTokenInner, AccountCache, DomainCache, MailingListCache, RoleCache, TenantCache}, + config::{ + mailstore::spamfilter::SpamClassifier, + server::tls::parse_certificates, + smtp::{ + auth::DkimSigners, + resolver::{Policy, Tlsa}, + }, + }, + manager::application::WebApplications, + network::security::BlockedIps, +}; +use ahash::{AHashMap, AHashSet}; +use arc_swap::ArcSwap; +use mail_auth::{MX, Parameters, RecordSet, Txt}; +use parking_lot::RwLock; +use registry::schema::{prelude::ObjectType, structs}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, +}; +use store::{LookupStores, registry::bootstrap::Bootstrap}; +use utils::{ + UnwrapFailure, + cache::{Cache, CacheWithTtl}, + snowflake::{MAX_NODE_ID, SnowflakeIdGenerator}, + tls::build_tls_connector, +}; + +impl Data { + pub async fn parse(bp: &mut Bootstrap) -> Self { + // Parse certificates + let mut certificates = AHashMap::new(); + let mut subject_names = AHashSet::new(); + parse_certificates(bp, &mut certificates, &mut subject_names).await; + if subject_names.is_empty() { + subject_names.insert("localhost".into()); + } + + // Build and test snowflake id generator + let node_id = bp.node_id(); + if node_id > MAX_NODE_ID { + panic!("Node id {node_id} exceeds {MAX_NODE_ID}, panicking to avoid data corruption"); + } + SnowflakeIdGenerator::set_node_id(node_id as u64); + let id_generator = SnowflakeIdGenerator::new(); + if !id_generator.is_valid() { + panic!("Invalid system time, panicking to avoid data corruption"); + } + + // Initialize apps + let applications = WebApplications::new(); + applications.reload(bp).await; + + let blocked_ips = BlockedIps::parse(bp).await; + let lookup_stores = LookupStores::build(bp).await; + + Data { + spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()), + tls_certificates: ArcSwap::from_pointee(certificates), + tls_self_signed_cert: build_self_signed_cert( + subject_names + .into_iter() + .map(Into::into) + .collect::>(), + ) + .or_else(|err| { + bp.build_error( + ObjectType::Certificate.singleton(), + format!("Failed to build self-signed TLS certificate: {err}"), + ); + build_self_signed_cert(vec!["localhost".to_string()]) + }) + .ok() + .map(Arc::new), + lookup_stores: ArcSwap::from_pointee(lookup_stores.stores), + blocked_ips: RwLock::new(blocked_ips), + jmap_id_gen: id_generator.clone(), + queue_id_gen: id_generator.clone(), + registry_id_gen: id_generator.clone(), + span_id_gen: id_generator, + queue_status: true.into(), + applications, + logos: Default::default(), + smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"), + asn_geo_data: Default::default(), + } + } +} + +impl Caches { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let cache = bp.setting_infallible::().await; + + Caches { + access_tokens: Cache::new_single_shard( + cache.access_tokens, + (std::mem::size_of::() + 255) as u64, + ), + http_auth: Cache::new(cache.http_auth, (50 + std::mem::size_of::()) as u64), + messages: Cache::new_single_shard( + cache.messages, + (std::mem::size_of::() + + std::mem::size_of::>() + + (1024 * std::mem::size_of::()) + + (15 * (std::mem::size_of::() + 60))) as u64, + ), + files: Cache::new_single_shard( + cache.files, + (std::mem::size_of::() + (500 * std::mem::size_of::())) + as u64, + ), + events: Cache::new_single_shard( + cache.events, + (std::mem::size_of::() + (500 * std::mem::size_of::())) + as u64, + ), + contacts: Cache::new_single_shard( + cache.contacts, + (std::mem::size_of::() + (500 * std::mem::size_of::())) + as u64, + ), + scheduling: Cache::new_single_shard( + cache.scheduling, + (std::mem::size_of::() + (500 * std::mem::size_of::())) + as u64, + ), + emails: Cache::new(cache.email_addresses, 255u64), + emails_negative: CacheWithTtl::new( + cache.email_addresses_negative, + (std::mem::size_of::() + 255) as u64, + ), + domain_names: Cache::new( + cache.domain_names, + (std::mem::size_of::() + 255) as u64, + ), + domain_names_negative: CacheWithTtl::new( + cache.domain_names_negative, + (std::mem::size_of::() + 255) as u64, + ), + domains: Cache::new( + cache.domains, + (std::mem::size_of::() + 255) as u64, + ), + accounts: Cache::new( + cache.accounts, + (std::mem::size_of::() + 255) as u64, + ), + roles: Cache::new(cache.roles, (std::mem::size_of::() + 255) as u64), + tenants: Cache::new( + cache.tenants, + (std::mem::size_of::() + 255) as u64, + ), + lists: Cache::new( + cache.mailing_lists, + (std::mem::size_of::() + 255) as u64, + ), + dkim_signers: Cache::new( + cache.dkim_signatures, + (std::mem::size_of::() + 255) as u64, + ), + dns_txt: CacheWithTtl::new(cache.dns_txt, (std::mem::size_of::() + 255) as u64), + dns_mx: CacheWithTtl::new(cache.dns_mx, ((std::mem::size_of::() + 255) * 2) as u64), + dns_ptr: CacheWithTtl::new(cache.dns_ptr, (std::mem::size_of::() + 255) as u64), + dns_ipv4: CacheWithTtl::new( + cache.dns_ipv4, + ((std::mem::size_of::() + 255) * 2) as u64, + ), + dns_ipv6: CacheWithTtl::new( + cache.dns_ipv6, + ((std::mem::size_of::() + 255) * 2) as u64, + ), + dns_tlsa: CacheWithTtl::new(cache.dns_tlsa, (std::mem::size_of::() + 255) as u64), + dns_mta_sts: CacheWithTtl::new( + cache.dns_mta_sts, + (std::mem::size_of::() + 255) as u64, + ), + dns_rbl: CacheWithTtl::new( + cache.dns_rbl, + ((std::mem::size_of::() + 255) * 2) as u64, + ), + negative_cache_ttl: cache.negative_ttl.into_inner(), + } + } + + #[allow(clippy::type_complexity)] + #[inline(always)] + pub fn build_auth_parameters( + &self, + params: T, + ) -> Parameters< + '_, + T, + CacheWithTtl, Txt>, + CacheWithTtl, RecordSet>, + CacheWithTtl, RecordSet>, + CacheWithTtl, RecordSet>, + CacheWithTtl>>, + > { + Parameters { + params, + cache_txt: Some(&self.dns_txt), + cache_mx: Some(&self.dns_mx), + cache_ptr: Some(&self.dns_ptr), + cache_ipv4: Some(&self.dns_ipv4), + cache_ipv6: Some(&self.dns_ipv6), + } + } +} + +impl Default for Data { + fn default() -> Self { + Self { + spam_classifier: Default::default(), + tls_certificates: Default::default(), + tls_self_signed_cert: Default::default(), + blocked_ips: Default::default(), + jmap_id_gen: Default::default(), + queue_id_gen: Default::default(), + span_id_gen: Default::default(), + registry_id_gen: Default::default(), + queue_status: true.into(), + applications: WebApplications::new(), + logos: Default::default(), + smtp_connectors: TlsConnectors::try_new().unwrap(), + asn_geo_data: Default::default(), + lookup_stores: Default::default(), + } + } +} + +impl TlsConnectors { + fn try_new() -> Result { + Ok(TlsConnectors { + pki_verify: build_tls_connector(false)?, + dummy_verify: build_tls_connector(true)?, + }) + } +} diff --git a/crates/common/src/config/mailstore/capabilities.rs b/crates/common/src/config/mailstore/capabilities.rs new file mode 100644 index 0000000..525614d --- /dev/null +++ b/crates/common/src/config/mailstore/capabilities.rs @@ -0,0 +1,323 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::mailstore::jmap::JmapConfig; +use ahash::AHashSet; +use calcard::icalendar::ICalendarDuration; +use jmap_proto::{ + object::{email::EmailComparator, file_node::FileNodeComparator}, + request::capability::{ + BlobCapabilities, CalendarCapabilities, Capabilities, Capability, ContactsCapabilities, + CoreCapabilities, EmptyCapabilities, FileNodeCapabilities, MailCapabilities, + PrincipalAvailabilityCapabilities, PrincipalCapabilities, SieveAccountCapabilities, + SieveSessionCapabilities, SubmissionCapabilities, WebPushCapabilities, + }, + types::date::UTCDate, +}; +use registry::{ + schema::structs::{Calendar, Email, SieveUserInterpreter}, + types::EnumImpl, +}; +use store::registry::bootstrap::Bootstrap; +use types::type_state::DataType; +use utils::map::vec_map::VecMap; + +impl JmapConfig { + pub async fn add_capabilities(&mut self, bp: &mut Bootstrap) { + // Add core capabilities + self.capabilities.session.append( + Capability::Core, + Capabilities::Core(CoreCapabilities { + max_size_upload: self.upload_max_size as u64, + max_concurrent_upload: self.upload_max_concurrent.unwrap_or(u32::MAX as u64), + max_size_request: self.request_max_size as u64, + max_concurrent_requests: self.request_max_concurrent.unwrap_or(u32::MAX as u64), + max_calls_in_request: self.request_max_calls as u64, + max_objects_in_get: self.get_max_objects as u64, + max_objects_in_set: self.set_max_objects as u64, + collation_algorithms: vec![ + "i;ascii-numeric".to_string(), + "i;ascii-casemap".to_string(), + "i;unicode-casemap".to_string(), + ], + }), + ); + + // Add email capabilities + let email = bp.setting_infallible::().await; + self.capabilities.session.append( + Capability::Mail, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Mail, + Capabilities::Mail(MailCapabilities { + max_mailboxes_per_email: None, + max_mailbox_depth: email.max_mailbox_depth, + max_size_mailbox_name: email.max_mailbox_name_length, + max_size_attachments_per_email: email.max_attachment_size, + email_query_sort_options: vec![ + EmailComparator::ReceivedAt, + EmailComparator::Size, + EmailComparator::From, + EmailComparator::To, + EmailComparator::Subject, + EmailComparator::SentAt, + EmailComparator::HasKeyword(Default::default()), + EmailComparator::AllInThreadHaveKeyword(Default::default()), + EmailComparator::SomeInThreadHaveKeyword(Default::default()), + ], + may_create_top_level_mailbox: true, + }), + ); + + // Add calendar capabilities + self.capabilities.session.append( + Capability::Calendars, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Calendars, + Capabilities::Calendar(CalendarCapabilities { + max_calendars_per_event: None, + min_date_time: UTCDate { + year: 1, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + tz_before_gmt: false, + tz_hour: 0, + tz_minute: 0, + }, + max_date_time: UTCDate { + year: 9999, + month: 12, + day: 31, + hour: 23, + minute: 59, + second: 59, + tz_before_gmt: false, + tz_hour: 0, + tz_minute: 0, + }, + max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365) + .to_string(), + max_participants_per_event: bp + .setting_infallible::() + .await + .max_attendees + .into(), + may_create_calendar: true, + }), + ); + + self.capabilities.session.append( + Capability::CalendarsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::CalendarsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add contacts capabilities + self.capabilities.session.append( + Capability::Contacts, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Contacts, + Capabilities::Contacts(ContactsCapabilities { + max_address_books_per_card: None, + may_create_address_book: true, + }), + ); + self.capabilities.session.append( + Capability::ContactsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::ContactsParse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add file node capabilities + self.capabilities.session.append( + Capability::FileNode, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::FileNode, + Capabilities::FileNode(FileNodeCapabilities { + max_file_node_depth: None, + max_size_file_node_name: 255, + forbidden_name_chars: Some("/<>:\"\\|?*".to_string()), + forbidden_node_names: Some( + [ + ".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", + "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", + "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ] + .into_iter() + .map(str::to_string) + .collect(), + ), + file_node_query_sort_options: vec![ + FileNodeComparator::Name, + FileNodeComparator::Size, + FileNodeComparator::NodeType, + ], + may_create_top_level_file_node: true, + case_insensitive_names: false, + web_trash_url: None, + web_url_template: None, + web_write_url_template: None, + }), + ); + + // Add principal capabilities + self.capabilities.session.append( + Capability::Principals, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Principals, + Capabilities::Principals(PrincipalCapabilities { + current_user_principal_id: None, + }), + ); + self.capabilities.session.append( + Capability::PrincipalsAvailability, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::PrincipalsAvailability, + Capabilities::PrincipalsAvailability(PrincipalAvailabilityCapabilities { + max_availability_duration: ICalendarDuration::from_seconds(86400 * 365).to_string(), + }), + ); + + // Add submission capabilities + self.capabilities.session.append( + Capability::Submission, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Submission, + Capabilities::Submission(SubmissionCapabilities { + max_delayed_send: 86400 * 30, + submission_extensions: VecMap::from_iter([ + ("FUTURERELEASE".to_string(), Vec::new()), + ("SIZE".to_string(), Vec::new()), + ("DSN".to_string(), Vec::new()), + ("DELIVERYBY".to_string(), Vec::new()), + ("MT-PRIORITY".to_string(), vec!["MIXER".to_string()]), + ("REQUIRETLS".to_string(), vec![]), + ]), + }), + ); + + // Add vacation response capabilities + self.capabilities.session.append( + Capability::VacationResponse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::VacationResponse, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add Sieve capabilities + let sieve = bp.setting_infallible::().await; + let disabled_capabilities = sieve + .disable_capabilities + .into_iter() + .map(|v| v.as_str()) + .collect::>(); + let mut extensions = sieve::compiler::grammar::Capability::all() + .iter() + .map(|c| c.to_string()) + .filter(|c| !disabled_capabilities.contains(c.as_str())) + .collect::>(); + extensions.sort_unstable(); + + self.capabilities.session.append( + Capability::Sieve, + Capabilities::SieveSession(SieveSessionCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Sieve, + Capabilities::SieveAccount(SieveAccountCapabilities { + max_script_name: sieve.max_script_name_length as u64, + max_script_size: sieve.max_script_size, + max_scripts: sieve.max_scripts.unwrap_or(u32::MAX as u64), + max_redirects: sieve.max_redirects, + extensions, + notification_methods: if !sieve.allowed_notify_uris.is_empty() { + sieve.allowed_notify_uris.into_inner().into() + } else { + None + }, + ext_lists: None, + }), + ); + + // Add Blob capabilities + self.capabilities.session.append( + Capability::Blob, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Blob, + Capabilities::Blob(BlobCapabilities { + max_size_blob_set: (self.request_max_size as u64 * 3 / 4) - 512, + max_data_sources: self.request_max_calls as u64, + supported_type_names: vec![ + DataType::Email, + DataType::Thread, + DataType::SieveScript, + ], + supported_digest_algorithms: vec!["sha", "sha-256", "sha-512"], + }), + ); + + // Add Quota capabilities + self.capabilities.session.append( + Capability::Quota, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::Quota, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add Email Delivery Push capabilities + self.capabilities.session.append( + Capability::EmailPush, + Capabilities::Empty(EmptyCapabilities::default()), + ); + self.capabilities.account.insert( + Capability::EmailPush, + Capabilities::Empty(EmptyCapabilities::default()), + ); + + // Add Web Push VAPID capabilities + if let Some(application_server_key) = self + .vapid + .as_ref() + .map(|vapid| vapid.public_key().to_string()) + { + self.capabilities.session.append( + Capability::WebPushVapid, + Capabilities::WebPush(WebPushCapabilities { + application_server_key, + }), + ); + } + } +} diff --git a/crates/common/src/config/mailstore/email.rs b/crates/common/src/config/mailstore/email.rs new file mode 100644 index 0000000..20b3883 --- /dev/null +++ b/crates/common/src/config/mailstore/email.rs @@ -0,0 +1,298 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::{AHashMap, AHashSet}; +use nlp::language::Language; +use registry::{ + schema::{ + enums::{ + CompressionAlgo, SearchCalendarField, SearchContactField, SearchEmailField, + StorageQuota, + }, + prelude::ObjectType, + structs::{ + AddressBook, Authentication, Calendar, DataRetention, Domain, Email, FileStorage, Jmap, + Search, SieveUserInterpreter, SystemSettings, + }, + }, + types::EnumImpl, +}; +use std::time::Duration; +use store::{ + registry::bootstrap::Bootstrap, + search::{CalendarSearchField, ContactSearchField, EmailSearchField, SearchField}, + write::SearchIndex, +}; +use types::special_use::SpecialUse; +use utils::cron::SimpleCron; + +use crate::storage::ObjectQuota; + +#[derive(Clone)] +pub struct EmailConfig { + pub default_language: Language, + pub default_domain_id: u32, + pub default_domain_name: String, + + pub mailbox_max_depth: usize, + pub mailbox_name_max_len: usize, + + pub mail_attachments_max_size: usize, + pub mail_max_size: usize, + pub mail_autoexpunge_after: Option, + pub email_submission_autoexpunge_after: Option, + + pub changes_max_history: Option, + pub share_notification_max_history: Option, + + pub sieve_max_script_name: usize, + + pub default_folders: Vec, + pub shared_folder: String, + + pub encrypt: bool, + pub encrypt_append: bool, + + pub index_batch_size: usize, + pub index_fields: AHashMap>, + + pub max_objects: ObjectQuota, + pub compression: CompressionAlgo, + + pub account_purge_frequency: SimpleCron, + pub data_purge_frequency: SimpleCron, + pub blob_purge_frequency: SimpleCron, +} + +#[derive(Clone, Debug)] +pub struct DefaultFolder { + pub name: String, + pub aliases: Vec, + pub special_use: SpecialUse, + pub subscribe: bool, + pub create: bool, +} + +impl EmailConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let email = bp.setting_infallible::().await; + let dr = bp.setting_infallible::().await; + let sieve = bp.setting_infallible::().await; + let search = bp.setting_infallible::().await; + let jmap = bp.setting_infallible::().await; + let file = bp.setting_infallible::().await; + let calendar = bp.setting_infallible::().await; + let address_book = bp.setting_infallible::().await; + let system = bp.setting_infallible::().await; + let auth = bp.setting_infallible::().await; + + // Obtain default domain name + let default_domain_name = if system.default_domain_id.is_valid() + && let Some(default_domain) = + bp.get_infallible::(system.default_domain_id).await + { + default_domain.name + } else { + if system.default_domain_id.is_valid() { + bp.build_error( + ObjectType::SystemSettings.singleton(), + format!( + "Default domain with ID {} not found", + system.default_domain_id + ), + ); + } + "localhost.local".to_string() + }; + + // Parse default object quotas + let mut max_objects = ObjectQuota::default(); + for (item, max) in [ + (StorageQuota::MaxEmails, email.max_messages), + (StorageQuota::MaxMailboxes, email.max_mailboxes), + (StorageQuota::MaxSieveScripts, sieve.max_scripts), + (StorageQuota::MaxEmailIdentities, email.max_identities), + (StorageQuota::MaxEmailSubmissions, email.max_submissions), + (StorageQuota::MaxMaskedAddresses, email.max_masked_addresses), + (StorageQuota::MaxAppPasswords, auth.max_app_passwords), + (StorageQuota::MaxApiKeys, auth.max_api_keys), + (StorageQuota::MaxPublicKeys, email.max_public_keys), + (StorageQuota::MaxPushSubscriptions, jmap.max_subscriptions), + (StorageQuota::MaxCalendars, calendar.max_calendars), + (StorageQuota::MaxCalendarEvents, calendar.max_events), + ( + StorageQuota::MaxParticipantIdentities, + calendar.max_participant_identities, + ), + ( + StorageQuota::MaxCalendarEventNotifications, + calendar.max_event_notifications, + ), + ( + StorageQuota::MaxAddressBooks, + address_book.max_address_books, + ), + (StorageQuota::MaxContactCards, address_book.max_contacts), + (StorageQuota::MaxFiles, file.max_files), + (StorageQuota::MaxFolders, file.max_folders), + ] { + if let Some(max) = max { + max_objects.set(item, max as u32); + } + } + + // Parse default folders + let mut default_folders = Vec::new(); + let mut shared_folder = "Shared Folders".to_string(); + for (special_use, folder) in email.default_folders { + let special_use = match special_use { + registry::schema::enums::SpecialUse::Inbox => SpecialUse::Inbox, + registry::schema::enums::SpecialUse::Trash => SpecialUse::Trash, + registry::schema::enums::SpecialUse::Junk => SpecialUse::Junk, + registry::schema::enums::SpecialUse::Drafts => SpecialUse::Drafts, + registry::schema::enums::SpecialUse::Archive => SpecialUse::Archive, + registry::schema::enums::SpecialUse::Sent => SpecialUse::Sent, + registry::schema::enums::SpecialUse::Important => SpecialUse::Important, + registry::schema::enums::SpecialUse::Memos => SpecialUse::Memos, + registry::schema::enums::SpecialUse::Scheduled => SpecialUse::Scheduled, + registry::schema::enums::SpecialUse::Snoozed => SpecialUse::Snoozed, + registry::schema::enums::SpecialUse::Shared => { + shared_folder = folder.name; + continue; + } + }; + default_folders.push(DefaultFolder { + name: folder.name, + aliases: folder.aliases.into_inner(), + special_use, + subscribe: folder.subscribe, + create: folder.create + || matches!( + special_use, + SpecialUse::Inbox | SpecialUse::Trash | SpecialUse::Junk + ), + }); + } + for (special_use, name) in [ + (SpecialUse::Inbox, "Inbox"), + (SpecialUse::Trash, "Deleted Items"), + (SpecialUse::Junk, "Junk Mail"), + (SpecialUse::Drafts, "Drafts"), + (SpecialUse::Sent, "Sent Items"), + ] { + if !default_folders.iter().any(|f| f.special_use == special_use) { + default_folders.push(DefaultFolder { + name: name.to_string(), + aliases: Vec::new(), + special_use, + subscribe: true, + create: true, + }); + } + } + + // Search Index settings + let mut index_fields = AHashMap::new(); + if search.index_email { + index_fields.insert( + SearchIndex::Email, + search + .index_email_fields + .into_iter() + .map(|field| { + SearchField::Email(match field { + SearchEmailField::From => EmailSearchField::From, + SearchEmailField::To => EmailSearchField::To, + SearchEmailField::Cc => EmailSearchField::Cc, + SearchEmailField::Bcc => EmailSearchField::Bcc, + SearchEmailField::Subject => EmailSearchField::Subject, + SearchEmailField::Body => EmailSearchField::Body, + SearchEmailField::Attachment => EmailSearchField::Attachment, + SearchEmailField::ReceivedAt => EmailSearchField::ReceivedAt, + SearchEmailField::SentAt => EmailSearchField::SentAt, + SearchEmailField::Size => EmailSearchField::Size, + SearchEmailField::HasAttachment => EmailSearchField::HasAttachment, + SearchEmailField::Headers => EmailSearchField::Headers, + }) + }) + .collect(), + ); + } + if search.index_contacts { + index_fields.insert( + SearchIndex::Contacts, + search + .index_contact_fields + .into_iter() + .map(|field| { + SearchField::Contact(match field { + SearchContactField::Member => ContactSearchField::Member, + SearchContactField::Kind => ContactSearchField::Kind, + SearchContactField::Name => ContactSearchField::Name, + SearchContactField::Nickname => ContactSearchField::Nickname, + SearchContactField::Organization => ContactSearchField::Organization, + SearchContactField::Email => ContactSearchField::Email, + SearchContactField::Phone => ContactSearchField::Phone, + SearchContactField::OnlineService => ContactSearchField::OnlineService, + SearchContactField::Address => ContactSearchField::Address, + SearchContactField::Note => ContactSearchField::Note, + SearchContactField::Uid => ContactSearchField::Uid, + }) + }) + .collect(), + ); + } + if search.index_calendar { + index_fields.insert( + SearchIndex::Calendar, + search + .index_calendar_fields + .into_iter() + .map(|field| { + SearchField::Calendar(match field { + SearchCalendarField::Title => CalendarSearchField::Title, + SearchCalendarField::Description => CalendarSearchField::Description, + SearchCalendarField::Location => CalendarSearchField::Location, + SearchCalendarField::Owner => CalendarSearchField::Owner, + SearchCalendarField::Attendee => CalendarSearchField::Attendee, + SearchCalendarField::Start => CalendarSearchField::Start, + SearchCalendarField::Uid => CalendarSearchField::Uid, + }) + }) + .collect(), + ); + } + + EmailConfig { + default_language: Language::from_iso_639(search.default_language.as_str()) + .unwrap_or(Language::English), + mailbox_max_depth: email.max_mailbox_depth as usize, + mailbox_name_max_len: email.max_mailbox_name_length as usize, + mail_attachments_max_size: email.max_attachment_size as usize, + mail_max_size: email.max_message_size as usize, + mail_autoexpunge_after: dr.expunge_trash_after.map(|d| d.into_inner().as_secs()), + email_submission_autoexpunge_after: dr + .expunge_submissions_after + .map(|d| d.into_inner().as_secs()), + changes_max_history: dr.max_changes_history.map(|v| v as usize), + share_notification_max_history: dr.expunge_share_notify_after.map(|v| v.into_inner()), + sieve_max_script_name: sieve.max_script_name_length as usize, + encrypt: email.encrypt_at_rest, + encrypt_append: email.encrypt_on_append, + index_batch_size: search.index_batch_size as usize, + index_fields, + max_objects, + default_folders, + shared_folder, + account_purge_frequency: dr.expunge_schedule.into(), + data_purge_frequency: dr.data_cleanup_schedule.into(), + blob_purge_frequency: dr.blob_cleanup_schedule.into(), + compression: email.compression_algorithm, + default_domain_id: system.default_domain_id.id() as u32, + default_domain_name, + } + } +} diff --git a/crates/common/src/config/mailstore/imap.rs b/crates/common/src/config/mailstore/imap.rs new file mode 100644 index 0000000..af58eee --- /dev/null +++ b/crates/common/src/config/mailstore/imap.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use registry::schema::structs::{Imap, Rate}; +use std::time::Duration; +use store::registry::bootstrap::Bootstrap; + +#[derive(Default, Clone)] +pub struct ImapConfig { + pub max_request_size: usize, + pub max_auth_failures: u32, + pub allow_plain_auth: bool, + + pub timeout_auth: Duration, + pub timeout_unauth: Duration, + pub timeout_idle: Duration, + + pub rate_requests: Option, + pub rate_concurrent: Option, + + pub max_messages_per_command: u32, + pub max_messages_per_save: u32, + pub min_uid_batch_size: u32, + pub max_uid_batches: u32, +} + +impl ImapConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let imap = bp.setting_infallible::().await; + + ImapConfig { + max_request_size: imap.max_request_size as usize, + max_auth_failures: imap.max_auth_failures as u32, + timeout_auth: imap.timeout_authenticated.into_inner(), + timeout_unauth: imap.timeout_anonymous.into_inner(), + timeout_idle: imap.timeout_idle.into_inner(), + rate_requests: imap.max_request_rate, + rate_concurrent: imap.max_concurrent, + allow_plain_auth: imap.allow_plain_text_auth, + max_messages_per_command: imap.max_messages_per_command.min(u32::MAX as u64) as u32, + max_messages_per_save: imap.max_messages_per_save.min(u32::MAX as u64) as u32, + min_uid_batch_size: imap.min_uid_batch_size.min(u32::MAX as u64) as u32, + max_uid_batches: imap.max_uid_batches.min(u32::MAX as u64) as u32, + } + } +} diff --git a/crates/common/src/config/mailstore/jmap.rs b/crates/common/src/config/mailstore/jmap.rs new file mode 100644 index 0000000..ba3350e --- /dev/null +++ b/crates/common/src/config/mailstore/jmap.rs @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::network::webpush::{Vapid, VapidKey}; +use jmap_proto::request::capability::BaseCapabilities; +use registry::schema::{prelude::ObjectType, structs::Jmap}; +use std::time::Duration; +use store::registry::bootstrap::Bootstrap; + +#[derive(Default, Clone)] +pub struct JmapConfig { + pub query_max_results: usize, + pub snippet_max_results: usize, + pub changes_max_results: usize, + + pub request_max_size: usize, + pub request_max_calls: usize, + pub request_max_concurrent: Option, + + pub get_max_objects: usize, + pub set_max_objects: usize, + + pub upload_max_size: usize, + pub upload_max_concurrent: Option, + + pub upload_tmp_quota_size: usize, + pub upload_tmp_quota_amount: usize, + pub upload_tmp_ttl: u64, + + pub mail_parse_max_items: usize, + pub contact_parse_max_items: usize, + pub calendar_parse_max_items: usize, + + pub event_source_throttle: Duration, + pub push_attempt_interval: Duration, + pub push_attempts_max: u32, + pub push_retry_interval: Duration, + pub push_timeout: Duration, + pub push_verify_timeout: Duration, + pub push_throttle: Duration, + pub push_total_shards: u32, + pub push_max_size: usize, + + pub web_socket_throttle: Duration, + pub web_socket_timeout: Duration, + pub web_socket_heartbeat: Duration, + + pub vapid: Option, + + pub capabilities: BaseCapabilities, +} + +impl JmapConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let jmap = bp.setting_infallible::().await; + let web_push_key = jmap + .web_push_key + .secret() + .await + .map_err(|err| { + bp.build_error( + ObjectType::Jmap.singleton(), + format!("Unable to retrieve Web Push key: {err}"), + ); + }) + .unwrap_or_default() + .map(|k| k.into_owned()); + let web_push_contact = jmap + .web_push_contact + .as_deref() + .and_then(crate::network::webpush::normalize_contact) + .or_else(|| { + let hostname = bp.registry.local_hostname(); + (!hostname.is_empty()).then(|| format!("mailto:postmaster@{hostname}")) + }); + + let mut jmap = JmapConfig { + query_max_results: jmap.query_max_results as usize, + changes_max_results: jmap.changes_max_results as usize, + snippet_max_results: jmap.snippet_max_results as usize, + request_max_size: jmap.max_request_size as usize, + request_max_calls: jmap.max_method_calls as usize, + request_max_concurrent: jmap.max_concurrent_requests, + get_max_objects: jmap.get_max_results as usize, + set_max_objects: jmap.set_max_objects as usize, + upload_max_size: jmap.max_upload_size as usize, + upload_max_concurrent: jmap.max_concurrent_uploads, + upload_tmp_quota_size: jmap.upload_quota as usize, + upload_tmp_quota_amount: jmap.max_upload_count as usize, + upload_tmp_ttl: jmap.upload_ttl.into_inner().as_secs().max(1), + mail_parse_max_items: jmap.parse_limit_email as usize, + contact_parse_max_items: jmap.parse_limit_contact as usize, + calendar_parse_max_items: jmap.parse_limit_event as usize, + event_source_throttle: jmap.event_source_throttle.into_inner(), + web_socket_throttle: jmap.websocket_throttle.into_inner(), + web_socket_timeout: jmap.websocket_timeout.into_inner(), + web_socket_heartbeat: jmap.websocket_heartbeat.into_inner(), + push_attempt_interval: jmap.push_attempt_wait.into_inner(), + push_attempts_max: jmap.push_max_attempts as u32, + push_retry_interval: jmap.push_retry_wait.into_inner(), + push_timeout: jmap.push_request_timeout.into_inner(), + push_verify_timeout: jmap.push_verify_timeout.into_inner(), + push_throttle: jmap.push_throttle.into_inner(), + push_total_shards: jmap.push_shards_total as u32, + push_max_size: jmap.max_push_size as usize, + vapid: None, + capabilities: BaseCapabilities::default(), + }; + + // Enable Web Push VAPID only when a signing key is configured + jmap.vapid = web_push_key + .as_deref() + .map(str::trim) + .filter(|pem| !pem.is_empty()) + .and_then(|pem| match VapidKey::from_pkcs8_pem(pem) { + Ok(key) => Some(key), + Err(err) => { + bp.build_error( + ObjectType::Jmap.singleton(), + format!("Invalid Web Push VAPID key: {err}"), + ); + None + } + }) + .map(|key| Vapid::new(key, web_push_contact)); + + // Add capabilities + jmap.add_capabilities(bp).await; + jmap + } +} diff --git a/crates/common/src/config/mailstore/mod.rs b/crates/common/src/config/mailstore/mod.rs new file mode 100644 index 0000000..cd52806 --- /dev/null +++ b/crates/common/src/config/mailstore/mod.rs @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod capabilities; +pub mod email; +pub mod imap; +pub mod jmap; +pub mod scripts; +pub mod spamfilter; diff --git a/crates/common/src/config/mailstore/scripts.rs b/crates/common/src/config/mailstore/scripts.rs new file mode 100644 index 0000000..212ea0a --- /dev/null +++ b/crates/common/src/config/mailstore/scripts.rs @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + VERSION_PUBLIC, + expr::if_block::{BootstrapExprExt, IfBlock}, + scripts::{ + functions::{register_functions_trusted, register_functions_untrusted}, + plugins::RegisterSievePlugins, + }, +}; +use ahash::AHashMap; +use registry::{ + schema::{ + prelude::ObjectType, + structs::{ + SieveSystemInterpreter, SieveSystemScript, SieveUserInterpreter, SieveUserScript, + SystemSettings, + }, + }, + types::EnumImpl, +}; +use sieve::{Compiler, Runtime, Sieve, compiler::grammar::Capability}; +use std::{collections::hash_map::Entry, sync::Arc}; +use store::registry::bootstrap::Bootstrap; + +pub struct Scripting { + pub untrusted_compiler: Compiler, + pub untrusted_runtime: Runtime, + pub trusted_runtime: Runtime, + pub trusted_compiler: Compiler, + pub max_received_headers: usize, + pub from_addr: IfBlock, + pub from_name: IfBlock, + pub return_path: IfBlock, + pub sign: IfBlock, + pub untrusted_sign: IfBlock, + pub trusted_scripts: AHashMap>, + pub untrusted_scripts: AHashMap>, + pub http_client: reqwest::Client, +} + +impl Scripting { + pub async fn parse(bp: &mut Bootstrap) -> Self { + // Parse untrusted compiler + let untrusted = bp.setting_infallible::().await; + let untrusted_sign = bp.compile_expr( + ObjectType::SieveUserInterpreter.singleton(), + &untrusted.ctx_dkim_sign_domain(), + ); + let mut fnc_map_untrusted = register_functions_untrusted().register_plugins_untrusted(); + let untrusted_compiler = Compiler::new() + .with_max_script_size(untrusted.max_script_size as usize) + .with_max_string_size(untrusted.max_string_length as usize) + .with_max_variable_name_size(untrusted.max_var_name_length as usize) + .with_max_nested_blocks(untrusted.max_nested_blocks as usize) + .with_max_nested_tests(untrusted.max_nested_tests as usize) + .with_max_nested_foreverypart(untrusted.max_nested_for_every as usize) + .with_max_match_variables(untrusted.max_match_vars as usize) + .with_max_local_variables(untrusted.max_local_vars as usize) + .with_max_header_size(untrusted.max_header_size as usize) + .with_max_includes(untrusted.max_includes as usize) + .register_functions(&mut fnc_map_untrusted); + + // Parse untrusted runtime + let mut untrusted_runtime = Runtime::new() + .with_functions(&mut fnc_map_untrusted) + .with_max_nested_includes(untrusted.max_nested_includes as usize) + .with_cpu_limit(untrusted.max_cpu_cycles as usize) + .with_max_variable_size(untrusted.max_var_size as usize) + .with_max_redirects(untrusted.max_redirects as usize) + .with_max_received_headers(usize::MAX) // This is set to usize::MAX here, but the actual limit is enforced during ingestion. + .with_max_header_size(untrusted.max_header_size as usize) + .with_max_out_messages(untrusted.max_out_messages as usize) + .with_default_vacation_expiry(untrusted.default_expiry_vacation.into_inner().as_secs()) + .with_default_duplicate_expiry( + untrusted.default_expiry_duplicate.into_inner().as_secs(), + ) + .with_capability(Capability::Expressions) + .without_capabilities( + untrusted + .disable_capabilities + .iter() + .map(|cap| cap.as_str()), + ) + .with_valid_notification_uris(untrusted.allowed_notify_uris) + .with_protected_headers(untrusted.protected_headers) + .with_vacation_default_subject(untrusted.default_subject) + .with_vacation_subject_prefix(untrusted.default_subject_prefix) + .with_env_variable("name", "Stalwart Server") + .with_env_variable("version", VERSION_PUBLIC) + .with_env_variable("location", "MS") + .with_env_variable("phase", "during"); + + // Parse trusted compiler and runtime + let mut fnc_map_trusted = register_functions_trusted().register_plugins_trusted(); + + // Allocate compiler and runtime + let trusted = bp.setting_infallible::().await; + let system = bp.setting_infallible::().await; + let local_hostname = if !system.default_hostname.is_empty() { + system.default_hostname.clone() + } else { + bp.registry.local_hostname().to_string() + }; + let trusted_compiler = Compiler::new() + .with_max_string_size(52428800) + .with_max_variable_name_size(100) + .with_max_nested_blocks(50) + .with_max_nested_tests(50) + .with_max_nested_foreverypart(10) + .with_max_local_variables(8192) + .with_max_header_size(10240) + .with_max_includes(10) + .with_no_capability_check(trusted.no_capability_check) + .register_functions(&mut fnc_map_trusted); + let mut trusted_runtime = Runtime::new() + .without_capabilities([ + Capability::FileInto, + Capability::Vacation, + Capability::VacationSeconds, + Capability::Fcc, + Capability::Mailbox, + Capability::MailboxId, + Capability::MboxMetadata, + Capability::ServerMetadata, + Capability::ImapSieve, + Capability::Duplicate, + ]) + .with_capability(Capability::Expressions) + .with_capability(Capability::While) + .with_max_variable_size(trusted.max_var_size as usize) + .with_max_header_size(10240) + .with_valid_notification_uri("mailto") + .with_functions(&mut fnc_map_trusted) + .with_max_redirects(trusted.max_redirects as usize) + .with_max_out_messages(trusted.max_out_messages as usize) + .with_cpu_limit(trusted.max_cpu_cycles as usize) + .with_max_nested_includes(trusted.max_nested_includes as usize) + .with_max_received_headers(trusted.max_received_headers as usize) + .with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs()); + trusted_runtime.set_local_hostname(local_hostname.clone()); + untrusted_runtime.set_local_hostname(local_hostname); + + // Parse trusted scripts + let mut trusted_scripts: AHashMap> = AHashMap::new(); + for script in bp.list_infallible::().await { + if !script.object.is_active { + continue; + } + + match trusted_compiler.compile(script.object.contents.as_bytes()) { + Ok(compiled) => match trusted_scripts.entry(script.object.name.to_lowercase()) { + Entry::Vacant(entry) => { + entry.insert(compiled.into()); + } + Entry::Occupied(_) => { + bp.build_error( + script.id, + format!( + "Another active system Sieve script is already named {:?}, script names are case insensitive", + script.object.name + ), + ); + } + }, + Err(err) => { + bp.build_error( + script.id, + format!("Failed to compile system Sieve script: {err}"), + ); + } + } + } + + // Parse untrusted scripts + let mut untrusted_scripts: AHashMap> = AHashMap::new(); + for script in bp.list_infallible::().await { + if !script.object.is_active { + continue; + } + + match untrusted_compiler.compile(script.object.contents.as_bytes()) { + Ok(compiled) => match untrusted_scripts.entry(script.object.name.to_lowercase()) { + Entry::Vacant(entry) => { + entry.insert(compiled.into()); + } + Entry::Occupied(_) => { + bp.build_error( + script.id, + format!( + "Another active user global Sieve script is already named {:?}, script names are case insensitive", + script.object.name + ), + ); + } + }, + Err(err) => { + bp.build_error( + script.id, + format!("Failed to compile user global Sieve script: {err}"), + ); + } + } + } + + Scripting { + untrusted_compiler, + untrusted_runtime, + trusted_runtime, + trusted_compiler, + untrusted_scripts, + trusted_scripts, + http_client: utils::http::http_client_builder(cfg!(feature = "test_mode")) + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_default(), + max_received_headers: untrusted.max_received_headers as usize, + from_addr: bp.compile_expr( + ObjectType::SieveSystemInterpreter.singleton(), + &trusted.ctx_default_from_address(), + ), + from_name: bp.compile_expr( + ObjectType::SieveSystemInterpreter.singleton(), + &trusted.ctx_default_from_name(), + ), + return_path: bp.compile_expr( + ObjectType::SieveSystemInterpreter.singleton(), + &trusted.ctx_default_return_path(), + ), + sign: bp.compile_expr( + ObjectType::SieveSystemInterpreter.singleton(), + &trusted.ctx_dkim_sign_domain(), + ), + untrusted_sign, + } + } + + pub fn trusted_script(&self, name: &str) -> Option<&Arc> { + script_by_name(&self.trusted_scripts, name) + } + + pub fn untrusted_script(&self, name: &str) -> Option<&Arc> { + script_by_name(&self.untrusted_scripts, name) + } +} + +fn script_by_name<'x>( + scripts: &'x AHashMap>, + name: &str, +) -> Option<&'x Arc> { + scripts + .get(name) + .or_else(|| scripts.get(name.to_lowercase().as_str())) +} + +impl Clone for Scripting { + fn clone(&self) -> Self { + Self { + untrusted_compiler: self.untrusted_compiler.clone(), + untrusted_runtime: self.untrusted_runtime.clone(), + trusted_runtime: self.trusted_runtime.clone(), + from_addr: self.from_addr.clone(), + from_name: self.from_name.clone(), + return_path: self.return_path.clone(), + max_received_headers: self.max_received_headers, + sign: self.sign.clone(), + untrusted_sign: self.untrusted_sign.clone(), + trusted_scripts: self.trusted_scripts.clone(), + untrusted_scripts: self.untrusted_scripts.clone(), + trusted_compiler: self.trusted_compiler.clone(), + http_client: self.http_client.clone(), + } + } +} diff --git a/crates/common/src/config/mailstore/spamfilter.rs b/crates/common/src/config/mailstore/spamfilter.rs new file mode 100644 index 0000000..54a9503 --- /dev/null +++ b/crates/common/src/config/mailstore/spamfilter.rs @@ -0,0 +1,742 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::expr::{ + Variable, + functions::ResolveVariable, + if_block::{BootstrapExprExt, IfBlock}, +}; +use ahash::AHashSet; +use mail_auth::common::resolver::ToReverseName; +use nlp::classifier::model::{CcfhClassifier, FhClassifier}; +use registry::schema::{ + enums::{ExpressionVariable, ModelSize}, + prelude::ObjectType, + structs::{ + self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule, + SpamSettings, SpamTag, + }, +}; +use sieve::SpamStatus; +use std::{ + net::{IpAddr, SocketAddr}, + time::Duration, +}; +use store::registry::{RegistryObject, bootstrap::Bootstrap}; +use tokio::net::lookup_host; +use utils::{cache::CacheItemWeight, glob::GlobMap}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub enum SpamClassifier { + FhClassifier { + classifier: FhClassifier, + last_trained_at: u64, + }, + CcfhClassifier { + classifier: CcfhClassifier, + last_trained_at: u64, + }, + #[default] + Disabled, +} + +#[derive(Debug, Clone, Default)] +pub struct SpamFilterConfig { + pub enabled: bool, + pub card_is_ham: bool, + pub trusted_reply: bool, + pub grey_list_expiry: Option, + + pub dnsbl: DnsBlConfig, + pub rules: SpamFilterRules, + pub lists: SpamFilterLists, + pub pyzor: Option, + pub classifier: Option, + pub scores: SpamFilterScoreConfig, + pub spam_rules_url: Option, + pub url_client: reqwest::Client, +} + +#[derive(Debug, Clone, Default)] +pub struct SpamFilterScoreConfig { + pub reject_threshold: f32, + pub discard_threshold: f32, + pub spam_threshold: f32, +} + +impl SpamFilterScoreConfig { + pub fn spam_percentage(&self, score: f32) -> u8 { + let spam_threshold = self.spam_threshold; + if spam_threshold <= 0.0 { + return if score >= spam_threshold { 100 } else { 0 }; + } + + let max_threshold = [self.reject_threshold, self.discard_threshold] + .into_iter() + .filter(|threshold| *threshold > spam_threshold) + .min_by(f32::total_cmp) + .unwrap_or(spam_threshold * 2.0); + + if score <= 0.0 { + 0 + } else if score < spam_threshold { + ((50.0 * score / spam_threshold) as u8).min(49) + } else { + ((50.0 + 50.0 * (score - spam_threshold) / (max_threshold - spam_threshold)) as u8) + .min(100) + } + } + + pub fn is_spam(&self, score: f32) -> bool { + score >= self.spam_threshold + } +} + +pub fn spam_status(percentage: Option) -> SpamStatus { + match percentage { + Some(0) => SpamStatus::Ham, + Some(100) => SpamStatus::Spam, + Some(percentage) => SpamStatus::MaybeSpam(percentage as f64 / 100.0), + None => SpamStatus::Unknown, + } +} + +#[derive(Debug, Clone, Default)] +pub struct DnsBlConfig { + pub max_ip_checks: usize, + pub max_domain_checks: usize, + pub max_email_checks: usize, + pub max_url_checks: usize, + pub servers: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SpamFilterLists { + pub file_extensions: GlobMap, + pub scores: GlobMap>, +} + +#[derive(Debug, Clone)] +pub enum SpamFilterAction { + Allow(T), + Discard, + Reject, + Disabled, +} + +#[derive(Debug, Clone, Default)] +pub struct ClassifierConfig { + pub w_params: FtrlParameters, + pub i_params: Option, + pub reservoir_capacity: usize, + pub min_ham_samples: u64, + pub min_spam_samples: u64, + pub auto_learn_reply_ham: bool, + pub auto_learn_card_is_ham: bool, + pub auto_learn_spam_trap: bool, + pub auto_learn_spam_rbl_count: u32, + pub hold_samples_for: u64, + pub train_frequency: Option, + pub log_scale: bool, + pub l2_normalize: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct FtrlParameters { + pub feature_hash_size: usize, + pub alpha: f64, + pub beta: f64, + pub l1_ratio: f64, + pub l2_ratio: f64, +} + +#[derive(Debug, Clone)] +pub struct PyzorConfig { + pub address: SocketAddr, + pub timeout: Duration, + pub min_count: u64, + pub min_wl_count: u64, + pub ratio: f64, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SpamFilterRules { + pub url: Vec, + pub domain: Vec, + pub email: Vec, + pub ip: Vec, + pub header: Vec, + pub body: Vec, + pub any: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FileExtension { + pub known_types: AHashSet, + pub is_bad: bool, + pub is_archive: bool, + pub is_nz: bool, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Element { + Url, + Domain, + Email, + Ip, + Header, + Body, + #[default] + Any, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Location { + EnvelopeFrom, + EnvelopeTo, + HeaderDkimPass, + HeaderReceived, + HeaderFrom, + HeaderReplyTo, + HeaderSubject, + HeaderTo, + HeaderCc, + HeaderBcc, + HeaderMid, + HeaderDnt, + Ehlo, + BodyText, + BodyHtml, + Attachment, + Tcp, +} + +#[derive(Debug, Clone)] +pub struct DnsBlServer { + pub id: String, + pub zone: IfBlock, + pub scope: Element, + pub tags: IfBlock, +} + +impl SpamFilterConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let spam = bp.setting_infallible::().await; + + SpamFilterConfig { + enabled: spam.enable, + card_is_ham: spam.trust_contacts, + trusted_reply: spam.trust_replies, + dnsbl: DnsBlConfig::parse(bp).await, + rules: SpamFilterRules::parse(bp).await, + lists: SpamFilterLists::parse(bp).await, + pyzor: PyzorConfig::parse(bp).await, + classifier: ClassifierConfig::parse(bp).await, + scores: SpamFilterScoreConfig { + reject_threshold: spam.score_reject.into_inner() as f32, + discard_threshold: spam.score_discard.into_inner() as f32, + spam_threshold: spam.score_spam.into_inner() as f32, + }, + grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), + spam_rules_url: spam.spam_filter_rules_url, + url_client: utils::http::http_client_builder(true) + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/118.0") + .build() + .unwrap_or_default(), + } + } +} + +impl SpamFilterRules { + pub async fn parse(bp: &mut Bootstrap) -> SpamFilterRules { + let mut rules = vec![]; + for rule in bp.list_infallible::().await { + if let Some(rule) = SpamFilterRule::parse(bp, rule) { + rules.push(rule); + } + } + rules.sort_by_key(|a| a.priority); + + let mut result = SpamFilterRules::default(); + + for rule in rules { + match rule.scope { + Element::Url => result.url.push(rule.rule), + Element::Domain => result.domain.push(rule.rule), + Element::Email => result.email.push(rule.rule), + Element::Ip => result.ip.push(rule.rule), + Element::Header => result.header.push(rule.rule), + Element::Body => result.body.push(rule.rule), + Element::Any => result.any.push(rule.rule), + } + } + + result + } +} + +struct SpamFilterRule { + rule: IfBlock, + priority: i32, + scope: Element, +} + +impl SpamFilterRule { + pub fn parse(bp: &mut Bootstrap, obj: RegistryObject) -> Option { + match obj.object { + SpamRule::Any(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Any, + priority: rule.priority as i32, + } + .into(), + SpamRule::Url(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Url, + priority: rule.priority as i32, + } + .into(), + SpamRule::Domain(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Domain, + priority: rule.priority as i32, + } + .into(), + SpamRule::Email(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Email, + priority: rule.priority as i32, + } + .into(), + SpamRule::Ip(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Ip, + priority: rule.priority as i32, + } + .into(), + SpamRule::Header(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Header, + priority: rule.priority as i32, + } + .into(), + SpamRule::Body(rule) if rule.enable => SpamFilterRule { + rule: bp.compile_expr(obj.id, &rule.ctx_condition()), + scope: Element::Body, + priority: rule.priority as i32, + } + .into(), + _ => None, + } + } +} + +impl DnsBlConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let mut servers = vec![]; + for server in bp.list_infallible::().await { + if let Some(server) = DnsBlServer::parse(bp, server) { + servers.push(server); + } + } + + let dnsbl = bp.setting_infallible::().await; + DnsBlConfig { + max_ip_checks: dnsbl.ip_limit as usize, + max_domain_checks: dnsbl.domain_limit as usize, + max_email_checks: dnsbl.email_limit as usize, + max_url_checks: dnsbl.url_limit as usize, + servers, + } + } +} + +impl DnsBlServer { + pub fn parse(bp: &mut Bootstrap, obj: RegistryObject) -> Option { + match obj.object { + SpamDnsblServer::Any(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Any, + id: server.name, + } + .into(), + SpamDnsblServer::Url(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Url, + id: server.name, + } + .into(), + SpamDnsblServer::Domain(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Domain, + id: server.name, + } + .into(), + SpamDnsblServer::Email(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Email, + id: server.name, + } + .into(), + SpamDnsblServer::Ip(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Ip, + id: server.name, + } + .into(), + SpamDnsblServer::Header(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Header, + id: server.name, + } + .into(), + SpamDnsblServer::Body(server) if server.enable => DnsBlServer { + zone: bp.compile_expr(obj.id, &server.ctx_zone()), + tags: bp.compile_expr(obj.id, &server.ctx_tag()), + scope: Element::Body, + id: server.name, + } + .into(), + _ => None, + } + } +} + +impl SpamFilterLists { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let mut lists = SpamFilterLists { + file_extensions: GlobMap::default(), + scores: GlobMap::default(), + }; + + for tag in bp.list_infallible::().await { + match tag.object { + SpamTag::Score(tag) => lists.scores.insert_pattern( + &tag.tag, + SpamFilterAction::Allow(tag.score.into_inner() as f32), + ), + SpamTag::Discard(tag) => lists + .scores + .insert_pattern(&tag.tag, SpamFilterAction::Discard), + SpamTag::Reject(tag) => lists + .scores + .insert_pattern(&tag.tag, SpamFilterAction::Reject), + } + } + + for ext in bp.list_infallible::().await { + let ext = ext.object; + lists.file_extensions.insert_pattern( + &ext.extension, + FileExtension { + known_types: ext.content_types.into_iter().collect(), + is_bad: ext.is_bad, + is_archive: ext.is_archive, + is_nz: ext.is_nz, + }, + ); + } + + lists + } +} + +impl PyzorConfig { + pub async fn parse(bp: &mut Bootstrap) -> Option { + let pyzor = bp.setting_infallible::().await; + + if !pyzor.enable { + return None; + } + + let port = pyzor.port; + let host = pyzor.host; + let address = match lookup_host(format!("{host}:{port}")) + .await + .map(|mut a| a.next()) + { + Ok(Some(address)) => address, + Ok(None) => { + bp.build_error( + ObjectType::SpamPyzor.singleton(), + "Invalid address: No addresses found.", + ); + return None; + } + Err(err) => { + bp.build_error( + ObjectType::SpamPyzor.singleton(), + format!("Invalid address: {}", err), + ); + return None; + } + }; + + PyzorConfig { + address, + timeout: pyzor.timeout.into_inner(), + min_count: pyzor.block_count, + min_wl_count: pyzor.allow_count, + ratio: pyzor.ratio.into_inner(), + } + .into() + } +} + +impl ClassifierConfig { + pub async fn parse(bp: &mut Bootstrap) -> Option { + let classifier = bp.setting_infallible::().await; + let (log_scale, l2_normalize, w_params, i_params) = match classifier.model { + structs::SpamClassifierModel::FtrlFh(model) => ( + model.feature_log_scale, + model.feature_l2_normalize, + FtrlParameters::parse(&model.parameters), + None, + ), + structs::SpamClassifierModel::FtrlCcfh(model) => ( + model.feature_log_scale, + model.feature_l2_normalize, + FtrlParameters::parse(&model.parameters), + Some(FtrlParameters::parse(&model.indicator_parameters)), + ), + structs::SpamClassifierModel::Disabled => return None, + }; + + ClassifierConfig { + w_params, + i_params, + reservoir_capacity: classifier.reservoir_capacity as usize, + auto_learn_card_is_ham: classifier.learn_ham_from_card, + auto_learn_reply_ham: classifier.learn_ham_from_reply, + auto_learn_spam_trap: classifier.learn_spam_from_traps, + auto_learn_spam_rbl_count: classifier.learn_spam_from_rbl_hits as u32, + hold_samples_for: classifier.hold_samples_for.into_inner().as_secs(), + min_ham_samples: classifier.min_ham_samples, + min_spam_samples: classifier.min_spam_samples, + train_frequency: classifier.train_frequency.map(|d| d.into_inner().as_secs()), + log_scale, + l2_normalize, + } + .into() + } +} + +impl FtrlParameters { + pub fn parse(params: &structs::FtrlParameters) -> Self { + let hash_size = match params.num_features { + ModelSize::V16 => 16, + ModelSize::V17 => 17, + ModelSize::V18 => 18, + ModelSize::V19 => 19, + ModelSize::V20 => 20, + ModelSize::V21 => 21, + ModelSize::V22 => 22, + ModelSize::V23 => 23, + ModelSize::V24 => 24, + ModelSize::V25 => 25, + ModelSize::V26 => 26, + ModelSize::V27 => 27, + ModelSize::V28 => 28, + }; + FtrlParameters { + feature_hash_size: 1 << hash_size, + alpha: params.alpha.into_inner(), + beta: params.beta.into_inner(), + l1_ratio: params.l1_ratio.into_inner(), + l2_ratio: params.l2_ratio.into_inner(), + } + } +} + +impl SpamClassifier { + pub fn is_active(&self) -> bool { + !matches!(self, SpamClassifier::Disabled) + } +} + +impl Location { + pub fn as_str(&self) -> &'static str { + match self { + Location::EnvelopeFrom => "env_from", + Location::EnvelopeTo => "env_to", + Location::HeaderDkimPass => "dkim_pass", + Location::HeaderReceived => "received", + Location::HeaderFrom => "from", + Location::HeaderReplyTo => "reply_to", + Location::HeaderSubject => "subject", + Location::HeaderTo => "to", + Location::HeaderCc => "cc", + Location::HeaderBcc => "bcc", + Location::HeaderMid => "message_id", + Location::HeaderDnt => "dnt", + Location::Ehlo => "ehlo", + Location::BodyText => "body_text", + Location::BodyHtml => "body_html", + Location::Attachment => "attachment", + Location::Tcp => "tcp", + } + } +} + +impl Element { + pub fn as_str(&self) -> &'static str { + match self { + Element::Url => "url", + Element::Domain => "domain", + Element::Email => "email", + Element::Ip => "ip", + Element::Header => "header", + Element::Body => "body", + Element::Any => "any", + } + } +} + +pub struct IpResolver { + ip: IpAddr, + ip_string: String, + reverse: String, + octets: Variable<'static>, +} + +impl ResolveVariable for IpResolver { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::Ip | ExpressionVariable::Value => self.ip_string.as_str().into(), + ExpressionVariable::IpReverse => self.reverse.as_str().into(), + ExpressionVariable::Octets => self.octets.clone(), + ExpressionVariable::IsV4 => Variable::Integer(self.ip.is_ipv4() as _), + ExpressionVariable::IsV6 => Variable::Integer(self.ip.is_ipv6() as _), + _ => Variable::Integer(0), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl IpResolver { + pub fn new(ip: IpAddr) -> Self { + Self { + ip_string: ip.to_string(), + reverse: ip.to_reverse_name(), + octets: Variable::Array(match ip { + IpAddr::V4(ipv4_addr) => ipv4_addr + .octets() + .iter() + .map(|o| Variable::Integer(*o as _)) + .collect(), + IpAddr::V6(ipv6_addr) => ipv6_addr + .octets() + .iter() + .map(|o| Variable::Integer(*o as _)) + .collect(), + }), + ip, + } + } +} + +impl CacheItemWeight for IpResolver { + fn weight(&self) -> u64 { + (std::mem::size_of::() + self.ip_string.len() + self.reverse.len()) as u64 + } +} + +impl SpamFilterAction { + pub fn as_score(&self) -> Option<&T> { + match self { + SpamFilterAction::Allow(value) => Some(value), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(spam: f32, discard: f32, reject: f32) -> SpamFilterScoreConfig { + SpamFilterScoreConfig { + reject_threshold: reject, + discard_threshold: discard, + spam_threshold: spam, + } + } + + #[test] + fn spam_percentage_defaults() { + let config = config(5.0, 0.0, 0.0); + + for (score, expected) in [ + (-10.0, 0), + (0.0, 0), + (0.5, 5), + (2.5, 25), + (4.9, 49), + (4.999, 49), + (5.0, 50), + (7.5, 75), + (9.9, 99), + (10.0, 100), + (50.0, 100), + ] { + assert_eq!(config.spam_percentage(score), expected, "score {score}"); + } + } + + #[test] + fn spam_percentage_matches_is_spam() { + for config in [ + config(5.0, 0.0, 0.0), + config(5.0, 20.0, 15.0), + config(1.0, 0.0, 3.0), + config(12.5, 25.0, 0.0), + config(0.0, 0.0, 0.0), + ] { + for score in (-2000..=4000).map(|score| score as f32 / 100.0) { + assert_eq!( + config.spam_percentage(score) >= 50, + config.is_spam(score), + "score {score} with {config:?}" + ); + } + } + } + + #[test] + fn spam_percentage_ceiling_is_lowest_enabled_threshold() { + let reject_lowest = config(5.0, 20.0, 15.0); + assert_eq!(reject_lowest.spam_percentage(10.0), 75); + assert_eq!(reject_lowest.spam_percentage(15.0), 100); + + let discard_only = config(5.0, 15.0, 0.0); + assert_eq!(discard_only.spam_percentage(10.0), 75); + + let below_spam_threshold = config(5.0, 3.0, 0.0); + assert_eq!(below_spam_threshold.spam_percentage(7.5), 75); + } + + #[test] + fn spam_status_from_percentage() { + assert!(matches!(spam_status(None), SpamStatus::Unknown)); + assert!(matches!(spam_status(Some(0)), SpamStatus::Ham)); + assert!(matches!(spam_status(Some(100)), SpamStatus::Spam)); + assert!(matches!( + spam_status(Some(50)), + SpamStatus::MaybeSpam(fraction) if fraction == 0.5 + )); + } +} diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs new file mode 100644 index 0000000..20d09f5 --- /dev/null +++ b/crates/common/src/config/mod.rs @@ -0,0 +1,342 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::io::Cursor; + +use self::{mailstore::jmap::JmapConfig, smtp::SmtpConfig, storage::Storage}; +use crate::{ + Core, Network, + auth::oauth::config::OAuthConfig, + config::mailstore::{ + email::EmailConfig, imap::ImapConfig, scripts::Scripting, spamfilter::SpamFilterConfig, + }, +}; +use arc_swap::ArcSwap; +use groupware::GroupwareConfig; +use hyper::HeaderMap; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use pkcs8::EncodePrivateKey; +use rsa::{ + RsaPrivateKey, + pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, + pkcs8::DecodePrivateKey as _, + traits::PublicKeyParts, +}; +use store::registry::bootstrap::Bootstrap; +use telemetry::Metrics; + +pub mod groupware; +pub mod inner; +pub mod mailstore; +pub mod network; +pub mod server; +pub mod smtp; +pub mod storage; +pub mod telemetry; + +impl Core { + pub async fn parse(bp: &mut Bootstrap, mut storage: Storage) -> Self { + + Self { + sieve: Scripting::parse(bp).await, + network: Network::parse(bp).await, + smtp: Box::pin(SmtpConfig::parse(bp)).await, + jmap: JmapConfig::parse(bp).await, + imap: ImapConfig::parse(bp).await, + oauth: OAuthConfig::parse(bp).await, + metrics: Metrics::parse(bp).await, + spam: SpamFilterConfig::parse(bp).await, + email: EmailConfig::parse(bp).await, + groupware: GroupwareConfig::parse(bp).await, + storage, + } + } + + pub fn into_shared(self) -> ArcSwap { + ArcSwap::from_pointee(self) + } +} + +const RSA_MIN_MODULUS_BITS: usize = 2048; +const RSA_MAX_MODULUS_BITS: usize = 8192; + +fn no_key_found(pem: &str, expected: &str) -> String { + if pem.contains("ENCRYPTED PRIVATE KEY") || pem.contains("Proc-Type: 4,ENCRYPTED") { + format!( + "No usable {expected} private key found in PEM: the key is password-protected, \ + which is not supported. Decrypt it first with 'openssl pkcs8 -topk8 -nocrypt'." + ) + } else { + format!("No usable {expected} private key found in PEM") + } +} + +pub struct RsaSigningKey { + pub pkcs1_der: Vec, + pub modulus: Vec, + pub exponent: Vec, +} + +pub fn build_rsa_keypair(pem: &str) -> Result { + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + let key = match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + rustls_pemfile::Item::Pkcs1Key(key) => { + RsaPrivateKey::from_pkcs1_der(key.secret_pkcs1_der()) + .map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}"))? + } + rustls_pemfile::Item::Pkcs8Key(key) => { + RsaPrivateKey::from_pkcs8_der(key.secret_pkcs8_der()) + .map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}"))? + } + _ => continue, + }; + + let bits = key.n().bits(); + if !(RSA_MIN_MODULUS_BITS..=RSA_MAX_MODULUS_BITS).contains(&bits) { + return Err(format!( + "RSA key modulus is {bits} bits, expected between {RSA_MIN_MODULUS_BITS} and {RSA_MAX_MODULUS_BITS}" + )); + } + + let pkcs1_der = key + .to_pkcs1_der() + .map_err(|err| format!("Failed to encode RSA key as PKCS1: {err}"))?; + + return Ok(RsaSigningKey { + pkcs1_der: pkcs1_der.as_bytes().to_vec(), + modulus: key.n().to_bytes_be(), + exponent: key.e().to_bytes_be(), + }); + } + + Err(no_key_found(pem, "RSA")) +} + +#[derive(Clone, Copy)] +pub enum EcKeyCurve { + P256, + P384, +} + +pub struct EcdsaSigningKey { + pub pkcs8_der: Vec, + pub x: Vec, + pub y: Vec, +} + +pub fn build_ecdsa_pem(curve: EcKeyCurve, pem: &str) -> Result { + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + let pkcs8 = match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + rustls_pemfile::Item::Pkcs8Key(key) => key.secret_pkcs8_der().to_vec(), + rustls_pemfile::Item::Sec1Key(key) => curve + .sec1_to_pkcs8(key.secret_sec1_der())? + .as_bytes() + .to_vec(), + _ => continue, + }; + + let (x, y) = curve.public_coordinates(&pkcs8)?; + + return Ok(EcdsaSigningKey { + pkcs8_der: pkcs8, + x, + y, + }); + } + + Err(no_key_found(pem, "ECDSA")) +} + +impl EcKeyCurve { + fn sec1_to_pkcs8(self, der: &[u8]) -> Result { + match self { + EcKeyCurve::P256 => p256::SecretKey::from_sec1_der(der) + .map_err(|err| format!("Failed to parse SEC1 ECDSA key: {err}"))? + .to_pkcs8_der() + .map_err(|err| format!("Failed to convert SEC1 ECDSA key to PKCS8: {err}")), + EcKeyCurve::P384 => p384::SecretKey::from_sec1_der(der) + .map_err(|err| format!("Failed to parse SEC1 ECDSA key: {err}"))? + .to_pkcs8_der() + .map_err(|err| format!("Failed to convert SEC1 ECDSA key to PKCS8: {err}")), + } + } + + fn public_coordinates(self, pkcs8: &[u8]) -> Result<(Vec, Vec), String> { + use pkcs8::DecodePrivateKey; + + match self { + EcKeyCurve::P256 => { + let point = p256::SecretKey::from_pkcs8_der(pkcs8) + .map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}"))? + .public_key() + .to_encoded_point(false); + Ok(( + point.x().map(|x| x.to_vec()).unwrap_or_default(), + point.y().map(|y| y.to_vec()).unwrap_or_default(), + )) + } + EcKeyCurve::P384 => { + let point = p384::SecretKey::from_pkcs8_der(pkcs8) + .map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}"))? + .public_key() + .to_encoded_point(false); + Ok(( + point.x().map(|x| x.to_vec()).unwrap_or_default(), + point.y().map(|y| y.to_vec()).unwrap_or_default(), + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{EcKeyCurve, build_ecdsa_pem, build_rsa_keypair}; + + const P256_SEC1: &str = "-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJ9a6n/cu7XaQez5ZX8z8jDFkkfsMB1P9Vbqzbaes2zOoAoGCCqGSM49 +AwEHoUQDQgAEPCbID7bo+8Nk1vIsTFhVKwRWvb9GWTzzwS75Dd8iZuFl23Twn6Sp +V2ZO1FC0WyXxcVOMZN2sJFlCjtaQS+p5Zg== +-----END EC PRIVATE KEY-----"; + + const P256_PKCS8: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgn1rqf9y7tdpB7Pll +fzPyMMWSR+wwHU/1VurNtp6zbM6hRANCAAQ8JsgPtuj7w2TW8ixMWFUrBFa9v0ZZ +PPPBLvkN3yJm4WXbdPCfpKlXZk7UULRbJfFxU4xk3awkWUKO1pBL6nlm +-----END PRIVATE KEY-----"; + + const P384_SEC1: &str = "-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDAeecJf8ju/70Nf5nbI4DeRo/+Z3VWXUvB+GwuUczew7fyMbyc6B3EE +BskOIqvqu6egBwYFK4EEACKhZANiAAQQjDW03Xn2h9ZmmCMRx+uRaLLfg4o2XITE +pwACH9EY4IjTe9LNNp5CTjERd+RlpWxkYopmDS5Trzycz9sDxxSzzXmq90vomJqt +fTnNHPFHuR2SAiwuzUf26rcPwa7DCWk= +-----END EC PRIVATE KEY-----"; + + const P384_PKCS8: &str = "-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAeecJf8ju/70Nf5nbI +4DeRo/+Z3VWXUvB+GwuUczew7fyMbyc6B3EEBskOIqvqu6ehZANiAAQQjDW03Xn2 +h9ZmmCMRx+uRaLLfg4o2XITEpwACH9EY4IjTe9LNNp5CTjERd+RlpWxkYopmDS5T +rzycz9sDxxSzzXmq90vomJqtfTnNHPFHuR2SAiwuzUf26rcPwa7DCWk= +-----END PRIVATE KEY-----"; + + #[test] + fn ecdsa_pem_accepts_sec1_and_pkcs8() { + let sec1 = + build_ecdsa_pem(EcKeyCurve::P256, P256_SEC1).expect("P-256 SEC1 key should parse"); + let pkcs8 = + build_ecdsa_pem(EcKeyCurve::P256, P256_PKCS8).expect("P-256 PKCS8 key should parse"); + assert_eq!((&sec1.x, &sec1.y), (&pkcs8.x, &pkcs8.y)); + assert_eq!(sec1.x.len(), 32); + + let sec1 = + build_ecdsa_pem(EcKeyCurve::P384, P384_SEC1).expect("P-384 SEC1 key should parse"); + let pkcs8 = + build_ecdsa_pem(EcKeyCurve::P384, P384_PKCS8).expect("P-384 PKCS8 key should parse"); + assert_eq!((&sec1.x, &sec1.y), (&pkcs8.x, &pkcs8.y)); + assert_eq!(sec1.x.len(), 48); + } + + const RSA_PKCS1: &str = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAt5Kp7o945bxbnvviI9Kzbjcwi6B5cStu4dBbNhe/ld0Js4tQ\n8Uq9qYaFBlocYzWkEd3e2IG0+uIVB37ewUe0xjq25u6G4ZWeK+SZgzXB4jHinXvh\nuhHW+KzbmO+aYO115451Cu6ymJ8DLVaR6zxT9CJsiS4lMsYZ5JHcLY3az1A5z0df\nF+chjR+sLxdc0ggKqnX6fT/sVXHIlVk6riyeFV929k/v1f0pmRQ2nNu0NMSOK7Mk\nqsvHiAb1e/41LIwlbmbzd5ASHitYYXKP+2YR29SRr2D+52S1M29h4/XbUcP6Zo2U\np5mKgQ0kFZ8pHFhbruamzRp87+yhu98IbZ9ksQIDAQABAoIBAAu2+BGxhbNReR5U\n8Co9krZEntw2NjHG5glSkNOLoe4IIEudJyHy1VYpb7lHTFr3bBw4xrUV1+0PuuxS\nyBfZAdwJmKz1iVWBhQnDiZliN5h9+vp2UqIba9bMPypMFhO766OGh4kWUP7k3ODK\njr7Oh4QDo14AvB54nmPj/ANLM2y50/Upy5s7FK0tm0ntzxSscwQFSZAJ9B0ne6Qe\nu1/PXgiXW4JKNOgrCTrRB2BcOi/Ke6OA/kg54sD+Z9PZivO/qHTx9xXzqivmbg9a\nGmoivaWH/pKwAywFogJnWH/iTe+r//fKdlEDeK+s/iCr0ht//c0w+GxPvPF//wz0\n+1u9+n0CgYEA5M7jzpT8rCYWdORvvRP1BC4+A5jb83zXW4FS+rZRSmq775zDPAif\npm653vAlNHIphEvqSdVw64+36nJFtjuBI17BHCQi0j3iNVjrLC7lbfqIobnNDdmR\n9VeqZ6qwPYt2oi4iBY2dAnPdYVTDMomHSC4vW/SER0l9A9bxt3Co1a0CgYEAzWOQ\n490s6K186CyUMFrNrUmIWEJNd7b6JGI+oCioZLtPZzxO4ebc+bHEPbpbSqx7lJRJ\nt5u6zw/RwUc+6YXXImekvMfZpZMH9v1wjp3djnxGQO4ucmvmu6H25qcYup8tRtlo\n2AVLd1jg3yka1yr7O26M3bhVfm5LOUQfoLuCA5UCgYEA3Iw7882SfFE+RjBHMIcD\nHqOALTFzmhDU+SQAGyAP3V5ihwWg/sYFNYT3btgl1JbSQ+51B/RQIw9mJPs/DPfw\nc2qLU5fVZLg3ylpKXU1a4xaiCtmwuM/mLAnzfHd/5+L9WDiFnLqzBEEwu/fbK2R7\nXOz/w3A+7QP+F+xhFAPpCgUCgYEAsnZOIkA/UlnUi6SYir+LsYOQLihGSbw687xN\n8DoDv6sl3mz/mbhQz8GP45b21hazNrH2r8xn8J0tRATU/HIoMaPe942rZvwv0oP6\n9mDjb3g6TxbmUtPA485iy53rldTTsZkdSX6oSSZ4FlAQG2AkdkqjqdAOsVHCmRrB\nZJco7FUCgYBwk7tQt3YS5b0wi8fH3BIfAH31vJ2VGlAin860H8FXjAj8EZ7Ff9Iq\n5dQIyPbp89TOSxIVxPGniI2ruLy4DZQM7xa42oyxyRir4UeHN2P5D2yEAHaVSwSd\nO6yiiOBj62OATapI8BqeFJZGRFltDsj6XbwC/Z9S2tRKCuE/zp+FLg==\n-----END RSA PRIVATE KEY-----"; + + const RSA_PKCS8: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3kqnuj3jlvFue\n++Ij0rNuNzCLoHlxK27h0Fs2F7+V3Qmzi1DxSr2phoUGWhxjNaQR3d7YgbT64hUH\nft7BR7TGOrbm7obhlZ4r5JmDNcHiMeKde+G6Edb4rNuY75pg7XXnjnUK7rKYnwMt\nVpHrPFP0ImyJLiUyxhnkkdwtjdrPUDnPR18X5yGNH6wvF1zSCAqqdfp9P+xVcciV\nWTquLJ4VX3b2T+/V/SmZFDac27Q0xI4rsySqy8eIBvV7/jUsjCVuZvN3kBIeK1hh\nco/7ZhHb1JGvYP7nZLUzb2Hj9dtRw/pmjZSnmYqBDSQVnykcWFuu5qbNGnzv7KG7\n3whtn2SxAgMBAAECggEAC7b4EbGFs1F5HlTwKj2StkSe3DY2McbmCVKQ04uh7ggg\nS50nIfLVVilvuUdMWvdsHDjGtRXX7Q+67FLIF9kB3AmYrPWJVYGFCcOJmWI3mH36\n+nZSohtr1sw/KkwWE7vro4aHiRZQ/uTc4MqOvs6HhAOjXgC8HnieY+P8A0szbLnT\n9SnLmzsUrS2bSe3PFKxzBAVJkAn0HSd7pB67X89eCJdbgko06CsJOtEHYFw6L8p7\no4D+SDniwP5n09mK87+odPH3FfOqK+ZuD1oaaiK9pYf+krADLAWiAmdYf+JN76v/\n98p2UQN4r6z+IKvSG3/9zTD4bE+88X//DPT7W736fQKBgQDkzuPOlPysJhZ05G+9\nE/UELj4DmNvzfNdbgVL6tlFKarvvnMM8CJ+mbrne8CU0cimES+pJ1XDrj7fqckW2\nO4EjXsEcJCLSPeI1WOssLuVt+oihuc0N2ZH1V6pnqrA9i3aiLiIFjZ0Cc91hVMMy\niYdILi9b9IRHSX0D1vG3cKjVrQKBgQDNY5Dj3SzorXzoLJQwWs2tSYhYQk13tvok\nYj6gKKhku09nPE7h5tz5scQ9ultKrHuUlEm3m7rPD9HBRz7phdciZ6S8x9mlkwf2\n/XCOnd2OfEZA7i5ya+a7ofbmpxi6ny1G2WjYBUt3WODfKRrXKvs7bozduFV+bks5\nRB+gu4IDlQKBgQDcjDvzzZJ8UT5GMEcwhwMeo4AtMXOaENT5JAAbIA/dXmKHBaD+\nxgU1hPdu2CXUltJD7nUH9FAjD2Yk+z8M9/BzaotTl9VkuDfKWkpdTVrjFqIK2bC4\nz+YsCfN8d3/n4v1YOIWcurMEQTC799srZHtc7P/DcD7tA/4X7GEUA+kKBQKBgQCy\ndk4iQD9SWdSLpJiKv4uxg5AuKEZJvDrzvE3wOgO/qyXebP+ZuFDPwY/jlvbWFrM2\nsfavzGfwnS1EBNT8cigxo973jatm/C/Sg/r2YONveDpPFuZS08DjzmLLneuV1NOx\nmR1JfqhJJngWUBAbYCR2SqOp0A6xUcKZGsFklyjsVQKBgHCTu1C3dhLlvTCLx8fc\nEh8AffW8nZUaUCKfzrQfwVeMCPwRnsV/0irl1AjI9unz1M5LEhXE8aeIjau4vLgN\nlAzvFrjajLHJGKvhR4c3Y/kPbIQAdpVLBJ07rKKI4GPrY4BNqkjwGp4UlkZEWW0O\nyPpdvAL9n1La1EoK4T/On4Uu\n-----END PRIVATE KEY-----"; + + #[test] + fn rsa_pem_accepts_pkcs1_and_pkcs8() { + let a = build_rsa_keypair(RSA_PKCS1).expect("PKCS1 RSA key should parse"); + let b = build_rsa_keypair(RSA_PKCS8).expect("PKCS8 RSA key should parse"); + assert_eq!(a.modulus, b.modulus); + assert_eq!(a.exponent, b.exponent); + assert_eq!(a.pkcs1_der, b.pkcs1_der); + assert_eq!(a.modulus.len(), 256); + } + + #[test] + fn signing_keys_are_accepted_by_jsonwebtoken() { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + + #[derive(serde::Serialize)] + struct Claims { + sub: &'static str, + } + + let claims = Claims { sub: "test" }; + + for (curve, pem, alg) in [ + (EcKeyCurve::P256, P256_SEC1, Algorithm::ES256), + (EcKeyCurve::P256, P256_PKCS8, Algorithm::ES256), + (EcKeyCurve::P384, P384_SEC1, Algorithm::ES384), + (EcKeyCurve::P384, P384_PKCS8, Algorithm::ES384), + ] { + let key = build_ecdsa_pem(curve, pem).expect("key should parse"); + jsonwebtoken::encode( + &Header::new(alg), + &claims, + &EncodingKey::from_ec_der(&key.pkcs8_der), + ) + .unwrap_or_else(|err| panic!("{alg:?} signing failed: {err}")); + } + + for pem in [RSA_PKCS1, RSA_PKCS8] { + let key = build_rsa_keypair(pem).expect("key should parse"); + for alg in [Algorithm::RS256, Algorithm::PS512] { + jsonwebtoken::encode( + &Header::new(alg), + &claims, + &EncodingKey::from_rsa_der(&key.pkcs1_der), + ) + .unwrap_or_else(|err| panic!("{alg:?} signing failed: {err}")); + } + } + } + + #[test] + fn ecdsa_pem_rejects_keyless_pem() { + let err = match build_ecdsa_pem( + EcKeyCurve::P256, + "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", + ) { + Ok(_) => panic!("expected a certificate-only PEM to be rejected"), + Err(err) => err, + }; + assert!(err.contains("No usable ECDSA private key"), "{err}"); + } + + const P256_ENCRYPTED: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCoW4qsep9YbFLRW2u4\nk8ljAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQZYoEYHqh+y9uqT70\n6521jwSBkA9dcdq6hT/7Fzqcu0wX3QVr+8g1Kxc6tCV9dLShi8VU2ax8jG3zZt3h\nBp1CLyX8UfT98SujtoH36PEXPDDTralcP6vWViqGx5AagT4DRFjcI8yucTUXkLoD\n9ZIRBVPviTeznEHt3OvCCMuO76rsyu/gxNC7D46TBtq8JX1OFcaXPctpN8l5GKqH\nh4gA6av3og==\n-----END ENCRYPTED PRIVATE KEY-----"; + + #[test] + fn ecdsa_pem_reports_password_protected_key() { + let err = match build_ecdsa_pem(EcKeyCurve::P256, P256_ENCRYPTED) { + Ok(_) => panic!("expected an encrypted key to be rejected"), + Err(err) => err, + }; + assert!(err.contains("password-protected"), "{err}"); + } + + #[test] + fn rsa_pem_rejects_undersized_modulus() { + const RSA_1024: &str = "-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCz6gcAg0f+2/HFrudtMfRSylyzI8W/lNmPQZhUpz+R6D/7/+4/ +HFKsUcZIi+nzdOnrzW/kw19nVOKk2ylAUNV9d2TR75HqrPBYsu0LCDRidb9XOyhY +bQJII1KuFlaWNjfxG28Tlg//FdVPPkn/oTQwnhvMcWCK3Hatho6cx9uzWwIDAQAB +AoGBAIPvjwr1OwrOyFIrnVMaWw2LkMdd6FpCEflYJRmPPLMHGkT2vgRSBN6RaVMy +J3J9vj1J/lBIZeIlAb/baDjeDnAj5GBzCB319oxnBuZSmpyYntW1DEsdhbK0Yeu+ +7v05oXBXfzdZvGBWYrwlj5ipoHQo0R+WN4NVXqJFwiagaGBBAkEA6qwv5Pww54za +fHNUD1M6MKRBk5m0Y/GJ58sWmnmFJI6I3sHBIfcy5lylm5KecduzSKoVtAUUbNWf +KOKoZcKHMwJBAMRD2WDEd5+8q5ZxzYG0x5sEdz1lhJkt+YSbudNgfE1kPDDrCE0V +8+hgNdp6Mj1hfihwB0hTCcnaPsXLl9AyAzkCQDVU+HWD0uFso2LRGvN4qKrRSY3v +yo1EIWEqSHLG1zldo0FsqyW69jhgKcrXYWbi1TXYYaJN3Tx2t/skt7yYnv0CQDtD +NYdHq8tbAADcei5ZNRB058BtP/206SwGjbTq5H3F73rh7U7BezXGn1xKG5N3Nc3m +DfzjvgfqU5wMHtopz9kCQBw3AAiRCY8Y0UgejUtu8tXIK76qebaNcMCabBnFrAqV +A4Oj1c5BcOHVtww9W6NeiiRMJpUNN71gmyjsnOyT3cY= +-----END RSA PRIVATE KEY-----"; + + let err = match build_rsa_keypair(RSA_1024) { + Ok(_) => panic!("expected a 1024-bit RSA key to be rejected"), + Err(err) => err, + }; + assert!(err.contains("1024 bits"), "{err}"); + } +} diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs new file mode 100644 index 0000000..be4d848 --- /dev/null +++ b/crates/common/src/config/network.rs @@ -0,0 +1,555 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::{ + expr::if_block::{BootstrapExprExt, IfBlock}, + network::{ + autoconfig::pacc::{ + Authentication, Configuration, HttpServer, Info, Logo, OAuthPublic, Protocols, + Provider, TextServer, + }, + security::Security, + }, +}; +use mail_builder::mime::make_boundary; +use registry::schema::{ + enums::{AcmeChallengeType, ClusterTaskType, ProviderInfo, ServiceProtocol}, + prelude::{ObjectType, Property}, + structs::{ + self, AcmeProvider, Asn, ClusterTaskGroup, HttpForm, MailExchanger, Rate, Service, + SystemSettings, TaskManager, + }, +}; +use std::{str::FromStr, time::Duration}; +use utils::map::vec_map::VecMap; + +#[derive(Clone)] +pub struct Network { + pub node_id: u64, + pub roles: ClusterRoles, + pub server_name: String, + pub security: Security, + pub http: Http, + pub contact_form: Option, + pub asn_geo_lookup: AsnGeoLookupConfig, + pub task_manager: TaskManager, + pub has_acme_tls_challenge: bool, + pub has_acme_http_challenge: bool, + pub info: NetworkInfo, +} + +#[derive(Clone)] +pub struct NetworkInfo { + pub pacc: Pacc, + pub mxs: Vec, + pub services: VecMap, +} + +#[derive(Clone)] +pub struct Pacc { + pub prefix: String, + pub suffix: String, +} + +#[derive(Clone)] +pub struct Http { + pub rate_authenticated: Option, + pub rate_anonymous: Option, + pub url_https: String, + pub allowed_endpoint: IfBlock, + pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>, + pub use_forwarded: bool, + pub redirect_root: Option, +} + +#[derive(Clone)] +pub struct ContactForm { + pub rcpt_to: Vec, + pub max_size: usize, + pub rate: Option, + pub validate_domain: bool, + pub from_email: FieldOrDefault, + pub from_subject: FieldOrDefault, + pub from_name: FieldOrDefault, + pub field_honey_pot: Option, +} + +#[derive(Clone)] +pub struct ClusterRoles { + pub store_maintenance: bool, + pub account_maintenance: bool, + pub push_notifications: bool, + pub search_indexing: bool, + pub spam_training: bool, + pub metrics_calculate: bool, + pub metrics_push: bool, + pub outbound_mta: bool, + pub task_scheduler: bool, + pub task_manager: bool, +} + +#[derive(Clone, Default)] +pub enum AsnGeoLookupConfig { + Resource { + expires: Duration, + timeout: Duration, + max_size: usize, + headers: HeaderMap, + asn_resources: Vec, + geo_resources: Vec, + }, + Dns { + zone_ipv4: String, + zone_ipv6: String, + separator: String, + index_asn: usize, + index_asn_name: Option, + index_country: Option, + }, + #[default] + Disabled, +} + +#[derive(Clone)] +pub struct FieldOrDefault { + pub field: Option, + pub default: String, +} + +impl ContactForm { + pub async fn parse(bp: &mut Bootstrap) -> Option { + let form = bp.setting_infallible::().await; + + if !form.enable { + return None; + } else if form.deliver_to.is_empty() { + bp.build_error( + ObjectType::HttpForm.singleton(), + "Contact form is enabled but no recipient addresses are configured", + ); + return None; + } + + Some(ContactForm { + rcpt_to: form.deliver_to.into_inner(), + max_size: form.max_size as usize, + validate_domain: form.validate_domain, + from_email: FieldOrDefault { + field: form.field_email, + default: form.default_from_address, + }, + from_subject: FieldOrDefault { + field: form.field_subject, + default: form.default_subject, + }, + from_name: FieldOrDefault { + field: form.field_name, + default: form.default_name, + }, + field_honey_pot: form.field_honey_pot, + rate: form.rate_limit, + }) + } +} + +impl Network { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let system = bp.setting_infallible::().await; + let mut has_acme_tls_challenge = false; + let mut has_acme_http_challenge = false; + let mut has_acme_challenges = false; + + for provider in bp.list_infallible::().await { + match provider.object.challenge_type { + AcmeChallengeType::Http01 => has_acme_http_challenge = true, + AcmeChallengeType::TlsAlpn01 => has_acme_tls_challenge = true, + _ => {} + } + has_acme_challenges = true; + } + + if !has_acme_challenges { + // Assume this is an initial deployment and optimistically set both to true + // to avoid requiring a reload after ACME providers are added + has_acme_http_challenge = true; + has_acme_tls_challenge = true; + } + + const SPLIT_HERE: &str = "$$__SPLIT_HERE__$$"; + let mut pacc = Configuration { + protocols: Protocols::default(), + authentication: Some(Authentication { + oauth_public: Some(OAuthPublic { + issuer: SPLIT_HERE.to_string(), + }), + password: true, + }), + info: Info { + provider: Provider { + name: "Stalwart".into(), + ..Default::default() + }, + ..Default::default() + }, + }; + + let default_hostname = if !system.default_hostname.is_empty() { + system.default_hostname.as_str() + } else { + bp.registry.local_hostname() + }; + let mut http_host = default_hostname.to_string(); + for (service, details) in &system.services { + let hostname = details.hostname.as_deref().unwrap_or(default_hostname); + + match service { + ServiceProtocol::Jmap => { + if hostname != http_host { + http_host = hostname.to_string(); + } + pacc.protocols.jmap = HttpServer { + url: format!("https://{hostname}/jmap/session",), + } + .into(); + } + ServiceProtocol::Caldav => { + pacc.protocols.caldav = HttpServer { + url: format!("https://{hostname}/dav/cal/",), + } + .into(); + } + ServiceProtocol::Carddav => { + pacc.protocols.carddav = HttpServer { + url: format!("https://{hostname}/dav/card/",), + } + .into(); + } + ServiceProtocol::Webdav => { + pacc.protocols.webdav = HttpServer { + url: format!("https://{hostname}/dav/file/",), + } + .into(); + } + ServiceProtocol::Imap => { + pacc.protocols.imap = TextServer { + host: hostname.to_string(), + } + .into(); + } + ServiceProtocol::Pop3 => { + pacc.protocols.pop3 = TextServer { + host: hostname.to_string(), + } + .into(); + } + ServiceProtocol::Smtp => { + pacc.protocols.smtp = TextServer { + host: hostname.to_string(), + } + .into(); + } + ServiceProtocol::Managesieve => { + pacc.protocols.managesieve = TextServer { + host: hostname.to_string(), + } + .into(); + } + } + } + + for (tag, text) in system.provider_info { + match tag { + ProviderInfo::ProviderName => pacc.info.provider.name = text, + ProviderInfo::ProviderShortName => pacc.info.provider.short_name = Some(text), + ProviderInfo::UserDocumentation => { + pacc.info.help.get_or_insert_default().documentation = Some(text) + } + ProviderInfo::DeveloperDocumentation => { + pacc.info.help.get_or_insert_default().developer = Some(text) + } + ProviderInfo::ContactUri => { + pacc.info + .help + .get_or_insert_default() + .contact + .get_or_insert_default() + .push(text); + } + ProviderInfo::LogoUrl => { + let logo = pacc.info.provider.logo.get_or_insert_default(); + if logo.is_empty() { + logo.push(Logo { + url: text, + ..Default::default() + }); + } else { + logo[0].url = text; + } + } + ProviderInfo::LogoWidth => { + let logo = pacc.info.provider.logo.get_or_insert_default(); + if logo.is_empty() { + logo.push(Logo { + width: text.parse().ok(), + ..Default::default() + }); + } else { + logo[0].width = text.parse().ok(); + } + } + ProviderInfo::LogoHeight => { + let logo = pacc.info.provider.logo.get_or_insert_default(); + if logo.is_empty() { + logo.push(Logo { + height: text.parse().ok(), + ..Default::default() + }); + } else { + logo[0].height = text.parse().ok(); + } + } + } + } + + let (prefix, suffix) = serde_json::to_string(&pacc) + .unwrap_or_default() + .rsplit_once(SPLIT_HERE) + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + .unwrap(); + let mut network = Network { + node_id: bp.node_id() as u64, + server_name: default_hostname.to_string(), + security: Security::parse(bp).await, + contact_form: ContactForm::parse(bp).await, + asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(), + roles: ClusterRoles::default(), + http: Http::parse(bp, &http_host).await, + task_manager: bp.setting_infallible::().await, + has_acme_tls_challenge, + has_acme_http_challenge, + info: NetworkInfo { + mxs: system.mail_exchangers.into_iter().collect(), + services: system.services, + pacc: Pacc { prefix, suffix }, + }, + }; + + if let Some(role) = &bp.role { + match &role.tasks { + ClusterTaskGroup::EnableAll => {} + ClusterTaskGroup::DisableAll => { + for network_role in network.roles.all_mut() { + *network_role = false; + } + } + ClusterTaskGroup::EnableSome(group) => { + for network_role in network.roles.all_mut() { + *network_role = false; + } + for task_type in group.task_types.iter() { + network.roles.set_role(*task_type, true); + } + } + ClusterTaskGroup::DisableSome(group) => { + for task_type in group.task_types.iter() { + network.roles.set_role(*task_type, false); + } + } + } + } + + network + } + + pub fn message_id(&self) -> String { + format!("{}@{}", make_boundary("."), self.server_name) + } +} + +impl Http { + #[cfg_attr( + any(feature = "dev_mode", feature = "test_mode"), + allow(unused_variables) + )] + pub async fn parse(bp: &mut Bootstrap, server_name: &str) -> Self { + let http = bp.setting_infallible::().await; + + // Parse HTTP headers + let mut http_headers = http + .response_headers + .iter() + .map(|(k, v)| { + Ok(( + hyper::header::HeaderName::from_str(k.trim()).map_err(|err| { + format!("Invalid header found in property \"http.headers\": {}", err) + })?, + hyper::header::HeaderValue::from_str(v.trim()).map_err(|err| { + format!("Invalid header found in property \"http.headers\": {}", err) + })?, + )) + }) + .collect::, String>>() + .map_err(|e| { + bp.build_error( + ObjectType::Http.singleton(), + format!("Failed to parse HTTP headers: {}", e), + ) + }) + .unwrap_or_default(); + + // Add permissive CORS headers + #[cfg(feature = "dev_mode")] + let use_permissive_cors = true; + + #[cfg(not(feature = "dev_mode"))] + let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode(); + + if use_permissive_cors { + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, + hyper::header::HeaderValue::from_static("*"), + )); + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_HEADERS, + hyper::header::HeaderValue::from_static( + "Authorization, Content-Type, Accept, X-Requested-With", + ), + )); + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_METHODS, + hyper::header::HeaderValue::from_static( + "POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS", + ), + )); + } + + // Add HTTP Strict Transport Security + if http.enable_hsts { + http_headers.push(( + hyper::header::STRICT_TRANSPORT_SECURITY, + hyper::header::HeaderValue::from_static("max-age=31536000; includeSubDomains"), + )); + } + + #[cfg(any(feature = "dev_mode", feature = "test_mode"))] + let server_name = "127.0.0.1"; + + Http { + url_https: if !bp.registry.is_recovery_mode() { + if let Some(url) = bp.registry.public_url() { + url.to_string() + } else { + format!("https://{server_name}") + } + } else { + String::new() + }, + allowed_endpoint: if bp.registry.is_recovery_mode() { + IfBlock::empty(ObjectType::Http.singleton(), Property::AllowedEndpoints) + } else { + bp.compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints()) + }, + rate_authenticated: if bp.registry.is_recovery_mode() { + None + } else { + http.rate_limit_authenticated + }, + rate_anonymous: if bp.registry.is_recovery_mode() { + None + } else { + http.rate_limit_anonymous + }, + response_headers: http_headers, + use_forwarded: http.use_x_forwarded, + redirect_root: http.redirect_root, + } + } +} + +impl AsnGeoLookupConfig { + pub async fn parse(bp: &mut Bootstrap) -> Option { + match bp.setting_infallible::().await { + Asn::Resource(asn) => Some(AsnGeoLookupConfig::Resource { + expires: asn.expires.into_inner(), + timeout: asn.timeout.into_inner(), + max_size: asn.max_size as usize, + headers: asn + .http_auth + .build_headers(asn.http_headers, None) + .await + .map_err(|err| { + bp.build_error( + ObjectType::Asn.singleton(), + format!("Unable to build HTTP headers: {}", err), + ) + }) + .ok()?, + asn_resources: asn.asn_urls.into_inner(), + geo_resources: asn.geo_urls.into_inner(), + }), + Asn::Dns(asn) => Some(AsnGeoLookupConfig::Dns { + zone_ipv4: asn.zone_ip_v4, + zone_ipv6: asn.zone_ip_v6, + separator: asn.separator, + index_asn: asn.index_asn as usize, + index_asn_name: asn.index_asn_name.map(|v| v as usize), + index_country: asn.index_country.map(|v| v as usize), + }), + Asn::Disabled => None, + } + } +} + +impl ClusterRoles { + fn all_mut(&mut self) -> impl Iterator { + [ + &mut self.store_maintenance, + &mut self.account_maintenance, + &mut self.push_notifications, + &mut self.search_indexing, + &mut self.spam_training, + &mut self.outbound_mta, + &mut self.task_manager, + &mut self.task_scheduler, + &mut self.metrics_calculate, + &mut self.metrics_push, + ] + .into_iter() + } + + fn set_role(&mut self, role: ClusterTaskType, enabled: bool) { + match role { + ClusterTaskType::StoreMaintenance => self.store_maintenance = enabled, + ClusterTaskType::AccountMaintenance => self.account_maintenance = enabled, + ClusterTaskType::PushNotifications => self.push_notifications = enabled, + ClusterTaskType::SearchIndexing => self.search_indexing = enabled, + ClusterTaskType::SpamClassifierTraining => self.spam_training = enabled, + ClusterTaskType::MetricsCalculate => self.metrics_calculate = enabled, + ClusterTaskType::MetricsPush => self.metrics_push = enabled, + ClusterTaskType::OutboundMta => self.outbound_mta = enabled, + ClusterTaskType::TaskQueueProcessing => self.task_manager = enabled, + ClusterTaskType::TaskScheduler => self.task_scheduler = enabled, + } + } +} + +impl Default for ClusterRoles { + fn default() -> Self { + ClusterRoles { + store_maintenance: true, + account_maintenance: true, + push_notifications: true, + search_indexing: true, + spam_training: true, + metrics_calculate: true, + metrics_push: true, + outbound_mta: true, + task_manager: true, + task_scheduler: true, + } + } +} diff --git a/crates/common/src/config/server/listener.rs b/crates/common/src/config/server/listener.rs new file mode 100644 index 0000000..869015d --- /dev/null +++ b/crates/common/src/config/server/listener.rs @@ -0,0 +1,349 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + DEFAULT_TLS_TIMEOUT, Listener, Listeners, ServerProtocol, TcpListener, + tls::{TLS12_VERSION, TLS13_VERSION}, +}; +use crate::{ + Inner, + network::{TcpAcceptor, tls::CertificateResolver}, +}; +use registry::{ + schema::{ + enums::{NetworkListenerProtocol, TlsCipherSuite, TlsVersion}, + prelude::{ObjectType, SocketAddr}, + structs::{ClusterListenerGroup, NetworkListener, SystemSettings}, + }, + types::{id::ObjectId, map::Map}, +}; +use rustls::{ + ALL_VERSIONS, ServerConfig, SupportedCipherSuite, + crypto::aws_lc_rs::{ALL_CIPHER_SUITES, cipher_suite::*, default_provider}, +}; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr as StdSocketAddr}, + str::FromStr, + sync::Arc, +}; +use store::registry::{RegistryObject, bootstrap::Bootstrap}; +use tokio::net::TcpSocket; +use tokio_rustls::TlsAcceptor; +use types::id::Id; +use utils::snowflake::SnowflakeIdGenerator; + +impl Listeners { + pub async fn parse(bp: &mut Bootstrap) -> Self { + // Parse ACME managers + let mut servers = Listeners { + span_id_gen: Arc::new(SnowflakeIdGenerator::new()), + ..Default::default() + }; + + // Parse servers + if !bp.registry.is_recovery_mode() { + let system = bp.setting_infallible::().await; + for listener in bp.list_infallible::().await { + if bp.role.as_ref().is_none_or(|r| match &r.listeners { + ClusterListenerGroup::EnableAll => true, + ClusterListenerGroup::DisableAll => false, + ClusterListenerGroup::EnableSome(group) => { + group.listener_ids.iter().any(|id| *id == listener.id.id()) + } + ClusterListenerGroup::DisableSome(group) => { + !group.listener_ids.iter().any(|id| *id == listener.id.id()) + } + }) { + servers.parse_server(bp, listener, &system); + } + } + } else { + servers.parse_server( + bp, + RegistryObject { + id: ObjectId::new(ObjectType::NetworkListener, Id::singleton()), + object: NetworkListener { + bind: Map::new(vec![ + SocketAddr::from_str(&format!( + "[::]:{}", + std::env::var("STALWART_RECOVERY_MODE_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(8080) + )) + .unwrap(), + ]), + name: "http-recovery".to_string(), + protocol: NetworkListenerProtocol::Http, + tls_implicit: false, + ..Default::default() + }, + revision: 0, + }, + &SystemSettings::default(), + ); + } + servers + } + + pub fn parse_server( + &mut self, + bp: &mut Bootstrap, + listener: RegistryObject, + system: &SystemSettings, + ) { + let id = listener.id; + let revision = listener.revision; + let listener = listener.object; + + // Parse protocol + let protocol = match listener.protocol { + NetworkListenerProtocol::Smtp => ServerProtocol::Smtp, + NetworkListenerProtocol::Lmtp => ServerProtocol::Lmtp, + NetworkListenerProtocol::Http => ServerProtocol::Http, + NetworkListenerProtocol::Imap => ServerProtocol::Imap, + NetworkListenerProtocol::Pop3 => ServerProtocol::Pop3, + NetworkListenerProtocol::ManageSieve => ServerProtocol::ManageSieve, + }; + + // Build listeners + let mut listeners = Vec::new(); + for addr in listener.bind.iter() { + // Parse bind address and build socket + let mut addr = addr.0; + let socket = match if addr.is_ipv4() { + TcpSocket::new_v4() + } else { + TcpSocket::new_v6() + } { + Ok(socket) => socket, + Err(err) + if is_ipv6_unsupported(&err) + && addr.is_ipv6() + && addr.ip().is_unspecified() => + { + let v4_addr = + StdSocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), addr.port()); + bp.build_warning( + id, + format!( + "IPv6 unavailable on this host ({err}); \ + falling back from {addr} to {v4_addr}" + ), + ); + addr = v4_addr; + match TcpSocket::new_v4() { + Ok(socket) => socket, + Err(err) => { + bp.build_error( + id, + format!("Failed to create IPv4 fallback socket: {err}"), + ); + return; + } + } + } + Err(err) => { + bp.build_error(id, format!("Failed to create socket: {err}")); + return; + } + }; + + #[cfg(windows)] + if addr.is_ipv6() + && addr.ip().is_unspecified() + && let Err(err) = socket2::SockRef::from(&socket).set_only_v6(false) + { + bp.build_warning( + id, + format!( + "Failed to disable IPV6_V6ONLY on {addr} ({err}); \ + IPv4 clients will not be able to connect to this listener" + ), + ); + } + + if let Err(err) = socket.set_reuseaddr(listener.socket_reuse_address) { + bp.build_error(id, format!("Failed to set SO_REUSEADDR: {err}")); + return; + } + + #[cfg(not(target_env = "msvc"))] + if let Err(err) = socket.set_reuseport(listener.socket_reuse_port) { + bp.build_error(id, format!("Failed to set SO_REUSEPORT: {err}")); + return; + } + + if let Some(send_size) = listener.socket_send_buffer_size + && let Err(err) = socket.set_send_buffer_size(send_size as u32) + { + bp.build_error(id, format!("Failed to set SO_SNDBUF: {err}")); + return; + } + + if let Some(recv_size) = listener.socket_receive_buffer_size + && let Err(err) = socket.set_recv_buffer_size(recv_size as u32) + { + bp.build_error(id, format!("Failed to set SO_RCVBUF: {err}")); + return; + } + + if let Some(tos) = listener.socket_tos_v4 + && let Err(err) = socket.set_tos_v4(tos as u32) + { + bp.build_error(id, format!("Failed to set IP_TOS: {err}")); + return; + } + + listeners.push(TcpListener { + socket, + addr, + ttl: listener.socket_ttl.map(|v| v as u32), + backlog: listener.socket_backlog.map(|v| v as u32), + nodelay: listener.socket_no_delay, + }); + } + + let span_id_gen = self.span_id_gen.clone(); + + self.servers.push(Listener { + max_connections: listener.max_connections.unwrap_or(system.max_connections), + tls_timeout: listener + .tls_timeout + .map_or(DEFAULT_TLS_TIMEOUT, |timeout| timeout.into_inner()), + id: listener.name.clone(), + registry_id: id, + protocol, + listeners, + proxy_networks: if !listener.override_proxy_trusted_networks.is_empty() { + listener.override_proxy_trusted_networks.as_slice().to_vec() + } else { + system.proxy_trusted_networks.as_slice().to_vec() + }, + span_id_gen, + }); + self.parsed_listeners.push(RegistryObject { + id, + object: listener, + revision, + }); + } + + pub async fn parse_tcp_acceptors(&mut self, bp: &mut Bootstrap, inner: Arc) { + let resolver = Arc::new(CertificateResolver::new(inner.clone())); + + for listener in std::mem::take(&mut self.parsed_listeners) { + let id = listener.id; + let listener = listener.object; + + // Build TLS config + let acceptor = if listener.use_tls { + // Parse protocol versions + let mut tls_v2 = true; + let mut tls_v3 = true; + + for disabled in listener.tls_disable_protocols { + match disabled { + TlsVersion::Tls12 => { + tls_v2 = false; + } + TlsVersion::Tls13 => { + tls_v3 = false; + } + } + } + + // Parse cipher suites + let mut disabled_ciphers: Vec = Vec::new(); + for disabled in listener.tls_disable_cipher_suites { + disabled_ciphers.push(match disabled { + TlsCipherSuite::Tls13Aes256GcmSha384 => TLS13_AES_256_GCM_SHA384, + TlsCipherSuite::Tls13Aes128GcmSha256 => TLS13_AES_128_GCM_SHA256, + TlsCipherSuite::Tls13Chacha20Poly1305Sha256 => { + TLS13_CHACHA20_POLY1305_SHA256 + } + TlsCipherSuite::TlsEcdheEcdsaWithAes256GcmSha384 => { + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + } + TlsCipherSuite::TlsEcdheEcdsaWithAes128GcmSha256 => { + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + } + TlsCipherSuite::TlsEcdheEcdsaWithChacha20Poly1305Sha256 => { + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + } + TlsCipherSuite::TlsEcdheRsaWithAes256GcmSha384 => { + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + } + TlsCipherSuite::TlsEcdheRsaWithAes128GcmSha256 => { + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + } + TlsCipherSuite::TlsEcdheRsaWithChacha20Poly1305Sha256 => { + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + } + }); + } + + // Build cert provider + let mut provider = default_provider(); + if !disabled_ciphers.is_empty() { + provider.cipher_suites = ALL_CIPHER_SUITES + .iter() + .filter(|suite| !disabled_ciphers.contains(suite)) + .copied() + .collect(); + } + + // Build server config + let mut server_config = match ServerConfig::builder_with_provider(provider.into()) + .with_protocol_versions(if tls_v3 == tls_v2 { + ALL_VERSIONS + } else if tls_v3 { + TLS13_VERSION + } else { + TLS12_VERSION + }) { + Ok(server_config) => server_config + .with_no_client_auth() + .with_cert_resolver(resolver.clone()), + Err(err) => { + bp.build_error(id, format!("Failed to build TLS server config: {err}")); + return; + } + }; + + server_config.ignore_client_order = listener.tls_ignore_client_order; + + // Build acceptor + let default_config = Arc::new(server_config); + TcpAcceptor::Tls { + acceptor: TlsAcceptor::from(default_config.clone()), + config: default_config, + implicit: listener.tls_implicit, + } + } else { + TcpAcceptor::Plain + }; + + self.tcp_acceptors.insert(listener.name, acceptor); + } + } +} + +fn is_ipv6_unsupported(err: &std::io::Error) -> bool { + let code = err.raw_os_error(); + #[cfg(unix)] + { + matches!(code, Some(libc::EAFNOSUPPORT) | Some(libc::EPROTONOSUPPORT)) + } + #[cfg(windows)] + { + matches!(code, Some(10047) | Some(10043)) + } + #[cfg(not(any(unix, windows)))] + { + false + } +} diff --git a/crates/common/src/config/server/mod.rs b/crates/common/src/config/server/mod.rs new file mode 100644 index 0000000..f131937 --- /dev/null +++ b/crates/common/src/config/server/mod.rs @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::network::TcpAcceptor; +use ahash::AHashMap; +use registry::{ + schema::structs::NetworkListener, + types::{id::ObjectId, ipmask::IpAddrOrMask}, +}; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, net::SocketAddr, sync::Arc, time::Duration}; +use store::registry::RegistryObject; +use tokio::net::TcpSocket; +use utils::snowflake::SnowflakeIdGenerator; + +pub mod listener; +pub mod tls; + +#[derive(Default)] +pub struct Listeners { + pub servers: Vec, + pub tcp_acceptors: AHashMap, + pub span_id_gen: Arc, + parsed_listeners: Vec>, +} + +#[derive(Debug, Default)] +pub struct Listener { + pub registry_id: ObjectId, + pub id: String, + pub protocol: ServerProtocol, + pub listeners: Vec, + pub proxy_networks: Vec, + pub max_connections: u64, + pub tls_timeout: Duration, + pub span_id_gen: Arc, +} + +pub const DEFAULT_TLS_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub struct TcpListener { + pub socket: TcpSocket, + pub addr: SocketAddr, + pub backlog: Option, + + // TCP options + pub ttl: Option, + pub nodelay: bool, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Default, Serialize, Deserialize)] +pub enum ServerProtocol { + #[default] + Smtp, + Lmtp, + Imap, + Pop3, + Http, + ManageSieve, +} + +impl ServerProtocol { + pub fn as_str(&self) -> &'static str { + match self { + ServerProtocol::Smtp => "smtp", + ServerProtocol::Lmtp => "lmtp", + ServerProtocol::Imap => "imap", + ServerProtocol::Http => "http", + ServerProtocol::Pop3 => "pop3", + ServerProtocol::ManageSieve => "managesieve", + } + } +} + +impl Display for ServerProtocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs new file mode 100644 index 0000000..df5660f --- /dev/null +++ b/crates/common/src/config/server/tls.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::network::acme::ParsedCert; +use ahash::{AHashMap, AHashSet}; +use rcgen::generate_simple_self_signed; +use registry::{ + schema::{ + prelude::Object, + structs::{Certificate, PublicText, SecretText, SystemSettings}, + }, + types::{datetime::UTCDateTime, map::Map}, +}; +use rustls::{ + SupportedProtocolVersion, + crypto::aws_lc_rs::sign::any_supported_type, + sign::CertifiedKey, + version::{TLS12, TLS13}, +}; +use rustls_pemfile::{Item, certs, read_all}; +use rustls_pki_types::PrivateKeyDer; +use std::{io::Cursor, sync::Arc}; +use store::{ + registry::{bootstrap::Bootstrap, write::RegistryWrite}, + write::now, +}; + +pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; +pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; + +pub(crate) async fn parse_certificates( + bp: &mut Bootstrap, + certificates: &mut AHashMap, Arc>, + subject_names: &mut AHashSet>, +) { + let system = bp.setting_infallible::().await; + + // Parse certificates + let now = now() as i64; + let mut certs_expired = Vec::new(); + let mut certs_expirations = AHashMap::new(); + for cert_obj in bp.list_infallible::().await { + let obj_id = cert_obj.id; + let revision = cert_obj.revision; + let mut cert = cert_obj.object; + + let is_file_backed = matches!(cert.certificate, PublicText::File(_)) + || matches!(cert.private_key, SecretText::File(_)); + let mut public = None; + let mut refreshed_meta = None; + if is_file_backed { + let pem = match cert.certificate.value().await { + Ok(value) => value.into_owned().into_bytes(), + Err(err) => { + bp.build_error(obj_id, format!("Failed to obtain certificate value: {err}")); + continue; + } + }; + match ParsedCert::parse(&pem) { + Ok(parsed) => { + let not_valid_after = + UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp()); + let not_valid_before = + UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp()); + let sans = Map::new(parsed.sans); + if cert.not_valid_after != not_valid_after + || cert.not_valid_before != not_valid_before + || cert.issuer != parsed.issuer + || cert.subject_alternative_names != sans + { + refreshed_meta = + Some((not_valid_after, not_valid_before, parsed.issuer, sans)); + } + public = Some(pem); + } + Err(err) => { + bp.build_error(obj_id, format!("Invalid certificate: {err}")); + continue; + } + } + } + + let (not_valid_after, not_valid_before) = match refreshed_meta.as_ref() { + Some((after, before, _, _)) => (after.timestamp(), before.timestamp()), + None => ( + cert.not_valid_after.timestamp(), + cert.not_valid_before.timestamp(), + ), + }; + + if not_valid_after <= now { + certs_expired.push(( + obj_id, + cert.subject_alternative_names.clone().into_inner(), + Object { + inner: cert.into(), + revision, + }, + )); + continue; + } else if not_valid_before > now { + continue; // Skip certificates that are not yet valid + } + + let secret = match cert.private_key.secret().await { + Ok(secret) => secret.into_owned().into_bytes(), + Err(err) => { + bp.build_error( + obj_id, + format!("Failed to obtain private key secret: {err}"), + ); + continue; + } + }; + let public = match public { + Some(public) => public, + None => match cert.certificate.value().await { + Ok(value) => value.into_owned().into_bytes(), + Err(err) => { + bp.build_error(obj_id, format!("Failed to obtain certificate value: {err}")); + continue; + } + }, + }; + + if let Some((not_valid_after, not_valid_before, issuer, sans)) = refreshed_meta { + let old = Object { + inner: cert.clone().into(), + revision, + }; + cert.not_valid_after = not_valid_after; + cert.not_valid_before = not_valid_before; + cert.issuer = issuer; + cert.subject_alternative_names = sans; + let new = Object { + inner: cert.clone().into(), + revision, + }; + if let Err(err) = bp + .registry + .write(RegistryWrite::update(obj_id.id(), &new, &old)) + .await + { + trc::error!( + err.details("Failed to refresh TLS certificate metadata in registry.") + .caused_by(trc::location!()) + ); + } + } + + // Add default certificate + if system + .default_certificate_id + .as_ref() + .is_some_and(|id| *id == obj_id.id()) + { + cert.subject_alternative_names + .push_unchecked("*".to_string()); + } + + // Ensure that the most up-to-date certificate is used + cert.subject_alternative_names.inner_mut().retain(|name| { + if certs_expirations + .get(name) + .is_none_or(|expires| *expires < not_valid_after) + { + certs_expirations.insert(name.clone(), not_valid_after); + true + } else { + false + } + }); + + match build_certified_key(public, secret) { + Ok(key) => { + // Add certificates + let key = Arc::new(key); + for name in cert.subject_alternative_names.into_inner() { + subject_names.insert(name.as_str().into()); + certificates.insert( + name.strip_prefix("*.") + .map(Into::into) + .unwrap_or_else(|| name.into_boxed_str()), + key.clone(), + ); + } + } + Err(err) => { + bp.build_error(obj_id, format!("Invalid certificate: {err}")); + } + } + } + + // Remove expired certificates + if !certs_expired.is_empty() { + for (id, sans, object) in certs_expired { + if let Err(err) = bp + .registry + .write(RegistryWrite::delete_object(id, &object)) + .await + { + trc::error!( + err.details("Failed to delete expired TLS certificate from registry.") + .caused_by(trc::location!()) + ); + } else { + trc::event!( + Tls(trc::TlsEvent::ExpiredCertificateRemoved), + Details = sans + ); + } + } + } +} + +pub(crate) fn build_certified_key( + cert: Vec, + pk_bytes: Vec, +) -> Result { + let mut pk = None; + for item in read_all(&mut Cursor::new(pk_bytes)) { + match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + Item::Pkcs8Key(key) => { + pk = Some(PrivateKeyDer::Pkcs8(key)); + break; + } + Item::Pkcs1Key(key) => { + pk = Some(PrivateKeyDer::Pkcs1(key)); + break; + } + Item::Sec1Key(key) => { + pk = Some(PrivateKeyDer::Sec1(key)); + break; + } + _ => continue, // Skip certificates, DH params, etc. + } + } + let pk = pk.ok_or_else(|| "No private keys found.".to_string())?; + let cert = certs(&mut Cursor::new(cert)) + .collect::, _>>() + .map_err(|err| format!("Failed to read certificates: {err}"))?; + + if !cert.is_empty() { + Ok(CertifiedKey { + cert, + key: any_supported_type(&pk) + .map_err(|err| format!("Failed to sign certificate: {err}",))?, + ocsp: None, + }) + } else { + Err("No certificates found.".to_string()) + } +} + +pub(crate) fn build_self_signed_cert( + domains: impl Into>, +) -> Result { + let domains = domains + .into() + .into_iter() + .map(|domain| { + if domain.is_ascii() { + domain + } else { + idna::domain_to_ascii(&domain).unwrap_or(domain) + } + }) + .collect::>(); + let rcgen::CertifiedKey { cert, signing_key } = generate_simple_self_signed(domains) + .map_err(|err| format!("Failed to generate self-signed certificate: {err}",))?; + build_certified_key( + cert.pem().into_bytes(), + signing_key.serialize_pem().into_bytes(), + ) +} diff --git a/crates/common/src/config/smtp/auth.rs b/crates/common/src/config/smtp/auth.rs new file mode 100644 index 0000000..5580531 --- /dev/null +++ b/crates/common/src/config/smtp/auth.rs @@ -0,0 +1,380 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::expr::{ + self, + if_block::{BootstrapExprExt, IfBlock}, +}; +use mail_auth::{ + common::crypto::{Ed25519Key, HashAlgorithm, RsaKey, Sha256, SigningKey}, + dkim::{Canonicalization, Done}, + dkim2::{Dkim2Signer, Done as Dkim2Done, Flag}, +}; +use mail_parser::decoders::base64::base64_decode; +use registry::{ + schema::{ + enums::{self, Dkim2Flag, ExpressionConstant}, + prelude::ObjectType, + structs::{Dkim1Signature, DkimSignature, SenderAuth}, + }, + types::{ObjectImpl, map::Map}, +}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, pem::PemObject}; +use store::registry::bootstrap::Bootstrap; +use utils::cache::CacheItemWeight; + +#[derive(Clone)] +pub struct MailAuthConfig { + pub dkim: DkimAuthConfig, + pub arc: ArcAuthConfig, + pub spf: SpfAuthConfig, + pub dmarc: DmarcAuthConfig, + pub iprev: IpRevAuthConfig, +} + +#[derive(Clone)] +pub struct DkimAuthConfig { + pub verify: IfBlock, + pub sign: IfBlock, + pub strict: bool, +} + +#[derive(Clone)] +pub struct ArcAuthConfig { + pub verify: IfBlock, + //pub seal: IfBlock, +} + +#[derive(Clone)] +pub struct SpfAuthConfig { + pub verify_ehlo: IfBlock, + pub verify_mail_from: IfBlock, +} + +#[derive(Clone)] +pub struct DmarcAuthConfig { + pub verify: IfBlock, +} + +#[derive(Clone)] +pub struct IpRevAuthConfig { + pub verify: IfBlock, +} + +#[derive(Debug, Clone, Copy, Default)] +pub enum VerifyStrategy { + #[default] + Relaxed, + Strict, + Disable, +} + +pub enum Dkim1Signer { + RsaSha256(mail_auth::dkim::DkimSigner, Done>), + Ed25519Sha256(mail_auth::dkim::DkimSigner), +} + +#[derive(Default)] +pub struct DkimSigners { + pub dkim1: Vec, + pub dkim2: Option>, +} + +impl MailAuthConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let auth = bp.setting_infallible::().await; + + MailAuthConfig { + dkim: DkimAuthConfig { + verify: bp + .compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_dkim_verify()), + sign: bp.compile_expr( + ObjectType::SenderAuth.singleton(), + &auth.ctx_dkim_sign_domain(), + ), + strict: auth.dkim_strict, + }, + arc: ArcAuthConfig { + verify: bp.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_arc_verify()), + //seal: bp.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_arc_seal_domain()), + }, + spf: SpfAuthConfig { + verify_ehlo: bp.compile_expr( + ObjectType::SenderAuth.singleton(), + &auth.ctx_spf_ehlo_verify(), + ), + verify_mail_from: bp.compile_expr( + ObjectType::SenderAuth.singleton(), + &auth.ctx_spf_from_verify(), + ), + }, + dmarc: DmarcAuthConfig { + verify: bp + .compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_dmarc_verify()), + }, + iprev: IpRevAuthConfig { + verify: bp.compile_expr( + ObjectType::SenderAuth.singleton(), + &auth.ctx_reverse_ip_verify(), + ), + }, + } + } +} + +impl DkimSigners { + pub async fn insert(&mut self, domain: String, signature: DkimSignature) -> trc::Result<()> { + let mut errors = vec![]; + if !signature.validate(&mut errors) { + return Err(trc::DkimEvent::BuildError + .reason("DKIM signature validation failed") + .details( + errors + .into_iter() + .map(|v| trc::Value::from(v.to_string())) + .collect::>(), + )); + } + + match signature { + DkimSignature::Dkim1Ed25519Sha256(signature) => { + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; + let private_key = simple_pem_parse(&private_key).ok_or_else(|| { + trc::DkimEvent::BuildError + .reason("Failed to parse ED25519 private key PEM") + .details("Invalid PEM format") + })?; + let key = + Ed25519Key::from_pkcs8_maybe_unchecked_der(&private_key).map_err(|err| { + trc::DkimEvent::BuildError + .reason(err) + .details("Failed to build ED25519 key") + })?; + + self.dkim1 + .push(Dkim1Signer::Ed25519Sha256(build_dkim1_signer( + domain, signature, key, + ))); + } + DkimSignature::Dkim1RsaSha256(signature) => { + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; + let key = rsa_key_parse(private_key.as_bytes())?; + + self.dkim1.push(Dkim1Signer::RsaSha256(build_dkim1_signer( + domain, signature, key, + ))); + } + DkimSignature::Dkim2Ed25519Sha256(signature) => { + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; + let private_key = simple_pem_parse(&private_key).ok_or_else(|| { + trc::DkimEvent::BuildError + .reason("Failed to parse ED25519 private key PEM") + .details("Invalid PEM format") + })?; + let key = + Ed25519Key::from_pkcs8_maybe_unchecked_der(&private_key).map_err(|err| { + trc::DkimEvent::BuildError + .reason(err) + .details("Failed to build ED25519 key") + })?; + + self.dkim2 = Some(match self.dkim2.take() { + None => Dkim2Signer::from_key(key) + .domain(domain) + .selector(signature.selector) + .flags(map_dkim2_flags(signature.flags)), + Some(signer) => signer + .additional_key(key, signature.selector) + .flags(map_dkim2_flags(signature.flags)), + }); + } + DkimSignature::Dkim2RsaSha256(signature) => { + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; + let key = rsa_key_parse(private_key.as_bytes())?; + + self.dkim2 = Some(match self.dkim2.take() { + None => Dkim2Signer::from_key(key) + .domain(domain) + .selector(signature.selector) + .flags(map_dkim2_flags(signature.flags)), + Some(signer) => signer + .additional_key(key, signature.selector) + .flags(map_dkim2_flags(signature.flags)), + }); + } + } + + Ok(()) + } +} + +fn map_dkim2_flags(flags: Map) -> impl Iterator { + flags.into_inner().into_iter().map(|flag| match flag { + Dkim2Flag::Donotmodify => Flag::DoNotModify, + Dkim2Flag::Donotexplode => Flag::DoNotExplode, + Dkim2Flag::Feedback => Flag::Feedback, + }) +} + +pub fn rsa_key_parse(private_key: &[u8]) -> trc::Result> { + PrivatePkcs1KeyDer::from_pem_slice(private_key) + .map(PrivateKeyDer::Pkcs1) + .or_else(|_| PrivatePkcs8KeyDer::from_pem_slice(private_key).map(PrivateKeyDer::Pkcs8)) + .map_err(|err| { + trc::DkimEvent::BuildError + .reason(err) + .details("Failed to build RSA key") + }) + .and_then(|key| { + RsaKey::::from_key_der(key).map_err(|err| { + trc::DkimEvent::BuildError + .reason(err) + .details("Failed to build RSA key") + }) + }) +} + +pub fn simple_pem_parse(contents: &str) -> Option> { + let mut contents = contents.as_bytes().iter().copied(); + let mut base64 = vec![]; + + 'outer: while let Some(ch) = contents.next() { + if !ch.is_ascii_whitespace() { + if ch == b'-' { + for ch in contents.by_ref() { + if ch == b'\n' { + break; + } + } + } else { + base64.push(ch); + } + + for ch in contents.by_ref() { + if ch == b'-' { + break 'outer; + } else if !ch.is_ascii_whitespace() { + base64.push(ch); + } + } + } + } + + base64_decode(&base64) +} + +fn build_dkim1_signer( + domain: String, + signature: Dkim1Signature, + key: T, +) -> mail_auth::dkim::DkimSigner { + let mut signer = mail_auth::dkim::DkimSigner::from_key(key) + .domain(domain) + .selector(signature.selector) + .headers(signature.headers) + .reporting(signature.report); + + match signature.canonicalization { + enums::DkimCanonicalization::RelaxedRelaxed => { + signer = signer + .body_canonicalization(Canonicalization::Relaxed) + .header_canonicalization(Canonicalization::Relaxed); + } + enums::DkimCanonicalization::SimpleSimple => { + signer = signer + .body_canonicalization(Canonicalization::Simple) + .header_canonicalization(Canonicalization::Simple); + } + enums::DkimCanonicalization::RelaxedSimple => { + signer = signer + .body_canonicalization(Canonicalization::Simple) + .header_canonicalization(Canonicalization::Relaxed); + } + enums::DkimCanonicalization::SimpleRelaxed => { + signer = signer + .body_canonicalization(Canonicalization::Relaxed) + .header_canonicalization(Canonicalization::Simple); + } + } + + if let Some(expire) = signature.expire { + signer = signer.expiration(expire.into_inner().as_secs()); + } + + if let Some(auid) = signature.auid { + signer = signer.agent_user_identifier(auid); + } + + if let Some(atps) = signature.third_party { + signer = signer.atps(atps); + } + + if let Some(atpsh) = signature.third_party_hash { + signer = signer.atpsh(match atpsh { + enums::DkimHash::Sha256 => HashAlgorithm::Sha256, + enums::DkimHash::Sha1 => HashAlgorithm::Sha1, + }); + } + signer +} + +impl<'x> TryFrom> for VerifyStrategy { + type Error = (); + + fn try_from(value: expr::Variable<'x>) -> Result { + match value { + expr::Variable::Constant(c) => match c { + ExpressionConstant::Relaxed => Ok(VerifyStrategy::Relaxed), + ExpressionConstant::Strict => Ok(VerifyStrategy::Strict), + ExpressionConstant::Disable => Ok(VerifyStrategy::Disable), + _ => Err(()), + }, + _ => Err(()), + } + } +} + +impl VerifyStrategy { + #[inline(always)] + pub fn verify(&self) -> bool { + matches!(self, VerifyStrategy::Strict | VerifyStrategy::Relaxed) + } + + #[inline(always)] + pub fn is_strict(&self) -> bool { + matches!(self, VerifyStrategy::Strict) + } +} + +impl CacheItemWeight for Dkim1Signer { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for DkimSigners { + fn weight(&self) -> u64 { + (std::mem::size_of::() + + self.dkim1.len() * std::mem::size_of::() + + std::mem::size_of::>()) as u64 + } +} diff --git a/crates/common/src/config/smtp/mod.rs b/crates/common/src/config/smtp/mod.rs new file mode 100644 index 0000000..3268d08 --- /dev/null +++ b/crates/common/src/config/smtp/mod.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod auth; +pub mod queue; +pub mod report; +pub mod resolver; +pub mod session; + +use self::{ + auth::MailAuthConfig, queue::QueueConfig, report::ReportConfig, resolver::Resolvers, + session::SessionConfig, +}; +use crate::{config::smtp::queue::RequireOptional, expr::if_block::IfBlock}; +use registry::{ + schema::{properties::ObjectType, structs::Rate}, + types::id::ObjectId, +}; +use store::registry::bootstrap::Bootstrap; + +#[derive(Clone)] +pub struct SmtpConfig { + pub session: SessionConfig, + pub queue: QueueConfig, + pub resolvers: Resolvers, + pub mail_auth: MailAuthConfig, + pub report: ReportConfig, + pub mta_sts_client: reqwest::Client, + pub tls_report_client: reqwest::Client, +} + +#[derive(Debug, Clone)] +//#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))] +pub struct QueueRateLimiter { + pub id: ObjectId, + pub expr: IfBlock, + pub keys: u16, + pub rate: Rate, +} + +pub const THROTTLE_RCPT: u16 = 1 << 0; +pub const THROTTLE_RCPT_DOMAIN: u16 = 1 << 1; +pub const THROTTLE_SENDER: u16 = 1 << 2; +pub const THROTTLE_SENDER_DOMAIN: u16 = 1 << 3; +pub const THROTTLE_AUTH_AS: u16 = 1 << 4; +pub const THROTTLE_LISTENER: u16 = 1 << 5; +pub const THROTTLE_MX: u16 = 1 << 6; +pub const THROTTLE_REMOTE_IP: u16 = 1 << 7; +pub const THROTTLE_LOCAL_IP: u16 = 1 << 8; +pub const THROTTLE_HELO_DOMAIN: u16 = 1 << 9; + +impl SmtpConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let config = Self { + session: SessionConfig::parse(bp).await, + queue: QueueConfig::parse(bp).await, + resolvers: Resolvers::parse(bp).await, + mail_auth: MailAuthConfig::parse(bp).await, + report: ReportConfig::parse(bp).await, + mta_sts_client: utils::http::http_client_builder(false) + .pool_max_idle_per_host(0) + .user_agent(crate::USER_AGENT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_default(), + tls_report_client: utils::http::unpooled_http_client(false), + }; + + if !config.resolvers.dnssec_available + && (config.queue.tls_strategy.is_empty() + || config + .queue + .tls_strategy + .values() + .any(|t| !matches!(t.dane, RequireOptional::Disable))) + { + bp.build_warning( + ObjectType::DnsResolver.singleton(), + concat!( + "The configured DNS resolver cannot validate DNSSEC. ", + "DANE has been disabled to avoid deferring mail. ", + "Ensure the resolver is DNSSEC-capable and reachable over TCP." + ), + ); + } + + config + } +} diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs new file mode 100644 index 0000000..1dd4faa --- /dev/null +++ b/crates/common/src/config/smtp/queue.rs @@ -0,0 +1,801 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::{ + config::server::ServerProtocol, + expr::{ + if_block::{BootstrapExprExt, IfBlock}, + *, + }, +}; +use ahash::AHashMap; +use directory::Credentials; +use mail_auth::IpLookupStrategy; +use registry::schema::{ + enums::{self, ExpressionConstant, ExpressionVariable, MtaRequiredOrOptional}, + prelude::ObjectType, + structs::{ + DsnReportSettings, MtaConnectionStrategy, MtaDeliveryExpiration, MtaDeliverySchedule, + MtaDeliveryScheduleIntervalsOrDefault, MtaInboundThrottle, MtaOutboundStrategy, + MtaOutboundThrottle, MtaQueueQuota, MtaRoute, MtaTlsStrategy, MtaVirtualQueue, + }, +}; +use std::{ + fmt::Display, + hash::{Hash, Hasher}, + net::IpAddr, + time::Duration, +}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, +)] +#[rkyv(derive(Debug, Clone, Copy, PartialEq), compare(PartialEq))] +#[repr(transparent)] +pub struct QueueName([u8; 8]); + +pub const DEFAULT_QUEUE_NAME: QueueName = QueueName([b'd', b'e', b'f', b'a', b'u', b'l', b't', 0]); + +#[derive(Clone)] +pub struct QueueConfig { + // Strategy resolver + pub route: IfBlock, + pub queue: IfBlock, + pub connection: IfBlock, + pub tls: IfBlock, + + // DSN + pub dsn: Dsn, + + // Rate limits + pub inbound_limiters: QueueRateLimiters, + pub outbound_limiters: QueueRateLimiters, + pub quota: QueueQuotas, + + // Strategies + pub queue_strategy: AHashMap, + pub connection_strategy: AHashMap, + pub routing_strategy: AHashMap, + pub tls_strategy: AHashMap, + pub virtual_queues: AHashMap, +} + +#[derive(Clone, Hash, PartialEq, Eq, Debug)] +pub enum RoutingStrategy { + Local, + Mx(MxConfig), + Relay(RelayConfig), +} + +#[derive(Clone, Debug)] +pub struct MxConfig { + pub max_mx: usize, + pub max_multi_homed: usize, + pub ip_lookup_strategy: IpLookupStrategy, +} + +#[derive(Clone)] +pub struct Dsn { + pub name: IfBlock, + pub address: IfBlock, + pub sign: IfBlock, +} + +#[derive(Clone, Debug)] +pub struct VirtualQueue { + pub threads: usize, +} + +#[derive(Clone, Debug)] +pub struct QueueStrategy { + pub retry: Vec, + pub notify: Vec, + pub expiry: QueueExpiry, + pub virtual_queue: QueueName, +} + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Deserialize, +)] +pub enum QueueExpiry { + Ttl(u64), + Attempts(u32), +} + +#[derive(Clone, Debug)] +pub struct TlsStrategy { + pub dane: RequireOptional, + pub mta_sts: RequireOptional, + pub tls: RequireOptional, + pub allow_invalid_certs: bool, + + pub timeout_tls: Duration, + pub timeout_mta_sts: Duration, +} + +#[derive(Clone, Debug)] +pub struct ConnectionStrategy { + pub source_ipv4: Vec, + pub source_ipv6: Vec, + pub ehlo_hostname: Option, + + pub timeout_connect: Duration, + pub timeout_greeting: Duration, + pub timeout_ehlo: Duration, + pub timeout_mail: Duration, + pub timeout_rcpt: Duration, + pub timeout_data: Duration, +} + +#[derive(Clone, Debug)] +pub struct IpAndHost { + pub ip: IpAddr, + pub host: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct QueueRateLimiters { + pub sender: Vec, + pub rcpt: Vec, + pub remote: Vec, +} + +#[derive(Clone, Default)] +pub struct QueueQuotas { + pub sender: Vec, + pub rcpt: Vec, + pub rcpt_domain: Vec, +} + +#[derive(Clone)] +pub struct QueueQuota { + pub id: ObjectId, + pub expr: IfBlock, + pub keys: u16, + pub size: Option, + pub messages: Option, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +pub struct RelayConfig { + pub address: HostOrIp, IpStr>, + pub port: u16, + pub protocol: ServerProtocol, + pub auth: Option, + pub tls_implicit: bool, + pub tls_allow_invalid_certs: bool, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum HostOrIp { + Host(N), + Ip(I), +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct IpStr { + pub ip: IpAddr, + pub ip_str: Box, +} + +#[derive(Debug, Clone, Copy, Default)] +pub enum RequireOptional { + #[default] + Optional, + Require, + Disable, +} + +impl QueueConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let st = bp.setting_infallible::().await; + let dsn = bp.setting_infallible::().await; + + let mut queue = QueueConfig { + route: bp.compile_expr(ObjectType::MtaOutboundStrategy.singleton(), &st.ctx_route()), + queue: bp.compile_expr( + ObjectType::MtaOutboundStrategy.singleton(), + &st.ctx_schedule(), + ), + connection: bp.compile_expr( + ObjectType::MtaOutboundStrategy.singleton(), + &st.ctx_connection(), + ), + tls: bp.compile_expr(ObjectType::MtaOutboundStrategy.singleton(), &st.ctx_tls()), + dsn: Dsn { + name: bp.compile_expr( + ObjectType::DsnReportSettings.singleton(), + &dsn.ctx_from_name(), + ), + address: bp.compile_expr( + ObjectType::DsnReportSettings.singleton(), + &dsn.ctx_from_address(), + ), + sign: bp.compile_expr( + ObjectType::DsnReportSettings.singleton(), + &dsn.ctx_dkim_sign_domain(), + ), + }, + inbound_limiters: QueueRateLimiters::parse_inbound(bp).await, + outbound_limiters: QueueRateLimiters::parse_outbound(bp).await, + quota: QueueQuotas::parse(bp).await, + queue_strategy: Default::default(), + connection_strategy: Default::default(), + routing_strategy: Default::default(), + tls_strategy: Default::default(), + virtual_queues: Default::default(), + }; + + // Parse virtual queues + let mut queue_id_to_name = AHashMap::new(); + for obj in bp.list_infallible::().await { + if let Some(queue_name) = QueueName::new(&obj.object.name) { + queue_id_to_name.insert(obj.id.id(), queue_name); + queue.virtual_queues.insert( + queue_name, + VirtualQueue { + threads: obj.object.threads_per_node as usize, + }, + ); + } + } + + // Parse queue strategies + for obj in bp.list_infallible::().await { + let virtual_queue = if let Some(name) = queue_id_to_name.get(&obj.object.queue_id) { + *name + } else { + bp.build_error( + obj.id, + format!("Virtual queue ID '{}' does not exist.", obj.object.queue_id), + ); + continue; + }; + + queue.queue_strategy.insert( + obj.object.name, + QueueStrategy { + retry: match obj.object.retry { + MtaDeliveryScheduleIntervalsOrDefault::Default => vec![ + 2 * 60, + 5 * 60, + 10 * 60, + 15 * 60, + 30 * 60, + 60 * 60, + 2 * 60 * 60, + 24 * 60 * 60, + 3 * 24 * 60 * 60, + ], + MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals + .intervals + .into_iter() + .map(|d| d.duration.as_secs()) + .collect(), + }, + notify: match obj.object.notify { + MtaDeliveryScheduleIntervalsOrDefault::Default => { + vec![24 * 60 * 60, 3 * 24 * 60 * 60] + } + MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals + .intervals + .into_iter() + .map(|d| d.duration.as_secs()) + .collect(), + }, + expiry: match obj.object.expiry { + MtaDeliveryExpiration::Ttl(exp) => { + QueueExpiry::Ttl(exp.expire.into_inner().as_secs()) + } + MtaDeliveryExpiration::Attempts(exp) => { + QueueExpiry::Attempts(exp.max_attempts as u32) + } + }, + virtual_queue, + }, + ); + } + + // Parse connection strategies + for obj in bp.list_infallible::().await { + let mut source_ipv4 = Vec::new(); + let mut source_ipv6 = Vec::new(); + + for ip_host in obj.object.source_ips { + let ip_host = IpAndHost { + ip: ip_host.source_ip.into_inner(), + host: ip_host.ehlo_hostname, + }; + if ip_host.ip.is_ipv4() { + source_ipv4.push(ip_host); + } else { + source_ipv6.push(ip_host); + } + } + + queue.connection_strategy.insert( + obj.object.name, + ConnectionStrategy { + source_ipv4, + source_ipv6, + ehlo_hostname: obj.object.ehlo_hostname, + timeout_connect: obj.object.connect_timeout.into_inner(), + timeout_greeting: obj.object.greeting_timeout.into_inner(), + timeout_ehlo: obj.object.ehlo_timeout.into_inner(), + timeout_mail: obj.object.mail_from_timeout.into_inner(), + timeout_rcpt: obj.object.rcpt_to_timeout.into_inner(), + timeout_data: obj.object.data_timeout.into_inner(), + }, + ); + } + + // Parse routing strategies + for obj in bp.list_infallible::().await { + match obj.object { + MtaRoute::Mx(route) => { + queue.routing_strategy.insert( + route.name, + RoutingStrategy::Mx(MxConfig { + max_mx: route.max_mx_hosts as usize, + max_multi_homed: route.max_multihomed as usize, + ip_lookup_strategy: match route.ip_lookup_strategy { + enums::MtaIpStrategy::V4ThenV6 => IpLookupStrategy::Ipv4thenIpv6, + enums::MtaIpStrategy::V6ThenV4 => IpLookupStrategy::Ipv6thenIpv4, + enums::MtaIpStrategy::V4Only => IpLookupStrategy::Ipv4Only, + enums::MtaIpStrategy::V6Only => IpLookupStrategy::Ipv6Only, + }, + }), + ); + } + MtaRoute::Relay(route) => { + let secret = route + .auth_secret + .secret() + .await + .map_err(|err| { + bp.build_error(obj.id, err); + }) + .unwrap_or_default(); + queue.routing_strategy.insert( + route.name, + RoutingStrategy::Relay(RelayConfig { + address: if let Ok(ip) = route.address.parse() { + HostOrIp::Ip(IpStr { + ip, + ip_str: route.address.into(), + }) + } else { + HostOrIp::Host(route.address.into()) + }, + port: route.port as u16, + protocol: match route.protocol { + enums::MtaProtocol::Smtp => ServerProtocol::Smtp, + enums::MtaProtocol::Lmtp => ServerProtocol::Lmtp, + }, + auth: route.auth_username.zip(secret).map(|(user, secret)| { + Credentials::Basic { + username: user, + secret: secret.into_owned(), + mfa_token: None, + } + }), + tls_implicit: route.implicit_tls, + tls_allow_invalid_certs: route.allow_invalid_certs, + }), + ); + } + MtaRoute::Local(route) => { + queue + .routing_strategy + .insert(route.name, RoutingStrategy::Local); + } + } + } + + // Parse TLS strategies + for obj in bp.list_infallible::().await { + queue.tls_strategy.insert( + obj.object.name, + TlsStrategy { + dane: match obj.object.dane { + MtaRequiredOrOptional::Optional => RequireOptional::Optional, + MtaRequiredOrOptional::Require => RequireOptional::Require, + MtaRequiredOrOptional::Disable => RequireOptional::Disable, + }, + mta_sts: match obj.object.mta_sts { + MtaRequiredOrOptional::Optional => RequireOptional::Optional, + MtaRequiredOrOptional::Require => RequireOptional::Require, + MtaRequiredOrOptional::Disable => RequireOptional::Disable, + }, + tls: match obj.object.start_tls { + MtaRequiredOrOptional::Optional => RequireOptional::Optional, + MtaRequiredOrOptional::Require => RequireOptional::Require, + MtaRequiredOrOptional::Disable => RequireOptional::Disable, + }, + allow_invalid_certs: obj.object.allow_invalid_certs, + timeout_tls: obj.object.tls_timeout.into_inner(), + timeout_mta_sts: obj.object.mta_sts_timeout.into_inner(), + }, + ); + } + + queue + } +} + +impl QueueRateLimiters { + async fn parse_inbound(bp: &mut Bootstrap) -> QueueRateLimiters { + let mut throttle = QueueRateLimiters::default(); + + for obj in bp.list_infallible::().await { + if !obj.object.enable { + continue; + } + + let limiter = QueueRateLimiter { + expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()), + id: obj.id, + keys: obj + .object + .key + .iter() + .map(|key| match key { + enums::MtaInboundThrottleKey::Rcpt => THROTTLE_RCPT, + enums::MtaInboundThrottleKey::RcptDomain => THROTTLE_RCPT_DOMAIN, + enums::MtaInboundThrottleKey::Sender => THROTTLE_SENDER, + enums::MtaInboundThrottleKey::SenderDomain => THROTTLE_SENDER_DOMAIN, + enums::MtaInboundThrottleKey::AuthenticatedAs => THROTTLE_AUTH_AS, + enums::MtaInboundThrottleKey::Listener => THROTTLE_LISTENER, + enums::MtaInboundThrottleKey::RemoteIp => THROTTLE_REMOTE_IP, + enums::MtaInboundThrottleKey::LocalIp => THROTTLE_LOCAL_IP, + enums::MtaInboundThrottleKey::HeloDomain => THROTTLE_HELO_DOMAIN, + }) + .fold(0, |acc, key| acc | key), + rate: obj.object.rate, + }; + + if (limiter.keys & (THROTTLE_RCPT | THROTTLE_RCPT_DOMAIN)) != 0 + || limiter.expr.all_items().any(|c| { + matches!( + c, + ExpressionItem::Variable( + ExpressionVariable::Rcpt | ExpressionVariable::RcptDomain + ) + ) + }) + { + throttle.rcpt.push(limiter); + } else if (limiter.keys + & (THROTTLE_SENDER + | THROTTLE_SENDER_DOMAIN + | THROTTLE_HELO_DOMAIN + | THROTTLE_AUTH_AS)) + != 0 + || limiter.expr.all_items().any(|c| { + matches!( + c, + ExpressionItem::Variable( + ExpressionVariable::Sender + | ExpressionVariable::SenderDomain + | ExpressionVariable::HeloDomain + | ExpressionVariable::AuthenticatedAs + ) + ) + }) + { + throttle.sender.push(limiter); + } else { + throttle.remote.push(limiter); + } + } + + throttle + } + + async fn parse_outbound(bp: &mut Bootstrap) -> QueueRateLimiters { + // Parse throttle + let mut throttle = QueueRateLimiters::default(); + + for obj in bp.list_infallible::().await { + if !obj.object.enable { + continue; + } + + let limiter = QueueRateLimiter { + expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()), + id: obj.id, + keys: obj + .object + .key + .iter() + .map(|key| match key { + enums::MtaOutboundThrottleKey::RcptDomain => THROTTLE_RCPT_DOMAIN, + enums::MtaOutboundThrottleKey::Sender => THROTTLE_SENDER, + enums::MtaOutboundThrottleKey::SenderDomain => THROTTLE_SENDER_DOMAIN, + enums::MtaOutboundThrottleKey::Mx => THROTTLE_MX, + enums::MtaOutboundThrottleKey::RemoteIp => THROTTLE_REMOTE_IP, + enums::MtaOutboundThrottleKey::LocalIp => THROTTLE_LOCAL_IP, + }) + .fold(0, |acc, key| acc | key), + rate: obj.object.rate, + }; + if (limiter.keys & (THROTTLE_MX | THROTTLE_REMOTE_IP | THROTTLE_LOCAL_IP)) != 0 + || limiter.expr.all_items().any(|c| { + matches!( + c, + ExpressionItem::Variable( + ExpressionVariable::Mx + | ExpressionVariable::RemoteIp + | ExpressionVariable::LocalIp + ) + ) + }) + { + throttle.remote.push(limiter); + } else if (limiter.keys & (THROTTLE_RCPT_DOMAIN)) != 0 + || limiter + .expr + .all_items() + .any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::RcptDomain))) + { + throttle.rcpt.push(limiter); + } else { + throttle.sender.push(limiter); + } + } + + throttle + } +} + +impl QueueQuotas { + async fn parse(bp: &mut Bootstrap) -> QueueQuotas { + let mut capacities = QueueQuotas { + sender: Vec::new(), + rcpt: Vec::new(), + rcpt_domain: Vec::new(), + }; + + for obj in bp.list_infallible::().await { + if !obj.object.enable { + continue; + } + + let quota = QueueQuota { + expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()), + id: obj.id, + keys: obj + .object + .key + .iter() + .map(|key| match key { + enums::MtaQueueQuotaKey::Rcpt => THROTTLE_RCPT, + enums::MtaQueueQuotaKey::RcptDomain => THROTTLE_RCPT_DOMAIN, + enums::MtaQueueQuotaKey::Sender => THROTTLE_SENDER, + enums::MtaQueueQuotaKey::SenderDomain => THROTTLE_SENDER_DOMAIN, + }) + .fold(0, |acc, key| acc | key), + size: obj.object.size, + messages: obj.object.messages, + }; + + if (quota.keys & THROTTLE_RCPT) != 0 + || quota + .expr + .all_items() + .any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::Rcpt))) + { + capacities.rcpt.push(quota); + } else if (quota.keys & THROTTLE_RCPT_DOMAIN) != 0 + || quota + .expr + .all_items() + .any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::RcptDomain))) + { + capacities.rcpt_domain.push(quota); + } else { + capacities.sender.push(quota); + } + } + + capacities + } +} + +impl<'x> TryFrom> for RequireOptional { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Constant(ExpressionConstant::Optional) => Ok(RequireOptional::Optional), + Variable::Constant(ExpressionConstant::Require) => Ok(RequireOptional::Require), + Variable::Constant(ExpressionConstant::Disable) => Ok(RequireOptional::Disable), + _ => Err(()), + } + } +} + +impl<'x> TryFrom> for IpLookupStrategy { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Constant(value) => match value { + ExpressionConstant::Ipv4Only => Ok(IpLookupStrategy::Ipv4Only), + ExpressionConstant::Ipv6Only => Ok(IpLookupStrategy::Ipv6Only), + ExpressionConstant::Ipv6ThenIpv4 => Ok(IpLookupStrategy::Ipv6thenIpv4), + ExpressionConstant::Ipv4ThenIpv6 => Ok(IpLookupStrategy::Ipv4thenIpv6), + _ => Err(()), + }, + Variable::String(value) => { + match value.as_str() { + "ipv4_only" => Ok(IpLookupStrategy::Ipv4Only), + "ipv6_only" => Ok(IpLookupStrategy::Ipv6Only), + //"ipv4_and_ipv6" => IpLookupStrategy::Ipv4AndIpv6, + "ipv6_then_ipv4" => Ok(IpLookupStrategy::Ipv6thenIpv4), + "ipv4_then_ipv6" => Ok(IpLookupStrategy::Ipv4thenIpv6), + _ => Err(()), + } + } + _ => Err(()), + } + } +} + +impl std::fmt::Debug for RelayConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RelayConfig") + .field("address", &self.address) + .field("port", &self.port) + .field("protocol", &self.protocol) + .field("tls_implicit", &self.tls_implicit) + .field("tls_allow_invalid_certs", &self.tls_allow_invalid_certs) + .finish() + } +} + +impl TlsStrategy { + #[inline(always)] + pub fn try_dane(&self) -> bool { + matches!( + self.dane, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn try_start_tls(&self) -> bool { + matches!( + self.tls, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn is_dane_required(&self) -> bool { + matches!(self.dane, RequireOptional::Require) + } + + #[inline(always)] + pub fn try_mta_sts(&self) -> bool { + matches!( + self.mta_sts, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn is_mta_sts_required(&self) -> bool { + matches!(self.mta_sts, RequireOptional::Require) + } + + #[inline(always)] + pub fn is_tls_required(&self) -> bool { + matches!(self.tls, RequireOptional::Require) + || self.is_dane_required() + || self.is_mta_sts_required() + } +} + +impl Hash for MxConfig { + fn hash(&self, state: &mut H) { + self.max_mx.hash(state); + self.max_multi_homed.hash(state); + } +} + +impl PartialEq for MxConfig { + fn eq(&self, other: &Self) -> bool { + self.max_mx == other.max_mx && self.max_multi_homed == other.max_multi_homed + } +} + +impl Eq for MxConfig {} + +impl QueueName { + pub fn new(name: impl AsRef<[u8]>) -> Option { + let name_bytes = name.as_ref(); + if (1..=8).contains(&name_bytes.len()) { + let mut bytes = [0; 8]; + bytes[..name_bytes.len()].copy_from_slice(name_bytes); + QueueName(bytes).into() + } else { + None + } + } + + pub fn from_bytes(name: &[u8]) -> Option { + name.try_into().ok().map(|bytes: [u8; 8]| QueueName(bytes)) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.0) + .unwrap_or_default() + .trim_end_matches('\0') + } + + pub fn into_inner(self) -> [u8; 8] { + self.0 + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl ArchivedQueueName { + pub fn as_str(&self) -> &str { + std::str::from_utf8(self.0.as_ref()) + .unwrap_or_default() + .trim_end_matches('\0') + } + + pub fn as_slice(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl Default for QueueName { + fn default() -> Self { + DEFAULT_QUEUE_NAME + } +} + +impl Display for QueueName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +impl Display for ArchivedQueueName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +impl AsRef<[u8]> for QueueName { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/crates/common/src/config/smtp/report.rs b/crates/common/src/config/smtp/report.rs new file mode 100644 index 0000000..eaf7751 --- /dev/null +++ b/crates/common/src/config/smtp/report.rs @@ -0,0 +1,273 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::expr::{ + Variable, + if_block::{BootstrapExprExt, IfBlock}, +}; +use registry::schema::{ + enums::ExpressionConstant, + prelude::ObjectType, + structs::{ + DataRetention, DkimReportSettings, DmarcReportSettings, ReportSettings, SpfReportSettings, + TlsReportSettings, + }, +}; +use std::{str::FromStr, time::Duration}; + +#[derive(Clone)] +pub struct ReportConfig { + pub submitter: IfBlock, + pub analysis: ReportAnalysis, + + pub dkim: Report, + pub spf: Report, + pub dmarc: Report, + pub dmarc_aggregate: AggregateReport, + pub tls: AggregateReport, +} + +#[derive(Clone)] +pub struct ReportAnalysis { + pub addresses: Vec, + pub forward: bool, + pub store: Option, + pub max_size: usize, +} + +#[derive(Clone)] +pub enum AddressMatch { + StartsWith(String), + EndsWith(String), + Equals(String), +} + +#[derive(Clone)] +pub struct AggregateReport { + pub name: IfBlock, + pub address: IfBlock, + pub org_name: IfBlock, + pub contact_info: IfBlock, + pub send: IfBlock, + pub sign: IfBlock, + pub max_size: IfBlock, +} + +#[derive(Clone)] +pub struct Report { + pub name: IfBlock, + pub address: IfBlock, + pub subject: IfBlock, + pub sign: IfBlock, + pub send: IfBlock, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AggregateFrequency { + Hourly, + Daily, + Weekly, + #[default] + Never, +} + +impl ReportConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let report = bp.setting_infallible::().await; + let dkim = bp.setting_infallible::().await; + let spf = bp.setting_infallible::().await; + let dmarc = bp.setting_infallible::().await; + let tls = bp.setting_infallible::().await; + let dr = bp.setting_infallible::().await; + + ReportConfig { + submitter: bp.compile_expr( + ObjectType::ReportSettings.singleton(), + &report.ctx_outbound_report_submitter(), + ), + analysis: ReportAnalysis { + addresses: report + .inbound_report_addresses + .iter() + .filter_map(|addr| AddressMatch::from_str(addr).ok()) + .collect(), + forward: report.inbound_report_forwarding, + store: dr.hold_mta_reports_for.map(|d| d.into_inner()), + max_size: std::cmp::max(report.inbound_report_max_size, 1024) as usize, + }, + dkim: Report { + name: bp.compile_expr( + ObjectType::DkimReportSettings.singleton(), + &dkim.ctx_from_name(), + ), + address: bp.compile_expr( + ObjectType::DkimReportSettings.singleton(), + &dkim.ctx_from_address(), + ), + subject: bp.compile_expr( + ObjectType::DkimReportSettings.singleton(), + &dkim.ctx_subject(), + ), + sign: bp.compile_expr( + ObjectType::DkimReportSettings.singleton(), + &dkim.ctx_dkim_sign_domain(), + ), + send: bp.compile_expr( + ObjectType::DkimReportSettings.singleton(), + &dkim.ctx_send_frequency(), + ), + }, + spf: Report { + name: bp.compile_expr( + ObjectType::SpfReportSettings.singleton(), + &spf.ctx_from_name(), + ), + address: bp.compile_expr( + ObjectType::SpfReportSettings.singleton(), + &spf.ctx_from_address(), + ), + subject: bp.compile_expr( + ObjectType::SpfReportSettings.singleton(), + &spf.ctx_subject(), + ), + sign: bp.compile_expr( + ObjectType::SpfReportSettings.singleton(), + &spf.ctx_dkim_sign_domain(), + ), + send: bp.compile_expr( + ObjectType::SpfReportSettings.singleton(), + &spf.ctx_send_frequency(), + ), + }, + dmarc: Report { + name: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_failure_from_name(), + ), + address: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_failure_from_address(), + ), + subject: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_failure_subject(), + ), + sign: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_failure_dkim_sign_domain(), + ), + send: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_failure_send_frequency(), + ), + }, + dmarc_aggregate: AggregateReport { + name: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_from_name(), + ), + address: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_from_address(), + ), + org_name: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_org_name(), + ), + contact_info: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_contact_info(), + ), + send: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_send_frequency(), + ), + sign: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_dkim_sign_domain(), + ), + max_size: bp.compile_expr( + ObjectType::DmarcReportSettings.singleton(), + &dmarc.ctx_aggregate_max_report_size(), + ), + }, + tls: AggregateReport { + name: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_from_name(), + ), + address: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_from_address(), + ), + org_name: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_org_name(), + ), + contact_info: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_contact_info(), + ), + send: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_send_frequency(), + ), + sign: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_dkim_sign_domain(), + ), + max_size: bp.compile_expr( + ObjectType::TlsReportSettings.singleton(), + &tls.ctx_max_report_size(), + ), + }, + } + } +} + +impl<'x> TryFrom> for AggregateFrequency { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Constant(ExpressionConstant::Disable) => Ok(AggregateFrequency::Never), + Variable::Constant(ExpressionConstant::Hourly) => Ok(AggregateFrequency::Hourly), + Variable::Constant(ExpressionConstant::Daily) => Ok(AggregateFrequency::Daily), + Variable::Constant(ExpressionConstant::Weekly) => Ok(AggregateFrequency::Weekly), + _ => Err(()), + } + } +} + +impl ReportAnalysis { + pub fn is_report_address(&self, address: &str) -> bool { + self.addresses.iter().any(|addr_match| match addr_match { + AddressMatch::StartsWith(prefix) => address.starts_with(prefix), + AddressMatch::EndsWith(suffix) => address.ends_with(suffix), + AddressMatch::Equals(value) => address == value, + }) + } +} + +impl FromStr for AddressMatch { + type Err = String; + + fn from_str(value: &str) -> Result { + if let Some(value) = value.strip_prefix('*').map(|v| v.trim()) { + if !value.is_empty() { + return Ok(AddressMatch::EndsWith(value.to_lowercase())); + } + } else if let Some(value) = value.strip_suffix('*').map(|v| v.trim()) { + if !value.is_empty() { + return Ok(AddressMatch::StartsWith(value.to_lowercase())); + } + } else if value.contains('@') { + return Ok(AddressMatch::Equals(value.trim().to_lowercase())); + } + Err(format!("Invalid address match value {:?}.", value,)) + } +} diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs new file mode 100644 index 0000000..cedf560 --- /dev/null +++ b/crates/common/src/config/smtp/resolver.rs @@ -0,0 +1,406 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use mail_auth::{ + MessageAuthenticator, + hickory_resolver::{ + TokioResolver, + config::{ + CLOUDFLARE, ConnectionConfig, GOOGLE, NameServerConfig, ProtocolConfig, QUAD9, + ResolverConfig, ResolverOpts, + }, + net::runtime::TokioRuntimeProvider, + system_conf::read_system_conf, + }, +}; +use registry::schema::{ + enums::{DnsResolverProtocol, PolicyEnforcement}, + prelude::ObjectType, + structs::{DnsResolver, MtaSts, SystemSettings}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::Display, + hash::{DefaultHasher, Hash, Hasher}, + net::IpAddr, + str::FromStr, + sync::Arc, +}; +use store::registry::bootstrap::Bootstrap; +use utils::cache::CacheItemWeight; + +pub struct Resolvers { + pub dns: MessageAuthenticator, + pub dnssec: DnssecResolver, + pub dnssec_available: bool, +} + +#[derive(Clone)] +pub struct DnssecResolver { + pub resolver: TokioResolver, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum TlsaMatching { + Full, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct TlsaEntry { + pub is_end_entity: bool, + pub is_spki: bool, + pub matching: TlsaMatching, + pub data: Vec, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tlsa { + pub entries: Vec, + pub has_end_entities: bool, + pub has_intermediates: bool, +} + +#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + Enforce, + Testing, + #[default] + None, +} + +#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MxPattern { + Equals(String), + StartsWith(String), +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct Policy { + pub id: String, + pub mode: Mode, + pub mx: Box<[MxPattern]>, + pub max_age: u64, +} + +impl CacheItemWeight for Tlsa { + fn weight(&self) -> u64 { + self.entries + .iter() + .map(|entry| (entry.data.len() + std::mem::size_of::()) as u64) + .sum::() + + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Policy { + fn weight(&self) -> u64 { + (std::mem::size_of::() + + self + .mx + .iter() + .map(|mx| match mx { + MxPattern::Equals(t) => t.len(), + MxPattern::StartsWith(t) => t.len(), + }) + .sum::()) as u64 + } +} + +impl Resolvers { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let mut resolver_config: ResolverConfig; + let mut opts = ResolverOpts::default(); + + match bp.setting_infallible::().await { + DnsResolver::System(resolver) => match read_system_conf() { + Ok((config, options)) => { + resolver_config = config; + opts = options; + opts.num_concurrent_reqs = resolver.concurrency as usize; + opts.timeout = resolver.timeout.into_inner(); + opts.preserve_intermediates = resolver.preserve_intermediates; + opts.try_tcp_on_error = resolver.tcp_on_error; + opts.attempts = resolver.attempts as usize; + opts.edns0 = resolver.enable_edns; + } + Err(err) => { + bp.build_error( + ObjectType::DnsResolver.singleton(), + format!("Failed to read system DNS config: {err}"), + ); + resolver_config = ResolverConfig::udp_and_tcp(&CLOUDFLARE); + } + }, + DnsResolver::Custom(resolver) => { + resolver_config = ResolverConfig::default(); + let mut nameservers: AHashMap> = AHashMap::new(); + + for server in resolver.servers { + let ip = server.address.into_inner(); + let port = server.port as u16; + let protocol = match server.protocol { + DnsResolverProtocol::Udp => ProtocolConfig::Udp, + DnsResolverProtocol::Tcp => ProtocolConfig::Tcp, + DnsResolverProtocol::Tls => ProtocolConfig::Tls { + server_name: Arc::from(server.address.to_string()), + }, + }; + let mut connection = ConnectionConfig::new(protocol); + connection.port = port; + nameservers.entry(ip).or_default().push(connection); + } + + for (ip, connections) in nameservers { + resolver_config.add_name_server(NameServerConfig::new(ip, true, connections)); + } + + opts.num_concurrent_reqs = resolver.concurrency as usize; + opts.timeout = resolver.timeout.into_inner(); + opts.preserve_intermediates = resolver.preserve_intermediates; + opts.try_tcp_on_error = resolver.tcp_on_error; + opts.attempts = resolver.attempts as usize; + opts.edns0 = resolver.enable_edns; + } + DnsResolver::Cloudflare(resolver) => { + resolver_config = if resolver.use_tls { + ResolverConfig::tls(&CLOUDFLARE) + } else { + ResolverConfig::udp_and_tcp(&CLOUDFLARE) + }; + + opts.num_concurrent_reqs = resolver.concurrency as usize; + opts.timeout = resolver.timeout.into_inner(); + opts.preserve_intermediates = resolver.preserve_intermediates; + opts.try_tcp_on_error = resolver.tcp_on_error; + opts.attempts = resolver.attempts as usize; + opts.edns0 = resolver.enable_edns; + } + DnsResolver::Quad9(resolver) => { + resolver_config = if resolver.use_tls { + ResolverConfig::tls(&QUAD9) + } else { + ResolverConfig::udp_and_tcp(&QUAD9) + }; + opts.num_concurrent_reqs = resolver.concurrency as usize; + opts.timeout = resolver.timeout.into_inner(); + opts.preserve_intermediates = resolver.preserve_intermediates; + opts.try_tcp_on_error = resolver.tcp_on_error; + opts.attempts = resolver.attempts as usize; + opts.edns0 = resolver.enable_edns; + } + DnsResolver::Google(resolver) => { + resolver_config = ResolverConfig::udp_and_tcp(&GOOGLE); + opts.num_concurrent_reqs = resolver.concurrency as usize; + opts.timeout = resolver.timeout.into_inner(); + opts.preserve_intermediates = resolver.preserve_intermediates; + opts.try_tcp_on_error = resolver.tcp_on_error; + opts.attempts = resolver.attempts as usize; + opts.edns0 = resolver.enable_edns; + } + } + + // We already have a cache, so disable the built-in cache + opts.cache_size = 0; + + // Prepare DNSSEC resolver options + let config_dnssec = resolver_config.clone(); + let mut opts_dnssec = opts.clone(); + opts_dnssec.validate = true; + + let dnssec = DnssecResolver { + resolver: TokioResolver::builder_with_config( + config_dnssec, + TokioRuntimeProvider::default(), + ) + .with_options(opts_dnssec) + .build() + .expect("Failed to build DNSSEC resolver"), + }; + + Resolvers { + #[cfg(not(feature = "test_mode"))] + dnssec_available: ensure_dnssec(&resolver_config, &dnssec.resolver).await, + #[cfg(feature = "test_mode")] + dnssec_available: true, + dns: MessageAuthenticator::new(resolver_config, opts).unwrap(), + dnssec, + } + } +} + +#[cfg(not(feature = "test_mode"))] +async fn ensure_dnssec(config: &ResolverConfig, resolver: &TokioResolver) -> bool { + config.name_servers().iter().any(|name_server| { + name_server + .connections + .iter() + .any(|connection| !matches!(connection.protocol, ProtocolConfig::Udp)) + }) && resolver + .lookup( + hickory_proto::rr::Name::root(), + hickory_proto::rr::RecordType::DNSKEY, + ) + .await + .is_ok_and(|lookup| { + lookup + .answers() + .iter() + .any(|record| record.proof.is_secure()) + }) +} + +impl Policy { + pub async fn try_parse(bp: &mut Bootstrap) -> Option { + let mta = bp.setting_infallible::().await; + + if matches!(mta.mode, PolicyEnforcement::Disable) { + return None; + } + + let mut mx_hosts = mta.mx_hosts.into_inner(); + + if mx_hosts.is_empty() { + let settings = bp.setting_infallible::().await; + let default_host = settings.default_hostname.as_str(); + mx_hosts = settings + .mail_exchangers + .iter() + .map(|mx| mx.hostname.as_deref().unwrap_or(default_host).to_string()) + .collect(); + } + + if !mx_hosts.is_empty() { + mx_hosts.sort_unstable(); + mx_hosts.dedup(); + + let mut policy = Policy { + id: Default::default(), + mode: match mta.mode { + PolicyEnforcement::Enforce => Mode::Enforce, + PolicyEnforcement::Testing => Mode::Testing, + PolicyEnforcement::Disable => Mode::None, + }, + mx: mx_hosts + .into_iter() + .map(|mx| { + if let Some(mx) = mx.strip_prefix("*.") { + MxPattern::StartsWith(mx.to_string()) + } else { + MxPattern::Equals(mx) + } + }) + .collect(), + max_age: mta.max_age.into_inner().as_secs(), + }; + + policy.id = policy.hash().to_string(); + + Some(policy) + } else { + None + } + } + + fn hash(&self) -> u64 { + let mut s = DefaultHasher::new(); + self.mode.hash(&mut s); + self.max_age.hash(&mut s); + self.mx.hash(&mut s); + s.finish() + } +} + +impl FromStr for Mode { + type Err = String; + fn from_str(value: &str) -> Result { + match value { + "enforce" => Ok(Self::Enforce), + "testing" | "test" => Ok(Self::Testing), + "none" => Ok(Self::None), + _ => Err(format!("Invalid mode value {value:?}")), + } + } +} + +impl Default for Resolvers { + fn default() -> Self { + let (config, opts) = match read_system_conf() { + Ok(conf) => conf, + Err(_) => ( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + ResolverOpts::default(), + ), + }; + + let config_dnssec = config.clone(); + let mut opts_dnssec = opts.clone(); + opts_dnssec.validate = true; + + Self { + dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"), + dnssec: DnssecResolver { + resolver: TokioResolver::builder_with_config( + config_dnssec, + TokioRuntimeProvider::default(), + ) + .with_options(opts_dnssec) + .build() + .expect("Failed to build DNSSEC resolver"), + }, + dnssec_available: true, + } + } +} + +impl Display for Policy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("version: STSv1\r\n")?; + f.write_str("mode: ")?; + match self.mode { + Mode::Enforce => f.write_str("enforce")?, + Mode::Testing => f.write_str("testing")?, + Mode::None => f.write_str("none")?, + } + f.write_str("\r\nmax_age: ")?; + self.max_age.fmt(f)?; + f.write_str("\r\n")?; + + for mx in &self.mx { + f.write_str("mx: ")?; + mx.fmt(f)?; + f.write_str("\r\n")?; + } + + Ok(()) + } +} + +impl Display for MxPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MxPattern::Equals(mx) => f.write_str(mx), + MxPattern::StartsWith(mx) => { + f.write_str("*.")?; + f.write_str(mx) + } + } + } +} + +impl Clone for Resolvers { + fn clone(&self) -> Self { + Self { + dns: self.dns.clone(), + dnssec: self.dnssec.clone(), + dnssec_available: self.dnssec_available, + } + } +} diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs new file mode 100644 index 0000000..2efcb13 --- /dev/null +++ b/crates/common/src/config/smtp/session.rs @@ -0,0 +1,567 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use self::resolver::Policy; +use super::*; +use crate::expr::{ + Variable, + if_block::{BootstrapExprExt, IfBlock}, +}; +use ahash::AHashSet; +use hyper::HeaderMap; +use registry::schema::{ + enums::{self, ExpressionConstant, MtaStage}, + prelude::ObjectType, + structs::{ + MtaExtensions, MtaHook, MtaInboundSession, MtaMilter, MtaStageAuth, MtaStageConnect, + MtaStageData, MtaStageEhlo, MtaStageMail, MtaStageRcpt, + }, +}; +use smtp_proto::*; +use std::{ + net::{SocketAddr, ToSocketAddrs}, + str::FromStr, + time::Duration, +}; + +#[derive(Clone)] +pub struct SessionConfig { + pub timeout: IfBlock, + pub duration: IfBlock, + pub transfer_limit: IfBlock, + + pub connect: Connect, + pub ehlo: Ehlo, + pub auth: Auth, + pub mail: Mail, + pub rcpt: Rcpt, + pub data: Data, + pub extensions: Extensions, + pub mta_sts_policy: Option, + + pub milters: Vec, + pub hooks: Vec, +} + +#[derive(Clone)] +pub struct Connect { + pub hostname: IfBlock, + pub script: IfBlock, + pub greeting: IfBlock, +} + +#[derive(Clone)] +pub struct Ehlo { + pub script: IfBlock, + pub require: IfBlock, + pub reject_non_fqdn: IfBlock, +} + +#[derive(Clone)] +pub struct Extensions { + pub pipelining: IfBlock, + pub chunking: IfBlock, + pub requiretls: IfBlock, + pub dsn: IfBlock, + pub vrfy: IfBlock, + pub expn: IfBlock, + pub no_soliciting: IfBlock, + pub future_release: IfBlock, + pub deliver_by: IfBlock, + pub mt_priority: IfBlock, +} + +#[derive(Clone)] +pub struct Auth { + pub mechanisms: IfBlock, + pub require: IfBlock, + pub must_match_sender: IfBlock, + pub errors_max: IfBlock, + pub errors_wait: IfBlock, +} + +#[derive(Clone)] +pub struct Mail { + pub script: IfBlock, + pub rewrite: IfBlock, + pub is_allowed: IfBlock, +} + +#[derive(Clone)] +pub struct Rcpt { + pub script: IfBlock, + pub relay: IfBlock, + pub rewrite: IfBlock, + pub errors_max: IfBlock, + pub errors_wait: IfBlock, + pub max_recipients: IfBlock, +} + +#[derive(Debug, Default, Clone)] +pub enum AddressMapping { + Enable, + Custom(IfBlock), + #[default] + Disable, +} + +#[derive(Clone)] +pub struct Data { + pub script: IfBlock, + pub spam_filter: IfBlock, + pub max_messages: IfBlock, + pub max_message_size: IfBlock, + pub max_received_headers: IfBlock, + pub add_received: IfBlock, + pub add_received_spf: IfBlock, + pub add_return_path: IfBlock, + pub add_auth_results: IfBlock, + pub add_message_id: IfBlock, + pub add_date: IfBlock, + pub add_delivered_to: bool, +} + +#[derive(Clone)] +pub struct Milter { + pub enable: IfBlock, + pub id: ObjectId, + pub addrs: Vec, + pub hostname: String, + pub port: u16, + pub timeout_connect: Duration, + pub timeout_command: Duration, + pub timeout_data: Duration, + pub tls: bool, + pub tls_allow_invalid_certs: bool, + pub tempfail_on_error: bool, + pub max_frame_len: usize, + pub protocol_version: MilterVersion, + pub flags_actions: Option, + pub flags_protocol: Option, + pub run_on_stage: AHashSet, +} + +#[derive(Clone, Copy)] +pub enum MilterVersion { + V2, + V6, +} + +#[derive(Clone)] +pub struct MTAHook { + pub enable: IfBlock, + pub id: ObjectId, + pub url: String, + pub timeout: Duration, + pub headers: HeaderMap, + pub tls_allow_invalid_certs: bool, + pub tempfail_on_error: bool, + pub run_on_stage: AHashSet, + pub max_response_size: usize, + pub client: reqwest::Client, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum Stage { + Connect, + Ehlo, + Auth, + Mail, + Rcpt, + Data, +} + +impl SessionConfig { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let session = bp.setting_infallible::().await; + let connect = bp.setting_infallible::().await; + let auth = bp.setting_infallible::().await; + let ehlo = bp.setting_infallible::().await; + let mail = bp.setting_infallible::().await; + let rcpt = bp.setting_infallible::().await; + let data = bp.setting_infallible::().await; + let ext = bp.setting_infallible::().await; + + let mut hooks = Vec::new(); + + for hook in bp.list_infallible::().await { + let id = hook.id; + let hook = hook.object; + let enable = bp.compile_expr(id, &hook.ctx_enable()); + let headers = match hook + .http_auth + .build_headers(hook.http_headers, "application/json".into()) + .await + { + Ok(headers) => headers, + Err(err) => { + bp.build_error(id, format!("Unable to build HTTP headers: {}", err)); + continue; + } + }; + + hooks.push(MTAHook { + enable, + id, + url: hook.url, + timeout: hook.timeout.into_inner(), + headers, + tls_allow_invalid_certs: hook.allow_invalid_certs, + tempfail_on_error: hook.temp_fail_on_error, + run_on_stage: hook.stages.into_iter().map(Stage::from).collect(), + max_response_size: hook.max_response_size as usize, + client: utils::http::http_client_builder(hook.allow_invalid_certs) + .build() + .unwrap_or_default(), + }); + } + + SessionConfig { + timeout: bp.compile_expr( + ObjectType::MtaInboundSession.singleton(), + &session.ctx_timeout(), + ), + duration: bp.compile_expr( + ObjectType::MtaInboundSession.singleton(), + &session.ctx_max_duration(), + ), + transfer_limit: bp.compile_expr( + ObjectType::MtaInboundSession.singleton(), + &session.ctx_transfer_limit(), + ), + connect: Connect { + hostname: bp.compile_expr( + ObjectType::MtaStageConnect.singleton(), + &connect.ctx_hostname(), + ), + script: bp.compile_expr( + ObjectType::MtaStageConnect.singleton(), + &connect.ctx_script(), + ), + greeting: bp.compile_expr( + ObjectType::MtaStageConnect.singleton(), + &connect.ctx_smtp_greeting(), + ), + }, + ehlo: Ehlo { + script: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_script()), + require: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_require()), + reject_non_fqdn: bp.compile_expr( + ObjectType::MtaStageEhlo.singleton(), + &ehlo.ctx_reject_non_fqdn(), + ), + }, + auth: Auth { + mechanisms: bp.compile_expr( + ObjectType::MtaStageAuth.singleton(), + &auth.ctx_sasl_mechanisms(), + ), + require: bp.compile_expr(ObjectType::MtaStageAuth.singleton(), &auth.ctx_require()), + must_match_sender: bp.compile_expr( + ObjectType::MtaStageAuth.singleton(), + &auth.ctx_must_match_sender(), + ), + errors_max: bp.compile_expr( + ObjectType::MtaStageAuth.singleton(), + &auth.ctx_max_failures(), + ), + errors_wait: bp.compile_expr( + ObjectType::MtaStageAuth.singleton(), + &auth.ctx_wait_on_fail(), + ), + }, + mail: Mail { + script: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_script()), + rewrite: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_rewrite()), + is_allowed: bp.compile_expr( + ObjectType::MtaStageMail.singleton(), + &mail.ctx_is_sender_allowed(), + ), + }, + rcpt: Rcpt { + script: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_script()), + relay: bp.compile_expr( + ObjectType::MtaStageRcpt.singleton(), + &rcpt.ctx_allow_relaying(), + ), + rewrite: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_rewrite()), + errors_max: bp.compile_expr( + ObjectType::MtaStageRcpt.singleton(), + &rcpt.ctx_max_failures(), + ), + errors_wait: bp.compile_expr( + ObjectType::MtaStageRcpt.singleton(), + &rcpt.ctx_wait_on_fail(), + ), + max_recipients: bp.compile_expr( + ObjectType::MtaStageRcpt.singleton(), + &rcpt.ctx_max_recipients(), + ), + }, + data: Data { + script: bp.compile_expr(ObjectType::MtaStageData.singleton(), &data.ctx_script()), + spam_filter: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_enable_spam_filter(), + ), + max_messages: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_max_messages(), + ), + max_message_size: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_max_message_size(), + ), + max_received_headers: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_max_received_headers(), + ), + add_received: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_received_header(), + ), + add_received_spf: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_received_spf_header(), + ), + add_return_path: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_return_path_header(), + ), + add_auth_results: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_auth_results_header(), + ), + add_message_id: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_message_id_header(), + ), + add_date: bp.compile_expr( + ObjectType::MtaStageData.singleton(), + &data.ctx_add_date_header(), + ), + add_delivered_to: data.add_delivered_to_header, + }, + extensions: Extensions { + pipelining: bp + .compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_pipelining()), + chunking: bp + .compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_chunking()), + requiretls: bp.compile_expr( + ObjectType::MtaExtensions.singleton(), + &ext.ctx_require_tls(), + ), + dsn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_dsn()), + vrfy: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_vrfy()), + expn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_expn()), + no_soliciting: bp.compile_expr( + ObjectType::MtaExtensions.singleton(), + &ext.ctx_no_soliciting(), + ), + future_release: bp.compile_expr( + ObjectType::MtaExtensions.singleton(), + &ext.ctx_future_release(), + ), + deliver_by: bp + .compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_deliver_by()), + mt_priority: bp.compile_expr( + ObjectType::MtaExtensions.singleton(), + &ext.ctx_mt_priority(), + ), + }, + mta_sts_policy: Policy::try_parse(bp).await, + milters: bp + .list_infallible::() + .await + .into_iter() + .filter_map(|milter| { + let id = milter.id; + let milter = milter.object; + + Some(Milter { + enable: bp.compile_expr(id, &milter.ctx_enable()), + id, + addrs: format!("{}:{}", milter.hostname, milter.port) + .to_socket_addrs() + .map_err(|err| { + bp.build_error( + id, + format!( + "Unable to resolve milter hostname {}: {}", + milter.hostname, err + ), + ) + }) + .ok()? + .collect(), + hostname: milter.hostname, + port: milter.port as u16, + timeout_connect: milter.timeout_connect.into_inner(), + timeout_command: milter.timeout_command.into_inner(), + timeout_data: milter.timeout_data.into_inner(), + tls: milter.use_tls, + tls_allow_invalid_certs: milter.allow_invalid_certs, + tempfail_on_error: milter.temp_fail_on_error, + max_frame_len: milter.max_response_size as usize, + protocol_version: match milter.protocol_version { + enums::MilterVersion::V2 => MilterVersion::V2, + enums::MilterVersion::V6 => MilterVersion::V6, + }, + flags_actions: milter.flags_action.map(|v| v as u32), + flags_protocol: milter.flags_protocol.map(|v| v as u32), + run_on_stage: milter.stages.into_iter().map(Stage::from).collect(), + }) + }) + .collect(), + hooks, + } + } +} + +#[derive(Default)] +pub struct Mechanism(u64); + +impl FromStr for Mechanism { + type Err = String; + + fn from_str(value: &str) -> Result { + Ok(Mechanism(match value.to_ascii_uppercase().as_str() { + "LOGIN" => AUTH_LOGIN, + "PLAIN" => AUTH_PLAIN, + "XOAUTH2" => AUTH_XOAUTH2, + "OAUTHBEARER" => AUTH_OAUTHBEARER, + /*"SCRAM-SHA-256-PLUS" => AUTH_SCRAM_SHA_256_PLUS, + "SCRAM-SHA-256" => AUTH_SCRAM_SHA_256, + "SCRAM-SHA-1-PLUS" => AUTH_SCRAM_SHA_1_PLUS, + "SCRAM-SHA-1" => AUTH_SCRAM_SHA_1, + "XOAUTH" => AUTH_XOAUTH, + "9798-M-DSA-SHA1" => AUTH_9798_M_DSA_SHA1, + "9798-M-ECDSA-SHA1" => AUTH_9798_M_ECDSA_SHA1, + "9798-M-RSA-SHA1-ENC" => AUTH_9798_M_RSA_SHA1_ENC, + "9798-U-DSA-SHA1" => AUTH_9798_U_DSA_SHA1, + "9798-U-ECDSA-SHA1" => AUTH_9798_U_ECDSA_SHA1, + "9798-U-RSA-SHA1-ENC" => AUTH_9798_U_RSA_SHA1_ENC, + "EAP-AES128" => AUTH_EAP_AES128, + "EAP-AES128-PLUS" => AUTH_EAP_AES128_PLUS, + "ECDH-X25519-CHALLENGE" => AUTH_ECDH_X25519_CHALLENGE, + "ECDSA-NIST256P-CHALLENGE" => AUTH_ECDSA_NIST256P_CHALLENGE, + "EXTERNAL" => AUTH_EXTERNAL, + "GS2-KRB5" => AUTH_GS2_KRB5, + "GS2-KRB5-PLUS" => AUTH_GS2_KRB5_PLUS, + "GSS-SPNEGO" => AUTH_GSS_SPNEGO, + "GSSAPI" => AUTH_GSSAPI, + "KERBEROS_V4" => AUTH_KERBEROS_V4, + "KERBEROS_V5" => AUTH_KERBEROS_V5, + "NMAS-SAMBA-AUTH" => AUTH_NMAS_SAMBA_AUTH, + "NMAS_AUTHEN" => AUTH_NMAS_AUTHEN, + "NMAS_LOGIN" => AUTH_NMAS_LOGIN, + "NTLM" => AUTH_NTLM, + "OAUTH10A" => AUTH_OAUTH10A, + "OPENID20" => AUTH_OPENID20, + "OTP" => AUTH_OTP, + "SAML20" => AUTH_SAML20, + "SECURID" => AUTH_SECURID, + "SKEY" => AUTH_SKEY, + "SPNEGO" => AUTH_SPNEGO, + "SPNEGO-PLUS" => AUTH_SPNEGO_PLUS, + "SXOVER-PLUS" => AUTH_SXOVER_PLUS, + "CRAM-MD5" => AUTH_CRAM_MD5, + "DIGEST-MD5" => AUTH_DIGEST_MD5, + "ANONYMOUS" => AUTH_ANONYMOUS,*/ + _ => return Err(format!("Unsupported mechanism {:?}.", value)), + })) + } +} + +impl<'x> TryFrom> for Mechanism { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Constant(value) => Mechanism::try_from(value), + Variable::Array(items) => { + let mut mechanism = 0; + + for item in items { + match item { + Variable::Constant(value) => mechanism |= Mechanism::try_from(value)?.0, + _ => return Err(()), + } + } + + Ok(Mechanism(mechanism)) + } + _ => Err(()), + } + } +} + +impl TryFrom for Mechanism { + type Error = (); + + fn try_from(value: ExpressionConstant) -> Result { + match value { + ExpressionConstant::Login => Ok(Mechanism(AUTH_LOGIN)), + ExpressionConstant::Plain => Ok(Mechanism(AUTH_PLAIN)), + ExpressionConstant::Xoauth2 => Ok(Mechanism(AUTH_XOAUTH2)), + ExpressionConstant::Oauthbearer => Ok(Mechanism(AUTH_OAUTHBEARER)), + _ => Err(()), + } + } +} + +impl From for u64 { + fn from(value: Mechanism) -> Self { + value.0 + } +} + +impl From for Mechanism { + fn from(value: u64) -> Self { + Mechanism(value) + } +} + +impl<'x> TryFrom> for MtPriority { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Constant(value) => match value { + ExpressionConstant::Mixer => Ok(MtPriority::Mixer), + ExpressionConstant::Stanag4406 => Ok(MtPriority::Stanag4406), + ExpressionConstant::Nsep => Ok(MtPriority::Nsep), + _ => Err(()), + }, + Variable::String(value) => { + let value = value.as_str(); + if value.eq_ignore_ascii_case("MIXER") { + Ok(MtPriority::Mixer) + } else if value.eq_ignore_ascii_case("STANAG4406") { + Ok(MtPriority::Stanag4406) + } else if value.eq_ignore_ascii_case("NSEP") { + Ok(MtPriority::Nsep) + } else { + Err(()) + } + } + _ => Err(()), + } + } +} + +impl From for Stage { + fn from(value: MtaStage) -> Self { + match value { + MtaStage::Connect => Stage::Connect, + MtaStage::Ehlo => Stage::Ehlo, + MtaStage::Auth => Stage::Auth, + MtaStage::Mail => Stage::Mail, + MtaStage::Rcpt => Stage::Rcpt, + MtaStage::Data => Stage::Data, + } + } +} diff --git a/crates/common/src/config/storage.rs b/crates/common/src/config/storage.rs new file mode 100644 index 0000000..6bc87b4 --- /dev/null +++ b/crates/common/src/config/storage.rs @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use coordinator::Coordinator; +use directory::{Directories, Directory}; +use registry::schema::prelude::ObjectType; +use std::{collections::HashMap, sync::Arc}; +use store::{ + BlobStore, InMemoryStore, RegistryStore, SearchStore, Store, registry::bootstrap::Bootstrap, +}; + +pub type IdMap = HashMap, nohash_hasher::BuildNoHashHasher>; + +#[derive(Clone)] +pub struct Storage { + pub registry: RegistryStore, + pub data: Store, + pub blob: BlobStore, + pub search: SearchStore, + pub memory: InMemoryStore, + pub metrics: Store, + pub tracing: Store, + pub coordinator: Coordinator, + pub directory: Option>, + pub directories: IdMap, +} + +impl Storage { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let memory = InMemoryStore::build(bp).await.unwrap_or_default(); + let directory = Directories::build(bp).await; + let search = SearchStore::build(bp).await.unwrap_or_default(); + + if let Err(err) = search.create_indexes().await { + bp.build_warning( + ObjectType::SearchStore.singleton(), + format!("Failed to create search indexes: {err}"), + ); + } + + Storage { + registry: bp.registry.clone(), + data: bp.data_store.clone(), + blob: BlobStore::build(bp).await.unwrap_or_default(), + search, + coordinator: Coordinator::build(bp, &memory).await.unwrap_or_default(), + memory, + tracing: Store::build_tracing(bp).await.unwrap_or_default(), + metrics: Store::build_metrics(bp).await.unwrap_or_default(), + directory: directory.default_directory, + directories: directory.directories, + } + } +} diff --git a/crates/common/src/config/telemetry.rs b/crates/common/src/config/telemetry.rs new file mode 100644 index 0000000..fea1760 --- /dev/null +++ b/crates/common/src/config/telemetry.rs @@ -0,0 +1,716 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::storage::Storage; +use ahash::{AHashMap, AHashSet}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hyper::HeaderMap; +use opentelemetry::{InstrumentationScope, KeyValue}; +use opentelemetry_otlp::{ + LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig, +}; +use opentelemetry_sdk::{Resource, metrics::Temporality}; +use opentelemetry_semantic_conventions::resource::SERVICE_VERSION; +use registry::schema::{ + enums::{EventPolicy, LogRotateFrequency}, + prelude::ObjectType, + structs::{self, EventTracingLevel, MetricsPrometheus, Tracer, WebHook}, +}; +use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration}; +use store::registry::bootstrap::Bootstrap; +use trc::{EventType, Level, MetricType, TelemetryEvent, ipc::subscriber::Interests}; + +#[derive(Debug)] +pub struct TelemetrySubscriber { + pub id: String, + pub interests: Interests, + pub typ: TelemetrySubscriberType, + pub lossy: bool, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum TelemetrySubscriberType { + ConsoleTracer(ConsoleTracer), + LogTracer(LogTracer), + OtelTracer(OtelTracer), + Webhook(WebhookTracer), + #[cfg(unix)] + JournalTracer(crate::telemetry::tracers::journald::Subscriber), +} + +#[derive(Debug)] +pub struct OtelTracer { + pub span_exporter: SpanExporter, + pub span_exporter_enable: bool, + pub log_exporter: LogExporter, + pub log_exporter_enable: bool, + pub throttle: Duration, +} + +pub struct OtelMetrics { + pub resource: Resource, + pub instrumentation: InstrumentationScope, + pub exporter: MetricExporter, + pub interval: Duration, +} + +#[derive(Debug)] +pub struct ConsoleTracer { + pub ansi: bool, + pub multiline: bool, + pub buffered: bool, +} + +#[derive(Debug)] +pub struct LogTracer { + pub path: String, + pub prefix: String, + pub rotate: RotationStrategy, + pub ansi: bool, + pub multiline: bool, +} + +#[derive(Debug)] +pub struct WebhookTracer { + pub url: String, + pub key: String, + pub timeout: Duration, + pub throttle: Duration, + pub discard_after: Duration, + pub tls_allow_invalid_certs: bool, + pub headers: HeaderMap, + pub client: reqwest::Client, +} + + +#[derive(Debug)] +pub enum RotationStrategy { + Daily, + Hourly, + Minutely, + Never, +} + +#[derive(Debug)] +pub struct Telemetry { + pub tracers: Tracers, + pub metrics: Interests, +} + +#[derive(Debug)] +pub struct Tracers { + pub interests: Interests, + pub levels: AHashMap, + pub subscribers: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct Metrics { + pub prometheus: Option, + pub otel: Option>, + pub log_path: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct PrometheusMetrics { + pub auth: Option, +} + +impl Telemetry { + pub async fn parse(bp: &mut Bootstrap, storage: &Storage) -> Self { + let mut telemetry = Telemetry { + tracers: Tracers::parse(bp, storage).await, + metrics: Interests::default(), + }; + + // Parse metrics + let metrics = bp.setting_infallible::().await; + apply_metrics(metrics.metrics, metrics.metrics_policy, |metric_type| { + let event_id = metric_type.event_id(); + if event_id != usize::MAX { + telemetry.metrics.set(event_id); + } + }); + + telemetry + } +} + +impl Tracers { + pub async fn parse(bp: &mut Bootstrap, storage: &Storage) -> Self { + let mut custom_levels = AHashMap::new(); + let mut tracers: Vec = Vec::new(); + let mut global_interests = Interests::default(); + + if !bp.registry.is_recovery_mode() { + // Parse custom logging levels + for level in bp.list_infallible::().await { + custom_levels.insert(level.object.event, level.object.level.into()); + } + + // Parse tracers + for tracer in bp.list_infallible::().await { + let id = tracer.id; + let tracer = tracer.object; + let level; + let lossy; + let events; + let events_policy; + let enable; + + let typ = match tracer { + Tracer::Log(tracer) if tracer.enable => { + level = Level::from(tracer.level); + lossy = tracer.lossy; + events = tracer.events; + events_policy = tracer.events_policy; + enable = tracer.enable; + + TelemetrySubscriberType::LogTracer(LogTracer { + path: tracer.path, + prefix: tracer.prefix, + rotate: match tracer.rotate { + LogRotateFrequency::Daily => RotationStrategy::Daily, + LogRotateFrequency::Hourly => RotationStrategy::Hourly, + LogRotateFrequency::Minutely => RotationStrategy::Minutely, + LogRotateFrequency::Never => RotationStrategy::Never, + }, + ansi: tracer.ansi, + multiline: tracer.multiline, + }) + } + Tracer::Stdout(tracer) if tracer.enable => { + level = Level::from(tracer.level); + lossy = tracer.lossy; + events = tracer.events; + events_policy = tracer.events_policy; + enable = tracer.enable; + + if !tracers + .iter() + .any(|t| matches!(t.typ, TelemetrySubscriberType::ConsoleTracer(_))) + { + TelemetrySubscriberType::ConsoleTracer(ConsoleTracer { + ansi: tracer.ansi, + multiline: tracer.multiline, + buffered: tracer.buffered, + }) + } else { + bp.build_error(id, "Only one console tracer is allowed"); + continue; + } + } + Tracer::Journal(tracer) if tracer.enable => { + #[cfg(unix)] + { + level = Level::from(tracer.level); + lossy = tracer.lossy; + events = tracer.events; + events_policy = tracer.events_policy; + enable = tracer.enable; + + if !tracers + .iter() + .any(|t| matches!(t.typ, TelemetrySubscriberType::JournalTracer(_))) + { + match crate::telemetry::tracers::journald::Subscriber::new() { + Ok(subscriber) => { + TelemetrySubscriberType::JournalTracer(subscriber) + } + Err(e) => { + bp.build_error( + id, + format!("Failed to create journald subscriber: {e}"), + ); + continue; + } + } + } else { + bp.build_error(id, "Only one journal tracer is allowed"); + continue; + } + } + + #[cfg(not(unix))] + { + bp.build_error(id, "Journald is only available on Unix systems."); + continue; + } + } + Tracer::OtelHttp(tracer) if tracer.enable => { + level = Level::from(tracer.level); + lossy = tracer.lossy; + events = tracer.events; + events_policy = tracer.events_policy; + enable = tracer.enable; + + let headers = match tracer + .http_auth + .build_headers(tracer.http_headers, None) + .await + { + Ok(headers) => headers + .into_iter() + .filter_map(|(k, v)| { + k.and_then(|k| { + Some((k.to_string(), v.to_str().ok()?.to_string())) + }) + }) + .collect::>(), + Err(err) => { + bp.build_error( + id, + format!("Failed to build OpenTelemetry HTTP headers: {err}"), + ); + continue; + } + }; + + let mut span_exporter = SpanExporter::builder() + .with_http() + .with_endpoint(tracer.endpoint.clone()) + .with_timeout(tracer.timeout.into_inner()); + let mut log_exporter = LogExporter::builder() + .with_http() + .with_endpoint(tracer.endpoint) + .with_timeout(tracer.timeout.into_inner()); + if !headers.is_empty() { + span_exporter = span_exporter.with_headers(headers.clone()); + log_exporter = log_exporter.with_headers(headers); + } + + match (span_exporter.build(), log_exporter.build()) { + (Ok(span_exporter), Ok(log_exporter)) => { + TelemetrySubscriberType::OtelTracer(OtelTracer { + span_exporter, + log_exporter, + throttle: tracer.throttle.into_inner(), + span_exporter_enable: tracer.enable_span_exporter, + log_exporter_enable: tracer.enable_log_exporter, + }) + } + (Err(err), _) => { + bp.build_error( + id, + format!("Failed to build OpenTelemetry span exporter: {err}"), + ); + continue; + } + (_, Err(err)) => { + bp.build_error( + id, + format!("Failed to build OpenTelemetry log exporter: {err}"), + ); + continue; + } + } + } + Tracer::OtelGrpc(tracer) if tracer.enable => { + level = Level::from(tracer.level); + lossy = tracer.lossy; + events = tracer.events; + events_policy = tracer.events_policy; + enable = tracer.enable; + + let mut span_exporter = SpanExporter::builder() + .with_tonic() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) + .with_timeout(tracer.timeout.into_inner()); + let mut log_exporter = LogExporter::builder() + .with_tonic() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) + .with_timeout(tracer.timeout.into_inner()); + if let Some(endpoint) = tracer.endpoint { + span_exporter = span_exporter.with_endpoint(endpoint.clone()); + log_exporter = log_exporter.with_endpoint(endpoint); + } + + match (span_exporter.build(), log_exporter.build()) { + (Ok(span_exporter), Ok(log_exporter)) => { + TelemetrySubscriberType::OtelTracer(OtelTracer { + span_exporter, + log_exporter, + throttle: tracer.throttle.into_inner(), + span_exporter_enable: tracer.enable_span_exporter, + log_exporter_enable: tracer.enable_log_exporter, + }) + } + (Err(err), _) => { + bp.build_error( + id, + format!("Failed to build OpenTelemetry span exporter: {err}"), + ); + continue; + } + (_, Err(err)) => { + bp.build_error( + id, + format!("Failed to build OpenTelemetry log exporter: {err}"), + ); + continue; + } + } + } + _ => continue, + }; + + if !enable { + continue; + } + + // Create tracer + let mut tracer = TelemetrySubscriber { + id: format!("t_{}", id.id()), + interests: Default::default(), + lossy, + typ, + }; + + // Parse disabled events + let exclude_event = match &tracer.typ { + TelemetrySubscriberType::ConsoleTracer(_) => None, + TelemetrySubscriberType::LogTracer(_) => { + EventType::Telemetry(TelemetryEvent::LogError).into() + } + TelemetrySubscriberType::OtelTracer(_) => { + EventType::Telemetry(TelemetryEvent::OtelExporterError).into() + } + TelemetrySubscriberType::Webhook(_) => { + EventType::Telemetry(TelemetryEvent::WebhookError).into() + } + #[cfg(unix)] + TelemetrySubscriberType::JournalTracer(_) => { + EventType::Telemetry(TelemetryEvent::JournalError).into() + } + }; + + // Parse disabled events + apply_events(events, events_policy, |event_type| { + if exclude_event != Some(event_type) { + let event_level = custom_levels + .get(&event_type) + .copied() + .unwrap_or(event_type.level()); + if level.is_contained(event_level) { + tracer.interests.set(event_type); + global_interests.set(event_type); + } + } + }); + + if !tracer.interests.is_empty() { + tracers.push(tracer); + } else { + bp.build_warning(id, "No events enabled for tracer"); + } + } + + + // Parse webhooks + for hook in bp.list_infallible::().await { + let id = hook.id; + let hook = hook.object; + + if !hook.enable { + continue; + } + + let headers = match hook + .http_auth + .build_headers(hook.http_headers, "application/json".into()) + .await + { + Ok(headers) => headers, + Err(err) => { + bp.build_error(id, format!("Unable to build HTTP headers: {}", err)); + continue; + } + }; + + // Build tracer + let mut tracer = TelemetrySubscriber { + id: format!("w_{}", id.id()), + interests: Default::default(), + lossy: hook.lossy, + typ: TelemetrySubscriberType::Webhook(WebhookTracer { + url: hook.url, + timeout: hook.timeout.into_inner(), + tls_allow_invalid_certs: hook.allow_invalid_certs, + client: utils::http::http_client_builder(hook.allow_invalid_certs) + .build() + .unwrap_or_default(), + headers, + key: hook + .signature_key + .secret() + .await + .map_err(|err| { + bp.build_error( + id, + format!("Unable to retrieve signature key: {}", err), + ); + }) + .unwrap_or_default() + .unwrap_or_default() + .into_owned(), + throttle: hook.throttle.into_inner(), + discard_after: hook.discard_after.into_inner(), + }), + }; + + // Parse webhook events + apply_events(hook.events, hook.events_policy, |event_type| { + if event_type != EventType::Telemetry(TelemetryEvent::WebhookError) { + tracer.interests.set(event_type); + global_interests.set(event_type); + } + }); + + if !tracer.interests.is_empty() { + tracers.push(tracer); + } else { + bp.build_error(id, "No events enabled for webhook"); + } + } + + #[cfg(feature = "dev_mode")] + if let Ok(level) = std::env::var("LOG") { + let level = Level::from_str(&level).expect("Invalid LOG level"); + for event_type in EventType::variants() { + let event_level = custom_levels + .get(event_type) + .copied() + .unwrap_or(event_type.level()); + if level.is_contained(event_level) { + global_interests.set(event_type.to_id() as usize); + } + } + + tracers.push(TelemetrySubscriber { + id: "default".to_string(), + interests: global_interests.clone(), + typ: TelemetrySubscriberType::ConsoleTracer(ConsoleTracer { + ansi: true, + multiline: false, + buffered: true, + }), + lossy: false, + }); + } + } else { + // Add default tracer if none were found + let level = std::env::var("STALWART_RECOVERY_MODE_LOG_LEVEL") + .ok() + .and_then(|level| Level::from_str(&level).ok()) + .unwrap_or(Level::Info); + for event_type in EventType::variants() { + let event_level = custom_levels + .get(event_type) + .copied() + .unwrap_or(event_type.level()); + if level.is_contained(event_level) { + global_interests.set(event_type.to_id() as usize); + } + } + + tracers.push(TelemetrySubscriber { + id: "recover-log".to_string(), + interests: global_interests.clone(), + typ: TelemetrySubscriberType::ConsoleTracer(ConsoleTracer { + ansi: true, + multiline: false, + buffered: true, + }), + lossy: false, + }); + } + + Tracers { + subscribers: tracers, + interests: global_interests, + levels: custom_levels, + } + } +} + +impl Metrics { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let metrics = bp.setting_infallible::().await; + let resource = Resource::builder() + .with_service_name("stalwart") + .with_attribute(KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION"))) + .build(); + let instrumentation = InstrumentationScope::builder("stalwart") + .with_version(env!("CARGO_PKG_VERSION")) + .build(); + + Metrics { + prometheus: match metrics.prometheus { + MetricsPrometheus::Enabled(prom) => { + let secret = prom + .auth_secret + .secret() + .await + .map_err(|err| { + bp.build_error( + ObjectType::Metrics.singleton(), + format!("Unable to retrieve Prometheus auth secret: {err}"), + ); + }) + .unwrap_or_default(); + Some(PrometheusMetrics { + auth: prom.auth_username.and_then(|user| { + secret.map(|secret| STANDARD.encode(format!("{user}:{secret}"))) + }), + }) + } + MetricsPrometheus::Disabled => None, + }, + otel: match metrics.open_telemetry { + structs::MetricsOtel::Http(otel) => { + let headers = match otel.http_auth.build_headers(otel.http_headers, None).await + { + Ok(headers) => headers + .into_iter() + .filter_map(|(k, v)| { + k.and_then(|k| Some((k.to_string(), v.to_str().ok()?.to_string()))) + }) + .collect::>(), + Err(err) => { + bp.build_error( + ObjectType::Metrics.singleton(), + format!("Failed to build OpenTelemetry HTTP headers: {err}"), + ); + Default::default() + } + }; + + let mut exporter = MetricExporter::builder() + .with_temporality(Temporality::Delta) + .with_http() + .with_endpoint(otel.endpoint) + .with_timeout(otel.timeout.into_inner()); + if !headers.is_empty() { + exporter = exporter.with_headers(headers); + } + + match exporter.build() { + Ok(exporter) => Some(Arc::new(OtelMetrics { + exporter, + interval: otel.interval.into_inner(), + resource, + instrumentation, + })), + Err(err) => { + bp.build_error( + ObjectType::Metrics.singleton(), + format!("Failed to build OpenTelemetry metrics exporter: {err}"), + ); + None + } + } + } + structs::MetricsOtel::Grpc(otel) => { + let mut exporter = MetricExporter::builder() + .with_temporality(Temporality::Delta) + .with_tonic() + .with_protocol(opentelemetry_otlp::Protocol::Grpc) + .with_timeout(otel.timeout.into_inner()); + if let Some(endpoint) = otel.endpoint { + exporter = exporter.with_endpoint(endpoint); + } + + match exporter.build() { + Ok(exporter) => Some(Arc::new(OtelMetrics { + exporter, + interval: otel.interval.into_inner(), + resource, + instrumentation, + })), + Err(err) => { + bp.build_error( + ObjectType::Metrics.singleton(), + format!("Failed to build OpenTelemetry metrics exporter: {err}"), + ); + None + } + } + } + structs::MetricsOtel::Disabled => None, + }, + log_path: bp + .list_infallible::() + .await + .into_iter() + .find_map(|tracer| { + if let Tracer::Log(log_tracer) = tracer.object + && log_tracer.enable + { + Some(log_tracer.path) + } else { + None + } + }), + } + } +} + +fn apply_events( + event_types: impl IntoIterator, + policy: EventPolicy, + mut apply_fn: impl FnMut(EventType), +) { + let mut exclude_events = AHashSet::new(); + + for event_type in event_types { + if policy == EventPolicy::Include { + apply_fn(event_type); + } else { + exclude_events.insert(event_type); + } + } + + if policy != EventPolicy::Include { + for event_type in EventType::variants() { + if !exclude_events.contains(event_type) { + apply_fn(*event_type); + } + } + } +} + +fn apply_metrics( + event_types: impl IntoIterator, + policy: EventPolicy, + mut apply_fn: impl FnMut(MetricType), +) { + let mut exclude_events = AHashSet::new(); + + for event_type in event_types { + if policy == EventPolicy::Include { + apply_fn(event_type); + } else { + exclude_events.insert(event_type); + } + } + + if policy != EventPolicy::Include { + for event_type in MetricType::variants() { + if !exclude_events.contains(event_type) { + apply_fn(*event_type); + } + } + } +} + +impl std::fmt::Debug for OtelMetrics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OtelMetrics") + .field("interval", &self.interval) + .finish() + } +} diff --git a/crates/common/src/expr/eval.rs b/crates/common/src/expr/eval.rs new file mode 100644 index 0000000..3fc27a8 --- /dev/null +++ b/crates/common/src/expr/eval.rs @@ -0,0 +1,787 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + BinaryOperator, Constant, Expression, ExpressionItem, StringCow, SystemVariable, UnaryOperator, + Variable, + functions::{FUNCTIONS, ResolveVariable}, + if_block::IfBlock, +}; +use crate::Server; +use compact_str::{CompactString, ToCompactString, format_compact}; +use hyper::StatusCode; +use registry::{ + schema::prelude::Property, + types::{EnumImpl, id::ObjectId}, +}; +use std::{cmp::Ordering, fmt::Display}; +use trc::{Collector, EvalEvent}; + +impl Server { + pub async fn eval_if<'x, R: TryFrom>, V: ResolveVariable>( + &'x self, + if_block: &'x IfBlock, + resolver: &'x V, + session_id: u64, + ) -> Option { + if if_block.is_empty() { + trc::event!( + Eval(EvalEvent::Result), + SpanId = session_id, + Id = if_block.id.to_string(), + Key = if_block.property.as_str(), + Result = "" + ); + + return None; + } + + match (EvalContext { + resolver, + core: self, + expr: if_block, + captures: Vec::new(), + session_id, + }) + .eval() + .await + { + Ok(result) => { + trc::event!( + Eval(EvalEvent::Result), + SpanId = session_id, + Id = if_block.id.to_string(), + Key = if_block.property.as_str(), + Result = format!("{result:?}"), + ); + + match result.try_into() { + Ok(value) => Some(value), + Err(_) => { + trc::event!( + Eval(EvalEvent::Result), + SpanId = session_id, + Id = if_block.id.to_string(), + Key = if_block.property.as_str(), + Result = "", + ); + + None + } + } + } + Err(err) => { + trc::event!( + Eval(EvalEvent::Error), + SpanId = session_id, + Id = if_block.id.to_string(), + Key = if_block.property.as_str(), + CausedBy = err, + ); + + None + } + } + } + + pub async fn eval_expr<'x, R: TryFrom>, V: ResolveVariable>( + &'x self, + expr: &'x Expression, + resolver: &'x V, + obj_id: ObjectId, + property: Property, + session_id: u64, + ) -> Option { + if expr.is_empty() { + return None; + } + + match (EvalContext { + resolver, + core: self, + expr, + captures: &mut Vec::new(), + session_id, + }) + .eval() + .await + { + Ok(result) => { + trc::event!( + Eval(EvalEvent::Result), + SpanId = session_id, + Id = obj_id.to_string(), + Key = property.as_str(), + Result = format!("{result:?}"), + ); + + match result.try_into() { + Ok(value) => Some(value), + Err(_) => { + trc::event!( + Eval(EvalEvent::Error), + SpanId = session_id, + Id = obj_id.to_string(), + Key = property.as_str(), + Details = "Failed to convert result", + ); + + None + } + } + } + Err(err) => { + trc::event!( + Eval(EvalEvent::Error), + SpanId = session_id, + Id = obj_id.to_string(), + Key = property.as_str(), + CausedBy = err, + ); + + None + } + } + } +} + +struct EvalContext<'x, V: ResolveVariable, T, C> { + resolver: &'x V, + core: &'x Server, + expr: &'x T, + captures: C, + session_id: u64, +} + +impl<'x, V: ResolveVariable> EvalContext<'x, V, IfBlock, Vec> { + async fn eval(&mut self) -> trc::Result> { + for if_then in &self.expr.if_then { + if (EvalContext { + resolver: self.resolver, + core: self.core, + expr: &if_then.expr, + captures: &mut self.captures, + session_id: self.session_id, + }) + .eval() + .await? + .to_bool() + { + return (EvalContext { + resolver: self.resolver, + core: self.core, + expr: &if_then.then, + captures: &mut self.captures, + session_id: self.session_id, + }) + .eval() + .await; + } + } + + (EvalContext { + resolver: self.resolver, + core: self.core, + expr: &self.expr.default, + captures: &mut self.captures, + session_id: self.session_id, + }) + .eval() + .await + } +} + +impl<'x, V: ResolveVariable> EvalContext<'x, V, Expression, &mut Vec> { + async fn eval(&mut self) -> trc::Result> { + let mut stack = Vec::new(); + let mut exprs = self.expr.items.iter(); + + while let Some(expr) = exprs.next() { + match expr { + ExpressionItem::Variable(v) => { + stack.push(self.resolver.resolve_variable(*v)); + } + ExpressionItem::Global(v) => { + stack.push(self.resolver.resolve_global(v)); + } + ExpressionItem::Constant(val) => { + stack.push(Variable::from(val)); + } + ExpressionItem::Capture(v) => { + stack.push(Variable::String(StringCow::Owned( + self.captures + .get(*v as usize) + .map(|v| v.as_str()) + .unwrap_or_default() + .to_compact_string(), + ))); + } + ExpressionItem::System(setting) => match setting { + SystemVariable::Hostname => { + stack.push(self.core.core.network.server_name.as_str().into()) + } + SystemVariable::Domain => { + stack.push(self.core.core.email.default_domain_name.as_str().into()) + } + SystemVariable::NodeId => stack.push(self.core.core.network.node_id.into()), + SystemVariable::NodeHostname => { + stack.push(self.core.registry().local_hostname().into()) + } + SystemVariable::NodeRole => stack.push( + self.core + .registry() + .cluster_role() + .unwrap_or_default() + .into(), + ), + SystemVariable::Metric(variable) => { + stack.push(Variable::Float(Collector::read_metric(*variable))); + } + }, + ExpressionItem::UnaryOperator(op) => { + let value = stack.pop().unwrap_or_default(); + stack.push(match op { + UnaryOperator::Not => value.op_not(), + UnaryOperator::Minus => value.op_minus(), + }); + } + ExpressionItem::BinaryOperator(op) => { + let right = stack.pop().unwrap_or_default(); + let left = stack.pop().unwrap_or_default(); + stack.push(match op { + BinaryOperator::Add => left.op_add(right), + BinaryOperator::Subtract => left.op_subtract(right), + BinaryOperator::Multiply => left.op_multiply(right), + BinaryOperator::Divide => left.op_divide(right), + BinaryOperator::And => left.op_and(right), + BinaryOperator::Or => left.op_or(right), + BinaryOperator::Xor => left.op_xor(right), + BinaryOperator::Eq => left.op_eq(right), + BinaryOperator::Ne => left.op_ne(right), + BinaryOperator::Lt => left.op_lt(right), + BinaryOperator::Le => left.op_le(right), + BinaryOperator::Gt => left.op_gt(right), + BinaryOperator::Ge => left.op_ge(right), + }); + } + ExpressionItem::Function { id, num_args } => { + let num_args = *num_args as usize; + + let mut arguments = Variable::array(num_args); + for arg_num in 0..num_args { + arguments[num_args - arg_num - 1] = stack.pop().unwrap_or_default(); + } + + let result = if let Some((_, fnc, _)) = FUNCTIONS.get(*id as usize) { + (fnc)(arguments) + } else { + Box::pin(self.core.eval_fnc( + *id - FUNCTIONS.len() as u32, + arguments, + self.session_id, + )) + .await? + }; + + stack.push(result); + } + ExpressionItem::JmpIf { val, pos } => { + if stack.last().is_some_and(|v| v.to_bool()) == *val { + for _ in 0..*pos { + exprs.next(); + } + } + } + ExpressionItem::ArrayAccess => { + let index = stack + .pop() + .unwrap_or_default() + .to_usize() + .unwrap_or_default(); + let array = stack.pop().unwrap_or_default().into_array(); + stack.push(array.into_iter().nth(index).unwrap_or_default()); + } + ExpressionItem::ArrayBuild(num_items) => { + let num_items = *num_items as usize; + let mut items = Variable::array(num_items); + for arg_num in 0..num_items { + items[num_items - arg_num - 1] = stack.pop().unwrap_or_default(); + } + stack.push(Variable::Array(items)); + } + ExpressionItem::Regex(regex) => { + self.captures.clear(); + let value = stack.pop().unwrap_or_default().into_string(); + + if let Some(captures_) = regex.captures(value.as_ref()) { + for capture in captures_.iter() { + self.captures + .push(capture.map_or("", |m| m.as_str()).to_compact_string()); + } + } + + stack.push(Variable::Integer(!self.captures.is_empty() as i64)); + } + } + } + + Ok(stack.pop().unwrap_or_default()) + } +} + +impl Expression { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn items(&self) -> &[ExpressionItem] { + &self.items + } +} + +impl<'x> Variable<'x> { + pub fn op_add(self, other: Variable<'x>) -> Variable<'x> { + match (self, other) { + (Variable::Integer(a), Variable::Integer(b)) => Variable::Integer(a.saturating_add(b)), + (Variable::Float(a), Variable::Float(b)) => Variable::Float(a + b), + (Variable::Integer(i), Variable::Float(f)) + | (Variable::Float(f), Variable::Integer(i)) => Variable::Float(i as f64 + f), + (Variable::Array(a), Variable::Array(b)) => { + Variable::Array(a.into_iter().chain(b).collect::>()) + } + (Variable::Array(a), b) => { + Variable::Array(a.into_iter().chain([b]).collect::>()) + } + (a, Variable::Array(b)) => { + Variable::Array([a].into_iter().chain(b).collect::>()) + } + (Variable::String(a), b) => { + if !a.is_empty() { + Variable::String(StringCow::Owned(format_compact!("{}{}", a, b))) + } else { + b + } + } + (a, Variable::String(b)) => { + if !b.is_empty() { + Variable::String(StringCow::Owned(format_compact!("{}{}", a, b))) + } else { + a + } + } + (a, Variable::Constant(_)) => a, + (Variable::Constant(_), b) => b, + } + } + + pub fn op_subtract(self, other: Variable<'x>) -> Variable<'x> { + match (self, other) { + (Variable::Integer(a), Variable::Integer(b)) => Variable::Integer(a.saturating_sub(b)), + (Variable::Float(a), Variable::Float(b)) => Variable::Float(a - b), + (Variable::Integer(a), Variable::Float(b)) => Variable::Float(a as f64 - b), + (Variable::Float(a), Variable::Integer(b)) => Variable::Float(a - b as f64), + (Variable::Array(a), b) | (b, Variable::Array(a)) => { + Variable::Array(a.into_iter().filter(|v| v != &b).collect::>()) + } + (a, b) => a.parse_number().op_subtract(b.parse_number()), + } + } + + pub fn op_multiply(self, other: Variable<'x>) -> Variable<'x> { + match (self, other) { + (Variable::Integer(a), Variable::Integer(b)) => Variable::Integer(a.saturating_mul(b)), + (Variable::Float(a), Variable::Float(b)) => Variable::Float(a * b), + (Variable::Integer(i), Variable::Float(f)) + | (Variable::Float(f), Variable::Integer(i)) => Variable::Float(i as f64 * f), + (a, b) => a.parse_number().op_multiply(b.parse_number()), + } + } + + pub fn op_divide(self, other: Variable<'x>) -> Variable<'x> { + match (self, other) { + (Variable::Integer(a), Variable::Integer(b)) => { + Variable::Float(if b != 0 { a as f64 / b as f64 } else { 0.0 }) + } + (Variable::Float(a), Variable::Float(b)) => { + Variable::Float(if b != 0.0 { a / b } else { 0.0 }) + } + (Variable::Integer(a), Variable::Float(b)) => { + Variable::Float(if b != 0.0 { a as f64 / b } else { 0.0 }) + } + (Variable::Float(a), Variable::Integer(b)) => { + Variable::Float(if b != 0 { a / b as f64 } else { 0.0 }) + } + (a, b) => a.parse_number().op_divide(b.parse_number()), + } + } + + pub fn op_and(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self.to_bool() & other.to_bool())) + } + + pub fn op_or(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self.to_bool() | other.to_bool())) + } + + pub fn op_xor(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self.to_bool() ^ other.to_bool())) + } + + pub fn op_eq(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self == other)) + } + + pub fn op_ne(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self != other)) + } + + pub fn op_lt(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self < other)) + } + + pub fn op_le(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self <= other)) + } + + pub fn op_gt(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self > other)) + } + + pub fn op_ge(self, other: Variable) -> Variable { + Variable::Integer(i64::from(self >= other)) + } + + pub fn op_not(self) -> Variable<'static> { + Variable::Integer(i64::from(!self.to_bool())) + } + + pub fn op_minus(self) -> Variable<'static> { + match self { + Variable::Integer(n) => Variable::Integer(-n), + Variable::Float(n) => Variable::Float(-n), + _ => self.parse_number().op_minus(), + } + } + + pub fn parse_number(&self) -> Variable<'static> { + match self { + Variable::String(s) if !s.is_empty() => { + if let Ok(n) = s.as_str().parse::() { + Variable::Integer(n) + } else if let Ok(n) = s.as_str().parse::() { + Variable::Float(n) + } else { + Variable::Integer(0) + } + } + Variable::Integer(n) => Variable::Integer(*n), + Variable::Float(n) => Variable::Float(*n), + Variable::Array(l) => Variable::Integer(l.is_empty() as i64), + _ => Variable::Integer(0), + } + } + + #[inline(always)] + fn array(num_items: usize) -> Vec> { + let mut items = Vec::with_capacity(num_items); + for _ in 0..num_items { + items.push(Variable::Integer(0)); + } + items + } + + pub fn to_ref<'y: 'x>(&'y self) -> Variable<'x> { + match self { + Variable::String(s) => Variable::String(StringCow::Borrowed(s.as_str())), + Variable::Integer(n) => Variable::Integer(*n), + Variable::Float(n) => Variable::Float(*n), + Variable::Constant(c) => Variable::Constant(*c), + Variable::Array(l) => Variable::Array(l.iter().map(|v| v.to_ref()).collect::>()), + } + } + + pub fn to_bool(&self) -> bool { + match self { + Variable::Float(f) => *f != 0.0, + Variable::Integer(n) => *n != 0, + Variable::String(s) => !s.is_empty(), + Variable::Array(a) => !a.is_empty(), + Variable::Constant(_) => true, + } + } + + pub fn to_string(&'_ self) -> StringCow<'_> { + match self { + Variable::String(s) => StringCow::Borrowed(s.as_str()), + Variable::Integer(n) => StringCow::Owned(n.to_compact_string()), + Variable::Float(n) => StringCow::Owned(n.to_compact_string()), + Variable::Array(l) => { + let mut result = CompactString::with_capacity(self.len() * 10); + for item in l { + if !result.is_empty() { + result.push_str("\r\n"); + } + match item { + Variable::String(v) => result.push_str(v.as_str()), + Variable::Integer(v) => result.push_str(&v.to_compact_string()), + Variable::Float(v) => result.push_str(&v.to_compact_string()), + Variable::Array(_) => {} + Variable::Constant(c) => result.push_str(c.as_str()), + } + } + StringCow::Owned(result) + } + Variable::Constant(c) => StringCow::Borrowed(c.as_str()), + } + } + + pub fn into_string(self) -> StringCow<'x> { + match self { + Variable::String(s) => s, + Variable::Integer(n) => StringCow::Owned(n.to_compact_string()), + Variable::Float(n) => StringCow::Owned(n.to_compact_string()), + Variable::Array(l) => { + let mut result = CompactString::with_capacity(l.len() * 10); + for item in l { + if !result.is_empty() { + result.push_str("\r\n"); + } + match item { + Variable::String(v) => result.push_str(v.as_ref()), + Variable::Integer(v) => result.push_str(&v.to_compact_string()), + Variable::Float(v) => result.push_str(&v.to_compact_string()), + Variable::Array(_) => {} + Variable::Constant(c) => result.push_str(c.as_str()), + } + } + StringCow::Owned(result) + } + Variable::Constant(c) => StringCow::Borrowed(c.as_str()), + } + } + + pub fn to_integer(&self) -> Option { + match self { + Variable::Integer(n) => Some(*n), + Variable::Float(n) => Some(*n as i64), + Variable::String(s) if !s.is_empty() => s.as_str().parse::().ok(), + _ => None, + } + } + + pub fn to_usize(&self) -> Option { + match self { + Variable::Integer(n) => Some(*n as usize), + Variable::Float(n) => Some(*n as usize), + Variable::String(s) if !s.is_empty() => s.as_str().parse::().ok(), + _ => None, + } + } + + pub fn len(&self) -> usize { + match self { + Variable::String(s) => s.len(), + Variable::Integer(_) | Variable::Float(_) => 2, + Variable::Array(l) => l.iter().map(|v| v.len() + 2).sum(), + Variable::Constant(c) => c.as_str().len(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + Variable::String(s) => s.is_empty(), + _ => false, + } + } + + pub fn as_array(&'_ self) -> Option<&'_ [Variable<'_>]> { + match self { + Variable::Array(l) => Some(l), + _ => None, + } + } + + pub fn into_array(self) -> Vec> { + match self { + Variable::Array(l) => l, + v if !v.is_empty() => vec![v], + _ => vec![], + } + } + + pub fn to_array(&self) -> Vec> { + match self { + Variable::Array(l) => l.iter().map(|v| v.to_ref()).collect::>(), + v if !v.is_empty() => vec![v.to_ref()], + _ => vec![], + } + } + + pub fn into_owned(self) -> Variable<'static> { + match self { + Variable::String(s) => Variable::String(StringCow::Owned(s.into_owned())), + Variable::Integer(n) => Variable::Integer(n), + Variable::Float(n) => Variable::Float(n), + Variable::Constant(c) => Variable::Constant(c), + Variable::Array(l) => Variable::Array(l.into_iter().map(|v| v.into_owned()).collect()), + } + } +} + +impl PartialEq for Variable<'_> { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Integer(a), Self::Integer(b)) => a == b, + (Self::Float(a), Self::Float(b)) => a == b, + (Self::Integer(a), Self::Float(b)) | (Self::Float(b), Self::Integer(a)) => { + *a as f64 == *b + } + (Self::String(a), Self::String(b)) => a.as_str() == b.as_str(), + (Self::String(_), Self::Integer(_) | Self::Float(_)) => &self.parse_number() == other, + (Self::Integer(_) | Self::Float(_), Self::String(_)) => self == &other.parse_number(), + (Self::Array(a), Self::Array(b)) => a == b, + _ => false, + } + } +} + +impl Eq for Variable<'_> {} + +#[allow(clippy::non_canonical_partial_ord_impl)] +impl PartialOrd for Variable<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (Self::Integer(a), Self::Integer(b)) => a.partial_cmp(b), + (Self::Float(a), Self::Float(b)) => a.partial_cmp(b), + (Self::Integer(a), Self::Float(b)) => (*a as f64).partial_cmp(b), + (Self::Float(a), Self::Integer(b)) => a.partial_cmp(&(*b as f64)), + (Self::String(a), Self::String(b)) => a.as_str().partial_cmp(b.as_str()), + (Self::String(_), Self::Integer(_) | Self::Float(_)) => { + self.parse_number().partial_cmp(other) + } + (Self::Integer(_) | Self::Float(_), Self::String(_)) => { + self.partial_cmp(&other.parse_number()) + } + (Self::Array(a), Self::Array(b)) => a.partial_cmp(b), + (Self::Array(_) | Self::String(_), _) => Ordering::Greater.into(), + (Self::Constant(a), Self::Constant(b)) => a.to_id().partial_cmp(&b.to_id()), + (_, Self::Array(_) | Self::Constant(_)) | (Self::Constant(_), _) => { + Ordering::Less.into() + } + } + } +} + +impl Ord for Variable<'_> { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.partial_cmp(other).unwrap_or(Ordering::Greater) + } +} + +impl Display for Variable<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Variable::String(v) => v.fmt(f), + Variable::Integer(v) => v.fmt(f), + Variable::Float(v) => v.fmt(f), + Variable::Array(v) => { + for (i, v) in v.iter().enumerate() { + if i > 0 { + f.write_str("\n")?; + } + v.fmt(f)?; + } + Ok(()) + } + Variable::Constant(c) => c.as_str().fmt(f), + } + } +} + +impl<'x> From<&'x Constant> for Variable<'x> { + fn from(value: &'x Constant) -> Self { + match value { + Constant::Integer(i) => Variable::Integer(*i), + Constant::Float(f) => Variable::Float(*f), + Constant::String(s) => Variable::String(StringCow::Borrowed(s.as_str())), + Constant::Static(c) => Variable::Constant(*c), + } + } +} + +impl<'x> TryFrom> for CompactString { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + if let Variable::String(s) = value { + Ok(match s { + StringCow::Borrowed(v) => v.into(), + StringCow::Owned(v) => v, + }) + } else { + Err(()) + } + } +} + +impl<'x> TryFrom> for String { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + if let Variable::String(s) = value { + Ok(match s { + StringCow::Borrowed(v) => v.to_string(), + StringCow::Owned(v) => v.into_string(), + }) + } else { + Err(()) + } + } +} + +impl<'x> From> for bool { + fn from(val: Variable<'x>) -> Self { + val.to_bool() + } +} + +impl<'x> TryFrom> for i64 { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + value.to_integer().ok_or(()) + } +} + +impl<'x> TryFrom> for u64 { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + value.to_integer().map(|v| v as u64).ok_or(()) + } +} + +impl<'x> TryFrom> for usize { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + value.to_usize().ok_or(()) + } +} + +impl<'x> TryFrom> for StatusCode { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value.to_integer() { + Some(v) => match StatusCode::from_u16(v as u16) { + Ok(status) => Ok(status), + Err(_) => Err(()), + }, + None => Err(()), + } + } +} diff --git a/crates/common/src/expr/functions/array.rs b/crates/common/src/expr/functions/array.rs new file mode 100644 index 0000000..69f10e2 --- /dev/null +++ b/crates/common/src/expr/functions/array.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::expr::Variable; + +pub(crate) fn fn_count(v: Vec) -> Variable { + match &v[0] { + Variable::Array(a) => a.len(), + v => { + if !v.is_empty() { + 1 + } else { + 0 + } + } + } + .into() +} + +pub(crate) fn fn_sort(mut v: Vec) -> Variable { + let is_asc = v[1].to_bool(); + let mut arr = v.remove(0).into_array(); + if is_asc { + arr.sort_unstable(); + } else { + arr.sort_unstable_by(|a, b| b.cmp(a)); + } + arr.into() +} + +pub(crate) fn fn_dedup(mut v: Vec) -> Variable { + let arr = v.remove(0).into_array(); + let mut result = Vec::with_capacity(arr.len()); + + for item in arr { + if !result.contains(&item) { + result.push(item); + } + } + + result.into() +} + +pub(crate) fn fn_is_intersect(v: Vec) -> Variable { + match (&v[0], &v[1]) { + (Variable::Array(a), Variable::Array(b)) => a.iter().any(|x| b.contains(x)), + (Variable::Array(a), item) | (item, Variable::Array(a)) => a.contains(item), + _ => false, + } + .into() +} + +pub(crate) fn fn_winnow(mut v: Vec) -> Variable { + match v.remove(0) { + Variable::Array(a) => a + .into_iter() + .filter(|i| !i.is_empty()) + .collect::>() + .into(), + v => v, + } +} diff --git a/crates/common/src/expr/functions/asynch.rs b/crates/common/src/expr/functions/asynch.rs new file mode 100644 index 0000000..b66b99e --- /dev/null +++ b/crates/common/src/expr/functions/asynch.rs @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::{Server, expr::StringCow}; +use compact_str::{CompactString, ToCompactString}; +use mail_auth::IpLookupStrategy; +use std::{cmp::Ordering, net::IpAddr, vec::IntoIter}; +use store::{Deserialize, Rows, Value, dispatch::lookup::KeyValue}; +use trc::AddContext; + +impl Server { + pub(crate) async fn eval_fnc<'x>( + &self, + fnc_id: u32, + params: Vec>, + session_id: u64, + ) -> trc::Result> { + let mut params = FncParams::new(params); + + match fnc_id { + F_IS_LOCAL_DOMAIN => { + let domain = params.next_as_string(); + + self.domain(domain.as_str()) + .await + .caused_by(trc::location!()) + .map(|v| v.is_some().into()) + } + F_IS_LOCAL_ADDRESS => { + let address = params.next_as_string(); + + self.rcpt_id_from_email(address.as_ref()) + .await + .caused_by(trc::location!()) + .map(|v| v.is_some().into()) + } + F_KEY_GET => { + let Some(store) = self.get_lookup_store(params.next_as_string().as_str()) else { + return Ok(Variable::default()); + }; + let key = params.next_as_string(); + + store + .key_get::(key.as_str()) + .await + .map(|value| value.map(|v| v.into_inner()).unwrap_or_default()) + .caused_by(trc::location!()) + } + F_KEY_EXISTS => { + let Some(store) = self.get_lookup_store(params.next_as_string().as_str()) else { + return Ok(Variable::default()); + }; + let key = params.next_as_string(); + + store + .key_exists(key.as_str()) + .await + .caused_by(trc::location!()) + .map(|v| v.into()) + } + F_KEY_SET => { + let Some(store) = self.get_lookup_store(params.next_as_string().as_str()) else { + return Ok(Variable::default()); + }; + let key = params.next_as_string(); + let value = params.next_as_string(); + + store + .key_set(KeyValue::new( + key.as_bytes().to_vec(), + value.as_bytes().to_vec(), + )) + .await + .map(|_| true) + .caused_by(trc::location!()) + .map(|v| v.into()) + } + F_COUNTER_INCR => { + let Some(store) = self.get_lookup_store(params.next_as_string().as_str()) else { + return Ok(Variable::default()); + }; + let key = params.next_as_string(); + let value = params.next_as_integer(); + + store + .counter_incr(KeyValue::new(key.into_owned(), value), true) + .await + .map(Variable::Integer) + .caused_by(trc::location!()) + } + F_COUNTER_GET => { + let Some(store) = self.get_lookup_store(params.next_as_string().as_str()) else { + return Ok(Variable::default()); + }; + let key = params.next_as_string(); + + store + .counter_get(key.as_bytes().to_vec()) + .await + .map(Variable::Integer) + .caused_by(trc::location!()) + } + F_DNS_QUERY => self.dns_query(params).await, + F_SQL_QUERY => self.sql_query(params, session_id).await, + _ => Ok(Variable::default()), + } + } + + async fn sql_query<'x>( + &self, + mut arguments: FncParams<'x>, + session_id: u64, + ) -> trc::Result> { + let store_name = arguments.next_as_string(); + let Some(store) = self + .get_lookup_store(store_name.as_ref()) + .and_then(|v| v.into_store()) + else { + return Err(trc::EventType::Eval(trc::EvalEvent::Error) + .into_err() + .id(store_name.into_owned()) + .span_id(session_id) + .details("Store not found or is not a SQL store")); + }; + let query = arguments.next_as_string(); + + if query.is_empty() { + return Err(trc::EventType::Eval(trc::EvalEvent::Error) + .into_err() + .details("Empty query string") + .span_id(session_id)); + } + + // Obtain arguments + let arguments = match arguments.next() { + Variable::Array(l) => l.into_iter().map(to_store_value).collect(), + v => vec![to_store_value(v)], + }; + + // Run query + if query + .as_bytes() + .get(..6) + .is_some_and(|q| q.eq_ignore_ascii_case(b"SELECT")) + { + let mut rows = store + .sql_query::(query.as_str(), arguments) + .await + .caused_by(trc::location!())?; + Ok(match rows.rows.len().cmp(&1) { + Ordering::Equal => { + let mut row = rows.rows.pop().unwrap().values; + match row.len().cmp(&1) { + Ordering::Equal if !matches!(row.first(), Some(Value::Null)) => { + row.pop().map(into_variable).unwrap() + } + Ordering::Less => Variable::default(), + _ => { + Variable::Array(row.into_iter().map(into_variable).collect::>()) + } + } + } + Ordering::Less => Variable::default(), + Ordering::Greater => rows + .rows + .into_iter() + .map(|r| { + Variable::Array(r.values.into_iter().map(into_variable).collect::>()) + }) + .collect::>() + .into(), + }) + } else { + store + .sql_query::(query.as_str(), arguments) + .await + .caused_by(trc::location!()) + .map(|v| v.into()) + } + } + + async fn dns_query<'x>(&self, mut arguments: FncParams<'x>) -> trc::Result> { + let entry = arguments.next_as_string(); + let record_type = arguments.next_as_string(); + + if record_type.as_str().eq_ignore_ascii_case("ip") { + self.core + .smtp + .resolvers + .dns + .ip_lookup( + entry.as_ref(), + IpLookupStrategy::Ipv4thenIpv6, + 10, + Some(&self.inner.cache.dns_ipv4), + Some(&self.inner.cache.dns_ipv6), + ) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| { + result + .iter() + .map(|ip| Variable::from(ip.to_compact_string())) + .collect::>() + .into() + }) + } else if record_type.as_str().eq_ignore_ascii_case("mx") { + self.core + .smtp + .resolvers + .dns + .mx_lookup(entry.as_str(), Some(&self.inner.cache.dns_mx)) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| { + result + .rrset + .iter() + .flat_map(|mx| { + mx.exchanges.iter().map(|host| { + Variable::String(StringCow::Owned( + host.strip_suffix('.').unwrap_or(host).to_compact_string(), + )) + }) + }) + .collect::>() + .into() + }) + } else if record_type.as_str().eq_ignore_ascii_case("txt") { + self.core + .smtp + .resolvers + .dns + .txt_raw_lookup(entry.as_str()) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| Variable::from(CompactString::from_utf8(result).unwrap_or_default())) + } else if record_type.as_str().eq_ignore_ascii_case("ptr") { + self.core + .smtp + .resolvers + .dns + .ptr_lookup( + entry.as_str().parse::().map_err(|err| { + trc::EventType::Eval(trc::EvalEvent::Error) + .into_err() + .details("Failed to parse IP address") + .reason(err) + })?, + Some(&self.inner.cache.dns_ptr), + ) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| { + result + .rrset + .iter() + .map(|host| Variable::from(host.to_compact_string())) + .collect::>() + .into() + }) + } else if record_type.as_str().eq_ignore_ascii_case("ipv4") { + self.core + .smtp + .resolvers + .dns + .ipv4_lookup(entry.as_str(), Some(&self.inner.cache.dns_ipv4)) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| { + result + .rrset + .iter() + .map(|ip| Variable::from(ip.to_compact_string())) + .collect::>() + .into() + }) + } else if record_type.as_str().eq_ignore_ascii_case("ipv6") { + self.core + .smtp + .resolvers + .dns + .ipv6_lookup(entry.as_str(), Some(&self.inner.cache.dns_ipv6)) + .await + .map_err(|err| trc::Error::from(err).caused_by(trc::location!())) + .map(|result| { + result + .rrset + .iter() + .map(|ip| Variable::from(ip.to_compact_string())) + .collect::>() + .into() + }) + } else { + Ok(Variable::default()) + } + } +} + +struct FncParams<'x> { + params: IntoIter>, +} + +impl<'x> FncParams<'x> { + pub fn new(params: Vec>) -> Self { + Self { + params: params.into_iter(), + } + } + + pub fn next_as_string(&mut self) -> StringCow<'x> { + self.params.next().unwrap().into_string() + } + + pub fn next_as_integer(&mut self) -> i64 { + self.params.next().unwrap().to_integer().unwrap_or_default() + } + + pub fn next(&mut self) -> Variable<'x> { + self.params.next().unwrap() + } +} + +#[derive(Debug)] +struct VariableWrapper(Variable<'static>); + +impl From for VariableWrapper { + fn from(value: i64) -> Self { + VariableWrapper(Variable::Integer(value)) + } +} + +impl Deserialize for VariableWrapper { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(VariableWrapper(Variable::String(StringCow::Owned( + CompactString::from_utf8_lossy(bytes), + )))) + } +} + +impl From> for VariableWrapper { + fn from(value: store::Value<'static>) -> Self { + VariableWrapper(match value { + Value::Integer(v) => Variable::Integer(v), + Value::Bool(v) => Variable::Integer(v as i64), + Value::Float(v) => Variable::Float(v), + Value::Text(v) => Variable::String(StringCow::Owned(v.into())), + Value::Blob(v) => Variable::String(StringCow::Owned(match v { + std::borrow::Cow::Borrowed(v) => CompactString::from_utf8_lossy(v), + std::borrow::Cow::Owned(v) => CompactString::from_utf8_lossy(&v), + })), + Value::Null => Variable::String(StringCow::Borrowed("")), + }) + } +} + +impl VariableWrapper { + pub fn into_inner(self) -> Variable<'static> { + self.0 + } +} + +fn to_store_value(value: Variable) -> Value { + match value { + Variable::String(v) => Value::Text(v.to_string().into()), + Variable::Integer(v) => Value::Integer(v), + Variable::Float(v) => Value::Float(v), + v => Value::Text(v.to_string().into_owned().into()), + } +} + +fn into_variable(value: Value) -> Variable { + match value { + Value::Integer(v) => Variable::Integer(v), + Value::Bool(v) => Variable::Integer(i64::from(v)), + Value::Float(v) => Variable::Float(v), + Value::Text(v) => Variable::String(v.into()), + Value::Blob(v) => Variable::String(StringCow::Owned(CompactString::from_utf8_lossy(&v))), + Value::Null => Variable::default(), + } +} diff --git a/crates/common/src/expr/functions/email.rs b/crates/common/src/expr/functions/email.rs new file mode 100644 index 0000000..786219d --- /dev/null +++ b/crates/common/src/expr/functions/email.rs @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::CompactString; + +use crate::expr::{StringCow, Variable}; + +pub(crate) fn fn_is_email(v: Vec) -> Variable { + let mut last_ch = 0; + let mut in_quote = false; + let mut at_count = 0; + let mut dot_count = 0; + let mut lp_len = 0; + let mut value = 0; + + for &ch in v[0].to_string().as_bytes() { + match ch { + b'0'..=b'9' + | b'a'..=b'z' + | b'A'..=b'Z' + | b'!' + | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'/' + | b'=' + | b'?' + | b'^' + | b'_' + | b'`' + | b'{' + | b'|' + | b'}' + | b'~' + | 0x7f..=u8::MAX => { + value += 1; + } + b'.' if !in_quote => { + if last_ch != b'.' && last_ch != b'@' && value != 0 { + value += 1; + if at_count == 1 { + dot_count += 1; + } + } else { + return false.into(); + } + } + b'@' if !in_quote => { + at_count += 1; + lp_len = value; + value = 0; + } + b'>' | b':' | b',' | b' ' if in_quote => { + value += 1; + } + b'\"' if !in_quote || last_ch != b'\\' => { + in_quote = !in_quote; + } + b'\\' if in_quote && last_ch != b'\\' => (), + _ => { + if !in_quote { + return false.into(); + } + } + } + + last_ch = ch; + } + + (at_count == 1 && dot_count > 0 && lp_len > 0 && value > 0).into() +} + +pub(crate) fn fn_email_part(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap(); + let part = v.next().unwrap().into_string(); + + value.transform(|s| match s { + StringCow::Borrowed(s) => s + .rsplit_once('@') + .map(|(u, d)| match part.as_str() { + "local" => Variable::from(u.trim()), + "domain" => Variable::from(d.trim()), + _ => Variable::default(), + }) + .unwrap_or_default(), + StringCow::Owned(s) => s + .rsplit_once('@') + .map(|(u, d)| match part.as_str() { + "local" => Variable::from(CompactString::new(u.trim())), + "domain" => Variable::from(CompactString::new(d.trim())), + _ => Variable::default(), + }) + .unwrap_or_default(), + }) +} diff --git a/crates/common/src/expr/functions/misc.rs b/crates/common/src/expr/functions/misc.rs new file mode 100644 index 0000000..924a625 --- /dev/null +++ b/crates/common/src/expr/functions/misc.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::expr::Variable; +use compact_str::CompactString; +use mail_auth::common::resolver::ToReverseName; +use registry::types::ipmask::IpAddrOrMask; +use std::{net::IpAddr, str::FromStr}; + +pub(crate) fn fn_is_empty(v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.is_empty(), + Variable::Integer(_) | Variable::Float(_) | Variable::Constant(_) => false, + Variable::Array(a) => a.is_empty(), + } + .into() +} + +pub(crate) fn fn_is_number(v: Vec) -> Variable { + matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() +} + +pub(crate) fn fn_is_ip_addr(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .parse::() + .is_ok() + .into() +} + +pub(crate) fn fn_is_ipv4_addr(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V4(_))) + .into() +} + +pub(crate) fn fn_is_ipv6_addr(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V6(_))) + .into() +} + +pub(crate) fn fn_is_ip_in_cidr(v: Vec) -> Variable { + let Ok(ip) = v[0].to_string().as_str().parse::() else { + return false.into(); + }; + IpAddrOrMask::from_str(v[1].to_string().as_str()) + .map(|mask| mask.matches(&ip)) + .unwrap_or(false) + .into() +} + +pub(crate) fn fn_ip_reverse_name(v: Vec) -> Variable { + CompactString::new( + v[0].to_string() + .as_str() + .parse::() + .map(|ip| ip.to_reverse_name()) + .unwrap_or_default(), + ) + .into() +} + +pub(crate) fn fn_if_then(v: Vec) -> Variable { + let mut v = v.into_iter(); + let condition = v.next().unwrap(); + let iff = v.next().unwrap(); + let then = v.next().unwrap(); + + if condition.to_bool() { iff } else { then } +} diff --git a/crates/common/src/expr/functions/mod.rs b/crates/common/src/expr/functions/mod.rs new file mode 100644 index 0000000..78cc03e --- /dev/null +++ b/crates/common/src/expr/functions/mod.rs @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{StringCow, Variable}; +use registry::schema::enums::ExpressionVariable; + +pub mod array; +pub mod asynch; +pub mod email; +pub mod misc; +pub mod text; + +pub trait ResolveVariable: Sync + Send { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_>; + fn resolve_global(&self, variable: &str) -> Variable<'_>; +} + +impl<'x> Variable<'x> { + fn transform(self, f: impl Fn(StringCow<'x>) -> Variable<'x>) -> Variable<'x> { + match self { + Variable::String(s) => f(s), + Variable::Array(list) => Variable::Array( + list.into_iter() + .map(|v| match v { + Variable::String(s) => f(s), + v => f(v.into_string()), + }) + .collect::>(), + ), + v => f(v.into_string()), + } + } +} + +#[allow(clippy::type_complexity)] +pub(crate) const FUNCTIONS: &[(&str, fn(Vec) -> Variable, u32)] = &[ + ("count", array::fn_count, 1), + ("sort", array::fn_sort, 2), + ("dedup", array::fn_dedup, 1), + ("winnow", array::fn_winnow, 1), + ("is_intersect", array::fn_is_intersect, 2), + ("is_email", email::fn_is_email, 1), + ("email_part", email::fn_email_part, 2), + ("is_empty", misc::fn_is_empty, 1), + ("is_number", misc::fn_is_number, 1), + ("is_ip_addr", misc::fn_is_ip_addr, 1), + ("is_ipv4_addr", misc::fn_is_ipv4_addr, 1), + ("is_ipv6_addr", misc::fn_is_ipv6_addr, 1), + ("is_ip_in_cidr", misc::fn_is_ip_in_cidr, 2), + ("ip_reverse_name", misc::fn_ip_reverse_name, 1), + ("trim", text::fn_trim, 1), + ("trim_end", text::fn_trim_end, 1), + ("trim_start", text::fn_trim_start, 1), + ("len", text::fn_len, 1), + ("to_lowercase", text::fn_to_lowercase, 1), + ("to_uppercase", text::fn_to_uppercase, 1), + ("is_uppercase", text::fn_is_uppercase, 1), + ("is_lowercase", text::fn_is_lowercase, 1), + ("has_digits", text::fn_has_digits, 1), + ("count_spaces", text::fn_count_spaces, 1), + ("count_uppercase", text::fn_count_uppercase, 1), + ("count_lowercase", text::fn_count_lowercase, 1), + ("count_chars", text::fn_count_chars, 1), + ("contains", text::fn_contains, 2), + ("contains_ignore_case", text::fn_contains_ignore_case, 2), + ("eq_ignore_case", text::fn_eq_ignore_case, 2), + ("starts_with", text::fn_starts_with, 2), + ("ends_with", text::fn_ends_with, 2), + ("lines", text::fn_lines, 1), + ("substring", text::fn_substring, 3), + ("strip_prefix", text::fn_strip_prefix, 2), + ("strip_suffix", text::fn_strip_suffix, 2), + ("split", text::fn_split, 2), + ("rsplit", text::fn_rsplit, 2), + ("split_once", text::fn_split_once, 2), + ("rsplit_once", text::fn_rsplit_once, 2), + ("split_n", text::fn_split_n, 3), + ("split_words", text::fn_split_words, 1), + ("hash", text::fn_hash, 2), + ("if_then", misc::fn_if_then, 3), +]; + +pub const F_IS_LOCAL_DOMAIN: u32 = 0; +pub const F_IS_LOCAL_ADDRESS: u32 = 1; +pub const F_KEY_GET: u32 = 2; +pub const F_KEY_EXISTS: u32 = 3; +pub const F_KEY_SET: u32 = 4; +pub const F_COUNTER_INCR: u32 = 5; +pub const F_COUNTER_GET: u32 = 6; +pub const F_SQL_QUERY: u32 = 7; +pub const F_DNS_QUERY: u32 = 8; + +pub const ASYNC_FUNCTIONS: &[(&str, u32, u32)] = &[ + ("is_local_domain", F_IS_LOCAL_DOMAIN, 1), + ("is_local_address", F_IS_LOCAL_ADDRESS, 1), + ("key_get", F_KEY_GET, 2), + ("key_exists", F_KEY_EXISTS, 2), + ("key_set", F_KEY_SET, 3), + ("counter_incr", F_COUNTER_INCR, 3), + ("counter_get", F_COUNTER_GET, 2), + ("dns_query", F_DNS_QUERY, 2), + ("sql_query", F_SQL_QUERY, 3), +]; + +pub struct EmptyResolver; + +impl ResolveVariable for EmptyResolver { + fn resolve_variable(&self, _: ExpressionVariable) -> Variable<'_> { + Variable::Integer(0) + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} diff --git a/crates/common/src/expr/functions/text.rs b/crates/common/src/expr/functions/text.rs new file mode 100644 index 0000000..3b0eb96 --- /dev/null +++ b/crates/common/src/expr/functions/text.rs @@ -0,0 +1,359 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString, format_compact}; +use sha1::Sha1; +use sha2::{Sha256, Sha512}; +use utils::HexEncode; + +use crate::expr::{StringCow, Variable}; + +pub(crate) fn fn_trim(mut v: Vec) -> Variable { + v.remove(0).transform(|s| match s { + StringCow::Borrowed(s) => Variable::from(s.trim()), + StringCow::Owned(s) => Variable::from(s.trim().to_compact_string()), + }) +} + +pub(crate) fn fn_trim_end(mut v: Vec) -> Variable { + v.remove(0).transform(|s| match s { + StringCow::Borrowed(s) => Variable::from(s.trim_end()), + StringCow::Owned(s) => Variable::from(s.trim_end().to_compact_string()), + }) +} + +pub(crate) fn fn_trim_start(mut v: Vec) -> Variable { + v.remove(0).transform(|s| match s { + StringCow::Borrowed(s) => Variable::from(s.trim_start()), + StringCow::Owned(s) => Variable::from(s.trim_start().to_compact_string()), + }) +} + +pub(crate) fn fn_len(v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.len(), + Variable::Array(a) => a.len(), + v => v.to_string().len(), + } + .into() +} + +pub(crate) fn fn_to_lowercase(mut v: Vec) -> Variable { + v.remove(0) + .transform(|s| Variable::from(CompactString::from_str_to_lowercase(s.as_str()))) +} + +pub(crate) fn fn_to_uppercase(mut v: Vec) -> Variable { + v.remove(0) + .transform(|s| Variable::from(CompactString::from_str_to_uppercase(s.as_str()))) +} + +pub(crate) fn fn_is_uppercase(mut v: Vec) -> Variable { + v.remove(0).transform(|s| { + s.as_str() + .chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_uppercase()) + .into() + }) +} + +pub(crate) fn fn_is_lowercase(mut v: Vec) -> Variable { + v.remove(0).transform(|s| { + s.as_str() + .chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_lowercase()) + .into() + }) +} + +pub(crate) fn fn_has_digits(mut v: Vec) -> Variable { + v.remove(0) + .transform(|s| s.as_str().chars().any(|c| c.is_ascii_digit()).into()) +} + +pub(crate) fn fn_split_words(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .split_whitespace() + .filter(|word| word.chars().all(|c| c.is_alphanumeric())) + .map(|word| Variable::from(CompactString::new(word))) + .collect::>() + .into() +} + +pub(crate) fn fn_count_spaces(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .chars() + .filter(|c| c.is_whitespace()) + .count() + .into() +} + +pub(crate) fn fn_count_uppercase(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .chars() + .filter(|c| c.is_alphabetic() && c.is_uppercase()) + .count() + .into() +} + +pub(crate) fn fn_count_lowercase(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .chars() + .filter(|c| c.is_alphabetic() && c.is_lowercase()) + .count() + .into() +} + +pub(crate) fn fn_count_chars(v: Vec) -> Variable { + v[0].to_string().as_str().chars().count().into() +} + +pub(crate) fn fn_eq_ignore_case(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .eq_ignore_ascii_case(v[1].to_string().as_str()) + .into() +} + +pub(crate) fn fn_contains(v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.as_str().contains(v[1].to_string().as_str()), + Variable::Array(arr) => arr.contains(&v[1]), + val => val.to_string().as_str().contains(v[1].to_string().as_str()), + } + .into() +} + +pub(crate) fn fn_contains_ignore_case(v: Vec) -> Variable { + let needle = v[1].to_string(); + match &v[0] { + Variable::String(s) => s + .as_str() + .to_lowercase() + .contains(&needle.as_str().to_lowercase()), + Variable::Array(arr) => arr.iter().any(|v| match v { + Variable::String(s) => s.as_str().eq_ignore_ascii_case(needle.as_str()), + _ => false, + }), + val => val.to_string().as_str().contains(needle.as_str()), + } + .into() +} + +pub(crate) fn fn_starts_with(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .starts_with(v[1].to_string().as_str()) + .into() +} + +pub(crate) fn fn_ends_with(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .ends_with(v[1].to_string().as_str()) + .into() +} + +pub(crate) fn fn_lines(mut v: Vec) -> Variable { + match v.remove(0) { + Variable::String(s) => s + .as_str() + .lines() + .map(|s| Variable::from(CompactString::new(s))) + .collect::>() + .into(), + val => val, + } +} + +pub(crate) fn fn_substring(v: Vec) -> Variable { + v[0].to_string() + .as_str() + .chars() + .skip(v[1].to_usize().unwrap_or_default()) + .take(v[2].to_usize().unwrap_or_default()) + .collect::() + .into() +} + +pub(crate) fn fn_strip_prefix(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap(); + let prefix = v.next().unwrap().into_string(); + + value.transform(|s| match s { + StringCow::Borrowed(s) => s + .strip_prefix(prefix.as_str()) + .map(Variable::from) + .unwrap_or_default(), + StringCow::Owned(s) => s + .strip_prefix(prefix.as_str()) + .map(|s| Variable::from(CompactString::new(s))) + .unwrap_or_default(), + }) +} + +pub(crate) fn fn_strip_suffix(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap(); + let suffix = v.next().unwrap().into_string(); + + value.transform(|s| match s { + StringCow::Borrowed(s) => s + .strip_suffix(suffix.as_str()) + .map(Variable::from) + .unwrap_or_default(), + StringCow::Owned(s) => s + .strip_suffix(suffix.as_str()) + .map(|s| Variable::from(CompactString::new(s))) + .unwrap_or_default(), + }) +} + +pub(crate) fn fn_split(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + + match value { + StringCow::Borrowed(s) => s + .split(arg.as_str()) + .map(Variable::from) + .collect::>() + .into(), + StringCow::Owned(s) => s + .split(arg.as_str()) + .map(|s| Variable::from(CompactString::new(s))) + .collect::>() + .into(), + } +} + +pub(crate) fn fn_rsplit(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + + match value { + StringCow::Borrowed(s) => s + .rsplit(arg.as_str()) + .map(Variable::from) + .collect::>() + .into(), + StringCow::Owned(s) => s + .rsplit(arg.as_str()) + .map(|s| Variable::from(CompactString::new(s))) + .collect::>() + .into(), + } +} + +pub(crate) fn fn_split_n(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + let num = v.next().unwrap().to_integer().unwrap_or_default() as usize; + + fn split_n<'x, 'y>(s: &'x str, arg: &'y str, num: usize, mut f: impl FnMut(&'x str)) { + let mut s = s; + for _ in 0..num { + if let Some((a, b)) = s.split_once(arg) { + f(a); + s = b; + } else { + break; + } + } + f(s); + } + + let mut result = Vec::new(); + match value { + StringCow::Borrowed(s) => split_n(s, arg.as_str(), num, |s| result.push(Variable::from(s))), + StringCow::Owned(s) => split_n(&s, arg.as_str(), num, |s| { + result.push(Variable::from(CompactString::new(s))) + }), + } + + result.into() +} + +pub(crate) fn fn_split_once(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + + match value { + StringCow::Borrowed(s) => s + .split_once(arg.as_str()) + .map(|(a, b)| Variable::Array(vec![Variable::from(a), Variable::from(b)])) + .unwrap_or_default(), + StringCow::Owned(s) => s + .split_once(arg.as_str()) + .map(|(a, b)| { + Variable::Array(vec![ + Variable::from(CompactString::new(a)), + Variable::from(CompactString::new(b)), + ]) + }) + .unwrap_or_default(), + } +} + +pub(crate) fn fn_rsplit_once(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + + match value { + StringCow::Borrowed(s) => s + .rsplit_once(arg.as_str()) + .map(|(a, b)| Variable::Array(vec![Variable::from(a), Variable::from(b)])) + .unwrap_or_default(), + StringCow::Owned(s) => s + .rsplit_once(arg.as_str()) + .map(|(a, b)| { + Variable::Array(vec![ + Variable::from(CompactString::new(a)), + Variable::from(CompactString::new(b)), + ]) + }) + .unwrap_or_default(), + } +} + +pub(crate) fn fn_hash(v: Vec) -> Variable { + use sha1::Digest; + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let algo = v.next().unwrap().into_string(); + + match algo.as_str() { + "md5" => format_compact!("{:x}", md5::compute(value.as_bytes())).into(), + "sha1" => { + let mut hasher = Sha1::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().to_compact_string().into() + } + "sha256" => { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().to_compact_string().into() + } + "sha512" => { + let mut hasher = Sha512::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().to_compact_string().into() + } + _ => Variable::default(), + } +} diff --git a/crates/common/src/expr/if_block.rs b/crates/common/src/expr/if_block.rs new file mode 100644 index 0000000..e44974c --- /dev/null +++ b/crates/common/src/expr/if_block.rs @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ExpressionItem, + parser::ExpressionParser, + tokenizer::{TokenMap, Tokenizer}, +}; +use crate::expr::{Constant, Expression}; +use compact_str::CompactString; +use registry::{ + schema::{ + prelude::{ExpressionContext, Property}, + structs, + }, + types::id::ObjectId, +}; +use store::registry::bootstrap::Bootstrap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfThen { + pub expr: Expression, + pub then: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfBlock { + pub id: ObjectId, + pub property: Property, + pub if_then: Box<[IfThen]>, + pub default: Expression, +} + +impl IfBlock { + pub fn new_default(id: ObjectId, expr_ctx: ExpressionContext<'_>) -> Self { + let token_map = TokenMap::default(); + + if let Some(default) = expr_ctx.default { + Self { + id, + property: expr_ctx.property, + if_then: default + .match_ + .into_iter() + .map(|match_| IfThen { + expr: Expression::parse(&token_map, &match_.if_), + then: Expression::parse(&token_map, &match_.then), + }) + .collect(), + default: Expression::parse(&token_map, &default.else_), + } + } else { + Self::empty(id, expr_ctx.property) + } + } + + pub fn empty(id: ObjectId, property: Property) -> Self { + Self { + id, + property, + if_then: Default::default(), + default: Expression { + items: Default::default(), + }, + } + } + + pub fn is_empty(&self) -> bool { + self.default.is_empty() && self.if_then.is_empty() + } +} + +impl Expression { + pub fn parse(token_map: &TokenMap, expr: &str) -> Self { + ExpressionParser::new(Tokenizer::new(expr, token_map)) + .parse() + .unwrap() + } +} + +pub trait BootstrapExprExt { + fn compile_expr(&mut self, id: ObjectId, expr_ctx: &ExpressionContext<'_>) -> IfBlock; + fn compile_default_expr(&mut self, id: ObjectId, expr_ctx: &ExpressionContext<'_>) -> IfBlock; + fn try_compile_expr( + &mut self, + id: ObjectId, + expr_ctx: &ExpressionContext<'_>, + expr: &structs::Expression, + ) -> Option; +} + +impl BootstrapExprExt for Bootstrap { + fn compile_expr(&mut self, id: ObjectId, expr_ctx: &ExpressionContext<'_>) -> IfBlock { + if expr_ctx.expr.else_.is_empty() && expr_ctx.expr.match_.is_empty() { + return IfBlock::empty(id, expr_ctx.property); + } + + if let Some(if_block) = self.try_compile_expr(id, expr_ctx, expr_ctx.expr) { + if_block + } else { + self.compile_default_expr(id, expr_ctx) + } + } + + fn compile_default_expr(&mut self, id: ObjectId, expr_ctx: &ExpressionContext<'_>) -> IfBlock { + if let Some(default) = &expr_ctx.default { + self.try_compile_expr(id, expr_ctx, default) + .expect("Valid default expression") + } else { + IfBlock::empty(id, expr_ctx.property) + } + } + + fn try_compile_expr( + &mut self, + id: ObjectId, + expr_ctx: &ExpressionContext<'_>, + expr: &structs::Expression, + ) -> Option { + // Parse conditions + let mut if_then = Vec::with_capacity(expr.match_.len()); + + if expr.else_.is_empty() { + if !expr.match_.is_empty() { + self.invalid_property( + id, + expr_ctx.property, + "Missing 'else' block in 'if' expression", + ); + } + return None; + } + + if expr + .match_ + .iter() + .any(|m| m.if_.is_empty() || m.then.is_empty()) + { + self.invalid_property( + id, + expr_ctx.property, + "All 'if' and 'then' blocks must be non-empty", + ); + return None; + } + + let token_map = TokenMap::default() + .with_variables(expr_ctx.allowed_variables) + .with_constants(expr_ctx.allowed_constants); + + let default = match ExpressionParser::new(Tokenizer::new(&expr.else_, &token_map)).parse() { + Ok(expr) => expr, + Err(err) => { + self.invalid_property( + id, + expr_ctx.property, + format!("Error parsing 'else' expression: {}", err), + ); + return None; + } + }; + + for (num, match_) in expr.match_.iter().enumerate() { + match ExpressionParser::new(Tokenizer::new(&match_.if_, &token_map)).parse() { + Ok(if_expr) => { + match ExpressionParser::new(Tokenizer::new(&match_.then, &token_map)).parse() { + Ok(then_expr) => { + if_then.push(IfThen { + expr: if_expr, + then: then_expr, + }); + } + Err(err) => { + self.invalid_property( + id, + expr_ctx.property, + format!( + "Error parsing 'then' expression in condition #{}: {}", + num + 1, + err + ), + ); + return None; + } + } + } + Err(err) => { + self.invalid_property( + id, + expr_ctx.property, + format!( + "Error parsing 'if' expression in condition #{}: {}", + num + 1, + err + ), + ); + return None; + } + } + } + + Some(IfBlock { + id, + property: expr_ctx.property, + if_then: if_then.into_boxed_slice(), + default, + }) + } +} + +impl IfBlock { + pub fn into_default(self, id: ObjectId, property: Property) -> IfBlock { + IfBlock { + id, + property, + if_then: Default::default(), + default: self.default, + } + } + + pub fn all_items(&self) -> impl Iterator { + self.if_then + .iter() + .flat_map(|if_then| if_then.expr.items().iter().chain(if_then.then.items())) + .chain(self.default.items()) + } + + pub fn default_string(&self) -> Option<&str> { + for expr_item in &self.default.items { + if let ExpressionItem::Constant(Constant::String(value)) = expr_item { + return Some(value.as_str()); + } + } + + None + } + + pub fn into_default_string(self) -> Option { + for expr_item in self.default.items { + if let ExpressionItem::Constant(Constant::String(value)) = expr_item { + return Some(value); + } + } + + None + } +} diff --git a/crates/common/src/expr/mod.rs b/crates/common/src/expr/mod.rs new file mode 100644 index 0000000..d07c149 --- /dev/null +++ b/crates/common/src/expr/mod.rs @@ -0,0 +1,523 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::CompactString; +use regex::Regex; +use registry::schema::{ + enums::{ExpressionConstant, ExpressionVariable}, + structs::Rate, +}; +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, + time::Duration, +}; +use trc::MetricType; +use utils::cache::CacheItemWeight; + +use crate::expr::if_block::IfBlock; + +pub mod eval; +pub mod functions; +pub mod if_block; +pub mod parser; +pub mod tokenizer; + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +#[repr(transparent)] +pub struct Expression { + pub items: Box<[ExpressionItem]>, +} + +#[derive(Debug, Clone)] +pub enum ExpressionItem { + Variable(ExpressionVariable), + Global(CompactString), + System(SystemVariable), + Capture(u32), + Constant(Constant), + BinaryOperator(BinaryOperator), + UnaryOperator(UnaryOperator), + Regex(Regex), + JmpIf { val: bool, pos: u32 }, + Function { id: u32, num_args: u32 }, + ArrayAccess, + ArrayBuild(u32), +} + +#[derive(Debug, Clone)] +pub enum Variable<'x> { + String(StringCow<'x>), + Integer(i64), + Float(f64), + Array(Vec>), + Constant(ExpressionConstant), +} + +#[derive(Debug, Clone)] +pub enum StringCow<'x> { + Owned(CompactString), + Borrowed(&'x str), +} + +impl Default for Variable<'_> { + fn default() -> Self { + Variable::String(StringCow::Borrowed("")) + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum Constant { + Static(ExpressionConstant), + Integer(i64), + Float(f64), + String(CompactString), +} + +impl Eq for Constant {} + +impl From for Constant { + fn from(value: CompactString) -> Self { + Constant::String(value) + } +} + +impl From for Constant { + fn from(value: bool) -> Self { + Constant::Integer(value as i64) + } +} + +impl From for Constant { + fn from(value: i64) -> Self { + Constant::Integer(value) + } +} + +impl From for Constant { + fn from(value: i32) -> Self { + Constant::Integer(value as i64) + } +} + +impl From for Constant { + fn from(value: i16) -> Self { + Constant::Integer(value as i64) + } +} + +impl From for Constant { + fn from(value: f64) -> Self { + Constant::Float(value) + } +} + +impl From for Constant { + fn from(value: usize) -> Self { + Constant::Integer(value as i64) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum BinaryOperator { + Add, + Subtract, + Multiply, + Divide, + + And, + Or, + Xor, + + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum UnaryOperator { + Not, + Minus, +} + +#[derive(Debug, Clone)] +pub enum Token { + Variable(ExpressionVariable), + Global(CompactString), + Capture(u32), + Function { + name: Cow<'static, str>, + id: u32, + num_args: u32, + }, + Constant(Constant), + System(SystemVariable), + Regex(Regex), + BinaryOperator(BinaryOperator), + UnaryOperator(UnaryOperator), + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Comma, +} + +#[derive(Debug, Clone)] +pub enum SystemVariable { + Hostname, + Domain, + NodeId, + NodeHostname, + NodeRole, + Metric(MetricType), +} + +impl From for Variable<'_> { + fn from(value: usize) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: i64) -> Self { + Variable::Integer(value) + } +} + +impl From for Variable<'_> { + fn from(value: u64) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: i32) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: u32) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: u16) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: i16) -> Self { + Variable::Integer(value as i64) + } +} + +impl From for Variable<'_> { + fn from(value: f64) -> Self { + Variable::Float(value) + } +} + +impl<'x> From<&'x str> for Variable<'x> { + fn from(value: &'x str) -> Self { + Variable::String(StringCow::Borrowed(value)) + } +} + +impl From for Variable<'_> { + fn from(value: CompactString) -> Self { + Variable::String(StringCow::Owned(value)) + } +} + +impl<'x> From>> for Variable<'x> { + fn from(value: Vec>) -> Self { + Variable::Array(value) + } +} + +impl From for Variable<'_> { + fn from(value: bool) -> Self { + Variable::Integer(value as i64) + } +} + +impl> From for Expression { + fn from(value: T) -> Self { + Expression { + items: Box::new([ExpressionItem::Constant(value.into())]), + } + } +} + +impl PartialEq for ExpressionItem { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Variable(l0), Self::Variable(r0)) => l0 == r0, + (Self::Constant(l0), Self::Constant(r0)) => l0 == r0, + (Self::BinaryOperator(l0), Self::BinaryOperator(r0)) => l0 == r0, + (Self::UnaryOperator(l0), Self::UnaryOperator(r0)) => l0 == r0, + (Self::Regex(_), Self::Regex(_)) => true, + ( + Self::JmpIf { + val: l_val, + pos: l_pos, + }, + Self::JmpIf { + val: r_val, + pos: r_pos, + }, + ) => l_val == r_val && l_pos == r_pos, + ( + Self::Function { + id: l_id, + num_args: l_num_args, + }, + Self::Function { + id: r_id, + num_args: r_num_args, + }, + ) => l_id == r_id && l_num_args == r_num_args, + (Self::ArrayBuild(l0), Self::ArrayBuild(r0)) => l0 == r0, + _ => core::mem::discriminant(self) == core::mem::discriminant(other), + } + } +} + +impl Eq for ExpressionItem {} + +impl PartialEq for Token { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Variable(l0), Self::Variable(r0)) => l0 == r0, + ( + Self::Function { + name: l_name, + id: l_id, + num_args: l_num_args, + }, + Self::Function { + name: r_name, + id: r_id, + num_args: r_num_args, + }, + ) => l_name == r_name && l_id == r_id && l_num_args == r_num_args, + (Self::Constant(l0), Self::Constant(r0)) => l0 == r0, + (Self::Regex(_), Self::Regex(_)) => true, + (Self::BinaryOperator(l0), Self::BinaryOperator(r0)) => l0 == r0, + (Self::UnaryOperator(l0), Self::UnaryOperator(r0)) => l0 == r0, + _ => core::mem::discriminant(self) == core::mem::discriminant(other), + } + } +} + +impl Eq for Token {} + +impl From<()> for Constant { + fn from(_: ()) -> Self { + Constant::Integer(0) + } +} + +impl<'x> TryFrom> for () { + type Error = (); + + fn try_from(_: Variable<'x>) -> Result { + Ok(()) + } +} + +impl<'x> TryFrom> for Duration { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Integer(value) if value > 0 => Ok(Duration::from_millis(value as u64)), + Variable::Float(value) if value > 0.0 => Ok(Duration::from_millis(value as u64)), + Variable::String(value) if !value.is_empty() => { + registry::types::duration::Duration::from_str(value.as_str()) + .map(|v| v.into_inner()) + .map_err(|_| ()) + } + _ => Err(()), + } + } +} + +impl StringCow<'_> { + pub fn as_str(&self) -> &str { + match self { + StringCow::Owned(s) => s.as_str(), + StringCow::Borrowed(s) => s, + } + } + + pub fn as_bytes(&self) -> &[u8] { + match self { + StringCow::Owned(s) => s.as_bytes(), + StringCow::Borrowed(s) => s.as_bytes(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + StringCow::Owned(s) => s.is_empty(), + StringCow::Borrowed(s) => s.is_empty(), + } + } + + pub fn len(&self) -> usize { + match self { + StringCow::Owned(s) => s.len(), + StringCow::Borrowed(s) => s.len(), + } + } + + pub fn into_owned(self) -> CompactString { + match self { + StringCow::Owned(s) => s, + StringCow::Borrowed(s) => s.into(), + } + } +} + +impl<'x> From> for StringCow<'x> { + fn from(value: Cow<'x, str>) -> Self { + match value { + Cow::Borrowed(s) => StringCow::Borrowed(s), + Cow::Owned(s) => StringCow::Owned(s.into()), + } + } +} + +impl From for StringCow<'_> { + fn from(value: CompactString) -> Self { + StringCow::Owned(value) + } +} + +impl AsRef for StringCow<'_> { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl AsRef<[u8]> for StringCow<'_> { + fn as_ref(&self) -> &[u8] { + self.as_str().as_bytes() + } +} + +impl Display for StringCow<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + StringCow::Owned(s) => write!(f, "{}", s), + StringCow::Borrowed(s) => write!(f, "{}", s), + } + } +} + +impl From for Constant { + fn from(value: Duration) -> Self { + Constant::Integer(value.as_millis() as i64) + } +} + +impl<'x> TryFrom> for Rate { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::Array(items) if items.len() == 2 => { + let requests = items[0].to_integer().ok_or(())?; + let period = items[1].to_integer().ok_or(())?; + + if requests > 0 && period > 0 { + Ok(Rate { + count: requests as u64, + period: registry::types::duration::Duration::from_millis(period as u64), + }) + } else { + Err(()) + } + } + _ => Err(()), + } + } +} + +impl<'x> TryFrom> for Ipv4Addr { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::String(value) => value.as_str().parse().map_err(|_| ()), + _ => Err(()), + } + } +} + +impl<'x> TryFrom> for Ipv6Addr { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::String(value) => value.as_str().parse().map_err(|_| ()), + _ => Err(()), + } + } +} + +impl<'x> TryFrom> for IpAddr { + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + match value { + Variable::String(value) => value.as_str().parse().map_err(|_| ()), + _ => Err(()), + } + } +} + +impl<'x, T: TryFrom>> TryFrom> for Vec +where + Result, ()>: FromIterator>>::Error>>, +{ + type Error = (); + + fn try_from(value: Variable<'x>) -> Result { + value + .into_array() + .into_iter() + .map(|v| T::try_from(v)) + .collect() + } +} + +impl CacheItemWeight for Expression { + fn weight(&self) -> u64 { + self.items.len() as u64 * std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for IfBlock { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + + self + .if_then + .iter() + .map(|if_then| if_then.expr.weight() + if_then.then.weight()) + .sum::() + + self.default.weight() + } +} diff --git a/crates/common/src/expr/parser.rs b/crates/common/src/expr/parser.rs new file mode 100644 index 0000000..e74eb77 --- /dev/null +++ b/crates/common/src/expr/parser.rs @@ -0,0 +1,277 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{BinaryOperator, Expression, ExpressionItem, Token, tokenizer::Tokenizer}; + +pub struct ExpressionParser<'x> { + pub(crate) tokenizer: Tokenizer<'x>, + pub(crate) output: Vec, + operator_stack: Vec<(Token, Option)>, + arg_count: Vec, +} + +pub(crate) const ID_ARRAY_ACCESS: u32 = u32::MAX; +pub(crate) const ID_ARRAY_BUILD: u32 = u32::MAX - 1; + +impl<'x> ExpressionParser<'x> { + pub fn new(tokenizer: Tokenizer<'x>) -> Self { + Self { + tokenizer, + output: Vec::new(), + operator_stack: Vec::new(), + arg_count: Vec::new(), + } + } + + pub fn parse(mut self) -> Result { + let mut last_is_var_or_fnc = false; + + while let Some(token) = self.tokenizer.next()? { + let mut is_var_or_fnc = false; + match token { + Token::Variable(v) => { + self.inc_arg_count(); + is_var_or_fnc = true; + self.output.push(ExpressionItem::Variable(v)) + } + Token::Constant(c) => { + self.inc_arg_count(); + self.output.push(ExpressionItem::Constant(c)) + } + Token::Global(g) => { + self.inc_arg_count(); + self.output.push(ExpressionItem::Global(g)) + } + Token::Capture(c) => { + self.inc_arg_count(); + self.output.push(ExpressionItem::Capture(c)) + } + Token::UnaryOperator(uop) => { + self.operator_stack.push((Token::UnaryOperator(uop), None)) + } + Token::OpenParen => self.operator_stack.push((token, None)), + Token::CloseParen | Token::CloseBracket => { + let expect_token = if matches!(token, Token::CloseParen) { + Token::OpenParen + } else { + Token::OpenBracket + }; + loop { + match self.operator_stack.pop() { + Some((t, _)) if t == expect_token => { + break; + } + Some((Token::BinaryOperator(bop), jmp_pos)) => { + self.update_jmp_pos(jmp_pos); + self.output.push(ExpressionItem::BinaryOperator(bop)) + } + Some((Token::UnaryOperator(uop), _)) => { + self.output.push(ExpressionItem::UnaryOperator(uop)) + } + _ => return Err("Mismatched parentheses".to_string()), + } + } + + match self.operator_stack.last() { + Some((Token::Function { id, num_args, name }, _)) => { + let got_args = self.arg_count.pop().unwrap(); + if got_args != *num_args as i32 { + return Err(if *id != u32::MAX { + format!( + "Expression function {:?} expected {} arguments, got {}", + name, num_args, got_args + ) + } else { + "Missing array index".to_string() + }); + } + + let expr = match *id { + ID_ARRAY_ACCESS => ExpressionItem::ArrayAccess, + ID_ARRAY_BUILD => ExpressionItem::ArrayBuild(*num_args), + id => ExpressionItem::Function { + id, + num_args: *num_args, + }, + }; + + self.operator_stack.pop(); + self.output.push(expr); + } + Some((Token::Regex(regex), _)) => { + if self.arg_count.pop().unwrap() != 1 { + return Err("Expression function \"matches\" expected 2 arguments" + .to_string()); + } + self.output.push(ExpressionItem::Regex(regex.clone())); + self.operator_stack.pop(); + } + Some((Token::System(setting), _)) => { + if self.arg_count.pop().unwrap() != 0 { + return Err("Expression function expected 1 argument".to_string()); + } + self.output.push(ExpressionItem::System(setting.clone())); + self.operator_stack.pop(); + } + _ => {} + } + + is_var_or_fnc = true; + } + Token::BinaryOperator(bop) => { + self.dec_arg_count(); + while let Some((top_token, prev_jmp_pos)) = self.operator_stack.last() { + match top_token { + Token::BinaryOperator(top_bop) => { + if bop.precedence() <= top_bop.precedence() { + let top_bop = *top_bop; + let jmp_pos = *prev_jmp_pos; + self.update_jmp_pos(jmp_pos); + self.operator_stack.pop(); + self.output.push(ExpressionItem::BinaryOperator(top_bop)); + } else { + break; + } + } + Token::UnaryOperator(top_uop) => { + let top_uop = *top_uop; + self.operator_stack.pop(); + self.output.push(ExpressionItem::UnaryOperator(top_uop)); + } + _ => break, + } + } + + // Add jump instruction for short-circuiting + let jmp_pos = match bop { + BinaryOperator::And => { + self.output + .push(ExpressionItem::JmpIf { val: false, pos: 0 }); + Some(self.output.len() - 1) + } + BinaryOperator::Or => { + self.output + .push(ExpressionItem::JmpIf { val: true, pos: 0 }); + Some(self.output.len() - 1) + } + _ => None, + }; + + self.operator_stack + .push((Token::BinaryOperator(bop), jmp_pos)); + } + token @ (Token::Function { .. } | Token::Regex(_) | Token::System(_)) => { + self.inc_arg_count(); + self.arg_count.push(0); + self.operator_stack.push((token, None)) + } + Token::OpenBracket => { + // Array functions + let (id, num_args, arg_count) = if last_is_var_or_fnc { + (ID_ARRAY_ACCESS, 2, 1) + } else { + self.inc_arg_count(); + (ID_ARRAY_BUILD, 0, 0) + }; + self.arg_count.push(arg_count); + self.operator_stack.push(( + Token::Function { + id, + name: "array".into(), + num_args, + }, + None, + )); + self.operator_stack.push((token, None)); + } + Token::Comma => { + while let Some((token, jmp_pos)) = self.operator_stack.last() { + match token { + Token::OpenParen => break, + Token::BinaryOperator(bop) => { + let bop = *bop; + let jmp_pos = *jmp_pos; + self.update_jmp_pos(jmp_pos); + self.output.push(ExpressionItem::BinaryOperator(bop)); + self.operator_stack.pop(); + } + Token::UnaryOperator(uop) => { + self.output.push(ExpressionItem::UnaryOperator(*uop)); + self.operator_stack.pop(); + } + _ => break, + } + } + } + } + last_is_var_or_fnc = is_var_or_fnc; + } + + while let Some((token, jmp_pos)) = self.operator_stack.pop() { + match token { + Token::BinaryOperator(bop) => { + self.update_jmp_pos(jmp_pos); + self.output.push(ExpressionItem::BinaryOperator(bop)) + } + Token::UnaryOperator(uop) => self.output.push(ExpressionItem::UnaryOperator(uop)), + _ => return Err("Invalid token on the operator stack".to_string()), + } + } + + if self.operator_stack.is_empty() { + Ok(Expression { + items: self.output.into_boxed_slice(), + }) + } else { + Err("Invalid expression".to_string()) + } + } + + fn inc_arg_count(&mut self) { + if let Some(x) = self.arg_count.last_mut() { + *x = x.saturating_add(1); + let op_pos = self.operator_stack.len().saturating_sub(2); + match self.operator_stack.get_mut(op_pos) { + Some((Token::Function { num_args, id, .. }, _)) if *id == ID_ARRAY_BUILD => { + *num_args += 1; + } + _ => {} + } + } + } + + fn dec_arg_count(&mut self) { + if let Some(x) = self.arg_count.last_mut() { + *x = x.saturating_sub(1); + } + } + + fn update_jmp_pos(&mut self, jmp_pos: Option) { + if let Some(jmp_pos) = jmp_pos { + let cur_pos = self.output.len(); + if let ExpressionItem::JmpIf { pos, .. } = &mut self.output[jmp_pos] { + *pos = (cur_pos - jmp_pos) as u32; + } else { + #[cfg(test)] + panic!("Invalid jump position"); + } + } + } +} + +impl BinaryOperator { + fn precedence(&self) -> i32 { + match self { + BinaryOperator::Multiply | BinaryOperator::Divide => 7, + BinaryOperator::Add | BinaryOperator::Subtract => 6, + BinaryOperator::Gt | BinaryOperator::Ge | BinaryOperator::Lt | BinaryOperator::Le => 5, + BinaryOperator::Eq | BinaryOperator::Ne => 4, + BinaryOperator::Xor => 3, + BinaryOperator::And => 2, + BinaryOperator::Or => 1, + } + } +} diff --git a/crates/common/src/expr/tokenizer.rs b/crates/common/src/expr/tokenizer.rs new file mode 100644 index 0000000..01a8c86 --- /dev/null +++ b/crates/common/src/expr/tokenizer.rs @@ -0,0 +1,401 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + functions::{ASYNC_FUNCTIONS, FUNCTIONS}, + *, +}; +use ahash::AHashSet; +use regex::Regex; +use registry::{schema::enums::ExpressionConstant, types::EnumImpl}; +use std::{borrow::Cow, iter::Peekable, slice::Iter}; +use trc::MetricType; + +pub struct Tokenizer<'x> { + pub(crate) iter: Peekable>, + token_map: &'x TokenMap, + buf: Vec, + depth: u32, + next_token: Vec, + has_number: bool, + has_dot: bool, + has_alpha: bool, + is_start: bool, + is_eof: bool, +} + +#[derive(Debug, Default, Clone)] +pub struct TokenMap { + pub variables: AHashSet, + pub constants: AHashSet, +} + +impl<'x> Tokenizer<'x> { + #[allow(clippy::should_implement_trait)] + pub fn new(expr: &'x str, token_map: &'x TokenMap) -> Self { + Self { + iter: expr.as_bytes().iter().peekable(), + buf: Vec::new(), + depth: 0, + next_token: Vec::with_capacity(2), + has_number: false, + has_dot: false, + has_alpha: false, + is_start: true, + is_eof: false, + token_map, + } + } + + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Result, String> { + if let Some(token) = self.next_token.pop() { + return Ok(Some(token)); + } else if self.is_eof { + return Ok(None); + } + + while let Some(&ch) = self.iter.next() { + match ch { + b'A'..=b'Z' | b'a'..=b'z' | b'_' | b'$' => { + self.buf.push(ch); + self.has_alpha = true; + } + b'0'..=b'9' => { + self.buf.push(ch); + self.has_number = true; + } + b'.' => { + self.buf.push(ch); + self.has_dot = true; + } + b'}' => { + self.is_eof = true; + break; + } + b'-' if self.buf.last().is_some_and(|c| *c == b'[') => { + self.buf.push(ch); + } + b':' if self.buf.contains(&b'.') => { + self.buf.push(ch); + } + b']' if self.buf.contains(&b'[') => { + self.buf.push(b']'); + } + b'*' if self.buf.last().is_some_and(|&c| c == b'[' || c == b'.') => { + self.buf.push(ch); + } + _ => { + let (prev_token, ch) = if ch == b'(' && !self.buf.is_empty() { + match self.buf.as_slice() { + b"matches" => { + // Parse regular expressions + let stop_ch = self.find_char(b"\"'")?; + let regex_str = self.parse_string(stop_ch)?; + let regex = Regex::new(®ex_str).map_err(|e| { + format!("Invalid regular expression {:?}: {}", regex_str, e) + })?; + self.has_alpha = false; + self.buf.clear(); + self.find_char(b",")?; + (Token::Regex(regex).into(), b'(') + } + b"metric" => { + let stop_ch = self.find_char(b"\"'")?; + let metric_str = self.parse_string(stop_ch)?; + let metric = MetricType::parse(&metric_str).ok_or_else(|| { + format!("Invalid metric name {:?}", metric_str) + })?; + self.has_alpha = false; + self.buf.clear(); + (Token::System(SystemVariable::Metric(metric)).into(), b'(') + } + b"system" => { + let stop_ch = self.find_char(b"\"'")?; + let var = match self.parse_string(stop_ch)?.as_str() { + "domain" => SystemVariable::Domain, + "hostname" => SystemVariable::Hostname, + "node_id" => SystemVariable::NodeId, + "node_hostname" => SystemVariable::NodeHostname, + "node_role" => SystemVariable::NodeRole, + other => { + return Err(format!( + "Invalid system variable name {:?}", + other + )); + } + }; + self.has_alpha = false; + self.buf.clear(); + (Token::System(var).into(), b'(') + } + _ => { + self.is_start = false; + (self.parse_buf()?.into(), ch) + } + } + } else if !self.buf.is_empty() { + self.is_start = false; + (self.parse_buf()?.into(), ch) + } else { + (None, ch) + }; + let token = match ch { + b'&' => { + if matches!(self.iter.peek(), Some(b'&')) { + self.iter.next(); + } + Token::BinaryOperator(BinaryOperator::And) + } + b'|' => { + if matches!(self.iter.peek(), Some(b'|')) { + self.iter.next(); + } + Token::BinaryOperator(BinaryOperator::Or) + } + b'!' => { + if matches!(self.iter.peek(), Some(b'=')) { + self.iter.next(); + Token::BinaryOperator(BinaryOperator::Ne) + } else { + Token::UnaryOperator(UnaryOperator::Not) + } + } + b'^' => Token::BinaryOperator(BinaryOperator::Xor), + b'(' => { + self.depth += 1; + Token::OpenParen + } + b')' => { + if self.depth == 0 { + return Err("Unmatched close parenthesis".to_string()); + } + self.depth -= 1; + Token::CloseParen + } + b'+' => Token::BinaryOperator(BinaryOperator::Add), + b'*' => Token::BinaryOperator(BinaryOperator::Multiply), + b'/' => Token::BinaryOperator(BinaryOperator::Divide), + b'-' => { + if self.is_start { + Token::UnaryOperator(UnaryOperator::Minus) + } else { + Token::BinaryOperator(BinaryOperator::Subtract) + } + } + b'=' => match self.iter.next() { + Some(b'=') => Token::BinaryOperator(BinaryOperator::Eq), + Some(b'>') => Token::BinaryOperator(BinaryOperator::Ge), + Some(b'<') => Token::BinaryOperator(BinaryOperator::Le), + _ => Token::BinaryOperator(BinaryOperator::Eq), + }, + b'>' => match self.iter.peek() { + Some(b'=') => { + self.iter.next(); + Token::BinaryOperator(BinaryOperator::Ge) + } + _ => Token::BinaryOperator(BinaryOperator::Gt), + }, + b'<' => match self.iter.peek() { + Some(b'=') => { + self.iter.next(); + Token::BinaryOperator(BinaryOperator::Le) + } + _ => Token::BinaryOperator(BinaryOperator::Lt), + }, + b',' => Token::Comma, + b'[' => Token::OpenBracket, + b']' => Token::CloseBracket, + b' ' | b'\r' | b'\n' => { + if prev_token.is_some() { + return Ok(prev_token); + } else { + continue; + } + } + b'\"' | b'\'' => Token::Constant(Constant::String(self.parse_string(ch)?)), + _ => { + return Err(format!("Invalid character {:?}", char::from(ch),)); + } + }; + self.is_start = matches!( + token, + Token::OpenParen | Token::Comma | Token::BinaryOperator(_) + ); + + return if prev_token.is_some() { + self.next_token.push(token); + Ok(prev_token) + } else { + Ok(Some(token)) + }; + } + } + } + + if self.depth > 0 { + Err("Unmatched open parenthesis".to_string()) + } else if !self.buf.is_empty() { + self.parse_buf().map(Some) + } else { + Ok(None) + } + } + + fn find_char(&mut self, chars: &[u8]) -> Result { + for &ch in self.iter.by_ref() { + if !ch.is_ascii_whitespace() { + return if chars.contains(&ch) { + Ok(ch) + } else { + Err(format!( + "Expected {:?}, found invalid character {:?}", + char::from(chars[0]), + char::from(ch), + )) + }; + } + } + + Err("Unexpected end of expression".to_string()) + } + + fn parse_string(&mut self, stop_ch: u8) -> Result { + let mut buf = Vec::with_capacity(16); + let mut last_ch = 0; + let mut found_end = false; + + for &ch in self.iter.by_ref() { + if last_ch != b'\\' { + if ch != stop_ch { + buf.push(ch); + } else { + found_end = true; + break; + } + } else { + match ch { + b'n' => { + buf.push(b'\n'); + } + b'r' => { + buf.push(b'\r'); + } + b't' => { + buf.push(b'\t'); + } + _ => { + buf.push(ch); + } + } + } + + last_ch = ch; + } + + if found_end { + CompactString::from_utf8(buf).map_err(|_| "Invalid UTF-8".into()) + } else { + Err("Unterminated string".to_string()) + } + } + + fn parse_buf(&mut self) -> Result { + let buf = String::from_utf8(std::mem::take(&mut self.buf)).unwrap_or_default(); + if self.has_number && !self.has_alpha { + self.has_number = false; + if self.has_dot { + self.has_dot = false; + + buf.parse::() + .map(|f| Token::Constant(Constant::Float(f))) + .map_err(|_| format!("Invalid float value {}", buf,)) + } else { + buf.parse::() + .map(|i| Token::Constant(Constant::Integer(i))) + .map_err(|_| format!("Invalid integer value {}", buf,)) + } + } else { + let has_dot = self.has_dot; + let has_number = self.has_number; + + self.has_alpha = false; + self.has_number = false; + self.has_dot = false; + + if !has_number && !has_dot && [4, 5].contains(&buf.len()) { + if buf == "true" { + return Ok(Token::Constant(Constant::Integer(1))); + } else if buf == "false" { + return Ok(Token::Constant(Constant::Integer(0))); + } + } + + if let Some(variable) = buf.strip_prefix('$').filter(|s| !s.is_empty()) { + if variable.chars().all(|c| c.is_ascii_digit()) { + Ok(variable + .parse::() + .map(Token::Capture) + .unwrap_or_else(|_| Token::Global(variable.into()))) + } else { + Ok(Token::Global(variable.into())) + } + } else if let Some((idx, (name, _, num_args))) = FUNCTIONS + .iter() + .enumerate() + .find(|(_, (name, _, _))| name == &buf) + { + Ok(Token::Function { + name: Cow::Borrowed(*name), + id: idx as u32, + num_args: *num_args, + }) + } else if let Some((name, idx, num_args)) = + ASYNC_FUNCTIONS.iter().find(|(name, _, _)| name == &buf) + { + Ok(Token::Function { + name: Cow::Borrowed(*name), + id: *idx + FUNCTIONS.len() as u32, + num_args: *num_args, + }) + } else if let Some(variable) = ExpressionVariable::parse(buf.as_str()) { + if self.token_map.variables.is_empty() + || self.token_map.variables.contains(&variable) + { + Ok(Token::Variable(variable)) + } else { + Err(format!("Variable {:?} not allowed in this context", buf)) + } + } else if let Some(constant) = ExpressionConstant::parse(buf.as_str()) { + if self.token_map.constants.is_empty() + || self.token_map.constants.contains(&constant) + { + Ok(Token::Constant(Constant::Static(constant))) + } else { + Err(format!("Constant {:?} not allowed in this context", buf)) + } + } else if let Ok(duration) = registry::types::duration::Duration::from_str(&buf) { + Ok(Token::Constant(Constant::Integer( + duration.as_millis() as i64 + ))) + } else { + Err(format!("Invalid variable or constant {buf:?}")) + } + } + } +} + +impl TokenMap { + pub fn with_variables(mut self, variables: &[ExpressionVariable]) -> Self { + self.variables.extend(variables.iter().copied()); + self + } + + pub fn with_constants(mut self, constants: &[ExpressionConstant]) -> Self { + self.constants.extend(constants.iter().copied()); + self + } +} diff --git a/crates/common/src/i18n.rs b/crates/common/src/i18n.rs new file mode 100644 index 0000000..4b10993 --- /dev/null +++ b/crates/common/src/i18n.rs @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +include!(concat!(env!("OUT_DIR"), "/locales.rs")); + +const TRADITIONAL_CHINESE: [&str; 4] = ["hant", "tw", "hk", "mo"]; + +pub fn locale_or_default(name: &str) -> &'static Locale { + if let Some(locale) = locale(name) { + return locale; + } + + let mut subtags = name.split(['_', '-']); + let language = subtags.next().unwrap_or(name); + + if language.eq_ignore_ascii_case("zh") + && subtags.any(|subtag| { + TRADITIONAL_CHINESE + .iter() + .any(|variant| subtag.eq_ignore_ascii_case(variant)) + }) + { + return &ZH_TW_LOCALES; + } + + locale_by_language(language).unwrap_or(&EN_US_LOCALES) +} + +#[cfg(test)] +mod tests { + use super::{ALL_LOCALES, locale, locale_or_default}; + + const LOCALES: [&str; 37] = [ + "en-US", "es-ES", "fr-FR", "de-DE", "it-IT", "pt-PT", "pt-BR", "nl-NL", "da-DK", "ca-ES", + "el-GR", "sv-SE", "pl-PL", "ru-RU", "uk-UA", "bg-BG", "cs-CZ", "sk-SK", "sl-SI", "hr-HR", + "lt-LT", "hu-HU", "ro-RO", "fi-FI", "nb-NO", "tr-TR", "zh-CN", "zh-TW", "ja-JP", "ko-KR", + "th-TH", "vi-VN", "id-ID", "hi-IN", "ar-SA", "he-IL", "fa-IR", + ]; + + #[test] + fn locales_are_named_after_themselves() { + for lang in LOCALES { + assert_eq!(locale(lang).expect("locale must exist").name, lang); + } + assert_eq!(ALL_LOCALES.len(), LOCALES.len()); + } + + #[test] + fn bare_and_hyphenated_language_tags_resolve() { + for (input, expected) in [ + ("es-ES", "es-ES"), + ("es", "es-ES"), + ("es-MX", "es-ES"), + ("pt-BR", "pt-BR"), + ("pt-PT", "pt-PT"), + ("pt", "pt-BR"), + ("zh-Hans", "zh-CN"), + ("zh-Hant", "zh-TW"), + ("zh-HK", "zh-TW"), + ("zh-Hant-HK", "zh-TW"), + ("zh", "zh-CN"), + ("zz", "en-US"), + ("", "en-US"), + // BCP 47 tags are case-insensitive + ("ES", "es-ES"), + ("es-es", "es-ES"), + ("PT-br", "pt-BR"), + ("EL-GR", "el-GR"), + ("ZH-HANT", "zh-TW"), + ] { + assert_eq!( + locale_or_default(input).name, + expected, + "failed for {input}" + ); + } + } + + #[test] + fn right_to_left_locales_are_flagged() { + for lang in ["ar-SA", "he-IL", "fa-IR"] { + assert_eq!( + locale_or_default(lang).direction, + "rtl", + "failed for {lang}" + ); + } + for lang in ["en-US", "de-DE", "ja-JP", "ru-RU"] { + assert_eq!( + locale_or_default(lang).direction, + "ltr", + "failed for {lang}" + ); + } + } +} diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs new file mode 100644 index 0000000..ff8f405 --- /dev/null +++ b/crates/common/src/ipc.rs @@ -0,0 +1,330 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::smtp::{ + queue::QueueName, + report::AggregateFrequency, + resolver::{Policy, Tlsa}, +}; +use ahash::RandomState; +use mail_auth::{ + dmarc::Dmarc, + mta_sts::TlsRpt, + report::{Record, tlsrpt::FailureDetails}, +}; +use registry::{schema::prelude::ObjectType, types::id::ObjectId}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::sync::{Semaphore, SemaphorePermit, mpsc}; +use types::type_state::{DataType, StateChange}; +use utils::map::bitmap::Bitmap; + +#[derive(Debug)] +pub enum PushEvent { + Subscribe { + account_ids: Vec, + types: Bitmap, + tx: mpsc::Sender, + }, + Publish { + notification: PushNotification, + broadcast: bool, + }, + PushServerRegister { + activate: Vec, + expired: Vec, + }, + PushServerUpdate { + account_id: u32, + broadcast: bool, + }, + Stop, +} + +#[derive(Debug, Clone)] +pub enum PushNotification { + StateChange(StateChange), + CalendarAlert(CalendarAlert), + EmailPush(EmailPush), +} + +#[derive(Debug, Clone)] +pub struct EmailPush { + pub account_id: u32, + pub email_id: u32, + pub change_id: u64, +} + +#[derive(Debug, Clone)] +pub struct CalendarAlert { + pub account_id: u32, + pub event_id: u32, + pub recurrence_id: Option, + pub uid: String, + pub alert_id: String, +} + +#[derive(Debug)] +pub enum BroadcastEvent { + PushNotification(PushNotification), + PushServerUpdate(u32), + RegistryChange(RegistryChange), + CacheInvalidate(Vec), + CacheInvalidateAll, + CacheInvalidateNegative, + MtaQueueStatus { is_running: bool }, + QueueRefresh, +} + +#[derive(Debug, Clone, Copy)] +pub enum RegistryChange { + Insert(ObjectId), + Delete(ObjectId), + Reload(ObjectType), +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub enum CacheInvalidation { + AccessToken(u32), + DavResources(u32), + Domain(u32), + Account(u32), + DkimSignature(u32), + Tenant(u32), + Role(u32), + List(u32), + DomainLogo(u32), + TenantLogo(u32), + EmailNegative { + domain_id: u32, + local_part_hash: u32, + }, + DomainNegative, +} + +#[derive(Debug)] +pub enum QueueEvent { + Refresh, + WorkerDone { + queue_id: u64, + queue_name: QueueName, + status: QueueEventStatus, + }, + Paused(bool), + ReloadSettings, + Stop, +} + +#[derive(Debug)] +pub enum QueueEventStatus { + Completed, + Locked, + Deferred, +} + +#[derive(Debug)] +pub enum ReportingEvent { + Dmarc(Box), + Tls(Box), + Stop, +} + +#[derive(Debug)] +pub struct DmarcEvent { + pub domain: String, + pub report_record: Record, + pub dmarc_record: Arc, + pub interval: AggregateFrequency, + pub span_id: u64, +} + +#[derive(Debug)] +pub struct TlsEvent { + pub domain: String, + pub policy: PolicyType, + pub failure: Option, + pub tls_record: Arc, + pub interval: AggregateFrequency, + pub span_id: u64, +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub enum PolicyType { + Tlsa(Option>), + Sts(Option>), + None, +} + +pub struct TrainTaskController { + semaphore: Semaphore, + stop_flag: AtomicBool, +} + +impl Default for TrainTaskController { + fn default() -> Self { + Self { + semaphore: Semaphore::new(1), + stop_flag: AtomicBool::new(false), + } + } +} + +impl TrainTaskController { + pub fn try_run(&self) -> Option> { + let permit = self.semaphore.try_acquire().ok()?; + + self.stop_flag.store(false, Ordering::SeqCst); + + Some(permit) + } + + pub fn is_running(&self) -> bool { + self.semaphore.available_permits() == 0 + } + + pub fn stop(&self) { + self.stop_flag.store(true, Ordering::SeqCst); + } + + pub fn should_stop(&self) -> bool { + self.stop_flag.load(Ordering::SeqCst) + } +} + +impl BroadcastEvent { + pub fn reload(object: ObjectType) -> Self { + BroadcastEvent::RegistryChange(RegistryChange::Reload(object)) + } +} + +pub trait ToHash { + fn to_hash(&self) -> u64; +} + +impl ToHash for Dmarc { + fn to_hash(&self) -> u64 { + RandomState::with_seeds(1, 9, 7, 9).hash_one(self) + } +} + +impl ToHash for PolicyType { + fn to_hash(&self) -> u64 { + RandomState::with_seeds(1, 9, 7, 9).hash_one(self) + } +} + +impl From for ReportingEvent { + fn from(value: DmarcEvent) -> Self { + ReportingEvent::Dmarc(Box::new(value)) + } +} + +impl From for ReportingEvent { + fn from(value: TlsEvent) -> Self { + ReportingEvent::Tls(Box::new(value)) + } +} + +impl From> for PolicyType { + fn from(value: Arc) -> Self { + PolicyType::Tlsa(Some(value)) + } +} + +impl From> for PolicyType { + fn from(value: Arc) -> Self { + PolicyType::Sts(Some(value)) + } +} + +impl From<&Arc> for PolicyType { + fn from(value: &Arc) -> Self { + PolicyType::Tlsa(Some(value.clone())) + } +} + +impl From<&Arc> for PolicyType { + fn from(value: &Arc) -> Self { + PolicyType::Sts(Some(value.clone())) + } +} + +impl From<(&Option>, &Option>)> for PolicyType { + fn from(value: (&Option>, &Option>)) -> Self { + match value { + (Some(value), _) => PolicyType::Sts(Some(value.clone())), + (_, Some(value)) => PolicyType::Tlsa(Some(value.clone())), + _ => PolicyType::None, + } + } +} + +impl PushNotification { + pub fn account_id(&self) -> u32 { + match self { + PushNotification::StateChange(state_change) => state_change.account_id, + PushNotification::CalendarAlert(calendar_alert) => calendar_alert.account_id, + PushNotification::EmailPush(email_push) => email_push.account_id, + } + } + + pub fn filter_types(&self, types: &Bitmap) -> Option { + match self { + PushNotification::StateChange(state_change) => { + let mut filtered_types = state_change.types; + filtered_types.intersection(types); + if !filtered_types.is_empty() { + Some(PushNotification::StateChange(StateChange { + account_id: state_change.account_id, + change_id: state_change.change_id, + types: filtered_types, + })) + } else { + None + } + } + PushNotification::CalendarAlert(_) => { + if types.contains(DataType::CalendarAlert) { + Some(self.clone()) + } else { + None + } + } + PushNotification::EmailPush(_) => { + if types.contains_any( + [ + DataType::EmailDelivery, + DataType::Email, + DataType::Mailbox, + DataType::Thread, + ] + .into_iter(), + ) { + Some(self.clone()) + } else { + None + } + } + } + } +} + +impl EmailPush { + pub fn to_state_change(&self) -> StateChange { + StateChange { + account_id: self.account_id, + change_id: self.change_id, + types: Bitmap::from_iter([ + DataType::EmailDelivery, + DataType::Email, + DataType::Mailbox, + DataType::Thread, + ]), + } + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..68b35b3 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,473 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use crate::auth::{AccessTokenInner, EmailAddress}; +use crate::manager::application::WebApplications; +use crate::network::asn::AsnGeoLookupData; +use crate::{ + auth::{AccountCache, DomainCache, EmailCache, MailingListCache, RoleCache, TenantCache}, + config::{ + mailstore::{ + email::EmailConfig, + imap::ImapConfig, + scripts::Scripting, + spamfilter::{IpResolver, SpamClassifier, SpamFilterConfig}, + }, + smtp::auth::DkimSigners, + }, + ipc::TrainTaskController, + network::security::BlockedIps, +}; +use ahash::{AHashMap, AHashSet}; +use arc_swap::ArcSwap; +use auth::oauth::config::OAuthConfig; +use calcard::common::timezone::Tz; +use config::{ + groupware::GroupwareConfig, + mailstore::jmap::JmapConfig, + network::Network, + smtp::{ + SmtpConfig, + resolver::{Policy, Tlsa}, + }, + storage::Storage, + telemetry::Metrics, +}; +use ipc::{BroadcastEvent, PushEvent, QueueEvent, ReportingEvent}; +use mail_auth::{MX, RecordSet, Txt}; +use manager::application::Resource; +use parking_lot::{Mutex, RwLock}; +use rustls::sign::CertifiedKey; +use std::sync::atomic::AtomicU64; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::{Arc, atomic::AtomicBool}, + time::{Duration, Instant}, +}; +use store::InMemoryStore; +use tinyvec::TinyVec; +use tokio::sync::{Notify, Semaphore, mpsc}; +use tokio_rustls::TlsConnector; +use types::{acl::AclGrant, special_use::SpecialUse}; +use utils::{ + cache::{Cache, CacheWithTtl}, + snowflake::SnowflakeIdGenerator, +}; + +pub mod auth; +pub mod cache; +pub mod config; +pub mod expr; +pub mod i18n; +pub mod ipc; +pub mod manager; +pub mod network; +pub mod scripts; +pub mod sharing; +pub mod storage; +pub mod telemetry; + + +pub use psl; + +pub static VERSION_PRIVATE: &str = env!("CARGO_PKG_VERSION"); +pub static VERSION_PUBLIC: &str = "1.0.0"; + +pub static USER_AGENT: &str = "Stalwart/1.0.0"; +pub static DAEMON_NAME: &str = concat!("Stalwart v", env!("CARGO_PKG_VERSION"),); +pub static PROD_ID: &str = "-//Stalwart Labs LLC//Stalwart Server//EN"; + +/* + +Schema history: + +1 - v0.12.0 +2 - v0.12.4 +3 - v0.13.0 +4 - v0.14.0 +5 - v0.15.0 +6 - v0.16.0 + +*/ + +pub const DATABASE_SCHEMA_VERSION: u32 = 6; + +pub const LONG_1D_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24); +pub const LONG_1Y_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24 * 365); + +pub const IPC_CHANNEL_BUFFER: usize = 1024; + +pub const KV_ACME: u8 = 0; +pub const KV_OAUTH: u8 = 1; +pub const KV_RATE_LIMIT_RCPT: u8 = 2; +pub const KV_RATE_LIMIT_SCAN: u8 = 3; +pub const KV_RATE_LIMIT_LOITER: u8 = 4; +pub const KV_RATE_LIMIT_AUTH: u8 = 5; +pub const KV_RATE_LIMIT_SMTP: u8 = 6; +pub const KV_RATE_LIMIT_CONTACT: u8 = 7; +pub const KV_RATE_LIMIT_HTTP_AUTHENTICATED: u8 = 8; +pub const KV_RATE_LIMIT_HTTP_ANONYMOUS: u8 = 9; +pub const KV_RATE_LIMIT_IMAP: u8 = 10; +pub const KV_QUOTA_BLOB: u8 = 11; +pub const KV_GREYLIST: u8 = 16; +pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21; +pub const KV_LOCK_TASK: u8 = 23; +pub const KV_LOCK_DAV: u8 = 25; +pub const KV_SIEVE_ID: u8 = 26; + +#[derive(Clone)] +pub struct Server { + pub inner: Arc, + pub core: Arc, +} + +pub struct Inner { + pub shared_core: ArcSwap, + pub data: Data, + pub cache: Caches, + pub ipc: Ipc, +} + +#[allow(clippy::type_complexity)] +pub struct Data { + pub spam_classifier: ArcSwap, + + pub tls_certificates: ArcSwap, Arc>>, + pub tls_self_signed_cert: Option>, + + pub blocked_ips: RwLock, + pub lookup_stores: ArcSwap, InMemoryStore>>, + + pub asn_geo_data: AsnGeoLookupData, + + pub jmap_id_gen: SnowflakeIdGenerator, + pub queue_id_gen: SnowflakeIdGenerator, + pub span_id_gen: SnowflakeIdGenerator, + pub registry_id_gen: SnowflakeIdGenerator, + pub queue_status: AtomicBool, + + pub applications: WebApplications, + pub logos: Mutex, LogoCache>>, + + pub smtp_connectors: TlsConnectors, +} + +#[derive(Clone)] +pub struct LogoCache { + domain_id: u32, + tenant_id: Option, + data: Option>>, +} + +pub struct Caches { + pub access_tokens: Cache>, + pub http_auth: Cache, HttpAuthCache>, + + pub messages: Cache>, + pub files: Cache>, + pub contacts: Cache>, + pub events: Cache>, + pub scheduling: Cache>, + + pub emails: Cache, + pub emails_negative: CacheWithTtl, + pub domain_names: Cache, u32>, + pub domain_names_negative: CacheWithTtl, ()>, + + pub domains: Cache>, + pub accounts: Cache>, + pub roles: Cache>, + pub tenants: Cache>, + pub lists: Cache>, + + pub dkim_signers: Cache>, + + pub dns_txt: CacheWithTtl, Txt>, + pub dns_mx: CacheWithTtl, RecordSet>, + pub dns_ptr: CacheWithTtl>>, + pub dns_ipv4: CacheWithTtl, RecordSet>, + pub dns_ipv6: CacheWithTtl, RecordSet>, + pub dns_tlsa: CacheWithTtl, Arc>, + pub dns_mta_sts: CacheWithTtl, Arc>, + pub dns_rbl: CacheWithTtl, Option>>, + + pub negative_cache_ttl: Duration, +} + +#[derive(Debug, Clone)] +pub struct MessageStoreCache { + pub emails: Arc, + pub mailboxes: Arc, + pub update_lock: Arc, + pub last_change_id: u64, + pub size: u64, +} + +#[derive(Debug, Clone)] +pub struct MailboxesCache { + pub change_id: u64, + pub index: AHashMap, + pub items: Box<[MailboxCache]>, + pub size: u64, +} + +#[derive(Debug, Clone)] +pub struct MessagesCache { + pub change_id: u64, + pub items: Box<[MessageCache]>, + pub index: AHashMap, + pub keywords: Box<[Box]>, + pub size: u64, +} + +#[derive(Debug, Clone)] +pub struct MessageCache { + pub document_id: u32, + pub mailboxes: TinyVec<[MessageUidCache; 2]>, + pub keywords: u128, + pub thread_id: u32, + pub change_id: u64, + pub size: u32, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct MessageUidCache { + pub mailbox_id: u32, + pub uid: u32, +} + +#[derive(Debug, Clone)] +pub struct MailboxCache { + pub document_id: u32, + pub name: String, + pub path: String, + pub role: SpecialUse, + pub parent_id: u32, + pub sort_order: u32, + pub subscribers: TinyVec<[u32; 4]>, + pub uid_validity: u32, + pub acls: TinyVec<[AclGrant; 2]>, +} + +#[derive(Debug, Clone)] +pub struct HttpAuthCache { + pub account_id: u32, + pub revision: u64, + pub credential_id: Option, + pub expires: Instant, +} + +pub struct Ipc { + pub push_tx: mpsc::Sender, + pub task_tx: Arc, + pub queue_tx: mpsc::Sender, + pub report_tx: mpsc::Sender, + pub broadcast_tx: Option>, + pub train_task_controller: Arc, +} + +pub struct TlsConnectors { + pub pki_verify: TlsConnector, + pub dummy_verify: TlsConnector, +} + +pub struct NameWrapper(pub String); + +#[derive(Debug, Clone)] +pub struct DavResources { + pub base_path: String, + pub paths: AHashSet, + pub resources: Vec, + pub item_change_id: u64, + pub container_change_id: u64, + pub highest_change_id: u64, + pub size: u64, + pub update_lock: Arc, +} + +#[derive(Debug)] +pub struct UpdateLock { + pub semaphore: Semaphore, + pub revision: AtomicU64, +} + +#[derive(Debug, Clone)] +pub struct DavPath { + pub path: String, + pub parent_id: Option, + pub hierarchy_seq: u32, + pub resource_idx: usize, +} + +#[derive(Debug, Clone)] +pub struct DavResource { + pub document_id: u32, + pub data: DavResourceMetadata, +} + +#[derive(Debug, Clone, Copy)] +pub struct DavResourcePath<'x> { + pub path: &'x DavPath, + pub resource: &'x DavResource, +} + +#[derive(Debug, Clone)] +pub enum DavResourceMetadata { + File { + name: String, + size: Option, + parent_id: Option, + acls: TinyVec<[AclGrant; 2]>, + }, + Calendar { + name: String, + acls: TinyVec<[AclGrant; 2]>, + preferences: TinyVec<[TinyCalendarPreferences; 2]>, + }, + CalendarEvent { + names: TinyVec<[DavName; 2]>, + start: i64, + duration: u32, + }, + CalendarEventNotification { + names: TinyVec<[DavName; 2]>, + }, + AddressBook { + name: String, + acls: TinyVec<[AclGrant; 2]>, + }, + ContactCard { + names: TinyVec<[DavName; 2]>, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct TinyCalendarPreferences { + pub account_id: u32, + pub tz: Tz, + pub flags: u16, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct DavName { + pub name: String, + pub parent_id: u32, +} + +#[derive(Clone)] +pub struct Core { + pub storage: Storage, + pub sieve: Scripting, + pub network: Network, + pub oauth: OAuthConfig, + pub email: EmailConfig, + pub jmap: JmapConfig, + pub imap: ImapConfig, + pub smtp: SmtpConfig, + pub spam: SpamFilterConfig, + pub groupware: GroupwareConfig, + pub metrics: Metrics, + +} + +pub trait BuildServer { + fn build_server(&self) -> Server; +} + +impl BuildServer for Arc { + fn build_server(&self) -> Server { + Server { + inner: self.clone(), + core: self.shared_core.load_full(), + } + } +} + +pub trait IntoString: Sized { + fn into_string(self) -> String; +} + +impl IntoString for Vec { + fn into_string(self) -> String { + String::from_utf8(self) + .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned()) + } +} + +#[derive(Debug, Clone, Eq)] +pub struct ThrottleKey { + pub hash: [u8; 32], +} + +#[derive(Default)] +pub struct ThrottleKeyHasher { + hash: u64, +} + +#[derive(Clone, Default)] +pub struct ThrottleKeyHasherBuilder {} + +pub const DEFAULT_LOGO_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAMgAAAAnCAMAAAB9lPf7AAABOFBMVEUAAAAAADoAAEkPDkIPDkIQ\r\n\ + DkIQDkLcLVTYMVTbLVTbLVQPDkLWM2YQDkIPD0IODEHaJlPbLVQWDT8MC0IQDkIQDkIPDkIODkEQ\r\n\ + D0ITDEAQDkIPDkIODkIPDkIPDkIRDUIQDkIQDUIPDkLcLVQQDkIQDkIQDkIQDULbLVQQDUIPDkIQ\r\n\ + EEIPDkIPD0EPDkHcLlUPDkLbLFQPDULbLFPbLVQQDkLbLFPcK1TbLVTbLVQQDUIPDkIPDkIPDULc\r\n\ + LVTcLVQQDkLbLFTcLFTbLFQPDkIQDkIQDULbLVUSEkPcLVTbLVQQDULbLFTcLVTbLVTbLVPbLFQP\r\n\ + DUHbLVMPDkPbLVTdLFXbLFQNDULcLVTcLlMRD0cQDkIQDkXpMFnrMFrtMFvlL1jjLlfdLVThLlYS\r\n\ + EErnL1gRD0n0Ml2YjG1wAAAAWnRSTlMABAb59/379wr7/lMF9HgoBvQJF/BtZSQwGu7JNulAO95x\r\n\ + WD3TsF1M3b6kH5MRiRe5saujyH9oHezk4tjGmn9fwkc1vrVqYA8N19DPqnFQkYh1V0a4LSITmSiJ\r\n\ + LN30AAAKZUlEQVRYw91ZCVvbRhCVrcuWD7AxYLANtrEx5opDIBCOQBLO5qTQ0uyubyD//x/0za4O\r\n\ + RIG0/dLvazu0tbTaWc3bOd6sqv2PJen9/LcF9o++fnf58r8OBcb/vNBttzvrc0Ck/VcFMN6+a/fs\r\n\ + uG23+x9+fcopkUiE/ks/35ew2j8ucMCz3wbteNwZtIcZp2O/jj3mlWjo+t8nHzNdx3ac/sLrTDee\r\n\ + GXRf7cMpD+OYnF9dO6zvnc/gOuLv93dBRScTkz/KWrwu+mBUfTrrwhHtod1b0J696N/E7T5S5UEo\r\n\ + xbrBlIidyp90CvCO1cu56you/jFBAP1y2evZdq/z4suwv3CiaXPr7X7Gbg9fbP4xvtKccSNlGEYq\r\n\ + ZQpWG3GRTM2ki49biUkjgpks8aOAZCcT6ZX7ME4+Izkyw85Pn7T9m+FCjMb2X3UHSJXM62TIKxFt\r\n\ + lXHdEPAG/jUNnelFQjJVM8T1BK4e98hIiqd+DBCskcgZjE/dW+zlFiVHd+sjXfcICEnsS7wTtwfd\r\n\ + hZ8DJFBsMNPirLxabSQqTSFMQ5RWyErBDTH/NBCdGz8MyPm1wUtjocXe/NQZZuLtwednMN4FkpSO\r\n\ + +vqhh1Tp3bx7qwXyXuhcLLsLNHJAwvZg/6TBLRYGEoE8AOTplAqrPx6nR8wQ01N3Fht90evF4+2b\r\n\ + y1+k7QEQLTkqQfbt+M3wi29NUZgWO8RSJLg1ucVTY6hj5j0geCh//pZHAvX7s+nNtOr4fSCbw0Hc\r\n\ + cdbd8ElKIIESMf3AsftbSW8nJphusqqWdTMOGQNgVRdINUQvkZWrLP24Ix6Q74qn46kHEtwqIKda\r\n\ + IJtbDgQ4YpoLxMk89+Xy+W/rAyc+OPOBpBki6wIX7v2s4CZblUBMXjtstgoNLYK/6PxarVQqt8Zh\r\n\ + zEThEKMKSCPaah4WKporK/Vm6yCP+SqDC61ma0paW10k9SYVpirUq3Jqcw23F8tr0K8wg+sHrSbN\r\n\ + l/LMadvxwfDyKyKL/vn4DQSipNdG19XtO3G7+8rf6Ap5pKJlPZ9H8/l8ccoFQrXsukIoG2Vcco6R\r\n\ + UkNrXfPrI83zSA135YhXOm6hseHt8yEe1Wh4puapT89rixjdQF3kGFvKHtA7FskOeow/RbGKCG2n\r\n\ + 7Xw5kdG1+bzXaUvp9uIkjiRGH0iCWSafzitmDUhdATFNpP4RZm0wYRm6EFTKxHxBpMSEC6QKE1I6\r\n\ + B+NItWWWMsSupmRsmudok7QjqoaGVOdsoiVS7Fw+tfhx4dpI5cSyAoL3cWPyTmvScWx0JC9din+h\r\n\ + 5POH+MBx4rJVCWTKgLrQV4tZt7QADWxSQCAWgCCATcsSorxbKDGMUUELgOQt/KSBltR3hMW5vuRG\r\n\ + Fowz84glMJUl2PbuLtRNqOssDSA5WJ7CdeAREssDEqNmcQg+7Ldp42PJoCwPHLgq/iWG0WDrl29T\r\n\ + WJzx7cONxEjUTz9KdpFeQqCNaXm80WKFGWBdmS8JHdABxE/2XWGwA68GcuBgx4CFu0WWYvDOEqw1\r\n\ + We0C6qeJMtR9ILRVopmeqMxQsvPczCTeB9UACrXvGftGNu8xd6SD0PJHAokc3ILPLcoHxsuL1RUM\r\n\ + uUC8klRnusXqXryUsekhIGkGnyxBi0JQJycuyiWy20JavMcMizW9TgS8FQJSfaRqPfuEFB+VB6qB\r\n\ + HVfNu+sjGz3LG8DA0093XBJdRIaZOgmhKW1g0OeRLHEjDBZlTJSGSP+EgOTJdtXN7FJkmWI7S+l3\r\n\ + IWBoXpvKcZ26haxSh39MH4iOZEFqRh7ikcE7UGGMjrh2x3Gb949bXVx2tl5iGI/mzohHgkI+u1gS\r\n\ + sqpYhoFYLqxo0YAQI0Q1iJagQi8y/S6QCJoDg61pEQlZ7BSELmaI5pZhW0GT5QSGZz31VWZ5QHRA\r\n\ + VlXCAxIJPJLpqOYEmF4M2zZS5d16Fz1Lx/l8Iod//TDsnmlhOb3YqO+kBMBYpkFxfRfIHkuJ3NSd\r\n\ + 7iwMJIuAMtSWTyApNiZuU2yV7N4RBhm8qlI+YKogRwwZhA8D2cygXfS3fl12JNTQ954rR8Veo3V0\r\n\ + FkbvdkBu2V1qrL5HncTy46EW5RDm1QLQ8lEABFPyVPgasL3ODFacFJbYpcTnkBGNHCi2Tz0TI1R2\r\n\ + fY/IaveYR7po4LuUDKNu827b6j6WdJv5+Dd4JCwemEZJwKhCCMgBgBz4M5UFodBCbKXYsqZdlbjY\r\n\ + jmo1IclgA2oEqA4gO8GLqAYEQMYfBYIjleuBrzBdNu/tNjX0SSpdOF5RMcPxypVssUgFNjhrFok/\r\n\ + SlfaiOUDacKiwl3qSYWBRMhmUctqDYHOmWLJYMdIfKhRaqwBSPlOr3la+nNAvENuB9wuoXzd3/dy\r\n\ + BgfeTMDrpL9kCo5AjfqOgdk6zy3dBbIGM8ungQ5VqRAQFUVFmd3owWYo92kB1CwA2aMFx4LQWjL4\r\n\ + nwIympSfHRS3K0dIGMnX7uA+BZ3vEbCCKAdNaJQ2kE+PBUAitMMwM+h6j8LJTuM12XXsCF46pTW5\r\n\ + KMnEL+BGcrZoBMleFeb3gYS5XW1+LBaTvNIhN4V5XUWwicYp6582doCshhxxgeCv4R22ICojrDCQ\r\n\ + qIymwxGhUxWKkg9EHrROqeyeeOq4UrUFLrceBxINkv1jUkv63N6TTC7v+rbP9JgUC5pGHXVqlq6j\r\n\ + tEqF9mtZJrvOqpIQT7cFtfowEUJJbPH7QGaJBfdAIChiWFMYYrEMXlJFt0bq85qm1I+Y+Vho5a5k\r\n\ + BCjZ7C/c5/ZRfKgL8fp+JqP5sssMU5jpK1WQVgU3qWhSJFOkS0lLsG6/soGyFgaimkWTQ6ZX5KFk\r\n\ + mq5l8dMUoeLhsVI/4vxBIJjExXHoYNXuXXqUobh94VWY19fbnbNk0K8bwtA5m24try63cozrqrhH\r\n\ + y7SP7+v11h7CDWC5aI03EukaQ3m4D4RYXPaCa3QXkXkWkIR2INUPKonEeUFA/SEgM7Q/VrNeb07g\r\n\ + joDEbfrwcBJwu90P83q/ZzuvRoMzbDHHcFjgjISncIHAIj6/zYFRmLgFM0xOM90wGROMERuDskNA\r\n\ + lB2cYlG1vccEhCIroihwG+oW1AWDZr0mjHtAVIeZMjm19Mtq6GSBPgXJ7deSHrcrXh9VTsInoW/v\r\n\ + 7n7AWGoJOjfRNzpYnhpXWzRVuuVwj8Fr5Lb3DCwH0QXbI4aUQAw/tKQDLaqyEBmXpi4K/hvGcAz0\r\n\ + 1NdQX1J+izLu15AqY1DSU2LVHXr22ekPB+1vZ59wI7m815M8j7uXW996g2Evg5Y4EKhdrJWE8sjO\r\n\ + xpRf79emucAmluWU8RqXE94nNDqr3tJRlwt+W/WOhtecX9e9NZu3uEsH3KEdF6S62DnWaOo1AdGh\r\n\ + XgnqVKNg4H3c8wjk69zc3Nu3b+ZG3c+Oc3SVJG+9efP2LR5u/vFDxkqxOn5+lMhTwPujK/nZ2dmZ\r\n\ + vDslX61U5vMS4szsDPY+S0+vvL7lAoNeZ4kZeHQaesNIYvz8uCg7AzUzWpwNNJRWkVZceur/vSUf\r\n\ + GAtDCZrI0KAvoeG/Lt9Xx5MfIhFiicj9QSmhGd649zg098nPik+rB+/T/k/yO9A7bEvKcQkCAAAA\r\n\ + AElFTkSuQmCC"; diff --git a/crates/common/src/manager/application.rs b/crates/common/src/manager/application.rs new file mode 100644 index 0000000..a82bef8 --- /dev/null +++ b/crates/common/src/manager/application.rs @@ -0,0 +1,627 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, manager::fetch_resource}; +use ahash::AHashMap; +use arc_swap::ArcSwap; +use registry::schema::{enums::CompressionAlgo, structs::Application}; +use std::{ + borrow::Cow, + io::{self, Cursor, Read}, + path::PathBuf, + sync::Arc, + time::Duration, +}; +use store::{ + registry::{RegistryObject, bootstrap::Bootstrap}, + write::{BatchBuilder, BlobLink, BlobOp, now}, +}; +use trc::{AddContext, Key}; +use types::{blob_hash::BlobHash, id::Id}; + +const APP_BLOB_PREFIX: &str = "STALWART_APP_"; +const MAX_APP_SIZE: usize = 100 * 1024 * 1024; +const BASE_HREF: &str = " { + BaseHref(&'x str), + OAuthClientId(&'x str), +} + +#[allow(clippy::type_complexity)] +pub struct WebApplications { + applications: ArcSwap>, + routes: ArcSwap>>, +} + +pub struct AppRoutes { + resources: AHashMap>, + oauth_client_id_meta: Option, +} + +#[derive(Clone)] +pub struct WebApplicationManager { + bundle_path: TempDir, + prefixes: Vec, + description: String, + url: String, + expiry: u64, + blob_key: BlobHash, + oauth_client_id: Option, +} + +#[derive(Default, Clone)] +pub struct Resource { + pub content_type: Cow<'static, str>, + pub contents: T, +} + +impl Resource { + pub fn new(content_type: impl Into>, contents: T) -> Self { + Self { + content_type: content_type.into(), + contents, + } + } +} + +pub struct AppResource { + pub resource: Resource>, + pub no_cache: bool, +} + +impl WebApplications { + pub fn new() -> Self { + Self { + applications: ArcSwap::new(Arc::new(Vec::new())), + routes: ArcSwap::new(Arc::new(AHashMap::new())), + } + } + + pub async fn serve(&self, prefix: &str, path: &str) -> trc::Result> { + if let Some(routes) = self.routes.load().get(prefix) + && let Some((is_index, resource)) = routes + .resources + .get(path) + .map(|res| (path == "index.html", res)) + .or_else(|| routes.resources.get("index.html").map(|res| (true, res))) + { + tokio::fs::read(&resource.contents) + .await + .map(|mut contents| { + if is_index && let Ok(html) = std::str::from_utf8(&contents) { + contents = + rewrite_index(html, prefix, routes.oauth_client_id_meta.as_deref()); + } + + Some(AppResource { + resource: Resource { + content_type: resource.content_type.clone(), + contents, + }, + no_cache: is_index, + }) + }) + .map_err(|err| { + trc::ResourceEvent::Error + .reason(err) + .ctx(trc::Key::Path, path.to_string()) + .caused_by(trc::location!()) + }) + } else { + Ok(None) + } + } + + pub async fn reload(&self, bp: &mut Bootstrap) { + let mut apps = Vec::new(); + for app in bp.list_infallible::().await { + if app.object.enabled { + apps.push(WebApplicationManager::new(app)); + } + } + self.applications.store(Arc::new(apps)); + } + + pub async fn unpack_all(&self, server: &Server, update: bool) { + let mut routes = AHashMap::new(); + for app in self.applications.load().as_ref() { + if update && let Err(err) = app.delete(server).await { + trc::event!( + Resource(trc::ResourceEvent::Error), + Reason = err, + Url = app.url.clone(), + Details = format!( + "Failed to delete application bundle for prefixes: {}", + app.prefixes.join(", ") + ) + ); + } + match app.unpack(server).await { + Ok(resources) => { + let app_routes = Arc::new(AppRoutes { + resources, + oauth_client_id_meta: app + .oauth_client_id + .as_deref() + .map(oauth_client_id_meta), + }); + + for prefix in &app.prefixes { + routes.insert(prefix.clone(), app_routes.clone()); + } + } + Err(err) => { + trc::event!( + Resource(trc::ResourceEvent::Error), + Reason = err, + Url = app.url.clone(), + Details = format!( + "Failed to unpack application for prefixes: {}", + app.prefixes.join(", ") + ) + ); + } + } + } + self.routes.store(Arc::new(routes)); + } +} + +impl WebApplicationManager { + pub fn new(app: RegistryObject) -> Self { + let base_path = app + .object + .unpack_directory + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(app.id.id().to_string()); + + Self { + bundle_path: TempDir::new(base_path), + blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()), + url: app.object.resource_url, + description: app.object.description, + expiry: app.object.auto_update_frequency.as_secs(), + oauth_client_id: app.object.oauth_client_id, + prefixes: app + .object + .url_prefix + .iter() + .map(|prefix| { + prefix + .trim_end_matches('/') + .trim_start_matches('/') + .to_string() + }) + .collect(), + } + } + + async fn unpack(&self, server: &Server) -> trc::Result>> { + // Delete any existing bundles + self.bundle_path.clean().await.map_err(unpack_error)?; + + // Obtain application bundle + let bundle = if let Some(bundle) = server + .blob_store() + .get_blob(self.blob_key.as_slice(), 0..usize::MAX) + .await? + { + bundle + } else { + // Fetch app bundle + let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE) + .await + .map_err(|err| { + trc::ResourceEvent::Error + .caused_by(trc::location!()) + .ctx(Key::Url, self.url.clone()) + .reason(err) + .details("Failed to fetch application bundle") + })?; + + // Store in blob store for future use + server + .blob_store() + .put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None) + .await + .caused_by(trc::location!())?; + + // Schedule expiration + let mut batch = BatchBuilder::new(); + batch + .set( + BlobOp::Link { + hash: self.blob_key.clone(), + to: BlobLink::Temporary { + until: now() + self.expiry, + }, + }, + vec![], + ) + .set( + BlobOp::Commit { + hash: self.blob_key.clone(), + }, + Vec::new(), + ); + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + trc::event!( + Resource(trc::ResourceEvent::ApplicationUpdated), + Url = self.url.clone(), + Details = self.description.clone(), + ); + + resource + }; + + let url = self.url.clone(); + let bundle_path = self.bundle_path.path.clone(); + let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> { + let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| { + trc::ResourceEvent::Error + .caused_by(trc::location!()) + .reason(err) + .ctx(Key::Url, url.clone()) + .details("Failed to decompress application bundle") + })?; + let mut routes = AHashMap::new(); + for i in 0..bundle.len() { + let mut file = bundle.by_index(i).map_err(|err| { + trc::ResourceEvent::Error + .caused_by(trc::location!()) + .reason(err) + .details("Failed to read file from application bundle") + })?; + if file.is_dir() { + continue; + } + + let mut contents = Vec::new(); + file.read_to_end(&mut contents).map_err(unpack_error)?; + let file_name = file.name().to_string(); + drop(file); + + let path = bundle_path.join(format!("{i:02}")); + std::fs::write(&path, contents).map_err(unpack_error)?; + + let resource = Resource { + content_type: match file_name + .rsplit_once('.') + .map(|(_, ext)| ext) + .unwrap_or_default() + { + "html" => "text/html", + "css" => "text/css", + "wasm" => "application/wasm", + "js" => "application/javascript", + "json" => "application/json", + "png" => "image/png", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + _ => "application/octet-stream", + } + .into(), + contents: path, + }; + + routes.insert(file_name, resource); + } + Ok(routes) + }) + .await + .map_err(|err| { + trc::ResourceEvent::Error + .caused_by(trc::location!()) + .reason(err) + .details("Bundle unpack task panicked") + })??; + + trc::event!( + Resource(trc::ResourceEvent::ApplicationUnpacked), + Url = self.url.clone(), + Path = self.bundle_path.path.to_string_lossy().into_owned(), + ); + + Ok(routes) + } + + async fn delete(&self, server: &Server) -> trc::Result<()> { + server + .blob_store() + .delete_blob(self.blob_key.as_slice()) + .await + .map(|_| ()) + } + + pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> { + let blob_key = BlobHash::generate(format!("{APP_BLOB_PREFIX}{app_id}").as_bytes()); + + server + .blob_store() + .delete_blob(blob_key.as_slice()) + .await + .map(|_| ()) + } +} + +impl Resource> { + pub fn is_empty(&self) -> bool { + self.content_type.is_empty() && self.contents.is_empty() + } +} + +#[derive(Clone)] +pub struct TempDir { + pub path: PathBuf, +} + +impl TempDir { + pub fn new(path: PathBuf) -> TempDir { + TempDir { path } + } + + pub async fn clean(&self) -> io::Result<()> { + if tokio::fs::metadata(&self.path).await.is_ok() { + let _ = tokio::fs::remove_dir_all(&self.path).await; + } + tokio::fs::create_dir(&self.path).await + } +} + +fn unpack_error(err: std::io::Error) -> trc::Error { + trc::ResourceEvent::Error + .reason(err) + .details("Failed to unpack application bundle") +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +impl Default for WebApplications { + fn default() -> Self { + Self::new() + } +} + +fn rewrite_index(html: &str, prefix: &str, oauth_client_id_meta: Option<&str>) -> Vec { + let mut edits = [ + html.find(BASE_HREF) + .map(|at| (at, BASE_HREF.len(), IndexEdit::BaseHref(prefix))), + oauth_client_id_meta.and_then(|meta| { + html.find(OAUTH_CLIENT_ID) + .map(|at| (at, OAUTH_CLIENT_ID.len(), IndexEdit::OAuthClientId(meta))) + }), + ]; + + if edits.iter().all(Option::is_none) { + return html.as_bytes().to_vec(); + } + edits.sort_unstable_by_key(|edit| edit.as_ref().map_or(usize::MAX, |(at, _, _)| *at)); + + let mut out = + String::with_capacity(html.len() + prefix.len() + oauth_client_id_meta.map_or(0, str::len)); + let mut pos = 0; + + for (at, len, edit) in edits.into_iter().flatten() { + out.push_str(&html[pos..at]); + match edit { + IndexEdit::BaseHref(prefix) => { + out.push_str(" out.push_str(meta), + } + pos = at + len; + } + out.push_str(&html[pos..]); + out.into_bytes() +} + +fn oauth_client_id_meta(client_id: &str) -> String { + let mut meta = String::with_capacity(OAUTH_CLIENT_ID.len() + client_id.len()); + meta.push_str(" meta.push_str("&"), + '"' => meta.push_str("""), + '<' => meta.push_str("<"), + '>' => meta.push_str(">"), + _ => meta.push(ch), + } + } + meta.push('"'); + meta +} + +#[cfg(test)] +mod tests { + use super::*; + + const INDEX: &str = concat!( + "\n\n\n\n \n", + " \n \n", + " Portal\n\n\n\n\n\n" + ); + + #[test] + fn index_is_rewritten_with_the_prefix_and_client_id() { + let meta = oauth_client_id_meta("stalwart-webui"); + let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap(); + + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + assert!(html.contains("Portal"), "{html}"); + assert!(html.starts_with(""), "{html}"); + assert!(html.ends_with("\n"), "{html}"); + } + + #[test] + fn index_keeps_the_empty_placeholder_when_no_client_id_is_configured() { + let html = String::from_utf8(rewrite_index(INDEX, "account", None)).unwrap(); + + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + } + + #[test] + fn index_without_a_placeholder_is_left_alone() { + let bundle = "\n \n"; + let meta = oauth_client_id_meta("stalwart-webui"); + let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap(); + + assert_eq!(html, "\n \n"); + } + + #[test] + fn edits_are_applied_in_document_order() { + let bundle = concat!( + "", + "" + ); + let meta = oauth_client_id_meta("app"); + let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap(); + + assert_eq!( + html, + concat!( + "", + "" + ) + ); + } + + #[test] + fn client_ids_are_escaped_for_the_attribute() { + let meta = oauth_client_id_meta("a\"b&c"); + + assert_eq!( + meta, + ") -> (WebApplications, TempDir) { + let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}"))); + dir.clean().await.unwrap(); + tokio::fs::write(dir.path.join("index.html"), INDEX) + .await + .unwrap(); + tokio::fs::write(dir.path.join("app.js"), "export const x = 1;\n") + .await + .unwrap(); + + let mut resources = AHashMap::new(); + resources.insert( + "index.html".to_string(), + Resource::new("text/html", dir.path.join("index.html")), + ); + resources.insert( + "app.js".to_string(), + Resource::new("text/javascript", dir.path.join("app.js")), + ); + + let routes = Arc::new(AppRoutes { + resources, + oauth_client_id_meta: client_id.map(oauth_client_id_meta), + }); + + let mut map = AHashMap::new(); + map.insert("admin".to_string(), routes.clone()); + map.insert("account".to_string(), routes); + + let apps = WebApplications::new(); + apps.routes.store(Arc::new(map)); + + (apps, dir) + } + + async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String { + let served = apps.serve(prefix, path).await.unwrap().unwrap(); + assert!(served.no_cache, "index responses must not be cached"); + assert_eq!(served.resource.content_type.as_ref(), "text/html"); + String::from_utf8(served.resource.contents).unwrap() + } + + #[tokio::test] + async fn serving_index_injects_the_prefix_and_client_id() { + let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await; + + let html = serve_html(&apps, "admin", "index.html").await; + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + + let html = serve_html(&apps, "account", "index.html").await; + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + } + + #[tokio::test] + async fn unknown_paths_fall_back_to_a_rewritten_index() { + let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await; + + let html = serve_html(&apps, "admin", "settings/directory").await; + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + } + + #[tokio::test] + async fn assets_and_unknown_prefixes_are_untouched() { + let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await; + + let served = apps.serve("admin", "app.js").await.unwrap().unwrap(); + assert_eq!(served.resource.contents, b"export const x = 1;\n"); + assert_eq!(served.resource.content_type.as_ref(), "text/javascript"); + assert!(!served.no_cache); + + assert!(apps.serve("unknown", "index.html").await.unwrap().is_none()); + } + + #[tokio::test] + async fn serving_index_without_a_client_id_keeps_the_placeholder() { + let (apps, _dir) = fixture("serve-unconfigured", None).await; + + let html = serve_html(&apps, "admin", "index.html").await; + assert!(html.contains(""), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + } + + #[test] + fn an_unmodified_document_is_returned_verbatim() { + let bundle = "x"; + + assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes()); + } +} diff --git a/crates/common/src/manager/backup.rs b/crates/common/src/manager/backup.rs new file mode 100644 index 0000000..6db3c93 --- /dev/null +++ b/crates/common/src/manager/backup.rs @@ -0,0 +1,346 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Core; +use ahash::AHashSet; +use lz4_flex::frame::FrameEncoder; +use std::{ + io::{BufWriter, Write}, + path::{Path, PathBuf}, + sync::mpsc::{self, SyncSender}, +}; +use store::{ + write::{AnyClass, AnyKey, ValueClass}, + *, +}; +use types::blob_hash::{BLOB_HASH_LEN, BlobHash}; +use utils::{UnwrapFailure, codec::leb128::Leb128_}; + +pub(super) const MAGIC_MARKER: u8 = 123; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub(super) enum Family { + Data = 0, + Blob = 2, + Registry = 3, + Changelog = 4, + Queue = 5, + Report = 6, + Telemetry = 7, + Tasks = 8, +} + +type TaskHandle = (tokio::task::JoinHandle<()>, std::thread::JoinHandle<()>); + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct BackupParams { + dest: PathBuf, + families: AHashSet, +} + +impl Core { + pub async fn backup(&self, mut params: BackupParams) { + if !params.dest.exists() { + std::fs::create_dir_all(¶ms.dest).failed("Failed to create backup directory"); + } else if !params.dest.is_dir() { + eprintln!("Backup destination {:?} is not a directory.", params.dest); + std::process::exit(1); + } + + let mut sync_handles = Vec::new(); + let schema_version = self + .storage + .data + .get_value::(AnyKey { + subspace: SUBSPACE_PROPERTY, + key: vec![0u8], + }) + .await + .failed("Could not retrieve database schema version.") + .failed("Could not retrieve database schema version."); + + if params.families.is_empty() { + params.families = [ + Family::Data, + Family::Registry, + Family::Blob, + Family::Changelog, + Family::Queue, + Family::Report, + Family::Telemetry, + Family::Tasks, + ] + .into_iter() + .collect(); + } + + for subspace in params + .families + .into_iter() + .flat_map(|f| f.subspaces()) + .copied() + { + let (async_handle, sync_handle) = if subspace == SUBSPACE_BLOBS { + self.backup_blobs(¶ms.dest, subspace, schema_version) + } else { + self.backup_subspace(¶ms.dest, subspace, schema_version) + }; + async_handle.await.failed("Task failed"); + sync_handles.push(sync_handle); + } + + for handle in sync_handles { + handle.join().expect("Failed to join thread"); + } + } + + fn backup_blobs(&self, dest: &Path, subspace: u8, schema_version: u32) -> TaskHandle { + let store = self.storage.data.clone(); + let blob_store = self.storage.blob.clone(); + let (handle, writer) = spawn_writer( + dest.join(format!("subspace_{}", char::from(subspace))), + subspace, + schema_version, + ); + ( + tokio::spawn(async move { + let mut blobs = Vec::new(); + let mut last_hash = BlobHash::default(); + store + .iterate( + IterateParams::new( + AnyKey { + subspace: SUBSPACE_BLOB_LINK, + key: vec![0u8], + }, + AnyKey { + subspace: SUBSPACE_BLOB_LINK, + key: vec![u8::MAX; 32], + }, + ) + .no_values(), + |key, _| { + let hash = BlobHash::try_from_hash_slice( + key.get(0..BLOB_HASH_LEN).ok_or_else(|| { + trc::Error::corrupted_key(key, None, trc::location!()) + })?, + ) + .unwrap(); + + if last_hash != hash { + blobs.push(hash.clone()); + last_hash = hash; + } + + Ok(true) + }, + ) + .await + .failed("Failed to iterate over data store"); + + for hash in blobs { + if let Some(blob) = blob_store + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .failed("Failed to get blob") + { + writer + .send((hash.as_slice().to_vec(), blob)) + .failed("Failed to send key"); + } + } + }), + handle, + ) + } + + fn backup_subspace(&self, dest: &Path, subspace: u8, schema_version: u32) -> TaskHandle { + let store = self.storage.data.clone(); + let (handle, writer) = spawn_writer( + dest.join(format!("subspace_{}", char::from(subspace))), + subspace, + schema_version, + ); + ( + tokio::spawn(async move { + if !store.is_sql() || (subspace != SUBSPACE_COUNTER && subspace != SUBSPACE_QUOTA) { + store + .iterate( + IterateParams::new( + AnyKey { + subspace, + key: vec![0u8], + }, + AnyKey { + subspace, + key: vec![u8::MAX; 32], + }, + ) + .set_values( + ![SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX].contains(&subspace), + ), + |key, value| { + writer + .send((key.to_vec(), value.to_vec())) + .failed("Failed to send key"); + + Ok(true) + }, + ) + .await + .failed("Failed to iterate over data store"); + } else { + let mut keys = Vec::with_capacity(128); + store + .iterate( + IterateParams::new( + AnyKey { + subspace, + key: vec![0u8], + }, + AnyKey { + subspace, + key: vec![u8::MAX; 32], + }, + ) + .no_values(), + |key, _| { + keys.push(key.to_vec()); + + Ok(true) + }, + ) + .await + .failed("Failed to iterate over data store"); + + for key in keys { + let counter = store + .get_counter(ValueClass::Any(AnyClass { + subspace, + key: key.clone(), + })) + .await + .failed("Failed to get counter"); + writer + .send((key.to_vec(), (counter as u64).to_le_bytes().to_vec())) + .failed("Failed to send key"); + } + } + }), + handle, + ) + } +} + +#[allow(clippy::type_complexity)] +fn spawn_writer( + path: PathBuf, + subspace: u8, + version: u32, +) -> (std::thread::JoinHandle<()>, SyncSender<(Vec, Vec)>) { + let (tx, rx) = mpsc::sync_channel::<(Vec, Vec)>(10); + + let handle = std::thread::spawn(move || { + println!("Exporting database to {}.", path.to_str().unwrap()); + + let mut file = FrameEncoder::new(BufWriter::new( + std::fs::File::create(path).failed("Failed to create backup file"), + )); + file.write_all(&[MAGIC_MARKER, subspace]) + .failed("Failed to write version"); + file.write_all(&version.to_le_bytes()) + .failed("Failed to write version"); + + while let Ok((key, value)) = rx.recv() { + key.len() + .to_leb128_writer(&mut file) + .failed("Failed to write key value"); + file.write_all(&key).failed("Failed to write key"); + value + .len() + .to_leb128_writer(&mut file) + .failed("Failed to write key value"); + if !value.is_empty() { + file.write_all(&value).failed("Failed to write key value"); + } + } + + let mut file = file.finish().failed("Failed to finish backup file"); + file.flush().failed("Failed to flush backup file"); + }); + + (handle, tx) +} + +impl BackupParams { + pub fn new(dest: PathBuf) -> Self { + let mut params = Self { + dest, + families: AHashSet::new(), + }; + + if let Ok(families) = std::env::var("EXPORT_TYPES") { + params.parse_families(&families); + } + + params + } + + fn parse_families(&mut self, families: &str) { + for family in families.split(',') { + let family = family.trim(); + match Family::parse(family) { + Ok(family) => { + self.families.insert(family); + } + Err(err) => { + eprintln!("Backup failed: {err}."); + std::process::exit(1); + } + } + } + } +} + +impl Family { + pub fn subspaces(&self) -> &'static [u8] { + match self { + Family::Data => &[ + SUBSPACE_ACL, + SUBSPACE_INDEXES, + SUBSPACE_QUOTA, + SUBSPACE_COUNTER, + SUBSPACE_PROPERTY, + ], + Family::Blob => &[SUBSPACE_BLOBS, SUBSPACE_BLOB_LINK], + Family::Registry => &[ + SUBSPACE_REGISTRY, + SUBSPACE_REGISTRY_IDX, + SUBSPACE_REGISTRY_PK, + SUBSPACE_DIRECTORY, + ], + Family::Changelog => &[SUBSPACE_LOGS], + Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT], + Family::Report => &[SUBSPACE_REPORT_OUT, SUBSPACE_REPORT_IN], + Family::Telemetry => &[SUBSPACE_TELEMETRY_SPAN, SUBSPACE_TELEMETRY_METRIC], + Family::Tasks => &[SUBSPACE_TASK_QUEUE], + } + } + + pub fn parse(family: &str) -> Result { + match family { + "data" => Ok(Family::Data), + "registry" => Ok(Family::Registry), + "blob" => Ok(Family::Blob), + "changelog" => Ok(Family::Changelog), + "queue" => Ok(Family::Queue), + "report" => Ok(Family::Report), + "telemetry" => Ok(Family::Telemetry), + "tasks" => Ok(Family::Tasks), + _ => Err(format!("Unknown family {}", family)), + } + } +} diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs new file mode 100644 index 0000000..5f7f501 --- /dev/null +++ b/crates/common/src/manager/boot.rs @@ -0,0 +1,300 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{backup::BackupParams, console::store_console}; +use crate::{ + BuildServer, Caches, Core, Data, IPC_CHANNEL_BUFFER, Inner, Ipc, + config::{ + network::AsnGeoLookupConfig, server::Listeners, storage::Storage, telemetry::Telemetry, + }, + ipc::{BroadcastEvent, PushEvent, QueueEvent, ReportingEvent, TrainTaskController}, + manager::defaults::BootstrapDefaults, +}; +use arc_swap::ArcSwap; +use std::{ + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + sync::Arc, +}; +use store::{RegistryStore, registry::bootstrap::Bootstrap}; +use tokio::sync::{Notify, mpsc}; +use utils::{UnwrapFailure, failed}; + +pub struct BootManager { + pub bootstrap: Bootstrap, + pub inner: Arc, + pub servers: Listeners, + pub ipc_rxs: IpcReceivers, +} + +pub struct IpcReceivers { + pub push_rx: Option>, + pub queue_rx: Option>, + pub report_rx: Option>, + pub broadcast_rx: Option>, +} + +const HELP: &str = concat!( + "Stalwart Server v", + env!("CARGO_PKG_VERSION"), + r#" + +Usage: stalwart [OPTIONS] + +Options: + -c, --config Start server with the specified configuration file + -e, --export Export all store data to a specific path + -i, --import Import store data from a specific path + -o, --console Open the store console + -h, --help Print help + -V, --version Print version +"# +); + +#[derive(PartialEq, Eq)] +enum StoreOp { + Export(BackupParams), + Import(PathBuf), + Console, + None, +} + +impl BootManager { + pub async fn init() -> Self { + let mut config_path = std::env::var("CONFIG_PATH").ok(); + let mut import_export = StoreOp::None; + + if config_path.is_none() { + let mut args = std::env::args().skip(1); + + while let Some(arg) = args.next().and_then(|arg| { + arg.strip_prefix("--") + .or_else(|| arg.strip_prefix('-')) + .map(|arg| arg.to_string()) + }) { + let (key, value) = if let Some((key, value)) = arg.split_once('=') { + (key.to_string(), Some(value.trim().to_string())) + } else { + (arg, args.next()) + }; + + match (key.as_str(), value) { + ("help" | "h", _) => { + eprintln!("{HELP}"); + std::process::exit(0); + } + ("version" | "V", _) => { + println!("{}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + ("config" | "c", Some(value)) => { + config_path = Some(value); + } + ("export" | "e", Some(value)) => { + import_export = StoreOp::Export(BackupParams::new(value.into())); + } + ("import" | "i", Some(value)) => { + import_export = StoreOp::Import(value.into()); + } + ("console" | "o", None) => { + import_export = StoreOp::Console; + } + (_, None) => { + failed(&format!("Unrecognized command '{key}', try '--help'.")); + } + (_, Some(_)) => failed(&format!( + "Missing value for argument '{key}', try '--help'." + )), + } + } + + if config_path.is_none() { + if import_export == StoreOp::None { + eprintln!("{HELP}"); + } else { + eprintln!("Missing '--config' argument for import/export.") + } + std::process::exit(0); + } + } + + // Initialize registry + let registry = RegistryStore::init( + PathBuf::from(config_path.unwrap()), + import_export == StoreOp::None, + ) + .await + .failed("⚠️ Startup failed"); + let mut bootstrap = Bootstrap::new(registry).await; + + // Add safe defaults if missing + if import_export == StoreOp::None { + bootstrap.insert_safe_defaults().await; + } + + // Start listeners + let mut servers = Listeners::parse(&mut bootstrap).await; + servers.bind_and_drop_priv(&mut bootstrap); + + // Parse storage + let storage = Storage::parse(&mut bootstrap).await; + + // Parse telemetry + let telemetry = Telemetry::parse(&mut bootstrap, &storage).await; + + match import_export { + StoreOp::None => { + // Parse components + let core: Box = + Box::new(Box::pin(Core::parse(&mut bootstrap, storage)).await); + let data = Data::parse(&mut bootstrap).await; + let cache = Caches::parse(&mut bootstrap).await; + + // Enable telemetry + + + #[cfg(not(feature = "enterprise"))] + telemetry.enable(false); + + if bootstrap.registry.is_bootstrap_mode() { + trc::event!( + Server(trc::ServerEvent::BootstrapMode), + Hostname = bootstrap.registry.local_hostname().to_string(), + Details = + "No configuration file was found. Port 8080 is open for initial setup.", + Version = env!("CARGO_PKG_VERSION"), + ); + } else if bootstrap.registry.is_recovery_mode() { + trc::event!( + Server(trc::ServerEvent::RecoveryMode), + Details = "Port 8080 is open for troubleshooting and recovery.", + Hostname = bootstrap.registry.local_hostname().to_string(), + Version = env!("CARGO_PKG_VERSION"), + ); + } else { + trc::event!( + Server(trc::ServerEvent::Startup), + Hostname = bootstrap.registry.local_hostname().to_string(), + Version = env!("CARGO_PKG_VERSION"), + ); + } + + if core.storage.coordinator.is_enabled() { + trc::event!( + Cluster(trc::ClusterEvent::Startup), + Id = bootstrap.registry.node_id(), + Type = bootstrap + .registry + .cluster_role() + .unwrap_or("[default]") + .to_string(), + Details = bootstrap.registry.cluster_push_shard() + ); + } + + // Build shared inner + let has_remote_asn = matches!( + core.network.asn_geo_lookup, + AsnGeoLookupConfig::Resource { .. } + ); + let (ipc, ipc_rxs) = build_ipc(!core.storage.coordinator.is_none()); + let inner = Arc::new(Inner { + shared_core: ArcSwap::new(Arc::from(core)), + data, + ipc, + cache, + }); + + if !bootstrap.registry.is_recovery_mode() { + // Load spam model + if let Err(err) = inner.build_server().spam_model_reload().await { + trc::error!( + err.details("Failed to load spam filter model") + .caused_by(trc::location!()) + ); + } + + // Fetch ASN database + if has_remote_asn { + inner + .build_server() + .lookup_asn_country(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))) + .await; + } + } + + // Parse TCP acceptors + servers + .parse_tcp_acceptors(&mut bootstrap, inner.clone()) + .await; + + BootManager { + inner, + bootstrap, + servers, + ipc_rxs, + } + } + StoreOp::Export(path) => { + // Enable telemetry + telemetry.enable(false); + + // Parse settings and backup + Box::pin(Core::parse(&mut bootstrap, storage)) + .await + .backup(path) + .await; + std::process::exit(0); + } + StoreOp::Import(path) => { + // Enable telemetry + telemetry.enable(false); + + // Parse settings and restore + Box::pin(Core::parse(&mut bootstrap, storage)) + .await + .restore(path) + .await; + std::process::exit(0); + } + StoreOp::Console => { + // Store console + store_console( + Box::pin(Core::parse(&mut bootstrap, storage)) + .await + .storage + .data, + ) + .await; + std::process::exit(0); + } + } + } +} + +pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) { + // Build ipc receivers + let (push_tx, push_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); + let (queue_tx, queue_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); + let (report_tx, report_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); + let (broadcast_tx, broadcast_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); + ( + Ipc { + push_tx, + queue_tx, + report_tx, + broadcast_tx: has_pubsub.then_some(broadcast_tx), + task_tx: Arc::new(Notify::new()), + train_task_controller: Arc::new(TrainTaskController::default()), + }, + IpcReceivers { + push_rx: Some(push_rx), + queue_rx: Some(queue_rx), + report_rx: Some(report_rx), + broadcast_rx: has_pubsub.then_some(broadcast_rx), + }, + ) +} diff --git a/crates/common/src/manager/console.rs b/crates/common/src/manager/console.rs new file mode 100644 index 0000000..470f697 --- /dev/null +++ b/crates/common/src/manager/console.rs @@ -0,0 +1,316 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::Engine; +use base64::engine::general_purpose; +use std::env; +use std::io::{self, Write}; +use store::write::{AnyClass, AnyKey, BatchBuilder, ValueClass}; +use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store}; + +const HELP: &str = concat!( + "Stalwart Server v", + env!("CARGO_PKG_VERSION"), + r#" Data Store CLI + +Enter commands (type 'help' for available commands). +"# +); + +pub async fn store_console(store: Store) { + print!("{HELP}"); + + if matches!(store, Store::None) { + println!("No store available. Verify your configuration."); + return; + } + + loop { + print!("> "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + + let parts: Vec<&str> = input.split_whitespace().collect(); + + if parts.is_empty() { + continue; + } + + match parts[0] { + "scan" => { + if parts.len() != 3 { + println!("Usage: scan "); + } else if let (Some(from_key), Some(to_key)) = + (parse_key(parts[1]), parse_key(parts[2])) + { + println!("Scanning from {:?} to {:?}", from_key, to_key); + let mut from_key = from_key.into_iter(); + let mut to_key = to_key.into_iter(); + let from_subspace = from_key.next().unwrap(); + let to_subspace = to_key.next().unwrap(); + + if from_subspace != to_subspace { + println!("Keys must be in the same subspace."); + return; + } + + store + .iterate( + IterateParams::new( + AnyKey { + subspace: from_subspace, + key: from_key.collect::>(), + }, + AnyKey { + subspace: to_subspace, + key: to_key.collect::>(), + }, + ) + .set_values( + ![SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX].contains(&from_subspace), + ), + |key, value| { + print!("{}", char::from(from_subspace)); + print_escaped(key); + print!(" : "); + print_escaped(value); + println!(); + Ok(true) + }, + ) + .await + .expect("Failed to scan keys"); + } + } + "delete" => match (parts.get(1), parts.get(2)) { + (Some(from_key), Some(to_key)) => { + if let (Some(from_key), Some(to_key)) = (parse_key(from_key), parse_key(to_key)) + { + let mut from_key = from_key.into_iter(); + let mut to_key = to_key.into_iter(); + + let from_key = AnyKey { + subspace: from_key.next().unwrap(), + key: from_key.collect::>(), + }; + let to_key = AnyKey { + subspace: to_key.next().unwrap(), + key: to_key.collect::>(), + }; + + if from_key.subspace != to_key.subspace { + println!("Keys must be in the same subspace."); + return; + } + + let mut total = 0; + store + .iterate( + IterateParams::new(from_key.clone(), to_key.clone()).no_values(), + |_, _| { + total += 1; + Ok(true) + }, + ) + .await + .expect("Failed to scan keys"); + + if total > 0 { + print!("Are you sure you want to delete {total} keys? (y/N): "); + io::stdout().flush().unwrap(); + let mut response = String::new(); + io::stdin().read_line(&mut response).unwrap(); + if !response.trim().eq_ignore_ascii_case("y") { + println!("Aborted."); + return; + } + + store + .delete_range(from_key, to_key) + .await + .expect("Failed to delete keys"); + println!("Deleted {total} keys."); + } else { + println!("No keys found."); + } + } + } + (Some(key), None) => { + if let Some(key) = parse_key(key) { + println!("Deleting key: {:?}", key); + let mut key = key.into_iter(); + let mut batch = BatchBuilder::new(); + batch.clear(ValueClass::Any(AnyClass { + subspace: key.next().unwrap(), + key: key.collect(), + })); + if let Err(err) = store.write(batch.build_all()).await { + println!("Failed to delete key: {}", err); + } + } + } + _ => { + println!("Usage: delete []"); + } + }, + "get" => { + if parts.len() != 2 { + println!("Usage: get "); + } else if let Some(key) = parse_key(parts[1]) { + let mut key = key.into_iter(); + match store + .get_value::(AnyKey { + subspace: key.next().unwrap(), + key: key.collect::>(), + }) + .await + { + Ok(Some(data)) => { + print_escaped(&data.0); + println!(); + } + Ok(None) => { + println!("Key not found."); + } + Err(err) => { + println!("Failed to retrieve key: {}", err); + } + } + } + } + "put" => { + if parts.len() < 2 { + println!("Usage: put []"); + } else if let Some(key) = parse_key(parts[1]) { + let value = parts.get(2).map(|v| parse_value(v)).unwrap_or_default(); + println!("Putting key: {key:?}"); + + let mut key = key.into_iter(); + let mut batch = BatchBuilder::new(); + batch.set( + ValueClass::Any(AnyClass { + subspace: key.next().unwrap(), + key: key.collect(), + }), + value, + ); + if let Err(err) = store.write(batch.build_all()).await { + println!("Failed to insert key: {}", err); + } + } + } + "help" => { + print_help(); + } + "exit" | "quit" => { + println!("Exiting..."); + break; + } + _ => { + println!("Unknown command. Type 'help' for available commands."); + } + } + } +} + +fn parse_key(input: &str) -> Option> { + let result = if let Some(key) = input.strip_prefix("base64:") { + base64_decode(key) + } else { + parse_binary(input) + }; + if matches!(result.first(), Some(ch) if ch.is_ascii_alphabetic() && ch.is_ascii_lowercase()) { + Some(result) + } else { + println!("Invalid key: {result:?}"); + None + } +} + +fn parse_value(input: &str) -> Vec { + if let Some(key) = input.strip_prefix("base64:") { + base64_decode(key) + } else { + parse_binary(input) + } +} + +fn base64_decode(input: &str) -> Vec { + general_purpose::STANDARD + .decode(input) + .expect("Failed to decode base64") +} + +fn parse_binary(input: &str) -> Vec { + let mut result = Vec::new(); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('x') => { + let hex: String = chars.by_ref().take(2).collect(); + if hex.len() == 2 { + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + result.push(byte); + } else { + result.extend_from_slice(b"\\x"); + result.extend_from_slice(hex.as_bytes()); + } + } else { + result.push(b'\\'); + result.push(b'x'); + result.extend_from_slice(hex.as_bytes()); + } + } + Some(other) => { + result.push(b'\\'); + result.push(other as u8); + } + None => { + result.push(b'\\'); + } + } + } else { + result.push(c as u8); + } + } + + result +} + +fn print_escaped(bytes: &[u8]) { + for ch in bytes { + if ch.is_ascii() && !ch.is_ascii_control() && *ch != b'\\' { + print!("{}", *ch as char); + } else { + print!("\\x{:02x}", ch); + } + } +} + +fn print_help() { + println!("Available commands:"); + println!(" scan "); + println!(" delete []"); + println!(" get "); + println!(" put []"); + println!(" help"); + println!(" exit/quit"); + println!("Note: Keys and values can be prefixed with 'base64:' for base64 encoding"); + println!(" or use escaped hex values (e.g., \\x41 for 'A')"); +} + +struct RawValue(Vec); + +impl Deserialize for RawValue { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(RawValue(bytes.to_vec())) + } +} diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs new file mode 100644 index 0000000..698af40 --- /dev/null +++ b/crates/common/src/manager/defaults.rs @@ -0,0 +1,562 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::auth::permissions::DefaultPermissions; +use aws_lc_rs::{ + rand::SystemRandom, + signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, +}; +use registry::{ + schema::{ + enums::*, + prelude::{ObjectType, SocketAddr}, + structs::*, + }, + types::{duration::Duration, error::Error, list::List, map::Map}, +}; +use std::str::FromStr; +use store::{ + rand::{RngExt, distr::Alphanumeric, rng}, + registry::{ + bootstrap::Bootstrap, + write::{RegistryWrite, RegistryWriteResult}, + }, +}; + +pub const ASN_IPV4: &str = + "https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn-ipv4.csv"; +pub const ASN_IPV6: &str = + "https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn-ipv6.csv"; +pub const GEO_IPV4: &str = + "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv"; +pub const GEO_IPV6: &str = + "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv"; + +pub trait BootstrapDefaults { + fn insert_safe_defaults(&mut self) -> impl Future + Send; +} + +impl BootstrapDefaults for Bootstrap { + async fn insert_safe_defaults(&mut self) { + if let Err(error) = insert_safe_defaults(self).await { + self.errors.push(Error::Internal { + object_id: None, + error, + }); + } + } +} + +async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { + let is_recovery_mode = bp.registry.is_recovery_mode(); + let is_bootstrap_mode = bp.registry.is_bootstrap_mode(); + + #[cfg(not(feature = "test_mode"))] + if bp.registry.count_object(ObjectType::Application).await? == 0 { + bp.registry + .write(RegistryWrite::insert( + &Application { + auto_update_frequency: Duration::from_millis(30 * 24 * 60 * 60 * 1000), + description: "Stalwart Web Interface".to_string(), + enabled: true, + #[cfg(not(feature = "dev_mode"))] + resource_url: + "https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip" + .into(), + #[cfg(feature = "dev_mode")] + resource_url: "file:///Users/me/code/webui/.ignore/webui.zip".into(), + unpack_directory: None, + oauth_client_id: None, + url_prefix: Map::new(vec!["/admin".into(), "/account".into()]), + } + .into(), + )) + .await?; + } + + if is_bootstrap_mode { + #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] + if bp.registry.count_object(ObjectType::SystemSettings).await? == 0 { + bp.registry + .write(RegistryWrite::insert( + &SystemSettings { + default_hostname: bp.registry.local_hostname().to_string(), + ..Default::default() + } + .into(), + )) + .await?; + } + + return Ok(()); + } + + if is_recovery_mode { + return Ok(()); + } + + if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 { + bp.registry + .write(RegistryWrite::insert( + &MtaQueueQuota { + description: "Global queue quota".to_string().into(), + enable: true, + messages: 100000.into(), + size: 10737418240.into(), + ..Default::default() + } + .into(), + )) + .await?; + } + + if bp + .registry + .count_object(ObjectType::MtaInboundThrottle) + .await? + == 0 + { + for object in [ + MtaInboundThrottle { + description: "Sender IP throttle".to_string(), + enable: true, + key: Map::new(vec![MtaInboundThrottleKey::RemoteIp]), + rate: Rate { + count: 5, + period: Duration::from_millis(1000), + }, + ..Default::default() + }, + MtaInboundThrottle { + description: "Sender address to recipient throttle".to_string(), + enable: true, + key: Map::new(vec![ + MtaInboundThrottleKey::SenderDomain, + MtaInboundThrottleKey::Rcpt, + ]), + rate: Rate { + count: 25, + period: Duration::from_millis(60 * 60 * 1000), + }, + ..Default::default() + }, + ] { + bp.registry + .write(RegistryWrite::insert(&object.into())) + .await?; + } + } + + if bp + .registry + .count_object(ObjectType::MtaVirtualQueue) + .await? + == 0 + && bp + .registry + .count_object(ObjectType::MtaDeliverySchedule) + .await? + == 0 + { + for (id, object) in [ + MtaVirtualQueue { + description: "Local delivery queue".to_string().into(), + name: "local".into(), + threads_per_node: 25, + }, + MtaVirtualQueue { + description: "Remote delivery queue".to_string().into(), + name: "remote".into(), + threads_per_node: 50, + }, + MtaVirtualQueue { + description: "Delivery Status Notification delivery queue" + .to_string() + .into(), + name: "dsn".into(), + threads_per_node: 5, + }, + MtaVirtualQueue { + description: "DMARC and TLS report delivery queue".to_string().into(), + name: "report".into(), + threads_per_node: 5, + }, + ] + .into_iter() + .enumerate() + { + bp.registry + .write(RegistryWrite::insert_with_id( + (id as u64).into(), + &object.into(), + )) + .await?; + } + + for (id, object) in [ + MtaDeliverySchedule { + name: "local".into(), + description: "Local delivery schedule".to_string().into(), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: Duration::from_millis(3 * 24 * 60 * 60 * 1000), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Default, + retry: MtaDeliveryScheduleIntervalsOrDefault::Default, + queue_id: 0u64.into(), + }, + MtaDeliverySchedule { + name: "remote".into(), + description: "Remote delivery schedule".to_string().into(), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: Duration::from_millis(3 * 24 * 60 * 60 * 1000), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Default, + retry: MtaDeliveryScheduleIntervalsOrDefault::Default, + queue_id: 1u64.into(), + }, + MtaDeliverySchedule { + name: "dsn".into(), + description: "Delivery Status Notification delivery schedule" + .to_string() + .into(), + expiry: MtaDeliveryExpiration::Attempts(MtaDeliveryExpirationAttempts { + max_attempts: 10, + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Default, + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom( + MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(15 * 60 * 1000), + }, + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(30 * 60 * 1000), + }, + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(60 * 60 * 1000), + }, + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(2 * 60 * 60 * 1000), + }, + ]), + }, + ), + queue_id: 2u64.into(), + }, + MtaDeliverySchedule { + name: "report".into(), + description: "DMARC and TLS report delivery schedule".to_string().into(), + expiry: MtaDeliveryExpiration::Attempts(MtaDeliveryExpirationAttempts { + max_attempts: 8, + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Default, + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom( + MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(30 * 60 * 1000), + }, + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(60 * 60 * 1000), + }, + MtaDeliveryScheduleInterval { + duration: Duration::from_millis(2 * 60 * 60 * 1000), + }, + ]), + }, + ), + queue_id: 3u64.into(), + }, + ] + .into_iter() + .enumerate() + { + bp.registry + .write(RegistryWrite::insert_with_id( + (id as u64).into(), + &object.into(), + )) + .await?; + } + } + + if bp.registry.count_object(ObjectType::MtaTlsStrategy).await? == 0 { + for object in [ + MtaTlsStrategy { + name: "invalid-tls".into(), + description: "Allow invalid TLS certificates".to_string().into(), + allow_invalid_certs: true, + ..Default::default() + }, + MtaTlsStrategy { + name: "default".into(), + description: "Default TLS settings".to_string().into(), + allow_invalid_certs: false, + ..Default::default() + }, + ] { + bp.registry + .write(RegistryWrite::insert(&object.into())) + .await?; + } + } + + if bp.registry.count_object(ObjectType::MtaRoute).await? == 0 { + for object in [ + MtaRoute::Mx(MtaRouteMx { + description: "MX delivery route".to_string().into(), + ip_lookup_strategy: MtaIpStrategy::V4ThenV6, + max_multihomed: 2, + max_mx_hosts: 2, + name: "mx".into(), + }), + MtaRoute::Local(MtaRouteCommon { + description: "Local delivery route".to_string().into(), + name: "local".into(), + }), + ] { + bp.registry + .write(RegistryWrite::insert(&object.into())) + .await?; + } + } + + if bp + .registry + .count_object(ObjectType::MtaConnectionStrategy) + .await? + == 0 + { + bp.registry + .write(RegistryWrite::insert( + &MtaConnectionStrategy { + name: "default".into(), + description: "Default connection strategy".to_string().into(), + ..Default::default() + } + .into(), + )) + .await?; + } + + if bp.registry.count_object(ObjectType::OidcProvider).await? == 0 { + let pkcs8_doc = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &SystemRandom::new()) + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::Startup) + .into_err() + .reason(err) + .caused_by(trc::location!()) + })?; + let signature_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref())); + + bp.registry + .write(RegistryWrite::insert( + &OidcProvider { + encryption_key: SecretKey::Value(SecretKeyValue { + secret: rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::(), + }), + signature_key: SecretText::Text(SecretTextValue { + secret: signature_pem, + }), + signature_algorithm: JwtSignatureAlgorithm::Es256, + ..Default::default() + } + .into(), + )) + .await?; + + // Generate a Web Push VAPID signing key (RFC 9749) + if bp.registry.count_object(ObjectType::Jmap).await? == 0 { + match crate::network::webpush::generate_pkcs8_pem() { + Ok(web_push_pem) => { + bp.registry + .write(RegistryWrite::insert( + &Jmap { + web_push_key: SecretTextOptional::Text(SecretTextValue { + secret: web_push_pem, + }), + ..Default::default() + } + .into(), + )) + .await?; + } + Err(err) => { + trc::event!( + Server(trc::ServerEvent::Startup), + Details = "Failed to generate Web Push VAPID key", + Reason = err + ); + } + } + } + } + + if bp.registry.count_object(ObjectType::Role).await? == 0 { + let permissions = DefaultPermissions::default(); + let mut role_ids = Vec::with_capacity(4); + + for role in [ + Role { + description: "User".into(), + enabled_permissions: Map::new(permissions.user), + ..Default::default() + }, + Role { + description: "Group".into(), + enabled_permissions: Map::new(permissions.group), + ..Default::default() + }, + Role { + description: "Tenant Administrator".into(), + enabled_permissions: Map::new(permissions.tenant), + ..Default::default() + }, + Role { + description: "System Administrator".into(), + enabled_permissions: Map::new(permissions.superuser), + ..Default::default() + }, + ] { + match bp + .registry + .write(RegistryWrite::insert(&role.into())) + .await? + { + RegistryWriteResult::Success(id) => role_ids.push(id), + err => { + bp.build_error( + ObjectType::Role.singleton(), + format!("Failed to insert default role: {err}"), + ); + } + } + } + + if bp.registry.count_object(ObjectType::Authentication).await? == 0 && role_ids.len() == 4 { + bp.registry + .write(RegistryWrite::insert( + &Authentication { + default_user_role_ids: Map::new(vec![role_ids[0]]), + default_group_role_ids: Map::new(vec![role_ids[1]]), + default_tenant_role_ids: Map::new(vec![role_ids[2], role_ids[0]]), + default_admin_role_ids: Map::new(vec![role_ids[3], role_ids[0]]), + ..Default::default() + } + .into(), + )) + .await?; + } + } + + if bp + .registry + .count_object(ObjectType::NetworkListener) + .await? + == 0 + { + for (protocol, name, port, tls_implicit) in [ + (NetworkListenerProtocol::Smtp, "smtp", 25, false), + (NetworkListenerProtocol::Smtp, "submissions", 465, true), + (NetworkListenerProtocol::Imap, "imaps", 993, true), + (NetworkListenerProtocol::Pop3, "pop3s", 995, true), + (NetworkListenerProtocol::ManageSieve, "sieve", 4190, false), + (NetworkListenerProtocol::Http, "https", 443, true), + (NetworkListenerProtocol::Http, "http", 8080, false), + ] { + bp.registry + .write(RegistryWrite::insert( + &NetworkListener { + bind: Map::new(vec![ + SocketAddr::from_str(&format!("[::]:{port}")).unwrap(), + ]), + name: name.to_string(), + protocol, + use_tls: true, + tls_implicit, + ..Default::default() + } + .into(), + )) + .await?; + } + } + + #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] + if bp.registry.count_object(ObjectType::Asn).await? == 0 { + bp.registry + .write(RegistryWrite::insert( + &Asn::Resource(AsnResource { + asn_urls: Map::new(vec![ASN_IPV4.into(), ASN_IPV6.into()]), + geo_urls: Map::new(vec![GEO_IPV4.into(), GEO_IPV6.into()]), + max_size: 104857600, + expires: Duration::from_millis(24 * 60 * 60 * 1000), + timeout: Duration::from_millis(5 * 60 * 1000), + ..Default::default() + }) + .into(), + )) + .await?; + } + + #[cfg(not(feature = "test_mode"))] + if bp.registry.count_object(ObjectType::TracingStore).await? == 0 { + bp.registry + .write(RegistryWrite::insert(&TracingStore::Default.into())) + .await?; + } + + #[cfg(not(feature = "test_mode"))] + if bp.registry.count_object(ObjectType::MetricsStore).await? == 0 { + bp.registry + .write(RegistryWrite::insert(&MetricsStore::Default.into())) + .await?; + } + + if bp.registry.count_object(ObjectType::Tracer).await? == 0 { + bp.registry + .write(RegistryWrite::insert( + &Tracer::Log(TracerLog { + enable: true, + ansi: false, + prefix: "stalwart.log".into(), + rotate: LogRotateFrequency::Daily, + path: "/var/log/stalwart".into(), + ..Default::default() + }) + .into(), + )) + .await?; + } + + #[cfg(not(feature = "test_mode"))] + { + use store::write::BatchBuilder; + use types::id::Id; + + if bp.registry.count_object(ObjectType::SpamRule).await? == 0 + && bp + .registry + .object::(Id::singleton()) + .await? + .is_none_or(|spam| spam.spam_filter_rules_url.is_some()) + { + let mut batch = BatchBuilder::new(); + batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules, + status: TaskStatus::now(), + })); + bp.data_store.write(batch.build_all()).await?; + } + } + + Ok(()) +} diff --git a/crates/common/src/manager/mod.rs b/crates/common/src/manager/mod.rs new file mode 100644 index 0000000..a86bdb0 --- /dev/null +++ b/crates/common/src/manager/mod.rs @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::USER_AGENT; +use hyper::HeaderMap; +use mail_auth::flate2; +use std::{ + io::{BufReader, Read}, + time::Duration, +}; +use utils::HttpLimitResponse; + +pub mod application; +pub mod backup; +pub mod boot; +pub mod console; +pub mod defaults; +pub mod restore; + +pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes(); +pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes(); + +pub async fn fetch_resource( + url: &str, + headers: Option, + timeout: Duration, + max_size: usize, +) -> Result, String> { + if let Some(path) = url.strip_prefix("file://") { + tokio::fs::read(path) + .await + .map_err(|err| format!("Failed to read {path}: {err}")) + } else { + let response = utils::http::http_client_builder(is_localhost_url(url)) + .timeout(timeout) + .user_agent(USER_AGENT) + .build() + .unwrap_or_default() + .get(url) + .headers(headers.unwrap_or_default()) + .send() + .await + .map_err(|err| format!("Failed to fetch {url}: {err}"))?; + + if response.status().is_success() { + response + .bytes_with_limit(max_size) + .await + .map_err(|err| format!("Failed to fetch {url}: {err}")) + .and_then(|bytes| bytes.ok_or_else(|| format!("Resource too large: {url}"))) + } else { + let code = response.status().canonical_reason().unwrap_or_default(); + let reason = response.text().await.unwrap_or_default(); + + Err(format!( + "Failed to fetch {url}: Code: {code}, Details: {reason}", + )) + } + } + .and_then(|bytes| { + if url.ends_with(".gz") || url.ends_with(".gzip") { + BufReader::new(flate2::read::GzDecoder::new(&bytes[..])) + .bytes() + .collect::, _>>() + .map_err(|err| format!("Failed to decompress {url}: {err}")) + } else { + Ok(bytes) + } + }) +} + +pub fn is_localhost_url(url: &str) -> bool { + url.split_once("://") + .map(|(_, url)| url.split_once('/').map_or(url, |(host, _)| host)) + .is_some_and(|host| { + let host = host.rsplit_once(':').map_or(host, |(host, _)| host); + host == "localhost" || host == "127.0.0.1" || host == "[::1]" + }) +} diff --git a/crates/common/src/manager/restore.rs b/crates/common/src/manager/restore.rs new file mode 100644 index 0000000..cfdb084 --- /dev/null +++ b/crates/common/src/manager/restore.rs @@ -0,0 +1,287 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::backup::MAGIC_MARKER; +use crate::{Core, DATABASE_SCHEMA_VERSION}; +use lz4_flex::frame::FrameDecoder; +use registry::schema::enums::CompressionAlgo; +use std::{ + fs::File, + io::{BufReader, ErrorKind, Read}, + path::{Path, PathBuf}, +}; +use store::{ + BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES, SUBSPACE_QUOTA, + SUBSPACE_REGISTRY_PK, Store, U32_LEN, + write::{ + AnyClass, AnyKey, BatchBuilder, ValueClass, + key::{DeserializeBigEndian, is_node_id_key}, + }, +}; +use types::{collection::Collection, field::Field}; +use utils::{UnwrapFailure, failed}; + +impl Core { + pub async fn restore(&self, src: PathBuf) { + // Backup the core + let paths = if src.is_dir() { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(&src).failed("Failed to read directory") { + let entry = entry.failed("Failed to read entry"); + let path = entry.path(); + if path.is_file() { + paths.push(path); + } + } + paths + } else { + vec![src] + }; + + let mut conflicts = Vec::new(); + for path in &paths { + let subspace = KeyValueReader::new(path).subspace; + if subspace_has_data(&self.storage.data, subspace).await { + conflicts.push(path.clone()); + } + } + + if !conflicts.is_empty() { + eprintln!( + "Cannot import: the target database already contains data in the key ranges being \ + imported. This usually means Stalwart was started before the import ran, which \ + can create duplicate entries. Import into a fresh, empty database and do not \ + start Stalwart before importing. Conflicting dumps:" + ); + for path in conflicts { + eprintln!(" {}", path.display()); + } + std::process::exit(1); + } + + let mut tasks = Vec::new(); + for path in paths { + let storage = self.storage.clone(); + let blob_store = self.storage.blob.clone(); + tasks.push(tokio::spawn(async move { + restore_file(storage.data, blob_store, &path).await; + })); + } + + for task in tasks { + task.await.failed("Failed to wait for task"); + } + } +} + +async fn subspace_has_data(store: &Store, subspace: u8) -> bool { + let mut has_data = false; + store + .iterate( + IterateParams::new( + AnyKey { + subspace, + key: vec![0u8], + }, + AnyKey { + subspace, + key: vec![u8::MAX; 32], + }, + ) + .no_values(), + |key, _| { + if subspace == SUBSPACE_REGISTRY_PK && is_node_id_key(key) { + Ok(true) + } else { + has_data = true; + Ok(false) + } + }, + ) + .await + .failed("Failed to inspect target database"); + has_data +} + +async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) { + println!("Importing database dump from {}.", path.to_str().unwrap()); + + let mut reader = KeyValueReader::new(path); + let mut batch = BatchBuilder::new(); + + match reader.subspace { + SUBSPACE_BLOBS => { + while let Some((key, value)) = reader.next() { + blob_store + .put_blob(&key, &value, CompressionAlgo::Lz4) + .await + .failed("Failed to write blob"); + } + } + SUBSPACE_COUNTER | SUBSPACE_QUOTA => { + while let Some((key, value)) = reader.next() { + batch.add( + ValueClass::Any(AnyClass { + subspace: reader.subspace, + key, + }), + u64::from_le_bytes( + value + .try_into() + .expect("Failed to deserialize counter/quota"), + ) as i64, + ); + if batch.is_large_batch() { + store + .write(batch.build_all()) + .await + .failed("Failed to write batch"); + batch = BatchBuilder::new(); + } + } + } + SUBSPACE_INDEXES => { + while let Some((key, _)) = reader.next() { + let account_id = key + .as_slice() + .deserialize_be_u32(0) + .failed("Failed to deserialize account ID"); + let collection = *key.get(U32_LEN).failed("Missing collection byte"); + let field = *key.get(U32_LEN + 1).failed("Missing field byte"); + let value = key + .get(U32_LEN + 2..key.len() - U32_LEN) + .failed("Missing index key") + .to_vec(); + let document_id = key + .as_slice() + .deserialize_be_u32(key.len() - U32_LEN) + .failed("Failed to deserialize document ID"); + + batch + .with_account_id(account_id) + .with_collection(Collection::from(collection)) + .with_document(document_id) + .index(Field::new(field), value); + + if batch.is_large_batch() { + store + .write(batch.build_all()) + .await + .failed("Failed to write batch"); + batch = BatchBuilder::new(); + } + } + } + _ => { + while let Some((key, value)) = reader.next() { + batch.set( + ValueClass::Any(AnyClass { + subspace: reader.subspace, + key, + }), + value, + ); + if batch.is_large_batch() { + store + .write(batch.build_all()) + .await + .failed("Failed to write batch"); + batch = BatchBuilder::new(); + } + } + } + } + + if !batch.is_empty() { + store + .write(batch.build_all()) + .await + .failed("Failed to write batch"); + } +} + +struct KeyValueReader { + subspace: u8, + file: FrameDecoder>, +} + +impl KeyValueReader { + fn new(path: &Path) -> Self { + let mut file = FrameDecoder::new(BufReader::new( + File::open(path).failed("Failed to open file"), + )); + let mut buf = [0u8; 1]; + file.read_exact(&mut buf) + .failed(&format!("Failed to read magic marker from {path:?}")); + + if buf[0] != MAGIC_MARKER { + failed(&format!("Invalid magic marker in {path:?}")); + } + + file.read_exact(&mut buf) + .failed(&format!("Failed to read subspace from {path:?}")); + let subspace = buf[0]; + + let mut buf = [0u8; 4]; + file.read_exact(&mut buf) + .failed(&format!("Failed to read version from {path:?}")); + let version = u32::from_le_bytes(buf); + + if version != DATABASE_SCHEMA_VERSION { + failed(&format!( + "Invalid database schema version in {path:?}: Expected {DATABASE_SCHEMA_VERSION}, found {version}" + )); + } + + Self { file, subspace } + } + + fn next(&mut self) -> Option<(Vec, Vec)> { + let size = self.read_size()?; + + let mut key = vec![0; size as usize]; + self.file + .read_exact(&mut key) + .failed("Failed to read bytes"); + let value = self.expect_sized_bytes(); + + Some((key, value)) + } + + fn read_size(&mut self) -> Option { + let mut result = 0; + let mut buf = [0u8; 1]; + + for shift in [0, 7, 14, 21, 28] { + if let Err(err) = self.file.read_exact(&mut buf) { + if err.kind() == ErrorKind::UnexpectedEof { + return None; + } else { + failed(&format!("Failed to read file: {err:?}")); + } + } + + let byte = buf[0]; + if (byte & 0x80) == 0 { + result |= (byte as u32) << shift; + return Some(result); + } else { + result |= ((byte & 0x7F) as u32) << shift; + } + } + + failed("Invalid leb128 sequence") + } + + fn expect_sized_bytes(&mut self) -> Vec { + let len = self.read_size().failed("Missing leb128 value sequence") as usize; + let mut bytes = vec![0; len]; + self.file + .read_exact(&mut bytes) + .failed("Failed to read bytes"); + bytes + } +} diff --git a/crates/common/src/network/acme/account.rs b/crates/common/src/network/acme/account.rs new file mode 100644 index 0000000..318ae9b --- /dev/null +++ b/crates/common/src/network/acme/account.rs @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::jose::{Body, eab_sign, sign}; +use crate::network::acme::http::{get_header, https}; +use crate::network::acme::{AcmeError, AcmeResult, Directory}; +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; +use base64::Engine; +use base64::engine::general_purpose::{self, URL_SAFE_NO_PAD}; +use registry::schema::structs::AcmeProvider; +use reqwest::Method; +use utils::sanitize_email; + +static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + +#[derive(Clone)] +pub struct EabSettings { + pub kid: String, + pub hmac_key: Vec, +} + +#[derive(Debug, serde::Serialize)] +pub struct NewAccountPayload<'x> { + #[serde(rename = "termsOfServiceAgreed")] + tos_agreed: bool, + contact: &'x [String], + #[serde(rename = "externalAccountBinding")] + #[serde(skip_serializing_if = "Option::is_none")] + eab: Option, +} + +pub async fn acme_create_account( + provider: &mut AcmeProvider, + eab: Option, +) -> AcmeResult<()> { + if provider.contact.is_empty() { + return Err(AcmeError::Invalid( + "At least one contact email is required".to_string(), + )); + } + + for contact in provider.contact.iter_mut() { + let email = sanitize_email(contact.trim().strip_prefix("mailto:").unwrap_or(contact)) + .ok_or_else(|| AcmeError::Invalid(format!("Invalid contact email: {}", contact)))?; + *contact = format!("mailto:{}", email); + } + + let directory = Directory::discover(&provider.directory, provider.max_retries as u32).await?; + let account_key = EcdsaKeyPair::generate_pkcs8(ALG, &SystemRandom::new()).unwrap(); + let key_pair = EcdsaKeyPair::from_pkcs8(ALG, account_key.as_ref()) + .map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?; + let eab = if let Some(eab) = &eab { + eab_sign(&key_pair, &eab.kid, &eab.hmac_key, &directory.new_account)?.into() + } else { + None + }; + + let payload = serde_json::to_string(&NewAccountPayload { + tos_agreed: true, + contact: provider.contact.as_slice(), + eab, + }) + .unwrap_or_default(); + let body = sign( + &key_pair, + None, + directory.nonce(provider.max_retries as u32).await?, + &directory.new_account, + &payload, + )?; + + provider.account_uri = get_header( + &https( + &directory.new_account, + Method::POST, + Some(body), + provider.max_retries as u32, + ) + .await?, + "Location", + )?; + provider.account_key = URL_SAFE_NO_PAD.encode(account_key.as_ref()); + + Ok(()) +} + +impl EabSettings { + pub fn new(kid: impl Into, hmac_key: impl AsRef<[u8]>) -> AcmeResult { + let key = general_purpose::URL_SAFE_NO_PAD + .decode(hmac_key.as_ref()) + .map_err(|err| AcmeError::Invalid(format!("Failed to decode EAB HMAC key: {}", err)))?; + Ok(Self { + kid: kid.into(), + hmac_key: key, + }) + } +} diff --git a/crates/common/src/network/acme/directory.rs b/crates/common/src/network/acme/directory.rs new file mode 100644 index 0000000..5f5d5be --- /dev/null +++ b/crates/common/src/network/acme/directory.rs @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use super::jose::{ + key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign, +}; +use crate::network::acme::http::{get_header, https, parse_alternate_links, parse_retry_after}; +use crate::network::acme::{ + AcmeError, AcmeResult, Auth, AuthStatus, Challenge, ChallengeType, Directory, Identifier, + Order, SerializedCert, +}; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rcgen::{CustomExtension, KeyPair, PKCS_ECDSA_P256_SHA256}; +use registry::schema::structs::AcmeProvider; +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::time::Duration; +use store::Serialize; +use store::write::Archiver; + +pub const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1"; + +#[derive(Debug)] +pub struct AcmeRequestBuilder { + pub key_pair: EcdsaKeyPair, + pub directory: Directory, + pub kid: String, + pub challenge: ChallengeType, + pub max_retries: u32, + pub preferred_chain: Option, +} + +pub struct AcmeResponse { + pub location: L, + pub body: B, + pub retry_after: Option, + pub alternates: Vec, +} + +static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + +impl AcmeRequestBuilder { + pub async fn new(provider: AcmeProvider) -> AcmeResult { + let directory = + Directory::discover(&provider.directory, provider.max_retries as u32).await?; + let key_pair = EcdsaKeyPair::from_pkcs8( + ALG, + &URL_SAFE_NO_PAD + .decode(&provider.account_key) + .map_err(|err| { + AcmeError::Crypto(format!("Failed to decode account key: {}", err)) + })?, + ) + .map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?; + + Ok(Self { + key_pair, + directory, + kid: provider.account_uri, + challenge: provider.challenge_type.into(), + max_retries: provider.max_retries as u32, + preferred_chain: provider.preferred_chain, + }) + } + + async fn request( + &self, + url: impl AsRef, + payload: &str, + ) -> AcmeResult, String>> { + let body = sign( + &self.key_pair, + Some(&self.kid), + self.directory.nonce(self.max_retries).await?, + url.as_ref(), + payload, + )?; + let response = https(url.as_ref(), Method::POST, Some(body), self.max_retries).await?; + + Ok(AcmeResponse { + location: get_header(&response, "Location").ok(), + retry_after: parse_retry_after(&response), + alternates: parse_alternate_links(&response), + body: response.text().await?, + }) + } + + pub async fn new_order(&self, domains: Vec) -> AcmeResult> { + let domains: Vec = domains.into_iter().map(Identifier::Dns).collect(); + let payload = json!({ + "identifiers": domains, + }) + .to_string(); + let response = self.request(&self.directory.new_order, &payload).await?; + Ok(AcmeResponse { + location: response.location.ok_or(AcmeError::Invalid(format!( + "Missing Location header in new order response from {}", + self.directory.new_order + )))?, + body: serde_json::from_str(&response.body).map_err(AcmeError::Json)?, + retry_after: response.retry_after, + alternates: response.alternates, + }) + } + + pub async fn auth( + &self, + url: impl AsRef, + ) -> AcmeResult, Auth>> { + AcmeResponse::parse(self.request(url, "").await?) + } + + pub async fn challenge(&self, url: impl AsRef) -> AcmeResult<()> { + self.request(&url, "{}").await.map(|_| ()) + } + + pub async fn order( + &self, + url: impl AsRef, + ) -> AcmeResult, Order>> { + AcmeResponse::parse(self.request(&url, "").await?) + } + + pub async fn finalize( + &self, + url: impl AsRef, + csr: Vec, + ) -> AcmeResult, Order>> { + let payload = format!("{{\"csr\":\"{}\"}}", URL_SAFE_NO_PAD.encode(csr)); + AcmeResponse::parse(self.request(&url, &payload).await?) + } + + pub async fn certificate( + &self, + url: impl AsRef, + ) -> AcmeResult, String>> { + self.request(&url, "").await + } + + pub fn http_proof(&self, challenge: &Challenge) -> AcmeResult> { + let challenge_token = challenge.token.as_deref().ok_or_else(|| { + AcmeError::Invalid("Missing http-01 challenge token in response".to_string()) + })?; + key_authorization(&self.key_pair, challenge_token).map(|key| key.into_bytes()) + } + + pub fn dns_proof(&self, challenge: &Challenge) -> AcmeResult { + let challenge_token = challenge.token.as_deref().ok_or_else(|| { + AcmeError::Invalid("Missing dns-01 challenge token in response".to_string()) + })?; + key_authorization_sha256_base64(&self.key_pair, challenge_token) + } + + pub fn tls_alpn_key(&self, challenge: &Challenge, domain: String) -> AcmeResult> { + let challenge_token = challenge.token.as_deref().ok_or_else(|| { + AcmeError::Invalid("Missing tls-alpn-01 challenge token in response".to_string()) + })?; + let mut params = rcgen::CertificateParams::new(vec![domain]).map_err(|err| { + AcmeError::Crypto(format!("Failed to create certificate params: {}", err)) + })?; + let key_auth = key_authorization_sha256(&self.key_pair, challenge_token)?; + params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())]; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .map_err(|err| AcmeError::Crypto(format!("Failed to generate key pair: {}", err)))?; + let cert = params.self_signed(&key_pair).map_err(|err| { + AcmeError::Crypto(format!( + "Failed to generate TLS-ALPN-01 certificate: {}", + err + )) + })?; + + Archiver::new(SerializedCert { + certificate: cert.der().to_vec(), + private_key: key_pair.serialize_der(), + }) + .untrusted() + .serialize() + .map_err(|_| AcmeError::Crypto("Failed to serialize certificate".to_string())) + } +} + +impl Directory { + pub async fn discover(url: impl AsRef, max_retries: u32) -> AcmeResult { + serde_json::from_str( + &https(url, Method::GET, None, max_retries) + .await? + .text() + .await?, + ) + .map_err(Into::into) + } + + pub async fn nonce(&self, max_retries: u32) -> AcmeResult { + get_header( + &https(&self.new_nonce.as_str(), Method::HEAD, None, max_retries).await?, + "replay-nonce", + ) + } +} + +impl AcmeResponse { + pub fn parse(input: AcmeResponse) -> AcmeResult> { + serde_json::from_str(&input.body) + .map_err(|err| { + AcmeError::Invalid(format!( + "ACME response parsing error: {}, body: {}", + err, input.body + )) + }) + .map(|body| AcmeResponse { + location: input.location, + body, + retry_after: input.retry_after, + alternates: input.alternates, + }) + } +} + +impl AcmeResponse { + pub fn assert_reasonable_retry_after(self, max_retries: u32) -> AcmeResult { + if let Some(retry_after) = self.retry_after + && retry_after > Duration::from_secs(10 * 60) + { + trc::event!( + Acme(trc::AcmeEvent::RenewBackoff), + Elapsed = retry_after, + Reason = "ACME server requested an excessively long Retry-After", + ); + + return Err(AcmeError::Backoff { + max_retries, + wait: retry_after.into(), + }); + } + + Ok(self) + } +} + +impl ChallengeType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Http01 => "http-01", + Self::Dns01 => "dns-01", + Self::TlsAlpn01 => "tls-alpn-01", + Self::DnsPersist01 => "dns-persist-01", + Self::Unknown => "unknown", + } + } +} + +impl AuthStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Valid => "valid", + Self::Invalid => "invalid", + Self::Revoked => "revoked", + Self::Expired => "expired", + Self::Deactivated => "deactivated", + } + } +} diff --git a/crates/common/src/network/acme/http.rs b/crates/common/src/network/acme/http.rs new file mode 100644 index 0000000..9ed63db --- /dev/null +++ b/crates/common/src/network/acme/http.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::network::acme::{AcmeError, AcmeResult}; +use chrono::{DateTime, Utc}; +use hyper::{ + Method, StatusCode, + header::{CONTENT_TYPE, USER_AGENT}, +}; +use reqwest::Response; +use std::time::Duration; + +#[allow(unused_mut)] +pub(crate) async fn https( + url: impl AsRef, + method: Method, + body: Option, + max_retries: u32, +) -> AcmeResult { + let url = url.as_ref(); + + #[allow(unused_mut)] + #[allow(unused_assignments)] + let mut allow_invalid_certs = false; + + #[cfg(any(feature = "dev_mode", feature = "test_mode"))] + { + allow_invalid_certs = + url.starts_with("https://localhost") || url.starts_with("https://127.0.0.1"); + } + + let mut request = utils::http::http1_client_builder(allow_invalid_certs) + .timeout(Duration::from_secs(30)) + .build()? + .request(method, url) + .header(USER_AGENT, crate::USER_AGENT); + + if let Some(body) = body { + request = request + .header(CONTENT_TYPE, "application/jose+json") + .body(body); + } + + let response = request.send().await?; + if response.status().is_success() { + Ok(response) + } else if matches!( + response.status(), + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE + ) { + let wait = parse_retry_after(&response); + + trc::event!( + Acme(trc::AcmeEvent::RenewBackoff), + Url = url.to_string(), + Code = response.status().as_u16(), + Elapsed = wait.unwrap_or_default(), + ); + + Err(AcmeError::Backoff { wait, max_retries }) + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + Err(AcmeError::HttpStatus(format!( + "Unexpected status {}: {}", + status, text + ))) + } +} + +pub(crate) fn get_header(response: &Response, header: &'static str) -> AcmeResult { + match response.headers().get_all(header).iter().next_back() { + Some(value) => Ok(value + .to_str() + .map_err(|err| { + AcmeError::Invalid(format!("Failed to read header {}: {}", header, err)) + })? + .to_string()), + None => Err(AcmeError::Invalid(format!("Missing header: {}", header))), + } +} + +pub(crate) fn parse_alternate_links(response: &Response) -> Vec { + alternate_links( + response + .headers() + .get_all("Link") + .iter() + .filter_map(|value| value.to_str().ok()), + ) +} + +fn alternate_links<'a>(values: impl Iterator) -> Vec { + let mut urls = Vec::new(); + for value in values { + for link in value.split(',') { + let mut url = None; + let mut is_alternate = false; + for (index, part) in link.split(';').enumerate() { + let part = part.trim(); + if index == 0 { + url = part + .strip_prefix('<') + .and_then(|part| part.strip_suffix('>')); + } else if let Some(rel) = part.strip_prefix("rel=") { + is_alternate = rel.trim_matches('"') == "alternate"; + } + } + if is_alternate && let Some(url) = url { + urls.push(url.to_string()); + } + } + } + urls +} + +pub(crate) fn parse_retry_after(response: &Response) -> Option { + let value = response.headers().get("Retry-After")?.to_str().ok()?; + if let Ok(secs) = value.parse::() { + Some(Duration::from_secs(secs + 1)) + } else if let Ok(dt) = DateTime::parse_from_rfc2822(value) { + Utc::now() + .signed_duration_since(dt.with_timezone(&Utc)) + .to_std() + .map(|dur| dur + Duration::from_secs(1)) + .ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::alternate_links; + + #[test] + fn parses_single_alternate_link() { + let links = + alternate_links([r#";rel="alternate""#].into_iter()); + assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]); + } + + #[test] + fn parses_multiple_alternates_in_one_header() { + let links = alternate_links( + [r#";rel="alternate", ;rel="alternate""#] + .into_iter(), + ); + assert_eq!( + links, + vec![ + "https://acme.example/cert/1/1".to_string(), + "https://acme.example/cert/1/2".to_string(), + ] + ); + } + + #[test] + fn parses_alternates_across_multiple_headers() { + let links = alternate_links( + [ + r#";rel="alternate""#, + r#";rel="alternate""#, + ] + .into_iter(), + ); + assert_eq!( + links, + vec![ + "https://acme.example/cert/1/1".to_string(), + "https://acme.example/cert/1/2".to_string(), + ] + ); + } + + #[test] + fn ignores_non_alternate_relations() { + let links = alternate_links( + [r#";rel="index", ;rel="alternate""#] + .into_iter(), + ); + assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]); + } + + #[test] + fn tolerates_unquoted_rel_and_extra_whitespace() { + let links = + alternate_links([r#" ; rel=alternate "#].into_iter()); + assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]); + } + + #[test] + fn returns_empty_when_no_alternates() { + let links = alternate_links([r#";rel="index""#].into_iter()); + assert!(links.is_empty()); + } +} diff --git a/crates/common/src/network/acme/jose.rs b/crates/common/src/network/acme/jose.rs new file mode 100644 index 0000000..b233c35 --- /dev/null +++ b/crates/common/src/network/acme/jose.rs @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use crate::network::acme::{AcmeError, AcmeResult}; +use aws_lc_rs::digest::{Digest, SHA256, digest}; +use aws_lc_rs::hmac; +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::signature::{EcdsaKeyPair, KeyPair}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::Serialize; + +pub(crate) fn sign( + key: &EcdsaKeyPair, + kid: Option<&str>, + nonce: String, + url: &str, + payload: &str, +) -> AcmeResult { + let jwk = match kid { + None => Some(Jwk::new(key)), + Some(_) => None, + }; + let protected = Protected::encode("ES256", jwk, kid, nonce.into(), url)?; + let payload = URL_SAFE_NO_PAD.encode(payload); + let combined = format!("{}.{}", protected, payload); + let signature = key + .sign(&SystemRandom::new(), combined.as_bytes()) + .map_err(|err| AcmeError::Crypto(format!("Failed to sign payload: {}", err)))?; + + serde_json::to_string(&Body { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature.as_ref()), + }) + .map_err(Into::into) +} + +pub(crate) fn eab_sign( + key: &EcdsaKeyPair, + kid: &str, + hmac_key: &[u8], + url: &str, +) -> AcmeResult { + let protected = Protected::encode("HS256", None, kid.into(), None, url)?; + let payload = Jwk::new(key).base64()?; + let combined = format!("{}.{}", protected, payload); + + let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_key); + let tag = hmac::sign(&key, combined.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(tag.as_ref()); + + Ok(Body { + protected, + payload, + signature, + }) +} + +pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> AcmeResult { + Ok(format!( + "{}.{}", + token, + Jwk::new(key).thumb_sha256_base64()? + )) +} + +pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> AcmeResult { + key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes())) +} + +pub(crate) fn key_authorization_sha256_base64( + key: &EcdsaKeyPair, + token: &str, +) -> AcmeResult { + key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref())) +} + +#[derive(Debug, Serialize)] +pub(crate) struct Body { + protected: String, + payload: String, + signature: String, +} + +#[derive(Serialize)] +struct Protected<'a> { + alg: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + jwk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + nonce: Option, + url: &'a str, +} + +impl<'a> Protected<'a> { + fn encode( + alg: &'static str, + jwk: Option, + kid: Option<&'a str>, + nonce: Option, + url: &'a str, + ) -> AcmeResult { + serde_json::to_vec(&Protected { + alg, + jwk, + kid, + nonce, + url, + }) + .map_err(Into::into) + .map(|v| URL_SAFE_NO_PAD.encode(v.as_slice())) + } +} + +#[derive(Serialize)] +struct Jwk { + alg: &'static str, + crv: &'static str, + kty: &'static str, + #[serde(rename = "use")] + u: &'static str, + x: String, + y: String, +} + +impl Jwk { + pub(crate) fn new(key: &EcdsaKeyPair) -> Self { + let (x, y) = key.public_key().as_ref()[1..].split_at(32); + Self { + alg: "ES256", + crv: "P-256", + kty: "EC", + u: "sig", + x: URL_SAFE_NO_PAD.encode(x), + y: URL_SAFE_NO_PAD.encode(y), + } + } + + pub(crate) fn base64(&self) -> AcmeResult { + serde_json::to_vec(self) + .map_err(Into::into) + .map(|v| URL_SAFE_NO_PAD.encode(v.as_slice())) + } + + pub(crate) fn thumb_sha256_base64(&self) -> AcmeResult { + Ok(URL_SAFE_NO_PAD.encode(digest( + &SHA256, + &serde_json::to_vec(&JwkThumb { + crv: self.crv, + kty: self.kty, + x: &self.x, + y: &self.y, + }) + .map_err(AcmeError::Json)?, + ))) + } +} + +#[derive(Serialize)] +struct JwkThumb<'a> { + crv: &'a str, + kty: &'a str, + x: &'a str, + y: &'a str, +} diff --git a/crates/common/src/network/acme/mod.rs b/crates/common/src/network/acme/mod.rs new file mode 100644 index 0000000..455b1d5 --- /dev/null +++ b/crates/common/src/network/acme/mod.rs @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod account; +pub mod directory; +pub mod http; +pub mod jose; +pub mod order; +pub mod renew; +pub mod resolver; + +use crate::network::dns::update::DnsUpdater; +use chrono::{DateTime, Utc}; +use registry::schema::enums::AcmeChallengeType; +use rustls::sign::CertifiedKey; +use serde::Deserialize; +use std::{ + fmt::{Debug, Display}, + sync::Arc, + time::Duration, +}; +use store::registry::write::RegistryWriteResult; + +pub type AcmeResult = Result; + +pub enum AcmeError { + Http(reqwest::Error), + HttpStatus(String), + Json(serde_json::Error), + Crypto(String), + Invalid(String), + NotDue(String), + Dns(String), + AuthInvalid(String), + OrderInvalid(String), + ChallengeNotSupported { + requested: ChallengeType, + supported: Vec, + }, + Internal(trc::Error), + Registry(RegistryWriteResult), + OrderTimeout { + max_retries: u32, + }, + AuthTimeout { + max_retries: u32, + }, + Backoff { + max_retries: u32, + wait: Option, + }, +} + +#[derive( + rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, serde::Serialize, Deserialize, +)] +pub struct SerializedCert { + pub certificate: Vec, + pub private_key: Vec, +} + +pub struct PemCert { + pub certificate: String, + pub private_key: String, +} + +pub struct ParsedCert { + pub sans: Vec, + pub issuer: String, + pub valid_not_before: DateTime, + pub valid_not_after: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Directory { + pub new_nonce: String, + pub new_account: String, + pub new_order: String, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)] +pub enum ChallengeType { + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "dns-persist-01")] + DnsPersist01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + #[serde(flatten)] + pub status: OrderStatus, + pub authorizations: Vec, + pub finalize: String, + pub error: Option, +} + +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum OrderStatus { + Pending, + Ready, + Valid { certificate: String }, + Invalid, + Processing, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Auth { + pub status: AuthStatus, + pub identifier: Identifier, + pub challenges: Vec, + pub wildcard: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthStatus { + Pending, + Valid, + Invalid, + Revoked, + Expired, + Deactivated, +} + +pub struct AcmeDnsParameters { + pub updater: DnsUpdater, + pub origin: Option, +} + +#[derive(Clone, Debug, serde::Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub enum Identifier { + Dns(String), +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Challenge { + #[serde(rename = "type")] + pub typ: ChallengeType, + pub url: String, + pub token: Option, + pub error: Option, +} + +#[derive(Clone, Debug, serde::Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + #[serde(rename = "type")] + pub typ: Option, + pub detail: Option, +} + +pub struct StaticResolver { + pub key: Option>, +} + +impl Debug for StaticResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticResolver").finish() + } +} + +impl From for AcmeError { + fn from(err: reqwest::Error) -> Self { + AcmeError::Http(err) + } +} + +impl From for AcmeError { + fn from(err: serde_json::Error) -> Self { + AcmeError::Json(err) + } +} + +impl From for AcmeError { + fn from(err: trc::Error) -> Self { + AcmeError::Internal(err) + } +} + +impl From for ChallengeType { + fn from(value: AcmeChallengeType) -> Self { + match value { + AcmeChallengeType::Http01 => ChallengeType::Http01, + AcmeChallengeType::Dns01 => ChallengeType::Dns01, + AcmeChallengeType::TlsAlpn01 => ChallengeType::TlsAlpn01, + AcmeChallengeType::DnsPersist01 => ChallengeType::DnsPersist01, + } + } +} + +impl Display for AuthStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthStatus::Pending => write!(f, "pending"), + AuthStatus::Valid => write!(f, "valid"), + AuthStatus::Invalid => write!(f, "invalid"), + AuthStatus::Revoked => write!(f, "revoked"), + AuthStatus::Expired => write!(f, "expired"), + AuthStatus::Deactivated => write!(f, "deactivated"), + } + } +} + +impl Auth { + pub fn to_error(&self) -> String { + let mut errors = format!("Status: {}", self.status); + for challenge in &self.challenges { + if let Some(error) = &challenge.error { + errors.push_str(&format!( + "; Challenge type: {}, error: {}", + challenge.typ.as_str(), + error + )); + } + } + + errors + } +} + +impl Identifier { + pub fn hostname(&self) -> &str { + match self { + Identifier::Dns(hostname) => hostname, + } + } +} + +impl Display for Problem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(typ) = &self.typ { + if let Some(detail) = &self.detail { + write!(f, "{}: {}", typ, detail) + } else { + write!(f, "{}", typ) + } + } else if let Some(detail) = &self.detail { + write!(f, "{}", detail) + } else { + write!(f, "Unknown error") + } + } +} + +impl Display for AcmeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AcmeError::Http(err) => write!(f, "HTTP error: {}", err), + AcmeError::HttpStatus(status) => write!(f, "HTTP error: {}", status), + AcmeError::Json(err) => write!(f, "JSON error: {}", err), + AcmeError::Dns(err) => write!(f, "DNS error: {}", err), + AcmeError::Crypto(err) => write!(f, "Cryptographic error: {}", err), + AcmeError::Invalid(err) => write!(f, "Invalid request: {}", err), + AcmeError::NotDue(err) => write!(f, "{}", err), + AcmeError::AuthInvalid(status) => write!(f, "Authentication failed: {:?}", status), + AcmeError::OrderTimeout { .. } => write!(f, "Order processing timed out"), + AcmeError::OrderInvalid(reason) => write!(f, "Order is invalid: {}", reason), + AcmeError::AuthTimeout { .. } => write!(f, "Authentication timed out"), + AcmeError::ChallengeNotSupported { + requested, + supported, + } => { + write!( + f, + "Challenge type {:?} not supported. Supported types: {:?}", + requested, supported + ) + } + AcmeError::Internal(err) => write!(f, "Internal error: {}", err), + AcmeError::Registry(err) => write!(f, "Registry error: {:?}", err), + AcmeError::Backoff { wait, .. } => { + if let Some(time) = wait { + write!(f, "Rate limited. Retry after {} seconds", time.as_secs()) + } else { + write!(f, "Rate limited. Retry after some time") + } + } + } + } +} diff --git a/crates/common/src/network/acme/order.rs b/crates/common/src/network/acme/order.rs new file mode 100644 index 0000000..41f47c8 --- /dev/null +++ b/crates/common/src/network/acme/order.rs @@ -0,0 +1,567 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use crate::network::acme::directory::AcmeRequestBuilder; +use crate::network::acme::{ + AcmeDnsParameters, AcmeError, AcmeResult, AuthStatus, ChallengeType, Identifier, OrderStatus, + ParsedCert, PemCert, +}; +use crate::{KV_ACME, Server}; +use chrono::{TimeZone, Utc}; +use dns_update::DnsRecord; +use futures::future::try_join_all; +use rcgen::{CertificateParams, DistinguishedName, KeyPair, PKCS_ECDSA_P256_SHA256}; +use std::collections::BTreeSet; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::Duration; +use store::dispatch::lookup::KeyValue; +use trc::AcmeEvent; +use x509_parser::parse_x509_certificate; +use x509_parser::prelude::{GeneralName, ParsedExtension}; + +const HOSTNAMES: &[&str] = &["mta-sts", "ua-auto-config", "autoconfig", "autodiscover"]; + +impl AcmeRequestBuilder { + pub fn build_domains( + &self, + server: &Server, + domain: &str, + hostnames: &[String], + ) -> Vec { + if hostnames.is_empty() { + if matches!( + self.challenge, + ChallengeType::Dns01 | ChallengeType::DnsPersist01 + ) { + vec![format!("*.{domain}"), domain.to_string()] + } else { + let server_name = server.core.network.server_name.as_str(); + let domain_suffix = format!(".{domain}"); + let matches_zone = |name: &str| name == domain || name.ends_with(&domain_suffix); + + // Add technical domains + let mut domains = HOSTNAMES + .iter() + .map(|hostname| format!("{hostname}.{domain}")) + .collect::>(); + + // Add server name if it matches the domain (including the apex itself) + if matches_zone(server_name) { + domains.insert(server_name.to_string()); + } + + // Add mail exchangers + for exchanger in &server.core.network.info.mxs { + if let Some(exchanger) = &exchanger.hostname + && matches_zone(exchanger) + { + domains.insert(exchanger.to_string()); + } + } + + // Add service hosts + for (_, service) in &server.core.network.info.services { + if let Some(service) = &service.hostname + && matches_zone(service) + { + domains.insert(service.to_string()); + } + } + + domains.into_iter().collect() + } + } else { + hostnames + .iter() + .map(|h| { + if h.contains('.') { + h.clone() + } else { + format!("{h}.{domain}") + } + }) + .collect() + } + } + + pub async fn renew( + &self, + server: &Server, + domains: Vec, + reuse_key_pem: Option, + dns_parameters: Option, + ) -> AcmeResult { + let mut params = CertificateParams::new(domains.clone()).map_err(|err| { + AcmeError::Crypto(format!("Failed to create certificate params: {}", err)) + })?; + params.distinguished_name = DistinguishedName::new(); + let key_pair = match reuse_key_pem { + Some(pem) => KeyPair::from_pem(&pem).map_err(|err| { + AcmeError::Crypto(format!("Failed to load private key for reuse: {}", err)) + })?, + None => KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(|err| { + AcmeError::Crypto(format!("Failed to generate key pair: {}", err)) + })?, + }; + let response = self.new_order(domains.clone()).await?; + let order_url = response.location; + let mut order = response.body; + let mut retry_after = None; + + trc::event!( + Acme(AcmeEvent::OrderStart), + Url = self.directory.new_order.to_string(), + Details = order_url.to_string(), + Hostname = domains.as_slice(), + Type = self.challenge.as_str(), + ); + + loop { + match order.status { + OrderStatus::Pending => { + if matches!(self.challenge, ChallengeType::Dns01) { + for url in &order.authorizations { + self.authorize(server, url, dns_parameters.as_ref()).await?; + } + } else { + let auth_futures = order + .authorizations + .iter() + .map(|url| self.authorize(server, url, dns_parameters.as_ref())); + try_join_all(auth_futures).await?; + } + trc::event!( + Acme(AcmeEvent::AuthCompleted), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), + ); + let response = self.order(&order_url).await?; + order = response.body; + retry_after = response.retry_after; + } + OrderStatus::Processing => { + for i in 0u64..10 { + trc::event!( + Acme(AcmeEvent::OrderProcessing), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), + Total = i, + ); + + tokio::time::sleep( + retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i)), + ) + .await; + let response = self + .order(&order_url) + .await? + .assert_reasonable_retry_after(self.max_retries)?; + order = response.body; + retry_after = response.retry_after; + if order.status != OrderStatus::Processing { + break; + } + } + if order.status == OrderStatus::Processing { + return Err(AcmeError::OrderTimeout { + max_retries: self.max_retries, + }); + } + } + OrderStatus::Ready => { + trc::event!( + Acme(AcmeEvent::OrderReady), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), + ); + + let csr = params.serialize_request(&key_pair).map_err(|err| { + AcmeError::Crypto(format!("Failed to serialize CSR: {}", err)) + })?; + let csr = csr.der().to_vec(); + order = self.finalize(order.finalize, csr).await?.body; + } + OrderStatus::Valid { certificate } => { + trc::event!( + Acme(AcmeEvent::OrderValid), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), + ); + + let certificate = self.select_certificate(&domains, certificate).await?; + + return Ok(PemCert { + certificate, + private_key: key_pair.serialize_pem(), + }); + } + OrderStatus::Invalid => { + let reason = if let Some(reason) = order.error { + reason.to_string() + } else { + "Unknown reason".to_string() + }; + + trc::event!( + Acme(AcmeEvent::OrderInvalid), + Url = self.directory.new_order.to_string(), + Details = order_url.to_string(), + Hostname = domains.as_slice(), + Reason = reason.clone(), + ); + + return Err(AcmeError::OrderInvalid(reason)); + } + } + } + } + + async fn authorize( + &self, + server: &Server, + url: &String, + dns_parameters: Option<&AcmeDnsParameters>, + ) -> AcmeResult<()> { + let response = self + .auth(url) + .await? + .assert_reasonable_retry_after(self.max_retries)?; + let mut retry_after = response.retry_after; + let auth = response.body; + + let (domain, challenge_url) = match auth.status { + AuthStatus::Pending => { + let Identifier::Dns(domain) = auth.identifier; + + trc::event!( + Acme(AcmeEvent::AuthStart), + Hostname = domain.to_string(), + Type = self.challenge.as_str(), + Url = self.directory.new_order.to_string(), + ); + + let challenge = auth + .challenges + .iter() + .find(|c| c.typ == self.challenge) + .ok_or(AcmeError::ChallengeNotSupported { + requested: self.challenge, + supported: auth.challenges.clone(), + })?; + + match &self.challenge { + ChallengeType::TlsAlpn01 => { + server + .in_memory_store() + .key_set( + KeyValue::with_prefix( + KV_ACME, + &domain, + self.tls_alpn_key(challenge, domain.clone())?, + ) + .expires(3600), + ) + .await?; + } + ChallengeType::Http01 => { + server + .in_memory_store() + .key_set( + KeyValue::with_prefix( + KV_ACME, + challenge.token.as_deref().ok_or_else(|| { + AcmeError::Invalid( + "Missing http-01 challenge token in response" + .to_string(), + ) + })?, + self.http_proof(challenge)?, + ) + .expires(3600), + ) + .await?; + } + ChallengeType::Dns01 => { + let dns_parameters = dns_parameters.unwrap(); + let domain = domain.strip_prefix("*.").unwrap_or(&domain); + + let zone = dns_parameters + .origin + .as_deref() + .or_else(|| psl::domain_str(domain)) + .unwrap_or(domain); + + let proof = self.dns_proof(challenge)?; + let challenge_name = format!("_acme-challenge.{}", domain); + dns_parameters + .updater + .set_rrset( + zone, + &challenge_name, + dns_update::DnsRecordType::TXT, + vec![DnsRecord::TXT(proof.clone())], + ) + .await + .map_err(AcmeError::Dns)?; + dns_parameters + .updater + .wait_for_txt_propagation(&challenge_name, zone, &proof) + .await; + } + ChallengeType::DnsPersist01 => {} + ChallengeType::Unknown => unreachable!(), + } + + self.challenge(&challenge.url).await?; + (domain, challenge.url.clone()) + } + AuthStatus::Valid => return Ok(()), + _ => { + trc::event!( + Acme(AcmeEvent::AuthError), + Hostname = auth.identifier.hostname().to_string(), + Type = self.challenge.as_str(), + Url = self.directory.new_order.to_string(), + Details = url.to_string(), + Reason = auth.to_error(), + ); + + return Err(AcmeError::AuthInvalid(auth.to_error())); + } + }; + + for i in 0u64..5 { + tokio::time::sleep(retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i))).await; + let response = self + .auth(url) + .await? + .assert_reasonable_retry_after(self.max_retries)?; + retry_after = response.retry_after; + + match response.body.status { + AuthStatus::Pending => { + trc::event!( + Acme(AcmeEvent::AuthPending), + Hostname = domain.to_string(), + Url = self.directory.new_order.to_string(), + Total = i, + ); + + self.challenge(&challenge_url).await? + } + AuthStatus::Valid => { + trc::event!( + Acme(AcmeEvent::AuthValid), + Hostname = domain.to_string(), + Url = self.directory.new_order.to_string(), + ); + + return Ok(()); + } + _ => { + trc::event!( + Acme(AcmeEvent::AuthError), + Hostname = domain.to_string(), + Type = self.challenge.as_str(), + Url = self.directory.new_order.to_string(), + Details = url.to_string(), + Reason = response.body.to_error(), + ); + + return Err(AcmeError::AuthInvalid(response.body.to_error())); + } + } + } + + trc::event!( + Acme(AcmeEvent::AuthTooManyAttempts), + Hostname = domain.to_string(), + Type = self.challenge.as_str(), + Url = self.directory.new_order.to_string(), + Details = url.to_string(), + Total = 5u64, + ); + + Err(AcmeError::AuthTimeout { + max_retries: self.max_retries, + }) + } + + async fn select_certificate(&self, domains: &[String], url: String) -> AcmeResult { + let response = self.certificate(url).await?; + let Some(preferred) = self.preferred_chain.as_deref() else { + return Ok(response.body); + }; + + if chain_matches(&response.body, preferred) { + return Ok(response.body); + } + + for alternate in &response.alternates { + match self.certificate(alternate).await { + Ok(alternate) if chain_matches(&alternate.body, preferred) => { + return Ok(alternate.body); + } + Ok(_) => {} + Err(err) => { + trc::event!( + Acme(AcmeEvent::ProcessCert), + Url = alternate.to_string(), + Hostname = domains, + Reason = err.to_string(), + ); + } + } + } + + trc::event!( + Acme(AcmeEvent::ProcessCert), + Hostname = domains, + Reason = format!( + "Preferred certificate chain '{preferred}' not offered by the CA; using the default chain", + ), + ); + + Ok(response.body) + } +} + +fn chain_matches(pem_chain: &str, preferred: &str) -> bool { + let Ok(blocks) = pem::parse_many(pem_chain) else { + return false; + }; + let Some(top) = blocks.last() else { + return false; + }; + let Ok((_, cert)) = parse_x509_certificate(top.contents()) else { + return false; + }; + cert.issuer() + .iter_common_name() + .filter_map(|cn| cn.as_str().ok()) + .any(|cn| cn == preferred) +} + +impl ParsedCert { + pub fn parse(certificate: impl AsRef<[u8]>) -> AcmeResult { + let der = pem::parse_many(certificate) + .map_err(|err| AcmeError::Crypto(format!("Failed to parse PEM: {}", err)))? + .into_iter() + .next() + .ok_or_else(|| AcmeError::Crypto("No certificates found in PEM".to_string()))?; + Self::parse_der(der.contents()) + } + + pub fn parse_der(der: &[u8]) -> AcmeResult { + parse_x509_certificate(der) + .map_err(|err| AcmeError::Crypto(format!("Failed to parse X.509 certificate: {}", err))) + .and_then(|(_, cert)| { + // Add CNs and SANs to the list of names + let mut names: BTreeSet = BTreeSet::new(); + for name in cert.subject().iter_common_name() { + if let Ok(name) = name.as_str() { + names.insert(name.into()); + } + } + for ext in cert.extensions() { + if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() { + for name in &san.general_names { + let name = match name { + GeneralName::DNSName(name) => (*name).into(), + GeneralName::IPAddress(ip) => match ip.len() { + 4 => Ipv4Addr::from(<[u8; 4]>::try_from(*ip).unwrap()) + .to_string(), + 16 => Ipv6Addr::from(<[u8; 16]>::try_from(*ip).unwrap()) + .to_string(), + _ => continue, + }, + _ => { + continue; + } + }; + names.insert(name); + } + } + } + + Ok(ParsedCert { + sans: names.into_iter().collect(), + issuer: cert.tbs_certificate.issuer().to_string(), + valid_not_before: Utc + .timestamp_opt(cert.tbs_certificate.validity().not_before.timestamp(), 0) + .single() + .ok_or_else(|| { + AcmeError::Crypto( + "Certificate not_before time is out of range".to_string(), + ) + })?, + valid_not_after: Utc + .timestamp_opt(cert.tbs_certificate.validity().not_after.timestamp(), 0) + .single() + .ok_or_else(|| { + AcmeError::Crypto( + "Certificate not_after time is out of range".to_string(), + ) + })?, + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::chain_matches; + use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ECDSA_P256_SHA256}; + + fn self_signed_pem(common_name: &str) -> String { + let mut params = CertificateParams::new(vec!["host.example".to_string()]).unwrap(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, common_name); + params.distinguished_name = dn; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + params.self_signed(&key_pair).unwrap().pem() + } + + #[test] + fn matches_top_certificate_issuer() { + let chain = self_signed_pem("ISRG Root X1"); + assert!(chain_matches(&chain, "ISRG Root X1")); + } + + #[test] + fn match_is_case_sensitive() { + let chain = self_signed_pem("ISRG Root X1"); + assert!(!chain_matches(&chain, "isrg root x1")); + } + + #[test] + fn match_is_exact_not_substring() { + let chain = self_signed_pem("ISRG Root X10"); + assert!(!chain_matches(&chain, "ISRG Root X1")); + } + + #[test] + fn does_not_match_unrelated_issuer() { + let chain = self_signed_pem("ISRG Root X2"); + assert!(!chain_matches(&chain, "ISRG Root X1")); + } + + #[test] + fn uses_topmost_certificate_not_leaf() { + let leaf = self_signed_pem("Leaf Issuer"); + let top = self_signed_pem("ISRG Root X1"); + let chain = format!("{leaf}{top}"); + assert!(chain_matches(&chain, "ISRG Root X1")); + assert!(!chain_matches(&chain, "Leaf Issuer")); + } + + #[test] + fn rejects_unparseable_chain() { + assert!(!chain_matches("not a pem", "ISRG Root X1")); + } +} diff --git a/crates/common/src/network/acme/renew.rs b/crates/common/src/network/acme/renew.rs new file mode 100644 index 0000000..a829456 --- /dev/null +++ b/crates/common/src/network/acme/renew.rs @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + ipc::{BroadcastEvent, RegistryChange}, + network::acme::{ + AcmeDnsParameters, AcmeError, AcmeResult, ParsedCert, directory::AcmeRequestBuilder, + }, +}; +use registry::{ + schema::{ + enums::{AcmeChallengeType, AcmeRenewBefore, DnsRecordType}, + prelude::{ObjectType, Property}, + structs::{ + AcmeProvider, Certificate, CertificateManagement, DnsManagement, Domain, PublicText, + PublicTextValue, SecretText, SecretTextValue, SystemSettings, Task, TaskDnsManagement, + TaskDomainManagement, TaskStatus, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId, map::Map}, +}; +use store::{ + registry::{ + RegistryQuery, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::now, +}; +use types::id::Id; + +impl Server { + pub async fn acme_renew(&self, domain_id: Id) -> AcmeResult> { + let Some(domain) = self.registry().object::(domain_id).await? else { + return Err(AcmeError::Invalid(format!( + "Domain with ID {} not found", + domain_id + ))); + }; + let cert = match domain.certificate_management { + CertificateManagement::Manual => { + return Err(AcmeError::Invalid( + "ACME not configured for domain".to_string(), + )); + } + CertificateManagement::Automatic(props) => props, + }; + let Some(acme_provider) = self + .registry() + .object::(cert.acme_provider_id) + .await? + else { + return Err(AcmeError::Invalid(format!( + "ACME provider with ID {} not found", + cert.acme_provider_id + ))); + }; + let challenge_type = acme_provider.challenge_type; + let renew_before = acme_provider.renew_before; + let reuse_key = acme_provider.reuse_key; + let request = AcmeRequestBuilder::new(acme_provider).await?; + let domains = request.build_domains( + self, + &domain.name, + &cert.subject_alternative_names.into_inner(), + ); + + if let Some(renew_at) = self + .acme_certificate_renewal_due(&domains, renew_before, now()) + .await? + { + return Err(AcmeError::NotDue(format!( + "Certificate for domain {} is still valid; renewal is not due until {}", + domain.name, + UTCDateTime::from_timestamp(renew_at as i64) + ))); + } + + let dns_parameters = match &domain.dns_management { + DnsManagement::Automatic(props) if challenge_type == AcmeChallengeType::Dns01 => { + match self.build_dns_updater(props.dns_server_id).await? { + Ok(updater) => Some(AcmeDnsParameters { + updater, + origin: props.origin.clone(), + }), + Err(err) => { + return Err(AcmeError::Invalid(format!( + "Failed to build DNS updater: {}", + err + ))); + } + } + } + _ => None, + }; + if challenge_type == AcmeChallengeType::Dns01 && dns_parameters.is_none() { + return Err(AcmeError::Invalid( + "ACME provider requires DNS challenge but a DNS provider was not configured" + .to_string(), + )); + } + let reuse_key_pem = if reuse_key { + match self.acme_certificate_by_domains(&domains).await? { + Some(certificate) => certificate + .private_key + .secret() + .await + .map(std::borrow::Cow::into_owned) + .map_err(|err| { + AcmeError::Crypto(format!("Failed to load certificate private key: {err}")) + })? + .into(), + None => None, + } + } else { + None + }; + let pem_cert = request + .renew(self, domains, reuse_key_pem, dns_parameters) + .await?; + let parsed_cert = ParsedCert::parse(&pem_cert.certificate)?; + let mut new_sans = parsed_cert.sans.clone(); + new_sans.sort(); + let certificate = Certificate { + private_key: SecretText::Text(SecretTextValue { + secret: pem_cert.private_key, + }), + certificate: PublicText::Text(PublicTextValue { + value: pem_cert.certificate, + }), + issuer: parsed_cert.issuer, + not_valid_after: UTCDateTime::from_timestamp(parsed_cert.valid_not_after.timestamp()), + not_valid_before: UTCDateTime::from_timestamp(parsed_cert.valid_not_before.timestamp()), + subject_alternative_names: Map::new(parsed_cert.sans), + }; + let now = now(); + let expires_in = (parsed_cert.valid_not_after.timestamp() as u64).saturating_sub(now); + if expires_in < 3600 { + return Err(AcmeError::Invalid(format!( + "Certificate expires in {} seconds, expected at least 3600 seconds", + expires_in + ))); + } + + match self + .registry() + .write(RegistryWrite::insert(&certificate.into())) + .await? + { + RegistryWriteResult::Success(id) => { + // Repoint the default certificate to the renewed object when it + // tracks the same SAN set, so its id does not go stale + if let Some(old) = self + .registry() + .get(ObjectType::SystemSettings.singleton()) + .await? + { + let mut settings = SystemSettings::from(old.clone()); + if let Some(default_id) = settings.default_certificate_id + && let Some(default_cert) = + self.registry().object::(default_id).await? + { + let mut default_sans = + default_cert.subject_alternative_names.clone().into_inner(); + default_sans.sort(); + if default_sans == new_sans { + settings.default_certificate_id = Some(id); + if let Err(err) = self + .registry() + .write(RegistryWrite::update( + Id::singleton(), + &settings.into(), + &old, + )) + .await + { + trc::error!( + err.details( + "Failed to update default certificate after ACME renewal." + ) + .caused_by(trc::location!()) + ); + } + } + } + } + + // Reload registry + let change = RegistryChange::Insert(ObjectId::new(ObjectType::Certificate, id)); + Box::pin(self.reload_registry(change)).await?; + self.cluster_broadcast(BroadcastEvent::RegistryChange(change)) + .await; + + let mut tasks = Vec::new(); + let renew_at = Self::acme_renewal_due_at( + parsed_cert.valid_not_before.timestamp(), + parsed_cert.valid_not_after.timestamp(), + renew_before, + ); + + trc::event!( + Acme(trc::AcmeEvent::OrderCompleted), + Domain = domain.name.clone(), + Hostname = new_sans.as_slice(), + Id = id.to_string(), + ValidFrom = + trc::Value::Timestamp(parsed_cert.valid_not_before.timestamp() as u64), + ValidTo = trc::Value::Timestamp(parsed_cert.valid_not_after.timestamp() as u64), + NextRetry = trc::Value::Timestamp(renew_at as u64), + ); + + tasks.push(Task::AcmeRenewal(TaskDomainManagement { + domain_id, + status: TaskStatus::at(renew_at), + })); + + // Update TLSA records + if let DnsManagement::Automatic(props) = &domain.dns_management + && props.publish_records.contains(&DnsRecordType::Tlsa) + { + tasks.push(Task::DnsManagement(TaskDnsManagement { + domain_id, + on_success_renew_certificate: false, + status: TaskStatus::now(), + update_records: Map::new(vec![DnsRecordType::Tlsa]), + })); + } + + Ok(tasks) + } + err => Err(AcmeError::Registry(err)), + } + } + + async fn acme_certificate_by_domains( + &self, + domains: &[String], + ) -> AcmeResult> { + let mut wanted = domains.iter().collect::>(); + wanted.sort(); + let Some(reference) = wanted.first() else { + return Ok(None); + }; + + let candidate_ids = self + .registry() + .query::>( + RegistryQuery::new(ObjectType::Certificate) + .text(Property::SubjectAlternativeNames, reference.as_str()), + ) + .await?; + + for id in candidate_ids { + let Some(certificate) = self.registry().object::(id).await? else { + continue; + }; + let mut sans = certificate + .subject_alternative_names + .iter() + .collect::>(); + sans.sort(); + if sans == wanted { + return Ok(Some(certificate)); + } + } + + Ok(None) + } + + async fn acme_certificate_renewal_due( + &self, + domains: &[String], + renew_before: AcmeRenewBefore, + now: u64, + ) -> AcmeResult> { + let now = now as i64; + let Some(certificate) = self.acme_certificate_by_domains(domains).await? else { + return Ok(None); + }; + + let not_valid_after = certificate.not_valid_after.timestamp(); + if not_valid_after <= now { + return Ok(None); + } + let not_valid_before = certificate.not_valid_before.timestamp(); + let renew_at = Self::acme_renewal_due_at(not_valid_before, not_valid_after, renew_before); + Ok(if now < renew_at { + Some(renew_at as u64) + } else { + None + }) + } + + fn acme_renewal_due_at( + not_valid_before: i64, + not_valid_after: i64, + renew_before: AcmeRenewBefore, + ) -> i64 { + let total = not_valid_after.saturating_sub(not_valid_before); + let (numerator, denominator) = match renew_before { + AcmeRenewBefore::R12 => (1, 2), + AcmeRenewBefore::R23 => (2, 3), + AcmeRenewBefore::R34 => (3, 4), + AcmeRenewBefore::R45 => (4, 5), + }; + not_valid_before + total * numerator / denominator + } +} diff --git a/crates/common/src/network/acme/resolver.rs b/crates/common/src/network/acme/resolver.rs new file mode 100644 index 0000000..4ff9bbb --- /dev/null +++ b/crates/common/src/network/acme/resolver.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + KV_ACME, Server, + network::acme::{SerializedCert, StaticResolver, directory::ACME_TLS_ALPN_NAME}, +}; +use rustls::{ + ServerConfig, + crypto::aws_lc_rs::sign::any_ecdsa_type, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use std::sync::Arc; +use store::{ + dispatch::lookup::KeyValue, + write::{AlignedBytes, Archive}, +}; +use trc::AcmeEvent; + +impl Server { + pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option> { + match self + .in_memory_store() + .key_get::>(KeyValue::<()>::build_key(KV_ACME, domain)) + .await + { + Ok(Some(cert_)) => match cert_.unarchive::() { + Ok(cert) => { + match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + cert.private_key.as_ref(), + ))) { + Ok(key) => Some(Arc::new(CertifiedKey::new( + vec![CertificateDer::from(cert.certificate.to_vec())], + key, + ))), + Err(err) => { + trc::event!( + Acme(AcmeEvent::Error), + Domain = domain.to_string(), + Reason = err.to_string(), + Details = "Failed to parse private key" + ); + None + } + } + } + + Err(err) => { + trc::event!( + Acme(AcmeEvent::Error), + Domain = domain.to_string(), + CausedBy = err, + Details = "Failed to unarchive certificate" + ); + None + } + }, + Err(err) => { + trc::event!( + Acme(AcmeEvent::Error), + Domain = domain.to_string(), + CausedBy = err + ); + None + } + Ok(None) => { + trc::event!(Acme(AcmeEvent::TokenNotFound), Domain = domain.to_string()); + None + } + } + } + + #[inline(always)] + pub fn has_acme_tls_providers(&self) -> bool { + self.core.network.has_acme_tls_challenge + } + + #[inline(always)] + pub fn has_acme_http_providers(&self) -> bool { + self.core.network.has_acme_http_challenge + } +} + +impl ResolvesServerCert for StaticResolver { + fn resolve(&self, _: ClientHello) -> Option> { + self.key.clone() + } +} + +pub(crate) fn build_acme_static_resolver(key: Option>) -> Arc { + let mut challenge = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(StaticResolver { key })); + challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec()); + Arc::new(challenge) +} + +pub trait IsTlsAlpnChallenge { + fn is_tls_alpn_challenge(&self) -> bool; +} + +impl IsTlsAlpnChallenge for ClientHello<'_> { + fn is_tls_alpn_challenge(&self) -> bool { + self.alpn().into_iter().flatten().eq([ACME_TLS_ALPN_NAME]) + } +} diff --git a/crates/common/src/network/asn.rs b/crates/common/src/network/asn.rs new file mode 100644 index 0000000..abf9141 --- /dev/null +++ b/crates/common/src/network/asn.rs @@ -0,0 +1,400 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + net::IpAddr, + sync::{Arc, atomic::AtomicU64}, + time::{Duration, Instant}, +}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; +use mail_auth::common::resolver::ToReverseName; +use store::write::now; +use tokio::sync::Semaphore; + +use crate::{Server, config::network::AsnGeoLookupConfig, manager::fetch_resource}; + +pub struct AsnGeoLookupData { + pub lock: Semaphore, + expires: AtomicU64, + asn: ArcSwap>>, + country: ArcSwap>>, +} + +#[derive(Clone, Default, Debug)] +pub struct AsnData { + pub id: u32, + pub name: Option, +} + +#[derive(Clone, Default, Debug)] +pub struct AsnGeoLookupResult { + pub asn: Option>, + pub country: Option>, +} + +struct Data { + ip4_ranges: Vec>, + ip6_ranges: Vec>, +} + +pub struct IpRange { + pub start: I, + pub end: I, + pub data: T, +} + +impl Server { + pub async fn lookup_asn_country(&self, ip: IpAddr) -> AsnGeoLookupResult { + let mut result = AsnGeoLookupResult::default(); + + match &self.core.network.asn_geo_lookup { + AsnGeoLookupConfig::Resource { .. } if !ip.is_loopback() => { + let asn_geo = &self.inner.data.asn_geo_data; + + if asn_geo.expires.load(std::sync::atomic::Ordering::Relaxed) <= now() + && asn_geo.lock.available_permits() > 0 + { + self.refresh_asn_geo_tables(); + } + + result.asn = asn_geo.asn.load().lookup(ip).cloned(); + result.country = asn_geo.country.load().lookup(ip).cloned(); + } + AsnGeoLookupConfig::Dns { + zone_ipv4, + zone_ipv6, + separator, + index_asn, + index_asn_name, + index_country, + } if !ip.is_loopback() => { + let zone = if ip.is_ipv4() { zone_ipv4 } else { zone_ipv6 }; + match self + .core + .smtp + .resolvers + .dns + .txt_raw_lookup(format!("{}.{}.", ip.to_reverse_name(), zone)) + .await + .map(String::from_utf8) + { + Ok(Ok(entry)) => { + let mut asn = None; + let mut asn_name = None; + let mut country = None; + + for (idx, part) in entry.split(separator).enumerate() { + let part = part.trim(); + if !part.is_empty() { + if idx == *index_asn { + asn = part.parse::().ok(); + } else if index_asn_name.is_some_and(|i| i == idx) { + asn_name = Some(part.to_string()); + } else if index_country.is_some_and(|i| i == idx) { + country = Some(part.to_string()); + } + } + } + + if let Some(asn) = asn { + result.asn = Some(Arc::new(AsnData { + id: asn, + name: asn_name, + })); + } + + if let Some(country) = country { + result.country = Some(Arc::new(country)); + } + } + Ok(Err(_)) => { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Failed to UTF-8 decode ASN/Geo data", + Hostname = format!("{}.{}.", ip.to_reverse_name(), zone), + ); + } + Err(err) => { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Failed to lookup ASN/Geo data", + Hostname = format!("{}.{}.", ip.to_reverse_name(), zone), + CausedBy = err.to_string() + ); + } + } + } + _ => (), + } + + result + } + + fn refresh_asn_geo_tables(&self) { + let server = self.clone(); + tokio::spawn(async move { + let asn_geo = &server.inner.data.asn_geo_data; + let _permit = asn_geo.lock.acquire().await; + + if asn_geo.expires.load(std::sync::atomic::Ordering::Relaxed) > now() { + return; + } + + if let AsnGeoLookupConfig::Resource { + expires, + timeout, + max_size, + asn_resources, + geo_resources, + headers, + } = &server.core.network.asn_geo_lookup + { + let mut asn_data = Data::new(); + let mut country_data = Data::new(); + + for (is_asn, url) in asn_resources + .iter() + .map(|url| (true, url)) + .chain(geo_resources.iter().map(|url| (false, url))) + { + let time = Instant::now(); + match fetch_resource(url, headers.clone().into(), *timeout, *max_size) + .await + .map(String::from_utf8) + { + Ok(Ok(data)) => { + let mut has_errors = false; + let mut asn_mappings = AHashMap::new(); + let mut geo_mappings = AHashMap::new(); + + let mut from_ip = None; + let mut to_ip = None; + let mut asn = None; + let mut details = None; + + let mut in_quote = false; + let mut col_num = 0; + let mut col_start = 0; + let mut line_start = 0; + + for (idx, ch) in data.char_indices() { + match ch { + '"' => in_quote = !in_quote, + ',' | '\n' if !in_quote => { + let column = + data.get(col_start..idx).unwrap_or_default().trim(); + match col_num { + 0 => from_ip = column.parse::().ok(), + 1 => to_ip = column.parse::().ok(), + 2 if is_asn => asn = column.parse::().ok(), + 2 | 3 => { + let column = column + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(column); + if !column.is_empty() || details.is_none() { + details = Some(column); + } + } + _ => break, + } + + if ch == '\n' { + let is_success = match (from_ip, to_ip, asn, details) { + ( + Some(from_ip), + Some(to_ip), + Some(asn), + asn_name, + ) if is_asn => { + let data = asn_mappings + .entry(asn) + .or_insert_with(|| { + Arc::new(AsnData { + id: asn, + name: asn_name.map(String::from), + }) + }) + .clone(); + asn_data.insert(from_ip, to_ip, data) + } + (Some(from_ip), Some(to_ip), _, Some(code)) + if !is_asn && [2, 3].contains(&code.len()) => + { + let code = code.to_uppercase(); + let data = geo_mappings + .entry(code.clone()) + .or_insert_with(|| Arc::new(code)) + .clone(); + country_data.insert(from_ip, to_ip, data) + } + (None, None, _, _) => true, // Ignore empty rows + _ => false, + }; + + if !is_success && !has_errors { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Invalid ASN/Geo data", + Url = url.clone(), + Details = data + .get(line_start..idx) + .unwrap_or_default() + .to_string(), + ); + has_errors = true; + } + + col_num = 0; + from_ip = None; + to_ip = None; + asn = None; + details = None; + line_start = idx + 1; + } else { + col_num += 1; + } + col_start = idx + 1; + } + _ => {} + } + } + + trc::event!( + Resource(trc::ResourceEvent::DownloadExternal), + Details = "Downloaded ASN/Geo data", + Url = url.clone(), + Elapsed = time.elapsed() + ); + } + Ok(Err(_)) => { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Failed to UTF-8 decode ASN/Geo data", + Url = url.clone(), + ); + } + Err(err) => { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Failed to download ASN/Geo data", + Url = url.clone(), + CausedBy = err + ); + } + } + } + + let expires = if !asn_data.is_empty() || !country_data.is_empty() { + *expires + } else { + Duration::from_secs(60) + }; + + if !asn_data.is_empty() { + asn_geo.asn.store(Arc::new(asn_data.sorted())); + } + if !country_data.is_empty() { + asn_geo.country.store(Arc::new(country_data.sorted())); + } + + asn_geo.expires.store( + now() + expires.as_secs(), + std::sync::atomic::Ordering::Relaxed, + ); + } + }); + } +} + +impl Data { + fn new() -> Self { + Self { + ip4_ranges: Vec::new(), + ip6_ranges: Vec::new(), + } + } + + pub fn lookup(&self, ip: IpAddr) -> Option<&T> { + match ip { + IpAddr::V4(ip) => { + let ip = u32::from(ip); + match self.ip4_ranges.binary_search_by(|range| { + if ip < range.start { + std::cmp::Ordering::Greater + } else if ip > range.end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) { + Ok(idx) => Some(&self.ip4_ranges[idx].data), + Err(_) => None, + } + } + IpAddr::V6(ip) => { + let ip = u128::from(ip); + match self.ip6_ranges.binary_search_by(|range| { + if ip < range.start { + std::cmp::Ordering::Greater + } else if ip > range.end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) { + Ok(idx) => Some(&self.ip6_ranges[idx].data), + Err(_) => None, + } + } + } + } + + pub fn insert(&mut self, from_ip: IpAddr, to_ip: IpAddr, data: T) -> bool { + match (from_ip, to_ip) { + (IpAddr::V4(from), IpAddr::V4(to)) => { + self.ip4_ranges.push(IpRange { + start: u32::from(from), + end: u32::from(to), + data, + }); + true + } + (IpAddr::V6(from), IpAddr::V6(to)) => { + self.ip6_ranges.push(IpRange { + start: u128::from(from), + end: u128::from(to), + data, + }); + true + } + _ => false, + } + } + + pub fn sorted(mut self) -> Self { + self.ip4_ranges.sort_unstable_by_key(|range| range.start); + self.ip6_ranges.sort_unstable_by_key(|range| range.start); + self + } + + pub fn is_empty(&self) -> bool { + self.ip4_ranges.is_empty() && self.ip6_ranges.is_empty() + } +} + +impl Default for AsnGeoLookupData { + fn default() -> Self { + Self { + lock: Semaphore::new(1), + expires: AtomicU64::new(0), + asn: ArcSwap::new(Arc::new(Data::new())), + country: ArcSwap::new(Arc::new(Data::new())), + } + } +} diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs new file mode 100644 index 0000000..c548a48 --- /dev/null +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, manager::application::Resource}; +use quick_xml::Reader; +use quick_xml::XmlVersion; +use quick_xml::events::Event; +use registry::schema::enums::ServiceProtocol; +use std::fmt::Write; + +impl Server { + pub async fn handle_autodiscover_request( + &self, + body: Option>, + ) -> trc::Result>> { + // Obtain parameters + let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default()) + .map_err(|err| { + trc::ResourceEvent::BadParameters + .into_err() + .details("Failed to parse autodiscover request") + .ctx(trc::Key::Reason, err) + })?; + let default_host = &self.core.network.server_name; + + // Build XML response + let mut config = String::with_capacity(1024); + let _ = writeln!(&mut config, ""); + let _ = writeln!( + &mut config, + "" + ); + let _ = writeln!( + &mut config, + "\t" + ); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!( + &mut config, + "\t\t\t{emailaddress}" + ); + let _ = writeln!( + &mut config, + "\t\t\t{emailaddress}" + ); + // DeploymentId is a required field of User but we are not a MS Exchange server so use a random value + let _ = writeln!( + &mut config, + "\t\t\t644560b8-a1ce-429c-8ace-23395843f701" + ); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t\t\temail"); + let _ = writeln!(&mut config, "\t\t\tsettings"); + for (protocol, service) in &self.core.network.info.services { + let (protocol, ports) = match protocol { + ServiceProtocol::Imap => ("IMAP", [143, 993]), + ServiceProtocol::Pop3 => ("POP3", [110, 995]), + ServiceProtocol::Smtp => ("SMTP", [587, 465]), + _ => continue, + }; + + for (is_tls, port) in ports.into_iter().enumerate() { + if is_tls == 1 || service.cleartext { + let server_name = service.hostname.as_deref().unwrap_or(default_host); + let _ = writeln!(&mut config, "\t\t\t"); + let _ = writeln!(&mut config, "\t\t\t\t{protocol}",); + let _ = writeln!(&mut config, "\t\t\t\t{server_name}"); + let _ = writeln!(&mut config, "\t\t\t\t{port}"); + let _ = writeln!(&mut config, "\t\t\t\t{emailaddress}"); + let _ = writeln!(&mut config, "\t\t\t\ton"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!( + &mut config, + "\t\t\t\t{}", + if is_tls == 1 { "on" } else { "off" } + ); + if is_tls == 1 { + let _ = writeln!(&mut config, "\t\t\t\tTLS"); + } + let _ = writeln!(&mut config, "\t\t\t\toff"); + let _ = writeln!(&mut config, "\t\t\t"); + } + } + } + + let _ = writeln!(&mut config, "\t\t"); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, ""); + + Ok(Resource::new( + "application/xml; charset=utf-8", + config.into_bytes(), + )) + } +} + +fn parse_autodiscover_request(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("Empty request body".to_string()); + } + + let mut reader = Reader::from_reader(bytes); + reader.config_mut().trim_text(true); + let mut buf = Vec::with_capacity(128); + + 'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] { + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let found_tag_name = e.name(); + if tag_name + .as_bytes() + .eq_ignore_ascii_case(found_tag_name.as_ref()) + { + continue 'outer; + } else if tag_name == "EMailAddress" { + // Skip unsupported tags under Request, such as AcceptableResponseSchema + let mut tag_count = 0; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::End(_)) => { + if tag_count == 0 { + break; + } else { + tag_count -= 1; + } + } + Ok(Event::Start(_)) => { + tag_count += 1; + } + Ok(Event::Eof) => { + return Err(format!( + "Expected value, found unexpected EOF at position {}.", + reader.buffer_position() + )); + } + _ => (), + } + } + } else { + return Err(format!( + "Expected tag {}, found unexpected tag {} at position {}.", + tag_name, + String::from_utf8_lossy(found_tag_name.as_ref()), + reader.buffer_position() + )); + } + } + Ok(Event::Decl(_) | Event::Text(_)) => (), + Err(e) => { + return Err(format!( + "Error at position {}: {:?}", + reader.buffer_position(), + e + )); + } + Ok(event) => { + return Err(format!( + "Expected tag {}, found unexpected event {event:?} at position {}.", + tag_name, + reader.buffer_position() + )); + } + } + } + } + + if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf) + && let Ok(text) = text.xml_content(XmlVersion::Implicit1_0) + && text.contains('@') + { + return Ok(text.trim().to_lowercase()); + } + + Err(format!( + "Expected email address, found unexpected value at position {}.", + reader.buffer_position() + )) +} + +#[cfg(test)] +mod tests { + + #[test] + fn parse_autodiscover() { + let r = r#" + + + email@example.com + http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a + + "#; + + assert_eq!( + super::parse_autodiscover_request(r.as_bytes()).unwrap(), + "email@example.com" + ); + } +} diff --git a/crates/common/src/network/autoconfig/autodiscover_v2.rs b/crates/common/src/network/autoconfig/autodiscover_v2.rs new file mode 100644 index 0000000..2bc0868 --- /dev/null +++ b/crates/common/src/network/autoconfig/autodiscover_v2.rs @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, manager::application::Resource}; +use utils::url_params::UrlParams; + +impl Server { + pub async fn handle_autodiscover_v2_request( + &self, + query: Option<&str>, + path_email: Option<&str>, + ) -> trc::Result>, String>> { + // Parse query parameters + let params = UrlParams::new(query); + let emailaddress = path_email + .filter(|email| !email.is_empty()) + .or_else(|| params.get("Email")) + .unwrap_or_default() + .to_lowercase(); + let protocol = params.get("Protocol").unwrap_or_default(); + + // Validate email address + let Some((_, domain)) = emailaddress.rsplit_once('@') else { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("Missing domain in email address")); + }; + + if domain.is_empty() { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("Missing domain in email address")); + } + + if protocol.eq_ignore_ascii_case("autodiscoverv1") { + let server_name = &self.core.network.server_name; + let body = format!( + "{{\"Protocol\":\"AutodiscoverV1\",\ + \"Url\":\"https://{server_name}/autodiscover/autodiscover.xml\"}}" + ); + Ok(Ok(Resource::new( + "application/json; charset=utf-8", + body.into_bytes(), + ))) + } else { + let safe_protocol: String = protocol + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect(); + let err = format!( + "{{\"ErrorCode\":\"InvalidProtocol\",\ + \"ErrorMessage\":\"The given protocol value \ + '{safe_protocol}' is invalid. \ + Supported values are 'AutodiscoverV1'\"}}" + ); + Ok(Err(err)) + } + } +} diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs new file mode 100644 index 0000000..2f2e2cb --- /dev/null +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, manager::application::Resource}; +use registry::schema::enums::ServiceProtocol; +use std::fmt::Write; +use utils::url_params::UrlParams; + +impl Server { + pub async fn handle_autoconfig_request( + &self, + uri: Option<&str>, + ) -> trc::Result>> { + // Obtain parameters + let params = UrlParams::new(uri); + let emailaddress_param = params + .get("emailaddress") + .unwrap_or_default() + .to_lowercase(); + let default_host = &self.core.network.server_name; + let (emailaddress, domain) = if let Some((_, domain)) = emailaddress_param.rsplit_once('@') + { + (emailaddress_param.as_str(), domain) + } else { + ("%EMAILADDRESS%", default_host.as_str()) + }; + + // Build XML response + let mut config = String::with_capacity(1024); + config.push_str("\n"); + config.push_str("\n"); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, "\t\t{domain}"); + let _ = writeln!(&mut config, "\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\t{domain}" + ); + for (protocol, service) in &self.core.network.info.services { + let (protocol, tag, ports) = match protocol { + ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), + ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), + ServiceProtocol::Pop3 => ("pop3", "incomingServer", [110, 995]), + _ => continue, + }; + for (is_tls, port) in ports.into_iter().enumerate() { + if is_tls == 1 || service.cleartext { + let server_name = service.hostname.as_deref().unwrap_or(default_host); + let _ = writeln!(&mut config, "\t\t<{tag} type=\"{protocol}\">"); + let _ = writeln!(&mut config, "\t\t\t{server_name}"); + let _ = writeln!(&mut config, "\t\t\t{port}"); + let _ = writeln!( + &mut config, + "\t\t\t{}", + if is_tls == 1 { "SSL" } else { "STARTTLS" } + ); + let _ = writeln!(&mut config, "\t\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\t\tpassword-cleartext" + ); + let _ = writeln!(&mut config, "\t\t"); + } + } + } + + config.push_str("\t\n"); + + for (protocol, service) in &self.core.network.info.services { + let (tag, protocol, url) = match protocol { + ServiceProtocol::Carddav => ("addressBook", "carddav", "card"), + ServiceProtocol::Caldav => ("calendar", "caldav", "cal"), + ServiceProtocol::Webdav => ("fileShare", "webdav", "file"), + _ => continue, + }; + let server_name = service.hostname.as_deref().unwrap_or(default_host); + + let _ = writeln!(&mut config, "\t<{tag} type=\"{protocol}\">"); + let _ = writeln!(&mut config, "\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\thttp-basic" + ); + let _ = writeln!( + &mut config, + "\t\thttps://{server_name}/dav/{url}" + ); + let _ = writeln!(&mut config, "\t"); + } + + let _ = writeln!( + &mut config, + "\t" + ); + config.push_str("\n"); + + Ok(Resource::new( + "application/xml; charset=utf-8", + config.into_bytes(), + )) + } +} diff --git a/crates/common/src/network/autoconfig/mod.rs b/crates/common/src/network/autoconfig/mod.rs new file mode 100644 index 0000000..88d4fe2 --- /dev/null +++ b/crates/common/src/network/autoconfig/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod autodiscover; +pub mod autodiscover_v2; +pub mod legacy_autoconfig; +pub mod pacc; diff --git a/crates/common/src/network/autoconfig/pacc.rs b/crates/common/src/network/autoconfig/pacc.rs new file mode 100644 index 0000000..74b3899 --- /dev/null +++ b/crates/common/src/network/autoconfig/pacc.rs @@ -0,0 +1,358 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::{Deserialize, Serialize}; + +/// Top-level configuration document. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Configuration { + /// Supported protocols and their server endpoints. + pub protocols: Protocols, + + /// Authentication mechanisms the provider supports. + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication: Option, + + /// Informational metadata about the provider. + pub info: Info, +} + +/// The `protocols` object listing available protocol endpoints. +/// +/// HTTP-based protocols (JMAP, CalDAV, CardDAV, WebDAV) use [`HttpServer`]. +/// Text-based protocols (IMAP, POP3, SMTP, ManageSieve) use [`TextServer`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Protocols { + #[serde(skip_serializing_if = "Option::is_none")] + pub jmap: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub imap: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub pop3: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub smtp: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub caldav: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub carddav: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub webdav: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub managesieve: Option, +} + +/// An HTTP-based protocol endpoint (JMAP, CalDAV, CardDAV, WebDAV). +/// +/// The `url` MUST use the `https` scheme and the default port 443. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HttpServer { + /// HTTPS URL of the protocol endpoint. + pub url: String, +} + +/// A text-based protocol endpoint (IMAP, POP3, SMTP, ManageSieve). +/// +/// Connections use TLS on the protocol's default port +/// (993 IMAP, 995 POP3, 465 SMTP). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TextServer { + /// Hostname of the server. + pub host: String, +} + +/// Authentication mechanisms supported by the provider. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Authentication { + /// OAuth Profile for Open Public Clients configuration. + #[serde(rename = "oauth-public", skip_serializing_if = "Option::is_none")] + pub oauth_public: Option, + + /// Whether the provider supports username/password authentication. + pub password: bool, +} + +/// OAuth Profile for Open Public Clients parameters. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OAuthPublic { + /// The authorization server's issuer identifier (RFC 8414). + /// Must be an `https` URL with no query or fragment components. + pub issuer: String, +} + +/// Informational metadata presented to users and developers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Info { + /// Provider identity information (required). + pub provider: Provider, + + /// Help links for users and developers. + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, +} + +/// Provider identity. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Provider { + /// Display name of the provider (≤ 60 characters, SHOULD ≤ 30). + pub name: String, + + /// Short name (≤ 20 characters, SHOULD ≤ 12). + #[serde(rename = "shortName", skip_serializing_if = "Option::is_none")] + pub short_name: Option, + + /// Logo image variants. + #[serde(skip_serializing_if = "Option::is_none")] + pub logo: Option>, +} + +/// A single logo variant. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Logo { + /// URL where the logo can be retrieved. + pub url: String, + + /// Media type of the logo image (e.g. `image/svg+xml`, `image/png`). + #[serde(rename = "content-type")] + pub content_type: String, + + /// Image width in pixels. Omitted for SVG. + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// Image height in pixels. Omitted for SVG. + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +/// Help links for users and developers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Help { + /// URL with user-facing documentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + + /// URL with developer-facing documentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub developer: Option, + + /// Contact URIs (e.g. `mailto:` URLs). NOT for end-user display. + #[serde(skip_serializing_if = "Option::is_none")] + pub contact: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The full example from Section 4.1 of the draft. + const EXAMPLE_JSON: &str = r#"{ + "protocols": { + "jmap": { + "url": "https://jmap.example.com/session" + }, + "imap": { + "host": "imap.example.com" + }, + "pop3": { + "host": "pop3.example.com" + }, + "smtp": { + "host": "smtp.example.com" + }, + "caldav": { + "url": "https://sync.example.com/calendar/" + }, + "carddav": { + "url": "https://sync.example.com/contacts/" + } + }, + "authentication": { + "oauth-public": { + "issuer": "https://auth.example.com/" + }, + "password": true + }, + "info": { + "provider": { + "name": "Example Provider Name", + "shortName": "Example", + "logo": [ + { + "url": "https://www.example.net/logo.svg", + "content-type": "image/svg+xml" + } + ] + }, + "help": { + "documentation": "https://help.example.net/howto/set-up-your-mail-app.html", + "developer": "https://developer.example.net/client-apps/", + "contact": ["mailto:it@team.example.net"] + } + } +}"#; + + #[test] + fn deserialize_full_example() { + let config: Configuration = + serde_json::from_str(EXAMPLE_JSON).expect("failed to deserialize"); + + // Protocols + assert_eq!( + config.protocols.jmap.as_ref().unwrap().url, + "https://jmap.example.com/session" + ); + assert_eq!( + config.protocols.imap.as_ref().unwrap().host, + "imap.example.com" + ); + assert_eq!( + config.protocols.smtp.as_ref().unwrap().host, + "smtp.example.com" + ); + assert_eq!( + config.protocols.pop3.as_ref().unwrap().host, + "pop3.example.com" + ); + assert_eq!( + config.protocols.caldav.as_ref().unwrap().url, + "https://sync.example.com/calendar/" + ); + assert_eq!( + config.protocols.carddav.as_ref().unwrap().url, + "https://sync.example.com/contacts/" + ); + assert!(config.protocols.webdav.is_none()); + assert!(config.protocols.managesieve.is_none()); + + // Authentication + let auth = config.authentication.as_ref().unwrap(); + assert!(auth.password); + assert_eq!( + auth.oauth_public.as_ref().unwrap().issuer, + "https://auth.example.com/" + ); + + // Info + assert_eq!(config.info.provider.name, "Example Provider Name"); + assert_eq!(config.info.provider.short_name.as_deref(), Some("Example")); + + let logos = config.info.provider.logo.as_ref().unwrap(); + assert_eq!(logos.len(), 1); + assert_eq!(logos[0].content_type, "image/svg+xml"); + assert!(logos[0].width.is_none()); + + let help = config.info.help.as_ref().unwrap(); + assert_eq!( + help.documentation.as_deref(), + Some("https://help.example.net/howto/set-up-your-mail-app.html") + ); + assert_eq!( + help.contact.as_ref().unwrap(), + &["mailto:it@team.example.net"] + ); + } + + #[test] + fn roundtrip() { + let config: Configuration = + serde_json::from_str(EXAMPLE_JSON).expect("failed to deserialize"); + let serialized = serde_json::to_string_pretty(&config).expect("failed to serialize"); + let roundtripped: Configuration = + serde_json::from_str(&serialized).expect("failed to re-deserialize"); + assert_eq!(config, roundtripped); + } + + #[test] + fn minimal_config() { + let json = r#"{ + "protocols": {}, + "info": { + "provider": { + "name": "Minimal" + } + } + }"#; + let config: Configuration = serde_json::from_str(json).expect("failed to deserialize"); + assert_eq!(config.info.provider.name, "Minimal"); + assert!(config.authentication.is_none()); + assert!(config.protocols.jmap.is_none()); + } + + #[test] + fn ignores_unknown_properties() { + let json = r#"{ + "protocols": { + "imap": { "host": "imap.example.com" }, + "future-protocol": { "endpoint": "wss://example.com" } + }, + "info": { + "provider": { "name": "Test" } + }, + "futureField": 42 + }"#; + let config: Configuration = serde_json::from_str(json).expect("should ignore unknowns"); + assert_eq!( + config.protocols.imap.as_ref().unwrap().host, + "imap.example.com" + ); + } + + #[test] + fn logo_with_dimensions() { + let json = r#"{ + "protocols": {}, + "info": { + "provider": { + "name": "Test", + "logo": [ + { + "url": "https://example.com/logo.svg", + "content-type": "image/svg+xml" + }, + { + "url": "https://example.com/logo-128.png", + "content-type": "image/png", + "width": 128, + "height": 128 + }, + { + "url": "https://example.com/logo-512.png", + "content-type": "image/png", + "width": 512, + "height": 512 + } + ] + } + } + }"#; + let config: Configuration = serde_json::from_str(json).unwrap(); + let logos = config.info.provider.logo.as_ref().unwrap(); + assert_eq!(logos.len(), 3); + assert!(logos[0].width.is_none()); + assert_eq!(logos[1].width, Some(128)); + assert_eq!(logos[2].height, Some(512)); + } + + #[test] + fn password_only_auth() { + let json = r#"{ + "protocols": { "imap": { "host": "mail.example.com" } }, + "authentication": { "password": true }, + "info": { "provider": { "name": "PW Only" } } + }"#; + let config: Configuration = serde_json::from_str(json).unwrap(); + let auth = config.authentication.unwrap(); + assert!(auth.password); + assert!(auth.oauth_public.is_none()); + } +} diff --git a/crates/common/src/network/dkim.rs b/crates/common/src/network/dkim.rs new file mode 100644 index 0000000..eaa3679 --- /dev/null +++ b/crates/common/src/network/dkim.rs @@ -0,0 +1,317 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::smtp::auth::{rsa_key_parse, simple_pem_parse}; +use chrono::Utc; +use dns_update::{DnsRecord, NamedDnsRecord}; +use mail_auth::common::crypto::Ed25519Key; +use mail_auth::dkim::generate::DkimKeyPair; +use mail_builder::encoders::Base64Encoder; +use pkcs8::Document; +use registry::schema::enums::DkimSignatureType; +use registry::schema::structs::DkimSignature; +use rsa::pkcs1::DecodeRsaPublicKey; +use store::rand::distr::Alphanumeric; +use store::rand::{self, RngExt}; + +pub async fn generate_dkim_private_key( + key_type: DkimSignatureType, +) -> trc::Result> { + let private_key = tokio::task::spawn_blocking(move || match key_type { + DkimSignatureType::Dkim1RsaSha256 | DkimSignatureType::Dkim2RsaSha256 => { + DkimKeyPair::generate_rsa(2048).map(|key| (key, "RSA PRIVATE KEY")) + } + DkimSignatureType::Dkim1Ed25519Sha256 | DkimSignatureType::Dkim2Ed25519Sha256 => { + DkimKeyPair::generate_ed25519().map(|key| (key, "PRIVATE KEY")) + } + }) + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + })?; + + Ok(private_key + .map(|(private_key, pk_type)| { + let mut pem = format!("-----BEGIN {pk_type}-----\n").into_bytes(); + let mut lf_count = 65; + for ch in Base64Encoder::new() + .encode(private_key.private_key()) + .unwrap_or_default() + { + pem.push(ch); + lf_count -= 1; + if lf_count == 0 { + pem.push(b'\n'); + lf_count = 65; + } + } + if lf_count != 65 { + pem.push(b'\n'); + } + pem.extend_from_slice(format!("-----END {pk_type}-----\n").as_bytes()); + + String::from_utf8(pem).unwrap_or_default() + }) + .map_err(|err| err.to_string())) +} + +pub async fn generate_dkim_public_key(key: &DkimSignature) -> trc::Result { + let is_rsa = matches!( + key, + DkimSignature::Dkim1RsaSha256(_) | DkimSignature::Dkim2RsaSha256(_) + ); + let pem = key + .private_key() + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; + + if is_rsa { + rsa_key_parse(pem.as_bytes()) + .and_then(|pk| { + Document::from_pkcs1_der(&pk.public_key()).map_err(|err| { + trc::EventType::Dkim(trc::DkimEvent::BuildError) + .into_err() + .reason(err) + }) + }) + .map(|pk| { + String::from_utf8( + Base64Encoder::new() + .encode(pk.as_bytes()) + .unwrap_or_default(), + ) + .unwrap_or_default() + }) + } else { + simple_pem_parse(&pem) + .ok_or_else(|| { + trc::EventType::Dkim(trc::DkimEvent::BuildError) + .into_err() + .details("Failed to parse private key PEM") + }) + .and_then(|der| { + Ed25519Key::from_pkcs8_maybe_unchecked_der(&der).map_err(|err| { + trc::EventType::Dkim(trc::DkimEvent::BuildError) + .into_err() + .reason(err) + }) + }) + .map(|pk| { + String::from_utf8( + Base64Encoder::new() + .encode(&pk.public_key()) + .unwrap_or_default(), + ) + .unwrap_or_default() + }) + } +} + +pub async fn generate_dkim_dns_record( + key: &DkimSignature, + domain: &str, +) -> trc::Result { + let public_key = generate_dkim_public_key(key).await?; + + let (selector, record) = match key { + DkimSignature::Dkim1Ed25519Sha256(sign) => ( + &sign.selector, + format!("v=DKIM1; k=ed25519; h=sha256; p={public_key}"), + ), + DkimSignature::Dkim2Ed25519Sha256(sign) => ( + &sign.selector, + format!("v=DKIM1; k=ed25519; h=sha256; p={public_key}"), + ), + DkimSignature::Dkim1RsaSha256(sign) => ( + &sign.selector, + format!("v=DKIM1; k=rsa; h=sha256; p={public_key}"), + ), + DkimSignature::Dkim2RsaSha256(sign) => ( + &sign.selector, + format!("v=DKIM1; k=rsa; h=sha256; p={public_key}"), + ), + }; + + Ok(NamedDnsRecord { + name: format!("{selector}._domainkey.{domain}."), + record: DnsRecord::TXT(record), + }) +} + +pub fn generate_dkim_dns_record_name(key: &DkimSignature, domain: &str) -> String { + format!("{}._domainkey.{domain}.", key.selector()) +} + +/// Generate a DKIM selector from a template string. +/// +/// Supported variables: +/// - `{algorithm}`: signing algorithm in lowercase (`rsa`, `ed25519`) +/// - `{hash}`: hash algorithm (`sha256`) +/// - `{version}`: DKIM version number (`1`) +/// - `{date-}`: current UTC date formatted with chrono strftime (e.g. `{date-%Y%m%d}`) +/// - `{epoch}`: current UTC unix timestamp +/// - `{random}`: random 8-character alphanumeric string +/// +pub fn generate_dkim_selector( + template: &str, + sig_type: DkimSignatureType, +) -> Result { + let now = Utc::now(); + let mut result = Vec::with_capacity(template.len()); + let mut chars = template.as_bytes(); + + while !chars.is_empty() { + // Find next '{' or consume literal text + let Some(open) = memchr(b'{', chars) else { + // No more variables: append remaining literal + // SAFETY: template is valid UTF-8, and we only slice on ASCII boundaries + result.extend( + chars + .iter() + .filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'), + ); + break; + }; + + // Append literal before '{' + if open > 0 { + result.extend( + chars[..open] + .iter() + .filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'), + ); + } + + // Find matching '}' + let rest = chars.get(open + 1..).unwrap_or_default(); + let Some(close) = memchr(b'}', rest) else { + return Err("unclosed '{' in template".into()); + }; + + let var = + std::str::from_utf8(&rest[..close]).map_err(|_| "invalid UTF-8 in variable name")?; + + match var { + "algorithm" => result.extend_from_slice(sig_type.algorithm().as_bytes()), + "hash" => result.extend_from_slice(sig_type.hash().as_bytes()), + "version" => result.extend_from_slice(sig_type.version().as_bytes()), + "epoch" => { + result.extend_from_slice(now.timestamp().to_string().as_bytes()); + } + "random" => { + let rand_str: String = rand::rng() + .sample_iter(Alphanumeric) + .take(8) + .map(|ch| char::from(ch.to_ascii_lowercase())) + .collect::(); + result.extend(rand_str.as_bytes()); + } + v => { + if let Some(fmt) = v.strip_prefix("date-") { + if fmt.is_empty() { + return Err("empty strftime format in {date-}".into()); + } + let formatted = now.format(fmt).to_string(); + if formatted.is_empty() { + return Err(format!("date format '{fmt}' produced empty output")); + } + result.extend(formatted.as_bytes().iter().filter(|&&c| { + c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_' + })); + } else { + return Err(format!("unrecognized variable '{{{var}}}'")); + } + } + } + + chars = rest.get(close + 1..).unwrap_or_default(); + } + + if !result.is_empty() { + Ok(String::from_utf8(result).unwrap_or_default()) + } else { + Err("Selector cannot be empty".into()) + } +} + +#[inline] +fn memchr(needle: u8, haystack: &[u8]) -> Option { + haystack.iter().position(|&b| b == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_algorithm_date() { + let sel = generate_dkim_selector( + "{algorithm}-{date-%Y%m%d}", + DkimSignatureType::Dkim1RsaSha256, + ) + .unwrap(); + let today = Utc::now().format("%Y%m%d").to_string(); + assert_eq!(sel, format!("rsa-{today}")); + } + + #[test] + fn all_variables() { + let sel = generate_dkim_selector( + "v{version}-{algorithm}-{hash}-{epoch}", + DkimSignatureType::Dkim1Ed25519Sha256, + ) + .unwrap(); + assert!(sel.starts_with("v1-ed25519-sha256-")); + } + + #[test] + fn literal_only() { + let sel = + generate_dkim_selector("my-static-selector", DkimSignatureType::default()).unwrap(); + assert_eq!(sel, "my-static-selector"); + } + + #[test] + fn invalid_chars_stripped() { + let sel = generate_dkim_selector("{algorithm} {hash}", DkimSignatureType::Dkim1RsaSha256) + .unwrap(); + assert_eq!(sel, "rsasha256"); + } + + #[test] + fn unrecognized_variable_errors() { + let err = generate_dkim_selector("{bogus}", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("unrecognized variable")); + } + + #[test] + fn unclosed_brace_errors() { + let err = generate_dkim_selector("{algorithm", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("unclosed")); + } + + #[test] + fn empty_after_sanitization_errors() { + let err = generate_dkim_selector(" ", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("empty")); + } + + #[test] + fn empty_date_format_errors() { + let err = generate_dkim_selector("{date-}", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("empty strftime")); + } + + #[test] + fn date_month_only() { + let sel = generate_dkim_selector("{date-%Y%m}", DkimSignatureType::Dkim1RsaSha256).unwrap(); + let expected = Utc::now().format("%Y%m").to_string(); + assert_eq!(sel, expected); + } +} diff --git a/crates/common/src/network/dns/mod.rs b/crates/common/src/network/dns/mod.rs new file mode 100644 index 0000000..d2670fe --- /dev/null +++ b/crates/common/src/network/dns/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod records; +pub mod resolve; +pub mod update; diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs new file mode 100644 index 0000000..221ae15 --- /dev/null +++ b/crates/common/src/network/dns/records.rs @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record}; +use ahash::{AHashMap, AHashSet}; +use base64::{Engine, engine::general_purpose}; +use dns_update::{ + CAARecord, DnsRecord, KeyValue, MXRecord, NamedDnsRecord, SRVRecord, TLSARecord, TlsaCertUsage, + TlsaMatching, TlsaSelector, bind::BindSerializer, +}; +use registry::schema::{ + enums::{DnsRecordType, ServiceProtocol}, + prelude::{ObjectType, Property}, + structs::{AcmeProvider, CertificateManagement, DkimSignature, DnsManagement, Domain}, +}; +use reqwest::Url; +use sha2::{Digest, Sha256}; +use store::registry::RegistryQuery; +use trc::AddContext; +use types::id::Id; +use x509_parser::parse_x509_certificate; + +impl Server { + pub async fn build_dns_records( + &self, + domain_id: Id, + domain: &Domain, + record_types: &[DnsRecordType], + ) -> trc::Result> { + let mut records = Vec::new(); + let network = &self.core.network; + let default_host = network.server_name.as_str(); + let domain_name = domain.name.as_str(); + let domain_name_suffix = format!(".{domain_name}"); + + for record_type in record_types { + match record_type { + DnsRecordType::Dkim => { + let signature_ids = self + .registry() + .query::>( + RegistryQuery::new(ObjectType::DkimSignature) + .equal(Property::DomainId, domain_id.document_id()), + ) + .await?; + + for id in signature_ids { + let Some(key) = self.registry().object::(id).await? else { + continue; + }; + if !key.is_published() { + continue; + } + records.push(generate_dkim_dns_record(&key, domain_name).await?); + } + } + DnsRecordType::Mx => { + for mx in &network.info.mxs { + records.push(NamedDnsRecord { + name: format!("{domain_name}."), + record: DnsRecord::MX(MXRecord { + exchange: format!( + "{}.", + mx.hostname.as_deref().unwrap_or(default_host) + ), + priority: mx.priority as u16, + }), + }); + } + } + DnsRecordType::Spf => { + let mxs = network + .info + .mxs + .iter() + .map(|mx| mx.hostname.as_deref().unwrap_or(default_host)) + .collect::>(); + + for mx in mxs { + if mx.ends_with(&domain_name_suffix) { + records.push(NamedDnsRecord { + name: format!("{mx}."), + record: DnsRecord::TXT("v=spf1 a -all".to_string()), + }); + } + } + + records.push(NamedDnsRecord { + name: format!("{domain_name}."), + record: DnsRecord::TXT("v=spf1 mx -all".to_string()), + }); + } + DnsRecordType::Dmarc => { + if let Some(uri) = &domain.report_address_uri { + let contents = if uri.starts_with("mailto:") && !uri.contains('@') { + format!("v=DMARC1; p=reject; rua={uri}@{domain_name}",) + } else { + format!("v=DMARC1; p=reject; rua={uri}",) + }; + + records.push(NamedDnsRecord { + name: format!("_dmarc.{domain_name}."), + record: DnsRecord::TXT(contents), + }); + } + } + DnsRecordType::TlsRpt => { + if let Some(uri) = &domain.report_address_uri { + let contents = if uri.starts_with("mailto:") && !uri.contains('@') { + format!("v=TLSRPTv1; rua={uri}@{domain_name}",) + } else { + format!("v=TLSRPTv1; rua={uri}",) + }; + + records.push(NamedDnsRecord { + name: format!("_smtp._tls.{domain_name}."), + record: DnsRecord::TXT(contents), + }); + } + } + DnsRecordType::MtaSts => { + if let Some(policy) = &self.core.smtp.session.mta_sts_policy { + records.push(NamedDnsRecord { + name: format!("mta-sts.{domain_name}."), + record: DnsRecord::CNAME(format!("{default_host}.")), + }); + + records.push(NamedDnsRecord { + name: format!("_mta-sts.{domain_name}."), + record: DnsRecord::TXT(format!("v=STSv1; id={}", policy.id)), + }); + } + } + DnsRecordType::AutoConfig => { + let pacc_digest = Sha256::digest(&self.get_pacc_for_domain(domain_name).await?); + let pacc_digest_encoded = general_purpose::STANDARD.encode(pacc_digest); + + records.push(NamedDnsRecord { + name: format!("ua-auto-config.{domain_name}."), + record: DnsRecord::CNAME(format!("{default_host}.")), + }); + records.push(NamedDnsRecord { + name: format!("_ua-auto-config.{domain_name}."), + record: DnsRecord::TXT(format!( + "v=UAAC1; a=sha256; d={pacc_digest_encoded}" + )), + }); + } + DnsRecordType::AutoConfigLegacy => { + records.push(NamedDnsRecord { + name: format!("autoconfig.{domain_name}."), + record: DnsRecord::CNAME(format!("{default_host}.")), + }); + } + DnsRecordType::AutoDiscover => { + records.push(NamedDnsRecord { + name: format!("autodiscover.{domain_name}."), + record: DnsRecord::CNAME(format!("{default_host}.")), + }); + } + DnsRecordType::Srv => { + for (protocol, service) in &network.info.services { + let target = + format!("{}.", service.hostname.as_deref().unwrap_or(default_host)); + let services = match protocol { + ServiceProtocol::Jmap + | ServiceProtocol::Caldav + | ServiceProtocol::Carddav => { + let name = match protocol { + ServiceProtocol::Jmap => "jmap", + ServiceProtocol::Caldav => "caldavs", + ServiceProtocol::Carddav => "carddavs", + _ => unreachable!(), + }; + + records.push(NamedDnsRecord { + name: format!("_{name}._tcp.{domain_name}."), + record: DnsRecord::SRV(SRVRecord { + target: target.clone(), + priority: 0, + weight: 1, + port: 443, + }), + }); + continue; + } + ServiceProtocol::Webdav | ServiceProtocol::Managesieve => continue, + ServiceProtocol::Imap => [("imap", 143), ("imaps", 993)], + ServiceProtocol::Pop3 => [("pop3", 110), ("pop3s", 995)], + ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], + }; + + for (is_tls, (service_name, port)) in services.into_iter().enumerate() { + if is_tls == 1 || service.cleartext { + records.push(NamedDnsRecord { + name: format!("_{service_name}._tcp.{domain_name}."), + record: DnsRecord::SRV(SRVRecord { + target: target.clone(), + priority: 0, + weight: 1, + port, + }), + }); + } + } + } + } + DnsRecordType::Caa => { + if let CertificateManagement::Automatic(props) = &domain.certificate_management + && let Some(provider) = self + .registry() + .object::(props.acme_provider_id) + .await? + && let Ok(provider_url) = Url::parse(&provider.directory) + && let Some(provider_name) = provider_domain(&provider_url) + { + records.push(NamedDnsRecord { + name: format!("{domain_name}."), + record: DnsRecord::CAA(CAARecord::Issue { + issuer_critical: false, + name: provider_name.to_string().into(), + options: vec![KeyValue { + key: "accounturi".to_string(), + value: provider.account_uri.clone(), + }], + }), + }); + + if let Some(uri) = &domain.report_address_uri + && uri.starts_with("mailto:") + { + let url = if !uri.contains('@') { + format!("{uri}@{domain_name}") + } else { + uri.to_string() + }; + records.push(NamedDnsRecord { + name: format!("{domain_name}."), + record: DnsRecord::CAA(CAARecord::Iodef { + issuer_critical: false, + url, + }), + }); + } + + // ACME DNS-PERSIST-01 validation record + records.push(NamedDnsRecord { + name: format!("_validation-persist.{domain_name}."), + record: DnsRecord::TXT(format!( + "{provider_name}; accounturi={}{}", + provider.account_uri, + if props.subject_alternative_names.is_empty() { + "; policy=wildcard" + } else { + "" + } + )), + }); + } + } + DnsRecordType::Tlsa => { + let mut hostnames: AHashMap> = AHashMap::new(); + + for mx in &network.info.mxs { + let hostname = mx.hostname.as_deref().unwrap_or(default_host); + if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { + hostnames + .entry(hostname.to_string()) + .or_default() + .insert(25); + } + } + + for (protocol, service) in &network.info.services { + let hostname = service.hostname.as_deref().unwrap_or(default_host); + if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { + let port = match protocol { + ServiceProtocol::Imap => 993, + ServiceProtocol::Pop3 => 995, + ServiceProtocol::Smtp => 465, + ServiceProtocol::Jmap + | ServiceProtocol::Caldav + | ServiceProtocol::Carddav + | ServiceProtocol::Webdav => 443, + ServiceProtocol::Managesieve => continue, + }; + hostnames + .entry(hostname.to_string()) + .or_default() + .insert(port); + } + } + + for (record_name, record_type) in [ + ("ua-auto-config", DnsRecordType::AutoConfig), + ("autoconfig", DnsRecordType::AutoConfigLegacy), + ("autodiscover", DnsRecordType::AutoDiscover), + ("mta-sts", DnsRecordType::MtaSts), + ] { + if matches!(&domain.dns_management, DnsManagement::Automatic(props) if props.publish_records.contains(&record_type)) + || matches!(domain.dns_management, DnsManagement::Manual) + { + hostnames + .entry(format!("{record_name}.{domain_name}")) + .or_default() + .insert(443); + } + } + + for (hostname, ports) in hostnames { + if let Some(key) = self.resolve_certificate(&hostname) { + for (cert_num, cert) in key.cert.iter().enumerate() { + let parsed_cert = match parse_x509_certificate(cert) { + Ok((_, parsed_cert)) => parsed_cert, + Err(err) => { + trc::error!( + trc::StoreEvent::UnexpectedError + .into_err() + .reason(err) + .caused_by(trc::location!()) + ); + continue; + } + }; + + let cert_usage = if cert_num == 0 { + TlsaCertUsage::DaneEe + } else { + TlsaCertUsage::DaneTa + }; + let cert_data = sha2::Sha256::digest(parsed_cert.subject_pki.raw); + + for port in &ports { + records.push(NamedDnsRecord { + name: format!("_{port}._tcp.{hostname}."), + record: DnsRecord::TLSA(TLSARecord { + cert_usage, + selector: TlsaSelector::Spki, + matching: TlsaMatching::Sha256, + cert_data: cert_data.to_vec(), + }), + }); + } + } + } + } + } + } + } + + Ok(records) + } + + pub async fn build_bind_dns_records( + &self, + domain_id: Id, + domain: &Domain, + ) -> trc::Result { + self.build_dns_records( + domain_id, + domain, + &[ + DnsRecordType::Dkim, + DnsRecordType::Tlsa, + DnsRecordType::Spf, + DnsRecordType::Mx, + DnsRecordType::Dmarc, + DnsRecordType::Srv, + DnsRecordType::MtaSts, + DnsRecordType::TlsRpt, + DnsRecordType::Caa, + DnsRecordType::AutoConfig, + DnsRecordType::AutoConfigLegacy, + DnsRecordType::AutoDiscover, + ], + ) + .await + .map(|records| BindSerializer::serialize(&records)) + } + + pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { + self.get_directory_for_domain(domain_name) + .await + .caused_by(trc::location!()) + .map(|directory| { + directory + .and_then(|directory| { + directory + .oidc_discovery_document() + .map(|doc| self.core.network.info.pacc.build(&doc.url)) + }) + .unwrap_or_else(|| { + self.core + .network + .info + .pacc + .build(&self.core.network.http.url_https) + }) + }) + } +} + +impl Pacc { + pub fn build(&self, endpoint: &str) -> String { + let mut response = + String::with_capacity(self.prefix.len() + self.suffix.len() + endpoint.len()); + response.push_str(&self.prefix); + response.push_str(endpoint); + response.push_str(&self.suffix); + response + } +} + +#[inline(always)] +#[allow(unused)] +fn provider_domain(url: &Url) -> Option<&str> { + #[cfg(feature = "test_mode")] + { + Some("pebble.letsencrypt.org") + } + + #[cfg(not(feature = "test_mode"))] + { + url.host_str().and_then(psl::domain_str) + } +} diff --git a/crates/common/src/network/dns/resolve.rs b/crates/common/src/network/dns/resolve.rs new file mode 100644 index 0000000..4364937 --- /dev/null +++ b/crates/common/src/network/dns/resolve.rs @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Server; +use mail_auth::{Error, IpLookupStrategy}; +use std::net::IpAddr; + +impl Server { + pub async fn dns_exists_mx(&self, entry: &str) -> trc::Result { + match self + .core + .smtp + .resolvers + .dns + .mx_lookup(entry, Some(&self.inner.cache.dns_mx)) + .await + { + Ok(result) => Ok(result.rrset.iter().any(|mx| !mx.exchanges.is_empty())), + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false), + Err(err) => Err(err.into()), + } + } + + pub async fn dns_exists_ip(&self, entry: &str) -> trc::Result { + match self + .core + .smtp + .resolvers + .dns + .ip_lookup( + entry, + IpLookupStrategy::Ipv4thenIpv6, + 10, + Some(&self.inner.cache.dns_ipv4), + Some(&self.inner.cache.dns_ipv6), + ) + .await + { + Ok(result) => Ok(!result.is_empty()), + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false), + Err(err) => Err(err.into()), + } + } + + pub async fn dns_exists_ptr(&self, entry: &str) -> trc::Result { + if let Ok(addr) = entry.parse::() { + match self + .core + .smtp + .resolvers + .dns + .ptr_lookup(addr, Some(&self.inner.cache.dns_ptr)) + .await + { + Ok(result) => Ok(!result.rrset.is_empty()), + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false), + Err(err) => Err(err.into()), + } + } else { + Err(trc::EventType::Resource(trc::ResourceEvent::BadParameters).into_err()) + } + } + + pub async fn dns_exists_ipv4(&self, entry: &str) -> trc::Result { + match self + .core + .smtp + .resolvers + .dns + .ipv4_lookup(entry, Some(&self.inner.cache.dns_ipv4)) + .await + { + Ok(result) => Ok(!result.rrset.is_empty()), + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false), + Err(err) => Err(err.into()), + } + } + + pub async fn dns_exists_ipv6(&self, entry: &str) -> trc::Result { + match self + .core + .smtp + .resolvers + .dns + .ipv6_lookup(entry, Some(&self.inner.cache.dns_ipv6)) + .await + { + Ok(result) => Ok(!result.rrset.is_empty()), + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => Ok(false), + Err(err) => Err(err.into()), + } + } +} diff --git a/crates/common/src/network/dns/update.rs b/crates/common/src/network/dns/update.rs new file mode 100644 index 0000000..394ba85 --- /dev/null +++ b/crates/common/src/network/dns/update.rs @@ -0,0 +1,1331 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Core, Server}; +use base64::{Engine, engine::general_purpose}; +use dns_update::{ + DnsRecord, DnsRecordType, TsigAlgorithm, + providers::{ovh::OvhEndpoint, rfc2136::DnsAddress}, +}; +use registry::schema::{ + enums, + structs::{DnsManagement, DnsServer, Domain}, +}; +use std::{ + net::SocketAddr, + sync::Arc, + time::{Duration, Instant}, +}; +use trc::DnsEvent; +use types::id::Id; + +pub struct DnsUpdater { + pub updater: dns_update::DnsUpdater, + pub polling_interval: Duration, + pub propagation_timeout: Duration, + pub propagation_delay: Option, + pub ttl: Duration, + core: Arc, +} + +#[cfg(feature = "test_mode")] +pub static DNS_RECORDS: std::sync::LazyLock< + Arc>>, +> = std::sync::LazyLock::new(|| Arc::new(std::sync::Mutex::new(Vec::new()))); + +impl DnsUpdater { + pub async fn build(server: DnsServer, core: Arc) -> Result { + match server { + DnsServer::Tsig(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_rfc2136_tsig( + match server.protocol { + enums::IpProtocol::Tcp => DnsAddress::Tcp(SocketAddr::new( + server.host.into_inner(), + server.port as u16, + )), + enums::IpProtocol::Udp => DnsAddress::Udp(SocketAddr::new( + server.host.into_inner(), + server.port as u16, + )), + }, + server.key_name, + general_purpose::STANDARD + .decode(server.key.secret().await?.as_bytes()) + .map_err(|err| format!("Failed to base64 decode TSIG key: {err}"))?, + match server.tsig_algorithm { + enums::TsigAlgorithm::HmacMd5 => TsigAlgorithm::HmacMd5, + enums::TsigAlgorithm::Gss => TsigAlgorithm::Gss, + enums::TsigAlgorithm::HmacSha1 => TsigAlgorithm::HmacSha1, + enums::TsigAlgorithm::HmacSha224 => TsigAlgorithm::HmacSha224, + enums::TsigAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256, + enums::TsigAlgorithm::HmacSha256128 => TsigAlgorithm::HmacSha256_128, + enums::TsigAlgorithm::HmacSha384 => TsigAlgorithm::HmacSha384, + enums::TsigAlgorithm::HmacSha384192 => TsigAlgorithm::HmacSha384_192, + enums::TsigAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512, + enums::TsigAlgorithm::HmacSha512256 => TsigAlgorithm::HmacSha512_256, + }, + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Cloudflare(server) => { + let updater = { + #[cfg(feature = "test_mode")] + match server.secret.secret().await.unwrap().as_ref() { + "test@pebble.org" => dns_update::DnsUpdater::new_pebble( + "http://localhost:8055", + server.timeout.into_inner().into(), + ), + "test@memory.org" => { + dns_update::DnsUpdater::new_in_memory(DNS_RECORDS.clone()) + } + _ => dns_update::DnsUpdater::new_cloudflare( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + } + + #[cfg(not(feature = "test_mode"))] + dns_update::DnsUpdater::new_cloudflare( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))? + }; + + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater, + }) + } + DnsServer::DigitalOcean(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_digitalocean( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::DeSEC(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_desec( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Ovh(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ovh( + server.application_key, + server.application_secret.secret().await?, + server.consumer_key.secret().await?, + match server.ovh_endpoint { + enums::OvhEndpoint::OvhEu => OvhEndpoint::OvhEu, + enums::OvhEndpoint::OvhCa => OvhEndpoint::OvhCa, + enums::OvhEndpoint::KimsufiEu => OvhEndpoint::KimsufiEu, + enums::OvhEndpoint::KimsufiCa => OvhEndpoint::KimsufiCa, + enums::OvhEndpoint::SoyoustartEu => OvhEndpoint::SoyoustartEu, + enums::OvhEndpoint::SoyoustartCa => OvhEndpoint::SoyoustartCa, + }, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Bunny(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_bunny( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Porkbun(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_porkbun( + server.api_key.as_str(), + server.secret_api_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Dnsimple(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_dnsimple( + server.auth_token.secret().await?, + server.account_identifier.as_str(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Spaceship(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_spaceship( + server.api_key.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Route53(server) => { + let secret_access_key = server.secret_access_key.secret().await?.into_owned(); + let session_token = server.session_token.secret().await?.map(|c| c.into_owned()); + let config = dns_update::providers::route53::Route53Config { + access_key_id: server.access_key_id, + secret_access_key, + session_token, + region: Some(server.region), + hosted_zone_id: server.hosted_zone_id, + private_zone_only: Some(server.private_zone_only), + endpoint: None, + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_route53(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::GoogleCloudDns(server) => { + let service_account_json = server.service_account_json.secret().await?.into_owned(); + let config = dns_update::providers::google_cloud_dns::GoogleCloudDnsConfig { + service_account_json, + project_id: server.project_id, + managed_zone: server.managed_zone, + private_zone: server.private_zone, + impersonate_service_account: server.impersonate_service_account, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_google_cloud_dns(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Alidns(server) => { + let secret_key = server.secret_key.secret().await?.into_owned(); + let security_token = server + .security_token + .secret() + .await? + .map(|c| c.into_owned()); + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_alidns( + server.access_key.as_str(), + secret_key.as_str(), + server.region.as_deref(), + security_token.as_deref(), + server.line.as_deref(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::ArvanCloud(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_arvancloud( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Autodns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_autodns( + server.username.as_str(), + server.password.secret().await?, + server.context.map(|v| v as u32), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::AzureDns(server) => { + let client_secret = server.client_secret.secret().await?.into_owned(); + let config = dns_update::providers::azuredns::AzureDnsConfig { + tenant_id: server.tenant_id, + client_id: server.client_id, + client_secret, + subscription_id: server.subscription_id, + resource_group: server.resource_group, + environment: match server.environment { + enums::AzureEnvironment::Public => { + dns_update::providers::azuredns::AzureEnvironment::Public + } + enums::AzureEnvironment::China => { + dns_update::providers::azuredns::AzureEnvironment::China + } + enums::AzureEnvironment::UsGovernment => { + dns_update::providers::azuredns::AzureEnvironment::UsGovernment + } + }, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_azuredns(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::BaiduCloud(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_baiducloud( + server.access_key.as_str(), + server.secret_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::BluecatV2(server) => { + let password = server.password.secret().await?.into_owned(); + let config = dns_update::providers::bluecatv2::BluecatV2Config { + server_url: server.base_url, + username: server.username, + password, + config_name: server.config_name, + view_name: server.view_name, + skip_deploy: server.skip_deploy, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_bluecatv2(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::ClouDns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_cloudns( + server.auth_id.as_deref(), + server.sub_auth_id.as_deref(), + server.password.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Constellix(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_constellix( + server.api_key.as_str(), + server.secret_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Cpanel(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_cpanel( + server.base_url.as_str(), + server.username.as_str(), + server.token.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Ddnss(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ddnss( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::DnsMadeEasy(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_dnsmadeeasy( + server.api_key.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Domeneshop(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_domeneshop( + server.auth_token.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Dreamhost(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_dreamhost( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::DuckDns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_duckdns( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Dynu(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_dynu( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::EasyDns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_easydns( + server.token.as_str(), + server.key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::EdgeDns(server) => { + let client_secret = server.client_secret.secret().await?.into_owned(); + let access_token = server.access_token.secret().await?.into_owned(); + let config = dns_update::providers::edgedns::EdgeDnsConfig { + host: server.host, + client_token: server.client_token, + client_secret, + access_token, + account_switch_key: server.account_switch_key, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_edgedns(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Exoscale(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_exoscale( + server.api_key.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::FreeMyIp(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_freemyip( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::GandiV5(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_gandiv5( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Gcore(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_gcore( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Glesys(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_glesys( + server.api_user.as_str(), + server.api_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Godaddy(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_godaddy( + server.api_key.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Hetzner(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_hetzner( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::HostingDe(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_hostingde( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Hostinger(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_hostinger( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::HuaweiCloud(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_huaweicloud( + server.access_key.as_str(), + server.secret_key.secret().await?, + server.region.as_str(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Hurricane(server) => { + let mut credentials = std::collections::HashMap::new(); + for cred in server.credentials.0.into_values() { + let secret = cred.secret.secret().await?.into_owned(); + credentials.insert(cred.zone, secret); + } + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_hurricane( + credentials, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::IbmCloud(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ibmcloud( + server.username.as_str(), + server.api_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Infoblox(server) => { + let password = server.password.secret().await?.into_owned(); + let config = dns_update::providers::infoblox::InfobloxConfig { + host: server.host, + port: server.port, + username: server.username, + password, + wapi_version: server.wapi_version, + dns_view: server.dns_view, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_infoblox(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Infomaniak(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_infomaniak( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Inwx(server) => { + let password = server.password.secret().await?.into_owned(); + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_inwx( + server.username, + password, + server.sandbox, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Ionos(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ionos( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Ipv64(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ipv64( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Joker(server) => { + let auth = match server.auth { + registry::schema::structs::JokerAuth::ApiKey(api) => { + let key = api.api_key.secret().await?.into_owned(); + dns_update::providers::joker::JokerAuth::api_key(key) + } + registry::schema::structs::JokerAuth::UsernamePassword(creds) => { + let password = creds.password.secret().await?.into_owned(); + dns_update::providers::joker::JokerAuth::username_password( + creds.username, + password, + ) + } + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_joker( + auth, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Lightsail(server) => { + let secret_access_key = server.secret_access_key.secret().await?.into_owned(); + let session_token = server.session_token.secret().await?.map(|c| c.into_owned()); + let config = dns_update::providers::lightsail::LightsailConfig { + access_key_id: server.access_key_id, + secret_access_key, + session_token, + region: server.region, + domain: server.domain, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_lightsail(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Linode(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_linode( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::LuaDns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_luadns( + server.username.as_str(), + server.auth_token.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::MythicBeasts(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_mythicbeasts( + server.username.as_str(), + server.password.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Namecheap(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_namecheap( + server.api_user.as_str(), + server.api_key.secret().await?, + server.client_ip.as_str(), + server.username.as_deref(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::NameDotCom(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_namedotcom( + server.username.as_str(), + server.auth_token.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::NameSilo(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_namesilo( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Netcup(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_netcup( + server.customer_number.as_str(), + server.api_key.as_str(), + server.password.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Netlify(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_netlify( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Nifcloud(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_nifcloud( + server.access_key.as_str(), + server.secret_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Ns1(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ns1( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::OracleCloud(server) => { + let private_key_pem = server.private_key_pem.secret().await?.into_owned(); + let private_key_password = server + .private_key_password + .secret() + .await? + .map(|c| c.into_owned()); + let config = dns_update::providers::oraclecloud::OracleCloudConfig { + tenancy_ocid: server.tenancy_ocid, + user_ocid: server.user_ocid, + fingerprint: server.fingerprint, + private_key_pem, + private_key_password, + region: server.region, + compartment_ocid: server.compartment_ocid, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_oraclecloud(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Plesk(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_plesk( + server.base_url.as_str(), + server.api_key.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Safedns(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_safedns( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Scaleway(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_scaleway( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::TencentCloud(server) => { + let secret_key = server.secret_key.secret().await?.into_owned(); + let session_token = server.session_token.secret().await?.map(|c| c.into_owned()); + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_tencentcloud( + server.secret_id.as_str(), + secret_key.as_str(), + server.region.as_deref(), + session_token.as_deref(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Transip(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_transip( + server.username.as_str(), + server.private_key_pem.secret().await?, + true, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::UltraDns(server) => { + let password = server.password.secret().await?.into_owned(); + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_ultradns( + server.username, + password, + server.endpoint, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Vercel(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_vercel( + server.auth_token.secret().await?, + server.team_id.as_deref(), + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::Volcengine(server) => { + let secret_key = server.secret_key.secret().await?.into_owned(); + let config = dns_update::providers::volcengine::VolcengineConfig { + access_key: server.access_key, + secret_key, + region: server.region, + host: server.host, + scheme: server.scheme, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_volcengine(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Vultr(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_vultr( + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::WebSupport(server) => Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_websupport( + server.api_key.as_str(), + server.secret.secret().await?, + server.timeout.into_inner().into(), + ) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }), + DnsServer::YandexCloud(server) => { + let iam_token_b64 = server.api_key.secret().await?.into_owned(); + let config = dns_update::providers::yandexcloud::YandexCloudConfig { + iam_token_b64, + folder_id: server.folder_id, + request_timeout: Some(server.timeout.into_inner()), + }; + Ok(DnsUpdater { + polling_interval: server.polling_interval.into_inner(), + propagation_timeout: server.propagation_timeout.into_inner(), + propagation_delay: server.propagation_delay.map(|d| d.into_inner()), + ttl: server.ttl.into_inner(), + core, + updater: dns_update::DnsUpdater::new_yandexcloud(config) + .map_err(|err| format!("Failed to build DNS updater: {}", err))?, + }) + } + DnsServer::Deprecated1 => Err("DNS server type no longer supported".to_string()), + } + } + + pub async fn set_rrset( + &self, + origin: &str, + name: &str, + record_type: DnsRecordType, + records: Vec, + ) -> Result<(), String> { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); + + if let Err(err) = self + .updater + .set_rrset( + name, + record_type, + self.ttl.as_secs() as u32, + records, + origin, + ) + .await + { + return Err(format!("Failed to set DNS RRSet: {}", err)); + } + + trc::event!( + Dns(DnsEvent::RecordCreated), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = record_type.as_str(), + Value = record_values, + ); + Ok(()) + } + + pub async fn add_to_rrset( + &self, + origin: &str, + name: &str, + record_type: DnsRecordType, + records: Vec, + ) -> Result<(), String> { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); + + if let Err(err) = self + .updater + .add_to_rrset( + name, + record_type, + self.ttl.as_secs() as u32, + records, + origin, + ) + .await + { + return Err(format!("Failed to add to DNS RRSet: {}", err)); + } + + trc::event!( + Dns(DnsEvent::RecordCreated), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = record_type.as_str(), + Value = record_values, + ); + Ok(()) + } + + pub async fn list_rrset( + &self, + origin: &str, + name: &str, + record_type: DnsRecordType, + ) -> dns_update::Result> { + self.updater.list_rrset(name, record_type, origin).await + } + + pub async fn remove_from_rrset( + &self, + origin: &str, + name: &str, + record_type: DnsRecordType, + records: Vec, + ) -> Result<(), String> { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); + + match self + .updater + .remove_from_rrset(name, record_type, records, origin) + .await + { + Ok(_) => Ok(()), + Err(err) => { + trc::event!( + Dns(DnsEvent::RecordDeletionFailed), + Hostname = name.to_string(), + Reason = err.to_string(), + Details = origin.to_string(), + Type = record_type.as_str(), + Value = record_values, + ); + Err(err.to_string()) + } + } + } + + pub async fn wait_for_txt_propagation(&self, name: &str, origin: &str, expected: &str) -> bool { + #[cfg(feature = "test_mode")] + if matches!( + self.updater, + dns_update::DnsUpdater::Pebble(_) | dns_update::DnsUpdater::InMemory(_) + ) { + return true; + } + + if let Some(initial_wait) = self.propagation_delay { + tokio::time::sleep(initial_wait).await; + } + let wait_until = Instant::now() + self.propagation_timeout; + let mut did_propagate = false; + while Instant::now() < wait_until { + match self.core.smtp.resolvers.dns.txt_raw_lookup(&name).await { + Ok(result) => { + let result = std::str::from_utf8(&result).unwrap_or_default(); + if result.contains(expected) { + did_propagate = true; + break; + } else { + trc::event!( + Dns(DnsEvent::RecordNotPropagated), + Hostname = name.to_string(), + Details = origin.to_string(), + Result = result.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + } + Err(err) => { + trc::event!( + Dns(DnsEvent::RecordLookupFailed), + Hostname = name.to_string(), + Details = origin.to_string(), + Reason = err.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + } + + tokio::time::sleep(self.polling_interval).await; + } + + if did_propagate { + trc::event!( + Dns(DnsEvent::RecordPropagated), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } else { + trc::event!( + Dns(DnsEvent::RecordPropagationTimeout), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + did_propagate + } +} + +impl Server { + pub async fn build_dns_updater_for_domain( + &self, + domain_id: Id, + ) -> trc::Result> { + if let Some(domain) = self.registry().object::(domain_id).await? { + match domain.dns_management { + DnsManagement::Automatic(props) => { + self.build_dns_updater(props.dns_server_id).await + } + DnsManagement::Manual => Ok(Err(format!( + "Domain with ID {} is set to manual DNS management", + domain_id + ))), + } + } else { + Ok(Err(format!("Domain with ID {} not found", domain_id))) + } + } + + pub async fn build_dns_updater( + &self, + dns_server_id: Id, + ) -> trc::Result> { + if let Some(settings) = self.registry().object::(dns_server_id).await? { + Ok(DnsUpdater::build(settings, self.core.clone()).await) + } else { + Ok(Err(format!( + "DNS server with ID {} not found", + dns_server_id + ))) + } + } +} diff --git a/crates/common/src/network/limiter.rs b/crates/common/src/network/limiter.rs new file mode 100644 index 0000000..8c5025a --- /dev/null +++ b/crates/common/src/network/limiter.rs @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ThrottleKey, ThrottleKeyHasher, ThrottleKeyHasherBuilder}; +use std::{ + hash::{BuildHasher, Hasher}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct ConcurrencyLimiter(Arc); + +#[derive(Debug, Default)] +pub struct ConcurrencyLimiterInner { + max_concurrent: u64, + concurrent: AtomicU64, +} + +pub struct InFlight(Arc); + +impl Drop for InFlight { + fn drop(&mut self) { + self.0.concurrent.fetch_sub(1, Ordering::Relaxed); + } +} + +impl ConcurrencyLimiter { + pub fn new(max_concurrent: u64) -> Self { + ConcurrencyLimiter(Arc::new(ConcurrencyLimiterInner { + max_concurrent, + concurrent: AtomicU64::new(0), + })) + } + + pub fn is_allowed(&self) -> LimiterResult { + if self.0.concurrent.load(Ordering::Relaxed) < self.0.max_concurrent { + // Return in-flight request + self.0.concurrent.fetch_add(1, Ordering::Relaxed); + LimiterResult::Allowed(InFlight(self.0.clone())) + } else { + LimiterResult::Forbidden + } + } + + pub fn check_is_allowed(&self) -> bool { + self.0.concurrent.load(Ordering::Relaxed) < self.0.max_concurrent + } + + pub fn is_active(&self) -> bool { + self.0.concurrent.load(Ordering::Relaxed) > 0 + } + + pub fn max_concurrent(&self) -> u64 { + self.0.max_concurrent + } +} + +impl InFlight { + pub fn num_concurrent(&self) -> u64 { + self.0.concurrent.load(Ordering::Relaxed) + } +} + +pub enum LimiterResult { + Allowed(InFlight), + Forbidden, + Disabled, +} + +impl From for Option { + fn from(result: LimiterResult) -> Self { + match result { + LimiterResult::Allowed(in_flight) => Some(in_flight), + LimiterResult::Forbidden => None, + LimiterResult::Disabled => Some(InFlight(Arc::new(ConcurrencyLimiterInner::default()))), + } + } +} + +impl PartialEq for ThrottleKey { + fn eq(&self, other: &Self) -> bool { + self.hash == other.hash + } +} + +impl std::hash::Hash for ThrottleKey { + fn hash(&self, state: &mut H) { + self.hash.hash(state); + } +} + +impl AsRef<[u8]> for ThrottleKey { + fn as_ref(&self) -> &[u8] { + &self.hash + } +} + +impl Hasher for ThrottleKeyHasher { + fn finish(&self) -> u64 { + self.hash + } + + fn write(&mut self, bytes: &[u8]) { + debug_assert!( + bytes.len() >= std::mem::size_of::(), + "ThrottleKeyHasher: input too short {bytes:?}" + ); + self.hash = bytes + .get(0..std::mem::size_of::()) + .map_or(0, |b| u64::from_ne_bytes(b.try_into().unwrap())); + } +} + +impl BuildHasher for ThrottleKeyHasherBuilder { + type Hasher = ThrottleKeyHasher; + + fn build_hasher(&self) -> Self::Hasher { + ThrottleKeyHasher::default() + } +} diff --git a/crates/common/src/network/listen.rs b/crates/common/src/network/listen.rs new file mode 100644 index 0000000..c790838 --- /dev/null +++ b/crates/common/src/network/listen.rs @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ServerInstance, SessionData, SessionManager, SessionStream, TcpAcceptor, + limiter::{ConcurrencyLimiter, LimiterResult}, +}; +use crate::{ + BuildServer, Inner, Server, + config::server::{Listener, Listeners, ServerProtocol, TcpListener}, +}; +use proxy_header::io::ProxiedStream; +use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_AES_128_GCM_SHA256; +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; +use store::registry::bootstrap::Bootstrap; +use tokio::{net::TcpStream, sync::watch, time::timeout}; +use tokio_rustls::server::TlsStream; +use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent}; +use utils::UnwrapFailure; + +impl Listener { + pub fn spawn( + self, + manager: impl SessionManager, + inner: Arc, + acceptor: TcpAcceptor, + shutdown_rx: watch::Receiver, + ) { + // Prepare instance + let instance = Arc::new(ServerInstance { + id: self.id, + protocol: self.protocol, + proxy_networks: self.proxy_networks, + limiter: ConcurrencyLimiter::new(self.max_connections), + tls_timeout: self.tls_timeout, + acceptor, + shutdown_rx, + span_id_gen: self.span_id_gen, + }); + let is_tls = matches!(instance.acceptor, TcpAcceptor::Tls { implicit, .. } if implicit); + let is_https = is_tls && self.protocol == ServerProtocol::Http; + let has_proxies = !instance.proxy_networks.is_empty(); + + // Spawn listeners + for listener in self.listeners { + let local_addr = listener.addr; + + // Obtain TCP options + let opts = SocketOpts { + nodelay: listener.nodelay, + ttl: listener.ttl, + }; + + // Bind socket + let listener = match listener.listen() { + Ok(listener) => { + trc::event!( + Network(trc::NetworkEvent::ListenStart), + ListenerId = instance.id.clone(), + LocalIp = local_addr.ip(), + LocalPort = local_addr.port(), + Tls = is_tls, + ); + + listener + } + Err(err) => { + trc::event!( + Network(trc::NetworkEvent::ListenError), + ListenerId = instance.id.clone(), + LocalIp = local_addr.ip(), + LocalPort = local_addr.port(), + Tls = is_tls, + Reason = err, + ); + + continue; + } + }; + + // Spawn listener + let mut shutdown_rx = instance.shutdown_rx.clone(); + let manager = manager.clone(); + let instance = instance.clone(); + let inner = inner.clone(); + tokio::spawn(async move { + let (span_start, span_end) = match self.protocol { + ServerProtocol::Smtp | ServerProtocol::Lmtp => ( + EventType::Smtp(SmtpEvent::ConnectionStart), + EventType::Smtp(SmtpEvent::ConnectionEnd), + ), + ServerProtocol::Imap => ( + EventType::Imap(ImapEvent::ConnectionStart), + EventType::Imap(ImapEvent::ConnectionEnd), + ), + ServerProtocol::Pop3 => ( + EventType::Pop3(Pop3Event::ConnectionStart), + EventType::Pop3(Pop3Event::ConnectionEnd), + ), + ServerProtocol::Http => ( + EventType::Http(HttpEvent::ConnectionStart), + EventType::Http(HttpEvent::ConnectionEnd), + ), + ServerProtocol::ManageSieve => ( + EventType::ManageSieve(ManageSieveEvent::ConnectionStart), + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd), + ), + }; + + const ACCEPT_BACKOFF: Duration = Duration::from_millis(5); + const MAX_ACCEPT_BACKOFF: Duration = Duration::from_secs(1); + let mut accept_backoff = ACCEPT_BACKOFF; + + loop { + tokio::select! { + stream = listener.accept() => { + match stream { + Ok((stream, remote_addr)) => { + accept_backoff = ACCEPT_BACKOFF; + + let server = inner.build_server(); + let enable_acme = (is_https && server.has_acme_tls_providers()).then(|| server.clone()); + + if has_proxies && instance.proxy_networks.iter().any(|network| network.matches(&remote_addr.ip())) { + let instance = instance.clone(); + let manager = manager.clone(); + + // Set socket options + opts.apply(&stream); + + tokio::spawn(async move { + match ProxiedStream::create_from_tokio(stream, Default::default()).await { + Ok(stream) =>{ + let (remote_addr, local_addr) = stream.proxy_header() + .proxied_address() + .map(|addr| { + let local_addr = match addr.destination.ip() { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(|ip| SocketAddr::new(IpAddr::V4(ip), addr.destination.port())) + .unwrap_or(addr.destination), + _ => addr.destination, + }; + + (addr.source, local_addr) + }) + .unwrap_or((remote_addr, local_addr)); + if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &server) { + // Spawn session + manager.spawn(session, is_tls, enable_acme, span_start, span_end); + } + } + Err(err) => { + trc::event!( + Network(trc::NetworkEvent::ProxyError), + ListenerId = instance.id.clone(), + LocalIp = local_addr.ip(), + LocalPort = local_addr.port(), + Tls = is_tls, + Reason = err.to_string(), + ); + } + } + }); + } else if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &server) { + // Set socket options + opts.apply(&session.stream); + + // Spawn session + manager.spawn(session, is_tls, enable_acme, span_start, span_end); + } + } + Err(err) => { + if matches!( + err.kind(), + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + ) { + continue; + } + + trc::event!( + Network(trc::NetworkEvent::AcceptError), + ListenerId = instance.id.clone(), + LocalIp = local_addr.ip(), + LocalPort = local_addr.port(), + Tls = is_tls, + Reason = err.to_string(), + ); + + tokio::select! { + _ = tokio::time::sleep(accept_backoff) => {} + _ = shutdown_rx.changed() => { + manager.shutdown().await; + break; + } + } + + accept_backoff = (accept_backoff * 2).min(MAX_ACCEPT_BACKOFF); + } + } + }, + _ = shutdown_rx.changed() => { + + trc::event!( + Network(trc::NetworkEvent::ListenStop), + ListenerId = instance.id.clone(), + LocalIp = local_addr.ip(), + Tls = is_tls, + LocalPort = local_addr.port(), + ); + + manager.shutdown().await; + break; + } + }; + } + }); + } + } +} + +trait BuildSession { + fn build_session( + &self, + stream: T, + local_addr: SocketAddr, + remote_addr: SocketAddr, + server: &Server, + ) -> Option>; +} + +impl BuildSession for Arc { + fn build_session( + &self, + stream: T, + local_addr: SocketAddr, + remote_addr: SocketAddr, + server: &Server, + ) -> Option> { + // Convert mapped IPv6 addresses to IPv4 + let remote_ip = match remote_addr.ip() { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + remote_ip => remote_ip, + }; + let remote_port = remote_addr.port(); + + // Check if blocked + if server.is_ip_blocked(remote_ip) { + trc::event!( + Security(trc::SecurityEvent::IpBlocked), + ListenerId = self.id.clone(), + LocalPort = local_addr.port(), + RemoteIp = remote_ip, + RemotePort = remote_port, + ); + None + } else if let LimiterResult::Allowed(in_flight) = self.limiter.is_allowed() { + // Enforce concurrency + SessionData { + stream, + in_flight, + local_ip: local_addr.ip(), + local_port: local_addr.port(), + session_id: 0, + remote_ip, + remote_port, + protocol: self.protocol, + instance: self.clone(), + } + .into() + } else { + trc::event!( + Limit(trc::LimitEvent::ConcurrentConnection), + ListenerId = self.id.clone(), + LocalPort = local_addr.port(), + RemoteIp = remote_ip, + RemotePort = remote_port, + Limit = self.limiter.max_concurrent(), + ); + + None + } + } +} + +pub struct SocketOpts { + pub nodelay: bool, + pub ttl: Option, +} + +impl SocketOpts { + pub fn apply(&self, stream: &TcpStream) { + // Set TCP options + if let Err(err) = stream.set_nodelay(self.nodelay) { + trc::event!( + Network(trc::NetworkEvent::SetOptError), + Reason = err.to_string(), + Details = "Failed to set TCP_NODELAY", + ); + } + if let Some(ttl) = self.ttl + && let Err(err) = stream.set_ttl(ttl) + { + trc::event!( + Network(trc::NetworkEvent::SetOptError), + Reason = err.to_string(), + Details = "Failed to set TTL", + ); + } + } +} + +impl Listeners { + pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) { + // Bind as root + for server in &self.servers { + for listener in &server.listeners { + if let Err(err) = listener.socket.bind(listener.addr) { + bp.build_error( + server.registry_id, + format!("Failed to bind to {}: {}", listener.addr, err), + ); + } + } + } + + // Drop privileges + #[cfg(not(target_env = "msvc"))] + { + if let Ok(run_as_user) = std::env::var("RUN_AS_USER") { + let mut pd = privdrop::PrivDrop::default() + .user(run_as_user) + .fallback_to_ids_if_names_are_numeric(); + if let Ok(run_as_group) = std::env::var("RUN_AS_GROUP") { + pd = pd + .group(run_as_group) + .fallback_to_ids_if_names_are_numeric(); + } + pd.apply().failed("Failed to drop privileges"); + } + } + } + + pub fn spawn( + mut self, + spawn: impl Fn(Listener, TcpAcceptor, watch::Receiver), + ) -> (watch::Sender, watch::Receiver) { + // Spawn listeners + let (shutdown_tx, shutdown_rx) = watch::channel(false); + for server in self.servers { + let acceptor = self + .tcp_acceptors + .remove(&server.id) + .unwrap_or(TcpAcceptor::Plain); + + spawn(server, acceptor, shutdown_rx.clone()); + } + (shutdown_tx, shutdown_rx) + } +} + +impl TcpListener { + pub fn listen(self) -> Result { + self.socket + .listen(self.backlog.unwrap_or(1024)) + .map_err(|err| format!("Failed to listen on {}: {}", self.addr, err)) + } +} + +impl ServerInstance { + pub async fn tls_accept( + &self, + stream: T, + session_id: u64, + ) -> Result, ()> { + match &self.acceptor { + TcpAcceptor::Tls { acceptor, .. } => { + match timeout(self.tls_timeout, acceptor.accept(stream)) + .await + .unwrap_or_else(|_| Err(io::Error::from(io::ErrorKind::TimedOut))) + { + Ok(stream) => { + trc::event!( + Tls(trc::TlsEvent::Handshake), + ListenerId = self.id.clone(), + SpanId = session_id, + Version = format!( + "{:?}", + stream + .get_ref() + .1 + .protocol_version() + .unwrap_or(rustls::ProtocolVersion::TLSv1_3) + ), + Details = format!( + "{:?}", + stream + .get_ref() + .1 + .negotiated_cipher_suite() + .unwrap_or(TLS13_AES_128_GCM_SHA256) + ) + ); + Ok(stream) + } + Err(err) => { + trc::event!( + Tls(trc::TlsEvent::HandshakeError), + ListenerId = self.id.clone(), + SpanId = session_id, + Reason = err.to_string(), + ); + Err(()) + } + } + } + TcpAcceptor::Plain => { + trc::event!( + Tls(trc::TlsEvent::NotConfigured), + ListenerId = self.id.clone(), + SpanId = session_id, + ); + Err(()) + } + } + } +} diff --git a/crates/common/src/network/mod.rs b/crates/common/src/network/mod.rs new file mode 100644 index 0000000..6e7727c --- /dev/null +++ b/crates/common/src/network/mod.rs @@ -0,0 +1,476 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use self::limiter::{ConcurrencyLimiter, InFlight}; +use crate::{ + Server, + config::server::ServerProtocol, + expr::{functions::ResolveVariable, *}, +}; +use compact_str::ToCompactString; +use registry::{schema::enums::ExpressionVariable, types::ipmask::IpAddrOrMask}; +use rustls::ServerConfig; +use std::fmt::Debug; +use std::{ + borrow::Cow, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + sync::watch, + time::timeout, +}; +use tokio_rustls::{Accept, TlsAcceptor}; +use trc::{Event, EventType, Key}; +use utils::snowflake::SnowflakeIdGenerator; + +pub mod acme; +pub mod asn; +pub mod autoconfig; +pub mod dkim; +pub mod dns; +pub mod limiter; +pub mod listen; +pub mod mta; +pub mod security; +pub mod stream; +pub mod tls; +pub mod webpush; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub enum RcptResolution { + Accept, + Expand(Arc<[Box]>), + Rewrite(String), + #[default] + UnknownRecipient, + UnknownDomain, +} + +pub struct ServerInstance { + pub id: String, + pub protocol: ServerProtocol, + pub acceptor: TcpAcceptor, + pub limiter: ConcurrencyLimiter, + pub proxy_networks: Vec, + pub tls_timeout: Duration, + pub shutdown_rx: watch::Receiver, + pub span_id_gen: Arc, +} + +#[derive(Default)] +pub enum TcpAcceptor { + Tls { + config: Arc, + acceptor: TlsAcceptor, + implicit: bool, + }, + #[default] + Plain, +} + +#[allow(clippy::large_enum_variant)] +pub enum TcpAcceptorResult +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + Tls(Accept), + Plain(IO), + Close, +} + +pub struct SessionData { + pub stream: T, + pub local_ip: IpAddr, + pub local_port: u16, + pub remote_ip: IpAddr, + pub remote_port: u16, + pub protocol: ServerProtocol, + pub session_id: u64, + pub in_flight: InFlight, + pub instance: Arc, +} + +pub trait SessionStream: AsyncRead + AsyncWrite + Unpin + 'static + Sync + Send { + fn is_tls(&self) -> bool; + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionResult { + Continue, + Close, + UpgradeTls, +} + +pub trait SessionManager: Sync + Send + 'static + Clone { + fn spawn( + &self, + mut session: SessionData, + is_tls: bool, + acme_core: Option, + span_start: EventType, + span_end: EventType, + ) { + let manager = self.clone(); + + tokio::spawn(async move { + let start_time = Instant::now(); + let local_port = session.local_port; + let session_id; + + if is_tls { + let tls_timeout = session.instance.tls_timeout; + match timeout( + tls_timeout, + session + .instance + .acceptor + .accept(session.stream, acme_core, &session.instance), + ) + .await + { + Ok(TcpAcceptorResult::Tls(accept)) => { + match timeout(tls_timeout.saturating_sub(start_time.elapsed()), accept) + .await + .unwrap_or_else(|_| Err(io::Error::from(io::ErrorKind::TimedOut))) + { + Ok(stream) => { + // Generate sessionId + session.session_id = session.instance.span_id_gen.generate(); + session_id = session.session_id; + + // Send span + Event::with_keys( + span_start, + vec![ + (Key::ListenerId, session.instance.id.clone().into()), + (Key::LocalPort, session.local_port.into()), + (Key::RemoteIp, session.remote_ip.into()), + (Key::RemotePort, session.remote_port.into()), + (Key::SpanId, session.session_id.into()), + ], + ) + .send_with_metrics(); + + manager + .handle(SessionData { + stream, + local_ip: session.local_ip, + local_port: session.local_port, + remote_ip: session.remote_ip, + remote_port: session.remote_port, + protocol: session.protocol, + session_id: session.session_id, + in_flight: session.in_flight, + instance: session.instance, + }) + .await; + } + Err(err) => { + trc::event!( + Tls(trc::TlsEvent::HandshakeError), + ListenerId = session.instance.id.clone(), + LocalPort = local_port, + RemoteIp = session.remote_ip, + RemotePort = session.remote_port, + Reason = err.to_string(), + ); + + return; + } + } + } + Ok(TcpAcceptorResult::Plain(stream)) => { + // Generate sessionId + session.session_id = session.instance.span_id_gen.generate(); + session_id = session.session_id; + + // Send span + Event::with_keys( + span_start, + vec![ + (Key::ListenerId, session.instance.id.clone().into()), + (Key::LocalPort, session.local_port.into()), + (Key::RemoteIp, session.remote_ip.into()), + (Key::RemotePort, session.remote_port.into()), + (Key::SpanId, session.session_id.into()), + ], + ) + .send_with_metrics(); + + session.stream = stream; + manager.handle(session).await; + } + Ok(TcpAcceptorResult::Close) => return, + Err(_) => { + trc::event!( + Tls(trc::TlsEvent::HandshakeError), + ListenerId = session.instance.id.clone(), + LocalPort = local_port, + RemoteIp = session.remote_ip, + RemotePort = session.remote_port, + Reason = io::Error::from(io::ErrorKind::TimedOut).to_string(), + ); + + return; + } + } + } else { + // Generate sessionId + session.session_id = session.instance.span_id_gen.generate(); + session_id = session.session_id; + + // Send span + Event::with_keys( + span_start, + vec![ + (Key::ListenerId, session.instance.id.clone().into()), + (Key::LocalPort, session.local_port.into()), + (Key::RemoteIp, session.remote_ip.into()), + (Key::RemotePort, session.remote_port.into()), + (Key::SpanId, session.session_id.into()), + ], + ) + .send_with_metrics(); + + manager.handle(session).await; + } + + // End span + Event::with_keys( + span_end, + vec![ + (Key::SpanId, session_id.into()), + (Key::Elapsed, start_time.elapsed().into()), + ], + ) + .send_with_metrics(); + }); + } + + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send; + + fn shutdown(&self) -> impl std::future::Future + Send; +} + +impl ResolveVariable for SessionData { + fn resolve_variable(&self, variable: ExpressionVariable) -> crate::expr::Variable<'_> { + match variable { + ExpressionVariable::RemoteIp => self.remote_ip.to_compact_string().into(), + ExpressionVariable::RemotePort => self.remote_port.into(), + ExpressionVariable::LocalIp => self.local_ip.to_compact_string().into(), + ExpressionVariable::LocalPort => self.local_port.into(), + ExpressionVariable::Listener => self.instance.id.as_str().into(), + ExpressionVariable::Protocol => self.protocol.as_str().into(), + ExpressionVariable::IsTls => self.stream.is_tls().into(), + _ => crate::expr::Variable::default(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl Debug for TcpAcceptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tls { + config, implicit, .. + } => f + .debug_struct("Tls") + .field("config", config) + .field("implicit", implicit) + .finish(), + Self::Plain => write!(f, "Plain"), + } + } +} + +pub fn is_global_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_global_ipv4(ip), + IpAddr::V6(ip) => is_global_ipv6(ip), + } +} + +fn is_global_ipv4(ip: &Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + let is_this_network = a == 0; + let is_shared = a == 100 && (64..128).contains(&b); + let is_protocol_assignment = a == 192 && b == 0 && c == 0; + let is_benchmarking = a == 198 && (b & 0xfe) == 18; + let is_relay_6to4 = a == 192 && b == 88 && c == 99; + let is_reserved = a >= 240; + + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() + || ip.is_documentation() + || is_this_network + || is_shared + || is_protocol_assignment + || is_benchmarking + || is_relay_6to4 + || is_reserved) +} + +fn is_global_ipv6(ip: &Ipv6Addr) -> bool { + if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() { + return false; + } + + if let Some(ip) = ip.to_ipv4() { + return is_global_ipv4(&ip); + } + + let segments = ip.segments(); + + if segments[0] == 0x2002 { + return is_global_ipv4(&Ipv4Addr::from( + ((segments[1] as u32) << 16) | segments[2] as u32, + )); + } + + if segments[0] == 0x0064 && segments[1] == 0xff9b { + let is_well_known_prefix = + segments[2] == 0 && segments[3] == 0 && segments[4] == 0 && segments[5] == 0; + + return is_well_known_prefix + && is_global_ipv4(&Ipv4Addr::from( + ((segments[6] as u32) << 16) | segments[7] as u32, + )); + } + + let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; + let is_link_local = (segments[0] & 0xffc0) == 0xfe80; + let is_site_local = (segments[0] & 0xffc0) == 0xfec0; + let is_discard_only = + segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0; + let is_documentation = segments[0] == 0x2001 && segments[1] == 0x0db8; + let is_teredo = segments[0] == 0x2001 && segments[1] == 0; + let is_orchid = segments[0] == 0x2001 && (segments[1] & 0xfff0) == 0x0020; + + !(is_unique_local + || is_link_local + || is_site_local + || is_discard_only + || is_documentation + || is_teredo + || is_orchid) +} + +pub fn ip_to_bytes(ip: &IpAddr) -> Vec { + match ip { + IpAddr::V4(ip) => ip.octets().to_vec(), + IpAddr::V6(ip) => ip.octets().to_vec(), + } +} + +pub fn ip_to_bytes_prefix(prefix: u8, ip: &IpAddr) -> Vec { + match ip { + IpAddr::V4(ip) => { + let mut buf = Vec::with_capacity(5); + buf.push(prefix); + buf.extend_from_slice(&ip.octets()); + buf + } + IpAddr::V6(ip) => { + let mut buf = Vec::with_capacity(17); + buf.push(prefix); + buf.extend_from_slice(&ip.octets()); + buf + } + } +} + +#[cfg(test)] +mod tests { + use super::is_global_ip; + use std::net::IpAddr; + + #[test] + fn global_ip_classification() { + for ip in [ + "8.8.8.8", + "1.1.1.1", + "93.184.216.34", + "172.32.0.1", + "100.63.255.255", + "100.128.0.1", + "198.20.0.1", + "192.0.3.1", + "2606:4700::1111", + "2a00:1450:4001::200e", + "::ffff:8.8.8.8", + "64:ff9b::808:808", + "2002:0808:0808::", + "2001:db9::1", + ] { + assert!( + is_global_ip(&ip.parse::().unwrap()), + "expected {ip} to be global" + ); + } + + for ip in [ + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "100.127.255.255", + "198.18.0.1", + "198.19.255.255", + "192.0.0.1", + "192.0.2.5", + "192.88.99.1", + "240.0.0.1", + "255.255.255.255", + "224.0.0.1", + "0.0.0.0", + "0.1.2.3", + "::1", + "::", + "fc00::1", + "fd12:3456::1", + "fe80::1", + "febf::1", + "fec0::1", + "2001:db8::1", + "ff02::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "::127.0.0.1", + "64:ff9b::7f00:1", + "64:ff9b::a00:1", + "64:ff9b:1::7f00:1", + "2002:7f00:1::", + "2002:c0a8:101::", + "100::1", + "2001::1", + "2001:20::1", + ] { + assert!( + !is_global_ip(&ip.parse::().unwrap()), + "expected {ip} to be rejected" + ); + } + } +} diff --git a/crates/common/src/network/mta.rs b/crates/common/src/network/mta.rs new file mode 100644 index 0000000..ee36fe9 --- /dev/null +++ b/crates/common/src/network/mta.rs @@ -0,0 +1,419 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::{DOMAIN_FLAG_RELAY, DOMAIN_FLAG_SUB_ADDRESSING, EmailAddressRef, EmailCache}, + config::{ + mailstore::spamfilter::SpamClassifier, + smtp::{ + auth::DkimSigners, + queue::{ + ConnectionStrategy, DEFAULT_QUEUE_NAME, MxConfig, QueueExpiry, QueueName, + QueueStrategy, RequireOptional, RoutingStrategy, TlsStrategy, VirtualQueue, + }, + }, + }, + expr::{Variable, functions::ResolveVariable}, + manager::SPAM_CLASSIFIER_KEY, + network::RcptResolution, +}; +use directory::Recipient; +use mail_auth::IpLookupStrategy; +use registry::schema::{enums::ExpressionVariable, structs::MaskedEmail}; +use sieve::Sieve; +use std::{ + borrow::Cow, + sync::{Arc, LazyLock}, + time::Duration, +}; +use store::{ + Deserialize, IterateParams, ValueKey, + write::{AlignedBytes, Archive, QueueClass, ValueClass, now}, +}; +use trc::{AddContext, SpamEvent}; +use types::id::Id; + +impl Server { + pub async fn rcpt_resolve( + &self, + rcpt: &str, + allow_catch_all: bool, + session_id: u64, + ) -> trc::Result { + // Obtain domain settings + let Some((local_part, domain_part)) = rcpt.rsplit_once('@') else { + return Ok(RcptResolution::UnknownDomain); + }; + let Some(domain) = self.domain(domain_part).await? else { + return Ok(RcptResolution::UnknownDomain); + }; + + // Sub-addressing resolution + let local_part_orig = local_part; + let mut local_part = Cow::Borrowed(local_part); + if domain.flags & DOMAIN_FLAG_SUB_ADDRESSING != 0 { + if let Some(sub_addressing) = &domain.sub_addressing_custom { + // Custom sub-addressing resolution + if let Some(result) = self + .eval_if::( + sub_addressing, + &AddressResolver(local_part.as_ref()), + session_id, + ) + .await + { + local_part = Cow::Owned(result); + } + } else if let Some((new_local_part, _)) = rcpt.split_once('+') { + local_part = Cow::Borrowed(new_local_part); + } + } + + + // Obtain external directory, if configured + let directory = self + .get_directory_for_cached_domain(&domain) + .filter(|directory| directory.can_lookup_recipients()); + if let Some(directory) = directory { + let is_subaddressed = local_part.as_ref() != local_part_orig; + let address = if is_subaddressed { + Cow::Owned(format!("{local_part}@{domain_part}")) + } else { + Cow::Borrowed(rcpt) + }; + match directory.recipient(address.as_ref()).await? { + Recipient::Account(account) => { + Box::pin(self.synchronize_account(account)).await?; + return Ok(if is_subaddressed { + RcptResolution::Rewrite(address.into_owned()) + } else { + RcptResolution::Accept + }); + } + Recipient::Group(group) => { + Box::pin(self.synchronize_group(group)).await?; + return Ok(if is_subaddressed { + RcptResolution::Rewrite(address.into_owned()) + } else { + RcptResolution::Accept + }); + } + Recipient::Invalid => {} + } + } + + // Try resolving address from registry + if let Some(address_type) = self + .rcpt_id_from_parts(local_part.as_ref(), domain.id) + .await? + { + match address_type { + EmailCache::Account(id) if directory.is_none() => { + if self.try_account(id).await?.is_some() { + return if local_part.as_ref() == local_part_orig { + Ok(RcptResolution::Accept) + } else { + Ok(RcptResolution::Rewrite(format!( + "{local_part}@{domain_part}" + ))) + }; + } else { + self.inner + .cache + .emails + .remove(&EmailAddressRef::new(local_part.as_ref(), domain.id)); + } + } + EmailCache::MailingList(id) => { + if let Some(list) = self.try_list(id).await? { + return Ok(RcptResolution::Expand(list.recipients.clone())); + } else { + self.inner + .cache + .emails + .remove(&EmailAddressRef::new(local_part.as_ref(), domain.id)); + } + } + _ => {} + } + } + + // Catch-all resolution + if allow_catch_all && let Some(catch_all) = &domain.catch_all { + return Ok( + match Box::pin(self.rcpt_resolve(catch_all, false, session_id)).await? { + resolution @ (RcptResolution::Expand(_) | RcptResolution::Rewrite(_)) => { + resolution + } + _ => RcptResolution::Rewrite(catch_all.to_string()), + }, + ); + } + + // Verify whether domain relaying is enabled + if domain.flags & DOMAIN_FLAG_RELAY != 0 { + Ok(RcptResolution::Accept) + } else { + Ok(RcptResolution::UnknownRecipient) + } + } + + pub async fn get_dkim_signers( + &self, + domain: &str, + session_id: u64, + ) -> trc::Result>> { + if let Some(signers) = self.dkim_signers(domain).await? { + Ok(Some(signers)) + } else { + trc::event!( + Dkim(trc::DkimEvent::SignerNotFound), + Id = domain.to_string(), + SpanId = session_id, + ); + + Ok(None) + } + } + + pub fn get_trusted_sieve_script(&self, name: &str, session_id: u64) -> Option<&Arc> { + self.core.sieve.trusted_script(name).or_else(|| { + trc::event!( + Sieve(trc::SieveEvent::ScriptNotFound), + Id = name.to_string(), + SpanId = session_id, + ); + + None + }) + } + + pub fn get_untrusted_sieve_script(&self, name: &str, session_id: u64) -> Option<&Arc> { + self.core.sieve.untrusted_script(name).or_else(|| { + trc::event!( + Sieve(trc::SieveEvent::ScriptNotFound), + Id = name.to_string(), + SpanId = session_id, + ); + + None + }) + } + + pub fn get_route_or_default(&self, name: &str, session_id: u64) -> &RoutingStrategy { + static LOCAL_GATEWAY: RoutingStrategy = RoutingStrategy::Local; + static MX_GATEWAY: RoutingStrategy = RoutingStrategy::Mx(MxConfig { + max_mx: 5, + max_multi_homed: 2, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }); + self.core + .smtp + .queue + .routing_strategy + .get(name) + .unwrap_or_else(|| match name { + "local" => &LOCAL_GATEWAY, + "mx" => &MX_GATEWAY, + _ => { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Gateway not found", + SpanId = session_id, + ); + &MX_GATEWAY + } + }) + } + + pub fn get_virtual_queue_or_default(&self, name: &QueueName) -> &VirtualQueue { + static DEFAULT_QUEUE: VirtualQueue = VirtualQueue { threads: 25 }; + self.core + .smtp + .queue + .virtual_queues + .get(name) + .unwrap_or_else(|| { + if name != &DEFAULT_QUEUE_NAME { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Virtual queue not found", + ); + } + + &DEFAULT_QUEUE + }) + } + + pub fn get_queue_or_default(&self, name: &str, session_id: u64) -> &QueueStrategy { + static DEFAULT_SCHEDULE: LazyLock = LazyLock::new(|| QueueStrategy { + retry: vec![ + 120, // 2 minutes + 300, // 5 minutes + 600, // 10 minutes + 900, // 15 minutes + 1800, // 30 minutes + 3600, // 1 hour + 7200, // 2 hours + ], + notify: vec![ + 86400, // 1 day + 259200, // 3 days + ], + expiry: QueueExpiry::Ttl(432000), // 5 days + virtual_queue: QueueName::default(), + }); + self.core + .smtp + .queue + .queue_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Queue strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_SCHEDULE + }) + } + + pub fn get_tls_or_default(&self, name: &str, session_id: u64) -> &TlsStrategy { + static DEFAULT_TLS: TlsStrategy = TlsStrategy { + dane: RequireOptional::Optional, + mta_sts: RequireOptional::Optional, + tls: RequireOptional::Optional, + allow_invalid_certs: false, + timeout_tls: Duration::from_secs(3 * 60), + timeout_mta_sts: Duration::from_secs(5 * 60), + }; + self.core + .smtp + .queue + .tls_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "TLS strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_TLS + }) + } + + pub fn get_connection_or_default(&self, name: &str, session_id: u64) -> &ConnectionStrategy { + static DEFAULT_CONNECTION: ConnectionStrategy = ConnectionStrategy { + source_ipv4: Vec::new(), + source_ipv6: Vec::new(), + ehlo_hostname: None, + timeout_connect: Duration::from_secs(5 * 60), + timeout_greeting: Duration::from_secs(5 * 60), + timeout_ehlo: Duration::from_secs(5 * 60), + timeout_mail: Duration::from_secs(5 * 60), + timeout_rcpt: Duration::from_secs(5 * 60), + timeout_data: Duration::from_secs(10 * 60), + }; + + self.core + .smtp + .queue + .connection_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Connection strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_CONNECTION + }) + } + + pub async fn spam_model_reload(&self) -> trc::Result<()> { + if self.core.spam.classifier.is_some() { + if let Some(model) = self + .blob_store() + .get_blob(SPAM_CLASSIFIER_KEY, 0..usize::MAX) + .await + .and_then(|archive| match archive { + Some(archive) => as Deserialize>::deserialize(&archive) + .and_then(|archive| archive.deserialize_untrusted::()) + .map(Some), + None => Ok(None), + }) + .caused_by(trc::location!())? + { + let last_trained_at = match &model { + SpamClassifier::FhClassifier { + last_trained_at, .. + } => Some(*last_trained_at), + SpamClassifier::CcfhClassifier { + last_trained_at, .. + } => Some(*last_trained_at), + SpamClassifier::Disabled => None, + }; + + trc::event!( + Spam(SpamEvent::ModelLoaded), + Details = last_trained_at.map(trc::Value::Timestamp), + ); + self.inner.data.spam_classifier.store(Arc::new(model)); + } else { + trc::event!(Spam(SpamEvent::ModelNotFound)); + } + } + + Ok(()) + } + + pub async fn total_queued_messages(&self) -> trc::Result { + let mut total = 0; + self.store() + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::Queue(QueueClass::Message(0))), + ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))), + ) + .no_values(), + |_, _| { + total += 1; + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| total) + } +} + +pub struct AddressResolver<'x>(pub &'x str); + +impl ResolveVariable for AddressResolver<'_> { + fn resolve_variable(&'_ self, _: ExpressionVariable) -> crate::expr::Variable<'_> { + Variable::from(self.0) + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs new file mode 100644 index 0000000..7ca0306 --- /dev/null +++ b/crates/common/src/network/security.rs @@ -0,0 +1,515 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + KV_RATE_LIMIT_AUTH, KV_RATE_LIMIT_LOITER, KV_RATE_LIMIT_RCPT, KV_RATE_LIMIT_SCAN, Server, + ipc::{BroadcastEvent, RegistryChange}, + network::ip_to_bytes, +}; +use ahash::AHashSet; +use registry::{ + schema::{ + enums::{BlockReason, PasswordHashAlgorithm, PasswordStrength}, + prelude::{Object, ObjectType}, + structs::{self, AllowedIp, BlockedIp, Rate, SystemSettings}, + }, + types::{datetime::UTCDateTime, ipmask::IpAddrOrMask}, +}; +use std::{fmt::Debug, hash::Hash, net::IpAddr}; +use store::{ + registry::{ + bootstrap::Bootstrap, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::now, +}; +use trc::AddContext; +use types::id::Id; +use utils::glob::{GlobPattern, MatchType}; +use zxcvbn::Score; + +#[derive(Debug, Clone)] +pub struct Security { + pub allowed_ip_addresses: AHashSet>, + pub allowed_ip_networks: Vec>, + pub has_allowed_networks: bool, + pub auth_ban_period: Option, + pub abuse_ban_period: Option, + pub loiter_ban_period: Option, + pub scan_ban_period: Option, + + pub http_banned_paths: Vec, + pub scanner_fail_rate: Option, + + pub auth_fail_rate: Option, + pub rcpt_fail_rate: Option, + pub loiter_fail_rate: Option, + + pub default_role_ids_user: Vec, + pub default_role_ids_group: Vec, + pub default_role_ids_tenant: Vec, + pub default_role_ids_admin: Vec, + + pub password_hash_algorithm: PasswordHashAlgorithm, + pub password_max_length: u32, + pub password_min_length: u32, + pub password_min_strength: Score, + pub password_default_expiration: Option, +} + +#[derive(Default)] +pub struct BlockedIps { + pub blocked_ip_addresses: AHashSet>, + pub blocked_ip_networks: Vec>, + pub has_blocked_networks: bool, +} + +#[derive(Debug, Clone)] +pub struct IpWithTtl { + pub ip: T, + pub expires_at: u64, +} + +impl Security { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let mut allowed_ip_addresses = AHashSet::new(); + let mut allowed_ip_networks = Vec::new(); + let mut expired_allows = Vec::new(); + let now = now(); + + for ip in bp.list_infallible::().await { + let id = ip.id; + let revision = ip.revision; + let ip = ip.object; + let expires_at = ip + .expires_at + .as_ref() + .map(|dt| dt.timestamp() as u64) + .unwrap_or(u64::MAX); + + if expires_at > now { + if let Some(ip) = ip.address.try_to_ip() { + allowed_ip_addresses.insert(IpWithTtl::new(ip, expires_at)); + } else { + let ip_with_ttl = IpWithTtl::new(ip.address, expires_at); + + if !allowed_ip_networks.contains(&ip_with_ttl) { + allowed_ip_networks.push(ip_with_ttl); + } + } + } else { + expired_allows.push(( + id, + ip.address.clone(), + Object { + inner: ip.into(), + revision, + }, + )); + } + } + + // Add proxy protocol IPs as allowed + let system = bp.setting_infallible::().await; + for ip in system.proxy_trusted_networks { + if let Some(ip) = ip.try_to_ip() { + allowed_ip_addresses.insert(IpWithTtl::new(ip, u64::MAX)); + } else { + let ip_with_ttl = IpWithTtl::new(ip, u64::MAX); + if !allowed_ip_networks.contains(&ip_with_ttl) { + allowed_ip_networks.push(ip_with_ttl); + } + } + } + + if !expired_allows.is_empty() { + for (id, _, object) in &expired_allows { + if let Err(err) = bp + .registry + .write(RegistryWrite::delete_object(*id, object)) + .await + { + trc::error!( + err.details("Failed to delete expired allowed IP from registry.") + .caused_by(trc::location!()) + ); + } + } + + trc::event!( + Security(trc::SecurityEvent::IpAllowExpired), + Details = expired_allows + .into_iter() + .map(|(_, ip, _)| trc::Value::from(ip.into_inner().0)) + .collect::>() + ); + } + + #[cfg(not(feature = "test_mode"))] + { + // Add loopback addresses + allowed_ip_addresses.insert(IpWithTtl::new( + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + u64::MAX, + )); + allowed_ip_addresses.insert(IpWithTtl::new( + IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), + u64::MAX, + )); + } + + let is_recovery_mode = bp.registry.is_recovery_mode(); + let security = bp.setting_infallible::().await; + let auth = bp.setting_infallible::().await; + Security { + has_allowed_networks: !allowed_ip_networks.is_empty(), + allowed_ip_addresses, + allowed_ip_networks, + auth_ban_period: security.auth_ban_period.map(|v| v.as_secs()), + abuse_ban_period: security.abuse_ban_period.map(|v| v.as_secs()), + loiter_ban_period: security.loiter_ban_period.map(|v| v.as_secs()), + scan_ban_period: security.scan_ban_period.map(|v| v.as_secs()), + auth_fail_rate: security.auth_ban_rate.filter(|_| !is_recovery_mode), + rcpt_fail_rate: security.abuse_ban_rate.filter(|_| !is_recovery_mode), + loiter_fail_rate: security.loiter_ban_rate.filter(|_| !is_recovery_mode), + http_banned_paths: if is_recovery_mode { + Vec::new() + } else { + security + .scan_ban_paths + .iter() + .map(|pattern| MatchType::Matches(GlobPattern::compile(pattern, true))) + .collect() + }, + scanner_fail_rate: security.scan_ban_rate.filter(|_| !is_recovery_mode), + default_role_ids_user: auth.default_user_role_ids.into_inner(), + default_role_ids_group: auth.default_group_role_ids.into_inner(), + default_role_ids_tenant: auth.default_tenant_role_ids.into_inner(), + default_role_ids_admin: auth.default_admin_role_ids.into_inner(), + password_hash_algorithm: auth.password_hash_algorithm, + password_max_length: auth.password_max_length as u32, + password_min_length: auth.password_min_length as u32, + password_min_strength: match auth.password_min_strength { + PasswordStrength::Zero => Score::Zero, + PasswordStrength::One => Score::One, + PasswordStrength::Two => Score::Two, + PasswordStrength::Three => Score::Three, + PasswordStrength::Four => Score::Four, + }, + password_default_expiration: auth.password_default_expiry.map(|v| v.as_secs()), + } + } + + fn ban_period(&self, reason: BlockReason) -> Option { + match reason { + BlockReason::RcptToFailure => self.abuse_ban_period, + BlockReason::AuthFailure => self.auth_ban_period, + BlockReason::Loitering => self.loiter_ban_period, + BlockReason::PortScanning => self.scan_ban_period, + BlockReason::Manual | BlockReason::Other => None, + } + } +} + +impl Server { + pub async fn is_rcpt_fail2banned(&self, ip: IpAddr, rcpt: &str) -> trc::Result { + if let Some(rate) = &self.core.network.security.rcpt_fail_rate { + let is_allowed = self.is_ip_allowed(ip) + || (self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_RCPT, &ip_to_bytes(&ip), rate, false) + .await? + .is_none() + && self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_RCPT, rcpt.as_bytes(), rate, false) + .await? + .is_none()); + + if !is_allowed { + return self + .block_ip(ip, BlockReason::RcptToFailure) + .await + .map(|_| true); + } + } + + Ok(false) + } + + pub async fn is_scanner_fail2banned(&self, ip: IpAddr) -> trc::Result { + if let Some(rate) = &self.core.network.security.scanner_fail_rate { + let is_allowed = self.is_ip_allowed(ip) + || self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_SCAN, &ip_to_bytes(&ip), rate, false) + .await? + .is_none(); + + if !is_allowed { + return self + .block_ip(ip, BlockReason::PortScanning) + .await + .map(|_| true); + } + } + + Ok(false) + } + + pub async fn is_http_banned_path(&self, path: &str, ip: IpAddr) -> trc::Result { + let paths = &self.core.network.security.http_banned_paths; + + if !paths.is_empty() && paths.iter().any(|p| p.matches(path)) && !self.is_ip_allowed(ip) { + self.block_ip(ip, BlockReason::PortScanning) + .await + .map(|_| true) + } else { + Ok(false) + } + } + + pub async fn is_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result { + if let Some(rate) = &self.core.network.security.loiter_fail_rate { + let is_allowed = self.is_ip_allowed(ip) + || self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_LOITER, &ip_to_bytes(&ip), rate, false) + .await? + .is_none(); + + if !is_allowed { + return self + .block_ip(ip, BlockReason::Loitering) + .await + .map(|_| true); + } + } + + Ok(false) + } + + pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: Option<&str>) -> trc::Result { + if let Some(rate) = &self.core.network.security.auth_fail_rate { + let login = login.unwrap_or_default(); + let is_allowed = self.is_ip_allowed(ip) + || (self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_AUTH, &ip_to_bytes(&ip), rate, false) + .await? + .is_none() + && (login.is_empty() + || self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_AUTH, login.as_bytes(), rate, false) + .await? + .is_none())); + if !is_allowed { + return self + .block_ip(ip, BlockReason::AuthFailure) + .await + .map(|_| true); + } + } + + Ok(false) + } + + pub async fn block_ip(&self, ip: IpAddr, reason: BlockReason) -> trc::Result<()> { + // Add IP to blocked list + let now = now(); + let expires_at = self + .core + .network + .security + .ban_period(reason) + .map(|v| now + v); + self.inner + .data + .blocked_ips + .write() + .blocked_ip_addresses + .insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); + + // Write blocked IP to config + let RegistryWriteResult::Success(id) = self + .registry() + .write(RegistryWrite::insert( + &BlockedIp { + address: IpAddrOrMask::from_ip(ip), + created_at: UTCDateTime::from_timestamp(now as i64), + expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)), + reason, + } + .into(), + )) + .await + .caused_by(trc::location!())? + else { + return Ok(()); + }; + + // Increment version + self.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Insert( + ObjectType::BlockedIp.id(id), + ))) + .await; + + Ok(()) + } + + pub fn has_auth_fail2ban(&self) -> bool { + self.core.network.security.auth_fail_rate.is_some() + } + + pub fn is_ip_blocked(&self, ip: IpAddr) -> bool { + let blocked_ips = self.inner.data.blocked_ips.read(); + (blocked_ips + .blocked_ip_addresses + .get(&IpWithTtl::new(ip, 0)) + .is_some_and(|v| !v.is_expired()) + || (blocked_ips.has_blocked_networks + && blocked_ips + .blocked_ip_networks + .iter() + .any(|network| network.ip.matches(&ip) && !network.is_expired()))) + && !self.is_ip_allowed(ip) + } + + pub fn is_ip_allowed(&self, ip: IpAddr) -> bool { + self.core + .network + .security + .allowed_ip_addresses + .get(&IpWithTtl::new(ip, 0)) + .is_some_and(|v| !v.is_expired()) + || (self.core.network.security.has_allowed_networks + && self + .core + .network + .security + .allowed_ip_networks + .iter() + .any(|network| network.ip.matches(&ip) && !network.is_expired())) + } + + pub fn is_secure_password(&self, password: &str, user_inputs: &[&str]) -> Result<(), String> { + if (password.len() as u32) > self.core.network.security.password_max_length { + Err(format!( + "Password must be at most {} characters long.", + self.core.network.security.password_max_length + )) + } else if (password.len() as u32) < self.core.network.security.password_min_length { + Err(format!( + "Password must be at least {} characters long.", + self.core.network.security.password_min_length + )) + } else if self.core.network.security.password_min_strength > Score::Zero { + let entropy = zxcvbn::zxcvbn(password, user_inputs); + if entropy.score() >= self.core.network.security.password_min_strength { + Ok(()) + } else if let Some(feedback) = entropy.feedback() { + Err(format!("Password is too weak. {feedback}")) + } else { + Err("Password is too weak.".to_string()) + } + } else { + Ok(()) + } + } +} + +impl BlockedIps { + pub async fn parse(bp: &mut Bootstrap) -> Self { + let mut ips = Self::default(); + + if bp.registry.is_recovery_mode() { + return ips; + } + + let mut expired_blocks = Vec::new(); + let now = now() as i64; + + for ip in bp.list_infallible::().await { + let id = ip.id; + let revision = ip.revision; + let ip = ip.object; + let expires_at = ip + .expires_at + .as_ref() + .map(|dt| dt.timestamp() as u64) + .unwrap_or(u64::MAX); + + if ip.expires_at.as_ref().is_none_or(|ip| ip.timestamp() > now) { + if let Some(ip) = ip.address.try_to_ip() { + ips.blocked_ip_addresses + .insert(IpWithTtl::new(ip, expires_at)); + } else { + ips.blocked_ip_networks + .push(IpWithTtl::new(ip.address, expires_at)); + } + } else { + expired_blocks.push(( + id, + ip.address.clone(), + Object { + inner: ip.into(), + revision, + }, + )); + } + } + + if !expired_blocks.is_empty() { + for (id, _, object) in &expired_blocks { + if let Err(err) = bp + .registry + .write(RegistryWrite::delete_object(*id, object)) + .await + { + trc::error!( + err.details("Failed to delete expired blocked IP from registry.") + .caused_by(trc::location!()) + ); + } + } + trc::event!( + Security(trc::SecurityEvent::IpBlockExpired), + Details = expired_blocks + .into_iter() + .map(|(_, ip, _)| trc::Value::from(ip.into_inner().0)) + .collect::>() + ); + } + + ips.has_blocked_networks = !ips.blocked_ip_networks.is_empty(); + ips + } +} + +impl Hash for IpWithTtl { + fn hash(&self, state: &mut H) { + self.ip.hash(state); + } +} + +impl PartialEq for IpWithTtl { + fn eq(&self, other: &Self) -> bool { + self.ip == other.ip + } +} + +impl Eq for IpWithTtl {} + +impl IpWithTtl { + pub fn new(ip: T, expires_at: u64) -> Self { + Self { ip, expires_at } + } + + pub fn is_expired(&self) -> bool { + self.expires_at <= now() + } +} diff --git a/crates/common/src/network/stream.rs b/crates/common/src/network/stream.rs new file mode 100644 index 0000000..9abdde5 --- /dev/null +++ b/crates/common/src/network/stream.rs @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use proxy_header::io::ProxiedStream; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + net::TcpStream, +}; +use tokio_rustls::server::TlsStream; + +use super::SessionStream; + +impl SessionStream for TcpStream { + fn is_tls(&self) -> bool { + false + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + (Cow::Borrowed(""), Cow::Borrowed("")) + } +} + +impl SessionStream for TlsStream { + fn is_tls(&self) -> bool { + true + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + let (_, conn) = self.get_ref(); + + ( + match conn + .protocol_version() + .unwrap_or(rustls::ProtocolVersion::Unknown(0)) + { + rustls::ProtocolVersion::SSLv2 => "SSLv2", + rustls::ProtocolVersion::SSLv3 => "SSLv3", + rustls::ProtocolVersion::TLSv1_0 => "TLSv1.0", + rustls::ProtocolVersion::TLSv1_1 => "TLSv1.1", + rustls::ProtocolVersion::TLSv1_2 => "TLSv1.2", + rustls::ProtocolVersion::TLSv1_3 => "TLSv1.3", + rustls::ProtocolVersion::DTLSv1_0 => "DTLSv1.0", + rustls::ProtocolVersion::DTLSv1_2 => "DTLSv1.2", + rustls::ProtocolVersion::DTLSv1_3 => "DTLSv1.3", + _ => "unknown", + } + .into(), + match conn.negotiated_cipher_suite() { + Some(rustls::SupportedCipherSuite::Tls13(cs)) => { + cs.common.suite.as_str().unwrap_or("unknown") + } + Some(rustls::SupportedCipherSuite::Tls12(cs)) => { + cs.common.suite.as_str().unwrap_or("unknown") + } + None => "unknown", + } + .into(), + ) + } +} + +impl SessionStream for ProxiedStream { + fn is_tls(&self) -> bool { + self.proxy_header() + .ssl() + .is_some_and(|ssl| ssl.client_ssl()) + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + self.proxy_header() + .ssl() + .map(|ssl| { + ( + ssl.version().unwrap_or("unknown").to_string().into(), + ssl.cipher().unwrap_or("unknown").to_string().into(), + ) + }) + .unwrap_or((Cow::Borrowed("unknown"), Cow::Borrowed("unknown"))) + } +} + +#[derive(Default)] +pub struct NullIo { + pub tx_buf: Vec, +} + +impl AsyncWrite for NullIo { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + self.tx_buf.extend_from_slice(buf); + std::task::Poll::Ready(Ok(buf.len())) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +impl AsyncRead for NullIo { + fn poll_read( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + _buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + unreachable!() + } +} + +impl SessionStream for NullIo { + fn is_tls(&self) -> bool { + true + } + + fn tls_version_and_cipher( + &self, + ) -> ( + std::borrow::Cow<'static, str>, + std::borrow::Cow<'static, str>, + ) { + ( + std::borrow::Cow::Borrowed(""), + std::borrow::Cow::Borrowed(""), + ) + } +} diff --git a/crates/common/src/network/tls.rs b/crates/common/src/network/tls.rs new file mode 100644 index 0000000..3e55a33 --- /dev/null +++ b/crates/common/src/network/tls.rs @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ServerInstance, SessionStream, TcpAcceptor, TcpAcceptorResult, + acme::resolver::{IsTlsAlpnChallenge, build_acme_static_resolver}, +}; +use crate::{Inner, Server}; +use rustls::{ + SupportedProtocolVersion, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, + version::{TLS12, TLS13}, +}; +use std::{ + cmp::Ordering, + fmt::{self, Formatter}, + sync::Arc, +}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio_rustls::{Accept, LazyConfigAcceptor}; + +pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; +pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; + +#[derive(Clone)] +pub struct CertificateResolver { + pub inner: Arc, +} + +impl CertificateResolver { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +impl ResolvesServerCert for CertificateResolver { + fn resolve(&self, hello: ClientHello<'_>) -> Option> { + self.resolve_certificate(hello.server_name()) + } +} + +impl Server { + pub fn resolve_certificate(&self, name: &str) -> Option> { + let certs = self.inner.data.tls_certificates.load(); + + certs + .get(name) + .or_else(|| { + // Try with a wildcard certificate + name.split_once('.') + .and_then(|(_, domain)| certs.get(domain)) + }) + .cloned() + } +} + +impl CertificateResolver { + pub(crate) fn resolve_certificate(&self, name: Option<&str>) -> Option> { + let certs = self.inner.data.tls_certificates.load(); + + name.map_or_else( + || certs.get("*"), + |name| { + certs + .get(name) + .or_else(|| { + // Try with a wildcard certificate + name.split_once('.') + .and_then(|(_, domain)| certs.get(domain)) + }) + .or_else(|| { + trc::event!( + Tls(trc::TlsEvent::CertificateNotFound), + Hostname = name.to_string(), + ); + certs.get("*") + }) + }, + ) + .or_else(|| match certs.len().cmp(&1) { + Ordering::Equal => certs.values().next(), + Ordering::Greater => { + trc::event!( + Tls(trc::TlsEvent::MultipleCertificatesAvailable), + Total = certs.len(), + ); + certs.values().next() + } + Ordering::Less => { + trc::event!( + Tls(trc::TlsEvent::NoCertificatesAvailable), + Total = certs.len(), + ); + self.inner.data.tls_self_signed_cert.as_ref() + } + }) + .cloned() + } +} + +impl TcpAcceptor { + pub async fn accept( + &self, + stream: IO, + enable_acme: Option, + instance: &ServerInstance, + ) -> TcpAcceptorResult + where + IO: SessionStream, + { + match self { + TcpAcceptor::Tls { + config, + acceptor, + implicit, + } if *implicit => match enable_acme { + None => TcpAcceptorResult::Tls(acceptor.accept(stream)), + Some(core) => { + match LazyConfigAcceptor::new(Default::default(), stream).await { + Ok(start_handshake) => { + if core.has_acme_tls_providers() + && start_handshake.client_hello().is_tls_alpn_challenge() + { + let key = match start_handshake.client_hello().server_name() { + Some(domain) => { + let key = core.build_acme_certificate(domain).await; + + trc::event!( + Acme(trc::AcmeEvent::ClientSuppliedSni), + ListenerId = instance.id.clone(), + Domain = domain.to_string(), + Result = key.is_some(), + ); + + key + } + None => { + trc::event!( + Acme(trc::AcmeEvent::ClientMissingSni), + ListenerId = instance.id.clone(), + ); + + None + } + }; + + match start_handshake + .into_stream(build_acme_static_resolver(key)) + .await + { + Ok(mut tls) => { + trc::event!( + Acme(trc::AcmeEvent::TlsAlpnReceived), + ListenerId = instance.id.clone(), + ); + + let _ = tls.shutdown().await; + } + Err(err) => { + trc::event!( + Acme(trc::AcmeEvent::TlsAlpnError), + ListenerId = instance.id.clone(), + Reason = err.to_string(), + ); + } + } + } else { + return TcpAcceptorResult::Tls( + start_handshake.into_stream(config.clone()), + ); + } + } + Err(err) => { + trc::event!( + Tls(trc::TlsEvent::HandshakeError), + ListenerId = instance.id.clone(), + Reason = err.to_string(), + ); + } + } + + TcpAcceptorResult::Close + } + }, + _ => TcpAcceptorResult::Plain(stream), + } + } + + pub fn is_tls(&self) -> bool { + matches!(self, TcpAcceptor::Tls { .. }) + } +} + +impl TcpAcceptorResult +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + pub fn unwrap_tls(self) -> Accept { + match self { + TcpAcceptorResult::Tls(accept) => accept, + _ => panic!("unwrap_tls called on non-TLS acceptor"), + } + } +} + +impl std::fmt::Debug for CertificateResolver { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("CertificateResolver").finish() + } +} diff --git a/crates/common/src/network/webpush.rs b/crates/common/src/network/webpush.rs new file mode 100644 index 0000000..abf49cc --- /dev/null +++ b/crates/common/src/network/webpush.rs @@ -0,0 +1,349 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use p256::{ + SecretKey, + ecdsa::{Signature, SigningKey, signature::Signer}, + pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument}, +}; + +const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60; + +#[derive(Clone)] +pub struct Vapid { + key: VapidKey, + contact: Option, +} + +impl Vapid { + pub fn new(key: VapidKey, contact: Option) -> Self { + Self { key, contact } + } + + pub fn public_key(&self) -> &str { + self.key.public_key() + } + + pub fn authorization(&self, endpoint: &str, now: u64) -> Option { + self.key + .authorization(endpoint, self.contact.as_deref(), now) + } +} + +#[derive(Clone)] +pub struct VapidKey { + signing_key: SigningKey, + public_key: String, +} + +impl VapidKey { + pub fn from_pkcs8_pem(pem: &str) -> Result { + let pem = pem.trim_start_matches('\u{feff}').trim(); + + if let Ok(key) = SigningKey::from_pkcs8_pem(pem) { + return Ok(Self::from_signing_key(key)); + } + if let Ok(secret) = SecretKey::from_sec1_pem(pem) { + return Ok(Self::from_signing_key(secret.into())); + } + if let Some(secret) = secret_key_from_explicit_params(pem) { + return Ok(Self::from_signing_key(secret.into())); + } + + Err(SigningKey::from_pkcs8_pem(pem) + .err() + .map(|err| { + format!( + "{err}. Re-encode the key as named-curve PKCS#8, \ + e.g. `openssl pkey -in key.pem -out key_pkcs8.pem`." + ) + }) + .unwrap_or_else(|| "unsupported VAPID key encoding".to_string())) + } + + fn from_signing_key(signing_key: SigningKey) -> Self { + let public_key = URL_SAFE_NO_PAD.encode( + signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes(), + ); + Self { + signing_key, + public_key, + } + } + + pub fn public_key(&self) -> &str { + &self.public_key + } + + pub fn authorization(&self, endpoint: &str, contact: Option<&str>, now: u64) -> Option { + let mut claims = serde_json::Map::new(); + claims.insert("aud".into(), endpoint_origin(endpoint)?.into()); + claims.insert("exp".into(), (now + VAPID_TOKEN_TTL).into()); + if let Some(sub) = contact { + claims.insert("sub".into(), sub.into()); + } + + let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).ok()?); + let signing_input = format!("{header}.{payload}"); + let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); + + Some(format!( + "vapid t={signing_input}.{}, k={}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()), + self.public_key + )) + } +} + +fn endpoint_origin(url: &str) -> Option { + let (scheme, rest) = url.split_once("://")?; + let scheme = scheme.to_ascii_lowercase(); + let authority = rest.split(['/', '?', '#']).next()?; + let authority = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + if authority.is_empty() { + return None; + } + + let (host, port) = if let Some(rest) = authority.strip_prefix('[') { + let (addr, tail) = rest.split_once(']')?; + ( + format!("[{}]", addr.to_ascii_lowercase()), + tail.strip_prefix(':').filter(|port| !port.is_empty()), + ) + } else if let Some((host, port)) = authority.rsplit_once(':') { + ( + host.to_ascii_lowercase(), + Some(port).filter(|p| !p.is_empty()), + ) + } else { + (authority.to_ascii_lowercase(), None) + }; + + match port { + Some(port) + if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) => + { + Some(format!("{scheme}://{host}:{port}")) + } + _ => Some(format!("{scheme}://{host}")), + } +} + +pub fn normalize_contact(contact: &str) -> Option { + let contact = contact.trim(); + + match contact.split_once(':') { + Some((scheme, _)) if scheme.eq_ignore_ascii_case("mailto") => Some(contact.to_string()), + Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => Some(contact.to_string()), + Some(_) => None, + None if contact.contains('@') => Some(format!("mailto:{contact}")), + None => None, + } +} + +pub fn generate_pkcs8_pem() -> Result { + use p256::elliptic_curve::rand_core::OsRng; + use p256::pkcs8::{EncodePrivateKey, LineEnding}; + + SigningKey::random(&mut OsRng) + .to_pkcs8_pem(LineEnding::LF) + .map(|pem| pem.to_string()) + .map_err(|err| err.to_string()) +} + +fn secret_key_from_explicit_params(pem: &str) -> Option { + let (_, document) = SecretDocument::from_pem(pem).ok()?; + let private_key_info = PrivateKeyInfo::try_from(document.as_bytes()).ok()?; + SecretKey::from_sec1_der(private_key_info.private_key).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + + fn test_key() -> VapidKey { + VapidKey::from_pkcs8_pem(&generate_pkcs8_pem().unwrap()).unwrap() + } + + #[test] + fn generated_key_round_trips_through_pkcs8_pem() { + let pem = generate_pkcs8_pem().unwrap(); + assert_eq!( + VapidKey::from_pkcs8_pem(&pem).unwrap().public_key(), + VapidKey::from_pkcs8_pem(&pem).unwrap().public_key() + ); + } + + #[test] + fn endpoint_origin_normalizes() { + assert_eq!( + endpoint_origin("HTTPS://Push.Example.COM:443/push?x=1").unwrap(), + "https://push.example.com" + ); + assert_eq!( + endpoint_origin("https://127.0.0.1:19000/push").unwrap(), + "https://127.0.0.1:19000" + ); + assert_eq!( + endpoint_origin("https://user:pass@fcm.googleapis.com/fcm/send/x").unwrap(), + "https://fcm.googleapis.com" + ); + assert_eq!( + endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(), + "http://[2001:db8::1]" + ); + assert!(endpoint_origin("not-a-url").is_none()); + } + + #[test] + fn authorization_signs_a_verifiable_es256_token() { + let key = test_key(); + let now = 1_700_000_000; + let header = key + .authorization( + "https://push.example.com/push/abc?token=1", + Some("mailto:admin@example.org"), + now, + ) + .unwrap(); + + let (token, advertised_key) = header + .strip_prefix("vapid ") + .and_then(|rest| rest.split_once(", ")) + .unwrap(); + let jwt = token.strip_prefix("t=").unwrap(); + assert_eq!(advertised_key.strip_prefix("k=").unwrap(), key.public_key()); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3); + + let verifying_key = + VerifyingKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(key.public_key()).unwrap()) + .unwrap(); + let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap(); + verifying_key + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) + .unwrap(); + + assert_eq!( + URL_SAFE_NO_PAD.decode(parts[0]).unwrap(), + br#"{"typ":"JWT","alg":"ES256"}"# + ); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://push.example.com"); + assert_eq!(claims["sub"], "mailto:admin@example.org"); + assert_eq!(claims["exp"], now + VAPID_TOKEN_TTL); + } + + const SEC1_PEM: &str = "-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIP4Zv7be5hDH0x4ur6ditW+whzyZBXK1Vyjn6aIDo0jhoAoGCCqGSM49 +AwEHoUQDQgAEVc4PXr+z61s9/dIas44+S0Nza3gm1UW/avddp99dUsEi3JV0H4Yk +1yfqVJ/O9KPvQ69uMAY0t3A5lx/GvOOZfg== +-----END EC PRIVATE KEY-----"; + + const PKCS8_NAMED_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/hm/tt7mEMfTHi6v +p2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3NreCbV +Rb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+ +-----END PRIVATE KEY-----"; + + const PKCS8_EXPLICIT_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIIBeQIBADCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAAB +AAAAAAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA +///////////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMV +AMSdNgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg +9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8A +AAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBBG0wawIBAQQg/hm/tt7mEMfT +Hi6vp2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3Nr +eCbVRb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+ +-----END PRIVATE KEY-----"; + + const PKCS8_P384_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCUx+yT22yGHP9q+Y1y +UedDkevSvPaUuSPH8Q4FJBdYKKLqX4a5VdBIOonKPC4Yj7yhZANiAAQPRBsMOJy/ +B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0 +1kt0wbEvKr7mPM/R8XS8dX0xYC58CXHqBsTM0piQN2R7kqWDJ5i4OjE= +-----END PRIVATE KEY-----"; + + #[test] + fn accepts_equivalent_p256_encodings() { + let named = VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM).unwrap(); + let sec1 = VapidKey::from_pkcs8_pem(SEC1_PEM).unwrap(); + let explicit = VapidKey::from_pkcs8_pem(PKCS8_EXPLICIT_PEM).unwrap(); + + assert_eq!(named.public_key(), sec1.public_key()); + assert_eq!(named.public_key(), explicit.public_key()); + } + + #[test] + fn accepts_pem_with_leading_bom_and_whitespace() { + let dirty = format!("\u{feff} \n{PKCS8_NAMED_PEM}\n "); + assert_eq!( + VapidKey::from_pkcs8_pem(&dirty).unwrap().public_key(), + VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM) + .unwrap() + .public_key() + ); + } + + #[test] + fn rejects_wrong_curve_key() { + assert!(VapidKey::from_pkcs8_pem(PKCS8_P384_PEM).is_err()); + } + + #[test] + fn rejects_garbage_with_actionable_error() { + let err = VapidKey::from_pkcs8_pem("not a key").err().unwrap(); + assert!(err.contains("openssl pkey"), "{err}"); + } + + #[test] + fn contact_is_normalized_to_a_uri() { + for (input, expected) in [ + ("hello@stalw.art", Some("mailto:hello@stalw.art")), + (" hello@stalw.art ", Some("mailto:hello@stalw.art")), + ("mailto:hello@stalw.art", Some("mailto:hello@stalw.art")), + ("MAILTO:hello@stalw.art", Some("MAILTO:hello@stalw.art")), + ( + "https://stalw.art/contact", + Some("https://stalw.art/contact"), + ), + ("stalw.art", None), + ("http://stalw.art", None), + ("tel:+123456789", None), + ("", None), + ] { + assert_eq!( + normalize_contact(input).as_deref(), + expected, + "unexpected normalization of {input:?}" + ); + } + } + + #[test] + fn authorization_omits_subject_when_no_contact() { + let key = test_key(); + let header = key + .authorization("https://fcm.googleapis.com/fcm/send/xyz", None, 0) + .unwrap(); + let payload = header.split('.').nth(1).unwrap(); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://fcm.googleapis.com"); + assert!(claims.get("sub").is_none()); + } +} diff --git a/crates/common/src/scripts/functions/array.rs b/crates/common/src/scripts/functions/array.rs new file mode 100644 index 0000000..5d854f0 --- /dev/null +++ b/crates/common/src/scripts/functions/array.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::{HashMap, HashSet}; + +use sieve::{Context, runtime::Variable}; + +pub fn fn_count<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::Array(a) => a.len(), + v => { + if !v.is_empty() { + 1 + } else { + 0 + } + } + } + .into() +} + +pub fn fn_sort<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let is_asc = v[1].to_bool(); + let mut arr = (*v[0].to_array()).clone(); + if is_asc { + arr.sort_unstable(); + } else { + arr.sort_unstable_by(|a, b| b.cmp(a)); + } + arr.into() +} + +pub fn fn_dedup<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let arr = v[0].to_array(); + let mut result = Vec::with_capacity(arr.len()); + + for item in arr.iter() { + if !result.contains(item) { + result.push(item.clone()); + } + } + + result.into() +} + +pub fn fn_cosine_similarity<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let mut word_freq: HashMap = HashMap::new(); + + for (idx, var) in v.into_iter().enumerate() { + match var { + Variable::Array(l) => { + for item in l.iter() { + word_freq.entry(item.clone()).or_insert([0, 0])[idx] += 1; + } + } + _ => { + for char in var.to_string().chars() { + word_freq.entry(char.to_string().into()).or_insert([0, 0])[idx] += 1; + } + } + } + } + + let mut dot_product = 0; + let mut magnitude_a = 0; + let mut magnitude_b = 0; + + for count in word_freq.values() { + dot_product += count[0] * count[1]; + magnitude_a += count[0] * count[0]; + magnitude_b += count[1] * count[1]; + } + + if magnitude_a != 0 && magnitude_b != 0 { + dot_product as f64 / (magnitude_a as f64).sqrt() / (magnitude_b as f64).sqrt() + } else { + 0.0 + } + .into() +} + +pub fn cosine_similarity(a: &[&str], b: &[&str]) -> f64 { + let mut word_freq: HashMap<&str, [u32; 2]> = HashMap::new(); + + for (idx, items) in [a, b].into_iter().enumerate() { + for item in items { + word_freq.entry(item).or_insert([0, 0])[idx] += 1; + } + } + + let mut dot_product = 0; + let mut magnitude_a = 0; + let mut magnitude_b = 0; + + for count in word_freq.values() { + dot_product += count[0] * count[1]; + magnitude_a += count[0] * count[0]; + magnitude_b += count[1] * count[1]; + } + + if magnitude_a != 0 && magnitude_b != 0 { + dot_product as f64 / (magnitude_a as f64).sqrt() / (magnitude_b as f64).sqrt() + } else { + 0.0 + } +} + +pub fn fn_jaccard_similarity<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let mut word_freq = [HashSet::new(), HashSet::new()]; + + for (idx, var) in v.into_iter().enumerate() { + match var { + Variable::Array(l) => { + for item in l.iter() { + word_freq[idx].insert(item.clone()); + } + } + _ => { + for char in var.to_string().chars() { + word_freq[idx].insert(char.to_string().into()); + } + } + } + } + + let intersection_size = word_freq[0].intersection(&word_freq[1]).count(); + let union_size = word_freq[0].union(&word_freq[1]).count(); + + if union_size != 0 { + intersection_size as f64 / union_size as f64 + } else { + 0.0 + } + .into() +} + +pub fn fn_is_intersect<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match (&v[0], &v[1]) { + (Variable::Array(a), Variable::Array(b)) => a.iter().any(|x| b.contains(x)), + (Variable::Array(a), item) | (item, Variable::Array(a)) => a.contains(item), + _ => false, + } + .into() +} + +pub fn fn_winnow<'x>(_: &'x Context<'x>, mut v: Vec) -> Variable { + match v.remove(0) { + Variable::Array(a) => a + .iter() + .filter(|i| !i.is_empty()) + .cloned() + .collect::>() + .into(), + v => v, + } +} diff --git a/crates/common/src/scripts/functions/email.rs b/crates/common/src/scripts/functions/email.rs new file mode 100644 index 0000000..6feac10 --- /dev/null +++ b/crates/common/src/scripts/functions/email.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::{Context, runtime::Variable}; + +use super::ApplyString; + +pub fn fn_is_email<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let mut last_ch = 0; + let mut in_quote = false; + let mut at_count = 0; + let mut dot_count = 0; + let mut lp_len = 0; + let mut value = 0; + + for ch in v[0].to_string().bytes() { + match ch { + b'0'..=b'9' + | b'a'..=b'z' + | b'A'..=b'Z' + | b'!' + | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'/' + | b'=' + | b'?' + | b'^' + | b'_' + | b'`' + | b'{' + | b'|' + | b'}' + | b'~' + | 0x7f..=u8::MAX => { + value += 1; + } + b'.' if !in_quote => { + if last_ch != b'.' && last_ch != b'@' && value != 0 { + value += 1; + if at_count == 1 { + dot_count += 1; + } + } else { + return false.into(); + } + } + b'@' if !in_quote => { + at_count += 1; + lp_len = value; + value = 0; + } + b'>' | b':' | b',' | b' ' if in_quote => { + value += 1; + } + b'\"' if !in_quote || last_ch != b'\\' => { + in_quote = !in_quote; + } + b'\\' if in_quote && last_ch != b'\\' => (), + _ => { + if !in_quote { + return false.into(); + } + } + } + + last_ch = ch; + } + + (at_count == 1 && dot_count > 0 && lp_len > 0 && value > 0).into() +} + +pub fn fn_email_part<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| { + s.rsplit_once('@') + .map(|(u, d)| match v[1].to_string().as_ref() { + "local" => Variable::from(u.trim()), + "domain" => Variable::from(d.trim()), + _ => Variable::default(), + }) + .unwrap_or_default() + }) +} diff --git a/crates/common/src/scripts/functions/header.rs b/crates/common/src/scripts/functions/header.rs new file mode 100644 index 0000000..00235ad --- /dev/null +++ b/crates/common/src/scripts/functions/header.rs @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_parser::{HeaderName, HeaderValue, MimeHeaders, parsers::fields::thread::thread_name}; +use sieve::{Context, compiler::ReceivedPart, runtime::Variable}; + +use super::ApplyString; + +pub fn fn_received_part<'x>(ctx: &'x Context<'x>, v: Vec) -> Variable { + if let (Ok(part), Some(HeaderValue::Received(rcvd))) = ( + ReceivedPart::try_from(v[1].to_string().as_ref()), + ctx.message() + .part(ctx.part()) + .and_then(|p| { + p.headers + .iter() + .filter(|h| h.name == HeaderName::Received) + .nth((v[0].to_integer() as usize).saturating_sub(1)) + }) + .map(|h| &h.value), + ) { + part.eval(rcvd).unwrap_or_default() + } else { + Variable::default() + } +} + +pub fn fn_is_encoding_problem<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .map(|p| p.is_encoding_problem) + .unwrap_or_default() + .into() +} + +pub fn fn_is_attachment<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + ctx.message().attachments.contains(&ctx.part()).into() +} + +pub fn fn_is_body<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + (ctx.message().text_body.contains(&ctx.part()) || ctx.message().html_body.contains(&ctx.part())) + .into() +} + +pub fn fn_attachment_name<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .and_then(|p| p.attachment_name()) + .unwrap_or_default() + .into() +} + +pub fn fn_mime_part_len<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .map(|p| p.len()) + .unwrap_or_default() + .into() +} + +pub fn fn_thread_name<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| thread_name(s).into()) +} + +pub fn fn_is_header_utf8_valid<'x>(ctx: &'x Context<'x>, v: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .map(|p| { + let raw = ctx.message().raw_message(); + let mut is_valid = true; + if let Some(header_name) = HeaderName::parse(v[0].to_string().as_ref()) { + for header in &p.headers { + if header.name == header_name + && raw + .get(header.offset_start() as usize..header.offset_end() as usize) + .and_then(|raw| std::str::from_utf8(raw).ok()) + .is_none() + { + is_valid = false; + break; + } + } + } else { + is_valid = raw + .get(p.raw_header_offset() as usize..p.raw_body_offset() as usize) + .and_then(|raw| std::str::from_utf8(raw).ok()) + .is_some(); + } + + Variable::from(is_valid) + }) + .unwrap_or(Variable::Integer(1)) +} diff --git a/crates/common/src/scripts/functions/image.rs b/crates/common/src/scripts/functions/image.rs new file mode 100644 index 0000000..49af80d --- /dev/null +++ b/crates/common/src/scripts/functions/image.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::{Context, runtime::Variable}; + +pub fn fn_img_metadata<'x>(ctx: &'x Context<'x>, v: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .map(|p| p.contents()) + .and_then(|bytes| { + let arg = v[1].to_string(); + match arg.as_ref() { + "type" => imagesize::image_type(bytes).ok().map(|t| { + Variable::from(match t { + imagesize::ImageType::Aseprite => "aseprite", + imagesize::ImageType::Bmp => "bmp", + imagesize::ImageType::Dds(_) => "dds", + imagesize::ImageType::Exr => "exr", + imagesize::ImageType::Farbfeld => "farbfeld", + imagesize::ImageType::Gif => "gif", + imagesize::ImageType::Hdr => "hdr", + imagesize::ImageType::Heif(_) => "heif", + imagesize::ImageType::Ico => "ico", + imagesize::ImageType::Jpeg => "jpeg", + imagesize::ImageType::Jxl => "jxl", + imagesize::ImageType::Ktx2 => "ktx2", + imagesize::ImageType::Png => "png", + imagesize::ImageType::Pnm => "pnm", + imagesize::ImageType::Psd => "psd", + imagesize::ImageType::Qoi => "qoi", + imagesize::ImageType::Tga => "tga", + imagesize::ImageType::Tiff => "tiff", + imagesize::ImageType::Vtf => "vtf", + imagesize::ImageType::Webp => "webp", + imagesize::ImageType::Ilbm => "ilbm", + _ => "unknown", + }) + }), + "width" => imagesize::blob_size(bytes) + .ok() + .map(|s| Variable::Integer(s.width as i64)), + "height" => imagesize::blob_size(bytes) + .ok() + .map(|s| Variable::Integer(s.height as i64)), + "area" => imagesize::blob_size(bytes) + .ok() + .map(|s| Variable::Integer(s.width.saturating_mul(s.height) as i64)), + "dimension" => imagesize::blob_size(bytes) + .ok() + .map(|s| Variable::Integer(s.width.saturating_add(s.height) as i64)), + _ => None, + } + }) + .unwrap_or_default() +} diff --git a/crates/common/src/scripts/functions/misc.rs b/crates/common/src/scripts/functions/misc.rs new file mode 100644 index 0000000..723d337 --- /dev/null +++ b/crates/common/src/scripts/functions/misc.rs @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{net::IpAddr, str::FromStr}; + +use mail_auth::common::resolver::ToReverseName; +use registry::types::ipmask::IpAddrOrMask; +use sha1::Sha1; +use sha2::{Sha256, Sha512}; +use sieve::{Context, runtime::Variable}; +use utils::HexEncode; + +use super::ApplyString; + +pub fn fn_is_empty<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.is_empty(), + Variable::Integer(_) | Variable::Float(_) => false, + Variable::Array(a) => a.is_empty(), + } + .into() +} + +pub fn fn_is_number<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() +} + +pub fn fn_is_ip_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string().parse::().is_ok().into() +} + +pub fn fn_is_ipv4_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V4(_))) + .into() +} + +pub fn fn_is_ipv6_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V6(_))) + .into() +} + +pub fn fn_is_ip_in_cidr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let Ok(ip) = v[0].to_string().parse::() else { + return false.into(); + }; + IpAddrOrMask::from_str(v[1].to_string().as_ref()) + .map(|mask| mask.matches(&ip)) + .unwrap_or(false) + .into() +} + +pub fn fn_ip_reverse_name<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .parse::() + .map(|ip| ip.to_reverse_name()) + .unwrap_or_default() + .into() +} + +pub fn fn_detect_file_type<'x>(ctx: &'x Context<'x>, v: Vec) -> Variable { + ctx.message() + .part(ctx.part()) + .and_then(|p| infer::get(p.contents())) + .map(|t| { + Variable::from( + if v[0].to_string() != "ext" { + t.mime_type() + } else { + t.extension() + } + .to_string(), + ) + }) + .unwrap_or_default() +} + +pub fn fn_hash<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + use sha1::Digest; + let hash = v[1].to_string(); + + v[0].transform(|value| match hash.as_ref() { + "md5" => format!("{:x}", md5::compute(value.as_bytes())).into(), + "sha1" => { + let mut hasher = Sha1::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().into() + } + "sha256" => { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().into() + } + "sha512" => { + let mut hasher = Sha512::new(); + hasher.update(value.as_bytes()); + hasher.finalize().hex_encode().into() + } + _ => Variable::default(), + }) +} + +pub fn fn_get_var_names<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { + Variable::Array( + ctx.global_variable_names() + .map(|v| Variable::from(v.to_uppercase())) + .collect::>() + .into(), + ) +} diff --git a/crates/common/src/scripts/functions/mod.rs b/crates/common/src/scripts/functions/mod.rs new file mode 100644 index 0000000..2312e56 --- /dev/null +++ b/crates/common/src/scripts/functions/mod.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod array; +mod email; +mod header; +pub mod image; +pub mod misc; +pub mod text; +pub mod unicode; +pub mod url; + +use sieve::{FunctionMap, runtime::Variable}; + +use self::{array::*, email::*, header::*, image::*, misc::*, text::*, unicode::*, url::*}; + +pub fn register_functions_trusted() -> FunctionMap { + FunctionMap::new() + .with_function("trim", fn_trim) + .with_function("trim_start", fn_trim_start) + .with_function("trim_end", fn_trim_end) + .with_function("len", fn_len) + .with_function("count", fn_count) + .with_function("is_empty", fn_is_empty) + .with_function("is_number", fn_is_number) + .with_function("is_ascii", fn_is_ascii) + .with_function("to_lowercase", fn_to_lowercase) + .with_function("to_uppercase", fn_to_uppercase) + .with_function("detect_language", fn_detect_language) + .with_function("is_email", fn_is_email) + .with_function("thread_name", fn_thread_name) + .with_function("html_to_text", fn_html_to_text) + .with_function("is_uppercase", fn_is_uppercase) + .with_function("is_lowercase", fn_is_lowercase) + .with_function("has_digits", fn_has_digits) + .with_function("count_spaces", fn_count_spaces) + .with_function("count_uppercase", fn_count_uppercase) + .with_function("count_lowercase", fn_count_lowercase) + .with_function("count_chars", fn_count_chars) + .with_function("dedup", fn_dedup) + .with_function("lines", fn_lines) + .with_function("is_header_utf8_valid", fn_is_header_utf8_valid) + .with_function("img_metadata", fn_img_metadata) + .with_function("is_ip_addr", fn_is_ip_addr) + .with_function("is_ipv4_addr", fn_is_ipv4_addr) + .with_function("is_ipv6_addr", fn_is_ipv6_addr) + .with_function("ip_reverse_name", fn_ip_reverse_name) + .with_function_args("is_ip_in_cidr", fn_is_ip_in_cidr, 2) + .with_function("winnow", fn_winnow) + .with_function("has_zwsp", fn_has_zwsp) + .with_function("has_obscured", fn_has_obscured) + .with_function("is_mixed_charset", fn_is_mixed_charset) + .with_function("puny_decode", fn_puny_decode) + .with_function("unicode_skeleton", fn_unicode_skeleton) + .with_function("cure_text", fn_cure_text) + .with_function("detect_file_type", fn_detect_file_type) + .with_function_args("sort", fn_sort, 2) + .with_function_args("email_part", fn_email_part, 2) + .with_function_args("eq_ignore_case", fn_eq_ignore_case, 2) + .with_function_args("contains", fn_contains, 2) + .with_function_args("contains_ignore_case", fn_contains_ignore_case, 2) + .with_function_args("starts_with", fn_starts_with, 2) + .with_function_args("ends_with", fn_ends_with, 2) + .with_function_args("received_part", fn_received_part, 2) + .with_function_args("cosine_similarity", fn_cosine_similarity, 2) + .with_function_args("jaccard_similarity", fn_jaccard_similarity, 2) + .with_function_args("levenshtein_distance", fn_levenshtein_distance, 2) + .with_function_args("uri_part", fn_uri_part, 2) + .with_function_args("substring", fn_substring, 3) + .with_function_args("split", fn_split, 2) + .with_function_args("rsplit", fn_rsplit, 2) + .with_function_args("split_once", fn_split_once, 2) + .with_function_args("rsplit_once", fn_rsplit_once, 2) + .with_function_args("split_n", fn_split_n, 3) + .with_function_args("strip_prefix", fn_strip_prefix, 2) + .with_function_args("strip_suffix", fn_strip_suffix, 2) + .with_function_args("is_intersect", fn_is_intersect, 2) + .with_function_args("hash", fn_hash, 2) + .with_function_no_args("is_encoding_problem", fn_is_encoding_problem) + .with_function_no_args("is_attachment", fn_is_attachment) + .with_function_no_args("is_body", fn_is_body) + .with_function_no_args("var_names", fn_get_var_names) + .with_function_no_args("attachment_name", fn_attachment_name) + .with_function_no_args("mime_part_len", fn_mime_part_len) +} + +pub fn register_functions_untrusted() -> FunctionMap { + FunctionMap::new() + .with_function("trim", fn_trim) + .with_function("trim_start", fn_trim_start) + .with_function("trim_end", fn_trim_end) + .with_function("len", fn_len) + .with_function("count", fn_count) + .with_function("is_empty", fn_is_empty) + .with_function("is_number", fn_is_number) + .with_function("is_ascii", fn_is_ascii) + .with_function("to_lowercase", fn_to_lowercase) + .with_function("to_uppercase", fn_to_uppercase) + .with_function("is_email", fn_is_email) + .with_function("thread_name", fn_thread_name) + .with_function("html_to_text", fn_html_to_text) + .with_function("is_uppercase", fn_is_uppercase) + .with_function("is_lowercase", fn_is_lowercase) + .with_function("has_digits", fn_has_digits) + .with_function("count_spaces", fn_count_spaces) + .with_function("count_uppercase", fn_count_uppercase) + .with_function("count_lowercase", fn_count_lowercase) + .with_function("count_chars", fn_count_chars) + .with_function("dedup", fn_dedup) + .with_function("lines", fn_lines) + .with_function("is_ip_addr", fn_is_ip_addr) + .with_function("is_ipv4_addr", fn_is_ipv4_addr) + .with_function("is_ipv6_addr", fn_is_ipv6_addr) + .with_function("winnow", fn_winnow) + .with_function_args("sort", fn_sort, 2) + .with_function_args("email_part", fn_email_part, 2) + .with_function_args("eq_ignore_case", fn_eq_ignore_case, 2) + .with_function_args("contains", fn_contains, 2) + .with_function_args("contains_ignore_case", fn_contains_ignore_case, 2) + .with_function_args("starts_with", fn_starts_with, 2) + .with_function_args("ends_with", fn_ends_with, 2) + .with_function_args("uri_part", fn_uri_part, 2) + .with_function_args("substring", fn_substring, 3) + .with_function_args("split", fn_split, 2) + .with_function_args("rsplit", fn_rsplit, 2) + .with_function_args("split_once", fn_split_once, 2) + .with_function_args("rsplit_once", fn_rsplit_once, 2) + .with_function_args("split_n", fn_split_n, 3) + .with_function_args("strip_prefix", fn_strip_prefix, 2) + .with_function_args("strip_suffix", fn_strip_suffix, 2) + .with_function_args("is_intersect", fn_is_intersect, 2) +} + +pub trait ApplyString<'x> { + fn transform(&self, f: impl Fn(&'_ str) -> Variable) -> Variable; +} + +impl ApplyString<'_> for Variable { + fn transform(&self, f: impl Fn(&'_ str) -> Variable) -> Variable { + match self { + Variable::String(s) => f(s), + Variable::Array(list) => list + .iter() + .map(|v| match v { + Variable::String(s) => f(s), + v => f(v.to_string().as_ref()), + }) + .collect::>() + .into(), + v => f(v.to_string().as_ref()), + } + } +} diff --git a/crates/common/src/scripts/functions/text.rs b/crates/common/src/scripts/functions/text.rs new file mode 100644 index 0000000..fb411fe --- /dev/null +++ b/crates/common/src/scripts/functions/text.rs @@ -0,0 +1,317 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_parser::decoders::html::html_to_text; +use sieve::{Context, runtime::Variable}; + +use super::ApplyString; + +pub fn fn_trim<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| Variable::from(s.trim())) +} + +pub fn fn_trim_end<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| Variable::from(s.trim_end())) +} + +pub fn fn_trim_start<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| Variable::from(s.trim_start())) +} + +pub fn fn_len<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.len(), + Variable::Array(a) => a.len(), + v => v.to_string().len(), + } + .into() +} + +pub fn fn_to_lowercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| Variable::from(s.to_lowercase())) +} + +pub fn fn_to_uppercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| Variable::from(s.to_uppercase())) +} + +pub fn fn_is_uppercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| { + s.chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_uppercase()) + .into() + }) +} + +pub fn fn_is_lowercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| { + s.chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_lowercase()) + .into() + }) +} + +pub fn fn_has_digits<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|s| s.chars().any(|c| c.is_ascii_digit()).into()) +} + +pub fn tokenize_words(v: &Variable) -> Variable { + v.to_string() + .split_whitespace() + .filter(|word| word.chars().all(|c| c.is_alphanumeric())) + .map(|word| Variable::from(word.to_string())) + .collect::>() + .into() +} + +pub fn fn_count_spaces<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .as_ref() + .chars() + .filter(|c| c.is_whitespace()) + .count() + .into() +} + +pub fn fn_count_uppercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .as_ref() + .chars() + .filter(|c| c.is_alphabetic() && c.is_uppercase()) + .count() + .into() +} + +pub fn fn_count_lowercase<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .as_ref() + .chars() + .filter(|c| c.is_alphabetic() && c.is_lowercase()) + .count() + .into() +} + +pub fn fn_count_chars<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string().as_ref().chars().count().into() +} + +pub fn fn_eq_ignore_case<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .eq_ignore_ascii_case(v[1].to_string().as_ref()) + .into() +} + +pub fn fn_contains<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.contains(v[1].to_string().as_ref()), + Variable::Array(arr) => arr.contains(&v[1]), + val => val.to_string().contains(v[1].to_string().as_ref()), + } + .into() +} + +pub fn fn_contains_ignore_case<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let needle = v[1].to_string(); + match &v[0] { + Variable::String(s) => s.to_lowercase().contains(&needle.to_lowercase()), + Variable::Array(arr) => arr.iter().any(|v| match v { + Variable::String(s) => s.eq_ignore_ascii_case(needle.as_ref()), + _ => false, + }), + val => val.to_string().contains(needle.as_ref()), + } + .into() +} + +pub fn fn_starts_with<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .starts_with(v[1].to_string().as_ref()) + .into() +} + +pub fn fn_ends_with<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string().ends_with(v[1].to_string().as_ref()).into() +} + +pub fn fn_lines<'x>(_: &'x Context<'x>, mut v: Vec) -> Variable { + match v.remove(0) { + Variable::String(s) => s + .lines() + .map(|s| Variable::from(s.to_string())) + .collect::>() + .into(), + val => val, + } +} + +pub fn fn_substring<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .chars() + .skip(v[1].to_usize()) + .take(v[2].to_usize()) + .collect::() + .into() +} + +pub fn fn_strip_prefix<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let prefix = v[1].to_string(); + v[0].transform(|s| { + s.strip_prefix(prefix.as_ref()) + .map(Variable::from) + .unwrap_or_default() + }) +} + +pub fn fn_strip_suffix<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let suffix = v[1].to_string(); + v[0].transform(|s| { + s.strip_suffix(suffix.as_ref()) + .map(Variable::from) + .unwrap_or_default() + }) +} + +pub fn fn_split<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .split(v[1].to_string().as_ref()) + .map(|s| Variable::from(s.to_string())) + .collect::>() + .into() +} + +pub fn fn_rsplit<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .rsplit(v[1].to_string().as_ref()) + .map(|s| Variable::from(s.to_string())) + .collect::>() + .into() +} + +pub fn fn_split_n<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let value = v[0].to_string(); + let arg = v[1].to_string(); + let num = v[2].to_integer() as usize; + let mut result = Vec::new(); + + let mut s = value.as_ref(); + for _ in 0..num { + if let Some((a, b)) = s.split_once(arg.as_ref()) { + result.push(Variable::from(a.to_string())); + s = b; + } else { + break; + } + } + result.push(Variable::from(s.to_string())); + result.into() +} + +pub fn fn_split_once<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .split_once(v[1].to_string().as_ref()) + .map(|(a, b)| { + Variable::Array( + vec![Variable::from(a.to_string()), Variable::from(b.to_string())].into(), + ) + }) + .unwrap_or_default() +} + +pub fn fn_rsplit_once<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .rsplit_once(v[1].to_string().as_ref()) + .map(|(a, b)| { + Variable::Array( + vec![Variable::from(a.to_string()), Variable::from(b.to_string())].into(), + ) + }) + .unwrap_or_default() +} + +/** + * `levenshtein-rs` - levenshtein + * + * MIT licensed. + * + * Copyright (c) 2016 Titus Wormer + */ +pub fn fn_levenshtein_distance<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let a = v[0].to_string(); + let b = v[1].to_string(); + + levenshtein_distance(a.as_ref(), b.as_ref()).into() +} + +pub fn levenshtein_distance(a: &str, b: &str) -> usize { + let mut result = 0; + + /* Shortcut optimizations / degenerate cases. */ + if a == b { + return result; + } + + let length_a = a.chars().count(); + let length_b = b.chars().count(); + + if length_a == 0 { + return length_b; + } else if length_b == 0 { + return length_a; + } + + /* Initialize the vector. + * + * This is why it’s fast, normally a matrix is used, + * here we use a single vector. */ + let mut cache: Vec = (1..).take(length_a).collect(); + let mut distance_a; + let mut distance_b; + + /* Loop. */ + for (index_b, code_b) in b.chars().enumerate() { + result = index_b; + distance_a = index_b; + + for (index_a, code_a) in a.chars().enumerate() { + distance_b = if code_a == code_b { + distance_a + } else { + distance_a + 1 + }; + + distance_a = cache[index_a]; + + result = if distance_a > result { + if distance_b > result { + result + 1 + } else { + distance_b + } + } else if distance_b > distance_a { + distance_a + 1 + } else { + distance_b + }; + + cache[index_a] = result; + } + } + + result +} + +pub fn fn_detect_language<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + whatlang::detect_lang(v[0].to_string().as_ref()) + .map(|l| l.code()) + .unwrap_or("unknown") + .into() +} + +pub fn fn_html_to_text<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + html_to_text(v[0].to_string().as_ref()).into() +} diff --git a/crates/common/src/scripts/functions/unicode.rs b/crates/common/src/scripts/functions/unicode.rs new file mode 100644 index 0000000..90e9d11 --- /dev/null +++ b/crates/common/src/scripts/functions/unicode.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::{Context, runtime::Variable}; + +use crate::scripts::IsMixedCharset; + +pub fn fn_is_ascii<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.is_ascii(), + Variable::Integer(_) | Variable::Float(_) => true, + Variable::Array(a) => a.iter().all(|v| match v { + Variable::String(s) => s.is_ascii(), + _ => true, + }), + } + .into() +} + +pub fn fn_has_zwsp<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.chars().any(|c| c.is_zwsp()), + Variable::Array(a) => a.iter().any(|v| match v { + Variable::String(s) => s.chars().any(|c| c.is_zwsp()), + _ => true, + }), + Variable::Integer(_) | Variable::Float(_) => false, + } + .into() +} + +pub fn fn_has_obscured<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + match &v[0] { + Variable::String(s) => s.chars().any(|c| c.is_obscured()), + Variable::Array(a) => a.iter().any(|v| match v { + Variable::String(s) => s.chars().any(|c| c.is_obscured()), + _ => true, + }), + Variable::Integer(_) | Variable::Float(_) => false, + } + .into() +} + +pub trait CharUtils { + fn is_zwsp(&self) -> bool; + fn is_obscured(&self) -> bool; +} + +impl CharUtils for char { + fn is_zwsp(&self) -> bool { + matches!( + self, + '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}' + ) + } + + fn is_obscured(&self) -> bool { + matches!( + self, + '\u{200B}'..='\u{200F}' + | '\u{2028}'..='\u{202F}' + | '\u{205F}'..='\u{206F}' + | '\u{FEFF}' + ) + } +} + +pub fn fn_cure_text<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + decancer::cure(v[0].to_string().as_ref(), decancer::Options::default()) + .map(String::from) + .unwrap_or_default() + .into() +} + +pub fn fn_unicode_skeleton<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + unicode_security::skeleton(v[0].to_string().as_ref()) + .collect::() + .into() +} + +pub fn fn_is_mixed_charset<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let text = v[0].to_string(); + if !text.is_empty() { + text.as_ref().is_mixed_charset() + } else { + false + } + .into() +} diff --git a/crates/common/src/scripts/functions/url.rs b/crates/common/src/scripts/functions/url.rs new file mode 100644 index 0000000..cdaeac2 --- /dev/null +++ b/crates/common/src/scripts/functions/url.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use hyper::Uri; +use sieve::{Context, runtime::Variable}; + +use super::ApplyString; + +pub fn fn_uri_part<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let part = v[1].to_string(); + v[0].transform(|uri| { + uri.parse::() + .ok() + .and_then(|uri| match part.as_ref() { + "scheme" => uri.scheme_str().map(|s| Variable::from(s.to_string())), + "host" => uri.host().map(|s| Variable::from(s.to_string())), + "scheme_host" => uri + .scheme_str() + .and_then(|s| (s, uri.host()?).into()) + .map(|(s, h)| Variable::from(format!("{}://{}", s, h))), + "path" => Variable::from(uri.path().to_string()).into(), + "port" => uri.port_u16().map(|port| Variable::Integer(port as i64)), + "query" => uri.query().map(|s| Variable::from(s.to_string())), + "path_query" => uri.path_and_query().map(|s| Variable::from(s.to_string())), + "authority" => uri.authority().map(|s| Variable::from(s.to_string())), + _ => None, + }) + .unwrap_or_default() + }) +} + +pub fn fn_puny_decode<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].transform(|domain| { + if domain.contains("xn--") { + let mut decoded = String::with_capacity(domain.len()); + for part in domain.split('.') { + if !decoded.is_empty() { + decoded.push('.'); + } + + if let Some(puny) = part + .strip_prefix("xn--") + .and_then(idna::punycode::decode_to_string) + { + decoded.push_str(&puny); + } else { + decoded.push_str(part); + } + } + decoded.into() + } else { + domain.into() + } + }) +} diff --git a/crates/common/src/scripts/mod.rs b/crates/common/src/scripts/mod.rs new file mode 100644 index 0000000..75c92d3 --- /dev/null +++ b/crates/common/src/scripts/mod.rs @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use sieve::{Envelope, runtime::Variable}; +use store::Value; +use unicode_security::mixed_script::AugmentedScriptSet; + +use crate::IntoString; + +pub mod functions; +pub mod plugins; + +#[derive(Debug, serde::Serialize)] +#[serde(tag = "action")] +#[serde(rename_all = "camelCase")] +pub enum ScriptModification { + SetEnvelope { + name: Envelope, + value: String, + }, + AddHeader { + name: Arc, + value: Arc, + }, +} + +pub fn into_sieve_value(value: Value) -> Variable { + match value { + Value::Integer(v) => Variable::Integer(v), + Value::Bool(v) => Variable::Integer(i64::from(v)), + Value::Float(v) => Variable::Float(v), + Value::Text(v) => Variable::String(v.into_owned().into()), + Value::Blob(v) => Variable::String(v.into_owned().into_string().into()), + Value::Null => Variable::default(), + } +} + +pub fn into_store_value(value: Variable) -> Value<'static> { + match value { + Variable::String(v) => Value::Text(v.to_string().into()), + Variable::Integer(v) => Value::Integer(v), + Variable::Float(v) => Value::Float(v), + v => Value::Text(v.to_string().into_owned().into()), + } +} + +pub fn to_store_value(value: &Variable) -> Value<'static> { + match value { + Variable::String(v) => Value::Text(v.to_string().into()), + Variable::Integer(v) => Value::Integer(*v), + Variable::Float(v) => Value::Float(*v), + v => Value::Text(v.to_string().into_owned().into()), + } +} + +pub trait IsMixedCharset { + fn is_mixed_charset(&self) -> bool; +} + +impl> IsMixedCharset for T { + fn is_mixed_charset(&self) -> bool { + let mut set: Option = None; + + for ch in self.as_ref().chars() { + if !ch.is_ascii() { + set.get_or_insert_default().intersect_with(ch.into()); + } + } + + set.is_some_and(|set| set.is_empty()) + } +} diff --git a/crates/common/src/scripts/plugins/dns.rs b/crates/common/src/scripts/plugins/dns.rs new file mode 100644 index 0000000..ba48877 --- /dev/null +++ b/crates/common/src/scripts/plugins/dns.rs @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::IpAddr; + +use mail_auth::IpLookupStrategy; +use sieve::{FunctionMap, runtime::Variable}; + +use super::PluginContext; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("dns_query", plugin_id, 2); +} + +pub fn register_exists(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("dns_exists", plugin_id, 2); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + let entry = ctx.arguments[0].to_string(); + let record_type = ctx.arguments[1].to_string(); + + Ok(if record_type.eq_ignore_ascii_case("ip") { + match ctx + .server + .core + .smtp + .resolvers + .dns + .ip_lookup( + entry.as_ref(), + IpLookupStrategy::Ipv4thenIpv6, + 10, + Some(&ctx.server.inner.cache.dns_ipv4), + Some(&ctx.server.inner.cache.dns_ipv6), + ) + .await + { + Ok(result) => result + .iter() + .map(|ip| Variable::from(ip.to_string())) + .collect::>() + .into(), + Err(err) => err.short_error().into(), + } + } else if record_type.eq_ignore_ascii_case("mx") { + match ctx + .server + .core + .smtp + .resolvers + .dns + .mx_lookup(entry.as_ref(), Some(&ctx.server.inner.cache.dns_mx)) + .await + { + Ok(result) => result + .rrset + .iter() + .flat_map(|mx| { + mx.exchanges + .iter() + .map(|host| Variable::from(format!("{} {}", mx.preference, host))) + }) + .collect::>() + .into(), + Err(err) => err.short_error().into(), + } + } else if record_type.eq_ignore_ascii_case("txt") { + #[cfg(feature = "test_mode")] + { + if entry.contains("origin") { + return Ok(Variable::from("23028|US|arin|2002-01-04".to_string())); + } + } + + match ctx + .server + .core + .smtp + .resolvers + .dns + .txt_raw_lookup(entry.as_ref()) + .await + { + Ok(result) => Variable::from(String::from_utf8(result).unwrap_or_default()), + Err(err) => err.short_error().into(), + } + } else if record_type.eq_ignore_ascii_case("ptr") { + if let Ok(addr) = entry.parse::() { + match ctx + .server + .core + .smtp + .resolvers + .dns + .ptr_lookup(addr, Some(&ctx.server.inner.cache.dns_ptr)) + .await + { + Ok(result) => result + .rrset + .iter() + .map(|host| Variable::from(host.to_string())) + .collect::>() + .into(), + Err(err) => err.short_error().into(), + } + } else { + Variable::default() + } + } else if record_type.eq_ignore_ascii_case("ipv4") { + #[cfg(feature = "test_mode")] + { + if entry.contains(".168.192.") { + let parts = entry.split('.').collect::>(); + return Ok(vec![Variable::from(format!("127.0.{}.{}", parts[1], parts[0]))].into()); + } + } + + match ctx + .server + .core + .smtp + .resolvers + .dns + .ipv4_lookup(entry.as_ref(), Some(&ctx.server.inner.cache.dns_ipv4)) + .await + { + Ok(result) => result + .rrset + .iter() + .map(|ip| Variable::from(ip.to_string())) + .collect::>() + .into(), + Err(err) => err.short_error().into(), + } + } else if record_type.eq_ignore_ascii_case("ipv6") { + match ctx + .server + .core + .smtp + .resolvers + .dns + .ipv6_lookup(entry.as_ref(), Some(&ctx.server.inner.cache.dns_ipv6)) + .await + { + Ok(result) => result + .rrset + .iter() + .map(|ip| Variable::from(ip.to_string())) + .collect::>() + .into(), + Err(err) => err.short_error().into(), + } + } else { + Variable::default() + }) +} + +pub async fn exec_exists(ctx: PluginContext<'_>) -> trc::Result { + let entry = ctx.arguments[0].to_string(); + let record_type = ctx.arguments[1].to_string(); + + let result = if record_type.eq_ignore_ascii_case("ip") { + ctx.server.dns_exists_ip(entry.as_ref()).await + } else if record_type.eq_ignore_ascii_case("mx") { + ctx.server.dns_exists_mx(entry.as_ref()).await + } else if record_type.eq_ignore_ascii_case("ptr") { + ctx.server.dns_exists_ptr(entry.as_ref()).await + } else if record_type.eq_ignore_ascii_case("ipv4") { + #[cfg(feature = "test_mode")] + { + if entry.starts_with("2.0.168.192.") { + return Ok(1.into()); + } + } + + ctx.server.dns_exists_ipv4(entry.as_ref()).await + } else if record_type.eq_ignore_ascii_case("ipv6") { + ctx.server.dns_exists_ipv6(entry.as_ref()).await + } else { + return Ok((-1).into()); + }; + + Ok(result.map(i64::from).unwrap_or(-1).into()) +} + +trait ShortError { + fn short_error(&self) -> &'static str; +} + +impl ShortError for mail_auth::Error { + fn short_error(&self) -> &'static str { + match self { + mail_auth::Error::Dns(mail_auth::DnsError::Resolver(_)) => "temp_fail", + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_)) => "not_found", + mail_auth::Error::Io(_) => "io_error", + mail_auth::Error::Dns(mail_auth::DnsError::InvalidRecordType) => "invalid_record", + _ => "unknown_error", + } + } +} diff --git a/crates/common/src/scripts/plugins/exec.rs b/crates/common/src/scripts/plugins/exec.rs new file mode 100644 index 0000000..36d9403 --- /dev/null +++ b/crates/common/src/scripts/plugins/exec.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::process::Command; + +use sieve::{FunctionMap, runtime::Variable}; + +use super::PluginContext; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("exec", plugin_id, 2); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + let mut arguments = ctx.arguments.into_iter(); + + tokio::task::spawn_blocking(move || { + let command = arguments + .next() + .map(|a| a.to_string().into_owned()) + .unwrap_or_default(); + + match Command::new(&command) + .args( + arguments + .next() + .map(|a| a.into_string_array()) + .unwrap_or_default(), + ) + .output() + { + Ok(result) => Ok(result.status.success()), + Err(err) => Err(trc::SieveEvent::RuntimeError + .ctx(trc::Key::Path, command) + .reason(err) + .details("Failed to execute command")), + } + }) + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + .details("Join Error") + })? + .map(Into::into) +} diff --git a/crates/common/src/scripts/plugins/headers.rs b/crates/common/src/scripts/plugins/headers.rs new file mode 100644 index 0000000..7b0bdde --- /dev/null +++ b/crates/common/src/scripts/plugins/headers.rs @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::{FunctionMap, runtime::Variable}; + +use crate::scripts::ScriptModification; + +use super::PluginContext; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("add_header", plugin_id, 2); +} + +pub fn exec(ctx: PluginContext<'_>) -> trc::Result { + Ok(if let (Variable::String(name), Variable::String(value)) = + (&ctx.arguments[0], &ctx.arguments[1]) + { + ctx.modifications.push(ScriptModification::AddHeader { + name: name.clone(), + value: value.clone(), + }); + true + } else { + false + } + .into()) +} diff --git a/crates/common/src/scripts/plugins/http.rs b/crates/common/src/scripts/plugins/http.rs new file mode 100644 index 0000000..7241f74 --- /dev/null +++ b/crates/common/src/scripts/plugins/http.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use reqwest::header::USER_AGENT; +use sieve::{FunctionMap, runtime::Variable}; + +use super::PluginContext; + +pub fn register_header(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("http_header", plugin_id, 4); +} + +pub async fn exec_header(ctx: PluginContext<'_>) -> trc::Result { + let url = ctx.arguments[0].to_string(); + let header = ctx.arguments[1].to_string(); + let agent = ctx.arguments[2].to_string(); + let timeout = ctx.arguments[3].to_string().parse::().unwrap_or(5000); + + #[cfg(feature = "test_mode")] + if url.contains("redirect.") { + return Ok(Variable::from(url.split_once("/?").unwrap().1.to_string())); + } + + ctx.server + .core + .sieve + .http_client + .get(url.as_ref()) + .header(USER_AGENT, agent.as_ref()) + .timeout(Duration::from_millis(timeout)) + .send() + .await + .map_err(|err| { + trc::SieveEvent::RuntimeError + .into_err() + .reason(err) + .details("Failed to send request") + }) + .map(|response| { + response + .headers() + .get(header.as_ref()) + .and_then(|h| h.to_str().ok()) + .map(|h| Variable::from(h.to_string())) + .unwrap_or_default() + }) +} diff --git a/crates/common/src/scripts/plugins/llm_prompt.rs b/crates/common/src/scripts/plugins/llm_prompt.rs new file mode 100644 index 0000000..2be23a2 --- /dev/null +++ b/crates/common/src/scripts/plugins/llm_prompt.rs @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::{FunctionMap, compiler::Number, runtime::Variable}; +use std::time::Instant; +use trc::{AiEvent, SecurityEvent}; + +use super::PluginContext; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("llm_prompt", plugin_id, 3); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + + Ok(false.into()) +} diff --git a/crates/common/src/scripts/plugins/lookup.rs b/crates/common/src/scripts/plugins/lookup.rs new file mode 100644 index 0000000..06458c0 --- /dev/null +++ b/crates/common/src/scripts/plugins/lookup.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::PluginContext; +use crate::scripts::into_sieve_value; +use sieve::{FunctionMap, runtime::Variable}; +use store::{Deserialize, Value, dispatch::lookup::KeyValue}; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("key_exists", plugin_id, 2); +} + +pub fn register_get(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("key_get", plugin_id, 2); +} + +pub fn register_set(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("key_set", plugin_id, 4); +} + +pub fn register_local_domain(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("is_local_domain", plugin_id, 1); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + let store = match &ctx.arguments[0] { + Variable::String(v) if !v.is_empty() => ctx.server.get_lookup_store(v.as_str()), + _ => Some(ctx.server.core.storage.memory.clone()), + } + .ok_or_else(|| { + trc::SieveEvent::RuntimeError + .ctx(trc::Key::Id, ctx.arguments[0].to_string().into_owned()) + .details("Unknown store") + })?; + + Ok(match &ctx.arguments[1] { + Variable::Array(items) => { + for item in items.iter() { + if !item.is_empty() && store.key_exists(item.to_string()).await? { + return Ok(true.into()); + } + } + false + } + v if !v.is_empty() => store.key_exists(v.to_string()).await?, + _ => false, + } + .into()) +} + +pub async fn exec_get(ctx: PluginContext<'_>) -> trc::Result { + match &ctx.arguments[0] { + Variable::String(v) if !v.is_empty() => ctx.server.get_lookup_store(v.as_str()), + _ => Some(ctx.server.core.storage.memory.clone()), + } + .ok_or_else(|| { + trc::SieveEvent::RuntimeError + .ctx(trc::Key::Id, ctx.arguments[0].to_string().into_owned()) + .details("Unknown store") + })? + .key_get::(ctx.arguments[1].to_string()) + .await + .map(|v| v.map(|v| v.into_inner()).unwrap_or_default()) +} + +pub async fn exec_set(ctx: PluginContext<'_>) -> trc::Result { + let expires = match &ctx.arguments[3] { + Variable::Integer(v) => Some(*v as u64), + Variable::Float(v) => Some(*v as u64), + _ => None, + }; + + match &ctx.arguments[0] { + Variable::String(v) if !v.is_empty() => ctx.server.get_lookup_store(v.as_str()), + _ => Some(ctx.server.core.storage.memory.clone()), + } + .ok_or_else(|| { + trc::SieveEvent::RuntimeError + .ctx(trc::Key::Id, ctx.arguments[0].to_string().into_owned()) + .details("Unknown store") + })? + .key_set( + KeyValue::new( + ctx.arguments[1].to_string().into_owned().into_bytes(), + if !ctx.arguments[2].is_empty() { + bincode::serde::encode_to_vec(&ctx.arguments[2], bincode::config::standard()) + .unwrap_or_default() + } else { + vec![] + }, + ) + .expires_opt(expires), + ) + .await + .map(|_| true.into()) +} + +pub async fn exec_local_domain(ctx: PluginContext<'_>) -> trc::Result { + let domain = ctx.arguments[0].to_string(); + + if !domain.is_empty() { + ctx.server + .domain(domain.as_ref()) + .await + .map(|result| Variable::from(result.is_some())) + } else { + Ok(Variable::default()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct VariableWrapper(Variable); + +impl Deserialize for VariableWrapper { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(VariableWrapper( + bincode::serde::decode_from_slice::(bytes, bincode::config::standard()) + .map(|v| v.0) + .unwrap_or_else(|_| { + Variable::String(String::from_utf8_lossy(bytes).into_owned().into()) + }), + )) + } +} + +impl From for VariableWrapper { + fn from(value: i64) -> Self { + VariableWrapper(value.into()) + } +} + +impl VariableWrapper { + pub fn into_inner(self) -> Variable { + self.0 + } +} + +impl From> for VariableWrapper { + fn from(value: Value<'static>) -> Self { + VariableWrapper(into_sieve_value(value)) + } +} diff --git a/crates/common/src/scripts/plugins/mod.rs b/crates/common/src/scripts/plugins/mod.rs new file mode 100644 index 0000000..5ea2974 --- /dev/null +++ b/crates/common/src/scripts/plugins/mod.rs @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod dns; +pub mod exec; +pub mod headers; +pub mod http; +pub mod llm_prompt; +pub mod lookup; +pub mod query; +pub mod text; + +use mail_parser::Message; +use sieve::{FunctionMap, Input, runtime::Variable}; + +use crate::{Core, Server, auth::AccessToken}; + +use super::ScriptModification; + +type RegisterPluginFnc = fn(u32, &mut FunctionMap) -> (); + +pub struct PluginContext<'x> { + pub session_id: u64, + pub access_token: Option<&'x AccessToken>, + pub server: &'x Server, + pub message: &'x Message<'x>, + pub modifications: &'x mut Vec, + pub arguments: Vec, +} + +const PLUGINS_REGISTER: [RegisterPluginFnc; 13] = [ + query::register, + exec::register, + lookup::register, + lookup::register_get, + lookup::register_set, + lookup::register_local_domain, + dns::register, + dns::register_exists, + http::register_header, + headers::register, + text::register_tokenize, + text::register_domain_part, + llm_prompt::register, +]; + +pub trait RegisterSievePlugins { + fn register_plugins_trusted(self) -> Self; + fn register_plugins_untrusted(self) -> Self; +} + +impl RegisterSievePlugins for FunctionMap { + fn register_plugins_trusted(mut self) -> Self { + #[cfg(feature = "test_mode")] + { + self.set_external_function("print", PLUGINS_REGISTER.len() as u32, 1) + } + + for (i, fnc) in PLUGINS_REGISTER.iter().enumerate() { + fnc(i as u32, &mut self); + } + self + } + + fn register_plugins_untrusted(mut self) -> Self { + llm_prompt::register(12, &mut self); + self + } +} + +impl Core { + pub async fn run_plugin(&self, id: u32, ctx: PluginContext<'_>) -> Input { + #[cfg(feature = "test_mode")] + if id == PLUGINS_REGISTER.len() as u32 { + return test_print(ctx); + } + + let session_id = ctx.session_id; + let result = match id { + 0 => query::exec(ctx).await, + 1 => exec::exec(ctx).await, + 2 => lookup::exec(ctx).await, + 3 => lookup::exec_get(ctx).await, + 4 => lookup::exec_set(ctx).await, + 5 => lookup::exec_local_domain(ctx).await, + 6 => dns::exec(ctx).await, + 7 => dns::exec_exists(ctx).await, + 8 => http::exec_header(ctx).await, + 9 => headers::exec(ctx), + 10 => text::exec_tokenize(ctx), + 11 => text::exec_domain_part(ctx), + 12 => llm_prompt::exec(ctx).await, + _ => unreachable!(), + }; + + match result { + Ok(result) => result.into(), + Err(err) => { + trc::error!(err.span_id(session_id).details("Sieve runtime error")); + Input::FncResult(Variable::default()) + } + } + } +} + +#[cfg(feature = "test_mode")] +pub fn test_print(ctx: PluginContext<'_>) -> Input { + println!("{}", ctx.arguments[0].to_string()); + Input::True +} diff --git a/crates/common/src/scripts/plugins/query.rs b/crates/common/src/scripts/plugins/query.rs new file mode 100644 index 0000000..a3665e8 --- /dev/null +++ b/crates/common/src/scripts/plugins/query.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::PluginContext; +use crate::scripts::{into_sieve_value, to_store_value}; +use sieve::{FunctionMap, runtime::Variable}; +use std::cmp::Ordering; +use store::{Rows, Value}; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("query", plugin_id, 3); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + // Obtain store name + let store = match &ctx.arguments[0] { + Variable::String(v) if !v.is_empty() => ctx + .server + .get_lookup_store(v.as_str()) + .and_then(|v| v.into_store()), + _ => Some(ctx.server.core.storage.data.clone()), + } + .ok_or_else(|| { + trc::SieveEvent::RuntimeError + .ctx(trc::Key::Id, ctx.arguments[0].to_string().into_owned()) + .details("Unknown store") + })?; + + // Obtain query string + let query = ctx.arguments[1].to_string(); + if query.is_empty() { + trc::bail!( + trc::SieveEvent::RuntimeError + .ctx(trc::Key::Id, ctx.arguments[0].to_string().into_owned()) + .details("Empty query string") + ); + } + + // Obtain arguments + let arguments = match &ctx.arguments[2] { + Variable::Array(l) => l.iter().map(to_store_value).collect(), + v => vec![to_store_value(v)], + }; + + // Run query + if query + .as_bytes() + .get(..6) + .is_some_and(|q| q.eq_ignore_ascii_case(b"SELECT")) + { + let mut rows = store.sql_query::(&query, arguments).await?; + Ok(match rows.rows.len().cmp(&1) { + Ordering::Equal => { + let mut row = rows.rows.pop().unwrap().values; + match row.len().cmp(&1) { + Ordering::Equal if !matches!(row.first(), Some(Value::Null)) => { + row.pop().map(into_sieve_value).unwrap() + } + Ordering::Less => Variable::default(), + _ => Variable::Array( + row.into_iter() + .map(into_sieve_value) + .collect::>() + .into(), + ), + } + } + Ordering::Less => Variable::default(), + Ordering::Greater => rows + .rows + .into_iter() + .map(|r| { + Variable::Array( + r.values + .into_iter() + .map(into_sieve_value) + .collect::>() + .into(), + ) + }) + .collect::>() + .into(), + }) + } else { + Ok(store + .sql_query::(&query, arguments) + .await + .is_ok() + .into()) + } +} diff --git a/crates/common/src/scripts/plugins/text.rs b/crates/common/src/scripts/plugins/text.rs new file mode 100644 index 0000000..d4c4dbd --- /dev/null +++ b/crates/common/src/scripts/plugins/text.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use nlp::tokenizers::types::{TokenType, TypesTokenizer}; +use sieve::{FunctionMap, runtime::Variable}; + +use crate::scripts::functions::{ApplyString, text::tokenize_words}; + +use super::PluginContext; + +pub fn register_tokenize(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("tokenize", plugin_id, 2); +} + +pub fn register_domain_part(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("domain_part", plugin_id, 2); +} + +pub fn exec_tokenize(ctx: PluginContext<'_>) -> trc::Result { + let mut v = ctx.arguments; + let (urls, urls_without_scheme, emails) = match v[1].to_string().as_ref() { + "words" => return Ok(tokenize_words(&v[0])), + "uri" | "url" => (true, true, true), + "uri_strict" | "url_strict" => (true, false, false), + "email" => (false, false, true), + _ => return Ok(Variable::default()), + }; + + Ok(match v.remove(0) { + v @ (Variable::String(_) | Variable::Array(_)) => { + TypesTokenizer::new(v.to_string().as_ref()) + .tokenize_numbers(false) + .tokenize_urls(urls) + .tokenize_urls_without_scheme(urls_without_scheme) + .tokenize_emails(emails) + .filter_map(|t| match t.word { + TokenType::Url(text) if urls => Variable::from(text.to_string()).into(), + TokenType::UrlNoScheme(text) if urls_without_scheme => { + Variable::from(format!("https://{text}")).into() + } + TokenType::Email(text) if emails => Variable::from(text.to_string()).into(), + _ => None, + }) + .collect::>() + .into() + } + v => v, + }) +} + +enum DomainPart { + Sld, + Tld, + Host, +} + +pub fn exec_domain_part(ctx: PluginContext<'_>) -> trc::Result { + let v = ctx.arguments; + let part = match v[1].to_string().as_ref() { + "sld" => DomainPart::Sld, + "tld" => DomainPart::Tld, + "host" => DomainPart::Host, + _ => return Ok(Variable::default()), + }; + + Ok(v[0].transform(|domain| { + match part { + DomainPart::Sld => psl::domain_str(domain), + DomainPart::Tld => domain.rsplit_once('.').map(|(_, tld)| tld), + DomainPart::Host => domain.split_once('.').map(|(host, _)| host), + } + .map(Variable::from) + .unwrap_or_default() + })) +} diff --git a/crates/common/src/sharing/acl.rs b/crates/common/src/sharing/acl.rs new file mode 100644 index 0000000..f07c4f1 --- /dev/null +++ b/crates/common/src/sharing/acl.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, cache::invalidate::CacheInvalidationBuilder, ipc::CacheInvalidation}; +use types::acl::{AclGrant, ArchivedAclGrant}; + +impl Server { + pub async fn refresh_acls( + &self, + acl_changes: &[AclGrant], + current: Option<&[AclGrant]>, + ) -> trc::Result<()> { + let mut changed_principals = CacheInvalidationBuilder::default(); + if let Some(acl_current) = current { + for current_item in acl_current { + let mut invalidate = true; + for change_item in acl_changes { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals + .invalidate(CacheInvalidation::AccessToken(current_item.account_id)); + } + } + + for change_item in acl_changes { + let mut invalidate = true; + for current_item in acl_current { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals + .invalidate(CacheInvalidation::AccessToken(change_item.account_id)); + } + } + } else { + for value in acl_changes { + changed_principals.invalidate(CacheInvalidation::AccessToken(value.account_id)); + } + } + + self.invalidate_caches(changed_principals).await + } + + pub async fn refresh_archived_acls( + &self, + acl_changes: &[AclGrant], + acl_current: &[ArchivedAclGrant], + ) -> trc::Result<()> { + let mut changed_principals = CacheInvalidationBuilder::default(); + + for current_item in acl_current.iter() { + let mut invalidate = true; + for change_item in acl_changes { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals.invalidate(CacheInvalidation::AccessToken( + current_item.account_id.to_native(), + )); + } + } + + for change_item in acl_changes { + let mut invalidate = true; + for current_item in acl_current.iter() { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals + .invalidate(CacheInvalidation::AccessToken(change_item.account_id)); + } + } + + self.invalidate_caches(changed_principals).await + } +} diff --git a/crates/common/src/sharing/mod.rs b/crates/common/src/sharing/mod.rs new file mode 100644 index 0000000..5f14e14 --- /dev/null +++ b/crates/common/src/sharing/mod.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::auth::AccessToken; +use rkyv::vec::ArchivedVec; +use types::acl::{Acl, AclGrant, ArchivedAclGrant}; +use utils::map::bitmap::Bitmap; + +pub mod acl; +pub mod notification; +pub mod resources; + +pub trait EffectiveAcl { + fn effective_acl(&self, access_token: &AccessToken) -> Bitmap; +} + +impl EffectiveAcl for Vec { + fn effective_acl(&self, access_token: &AccessToken) -> Bitmap { + self.as_slice().effective_acl(access_token) + } +} + +impl EffectiveAcl for &[AclGrant] { + fn effective_acl(&self, access_token: &AccessToken) -> Bitmap { + let mut acl = Bitmap::::new(); + for item in self.iter() { + if access_token.is_member(item.account_id) { + acl.union(&item.grants); + } + } + + acl + } +} + +impl EffectiveAcl for ArchivedVec { + fn effective_acl(&self, access_token: &AccessToken) -> Bitmap { + let mut acl = Bitmap::::new(); + for item in self.iter() { + if access_token.is_member(item.account_id.into()) { + acl.union_raw(item.grants.bitmap); + } + } + + acl + } +} diff --git a/crates/common/src/sharing/notification.rs b/crates/common/src/sharing/notification.rs new file mode 100644 index 0000000..78e0a80 --- /dev/null +++ b/crates/common/src/sharing/notification.rs @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use store::{Deserialize, SerializeInfallible, U32_LEN, U64_LEN, write::key::KeySerializer}; +use types::{acl::Acl, collection::Collection}; +use utils::map::bitmap::Bitmap; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ShareNotification { + pub object_account_id: u32, + pub object_id: u32, + pub object_type: Collection, + pub changed_by: u32, + pub old_rights: Bitmap, + pub new_rights: Bitmap, + pub name: String, +} + +impl SerializeInfallible for ShareNotification { + fn serialize(&self) -> Vec { + KeySerializer::new(U64_LEN * 2 + U32_LEN * 3 + 1 + self.name.len()) + .write(self.object_account_id) + .write(self.object_id) + .write(self.object_type as u8) + .write(self.changed_by) + .write(self.old_rights.bitmap) + .write(self.new_rights.bitmap) + .write(self.name.as_bytes()) + .finalize() + } +} + +impl Deserialize for ShareNotification { + fn deserialize(bytes: &[u8]) -> trc::Result { + Self::deserialize_from_slice(bytes) + .ok_or(trc::StoreEvent::DataCorruption.caused_by(trc::location!())) + } +} + +impl ShareNotification { + fn deserialize_from_slice(bytes: &[u8]) -> Option { + Some(Self { + object_account_id: bytes + .get(..U32_LEN) + .and_then(|b| b.try_into().ok()) + .map(u32::from_be_bytes)?, + object_id: bytes + .get(U32_LEN..U32_LEN * 2) + .and_then(|b| b.try_into().ok()) + .map(u32::from_be_bytes)?, + object_type: bytes.get(U32_LEN * 2).copied().map(Collection::from)?, + changed_by: bytes + .get(U32_LEN * 2 + 1..U32_LEN * 3 + 1) + .and_then(|b| b.try_into().ok()) + .map(u32::from_be_bytes)?, + old_rights: bytes + .get(U32_LEN * 3 + 1..U32_LEN * 3 + U64_LEN + 1) + .and_then(|b| b.try_into().ok()) + .map(u64::from_be_bytes) + .map(Bitmap::from)?, + new_rights: bytes + .get(U32_LEN * 3 + U64_LEN + 1..U32_LEN * 3 + U64_LEN * 2 + 1) + .and_then(|b| b.try_into().ok()) + .map(u64::from_be_bytes) + .map(Bitmap::from)?, + name: bytes + .get(U32_LEN * 3 + U64_LEN * 2 + 1..) + .and_then(|b| String::from_utf8(b.to_vec()).ok()) + .unwrap_or_default(), + }) + } +} diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs new file mode 100644 index 0000000..c12fc56 --- /dev/null +++ b/crates/common/src/sharing/resources.rs @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{DavResources, auth::AccessToken}; +use store::roaring::RoaringBitmap; +use types::acl::Acl; +use utils::map::bitmap::Bitmap; + +impl DavResources { + pub fn shared_containers( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + ) -> RoaringBitmap { + let check_acls = Bitmap::::from_iter(check_acls); + let mut document_ids = RoaringBitmap::new(); + + for resource in &self.resources { + if let Some(acls) = resource.acls() { + for acl in acls { + if access_token.is_member(acl.account_id) { + let mut grants = acl.grants; + grants.intersection(&check_acls); + if grants == check_acls || (match_any && !grants.is_empty()) { + document_ids.insert(resource.document_id); + } + } + } + } + } + + document_ids + } + + pub fn shared_items( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + ) -> RoaringBitmap { + let shared_containers = self.shared_containers(access_token, check_acls, match_any); + + if !shared_containers.is_empty() { + let mut document_ids = RoaringBitmap::new(); + + for resource in &self.resources { + if !resource.is_container() && shared_containers.contains(resource.document_id) { + document_ids.insert(resource.document_id); + } + } + + for path in &self.paths { + if let Some(parent_id) = path.parent_id + && shared_containers.contains(parent_id) + { + document_ids.insert(self.resources[path.resource_idx].document_id); + } + } + + document_ids + } else { + shared_containers + } + } + + pub fn shared_documents( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + ) -> RoaringBitmap { + let shared_containers = self.shared_containers(access_token, check_acls, match_any); + let mut document_ids = shared_containers.clone(); + + if !shared_containers.is_empty() { + for path in &self.paths { + if let Some(parent_id) = path.parent_id + && shared_containers.contains(parent_id) + { + document_ids.insert(self.resources[path.resource_idx].document_id); + } + } + } + + document_ids + } + + pub fn has_access_to_container( + &self, + access_token: &AccessToken, + document_id: u32, + check_acls: impl Into>, + ) -> bool { + let check_acls = check_acls.into(); + + for resource in &self.resources { + if resource.document_id == document_id + && let Some(acls) = resource.acls() + { + for acl in acls { + if access_token.is_member(acl.account_id) { + let mut grants = acl.grants; + grants.intersection(&check_acls); + return !grants.is_empty(); + } + } + break; + } + } + + false + } + + pub fn container_acl(&self, access_token: &AccessToken, document_id: u32) -> Bitmap { + let mut account_acls = Bitmap::::new(); + + for resource in &self.resources { + if resource.document_id == document_id + && let Some(acls) = resource.acls() + { + for acl in acls { + if access_token.is_member(acl.account_id) { + account_acls.union(&acl.grants); + } + } + break; + } + } + + account_acls + } + + pub fn document_ids(&self, is_container: bool) -> impl Iterator { + self.resources.iter().filter_map(move |resource| { + if resource.is_container() == is_container { + Some(resource.document_id) + } else { + None + } + }) + } + + pub fn has_container_id(&self, id: &u32) -> bool { + self.resources + .iter() + .any(|r| r.document_id == *id && r.is_container()) + } + + pub fn has_item_id(&self, id: &u32) -> bool { + self.resources + .iter() + .any(|r| r.document_id == *id && !r.is_container()) + } +} diff --git a/crates/common/src/storage/archive.rs b/crates/common/src/storage/archive.rs new file mode 100644 index 0000000..00b3867 --- /dev/null +++ b/crates/common/src/storage/archive.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Server; +use store::{ + Deserialize, IterateParams, U32_LEN, ValueKey, + dispatch::DocumentSet, + write::{AlignedBytes, Archive, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::{collection::Collection, field::Field}; + +impl Server { + pub async fn archives( + &self, + account_id: u32, + collection: Collection, + documents: &I, + mut cb: CB, + ) -> trc::Result<()> + where + I: DocumentSet + Send + Sync, + CB: FnMut(u32, Archive) -> trc::Result + Send + Sync, + { + let collection: u8 = collection.into(); + + self.core + .storage + .data + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection, + document_id: documents.min(), + class: ValueClass::Property(Field::ARCHIVE.into()), + }, + ValueKey { + account_id, + collection, + document_id: documents.max(), + class: ValueClass::Property(Field::ARCHIVE.into()), + }, + ), + |key, value| { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + if documents.contains(document_id) { + as Deserialize>::deserialize(value) + .and_then(|archive| cb(document_id, archive)) + } else { + Ok(true) + } + }, + ) + .await + .add_context(|err| { + err.caused_by(trc::location!()) + .account_id(account_id) + .collection(collection) + }) + } + + pub async fn all_archives( + &self, + account_id: u32, + collection: Collection, + field: u8, + mut cb: CB, + ) -> trc::Result<()> + where + CB: FnMut(u32, Archive) -> trc::Result<()> + Send + Sync, + { + let collection: u8 = collection.into(); + + self.core + .storage + .data + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection, + document_id: 0, + class: ValueClass::Property(field), + }, + ValueKey { + account_id, + collection, + document_id: u32::MAX, + class: ValueClass::Property(field), + }, + ), + |key, value| { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + let archive = as Deserialize>::deserialize(value)?; + cb(document_id, archive)?; + + Ok(true) + }, + ) + .await + .add_context(|err| { + err.caused_by(trc::location!()) + .account_id(account_id) + .collection(collection) + }) + } +} diff --git a/crates/common/src/storage/blob.rs b/crates/common/src/storage/blob.rs new file mode 100644 index 0000000..ca957ff --- /dev/null +++ b/crates/common/src/storage/blob.rs @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{KV_QUOTA_BLOB, Server}; +use mail_parser::{ + Encoding, + decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode}, +}; +use store::{ + U32_LEN, U64_LEN, + dispatch::lookup::KeyValue, + write::{BatchBuilder, BlobLink, BlobOp, now}, +}; +use trc::AddContext; +use types::{ + blob::{BlobClass, BlobId, BlobSection}, + blob_hash::BlobHash, +}; + +const COUNT_BYTES: u32 = 20; +const COUNT_SHIFT: u32 = 64 - COUNT_BYTES; +const SIZE_MASK: u64 = (1u64 << COUNT_SHIFT) - 1; + +pub struct BlobQuotaStatus { + pub allowed: bool, + pub expires_in: u64, +} + +impl Server { + pub async fn blob_has_quota( + &self, + account_id: u32, + bytes: usize, + ) -> trc::Result { + if self.core.jmap.upload_tmp_quota_size > 0 || self.core.jmap.upload_tmp_quota_amount > 0 { + let now = now(); + let range_start = now / self.core.jmap.upload_tmp_ttl; + let range_end = + (range_start * self.core.jmap.upload_tmp_ttl) + self.core.jmap.upload_tmp_ttl; + let expires_in = range_end - now; + + let mut bucket = Vec::with_capacity(U32_LEN + U64_LEN + 1); + bucket.push(KV_QUOTA_BLOB); + bucket.extend_from_slice(account_id.to_be_bytes().as_slice()); + bucket.extend_from_slice(range_start.to_be_bytes().as_slice()); + + self.in_memory_store() + .counter_incr( + KeyValue::new(bucket, 1i64 << COUNT_SHIFT | bytes as i64).expires(expires_in), + true, + ) + .await + .caused_by(trc::location!()) + .map(|v| { + let v = v as u64; + let count = v >> COUNT_SHIFT; + let size = v & SIZE_MASK; + + let allowed = (self.core.jmap.upload_tmp_quota_amount == 0 + || count <= self.core.jmap.upload_tmp_quota_amount as u64) + && (self.core.jmap.upload_tmp_quota_size == 0 + || size <= self.core.jmap.upload_tmp_quota_size as u64); + + BlobQuotaStatus { + allowed, + expires_in, + } + }) + } else { + Ok(BlobQuotaStatus { + allowed: true, + expires_in: 0, + }) + } + } + + #[allow(clippy::blocks_in_conditions)] + pub async fn put_jmap_blob(&self, account_id: u32, data: &[u8]) -> trc::Result { + // First reserve the hash + let hash = BlobHash::generate(data); + let mut batch = BatchBuilder::new(); + let until = now() + self.core.jmap.upload_tmp_ttl; + + batch.with_account_id(account_id).set( + BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Temporary { until }, + }, + vec![], + ); + + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + if !self + .core + .storage + .data + .blob_exists(&hash) + .await + .caused_by(trc::location!())? + { + // Upload blob to store + self.core + .storage + .blob + .put_blob(hash.as_ref(), data, self.core.email.compression) + .await + .caused_by(trc::location!())?; + + // Commit blob + let mut batch = BatchBuilder::new(); + batch.set(BlobOp::Commit { hash: hash.clone() }, Vec::new()); + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(BlobId { + hash, + class: BlobClass::Reserved { + account_id, + expires: until, + }, + section: None, + }) + } + + pub async fn put_temporary_blob( + &self, + account_id: u32, + data: &[u8], + hold_for: u64, + ) -> trc::Result<(BlobHash, BlobOp)> { + // First reserve the hash + let hash = BlobHash::generate(data); + let mut batch = BatchBuilder::new(); + let until = now() + hold_for; + + batch.with_account_id(account_id).set( + BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Temporary { until }, + }, + vec![], + ); + + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + if !self + .core + .storage + .data + .blob_exists(&hash) + .await + .caused_by(trc::location!())? + { + // Upload blob to store + self.core + .storage + .blob + .put_blob(hash.as_ref(), data, self.core.email.compression) + .await + .caused_by(trc::location!())?; + + // Commit blob + let mut batch = BatchBuilder::new(); + batch.set(BlobOp::Commit { hash: hash.clone() }, Vec::new()); + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(( + hash.clone(), + BlobOp::Link { + hash, + to: BlobLink::Temporary { until }, + }, + )) + } + + pub async fn get_blob_section( + &self, + hash: &BlobHash, + section: &BlobSection, + ) -> trc::Result>> { + Ok(self + .blob_store() + .get_blob( + hash.as_slice(), + (section.offset_start)..(section.offset_start.saturating_add(section.size)), + ) + .await? + .and_then(|bytes| match Encoding::from(section.encoding) { + Encoding::None => Some(bytes), + Encoding::Base64 => base64_decode(&bytes), + Encoding::QuotedPrintable => quoted_printable_decode(&bytes), + })) + } +} diff --git a/crates/common/src/storage/dav.rs b/crates/common/src/storage/dav.rs new file mode 100644 index 0000000..c36eeed --- /dev/null +++ b/crates/common/src/storage/dav.rs @@ -0,0 +1,403 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavName, DavPath, DavResource, DavResourceMetadata, DavResourcePath, DavResources, + TinyCalendarPreferences, +}; +use std::hash::{Hash, Hasher}; +use store::rand::{RngExt, distr::Alphanumeric}; +use types::acl::AclGrant; + +const SCHEDULE_INBOX_ID: u32 = u32::MAX - 1; + +impl DavResourcePath<'_> { + #[inline(always)] + pub fn document_id(&self) -> u32 { + self.resource.document_id + } + + #[inline(always)] + pub fn parent_id(&self) -> Option { + self.path.parent_id + } + + #[inline(always)] + pub fn path(&self) -> &str { + self.path.path.as_str() + } + + #[inline(always)] + pub fn is_container(&self) -> bool { + self.resource.is_container() + } + + #[inline(always)] + pub fn hierarchy_seq(&self) -> u32 { + self.path.hierarchy_seq + } + + #[inline(always)] + pub fn size(&self) -> u32 { + self.resource.size().unwrap_or_default() + } +} + +impl DavResources { + pub fn by_path(&self, name: &str) -> Option> { + self.paths.get(name).map(|path| DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + } + + pub fn container_resource_by_id(&self, id: u32) -> Option<&DavResource> { + self.resources + .iter() + .find(|res| res.document_id == id && res.is_container()) + } + + pub fn container_resource_path_by_id(&self, id: u32) -> Option> { + self.resources + .iter() + .enumerate() + .find(|(_, resource)| resource.document_id == id && resource.is_container()) + .and_then(|(idx, resource)| { + self.paths + .iter() + .find(|path| path.resource_idx == idx) + .map(|path| DavResourcePath { path, resource }) + }) + } + + pub fn any_resource_path_by_id(&self, id: u32) -> Option> { + self.resources + .iter() + .enumerate() + .find(|(_, resource)| resource.document_id == id) + .and_then(|(idx, resource)| { + self.paths + .iter() + .find(|path| path.resource_idx == idx) + .map(|path| DavResourcePath { path, resource }) + }) + } + + pub fn subtree(&self, search_path: &str) -> impl Iterator> { + let prefix = format!("{search_path}/"); + self.paths.iter().filter_map(move |path| { + if path.path.starts_with(&prefix) || path.path == search_path { + Some(DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + } else { + None + } + }) + } + + pub fn subtree_with_depth( + &self, + search_path: &str, + depth: usize, + ) -> impl Iterator> { + let prefix = format!("{search_path}/"); + self.paths.iter().filter_map(move |path| { + if path + .path + .strip_prefix(&prefix) + .is_some_and(|name| name.as_bytes().iter().filter(|&&c| c == b'/').count() < depth) + || path.path.as_str() == search_path + { + Some(DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + } else { + None + } + }) + } + + pub fn tree_with_depth(&self, depth: usize) -> impl Iterator> { + self.paths.iter().filter_map(move |path| { + if path.path.as_bytes().iter().filter(|&&c| c == b'/').count() <= depth { + Some(DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + } else { + None + } + }) + } + + pub fn children(&self, parent_id: u32) -> impl Iterator> { + self.paths + .iter() + .filter(move |item| item.parent_id.is_some_and(|id| id == parent_id)) + .map(|path| DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + } + + pub fn children_ids(&self, parent_id: u32) -> impl Iterator { + self.paths + .iter() + .filter(move |item| item.parent_id.is_some_and(|id| id == parent_id)) + .map(|path| self.resources[path.resource_idx].document_id) + } + + pub fn format_resource(&self, resource: DavResourcePath<'_>) -> String { + if resource.resource.is_container() { + format!("{}{}/", self.base_path, resource.path.path) + } else { + format!("{}{}", self.base_path, resource.path.path) + } + } + + pub fn format_resource_paths_by_id( + &self, + document_id: u32, + ) -> impl Iterator + '_ { + self.paths + .iter() + .filter(move |path| self.resources[path.resource_idx].document_id == document_id) + .map(move |path| { + self.format_resource(DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + }) + } + + pub fn format_resource_path_by_parent( + &self, + document_id: u32, + parent_id: u32, + ) -> Option { + self.paths + .iter() + .find(|path| { + self.resources[path.resource_idx].document_id == document_id + && path.parent_id == Some(parent_id) + }) + .map(|path| { + self.format_resource(DavResourcePath { + path, + resource: &self.resources[path.resource_idx], + }) + }) + } + + pub fn format_collection(&self, name: &str) -> String { + format!("{}{name}/", self.base_path) + } + + pub fn format_item(&self, name: &str) -> String { + format!("{}{}", self.base_path, name) + } +} + +impl DavResource { + pub fn is_child_of(&self, parent_id: u32) -> bool { + match &self.data { + DavResourceMetadata::File { parent_id: id, .. } => id.is_some_and(|id| id == parent_id), + DavResourceMetadata::CalendarEvent { names, .. } => { + names.iter().any(|name| name.parent_id == parent_id) + } + DavResourceMetadata::ContactCard { names } => { + names.iter().any(|name| name.parent_id == parent_id) + } + DavResourceMetadata::CalendarEventNotification { names } => { + names.is_empty() && parent_id == SCHEDULE_INBOX_ID + } + _ => false, + } + } + + pub fn parent_id(&self) -> Option { + match &self.data { + DavResourceMetadata::File { parent_id, .. } => *parent_id, + DavResourceMetadata::CalendarEvent { names, .. } => { + names.first().map(|name| name.parent_id) + } + DavResourceMetadata::ContactCard { names } => names.first().map(|name| name.parent_id), + DavResourceMetadata::CalendarEventNotification { names } if names.is_empty() => { + Some(SCHEDULE_INBOX_ID) + } + _ => None, + } + } + + pub fn child_names(&self) -> Option<&[DavName]> { + match &self.data { + DavResourceMetadata::CalendarEvent { names, .. } => Some(names.as_slice()), + DavResourceMetadata::ContactCard { names } => Some(names.as_slice()), + DavResourceMetadata::CalendarEventNotification { names } if !names.is_empty() => { + Some(names.as_slice()) + } + _ => None, + } + } + + pub fn container_name(&self) -> Option<&str> { + match &self.data { + DavResourceMetadata::File { name, .. } => Some(name.as_str()), + DavResourceMetadata::Calendar { name, .. } => Some(name.as_str()), + DavResourceMetadata::AddressBook { name, .. } => Some(name.as_str()), + DavResourceMetadata::CalendarEventNotification { names } if names.is_empty() => { + Some(if self.document_id == SCHEDULE_INBOX_ID { + "inbox" + } else { + "outbox" + }) + } + _ => None, + } + } + + pub fn has_hierarchy_changes(&self, other: &DavResource) -> bool { + match (&self.data, &other.data) { + ( + DavResourceMetadata::File { + name: a, + parent_id: c, + .. + }, + DavResourceMetadata::File { + name: b, + parent_id: d, + .. + }, + ) => a != b || c != d, + ( + DavResourceMetadata::Calendar { name: a, .. }, + DavResourceMetadata::Calendar { name: b, .. }, + ) => a != b, + ( + DavResourceMetadata::AddressBook { name: a, .. }, + DavResourceMetadata::AddressBook { name: b, .. }, + ) => a != b, + ( + DavResourceMetadata::CalendarEvent { names: a, .. }, + DavResourceMetadata::CalendarEvent { names: b, .. }, + ) => a != b, + ( + DavResourceMetadata::ContactCard { names: a, .. }, + DavResourceMetadata::ContactCard { names: b, .. }, + ) => a != b, + ( + DavResourceMetadata::CalendarEventNotification { names: a, .. }, + DavResourceMetadata::CalendarEventNotification { names: b, .. }, + ) => a != b, + _ => unreachable!(), + } + } + + pub fn event_time_range(&self) -> Option<(i64, i64)> { + match &self.data { + DavResourceMetadata::CalendarEvent { + start, duration, .. + } => Some((*start, *start + *duration as i64)), + _ => None, + } + } + + pub fn calendar_preferences(&self, account_id: u32) -> Option<&TinyCalendarPreferences> { + match &self.data { + DavResourceMetadata::Calendar { preferences, .. } => preferences + .iter() + .find(|pref| pref.account_id == account_id) + .or_else(|| preferences.first()), + _ => None, + } + } + + pub fn is_container(&self) -> bool { + match &self.data { + DavResourceMetadata::File { size, .. } => size.is_none(), + DavResourceMetadata::Calendar { .. } | DavResourceMetadata::AddressBook { .. } => true, + DavResourceMetadata::CalendarEventNotification { names } => names.is_empty(), + _ => false, + } + } + + pub fn size(&self) -> Option { + match &self.data { + DavResourceMetadata::File { size, .. } => *size, + _ => None, + } + } + + pub fn acls(&self) -> Option<&[AclGrant]> { + match &self.data { + DavResourceMetadata::File { acls, .. } => Some(acls.as_slice()), + DavResourceMetadata::Calendar { acls, .. } => Some(acls.as_slice()), + DavResourceMetadata::AddressBook { acls, .. } => Some(acls.as_slice()), + _ => None, + } + } +} + +impl Hash for DavPath { + fn hash(&self, state: &mut H) { + self.path.hash(state); + } +} + +impl PartialEq for DavPath { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +impl Eq for DavPath {} + +impl std::borrow::Borrow for DavPath { + fn borrow(&self) -> &str { + &self.path + } +} + +impl std::hash::Hash for DavResource { + fn hash(&self, state: &mut H) { + self.document_id.hash(state); + } +} + +impl PartialEq for DavResource { + fn eq(&self, other: &Self) -> bool { + self.document_id == other.document_id + } +} + +impl Eq for DavResource {} + +impl std::borrow::Borrow for DavResource { + fn borrow(&self) -> &u32 { + &self.document_id + } +} + +impl DavName { + pub fn new(name: String, parent_id: u32) -> Self { + Self { name, parent_id } + } + + pub fn new_with_rand_name(parent_id: u32) -> Self { + Self { + name: store::rand::rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(), + parent_id, + } + } +} diff --git a/crates/common/src/storage/document.rs b/crates/common/src/storage/document.rs new file mode 100644 index 0000000..64a8413 --- /dev/null +++ b/crates/common/src/storage/document.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use store::{ + IndexKey, IndexKeyPrefix, IterateParams, U32_LEN, roaring::RoaringBitmap, + write::key::DeserializeBigEndian, +}; +use trc::AddContext; +use types::collection::Collection; + +use crate::Server; + +impl Server { + pub async fn document_ids( + &self, + account_id: u32, + collection: Collection, + field: impl Into, + ) -> trc::Result { + let field = field.into(); + let mut results = RoaringBitmap::new(); + self.store() + .iterate( + IterateParams::new( + IndexKeyPrefix { + account_id, + collection: collection.into(), + field, + }, + IndexKeyPrefix { + account_id, + collection: collection.into(), + field: field + 1, + }, + ) + .no_values(), + |key, _| { + results.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| results) + } + + pub async fn document_exists( + &self, + account_id: u32, + collection: Collection, + field: impl Into, + filter: impl AsRef<[u8]>, + ) -> trc::Result { + let field = field.into(); + let mut exists = false; + let filter = filter.as_ref(); + let key_len = IndexKeyPrefix::len() + filter.len() + U32_LEN; + + self.store() + .iterate( + IterateParams::new( + IndexKey { + account_id, + collection: collection.into(), + document_id: 0, + field, + key: filter, + }, + IndexKey { + account_id, + collection: collection.into(), + document_id: u32::MAX, + field, + key: filter, + }, + ) + .no_values(), + |key, _| { + exists = key.len() == key_len; + + Ok(!exists) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| exists) + } + + pub async fn document_ids_matching( + &self, + account_id: u32, + collection: Collection, + field: impl Into, + filter: impl AsRef<[u8]>, + ) -> trc::Result { + let field = field.into(); + let filter = filter.as_ref(); + let key_len = IndexKeyPrefix::len() + filter.len() + U32_LEN; + let mut results = RoaringBitmap::new(); + + self.store() + .iterate( + IterateParams::new( + IndexKey { + account_id, + collection: collection.into(), + document_id: 0, + field, + key: filter, + }, + IndexKey { + account_id, + collection: collection.into(), + document_id: u32::MAX, + field, + key: filter, + }, + ) + .no_values(), + |key, _| { + if key.len() == key_len { + results.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| results) + } +} diff --git a/crates/common/src/storage/encryption.rs b/crates/common/src/storage/encryption.rs new file mode 100644 index 0000000..9b4247a --- /dev/null +++ b/crates/common/src/storage/encryption.rs @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::auth::EncryptionKeys; +use mail_parser::decoders::base64::base64_decode; +use registry::schema::structs::PublicKey; +use sequoia_openpgp::{Cert, parse::Parse, policy::StandardPolicy, types::KeyFlags}; +use std::borrow::Cow; + +const P: StandardPolicy<'static> = StandardPolicy::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionMethod { + PGP, + SMIME, +} + +pub struct EncryptionParams { + pub certs: EncryptionKeys, + pub method: EncryptionMethod, +} + +#[allow(clippy::type_complexity)] +pub fn parse_public_key(pk: &PublicKey) -> Result, Cow<'static, str>> { + let bytes_ = pk.key.as_bytes(); + let mut bytes = bytes_.iter().enumerate(); + let mut buf = vec![]; + let mut method = None; + let mut certs: Vec> = vec![]; + + loop { + // Find start of PEM block + let mut start_pos = 0; + for (pos, &ch) in bytes.by_ref() { + if ch.is_ascii_whitespace() { + continue; + } else if ch == b'-' { + start_pos = pos; + break; + } else { + return Ok(None); + } + } + + // Find block type + for (_, &ch) in bytes.by_ref() { + match ch { + b'-' => (), + b'\n' => break, + _ => { + if ch.is_ascii() { + buf.push(ch.to_ascii_uppercase()); + } else { + return Ok(None); + } + } + } + } + if buf.is_empty() { + break; + } + + // Find type + let tag = std::str::from_utf8(&buf).unwrap(); + if tag.contains("CERTIFICATE") { + if method.is_some_and(|m| m == EncryptionMethod::PGP) { + return Err("Cannot mix OpenPGP and S/MIME certificates".into()); + } else { + method = Some(EncryptionMethod::SMIME); + } + } else if tag.contains("PGP") { + if method.is_some_and(|m| m == EncryptionMethod::SMIME) { + return Err("Cannot mix OpenPGP and S/MIME certificates".into()); + } else { + method = Some(EncryptionMethod::PGP); + } + } else { + // Ignore block + let mut found_end = false; + for (_, &ch) in bytes.by_ref() { + if ch == b'-' { + found_end = true; + } else if ch == b'\n' && found_end { + break; + } + } + buf.clear(); + continue; + } + + // Collect base64 + buf.clear(); + let mut found_end = false; + let mut end_pos = 0; + for (pos, &ch) in bytes.by_ref() { + match ch { + b'-' => { + found_end = true; + } + b'\n' => { + if found_end { + end_pos = pos; + break; + } + } + _ => { + if !ch.is_ascii_whitespace() { + buf.push(ch); + } + } + } + } + + // Decode base64 + let cert = base64_decode(&buf) + .ok_or_else(|| Cow::from("Failed to decode base64 certificate."))? + .into_boxed_slice(); + match method.unwrap() { + EncryptionMethod::PGP => match Cert::from_bytes(bytes_) { + Ok(cert) => { + if !has_pgp_keys(cert) { + return Err("Could not find any suitable keys in OpenPGP public key".into()); + } + certs.push( + bytes_ + .get(start_pos..end_pos + 1) + .unwrap_or_default() + .into(), + ); + } + Err(err) => { + return Err(format!("Failed to decode OpenPGP public key: {err}").into()); + } + }, + EncryptionMethod::SMIME => { + if let Err(err) = rasn::der::decode::(&cert) { + return Err(format!("Failed to decode X509 certificate: {err}").into()); + } + certs.push(cert); + } + } + buf.clear(); + } + + Ok(method.map(|method| EncryptionParams { + method, + certs: certs.into_boxed_slice(), + })) +} + +fn has_pgp_keys(cert: Cert) -> bool { + cert.keys() + .with_policy(&P, None) + .supported() + .alive() + .revoked(false) + .key_flags(KeyFlags::empty().set_transport_encryption()) + .next() + .is_some() +} diff --git a/crates/common/src/storage/index.rs b/crates/common/src/storage/index.rs new file mode 100644 index 0000000..9b305ca --- /dev/null +++ b/crates/common/src/storage/index.rs @@ -0,0 +1,784 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{auth::AccountTenantIds, sharing::notification::ShareNotification}; +use registry::schema::{ + enums::IndexDocumentType, + structs::{Task, TaskIndexDocument, TaskStatus}, +}; +use rkyv::{ + option::ArchivedOption, + primitive::{ArchivedU32, ArchivedU64}, + string::ArchivedString, +}; +use std::{borrow::Cow, fmt::Debug}; +use store::{ + Serialize, SerializeInfallible, + write::{ + Archive, Archiver, BatchBuilder, BlobLink, BlobOp, IntoOperations, Params, SearchIndex, + ValueClass, + }, +}; +use types::{ + acl::AclGrant, + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, + field::Field, +}; +use utils::{cheeky_hash::CheekyHash, map::bitmap::Bitmap, snowflake::SnowflakeIdGenerator}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IndexValue<'x> { + Index { + field: Field, + value: IndexItem<'x>, + }, + Property { + field: ValueClass, + value: IndexItem<'x>, + }, + SearchIndex { + index: SearchIndex, + hash: u64, + }, + Blob { + value: BlobHash, + }, + Quota { + used: u32, + }, + LogContainer { + sync_collection: SyncCollection, + }, + LogContainerProperty { + sync_collection: SyncCollection, + ids: Vec, + }, + LogItem { + sync_collection: SyncCollection, + prefix: Option, + }, + Acl { + value: Cow<'x, [AclGrant]>, + }, +} + +#[derive(Debug, Clone)] +pub enum IndexItem<'x> { + Vec(Vec), + Slice(&'x [u8]), + ShortInt([u8; std::mem::size_of::()]), + LongInt([u8; std::mem::size_of::()]), + Hash(CheekyHash), + None, +} + +impl IndexItem<'_> { + pub fn as_slice(&self) -> &[u8] { + match self { + IndexItem::Vec(v) => v, + IndexItem::Slice(s) => s, + IndexItem::ShortInt(s) => s, + IndexItem::LongInt(s) => s, + IndexItem::Hash(h) => h.as_bytes(), + IndexItem::None => &[], + } + } + + pub fn into_owned(self) -> Vec { + match self { + IndexItem::Vec(v) => v, + IndexItem::Slice(s) => s.to_vec(), + IndexItem::ShortInt(s) => s.to_vec(), + IndexItem::LongInt(s) => s.to_vec(), + IndexItem::Hash(h) => h.as_bytes().to_vec(), + IndexItem::None => vec![], + } + } + + pub fn is_empty(&self) -> bool { + match self { + IndexItem::Vec(v) => v.is_empty(), + IndexItem::Slice(s) => s.is_empty(), + IndexItem::None => true, + _ => false, + } + } + + pub fn is_none(&self) -> bool { + matches!(self, IndexItem::None) + } + + pub fn is_some(&self) -> bool { + !self.is_none() + } +} + +impl PartialEq for IndexItem<'_> { + fn eq(&self, other: &Self) -> bool { + self.as_slice() == other.as_slice() + } +} + +impl Eq for IndexItem<'_> {} + +impl std::hash::Hash for IndexItem<'_> { + fn hash(&self, state: &mut H) { + match self { + IndexItem::Vec(v) => v.as_slice().hash(state), + IndexItem::Slice(s) => s.hash(state), + IndexItem::ShortInt(s) => s.as_slice().hash(state), + IndexItem::LongInt(s) => s.as_slice().hash(state), + IndexItem::Hash(h) => h.hash(state), + IndexItem::None => 0.hash(state), + } + } +} + +impl From for IndexItem<'_> { + fn from(value: u32) -> Self { + IndexItem::ShortInt(value.to_be_bytes()) + } +} + +impl From<&u32> for IndexItem<'_> { + fn from(value: &u32) -> Self { + IndexItem::ShortInt(value.to_be_bytes()) + } +} + +impl From for IndexItem<'_> { + fn from(value: u64) -> Self { + IndexItem::LongInt(value.to_be_bytes()) + } +} + +impl From for IndexItem<'_> { + fn from(value: i64) -> Self { + IndexItem::LongInt(value.to_be_bytes()) + } +} + +impl<'x> From<&'x [u8]> for IndexItem<'x> { + fn from(value: &'x [u8]) -> Self { + IndexItem::Slice(value) + } +} + +impl From> for IndexItem<'_> { + fn from(value: Vec) -> Self { + IndexItem::Vec(value) + } +} + +impl<'x> From<&'x str> for IndexItem<'x> { + fn from(value: &'x str) -> Self { + IndexItem::Slice(value.as_bytes()) + } +} + +impl<'x> From<&'x String> for IndexItem<'x> { + fn from(value: &'x String) -> Self { + IndexItem::Slice(value.as_bytes()) + } +} + +impl From for IndexItem<'_> { + fn from(value: String) -> Self { + IndexItem::Vec(value.into_bytes()) + } +} + +impl<'x> From<&'x ArchivedString> for IndexItem<'x> { + fn from(value: &'x ArchivedString) -> Self { + IndexItem::Slice(value.as_bytes()) + } +} + +impl From for IndexItem<'_> { + fn from(value: ArchivedU32) -> Self { + IndexItem::ShortInt(value.to_native().to_be_bytes()) + } +} + +impl From<&ArchivedU32> for IndexItem<'_> { + fn from(value: &ArchivedU32) -> Self { + IndexItem::ShortInt(value.to_native().to_be_bytes()) + } +} + +impl From for IndexItem<'_> { + fn from(value: ArchivedU64) -> Self { + IndexItem::LongInt(value.to_native().to_be_bytes()) + } +} + +impl<'x, T: Into>> From> for IndexItem<'x> { + fn from(value: Option) -> Self { + match value { + Some(v) => v.into(), + None => IndexItem::None, + } + } +} + +impl<'x, T: Into>> From> for IndexItem<'x> { + fn from(value: ArchivedOption) -> Self { + match value { + ArchivedOption::Some(v) => v.into(), + ArchivedOption::None => IndexItem::None, + } + } +} + +pub trait IndexableObject: Sync + Send { + fn index_values(&self) -> impl Iterator>; +} + +pub trait IndexableAndSerializableObject: + IndexableObject + + rkyv::Archive + + for<'a> rkyv::Serialize< + rkyv::api::high::HighSerializer< + rkyv::util::AlignedVec, + rkyv::ser::allocator::ArenaHandle<'a>, + rkyv::rancor::Error, + >, + > +{ + fn is_versioned() -> bool; +} + +#[derive(Debug)] +pub struct ObjectIndexBuilder { + changed_by: u32, + tenant_id: Option, + current: Option>, + changes: Option, +} + +impl Default for ObjectIndexBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ObjectIndexBuilder { + pub fn new() -> Self { + Self { + current: None, + changes: None, + tenant_id: None, + changed_by: u32::MAX, + } + } + + pub fn with_current(mut self, current: Archive) -> Self { + self.current = Some(current); + self + } + + pub fn with_changes(mut self, changes: N) -> Self { + self.changes = Some(changes); + self + } + + pub fn with_current_opt(mut self, current: Option>) -> Self { + self.current = current; + self + } + + pub fn changes(&self) -> Option<&N> { + self.changes.as_ref() + } + + pub fn changes_mut(&mut self) -> Option<&mut N> { + self.changes.as_mut() + } + + pub fn current(&self) -> Option<&Archive> { + self.current.as_ref() + } + + pub fn with_changed_by(mut self, ids: AccountTenantIds) -> Self { + self.tenant_id = ids.tenant_id; + self.changed_by = ids.account_id; + self + } + + pub fn with_tenant_id(mut self, tenant_id: Option) -> Self { + self.tenant_id = tenant_id; + self + } +} + +impl IntoOperations + for ObjectIndexBuilder +{ + fn build(self, batch: &mut BatchBuilder) -> trc::Result<()> { + match (self.current, self.changes) { + (None, Some(changes)) => { + // Insertion + for item in changes.index_values() { + build_index(batch, item, self.changed_by, self.tenant_id, true); + } + if N::is_versioned() { + let (offset, bytes) = Archiver::new(changes).serialize_versioned()?; + batch.set_fnc( + Field::ARCHIVE, + Params::with_capacity(2).with_bytes(bytes).with_u64(offset), + |params, ids| { + let change_id = ids.current_change_id()?; + let archive = params.bytes(0); + let offset = params.u64(1); + + let mut bytes = Vec::with_capacity(archive.len()); + bytes.extend_from_slice(&archive[..offset as usize]); + bytes.extend_from_slice(&change_id.to_be_bytes()[..]); + bytes.push(archive.last().copied().unwrap()); // Marker + Ok(bytes) + }, + ); + } else { + batch.set(Field::ARCHIVE, Archiver::new(changes).serialize()?); + } + } + (Some(current), Some(changes)) => { + // Update + batch.assert_value(Field::ARCHIVE, ¤t); + for (current, change) in current.inner.index_values().zip(changes.index_values()) { + if current != change { + merge_index(batch, current, change, self.changed_by, self.tenant_id)?; + } else { + match current { + IndexValue::LogContainer { sync_collection } => { + batch.log_container_update(sync_collection); + } + IndexValue::LogItem { + sync_collection, + prefix, + } => { + batch.log_item_update(sync_collection, prefix); + } + _ => (), + } + } + } + if N::is_versioned() { + let (offset, bytes) = Archiver::new(changes).serialize_versioned()?; + batch.set_fnc( + Field::ARCHIVE, + Params::with_capacity(2).with_bytes(bytes).with_u64(offset), + |params, ids| { + let change_id = ids.current_change_id()?; + let archive = params.bytes(0); + let offset = params.u64(1); + + let mut bytes = Vec::with_capacity(archive.len()); + bytes.extend_from_slice(&archive[..offset as usize]); + bytes.extend_from_slice(&change_id.to_be_bytes()[..]); + bytes.push(archive.last().copied().unwrap()); // Marker + Ok(bytes) + }, + ); + } else { + batch.set(Field::ARCHIVE, Archiver::new(changes).serialize()?); + } + } + (Some(current), None) => { + // Deletion + batch.assert_value(Field::ARCHIVE, ¤t); + for item in current.inner.index_values() { + build_index(batch, item, self.changed_by, self.tenant_id, false); + } + + batch.clear(Field::ARCHIVE); + } + (None, None) => unreachable!(), + } + + Ok(()) + } +} + +fn build_index( + batch: &mut BatchBuilder, + item: IndexValue<'_>, + changed_by: u32, + tenant_id: Option, + set: bool, +) { + match item { + IndexValue::Index { field, value } => { + if !value.is_empty() { + if set { + batch.index(field, value.into_owned()); + } else { + batch.unindex(field, value.into_owned()); + } + } + } + IndexValue::SearchIndex { index, .. } => { + let task = TaskIndexDocument { + account_id: batch.last_account_id().unwrap().into(), + document_id: batch.last_document_id().unwrap().into(), + document_type: match index { + SearchIndex::Email => IndexDocumentType::Email, + SearchIndex::Calendar => IndexDocumentType::Calendar, + SearchIndex::Contacts => IndexDocumentType::Contacts, + SearchIndex::File => IndexDocumentType::File, + SearchIndex::Tracing | SearchIndex::InMemory => unreachable!(), + }, + status: TaskStatus::now(), + }; + batch.schedule_task(if set { + Task::IndexDocument(task) + } else { + Task::UnindexDocument(task) + }); + } + IndexValue::Property { field, value } => { + if !value.is_none() { + if set { + batch.set(field, value.into_owned()); + } else { + batch.clear(field); + } + } + } + IndexValue::Blob { value } => { + if set { + batch.set( + BlobOp::Link { + hash: value, + to: BlobLink::Document, + }, + vec![], + ); + } else { + batch.clear(BlobOp::Link { + hash: value, + to: BlobLink::Document, + }); + } + } + IndexValue::Acl { value } => { + let object_account_id = batch.last_account_id().unwrap_or_default(); + let object_type = batch.last_collection().unwrap_or(Collection::None); + let object_id = batch.last_document_id().unwrap_or_default(); + let notification_id = SnowflakeIdGenerator::global_id().unwrap_or_default(); + + for item in value.as_ref() { + if set { + batch.acl_grant(item.account_id, item.grants.bitmap.serialize()); + batch.log_share_notification( + notification_id, + item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights: Default::default(), + new_rights: item.grants, + name: Default::default(), + }, + ); + } else { + batch.acl_revoke(item.account_id); + batch.log_share_notification( + notification_id, + item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights: item.grants, + new_rights: Default::default(), + name: Default::default(), + }, + ); + } + } + } + IndexValue::Quota { used } => { + let value = if set { used as i64 } else { -(used as i64) }; + + batch.add(ValueClass::Quota, value); + + if let Some(tenant_id) = tenant_id { + batch.add(ValueClass::TenantQuota(tenant_id), value); + } + } + IndexValue::LogItem { + sync_collection, + prefix, + } => { + if set { + batch.log_item_insert(sync_collection, prefix); + } else { + batch.log_item_delete(sync_collection, prefix); + } + } + IndexValue::LogContainer { sync_collection } => { + if set { + batch.log_container_insert(sync_collection); + } else { + batch.log_container_delete(sync_collection); + } + } + IndexValue::LogContainerProperty { + sync_collection, + ids, + } => { + for parent_id in ids { + batch.log_container_property_change(sync_collection, parent_id); + } + } + } +} + +fn merge_index( + batch: &mut BatchBuilder, + current: IndexValue<'_>, + change: IndexValue<'_>, + changed_by: u32, + tenant_id: Option, +) -> trc::Result<()> { + match (current, change) { + ( + IndexValue::Index { + field, + value: old_value, + }, + IndexValue::Index { + value: new_value, .. + }, + ) => { + if !old_value.is_empty() { + batch.unindex(field, old_value.into_owned()); + } + + if !new_value.is_empty() { + batch.index(field, new_value.into_owned()); + } + } + (IndexValue::SearchIndex { index, .. }, IndexValue::SearchIndex { .. }) => { + batch.schedule_task(Task::IndexDocument(TaskIndexDocument { + account_id: batch.last_account_id().unwrap().into(), + document_id: batch.last_document_id().unwrap().into(), + document_type: match index { + SearchIndex::Email => IndexDocumentType::Email, + SearchIndex::Calendar => IndexDocumentType::Calendar, + SearchIndex::Contacts => IndexDocumentType::Contacts, + SearchIndex::File => IndexDocumentType::File, + SearchIndex::Tracing | SearchIndex::InMemory => unreachable!(), + }, + status: TaskStatus::now(), + })); + } + ( + IndexValue::Property { + field: old_field, + value: old_value, + }, + IndexValue::Property { + field: new_field, + value: new_value, + .. + }, + ) => { + if old_field != new_field { + batch.clear(old_field); + batch.set(new_field, new_value.into_owned()); + } else if new_value != old_value { + if new_value.is_some() { + batch.set(old_field, new_value.into_owned()); + } else { + batch.clear(old_field); + } + } + } + (IndexValue::Blob { value: old_hash }, IndexValue::Blob { value: new_hash }) => { + batch.clear(BlobOp::Link { + hash: old_hash, + to: BlobLink::Document, + }); + batch.set( + BlobOp::Link { + hash: new_hash, + to: BlobLink::Document, + }, + vec![], + ); + } + (IndexValue::Acl { value: old_acl }, IndexValue::Acl { value: new_acl }) => { + let has_old_acl = !old_acl.is_empty(); + let has_new_acl = !new_acl.is_empty(); + + if !has_old_acl && !has_new_acl { + return Ok(()); + } + + let object_account_id = batch.last_account_id().unwrap_or_default(); + let object_type = batch.last_collection().unwrap_or(Collection::None); + let object_id = batch.last_document_id().unwrap_or_default(); + let notification_id = SnowflakeIdGenerator::global_id().unwrap_or_default(); + + match (has_old_acl, has_new_acl) { + (true, true) => { + // Remove deleted ACLs + for current_item in old_acl.as_ref() { + if !new_acl + .iter() + .any(|item| item.account_id == current_item.account_id) + { + batch.acl_revoke(current_item.account_id); + batch.log_share_notification( + notification_id, + current_item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights: current_item.grants, + new_rights: Default::default(), + name: Default::default(), + }, + ); + } + } + + // Update ACLs + for item in new_acl.as_ref() { + let mut add_item = true; + let mut old_rights = Bitmap::default(); + for current_item in old_acl.as_ref() { + if item.account_id == current_item.account_id { + if item.grants == current_item.grants { + add_item = false; + } else { + old_rights = current_item.grants; + } + break; + } + } + if add_item { + batch.acl_grant(item.account_id, item.grants.bitmap.serialize()); + batch.log_share_notification( + notification_id, + item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights, + new_rights: item.grants, + name: Default::default(), + }, + ); + } + } + } + (false, true) => { + // Add all ACLs + for item in new_acl.as_ref() { + batch.acl_grant(item.account_id, item.grants.bitmap.serialize()); + batch.log_share_notification( + notification_id, + item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights: Default::default(), + new_rights: item.grants, + name: Default::default(), + }, + ); + } + } + (true, false) => { + // Remove all ACLs + for item in old_acl.as_ref() { + batch.acl_revoke(item.account_id); + batch.log_share_notification( + notification_id, + item.account_id, + ShareNotification { + object_account_id, + object_id, + object_type, + changed_by, + old_rights: item.grants, + new_rights: Default::default(), + name: Default::default(), + }, + ); + } + } + _ => {} + } + } + (IndexValue::Quota { used: old_used }, IndexValue::Quota { used: new_used }) => { + let value = new_used as i64 - old_used as i64; + batch.add(ValueClass::Quota, value); + + if let Some(tenant_id) = tenant_id { + batch.add(ValueClass::TenantQuota(tenant_id), value); + } + } + ( + IndexValue::LogItem { + sync_collection, + prefix: old_prefix, + }, + IndexValue::LogItem { + prefix: new_prefix, .. + }, + ) => { + batch.log_item_delete(sync_collection, old_prefix); + batch.log_item_insert(sync_collection, new_prefix); + } + ( + IndexValue::LogContainerProperty { + sync_collection, + ids: old_ids, + }, + IndexValue::LogContainerProperty { ids: new_ids, .. }, + ) => { + for parent_id in &old_ids { + if !new_ids.contains(parent_id) { + batch.log_container_property_change(sync_collection, *parent_id); + } + } + for parent_id in new_ids { + if !old_ids.contains(&parent_id) { + batch.log_container_property_change(sync_collection, parent_id); + } + } + } + _ => unreachable!(), + } + + Ok(()) +} + +impl IndexableObject for () { + fn index_values(&self) -> impl Iterator> { + std::iter::empty() + } +} + +impl IndexableAndSerializableObject for () { + fn is_versioned() -> bool { + false + } +} diff --git a/crates/common/src/storage/mod.rs b/crates/common/src/storage/mod.rs new file mode 100644 index 0000000..f65943b --- /dev/null +++ b/crates/common/src/storage/mod.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Server; +use directory::Directory; +use registry::{ + schema::{ + enums::{StorageQuota, TenantStorageQuota}, + prelude::ObjectType, + }, + types::EnumImpl, +}; +use std::sync::Arc; +use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store}; + +pub mod archive; +pub mod blob; +pub mod dav; +pub mod document; +pub mod encryption; +pub mod index; +pub mod quota; +pub mod state; +pub mod transaction; + +#[derive(Debug, Clone)] +pub struct ObjectQuota([u32; StorageQuota::COUNT - 1]); + +#[derive(Debug, Clone)] +pub struct TenantQuota([u32; TenantStorageQuota::COUNT - 1]); + +impl Server { + #[inline(always)] + pub fn registry(&self) -> &RegistryStore { + &self.core.storage.registry + } + + #[inline(always)] + pub fn store(&self) -> &Store { + &self.core.storage.data + } + + #[inline(always)] + pub fn blob_store(&self) -> &BlobStore { + &self.core.storage.blob + } + + #[inline(always)] + pub fn search_store(&self) -> &SearchStore { + &self.core.storage.search + } + + #[inline(always)] + pub fn in_memory_store(&self) -> &InMemoryStore { + &self.core.storage.memory + } + + #[inline(always)] + pub fn tracing_store(&self) -> &Store { + &self.core.storage.tracing + } + + #[inline(always)] + pub fn metrics_store(&self) -> &Store { + &self.core.storage.metrics + } + + #[inline(always)] + pub fn get_directory(&self, id: &u32) -> Option<&Arc> { + self.core.storage.directories.get(id) + } + + #[inline(always)] + pub fn get_default_directory(&self) -> Option<&Arc> { + self.core.storage.directory.as_ref() + } + + #[inline(always)] + pub fn get_lookup_store(&self, name: &str) -> Option { + if !name.is_empty() && name != "*" { + self.inner.data.lookup_stores.load().get(name).cloned() + } else { + self.in_memory_store().clone().into() + } + } + + pub async fn total_accounts(&self) -> trc::Result { + self.registry().count_object(ObjectType::Account).await + } + + pub async fn total_domains(&self) -> trc::Result { + self.registry().count_object(ObjectType::Domain).await + } + + #[cfg(not(feature = "enterprise"))] + pub async fn logo_resource( + &self, + _: &str, + ) -> trc::Result>>> { + Ok(None) + } +} diff --git a/crates/common/src/storage/quota.rs b/crates/common/src/storage/quota.rs new file mode 100644 index 0000000..b106f23 --- /dev/null +++ b/crates/common/src/storage/quota.rs @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + auth::AccountCache, + storage::{ObjectQuota, TenantQuota}, +}; +use registry::{ + schema::enums::{StorageQuota, TenantStorageQuota}, + types::EnumImpl, +}; +use store::{ValueKey, write::ValueClass}; +use trc::AddContext; + +impl Server { + pub async fn get_used_quota_account(&self, account_id: u32) -> trc::Result { + self.core + .storage + .data + .get_counter(ValueKey { + account_id, + collection: 0, + document_id: 0, + class: ValueClass::Quota, + }) + .await + .add_context(|err| err.caused_by(trc::location!()).account_id(account_id)) + } + + #[cfg(not(feature = "enterprise"))] + pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result { + Ok(0) + } + + pub async fn has_available_quota( + &self, + account: &AccountCache, + item_size: u64, + ) -> trc::Result<()> { + if account.quota_disk != 0 { + let used_quota = self.get_used_quota_account(account.id).await?.max(0) as u64; + + if used_quota + item_size > account.quota_disk { + return Err(trc::LimitEvent::Quota + .into_err() + .ctx(trc::Key::Limit, account.quota_disk) + .ctx(trc::Key::Size, used_quota)); + } + } + + + Ok(()) + } + + #[inline(always)] + pub fn object_quota(&self, user_quotas: Option<&ObjectQuota>, object: StorageQuota) -> u32 { + user_quotas.unwrap_or(&self.core.email.max_objects).0[object as usize] + } +} + +impl ObjectQuota { + #[inline(always)] + pub fn set(&mut self, item: StorageQuota, max: u32) { + self.0[item as usize] = max; + } + + #[inline(always)] + pub fn get(&self, item: StorageQuota) -> u32 { + self.0[item as usize] + } +} + +impl TenantQuota { + #[inline(always)] + pub fn set(&mut self, item: TenantStorageQuota, max: u32) { + self.0[item as usize] = max; + } + + #[inline(always)] + pub fn get(&self, item: TenantStorageQuota) -> u32 { + self.0[item as usize] + } +} + +impl Default for ObjectQuota { + fn default() -> Self { + Self([u32::MAX; StorageQuota::COUNT - 1]) + } +} + +impl Default for TenantQuota { + fn default() -> Self { + Self([u32::MAX; TenantStorageQuota::COUNT - 1]) + } +} diff --git a/crates/common/src/storage/state.rs b/crates/common/src/storage/state.rs new file mode 100644 index 0000000..979fb4a --- /dev/null +++ b/crates/common/src/storage/state.rs @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IPC_CHANNEL_BUFFER, Server, + auth::AccessToken, + ipc::{BroadcastEvent, PushEvent, PushNotification}, +}; +use tokio::sync::mpsc; +use types::type_state::DataType; +use utils::map::bitmap::Bitmap; + +impl Server { + pub async fn subscribe_push_manager( + &self, + access_token: &AccessToken, + types: Bitmap, + ) -> trc::Result> { + let (tx, rx) = mpsc::channel::(IPC_CHANNEL_BUFFER); + let push_tx = self.inner.ipc.push_tx.clone(); + + push_tx + .send(PushEvent::Subscribe { + account_ids: access_token.member_ids().collect(), + types, + tx, + }) + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + })?; + + Ok(rx) + } + + #[inline(always)] + pub fn notify_task_queue(&self) { + self.inner.ipc.task_tx.notify_one(); + } + + pub async fn broadcast_push_notification(&self, notification: PushNotification) -> bool { + match self + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::Publish { + notification, + broadcast: true, + }) + .await + { + Ok(_) => true, + Err(_) => { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending state change.", + CausedBy = trc::location!() + ); + + false + } + } + } + + pub async fn cluster_broadcast(&self, event: BroadcastEvent) { + if let Some(broadcast_tx) = &self.inner.ipc.broadcast_tx.clone() + && broadcast_tx.send(event).await.is_err() + { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending broadcast event.", + CausedBy = trc::location!() + ); + } + } +} diff --git a/crates/common/src/storage/transaction.rs b/crates/common/src/storage/transaction.rs new file mode 100644 index 0000000..f8e6fc0 --- /dev/null +++ b/crates/common/src/storage/transaction.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Server, ipc::PushNotification}; +use std::time::Duration; +use store::{ + IterateParams, Key, LogKey, SUBSPACE_LOGS, U64_LEN, + write::{AnyClass, AssignedIds, BatchBuilder, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::{ + collection::SyncCollection, + type_state::{DataType, StateChange}, +}; +use utils::{map::bitmap::Bitmap, snowflake::SnowflakeIdGenerator}; + +impl Server { + pub async fn commit_batch(&self, mut builder: BatchBuilder) -> trc::Result { + let mut assigned_ids = AssignedIds::default(); + let mut commit_points = builder.commit_points(); + + for commit_point in commit_points.iter() { + let batch = builder.build_one(commit_point); + assigned_ids + .ids + .extend(self.store().write(batch).await?.ids); + } + + if let Some(changes) = builder.changes() { + for (account_id, changed_collections) in changes { + let mut state_change = StateChange::new(account_id); + for changed_collection in changed_collections.changed_containers { + if let Some(data_type) = DataType::try_from_sync(changed_collection, true) { + state_change.set_change(data_type); + } + } + for changed_collection in changed_collections.changed_items { + if let Some(data_type) = DataType::try_from_sync(changed_collection, false) { + state_change.set_change(data_type); + } + } + if state_change.has_changes() { + self.broadcast_push_notification(PushNotification::StateChange( + state_change.with_change_id(assigned_ids.last_change_id(account_id)?), + )) + .await; + } + if let Some(change_id) = changed_collections.share_notification_id { + self.broadcast_push_notification(PushNotification::StateChange(StateChange { + account_id, + change_id, + types: Bitmap::from_iter([DataType::ShareNotification]), + })) + .await; + } + } + } + + Ok(assigned_ids) + } + + pub async fn delete_changes( + &self, + account_id: u32, + max_entries: Option, + max_duration: Option, + ) -> trc::Result<()> { + if let Some(max_entries) = max_entries { + for sync_collection in [ + SyncCollection::Email, + SyncCollection::Thread, + SyncCollection::Identity, + SyncCollection::EmailSubmission, + SyncCollection::SieveScript, + SyncCollection::FileNode, + SyncCollection::AddressBook, + SyncCollection::Calendar, + SyncCollection::CalendarEventNotification, + ] { + let collection = sync_collection.into(); + let from_key = LogKey { + account_id, + collection, + change_id: 0, + }; + let to_key = LogKey { + account_id, + collection, + change_id: u64::MAX, + }; + + let mut first_change_id = 0; + let mut num_changes = 0; + + self.store() + .iterate( + IterateParams::new(from_key, to_key) + .descending() + .no_values(), + |key, _| { + first_change_id = key.deserialize_be_u64(key.len() - U64_LEN)?; + num_changes += 1; + + Ok(num_changes <= max_entries) + }, + ) + .await + .caused_by(trc::location!())?; + + if num_changes > max_entries { + self.store() + .delete_range( + LogKey { + account_id, + collection, + change_id: 0, + }, + LogKey { + account_id, + collection, + change_id: first_change_id, + }, + ) + .await + .caused_by(trc::location!())?; + + // Delete vanished items + if let Some(vanished_collection) = + sync_collection.vanished_collection().map(u8::from) + { + self.store() + .delete_range( + LogKey { + account_id, + collection: vanished_collection, + change_id: 0, + }, + LogKey { + account_id, + collection: vanished_collection, + change_id: first_change_id, + }, + ) + .await + .caused_by(trc::location!())?; + } + + // Write truncation entry for cache + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id).set( + ValueClass::Any(AnyClass { + subspace: SUBSPACE_LOGS, + key: LogKey { + account_id, + collection, + change_id: first_change_id, + } + .serialize(0), + }), + Vec::new(), + ); + self.store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } + } + + if let Some(max_duration) = max_duration { + self.store() + .delete_range( + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: 0, + }, + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: SnowflakeIdGenerator::from_duration(max_duration) + .unwrap_or_default(), + }, + ) + .await + .caused_by(trc::location!())?; + } + Ok(()) + } + + #[inline(always)] + pub fn generate_snowflake_id(&self) -> u64 { + self.inner.data.jmap_id_gen.generate() + } +} diff --git a/crates/common/src/telemetry/metrics/mod.rs b/crates/common/src/telemetry/metrics/mod.rs new file mode 100644 index 0000000..831aa42 --- /dev/null +++ b/crates/common/src/telemetry/metrics/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod otel; +pub mod prometheus; + + diff --git a/crates/common/src/telemetry/metrics/otel.rs b/crates/common/src/telemetry/metrics/otel.rs new file mode 100644 index 0000000..e7dd9ef --- /dev/null +++ b/crates/common/src/telemetry/metrics/otel.rs @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::telemetry::OtelMetrics; +use opentelemetry_sdk::metrics::{ + Temporality, + data::{ + AggregatedMetrics, Gauge, GaugeDataPoint, Histogram, HistogramDataPoint, Metric, + MetricData, ResourceMetrics, ScopeMetrics, Sum, SumDataPoint, + }, + exporter::PushMetricExporter, +}; +use std::time::SystemTime; +use trc::{Collector, TelemetryEvent}; + +impl OtelMetrics { + pub async fn push_metrics(&self, is_enterprise: bool, start_time: SystemTime) { + let mut metrics = Vec::with_capacity(256); + let time = SystemTime::now(); + + // Add counters + for counter in Collector::collect_counters(is_enterprise) { + metrics.push(Metric::new( + counter.id().as_str(), + counter.id().description(), + "events", + AggregatedMetrics::U64(MetricData::Sum(Sum::new( + vec![SumDataPoint::new(vec![], counter.value(), vec![])], + start_time, + time, + Temporality::Cumulative, + true, + ))), + )); + } + + // Add gauges + for gauge in Collector::collect_gauges(is_enterprise) { + metrics.push(Metric::new( + gauge.id().as_str(), + gauge.id().description(), + gauge.id().unit(), + AggregatedMetrics::U64(MetricData::Gauge(Gauge::new( + vec![GaugeDataPoint::new(vec![], gauge.get(), vec![])], + Some(start_time), + time, + ))), + )); + } + + // Add histograms + for histogram in Collector::collect_histograms(is_enterprise) { + metrics.push(Metric::new( + histogram.id().as_str(), + histogram.id().description(), + histogram.id().unit(), + AggregatedMetrics::U64(MetricData::Histogram(Histogram::new( + vec![HistogramDataPoint::new( + vec![], + histogram.count(), + histogram.upper_bounds_vec(), + histogram.buckets_vec(), + histogram.min(), + histogram.max(), + histogram.sum(), + vec![], + )], + start_time, + time, + Temporality::Cumulative, + ))), + )); + } + + // Export metrics + let rm = ResourceMetrics::new( + self.resource.clone(), + vec![ScopeMetrics::new(self.instrumentation.clone(), metrics)], + ); + if let Err(err) = self.exporter.export(&rm).await { + trc::event!( + Telemetry(TelemetryEvent::OtelMetricsExporterError), + Reason = err.to_string(), + ); + } + } + + pub fn enable_errors() { + // TODO: Remove this when the OpenTelemetry SDK supports error handling + /*let _ = set_error_handler(|error| { + trc::event!( + Telemetry(TelemetryEvent::OtelMetricsExporterError), + Reason = error.to_string(), + ); + });*/ + } +} diff --git a/crates/common/src/telemetry/metrics/prometheus.rs b/crates/common/src/telemetry/metrics/prometheus.rs new file mode 100644 index 0000000..cea5bfd --- /dev/null +++ b/crates/common/src/telemetry/metrics/prometheus.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use prometheus::{ + TextEncoder, + proto::{Bucket, Counter, Gauge, Histogram, Metric, MetricFamily, MetricType}, +}; +use trc::{Collector, atomics::histogram::AtomicHistogram}; + +use crate::Server; + +impl Server { + pub async fn export_prometheus_metrics(&self) -> trc::Result { + let mut metrics = Vec::new(); + + + #[cfg(not(feature = "enterprise"))] + let is_enterprise = false; + + // Add counters + for counter in Collector::collect_counters(is_enterprise) { + let mut metric = MetricFamily::default(); + metric.set_name(metric_name(counter.id().as_str())); + metric.set_help(counter.id().description().into()); + metric.set_field_type(MetricType::COUNTER); + metric.set_metric(vec![new_counter(counter.value())]); + metrics.push(metric); + } + + // Add gauges + for gauge in Collector::collect_gauges(is_enterprise) { + let mut metric = MetricFamily::default(); + metric.set_name(metric_name(gauge.id().as_str())); + metric.set_help(gauge.id().description().into()); + metric.set_field_type(MetricType::GAUGE); + metric.set_metric(vec![new_gauge(gauge.get())]); + metrics.push(metric); + } + + // Add histograms + for histogram in Collector::collect_histograms(is_enterprise) { + let mut metric = MetricFamily::default(); + metric.set_name(metric_name(histogram.id().as_str())); + metric.set_help(histogram.id().description().into()); + metric.set_field_type(MetricType::HISTOGRAM); + metric.set_metric(vec![new_histogram(histogram)]); + metrics.push(metric); + } + + TextEncoder::new().encode_to_string(&metrics).map_err(|e| { + trc::EventType::Telemetry(trc::TelemetryEvent::OtelExporterError).reason(e) + }) + } +} + +fn metric_name(id: impl AsRef) -> String { + let id = id.as_ref(); + let mut name = String::with_capacity(id.len()); + for c in id.chars() { + if c.is_ascii_alphanumeric() { + name.push(c); + } else { + name.push('_'); + } + } + name +} + +fn new_counter(value: u64) -> Metric { + let mut m = Metric::default(); + let mut counter = Counter::default(); + counter.set_value(value as f64); + m.set_counter(counter); + m +} + +fn new_gauge(value: u64) -> Metric { + let mut m = Metric::default(); + let mut gauge = Gauge::default(); + gauge.set_value(value as f64); + m.set_gauge(gauge); + m +} + +fn new_histogram(histogram: &AtomicHistogram<12>) -> Metric { + let mut m = Metric::default(); + let mut h = Histogram::default(); + h.set_sample_count(histogram.count()); + h.set_sample_sum(histogram.sum() as f64); + h.set_bucket( + histogram + .buckets_iter() + .into_iter() + .zip(histogram.upper_bounds_iter()) + .map(|(count, upper_bound)| { + let mut b = Bucket::default(); + b.set_cumulative_count(count); + b.set_upper_bound(if upper_bound != u64::MAX { + upper_bound as f64 + } else { + f64::INFINITY + }); + b + }) + .collect(), + ); + m.set_histogram(h); + m +} diff --git a/crates/common/src/telemetry/mod.rs b/crates/common/src/telemetry/mod.rs new file mode 100644 index 0000000..363a761 --- /dev/null +++ b/crates/common/src/telemetry/mod.rs @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod metrics; +pub mod tracers; +pub mod webhooks; + +use tracers::log::spawn_log_tracer; +use tracers::otel::spawn_otel_tracer; +use tracers::stdout::spawn_console_tracer; +use trc::{Collector, ipc::subscriber::SubscriberBuilder}; +use webhooks::spawn_webhook_tracer; + +use crate::config::telemetry::{Telemetry, TelemetrySubscriberType}; + +impl Telemetry { + pub fn enable(self, is_enterprise: bool) { + // Spawn tracers + for tracer in self.tracers.subscribers { + tracer.typ.spawn( + SubscriberBuilder::new(tracer.id) + .with_interests(tracer.interests) + .with_lossy(tracer.lossy), + is_enterprise, + ); + } + + // Update global collector + Collector::set_interests(self.tracers.interests); + Collector::update_custom_levels(self.tracers.levels); + Collector::set_metrics(self.metrics); + Collector::reload(); + } + + pub fn update(self, is_enterprise: bool) { + // Remove tracers that are no longer active + let active_subscribers = Collector::get_subscribers(); + for subscribed_id in &active_subscribers { + if !self + .tracers + .subscribers + .iter() + .any(|tracer| tracer.id == *subscribed_id) + { + Collector::remove_subscriber(subscribed_id.clone()); + } + } + + // Activate new tracers or update existing ones + for tracer in self.tracers.subscribers { + if active_subscribers.contains(&tracer.id) { + Collector::update_subscriber(tracer.id, tracer.interests, tracer.lossy); + } else { + tracer.typ.spawn( + SubscriberBuilder::new(tracer.id) + .with_interests(tracer.interests) + .with_lossy(tracer.lossy), + is_enterprise, + ); + } + } + + // Update global collector + Collector::set_interests(self.tracers.interests); + Collector::update_custom_levels(self.tracers.levels); + Collector::set_metrics(self.metrics); + Collector::reload(); + } + + #[cfg(feature = "test_mode")] + pub fn test_tracer(level: trc::Level) { + let mut interests = trc::ipc::subscriber::Interests::default(); + for event in trc::EventType::variants() { + if level.is_contained(event.level()) { + interests.set(*event); + } + } + + spawn_console_tracer( + SubscriberBuilder::new("stderr".to_string()) + .with_interests(interests.clone()) + .with_lossy(false), + crate::config::telemetry::ConsoleTracer { + ansi: true, + multiline: false, + buffered: false, + }, + ); + + Collector::union_interests(interests); + Collector::reload(); + } +} + +impl TelemetrySubscriberType { + pub fn spawn(self, builder: SubscriberBuilder, is_enterprise: bool) { + match self { + TelemetrySubscriberType::ConsoleTracer(settings) => { + spawn_console_tracer(builder, settings) + } + TelemetrySubscriberType::LogTracer(settings) => spawn_log_tracer(builder, settings), + TelemetrySubscriberType::Webhook(settings) => spawn_webhook_tracer(builder, settings), + TelemetrySubscriberType::OtelTracer(settings) => spawn_otel_tracer(builder, settings), + #[cfg(unix)] + TelemetrySubscriberType::JournalTracer(subscriber) => { + tracers::journald::spawn_journald_tracer(builder, subscriber) + } + } + } +} diff --git a/crates/common/src/telemetry/tracers/journald.rs b/crates/common/src/telemetry/tracers/journald.rs new file mode 100644 index 0000000..a237651 --- /dev/null +++ b/crates/common/src/telemetry/tracers/journald.rs @@ -0,0 +1,468 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashSet; +use std::io::Write; +use trc::ipc::subscriber::SubscriberBuilder; +use trc::{Event, EventDetails, Level, TelemetryEvent}; + +pub(crate) fn spawn_journald_tracer(builder: SubscriberBuilder, subscriber: Subscriber) { + let (_, mut rx) = builder.register(); + tokio::spawn(async move { + while let Some(events) = rx.recv().await { + for event in events { + subscriber.send_event(&event); + } + } + }); +} + +impl Subscriber { + fn send_event(&self, event: &Event) { + let mut buf = Vec::with_capacity(256); + put_field_wellformed( + &mut buf, + "PRIORITY", + &[match event.inner.level { + Level::Error => self.priority_mappings.error as u8, + Level::Warn => self.priority_mappings.warn as u8, + Level::Info => self.priority_mappings.info as u8, + Level::Debug => self.priority_mappings.debug as u8, + Level::Trace | Level::Disable => self.priority_mappings.trace as u8, + }], + ); + put_field_length_encoded(&mut buf, "SYSLOG_IDENTIFIER", |buf| { + write!(buf, "{}", self.syslog_identifier).unwrap() + }); + put_field_length_encoded(&mut buf, "MESSAGE", |buf| { + write!(buf, "{}", event.inner.typ.description()).unwrap() + }); + + let mut seen_keys = AHashSet::new(); + for (key, value) in event.keys.iter().chain( + event + .inner + .span + .as_ref() + .map_or(([]).iter(), |span| span.keys.iter()), + ) { + if seen_keys.insert(*key) { + put_field_length_encoded(&mut buf, key.as_str(), |buf| { + write!(buf, "{value}").unwrap() + }); + } + } + + if let Err(err) = self.send_payload(&buf) { + trc::event!( + Telemetry(TelemetryEvent::JournalError), + Details = "Failed to send event to journald", + Reason = err.to_string() + ); + } + } +} + +// SPDX-SnippetBegin +// SPDX-FileCopyrightText: 2018 Benjamin Saunders +// SPDX-License-Identifier: MIT + +#[cfg(target_os = "linux")] +use std::fs::File; +use std::io::{self, Error, Result}; +use std::mem::{size_of, zeroed}; +#[cfg(target_os = "linux")] +use std::os::raw::c_uint; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::net::UnixDatagram; +#[cfg(target_os = "linux")] +use std::os::unix::prelude::FromRawFd; +use std::os::unix::prelude::{AsRawFd, RawFd}; +use std::path::Path; +use std::ptr; + +use libc::*; + +#[cfg(unix)] +const JOURNALD_PATH: &str = "/run/systemd/journal/socket"; +const CMSG_BUFSIZE: usize = 64; + +pub struct Subscriber { + #[cfg(unix)] + socket: UnixDatagram, + syslog_identifier: String, + priority_mappings: PriorityMappings, +} + +#[derive(Debug, Clone)] +pub struct PriorityMappings { + /// Priority mapped to the `ERROR` level + pub error: Priority, + /// Priority mapped to the `WARN` level + pub warn: Priority, + /// Priority mapped to the `INFO` level + pub info: Priority, + /// Priority mapped to the `DEBUG` level + pub debug: Priority, + /// Priority mapped to the `TRACE` level + pub trace: Priority, +} + +#[repr(C)] +union AlignedBuffer { + buffer: T, + align: cmsghdr, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[repr(u8)] +pub enum Priority { + /// System is unusable. + /// + /// Examples: + /// + /// - severe Kernel BUG + /// - systemd dumped core + /// + /// This level should not be used by applications. + Emergency = b'0', + /// Should be corrected immediately. + /// + /// Examples: + /// + /// - Vital subsystem goes out of work, data loss: + /// - `kernel: BUG: unable to handle kernel paging request at ffffc90403238ffc` + Alert = b'1', + /// Critical conditions + /// + /// Examples: + /// + /// - Crashe, coredumps + /// - `systemd-coredump[25319]: Process 25310 (plugin-container) of user 1000 dumped core` + Critical = b'2', + /// Error conditions + /// + /// Examples: + /// + /// - Not severe error reported + /// - `kernel: usb 1-3: 3:1: cannot get freq at ep 0x84, systemd[1]: Failed unmounting /var` + /// - `libvirtd[1720]: internal error: Failed to initialize a valid firewall backend` + Error = b'3', + /// May indicate that an error will occur if action is not taken. + /// + /// Examples: + /// + /// - a non-root file system has only 1GB free + /// - `org.freedesktop. Notifications[1860]: (process:5999): Gtk-WARNING **: Locale not supported by C library. Using the fallback 'C' locale` + Warning = b'4', + /// Events that are unusual, but not error conditions. + /// + /// Examples: + /// + /// - `systemd[1]: var.mount: Directory /var to mount over is not empty, mounting anyway` + /// - `gcr-prompter[4997]: Gtk: GtkDialog mapped without a transient parent. This is discouraged` + Notice = b'5', + /// Normal operational messages that require no action. + /// + /// Example: `lvm[585]: 7 logical volume(s) in volume group "archvg" now active` + Informational = b'6', + /// Information useful to developers for debugging the + /// application. + /// + /// Example: `kdeinit5[1900]: powerdevil: Scheduling inhibition from ":1.14" "firefox" with cookie 13 and reason "screen"` + Debug = b'7', +} + +impl Subscriber { + /// Construct a journald subscriber + /// + /// Fails if the journald socket couldn't be opened. Returns a `NotFound` error unconditionally + /// in non-Unix environments. + pub fn new() -> io::Result { + #[cfg(unix)] + { + let socket = UnixDatagram::unbound()?; + let sub = Self { + socket, + syslog_identifier: std::env::current_exe() + .ok() + .as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + // If we fail to get the name of the current executable fall back to an empty string. + .unwrap_or_default(), + priority_mappings: PriorityMappings::new(), + }; + // Check that we can talk to journald, by sending empty payload which journald discards. + // However if the socket didn't exist or if none listened we'd get an error here. + sub.send_payload(&[])?; + Ok(sub) + } + #[cfg(not(unix))] + Err(io::Error::new( + io::ErrorKind::NotFound, + "journald does not exist in this environment", + )) + } + + /// Sets how [`tracing_core::Level`]s are mapped to [journald priorities](Priority). + /// + pub fn with_priority_mappings(mut self, mappings: PriorityMappings) -> Self { + self.priority_mappings = mappings; + self + } + + /// Sets the syslog identifier for this logger. + /// + /// The syslog identifier comes from the classic syslog interface (`openlog()` + /// and `syslog()`) and tags log entries with a given identifier. + /// Systemd exposes it in the `SYSLOG_IDENTIFIER` journal field, and allows + /// filtering log messages by syslog identifier with `journalctl -t`. + /// Unlike the unit (`journalctl -u`) this field is not trusted, i.e. applications + /// can set it freely, and use it e.g. to further categorize log entries emitted under + /// the same systemd unit or in the same process. It also allows to filter for log + /// entries of processes not started in their own unit. + /// + /// See [Journal Fields](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html) + /// and [journalctl](https://www.freedesktop.org/software/systemd/man/journalctl.html) + /// for more information. + /// + /// Defaults to the file name of the executable of the current process, if any. + pub fn with_syslog_identifier(mut self, identifier: String) -> Self { + self.syslog_identifier = identifier; + self + } + + /// Returns the syslog identifier in use. + pub fn syslog_identifier(&self) -> &str { + &self.syslog_identifier + } + + #[cfg(not(unix))] + fn send_payload(&self, _opayload: &[u8]) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Other, + "journald not supported on non-Unix", + )) + } + + #[cfg(unix)] + fn send_payload(&self, payload: &[u8]) -> io::Result { + self.socket + .send_to(payload, JOURNALD_PATH) + .or_else(|error| { + if Some(libc::EMSGSIZE) == error.raw_os_error() { + self.send_large_payload(payload) + } else { + Err(error) + } + }) + } + + #[cfg(all(unix, not(target_os = "linux")))] + fn send_large_payload(&self, _payload: &[u8]) -> io::Result { + Err(std::io::Error::other( + "Large payloads not supported on non-Linux OS", + )) + } + + /// Send large payloads to journald via a memfd. + #[cfg(target_os = "linux")] + fn send_large_payload(&self, payload: &[u8]) -> io::Result { + // If the payload's too large for a single datagram, send it through a memfd, see + // https://systemd.io/JOURNAL_NATIVE_PROTOCOL/ + use std::os::unix::prelude::AsRawFd; + // Write the whole payload to a memfd + let mut mem = create_sealable()?; + mem.write_all(payload)?; + // Fully seal the memfd to signal journald that its backing data won't resize anymore + // and so is safe to mmap. + seal_fully(mem.as_raw_fd())?; + send_one_fd_to(&self.socket, mem.as_raw_fd(), JOURNALD_PATH) + } +} + +impl PriorityMappings { + /// Returns the default priority mappings: + /// + pub fn new() -> PriorityMappings { + Self { + error: Priority::Error, + warn: Priority::Warning, + info: Priority::Notice, + debug: Priority::Informational, + trace: Priority::Debug, + } + } +} + +impl Default for PriorityMappings { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for Subscriber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Subscriber") + .field("socket", &self.socket) + .field("syslog_identifier", &self.syslog_identifier) + .field("priority_mappings", &self.priority_mappings) + .finish() + } +} + +/// Append a sanitized and length-encoded field into `buf`. +/// +/// Unlike `put_field_wellformed` this function handles arbitrary field names and values. +/// +/// `name` denotes the field name. It gets sanitized before being appended to `buf`. +/// +/// `write_value` is invoked with `buf` as argument to append the value data to `buf`. It must +/// not delete from `buf`, but may append arbitrary data. This function then determines the length +/// of the data written and adds it in the appropriate place in `buf`. +fn put_field_length_encoded(buf: &mut Vec, name: &str, write_value: impl FnOnce(&mut Vec)) { + for ch in name.as_bytes() { + buf.push(ch.to_ascii_uppercase()); + } + buf.push(b'\n'); + buf.extend_from_slice(&[0; 8]); // Length tag, to be populated + let start = buf.len(); + write_value(buf); + let end = buf.len(); + buf[start - 8..start].copy_from_slice(&((end - start) as u64).to_le_bytes()); + buf.push(b'\n'); +} + +/// Append arbitrary data with a well-formed name and value. +/// +/// `value` must not contain an internal newline, because this function writes +/// `value` in the new-line separated format. +/// +/// For a "newline-safe" variant, see `put_field_length_encoded`. +fn put_field_wellformed(buf: &mut Vec, name: &str, value: &[u8]) { + buf.extend_from_slice(name.as_bytes()); + buf.push(b'\n'); + put_value(buf, value); +} + +/// Write the value portion of a key-value pair, in newline separated format. +/// +/// `value` must not contain an internal newline. +/// +/// For a "newline-safe" variant, see `put_field_length_encoded`. +fn put_value(buf: &mut Vec, value: &[u8]) { + buf.extend_from_slice(&(value.len() as u64).to_le_bytes()); + buf.extend_from_slice(value); + buf.push(b'\n'); +} + +fn assert_cmsg_bufsize() { + let space_one_fd = unsafe { CMSG_SPACE(size_of::() as u32) }; + assert!( + space_one_fd <= CMSG_BUFSIZE as u32, + "cmsghdr buffer too small (< {}) to hold a single fd", + space_one_fd + ); +} + +pub fn send_one_fd_to>(socket: &UnixDatagram, fd: RawFd, path: P) -> Result { + assert_cmsg_bufsize(); + + let mut addr: sockaddr_un = unsafe { zeroed() }; + let path_bytes = path.as_ref().as_os_str().as_bytes(); + // path_bytes may have at most sun_path + 1 bytes, to account for the trailing NUL byte. + if addr.sun_path.len() <= path_bytes.len() { + return Err(Error::from_raw_os_error(ENAMETOOLONG)); + } + + addr.sun_family = AF_UNIX as _; + unsafe { + std::ptr::copy_nonoverlapping( + path_bytes.as_ptr(), + addr.sun_path.as_mut_ptr() as *mut u8, + path_bytes.len(), + ) + }; + + let mut msg: msghdr = unsafe { zeroed() }; + // Set the target address. + msg.msg_name = &mut addr as *mut _ as *mut c_void; + msg.msg_namelen = size_of::() as socklen_t; + + // We send no data body with this message. + msg.msg_iov = ptr::null_mut(); + msg.msg_iovlen = 0; + + // Create and fill the control message buffer with our file descriptor + let mut cmsg_buffer = AlignedBuffer { + buffer: ([0u8; CMSG_BUFSIZE]), + }; + msg.msg_control = unsafe { cmsg_buffer.buffer.as_mut_ptr() as _ }; + msg.msg_controllen = unsafe { CMSG_SPACE(size_of::() as _) as _ }; + + let cmsg: &mut cmsghdr = + unsafe { CMSG_FIRSTHDR(&msg).as_mut() }.expect("Control message buffer exhausted"); + + cmsg.cmsg_level = SOL_SOCKET; + cmsg.cmsg_type = SCM_RIGHTS; + cmsg.cmsg_len = unsafe { CMSG_LEN(size_of::() as _) as _ }; + + unsafe { ptr::write(CMSG_DATA(cmsg) as *mut RawFd, fd) }; + + let result = unsafe { sendmsg(socket.as_raw_fd(), &msg, libc::MSG_NOSIGNAL) }; + + if result < 0 { + Err(Error::last_os_error()) + } else { + // sendmsg returns the number of bytes written + Ok(result as usize) + } +} + +#[cfg(target_os = "linux")] +fn create(flags: c_uint) -> Result { + let fd = memfd_create_syscall(flags); + if fd < 0 { + Err(Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(fd as RawFd) }) + } +} + +/// Make the `memfd_create` syscall ourself instead of going through `libc`; +/// `memfd_create` isn't supported on `glibc<2.27` so this allows us to +/// support old-but-still-used distros like Ubuntu Xenial, Debian Stretch, +/// RHEL 7, etc. +/// +/// See: https://github.com/tokio-rs/tracing/issues/1879 +#[cfg(target_os = "linux")] +fn memfd_create_syscall(flags: c_uint) -> c_int { + unsafe { + syscall( + SYS_memfd_create, + "tracing-journald\0".as_ptr() as *const c_char, + flags, + ) as c_int + } +} + +#[cfg(target_os = "linux")] +pub fn create_sealable() -> Result { + create(MFD_ALLOW_SEALING | MFD_CLOEXEC) +} + +#[cfg(target_os = "linux")] +pub fn seal_fully(fd: RawFd) -> Result<()> { + let all_seals = F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_SEAL; + let result = unsafe { fcntl(fd, F_ADD_SEALS, all_seals) }; + if result < 0 { + Err(Error::last_os_error()) + } else { + Ok(()) + } +} +// SPDX-SnippetEnd diff --git a/crates/common/src/telemetry/tracers/log.rs b/crates/common/src/telemetry/tracers/log.rs new file mode 100644 index 0000000..455db0f --- /dev/null +++ b/crates/common/src/telemetry/tracers/log.rs @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{path::PathBuf, time::SystemTime}; + +use crate::config::telemetry::{LogTracer, RotationStrategy}; + +use mail_parser::DateTime; +use tokio::{ + fs::{File, OpenOptions}, + io::BufWriter, +}; +use trc::{TelemetryEvent, ipc::subscriber::SubscriberBuilder, serializers::text::FmtWriter}; + +pub(crate) fn spawn_log_tracer(builder: SubscriberBuilder, settings: LogTracer) { + let (_, mut rx) = builder.register(); + tokio::spawn(async move { + if let Some(writer) = settings.build_writer().await { + let mut buf = FmtWriter::new(writer) + .with_ansi(settings.ansi) + .with_multiline(settings.multiline); + let mut roatation_timestamp = settings.next_rotation(); + + while let Some(events) = rx.recv().await { + for event in events { + // Check if we need to rotate the log file + if roatation_timestamp != 0 && event.inner.timestamp > roatation_timestamp { + if let Err(err) = buf.flush().await { + trc::event!( + Telemetry(TelemetryEvent::LogError), + Reason = err.to_string(), + Details = "Failed to flush log buffer" + ); + } + + if let Some(writer) = settings.build_writer().await { + buf.update_writer(writer); + roatation_timestamp = settings.next_rotation(); + } else { + return; + }; + } + + if let Err(err) = buf.write(&event).await { + trc::event!( + Telemetry(TelemetryEvent::LogError), + Reason = err.to_string(), + Details = "Failed to write event to log" + ); + return; + } + } + + if let Err(err) = buf.flush().await { + trc::event!( + Telemetry(TelemetryEvent::LogError), + Reason = err.to_string(), + Details = "Failed to flush log buffer" + ); + } + } + } + }); +} + +impl LogTracer { + pub async fn build_writer(&self) -> Option> { + let now = DateTime::from_timestamp( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64, + ); + let file_name = match self.rotate { + RotationStrategy::Daily => { + format!( + "{}.{:04}-{:02}-{:02}", + self.prefix, now.year, now.month, now.day + ) + } + RotationStrategy::Hourly => { + format!( + "{}.{:04}-{:02}-{:02}T{:02}", + self.prefix, now.year, now.month, now.day, now.hour + ) + } + RotationStrategy::Minutely => { + format!( + "{}.{:04}-{:02}-{:02}T{:02}:{:02}", + self.prefix, now.year, now.month, now.day, now.hour, now.minute + ) + } + RotationStrategy::Never => self.prefix.clone(), + }; + let path = PathBuf::from(&self.path).join(file_name); + + match OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await + { + Ok(writer) => Some(BufWriter::new(writer)), + Err(err) => { + trc::event!( + Telemetry(TelemetryEvent::LogError), + Details = "Failed to create log file", + Path = path.to_string_lossy().into_owned(), + Reason = err.to_string(), + ); + None + } + } + } + + pub fn next_rotation(&self) -> u64 { + let mut now = DateTime::from_timestamp( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64, + ); + + now.second = 0; + + match self.rotate { + RotationStrategy::Daily => { + now.hour = 0; + now.minute = 0; + now.to_timestamp() as u64 + 86400 + } + RotationStrategy::Hourly => { + now.minute = 0; + now.to_timestamp() as u64 + 3600 + } + RotationStrategy::Minutely => now.to_timestamp() as u64 + 60, + RotationStrategy::Never => 0, + } + } +} diff --git a/crates/common/src/telemetry/tracers/mod.rs b/crates/common/src/telemetry/tracers/mod.rs new file mode 100644 index 0000000..3461756 --- /dev/null +++ b/crates/common/src/telemetry/tracers/mod.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[cfg(unix)] +pub mod journald; +pub mod log; +pub mod otel; +pub mod stdout; + + +use registry::{ + schema::structs::{ + Trace, TraceEvent, TraceKeyValue, TraceValue, TraceValueBoolean, TraceValueDuration, + TraceValueEvent, TraceValueFloat, TraceValueInteger, TraceValueIpAddr, TraceValueList, + TraceValueString, TraceValueUTCDateTime, TraceValueUnsignedInt, + }, + types::{datetime::UTCDateTime, ipaddr::IpAddr, list::List}, +}; +use trc::{Event, EventDetails, Value}; + +pub trait TraceEvents { + fn build_trace_events<'x>( + span_events: impl IntoIterator>, + num_events: usize, + ) -> Vec; + + fn from_events<'x>( + span_events: impl IntoIterator>, + num_events: usize, + ) -> Self; +} + +impl TraceEvents for Trace { + fn build_trace_events<'x>( + span_events: impl IntoIterator>, + num_events: usize, + ) -> Vec { + let mut events = Vec::with_capacity(num_events); + + for event in span_events { + let mut key_values = Vec::with_capacity(event.keys.len()); + for (key, value) in &event.keys { + key_values.push(TraceKeyValue { + key: *key, + value: map_value(value), + }); + } + + events.push(TraceEvent { + event: event.inner.typ, + timestamp: UTCDateTime::from_timestamp(event.inner.timestamp as i64), + key_values: key_values.into(), + }); + } + + events + } + + fn from_events<'x>( + span_events: impl IntoIterator>, + num_events: usize, + ) -> Self { + Trace { + events: Self::build_trace_events(span_events, num_events).into(), + } + } +} + +fn map_value(value: &Value) -> TraceValue { + match value { + Value::String(value) => TraceValue::String(TraceValueString { + value: value.to_string(), + }), + Value::UInt(value) => TraceValue::UnsignedInt(TraceValueUnsignedInt { value: *value }), + Value::Int(value) => TraceValue::Integer(TraceValueInteger { value: *value }), + Value::Float(value) => TraceValue::Float(TraceValueFloat { + value: (*value).into(), + }), + Value::Timestamp(value) => TraceValue::UTCDateTime(TraceValueUTCDateTime { + value: UTCDateTime::from_timestamp(*value as i64), + }), + Value::Duration(value) => TraceValue::Duration(TraceValueDuration { value: *value }), + Value::Bytes(items) => TraceValue::String(TraceValueString { + value: String::from_utf8_lossy(items).to_string(), + }), + Value::Bool(value) => TraceValue::Boolean(TraceValueBoolean { value: *value }), + Value::Ipv4(ipv4_addr) => TraceValue::IpAddr(TraceValueIpAddr { + value: IpAddr((*ipv4_addr).into()), + }), + Value::Ipv6(ipv6_addr) => TraceValue::IpAddr(TraceValueIpAddr { + value: IpAddr((*ipv6_addr).into()), + }), + Value::Event(event) => TraceValue::Event(TraceValueEvent { + value: event + .keys() + .iter() + .map(|(k, v)| TraceKeyValue { + key: *k, + value: map_value(v), + }) + .collect(), + event: event.event_type(), + }), + Value::Array(values) => TraceValue::List(TraceValueList { + value: List::from_iter(values.iter().map(map_value)), + }), + Value::None => TraceValue::Null, + } +} diff --git a/crates/common/src/telemetry/tracers/otel.rs b/crates/common/src/telemetry/tracers/otel.rs new file mode 100644 index 0000000..79903c8 --- /dev/null +++ b/crates/common/src/telemetry/tracers/otel.rs @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{LONG_1Y_SLUMBER, config::telemetry::OtelTracer}; +use ahash::{AHashMap, AHashSet}; +use mail_parser::DateTime; +use opentelemetry::{ + InstrumentationScope, Key, KeyValue, Value, + logs::{AnyValue, Severity}, + trace::{SpanContext, SpanKind, Status, TraceFlags, TraceState}, +}; +use opentelemetry_sdk::{ + Resource, + logs::{LogBatch, LogExporter, SdkLogRecord}, + trace::{SpanData, SpanEvents, SpanExporter, SpanLinks}, +}; +use opentelemetry_semantic_conventions::resource::SERVICE_VERSION; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use trc::{Event, EventDetails, Level, TelemetryEvent, ipc::subscriber::SubscriberBuilder}; + +const MAX_EVENTS: usize = 2048; + +pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer) { + let (_, mut rx) = builder.register(); + tokio::spawn(async move { + let resource = Resource::builder() + .with_service_name("stalwart") + .with_attribute(KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION"))) + .build(); + + let instrumentation = InstrumentationScope::builder("stalwart") + .with_version(env!("CARGO_PKG_VERSION")) + .build(); + + otel.log_exporter.set_resource(&resource); + otel.span_exporter.set_resource(&resource); + + let mut wakeup_time = LONG_1Y_SLUMBER; + let mut next_delivery = Instant::now(); + + let mut pending_logs = Vec::new(); + let mut pending_spans = Vec::new(); + + let mut active_spans = AHashMap::new(); + + loop { + // Wait for the next event or timeout + let event_or_timeout = tokio::time::timeout(wakeup_time, rx.recv()).await; + + match event_or_timeout { + Ok(Some(events)) => { + for event in events { + if otel.log_exporter_enable { + pending_logs.push(otel.build_log_record(&event)); + } + + if otel.span_exporter_enable + && let Some(span) = event.inner.span.as_ref() + { + let span_id = span.span_id().unwrap(); + if !event.inner.typ.is_span_end() { + let events = active_spans.entry(span_id).or_insert_with(Vec::new); + if events.len() < MAX_EVENTS { + events.push(event); + } + } else if let Some(events) = active_spans.remove(&span_id) { + pending_spans.push(build_span_data( + span, + &event, + events.iter().chain(std::iter::once(&event)), + &instrumentation, + )); + } + } + } + } + Ok(None) => { + break; + } + Err(_) => (), + } + + // Process events + let mut next_retry = None; + let now = Instant::now(); + if next_delivery <= now { + if !pending_spans.is_empty() || !pending_logs.is_empty() { + next_delivery = now + otel.throttle; + + if !pending_spans.is_empty() + && let Err(err) = otel + .span_exporter + .export(std::mem::take(&mut pending_spans)) + .await + { + trc::event!( + Telemetry(TelemetryEvent::OtelExporterError), + Details = "Failed to export spans", + Reason = err.to_string() + ); + } + + if !pending_logs.is_empty() { + let logs = pending_logs + .iter() + .map(|log| (log, &instrumentation)) + .collect::>(); + + if let Err(err) = otel.log_exporter.export(LogBatch::new(&logs)).await { + trc::event!( + Telemetry(TelemetryEvent::OtelExporterError), + Details = "Failed to export logs", + Reason = err.to_string() + ); + } + pending_logs.clear(); + } + } + } else if !pending_logs.is_empty() || !pending_spans.is_empty() { + // Retry later + let this_retry = next_delivery - now; + match next_retry { + Some(next_retry) if this_retry >= next_retry => {} + _ => { + next_retry = Some(this_retry); + } + } + } + wakeup_time = next_retry.unwrap_or(LONG_1Y_SLUMBER); + } + }); +} + +fn build_span_data( + start_span: &Event, + end_span: &Event, + span_events: I, + instrumentation: &InstrumentationScope, +) -> SpanData +where + I: IntoIterator, + T: AsRef>, +{ + let span_id = start_span.span_id().unwrap(); + + let mut events = SpanEvents::default(); + events.events = span_events + .into_iter() + .map(|event| { + let event = event.as_ref(); + + opentelemetry::trace::Event::new( + event.inner.typ.as_str(), + UNIX_EPOCH + Duration::from_secs(event.inner.timestamp), + event.keys.iter().filter_map(build_key_value).collect(), + 0, + ) + }) + .collect(); + + SpanData { + span_context: SpanContext::new( + (span_id as u128).into(), + span_id.into(), + TraceFlags::default(), + false, + TraceState::default(), + ), + dropped_attributes_count: 0, + parent_span_id: 0.into(), + parent_span_is_remote: false, + name: start_span.inner.typ.as_str().into(), + start_time: UNIX_EPOCH + Duration::from_secs(start_span.inner.timestamp), + end_time: UNIX_EPOCH + Duration::from_secs(end_span.inner.timestamp), + attributes: start_span.keys.iter().filter_map(build_key_value).collect(), + events, + links: SpanLinks::default(), + status: Status::default(), + span_kind: SpanKind::Server, + instrumentation_scope: instrumentation.clone(), + } +} + +impl OtelTracer { + fn build_log_record(&self, event: &Event) -> SdkLogRecord { + use opentelemetry::logs::LogRecord; + + let mut record = SdkLogRecord::new(); + record.set_event_name(event.inner.typ.as_str()); + record.set_severity_number(match event.inner.level { + Level::Trace => Severity::Trace, + Level::Debug => Severity::Debug, + Level::Info => Severity::Info, + Level::Warn => Severity::Warn, + Level::Error => Severity::Error, + Level::Disable => Severity::Error, + }); + record.set_severity_text(event.inner.level.as_str()); + record.set_body(AnyValue::String(event.inner.typ.description().into())); + record.set_timestamp(UNIX_EPOCH + Duration::from_secs(event.inner.timestamp)); + record.set_observed_timestamp(SystemTime::now()); + + if let Some(span_id) = event.span_id().filter(|span_id| *span_id != 0) { + record.set_trace_context((span_id as u128).into(), span_id.into(), None); + } + + let mut seen_keys = AHashSet::new(); + for (k, v) in event.keys.iter().chain( + event + .inner + .span + .as_ref() + .map_or(([]).iter(), |span| span.keys.iter()), + ) { + if *k != trc::Key::SpanId && seen_keys.insert(*k) { + record.add_attribute(k.as_str(), build_any_value(v)); + } + } + + record + } +} + +fn build_key_value(key_value: &(trc::Key, trc::Value)) -> Option { + (key_value.0 != trc::Key::SpanId).then(|| { + KeyValue::new( + build_key(&key_value.0), + match &key_value.1 { + trc::Value::String(v) => Value::String(v.to_string().into()), + trc::Value::UInt(v) => Value::I64(*v as i64), + trc::Value::Int(v) => Value::I64(*v), + trc::Value::Float(v) => Value::F64(*v), + trc::Value::Timestamp(v) => { + Value::String(DateTime::from_timestamp(*v as i64).to_rfc3339().into()) + } + trc::Value::Duration(v) => Value::I64(*v as i64), + trc::Value::Bytes(_) => Value::String("[binary data]".into()), + trc::Value::Bool(v) => Value::Bool(*v), + trc::Value::Ipv4(v) => Value::String(v.to_string().into()), + trc::Value::Ipv6(v) => Value::String(v.to_string().into()), + trc::Value::Event(_) => Value::String("[event data]".into()), + trc::Value::Array(_) => Value::String("[array]".into()), + trc::Value::None => Value::Bool(false), + }, + ) + }) +} + +fn build_key(key: &trc::Key) -> Key { + Key::from_static_str(key.as_str()) +} + +fn build_any_value(value: &trc::Value) -> AnyValue { + match value { + trc::Value::String(v) => AnyValue::String(v.to_string().into()), + trc::Value::UInt(v) => AnyValue::Int(*v as i64), + trc::Value::Int(v) => AnyValue::Int(*v), + trc::Value::Float(v) => AnyValue::Double(*v), + trc::Value::Timestamp(v) => { + AnyValue::String(DateTime::from_timestamp(*v as i64).to_rfc3339().into()) + } + trc::Value::Duration(v) => AnyValue::Int(*v as i64), + trc::Value::Bytes(v) => AnyValue::Bytes(Box::new(v.clone())), + trc::Value::Bool(v) => AnyValue::Boolean(*v), + trc::Value::Ipv4(v) => AnyValue::String(v.to_string().into()), + trc::Value::Ipv6(v) => AnyValue::String(v.to_string().into()), + trc::Value::Event(v) => AnyValue::Map(Box::new( + [( + Key::from_static_str("eventName"), + AnyValue::String(v.event_type().as_str().into()), + )] + .into_iter() + .chain( + v.keys() + .iter() + .map(|(k, v)| (build_key(k), build_any_value(v))), + ) + .collect(), + )), + trc::Value::Array(v) => { + AnyValue::ListAny(Box::new(v.iter().map(build_any_value).collect())) + } + trc::Value::None => AnyValue::Boolean(false), + } +} diff --git a/crates/common/src/telemetry/tracers/stdout.rs b/crates/common/src/telemetry/tracers/stdout.rs new file mode 100644 index 0000000..be591f1 --- /dev/null +++ b/crates/common/src/telemetry/tracers/stdout.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + io::{Error, stderr}, + pin::Pin, + task::{Context, Poll}, +}; + +use crate::config::telemetry::ConsoleTracer; +use std::io::Write; +use tokio::io::AsyncWrite; +use trc::{ipc::subscriber::SubscriberBuilder, serializers::text::FmtWriter}; + +pub(crate) fn spawn_console_tracer(builder: SubscriberBuilder, settings: ConsoleTracer) { + let (_, mut rx) = builder.register(); + tokio::spawn(async move { + let mut buf = FmtWriter::new(StdErrWriter::default()) + .with_ansi(settings.ansi) + .with_multiline(settings.multiline); + + while let Some(events) = rx.recv().await { + for event in events { + let _ = buf.write(&event).await; + + if !settings.buffered { + let _ = buf.flush().await; + } + } + + if settings.buffered { + let _ = buf.flush().await; + } + } + }); +} + +const BUFFER_CAPACITY: usize = 4096; + +pub struct StdErrWriter { + buffer: Vec, +} + +impl AsyncWrite for StdErrWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + let bytes_len = bytes.len(); + let buffer_len = self.buffer.len(); + + if buffer_len + bytes_len < BUFFER_CAPACITY { + self.buffer.extend_from_slice(bytes); + Poll::Ready(Ok(bytes_len)) + } else if bytes_len > BUFFER_CAPACITY { + let result = stderr() + .write_all(&self.buffer) + .and_then(|_| stderr().write_all(bytes)); + self.buffer.clear(); + Poll::Ready(result.map(|_| bytes_len)) + } else { + let result = stderr().write_all(&self.buffer); + self.buffer.clear(); + self.buffer.extend_from_slice(bytes); + Poll::Ready(result.map(|_| bytes_len)) + } + } + + fn poll_flush(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(if !self.buffer.is_empty() { + let result = stderr().write_all(&self.buffer); + self.buffer.clear(); + result + } else { + Ok(()) + }) + } + + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl Default for StdErrWriter { + fn default() -> Self { + Self { + buffer: Vec::with_capacity(BUFFER_CAPACITY), + } + } +} diff --git a/crates/common/src/telemetry/webhooks/mod.rs b/crates/common/src/telemetry/webhooks/mod.rs new file mode 100644 index 0000000..33f966a --- /dev/null +++ b/crates/common/src/telemetry/webhooks/mod.rs @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{LONG_1Y_SLUMBER, config::telemetry::WebhookTracer}; +use aws_lc_rs::hmac; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::Serialize; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Instant, +}; +use store::write::now; +use tokio::sync::mpsc; +use trc::{ + Event, EventDetails, ServerEvent, TelemetryEvent, + ipc::subscriber::{EventBatch, SubscriberBuilder}, + serializers::json::JsonEventSerializer, +}; + +pub(crate) fn spawn_webhook_tracer(builder: SubscriberBuilder, settings: WebhookTracer) { + let (tx, mut rx) = builder.register(); + tokio::spawn(async move { + let settings = Arc::new(settings); + let mut wakeup_time = LONG_1Y_SLUMBER; + let discard_after = settings.discard_after.as_secs(); + let mut pending_events = Vec::new(); + let mut next_delivery = Instant::now(); + let in_flight = Arc::new(AtomicBool::new(false)); + + loop { + // Wait for the next event or timeout + let event_or_timeout = tokio::time::timeout(wakeup_time, rx.recv()).await; + let now = now(); + + match event_or_timeout { + Ok(Some(events)) => { + let mut discard_count = 0; + for event in events { + if now.saturating_sub(event.inner.timestamp) < discard_after { + pending_events.push(event) + } else { + discard_count += 1; + } + } + + if discard_count > 0 { + trc::event!( + Telemetry(TelemetryEvent::WebhookError), + Details = "Discarded stale events", + Total = discard_count + ); + } + } + Ok(None) => { + break; + } + Err(_) => (), + } + + // Process events + let mut next_retry = None; + let now = Instant::now(); + if next_delivery <= now { + if !pending_events.is_empty() { + next_delivery = now + settings.throttle; + if !in_flight.load(Ordering::Relaxed) { + spawn_webhook_handler( + settings.clone(), + in_flight.clone(), + std::mem::take(&mut pending_events), + tx.clone(), + ); + } + } + } else if !pending_events.is_empty() { + // Retry later + let this_retry = next_delivery - now; + match next_retry { + Some(next_retry) if this_retry >= next_retry => {} + _ => { + next_retry = Some(this_retry); + } + } + } + wakeup_time = next_retry.unwrap_or(LONG_1Y_SLUMBER); + } + }); +} + +#[derive(Serialize)] +struct EventWrapper { + events: JsonEventSerializer>>>, +} + +fn spawn_webhook_handler( + settings: Arc, + in_flight: Arc, + events: EventBatch, + webhook_tx: mpsc::Sender, +) { + tokio::spawn(async move { + in_flight.store(true, Ordering::Relaxed); + let wrapper = EventWrapper { + events: JsonEventSerializer::new(events).with_id().with_spans(), + }; + + if let Err(err) = post_webhook_events(&settings, &wrapper).await { + trc::event!(Telemetry(TelemetryEvent::WebhookError), Details = err); + + if webhook_tx.send(wrapper.events.into_inner()).await.is_err() { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Failed to send failed webhook events back to main thread", + CausedBy = trc::location!() + ); + } + } + + in_flight.store(false, Ordering::Relaxed); + }); +} + +async fn post_webhook_events( + settings: &WebhookTracer, + events: &EventWrapper, +) -> Result<(), String> { + // Serialize body + let body = serde_json::to_string(events) + .map_err(|err| format!("Failed to serialize events: {}", err))?; + + // Add HMAC-SHA256 signature + let mut headers = settings.headers.clone(); + if !settings.key.is_empty() { + let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes()); + let tag = hmac::sign(&key, body.as_bytes()); + + headers.insert( + "X-Signature", + STANDARD.encode(tag.as_ref()).parse().unwrap(), + ); + } + + // Send request + let response = settings + .client + .post(&settings.url) + .timeout(settings.timeout) + .headers(headers) + .body(body) + .send() + .await + .map_err(|err| format!("Webhook request to {} failed: {err}", settings.url))?; + + if response.status().is_success() { + Ok(()) + } else { + Err(format!( + "Webhook request to {} failed with code {}: {}", + settings.url, + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("Unknown") + )) + } +} diff --git a/crates/coordinator/Cargo.toml b/crates/coordinator/Cargo.toml new file mode 100644 index 0000000..d1e161b --- /dev/null +++ b/crates/coordinator/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "coordinator" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +registry = { path = "../registry" } +trc = { path = "../trc" } +futures = { version = "0.3", optional = true } +tokio = { version = "1.53", features = ["sync", "fs", "io-util"] } +async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true } +zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true } +rdkafka = { version = "0.39", features = ["cmake-build"], optional = true } +redis = { version = "1.6", features = [ "tokio-comp", "tokio-rustls-comp", "tls-rustls-insecure", "tls-rustls", "cluster-async", "sentinel"], optional = true } + +[features] +nats = ["async-nats", "futures"] +zenoh = ["dep:zenoh"] +kafka = ["rdkafka"] +redis = ["dep:redis", "futures"] +enterprise = [] +test_mode = [] + +[lints] +workspace = true diff --git a/crates/coordinator/src/backend/kafka/mod.rs b/crates/coordinator/src/backend/kafka/mod.rs new file mode 100644 index 0000000..bc36e79 --- /dev/null +++ b/crates/coordinator/src/backend/kafka/mod.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use crate::Coordinator; +use rdkafka::{ + ClientConfig, ClientContext, TopicPartitionList, + consumer::{BaseConsumer, ConsumerContext, Rebalance, StreamConsumer}, + error::KafkaResult, + producer::FutureProducer, +}; +use registry::schema::structs::KafkaCoordinator; + +pub mod pubsub; + +pub(super) type LoggingConsumer = StreamConsumer; + +pub struct KafkaPubSub { + consumer_builder: ClientConfig, + producer: FutureProducer, +} + +impl KafkaPubSub { + pub async fn open(config: KafkaCoordinator) -> Result { + if config.brokers.is_empty() { + return Err("No Kafka brokers specified".to_string()); + } + + let brokers = config.brokers.into_inner().join(","); + let mut consumer_builder = ClientConfig::new(); + + consumer_builder + .set("group.id", config.group_id) + .set("bootstrap.servers", &brokers) + .set("enable.partition.eof", "false") + .set( + "session.timeout.ms", + config.timeout_session.as_millis().to_string(), + ) + .set("enable.auto.commit", "true"); + + let producer = ClientConfig::new() + .set("bootstrap.servers", brokers) + .set( + "message.timeout.ms", + config.timeout_message.as_millis().to_string(), + ) + .create() + .map_err(|err| format!("Failed to create Kafka producer: {}", err))?; + + Ok(Coordinator::Kafka(Arc::new(KafkaPubSub { + consumer_builder, + producer, + }))) + } +} + +impl std::fmt::Debug for KafkaPubSub { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KafkaPubSub").finish() + } +} + +pub(super) struct CustomContext; + +impl ClientContext for CustomContext {} + +impl ConsumerContext for CustomContext { + fn pre_rebalance(&self, _: &BaseConsumer, _: &Rebalance) {} + + fn post_rebalance(&self, _: &BaseConsumer, _: &Rebalance) {} + + fn commit_callback(&self, _: KafkaResult<()>, _: &TopicPartitionList) {} +} diff --git a/crates/coordinator/src/backend/kafka/pubsub.rs b/crates/coordinator/src/backend/kafka/pubsub.rs new file mode 100644 index 0000000..00b77e7 --- /dev/null +++ b/crates/coordinator/src/backend/kafka/pubsub.rs @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CustomContext, KafkaPubSub, LoggingConsumer}; +use crate::{Msg, PubSubStream}; +use rdkafka::{ + Message, + consumer::{CommitMode, Consumer, StreamConsumer}, + producer::FutureRecord, +}; +use std::time::Duration; +use trc::{ClusterEvent, Error, EventType}; + +pub struct KafkaPubSubStream { + subs: LoggingConsumer, +} + +impl KafkaPubSub { + pub async fn publish(&self, topic: &'static str, message: Vec) -> trc::Result<()> { + self.producer + .send( + FutureRecord::<(), [u8]>::to(topic).payload(message.as_slice()), + Duration::from_secs(0), + ) + .await + .map(|_| ()) + .map_err(|(err, _)| { + Error::new(EventType::Cluster(ClusterEvent::PublisherError)).reason(err) + }) + } + + pub async fn subscribe(&self, topic: &'static str) -> trc::Result { + let subs: StreamConsumer = self + .consumer_builder + .create_with_context(CustomContext) + .map_err(|err| { + Error::new(EventType::Cluster(ClusterEvent::SubscriberError)).reason(err) + })?; + subs.subscribe(&[topic]).map_err(|err| { + Error::new(EventType::Cluster(ClusterEvent::SubscriberError)).reason(err) + })?; + + Ok(PubSubStream::Kafka(KafkaPubSubStream { subs })) + } +} + +impl KafkaPubSubStream { + pub async fn next(&mut self) -> Option { + let msg = self.subs.recv().await.ok()?; + let _ = self.subs.commit_message(&msg, CommitMode::Async); + Msg::Kafka(msg.payload().unwrap_or_default().to_vec()).into() + } +} diff --git a/crates/coordinator/src/backend/mod.rs b/crates/coordinator/src/backend/mod.rs new file mode 100644 index 0000000..d384cf2 --- /dev/null +++ b/crates/coordinator/src/backend/mod.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[cfg(feature = "kafka")] +pub mod kafka; +#[cfg(feature = "nats")] +pub mod nats; +#[cfg(feature = "redis")] +pub mod redis; +#[cfg(feature = "zenoh")] +pub mod zenoh; diff --git a/crates/coordinator/src/backend/nats/mod.rs b/crates/coordinator/src/backend/nats/mod.rs new file mode 100644 index 0000000..ee01fd5 --- /dev/null +++ b/crates/coordinator/src/backend/nats/mod.rs @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use crate::Coordinator; +use async_nats::Client; +use registry::schema::structs::NatsCoordinator; + +pub mod pubsub; + +#[derive(Debug)] +pub struct NatsPubSub { + client: Client, +} + +impl NatsPubSub { + pub async fn open(config: NatsCoordinator) -> Result { + if config.addresses.is_empty() { + return Err("No Nats addresses specified".to_string()); + } + + let mut opts = async_nats::ConnectOptions::new() + .max_reconnects(config.max_reconnects.map(|v| v as usize)) + .connection_timeout(config.timeout_connection.into_inner()) + .request_timeout(config.timeout_request.into_inner().into()) + .ping_interval(config.ping_interval.into_inner()) + .client_capacity(config.capacity_client as usize) + .subscription_capacity(config.capacity_subscription as usize) + .read_buffer_capacity(config.capacity_read_buffer as u16) + .require_tls(config.use_tls); + + if config.no_echo { + opts = opts.no_echo(); + } + + if let (Some(user), Some(pass)) = ( + config.auth_username, + config.auth_secret.secret().await?.map(|v| v.into_owned()), + ) { + opts = opts.user_and_password(user.to_string(), pass.to_string()); + } else if let Some(credentials) = config.credentials.secret().await?.map(|v| v.into_owned()) + { + opts = opts.token(credentials); + } + + async_nats::connect_with_options(config.addresses.into_inner(), opts) + .await + .map(|client| Coordinator::Nats(Arc::new(NatsPubSub { client }))) + .map_err(|err| format!("Failed to connect to Nats: {}", err)) + } +} diff --git a/crates/coordinator/src/backend/nats/pubsub.rs b/crates/coordinator/src/backend/nats/pubsub.rs new file mode 100644 index 0000000..3b1d876 --- /dev/null +++ b/crates/coordinator/src/backend/nats/pubsub.rs @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::NatsPubSub; +use crate::{Msg, PubSubStream}; +use futures::StreamExt; +use trc::{ClusterEvent, Error, EventType}; + +pub struct NatsPubSubStream { + subs: async_nats::Subscriber, +} + +impl NatsPubSub { + pub async fn publish(&self, topic: &'static str, message: Vec) -> trc::Result<()> { + self.client + .publish(topic, message.into()) + .await + .map_err(|err| Error::new(EventType::Cluster(ClusterEvent::PublisherError)).reason(err)) + } + + pub async fn subscribe(&self, topic: &'static str) -> trc::Result { + self.client + .subscribe(topic) + .await + .map(|subs| PubSubStream::Nats(NatsPubSubStream { subs })) + .map_err(|err| { + Error::new(EventType::Cluster(ClusterEvent::SubscriberError)).reason(err) + }) + } +} + +impl NatsPubSubStream { + pub async fn next(&mut self) -> Option { + self.subs.next().await.map(Msg::Nats) + } +} diff --git a/crates/coordinator/src/backend/redis/mod.rs b/crates/coordinator/src/backend/redis/mod.rs new file mode 100644 index 0000000..0269376 --- /dev/null +++ b/crates/coordinator/src/backend/redis/mod.rs @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod pubsub; diff --git a/crates/coordinator/src/backend/redis/pubsub.rs b/crates/coordinator/src/backend/redis/pubsub.rs new file mode 100644 index 0000000..547f1ec --- /dev/null +++ b/crates/coordinator/src/backend/redis/pubsub.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Msg, PubSubStream}; +use futures::StreamExt; +use redis::{AsyncCommands, PushInfo, cluster::ClusterConfig, cluster_async::ClusterConnection}; +use std::fmt::Display; +use store::backend::redis::{RedisPool, RedisStore}; +use tokio::sync::mpsc::UnboundedReceiver; + +pub struct RedisPubSubStream { + stream: redis::aio::PubSubStream, +} + +pub struct RedisClusterPubSubStream { + _conn: ClusterConnection, + rx: UnboundedReceiver, +} + +pub(crate) async fn redis_publish( + redis: &RedisStore, + topic: &'static str, + message: Vec, +) -> trc::Result<()> { + match &redis.pool { + RedisPool::Single(pool) => pool + .get() + .await + .map_err(into_error)? + .as_mut() + .publish(topic, message) + .await + .map_err(into_error), + RedisPool::Cluster(pool) => pool + .get() + .await + .map_err(into_error)? + .as_mut() + .publish(topic, message) + .await + .map_err(into_error), + RedisPool::Sentinel(pool) => pool + .get() + .await + .map_err(into_error)? + .as_mut() + .publish(topic, message) + .await + .map_err(into_error), + } +} + +pub(crate) async fn redis_subscribe( + redis: &RedisStore, + topic: &'static str, +) -> trc::Result { + match &redis.pool { + RedisPool::Single(pool) => { + let mut pubsub = pool + .manager() + .client + .get_async_pubsub() + .await + .map_err(into_error)?; + pubsub.subscribe(topic).await.map_err(into_error)?; + + Ok(PubSubStream::Redis(RedisPubSubStream { + stream: pubsub.into_on_message(), + })) + } + RedisPool::Cluster(pool) => { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + let mut _conn = pool + .manager() + .client + .get_async_connection_with_config(ClusterConfig::default().set_push_sender(tx)) + .await + .map_err(into_error)?; + + _conn.subscribe(topic).await.map_err(into_error)?; + + Ok(PubSubStream::RedisCluster(RedisClusterPubSubStream { + _conn, + rx, + })) + } + RedisPool::Sentinel(pool) => { + let client = pool + .manager() + .client + .lock() + .await + .async_get_client() + .await + .map_err(into_error)?; + + let mut pubsub = client.get_async_pubsub().await.map_err(into_error)?; + pubsub.subscribe(topic).await.map_err(into_error)?; + + Ok(PubSubStream::Redis(RedisPubSubStream { + stream: pubsub.into_on_message(), + })) + } + } +} + +impl RedisPubSubStream { + pub async fn next(&mut self) -> Option { + self.stream.next().await.map(Msg::Redis) + } +} + +impl RedisClusterPubSubStream { + pub async fn next(&mut self) -> Option { + loop { + if let Some(msg) = redis::Msg::from_push_info(self.rx.recv().await?) { + return Some(Msg::Redis(msg)); + } + } + } +} + +#[inline(always)] +fn into_error(err: impl Display) -> trc::Error { + trc::StoreEvent::RedisError.reason(err) +} diff --git a/crates/coordinator/src/backend/zenoh/mod.rs b/crates/coordinator/src/backend/zenoh/mod.rs new file mode 100644 index 0000000..17b5a96 --- /dev/null +++ b/crates/coordinator/src/backend/zenoh/mod.rs @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use registry::schema::structs::ZenohCoordinator; + +use crate::Coordinator; +pub mod pubsub; + +#[derive(Debug)] +pub struct ZenohPubSub { + session: zenoh::Session, +} + +impl ZenohPubSub { + pub async fn open(config: ZenohCoordinator) -> Result { + let zenoh_config = zenoh::Config::from_json5(&config.config) + .map_err(|err| format!("Invalid Zenoh config: {}", err))?; + zenoh::open(zenoh_config) + .await + .map_err(|err| format!("Failed to create Zenoh session: {}", err)) + .map(|session| ZenohPubSub { session }) + .map(|store| Coordinator::Zenoh(std::sync::Arc::new(store))) + } +} diff --git a/crates/coordinator/src/backend/zenoh/pubsub.rs b/crates/coordinator/src/backend/zenoh/pubsub.rs new file mode 100644 index 0000000..6650edd --- /dev/null +++ b/crates/coordinator/src/backend/zenoh/pubsub.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ZenohPubSub; +use crate::{Msg, PubSubStream}; +use trc::{ClusterEvent, Error, EventType}; + +pub struct ZenohPubSubStream { + subs: zenoh::pubsub::Subscriber>, +} + +impl ZenohPubSub { + pub async fn publish(&self, topic: &'static str, message: Vec) -> trc::Result<()> { + self.session + .declare_publisher(topic) + .await + .map_err(|err| { + Error::new(EventType::Cluster(ClusterEvent::PublisherError)).reason(err) + })? + .put(message) + .await + .map_err(|err| Error::new(EventType::Cluster(ClusterEvent::PublisherError)).reason(err)) + } + + pub async fn subscribe(&self, topic: &'static str) -> trc::Result { + self.session + .declare_subscriber(topic) + .await + .map(|subs| PubSubStream::Zenoh(ZenohPubSubStream { subs })) + .map_err(|err| { + Error::new(EventType::Cluster(ClusterEvent::SubscriberError)).reason(err) + }) + } +} + +impl ZenohPubSubStream { + pub async fn next(&mut self) -> Option { + self.subs + .recv_async() + .await + .map(|sample| Msg::Zenoh(sample.payload().to_bytes().into_owned())) + .ok() + } +} diff --git a/crates/coordinator/src/bootstrap.rs b/crates/coordinator/src/bootstrap.rs new file mode 100644 index 0000000..8cd35d6 --- /dev/null +++ b/crates/coordinator/src/bootstrap.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Coordinator; +use registry::schema::{prelude::ObjectType, structs}; +use store::{InMemoryStore, registry::bootstrap::Bootstrap}; + +#[allow(unreachable_patterns)] +impl Coordinator { + pub async fn build(bp: &mut Bootstrap, _in_memory: &InMemoryStore) -> Option { + let result = match bp.setting_infallible::().await { + structs::Coordinator::Disabled => Ok(Coordinator::None), + #[cfg(feature = "redis")] + structs::Coordinator::Default => { + if let InMemoryStore::Redis(redis) = &_in_memory { + Ok(Coordinator::Redis(redis.clone())) + } else { + Err( + "Default coordinator requires Redis or Redis Cluster in-memory backend" + .to_string(), + ) + } + } + #[cfg(feature = "kafka")] + structs::Coordinator::Kafka(kafka_coordinator) => { + crate::backend::kafka::KafkaPubSub::open(kafka_coordinator).await + } + #[cfg(feature = "nats")] + structs::Coordinator::Nats(nats_coordinator) => { + crate::backend::nats::NatsPubSub::open(nats_coordinator).await + } + #[cfg(feature = "zenoh")] + structs::Coordinator::Zenoh(zenoh_coordinator) => { + crate::backend::zenoh::ZenohPubSub::open(zenoh_coordinator).await + } + #[cfg(feature = "redis")] + structs::Coordinator::Redis(redis_store) => { + store::backend::redis::RedisStore::open_single(redis_store) + .await + .map(unwrap_redis) + } + #[cfg(feature = "redis")] + structs::Coordinator::RedisCluster(redis_cluster_store) => { + store::backend::redis::RedisStore::open_cluster(redis_cluster_store) + .await + .map(unwrap_redis) + } + #[cfg(feature = "redis")] + structs::Coordinator::RedisSentinel(redis_sentinel_store) => { + store::backend::redis::RedisStore::open_sentinel(redis_sentinel_store) + .await + .map(unwrap_redis) + } + _ => Err("Binary was not compiled with the selected coordinator backend".to_string()), + }; + + match result { + Ok(store) => Some(store), + Err(err) => { + bp.build_error(ObjectType::Coordinator.singleton(), err); + None + } + } + } + + pub fn is_enabled(&self) -> bool { + !matches!(self, Coordinator::None) + } +} + +#[cfg(feature = "redis")] +fn unwrap_redis(store: InMemoryStore) -> Coordinator { + if let InMemoryStore::Redis(redis) = store { + Coordinator::Redis(redis) + } else { + unreachable!() + } +} diff --git a/crates/coordinator/src/dispatch.rs b/crates/coordinator/src/dispatch.rs new file mode 100644 index 0000000..bd84b67 --- /dev/null +++ b/crates/coordinator/src/dispatch.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Coordinator, Msg, PubSubStream}; + +#[allow(unused_variables)] +impl Coordinator { + pub async fn publish(&self, topic: &'static str, message: Vec) -> trc::Result<()> { + match self { + #[cfg(feature = "redis")] + Coordinator::Redis(store) => { + crate::backend::redis::pubsub::redis_publish(store, topic, message).await + } + #[cfg(feature = "nats")] + Coordinator::Nats(store) => store.publish(topic, message).await, + #[cfg(feature = "zenoh")] + Coordinator::Zenoh(store) => store.publish(topic, message).await, + #[cfg(feature = "kafka")] + Coordinator::Kafka(store) => store.publish(topic, message).await, + Coordinator::None => Err(trc::StoreEvent::NotSupported.into_err()), + } + } + + pub async fn subscribe(&self, topic: &'static str) -> trc::Result { + match self { + #[cfg(feature = "redis")] + Coordinator::Redis(store) => { + crate::backend::redis::pubsub::redis_subscribe(store, topic).await + } + #[cfg(feature = "nats")] + Coordinator::Nats(store) => store.subscribe(topic).await, + #[cfg(feature = "zenoh")] + Coordinator::Zenoh(store) => store.subscribe(topic).await, + #[cfg(feature = "kafka")] + Coordinator::Kafka(store) => store.subscribe(topic).await, + Coordinator::None => Err(trc::StoreEvent::NotSupported.into_err()), + } + } + + pub fn is_none(&self) -> bool { + matches!(self, Coordinator::None) + } +} + +impl PubSubStream { + pub async fn next(&mut self) -> Option { + match self { + #[cfg(feature = "redis")] + PubSubStream::Redis(stream) => stream.next().await, + #[cfg(feature = "redis")] + PubSubStream::RedisCluster(stream) => stream.next().await, + #[cfg(feature = "nats")] + PubSubStream::Nats(stream) => stream.next().await, + #[cfg(feature = "zenoh")] + PubSubStream::Zenoh(stream) => stream.next().await, + #[cfg(feature = "kafka")] + PubSubStream::Kafka(stream) => stream.next().await, + PubSubStream::Unimplemented => None, + } + } +} + +impl Msg { + pub fn payload(&self) -> &[u8] { + match self { + #[cfg(feature = "redis")] + Msg::Redis(msg) => msg.get_payload_bytes(), + #[cfg(feature = "nats")] + Msg::Nats(msg) => msg.payload.as_ref(), + #[cfg(feature = "zenoh")] + Msg::Zenoh(msg) => msg.as_slice(), + #[cfg(feature = "kafka")] + Msg::Kafka(msg) => msg.as_slice(), + Msg::Unimplemented => &[], + } + } + + pub fn topic(&self) -> &str { + match self { + #[cfg(feature = "redis")] + Msg::Redis(msg) => msg.get_channel_name(), + #[cfg(feature = "nats")] + Msg::Nats(msg) => msg.subject.as_str(), + #[cfg(feature = "zenoh")] + Msg::Zenoh(_) => "", + #[cfg(feature = "kafka")] + Msg::Kafka(_) => "", + Msg::Unimplemented => "", + } + } +} diff --git a/crates/coordinator/src/lib.rs b/crates/coordinator/src/lib.rs new file mode 100644 index 0000000..3abbde9 --- /dev/null +++ b/crates/coordinator/src/lib.rs @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +#[allow(unused_imports)] +use std::sync::Arc; + +pub mod backend; +pub mod bootstrap; +pub mod dispatch; + +#[derive(Clone, Default)] +pub enum Coordinator { + #[cfg(feature = "redis")] + Redis(Arc), + #[cfg(feature = "nats")] + Nats(Arc), + #[cfg(feature = "zenoh")] + Zenoh(Arc), + #[cfg(feature = "kafka")] + Kafka(Arc), + #[default] + None, +} + +pub enum PubSubStream { + #[cfg(feature = "redis")] + Redis(crate::backend::redis::pubsub::RedisPubSubStream), + #[cfg(feature = "redis")] + RedisCluster(crate::backend::redis::pubsub::RedisClusterPubSubStream), + #[cfg(feature = "nats")] + Nats(crate::backend::nats::pubsub::NatsPubSubStream), + #[cfg(feature = "zenoh")] + Zenoh(crate::backend::zenoh::pubsub::ZenohPubSubStream), + #[cfg(feature = "kafka")] + Kafka(crate::backend::kafka::pubsub::KafkaPubSubStream), + Unimplemented, +} + +pub enum Msg { + #[cfg(feature = "redis")] + Redis(redis::Msg), + #[cfg(feature = "nats")] + Nats(async_nats::Message), + #[cfg(feature = "zenoh")] + Zenoh(Vec), + #[cfg(feature = "kafka")] + Kafka(Vec), + Unimplemented, +} diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml new file mode 100644 index 0000000..2388a3e --- /dev/null +++ b/crates/dav-proto/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "dav-proto" +version = "0.16.22" +edition = "2024" + +[dependencies] +trc = { path = "../trc" } +types = { path = "../types" } +hashify = "0.2.9" +quick-xml = "0.41" +calcard = { version = "0.3", features = ["rkyv"] } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +hyper = "1.11.1" +chrono = { version = "0.4.45", features = ["serde"], optional = true } +compact_str = "0.10.0" + +[dev-dependencies] +calcard = { version = "0.3", features = ["serde", "rkyv"] } +types = { path = "../types", features = ["test_mode"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +chrono = { version = "0.4.45", features = ["serde"] } + +[features] +test_mode = ["chrono"] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/dav-proto/resources/requests/acl-001.json b/crates/dav-proto/resources/requests/acl-001.json new file mode 100644 index 0000000..56d5f5e --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-001.json @@ -0,0 +1,30 @@ +{ + "aces": [ + { + "principal": { + "Href": "http://www.example.com/users/friends" + }, + "invert": false, + "grant_deny": { + "Grant": [ + "Read" + ] + }, + "protected": false, + "inherited": null + }, + { + "principal": { + "Href": "http://www.example.com/users/ygoland-so" + }, + "invert": false, + "grant_deny": { + "Deny": [ + "Read" + ] + }, + "protected": false, + "inherited": null + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-001.xml b/crates/dav-proto/resources/requests/acl-001.xml new file mode 100644 index 0000000..1853ad1 --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-001.xml @@ -0,0 +1,15 @@ + + + + + http://www.example.com/users/friends + + + + + + http://www.example.com/users/ygoland-so + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-002.json b/crates/dav-proto/resources/requests/acl-002.json new file mode 100644 index 0000000..ccf9b50 --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-002.json @@ -0,0 +1,17 @@ +{ + "aces": [ + { + "principal": { + "Href": "http://www.example.com/users/ejw" + }, + "invert": false, + "grant_deny": { + "Grant": [ + "Write" + ] + }, + "protected": false, + "inherited": null + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-002.xml b/crates/dav-proto/resources/requests/acl-002.xml new file mode 100644 index 0000000..f97bc12 --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-002.xml @@ -0,0 +1,9 @@ + + + + + http://www.example.com/users/ejw + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-003.json b/crates/dav-proto/resources/requests/acl-003.json new file mode 100644 index 0000000..9b8648c --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-003.json @@ -0,0 +1,17 @@ +{ + "aces": [ + { + "principal": { + "Href": "http://www.example.com/users/esedlar" + }, + "invert": true, + "grant_deny": { + "Deny": [ + "Write" + ] + }, + "protected": true, + "inherited": "http://www.example.com/container/" + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-003.xml b/crates/dav-proto/resources/requests/acl-003.xml new file mode 100644 index 0000000..d62274b --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-003.xml @@ -0,0 +1,15 @@ + + + + + http://www.example.com/users/esedlar + + + + + + + http://www.example.com/container/ + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-004.json b/crates/dav-proto/resources/requests/acl-004.json new file mode 100644 index 0000000..b56597e --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-004.json @@ -0,0 +1,53 @@ +{ + "aces": [ + { + "principal": { + "Href": "http://www.example.com/users/esedlar" + }, + "invert": false, + "grant_deny": { + "Grant": [ + "Read", + "Write" + ] + }, + "protected": false, + "inherited": null + }, + { + "principal": { + "Property": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "Owner" + } + }, + "value": "Null" + } + ] + }, + "invert": false, + "grant_deny": { + "Grant": [ + "ReadAcl", + "WriteAcl" + ] + }, + "protected": false, + "inherited": null + }, + { + "principal": "All", + "invert": false, + "grant_deny": { + "Grant": [ + "Read" + ] + }, + "protected": false, + "inherited": null + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/acl-004.xml b/crates/dav-proto/resources/requests/acl-004.xml new file mode 100644 index 0000000..9414370 --- /dev/null +++ b/crates/dav-proto/resources/requests/acl-004.xml @@ -0,0 +1,27 @@ + + + + + http://www.example.com/users/esedlar + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/lockinfo-001.json b/crates/dav-proto/resources/requests/lockinfo-001.json new file mode 100644 index 0000000..34cb0cd --- /dev/null +++ b/crates/dav-proto/resources/requests/lockinfo-001.json @@ -0,0 +1,20 @@ +{ + "lock_scope": "Shared", + "lock_type": "Write", + "owner": [ + { + "type": "ElementStart", + "data": { + "name": "href", + "attrs": "xmlns=\"DAV:\"" + } + }, + { + "type": "Text", + "data": "http://example.org/~ejw/contact.html" + }, + { + "type": "ElementEnd" + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/lockinfo-001.xml b/crates/dav-proto/resources/requests/lockinfo-001.xml new file mode 100644 index 0000000..5a9b42e --- /dev/null +++ b/crates/dav-proto/resources/requests/lockinfo-001.xml @@ -0,0 +1,8 @@ + + + + + + http://example.org/~ejw/contact.html + + diff --git a/crates/dav-proto/resources/requests/lockinfo-002.json b/crates/dav-proto/resources/requests/lockinfo-002.json new file mode 100644 index 0000000..e613868 --- /dev/null +++ b/crates/dav-proto/resources/requests/lockinfo-002.json @@ -0,0 +1,20 @@ +{ + "lock_scope": "Exclusive", + "lock_type": "Write", + "owner": [ + { + "type": "ElementStart", + "data": { + "name": "href", + "attrs": "xmlns=\"DAV:\"" + } + }, + { + "type": "Text", + "data": "http://example.org/~ejw/contact.html" + }, + { + "type": "ElementEnd" + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/lockinfo-002.xml b/crates/dav-proto/resources/requests/lockinfo-002.xml new file mode 100644 index 0000000..05fc33a --- /dev/null +++ b/crates/dav-proto/resources/requests/lockinfo-002.xml @@ -0,0 +1,9 @@ + + + + + + http://example.org/~ejw/contact.html + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-001.json b/crates/dav-proto/resources/requests/mkcol-001.json new file mode 100644 index 0000000..9265cdf --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-001.json @@ -0,0 +1,41 @@ +{ + "is_mkcalendar": false, + "props": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + "value": { + "ResourceTypes": [ + "Collection", + "AddressBook" + ] + } + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "value": { + "String": "Lisa's Contacts" + } + }, + { + "property": { + "type": "CardDav", + "data": { + "type": "AddressbookDescription" + } + }, + "value": { + "String": "My primary address book." + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-001.xml b/crates/dav-proto/resources/requests/mkcol-001.xml new file mode 100644 index 0000000..c7dff1b --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-001.xml @@ -0,0 +1,15 @@ + + + + + + + + + Lisa's Contacts + My primary address book. + + + diff --git a/crates/dav-proto/resources/requests/mkcol-002.json b/crates/dav-proto/resources/requests/mkcol-002.json new file mode 100644 index 0000000..e98a7dc --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-002.json @@ -0,0 +1,369 @@ +{ + "is_mkcalendar": true, + "props": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "value": { + "String": "Lisa's Events" + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "CalendarDescription" + } + }, + "value": { + "String": "Calendar restricted to events." + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "SupportedCalendarComponentSet" + } + }, + "value": { + "Components": [ + "VEvent" + ] + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "CalendarTimezone" + } + }, + "value": { + "ICalendar": { + "components": [ + { + "component_type": "VCalendar", + "entries": [ + { + "name": { + "type": "Prodid" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "-//Example Corp.//CalDAV Client//EN" + } + ] + }, + { + "name": { + "type": "Version" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "2.0" + } + ] + } + ], + "component_ids": [ + 1 + ] + }, + { + "component_type": "VTimezone", + "entries": [ + { + "name": { + "type": "Tzid" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "US-Eastern" + } + ] + }, + { + "name": { + "type": "LastModified" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1987, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "tz_hour": 0, + "tz_minute": 0, + "tz_minus": false + } + } + ] + } + ], + "component_ids": [ + 2, + 3 + ] + }, + { + "component_type": "Standard", + "entries": [ + { + "name": { + "type": "Dtstart" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1967, + "month": 10, + "day": 29, + "hour": 2, + "minute": 0, + "second": 0, + "tz_hour": null, + "tz_minute": null, + "tz_minus": false + } + } + ] + }, + { + "name": { + "type": "Rrule" + }, + "params": [], + "values": [ + { + "type": "RecurrenceRule", + "data": { + "freq": "Yearly", + "until": null, + "count": null, + "interval": null, + "bysecond": [], + "byminute": [], + "byhour": [], + "byday": [ + { + "ordwk": -1, + "weekday": "Sunday" + } + ], + "bymonthday": [], + "byyearday": [], + "byweekno": [], + "bymonth": [ + 10 + ], + "bysetpos": [], + "wkst": null, + "rscale": null, + "skip": null + } + } + ] + }, + { + "name": { + "type": "Tzoffsetfrom" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 4, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzoffsetto" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 5, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzname" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "Eastern Standard Time (US & Canada)" + } + ] + } + ], + "component_ids": [] + }, + { + "component_type": "Daylight", + "entries": [ + { + "name": { + "type": "Dtstart" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1987, + "month": 4, + "day": 5, + "hour": 2, + "minute": 0, + "second": 0, + "tz_hour": null, + "tz_minute": null, + "tz_minus": false + } + } + ] + }, + { + "name": { + "type": "Rrule" + }, + "params": [], + "values": [ + { + "type": "RecurrenceRule", + "data": { + "freq": "Yearly", + "until": null, + "count": null, + "interval": null, + "bysecond": [], + "byminute": [], + "byhour": [], + "byday": [ + { + "ordwk": 1, + "weekday": "Sunday" + } + ], + "bymonthday": [], + "byyearday": [], + "byweekno": [], + "bymonth": [ + 4 + ], + "bysetpos": [], + "wkst": null, + "rscale": null, + "skip": null + } + } + ] + }, + { + "name": { + "type": "Tzoffsetfrom" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 5, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzoffsetto" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 4, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzname" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "Eastern Daylight Time (US & Canada)" + } + ] + } + ], + "component_ids": [] + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-002.xml b/crates/dav-proto/resources/requests/mkcol-002.xml new file mode 100644 index 0000000..d31a33d --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-002.xml @@ -0,0 +1,37 @@ + + + + + Lisa's Events + Calendar restricted to events. + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-003.json b/crates/dav-proto/resources/requests/mkcol-003.json new file mode 100644 index 0000000..c56383b --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-003.json @@ -0,0 +1,29 @@ +{ + "is_mkcalendar": false, + "props": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + "value": { + "ResourceTypes": [ + "Collection" + ] + } + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "value": { + "String": "Special Resource" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-003.xml b/crates/dav-proto/resources/requests/mkcol-003.xml new file mode 100644 index 0000000..0d7e13a --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-003.xml @@ -0,0 +1,14 @@ + + + + + + + + + Special Resource + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-004.json b/crates/dav-proto/resources/requests/mkcol-004.json new file mode 100644 index 0000000..5692c8e --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-004.json @@ -0,0 +1,30 @@ +{ + "is_mkcalendar": false, + "props": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + "value": { + "ResourceTypes": [ + "Collection", + "Calendar" + ] + } + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "value": { + "String": "Lisa's Events" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/mkcol-004.xml b/crates/dav-proto/resources/requests/mkcol-004.xml new file mode 100644 index 0000000..a7d09fd --- /dev/null +++ b/crates/dav-proto/resources/requests/mkcol-004.xml @@ -0,0 +1,13 @@ + + + + + + + + + Lisa's Events + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propertyupdate-001.json b/crates/dav-proto/resources/requests/propertyupdate-001.json new file mode 100644 index 0000000..d16de76 --- /dev/null +++ b/crates/dav-proto/resources/requests/propertyupdate-001.json @@ -0,0 +1,398 @@ +{ + "set": [ + { + "property": { + "type": "CardDav", + "data": { + "type": "AddressbookDescription" + } + }, + "value": { + "String": "Adresses de Oliver Daboo" + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "CalendarDescription" + } + }, + "value": { + "String": "Calendrier de Mathilde Desruisseaux" + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "SupportedCalendarComponentSet" + } + }, + "value": { + "Components": [ + "VEvent", + "VTodo" + ] + } + }, + { + "property": { + "type": "CalDav", + "data": { + "type": "CalendarTimezone" + } + }, + "value": { + "ICalendar": { + "components": [ + { + "component_type": "VCalendar", + "entries": [ + { + "name": { + "type": "Prodid" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "-//Example Corp.//CalDAV Client//EN" + } + ] + }, + { + "name": { + "type": "Version" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "2.0" + } + ] + } + ], + "component_ids": [ + 1 + ] + }, + { + "component_type": "VTimezone", + "entries": [ + { + "name": { + "type": "Tzid" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "US-Eastern" + } + ] + }, + { + "name": { + "type": "LastModified" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1987, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "tz_hour": 0, + "tz_minute": 0, + "tz_minus": false + } + } + ] + } + ], + "component_ids": [ + 2, + 3 + ] + }, + { + "component_type": "Standard", + "entries": [ + { + "name": { + "type": "Dtstart" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1967, + "month": 10, + "day": 29, + "hour": 2, + "minute": 0, + "second": 0, + "tz_hour": null, + "tz_minute": null, + "tz_minus": false + } + } + ] + }, + { + "name": { + "type": "Rrule" + }, + "params": [], + "values": [ + { + "type": "RecurrenceRule", + "data": { + "freq": "Yearly", + "until": null, + "count": null, + "interval": null, + "bysecond": [], + "byminute": [], + "byhour": [], + "byday": [ + { + "ordwk": -1, + "weekday": "Sunday" + } + ], + "bymonthday": [], + "byyearday": [], + "byweekno": [], + "bymonth": [ + 10 + ], + "bysetpos": [], + "wkst": null, + "rscale": null, + "skip": null + } + } + ] + }, + { + "name": { + "type": "Tzoffsetfrom" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 4, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzoffsetto" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 5, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzname" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "Eastern Standard Time (US & Canada)" + } + ] + } + ], + "component_ids": [] + }, + { + "component_type": "Daylight", + "entries": [ + { + "name": { + "type": "Dtstart" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": 1987, + "month": 4, + "day": 5, + "hour": 2, + "minute": 0, + "second": 0, + "tz_hour": null, + "tz_minute": null, + "tz_minus": false + } + } + ] + }, + { + "name": { + "type": "Rrule" + }, + "params": [], + "values": [ + { + "type": "RecurrenceRule", + "data": { + "freq": "Yearly", + "until": null, + "count": null, + "interval": null, + "bysecond": [], + "byminute": [], + "byhour": [], + "byday": [ + { + "ordwk": 1, + "weekday": "Sunday" + } + ], + "bymonthday": [], + "byyearday": [], + "byweekno": [], + "bymonth": [ + 4 + ], + "bysetpos": [], + "wkst": null, + "rscale": null, + "skip": null + } + } + ] + }, + { + "name": { + "type": "Tzoffsetfrom" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 5, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzoffsetto" + }, + "params": [], + "values": [ + { + "type": "PartialDateTime", + "data": { + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "tz_hour": 4, + "tz_minute": 0, + "tz_minus": true + } + } + ] + }, + { + "name": { + "type": "Tzname" + }, + "params": [], + "values": [ + { + "type": "Text", + "data": "Eastern Daylight Time (US & Canada)" + } + ] + } + ], + "component_ids": [] + } + ] + } + } + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + "value": { + "ResourceTypes": [ + "Collection", + "AddressBook" + ] + } + } + ], + "remove": [ + { + "type": "CalDav", + "data": { + "type": "CalendarTimezone" + } + }, + { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + } + ], + "set_first": true +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propertyupdate-001.xml b/crates/dav-proto/resources/requests/propertyupdate-001.xml new file mode 100644 index 0000000..3b92ee9 --- /dev/null +++ b/crates/dav-proto/resources/requests/propertyupdate-001.xml @@ -0,0 +1,55 @@ + + + + + Adresses de Oliver Daboo + + Calendrier de Mathilde Desruisseaux + + + + + BEGIN:VCALENDAR +PRODID:-//Example Corp.//CalDAV Client//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:US-Eastern +LAST-MODIFIED:19870101T000000Z +BEGIN:STANDARD +DTSTART:19671029T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:Eastern Standard Time (US & Canada) +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19870405T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:Eastern Daylight Time (US & Canada) +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR + + + + + + + + + + + + + + diff --git a/crates/dav-proto/resources/requests/propertyupdate-002.json b/crates/dav-proto/resources/requests/propertyupdate-002.json new file mode 100644 index 0000000..f9e2832 --- /dev/null +++ b/crates/dav-proto/resources/requests/propertyupdate-002.json @@ -0,0 +1,176 @@ +{ + "set": [ + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop0", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value0" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop1", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value1" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop2", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value2" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop3", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value3" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop4", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value4" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop5", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value5" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop6", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value6" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop7", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value7" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop8", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value8" + } + ] + } + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "prop9", + "attrs": "xmlns=\"http://example.com/neon/litmus/\"" + } + }, + "value": { + "DeadProperty": [ + { + "type": "Text", + "data": "value9" + } + ] + } + } + ], + "remove": [], + "set_first": true +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propertyupdate-002.xml b/crates/dav-proto/resources/requests/propertyupdate-002.xml new file mode 100644 index 0000000..1c38688 --- /dev/null +++ b/crates/dav-proto/resources/requests/propertyupdate-002.xml @@ -0,0 +1,11 @@ +value0 +value1 +value2 +value3 +value4 +value5 +value6 +value7 +value8 +value9 + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-001.json b/crates/dav-proto/resources/requests/propfind-001.json new file mode 100644 index 0000000..769ce83 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-001.json @@ -0,0 +1,33 @@ +{ + "type": "Prop", + "data": [ + { + "type": "DeadProperty", + "data": { + "name": "bigbox", + "attrs": "xmlns=\"http://ns.example.com/boxschema/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "author", + "attrs": "xmlns=\"http://ns.example.com/boxschema/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "DingALing", + "attrs": "xmlns=\"http://ns.example.com/boxschema/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "Random", + "attrs": "xmlns=\"http://ns.example.com/boxschema/\"" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-001.xml b/crates/dav-proto/resources/requests/propfind-001.xml new file mode 100644 index 0000000..4f71941 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-001.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/dav-proto/resources/requests/propfind-002.json b/crates/dav-proto/resources/requests/propfind-002.json new file mode 100644 index 0000000..7d15ff6 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-002.json @@ -0,0 +1,3 @@ +{ + "type": "PropName" +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-002.xml b/crates/dav-proto/resources/requests/propfind-002.xml new file mode 100644 index 0000000..8d04518 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-002.xml @@ -0,0 +1,4 @@ + + + + diff --git a/crates/dav-proto/resources/requests/propfind-003.json b/crates/dav-proto/resources/requests/propfind-003.json new file mode 100644 index 0000000..0d98007 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-003.json @@ -0,0 +1,4 @@ +{ + "type": "AllProp", + "data": [] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-003.xml b/crates/dav-proto/resources/requests/propfind-003.xml new file mode 100644 index 0000000..b509470 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-003.xml @@ -0,0 +1,4 @@ + + + + diff --git a/crates/dav-proto/resources/requests/propfind-004.json b/crates/dav-proto/resources/requests/propfind-004.json new file mode 100644 index 0000000..70bf315 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-004.json @@ -0,0 +1,11 @@ +{ + "type": "AllProp", + "data": [ + { + "type": "WebDav", + "data": { + "type": "SupportedReportSet" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-004.xml b/crates/dav-proto/resources/requests/propfind-004.xml new file mode 100644 index 0000000..ed6f0de --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-004.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/dav-proto/resources/requests/propfind-005.json b/crates/dav-proto/resources/requests/propfind-005.json new file mode 100644 index 0000000..ac4ba7a --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-005.json @@ -0,0 +1,11 @@ +{ + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "CurrentUserPrincipal" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-005.xml b/crates/dav-proto/resources/requests/propfind-005.xml new file mode 100644 index 0000000..b9b9185 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-005.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/dav-proto/resources/requests/propfind-006.json b/crates/dav-proto/resources/requests/propfind-006.json new file mode 100644 index 0000000..c1b1d1b --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-006.json @@ -0,0 +1,17 @@ +{ + "type": "Prop", + "data": [ + { + "type": "Principal", + "data": { + "type": "CalendarHomeSet" + } + }, + { + "type": "Principal", + "data": { + "type": "GroupMembership" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-006.xml b/crates/dav-proto/resources/requests/propfind-006.xml new file mode 100644 index 0000000..b5f399f --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-006.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/dav-proto/resources/requests/propfind-007.json b/crates/dav-proto/resources/requests/propfind-007.json new file mode 100644 index 0000000..3963b0e --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-007.json @@ -0,0 +1,36 @@ +{ + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "CurrentUserPrivilegeSet" + } + }, + { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "calendar-color", + "attrs": "xmlns=\"http://apple.com/ns/ical/\"" + } + }, + { + "type": "CalDav", + "data": { + "type": "SupportedCalendarComponentSet" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-007.xml b/crates/dav-proto/resources/requests/propfind-007.xml new file mode 100644 index 0000000..0a39e9a --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-007.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/dav-proto/resources/requests/propfind-008.json b/crates/dav-proto/resources/requests/propfind-008.json new file mode 100644 index 0000000..425a66a --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-008.json @@ -0,0 +1,29 @@ +{ + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + { + "type": "WebDav", + "data": { + "type": "GetCTag" + } + }, + { + "type": "CalDav", + "data": { + "type": "SupportedCalendarComponentSet" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-008.xml b/crates/dav-proto/resources/requests/propfind-008.xml new file mode 100644 index 0000000..50545fe --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-008.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-009.json b/crates/dav-proto/resources/requests/propfind-009.json new file mode 100644 index 0000000..e69de29 diff --git a/crates/dav-proto/resources/requests/propfind-009.xml b/crates/dav-proto/resources/requests/propfind-009.xml new file mode 100644 index 0000000..cd262d1 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-009.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-010.json b/crates/dav-proto/resources/requests/propfind-010.json new file mode 100644 index 0000000..42e9436 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-010.json @@ -0,0 +1,36 @@ +{ + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "ResourceType" + } + }, + { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + { + "type": "WebDav", + "data": { + "type": "SyncToken" + } + }, + { + "type": "WebDav", + "data": { + "type": "GetCTag" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "me-card", + "attrs": "xmlns=\"http://calendarserver.org/ns/\" hello=\"world & test\"" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/propfind-010.xml b/crates/dav-proto/resources/requests/propfind-010.xml new file mode 100644 index 0000000..b9fb738 --- /dev/null +++ b/crates/dav-proto/resources/requests/propfind-010.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-001.json b/crates/dav-proto/resources/requests/report-001.json new file mode 100644 index 0000000..891205d --- /dev/null +++ b/crates/dav-proto/resources/requests/report-001.json @@ -0,0 +1,128 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [ + { + "component": "VCalendar", + "name": { + "type": "Version" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Summary" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Uid" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Dtstart" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Dtend" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Duration" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Rrule" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Rdate" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Exrule" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "Exdate" + }, + "no_value": false + }, + { + "component": "VEvent", + "name": { + "type": "RecurrenceId" + }, + "no_value": false + }, + { + "component": "VTimezone", + "name": null, + "no_value": false + } + ], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VEvent" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1136332800, + "end": 1136419200 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-001.xml b/crates/dav-proto/resources/requests/report-001.xml new file mode 100644 index 0000000..29eea63 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-001.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-002.json b/crates/dav-proto/resources/requests/report-002.json new file mode 100644 index 0000000..bc9b0a4 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-002.json @@ -0,0 +1,42 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": { + "start": 1136246400, + "end": 1136419200 + }, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VEvent" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1136246400, + "end": 1136419200 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-002.xml b/crates/dav-proto/resources/requests/report-002.xml new file mode 100644 index 0000000..796017d --- /dev/null +++ b/crates/dav-proto/resources/requests/report-002.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/crates/dav-proto/resources/requests/report-003.json b/crates/dav-proto/resources/requests/report-003.json new file mode 100644 index 0000000..21dc8a0 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-003.json @@ -0,0 +1,42 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": { + "start": 1136246400, + "end": 1136419200 + }, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VEvent" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1136246400, + "end": 1136419200 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-003.xml b/crates/dav-proto/resources/requests/report-003.xml new file mode 100644 index 0000000..1600fb4 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-003.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-004.json b/crates/dav-proto/resources/requests/report-004.json new file mode 100644 index 0000000..bd7ed8d --- /dev/null +++ b/crates/dav-proto/resources/requests/report-004.json @@ -0,0 +1,42 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": { + "start": 1136160000, + "end": 1136246400 + } + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VFreebusy" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1136160000, + "end": 1136246400 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-004.xml b/crates/dav-proto/resources/requests/report-004.xml new file mode 100644 index 0000000..cdd92b1 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-004.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-005.json b/crates/dav-proto/resources/requests/report-005.json new file mode 100644 index 0000000..c4552ac --- /dev/null +++ b/crates/dav-proto/resources/requests/report-005.json @@ -0,0 +1,46 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VTodo", + "VAlarm" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1136541600, + "end": 1136628000 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-005.xml b/crates/dav-proto/resources/requests/report-005.xml new file mode 100644 index 0000000..d748d2a --- /dev/null +++ b/crates/dav-proto/resources/requests/report-005.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-006.json b/crates/dav-proto/resources/requests/report-006.json new file mode 100644 index 0000000..a3da597 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-006.json @@ -0,0 +1,51 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Property", + "comp": [ + "VCalendar", + "VEvent" + ], + "prop": { + "type": "Uid" + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "DC6C50A017428C5216A2F1CD@example.com", + "collation": "Octet", + "negate": false + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-006.xml b/crates/dav-proto/resources/requests/report-006.xml new file mode 100644 index 0000000..7818a48 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-006.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + DC6C50A017428C5216A2F1CD@example.com + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-007.json b/crates/dav-proto/resources/requests/report-007.json new file mode 100644 index 0000000..68b17c2 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-007.json @@ -0,0 +1,72 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Property", + "comp": [ + "VCalendar", + "VEvent" + ], + "prop": { + "type": "Attendee" + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "mailto:lisa@example.com", + "collation": "AsciiCasemap", + "negate": false + } + } + }, + { + "type": "Parameter", + "comp": [ + "VCalendar", + "VEvent" + ], + "prop": { + "type": "Attendee" + }, + "param": "Partstat", + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "NEEDS-ACTION", + "collation": "AsciiCasemap", + "negate": false + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-007.xml b/crates/dav-proto/resources/requests/report-007.xml new file mode 100644 index 0000000..88983b5 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-007.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + mailto:lisa@example.com + + NEEDS-ACTION + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-008.json b/crates/dav-proto/resources/requests/report-008.json new file mode 100644 index 0000000..1c6f490 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-008.json @@ -0,0 +1,41 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VEvent" + ], + "op": { + "type": "Exists" + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-008.xml b/crates/dav-proto/resources/requests/report-008.xml new file mode 100644 index 0000000..2ea1713 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-008.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-009.json b/crates/dav-proto/resources/requests/report-009.json new file mode 100644 index 0000000..a6ee429 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-009.json @@ -0,0 +1,64 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Property", + "comp": [ + "VCalendar", + "VTodo" + ], + "prop": { + "type": "Completed" + }, + "op": { + "type": "Undefined" + } + }, + { + "type": "Property", + "comp": [ + "VCalendar", + "VTodo" + ], + "prop": { + "type": "Status" + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "CANCELLED", + "collation": "AsciiCasemap", + "negate": true + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-009.xml b/crates/dav-proto/resources/requests/report-009.xml new file mode 100644 index 0000000..76c2e2a --- /dev/null +++ b/crates/dav-proto/resources/requests/report-009.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + CANCELLED + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-010.json b/crates/dav-proto/resources/requests/report-010.json new file mode 100644 index 0000000..d7cabdf --- /dev/null +++ b/crates/dav-proto/resources/requests/report-010.json @@ -0,0 +1,52 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [ + { + "type": "Property", + "comp": [ + "VCalendar", + "VEvent" + ], + "prop": { + "type": "Other", + "data": "X-ABC-GUID" + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "ABC", + "collation": "AsciiCasemap", + "negate": false + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-010.xml b/crates/dav-proto/resources/requests/report-010.xml new file mode 100644 index 0000000..bbeb447 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-010.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + ABC + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-011.json b/crates/dav-proto/resources/requests/report-011.json new file mode 100644 index 0000000..6f5c8d0 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-011.json @@ -0,0 +1,33 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + } + ] + }, + "filters": [ + { + "type": "Component", + "comp": [ + "VCalendar", + "VEvent" + ], + "op": { + "type": "TimeRange", + "data": { + "start": 1094083200, + "end": 1094169600 + } + } + } + ], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-011.xml b/crates/dav-proto/resources/requests/report-011.xml new file mode 100644 index 0000000..8afce33 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-011.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-012.json b/crates/dav-proto/resources/requests/report-012.json new file mode 100644 index 0000000..72f4b64 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-012.json @@ -0,0 +1,30 @@ +{ + "type": "CalendarQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "filters": [], + "timezone": { + "type": "None" + } +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-012.xml b/crates/dav-proto/resources/requests/report-012.xml new file mode 100644 index 0000000..65b3f95 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-012.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-013.json b/crates/dav-proto/resources/requests/report-013.json new file mode 100644 index 0000000..6ecabfd --- /dev/null +++ b/crates/dav-proto/resources/requests/report-013.json @@ -0,0 +1,30 @@ +{ + "type": "CalendarMultiGet", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CalDav", + "data": { + "type": "CalendarData", + "data": { + "properties": [], + "expand": null, + "limit_recurrence": null, + "limit_freebusy": null + } + } + } + ] + }, + "hrefs": [ + "/bernard/work/abcd1.ics", + "/bernard/work/mtg1.ics" + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-013.xml b/crates/dav-proto/resources/requests/report-013.xml new file mode 100644 index 0000000..a87e867 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-013.xml @@ -0,0 +1,10 @@ + + + + + + + /bernard/work/abcd1.ics + /bernard/work/mtg1.ics + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-014.json b/crates/dav-proto/resources/requests/report-014.json new file mode 100644 index 0000000..967e5dc --- /dev/null +++ b/crates/dav-proto/resources/requests/report-014.json @@ -0,0 +1,82 @@ +{ + "type": "AddressbookQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CardDav", + "data": { + "type": "AddressData", + "data": { + "properties": [ + { + "group": null, + "name": { + "type": "Version" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Uid" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Nickname" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Email" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Fn" + }, + "no_value": false + } + ] + } + } + } + ] + }, + "filters": [ + { + "type": "Property", + "comp": null, + "prop": { + "name": { + "type": "Nickname" + }, + "group": null + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Equals", + "value": "me", + "collation": "UnicodeCasemap", + "negate": false + } + } + } + ], + "limit": null +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-014.xml b/crates/dav-proto/resources/requests/report-014.xml new file mode 100644 index 0000000..c367c61 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-014.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + me + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-015.json b/crates/dav-proto/resources/requests/report-015.json new file mode 100644 index 0000000..2fadae0 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-015.json @@ -0,0 +1,105 @@ +{ + "type": "AddressbookQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CardDav", + "data": { + "type": "AddressData", + "data": { + "properties": [ + { + "group": null, + "name": { + "type": "Version" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Uid" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Nickname" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Email" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Fn" + }, + "no_value": false + } + ] + } + } + } + ] + }, + "filters": [ + { + "type": "AnyOf" + }, + { + "type": "Property", + "comp": null, + "prop": { + "name": { + "type": "Fn" + }, + "group": null + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "daboo", + "collation": "UnicodeCasemap", + "negate": false + } + } + }, + { + "type": "Property", + "comp": null, + "prop": { + "name": { + "type": "Email" + }, + "group": null + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "daboo", + "collation": "UnicodeCasemap", + "negate": false + } + } + } + ], + "limit": null +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-015.xml b/crates/dav-proto/resources/requests/report-015.xml new file mode 100644 index 0000000..4374910 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-015.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + daboo + + + daboo + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-016.json b/crates/dav-proto/resources/requests/report-016.json new file mode 100644 index 0000000..6d1bc45 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-016.json @@ -0,0 +1,40 @@ +{ + "type": "AddressbookQuery", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + } + ] + }, + "filters": [ + { + "type": "AnyOf" + }, + { + "type": "Property", + "comp": null, + "prop": { + "name": { + "type": "Fn" + }, + "group": null + }, + "op": { + "type": "TextMatch", + "data": { + "type": "TextMatch", + "match_type": "Contains", + "value": "daboo", + "collation": "UnicodeCasemap", + "negate": false + } + } + } + ], + "limit": 2 +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-016.xml b/crates/dav-proto/resources/requests/report-016.xml new file mode 100644 index 0000000..2cd16ff --- /dev/null +++ b/crates/dav-proto/resources/requests/report-016.xml @@ -0,0 +1,17 @@ + + + + + + + + daboo + + + + 2 + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-017.json b/crates/dav-proto/resources/requests/report-017.json new file mode 100644 index 0000000..fa10e19 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-017.json @@ -0,0 +1,63 @@ +{ + "type": "AddressbookMultiGet", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CardDav", + "data": { + "type": "AddressData", + "data": { + "properties": [ + { + "group": null, + "name": { + "type": "Version" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Uid" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Nickname" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Email" + }, + "no_value": false + }, + { + "group": null, + "name": { + "type": "Fn" + }, + "no_value": false + } + ] + } + } + } + ] + }, + "hrefs": [ + "/home/bernard/addressbook/vcf102.vcf", + "/home/bernard/addressbook/vcf1.vcf" + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-017.xml b/crates/dav-proto/resources/requests/report-017.xml new file mode 100644 index 0000000..414e774 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-017.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + /home/bernard/addressbook/vcf102.vcf + /home/bernard/addressbook/vcf1.vcf + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-018.json b/crates/dav-proto/resources/requests/report-018.json new file mode 100644 index 0000000..c468ee0 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-018.json @@ -0,0 +1,26 @@ +{ + "type": "AddressbookMultiGet", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + }, + { + "type": "CardDav", + "data": { + "type": "AddressData", + "data": { + "properties": [] + } + } + } + ] + }, + "hrefs": [ + "/home/bernard/addressbook/vcf3.vcf" + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-018.xml b/crates/dav-proto/resources/requests/report-018.xml new file mode 100644 index 0000000..8accfe5 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-018.xml @@ -0,0 +1,9 @@ + + + + + + + /home/bernard/addressbook/vcf3.vcf + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-019.json b/crates/dav-proto/resources/requests/report-019.json new file mode 100644 index 0000000..10d2bbd --- /dev/null +++ b/crates/dav-proto/resources/requests/report-019.json @@ -0,0 +1,17 @@ +{ + "type": "SyncCollection", + "sync_token": "abc", + "properties": { + "type": "Prop", + "data": [ + { + "type": "WebDav", + "data": { + "type": "GetETag" + } + } + ] + }, + "depth": "Infinity", + "limit": 9 +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-019.xml b/crates/dav-proto/resources/requests/report-019.xml new file mode 100644 index 0000000..5c561f8 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-019.xml @@ -0,0 +1,10 @@ + + + + abc + infinite + 9 + + + + diff --git a/crates/dav-proto/resources/requests/report-020.json b/crates/dav-proto/resources/requests/report-020.json new file mode 100644 index 0000000..52a4964 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-020.json @@ -0,0 +1,11 @@ +{ + "type": "AclPrincipalPropSet", + "properties": [ + { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-020.xml b/crates/dav-proto/resources/requests/report-020.xml new file mode 100644 index 0000000..5b9b9b5 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-020.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/dav-proto/resources/requests/report-021.json b/crates/dav-proto/resources/requests/report-021.json new file mode 100644 index 0000000..d12ead2 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-021.json @@ -0,0 +1,14 @@ +{ + "type": "PrincipalMatch", + "principal_properties": { + "Properties": [ + { + "type": "WebDav", + "data": { + "type": "Owner" + } + } + ] + }, + "properties": [] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-021.xml b/crates/dav-proto/resources/requests/report-021.xml new file mode 100644 index 0000000..114793a --- /dev/null +++ b/crates/dav-proto/resources/requests/report-021.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-022.json b/crates/dav-proto/resources/requests/report-022.json new file mode 100644 index 0000000..76eb5a5 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-022.json @@ -0,0 +1,61 @@ +{ + "type": "PrincipalPropertySearch", + "property_search": [ + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "match_": "doE" + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "title", + "attrs": "xmlns=\"http://www.example.com/ns/\"" + } + }, + "match_": "Sales" + } + ], + "properties": [ + { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "department", + "attrs": "xmlns=\"http://www.example.com/ns/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "phone", + "attrs": "xmlns=\"http://www.example.com/ns/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "office", + "attrs": "xmlns=\"http://www.example.com/ns/\"" + } + }, + { + "type": "DeadProperty", + "data": { + "name": "salary", + "attrs": "xmlns=\"http://www.example.com/ns/\"" + } + } + ], + "apply_to_principal_collection_set": false +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-022.xml b/crates/dav-proto/resources/requests/report-022.xml new file mode 100644 index 0000000..767edbc --- /dev/null +++ b/crates/dav-proto/resources/requests/report-022.xml @@ -0,0 +1,22 @@ + + + + + + + doE + + + + + + Sales + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-023.json b/crates/dav-proto/resources/requests/report-023.json new file mode 100644 index 0000000..b160fb6 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-023.json @@ -0,0 +1,3 @@ +{ + "type": "PrincipalSearchPropertySet" +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-023.xml b/crates/dav-proto/resources/requests/report-023.xml new file mode 100644 index 0000000..f8b7d55 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-023.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-024.json b/crates/dav-proto/resources/requests/report-024.json new file mode 100644 index 0000000..5c82d5a --- /dev/null +++ b/crates/dav-proto/resources/requests/report-024.json @@ -0,0 +1,45 @@ +{ + "type": "ExpandProperty", + "properties": [ + { + "property": { + "type": "DeadProperty", + "data": { + "name": "version-history", + "attrs": "name=\"version-history\"" + } + }, + "depth": 0 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "version-set", + "attrs": "name=\"version-set\"" + } + }, + "depth": 1 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "creator-displayname", + "attrs": "name=\"creator-displayname\"" + } + }, + "depth": 2 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "activity-set", + "attrs": "name=\"activity-set\"" + } + }, + "depth": 2 + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-024.xml b/crates/dav-proto/resources/requests/report-024.xml new file mode 100644 index 0000000..72b1572 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-024.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-025.json b/crates/dav-proto/resources/requests/report-025.json new file mode 100644 index 0000000..c251903 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-025.json @@ -0,0 +1,83 @@ +{ + "type": "ExpandProperty", + "properties": [ + { + "property": { + "type": "DeadProperty", + "data": { + "name": "calendar-proxy-read-for", + "attrs": "name=\"calendar-proxy-read-for\" namespace=\"\nhttp://calendarserver.org/ns/\"" + } + }, + "depth": 0 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "email-address-set", + "attrs": "name=\"email-address-set\"\nnamespace=\"http://calendarserver.org/ns/\"" + } + }, + "depth": 1 + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "depth": 1 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "calendar-user-address-set", + "attrs": "name=\"calendar-user-address-set\"\nnamespace=\"urn:ietf:params:xml:ns:caldav\"" + } + }, + "depth": 1 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "calendar-proxy-write-for", + "attrs": "name=\"calendar-proxy-write-for\"\nnamespace=\"http://calendarserver.org/ns/\"" + } + }, + "depth": 0 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "email-address-set", + "attrs": "name=\"email-address-set\" namespace=\"http://calendarserver.org/ns/\"" + } + }, + "depth": 1 + }, + { + "property": { + "type": "WebDav", + "data": { + "type": "DisplayName" + } + }, + "depth": 1 + }, + { + "property": { + "type": "DeadProperty", + "data": { + "name": "calendar-user-address-set", + "attrs": "name=\"calendar-user-address-set\"\nnamespace=\"urn:ietf:params:xml:ns:caldav\"" + } + }, + "depth": 1 + } + ] +} \ No newline at end of file diff --git a/crates/dav-proto/resources/requests/report-025.xml b/crates/dav-proto/resources/requests/report-025.xml new file mode 100644 index 0000000..fa75338 --- /dev/null +++ b/crates/dav-proto/resources/requests/report-025.xml @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/001.xml b/crates/dav-proto/resources/responses/001.xml new file mode 100644 index 0000000..0773154 --- /dev/null +++ b/crates/dav-proto/resources/responses/001.xml @@ -0,0 +1,6 @@ + + + + /locked/ + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/002.xml b/crates/dav-proto/resources/responses/002.xml new file mode 100644 index 0000000..d840870 --- /dev/null +++ b/crates/dav-proto/resources/responses/002.xml @@ -0,0 +1,18 @@ + + + + http://www.example.com/file + + + Box type A + + HTTP/1.1 200 OK + + + Box type B + HTTP/1.1 403 Forbidden + The user does not have access to the DingALing property. + + + There has been an access violation error. + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/003.xml b/crates/dav-proto/resources/responses/003.xml new file mode 100644 index 0000000..fe755bf --- /dev/null +++ b/crates/dav-proto/resources/responses/003.xml @@ -0,0 +1,50 @@ + + + + /container/ + + + 1997-12-02T01:42:21Z + Example collection + + + + + + + + + + + + + HTTP/1.1 200 OK + + + + /container/front.html + + + 1997-12-02T02:27:21Z + Example HTML resource + 4525 + text/html + "zzyzx" + Mon, 12 Jan 1998 09:25:56 GMT + + + + + + + + + + + + + HTTP/1.1 200 OK + + + diff --git a/crates/dav-proto/resources/responses/004.xml b/crates/dav-proto/resources/responses/004.xml new file mode 100644 index 0000000..5a74c55 --- /dev/null +++ b/crates/dav-proto/resources/responses/004.xml @@ -0,0 +1,8 @@ + + + + http://www.example.com/container/resource3 + HTTP/1.1 423 Locked + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/005.xml b/crates/dav-proto/resources/responses/005.xml new file mode 100644 index 0000000..03c199e --- /dev/null +++ b/crates/dav-proto/resources/responses/005.xml @@ -0,0 +1,22 @@ + + + + + + + infinity + + http://example.org/~ejw/contact.html + + Second-604800 + + urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4 + + + http://example.com/workspace/webdav/proposal.doc + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/006.xml b/crates/dav-proto/resources/responses/006.xml new file mode 100644 index 0000000..ce54a79 --- /dev/null +++ b/crates/dav-proto/resources/responses/006.xml @@ -0,0 +1,27 @@ + + + + http://www.example.com/container/ + + + + + + + 0 + Jane Smith + Infinite + + urn:uuid:f81de2ad-7f3d-a1b2-4f3c-00a0c91a9d76 + + + http://www.example.com/container/ + + + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/007.xml b/crates/dav-proto/resources/responses/007.xml new file mode 100644 index 0000000..d6f0c11 --- /dev/null +++ b/crates/dav-proto/resources/responses/007.xml @@ -0,0 +1,6 @@ + + + + /workspace/webdav/ + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/008.xml b/crates/dav-proto/resources/responses/008.xml new file mode 100644 index 0000000..fa1ac79 --- /dev/null +++ b/crates/dav-proto/resources/responses/008.xml @@ -0,0 +1,43 @@ + + + + http://cal.example.com/bernard/work/abcd2.ics + + + "fffff-abcd2" + + + HTTP/1.1 200 OK + + + + http://cal.example.com/bernard/work/abcd3.ics + + + "fffff-abcd3" + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/009.xml b/crates/dav-proto/resources/responses/009.xml new file mode 100644 index 0000000..b9b8621 --- /dev/null +++ b/crates/dav-proto/resources/responses/009.xml @@ -0,0 +1,11 @@ + + + + + + + + + HTTP/1.1 200 OK + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/010.xml b/crates/dav-proto/resources/responses/010.xml new file mode 100644 index 0000000..52f36ba --- /dev/null +++ b/crates/dav-proto/resources/responses/010.xml @@ -0,0 +1,20 @@ + + + + /home/bernard/addressbook/v102.vcf + + + "23ba4d-ff11fb" + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/011.xml b/crates/dav-proto/resources/responses/011.xml new file mode 100644 index 0000000..1d2b4fb --- /dev/null +++ b/crates/dav-proto/resources/responses/011.xml @@ -0,0 +1,27 @@ + + + + /home/bernard/addressbook/ + HTTP/1.1 507 Insufficient Storage + + Only two matching records were returned + + + /home/bernard/addressbook/v102.vcf + + + "23ba4d-ff11fb" + + HTTP/1.1 200 OK + + + + /home/bernard/addressbook/v104.vcf + + + "23ba4d-ff11fc" + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/012.xml b/crates/dav-proto/resources/responses/012.xml new file mode 100644 index 0000000..df7c55e --- /dev/null +++ b/crates/dav-proto/resources/responses/012.xml @@ -0,0 +1,12 @@ + + + + /a + + + + /c + + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/013.xml b/crates/dav-proto/resources/responses/013.xml new file mode 100644 index 0000000..ac089fd --- /dev/null +++ b/crates/dav-proto/resources/responses/013.xml @@ -0,0 +1,15 @@ + + + + + + + Full name + + + + + + Job title + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/014.xml b/crates/dav-proto/resources/responses/014.xml new file mode 100644 index 0000000..a8b5d89 --- /dev/null +++ b/crates/dav-proto/resources/responses/014.xml @@ -0,0 +1,55 @@ + + + + http://www.example.com/papers/ + + + + + + + Any operation + + + Read any object + + + + Read ACL + + + + + + + Read current user privilege set property + + + + + Write any object + + + + Write ACL + + + + Write properties + + + + Write resource content + + + + + Unlock resource + + + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/015.xml b/crates/dav-proto/resources/responses/015.xml new file mode 100644 index 0000000..0b3d38c --- /dev/null +++ b/crates/dav-proto/resources/responses/015.xml @@ -0,0 +1,14 @@ + + + + http://www.example.com/papers/ + + + + + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/016.xml b/crates/dav-proto/resources/responses/016.xml new file mode 100644 index 0000000..683b4aa --- /dev/null +++ b/crates/dav-proto/resources/responses/016.xml @@ -0,0 +1,29 @@ + + + http://www.example.com/papers/ + + + + + + http://www.example.com/acl/groups/maintainers + + + + + + + + + + + + + + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/017.xml b/crates/dav-proto/resources/responses/017.xml new file mode 100644 index 0000000..2e62b8f --- /dev/null +++ b/crates/dav-proto/resources/responses/017.xml @@ -0,0 +1,17 @@ + + + + http://www.example.com/papers/ + + + + + + + + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/018.xml b/crates/dav-proto/resources/responses/018.xml new file mode 100644 index 0000000..9577ac7 --- /dev/null +++ b/crates/dav-proto/resources/responses/018.xml @@ -0,0 +1,15 @@ + + + + http://www.example.com/papers/ + + + + http://www.example.com/acl/users/ + http://www.example.com/acl/groups/ + + + HTTP/1.1 200 OK + + + \ No newline at end of file diff --git a/crates/dav-proto/resources/responses/019.xml b/crates/dav-proto/resources/responses/019.xml new file mode 100644 index 0000000..dcaf8cf --- /dev/null +++ b/crates/dav-proto/resources/responses/019.xml @@ -0,0 +1,80 @@ + + + + http://www.example.com/top/container/ + + + + http://www.example.com/users/gclemm + + + + + + Any operation + + + Read any object + + + + + Write any object + + + + Read the ACL + + + + Write the ACL + + + + + + + + + + + http://www.example.com/users/esedlar + + + + + + + + + + http://www.example.com/groups/mrktng + + + + + + + + + + + + + + + + + + + + + http://www.example.com/top + + + + + HTTP/1.1 200 OK + + + diff --git a/crates/dav-proto/resources/responses/020.xml b/crates/dav-proto/resources/responses/020.xml new file mode 100644 index 0000000..89eb7c2 --- /dev/null +++ b/crates/dav-proto/resources/responses/020.xml @@ -0,0 +1,23 @@ + + + + +mailto:wilfredo@example.com + +2.0;Success + + + + +mailto:bernard@example.net + +2.0;Success + + + + +mailto:mike@example.org + +3.7;Invalid calendar user + + diff --git a/crates/dav-proto/src/lib.rs b/crates/dav-proto/src/lib.rs new file mode 100644 index 0000000..c3681d6 --- /dev/null +++ b/crates/dav-proto/src/lib.rs @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::vcard::VCardVersion; +use compact_str::{CompactString, ToCompactString}; +use trc::Value; + +pub mod parser; +pub mod requests; +pub mod responses; +pub mod schema; + +pub fn xml_pretty_print(xml_string: &str) -> String { + // Create a reader + let mut reader = quick_xml::Reader::from_str(xml_string); + let mut writer = quick_xml::Writer::new_with_indent(std::io::Cursor::new(Vec::new()), b' ', 2); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(quick_xml::events::Event::Eof) => break, + Ok(event) => { + writer.write_event(event).unwrap(); + } + Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e), + } + buf.clear(); + } + + let result = writer.into_inner().into_inner(); + String::from_utf8(result).unwrap() +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RequestHeaders<'x> { + pub uri: &'x str, + pub depth: Depth, + pub timeout: Timeout, + pub content_type: Option<&'x str>, + pub destination: Option<&'x str>, + pub lock_token: Option<&'x str>, + pub vcard_version: Option, + pub no_schedule_reply: bool, + pub if_schedule_tag: Option, + pub overwrite_fail: bool, + pub no_timezones: bool, + pub ret: Return, + pub depth_no_root: bool, + pub if_: Vec>, + pub range: Option, + pub if_range: Option<&'x str>, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ByteRange { + Offset { start: u64, end: Option }, + Suffix(u64), +} + +pub struct ResourceState> { + pub resource: Option, + pub etag: T, + pub state_token: T, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub enum Return { + Minimal, + Representation, + #[default] + Default, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct If<'x> { + pub resource: Option<&'x str>, + pub list: Vec>, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Condition<'x> { + StateToken { is_not: bool, token: &'x str }, + ETag { is_not: bool, tag: &'x str }, + Exists { is_not: bool }, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum Timeout { + Infinite, + Second(u64), + #[default] + None, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Depth { + Zero, + One, + Infinity, + #[default] + None, +} + +impl From<&RequestHeaders<'_>> for Value { + fn from(headers: &RequestHeaders<'_>) -> Self { + let mut values = Vec::with_capacity(4); + if headers.depth != Depth::None { + values.push(Value::String(CompactString::const_new("Depth"))); + values.push(match headers.depth { + Depth::Zero => Value::Int(0), + Depth::One => Value::Int(1), + Depth::Infinity => Value::String(CompactString::const_new("infinity")), + Depth::None => Value::None, + }); + } + if headers.timeout != Timeout::None { + values.push(Value::String(CompactString::const_new("Timeout"))); + values.push(match headers.timeout { + Timeout::Infinite => Value::String(CompactString::const_new("infinite")), + Timeout::Second(n) => Value::Int(n as i64), + Timeout::None => Value::None, + }); + } + for (name, header_value) in [ + ("Content-Type", headers.content_type), + ("Destination", headers.destination), + ("Lock-Token", headers.lock_token), + ("If-Range", headers.if_range), + ] { + if let Some(value) = header_value { + values.push(CompactString::const_new(name).into()); + values.push(value.to_compact_string().into()); + } + } + if let Some(range) = headers.range { + values.push(CompactString::const_new("Range").into()); + values.push( + match range { + ByteRange::Offset { + start, + end: Some(end), + } => format!("{start}-{end}"), + ByteRange::Offset { start, end: None } => format!("{start}-"), + ByteRange::Suffix(length) => format!("-{length}"), + } + .to_compact_string() + .into(), + ); + } + for (name, is_set) in [ + ("Overwrite", headers.overwrite_fail), + ("No-Timezones", headers.no_timezones), + ("Depth-No-Root", headers.depth_no_root), + ] { + if is_set { + values.push(CompactString::const_new(name).into()); + } + } + for if_ in &headers.if_ { + values.push(CompactString::const_new("If").into()); + let mut if_values = Vec::with_capacity(if_.list.len() * 2 + 1); + if let Some(resource) = if_.resource { + if_values.push(Value::String(resource.to_compact_string())); + } + for condition in &if_.list { + match condition { + Condition::StateToken { is_not, token } => { + if *is_not { + if_values.push(Value::String(CompactString::const_new("!State-Token"))); + } else { + if_values.push(Value::String(CompactString::const_new("State-Token"))); + } + if_values.push(Value::String(token.to_compact_string())); + } + Condition::ETag { is_not, tag } => { + if *is_not { + if_values.push(Value::String(CompactString::const_new("!ETag"))); + } else { + if_values.push(Value::String(CompactString::const_new("ETag"))); + } + if_values.push(Value::String(tag.to_compact_string())); + } + Condition::Exists { is_not } => { + if *is_not { + if_values.push(Value::String(CompactString::const_new("!Exists"))); + } else { + if_values.push(Value::String(CompactString::const_new("Exists"))); + } + } + } + } + values.push(Value::Array(if_values)); + } + + Value::Array(values) + } +} + +/* + + +Implemented: + +RFC4918 - HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV) +RFC5689 - Extended MKCOL for Web Distributed Authoring and Versioning (WebDAV) +RFC6578 - Collection Synchronization for Web Distributed Authoring and Versioning (WebDAV) +RFC3744 - Web Distributed Authoring and Versioning (WebDAV) Access Control Protocol +RFC4331 - Quota and Size Properties for Distributed Authoring and Versioning (DAV) Collections +RFC5397 - WebDAV Current Principal Extension +RFC8144 - Use of the Prefer Header Field in Web Distributed Authoring and Versioning (WebDAV) +RFC4791 - Calendaring Extensions to WebDAV (CalDAV) +RFC7809 - Calendaring Extensions to WebDAV (CalDAV) Time Zones by Reference +RFC6638 - Scheduling Extensions to CalDAV +RFC6352 - CardDAV vCard Extensions to Web Distributed Authoring and Versioning (WebDAV) +RFC6764 - Locating Services for Calendaring Extensions to WebDAV (CalDAV) and vCard Extensions to WebDAV (CardDAV) + +Out of scope: + +RFC5842 - Binding Extensions to Web Distributed Authoring and Versioning (WebDAV) +RFC4316 - Datatypes for Web Distributed Authoring and Versioning (WebDAV) Properties +RFC4709 - Mounting Web Distributed Authoring and Versioning (WebDAV) Servers +RFC3648 - Web Distributed Authoring and Versioning (WebDAV) Ordered Collections Protocol +RFC4437 - Web Distributed Authoring and Versioning (WebDAV) Redirect Reference Resources +RFC8607 - Calendaring Extensions to WebDAV (CalDAV) Managed Attachments +RFC5995 - Using POST to Add Members to Web Distributed Authoring and Versioning (WebDAV) Collections +RFC3253 - Versioning Extensions to WebDAV (Web Distributed Authoring and Versioning) +RFC5323 - Web Distributed Authoring and Versioning (WebDAV) SEARCH + + +*/ diff --git a/crates/dav-proto/src/parser/header.rs b/crates/dav-proto/src/parser/header.rs new file mode 100644 index 0000000..7908aa4 --- /dev/null +++ b/crates/dav-proto/src/parser/header.rs @@ -0,0 +1,950 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ByteRange, Condition, Depth, If, RequestHeaders, ResourceState, Return, Timeout}; +use calcard::vcard::VCardVersion; +use std::ops::Range; + +impl<'x> RequestHeaders<'x> { + pub fn new(uri: &'x str) -> Self { + RequestHeaders { + uri, + ..Default::default() + } + } + + pub fn parse(&mut self, key: &str, value: &'x str) -> bool { + hashify::fnc_map_ignore_case!(key.as_bytes(), + "Depth" => { + if let Some(depth) = Depth::parse(value.as_bytes()) { + self.depth = depth; + return true; + } + }, + "Destination" => { + self.destination = Some(value); + return true; + }, + "Lock-Token" => { + self.lock_token = Some(try_unwrap_coded_url(value)); + return true; + }, + "If" => { + let num = self.if_.len(); + self.parse_if(value); + return self.if_.len() != num; + }, + "If-Match" => { + let num = self.if_.len(); + self.parse_if_match(value, false); + return self.if_.len() != num; + }, + "If-None-Match" => { + let num = self.if_.len(); + self.parse_if_match(value, true); + return self.if_.len() != num; + }, + "Timeout" => { + let value = value.split_once(',').map(|(first, _)| first).unwrap_or(value).trim(); + if let Some(seconds) = value.strip_prefix("Second-") { + if let Ok(seconds) = seconds.parse() { + self.timeout = Timeout::Second(seconds); + return true; + } + } else if value == "Infinite" { + self.timeout = Timeout::Infinite; + return true; + } + }, + "Overwrite" => { + self.overwrite_fail = value == "F"; + return true; + }, + "CalDAV-Timezones" => { + self.no_timezones = value == "F"; + return true; + }, + "Prefer" => { + for value in value.split(&[',', ';']) { + match value.trim() { + "return=minimal" => self.ret = Return::Minimal, + "return=representation" => self.ret = Return::Representation, + "depth-noroot" => self.depth_no_root = true, + _ => {} + } + } + }, + "Content-Type" => { + let value = value.trim(); + if (2..=127).contains(&value.len()) { + self.content_type = Some(value); + } + return true; + }, + "Accept" => { + let mut preferred: Option<(f32, VCardVersion)> = None; + + for entry in value.split(',') { + let mut parts = entry.split(';'); + if !parts.next().is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("text/vcard")) { + continue; + } + + let mut version = None; + let mut quality = 1.0; + + for param in parts { + let Some((name, param_value)) = param.split_once('=') else { + continue; + }; + let param_value = param_value.trim().trim_matches('"'); + + if name.trim().eq_ignore_ascii_case("version") { + version = VCardVersion::try_parse(param_value); + } else if name.trim().eq_ignore_ascii_case("q") { + quality = param_value.parse().unwrap_or(1.0); + } + } + + if let Some(version) = version + && quality > 0.0 + && preferred.is_none_or(|(preferred_quality, _)| quality > preferred_quality) { + preferred = Some((quality, version)); + } + } + + if let Some((_, version)) = preferred { + self.vcard_version = Some(version); + } + return true; + }, + "If-Schedule-Tag-Match" => { + self.if_schedule_tag = value.trim().trim_matches('"').parse().ok(); + return true; + }, + "Schedule-Reply" => { + self.no_schedule_reply = value == "F"; + return true; + }, + "Range" => { + self.range = ByteRange::parse(value); + return self.range.is_some(); + }, + "If-Range" => { + self.if_range = Some(value.trim()); + return true; + }, + _ => {} + ); + + false + } + + pub fn has_if(&self) -> bool { + !self.if_.is_empty() + } + + pub fn eval_if_resources(&self) -> impl Iterator { + self.if_.iter().filter_map(|if_| if_.resource) + } + + pub fn eval_if(&self, resources: &[ResourceState]) -> bool + where + T: AsRef, + { + if self.if_.is_empty() { + return true; + } + + 'outer: for if_ in &self.if_ { + if if_.list.is_empty() { + continue; + } + + let (current_token, current_etag) = resources + .iter() + .find_map(|r| { + if if_.resource == r.resource.as_ref().map(|v| v.as_ref()) { + Some((r.state_token.as_ref(), r.etag.as_ref())) + } else { + None + } + }) + .unwrap_or_default(); + + for cond in if_.list.iter() { + match cond { + Condition::StateToken { is_not, token } => { + if !((current_token == *token) ^ is_not) { + continue 'outer; + } + } + Condition::ETag { is_not, tag } => { + if !((current_etag == *tag) ^ is_not) { + continue 'outer; + } + } + Condition::Exists { is_not } => { + if !((current_etag.is_empty()) ^ is_not) { + continue 'outer; + } + } + } + } + + return true; + } + + false + } + + fn parse_if(&mut self, value: &'x str) { + let value = value.as_bytes(); + let mut iter = value.iter().enumerate(); + let mut resource = None; + + while let Some((idx, ch)) = iter.next() { + match ch { + b'<' if resource.is_none() => { + for (to_idx, ch) in iter.by_ref() { + if *ch == b'>' { + resource = Some(std::str::from_utf8(&value[idx + 1..to_idx]).unwrap()); + break; + } + } + } + b'(' => { + let mut is_not = false; + let mut conditions = Vec::new(); + while let Some((idx, ch)) = iter.next() { + match ch { + b'N' => { + if matches!(iter.next(), Some((_, b'o'))) + && matches!(iter.next(), Some((_, b't'))) + { + is_not = true; + } else { + return; + } + } + b'<' | b'[' => { + let (stop_char, is_etag) = match ch { + b'<' => (b'>', false), + b'[' => (b']', true), + _ => unreachable!(), + }; + + for (to_idx, ch) in iter.by_ref() { + if *ch == stop_char { + let value = + std::str::from_utf8(&value[idx + 1..to_idx]).unwrap(); + let condition = if is_etag { + Condition::ETag { is_not, tag: value } + } else { + Condition::StateToken { + is_not, + token: value, + } + }; + conditions.push(condition); + is_not = false; + break; + } + } + } + b')' => { + self.if_.push(If { + resource: resource.take(), + list: conditions, + }); + break; + } + _ => { + if !ch.is_ascii_whitespace() { + return; + } + } + } + } + } + _ => { + if !ch.is_ascii_whitespace() { + return; + } + } + } + } + } + + pub fn parse_if_match(&mut self, value: &'x str, is_not: bool) { + if value == "*" { + self.if_.push(If { + resource: None, + list: vec![Condition::Exists { is_not }], + }); + } else if !is_not { + for etag in value.split(',') { + self.if_.push(If { + resource: None, + list: vec![Condition::ETag { + is_not, + tag: etag.trim(), + }], + }); + } + } else { + let mut etags = Vec::new(); + for etag in value.split(',') { + etags.push(Condition::ETag { + is_not, + tag: etag.trim(), + }); + } + self.if_.push(If { + resource: None, + list: etags, + }); + } + } + + pub fn eval_if_range(&self, etag: &str, last_modified: Option<&str>) -> bool { + match self.if_range { + Some(validator) => { + !validator.starts_with("W/") + && (validator == etag + || last_modified.is_some_and(|last_modified| validator == last_modified)) + } + None => true, + } + } + + pub fn base_uri(&self) -> Option<&str> { + dav_base_uri(self.uri) + } +} + +impl ByteRange { + pub fn parse(value: &str) -> Option { + let (unit, spec) = value.split_once('=')?; + if !unit.trim().eq_ignore_ascii_case("bytes") || spec.contains(',') { + return None; + } + + let (start, end) = spec.split_once('-')?; + let (start, end) = (start.trim(), end.trim()); + + if !start.is_empty() { + let start = start.parse::().ok()?; + let end = if !end.is_empty() { + let end = end.parse::().ok()?; + if end < start { + return None; + } + Some(end) + } else { + None + }; + + Some(ByteRange::Offset { start, end }) + } else { + end.parse::().ok().map(ByteRange::Suffix) + } + } + + pub fn resolve(&self, size: u64) -> Option> { + match self { + ByteRange::Offset { start, end } if *start < size => { + Some(*start..end.map_or(size, |end| std::cmp::min(end.saturating_add(1), size))) + } + ByteRange::Suffix(length) if *length > 0 && size > 0 => { + Some(size.saturating_sub(*length)..size) + } + _ => None, + } + } +} + +pub fn dav_base_uri(uri: &str) -> Option<&str> { + // From a path ../dav/collection/account/.. + // returns ../dav/collection/account without the trailing slash + + let uri = uri.as_bytes(); + let mut found_dav = false; + let mut last_idx = 0; + let mut sep_count = 0; + + for (idx, ch) in uri.iter().enumerate() { + if *ch == b'/' { + if !found_dav { + found_dav = uri.get(idx + 1..idx + 5).is_some_and(|s| s == b"dav/"); + } else if found_dav { + if sep_count == 2 { + break; + } + sep_count += 1; + } + } + last_idx = idx; + } + + if sep_count == 2 { + uri.get(..last_idx + 1) + .map(|uri| std::str::from_utf8(uri).unwrap()) + } else { + None + } +} + +impl Depth { + pub fn parse(value: &[u8]) -> Option { + hashify::tiny_map!(value, + "0" => Depth::Zero, + "1" => Depth::One, + "infinity" => Depth::Infinity, + "infinite" => Depth::Infinity, + ) + } +} + +fn try_unwrap_coded_url(url: &str) -> &str { + url.strip_prefix("<") + .and_then(|url| url.strip_suffix(">")) + .unwrap_or(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_accept_vcard_version() { + for (header, expected) in [ + ("text/vcard", None), + ("application/json", None), + ("text/vcard; version=3.0", Some(VCardVersion::V3_0)), + ("text/vcard;version=\"3.0\"", Some(VCardVersion::V3_0)), + ("text/vcard; VERSION=3.0", Some(VCardVersion::V3_0)), + ("text/vcard; version=3.0; q=1.0", Some(VCardVersion::V3_0)), + ( + "text/vcard; version=3.0; charset=utf-8", + Some(VCardVersion::V3_0), + ), + ( + "text/vcard; charset=utf-8; version=3.0", + Some(VCardVersion::V3_0), + ), + ( + "text/vcard; version=4.0, text/vcard; version=3.0", + Some(VCardVersion::V4_0), + ), + ( + "text/vcard; version=4.0; q=0.5, text/vcard; version=3.0", + Some(VCardVersion::V3_0), + ), + ( + "text/vcard; q=0.5; version=4.0, text/vcard; q=1.0; version=3.0", + Some(VCardVersion::V3_0), + ), + ( + "text/vcard; version=4.0; q=0, text/vcard; version=3.0; q=0.1", + Some(VCardVersion::V3_0), + ), + ("*/*, text/vcard; version=3.0", Some(VCardVersion::V3_0)), + ("text/vcard-custom; version=3.0", None), + ] { + let mut headers = RequestHeaders::new("/dav/card/test/default/"); + assert!(headers.parse("Accept", header)); + assert_eq!(headers.vcard_version, expected, "failed for {header:?}"); + } + } + + #[test] + fn parse_range() { + for (header, expected) in [ + ( + "bytes=0-499", + Some(ByteRange::Offset { + start: 0, + end: Some(499), + }), + ), + ( + "bytes=500-", + Some(ByteRange::Offset { + start: 500, + end: None, + }), + ), + ( + "bytes = 500 - 999 ", + Some(ByteRange::Offset { + start: 500, + end: Some(999), + }), + ), + ( + "BYTES=0-0", + Some(ByteRange::Offset { + start: 0, + end: Some(0), + }), + ), + ("bytes=-500", Some(ByteRange::Suffix(500))), + ("bytes=-0", Some(ByteRange::Suffix(0))), + ("bytes=0-499,600-999", None), + ("bytes=499-100", None), + ("bytes=abc-def", None), + ("bytes=-", None), + ("bytes=0", None), + ("items=0-499", None), + ("0-499", None), + ] { + let mut headers = RequestHeaders::new("/dav/file/test/file.txt"); + assert_eq!(headers.parse("Range", header), expected.is_some()); + assert_eq!(headers.range, expected, "failed for {header:?}"); + } + + for (range, size, expected) in [ + ( + ByteRange::Offset { + start: 0, + end: Some(499), + }, + 1000, + Some(0..500), + ), + ( + ByteRange::Offset { + start: 0, + end: Some(499), + }, + 100, + Some(0..100), + ), + ( + ByteRange::Offset { + start: 500, + end: None, + }, + 1000, + Some(500..1000), + ), + ( + ByteRange::Offset { + start: 999, + end: Some(u64::MAX), + }, + 1000, + Some(999..1000), + ), + ( + ByteRange::Offset { + start: 1000, + end: None, + }, + 1000, + None, + ), + ( + ByteRange::Offset { + start: 0, + end: None, + }, + 0, + None, + ), + (ByteRange::Suffix(500), 1000, Some(500..1000)), + (ByteRange::Suffix(5000), 1000, Some(0..1000)), + (ByteRange::Suffix(0), 1000, None), + (ByteRange::Suffix(500), 0, None), + ] { + assert_eq!( + range.resolve(size), + expected, + "failed for {range:?} of {size}" + ); + } + } + + #[test] + fn eval_if_range_header() { + const LAST_MODIFIED: &str = "Mon, 10 Aug 2026 12:00:00 GMT"; + + let mut headers = RequestHeaders::new("/dav/file/test/file.txt"); + assert!(headers.eval_if_range("\"etag\"", Some(LAST_MODIFIED))); + + for (validator, expected) in [ + ("\"etag\"", true), + (LAST_MODIFIED, true), + ("W/\"etag\"", false), + ("\"other\"", false), + ("Sun, 09 Aug 2026 12:00:00 GMT", false), + ] { + assert!(headers.parse("If-Range", validator)); + assert_eq!( + headers.eval_if_range("\"etag\"", Some(LAST_MODIFIED)), + expected, + "failed for {validator:?}" + ); + } + + for (validator, expected) in [("\"etag\"", true), (LAST_MODIFIED, false)] { + assert!(headers.parse("If-Range", validator)); + assert_eq!( + headers.eval_if_range("\"etag\"", None), + expected, + "failed for {validator:?}" + ); + } + } + + #[test] + fn base_uri() { + for (uri, expected_base) in [ + ( + "http://host/dav/collection/account/test/", + Some("http://host/dav/collection/account"), + ), + ( + "http://host/dav/collection/account/test", + Some("http://host/dav/collection/account"), + ), + ( + "http://host/dav/collection/account/", + Some("http://host/dav/collection/account"), + ), + ( + "http://host/dav/collection/account", + Some("http://host/dav/collection/account"), + ), + ( + "http://host/dev/dav/collection/account/test/", + Some("http://host/dev/dav/collection/account"), + ), + ( + "http://host/dev/dav/collection/account/test", + Some("http://host/dev/dav/collection/account"), + ), + ( + "http://host/dev/dav/collection/account/", + Some("http://host/dev/dav/collection/account"), + ), + ( + "http://host/dev/dav/collection/account", + Some("http://host/dev/dav/collection/account"), + ), + ( + "/dav/collection/account/test/", + Some("/dav/collection/account"), + ), + ( + "/dav/collection/account/test", + Some("/dav/collection/account"), + ), + ("/dav/collection/account/", Some("/dav/collection/account")), + ("/dav/collection/account", Some("/dav/collection/account")), + ] { + assert_eq!(RequestHeaders::new(uri).base_uri(), expected_base); + } + } + + #[test] + fn eval_if_header() { + let mut headers = RequestHeaders::default(); + assert!(headers.parse( + "If", + r#"( + ["I am an ETag"]) + (["I am another ETag"])"#, + )); + + assert!(headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + etag: "\"I am an ETag\"" + }])); + assert!(headers.eval_if(&[ResourceState { + resource: None, + state_token: "", + etag: "\"I am another ETag\"" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "", + etag: "\"Unknown ETag\"" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + etag: "" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + etag: "\"Other ETag\"" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "", + etag: "\"I am an ETag\"" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:blah", + etag: "\"I am an ETag\"" + }])); + + assert!(headers.parse( + "If", + r#"(Not + )"#, + )); + assert!(headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092", + etag: "" + }])); + assert!(!headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + etag: "" + }])); + + assert!(headers.parse( + "If", + r#"() + (Not )"# + )); + assert!(headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + etag: "" + }])); + assert!(headers.eval_if(&[ResourceState { + resource: None, + state_token: "urn:other-token", + etag: "" + }])); + } + + #[test] + fn parse_headers() { + let mut headers = RequestHeaders::default(); + assert!(headers.parse("Depth", "0")); + assert_eq!(headers.depth, Depth::Zero); + + assert!(headers.parse("Destination", "/path/to/destination")); + assert_eq!(headers.destination, Some("/path/to/destination")); + + assert!(headers.parse("Lock-Token", "")); + assert_eq!(headers.lock_token, Some("urn:uuid:1234")); + + for (input, expected) in [ + ( + "()", + vec![If { + resource: "urn:uuid:1234".into(), + list: vec![Condition::StateToken { + is_not: false, + token: "urn:uuid:1234", + }], + }], + ), + ( + "<>(<>)", + vec![If { + resource: "".into(), + list: vec![Condition::StateToken { + is_not: false, + token: "", + }], + }], + ), + ( + r#"( + ["I am an ETag"]) + (["I am another ETag"])"#, + vec![ + If { + resource: None, + list: vec![ + Condition::StateToken { + is_not: false, + token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + }, + Condition::ETag { + is_not: false, + tag: "\"I am an ETag\"", + }, + ], + }, + If { + resource: None, + list: vec![Condition::ETag { + is_not: false, + tag: "\"I am another ETag\"", + }], + }, + ], + ), + ( + r#"(Not + )"#, + vec![If { + resource: None, + list: vec![ + Condition::StateToken { + is_not: true, + token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + }, + Condition::StateToken { + is_not: false, + token: "urn:uuid:58f202ac-22cf-11d1-b12d-002035b29092", + }, + ], + }], + ), + ( + r#"() + (Not )"#, + vec![ + If { + resource: None, + list: vec![Condition::StateToken { + is_not: false, + token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + }], + }, + If { + resource: None, + list: vec![Condition::StateToken { + is_not: true, + token: "DAV:no-lock", + }], + }, + ], + ), + ( + r#" + ( + [W/"A weak ETag"]) (["strong ETag"])"#, + vec![ + If { + resource: "/resource1".into(), + list: vec![ + Condition::StateToken { + is_not: false, + token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + }, + Condition::ETag { + is_not: false, + tag: "W/\"A weak ETag\"", + }, + ], + }, + If { + resource: None, + list: vec![Condition::ETag { + is_not: false, + tag: "\"strong ETag\"", + }], + }, + ], + ), + ( + r#" + ()"#, + vec![If { + resource: "http://www.example.com/specs/".into(), + list: vec![Condition::StateToken { + is_not: false, + token: "urn:uuid:181d4fae-7d8c-11d0-a765-00a0c91e6bf2", + }], + }], + ), + ( + r#" (["4217"])"#, + vec![If { + resource: "/specs/rfc2518.doc".into(), + list: vec![Condition::ETag { + is_not: false, + tag: "\"4217\"", + }], + }], + ), + ( + r#" (Not ["4217"])"#, + vec![If { + resource: "/specs/rfc2518.doc".into(), + list: vec![Condition::ETag { + is_not: true, + tag: "\"4217\"", + }], + }], + ), + ( + r#" (["1234"]) (Not ["4217"])"#, + vec![ + If { + resource: "/test/file.txt".into(), + list: vec![Condition::ETag { + is_not: false, + tag: "\"1234\"", + }], + }, + If { + resource: "/specs/rfc2518.doc".into(), + list: vec![Condition::ETag { + is_not: true, + tag: "\"4217\"", + }], + }, + ], + ), + ] { + assert!(headers.parse("If", input)); + assert_eq!(headers.if_, expected, "Failed for input: {}", input); + headers.if_.clear(); + } + + assert!(headers.parse("If-Match", "*")); + assert_eq!( + headers.if_, + vec![If { + resource: None, + list: vec![Condition::Exists { is_not: false }], + }] + ); + headers.if_.clear(); + + assert!(headers.parse("If-None-Match", "etag1, etag2")); + assert_eq!( + headers.if_, + vec![If { + resource: None, + list: vec![ + Condition::ETag { + is_not: true, + tag: "etag1", + }, + Condition::ETag { + is_not: true, + tag: "etag2", + } + ], + },] + ); + + assert!(headers.parse("Timeout", "Second-10")); + assert_eq!(headers.timeout, Timeout::Second(10)); + + assert!(headers.parse("Timeout", "Infinite, Second-4100000000")); + assert_eq!(headers.timeout, Timeout::Infinite); + + assert!(headers.parse("Overwrite", "F")); + assert!(headers.overwrite_fail); + } +} diff --git a/crates/dav-proto/src/parser/mod.rs b/crates/dav-proto/src/parser/mod.rs new file mode 100644 index 0000000..150d413 --- /dev/null +++ b/crates/dav-proto/src/parser/mod.rs @@ -0,0 +1,210 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, +}; + +use quick_xml::events::BytesStart; +use tokenizer::Tokenizer; + +use crate::schema::{Element, NamedElement, Namespace}; + +pub mod header; +pub mod property; +pub mod tokenizer; + +#[derive(Debug, Clone)] +pub enum Error { + Xml(Box), + UnexpectedToken(Box), +} + +#[derive(Debug, Clone)] +pub struct UnexpectedToken { + pub expected: Option>, + pub found: Token<'static>, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +pub enum Token<'x> { + ElementStart { + name: NamedElement, + raw: RawElement<'x>, + }, + ElementEnd, + Bytes(Cow<'x, [u8]>), + Text(Cow<'x, str>), + UnknownElement(RawElement<'x>), + Eof, +} + +#[derive(Debug, Clone)] +pub struct RawElement<'x> { + pub element: BytesStart<'x>, + pub namespace: Option>, +} + +pub trait DavParser: Sized { + fn parse(stream: &mut Tokenizer<'_>) -> Result; +} + +pub trait XmlValueParser: Sized { + fn parse_bytes(bytes: &[u8]) -> Option; + fn parse_str(text: &str) -> Option; +} + +impl NamedElement { + pub fn dav(element: Element) -> NamedElement { + NamedElement { + ns: Namespace::Dav, + element, + } + } + + pub fn caldav(element: Element) -> NamedElement { + NamedElement { + ns: Namespace::CalDav, + element, + } + } + + pub fn carddav(element: Element) -> NamedElement { + NamedElement { + ns: Namespace::CardDav, + element, + } + } + + pub fn calendarserver(element: Element) -> NamedElement { + NamedElement { + ns: Namespace::CalendarServer, + element, + } + } +} + +impl Token<'_> { + pub fn into_owned(self) -> Token<'static> { + match self { + Token::ElementStart { name, raw } => Token::ElementStart { + name, + raw: raw.into_owned(), + }, + Token::ElementEnd => Token::ElementEnd, + Token::Bytes(bytes) => Token::Bytes(bytes.into_owned().into()), + Token::Text(text) => Token::Text(text.into_owned().into()), + Token::UnknownElement(raw) => Token::UnknownElement(raw.into_owned()), + Token::Eof => Token::Eof, + } + } + + pub fn into_unexpected(self) -> Error { + Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: None, + found: self.into_owned(), + })) + } +} + +impl<'x> RawElement<'x> { + pub fn new(element: BytesStart<'x>) -> Self { + RawElement { + element, + namespace: None, + } + } + + pub fn with_namespace(self, namespace: quick_xml::name::Namespace<'_>) -> Self { + RawElement { + element: self.element, + namespace: Some(Cow::Owned(namespace.into_inner().to_vec())), + } + } + + pub fn with_namespace_static(self, namespace: &'static [u8]) -> Self { + RawElement { + element: self.element, + namespace: Some(Cow::Borrowed(namespace)), + } + } + + pub fn into_owned(self) -> RawElement<'static> { + RawElement { + element: self.element.into_owned(), + namespace: self.namespace, + } + } +} + +#[cfg(test)] +impl PartialEq for Token<'_> { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + Self::ElementStart { + name: l_name, + raw: l_raw, + }, + Self::ElementStart { + name: r_name, + raw: r_raw, + }, + ) => { + l_name == r_name + && l_raw + .element + .attributes_raw() + .trim_ascii() + .eq_ignore_ascii_case(r_raw.element.attributes_raw().trim_ascii()) + } + (Self::Bytes(l0), Self::Bytes(r0)) => l0 == r0, + (Self::Text(l0), Self::Text(r0)) => l0 == r0, + (Self::UnknownElement(l0), Self::UnknownElement(r0)) => { + let l0: &[u8] = l0.element.as_ref(); + let r0: &[u8] = r0.element.as_ref(); + l0.eq_ignore_ascii_case(r0) + } + _ => core::mem::discriminant(self) == core::mem::discriminant(other), + } + } +} + +impl NamedElement { + pub fn into_unexpected(self) -> Error { + Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: None, + found: Token::ElementStart { + name: self, + raw: RawElement::new(BytesStart::new("")), + }, + })) + } +} + +impl Default for RawElement<'_> { + fn default() -> Self { + RawElement::new(BytesStart::new("")) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::Xml(err) => write!(f, "XML error: {}", err), + Error::UnexpectedToken(err) => { + write!(f, "Unexpected token: {:?}", err.found)?; + if let Some(expected) = &err.expected { + write!(f, ", expected: {expected:?}")?; + } + Ok(()) + } + } + } +} diff --git a/crates/dav-proto/src/parser/property.rs b/crates/dav-proto/src/parser/property.rs new file mode 100644 index 0000000..5994646 --- /dev/null +++ b/crates/dav-proto/src/parser/property.rs @@ -0,0 +1,741 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{DavParser, RawElement, Token, XmlValueParser, tokenizer::Tokenizer}; +use crate::schema::{ + Attribute, AttributeValue, Element, NamedElement, Namespace, + property::{ + CalDavProperty, CalDavPropertyName, CalendarData, CardDavProperty, CardDavPropertyName, + Comp, DavProperty, DavValue, PrincipalProperty, ResourceType, WebDavProperty, + }, + request::{DavPropertyValue, VCardPropertyWithGroup}, + response::List, +}; +use calcard::{ + Entry, Parser, + common::{IanaParse, PartialDateTime}, + icalendar::{ICalendar, ICalendarComponentType, ICalendarParameterName, ICalendarProperty}, + vcard::{VCardParameterName, VCardProperty, VCardVersion}, +}; +use mail_parser::DateTime; +use types::{TimeRange, dead_property::DeadProperty}; + +impl Tokenizer<'_> { + pub(crate) fn collect_properties( + &mut self, + mut elements: Vec, + ) -> crate::parser::Result> { + loop { + match self.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::CalendarData, + }, + .. + } => { + elements.push(DavProperty::CalDav(CalDavProperty::CalendarData( + self.collect_calendar_data()?, + ))); + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CardDav, + element: Element::AddressData, + }, + raw, + } => { + let mut version = None; + for attribute in raw.attributes::() { + if let Attribute::Version(value) = attribute? { + version = VCardVersion::try_parse(value.trim().trim_matches('"')); + } + } + elements.push(DavProperty::CardDav(CardDavProperty::AddressData { + properties: self.collect_address_data()?, + version, + })); + } + Token::ElementStart { name, .. } => { + if let Some(property) = DavProperty::from_element(name) { + elements.push(property); + } + self.expect_element_end()?; + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(name) => { + elements.push(DavProperty::DeadProperty((&name).into())); + self.expect_element_end()?; + } + token => return Err(token.into_unexpected()), + } + } + + Ok(elements) + } + + pub(crate) fn collect_calendar_data(&mut self) -> crate::parser::Result { + let mut depth = 1; + let mut data = CalendarData { + properties: Vec::with_capacity(4), + expand: None, + limit_recurrence: None, + limit_freebusy: None, + }; + let mut components: Vec = Vec::new(); + + loop { + match self.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Allcomp, + }, + .. + } => { + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Allprop, + }, + .. + } => { + if let Some(component) = components.last().cloned() { + data.properties.push(CalDavPropertyName { + component: Some(component), + name: None, + no_value: false, + }); + } + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Comp, + }, + raw, + } => { + depth += 1; + + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + components.push(name); + } + } + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Prop, + }, + raw, + } => { + let mut name = None; + let mut no_value = false; + + for attribute in raw.attributes::() { + match attribute? { + Attribute::Name(name_) => { + name = Some(name_); + } + Attribute::NoValue(no_value_) => { + no_value = no_value_; + } + _ => {} + } + } + + if let Some(name) = name { + data.properties.push(CalDavPropertyName { + component: components.last().cloned(), + name: Some(name), + no_value, + }); + } + + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Expand, + }, + raw, + } => { + data.expand = TimeRange::from_raw(&raw)?; + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::LimitRecurrenceSet, + }, + raw, + } => { + data.limit_recurrence = TimeRange::from_raw(&raw)?; + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::LimitFreebusySet, + }, + raw, + } => { + data.limit_freebusy = TimeRange::from_raw(&raw)?; + self.expect_element_end()?; + } + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + if let Some(last_component) = components.pop() + && last_component != ICalendarComponentType::VCalendar + && !matches!(data.properties.last(), Some(CalDavPropertyName { component: Some(component), .. }) if component == &last_component) + { + data.properties.push(CalDavPropertyName { + component: Some(last_component), + name: None, + no_value: false, + }); + } + } + Token::Eof => { + break; + } + token => return Err(token.into_unexpected()), + } + } + + Ok(data) + } + + pub(crate) fn collect_address_data( + &mut self, + ) -> crate::parser::Result> { + let mut items = Vec::with_capacity(4); + loop { + match self.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CardDav, + element: Element::Allprop, + }, + .. + } => { + self.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }, + raw, + } => { + let mut name = None; + let mut group = None; + let mut no_value = false; + + for attribute in raw.attributes::() { + match attribute? { + Attribute::Name(name_) => { + name = Some(name_.name); + group = name_.group; + } + Attribute::NoValue(no_value_) => { + no_value = no_value_; + } + _ => {} + } + } + + if let Some(name) = name { + items.push(CardDavPropertyName { + name, + group, + no_value, + }); + } + + self.expect_element_end()?; + } + Token::ElementEnd | Token::Eof => { + break; + } + token => return Err(token.into_unexpected()), + } + } + + Ok(items) + } +} + +impl Tokenizer<'_> { + pub(crate) fn collect_property_values( + &mut self, + elements: &mut Vec, + ) -> crate::parser::Result<()> { + loop { + match self.token()? { + Token::ElementStart { name, .. } => { + if let Some(property) = DavProperty::from_element(name) { + let value = match property { + DavProperty::WebDav(WebDavProperty::ResourceType) => { + DavValue::ResourceTypes(List(self.collect_elements()?)) + } + DavProperty::WebDav(WebDavProperty::CreationDate) => { + match self.parse_value::()? { + Some(Ok(value)) => DavValue::Timestamp(value.to_timestamp()), + Some(Err(value)) => DavValue::String(value), + None => DavValue::Null, + } + } + DavProperty::CalDav(CalDavProperty::CalendarTimezone) => { + match self + .collect_string_value()? + .map(|v| ICalendar::parse(&v).map_err(|_| v)) + { + Some(Ok(value)) => DavValue::ICalendar(value), + Some(Err(value)) => DavValue::String(value), + None => DavValue::Null, + } + } + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet) => { + let mut components = Vec::new(); + + loop { + match self.token()? { + Token::ElementStart { name, raw } => { + if name.ns == Namespace::CalDav + && name.element == Element::Comp + { + for component in + raw.attributes::() + { + if let Attribute::Name(name) = component? { + components.push(Comp(name)); + } + } + } + self.seek_element_end()?; + } + Token::UnknownElement(_) => { + // Ignore unknown elements + self.seek_element_end()?; + } + Token::ElementEnd | Token::Eof => { + break; + } + _ => {} + } + } + + DavValue::Components(List(components)) + } + DavProperty::CalDav( + CalDavProperty::MaxInstances + | CalDavProperty::MaxAttendeesPerInstance, + ) => match self.parse_value()? { + Some(Ok(value)) => DavValue::Uint64(value), + Some(Err(value)) => DavValue::String(value), + None => DavValue::Null, + }, + _ => self + .collect_string_value()? + .map(DavValue::String) + .unwrap_or(DavValue::Null), + }; + + elements.push(DavPropertyValue { property, value }); + } else { + // Ignore unknown elements + self.seek_element_end()?; + } + } + Token::ElementEnd | Token::Eof => { + break; + } + Token::UnknownElement(raw) => { + elements.push(DavPropertyValue { + property: DavProperty::DeadProperty((&raw).into()), + value: DavValue::DeadProperty(DeadProperty::parse(self)?), + }); + } + token => return Err(token.into_unexpected()), + } + } + + Ok(()) + } +} + +pub(crate) trait TimeRangeFromRaw { + fn from_raw(raw: &RawElement<'_>) -> super::Result>; +} + +impl TimeRangeFromRaw for TimeRange { + fn from_raw(raw: &RawElement<'_>) -> super::Result> { + let mut range = TimeRange { + start: i64::MIN, + end: i64::MAX, + }; + + for attribute in raw.attributes::() { + match attribute? { + Attribute::Start(start) => { + range.start = start.0; + } + Attribute::End(end) => { + range.end = end.0; + } + _ => {} + } + } + + if range.end < range.start { + range.end = i64::MAX; + } + + if range.start != i64::MIN || range.end != i64::MAX { + Ok(Some(range)) + } else { + Ok(None) + } + } +} + +impl DavProperty { + pub(crate) fn from_element(element: NamedElement) -> Option { + match (element.ns, element.element) { + (Namespace::Dav, Element::Creationdate) => { + Some(DavProperty::WebDav(WebDavProperty::CreationDate)) + } + (Namespace::Dav, Element::Displayname) => { + Some(DavProperty::WebDav(WebDavProperty::DisplayName)) + } + (Namespace::Dav, Element::Getcontentlanguage) => { + Some(DavProperty::WebDav(WebDavProperty::GetContentLanguage)) + } + (Namespace::Dav, Element::Getcontentlength) => { + Some(DavProperty::WebDav(WebDavProperty::GetContentLength)) + } + (Namespace::Dav, Element::Getcontenttype) => { + Some(DavProperty::WebDav(WebDavProperty::GetContentType)) + } + (Namespace::Dav, Element::Getetag) => { + Some(DavProperty::WebDav(WebDavProperty::GetETag)) + } + (Namespace::Dav, Element::Getlastmodified) => { + Some(DavProperty::WebDav(WebDavProperty::GetLastModified)) + } + (Namespace::Dav, Element::Resourcetype) => { + Some(DavProperty::WebDav(WebDavProperty::ResourceType)) + } + (Namespace::Dav, Element::Lockdiscovery) => { + Some(DavProperty::WebDav(WebDavProperty::LockDiscovery)) + } + (Namespace::Dav, Element::Supportedlock) => { + Some(DavProperty::WebDav(WebDavProperty::SupportedLock)) + } + (Namespace::Dav, Element::CurrentUserPrincipal) => { + Some(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + } + (Namespace::Dav, Element::QuotaAvailableBytes) => { + Some(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + } + (Namespace::Dav, Element::QuotaUsedBytes) => { + Some(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes)) + } + (Namespace::Dav, Element::SupportedReportSet) => { + Some(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + } + (Namespace::Dav, Element::SyncToken) => { + Some(DavProperty::WebDav(WebDavProperty::SyncToken)) + } + (Namespace::Dav, Element::AlternateUriSet) => { + Some(DavProperty::Principal(PrincipalProperty::AlternateURISet)) + } + (Namespace::Dav, Element::PrincipalUrl) => { + Some(DavProperty::Principal(PrincipalProperty::PrincipalURL)) + } + (Namespace::Dav, Element::GroupMemberSet) => { + Some(DavProperty::Principal(PrincipalProperty::GroupMemberSet)) + } + (Namespace::Dav, Element::GroupMembership) => { + Some(DavProperty::Principal(PrincipalProperty::GroupMembership)) + } + (Namespace::Dav, Element::Owner) => Some(DavProperty::WebDav(WebDavProperty::Owner)), + (Namespace::Dav, Element::Group) => Some(DavProperty::WebDav(WebDavProperty::Group)), + (Namespace::Dav, Element::SupportedPrivilegeSet) => { + Some(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + } + (Namespace::Dav, Element::CurrentUserPrivilegeSet) => { + Some(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + } + (Namespace::Dav, Element::Acl) => Some(DavProperty::WebDav(WebDavProperty::Acl)), + (Namespace::Dav, Element::AclRestrictions) => { + Some(DavProperty::WebDav(WebDavProperty::AclRestrictions)) + } + (Namespace::Dav, Element::InheritedAclSet) => { + Some(DavProperty::WebDav(WebDavProperty::InheritedAclSet)) + } + (Namespace::Dav, Element::PrincipalCollectionSet) => { + Some(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + } + (Namespace::CardDav, Element::AddressbookDescription) => Some(DavProperty::CardDav( + CardDavProperty::AddressbookDescription, + )), + (Namespace::CardDav, Element::SupportedAddressData) => { + Some(DavProperty::CardDav(CardDavProperty::SupportedAddressData)) + } + (Namespace::CardDav, Element::SupportedCollationSet) => { + Some(DavProperty::CardDav(CardDavProperty::SupportedCollationSet)) + } + (Namespace::CardDav, Element::AddressbookHomeSet) => Some(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )), + (Namespace::CardDav, Element::PrincipalAddress) => { + Some(DavProperty::Principal(PrincipalProperty::PrincipalAddress)) + } + (Namespace::CardDav, Element::AddressData) => { + Some(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + } + (Namespace::CardDav, Element::MaxResourceSize) => { + Some(DavProperty::CardDav(CardDavProperty::MaxResourceSize)) + } + (Namespace::CalDav, Element::CalendarDescription) => { + Some(DavProperty::CalDav(CalDavProperty::CalendarDescription)) + } + (Namespace::CalDav, Element::CalendarTimezone) => { + Some(DavProperty::CalDav(CalDavProperty::CalendarTimezone)) + } + (Namespace::CalDav, Element::SupportedCalendarComponentSet) => Some( + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet), + ), + (Namespace::CalDav, Element::SupportedCollationSet) => { + Some(DavProperty::CalDav(CalDavProperty::SupportedCollationSet)) + } + (Namespace::CalDav, Element::SupportedCalendarData) => { + Some(DavProperty::CalDav(CalDavProperty::SupportedCalendarData)) + } + (Namespace::CalDav, Element::MaxResourceSize) => { + Some(DavProperty::CalDav(CalDavProperty::MaxResourceSize)) + } + (Namespace::CalDav, Element::MinDateTime) => { + Some(DavProperty::CalDav(CalDavProperty::MinDateTime)) + } + (Namespace::CalDav, Element::MaxDateTime) => { + Some(DavProperty::CalDav(CalDavProperty::MaxDateTime)) + } + (Namespace::CalDav, Element::MaxInstances) => { + Some(DavProperty::CalDav(CalDavProperty::MaxInstances)) + } + (Namespace::CalDav, Element::MaxAttendeesPerInstance) => { + Some(DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance)) + } + (Namespace::CalDav, Element::ScheduleDefaultCalendarUrl) => Some(DavProperty::CalDav( + CalDavProperty::ScheduleDefaultCalendarURL, + )), + (Namespace::CalDav, Element::ScheduleTag) => { + Some(DavProperty::CalDav(CalDavProperty::ScheduleTag)) + } + (Namespace::CalDav, Element::ScheduleCalendarTransp) => { + Some(DavProperty::CalDav(CalDavProperty::ScheduleCalendarTransp)) + } + (Namespace::CalDav, Element::CalendarHomeSet) => { + Some(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + } + (Namespace::CalDav, Element::CalendarUserAddressSet) => Some(DavProperty::Principal( + PrincipalProperty::CalendarUserAddressSet, + )), + (Namespace::CalDav, Element::CalendarUserType) => { + Some(DavProperty::Principal(PrincipalProperty::CalendarUserType)) + } + (Namespace::CalDav, Element::ScheduleInboxUrl) => { + Some(DavProperty::Principal(PrincipalProperty::ScheduleInboxURL)) + } + (Namespace::CalDav, Element::ScheduleOutboxUrl) => { + Some(DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL)) + } + (Namespace::CalDav, Element::CalendarData) => Some(DavProperty::CalDav( + CalDavProperty::CalendarData(Default::default()), + )), + (Namespace::CalDav, Element::TimezoneServiceSet) => { + Some(DavProperty::CalDav(CalDavProperty::TimezoneServiceSet)) + } + (Namespace::CalDav, Element::CalendarTimezoneId) => { + Some(DavProperty::CalDav(CalDavProperty::TimezoneId)) + } + (Namespace::CalendarServer, Element::Getctag) => { + Some(DavProperty::WebDav(WebDavProperty::GetCTag)) + } + _ => None, + } + } +} + +impl TryFrom for ResourceType { + type Error = (); + + fn try_from(value: NamedElement) -> Result { + match (value.ns, value.element) { + (Namespace::Dav, Element::Collection) => Ok(ResourceType::Collection), + (Namespace::Dav, Element::Principal) => Ok(ResourceType::Principal), + (Namespace::CardDav, Element::Addressbook) => Ok(ResourceType::AddressBook), + (Namespace::CalDav, Element::Calendar) => Ok(ResourceType::Calendar), + (Namespace::CalDav, Element::ScheduleInbox) => Ok(ResourceType::ScheduleInbox), + (Namespace::CalDav, Element::ScheduleOutbox) => Ok(ResourceType::ScheduleOutbox), + _ => Err(()), + } + } +} + +struct ICalendarDateTime(i64); + +impl AttributeValue for ICalendarDateTime { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + let mut dt = PartialDateTime::default(); + dt.parse_timestamp(&mut s.as_bytes().iter().peekable(), true); + dt.to_timestamp().map(ICalendarDateTime) + } +} + +impl AttributeValue for ICalendarComponentType { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + ICalendarComponentType::parse(s.as_bytes()) + } +} + +impl AttributeValue for ICalendarProperty { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + ICalendarProperty::parse(s.as_bytes()) + .unwrap_or_else(|| ICalendarProperty::Other(s.to_string())) + .into() + } +} + +impl AttributeValue for ICalendarParameterName { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + ICalendarParameterName::parse(s).into() + } +} + +impl AttributeValue for VCardPropertyWithGroup { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + if let Some((group, s)) = s.split_once('.') { + VCardPropertyWithGroup { + name: VCardProperty::parse(s.as_bytes()) + .unwrap_or_else(|| VCardProperty::Other(s.to_string())), + group: group.to_string().into(), + } + .into() + } else { + VCardPropertyWithGroup { + name: VCardProperty::parse(s.as_bytes()) + .unwrap_or_else(|| VCardProperty::Other(s.to_string())), + group: None, + } + .into() + } + } +} + +impl AttributeValue for VCardParameterName { + fn from_str(s: &str) -> Option + where + Self: Sized, + { + VCardParameterName::parse(s).into() + } +} + +impl XmlValueParser for ICalendar { + fn parse_bytes(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + let mut parser = Parser::new(&text); + if let Entry::ICalendar(ical) = parser.entry() { + Some(ical) + } else { + None + } + } + + fn parse_str(text: &str) -> Option { + let mut parser = Parser::new(text); + if let Entry::ICalendar(ical) = parser.entry() { + Some(ical) + } else { + None + } + } +} + +impl XmlValueParser for u64 { + fn parse_bytes(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()) + } + + fn parse_str(text: &str) -> Option { + text.parse().ok() + } +} + +impl XmlValueParser for u32 { + fn parse_bytes(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()) + } + + fn parse_str(text: &str) -> Option { + text.parse().ok() + } +} + +impl XmlValueParser for DateTime { + fn parse_bytes(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .ok() + .and_then(DateTime::parse_rfc3339) + } + + fn parse_str(text: &str) -> Option { + DateTime::parse_rfc3339(text) + } +} diff --git a/crates/dav-proto/src/parser/tokenizer.rs b/crates/dav-proto/src/parser/tokenizer.rs new file mode 100644 index 0000000..7287087 --- /dev/null +++ b/crates/dav-proto/src/parser/tokenizer.rs @@ -0,0 +1,503 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Error, RawElement, Token, UnexpectedToken, XmlValueParser}; +use crate::schema::{Attribute, AttributeValue, Element, NamedElement, Namespace}; +use quick_xml::{ + NsReader, XmlVersion, + events::{Event, attributes::AttrError}, + name::ResolveResult, +}; + +pub struct Tokenizer<'x> { + xml: NsReader<&'x [u8]>, + last_is_end: bool, +} + +impl<'x> Tokenizer<'x> { + pub fn new(input: &'x [u8]) -> Self { + let mut xml = NsReader::from_reader(input); + xml.config_mut(); + Self { + xml, + last_is_end: false, + } + } + + pub fn token(&'_ mut self) -> super::Result> { + loop { + if self.last_is_end { + self.last_is_end = false; + return Ok(Token::ElementEnd); + } + + let (resolve_result, event) = self.xml.read_resolved_event()?; + let tag = match event { + Event::Start(tag) => tag, + Event::Empty(tag) => { + self.last_is_end = true; + tag + } + Event::End(_) => { + return Ok(Token::ElementEnd); + } + Event::Text(text) if text.iter().any(|ch| !ch.is_ascii_whitespace()) => { + return text + .xml_content(XmlVersion::Implicit1_0) + .map(Token::Text) + .map_err(|err| Error::Xml(Box::new(err.into()))); + } + Event::GeneralRef(entity) => { + let entity_ref: &[u8] = entity.as_ref(); + hashify::fnc_map!(entity_ref, + b"lt" => { return Ok(Token::Text("<".into())); }, + b"gt" => { return Ok(Token::Text(">".into())); }, + b"amp" => { return Ok(Token::Text("&".into())); }, + b"apos" => { return Ok(Token::Text("'".into())); }, + b"quot" => { return Ok(Token::Text("\"".into())); }, + _ => { + if let Ok(Some(gr)) = entity.resolve_char_ref() { + return Ok(Token::Text(gr.to_string().into())); + } + } + ); + + return entity + .xml_content(XmlVersion::Implicit1_0) + .map(Token::Text) + .map_err(|err| Error::Xml(Box::new(err.into()))); + } + Event::CData(bytes) => return Ok(Token::Bytes(bytes.into_inner())), + Event::Eof => return Ok(Token::Eof), + _ => { + continue; + } + }; + + // Parse element + let name = tag.name(); + match resolve_result { + ResolveResult::Bound(raw_ns) if !raw_ns.as_ref().is_empty() => { + if let (Some(ns), Some(element)) = ( + Namespace::try_parse(raw_ns.as_ref()), + Element::try_parse(name.local_name().as_ref()).copied(), + ) { + return Ok(Token::ElementStart { + name: NamedElement { ns, element }, + raw: RawElement::new(tag) + .with_namespace_static(ns.namespace().as_bytes()), + }); + } else { + return Ok(Token::UnknownElement( + RawElement::new(tag).with_namespace(raw_ns), + )); + } + } + ResolveResult::Unknown(p) => { + return Err(Error::Xml(Box::new(quick_xml::Error::Namespace( + quick_xml::name::NamespaceError::UnknownPrefix(p), + )))); + } + _ => { + return Ok(Token::UnknownElement(RawElement::new(tag))); + } + } + } + } + + pub fn unwrap_named_element(&mut self) -> super::Result { + match self.token()? { + Token::ElementStart { name, .. } => Ok(name), + found => Err(Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: None, + found: found.into_owned(), + }))), + } + } + + pub fn expect_named_element(&mut self, expected: NamedElement) -> super::Result<()> { + match self.token()? { + Token::ElementStart { name, .. } if name == expected => Ok(()), + found => Err(Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: Token::ElementStart { + name: expected, + raw: RawElement::default(), + } + .into(), + found: found.into_owned(), + }))), + } + } + + pub fn expect_named_element_or_eof(&mut self, expected: NamedElement) -> super::Result { + match self.token()? { + Token::ElementStart { name, .. } if name == expected => Ok(true), + Token::Eof => Ok(false), + found => Err(Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: Token::ElementStart { + name: expected, + raw: RawElement::default(), + } + .into(), + found: found.into_owned(), + }))), + } + } + + pub fn expect_element_end(&mut self) -> super::Result<()> { + match self.token()? { + Token::ElementEnd => Ok(()), + found => Err(Error::UnexpectedToken(Box::new(UnexpectedToken { + expected: Token::ElementEnd.into(), + found: found.into_owned(), + }))), + } + } + + pub fn seek_element_end(&mut self) -> super::Result<()> { + let mut depth = 1; + loop { + match self.token()? { + Token::ElementStart { .. } | Token::UnknownElement(_) => depth += 1, + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + return Ok(()); + } + } + Token::Eof => return Err(Token::Eof.into_unexpected()), + _ => {} + } + } + } + + pub fn collect_string_value(&mut self) -> super::Result> { + let mut depth = 1; + let mut value: Option = None; + + loop { + match self.token()? { + Token::ElementStart { .. } | Token::UnknownElement(_) => depth += 1, + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::Text(text) => { + if let Some(ref mut v) = value { + v.push_str(&text); + } else { + value = Some(text.into_owned()); + } + } + Token::Bytes(bytes) => { + if let Some(ref mut v) = value { + v.push_str(&String::from_utf8_lossy(&bytes)); + } else { + value = Some(String::from_utf8_lossy(&bytes).into_owned()); + } + } + Token::Eof => return Err(Token::Eof.into_unexpected()), + } + } + + Ok(value) + } + + pub fn parse_value(&mut self) -> super::Result>> { + let mut depth = 1; + let mut result: Option> = None; + + loop { + match self.token()? { + Token::ElementStart { .. } | Token::UnknownElement(_) => depth += 1, + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::Text(text) => { + if let Some(value) = T::parse_str(&text) { + result = Some(Ok(value)); + } else { + result = Some(Err(text.into_owned())); + } + } + Token::Bytes(bytes) => { + if let Some(value) = T::parse_bytes(&bytes) { + result = Some(Ok(value)); + } else { + result = Some(Err(String::from_utf8_lossy(&bytes).into_owned())); + } + } + Token::Eof => return Err(Token::Eof.into_unexpected()), + } + } + + Ok(result) + } + + pub fn collect_elements(&mut self) -> super::Result> + where + T: TryFrom, + { + let mut elements = Vec::with_capacity(2); + let mut depth = 1; + + loop { + match self.token()? { + Token::ElementStart { name, .. } => { + if depth == 1 + && let Ok(element) = T::try_from(name) + { + elements.push(element); + } + + depth += 1; + } + Token::UnknownElement(_) => { + depth += 1; + } + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::Eof => break, + _ => {} + } + } + Ok(elements) + } +} + +impl RawElement<'_> { + pub fn attributes( + &self, + ) -> impl Iterator>> + '_ { + self.element.attributes().filter_map(|attr| match attr { + Ok(attr) => match attr.normalized_value(XmlVersion::Implicit1_0) { + Ok(value) => Attribute::from_param(attr.key.as_ref(), value).map(Ok), + Err(err) => Some(Err(err.into())), + }, + Err(err) => Some(Err(err.into())), + }) + } +} + +impl From for Error { + fn from(err: quick_xml::Error) -> Self { + Error::Xml(Box::new(err)) + } +} + +impl From for Error { + fn from(err: AttrError) -> Self { + Error::Xml(Box::new(err.into())) + } +} + +#[cfg(test)] +mod tests { + + use std::borrow::Cow; + + use crate::schema::{Collation, MatchType}; + + use super::*; + + #[derive(Debug, PartialEq, Eq)] + pub enum TestToken<'x> { + ElementStart(NamedElement), + ElementEnd, + Attribute(Attribute), + Bytes(Cow<'x, [u8]>), + Text(Cow<'x, str>), + } + + #[test] + fn test_tokenizer() { + for (input, expected) in [ + ( + r#" + + + + + + + + + "#, + vec![ + TestToken::ElementStart(NamedElement { + ns: Namespace::CalDav, + element: Element::CalendarQuery, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::Dav, + element: Element::Getetag, + }), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CalDav, + element: Element::CalendarData, + }), + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CalDav, + element: Element::Filter, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::CalDav, + element: Element::CompFilter, + }), + TestToken::Attribute(Attribute::Name("VCALENDAR".to_string())), + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementEnd, + ], + ), + ( + r#" + + + + + + + + + + + + + + me + + + "#, + vec![ + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::AddressbookQuery, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::Dav, + element: Element::Getetag, + }), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::AddressData, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }), + TestToken::Attribute(Attribute::Name("VERSION".to_string())), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }), + TestToken::Attribute(Attribute::Name("UID".to_string())), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }), + TestToken::Attribute(Attribute::Name("NICKNAME".to_string())), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }), + TestToken::Attribute(Attribute::Name("EMAIL".to_string())), + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Prop, + }), + TestToken::Attribute(Attribute::Name("FN".to_string())), + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::Filter, + }), + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::PropFilter, + }), + TestToken::Attribute(Attribute::Name("NICKNAME".to_string())), + TestToken::ElementStart(NamedElement { + ns: Namespace::CardDav, + element: Element::TextMatch, + }), + TestToken::Attribute(Attribute::Collation(Collation::UnicodeCasemap)), + TestToken::Attribute(Attribute::MatchType(MatchType::Equals)), + TestToken::Text("me".into()), + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementEnd, + TestToken::ElementEnd, + ], + ), + ] { + let mut tokenizer = Tokenizer::new(input.as_bytes()); + let mut result = vec![]; + + loop { + match tokenizer.token() { + Ok(token) => match token { + Token::ElementStart { name, raw } => { + result.push(TestToken::ElementStart(name)); + for attr in raw.attributes::() { + result.push(TestToken::Attribute(attr.unwrap())); + } + } + Token::ElementEnd => { + result.push(TestToken::ElementEnd); + } + Token::Bytes(cow) => { + result.push(TestToken::Bytes(cow.into_owned().into())); + } + Token::Text(cow) => { + result.push(TestToken::Text(cow.into_owned().into())); + } + Token::UnknownElement(_) => { + //result.push(TestToken::UnknownElement(unknown_element)); + } + Token::Eof => break, + }, + Err(err) => { + panic!("Error: {:?}", err); + } + } + } + + assert_eq!(result, expected); + } + } +} diff --git a/crates/dav-proto/src/requests/acl.rs b/crates/dav-proto/src/requests/acl.rs new file mode 100644 index 0000000..c47d384 --- /dev/null +++ b/crates/dav-proto/src/requests/acl.rs @@ -0,0 +1,469 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, Token, tokenizer::Tokenizer}, + schema::{ + Element, NamedElement, Namespace, + property::{DavValue, Privilege}, + request::{ + Acl, AclPrincipalPropSet, DavPropertyValue, PrincipalMatch, PrincipalMatchProperties, + PrincipalPropertySearch, PropertySearch, + }, + response::{Ace, GrantDeny, Href, List, Principal}, + }, +}; + +impl DavParser for Acl { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + stream.expect_named_element(NamedElement::dav(Element::Acl))?; + + let mut acl = Acl { aces: vec![] }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Ace, + }, + .. + } => { + acl.aces.push(Ace::parse(stream)?); + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(acl) + } +} + +impl DavParser for Ace { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut ace = Ace { + principal: Principal::All, + invert: false, + grant_deny: GrantDeny::Grant(List(vec![])), + protected: false, + inherited: None, + }; + let mut depth = 1; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Principal, + }, + .. + } => { + ace.principal = Principal::parse(stream)?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Invert, + }, + .. + } if depth == 1 => { + ace.invert = true; + depth += 1; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Protected, + }, + .. + } if depth == 1 => { + ace.protected = true; + stream.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Inherited, + }, + .. + } if depth == 1 => { + stream.expect_named_element(NamedElement::dav(Element::Href))?; + ace.inherited = stream.collect_string_value()?.map(Href); + stream.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Grant, + }, + .. + } if depth == 1 => { + ace.grant_deny = GrantDeny::Grant(List(stream.collect_privileges()?)); + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Deny, + }, + .. + } if depth == 1 => { + ace.grant_deny = GrantDeny::Deny(List(stream.collect_privileges()?)); + } + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(ace) + } +} + +impl DavParser for Principal { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let result = match stream.unwrap_named_element()? { + NamedElement { + ns: Namespace::Dav, + element: Element::Href, + } => Principal::Href(Href(stream.collect_string_value()?.unwrap_or_default())), + NamedElement { + ns: Namespace::Dav, + element: Element::All, + } => { + stream.expect_element_end()?; + Principal::All + } + NamedElement { + ns: Namespace::Dav, + element: Element::Authenticated, + } => { + stream.expect_element_end()?; + Principal::Authenticated + } + NamedElement { + ns: Namespace::Dav, + element: Element::Unauthenticated, + } => { + stream.expect_element_end()?; + Principal::Unauthenticated + } + NamedElement { + ns: Namespace::Dav, + element: Element::Property, + } => { + let property = stream.collect_properties(Vec::new())?; + Principal::Property(List( + property + .into_iter() + .map(|prop| DavPropertyValue::new(prop, DavValue::Null)) + .collect(), + )) + } + NamedElement { + ns: Namespace::Dav, + element: Element::Self_, + } => { + stream.expect_element_end()?; + Principal::Self_ + } + other => return Err(other.into_unexpected()), + }; + stream.expect_element_end()?; + Ok(result) + } +} + +impl Tokenizer<'_> { + pub fn collect_privileges(&mut self) -> crate::parser::Result> { + let mut privileges = Vec::new(); + let mut depth = 1; + + loop { + match self.token()? { + Token::ElementStart { name, .. } => { + if let Some(privilege) = Privilege::from_element(name) { + privileges.push(privilege); + self.expect_element_end()?; + } else { + depth += 1; + } + } + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::UnknownElement(_) => { + self.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(privileges) + } +} + +impl Privilege { + pub fn from_element(element: NamedElement) -> Option { + match (element.ns, element.element) { + (Namespace::Dav, Element::Read) => Some(Privilege::Read), + (Namespace::Dav, Element::Write) => Some(Privilege::Write), + (Namespace::Dav, Element::WriteProperties) => Some(Privilege::WriteProperties), + (Namespace::Dav, Element::WriteContent) => Some(Privilege::WriteContent), + (Namespace::Dav, Element::Unlock) => Some(Privilege::Unlock), + (Namespace::Dav, Element::ReadAcl) => Some(Privilege::ReadAcl), + (Namespace::Dav, Element::ReadCurrentUserPrivilegeSet) => { + Some(Privilege::ReadCurrentUserPrivilegeSet) + } + (Namespace::Dav, Element::WriteAcl) => Some(Privilege::WriteAcl), + (Namespace::Dav, Element::Bind) => Some(Privilege::Bind), + (Namespace::Dav, Element::Unbind) => Some(Privilege::Unbind), + (Namespace::Dav, Element::All) => Some(Privilege::All), + (Namespace::CalDav, Element::ReadFreeBusy) => Some(Privilege::ReadFreeBusy), + (Namespace::CalDav, Element::ScheduleDeliver) => Some(Privilege::ScheduleDeliver), + (Namespace::CalDav, Element::ScheduleDeliverInvite) => { + Some(Privilege::ScheduleDeliverInvite) + } + (Namespace::CalDav, Element::ScheduleDeliverReply) => { + Some(Privilege::ScheduleDeliverReply) + } + (Namespace::CalDav, Element::ScheduleQueryFreebusy) => { + Some(Privilege::ScheduleQueryFreeBusy) + } + (Namespace::CalDav, Element::ScheduleSend) => Some(Privilege::ScheduleSend), + (Namespace::CalDav, Element::ScheduleSendInvite) => Some(Privilege::ScheduleSendInvite), + (Namespace::CalDav, Element::ScheduleSendReply) => Some(Privilege::ScheduleSendReply), + (Namespace::CalDav, Element::ScheduleSendFreebusy) => { + Some(Privilege::ScheduleSendFreeBusy) + } + _ => None, + } + } +} + +impl DavParser for AclPrincipalPropSet { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut acps = AclPrincipalPropSet { properties: vec![] }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }, + .. + } => { + acps.properties = stream.collect_properties(acps.properties)?; + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(acps) + } +} + +impl DavParser for PrincipalMatch { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut pm = PrincipalMatch { + principal_properties: PrincipalMatchProperties::Self_, + properties: vec![], + }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::PrincipalProperty, + }, + .. + } => { + pm.principal_properties = PrincipalMatchProperties::Properties( + stream.collect_properties(Vec::new())?, + ); + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Self_, + }, + .. + } => { + pm.principal_properties = PrincipalMatchProperties::Self_; + stream.expect_element_end()?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }, + .. + } => { + pm.properties = stream.collect_properties(pm.properties)?; + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(pm) + } +} + +impl DavParser for PrincipalPropertySearch { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut pps = PrincipalPropertySearch { + property_search: vec![], + properties: vec![], + apply_to_principal_collection_set: false, + }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::PropertySearch, + }, + .. + } => { + if let Some(prop) = PropertySearch::parse(stream)? { + pps.property_search.push(prop); + } + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }, + .. + } => { + pps.properties = stream.collect_properties(pps.properties)?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::ApplyToPrincipalCollectionSet, + }, + .. + } => { + stream.expect_element_end()?; + pps.apply_to_principal_collection_set = true; + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(pps) + } +} + +impl PropertySearch { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result> { + let mut property = None; + let mut match_ = None; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + }, + .. + } => { + property = stream.collect_properties(Vec::new())?.into_iter().next(); + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Match, + }, + .. + } => { + match_ = stream.collect_string_value()?; + } + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + other => { + return Err(other.into_unexpected()); + } + } + } + + Ok(property.map(|property| PropertySearch { + property, + match_: match_.unwrap_or_default(), + })) + } +} diff --git a/crates/dav-proto/src/requests/lockinfo.rs b/crates/dav-proto/src/requests/lockinfo.rs new file mode 100644 index 0000000..47f6837 --- /dev/null +++ b/crates/dav-proto/src/requests/lockinfo.rs @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, Token, tokenizer::Tokenizer}, + schema::{ + Element, NamedElement, Namespace, + property::{LockScope, LockType}, + request::LockInfo, + }, +}; +use types::dead_property::DeadProperty; + +impl DavParser for LockInfo { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut lockinfo = LockInfo { + lock_scope: LockScope::Exclusive, + lock_type: LockType::Write, + owner: None, + }; + + if stream.expect_named_element_or_eof(NamedElement::dav(Element::Lockinfo))? { + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Lockscope, + }, + .. + } => { + lockinfo.lock_scope = LockScope::parse(stream)?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Locktype, + }, + .. + } => { + lockinfo.lock_type = LockType::parse(stream)?; + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Owner, + }, + .. + } => { + lockinfo.owner = Some(DeadProperty::parse(stream)?); + } + Token::ElementEnd | Token::Eof => { + break; + } + other => { + return Err(other.into_unexpected()); + } + } + } + } + + Ok(lockinfo) + } +} + +impl DavParser for LockScope { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + match stream.unwrap_named_element()? { + NamedElement { + ns: Namespace::Dav, + element: Element::Exclusive, + } => { + stream.expect_element_end()?; + stream.expect_element_end()?; + Ok(LockScope::Exclusive) + } + NamedElement { + ns: Namespace::Dav, + element: Element::Shared, + } => { + stream.expect_element_end()?; + stream.expect_element_end()?; + Ok(LockScope::Shared) + } + other => Err(other.into_unexpected()), + } + } +} + +impl DavParser for LockType { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + match stream.unwrap_named_element()? { + NamedElement { + ns: Namespace::Dav, + element: Element::Write, + } => { + stream.expect_element_end()?; + stream.expect_element_end()?; + Ok(LockType::Write) + } + other => Err(other.into_unexpected()), + } + } +} diff --git a/crates/dav-proto/src/requests/mkcol.rs b/crates/dav-proto/src/requests/mkcol.rs new file mode 100644 index 0000000..da919e8 --- /dev/null +++ b/crates/dav-proto/src/requests/mkcol.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, Token, tokenizer::Tokenizer}, + schema::{Element, NamedElement, Namespace, request::MkCol}, +}; + +impl DavParser for MkCol { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut mkcol = MkCol { + is_mkcalendar: false, + props: Vec::new(), + }; + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Mkcol, + }, + .. + } => {} + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::Mkcalendar, + }, + .. + } => { + mkcol.is_mkcalendar = true; + } + Token::Eof => { + return Ok(mkcol); + } + other => return Err(other.into_unexpected()), + }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Set, + }, + .. + } => { + stream.expect_named_element(NamedElement::dav(Element::Prop))?; + stream.collect_property_values(&mut mkcol.props)?; + stream.expect_element_end()?; + } + Token::ElementEnd | Token::Eof => { + break; + } + token => return Err(token.into_unexpected()), + } + } + + Ok(mkcol) + } +} diff --git a/crates/dav-proto/src/requests/mod.rs b/crates/dav-proto/src/requests/mod.rs new file mode 100644 index 0000000..e00526c --- /dev/null +++ b/crates/dav-proto/src/requests/mod.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, RawElement, Token, tokenizer::Tokenizer}, + schema::Namespace, +}; +use types::dead_property::{DeadElementTag, DeadProperty, DeadPropertyTag}; + +pub mod acl; +pub mod lockinfo; +pub mod mkcol; +pub mod propertyupdate; +pub mod propfind; +pub mod report; + +impl DavParser for DeadProperty { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut depth = 1; + let mut items = DeadProperty::default(); + + loop { + match stream.token()? { + Token::ElementStart { raw, .. } | Token::UnknownElement(raw) => { + items.0.push(DeadPropertyTag::ElementStart((&raw).into())); + depth += 1; + } + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + items.0.push(DeadPropertyTag::ElementEnd); + } + Token::Text(text) => { + items.0.push(DeadPropertyTag::Text(text.into_owned())); + } + Token::Bytes(bytes) => { + items.0.push(DeadPropertyTag::Text( + String::from_utf8_lossy(&bytes).into_owned(), + )); + } + Token::Eof => { + break; + } + } + } + + Ok(items) + } +} + +pub trait NsDeadProperty { + fn single_with_ns(namespace: Namespace, name: &str) -> Self; +} + +impl NsDeadProperty for DeadProperty { + fn single_with_ns(namespace: Namespace, name: &str) -> Self { + DeadProperty(vec![ + DeadPropertyTag::ElementStart(DeadElementTag { + name: format!("{}:{name}", namespace.prefix()), + attrs: None, + }), + DeadPropertyTag::ElementEnd, + ]) + } +} + +impl From<&RawElement<'_>> for DeadElementTag { + fn from(raw: &RawElement<'_>) -> Self { + let name = std::str::from_utf8(raw.element.local_name().as_ref()) + .unwrap_or("invalid-utf8") + .trim_ascii() + .to_string(); + let mut attrs = String::with_capacity(raw.element.attributes_raw().len()); + if let Some(namespace) = &raw.namespace { + attrs.push_str("xmlns=\""); + attrs.push_str(std::str::from_utf8(namespace).unwrap_or("invalid-utf8")); + attrs.push('"'); + } + + for attr in raw.element.attributes().flatten() { + if attr.key.as_ref() == b"xmlns" || attr.key.as_ref().starts_with(b"xmlns:") { + // Skip namespace attributes + continue; + } + if let (Ok(key), Ok(value)) = ( + std::str::from_utf8(attr.key.as_ref()), + std::str::from_utf8(attr.value.as_ref()), + ) { + if !attrs.is_empty() { + attrs.push(' '); + } + attrs.push_str(key); + attrs.push('='); + attrs.push('"'); + attrs.push_str(value); + attrs.push('"'); + } + } + + DeadElementTag { + name, + attrs: (!attrs.is_empty()).then_some(attrs), + } + } +} + +#[cfg(test)] +mod tests { + use calcard::vcard::VCardVersion; + + use crate::{ + parser::{DavParser, tokenizer::Tokenizer}, + schema::{ + property::{CardDavProperty, DavProperty}, + request::{Acl, LockInfo, MkCol, PropFind, PropertyUpdate, Report}, + }, + }; + + #[test] + fn parse_address_data_version() { + let xml = r#" + + + + + + + "#; + + let mut tokenizer = Tokenizer::new(xml.as_bytes()); + let report = Report::parse(&mut tokenizer).unwrap(); + let Report::AddressbookQuery(query) = report else { + panic!("expected addressbook-query, got {report:?}"); + }; + let PropFind::Prop(properties) = query.properties else { + panic!("expected prop, got {:?}", query.properties); + }; + + let version = properties.iter().find_map(|property| match property { + DavProperty::CardDav(CardDavProperty::AddressData { version, .. }) => Some(*version), + _ => None, + }); + + assert_eq!(version, Some(Some(VCardVersion::V3_0))); + } + + #[test] + fn parse_requests() { + for entry in std::fs::read_dir("resources/requests").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + + if path.extension().map(|ext| ext == "xml").unwrap_or(false) { + println!("Parsing: {:?}", path); + let filename = path.file_name().unwrap().to_str().unwrap(); + let xml = std::fs::read_to_string(&path).unwrap(); + let mut tokenizer = Tokenizer::new(xml.as_bytes()); + + let json_path = path.with_extension("json"); + let json_output = match filename.split_once('-').unwrap().0 { + "propfind" => match PropFind::parse(&mut tokenizer) { + Ok(propfind) => serde_json::to_string_pretty(&propfind).unwrap(), + Err(_) => String::new(), + }, + "propertyupdate" => serde_json::to_string_pretty( + &PropertyUpdate::parse(&mut tokenizer).unwrap(), + ) + .unwrap(), + "mkcol" => serde_json::to_string_pretty(&MkCol::parse(&mut tokenizer).unwrap()) + .unwrap(), + "lockinfo" => { + serde_json::to_string_pretty(&LockInfo::parse(&mut tokenizer).unwrap()) + .unwrap() + } + "report" => { + serde_json::to_string_pretty(&Report::parse(&mut tokenizer).unwrap()) + .unwrap() + } + "acl" => { + serde_json::to_string_pretty(&Acl::parse(&mut tokenizer).unwrap()).unwrap() + } + _ => { + panic!("Unknown method: {}", filename); + } + }; + + /*if json_path.exists() { + let expected = std::fs::read_to_string(json_path).unwrap(); + assert_eq!(json_output, expected); + } else {*/ + std::fs::write(json_path, json_output).unwrap(); + //} + } + } + } +} diff --git a/crates/dav-proto/src/requests/propertyupdate.rs b/crates/dav-proto/src/requests/propertyupdate.rs new file mode 100644 index 0000000..324b6fe --- /dev/null +++ b/crates/dav-proto/src/requests/propertyupdate.rs @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, Token, tokenizer::Tokenizer}, + schema::{Element, NamedElement, Namespace, request::PropertyUpdate}, +}; + +impl DavParser for PropertyUpdate { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + stream.expect_named_element(NamedElement::dav(Element::Propertyupdate))?; + let mut update = PropertyUpdate { + set: Vec::with_capacity(4), + remove: Vec::with_capacity(4), + set_first: true, + }; + + loop { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Set, + }, + .. + } => { + stream.expect_named_element(NamedElement::dav(Element::Prop))?; + stream.collect_property_values(&mut update.set)?; + stream.expect_element_end()?; + update.set_first = update.remove.is_empty(); + } + Token::ElementStart { + name: + NamedElement { + ns: Namespace::Dav, + element: Element::Remove, + }, + .. + } => { + stream.expect_named_element(NamedElement::dav(Element::Prop))?; + update.remove = stream.collect_properties(update.remove)?; + stream.expect_element_end()?; + } + Token::ElementEnd | Token::Eof => { + break; + } + Token::UnknownElement(_) => { + // Ignore unknown elements + stream.seek_element_end()?; + } + token => return Err(token.into_unexpected()), + } + } + + Ok(update) + } +} diff --git a/crates/dav-proto/src/requests/propfind.rs b/crates/dav-proto/src/requests/propfind.rs new file mode 100644 index 0000000..7629830 --- /dev/null +++ b/crates/dav-proto/src/requests/propfind.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + parser::{DavParser, Token, tokenizer::Tokenizer}, + schema::{Element, NamedElement, Namespace, request::PropFind}, +}; + +impl DavParser for PropFind { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + if stream.expect_named_element_or_eof(NamedElement::dav(Element::Propfind))? { + match stream.unwrap_named_element()? { + NamedElement { + ns: Namespace::Dav, + element: Element::Propname, + } => Ok(PropFind::PropName), + NamedElement { + ns: Namespace::Dav, + element: Element::Allprop, + } => { + stream.expect_element_end()?; + if matches!( + stream.token()?, + Token::ElementStart { + name: NamedElement { + ns: Namespace::Dav, + element: Element::Include + }, + .. + } + ) { + stream.collect_properties(Vec::new()).map(PropFind::AllProp) + } else { + Ok(PropFind::AllProp(vec![])) + } + } + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + } => stream.collect_properties(Vec::new()).map(PropFind::Prop), + element => Err(element.into_unexpected()), + } + } else { + Ok(PropFind::AllProp(vec![])) + } + } +} diff --git a/crates/dav-proto/src/requests/report.rs b/crates/dav-proto/src/requests/report.rs new file mode 100644 index 0000000..fce3c2a --- /dev/null +++ b/crates/dav-proto/src/requests/report.rs @@ -0,0 +1,641 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Depth, + parser::{ + DavParser, RawElement, Token, XmlValueParser, property::TimeRangeFromRaw, + tokenizer::Tokenizer, + }, + schema::{ + Attribute, Collation, Element, MatchType, NamedElement, Namespace, + property::DavProperty, + request::{ + AclPrincipalPropSet, AddressbookQuery, CalendarQuery, ExpandProperty, + ExpandPropertyItem, Filter, FilterOp, FreeBusyQuery, MultiGet, PrincipalMatch, + PrincipalPropertySearch, PropFind, Report, SyncCollection, TextMatch, Timezone, + VCardPropertyWithGroup, + }, + }, +}; +use calcard::{ + icalendar::{ICalendarComponentType, ICalendarParameterName, ICalendarProperty}, + vcard::VCardParameterName, +}; +use types::{TimeRange, dead_property::DeadElementTag}; + +impl DavParser for Report { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + match stream.unwrap_named_element()? { + NamedElement { + ns: Namespace::CalDav, + element: Element::CalendarQuery, + } => CalendarQuery::parse(stream).map(Report::CalendarQuery), + NamedElement { + ns: Namespace::CalDav, + element: Element::FreeBusyQuery, + } => FreeBusyQuery::parse(stream).map(Report::FreeBusyQuery), + NamedElement { + ns: Namespace::CalDav, + element: Element::CalendarMultiget, + } => MultiGet::parse(stream).map(Report::CalendarMultiGet), + NamedElement { + ns: Namespace::CardDav, + element: Element::AddressbookQuery, + } => AddressbookQuery::parse(stream).map(Report::AddressbookQuery), + NamedElement { + ns: Namespace::CardDav, + element: Element::AddressbookMultiget, + } => MultiGet::parse(stream).map(Report::AddressbookMultiGet), + NamedElement { + ns: Namespace::Dav, + element: Element::SyncCollection, + } => SyncCollection::parse(stream).map(Report::SyncCollection), + NamedElement { + ns: Namespace::Dav, + element: Element::AclPrincipalPropSet, + } => AclPrincipalPropSet::parse(stream).map(Report::AclPrincipalPropSet), + NamedElement { + ns: Namespace::Dav, + element: Element::PrincipalMatch, + } => PrincipalMatch::parse(stream).map(Report::PrincipalMatch), + NamedElement { + ns: Namespace::Dav, + element: Element::PrincipalPropertySearch, + } => PrincipalPropertySearch::parse(stream).map(Report::PrincipalPropertySearch), + NamedElement { + ns: Namespace::Dav, + element: Element::PrincipalSearchPropertySet, + } => stream + .expect_element_end() + .map(|_| Report::PrincipalSearchPropertySet), + NamedElement { + ns: Namespace::Dav, + element: Element::ExpandProperty, + } => ExpandProperty::parse(stream).map(Report::ExpandProperty), + other => Err(other.into_unexpected()), + } + } +} + +impl DavParser for CalendarQuery { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut cq = CalendarQuery { + properties: PropFind::AllProp(vec![]), + filters: vec![], + timezone: Timezone::None, + }; + let mut depth = 1; + let mut components = Vec::with_capacity(3); + let mut property = None; + let mut parameter = None; + + loop { + match stream.token()? { + Token::ElementStart { name, raw } => match name { + NamedElement { + ns: Namespace::Dav, + element: Element::Propname, + } if depth == 1 => { + cq.properties = PropFind::PropName; + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Allprop, + } if depth == 1 => { + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + } if depth == 1 => { + cq.properties = PropFind::Prop(stream.collect_properties(Vec::new())?); + } + NamedElement { + ns: Namespace::CalDav, + element: Element::Filter, + } if depth == 1 => { + depth += 1; + } + NamedElement { + ns: Namespace::CalDav, + element: Element::Timezone, + } if depth == 1 => { + cq.timezone = + Timezone::Name(stream.collect_string_value()?.unwrap_or_default()); + } + NamedElement { + ns: Namespace::CalDav, + element: Element::TimezoneId, + } if depth == 1 => { + cq.timezone = + Timezone::Id(stream.collect_string_value()?.unwrap_or_default()); + } + NamedElement { + ns: Namespace::CalDav, + element: Element::CompFilter, + } if depth >= 2 => { + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + components.push((name, depth)); + } + } + depth += 1; + } + + NamedElement { + ns: Namespace::CalDav, + element: Element::PropFilter, + } if depth >= 3 => { + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + property = Some(name); + } + } + depth += 1; + } + NamedElement { + ns: Namespace::CalDav, + element: Element::ParamFilter, + } if depth >= 4 => { + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + parameter = Some(name); + } + } + depth += 1; + } + NamedElement { + ns: Namespace::CalDav, + element: Element::IsNotDefined, + } => { + stream.expect_element_end()?; + if let Some(filter) = Filter::from_parts( + components.iter().map(|(c, _)| c.clone()).collect(), + property.clone(), + parameter.clone(), + FilterOp::Undefined, + ) { + cq.filters.push(filter); + } + } + NamedElement { + ns: Namespace::CalDav, + element: Element::TextMatch, + } => { + let mut tm = TextMatch::parse(raw)?; + tm.value = stream.collect_string_value()?.unwrap_or_default(); + if let Some(filter) = Filter::from_parts( + components.iter().map(|(c, _)| c.clone()).collect(), + property.clone(), + parameter.clone(), + FilterOp::TextMatch(tm), + ) { + cq.filters.push(filter); + } + } + NamedElement { + ns: Namespace::CalDav, + element: Element::TimeRange, + } => { + let range = TimeRange::from_raw(&raw)?; + stream.expect_element_end()?; + if let Some(filter) = range.and_then(|range| { + Filter::from_parts( + components.iter().map(|(c, _)| c.clone()).collect(), + property.clone(), + parameter.clone(), + FilterOp::TimeRange(range), + ) + }) { + cq.filters.push(filter); + } + } + name => return Err(name.into_unexpected()), + }, + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + if matches!(components.last(), Some((_, d)) if *d == depth) { + if components.len() > 1 + && cq + .filters + .last() + .and_then(|c| c.components()) + .is_none_or(|c| c.len() < components.len()) + { + cq.filters.push(Filter::Component { + comp: components.iter().map(|(c, _)| c.clone()).collect(), + op: FilterOp::Exists, + }); + } + components.pop(); + } + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + element => return Err(element.into_unexpected()), + } + } + + Ok(cq) + } +} + +impl DavParser for AddressbookQuery { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut aq = AddressbookQuery { + properties: PropFind::AllProp(vec![]), + filters: vec![], + limit: None, + }; + let mut depth = 1; + let mut property = None; + let mut parameter = None; + + loop { + match stream.token()? { + Token::ElementStart { name, raw } => match name { + NamedElement { + ns: Namespace::Dav, + element: Element::Propname, + } if depth == 1 => { + aq.properties = PropFind::PropName; + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Allprop, + } if depth == 1 => { + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + } if depth == 1 => { + aq.properties = PropFind::Prop(stream.collect_properties(Vec::new())?); + } + NamedElement { + ns: Namespace::CardDav, + element: Element::Filter, + } if depth == 1 => { + if let Some(filter) = Filter::parse(raw)? { + aq.filters.push(filter); + } + depth += 1; + } + NamedElement { + ns: Namespace::CardDav, + element: Element::Limit, + } if depth == 1 => { + stream.expect_named_element(NamedElement::carddav(Element::Nresults))?; + if let Some(Ok(limit)) = stream.parse_value::()? { + aq.limit = limit.into(); + } + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::CardDav, + element: Element::PropFilter, + } if depth == 2 => { + let mut filter = None; + for attribute in raw.attributes::() { + match attribute? { + Attribute::Name(name) => { + property = Some(name); + } + Attribute::TestAllOf(all_of) => { + filter = + (if all_of { Filter::AllOf } else { Filter::AnyOf }).into(); + } + _ => {} + } + } + if let Some(filter) = filter { + aq.filters.push(filter); + } + depth += 1; + } + NamedElement { + ns: Namespace::CardDav, + element: Element::ParamFilter, + } if depth == 3 => { + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + parameter = Some(name); + } + } + depth += 1; + } + NamedElement { + ns: Namespace::CardDav, + element: Element::IsNotDefined, + } => { + stream.expect_element_end()?; + if let Some(filter) = Filter::from_parts( + (), + property.clone(), + parameter.clone(), + FilterOp::Undefined, + ) { + aq.filters.push(filter); + } + } + NamedElement { + ns: Namespace::CardDav, + element: Element::TextMatch, + } => { + let mut tm = TextMatch::parse(raw)?; + tm.value = stream.collect_string_value()?.unwrap_or_default(); + if let Some(filter) = Filter::from_parts( + (), + property.clone(), + parameter.clone(), + FilterOp::TextMatch(tm), + ) { + aq.filters.push(filter); + } + } + name => return Err(name.into_unexpected()), + }, + Token::ElementEnd => { + depth -= 1; + if depth == 0 { + break; + } + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + element => return Err(element.into_unexpected()), + } + } + + Ok(aq) + } +} + +impl DavParser for FreeBusyQuery { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + match stream.token()? { + Token::ElementStart { + name: + NamedElement { + ns: Namespace::CalDav, + element: Element::TimeRange, + }, + raw, + } => TimeRange::from_raw(&raw).map(|range| FreeBusyQuery { range }), + other => Err(other.into_unexpected()), + } + } +} + +impl DavParser for MultiGet { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut mg = MultiGet { + properties: PropFind::AllProp(vec![]), + hrefs: vec![], + }; + + loop { + match stream.token()? { + Token::ElementStart { name, .. } => match name { + NamedElement { + ns: Namespace::Dav, + element: Element::Propname, + } => { + mg.properties = PropFind::PropName; + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Allprop, + } => { + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + } => { + mg.properties = PropFind::Prop(stream.collect_properties(Vec::new())?); + } + NamedElement { + ns: Namespace::Dav, + element: Element::Href, + } => { + if let Some(href) = stream.collect_string_value()? { + mg.hrefs.push(href); + } + } + name => return Err(name.into_unexpected()), + }, + Token::ElementEnd => { + break; + } + element => return Err(element.into_unexpected()), + } + } + + Ok(mg) + } +} + +impl DavParser for SyncCollection { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut sc = SyncCollection { + properties: PropFind::AllProp(vec![]), + limit: None, + sync_token: None, + depth: Depth::None, + }; + + loop { + match stream.token()? { + Token::ElementStart { name, .. } => match name { + NamedElement { + ns: Namespace::Dav, + element: Element::Prop, + } => { + sc.properties = PropFind::Prop(stream.collect_properties(Vec::new())?); + } + NamedElement { + ns: Namespace::Dav, + element: Element::Limit, + } => { + stream.expect_named_element(NamedElement::dav(Element::Nresults))?; + if let Some(Ok(limit)) = stream.parse_value::()? { + sc.limit = limit.into(); + } + stream.expect_element_end()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::SyncToken, + } => { + sc.sync_token = stream.collect_string_value()?; + } + NamedElement { + ns: Namespace::Dav, + element: Element::SyncLevel, + } => { + if let Some(Ok(depth)) = stream.parse_value::()? { + sc.depth = depth; + } + } + name => return Err(name.into_unexpected()), + }, + Token::ElementEnd => { + break; + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + element => return Err(element.into_unexpected()), + } + } + + Ok(sc) + } +} + +impl DavParser for ExpandProperty { + fn parse(stream: &mut Tokenizer<'_>) -> crate::parser::Result { + let mut ep = ExpandProperty { properties: vec![] }; + let mut depth = 1; + + loop { + match stream.token()? { + Token::ElementStart { name, raw } => match name { + NamedElement { + ns, + element: Element::Property, + } => { + for attribute in raw.attributes::() { + if let Attribute::Name(name) = attribute? { + if let Some(property) = Element::try_parse(name.as_bytes()) + .copied() + .and_then(|element| { + DavProperty::from_element(NamedElement { ns, element }) + }) + { + ep.properties.push(ExpandPropertyItem { + property, + depth: depth - 1, + }); + } else { + let attrs = raw.element.attributes_raw().trim_ascii(); + ep.properties.push(ExpandPropertyItem { + property: DavProperty::DeadProperty(DeadElementTag { + name, + attrs: (!attrs.is_empty()).then(|| { + String::from_utf8_lossy(attrs).into_owned() + }), + }), + depth: depth - 1, + }); + } + break; + } + } + depth += 1; + } + name => return Err(name.into_unexpected()), + }, + Token::ElementEnd => { + depth -= 1; + + if depth == 0 { + break; + } + } + Token::UnknownElement(_) => { + stream.seek_element_end()?; + } + element => return Err(element.into_unexpected()), + } + } + + Ok(ep) + } +} + +impl TextMatch { + fn parse(raw: RawElement<'_>) -> crate::parser::Result { + let mut tm = TextMatch { + match_type: MatchType::Contains, + value: String::new(), + collation: Collation::AsciiCasemap, + negate: false, + }; + + for attribute in raw.attributes::() { + match attribute? { + Attribute::MatchType(match_type) => { + tm.match_type = match_type; + } + Attribute::NegateCondition(negate) => { + tm.negate = negate; + } + Attribute::Collation(collation) => { + tm.collation = collation; + } + _ => {} + } + } + + Ok(tm) + } +} + +impl Filter { + fn from_parts(comp: A, prop: Option, param: Option, op: FilterOp) -> Option { + match (prop, param) { + (Some(prop), Some(param)) => Some(Filter::Parameter { + comp, + prop, + param, + op, + }), + (Some(prop), None) => Some(Filter::Property { comp, prop, op }), + (None, None) => Some(Filter::Component { comp, op }), + _ => None, + } + } + + fn components(&self) -> Option<&A> { + match self { + Filter::Component { comp, .. } => Some(comp), + Filter::Property { comp, .. } => Some(comp), + Filter::Parameter { comp, .. } => Some(comp), + _ => None, + } + } + + fn parse(raw: RawElement<'_>) -> crate::parser::Result> { + for attribute in raw.attributes::() { + if let Attribute::TestAllOf(all_of) = attribute? { + return Ok(Some(if all_of { Filter::AllOf } else { Filter::AnyOf })); + } + } + + Ok(None) + } +} + +impl XmlValueParser for Depth { + fn parse_bytes(bytes: &[u8]) -> Option { + Depth::parse(bytes) + } + + fn parse_str(text: &str) -> Option { + Depth::parse(text.as_bytes()) + } +} diff --git a/crates/dav-proto/src/responses/acl.rs b/crates/dav-proto/src/responses/acl.rs new file mode 100644 index 0000000..829c5fc --- /dev/null +++ b/crates/dav-proto/src/responses/acl.rs @@ -0,0 +1,426 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + responses::XmlEscape, + schema::{ + Namespace, Namespaces, + property::{DavProperty, Privilege}, + response::{ + Ace, AclRestrictions, GrantDeny, Href, List, Principal, PrincipalSearchProperty, + PrincipalSearchPropertySet, RequiredPrincipal, Resource, SupportedPrivilege, + }, + }, +}; +use std::fmt::Display; + +impl Display for SupportedPrivilege { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.privilege)?; + if self.abstract_ { + write!(f, "")?; + } + write!(f, "")?; + self.description.write_escaped_to(f)?; + write!( + f, + "{}", + self.supported_privilege + ) + } +} + +impl SupportedPrivilege { + pub fn new(privilege: Privilege, description: impl Into) -> Self { + SupportedPrivilege { + privilege, + abstract_: false, + description: description.into(), + supported_privilege: List(vec![]), + } + } + + pub fn with_abstract(mut self) -> Self { + self.abstract_ = true; + self + } + + pub fn with_supported_privilege(mut self, supported_privilege: SupportedPrivilege) -> Self { + self.supported_privilege.0.push(supported_privilege); + self + } + + pub fn with_opt_supported_privilege( + mut self, + supported_privilege: Option, + ) -> Self { + if let Some(supported_privilege) = supported_privilege { + self.supported_privilege.0.push(supported_privilege); + } + self + } + + pub fn all_privileges(is_calendar: bool) -> SupportedPrivilege { + SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Read, "Read objects").with_supported_privilege( + SupportedPrivilege::new( + Privilege::ReadCurrentUserPrivilegeSet, + "Read current user privileges", + ), + ), + ) + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Write, "Write objects") + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteProperties, + "Write properties", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteContent, + "Write object contents", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Bind, + "Add resources to a collection", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Unbind, + "Remove resources from a collection", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Unlock, + "Unlock resources", + )), + ) + .with_supported_privilege(SupportedPrivilege::new(Privilege::ReadAcl, "Read ACL")) + .with_supported_privilege(SupportedPrivilege::new(Privilege::WriteAcl, "Write ACL")) + .with_opt_supported_privilege((is_calendar).then(|| { + SupportedPrivilege::new(Privilege::ReadFreeBusy, "Read free/busy information") + })) + } + + pub fn all_scheduling_privileges(is_inbox: bool) -> SupportedPrivilege { + let privilege = SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Read, "Read objects").with_supported_privilege( + SupportedPrivilege::new( + Privilege::ReadCurrentUserPrivilegeSet, + "Read current user privileges", + ), + ), + ); + + if is_inbox { + privilege.with_supported_privilege( + SupportedPrivilege::new( + Privilege::ScheduleDeliver, + "Deliver calendar scheduling messages", + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleDeliverInvite, + "Deliver calendar scheduling invites", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleDeliverReply, + "Deliver calendar scheduling replies", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleQueryFreeBusy, + "Query free/busy information", + )), + ) + } else { + privilege.with_supported_privilege( + SupportedPrivilege::new( + Privilege::ScheduleSend, + "Send calendar scheduling messages", + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendInvite, + "Send calendar scheduling invites", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendReply, + "Send calendar scheduling replies", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ScheduleSendFreeBusy, + "Send free/busy information", + )), + ) + } + } +} + +impl Display for Ace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + if self.invert { + write!(f, "")?; + } + self.principal.fmt(f)?; + if self.invert { + write!(f, "")?; + } + self.grant_deny.fmt(f)?; + if self.protected { + write!(f, "")?; + } + if let Some(inherited) = &self.inherited { + write!(f, "")?; + inherited.fmt(f)?; + write!(f, "")?; + } + write!(f, "") + } +} + +impl Display for Principal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + match self { + Principal::Href(href) => href.fmt(f), + Principal::Response(response) => response.fmt(f), + Principal::All => "".fmt(f), + Principal::Authenticated => "".fmt(f), + Principal::Unauthenticated => "".fmt(f), + Principal::Property(property) => { + write!(f, "{}", property) + } + Principal::Self_ => "".fmt(f), + }?; + write!(f, "") + } +} + +impl Display for GrantDeny { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GrantDeny::Grant(privileges) => { + write!(f, "")?; + privileges.fmt(f)?; + write!(f, "") + } + GrantDeny::Deny(privileges) => { + write!(f, "")?; + privileges.fmt(f)?; + write!(f, "") + } + } + } +} + +impl Display for AclRestrictions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.grant_only { + write!(f, "")?; + } + if self.no_invert { + write!(f, "")?; + } + if self.deny_before_grant { + write!(f, "")?; + } + if let Some(required_principal) = &self.required_principal { + required_principal.fmt(f)?; + } + Ok(()) + } +} + +impl Display for RequiredPrincipal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + match self { + RequiredPrincipal::All => "".fmt(f)?, + RequiredPrincipal::Authenticated => "".fmt(f)?, + RequiredPrincipal::Unauthenticated => "".fmt(f)?, + RequiredPrincipal::Self_ => "".fmt(f)?, + RequiredPrincipal::Href(hrefs) => hrefs.fmt(f)?, + RequiredPrincipal::Property(properties) => { + for property in properties { + write!(f, "{}", property)?; + } + } + } + write!(f, "") + } +} + +impl Display for Privilege { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Privilege::Read => "".fmt(f), + Privilege::Write => "".fmt(f), + Privilege::WriteProperties => "".fmt(f), + Privilege::WriteContent => "".fmt(f), + Privilege::Unlock => "".fmt(f), + Privilege::ReadAcl => "".fmt(f), + Privilege::ReadCurrentUserPrivilegeSet => { + "".fmt(f) + } + Privilege::WriteAcl => "".fmt(f), + Privilege::Bind => "".fmt(f), + Privilege::Unbind => "".fmt(f), + Privilege::All => "".fmt(f), + Privilege::ReadFreeBusy => "".fmt(f), + Privilege::ScheduleDeliver => "".fmt(f), + Privilege::ScheduleDeliverInvite => { + "".fmt(f) + } + Privilege::ScheduleDeliverReply => { + "".fmt(f) + } + Privilege::ScheduleQueryFreeBusy => { + "".fmt(f) + } + Privilege::ScheduleSend => "".fmt(f), + Privilege::ScheduleSendInvite => { + "".fmt(f) + } + Privilege::ScheduleSendReply => { + "".fmt(f) + } + Privilege::ScheduleSendFreeBusy => { + "".fmt(f) + } + } + } +} + +impl Display for Resource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}", + self.href, self.privilege + ) + } +} + +impl Display for PrincipalSearchPropertySet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + write!( + f, + "{}", + self.namespaces, self.properties + ) + } +} + +impl Display for PrincipalSearchProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.name + )?; + write!( + f, + "{}", + self.description + ) + } +} + +impl Resource { + pub fn new(href: impl Into, privilege: Privilege) -> Self { + Resource { + href: Href(href.into()), + privilege, + } + } +} + +impl PrincipalSearchPropertySet { + pub fn new(properties: Vec) -> Self { + PrincipalSearchPropertySet { + namespaces: Namespaces::default(), + properties: List(properties), + } + } + + pub fn with_namespace(mut self, namespace: Namespace) -> Self { + self.namespaces.set(namespace); + self + } +} + +impl PrincipalSearchProperty { + pub fn new(name: impl Into, description: impl Into) -> Self { + PrincipalSearchProperty { + name: name.into(), + description: description.into(), + } + } +} + +impl Ace { + pub fn new(principal: Principal, grant_deny: GrantDeny) -> Self { + Ace { + principal, + invert: false, + grant_deny, + protected: false, + inherited: None, + } + } + + pub fn with_invert(mut self) -> Self { + self.invert = true; + self + } + + pub fn with_protected(mut self) -> Self { + self.protected = true; + self + } + + pub fn with_inherited(mut self, inherited: impl Into) -> Self { + self.inherited = Some(Href(inherited.into())); + self + } +} + +impl GrantDeny { + pub fn grant(privileges: Vec) -> Self { + GrantDeny::Grant(List(privileges)) + } + + pub fn deny(privileges: Vec) -> Self { + GrantDeny::Deny(List(privileges)) + } +} + +impl AclRestrictions { + pub fn new() -> Self { + Self::default() + } + + pub fn with_grant_only(mut self) -> Self { + self.grant_only = true; + self + } + + pub fn with_no_invert(mut self) -> Self { + self.no_invert = true; + self + } + + pub fn with_deny_before_grant(mut self) -> Self { + self.deny_before_grant = true; + self + } + + pub fn with_required_principal(mut self, required_principal: RequiredPrincipal) -> Self { + self.required_principal = Some(required_principal); + self + } +} diff --git a/crates/dav-proto/src/responses/error.rs b/crates/dav-proto/src/responses/error.rs new file mode 100644 index 0000000..14e5b0b --- /dev/null +++ b/crates/dav-proto/src/responses/error.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + Namespace, Namespaces, + response::{BaseCondition, CalCondition, CardCondition, Condition, ErrorResponse}, +}; +use std::fmt::Display; + +impl Display for ErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "", + self.namespaces + )?; + + match &self.error { + Condition::Base(e) => e.fmt(f)?, + Condition::Cal(e) => e.fmt(f)?, + Condition::Card(e) => e.fmt(f)?, + } + + write!(f, "") + } +} + +impl Display for Condition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + + match self { + Condition::Base(e) => e.fmt(f)?, + Condition::Cal(e) => e.fmt(f)?, + Condition::Card(e) => e.fmt(f)?, + } + + write!(f, "") + } +} + +impl Display for BaseCondition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BaseCondition::NoConflictingLock(items) => { + write!(f, "{items}") + } + BaseCondition::LockTokenSubmitted(items) => write!( + f, + "{items}" + ), + BaseCondition::LockTokenMatchesRequestUri => { + write!(f, "") + } + BaseCondition::CannotModifyProtectedProperty => { + write!(f, "") + } + BaseCondition::NoExternalEntities => write!(f, ""), + BaseCondition::PreservedLiveProperties => write!(f, ""), + BaseCondition::PropFindFiniteDepth => write!(f, ""), + BaseCondition::ResourceMustBeNull => write!(f, ""), + BaseCondition::NeedPrivileges(resources) => { + write!(f, "{resources}") + } + BaseCondition::NumberOfMatchesWithinLimit => { + write!(f, "") + } + BaseCondition::QuotaNotExceeded => write!(f, ""), + BaseCondition::ValidResourceType => write!(f, ""), + BaseCondition::ValidSyncToken => write!(f, ""), + BaseCondition::NoAceConflict => write!(f, ""), + BaseCondition::NoProtectedAceConflict => write!(f, ""), + BaseCondition::NoInheritedAceConflict => write!(f, ""), + BaseCondition::LimitedNumberOfAces => write!(f, ""), + BaseCondition::DenyBeforeGrant => write!(f, ""), + BaseCondition::GrantOnly => write!(f, ""), + BaseCondition::NoInvert => write!(f, ""), + BaseCondition::NoAbstract => write!(f, ""), + BaseCondition::NotSupportedPrivilege => write!(f, ""), + BaseCondition::MissingRequiredPrincipal => write!(f, ""), + BaseCondition::RecognizedPrincipal => write!(f, ""), + BaseCondition::AllowedPrincipal => write!(f, ""), + } + } +} + +impl Display for CalCondition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CalCondition::CalendarCollectionLocationOk => { + write!(f, "") + } + CalCondition::ValidCalendarData => write!(f, ""), + CalCondition::ValidFilter => write!(f, ""), + CalCondition::ValidTimezone => write!(f, ""), + CalCondition::ValidCalendarObjectResource => { + write!(f, "") + } + CalCondition::NoUidConflict(uid) => { + write!(f, "{uid}") + } + CalCondition::InitializeCalendarCollection => { + write!(f, "") + } + CalCondition::SupportedCalendarData => write!(f, ""), + CalCondition::SupportedFilter(_) => write!(f, ""), + CalCondition::SupportedCollation(c) => { + write!(f, "{c}") + } + CalCondition::MinDateTime => write!(f, ""), + CalCondition::MaxDateTime => write!(f, ""), + CalCondition::MaxResourceSize(l) => { + write!(f, "{l}") + } + CalCondition::MaxInstances => write!(f, ""), + CalCondition::MaxAttendeesPerInstance => write!(f, ""), + CalCondition::UniqueSchedulingObjectResource(href) => write!( + f, + "{href}" + ), + CalCondition::SameOrganizerInAllComponents => { + write!(f, "") + } + CalCondition::AllowedOrganizerObjectChange => { + write!(f, "") + } + CalCondition::AllowedAttendeeObjectChange => { + write!(f, "") + } + CalCondition::DefaultCalendarNeeded => write!(f, ""), + CalCondition::ValidScheduleDefaultCalendarUrl => { + write!(f, "") + } + CalCondition::ValidSchedulingMessage => write!(f, ""), + CalCondition::ValidOrganizer => write!(f, ""), + CalCondition::SupportedCalendarComponent => { + write!(f, "") + } + } + } +} + +impl Display for CardCondition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CardCondition::SupportedAddressData => write!(f, ""), + CardCondition::SupportedAddressDataConversion => { + write!(f, "") + } + CardCondition::SupportedFilter(_) => write!(f, ""), + CardCondition::SupportedCollation(c) => { + write!(f, "{c}") + } + CardCondition::ValidAddressData => write!(f, ""), + CardCondition::NoUidConflict(uid) => { + write!(f, "{uid}") + } + CardCondition::MaxResourceSize(l) => { + write!(f, "{l}") + } + CardCondition::AddressBookCollectionLocationOk => { + write!(f, "") + } + } + } +} + +impl From for Condition { + fn from(error: CalCondition) -> Self { + Condition::Cal(error) + } +} + +impl From for Condition { + fn from(error: CardCondition) -> Self { + Condition::Card(error) + } +} + +impl From for Condition { + fn from(error: BaseCondition) -> Self { + Condition::Base(error) + } +} + +impl ErrorResponse { + pub fn new(error: impl Into) -> Self { + ErrorResponse { + namespaces: Namespaces::default(), + error: error.into(), + } + } + + pub fn with_namespace(mut self, namespace: impl Into) -> Self { + self.namespaces.set(namespace.into()); + self + } +} diff --git a/crates/dav-proto/src/responses/lock.rs b/crates/dav-proto/src/responses/lock.rs new file mode 100644 index 0000000..a7d1aaa --- /dev/null +++ b/crates/dav-proto/src/responses/lock.rs @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Depth, Timeout, + responses::DeadPropertyFormat, + schema::{ + property::{ActiveLock, LockDiscovery, LockEntry, LockScope, LockType, SupportedLock}, + request::LockInfo, + response::{Href, List}, + }, +}; +use std::fmt::Display; +use types::dead_property::DeadProperty; + +impl Display for SupportedLock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for LockDiscovery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Display for ActiveLock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}{}", + self.lock_scope, self.lock_type, self.depth + )?; + + if let Some(owner) = &self.owner { + f.write_str("")?; + owner.fmt(f)?; + f.write_str("")?; + } + + write!(f, "{}", self.timeout)?; + + if let Some(lock_token) = &self.lock_token { + write!(f, "{}", lock_token)?; + } + + write!( + f, + "{}", + self.lock_root + ) + } +} + +impl Display for Depth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Depth::Zero => write!(f, "0"), + Depth::One => write!(f, "1"), + Depth::Infinity => write!(f, "infinity"), + Depth::None => write!(f, ""), + } + } +} + +impl Display for Timeout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Timeout::Infinite => write!(f, "Infinite"), + Timeout::Second(s) => write!(f, "Second-{}", s), + Timeout::None => Ok(()), + } + } +} + +impl Display for LockInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.lock_scope, self.lock_type)?; + + if let Some(owner) = &self.owner { + f.write_str("")?; + owner.fmt(f)?; + f.write_str("")?; + } + + write!(f, "",) + } +} + +impl Display for LockEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}", + self.lock_scope, self.lock_type + ) + } +} + +impl Display for LockScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockScope::Exclusive => write!(f, ""), + LockScope::Shared => write!(f, ""), + } + } +} + +impl Display for LockType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockType::Write => write!(f, ""), + LockType::Other => write!(f, ""), + } + } +} + +impl ActiveLock { + pub fn new(href: impl Into, lock_scope: LockScope) -> Self { + Self { + lock_scope, + lock_type: LockType::Write, + depth: Depth::Infinity, + owner: None, + timeout: Timeout::Infinite, + lock_token: None, + lock_root: Href(href.into()), + } + } + + pub fn with_depth(mut self, depth: Depth) -> Self { + self.depth = depth; + self + } + + pub fn with_timeout(mut self, timeout: u64) -> Self { + self.timeout = Timeout::Second(timeout); + self + } + + pub fn with_owner_opt(mut self, owner: Option) -> Self { + self.owner = owner; + self + } + + pub fn with_owner(mut self, owner: DeadProperty) -> Self { + self.owner = Some(owner); + self + } + + pub fn with_lock_token(mut self, token: impl Into) -> Self { + self.lock_token = Some(Href(token.into())); + self + } +} + +impl Default for SupportedLock { + fn default() -> Self { + Self(List(vec![ + LockEntry { + lock_scope: LockScope::Exclusive, + lock_type: LockType::Write, + }, + LockEntry { + lock_scope: LockScope::Shared, + lock_type: LockType::Write, + }, + ])) + } +} diff --git a/crates/dav-proto/src/responses/mkcol.rs b/crates/dav-proto/src/responses/mkcol.rs new file mode 100644 index 0000000..e333cc2 --- /dev/null +++ b/crates/dav-proto/src/responses/mkcol.rs @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + Namespace, Namespaces, + response::{List, MkColResponse, PropStat}, +}; +use std::fmt::Display; + +impl Display for MkColResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + if !self.mkcalendar { + write!( + f, + "{}", + self.namespaces, self.propstat + ) + } else { + write!( + f, + "{}", + self.namespaces, self.propstat + ) + } + } +} + +impl MkColResponse { + pub fn new(propstat: Vec) -> Self { + Self { + namespaces: Namespaces::default(), + propstat: List(propstat), + mkcalendar: false, + } + } + + pub fn with_mkcalendar(mut self, mkcalendar: bool) -> Self { + self.mkcalendar = mkcalendar; + if mkcalendar { + self.namespaces.set(Namespace::CalDav); + } + self + } + + pub fn with_namespace(mut self, namespace: Namespace) -> Self { + self.namespaces.set(namespace); + self + } +} diff --git a/crates/dav-proto/src/responses/mod.rs b/crates/dav-proto/src/responses/mod.rs new file mode 100644 index 0000000..1ce07a8 --- /dev/null +++ b/crates/dav-proto/src/responses/mod.rs @@ -0,0 +1,750 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod error; +pub mod lock; +pub mod mkcol; +pub mod multistatus; +pub mod property; +pub mod propstat; +pub mod schedule; + +use crate::schema::{ + Namespaces, + property::{Comp, ResourceType, SupportedCollation}, + response::{Href, List, Location, ResponseDescription, Status, SyncToken}, +}; +use std::fmt::{Display, Write}; +use types::dead_property::{DeadProperty, DeadPropertyTag}; + +trait XmlEscape { + fn write_escaped_to(&self, out: &mut impl Write) -> std::fmt::Result; +} + +trait XmlCdataEscape { + fn write_cdata_escaped_to(&self, out: &mut impl Write) -> std::fmt::Result; +} + +impl> XmlEscape for T { + fn write_escaped_to(&self, out: &mut impl Write) -> std::fmt::Result { + let str = self.as_ref(); + + for c in str.chars() { + match c { + '<' => out.write_str("<")?, + '>' => out.write_str(">")?, + '&' => out.write_str("&")?, + '"' => out.write_str(""")?, + '\'' => out.write_str("'")?, + _ => out.write_char(c)?, + } + } + + Ok(()) + } +} + +impl> XmlCdataEscape for T { + fn write_cdata_escaped_to(&self, out: &mut impl Write) -> std::fmt::Result { + let str = self.as_ref(); + let mut last_ch = '\0'; + let mut last_ch2 = '\0'; + + out.write_str("' if last_ch == ']' && last_ch2 == ']' => { + out.write_str("]]>")?; + } + _ => out.write_char(ch)?, + } + + last_ch2 = last_ch; + last_ch = ch; + } + + out.write_str("]]>") + } +} + +impl Display for Namespaces { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("xmlns:D=\"DAV:\"")?; + if self.cal { + f.write_str(" xmlns:A=\"urn:ietf:params:xml:ns:caldav\"")?; + } + if self.card { + f.write_str(" xmlns:B=\"urn:ietf:params:xml:ns:carddav\"")?; + } + if self.cs { + f.write_str(" xmlns:C=\"http://calendarserver.org/ns/\"")?; + } + Ok(()) + } +} + +impl Display for Href { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.0.write_escaped_to(f)?; + write!(f, "") + } +} + +impl Display for List { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for item in &self.0 { + item.fmt(f)?; + } + + Ok(()) + } +} + +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + write!(f, "HTTP/1.1 {}", self.0)?; + write!(f, "") + } +} + +impl Display for ResponseDescription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.0.write_escaped_to(f)?; + write!(f, "") + } +} + +impl Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.0.fmt(f)?; + write!(f, "") + } +} + +impl Display for SyncToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.0.write_escaped_to(f)?; + write!(f, "") + } +} + +impl Display for Comp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "", self.0.as_str()) + } +} + +impl Display for ResourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResourceType::Collection => write!(f, ""), + ResourceType::Principal => write!(f, ""), + ResourceType::AddressBook => write!(f, ""), + ResourceType::Calendar => write!(f, ""), + ResourceType::ScheduleInbox => write!(f, ""), + ResourceType::ScheduleOutbox => write!(f, ""), + } + } +} + +impl Display for SupportedCollation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ns = self.namespace.prefix(); + write!( + f, + "<{ns}:supported-collation>{}", + self.collation.as_str() + ) + } +} + +pub trait DeadPropertyFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; +} + +impl DeadPropertyFormat for DeadProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut last_tag = ""; + + for item in &self.0 { + match item { + DeadPropertyTag::ElementStart(tag) => { + let name = &tag.name; + if let Some(attrs) = &tag.attrs { + write!(f, "<{name} {attrs}>")?; + } else { + write!(f, "<{name}>")?; + } + last_tag = name; + } + DeadPropertyTag::ElementEnd => { + write!(f, "", last_tag)?; + } + DeadPropertyTag::Text(text) => { + text.write_escaped_to(f)?; + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fmt::Display; + + use calcard::{icalendar::ICalendar, vcard::VCard}; + use hyper::StatusCode; + use mail_parser::DateTime; + use types::dead_property::{DeadElementTag, DeadProperty, DeadPropertyTag}; + + use crate::{ + Depth, + parser::{Token, tokenizer::Tokenizer}, + responses::XmlCdataEscape, + schema::{ + Namespace, + property::{ + ActiveLock, CalDavProperty, CardDavProperty, DavValue, LockScope, Privilege, + ResourceType, Rfc1123DateTime, SupportedLock, WebDavProperty, + }, + request::DavPropertyValue, + response::{ + Ace, AclRestrictions, BaseCondition, ErrorResponse, GrantDeny, Href, List, + MkColResponse, MultiStatus, Principal, PrincipalSearchProperty, + PrincipalSearchPropertySet, PropResponse, PropStat, RequiredPrincipal, Resource, + Response, ScheduleResponse, ScheduleResponseItem, SupportedPrivilege, + }, + }, + }; + + impl List { + pub fn new(vec: impl IntoIterator) -> Self { + List(vec.into_iter().collect()) + } + } + + impl From for DavValue { + fn from(v: ICalendar) -> Self { + DavValue::ICalendar(v) + } + } + + impl From for DavValue { + fn from(v: VCard) -> Self { + DavValue::VCard(v) + } + } + + #[test] + fn parse_responses() { + for (num, test) in [ + // 001.xml + ErrorResponse::new(BaseCondition::LockTokenSubmitted(List::new([Href( + "/locked/".to_string(), + )]))) + .to_string(), + // 002.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/file", + vec![ + PropStat::new(DavPropertyValue::new( + WebDavProperty::DisplayName, + "Box type A", + )), + PropStat::new(DavPropertyValue::new( + WebDavProperty::DisplayName, + "Box type B", + )) + .with_status(StatusCode::FORBIDDEN) + .with_response_description( + "The user does not have access to the DingALing property.", + ), + ], + )]) + .with_response_description("There has been an access violation error.") + .to_string(), + // 003.xml + MultiStatus::new(vec![ + Response::new_propstat( + "/container/", + vec![PropStat::new_list(vec![ + DavPropertyValue::new( + WebDavProperty::CreationDate, + DateTime::parse_rfc3339("1997-12-01T17:42:21-08:00Z").unwrap(), + ), + DavPropertyValue::new(WebDavProperty::DisplayName, "Example collection"), + DavPropertyValue::new( + WebDavProperty::ResourceType, + vec![ResourceType::Collection], + ), + DavPropertyValue::new( + WebDavProperty::SupportedLock, + SupportedLock::default(), + ), + ])], + ), + Response::new_propstat( + "/container/front.html", + vec![PropStat::new_list(vec![ + DavPropertyValue::new( + WebDavProperty::CreationDate, + DateTime::parse_rfc3339("1997-12-01T18:27:21-08:00").unwrap(), + ), + DavPropertyValue::new(WebDavProperty::DisplayName, "Example HTML resource"), + DavPropertyValue::new(WebDavProperty::GetContentLength, 4525u64), + DavPropertyValue::new(WebDavProperty::GetContentType, "text/html"), + DavPropertyValue::new(WebDavProperty::GetETag, "\"zzyzx\""), + DavPropertyValue::new( + WebDavProperty::GetLastModified, + DavValue::Rfc1123Date(Rfc1123DateTime::new( + DateTime::parse_rfc822("Mon, 12 Jan 1998 09:25:56 GMT") + .unwrap() + .to_timestamp(), + )), + ), + DavPropertyValue::new(WebDavProperty::ResourceType, DavValue::Null), + DavPropertyValue::new( + WebDavProperty::SupportedLock, + SupportedLock::default(), + ), + ])], + ), + ]) + .to_string(), + // 004.xml + MultiStatus::new(vec![ + Response::new_status( + ["http://www.example.com/container/resource3"], + StatusCode::LOCKED, + ) + .with_error(BaseCondition::LockTokenSubmitted(List(vec![]))), + ]) + .to_string(), + // 005.xml + PropResponse::new(vec![DavPropertyValue::new( + WebDavProperty::LockDiscovery, + vec![ + ActiveLock::new( + "http://example.com/workspace/webdav/proposal.doc", + LockScope::Exclusive, + ) + .with_owner(DeadProperty(vec![ + DeadPropertyTag::ElementStart(DeadElementTag { + name: "D:href".to_string(), + attrs: None, + }), + DeadPropertyTag::Text("http://example.org/~ejw/contact.html".to_string()), + DeadPropertyTag::ElementEnd, + ])) + .with_timeout(604800) + .with_lock_token("urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4"), + ], + )]) + .to_string(), + // 006.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/container/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::LockDiscovery, + vec![ + ActiveLock::new("http://www.example.com/container/", LockScope::Shared) + .with_owner(DeadProperty(vec![DeadPropertyTag::Text( + "Jane Smith".to_string(), + )])) + .with_depth(Depth::Zero) + .with_lock_token("urn:uuid:f81de2ad-7f3d-a1b2-4f3c-00a0c91a9d76"), + ], + )])], + )]) + .to_string(), + // 007.xml + ErrorResponse::new(BaseCondition::LockTokenSubmitted(List(vec![Href( + "/workspace/webdav/".to_string(), + )]))) + .to_string(), + // 008.xml + MultiStatus::new(vec![ + Response::new_propstat( + "http://cal.example.com/bernard/work/abcd2.ics", + vec![PropStat::new_list(vec![ + DavPropertyValue::new(WebDavProperty::GetETag, "\"fffff-abcd2\""), + DavPropertyValue::new( + CalDavProperty::CalendarData(Default::default()), + DavValue::CData( + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060106T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060106T120000 +SUMMARY:Event #2 bis bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"# + .to_string(), + ), + ), + ])], + ), + Response::new_propstat( + "http://cal.example.com/bernard/work/abcd3.ics", + vec![PropStat::new_list(vec![ + DavPropertyValue::new(WebDavProperty::GetETag, "\"fffff-abcd3\""), + DavPropertyValue::new( + CalDavProperty::CalendarData(Default::default()), + DavValue::CData( + r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +END:VEVENT +END:VCALENDAR +"# + .to_string(), + ), + ), + ])], + ), + ]) + .with_namespace(Namespace::CalDav) + .to_string(), + // 009.xml + MkColResponse::new(vec![PropStat::new_list(vec![ + DavPropertyValue::new(WebDavProperty::ResourceType, DavValue::Null), + DavPropertyValue::new(WebDavProperty::DisplayName, DavValue::Null), + DavPropertyValue::new(CardDavProperty::AddressbookDescription, DavValue::Null), + ])]) + .with_namespace(Namespace::CardDav) + .to_string(), + // 010.xml + MultiStatus::new(vec![Response::new_propstat( + "/home/bernard/addressbook/v102.vcf", + vec![PropStat::new_list(vec![ + DavPropertyValue::new(WebDavProperty::GetETag, "\"23ba4d-ff11fb\""), + DavPropertyValue::new( + CardDavProperty::AddressData { + properties: Default::default(), + version: None, + }, + DavValue::CData( + r#"BEGIN:VCARD +VERSION:3.0 +NICKNAME:me +UID:34222-232@example.com +FN:Cyrus Daboo +EMAIL:daboo@example.com +END:VCARD +"# + .to_string(), + ), + ), + ])], + )]) + .with_namespace(Namespace::CardDav) + .to_string(), + // 011.xml + MultiStatus::new(vec![ + Response::new_status( + ["/home/bernard/addressbook/"], + StatusCode::INSUFFICIENT_STORAGE, + ) + .with_error(BaseCondition::NumberOfMatchesWithinLimit) + .with_response_description("Only two matching records were returned"), + Response::new_propstat( + "/home/bernard/addressbook/v102.vcf", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::GetETag, + "\"23ba4d-ff11fb\"", + )])], + ), + Response::new_propstat( + "/home/bernard/addressbook/v104.vcf", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::GetETag, + "\"23ba4d-ff11fc\"", + )])], + ), + ]) + .with_namespace(Namespace::CardDav) + .to_string(), + // 012.xml + ErrorResponse::new(BaseCondition::NeedPrivileges(List(vec![ + Resource::new("/a", Privilege::Unbind), + Resource::new("/c", Privilege::Bind), + ]))) + .to_string(), + // 013.xml + PrincipalSearchPropertySet::new(vec![ + PrincipalSearchProperty::new(WebDavProperty::DisplayName, "Full name"), + PrincipalSearchProperty::new(WebDavProperty::DisplayName, "Job title"), + ]) + .to_string(), + // 014.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/papers/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::SupportedPrivilegeSet, + vec![ + SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Read, "Read any object") + .with_supported_privilege( + SupportedPrivilege::new(Privilege::ReadAcl, "Read ACL") + .with_abstract(), + ) + .with_supported_privilege( + SupportedPrivilege::new( + Privilege::ReadCurrentUserPrivilegeSet, + "Read current user privilege set property", + ) + .with_abstract(), + ), + ) + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Write, "Write any object") + .with_supported_privilege( + SupportedPrivilege::new(Privilege::WriteAcl, "Write ACL") + .with_abstract(), + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteProperties, + "Write properties", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteContent, + "Write resource content", + )), + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Unlock, + "Unlock resource", + )), + ], + )])], + )]) + .to_string(), + // 015.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/papers/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::CurrentUserPrivilegeSet, + vec![Privilege::Read], + )])], + )]) + .to_string(), + // 016.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/papers/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::Acl, + vec![ + Ace::new( + Principal::Href(Href( + "http://www.example.com/acl/groups/maintainers".to_string(), + )), + GrantDeny::grant(vec![Privilege::Write]), + ), + Ace::new(Principal::All, GrantDeny::grant(vec![Privilege::Read])), + ], + )])], + )]) + .to_string(), + // 017.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/papers/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::AclRestrictions, + AclRestrictions::new() + .with_grant_only() + .with_required_principal(RequiredPrincipal::All), + )])], + )]) + .to_string(), + // 018.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/papers/", + vec![PropStat::new_list(vec![DavPropertyValue::new( + WebDavProperty::PrincipalCollectionSet, + vec![ + Href("http://www.example.com/acl/users/".to_string()), + Href("http://www.example.com/acl/groups/".to_string()), + ], + )])], + )]) + .to_string(), + // 019.xml + MultiStatus::new(vec![Response::new_propstat( + "http://www.example.com/top/container/", + vec![PropStat::new_list(vec![ + DavPropertyValue::new( + WebDavProperty::Owner, + vec![Href("http://www.example.com/users/gclemm".to_string())], + ), + DavPropertyValue::new( + WebDavProperty::SupportedPrivilegeSet, + vec![ + SupportedPrivilege::new(Privilege::All, "Any operation") + .with_abstract() + .with_supported_privilege(SupportedPrivilege::new( + Privilege::Read, + "Read any object", + )) + .with_supported_privilege( + SupportedPrivilege::new(Privilege::Write, "Write any object") + .with_abstract(), + ) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::ReadAcl, + "Read the ACL", + )) + .with_supported_privilege(SupportedPrivilege::new( + Privilege::WriteAcl, + "Write the ACL", + )), + ], + ), + DavPropertyValue::new( + WebDavProperty::CurrentUserPrivilegeSet, + vec![Privilege::Read, Privilege::ReadAcl], + ), + DavPropertyValue::new( + WebDavProperty::Acl, + vec![ + Ace::new( + Principal::Href(Href( + "http://www.example.com/users/esedlar".to_string(), + )), + GrantDeny::grant(vec![ + Privilege::Read, + Privilege::Write, + Privilege::ReadAcl, + ]), + ), + Ace::new( + Principal::Href(Href( + "http://www.example.com/groups/mrktng".to_string(), + )), + GrantDeny::deny(vec![Privilege::Read]), + ), + Ace::new( + Principal::Property(List(vec![DavPropertyValue::new( + WebDavProperty::Owner, + DavValue::Null, + )])), + GrantDeny::grant(vec![Privilege::ReadAcl, Privilege::WriteAcl]), + ), + Ace::new(Principal::All, GrantDeny::grant(vec![Privilege::Read])) + .with_inherited("http://www.example.com/top"), + ], + ), + ])], + )]) + .to_string(), + // 020.xml + ScheduleResponse { + items: List(vec![ + ScheduleResponseItem { + recipient: Href("mailto:wilfredo@example.com".to_string()), + request_status: "2.0;Success".into(), + calendar_data: Some("BEGIN:VCALENDAR".to_string()), + }, + ScheduleResponseItem { + recipient: Href("mailto:bernard@example.net".to_string()), + request_status: "2.0;Success".into(), + calendar_data: Some("END:VCALENDAR".to_string()), + }, + ScheduleResponseItem { + recipient: Href("mailto:mike@example.org".to_string()), + request_status: "3.7;Invalid calendar user".into(), + calendar_data: None, + }, + ]), + } + .to_string(), + ] + .into_iter() + .enumerate() + { + let xml = + std::fs::read_to_string(format!("resources/responses/{:03}.xml", num + 1)).unwrap(); + let mut output_token = Tokenizer::new(test.as_bytes()); + let mut expected_token = Tokenizer::new(xml.as_bytes()); + let mut output_tokens = Vec::new(); + let mut expected_tokens = Vec::new(); + + for (tokens, tokenizer) in [ + (&mut output_tokens, &mut output_token), + (&mut expected_tokens, &mut expected_token), + ] { + while let Ok(token) = tokenizer.token() { + if token == Token::Eof { + break; + } + match (tokens.last_mut(), token) { + (Some(Token::Text(text)), Token::Text(new_text)) => { + *text = format!("{}{}", text, new_text).into(); + } + (_, element) => { + tokens.push(element.into_owned()); + } + } + } + } + + assert!(!output_tokens.is_empty()); + assert!(!expected_tokens.is_empty()); + assert_eq!(output_tokens.len(), expected_tokens.len()); + + for (output, expected) in output_tokens.iter().zip(expected_tokens.iter()) { + if output != expected { + eprintln!("{test}"); + } + assert_eq!(output, expected, "failed for {:03}.xml", num + 1); + } + } + } + + #[test] + fn escape_cdata() { + for (test, expected) in [ + ("", ""), + ("hello", ""), + ("hello world", ""), + ("", "]]>"), + ("&hello;", ""), + ("'hello'", ""), + ("\"hello\"", ""), + ("<>&'\"", "&'\"]]>"), + (">", "]]>"), + ("]]>]", "]]]>"), + ("]]>", "]]>"), + ("hello]]>world", "world]]>"), + ( + "hello]]>pure-evil", + "pure-evil]]>", + ), + ] { + let mut output = String::new(); + test.write_cdata_escaped_to(&mut output).unwrap(); + assert_eq!(output, expected, "failed for input: {test:?}"); + } + } +} diff --git a/crates/dav-proto/src/responses/multistatus.rs b/crates/dav-proto/src/responses/multistatus.rs new file mode 100644 index 0000000..0d10e73 --- /dev/null +++ b/crates/dav-proto/src/responses/multistatus.rs @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + Namespace, Namespaces, + response::{ + Condition, Href, List, Location, MultiStatus, PropStat, Response, ResponseDescription, + ResponseType, Status, SyncToken, + }, +}; +use hyper::StatusCode; +use std::fmt::Display; + +impl Display for MultiStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.namespaces, self.response + )?; + if let Some(response_description) = &self.response_description { + write!(f, "{response_description}")?; + } + + if let Some(sync_token) = &self.sync_token { + write!(f, "{sync_token}")?; + } + + write!(f, "") + } +} + +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.href.fmt(f)?; + self.typ.fmt(f)?; + if let Some(error) = &self.error { + error.fmt(f)?; + } + if let Some(response_description) = &self.response_description { + response_description.fmt(f)?; + } + if let Some(location) = &self.location { + location.fmt(f)?; + } + write!(f, "") + } +} + +impl Display for ResponseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResponseType::PropStat(list) => list.fmt(f), + ResponseType::Status { href, status } => { + href.fmt(f)?; + status.fmt(f) + } + } + } +} + +impl MultiStatus { + pub fn new(response: Vec) -> Self { + MultiStatus { + namespaces: Namespaces::default(), + response: List(response), + response_description: None, + sync_token: None, + } + } + + pub fn with_response(mut self, response: Response) -> Self { + self.response.0.push(response); + self + } + + pub fn not_found(href: impl Into) -> Self { + let mut response = Self::new(Vec::with_capacity(1)); + response.response.0.push( + Response::new_status([href], StatusCode::NOT_FOUND) + .with_response_description("No resources found"), + ); + response + } + + pub fn add_response(&mut self, response: Response) { + self.response.0.push(response); + } + + pub fn with_response_description(mut self, response_description: impl Into) -> Self { + self.response_description = Some(ResponseDescription(response_description.into())); + self + } + + pub fn with_namespace(mut self, namespace: Namespace) -> Self { + self.namespaces.set(namespace); + self + } + + pub fn set_namespace(&mut self, namespace: Namespace) { + self.namespaces.set(namespace); + } + + pub fn with_sync_token(mut self, sync_token: impl Into) -> Self { + self.sync_token = Some(SyncToken(sync_token.into())); + self + } + + pub fn set_sync_token(&mut self, sync_token: impl Into) { + self.sync_token = Some(SyncToken(sync_token.into())); + } +} + +impl Response { + pub fn new_propstat(href: impl Into, propstat: Vec) -> Self { + Response { + href: href.into(), + typ: ResponseType::PropStat(List(propstat)), + error: None, + response_description: None, + location: None, + } + } + + pub fn new_status(href: T, status: StatusCode) -> Self + where + T: IntoIterator, + H: Into, + { + let mut href = href.into_iter().map(|h| Href(h.into())); + Response { + href: href.next().unwrap(), + typ: ResponseType::Status { + href: List(href.collect()), + status: Status(status), + }, + error: None, + response_description: None, + location: None, + } + } + + pub fn with_error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + pub fn with_response_description(mut self, response_description: impl Into) -> Self { + self.response_description = Some(ResponseDescription(response_description.into())); + self + } + + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(Location(Href(location.into()))); + self + } +} diff --git a/crates/dav-proto/src/responses/property.rs b/crates/dav-proto/src/responses/property.rs new file mode 100644 index 0000000..44dffde --- /dev/null +++ b/crates/dav-proto/src/responses/property.rs @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{XmlCdataEscape, XmlEscape}; +use crate::{ + responses::DeadPropertyFormat, + schema::{ + Namespace, Namespaces, + property::{ + ActiveLock, CalDavProperty, CardDavProperty, Comp, DavProperty, DavValue, + LockDiscovery, LockEntry, PrincipalProperty, Privilege, ReportSet, ResourceType, + Rfc1123DateTime, SupportedCollation, SupportedLock, WebDavProperty, + }, + request::DavPropertyValue, + response::{Ace, AclRestrictions, Href, List, PropResponse, SupportedPrivilege}, + }, +}; +use calcard::icalendar::ICalendarComponentType; +use mail_parser::{ + DateTime, + parsers::fields::date::{DOW, MONTH}, +}; +use std::fmt::Display; +use types::dead_property::DeadProperty; + +impl Display for PropResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.namespaces, self.properties + ) + } +} + +impl Display for DavPropertyValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (name, attrs) = self.property.tag_name(); + + write!(f, "<{}", name)?; + + if let Some(attrs) = attrs { + write!(f, " {attrs}")?; + } + + if !matches!(self.value, DavValue::Null) { + write!(f, ">{}", self.value, name) + } else { + write!(f, "/>") + } + } +} + +impl Display for Rfc1123DateTime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let dt = DateTime::from_timestamp(self.0); + write!( + f, + "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT", + DOW[dt.day_of_week() as usize], + dt.day, + MONTH + .get(dt.month.saturating_sub(1) as usize) + .copied() + .unwrap_or_default(), + dt.year, + dt.hour, + dt.minute, + dt.second, + ) + } +} + +impl Display for DavValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DavValue::Timestamp(v) => { + let dt = DateTime::from_timestamp(*v); + write!( + f, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, + ) + } + DavValue::Rfc1123Date(v) => v.fmt(f), + DavValue::Uint64(v) => v.fmt(f), + DavValue::String(v) => v.write_escaped_to(f), + DavValue::ResourceTypes(v) => v.fmt(f), + DavValue::ActiveLocks(v) => v.fmt(f), + DavValue::LockEntries(v) => v.fmt(f), + DavValue::ReportSets(v) => v.fmt(f), + DavValue::CData(v) => v.write_cdata_escaped_to(f), + DavValue::Components(v) => v.fmt(f), + DavValue::Collations(v) => v.fmt(f), + DavValue::Href(v) => v.fmt(f), + DavValue::PrivilegeSet(v) => v.fmt(f), + DavValue::Privileges(v) => v.fmt(f), + DavValue::Acl(v) => v.fmt(f), + DavValue::AclRestrictions(v) => v.fmt(f), + DavValue::DeadProperty(v) => v.fmt(f), + DavValue::SupportedAddressData => { + write!( + f, + concat!( + "", + "", + "", + ) + ) + } + DavValue::SupportedCalendarData => { + write!( + f, + concat!( + "", + "", + ) + ) + } + DavValue::Response(v) => v.fmt(f), + DavValue::VCard(_) | DavValue::ICalendar(_) | DavValue::Null => Ok(()), + } + } +} + +impl DavValue { + pub fn all_calendar_components() -> Self { + DavValue::Components(List(vec![ + Comp(ICalendarComponentType::VEvent), + Comp(ICalendarComponentType::VTodo), + Comp(ICalendarComponentType::VJournal), + Comp(ICalendarComponentType::VFreebusy), + Comp(ICalendarComponentType::VTimezone), + Comp(ICalendarComponentType::VAlarm), + Comp(ICalendarComponentType::Standard), + Comp(ICalendarComponentType::Daylight), + Comp(ICalendarComponentType::VAvailability), + Comp(ICalendarComponentType::Available), + Comp(ICalendarComponentType::Participant), + Comp(ICalendarComponentType::VLocation), + Comp(ICalendarComponentType::VResource), + ])) + } +} + +impl DavProperty { + fn tag_name(&self) -> (&str, Option<&str>) { + ( + match self { + DavProperty::WebDav(prop) => match prop { + WebDavProperty::CreationDate => "D:creationdate", + WebDavProperty::DisplayName => "D:displayname", + WebDavProperty::GetContentLanguage => "D:getcontentlanguage", + WebDavProperty::GetContentLength => "D:getcontentlength", + WebDavProperty::GetContentType => "D:getcontenttype", + WebDavProperty::GetETag => "D:getetag", + WebDavProperty::GetLastModified => "D:getlastmodified", + WebDavProperty::ResourceType => "D:resourcetype", + WebDavProperty::LockDiscovery => "D:lockdiscovery", + WebDavProperty::SupportedLock => "D:supportedlock", + WebDavProperty::CurrentUserPrincipal => "D:current-user-principal", + WebDavProperty::QuotaAvailableBytes => "D:quota-available-bytes", + WebDavProperty::QuotaUsedBytes => "D:quota-used-bytes", + WebDavProperty::SupportedReportSet => "D:supported-report-set", + WebDavProperty::SyncToken => "D:sync-token", + WebDavProperty::Owner => "D:owner", + WebDavProperty::Group => "D:group", + WebDavProperty::SupportedPrivilegeSet => "D:supported-privilege-set", + WebDavProperty::CurrentUserPrivilegeSet => "D:current-user-privilege-set", + WebDavProperty::Acl => "D:acl", + WebDavProperty::AclRestrictions => "D:acl-restrictions", + WebDavProperty::InheritedAclSet => "D:inherited-acl-set", + WebDavProperty::PrincipalCollectionSet => "D:principal-collection-set", + WebDavProperty::GetCTag => "C:getctag", + }, + DavProperty::CardDav(prop) => match prop { + CardDavProperty::AddressbookDescription => "B:addressbook-description", + CardDavProperty::SupportedAddressData => "B:supported-address-data", + CardDavProperty::SupportedCollationSet => "B:supported-collation-set", + CardDavProperty::MaxResourceSize => "B:max-resource-size", + CardDavProperty::AddressData { .. } => "B:address-data", + }, + DavProperty::CalDav(prop) => match prop { + CalDavProperty::CalendarDescription => "A:calendar-description", + CalDavProperty::CalendarTimezone => "A:calendar-timezone", + CalDavProperty::SupportedCalendarComponentSet => { + "A:supported-calendar-component-set" + } + CalDavProperty::SupportedCalendarData => "A:supported-calendar-data", + CalDavProperty::SupportedCollationSet => "A:supported-collation-set", + CalDavProperty::MaxResourceSize => "A:max-resource-size", + CalDavProperty::MinDateTime => "A:min-date-time", + CalDavProperty::MaxDateTime => "A:max-date-time", + CalDavProperty::MaxInstances => "A:max-instances", + CalDavProperty::MaxAttendeesPerInstance => "A:max-attendees-per-instance", + CalDavProperty::CalendarData(_) => "A:calendar-data", + CalDavProperty::TimezoneServiceSet => "A:timezone-service-set", + CalDavProperty::TimezoneId => "A:calendar-timezone-id", + CalDavProperty::ScheduleDefaultCalendarURL => "A:schedule-default-calendar-URL", + CalDavProperty::ScheduleTag => "A:schedule-tag", + CalDavProperty::ScheduleCalendarTransp => "A:schedule-calendar-transp", + }, + DavProperty::Principal(prop) => match prop { + PrincipalProperty::AlternateURISet => "D:alternate-URI-set", + PrincipalProperty::PrincipalURL => "D:principal-URL", + PrincipalProperty::GroupMemberSet => "D:group-member-set", + PrincipalProperty::GroupMembership => "D:group-membership", + PrincipalProperty::CalendarHomeSet => "A:calendar-home-set", + PrincipalProperty::AddressbookHomeSet => "B:addressbook-home-set", + PrincipalProperty::PrincipalAddress => "B:principal-address", + PrincipalProperty::CalendarUserAddressSet => "A:calendar-user-address-set", + PrincipalProperty::CalendarUserType => "A:calendar-user-type", + PrincipalProperty::ScheduleInboxURL => "A:schedule-inbox-URL", + PrincipalProperty::ScheduleOutboxURL => "A:schedule-outbox-URL", + }, + DavProperty::DeadProperty(dead) => { + return (dead.name.as_str(), dead.attrs.as_deref()); + } + }, + None, + ) + } + + pub fn namespace(&self) -> Namespace { + match self { + DavProperty::WebDav(WebDavProperty::GetCTag) => Namespace::CalendarServer, + DavProperty::CardDav(_) + | DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet | PrincipalProperty::PrincipalAddress, + ) => Namespace::CardDav, + DavProperty::CalDav(_) + | DavProperty::Principal( + PrincipalProperty::CalendarHomeSet + | PrincipalProperty::CalendarUserAddressSet + | PrincipalProperty::CalendarUserType + | PrincipalProperty::ScheduleInboxURL + | PrincipalProperty::ScheduleOutboxURL, + ) => Namespace::CalDav, + _ => Namespace::Dav, + } + } +} + +impl AsRef for DavProperty { + fn as_ref(&self) -> &str { + self.tag_name().0 + } +} + +impl Display for ReportSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("")?; + match self { + ReportSet::SyncCollection => write!(f, ""), + ReportSet::ExpandProperty => write!(f, ""), + ReportSet::AddressbookQuery => write!(f, ""), + ReportSet::AddressbookMultiGet => write!(f, ""), + ReportSet::CalendarQuery => write!(f, ""), + ReportSet::CalendarMultiGet => write!(f, ""), + ReportSet::FreeBusyQuery => write!(f, ""), + ReportSet::AclPrincipalPropSet => write!(f, ""), + ReportSet::PrincipalMatch => write!(f, ""), + ReportSet::PrincipalPropertySearch => write!(f, ""), + ReportSet::PrincipalSearchPropertySet => { + write!(f, "") + } + }?; + f.write_str("") + } +} + +impl Display for DavProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (name, attrs) = self.tag_name(); + if let Some(attrs) = attrs { + write!(f, "<{name} {attrs}/>") + } else { + write!(f, "<{name}/>") + } + } +} + +impl PropResponse { + pub fn new(properties: Vec) -> Self { + PropResponse { + namespaces: Namespaces::default(), + properties: List(properties), + } + } + + pub fn with_namespace(mut self, namespace: Namespace) -> Self { + self.namespaces.set(namespace); + self + } +} + +impl From for DavProperty { + fn from(prop: WebDavProperty) -> Self { + DavProperty::WebDav(prop) + } +} + +impl From for DavProperty { + fn from(prop: CardDavProperty) -> Self { + DavProperty::CardDav(prop) + } +} + +impl From for DavProperty { + fn from(prop: CalDavProperty) -> Self { + DavProperty::CalDav(prop) + } +} + +impl From for DavValue { + fn from(v: String) -> Self { + DavValue::String(v) + } +} + +impl From<&str> for DavValue { + fn from(v: &str) -> Self { + DavValue::String(v.to_string()) + } +} + +impl From for DavValue { + fn from(v: u64) -> Self { + DavValue::Uint64(v) + } +} + +impl From for DavValue { + fn from(v: DateTime) -> Self { + DavValue::Timestamp(v.to_timestamp()) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::ResourceTypes(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::ReportSets(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::Components(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::Collations(List(v)) + } +} + +impl From for DavValue { + fn from(v: SupportedLock) -> Self { + DavValue::LockEntries(v.0) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::LockEntries(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::ActiveLocks(List(v)) + } +} + +impl From for DavValue { + fn from(v: LockDiscovery) -> Self { + DavValue::ActiveLocks(v.0) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::PrivilegeSet(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::Privileges(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::Href(List(v)) + } +} + +impl From> for DavValue { + fn from(v: Vec) -> Self { + DavValue::Acl(List(v)) + } +} + +impl From for DavValue { + fn from(v: AclRestrictions) -> Self { + DavValue::AclRestrictions(v) + } +} + +impl From for DavValue { + fn from(v: DeadProperty) -> Self { + DavValue::DeadProperty(v) + } +} + +impl DavPropertyValue { + pub fn new(property: impl Into, value: impl Into) -> Self { + DavPropertyValue { + property: property.into(), + value: value.into(), + } + } + + pub fn empty(property: impl Into) -> Self { + DavPropertyValue { + property: property.into(), + value: DavValue::Null, + } + } +} diff --git a/crates/dav-proto/src/responses/propstat.rs b/crates/dav-proto/src/responses/propstat.rs new file mode 100644 index 0000000..4d1089d --- /dev/null +++ b/crates/dav-proto/src/responses/propstat.rs @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + request::DavPropertyValue, + response::{Condition, List, Prop, PropStat, ResponseDescription, Status}, +}; +use hyper::StatusCode; +use std::fmt::Display; + +impl Display for PropStat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + self.prop.fmt(f)?; + self.status.fmt(f)?; + if let Some(error) = &self.error { + error.fmt(f)?; + } + if let Some(response_description) = &self.response_description { + response_description.fmt(f)?; + } + write!(f, "") + } +} + +impl Display for Prop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl PropStat { + #[cfg(test)] + pub(crate) fn new(prop: impl Into) -> Self { + PropStat { + prop: Prop(List(vec![prop.into()])), + status: Status(StatusCode::OK), + error: None, + response_description: None, + } + } + + pub fn new_list(props: Vec) -> Self { + PropStat { + prop: Prop(List(props)), + status: Status(StatusCode::OK), + error: None, + response_description: None, + } + } + + pub fn with_prop(mut self, prop: impl Into) -> Self { + self.prop.0.0.push(prop.into()); + self + } + + pub fn with_status(mut self, status: StatusCode) -> Self { + self.status = Status(status); + self + } + + pub fn with_error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + pub fn with_response_description(mut self, response_description: impl Into) -> Self { + self.response_description = Some(ResponseDescription(response_description.into())); + self + } +} diff --git a/crates/dav-proto/src/responses/schedule.rs b/crates/dav-proto/src/responses/schedule.rs new file mode 100644 index 0000000..911c2a4 --- /dev/null +++ b/crates/dav-proto/src/responses/schedule.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + responses::{XmlCdataEscape, XmlEscape}, + schema::{ + Namespaces, + response::{ScheduleResponse, ScheduleResponseItem}, + }, +}; +use std::fmt::Display; + +const NAMESPACE: Namespaces = Namespaces { + cal: true, + card: false, + cs: false, +}; + +impl Display for ScheduleResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + write!( + f, + "{}", + self.items + ) + } +} + +impl Display for ScheduleResponseItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "")?; + write!(f, "{}", self.recipient)?; + + write!(f, "")?; + self.request_status.write_escaped_to(f)?; + write!(f, "")?; + + if let Some(calendar_data) = &self.calendar_data { + write!(f, "")?; + calendar_data.write_cdata_escaped_to(f)?; + write!(f, "")?; + } + write!(f, "") + } +} diff --git a/crates/dav-proto/src/schema/mod.rs b/crates/dav-proto/src/schema/mod.rs new file mode 100644 index 0000000..06f996e --- /dev/null +++ b/crates/dav-proto/src/schema/mod.rs @@ -0,0 +1,1484 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use request::TextMatch; +pub mod property; +pub mod request; +pub mod response; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct NamedElement { + pub ns: Namespace, + pub element: Element, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(u8)] +pub enum Namespace { + Dav, + CalDav, + CardDav, + CalendarServer, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Namespaces { + pub(crate) cal: bool, + pub(crate) card: bool, + pub(crate) cs: bool, +} + +impl Namespaces { + pub fn set(&mut self, ns: Namespace) { + match ns { + Namespace::CalDav => self.cal = true, + Namespace::CardDav => self.card = true, + Namespace::CalendarServer => self.cs = true, + Namespace::Dav => {} + } + } +} + +impl Namespace { + pub fn try_parse(value: &[u8]) -> Option { + hashify::tiny_map!(value, + "DAV:" => Namespace::Dav, + "urn:ietf:params:xml:ns:caldav" => Namespace::CalDav, + "urn:ietf:params:xml:ns:carddav" => Namespace::CardDav, + "http://calendarserver.org/ns/" => Namespace::CalendarServer, + "http://calendarserver.org/ns" => Namespace::CalendarServer + ) + } + + pub fn prefix(&self) -> &str { + match self { + Namespace::Dav => "D", + Namespace::CalDav => "A", + Namespace::CardDav => "B", + Namespace::CalendarServer => "C", + } + } + + pub fn namespace(&self) -> &'static str { + match self { + Namespace::Dav => "DAV:", + Namespace::CalDav => "urn:ietf:params:xml:ns:caldav", + Namespace::CardDav => "urn:ietf:params:xml:ns:carddav", + Namespace::CalendarServer => "http://calendarserver.org/ns/", + } + } +} + +impl AsRef for Namespace { + fn as_ref(&self) -> &str { + self.namespace() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Element { + Abstract, + Ace, + Acl, + AclPrincipalPropSet, + AclRestrictions, + Activelock, + ActivityCheckoutSet, + ActivityCollectionSet, + ActivitySet, + ActivityVersionSet, + Add, + AddMember, + AddedVersion, + AddressData, + AddressDataType, + Addressbook, + AddressbookDescription, + AddressbookHomeSet, + AddressbookMultiget, + AddressbookQuery, + After, + All, + Allcomp, + AllowClientDefinedUri, + AllowedAttendeeSchedulingObjectChange, + AllowedOrganizerSchedulingObjectChange, + AllowedPrincipal, + Allprop, + AlternateUriSet, + And, + AnyOtherProperty, + ApplyToVersion, + ApplyToPrincipalCollectionSet, + Ascending, + Authenticated, + AutoMergeSet, + AutoUpdate, + AutoVersion, + Baseline, + BaselineCollection, + BaselineControl, + BaselineControlResponse, + BaselineControlledCollection, + BaselineControlledCollectionSet, + Basicsearch, + Basicsearchschema, + Before, + Bind, + BindResponse, + BindingName, + Calendar, + CalendarAvailability, + CalendarData, + CalendarDescription, + CalendarHomeSet, + CalendarMultiget, + CalendarQuery, + CalendarTimezone, + CalendarTimezoneId, + CalendarUserAddressSet, + CalendarUserType, + Caseless, + ChangedVersion, + CheckedIn, + CheckedOut, + Checkin, + CheckinActivity, + CheckinFork, + CheckinResponse, + Checkout, + CheckoutCheckin, + CheckoutFork, + CheckoutResponse, + CheckoutSet, + CheckoutUnlockedCheckin, + Collection, + Comment, + CommonAncestor, + Comp, + CompFilter, + CompareBaseline, + CompareBaselineReport, + ConflictPreview, + Contains, + Creationdate, + CreatorDisplayname, + CurrentActivitySet, + CurrentUserPrincipal, + CurrentUserPrivilegeSet, + CurrentWorkspaceSet, + Datatype, + DefaultCalendarNeeded, + DeletedVersion, + Deny, + DenyBeforeGrant, + Depth, + Descending, + Description, + Discouraged, + Displayname, + Eq, + Error, + Exclusive, + Expand, + ExpandProperty, + Filter, + First, + Forbidden, + ForkOk, + FreeBusyQuery, + From, + Getcontentlanguage, + Getcontentlength, + Getcontenttype, + Getctag, + Getetag, + Getlastmodified, + Grammar, + Grant, + GrantOnly, + Group, + GroupMemberSet, + GroupMembership, + Gt, + Gte, + Href, + IgnorePreview, + Include, + IncludeVersions, + Inherited, + InheritedAclSet, + Invert, + IsCollection, + IsDefined, + IsNotDefined, + KeepCheckedOut, + Label, + LabelName, + LabelNameSet, + LabelResponse, + LanguageDefined, + LanguageMatches, + Last, + LatestActivityVersion, + LatestActivityVersionReport, + Like, + Limit, + LimitFreebusySet, + LimitRecurrenceSet, + LimitedNumberOfAces, + Literal, + LocateByHistory, + Location, + LockTokenSubmitted, + Lockdiscovery, + LockedCheckout, + Lockentry, + Lockinfo, + Lockroot, + Lockscope, + Locktoken, + Locktype, + Lt, + Lte, + ManagedAttachmentsServerUrl, + Match, + MaxAttachmentSize, + MaxAttachmentsPerResource, + MaxAttendeesPerInstance, + MaxDateTime, + MaxInstances, + MaxResourceSize, + Merge, + MergePreview, + MergePreviewReport, + MergeSet, + MinDateTime, + MissingRequiredPrincipal, + Mkactivity, + MkactivityResponse, + Mkcalendar, + MkcalendarResponse, + Mkcol, + MkcolResponse, + Mkredirectref, + MkredirectrefResponse, + Mkworkspace, + MkworkspaceResponse, + Mount, + Multistatus, + NeedPrivileges, + New, + NoAbstract, + NoAceConflict, + NoAutoMerge, + NoCheckout, + NoConflictingLock, + NoInheritedAceConflict, + NoInvert, + NoProtectedAceConflict, + NoUidConflict, + Not, + NotSupportedPrivilege, + Nresults, + Opaque, + Opdesc, + Open, + OperandLiteral, + OperandProperty, + OperandTypedLiteral, + Operators, + Options, + OptionsResponse, + Or, + Order, + OrderMember, + Orderby, + OrderingType, + Orderpatch, + OrderpatchResponse, + Owner, + ParamFilter, + Parent, + ParentSet, + Permanent, + Position, + PredecessorSet, + Principal, + PrincipalUrl, + PrincipalAddress, + PrincipalCollectionSet, + PrincipalMatch, + PrincipalProperty, + PrincipalPropertySearch, + PrincipalSearchProperty, + PrincipalSearchPropertySet, + Privilege, + Prop, + PropFilter, + Propdesc, + Properties, + Property, + PropertySearch, + Propertyupdate, + Propfind, + Propname, + Propstat, + Protected, + QuerySchema, + QuerySchemaDiscovery, + QuotaAvailableBytes, + QuotaUsedBytes, + Read, + ReadAcl, + ReadCurrentUserPrivilegeSet, + ReadFreeBusy, + Rebind, + RebindResponse, + Recipient, + RecognizedPrincipal, + RedirectLifetime, + Redirectref, + Reftarget, + Remove, + Report, + RequestStatus, + RequiredPrincipal, + Resource, + ResourceId, + Resourcetype, + Response, + Responsedescription, + RootVersion, + SameOrganizerInAllComponents, + ScheduleCalendarTransp, + ScheduleDefaultCalendarUrl, + ScheduleDeliver, + ScheduleDeliverInvite, + ScheduleDeliverReply, + ScheduleInbox, + ScheduleInboxUrl, + ScheduleOutbox, + ScheduleOutboxUrl, + ScheduleQueryFreebusy, + ScheduleResponse, + ScheduleSend, + ScheduleSendFreebusy, + ScheduleSendInvite, + ScheduleSendReply, + ScheduleTag, + Scope, + Score, + Searchable, + Segment, + Select, + Selectable, + Self_, + Set, + Shared, + Sortable, + Source, + Status, + SubactivitySet, + SubbaselineSet, + SuccessorSet, + SupportedAddressData, + SupportedCalendarComponentSet, + SupportedCalendarData, + SupportedCollation, + SupportedCollationSet, + SupportedFilter, + SupportedLiveProperty, + SupportedLivePropertySet, + SupportedMethod, + SupportedMethodSet, + SupportedPrivilege, + SupportedPrivilegeSet, + SupportedQueryGrammar, + SupportedQueryGrammarSet, + SupportedReport, + SupportedReportSet, + SupportedRscale, + SupportedRscaleSet, + Supportedlock, + SyncCollection, + SyncLevel, + SyncToken, + Target, + Temporary, + TextMatch, + TimeRange, + Timeout, + Timezone, + TimezoneId, + TimezoneServiceSet, + Transparent, + TypedLiteral, + Unauthenticated, + Unbind, + UnbindResponse, + Uncheckout, + UncheckoutResponse, + UniqueSchedulingObjectResource, + Unlock, + Unreserved, + Update, + UpdatePreview, + Updateredirectref, + UpdateredirectrefResponse, + Url, + Username, + ValidOrganizer, + ValidScheduleDefaultCalendarUrl, + ValidSchedulingMessage, + Version, + VersionControl, + VersionControlResponse, + VersionControlledBinding, + VersionControlledBindingSet, + VersionControlledConfiguration, + VersionHistory, + VersionHistoryCollectionSet, + VersionHistorySet, + VersionName, + VersionSet, + VersionTree, + Where, + Workspace, + WorkspaceCheckoutSet, + WorkspaceCollectionSet, + Write, + WriteAcl, + WriteContent, + WriteProperties, +} + +impl Element { + pub fn try_parse(value: &[u8]) -> Option<&Self> { + hashify::map!(value, + Element, + "abstract" => Element::Abstract, + "ace" => Element::Ace, + "acl" => Element::Acl, + "acl-principal-prop-set" => Element::AclPrincipalPropSet, + "acl-restrictions" => Element::AclRestrictions, + "activelock" => Element::Activelock, + "activity-checkout-set" => Element::ActivityCheckoutSet, + "activity-collection-set" => Element::ActivityCollectionSet, + "activity-set" => Element::ActivitySet, + "activity-version-set" => Element::ActivityVersionSet, + "add" => Element::Add, + "add-member" => Element::AddMember, + "added-version" => Element::AddedVersion, + "address-data" => Element::AddressData, + "address-data-type" => Element::AddressDataType, + "addressbook" => Element::Addressbook, + "addressbook-description" => Element::AddressbookDescription, + "addressbook-home-set" => Element::AddressbookHomeSet, + "addressbook-multiget" => Element::AddressbookMultiget, + "addressbook-query" => Element::AddressbookQuery, + "after" => Element::After, + "all" => Element::All, + "allcomp" => Element::Allcomp, + "allow-client-defined-uri" => Element::AllowClientDefinedUri, + "allowed-attendee-scheduling-object-change" => Element::AllowedAttendeeSchedulingObjectChange, + "allowed-organizer-scheduling-object-change" => Element::AllowedOrganizerSchedulingObjectChange, + "allowed-principal" => Element::AllowedPrincipal, + "allprop" => Element::Allprop, + "alternate-URI-set" => Element::AlternateUriSet, + "and" => Element::And, + "any-other-property" => Element::AnyOtherProperty, + "apply-to-version" => Element::ApplyToVersion, + "apply-to-principal-collection-set" => Element::ApplyToPrincipalCollectionSet, + "ascending" => Element::Ascending, + "authenticated" => Element::Authenticated, + "auto-merge-set" => Element::AutoMergeSet, + "auto-update" => Element::AutoUpdate, + "auto-version" => Element::AutoVersion, + "baseline" => Element::Baseline, + "baseline-collection" => Element::BaselineCollection, + "baseline-control" => Element::BaselineControl, + "baseline-control-response" => Element::BaselineControlResponse, + "baseline-controlled-collection" => Element::BaselineControlledCollection, + "baseline-controlled-collection-set" => Element::BaselineControlledCollectionSet, + "basicsearch" => Element::Basicsearch, + "basicsearchschema" => Element::Basicsearchschema, + "before" => Element::Before, + "bind" => Element::Bind, + "bind-response" => Element::BindResponse, + "binding-name" => Element::BindingName, + "calendar" => Element::Calendar, + "calendar-availability" => Element::CalendarAvailability, + "calendar-data" => Element::CalendarData, + "calendar-description" => Element::CalendarDescription, + "calendar-home-set" => Element::CalendarHomeSet, + "calendar-multiget" => Element::CalendarMultiget, + "calendar-query" => Element::CalendarQuery, + "calendar-timezone" => Element::CalendarTimezone, + "calendar-timezone-id" => Element::CalendarTimezoneId, + "calendar-user-address-set" => Element::CalendarUserAddressSet, + "calendar-user-type" => Element::CalendarUserType, + "caseless" => Element::Caseless, + "changed-version" => Element::ChangedVersion, + "checked-in" => Element::CheckedIn, + "checked-out" => Element::CheckedOut, + "checkin" => Element::Checkin, + "checkin-activity" => Element::CheckinActivity, + "checkin-fork" => Element::CheckinFork, + "checkin-response" => Element::CheckinResponse, + "checkout" => Element::Checkout, + "checkout-checkin" => Element::CheckoutCheckin, + "checkout-fork" => Element::CheckoutFork, + "checkout-response" => Element::CheckoutResponse, + "checkout-set" => Element::CheckoutSet, + "checkout-unlocked-checkin" => Element::CheckoutUnlockedCheckin, + "collection" => Element::Collection, + "comment" => Element::Comment, + "common-ancestor" => Element::CommonAncestor, + "comp" => Element::Comp, + "comp-filter" => Element::CompFilter, + "compare-baseline" => Element::CompareBaseline, + "compare-baseline-report" => Element::CompareBaselineReport, + "conflict-preview" => Element::ConflictPreview, + "contains" => Element::Contains, + "creationdate" => Element::Creationdate, + "creator-displayname" => Element::CreatorDisplayname, + "current-activity-set" => Element::CurrentActivitySet, + "current-user-principal" => Element::CurrentUserPrincipal, + "current-user-privilege-set" => Element::CurrentUserPrivilegeSet, + "current-workspace-set" => Element::CurrentWorkspaceSet, + "datatype" => Element::Datatype, + "default-calendar-needed" => Element::DefaultCalendarNeeded, + "deleted-version" => Element::DeletedVersion, + "deny" => Element::Deny, + "deny-before-grant" => Element::DenyBeforeGrant, + "depth" => Element::Depth, + "descending" => Element::Descending, + "description" => Element::Description, + "discouraged" => Element::Discouraged, + "displayname" => Element::Displayname, + "eq" => Element::Eq, + "error" => Element::Error, + "exclusive" => Element::Exclusive, + "expand" => Element::Expand, + "expand-property" => Element::ExpandProperty, + "filter" => Element::Filter, + "first" => Element::First, + "forbidden" => Element::Forbidden, + "fork-ok" => Element::ForkOk, + "free-busy-query" => Element::FreeBusyQuery, + "from" => Element::From, + "getcontentlanguage" => Element::Getcontentlanguage, + "getcontentlength" => Element::Getcontentlength, + "getcontenttype" => Element::Getcontenttype, + "getetag" => Element::Getetag, + "getctag" => Element::Getctag, + "getlastmodified" => Element::Getlastmodified, + "grammar" => Element::Grammar, + "grant" => Element::Grant, + "grant-only" => Element::GrantOnly, + "group" => Element::Group, + "group-member-set" => Element::GroupMemberSet, + "group-membership" => Element::GroupMembership, + "gt" => Element::Gt, + "gte" => Element::Gte, + "href" => Element::Href, + "ignore-preview" => Element::IgnorePreview, + "include" => Element::Include, + "include-versions" => Element::IncludeVersions, + "inherited" => Element::Inherited, + "inherited-acl-set" => Element::InheritedAclSet, + "invert" => Element::Invert, + "is-collection" => Element::IsCollection, + "is-defined" => Element::IsDefined, + "is-not-defined" => Element::IsNotDefined, + "keep-checked-out" => Element::KeepCheckedOut, + "label" => Element::Label, + "label-name" => Element::LabelName, + "label-name-set" => Element::LabelNameSet, + "label-response" => Element::LabelResponse, + "language-defined" => Element::LanguageDefined, + "language-matches" => Element::LanguageMatches, + "last" => Element::Last, + "latest-activity-version" => Element::LatestActivityVersion, + "latest-activity-version-report" => Element::LatestActivityVersionReport, + "like" => Element::Like, + "limit" => Element::Limit, + "limit-freebusy-set" => Element::LimitFreebusySet, + "limit-recurrence-set" => Element::LimitRecurrenceSet, + "limited-number-of-aces" => Element::LimitedNumberOfAces, + "literal" => Element::Literal, + "locate-by-history" => Element::LocateByHistory, + "location" => Element::Location, + "lock-token-submitted" => Element::LockTokenSubmitted, + "lockdiscovery" => Element::Lockdiscovery, + "locked-checkout" => Element::LockedCheckout, + "lockentry" => Element::Lockentry, + "lockinfo" => Element::Lockinfo, + "lockroot" => Element::Lockroot, + "lockscope" => Element::Lockscope, + "locktoken" => Element::Locktoken, + "locktype" => Element::Locktype, + "lt" => Element::Lt, + "lte" => Element::Lte, + "managed-attachments-server-URL" => Element::ManagedAttachmentsServerUrl, + "match" => Element::Match, + "max-attachment-size" => Element::MaxAttachmentSize, + "max-attachments-per-resource" => Element::MaxAttachmentsPerResource, + "max-attendees-per-instance" => Element::MaxAttendeesPerInstance, + "max-date-time" => Element::MaxDateTime, + "max-instances" => Element::MaxInstances, + "max-resource-size" => Element::MaxResourceSize, + "merge" => Element::Merge, + "merge-preview" => Element::MergePreview, + "merge-preview-report" => Element::MergePreviewReport, + "merge-set" => Element::MergeSet, + "min-date-time" => Element::MinDateTime, + "missing-required-principal" => Element::MissingRequiredPrincipal, + "mkactivity" => Element::Mkactivity, + "mkactivity-response" => Element::MkactivityResponse, + "mkcalendar" => Element::Mkcalendar, + "mkcalendar-response" => Element::MkcalendarResponse, + "mkcol" => Element::Mkcol, + "mkcol-response" => Element::MkcolResponse, + "mkredirectref" => Element::Mkredirectref, + "mkredirectref-response" => Element::MkredirectrefResponse, + "mkworkspace" => Element::Mkworkspace, + "mkworkspace-response" => Element::MkworkspaceResponse, + "mount" => Element::Mount, + "multistatus" => Element::Multistatus, + "need-privileges" => Element::NeedPrivileges, + "new" => Element::New, + "no-abstract" => Element::NoAbstract, + "no-ace-conflict" => Element::NoAceConflict, + "no-auto-merge" => Element::NoAutoMerge, + "no-checkout" => Element::NoCheckout, + "no-conflicting-lock" => Element::NoConflictingLock, + "no-inherited-ace-conflict" => Element::NoInheritedAceConflict, + "no-invert" => Element::NoInvert, + "no-protected-ace-conflict" => Element::NoProtectedAceConflict, + "no-uid-conflict" => Element::NoUidConflict, + "not" => Element::Not, + "not-supported-privilege" => Element::NotSupportedPrivilege, + "nresults" => Element::Nresults, + "opaque" => Element::Opaque, + "opdesc" => Element::Opdesc, + "open" => Element::Open, + "operand-literal" => Element::OperandLiteral, + "operand-property" => Element::OperandProperty, + "operand-typed-literal" => Element::OperandTypedLiteral, + "operators" => Element::Operators, + "options" => Element::Options, + "options-response" => Element::OptionsResponse, + "or" => Element::Or, + "order" => Element::Order, + "order-member" => Element::OrderMember, + "orderby" => Element::Orderby, + "ordering-type" => Element::OrderingType, + "orderpatch" => Element::Orderpatch, + "orderpatch-response" => Element::OrderpatchResponse, + "owner" => Element::Owner, + "param-filter" => Element::ParamFilter, + "parent" => Element::Parent, + "parent-set" => Element::ParentSet, + "permanent" => Element::Permanent, + "position" => Element::Position, + "predecessor-set" => Element::PredecessorSet, + "principal" => Element::Principal, + "principal-URL" => Element::PrincipalUrl, + "principal-address" => Element::PrincipalAddress, + "principal-collection-set" => Element::PrincipalCollectionSet, + "principal-match" => Element::PrincipalMatch, + "principal-property" => Element::PrincipalProperty, + "principal-property-search" => Element::PrincipalPropertySearch, + "principal-search-property" => Element::PrincipalSearchProperty, + "principal-search-property-set" => Element::PrincipalSearchPropertySet, + "privilege" => Element::Privilege, + "prop" => Element::Prop, + "prop-filter" => Element::PropFilter, + "propdesc" => Element::Propdesc, + "properties" => Element::Properties, + "property" => Element::Property, + "property-search" => Element::PropertySearch, + "propertyupdate" => Element::Propertyupdate, + "propfind" => Element::Propfind, + "propname" => Element::Propname, + "propstat" => Element::Propstat, + "protected" => Element::Protected, + "query-schema" => Element::QuerySchema, + "query-schema-discovery" => Element::QuerySchemaDiscovery, + "quota-available-bytes" => Element::QuotaAvailableBytes, + "quota-used-bytes" => Element::QuotaUsedBytes, + "read" => Element::Read, + "read-acl" => Element::ReadAcl, + "read-current-user-privilege-set" => Element::ReadCurrentUserPrivilegeSet, + "read-free-busy" => Element::ReadFreeBusy, + "rebind" => Element::Rebind, + "rebind-response" => Element::RebindResponse, + "recipient" => Element::Recipient, + "recognized-principal" => Element::RecognizedPrincipal, + "redirect-lifetime" => Element::RedirectLifetime, + "redirectref" => Element::Redirectref, + "reftarget" => Element::Reftarget, + "remove" => Element::Remove, + "report" => Element::Report, + "request-status" => Element::RequestStatus, + "required-principal" => Element::RequiredPrincipal, + "resource" => Element::Resource, + "resource-id" => Element::ResourceId, + "resourcetype" => Element::Resourcetype, + "response" => Element::Response, + "responsedescription" => Element::Responsedescription, + "root-version" => Element::RootVersion, + "same-organizer-in-all-components" => Element::SameOrganizerInAllComponents, + "schedule-calendar-transp" => Element::ScheduleCalendarTransp, + "schedule-default-calendar-URL" => Element::ScheduleDefaultCalendarUrl, + "schedule-deliver" => Element::ScheduleDeliver, + "schedule-deliver-invite" => Element::ScheduleDeliverInvite, + "schedule-deliver-reply" => Element::ScheduleDeliverReply, + "schedule-inbox" => Element::ScheduleInbox, + "schedule-inbox-URL" => Element::ScheduleInboxUrl, + "schedule-outbox" => Element::ScheduleOutbox, + "schedule-outbox-URL" => Element::ScheduleOutboxUrl, + "schedule-query-freebusy" => Element::ScheduleQueryFreebusy, + "schedule-response" => Element::ScheduleResponse, + "schedule-send" => Element::ScheduleSend, + "schedule-send-freebusy" => Element::ScheduleSendFreebusy, + "schedule-send-invite" => Element::ScheduleSendInvite, + "schedule-send-reply" => Element::ScheduleSendReply, + "schedule-tag" => Element::ScheduleTag, + "scope" => Element::Scope, + "score" => Element::Score, + "searchable" => Element::Searchable, + "segment" => Element::Segment, + "select" => Element::Select, + "selectable" => Element::Selectable, + "self" => Element::Self_, + "set" => Element::Set, + "shared" => Element::Shared, + "sortable" => Element::Sortable, + "source" => Element::Source, + "status" => Element::Status, + "subactivity-set" => Element::SubactivitySet, + "subbaseline-set" => Element::SubbaselineSet, + "successor-set" => Element::SuccessorSet, + "supported-address-data" => Element::SupportedAddressData, + "supported-calendar-component-set" => Element::SupportedCalendarComponentSet, + "supported-calendar-data" => Element::SupportedCalendarData, + "supported-collation" => Element::SupportedCollation, + "supported-collation-set" => Element::SupportedCollationSet, + "supported-filter" => Element::SupportedFilter, + "supported-live-property" => Element::SupportedLiveProperty, + "supported-live-property-set" => Element::SupportedLivePropertySet, + "supported-method" => Element::SupportedMethod, + "supported-method-set" => Element::SupportedMethodSet, + "supported-privilege" => Element::SupportedPrivilege, + "supported-privilege-set" => Element::SupportedPrivilegeSet, + "supported-query-grammar" => Element::SupportedQueryGrammar, + "supported-query-grammar-set" => Element::SupportedQueryGrammarSet, + "supported-report" => Element::SupportedReport, + "supported-report-set" => Element::SupportedReportSet, + "supported-rscale" => Element::SupportedRscale, + "supported-rscale-set" => Element::SupportedRscaleSet, + "supportedlock" => Element::Supportedlock, + "sync-collection" => Element::SyncCollection, + "sync-level" => Element::SyncLevel, + "sync-token" => Element::SyncToken, + "target" => Element::Target, + "temporary" => Element::Temporary, + "text-match" => Element::TextMatch, + "time-range" => Element::TimeRange, + "timeout" => Element::Timeout, + "timezone" => Element::Timezone, + "timezone-id" => Element::TimezoneId, + "timezone-service-set" => Element::TimezoneServiceSet, + "transparent" => Element::Transparent, + "typed-literal" => Element::TypedLiteral, + "unauthenticated" => Element::Unauthenticated, + "unbind" => Element::Unbind, + "unbind-response" => Element::UnbindResponse, + "uncheckout" => Element::Uncheckout, + "uncheckout-response" => Element::UncheckoutResponse, + "unique-scheduling-object-resource" => Element::UniqueSchedulingObjectResource, + "unlock" => Element::Unlock, + "unreserved" => Element::Unreserved, + "update" => Element::Update, + "update-preview" => Element::UpdatePreview, + "updateredirectref" => Element::Updateredirectref, + "updateredirectref-response" => Element::UpdateredirectrefResponse, + "url" => Element::Url, + "username" => Element::Username, + "valid-organizer" => Element::ValidOrganizer, + "valid-schedule-default-calendar-URL" => Element::ValidScheduleDefaultCalendarUrl, + "valid-scheduling-message" => Element::ValidSchedulingMessage, + "version" => Element::Version, + "version-control" => Element::VersionControl, + "version-control-response" => Element::VersionControlResponse, + "version-controlled-binding" => Element::VersionControlledBinding, + "version-controlled-binding-set" => Element::VersionControlledBindingSet, + "version-controlled-configuration" => Element::VersionControlledConfiguration, + "version-history" => Element::VersionHistory, + "version-history-collection-set" => Element::VersionHistoryCollectionSet, + "version-history-set" => Element::VersionHistorySet, + "version-name" => Element::VersionName, + "version-set" => Element::VersionSet, + "version-tree" => Element::VersionTree, + "where" => Element::Where, + "workspace" => Element::Workspace, + "workspace-checkout-set" => Element::WorkspaceCheckoutSet, + "workspace-collection-set" => Element::WorkspaceCollectionSet, + "write" => Element::Write, + "write-acl" => Element::WriteAcl, + "write-content" => Element::WriteContent, + "write-properties" => Element::WriteProperties, + ) + } +} + +impl AsRef for Element { + fn as_ref(&self) -> &str { + match self { + Element::Abstract => "abstract", + Element::Ace => "ace", + Element::Acl => "acl", + Element::AclPrincipalPropSet => "acl-principal-prop-set", + Element::AclRestrictions => "acl-restrictions", + Element::Activelock => "activelock", + Element::ActivityCheckoutSet => "activity-checkout-set", + Element::ActivityCollectionSet => "activity-collection-set", + Element::ActivitySet => "activity-set", + Element::ActivityVersionSet => "activity-version-set", + Element::Add => "add", + Element::AddMember => "add-member", + Element::AddedVersion => "added-version", + Element::AddressData => "address-data", + Element::AddressDataType => "address-data-type", + Element::Addressbook => "addressbook", + Element::AddressbookDescription => "addressbook-description", + Element::AddressbookHomeSet => "addressbook-home-set", + Element::AddressbookMultiget => "addressbook-multiget", + Element::AddressbookQuery => "addressbook-query", + Element::After => "after", + Element::All => "all", + Element::Allcomp => "allcomp", + Element::AllowClientDefinedUri => "allow-client-defined-uri", + Element::AllowedAttendeeSchedulingObjectChange => { + "allowed-attendee-scheduling-object-change" + } + Element::AllowedOrganizerSchedulingObjectChange => { + "allowed-organizer-scheduling-object-change" + } + Element::AllowedPrincipal => "allowed-principal", + Element::Allprop => "allprop", + Element::AlternateUriSet => "alternate-URI-set", + Element::And => "and", + Element::AnyOtherProperty => "any-other-property", + Element::ApplyToVersion => "apply-to-version", + Element::ApplyToPrincipalCollectionSet => "apply-to-principal-collection-set", + Element::Ascending => "ascending", + Element::Authenticated => "authenticated", + Element::AutoMergeSet => "auto-merge-set", + Element::AutoUpdate => "auto-update", + Element::AutoVersion => "auto-version", + Element::Baseline => "baseline", + Element::BaselineCollection => "baseline-collection", + Element::BaselineControl => "baseline-control", + Element::BaselineControlResponse => "baseline-control-response", + Element::BaselineControlledCollection => "baseline-controlled-collection", + Element::BaselineControlledCollectionSet => "baseline-controlled-collection-set", + Element::Basicsearch => "basicsearch", + Element::Basicsearchschema => "basicsearchschema", + Element::Before => "before", + Element::Bind => "bind", + Element::BindResponse => "bind-response", + Element::BindingName => "binding-name", + Element::Calendar => "calendar", + Element::CalendarAvailability => "calendar-availability", + Element::CalendarData => "calendar-data", + Element::CalendarDescription => "calendar-description", + Element::CalendarHomeSet => "calendar-home-set", + Element::CalendarMultiget => "calendar-multiget", + Element::CalendarQuery => "calendar-query", + Element::CalendarTimezone => "calendar-timezone", + Element::CalendarTimezoneId => "calendar-timezone-id", + Element::CalendarUserAddressSet => "calendar-user-address-set", + Element::CalendarUserType => "calendar-user-type", + Element::Caseless => "caseless", + Element::ChangedVersion => "changed-version", + Element::CheckedIn => "checked-in", + Element::CheckedOut => "checked-out", + Element::Checkin => "checkin", + Element::CheckinActivity => "checkin-activity", + Element::CheckinFork => "checkin-fork", + Element::CheckinResponse => "checkin-response", + Element::Checkout => "checkout", + Element::CheckoutCheckin => "checkout-checkin", + Element::CheckoutFork => "checkout-fork", + Element::CheckoutResponse => "checkout-response", + Element::CheckoutSet => "checkout-set", + Element::CheckoutUnlockedCheckin => "checkout-unlocked-checkin", + Element::Collection => "collection", + Element::Comment => "comment", + Element::CommonAncestor => "common-ancestor", + Element::Comp => "comp", + Element::CompFilter => "comp-filter", + Element::CompareBaseline => "compare-baseline", + Element::CompareBaselineReport => "compare-baseline-report", + Element::ConflictPreview => "conflict-preview", + Element::Contains => "contains", + Element::Creationdate => "creationdate", + Element::CreatorDisplayname => "creator-displayname", + Element::CurrentActivitySet => "current-activity-set", + Element::CurrentUserPrincipal => "current-user-principal", + Element::CurrentUserPrivilegeSet => "current-user-privilege-set", + Element::CurrentWorkspaceSet => "current-workspace-set", + Element::Datatype => "datatype", + Element::DefaultCalendarNeeded => "default-calendar-needed", + Element::DeletedVersion => "deleted-version", + Element::Deny => "deny", + Element::DenyBeforeGrant => "deny-before-grant", + Element::Depth => "depth", + Element::Descending => "descending", + Element::Description => "description", + Element::Discouraged => "discouraged", + Element::Displayname => "displayname", + Element::Eq => "eq", + Element::Error => "error", + Element::Exclusive => "exclusive", + Element::Expand => "expand", + Element::ExpandProperty => "expand-property", + Element::Filter => "filter", + Element::First => "first", + Element::Forbidden => "forbidden", + Element::ForkOk => "fork-ok", + Element::FreeBusyQuery => "free-busy-query", + Element::From => "from", + Element::Getcontentlanguage => "getcontentlanguage", + Element::Getcontentlength => "getcontentlength", + Element::Getcontenttype => "getcontenttype", + Element::Getetag => "getetag", + Element::Getctag => "getctag", + Element::Getlastmodified => "getlastmodified", + Element::Grammar => "grammar", + Element::Grant => "grant", + Element::GrantOnly => "grant-only", + Element::Group => "group", + Element::GroupMemberSet => "group-member-set", + Element::GroupMembership => "group-membership", + Element::Gt => "gt", + Element::Gte => "gte", + Element::Href => "href", + Element::IgnorePreview => "ignore-preview", + Element::Include => "include", + Element::IncludeVersions => "include-versions", + Element::Inherited => "inherited", + Element::InheritedAclSet => "inherited-acl-set", + Element::Invert => "invert", + Element::IsCollection => "is-collection", + Element::IsDefined => "is-defined", + Element::IsNotDefined => "is-not-defined", + Element::KeepCheckedOut => "keep-checked-out", + Element::Label => "label", + Element::LabelName => "label-name", + Element::LabelNameSet => "label-name-set", + Element::LabelResponse => "label-response", + Element::LanguageDefined => "language-defined", + Element::LanguageMatches => "language-matches", + Element::Last => "last", + Element::LatestActivityVersion => "latest-activity-version", + Element::LatestActivityVersionReport => "latest-activity-version-report", + Element::Like => "like", + Element::Limit => "limit", + Element::LimitFreebusySet => "limit-freebusy-set", + Element::LimitRecurrenceSet => "limit-recurrence-set", + Element::LimitedNumberOfAces => "limited-number-of-aces", + Element::Literal => "literal", + Element::LocateByHistory => "locate-by-history", + Element::Location => "location", + Element::LockTokenSubmitted => "lock-token-submitted", + Element::Lockdiscovery => "lockdiscovery", + Element::LockedCheckout => "locked-checkout", + Element::Lockentry => "lockentry", + Element::Lockinfo => "lockinfo", + Element::Lockroot => "lockroot", + Element::Lockscope => "lockscope", + Element::Locktoken => "locktoken", + Element::Locktype => "locktype", + Element::Lt => "lt", + Element::Lte => "lte", + Element::ManagedAttachmentsServerUrl => "managed-attachments-server-URL", + Element::Match => "match", + Element::MaxAttachmentSize => "max-attachment-size", + Element::MaxAttachmentsPerResource => "max-attachments-per-resource", + Element::MaxAttendeesPerInstance => "max-attendees-per-instance", + Element::MaxDateTime => "max-date-time", + Element::MaxInstances => "max-instances", + Element::MaxResourceSize => "max-resource-size", + Element::Merge => "merge", + Element::MergePreview => "merge-preview", + Element::MergePreviewReport => "merge-preview-report", + Element::MergeSet => "merge-set", + Element::MinDateTime => "min-date-time", + Element::MissingRequiredPrincipal => "missing-required-principal", + Element::Mkactivity => "mkactivity", + Element::MkactivityResponse => "mkactivity-response", + Element::Mkcalendar => "mkcalendar", + Element::MkcalendarResponse => "mkcalendar-response", + Element::Mkcol => "mkcol", + Element::MkcolResponse => "mkcol-response", + Element::Mkredirectref => "mkredirectref", + Element::MkredirectrefResponse => "mkredirectref-response", + Element::Mkworkspace => "mkworkspace", + Element::MkworkspaceResponse => "mkworkspace-response", + Element::Mount => "mount", + Element::Multistatus => "multistatus", + Element::NeedPrivileges => "need-privileges", + Element::New => "new", + Element::NoAbstract => "no-abstract", + Element::NoAceConflict => "no-ace-conflict", + Element::NoAutoMerge => "no-auto-merge", + Element::NoCheckout => "no-checkout", + Element::NoConflictingLock => "no-conflicting-lock", + Element::NoInheritedAceConflict => "no-inherited-ace-conflict", + Element::NoInvert => "no-invert", + Element::NoProtectedAceConflict => "no-protected-ace-conflict", + Element::NoUidConflict => "no-uid-conflict", + Element::Not => "not", + Element::NotSupportedPrivilege => "not-supported-privilege", + Element::Nresults => "nresults", + Element::Opaque => "opaque", + Element::Opdesc => "opdesc", + Element::Open => "open", + Element::OperandLiteral => "operand-literal", + Element::OperandProperty => "operand-property", + Element::OperandTypedLiteral => "operand-typed-literal", + Element::Operators => "operators", + Element::Options => "options", + Element::OptionsResponse => "options-response", + Element::Or => "or", + Element::Order => "order", + Element::OrderMember => "order-member", + Element::Orderby => "orderby", + Element::OrderingType => "ordering-type", + Element::Orderpatch => "orderpatch", + Element::OrderpatchResponse => "orderpatch-response", + Element::Owner => "owner", + Element::ParamFilter => "param-filter", + Element::Parent => "parent", + Element::ParentSet => "parent-set", + Element::Permanent => "permanent", + Element::Position => "position", + Element::PredecessorSet => "predecessor-set", + Element::Principal => "principal", + Element::PrincipalUrl => "principal-URL", + Element::PrincipalAddress => "principal-address", + Element::PrincipalCollectionSet => "principal-collection-set", + Element::PrincipalMatch => "principal-match", + Element::PrincipalProperty => "principal-property", + Element::PrincipalPropertySearch => "principal-property-search", + Element::PrincipalSearchProperty => "principal-search-property", + Element::PrincipalSearchPropertySet => "principal-search-property-set", + Element::Privilege => "privilege", + Element::Prop => "prop", + Element::PropFilter => "prop-filter", + Element::Propdesc => "propdesc", + Element::Properties => "properties", + Element::Property => "property", + Element::PropertySearch => "property-search", + Element::Propertyupdate => "propertyupdate", + Element::Propfind => "propfind", + Element::Propname => "propname", + Element::Propstat => "propstat", + Element::Protected => "protected", + Element::QuerySchema => "query-schema", + Element::QuerySchemaDiscovery => "query-schema-discovery", + Element::QuotaAvailableBytes => "quota-available-bytes", + Element::QuotaUsedBytes => "quota-used-bytes", + Element::Read => "read", + Element::ReadAcl => "read-acl", + Element::ReadCurrentUserPrivilegeSet => "read-current-user-privilege-set", + Element::ReadFreeBusy => "read-free-busy", + Element::Rebind => "rebind", + Element::RebindResponse => "rebind-response", + Element::Recipient => "recipient", + Element::RecognizedPrincipal => "recognized-principal", + Element::RedirectLifetime => "redirect-lifetime", + Element::Redirectref => "redirectref", + Element::Reftarget => "reftarget", + Element::Remove => "remove", + Element::Report => "report", + Element::RequestStatus => "request-status", + Element::RequiredPrincipal => "required-principal", + Element::Resource => "resource", + Element::ResourceId => "resource-id", + Element::Resourcetype => "resourcetype", + Element::Response => "response", + Element::Responsedescription => "responsedescription", + Element::RootVersion => "root-version", + Element::SameOrganizerInAllComponents => "same-organizer-in-all-components", + Element::ScheduleCalendarTransp => "schedule-calendar-transp", + Element::ScheduleDefaultCalendarUrl => "schedule-default-calendar-URL", + Element::ScheduleDeliver => "schedule-deliver", + Element::ScheduleDeliverInvite => "schedule-deliver-invite", + Element::ScheduleDeliverReply => "schedule-deliver-reply", + Element::ScheduleInbox => "schedule-inbox", + Element::ScheduleInboxUrl => "schedule-inbox-URL", + Element::ScheduleOutbox => "schedule-outbox", + Element::ScheduleOutboxUrl => "schedule-outbox-URL", + Element::ScheduleQueryFreebusy => "schedule-query-freebusy", + Element::ScheduleResponse => "schedule-response", + Element::ScheduleSend => "schedule-send", + Element::ScheduleSendFreebusy => "schedule-send-freebusy", + Element::ScheduleSendInvite => "schedule-send-invite", + Element::ScheduleSendReply => "schedule-send-reply", + Element::ScheduleTag => "schedule-tag", + Element::Scope => "scope", + Element::Score => "score", + Element::Searchable => "searchable", + Element::Segment => "segment", + Element::Select => "select", + Element::Selectable => "selectable", + Element::Self_ => "self", + Element::Set => "set", + Element::Shared => "shared", + Element::Sortable => "sortable", + Element::Source => "source", + Element::Status => "status", + Element::SubactivitySet => "subactivity-set", + Element::SubbaselineSet => "subbaseline-set", + Element::SuccessorSet => "successor-set", + Element::SupportedAddressData => "supported-address-data", + Element::SupportedCalendarComponentSet => "supported-calendar-component-set", + Element::SupportedCalendarData => "supported-calendar-data", + Element::SupportedCollation => "supported-collation", + Element::SupportedCollationSet => "supported-collation-set", + Element::SupportedFilter => "supported-filter", + Element::SupportedLiveProperty => "supported-live-property", + Element::SupportedLivePropertySet => "supported-live-property-set", + Element::SupportedMethod => "supported-method", + Element::SupportedMethodSet => "supported-method-set", + Element::SupportedPrivilege => "supported-privilege", + Element::SupportedPrivilegeSet => "supported-privilege-set", + Element::SupportedQueryGrammar => "supported-query-grammar", + Element::SupportedQueryGrammarSet => "supported-query-grammar-set", + Element::SupportedReport => "supported-report", + Element::SupportedReportSet => "supported-report-set", + Element::SupportedRscale => "supported-rscale", + Element::SupportedRscaleSet => "supported-rscale-set", + Element::Supportedlock => "supportedlock", + Element::SyncCollection => "sync-collection", + Element::SyncLevel => "sync-level", + Element::SyncToken => "sync-token", + Element::Target => "target", + Element::Temporary => "temporary", + Element::TextMatch => "text-match", + Element::TimeRange => "time-range", + Element::Timeout => "timeout", + Element::Timezone => "timezone", + Element::TimezoneId => "timezone-id", + Element::TimezoneServiceSet => "timezone-service-set", + Element::Transparent => "transparent", + Element::TypedLiteral => "typed-literal", + Element::Unauthenticated => "unauthenticated", + Element::Unbind => "unbind", + Element::UnbindResponse => "unbind-response", + Element::Uncheckout => "uncheckout", + Element::UncheckoutResponse => "uncheckout-response", + Element::UniqueSchedulingObjectResource => "unique-scheduling-object-resource", + Element::Unlock => "unlock", + Element::Unreserved => "unreserved", + Element::Update => "update", + Element::UpdatePreview => "update-preview", + Element::Updateredirectref => "updateredirectref", + Element::UpdateredirectrefResponse => "updateredirectref-response", + Element::Url => "url", + Element::Username => "username", + Element::ValidOrganizer => "valid-organizer", + Element::ValidScheduleDefaultCalendarUrl => "valid-schedule-default-calendar-URL", + Element::ValidSchedulingMessage => "valid-scheduling-message", + Element::Version => "version", + Element::VersionControl => "version-control", + Element::VersionControlResponse => "version-control-response", + Element::VersionControlledBinding => "version-controlled-binding", + Element::VersionControlledBindingSet => "version-controlled-binding-set", + Element::VersionControlledConfiguration => "version-controlled-configuration", + Element::VersionHistory => "version-history", + Element::VersionHistoryCollectionSet => "version-history-collection-set", + Element::VersionHistorySet => "version-history-set", + Element::VersionName => "version-name", + Element::VersionSet => "version-set", + Element::VersionTree => "version-tree", + Element::Where => "where", + Element::Workspace => "workspace", + Element::WorkspaceCheckoutSet => "workspace-checkout-set", + Element::WorkspaceCollectionSet => "workspace-collection-set", + Element::Write => "write", + Element::WriteAcl => "write-acl", + Element::WriteContent => "write-content", + Element::WriteProperties => "write-properties", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attribute { + Caseless(bool), + XsiType(XsiType), + AllowPCData(bool), + Name(T), + Namespace(Namespace), + ContentType(String), + XmlLanguage(String), + Version(String), + NoValue(bool), + TestAllOf(bool), + MatchType(MatchType), + NegateCondition(bool), + Collation(Collation), + Start(T), + End(T), + Unknown { param: String, value: String }, +} + +pub trait AttributeValue { + fn from_str(s: &str) -> Option + where + Self: Sized; +} + +impl Attribute { + pub fn from_param(param: &[u8], value: Cow<'_, str>) -> Option> { + hashify::fnc_map!(param, + "caseless" => { + if let Some(b) = YesNo::from_str(value.as_ref()) { + return Some(Attribute::Caseless(b)); + } + }, + "xsi:type" => { + return Some(Attribute::XsiType(XsiType::from_str(value.as_ref()).unwrap_or(XsiType::Unsupported))); + }, + "allow-pcdata" => { + if let Some(b) = YesNo::from_str(value.as_ref()) { + return Some(Attribute::AllowPCData(b)); + } + }, + "novalue" => { + if let Some(b) = YesNo::from_str(value.as_ref()) { + return Some(Attribute::NoValue(b)); + } + }, + "negate-condition" => { + if let Some(b) = YesNo::from_str(value.as_ref()) { + return Some(Attribute::NegateCondition(b)); + } + }, + "name" => { + if let Some(value) = T::from_str(value.as_ref()) { + return Some(Attribute::Name(value)); + } + }, + "namespace" => { + if let Some(ns) = Namespace::try_parse(value.as_bytes()) { + return Some(Attribute::Namespace(ns)); + } + }, + "content-type" => { + return Some(Attribute::ContentType(value.into_owned())); + }, + "version" => { + return Some(Attribute::Version(value.into_owned())); + }, + "test" => { + return Some(Attribute::TestAllOf(value.eq("allof"))); + }, + "match-type" => { + if let Some(mt) = MatchType::try_parse(value.as_ref()) { + return Some(Attribute::MatchType(mt)); + } + }, + "collation" => { + if let Some(c) = Collation::try_parse(value.as_ref()) { + return Some(Attribute::Collation(c)); + } + }, + "start" => { + if let Some(value) = T::from_str(value.as_ref()) { + return Some(Attribute::Start(value)); + } + }, + "end" => { + if let Some(value) = T::from_str(value.as_ref()) { + return Some(Attribute::End(value)); + } + }, + "xml:lang" => { + return Some(Attribute::XmlLanguage(value.into_owned())); + }, + "xmlns" => { + return None; + }, + _ => { + if param.starts_with(b"xmlns:") { + return None; + } + } + ); + + Some(Attribute::Unknown { + param: String::from_utf8_lossy(param).into_owned(), + value: value.into_owned(), + }) + } +} + +impl AttributeValue for String { + fn from_str(s: &str) -> Option { + Some(s.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Collation { + AsciiNumeric, + AsciiCasemap, + Octet, + UnicodeCasemap, +} + +impl Collation { + pub fn try_parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "i;ascii-numeric" => Collation::AsciiNumeric, + "i;ascii-casemap" => Collation::AsciiCasemap, + "i;octet" => Collation::Octet, + "i;unicode-casemap" => Collation::UnicodeCasemap, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + Collation::AsciiNumeric => "i;ascii-numeric", + Collation::AsciiCasemap => "i;ascii-casemap", + Collation::Octet => "i;octet", + Collation::UnicodeCasemap => "i;unicode-casemap", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum MatchType { + Equals, + Contains, + StartsWith, + EndsWith, +} + +impl MatchType { + pub fn try_parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "equals" => MatchType::Equals, + "contains" => MatchType::Contains, + "starts-with" => MatchType::StartsWith, + "ends-with" => MatchType::EndsWith, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum XsiType { + String, + Boolean, + Decimal, + Float, + Double, + Duration, + DateTime, + Time, + Date, + GYearMonth, + GYear, + GMonthDay, + GDay, + GMonth, + HexBinary, + Base64Binary, + AnyUri, + QName, + Notation, + Unsupported, +} + +impl XsiType { + fn from_str(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "xs:string" => XsiType::String, + "xs:boolean" => XsiType::Boolean, + "xs:decimal" => XsiType::Decimal, + "xs:float" => XsiType::Float, + "xs:double" => XsiType::Double, + "xs:duration" => XsiType::Duration, + "xs:dateTime" => XsiType::DateTime, + "xs:time" => XsiType::Time, + "xs:date" => XsiType::Date, + "xs:gYearMonth" => XsiType::GYearMonth, + "xs:gYear" => XsiType::GYear, + "xs:gMonthDay" => XsiType::GMonthDay, + "xs:gDay" => XsiType::GDay, + "xs:gMonth" => XsiType::GMonth, + "xs:hexBinary" => XsiType::HexBinary, + "xs:base64Binary" => XsiType::Base64Binary, + "xs:anyURI" => XsiType::AnyUri, + "xs:QName" => XsiType::QName, + "xs:NOTATION" => XsiType::Notation, + ) + } +} + +struct YesNo; + +impl YesNo { + fn from_str(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "yes" => true, + "no" => false, + ) + } +} + +impl TextMatch { + pub fn matches(&self, text: &str) -> bool { + match self.collation { + Collation::Octet => { + (match self.match_type { + MatchType::Equals => text == self.value, + MatchType::Contains => text.contains(&self.value), + MatchType::StartsWith => text.starts_with(&self.value), + MatchType::EndsWith => text.ends_with(&self.value), + }) ^ self.negate + } + _ => { + (match self.match_type { + MatchType::Equals => text.to_lowercase() == self.value.to_lowercase(), + MatchType::Contains => text.to_lowercase().contains(&self.value.to_lowercase()), + MatchType::StartsWith => { + text.to_lowercase().starts_with(&self.value.to_lowercase()) + } + MatchType::EndsWith => { + text.to_lowercase().ends_with(&self.value.to_lowercase()) + } + }) ^ self.negate + } + } + } +} diff --git a/crates/dav-proto/src/schema/property.rs b/crates/dav-proto/src/schema/property.rs new file mode 100644 index 0000000..d3d3a1a --- /dev/null +++ b/crates/dav-proto/src/schema/property.rs @@ -0,0 +1,441 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Collation, Namespace, + request::DavPropertyValue, + response::{Ace, AclRestrictions, Href, List, Response, SupportedPrivilege}, +}; +use crate::{Depth, Timeout}; +use calcard::{ + icalendar::{ICalendar, ICalendarComponentType, ICalendarProperty}, + vcard::{VCard, VCardProperty, VCardVersion}, +}; +use types::{ + TimeRange, + dead_property::{DeadElementTag, DeadProperty}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum DavProperty { + WebDav(WebDavProperty), + CardDav(CardDavProperty), + CalDav(CalDavProperty), + Principal(PrincipalProperty), + DeadProperty(DeadElementTag), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum WebDavProperty { + CreationDate, + DisplayName, + GetContentLanguage, + GetContentLength, + GetContentType, + GetETag, + GetLastModified, + ResourceType, + LockDiscovery, + SupportedLock, + SupportedReportSet, + CurrentUserPrincipal, + // Quota properties + QuotaAvailableBytes, + QuotaUsedBytes, + // Sync properties + SyncToken, + // ACL properties (all protected) + Owner, + Group, + SupportedPrivilegeSet, + CurrentUserPrivilegeSet, + Acl, + AclRestrictions, + InheritedAclSet, + PrincipalCollectionSet, + // Apple proprietary properties + GetCTag, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum CardDavProperty { + AddressbookDescription, + SupportedAddressData, + SupportedCollationSet, + MaxResourceSize, + AddressData { + properties: Vec, + #[cfg_attr(test, serde(skip))] + version: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct CardDavPropertyName { + pub group: Option, + pub name: VCardProperty, + pub no_value: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum CalDavProperty { + CalendarDescription, + CalendarTimezone, + SupportedCalendarComponentSet, + SupportedCalendarData, + SupportedCollationSet, + MaxResourceSize, + MinDateTime, + MaxDateTime, + MaxInstances, + MaxAttendeesPerInstance, + CalendarData(CalendarData), + TimezoneServiceSet, + TimezoneId, + ScheduleDefaultCalendarURL, + ScheduleTag, + ScheduleCalendarTransp, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum PrincipalProperty { + AlternateURISet, + PrincipalURL, + GroupMemberSet, + GroupMembership, + CalendarHomeSet, + AddressbookHomeSet, + PrincipalAddress, + CalendarUserAddressSet, + CalendarUserType, + ScheduleInboxURL, + ScheduleOutboxURL, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct CalendarData { + pub properties: Vec, + pub expand: Option, + pub limit_recurrence: Option, + pub limit_freebusy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct CalDavPropertyName { + pub component: Option, + pub name: Option, + pub no_value: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct Rfc1123DateTime(pub(crate) i64); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum DavValue { + Timestamp(i64), + Rfc1123Date(Rfc1123DateTime), + Uint64(u64), + String(String), + CData(String), + ResourceTypes(List), + ActiveLocks(List), + LockEntries(List), + ReportSets(List), + ICalendar(ICalendar), + VCard(VCard), + Components(List), + Collations(List), + PrivilegeSet(List), + Privileges(List), + Href(List), + Acl(List), + AclRestrictions(AclRestrictions), + Response(Box), + DeadProperty(DeadProperty), + SupportedAddressData, + SupportedCalendarData, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum ReportSet { + SyncCollection, + ExpandProperty, + AddressbookQuery, + AddressbookMultiGet, + CalendarQuery, + CalendarMultiGet, + FreeBusyQuery, + AclPrincipalPropSet, + PrincipalMatch, + PrincipalPropertySearch, + PrincipalSearchPropertySet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Comp(pub ICalendarComponentType); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct SupportedCollation { + pub collation: Collation, + pub namespace: Namespace, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum ResourceType { + Collection, + Principal, + AddressBook, + Calendar, + ScheduleInbox, + ScheduleOutbox, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct LockDiscovery(pub List); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct ActiveLock { + pub lock_scope: LockScope, + pub lock_type: LockType, + pub depth: Depth, + pub owner: Option, + pub timeout: Timeout, + pub lock_token: Option, + pub lock_root: Href, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct SupportedLock(pub List); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct LockEntry { + pub lock_scope: LockScope, + pub lock_type: LockType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum LockType { + Write, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum LockScope { + Exclusive, + Shared, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Privilege { + Read, + Write, + WriteProperties, + WriteContent, + Unlock, + ReadAcl, + ReadCurrentUserPrivilegeSet, + WriteAcl, + Bind, + Unbind, + All, + ReadFreeBusy, + ScheduleDeliver, + ScheduleDeliverInvite, + ScheduleDeliverReply, + ScheduleQueryFreeBusy, + ScheduleSend, + ScheduleSendInvite, + ScheduleSendReply, + ScheduleSendFreeBusy, +} + +impl Privilege { + pub fn all(is_calendar: bool) -> Vec { + if is_calendar { + vec![ + Privilege::All, + Privilege::Read, + Privilege::Write, + Privilege::WriteProperties, + Privilege::WriteContent, + Privilege::Unlock, + Privilege::ReadAcl, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::WriteAcl, + Privilege::Bind, + Privilege::Unbind, + Privilege::ReadFreeBusy, + ] + } else { + vec![ + Privilege::All, + Privilege::Read, + Privilege::Write, + Privilege::WriteProperties, + Privilege::WriteContent, + Privilege::Unlock, + Privilege::ReadAcl, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::WriteAcl, + Privilege::Bind, + Privilege::Unbind, + ] + } + } + + pub fn scheduling(is_inbox: bool, is_owner: bool) -> Vec { + let mut privileges = if is_inbox { + vec![ + Privilege::Read, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::ScheduleDeliver, + Privilege::ScheduleDeliverInvite, + Privilege::ScheduleDeliverReply, + Privilege::ScheduleQueryFreeBusy, + ] + } else { + vec![ + Privilege::Read, + Privilege::ReadCurrentUserPrivilegeSet, + Privilege::ScheduleSend, + Privilege::ScheduleSendInvite, + Privilege::ScheduleSendReply, + Privilege::ScheduleSendFreeBusy, + ] + }; + + if is_owner { + privileges.extend([ + Privilege::All, + Privilege::Write, + Privilege::WriteProperties, + Privilege::WriteContent, + Privilege::ReadAcl, + Privilege::WriteAcl, + ]); + } + + privileges + } +} + +impl From for DavPropertyValue { + fn from(value: DavProperty) -> Self { + DavPropertyValue { + property: value, + value: DavValue::Null, + } + } +} + +impl Rfc1123DateTime { + pub fn new(timestamp: i64) -> Self { + Self(timestamp) + } +} + +impl DavProperty { + pub const ALL_PROPS: [DavProperty; 11] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), + ]; + + pub fn is_all_prop(&self) -> bool { + matches!( + self, + DavProperty::WebDav(WebDavProperty::CreationDate) + | DavProperty::WebDav(WebDavProperty::DisplayName) + | DavProperty::WebDav(WebDavProperty::GetETag) + | DavProperty::WebDav(WebDavProperty::GetLastModified) + | DavProperty::WebDav(WebDavProperty::ResourceType) + | DavProperty::WebDav(WebDavProperty::LockDiscovery) + | DavProperty::WebDav(WebDavProperty::SupportedLock) + | DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal) + | DavProperty::WebDav(WebDavProperty::GetContentLanguage) + | DavProperty::WebDav(WebDavProperty::GetContentLength) + | DavProperty::WebDav(WebDavProperty::GetContentType) + | DavProperty::DeadProperty(_) + ) + } +} + +impl ReportSet { + pub fn calendar() -> Vec { + vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ReportSet::ExpandProperty, + ReportSet::CalendarQuery, + ReportSet::CalendarMultiGet, + ReportSet::FreeBusyQuery, + ] + } + + pub fn addressbook() -> Vec { + vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ReportSet::ExpandProperty, + ReportSet::AddressbookQuery, + ReportSet::AddressbookMultiGet, + ] + } + + pub fn file() -> Vec { + vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ] + } + + pub fn principal() -> Vec { + vec![ + ReportSet::PrincipalPropertySearch, + ReportSet::PrincipalSearchPropertySet, + ReportSet::PrincipalMatch, + ] + } +} diff --git a/crates/dav-proto/src/schema/request.rs b/crates/dav-proto/src/schema/request.rs new file mode 100644 index 0000000..878c03e --- /dev/null +++ b/crates/dav-proto/src/schema/request.rs @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Collation, MatchType, + property::{DavProperty, DavValue, LockScope, LockType}, + response::Ace, +}; +use crate::{Condition, Depth}; +use calcard::{ + icalendar::{ICalendarComponentType, ICalendarParameterName, ICalendarProperty}, + vcard::{VCardParameterName, VCardProperty}, +}; +use types::{ + TimeRange, + dead_property::{ArchivedDeadProperty, ArchivedDeadPropertyTag, DeadElementTag, DeadProperty}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum PropFind { + #[default] + PropName, + AllProp(Vec), + Prop(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PropertyUpdate { + pub set: Vec, + pub remove: Vec, + pub set_first: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct DavPropertyValue { + pub property: DavProperty, + pub value: DavValue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct MkCol { + pub is_mkcalendar: bool, + pub props: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct LockInfo { + pub lock_scope: LockScope, + pub lock_type: LockType, + pub owner: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type"))] +pub enum Report { + AddressbookQuery(AddressbookQuery), + AddressbookMultiGet(MultiGet), + CalendarQuery(CalendarQuery), + CalendarMultiGet(MultiGet), + FreeBusyQuery(FreeBusyQuery), + SyncCollection(SyncCollection), + ExpandProperty(ExpandProperty), + AclPrincipalPropSet(AclPrincipalPropSet), + PrincipalMatch(PrincipalMatch), + PrincipalPropertySearch(PrincipalPropertySearch), + PrincipalSearchPropertySet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct ExpandProperty { + pub properties: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct ExpandPropertyItem { + pub property: DavProperty, + pub depth: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct AddressbookQuery { + pub properties: PropFind, + pub filters: Vec>, + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct VCardPropertyWithGroup { + pub name: VCardProperty, + pub group: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct CalendarQuery { + pub properties: PropFind, + pub filters: + Vec, ICalendarProperty, ICalendarParameterName>>, + pub timezone: Timezone, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type"))] +pub enum Timezone { + Name(String), + Id(String), + None, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct FreeBusyQuery { + pub range: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct MultiGet { + pub properties: PropFind, + pub hrefs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct SyncCollection { + pub sync_token: Option, + pub properties: PropFind, + pub depth: Depth, + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type"))] +pub enum Filter { + AnyOf, + AllOf, + Component { + comp: A, + op: FilterOp, + }, + Property { + comp: A, + prop: B, + op: FilterOp, + }, + Parameter { + comp: A, + prop: B, + param: C, + op: FilterOp, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type", content = "data"))] +pub enum FilterOp { + Exists, + Undefined, + TimeRange(TimeRange), + TextMatch(TextMatch), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(test, serde(tag = "type"))] +pub struct TextMatch { + pub match_type: MatchType, + pub value: String, + pub collation: Collation, + pub negate: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Acl { + pub aces: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct AclPrincipalPropSet { + pub properties: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PrincipalMatch { + pub principal_properties: PrincipalMatchProperties, + pub properties: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum PrincipalMatchProperties { + Properties(Vec), + Self_, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PrincipalPropertySearch { + pub property_search: Vec, + pub properties: Vec, + pub apply_to_principal_collection_set: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PropertySearch { + pub property: DavProperty, + pub match_: String, +} + +impl PropertyUpdate { + pub fn has_changes(&self) -> bool { + !self.set.is_empty() || !self.remove.is_empty() + } +} + +impl FreeBusyQuery { + pub fn new(start: i64, end: i64) -> Self { + FreeBusyQuery { + range: Some(TimeRange { start, end }), + } + } +} + +pub trait DavDeadProperty { + fn to_dav_values(&self, output: &mut Vec); +} + +impl DavDeadProperty for ArchivedDeadProperty { + fn to_dav_values(&self, output: &mut Vec) { + let mut depth: u32 = 0; + let mut tags = Vec::new(); + let mut tag_start = None; + + for tag in self.0.iter() { + match tag { + ArchivedDeadPropertyTag::ElementStart(start) => { + if depth == 0 { + tag_start = Some(DeadElementTag::from(start)); + } else { + tags.push(tag.into()); + } + + depth += 1; + } + ArchivedDeadPropertyTag::ElementEnd => { + depth = depth.saturating_sub(1); + + if depth > 0 { + tags.push(tag.into()); + } else if let Some(tag_start) = tag_start.take() { + output.push(DavPropertyValue::new( + DavProperty::DeadProperty(tag_start), + DavValue::DeadProperty(DeadProperty(std::mem::take(&mut tags))), + )); + } + } + ArchivedDeadPropertyTag::Text(_) => { + if tag_start.is_some() { + tags.push(tag.into()); + } + } + } + } + } +} + +impl Condition<'_> { + pub fn is_none_match(&self) -> bool { + match self { + Condition::ETag { is_not, .. } | Condition::Exists { is_not } => *is_not, + Condition::StateToken { .. } => false, + } + } +} diff --git a/crates/dav-proto/src/schema/response.rs b/crates/dav-proto/src/schema/response.rs new file mode 100644 index 0000000..cd507a2 --- /dev/null +++ b/crates/dav-proto/src/schema/response.rs @@ -0,0 +1,430 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Namespaces, + property::{DavProperty, Privilege}, + request::{DavPropertyValue, Filter}, +}; +use calcard::{ + icalendar::{ICalendarComponentType, ICalendarParameterName, ICalendarProperty}, + vcard::{VCardParameterName, VCardProperty}, +}; +use hyper::StatusCode; +use std::{borrow::Cow, fmt::Display}; + +pub struct MultiStatus { + pub namespaces: Namespaces, + pub response: List, + pub response_description: Option, + pub sync_token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Response { + pub href: Href, + pub typ: ResponseType, + pub error: Option, + pub response_description: Option, + pub location: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum ResponseType { + PropStat(List), + Status { href: List, status: Status }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(transparent)] +pub struct Status(pub StatusCode); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct Location(pub Href); + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct ResponseDescription(pub String); + +#[repr(transparent)] +pub struct SyncToken(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct Href(pub String); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct List(pub Vec); + +pub struct MkColResponse { + pub namespaces: Namespaces, + pub propstat: List, + pub mkcalendar: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PropStat { + pub prop: Prop, + pub status: Status, + pub error: Option, + pub response_description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +#[repr(transparent)] +pub struct Prop(pub List); + +pub struct PropResponse { + pub namespaces: Namespaces, + pub properties: List, +} + +#[derive(Default)] +pub struct ScheduleResponse { + pub items: List, +} + +#[derive(Default)] +pub struct ScheduleResponseItem { + pub recipient: Href, + pub request_status: Cow<'static, str>, + pub calendar_data: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct SupportedPrivilege { + pub privilege: Privilege, + pub abstract_: bool, + pub description: String, + pub supported_privilege: List, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Ace { + pub principal: Principal, + pub invert: bool, + pub grant_deny: GrantDeny, + pub protected: bool, + pub inherited: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum GrantDeny { + Grant(List), + Deny(List), +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Principal { + Href(Href), + Response(Response), + All, + #[default] + Authenticated, + Unauthenticated, + Property(List), + Self_, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct AclRestrictions { + pub grant_only: bool, + pub no_invert: bool, + pub deny_before_grant: bool, + pub required_principal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum RequiredPrincipal { + All, + Authenticated, + Unauthenticated, + Self_, + Href(List), + Property(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PrincipalSearchPropertySet { + pub namespaces: Namespaces, + pub properties: List, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct PrincipalSearchProperty { + pub name: DavProperty, + pub description: String, +} + +pub struct ErrorResponse { + pub namespaces: Namespaces, + pub error: Condition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum Condition { + Base(BaseCondition), + Cal(CalCondition), + Card(CardCondition), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum BaseCondition { + NoConflictingLock(List), + LockTokenSubmitted(List), + LockTokenMatchesRequestUri, + CannotModifyProtectedProperty, + NoExternalEntities, + PreservedLiveProperties, + PropFindFiniteDepth, + ResourceMustBeNull, + NeedPrivileges(List), + NoAceConflict, + NoProtectedAceConflict, + NoInheritedAceConflict, + LimitedNumberOfAces, + DenyBeforeGrant, + GrantOnly, + NoInvert, + NoAbstract, + NotSupportedPrivilege, + MissingRequiredPrincipal, + RecognizedPrincipal, + AllowedPrincipal, + NumberOfMatchesWithinLimit, + QuotaNotExceeded, + ValidResourceType, + ValidSyncToken, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct Resource { + pub href: Href, + pub privilege: Privilege, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum CalCondition { + CalendarCollectionLocationOk, + ValidCalendarData, + ValidFilter, + ValidCalendarObjectResource, + ValidTimezone, + NoUidConflict(Href), + InitializeCalendarCollection, + SupportedCalendarData, + SupportedFilter( + Vec, ICalendarProperty, ICalendarParameterName>>, + ), + SupportedCollation(String), + SupportedCalendarComponent, + MinDateTime, + MaxDateTime, + MaxResourceSize(u32), + MaxInstances, + MaxAttendeesPerInstance, + UniqueSchedulingObjectResource(Href), + SameOrganizerInAllComponents, + AllowedOrganizerObjectChange, + AllowedAttendeeObjectChange, + DefaultCalendarNeeded, + ValidScheduleDefaultCalendarUrl, + ValidSchedulingMessage, + ValidOrganizer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum CardCondition { + SupportedAddressData, + SupportedAddressDataConversion, + SupportedFilter(Vec>), + SupportedCollation(String), + ValidAddressData, + NoUidConflict(Href), + MaxResourceSize(u32), + AddressBookCollectionLocationOk, +} + +impl BaseCondition { + pub fn status(&self) -> StatusCode { + match self { + BaseCondition::NoConflictingLock(_) => StatusCode::LOCKED, + BaseCondition::CannotModifyProtectedProperty => StatusCode::FORBIDDEN, + BaseCondition::LockTokenSubmitted(_) => StatusCode::LOCKED, + BaseCondition::LockTokenMatchesRequestUri => StatusCode::CONFLICT, + BaseCondition::NoExternalEntities => StatusCode::FORBIDDEN, + BaseCondition::PreservedLiveProperties => StatusCode::CONFLICT, + BaseCondition::PropFindFiniteDepth => StatusCode::FORBIDDEN, + BaseCondition::ResourceMustBeNull => StatusCode::CONFLICT, + BaseCondition::NeedPrivileges(_) => StatusCode::FORBIDDEN, + BaseCondition::NumberOfMatchesWithinLimit => StatusCode::FORBIDDEN, + _ => StatusCode::FORBIDDEN, + } + } +} + +impl From for Href { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for Href { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl MultiStatus { + pub fn is_empty(&self) -> bool { + self.response.0.is_empty() + } +} + +impl BaseCondition { + pub fn display_name(&self) -> &'static str { + match self { + BaseCondition::NoConflictingLock(_) => "NoConflictingLock", + BaseCondition::CannotModifyProtectedProperty => "CannotModifyProtectedProperty", + BaseCondition::LockTokenSubmitted(_) => "LockTokenSubmitted", + BaseCondition::LockTokenMatchesRequestUri => "LockTokenMatchesRequestUri", + BaseCondition::NoExternalEntities => "NoExternalEntities", + BaseCondition::PreservedLiveProperties => "PreservedLiveProperties", + BaseCondition::PropFindFiniteDepth => "PropFindFiniteDepth", + BaseCondition::ResourceMustBeNull => "ResourceMustBeNull", + BaseCondition::NeedPrivileges(_) => "NeedPrivileges", + BaseCondition::NoAceConflict => "NoAceConflict", + BaseCondition::NoProtectedAceConflict => "NoProtectedAceConflict", + BaseCondition::NoInheritedAceConflict => "NoInheritedAceConflict", + BaseCondition::LimitedNumberOfAces => "LimitedNumberOfAces", + BaseCondition::DenyBeforeGrant => "DenyBeforeGrant", + BaseCondition::GrantOnly => "GrantOnly", + BaseCondition::NoInvert => "NoInvert", + BaseCondition::NoAbstract => "NoAbstract", + BaseCondition::NotSupportedPrivilege => "NotSupportedPrivilege", + BaseCondition::MissingRequiredPrincipal => "MissingRequiredPrincipal", + BaseCondition::RecognizedPrincipal => "RecognizedPrincipal", + BaseCondition::AllowedPrincipal => "AllowedPrincipal", + BaseCondition::NumberOfMatchesWithinLimit => "NumberOfMatchesWithinLimit", + BaseCondition::QuotaNotExceeded => "QuotaNotExceeded", + BaseCondition::ValidResourceType => "ValidResourceType", + BaseCondition::ValidSyncToken => "ValidSyncToken", + } + } +} + +impl CalCondition { + pub fn display_name(&self) -> &'static str { + match self { + CalCondition::CalendarCollectionLocationOk => "CalendarCollectionLocationOk", + CalCondition::ValidCalendarData => "ValidCalendarData", + CalCondition::ValidFilter => "ValidFilter", + CalCondition::ValidCalendarObjectResource => "ValidCalendarObjectResource", + CalCondition::ValidTimezone => "ValidTimezone", + CalCondition::NoUidConflict(_) => "NoUidConflict", + CalCondition::InitializeCalendarCollection => "InitializeCalendarCollection", + CalCondition::SupportedCalendarData => "SupportedCalendarData", + CalCondition::SupportedFilter(_) => "SupportedFilter", + CalCondition::SupportedCollation(_) => "SupportedCollation", + CalCondition::MinDateTime => "MinDateTime", + CalCondition::MaxDateTime => "MaxDateTime", + CalCondition::MaxResourceSize(_) => "MaxResourceSize", + CalCondition::MaxInstances => "MaxInstances", + CalCondition::MaxAttendeesPerInstance => "MaxAttendeesPerInstance", + CalCondition::UniqueSchedulingObjectResource(_) => "UniqueSchedulingObjectResource", + CalCondition::SameOrganizerInAllComponents => "SameOrganizerInAllComponents", + CalCondition::AllowedOrganizerObjectChange => "AllowedOrganizerObjectChange", + CalCondition::AllowedAttendeeObjectChange => "AllowedAttendeeObjectChange", + CalCondition::DefaultCalendarNeeded => "DefaultCalendarNeeded", + CalCondition::ValidScheduleDefaultCalendarUrl => "ValidScheduleDefaultCalendarUrl", + CalCondition::ValidSchedulingMessage => "ValidSchedulingMessage", + CalCondition::ValidOrganizer => "ValidOrganizer", + CalCondition::SupportedCalendarComponent => "SupportedCalendarComponent", + } + } +} + +impl CardCondition { + pub fn display_name(&self) -> &'static str { + match self { + CardCondition::SupportedAddressData => "SupportedAddressData", + CardCondition::SupportedAddressDataConversion => "SupportedAddressDataConversion", + CardCondition::SupportedFilter(_) => "SupportedFilter", + CardCondition::SupportedCollation(_) => "SupportedCollation", + CardCondition::ValidAddressData => "ValidAddressData", + CardCondition::NoUidConflict(_) => "NoUidConflict", + CardCondition::MaxResourceSize(_) => "MaxResourceSize", + CardCondition::AddressBookCollectionLocationOk => "AddressBookCollectionLocationOk", + } + } +} + +impl Condition { + pub fn display_name(&self) -> &'static str { + match self { + Condition::Base(base) => base.display_name(), + Condition::Cal(cal) => cal.display_name(), + Condition::Card(card) => card.display_name(), + } + } +} + +#[cfg(test)] +mod serde_impl { + use super::Status; + use hyper::StatusCode; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + impl Serialize for Status { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Serialize the status code as a u16 + serializer.serialize_u16(self.0.as_u16()) + } + } + + impl<'de> Deserialize<'de> for Status { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize as u16 + let status_value = u16::deserialize(deserializer)?; + + // Convert u16 to StatusCode + let status_code = StatusCode::try_from(status_value).map_err(|_| { + serde::de::Error::custom(format!("Invalid status code: {}", status_value)) + })?; + + Ok(Status(status_code)) + } + } +} diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml new file mode 100644 index 0000000..a80ae27 --- /dev/null +++ b/crates/dav/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "dav" +version = "0.16.22" +edition = "2024" + +[dependencies] +dav-proto = { path = "../dav-proto" } +common = { path = "../common" } +store = { path = "../store" } +utils = { path = "../utils" } +groupware = { path = "../groupware" } +directory = { path = "../directory" } +registry = { path = "../registry" } +http_proto = { path = "../http-proto" } +types = { path = "../types" } +trc = { path = "../trc" } +calcard = { version = "0.3", features = ["rkyv"] } +hashify = { version = "0.2" } +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +percent-encoding = "2.3.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = "0.10.0" +chrono = "0.4.45" + +[dev-dependencies] + +[features] +test_mode = [] +dev_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/dav/src/calendar/copy_move.rs b/crates/dav/src/calendar/copy_move.rs new file mode 100644 index 0000000..46e9e19 --- /dev/null +++ b/crates/dav/src/calendar/copy_move.rs @@ -0,0 +1,1119 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::assert_is_unique_uid; +use crate::{ + DavError, DavMethod, + common::{ + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use calcard::common::timezone::Tz; +use common::{DavName, Server, auth::AccessToken}; +use dav_proto::{Depth, RequestHeaders}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + calendar::{Calendar, CalendarEvent, CalendarPreferences, Timezone}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::{BatchBuilder, now}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection, VanishedCollection}, + field::PrincipalField, +}; + +pub(crate) trait CalendarCopyMoveRequestHandler: Sync + Send { + fn handle_calendar_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> impl Future> + Send; +} + +impl CalendarCopyMoveRequestHandler for Server { + async fn handle_calendar_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> crate::Result { + // Validate source + let from_resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let from_account_id = from_resource_.account_id; + let from_resources = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let from_resource_name = from_resource_ + .resource + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let from_resource = from_resources + .by_path(from_resource_name) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] + if is_move + && from_resource.is_container() + && self + .core + .groupware + .default_calendar_name + .as_ref() + .is_some_and(|name| name == from_resource_name) + { + return Err(DavError::Condition(crate::DavErrorCondition::new( + StatusCode::FORBIDDEN, + dav_proto::schema::response::CalCondition::DefaultCalendarNeeded, + ))); + } + + // Validate ACL + if !access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + if from_resource.is_container() { + from_resource.document_id() + } else { + from_resource.parent_id().unwrap() + }, + Acl::ReadItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate destination + let destination = self + .validate_uri_with_status( + access_token, + headers + .destination + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, + ) + .await?; + if destination.collection != Collection::Calendar { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + let to_account_id = destination + .account_id + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + let to_resources = if to_account_id == from_account_id { + from_resources.clone() + } else { + self.fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())? + }; + + // Validate headers + let destination_resource_name = destination + .resource + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + let to_resource = to_resources.by_path(destination_resource_name); + self.validate_headers( + access_token, + headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: if from_resource.is_container() { + Collection::Calendar + } else { + Collection::CalendarEvent + }, + document_id: Some(from_resource.document_id()), + path: from_resource_name, + ..Default::default() + }, + ResourceState { + account_id: to_account_id, + collection: to_resource + .map(|r| { + if r.is_container() { + Collection::Calendar + } else { + Collection::CalendarEvent + } + }) + .unwrap_or(Collection::Calendar), + document_id: Some(to_resource.map(|r| r.document_id()).unwrap_or(u32::MAX)), + path: destination_resource_name, + ..Default::default() + }, + ], + Default::default(), + if is_move { + DavMethod::MOVE + } else { + DavMethod::COPY + }, + ) + .await?; + + // Map destination + if let Some(to_resource) = to_resource { + if from_resource.path() == to_resource.path() { + // Same resource + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + let new_name = destination_resource_name + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(destination_resource_name); + + match (from_resource.is_container(), to_resource.is_container()) { + (true, true) => { + let from_children_ids = from_resources + .subtree(from_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + let to_document_ids = to_resources + .subtree(destination_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + + // Validate ACLs + if !access_token.is_member(to_account_id) + || (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_resource.document_id(), + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Overwrite container + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_children_ids, + from_resources.format_collection(from_resource_name), + to_account_id, + to_resource.document_id().into(), + to_document_ids, + new_name, + is_move, + ) + .await + } + (false, false) => { + // Overwrite event + let from_calendar_id = from_resource.parent_id().unwrap(); + let to_calendar_id = to_resource.parent_id().unwrap(); + + // Validate ACL + if (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_calendar_id, + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + || (!access_token.is_member(to_account_id) + && !to_resources.has_access_to_container( + access_token, + to_calendar_id, + Acl::RemoveItems, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + if is_move { + move_event( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_calendar_id, + from_resources.format_item(from_resource_name), + to_account_id, + to_resource.document_id().into(), + to_calendar_id, + new_name, + headers.if_schedule_tag, + ) + .await + } else { + copy_event( + self, + access_token, + from_account_id, + from_resource.document_id(), + to_account_id, + to_resource.document_id().into(), + to_calendar_id, + new_name, + ) + .await + } + } + _ => Err(DavError::Code(StatusCode::BAD_GATEWAY)), + } + } else if let Some((parent_resource, new_name)) = + to_resources.map_parent(destination_resource_name) + { + if let Some(parent_resource) = parent_resource { + // Creating items under an event is not allowed + // Copying/moving containers under a container is not allowed + if !parent_resource.is_container() || from_resource.is_container() { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + + // Validate ACL + let from_calendar_id = from_resource.parent_id().unwrap(); + let to_calendar_id = parent_resource.document_id(); + if (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_calendar_id, + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + || (!access_token.is_member(to_account_id) + && !to_resources.has_access_to_container( + access_token, + to_calendar_id, + Acl::AddItems, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Copy/move event + if is_move { + if from_account_id != to_account_id + || parent_resource.document_id() != from_calendar_id + { + move_event( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_calendar_id, + from_resources.format_item(from_resource_name), + to_account_id, + None, + to_calendar_id, + new_name, + headers.if_schedule_tag, + ) + .await + } else { + rename_event( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_calendar_id, + new_name, + from_resources.format_item(from_resource_name), + ) + .await + } + } else { + copy_event( + self, + access_token, + from_account_id, + from_resource.document_id(), + to_account_id, + None, + to_calendar_id, + new_name, + ) + .await + } + } else { + // Copying/moving events to the root is not allowed + if !from_resource.is_container() { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + + // Shared users cannot create containers + if !access_token.is_member(to_account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate ACLs + if !access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_resource.document_id(), + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Copy/move container + let from_children_ids = from_resources + .subtree(from_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + if is_move { + if from_account_id != to_account_id { + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + if headers.depth != Depth::Zero { + from_children_ids + } else { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + }, + from_resources.format_collection(from_resource_name), + to_account_id, + None, + vec![], + new_name, + true, + ) + .await + } else { + rename_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + new_name, + from_resources.format_collection(from_resource_name), + ) + .await + } + } else { + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + if headers.depth != Depth::Zero { + from_children_ids + } else { + vec![] + }, + from_resources.format_collection(from_resource_name), + to_account_id, + None, + vec![], + new_name, + false, + ) + .await + } + } + } else { + Err(DavError::Code(StatusCode::CONFLICT)) + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn copy_event( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + to_account_id: u32, + to_document_id: Option, + to_calendar_id: u32, + new_name: &str, +) -> crate::Result { + // Fetch event + let event_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::CalendarEvent, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + + // Validate UID + assert_is_unique_uid( + server, + server + .fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_calendar_id, + event.inner.data.event.uids().next(), + ) + .await?; + + let changed_by = access_token.account_tenant_ids(); + if from_account_id == to_account_id { + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names.push(DavName { + name: new_name.to_string(), + parent_id: to_calendar_id, + }); + new_event + .update( + changed_by, + event, + from_account_id, + from_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names = vec![DavName { + name: new_name.to_string(), + parent_id: to_calendar_id, + }]; + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + new_event + .insert( + changed_by, + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + let response = if let Some(to_document_id) = to_document_id { + // Overwrite event on destination + let event_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::CalendarEvent, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(event_) = event_ { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + let account_info = server + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + + DestroyArchive(event) + .delete( + &account_info, + to_account_id, + to_document_id, + to_calendar_id, + None, + false, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED)) + }; + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + response +} + +#[allow(clippy::too_many_arguments)] +async fn move_event( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + from_calendar_id: u32, + from_resource_path: String, + to_account_id: u32, + to_document_id: Option, + to_calendar_id: u32, + new_name: &str, + if_schedule_tag: Option, +) -> crate::Result { + // Fetch event + let event_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::CalendarEvent, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate headers + if if_schedule_tag.is_some() + && event.inner.schedule_tag.as_ref().map(|t| t.to_native()) != if_schedule_tag + { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + + // Validate UID + if from_account_id != to_account_id + || from_calendar_id != to_calendar_id + || to_document_id.is_none() + { + assert_is_unique_uid( + server, + server + .fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_calendar_id, + event.inner.data.event.uids().next(), + ) + .await?; + } + + let account_info = server + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + if from_account_id == to_account_id { + let mut name_idx = None; + for (idx, name) in event.inner.names.iter().enumerate() { + if name.parent_id == from_calendar_id { + name_idx = Some(idx); + break; + } + } + + let name_idx = if let Some(name_idx) = name_idx { + name_idx + } else { + return Err(DavError::Code(StatusCode::NOT_FOUND)); + }; + + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names.swap_remove(name_idx); + new_event.names.push(DavName { + name: new_name.to_string(), + parent_id: to_calendar_id, + }); + new_event + .update( + access_token.account_tenant_ids(), + event.clone(), + from_account_id, + from_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::Calendar, from_resource_path); + } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names = vec![DavName { + name: new_name.to_string(), + parent_id: to_calendar_id, + }]; + + DestroyArchive(event) + .delete( + &account_info, + from_account_id, + from_document_id, + from_calendar_id, + from_resource_path.into(), + false, + &mut batch, + ) + .caused_by(trc::location!())?; + + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + new_event + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + let response = if let Some(to_document_id) = to_document_id { + // Overwrite event on destination + let event_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::CalendarEvent, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(event_) = event_ { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(event) + .delete( + &account_info, + to_account_id, + to_document_id, + to_calendar_id, + None, + false, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED)) + }; + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + response +} + +#[allow(clippy::too_many_arguments)] +async fn rename_event( + server: &Server, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + calendar_id: u32, + new_name: &str, + from_resource_path: String, +) -> crate::Result { + // Fetch event + let event_ = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + let name_idx = event + .inner + .names + .iter() + .position(|n| n.parent_id == calendar_id) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names[name_idx].name = new_name.to_string(); + + let mut batch = BatchBuilder::new(); + new_event + .update( + access_token.account_tenant_ids(), + event, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::Calendar, from_resource_path); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED)) +} + +#[allow(clippy::too_many_arguments)] +async fn copy_container( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + from_children_ids: Vec, + from_resource_path: String, + to_account_id: u32, + to_document_id: Option, + to_children_ids: Vec, + new_name: &str, + remove_source: bool, +) -> crate::Result { + // Fetch calendar + let calendar_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::Calendar, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let old_calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut calendar = old_calendar + .deserialize::() + .caused_by(trc::location!())?; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + + if remove_source { + DestroyArchive(old_calendar) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_document_id, + from_resource_path.into(), + &mut batch, + ) + .caused_by(trc::location!())?; + + // Reset default calendar id + let default_calendar_id = server + .store() + .get_value::(ValueKey { + account_id: from_account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())?; + if default_calendar_id.is_some_and(|id| id == from_document_id) { + batch + .with_account_id(from_account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultCalendarId); + } + } + + let preference = calendar.preferences.into_iter().next().unwrap(); + calendar.name = new_name.to_string(); + calendar.acls.clear(); + calendar.preferences = vec![CalendarPreferences { + account_id: to_account_id, + name: preference.name, + description: preference.description, + default_alerts: preference.default_alerts, + sort_order: 0, + color: preference.color, + flags: 0, + time_zone: Timezone::Default, + }]; + + let account_info = server + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + let is_overwrite = to_document_id.is_some(); + let to_document_id = if let Some(to_document_id) = to_document_id { + // Overwrite destination + let calendar_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::Calendar, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(calendar_) = calendar_ { + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(calendar) + .delete_with_events( + server, + &account_info, + to_account_id, + to_document_id, + to_children_ids, + None, + false, + &mut batch, + ) + .await + .caused_by(trc::location!())?; + } + + to_document_id + } else { + server + .store() + .assign_document_ids(to_account_id, Collection::Calendar, 1) + .await + .caused_by(trc::location!())? + }; + calendar + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + // Copy children + let mut required_space = 0; + for from_child_document_id in from_children_ids { + if let Some(event_) = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::CalendarEvent, + from_child_document_id, + )) + .await? + { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_name = None; + + for name in event.inner.names.iter() { + if name.parent_id == to_document_id { + continue; + } else if name.parent_id == from_document_id { + new_name = Some(name.name.to_string()); + } + } + let new_name = if let Some(new_name) = new_name { + DavName { + name: new_name, + parent_id: to_document_id, + } + } else { + continue; + }; + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + + if from_account_id == to_account_id { + if remove_source { + new_event + .names + .retain(|name| name.parent_id != from_document_id); + } + + new_event.names.push(new_name); + new_event + .update( + access_token.account_tenant_ids(), + event, + from_account_id, + from_child_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); + if remove_source { + DestroyArchive(event) + .delete( + &account_info, + from_account_id, + from_child_document_id, + from_document_id, + None, + false, + &mut batch, + ) + .caused_by(trc::location!())?; + } + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + new_event.names = vec![new_name]; + required_space += new_event.size as u64; + new_event + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())?; + } + } + } + + if from_account_id != to_account_id && required_space > 0 { + server + .has_available_quota( + server.account(to_account_id).await?.as_ref(), + required_space, + ) + .await?; + } + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + if !is_overwrite { + Ok(HttpResponse::new(StatusCode::CREATED)) + } else { + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } +} + +#[allow(clippy::too_many_arguments)] +async fn rename_container( + server: &Server, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + new_name: &str, + from_resource_path: String, +) -> crate::Result { + // Fetch calendar + let calendar_ = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_calendar = calendar + .deserialize::() + .caused_by(trc::location!())?; + new_calendar.name = new_name.to_string(); + + let mut batch = BatchBuilder::new(); + new_calendar + .update( + access_token.account_tenant_ids(), + calendar, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::Calendar, from_resource_path); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED)) +} diff --git a/crates/dav/src/calendar/delete.rs b/crates/dav/src/calendar/delete.rs new file mode 100644 index 0000000..520c3da --- /dev/null +++ b/crates/dav/src/calendar/delete.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use dav_proto::RequestHeaders; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + calendar::{Calendar, CalendarEvent}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use registry::schema::enums::Permission; +use store::write::{BatchBuilder, ValueClass}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::PrincipalField, +}; + +pub(crate) trait CalendarDeleteRequestHandler: Sync + Send { + fn handle_calendar_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> impl Future> + Send; +} + +impl CalendarDeleteRequestHandler for Server { + async fn handle_calendar_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let delete_path = resource + .resource + .filter(|r| !r.is_empty()) + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + + // Check resource type + let delete_resource = resources + .by_path(delete_path) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let document_id = delete_resource.document_id(); + let account_info = self + .scheduling_account_info(access_token.account_id(), account_id) + .await?; + let send_itip = self.core.groupware.itip_enabled + && !headers.no_schedule_reply + && !account_info.addresses().is_empty() + && access_token.has_permission(Permission::CalendarSchedulingSend); + + // Fetch entry + let mut batch = BatchBuilder::new(); + if delete_resource.is_container() { + // Deleting the default calendar is not allowed + #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] + if self + .core + .groupware + .default_calendar_name + .as_ref() + .is_some_and(|name| name == delete_path) + { + return Err(DavError::Condition(crate::DavErrorCondition::new( + StatusCode::FORBIDDEN, + dav_proto::schema::response::CalCondition::DefaultCalendarNeeded, + ))); + } + + let calendar_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACL + if !access_token.is_member(account_id) + && !calendar + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::Calendar, + document_id: document_id.into(), + etag: calendar.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + // Delete calendar and events + DestroyArchive(calendar) + .delete_with_events( + self, + &account_info, + account_id, + document_id, + resources + .subtree(delete_path) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(), + resources.format_resource(delete_resource).into(), + send_itip, + &mut batch, + ) + .await + .caused_by(trc::location!())?; + + // Reset default calendar id + let default_calendar_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())?; + if default_calendar_id.is_some_and(|id| id == document_id) { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultCalendarId); + } + } else { + // Validate ACL + let calendar_id = delete_resource.parent_id().unwrap(); + if !access_token.is_member(account_id) + && !resources.has_access_to_container(access_token, calendar_id, Acl::RemoveItems) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let event_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarEvent, + document_id: document_id.into(), + etag: event_.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + // Validate schedule tag + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + if headers.if_schedule_tag.is_some() + && event.inner.schedule_tag.as_ref().map(|t| t.to_native()) + != headers.if_schedule_tag + { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + + // Delete event + DestroyArchive(event) + .delete( + &account_info, + account_id, + document_id, + calendar_id, + resources.format_resource(delete_resource).into(), + send_itip, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } +} diff --git a/crates/dav/src/calendar/freebusy.rs b/crates/dav/src/calendar/freebusy.rs new file mode 100644 index 0000000..3c76a7f --- /dev/null +++ b/crates/dav/src/calendar/freebusy.rs @@ -0,0 +1,407 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::query::CalendarQueryHandler; +use crate::{DavError, calendar::query::is_resource_in_time_range, common::uri::DavUriResource}; +use calcard::{ + common::{PartialDateTime, timezone::Tz}, + icalendar::{ + ArchivedICalendarComponentType, ArchivedICalendarEntry, ArchivedICalendarParameterName, + ArchivedICalendarParameterValue, ArchivedICalendarProperty, ArchivedICalendarStatus, + ArchivedICalendarValue, ICalendar, ICalendarComponent, ICalendarComponentType, + ICalendarEntry, ICalendarFreeBusyType, ICalendarParameter, ICalendarPeriod, + ICalendarProperty, ICalendarTransparency, ICalendarValue, + }, +}; +use common::{DavResourcePath, DavResources, PROD_ID, Server, auth::AccessToken}; +use dav_proto::{RequestHeaders, schema::request::FreeBusyQuery}; +use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::str::FromStr; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ + ahash::AHashMap, + write::{now, serialize::rkyv_deserialize}, +}; +use trc::AddContext; +use types::{ + TimeRange, + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CalendarFreebusyRequestHandler: Sync + Send { + fn handle_calendar_freebusy_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: FreeBusyQuery, + ) -> impl Future> + Send; + + fn build_freebusy_object( + &self, + access_token: &AccessToken, + request: FreeBusyQuery, + resources: &DavResources, + account_id: u32, + resource: DavResourcePath<'_>, + ) -> impl Future> + Send; +} + +impl CalendarFreebusyRequestHandler for Server { + async fn handle_calendar_freebusy_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: FreeBusyQuery, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let resource = resources + .by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if !resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + self.build_freebusy_object(access_token, request, &resources, account_id, resource) + .await + .map(|ical| { + HttpResponse::new(StatusCode::OK) + .with_content_type("text/calendar; charset=utf-8") + .with_text_body(ical.to_string()) + }) + } + + async fn build_freebusy_object( + &self, + access_token: &AccessToken, + request: FreeBusyQuery, + resources: &DavResources, + account_id: u32, + resource: DavResourcePath<'_>, + ) -> crate::Result { + // Obtain shared ids + let shared_ids = if !access_token.is_member(account_id) { + resources + .shared_items( + access_token, + [Acl::ReadItems, Acl::SchedulingReadFreeBusy], + false, + ) + .into() + } else { + None + }; + + // Build FreeBusy component + let default_tz = resource + .resource + .calendar_preferences(account_id) + .map(|p| p.tz) + .unwrap_or(Tz::UTC); + let mut entries = Vec::with_capacity(6); + if let Some(range) = request.range { + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstart, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(range.start), + ))], + }); + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtend, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(range.end), + ))], + }); + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstamp, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(now() as i64), + ))], + }); + + let document_ids = resources + .children(resource.document_id()) + .filter(|resource| { + shared_ids + .as_ref() + .is_none_or(|ids| ids.contains(resource.document_id())) + && is_resource_in_time_range(resource.resource, &range) + }) + .map(|resource| resource.document_id()) + .collect::>(); + + let mut fb_entries: AHashMap> = + AHashMap::with_capacity(document_ids.len()); + let max_instances = self.core.groupware.max_ical_instances; + let mut total_instances: usize = 0; + + for document_id in document_ids { + let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let event = archive + .unarchive::() + .caused_by(trc::location!())?; + + /* + Only VEVENT components without a TRANSP property or with the TRANSP + property set to OPAQUE, and VFREEBUSY components SHOULD be considered + in generating the free busy time information. + */ + let mut components = event + .data + .event + .components + .iter() + .enumerate() + .filter(|(_, comp)| { + (matches!(comp.component_type, ArchivedICalendarComponentType::VEvent) + && comp + .transparency() + .is_none_or(|t| t == &ICalendarTransparency::Opaque)) + || matches!( + comp.component_type, + ArchivedICalendarComponentType::VFreebusy + ) + }) + .peekable(); + + if components.peek().is_none() { + continue; + } + + let events = + CalendarQueryHandler::new(event, Some(range), default_tz).into_expanded_times(); + + if events.is_empty() { + continue; + } + + total_instances = total_instances.saturating_add(events.len()); + if total_instances > max_instances { + return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); + } + + for (component_id, component) in components { + let component_id = component_id as u32; + match component.component_type { + ArchivedICalendarComponentType::VEvent => { + let fbtype = match component.status() { + Some(ArchivedICalendarStatus::Cancelled) => continue, + Some(ArchivedICalendarStatus::Tentative) => { + ICalendarFreeBusyType::BusyTentative + } + _ => ICalendarFreeBusyType::Busy, + }; + + let mut events_in_range = Vec::new(); + for event in &events { + if event.comp_id == component_id + && range.is_in_range(false, event.start, event.end) + { + events_in_range.push((event.start, event.end)); + } + } + + if !events_in_range.is_empty() { + fb_entries + .entry(fbtype) + .or_default() + .extend(events_in_range); + } + } + ArchivedICalendarComponentType::VFreebusy => { + for entry in component.entries.iter() { + if matches!(entry.name, ArchivedICalendarProperty::Freebusy) { + let mut fb_in_range = + freebusy_in_range_utc(entry, &range, default_tz).peekable(); + if fb_in_range.peek().is_some() { + let fb_type = entry + .params + .iter() + .find_map(|param| { + if let ( + ArchivedICalendarParameterName::Fbtype, + ArchivedICalendarParameterValue::Fbtype(param), + ) = (¶m.name, ¶m.value) + { + rkyv_deserialize(param).ok() + } else { + None + } + }) + .unwrap_or(ICalendarFreeBusyType::Busy); + + fb_entries.entry(fb_type).or_default().extend(fb_in_range); + } + } + } + } + _ => {} + } + } + } + + for (fbtype, events_in_range) in fb_entries { + entries.push(ICalendarEntry { + name: ICalendarProperty::Freebusy, + params: vec![ICalendarParameter::fbtype(fbtype)], + values: merge_intervals(events_in_range), + }); + } + } + + // Build ICalendar + Ok(ICalendar { + components: vec![ + ICalendarComponent { + component_type: ICalendarComponentType::VCalendar, + entries: vec![ + ICalendarEntry { + name: ICalendarProperty::Version, + params: vec![], + values: vec![ICalendarValue::Text("2.0".to_string())], + }, + ICalendarEntry { + name: ICalendarProperty::Prodid, + params: vec![], + values: vec![ICalendarValue::Text(PROD_ID.to_string())], + }, + ], + component_ids: vec![1], + }, + ICalendarComponent { + component_type: ICalendarComponentType::VFreebusy, + entries, + component_ids: vec![], + }, + ], + }) + } +} + +fn merge_intervals(mut intervals: Vec<(i64, i64)>) -> Vec { + if intervals.len() > 1 { + intervals.sort_unstable_by_key(|a| a.0); + + let mut unique_intervals = Vec::new(); + let mut start_time = intervals[0].0; + let mut end_time = intervals[0].1; + + for &(curr_start, curr_end) in intervals.iter().skip(1) { + if curr_start <= end_time { + end_time = end_time.max(curr_end); + } else { + unique_intervals.push(build_ical_value(start_time, end_time)); + start_time = curr_start; + end_time = curr_end; + } + } + + unique_intervals.push(build_ical_value(start_time, end_time)); + unique_intervals + } else { + intervals + .into_iter() + .map(|(start, end)| build_ical_value(start, end)) + .collect() + } +} + +fn build_ical_value(from: i64, to: i64) -> ICalendarValue { + ICalendarValue::Period(ICalendarPeriod::Range { + start: PartialDateTime::from_utc_timestamp(from), + end: PartialDateTime::from_utc_timestamp(to), + }) +} + +pub(crate) fn freebusy_in_range( + entry: &ArchivedICalendarEntry, + range: &TimeRange, + default_tz: Tz, +) -> impl Iterator { + let tz = entry + .tz_id() + .and_then(|tz_id| Tz::from_str(tz_id).ok()) + .unwrap_or(default_tz); + + entry.values.iter().filter_map(move |value| { + if let ArchivedICalendarValue::Period(period) = &value { + period.time_range(tz).and_then(|(start, end)| { + let start = start.timestamp(); + let end = end.timestamp(); + if range.is_in_range(false, start, end) { + rkyv_deserialize(value).ok() + } else { + None + } + }) + } else { + None + } + }) +} + +fn freebusy_in_range_utc( + entry: &ArchivedICalendarEntry, + range: &TimeRange, + default_tz: Tz, +) -> impl Iterator { + let tz = entry + .tz_id() + .and_then(|tz_id| Tz::from_str(tz_id).ok()) + .unwrap_or(default_tz); + + entry.values.iter().filter_map(move |value| { + if let ArchivedICalendarValue::Period(period) = &value { + period.time_range(tz).and_then(|(start, end)| { + let start = start.timestamp(); + let end = end.timestamp(); + if range.is_in_range(false, start, end) { + Some((start, end)) + } else { + None + } + }) + } else { + None + } + }) +} diff --git a/crates/dav/src/calendar/get.rs b/crates/dav/src/calendar/get.rs new file mode 100644 index 0000000..f786aea --- /dev/null +++ b/crates/dav/src/calendar/get.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{RequestHeaders, schema::property::Rfc1123DateTime}; +use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CalendarGetRequestHandler: Sync + Send { + fn handle_calendar_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> impl Future> + Send; +} + +impl CalendarGetRequestHandler for Server { + async fn handle_calendar_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let resource = resources + .by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) + && !resources.has_access_to_container( + access_token, + resource.parent_id().unwrap(), + Acl::ReadItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Fetch event + let event_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + resource.document_id(), + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .unarchive::() + .caused_by(trc::location!())?; + + // Validate headers + let etag = event_.etag(); + let schedule_tag = event.schedule_tag.as_ref().map(|tag| tag.to_native()); + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarEvent, + document_id: resource.document_id().into(), + etag: etag.clone().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::GET, + ) + .await?; + + let response = HttpResponse::new(StatusCode::OK) + .with_content_type("text/calendar; charset=utf-8") + .with_etag(etag) + .with_schedule_tag_opt(schedule_tag) + .with_last_modified(Rfc1123DateTime::new(i64::from(event.modified)).to_string()); + + let ical = event.data.event.to_string(); + + if !is_head { + Ok(response.with_binary_body(ical)) + } else { + Ok(response.with_content_length(ical.len())) + } + } +} diff --git a/crates/dav/src/calendar/mkcol.rs b/crates/dav/src/calendar/mkcol.rs new file mode 100644 index 0000000..7d3846c --- /dev/null +++ b/crates/dav/src/calendar/mkcol.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::proppatch::CalendarPropPatchRequestHandler; +use crate::{ + DavError, DavMethod, PropStatBuilder, + common::{ + ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{Namespace, request::MkCol, response::MkColResponse}, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{Calendar, CalendarPreferences}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use trc::AddContext; +use types::collection::{Collection, SyncCollection}; + +pub(crate) trait CalendarMkColRequestHandler: Sync + Send { + fn handle_calendar_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> impl Future> + Send; +} + +impl CalendarMkColRequestHandler for Server { + async fn handle_calendar_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let name = resource + .resource + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } else if name.contains('/') + || self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())? + .by_path(name) + .is_some() + { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: name, + ..Default::default() + }], + Default::default(), + DavMethod::MKCOL, + ) + .await?; + + // Build file container + let mut calendar = Calendar { + name: name.to_string(), + preferences: vec![CalendarPreferences { + account_id, + name: name.to_string(), + ..Default::default() + }], + ..Default::default() + }; + + // Apply MKCOL properties + let mut return_prop_stat = None; + let mut is_mkcalendar = false; + if let Some(mkcol) = request { + let mut prop_stat = PropStatBuilder::default(); + is_mkcalendar = mkcol.is_mkcalendar; + if !self.apply_calendar_properties( + access_token.personal_id(account_id, Collection::Calendar), + &mut calendar, + false, + mkcol.props, + &mut prop_stat, + ) { + return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CalDav) + .with_mkcalendar(is_mkcalendar) + .to_string(), + )); + } + if headers.ret != Return::Minimal { + return_prop_stat = Some(prop_stat); + } + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::Calendar, 1) + .await + .caused_by(trc::location!())?; + calendar + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + let etag = batch.etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + + if let Some(prop_stat) = return_prop_stat { + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CalDav) + .with_mkcalendar(is_mkcalendar) + .to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } + } +} diff --git a/crates/dav/src/calendar/mod.rs b/crates/dav/src/calendar/mod.rs new file mode 100644 index 0000000..064d376 --- /dev/null +++ b/crates/dav/src/calendar/mod.rs @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod copy_move; +pub mod delete; +pub mod freebusy; +pub mod get; +pub mod mkcol; +pub mod proppatch; +pub mod query; +pub mod scheduling; +pub mod update; + +use crate::{DavError, DavErrorCondition}; +use common::{DavResources, Server}; +use dav_proto::schema::{ + property::{CalDavProperty, CalendarData, DavProperty, WebDavProperty}, + response::CalCondition, +}; +use groupware::scheduling::ItipError; +use hyper::StatusCode; +use trc::AddContext; +use types::{collection::Collection, field::CalendarEventField}; + +pub(crate) static CALENDAR_CONTAINER_PROPS: [DavProperty; 31] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes), + DavProperty::WebDav(WebDavProperty::QuotaUsedBytes), + DavProperty::CalDav(CalDavProperty::CalendarDescription), + DavProperty::CalDav(CalDavProperty::SupportedCalendarData), + DavProperty::CalDav(CalDavProperty::SupportedCollationSet), + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet), + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + DavProperty::CalDav(CalDavProperty::MaxResourceSize), + DavProperty::CalDav(CalDavProperty::MinDateTime), + DavProperty::CalDav(CalDavProperty::MaxDateTime), + DavProperty::CalDav(CalDavProperty::MaxInstances), + DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), + DavProperty::CalDav(CalDavProperty::TimezoneServiceSet), + DavProperty::CalDav(CalDavProperty::TimezoneId), +]; + +pub(crate) static CALENDAR_ITEM_PROPS: [DavProperty; 20] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), + DavProperty::CalDav(CalDavProperty::CalendarData(CalendarData { + properties: vec![], + expand: None, + limit_recurrence: None, + limit_freebusy: None, + })), +]; + +pub(crate) async fn assert_is_unique_uid( + server: &Server, + resources: &DavResources, + account_id: u32, + calendar_id: u32, + uid: Option<&str>, +) -> crate::Result<()> { + if let Some(uid) = uid { + let hits = server + .document_ids_matching( + account_id, + Collection::CalendarEvent, + CalendarEventField::Uid, + uid.as_bytes(), + ) + .await + .caused_by(trc::location!())?; + + if !hits.is_empty() { + for path in resources.children(calendar_id) { + if hits.contains(path.document_id()) { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::NoUidConflict(resources.format_resource(path).into()), + ))); + } + } + } + } + + Ok(()) +} + +pub(crate) trait ItipPrecondition { + fn failed_precondition(&self) -> Option; +} + +impl ItipPrecondition for ItipError { + fn failed_precondition(&self) -> Option { + match self { + ItipError::MultipleOrganizer => Some(CalCondition::SameOrganizerInAllComponents), + ItipError::OrganizerIsLocalAddress + | ItipError::SenderIsNotParticipant(_) + | ItipError::OrganizerMismatch => Some(CalCondition::ValidOrganizer), + ItipError::CannotModifyProperty(_) + | ItipError::CannotModifyInstance + | ItipError::CannotModifyAddress => Some(CalCondition::AllowedAttendeeObjectChange), + ItipError::MissingUid + | ItipError::MultipleUid + | ItipError::MultipleObjectTypes + | ItipError::MultipleObjectInstances + | ItipError::MissingMethod + | ItipError::InvalidComponentType + | ItipError::OutOfSequence + | ItipError::UnknownParticipant(_) + | ItipError::UnsupportedMethod(_) => Some(CalCondition::ValidSchedulingMessage), + _ => None, + } + } +} diff --git a/crates/dav/src/calendar/proppatch.rs b/crates/dav/src/calendar/proppatch.rs new file mode 100644 index 0000000..efaf5ed --- /dev/null +++ b/crates/dav/src/calendar/proppatch.rs @@ -0,0 +1,565 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, PropStatBuilder, + common::{ + ETag, ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use calcard::common::timezone::Tz; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{ + Namespace, + property::{CalDavProperty, DavProperty, DavValue, ResourceType, WebDavProperty}, + request::{DavPropertyValue, PropertyUpdate}, + response::{BaseCondition, CalCondition, MultiStatus, Response}, + }, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{Calendar, CalendarEvent, SupportedComponent, Timezone}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::str::FromStr; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::bitmap::Bitmap; + +pub(crate) trait CalendarPropPatchRequestHandler: Sync + Send { + fn handle_calendar_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PropertyUpdate, + ) -> impl Future> + Send; + + fn apply_calendar_properties( + &self, + personal_id: u32, + calendar: &mut Calendar, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool; + + fn apply_event_properties( + &self, + event: &mut CalendarEvent, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool; +} + +impl CalendarPropPatchRequestHandler for Server { + async fn handle_calendar_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut request: PropertyUpdate, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let uri = headers.uri; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let resource = resource_ + .resource + .and_then(|r| resources.by_path(r)) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let document_id = resource.document_id(); + let collection = if resource.is_container() { + Collection::Calendar + } else { + Collection::CalendarEvent + }; + + if !request.has_changes() { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + + // Verify ACL + if !access_token.is_member(account_id) { + let (acl, document_id) = if resource.is_container() { + (Acl::Modify, resource.document_id()) + } else { + (Acl::ModifyItems, resource.parent_id().unwrap()) + }; + + if !resources.has_access_to_container(access_token, document_id, acl) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } + + // Fetch archive + let archive = self + .store() + .get_value::>(ValueKey::archive( + account_id, + collection, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection, + document_id: document_id.into(), + etag: archive.etag().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::PROPPATCH, + ) + .await?; + + let is_success; + let mut batch = BatchBuilder::new(); + let mut items = PropStatBuilder::default(); + + let etag = if resource.is_container() { + // Deserialize + let calendar = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_calendar = archive + .deserialize::() + .caused_by(trc::location!())?; + let personal_id = access_token.personal_id(account_id, Collection::Calendar); + + // Remove properties + if !request.set_first && !request.remove.is_empty() { + remove_calendar_properties( + personal_id, + &mut new_calendar, + std::mem::take(&mut request.remove), + &mut items, + ); + } + + // Set properties + is_success = self.apply_calendar_properties( + personal_id, + &mut new_calendar, + true, + request.set, + &mut items, + ); + + // Remove properties + if is_success && !request.remove.is_empty() { + remove_calendar_properties( + personal_id, + &mut new_calendar, + request.remove, + &mut items, + ); + } + + if is_success { + new_calendar + .update( + access_token.account_tenant_ids(), + calendar, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag() + } else { + calendar.etag().into() + } + } else { + // Deserialize + let event = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_event = archive + .deserialize::() + .caused_by(trc::location!())?; + + // Remove properties + if !request.set_first && !request.remove.is_empty() { + remove_event_properties( + &mut new_event, + std::mem::take(&mut request.remove), + &mut items, + ); + } + + // Set properties + is_success = self.apply_event_properties(&mut new_event, true, request.set, &mut items); + + // Remove properties + if is_success && !request.remove.is_empty() { + remove_event_properties(&mut new_event, request.remove, &mut items); + } + + if is_success { + new_event + .update( + access_token.account_tenant_ids(), + event, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag() + } else { + event.etag().into() + } + }; + + if is_success { + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + if headers.ret != Return::Minimal || !is_success { + Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body( + MultiStatus::new(vec![Response::new_propstat(uri, items.build())]) + .with_namespace(Namespace::CalDav) + .to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) + } + } + + fn apply_calendar_properties( + &self, + personal_id: u32, + calendar: &mut Calendar, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool { + let mut has_errors = false; + + for property in properties { + match (&property.property, property.value) { + (DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => { + if name.len() <= self.core.groupware.live_property_size { + calendar.preferences_mut(personal_id).name = name; + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + ( + DavProperty::CalDav(CalDavProperty::CalendarDescription), + DavValue::String(name), + ) => { + if name.len() <= self.core.groupware.live_property_size { + calendar.preferences_mut(personal_id).description = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + + has_errors = true; + } + } + ( + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + DavValue::ICalendar(ical), + ) => { + if ical.size() > self.core.groupware.max_ical_size { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } else if !ical.is_timezone() { + items.insert_precondition_failed_with_description( + property.property, + StatusCode::PRECONDITION_FAILED, + CalCondition::ValidCalendarData, + "Invalid calendar timezone", + ); + has_errors = true; + } else { + calendar.preferences_mut(personal_id).time_zone = Timezone::Custom(ical); + items.insert_ok(property.property); + } + } + (DavProperty::CalDav(CalDavProperty::TimezoneId), DavValue::String(tz_id)) => { + if let Ok(tz) = Tz::from_str(&tz_id) { + calendar.preferences_mut(personal_id).time_zone = + Timezone::IANA(tz.as_id()); + items.insert_ok(property.property); + } else { + items.insert_precondition_failed_with_description( + property.property, + StatusCode::PRECONDITION_FAILED, + CalCondition::ValidTimezone, + "Invalid timezone ID", + ); + has_errors = true; + } + } + (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { + calendar.created = dt; + items.insert_ok(property.property); + } + ( + DavProperty::WebDav(WebDavProperty::ResourceType), + DavValue::ResourceTypes(types), + ) => { + if !types + .0 + .iter() + .all(|rt| matches!(rt, ResourceType::Collection | ResourceType::Calendar)) + { + items.insert_precondition_failed( + property.property, + StatusCode::FORBIDDEN, + BaseCondition::ValidResourceType, + ); + has_errors = true; + } else { + items.insert_ok(property.property); + } + } + ( + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet), + DavValue::Components(components), + ) => { + if !is_update { + calendar.supported_components = Bitmap::::from_iter( + components + .0 + .into_iter() + .map(|v| SupportedComponent::from(v.0)), + ) + .into_inner(); + if calendar.supported_components != 0 { + items.insert_ok(property.property); + } else { + items.insert_precondition_failed_with_description( + property.property, + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarComponent, + "At least one supported component must be specified", + ); + has_errors = true; + } + } else { + items.insert_precondition_failed_with_description( + property.property, + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarComponent, + "Property cannot be modified", + ); + has_errors = true; + } + } + (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) + if self.core.groupware.dead_property_size.is_some() => + { + if is_update { + calendar.dead_properties.remove_element(dead); + } + + if calendar.dead_properties.size() + values.size() + dead.size() + < self.core.groupware.dead_property_size.unwrap() + { + calendar.dead_properties.add_element(dead.clone(), values.0); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + + has_errors = true; + } + } + (_, DavValue::Null) => { + items.insert_ok(property.property); + } + _ => { + items.insert_error_with_description( + property.property, + StatusCode::CONFLICT, + "Property cannot be modified", + ); + has_errors = true; + } + } + } + + !has_errors + } + + fn apply_event_properties( + &self, + event: &mut CalendarEvent, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool { + let mut has_errors = false; + + for property in properties { + match (&property.property, property.value) { + (DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => { + if name.len() <= self.core.groupware.live_property_size { + event.display_name = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { + event.created = dt; + items.insert_ok(property.property); + } + (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) + if self.core.groupware.dead_property_size.is_some() => + { + if is_update { + event.dead_properties.remove_element(dead); + } + + if event.dead_properties.size() + values.size() + dead.size() + < self.core.groupware.dead_property_size.unwrap() + { + event.dead_properties.add_element(dead.clone(), values.0); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (_, DavValue::Null) => { + items.insert_ok(property.property); + } + _ => { + items.insert_error_with_description( + property.property, + StatusCode::CONFLICT, + "Property cannot be modified", + ); + has_errors = true; + } + } + } + + !has_errors + } +} + +fn remove_event_properties( + event: &mut CalendarEvent, + properties: Vec, + items: &mut PropStatBuilder, +) { + for property in properties { + match &property { + DavProperty::WebDav(WebDavProperty::DisplayName) => { + event.display_name = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::DeadProperty(dead) => { + event.dead_properties.remove_element(dead); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + _ => { + items.insert_error_with_description( + property, + StatusCode::CONFLICT, + "Property cannot be deleted", + ); + } + } + } +} + +fn remove_calendar_properties( + personal_id: u32, + calendar: &mut Calendar, + properties: Vec, + items: &mut PropStatBuilder, +) { + for property in properties { + match &property { + DavProperty::CalDav(CalDavProperty::CalendarDescription) => { + calendar.preferences_mut(personal_id).description = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::CalDav(CalDavProperty::CalendarTimezone) + | DavProperty::CalDav(CalDavProperty::TimezoneId) => { + calendar.preferences_mut(personal_id).time_zone = Timezone::Default; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::DeadProperty(dead) => { + calendar.dead_properties.remove_element(dead); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + _ => { + items.insert_error_with_description( + property, + StatusCode::CONFLICT, + "Property cannot be deleted", + ); + } + } + } +} diff --git a/crates/dav/src/calendar/query.rs b/crates/dav/src/calendar/query.rs new file mode 100644 index 0000000..0d14cc7 --- /dev/null +++ b/crates/dav/src/calendar/query.rs @@ -0,0 +1,657 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::freebusy::freebusy_in_range; +use crate::{ + DavError, + common::{ + CalendarFilter, DavQuery, + propfind::{PropFindItem, PropFindRequestHandler}, + uri::DavUriResource, + }, +}; +use calcard::{ + common::{PartialDateTime, timezone::Tz}, + icalendar::{ + ArchivedICalendar, ArchivedICalendarComponent, ArchivedICalendarEntry, + ArchivedICalendarParameter, ArchivedICalendarProperty, ArchivedICalendarValue, + ICalendarComponentType, ICalendarEntry, ICalendarParameterName, ICalendarProperty, + ICalendarValue, + }, +}; +use common::{DavResource, Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::{CalDavProperty, CalendarData, DavProperty}, + request::{CalendarQuery, Filter, FilterOp, PropFind, Timezone}, + response::MultiStatus, + }, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{ArchivedCalendarEvent, expand::CalendarEventExpansion}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::{fmt::Write, slice::Iter, str::FromStr}; +use store::{ + ahash::{AHashMap, AHashSet}, + write::serialize::rkyv_deserialize, +}; +use trc::AddContext; +use types::{TimeRange, acl::Acl, collection::SyncCollection}; + +pub(crate) trait CalendarQueryRequestHandler: Sync + Send { + fn handle_calendar_query_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: CalendarQuery, + ) -> impl Future> + Send; +} + +impl CalendarQueryRequestHandler for Server { + async fn handle_calendar_query_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: CalendarQuery, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let Some(resource) = resources.by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) else { + return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body(MultiStatus::not_found(headers.uri).to_string())); + }; + if !resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Obtain shared ids + let shared_ids = if !access_token.is_member(account_id) { + resources + .shared_items(access_token, [Acl::ReadItems], false) + .into() + } else { + None + }; + + // Pre-filter by date range + let filter_range = extract_filter_range(&request); + + // Obtain document ids in folder + let mut items = Vec::with_capacity(16); + for resource in resources.children(resource.document_id()) { + if shared_ids + .as_ref() + .is_none_or(|ids| ids.contains(resource.document_id())) + && filter_range + .as_ref() + .is_none_or(|range| is_resource_in_time_range(resource.resource, range)) + { + items.push(PropFindItem::new( + resources.format_resource(resource), + account_id, + resource, + )); + } + } + + // Extract the time range from the request + let max_time_range = extract_data_range(&request.properties, filter_range); + + self.handle_dav_query( + access_token, + DavQuery::calendar_query(request, max_time_range, items, headers), + ) + .await + } +} + +pub(crate) fn is_resource_in_time_range(resource: &DavResource, filter: &TimeRange) -> bool { + // Check whether the resource has a time range and if it overlaps with the filter + if let Some((start, end)) = resource.event_time_range() { + ((filter.start < end) || (filter.start <= start)) + && (filter.end > start || filter.end >= end) + } else { + // If the resource does not have a time range, it is not in the range + false + } +} + +fn extract_filter_range(query: &CalendarQuery) -> Option { + let mut range = TimeRange { + start: i64::MAX, + end: i64::MIN, + }; + + for filter in &query.filters { + let op = match filter { + Filter::Component { op, .. } => op, + Filter::Property { op, .. } => op, + Filter::Parameter { op, .. } => op, + _ => continue, + }; + if let FilterOp::TimeRange(date_range) = op { + if date_range.start < range.start { + range.start = date_range.start; + } + if date_range.end > range.end { + range.end = date_range.end; + } + } + } + + if range.start != i64::MAX { + Some(range) + } else { + None + } +} + +fn extract_data_range(propfind: &PropFind, filter_range: Option) -> Option { + let props = match propfind { + PropFind::AllProp(props) | PropFind::Prop(props) => props, + PropFind::PropName => &[][..], + }; + + for prop in props { + if let DavProperty::CalDav(CalDavProperty::CalendarData(data)) = prop { + let mut range = filter_range.unwrap_or(TimeRange { + start: i64::MAX, + end: i64::MIN, + }); + + for data_range in [&data.expand, &data.limit_recurrence, &data.limit_freebusy] + .into_iter() + .flatten() + { + if data_range.start < range.start { + range.start = data_range.start; + } + if data_range.end > range.end { + range.end = data_range.end; + } + } + + return if range.start != i64::MAX { + Some(range) + } else { + None + }; + } + } + + filter_range +} + +pub fn try_parse_tz(tz: &Timezone) -> Option { + match tz { + Timezone::Name(value) | Timezone::Id(value) => Tz::from_str(value).ok(), + Timezone::None => None, + } +} + +pub(crate) struct CalendarQueryHandler { + default_tz: Tz, + expanded_times: Vec, +} + +impl CalendarQueryHandler { + pub fn new( + event: &ArchivedCalendarEvent, + max_time_range: Option, + default_tz: Tz, + ) -> Self { + Self { + default_tz, + expanded_times: max_time_range + .map(|max_time_range| { + event + .data + .expand(default_tz, max_time_range) + .unwrap_or_else(|| { + trc::event!( + Calendar(trc::CalendarEvent::RuleExpansionError), + Reason = "chrono error", + Details = event.data.event.to_string(), + ); + vec![] + }) + }) + .unwrap_or_default(), + } + } + + pub fn filter(&mut self, event: &ArchivedCalendarEvent, filters: &CalendarFilter) -> bool { + let ical = &event.data.event; + let mut is_all = true; + let mut matches_one = false; + + for filter in filters { + match filter { + Filter::AnyOf => { + is_all = false; + } + Filter::AllOf => { + is_all = true; + } + Filter::Property { prop, op, comp } => { + let mut properties = find_components(ical, comp) + .flat_map(|(_, comp)| find_properties(comp, prop)) + .peekable(); + + let result = if properties.peek().is_some() { + properties.any(|entry| { + match op { + FilterOp::Exists => true, + FilterOp::Undefined => false, + FilterOp::TextMatch(text_match) => { + let mut matched_any = false; + + for value in entry.values.iter() { + if let Some(text) = value.as_text() + && text_match.matches(text) + { + matched_any = true; + break; + } + } + + matched_any + } + FilterOp::TimeRange(range) => { + if let Some(ArchivedICalendarValue::PartialDateTime(date)) = + entry.values.first() + { + let tz = entry + .tz_id() + .and_then(|tz_id| Tz::from_str(tz_id).ok()) + .unwrap_or(self.default_tz); + + if let Some(date) = date + .to_date_time() + .and_then(|date| date.to_date_time_with_tz(tz)) + { + let timestamp = date.timestamp(); + // RFC4791#9.9: start <= DTSTART AND end > DTSTART + range.start <= timestamp && range.end > timestamp + } else { + false + } + } else { + false + } + } + } + }) + } else { + matches!(op, FilterOp::Undefined) + }; + + if result { + matches_one = true; + } else if is_all { + return false; + } + } + Filter::Parameter { + prop, + param, + op, + comp, + } => { + let mut parameters = find_components(ical, comp) + .flat_map(|(_, comp)| { + find_properties(comp, prop) + .filter_map(|entry| find_parameter(entry, param)) + }) + .peekable(); + + let result = if parameters.peek().is_some() { + parameters.any(|entry| match op { + FilterOp::Exists => true, + FilterOp::Undefined => false, + FilterOp::TextMatch(text_match) => { + if let Some(text) = entry.value.as_text() { + text_match.matches(text) + } else { + false + } + } + FilterOp::TimeRange(_) => false, + }) + } else { + matches!(op, FilterOp::Undefined) + }; + + if result { + matches_one = true; + } else if is_all { + return false; + } + } + Filter::Component { comp, op } => { + let result = match op { + FilterOp::Exists => find_components(ical, comp).next().is_some(), + FilterOp::Undefined => find_components(ical, comp).next().is_none(), + FilterOp::TimeRange(range) => { + if !matches!(comp.last(), Some(ICalendarComponentType::VAlarm)) { + let matching_comp_ids = find_components(ical, comp) + .map(|(id, comp)| (id as u32, &comp.component_type)) + .collect::>(); + + !matching_comp_ids.is_empty() + && self.expanded_times.iter().any(|event| { + matching_comp_ids.get(&event.comp_id).is_some_and(|ct| { + range.is_in_range( + ct == &&ICalendarComponentType::VTodo, + event.start, + event.end, + ) + }) + }) + } else { + let matching_comp_ids = event + .data + .alarms + .iter() + .map(|alarm| alarm.parent_id.to_native() as u32) + .collect::>(); + + !matching_comp_ids.is_empty() + && self.expanded_times.iter().any(|time| { + matching_comp_ids.contains(&time.comp_id) + && event.data.alarms.iter().any(|alarm| { + alarm.parent_id.to_native() as u32 == time.comp_id + && alarm + .delta + .to_timestamp( + time.start, + time.end, + self.default_tz, + ) + .is_some_and(|timestamp| { + range.is_in_range( + false, timestamp, timestamp, + ) + }) + }) + }) + } + } + FilterOp::TextMatch(_) => false, + }; + + if result { + matches_one = true; + } else if is_all { + return false; + } + } + } + } + + is_all || matches_one + } + + pub fn serialize_ical( + &mut self, + event: &ArchivedCalendarEvent, + data: &CalendarData, + instances_limit: &mut usize, + ) -> Option { + let mut out = String::with_capacity(event.size.to_native() as usize); + let _v = [0.into()]; + let mut component_iter: Iter<'_, rkyv::rend::u32_le> = _v.iter(); + let mut component_stack: Vec<(&ArchivedICalendarComponent, Iter<'_, rkyv::rend::u32_le>)> = + Vec::with_capacity(4); + + if data.expand.is_some() { + self.expanded_times.sort_unstable_by_key(|a| a.start); + } + + loop { + if let Some(component_id) = component_iter.next() { + let component_id = component_id.to_native(); + let component = event + .data + .event + .components + .get(component_id as usize) + .unwrap(); + + // Limit recurrence override + if let Some(limit_recurrence) = &data.limit_recurrence + && component.is_recurrence_override() + && !self.expanded_times.iter().any(|event| { + event.comp_id == component_id + && limit_recurrence.is_in_range( + component.component_type == ICalendarComponentType::VTodo, + event.start, + event.end, + ) + }) + { + continue; + } + + // Limit freebusy + if let Some(limit_recurrence) = &data.limit_freebusy + && component.component_type == ICalendarComponentType::VFreebusy + && !self.expanded_times.iter().any(|event| { + event.comp_id == component_id + && limit_recurrence.is_in_range(false, event.start, event.end) + }) + { + continue; + } + + // Filter entries + let mut entries = component + .entries + .iter() + .filter_map(|entry| { + if data.properties.is_empty() + || component.component_type == ICalendarComponentType::VCalendar + { + Some((entry, true)) + } else { + data.properties + .iter() + .find(|prop| { + prop.component.as_ref().is_none_or(|comp| { + comp == &component.component_type + || component_stack.iter().any(|(parent_comp, _)| { + comp == &parent_comp.component_type + }) + }) && prop.name.as_ref().is_none_or(|name| name == &entry.name) + }) + .map(|prop| (entry, !prop.no_value)) + } + }) + .peekable(); + + // Expand recurrences + let component_name = component.component_type.as_str(); + if let Some(expand) = &data + .expand + .filter(|_| component.component_type.has_time_ranges()) + { + let is_recurrent = component.is_recurrent(); + let is_recurrent_or_override = + is_recurrent || component.is_recurrence_override(); + let is_todo = component.component_type == ICalendarComponentType::VTodo; + let mut has_duration = false; + let entries = entries + .filter(|(entry, _)| match &entry.name { + ArchivedICalendarProperty::Dtstart + | ArchivedICalendarProperty::Dtend + | ArchivedICalendarProperty::Exdate + | ArchivedICalendarProperty::Exrule + | ArchivedICalendarProperty::Rdate + | ArchivedICalendarProperty::Rrule + | ArchivedICalendarProperty::RecurrenceId => false, + ArchivedICalendarProperty::Due + | ArchivedICalendarProperty::Completed + | ArchivedICalendarProperty::Created => is_recurrent, + ArchivedICalendarProperty::Duration => { + has_duration = true; + true + } + _ => true, + }) + .collect::>(); + for event in &self.expanded_times { + if event.comp_id == component_id + && (!is_recurrent_or_override + || expand.is_in_range(is_todo, event.start, event.end)) + { + if *instances_limit > 0 { + *instances_limit -= 1; + } else { + return None; + } + let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); + + // Write DTSTART, DTEND and RECURRENCE-ID + let mut entry = ICalendarEntry { + name: ICalendarProperty::Dtstart, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(event.start), + ))], + }; + let _ = entry.write_to(&mut out); + if is_recurrent_or_override { + entry.name = ICalendarProperty::RecurrenceId; + let _ = entry.write_to(&mut out); + } + if !has_duration { + entry.name = ICalendarProperty::Dtend; + entry.values = vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(event.end), + ))]; + let _ = entry.write_to(&mut out); + } + + // Write other component entries + for (entry, with_value) in &entries { + let _ = entry.write_to(&mut out, *with_value); + } + let _ = write!(&mut out, "END:{component_name}\r\n"); + } + } + } else if entries.peek().is_some() + || (component.component_type == ICalendarComponentType::VCalendar + && !component.component_ids.is_empty()) + { + let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); + + match data.limit_freebusy { + Some(range) + if component.component_type == ICalendarComponentType::VFreebusy => + { + // Filter freebusy + for (entry, with_value) in entries { + if matches!(entry.name, ArchivedICalendarProperty::Freebusy) { + let mut fb_in_range = + freebusy_in_range(entry, &range, self.default_tz) + .peekable(); + if fb_in_range.peek().is_none() { + continue; + } else { + let _ = ICalendarEntry { + name: ICalendarProperty::Freebusy, + params: rkyv_deserialize(&entry.params) + .ok() + .unwrap_or_default(), + values: fb_in_range.collect(), + } + .write_to(&mut out); + } + } else { + let _ = entry.write_to(&mut out, with_value); + } + } + } + _ => { + for (entry, with_value) in entries { + let _ = entry.write_to(&mut out, with_value); + } + } + } + + if !component.component_ids.is_empty() { + component_stack.push((component, component_iter)); + component_iter = component.component_ids.iter(); + } else if component.component_ids.is_empty() { + let _ = write!(&mut out, "END:{component_name}\r\n"); + } + } + } else if let Some((component, iter)) = component_stack.pop() { + let _ = write!(&mut out, "END:{}\r\n", component.component_type.as_str()); + component_iter = iter; + } else { + break; + } + } + + Some(out) + } + + pub fn into_expanded_times(self) -> Vec { + self.expanded_times + } +} + +#[inline(always)] +fn find_components<'x>( + ical: &'x ArchivedICalendar, + comp: &[ICalendarComponentType], +) -> impl Iterator { + // TODO: Properly expand the component type path + let comp = comp.last().unwrap_or(&ICalendarComponentType::VCalendar); + ical.components + .iter() + .enumerate() + .filter(move |(_, entry)| { + comp == &ICalendarComponentType::VCalendar || &entry.component_type == comp + }) +} + +#[inline(always)] +fn find_properties<'x>( + comp: &'x ArchivedICalendarComponent, + prop: &ICalendarProperty, +) -> impl Iterator { + comp.entries.iter().filter(move |entry| &entry.name == prop) +} + +#[inline(always)] +fn find_parameter<'x>( + entry: &'x ArchivedICalendarEntry, + name: &ICalendarParameterName, +) -> Option<&'x ArchivedICalendarParameter> { + entry.params.iter().find(|param| param.name == *name) +} diff --git a/crates/dav/src/calendar/scheduling.rs b/crates/dav/src/calendar/scheduling.rs new file mode 100644 index 0000000..ffc950a --- /dev/null +++ b/crates/dav/src/calendar/scheduling.rs @@ -0,0 +1,438 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavErrorCondition, DavMethod, + calendar::freebusy::CalendarFreebusyRequestHandler, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use calcard::{ + Entry, Parser, + icalendar::{ + ICalendarComponentType, ICalendarEntry, ICalendarMethod, ICalendarProperty, ICalendarValue, + Uri, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::Rfc1123DateTime, + request::FreeBusyQuery, + response::{CalCondition, Href, ScheduleResponse, ScheduleResponseItem}, + }, +}; +use groupware::{ + DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification, strip_mailto_scheme, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ahash::AHashMap, write::BatchBuilder}; +use trc::AddContext; +use types::collection::{Collection, SyncCollection}; +use utils::sanitize_email; + +pub(crate) trait CalendarEventNotificationHandler: Sync + Send { + fn handle_scheduling_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> impl Future> + Send; + + fn handle_scheduling_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> impl Future> + Send; + + fn handle_scheduling_post_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + ) -> impl Future> + Send; +} + +impl CalendarEventNotificationHandler for Server { + async fn handle_scheduling_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::CalendarEventNotification, + ) + .await + .caused_by(trc::location!())?; + let resource = resources + .by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Fetch event + let event_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEventNotification, + resource.document_id(), + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .unarchive::() + .caused_by(trc::location!())?; + + // Validate headers + let etag = event_.etag(); + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarEventNotification, + document_id: resource.document_id().into(), + etag: etag.clone().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::GET, + ) + .await?; + + let response = HttpResponse::new(StatusCode::OK) + .with_content_type("text/calendar; charset=utf-8") + .with_etag(etag) + .with_last_modified(Rfc1123DateTime::new(i64::from(event.modified)).to_string()); + + let ical = event.event.to_string(); + + if !is_head { + Ok(response.with_binary_body(ical)) + } else { + Ok(response.with_content_length(ical.len())) + } + } + + async fn handle_scheduling_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let delete_path = resource + .resource + .filter(|r| !r.is_empty()) + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::CalendarEventNotification, + ) + .await + .caused_by(trc::location!())?; + + // Check resource type + let resource = resources + .by_path(delete_path) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let document_id = resource.document_id(); + let event_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEventNotification, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarEventNotification, + document_id: document_id.into(), + etag: event_.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Delete event + let mut batch = BatchBuilder::new(); + DestroyArchive(event) + .delete( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } + + async fn handle_scheduling_post_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + if resource.resource.is_none_or(|r| r != "outbox") { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Parse iTIP message + if bytes.len() > self.core.groupware.max_ical_size { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::MaxResourceSize(self.core.groupware.max_ical_size as u32), + ))); + } + let itip_raw = std::str::from_utf8(&bytes).map_err(|_| { + DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Invalid UTF-8 in iCalendar data"), + ) + })?; + let itip = match Parser::new(itip_raw).entry() { + Entry::ICalendar(ical) if ical.components.len() > 1 => ical, + _ => { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Failed to parse iCalendar data"), + )); + } + }; + + // Parse request + let mut from_date = None; + let mut to_date = None; + let mut organizer = None; + let mut attendees = AHashMap::new(); + let mut uid = None; + let tz_resolver = itip.build_tz_resolver(); + let mut found_freebusy = false; + + for component in &itip.components { + if component.component_type != ICalendarComponentType::VFreebusy { + continue; + } else if !found_freebusy { + found_freebusy = true; + } else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Multiple VFREEBUSY components found"), + )); + } + + for entry in &component.entries { + let tz_id = entry.tz_id(); + match (&entry.name, entry.values.first()) { + (ICalendarProperty::Dtstart, Some(ICalendarValue::PartialDateTime(dt))) => { + from_date = dt.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id)); + } + (ICalendarProperty::Dtend, Some(ICalendarValue::PartialDateTime(dt))) => { + to_date = dt.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id)); + } + (ICalendarProperty::Uid, Some(ICalendarValue::Text(_))) => { + uid = Some(entry); + } + ( + ICalendarProperty::Organizer, + Some(ICalendarValue::Text(_) | ICalendarValue::Uri(Uri::Location(_))), + ) => { + organizer = Some(entry); + } + ( + ICalendarProperty::Attendee, + Some( + ICalendarValue::Text(value) | ICalendarValue::Uri(Uri::Location(value)), + ), + ) => { + if let Some(email) = sanitize_email(strip_mailto_scheme(value.as_str())) { + attendees.insert(email, entry); + } + } + _ => {} + } + } + } + + let (Some(from_date), Some(to_date)) = (from_date, to_date) else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing DTSTART or DTEND in VFREEBUSY component"), + )); + }; + let Some(organizer) = organizer else { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing ORGANIZER in VFREEBUSY component"), + )); + }; + if attendees.is_empty() { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::BAD_REQUEST, + CalCondition::ValidSchedulingMessage, + ) + .with_details("Missing ATTENDEE in VFREEBUSY component"), + )); + } + + let mut response = ScheduleResponse::default(); + + for (email, attendee) in attendees { + if let Some(account_id) = self + .account_id_from_email(&email, true) + .await + .caused_by(trc::location!())? + { + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + if let Some(resource) = self + .core + .groupware + .default_calendar_name + .as_ref() + .and_then(|name| resources.by_path(name)) + { + let mut free_busy = self + .build_freebusy_object( + access_token, + FreeBusyQuery::new(from_date.timestamp(), to_date.timestamp()), + &resources, + account_id, + resource, + ) + .await?; + + // Add iTIP method + free_busy.components[0].entries.push(ICalendarEntry { + name: ICalendarProperty::Method, + params: vec![], + values: vec![ICalendarValue::Method(ICalendarMethod::Reply)], + }); + + // Add properties + let component = &mut free_busy.components[1]; + component.entries.push(organizer.clone()); + component.entries.push(attendee.clone()); + if let Some(uid) = uid { + component.entries.push(uid.clone()); + } + + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "2.0;Success".into(), + calendar_data: Some(free_busy.to_string()), + }); + } else { + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "3.7;Default calendar not found".into(), + calendar_data: None, + }); + } + } else { + response.items.0.push(ScheduleResponseItem { + recipient: Href(format!("mailto:{email}")), + request_status: "3.7;Invalid calendar user or insufficient permissions".into(), + calendar_data: None, + }); + } + } + + Ok(HttpResponse::new(StatusCode::OK).with_xml_body(response.to_string())) + } +} diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs new file mode 100644 index 0000000..e6a6dd8 --- /dev/null +++ b/crates/dav/src/calendar/update.rs @@ -0,0 +1,536 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::assert_is_unique_uid; +use crate::{ + DavError, DavErrorCondition, DavMethod, + calendar::ItipPrecondition, + common::{ + ETag, ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, + fix_percent_encoding, +}; +use calcard::{ + Entry, Parser, + common::timezone::Tz, + icalendar::{ICalendar, ICalendarComponentType}, +}; +use common::{DavName, Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{property::Rfc1123DateTime, response::CalCondition}, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{CalendarEvent, CalendarEventData, itip::ItipSendStatus}, + scheduling::{ + ItipMessages, event_create::itip_create, event_update::itip_update, + itip::itip_set_unreachable_status, + }, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::collections::HashSet; +use store::write::{BatchBuilder, now}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CalendarUpdateRequestHandler: Sync + Send { + fn handle_calendar_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + is_patch: bool, + ) -> impl Future> + Send; +} + +impl CalendarUpdateRequestHandler for Server { + async fn handle_calendar_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + _is_patch: bool, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let resource_name = fix_percent_encoding( + resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?, + ); + + if bytes.len() > self.core.groupware.max_ical_size { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::MaxResourceSize(self.core.groupware.max_ical_size as u32), + ))); + } + let ical_raw = std::str::from_utf8(&bytes).map_err(|_| { + DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarData, + ) + .with_details("Invalid UTF-8 in iCalendar data"), + ) + })?; + + let ical = match Parser::new(ical_raw).entry() { + Entry::ICalendar(ical) => ical, + _ => { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::SupportedCalendarData, + ) + .with_details("Failed to parse iCalendar data"), + )); + } + }; + + let account_info = self + .scheduling_account_info(access_token.account_id(), account_id) + .await + .caused_by(trc::location!())?; + + if let Some(resource) = resources.by_path(resource_name.as_ref()) { + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + let parent_id = resource.parent_id().unwrap(); + let document_id = resource.document_id(); + if !access_token.is_member(account_id) + && !resources.has_access_to_container(access_token, parent_id, Acl::ModifyItems) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Update + let event_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate headers + match self + .validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::CalendarEvent, + document_id: Some(document_id), + etag: event.etag().into(), + path: resource_name.as_ref(), + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await + { + Ok(_) => {} + Err(DavError::Code(StatusCode::PRECONDITION_FAILED)) + if headers.ret == Return::Representation => + { + return Ok(HttpResponse::new(StatusCode::PRECONDITION_FAILED) + .with_content_type("text/calendar; charset=utf-8") + .with_etag(event.etag()) + .with_last_modified( + Rfc1123DateTime::new(i64::from(event.inner.modified)).to_string(), + ) + .with_header("Preference-Applied", "return=representation") + .with_binary_body(event.inner.data.event.to_string())); + } + Err(e) => return Err(e), + } + + if ical == event.inner.data.event { + // No changes, return existing event + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + + // Validate iCal + if event.inner.data.event.uids().next().unwrap_or_default() != validate_ical(&ical)? { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::NoUidConflict(resources.format_resource(resource).into()), + ))); + } + + // Validate schedule tag + if headers.if_schedule_tag.is_some() + && event.inner.schedule_tag.as_ref().map(|t| t.to_native()) + != headers.if_schedule_tag + { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + + // Obtain previous alarm + let now = now() as i64; + let prev_email_alarm = event.inner.data.next_alarm(now, Tz::Floating); + + // Build event + let mut next_email_alarm = None; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + let old_ical = new_event.data.event; + new_event.size = bytes.len() as u32; + new_event.data = CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + + // Scheduling + let mut itip_messages = None; + let itip_status = ItipSendStatus::resolve( + self, + access_token, + &account_info, + new_event.data.event_range_end(), + ); + if itip_status.is_send() { + let result = if new_event.schedule_tag.is_some() { + itip_update( + &mut new_event.data.event, + &old_ical, + account_info.addresses(), + ) + } else { + itip_create(&mut new_event.data.event, account_info.addresses()) + }; + + match result { + Ok(messages) => { + let mut is_organizer = false; + if messages + .iter() + .map(|r| { + is_organizer = r.from_organizer; + r.to.len() + }) + .sum::() + < self.core.groupware.itip_outbound_max_recipients + { + // Only update schedule tag if the user is the organizer + if is_organizer { + if let Some(schedule_tag) = &mut new_event.schedule_tag { + *schedule_tag += 1; + } else { + new_event.schedule_tag = Some(1); + } + } + + itip_messages = Some(ItipMessages::new(messages)); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::MaxAttendeesPerInstance, + ))); + } + } + Err(err) => { + if let Some(failed_precondition) = err.failed_precondition() { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + failed_precondition, + ) + .with_details(err.to_string()), + )); + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + Reason = err.to_string(), + ); + + // Event changed, but there are no iTIP messages to send + if let Some(schedule_tag) = &mut new_event.schedule_tag { + *schedule_tag += 1; + } + } + } + + itip_set_unreachable_status(&mut new_event.data.event, account_info.addresses()); + } else if let Some(reason) = itip_status.reason() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + Reason = reason, + ); + } + + // Validate quota + let extra_bytes = + (bytes.len() as u64).saturating_sub(u32::from(event.inner.size) as u64); + if extra_bytes > 0 { + self.has_available_quota(self.account(account_id).await?.as_ref(), extra_bytes) + .await?; + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let schedule_tag = new_event.schedule_tag; + let etag = new_event + .update( + access_token.account_tenant_ids(), + event, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + if prev_email_alarm != next_email_alarm { + if let Some(prev_alarm) = prev_email_alarm { + prev_alarm.delete_task(&mut batch); + } + if let Some(next_alarm) = next_email_alarm { + next_alarm.write_task(&mut batch); + } + } + if let Some(itip_messages) = itip_messages { + itip_messages + .queue(&mut batch) + .caused_by(trc::location!())?; + } + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::NO_CONTENT) + .with_etag_opt(etag) + .with_schedule_tag_opt(schedule_tag)) + } else if let Some((Some(parent), name)) = resources.map_parent(resource_name.as_ref()) { + if !parent.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) + && !resources.has_access_to_container( + access_token, + parent.document_id(), + Acl::AddItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: resource_name.as_ref(), + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await?; + + // Validate ical object + assert_is_unique_uid( + self, + &resources, + account_id, + parent.document_id(), + validate_ical(&ical)?.into(), + ) + .await?; + + // Build event + let mut next_email_alarm = None; + let mut event = CalendarEvent { + names: vec![DavName { + name: name.to_string(), + parent_id: parent.document_id(), + }], + data: CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ), + size: bytes.len() as u32, + ..Default::default() + }; + + // Scheduling + let mut itip_messages = None; + let itip_status = ItipSendStatus::resolve( + self, + access_token, + &account_info, + event.data.event_range_end(), + ); + if itip_status.is_send() { + match itip_create(&mut event.data.event, account_info.addresses()) { + Ok(messages) => { + if messages.iter().map(|r| r.to.len()).sum::() + < self.core.groupware.itip_outbound_max_recipients + { + event.schedule_tag = Some(1); + itip_messages = Some(ItipMessages::new(messages)); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::MaxAttendeesPerInstance, + ))); + } + } + Err(err) => { + if let Some(failed_precondition) = err.failed_precondition() { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + failed_precondition, + ) + .with_details(err.to_string()), + )); + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + Reason = err.to_string(), + ); + } + } + + itip_set_unreachable_status(&mut event.data.event, account_info.addresses()); + } else if let Some(reason) = itip_status.reason() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + Reason = reason, + ); + } + + // Validate quota + if !bytes.is_empty() { + self.has_available_quota( + self.account(account_id).await?.as_ref(), + bytes.len() as u64, + ) + .await?; + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + let schedule_tag = event.schedule_tag; + let etag = event + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + if let Some(itip_messages) = itip_messages { + itip_messages + .queue(&mut batch) + .caused_by(trc::location!())?; + } + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED) + .with_etag_opt(etag) + .with_schedule_tag_opt(schedule_tag)) + } else { + Err(DavError::Code(StatusCode::CONFLICT))? + } + } +} + +fn validate_ical(ical: &ICalendar) -> crate::Result<&str> { + // Validate UIDs + let mut uids = HashSet::with_capacity(1); + + // Validate component types + let mut types: [u8; 5] = [0; 5]; + for comp in &ical.components { + *(match comp.component_type { + ICalendarComponentType::VEvent => &mut types[0], + ICalendarComponentType::VTodo => &mut types[1], + ICalendarComponentType::VJournal => &mut types[2], + ICalendarComponentType::VFreebusy => &mut types[3], + ICalendarComponentType::VAvailability => &mut types[4], + _ => { + continue; + } + }) += 1; + + if let Some(uid) = comp.uid() { + uids.insert(uid); + } + } + + if uids.len() == 1 && types.iter().filter(|&&v| v == 0).count() == 4 { + Ok(uids.iter().next().unwrap()) + } else { + Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::ValidCalendarObjectResource, + ) + .with_details("iCalendar must contain exactly one UID and same component types"), + )) + } +} diff --git a/crates/dav/src/card/copy_move.rs b/crates/dav/src/card/copy_move.rs new file mode 100644 index 0000000..eb4bb9e --- /dev/null +++ b/crates/dav/src/card/copy_move.rs @@ -0,0 +1,1066 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::assert_is_unique_uid; +use crate::{ + DavError, DavMethod, + common::{ + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use common::{DavName, Server, auth::AccessToken}; +use dav_proto::{Depth, RequestHeaders}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + contact::{AddressBook, AddressBookPreferences, ContactCard}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection, VanishedCollection}, + field::PrincipalField, +}; + +pub(crate) trait CardCopyMoveRequestHandler: Sync + Send { + fn handle_card_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> impl Future> + Send; +} + +impl CardCopyMoveRequestHandler for Server { + async fn handle_card_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> crate::Result { + // Validate source + let from_resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let from_account_id = from_resource_.account_id; + let from_resources = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let from_resource_name = from_resource_ + .resource + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let from_resource = from_resources + .by_path(from_resource_name) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate ACL + if !access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + if from_resource.is_container() { + from_resource.document_id() + } else { + from_resource.parent_id().unwrap() + }, + Acl::ReadItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate destination + let destination = self + .validate_uri_with_status( + access_token, + headers + .destination + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, + ) + .await?; + if destination.collection != Collection::AddressBook { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + let to_account_id = destination + .account_id + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + let to_resources = if to_account_id == from_account_id { + from_resources.clone() + } else { + self.fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())? + }; + + // Validate headers + let destination_resource_name = destination + .resource + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + let to_resource = to_resources.by_path(destination_resource_name); + self.validate_headers( + access_token, + headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: if from_resource.is_container() { + Collection::AddressBook + } else { + Collection::ContactCard + }, + document_id: Some(from_resource.document_id()), + path: from_resource_name, + ..Default::default() + }, + ResourceState { + account_id: to_account_id, + collection: to_resource + .map(|r| { + if r.is_container() { + Collection::AddressBook + } else { + Collection::ContactCard + } + }) + .unwrap_or(Collection::AddressBook), + document_id: Some(to_resource.map(|r| r.document_id()).unwrap_or(u32::MAX)), + path: destination_resource_name, + ..Default::default() + }, + ], + Default::default(), + if is_move { + DavMethod::MOVE + } else { + DavMethod::COPY + }, + ) + .await?; + + // Map destination + if let Some(to_resource) = to_resource { + if from_resource.path() == to_resource.path() { + // Same resource + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + let new_name = destination_resource_name + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(destination_resource_name); + + match (from_resource.is_container(), to_resource.is_container()) { + (true, true) => { + let from_children_ids = from_resources + .subtree(from_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + let to_document_ids = to_resources + .subtree(destination_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + + // Validate ACLs + if !access_token.is_member(to_account_id) + || (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_resource.document_id(), + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Overwrite container + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_children_ids, + from_resources.format_collection(from_resource_name), + to_account_id, + to_resource.document_id().into(), + to_document_ids, + new_name, + is_move, + ) + .await + } + (false, false) => { + // Overwrite card + let from_addressbook_id = from_resource.parent_id().unwrap(); + let to_addressbook_id = to_resource.parent_id().unwrap(); + + // Validate ACL + if (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_addressbook_id, + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + || (!access_token.is_member(to_account_id) + && !to_resources.has_access_to_container( + access_token, + to_addressbook_id, + Acl::RemoveItems, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + if is_move { + move_card( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_addressbook_id, + from_resources.format_item(from_resource_name), + to_account_id, + to_resource.document_id().into(), + to_addressbook_id, + new_name, + ) + .await + } else { + copy_card( + self, + access_token, + from_account_id, + from_resource.document_id(), + to_account_id, + to_resource.document_id().into(), + to_addressbook_id, + new_name, + ) + .await + } + } + _ => Err(DavError::Code(StatusCode::BAD_GATEWAY)), + } + } else if let Some((parent_resource, new_name)) = + to_resources.map_parent(destination_resource_name) + { + if let Some(parent_resource) = parent_resource { + // Creating items under a card is not allowed + // Copying/moving containers under a container is not allowed + if !parent_resource.is_container() || from_resource.is_container() { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + + // Validate ACL + let from_addressbook_id = from_resource.parent_id().unwrap(); + let to_addressbook_id = parent_resource.document_id(); + if (!access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_addressbook_id, + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + )) + || (!access_token.is_member(to_account_id) + && !to_resources.has_access_to_container( + access_token, + to_addressbook_id, + Acl::AddItems, + )) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Copy/move card + if is_move { + if from_account_id != to_account_id + || parent_resource.document_id() != from_addressbook_id + { + move_card( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_addressbook_id, + from_resources.format_item(from_resource_name), + to_account_id, + None, + to_addressbook_id, + new_name, + ) + .await + } else { + rename_card( + self, + access_token, + from_account_id, + from_resource.document_id(), + from_addressbook_id, + new_name, + from_resources.format_item(from_resource_name), + ) + .await + } + } else { + copy_card( + self, + access_token, + from_account_id, + from_resource.document_id(), + to_account_id, + None, + to_addressbook_id, + new_name, + ) + .await + } + } else { + // Copying/moving cards to the root is not allowed + if !from_resource.is_container() { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + + // Shared users cannot create containers + if !access_token.is_member(to_account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate ACLs + if !access_token.is_member(from_account_id) + && !from_resources.has_access_to_container( + access_token, + from_resource.document_id(), + if is_move { + Acl::RemoveItems + } else { + Acl::ReadItems + }, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Copy/move container + let from_children_ids = from_resources + .subtree(from_resource_name) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(); + if is_move { + if from_account_id != to_account_id { + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + if headers.depth != Depth::Zero { + from_children_ids + } else { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + }, + from_resources.format_collection(from_resource_name), + to_account_id, + None, + vec![], + new_name, + true, + ) + .await + } else { + rename_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + new_name, + from_resources.format_collection(from_resource_name), + ) + .await + } + } else { + copy_container( + self, + access_token, + from_account_id, + from_resource.document_id(), + if headers.depth != Depth::Zero { + from_children_ids + } else { + vec![] + }, + from_resources.format_collection(from_resource_name), + to_account_id, + None, + vec![], + new_name, + false, + ) + .await + } + } + } else { + Err(DavError::Code(StatusCode::CONFLICT)) + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn copy_card( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + to_account_id: u32, + to_document_id: Option, + to_addressbook_id: u32, + new_name: &str, +) -> crate::Result { + // Fetch card + let card_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::ContactCard, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + + // Validate UID + assert_is_unique_uid( + server, + server + .fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_addressbook_id, + card.inner.card.uid(), + ) + .await?; + + if from_account_id == to_account_id { + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names.push(DavName { + name: new_name.to_string(), + parent_id: to_addressbook_id, + }); + new_card + .update( + access_token.account_tenant_ids(), + card, + from_account_id, + from_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } else { + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names = vec![DavName { + name: new_name.to_string(), + parent_id: to_addressbook_id, + }]; + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + new_card + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + let response = if let Some(to_document_id) = to_document_id { + // Overwrite card on destination + let card_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::ContactCard, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(card_) = card_ { + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(card) + .delete( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + to_addressbook_id, + None, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED)) + }; + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + response +} + +#[allow(clippy::too_many_arguments)] +async fn move_card( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + from_addressbook_id: u32, + from_resource_path: String, + to_account_id: u32, + to_document_id: Option, + to_addressbook_id: u32, + new_name: &str, +) -> crate::Result { + // Fetch card + let card_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::ContactCard, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate UID + if from_account_id != to_account_id + || from_addressbook_id != to_addressbook_id + || to_document_id.is_none() + { + assert_is_unique_uid( + server, + server + .fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_addressbook_id, + card.inner.card.uid(), + ) + .await?; + } + + let mut batch = BatchBuilder::new(); + if from_account_id == to_account_id { + let mut name_idx = None; + for (idx, name) in card.inner.names.iter().enumerate() { + if name.parent_id == from_addressbook_id { + name_idx = Some(idx); + break; + } + } + + let name_idx = if let Some(name_idx) = name_idx { + name_idx + } else { + return Err(DavError::Code(StatusCode::NOT_FOUND)); + }; + + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names.swap_remove(name_idx); + new_card.names.push(DavName { + name: new_name.to_string(), + parent_id: to_addressbook_id, + }); + new_card + .update( + access_token.account_tenant_ids(), + card.clone(), + from_account_id, + from_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::AddressBook, from_resource_path); + } else { + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names = vec![DavName { + name: new_name.to_string(), + parent_id: to_addressbook_id, + }]; + + DestroyArchive(card) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_document_id, + from_addressbook_id, + from_resource_path.into(), + &mut batch, + ) + .caused_by(trc::location!())?; + + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + new_card + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + let response = if let Some(to_document_id) = to_document_id { + // Overwrite card on destination + let card_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::ContactCard, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(card_) = card_ { + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(card) + .delete( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + to_addressbook_id, + None, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED)) + }; + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + response +} + +#[allow(clippy::too_many_arguments)] +async fn rename_card( + server: &Server, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + addressbook_id: u32, + new_name: &str, + from_resource_path: String, +) -> crate::Result { + // Fetch card + let card_ = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + let name_idx = card + .inner + .names + .iter() + .position(|n| n.parent_id == addressbook_id) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names[name_idx].name = new_name.to_string(); + + let mut batch = BatchBuilder::new(); + new_card + .update( + access_token.account_tenant_ids(), + card, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::AddressBook, from_resource_path); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED)) +} + +#[allow(clippy::too_many_arguments)] +async fn copy_container( + server: &Server, + access_token: &AccessToken, + from_account_id: u32, + from_document_id: u32, + from_children_ids: Vec, + from_resource_path: String, + to_account_id: u32, + to_document_id: Option, + to_children_ids: Vec, + new_name: &str, + remove_source: bool, +) -> crate::Result { + // Fetch book + let book_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::AddressBook, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let old_book = book_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut book = old_book + .deserialize::() + .caused_by(trc::location!())?; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + + if remove_source { + DestroyArchive(old_book) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_document_id, + from_resource_path.into(), + &mut batch, + ) + .caused_by(trc::location!())?; + + // Reset default address book id + let default_book_id = server + .store() + .get_value::(ValueKey { + account_id: from_account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) + .await + .caused_by(trc::location!())?; + if default_book_id.is_some_and(|id| id == from_document_id) { + batch + .with_account_id(from_account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultAddressBookId); + } + } + + let preference = book.preferences.into_iter().next().unwrap(); + book.name = new_name.to_string(); + book.subscribers.clear(); + book.acls.clear(); + book.preferences = vec![AddressBookPreferences { + account_id: to_account_id, + name: preference.name, + description: preference.description, + sort_order: 0, + }]; + + let is_overwrite = to_document_id.is_some(); + let to_document_id = if let Some(to_document_id) = to_document_id { + // Overwrite destination + let book_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::AddressBook, + to_document_id, + )) + .await + .caused_by(trc::location!())?; + if let Some(book_) = book_ { + let book = book_ + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(book) + .delete_with_cards( + server, + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + to_children_ids, + None, + &mut batch, + ) + .await + .caused_by(trc::location!())?; + } + + to_document_id + } else { + server + .store() + .assign_document_ids(to_account_id, Collection::AddressBook, 1) + .await + .caused_by(trc::location!())? + }; + book.insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + // Copy children + let mut required_space = 0; + for from_child_document_id in from_children_ids { + if let Some(card_) = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::ContactCard, + from_child_document_id, + )) + .await? + { + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_name = None; + + for name in card.inner.names.iter() { + if name.parent_id == to_document_id { + continue; + } else if name.parent_id == from_document_id { + new_name = Some(name.name.to_string()); + } + } + let new_name = if let Some(new_name) = new_name { + DavName { + name: new_name, + parent_id: to_document_id, + } + } else { + continue; + }; + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + + if from_account_id == to_account_id { + if remove_source { + new_card + .names + .retain(|name| name.parent_id != from_document_id); + } + + new_card.names.push(new_name); + new_card + .update( + access_token.account_tenant_ids(), + card, + from_account_id, + from_child_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } else { + if remove_source { + DestroyArchive(card) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_child_document_id, + from_document_id, + None, + &mut batch, + ) + .caused_by(trc::location!())?; + } + + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + new_card.names = vec![new_name]; + required_space += new_card.size as u64; + new_card + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + } + } + } + + if from_account_id != to_account_id && required_space > 0 { + server + .has_available_quota( + server.account(to_account_id).await?.as_ref(), + required_space, + ) + .await?; + } + + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + if !is_overwrite { + Ok(HttpResponse::new(StatusCode::CREATED)) + } else { + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } +} + +#[allow(clippy::too_many_arguments)] +async fn rename_container( + server: &Server, + access_token: &AccessToken, + account_id: u32, + document_id: u32, + new_name: &str, + from_resource_path: String, +) -> crate::Result { + // Fetch book + let book_ = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::AddressBook, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let book = book_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_book = book + .deserialize::() + .caused_by(trc::location!())?; + new_book.name = new_name.to_string(); + + let mut batch = BatchBuilder::new(); + new_book + .update( + access_token.account_tenant_ids(), + book, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + batch.log_vanished_item(VanishedCollection::AddressBook, from_resource_path); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED)) +} diff --git a/crates/dav/src/card/delete.rs b/crates/dav/src/card/delete.rs new file mode 100644 index 0000000..8a25b48 --- /dev/null +++ b/crates/dav/src/card/delete.rs @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use dav_proto::RequestHeaders; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + contact::{AddressBook, ContactCard}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::{BatchBuilder, ValueClass}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::PrincipalField, +}; + +pub(crate) trait CardDeleteRequestHandler: Sync + Send { + fn handle_card_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> impl Future> + Send; +} + +impl CardDeleteRequestHandler for Server { + async fn handle_card_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let delete_path = resource + .resource + .filter(|r| !r.is_empty()) + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + + // Check resource type + let delete_resource = resources + .by_path(delete_path) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let document_id = delete_resource.document_id(); + + // Fetch entry + let mut batch = BatchBuilder::new(); + if delete_resource.is_container() { + let book_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::AddressBook, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let book = book_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACL + if !access_token.is_member(account_id) + && !book + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::AddressBook, + document_id: document_id.into(), + etag: book.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + // Delete addressbook and cards + DestroyArchive(book) + .delete_with_cards( + self, + access_token.account_tenant_ids(), + account_id, + document_id, + resources + .subtree(delete_path) + .filter(|r| !r.is_container()) + .map(|r| r.document_id()) + .collect::>(), + resources.format_resource(delete_resource).into(), + &mut batch, + ) + .await + .caused_by(trc::location!())?; + + // Reset default address book id + let default_book_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) + .await + .caused_by(trc::location!())?; + if default_book_id.is_some_and(|id| id == document_id) { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultAddressBookId); + } + } else { + // Validate ACL + let addressbook_id = delete_resource.parent_id().unwrap(); + if !access_token.is_member(account_id) + && !resources.has_access_to_container( + access_token, + addressbook_id, + Acl::RemoveItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let card_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::ContactCard, + document_id: document_id.into(), + etag: card_.etag().into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + // Delete card + DestroyArchive( + card_ + .to_unarchived::() + .caused_by(trc::location!())?, + ) + .delete( + access_token.account_tenant_ids(), + account_id, + document_id, + addressbook_id, + resources.format_resource(delete_resource).into(), + &mut batch, + ) + .caused_by(trc::location!())?; + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } +} diff --git a/crates/dav/src/card/get.rs b/crates/dav/src/card/get.rs new file mode 100644 index 0000000..702ca9b --- /dev/null +++ b/crates/dav/src/card/get.rs @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{RequestHeaders, schema::property::Rfc1123DateTime}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CardGetRequestHandler: Sync + Send { + fn handle_card_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> impl Future> + Send; +} + +impl CardGetRequestHandler for Server { + async fn handle_card_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let resource = resources + .by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) + && !resources.has_access_to_container( + access_token, + resource.parent_id().unwrap(), + Acl::ReadItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Fetch card + let card_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + resource.document_id(), + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let card = card_ + .unarchive::() + .caused_by(trc::location!())?; + + // Validate headers + let etag = card_.etag(); + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::ContactCard, + document_id: resource.document_id().into(), + etag: etag.clone().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::GET, + ) + .await?; + + let response = HttpResponse::new(StatusCode::OK) + .with_content_type("text/vcard; charset=utf-8") + .with_etag(etag) + .with_last_modified(Rfc1123DateTime::new(i64::from(card.modified)).to_string()); + + let mut vcard = String::with_capacity(128); + let _ = card.card.write_to( + &mut vcard, + headers + .vcard_version + .unwrap_or(self.core.groupware.vcard_version), + ); + + if !is_head { + Ok(response.with_binary_body(vcard)) + } else { + Ok(response.with_content_length(vcard.len())) + } + } +} diff --git a/crates/dav/src/card/mkcol.rs b/crates/dav/src/card/mkcol.rs new file mode 100644 index 0000000..b76e6a1 --- /dev/null +++ b/crates/dav/src/card/mkcol.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::proppatch::CardPropPatchRequestHandler; +use crate::{ + DavError, DavMethod, PropStatBuilder, + common::{ + ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{Namespace, request::MkCol, response::MkColResponse}, +}; +use groupware::{ + cache::GroupwareCache, + contact::{AddressBook, AddressBookPreferences}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use trc::AddContext; +use types::collection::{Collection, SyncCollection}; + +pub(crate) trait CardMkColRequestHandler: Sync + Send { + fn handle_card_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> impl Future> + Send; +} + +impl CardMkColRequestHandler for Server { + async fn handle_card_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let name = resource + .resource + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } else if name.contains('/') + || self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())? + .by_path(name) + .is_some() + { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: name, + ..Default::default() + }], + Default::default(), + DavMethod::MKCOL, + ) + .await?; + + // Build file container + let mut book = AddressBook { + name: name.to_string(), + preferences: vec![AddressBookPreferences { + account_id, + name: "Address Book".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + // Apply MKCOL properties + let mut return_prop_stat = None; + if let Some(mkcol) = request { + let mut prop_stat = PropStatBuilder::default(); + if !self.apply_addressbook_properties( + access_token.personal_id(account_id, Collection::AddressBook), + &mut book, + false, + mkcol.props, + &mut prop_stat, + ) { + return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CardDav) + .to_string(), + )); + } + if headers.ret != Return::Minimal { + return_prop_stat = Some(prop_stat); + } + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::AddressBook, 1) + .await + .caused_by(trc::location!())?; + book.insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + let etag = batch.etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + + if let Some(prop_stat) = return_prop_stat { + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CardDav) + .to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } + } +} diff --git a/crates/dav/src/card/mod.rs b/crates/dav/src/card/mod.rs new file mode 100644 index 0000000..6c14e1c --- /dev/null +++ b/crates/dav/src/card/mod.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{DavError, DavErrorCondition}; +use common::{DavResources, Server}; +use dav_proto::schema::{ + property::{CardDavProperty, DavProperty, WebDavProperty}, + response::CardCondition, +}; +use hyper::StatusCode; +use trc::AddContext; +use types::{collection::Collection, field::ContactField}; + +pub mod copy_move; +pub mod delete; +pub mod get; +pub mod mkcol; +pub mod proppatch; +pub mod query; +pub mod update; + +pub(crate) static CARD_CONTAINER_PROPS: [DavProperty; 23] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes), + DavProperty::WebDav(WebDavProperty::QuotaUsedBytes), + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + DavProperty::CardDav(CardDavProperty::SupportedAddressData), + DavProperty::CardDav(CardDavProperty::SupportedCollationSet), + DavProperty::CardDav(CardDavProperty::MaxResourceSize), +]; + +pub(crate) static CARD_ITEM_PROPS: [DavProperty; 20] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), + DavProperty::CardDav(CardDavProperty::AddressData { + properties: Vec::new(), + version: None, + }), +]; + +pub(crate) async fn assert_is_unique_uid( + server: &Server, + resources: &DavResources, + account_id: u32, + addressbook_id: u32, + uid: Option<&str>, +) -> crate::Result<()> { + if let Some(uid) = uid { + let hits = server + .document_ids_matching( + account_id, + Collection::ContactCard, + ContactField::Uid, + uid.as_bytes(), + ) + .await + .caused_by(trc::location!())?; + if !hits.is_empty() { + for path in resources.children(addressbook_id) { + if hits.contains(path.document_id()) { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::NoUidConflict(resources.format_resource(path).into()), + ))); + } + } + } + } + + Ok(()) +} diff --git a/crates/dav/src/card/proppatch.rs b/crates/dav/src/card/proppatch.rs new file mode 100644 index 0000000..3e518b9 --- /dev/null +++ b/crates/dav/src/card/proppatch.rs @@ -0,0 +1,488 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, PropStatBuilder, + common::{ + ETag, ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{ + Namespace, + property::{CardDavProperty, DavProperty, DavValue, ResourceType, WebDavProperty}, + request::{DavPropertyValue, PropertyUpdate}, + response::{BaseCondition, MultiStatus, Response}, + }, +}; +use groupware::{ + cache::GroupwareCache, + contact::{AddressBook, ContactCard}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CardPropPatchRequestHandler: Sync + Send { + fn handle_card_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PropertyUpdate, + ) -> impl Future> + Send; + + fn apply_addressbook_properties( + &self, + personal_id: u32, + address_book: &mut AddressBook, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool; + + fn apply_card_properties( + &self, + card: &mut ContactCard, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool; +} + +impl CardPropPatchRequestHandler for Server { + async fn handle_card_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut request: PropertyUpdate, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let uri = headers.uri; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let resource = resource_ + .resource + .and_then(|r| resources.by_path(r)) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let document_id = resource.document_id(); + let collection = if resource.is_container() { + Collection::AddressBook + } else { + Collection::ContactCard + }; + + if !request.has_changes() { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + + // Verify ACL + if !access_token.is_member(account_id) { + let (acl, document_id) = if resource.is_container() { + (Acl::Modify, resource.document_id()) + } else { + (Acl::ModifyItems, resource.parent_id().unwrap()) + }; + + if !resources.has_access_to_container(access_token, document_id, acl) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } + + // Fetch archive + let archive = self + .store() + .get_value::>(ValueKey::archive( + account_id, + collection, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection, + document_id: document_id.into(), + etag: archive.etag().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::PROPPATCH, + ) + .await?; + + let is_success; + let mut batch = BatchBuilder::new(); + let mut items = PropStatBuilder::default(); + + let etag = if resource.is_container() { + // Deserialize + let book = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_book = archive + .deserialize::() + .caused_by(trc::location!())?; + let personal_id = access_token.personal_id(account_id, Collection::AddressBook); + + // Remove properties + if !request.set_first && !request.remove.is_empty() { + remove_addressbook_properties( + personal_id, + &mut new_book, + std::mem::take(&mut request.remove), + &mut items, + ); + } + + // Set properties + is_success = self.apply_addressbook_properties( + personal_id, + &mut new_book, + true, + request.set, + &mut items, + ); + + // Remove properties + if is_success && !request.remove.is_empty() { + remove_addressbook_properties( + personal_id, + &mut new_book, + request.remove, + &mut items, + ); + } + + if is_success { + new_book + .update( + access_token.account_tenant_ids(), + book, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag() + } else { + book.etag().into() + } + } else { + // Deserialize + let card = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_card = archive + .deserialize::() + .caused_by(trc::location!())?; + + // Remove properties + if !request.set_first && !request.remove.is_empty() { + remove_card_properties( + &mut new_card, + std::mem::take(&mut request.remove), + &mut items, + ); + } + + // Set properties + is_success = self.apply_card_properties(&mut new_card, true, request.set, &mut items); + + // Remove properties + if is_success && !request.remove.is_empty() { + remove_card_properties(&mut new_card, request.remove, &mut items); + } + + if is_success { + new_card + .update( + access_token.account_tenant_ids(), + card, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag() + } else { + card.etag().into() + } + }; + + if is_success { + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + if headers.ret != Return::Minimal || !is_success { + Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body( + MultiStatus::new(vec![Response::new_propstat(uri, items.build())]) + .with_namespace(Namespace::CardDav) + .to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) + } + } + + fn apply_addressbook_properties( + &self, + personal_id: u32, + address_book: &mut AddressBook, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool { + let mut has_errors = false; + + for property in properties { + match (&property.property, property.value) { + (DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => { + if name.len() <= self.core.groupware.live_property_size { + address_book.preferences_mut(personal_id).name = name; + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + ( + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + DavValue::String(name), + ) => { + if name.len() <= self.core.groupware.live_property_size { + address_book.preferences_mut(personal_id).description = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + + has_errors = true; + } + } + (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { + address_book.created = dt; + items.insert_ok(property.property); + } + ( + DavProperty::WebDav(WebDavProperty::ResourceType), + DavValue::ResourceTypes(types), + ) => { + if !types.0.iter().all(|rt| { + matches!(rt, ResourceType::Collection | ResourceType::AddressBook) + }) { + items.insert_precondition_failed( + property.property, + StatusCode::FORBIDDEN, + BaseCondition::ValidResourceType, + ); + has_errors = true; + } else { + items.insert_ok(property.property); + } + } + (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) + if self.core.groupware.dead_property_size.is_some() => + { + if is_update { + address_book.dead_properties.remove_element(dead); + } + + if address_book.dead_properties.size() + values.size() + dead.size() + < self.core.groupware.dead_property_size.unwrap() + { + address_book + .dead_properties + .add_element(dead.clone(), values.0); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (_, DavValue::Null) => { + items.insert_ok(property.property); + } + _ => { + items.insert_error_with_description( + property.property, + StatusCode::CONFLICT, + "Property cannot be modified", + ); + has_errors = true; + } + } + } + + !has_errors + } + + fn apply_card_properties( + &self, + card: &mut ContactCard, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool { + let mut has_errors = false; + + for property in properties { + match (&property.property, property.value) { + (DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => { + if name.len() <= self.core.groupware.live_property_size { + card.display_name = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { + card.created = dt; + items.insert_ok(property.property); + } + (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) + if self.core.groupware.dead_property_size.is_some() => + { + if is_update { + card.dead_properties.remove_element(dead); + } + + if card.dead_properties.size() + values.size() + dead.size() + < self.core.groupware.dead_property_size.unwrap() + { + card.dead_properties.add_element(dead.clone(), values.0); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (_, DavValue::Null) => { + items.insert_ok(property.property); + } + _ => { + items.insert_error_with_description( + property.property, + StatusCode::CONFLICT, + "Property cannot be modified", + ); + has_errors = true; + } + } + } + + !has_errors + } +} + +fn remove_card_properties( + card: &mut ContactCard, + properties: Vec, + items: &mut PropStatBuilder, +) { + for property in properties { + match &property { + DavProperty::WebDav(WebDavProperty::DisplayName) => { + card.display_name = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::DeadProperty(dead) => { + card.dead_properties.remove_element(dead); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + _ => { + items.insert_error_with_description( + property, + StatusCode::CONFLICT, + "Property cannot be deleted", + ); + } + } + } +} + +fn remove_addressbook_properties( + personal_id: u32, + book: &mut AddressBook, + properties: Vec, + items: &mut PropStatBuilder, +) { + for property in properties { + match &property { + DavProperty::CardDav(CardDavProperty::AddressbookDescription) => { + book.preferences_mut(personal_id).description = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::WebDav(WebDavProperty::DisplayName) => { + book.preferences_mut(personal_id).name.clear(); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::DeadProperty(dead) => { + book.dead_properties.remove_element(dead); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + _ => { + items.insert_error_with_description( + property, + StatusCode::CONFLICT, + "Property cannot be deleted", + ); + } + } + } +} diff --git a/crates/dav/src/card/query.rs b/crates/dav/src/card/query.rs new file mode 100644 index 0000000..75ccb2d --- /dev/null +++ b/crates/dav/src/card/query.rs @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, + common::{ + AddressbookFilter, DavQuery, + propfind::{PropFindItem, PropFindRequestHandler}, + uri::DavUriResource, + }, +}; +use calcard::vcard::{ + ArchivedVCard, ArchivedVCardEntry, ArchivedVCardParameter, VCardParameterName, VCardProperty, + VCardVersion, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::CardDavPropertyName, + request::{AddressbookQuery, Filter, FilterOp, VCardPropertyWithGroup}, + response::MultiStatus, + }, +}; +use groupware::cache::GroupwareCache; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::fmt::Write; +use trc::AddContext; +use types::{acl::Acl, collection::SyncCollection}; + +pub(crate) trait CardQueryRequestHandler: Sync + Send { + fn handle_card_query_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: AddressbookQuery, + ) -> impl Future> + Send; +} + +impl CardQueryRequestHandler for Server { + async fn handle_card_query_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: AddressbookQuery, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let Some(resource) = resources.by_path( + resource_ + .resource + .ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?, + ) else { + return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body(MultiStatus::not_found(headers.uri).to_string())); + }; + if !resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Obtain shared ids + let shared_ids = if !access_token.is_member(account_id) { + resources + .shared_items(access_token, [Acl::ReadItems], false) + .into() + } else { + None + }; + + // Obtain document ids in folder + let mut items = Vec::with_capacity(16); + for resource in resources.children(resource.document_id()) { + if shared_ids + .as_ref() + .is_none_or(|ids| ids.contains(resource.document_id())) + { + items.push(PropFindItem::new( + resources.format_resource(resource), + account_id, + resource, + )); + } + } + + self.handle_dav_query( + access_token, + DavQuery::addressbook_query(request, items, headers), + ) + .await + } +} + +pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> bool { + let mut is_all = true; + let mut matches_one = false; + + for filter in filters { + match filter { + Filter::AnyOf => { + is_all = false; + } + Filter::AllOf => { + is_all = true; + } + Filter::Property { prop, op, .. } => { + let mut properties = find_properties(card, prop).peekable(); + let result = if properties.peek().is_some() { + properties.any(|entry| match op { + FilterOp::Exists => true, + FilterOp::Undefined => false, + FilterOp::TextMatch(text_match) => { + let mut matched_any = false; + + for value in entry.values.iter() { + if let Some(text) = value.as_text() + && text_match.matches(text) + { + matched_any = true; + break; + } + } + + matched_any + } + FilterOp::TimeRange(_) => false, + }) + } else { + matches!(op, FilterOp::Undefined) + }; + + if result { + matches_one = true; + } else if is_all { + return false; + } + } + Filter::Parameter { + prop, param, op, .. + } => { + let mut properties = find_properties(card, prop) + .filter_map(|entry| find_parameter(entry, param)) + .peekable(); + let result = if properties.peek().is_some() { + properties.any(|entry| match op { + FilterOp::Exists => true, + FilterOp::Undefined => false, + FilterOp::TextMatch(text_match) => { + if let Some(text) = entry.value.as_text() { + text_match.matches(text) + } else { + false + } + } + FilterOp::TimeRange(_) => false, + }) + } else { + matches!(op, FilterOp::Undefined) + }; + + if result { + matches_one = true; + } else if is_all { + return false; + } + } + Filter::Component { .. } => {} + } + } + + is_all || matches_one +} + +#[inline(always)] +fn find_properties<'x>( + card: &'x ArchivedVCard, + prop: &VCardPropertyWithGroup, +) -> impl Iterator { + card.entries + .iter() + .filter(move |entry| entry.name == prop.name && entry.group == prop.group) +} + +#[inline(always)] +fn find_parameter<'x>( + entry: &'x ArchivedVCardEntry, + name: &VCardParameterName, +) -> Option<&'x ArchivedVCardParameter> { + entry.params.iter().find(|param| param.name == *name) +} + +pub(crate) fn serialize_vcard_with_props( + card: &ArchivedVCard, + props: &[CardDavPropertyName], + version: VCardVersion, +) -> String { + let mut vcard = String::with_capacity(128); + if !props.is_empty() { + let _ = write!(&mut vcard, "BEGIN:VCARD\r\n"); + let is_v4 = matches!(version, VCardVersion::V4_0); + + for entry in card.entries.iter() { + for item in props { + if entry.name == item.name && entry.group == item.group { + if item.name != VCardProperty::Version { + let _ = entry.write_to(&mut vcard, !item.no_value, is_v4); + } else { + let _ = write!(&mut vcard, "VERSION:{version}\r\n"); + } + break; + } + } + } + let _ = write!(&mut vcard, "END:VCARD\r\n"); + } else { + let _ = card.write_to(&mut vcard, version); + } + + vcard +} diff --git a/crates/dav/src/card/update.rs b/crates/dav/src/card/update.rs new file mode 100644 index 0000000..0bb0c6f --- /dev/null +++ b/crates/dav/src/card/update.rs @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::assert_is_unique_uid; +use crate::{ + DavError, DavErrorCondition, DavMethod, + common::{ + ETag, ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, + fix_percent_encoding, +}; +use calcard::{Entry, Parser}; +use common::{DavName, Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, Return, + schema::{property::Rfc1123DateTime, response::CardCondition}, +}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait CardUpdateRequestHandler: Sync + Send { + fn handle_card_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + is_patch: bool, + ) -> impl Future> + Send; +} + +impl CardUpdateRequestHandler for Server { + async fn handle_card_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + _is_patch: bool, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let resource_name = fix_percent_encoding( + resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?, + ); + + if bytes.len() > self.core.groupware.max_vcard_size { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::MaxResourceSize(self.core.groupware.max_vcard_size as u32), + ))); + } + let vcard_raw = std::str::from_utf8(&bytes).map_err(|_| { + DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::SupportedAddressData, + ) + .with_details("The request body is not valid UTF-8."), + ) + })?; + + let vcard = match Parser::new(vcard_raw).strict().entry() { + Entry::VCard(vcard) => vcard, + _ => { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::SupportedAddressData, + ) + .with_details("Failed to parse vCard data."), + )); + } + }; + + if let Some(resource) = resources.by_path(resource_name.as_ref()) { + if resource.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + let parent_id = resource.parent_id().unwrap(); + let document_id = resource.document_id(); + if !access_token.is_member(account_id) + && !resources.has_access_to_container(access_token, parent_id, Acl::ModifyItems) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Update + let card_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate headers + match self + .validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: Collection::ContactCard, + document_id: Some(document_id), + etag: card.etag().into(), + path: resource_name.as_ref(), + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await + { + Ok(_) => {} + Err(DavError::Code(StatusCode::PRECONDITION_FAILED)) + if headers.ret == Return::Representation => + { + let mut vcard = String::with_capacity(128); + let _ = card.inner.card.write_to( + &mut vcard, + headers + .vcard_version + .unwrap_or(self.core.groupware.vcard_version), + ); + + return Ok(HttpResponse::new(StatusCode::PRECONDITION_FAILED) + .with_content_type("text/vcard; charset=utf-8") + .with_etag(card.etag()) + .with_last_modified( + Rfc1123DateTime::new(i64::from(card.inner.modified)).to_string(), + ) + .with_header("Preference-Applied", "return=representation") + .with_binary_body(vcard)); + } + Err(e) => return Err(e), + } + + // Validate UID + match (card.inner.card.uid(), vcard.uid()) { + (Some(old_uid), Some(new_uid)) if old_uid == new_uid => {} + (None, None) | (None, Some(_)) => {} + _ => { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CardCondition::NoUidConflict(resources.format_resource(resource).into()), + ))); + } + } + + // Validate quota + let extra_bytes = + (bytes.len() as u64).saturating_sub(u32::from(card.inner.size) as u64); + if extra_bytes > 0 { + self.has_available_quota(self.account(account_id).await?.as_ref(), extra_bytes) + .await?; + } + + // Build node + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.size = bytes.len() as u32; + new_card.card = vcard; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let etag = new_card + .update( + access_token.account_tenant_ids(), + card, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) + } else if let Some((Some(parent), name)) = resources.map_parent(resource_name.as_ref()) { + if !parent.is_container() { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate ACL + if !access_token.is_member(account_id) + && !resources.has_access_to_container( + access_token, + parent.document_id(), + Acl::AddItems, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: resource_name.as_ref(), + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await?; + + // Validate UID + assert_is_unique_uid( + self, + &resources, + account_id, + parent.document_id(), + vcard.uid(), + ) + .await?; + + // Validate quota + if !bytes.is_empty() { + self.has_available_quota( + self.account(account_id).await?.as_ref(), + bytes.len() as u64, + ) + .await?; + } + + // Build node + let card = ContactCard { + names: vec![DavName { + name: name.to_string(), + parent_id: parent.document_id(), + }], + card: vcard, + size: bytes.len() as u32, + ..Default::default() + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + let etag = card + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + self.notify_task_queue(); + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } else { + Err(DavError::Code(StatusCode::CONFLICT))? + } + } +} diff --git a/crates/dav/src/common/acl.rs b/crates/dav/src/common/acl.rs new file mode 100644 index 0000000..b3a5c79 --- /dev/null +++ b/crates/dav/src/common/acl.rs @@ -0,0 +1,582 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ArchivedResource; +use crate::{ + DavError, DavErrorCondition, DavResourceName, common::uri::DavUriResource, + principal::propfind::PrincipalPropFind, +}; +use common::{DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::{DavProperty, Privilege, WebDavProperty}, + request::{AclPrincipalPropSet, PropFind}, + response::{Ace, BaseCondition, GrantDeny, Href, MultiStatus, Principal}, + }, +}; +use groupware::RFC_3986; +use groupware::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use rkyv::vec::ArchivedVec; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ahash::AHashSet, roaring::RoaringBitmap, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant, ArchivedAclGrant}, + collection::Collection, +}; +use utils::map::bitmap::Bitmap; + +pub(crate) trait DavAclHandler: Sync + Send { + fn handle_acl_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: dav_proto::schema::request::Acl, + ) -> impl Future> + Send; + + fn handle_acl_prop_set( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: AclPrincipalPropSet, + ) -> impl Future> + Send; + + fn validate_and_map_aces( + &self, + access_token: &AccessToken, + acl: dav_proto::schema::request::Acl, + collection: Collection, + ) -> impl Future>> + Send; + + fn resolve_ace( + &self, + access_token: &AccessToken, + account_id: u32, + grants: &ArchivedVec, + expand: Option<&PropFind>, + ) -> impl Future>> + Send; +} + +pub(crate) trait ResourceAcl { + fn validate_and_map_parent_acl( + &self, + access_token: &AccessToken, + is_member: bool, + parent_id: Option, + check_acls: impl Into> + Send, + ) -> crate::Result; +} + +impl DavAclHandler for Server { + async fn handle_acl_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: dav_proto::schema::request::Acl, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let collection = resource_.collection; + + if !matches!( + collection, + Collection::AddressBook | Collection::Calendar | Collection::FileNode + ) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + let resources = self + .fetch_dav_resources(access_token.account_id(), account_id, collection.into()) + .await + .caused_by(trc::location!())?; + let resource = resource_ + .resource + .and_then(|r| resources.by_path(r)) + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + if !resource.resource.is_container() && !matches!(collection, Collection::FileNode) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Fetch node + let archive = self + .store() + .get_value::>(ValueKey::archive( + account_id, + collection, + resource.document_id(), + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let container = + ArchivedResource::from_archive(&archive, collection).caused_by(trc::location!())?; + + // Validate ACL + let acls = container.acls().unwrap(); + if !access_token.is_member(account_id) + && !acls.effective_acl(access_token).contains(Acl::Share) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate ACEs + let grants = self + .validate_and_map_aces(access_token, request, collection) + .await?; + + if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) { + // Refresh ACLs + self.refresh_archived_acls(&grants, acls) + .await + .caused_by(trc::location!())?; + + let mut batch = BatchBuilder::new(); + match container { + ArchivedResource::Calendar(calendar) => { + let mut new_calendar = calendar + .deserialize::() + .caused_by(trc::location!())?; + new_calendar.acls = grants; + new_calendar + .update( + access_token.account_tenant_ids(), + calendar, + account_id, + resource.document_id(), + &mut batch, + ) + .caused_by(trc::location!())?; + } + ArchivedResource::AddressBook(book) => { + let mut new_book = book + .deserialize::() + .caused_by(trc::location!())?; + new_book.acls = grants; + new_book + .update( + access_token.account_tenant_ids(), + book, + account_id, + resource.document_id(), + &mut batch, + ) + .caused_by(trc::location!())?; + } + ArchivedResource::FileNode(node) => { + let mut new_node = + node.deserialize::().caused_by(trc::location!())?; + new_node.acls = grants; + new_node + .update( + access_token.account_tenant_ids(), + node, + account_id, + resource.document_id(), + true, + &mut batch, + ) + .caused_by(trc::location!())?; + } + _ => unreachable!(), + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::OK)) + } + + async fn handle_acl_prop_set( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut request: AclPrincipalPropSet, + ) -> crate::Result { + let uri = self + .validate_uri(access_token, headers.uri) + .await + .and_then(|uri| uri.into_owned_uri())?; + let uri = self + .map_uri_resource(access_token, uri) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + if !matches!( + uri.collection, + Collection::Calendar | Collection::AddressBook | Collection::FileNode + ) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let archive = self + .store() + .get_value::>(ValueKey::archive( + uri.account_id, + uri.collection, + uri.resource, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let acls = match uri.collection { + Collection::FileNode => { + &archive + .unarchive::() + .caused_by(trc::location!())? + .acls + } + Collection::AddressBook => { + &archive + .unarchive::() + .caused_by(trc::location!())? + .acls + } + Collection::Calendar => { + &archive + .unarchive::() + .caused_by(trc::location!())? + .acls + } + _ => unreachable!(), + }; + + // Validate ACLs + if !access_token.is_member(uri.account_id) + && !acls.effective_acl(access_token).contains(Acl::Share) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate + let account_ids = RoaringBitmap::from_iter(acls.iter().map(|a| u32::from(a.account_id))); + let mut response = MultiStatus::new(Vec::with_capacity(16)); + + if !account_ids.is_empty() { + if request.properties.is_empty() { + request + .properties + .push(DavProperty::WebDav(WebDavProperty::DisplayName)); + } + let request = PropFind::Prop(request.properties); + self.prepare_principal_propfind_response( + access_token, + Collection::Principal, + account_ids.into_iter(), + &request, + &mut response, + ) + .await?; + } + + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) + } + + async fn validate_and_map_aces( + &self, + access_token: &AccessToken, + acl: dav_proto::schema::request::Acl, + collection: Collection, + ) -> crate::Result> { + let mut grants = Vec::with_capacity(acl.aces.len()); + for ace in acl.aces { + if ace.invert { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NoInvert, + ))); + } + let privileges = match ace.grant_deny { + GrantDeny::Grant(list) => list.0, + GrantDeny::Deny(_) => { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::GrantOnly, + ))); + } + }; + let principal_uri = match ace.principal { + Principal::Href(href) => href.0, + _ => { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + ))); + } + }; + + let mut acls = Bitmap::::default(); + for privilege in privileges { + match privilege { + Privilege::Read => { + acls.insert(Acl::Read); + acls.insert(Acl::ReadItems); + } + Privilege::Write => { + acls.insert(Acl::Modify); + acls.insert(Acl::Delete); + acls.insert(Acl::AddItems); + acls.insert(Acl::ModifyItems); + acls.insert(Acl::RemoveItems); + } + Privilege::WriteContent => { + acls.insert(Acl::AddItems); + acls.insert(Acl::Modify); + acls.insert(Acl::ModifyItems); + } + Privilege::WriteProperties => { + acls.insert(Acl::Modify); + } + Privilege::ReadCurrentUserPrivilegeSet + | Privilege::Unlock + | Privilege::Bind + | Privilege::Unbind => {} + Privilege::All => { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NoAbstract, + ))); + } + Privilege::ReadAcl => {} + Privilege::WriteAcl => { + acls.insert(Acl::Share); + } + Privilege::ReadFreeBusy + | Privilege::ScheduleQueryFreeBusy + | Privilege::ScheduleSendFreeBusy => { + if collection == Collection::Calendar { + acls.insert(Acl::SchedulingReadFreeBusy); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NotSupportedPrivilege, + ))); + } + } + Privilege::ScheduleDeliver | Privilege::ScheduleSend => { + if collection == Collection::Calendar { + acls.insert(Acl::SchedulingReadFreeBusy); + acls.insert(Acl::SchedulingInvite); + acls.insert(Acl::SchedulingReply); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NotSupportedPrivilege, + ))); + } + } + Privilege::ScheduleDeliverInvite | Privilege::ScheduleSendInvite => { + if collection == Collection::Calendar { + acls.insert(Acl::SchedulingInvite); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NotSupportedPrivilege, + ))); + } + } + Privilege::ScheduleDeliverReply | Privilege::ScheduleSendReply => { + if collection == Collection::Calendar { + acls.insert(Acl::SchedulingReply); + } else { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NotSupportedPrivilege, + ))); + } + } + } + } + + if acls.is_empty() { + continue; + } + + let principal_id = self + .validate_uri(access_token, &principal_uri) + .await + .map_err(|_| { + DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + )) + })? + .account_id + .ok_or_else(|| { + DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + )) + })?; + + // Verify that the principal is a valid principal + /*let principal = self + .directory() + .query(QueryParams::id(principal_id).with_return_member_of(false)) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + )) + })?; + if !matches!(principal.typ(), Type::Individual | Type::Group) { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + ))); + }*/ + + grants.push(AclGrant { + account_id: principal_id, + grants: acls, + }); + } + + Ok(grants) + } + + async fn resolve_ace( + &self, + access_token: &AccessToken, + account_id: u32, + grants: &ArchivedVec, + expand: Option<&PropFind>, + ) -> crate::Result> { + let mut aces = Vec::with_capacity(grants.len()); + if access_token.is_member(account_id) + || grants.effective_acl(access_token).contains(Acl::Share) + { + for grant in grants.iter() { + let grant_account_id = u32::from(grant.account_id); + let principal = if let Some(expand) = expand { + self.expand_principal(access_token, grant_account_id, expand) + .await? + .map(Principal::Response) + .unwrap_or_else(|| { + Principal::Href(Href(format!( + "{}/_{grant_account_id}/", + DavResourceName::Principal.base_path(), + ))) + }) + } else { + let grant_account = self + .account(grant_account_id) + .await + .caused_by(trc::location!())?; + + Principal::Href(Href(format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + percent_encoding::utf8_percent_encode(grant_account.name(), RFC_3986), + ))) + }; + + aces.push(Ace::new( + principal, + GrantDeny::grant(current_user_privilege_set(Bitmap::::from( + &grant.grants, + ))), + )); + } + } + + Ok(aces) + } +} + +impl ResourceAcl for DavResources { + fn validate_and_map_parent_acl( + &self, + access_token: &AccessToken, + is_member: bool, + parent_id: Option, + check_acls: impl Into> + Send, + ) -> crate::Result { + match parent_id { + Some(parent_id) => { + if is_member || self.has_access_to_container(access_token, parent_id, check_acls) { + Ok(parent_id + 1) + } else { + Err(DavError::Code(StatusCode::FORBIDDEN)) + } + } + None => { + if is_member { + Ok(0) + } else { + Err(DavError::Code(StatusCode::FORBIDDEN)) + } + } + } + } +} + +pub(crate) trait Privileges { + fn current_privilege_set( + &self, + account_id: u32, + grants: &ArchivedVec, + is_calendar: bool, + ) -> Vec; +} + +impl Privileges for AccessToken { + fn current_privilege_set( + &self, + account_id: u32, + grants: &ArchivedVec, + is_calendar: bool, + ) -> Vec { + if self.is_member(account_id) { + Privilege::all(is_calendar) + } else { + current_user_privilege_set(grants.effective_acl(self)) + } + } +} + +pub(crate) fn current_user_privilege_set(acl_bitmap: Bitmap) -> Vec { + let mut acls = AHashSet::with_capacity(16); + for grant in acl_bitmap { + match grant { + Acl::Read | Acl::ReadItems => { + acls.insert(Privilege::Read); + acls.insert(Privilege::ReadCurrentUserPrivilegeSet); + } + Acl::Modify => { + acls.insert(Privilege::WriteProperties); + } + Acl::ModifyItems => { + acls.insert(Privilege::WriteContent); + } + Acl::Delete | Acl::RemoveItems => { + acls.insert(Privilege::Write); + } + Acl::Share => { + acls.insert(Privilege::ReadAcl); + acls.insert(Privilege::WriteAcl); + } + Acl::SchedulingReadFreeBusy => { + acls.insert(Privilege::ReadFreeBusy); + } + _ => {} + } + } + acls.into_iter().collect() +} diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs new file mode 100644 index 0000000..ca9f89a --- /dev/null +++ b/crates/dav/src/common/lock.rs @@ -0,0 +1,892 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ETag; +use super::uri::{DavUriResource, OwnedUri, UriResource, Urn}; +use crate::{DavError, DavErrorCondition, DavMethod}; +use common::KV_LOCK_DAV; +use common::{Server, auth::AccessToken}; +use dav_proto::schema::property::{ActiveLock, LockScope, WebDavProperty}; +use dav_proto::schema::request::DavPropertyValue; +use dav_proto::schema::response::{BaseCondition, List, PropResponse}; +use dav_proto::{Condition, Depth, Timeout}; +use dav_proto::{RequestHeaders, schema::request::LockInfo}; +use groupware::cache::GroupwareCache; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::collections::HashMap; +use store::ValueKey; +use store::dispatch::lookup::KeyValue; +use store::write::serialize::rkyv_deserialize; +use store::write::{AlignedBytes, Archive, Archiver, now}; +use store::{Serialize, U32_LEN}; +use trc::AddContext; +use types::collection::Collection; +use types::dead_property::DeadProperty; + +#[derive(Debug, Default, Clone)] +pub struct ResourceState<'x> { + pub account_id: u32, + pub collection: Collection, + pub document_id: Option, + pub etag: Option, + pub lock_tokens: Vec, + pub sync_token: Option, + pub path: &'x str, +} + +#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub(crate) struct LockData { + locks: HashMap, +} + +#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[repr(transparent)] +pub(crate) struct LockItems(Vec); + +#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub(crate) struct LockItem { + lock_id: u64, + owner: u32, + expires: u64, + depth_infinity: bool, + exclusive: bool, + owner_dav: Option, +} + +struct LockCache<'x> { + account_id: u32, + collection: Collection, + lock_archive: LockArchive<'x>, +} + +enum LockArchive<'x> { + Unarchived(&'x ArchivedLockData), + Archived(Archive), +} + +#[derive(Default)] +pub(crate) struct LockCaches<'x> { + caches: Vec>, +} + +pub(crate) trait LockRequestHandler: Sync + Send { + fn handle_lock_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + lock_info: LockRequest, + ) -> impl Future> + Send; + + fn validate_headers( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + resources: Vec>, + locks: LockCaches<'_>, + method: DavMethod, + ) -> impl Future> + Send; +} + +pub(crate) enum LockRequest { + Lock(LockInfo), + Unlock, + Refresh, +} + +impl LockRequestHandler for Server { + async fn handle_lock_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + lock_info: LockRequest, + ) -> crate::Result { + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let resource_hash = resource.lock_key(); + let resource_path = resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?; + let account_id = resource.account_id; + if !access_token.is_member(account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let resources = vec![ResourceState { + account_id, + collection: resource.collection, + path: resource_path, + ..Default::default() + }]; + + let mut base_path = None; + let is_lock_request = !matches!(lock_info, LockRequest::Unlock); + let if_lock_token = headers + .if_ + .iter() + .flat_map(|if_| if_.list.iter()) + .find_map(|cond| { + if let Condition::StateToken { token, .. } = cond { + Urn::parse(token).and_then(|u| u.try_unwrap_lock()) + } else { + None + } + }) + .unwrap_or_default(); + let mut lock_data = if let Some(lock_data) = self + .in_memory_store() + .key_get::>(resource_hash.as_slice()) + .await + .caused_by(trc::location!())? + { + let lock_data = lock_data + .unarchive::() + .caused_by(trc::location!())?; + + self.validate_headers( + access_token, + headers, + resources, + LockCaches::new_shared(account_id, resource.collection, lock_data), + if is_lock_request { + DavMethod::LOCK + } else { + DavMethod::UNLOCK + }, + ) + .await?; + + if let LockRequest::Lock(lock_info) = &lock_info { + let mut failed_locks = Vec::new(); + let is_exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive); + let is_infinity = matches!(headers.depth, Depth::Infinity); + + for (lock_path, lock_item) in lock_data.find_locks(resource_path, true) { + if if_lock_token != lock_item.lock_id + && (lock_item.exclusive || is_exclusive) + && (lock_path.len() == resource_path.len() + || lock_item.depth_infinity && resource_path.len() > lock_path.len() + || is_infinity && lock_path.len() > resource_path.len()) + { + let base_path = + base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default()); + failed_locks.push(format!("{base_path}/{lock_path}").into()); + } + } + + if !failed_locks.is_empty() { + return Err(DavErrorCondition::new( + StatusCode::LOCKED, + BaseCondition::LockTokenSubmitted(List(failed_locks)), + ) + .into()); + } + + // Validate lock_info + if lock_info.owner.as_ref().is_some_and(|o| { + o.size() > self.core.groupware.dead_property_size.unwrap_or(512) + }) { + return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); + } + + if self.core.groupware.max_locks_per_user > 0 + && lock_data + .locks + .values() + .flat_map(|locks| { + locks + .0 + .iter() + .filter(|lock| lock.owner == access_token.account_id()) + }) + .count() + >= self.core.groupware.max_locks_per_user + { + return Err(DavError::Code(StatusCode::TOO_MANY_REQUESTS)); + } + } + + rkyv_deserialize(lock_data).caused_by(trc::location!())? + } else if is_lock_request { + self.validate_headers( + access_token, + headers, + resources, + Default::default(), + DavMethod::LOCK, + ) + .await?; + + LockData::default() + } else { + return Err(DavErrorCondition::new( + StatusCode::CONFLICT, + BaseCondition::LockTokenMatchesRequestUri, + ) + .into()); + }; + + let now = now(); + let response = if is_lock_request { + let timeout = if let Timeout::Second(seconds) = headers.timeout { + std::cmp::min(seconds, self.core.groupware.max_lock_timeout) + } else { + self.core.groupware.max_lock_timeout + }; + let expires = now + timeout; + + let lock_item = if if_lock_token > 0 { + if let Some(lock_item) = lock_data + .locks + .values_mut() + .flat_map(|locks| locks.0.iter_mut()) + .find(|lock| lock.lock_id == if_lock_token) + { + lock_item + } else { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + } else { + let locks = lock_data + .locks + .entry(resource_path.to_string()) + .or_insert_with(Default::default); + locks.0.push(LockItem::default()); + locks.0.last_mut().unwrap() + }; + + lock_item.expires = expires; + if let LockRequest::Lock(lock_info) = lock_info { + // Validate lock_info + if lock_info.owner.as_ref().is_some_and(|o| { + o.size() > self.core.groupware.dead_property_size.unwrap_or(512) + }) { + return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); + } + + lock_item.lock_id = store::rand::random::() ^ expires; + lock_item.owner = access_token.account_id(); + lock_item.depth_infinity = matches!(headers.depth, Depth::Infinity); + lock_item.owner_dav = lock_info.owner; + lock_item.exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive); + } + + let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default()); + let active_lock = lock_item.to_active_lock(format!("{base_path}/{resource_path}")); + + HttpResponse::new(if if_lock_token == 0 { + StatusCode::CREATED + } else { + StatusCode::OK + }) + .with_lock_token(&active_lock.lock_token.as_ref().unwrap().0) + .with_xml_body( + PropResponse::new(vec![DavPropertyValue::new( + WebDavProperty::LockDiscovery, + vec![active_lock], + )]) + .to_string(), + ) + } else { + let lock_id = headers + .lock_token + .and_then(Urn::parse) + .and_then(|urn| urn.try_unwrap_lock()) + .ok_or(DavError::Code(StatusCode::BAD_REQUEST))?; + + if lock_data.remove_lock(lock_id) { + HttpResponse::new(StatusCode::NO_CONTENT) + } else { + return Err(DavErrorCondition::new( + StatusCode::CONFLICT, + BaseCondition::LockTokenMatchesRequestUri, + ) + .into()); + } + }; + + // Remove expired locks + let max_expire = lock_data.remove_expired(); + if max_expire > 0 { + self.in_memory_store() + .key_set( + KeyValue::new( + resource_hash, + Archiver::new(lock_data) + .untrusted() + .serialize() + .caused_by(trc::location!())?, + ) + .expires(max_expire), + ) + .await + .caused_by(trc::location!())?; + } else { + self.in_memory_store() + .key_delete(resource_hash) + .await + .caused_by(trc::location!())?; + } + + Ok(response) + } + + async fn validate_headers( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut resources: Vec>, + mut locks_: LockCaches<'_>, + method: DavMethod, + ) -> crate::Result<()> { + let no_if_headers = headers.if_.is_empty(); + match method { + DavMethod::GET | DavMethod::HEAD if no_if_headers => { + // Return early for GET/HEAD requests without If headers + return Ok(()); + } + DavMethod::COPY + | DavMethod::MOVE + | DavMethod::POST + | DavMethod::PUT + | DavMethod::PATCH + if headers.overwrite_fail + && resources.last().is_some_and(|r| { + r.etag.is_some() || r.document_id.is_some_and(|id| id != u32::MAX) + }) => + { + return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); + } + _ => {} + } + + // Add lock data to the cache + for resource in &resources { + if locks_.is_cached(resource).is_none() { + locks_.insert_lock_data(self, resource).await?; + } + } + + // Unarchive lock data + let mut locks = locks_.to_unarchived().caused_by(trc::location!())?; + + // Validate locks for write operations + let mut lock_response = Ok(()); + if !matches!( + method, + DavMethod::GET | DavMethod::HEAD | DavMethod::LOCK | DavMethod::UNLOCK + ) { + let mut base_path = None; + + 'outer: for (pos, resource) in resources.iter().enumerate() { + if pos == 0 && matches!(method, DavMethod::COPY) { + continue; + } + + if let Some(idx) = locks.find_cache_pos(self, resource).await? { + let mut failed_locks = Vec::new(); + + for (lock_path, lock_item) in locks.find_locks_by_pos(idx, resource, true)? { + let lock_token = lock_item.urn().to_string(); + if headers.if_.iter().any(|if_| { + if_.resource + .is_none_or(|r| { + r.trim_end_matches('/').ends_with(lock_path)}) + && if_.list.iter().any(|cond| matches!(cond, Condition::StateToken { token, .. } if token == &lock_token)) + }) { + break 'outer; + } else { + let base_path = base_path.get_or_insert_with(|| { + headers.base_uri() + .unwrap_or_default() + }); + failed_locks.push(format!("{base_path}/{lock_path}").into()); + } + } + + if !failed_locks.is_empty() { + lock_response = Err(DavErrorCondition::new( + StatusCode::LOCKED, + BaseCondition::LockTokenSubmitted(List(failed_locks)), + ) + .into()); + break; + } + } + } + } + + // There are no If headers, so we can return early + if no_if_headers { + return lock_response; + } + + let mut resource_not_found = ResourceState { + account_id: u32::MAX, + collection: Collection::None, + path: "", + ..Default::default() + }; + + 'outer: for if_ in &headers.if_ { + if if_.list.is_empty() { + continue; + } + + let mut resource_state = &mut resource_not_found; + + if let Some(resource) = if_.resource { + if let Some(resource) = self + .validate_uri(access_token, resource) + .await + .ok() + .and_then(|r| { + let path = r.resource?; + + Some(ResourceState { + account_id: r.account_id?, + collection: if !matches!(r.collection, Collection::FileNode) + && path.contains('/') + { + r.collection.child_collection().unwrap_or(r.collection) + } else { + r.collection + }, + path, + ..Default::default() + }) + }) + { + if let Some(known_resource) = resources.iter_mut().find(|r| { + r.account_id == resource.account_id + && r.collection == resource.collection + && r.path == resource.path + }) { + resource_state = known_resource; + } else if access_token.has_access(resource.account_id, resource.collection) { + resources.push(resource); + resource_state = resources.last_mut().unwrap(); + } + } + } else if let Some(resource) = resources.first_mut() { + resource_state = resource; + }; + + // Fill missing data for resource + if resource_state.collection != Collection::None + && (resource_state.etag.is_none() + || resource_state.lock_tokens.is_empty() + || resource_state.sync_token.is_none()) + { + let mut needs_lock_token = false; + let mut needs_sync_token = false; + let mut needs_etag = false; + + for cond in &if_.list { + match cond { + Condition::StateToken { token, .. } => { + if token.starts_with("urn:stalwart:davsync:") { + needs_sync_token = true; + } else { + needs_lock_token = true; + } + } + Condition::ETag { .. } | Condition::Exists { .. } => { + needs_etag = true; + } + } + } + + // Fetch eTag + if needs_etag && resource_state.etag.is_none() { + if resource_state.document_id.is_none() { + resource_state.document_id = self + .map_uri_resource( + access_token, + UriResource { + collection: resource_state.collection, + account_id: resource_state.account_id, + resource: resource_state.path.into(), + }, + ) + .await + .caused_by(trc::location!())? + .map(|uri| uri.resource) + .unwrap_or(u32::MAX) + .into(); + } + + if let Some(document_id) = + resource_state.document_id.filter(|&id| id != u32::MAX) + && let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + resource_state.account_id, + resource_state.collection, + document_id, + )) + .await + .caused_by(trc::location!())? + { + resource_state.etag = archive.etag().into(); + } + } + + // Fetch lock token + if needs_lock_token + && resource_state.lock_tokens.is_empty() + && let Some(idx) = locks.find_cache_pos(self, resource_state).await? + { + let found_locks = locks + .find_locks_by_pos(idx, resource_state, false)? + .iter() + .map(|(_, lock)| lock.urn().to_string()) + .collect::>(); + resource_state.lock_tokens = found_locks; + } + + // Fetch sync token + if needs_sync_token && resource_state.sync_token.is_none() { + let id = self + .fetch_dav_resources( + access_token.account_id(), + resource_state.account_id, + resource_state.collection.into(), + ) + .await + .caused_by(trc::location!())? + .highest_change_id; + resource_state.sync_token = Some(Urn::Sync { id, seq: 0 }.to_string()); + } + } + + for cond in &if_.list { + match cond { + Condition::StateToken { is_not, token } => { + if let Some(token) = Urn::try_extract_sync_id(token) { + if !((resource_state + .sync_token + .as_deref() + .and_then(Urn::try_extract_sync_id) + .is_some_and(|sync_token| sync_token == token)) + ^ is_not) + { + continue 'outer; + } + } else if !((resource_state.lock_tokens.iter().any(|t| t == token)) + ^ is_not) + { + continue 'outer; + } + } + Condition::ETag { is_not, tag } => { + if !((resource_state.etag.as_ref().is_some_and(|etag| etag == tag)) + ^ is_not) + { + continue 'outer; + } + } + Condition::Exists { is_not } => { + if !((resource_state.etag.is_some()) ^ is_not) { + continue 'outer; + } + } + } + } + + return lock_response; + } + + Err(DavError::Code( + if matches!(method, DavMethod::GET | DavMethod::HEAD) + && headers + .if_ + .iter() + .any(|if_| if_.list.iter().any(|cond| cond.is_none_match())) + { + StatusCode::NOT_MODIFIED + } else { + StatusCode::PRECONDITION_FAILED + }, + )) + } +} + +impl LockData { + pub fn remove_lock(&mut self, lock_id: u64) -> bool { + for (lock_path, lock_items) in self.locks.iter_mut() { + for (idx, lock_item) in lock_items.0.iter().enumerate() { + if lock_item.lock_id == lock_id { + lock_items.0.swap_remove(idx); + if lock_items.0.is_empty() { + let lock_path = lock_path.clone(); + self.locks.remove(&lock_path); + } + return true; + } + } + } + + false + } + + pub fn remove_expired(&mut self) -> u64 { + let mut max_expire = 0; + let now = now(); + + self.locks.retain(|_, locks| { + locks.0.retain(|lock| { + if lock.expires > now { + max_expire = std::cmp::max(max_expire, lock.expires); + true + } else { + false + } + }); + + !locks.0.is_empty() + }); + + max_expire + } +} + +impl<'x> LockArchive<'x> { + fn unarchive(&'x self) -> trc::Result<&'x ArchivedLockData> { + match self { + LockArchive::Unarchived(archived_lock_data) => Ok(archived_lock_data), + LockArchive::Archived(archive) => { + archive.unarchive::().caused_by(trc::location!()) + } + } + } +} + +impl<'x> LockCaches<'x> { + pub(self) fn new_shared( + account_id: u32, + collection: Collection, + lock_data: &'x ArchivedLockData, + ) -> Self { + Self { + caches: vec![LockCache { + account_id, + collection, + lock_archive: LockArchive::Unarchived(lock_data), + }], + } + } + + pub fn to_unarchived(&'x self) -> trc::Result> { + let caches = self + .caches + .iter() + .map(|cache| { + Ok(LockCache { + account_id: cache.account_id, + collection: cache.collection, + lock_archive: LockArchive::Unarchived( + cache.lock_archive.unarchive().caused_by(trc::location!())?, + ), + }) + }) + .collect::>>()?; + + Ok(LockCaches { caches }) + } + + #[inline] + pub fn is_cached(&self, resource_state: &ResourceState<'_>) -> Option { + self.caches.iter().position(|cache| { + resource_state.account_id == cache.account_id + && resource_state.collection.main_collection() == cache.collection.main_collection() + }) + } + + pub async fn find_cache_pos( + &mut self, + server: &Server, + resource_state: &ResourceState<'_>, + ) -> trc::Result> { + if let Some(idx) = self.is_cached(resource_state) { + Ok(Some(idx)) + } else if resource_state.collection != Collection::None { + if self.insert_lock_data(server, resource_state).await? { + Ok(Some(self.caches.len() - 1)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + fn find_locks_by_pos( + &'x self, + pos: usize, + resource_state: &'x ResourceState<'_>, + include_children: bool, + ) -> trc::Result> { + self.caches[pos] + .lock_archive + .unarchive() + .map(|l| l.find_locks(resource_state.path, include_children)) + } + + async fn insert_lock_data( + &mut self, + server: &Server, + resource_state: &ResourceState<'_>, + ) -> trc::Result { + if let Some(lock_archive) = server + .in_memory_store() + .key_get::>(resource_state.lock_key().as_slice()) + .await + .caused_by(trc::location!())? + { + self.caches.push(LockCache { + account_id: resource_state.account_id, + collection: resource_state.collection, + lock_archive: LockArchive::Archived(lock_archive), + }); + + Ok(true) + } else { + Ok(false) + } + } +} + +impl LockItem { + pub fn to_active_lock(&self, href: String) -> ActiveLock { + ActiveLock::new( + href, + if self.exclusive { + LockScope::Exclusive + } else { + LockScope::Shared + }, + ) + .with_depth(if self.depth_infinity { + Depth::Infinity + } else { + Depth::Zero + }) + .with_owner_opt(self.owner_dav.clone()) + .with_timeout(self.expires.saturating_sub(now())) + .with_lock_token(self.urn().to_string()) + } + + pub fn urn(&self) -> Urn { + Urn::Lock(self.lock_id) + } +} + +impl ArchivedLockData { + pub fn find_locks<'x: 'y, 'y>( + &'x self, + resource: &'y str, + include_children: bool, + ) -> Vec<(&'y str, &'x ArchivedLockItem)> { + let now = now(); + let mut resource_part = resource; + let mut found_locks = Vec::new(); + + loop { + if let Some(locks) = self.locks.get(resource_part) { + found_locks.extend( + locks + .0 + .iter() + .filter(|lock| { + lock.expires > now && (resource == resource_part || lock.depth_infinity) + }) + .map(|lock| (resource_part, lock)), + ); + } + + if let Some((resource_part_, _)) = resource_part.rsplit_once('/') { + resource_part = resource_part_; + } else { + break; + } + } + + if include_children { + let prefix = format!("{}/", resource); + for (resource_part, locks) in self.locks.iter() { + if resource_part.starts_with(&prefix) { + found_locks.extend( + locks + .0 + .iter() + .filter(|lock| lock.expires > now) + .map(|lock| (resource_part.as_str(), lock)), + ); + } + } + } + + found_locks + } +} + +impl ArchivedLockItem { + pub fn to_active_lock(&self, href: String) -> ActiveLock { + ActiveLock::new( + href, + if self.exclusive { + LockScope::Exclusive + } else { + LockScope::Shared + }, + ) + .with_depth(if self.depth_infinity { + Depth::Infinity + } else { + Depth::Zero + }) + .with_owner_opt(self.owner_dav.as_ref().map(Into::into)) + .with_timeout(u64::from(self.expires).saturating_sub(now())) + .with_lock_token(self.urn().to_string()) + } + + pub fn urn(&self) -> Urn { + Urn::Lock(self.lock_id.into()) + } +} + +impl OwnedUri<'_> { + pub fn lock_key(&self) -> Vec { + build_lock_key(self.account_id, self.collection.main_collection()) + } +} + +impl ResourceState<'_> { + pub fn lock_key(&self) -> Vec { + build_lock_key(self.account_id, self.collection.main_collection()) + } +} + +pub(crate) fn build_lock_key(account_id: u32, collection: Collection) -> Vec { + let mut result = Vec::with_capacity(U32_LEN + 2); + result.push(KV_LOCK_DAV); + result.extend_from_slice(account_id.to_be_bytes().as_slice()); + result.push(u8::from(collection)); + result +} + +impl PartialEq for ResourceState<'_> { + fn eq(&self, other: &Self) -> bool { + self.account_id == other.account_id + && self.collection == other.collection + && self.document_id == other.document_id + } +} + +impl Eq for ResourceState<'_> {} diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs new file mode 100644 index 0000000..ecc32fc --- /dev/null +++ b/crates/dav/src/common/mod.rs @@ -0,0 +1,520 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::{ + icalendar::{ICalendarComponentType, ICalendarParameterName, ICalendarProperty}, + vcard::{VCardParameterName, VCardVersion}, +}; +use dav_proto::{ + Depth, RequestHeaders, Return, + schema::{ + Namespace, + property::{DavProperty, ReportSet, ResourceType}, + request::{ + AddressbookQuery, CalendarQuery, ExpandProperty, Filter, MultiGet, PropFind, + SyncCollection, Timezone, VCardPropertyWithGroup, + }, + }, +}; +use groupware::{ + calendar::{ + ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarEventNotification, Calendar, + CalendarEvent, CalendarEventNotification, + }, + contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, + file::{ArchivedFileNode, FileNode}, +}; +use propfind::PropFindItem; +use rkyv::vec::ArchivedVec; +use store::write::{AlignedBytes, Archive, BatchBuilder, Operation, ValueClass, ValueOp}; +use types::{ + TimeRange, acl::ArchivedAclGrant, collection::Collection, dead_property::ArchivedDeadProperty, + field::Field, +}; +use uri::{OwnedUri, Urn}; + +pub mod acl; +pub mod lock; +pub mod propfind; +pub mod uri; + +#[derive(Debug)] +pub(crate) struct DavQuery<'x> { + pub uri: &'x str, + pub resource: DavQueryResource<'x>, + pub propfind: PropFind, + pub sync_type: SyncType, + pub depth: usize, + pub limit: Option, + pub vcard_version: Option, + pub ret: Return, + pub depth_no_root: bool, + pub expand: bool, +} + +#[derive(Default, Debug)] +pub(crate) enum SyncType { + #[default] + None, + Initial, + From { + id: u64, + seq: u32, + }, +} + +#[derive(Default, Debug)] +pub(crate) enum DavQueryResource<'x> { + Uri(OwnedUri<'x>), + Multiget { + parent_collection: Collection, + hrefs: Vec, + }, + Query { + filter: DavQueryFilter, + parent_collection: Collection, + items: Vec, + }, + #[default] + None, +} + +pub(crate) type AddressbookFilter = Vec>; +pub(crate) type CalendarFilter = + Vec, ICalendarProperty, ICalendarParameterName>>; + +#[derive(Debug)] +pub(crate) enum DavQueryFilter { + Addressbook(AddressbookFilter), + Calendar { + filter: CalendarFilter, + max_time_range: Option, + timezone: Timezone, + }, +} + +pub(crate) trait ETag { + fn etag(&self) -> String; +} + +pub(crate) trait ExtractETag { + fn etag(&self) -> Option; +} + +impl ETag for Archive { + fn etag(&self) -> String { + format!("\"{}\"", self.version.hash().unwrap_or_default()) + } +} + +impl ExtractETag for BatchBuilder { + fn etag(&self) -> Option { + let p_value = u8::from(Field::ARCHIVE); + for op in self.ops().iter().rev() { + match op { + Operation::Value { + class: ValueClass::Property(p_id), + op: ValueOp::Set(value), + } if *p_id == p_value => { + return Archive::::extract_hash(value) + .map(|hash| format!("\"{}\"", hash)); + } + Operation::Value { + class: ValueClass::Property(p_id), + op: ValueOp::SetFnc(set_fnc), + } if *p_id == p_value => { + return Archive::::extract_hash(set_fnc.params().bytes(0)) + .map(|hash| format!("\"{}\"", hash)); + } + _ => {} + } + } + + None + } +} + +pub(crate) trait DavCollection { + fn namespace(&self) -> Namespace; +} + +impl DavCollection for Collection { + fn namespace(&self) -> Namespace { + match self { + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarEventNotification => Namespace::CalDav, + Collection::AddressBook | Collection::ContactCard => Namespace::CardDav, + _ => Namespace::Dav, + } + } +} + +impl<'x> DavQuery<'x> { + pub fn propfind( + resource: OwnedUri<'x>, + propfind: PropFind, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Uri(resource), + propfind, + depth: match headers.depth { + Depth::Zero => 0, + _ => 1, + }, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + vcard_version: headers.vcard_version, + sync_type: Default::default(), + limit: Default::default(), + expand: Default::default(), + } + } + + pub fn multiget( + multiget: MultiGet, + collection: Collection, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Multiget { + hrefs: multiget.hrefs, + parent_collection: collection, + }, + propfind: multiget.properties, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + vcard_version: headers.vcard_version, + sync_type: Default::default(), + depth: Default::default(), + limit: Default::default(), + expand: Default::default(), + } + } + + pub fn addressbook_query( + query: AddressbookQuery, + items: Vec, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Query { + filter: DavQueryFilter::Addressbook(query.filters), + parent_collection: Collection::AddressBook, + items, + }, + propfind: query.properties, + limit: query.limit, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + vcard_version: headers.vcard_version, + sync_type: Default::default(), + depth: Default::default(), + expand: Default::default(), + } + } + + pub fn calendar_query( + query: CalendarQuery, + max_time_range: Option, + items: Vec, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Query { + filter: DavQueryFilter::Calendar { + filter: query.filters, + timezone: query.timezone, + max_time_range, + }, + parent_collection: Collection::Calendar, + items, + }, + propfind: query.properties, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + sync_type: Default::default(), + depth: Default::default(), + limit: Default::default(), + vcard_version: Default::default(), + expand: Default::default(), + } + } + + pub fn changes( + resource: OwnedUri<'x>, + changes: SyncCollection, + headers: &RequestHeaders<'x>, + ) -> Self { + Self { + resource: DavQueryResource::Uri(resource), + propfind: changes.properties, + sync_type: changes + .sync_token + .as_deref() + .and_then(Urn::parse) + .and_then(|urn| urn.try_unwrap_sync()) + .map(|(id, seq)| SyncType::From { id, seq }) + .unwrap_or(SyncType::Initial), + depth: match changes.depth { + Depth::One => 1, + Depth::Infinity => usize::MAX, + _ => 0, + }, + limit: changes.limit, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + expand: false, + uri: headers.uri, + vcard_version: headers.vcard_version, + } + } + + pub fn expand( + resource: OwnedUri<'x>, + expand: ExpandProperty, + headers: &RequestHeaders<'x>, + ) -> Self { + let mut props = Vec::with_capacity(expand.properties.len()); + for item in expand.properties { + if !matches!(item.property, DavProperty::DeadProperty(_)) + && !props.contains(&item.property) + { + props.push(item.property); + } + } + + Self { + resource: DavQueryResource::Uri(resource), + propfind: PropFind::Prop(props), + depth: match headers.depth { + Depth::Zero => 0, + _ => 1, + }, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + expand: true, + uri: headers.uri, + sync_type: Default::default(), + limit: Default::default(), + vcard_version: headers.vcard_version, + } + } + + pub fn is_minimal(&self) -> bool { + self.ret == Return::Minimal + } +} + +pub(crate) enum ArchivedResource<'x> { + Calendar(Archive<&'x ArchivedCalendar>), + CalendarEvent(Archive<&'x ArchivedCalendarEvent>), + CalendarEventNotification(Archive<&'x ArchivedCalendarEventNotification>), + CalendarEventNotificationCollection(bool), + AddressBook(Archive<&'x ArchivedAddressBook>), + ContactCard(Archive<&'x ArchivedContactCard>), + FileNode(Archive<&'x ArchivedFileNode>), +} + +impl<'x> ArchivedResource<'x> { + pub fn from_archive( + archive: &'x Archive, + collection: Collection, + ) -> trc::Result { + match collection { + Collection::Calendar => archive + .to_unarchived::() + .map(ArchivedResource::Calendar), + Collection::CalendarEvent => archive + .to_unarchived::() + .map(ArchivedResource::CalendarEvent), + Collection::CalendarEventNotification => archive + .to_unarchived::() + .map(ArchivedResource::CalendarEventNotification), + Collection::AddressBook => archive + .to_unarchived::() + .map(ArchivedResource::AddressBook), + Collection::FileNode => archive + .to_unarchived::() + .map(ArchivedResource::FileNode), + Collection::ContactCard => archive + .to_unarchived::() + .map(ArchivedResource::ContactCard), + _ => unreachable!(), + } + } + + pub fn acls(&self) -> Option<&ArchivedVec> { + match self { + Self::Calendar(archive) => Some(&archive.inner.acls), + Self::AddressBook(archive) => Some(&archive.inner.acls), + Self::FileNode(archive) => Some(&archive.inner.acls), + _ => None, + } + } + + pub fn created(&self) -> i64 { + match self { + ArchivedResource::Calendar(archive) => archive.inner.created.to_native(), + ArchivedResource::CalendarEvent(archive) => archive.inner.created.to_native(), + ArchivedResource::AddressBook(archive) => archive.inner.created.to_native(), + ArchivedResource::ContactCard(archive) => archive.inner.created.to_native(), + ArchivedResource::FileNode(archive) => archive.inner.created.to_native(), + ArchivedResource::CalendarEventNotification(archive) => { + archive.inner.created.to_native() + } + ArchivedResource::CalendarEventNotificationCollection(_) => 1634515200, + } + } + + pub fn modified(&self) -> i64 { + match self { + ArchivedResource::Calendar(archive) => archive.inner.modified.to_native(), + ArchivedResource::CalendarEvent(archive) => archive.inner.modified.to_native(), + ArchivedResource::AddressBook(archive) => archive.inner.modified.to_native(), + ArchivedResource::ContactCard(archive) => archive.inner.modified.to_native(), + ArchivedResource::FileNode(archive) => archive.inner.modified.to_native(), + ArchivedResource::CalendarEventNotification(archive) => { + archive.inner.modified.to_native() + } + ArchivedResource::CalendarEventNotificationCollection(_) => 1634515200, + } + } + + pub fn dead_properties(&self) -> Option<&ArchivedDeadProperty> { + match self { + ArchivedResource::Calendar(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::CalendarEvent(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::AddressBook(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::ContactCard(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::FileNode(archive) => Some(&archive.inner.dead_properties), + ArchivedResource::CalendarEventNotification(_) + | ArchivedResource::CalendarEventNotificationCollection(_) => None, + } + } + + pub fn content_length(&self) -> Option { + match self { + ArchivedResource::FileNode(archive) => { + archive.inner.file.as_ref().map(|f| f.size.to_native()) + } + ArchivedResource::CalendarEvent(archive) => archive.inner.size.to_native().into(), + ArchivedResource::CalendarEventNotification(archive) => { + archive.inner.size.to_native().into() + } + ArchivedResource::ContactCard(archive) => archive.inner.size.to_native().into(), + ArchivedResource::AddressBook(_) + | ArchivedResource::Calendar(_) + | ArchivedResource::CalendarEventNotificationCollection(_) => None, + } + } + + pub fn content_type(&self) -> Option<&str> { + match self { + ArchivedResource::FileNode(archive) => archive + .inner + .file + .as_ref() + .and_then(|f| f.media_type.as_deref()), + ArchivedResource::CalendarEvent(_) | ArchivedResource::CalendarEventNotification(_) => { + "text/calendar".into() + } + ArchivedResource::ContactCard(_) => "text/vcard".into(), + ArchivedResource::AddressBook(_) + | ArchivedResource::Calendar(_) + | ArchivedResource::CalendarEventNotificationCollection(_) => None, + } + } + + pub fn display_name(&self, account_id: u32) -> Option<&str> { + match self { + ArchivedResource::Calendar(archive) => { + Some(archive.inner.preferences(account_id).name.as_str()) + } + ArchivedResource::CalendarEvent(archive) => archive.inner.display_name.as_deref(), + ArchivedResource::AddressBook(archive) => { + Some(archive.inner.preferences(account_id).name.as_str()) + } + ArchivedResource::ContactCard(archive) => archive.inner.display_name.as_deref(), + ArchivedResource::FileNode(archive) => archive.inner.display_name.as_deref(), + ArchivedResource::CalendarEventNotification(_) + | ArchivedResource::CalendarEventNotificationCollection(_) => None, + } + } + + pub fn supported_report_set(&self) -> Option> { + match self { + ArchivedResource::Calendar(_) => vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ReportSet::ExpandProperty, + ReportSet::CalendarQuery, + ReportSet::CalendarMultiGet, + ReportSet::FreeBusyQuery, + ] + .into(), + ArchivedResource::AddressBook(_) => vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ReportSet::ExpandProperty, + ReportSet::AddressbookQuery, + ReportSet::AddressbookMultiGet, + ] + .into(), + ArchivedResource::FileNode(archive) if archive.inner.file.is_none() => vec![ + ReportSet::SyncCollection, + ReportSet::AclPrincipalPropSet, + ReportSet::PrincipalMatch, + ] + .into(), + ArchivedResource::CalendarEventNotificationCollection(_) => vec![ + ReportSet::SyncCollection, + ReportSet::CalendarQuery, + ReportSet::CalendarMultiGet, + ] + .into(), + _ => None, + } + } + + pub fn resource_type(&self) -> Option> { + match self { + ArchivedResource::Calendar(_) => { + vec![ResourceType::Collection, ResourceType::Calendar].into() + } + ArchivedResource::AddressBook(_) => { + vec![ResourceType::Collection, ResourceType::AddressBook].into() + } + ArchivedResource::FileNode(archive) if archive.inner.file.is_none() => { + vec![ResourceType::Collection].into() + } + ArchivedResource::CalendarEventNotificationCollection(true) => { + vec![ResourceType::Collection, ResourceType::ScheduleInbox].into() + } + ArchivedResource::CalendarEventNotificationCollection(false) => { + vec![ResourceType::Collection, ResourceType::ScheduleOutbox].into() + } + _ => None, + } + } +} + +impl SyncType { + pub fn is_none(&self) -> bool { + matches!(self, SyncType::None) + } + + pub fn is_none_or_initial(&self) -> bool { + matches!(self, SyncType::None | SyncType::Initial) + } +} diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs new file mode 100644 index 0000000..a245c0e --- /dev/null +++ b/crates/dav/src/common/propfind.rs @@ -0,0 +1,1818 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedResource, DavCollection, DavQuery, DavQueryFilter, ETag, SyncType, + acl::{DavAclHandler, Privileges}, + lock::{LockData, build_lock_key}, + uri::{UriResource, Urn}, +}; +use crate::{ + DavError, DavErrorCondition, + calendar::{ + CALENDAR_CONTAINER_PROPS, CALENDAR_ITEM_PROPS, + query::{CalendarQueryHandler, try_parse_tz}, + }, + card::{ + CARD_CONTAINER_PROPS, CARD_ITEM_PROPS, + query::{serialize_vcard_with_props, vcard_query}, + }, + common::{DavQueryResource, acl::current_user_privilege_set, uri::DavUriResource}, + file::{FILE_CONTAINER_PROPS, FILE_ITEM_PROPS}, + principal::{ + CurrentUserPrincipal, + propfind::{PrincipalPropFind, build_home_set}, + }, +}; +use calcard::{common::timezone::Tz, icalendar::ICalendarComponentType}; +use common::{ + DavResourcePath, DavResources, Server, + auth::{AccessToken, AccountCache}, +}; +use dav_proto::{ + Depth, RequestHeaders, + parser::header::dav_base_uri, + requests::NsDeadProperty, + schema::{ + Collation, Namespace, + property::{ + ActiveLock, CalDavProperty, CardDavProperty, Comp, DavProperty, DavValue, + PrincipalProperty, Privilege, ReportSet, ResourceType, Rfc1123DateTime, + SupportedCollation, SupportedLock, WebDavProperty, + }, + request::{DavDeadProperty, DavPropertyValue, PropFind}, + response::{ + AclRestrictions, BaseCondition, Href, List, MultiStatus, PropStat, Response, + SupportedPrivilege, + }, + }, +}; +use groupware::calendar::{SCHEDULE_INBOX_ID, SupportedComponent}; +use groupware::{ + DavCalendarResource, DavResourceName, cache::GroupwareCache, calendar::ArchivedTimezone, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use registry::schema::{enums::Permission, prelude::ObjectType}; +use std::sync::Arc; +use store::{ + ValueKey, + registry::RegistryQuery, + write::{AlignedBytes, Archive}, +}; +use store::{ + ahash::AHashMap, + query::log::{Change, Query}, + roaring::RoaringBitmap, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + dead_property::DeadProperty, +}; +use utils::map::bitmap::Bitmap; + +pub(crate) trait PropFindRequestHandler: Sync + Send { + fn handle_propfind_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PropFind, + ) -> impl Future> + Send; + + fn handle_dav_query( + &self, + access_token: &AccessToken, + query: DavQuery<'_>, + ) -> impl Future> + Send; + + fn dav_quota( + &self, + account_id: u32, + ) -> impl Future> + Send; +} + +pub(crate) struct PropFindData { + pub accounts: AHashMap, +} + +#[derive(Default)] +pub(crate) struct PropFindAccountData { + pub resources: Option>, + pub quota: Option, + pub owner: Option, + pub locks: Option>, + pub locks_not_found: bool, +} + +#[derive(Clone, Default)] +pub(crate) struct PropFindAccountQuota { + pub used: u64, + pub available: Option, +} + +#[derive(Debug)] +pub(crate) struct PropFindItem { + pub name: String, + pub account_id: u32, + pub document_id: u32, + pub parent_id: Option, + pub is_container: bool, +} + +impl PropFindRequestHandler for Server { + async fn handle_propfind_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PropFind, + ) -> crate::Result { + // Validate URI + let resource = self.validate_uri(access_token, headers.uri).await?; + + // Reject Infinity depth for certain queries + let return_children = match headers.depth { + Depth::One | Depth::None => true, + Depth::Zero => false, + Depth::Infinity => match resource.collection { + Collection::Principal => true, + Collection::Calendar | Collection::AddressBook + if resource.account_id.is_some() && resource.resource.is_some() => + { + true + } + Collection::CalendarEventNotification if resource.account_id.is_some() => true, + _ => { + return Err(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::PropFindFiniteDepth, + ) + .into()); + } + }, + }; + + // List shared resources + if let Some(account_id) = resource.account_id { + match resource.collection { + Collection::FileNode + | Collection::Calendar + | Collection::AddressBook + | Collection::CalendarEventNotification => { + // Validate permissions + access_token.enforce_permission(match resource.collection { + Collection::FileNode => Permission::DavFilePropFind, + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarEventNotification => Permission::DavCalPropFind, + Collection::AddressBook | Collection::ContactCard => { + Permission::DavCardPropFind + } + _ => unreachable!(), + })?; + + self.handle_dav_query( + access_token, + DavQuery::propfind( + UriResource::new_owned( + resource.collection, + account_id, + resource.resource, + ), + request, + headers, + ), + ) + .await + } + Collection::Principal => { + let mut response = MultiStatus::new(Vec::with_capacity(16)); + + if resource.resource.is_some() { + response.add_response(Response::new_status( + [headers.uri.to_string()], + StatusCode::NOT_FOUND, + )); + } else if access_token.has_account_access(account_id) + || (self.core.groupware.allow_directory_query + && access_token.has_permission(Permission::DavPrincipalList)) + || access_token.has_permission(Permission::SysAccountQuery) + { + self.prepare_principal_propfind_response( + access_token, + Collection::Principal, + [account_id].into_iter(), + &request, + &mut response, + ) + .await?; + } else { + response.add_response(Response::new_status( + [headers.uri.to_string()], + StatusCode::FORBIDDEN, + )); + } + + Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body(response.to_string())) + } + _ => unreachable!(), + } + } else { + let mut response = MultiStatus::new(Vec::with_capacity(16)); + + // Add container info + if !headers.depth_no_root { + add_base_collection_response( + self, + &request, + resource.collection, + access_token, + &mut response, + ) + .await?; + } + + if return_children { + let ids = if !matches!(resource.collection, Collection::Principal) { + // Validate permissions + access_token.enforce_permission(match resource.collection { + Collection::FileNode => Permission::DavFilePropFind, + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarEventNotification => Permission::DavCalPropFind, + Collection::AddressBook | Collection::ContactCard => { + Permission::DavCardPropFind + } + _ => unreachable!(), + })?; + RoaringBitmap::from_iter( + access_token.all_ids_by_collection(resource.collection), + ) + } else if (self.core.groupware.allow_directory_query + && access_token.has_permission(Permission::DavPrincipalList)) + || access_token.has_permission(Permission::SysAccountQuery) + { + // Return all principals + self.registry() + .query::( + RegistryQuery::new(ObjectType::Account) + .with_tenant(access_token.tenant_id()), + ) + .await + .caused_by(trc::location!())? + } else { + RoaringBitmap::from_iter(access_token.all_ids()) + }; + + self.prepare_principal_propfind_response( + access_token, + resource.collection, + ids.into_iter(), + &request, + &mut response, + ) + .await?; + } + + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) + } + } + + async fn handle_dav_query( + &self, + access_token: &AccessToken, + mut query: DavQuery<'_>, + ) -> crate::Result { + let mut response = MultiStatus::new(Vec::with_capacity(16)); + let mut data = PropFindData::new(); + let collection_container; + let collection_children; + let sync_collection; + let mut query_filter = None; + let mut limit = std::cmp::min( + query.limit.unwrap_or(u32::MAX) as usize, + self.core.groupware.max_results, + ); + let mut is_sync_limited = false; + let mut is_propfind = false; + let mut ical_instances_limit = self.core.groupware.max_ical_instances; + + let paths = match std::mem::take(&mut query.resource) { + DavQueryResource::Uri(resource) => { + collection_container = resource.collection; + collection_children = collection_container.child_collection().unwrap(); + sync_collection = SyncCollection::from(collection_container); + is_propfind = true; + + get( + self, + access_token, + collection_container, + collection_children, + sync_collection, + &query, + &mut data, + &mut response, + resource, + limit, + &mut is_sync_limited, + ) + .await? + } + DavQueryResource::Multiget { + hrefs, + parent_collection, + } => { + collection_container = parent_collection; + collection_children = collection_container.child_collection().unwrap(); + sync_collection = SyncCollection::from(collection_container); + + multiget( + self, + access_token, + collection_container, + collection_children, + sync_collection, + &mut data, + &mut response, + hrefs, + ) + .await? + } + DavQueryResource::Query { + filter, + parent_collection, + items, + } => { + query_filter = Some(filter); + collection_container = parent_collection; + collection_children = collection_container.child_collection().unwrap(); + sync_collection = SyncCollection::from(collection_container); + + items + } + DavQueryResource::None => unreachable!(), + }; + response.set_namespace(collection_container.namespace()); + + let mut skip_not_found = query.expand; + let properties = match &query.propfind { + PropFind::PropName => { + let (container_props, children_props) = match collection_container { + Collection::FileNode => { + (FILE_CONTAINER_PROPS.as_slice(), FILE_ITEM_PROPS.as_slice()) + } + Collection::Calendar | Collection::CalendarEventNotification => ( + CALENDAR_CONTAINER_PROPS.as_slice(), + CALENDAR_ITEM_PROPS.as_slice(), + ), + Collection::AddressBook => { + (CARD_CONTAINER_PROPS.as_slice(), CARD_ITEM_PROPS.as_slice()) + } + _ => unreachable!(), + }; + + for property in container_props.iter().chain(children_props) { + response.set_namespace(property.namespace()); + } + + for item in paths { + let props = if item.is_container { + container_props + .iter() + .cloned() + .map(DavPropertyValue::empty) + .collect::>() + } else { + children_props + .iter() + .cloned() + .map(DavPropertyValue::empty) + .collect::>() + }; + + response.add_response(Response::new_propstat( + item.name, + vec![PropStat::new_list(props)], + )); + } + + return Ok( + HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string()) + ); + } + PropFind::AllProp(items) => { + skip_not_found = true; + let mut result = Vec::with_capacity(items.len() + DavProperty::ALL_PROPS.len()); + result.extend(DavProperty::ALL_PROPS); + result.extend(items.iter().filter(|field| !field.is_all_prop()).cloned()); + result + } + PropFind::Prop(items) => items.clone(), + }; + + for property in &properties { + response.set_namespace(property.namespace()); + } + + let is_scheduling = collection_container == Collection::CalendarEventNotification; + let account_info = self + .account(access_token.account_id()) + .await + .caused_by(trc::location!())?; + 'outer: for item in paths { + let account_id = item.account_id; + let personal_id = access_token.personal_id(account_id, collection_container); + let document_id = item.document_id; + let collection = if item.is_container { + collection_container + } else { + collection_children + }; + + // Unarchive resource + let archive_; + let archive = if is_scheduling && item.is_container { + archive_ = Archive::default(); + ArchivedResource::CalendarEventNotificationCollection( + item.document_id == SCHEDULE_INBOX_ID, + ) + } else if let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + collection, + document_id, + )) + .await + .caused_by(trc::location!())? + { + archive_ = archive; + ArchivedResource::from_archive(&archive_, collection).caused_by(trc::location!())? + } else { + response.add_response(Response::new_status([item.name], StatusCode::NOT_FOUND)); + continue; + }; + + // Filter + let mut calendar_filter = None; + if let Some(query_filter) = &query_filter { + match (query_filter, &archive) { + (DavQueryFilter::Addressbook(filter), ArchivedResource::ContactCard(card)) + if !vcard_query(&card.inner.card, filter) => + { + continue; + } + ( + DavQueryFilter::Calendar { + filter, + timezone, + max_time_range, + }, + ArchivedResource::CalendarEvent(event), + ) => { + let default_tz = if let Some(tz) = try_parse_tz(timezone) { + tz + } else if let Some(calendar_id) = item.parent_id { + data.resources(self, access_token, account_id, SyncCollection::Calendar) + .await + .caused_by(trc::location!())? + .calendar_default_tz(calendar_id, account_id) + .unwrap_or(Tz::UTC) + } else { + Tz::UTC + }; + let mut query_handler = + CalendarQueryHandler::new(event.inner, *max_time_range, default_tz); + if !query_handler.filter(event.inner, filter) { + continue; + } + calendar_filter = Some(query_handler); + } + _ => (), + } + } + + // Fill properties + let dead_properties = archive.dead_properties(); + let mut fields = Vec::with_capacity(properties.len()); + let mut fields_not_found = Vec::new(); + for property in &properties { + match property { + DavProperty::WebDav(dav_property) => match dav_property { + WebDavProperty::CreationDate => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::Timestamp(archive.created()), + )); + } + WebDavProperty::DisplayName => { + if let Some(name) = archive.display_name(personal_id) { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(name.to_string()), + )); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::GetContentLanguage => { + if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::GetContentLength => { + if let Some(value) = archive.content_length() { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::Uint64(value as u64), + )); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::GetContentType => { + if let Some(value) = archive.content_type() { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(value.to_string()), + )); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::GetETag => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(archive_.etag()), + )); + } + WebDavProperty::GetCTag => { + if item.is_container { + let ctag = data + .resources(self, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())? + .highest_change_id; + + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(format!("\"{ctag}\"")), + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::GetLastModified => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::Rfc1123Date(Rfc1123DateTime::new(archive.modified())), + )); + } + WebDavProperty::ResourceType => { + if let Some(resource_type) = archive.resource_type() { + fields.push(DavPropertyValue::new(property.clone(), resource_type)); + } else { + fields.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::LockDiscovery => { + if let Some(locks) = data + .locks(self, account_id, collection_container, &item) + .await + .caused_by(trc::location!())? + { + fields.push(DavPropertyValue::new(property.clone(), locks)); + } else { + fields.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::SupportedLock => { + if !is_scheduling { + fields.push(DavPropertyValue::new( + property.clone(), + SupportedLock::default(), + )); + } else { + fields.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::SupportedReportSet => { + if let Some(report_set) = archive.supported_report_set() { + fields.push(DavPropertyValue::new(property.clone(), report_set)); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::SyncToken => { + let sync_token = data + .resources(self, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())? + .sync_token(); + + fields.push(DavPropertyValue::new(property.clone(), sync_token)); + } + WebDavProperty::CurrentUserPrincipal => { + if !query.expand { + fields.push(DavPropertyValue::new( + property.clone(), + vec![account_info.current_user_principal()], + )); + } else { + fields.push(DavPropertyValue::new( + property.clone(), + self.expand_principal( + access_token, + access_token.account_id(), + &query.propfind, + ) + .await? + .map(|r| DavValue::Response(Box::new(r))) + .unwrap_or(DavValue::Null), + )); + } + } + WebDavProperty::QuotaAvailableBytes => { + let available = if item.is_container { + data.quota(self, account_id) + .await + .caused_by(trc::location!())? + .available + } else { + None + }; + + if let Some(available) = available { + fields.push(DavPropertyValue::new(property.clone(), available)); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::QuotaUsedBytes => { + if item.is_container { + fields.push(DavPropertyValue::new( + property.clone(), + data.quota(self, account_id) + .await + .caused_by(trc::location!())? + .used, + )); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::Owner => { + if !query.expand { + fields.push(DavPropertyValue::new( + property.clone(), + vec![ + data.owner(self, &account_info, account_id) + .await + .caused_by(trc::location!())?, + ], + )); + } else { + fields.push(DavPropertyValue::new( + property.clone(), + self.expand_principal( + access_token, + account_id, + &query.propfind, + ) + .await? + .map(|r| DavValue::Response(Box::new(r))) + .unwrap_or(DavValue::Null), + )); + } + } + WebDavProperty::Group => { + fields.push(DavPropertyValue::empty(property.clone())); + } + WebDavProperty::SupportedPrivilegeSet => { + if !is_scheduling { + fields.push(DavPropertyValue::new( + property.clone(), + vec![SupportedPrivilege::all_privileges( + collection_container == Collection::Calendar, + )], + )); + } else { + fields.push(DavPropertyValue::new( + property.clone(), + vec![SupportedPrivilege::all_scheduling_privileges(matches!( + archive, + ArchivedResource::CalendarEventNotification(_) + | ArchivedResource::CalendarEventNotificationCollection( + true + ) + ))], + )); + } + } + WebDavProperty::CurrentUserPrivilegeSet => { + let privileges = if is_scheduling { + Privilege::scheduling( + matches!( + archive, + ArchivedResource::CalendarEventNotification(_) + | ArchivedResource::CalendarEventNotificationCollection( + true + ) + ), + access_token.is_member(account_id), + ) + } else if access_token.is_member(account_id) { + Privilege::all(matches!( + collection, + Collection::Calendar | Collection::CalendarEvent + )) + } else if let Some(acls) = archive.acls() { + access_token.current_privilege_set( + account_id, + acls, + collection_container == Collection::Calendar, + ) + } else if let Some(parent_id) = item.parent_id { + current_user_privilege_set( + data.resources(self, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())? + .container_acl(access_token, parent_id), + ) + } else { + vec![] + }; + + if !privileges.is_empty() { + fields.push(DavPropertyValue::new(property.clone(), privileges)); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::Acl => { + if let Some(acls) = archive.acls() { + let aces = self + .resolve_ace( + access_token, + account_id, + acls, + query.expand.then_some(&query.propfind), + ) + .await?; + + fields.push(DavPropertyValue::new(property.clone(), aces)); + } else if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::AclRestrictions => { + fields.push(DavPropertyValue::new( + property.clone(), + AclRestrictions::default() + .with_no_invert() + .with_grant_only(), + )); + } + WebDavProperty::InheritedAclSet => { + fields.push(DavPropertyValue::empty(property.clone())); + } + WebDavProperty::PrincipalCollectionSet => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href( + DavResourceName::Principal.collection_path().to_string(), + )], + )); + } + }, + DavProperty::DeadProperty(tag) => { + if let Some(value) = + dead_properties.and_then(|props| props.find_tag(&tag.name)) + { + fields.push(DavPropertyValue::new(property.clone(), value)); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + DavProperty::CardDav(card_property) => match (card_property, &archive) { + ( + CardDavProperty::AddressbookDescription, + ArchivedResource::AddressBook(book), + ) => { + if let Some(desc) = + book.inner.preferences(personal_id).description.as_deref() + { + fields.push(DavPropertyValue::new( + property.clone(), + desc.to_string(), + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + ( + CardDavProperty::SupportedAddressData, + ArchivedResource::AddressBook(_), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::SupportedAddressData, + )); + } + ( + CardDavProperty::SupportedCollationSet, + ArchivedResource::AddressBook(_), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::Collations(List(vec![ + SupportedCollation { + collation: Collation::AsciiCasemap, + namespace: Namespace::CardDav, + }, + SupportedCollation { + collation: Collation::UnicodeCasemap, + namespace: Namespace::CardDav, + }, + ])), + )); + } + (CardDavProperty::MaxResourceSize, ArchivedResource::AddressBook(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + self.core.groupware.max_vcard_size as u64, + )); + } + ( + CardDavProperty::AddressData { + properties, + version, + }, + ArchivedResource::ContactCard(card), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(serialize_vcard_with_props( + &card.inner.card, + properties, + (*version) + .or(query.vcard_version) + .unwrap_or(self.core.groupware.vcard_version), + )), + )); + } + _ => { + if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + }, + DavProperty::CalDav(cal_property) => match (cal_property, &archive) { + ( + CalDavProperty::CalendarDescription, + ArchivedResource::Calendar(calendar), + ) => { + if let Some(desc) = calendar + .inner + .preferences(personal_id) + .description + .as_deref() + { + fields.push(DavPropertyValue::new( + property.clone(), + desc.to_string(), + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + ( + CalDavProperty::CalendarTimezone, + ArchivedResource::Calendar(calendar), + ) => { + if let ArchivedTimezone::Custom(tz) = + &calendar.inner.preferences(personal_id).time_zone + { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(tz.to_string()), + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + (CalDavProperty::TimezoneId, ArchivedResource::Calendar(calendar)) => { + if let ArchivedTimezone::IANA(tz) = + &calendar.inner.preferences(personal_id).time_zone + { + fields.push(DavPropertyValue::new( + property.clone(), + Tz::from_id(tz.to_native()).unwrap_or(Tz::UTC).to_string(), + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + ( + CalDavProperty::SupportedCalendarComponentSet, + ArchivedResource::Calendar(calendar), + ) => { + let supported_components = + calendar.inner.supported_components.to_native(); + fields.push(DavPropertyValue::new( + property.clone(), + if supported_components != 0 { + DavValue::Components(List( + Bitmap::::from(supported_components) + .into_iter() + .map(ICalendarComponentType::from) + .map(Comp) + .collect(), + )) + } else { + DavValue::all_calendar_components() + }, + )); + } + (CalDavProperty::SupportedCalendarData, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::SupportedCalendarData, + )); + } + (CalDavProperty::SupportedCollationSet, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::Collations(List(vec![ + SupportedCollation { + collation: Collation::AsciiCasemap, + namespace: Namespace::CalDav, + }, + SupportedCollation { + collation: Collation::UnicodeCasemap, + namespace: Namespace::CalDav, + }, + ])), + )); + } + (CalDavProperty::MaxResourceSize, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + self.core.groupware.max_ical_size as u64, + )); + } + (CalDavProperty::MinDateTime, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String("0001-01-01T00:00:00Z".to_string()), + )); + } + (CalDavProperty::MaxDateTime, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String("9999-12-31T23:59:59Z".to_string()), + )); + } + (CalDavProperty::MaxInstances, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + self.core.groupware.max_ical_instances as u64, + )); + } + ( + CalDavProperty::MaxAttendeesPerInstance, + ArchivedResource::Calendar(_), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + self.core.groupware.max_ical_attendees_per_instance as u64, + )); + } + ( + CalDavProperty::CalendarData(data), + ArchivedResource::CalendarEvent(event), + ) => { + if calendar_filter.is_some() || !data.properties.is_empty() { + if let Some(ical) = calendar_filter + .get_or_insert_with(|| { + CalendarQueryHandler::new(event.inner, None, Tz::UTC) + }) + .serialize_ical(event.inner, data, &mut ical_instances_limit) + { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(ical), + )); + } else { + limit = 0; + break 'outer; + } + } else { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(event.inner.data.event.to_string()), + )); + } + } + ( + CalDavProperty::CalendarData(_), + ArchivedResource::CalendarEventNotification(event), + ) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::CData(event.inner.event.to_string()), + )); + } + (CalDavProperty::ScheduleTag, ArchivedResource::CalendarEvent(event)) + if event.inner.schedule_tag.is_some() => + { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(format!( + "\"{}\"", + event.inner.schedule_tag.as_ref().unwrap() + )), + )); + } + (CalDavProperty::ScheduleCalendarTransp, ArchivedResource::Calendar(_)) => { + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::DeadProperty(DeadProperty::single_with_ns( + Namespace::CalDav, + "opaque", + )), + )); + } + ( + CalDavProperty::ScheduleDefaultCalendarURL, + ArchivedResource::CalendarEventNotificationCollection(true), + ) => { + if let Some(default_cal) = &self.core.groupware.default_calendar_name { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/{default_cal}/", + DavResourceName::Cal.base_path(), + item.name.split('/').nth(3).unwrap_or_default() + ))], + )); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + + _ => { + if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + }, + + property => { + if !skip_not_found { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + } + } + + // Add dead properties + if skip_not_found + && let Some(dead_properties) = + dead_properties.filter(|dead_properties| !dead_properties.0.is_empty()) + { + dead_properties.to_dav_values(&mut fields); + } + + // Add response + let mut prop_stat = Vec::with_capacity(2); + if !fields.is_empty() { + prop_stat.push(PropStat::new_list(fields)); + } + if !fields_not_found.is_empty() && !query.is_minimal() { + prop_stat + .push(PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND)); + } + if prop_stat.is_empty() { + prop_stat.push(PropStat::new_list(vec![])); + } + response.add_response(Response::new_propstat(item.name, prop_stat)); + + limit -= 1; + if limit == 0 { + break; + } + } + + if limit == 0 || is_sync_limited { + response.add_response( + Response::new_status([query.uri], StatusCode::INSUFFICIENT_STORAGE) + .with_error(BaseCondition::NumberOfMatchesWithinLimit) + .with_response_description(if ical_instances_limit > 0 { + format!( + "The number of matches exceeds the limit of {}", + query + .limit + .unwrap_or(self.core.groupware.max_results as u32) + ) + } else { + format!( + "The number of recurrence instances exceeds the limit of {}", + query + .limit + .unwrap_or(self.core.groupware.max_ical_instances as u32) + ) + }), + ); + } + + if !is_propfind || !response.response.0.is_empty() || !query.sync_type.is_none() { + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) + } else { + Ok(HttpResponse::new(StatusCode::NOT_FOUND)) + } + } + + async fn dav_quota(&self, account_id: u32) -> trc::Result { + let account = self.account(account_id).await.caused_by(trc::location!())?; + let used = self + .get_used_quota_account(account_id) + .await + .caused_by(trc::location!())? + .max(0) as u64; + let mut available = + (account.quota_disk > 0).then(|| account.quota_disk.saturating_sub(used)); + + if let Some(tenant_id) = account.id_tenant { + let tenant = self.tenant(tenant_id).await.caused_by(trc::location!())?; + + if tenant.quota_disk > 0 { + let tenant_used = self + .get_used_quota_tenant(tenant_id) + .await + .caused_by(trc::location!())? + .max(0) as u64; + let tenant_available = tenant.quota_disk.saturating_sub(tenant_used); + + available = Some(available.map_or(tenant_available, |available| { + available.min(tenant_available) + })); + } + } + + Ok(PropFindAccountQuota { used, available }) + } +} +#[allow(clippy::too_many_arguments)] +async fn get( + server: &Server, + access_token: &AccessToken, + collection_container: Collection, + collection_children: Collection, + sync_collection: SyncCollection, + query: &DavQuery<'_>, + data: &mut PropFindData, + response: &mut MultiStatus, + resource: UriResource>, + limit: usize, + is_sync_limited: &mut bool, +) -> crate::Result> { + let container_has_children = collection_children != collection_container; + response.set_namespace(collection_container.namespace()); + + let account_id = resource.account_id; + let resources = data + .resources(server, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())?; + + // Obtain document ids + let mut display_containers = if !access_token.is_member(account_id) { + resources + .shared_containers( + access_token, + [if container_has_children { + Acl::ReadItems + } else { + Acl::Read + }], + true, + ) + .into() + } else { + None + }; + let mut display_children = display_containers + .as_ref() + .filter(|_| container_has_children) + .map(|containers| { + RoaringBitmap::from_iter(resources.resources.iter().filter_map(|r| { + if r.child_names() + .is_some_and(|n| n.iter().any(|n| containers.contains(n.parent_id))) + { + Some(r.document_id) + } else { + None + } + })) + }); + + // Filter by changelog + let is_sync = match query.sync_type { + SyncType::From { id, seq } => { + let changes = server + .store() + .changes(account_id, sync_collection.into(), Query::Since(id)) + .await + .caused_by(trc::location!())?; + let mut vanished: Vec = Vec::new(); + + // Merge changes + let mut total_changes = 0; + let mut maybe_has_vanished = false; + if container_has_children { + let mut container_changes = RoaringBitmap::new(); + let mut item_changes = RoaringBitmap::new(); + + for change in changes.changes { + match change { + Change::InsertItem(id) => { + item_changes.insert(id as u32); + } + Change::UpdateItem(id) => { + maybe_has_vanished = true; + item_changes.insert(id as u32); + } + Change::InsertContainer(id) => { + container_changes.insert(id as u32); + } + Change::UpdateContainer(id) => { + maybe_has_vanished = true; + container_changes.insert(id as u32); + } + Change::DeleteContainer(_) | Change::DeleteItem(_) => { + maybe_has_vanished = true; + } + Change::UpdateContainerProperty(_) => (), + } + } + + for (document_ids, changes) in [ + (&mut display_containers, container_changes), + (&mut display_children, item_changes), + ] { + if let Some(document_ids) = document_ids { + *document_ids &= changes; + total_changes += document_ids.len() as usize; + } else { + total_changes += changes.len() as usize; + *document_ids = Some(changes); + } + } + } else { + let changes = RoaringBitmap::from_iter(changes.changes.iter().filter_map( + |change| match change { + Change::InsertItem(id) | Change::InsertContainer(id) => Some(*id as u32), + Change::UpdateItem(id) | Change::UpdateContainer(id) => { + maybe_has_vanished = true; + Some(*id as u32) + } + Change::DeleteContainer(_) | Change::DeleteItem(_) => { + maybe_has_vanished = true; + None + } + _ => None, + }, + )); + if let Some(document_ids) = &mut display_containers { + *document_ids &= changes; + total_changes += document_ids.len() as usize; + } else { + total_changes += changes.len() as usize; + display_containers = Some(changes); + } + } + + if maybe_has_vanished + && let Some(vanished_collection) = sync_collection.vanished_collection() + { + vanished = server + .store() + .vanished(account_id, vanished_collection.into(), Query::Since(id)) + .await + .caused_by(trc::location!())?; + total_changes += vanished.len(); + } + + // Truncate changes + if total_changes > limit { + let mut offset = limit * seq as usize; + let mut total_changes = 0; + + // Add vanished items to response + for item in vanished { + if offset > 0 { + offset -= 1; + } else if total_changes < limit { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + total_changes += 1; + } else { + *is_sync_limited = true; + } + } + + // Add items to document set + for document_ids in [&mut display_containers, &mut display_children] + .into_iter() + .flatten() + { + let mut new_document_ids = RoaringBitmap::new(); + for id in document_ids.iter() { + if offset > 0 { + offset -= 1; + } else if total_changes < limit { + new_document_ids.insert(id); + total_changes += 1; + } else { + *is_sync_limited = true; + } + } + *document_ids = new_document_ids; + } + + if *is_sync_limited { + response.set_sync_token(Urn::Sync { id, seq: seq + 1 }.to_string()); + } + } else { + // Add vanished items to response + for item in vanished { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + } + } + + if !*is_sync_limited { + response.set_sync_token(resources.sync_token()); + } + + true + } + SyncType::Initial => { + response.set_sync_token(resources.sync_token()); + false + } + SyncType::None => false, + }; + + let mut results = Vec::new(); + if let Some(resource) = resource.resource { + results = resources + .subtree_with_depth(resource, query.depth) + .filter(|item| { + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id()) + } else { + display_children + .as_ref() + .is_some_and(|children| children.contains(item.document_id())) + } + } else { + containers.contains(item.document_id()) + } + }) && (!query.depth_no_root || item.path() != resource) + }) + .map(|item| PropFindItem::new(resources.format_resource(item), account_id, item)) + .collect::>(); + } else { + if !query.depth_no_root && query.sync_type.is_none_or_initial() { + server + .prepare_principal_propfind_response( + access_token, + collection_container, + [account_id].into_iter(), + &query.propfind, + response, + ) + .await?; + } + + if query.depth != 0 { + results = resources + .tree_with_depth(query.depth - 1) + .filter(|item| { + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id()) + } else { + display_children + .as_ref() + .is_some_and(|children| children.contains(item.document_id())) + } + } else { + containers.contains(item.document_id()) + } + }) + }) + .map(|item| PropFindItem::new(resources.format_resource(item), account_id, item)) + .collect::>(); + + // Assisted discovery: + // If 'bob' has access to 'jane' and `bill` calendars, a query to '/dav/cal/bob' will return: + // - /dav/cal/bob/default + // - /dav/cal/jane/default + // - /dav/cal/bill/default + // This is invalid but it's the only workaround for clients which do not support multiple home-sets + if server.core.groupware.assisted_discovery + && !is_sync + && account_id == access_token.account_id() + && matches!( + sync_collection, + SyncCollection::Calendar | SyncCollection::AddressBook + ) + { + for shared_account_id in access_token.all_ids_by_collection(collection_container) { + if shared_account_id == access_token.account_id() { + continue; + } + let shared_resources = data + .resources(server, access_token, shared_account_id, sync_collection) + .await + .caused_by(trc::location!())?; + let shared_containers = + (!access_token.is_member(shared_account_id)).then(|| { + shared_resources.shared_containers( + access_token, + [if container_has_children { + Acl::ReadItems + } else { + Acl::Read + }], + true, + ) + }); + if shared_containers + .as_ref() + .is_none_or(|containers| !containers.is_empty()) + { + results.extend( + shared_resources + .tree_with_depth(query.depth - 1) + .filter(|item| { + item.is_container() + && shared_containers.as_ref().is_none_or(|containers| { + containers.contains(item.document_id()) + }) + }) + .map(|item| { + PropFindItem::new( + shared_resources.format_resource(item), + shared_account_id, + item, + ) + }), + ); + } + } + } + } + } + + Ok(results) +} + +#[allow(clippy::too_many_arguments)] +async fn multiget( + server: &Server, + access_token: &AccessToken, + collection_container: Collection, + collection_children: Collection, + sync_collection: SyncCollection, + data: &mut PropFindData, + response: &mut MultiStatus, + hrefs: Vec, +) -> crate::Result> { + let mut paths = Vec::with_capacity(hrefs.len() * 2); + let mut shared_folders_by_account: AHashMap> = + AHashMap::with_capacity(3); + + for item in hrefs { + let resource = match server + .validate_uri(access_token, &item) + .await + .and_then(|r| r.into_owned_uri()) + { + Ok(resource) => resource, + Err(DavError::Code(code)) => { + response.add_response(Response::new_status([item], code)); + continue; + } + Err(err) => { + return Err(err); + } + }; + + let account_id = resource.account_id; + let resources = data + .resources(server, access_token, account_id, sync_collection) + .await + .caused_by(trc::location!())?; + + let document_ids = if !access_token.is_member(account_id) { + if let Some(document_ids) = shared_folders_by_account.get(&account_id) { + document_ids.clone().into() + } else { + let document_ids = Arc::new(resources.shared_containers( + access_token, + [if collection_children == collection_container { + Acl::ReadItems + } else { + Acl::Read + }], + true, + )); + shared_folders_by_account.insert(account_id, document_ids.clone()); + document_ids.into() + } + } else { + None + }; + + if let Some(resource) = resource.resource.and_then(|name| resources.by_path(name)) { + if !resource.is_container() { + if document_ids + .as_ref() + .is_none_or(|docs| docs.contains(resource.parent_id().unwrap())) + { + paths.push(PropFindItem::new( + resources.format_resource(resource), + account_id, + resource, + )); + } else { + response.add_response( + Response::new_status([item], StatusCode::FORBIDDEN) + .with_response_description( + "Not enough permissions to access this shared resource", + ), + ); + } + } else { + response.add_response( + Response::new_status([item], StatusCode::FORBIDDEN) + .with_response_description("Multiget not allowed for collections"), + ); + } + } else { + response.add_response(Response::new_status([item], StatusCode::NOT_FOUND)); + } + } + + Ok(paths) +} + +impl PropFindItem { + pub fn new(name: String, account_id: u32, resource: DavResourcePath<'_>) -> Self { + Self { + name, + account_id, + document_id: resource.document_id(), + parent_id: resource.parent_id(), + is_container: resource.is_container(), + } + } +} + +impl PropFindData { + pub fn new() -> Self { + Self { + accounts: AHashMap::with_capacity(2), + } + } + + pub async fn quota( + &mut self, + server: &Server, + account_id: u32, + ) -> trc::Result { + let data = self.accounts.entry(account_id).or_default(); + + if data.quota.is_none() { + data.quota = server.dav_quota(account_id).await?.into(); + } + + Ok(data.quota.clone().unwrap()) + } + + pub async fn owner( + &mut self, + server: &Server, + account_info: &AccountCache, + account_id: u32, + ) -> trc::Result { + let data = self.accounts.entry(account_id).or_default(); + + if data.owner.is_none() { + data.owner = server + .owner_href(account_info, account_id) + .await + .caused_by(trc::location!())? + .into(); + } + + Ok(data.owner.clone().unwrap()) + } + + pub async fn resources( + &mut self, + server: &Server, + access_token: &AccessToken, + account_id: u32, + sync_collection: SyncCollection, + ) -> trc::Result> { + let data = self.accounts.entry(account_id).or_default(); + + if data.resources.is_none() { + let resources = server + .fetch_dav_resources(access_token.account_id(), account_id, sync_collection) + .await + .caused_by(trc::location!())?; + data.resources = resources.into(); + } + + Ok(data.resources.clone().unwrap()) + } + + pub async fn locks( + &mut self, + server: &Server, + account_id: u32, + collection_container: Collection, + item: &PropFindItem, + ) -> trc::Result>> { + let data = self.accounts.entry(account_id).or_default(); + + if data.locks.is_none() && !data.locks_not_found { + data.locks = server + .in_memory_store() + .key_get::>( + build_lock_key(account_id, collection_container).as_slice(), + ) + .await + .caused_by(trc::location!())?; + if data.locks.is_none() { + data.locks_not_found = true; + } + } + + if let Some(lock_data) = &data.locks { + let base_uri = dav_base_uri(&item.name).unwrap_or_default(); + lock_data.unarchive::().map(|locks| { + locks + .find_locks(&item.name.strip_prefix(base_uri).unwrap()[1..], false) + .iter() + .map(|(path, lock)| lock.to_active_lock(format!("{base_uri}/{path}"))) + .collect::>() + .into() + }) + } else { + Ok(None) + } + } +} + +pub(crate) trait SyncTokenUrn { + fn sync_token(&self) -> String; +} + +impl SyncTokenUrn for DavResources { + fn sync_token(&self) -> String { + Urn::Sync { + id: self.highest_change_id, + seq: 0, + } + .to_string() + } +} + +async fn add_base_collection_response( + server: &Server, + request: &PropFind, + collection: Collection, + access_token: &AccessToken, + response: &mut MultiStatus, +) -> trc::Result<()> { + let properties = match request { + PropFind::PropName => { + response.add_response(Response::new_propstat( + DavResourceName::from(collection).collection_path(), + vec![PropStat::new_list(vec![ + DavPropertyValue::empty(DavProperty::WebDav(WebDavProperty::ResourceType)), + DavPropertyValue::empty(DavProperty::WebDav( + WebDavProperty::CurrentUserPrincipal, + )), + DavPropertyValue::empty(DavProperty::WebDav( + WebDavProperty::SupportedReportSet, + )), + ])], + )); + return Ok(()); + } + PropFind::AllProp(_) => [ + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + ] + .as_slice(), + PropFind::Prop(items) => items, + }; + + let mut fields = Vec::with_capacity(properties.len()); + let mut fields_not_found = Vec::new(); + let account_info = server + .account(access_token.account_id()) + .await + .caused_by(trc::location!())?; + + for prop in properties { + response.set_namespace(prop.namespace()); + match &prop { + DavProperty::WebDav(WebDavProperty::ResourceType) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![ResourceType::Collection], + )); + } + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal) => { + fields.push(DavPropertyValue::new( + prop.clone(), + vec![account_info.current_user_principal()], + )); + } + DavProperty::Principal(PrincipalProperty::CalendarHomeSet) => { + let hrefs = build_home_set( + server, + access_token, + account_info.name(), + access_token.account_id(), + true, + ) + .await + .caused_by(trc::location!())?; + + fields.push(DavPropertyValue::new(prop.clone(), hrefs)); + } + DavProperty::Principal(PrincipalProperty::AddressbookHomeSet) => { + let hrefs = build_home_set( + server, + access_token, + account_info.name(), + access_token.account_id(), + false, + ) + .await + .caused_by(trc::location!())?; + + fields.push(DavPropertyValue::new(prop.clone(), hrefs)); + } + DavProperty::WebDav(WebDavProperty::SupportedReportSet) => { + let reports = match collection { + Collection::Principal => ReportSet::principal(), + Collection::Calendar | Collection::CalendarEvent => ReportSet::calendar(), + Collection::AddressBook | Collection::ContactCard => ReportSet::addressbook(), + _ => ReportSet::file(), + }; + + fields.push(DavPropertyValue::new(prop.clone(), reports)); + response.set_namespace(collection.namespace()); + } + _ => { + fields_not_found.push(DavPropertyValue::empty(prop.clone())); + } + } + } + + let mut prop_stat = Vec::with_capacity(2); + + if !fields.is_empty() { + prop_stat.push(PropStat::new_list(fields)); + } + + if !fields_not_found.is_empty() { + prop_stat.push(PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND)); + } + + response.add_response(Response::new_propstat( + DavResourceName::from(collection).collection_path(), + prop_stat, + )); + + Ok(()) +} diff --git a/crates/dav/src/common/uri.rs b/crates/dav/src/common/uri.rs new file mode 100644 index 0000000..d1c2f98 --- /dev/null +++ b/crates/dav/src/common/uri.rs @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{DavError, DavResourceName}; +use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; +use http_proto::request::decode_path_element; +use hyper::StatusCode; +use std::fmt::Display; +use trc::AddContext; +use types::collection::Collection; + +#[derive(Debug)] +pub(crate) struct UriResource { + pub collection: Collection, + pub account_id: A, + pub resource: R, +} + +pub(crate) enum Urn { + Lock(u64), + Sync { id: u64, seq: u32 }, +} + +pub(crate) type UnresolvedUri<'x> = UriResource, Option<&'x str>>; +pub(crate) type OwnedUri<'x> = UriResource>; +pub(crate) type DocumentUri = UriResource; + +pub(crate) trait DavUriResource: Sync + Send { + fn validate_uri_with_status<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + error_status: StatusCode, + ) -> impl Future>> + Send; + + fn validate_uri<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + ) -> impl Future>> + Send; + + fn map_uri_resource( + &self, + access_token: &AccessToken, + uri: OwnedUri<'_>, + ) -> impl Future>> + Send; +} + +impl DavUriResource for Server { + async fn validate_uri<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + ) -> crate::Result> { + self.validate_uri_with_status(access_token, uri, StatusCode::NOT_FOUND) + .await + } + + async fn validate_uri_with_status<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + error_status: StatusCode, + ) -> crate::Result> { + let (_, uri_parts) = uri + .split_once("/dav/") + .ok_or(DavError::Code(error_status))?; + + let mut uri_parts = uri_parts + .trim_end_matches('/') + .splitn(3, '/') + .filter(|x| !x.is_empty()); + let mut resource = UriResource { + collection: uri_parts + .next() + .and_then(DavResourceName::parse) + .ok_or(DavError::Code(error_status))? + .into(), + account_id: None, + resource: None, + }; + if let Some(account) = uri_parts.next() { + // Parse account id + let account_id = if let Some(account_id) = account.strip_prefix('_') { + account_id + .parse::() + .map_err(|_| DavError::Code(error_status))? + } else { + let account = decode_path_element(account); + self.account_id_from_email(&account, false) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(error_status))? + }; + + // Validate access + if resource.collection != Collection::Principal + && !access_token.has_access(account_id, resource.collection) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Obtain remaining path + resource.account_id = Some(account_id); + resource.resource = uri_parts.next(); + } + + Ok(resource) + } + + async fn map_uri_resource( + &self, + access_token: &AccessToken, + uri: OwnedUri<'_>, + ) -> trc::Result> { + if let Some(resource) = uri.resource { + if let Some(resource) = self + .fetch_dav_resources( + access_token.account_id(), + uri.account_id, + uri.collection.into(), + ) + .await + .caused_by(trc::location!())? + .by_path(resource) + { + Ok(Some(DocumentUri { + collection: if resource.is_container() { + uri.collection + } else { + uri.collection.child_collection().unwrap_or(uri.collection) + }, + account_id: uri.account_id, + resource: resource.document_id(), + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } +} + +impl<'x> UnresolvedUri<'x> { + pub fn into_owned_uri(self) -> crate::Result> { + Ok(OwnedUri { + collection: self.collection, + account_id: self + .account_id + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?, + resource: self.resource, + }) + } +} + +impl OwnedUri<'_> { + pub fn new_owned( + collection: Collection, + account_id: u32, + resource: Option<&str>, + ) -> OwnedUri<'_> { + OwnedUri { + collection, + account_id, + resource, + } + } +} + +/*impl UriResource { + pub fn collection_path(&self) -> &'static str { + DavResourceName::from(self.collection).collection_path() + } +}*/ + +impl Urn { + pub fn try_extract_sync_id(token: &str) -> Option<&str> { + token + .strip_prefix("urn:stalwart:davsync:") + .map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x)) + } + + pub fn parse(input: &str) -> Option { + let inbox = input.strip_prefix("urn:stalwart:")?; + let (kind, id) = inbox.split_once(':')?; + match kind { + "davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock), + "davsync" => { + if let Some((id, seq)) = id.split_once(':') { + let id = u64::from_str_radix(id, 16).ok()?; + let seq = u32::from_str_radix(seq, 16).ok()?; + Some(Urn::Sync { id, seq }) + } else { + u64::from_str_radix(id, 16) + .ok() + .map(|id| Urn::Sync { id, seq: 0 }) + } + } + _ => None, + } + } + + pub fn try_unwrap_lock(&self) -> Option { + match self { + Urn::Lock(id) => Some(*id), + _ => None, + } + } + + pub fn try_unwrap_sync(&self) -> Option<(u64, u32)> { + match self { + Urn::Sync { id, seq } => Some((*id, *seq)), + _ => None, + } + } +} + +impl Display for Urn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Urn::Lock(id) => write!(f, "urn:stalwart:davlock:{id:x}",), + Urn::Sync { id, seq } => { + if *seq == 0 { + write!(f, "urn:stalwart:davsync:{id:x}") + } else { + write!(f, "urn:stalwart:davsync:{id:x}:{seq:x}") + } + } + } + } +} diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs new file mode 100644 index 0000000..0130e4a --- /dev/null +++ b/crates/dav/src/file/copy_move.rs @@ -0,0 +1,907 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::FromDavResource; +use crate::{ + DavError, DavMethod, + common::{ + ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::{DavUriResource, UriResource}, + }, + file::{DavFileResource, FileItemId}, +}; +use common::{ + DavResourcePath, DavResources, Server, auth::AccessToken, storage::index::ObjectIndexBuilder, +}; +use dav_proto::{Depth, RequestHeaders}; +use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use std::sync::Arc; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ + ahash::AHashMap, + write::{BatchBuilder, now}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection, VanishedCollection}, +}; + +pub(crate) trait FileCopyMoveRequestHandler: Sync + Send { + fn handle_file_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> impl Future> + Send; +} + +impl FileCopyMoveRequestHandler for Server { + async fn handle_file_copy_move_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_move: bool, + ) -> crate::Result { + // Validate source + let from_resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let from_account_id = from_resource_.account_id; + let from_resources = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let from_resource = from_resources.map_resource::(&from_resource_)?; + let from_resource_name = from_resource_.resource.unwrap(); + + // Validate source ACLs + if !access_token.is_member(from_account_id) { + let shared = from_resources.shared_containers( + access_token, + if is_move { + [Acl::Read, Acl::Delete].as_slice().iter().copied() + } else { + [Acl::Read].as_slice().iter().copied() + }, + false, + ); + + for resource in from_resources.subtree(from_resource_.resource.unwrap()) { + if !shared.contains(resource.document_id()) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } + } + + // Validate destination + let destination = self + .validate_uri_with_status( + access_token, + headers + .destination + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, + ) + .await?; + if destination.collection != Collection::FileNode { + return Err(DavError::Code(StatusCode::BAD_GATEWAY)); + } + let to_account_id = destination + .account_id + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + let to_resources = if to_account_id == from_account_id { + from_resources.clone() + } else { + self.fetch_dav_resources( + access_token.account_id(), + to_account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())? + }; + + // Map file item + let destination_resource_name = destination + .resource + .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + if from_account_id == to_account_id + && (from_resource_name == destination_resource_name + || from_resource_name + .strip_prefix(destination_resource_name) + .is_some_and(|v| v.is_empty() || v.starts_with('/'))) + { + return Ok(HttpResponse::new(StatusCode::BAD_GATEWAY)); + } + + // Check if the resource exists + let mut delete_destination = None; + let mut destination = if let Some((destination, new_name)) = + to_resources.map_parent(destination_resource_name) + { + if let Some(mut existing_destination) = to_resources + .by_path(destination_resource_name) + .map(Destination::from_dav_resource) + { + if !headers.overwrite_fail { + existing_destination.account_id = to_account_id; + delete_destination = Some(existing_destination); + } else { + return Ok(HttpResponse::new(StatusCode::PRECONDITION_FAILED)); + } + } + + let mut destination = destination + .map(Destination::from_dav_resource) + .unwrap_or_default(); + destination.new_name = Some(new_name.to_string()); + destination + } else { + return Err(DavError::Code(StatusCode::CONFLICT)); + }; + destination.account_id = to_account_id; + + // Validate destination ACLs + if let Some(document_id) = destination.document_id { + if let Some(delete_destination) = &delete_destination + && !access_token.is_member(to_account_id) + && !to_resources.has_access_to_container( + access_token, + delete_destination.document_id.unwrap(), + Acl::Delete, + ) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + if !access_token.is_member(to_account_id) + && !to_resources.has_access_to_container(access_token, document_id, Acl::Modify) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } else if !access_token.is_member(to_account_id) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: Collection::FileNode, + document_id: Some(from_resource.resource.document_id), + path: from_resource_name, + ..Default::default() + }, + ResourceState { + account_id: to_account_id, + collection: Collection::FileNode, + document_id: Some( + delete_destination + .as_ref() + .and_then(|d| d.document_id) + .unwrap_or(u32::MAX), + ), + path: destination_resource_name, + ..Default::default() + }, + ], + Default::default(), + if is_move { + DavMethod::MOVE + } else { + DavMethod::COPY + }, + ) + .await?; + + if delete_destination.is_none() + && from_account_id == destination.account_id + && from_resource.resource.parent_id == destination.document_id + && destination.new_name.is_some() + && is_move + { + // Rename + let from_resource_path = if from_resource.resource.is_container { + from_resources.format_collection(from_resource_name) + } else { + from_resources.format_item(from_resource_name) + }; + return rename_item( + self, + access_token, + from_resource, + from_resource_path, + destination, + ) + .await; + } + + // Validate quota + if !is_move || from_account_id != to_account_id { + let space_needed = from_resources + .subtree(from_resource_name) + .map(|a| a.size() as u64) + .sum::(); + self.has_available_quota(self.account(to_account_id).await?.as_ref(), space_needed) + .await?; + } + + // Delete collection + let is_overwrite = delete_destination + .as_ref() + .is_some_and(|d| d.is_container || from_resource.resource.is_container); + if is_overwrite { + delete_destination = None; + // Find ids to delete + let mut ids = to_resources + .subtree(destination_resource_name) + .collect::>(); + if !ids.is_empty() { + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let mut sorted_ids = Vec::with_capacity(ids.len()); + sorted_ids.extend(ids.into_iter().map(|a| a.document_id())); + DestroyArchive(sorted_ids) + .delete( + self, + access_token.account_tenant_ids(), + destination.account_id, + None, + ) + .await + .caused_by(trc::location!())?; + } + } + + match (from_resource.resource.is_container, is_move) { + (true, true) => { + move_container( + self, + access_token, + from_resources, + from_resource, + from_resource_name, + destination, + headers.depth, + ) + .await + } + (true, false) => { + copy_container( + self, + access_token, + from_resources, + from_resource, + from_resource_name, + destination, + headers.depth, + false, + ) + .await + } + (false, true) => { + if let Some(delete_destination) = delete_destination { + overwrite_and_delete_item( + self, + access_token, + from_resource, + from_resources.format_item(from_resource_name), + delete_destination, + ) + .await + } else { + move_item( + self, + access_token, + from_resource, + from_resources.format_item(from_resource_name), + destination, + ) + .await + } + } + + (false, false) => { + if let Some(delete_destination) = delete_destination { + overwrite_item(self, access_token, from_resource, delete_destination).await + } else { + copy_item(self, access_token, from_resource, destination).await + } + } + } + .map(|r| { + if is_overwrite && r.status() == StatusCode::CREATED { + r.with_status_code(StatusCode::NO_CONTENT) + } else { + r + } + }) + } +} + +#[derive(Debug)] +pub(crate) struct Destination { + pub account_id: u32, + pub new_name: Option, + pub document_id: Option, + pub is_container: bool, +} + +impl Default for Destination { + fn default() -> Self { + Self { + account_id: Default::default(), + document_id: Default::default(), + new_name: Default::default(), + is_container: true, + } + } +} + +// Moves a container under an existing container +async fn move_container( + server: &Server, + access_token: &AccessToken, + from_resources: Arc, + from_resource: UriResource, + from_resource_name: &str, + destination: Destination, + depth: Depth, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let from_document_id = from_resource.resource.document_id; + let parent_id = destination.document_id.map(|id| id + 1).unwrap_or(0); + + if from_account_id == to_account_id { + let node_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_node = node.deserialize::().caused_by(trc::location!())?; + new_node.parent_id = parent_id; + if let Some(new_name) = destination.new_name { + new_node.name = new_name; + } + let mut batch = BatchBuilder::new(); + let etag = new_node + .update( + access_token.account_tenant_ids(), + node, + from_account_id, + from_document_id, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + batch.with_account_id(from_account_id).log_vanished_item( + VanishedCollection::FileNode, + from_resources.format_collection(from_resource_name), + ); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } else { + copy_container( + server, + access_token, + from_resources, + from_resource, + from_resource_name, + destination, + depth, + true, + ) + .await + } +} + +#[allow(clippy::too_many_arguments)] +async fn copy_container( + server: &Server, + access_token: &AccessToken, + from_resources: Arc, + from_resource: UriResource, + from_resource_name: &str, + mut destination: Destination, + depth: Depth, + delete_source: bool, +) -> crate::Result { + let infinity_copy = match depth { + Depth::Zero if !delete_source => { + return copy_item(server, access_token, from_resource, destination).await; + } + Depth::One if !delete_source => false, + _ => true, + }; + + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let parent_id = destination.document_id.map(|id| id + 1).unwrap_or(0); + + // Obtain files to copy + let mut copy_files = if infinity_copy { + from_resources + .subtree(from_resource_name) + .map(|r| (r.document_id(), r.hierarchy_seq())) + .collect::>() + } else { + from_resources + .subtree_with_depth(from_resource_name, 1) + .map(|r| (r.document_id(), r.hierarchy_seq())) + .collect::>() + }; + + // Top-down copy + let mut batch = BatchBuilder::new(); + let mut id_map = AHashMap::with_capacity(copy_files.len()); + let mut delete_files = if delete_source { + Vec::with_capacity(copy_files.len()) + } else { + Vec::new() + }; + copy_files.sort_unstable_by_key(|a| a.1); + let now = now() as i64; + let mut next_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::FileNode, copy_files.len() as u64) + .await + .caused_by(trc::location!())?; + for (document_id, _) in copy_files.into_iter() { + let node_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))? + .into_deserialized::() + .caused_by(trc::location!())?; + + // Build node + let mut node = if !delete_source { + node_.inner + } else { + let node = node_.inner.clone(); + delete_files.push((document_id, node_)); + node + }; + node.modified = now; + node.created = now; + if let Some(new_name) = destination.new_name.take() { + node.name = new_name; + } + node.parent_id = if let Some(&prev_document_id) = id_map.get(&node.parent_id) { + prev_document_id + } else { + parent_id + }; + + // Prepare write batch + let new_document_id = next_document_id; + next_document_id -= 1; + batch + .with_account_id(to_account_id) + .with_collection(Collection::FileNode) + .with_document(new_document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(node) + .with_changed_by(access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .commit_point(); + id_map.insert(document_id + 1, new_document_id + 1); + } + + // Delete nodes + if !delete_files.is_empty() { + for (document_id, node) in delete_files.into_iter().rev() { + // Delete record + batch + .with_account_id(from_account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(access_token.account_tenant_ids()) + .with_current(node), + ) + .caused_by(trc::location!())? + .commit_point(); + } + batch.with_account_id(from_account_id).log_vanished_item( + VanishedCollection::FileNode, + from_resources.format_collection(from_resource_name), + ); + } + + // Write changes + if !batch.is_empty() { + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + Ok(HttpResponse::new(StatusCode::CREATED)) +} + +// Overwrites the contents of one file with another, then deletes the original +async fn overwrite_and_delete_item( + server: &Server, + access_token: &AccessToken, + from_resource: UriResource, + from_resource_path: String, + destination: Destination, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let from_document_id = from_resource.resource.document_id; + let to_document_id = destination.document_id.unwrap(); + + // dest_node is the current file at the destination + let dest_node_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::FileNode, + to_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let dest_node = dest_node_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // source_node is the file to be copied + let source_node__ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let source_node_ = source_node__ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut source_node = source_node_ + .deserialize::() + .caused_by(trc::location!())?; + source_node.name = if let Some(new_name) = destination.new_name { + new_name + } else { + dest_node.inner.name.to_string() + }; + source_node.parent_id = dest_node.inner.parent_id.into(); + + let mut batch = BatchBuilder::new(); + let etag = source_node + .update( + access_token.account_tenant_ids(), + dest_node, + to_account_id, + to_document_id, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + DestroyArchive(source_node_) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_document_id, + &mut batch, + from_resource_path, + ) + .caused_by(trc::location!())?; + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) +} + +// Overwrites the contents of one file with another +async fn overwrite_item( + server: &Server, + access_token: &AccessToken, + from_resource: UriResource, + destination: Destination, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let from_document_id = from_resource.resource.document_id; + let to_document_id = destination.document_id.unwrap(); + + // dest_node is the current file at the destination + let dest_node_ = server + .store() + .get_value::>(ValueKey::archive( + to_account_id, + Collection::FileNode, + to_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + let dest_node = dest_node_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // source_node is the file to be copied + let mut source_node = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))? + .deserialize::() + .caused_by(trc::location!())?; + source_node.name = if let Some(new_name) = destination.new_name { + new_name + } else { + dest_node.inner.name.to_string() + }; + source_node.parent_id = dest_node.inner.parent_id.into(); + let mut batch = BatchBuilder::new(); + let etag = source_node + .update( + access_token.account_tenant_ids(), + dest_node, + to_account_id, + to_document_id, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) +} + +// Moves an item under an existing container +async fn move_item( + server: &Server, + access_token: &AccessToken, + from_resource: UriResource, + from_resource_path: String, + destination: Destination, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let from_document_id = from_resource.resource.document_id; + let parent_id = destination.document_id.map(|id| id + 1).unwrap_or(0); + + let node_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_node = node.deserialize::().caused_by(trc::location!())?; + new_node.parent_id = parent_id; + if let Some(new_name) = destination.new_name { + new_node.name = new_name; + } + + let mut batch = BatchBuilder::new(); + let etag = if from_account_id == to_account_id { + // Destination is in the same account: just update the parent id + batch.log_vanished_item(VanishedCollection::FileNode, from_resource_path); + new_node + .update( + access_token.account_tenant_ids(), + node, + from_account_id, + from_document_id, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag() + } else { + // Destination is in a different account: insert a new node, then delete the old one + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + let etag = new_node + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + true, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + DestroyArchive(node) + .delete( + access_token.account_tenant_ids(), + from_account_id, + from_document_id, + &mut batch, + from_resource_path, + ) + .caused_by(trc::location!())?; + etag + }; + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) +} + +// Copies an item under an existing container +async fn copy_item( + server: &Server, + access_token: &AccessToken, + from_resource: UriResource, + destination: Destination, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let to_account_id = destination.account_id; + let from_document_id = from_resource.resource.document_id; + let parent_id = destination.document_id.map(|id| id + 1).unwrap_or(0); + + let mut node = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))? + .deserialize::() + .caused_by(trc::location!())?; + node.parent_id = parent_id; + if let Some(new_name) = destination.new_name { + node.name = new_name; + } + let mut batch = BatchBuilder::new(); + let to_document_id = server + .store() + .assign_document_ids(to_account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + let etag = node + .insert( + access_token.account_tenant_ids(), + to_account_id, + to_document_id, + true, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) +} + +// Renames an item +async fn rename_item( + server: &Server, + access_token: &AccessToken, + from_resource: UriResource, + from_resource_path: String, + destination: Destination, +) -> crate::Result { + let from_account_id = from_resource.account_id; + let from_document_id = from_resource.resource.document_id; + + let node_ = server + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_node = node.deserialize::().caused_by(trc::location!())?; + if let Some(new_name) = destination.new_name { + new_node.name = new_name; + } + let mut batch = BatchBuilder::new(); + let etag = new_node + .update( + access_token.account_tenant_ids(), + node, + from_account_id, + from_document_id, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + batch.log_vanished_item(VanishedCollection::FileNode, from_resource_path); + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) +} + +impl FromDavResource for Destination { + fn from_dav_resource(item: DavResourcePath<'_>) -> Self { + Destination { + account_id: u32::MAX, + document_id: Some(item.document_id()), + is_container: item.is_container(), + new_name: None, + } + } +} diff --git a/crates/dav/src/file/delete.rs b/crates/dav/src/file/delete.rs new file mode 100644 index 0000000..942a8fe --- /dev/null +++ b/crates/dav/src/file/delete.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::RequestHeaders; +use groupware::{DestroyArchive, cache::GroupwareCache}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use trc::AddContext; +use types::{acl::Acl, collection::SyncCollection}; + +pub(crate) trait FileDeleteRequestHandler: Sync + Send { + fn handle_file_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> impl Future> + Send; +} + +impl FileDeleteRequestHandler for Server { + async fn handle_file_delete_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let delete_path = resource + .resource + .filter(|r| !r.is_empty()) + .ok_or(DavError::Code(StatusCode::FORBIDDEN))?; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + + // Find ids to delete + let mut ids = resources.subtree(delete_path).collect::>(); + if ids.is_empty() { + return Err(DavError::Code(StatusCode::NOT_FOUND)); + } + + // Sort ids descending from the deepest to the root + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let (document_id, full_delete_path) = ids + .last() + .map(|a| (a.document_id(), resources.format_resource(*a))) + .unwrap(); + let mut sorted_ids = Vec::with_capacity(ids.len()); + sorted_ids.extend(ids.into_iter().map(|a| a.document_id())); + + // Validate ACLs + if !access_token.is_member(account_id) { + let permissions = resources.shared_containers(access_token, [Acl::Delete], false); + if permissions.len() < sorted_ids.len() as u64 + || !sorted_ids.iter().all(|id| permissions.contains(*id)) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: document_id.into(), + path: delete_path, + ..Default::default() + }], + Default::default(), + DavMethod::DELETE, + ) + .await?; + + DestroyArchive(sorted_ids) + .delete( + self, + access_token.account_tenant_ids(), + account_id, + full_delete_path.into(), + ) + .await?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT)) + } +} diff --git a/crates/dav/src/file/get.rs b/crates/dav/src/file/get.rs new file mode 100644 index 0000000..934aa49 --- /dev/null +++ b/crates/dav/src/file/get.rs @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use dav_proto::{RequestHeaders, schema::property::Rfc1123DateTime}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, now}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait FileGetRequestHandler: Sync + Send { + fn handle_file_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> impl Future> + Send; +} + +impl FileGetRequestHandler for Server { + async fn handle_file_get_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + is_head: bool, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let files = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let resource = files.map_resource(&resource_)?; + + // Fetch node + let node_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + resource.resource, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_.unarchive::().caused_by(trc::location!())?; + + // Validate ACL + if !access_token.is_member(account_id) + && !node.acls.effective_acl(access_token).contains(Acl::Read) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + let (hash, size, content_type) = if let Some(file) = node.file.as_ref() { + ( + file.blob_hash.0.as_ref(), + u32::from(file.size) as usize, + file.media_type.as_ref().map(|s| s.as_str()), + ) + } else { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + }; + + // Validate headers + let etag = node_.etag(); + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: resource.resource.into(), + etag: etag.clone().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::GET, + ) + .await?; + + let modified = i64::from(node.modified); + let last_modified = Rfc1123DateTime::new(modified).to_string(); + let byte_range = if !is_head && size > 0 { + headers + .range + .filter(|_| { + headers.eval_if_range( + &etag, + ((modified as u64) < now()).then_some(last_modified.as_str()), + ) + }) + .map(|range| range.resolve(size as u64)) + } else { + None + }; + let byte_range = match byte_range { + Some(Some(range)) => Some(range.start as usize..range.end as usize), + Some(None) => { + return Ok(HttpResponse::new(StatusCode::RANGE_NOT_SATISFIABLE) + .with_accept_ranges() + .with_etag(etag) + .with_content_range(format!("bytes */{size}"))); + } + None => None, + }; + + let response = HttpResponse::new(StatusCode::OK) + .with_content_type(content_type.unwrap_or("application/octet-stream")) + .with_etag(etag) + .with_last_modified(last_modified) + .with_accept_ranges(); + + if is_head { + return Ok(response.with_content_length(size)); + } + + let contents = self + .blob_store() + .get_blob(hash, byte_range.clone().unwrap_or(0..usize::MAX)) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + + Ok(match byte_range { + Some(byte_range) if !contents.is_empty() => response + .with_status_code(StatusCode::PARTIAL_CONTENT) + .with_content_range(format!( + "bytes {}-{}/{}", + byte_range.start, + byte_range.start + contents.len() - 1, + size + )), + Some(_) => return Err(DavError::Code(StatusCode::NOT_FOUND)), + None => response, + } + .with_binary_body(contents)) + } +} diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs new file mode 100644 index 0000000..e9d9249 --- /dev/null +++ b/crates/dav/src/file/mkcol.rs @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::proppatch::FilePropPatchRequestHandler; +use crate::{ + DavMethod, PropStatBuilder, + common::{ + ExtractETag, + acl::ResourceAcl, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; +use dav_proto::{ + RequestHeaders, Return, + schema::{Namespace, request::MkCol, response::MkColResponse}, +}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::{BatchBuilder, now}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait FileMkColRequestHandler: Sync + Send { + fn handle_file_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> impl Future> + Send; +} + +impl FileMkColRequestHandler for Server { + async fn handle_file_mkcol_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: Option, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource_.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let resource = resources.map_parent_resource(&resource_)?; + + // Validate and map parent ACL + let parent_id = resources.validate_and_map_parent_acl( + access_token, + access_token.is_member(account_id), + resource.resource.0, + Acl::AddItems, + )?; + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::MKCOL, + ) + .await?; + + // Build file container + let now = now(); + let mut node = FileNode { + parent_id, + name: resource.resource.1.to_string(), + display_name: None, + file: None, + created: now as i64, + modified: now as i64, + dead_properties: Default::default(), + acls: Default::default(), + }; + + // Apply MKCOL properties + let mut return_prop_stat = None; + if let Some(mkcol) = request { + let mut prop_stat = PropStatBuilder::default(); + if !self.apply_file_properties(&mut node, false, mkcol.props, &mut prop_stat) { + return Ok(HttpResponse::new(StatusCode::FORBIDDEN).with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::Dav) + .to_string(), + )); + } + if headers.ret != Return::Minimal { + return_prop_stat = Some(prop_stat); + } + } + + // Prepare write batch + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(node)) + .caused_by(trc::location!())?; + let etag = batch.etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + + if let Some(prop_stat) = return_prop_stat { + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::Dav) + .to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } + } +} diff --git a/crates/dav/src/file/mod.rs b/crates/dav/src/file/mod.rs new file mode 100644 index 0000000..a5c0a5a --- /dev/null +++ b/crates/dav/src/file/mod.rs @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, + common::uri::{OwnedUri, UriResource}, +}; +use common::{DavResourcePath, DavResources}; +use dav_proto::schema::property::{DavProperty, WebDavProperty}; +use hyper::StatusCode; + +pub mod copy_move; +pub mod delete; +pub mod get; +pub mod mkcol; +pub mod proppatch; +pub mod update; + +pub(crate) static FILE_CONTAINER_PROPS: [DavProperty; 19] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes), + DavProperty::WebDav(WebDavProperty::QuotaUsedBytes), +]; + +pub(crate) static FILE_ITEM_PROPS: [DavProperty; 19] = [ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), +]; + +pub(crate) trait FromDavResource { + fn from_dav_resource(item: DavResourcePath<'_>) -> Self; +} + +pub(crate) struct FileItemId { + pub document_id: u32, + pub parent_id: Option, + pub is_container: bool, +} + +pub(crate) trait DavFileResource { + fn map_resource( + &self, + resource: &OwnedUri<'_>, + ) -> crate::Result>; + + fn map_parent<'x>(&self, resource: &'x str) -> Option<(Option>, &'x str)>; + + #[allow(clippy::type_complexity)] + fn map_parent_resource<'x, T: FromDavResource>( + &self, + resource: &OwnedUri<'x>, + ) -> crate::Result, &'x str)>>; +} + +impl DavFileResource for DavResources { + fn map_resource( + &self, + resource: &OwnedUri<'_>, + ) -> crate::Result> { + resource + .resource + .and_then(|r| self.by_path(r)) + .map(|r| UriResource { + collection: resource.collection, + account_id: resource.account_id, + resource: T::from_dav_resource(r), + }) + .ok_or(DavError::Code(StatusCode::NOT_FOUND)) + } + + fn map_parent<'x>(&self, resource: &'x str) -> Option<(Option>, &'x str)> { + let (parent, child) = if let Some((parent, child)) = resource.rsplit_once('/') { + (Some(self.by_path(parent)?), child) + } else { + (None, resource) + }; + + Some((parent, child)) + } + + fn map_parent_resource<'x, T: FromDavResource>( + &self, + resource: &OwnedUri<'x>, + ) -> crate::Result, &'x str)>> { + if let Some(r) = resource.resource { + if self.by_path(r).is_none() { + self.map_parent(r) + .map(|(parent, child)| UriResource { + collection: resource.collection, + account_id: resource.account_id, + resource: (parent.map(T::from_dav_resource), child), + }) + .ok_or(DavError::Code(StatusCode::CONFLICT)) + } else { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } else { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } +} + +impl FromDavResource for u32 { + fn from_dav_resource(item: DavResourcePath) -> Self { + item.document_id() + } +} + +impl FromDavResource for FileItemId { + fn from_dav_resource(item: DavResourcePath) -> Self { + FileItemId { + document_id: item.document_id(), + parent_id: item.parent_id(), + is_container: item.is_container(), + } + } +} diff --git a/crates/dav/src/file/proppatch.rs b/crates/dav/src/file/proppatch.rs new file mode 100644 index 0000000..8f76b64 --- /dev/null +++ b/crates/dav/src/file/proppatch.rs @@ -0,0 +1,304 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, PropStatBuilder, + common::{ + ETag, ExtractETag, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use dav_proto::{ + RequestHeaders, Return, + schema::{ + property::{DavProperty, DavValue, ResourceType, WebDavProperty}, + request::{DavPropertyValue, PropertyUpdate}, + response::{BaseCondition, MultiStatus, Response}, + }, +}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait FilePropPatchRequestHandler: Sync + Send { + fn handle_file_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PropertyUpdate, + ) -> impl Future> + Send; + + fn apply_file_properties( + &self, + file: &mut FileNode, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool; +} + +impl FilePropPatchRequestHandler for Server { + async fn handle_file_proppatch_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut request: PropertyUpdate, + ) -> crate::Result { + // Validate URI + let resource_ = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let uri = headers.uri; + let account_id = resource_.account_id; + let files = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let resource = files.map_resource(&resource_)?; + + if !request.has_changes() { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + + // Fetch node + let node_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + resource.resource, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACL + if !access_token.is_member(account_id) + && !node + .inner + .acls + .effective_acl(access_token) + .contains(Acl::Modify) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: resource.resource.into(), + etag: node_.etag().into(), + path: resource_.resource.unwrap(), + ..Default::default() + }], + Default::default(), + DavMethod::PROPPATCH, + ) + .await?; + + // Deserialize + let mut new_node = node.deserialize::().caused_by(trc::location!())?; + + // Remove properties + let mut items = PropStatBuilder::default(); + if !request.set_first && !request.remove.is_empty() { + remove_file_properties( + &mut new_node, + std::mem::take(&mut request.remove), + &mut items, + ); + } + + // Set properties + let is_success = self.apply_file_properties(&mut new_node, true, request.set, &mut items); + + // Remove properties + if is_success && !request.remove.is_empty() { + remove_file_properties(&mut new_node, request.remove, &mut items); + } + + let etag = if is_success { + let mut batch = BatchBuilder::new(); + let etag = new_node + .update( + access_token.account_tenant_ids(), + node, + account_id, + resource.resource, + true, + &mut batch, + ) + .caused_by(trc::location!())? + .etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + etag + } else { + node_.etag().into() + }; + + if headers.ret != Return::Minimal || !is_success { + Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body( + MultiStatus::new(vec![Response::new_propstat(uri, items.build())]).to_string(), + ) + .with_etag_opt(etag)) + } else { + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) + } + } + + fn apply_file_properties( + &self, + file: &mut FileNode, + is_update: bool, + properties: Vec, + items: &mut PropStatBuilder, + ) -> bool { + let mut has_errors = false; + + for property in properties { + match (&property.property, property.value) { + (DavProperty::WebDav(WebDavProperty::DisplayName), DavValue::String(name)) => { + if name.len() <= self.core.groupware.live_property_size { + file.display_name = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + + has_errors = true; + } + } + (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { + file.created = dt; + items.insert_ok(property.property); + } + (DavProperty::WebDav(WebDavProperty::GetContentType), DavValue::String(name)) + if file.file.is_some() => + { + if name.len() <= self.core.groupware.live_property_size { + file.file.as_mut().unwrap().media_type = Some(name); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + ( + DavProperty::WebDav(WebDavProperty::ResourceType), + DavValue::ResourceTypes(types), + ) if file.file.is_none() => { + if types.0.len() != 1 || types.0.first() != Some(&ResourceType::Collection) { + items.insert_precondition_failed( + property.property, + StatusCode::FORBIDDEN, + BaseCondition::ValidResourceType, + ); + has_errors = true; + } else { + items.insert_ok(property.property); + } + } + (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) + if self.core.groupware.dead_property_size.is_some() => + { + if is_update { + file.dead_properties.remove_element(dead); + } + + if file.dead_properties.size() + values.size() + dead.size() + < self.core.groupware.dead_property_size.unwrap() + { + file.dead_properties.add_element(dead.clone(), values.0); + items.insert_ok(property.property); + } else { + items.insert_error_with_description( + property.property, + StatusCode::INSUFFICIENT_STORAGE, + "Property value is too long", + ); + has_errors = true; + } + } + (_, DavValue::Null) => { + items.insert_ok(property.property); + } + _ => { + items.insert_error_with_description( + property.property, + StatusCode::CONFLICT, + "Property cannot be modified", + ); + has_errors = true; + } + } + } + + !has_errors + } +} + +fn remove_file_properties( + node: &mut FileNode, + properties: Vec, + items: &mut PropStatBuilder, +) { + for property in properties { + match &property { + DavProperty::WebDav(WebDavProperty::DisplayName) => { + node.display_name = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::WebDav(WebDavProperty::GetContentType) if node.file.is_some() => { + node.file.as_mut().unwrap().media_type = None; + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + DavProperty::DeadProperty(dead) => { + node.dead_properties.remove_element(dead); + items.insert_with_status(property, StatusCode::NO_CONTENT); + } + _ => { + items.insert_error_with_description( + property, + StatusCode::CONFLICT, + "Property cannot be deleted", + ); + } + } + } +} diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs new file mode 100644 index 0000000..85fffe9 --- /dev/null +++ b/crates/dav/src/file/update.rs @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavMethod, + common::{ + ETag, ExtractETag, + acl::ResourceAcl, + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; +use common::{ + Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder, +}; +use dav_proto::{RequestHeaders, Return, schema::property::Rfc1123DateTime}; +use groupware::{ + cache::GroupwareCache, + file::{FileNode, FileProperties}, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::write::{BatchBuilder, now}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, +}; + +pub(crate) trait FileUpdateRequestHandler: Sync + Send { + fn handle_file_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + is_patch: bool, + ) -> impl Future> + Send; +} + +impl FileUpdateRequestHandler for Server { + async fn handle_file_update_request( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + bytes: Vec, + _is_patch: bool, + ) -> crate::Result { + // Validate URI + let resource = self + .validate_uri(access_token, headers.uri) + .await? + .into_owned_uri()?; + let account_id = resource.account_id; + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let resource_name = resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?; + + if bytes.len() > self.core.groupware.max_file_size { + return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); + } + + if let Some(document_id) = resources + .by_path(resource_name.as_ref()) + .map(|r| r.document_id()) + { + // Update + let node_ = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + let node = node_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACL + if !access_token.is_member(account_id) + && !node + .inner + .acls + .effective_acl(access_token) + .contains(Acl::Modify) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + + // Validate headers + match self + .validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(document_id), + etag: node.etag().into(), + path: resource_name, + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await + { + Ok(_) => {} + Err(DavError::Code(StatusCode::PRECONDITION_FAILED)) + if headers.ret == Return::Representation => + { + let file = node.inner.file.as_ref().unwrap(); + let contents = self + .blob_store() + .get_blob(file.blob_hash.0.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + .ok_or(DavError::Code(StatusCode::PRECONDITION_FAILED))?; + + return Ok(HttpResponse::new(StatusCode::PRECONDITION_FAILED) + .with_content_type( + file.media_type + .as_ref() + .map(|v| v.as_str()) + .unwrap_or("application/octet-stream"), + ) + .with_etag(node.etag()) + .with_last_modified( + Rfc1123DateTime::new(i64::from(node.inner.modified)).to_string(), + ) + .with_header("Preference-Applied", "return=representation") + .with_binary_body(contents)); + } + Err(e) => return Err(e), + } + + // Verify that the node is a file + if let Some(file) = node.inner.file.as_ref() { + if BlobHash::generate(&bytes).as_slice() == file.blob_hash.0.as_slice() { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + } else { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate quota + let extra_bytes = (bytes.len() as u64) + .saturating_sub(u32::from(node.inner.file.as_ref().unwrap().size) as u64); + if extra_bytes > 0 { + self.has_available_quota(self.account(account_id).await?.as_ref(), extra_bytes) + .await?; + } + + // Write blob + let (blob_hash, blob_hold) = self + .put_temporary_blob(account_id, &bytes, 60) + .await + .caused_by(trc::location!())?; + + // Build node + let mut new_node = node.deserialize::().caused_by(trc::location!())?; + let new_file = new_node.file.as_mut().unwrap(); + new_file.blob_hash = blob_hash; + new_file.media_type = headers + .content_type + .filter(|ct| !ct.is_empty() && *ct != "application/octet-stream") + .map(|v| v.to_string()); + new_file.size = bytes.len() as u32; + new_node.modified = now() as i64; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .clear(blob_hold) + .custom( + ObjectIndexBuilder::new() + .with_current(node) + .with_changes(new_node) + .with_changed_by(access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())?; + let etag = batch.etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) + } else { + // Insert + let orig_resource_name = resource_name; + let (parent, resource_name) = resources + .map_parent(orig_resource_name.as_ref()) + .ok_or(DavError::Code(StatusCode::CONFLICT))?; + + // Validate ACL + let parent_id = resources.validate_and_map_parent_acl( + access_token, + access_token.is_member(account_id), + parent.map(|r| r.document_id()), + Acl::AddItems, + )?; + + // Verify that parent is a collection + if parent.as_ref().is_some_and(|r| !r.is_container()) { + return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); + } + + // Validate headers + self.validate_headers( + access_token, + headers, + vec![ResourceState { + account_id, + collection: resource.collection, + document_id: Some(u32::MAX), + path: orig_resource_name, + ..Default::default() + }], + Default::default(), + DavMethod::PUT, + ) + .await?; + + // Validate quota + if !bytes.is_empty() { + self.has_available_quota( + self.account(account_id).await?.as_ref(), + bytes.len() as u64, + ) + .await?; + } + + // Write blob + let (blob_hash, blob_hold) = self + .put_temporary_blob(account_id, &bytes, 60) + .await + .caused_by(trc::location!())?; + + // Build node + let now = now(); + let node = FileNode { + parent_id, + name: resource_name.to_string(), + display_name: None, + file: Some(FileProperties { + blob_hash, + size: bytes.len() as u32, + media_type: headers.content_type.map(|v| v.to_string()), + executable: false, + }), + created: now as i64, + modified: now as i64, + dead_properties: Default::default(), + acls: parent + .as_ref() + .and_then(|p| p.resource.acls()) + .map(|acls| acls.to_vec()) + .unwrap_or_default(), + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .clear(blob_hold) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(node) + .with_changed_by(access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())?; + let etag = batch.etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) + } + } +} diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs new file mode 100644 index 0000000..dc4e6d3 --- /dev/null +++ b/crates/dav/src/lib.rs @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ +#![warn(clippy::large_futures)] + +pub mod calendar; +pub mod card; +pub mod common; +pub mod file; +pub mod principal; +pub mod request; + +use dav_proto::schema::{ + request::DavPropertyValue, + response::{Condition, List, Prop, PropStat, ResponseDescription, Status}, +}; +use groupware::{DavResourceName, RFC_3986, is_uri_segment}; +use hyper::{Method, StatusCode}; +use std::borrow::Cow; +use store::ahash::AHashMap; +pub(crate) type Result = std::result::Result; + +#[derive(Debug, Clone, Copy)] +pub enum DavMethod { + GET, + PUT, + POST, + DELETE, + HEAD, + PATCH, + PROPFIND, + PROPPATCH, + REPORT, + MKCOL, + MKCALENDAR, + COPY, + MOVE, + LOCK, + UNLOCK, + OPTIONS, + ACL, +} + +impl From for trc::WebDavEvent { + fn from(value: DavMethod) -> Self { + match value { + DavMethod::GET => trc::WebDavEvent::Get, + DavMethod::PUT => trc::WebDavEvent::Put, + DavMethod::POST => trc::WebDavEvent::Post, + DavMethod::DELETE => trc::WebDavEvent::Delete, + DavMethod::HEAD => trc::WebDavEvent::Head, + DavMethod::PATCH => trc::WebDavEvent::Patch, + DavMethod::PROPFIND => trc::WebDavEvent::Propfind, + DavMethod::PROPPATCH => trc::WebDavEvent::Proppatch, + DavMethod::REPORT => trc::WebDavEvent::Report, + DavMethod::MKCOL => trc::WebDavEvent::Mkcol, + DavMethod::MKCALENDAR => trc::WebDavEvent::Mkcalendar, + DavMethod::COPY => trc::WebDavEvent::Copy, + DavMethod::MOVE => trc::WebDavEvent::Move, + DavMethod::LOCK => trc::WebDavEvent::Lock, + DavMethod::UNLOCK => trc::WebDavEvent::Unlock, + DavMethod::OPTIONS => trc::WebDavEvent::Options, + DavMethod::ACL => trc::WebDavEvent::Acl, + } + } +} + +pub(crate) enum DavError { + Parse(dav_proto::parser::Error), + Internal(trc::Error), + Condition(DavErrorCondition), + Code(StatusCode), +} + +struct DavErrorCondition { + pub code: StatusCode, + pub condition: Condition, + pub details: Option, +} + +impl From for DavError { + fn from(value: DavErrorCondition) -> Self { + DavError::Condition(value) + } +} + +impl From for DavErrorCondition { + fn from(value: Condition) -> Self { + DavErrorCondition { + code: StatusCode::CONFLICT, + condition: value, + details: None, + } + } +} + +impl DavErrorCondition { + pub fn new(code: StatusCode, condition: impl Into) -> Self { + DavErrorCondition { + code, + condition: condition.into(), + details: None, + } + } + + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } +} + +impl DavMethod { + pub fn parse(method: &Method) -> Option { + match *method { + Method::GET => Some(DavMethod::GET), + Method::PUT => Some(DavMethod::PUT), + Method::DELETE => Some(DavMethod::DELETE), + Method::OPTIONS => Some(DavMethod::OPTIONS), + Method::POST => Some(DavMethod::POST), + Method::PATCH => Some(DavMethod::PATCH), + Method::HEAD => Some(DavMethod::HEAD), + _ => { + hashify::tiny_map!(method.as_str().as_bytes(), + "PROPFIND" => DavMethod::PROPFIND, + "PROPPATCH" => DavMethod::PROPPATCH, + "REPORT" => DavMethod::REPORT, + "MKCOL" => DavMethod::MKCOL, + "MKCALENDAR" => DavMethod::MKCALENDAR, + "COPY" => DavMethod::COPY, + "MOVE" => DavMethod::MOVE, + "LOCK" => DavMethod::LOCK, + "UNLOCK" => DavMethod::UNLOCK, + "ACL" => DavMethod::ACL + ) + } + } + } + + #[inline] + pub fn has_body(self) -> bool { + matches!( + self, + DavMethod::PUT + | DavMethod::POST + | DavMethod::PATCH + | DavMethod::PROPPATCH + | DavMethod::PROPFIND + | DavMethod::REPORT + | DavMethod::LOCK + | DavMethod::ACL + | DavMethod::MKCALENDAR + ) + } +} + +#[derive(Debug, Default)] +pub struct PropStatBuilder { + propstats: AHashMap<(StatusCode, Option, Option), Vec>, +} + +impl PropStatBuilder { + pub fn insert_ok(&mut self, prop: impl Into) -> &mut Self { + self.propstats + .entry((StatusCode::OK, None, None)) + .or_default() + .push(prop.into()); + self + } + + pub fn insert_with_status( + &mut self, + prop: impl Into, + status: StatusCode, + ) -> &mut Self { + self.propstats + .entry((status, None, None)) + .or_default() + .push(prop.into()); + self + } + + pub fn insert_error_with_description( + &mut self, + prop: impl Into, + status: StatusCode, + description: impl Into, + ) -> &mut Self { + self.propstats + .entry((status, None, Some(description.into()))) + .or_default() + .push(prop.into()); + self + } + + pub fn insert_precondition_failed( + &mut self, + prop: impl Into, + status: StatusCode, + condition: impl Into, + ) -> &mut Self { + self.propstats + .entry((status, Some(condition.into()), None)) + .or_default() + .push(prop.into()); + self + } + + pub fn insert_precondition_failed_with_description( + &mut self, + prop: impl Into, + status: StatusCode, + condition: impl Into, + description: impl Into, + ) -> &mut Self { + self.propstats + .entry((status, Some(condition.into()), Some(description.into()))) + .or_default() + .push(prop.into()); + self + } + + pub fn build(self) -> Vec { + self.propstats + .into_iter() + .map(|((status, condition, description), props)| PropStat { + prop: Prop(List(props)), + status: Status(status), + error: condition, + response_description: description.map(ResponseDescription), + }) + .collect() + } +} + +// Workaround for Apple bug with missing percent encoding in paths +pub(crate) fn fix_percent_encoding(path: &'_ str) -> Cow<'_, str> { + let (parent, name) = if let Some((parent, name)) = path.rsplit_once('/') { + (Some(parent), name) + } else { + (None, path) + }; + + if is_uri_segment(name) { + path.into() + } else { + let name = percent_encoding::utf8_percent_encode(name, RFC_3986); + + if let Some(parent) = parent { + Cow::Owned(format!("{parent}/{name}")) + } else { + Cow::Owned(name.to_string()) + } + } +} diff --git a/crates/dav/src/principal/matching.rs b/crates/dav/src/principal/matching.rs new file mode 100644 index 0000000..ce2e8a3 --- /dev/null +++ b/crates/dav/src/principal/matching.rs @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::propfind::PrincipalPropFind; +use crate::{ + DavError, + common::{ + DavQuery, DavQueryResource, + propfind::PropFindRequestHandler, + uri::{DavUriResource, UriResource}, + }, +}; +use common::{Server, auth::AccessToken}; +use dav_proto::{ + RequestHeaders, + schema::{ + property::{DavProperty, WebDavProperty}, + request::{PrincipalMatch, PropFind}, + response::MultiStatus, + }, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use store::roaring::RoaringBitmap; +use types::collection::Collection; + +pub(crate) trait PrincipalMatching: Sync + Send { + fn handle_principal_match( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + request: PrincipalMatch, + ) -> impl Future> + Send; +} + +impl PrincipalMatching for Server { + async fn handle_principal_match( + &self, + access_token: &AccessToken, + headers: &RequestHeaders<'_>, + mut request: PrincipalMatch, + ) -> crate::Result { + let resource = self.validate_uri(access_token, headers.uri).await?; + + match resource.collection { + Collection::AddressBook | Collection::Calendar | Collection::FileNode => { + if request.properties.is_empty() { + request + .properties + .push(DavProperty::WebDav(WebDavProperty::Owner)); + } + if let Some(account_id) = resource.account_id { + return self + .handle_dav_query( + access_token, + DavQuery { + resource: DavQueryResource::Uri(UriResource { + collection: resource.collection, + account_id, + resource: resource.resource, + }), + propfind: PropFind::Prop(request.properties), + depth: usize::MAX, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + uri: headers.uri, + sync_type: Default::default(), + limit: Default::default(), + vcard_version: Default::default(), + expand: Default::default(), + }, + ) + .await; + } + } + Collection::Principal => {} + _ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + } + + let mut response = MultiStatus::new(Vec::with_capacity(16)); + if request.properties.is_empty() { + request + .properties + .push(DavProperty::WebDav(WebDavProperty::DisplayName)); + } + let request = PropFind::Prop(request.properties); + self.prepare_principal_propfind_response( + access_token, + resource.collection, + RoaringBitmap::from_iter(access_token.all_ids()).into_iter(), + &request, + &mut response, + ) + .await?; + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) + } +} diff --git a/crates/dav/src/principal/mod.rs b/crates/dav/src/principal/mod.rs new file mode 100644 index 0000000..f6a0bb1 --- /dev/null +++ b/crates/dav/src/principal/mod.rs @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::auth::AccountCache; +use dav_proto::schema::response::Href; +use groupware::RFC_3986; + +use crate::DavResourceName; + +pub mod matching; +pub mod propfind; +pub mod propsearch; + +pub trait CurrentUserPrincipal { + fn current_user_principal(&self) -> Href; +} + +impl CurrentUserPrincipal for AccountCache { + fn current_user_principal(&self) -> Href { + Href(format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + percent_encoding::utf8_percent_encode(self.name(), RFC_3986) + )) + } +} diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs new file mode 100644 index 0000000..e21054d --- /dev/null +++ b/crates/dav/src/principal/propfind.rs @@ -0,0 +1,464 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::CurrentUserPrincipal; +use crate::{ + DavResourceName, + common::propfind::{PropFindRequestHandler, SyncTokenUrn}, +}; +use common::{ + Server, + auth::{AccessToken, AccountCache}, +}; +use dav_proto::schema::{ + Namespace, + property::{ + DavProperty, DavValue, PrincipalProperty, Privilege, ReportSet, ResourceType, + WebDavProperty, + }, + request::{DavPropertyValue, PropFind}, + response::{Href, MultiStatus, PropStat, Response}, +}; +use groupware::RFC_3986; +use groupware::cache::GroupwareCache; +use hyper::StatusCode; +use std::borrow::Cow; +use trc::AddContext; +use types::collection::Collection; + +pub(crate) trait PrincipalPropFind: Sync + Send { + fn prepare_principal_propfind_response( + &self, + access_token: &AccessToken, + collection: Collection, + documents: impl Iterator + Sync + Send, + request: &PropFind, + response: &mut MultiStatus, + ) -> impl Future> + Send; + + fn expand_principal( + &self, + access_token: &AccessToken, + account_id: u32, + propfind: &PropFind, + ) -> impl Future>> + Send; + + fn owner_href( + &self, + account_info: &AccountCache, + account_id: u32, + ) -> impl Future> + Send; +} + +impl PrincipalPropFind for Server { + async fn prepare_principal_propfind_response( + &self, + access_token: &AccessToken, + collection: Collection, + account_ids: impl Iterator + Sync + Send, + request: &PropFind, + response: &mut MultiStatus, + ) -> crate::Result<()> { + let access_account_info = self + .account(access_token.account_id()) + .await + .caused_by(trc::location!())?; + let properties = match request { + PropFind::PropName => { + let props = all_props(collection, None); + for property in &props { + response.set_namespace(property.namespace()); + } + for account_id in account_ids { + response.add_response(Response::new_propstat( + self.owner_href(&access_account_info, account_id) + .await + .caused_by(trc::location!())?, + vec![PropStat::new_list( + props.iter().cloned().map(DavPropertyValue::empty).collect(), + )], + )); + } + return Ok(()); + } + PropFind::AllProp(items) => Cow::Owned(all_props(collection, items.as_slice().into())), + PropFind::Prop(items) => Cow::Borrowed(items), + }; + for property in properties.as_slice() { + response.set_namespace(property.namespace()); + } + let is_principal = match collection { + Collection::AddressBook | Collection::ContactCard => { + response.set_namespace(Namespace::CardDav); + false + } + Collection::Calendar + | Collection::CalendarEvent + | Collection::CalendarEventNotification => { + response.set_namespace(Namespace::CalDav); + false + } + Collection::Principal => true, + _ => false, + }; + let base_path = DavResourceName::from(collection).base_path(); + let needs_quota = properties.iter().any(|property| { + matches!( + property, + DavProperty::WebDav( + WebDavProperty::QuotaAvailableBytes | WebDavProperty::QuotaUsedBytes + ) + ) + }); + + for account_id in account_ids { + let mut fields = Vec::with_capacity(properties.len()); + let mut fields_not_found = Vec::new(); + + let account = self.account(account_id).await.caused_by(trc::location!())?; + + // Fetch quota + let quota = if needs_quota { + self.dav_quota(account_id) + .await + .caused_by(trc::location!())? + } else { + Default::default() + }; + + for property in properties.as_slice() { + match property { + DavProperty::WebDav(dav_property) => match dav_property { + WebDavProperty::DisplayName => { + fields.push(DavPropertyValue::new( + property.clone(), + account.description().unwrap_or(account.name()).to_string(), + )); + } + WebDavProperty::ResourceType => { + let resource_type = if !is_principal { + vec![ResourceType::Collection] + } else { + vec![ResourceType::Principal, ResourceType::Collection] + }; + + fields.push(DavPropertyValue::new(property.clone(), resource_type)); + } + WebDavProperty::SupportedReportSet => { + let reports = match collection { + Collection::Principal => ReportSet::principal(), + Collection::Calendar | Collection::CalendarEvent => { + ReportSet::calendar() + } + Collection::AddressBook | Collection::ContactCard => { + ReportSet::addressbook() + } + _ => ReportSet::file(), + }; + + fields.push(DavPropertyValue::new(property.clone(), reports)); + } + WebDavProperty::CurrentUserPrincipal => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![access_account_info.current_user_principal()], + )); + } + WebDavProperty::QuotaAvailableBytes if !is_principal => { + if let Some(available) = quota.available { + fields.push(DavPropertyValue::new(property.clone(), available)); + } else { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + WebDavProperty::QuotaUsedBytes if !is_principal => { + fields.push(DavPropertyValue::new(property.clone(), quota.used)); + } + WebDavProperty::SyncToken if !is_principal => { + let sync_token = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + collection.into(), + ) + .await + .caused_by(trc::location!())? + .sync_token(); + + fields.push(DavPropertyValue::new(property.clone(), sync_token)); + } + WebDavProperty::GetCTag if !is_principal => { + let ctag = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + collection.into(), + ) + .await + .caused_by(trc::location!())? + .highest_change_id; + + fields.push(DavPropertyValue::new( + property.clone(), + DavValue::String(format!("\"{ctag}\"")), + )); + } + WebDavProperty::Owner => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + percent_encoding::utf8_percent_encode(account.name(), RFC_3986), + ))], + )); + } + WebDavProperty::Group if !is_principal => { + fields.push(DavPropertyValue::empty(property.clone())); + } + WebDavProperty::CurrentUserPrivilegeSet if !is_principal => { + fields.push(DavPropertyValue::new( + property.clone(), + if access_token.is_member(account_id) { + Privilege::all(matches!( + collection, + Collection::Calendar | Collection::CalendarEvent + )) + } else { + vec![Privilege::Read] + }, + )); + } + WebDavProperty::PrincipalCollectionSet => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href( + DavResourceName::Principal.collection_path().to_string(), + )], + )); + } + _ => { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + }, + DavProperty::Principal(principal_property) => match principal_property { + PrincipalProperty::AlternateURISet + | PrincipalProperty::GroupMemberSet + | PrincipalProperty::GroupMembership => { + fields.push(DavPropertyValue::empty(property.clone())); + } + PrincipalProperty::PrincipalURL => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + percent_encoding::utf8_percent_encode(account.name(), RFC_3986), + ))], + )); + } + PrincipalProperty::CalendarHomeSet => { + let hrefs = build_home_set( + self, + access_token, + account.name(), + account_id, + true, + ) + .await + .caused_by(trc::location!())?; + + fields.push(DavPropertyValue::new(property.clone(), hrefs)); + } + PrincipalProperty::AddressbookHomeSet => { + let hrefs = build_home_set( + self, + access_token, + account.name(), + account_id, + false, + ) + .await + .caused_by(trc::location!())?; + + fields.push(DavPropertyValue::new(property.clone(), hrefs)); + } + + PrincipalProperty::PrincipalAddress => { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + PrincipalProperty::CalendarUserAddressSet => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!("mailto:{}", account.name()))], + )); + } + PrincipalProperty::CalendarUserType => { + fields.push(DavPropertyValue::new( + property.clone(), + if account.is_user_account() { + DavValue::String("INDIVIDUAL".to_string()) + } else { + DavValue::String("GROUP".to_string()) + }, + )); + } + PrincipalProperty::ScheduleInboxURL => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/inbox/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(account.name(), RFC_3986), + ))], + )); + } + PrincipalProperty::ScheduleOutboxURL => { + fields.push(DavPropertyValue::new( + property.clone(), + vec![Href(format!( + "{}/{}/outbox/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(account.name(), RFC_3986), + ))], + )); + } + }, + _ => { + fields_not_found.push(DavPropertyValue::empty(property.clone())); + } + } + } + + let mut prop_stats = Vec::with_capacity(2); + + if !fields_not_found.is_empty() { + prop_stats + .push(PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND)); + } + + if !fields.is_empty() || prop_stats.is_empty() { + prop_stats.push(PropStat::new_list(fields)); + } + + response.add_response(Response::new_propstat( + Href(format!( + "{}/{}/", + base_path, + percent_encoding::utf8_percent_encode(account.name(), RFC_3986), + )), + prop_stats, + )); + } + + Ok(()) + } + + async fn expand_principal( + &self, + access_token: &AccessToken, + account_id: u32, + propfind: &PropFind, + ) -> crate::Result> { + let mut status = MultiStatus::new(vec![]); + self.prepare_principal_propfind_response( + access_token, + Collection::Principal, + [account_id].into_iter(), + propfind, + &mut status, + ) + .await?; + + Ok(status.response.0.into_iter().next()) + } + + async fn owner_href(&self, account_info: &AccountCache, account_id: u32) -> trc::Result { + if account_info.account_id() == account_id { + Ok(account_info.current_user_principal()) + } else { + let account_info = self.account(account_id).await.caused_by(trc::location!())?; + Ok(Href(format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + percent_encoding::utf8_percent_encode(account_info.name(), RFC_3986), + ))) + } + } +} + +pub(crate) async fn build_home_set( + server: &Server, + access_token: &AccessToken, + name: &str, + account_id: u32, + is_calendar: bool, +) -> trc::Result> { + let (collection, resource_name) = if is_calendar { + (Collection::Calendar, DavResourceName::Cal) + } else { + (Collection::AddressBook, DavResourceName::Card) + }; + + let mut hrefs = Vec::new(); + hrefs.push(Href(format!( + "{}/{}/", + resource_name.base_path(), + percent_encoding::utf8_percent_encode(name, RFC_3986), + ))); + + if !server.core.groupware.assisted_discovery && account_id == access_token.account_id() { + for account_id in access_token.all_ids_by_collection(collection) { + if account_id != access_token.account_id() { + let other = server + .account(account_id) + .await + .caused_by(trc::location!())?; + + hrefs.push(Href(format!( + "{}/{}/", + resource_name.base_path(), + percent_encoding::utf8_percent_encode(other.name(), RFC_3986), + ))); + } + } + } + + Ok(hrefs) +} + +fn all_props(collection: Collection, all_props: Option<&[DavProperty]>) -> Vec { + if collection == Collection::Principal { + vec![ + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::Principal(PrincipalProperty::AlternateURISet), + DavProperty::Principal(PrincipalProperty::PrincipalURL), + DavProperty::Principal(PrincipalProperty::GroupMemberSet), + DavProperty::Principal(PrincipalProperty::GroupMembership), + ] + } else { + let mut props = vec![ + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + ]; + + if let Some(all_props) = all_props { + props.extend(all_props.iter().filter(|p| !p.is_all_prop()).cloned()); + props + } else { + props + } + } +} diff --git a/crates/dav/src/principal/propsearch.rs b/crates/dav/src/principal/propsearch.rs new file mode 100644 index 0000000..def9d59 --- /dev/null +++ b/crates/dav/src/principal/propsearch.rs @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::propfind::PrincipalPropFind; +use common::{Server, auth::AccessToken}; +use dav_proto::schema::{ + property::{DavProperty, WebDavProperty}, + request::{PrincipalPropertySearch, PropFind}, + response::MultiStatus, +}; +use http_proto::HttpResponse; +use hyper::StatusCode; +use registry::schema::prelude::{ObjectType, Property}; +use store::{registry::RegistryQuery, roaring::RoaringBitmap}; +use trc::AddContext; +use types::collection::Collection; + +pub(crate) trait PrincipalPropSearch: Sync + Send { + fn handle_principal_property_search( + &self, + access_token: &AccessToken, + request: PrincipalPropertySearch, + ) -> impl Future> + Send; +} + +impl PrincipalPropSearch for Server { + async fn handle_principal_property_search( + &self, + access_token: &AccessToken, + mut request: PrincipalPropertySearch, + ) -> crate::Result { + let mut search_for = None; + + for prop_search in request.property_search { + if matches!( + prop_search.property, + DavProperty::WebDav(WebDavProperty::DisplayName) + ) && !prop_search.match_.is_empty() + { + search_for = Some(prop_search.match_); + } + } + + let mut response = MultiStatus::new(Vec::with_capacity(16)); + if let Some(search_for) = search_for { + let ids = self + .registry() + .query::( + RegistryQuery::new(ObjectType::Account) + .with_tenant(access_token.tenant_id()) + .text(Property::Text, search_for), + ) + .await + .caused_by(trc::location!())?; + + if !ids.is_empty() { + if request.properties.is_empty() { + request + .properties + .push(DavProperty::WebDav(WebDavProperty::DisplayName)); + } + let request = PropFind::Prop(request.properties); + self.prepare_principal_propfind_response( + access_token, + Collection::Principal, + ids.into_iter(), + &request, + &mut response, + ) + .await?; + } + } + + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) + } +} diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs new file mode 100644 index 0000000..2aaa3cb --- /dev/null +++ b/crates/dav/src/request.rs @@ -0,0 +1,772 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavError, DavErrorCondition, DavMethod, DavResourceName, + calendar::{ + copy_move::CalendarCopyMoveRequestHandler, delete::CalendarDeleteRequestHandler, + freebusy::CalendarFreebusyRequestHandler, get::CalendarGetRequestHandler, + mkcol::CalendarMkColRequestHandler, proppatch::CalendarPropPatchRequestHandler, + query::CalendarQueryRequestHandler, scheduling::CalendarEventNotificationHandler, + update::CalendarUpdateRequestHandler, + }, + card::{ + copy_move::CardCopyMoveRequestHandler, delete::CardDeleteRequestHandler, + get::CardGetRequestHandler, mkcol::CardMkColRequestHandler, + proppatch::CardPropPatchRequestHandler, query::CardQueryRequestHandler, + update::CardUpdateRequestHandler, + }, + common::{ + DavQuery, + acl::DavAclHandler, + lock::{LockRequest, LockRequestHandler}, + propfind::PropFindRequestHandler, + uri::DavUriResource, + }, + file::{ + copy_move::FileCopyMoveRequestHandler, delete::FileDeleteRequestHandler, + get::FileGetRequestHandler, mkcol::FileMkColRequestHandler, + proppatch::FilePropPatchRequestHandler, update::FileUpdateRequestHandler, + }, + principal::{matching::PrincipalMatching, propsearch::PrincipalPropSearch}, +}; +use common::{Server, auth::AccessToken}; +use compact_str::{CompactString, ToCompactString}; +use dav_proto::{ + RequestHeaders, + parser::{DavParser, tokenizer::Tokenizer}, + schema::{ + Namespace, + property::WebDavProperty, + request::{Acl, LockInfo, MkCol, PropFind, PropertyUpdate, Report}, + response::{ + BaseCondition, ErrorResponse, List, PrincipalSearchProperty, PrincipalSearchPropertySet, + }, + }, +}; +use http_proto::{HttpRequest, HttpResponse, HttpSessionData, request::fetch_body}; +use hyper::{StatusCode, header}; +use registry::schema::enums::Permission; +use std::time::Instant; +use trc::{EventType, LimitEvent, StoreEvent, WebDavEvent}; +use types::collection::Collection; + +pub trait DavRequestHandler: Sync + Send { + fn handle_dav_request( + &self, + request: HttpRequest, + access_token: AccessToken, + session: &HttpSessionData, + resource: DavResourceName, + method: DavMethod, + ) -> impl Future + Send; +} + +pub(crate) trait DavRequestDispatcher: Sync + Send { + fn dispatch_dav_request( + &self, + headers: &RequestHeaders<'_>, + access_token: AccessToken, + resource: DavResourceName, + method: DavMethod, + body: Vec, + ) -> impl Future> + Send; +} + +impl DavRequestDispatcher for Server { + async fn dispatch_dav_request( + &self, + headers: &RequestHeaders<'_>, + access_token: AccessToken, + resource: DavResourceName, + method: DavMethod, + body: Vec, + ) -> crate::Result { + // Dispatch + match method { + DavMethod::PROPFIND => { + let request = PropFind::parse(&mut Tokenizer::new(&body))?; + + self.handle_propfind_request(&access_token, headers, request) + .await + } + DavMethod::GET | DavMethod::HEAD => match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardGet)?; + + self.handle_card_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD), + ) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = access_token.assert_has_permission(Permission::DavCalGet)?; + + self.handle_calendar_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD), + ) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavFileGet)?; + + // Deal with Litmus bug + /*self.handle_file_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD) + && !request.headers().contains_key("x-litmus"), + ) + .await*/ + self.handle_file_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD), + ) + .await + } + DavResourceName::Scheduling => { + // Validate permissions + let access_token = access_token.assert_has_permission(Permission::DavCalGet)?; + + self.handle_scheduling_get_request( + &access_token, + headers, + matches!(method, DavMethod::HEAD), + ) + .await + } + DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + }, + DavMethod::REPORT => match Report::parse(&mut Tokenizer::new(&body))? { + Report::SyncCollection(sync_collection) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavSyncCollection)?; + + let uri = self + .validate_uri(&access_token, headers.uri) + .await + .and_then(|d| d.into_owned_uri())?; + match resource { + DavResourceName::Card + | DavResourceName::Cal + | DavResourceName::File + | DavResourceName::Scheduling => { + self.handle_dav_query( + &access_token, + DavQuery::changes(uri, sync_collection, headers), + ) + .await + } + DavResourceName::Principal => { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + } + Report::AclPrincipalPropSet(report) => { + // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::DavPrincipalAcl) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } + let access_token = + access_token.assert_has_permission(Permission::DavPrincipalAcl)?; + + self.handle_acl_prop_set(&access_token, headers, report) + .await + } + Report::PrincipalMatch(report) => { + // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::DavPrincipalMatch) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } + let access_token = + access_token.assert_has_permission(Permission::DavPrincipalMatch)?; + + self.handle_principal_match(&access_token, headers, report) + .await + } + Report::PrincipalPropertySearch(report) => { + if resource == DavResourceName::Principal { + // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::DavPrincipalSearch) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } + + let access_token = + access_token.assert_has_permission(Permission::DavPrincipalSearch)?; + + self.handle_principal_property_search(&access_token, report) + .await + } else { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + Report::PrincipalSearchPropertySet => { + if resource == DavResourceName::Principal { + // Validate permissions + access_token + .assert_has_permission(Permission::DavPrincipalSearchPropSet)?; + + Ok(HttpResponse::new(StatusCode::OK).with_xml_body( + PrincipalSearchPropertySet::new(vec![PrincipalSearchProperty::new( + WebDavProperty::DisplayName, + "Account or Group name", + )]) + .to_string(), + )) + } else { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + Report::AddressbookQuery(report) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardQuery)?; + + self.handle_card_query_request(&access_token, headers, report) + .await + } + Report::AddressbookMultiGet(report) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardMultiGet)?; + + self.handle_dav_query( + &access_token, + DavQuery::multiget(report, Collection::AddressBook, headers), + ) + .await + } + Report::CalendarQuery(report) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalQuery)?; + + self.handle_calendar_query_request(&access_token, headers, report) + .await + } + Report::CalendarMultiGet(report) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalMultiGet)?; + + self.handle_dav_query( + &access_token, + DavQuery::multiget(report, Collection::Calendar, headers), + ) + .await + } + Report::FreeBusyQuery(report) => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalFreeBusyQuery)?; + + self.handle_calendar_freebusy_request(&access_token, headers, report) + .await + } + Report::ExpandProperty(report) => { + let uri = self + .validate_uri(&access_token, headers.uri) + .await + .and_then(|d| d.into_owned_uri())?; + + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavExpandProperty)?; + + match resource { + DavResourceName::Card | DavResourceName::Cal | DavResourceName::File => { + self.handle_dav_query( + &access_token, + DavQuery::expand(uri, report, headers), + ) + .await + } + DavResourceName::Principal | DavResourceName::Scheduling => { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + } + }, + DavMethod::PROPPATCH => { + let request = PropertyUpdate::parse(&mut Tokenizer::new(&body))?; + match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardPropPatch)?; + + self.handle_card_proppatch_request(&access_token, headers, request) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalPropPatch)?; + + self.handle_calendar_proppatch_request(&access_token, headers, request) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavFilePropPatch)?; + + self.handle_file_proppatch_request(&access_token, headers, request) + .await + } + DavResourceName::Principal | DavResourceName::Scheduling => { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + } + DavMethod::MKCOL => { + let request = if !body.is_empty() { + Some(MkCol::parse(&mut Tokenizer::new(&body))?) + } else { + None + }; + + match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardMkCol)?; + + self.handle_card_mkcol_request(&access_token, headers, request) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalMkCol)?; + + self.handle_calendar_mkcol_request(&access_token, headers, request) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavFileMkCol)?; + + self.handle_file_mkcol_request(&access_token, headers, request) + .await + } + DavResourceName::Principal | DavResourceName::Scheduling => { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + } + DavMethod::DELETE => match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardDelete)?; + + self.handle_card_delete_request(&access_token, headers) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalDelete)?; + + self.handle_calendar_delete_request(&access_token, headers) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavFileDelete)?; + + self.handle_file_delete_request(&access_token, headers) + .await + } + DavResourceName::Scheduling => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalDelete)?; + + self.handle_scheduling_delete_request(&access_token, headers) + .await + } + DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + }, + DavMethod::PUT | DavMethod::POST | DavMethod::PATCH => match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCardPut)?; + + self.handle_card_update_request( + &access_token, + headers, + body, + matches!(method, DavMethod::PATCH), + ) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = access_token.assert_has_permission(Permission::DavCalPut)?; + + self.handle_calendar_update_request( + &access_token, + headers, + body, + matches!(method, DavMethod::PATCH), + ) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavFilePut)?; + + self.handle_file_update_request( + &access_token, + headers, + body, + matches!(method, DavMethod::PATCH), + ) + .await + } + DavResourceName::Scheduling => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalFreeBusyQuery)?; + + self.handle_scheduling_post_request(&access_token, headers, body) + .await + } + DavResourceName::Principal => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + }, + DavMethod::COPY | DavMethod::MOVE => { + let is_move = matches!(method, DavMethod::MOVE); + match resource { + DavResourceName::Card => { + // Validate permissions + let access_token = access_token.assert_has_permission(if is_move { + Permission::DavCardMove + } else { + Permission::DavCardCopy + })?; + + self.handle_card_copy_move_request(&access_token, headers, is_move) + .await + } + DavResourceName::Cal => { + // Validate permissions + let access_token = access_token.assert_has_permission(if is_move { + Permission::DavCalMove + } else { + Permission::DavCalCopy + })?; + self.handle_calendar_copy_move_request(&access_token, headers, is_move) + .await + } + DavResourceName::File => { + // Validate permissions + let access_token = access_token.assert_has_permission(if is_move { + Permission::DavFileMove + } else { + Permission::DavFileCopy + })?; + + self.handle_file_copy_move_request(&access_token, headers, is_move) + .await + } + DavResourceName::Principal | DavResourceName::Scheduling => { + Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)) + } + } + } + DavMethod::MKCALENDAR => match resource { + DavResourceName::Cal => { + // Validate permissions + let access_token = + access_token.assert_has_permission(Permission::DavCalMkCol)?; + + self.handle_calendar_mkcol_request( + &access_token, + headers, + Some(MkCol::parse(&mut Tokenizer::new(&body))?), + ) + .await + } + _ => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + }, + DavMethod::LOCK => { + // Validate permissions + let access_token = access_token.assert_has_permission(match resource { + DavResourceName::File => Permission::DavFileLock, + DavResourceName::Cal => Permission::DavCalLock, + DavResourceName::Card => Permission::DavCardLock, + _ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + })?; + + self.handle_lock_request( + &access_token, + headers, + if !body.is_empty() { + LockRequest::Lock(LockInfo::parse(&mut Tokenizer::new(&body))?) + } else { + LockRequest::Refresh + }, + ) + .await + } + DavMethod::UNLOCK => { + // Validate permissions + let access_token = access_token.assert_has_permission(match resource { + DavResourceName::File => Permission::DavFileLock, + DavResourceName::Cal => Permission::DavCalLock, + DavResourceName::Card => Permission::DavCardLock, + _ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + })?; + + self.handle_lock_request(&access_token, headers, LockRequest::Unlock) + .await + } + DavMethod::ACL => { + // Validate permissions + let access_token = access_token.assert_has_permission(match resource { + DavResourceName::File => Permission::DavFileAcl, + DavResourceName::Cal => Permission::DavCalAcl, + DavResourceName::Card => Permission::DavCardAcl, + _ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + })?; + + self.handle_acl_request( + &access_token, + headers, + Acl::parse(&mut Tokenizer::new(&body))?, + ) + .await + } + DavMethod::OPTIONS => unreachable!(), + } + } +} + +impl DavRequestHandler for Server { + async fn handle_dav_request( + &self, + mut request: HttpRequest, + access_token: AccessToken, + session: &HttpSessionData, + resource: DavResourceName, + method: DavMethod, + ) -> HttpResponse { + let body = if method.has_body() + || request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|len| len > 0) + { + if let Some(body) = fetch_body( + &mut request, + if !access_token.has_permission(Permission::UnlimitedUploads) { + self.core.groupware.max_request_size + } else { + 0 + }, + session.session_id, + ) + .await + { + body + } else { + trc::event!( + Limit(trc::LimitEvent::SizeRequest), + SpanId = session.session_id, + Contents = "Request body too large", + ); + + return HttpResponse::new(StatusCode::PAYLOAD_TOO_LARGE); + } + } else { + Vec::new() + }; + + // Parse headers + let mut headers = RequestHeaders::new(request.uri().path()); + for (key, value) in request.headers() { + headers.parse(key.as_str(), value.to_str().unwrap_or_default()); + } + + let start_time = Instant::now(); + match self + .dispatch_dav_request(&headers, access_token, resource, method, body) + .await + { + Ok(response) => { + let event = WebDavEvent::from(method); + + trc::event!( + WebDav(event), + SpanId = session.session_id, + Url = headers.uri.to_compact_string(), + Type = resource.name(), + Details = &headers, + Result = response.status().as_u16(), + Elapsed = start_time.elapsed(), + ); + + response + } + Err(DavError::Internal(err)) => { + let err_type = err.event_type(); + + trc::error!( + err.span_id(session.session_id) + .ctx(trc::Key::Url, headers.uri.to_compact_string()) + .ctx(trc::Key::Type, resource.name()) + .ctx(trc::Key::Elapsed, start_time.elapsed()) + ); + + match err_type { + EventType::Limit(LimitEvent::Quota | LimitEvent::TenantQuota) => { + HttpResponse::new(StatusCode::PRECONDITION_FAILED) + .with_xml_body( + ErrorResponse::new(BaseCondition::QuotaNotExceeded) + .with_namespace(match resource { + DavResourceName::Card => Namespace::CardDav, + DavResourceName::Cal | DavResourceName::Scheduling => { + Namespace::CalDav + } + DavResourceName::File | DavResourceName::Principal => { + Namespace::Dav + } + }) + .to_string(), + ) + .with_no_cache() + } + EventType::Store(StoreEvent::AssertValueFailed) => { + HttpResponse::new(StatusCode::CONFLICT) + } + EventType::Security(_) => HttpResponse::new(StatusCode::FORBIDDEN), + _ => HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR), + } + } + Err(DavError::Parse(err)) => { + let result = if headers.content_type.is_some_and(|h| h.contains("/xml")) { + StatusCode::BAD_REQUEST + } else { + StatusCode::UNSUPPORTED_MEDIA_TYPE + }; + + trc::event!( + WebDav(WebDavEvent::Error), + SpanId = session.session_id, + Url = headers.uri.to_compact_string(), + Type = resource.name(), + Details = &headers, + Result = result.as_u16(), + Reason = err.to_compact_string(), + Elapsed = start_time.elapsed(), + ); + + HttpResponse::new(result) + } + Err(DavError::Condition(condition)) => { + let event = WebDavEvent::from(method); + + trc::event!( + WebDav(event), + SpanId = session.session_id, + Url = headers.uri.to_compact_string(), + Type = resource.name(), + Details = &headers, + Code = condition.code.as_u16(), + Result = CompactString::const_new(condition.condition.display_name()), + Reason = condition.details, + Elapsed = start_time.elapsed(), + ); + + HttpResponse::new(condition.code) + .with_xml_body( + ErrorResponse::new(condition.condition) + .with_namespace(match resource { + DavResourceName::Card => Namespace::CardDav, + DavResourceName::Cal | DavResourceName::Scheduling => { + Namespace::CalDav + } + DavResourceName::File | DavResourceName::Principal => { + Namespace::Dav + } + }) + .to_string(), + ) + .with_no_cache() + } + Err(DavError::Code(code)) => { + let event = WebDavEvent::from(method); + + trc::event!( + WebDav(event), + SpanId = session.session_id, + Url = headers.uri.to_compact_string(), + Type = resource.name(), + Details = &headers, + Result = code.as_u16(), + Elapsed = start_time.elapsed(), + ); + + HttpResponse::new(code) + } + } + } +} + +impl From for DavError { + fn from(err: dav_proto::parser::Error) -> Self { + DavError::Parse(err) + } +} + +impl From for DavError { + fn from(err: trc::Error) -> Self { + DavError::Internal(err) + } +} diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml new file mode 100644 index 0000000..3a019b3 --- /dev/null +++ b/crates/directory/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "directory" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +store = { path = "../store" } +trc = { path = "../trc" } +registry = { path = "../registry" } +mail-parser = { version = "0.11" } +mail-builder = { version = "1.0" } +tokio = { version = "1.53", features = ["net"] } +ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-aws-lc-rs"] } +deadpool = { version = "0.13", features = ["managed", "rt_tokio_1"] } +ahash = { version = "0.8" } +pwhash = "1" +argon2 = "0.6.0" +pbkdf2 = { version = "0.13.0", features = ["phc"] } +scrypt = { version = "0.12.0", features = ["phc"] } +sha1 = "0.11" +sha2 = "0.11" +md5 = "0.8.1" +serde = { version = "1.0", features = ["derive"]} +totp-rs = { version = "6.0.0", features = ["otpauth"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] } +serde_json = "1.0" +base64 = "0.23" +nohash-hasher = "0.2.0" +jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[features] +test_mode = [] +enterprise = [] +mysql = [] +postgres = [] +sqlite = [] + +[lints] +workspace = true diff --git a/crates/directory/src/backend/ldap/config.rs b/crates/directory/src/backend/ldap/config.rs new file mode 100644 index 0000000..e26c1a6 --- /dev/null +++ b/crates/directory/src/backend/ldap/config.rs @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings}; +use crate::Directory; +use deadpool::{Runtime, managed::Pool}; +use ldap3::LdapConnSettings; +use registry::schema::structs; + +impl LdapDirectory { + pub async fn open(config: structs::LdapDirectory) -> Result { + let bind_dn = if let Some(dn) = config.bind_dn { + Bind::new( + dn, + config + .bind_secret + .secret() + .await? + .map(|v| v.into_owned()) + .ok_or_else(|| { + "LDAP bind password is required when bind DN is set".to_string() + })?, + ) + .into() + } else { + None + }; + + let manager = LdapConnectionManager::new( + config.url, + LdapConnSettings::new() + .set_conn_timeout(config.timeout.into_inner()) + .set_starttls(config.use_tls) + .set_no_tls_verify(config.allow_invalid_certs), + bind_dn, + ); + + let mut mappings = LdapMappings { + base_dn: config.base_dn, + filter_login: LdapFilter::new(&config.filter_login)?, + filter_mailbox: LdapFilter::new(&config.filter_mailbox)?, + filter_member_of: if let Some(filter) = config.filter_member_of { + Some(LdapFilter::new(&filter)?) + } else { + None + }, + attr_class: config + .attr_class + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_groups: config + .attr_member_of + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_description: config + .attr_description + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_secret: config + .attr_secret + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_secret_changed: config + .attr_secret_changed + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_email: config + .attr_email + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_email_alias: config + .attr_email_alias + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + group_class: config.group_class, + attrs_principal: vec![], + }; + + let mut attrs_principal: Vec = Vec::new(); + for attrs in [ + &mappings.attr_description, + &mappings.attr_secret, + &mappings.attr_secret_changed, + &mappings.attr_groups, + &mappings.attr_email_alias, + &mappings.attr_email, + &mappings.attr_class, + ] { + for attr in attrs.iter().filter(|a| !a.is_empty()) { + if !attrs_principal.contains(attr) { + attrs_principal.push(attr.clone()); + } + } + } + mappings.attrs_principal = attrs_principal; + + let pool = Pool::builder(manager) + .runtime(Runtime::Tokio1) + .max_size(config.pool_max_connections as usize) + .create_timeout(config.pool_timeout_create.into_inner().into()) + .wait_timeout(config.pool_timeout_wait.into_inner().into()) + .recycle_timeout(config.pool_timeout_recycle.into_inner().into()) + .build() + .map_err(|err| format!("Failed to build LDAP pool: {err}"))?; + + Ok(Directory::Ldap(LdapDirectory { + mappings, + pool, + auth_bind: config.bind_authentication, + })) + } +} + +impl LdapFilter { + pub(super) fn new(value: &str) -> Result { + let mut filter = Vec::new(); + let mut token = String::new(); + let mut value = value.chars(); + + while let Some(ch) = value.next() { + match ch { + '?' => { + // For backwards compatibility, we treat '?' as a placeholder for the full value. + if !token.is_empty() { + filter.push(LdapFilterItem::Static(token)); + token = String::new(); + } + filter.push(LdapFilterItem::Full); + } + '{' => { + if !token.is_empty() { + filter.push(LdapFilterItem::Static(token)); + token = String::new(); + } + for ch in value.by_ref() { + if ch == '}' { + break; + } else { + token.push(ch); + } + } + match token.as_str() { + "user" | "username" | "email" => filter.push(LdapFilterItem::Full), + "local" => filter.push(LdapFilterItem::LocalPart), + "domain" => filter.push(LdapFilterItem::DomainPart), + _ => { + return Err(format!("Unknown LDAP filter placeholder: {}", token)); + } + } + token.clear(); + } + _ => token.push(ch), + } + } + + if !token.is_empty() { + filter.push(LdapFilterItem::Static(token)); + } + + if filter.len() >= 2 { + Ok(LdapFilter { filter }) + } else { + Err(format!( + "Missing parameter placeholders in value {:?}", + value + )) + } + } +} diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs new file mode 100644 index 0000000..1ce1e70 --- /dev/null +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{LdapDirectory, LdapMappings}; +use crate::{Account, Credentials, Group, IntoError, Recipient, core::secret::verify_secret_hash}; +use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry}; +use store::xxhash_rust; +use utils::sanitize_email; + +impl LdapDirectory { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { + let (username, secret) = match credentials { + Credentials::Basic { + username, secret, .. + } => (username, secret), + Credentials::Bearer { token, .. } => (token, token), + }; + if secret.is_empty() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Empty secret rejected")); + } + let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; + + let mut result = if self.auth_bind { + let filter = self.mappings.filter_login.build(username); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + // Perform bind auth using the found dn + let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings( + self.pool.manager().settings.clone(), + &self.pool.manager().address, + ) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))?; + + ldap3::drive!(auth_bind_conn); + + if ldap + .simple_bind(&result.dn, secret) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .success() + .is_ok() + { + if result.account.email.is_empty() { + result.account.email = + sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); + } + result + } else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Secret rejected during auth bind using lookup filter") + .details(vec![result.dn, filter])); + } + } else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Auth bind lookup filter yielded no results") + .details(vec![filter])); + } + } else { + let filter = self.mappings.filter_login.build(username); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + if let Some(account_secret) = &result.account.secret { + if !verify_secret_hash(account_secret, secret.as_bytes()).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials") + .details(vec![filter])); + } + } else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account does not have a secret") + .details(vec![filter])); + } + if result.account.email.is_empty() { + result.account.email = + sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); + } + result + } else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Authentication filter yielded no results") + .details(vec![filter])); + } + }; + + self.add_group_membership(&mut conn, &mut result).await?; + + Ok(result.account) + } + + pub async fn recipient(&self, address: &str) -> trc::Result { + let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; + let filter = self.mappings.filter_mailbox.build(address); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + if !result.is_group { + self.add_group_membership(&mut conn, &mut result).await?; + Ok(Recipient::Account(result.account)) + } else { + Ok(Recipient::Group(Group { + email: result.account.email, + email_aliases: result.account.email_aliases, + description: result.account.description, + })) + } + } else { + trc::event!( + Store(trc::StoreEvent::LdapWarning), + Reason = "Mailbox filter yielded no results", + Details = filter + ); + Ok(Recipient::Invalid) + } + } + + async fn add_group_membership( + &self, + conn: &mut Ldap, + result: &mut LdapResult, + ) -> trc::Result<()> { + if let Some(group_dns) = result.account.groups.take() { + let mut groups = Vec::new(); + for name in group_dns.into_iter().filter(|name| name.contains('=')) { + let (rs, _res) = conn + .search( + &name, + Scope::Base, + "objectClass=*", + &self.mappings.attr_email, + ) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .success() + .map_err(|err| err.into_error().caused_by(trc::location!()))?; + for entry in rs { + 'outer: for (attr, value) in SearchEntry::construct(entry).attrs { + if self.mappings.attr_email.contains(&attr.to_lowercase()) + && let Some(email) = + value.first().map(|s| s.as_str()).and_then(sanitize_email) + { + groups.push(email); + break 'outer; + } + } + } + } + result.account.groups = if groups.is_empty() { + None + } else { + Some(groups) + }; + } else if let Some(filter) = &self.mappings.filter_member_of { + let filter = filter.build(&result.dn); + let rs = conn + .search( + &self.mappings.base_dn, + Scope::Subtree, + &filter, + &self.mappings.attr_email, + ) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .success() + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .0; + let had_entries = !rs.is_empty(); + let mut groups = Vec::new(); + for entry in rs { + for (attr, value) in SearchEntry::construct(entry).attrs { + if self.mappings.attr_email.contains(&attr.to_lowercase()) { + groups.extend(value.into_iter().filter_map(|v| { + sanitize_email(&v).or_else(|| { + trc::event!( + Store(trc::StoreEvent::LdapWarning), + Reason = "Group entry missing valid email attribute", + Details = v + ); + None + }) + })); + } + } + } + result.account.groups = if had_entries && groups.is_empty() { + None + } else { + Some(groups) + }; + } else { + result.account.groups = (!self.mappings.attr_groups.is_empty()).then(Vec::new); + } + + Ok(()) + } +} + +impl LdapDirectory { + async fn find_object(&self, conn: &mut Ldap, filter: &str) -> trc::Result> { + conn.search( + &self.mappings.base_dn, + Scope::Subtree, + filter, + &self.mappings.attrs_principal, + ) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .success() + .map(|(rs, _)| { + trc::event!( + Store(trc::StoreEvent::LdapQuery), + Details = filter.to_string(), + Result = rs.first().map(result_to_trace).unwrap_or_default() + ); + + rs.into_iter() + .next() + .map(|entry| self.mappings.map_entry(SearchEntry::construct(entry))) + }) + .map_err(|err| err.into_error().caused_by(trc::location!())) + } +} + +struct LdapResult { + dn: String, + account: Account, + is_group: bool, +} + +impl LdapMappings { + fn map_entry(&self, entry: SearchEntry) -> LdapResult { + let mut account = Account::default(); + let mut is_group = false; + + for (attr, value) in entry.attrs { + let attr = attr.to_lowercase(); + let is_email = self.attr_email.contains(&attr); + let is_email_alias = self.attr_email_alias.contains(&attr); + if is_email || is_email_alias { + let mut values = value.into_iter().filter_map(|v| sanitize_email(&v)); + if is_email + && account.email.is_empty() + && let Some(email) = values.next() + { + account.email = email; + } + if is_email_alias { + account.email_aliases.extend(values); + } + } else if self.attr_secret.contains(&attr) { + account.secret = value.into_iter().find(|secret| !secret.is_empty()); + } else if self.attr_secret_changed.contains(&attr) { + // Create a disabled AppPassword, used to indicate that the password has been changed + // but cannot be used for authentication. + if account.secret.is_none() { + account.secret = value.into_iter().find(|item| !item.is_empty()).map(|item| { + format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes())) + }); + } + } else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) { + if (account.description.is_none() || idx == 0) + && let Some(desc) = value.into_iter().find(|desc| !desc.is_empty()) + { + account.description = Some(desc); + } + } else if self.attr_groups.contains(&attr) { + account.groups.get_or_insert_default().extend(value); + } else if self.attr_class.contains(&attr) { + for value in value { + is_group |= value.eq_ignore_ascii_case(&self.group_class); + } + } + } + + if !account.email_aliases.is_empty() { + let mut aliases = Vec::with_capacity(account.email_aliases.len()); + for alias in std::mem::take(&mut account.email_aliases) { + if alias != account.email && !aliases.contains(&alias) { + aliases.push(alias); + } + } + account.email_aliases = aliases; + } + + LdapResult { + dn: entry.dn, + account, + is_group, + } + } +} + +fn result_to_trace(rs: &ResultEntry) -> trc::Value { + let se = SearchEntry::construct(rs.clone()); + se.attrs + .into_iter() + .map(|(k, v)| trc::Value::Array(vec![trc::Value::from(k), trc::Value::from(v.join(", "))])) + .chain([trc::Value::from(se.dn)]) + .collect::>() + .into() +} diff --git a/crates/directory/src/backend/ldap/mod.rs b/crates/directory/src/backend/ldap/mod.rs new file mode 100644 index 0000000..26ed54e --- /dev/null +++ b/crates/directory/src/backend/ldap/mod.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use deadpool::managed::Pool; +use ldap3::{LdapConnSettings, ldap_escape}; + +pub mod config; +pub mod lookup; +pub mod pool; + +pub struct LdapDirectory { + pool: Pool, + mappings: LdapMappings, + auth_bind: bool, +} + +#[derive(Debug, Default)] +pub struct LdapMappings { + base_dn: String, + filter_login: LdapFilter, + filter_mailbox: LdapFilter, + filter_member_of: Option, + attr_class: Vec, + attr_groups: Vec, + attr_description: Vec, + attr_secret: Vec, + attr_secret_changed: Vec, + attr_email: Vec, + attr_email_alias: Vec, + attrs_principal: Vec, + group_class: String, +} + +#[derive(Debug, Default)] +pub(crate) struct LdapFilter { + filter: Vec, +} + +#[derive(Debug)] +enum LdapFilterItem { + Static(String), + Full, + LocalPart, + DomainPart, +} + +impl LdapFilter { + pub fn build(&self, value: &str) -> String { + let mut result = String::with_capacity(value.len() + 16); + + for item in &self.filter { + match item { + LdapFilterItem::Static(s) => result.push_str(s), + LdapFilterItem::Full => result.push_str(ldap_escape(value).as_ref()), + LdapFilterItem::LocalPart => { + result.push_str( + ldap_escape( + value + .rsplit_once('@') + .map(|(local, _)| local) + .unwrap_or(value), + ) + .as_ref(), + ); + } + LdapFilterItem::DomainPart => { + if let Some((_, domain)) = value.rsplit_once('@') { + result.push_str(ldap_escape(domain).as_ref()); + } + } + } + } + + result + } +} + +pub(crate) struct LdapConnectionManager { + address: String, + settings: LdapConnSettings, + bind_dn: Option, +} + +pub(crate) struct Bind { + dn: String, + password: String, +} + +impl LdapConnectionManager { + pub fn new(address: String, settings: LdapConnSettings, bind_dn: Option) -> Self { + Self { + address, + settings, + bind_dn, + } + } +} + +impl Bind { + pub fn new(dn: String, password: String) -> Self { + Self { dn, password } + } +} + +#[cfg(test)] +mod tests { + use super::LdapFilter; + + #[test] + fn filter_placeholders_are_escaped() { + let filter = LdapFilter::new("(&(uid={local})(dc={domain})(mail=?))").unwrap(); + + assert_eq!( + filter.build("*)(uid=*@ex)(dc=*"), + "(&(uid=\\2a\\29\\28uid=\\2a)(dc=ex\\29\\28dc=\\2a)(mail=\\2a\\29\\28uid=\\2a@ex\\29\\28dc=\\2a))" + ); + assert_eq!( + filter.build("john@example.com"), + "(&(uid=john)(dc=example.com)(mail=john@example.com))" + ); + } +} diff --git a/crates/directory/src/backend/ldap/pool.rs b/crates/directory/src/backend/ldap/pool.rs new file mode 100644 index 0000000..a8913b7 --- /dev/null +++ b/crates/directory/src/backend/ldap/pool.rs @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::LdapConnectionManager; +use deadpool::managed; +use ldap3::{Ldap, LdapConnAsync, LdapError, exop::WhoAmI}; + +impl managed::Manager for LdapConnectionManager { + type Type = Ldap; + type Error = LdapError; + + async fn create(&self) -> Result { + let (conn, mut ldap) = + LdapConnAsync::with_settings(self.settings.clone(), &self.address).await?; + + ldap3::drive!(conn); + + if let Some(bind) = &self.bind_dn { + ldap.simple_bind(&bind.dn, &bind.password) + .await? + .success()?; + } + + Ok(ldap) + } + + async fn recycle( + &self, + conn: &mut Ldap, + _: &managed::Metrics, + ) -> managed::RecycleResult { + conn.extended(WhoAmI) + .await + .map(|_| ()) + .map_err(managed::RecycleError::Backend) + } +} diff --git a/crates/directory/src/backend/mod.rs b/crates/directory/src/backend/mod.rs new file mode 100644 index 0000000..050b4c4 --- /dev/null +++ b/crates/directory/src/backend/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod ldap; +pub mod oidc; +pub mod sql; diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs new file mode 100644 index 0000000..5fdadb1 --- /dev/null +++ b/crates/directory/src/backend/oidc/config.rs @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Directory; +use crate::backend::oidc::lookup::fetch_jwks_keys; +use crate::backend::oidc::{ + CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory, +}; +use ahash::AHashMap; +use registry::schema::structs; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use trc::AuthEvent; +use utils::Client; + +const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30); +const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3); + +impl OpenIdDirectory { + pub async fn open(config: structs::OidcDirectory) -> Result { + Self::new(OidcConfig { + issue_url: config.issuer_url, + require_aud: config.require_audience, + require_scopes: config.require_scopes.into_inner(), + claim_email: config.claim_username, + claim_name: config.claim_name, + claim_groups: config.claim_groups, + default_domain: config.username_domain, + }) + .await + .map(Directory::OpenId) + .map_err(|err| err.to_string()) + } + + pub async fn new(config: OidcConfig) -> Result { + let http = utils::http::http_client_builder(false) + .user_agent("Stalwart/1.0") + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?; + + let started_at = Instant::now(); + let (document, keys) = loop { + match Self::discover(&http, &config).await { + Ok(discovery) => break discovery, + Err(err) if err.is_transient() && started_at.elapsed() < DISCOVERY_RETRY_FOR => { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issue_url.to_string(), + Reason = format!( + "{err}, retrying in {} seconds", + DISCOVERY_RETRY_INTERVAL.as_secs() + ) + ); + tokio::time::sleep(DISCOVERY_RETRY_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; + + Ok(Self { + discovery: OidcDiscovery { + url: config.issue_url.clone(), + document, + }, + config, + http, + cache: RwLock::new(JwksCache { + keys, + last_updated: Instant::now(), + }), + }) + } + + async fn discover( + http: &Client, + config: &OidcConfig, + ) -> Result<(DiscoveryDocument, AHashMap>), OidcError> { + let discovery_url = format!( + "{}/.well-known/openid-configuration", + config.issue_url.trim_end_matches('/') + ); + let discovery_bytes = http + .get(&discovery_url) + .send() + .await + .map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))? + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?; + let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes) + .map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?; + + let normalised_issue = config.issue_url.trim_end_matches('/'); + let normalised_issuer = discovery.issuer.trim_end_matches('/'); + if normalised_issuer != normalised_issue { + return Err(OidcError::Config(format!( + "Issuer mismatch: discovery document says '{}' but configured issue_url is '{}'", + discovery.issuer, config.issue_url, + ))); + } + + if let Some(supported) = &discovery.scopes_supported { + for scope in &config.require_scopes { + if !supported.contains(scope) { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issue_url.to_string(), + Reason = format!( + "Required scope '{}' is not in scopes_supported from the IdP", + scope + ) + ); + } + } + } + + if let Some(supported) = &discovery.claims_supported { + let check = |name: &str, label: &str| { + if !supported.iter().any(|c| c == name) { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issue_url.to_string(), + Reason = format!( + "Configured {} claim '{}' is not in claims_supported from the IdP", + label, name + ) + ); + } + }; + check(&config.claim_email, "claim_email"); + if let Some(n) = &config.claim_name { + check(n, "claim_name"); + } + if let Some(g) = &config.claim_groups { + check(g, "claim_groups"); + } + } + + /*{ + let cache = Arc::clone(&cache); + let http = http.clone(); + let jwks_uri = discovery.jwks_uri.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600)); + interval.tick().await; + loop { + interval.tick().await; + match fetch_jwks_keys(&http, &jwks_uri).await { + Ok(new_keys) => { + let mut guard = cache.write().await; + guard.keys = new_keys; + guard.last_updated = Instant::now(); + } + Err(e) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Background JWKS refresh failed: {e}") + ); + } + } + } + }); + }*/ + + let keys = fetch_jwks_keys(http, &discovery.jwks_uri).await?; + + Ok((discovery, keys)) + } +} diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs new file mode 100644 index 0000000..91ff6cb --- /dev/null +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -0,0 +1,464 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Account, Credentials, + backend::oidc::{CachedKey, OidcError, OpenIdDirectory}, +}; +use ahash::AHashMap; +use jsonwebtoken::{ + Algorithm, DecodingKey, Header, Validation, decode, decode_header, + jwk::{self, JwkSet}, +}; +use reqwest::Client; +use serde_json::Value; +use std::time::Instant; +use std::{sync::Arc, time::Duration}; +use trc::AuthEvent; + +impl OpenIdDirectory { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { + match credentials { + Credentials::Bearer { token, .. } if token.is_empty() => { + Err(AuthEvent::Failed.into_err().reason("Empty token rejected")) + } + Credentials::Bearer { token, .. } => if let Ok(header) = decode_header(token) { + self.authenticate_jwt(token, header).await + } else { + #[cfg(feature = "test_mode")] + let token = token.strip_prefix(".").unwrap_or(token); + self.authenticate_opaque(token).await + } + .map_err(|err| match err { + OidcError::AuthorizationFailed(reason) => { + AuthEvent::Failed.into_err().reason(reason) + } + err => AuthEvent::Error.into_err().reason(err), + }), + _ => Err(AuthEvent::Error + .into_err() + .reason("Unsupported credentials type for OIDC backend")), + } + } + + async fn authenticate_jwt(&self, token: &str, header: Header) -> Result { + if matches!( + header.alg, + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 + ) { + return Err(OidcError::TokenValidation( + "Unsupported algorithm".to_string(), + )); + } + + let candidates = self.get_key(header.kid.as_deref()).await?; + let mut last_err = None; + for cached in &candidates { + let dk = &cached.decoding_key; + let alg = cached.algorithm; + let mut validation = Validation::new(alg); + + if let Some(aud) = &self.config.require_aud { + validation.set_audience(&[aud]); + } else { + validation.validate_aud = false; + } + + validation.set_issuer(&[&self.discovery.document.issuer]); + validation.leeway = 60; + + match decode::(token, dk, &validation) { + Ok(token_data) => { + if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() { + last_err = Some(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidAudience, + )); + continue; + } + + self.validate_scopes(&token_data.claims)?; + + let mut claims = token_data.claims; + let jwt_email = self.resolve_email(&claims).ok(); + let missing_profile = + is_claim_missing(&claims, self.config.claim_name.as_ref()) + || is_claim_missing(&claims, self.config.claim_groups.as_ref()); + + if jwt_email.is_none() || missing_profile { + match self.fetch_userinfo(token).await { + Ok(userinfo) => { + if let (Some(base), Value::Object(extra)) = + (claims.as_object_mut(), userinfo) + { + for (key, value) in extra { + if base.get(&key).is_none_or(Value::is_null) { + base.insert(key, value); + } + } + } + } + Err(err) if jwt_email.is_none() => return Err(err), + Err(_) => {} + } + } + + let email = match jwt_email { + Some(email) => email, + None => self.resolve_email(&claims)?, + }; + return self.build_account(email, &claims); + } + Err(e) => { + last_err = Some(e); + } + } + } + + Err(OidcError::TokenValidation(format!( + "JWT validation failed: {}", + last_err.map(|e| e.to_string()).unwrap_or_default() + ))) + } + + async fn authenticate_opaque(&self, token: &str) -> Result { + let claims = self.fetch_userinfo(token).await?; + self.build_account(self.resolve_email(&claims)?, &claims) + } + + async fn get_key(&self, kid: Option<&str>) -> Result>, OidcError> { + { + let guard = self.cache.read().await; + + if let Some(kid) = kid { + if let Some(cached) = guard.keys.get(kid) { + return Ok(vec![cached.clone()]); + } + + if guard.last_updated.elapsed() < Duration::from_secs(300) { + return Err(OidcError::TokenValidation("Unknown key id".to_string())); + } + } else { + let all: Vec<_> = guard.keys.values().cloned().collect(); + if !all.is_empty() { + return Ok(all); + } + } + } + + let new_keys = fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await?; + { + let mut guard = self.cache.write().await; + guard.keys = new_keys; + guard.last_updated = Instant::now(); + } + + let guard = self.cache.read().await; + if let Some(kid) = kid { + if let Some(cached) = guard.keys.get(kid) { + Ok(vec![cached.clone()]) + } else { + Err(OidcError::TokenValidation( + "Unknown key id after refresh".to_string(), + )) + } + } else { + let all: Vec<_> = guard.keys.values().cloned().collect(); + if all.is_empty() { + Err(OidcError::Provider( + "JWKS contains no usable keys".to_string(), + )) + } else { + Ok(all) + } + } + } + + async fn fetch_userinfo(&self, token: &str) -> Result { + let resp = self + .http + .get(&self.discovery.document.userinfo_endpoint) + .bearer_auth(token) + .send() + .await + .map_err(|e| OidcError::Network(format!("UserInfo request failed: {e}")))?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + let reason = resp.text().await.unwrap_or_default(); + return Err(OidcError::AuthorizationFailed(format!( + "Token rejected by UserInfo endpoint with status {status}: {reason}" + ))); + } + if !status.is_success() { + return Err(OidcError::Provider(format!( + "UserInfo returned HTTP {status}" + ))); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?; + + serde_json::from_slice::(&bytes) + .map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}"))) + } + + fn validate_scopes(&self, claims: &serde_json::Value) -> Result<(), OidcError> { + if !self.config.require_scopes.is_empty() { + let token_scopes = extract_scopes(claims); + + for required in &self.config.require_scopes { + if !token_scopes.iter().any(|s| s == required) { + return Err(OidcError::AuthorizationFailed(format!( + "Missing required scope '{required}', present scopes: {token_scopes:?}" + ))); + } + } + } + + Ok(()) + } + + fn build_account( + &self, + email: String, + claims: &serde_json::Value, + ) -> Result { + Ok(Account { + email, + email_aliases: Vec::new(), + secret: None, + groups: self + .config + .claim_groups + .as_ref() + .and_then(|groups_claim| claims.get(groups_claim)) + .and_then(extract_string_list) + .map(|groups| { + groups + .into_iter() + .map(|group| match &self.config.default_domain { + Some(domain) if !group.contains('@') => format!("{group}@{domain}"), + _ => group, + }) + .collect() + }), + description: self + .config + .claim_name + .as_ref() + .and_then(|name_claim| claims.get(name_claim)) + .and_then(|v| v.as_str()) + .filter(|name| !name.is_empty()) + .map(|s| s.to_string()), + }) + } + + fn resolve_email(&self, claims: &serde_json::Value) -> Result { + if let Some(val) = claims + .get(&self.config.claim_email) + .and_then(|v| v.as_str()) + { + if val.contains('@') { + return Ok(val.to_string()); + } + if let Some(domain) = &self.config.default_domain { + return Ok(format!("{val}@{domain}")); + } + } + + if self.config.claim_email != "email" + && let Some(val) = claims.get("email").and_then(|v| v.as_str()) + && val.contains('@') + { + return Ok(val.to_string()); + } + + Err(OidcError::AuthorizationFailed( + "Could not determine a valid email address for account".to_string(), + )) + } +} + +pub(super) async fn fetch_jwks_keys( + http: &Client, + jwks_uri: &str, +) -> Result>, OidcError> { + let jwks_bytes = http + .get(jwks_uri) + .send() + .await + .map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))? + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?; + let jwks: JwkSet = serde_json::from_slice(&jwks_bytes) + .map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?; + + let mut map = AHashMap::new(); + let mut synthetic_id: u64 = 0; + + for key in &jwks.keys { + if let Some(pk_use) = &key.common.public_key_use + && pk_use != &jwk::PublicKeyUse::Signature + { + continue; + } + + let algorithm = match &key.algorithm { + jwk::AlgorithmParameters::RSA(_) => match key.common.key_algorithm { + Some(jwk::KeyAlgorithm::RS256) => Algorithm::RS256, + Some(jwk::KeyAlgorithm::RS384) => Algorithm::RS384, + Some(jwk::KeyAlgorithm::RS512) => Algorithm::RS512, + Some(jwk::KeyAlgorithm::PS256) => Algorithm::PS256, + Some(jwk::KeyAlgorithm::PS384) => Algorithm::PS384, + Some(jwk::KeyAlgorithm::PS512) => Algorithm::PS512, + None => Algorithm::RS256, + Some(other) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Unsupported RSA key algorithm {:?}", other) + ); + continue; + } + }, + jwk::AlgorithmParameters::EllipticCurve(ec) => match ec.curve { + jwk::EllipticCurve::P256 => Algorithm::ES256, + jwk::EllipticCurve::P384 => Algorithm::ES384, + _ => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Unsupported EC curve {:?}", ec.curve) + ); + continue; + } + }, + jwk::AlgorithmParameters::OctetKeyPair(_) => Algorithm::EdDSA, + jwk::AlgorithmParameters::OctetKey(_) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "Symmetric (HMAC) key found in JWKS (kid={:?}), skipping — HMAC is not accepted", + key.common.key_id + ) + ); + continue; + } + _ => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "Unrecognised key type in JWKS (kid={:?}), skipping", + key.common.key_id + ) + ); + continue; + } + }; + + let decoding_key = match DecodingKey::from_jwk(key) { + Ok(decoding_key) => decoding_key, + Err(e) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "Failed to build DecodingKey from JWK (kid={:?}): {e}", + key.common.key_id + ) + ); + continue; + } + }; + + map.insert( + match &key.common.key_id { + Some(id) => id.clone(), + None => { + let id = format!("_synthetic_{synthetic_id}"); + synthetic_id += 1; + id + } + }, + CachedKey { + decoding_key, + algorithm, + } + .into(), + ); + } + + Ok(map) +} + +fn extract_scopes(claims: &serde_json::Value) -> Vec { + match claims.get("scope") { + Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => Vec::new(), + } +} + +fn extract_string_list(value: &serde_json::Value) -> Option> { + match value { + serde_json::Value::Array(arr) => Some( + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + ), + serde_json::Value::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect()), + _ => None, + } +} + +#[inline(always)] +fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool { + claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_string_list_distinguishes_absent_from_empty() { + assert_eq!( + extract_string_list(&json!(["sales", "support"])), + Some(vec!["sales".to_string(), "support".to_string()]) + ); + assert_eq!( + extract_string_list(&json!("sales support")), + Some(vec!["sales".to_string(), "support".to_string()]) + ); + assert_eq!(extract_string_list(&json!([])), Some(vec![])); + assert_eq!(extract_string_list(&json!("")), Some(vec![])); + assert_eq!(extract_string_list(&json!(null)), None); + assert_eq!(extract_string_list(&json!({"groups": []})), None); + assert_eq!(extract_string_list(&json!(42)), None); + } + + #[test] + fn is_claim_missing_treats_null_as_absent() { + let claims = json!({"groups": null, "name": "John Doe", "roles": []}); + + assert!(is_claim_missing(&claims, Some(&"groups".to_string()))); + assert!(is_claim_missing(&claims, Some(&"unknown".to_string()))); + assert!(!is_claim_missing(&claims, Some(&"name".to_string()))); + assert!(!is_claim_missing(&claims, Some(&"roles".to_string()))); + assert!(!is_claim_missing(&claims, None)); + } +} diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs new file mode 100644 index 0000000..0ddb931 --- /dev/null +++ b/crates/directory/src/backend/oidc/mod.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use jsonwebtoken::{Algorithm, DecodingKey}; +use serde::{Deserialize, Serialize}; +use std::{fmt, sync::Arc, time::Instant}; +use tokio::sync::RwLock; +use utils::Client; + +pub mod config; +pub mod lookup; + +pub struct OidcConfig { + pub issue_url: String, + pub require_aud: Option, + pub require_scopes: Vec, + pub claim_email: String, + pub claim_name: Option, + pub claim_groups: Option, + pub default_domain: Option, +} + +pub struct OidcDiscovery { + pub url: String, + pub document: DiscoveryDocument, +} + +#[derive(Deserialize, Serialize)] +pub struct DiscoveryDocument { + pub issuer: String, + pub jwks_uri: String, + pub userinfo_endpoint: String, + pub token_endpoint: String, + pub authorization_endpoint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_session_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub claims_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_challenge_methods_supported: Option>, +} + +struct CachedKey { + decoding_key: DecodingKey, + algorithm: Algorithm, +} + +struct JwksCache { + keys: AHashMap>, + last_updated: Instant, +} + +pub struct OpenIdDirectory { + config: OidcConfig, + pub discovery: OidcDiscovery, + http: Client, + cache: RwLock, +} + +#[derive(Debug)] +pub enum OidcError { + TokenValidation(String), + AuthorizationFailed(String), + Network(String), + Provider(String), + Config(String), +} + +impl OidcError { + pub fn is_transient(&self) -> bool { + matches!(self, OidcError::Network(_) | OidcError::Provider(_)) + } +} + +impl fmt::Display for OidcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OidcError::TokenValidation(msg) => write!(f, "Token validation error: {msg}"), + OidcError::AuthorizationFailed(msg) => write!(f, "Authorization failed: {msg}"), + OidcError::Network(msg) => write!(f, "Network error: {msg}"), + OidcError::Provider(msg) => write!(f, "Provider error: {msg}"), + OidcError::Config(msg) => write!(f, "Configuration error: {msg}"), + } + } +} + +impl std::error::Error for OidcError {} diff --git a/crates/directory/src/backend/sql/config.rs b/crates/directory/src/backend/sql/config.rs new file mode 100644 index 0000000..6615dd7 --- /dev/null +++ b/crates/directory/src/backend/sql/config.rs @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{SqlDirectory, SqlMappings}; +use crate::Directory; +use registry::schema::structs; +use store::Store; + +#[allow(unreachable_patterns)] +impl SqlDirectory { + pub async fn open( + config: structs::SqlDirectory, + data_store: &Store, + ) -> Result { + let sql_store = match config.store { + #[cfg(feature = "postgres")] + structs::SqlAuthStore::PostgreSql(store) => { + store::backend::postgres::PostgresStore::open(store).await? + } + #[cfg(feature = "mysql")] + structs::SqlAuthStore::MySql(store) => { + store::backend::mysql::MysqlStore::open(store).await? + } + #[cfg(feature = "sqlite")] + structs::SqlAuthStore::Sqlite(store) => { + store::backend::sqlite::SqliteStore::open(store)? + } + structs::SqlAuthStore::Default => { + if data_store.is_sql() { + data_store.clone() + } else { + return Err(concat!( + "This directory is set to store accounts in the main data store, ", + "but the configured data store is not an SQL database. ", + "Either select an SQL data store or configure a separate SQL ", + "database for this directory." + ) + .to_string()); + } + } + _ => { + return Err( + "Binary not compiled with support for the selected SQL directory backend." + .to_string(), + ); + } + }; + + let mappings = SqlMappings { + query_login: config.query_login, + query_recipient: config.query_recipient, + query_member_of: config.query_member_of, + query_email_aliases: config.query_email_aliases, + column_email: config.column_email, + column_secret: config.column_secret, + column_type: config.column_class, + column_description: config.column_description, + }; + + Ok(Directory::Sql(SqlDirectory { + sql_store, + mappings, + })) + } +} diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs new file mode 100644 index 0000000..16cf4f5 --- /dev/null +++ b/crates/directory/src/backend/sql/lookup.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{SqlDirectory, SqlMappings}; +use crate::{Account, Credentials, Recipient, core::secret::verify_secret_hash}; +use store::{NamedRows, Rows, Value}; +use trc::AddContext; +use utils::sanitize_email; + +impl SqlDirectory { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { + let (username, secret) = match credentials { + Credentials::Basic { + username, secret, .. + } => (username, secret), + Credentials::Bearer { .. } => { + return Err(trc::AuthEvent::Error + .into_err() + .details("Unsupported credentials type for SQL authentication")); + } + }; + if secret.is_empty() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Empty secret rejected") + .ctx(trc::Key::AccountName, username.to_string())); + } + + let Recipient::Account(mut account) = self.mappings.row_to_account( + self.sql_store + .sql_query::(&self.mappings.query_login, vec![username.into()]) + .await + .caused_by(trc::location!())?, + ) else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("SQL login query did not return an account") + .ctx(trc::Key::AccountName, username.to_string())); + }; + + // Validate secret + if let Some(account_secret) = &account.secret { + if !verify_secret_hash(account_secret, secret.as_bytes()).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials") + .ctx(trc::Key::AccountName, username.to_string())); + } + } else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account does not have a secret") + .ctx(trc::Key::AccountName, username.to_string())); + } + + // Obtain members + if let Some(query) = &self.mappings.query_member_of { + let members = account.groups.get_or_insert_default(); + for row in self + .sql_store + .sql_query::(query, vec![username.into()]) + .await + .caused_by(trc::location!())? + .rows + { + if let Some(Value::Text(address)) = row.values.first() + && let Some(email) = sanitize_email(address) + { + members.push(email); + } + } + } + + // Obtain emails + if let Some(query) = &self.mappings.query_email_aliases { + account.email_aliases.extend( + self.sql_store + .sql_query::(query, vec![username.into()]) + .await + .caused_by(trc::location!())? + .rows + .into_iter() + .flat_map(|v| { + v.values + .into_iter() + .filter_map(|v| sanitize_email(v.to_str().as_ref())) + }), + ); + } + + if account.email.is_empty() { + account.email = sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); + } + + Ok(account) + } + + pub async fn recipient(&self, address: &str) -> trc::Result { + let recipient = self.mappings.row_to_account( + self.sql_store + .sql_query::(&self.mappings.query_recipient, vec![address.into()]) + .await + .caused_by(trc::location!())?, + ); + + match recipient { + Recipient::Account(mut account) => { + // Obtain members + if let Some(query) = &self.mappings.query_member_of { + let members = account.groups.get_or_insert_default(); + for row in self + .sql_store + .sql_query::(query, vec![account.email.as_str().into()]) + .await + .caused_by(trc::location!())? + .rows + { + if let Some(Value::Text(address)) = row.values.first() + && let Some(email) = sanitize_email(address) + { + members.push(email); + } + } + } + + // Obtain emails + if let Some(query) = &self.mappings.query_email_aliases { + account.email_aliases.extend( + self.sql_store + .sql_query::(query, vec![account.email.as_str().into()]) + .await + .caused_by(trc::location!())? + .rows + .into_iter() + .flat_map(|v| { + v.values + .into_iter() + .filter_map(|v| sanitize_email(v.to_str().as_ref())) + }), + ); + } + + Ok(Recipient::Account(account)) + } + Recipient::Group(group) => Ok(Recipient::Group(group)), + Recipient::Invalid => Ok(Recipient::Invalid), + } + } +} + +impl SqlMappings { + pub fn row_to_account(&self, rows: NamedRows) -> Recipient { + if rows.rows.is_empty() { + return Recipient::Invalid; + } + + let mut account = Account::default(); + let mut is_group = false; + + if let Some(row) = rows.rows.into_iter().next() { + for (name, value) in rows.names.into_iter().zip(row.values) { + if name.eq_ignore_ascii_case(&self.column_email) { + if let Value::Text(text) = value + && let Some(email) = sanitize_email(&text) + { + account.email = email; + } + } else if name.eq_ignore_ascii_case(&self.column_secret) { + if let Value::Text(text) = value + && !text.is_empty() + { + account.secret = Some(text.into_owned()); + } + } else if let Some(column_type) = &self.column_type + && name.eq_ignore_ascii_case(column_type) + { + is_group = value.to_str().eq_ignore_ascii_case("group"); + } else if let Some(column_description) = &self.column_description + && name.eq_ignore_ascii_case(column_description) + && let Value::Text(text) = value + && !text.is_empty() + { + account.description = Some(text.into_owned()); + } + } + } + + if !is_group { + Recipient::Account(account) + } else { + Recipient::Group(crate::Group { + email: account.email, + email_aliases: account.email_aliases, + description: account.description, + }) + } + } +} diff --git a/crates/directory/src/backend/sql/mod.rs b/crates/directory/src/backend/sql/mod.rs new file mode 100644 index 0000000..816f9d2 --- /dev/null +++ b/crates/directory/src/backend/sql/mod.rs @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use store::Store; + +pub mod config; +pub mod lookup; + +pub struct SqlDirectory { + sql_store: Store, + mappings: SqlMappings, +} + +#[derive(Debug, Default)] +pub(crate) struct SqlMappings { + query_login: String, + query_recipient: String, + query_member_of: Option, + query_email_aliases: Option, + column_email: String, + column_secret: String, + column_type: Option, + column_description: Option, +} diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs new file mode 100644 index 0000000..eb1b34f --- /dev/null +++ b/crates/directory/src/core/config.rs @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Directories, Directory, UnavailableDirectory, + backend::{ldap::LdapDirectory, oidc::OpenIdDirectory, sql::SqlDirectory}, +}; +use registry::schema::{ + prelude::ObjectType, + structs::{self, Authentication}, +}; +use std::{collections::HashMap, sync::Arc}; +use store::registry::bootstrap::Bootstrap; + +impl Directories { + pub async fn build(bp: &mut Bootstrap) -> Self { + let mut directories = HashMap::default(); + + for directory in bp.list_infallible::().await { + let id = directory.id; + let directory_type = directory.object.object_type(); + let result = match directory.object { + structs::Directory::Ldap(directory) => LdapDirectory::open(directory).await, + structs::Directory::Sql(directory) => { + SqlDirectory::open(directory, &bp.data_store).await + } + structs::Directory::Oidc(directory) => OpenIdDirectory::open(directory).await, + }; + + let directory = match result { + Ok(directory) => directory, + Err(err) => { + bp.build_error(id, err.clone()); + Directory::Unavailable(UnavailableDirectory::new(directory_type, err)) + } + }; + directories.insert(id.id().id() as u32, Arc::new(directory)); + } + + let auth = bp.setting_infallible::().await; + let default_directory = if let Some(directory_id) = auth.directory_id { + match directories.get(&(directory_id.id() as u32)) { + Some(default_directory) => default_directory.clone().into(), + None => { + bp.build_error( + ObjectType::Authentication.singleton(), + format!("Default directory with ID {} not found", directory_id), + ); + None + } + } + } else { + None + }; + + Directories { + default_directory, + directories, + } + } +} diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs new file mode 100644 index 0000000..8ecd887 --- /dev/null +++ b/crates/directory/src/core/dispatch.rs @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Account, Credentials, Directory, Recipient, backend::oidc::OidcDiscovery}; +use registry::schema::enums::DirectoryType; +use trc::AddContext; + +impl Directory { + pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { + match &self { + Directory::Ldap(store) => store.authenticate(credentials).await, + Directory::Sql(store) => store.authenticate(credentials).await, + Directory::OpenId(store) => store.authenticate(credentials).await, + Directory::Unavailable(directory) => Err(directory.error()), + } + .caused_by(trc::location!()) + } + + pub async fn recipient(&self, address: &str) -> trc::Result { + match &self { + Directory::Ldap(store) => store.recipient(address).await, + Directory::Sql(store) => store.recipient(address).await, + Directory::OpenId(_) => Ok(Recipient::Invalid), // OIDC directories do not support recipient lookups + Directory::Unavailable(directory) => Err(directory.error()), + } + .caused_by(trc::location!()) + } + + pub fn has_bearer_token_support(&self) -> bool { + match &self { + Directory::OpenId(_) => true, + Directory::Unavailable(directory) => directory.directory_type() == DirectoryType::Oidc, + _ => false, + } + } + + pub fn can_lookup_recipients(&self) -> bool { + match &self { + Directory::OpenId(_) => false, + Directory::Unavailable(directory) => directory.directory_type() != DirectoryType::Oidc, + _ => true, + } + } + + pub fn oidc_discovery_document(&self) -> Option<&OidcDiscovery> { + match &self { + Directory::OpenId(directory) => Some(&directory.discovery), + _ => None, + } + } +} diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs new file mode 100644 index 0000000..799b5fd --- /dev/null +++ b/crates/directory/src/core/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod config; +pub mod dispatch; +pub mod sasl; +pub mod secret; diff --git a/crates/directory/src/core/sasl.rs b/crates/directory/src/core/sasl.rs new file mode 100644 index 0000000..9e7763e --- /dev/null +++ b/crates/directory/src/core/sasl.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Credentials; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + +impl Credentials { + pub fn decode_sasl_challenge_plain(challenge: &[u8]) -> Option { + let mut username = Vec::new(); + let mut secret = Vec::new(); + let mut arg_num = 0; + for &ch in challenge { + if ch != 0 { + if arg_num == 1 { + username.push(ch); + } else if arg_num == 2 { + secret.push(ch); + } + } else { + arg_num += 1; + } + } + + match (String::from_utf8(username), String::from_utf8(secret)) { + (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { + Some(Credentials::Basic { + username, + secret, + mfa_token: None, + }) + } + _ => None, + } + } + + pub fn decode_sasl_challenge_oauth(challenge: &[u8]) -> Option { + extract_oauth_bearer(challenge) + .map(|(token, username)| Credentials::Bearer { username, token }) + } +} + +fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option)> { + let mut start_pos = 0; + let eof = bytes.len().saturating_sub(1); + let mut iter = bytes.iter().enumerate(); + let mut a = None; + + while let Some((pos, ch)) = iter.next() { + if *ch == b',' + && bytes + .get(pos + 1..pos + 3) + .is_some_and(|s| s.eq_ignore_ascii_case(b"a=")) + { + let from_pos = pos + 3; + let mut to_pos = from_pos; + for (pos, ch) in iter.by_ref() { + if *ch == b',' || *ch == 1 { + to_pos = pos; + break; + } + } + + if to_pos > from_pos { + a = bytes + .get(from_pos..to_pos) + .and_then(|s| std::str::from_utf8(s).ok()) + .filter(|v| v.contains('@')); + } + } else { + let is_separator = *ch == 1; + if is_separator || pos == eof { + if bytes + .get(start_pos..start_pos + 12) + .is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer ")) + { + return bytes + .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(|token| { + ( + token.to_string(), + a.map(|s| s.to_string()) + .or_else(|| extract_email_from_jwt(token)), + ) + }); + } + + start_pos = pos + 1; + } + } + } + + None +} + +#[derive(Debug, serde::Deserialize)] +struct JwtClaims { + #[serde(default)] + email: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + upn: Option, + #[serde(default)] + unique_name: Option, + #[serde(default)] + sub: Option, +} + +fn extract_email_from_jwt(token: &str) -> Option { + let claims: JwtClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok()?; + [ + claims.email, + claims.preferred_username, + claims.upn, + claims.unique_name, + claims.sub, + ] + .into_iter() + .flatten() + .find(|v| v.contains('@')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_oauth_bearer() { + let input = b"auth=Bearer validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("validtoken".to_string(), None))); + + let input = b"auth=Invalid validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b""; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer token1\x01auth=Bearer token2"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("token1".to_string(), None))); + + let input = b"auth=Bearer VALIDTOKEN"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("VALIDTOKEN".to_string(), None))); + + let input = b"auth=Bearer token with spaces"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some(("token with spaces".to_string(), None))); + + let input = b"auth=Bearer token_with_special_chars!@#"; + let result = extract_oauth_bearer(input); + assert_eq!( + result, + Some(("token_with_special_chars!@#".to_string(), None)) + ); + + let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; + let result = extract_oauth_bearer(input.as_bytes()); + assert_eq!( + result, + Some(( + "vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(), + Some("user@example.com".to_string()) + )) + ); + } +} diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs new file mode 100644 index 0000000..f0d2c8f --- /dev/null +++ b/crates/directory/src/core/secret.rs @@ -0,0 +1,647 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use argon2::Argon2; +use argon2::PasswordHash; +use argon2::PasswordHasher; +use argon2::PasswordVerifier; +use mail_builder::encoders::Base64Encoder; +use mail_parser::decoders::base64::base64_decode; +use pbkdf2::Pbkdf2; +use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt}; +use registry::schema::enums::PasswordHashAlgorithm; +use scrypt::Scrypt; +use sha1::Digest; +use sha1::Sha1; +use sha2::Sha256; +use sha2::Sha512; +use tokio::sync::oneshot; +use totp_rs::Totp; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretVerificationResult { + Valid, + Invalid, + MissingMfaToken, +} + +pub async fn verify_mfa_secret_hash( + totp_uri: Option<&str>, + totp_token: Option<&str>, + hashed_secret: &str, + secret: &str, +) -> trc::Result { + if let Some(totp_uri) = totp_uri { + if let Some(totp_token) = totp_token { + let result = verify_secret_hash(hashed_secret, secret.as_bytes()).await? + && Totp::from_url(totp_uri) + .map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details(totp_uri.to_string()) + })? + .check_current(totp_token) + .is_some(); + Ok(if result { + SecretVerificationResult::Valid + } else { + SecretVerificationResult::Invalid + }) + } else if !hashed_secret.is_empty() + && !secret.is_empty() + && verify_secret_hash(hashed_secret, secret.as_bytes()).await? + { + // Only let the client know if the TOTP code is missing + // if the password is correct + + Ok(SecretVerificationResult::MissingMfaToken) + } else { + Ok(SecretVerificationResult::Invalid) + } + } else if !hashed_secret.is_empty() && !secret.is_empty() { + if verify_secret_hash(hashed_secret, secret.as_bytes()).await? { + Ok(SecretVerificationResult::Valid) + } else { + Ok(SecretVerificationResult::Invalid) + } + } else { + Ok(SecretVerificationResult::Invalid) + } +} + +async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { + let is_argon = hashed_secret.starts_with("$argon2"); + let is_pbkdf2 = !is_argon && hashed_secret.starts_with("$pbkdf2"); + let is_scrypt = !is_argon && !is_pbkdf2 && hashed_secret.starts_with("$scrypt"); + + if is_argon || is_pbkdf2 || is_scrypt { + let (tx, rx) = oneshot::channel(); + let secret = secret.to_vec(); + let hashed_secret = hashed_secret.to_string(); + + tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) { + Ok(hash) => { + let result = if is_argon { + Argon2::default().verify_password(&secret, &hash) + } else if is_pbkdf2 { + Pbkdf2::default().verify_password(&secret, &hash) + } else { + Scrypt::default().verify_password(&secret, &hash) + }; + + tx.send(Ok(result.is_ok())).ok(); + } + Err(err) => { + tx.send(Err(trc::AuthEvent::Error + .reason(err) + .details(hashed_secret))) + .ok(); + } + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err)), + } + } else if hashed_secret.starts_with("$2") { + // Blowfish crypt + Ok(bcrypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$6$") { + // SHA-512 crypt + Ok(sha512_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$5$") { + // SHA-256 crypt + Ok(sha256_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$sha1") { + // SHA-1 crypt + Ok(sha1_crypt::verify(secret, hashed_secret)) + } else if hashed_secret.starts_with("$1") { + // MD5 based hash + Ok(md5_crypt::verify(secret, hashed_secret)) + } else { + Err(trc::AuthEvent::Error + .into_err() + .details(hashed_secret.to_string())) + } +} + +pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Result { + if hashed_secret.starts_with('$') { + verify_hash_prefix(hashed_secret, secret).await + } else if hashed_secret.starts_with('_') { + // Enhanced DES-based hash + Ok(bsdi_crypt::verify(secret, hashed_secret)) + } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { + if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { + match algo.to_ascii_uppercase().as_str() { + "ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => { + verify_hash_prefix(hashed_secret, secret).await + } + "SHA" => { + // SHA-1 + let mut hasher = Sha1::new(); + hasher.update(secret); + Ok(String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + == hashed_secret) + } + "SSHA" => { + // Salted SHA-1 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..20).unwrap_or_default(); + let salt = decoded.get(20..).unwrap_or_default(); + let mut hasher = Sha1::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "SHA256" => { + // Verify hash + let mut hasher = Sha256::new(); + hasher.update(secret); + Ok(String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + == hashed_secret) + } + "SSHA256" => { + // Salted SHA-256 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..32).unwrap_or_default(); + let salt = decoded.get(32..).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "SHA512" => { + // SHA-512 + let mut hasher = Sha512::new(); + hasher.update(secret); + Ok(String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + == hashed_secret) + } + "SSHA512" => { + // Salted SHA-512 + let decoded = base64_decode(hashed_secret.as_bytes()).unwrap_or_default(); + let hash = decoded.get(..64).unwrap_or_default(); + let salt = decoded.get(64..).unwrap_or_default(); + let mut hasher = Sha512::new(); + hasher.update(secret); + hasher.update(salt); + Ok(&hasher.finalize()[..] == hash) + } + "MD5" => { + // MD5 + let digest = md5::compute(secret); + Ok(String::from_utf8( + Base64Encoder::new().encode(&digest[..]).unwrap_or_default(), + ) + .unwrap() + == hashed_secret) + } + "CRYPT" => { + if hashed_secret.starts_with('$') { + verify_hash_prefix(hashed_secret, secret).await + } else { + // Unix crypt + Ok(unix_crypt::verify(secret, hashed_secret)) + } + } + "PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret), + _ => Err(trc::AuthEvent::Error + .ctx(trc::Key::Reason, "Unsupported algorithm") + .details(hashed_secret.to_string())), + } + } else { + Err(trc::AuthEvent::Error + .into_err() + .details(hashed_secret.to_string())) + } + } else if !hashed_secret.is_empty() { + Ok(hashed_secret.as_bytes() == secret) + } else { + Ok(false) + } +} + +pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> trc::Result { + let (tx, rx) = oneshot::channel(); + + tokio::task::spawn_blocking(move || { + let result = match algorithm { + PasswordHashAlgorithm::Argon2id => { + let hasher = Argon2::default(); + hasher + .hash_password(secret.as_slice()) + .map(|h| h.to_string()) + } + PasswordHashAlgorithm::Bcrypt => { + return tx + .send(bcrypt::hash(secret.as_slice()).map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Bcrypt hash failed") + })) + .ok() + .unwrap_or(()); + } + PasswordHashAlgorithm::Scrypt => Scrypt::default() + .hash_password(secret.as_slice()) + .map(|h| h.to_string()), + PasswordHashAlgorithm::Pbkdf2 => Pbkdf2::default() + .hash_password(secret.as_slice()) + .map(|h| h.to_string()), + }; + + tx.send(result.map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Password hash failed") + })) + .ok(); + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err)), + } +} + +pub fn is_password_hash(s: &str) -> bool { + if s.starts_with("$argon2") || s.starts_with("$pbkdf2") || s.starts_with("$scrypt") { + is_complete_phc(s) + } else if s.starts_with("$2") { + is_bcrypt_format(s) + } else if let Some(body) = s.strip_prefix("$1$") { + is_md5_crypt(body) + } else if let Some(body) = s.strip_prefix("$5$") { + is_sha_crypt(body, 43) + } else if let Some(body) = s.strip_prefix("$6$") { + is_sha_crypt(body, 86) + } else if let Some(body) = s.strip_prefix("$sha1$") { + is_sha1_crypt(body) + } else if s.starts_with('_') { + is_unix_des_crypt(s) + } else if let Some(rest) = s.strip_prefix('{') { + rest.split_once('}') + .map(|(scheme, body)| is_ldap_hash(scheme, body)) + .unwrap_or(false) + } else { + false + } +} + +fn is_complete_phc(s: &str) -> bool { + PasswordHash::new(s) + .map(|h| h.hash.is_some() && h.salt.is_some()) + .unwrap_or(false) +} + +fn is_crypt_b64(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'.' || b == b'/' +} + +fn all_crypt_b64(s: &str) -> bool { + !s.is_empty() && s.bytes().all(is_crypt_b64) +} + +fn is_bcrypt_format(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() != 60 + || !matches!(bytes[2], b'a' | b'b' | b'x' | b'y') + || bytes[3] != b'$' + || !bytes[4].is_ascii_digit() + || !bytes[5].is_ascii_digit() + || bytes[6] != b'$' + { + false + } else { + bytes[7..].iter().copied().all(is_crypt_b64) + } +} + +fn is_md5_crypt(body: &str) -> bool { + let Some((salt, hash)) = body.split_once('$') else { + return false; + }; + !salt.is_empty() + && salt.len() <= 8 + && all_crypt_b64(salt) + && hash.len() == 22 + && all_crypt_b64(hash) +} + +fn is_sha_crypt(body: &str, hash_len: usize) -> bool { + let remainder = if let Some(after) = body.strip_prefix("rounds=") { + let Some((rounds, rest)) = after.split_once('$') else { + return false; + }; + if rounds.is_empty() || !rounds.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + rest + } else { + body + }; + let Some((salt, hash)) = remainder.split_once('$') else { + return false; + }; + !salt.is_empty() + && salt.len() <= 16 + && all_crypt_b64(salt) + && hash.len() == hash_len + && all_crypt_b64(hash) +} + +fn is_sha1_crypt(body: &str) -> bool { + let mut parts = body.splitn(3, '$'); + let Some(rounds) = parts.next() else { + return false; + }; + let Some(salt) = parts.next() else { + return false; + }; + let Some(hash) = parts.next() else { + return false; + }; + if rounds.is_empty() + || !rounds.bytes().all(|b| b.is_ascii_digit()) + || salt.is_empty() + || salt.len() > 64 + || !all_crypt_b64(salt) + { + false + } else { + hash.len() == 28 && all_crypt_b64(hash) + } +} + +fn is_ldap_hash(scheme: &str, body: &str) -> bool { + match scheme.to_ascii_uppercase().as_str() { + "SHA" => b64_decoded_len_eq(body, 20), + "SSHA" => b64_decoded_len_ge(body, 21), + "SHA256" => b64_decoded_len_eq(body, 32), + "SSHA256" => b64_decoded_len_ge(body, 33), + "SHA512" => b64_decoded_len_eq(body, 64), + "SSHA512" => b64_decoded_len_ge(body, 65), + "MD5" => b64_decoded_len_eq(body, 16), + "ARGON2" | "ARGON2I" | "ARGON2ID" | "PBKDF2" => is_complete_phc(body), + "CRYPT" => is_password_hash(body) || is_unix_des_crypt(body), + _ => false, + } +} + +fn is_unix_des_crypt(s: &str) -> bool { + let bytes = s.as_bytes(); + (bytes.len() == 13 && bytes.iter().copied().all(is_crypt_b64)) + || (bytes.len() == 20 && bytes[0] == b'_' && bytes[1..].iter().copied().all(is_crypt_b64)) +} + +fn b64_decoded_len_eq(body: &str, len: usize) -> bool { + b64_decode_loose(body) + .map(|d| d.len() == len) + .unwrap_or(false) +} + +fn b64_decoded_len_ge(body: &str, min: usize) -> bool { + b64_decode_loose(body) + .map(|d| d.len() >= min) + .unwrap_or(false) +} + +fn b64_decode_loose(s: &str) -> Option> { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use base64::engine::general_purpose::STANDARD_NO_PAD; + STANDARD + .decode(s) + .ok() + .or_else(|| STANDARD_NO_PAD.decode(s).ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b64(bytes: &[u8]) -> String { + String::from_utf8(Base64Encoder::new().encode(bytes).unwrap()).unwrap() + } + + #[test] + fn is_password_hash_detects_phc_strings() { + let argon = Argon2::default() + .hash_password(b"hello") + .unwrap() + .to_string(); + assert!(is_password_hash(&argon), "argon2 not detected: {argon}"); + + let pbkdf = Pbkdf2::default() + .hash_password(b"hello") + .unwrap() + .to_string(); + assert!(is_password_hash(&pbkdf), "pbkdf2 not detected: {pbkdf}"); + + let scr = Scrypt::default() + .hash_password(b"hello") + .unwrap() + .to_string(); + assert!(is_password_hash(&scr), "scrypt not detected: {scr}"); + } + + #[test] + fn is_password_hash_detects_crypt_variants() { + let bc = bcrypt::hash("hello").unwrap(); + assert!(is_password_hash(&bc), "bcrypt not detected: {bc}"); + assert!(bcrypt::verify("hello", &bc)); + + let md5 = "$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0"; + assert!(is_password_hash(md5)); + assert!(md5_crypt::verify("password", md5)); + + let sha256 = "$5$WH1ABM5sKhxbkgCK$sOnTVjQn1Y3EWibd8gWqqJqjH.KaFrxJE5rijqxcPp7"; + assert!(is_password_hash(sha256)); + assert!(sha256_crypt::verify("test", sha256)); + + let sha256_rounds = + "$5$rounds=11858$WH1ABM5sKhxbkgCK$aTQsjPkz0rBsH3lQlJxw9HDTDXPKBxC0LlVeV69P.t1"; + assert!(is_password_hash(sha256_rounds)); + assert!(sha256_crypt::verify("test", sha256_rounds)); + + let s512 = sha512_crypt::hash("hello").unwrap(); + assert!(is_password_hash(&s512), "sha512_crypt not detected: {s512}"); + assert!(sha512_crypt::verify("hello", &s512)); + + let s1 = sha1_crypt::hash("hello").unwrap(); + assert!(is_password_hash(&s1), "sha1_crypt not detected: {s1}"); + assert!(sha1_crypt::verify("hello", &s1)); + + let bsdi = "_J9..K0AyUubDkQmPLeM"; + assert!(is_password_hash(bsdi), "bsdi_crypt not detected: {bsdi}"); + } + + #[test] + fn is_password_hash_detects_ldap_schemes() { + let mut h = Sha1::new(); + h.update(b"hello"); + let sha = b64(&h.finalize()[..]); + assert!(is_password_hash(&format!("{{SHA}}{sha}"))); + + let mut h = Sha1::new(); + h.update(b"hello"); + h.update(b"saltbytes"); + let mut buf = h.finalize().to_vec(); + buf.extend_from_slice(b"saltbytes"); + let ssha = b64(&buf); + assert!(is_password_hash(&format!("{{SSHA}}{ssha}"))); + + let mut h = Sha256::new(); + h.update(b"hello"); + let sha256 = b64(&h.finalize()[..]); + assert!(is_password_hash(&format!("{{SHA256}}{sha256}"))); + + let mut h = Sha256::new(); + h.update(b"hello"); + h.update(b"saltbytes"); + let mut buf = h.finalize().to_vec(); + buf.extend_from_slice(b"saltbytes"); + let ssha256 = b64(&buf); + assert!(is_password_hash(&format!("{{SSHA256}}{ssha256}"))); + + let mut h = Sha512::new(); + h.update(b"hello"); + let sha512 = b64(&h.finalize()[..]); + assert!(is_password_hash(&format!("{{SHA512}}{sha512}"))); + + let mut h = Sha512::new(); + h.update(b"hello"); + h.update(b"saltbytes"); + let mut buf = h.finalize().to_vec(); + buf.extend_from_slice(b"saltbytes"); + let ssha512 = b64(&buf); + assert!(is_password_hash(&format!("{{SSHA512}}{ssha512}"))); + + let digest = md5::compute(b"hello"); + let md5b = b64(&digest[..]); + assert!(is_password_hash(&format!("{{MD5}}{md5b}"))); + + let inner = sha512_crypt::hash("hello").unwrap(); + assert!(is_password_hash(&format!("{{CRYPT}}{inner}"))); + assert!(is_password_hash(&format!("{{crypt}}{inner}"))); + + assert!(is_password_hash( + "{CRYPT}$1$5pZSV9va$azfrPr6af3Fc7dLblQXVa0" + )); + assert!(is_password_hash("{CRYPT}abcdefghij012")); + assert!(is_password_hash("{CRYPT}_J9..K0AyUubDkQmPLeM")); + + let a = Argon2::default() + .hash_password(b"hello") + .unwrap() + .to_string(); + assert!(is_password_hash(&format!("{{ARGON2ID}}{a}"))); + assert!(is_password_hash(&format!("{{ARGON2}}{a}"))); + assert!(is_password_hash(&format!("{{ARGON2I}}{a}"))); + + let p = Pbkdf2::default() + .hash_password(b"hello") + .unwrap() + .to_string(); + assert!(is_password_hash(&format!("{{PBKDF2}}{p}"))); + + let mut h = Sha1::new(); + h.update(b"hello"); + let sha_lc = b64(&h.finalize()[..]); + assert!(is_password_hash(&format!("{{sha}}{sha_lc}"))); + + let mut h = Sha256::new(); + h.update(b"hello"); + h.update(b"saltbytes"); + let mut buf = h.finalize().to_vec(); + buf.extend_from_slice(b"saltbytes"); + let ssha256_lc = b64(&buf); + assert!(is_password_hash(&format!("{{ssha256}}{ssha256_lc}"))); + + let digest = md5::compute(b"hello"); + let md5_mc = b64(&digest[..]); + assert!(is_password_hash(&format!("{{Md5}}{md5_mc}"))); + } + + #[test] + fn is_password_hash_rejects_passwords() { + let not_hashes = [ + "", + "hello", + "p@ssw0rd!", + "password123", + "correct horse battery staple", + "$myPassword", + "$1incomplete", + "$1$", + "$1$short", + "$1$abc$tooshorthash", + "$5$", + "$5$nohashpart$", + "$5$saltonly$alsotooshort", + "$6$", + "$$$", + "$$argon2$", + "$argon2id$broken", + "$argon2id$v=19$bad", + "$2", + "$2y$", + "$2y$10$short", + "$2z$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$sha1$", + "$sha1$notdigits$salt$hash", + "{", + "{}", + "{}foo", + "{SHA}", + "{SHA}not!valid!base!64", + "{SHA}aGVsbG8=", + "{MD5}", + "{MD5}aGVsbG8=", + "{SHA256}aGVsbG8=", + "{SHA512}aGVsbG8=", + "{SSHA}aGVsbG8=", + "{UNKNOWN}whatever", + "{PLAIN}stillplain", + "{plain}stillplain", + "{CLEAR}stillplain", + "{clear}stillplain", + "{CRYPT}plainpw", + "{CRYPT}", + "{CRYPT}toolongtobeunixcryptbutshortbsdi", + "{ARGON2ID}notaphcstring", + "_short", + "_notvalidbsdi", + "regular_password", + "1234567890123", + "abcdefghij012", + "$5$rounds=$saltvalue$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK", + ]; + for p in not_hashes { + assert!(!is_password_hash(p), "false positive: {p:?}"); + } + } +} diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs new file mode 100644 index 0000000..eb78641 --- /dev/null +++ b/crates/directory/src/lib.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use crate::backend::oidc::OpenIdDirectory; +use backend::{ldap::LdapDirectory, sql::SqlDirectory}; +use deadpool::managed::PoolError; +use ldap3::LdapError; +use registry::schema::enums::DirectoryType; +use std::{collections::HashMap, fmt::Debug, sync::Arc}; + +pub mod backend; +pub mod core; + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum Credentials { + Basic { + username: String, + secret: String, + mfa_token: Option, + }, + Bearer { + username: Option, + token: String, + }, +} + +#[allow(clippy::large_enum_variant)] +pub enum Directory { + Ldap(LdapDirectory), + Sql(SqlDirectory), + OpenId(OpenIdDirectory), + Unavailable(UnavailableDirectory), +} + +pub struct UnavailableDirectory { + directory_type: DirectoryType, + error: String, +} + +impl UnavailableDirectory { + pub fn new(directory_type: DirectoryType, error: impl Into) -> Self { + Self { + directory_type, + error: error.into(), + } + } + + pub fn directory_type(&self) -> DirectoryType { + self.directory_type + } + + pub fn error(&self) -> trc::Error { + trc::StoreEvent::NotConfigured + .into_err() + .details("Directory is unavailable because it failed to initialize") + .reason(&self.error) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Recipient { + Account(Account), + Group(Group), + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Account { + pub email: String, + pub email_aliases: Vec, + pub secret: Option, + pub groups: Option>, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Group { + pub email: String, + pub email_aliases: Vec, + pub description: Option, +} + +#[derive(Clone, Debug)] +pub struct Directories { + pub default_directory: Option>, + pub directories: HashMap, nohash_hasher::BuildNoHashHasher>, +} + +impl Debug for Directory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Directory").finish() + } +} + +trait IntoError { + fn into_error(self) -> trc::Error; +} + +impl IntoError for PoolError { + fn into_error(self) -> trc::Error { + match self { + PoolError::Backend(error) => error.into_error(), + PoolError::Timeout(_) => trc::StoreEvent::PoolError + .into_err() + .details("Connection timed out"), + err => trc::StoreEvent::PoolError.reason(err), + } + } +} + +impl IntoError for LdapError { + fn into_error(self) -> trc::Error { + if let LdapError::LdapResult { result } = &self { + trc::StoreEvent::LdapError + .ctx(trc::Key::Code, result.rc) + .reason(self) + } else { + trc::StoreEvent::LdapError.reason(self) + } + } +} diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 0000000..bc71398 --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "email" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +nlp = { path = "../nlp" } +store = { path = "../store" } +trc = { path = "../trc" } +types = { path = "../types" } +jmap_proto = { path = "../jmap-proto" } +jmap-tools = { version = "0.1" } +common = { path = "../common" } +groupware = { path = "../groupware" } +registry = { path = "../registry" } +mail-parser = { version = "0.11", features = ["full_encoding"] } +mail-builder = { version = "1.0" } +sieve-rs = { version = "0.7", features = ["rkyv"] } +tokio = { version = "1.53", features = ["net", "macros"] } +aes = "0.9" +aes-gcm = "0.11.1" +chacha20poly1305 = "0.11" +cbc = { version = "0.2", features = ["alloc"] } +rasn = "0.28" +rasn-cms = "0.28" +rasn-pkix = "0.28" +rsa = { version = "0.9.10", features = ["sha2"] } +rand = "0.8" +sequoia-openpgp = { version = "2.4", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] } +hashify = "0.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } + +[features] +test_mode = [] +enterprise = [] + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/email/src/cache/email.rs b/crates/email/src/cache/email.rs new file mode 100644 index 0000000..fd7f539 --- /dev/null +++ b/crates/email/src/cache/email.rs @@ -0,0 +1,356 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::metadata::{ArchivedMessageData, MessageData}; +use common::{ + MessageCache, MessageStoreCache, MessageUidCache, MessagesCache, Server, auth::AccessToken, + sharing::EffectiveAcl, +}; +use store::write::{AlignedBytes, Archive}; +use store::{ValueKey, ahash::AHashMap, roaring::RoaringBitmap}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::Collection, + keyword::{Keyword, OTHER}, +}; +use utils::map::bitmap::Bitmap; + +struct MessagesCacheBuilder { + pub change_id: u64, + pub items: Vec, + pub index: AHashMap, + pub keywords: Vec>, + pub size: u64, +} + +pub(crate) async fn update_email_cache( + server: &Server, + account_id: u32, + changed_ids: &AHashMap, + store_cache: &MessageStoreCache, +) -> trc::Result { + let mut new_cache = MessagesCacheBuilder { + index: AHashMap::with_capacity(store_cache.emails.items.len()), + items: Vec::with_capacity(store_cache.emails.items.len()), + size: 0, + change_id: 0, + keywords: store_cache.emails.keywords.to_vec(), + }; + + for (document_id, is_update) in changed_ids { + if *is_update + && let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + *document_id, + )) + .await + .caused_by(trc::location!())? + { + insert_item( + &mut new_cache, + *document_id, + archive.to_unarchived::()?, + ); + } + } + + for item in &store_cache.emails.items { + if !changed_ids.contains_key(&item.document_id) { + email_insert(&mut new_cache, item.clone()); + } + } + + Ok(new_cache.build()) +} + +pub(crate) async fn full_email_cache_build( + server: &Server, + account_id: u32, +) -> trc::Result { + // Build cache + let mut cache = MessagesCacheBuilder { + items: Vec::with_capacity(16), + index: AHashMap::with_capacity(16), + keywords: Vec::new(), + size: 0, + change_id: 0, + }; + + server + .archives( + account_id, + Collection::Email, + &(), + |document_id, archive| { + insert_item( + &mut cache, + document_id, + archive.to_unarchived::()?, + ); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(cache.build()) +} + +fn insert_item( + cache: &mut MessagesCacheBuilder, + document_id: u32, + archive: Archive<&ArchivedMessageData>, +) { + let message = archive.inner; + let mut item = MessageCache { + mailboxes: message + .mailboxes + .iter() + .map(|m| MessageUidCache { + mailbox_id: m.mailbox_id.to_native(), + uid: m.uid.to_native(), + }) + .collect(), + keywords: 0, + thread_id: message.thread_id.to_native(), + change_id: archive.version.change_id().unwrap_or_default(), + document_id, + size: message.size.to_native(), + }; + for keyword in message.keywords.iter() { + match keyword.id() { + Ok(id) => { + item.keywords |= 1 << id; + } + Err(custom) => { + if let Some(idx) = cache.keywords.iter().position(|k| **k == *custom) { + item.keywords |= 1 << (OTHER + idx); + } else if cache.keywords.len() < (128 - OTHER) { + cache.keywords.push(custom.into()); + item.keywords |= 1 << (OTHER + cache.keywords.len() - 1); + } + } + } + } + + email_insert(cache, item); +} + +impl MessagesCacheBuilder { + pub fn build(mut self) -> MessagesCache { + self.index.shrink_to_fit(); + MessagesCache { + change_id: self.change_id, + items: self.items.into_boxed_slice(), + index: self.index, + keywords: self.keywords.into_boxed_slice(), + size: self.size, + } + } +} + +pub trait MessageCacheAccess { + fn email_by_id(&self, id: &u32) -> Option<&MessageCache>; + + fn has_email_id(&self, id: &u32) -> bool; + + fn in_mailbox(&self, mailbox_id: u32) -> impl Iterator; + + fn in_mailboxes(&self, mailbox_ids: &[u32]) -> impl Iterator; + + fn in_thread(&self, thread_id: u32) -> impl Iterator; + + fn with_keyword(&self, keyword: &Keyword) -> impl Iterator; + + fn without_keyword(&self, keyword: &Keyword) -> impl Iterator; + + fn in_mailbox_with_keyword( + &self, + mailbox_id: u32, + keyword: &Keyword, + ) -> impl Iterator; + + fn in_mailbox_without_keyword( + &self, + mailbox_id: u32, + keyword: &Keyword, + ) -> impl Iterator; + + fn email_document_ids(&self) -> RoaringBitmap; + + fn shared_messages( + &self, + access_token: &AccessToken, + check_acls: impl Into> + Sync + Send, + ) -> RoaringBitmap; + + fn expand_keywords(&self, message: &MessageCache) -> impl Iterator; + + fn has_keyword(&self, message: &MessageCache, keyword: &Keyword) -> bool; +} + +impl MessageCacheAccess for MessageStoreCache { + fn in_mailbox(&self, mailbox_id: u32) -> impl Iterator { + self.emails + .items + .iter() + .filter(move |m| m.mailboxes.iter().any(|m| m.mailbox_id == mailbox_id)) + } + + fn in_mailboxes(&self, mailbox_ids: &[u32]) -> impl Iterator { + self.emails.items.iter().filter(move |m| { + m.mailboxes + .iter() + .any(|mb| mailbox_ids.contains(&mb.mailbox_id)) + }) + } + + fn in_thread(&self, thread_id: u32) -> impl Iterator { + self.emails + .items + .iter() + .filter(move |m| m.thread_id == thread_id) + } + + fn with_keyword(&self, keyword: &Keyword) -> impl Iterator { + let keyword_id = keyword_to_id(self, keyword); + self.emails + .items + .iter() + .filter(move |m| keyword_id.is_some_and(|id| m.keywords & (1 << id) != 0)) + } + + fn without_keyword(&self, keyword: &Keyword) -> impl Iterator { + let keyword_id = keyword_to_id(self, keyword); + self.emails + .items + .iter() + .filter(move |m| keyword_id.is_none_or(|id| m.keywords & (1 << id) == 0)) + } + + fn in_mailbox_with_keyword( + &self, + mailbox_id: u32, + keyword: &Keyword, + ) -> impl Iterator { + let keyword_id = keyword_to_id(self, keyword); + self.emails.items.iter().filter(move |m| { + m.mailboxes.iter().any(|m| m.mailbox_id == mailbox_id) + && keyword_id.is_some_and(|id| m.keywords & (1 << id) != 0) + }) + } + + fn in_mailbox_without_keyword( + &self, + mailbox_id: u32, + keyword: &Keyword, + ) -> impl Iterator { + let keyword_id = keyword_to_id(self, keyword); + self.emails.items.iter().filter(move |m| { + m.mailboxes.iter().any(|m| m.mailbox_id == mailbox_id) + && keyword_id.is_none_or(|id| m.keywords & (1 << id) == 0) + }) + } + + fn shared_messages( + &self, + access_token: &AccessToken, + check_acls: impl Into> + Sync + Send, + ) -> RoaringBitmap { + let check_acls = check_acls.into(); + let mut shared_messages = RoaringBitmap::new(); + for mailbox in &self.mailboxes.items { + if mailbox + .acls + .as_slice() + .effective_acl(access_token) + .contains_all(check_acls) + { + shared_messages.extend( + self.in_mailbox(mailbox.document_id) + .map(|item| item.document_id), + ); + } + } + shared_messages + } + + fn email_document_ids(&self) -> RoaringBitmap { + RoaringBitmap::from_iter(self.emails.index.keys()) + } + + fn email_by_id(&self, id: &u32) -> Option<&MessageCache> { + self.emails + .index + .get(id) + .and_then(|idx| self.emails.items.get(*idx as usize)) + } + + fn has_email_id(&self, id: &u32) -> bool { + self.emails.index.contains_key(id) + } + + fn expand_keywords(&self, message: &MessageCache) -> impl Iterator { + KeywordsIter(message.keywords).map(move |id| match Keyword::try_from_id(id) { + Ok(keyword) => keyword, + Err(id) => Keyword::Other(self.emails.keywords[id - OTHER].clone()), + }) + } + + fn has_keyword(&self, message: &MessageCache, keyword: &Keyword) -> bool { + keyword_to_id(self, keyword).is_some_and(|id| message.keywords & (1 << id) != 0) + } +} + +fn email_insert(cache: &mut MessagesCacheBuilder, item: MessageCache) { + let id = item.document_id; + if let Some(idx) = cache.index.get(&id) { + cache.items[*idx as usize] = item; + } else { + cache.size += (std::mem::size_of::() + + (std::mem::size_of::() * 2) + + (item.mailboxes.len() * std::mem::size_of::())) + as u64; + + let idx = cache.items.len() as u32; + cache.items.push(item); + cache.index.insert(id, idx); + } +} + +#[inline] +fn keyword_to_id(cache: &MessageStoreCache, keyword: &Keyword) -> Option { + match keyword.id() { + Ok(id) => Some(id), + Err(name) => cache + .emails + .keywords + .iter() + .position(|k| **k == *name) + .map(|idx| (OTHER + idx) as u32), + } +} + +#[derive(Clone, Copy, Debug)] +struct KeywordsIter(u128); + +impl Iterator for KeywordsIter { + type Item = usize; + + fn next(&mut self) -> Option { + if self.0 != 0 { + let item = 127 - self.0.leading_zeros(); + self.0 ^= 1 << item; + Some(item as usize) + } else { + None + } + } +} diff --git a/crates/email/src/cache/mailbox.rs b/crates/email/src/cache/mailbox.rs new file mode 100644 index 0000000..a315a6d --- /dev/null +++ b/crates/email/src/cache/mailbox.rs @@ -0,0 +1,322 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::mailbox::{ArchivedMailbox, Mailbox, manage::MailboxFnc}; +use common::{ + MailboxCache, MailboxesCache, MessageStoreCache, Server, auth::AccessToken, + sharing::EffectiveAcl, +}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ahash::AHashMap, roaring::RoaringBitmap}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::Collection, + special_use::SpecialUse, +}; +use utils::{map::bitmap::Bitmap, topological::TopologicalSort}; + +struct MailboxesCacheBuilder { + pub change_id: u64, + pub index: AHashMap, + pub items: Vec, + pub size: u64, +} + +pub(crate) async fn update_mailbox_cache( + server: &Server, + account_id: u32, + changed_ids: &AHashMap, + store_cache: &MessageStoreCache, +) -> trc::Result { + let mut new_cache = MailboxesCacheBuilder { + items: Vec::with_capacity(store_cache.mailboxes.items.len()), + index: AHashMap::with_capacity(store_cache.mailboxes.items.len()), + size: 0, + change_id: 0, + }; + + for (document_id, is_update) in changed_ids { + if *is_update + && let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + *document_id, + )) + .await + .caused_by(trc::location!())? + { + insert_item( + &mut new_cache, + *document_id, + archive.unarchive::()?, + ); + } + } + + for item in store_cache.mailboxes.items.iter() { + if !changed_ids.contains_key(&item.document_id) { + mailbox_insert(&mut new_cache, item.clone()); + } + } + + build_tree(&mut new_cache); + + Ok(new_cache.build()) +} + +pub(crate) async fn full_mailbox_cache_build( + server: &Server, + account_id: u32, +) -> trc::Result { + // Build cache + let mut cache = MailboxesCacheBuilder { + items: Default::default(), + index: Default::default(), + size: 0, + change_id: 0, + }; + + server + .archives( + account_id, + Collection::Mailbox, + &(), + |document_id, archive| { + insert_item(&mut cache, document_id, archive.unarchive::()?); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if cache.items.is_empty() { + server + .create_system_folders(account_id) + .await + .caused_by(trc::location!())?; + server + .archives( + account_id, + Collection::Mailbox, + &(), + |document_id, archive| { + insert_item(&mut cache, document_id, archive.unarchive::()?); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + } + + build_tree(&mut cache); + + Ok(cache.build()) +} + +fn insert_item(cache: &mut MailboxesCacheBuilder, document_id: u32, mailbox: &ArchivedMailbox) { + let parent_id = mailbox.parent_id.to_native(); + let item = MailboxCache { + document_id, + name: mailbox.name.as_str().into(), + path: "".into(), + role: (&mailbox.role).into(), + parent_id: if parent_id > 0 { + parent_id - 1 + } else { + u32::MAX + }, + sort_order: mailbox + .sort_order + .as_ref() + .map(|s| s.to_native()) + .unwrap_or(u32::MAX), + subscribers: mailbox.subscribers.iter().map(|s| s.to_native()).collect(), + uid_validity: mailbox.uid_validity.to_native(), + acls: mailbox + .acls + .iter() + .map(|acl| AclGrant { + account_id: acl.account_id.to_native(), + grants: Bitmap::from(&acl.grants), + }) + .collect(), + }; + + mailbox_insert(cache, item); +} + +fn build_tree(cache: &mut MailboxesCacheBuilder) { + cache.size = 0; + let mut topological_sort = TopologicalSort::with_capacity(cache.items.len()); + + for (idx, mailbox) in cache.items.iter_mut().enumerate() { + topological_sort.insert( + if mailbox.parent_id == u32::MAX { + 0 + } else { + mailbox.parent_id + 1 + }, + mailbox.document_id + 1, + ); + mailbox.path = if matches!(mailbox.role, SpecialUse::Inbox) { + "INBOX".into() + } else if mailbox.is_root() && mailbox.name.as_str().eq_ignore_ascii_case("inbox") { + format!("INBOX {}", idx + 1) + } else { + mailbox.name.clone() + }; + + cache.size += item_size(mailbox); + } + + for folder_id in topological_sort.into_iterator() { + if folder_id != 0 { + let folder_id = folder_id - 1; + if let Some((path, parent_path)) = by_id(cache, &folder_id) + .and_then(|folder| { + folder + .parent_id() + .map(|parent_id| (&folder.path, parent_id)) + }) + .and_then(|(path, parent_id)| { + by_id(cache, &parent_id).map(|folder| (path, &folder.path)) + }) + { + let mut new_path = String::with_capacity(parent_path.len() + path.len() + 1); + new_path.push_str(parent_path.as_str()); + new_path.push('/'); + new_path.push_str(path.as_str()); + let folder = by_id_mut(cache, &folder_id).unwrap(); + folder.path = new_path; + } + } + } +} + +impl MailboxesCacheBuilder { + fn build(mut self) -> MailboxesCache { + self.index.shrink_to_fit(); + MailboxesCache { + change_id: self.change_id, + index: self.index, + items: self.items.into_boxed_slice(), + size: self.size, + } + } +} + +pub trait MailboxCacheAccess { + fn mailbox_by_id(&self, id: &u32) -> Option<&MailboxCache>; + fn mailbox_by_name(&self, name: &str) -> Option<&MailboxCache>; + fn mailbox_by_path(&self, name: &str) -> Option<&MailboxCache>; + fn mailbox_by_role(&self, role: &SpecialUse) -> Option<&MailboxCache>; + fn shared_mailboxes( + &self, + access_token: &AccessToken, + check_acls: impl Into> + Sync + Send, + ) -> RoaringBitmap; + fn has_mailbox_id(&self, id: &u32) -> bool; +} + +impl MailboxCacheAccess for MessageStoreCache { + fn mailbox_by_name(&self, name: &str) -> Option<&MailboxCache> { + self.mailboxes + .items + .iter() + .find(|m| m.name.eq_ignore_ascii_case(name)) + } + + fn mailbox_by_path(&self, path: &str) -> Option<&MailboxCache> { + self.mailboxes + .items + .iter() + .find(|m| m.path.eq_ignore_ascii_case(path)) + } + + fn mailbox_by_role(&self, role: &SpecialUse) -> Option<&MailboxCache> { + self.mailboxes.items.iter().find(|m| &m.role == role) + } + + fn shared_mailboxes( + &self, + access_token: &AccessToken, + check_acls: impl Into> + Sync + Send, + ) -> RoaringBitmap { + let check_acls = check_acls.into(); + + RoaringBitmap::from_iter( + self.mailboxes + .items + .iter() + .filter(|m| { + m.acls + .as_slice() + .effective_acl(access_token) + .contains_all(check_acls) + }) + .map(|m| m.document_id), + ) + } + + fn mailbox_by_id(&self, id: &u32) -> Option<&MailboxCache> { + self.mailboxes + .index + .get(id) + .and_then(|idx| self.mailboxes.items.get(*idx as usize)) + } + + fn has_mailbox_id(&self, id: &u32) -> bool { + self.mailboxes.index.contains_key(id) + } +} + +#[inline(always)] +fn by_id<'x>(cache: &'x MailboxesCacheBuilder, id: &u32) -> Option<&'x MailboxCache> { + cache + .index + .get(id) + .and_then(|idx| cache.items.get(*idx as usize)) +} + +#[inline(always)] +fn by_id_mut<'x>(cache: &'x mut MailboxesCacheBuilder, id: &u32) -> Option<&'x mut MailboxCache> { + cache + .index + .get(id) + .and_then(|idx| cache.items.get_mut(*idx as usize)) +} + +fn mailbox_insert(cache: &mut MailboxesCacheBuilder, item: MailboxCache) { + let id = item.document_id; + if let Some(idx) = cache.index.get(&id) { + cache.items[*idx as usize] = item; + } else { + let idx = cache.items.len() as u32; + cache.items.push(item); + cache.index.insert(id, idx); + } +} + +#[inline(always)] +fn item_size(item: &MailboxCache) -> u64 { + (std::mem::size_of::() + + (if item.name.len() > std::mem::size_of::() { + item.name.len() + } else { + 0 + }) + + (if item.path.len() > std::mem::size_of::() { + item.path.len() + } else { + 0 + })) as u64 +} diff --git a/crates/email/src/cache/mod.rs b/crates/email/src/cache/mod.rs new file mode 100644 index 0000000..089b838 --- /dev/null +++ b/crates/email/src/cache/mod.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{MessageStoreCache, Server, UpdateLock, cache::LockResult}; +use email::{full_email_cache_build, update_email_cache}; +use mailbox::{full_mailbox_cache_build, update_mailbox_cache}; +use std::{collections::hash_map::Entry, sync::Arc, time::Instant}; +use store::{ + ahash::AHashMap, + query::log::{Change, Query}, +}; +use trc::{AddContext, StoreEvent}; +use types::collection::SyncCollection; +use utils::cache::Cache; + +pub mod email; +pub mod mailbox; + +pub trait MessageCacheFetch: Sync + Send { + fn get_cached_messages( + &self, + account_id: u32, + ) -> impl Future>> + Send; +} + +impl MessageCacheFetch for Server { + async fn get_cached_messages(&self, account_id: u32) -> trc::Result> { + let cache_store = &self.inner.cache.messages; + let mut cache = match cache_store.get_value_or_guard_async(&account_id).await { + Ok(cache) => cache, + Err(guard) => { + let start_time = Instant::now(); + let cache = full_cache_build(self, account_id, Arc::new(UpdateLock::new())).await?; + + if guard.insert(cache.clone()).is_err() { + cache_store.update(account_id, cache.clone()); + } + warn_if_uncacheable(cache_store, account_id, &cache); + + trc::event!( + Store(StoreEvent::CacheMiss), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + Total = vec![cache.emails.items.len(), cache.mailboxes.items.len()], + ChangeId = cache.last_change_id, + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + }; + + // Obtain current state + let start_time = Instant::now(); + let changes = self + .core + .storage + .data + .changes( + account_id, + SyncCollection::Email.into(), + Query::Since(cache.last_change_id), + ) + .await + .caused_by(trc::location!())?; + + // Regenerate cache if the change log has been truncated + if changes.is_truncated { + let cache = full_cache_build(self, account_id, cache.update_lock.clone()).await?; + cache_store.update(account_id, cache.clone()); + warn_if_uncacheable(cache_store, account_id, &cache); + + trc::event!( + Store(StoreEvent::CacheStale), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + ChangeId = cache.last_change_id, + Total = vec![cache.emails.items.len(), cache.mailboxes.items.len()], + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + + // Verify changes + if changes.changes.is_empty() { + trc::event!( + Store(StoreEvent::CacheHit), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + ChangeId = cache.last_change_id, + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + + // Lock for updates + let lock = cache.update_lock.clone(); + let _permit = match lock.acquire(cache.last_change_id).await? { + LockResult::Acquired(permit) => permit, + LockResult::Stale(permit) => { + cache = cache_store.peek(&account_id).unwrap_or(cache.clone()); + if cache.last_change_id >= changes.to_change_id { + trc::event!( + Store(StoreEvent::CacheHit), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + ChangeId = cache.last_change_id, + Elapsed = start_time.elapsed(), + ); + return Ok(cache); + } + + permit + } + }; + let mut cache = cache.as_ref().clone(); + + let mut changed_items: AHashMap = AHashMap::with_capacity(changes.changes.len()); + let mut changed_containers: AHashMap = + AHashMap::with_capacity(changes.changes.len()); + let mut has_container_property_changes = false; + + for change in changes.changes { + match change { + Change::InsertItem(id) => match changed_items.entry(id as u32) { + Entry::Occupied(mut entry) => { + *entry.get_mut() = true; + } + Entry::Vacant(entry) => { + entry.insert(true); + } + }, + Change::UpdateItem(id) => { + changed_items.insert(id as u32, true); + } + Change::DeleteItem(id) => { + match changed_items.entry(id as u32) { + Entry::Occupied(mut entry) => { + // Thread reassignment + *entry.get_mut() = true; + } + Entry::Vacant(entry) => { + entry.insert(false); + } + } + } + Change::InsertContainer(id) | Change::UpdateContainer(id) => { + changed_containers.insert(id as u32, true); + } + Change::DeleteContainer(id) => { + changed_containers.insert(id as u32, false); + } + Change::UpdateContainerProperty(_) => { + has_container_property_changes = true; + } + } + } + + if !changed_items.is_empty() { + let mut email_cache = + update_email_cache(self, account_id, &changed_items, &cache).await?; + email_cache.change_id = changes.item_change_id.unwrap_or(changes.to_change_id); + cache.emails = Arc::new(email_cache); + } + + if !changed_containers.is_empty() { + let mut mailbox_cache = + update_mailbox_cache(self, account_id, &changed_containers, &cache).await?; + mailbox_cache.change_id = changes.container_change_id.unwrap_or(changes.to_change_id); + cache.mailboxes = Arc::new(mailbox_cache); + } else if has_container_property_changes { + let mut mailbox_cache = cache.mailboxes.as_ref().clone(); + mailbox_cache.change_id = changes.container_change_id.unwrap_or(changes.to_change_id); + cache.mailboxes = Arc::new(mailbox_cache); + } + cache.size = cache.emails.size + cache.mailboxes.size; + cache.last_change_id = changes.to_change_id; + + cache.update_lock.set_revision(cache.last_change_id); + let cache = Arc::new(cache); + cache_store.update(account_id, cache.clone()); + warn_if_uncacheable(cache_store, account_id, &cache); + + trc::event!( + Store(StoreEvent::CacheUpdate), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + ChangeId = cache.last_change_id, + Details = vec![changed_items.len(), changed_containers.len()], + Total = vec![cache.emails.items.len(), cache.mailboxes.items.len()], + Elapsed = start_time.elapsed(), + ); + + Ok(cache) + } +} + +#[inline(always)] +fn warn_if_uncacheable( + cache_store: &Cache>, + account_id: u32, + cache: &Arc, +) { + let capacity = cache_store.weight_capacity(); + if cache.size > capacity { + trc::event!( + Store(StoreEvent::CacheEntryTooLarge), + AccountId = account_id, + Collection = SyncCollection::Email.as_str(), + Size = cache.size, + Limit = capacity, + ); + } +} + +async fn full_cache_build( + server: &Server, + account_id: u32, + update_lock: Arc, +) -> trc::Result> { + let last_change_id = server + .core + .storage + .data + .get_last_change_id(account_id, SyncCollection::Email.into()) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + let mut emails = full_email_cache_build(server, account_id).await?; + let mut mailboxes = full_mailbox_cache_build(server, account_id).await?; + let size = emails.size + mailboxes.size; + emails.change_id = last_change_id; + mailboxes.change_id = last_change_id; + update_lock.set_revision(last_change_id); + + Ok(Arc::new(MessageStoreCache { + update_lock, + emails: Arc::new(emails), + mailboxes: Arc::new(mailboxes), + last_change_id, + size, + })) +} diff --git a/crates/email/src/identity/index.rs b/crates/email/src/identity/index.rs new file mode 100644 index 0000000..c42d1fc --- /dev/null +++ b/crates/email/src/identity/index.rs @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedIdentity, Identity}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use types::collection::SyncCollection; + +impl IndexableObject for Identity { + fn index_values(&self) -> impl Iterator> { + [IndexValue::LogItem { + sync_collection: SyncCollection::Identity, + prefix: None, + }] + .into_iter() + } +} + +impl IndexableObject for &ArchivedIdentity { + fn index_values(&self) -> impl Iterator> { + [IndexValue::LogItem { + sync_collection: SyncCollection::Identity, + prefix: None, + }] + .into_iter() + } +} + +impl IndexableAndSerializableObject for Identity { + fn is_versioned() -> bool { + false + } +} diff --git a/crates/email/src/identity/mod.rs b/crates/email/src/identity/mod.rs new file mode 100644 index 0000000..b171723 --- /dev/null +++ b/crates/email/src/identity/mod.rs @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod index; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct Identity { + pub name: String, + pub email: String, + pub reply_to: Option>, + pub bcc: Option>, + pub text_signature: String, + pub html_signature: String, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub struct EmailAddress { + pub name: Option, + pub email: String, +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 0000000..815803d --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod cache; +pub mod identity; +pub mod mailbox; +pub mod message; +pub mod push; +pub mod sieve; +pub mod submission; diff --git a/crates/email/src/mailbox/destroy.rs b/crates/email/src/mailbox/destroy.rs new file mode 100644 index 0000000..aac155a --- /dev/null +++ b/crates/email/src/mailbox/destroy.rs @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::{delete::EmailDeletion, metadata::MessageData}, +}; +use common::{ + Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder, +}; +use registry::schema::{ + enums::IndexDocumentType, + structs::{Task, TaskIndexDocument, TaskStatus}, +}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{roaring::RoaringBitmap, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, VanishedCollection}, + field::MailboxField, +}; + +pub trait MailboxDestroy: Sync + Send { + fn mailbox_destroy( + &self, + account_id: u32, + document_id: u32, + access_token: &AccessToken, + remove_emails: bool, + ) -> impl Future, MailboxDestroyError>>> + Send; +} + +pub enum MailboxDestroyError { + CannotDestroy, + Forbidden, + HasChildren, + HasEmails, + NotFound, + AssertionFailed, +} + +impl MailboxDestroy for Server { + async fn mailbox_destroy( + &self, + account_id: u32, + document_id: u32, + access_token: &AccessToken, + remove_emails: bool, + ) -> trc::Result, MailboxDestroyError>> { + // Internal folders cannot be deleted + #[cfg(not(feature = "test_mode"))] + if [INBOX_ID, TRASH_ID, JUNK_ID].contains(&document_id) { + return Ok(Err(MailboxDestroyError::CannotDestroy)); + } + + // Verify that this mailbox does not have sub-mailboxes + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + if cache + .mailboxes + .items + .iter() + .any(|item| item.parent_id == document_id) + { + return Ok(Err(MailboxDestroyError::HasChildren)); + } + + // Verify that the mailbox is empty + let mut batch = BatchBuilder::new(); + + batch.with_account_id(account_id); + + let message_ids = + RoaringBitmap::from_iter(cache.in_mailbox(document_id).map(|m| m.document_id)); + + if !message_ids.is_empty() { + if remove_emails { + // If the message is in multiple mailboxes, untag it from the current mailbox, + // otherwise delete it. + + let mut deleted_ids = RoaringBitmap::new(); + let mut thread_ids = RoaringBitmap::new(); + self.archives( + account_id, + Collection::Email, + &message_ids, + |message_id, message_data_| { + // Remove mailbox from list + let prev_message_data = message_data_ + .to_unarchived::() + .caused_by(trc::location!())?; + if !prev_message_data + .inner + .mailboxes + .iter() + .any(|id| id.mailbox_id == document_id) + { + return Ok(true); + } + + if prev_message_data.inner.mailboxes.len() == 1 { + // Delete message + for mailbox in prev_message_data.inner.mailboxes.iter() { + batch.log_vanished_item( + VanishedCollection::Email, + (mailbox.mailbox_id.to_native(), mailbox.uid.to_native()), + ); + } + deleted_ids.insert(message_id); + thread_ids.insert(prev_message_data.inner.thread_id.to_native()); + batch + .with_collection(Collection::Email) + .with_document(message_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(access_token.account_tenant_ids()) + .with_current(prev_message_data), + ) + .caused_by(trc::location!())? + .schedule_task(Task::UnindexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: message_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::now(), + })) + .commit_point(); + } else { + let new_message_data = MessageData { + mailboxes: prev_message_data + .inner + .mailboxes + .iter() + .filter(|m| m.mailbox_id != document_id) + .map(|m| m.to_native()) + .collect(), + keywords: prev_message_data + .inner + .keywords + .iter() + .map(|k| k.to_native()) + .collect(), + thread_id: prev_message_data.inner.thread_id.to_native(), + size: prev_message_data.inner.size.to_native(), + }; + + // Untag message from mailbox + batch + .with_collection(Collection::Email) + .with_document(message_id) + .custom( + ObjectIndexBuilder::new() + .with_changed_by(access_token.account_tenant_ids()) + .with_changes(new_message_data) + .with_current(prev_message_data), + ) + .caused_by(trc::location!())? + .commit_point(); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + self.log_emptied_threads(account_id, &mut batch, thread_ids, &deleted_ids) + .await + .caused_by(trc::location!())?; + } else { + return Ok(Err(MailboxDestroyError::HasEmails)); + } + } + + // Obtain mailbox + if let Some(mailbox_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let mailbox = mailbox_ + .to_unarchived::() + .caused_by(trc::location!())?; + // Validate ACLs + if access_token.is_shared(account_id) { + let acl = mailbox.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Delete) || (remove_emails && !acl.contains(Acl::RemoveItems)) + { + return Ok(Err(MailboxDestroyError::Forbidden)); + } + } + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox) + .with_document(document_id) + .clear(MailboxField::UidCounter) + .custom(ObjectIndexBuilder::<_, ()>::new().with_current(mailbox)) + .caused_by(trc::location!())?; + } else { + return Ok(Err(MailboxDestroyError::NotFound)); + }; + + if !batch.is_empty() { + match self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + { + Ok(change_id) => { + self.notify_task_queue(); + + Ok(Ok(Some(change_id))) + } + Err(err) if err.is_assertion_failure() => { + Ok(Err(MailboxDestroyError::AssertionFailed)) + } + Err(err) => Err(err.caused_by(trc::location!())), + } + } else { + Ok(Ok(None)) + } + } +} diff --git a/crates/email/src/mailbox/index.rs b/crates/email/src/mailbox/index.rs new file mode 100644 index 0000000..0bf10a1 --- /dev/null +++ b/crates/email/src/mailbox/index.rs @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedMailbox, Mailbox}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use types::{acl::AclGrant, collection::SyncCollection}; + +impl IndexableObject for Mailbox { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::LogContainer { + sync_collection: SyncCollection::Email, + }, + IndexValue::Acl { + value: (&self.acls).into(), + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedMailbox { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::LogContainer { + sync_collection: SyncCollection::Email, + }, + IndexValue::Acl { + value: self + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .into(), + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for Mailbox { + fn is_versioned() -> bool { + false + } +} diff --git a/crates/email/src/mailbox/manage.rs b/crates/email/src/mailbox/manage.rs new file mode 100644 index 0000000..3bc662a --- /dev/null +++ b/crates/email/src/mailbox/manage.rs @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::*; +use crate::cache::MessageCacheFetch; +use common::{Server, storage::index::ObjectIndexBuilder}; +use registry::schema::enums::StorageQuota; +use std::future::Future; +use store::write::BatchBuilder; +use trc::AddContext; +use types::collection::Collection; + +pub trait MailboxFnc: Sync + Send { + fn create_system_folders( + &self, + account_id: u32, + ) -> impl Future> + Send; + + fn mailbox_create_path( + &self, + account_id: u32, + path: &str, + ) -> impl Future>> + Send; +} + +impl MailboxFnc for Server { + async fn create_system_folders(&self, account_id: u32) -> trc::Result<()> { + #[cfg(feature = "test_mode")] + if account_id == 0 { + return Ok(()); + } + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox); + + // Create mailboxes + let mut last_document_id = ARCHIVE_ID; + for folder in &self.core.email.default_folders { + let document_id = match folder.special_use { + SpecialUse::Inbox => INBOX_ID, + SpecialUse::Trash => TRASH_ID, + SpecialUse::Junk => JUNK_ID, + SpecialUse::Drafts => DRAFTS_ID, + SpecialUse::Sent => SENT_ID, + SpecialUse::Archive => ARCHIVE_ID, + SpecialUse::None + | SpecialUse::Important + | SpecialUse::Memos + | SpecialUse::Scheduled + | SpecialUse::Snoozed => { + last_document_id += 1; + last_document_id + } + SpecialUse::Shared => unreachable!(), + }; + + let mut object = Mailbox::new(folder.name.clone()).with_role(folder.special_use); + if folder.subscribe { + object.add_subscriber(account_id); + } + batch + .with_document(document_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(object)) + .caused_by(trc::location!())?; + } + self.store() + .assign_document_ids(account_id, Collection::Mailbox, (ARCHIVE_ID + 1) as u64) + .await + .caused_by(trc::location!())?; + + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + Ok(()) + } + + async fn mailbox_create_path(&self, account_id: u32, path: &str) -> trc::Result> { + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + let mut next_parent_id = 0; + let mut create_paths = Vec::with_capacity(2); + + let mut path = path.split('/').map(|v| v.trim()); + let mut found_path = String::with_capacity(16); + { + while let Some(name) = path.next() { + if !found_path.is_empty() { + found_path.push('/'); + } + + for ch in name.chars() { + for ch in ch.to_lowercase() { + found_path.push(ch); + } + } + + if let Some(item) = cache + .mailboxes + .items + .iter() + .find(|item| item.path.to_lowercase() == found_path) + { + next_parent_id = item.document_id + 1; + } else { + create_paths.push(name.to_string()); + create_paths.extend(path.map(|v| v.to_string())); + break; + } + } + } + + // Create missing folders + if !create_paths.is_empty() { + if create_paths + .iter() + .any(|name| name.len() > self.core.email.mailbox_name_max_len) + { + return Ok(None); + } + + let account = self.account(account_id).await.caused_by(trc::location!())?; + if cache.mailboxes.items.len() + create_paths.len() + > self.object_quota(account.object_quotas(), StorageQuota::MaxMailboxes) as usize + { + return Ok(None); + } + + let mut next_document_id = self + .store() + .assign_document_ids(account_id, Collection::Mailbox, create_paths.len() as u64) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + for name in create_paths { + let document_id = next_document_id; + next_document_id -= 1; + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(Mailbox::new(name).with_parent_id(next_parent_id)), + ) + .caused_by(trc::location!())?; + next_parent_id = document_id + 1; + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + Ok(Some(next_parent_id - 1)) + } +} diff --git a/crates/email/src/mailbox/mod.rs b/crates/email/src/mailbox/mod.rs new file mode 100644 index 0000000..49658c2 --- /dev/null +++ b/crates/email/src/mailbox/mod.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use types::{acl::AclGrant, special_use::SpecialUse}; + +pub mod destroy; +pub mod index; +pub mod manage; + +pub const INBOX_ID: u32 = 0; +pub const TRASH_ID: u32 = 1; +pub const JUNK_ID: u32 = 2; +pub const DRAFTS_ID: u32 = 3; +pub const SENT_ID: u32 = 4; +pub const ARCHIVE_ID: u32 = 5; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +#[rkyv(derive(Debug))] +pub struct Mailbox { + pub name: String, + pub role: SpecialUse, + pub parent_id: u32, + pub sort_order: Option, + pub uid_validity: u32, + pub subscribers: Vec, + pub acls: Vec, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, Copy)] +#[rkyv(derive(Debug, Clone, Copy))] +pub struct UidMailbox { + pub mailbox_id: u32, + pub uid: u32, +} + +impl Mailbox { + pub fn new(name: impl Into) -> Self { + Mailbox { + name: name.into(), + role: SpecialUse::None, + parent_id: 0, + sort_order: None, + uid_validity: rand::random::(), + subscribers: vec![], + acls: vec![], + } + } + + pub fn with_role(mut self, role: SpecialUse) -> Self { + self.role = role; + self + } + + pub fn with_parent_id(mut self, parent_id: u32) -> Self { + self.parent_id = parent_id; + self + } + + pub fn with_sort_order(mut self, sort_order: u32) -> Self { + self.sort_order = Some(sort_order); + self + } + + pub fn with_subscriber(mut self, subscriber: u32) -> Self { + self.subscribers.push(subscriber); + self + } + + pub fn add_subscriber(&mut self, subscriber: u32) -> bool { + if !self.subscribers.contains(&subscriber) { + self.subscribers.push(subscriber); + true + } else { + false + } + } + + pub fn remove_subscriber(&mut self, subscriber: u32) { + self.subscribers.retain(|&x| x != subscriber); + } + + pub fn is_subscribed(&self, subscriber: u32) -> bool { + self.subscribers.contains(&subscriber) + } +} + +impl ArchivedMailbox { + pub fn is_subscribed(&self, subscriber: u32) -> bool { + self.subscribers.iter().any(|x| u32::from(x) == subscriber) + } +} + +impl PartialEq for UidMailbox { + fn eq(&self, other: &Self) -> bool { + self.mailbox_id == other.mailbox_id + } +} + +impl Eq for UidMailbox {} + +impl UidMailbox { + pub fn new(mailbox_id: u32, uid: u32) -> Self { + UidMailbox { mailbox_id, uid } + } + + pub fn new_unassigned(mailbox_id: u32) -> Self { + UidMailbox { mailbox_id, uid: 0 } + } +} diff --git a/crates/email/src/message/body.rs b/crates/email/src/message/body.rs new file mode 100644 index 0000000..dcfaac9 --- /dev/null +++ b/crates/email/src/message/body.rs @@ -0,0 +1,395 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::metadata::{ + ArchivedMessageMetadataContents, ArchivedMetadataHeaderValue, ArchivedMetadataPartType, + PART_ENCODING_BASE64, PART_ENCODING_QP, PART_SIZE_MASK, +}; +use jmap_proto::object::email::{EmailProperty, EmailValue}; +use jmap_tools::{Map, Value}; +use mail_parser::{HeaderValue, MessagePart, MimeHeaders, PartType}; +use types::blob::BlobId; +use utils::chained_bytes::ChainedBytes; + +use super::headers::HeaderToValue; + +pub trait ToBodyPart { + fn to_body_part( + &self, + part_id: u32, + properties: &[EmailProperty], + raw_message: &ChainedBytes<'_>, + blob_id: &BlobId, + blob_body_offset: isize, + ) -> Value<'static, EmailProperty, EmailValue>; +} + +impl ToBodyPart for Vec> { + fn to_body_part( + &self, + part_id: u32, + properties: &[EmailProperty], + raw_message: &ChainedBytes<'_>, + blob_id: &BlobId, + blob_body_offset: isize, + ) -> Value<'static, EmailProperty, EmailValue> { + let mut parts = vec![part_id].into_iter(); + let mut parts_stack = Vec::new(); + let mut subparts = Vec::with_capacity(1); + + loop { + if let Some((part_id, part)) = parts + .next() + .map(|part_id| (part_id, &self[part_id as usize])) + { + let mut values = Map::with_capacity(properties.len()); + let multipart = if let PartType::Multipart(parts) = &part.body { + parts.into() + } else { + None + }; + + for property in properties { + let value = match property { + EmailProperty::PartId if multipart.is_none() => part_id.to_string().into(), + EmailProperty::BlobId if multipart.is_none() => { + let base_offset = blob_id.start_offset() as isize + blob_body_offset; + BlobId::new_section( + blob_id.hash.clone(), + blob_id.class.clone(), + (part.offset_body as isize + base_offset) as usize, + (part.offset_end as isize + base_offset) as usize, + part.encoding as u8, + ) + .into() + } + EmailProperty::Size if multipart.is_none() => match &part.body { + PartType::Text(text) | PartType::Html(text) => text.len(), + PartType::Binary(bin) | PartType::InlineBinary(bin) => bin.len(), + PartType::Message(message) => message.root_part().raw_len() as usize, + PartType::Multipart(_) => 0, + } + .into(), + EmailProperty::Name => part.attachment_name().map(|v| v.to_string()).into(), + EmailProperty::Type => part + .content_type() + .map(|ct| { + ct.subtype() + .map(|st| format!("{}/{}", ct.ctype(), st)) + .unwrap_or_else(|| ct.ctype().to_string()) + }) + .or_else(|| match &part.body { + PartType::Text(_) => Some("text/plain".to_string()), + PartType::Html(_) => Some("text/html".to_string()), + PartType::Message(_) => Some("message/rfc822".to_string()), + _ => None, + }) + .into(), + EmailProperty::Charset => part + .content_type() + .and_then(|ct| ct.attribute("charset")) + .or(match &part.body { + PartType::Text(_) | PartType::Html(_) => Some("us-ascii"), + _ => None, + }) + .map(|v| v.to_string()) + .into(), + EmailProperty::Disposition => part + .content_disposition() + .map(|cd| cd.ctype()) + .map(|v| v.to_string()) + .into(), + EmailProperty::Cid => part.content_id().map(|v| v.to_string()).into(), + EmailProperty::Language => match part.content_language() { + HeaderValue::Text(text) => vec![text.to_string()].into(), + HeaderValue::TextList(list) => list + .iter() + .map(|text| text.to_string().into()) + .collect::>>() + .into(), + _ => Value::Null, + }, + EmailProperty::Location => { + part.content_location().map(|v| v.to_string()).into() + } + EmailProperty::Header(_) => { + part.headers.header_to_value(property, raw_message) + } + EmailProperty::Headers => part.headers.headers_to_value(raw_message), + EmailProperty::SubParts => continue, + _ => Value::Null, + }; + values.insert_unchecked(property.clone(), value); + } + + subparts.push(values); + + if let Some(multipart) = multipart { + if parts_stack.len() == 10_000 { + debug_assert!(false, "Too much nesting in message metadata"); + return Value::Null; + } + let multipart = multipart.clone(); + parts_stack.push(( + parts, + std::mem::replace(&mut subparts, Vec::with_capacity(multipart.len())), + )); + parts = multipart.into_iter(); + } + } else if let Some((prev_parts, mut prev_subparts)) = parts_stack.pop() { + prev_subparts + .last_mut() + .unwrap() + .insert_unchecked(EmailProperty::SubParts, subparts); + parts = prev_parts; + subparts = prev_subparts; + } else { + return subparts.pop().map(Into::into).unwrap_or_default(); + } + } + } +} + +impl ToBodyPart for ArchivedMessageMetadataContents { + fn to_body_part( + &self, + part_id: u32, + properties: &[EmailProperty], + raw_message: &ChainedBytes<'_>, + blob_id: &BlobId, + blob_body_offset: isize, + ) -> Value<'static, EmailProperty, EmailValue> { + let mut parts = vec![part_id].into_iter(); + let mut parts_stack = Vec::new(); + let mut subparts = Vec::with_capacity(1); + + loop { + if let Some((part_id, part)) = parts + .next() + .map(|part_id| (part_id, &self.parts[part_id as usize])) + { + let mut values = Map::with_capacity(properties.len()); + let multipart = if let ArchivedMetadataPartType::Multipart(parts) = &part.body { + parts.into() + } else { + None + }; + + for property in properties { + let value = match property { + EmailProperty::PartId if multipart.is_none() => part_id.to_string().into(), + EmailProperty::BlobId if multipart.is_none() => { + let base_offset = blob_id.start_offset() as isize + blob_body_offset; + let flags = part.flags.to_native(); + let encoding = if flags & PART_ENCODING_BASE64 != 0 { + 2 + } else if flags & PART_ENCODING_QP != 0 { + 1 + } else { + 0 + }; + BlobId::new_section( + blob_id.hash.clone(), + blob_id.class.clone(), + (u32::from(part.offset_body) as isize + base_offset) as usize, + (u32::from(part.offset_end) as isize + base_offset) as usize, + encoding, + ) + .into() + } + EmailProperty::Size if multipart.is_none() => { + (part.flags.to_native() & PART_SIZE_MASK).into() + } + EmailProperty::Name => part.attachment_name().map(|v| v.to_string()).into(), + EmailProperty::Type => part + .content_type() + .map(|ct| { + ct.subtype() + .map(|st| format!("{}/{}", ct.ctype(), st)) + .unwrap_or_else(|| ct.ctype().to_string()) + }) + .or_else(|| match &part.body { + ArchivedMetadataPartType::Text => Some("text/plain".to_string()), + ArchivedMetadataPartType::Html => Some("text/html".to_string()), + ArchivedMetadataPartType::Message(_) => { + Some("message/rfc822".to_string()) + } + _ => None, + }) + .into(), + EmailProperty::Charset => { + part.content_type() + .and_then(|ct| ct.attribute("charset")) + .or(match &part.body { + ArchivedMetadataPartType::Text + | ArchivedMetadataPartType::Html => Some("us-ascii"), + _ => None, + }) + .map(|v| v.to_string()) + .into() + } + EmailProperty::Disposition => part + .content_disposition() + .map(|cd| cd.ctype()) + .map(|v| v.to_string()) + .into(), + EmailProperty::Cid => part.content_id().map(|v| v.to_string()).into(), + EmailProperty::Language => match part.content_language() { + ArchivedMetadataHeaderValue::Text(text) => { + vec![text.to_string()].into() + } + ArchivedMetadataHeaderValue::TextList(list) => list + .iter() + .map(|text| text.to_string().into()) + .collect::>>() + .into(), + _ => Value::Null, + }, + EmailProperty::Location => { + part.content_location().map(|v| v.to_string()).into() + } + EmailProperty::Header(_) => part.header_to_value(property, raw_message), + EmailProperty::Headers => part.headers_to_value(raw_message), + EmailProperty::SubParts => continue, + _ => Value::Null, + }; + values.insert_unchecked(property.clone(), value); + } + + subparts.push(values); + + if let Some(multipart) = multipart { + if parts_stack.len() == 10_000 { + debug_assert!(false, "Too much nesting in message metadata"); + return Value::Null; + } + let multipart = multipart + .iter() + .map(|id| u16::from(id) as u32) + .collect::>(); + parts_stack.push(( + parts, + std::mem::replace(&mut subparts, Vec::with_capacity(multipart.len())), + )); + parts = multipart.into_iter(); + } + } else if let Some((prev_parts, mut prev_subparts)) = parts_stack.pop() { + prev_subparts + .last_mut() + .unwrap() + .insert_unchecked(EmailProperty::SubParts, subparts); + parts = prev_parts; + subparts = prev_subparts; + } else { + return subparts.pop().map(Into::into).unwrap_or_default(); + } + } + } +} + +pub trait TruncateBody { + fn truncate(&self, max_len: usize) -> (bool, String); +} + +impl TruncateBody for PartType<'_> { + fn truncate(&self, max_len: usize) -> (bool, String) { + match self { + PartType::Text(text) => truncate_plain(text, max_len), + PartType::Html(html) => truncate_html(html, max_len), + PartType::Binary(bytes) | PartType::InlineBinary(bytes) => { + PartType::Text(String::from_utf8_lossy(bytes)).truncate(max_len) + } + _ => (false, "".into()), + } + } +} + +pub fn truncate_plain(text: &str, mut max_len: usize) -> (bool, String) { + if max_len != 0 && text.len() > max_len { + let add_dots = max_len > 6; + if add_dots { + max_len -= 3; + } + let mut result = String::with_capacity(max_len); + for ch in text.chars() { + if ch != '\r' { + if ch.len_utf8() + result.len() > max_len { + break; + } + result.push(ch); + } + } + if add_dots { + result.push_str("..."); + } + (true, result) + } else { + (false, text.replace('\r', "")) + } +} + +pub fn truncate_html(html: &str, mut max_len: usize) -> (bool, String) { + if max_len != 0 && html.len() > max_len { + let add_dots = max_len > 6; + if add_dots { + max_len -= 3; + } + + let mut result = String::with_capacity(max_len); + let mut in_tag = false; + let mut in_comment = false; + let mut last_tag_end_pos = 0; + let mut cr_count = 0; + for (pos, ch) in html.char_indices() { + let mut set_last_tag = 0; + match ch { + '<' if !in_tag => { + in_tag = true; + if let Some("!--") = html.get(pos + 1..pos + 4) { + in_comment = true; + } + set_last_tag = pos; + } + '>' if in_tag => { + if in_comment { + if let Some("--") = html.get(pos - 2..pos) { + in_comment = false; + in_tag = false; + set_last_tag = pos + 1; + } + } else { + in_tag = false; + set_last_tag = pos + 1; + } + } + '\r' => { + cr_count += 1; + continue; + } + _ => (), + } + if ch.len_utf8() + pos - cr_count > max_len { + result.push_str( + &html[0..if (in_tag || set_last_tag > 0) && last_tag_end_pos > 0 { + last_tag_end_pos + } else { + pos + }] + .replace('\r', ""), + ); + if add_dots { + result.push_str("..."); + } + break; + } else if set_last_tag > 0 { + last_tag_end_pos = set_last_tag; + } + } + (true, result) + } else { + (false, html.replace('\r', "")) + } +} diff --git a/crates/email/src/message/copy.rs b/crates/email/src/message/copy.rs new file mode 100644 index 0000000..5fa16be --- /dev/null +++ b/crates/email/src/message/copy.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ingest::{EmailIngest, IngestedEmail}, + metadata::{MessageData, MessageMetadata}, +}; +use crate::{ + mailbox::UidMailbox, + message::{ + index::extractors::VisitTextArchived, + ingest::ThreadInfo, + metadata::{ + MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK, MetadataHeaderName, MetadataHeaderValue, + }, + }, +}; +use common::{Server, storage::index::ObjectIndexBuilder}; +use mail_parser::parsers::fields::thread::thread_name; +use registry::{ + schema::{ + enums::IndexDocumentType, + structs::{Task, TaskIndexDocument, TaskMergeThreads, TaskStatus}, + }, + types::map::Map, +}; +use store::write::{BatchBuilder, IndexPropertyClass, ValueClass}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + blob::{BlobClass, BlobId}, + collection::{Collection, SyncCollection}, + field::EmailField, + keyword::Keyword, +}; +use utils::cheeky_hash::CheekyHash; + +pub enum CopyMessageError { + NotFound, + OverQuota, + AlreadyExists(u32), +} + +pub trait EmailCopy: Sync + Send { + #[allow(clippy::too_many_arguments)] + fn copy_message( + &self, + from_account_id: u32, + from_message_id: u32, + to_account_id: u32, + mailboxes: Vec, + keywords: Vec, + received_at: Option, + session_id: u64, + ) -> impl Future>> + Send; +} + +impl EmailCopy for Server { + #[allow(clippy::too_many_arguments)] + async fn copy_message( + &self, + from_account_id: u32, + from_message_id: u32, + to_account_id: u32, + mailboxes: Vec, + keywords: Vec, + received_at: Option, + session_id: u64, + ) -> trc::Result> { + // Obtain metadata + let mut metadata = if let Some(metadata) = self + .store() + .get_value::>(ValueKey::property( + from_account_id, + Collection::Email, + from_message_id, + EmailField::Metadata, + )) + .await? + { + metadata + .deserialize::() + .caused_by(trc::location!())? + } else { + return Ok(Err(CopyMessageError::NotFound)); + }; + + // Check quota + let size = metadata.root_part().offset_end; + let to_account = self.account(to_account_id).await?; + match self.has_available_quota(&to_account, size as u64).await { + Ok(_) => (), + Err(err) => { + if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) + || err.matches(trc::EventType::Limit(trc::LimitEvent::TenantQuota)) + { + trc::error!(err.account_id(to_account_id).span_id(session_id)); + return Ok(Err(CopyMessageError::OverQuota)); + } else { + return Err(err); + } + } + } + + // Set receivedAt + if let Some(received_at) = received_at { + metadata.rcvd_attach = (metadata.rcvd_attach & MESSAGE_HAS_ATTACHMENT) + | (received_at & MESSAGE_RECEIVED_MASK); + } + + // Obtain threadId + let mut message_ids = Vec::new(); + let mut subject = ""; + for header in &metadata.contents[0].parts[0].headers { + match &header.name { + MetadataHeaderName::MessageId => { + header.value.visit_text(|id| { + if !id.is_empty() { + message_ids.push(CheekyHash::new(id.as_bytes())); + } + }); + } + MetadataHeaderName::InReplyTo + | MetadataHeaderName::References + | MetadataHeaderName::ResentMessageId => { + header.value.visit_text(|id| { + if !id.is_empty() { + message_ids.push(CheekyHash::new(id.as_bytes())); + } + }); + } + MetadataHeaderName::Subject if subject.is_empty() => { + subject = thread_name(match &header.value { + MetadataHeaderValue::Text(text) => text.as_ref(), + MetadataHeaderValue::TextList(list) if !list.is_empty() => { + list.first().unwrap().as_ref() + } + _ => "", + }); + } + _ => (), + } + } + + message_ids.sort_unstable(); + message_ids.dedup(); + + // Obtain threadId + let thread_result = self + .find_thread_id(to_account_id, subject, &message_ids) + .await + .caused_by(trc::location!())?; + + if let Some(&existing) = thread_result.duplicate_ids.first() { + return Ok(Err(CopyMessageError::AlreadyExists(existing))); + } + + // Assign id + let mut email = IngestedEmail { + size: size as usize, + ..Default::default() + }; + let blob_hash = metadata.blob_hash.clone(); + + // Assign IMAP UIDs + let mut mailbox_ids = Vec::with_capacity(mailboxes.len()); + email.imap_uids = Vec::with_capacity(mailboxes.len()); + let mut ids = self + .assign_email_ids(to_account_id, mailboxes.iter().copied(), true) + .await + .caused_by(trc::location!())?; + let document_id = ids.next().unwrap(); + for (uid, mailbox_id) in ids.zip(mailboxes.iter().copied()) { + mailbox_ids.push(UidMailbox::new(mailbox_id, uid)); + email.imap_uids.push(uid); + } + + // Prepare batch + let mut batch = BatchBuilder::new(); + batch.with_account_id(to_account_id); + + // Determine thread id + let tenant_id = to_account.tenant_id(); + let thread_id = if let Some(thread_id) = thread_result.thread_id { + thread_id + } else { + batch + .with_collection(Collection::Thread) + .with_document(document_id) + .log_container_insert(SyncCollection::Thread); + document_id + }; + batch + .with_collection(Collection::Email) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_tenant_id(tenant_id) + .with_changes(MessageData { + mailboxes: mailbox_ids.into_boxed_slice(), + keywords: keywords.into_boxed_slice(), + thread_id, + size, + }), + ) + .caused_by(trc::location!())? + .set( + ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: thread_result.thread_hash, + }), + ThreadInfo::serialize(thread_id, &message_ids), + ) + .schedule_task(Task::IndexDocument(TaskIndexDocument { + account_id: to_account_id.into(), + document_id: document_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::now(), + })); + + // Merge threads if necessary + if !thread_result.merge_ids.is_empty() { + batch.schedule_task(Task::MergeThreads(TaskMergeThreads { + account_id: to_account_id.into(), + status: TaskStatus::now(), + thread_name: thread_result.thread_hash.to_string(), + message_ids: Map::new(message_ids.into_iter().map(|id| id.to_string()).collect()), + })); + } + + metadata + .index(&mut batch, true) + .caused_by(trc::location!())?; + + // Insert and obtain ids + let change_id = self + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())? + .last_change_id(to_account_id)?; + + // Request indexing + self.notify_task_queue(); + + // Update response + email.document_id = document_id; + email.thread_id = thread_id; + email.change_id = change_id; + email.blob_id = BlobId::new( + blob_hash, + BlobClass::Linked { + account_id: to_account_id, + collection: Collection::Email.into(), + document_id, + }, + ); + + Ok(Ok(email)) + } +} diff --git a/crates/email/src/message/crypto.rs b/crates/email/src/message/crypto.rs new file mode 100644 index 0000000..fd0b95e --- /dev/null +++ b/crates/email/src/message/crypto.rs @@ -0,0 +1,598 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use aes::cipher::{BlockModeEncrypt, KeyIvInit, block_padding::Pkcs7}; +use aes_gcm::{ + Aes256Gcm, + aead::{AeadInOut, KeyInit}, +}; +use chacha20poly1305::ChaCha20Poly1305; +use common::auth::{ + ACCOUNT_FLAG_ENCRYPT_ALGO_AES256, ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM, + ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305, ACCOUNT_FLAG_ENCRYPT_APPEND, + ACCOUNT_FLAG_ENCRYPT_METHOD_PGP, ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER, EncryptionKeys, +}; +use mail_builder::{encoders::Base64Encoder, mime::make_boundary}; +use mail_parser::{Message, MimeHeaders, PartType}; +use openpgp::{ + parse::Parse, + serialize::stream, + types::{KeyFlags, SymmetricAlgorithm}, +}; +use rand::{RngCore, SeedableRng, rngs::StdRng}; +use rasn::Encoder; +use rasn::types::{OctetString, Oid, SetOf}; +use rasn_cms::{ + AlgorithmIdentifier, AuthEnvelopedData, CONTENT_DATA, CONTENT_ENVELOPED_DATA, EncryptedContent, + EncryptedContentInfo, EncryptedKey, EnvelopedData, IssuerAndSerialNumber, + KeyTransRecipientInfo, RecipientIdentifier, RecipientInfo, + algorithms::{AES128_CBC, AES256_CBC, RSA}, + pkcs7_compat::EncapsulatedContentInfo, +}; +use rsa::{Oaep, Pkcs1v15Encrypt, RsaPublicKey, pkcs1::DecodeRsaPublicKey, sha2::Sha256}; +use sequoia_openpgp as openpgp; +use std::io::Cursor; + +const AES256_GCM: &Oid = + Oid::JOINT_ISO_ITU_T_COUNTRY_US_ORGANIZATION_GOV_CSOR_NIST_ALGORITHMS_AES256_GCM; +const CHACHA20_POLY1305: &Oid = Oid::const_new(&[1, 2, 840, 113549, 1, 9, 16, 3, 18]); +const CONTENT_AUTH_ENVELOPED_DATA: &Oid = + Oid::ISO_MEMBER_BODY_US_RSADSI_PKCS9_SMIME_CT_AUTH_ENVELOPED_DATA; +const SHA256: &Oid = + Oid::JOINT_ISO_ITU_T_COUNTRY_US_ORGANIZATION_GOV_CSOR_NIST_ALGORITHMS_HASH_SHA256; +const MGF1: &Oid = Oid::ISO_MEMBER_BODY_US_RSADSI_PKCS1_MGF1; +const RSAES_OAEP: &Oid = Oid::ISO_MEMBER_BODY_US_RSADSI_PKCS1_RSAES_OAEP; + +#[derive(Debug)] +pub enum EncryptMessageError { + AlreadyEncrypted, + Error(String), +} + +#[allow(async_fn_in_trait)] +pub trait EncryptMessage { + async fn encrypt( + &self, + keys: &EncryptionKeys, + flags: u64, + ) -> Result, EncryptMessageError>; + fn is_encrypted(&self) -> bool; +} + +impl EncryptMessage for Message<'_> { + async fn encrypt( + &self, + keys: &EncryptionKeys, + flags: u64, + ) -> Result, EncryptMessageError> { + if flags & ACCOUNT_FLAG_ENCRYPT_METHOD_PGP != 0 && flags.cipher().is_aead() { + return Err(EncryptMessageError::Error( + "AES-256-GCM and ChaCha20-Poly1305 are only supported for S/MIME encryption." + .into(), + )); + } + + let root = self.root_part(); + let raw_message = self.raw_message(); + let mut outer_message = Vec::with_capacity((raw_message.len() as f64 * 1.5) as usize); + let mut inner_message = Vec::with_capacity(raw_message.len()); + + // Move MIME headers and body to inner message + for header in root.headers() { + (if header.name.is_mime_header() { + &mut inner_message + } else { + &mut outer_message + }) + .extend_from_slice( + &raw_message[header.offset_field() as usize..header.offset_end() as usize], + ); + } + inner_message.extend_from_slice(b"\r\n"); + inner_message.extend_from_slice(&raw_message[root.raw_body_offset() as usize..]); + + // Encrypt inner message + if flags & ACCOUNT_FLAG_ENCRYPT_METHOD_PGP != 0 { + // Prepare encrypted message + let boundary = make_boundary("_"); + outer_message.extend_from_slice( + concat!( + "Content-Type: multipart/encrypted;\r\n\t", + "protocol=\"application/pgp-encrypted\";\r\n\t", + "boundary=\"" + ) + .as_bytes(), + ); + outer_message.extend_from_slice(boundary.as_bytes()); + outer_message.extend_from_slice( + concat!( + "\"\r\n\r\n", + "OpenPGP/MIME message (Automatically encrypted by Stalwart)\r\n\r\n", + "--" + ) + .as_bytes(), + ); + outer_message.extend_from_slice(boundary.as_bytes()); + outer_message.extend_from_slice( + concat!( + "\r\nContent-Type: application/pgp-encrypted\r\n\r\n", + "Version: 1\r\n\r\n--" + ) + .as_bytes(), + ); + outer_message.extend_from_slice(boundary.as_bytes()); + outer_message.extend_from_slice( + concat!( + "\r\nContent-Type: application/octet-stream; name=\"encrypted.asc\"\r\n", + "Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n" + ) + .as_bytes(), + ); + + let certs = keys + .iter() + .map(openpgp::Cert::from_bytes) + .collect::, _>>() + .map_err(|err| { + EncryptMessageError::Error(format!( + "Failed to parse OpenPGP public key: {}", + err + )) + })?; + + // Encrypt contents (TODO: use rayon) + let encrypted_contents = tokio::task::spawn_blocking(move || { + // Parse public key + let mut keys = Vec::with_capacity(certs.len()); + let policy = openpgp::policy::StandardPolicy::new(); + + for cert in &certs { + for key in cert + .keys() + .with_policy(&policy, None) + .supported() + .alive() + .revoked(false) + .key_flags(KeyFlags::empty().set_transport_encryption()) + { + keys.push(key); + } + } + + // Compose a writer stack corresponding to the output format and + // packet structure we want. + let mut sink = Vec::with_capacity(inner_message.len()); + + // Stream an OpenPGP message. + let message = stream::Armorer::new(stream::Message::new(&mut sink)) + .build() + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to create armorer: {}", err)) + })?; + let message = stream::Encryptor::for_recipients(message, keys) + .symmetric_algo(flags.algo()) + .build() + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to build encryptor: {}", err)) + })?; + let mut message = stream::LiteralWriter::new(message).build().map_err(|err| { + EncryptMessageError::Error(format!("Failed to create literal writer: {}", err)) + })?; + std::io::copy(&mut Cursor::new(inner_message), &mut message).map_err(|err| { + EncryptMessageError::Error(format!("Failed to encrypt message: {}", err)) + })?; + message.finalize().map_err(|err| { + EncryptMessageError::Error(format!("Failed to finalize message: {}", err)) + })?; + + String::from_utf8(sink).map_err(|err| { + EncryptMessageError::Error(format!( + "Failed to convert encrypted message to UTF-8: {}", + err + )) + }) + }) + .await + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encrypt message: {}", err)) + })??; + outer_message.extend_from_slice(encrypted_contents.as_bytes()); + outer_message.extend_from_slice(b"\r\n--"); + outer_message.extend_from_slice(boundary.as_bytes()); + outer_message.extend_from_slice(b"--\r\n"); + } else { + let cipher = flags.cipher(); + + // Generate random nonce + let mut rng = StdRng::from_entropy(); + let mut nonce = vec![0u8; cipher.nonce_size()]; + rng.fill_bytes(&mut nonce); + + // Generate random key + let mut key = vec![0u8; cipher.key_size()]; + rng.fill_bytes(&mut key); + + // Encrypt contents (TODO: use rayon) + let (encrypted_contents, mac, key, nonce) = tokio::task::spawn_blocking(move || { + let (encrypted_contents, mac) = cipher.encrypt(&key, &nonce, &inner_message); + (encrypted_contents, mac, key, nonce) + }) + .await + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encrypt message: {}", err)) + })?; + + // Encrypt key using public keys + let key_encryption_algorithm = cipher.key_encryption_algorithm()?; + let mut recipient_infos = SetOf::new(); + for cert in keys.iter() { + let cert = rasn::der::decode::(cert).map_err(|err| { + EncryptMessageError::Error(format!("Failed to parse certificate: {}", err)) + })?; + + let public_key = RsaPublicKey::from_pkcs1_der( + cert.tbs_certificate + .subject_public_key_info + .subject_public_key + .as_raw_slice(), + ) + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to parse public key: {}", err)) + })?; + let encrypted_key = if cipher.is_aead() { + public_key.encrypt(&mut rng, Oaep::new::(), &key[..]) + } else { + public_key.encrypt(&mut rng, Pkcs1v15Encrypt, &key[..]) + } + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encrypt key: {}", err)) + })?; + + recipient_infos.insert(RecipientInfo::KeyTransRecipientInfo( + KeyTransRecipientInfo { + version: 0.into(), + rid: RecipientIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber { + issuer: cert.tbs_certificate.issuer, + serial_number: cert.tbs_certificate.serial_number, + }), + key_encryption_algorithm: key_encryption_algorithm.clone(), + encrypted_key: EncryptedKey::from(encrypted_key), + }, + )); + } + + let encrypted_content_info = EncryptedContentInfo { + content_type: CONTENT_DATA.into(), + content_encryption_algorithm: cipher.content_encryption_algorithm(&nonce)?, + encrypted_content: Some(EncryptedContent::from(encrypted_contents)), + }; + + let (content_type, content) = if let Some(mac) = mac { + ( + CONTENT_AUTH_ENVELOPED_DATA, + rasn::der::encode(&AuthEnvelopedData { + version: 0.into(), + originator_info: None, + recipient_infos, + auth_encrypted_content_info: encrypted_content_info, + auth_attrs: None, + mac: OctetString::from(mac), + unauth_attrs: None, + }) + .map_err(|err| { + EncryptMessageError::Error(format!( + "Failed to encode AuthEnvelopedData: {}", + err + )) + })?, + ) + } else { + ( + CONTENT_ENVELOPED_DATA, + rasn::der::encode(&EnvelopedData { + version: 0.into(), + originator_info: None, + recipient_infos, + encrypted_content_info, + unprotected_attrs: None, + }) + .map_err(|err| { + EncryptMessageError::Error(format!( + "Failed to encode EnvelopedData: {}", + err + )) + })?, + ) + }; + + let pkcs7 = rasn::der::encode(&EncapsulatedContentInfo { + content_type: content_type.into(), + content: Some(content.into()), + }) + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encode ContentInfo: {}", err)) + })?; + + // Generate message + outer_message.extend_from_slice(b"Content-Type: application/pkcs7-mime;\r\n"); + outer_message.extend_from_slice(b"\tname=\"smime.p7m\";\r\n\tsmime-type="); + outer_message.extend_from_slice(if cipher.is_aead() { + b"authenticated-enveloped-data\r\n" + } else { + b"enveloped-data\r\n" + }); + outer_message.extend_from_slice( + concat!( + "Content-Disposition: attachment;\r\n", + "\tfilename=\"smime.p7m\"\r\n", + "Content-Transfer-Encoding: base64\r\n\r\n" + ) + .as_bytes(), + ); + Base64Encoder::new() + .wrap_lines() + .encode_to_writer(&pkcs7, &mut outer_message) + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to base64 encode PKCS7: {}", err)) + })?; + } + + Ok(outer_message) + } + + fn is_encrypted(&self) -> bool { + if self.content_type().is_some_and(|ct| { + let main_type = ct.c_type.as_ref(); + let sub_type = ct + .c_subtype + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or_default(); + + (main_type.eq_ignore_ascii_case("application") + && (sub_type.eq_ignore_ascii_case("pkcs7-mime") + || sub_type.eq_ignore_ascii_case("pkcs7-signature") + || (sub_type.eq_ignore_ascii_case("octet-stream") + && self.attachment_name().is_some_and(|name| { + name.rsplit_once('.') + .is_some_and(|(_, ext)| ["p7m", "p7s", "p7c", "p7z"].contains(&ext)) + })))) + || (main_type.eq_ignore_ascii_case("multipart") + && sub_type.eq_ignore_ascii_case("encrypted")) + }) { + return true; + } + + if self.parts.len() <= 2 { + let mut text_part = None; + let mut is_multipart = false; + + for part in &self.parts { + match &part.body { + PartType::Text(text) => { + text_part = Some(text.as_ref()); + } + PartType::Multipart(_) => { + is_multipart = true; + } + _ => (), + } + } + + match text_part { + Some(text) + if (self.parts.len() == 1 || is_multipart) + && text.trim_start().starts_with("-----BEGIN PGP MESSAGE-----") => + { + return true; + } + _ => (), + } + } + + false + } +} + +pub trait EncryptionFlags { + fn cipher(&self) -> SymmetricCipher; + fn can_train_spam_filter(&self) -> bool; + fn encrypt_on_append(&self) -> bool; + fn algo(&self) -> SymmetricAlgorithm; +} + +impl EncryptionFlags for u64 { + fn cipher(&self) -> SymmetricCipher { + if *self & ACCOUNT_FLAG_ENCRYPT_ALGO_AES256_GCM != 0 { + SymmetricCipher::Aes256Gcm + } else if *self & ACCOUNT_FLAG_ENCRYPT_ALGO_CHACHA20_POLY1305 != 0 { + SymmetricCipher::ChaCha20Poly1305 + } else if *self & ACCOUNT_FLAG_ENCRYPT_ALGO_AES256 != 0 { + SymmetricCipher::Aes256Cbc + } else { + SymmetricCipher::Aes128Cbc + } + } + + fn can_train_spam_filter(&self) -> bool { + *self & ACCOUNT_FLAG_ENCRYPT_TRAIN_SPAM_FILTER != 0 + } + + fn encrypt_on_append(&self) -> bool { + *self & ACCOUNT_FLAG_ENCRYPT_APPEND != 0 + } + + fn algo(&self) -> SymmetricAlgorithm { + if *self & ACCOUNT_FLAG_ENCRYPT_ALGO_AES256 != 0 { + SymmetricAlgorithm::AES256 + } else { + SymmetricAlgorithm::AES128 + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SymmetricCipher { + Aes128Cbc, + Aes256Cbc, + Aes256Gcm, + ChaCha20Poly1305, +} + +impl SymmetricCipher { + fn key_size(self) -> usize { + match self { + SymmetricCipher::Aes128Cbc => 16, + SymmetricCipher::Aes256Cbc + | SymmetricCipher::Aes256Gcm + | SymmetricCipher::ChaCha20Poly1305 => 32, + } + } + + fn nonce_size(self) -> usize { + match self { + SymmetricCipher::Aes128Cbc | SymmetricCipher::Aes256Cbc => 16, + SymmetricCipher::Aes256Gcm | SymmetricCipher::ChaCha20Poly1305 => 12, + } + } + + fn is_aead(self) -> bool { + matches!( + self, + SymmetricCipher::Aes256Gcm | SymmetricCipher::ChaCha20Poly1305 + ) + } + + fn encrypt(self, key: &[u8], nonce: &[u8], contents: &[u8]) -> (Vec, Option>) { + match self { + SymmetricCipher::Aes128Cbc => ( + cbc::Encryptor::::new_from_slices(key, nonce) + .expect("invalid key or iv length") + .encrypt_padded_vec::(contents), + None, + ), + SymmetricCipher::Aes256Cbc => ( + cbc::Encryptor::::new_from_slices(key, nonce) + .expect("invalid key or iv length") + .encrypt_padded_vec::(contents), + None, + ), + SymmetricCipher::Aes256Gcm => { + let cipher = Aes256Gcm::new_from_slice(key).expect("invalid key length"); + let mut buffer = contents.to_vec(); + let tag = cipher + .encrypt_inout_detached( + nonce.try_into().expect("invalid nonce length"), + b"", + buffer.as_mut_slice().into(), + ) + .expect("AES-GCM encryption failed"); + (buffer, Some(tag.to_vec())) + } + SymmetricCipher::ChaCha20Poly1305 => { + let cipher = ChaCha20Poly1305::new_from_slice(key).expect("invalid key length"); + let mut buffer = contents.to_vec(); + let tag = cipher + .encrypt_inout_detached( + nonce.try_into().expect("invalid nonce length"), + b"", + buffer.as_mut_slice().into(), + ) + .expect("ChaCha20-Poly1305 encryption failed"); + (buffer, Some(tag.to_vec())) + } + } + } + + fn content_encryption_algorithm( + self, + nonce: &[u8], + ) -> Result { + let (algorithm, parameters) = match self { + SymmetricCipher::Aes128Cbc => (AES128_CBC, encode_octet_string(nonce)?), + SymmetricCipher::Aes256Cbc => (AES256_CBC, encode_octet_string(nonce)?), + SymmetricCipher::ChaCha20Poly1305 => (CHACHA20_POLY1305, encode_octet_string(nonce)?), + SymmetricCipher::Aes256Gcm => ( + AES256_GCM, + rasn::der::encode(&GcmParameters { + nonce: OctetString::from_slice(nonce), + icv_len: 16, + }) + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encode GCM parameters: {}", err)) + })?, + ), + }; + + Ok(AlgorithmIdentifier { + algorithm: algorithm.into(), + parameters: Some(parameters.into()), + }) + } + + fn key_encryption_algorithm(self) -> Result { + if self.is_aead() { + let sha256 = AlgorithmIdentifier { + algorithm: SHA256.into(), + parameters: Some(encode_null()?.into()), + }; + let parameters = rasn::der::encode(&OaepParameters { + hash_algorithm: sha256.clone(), + mask_gen_algorithm: AlgorithmIdentifier { + algorithm: MGF1.into(), + parameters: Some( + rasn::der::encode(&sha256) + .map_err(|err| { + EncryptMessageError::Error(format!( + "Failed to encode MGF1 parameters: {}", + err + )) + })? + .into(), + ), + }, + }) + .map_err(|err| { + EncryptMessageError::Error(format!("Failed to encode OAEP parameters: {}", err)) + })?; + + Ok(AlgorithmIdentifier { + algorithm: RSAES_OAEP.into(), + parameters: Some(parameters.into()), + }) + } else { + Ok(AlgorithmIdentifier { + algorithm: RSA.into(), + parameters: Some(encode_null()?.into()), + }) + } + } +} + +#[derive(rasn::AsnType, rasn::Encode)] +struct GcmParameters { + nonce: OctetString, + icv_len: u8, +} + +#[derive(rasn::AsnType, rasn::Encode)] +struct OaepParameters { + #[rasn(tag(explicit(0)))] + hash_algorithm: AlgorithmIdentifier, + #[rasn(tag(explicit(1)))] + mask_gen_algorithm: AlgorithmIdentifier, +} + +fn encode_octet_string(value: &[u8]) -> Result, EncryptMessageError> { + rasn::der::encode(&OctetString::from_slice(value)) + .map_err(|err| EncryptMessageError::Error(format!("Failed to encode nonce: {}", err))) +} + +fn encode_null() -> Result, EncryptMessageError> { + rasn::der::encode(&()).map_err(|err| { + EncryptMessageError::Error(format!("Failed to encode NULL parameters: {}", err)) + }) +} diff --git a/crates/email/src/message/delete.rs b/crates/email/src/message/delete.rs new file mode 100644 index 0000000..6e437d0 --- /dev/null +++ b/crates/email/src/message/delete.rs @@ -0,0 +1,315 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::metadata::MessageData; +use crate::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use common::{Server, storage::index::ObjectIndexBuilder}; +use groupware::calendar::storage::ItipAutoExpunge; +use registry::schema::enums::IndexDocumentType; +use registry::schema::structs::{Task, TaskIndexDocument, TaskStatus}; +use std::future::Future; +use store::write::key::DeserializeBigEndian; +use store::write::{IndexPropertyClass, now}; +use store::{IterateParams, U32_LEN, U64_LEN, ValueKey}; +use store::{ + roaring::RoaringBitmap, + write::{BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::collection::{Collection, SyncCollection, VanishedCollection}; +use types::field::{EmailField, EmailSubmissionField}; + +pub trait EmailDeletion: Sync + Send { + fn emails_delete( + &self, + account_id: u32, + tenant_id: Option, + batch: &mut BatchBuilder, + document_ids: RoaringBitmap, + ) -> impl Future> + Send; + + fn purge_account(&self, account_id: u32) -> impl Future> + Send; + + fn purge_email_submissions( + &self, + account_id: u32, + hold_period: u64, + ) -> impl Future> + Send; + + fn emails_auto_expunge( + &self, + account_id: u32, + hold_period: u64, + ) -> impl Future> + Send; + + fn log_emptied_threads( + &self, + account_id: u32, + batch: &mut BatchBuilder, + thread_ids: RoaringBitmap, + deleted_ids: &RoaringBitmap, + ) -> impl Future> + Send; +} + +impl EmailDeletion for Server { + async fn emails_delete( + &self, + account_id: u32, + tenant_id: Option, + batch: &mut BatchBuilder, + document_ids: RoaringBitmap, + ) -> trc::Result { + let mut deleted_ids = RoaringBitmap::new(); + let mut thread_ids = RoaringBitmap::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Email); + self.archives( + account_id, + Collection::Email, + &document_ids, + |document_id, data_| { + // Add changes to batch + let metadata = data_ + .to_unarchived::() + .caused_by(trc::location!())?; + for mailbox in metadata.inner.mailboxes.iter() { + batch.log_vanished_item( + VanishedCollection::Email, + (mailbox.mailbox_id.to_native(), mailbox.uid.to_native()), + ); + } + thread_ids.insert(metadata.inner.thread_id.to_native()); + batch + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_tenant_id(tenant_id) + .with_current(metadata), + ) + .caused_by(trc::location!())? + .schedule_task(Task::UnindexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: document_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::now(), + })) + .commit_point(); + + deleted_ids.insert(document_id); + + Ok(true) + }, + ) + .await?; + + self.log_emptied_threads(account_id, batch, thread_ids, &deleted_ids) + .await?; + + let not_destroyed = if document_ids.len() == deleted_ids.len() { + RoaringBitmap::new() + } else { + deleted_ids ^= document_ids; + deleted_ids + }; + + Ok(not_destroyed) + } + + async fn log_emptied_threads( + &self, + account_id: u32, + batch: &mut BatchBuilder, + thread_ids: RoaringBitmap, + deleted_ids: &RoaringBitmap, + ) -> trc::Result<()> { + if !thread_ids.is_empty() { + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + for thread_id in &thread_ids { + if cache + .in_thread(thread_id) + .all(|message| deleted_ids.contains(message.document_id)) + { + batch + .with_account_id(account_id) + .with_collection(Collection::Thread) + .with_document(thread_id) + .log_container_delete(SyncCollection::Thread); + } + } + } + + Ok(()) + } + + async fn purge_account(&self, account_id: u32) -> trc::Result<()> { + // Auto-expunge deleted and junk messages + if let Some(hold_period) = self.core.email.mail_autoexpunge_after { + self.emails_auto_expunge(account_id, hold_period) + .await + .caused_by(trc::location!())?; + } + + // Auto-expunge iMIP messages + if let Some(hold_period) = self.core.groupware.itip_inbox_auto_expunge { + self.itip_auto_expunge(account_id, hold_period) + .await + .caused_by(trc::location!())?; + } + + // Delete old e-mail submissions + if let Some(hold_period) = self.core.email.email_submission_autoexpunge_after { + self.purge_email_submissions(account_id, hold_period) + .await + .caused_by(trc::location!())?; + } + + // Purge changelogs + self.delete_changes( + account_id, + self.core.email.changes_max_history, + self.core.email.share_notification_max_history, + ) + .await + .caused_by(trc::location!())?; + + Ok(()) + } + + async fn emails_auto_expunge(&self, account_id: u32, hold_period: u64) -> trc::Result<()> { + // Filter messages by received date + let mut destroy_ids = RoaringBitmap::new(); + let cutoff = now().saturating_sub(hold_period); + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: 0, + class: ValueClass::Property(EmailField::DeletedAt.into()), + }, + ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: u32::MAX, + class: ValueClass::Property(EmailField::DeletedAt.into()), + }, + ) + .ascending(), + |key, value| { + let deleted_at = value.deserialize_be_u64(0)?; + if deleted_at <= cutoff { + destroy_ids.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if destroy_ids.is_empty() { + return Ok(()); + } + + trc::event!( + Store(trc::StoreEvent::AutoExpunge), + Collection = Collection::Email.as_str(), + AccountId = account_id, + Total = destroy_ids.len(), + ); + + // Delete messages + let mut batch = BatchBuilder::new(); + let tenant_id = self + .account(account_id) + .await + .caused_by(trc::location!())? + .tenant_id(); + self.emails_delete(account_id, tenant_id, &mut batch, destroy_ids) + .await?; + self.commit_batch(batch).await?; + self.notify_task_queue(); + + Ok(()) + } + + async fn purge_email_submissions(&self, account_id: u32, hold_period: u64) -> trc::Result<()> { + // Filter messages by received date + let mut destroy_ids = Vec::new(); + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::EmailSubmission.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: 0, + }), + }, + ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: u32::MAX, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: now().saturating_sub(hold_period), + }), + }, + ) + .ascending() + .no_values(), + |key, _| { + destroy_ids.push(( + key.deserialize_be_u32(key.len() - U32_LEN)?, + key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?, + )); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if destroy_ids.is_empty() { + return Ok(()); + } + + trc::event!( + Store(trc::StoreEvent::AutoExpunge), + Collection = Collection::EmailSubmission.as_str(), + AccountId = account_id, + Total = destroy_ids.len(), + ); + + // Delete messages + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::EmailSubmission); + + for (document_id, send_at) in destroy_ids { + batch + .with_document(document_id) + .clear(EmailSubmissionField::Metadata) + .clear(ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: send_at, + })) + .commit_point(); + } + + self.commit_batch(batch).await?; + + Ok(()) + } +} diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs new file mode 100644 index 0000000..1ae7ade --- /dev/null +++ b/crates/email/src/message/delivery.rs @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ingest::{EmailIngest, IngestEmail, IngestSource}; +use crate::{mailbox::INBOX_ID, sieve::ingest::SieveScriptIngest}; +use common::{ + Server, + auth::BuildAccessToken, + ipc::{EmailPush, PushNotification}, +}; +use mail_parser::MessageParser; +use registry::schema::enums::Permission; +use std::{borrow::Cow, future::Future}; +use store::ahash::AHashMap; +use types::blob_hash::BlobHash; + +#[derive(Debug)] +pub struct IngestMessage { + pub sender_address: String, + pub sender_authenticated: bool, + pub recipients: Vec, + pub message_blob: BlobHash, + pub message_size: u64, + pub session_id: u64, +} + +#[derive(Debug)] +pub struct IngestRecipient { + pub address: String, + pub orcpt: Option, + pub spam_percentage: Option, +} + +impl IngestRecipient { + pub fn is_spam(&self) -> bool { + self.spam_percentage + .is_some_and(|percentage| percentage >= 50) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalDeliveryStatus { + Success, + TemporaryFailure { + reason: Cow<'static, str>, + }, + PermanentFailure { + code: [u8; 3], + reason: Cow<'static, str>, + }, +} + +pub struct LocalDeliveryResult { + pub status: Vec, + pub autogenerated: Vec, +} + +pub struct AutogeneratedMessage { + pub sender_address: String, + pub recipients: Vec, + pub message: Vec, +} + +pub trait MailDelivery: Sync + Send { + fn deliver_message( + &self, + message: IngestMessage, + ) -> impl Future + Send; +} + +impl MailDelivery for Server { + async fn deliver_message(&self, message: IngestMessage) -> LocalDeliveryResult { + // Read message + let raw_message = match self + .core + .storage + .blob + .get_blob(message.message_blob.as_slice(), 0..usize::MAX) + .await + { + Ok(Some(raw_message)) => raw_message, + Ok(None) => { + trc::event!( + MessageIngest(trc::MessageIngestEvent::Error), + Reason = "Blob not found.", + SpanId = message.session_id, + CausedBy = trc::location!() + ); + + return LocalDeliveryResult { + status: (0..message.recipients.len()) + .map(|_| LocalDeliveryStatus::TemporaryFailure { + reason: "Blob not found.".into(), + }) + .collect::>(), + autogenerated: vec![], + }; + } + Err(err) => { + trc::error!( + err.details("Failed to fetch message blob.") + .span_id(message.session_id) + .caused_by(trc::location!()) + ); + + return LocalDeliveryResult { + status: (0..message.recipients.len()) + .map(|_| LocalDeliveryStatus::TemporaryFailure { + reason: "Temporary I/O error.".into(), + }) + .collect::>(), + autogenerated: vec![], + }; + } + }; + + // Obtain the account IDs for each recipient + let mut account_ids: AHashMap = + AHashMap::with_capacity(message.recipients.len()); + let mut result = LocalDeliveryResult { + status: Vec::with_capacity(message.recipients.len()), + autogenerated: Vec::new(), + }; + + for rcpt in message.recipients { + let account_id = match self.account_id_from_email(&rcpt.address, false).await { + Ok(Some(account_id)) => account_id, + Ok(None) => { + // Something went wrong + result.status.push(LocalDeliveryStatus::PermanentFailure { + code: [5, 5, 0], + reason: "Mailbox not found.".into(), + }); + continue; + } + Err(err) => { + trc::error!( + err.details("Failed to lookup recipient.") + .ctx(trc::Key::To, rcpt.address.to_string()) + .span_id(message.session_id) + .caused_by(trc::location!()) + ); + result.status.push(LocalDeliveryStatus::TemporaryFailure { + reason: "Address lookup failed.".into(), + }); + continue; + } + }; + if let Some(status) = account_ids + .get(&account_id) + .and_then(|pos| result.status.get(*pos)) + { + result.status.push(status.clone()); + continue; + } + + // Obtain access token + let status = match self.access_token(account_id).await.and_then(|token| { + token + .build() + .assert_has_permission(Permission::EmailReceive) + }) { + Ok(access_token) => { + // Check if there is an active sieve script + match self.sieve_script_get_active(account_id).await { + Ok(None) => { + // Ingest message + self.email_ingest(IngestEmail { + raw_message: &raw_message, + blob_hash: Some(&message.message_blob), + message: MessageParser::new().parse(&raw_message), + access_token: &access_token, + mailbox_ids: vec![INBOX_ID], + keywords: vec![], + received_at: None, + source: IngestSource::Smtp { + deliver_to: &rcpt.address, + is_sender_authenticated: message.sender_authenticated, + is_spam: rcpt.is_spam(), + }, + session_id: message.session_id, + }) + .await + } + Ok(Some(active_script)) => { + self.sieve_script_ingest( + &access_token, + &message.message_blob, + &raw_message, + &message.sender_address, + message.sender_authenticated, + &rcpt, + message.session_id, + active_script, + &mut result.autogenerated, + ) + .await + } + Err(err) => Err(err), + } + } + + Err(err) => Err(err), + }; + + let status = match status { + Ok(ingested_message) => { + // Notify state change + if ingested_message.change_id != u64::MAX { + self.broadcast_push_notification(PushNotification::EmailPush(EmailPush { + account_id, + email_id: ingested_message.document_id, + change_id: ingested_message.change_id, + })) + .await; + } + + LocalDeliveryStatus::Success + } + Err(err) => { + let status = match err.as_ref() { + trc::EventType::Limit(trc::LimitEvent::Quota) => { + LocalDeliveryStatus::TemporaryFailure { + reason: "Mailbox over quota.".into(), + } + } + trc::EventType::Limit(trc::LimitEvent::TenantQuota) => { + LocalDeliveryStatus::TemporaryFailure { + reason: "Organization over quota.".into(), + } + } + trc::EventType::Security(trc::SecurityEvent::Unauthorized) => { + LocalDeliveryStatus::PermanentFailure { + code: [5, 5, 0], + reason: "This account is not authorized to receive email.".into(), + } + } + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) => { + LocalDeliveryStatus::PermanentFailure { + code: err + .value(trc::Key::Code) + .and_then(|v| v.to_uint()) + .map(|n| { + [(n / 100) as u8, ((n % 100) / 10) as u8, (n % 10) as u8] + }) + .unwrap_or([5, 5, 0]), + reason: err + .value_as_str(trc::Key::Reason) + .unwrap_or_default() + .to_string() + .into(), + } + } + _ => LocalDeliveryStatus::TemporaryFailure { + reason: "Transient server failure.".into(), + }, + }; + + trc::error!( + err.ctx(trc::Key::To, rcpt.address.to_string()) + .span_id(message.session_id) + ); + + status + } + }; + + // Cache response for UID to avoid duplicate deliveries + account_ids.insert(account_id, result.status.len()); + + result.status.push(status); + } + + result + } +} diff --git a/crates/email/src/message/headers.rs b/crates/email/src/message/headers.rs new file mode 100644 index 0000000..3e0ae75 --- /dev/null +++ b/crates/email/src/message/headers.rs @@ -0,0 +1,542 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::metadata::{ArchivedMessageMetadataPart, ArchivedMetadataHeaderValue}; +use jmap_proto::{ + object::email::{EmailProperty, EmailValue, HeaderForm, HeaderProperty}, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use mail_builder::{ + MessageBuilder, + headers::{ + address::{Address, EmailAddress, GroupedAddresses}, + date::Date, + message_id::MessageId, + raw::Raw, + text::Text, + url::URL, + }, +}; +use mail_parser::{Addr, DateTime, Group, Header, HeaderName, HeaderValue, parsers::MessageStream}; +use utils::chained_bytes::ChainedBytes; + +pub trait HeaderToValue { + fn header_to_value( + &self, + property: &EmailProperty, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue>; + fn headers_to_value( + &self, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue>; +} + +pub trait ValueToHeader<'x> { + fn try_into_grouped_addresses(self) -> Option>; + fn try_into_address_list(self) -> Option>>; + fn try_into_address(self) -> Option>; +} + +pub trait BuildHeader<'x>: Sized { + fn build_header( + self, + header: HeaderProperty, + value: Value<'x, EmailProperty, EmailValue>, + ) -> Result; +} + +impl HeaderToValue for Vec> { + fn header_to_value( + &self, + property: &EmailProperty, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue> { + let (header_name, form, all) = match property { + EmailProperty::Header(header) => ( + HeaderName::parse(header.header.as_str()) + .unwrap_or_else(|| HeaderName::Other(header.header.as_str().into())), + header.form, + header.all, + ), + EmailProperty::Sender => (HeaderName::Sender, HeaderForm::Addresses, false), + EmailProperty::From => (HeaderName::From, HeaderForm::Addresses, false), + EmailProperty::To => (HeaderName::To, HeaderForm::Addresses, false), + EmailProperty::Cc => (HeaderName::Cc, HeaderForm::Addresses, false), + EmailProperty::Bcc => (HeaderName::Bcc, HeaderForm::Addresses, false), + EmailProperty::ReplyTo => (HeaderName::ReplyTo, HeaderForm::Addresses, false), + EmailProperty::Subject => (HeaderName::Subject, HeaderForm::Text, false), + EmailProperty::MessageId => (HeaderName::MessageId, HeaderForm::MessageIds, false), + EmailProperty::InReplyTo => (HeaderName::InReplyTo, HeaderForm::MessageIds, false), + EmailProperty::References => (HeaderName::References, HeaderForm::MessageIds, false), + EmailProperty::SentAt => (HeaderName::Date, HeaderForm::Date, false), + _ => return Value::Null, + }; + + let is_raw = matches!(form, HeaderForm::Raw) || !header_name.is_structured(); + let mut headers = Vec::new(); + let header_name = header_name.as_str(); + for header in self.iter().rev() { + if header.name.as_str().eq_ignore_ascii_case(header_name) { + let raw_header; + let header_value = if is_raw || matches!(header.value, HeaderValue::Empty) { + raw_header = + raw_message.get(header.offset_start as usize..header.offset_end as usize); + + if let Some(bytes) = &raw_header { + let bytes = bytes.as_ref(); + match form { + HeaderForm::Raw => { + HeaderValue::Text(String::from_utf8_lossy(bytes.trim_end())) + } + HeaderForm::Text => MessageStream::new(bytes).parse_unstructured(), + HeaderForm::Addresses + | HeaderForm::GroupedAddresses + | HeaderForm::URLs => MessageStream::new(bytes).parse_address(), + HeaderForm::MessageIds => MessageStream::new(bytes).parse_id(), + HeaderForm::Date => MessageStream::new(bytes).parse_date(), + } + } else { + HeaderValue::Empty + } + } else { + header.value.clone() + }; + headers.push(header_value.into_form(&form)); + if !all { + break; + } + } + } + + if !all { + headers.pop().unwrap_or_default() + } else { + if headers.len() > 1 { + headers.reverse(); + } + Value::Array(headers) + } + } + + fn headers_to_value( + &self, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue> { + let mut headers = Vec::with_capacity(self.len()); + for header in self.iter() { + headers.push(Value::Object( + Map::with_capacity(2) + .with_key_value(EmailProperty::Name, header.name().to_string()) + .with_key_value( + EmailProperty::Value, + String::from_utf8_lossy( + raw_message + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default() + .as_ref() + .trim_end(), + ) + .into_owned(), + ), + )); + } + headers.into() + } +} + +impl<'x> ValueToHeader<'x> for Value<'x, EmailProperty, EmailValue> { + fn try_into_grouped_addresses(self) -> Option> { + let mut obj = self.into_object()?; + Some(GroupedAddresses { + name: obj + .remove(&Key::Property(EmailProperty::Name)) + .and_then(|n| n.into_string()), + addresses: obj + .remove(&Key::Property(EmailProperty::Addresses))? + .try_into_address_list()?, + }) + } + + fn try_into_address_list(self) -> Option>> { + let list = self.into_array()?; + let mut addresses = Vec::with_capacity(list.len()); + for value in list { + addresses.push(Address::Address(value.try_into_address()?)); + } + Some(addresses) + } + + fn try_into_address(self) -> Option> { + let mut obj = self.into_object()?; + Some(EmailAddress { + name: obj + .remove(&Key::Property(EmailProperty::Name)) + .and_then(|n| n.into_string()), + email: obj + .remove(&Key::Property(EmailProperty::Email))? + .into_string()?, + }) + } +} + +impl<'x> BuildHeader<'x> for MessageBuilder<'x> { + fn build_header( + self, + header: HeaderProperty, + value: Value<'x, EmailProperty, EmailValue>, + ) -> Result { + Ok(match (&header.form, header.all, value) { + (HeaderForm::Raw, false, Value::Str(value)) => { + self.header(header.header, Raw::from(value)) + } + (HeaderForm::Raw, true, Value::Array(value)) => self.headers( + header.header, + value + .into_iter() + .filter_map(|v| Raw::from(v.into_string()?).into()), + ), + (HeaderForm::Date, false, Value::Element(EmailValue::Date(value))) => { + self.header(header.header, Date::new(value.timestamp())) + } + (HeaderForm::Date, true, Value::Array(value)) => self.headers( + header.header, + value + .into_iter() + .filter_map(|v| Date::new(unwrap_date(v)?.timestamp()).into()), + ), + (HeaderForm::Text, false, Value::Str(value)) => { + self.header(header.header, Text::from(value)) + } + (HeaderForm::Text, true, Value::Array(value)) => self.headers( + header.header, + value + .into_iter() + .filter_map(|v| Text::from(v.into_string()?).into()), + ), + (HeaderForm::URLs, false, Value::Array(value)) => self.header( + header.header, + URL { + url: value + .into_iter() + .filter_map(|v| v.into_string()?.into()) + .collect(), + }, + ), + (HeaderForm::URLs, true, Value::Array(value)) => self.headers( + header.header, + value.into_iter().filter_map(|value| { + URL { + url: value + .into_array()? + .into_iter() + .filter_map(|v| v.into_string()?.into()) + .collect(), + } + .into() + }), + ), + (HeaderForm::MessageIds, false, Value::Array(value)) => self.header( + header.header, + MessageId { + id: value + .into_iter() + .filter_map(|v| v.into_string()?.into()) + .collect(), + }, + ), + (HeaderForm::MessageIds, true, Value::Array(value)) => self.headers( + header.header, + value.into_iter().filter_map(|value| { + MessageId { + id: value + .into_array()? + .into_iter() + .filter_map(|v| v.into_string()?.into()) + .collect(), + } + .into() + }), + ), + (HeaderForm::Addresses, false, Value::Array(value)) => self.header( + header.header, + Address::new_list( + value + .into_iter() + .filter_map(|v| Address::Address(v.try_into_address()?).into()) + .collect(), + ), + ), + (HeaderForm::Addresses, true, Value::Array(value)) => self.headers( + header.header, + value + .into_iter() + .filter_map(|v| Address::new_list(v.try_into_address_list()?).into()), + ), + (HeaderForm::GroupedAddresses, false, Value::Array(value)) => self.header( + header.header, + Address::new_list( + value + .into_iter() + .filter_map(|v| Address::Group(v.try_into_grouped_addresses()?).into()) + .collect(), + ), + ), + (HeaderForm::GroupedAddresses, true, Value::Array(value)) => self.headers( + header.header, + value.into_iter().filter_map(|v| { + Address::new_list( + v.into_array()? + .into_iter() + .filter_map(|v| Address::Group(v.try_into_grouped_addresses()?).into()) + .collect::>(), + ) + .into() + }), + ), + _ => { + return Err(header); + } + }) + } +} + +impl HeaderToValue for ArchivedMessageMetadataPart { + fn header_to_value( + &self, + property: &EmailProperty, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue> { + let (header_name, form, all) = match property { + EmailProperty::Header(header) => ( + HeaderName::parse(header.header.as_str()) + .unwrap_or_else(|| HeaderName::Other(header.header.as_str().into())), + header.form, + header.all, + ), + EmailProperty::Sender => (HeaderName::Sender, HeaderForm::Addresses, false), + EmailProperty::From => (HeaderName::From, HeaderForm::Addresses, false), + EmailProperty::To => (HeaderName::To, HeaderForm::Addresses, false), + EmailProperty::Cc => (HeaderName::Cc, HeaderForm::Addresses, false), + EmailProperty::Bcc => (HeaderName::Bcc, HeaderForm::Addresses, false), + EmailProperty::ReplyTo => (HeaderName::ReplyTo, HeaderForm::Addresses, false), + EmailProperty::Subject => (HeaderName::Subject, HeaderForm::Text, false), + EmailProperty::MessageId => (HeaderName::MessageId, HeaderForm::MessageIds, false), + EmailProperty::InReplyTo => (HeaderName::InReplyTo, HeaderForm::MessageIds, false), + EmailProperty::References => (HeaderName::References, HeaderForm::MessageIds, false), + EmailProperty::SentAt => (HeaderName::Date, HeaderForm::Date, false), + _ => return Value::Null, + }; + + let is_raw = matches!(form, HeaderForm::Raw) || !header_name.is_structured(); + let mut headers = Vec::new(); + let header_name = header_name.as_str(); + for header in self.headers.iter().rev() { + if header.name.as_str().eq_ignore_ascii_case(header_name) { + let raw_header; + let header_value = + if is_raw || matches!(header.value, ArchivedMetadataHeaderValue::Empty) { + raw_header = raw_message.get(header.value_range()); + + if let Some(bytes) = &raw_header { + let bytes = bytes.as_ref(); + match form { + HeaderForm::Raw => { + HeaderValue::Text(String::from_utf8_lossy(bytes.trim_end())) + } + HeaderForm::Text => MessageStream::new(bytes).parse_unstructured(), + HeaderForm::Addresses + | HeaderForm::GroupedAddresses + | HeaderForm::URLs => MessageStream::new(bytes).parse_address(), + HeaderForm::MessageIds => MessageStream::new(bytes).parse_id(), + HeaderForm::Date => MessageStream::new(bytes).parse_date(), + } + } else { + HeaderValue::Empty + } + } else { + HeaderValue::from(&header.value) + }; + headers.push(header_value.into_form(&form)); + if !all { + break; + } + } + } + + if !all { + headers.pop().unwrap_or_default() + } else { + if headers.len() > 1 { + headers.reverse(); + } + Value::Array(headers) + } + } + + fn headers_to_value( + &self, + raw_message: &ChainedBytes<'_>, + ) -> Value<'static, EmailProperty, EmailValue> { + let mut headers = Vec::with_capacity(self.headers.len()); + for header in self.headers.iter() { + headers.push(Value::Object( + Map::with_capacity(2) + .with_key_value(EmailProperty::Name, header.name.as_str().to_string()) + .with_key_value( + EmailProperty::Value, + String::from_utf8_lossy( + raw_message + .get(header.value_range()) + .unwrap_or_default() + .as_ref() + .trim_end(), + ) + .into_owned(), + ), + )); + } + headers.into() + } +} + +trait ByteTrim { + fn trim_end(&self) -> Self; +} + +impl ByteTrim for &[u8] { + fn trim_end(&self) -> Self { + let mut end = self.len(); + while end > 0 && self[end - 1].is_ascii_whitespace() { + end -= 1; + } + &self[..end] + } +} + +#[inline] +pub(crate) fn unwrap_date(value: Value<'_, EmailProperty, EmailValue>) -> Option { + match value { + Value::Element(EmailValue::Date(date)) => Some(date), + _ => None, + } +} + +pub trait IntoForm { + fn into_form(self, form: &HeaderForm) -> Value<'static, EmailProperty, EmailValue>; +} + +impl IntoForm for HeaderValue<'_> { + fn into_form(self, form: &HeaderForm) -> Value<'static, EmailProperty, EmailValue> { + match (self, form) { + (HeaderValue::Text(text), HeaderForm::Raw | HeaderForm::Text) => { + text.into_owned().into() + } + (HeaderValue::TextList(texts), HeaderForm::Raw | HeaderForm::Text) => { + texts.join(", ").into() + } + (HeaderValue::Text(text), HeaderForm::MessageIds) => { + Value::Array(vec![text.into_owned().into()]) + } + (HeaderValue::TextList(texts), HeaderForm::MessageIds) => { + Value::Array(texts.into_iter().map(|t| t.into_owned().into()).collect()) + } + (HeaderValue::DateTime(datetime), HeaderForm::Date) => from_mail_datetime(datetime), + (HeaderValue::Address(mail_parser::Address::List(addrlist)), HeaderForm::URLs) => { + Value::Array( + addrlist + .into_iter() + .filter_map(|addr| match addr { + Addr { + address: Some(addr), + .. + } if addr.contains(':') => Some(addr.into_owned().into()), + _ => None, + }) + .collect(), + ) + } + (HeaderValue::Address(mail_parser::Address::List(addrlist)), HeaderForm::Addresses) => { + from_mail_addrlist(addrlist) + } + ( + HeaderValue::Address(mail_parser::Address::Group(grouplist)), + HeaderForm::Addresses, + ) => Value::Array( + grouplist + .into_iter() + .flat_map(|group| group.addresses.into_iter().map(from_mail_addr)) + .collect(), + ), + ( + HeaderValue::Address(mail_parser::Address::List(addrlist)), + HeaderForm::GroupedAddresses, + ) => Value::Array(vec![ + Map::with_capacity(2) + .with_key_value(EmailProperty::Name, Value::Null) + .with_key_value(EmailProperty::Addresses, from_mail_addrlist(addrlist)) + .into(), + ]), + ( + HeaderValue::Address(mail_parser::Address::Group(grouplist)), + HeaderForm::GroupedAddresses, + ) => Value::Array( + grouplist + .into_iter() + .map(from_mail_group) + .collect::>>(), + ), + + _ => Value::Null, + } + } +} + +fn from_mail_datetime(date: DateTime) -> Value<'static, EmailProperty, EmailValue> { + Value::Element(EmailValue::Date(UTCDate { + year: date.year, + month: date.month, + day: date.day, + hour: date.hour, + minute: date.minute, + second: date.second, + tz_before_gmt: date.tz_before_gmt, + tz_hour: date.tz_hour, + tz_minute: date.tz_minute, + })) +} + +fn from_mail_addr(value: Addr<'_>) -> Value<'static, EmailProperty, EmailValue> { + Value::Object( + Map::with_capacity(2) + .with_key_value(EmailProperty::Name, value.name.map(|v| v.into_owned())) + .with_key_value( + EmailProperty::Email, + value.address.unwrap_or_default().into_owned(), + ), + ) +} + +fn from_mail_group(group: Group<'_>) -> Value<'static, EmailProperty, EmailValue> { + Value::Object( + Map::with_capacity(2) + .with_key_value(EmailProperty::Name, group.name.map(|v| v.into_owned())) + .with_key_value( + EmailProperty::Addresses, + from_mail_addrlist(group.addresses), + ), + ) +} + +fn from_mail_addrlist(addrlist: Vec>) -> Value<'static, EmailProperty, EmailValue> { + Value::Array( + addrlist + .into_iter() + .map(from_mail_addr) + .collect::>>(), + ) +} diff --git a/crates/email/src/message/index/extractors.rs b/crates/email/src/message/index/extractors.rs new file mode 100644 index 0000000..8b969da --- /dev/null +++ b/crates/email/src/message/index/extractors.rs @@ -0,0 +1,291 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::metadata::{ + ArchivedMessageMetadataContents, ArchivedMessageMetadataPart, ArchivedMetadataHeaderValue, + MetadataHeaderName, MetadataHeaderValue, +}; +use mail_parser::{Addr, Address, Group, HeaderValue}; +use nlp::language::Language; +use rkyv::option::ArchivedOption; +use std::borrow::Cow; + +impl ArchivedMessageMetadataContents { + pub fn is_html_part(&self, part_id: u16) -> bool { + self.html_body.iter().any(|&id| id == part_id) + } + + pub fn is_text_part(&self, part_id: u16) -> bool { + self.text_body.iter().any(|&id| id == part_id) + } +} + +impl ArchivedMessageMetadataPart { + pub fn language(&self) -> Option { + self.header_value(&MetadataHeaderName::ContentLanguage) + .and_then(|v| { + Language::from_iso_639(v.as_text()?) + .unwrap_or(Language::Unknown) + .into() + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AddressElement { + Name, + Address, + GroupName, +} + +pub trait VisitText { + fn visit_addresses(&self, visitor: impl FnMut(AddressElement, &str)); + fn visit_text<'x>(&'x self, visitor: impl FnMut(&'x str)); + fn into_visit_text(self, visitor: impl FnMut(String)); +} + +impl VisitText for HeaderValue<'_> { + fn visit_addresses(&self, mut visitor: impl FnMut(AddressElement, &str)) { + match self { + HeaderValue::Address(Address::List(addr_list)) => { + for addr in addr_list { + if let Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + HeaderValue::Address(Address::Group(groups)) => { + for group in groups { + if let Some(name) = &group.name { + visitor(AddressElement::GroupName, name); + } + + for addr in &group.addresses { + if let Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + } + _ => (), + } + } + + fn visit_text<'x>(&'x self, mut visitor: impl FnMut(&'x str)) { + match &self { + HeaderValue::Text(text) => { + visitor(text.as_ref()); + } + HeaderValue::TextList(texts) => { + for text in texts { + visitor(text.as_ref()); + } + } + _ => (), + } + } + + fn into_visit_text(self, mut visitor: impl FnMut(String)) { + match self { + HeaderValue::Text(text) => { + visitor(text.into_owned()); + } + HeaderValue::TextList(texts) => { + for text in texts { + visitor(text.into_owned()); + } + } + _ => (), + } + } +} + +pub trait VisitTextArchived { + fn visit_addresses(&self, visitor: impl FnMut(AddressElement, &str)); + fn visit_text(&self, visitor: impl FnMut(&str)); +} + +impl VisitTextArchived for MetadataHeaderValue { + fn visit_addresses(&self, mut visitor: impl FnMut(AddressElement, &str)) { + match self { + MetadataHeaderValue::AddressList(addr_list) => { + for addr in addr_list.iter() { + if let Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + MetadataHeaderValue::AddressGroup(groups) => { + for group in groups.iter() { + if let Some(name) = &group.name { + visitor(AddressElement::GroupName, name); + } + + for addr in group.addresses.iter() { + if let Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + } + _ => (), + } + } + + fn visit_text(&self, mut visitor: impl FnMut(&str)) { + match &self { + MetadataHeaderValue::Text(text) => { + visitor(text.as_ref()); + } + MetadataHeaderValue::TextList(texts) => { + for text in texts.iter() { + visitor(text.as_ref()); + } + } + _ => (), + } + } +} + +impl VisitTextArchived for ArchivedMetadataHeaderValue { + fn visit_addresses(&self, mut visitor: impl FnMut(AddressElement, &str)) { + match self { + ArchivedMetadataHeaderValue::AddressList(addr_list) => { + for addr in addr_list.iter() { + if let ArchivedOption::Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let ArchivedOption::Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + ArchivedMetadataHeaderValue::AddressGroup(groups) => { + for group in groups.iter() { + if let ArchivedOption::Some(name) = &group.name { + visitor(AddressElement::GroupName, name); + } + + for addr in group.addresses.iter() { + if let ArchivedOption::Some(name) = &addr.name { + visitor(AddressElement::Name, name); + } + if let ArchivedOption::Some(addr) = &addr.address { + visitor(AddressElement::Address, addr); + } + } + } + } + _ => (), + } + } + + fn visit_text(&self, mut visitor: impl FnMut(&str)) { + match &self { + ArchivedMetadataHeaderValue::Text(text) => { + visitor(text.as_ref()); + } + ArchivedMetadataHeaderValue::TextList(texts) => { + for text in texts.iter() { + visitor(text.as_ref()); + } + } + _ => (), + } + } +} + +pub trait TrimTextValue { + fn trim_text(self, length: usize) -> Self; +} + +impl TrimTextValue for HeaderValue<'_> { + fn trim_text(self, length: usize) -> Self { + match self { + HeaderValue::Address(Address::List(v)) => { + HeaderValue::Address(Address::List(v.trim_text(length))) + } + HeaderValue::Address(Address::Group(v)) => { + HeaderValue::Address(Address::Group(v.trim_text(length))) + } + HeaderValue::Text(v) => HeaderValue::Text(v.trim_text(length)), + HeaderValue::TextList(v) => HeaderValue::TextList(v.trim_text(length)), + v => v, + } + } +} + +impl TrimTextValue for Addr<'_> { + fn trim_text(self, length: usize) -> Self { + Self { + name: self.name.map(|v| v.trim_text(length)), + address: self.address.map(|v| v.trim_text(length)), + } + } +} + +impl TrimTextValue for Group<'_> { + fn trim_text(self, length: usize) -> Self { + Self { + name: self.name.map(|v| v.trim_text(length)), + addresses: self.addresses.trim_text(length), + } + } +} + +impl TrimTextValue for &str { + fn trim_text(self, length: usize) -> Self { + if self.len() < length { + self + } else { + let mut index = 0; + + for (i, _) in self.char_indices() { + if i > length { + break; + } + index = i; + } + + &self[..index] + } + } +} + +impl TrimTextValue for Cow<'_, str> { + fn trim_text(self, length: usize) -> Self { + if self.len() < length { + self + } else { + let mut result = String::with_capacity(length); + for (i, c) in self.char_indices() { + if i > length { + break; + } + result.push(c); + } + result.into() + } + } +} + +impl TrimTextValue for Vec { + fn trim_text(self, length: usize) -> Self { + self.into_iter().map(|v| v.trim_text(length)).collect() + } +} diff --git a/crates/email/src/message/index/metadata.rs b/crates/email/src/message/index/metadata.rs new file mode 100644 index 0000000..50eff87 --- /dev/null +++ b/crates/email/src/message/index/metadata.rs @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::{ + index::{IndexMessage, MAX_MESSAGE_PARTS, PREVIEW_LENGTH}, + metadata::{ + ArchivedMessageMetadata, ArchivedMessageMetadataPart, ArchivedMetadataHeaderName, + MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK, MessageData, MessageMetadata, + MessageMetadataPart, build_metadata_contents, + }, +}; +use common::storage::index::ObjectIndexBuilder; +use mail_parser::{ + PartType, + decoders::html::html_to_text, + parsers::{fields::thread::thread_name, preview::preview_text}, +}; +use store::{ + Serialize, + write::{Archiver, BatchBuilder, BlobLink, BlobOp, IndexPropertyClass, ValueClass}, +}; +use trc::AddContext; +use types::{blob_hash::BlobHash, field::EmailField}; +use utils::cheeky_hash::CheekyHash; + +impl MessageMetadata { + #[inline(always)] + pub fn root_part(&self) -> &MessageMetadataPart { + &self.contents[0].parts[0] + } + + pub fn index(self, batch: &mut BatchBuilder, set: bool) -> trc::Result<()> { + if set { + batch + .set( + BlobOp::Link { + hash: self.blob_hash.clone(), + to: BlobLink::Document, + }, + Vec::new(), + ) + .set(EmailField::Metadata, Archiver::new(self).serialize()?); + } else { + batch + .clear(BlobOp::Link { + hash: self.blob_hash.clone(), + to: BlobLink::Document, + }) + .clear(EmailField::Metadata); + } + + Ok(()) + } +} + +impl ArchivedMessageMetadata { + #[inline(always)] + pub fn root_part(&self) -> &ArchivedMessageMetadataPart { + &self.contents[0].parts[0] + } + + pub fn unindex(&self, batch: &mut BatchBuilder) { + // Delete metadata + let thread_name = self + .contents + .first() + .and_then(|c| c.parts.first()) + .and_then(|p| { + p.headers.iter().rev().find_map(|h| { + if let ArchivedMetadataHeaderName::Subject = &h.name { + h.value.as_text() + } else { + None + } + }) + }) + .map(thread_name) + .unwrap_or_default(); + + batch + .clear(EmailField::Metadata) + .clear(ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: CheekyHash::new(if !thread_name.is_empty() { + thread_name + } else { + "!" + }), + })) + .clear(BlobOp::Link { + hash: BlobHash::from(&self.blob_hash), + to: BlobLink::Document, + }); + } +} + +impl IndexMessage for BatchBuilder { + fn index_message<'x>( + &mut self, + tenant_id: Option, + mut message: mail_parser::Message<'x>, + extra_headers: Vec, + mut extra_headers_parsed: Vec>, + blob_hash: BlobHash, + data: MessageData, + received_at: u64, + ) -> trc::Result<&mut Self> { + let mut has_attachments = false; + let mut preview = None; + let preview_part_id = message + .text_body + .first() + .or_else(|| message.html_body.first()) + .copied() + .unwrap_or(u32::MAX); + + for (part_id, part) in message.parts.iter().take(MAX_MESSAGE_PARTS).enumerate() { + let part_id = part_id as u32; + match &part.body { + mail_parser::PartType::Text(text) => { + if part_id == preview_part_id { + preview = + preview_text(text.replace('\r', "").into(), PREVIEW_LENGTH).into(); + } + + if !message.text_body.contains(&part_id) + && !message.html_body.contains(&part_id) + { + has_attachments = true; + } + } + mail_parser::PartType::Html(html) => { + let text = html_to_text(html); + if part_id == preview_part_id { + preview = + preview_text(text.replace('\r', "").into(), PREVIEW_LENGTH).into(); + } + + if !message.text_body.contains(&part_id) + && !message.html_body.contains(&part_id) + { + has_attachments = true; + } + } + mail_parser::PartType::Binary(_) | mail_parser::PartType::Message(_) + if !has_attachments => + { + has_attachments = true; + } + _ => {} + } + } + + // Build raw headers + let root_part = message.root_part(); + let mut raw_headers = Vec::with_capacity( + (root_part.offset_body - root_part.offset_header) as usize + extra_headers.len(), + ); + raw_headers.extend_from_slice(&extra_headers); + raw_headers.extend_from_slice( + message + .raw_message + .as_ref() + .get(root_part.offset_header as usize..root_part.offset_body as usize) + .unwrap_or_default(), + ); + + // Add additional headers to message + let blob_body_offset = if !extra_headers.is_empty() { + // Add extra headers to root part + let offset_start = extra_headers.len() as u32; + let mut part_iter_stack = Vec::new(); + let mut part_iter = message.parts.iter_mut(); + + loop { + if let Some(part) = part_iter.next() { + // Increment header offsets + for header in part.headers.iter_mut() { + header.offset_field += offset_start; + header.offset_start += offset_start; + header.offset_end += offset_start; + } + + // Adjust part offsets + part.offset_body += offset_start; + part.offset_end += offset_start; + part.offset_header += offset_start; + + if let PartType::Message(sub_message) = &mut part.body + && sub_message.root_part().offset_header != 0 + { + part_iter_stack.push(part_iter); + part_iter = sub_message.parts.iter_mut(); + } + } else if let Some(iter) = part_iter_stack.pop() { + part_iter = iter; + } else { + break; + } + } + + // Add extra headers to root part + let root_part = &mut message.parts[0]; + extra_headers_parsed.append(&mut root_part.headers); + root_part.offset_header = 0; + root_part.headers = extra_headers_parsed; + root_part.offset_body - offset_start + } else { + message.root_part().offset_body + }; + + // Build metadata + let metadata = MessageMetadata { + preview: preview.unwrap_or_default().into_owned().into_boxed_str(), + raw_headers: raw_headers.into_boxed_slice(), + contents: build_metadata_contents(message), + blob_hash, + blob_body_offset, + rcvd_attach: (if has_attachments { + MESSAGE_HAS_ATTACHMENT + } else { + 0 + }) | (received_at & MESSAGE_RECEIVED_MASK), + }; + + self.set( + BlobOp::Link { + hash: metadata.blob_hash.clone(), + to: BlobLink::Document, + }, + Vec::new(), + ) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_tenant_id(tenant_id) + .with_changes(data), + ) + .caused_by(trc::location!())? + .set( + EmailField::Metadata, + Archiver::new(metadata) + .serialize() + .caused_by(trc::location!())?, + ); + + Ok(self) + } +} diff --git a/crates/email/src/message/index/mod.rs b/crates/email/src/message/index/mod.rs new file mode 100644 index 0000000..c0c397d --- /dev/null +++ b/crates/email/src/message/index/mod.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + mailbox::{JUNK_ID, TRASH_ID}, + message::metadata::{ArchivedMessageData, MessageData}, +}; +use common::storage::index::{IndexItem, IndexValue, IndexableObject}; +use store::write::now; +use types::{blob_hash::BlobHash, collection::SyncCollection, field::EmailField}; + +pub mod extractors; +pub mod metadata; +pub mod search; + +pub(super) const MAX_MESSAGE_PARTS: usize = 1000; +pub const PREVIEW_LENGTH: usize = 256; + +impl IndexableObject for MessageData { + fn index_values(&self) -> impl Iterator> { + let mut mailboxes = Vec::with_capacity(self.mailboxes.len()); + let mut is_in_trash = false; + + for mailbox in &self.mailboxes { + mailboxes.push(mailbox.mailbox_id); + is_in_trash |= mailbox.mailbox_id == TRASH_ID || mailbox.mailbox_id == JUNK_ID; + } + + [ + IndexValue::Property { + field: EmailField::DeletedAt.into(), + value: if is_in_trash { + IndexItem::from(now()) + } else { + IndexItem::None + }, + }, + IndexValue::Quota { used: self.size }, + IndexValue::LogItem { + sync_collection: SyncCollection::Email, + prefix: self.thread_id.into(), + }, + IndexValue::LogContainerProperty { + sync_collection: SyncCollection::Thread, + ids: vec![self.thread_id], + }, + IndexValue::LogContainerProperty { + sync_collection: SyncCollection::Email, + ids: mailboxes, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedMessageData { + fn index_values(&self) -> impl Iterator> { + let mut mailboxes = Vec::with_capacity(self.mailboxes.len()); + let mut is_in_trash = false; + + for mailbox in self.mailboxes.iter() { + let mailbox_id = mailbox.mailbox_id.to_native(); + mailboxes.push(mailbox_id); + is_in_trash |= mailbox_id == TRASH_ID || mailbox_id == JUNK_ID; + } + + [ + IndexValue::Property { + field: EmailField::DeletedAt.into(), + value: if is_in_trash { + IndexItem::from(now()) + } else { + IndexItem::None + }, + }, + IndexValue::Quota { + used: self.size.to_native(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::Email, + prefix: self.thread_id.to_native().into(), + }, + IndexValue::LogContainerProperty { + sync_collection: SyncCollection::Thread, + ids: vec![self.thread_id.to_native()], + }, + IndexValue::LogContainerProperty { + sync_collection: SyncCollection::Email, + ids: mailboxes, + }, + ] + .into_iter() + } +} + +pub(super) trait IndexMessage { + #[allow(clippy::too_many_arguments)] + fn index_message<'x>( + &mut self, + tenant_id: Option, + message: mail_parser::Message<'x>, + extra_headers: Vec, + extra_headers_parsed: Vec>, + blob_hash: BlobHash, + data: MessageData, + received_at: u64, + ) -> trc::Result<&mut Self>; +} diff --git a/crates/email/src/message/index/search.rs b/crates/email/src/message/index/search.rs new file mode 100644 index 0000000..a7ed9ae --- /dev/null +++ b/crates/email/src/message/index/search.rs @@ -0,0 +1,344 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::message::{ + index::{MAX_MESSAGE_PARTS, extractors::VisitTextArchived}, + metadata::{ + ArchivedMessageMetadata, ArchivedMetadataHeaderName, ArchivedMetadataHeaderValue, + ArchivedMetadataPartType, DecodedPartContent, MESSAGE_HAS_ATTACHMENT, + MESSAGE_RECEIVED_MASK, MetadataHeaderName, + }, +}; +use mail_parser::{DateTime, decoders::html::html_to_text, parsers::fields::thread::thread_name}; +use nlp::{ + language::{ + Language, + detect::{LanguageDetector, MIN_LANGUAGE_SCORE}, + }, + tokenizers::word::WordTokenizer, +}; +use store::{ + ahash::AHashSet, + backend::MAX_TOKEN_LENGTH, + search::{EmailSearchField, IndexDocument, SearchField}, + write::SearchIndex, +}; +use utils::chained_bytes::ChainedBytes; + +impl ArchivedMessageMetadata { + pub fn index_document( + &self, + account_id: u32, + document_id: u32, + raw_message: &[u8], + index_fields: &AHashSet, + default_language: Language, + ) -> IndexDocument { + let mut detector = LanguageDetector::new(); + let mut language = Language::Unknown; + let message_contents = &self.contents[0]; + let mut document = IndexDocument::new(SearchIndex::Email) + .with_account_id(account_id) + .with_document_id(document_id); + + let raw_message = ChainedBytes::new(self.raw_headers.as_ref()).with_last( + raw_message + .get(self.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::ReceivedAt)) + { + document.index_unsigned( + SearchField::Email(EmailSearchField::ReceivedAt), + self.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK, + ); + } + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::Size)) + { + document.index_unsigned( + SearchField::Email(EmailSearchField::Size), + raw_message.len() as u32, + ); + } + + for (part_id, part) in message_contents + .parts + .iter() + .take(MAX_MESSAGE_PARTS) + .enumerate() + { + let part_language = part.language().unwrap_or(language); + if part_id == 0 { + language = part_language; + + for header in part.headers.iter().rev() { + match &header.name { + ArchivedMetadataHeaderName::From => { + if index_fields.is_empty() + || index_fields + .contains(&SearchField::Email(EmailSearchField::From)) + { + header.value.visit_addresses(|_, value| { + document.index_text( + SearchField::Email(EmailSearchField::From), + value, + Language::None, + ); + }); + } + } + ArchivedMetadataHeaderName::To => { + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::To)) + { + header.value.visit_addresses(|_, value| { + document.index_text( + SearchField::Email(EmailSearchField::To), + value, + Language::None, + ); + }); + } + } + ArchivedMetadataHeaderName::Cc => { + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::Cc)) + { + header.value.visit_addresses(|_, value| { + document.index_text( + SearchField::Email(EmailSearchField::Cc), + value, + Language::None, + ); + }); + } + } + ArchivedMetadataHeaderName::Bcc => { + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::Bcc)) + { + header.value.visit_addresses(|_, value| { + document.index_text( + SearchField::Email(EmailSearchField::Bcc), + value, + Language::None, + ); + }); + } + } + ArchivedMetadataHeaderName::Subject => { + if (index_fields.is_empty() + || index_fields + .contains(&SearchField::Email(EmailSearchField::Subject))) + && let Some(subject) = header.value.as_text() + { + let subject = thread_name(subject); + + if part_language.is_unknown() { + detector.detect(subject, MIN_LANGUAGE_SCORE); + } + + document.index_text( + SearchField::Email(EmailSearchField::Subject), + subject, + part_language, + ); + } + } + ArchivedMetadataHeaderName::Date => { + if (index_fields.is_empty() + || index_fields + .contains(&SearchField::Email(EmailSearchField::SentAt))) + && let Some(date) = header.value.as_datetime() + { + document.index_integer( + SearchField::Email(EmailSearchField::SentAt), + DateTime::from(date).to_timestamp(), + ); + } + } + _ => { + #[cfg(not(feature = "test_mode"))] + let index_headers = index_fields + .contains(&SearchField::Email(EmailSearchField::Headers)); + + #[cfg(feature = "test_mode")] + let index_headers = true; + + if index_headers { + let mut value = String::new(); + match &header.value { + ArchivedMetadataHeaderValue::AddressList(_) + | ArchivedMetadataHeaderValue::AddressGroup(_) => { + header.value.visit_addresses(|_, addr| { + if !value.is_empty() { + value.push(' '); + } + value.push_str(addr); + }); + } + ArchivedMetadataHeaderValue::Text(_) + | ArchivedMetadataHeaderValue::TextList(_) => { + header.value.visit_text(|text| { + if !value.is_empty() { + value.push(' '); + } + value.push_str(text); + }); + } + _ => { + if let Some(raw_value) = + raw_message.get(header.value_range()) + { + let raw_value = std::str::from_utf8(raw_value.as_ref()) + .unwrap_or_default(); + + for word in + WordTokenizer::new(raw_value, MAX_TOKEN_LENGTH) + { + if !value.is_empty() { + value.push(' '); + } + value.push_str(word.word.as_ref()); + } + } + } + } + + document.insert_key_value( + EmailSearchField::Headers, + header.name.as_str(), + value, + ); + } + } + } + } + } + + let part_id = part_id as u16; + match &part.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => { + let text = match (part.decode_contents(&raw_message), &part.body) { + (DecodedPartContent::Text(text), ArchivedMetadataPartType::Text) => text, + (DecodedPartContent::Text(html), ArchivedMetadataPartType::Html) => { + html_to_text(html.as_ref()).into() + } + _ => unreachable!(), + }; + + if message_contents.is_html_part(part_id) + || message_contents.is_text_part(part_id) + { + if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::Body)) + { + if part_language.is_unknown() { + detector.detect(text.as_ref(), MIN_LANGUAGE_SCORE); + } + + document.index_text( + SearchField::Email(EmailSearchField::Body), + text.as_ref(), + part_language, + ); + } + } else if index_fields.is_empty() + || index_fields.contains(&SearchField::Email(EmailSearchField::Attachment)) + { + if part_language.is_unknown() { + detector.detect(text.as_ref(), MIN_LANGUAGE_SCORE); + } + + document.index_text( + SearchField::Email(EmailSearchField::Attachment), + text.as_ref(), + part_language, + ); + } + } + ArchivedMetadataPartType::Message(nested_message_id) + if index_fields.is_empty() + || index_fields + .contains(&SearchField::Email(EmailSearchField::Attachment)) => + { + let nested_message = self.message_id(*nested_message_id); + let nested_message_language = nested_message + .root_part() + .language() + .unwrap_or(Language::Unknown); + if let Some(ArchivedMetadataHeaderValue::Text(subject)) = nested_message + .root_part() + .header_value(&MetadataHeaderName::Subject) + { + if nested_message_language.is_unknown() { + detector.detect(subject.as_ref(), MIN_LANGUAGE_SCORE); + } + + document.index_text( + SearchField::Email(EmailSearchField::Attachment), + subject.as_ref(), + nested_message_language, + ); + } + + for sub_part in nested_message.parts.iter().take(MAX_MESSAGE_PARTS) { + let language = sub_part.language().unwrap_or(nested_message_language); + match &sub_part.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => { + let text = match ( + sub_part.decode_contents(&raw_message), + &sub_part.body, + ) { + ( + DecodedPartContent::Text(text), + ArchivedMetadataPartType::Text, + ) => text, + ( + DecodedPartContent::Text(html), + ArchivedMetadataPartType::Html, + ) => html_to_text(html.as_ref()).into(), + _ => unreachable!(), + }; + + if language.is_unknown() { + detector.detect(text.as_ref(), MIN_LANGUAGE_SCORE); + } + + document.index_text( + SearchField::Email(EmailSearchField::Attachment), + text.as_ref(), + language, + ); + } + _ => (), + } + } + } + _ => {} + } + } + + #[cfg(not(feature = "test_mode"))] + document.set_unknown_language( + detector + .most_frequent_language() + .unwrap_or(default_language), + ); + + #[cfg(feature = "test_mode")] + document.set_unknown_language(default_language); + + document.index_bool( + EmailSearchField::HasAttachment, + self.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT != 0, + ); + document + } +} diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs new file mode 100644 index 0000000..7538ee4 --- /dev/null +++ b/crates/email/src/message/ingest.rs @@ -0,0 +1,1101 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::crypto::{EncryptMessage, EncryptMessageError}; +use crate::{ + cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, + mailbox::{INBOX_ID, JUNK_ID, SENT_ID, TRASH_ID, UidMailbox}, + message::{ + crypto::EncryptionFlags, + index::{IndexMessage, extractors::VisitText}, + metadata::{MessageData, MessageMetadata}, + }, +}; +use common::{Server, auth::AccessToken}; +use groupware::{ + calendar::itip::{ItipIngest, ItipIngestError}, + scheduling::{ItipError, ItipMessages}, +}; +use mail_parser::{ + DateTime, Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType, + parsers::fields::thread::thread_name, +}; +use registry::{ + schema::{ + enums::IndexDocumentType, + prelude::{ObjectType, Permission, Property}, + structs::{SpamTrainingSample, Task, TaskIndexDocument, TaskMergeThreads, TaskStatus}, + }, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId, map::Map}, +}; +use std::future::Future; +use std::{borrow::Cow, cmp::Ordering, fmt::Write, time::Instant}; +use store::write::{AlignedBytes, Archive, RegistryClass}; +use store::{ + IndexKeyPrefix, IterateParams, SerializeInfallible, U32_LEN, ValueKey, + ahash::AHashMap, + write::{ + AssignedId, AssignedIds, BatchBuilder, BlobLink, BlobOp, IndexPropertyClass, ValueClass, + key::DeserializeBigEndian, now, + }, +}; +use trc::{AddContext, MessageIngestEvent, SpamEvent}; +use types::{ + blob::{BlobClass, BlobId}, + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, + field::{ContactField, EmailField, MailboxField}, + id::Id, + keyword::Keyword, + special_use::SpecialUse, +}; +use utils::{cheeky_hash::CheekyHash, sanitize_email, snowflake::SnowflakeIdGenerator}; + +#[derive(Default)] +pub struct IngestedEmail { + pub document_id: u32, + pub thread_id: u32, + pub change_id: u64, + pub blob_id: BlobId, + pub size: usize, + pub imap_uids: Vec, +} + +pub struct IngestEmail<'x> { + pub raw_message: &'x [u8], + pub blob_hash: Option<&'x BlobHash>, + pub message: Option>, + pub access_token: &'x AccessToken, + pub mailbox_ids: Vec, + pub keywords: Vec, + pub received_at: Option, + pub source: IngestSource<'x>, + pub session_id: u64, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum IngestSource<'x> { + Smtp { + deliver_to: &'x str, + is_sender_authenticated: bool, + is_spam: bool, + }, + Jmap { + train_classifier: bool, + }, + Imap { + train_classifier: bool, + }, + Restore, +} + +pub trait EmailIngest: Sync + Send { + fn email_ingest( + &self, + params: IngestEmail, + ) -> impl Future> + Send; + fn find_thread_id( + &self, + account_id: u32, + thread_name: &str, + message_ids: &[CheekyHash], + ) -> impl Future> + Send; + fn assign_email_ids( + &self, + account_id: u32, + mailbox_ids: impl IntoIterator + Sync + Send, + generate_email_id: bool, + ) -> impl Future + 'static>> + Send; + fn add_account_spam_sample( + &self, + batch: &mut BatchBuilder, + account_id: u32, + document_id: u32, + is_spam: bool, + span_id: u64, + ) -> impl Future> + Send; + + #[allow(clippy::too_many_arguments)] + fn add_spam_sample( + &self, + account_id: u32, + batch: &mut BatchBuilder, + hash: BlobHash, + from: String, + subject: String, + is_spam: bool, + hold_sample: bool, + span_id: u64, + ); +} + +pub struct ThreadResult { + pub thread_id: Option, + pub thread_hash: CheekyHash, + pub merge_ids: Vec, + pub duplicate_ids: Vec, +} + +impl EmailIngest for Server { + #[allow(clippy::blocks_in_conditions)] + async fn email_ingest(&self, mut params: IngestEmail<'_>) -> trc::Result { + // Check quota + let start_time = Instant::now(); + let account_id = params.access_token.account_id(); + let tenant_id = params.access_token.tenant_id(); + let mut raw_message_len = params.raw_message.len() as u64; + let account = self.account(account_id).await.caused_by(trc::location!())?; + self.has_available_quota(&account, raw_message_len) + .await + .caused_by(trc::location!())?; + + // Parse message + let mut raw_message = Cow::from(params.raw_message); + let mut message = params.message.ok_or_else(|| { + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) + .ctx(trc::Key::Code, 550) + .ctx(trc::Key::Reason, "Failed to parse e-mail message.") + })?; + + // Obtain message references and thread name + let mut message_id = None; + let mut message_ids = Vec::new(); + let thread_result = { + let mut subject = ""; + for header in message.root_part().headers().iter().rev() { + match &header.name { + HeaderName::MessageId => header.value.visit_text(|id| { + if !id.is_empty() { + if message_id.is_none() { + message_id = id.to_string().into(); + } + message_ids.push(CheekyHash::new(id.as_bytes())); + } + }), + HeaderName::InReplyTo + | HeaderName::References + | HeaderName::ResentMessageId => { + header.value.visit_text(|id| { + if !id.is_empty() { + message_ids.push(CheekyHash::new(id.as_bytes())); + } + }); + } + HeaderName::Subject if subject.is_empty() => { + subject = thread_name(match &header.value { + HeaderValue::Text(text) => text.as_ref(), + HeaderValue::TextList(list) if !list.is_empty() => { + list.first().unwrap().as_ref() + } + _ => "", + }); + } + _ => (), + } + } + + message_ids.sort_unstable(); + message_ids.dedup(); + + self.find_thread_id(account_id, subject, &message_ids) + .await? + }; + + // Skip duplicate messages for SMTP ingestion + if !thread_result.duplicate_ids.is_empty() && params.source.is_smtp() { + // Fetch cached messages + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + // Skip duplicate messages + let target_mailbox_id = params.mailbox_ids.first().copied().unwrap_or(INBOX_ID); + if cache + .in_mailboxes(&[target_mailbox_id, JUNK_ID]) + .any(|m| thread_result.duplicate_ids.contains(&m.document_id)) + { + trc::event!( + MessageIngest(MessageIngestEvent::Duplicate), + SpanId = params.session_id, + AccountId = account_id, + MessageId = message_id, + ); + + return Ok(IngestedEmail { + document_id: 0, + thread_id: 0, + change_id: u64::MAX, + blob_id: BlobId::default(), + imap_uids: Vec::new(), + size: 0, + }); + } + } + + // Spam classification and training + let mut train_spam = None; + let mut extra_headers = String::new(); + let mut extra_headers_parsed = Vec::new(); + let mut itip_messages = Vec::new(); + let is_spam = match params.source { + IngestSource::Smtp { + deliver_to, + is_sender_authenticated, + mut is_spam, + } => { + // Add delivered to header + if self.core.smtp.session.data.add_delivered_to { + extra_headers = format!("Delivered-To: {deliver_to}\r\n"); + extra_headers_parsed.push(Header { + name: HeaderName::DeliveredTo, + value: HeaderValue::Text(deliver_to.into()), + offset_field: 0, + offset_start: 13, + offset_end: extra_headers.len() as u32, + }); + } + + // Spam training on confirmed false positives + if self.core.spam.enabled { + let mut overridden = None; + // If the message is classified as spam, check whether the + // sender address is present in the user's address book. + if is_spam + && self.core.spam.card_is_ham + && let Some(sender) = message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .and_then(sanitize_email) + && sender != deliver_to + && is_sender_authenticated + && self + .document_exists( + account_id, + Collection::ContactCard, + ContactField::Email, + sender.as_bytes(), + ) + .await + .caused_by(trc::location!())? + { + is_spam = false; + if self + .core + .spam + .classifier + .as_ref() + .is_some_and(|c| c.auto_learn_card_is_ham) + { + train_spam = Some(false); + } + overridden = Some("card-exists"); + } + + // Check if the message is a trusted reply to a previous message + if is_spam + && self.core.spam.trusted_reply + && let Some(thread_id) = thread_result.thread_id + { + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + let sent_folder_id = cache + .mailbox_by_role(&SpecialUse::Sent) + .map(|m| m.document_id) + .unwrap_or(SENT_ID); + + if cache + .in_thread(thread_id) + .any(|m| m.mailboxes.iter().any(|mb| mb.mailbox_id == sent_folder_id)) + { + is_spam = false; + if self + .core + .spam + .classifier + .as_ref() + .is_some_and(|c| c.auto_learn_reply_ham) + { + train_spam = Some(false); + } + overridden = Some("trusted-reply"); + } + } + + // Add Spam-Status header + const HEADER: &str = "X-Spam-Status"; + let offset_field = extra_headers.len(); + let offset_start = offset_field + HEADER.len() + 1; + let result = if is_spam { "Yes" } else { "No" }; + if let Some(reason) = overridden { + let _ = write!( + &mut extra_headers, + "{HEADER}: {result}, reason={reason}\r\n", + ); + } else { + let _ = write!(&mut extra_headers, "{HEADER}: {result}\r\n",); + } + + extra_headers_parsed.push(Header { + name: HeaderName::Other(HEADER.into()), + value: HeaderValue::Text( + extra_headers[offset_start + 1..extra_headers.len() - 2] + .to_string() + .into(), + ), + offset_field: offset_field as u32, + offset_start: offset_start as u32, + offset_end: extra_headers.len() as u32, + }); + + if is_spam && params.mailbox_ids == [INBOX_ID] { + params.mailbox_ids[0] = JUNK_ID; + params.keywords.push(Keyword::Junk); + } + } + + // iMIP processing + if self.core.groupware.itip_enabled + && !is_spam + && is_sender_authenticated + && params + .access_token + .has_permission(Permission::CalendarSchedulingReceive) + { + let account_info = self + .build_account_info(account.clone()) + .await + .caused_by(trc::location!())?; + let mut sender = None; + for part in &message.parts { + if part.content_type().is_some_and(|ct| { + ct.ctype().eq_ignore_ascii_case("text") + && ct + .subtype() + .is_some_and(|st| st.eq_ignore_ascii_case("calendar")) + && ct.has_attribute("method") + }) && let Some(itip_message) = part.text_contents() + { + if itip_message.len() < self.core.groupware.itip_inbound_max_ical_size { + if let Some(sender) = sender.get_or_insert_with(|| { + message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .and_then(sanitize_email) + }) { + match self + .itip_ingest( + &account_info, + sender, + deliver_to, + itip_message, + ) + .await + { + Ok(message) => { + if let Some(message) = message { + itip_messages.push(message); + } + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageReceived), + SpanId = params.session_id, + From = sender.to_string(), + AccountId = account_id, + ); + } + Err(ItipIngestError::Message(itip_error)) => { + match itip_error { + ItipError::NothingToSend + | ItipError::OtherSchedulingAgent => (), + err => { + trc::event!( + Calendar( + trc::CalendarEvent::ItipMessageError + ), + SpanId = params.session_id, + From = sender.to_string(), + AccountId = account_id, + Details = err.to_string(), + ) + } + } + } + Err(ItipIngestError::Internal(err)) => { + trc::error!(err.caused_by(trc::location!())); + } + } + } + } else { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + SpanId = params.session_id, + From = message + .from() + .and_then(|a| a.first()) + .and_then(|a| a.address()) + .map(|a| a.to_string()), + AccountId = account_id, + Details = "iMIP message too large", + Limit = self.core.groupware.itip_inbound_max_ical_size, + Size = itip_message.len(), + ) + } + } + } + } + + is_spam + } + IngestSource::Jmap { train_classifier } | IngestSource::Imap { train_classifier } => { + // Determine spam training + if train_classifier && self.core.spam.enabled { + if params.keywords.contains(&Keyword::Junk) { + train_spam = Some(true); + } else if params.keywords.contains(&Keyword::NotJunk) { + if !params.mailbox_ids.contains(&TRASH_ID) { + train_spam = Some(false); + } + } else if params.mailbox_ids[0] == JUNK_ID { + train_spam = Some(true); + } else if params.mailbox_ids[0] == INBOX_ID { + train_spam = Some(false); + } + } + + // Set receivedAt if not present + if params.received_at.is_none() { + params.received_at = message + .root_part() + .headers() + .iter() + .filter_map(|header| { + if let (HeaderName::Received, HeaderValue::Received(received)) = + (&header.name, &header.value) + { + received + .date + .filter(|dt| dt.is_valid()) + .map(|dt| dt.to_timestamp() as u64) + } else { + None + } + }) + .max(); + } + + false + } + _ => false, + }; + + // Encrypt message + let do_encrypt = match params.source { + IngestSource::Jmap { .. } | IngestSource::Imap { .. } => { + self.core.email.encrypt + && self.core.email.encrypt_append + && account.flags.encrypt_on_append() + } + IngestSource::Smtp { .. } => self.core.email.encrypt, + IngestSource::Restore => false, + }; + let is_encrypted = if do_encrypt + && !message.is_encrypted() + && let Some(encrypt_keys) = &account.encryption_key + { + match message.encrypt(encrypt_keys, account.flags).await { + Ok(new_raw_message) => { + raw_message = Cow::from(new_raw_message); + raw_message_len = raw_message.len() as u64; + message = MessageParser::default() + .parse(raw_message.as_ref()) + .ok_or_else(|| { + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) + .ctx(trc::Key::Code, 550) + .ctx( + trc::Key::Reason, + "Failed to parse encrypted e-mail message.", + ) + })?; + + // Disable spam training if requested + if !account.flags.can_train_spam_filter() { + train_spam = None; + } + + // Remove contents from parsed message + for part in &mut message.parts { + match &mut part.body { + PartType::Text(txt) | PartType::Html(txt) => { + *txt = Cow::from(""); + } + PartType::Binary(bin) | PartType::InlineBinary(bin) => { + *bin = Cow::from(&[][..]); + } + PartType::Message(_) => { + part.body = PartType::Binary(Cow::from(&[][..])); + } + PartType::Multipart(_) => (), + } + } + + true + } + Err(EncryptMessageError::Error(err)) => { + trc::bail!( + trc::StoreEvent::CryptoError + .into_err() + .caused_by(trc::location!()) + .reason(err) + ); + } + _ => unreachable!(), + } + } else { + false + }; + + // Store blob + let (blob_hash, blob_hold) = if !is_encrypted && let Some(blob_hash) = params.blob_hash { + (blob_hash.clone(), None) + } else { + self.put_temporary_blob(account_id, raw_message.as_ref(), 60) + .await + .map(|(hash, op)| (hash, Some(op))) + .caused_by(trc::location!())? + }; + + // Assign IMAP UIDs + let mut mailbox_ids = Vec::with_capacity(params.mailbox_ids.len()); + let mut imap_uids = Vec::with_capacity(params.mailbox_ids.len()); + let mut ids = self + .assign_email_ids(account_id, params.mailbox_ids.iter().copied(), true) + .await + .caused_by(trc::location!())?; + let document_id = ids.next().unwrap(); + for (uid, mailbox_id) in ids.zip(params.mailbox_ids.iter().copied()) { + mailbox_ids.push(UidMailbox::new(mailbox_id, uid)); + imap_uids.push(uid); + } + + // Build write batch + let mut batch = BatchBuilder::new(); + let mailbox_ids_event = mailbox_ids + .iter() + .map(|m| trc::Value::from(m.mailbox_id)) + .collect::>(); + batch.with_account_id(account_id); + + // Determine thread id + let thread_id = if let Some(thread_id) = thread_result.thread_id { + thread_id + } else { + batch + .with_collection(Collection::Thread) + .with_document(document_id) + .log_container_insert(SyncCollection::Thread); + document_id + }; + + let data = MessageData { + mailboxes: mailbox_ids.into_boxed_slice(), + keywords: params.keywords.into_boxed_slice(), + thread_id, + size: (message.raw_message.len() + extra_headers.len()) as u32, + }; + + // Request spam training + if let Some(learn_spam) = train_spam { + self.add_spam_sample( + account_id, + &mut batch, + params.blob_hash.unwrap_or(&blob_hash).clone(), + message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .unwrap_or_default() + .to_string(), + thread_name(message.subject().unwrap_or_default()).to_string(), + learn_spam, + !is_encrypted, + params.session_id, + ); + } + + batch + .with_collection(Collection::Email) + .with_document(document_id) + .index_message( + tenant_id, + message, + extra_headers.into_bytes(), + extra_headers_parsed, + blob_hash.clone(), + data, + params.received_at.unwrap_or_else(now), + ) + .caused_by(trc::location!())? + .set( + ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: thread_result.thread_hash, + }), + ThreadInfo::serialize(thread_id, &message_ids), + ) + .schedule_task(Task::IndexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: document_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::now(), + })); + + if let Some(blob_hold) = blob_hold { + batch.clear(blob_hold); + } + + // Merge threads if necessary + if !thread_result.merge_ids.is_empty() + || matches!( + params.source, + IngestSource::Jmap { .. } | IngestSource::Imap { .. } + ) + { + batch.schedule_task(Task::MergeThreads(TaskMergeThreads { + account_id: account_id.into(), + status: TaskStatus::now(), + thread_name: thread_result.thread_hash.to_string(), + message_ids: Map::new(message_ids.into_iter().map(|id| id.to_string()).collect()), + })); + } + + // Add iTIP responses to batch + if !itip_messages.is_empty() { + ItipMessages::new(itip_messages) + .queue(&mut batch) + .caused_by(trc::location!())?; + } + + // Insert and obtain ids + let change_id = self + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())? + .last_change_id(account_id)?; + + // Request FTS index + self.notify_task_queue(); + + trc::event!( + MessageIngest(match params.source { + IngestSource::Smtp { .. } => + if !is_spam { + MessageIngestEvent::Ham + } else { + MessageIngestEvent::Spam + }, + IngestSource::Jmap { .. } | IngestSource::Restore => MessageIngestEvent::JmapAppend, + IngestSource::Imap { .. } => MessageIngestEvent::ImapAppend, + }), + SpanId = params.session_id, + AccountId = account_id, + DocumentId = document_id, + MailboxId = mailbox_ids_event, + BlobId = blob_hash.to_hex(), + ChangeId = change_id, + MessageId = message_id, + Size = raw_message_len, + Elapsed = start_time.elapsed(), + ); + + Ok(IngestedEmail { + document_id, + thread_id, + change_id, + blob_id: BlobId { + hash: blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::Email.into(), + document_id, + }, + section: None, + }, + size: raw_message_len as usize, + imap_uids, + }) + } + + async fn find_thread_id( + &self, + account_id: u32, + thread_name: &str, + message_ids: &[CheekyHash], + ) -> trc::Result { + let mut result = ThreadResult { + thread_id: None, + thread_hash: CheekyHash::new(if !thread_name.is_empty() { + thread_name + } else { + "!" + }), + merge_ids: vec![], + duplicate_ids: vec![], + }; + + if message_ids.is_empty() { + return Ok(result); + } + + // Find thread ids + let key_len = IndexKeyPrefix::len() + result.thread_hash.len() + U32_LEN; + let document_id_pos = key_len - U32_LEN; + let mut thread_merge = ThreadMerge::new(); + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: result.thread_hash, + }), + }, + ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: u32::MAX, + class: ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: result.thread_hash, + }), + }, + ) + .ascending(), + |key, value| { + if key.len() == key_len { + // Find matching references + let references = value.get(U32_LEN..).unwrap_or_default(); + + if has_message_id(message_ids, references) { + let document_id = key.deserialize_be_u32(document_id_pos)?; + let thread_id = value.deserialize_be_u32(0)?; + + if message_ids.len() == references.len() / CheekyHash::HASH_SIZE + && references + .as_chunks::<{ CheekyHash::HASH_SIZE }>() + .0 + .iter() + .zip(message_ids.iter()) + .all(|(a, b)| a == b.as_raw_bytes()) + { + result.duplicate_ids.push(document_id); + } + + thread_merge.add(thread_id, document_id); + } + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + match thread_merge.num_thread_ids() { + 0 => Ok(result), + 1 => { + // Happy path, only one thread id + result.thread_id = thread_merge.thread_ids().next().copied(); + Ok(result) + } + _ => { + // Multiple thread ids that this message belongs to, merge them + let thread_merge = thread_merge.merge(); + result.merge_ids = thread_merge.merge_ids; + result.thread_id = Some(thread_merge.thread_id); + Ok(result) + } + } + } + + async fn assign_email_ids( + &self, + account_id: u32, + mailbox_ids: impl IntoIterator + Sync + Send, + generate_email_id: bool, + ) -> trc::Result + 'static> { + // Increment UID next + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + + let mut expected_ids = 0; + if generate_email_id { + batch + .with_collection(Collection::Email) + .add_and_get(ValueClass::DocumentId, 1); + expected_ids += 1; + } + + batch.with_collection(Collection::Mailbox); + + for mailbox_id in mailbox_ids { + batch + .with_document(mailbox_id) + .add_and_get(MailboxField::UidCounter, 1); + expected_ids += 1; + } + + let ids = if expected_ids > 0 { + self.core.storage.data.write(batch.build_all()).await? + } else { + AssignedIds::default() + }; + if ids.ids.len() == expected_ids { + Ok(ids.ids.into_iter().map(|id| match id { + AssignedId::Counter(id) => id as u32, + AssignedId::ChangeId(_) => unreachable!(), + })) + } else { + Err(trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .ctx(trc::Key::Reason, "No all document ids were generated")) + } + } + + async fn add_account_spam_sample( + &self, + batch: &mut BatchBuilder, + account_id: u32, + document_id: u32, + is_spam: bool, + span_id: u64, + ) -> trc::Result<()> { + if self.core.spam.classifier.is_some() + && let Some(archive) = self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await + .caused_by(trc::location!())? + { + let metadata = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let part = metadata.inner.root_part(); + + self.add_spam_sample( + account_id, + batch, + (&metadata.inner.blob_hash).into(), + part.from().unwrap_or_default().to_string(), + thread_name(part.subject().unwrap_or_default()).to_string(), + is_spam, + true, + span_id, + ); + } + + Ok(()) + } + + fn add_spam_sample( + &self, + account_id: u32, + batch: &mut BatchBuilder, + hash: BlobHash, + from: String, + subject: String, + is_spam: bool, + hold_sample: bool, + span_id: u64, + ) { + if let Some(config) = &self.core.spam.classifier { + let mut dt = DateTime::from_timestamp(now() as i64); + dt.hour = 0; + dt.minute = 0; + dt.second = 0; + let until = dt.to_timestamp() as u64 + config.hold_samples_for; + + let sample = SpamTrainingSample { + account_id: Some(Id::from(account_id)), + blob_id: BlobId::new(hash.clone(), BlobClass::default()), + delete_after_use: !hold_sample, + expires_at: UTCDateTime::from_timestamp(until as i64), + from, + is_spam, + subject, + } + .to_pickled_vec(); + + let object_id = ObjectType::SpamTrainingSample.to_id(); + let item_id = SnowflakeIdGenerator::global_id().unwrap_or_default(); + batch + .set( + BlobOp::Link { + hash, + to: BlobLink::Temporary { until }, + }, + ObjectId::new(ObjectType::SpamTrainingSample, item_id.into()).serialize(), + ) + .set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + sample, + ) + .set( + ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (account_id as u64).serialize(), + }), + vec![], + ); + + trc::event!( + Spam(SpamEvent::TrainSampleAdded), + AccountId = batch.last_account_id(), + Details = if is_spam { "spam" } else { "ham" }, + Expires = trc::Value::Timestamp(until), + SpanId = span_id, + ); + } + } +} + +pub fn has_message_id(a: &[CheekyHash], b: &[u8]) -> bool { + let mut i = 0; + let mut j = 0; + + let a_len = a.len(); + let b_len = b.len() / CheekyHash::HASH_SIZE; + + while i < a_len && j < b_len { + match a[i] + .as_raw_bytes() + .as_slice() + .cmp(&b[j * CheekyHash::HASH_SIZE..(j + 1) * CheekyHash::HASH_SIZE]) + { + std::cmp::Ordering::Equal => return true, + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + } + } + + false +} + +impl IngestSource<'_> { + pub fn is_smtp(&self) -> bool { + matches!(self, Self::Smtp { .. }) + } +} + +pub struct ThreadInfo; + +impl ThreadInfo { + pub fn serialize(thread_id: u32, ref_ids: &[CheekyHash]) -> Vec { + let mut buf = Vec::with_capacity(U32_LEN + 1 + ref_ids.len() * CheekyHash::HASH_SIZE); + buf.extend_from_slice(&thread_id.to_be_bytes()); + for ref_id in ref_ids { + buf.extend_from_slice(ref_id.as_raw_bytes()); + } + buf + } +} + +pub struct ThreadMerge { + entries: AHashMap>, +} + +pub struct ThreadMergeResult { + pub thread_id: u32, + pub merge_ids: Vec, +} + +impl ThreadMerge { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + entries: AHashMap::with_capacity(8), + } + } + + pub fn add(&mut self, thread_id: u32, document_id: u32) { + self.entries.entry(thread_id).or_default().push(document_id); + } + + pub fn num_thread_ids(&self) -> usize { + self.entries.len() + } + + pub fn thread_ids(&self) -> impl Iterator { + self.entries.keys() + } + + pub fn thread_groups(&self) -> impl Iterator)> { + self.entries.iter() + } + + pub fn merge_thread_id(&self) -> u32 { + let mut max_thread_id = u32::MAX; + let mut max_count = 0; + + for (thread_id, ids) in &self.entries { + match ids.len().cmp(&max_count) { + Ordering::Greater => { + max_count = ids.len(); + max_thread_id = *thread_id; + } + Ordering::Equal => { + if *thread_id < max_thread_id { + max_thread_id = *thread_id; + } + } + Ordering::Less => (), + } + } + + max_thread_id + } + + pub fn merge(self) -> ThreadMergeResult { + let mut max_thread_id = u32::MAX; + let mut max_count = 0; + let mut merge_ids = Vec::with_capacity(self.entries.len()); + + for (thread_id, ids) in self.entries { + match ids.len().cmp(&max_count) { + Ordering::Greater => { + max_count = ids.len(); + max_thread_id = thread_id; + } + Ordering::Equal => { + if thread_id < max_thread_id { + max_thread_id = thread_id; + } + } + Ordering::Less => (), + } + merge_ids.push(thread_id); + } + + ThreadMergeResult { + thread_id: max_thread_id, + merge_ids, + } + } +} diff --git a/crates/email/src/message/metadata.rs b/crates/email/src/message/metadata.rs new file mode 100644 index 0000000..1110fc6 --- /dev/null +++ b/crates/email/src/message/metadata.rs @@ -0,0 +1,1320 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::mailbox::{ArchivedUidMailbox, UidMailbox}; +use common::storage::index::IndexableAndSerializableObject; +use mail_parser::{ + Addr, Address, Attribute, ContentType, DateTime, Encoding, Group, HeaderName, HeaderValue, + PartType, + decoders::{ + base64::base64_decode, charsets::map::charset_decoder, + quoted_printable::quoted_printable_decode, + }, +}; +use rkyv::{boxed::ArchivedBox, rend::u16_le}; +use std::{borrow::Cow, collections::VecDeque, ops::Range}; +use types::{ + blob_hash::BlobHash, + keyword::{ArchivedKeyword, Keyword}, +}; +use utils::chained_bytes::ChainedBytes; + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub struct MessageData { + pub mailboxes: Box<[UidMailbox]>, + pub keywords: Box<[Keyword]>, + pub thread_id: u32, + pub size: u32, +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub struct MessageMetadata { + pub contents: Box<[MessageMetadataContents]>, + pub rcvd_attach: u64, + pub blob_hash: BlobHash, + pub blob_body_offset: u32, + pub preview: Box, + pub raw_headers: Box<[u8]>, +} + +pub const MESSAGE_HAS_ATTACHMENT: u64 = 1 << 63; +pub const MESSAGE_RECEIVED_MASK: u64 = !MESSAGE_HAS_ATTACHMENT; + +impl IndexableAndSerializableObject for MessageData { + fn is_versioned() -> bool { + true + } +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub struct MessageMetadataContents { + pub html_body: Box<[u16]>, + pub text_body: Box<[u16]>, + pub attachments: Box<[u16]>, + pub parts: Box<[MessageMetadataPart]>, +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub struct MessageMetadataPart { + pub headers: Box<[MetadataHeader]>, + pub body: MetadataPartType, + pub flags: u32, + pub offset_header: u32, + pub offset_body: u32, + pub offset_end: u32, +} + +pub const PART_ENCODING_BASE64: u32 = 1 << 31; +pub const PART_ENCODING_QP: u32 = 1 << 30; +pub const PART_ENCODING_PROBLEM: u32 = 1 << 29; +pub const PART_SIZE_MASK: u32 = !(PART_ENCODING_BASE64 | PART_ENCODING_QP | PART_ENCODING_PROBLEM); + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataHeader { + pub name: MetadataHeaderName, + pub value: MetadataHeaderValue, + pub base_offset: u32, + pub start: u16, + pub end: u16, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub enum MetadataHeaderName { + Other(Box), + Subject, + From, + To, + Cc, + Date, + Bcc, + ReplyTo, + Sender, + Comments, + InReplyTo, + Keywords, + Received, + MessageId, + References, + ReturnPath, + MimeVersion, + ContentDescription, + ContentId, + ContentLanguage, + ContentLocation, + ContentTransferEncoding, + ContentType, + ContentDisposition, + ResentTo, + ResentFrom, + ResentBcc, + ResentCc, + ResentSender, + ResentDate, + ResentMessageId, + ListArchive, + ListHelp, + ListId, + ListOwner, + ListPost, + ListSubscribe, + ListUnsubscribe, + DkimSignature, + ArcAuthenticationResults, + ArcMessageSignature, + ArcSeal, + + // Delivery/Routing + DeliveredTo, + XOriginalTo, + ReturnReceiptTo, + DispositionNotificationTo, + ErrorsTo, + + // Authentication + AuthenticationResults, + ReceivedSpf, + + // Spam/Virus + XSpamStatus, + XSpamScore, + XSpamFlag, + XSpamResult, + + // Priority + Importance, + Priority, + XPriority, + XMSMailPriority, + + // Client/Agent + XMailer, + UserAgent, + XMimeOLE, + + // Network/Origin + XOriginatingIp, + XForwardedTo, + XForwardedFor, + + // Auto-response + AutoSubmitted, + XAutoResponseSuppress, + Precedence, + + // Organization/Threading + Organization, + ThreadIndex, + ThreadTopic, + + // List (additional) + ListUnsubscribePost, + FeedbackId, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub enum MetadataHeaderValue { + AddressList(Box<[MetadataAddress]>), + AddressGroup(Box<[MetadataAddressGroup]>), + Text(Box), + TextList(Box<[Box]>), + DateTime(MetadataDateTime), + ContentType(MetadataContentType), + Empty, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataDateTime { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub tz_hour: i8, + pub tz_minute: u8, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataAddress { + pub name: Option>, + pub address: Option>, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataAddressGroup { + pub name: Option>, + pub addresses: Box<[MetadataAddress]>, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataContentType { + pub c_type: Box, + pub c_subtype: Option>, + pub attributes: Box<[MetadataAttribute]>, +} + +#[derive(Debug, PartialEq, Eq, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +#[rkyv(compare(PartialEq))] +pub struct MetadataAttribute { + pub name: Box, + pub value: Box, +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub enum MetadataPartType { + Text, + Html, + Binary, + InlineBinary, + Message(u16), + Multipart(Box<[u16]>), +} + +impl MessageMetadataContents { + pub fn root_part(&self) -> &MessageMetadataPart { + &self.parts[0] + } +} + +#[derive(Debug)] +pub struct DecodedParts<'x> { + pub raw_messages: Vec>, + pub parts: Vec>, +} + +#[derive(Debug)] +pub enum DecodedRawMessage<'x> { + Borrowed(ChainedBytes<'x>), + Owned(Vec), +} + +#[derive(Debug)] +pub struct DecodedPart<'x> { + pub message_id: usize, + pub part_offset: usize, + pub content: DecodedPartContent<'x>, +} + +#[derive(Debug)] +pub enum DecodedPartContent<'x> { + Text(Cow<'x, str>), + Binary(Cow<'x, [u8]>), +} + +impl<'x> DecodedParts<'x> { + #[inline] + pub fn raw_message(&self, message_id: usize) -> Option<&DecodedRawMessage<'x>> { + self.raw_messages.get(message_id) + } + + #[inline] + pub fn raw_message_section( + &'_ self, + message_id: usize, + range: Range, + ) -> Option> { + self.raw_messages.get(message_id).and_then(|m| m.get(range)) + } + + #[inline] + pub fn part(&self, message_id: usize, part_offset: usize) -> Option<&DecodedPartContent<'x>> { + self.parts + .iter() + .find(|p| p.message_id == message_id && p.part_offset == part_offset) + .map(|p| &p.content) + } + + #[inline] + pub fn text_part(&self, message_id: usize, part_offset: usize) -> Option<&str> { + self.part(message_id, part_offset).and_then(|p| match p { + DecodedPartContent::Text(text) => Some(text.as_ref()), + DecodedPartContent::Binary(_) => None, + }) + } + + #[inline] + pub fn binary_part(&self, message_id: usize, part_offset: usize) -> Option<&[u8]> { + self.part(message_id, part_offset).map(|p| match p { + DecodedPartContent::Text(part) => part.as_bytes(), + DecodedPartContent::Binary(binary) => binary.as_ref(), + }) + } + + #[inline] + pub fn transfer_decoded_contents( + &self, + message_id: usize, + part: &ArchivedMessageMetadataPart, + ) -> Option> { + match self.raw_messages.get(message_id)? { + DecodedRawMessage::Borrowed(chain) => Some(part.contents(chain)), + DecodedRawMessage::Owned(vec) => Some(Cow::Owned( + part.contents(&ChainedBytes::new(vec)).into_owned(), + )), + } + } +} + +impl DecodedPartContent<'_> { + pub fn as_bytes(&self) -> &[u8] { + match self { + DecodedPartContent::Text(text) => text.as_bytes(), + DecodedPartContent::Binary(binary) => binary, + } + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + match self { + DecodedPartContent::Text(text) => text.len(), + DecodedPartContent::Binary(binary) => binary.len(), + } + } + + pub fn as_str(&self) -> &str { + match self { + DecodedPartContent::Text(text) => text, + DecodedPartContent::Binary(binary) => std::str::from_utf8(binary).unwrap_or_default(), + } + } +} + +impl<'x> DecodedRawMessage<'x> { + pub fn get(&'_ self, index: Range) -> Option> { + match self { + DecodedRawMessage::Borrowed(bytes) => bytes.get(index), + DecodedRawMessage::Owned(vec) => vec.get(index).map(Cow::Borrowed), + } + } +} + +impl ArchivedMessageMetadata { + #[inline(always)] + pub fn message_id(&self, message_id: u16_le) -> &ArchivedMessageMetadataContents { + &self.contents[u16::from(message_id) as usize] + } + + pub fn decode_contents<'x>(&self, raw: ChainedBytes<'x>) -> DecodedParts<'x> { + let mut result = DecodedParts { + raw_messages: Vec::with_capacity(self.contents.len()), + parts: Vec::new(), + }; + + for _ in 0..self.contents.len() { + result + .raw_messages + .push(DecodedRawMessage::Borrowed(raw.clone())); + } + + for (message_id, contents) in self.contents.iter().enumerate() { + for part in contents.parts.iter() { + let part_offset = u32::from(part.offset_header) as usize; + match &part.body { + ArchivedMetadataPartType::Text + | ArchivedMetadataPartType::Html + | ArchivedMetadataPartType::Binary + | ArchivedMetadataPartType::InlineBinary => { + match result.raw_messages.get(message_id).unwrap() { + DecodedRawMessage::Borrowed(bytes) => { + result.parts.push(DecodedPart { + message_id, + part_offset, + content: part.decode_contents(bytes), + }); + } + DecodedRawMessage::Owned(bytes) => { + result.parts.push(DecodedPart { + message_id, + part_offset, + content: match part.decode_contents(&ChainedBytes::new(bytes)) { + DecodedPartContent::Text(text) => { + DecodedPartContent::Text(text.into_owned().into()) + } + DecodedPartContent::Binary(binary) => { + DecodedPartContent::Binary(binary.into_owned().into()) + } + }, + }); + } + } + } + ArchivedMetadataPartType::Message(nested_message_id) => { + let sub_contents = + if (part.flags & (PART_ENCODING_BASE64 | PART_ENCODING_QP)) != 0 { + match result.raw_messages.get(message_id).unwrap() { + DecodedRawMessage::Borrowed(bytes) => { + part.contents(bytes).into_owned() + } + DecodedRawMessage::Owned(bytes) => { + let bytes = ChainedBytes::new(bytes); + part.contents(&bytes).into_owned() + } + } + } else if let Some(DecodedRawMessage::Owned(bytes)) = + result.raw_messages.get(message_id) + { + bytes.clone() + } else { + continue; + }; + + result.raw_messages[usize::from(*nested_message_id)] = + DecodedRawMessage::Owned(sub_contents); + } + _ => {} + } + } + } + + result + } +} + +impl ArchivedMessageMetadataPart { + pub fn contents<'x>(&self, raw_message: &ChainedBytes<'x>) -> Cow<'x, [u8]> { + let bytes = raw_message.get(self.body_to_end()).unwrap_or_default(); + + if (self.flags & PART_ENCODING_BASE64) != 0 { + base64_decode(bytes.as_ref()).unwrap_or_default().into() + } else if (self.flags & PART_ENCODING_QP) != 0 { + quoted_printable_decode(bytes.as_ref()) + .unwrap_or_default() + .into() + } else { + bytes + } + } + + #[inline(always)] + pub fn body_to_end(&self) -> Range { + (self.offset_body.to_native() as usize)..(self.offset_end.to_native() as usize) + } + + #[inline(always)] + pub fn header_to_end(&self) -> Range { + self.offset_header.to_native() as usize..self.offset_end.to_native() as usize + } + + #[inline(always)] + pub fn header_to_body(&self) -> Range { + self.offset_header.to_native() as usize..self.offset_body.to_native() as usize + } + + pub fn decode_contents<'x>(&self, raw_message: &ChainedBytes<'x>) -> DecodedPartContent<'x> { + let bytes = self.contents(raw_message); + + match self.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => { + DecodedPartContent::Text( + match ( + bytes, + self.header_value(&MetadataHeaderName::ContentType) + .and_then(|c| c.as_content_type()) + .and_then(|ct| { + ct.attribute("charset") + .and_then(|c| charset_decoder(c.as_bytes())) + }), + ) { + (Cow::Owned(vec), Some(charset_decoder)) => charset_decoder(&vec).into(), + (Cow::Owned(vec), None) => String::from_utf8(vec) + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) + .into(), + (Cow::Borrowed(bytes), Some(charset_decoder)) => { + charset_decoder(bytes).into() + } + (Cow::Borrowed(bytes), None) => String::from_utf8_lossy(bytes), + }, + ) + } + ArchivedMetadataPartType::Binary => DecodedPartContent::Binary(bytes), + ArchivedMetadataPartType::InlineBinary => DecodedPartContent::Binary(bytes), + ArchivedMetadataPartType::Message(_) | ArchivedMetadataPartType::Multipart(_) => { + unreachable!() + } + } + } +} + +pub fn build_metadata_contents( + message: mail_parser::Message<'_>, +) -> Box<[MessageMetadataContents]> { + let mut messages = VecDeque::from([message]); + let mut message_id = 0; + let mut contents = Vec::new(); + + while let Some(message) = messages.pop_front() { + let mut parts = Vec::with_capacity(message.parts.len()); + + for part in message.parts { + let (size, body) = match part.body { + PartType::Text(contents) => (contents.len(), MetadataPartType::Text), + PartType::Html(contents) => (contents.len(), MetadataPartType::Html), + PartType::Binary(contents) => (contents.len(), MetadataPartType::Binary), + PartType::InlineBinary(contents) => { + (contents.len(), MetadataPartType::InlineBinary) + } + PartType::Message(message) => { + let message_len = message.root_part().raw_len(); + messages.push_back(message); + message_id += 1; + + (message_len as usize, MetadataPartType::Message(message_id)) + } + PartType::Multipart(parts) => ( + 0, + MetadataPartType::Multipart(parts.into_iter().map(|p| p as u16).collect()), + ), + }; + + let flags = match part.encoding { + Encoding::None => 0, + Encoding::QuotedPrintable => PART_ENCODING_QP, + Encoding::Base64 => PART_ENCODING_BASE64, + } | (if part.is_encoding_problem { + PART_ENCODING_PROBLEM + } else { + 0 + }) | (size as u32 & PART_SIZE_MASK); + + parts.push(MessageMetadataPart { + headers: part + .headers + .into_iter() + .map(|hdr| MetadataHeader { + value: if matches!( + &hdr.name, + HeaderName::Subject + | HeaderName::From + | HeaderName::To + | HeaderName::Cc + | HeaderName::Date + | HeaderName::Bcc + | HeaderName::ReplyTo + | HeaderName::Sender + | HeaderName::Comments + | HeaderName::InReplyTo + | HeaderName::Keywords + | HeaderName::MessageId + | HeaderName::References + | HeaderName::ResentMessageId + | HeaderName::ContentDescription + | HeaderName::ContentId + | HeaderName::ContentLanguage + | HeaderName::ContentLocation + | HeaderName::ContentTransferEncoding + | HeaderName::ContentType + | HeaderName::ContentDisposition + | HeaderName::ListId + ) { + hdr.value + } else { + HeaderValue::Empty + } + .into(), + name: hdr.name.into(), + base_offset: hdr.offset_field, + start: (hdr.offset_start - hdr.offset_field) as u16, + end: (hdr.offset_end - hdr.offset_field) as u16, + }) + .collect(), + body, + flags, + offset_header: part.offset_header, + offset_body: part.offset_body, + offset_end: part.offset_end, + }); + } + contents.push(MessageMetadataContents { + html_body: message.html_body.into_iter().map(|c| c as u16).collect(), + text_body: message.text_body.into_iter().map(|c| c as u16).collect(), + attachments: message.attachments.into_iter().map(|c| c as u16).collect(), + parts: parts.into_boxed_slice(), + }); + } + contents.into_boxed_slice() +} + +impl ArchivedMessageMetadataPart { + pub fn is_message(&self) -> bool { + matches!(self.body, ArchivedMetadataPartType::Message(_)) + } + + pub fn sub_parts(&self) -> Option<&ArchivedBox<[u16_le]>> { + if let ArchivedMetadataPartType::Multipart(parts) = &self.body { + Some(parts) + } else { + None + } + } + + pub fn raw_len(&self) -> usize { + (u32::from(self.offset_end)).saturating_sub(u32::from(self.offset_header)) as usize + } + + pub fn header_values( + &self, + name: &MetadataHeaderName, + ) -> impl Iterator + Sync + Send { + self.headers.iter().filter_map(move |header| { + if &header.name == name { + Some(&header.value) + } else { + None + } + }) + } + + pub fn header_value(&self, name: &MetadataHeaderName) -> Option<&ArchivedMetadataHeaderValue> { + self.headers.iter().rev().find_map(move |header| { + if &header.name == name { + Some(&header.value) + } else { + None + } + }) + } + + pub fn from(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::From) + .and_then(|header| header.as_single_address()) + .and_then(|addr| addr.address.as_deref()) + } + + pub fn subject(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::Subject) + .and_then(|header| header.as_text()) + } + + pub fn date(&self) -> Option { + self.header_value(&MetadataHeaderName::Date) + .and_then(|header| header.as_datetime()) + .map(|dt| dt.into()) + } + + pub fn message_id(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::MessageId) + .and_then(|header| header.as_text()) + } + + pub fn in_reply_to(&self) -> &ArchivedMetadataHeaderValue { + self.header_value(&MetadataHeaderName::InReplyTo) + .unwrap_or(&ArchivedMetadataHeaderValue::Empty) + } + + pub fn content_description(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::ContentDescription) + .and_then(|header| header.as_text()) + } + + pub fn content_disposition(&self) -> Option<&ArchivedMetadataContentType> { + self.header_value(&MetadataHeaderName::ContentDisposition) + .and_then(|header| header.as_content_type()) + } + + pub fn content_id(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::ContentId) + .and_then(|header| header.as_text()) + } + + pub fn content_transfer_encoding(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::ContentTransferEncoding) + .and_then(|header| header.as_text()) + } + + pub fn content_type(&self) -> Option<&ArchivedMetadataContentType> { + self.header_value(&MetadataHeaderName::ContentType) + .and_then(|header| header.as_content_type()) + } + + pub fn is_text_mime_type(&self) -> bool { + matches!( + self.body, + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html + ) && self + .content_type() + .is_none_or(|ct| ct.ctype().eq_ignore_ascii_case("text")) + } + + pub fn content_language(&self) -> &ArchivedMetadataHeaderValue { + self.header_value(&MetadataHeaderName::ContentLanguage) + .unwrap_or(&ArchivedMetadataHeaderValue::Empty) + } + + pub fn content_location(&self) -> Option<&str> { + self.header_value(&MetadataHeaderName::ContentLocation) + .and_then(|header| header.as_text()) + } + + pub fn attachment_name(&self) -> Option<&str> { + self.content_disposition() + .and_then(|cd| cd.attribute("filename")) + .or_else(|| self.content_type().and_then(|ct| ct.attribute("name"))) + } +} + +impl From> for MetadataHeaderName { + fn from(value: HeaderName<'_>) -> Self { + match value { + HeaderName::Subject => MetadataHeaderName::Subject, + HeaderName::From => MetadataHeaderName::From, + HeaderName::To => MetadataHeaderName::To, + HeaderName::Cc => MetadataHeaderName::Cc, + HeaderName::Date => MetadataHeaderName::Date, + HeaderName::Bcc => MetadataHeaderName::Bcc, + HeaderName::ReplyTo => MetadataHeaderName::ReplyTo, + HeaderName::Sender => MetadataHeaderName::Sender, + HeaderName::Comments => MetadataHeaderName::Comments, + HeaderName::InReplyTo => MetadataHeaderName::InReplyTo, + HeaderName::Keywords => MetadataHeaderName::Keywords, + HeaderName::Received => MetadataHeaderName::Received, + HeaderName::MessageId => MetadataHeaderName::MessageId, + HeaderName::References => MetadataHeaderName::References, + HeaderName::ReturnPath => MetadataHeaderName::ReturnPath, + HeaderName::MimeVersion => MetadataHeaderName::MimeVersion, + HeaderName::ContentDescription => MetadataHeaderName::ContentDescription, + HeaderName::ContentId => MetadataHeaderName::ContentId, + HeaderName::ContentLanguage => MetadataHeaderName::ContentLanguage, + HeaderName::ContentLocation => MetadataHeaderName::ContentLocation, + HeaderName::ContentTransferEncoding => MetadataHeaderName::ContentTransferEncoding, + HeaderName::ContentType => MetadataHeaderName::ContentType, + HeaderName::ContentDisposition => MetadataHeaderName::ContentDisposition, + HeaderName::ResentTo => MetadataHeaderName::ResentTo, + HeaderName::ResentFrom => MetadataHeaderName::ResentFrom, + HeaderName::ResentBcc => MetadataHeaderName::ResentBcc, + HeaderName::ResentCc => MetadataHeaderName::ResentCc, + HeaderName::ResentSender => MetadataHeaderName::ResentSender, + HeaderName::ResentDate => MetadataHeaderName::ResentDate, + HeaderName::ResentMessageId => MetadataHeaderName::ResentMessageId, + HeaderName::ListArchive => MetadataHeaderName::ListArchive, + HeaderName::ListHelp => MetadataHeaderName::ListHelp, + HeaderName::ListId => MetadataHeaderName::ListId, + HeaderName::ListOwner => MetadataHeaderName::ListOwner, + HeaderName::ListPost => MetadataHeaderName::ListPost, + HeaderName::ListSubscribe => MetadataHeaderName::ListSubscribe, + HeaderName::ListUnsubscribe => MetadataHeaderName::ListUnsubscribe, + HeaderName::DkimSignature => MetadataHeaderName::DkimSignature, + HeaderName::ArcAuthenticationResults => MetadataHeaderName::ArcAuthenticationResults, + HeaderName::ArcMessageSignature => MetadataHeaderName::ArcMessageSignature, + HeaderName::ArcSeal => MetadataHeaderName::ArcSeal, + + // Delivery/Routing + HeaderName::DeliveredTo => MetadataHeaderName::DeliveredTo, + HeaderName::DispositionNotificationTo => MetadataHeaderName::DispositionNotificationTo, + HeaderName::ErrorsTo => MetadataHeaderName::ErrorsTo, + + // Authentication + HeaderName::AuthenticationResults => MetadataHeaderName::AuthenticationResults, + HeaderName::ReceivedSpf => MetadataHeaderName::ReceivedSpf, + + // Priority + HeaderName::Importance => MetadataHeaderName::Importance, + HeaderName::Priority => MetadataHeaderName::Priority, + + // Auto-response + HeaderName::AutoSubmitted => MetadataHeaderName::AutoSubmitted, + + // Organization/Threading + HeaderName::Organization => MetadataHeaderName::Organization, + + // List (additional) + HeaderName::ListUnsubscribePost => MetadataHeaderName::ListUnsubscribePost, + + HeaderName::Other(value) => { + let name = hashify::tiny_map_ignore_case!(value.as_bytes(), + // Delivery/Routing + "X-Original-To" => MetadataHeaderName::XOriginalTo, + "Return-Receipt-To" => MetadataHeaderName::ReturnReceiptTo, + + // Spam/Virus + "X-Spam-Status" => MetadataHeaderName::XSpamStatus, + "X-Spam-Score" => MetadataHeaderName::XSpamScore, + "X-Spam-Flag" => MetadataHeaderName::XSpamFlag, + "X-Spam-Result" => MetadataHeaderName::XSpamResult, + + // Priority + "X-Priority" => MetadataHeaderName::XPriority, + "X-MSMail-Priority" => MetadataHeaderName::XMSMailPriority, + + // Client/Agent + "X-Mailer" => MetadataHeaderName::XMailer, + "User-Agent" => MetadataHeaderName::UserAgent, + "X-MimeOLE" => MetadataHeaderName::XMimeOLE, + + // Network/Origin + "X-Originating-IP" => MetadataHeaderName::XOriginatingIp, + "X-Forwarded-To" => MetadataHeaderName::XForwardedTo, + "X-Forwarded-For" => MetadataHeaderName::XForwardedFor, + + // Auto-response + "X-Auto-Response-Suppress" => MetadataHeaderName::XAutoResponseSuppress, + "Precedence" => MetadataHeaderName::Precedence, + + // Organization/Threading + "Thread-Index" => MetadataHeaderName::ThreadIndex, + "Thread-Topic" => MetadataHeaderName::ThreadTopic, + + // List (additional) + "Feedback-ID" => MetadataHeaderName::FeedbackId, + ); + name.unwrap_or_else(|| { + MetadataHeaderName::Other(value.into_owned().into_boxed_str()) + }) + } + other => MetadataHeaderName::Other(other.as_str().to_string().into_boxed_str()), + } + } +} + +impl From> for MetadataHeaderValue { + fn from(value: HeaderValue<'_>) -> Self { + match value { + HeaderValue::Address(address) => match address { + Address::List(address) => MetadataHeaderValue::AddressList( + address + .into_iter() + .map(|a| MetadataAddress { + name: a.name.map(|a| a.into_owned().into_boxed_str()), + address: a.address.map(|a| a.into_owned().into_boxed_str()), + }) + .collect(), + ), + Address::Group(groups) => MetadataHeaderValue::AddressGroup( + groups + .into_iter() + .map(|g| MetadataAddressGroup { + name: g.name.map(|a| a.into_owned().into_boxed_str()), + addresses: g + .addresses + .into_iter() + .map(|a| MetadataAddress { + name: a.name.map(|a| a.into_owned().into_boxed_str()), + address: a.address.map(|a| a.into_owned().into_boxed_str()), + }) + .collect(), + }) + .collect(), + ), + }, + HeaderValue::Text(text) => { + MetadataHeaderValue::Text(text.into_owned().into_boxed_str()) + } + HeaderValue::TextList(texts) => MetadataHeaderValue::TextList( + texts + .into_iter() + .map(|v| v.into_owned().into_boxed_str()) + .collect(), + ), + HeaderValue::DateTime(dt) => MetadataHeaderValue::DateTime(MetadataDateTime { + year: dt.year, + month: dt.month, + day: dt.day, + hour: dt.hour, + minute: dt.minute, + second: dt.second, + tz_hour: (if dt.tz_before_gmt { -1 } else { 1 }) * dt.tz_hour as i8, + tz_minute: dt.tz_minute, + }), + HeaderValue::ContentType(ct) => MetadataHeaderValue::ContentType(MetadataContentType { + c_type: ct.c_type.into_owned().into_boxed_str(), + c_subtype: ct.c_subtype.map(|v| v.into_owned().into_boxed_str()), + attributes: ct + .attributes + .unwrap_or_default() + .into_iter() + .map(|a| MetadataAttribute { + name: a.name.into_owned().into_boxed_str(), + value: a.value.into_owned().into_boxed_str(), + }) + .collect(), + }), + HeaderValue::Received(_) | HeaderValue::Empty => MetadataHeaderValue::Empty, + } + } +} + +impl From<&ArchivedMetadataDateTime> for DateTime { + fn from(dt: &ArchivedMetadataDateTime) -> Self { + DateTime { + year: dt.year.to_native(), + month: dt.month, + day: dt.day, + hour: dt.hour, + minute: dt.minute, + second: dt.second, + tz_before_gmt: dt.tz_hour < 0, + tz_hour: dt.tz_hour.unsigned_abs(), + tz_minute: dt.tz_minute, + } + } +} + +impl ArchivedMessageMetadataContents { + pub fn root_part(&self) -> &ArchivedMessageMetadataPart { + &self.parts[0] + } +} + +#[derive(Default)] +pub struct MessageDataBuilder { + pub mailboxes: Vec, + pub keywords: Vec, + pub thread_id: u32, + pub size: u32, +} + +impl MessageDataBuilder { + pub fn set_keywords(&mut self, keywords: Vec) { + self.keywords = keywords; + } + + pub fn add_keyword(&mut self, keyword: Keyword) -> bool { + if !self.keywords.contains(&keyword) { + self.keywords.push(keyword); + true + } else { + false + } + } + + pub fn remove_keyword(&mut self, keyword: &Keyword) -> bool { + let prev_len = self.keywords.len(); + self.keywords.retain(|k| k != keyword); + self.keywords.len() != prev_len + } + + pub fn set_mailboxes(&mut self, mailboxes: Vec) { + self.mailboxes = mailboxes; + } + + pub fn add_mailbox(&mut self, mailbox: UidMailbox) { + if !self.mailboxes.contains(&mailbox) { + self.mailboxes.push(mailbox); + } + } + + pub fn remove_mailbox(&mut self, mailbox: u32) { + self.mailboxes.retain(|m| m.mailbox_id != mailbox); + } + + pub fn has_keyword(&self, keyword: &Keyword) -> bool { + self.keywords.iter().any(|k| k == keyword) + } + + pub fn has_keyword_changes(&self, prev_data: &ArchivedMessageData) -> bool { + self.keywords.len() != prev_data.keywords.len() + || !self + .keywords + .iter() + .all(|k| prev_data.keywords.iter().any(|pk| pk == k)) + } + + pub fn added_keywords( + &self, + prev_data: &ArchivedMessageData, + ) -> impl Iterator { + self.keywords + .iter() + .filter(|k| prev_data.keywords.iter().all(|pk| pk != *k)) + } + + pub fn removed_keywords<'x>( + &'x self, + prev_data: &'x ArchivedMessageData, + ) -> impl Iterator { + prev_data + .keywords + .iter() + .filter(|k| self.keywords.iter().all(|pk| pk != *k)) + } + + pub fn added_mailboxes( + &self, + prev_data: &ArchivedMessageData, + ) -> impl Iterator { + self.mailboxes.iter().filter(|m| { + prev_data + .mailboxes + .iter() + .all(|pm| pm.mailbox_id != m.mailbox_id) + }) + } + + pub fn removed_mailboxes<'x>( + &'x self, + prev_data: &'x ArchivedMessageData, + ) -> impl Iterator { + prev_data.mailboxes.iter().filter(|m| { + self.mailboxes + .iter() + .all(|pm| pm.mailbox_id != m.mailbox_id) + }) + } + + pub fn has_mailbox_changes(&self, prev_data: &ArchivedMessageData) -> bool { + self.mailboxes.len() != prev_data.mailboxes.len() + || !self.mailboxes.iter().all(|m| { + prev_data + .mailboxes + .iter() + .any(|pm| pm.mailbox_id == m.mailbox_id) + }) + } + + pub fn seal(self) -> MessageData { + MessageData { + mailboxes: self.mailboxes.into_boxed_slice(), + keywords: self.keywords.into_boxed_slice(), + thread_id: self.thread_id, + size: self.size, + } + } +} + +impl MessageData { + pub fn has_mailbox_id(&self, mailbox_id: u32) -> bool { + self.mailboxes.iter().any(|m| m.mailbox_id == mailbox_id) + } +} + +impl ArchivedMessageData { + pub fn has_mailbox_id(&self, mailbox_id: u32) -> bool { + self.mailboxes.iter().any(|m| m.mailbox_id == mailbox_id) + } + + pub fn message_uid(&self, mailbox_id: u32) -> Option { + self.mailboxes + .iter() + .find(|m| m.mailbox_id == mailbox_id) + .map(|m| m.uid.to_native()) + } + + pub fn to_builder(&self) -> MessageDataBuilder { + MessageDataBuilder { + mailboxes: self.mailboxes.iter().map(|m| m.to_native()).collect(), + keywords: self.keywords.iter().map(|k| k.to_native()).collect(), + thread_id: self.thread_id.to_native(), + size: self.size.to_native(), + } + } +} + +impl ArchivedMetadataContentType { + pub fn ctype(&self) -> &str { + &self.c_type + } + + pub fn subtype(&self) -> Option<&str> { + self.c_subtype.as_ref().map(|s| s.as_ref()) + } + + pub fn attribute(&self, name: &str) -> Option<&str> { + self.attributes + .iter() + .find(|a| *a.name == *name) + .map(|a| a.value.as_ref()) + } + + /// Returns `true` when the provided attribute name is present + pub fn has_attribute(&self, name: &str) -> bool { + self.attributes.iter().any(|a| *a.name == *name) + } + + pub fn is_attachment(&self) -> bool { + self.c_type.eq_ignore_ascii_case("attachment") + } + + pub fn is_inline(&self) -> bool { + self.c_type.eq_ignore_ascii_case("inline") + } +} + +impl ArchivedMetadataHeaderValue { + pub fn is_empty(&self) -> bool { + self == &MetadataHeaderValue::Empty + } + + pub fn as_text(&self) -> Option<&str> { + match self { + ArchivedMetadataHeaderValue::Text(s) => Some(s.as_ref()), + ArchivedMetadataHeaderValue::TextList(l) => l.last().map(|v| v.as_ref()), + _ => None, + } + } + + pub fn as_text_list(&self) -> Option<&[ArchivedBox]> { + match self { + ArchivedMetadataHeaderValue::Text(s) => Some(std::slice::from_ref(s)), + ArchivedMetadataHeaderValue::TextList(l) => Some(l.as_ref()), + _ => None, + } + } + + pub fn as_content_type(&self) -> Option<&ArchivedMetadataContentType> { + match self { + ArchivedMetadataHeaderValue::ContentType(c) => Some(c), + _ => None, + } + } + + pub fn as_datetime(&self) -> Option<&ArchivedMetadataDateTime> { + match self { + ArchivedMetadataHeaderValue::DateTime(d) => Some(d), + _ => None, + } + } + + pub fn as_single_address(&self) -> Option<&ArchivedMetadataAddress> { + match self { + ArchivedMetadataHeaderValue::AddressList(list) => list.first(), + ArchivedMetadataHeaderValue::AddressGroup(groups) => { + groups.first().and_then(|g| g.addresses.first()) + } + _ => None, + } + } +} + +impl ArchivedUidMailbox { + pub fn to_native(&self) -> UidMailbox { + UidMailbox { + mailbox_id: self.mailbox_id.to_native(), + uid: self.uid.to_native(), + } + } +} + +impl ArchivedMetadataHeader { + #[inline(always)] + pub fn value_range(&self) -> Range { + (self.base_offset.to_native() as usize + self.start.to_native() as usize) + ..(self.base_offset.to_native() as usize + self.end.to_native() as usize) + } + + #[inline(always)] + pub fn name_value_range(&self) -> Range { + (self.base_offset.to_native() as usize) + ..(self.base_offset.to_native() as usize + self.end.to_native() as usize) + } +} + +impl ArchivedMetadataHeaderName { + pub fn is_mime_header(&self) -> bool { + matches!( + self, + ArchivedMetadataHeaderName::ContentDescription + | ArchivedMetadataHeaderName::ContentId + | ArchivedMetadataHeaderName::ContentLanguage + | ArchivedMetadataHeaderName::ContentLocation + | ArchivedMetadataHeaderName::ContentTransferEncoding + | ArchivedMetadataHeaderName::ContentType + | ArchivedMetadataHeaderName::ContentDisposition + ) + } + + pub fn as_str(&self) -> &str { + match self { + ArchivedMetadataHeaderName::Subject => "Subject", + ArchivedMetadataHeaderName::From => "From", + ArchivedMetadataHeaderName::To => "To", + ArchivedMetadataHeaderName::Cc => "Cc", + ArchivedMetadataHeaderName::Date => "Date", + ArchivedMetadataHeaderName::Bcc => "Bcc", + ArchivedMetadataHeaderName::ReplyTo => "Reply-To", + ArchivedMetadataHeaderName::Sender => "Sender", + ArchivedMetadataHeaderName::Comments => "Comments", + ArchivedMetadataHeaderName::InReplyTo => "In-Reply-To", + ArchivedMetadataHeaderName::Keywords => "Keywords", + ArchivedMetadataHeaderName::Received => "Received", + ArchivedMetadataHeaderName::MessageId => "Message-ID", + ArchivedMetadataHeaderName::References => "References", + ArchivedMetadataHeaderName::ReturnPath => "Return-Path", + ArchivedMetadataHeaderName::MimeVersion => "MIME-Version", + ArchivedMetadataHeaderName::ContentDescription => "Content-Description", + ArchivedMetadataHeaderName::ContentId => "Content-ID", + ArchivedMetadataHeaderName::ContentLanguage => "Content-Language", + ArchivedMetadataHeaderName::ContentLocation => "Content-Location", + ArchivedMetadataHeaderName::ContentTransferEncoding => "Content-Transfer-Encoding", + ArchivedMetadataHeaderName::ContentType => "Content-Type", + ArchivedMetadataHeaderName::ContentDisposition => "Content-Disposition", + ArchivedMetadataHeaderName::ResentTo => "Resent-To", + ArchivedMetadataHeaderName::ResentFrom => "Resent-From", + ArchivedMetadataHeaderName::ResentBcc => "Resent-Bcc", + ArchivedMetadataHeaderName::ResentCc => "Resent-Cc", + ArchivedMetadataHeaderName::ResentSender => "Resent-Sender", + ArchivedMetadataHeaderName::ResentDate => "Resent-Date", + ArchivedMetadataHeaderName::ResentMessageId => "Resent-Message-ID", + ArchivedMetadataHeaderName::ListArchive => "List-Archive", + ArchivedMetadataHeaderName::ListHelp => "List-Help", + ArchivedMetadataHeaderName::ListId => "List-ID", + ArchivedMetadataHeaderName::ListOwner => "List-Owner", + ArchivedMetadataHeaderName::ListPost => "List-Post", + ArchivedMetadataHeaderName::ListSubscribe => "List-Subscribe", + ArchivedMetadataHeaderName::ListUnsubscribe => "List-Unsubscribe", + ArchivedMetadataHeaderName::ArcAuthenticationResults => "ARC-Authentication-Results", + ArchivedMetadataHeaderName::ArcMessageSignature => "ARC-Message-Signature", + ArchivedMetadataHeaderName::ArcSeal => "ARC-Seal", + ArchivedMetadataHeaderName::DkimSignature => "DKIM-Signature", + ArchivedMetadataHeaderName::DeliveredTo => "Delivered-To", + ArchivedMetadataHeaderName::XOriginalTo => "X-Original-To", + ArchivedMetadataHeaderName::ReturnReceiptTo => "Return-Receipt-To", + ArchivedMetadataHeaderName::DispositionNotificationTo => "Disposition-Notification-To", + ArchivedMetadataHeaderName::ErrorsTo => "Errors-To", + ArchivedMetadataHeaderName::AuthenticationResults => "Authentication-Results", + ArchivedMetadataHeaderName::ReceivedSpf => "Received-SPF", + ArchivedMetadataHeaderName::XSpamStatus => "X-Spam-Status", + ArchivedMetadataHeaderName::XSpamScore => "X-Spam-Score", + ArchivedMetadataHeaderName::XSpamFlag => "X-Spam-Flag", + ArchivedMetadataHeaderName::XSpamResult => "X-Spam-Result", + ArchivedMetadataHeaderName::Importance => "Importance", + ArchivedMetadataHeaderName::Priority => "Priority", + ArchivedMetadataHeaderName::XPriority => "X-Priority", + ArchivedMetadataHeaderName::XMSMailPriority => "X-MSMail-Priority", + ArchivedMetadataHeaderName::XMailer => "X-Mailer", + ArchivedMetadataHeaderName::UserAgent => "User-Agent", + ArchivedMetadataHeaderName::XMimeOLE => "X-MimeOLE", + ArchivedMetadataHeaderName::XOriginatingIp => "X-Originating-IP", + ArchivedMetadataHeaderName::XForwardedTo => "X-Forwarded-To", + ArchivedMetadataHeaderName::XForwardedFor => "X-Forwarded-For", + ArchivedMetadataHeaderName::AutoSubmitted => "Auto-Submitted", + ArchivedMetadataHeaderName::XAutoResponseSuppress => "X-Auto-Response-Suppress", + ArchivedMetadataHeaderName::Precedence => "Precedence", + ArchivedMetadataHeaderName::Organization => "Organization", + ArchivedMetadataHeaderName::ThreadIndex => "Thread-Index", + ArchivedMetadataHeaderName::ThreadTopic => "Thread-Topic", + ArchivedMetadataHeaderName::ListUnsubscribePost => "List-Unsubscribe-Post", + ArchivedMetadataHeaderName::FeedbackId => "Feedback-ID", + ArchivedMetadataHeaderName::Other(name) => name.as_ref(), + } + } +} + +impl From<&ArchivedMetadataHeaderValue> for HeaderValue<'static> { + fn from(value: &ArchivedMetadataHeaderValue) -> Self { + match value { + ArchivedMetadataHeaderValue::AddressList(addr) => HeaderValue::Address(Address::List( + addr.as_ref().iter().map(Into::into).collect(), + )), + ArchivedMetadataHeaderValue::AddressGroup(addr) => HeaderValue::Address( + Address::Group(addr.as_ref().iter().map(Into::into).collect()), + ), + ArchivedMetadataHeaderValue::Text(text) => HeaderValue::Text(text.to_string().into()), + ArchivedMetadataHeaderValue::TextList(textlist) => HeaderValue::TextList( + textlist + .as_ref() + .iter() + .map(|s| s.to_string().into()) + .collect(), + ), + ArchivedMetadataHeaderValue::DateTime(dt) => HeaderValue::DateTime(dt.into()), + ArchivedMetadataHeaderValue::ContentType(ct) => HeaderValue::ContentType(ct.into()), + ArchivedMetadataHeaderValue::Empty => HeaderValue::Empty, + } + } +} + +impl From<&ArchivedMetadataAddress> for Addr<'static> { + fn from(value: &ArchivedMetadataAddress) -> Self { + Addr { + name: value.name.as_ref().map(|n| n.to_string().into()), + address: value.address.as_ref().map(|a| a.to_string().into()), + } + } +} + +impl From<&ArchivedMetadataAddressGroup> for Group<'static> { + fn from(value: &ArchivedMetadataAddressGroup) -> Self { + Group { + name: value.name.as_ref().map(|n| n.to_string().into()), + addresses: value.addresses.as_ref().iter().map(Into::into).collect(), + } + } +} + +impl From<&ArchivedMetadataContentType> for ContentType<'static> { + fn from(value: &ArchivedMetadataContentType) -> Self { + ContentType { + c_type: value.ctype().to_string().into(), + c_subtype: value.subtype().map(|s| s.to_string().into()), + attributes: Some( + value + .attributes + .iter() + .map(|a| Attribute { + name: a.name.to_string().into(), + value: a.value.to_string().into(), + }) + .collect(), + ), + } + } +} diff --git a/crates/email/src/message/mod.rs b/crates/email/src/message/mod.rs new file mode 100644 index 0000000..b75cdb5 --- /dev/null +++ b/crates/email/src/message/mod.rs @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod body; +pub mod copy; +pub mod crypto; +pub mod delete; +pub mod delivery; +pub mod headers; +pub mod index; +pub mod ingest; +pub mod metadata; diff --git a/crates/email/src/push/mod.rs b/crates/email/src/push/mod.rs new file mode 100644 index 0000000..20e572b --- /dev/null +++ b/crates/email/src/push/mod.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::method::query::Filter; +use jmap_proto::object::email::EmailFilter; +use jmap_proto::object::push_subscription::EmailPushProperty; +use types::type_state::DataType; +use utils::map::bitmap::Bitmap; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Default, Debug, Clone, PartialEq, Eq, +)] +pub struct PushSubscription { + pub id: u32, + pub url: String, + pub device_client_id: String, + pub expires: u64, + pub verification_code: String, + pub verified: bool, + pub types: Bitmap, + pub keys: Option, + pub email_push: Vec, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Keys { + pub p256dh: Vec, + pub auth: Vec, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Default, Debug, Clone, PartialEq, Eq, +)] +pub struct PushSubscriptions { + pub subscriptions: Vec, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Default, Debug, Clone, PartialEq, Eq, +)] +pub struct EmailPush { + pub account_id: u32, + pub properties: Vec, + pub filter: Vec>, + pub urgency: Urgency, +} + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +#[repr(u8)] +pub enum Urgency { + VeryLow = 0, + Low = 1, + #[default] + Normal = 2, + High = 3, +} + +impl From<&ArchivedUrgency> for Urgency { + fn from(value: &ArchivedUrgency) -> Self { + match value { + ArchivedUrgency::VeryLow => Urgency::VeryLow, + ArchivedUrgency::Low => Urgency::Low, + ArchivedUrgency::Normal => Urgency::Normal, + ArchivedUrgency::High => Urgency::High, + } + } +} + +impl Urgency { + pub fn as_str(&self) -> &'static str { + match self { + Urgency::VeryLow => "very-low", + Urgency::Low => "low", + Urgency::Normal => "normal", + Urgency::High => "high", + } + } +} diff --git a/crates/email/src/sieve/delete.rs b/crates/email/src/sieve/delete.rs new file mode 100644 index 0000000..730fb43 --- /dev/null +++ b/crates/email/src/sieve/delete.rs @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::SieveScript; +use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; +use store::write::BatchBuilder; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{collection::Collection, field::SieveField}; + +pub trait SieveScriptDelete: Sync + Send { + fn sieve_script_delete( + &self, + account_id: u32, + document_id: u32, + access_token: &AccessToken, + batch: &mut BatchBuilder, + ) -> impl Future> + Send; +} + +impl SieveScriptDelete for Server { + async fn sieve_script_delete( + &self, + account_id: u32, + document_id: u32, + access_token: &AccessToken, + batch: &mut BatchBuilder, + ) -> trc::Result { + // Fetch record + if let Some(obj_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + { + // Delete record + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .clear(SieveField::Ids) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_current( + obj_.to_unarchived::() + .caused_by(trc::location!())?, + ) + .with_changed_by(access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .commit_point(); + + Ok(true) + } else { + Ok(false) + } + } +} diff --git a/crates/email/src/sieve/index.rs b/crates/email/src/sieve/index.rs new file mode 100644 index 0000000..2e5753a --- /dev/null +++ b/crates/email/src/sieve/index.rs @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedSieveScript, SieveScript}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use types::{collection::SyncCollection, field::SieveField}; + +impl IndexableObject for SieveScript { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Index { + field: SieveField::Name.into(), + value: self.name.as_str().to_lowercase().into(), + }, + IndexValue::Blob { + value: self.blob_hash.clone(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::SieveScript, + prefix: None, + }, + IndexValue::Quota { used: self.size }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for SieveScript { + fn is_versioned() -> bool { + false + } +} + +impl IndexableObject for &ArchivedSieveScript { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Index { + field: SieveField::Name.into(), + value: self.name.to_lowercase().into(), + }, + IndexValue::Blob { + value: (&self.blob_hash).into(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::SieveScript, + prefix: None, + }, + IndexValue::Quota { + used: u32::from(self.size), + }, + ] + .into_iter() + } +} diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs new file mode 100644 index 0000000..e3e966a --- /dev/null +++ b/crates/email/src/sieve/ingest.rs @@ -0,0 +1,793 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ActiveScript, SeenIdHash, SieveScript}; +use crate::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, + mailbox::{INBOX_ID, TRASH_ID, manage::MailboxFnc}, + message::{ + delivery::{AutogeneratedMessage, IngestRecipient}, + ingest::{EmailIngest, IngestEmail, IngestSource, IngestedEmail}, + }, +}; +use common::{ + Server, auth::AccessToken, config::mailstore::spamfilter::spam_status, + scripts::plugins::PluginContext, +}; +use mail_builder::headers::date::Date; +use mail_parser::{HeaderName, MessageParser}; +use sieve::{Envelope, Event, Input, Mailbox, Recipient, Sieve}; +use std::{borrow::Cow, sync::Arc}; +use std::{future::Future, str::FromStr}; +use store::{ + Deserialize, Serialize, ValueKey, + ahash::AHashMap, + dispatch::lookup::KeyValue, + write::{ + AlignedBytes, Archive, ArchiveVersion, Archiver, BatchBuilder, BlobLink, BlobOp, ValueClass, + }, +}; +use trc::{AddContext, SieveEvent, SmtpEvent}; +use types::{ + blob_hash::BlobHash, + collection::Collection, + field::{PrincipalField, SieveField}, + id::Id, + keyword::Keyword, + special_use::SpecialUse, +}; + +struct SieveMessage<'x> { + pub raw_message: Cow<'x, [u8]>, + pub file_into: Vec, + pub did_file_into: bool, + pub flags: Vec, +} + +pub trait SieveScriptIngest: Sync + Send { + #[allow(clippy::too_many_arguments)] + fn sieve_script_ingest( + &self, + access_token: &AccessToken, + blob_hash: &BlobHash, + raw_message: &[u8], + envelope_from: &str, + envelope_from_authenticated: bool, + envelope_to: &IngestRecipient, + session_id: u64, + active_script: ActiveScript, + autogenerated: &mut Vec, + ) -> impl Future> + Send; + + fn sieve_script_get_active_id( + &self, + account_id: u32, + ) -> impl Future>> + Send; + + fn sieve_script_get_active( + &self, + account_id: u32, + ) -> impl Future>> + Send; + + fn sieve_script_get_by_name( + &self, + account_id: u32, + name: &str, + ) -> impl Future>> + Send; + + fn sieve_script_compile( + &self, + account_id: u32, + document_id: u32, + ) -> impl Future>> + Send; +} + +impl SieveScriptIngest for Server { + #[allow(clippy::blocks_in_conditions)] + async fn sieve_script_ingest( + &self, + access_token: &AccessToken, + blob_hash: &BlobHash, + raw_message: &[u8], + envelope_from: &str, + envelope_from_authenticated: bool, + envelope_to: &IngestRecipient, + session_id: u64, + active_script: ActiveScript, + autogenerated: &mut Vec, + ) -> trc::Result { + // Parse message + let message = if let Some(message) = MessageParser::new().parse(raw_message) { + message + } else { + return Err( + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) + .ctx(trc::Key::Code, 550) + .ctx(trc::Key::Reason, "Failed to parse e-mail message."), + ); + }; + + let received_headers = message + .headers() + .iter() + .filter(|header| matches!(header.name, HeaderName::Received)) + .count(); + + // Obtain mailboxIds + let account_id = access_token.account_id(); + let mut cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + // Create Sieve instance + let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message); + + // Set account name and email + let account_info = self.account(account_id).await.caused_by(trc::location!())?; + let mail_from = account_info.name().to_string(); + instance.set_user_full_name( + account_info + .description() + .unwrap_or_else(|| account_info.name()), + ); + instance.set_user_address(&mail_from); + + // Set envelope + instance.set_envelope(Envelope::From, envelope_from); + instance.set_envelope(Envelope::To, envelope_to.address.as_str()); + if let Some(orcpt) = &envelope_to.orcpt { + instance.set_envelope(Envelope::Orcpt, orcpt.as_str()); + } + instance.set_spam_status(spam_status(envelope_to.spam_percentage)); + + let mut input = Input::script( + active_script.script_name.to_string(), + active_script.script.clone(), + ); + + let mut do_discard = false; + let mut do_deliver = false; + let mut do_redirect = false; + + let mut reject_reason = None; + let mut messages: Vec = vec![SieveMessage { + raw_message: raw_message.into(), + file_into: Vec::new(), + flags: Vec::new(), + did_file_into: false, + }]; + let mut ingested_message = IngestedEmail { + document_id: 0, + thread_id: 0, + change_id: u64::MAX, + blob_id: Default::default(), + size: raw_message.len(), + imap_uids: Vec::new(), + }; + let mut checked_ids: AHashMap = AHashMap::new(); + + while let Some(event) = instance.run(input) { + match event { + Ok(event) => match event { + Event::IncludeScript { name, .. } => match &name { + sieve::Script::Personal(name_) => { + if let Ok(Some(script)) = + self.sieve_script_get_by_name(account_id, name_).await + { + input = Input::script(name, script); + } else { + input = false.into(); + } + } + sieve::Script::Global(name_) => { + if let Some(script) = self.get_untrusted_sieve_script(name_, session_id) + { + input = Input::script(name, script.clone()); + } else { + input = false.into(); + } + } + }, + Event::MailboxExists { + mailboxes, + special_use, + } => { + if !mailboxes.is_empty() { + let mut special_use_ids = Vec::with_capacity(special_use.len()); + for role in special_use.iter().map(|v| SpecialUse::parse_use_attr(v)) { + special_use_ids.push(match role { + Some(SpecialUse::Inbox) => INBOX_ID, + Some(SpecialUse::Trash) => TRASH_ID, + Some(role) => cache + .mailbox_by_role(&role) + .map(|m| m.document_id) + .unwrap_or(u32::MAX), + None => u32::MAX, + }); + } + + let mut result = true; + for mailbox in mailboxes { + match mailbox { + Mailbox::Name(name) => { + if !matches!( + cache.mailbox_by_path(&name), + Some(item) if special_use_ids.is_empty() || + special_use_ids.contains(&item.document_id) + ) { + result = false; + break; + } + } + Mailbox::Id(id) => { + if !matches!(Id::from_str(&id), Ok(id) if + cache.has_mailbox_id(&id.document_id()) && + (special_use_ids.is_empty() || + special_use_ids.contains(&id.document_id()))) + { + result = false; + break; + } + } + } + } + input = result.into(); + } else if !special_use.is_empty() { + let mut result = true; + + for role in special_use.iter().map(|v| SpecialUse::parse_use_attr(v)) { + match role { + Some(SpecialUse::Inbox | SpecialUse::Trash) => {} + Some(other) if cache.mailbox_by_role(&other).is_some() => {} + _ => { + result = false; + break; + } + } + } + input = result.into(); + } else { + input = false.into(); + } + } + Event::DuplicateId { id, expiry, last } => { + let id_hash = SeenIdHash::new( + account_id, + active_script.version.hash().unwrap_or_default(), + &id, + ); + if let Some(result) = checked_ids.get(&id_hash) { + input = (*result).into(); + } else { + let exists = self + .in_memory_store() + .key_exists(id_hash.key()) + .await + .caused_by(trc::location!())?; + + if !exists || last { + self.in_memory_store() + .key_set(KeyValue::new(id_hash.key(), vec![]).expires(expiry)) + .await + .caused_by(trc::location!())?; + } + + checked_ids.insert(id_hash, exists); + input = exists.into(); + } + } + Event::Discard => { + do_discard = true; + input = true.into(); + } + Event::Reject { reason, .. } => { + reject_reason = reason.into(); + do_discard = true; + input = true.into(); + } + Event::Keep { flags, message_id } => { + if let Some(message) = messages.get_mut(message_id) { + message.flags = flags.into_iter().map(Keyword::from).collect(); + if !message.file_into.contains(&INBOX_ID) { + message.file_into.push(INBOX_ID); + } + do_deliver = true; + } else { + trc::event!( + Sieve(SieveEvent::UnexpectedError), + Details = "Unknown message id.", + MessageId = message_id, + SpanId = session_id + ); + } + input = true.into(); + } + Event::FileInto { + folder, + flags, + mailbox_id, + special_use, + create, + message_id, + } => { + let mut target_id = u32::MAX; + + // Find mailbox by Id + if let Some(mailbox_id) = mailbox_id.and_then(|m| Id::from_str(&m).ok()) { + let mailbox_id = mailbox_id.document_id(); + if cache.has_mailbox_id(&mailbox_id) { + target_id = mailbox_id; + } + } + + // Find mailbox by role + if target_id == u32::MAX + && let Some(special_use) = + special_use.as_deref().and_then(SpecialUse::parse_use_attr) + { + match special_use { + SpecialUse::Inbox => { + target_id = INBOX_ID; + } + SpecialUse::Trash => { + target_id = TRASH_ID; + } + role => { + if let Some(item) = cache.mailbox_by_role(&role) { + target_id = item.document_id; + } + } + } + } + + // Find mailbox by name + if target_id == u32::MAX { + if !create { + if let Some(m) = cache.mailbox_by_path(&folder) { + target_id = m.document_id; + } + } else if let Some(document_id) = self + .mailbox_create_path(account_id, &folder) + .await + .caused_by(trc::location!())? + { + cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + target_id = document_id; + } + } + + // Default to Inbox + if target_id == u32::MAX { + target_id = INBOX_ID; + } + + if let Some(message) = messages.get_mut(message_id) { + message.flags = flags.into_iter().map(Keyword::from).collect(); + if !message.file_into.contains(&target_id) { + message.file_into.push(target_id); + } + message.did_file_into = true; + do_deliver = true; + } else { + trc::event!( + Sieve(SieveEvent::UnexpectedError), + Details = "Unknown message id.", + MessageId = message_id, + SpanId = session_id + ); + } + input = true.into(); + } + Event::SendMessage { + recipient, + message_id, + .. + } => { + input = true.into(); + if let Some(message) = messages.get(message_id) { + if received_headers >= self.core.sieve.max_received_headers { + trc::event!( + Smtp(SmtpEvent::LoopDetected), + From = mail_from.clone(), + Total = received_headers, + Limit = self.core.sieve.max_received_headers, + SpanId = session_id, + ); + + continue; + } + + let recipients: Vec = match recipient { + Recipient::Address(rcpt) => vec![rcpt], + Recipient::Group(rcpts) => rcpts, + Recipient::List(_) => { + // Not yet implemented + continue; + } + }; + + if message.raw_message.len() <= self.core.email.mail_max_size { + trc::event!( + Sieve(SieveEvent::SendMessage), + From = mail_from.clone(), + To = recipients + .iter() + .map(|r| trc::Value::String(r.as_str().into())) + .collect::>(), + Size = message.raw_message.len(), + SpanId = session_id + ); + + let mut raw_message = + Vec::with_capacity(160 + message.raw_message.len()); + write_received_header( + &mut raw_message, + &self.core.network.server_name, + session_id, + ); + raw_message.extend_from_slice(message.raw_message.as_ref()); + + autogenerated.push(AutogeneratedMessage { + sender_address: mail_from.clone(), + recipients, + message: raw_message, + }); + do_redirect = true; + } else { + trc::event!( + Sieve(SieveEvent::MessageTooLarge), + From = mail_from.clone(), + To = recipients + .iter() + .map(|r| trc::Value::String(r.as_str().into())) + .collect::>(), + Size = message.raw_message.len(), + Limit = self.core.email.mail_max_size, + SpanId = session_id, + ); + } + } else { + trc::event!( + Sieve(SieveEvent::UnexpectedError), + Details = "Unknown message id.", + MessageId = message_id, + SpanId = session_id + ); + + continue; + } + } + Event::ListContains { .. } + | Event::Notify { .. } + | Event::SetEnvelope { .. } => { + // Not allowed + input = false.into(); + } + Event::Function { id, arguments } => { + input = self + .core + .run_plugin( + id, + PluginContext { + session_id, + server: self, + message: instance.message(), + modifications: &mut Vec::new(), + access_token: access_token.into(), + arguments, + }, + ) + .await; + } + Event::CreatedMessage { message, .. } => { + messages.push(SieveMessage { + raw_message: message.into(), + file_into: Vec::new(), + flags: Vec::new(), + did_file_into: false, + }); + input = true.into(); + } + }, + + #[cfg(feature = "test_mode")] + Err(sieve::runtime::RuntimeError::ScriptErrorMessage(err)) => { + panic!("Sieve test failed: {}", err); + } + + Err(err) => { + trc::event!( + Sieve(SieveEvent::RuntimeError), + Reason = err.to_string(), + SpanId = session_id + ); + + input = true.into(); + } + } + } + + // Fail-safe, no discard and no keep seen, assume that something went wrong and file anyway. + if !do_deliver && !do_discard && !do_redirect { + messages[0].file_into.push(INBOX_ID); + } + + // Deliver messages + let mut last_temp_error = None; + let mut has_delivered = false; + for (message_id, sieve_message) in messages.into_iter().enumerate() { + if !sieve_message.file_into.is_empty() { + // Parse message if needed + let (blob_hash, message) = if message_id == 0 && !instance.has_message_changed() { + (blob_hash.into(), instance.take_message()) + } else if let Some(message) = + MessageParser::new().parse(sieve_message.raw_message.as_ref()) + { + (None, message) + } else { + trc::event!( + Sieve(SieveEvent::UnexpectedError), + Details = "Failed to parse Sieve generated message.", + SpanId = session_id + ); + + continue; + }; + + // Deliver message + match self + .email_ingest(IngestEmail { + raw_message: &sieve_message.raw_message, + blob_hash, + message: message.into(), + access_token, + mailbox_ids: sieve_message.file_into, + keywords: sieve_message.flags, + received_at: None, + source: IngestSource::Smtp { + deliver_to: envelope_to.address.as_str(), + is_sender_authenticated: envelope_from_authenticated, + is_spam: envelope_to.is_spam() && !sieve_message.did_file_into, + }, + session_id, + }) + .await + { + Ok(ingested_message_) => { + has_delivered = true; + ingested_message = ingested_message_; + } + Err(err) => { + last_temp_error = err.into(); + } + } + } + } + + if let Some(reject_reason) = reject_reason { + Err( + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) + .ctx(trc::Key::Code, 571) + .ctx(trc::Key::Reason, reject_reason), + ) + } else if has_delivered || last_temp_error.is_none() { + Ok(ingested_message) + } else { + // There were problems during delivery + #[allow(clippy::unnecessary_unwrap)] + Err(last_temp_error.unwrap()) + } + } + + async fn sieve_script_get_active_id(&self, account_id: u32) -> trc::Result> { + self.store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::ActiveScriptId.into()), + }) + .await + .caused_by(trc::location!()) + } + + async fn sieve_script_get_active(&self, account_id: u32) -> trc::Result> { + // Find the currently active script + if let Some(document_id) = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::ActiveScriptId.into()), + }) + .await + .caused_by(trc::location!())? + { + if let Some(script) = self.sieve_script_compile(account_id, document_id).await? { + Ok(Some(ActiveScript { + document_id, + script: Arc::new(script.script), + script_name: script.name, + version: script.version, + })) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + async fn sieve_script_get_by_name( + &self, + account_id: u32, + name: &str, + ) -> trc::Result> { + // Find the script by name + if let Some(document_id) = self + .document_ids_matching( + account_id, + Collection::SieveScript, + SieveField::Name, + name.to_lowercase().as_bytes(), + ) + .await + .caused_by(trc::location!())? + .min() + { + self.sieve_script_compile(account_id, document_id) + .await + .map(|script| script.map(|s| s.script)) + } else { + Ok(None) + } + } + + #[allow(clippy::blocks_in_conditions)] + async fn sieve_script_compile( + &self, + account_id: u32, + document_id: u32, + ) -> trc::Result> { + // Obtain script object + let Some(script_object) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + else { + return Ok(None); + }; + + // Obtain the sieve script length + let version = script_object.version; + let unarchived_script = script_object + .unarchive::() + .caused_by(trc::location!())?; + let script_offset = u32::from(unarchived_script.size) as usize; + + // Obtain the sieve script blob + let script_bytes = self + .core + .storage + .blob + .get_blob(unarchived_script.blob_hash.0.as_ref(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .caused_by(trc::location!()) + .document_id(document_id) + })?; + + // Obtain the precompiled script + if let Some(script) = script_bytes.get(script_offset..).and_then(|bytes| { + as Deserialize>::deserialize(bytes) + .ok()? + .deserialize::() + .ok() + }) { + Ok(Some(CompiledScript { + script, + name: unarchived_script.name.as_str().into(), + version, + })) + } else { + // Deserialization failed, probably because the script compiler version changed + match self.core.sieve.untrusted_compiler.compile( + script_bytes.get(0..script_offset).ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .caused_by(trc::location!()) + .document_id(document_id) + })?, + ) { + Ok(sieve) => { + // Store updated compiled sieve script + let sieve = Archiver::new(sieve).untrusted(); + let compiled_bytes = sieve.serialize().caused_by(trc::location!())?; + let mut updated_sieve_bytes = + Vec::with_capacity(script_offset + compiled_bytes.len()); + updated_sieve_bytes.extend_from_slice(&script_bytes[0..script_offset]); + updated_sieve_bytes.extend_from_slice(&compiled_bytes); + + // Store updated blob + let (new_blob_hash, new_blob_hold) = self + .put_temporary_blob(account_id, &updated_sieve_bytes, 60) + .await?; + let mut new_script_object = + rkyv::deserialize(unarchived_script).caused_by(trc::location!())?; + let blob_hash = + std::mem::replace(&mut new_script_object.blob_hash, new_blob_hash.clone()); + let new_archive = Archiver::new(new_script_object); + + // Update script object + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .assert_value(SieveField::Archive, &script_object) + .set( + SieveField::Archive, + new_archive.serialize().caused_by(trc::location!())?, + ) + .clear(BlobOp::Link { + hash: blob_hash, + to: BlobLink::Document, + }) + .set( + BlobOp::Link { + hash: new_blob_hash, + to: BlobLink::Document, + }, + Vec::new(), + ) + .clear(new_blob_hold); + self.store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + Ok(Some(CompiledScript { + script: sieve.into_inner(), + name: new_archive.into_inner().name, + version, + })) + } + Err(error) => Err(trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(error) + .details("Failed to compile Sieve script")), + } + } + } +} + +fn write_received_header(buf: &mut Vec, hostname: &str, id: u64) { + buf.extend_from_slice(b"Received: from localhost (localhost [127.0.0.1])\r\n\tby "); + buf.extend_from_slice(hostname.as_bytes()); + buf.extend_from_slice(b" (Stalwart SMTP) with LMTP id "); + buf.extend_from_slice(format!("{id:X}").as_bytes()); + buf.extend_from_slice(b";\r\n\t"); + buf.extend_from_slice(Date::now().to_rfc822().as_bytes()); + buf.extend_from_slice(b"\r\n"); +} + +pub struct CompiledScript { + pub script: Sieve, + pub name: String, + pub version: ArchiveVersion, +} diff --git a/crates/email/src/sieve/mod.rs b/crates/email/src/sieve/mod.rs new file mode 100644 index 0000000..873614a --- /dev/null +++ b/crates/email/src/sieve/mod.rs @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::KV_SIEVE_ID; +use sieve::Sieve; +use std::sync::Arc; +use store::{blake3, write::ArchiveVersion}; +use types::blob_hash::BlobHash; + +pub mod delete; +pub mod index; +pub mod ingest; + +#[derive(Debug, Clone)] +pub struct ActiveScript { + pub document_id: u32, + pub version: ArchiveVersion, + pub script_name: String, + pub script: Arc, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct SieveScript { + pub name: String, + pub blob_hash: BlobHash, + pub size: u32, + pub vacation_response: Option, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct VacationResponse { + pub from_date: Option, + pub to_date: Option, + pub subject: Option, + pub text_body: Option, + pub html_body: Option, +} + +impl SieveScript { + pub fn new(name: impl Into, blob_hash: BlobHash) -> Self { + SieveScript { + name: name.into(), + blob_hash, + vacation_response: None, + size: 0, + } + } + + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + pub fn with_blob_hash(mut self, blob_hash: BlobHash) -> Self { + self.blob_hash = blob_hash; + self + } + + pub fn with_size(mut self, size: u32) -> Self { + self.size = size; + self + } + + pub fn with_vacation_response(mut self, vacation_response: VacationResponse) -> Self { + self.vacation_response = Some(vacation_response); + self + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct SeenIdHash(pub [u8; 32]); + +impl SeenIdHash { + pub fn new(account_id: u32, hash: u32, id: &str) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(&account_id.to_be_bytes()); + hasher.update(&hash.to_be_bytes()); + hasher.update(id.as_bytes()); + SeenIdHash(hasher.finalize().into()) + } + + pub fn key(&self) -> Vec { + let mut result = Vec::with_capacity(self.0.len() + 1); + result.push(KV_SIEVE_ID); + result.extend_from_slice(&self.0); + result + } +} + +impl AsRef<[u8]> for SeenIdHash { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/crates/email/src/submission/index.rs b/crates/email/src/submission/index.rs new file mode 100644 index 0000000..f80e267 --- /dev/null +++ b/crates/email/src/submission/index.rs @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedEmailSubmission, EmailSubmission}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use store::{ + U32_LEN, U64_LEN, + write::{IndexPropertyClass, ValueClass, key::KeySerializer}, +}; +use types::{collection::SyncCollection, field::EmailSubmissionField}; + +impl IndexableObject for EmailSubmission { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: self.send_at, + }), + value: KeySerializer::new(U32_LEN * 3 + U64_LEN + 1) + .write(self.email_id) + .write(self.thread_id) + .write(self.identity_id) + .write(self.queue_id.unwrap_or_default()) + .write(self.undo_status.as_index()) + .finalize() + .into(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::EmailSubmission, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedEmailSubmission { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: self.send_at.to_native(), + }), + value: KeySerializer::new(U32_LEN * 3 + U64_LEN + 1) + .write(self.email_id.to_native()) + .write(self.thread_id.to_native()) + .write(self.identity_id.to_native()) + .write(self.queue_id.as_ref().map(u64::from).unwrap_or_default()) + .write(self.undo_status.as_index()) + .finalize() + .into(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::EmailSubmission, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for EmailSubmission { + fn is_versioned() -> bool { + false + } +} diff --git a/crates/email/src/submission/mod.rs b/crates/email/src/submission/mod.rs new file mode 100644 index 0000000..ed59eae --- /dev/null +++ b/crates/email/src/submission/mod.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use utils::map::vec_map::VecMap; + +pub mod index; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct EmailSubmission { + pub email_id: u32, + pub thread_id: u32, + pub identity_id: u32, + pub send_at: u64, + pub queue_id: Option, + pub undo_status: UndoStatus, + pub envelope: Envelope, + pub delivery_status: VecMap, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct Envelope { + pub mail_from: Address, + pub rcpt_to: Vec
, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct Address { + pub email: String, + pub parameters: Option>>, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct DeliveryStatus { + pub smtp_reply: String, + pub delivered: Delivered, + pub displayed: bool, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub enum Delivered { + Queued, + Yes, + No, + #[default] + Unknown, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub enum UndoStatus { + #[default] + Pending, + Final, + Canceled, +} + +impl UndoStatus { + pub fn parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "pending" => UndoStatus::Pending, + "final" => UndoStatus::Final, + "canceled" => UndoStatus::Canceled, + "cancelled" => UndoStatus::Canceled, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + UndoStatus::Pending => "pending", + UndoStatus::Final => "final", + UndoStatus::Canceled => "canceled", + } + } + + pub fn as_index(&self) -> u8 { + match self { + UndoStatus::Pending => b'p', + UndoStatus::Final => b'f', + UndoStatus::Canceled => b'c', + } + } +} + +impl ArchivedUndoStatus { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedUndoStatus::Pending => "pending", + ArchivedUndoStatus::Final => "final", + ArchivedUndoStatus::Canceled => "canceled", + } + } + + pub fn as_index(&self) -> u8 { + match self { + ArchivedUndoStatus::Pending => b'p', + ArchivedUndoStatus::Final => b'f', + ArchivedUndoStatus::Canceled => b'c', + } + } +} + +impl From<&ArchivedDeliveryStatus> for DeliveryStatus { + fn from(value: &ArchivedDeliveryStatus) -> Self { + DeliveryStatus { + smtp_reply: value.smtp_reply.to_string(), + delivered: match value.delivered { + ArchivedDelivered::Queued => Delivered::Queued, + ArchivedDelivered::Yes => Delivered::Yes, + ArchivedDelivered::No => Delivered::No, + ArchivedDelivered::Unknown => Delivered::Unknown, + }, + displayed: value.displayed, + } + } +} + +impl Delivered { + pub fn as_str(&self) -> &'static str { + match self { + Delivered::Queued => "queued", + Delivered::Yes => "yes", + Delivered::No => "no", + Delivered::Unknown => "unknown", + } + } +} + +impl ArchivedDelivered { + pub fn as_str(&self) -> &'static str { + match self { + ArchivedDelivered::Queued => "queued", + ArchivedDelivered::Yes => "yes", + ArchivedDelivered::No => "no", + ArchivedDelivered::Unknown => "unknown", + } + } +} diff --git a/crates/groupware/Cargo.toml b/crates/groupware/Cargo.toml new file mode 100644 index 0000000..1d557f7 --- /dev/null +++ b/crates/groupware/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "groupware" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +store = { path = "../store" } +common = { path = "../common" } +types = { path = "../types" } +trc = { path = "../trc" } +nlp = { path = "../nlp" } +registry = { path = "../registry" } +calcard = { version = "0.3", features = ["rkyv"] } +hashify = "0.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } +percent-encoding = "2.3.2" +compact_str = "0.10.0" +ahash = { version = "0.8" } +indexmap = "2.14.1" +chrono = "0.4.45" +icu_locale_core = "2" +icu_provider = { version = "2", features = ["sync"] } +icu_plurals = "2" +serde = { version = "1.0", features = ["derive"]} +serde_json = "1.0" +icu_datetime = { version = "~2.3", features = ["unstable_chrono_0_4"] } + +[features] +test_mode = [] +enterprise = [] + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs new file mode 100644 index 0000000..f9dbe14 --- /dev/null +++ b/crates/groupware/src/cache/calcard.rs @@ -0,0 +1,404 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::GroupwareCache; +use crate::{ + DavResourceName, RFC_3986, + calendar::{ + ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, SCHEDULE_INBOX_ID, + SCHEDULE_OUTBOX_ID, storage::ItipAutoExpunge, + }, + contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, + encode_path_segment, +}; +use calcard::common::timezone::Tz; +use common::{ + DavName, DavPath, DavResource, DavResourceMetadata, DavResources, Server, + TinyCalendarPreferences, UpdateLock, +}; +use std::sync::Arc; +use store::ahash::{AHashMap, AHashSet}; +use trc::AddContext; +use types::{ + acl::AclGrant, + collection::{Collection, SyncCollection}, +}; +use utils::map::bitmap::Bitmap; + +pub(super) async fn build_calcard_resources( + server: &Server, + access_account_id: u32, + account_id: u32, + sync_collection: SyncCollection, + container_collection: Collection, + item_collection: Collection, + update_lock: Arc, +) -> trc::Result { + let is_calendar = matches!(sync_collection, SyncCollection::Calendar); + let owner_account_info = server.account(account_id).await?; + let access_account_info = if account_id == access_account_id { + owner_account_info.clone() + } else { + server.account(access_account_id).await? + }; + let mut cache = DavResources { + base_path: format!( + "{}/{}/", + if is_calendar { + DavResourceName::Cal + } else { + DavResourceName::Card + } + .base_path(), + percent_encoding::utf8_percent_encode(owner_account_info.name(), RFC_3986), + ), + paths: AHashSet::with_capacity(16), + resources: Vec::with_capacity(16), + item_change_id: 0, + container_change_id: 0, + highest_change_id: 0, + size: std::mem::size_of::() as u64, + update_lock, + }; + + let mut is_first_check = true; + loop { + let last_change_id = server + .core + .storage + .data + .get_last_change_id(account_id, sync_collection.into()) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + cache.item_change_id = last_change_id; + cache.container_change_id = last_change_id; + cache.highest_change_id = last_change_id; + cache.update_lock.set_revision(last_change_id); + + server + .archives( + account_id, + container_collection, + &(), + |document_id, archive| { + let resource = if is_calendar { + resource_from_calendar(archive.unarchive::()?, document_id) + } else { + resource_from_addressbook(archive.unarchive::()?, document_id) + }; + let path = DavPath { + path: encode_path_segment(resource.container_name().unwrap()).into_owned(), + parent_id: None, + hierarchy_seq: 1, + resource_idx: cache.resources.len(), + }; + + cache.size += (std::mem::size_of::() + + std::mem::size_of::() + + (path.path.len()) * 2) as u64; + cache.paths.insert(path); + cache.resources.push(resource); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if cache.paths.is_empty() { + if is_first_check { + if is_calendar { + server + .create_default_calendar(&access_account_info, &owner_account_info) + .await?; + } else { + server + .create_default_addressbook(&access_account_info, &owner_account_info) + .await?; + } + is_first_check = false; + continue; + } else { + return Ok(cache); + } + } + + let parent_range = cache.resources.len(); + server + .archives(account_id, item_collection, &(), |document_id, archive| { + let resource = if is_calendar { + resource_from_event(archive.unarchive::()?, document_id) + } else { + resource_from_card(archive.unarchive::()?, document_id) + }; + let resource_idx = cache.resources.len(); + + for name in resource.child_names().unwrap_or_default().iter() { + if let Some(parent) = + cache.resources.get(..parent_range).and_then(|resources| { + resources.iter().find(|r| r.document_id == name.parent_id) + }) + { + let path = DavPath { + path: format!( + "{}/{}", + encode_path_segment(parent.container_name().unwrap()), + encode_path_segment(&name.name) + ), + parent_id: Some(name.parent_id), + hierarchy_seq: 0, + resource_idx, + }; + + cache.size += (std::mem::size_of::() + + name.name.len() + + path.path.len()) as u64; + cache.paths.insert(path); + } + } + cache.size += std::mem::size_of::() as u64; + cache.resources.push(resource); + + Ok(true) + }) + .await + .caused_by(trc::location!())?; + + return Ok(cache); + } +} + +pub(super) async fn build_scheduling_resources( + server: &Server, + account_id: u32, + update_lock: Arc, +) -> trc::Result { + let last_change_id = server + .core + .storage + .data + .get_last_change_id(account_id, SyncCollection::CalendarEventNotification.into()) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + + let account_info = server.account(account_id).await?; + let item_ids = server + .itip_ids(account_id) + .await + .caused_by(trc::location!())?; + + update_lock.set_revision(last_change_id); + let mut cache = DavResources { + base_path: format!( + "{}/{}/", + DavResourceName::Scheduling.base_path(), + percent_encoding::utf8_percent_encode(account_info.name(), RFC_3986), + ), + paths: AHashSet::with_capacity((2 + item_ids.len()) as usize), + resources: Vec::with_capacity((2 + item_ids.len()) as usize), + item_change_id: last_change_id, + container_change_id: last_change_id, + highest_change_id: last_change_id, + size: std::mem::size_of::() as u64, + update_lock, + }; + + for (document_id, is_container) in item_ids + .into_iter() + .map(|document_id| (document_id, false)) + .chain([(SCHEDULE_INBOX_ID, true), (SCHEDULE_OUTBOX_ID, true)]) + { + let path = path_from_scheduling(document_id, cache.resources.len(), is_container); + cache.size += (std::mem::size_of::() + (path.path.len() * 2)) as u64 + + std::mem::size_of::() as u64; + cache.paths.insert(path); + cache + .resources + .push(resource_from_scheduling(document_id, is_container)); + } + + Ok(cache) +} + +pub(super) fn build_simple_hierarchy(cache: &mut DavResources) { + cache.paths = AHashSet::with_capacity(cache.resources.len()); + let name_idx = cache + .resources + .iter() + .filter_map(|resource| { + resource + .container_name() + .map(|name| (resource.document_id, name)) + }) + .collect::>(); + + for (resource_idx, resource) in cache.resources.iter().enumerate() { + match &resource.data { + DavResourceMetadata::Calendar { name, .. } + | DavResourceMetadata::AddressBook { name, .. } => { + let path = DavPath { + path: encode_path_segment(name).into_owned(), + parent_id: None, + hierarchy_seq: 1, + resource_idx, + }; + cache.size += + (std::mem::size_of::() + name.len() + path.path.len()) as u64; + cache.paths.insert(path); + } + DavResourceMetadata::CalendarEvent { names, .. } + | DavResourceMetadata::ContactCard { names } => { + for name in names { + if let Some(parent_name) = name_idx.get(&name.parent_id) { + let path = DavPath { + path: format!( + "{}/{}", + encode_path_segment(parent_name), + encode_path_segment(&name.name) + ), + parent_id: Some(name.parent_id), + hierarchy_seq: 0, + resource_idx, + }; + cache.size += (std::mem::size_of::() + + name.name.len() + + path.path.len()) as u64; + cache.paths.insert(path); + } + } + } + _ => unreachable!(), + } + cache.size += std::mem::size_of::() as u64; + } +} + +pub(super) fn resource_from_calendar(calendar: &ArchivedCalendar, document_id: u32) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::Calendar { + name: calendar.name.to_string(), + acls: calendar + .acls + .iter() + .map(|acl| AclGrant { + account_id: acl.account_id.to_native(), + grants: Bitmap::from(&acl.grants), + }) + .collect(), + preferences: calendar + .preferences + .iter() + .map(|pref| TinyCalendarPreferences { + account_id: pref.account_id.to_native(), + flags: pref.flags.to_native(), + tz: pref.time_zone.tz().unwrap_or(Tz::UTC), + }) + .collect(), + }, + } +} + +pub(super) fn resource_from_event(event: &ArchivedCalendarEvent, document_id: u32) -> DavResource { + let (start, duration) = event.data.event_range().unwrap_or_default(); + DavResource { + document_id, + data: DavResourceMetadata::CalendarEvent { + names: event + .names + .iter() + .map(|name| DavName { + name: name.name.to_string(), + parent_id: name.parent_id.to_native(), + }) + .collect(), + start, + duration, + }, + } +} + +pub(super) fn resource_from_scheduling(document_id: u32, is_container: bool) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::CalendarEventNotification { + names: if !is_container { + [DavName { + name: format!("{document_id}.ics"), + parent_id: SCHEDULE_INBOX_ID, + }] + .into_iter() + .collect() + } else { + Default::default() + }, + }, + } +} + +pub(super) fn path_from_scheduling( + document_id: u32, + resource_idx: usize, + is_container: bool, +) -> DavPath { + if is_container { + DavPath { + path: if document_id == SCHEDULE_INBOX_ID { + "inbox".to_string() + } else { + "outbox".to_string() + }, + parent_id: None, + hierarchy_seq: 1, + resource_idx, + } + } else { + DavPath { + path: format!("inbox/{document_id}.ics"), + parent_id: Some(SCHEDULE_INBOX_ID), + hierarchy_seq: 0, + resource_idx, + } + } +} + +pub(super) fn resource_from_addressbook( + book: &ArchivedAddressBook, + document_id: u32, +) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::AddressBook { + name: book.name.to_string(), + acls: book + .acls + .iter() + .map(|acl| AclGrant { + account_id: acl.account_id.to_native(), + grants: Bitmap::from(&acl.grants), + }) + .collect(), + }, + } +} + +pub(super) fn resource_from_card(card: &ArchivedContactCard, document_id: u32) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::ContactCard { + names: card + .names + .iter() + .map(|name| DavName { + name: name.name.to_string(), + parent_id: name.parent_id.to_native(), + }) + .collect(), + }, + } +} diff --git a/crates/groupware/src/cache/file.rs b/crates/groupware/src/cache/file.rs new file mode 100644 index 0000000..09885bd --- /dev/null +++ b/crates/groupware/src/cache/file.rs @@ -0,0 +1,281 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + DavResourceName, RFC_3986, encode_path_segment, + file::{ArchivedFileNode, FileNode}, +}; +use common::{DavPath, DavResource, DavResourceMetadata, DavResources, Server, UpdateLock}; +use std::sync::Arc; +use store::ahash::{AHashMap, AHashSet}; +use trc::AddContext; +use types::{ + acl::AclGrant, + collection::{Collection, SyncCollection}, +}; +use utils::{map::bitmap::Bitmap, topological::TopologicalSort}; + +pub(super) async fn build_file_resources( + server: &Server, + account_id: u32, + update_lock: Arc, +) -> trc::Result { + let last_change_id = server + .core + .storage + .data + .get_last_change_id(account_id, SyncCollection::FileNode.into()) + .await + .caused_by(trc::location!())? + .unwrap_or_default(); + let account_info = server.account(account_id).await?; + + let mut resources = Vec::with_capacity(16); + server + .archives( + account_id, + Collection::FileNode, + &(), + |document_id, archive| { + resources.push(resource_from_file( + archive.unarchive::()?, + document_id, + )); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + update_lock.set_revision(last_change_id); + let mut files = DavResources { + base_path: format!( + "{}/{}/", + DavResourceName::File.base_path(), + percent_encoding::utf8_percent_encode(account_info.name(), RFC_3986), + ), + size: std::mem::size_of::() as u64, + paths: AHashSet::with_capacity(resources.len()), + resources, + item_change_id: last_change_id, + container_change_id: last_change_id, + highest_change_id: last_change_id, + update_lock, + }; + + build_nested_hierarchy(&mut files); + + Ok(files) +} + +pub(super) fn build_nested_hierarchy(resources: &mut DavResources) { + let mut topological_sort = TopologicalSort::with_capacity(resources.resources.len()); + let mut names = AHashMap::with_capacity(resources.resources.len()); + + for (resource_idx, resource) in resources.resources.iter().enumerate() { + if let DavResourceMetadata::File { parent_id, .. } = resource.data { + topological_sort.insert( + parent_id.map(|id| id + 1).unwrap_or_default(), + resource.document_id + 1, + ); + names.insert( + resource.document_id, + DavPath { + path: encode_path_segment(resource.container_name().unwrap()).into_owned(), + parent_id, + hierarchy_seq: 0, + resource_idx, + }, + ); + } + } + + for (hierarchy_sequence, folder_id) in topological_sort.into_iterator().enumerate() { + if folder_id != 0 { + let folder_id = folder_id - 1; + let path = names + .get(&folder_id) + .and_then(|folder| folder.parent_id.map(|parent_id| (&folder.path, parent_id))) + .and_then(|(name, parent_id)| { + names + .get(&parent_id) + .map(|parent| format!("{}/{}", parent.path, name)) + }); + + if let Some(folder) = names.get_mut(&folder_id) { + if let Some(path) = path { + folder.path = path; + } + folder.hierarchy_seq = hierarchy_sequence as u32; + } + } + } + + resources.paths = names + .into_values() + .inspect(|v| { + resources.size += (std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::() + + v.path.len()) as u64; + }) + .collect(); +} + +pub(super) fn resource_from_file(node: &ArchivedFileNode, document_id: u32) -> DavResource { + let parent_id = node.parent_id.to_native(); + DavResource { + document_id, + data: DavResourceMetadata::File { + name: node.name.as_str().to_string(), + size: node.file.as_ref().map(|f| f.size.to_native()), + parent_id: if parent_id > 0 { + Some(parent_id - 1) + } else { + None + }, + acls: node + .acls + .iter() + .map(|acl| AclGrant { + account_id: acl.account_id.to_native(), + grants: Bitmap::from(&acl.grants), + }) + .collect(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MISSING_DOCUMENT_ID: u32 = 9; + + fn folder(document_id: u32, name: &str, parent_id: Option) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::File { + name: name.to_string(), + size: None, + parent_id, + acls: Default::default(), + }, + } + } + + fn file(document_id: u32, name: &str, parent_id: Option) -> DavResource { + DavResource { + document_id, + data: DavResourceMetadata::File { + name: name.to_string(), + size: Some(1024), + parent_id, + acls: Default::default(), + }, + } + } + + fn build(resources: Vec) -> DavResources { + let mut files = DavResources { + base_path: "/dav/file/john/".to_string(), + paths: AHashSet::with_capacity(resources.len()), + resources, + item_change_id: 0, + container_change_id: 0, + highest_change_id: 0, + size: 0, + update_lock: Arc::new(UpdateLock::new()), + }; + build_nested_hierarchy(&mut files); + files + } + + fn sorted_paths(files: &DavResources) -> Vec<&str> { + let mut paths = files + .paths + .iter() + .map(|path| path.path.as_str()) + .collect::>(); + paths.sort_unstable(); + paths + } + + fn hierarchy_seq(files: &DavResources, path: &str) -> u32 { + files.paths.get(path).expect(path).hierarchy_seq + } + + #[test] + fn nested_hierarchy() { + let files = build(vec![ + folder(0, "docs", None), + folder(1, "reports", Some(0)), + file(2, "q1.txt", Some(1)), + file(3, "readme.txt", None), + ]); + + assert_eq!( + sorted_paths(&files), + ["docs", "docs/reports", "docs/reports/q1.txt", "readme.txt"] + ); + assert!(hierarchy_seq(&files, "docs") < hierarchy_seq(&files, "docs/reports")); + assert!( + hierarchy_seq(&files, "docs/reports") < hierarchy_seq(&files, "docs/reports/q1.txt") + ); + } + + #[test] + fn nested_hierarchy_percent_encodes_paths() { + let files = build(vec![ + folder(0, "My Documents", None), + folder(1, "Berichte 2026", Some(0)), + file(2, "Ünterlagen Q1.txt", Some(1)), + folder(3, "My%20Folder", None), + file(4, "file(1)+a:b.txt", Some(3)), + ]); + + assert_eq!( + sorted_paths(&files), + [ + "My%20Documents", + "My%20Documents/Berichte%202026", + "My%20Documents/Berichte%202026/%C3%9Cnterlagen%20Q1.txt", + "My%20Folder", + "My%20Folder/file(1)+a:b.txt", + ] + ); + assert_eq!( + files.format_resource(files.by_path("My%20Documents").unwrap()), + "/dav/file/john/My%20Documents/" + ); + assert_eq!( + files.format_resource( + files + .by_path("My%20Documents/Berichte%202026/%C3%9Cnterlagen%20Q1.txt") + .unwrap() + ), + "/dav/file/john/My%20Documents/Berichte%202026/%C3%9Cnterlagen%20Q1.txt" + ); + } + + #[test] + fn nested_hierarchy_with_dangling_parent() { + let files = build(vec![ + folder(0, "docs", None), + folder(1, "reports", Some(MISSING_DOCUMENT_ID)), + file(2, "q1.txt", Some(1)), + file(3, "orphan.txt", Some(MISSING_DOCUMENT_ID)), + ]); + + assert_eq!( + sorted_paths(&files), + ["docs", "orphan.txt", "reports", "reports/q1.txt"] + ); + assert!(hierarchy_seq(&files, "reports") < hierarchy_seq(&files, "reports/q1.txt")); + } +} diff --git a/crates/groupware/src/cache/mod.rs b/crates/groupware/src/cache/mod.rs new file mode 100644 index 0000000..1d746fd --- /dev/null +++ b/crates/groupware/src/cache/mod.rs @@ -0,0 +1,653 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + cache::calcard::{build_scheduling_resources, path_from_scheduling, resource_from_scheduling}, + calendar::{CALENDAR_SUBSCRIBED, Calendar, CalendarEvent, CalendarPreferences}, + contact::{AddressBook, AddressBookPreferences, ContactCard}, + file::FileNode, +}; +use ahash::AHashSet; +use calcard::{ + build_calcard_resources, build_simple_hierarchy, resource_from_addressbook, + resource_from_calendar, resource_from_card, resource_from_event, +}; +use common::{ + DavResource, DavResources, Server, UpdateLock, auth::AccountCache, cache::LockResult, +}; +use file::{build_file_resources, build_nested_hierarchy, resource_from_file}; +use std::{sync::Arc, time::Instant}; +use store::{ + SerializeInfallible, ValueKey, + ahash::AHashMap, + query::log::{Change, Query}, + write::{AlignedBytes, Archive, BatchBuilder, ValueClass}, +}; +use trc::{AddContext, StoreEvent}; +use types::{ + collection::{Collection, SyncCollection}, + field::PrincipalField, +}; +use utils::cache::Cache; + +pub mod calcard; +pub mod file; + +pub trait GroupwareCache: Sync + Send { + fn fetch_dav_resources( + &self, + access_account_id: u32, + account_id: u32, + collection: SyncCollection, + ) -> impl Future>> + Send; + + fn create_default_addressbook( + &self, + account_info_access: &AccountCache, + account_info_owner: &AccountCache, + ) -> impl Future>> + Send; + + fn create_default_calendar( + &self, + account_info_access: &AccountCache, + account_info_owner: &AccountCache, + ) -> impl Future>> + Send; + + fn get_or_create_default_calendar( + &self, + access_account_id: u32, + account_id: u32, + ) -> impl Future>> + Send; + + fn cached_dav_resources( + &self, + account_id: u32, + collection: SyncCollection, + ) -> Option>; +} + +impl GroupwareCache for Server { + async fn fetch_dav_resources( + &self, + access_account_id: u32, + account_id: u32, + collection: SyncCollection, + ) -> trc::Result> { + let cache_store = match collection { + SyncCollection::Calendar => &self.inner.cache.events, + SyncCollection::AddressBook => &self.inner.cache.contacts, + SyncCollection::FileNode => &self.inner.cache.files, + SyncCollection::CalendarEventNotification => &self.inner.cache.scheduling, + _ => unreachable!(), + }; + let mut cache = match cache_store.get_value_or_guard_async(&account_id).await { + Ok(cache) => cache, + Err(guard) => { + let start_time = Instant::now(); + let cache = full_cache_build( + self, + account_id, + collection, + Arc::new(UpdateLock::new()), + access_account_id, + ) + .await?; + + if guard.insert(cache.clone()).is_err() { + cache_store.update(account_id, cache.clone()); + } + warn_if_uncacheable(cache_store, account_id, collection, &cache); + + trc::event!( + Store(StoreEvent::CacheMiss), + AccountId = account_id, + Collection = collection.as_str(), + Total = cache.resources.len(), + ChangeId = cache.highest_change_id, + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + }; + + // Obtain current state + let start_time = Instant::now(); + let changes = self + .core + .storage + .data + .changes( + account_id, + collection.into(), + Query::Since(cache.highest_change_id), + ) + .await + .caused_by(trc::location!())?; + + // Regenerate cache if the change log has been truncated + if changes.is_truncated { + let cache = full_cache_build( + self, + account_id, + collection, + cache.update_lock.clone(), + access_account_id, + ) + .await?; + cache_store.update(account_id, cache.clone()); + warn_if_uncacheable(cache_store, account_id, collection, &cache); + + trc::event!( + Store(StoreEvent::CacheStale), + AccountId = account_id, + Collection = collection.as_str(), + ChangeId = cache.highest_change_id, + Total = cache.resources.len(), + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + + // Verify changes + if changes.changes.is_empty() { + trc::event!( + Store(StoreEvent::CacheHit), + AccountId = account_id, + Collection = collection.as_str(), + ChangeId = cache.highest_change_id, + Elapsed = start_time.elapsed(), + ); + + return Ok(cache); + } + + // Lock for updates + let lock = cache.update_lock.clone(); + let _permit = match lock.acquire(cache.highest_change_id).await? { + LockResult::Acquired(permit) => permit, + LockResult::Stale(permit) => { + cache = cache_store.peek(&account_id).unwrap_or(cache.clone()); + if cache.highest_change_id >= changes.to_change_id { + trc::event!( + Store(StoreEvent::CacheHit), + AccountId = account_id, + Collection = collection.as_str(), + ChangeId = cache.highest_change_id, + Elapsed = start_time.elapsed(), + ); + return Ok(cache); + } + + permit + } + }; + + let num_changes = changes.changes.len(); + let cache = if !matches!(collection, SyncCollection::CalendarEventNotification) { + let mut updated_resources = AHashMap::with_capacity(8); + let has_no_children = collection == SyncCollection::FileNode; + + process_changes( + self, + account_id, + collection, + has_no_children, + &mut updated_resources, + changes.changes, + ) + .await?; + + let mut rebuild_hierarchy = false; + let mut resources = Vec::with_capacity(cache.resources.len()); + + for resource in &cache.resources { + let is_container = has_no_children || resource.is_container(); + if let Some(updated_resource) = + updated_resources.remove(&(is_container, resource.document_id)) + { + if let Some(updated_resource) = updated_resource { + rebuild_hierarchy = + rebuild_hierarchy || updated_resource.has_hierarchy_changes(resource); + resources.push(updated_resource); + } else { + // Deleted resource + rebuild_hierarchy = true; + } + } else { + resources.push(resource.clone()); + } + } + + // Add new resources + for resource in updated_resources.into_values().flatten() { + resources.push(resource); + rebuild_hierarchy = true; + } + + if rebuild_hierarchy { + let mut cache = DavResources { + base_path: cache.base_path.clone(), + paths: Default::default(), + resources, + item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), + container_change_id: changes + .container_change_id + .unwrap_or(cache.container_change_id), + highest_change_id: changes.to_change_id, + size: std::mem::size_of::() as u64, + update_lock: lock.clone(), + }; + + if matches!(collection, SyncCollection::FileNode) { + build_nested_hierarchy(&mut cache); + } else { + build_simple_hierarchy(&mut cache); + } + cache + } else { + DavResources { + base_path: cache.base_path.clone(), + paths: cache.paths.clone(), + resources, + item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), + container_change_id: changes + .container_change_id + .unwrap_or(cache.container_change_id), + highest_change_id: changes.to_change_id, + size: cache.size, + update_lock: lock.clone(), + } + } + } else { + let mut delete_ids = AHashSet::with_capacity(changes.changes.len()); + let mut resources = Vec::with_capacity(cache.resources.len()); + let mut paths = AHashSet::with_capacity(cache.paths.len()); + + for change in changes.changes { + match change { + Change::InsertItem(document_id) => { + let document_id = document_id as u32; + paths.insert(path_from_scheduling(document_id, resources.len(), false)); + resources.push(resource_from_scheduling(document_id, false)); + } + Change::DeleteItem(document_id) => { + delete_ids.insert(document_id as u32); + } + _ => {} + } + } + + for resource in &cache.resources { + if !delete_ids.contains(&resource.document_id) { + paths.insert(path_from_scheduling( + resource.document_id, + resources.len(), + resource.is_container(), + )); + resources.push(resource.clone()); + } + } + + DavResources { + base_path: cache.base_path.clone(), + paths, + resources, + item_change_id: changes.item_change_id.unwrap_or(cache.item_change_id), + container_change_id: changes + .container_change_id + .unwrap_or(cache.container_change_id), + highest_change_id: changes.to_change_id, + size: cache.size, + update_lock: cache.update_lock.clone(), + } + }; + + cache.update_lock.set_revision(cache.highest_change_id); + let cache = Arc::new(cache); + cache_store.update(account_id, cache.clone()); + warn_if_uncacheable(cache_store, account_id, collection, &cache); + + trc::event!( + Store(StoreEvent::CacheUpdate), + AccountId = account_id, + Collection = collection.as_str(), + ChangeId = cache.highest_change_id, + Details = num_changes, + Total = cache.resources.len(), + Elapsed = start_time.elapsed(), + ); + + Ok(cache) + } + + async fn create_default_addressbook( + &self, + account_info_access: &AccountCache, + account_info_owner: &AccountCache, + ) -> trc::Result> { + if let Some(name) = &self.core.groupware.default_addressbook_name { + let mut batch = BatchBuilder::new(); + let account_id = account_info_owner.account_id(); + let account_name = account_info_owner.name(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::AddressBook, 1) + .await?; + AddressBook { + name: name.clone(), + preferences: vec![AddressBookPreferences { + account_id, + name: format!( + "{} ({})", + self.core + .groupware + .default_addressbook_display_name + .as_ref() + .unwrap_or(name), + account_name + ), + ..Default::default() + }], + subscribers: vec![account_id], + ..Default::default() + } + .insert( + account_info_access.account_tenant_ids(), + account_id, + document_id, + &mut batch, + )?; + + batch + .with_collection(Collection::Principal) + .with_document(0) + .set( + PrincipalField::DefaultAddressBookId, + document_id.serialize(), + ); + + self.commit_batch(batch).await?; + Ok(Some(document_id)) + } else { + Ok(None) + } + } + + async fn create_default_calendar( + &self, + account_info_access: &AccountCache, + account_info_owner: &AccountCache, + ) -> trc::Result> { + if let Some(name) = &self.core.groupware.default_calendar_name { + let mut batch = BatchBuilder::new(); + let account_id = account_info_owner.account_id(); + let account_name = account_info_owner.name(); + let document_id = self + .store() + .assign_document_ids(account_id, Collection::Calendar, 1) + .await?; + Calendar { + name: name.clone(), + preferences: vec![CalendarPreferences { + account_id, + name: format!( + "{} ({})", + self.core + .groupware + .default_calendar_display_name + .as_ref() + .unwrap_or(name), + account_name + ), + flags: CALENDAR_SUBSCRIBED, + ..Default::default() + }], + ..Default::default() + } + .insert( + account_info_access.account_tenant_ids(), + account_id, + document_id, + &mut batch, + )?; + + // Set default calendar + batch + .with_collection(Collection::Principal) + .with_document(0) + .set(PrincipalField::DefaultCalendarId, document_id.serialize()); + + self.commit_batch(batch).await?; + Ok(Some(document_id)) + } else { + Ok(None) + } + } + + async fn get_or_create_default_calendar( + &self, + access_account_id: u32, + account_id: u32, + ) -> trc::Result> { + let default_calendar_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())?; + if default_calendar_id.is_some() { + Ok(default_calendar_id) + } else { + self.fetch_dav_resources(access_account_id, account_id, SyncCollection::Calendar) + .await + .map(|c| c.document_ids(true).next()) + } + } + + #[inline(always)] + fn cached_dav_resources( + &self, + account_id: u32, + collection: SyncCollection, + ) -> Option> { + (match collection { + SyncCollection::Calendar => &self.inner.cache.events, + SyncCollection::AddressBook => &self.inner.cache.contacts, + SyncCollection::FileNode => &self.inner.cache.files, + _ => unreachable!(), + }) + .get(&account_id) + } +} + +async fn process_changes( + server: &Server, + account_id: u32, + collection: SyncCollection, + has_no_children: bool, + updated_resources: &mut AHashMap<(bool, u32), Option>, + changes: Vec, +) -> trc::Result<()> { + for change in changes { + match change { + Change::InsertItem(id) | Change::UpdateItem(id) => { + let document_id = id as u32; + if let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + collection.collection(false), + document_id, + )) + .await + .caused_by(trc::location!())? + { + updated_resources.insert( + (has_no_children, document_id), + Some(resource_from_archive( + archive, + document_id, + collection, + false, + )?), + ); + } else { + updated_resources.insert((has_no_children, document_id), None); + } + } + Change::DeleteItem(id) => { + updated_resources.insert((has_no_children, id as u32), None); + } + Change::InsertContainer(id) | Change::UpdateContainer(id) => { + let document_id = id as u32; + if let Some(archive) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + collection.collection(true), + document_id, + )) + .await + .caused_by(trc::location!())? + { + updated_resources.insert( + (true, document_id), + Some(resource_from_archive( + archive, + document_id, + collection, + true, + )?), + ); + } else { + updated_resources.insert((true, document_id), None); + } + } + Change::DeleteContainer(id) => { + updated_resources.insert((true, id as u32), None); + } + Change::UpdateContainerProperty(_) => (), + } + } + Ok(()) +} + +#[inline(always)] +fn warn_if_uncacheable( + cache_store: &Cache>, + account_id: u32, + collection: SyncCollection, + cache: &Arc, +) { + let capacity = cache_store.weight_capacity(); + if cache.size > capacity { + trc::event!( + Store(StoreEvent::CacheEntryTooLarge), + AccountId = account_id, + Collection = collection.as_str(), + Size = cache.size, + Limit = capacity, + ); + } +} + +async fn full_cache_build( + server: &Server, + account_id: u32, + collection: SyncCollection, + update_lock: Arc, + access_account_id: u32, +) -> trc::Result> { + match collection { + SyncCollection::Calendar => { + build_calcard_resources( + server, + access_account_id, + account_id, + SyncCollection::Calendar, + Collection::Calendar, + Collection::CalendarEvent, + update_lock, + ) + .await + } + SyncCollection::AddressBook => { + build_calcard_resources( + server, + access_account_id, + account_id, + SyncCollection::AddressBook, + Collection::AddressBook, + Collection::ContactCard, + update_lock, + ) + .await + } + SyncCollection::FileNode => build_file_resources(server, account_id, update_lock).await, + SyncCollection::CalendarEventNotification => { + build_scheduling_resources(server, account_id, update_lock).await + } + _ => unreachable!(), + } + .map(Arc::new) +} + +fn resource_from_archive( + archive: Archive, + document_id: u32, + collection: SyncCollection, + is_container: bool, +) -> trc::Result { + Ok(match collection { + SyncCollection::Calendar => { + if is_container { + resource_from_calendar( + archive + .unarchive::() + .caused_by(trc::location!())?, + document_id, + ) + } else { + resource_from_event( + archive + .unarchive::() + .caused_by(trc::location!())?, + document_id, + ) + } + } + SyncCollection::AddressBook => { + if is_container { + resource_from_addressbook( + archive + .unarchive::() + .caused_by(trc::location!())?, + document_id, + ) + } else { + resource_from_card( + archive + .unarchive::() + .caused_by(trc::location!())?, + document_id, + ) + } + } + SyncCollection::FileNode => resource_from_file( + archive + .unarchive::() + .caused_by(trc::location!())?, + document_id, + ), + _ => unreachable!(), + }) +} diff --git a/crates/groupware/src/calendar/alarm.rs b/crates/groupware/src/calendar/alarm.rs new file mode 100644 index 0000000..2bc59fd --- /dev/null +++ b/crates/groupware/src/calendar/alarm.rs @@ -0,0 +1,273 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, expand::resolve_local, +}; +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendarComponent, ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, + ICalendarRelated, ICalendarValue, + }, +}; +use std::str::FromStr; +use store::write::bitpack::BitpackIterator; +use utils::codec::leb128::Leb128Reader; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CalendarAlarm { + pub alarm_id: u16, + pub event_id: u16, + pub alarm_time: i64, + pub typ: CalendarAlarmType, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CalendarAlarmType { + Email { + event_start: i64, + event_start_tz: u16, + event_end: i64, + event_end_tz: u16, + }, + Display { + recurrence_id: Option, + }, +} + +impl ArchivedCalendarEventData { + pub fn next_alarm(&self, start_time: i64, default_tz: Tz) -> Option { + if self.alarms.is_empty() { + return None; + } + + let base_offset = self.base_offset.to_native(); + let mut next_alarm: Option = None; + + 'outer: for range in self.time_ranges.iter() { + let comp_id = range.id.to_native(); + let Some(alarm) = self.alarms.iter().find(|a| a.parent_id == comp_id) else { + continue; + }; + + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + + let duration = range.duration.to_native() as i64; + let mut start_tz = Tz::from_id(range.start_tz.to_native())?; + let mut end_tz = Tz::from_id(range.end_tz.to_native())?; + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + + if instances.len() > bytes_read { + // Recurring event + let unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + for start_offset in unpacker { + let start_date_naive = start_offset as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + continue; + }; + + if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) + && alarm_time > start_time + && next_alarm + .as_ref() + .is_none_or(|next| alarm_time < next.alarm_time) + { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + typ: if alarm.is_email_alert { + CalendarAlarmType::Email { + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + } + } else { + let comp = + &self.event.components[alarm.parent_id.to_native() as usize]; + + CalendarAlarmType::Display { + recurrence_id: if comp.is_recurrent_or_override() { + start_date_naive.into() + } else { + None + }, + } + }, + }); + continue 'outer; + } + } + } else { + // Single event + let start_date_naive = offset_or_count as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + continue; + }; + + if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) + && alarm_time > start_time + && next_alarm + .as_ref() + .is_none_or(|next| alarm_time < next.alarm_time) + { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + typ: if alarm.is_email_alert { + CalendarAlarmType::Email { + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + } + } else { + let comp = &self.event.components[alarm.parent_id.to_native() as usize]; + + CalendarAlarmType::Display { + recurrence_id: if comp.is_recurrent_or_override() { + start_date_naive.into() + } else { + None + }, + } + }, + }); + } + } + } + + next_alarm + } +} + +pub trait ExpandAlarm { + fn expand_alarm(&self, id: u16, parent_id: u16) -> Option; +} + +impl ExpandAlarm for ICalendarComponent { + fn expand_alarm(&self, id: u16, parent_id: u16) -> Option { + let mut trigger = None; + let mut is_email_alert = false; + + for entry in self.entries.iter() { + match &entry.name { + ICalendarProperty::Trigger => { + let mut tz = None; + let mut trigger_start = true; + + for param in entry.params.iter() { + match (¶m.name, ¶m.value) { + ( + ICalendarParameterName::Related, + ICalendarParameterValue::Related(related), + ) => { + trigger_start = matches!(related, ICalendarRelated::Start); + } + ( + ICalendarParameterName::Tzid, + ICalendarParameterValue::Text(tz_id), + ) => { + tz = Tz::from_str(tz_id).ok(); + } + _ => {} + } + } + + trigger = match entry.values.first()? { + ICalendarValue::PartialDateTime(dt) => { + let tz = tz.unwrap_or(Tz::Floating); + + dt.to_date_time_with_tz(tz).map(|dt| { + let timestamp = dt.timestamp(); + if !dt.timezone().is_floating() { + AlarmDelta::FixedUtc(timestamp) + } else { + AlarmDelta::FixedFloating(timestamp) + } + }) + } + ICalendarValue::Duration(duration) => { + if trigger_start { + Some(AlarmDelta::Start(duration.as_seconds())) + } else { + Some(AlarmDelta::End(duration.as_seconds())) + } + } + _ => None, + }; + } + ICalendarProperty::Action => { + is_email_alert = is_email_alert + || entry + .values + .first() + .and_then(|v| v.as_text()) + .is_some_and(|v| v.eq_ignore_ascii_case("email")); + } + ICalendarProperty::Summary | ICalendarProperty::Description => { + is_email_alert = is_email_alert + || entry + .values + .first() + .and_then(|v| v.as_text()) + .is_some_and(|v| v.contains("@email")); + } + _ => {} + } + } + + trigger.map(|delta| Alarm { + id, + parent_id, + delta, + is_email_alert, + }) + } +} + +impl AlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + AlarmDelta::Start(delta) => Some(start + delta), + AlarmDelta::End(delta) => Some(end + delta), + AlarmDelta::FixedUtc(timestamp) => Some(*timestamp), + AlarmDelta::FixedFloating(timestamp) => resolve_local(default_tz, *timestamp), + } + } +} + +impl ArchivedAlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()), + ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()), + ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()), + ArchivedAlarmDelta::FixedFloating(timestamp) => { + resolve_local(default_tz, timestamp.to_native()) + } + } + } +} diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs new file mode 100644 index 0000000..44b9284 --- /dev/null +++ b/crates/groupware/src/calendar/dates.rs @@ -0,0 +1,298 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedCalendarEventData, ArchivedTimezone, CalendarEventData, Timezone, + alarm::{CalendarAlarm, ExpandAlarm}, +}; +use crate::calendar::{ComponentTimeRange, alarm::CalendarAlarmType}; +use calcard::{ + common::timezone::Tz, + icalendar::{ICalendar, ICalendarComponentType, dates::TimeOrDelta}, +}; +use compact_str::ToCompactString; +use indexmap::IndexMap; +use store::{ + ahash::{AHashMap, RandomState}, + write::{key::KeySerializer, now}, +}; + +const MAX_TIME_SPAN: i64 = u32::MAX as i64; + +impl CalendarEventData { + pub fn new( + ical: ICalendar, + default_tz: Tz, + max_expansions: usize, + next_email_alarm: &mut Option, + ) -> Self { + let mut ranges = TimeRanges::default(); + let now = now() as i64; + + let expanded = ical.expand_dates(default_tz, max_expansions); + let mut groups: IndexMap<(u16, u16, u16, i32), Vec, RandomState> = + IndexMap::with_capacity_and_hasher(16, RandomState::default()); + let mut alarms = AHashMap::with_capacity(16); + + for event in expanded.events { + let start_naive = event.start.naive_local(); + let start_tz = event.start.timezone().as_id(); + let start_timestamp_utc = event.start.timestamp(); + let start_timestamp_naive = start_naive.and_utc().timestamp(); + let (end_timestamp_utc, end_timestamp_naive, end_tz) = match event.end { + TimeOrDelta::Time(time) => { + let end_naive = time.naive_local(); + let end_timestamp_utc = time.timestamp(); + let end_timestamp_naive = end_naive.and_utc().timestamp(); + ( + end_timestamp_utc, + end_timestamp_naive, + time.timezone().as_id(), + ) + } + TimeOrDelta::Delta(delta) => { + let delta = delta.num_seconds(); + ( + start_timestamp_utc + delta, + start_timestamp_naive + delta, + start_tz, + ) + } + }; + + // Expand alarms + let mut min = std::cmp::min(start_timestamp_utc, end_timestamp_utc); + let mut max = std::cmp::max(start_timestamp_utc, end_timestamp_utc); + for alarm in alarms.entry(event.comp_id).or_insert_with(|| { + ical.component_by_id(event.comp_id) + .map_or(&[][..], |c| c.component_ids.as_slice()) + .iter() + .filter_map(|alarm_id| { + ical.component_by_id(*alarm_id).and_then(|alarm| { + if alarm.component_type == ICalendarComponentType::VAlarm { + alarm.expand_alarm(*alarm_id as u16, event.comp_id as u16) + } else { + None + } + }) + }) + .collect::>() + }) { + if let Some(alarm_time) = + alarm + .delta + .to_timestamp(start_timestamp_utc, end_timestamp_utc, default_tz) + { + if alarm_time < min { + min = alarm_time; + } + if alarm_time > max { + max = alarm_time; + } + if alarm_time > now + && next_email_alarm + .as_ref() + .is_none_or(|next| alarm_time < next.alarm_time) + { + *next_email_alarm = Some(CalendarAlarm { + alarm_id: alarm.id, + event_id: alarm.parent_id, + alarm_time, + typ: if alarm.is_email_alert { + CalendarAlarmType::Email { + event_start: start_timestamp_naive, + event_end: end_timestamp_naive, + event_start_tz: start_tz, + event_end_tz: end_tz, + } + } else { + CalendarAlarmType::Display { + recurrence_id: if ical.components[alarm.parent_id as usize] + .is_recurrent_or_override() + { + start_timestamp_naive.into() + } else { + None + }, + } + }, + }); + } + } + } + + ranges.update_base_offset(start_timestamp_naive, end_timestamp_naive); + ranges.update_utc_min_max(min, max); + groups + .entry(( + start_tz, + end_tz, + event.comp_id as u16, + (end_timestamp_naive - start_timestamp_naive) + .clamp(i32::MIN as i64, i32::MAX as i64) as i32, + )) + .or_default() + .push(start_timestamp_naive); + } + + let mut events = Vec::with_capacity(groups.len()); + for ((start_tz, end_tz, id, duration), mut instances) in groups { + instances.sort_unstable(); + instances.truncate(instances.partition_point(|instance| { + instance.saturating_sub(ranges.base_offset) <= MAX_TIME_SPAN + })); + + let instances = match instances.len() { + 0 => continue, + 1 => KeySerializer::new(std::mem::size_of::()) + .write_leb128((instances[0] - ranges.base_offset) as u32) + .finalize(), + len => { + // Bitpack instances + let mut instance_offsets = Vec::with_capacity(len); + for instance in instances { + debug_assert!(instance >= ranges.base_offset); + instance_offsets.push((instance - ranges.base_offset) as u32); + } + + KeySerializer::new(instance_offsets.len() * std::mem::size_of::()) + .bitpack_sorted(&instance_offsets) + .finalize() + } + }; + + events.push(ComponentTimeRange { + id, + start_tz, + end_tz, + duration, + instances: instances.into_boxed_slice(), + }); + } + + if !expanded.errors.is_empty() { + trc::event!( + Calendar(trc::CalendarEvent::RuleExpansionError), + Reason = expanded + .errors + .into_iter() + .map(|e| e.error.to_compact_string()) + .collect::>(), + Details = ical.to_string(), + Limit = max_expansions, + ); + } + + CalendarEventData { + event: ical, + time_ranges: events.into_boxed_slice(), + alarms: alarms + .into_values() + .flatten() + .collect::>() + .into_boxed_slice(), + base_offset: ranges.base_offset, + base_time_utc: (ranges.min_time_utc - ranges.base_offset).clamp(0, MAX_TIME_SPAN) + as u32, + duration: (ranges.max_time_utc - ranges.min_time_utc).clamp(0, MAX_TIME_SPAN) as u32, + } + } + + pub fn event_range(&self) -> Option<(i64, u32)> { + if self.base_offset != 0 { + Some((self.base_offset + self.base_time_utc as i64, self.duration)) + } else { + None + } + } +} + +#[derive(Default, Debug)] +struct TimeRanges { + max_time_utc: i64, + min_time_utc: i64, + base_offset: i64, +} + +impl TimeRanges { + pub fn update_base_offset(&mut self, t1: i64, t2: i64) { + let offset = std::cmp::min(t1, t2); + if offset < self.base_offset || self.base_offset == 0 { + self.base_offset = offset; + } + } + + pub fn update_utc_min_max(&mut self, min: i64, max: i64) { + if min < self.min_time_utc || self.min_time_utc == 0 { + self.min_time_utc = min; + } + if max > self.max_time_utc { + self.max_time_utc = max; + } + if min < self.base_offset || self.base_offset == 0 { + self.base_offset = min; + } + } +} + +impl ArchivedCalendarEventData { + pub fn event_range(&self) -> Option<(i64, u32)> { + if self.base_offset != 0 { + Some(( + self.base_offset.to_native() + self.base_time_utc.to_native() as i64, + self.duration.to_native(), + )) + } else { + None + } + } + + pub fn event_range_start(&self) -> i64 { + self.base_offset.to_native() + self.base_time_utc.to_native() as i64 + } + + pub fn event_range_end(&self) -> i64 { + self.base_offset.to_native() + + self.base_time_utc.to_native() as i64 + + self.duration.to_native() as i64 + } +} + +impl CalendarEventData { + pub fn event_range_start(&self) -> i64 { + self.base_offset + self.base_time_utc as i64 + } + + pub fn event_range_end(&self) -> i64 { + self.base_offset + self.base_time_utc as i64 + self.duration as i64 + } +} + +impl Timezone { + pub fn tz(&self) -> Option { + match self { + Timezone::IANA(iana) => Tz::from_id(*iana), + Timezone::Custom(icalendar) => icalendar + .timezones() + .filter_map(|t| t.timezone().map(|x| x.1)) + .next(), + Timezone::Default => None, + } + } +} + +impl ArchivedTimezone { + pub fn tz(&self) -> Option { + match self { + ArchivedTimezone::IANA(iana) => Tz::from_id(iana.to_native()), + ArchivedTimezone::Custom(icalendar) => icalendar + .timezones() + .filter_map(|t| t.timezone().map(|x| x.1)) + .next(), + ArchivedTimezone::Default => None, + } + } +} diff --git a/crates/groupware/src/calendar/expand.rs b/crates/groupware/src/calendar/expand.rs new file mode 100644 index 0000000..9314ada --- /dev/null +++ b/crates/groupware/src/calendar/expand.rs @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ArchivedCalendarEventData; +use crate::calendar::CalendarEventData; +use ahash::AHashSet; +use calcard::{ + common::{DateTimeResult, timezone::Tz}, + icalendar::{ArchivedICalendarComponent, ICalendarComponent, ICalendarProperty}, +}; +use chrono::{DateTime, TimeZone}; +use std::str::FromStr; +use store::write::bitpack::BitpackIterator; +use types::TimeRange; +use utils::codec::leb128::Leb128Reader; + +const RECURRENCE_KEY_EPOCH: i64 = -2208988800; +const RECURRENCE_KEY_GRANULARITY: i64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RecurrenceKey(u32); + +impl RecurrenceKey { + pub fn from_recurrence_id(recurrence_id_naive: i64) -> Option { + u32::try_from( + recurrence_id_naive + .checked_sub(RECURRENCE_KEY_EPOCH)? + .div_euclid(RECURRENCE_KEY_GRANULARITY), + ) + .ok()? + .checked_add(1) + .map(RecurrenceKey) + } + + pub fn from_prefix(prefix: u32) -> Option { + (prefix != 0).then_some(RecurrenceKey(prefix)) + } + + pub fn prefix(self) -> u32 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecurrenceId { + pub utc: i64, + pub naive: i64, +} + +pub trait ComponentRecurrenceId { + fn recurrence_id(&self, fallback_tz: Tz) -> Option; +} + +impl ComponentRecurrenceId for ArchivedICalendarComponent { + fn recurrence_id(&self, fallback_tz: Tz) -> Option { + let entry = self.property(&ICalendarProperty::RecurrenceId)?; + resolve_recurrence_id( + entry.tz_id(), + entry + .values + .first()? + .as_partial_date_time()? + .to_date_time()?, + fallback_tz, + ) + } +} + +impl ComponentRecurrenceId for ICalendarComponent { + fn recurrence_id(&self, fallback_tz: Tz) -> Option { + let entry = self.property(&ICalendarProperty::RecurrenceId)?; + resolve_recurrence_id( + entry.tz_id(), + entry + .values + .first()? + .as_partial_date_time()? + .to_date_time()?, + fallback_tz, + ) + } +} + +fn resolve_recurrence_id( + tz_id: Option<&str>, + date_time: DateTimeResult, + fallback_tz: Tz, +) -> Option { + let tz = tz_id + .and_then(|tz_id| Tz::from_str(tz_id).ok()) + .unwrap_or(fallback_tz); + let date_time = date_time.to_date_time_with_tz(tz)?.with_timezone(&tz); + + Some(RecurrenceId { + utc: date_time.timestamp(), + naive: date_time.naive_local().and_utc().timestamp(), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CalendarEventExpansion { + pub comp_id: u32, + pub own_recurrence_id: Option, + pub start: i64, + pub end: i64, + pub start_naive: i64, +} + +impl CalendarEventExpansion { + pub fn recurrence_id(&self) -> RecurrenceId { + self.own_recurrence_id.unwrap_or(RecurrenceId { + utc: self.start, + naive: self.start_naive, + }) + } + + pub fn recurrence_key(&self) -> Option { + RecurrenceKey::from_recurrence_id(self.recurrence_id().naive) + } +} + +impl ArchivedCalendarEventData { + pub fn expand(&self, default_tz: Tz, limit: TimeRange) -> Option> { + let mut expansion = Vec::with_capacity(self.time_ranges.len()); + let base_offset = self.base_offset.to_native(); + + 'outer: for (range_index, range) in self.time_ranges.iter().enumerate() { + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + + let comp_id = range.id.to_native() as u32; + let component = self.event.components.get(comp_id as usize)?; + let duration = range.duration.to_native() as i64; + let component_tz = Tz::from_id(range.start_tz.to_native())?; + let mut own_recurrence_id = self + .time_ranges + .iter() + .take(range_index) + .all(|prior| prior.id != range.id) + .then(|| component.recurrence_id(component_tz)) + .flatten(); + let mut start_tz = component_tz; + let mut end_tz = Tz::from_id(range.end_tz.to_native())?; + let is_todo = component.component_type.is_todo(); + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + + if instances.len() > bytes_read { + let unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + for start_offset in unpacker { + let own_recurrence_id = own_recurrence_id.take(); + let start_date_naive = start_offset as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + continue; + }; + + if limit.is_in_range(is_todo, start, end) { + expansion.push(CalendarEventExpansion { + comp_id, + own_recurrence_id, + start, + end, + start_naive: start_date_naive, + }); + } else if start > limit.end { + continue 'outer; + } + } + } else { + let start_date_naive = offset_or_count as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + if let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) && limit.is_in_range(is_todo, start, end) + { + expansion.push(CalendarEventExpansion { + comp_id, + own_recurrence_id, + start, + end, + start_naive: start_date_naive, + }); + } + } + } + + Some(expansion) + } +} + +impl CalendarEventData { + pub fn component_tz(&self, comp_id: u32) -> Option { + self.time_ranges + .iter() + .find(|range| range.id as u32 == comp_id) + .and_then(|range| Tz::from_id(range.start_tz)) + } + + pub fn expand_from_ids( + &self, + keys: &mut AHashSet, + default_tz: Tz, + ) -> Option> { + let mut expansion = Vec::with_capacity(keys.len()); + let base_offset = self.base_offset; + + for (range_index, range) in self.time_ranges.iter().enumerate() { + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + let comp_id = range.id as u32; + let component = self.event.components.get(comp_id as usize)?; + let duration = range.duration as i64; + let component_tz = Tz::from_id(range.start_tz)?; + let mut own_recurrence_id = self + .time_ranges + .iter() + .take(range_index) + .all(|prior| prior.id != range.id) + .then(|| component.recurrence_id(component_tz)) + .flatten(); + let mut start_tz = component_tz; + let mut end_tz = Tz::from_id(range.end_tz)?; + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + + let mut push_instance = |own_recurrence_id: Option, start_offset: u32| { + let start_date_naive = start_offset as i64 + base_offset; + let recurrence_id_naive = + own_recurrence_id.map_or(start_date_naive, |recurrence_id| recurrence_id.naive); + if RecurrenceKey::from_recurrence_id(recurrence_id_naive) + .is_none_or(|key| !keys.contains(&key)) + { + return; + } + + let end_date_naive = start_date_naive + duration; + if let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) { + expansion.push(CalendarEventExpansion { + comp_id, + own_recurrence_id, + start, + end, + start_naive: start_date_naive, + }); + } + }; + + if instances.len() > bytes_read { + let unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + for start_offset in unpacker { + push_instance(own_recurrence_id.take(), start_offset); + } + } else { + push_instance(own_recurrence_id, offset_or_count); + } + } + + keys.retain(|key| { + !expansion + .iter() + .any(|expansion| expansion.recurrence_key() == Some(*key)) + }); + + Some(expansion) + } + + pub fn expand_single(&self, comp_id: u32, default_tz: Tz) -> Option { + let range = self.time_ranges.iter().find(|r| r.id as u32 == comp_id)?; + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + let component_tz = Tz::from_id(range.start_tz)?; + let own_recurrence_id = self + .event + .components + .get(comp_id as usize) + .and_then(|component| component.recurrence_id(component_tz)); + let mut start_tz = component_tz; + let mut end_tz = Tz::from_id(range.end_tz)?; + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + let start_offset = if instances.len() > bytes_read { + let mut unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + unpacker.next()? + } else { + offset_or_count + }; + let start_date_naive = start_offset as i64 + self.base_offset; + let end_date_naive = start_date_naive + range.duration as i64; + let start = resolve_local(start_tz, start_date_naive)?; + let end = resolve_local(end_tz, end_date_naive)?; + + Some(CalendarEventExpansion { + comp_id, + own_recurrence_id, + start, + end, + start_naive: start_date_naive, + }) + } +} + +impl Default for CalendarEventExpansion { + fn default() -> Self { + Self { + comp_id: u32::MAX, + own_recurrence_id: None, + start: i64::MAX, + end: i64::MAX, + start_naive: i64::MAX, + } + } +} + +pub fn resolve_local(tz: Tz, naive_secs: i64) -> Option { + tz.from_local_datetime(&DateTime::from_timestamp(naive_secs, 0)?.naive_local()) + .earliest() + .map(|dt| dt.timestamp()) +} + +#[cfg(test)] +mod tests { + use super::*; + use calcard::{Entry, Parser}; + use chrono::NaiveDate; + + fn naive(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 { + NaiveDate::from_ymd_opt(year, month, day) + .and_then(|date| date.and_hms_opt(hour, minute, second)) + .map(|date_time| date_time.and_utc().timestamp()) + .expect("valid date") + } + + fn key(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> RecurrenceKey { + RecurrenceKey::from_recurrence_id(naive(year, month, day, hour, minute, 0)) + .expect("representable recurrence id") + } + + fn event_data(ical: &str) -> CalendarEventData { + let entry = Parser::new(ical).entry(); + let Entry::ICalendar(ical) = entry else { + panic!("failed to parse iCalendar: {entry:?}"); + }; + CalendarEventData::new(ical, Tz::UTC, 1000, &mut None) + } + + fn expand_key(data: &CalendarEventData, key: RecurrenceKey) -> Vec<(u32, i64)> { + let mut keys = AHashSet::from_iter([key]); + data.expand_from_ids(&mut keys, Tz::UTC) + .expect("expansion") + .into_iter() + .map(|expansion| (expansion.comp_id, expansion.start_naive)) + .collect() + } + + const MASTER: &str = concat!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n", + "BEGIN:VEVENT\r\nUID:u@example.com\r\nDTSTAMP:20270101T000000Z\r\n", + "DTSTART:20270301T090000Z\r\nDTEND:20270301T100000Z\r\n", + "RRULE:FREQ=WEEKLY;COUNT=5\r\nSUMMARY:Weekly\r\nEND:VEVENT\r\n", + ); + + const OVERRIDE: &str = concat!( + "BEGIN:VEVENT\r\nUID:u@example.com\r\nDTSTAMP:20270101T000000Z\r\n", + "RECURRENCE-ID:20270308T090000Z\r\nDTSTART:20270308T140000Z\r\n", + "DTEND:20270308T150000Z\r\nSUMMARY:Moved\r\nEND:VEVENT\r\n", + ); + + #[test] + fn recurrence_key_encoding() { + assert_eq!( + RecurrenceKey::from_recurrence_id(RECURRENCE_KEY_EPOCH), + Some(RecurrenceKey(1)) + ); + assert_eq!( + RecurrenceKey::from_recurrence_id(RECURRENCE_KEY_EPOCH - 1), + None + ); + assert_eq!(RecurrenceKey::from_recurrence_id(i64::MAX), None); + assert_eq!(RecurrenceKey::from_prefix(0), None); + assert_eq!( + RecurrenceKey::from_prefix(key(2027, 3, 15, 9, 0).prefix()), + Some(key(2027, 3, 15, 9, 0)) + ); + assert_ne!(key(2027, 3, 15, 9, 0), key(2027, 3, 15, 9, 1)); + assert_eq!( + RecurrenceKey::from_recurrence_id(naive(2027, 3, 15, 9, 0, 30)), + Some(key(2027, 3, 15, 9, 0)) + ); + } + + #[test] + fn recurrence_keys_survive_an_override() { + let before = event_data(&format!("{MASTER}END:VCALENDAR\r\n")); + let after = event_data(&format!("{MASTER}{OVERRIDE}END:VCALENDAR\r\n")); + + for (day, comp_id) in [(1, 1), (15, 1), (22, 1), (29, 1)] { + let key = key(2027, 3, day, 9, 0); + let start_naive = naive(2027, 3, day, 9, 0, 0); + assert_eq!(expand_key(&before, key), [(comp_id, start_naive)]); + assert_eq!(expand_key(&after, key), [(comp_id, start_naive)]); + } + + let overridden = key(2027, 3, 8, 9, 0); + assert_eq!( + expand_key(&before, overridden), + [(1, naive(2027, 3, 8, 9, 0, 0))] + ); + assert_eq!( + expand_key(&after, overridden), + [(2, naive(2027, 3, 8, 14, 0, 0))] + ); + } + + #[test] + fn this_and_future_instances_get_distinct_keys() { + const THIS_AND_FUTURE: &str = concat!( + "BEGIN:VEVENT\r\nUID:u@example.com\r\nDTSTAMP:20270101T000000Z\r\n", + "RECURRENCE-ID;RANGE=THISANDFUTURE:20270315T090000Z\r\n", + "DTSTART:20270315T100000Z\r\nDTEND:20270315T113000Z\r\n", + "SUMMARY:Longer\r\nEND:VEVENT\r\n", + ); + let data = event_data(&format!("{MASTER}{THIS_AND_FUTURE}END:VCALENDAR\r\n")); + + assert_eq!( + expand_key(&data, key(2027, 3, 15, 9, 0)), + [(2, naive(2027, 3, 15, 10, 0, 0))] + ); + assert_eq!( + expand_key(&data, key(2027, 3, 22, 10, 0)), + [(2, naive(2027, 3, 22, 10, 0, 0))] + ); + assert_eq!( + expand_key(&data, key(2027, 3, 29, 10, 0)), + [(2, naive(2027, 3, 29, 10, 0, 0))] + ); + assert_eq!( + expand_key(&data, key(2027, 3, 1, 9, 0)), + [(1, naive(2027, 3, 1, 9, 0, 0))] + ); + } + + #[test] + fn unmatched_recurrence_keys_are_reported_back() { + let data = event_data(&format!("{MASTER}END:VCALENDAR\r\n")); + let missing = key(2027, 4, 5, 9, 0); + let present = key(2027, 3, 15, 9, 0); + let mut keys = AHashSet::from_iter([missing, present]); + + let expansion = data.expand_from_ids(&mut keys, Tz::UTC).expect("expansion"); + + assert_eq!(expansion.len(), 1); + assert_eq!(keys.into_iter().collect::>(), [missing]); + } +} diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs new file mode 100644 index 0000000..74a4d8f --- /dev/null +++ b/crates/groupware/src/calendar/index.rs @@ -0,0 +1,495 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarPreferences, ArchivedDefaultAlert, + ArchivedTimezone, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, +}; +use crate::{ + calendar::{ + ArchivedCalendarEventNotification, ArchivedChangedBy, ArchivedEventPreferences, + CalendarEventNotification, ChangedBy, EventPreferences, + }, + strip_mailto_scheme, +}; +use ahash::AHashSet; +use calcard::icalendar::{ + ArchivedICalendarParameterValue, ArchivedICalendarProperty, ArchivedICalendarValue, + ICalendarParameterValue, ICalendarProperty, ICalendarValue, +}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use nlp::language::{ + Language, + detect::{LanguageDetector, MIN_LANGUAGE_SCORE}, +}; +use store::{ + U32_LEN, + search::{CalendarSearchField, IndexDocument, SearchField}, + write::{IndexPropertyClass, SearchIndex, ValueClass}, + xxhash_rust::xxh3, +}; +use types::{ + acl::AclGrant, + collection::SyncCollection, + field::{CalendarEventField, CalendarNotificationField}, +}; + +impl IndexableObject for Calendar { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Acl { + value: (&self.acls).into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogContainer { + sync_collection: SyncCollection::Calendar, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedCalendar { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Acl { + value: self + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogContainer { + sync_collection: SyncCollection::Calendar, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for Calendar { + fn is_versioned() -> bool { + true + } +} + +impl IndexableObject for CalendarEvent { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::SearchIndex { + index: SearchIndex::Calendar, + hash: self + .hashes() + .chain([self.data.event_range_start() as u64]) + .fold(0, |acc, hash| acc ^ hash), + }, + IndexValue::Index { + field: CalendarEventField::Uid.into(), + value: self.data.event.uids().next().into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogItem { + sync_collection: SyncCollection::Calendar, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedCalendarEvent { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::SearchIndex { + index: SearchIndex::Calendar, + hash: self + .hashes() + .chain([self.data.event_range_start() as u64]) + .fold(0, |acc, hash| acc ^ hash), + }, + IndexValue::Index { + field: CalendarEventField::Uid.into(), + value: self.data.event.uids().next().into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogItem { + sync_collection: SyncCollection::Calendar, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for CalendarEvent { + fn is_versioned() -> bool { + true + } +} + +impl IndexableObject for CalendarEventNotification { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: self.created as u64, + }), + value: self.event_id.unwrap_or(u32::MAX).into(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::CalendarEventNotification, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedCalendarEventNotification { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: self.created.to_native() as u64, + }), + value: self + .event_id + .as_ref() + .map(|v| v.to_native()) + .unwrap_or(u32::MAX) + .into(), + }, + IndexValue::LogItem { + sync_collection: SyncCollection::CalendarEventNotification, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for CalendarEventNotification { + fn is_versioned() -> bool { + false + } +} + +impl Calendar { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.preferences.iter().map(|p| p.size()).sum::() + + self.name.len() + + std::mem::size_of::() + } +} + +impl ArchivedCalendar { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.preferences.iter().map(|p| p.size()).sum::() + + self.name.len() + + std::mem::size_of::() + } +} + +impl CalendarEvent { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.names.iter().map(|n| n.name.len()).sum::() + + self.preferences.iter().map(|p| p.size()).sum::() + + self.size as usize + + std::mem::size_of::() + } +} + +impl ArchivedCalendarEvent { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.names.iter().map(|n| n.name.len()).sum::() + + self.preferences.iter().map(|p| p.size()).sum::() + + self.size.to_native() as usize + + std::mem::size_of::() + } +} + +impl CalendarEventNotification { + pub fn size(&self) -> usize { + (match &self.changed_by { + ChangedBy::PrincipalId(_) => U32_LEN, + ChangedBy::CalendarAddress(v) => v.len(), + }) + std::mem::size_of::() + + self.size as usize + } +} + +impl ArchivedCalendarEventNotification { + pub fn size(&self) -> usize { + (match &self.changed_by { + ArchivedChangedBy::PrincipalId(_) => U32_LEN, + ArchivedChangedBy::CalendarAddress(v) => v.len(), + }) + std::mem::size_of::() + + self.size.to_native() as usize + } +} + +impl CalendarPreferences { + pub fn size(&self) -> usize { + self.name.len() + + self.default_alerts.iter().map(|a| a.size()).sum::() + + self.description.as_ref().map_or(0, |n| n.len()) + + self.color.as_ref().map_or(0, |n| n.len()) + + self.time_zone.size() + + std::mem::size_of::() + } +} + +impl ArchivedCalendarPreferences { + pub fn size(&self) -> usize { + self.name.len() + + self.default_alerts.iter().map(|a| a.size()).sum::() + + self.description.as_ref().map_or(0, |n| n.len()) + + self.color.as_ref().map_or(0, |n| n.len()) + + self.time_zone.size() + + std::mem::size_of::() + } +} + +impl EventPreferences { + pub fn size(&self) -> usize { + self.alerts.iter().map(|a| a.size()).sum::() + + self.properties.iter().map(|p| p.size()).sum::() + + std::mem::size_of::() + } +} + +impl ArchivedEventPreferences { + pub fn size(&self) -> usize { + self.alerts.iter().map(|a| a.size()).sum::() + + self.properties.iter().map(|p| p.size()).sum::() + + std::mem::size_of::() + } +} + +impl Timezone { + pub fn size(&self) -> usize { + match self { + Timezone::IANA(_) => 2, + Timezone::Custom(c) => c.size(), + Timezone::Default => 0, + } + } +} + +impl ArchivedTimezone { + pub fn size(&self) -> usize { + match self { + ArchivedTimezone::IANA(_) => 2, + ArchivedTimezone::Custom(c) => c.size(), + ArchivedTimezone::Default => 0, + } + } +} + +impl DefaultAlert { + pub fn size(&self) -> usize { + std::mem::size_of::() + self.id.len() + } +} + +impl ArchivedDefaultAlert { + pub fn size(&self) -> usize { + std::mem::size_of::() + self.id.len() + } +} + +impl CalendarEvent { + pub fn hashes(&self) -> impl Iterator { + self.data + .event + .components + .iter() + .filter(|e| e.component_type.is_scheduling_object()) + .flat_map(|e| { + e.entries.iter().filter(|e| { + matches!( + e.name, + ICalendarProperty::Summary + | ICalendarProperty::Location + | ICalendarProperty::Description + | ICalendarProperty::Categories + | ICalendarProperty::Comment + | ICalendarProperty::Attendee + | ICalendarProperty::Organizer + | ICalendarProperty::Uid + ) + }) + }) + .flat_map(|e| { + e.values + .iter() + .filter_map(|v| match v { + ICalendarValue::Text(v) => Some(v.as_str()), + ICalendarValue::Uri(uri) => uri.as_str(), + _ => None, + }) + .chain(e.params.iter().filter_map(|p| match &p.value { + ICalendarParameterValue::Text(v) => Some(v.as_str()), + ICalendarParameterValue::Uri(uri) => uri.as_str(), + _ => None, + })) + }) + .map(|v| xxh3::xxh3_64(v.as_bytes())) + } +} + +impl ArchivedCalendarEvent { + pub fn hashes(&self) -> impl Iterator { + self.data + .event + .components + .iter() + .filter(|e| e.component_type.is_scheduling_object()) + .flat_map(|e| { + e.entries.iter().filter(|e| { + matches!( + e.name, + ArchivedICalendarProperty::Summary + | ArchivedICalendarProperty::Location + | ArchivedICalendarProperty::Description + | ArchivedICalendarProperty::Categories + | ArchivedICalendarProperty::Comment + | ArchivedICalendarProperty::Attendee + | ArchivedICalendarProperty::Organizer + | ArchivedICalendarProperty::Uid + ) + }) + }) + .flat_map(|e| { + e.values + .iter() + .filter_map(|v| match v { + ArchivedICalendarValue::Text(v) => Some(v.as_str()), + ArchivedICalendarValue::Uri(uri) => uri.as_str(), + _ => None, + }) + .chain(e.params.iter().filter_map(|p| match &p.value { + ArchivedICalendarParameterValue::Text(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Uri(uri) => uri.as_str(), + _ => None, + })) + }) + .map(|v| xxh3::xxh3_64(v.as_bytes())) + } +} + +impl ArchivedCalendarEvent { + pub fn index_document( + &self, + account_id: u32, + document_id: u32, + index_fields: &AHashSet, + default_language: Language, + ) -> IndexDocument { + let mut document = IndexDocument::new(SearchIndex::Calendar) + .with_account_id(account_id) + .with_document_id(document_id); + + if index_fields.is_empty() + || index_fields.contains(&SearchField::Calendar(CalendarSearchField::Start)) + { + document.index_integer(CalendarSearchField::Start, self.data.event_range_start()); + } + + let mut detector = LanguageDetector::new(); + for component in self + .data + .event + .components + .iter() + .filter(|e| e.component_type.is_scheduling_object()) + { + for entry in component.entries.iter() { + let (is_lang, is_keyword, field) = match entry.name { + ArchivedICalendarProperty::Summary => (true, false, CalendarSearchField::Title), + ArchivedICalendarProperty::Description => { + (true, false, CalendarSearchField::Description) + } + ArchivedICalendarProperty::Location => { + (false, false, CalendarSearchField::Location) + } + ArchivedICalendarProperty::Organizer => { + (false, false, CalendarSearchField::Owner) + } + ArchivedICalendarProperty::Attendee => { + (false, false, CalendarSearchField::Attendee) + } + ArchivedICalendarProperty::Uid => (false, true, CalendarSearchField::Uid), + _ => continue, + }; + let field = SearchField::Calendar(field); + + if index_fields.is_empty() || index_fields.contains(&field) { + for value in entry + .values + .iter() + .filter_map(|v| match v { + ArchivedICalendarValue::Text(v) => Some(v.as_str()), + ArchivedICalendarValue::Uri(uri) => uri.as_str(), + _ => None, + }) + .chain(entry.params.iter().filter_map(|p| match &p.value { + ArchivedICalendarParameterValue::Text(v) => Some(v.as_str()), + ArchivedICalendarParameterValue::Uri(uri) => uri.as_str(), + _ => None, + })) + { + let value = strip_mailto_scheme(value); + let lang = if is_lang { + detector.detect(value, MIN_LANGUAGE_SCORE); + Language::Unknown + } else { + Language::None + }; + + if !is_keyword { + document.index_text(field.clone(), value, lang); + } else { + document.index_keyword(field.clone(), value); + } + } + } + } + } + + document.set_unknown_language( + detector + .most_frequent_language() + .unwrap_or(default_language), + ); + + document + } +} diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs new file mode 100644 index 0000000..2d2b265 --- /dev/null +++ b/crates/groupware/src/calendar/itip.rs @@ -0,0 +1,1319 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + RFC_3986, + cache::GroupwareCache, + calendar::{ + CalendarEvent, CalendarEventData, CalendarEventNotification, ChangedBy, + EVENT_HIDE_ATTENDEES, EVENT_NOTIFICATION_IS_CHANGE, + }, + scheduling::{ + InstanceId, ItipError, ItipMessage, ItipSnapshots, + format::{DateStyle, TextFormatter, hyperlink}, + ical_size, + inbound::{ + MergeAction, MergeResult, itip_import_message, itip_merge_changes, itip_method, + itip_process_message, + }, + itip::itip_build_envelope, + snapshot::itip_snapshot, + }, +}; +use calcard::{ + common::{IanaString, PartialDateTime, timezone::Tz}, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarParameterValue, + ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, + }, +}; +use common::{ + DavName, Server, + auth::{AccessToken, AccountInfo, oauth::GrantType}, + i18n, +}; +use registry::schema::enums::Permission; +use std::net::IpAddr; +use store::{ + ValueKey, rand, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{ + collection::Collection, + field::{CalendarEventField, ContactField}, +}; +const MAX_RSVP_COMMENT_LEN: usize = 512; + +pub enum ItipIngestError { + Message(ItipError), + Internal(trc::Error), +} + +#[derive(Default)] +pub struct ItipRsvpUrl(String); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItipSendStatus { + Send, + NotRequested, + SchedulingDisabled, + NoCalendarAddress, + NoPermission, + EventInPast, +} + +pub trait ItipIngest: Sync + Send { + fn itip_ingest( + &self, + account_info: &AccountInfo, + sender: &str, + recipient: &str, + itip_message: &str, + ) -> impl Future>, ItipIngestError>> + Send; + + fn http_rsvp_url( + &self, + account_id: u32, + account_name: &str, + document_id: u32, + attendee: &str, + ) -> impl Future> + Send; + + fn http_rsvp_handle( + &self, + request: RsvpRequest, + language: &str, + remote_ip: IpAddr, + ) -> impl Future> + Send; +} + +impl ItipIngest for Server { + async fn itip_ingest( + &self, + account_info: &AccountInfo, + sender: &str, + recipient: &str, + itip_message: &str, + ) -> Result>, ItipIngestError> { + // Parse and validate the iTIP message + let mut itip = ICalendar::parse(itip_message) + .map_err(|_| ItipIngestError::Message(ItipError::ICalendarParseError)) + .and_then(|ical| { + if ical.components.len() > 1 + && ical.components[0].component_type == ICalendarComponentType::VCalendar + { + Ok(ical) + } else { + Err(ItipIngestError::Message(ItipError::ICalendarParseError)) + } + })?; + + // Microsoft Exchange does not include the organizer in REPLY, assume it is the recipient. + // This will be validated against the stored event anyway. + if itip.components[0] + .property(&ICalendarProperty::Method) + .and_then(|v| v.values.first()) + .is_some_and(|v| { + matches!( + v, + ICalendarValue::Method(ICalendarMethod::Reply | ICalendarMethod::Request) + ) + }) + { + for comp in &mut itip.components { + if comp.component_type.is_scheduling_object() { + let mut has_organizer = false; + let mut has_attendee = false; + + for entry in &comp.entries { + match entry.name { + ICalendarProperty::Organizer => has_organizer = true, + ICalendarProperty::Attendee => has_attendee = true, + _ => {} + } + } + + if has_attendee && !has_organizer { + comp.entries.push(ICalendarEntry { + name: ICalendarProperty::Organizer, + params: vec![], + values: vec![ICalendarValue::Text(format!("mailto:{recipient}"))], + }); + } + } + } + } + + let itip_snapshots = itip_snapshot(&itip, account_info.addresses(), false)?; + if !itip_snapshots.sender_is_organizer_or_attendee(sender) { + return Err(ItipIngestError::Message( + ItipError::SenderIsNotOrganizerNorAttendee, + )); + } + + // Obtain changedBy + let changed_by = if let Some(id) = self.account_id_from_email(sender, true).await? { + ChangedBy::PrincipalId(id) + } else { + ChangedBy::CalendarAddress(sender.into()) + }; + + // Find event by UID + let account_id = account_info.account_id(); + let document_id = self + .document_ids_matching( + account_id, + Collection::CalendarEvent, + CalendarEventField::Uid, + itip_snapshots.uid.as_bytes(), + ) + .await + .caused_by(trc::location!())? + .iter() + .next(); + + if let Some(document_id) = document_id { + if let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let event_ = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let event = event_ + .deserialize::() + .caused_by(trc::location!())?; + + // Process the iTIP message + let snapshots = itip_snapshot(&event.data.event, account_info.addresses(), false)?; + let is_organizer_update = !itip_snapshots.organizer.email.is_local; + match itip_process_message( + &event.data.event, + snapshots, + &itip, + itip_snapshots, + sender.to_string(), + )? { + MergeResult::Actions(changes) => { + commit_itip_merge( + self, + account_info, + account_id, + document_id, + &archive, + event, + changes, + itip, + itip_message.len(), + changed_by, + is_organizer_update, + ) + .await?; + + Ok(None) + } + MergeResult::Message(itip_message) => Ok(Some(itip_message)), + MergeResult::None => Ok(None), + } + } else { + Err(ItipIngestError::Message(ItipError::EventNotFound)) + } + } else { + // Verify that auto-adding invitations is allowed + if !self.core.groupware.itip_auto_add + && !matches!(changed_by, ChangedBy::PrincipalId(_)) + && !self + .document_exists( + account_id, + Collection::ContactCard, + ContactField::Email, + sender.as_bytes(), + ) + .await + .caused_by(trc::location!())? + { + return Err(ItipIngestError::Message(ItipError::AutoAddDisabled)); + } else if itip_method(&itip)? != &ICalendarMethod::Request { + return Err(ItipIngestError::Message(ItipError::EventNotFound)); + } + + // Import the iTIP message + let mut ical = itip.clone(); + itip_import_message(&mut ical)?; + + // Validate quota + if self + .has_available_quota( + self.account(account_id).await?.as_ref(), + itip_message.len() as u64, + ) + .await + .is_err() + { + return Err(ItipIngestError::Message(ItipError::QuotaExceeded)); + } + + // Obtain parent calendar + let Some(parent_id) = self + .get_or_create_default_calendar(account_id, account_id) + .await + .caused_by(trc::location!())? + else { + return Err(ItipIngestError::Message(ItipError::NoDefaultCalendar)); + }; + + // Build event + let mut next_email_alarm = None; + let now = now() as i64; + let event = CalendarEvent { + names: vec![DavName { + name: format!("{}_{}.ics", now, rand::random::()), + parent_id, + }], + data: CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ), + size: itip_message.len() as u32, + schedule_tag: Some(1), + ..Default::default() + }; + + // Obtain document ids + let document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + let itip_document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarEventNotification, 1) + .await + .caused_by(trc::location!())?; + let itip_message = CalendarEventNotification { + event: itip, + event_id: Some(document_id), + changed_by, + size: itip_message.len() as u32, + ..Default::default() + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + event + .insert( + account_info.account_tenant_ids(), + account_id, + document_id, + next_email_alarm, + &mut batch, + ) + .caused_by(trc::location!())?; + itip_message + .insert( + account_info.account_tenant_ids(), + account_id, + itip_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(None) + } + } + + async fn http_rsvp_url( + &self, + account_id: u32, + account_name: &str, + document_id: u32, + attendee: &str, + ) -> Option { + if let Some(base_url) = &self.core.groupware.itip_http_rsvp_url { + match self + .encode_access_token( + GrantType::Rsvp, + account_id, + account_name, + self.core.groupware.itip_http_rsvp_expiration, + Some(&format!("{attendee};{document_id}")), + None, + ) + .await + { + Ok(access_token) => Some(ItipRsvpUrl(format!( + "{base_url}?i={}", + percent_encoding::percent_encode(access_token.as_bytes(), RFC_3986) + ))), + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + None + } + } + } else { + None + } + } + + async fn http_rsvp_handle( + &self, + request: RsvpRequest, + language: &str, + remote_ip: IpAddr, + ) -> trc::Result { + let rsvp = match decode_rsvp_token(self, &request.token).await { + Ok(rsvp) => rsvp, + Err(reason) => return Ok(RsvpResponse::error(reason, language)), + }; + + let part_stat = match request.part_stat() { + Ok(part_stat) => part_stat, + Err(reason) => return Ok(RsvpResponse::error(reason, language)), + }; + + let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + rsvp.account_id, + Collection::CalendarEvent, + rsvp.document_id, + )) + .await + .caused_by(trc::location!())? + else { + return Ok(RsvpResponse::error(RsvpError::EventNotFound, language)); + }; + + let organizer_info = self + .account_info(rsvp.account_id) + .await + .caused_by(trc::location!())?; + + // Without a participation status this is a request for the invitation details + let Some(part_stat) = part_stat else { + let event = archive + .deserialize::() + .caused_by(trc::location!())?; + + return Ok(build_rsvp_invitation( + &event.data.event, + &rsvp.attendee, + organizer_info.addresses(), + event.flags & EVENT_HIDE_ATTENDEES != 0, + language, + )); + }; + let comment = request.sanitized_comment(); + + // Locate the attendee within the organizer's copy of the event + let event = archive + .deserialize::() + .caused_by(trc::location!())?; + let Ok(snapshots) = itip_snapshot(&event.data.event, organizer_info.addresses(), false) + else { + return Ok(RsvpResponse::error(RsvpError::NotParticipant, language)); + }; + let mut is_participant = false; + let mut instances = Vec::with_capacity(snapshots.components.len()); + for (instance_id, instance) in &snapshots.components { + let Some(attendee) = instance + .attendees + .iter() + .find(|attendee| attendee.email.email.eq_ignore_ascii_case(&rsvp.attendee)) + else { + continue; + }; + is_participant = true; + + if attendee.part_stat != Some(&part_stat) { + instances.push(instance_id); + } + } + + if !is_participant { + return Ok(RsvpResponse::error(RsvpError::NotParticipant, language)); + } + + // A response identical to the stored one is a no-op, so no reply is sent + if instances.is_empty() { + return Ok(RsvpResponse::recorded(&part_stat)); + } + instances.sort_unstable(); + + // Deliver the reply to the organizer without going through the mail queue + let reply = build_rsvp_reply( + &snapshots, + &instances, + &rsvp.attendee, + &part_stat, + comment.as_deref(), + ); + let reply_size = ical_size(&reply); + let attendee_copy = http_rsvp_attendee_copy(self, &rsvp, snapshots.uid, remote_ip).await?; + let changed_by = if let Some(account_id) = self + .account_id_from_email(&rsvp.attendee, true) + .await + .caused_by(trc::location!())? + { + ChangedBy::PrincipalId(account_id) + } else { + ChangedBy::CalendarAddress(rsvp.attendee.as_str().into()) + }; + + let merge = + itip_snapshot(&reply, organizer_info.addresses(), false).and_then(|reply_snapshots| { + itip_process_message( + &event.data.event, + snapshots, + &reply, + reply_snapshots, + rsvp.attendee.clone(), + ) + }); + let changes = match merge { + Ok(MergeResult::Actions(changes)) => changes, + Ok(MergeResult::Message(_) | MergeResult::None) => { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = rsvp.account_id, + DocumentId = rsvp.document_id, + From = rsvp.attendee.clone(), + Details = "RSVP reply did not apply to any instance", + ); + + return Ok(RsvpResponse::error(RsvpError::ServerError, language)); + } + Err(err) => { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = rsvp.account_id, + DocumentId = rsvp.document_id, + From = rsvp.attendee.clone(), + Details = err.to_string(), + ); + + return Ok(RsvpResponse::error(RsvpError::ServerError, language)); + } + }; + + match commit_itip_merge( + self, + &organizer_info, + rsvp.account_id, + rsvp.document_id, + &archive, + event, + changes, + reply, + reply_size, + changed_by, + false, + ) + .await + { + Ok(()) => {} + Err(ItipIngestError::Message(err)) => { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = rsvp.account_id, + DocumentId = rsvp.document_id, + From = rsvp.attendee.clone(), + Details = err.to_string(), + ); + + return Ok(RsvpResponse::error(RsvpError::ServerError, language)); + } + Err(ItipIngestError::Internal(err)) => { + return Err(err.caused_by(trc::location!())); + } + } + + // Only once the organizer holds the reply is the attendee's own copy brought in line + if let Some(target) = attendee_copy { + http_rsvp_sync_attendee_copy(self, target, &rsvp.attendee, &part_stat).await?; + } + + Ok(RsvpResponse::recorded(&part_stat)) + } +} + +async fn http_rsvp_sync_attendee_copy( + server: &Server, + target: RsvpTarget, + attendee: &str, + part_stat: &ICalendarParticipationStatus, +) -> trc::Result<()> { + let event = target + .archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + let mut did_change = false; + + for component in &mut new_event.data.event.components { + if !component.component_type.is_scheduling_object() { + continue; + } + + for entry in &mut component.entries { + if entry.name != ICalendarProperty::Attendee + || !entry + .calendar_address() + .is_some_and(|v| v.eq_ignore_ascii_case(attendee)) + { + continue; + } + + let mut has_partstat = false; + for param in &mut entry.params { + if let ( + ICalendarParameterName::Partstat, + ICalendarParameterValue::Partstat(current), + ) = (¶m.name, &mut param.value) + { + has_partstat = true; + if current != part_stat { + *current = part_stat.clone(); + did_change = true; + } + } + } + + if !has_partstat { + entry + .params + .push(ICalendarParameter::partstat(part_stat.clone())); + did_change = true; + } + } + } + + if did_change { + let attendee_info = server + .account_info(target.account_id) + .await + .caused_by(trc::location!())?; + new_event.size = ical_size(&new_event.data.event) as u32; + + let mut batch = BatchBuilder::new(); + new_event + .update( + attendee_info.account_tenant_ids(), + event, + target.account_id, + target.document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + Ok(()) +} + +struct RsvpTarget { + account_id: u32, + document_id: u32, + archive: Archive, +} + +async fn http_rsvp_attendee_copy( + server: &Server, + rsvp: &RsvpToken, + uid: &str, + remote_ip: IpAddr, +) -> trc::Result> { + if !server.core.groupware.itip_enabled { + return Ok(None); + } + + let Some(account_id) = server + .account_id_from_email(&rsvp.attendee, true) + .await + .caused_by(trc::location!())? + .filter(|account_id| *account_id != rsvp.account_id) + else { + return Ok(None); + }; + + let can_send = match server.access_token(account_id).await { + Ok(access_token) => AccessToken::new(access_token, remote_ip).is_ok_and(|access_token| { + access_token.has_permission(Permission::CalendarSchedulingSend) + }), + Err(err) => { + trc::error!( + err.account_id(account_id) + .caused_by(trc::location!()) + .details("Failed to obtain access token for RSVP attendee") + ); + false + } + }; + if !can_send { + return Ok(None); + } + + let Some(document_id) = server + .document_ids_matching( + account_id, + Collection::CalendarEvent, + CalendarEventField::Uid, + uid.as_bytes(), + ) + .await + .caused_by(trc::location!())? + .iter() + .next() + else { + return Ok(None); + }; + + Ok(server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + .map(|archive| RsvpTarget { + account_id, + document_id, + archive, + })) +} + +struct RsvpToken { + account_id: u32, + document_id: u32, + attendee: String, +} + +async fn decode_rsvp_token(server: &Server, token: &str) -> Result { + if token.is_empty() { + return Err(RsvpError::InvalidLink); + } + + let token = server + .validate_access_token(GrantType::Rsvp.into(), token) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::TokenExpired)) { + RsvpError::Expired + } else { + RsvpError::InvalidLink + } + })?; + + token + .claims + .as_deref() + .and_then(|claims| claims.rsplit_once(';')) + .and_then(|(attendee, document_id)| { + document_id + .parse::() + .ok() + .map(|document_id| RsvpToken { + account_id: token.account_id, + document_id, + attendee: attendee.to_string(), + }) + }) + .ok_or(RsvpError::InvalidLink) +} + +#[allow(clippy::too_many_arguments)] +async fn commit_itip_merge( + server: &Server, + account_info: &AccountInfo, + account_id: u32, + document_id: u32, + archive: &Archive, + mut event: CalendarEvent, + changes: Vec, + itip: ICalendar, + itip_size: usize, + changed_by: ChangedBy, + is_organizer_update: bool, +) -> Result<(), ItipIngestError> { + let event_ = archive + .to_unarchived::() + .caused_by(trc::location!())?; + + // Merge changes + itip_merge_changes(&mut event.data.event, changes); + + // Calculate the new ical size + event.size = ical_size(&event.data.event) as u32; + if event.size > server.core.groupware.max_ical_size as u32 { + return Err(ItipIngestError::Message(ItipError::EventTooLarge)); + } + + // Validate quota + let extra_bytes = (event.size as u64).saturating_sub(event_.inner.size.to_native() as u64); + if extra_bytes > 0 + && server + .has_available_quota(server.account(account_id).await?.as_ref(), extra_bytes) + .await + .is_err() + { + return Err(ItipIngestError::Message(ItipError::QuotaExceeded)); + } + + // Build event + let now = now() as i64; + let prev_email_alarm = event_.inner.data.next_alarm(now, Tz::Floating); + let mut next_email_alarm = None; + event.data = CalendarEventData::new( + event.data.event, + Tz::Floating, + server.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + if is_organizer_update { + if let Some(schedule_tag) = &mut event.schedule_tag { + *schedule_tag += 1; + } else { + event.schedule_tag = Some(1); + } + } + + // Build event for schedule inbox + let itip_document_id = server + .store() + .assign_document_ids(account_id, Collection::CalendarEventNotification, 1) + .await + .caused_by(trc::location!())?; + let itip_message = CalendarEventNotification { + event: itip, + changed_by, + event_id: Some(document_id), + flags: EVENT_NOTIFICATION_IS_CHANGE, + size: itip_size as u32, + ..Default::default() + }; + + // Prepare write batch + let mut batch = BatchBuilder::new(); + event + .update( + account_info.account_tenant_ids(), + event_, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + if prev_email_alarm != next_email_alarm { + if let Some(prev_alarm) = prev_email_alarm { + prev_alarm.delete_task(&mut batch); + } + if let Some(next_alarm) = next_email_alarm { + next_alarm.write_task(&mut batch); + } + } + itip_message + .insert( + account_info.account_tenant_ids(), + account_id, + itip_document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + Ok(()) +} + +fn build_rsvp_reply( + snapshots: &ItipSnapshots<'_>, + instances: &[&InstanceId], + attendee: &str, + part_stat: &ICalendarParticipationStatus, + comment: Option<&str>, +) -> ICalendar { + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: Vec::with_capacity(instances.len() + 1), + }; + message + .components + .push(itip_build_envelope(ICalendarMethod::Reply)); + + for instance_id in instances { + let Some(instance) = snapshots.components.get(*instance_id) else { + continue; + }; + let mut reply = ICalendarComponent { + component_type: instance.comp.component_type.clone(), + entries: Vec::with_capacity(8), + component_ids: vec![], + }; + + reply.add_property( + ICalendarProperty::Organizer, + ICalendarValue::Text(snapshots.organizer.email.to_string()), + ); + reply.add_property_with_params( + ICalendarProperty::Attendee, + [ICalendarParameter::partstat(part_stat.clone())], + ICalendarValue::Text(format!("mailto:{attendee}")), + ); + reply.add_uid(snapshots.uid); + reply.add_dtstamp(dt_stamp.clone()); + reply.add_sequence(instance.sequence.unwrap_or_default()); + + if !matches!(instance_id, InstanceId::Main) + && let Some(recurrence_id) = instance + .comp + .entries + .iter() + .find(|entry| entry.name == ICalendarProperty::RecurrenceId) + { + reply.entries.push(recurrence_id.clone()); + } + + if let Some(comment) = comment { + reply.add_property( + ICalendarProperty::Comment, + ICalendarValue::Text(comment.to_string()), + ); + } + + reply.entries.push(ICalendarEntry { + name: ICalendarProperty::RequestStatus, + params: vec![], + values: vec![ + ICalendarValue::Text("2.0".to_string()), + ICalendarValue::Text("Success".to_string()), + ], + }); + + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(reply); + } + + message +} + +fn build_rsvp_invitation( + ical: &ICalendar, + attendee: &str, + account_emails: &[String], + hide_attendees: bool, + language: &str, +) -> RsvpResponse { + let Ok(snapshots) = itip_snapshot(ical, account_emails, false) else { + return RsvpResponse::error(RsvpError::NotParticipant, language); + }; + let instance = snapshots.main_instance_or_default(); + let Some(participant) = instance + .attendees + .iter() + .find(|candidate| candidate.email.email.eq_ignore_ascii_case(attendee)) + else { + return RsvpResponse::error(RsvpError::NotParticipant, language); + }; + + let formatter = match TextFormatter::new(language) { + Ok(formatter) => formatter, + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + return RsvpResponse::error(RsvpError::ServerError, language); + } + }; + let locale = formatter.locale; + let mut invitation = RsvpInvitation { + kind: if instance.comp.entries.iter().any(|entry| { + entry.name == ICalendarProperty::Status + && entry + .values + .first() + .and_then(|value| value.as_text()) + .is_some_and(|value| value.eq_ignore_ascii_case("CANCELLED")) + }) { + "cancel" + } else if instance.sequence.is_some_and(|sequence| sequence > 0) { + "update" + } else { + "invite" + }, + partstat: participant + .part_stat + .map_or(ICalendarParticipationStatus::NeedsAction.as_str(), |v| { + v.as_str() + }), + language: locale.name, + dir: locale.direction, + labels: RsvpLabels::new(locale), + attendee: RsvpParticipant { + name: participant.name.map(|name| name.to_string()), + email: participant.email.email.clone(), + partstat: None, + is_organizer: false, + }, + ..Default::default() + }; + + for field in instance.build_summary(None, &[]) { + let value = formatter.field_to_string(&field.value, DateStyle::Long); + if value.is_empty() { + continue; + } + + match field.name { + ICalendarProperty::Summary => invitation.summary = Some(value), + ICalendarProperty::Description => invitation.description = Some(value), + ICalendarProperty::Location => invitation.location = Some(value), + ICalendarProperty::Rrule => invitation.recurrence = Some(value), + ICalendarProperty::Dtstart if invitation.when.is_none() => { + invitation.when = Some(value) + } + ICalendarProperty::Conference if invitation.conference.is_none() => { + invitation.conference = Some(RsvpConference { + url: hyperlink(&value).map(|url| url.to_string()), + value, + }); + } + _ => {} + } + } + + // hideAttendees limits the list to the owners and the requesting participant + let mut attendees = Vec::with_capacity(if hide_attendees { + 2 + } else { + instance.attendees.len() + 1 + }); + attendees.push(RsvpParticipant { + name: snapshots.organizer.name.map(|name| name.to_string()), + email: snapshots.organizer.email.email.clone(), + partstat: None, + is_organizer: true, + }); + attendees.extend( + instance + .attendees + .iter() + .filter(|candidate| { + !candidate + .email + .email + .eq_ignore_ascii_case(&snapshots.organizer.email.email) + && (!hide_attendees || candidate.email.email.eq_ignore_ascii_case(attendee)) + }) + .map(|candidate| RsvpParticipant { + name: candidate.name.map(|name| name.to_string()), + email: candidate.email.email.clone(), + partstat: Some(candidate.part_stat.map_or( + ICalendarParticipationStatus::NeedsAction.as_str(), + |part_stat| part_stat.as_str(), + )), + is_organizer: false, + }), + ); + + // Attendees are held in a hash set, so they are sorted to keep the response stable + attendees[1..].sort_unstable_by(|a, b| a.email.cmp(&b.email)); + invitation.attendees = attendees; + + RsvpResponse::Invitation(Box::new(invitation)) +} + +impl ItipSendStatus { + pub fn resolve( + server: &Server, + access_token: &AccessToken, + account_info: &AccountInfo, + event_range_end: i64, + ) -> Self { + if !server.core.groupware.itip_enabled { + Self::SchedulingDisabled + } else if account_info.addresses().is_empty() { + Self::NoCalendarAddress + } else if !access_token.has_permission(Permission::CalendarSchedulingSend) { + Self::NoPermission + } else if event_range_end <= now() as i64 { + Self::EventInPast + } else { + Self::Send + } + } + + #[inline(always)] + pub fn is_send(&self) -> bool { + matches!(self, Self::Send) + } + + #[inline(always)] + pub fn is_denied(&self) -> bool { + matches!( + self, + Self::SchedulingDisabled | Self::NoCalendarAddress | Self::NoPermission + ) + } + + pub fn reason(&self) -> Option<&'static str> { + match self { + Self::Send | Self::NotRequested => None, + Self::SchedulingDisabled => Some("Calendar scheduling is disabled on this server."), + Self::NoCalendarAddress => { + Some("This account has no calendar address to send scheduling messages from.") + } + Self::NoPermission => { + Some("This account is not allowed to send calendar scheduling messages.") + } + Self::EventInPast => { + Some("No scheduling messages were sent because the event lies in the past.") + } + } + } +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RsvpRequest { + pub token: String, + #[serde(default)] + pub partstat: Option, + #[serde(default)] + pub comment: Option, +} + +#[derive(Debug, serde::Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum RsvpResponse { + Invitation(Box), + Recorded { + partstat: &'static str, + }, + Error { + reason: RsvpError, + title: &'static str, + message: &'static str, + language: &'static str, + dir: &'static str, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RsvpError { + InvalidLink, + InvalidPartStat, + Expired, + EventNotFound, + NotParticipant, + ServerError, +} + +#[derive(Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RsvpInvitation { + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub when: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recurrence: Option, + pub attendee: RsvpParticipant, + pub attendees: Vec, + pub partstat: &'static str, + pub language: &'static str, + pub dir: &'static str, + pub labels: RsvpLabels, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RsvpConference { + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RsvpParticipant { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub partstat: Option<&'static str>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub is_organizer: bool, +} + +#[derive(Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RsvpLabels { + pub invitation: &'static str, + pub updated_invitation: &'static str, + pub cancelled: &'static str, + pub description: &'static str, + pub attendees: &'static str, + pub organizer: &'static str, + pub location: &'static str, + pub conference: &'static str, + pub when: &'static str, + pub you: &'static str, + pub yes: &'static str, + pub maybe: &'static str, + pub no: &'static str, + pub reply_as: &'static str, + pub note: &'static str, + pub note_hint: &'static str, + pub send: &'static str, + pub update: &'static str, + pub change: &'static str, + pub recorded: &'static str, + pub notified: &'static str, + pub accepted: &'static str, + pub tentative: &'static str, + pub declined: &'static str, + pub show_more: &'static str, + pub show_less: &'static str, + pub failed: &'static str, + pub error: &'static str, +} + +impl RsvpLabels { + fn new(locale: &'static i18n::Locale) -> Self { + Self { + invitation: locale.calendar_invitation, + updated_invitation: locale.calendar_updated_invitation, + cancelled: locale.calendar_cancelled, + description: locale.calendar_description, + attendees: locale.calendar_attendees, + organizer: locale.calendar_organizer, + location: locale.calendar_location, + conference: locale.calendar_conference, + when: locale.calendar_when, + you: locale.calendar_rsvp_you, + yes: locale.calendar_yes, + maybe: locale.calendar_maybe, + no: locale.calendar_no, + reply_as: locale.calendar_rsvp_reply_as, + note: locale.calendar_rsvp_comment, + note_hint: locale.calendar_rsvp_comment_hint, + send: locale.calendar_rsvp_send, + update: locale.calendar_rsvp_update, + change: locale.calendar_rsvp_change, + recorded: locale.calendar_rsvp_recorded, + notified: locale.calendar_rsvp_notified, + accepted: locale.calendar_accepted, + tentative: locale.calendar_tentative, + declined: locale.calendar_declined, + show_more: locale.calendar_show_more, + show_less: locale.calendar_show_less, + failed: locale.calendar_rsvp_failed, + error: locale.calendar_rsvp_error, + } + } +} + +impl RsvpResponse { + fn error(reason: RsvpError, language: &str) -> Self { + let locale = i18n::locale_or_default(language); + + RsvpResponse::Error { + reason, + title: locale.calendar_rsvp_failed, + message: match reason { + RsvpError::InvalidLink | RsvpError::InvalidPartStat => locale.calendar_invalid_rsvp, + RsvpError::Expired => locale.calendar_rsvp_expired, + RsvpError::EventNotFound => locale.calendar_event_not_found, + RsvpError::NotParticipant => locale.calendar_not_participant, + RsvpError::ServerError => locale.calendar_rsvp_error, + }, + language: locale.name, + dir: locale.direction, + } + } + + fn recorded(part_stat: &ICalendarParticipationStatus) -> Self { + RsvpResponse::Recorded { + partstat: part_stat.as_str(), + } + } +} + +impl RsvpRequest { + fn part_stat(&self) -> Result, RsvpError> { + match self.partstat.as_deref() { + Some(partstat) => hashify::tiny_map_ignore_case!(partstat.as_bytes(), + "ACCEPTED" => ICalendarParticipationStatus::Accepted, + "DECLINED" => ICalendarParticipationStatus::Declined, + "TENTATIVE" => ICalendarParticipationStatus::Tentative, + "COMPLETED" => ICalendarParticipationStatus::Completed, + "IN-PROCESS" => ICalendarParticipationStatus::InProcess, + ) + .map(Some) + .ok_or(RsvpError::InvalidPartStat), + None => Ok(None), + } + } + + fn sanitized_comment(&self) -> Option { + self.comment + .as_deref() + .map(|comment| comment.trim()) + .filter(|comment| !comment.is_empty()) + .map(|comment| { + comment + .chars() + .filter(|ch| !ch.is_control() || *ch == '\n') + .take(MAX_RSVP_COMMENT_LEN) + .collect() + }) + } +} + +impl ItipRsvpUrl { + pub fn url(&self, partstat: &ICalendarParticipationStatus) -> String { + format!("{}&m={}", self.0, partstat.as_str()) + } +} + +impl From for ItipIngestError { + fn from(err: ItipError) -> Self { + ItipIngestError::Message(err) + } +} + +impl From for ItipIngestError { + fn from(err: trc::Error) -> Self { + ItipIngestError::Internal(err) + } +} diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs new file mode 100644 index 0000000..424689b --- /dev/null +++ b/crates/groupware/src/calendar/mod.rs @@ -0,0 +1,439 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod alarm; +pub mod dates; +pub mod expand; +pub mod index; +pub mod itip; +pub mod storage; + +use calcard::icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry, +}; +use common::DavName; +use types::{acl::AclGrant, dead_property::DeadProperty}; +use utils::map::bitmap::BitmapItem; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct Calendar { + pub name: String, + pub preferences: Vec, + pub acls: Vec, + pub supported_components: u64, + pub dead_properties: DeadProperty, + pub created: i64, + pub modified: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportedComponent { + VCalendar, // [RFC5545, Section 3.4] + VEvent, // [RFC5545, Section 3.6.1] + VTodo, // [RFC5545, Section 3.6.2] + VJournal, // [RFC5545, Section 3.6.3] + VFreebusy, // [RFC5545, Section 3.6.4] + VTimezone, // [RFC5545, Section 3.6.5] + VAlarm, // [RFC5545, Section 3.6.6] + Standard, // [RFC5545, Section 3.6.5] + Daylight, // [RFC5545, Section 3.6.5] + VAvailability, // [RFC7953, Section 3.1] + Available, // [RFC7953, Section 3.1] + Participant, // [RFC9073, Section 7.1] + VLocation, // [RFC9073, Section 7.2] [RFC Errata 7381] + VResource, // [RFC9073, Section 7.3] + VStatus, // draft-ietf-calext-ical-tasks-14 + Other, +} + +pub const CALENDAR_SUBSCRIBED: u16 = 1; +pub const CALENDAR_INVISIBLE: u16 = 1 << 1; +pub const CALENDAR_AVAILABILITY_NONE: u16 = 1 << 2; +pub const CALENDAR_AVAILABILITY_ATTENDING: u16 = 1 << 3; +pub const CALENDAR_AVAILABILITY_ALL: u16 = 1 << 4; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarPreferences { + pub account_id: u32, + pub name: String, + pub description: Option, + pub sort_order: u32, + pub color: Option, + pub flags: u16, + pub time_zone: Timezone, + pub default_alerts: Vec, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct DefaultAlert { + pub id: String, + pub offset: ICalendarDuration, + pub flags: u16, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct ParticipantIdentities { + pub identities: Vec, + pub default_name: String, + pub default: u32, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct ParticipantIdentity { + pub id: u32, + pub name: Option, + pub calendar_address: String, +} + +pub const ALERT_WITH_TIME: u16 = 1; +pub const ALERT_EMAIL: u16 = 1 << 1; +pub const ALERT_RELATIVE_TO_END: u16 = 1 << 2; + +pub const SCHEDULE_INBOX_ID: u32 = u32::MAX - 1; +pub const SCHEDULE_OUTBOX_ID: u32 = u32::MAX - 2; + +pub const EVENT_INVITE_SELF: u16 = 1; +pub const EVENT_INVITE_OTHERS: u16 = 1 << 1; +pub const EVENT_HIDE_ATTENDEES: u16 = 1 << 2; +pub const EVENT_DRAFT: u16 = 1 << 3; + +pub const EVENT_NOTIFICATION_IS_DRAFT: u16 = 1; +pub const EVENT_NOTIFICATION_IS_CHANGE: u16 = 1 << 1; + +pub const PREF_USE_DEFAULT_ALERTS: u16 = 1; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarEvent { + pub names: Vec, + pub display_name: Option, + pub data: CalendarEventData, + pub preferences: Vec, + pub flags: u16, + pub dead_properties: DeadProperty, + pub size: u32, + pub created: i64, + pub modified: i64, + pub schedule_tag: Option, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarEventNotification { + pub event: ICalendar, + pub event_id: Option, + pub changed_by: ChangedBy, + pub flags: u16, + pub size: u32, + pub created: i64, + pub modified: i64, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub enum ChangedBy { + PrincipalId(u32), + CalendarAddress(String), +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarEventData { + pub event: ICalendar, + pub time_ranges: Box<[ComponentTimeRange]>, + pub alarms: Box<[Alarm]>, + pub base_offset: i64, + pub base_time_utc: u32, + pub duration: u32, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub struct Alarm { + pub id: u16, + pub parent_id: u16, + pub delta: AlarmDelta, + pub is_email_alert: bool, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub enum AlarmDelta { + Start(i64), + End(i64), + FixedUtc(i64), + FixedFloating(i64), +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct ComponentTimeRange { + pub id: u16, + pub start_tz: u16, + pub end_tz: u16, + pub duration: i32, + pub instances: Box<[u8]>, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct EventPreferences { + pub account_id: u32, + pub flags: u16, + pub properties: Vec, + pub alerts: Vec, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub enum Timezone { + IANA(u16), + Custom(ICalendar), + #[default] + Default, +} + +impl Calendar { + pub fn preferences(&self, account_id: u32) -> &CalendarPreferences { + if self.preferences.len() == 1 { + &self.preferences[0] + } else { + self.preferences + .iter() + .find(|p| p.account_id == account_id) + .or_else(|| self.preferences.first()) + .unwrap() + } + } + + pub fn preferences_mut(&mut self, account_id: u32) -> &mut CalendarPreferences { + let idx = if let Some(idx) = self + .preferences + .iter() + .position(|p| p.account_id == account_id) + { + idx + } else { + let mut preferences = self.preferences[0].clone(); + preferences.account_id = account_id; + self.preferences.push(preferences); + self.preferences.len() - 1 + }; + + &mut self.preferences[idx] + } +} + +impl ArchivedCalendar { + pub fn default_alerts( + &self, + account_id: u32, + with_time: bool, + ) -> impl Iterator { + self.preferences(account_id) + .default_alerts + .iter() + .filter(move |a| (a.flags & ALERT_WITH_TIME != 0) == with_time) + } + + pub fn preferences(&self, account_id: u32) -> &ArchivedCalendarPreferences { + if self.preferences.len() == 1 { + &self.preferences[0] + } else { + self.preferences + .iter() + .find(|p| p.account_id == account_id) + .or_else(|| self.preferences.first()) + .unwrap() + } + } +} + +impl CalendarEvent { + pub fn preferences(&self, account_id: u32) -> Option<&EventPreferences> { + self.preferences.iter().find(|p| p.account_id == account_id) + } + + pub fn preferences_mut(&mut self, account_id: u32) -> &mut EventPreferences { + let idx = if let Some(idx) = self + .preferences + .iter() + .position(|p| p.account_id == account_id) + { + idx + } else { + self.preferences.push(EventPreferences { + account_id, + flags: 0, + properties: Vec::new(), + alerts: Vec::new(), + }); + self.preferences.len() - 1 + }; + + &mut self.preferences[idx] + } + + pub fn added_calendar_ids( + &self, + prev_data: &ArchivedCalendarEvent, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id) + } + + pub fn removed_calendar_ids( + &self, + prev_data: &ArchivedCalendarEvent, + ) -> impl Iterator { + prev_data + .names + .iter() + .filter(|m| self.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id.to_native()) + } + + pub fn unchanged_calendar_ids( + &self, + prev_data: &ArchivedCalendarEvent, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().any(|pm| pm.parent_id == m.parent_id)) + .map(|m| m.parent_id) + } +} + +impl ArchivedCalendarEvent { + pub fn preferences(&self, account_id: u32) -> Option<&ArchivedEventPreferences> { + self.preferences.iter().find(|p| p.account_id == account_id) + } +} + +impl Default for ChangedBy { + fn default() -> Self { + ChangedBy::CalendarAddress("".into()) + } +} + +impl From for SupportedComponent { + fn from(value: u64) -> Self { + match value { + 0 => SupportedComponent::VCalendar, + 1 => SupportedComponent::VEvent, + 2 => SupportedComponent::VTodo, + 3 => SupportedComponent::VJournal, + 4 => SupportedComponent::VFreebusy, + 5 => SupportedComponent::VTimezone, + 6 => SupportedComponent::VAlarm, + 7 => SupportedComponent::Standard, + 8 => SupportedComponent::Daylight, + 9 => SupportedComponent::VAvailability, + 10 => SupportedComponent::Available, + 11 => SupportedComponent::Participant, + 12 => SupportedComponent::VLocation, + 13 => SupportedComponent::VResource, + 14 => SupportedComponent::VStatus, + _ => SupportedComponent::Other, + } + } +} + +impl From for u64 { + fn from(value: SupportedComponent) -> Self { + match value { + SupportedComponent::VCalendar => 0, + SupportedComponent::VEvent => 1, + SupportedComponent::VTodo => 2, + SupportedComponent::VJournal => 3, + SupportedComponent::VFreebusy => 4, + SupportedComponent::VTimezone => 5, + SupportedComponent::VAlarm => 6, + SupportedComponent::Standard => 7, + SupportedComponent::Daylight => 8, + SupportedComponent::VAvailability => 9, + SupportedComponent::Available => 10, + SupportedComponent::Participant => 11, + SupportedComponent::VLocation => 12, + SupportedComponent::VResource => 13, + SupportedComponent::VStatus => 14, + SupportedComponent::Other => 15, + } + } +} + +impl BitmapItem for SupportedComponent { + fn max() -> u64 { + u64::from(SupportedComponent::Other) + } + + fn is_valid(&self) -> bool { + !matches!(self, SupportedComponent::Other) + } +} + +impl From for SupportedComponent { + fn from(value: ICalendarComponentType) -> Self { + match value { + ICalendarComponentType::VCalendar => SupportedComponent::VCalendar, + ICalendarComponentType::VEvent => SupportedComponent::VEvent, + ICalendarComponentType::VTodo => SupportedComponent::VTodo, + ICalendarComponentType::VJournal => SupportedComponent::VJournal, + ICalendarComponentType::VFreebusy => SupportedComponent::VFreebusy, + ICalendarComponentType::VTimezone => SupportedComponent::VTimezone, + ICalendarComponentType::VAlarm => SupportedComponent::VAlarm, + ICalendarComponentType::Standard => SupportedComponent::Standard, + ICalendarComponentType::Daylight => SupportedComponent::Daylight, + ICalendarComponentType::VAvailability => SupportedComponent::VAvailability, + ICalendarComponentType::Available => SupportedComponent::Available, + ICalendarComponentType::Participant => SupportedComponent::Participant, + ICalendarComponentType::VLocation => SupportedComponent::VLocation, + ICalendarComponentType::VResource => SupportedComponent::VResource, + ICalendarComponentType::VStatus => SupportedComponent::VStatus, + _ => SupportedComponent::Other, + } + } +} + +impl From for ICalendarComponentType { + fn from(value: SupportedComponent) -> Self { + match value { + SupportedComponent::VCalendar => ICalendarComponentType::VCalendar, + SupportedComponent::VEvent => ICalendarComponentType::VEvent, + SupportedComponent::VTodo => ICalendarComponentType::VTodo, + SupportedComponent::VJournal => ICalendarComponentType::VJournal, + SupportedComponent::VFreebusy => ICalendarComponentType::VFreebusy, + SupportedComponent::VTimezone => ICalendarComponentType::VTimezone, + SupportedComponent::VAlarm => ICalendarComponentType::VAlarm, + SupportedComponent::Standard => ICalendarComponentType::Standard, + SupportedComponent::Daylight => ICalendarComponentType::Daylight, + SupportedComponent::VAvailability => ICalendarComponentType::VAvailability, + SupportedComponent::Available => ICalendarComponentType::Available, + SupportedComponent::Participant => ICalendarComponentType::Participant, + SupportedComponent::VLocation => ICalendarComponentType::VLocation, + SupportedComponent::VResource => ICalendarComponentType::VResource, + SupportedComponent::VStatus => ICalendarComponentType::VStatus, + SupportedComponent::Other => ICalendarComponentType::Other(Default::default()), + } + } +} diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs new file mode 100644 index 0000000..e566bc2 --- /dev/null +++ b/crates/groupware/src/calendar/storage.rs @@ -0,0 +1,634 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, CalendarPreferences, + alarm::CalendarAlarm, +}; +use crate::{ + DavResourceName, DestroyArchive, RFC_3986, + calendar::{ + ArchivedCalendarEventNotification, CalendarEventNotification, alarm::CalendarAlarmType, + }, + scheduling::{ItipMessages, event_cancel::itip_cancel}, +}; +use calcard::common::timezone::Tz; +use common::{ + Server, + auth::{AccountInfo, AccountTenantIds}, + storage::index::ObjectIndexBuilder, +}; +use registry::{ + schema::structs::{Task, TaskCalendarAlarmEmail, TaskCalendarAlarmNotification, TaskStatus}, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime}, +}; +use store::{ + IterateParams, SerializeInfallible, U32_LEN, ValueKey, + roaring::RoaringBitmap, + write::{ + AlignedBytes, Archive, BatchBuilder, IndexPropertyClass, Operation, TaskQueueClass, + ValueClass, ValueOp, key::DeserializeBigEndian, now, + }, +}; +use trc::AddContext; +use types::{ + collection::{Collection, VanishedCollection}, + field::CalendarNotificationField, + id::Id, +}; + +pub trait ItipAutoExpunge: Sync + Send { + fn itip_ids(&self, account_id: u32) -> impl Future> + Send; + + fn itip_auto_expunge( + &self, + account_id: u32, + hold_period: u64, + ) -> impl Future> + Send; +} + +impl ItipAutoExpunge for Server { + async fn itip_ids(&self, account_id: u32) -> trc::Result { + let mut document_ids = RoaringBitmap::new(); + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: 0, + }), + }, + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: u64::MAX, + }), + }, + ) + .no_values() + .ascending(), + |key, _| { + document_ids.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| document_ids) + } + + async fn itip_auto_expunge(&self, account_id: u32, hold_period: u64) -> trc::Result<()> { + let mut destroy_ids = RoaringBitmap::new(); + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: 0, + }), + }, + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: now().saturating_sub(hold_period), + }), + }, + ) + .no_values() + .ascending(), + |key, _| { + destroy_ids.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if destroy_ids.is_empty() { + return Ok(()); + } + + trc::event!( + Store(trc::StoreEvent::AutoExpunge), + AccountId = account_id, + Collection = Collection::CalendarEventNotification.as_str(), + Total = destroy_ids.len(), + ); + + // Tombstone messages + let mut batch = BatchBuilder::new(); + let changed_by = self + .account(account_id) + .await + .caused_by(trc::location!())? + .account_tenant_ids(); + + for document_id in destroy_ids { + // Fetch event + if let Some(event_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEventNotification, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + DestroyArchive(event) + .delete(changed_by, account_id, document_id, &mut batch) + .caused_by(trc::location!())?; + } + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + Ok(()) + } +} + +impl CalendarEvent { + pub fn update<'x>( + self, + changed_by: AccountTenantIds, + event: Archive<&ArchivedCalendarEvent>, + account_id: u32, + document_id: u32, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + let mut new_event = self; + + // Build event + new_event.modified = now() as i64; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEvent) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(event) + .with_changes(new_event) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } + + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + next_alarm: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + // Build event + let mut event = self; + let now = now() as i64; + event.modified = now; + event.created = now; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEvent) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(event) + .with_changed_by(changed_by), + ) + .map(|batch| { + if let Some(next_alarm) = next_alarm { + next_alarm.write_task(batch); + } + + batch.commit_point() + }) + } +} + +impl Calendar { + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + // Build address calendar + let mut calendar = self; + let now = now() as i64; + calendar.modified = now; + calendar.created = now; + + if calendar.preferences.is_empty() { + calendar.preferences.push(CalendarPreferences { + account_id, + name: "default".to_string(), + ..Default::default() + }); + } + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::Calendar) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(calendar) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } + + pub fn update<'x>( + self, + changed_by: AccountTenantIds, + calendar: Archive<&ArchivedCalendar>, + account_id: u32, + document_id: u32, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + // Build address calendar + let mut new_calendar = self; + new_calendar.modified = now() as i64; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::Calendar) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(calendar) + .with_changes(new_calendar) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } +} + +impl CalendarEventNotification { + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + // Build event + let mut event = self; + let now = now() as i64; + event.modified = now; + event.created = now; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEventNotification) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(event) + .with_changed_by(changed_by), + ) + .map(|batch| batch.commit_point()) + } +} + +impl DestroyArchive> { + #[allow(clippy::too_many_arguments)] + pub async fn delete_with_events( + self, + server: &Server, + account_info: &AccountInfo, + account_id: u32, + document_id: u32, + children_ids: Vec, + delete_path: Option, + send_itip: bool, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Process deletions + let calendar_id = document_id; + for document_id in children_ids { + if let Some(event_) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + { + DestroyArchive( + event_ + .to_unarchived::() + .caused_by(trc::location!())?, + ) + .delete( + account_info, + account_id, + document_id, + calendar_id, + None, + send_itip, + batch, + )?; + } + } + + self.delete( + account_info.account_tenant_ids(), + account_id, + document_id, + delete_path, + batch, + ) + } + + pub fn delete( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + let calendar = self.0; + // Delete calendar + batch + .with_account_id(account_id) + .with_collection(Collection::Calendar) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current(calendar), + ) + .caused_by(trc::location!())?; + if let Some(delete_path) = delete_path { + batch.log_vanished_item(VanishedCollection::Calendar, delete_path); + } + batch.commit_point(); + + Ok(()) + } +} + +impl DestroyArchive> { + #[allow(clippy::too_many_arguments)] + pub fn delete( + self, + account_info: &AccountInfo, + account_id: u32, + document_id: u32, + calendar_id: u32, + delete_path: Option, + send_itip: bool, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + if let Some(delete_idx) = self + .0 + .inner + .names + .iter() + .position(|name| name.parent_id == calendar_id) + { + if self.0.inner.names.len() > 1 { + // Unlink calendar id from event + let event = self.0; + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event.names.swap_remove(delete_idx); + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEvent) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_changed_by(account_info.account_tenant_ids()) + .with_current(event) + .with_changes(new_event), + ) + .caused_by(trc::location!())?; + } else { + self.delete_all(account_info, account_id, document_id, send_itip, batch)?; + } + + if let Some(delete_path) = delete_path { + batch.log_vanished_item(VanishedCollection::Calendar, delete_path); + } + + batch.commit_point(); + } + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn delete_all( + self, + account_info: &AccountInfo, + account_id: u32, + document_id: u32, + send_itip: bool, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + let event = self.0; + // Delete event + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEvent) + .with_document(document_id); + + // Remove next alarm if it exists + let now = now() as i64; + if let Some(next_alarm) = event.inner.data.next_alarm(now, Tz::Floating) { + next_alarm.delete_task(batch); + } + + // Scheduling + if send_itip + && event.inner.schedule_tag.is_some() + && event.inner.data.event_range_end() > now + { + let event = event + .deserialize::() + .caused_by(trc::location!())?; + + if let Ok(messages) = itip_cancel(&event.data.event, account_info.addresses(), true) { + ItipMessages::new(vec![messages]) + .queue(batch) + .caused_by(trc::location!())?; + } + } + + batch + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(account_info.account_tenant_ids()) + .with_current(event), + ) + .caused_by(trc::location!())?; + + Ok(()) + } +} + +impl DestroyArchive> { + #[allow(clippy::too_many_arguments)] + pub fn delete( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Delete event + batch + .with_account_id(account_id) + .with_collection(Collection::CalendarEventNotification) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current(self.0), + ) + .caused_by(trc::location!())? + .commit_point(); + + Ok(()) + } +} + +impl CalendarAlarm { + pub fn build_write_ops(&self, account_id: u32, document_id: u32) -> [Operation; 2] { + let task = match &self.typ { + CalendarAlarmType::Email { + event_start, + event_start_tz, + event_end, + event_end_tz, + } => Task::CalendarAlarmEmail(TaskCalendarAlarmEmail { + account_id: account_id.into(), + document_id: document_id.into(), + alarm_id: self.alarm_id.into(), + event_id: self.event_id.into(), + event_end: UTCDateTime::from_timestamp(*event_end), + event_end_tz: (*event_end_tz).into(), + event_start: UTCDateTime::from_timestamp(*event_start), + event_start_tz: (*event_start_tz).into(), + status: TaskStatus::at(self.alarm_time), + }), + CalendarAlarmType::Display { recurrence_id } => { + Task::CalendarAlarmNotification(TaskCalendarAlarmNotification { + account_id: account_id.into(), + document_id: document_id.into(), + alarm_id: self.alarm_id.into(), + event_id: self.event_id.into(), + recurrence_id: *recurrence_id, + status: TaskStatus::at(self.alarm_time), + }) + } + }; + let id = Id::from_parts(account_id, document_id).id(); + [ + Operation::Value { + class: ValueClass::TaskQueue(TaskQueueClass::Due { + id, + due: self.alarm_time as u64, + }), + op: ValueOp::Set(task.object_type().to_id().serialize()), + }, + Operation::Value { + class: ValueClass::TaskQueue(TaskQueueClass::Task { id }), + op: ValueOp::Set(task.to_pickled_vec()), + }, + ] + } + + pub fn write_task(&self, batch: &mut BatchBuilder) { + let account_id = batch.last_account_id().unwrap(); + let document_id = batch.last_document_id().unwrap(); + + for op in self.build_write_ops(account_id, document_id) { + batch.any_op(op); + } + } + + pub fn delete_task(&self, batch: &mut BatchBuilder) { + let account_id = batch.last_account_id().unwrap(); + let document_id = batch.last_document_id().unwrap(); + let id = Id::from_parts(account_id, document_id).id(); + batch + .clear(ValueClass::TaskQueue(TaskQueueClass::Task { id })) + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id, + due: self.alarm_time as u64, + })); + } +} + +impl ArchivedCalendarEvent { + pub async fn webcal_uri( + &self, + server: &Server, + account_info: &AccountInfo, + ) -> trc::Result { + for event_name in self.names.iter() { + if let Some(calendar_) = server + .store() + .get_value::>(ValueKey::archive( + account_info.account_id(), + Collection::Calendar, + event_name.parent_id.to_native(), + )) + .await + .caused_by(trc::location!())? + { + let calendar = calendar_ + .unarchive::() + .caused_by(trc::location!())?; + return Ok(format!( + "webcal://{}{}/{}/{}/{}", + server.core.network.server_name, + DavResourceName::Cal.base_path(), + percent_encoding::utf8_percent_encode(account_info.name(), RFC_3986), + calendar.name, + event_name.name + )); + } + } + + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Event is not linked to any calendar")) + } +} diff --git a/crates/groupware/src/contact/index.rs b/crates/groupware/src/contact/index.rs new file mode 100644 index 0000000..985e494 --- /dev/null +++ b/crates/groupware/src/contact/index.rs @@ -0,0 +1,342 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}; +use ahash::AHashSet; +use calcard::{ + common::IanaString, + vcard::{ArchivedVCardProperty, ArchivedVCardValue, VCardProperty}, +}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use nlp::language::{ + Language, + detect::{LanguageDetector, MIN_LANGUAGE_SCORE}, +}; +use store::{ + search::{ContactSearchField, IndexDocument, SearchField}, + write::{IndexPropertyClass, SearchIndex, ValueClass}, + xxhash_rust::xxh3, +}; +use types::{acl::AclGrant, collection::SyncCollection, field::ContactField}; +use utils::sanitize_email; + +impl IndexableObject for AddressBook { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Acl { + value: (&self.acls).into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogContainer { + sync_collection: SyncCollection::AddressBook, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedAddressBook { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Acl { + value: self + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .into(), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogContainer { + sync_collection: SyncCollection::AddressBook, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for AddressBook { + fn is_versioned() -> bool { + true + } +} + +impl IndexableObject for ContactCard { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Index { + field: ContactField::Uid.into(), + value: self.card.uid().into(), + }, + IndexValue::Index { + field: ContactField::Email.into(), + value: self.emails().next().into(), + }, + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: ContactField::CreatedToUpdated.into(), + value: self.created as u64, + }), + value: self.modified.into(), + }, + IndexValue::SearchIndex { + index: SearchIndex::Contacts, + hash: self.hashes().fold(0, |acc, hash| acc ^ hash), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogItem { + sync_collection: SyncCollection::AddressBook, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableObject for &ArchivedContactCard { + fn index_values(&self) -> impl Iterator> { + [ + IndexValue::Index { + field: ContactField::Uid.into(), + value: self.card.uid().into(), + }, + IndexValue::Index { + field: ContactField::Email.into(), + value: self.emails().next().into(), + }, + IndexValue::Property { + field: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: ContactField::CreatedToUpdated.into(), + value: self.created.to_native() as u64, + }), + value: (self.modified.to_native() as u64).into(), + }, + IndexValue::SearchIndex { + index: SearchIndex::Contacts, + hash: self.hashes().fold(0, |acc, hash| acc ^ hash), + }, + IndexValue::Quota { + used: self.size() as u32, + }, + IndexValue::LogItem { + sync_collection: SyncCollection::AddressBook, + prefix: None, + }, + ] + .into_iter() + } +} + +impl IndexableAndSerializableObject for ContactCard { + fn is_versioned() -> bool { + true + } +} + +impl AddressBook { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self + .preferences + .iter() + .map(|p| p.name.len() + p.description.as_ref().map_or(0, |n| n.len())) + .sum::() + + self.name.len() + + std::mem::size_of::() + } +} + +impl ArchivedAddressBook { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self + .preferences + .iter() + .map(|p| p.name.len() + p.description.as_ref().map_or(0, |n| n.len())) + .sum::() + + self.name.len() + + std::mem::size_of::() + } +} + +impl ContactCard { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.names.iter().map(|n| n.name.len()).sum::() + + self.size as usize + + std::mem::size_of::() + } + + pub fn hashes(&self) -> impl Iterator { + self.card + .entries + .iter() + .filter(|e| { + matches!( + e.name, + VCardProperty::Adr + | VCardProperty::N + | VCardProperty::Fn + | VCardProperty::Title + | VCardProperty::Org + | VCardProperty::Note + | VCardProperty::Nickname + | VCardProperty::Email + | VCardProperty::Kind + | VCardProperty::Uid + | VCardProperty::Member + | VCardProperty::Impp + | VCardProperty::Socialprofile + | VCardProperty::Tel + ) + }) + .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) + .map(|v| xxh3::xxh3_64(v.as_bytes())) + } + + pub fn emails(&self) -> impl Iterator { + self.card.properties(&VCardProperty::Email).flat_map(|e| { + e.values + .iter() + .filter_map(|v| v.as_text().and_then(sanitize_email)) + }) + } +} + +impl ArchivedContactCard { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.names.iter().map(|n| n.name.len()).sum::() + + self.size.to_native() as usize + + std::mem::size_of::() + } + + pub fn hashes(&self) -> impl Iterator { + self.card + .entries + .iter() + .filter(|e| { + matches!( + e.name, + ArchivedVCardProperty::Adr + | ArchivedVCardProperty::N + | ArchivedVCardProperty::Fn + | ArchivedVCardProperty::Title + | ArchivedVCardProperty::Org + | ArchivedVCardProperty::Note + | ArchivedVCardProperty::Nickname + | ArchivedVCardProperty::Email + | ArchivedVCardProperty::Kind + | ArchivedVCardProperty::Uid + | ArchivedVCardProperty::Member + | ArchivedVCardProperty::Impp + | ArchivedVCardProperty::Socialprofile + | ArchivedVCardProperty::Tel + ) + }) + .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) + .map(|v| xxh3::xxh3_64(v.as_bytes())) + } + + pub fn emails(&self) -> impl Iterator { + self.card.properties(&VCardProperty::Email).flat_map(|e| { + e.values + .iter() + .filter_map(|v| v.as_text().and_then(sanitize_email)) + }) + } + + pub fn index_document( + &self, + account_id: u32, + document_id: u32, + index_fields: &AHashSet, + default_language: Language, + ) -> IndexDocument { + let mut document = IndexDocument::new(SearchIndex::Contacts) + .with_account_id(account_id) + .with_document_id(document_id); + let mut detector = LanguageDetector::new(); + + for entry in self.card.entries.iter() { + let (is_text, is_keyword, field) = match entry.name { + ArchivedVCardProperty::N => (false, false, ContactSearchField::Name), + ArchivedVCardProperty::Nickname => (false, false, ContactSearchField::Nickname), + ArchivedVCardProperty::Org => (false, false, ContactSearchField::Organization), + ArchivedVCardProperty::Email => (false, false, ContactSearchField::Email), + ArchivedVCardProperty::Tel => (false, false, ContactSearchField::Phone), + ArchivedVCardProperty::Impp | ArchivedVCardProperty::Socialprofile => { + (false, false, ContactSearchField::OnlineService) + } + ArchivedVCardProperty::Adr => (false, false, ContactSearchField::Address), + ArchivedVCardProperty::Note => (true, false, ContactSearchField::Note), + ArchivedVCardProperty::Kind => (false, true, ContactSearchField::Kind), + ArchivedVCardProperty::Uid => (false, true, ContactSearchField::Uid), + ArchivedVCardProperty::Member => (false, false, ContactSearchField::Member), + _ => continue, + }; + let field = SearchField::Contact(field); + + if index_fields.is_empty() || index_fields.contains(&field) { + for value in entry.values.iter() { + match value { + ArchivedVCardValue::Text(v) => { + if !is_keyword { + let lang = if is_text { + detector.detect(v.as_str().trim(), MIN_LANGUAGE_SCORE); + Language::Unknown + } else { + Language::None + }; + + document.index_text(field.clone(), v, lang); + } else { + document.index_keyword(field.clone(), v.as_str()); + } + } + ArchivedVCardValue::Kind(v) => { + document.index_keyword(field.clone(), v.as_str()); + } + ArchivedVCardValue::Component(v) => { + for item in v.iter() { + document.index_text(field.clone(), item.trim(), Language::None); + } + } + _ => (), + } + } + + /*for param in entry.params.iter() { + if let ArchivedVCardParameterValue::Text(value) = ¶m.value { + let lang = if is_text { + detector.detect(value.as_str(), MIN_LANGUAGE_SCORE); + Language::Unknown + } else { + Language::None + }; + document.index_text(field.clone(), value, lang); + } + }*/ + } + } + + document.set_unknown_language( + detector + .most_frequent_language() + .unwrap_or(default_language), + ); + + document + } +} diff --git a/crates/groupware/src/contact/mod.rs b/crates/groupware/src/contact/mod.rs new file mode 100644 index 0000000..4ee1775 --- /dev/null +++ b/crates/groupware/src/contact/mod.rs @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod index; +pub mod storage; + +use calcard::vcard::VCard; +use common::DavName; +use types::{acl::AclGrant, dead_property::DeadProperty}; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct AddressBook { + pub name: String, + pub preferences: Vec, + pub subscribers: Vec, + pub dead_properties: DeadProperty, + pub acls: Vec, + pub created: i64, + pub modified: i64, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct AddressBookPreferences { + pub account_id: u32, + pub name: String, + pub description: Option, + pub sort_order: u32, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct ContactCard { + pub names: Vec, + pub display_name: Option, + pub card: VCard, + pub dead_properties: DeadProperty, + pub created: i64, + pub modified: i64, + pub size: u32, +} + +impl AddressBook { + pub fn preferences(&self, account_id: u32) -> &AddressBookPreferences { + if self.preferences.len() == 1 { + &self.preferences[0] + } else { + self.preferences + .iter() + .find(|p| p.account_id == account_id) + .or_else(|| self.preferences.first()) + .unwrap() + } + } + + pub fn preferences_mut(&mut self, account_id: u32) -> &mut AddressBookPreferences { + let idx = if let Some(idx) = self + .preferences + .iter() + .position(|p| p.account_id == account_id) + { + idx + } else { + let mut preferences = self.preferences[0].clone(); + preferences.account_id = account_id; + self.preferences.push(preferences); + self.preferences.len() - 1 + }; + + &mut self.preferences[idx] + } +} + +impl ArchivedAddressBook { + pub fn preferences(&self, account_id: u32) -> &ArchivedAddressBookPreferences { + if self.preferences.len() == 1 { + &self.preferences[0] + } else { + self.preferences + .iter() + .find(|p| p.account_id == account_id) + .or_else(|| self.preferences.first()) + .unwrap() + } + } +} + +impl ContactCard { + pub fn added_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id) + } + + pub fn removed_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + prev_data + .names + .iter() + .filter(|m| self.names.iter().all(|pm| pm.parent_id != m.parent_id)) + .map(|m| m.parent_id.to_native()) + } + + pub fn unchanged_addressbook_ids( + &self, + prev_data: &ArchivedContactCard, + ) -> impl Iterator { + self.names + .iter() + .filter(|m| prev_data.names.iter().any(|pm| pm.parent_id == m.parent_id)) + .map(|m| m.parent_id) + } +} diff --git a/crates/groupware/src/contact/storage.rs b/crates/groupware/src/contact/storage.rs new file mode 100644 index 0000000..01cfb69 --- /dev/null +++ b/crates/groupware/src/contact/storage.rs @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}; +use crate::DestroyArchive; +use common::{Server, auth::AccountTenantIds, storage::index::ObjectIndexBuilder}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::collection::{Collection, VanishedCollection}; + +impl ContactCard { + pub fn update<'x>( + self, + changed_by: AccountTenantIds, + card: Archive<&ArchivedContactCard>, + account_id: u32, + document_id: u32, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + let mut new_card = self; + + // Build card + new_card.modified = now() as i64; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::ContactCard) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(card) + .with_changes(new_card) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } + + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + // Build card + let mut card = self; + let now = now() as i64; + card.modified = now; + card.created = now; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::ContactCard) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(card) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } +} + +impl AddressBook { + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + // Build address book + let mut book = self; + let now = now() as i64; + book.modified = now; + book.created = now; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::AddressBook) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(book) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } + + pub fn update<'x>( + self, + changed_by: AccountTenantIds, + book: Archive<&ArchivedAddressBook>, + account_id: u32, + document_id: u32, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + // Build address book + let mut new_book = self; + new_book.modified = now() as i64; + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::AddressBook) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(book) + .with_changes(new_book) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } +} + +impl DestroyArchive> { + #[allow(clippy::too_many_arguments)] + pub async fn delete_with_cards( + self, + server: &Server, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + children_ids: Vec, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Process deletions + let addressbook_id = document_id; + for document_id in children_ids { + if let Some(card_) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await? + { + DestroyArchive( + card_ + .to_unarchived::() + .caused_by(trc::location!())?, + ) + .delete( + changed_by, + account_id, + document_id, + addressbook_id, + None, + batch, + )?; + } + } + + self.delete(changed_by, account_id, document_id, delete_path, batch) + } + + pub fn delete( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + let book = self.0; + // Delete addressbook + batch + .with_account_id(account_id) + .with_collection(Collection::AddressBook) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current(book), + ) + .caused_by(trc::location!())?; + + if let Some(delete_path) = delete_path { + batch.log_vanished_item(VanishedCollection::AddressBook, delete_path); + } + + batch.commit_point(); + + Ok(()) + } +} + +impl DestroyArchive> { + pub fn delete( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + addressbook_id: u32, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + let card = self.0; + if let Some(delete_idx) = card + .inner + .names + .iter() + .position(|name| name.parent_id == addressbook_id) + { + batch + .with_account_id(account_id) + .with_collection(Collection::ContactCard); + + if card.inner.names.len() > 1 { + // Unlink addressbook id from card + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card.names.swap_remove(delete_idx); + batch + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_changed_by(changed_by) + .with_current(card) + .with_changes(new_card), + ) + .caused_by(trc::location!())?; + } else { + // Delete card + batch + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current(card), + ) + .caused_by(trc::location!())?; + } + + if let Some(delete_path) = delete_path { + batch.log_vanished_item(VanishedCollection::AddressBook, delete_path); + } + + batch.commit_point(); + } + + Ok(()) + } + + pub fn delete_all( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + batch + .with_account_id(account_id) + .with_collection(Collection::ContactCard) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current(self.0), + ) + .caused_by(trc::location!()) + .map(|b| { + b.commit_point(); + }) + } +} diff --git a/crates/groupware/src/file/index.rs b/crates/groupware/src/file/index.rs new file mode 100644 index 0000000..7487df6 --- /dev/null +++ b/crates/groupware/src/file/index.rs @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedFileNode, FileNode}; +use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject}; +use types::{acl::AclGrant, collection::SyncCollection}; + +impl IndexableObject for FileNode { + fn index_values(&self) -> impl Iterator> { + let mut values = Vec::with_capacity(6); + + values.extend([ + IndexValue::Acl { + value: (&self.acls).into(), + }, + IndexValue::LogItem { + prefix: None, + sync_collection: SyncCollection::FileNode, + }, + IndexValue::Quota { + used: self.size() as u32, + }, + ]); + + if let Some(file) = &self.file { + values.extend([IndexValue::Blob { + value: file.blob_hash.clone(), + }]); + } + + values.into_iter() + } +} + +impl IndexableObject for &ArchivedFileNode { + fn index_values(&self) -> impl Iterator> { + let mut values = Vec::with_capacity(6); + + values.extend([ + IndexValue::Acl { + value: self + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .into(), + }, + IndexValue::LogItem { + prefix: None, + sync_collection: SyncCollection::FileNode, + }, + IndexValue::Quota { + used: self.size() as u32, + }, + ]); + + if let Some(file) = self.file.as_ref() { + values.extend([IndexValue::Blob { + value: (&file.blob_hash).into(), + }]); + } + + values.into_iter() + } +} + +impl IndexableAndSerializableObject for FileNode { + fn is_versioned() -> bool { + true + } +} + +impl FileNode { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.name.len() + + self.file.as_ref().map_or(0, |f| f.size as usize) + + std::mem::size_of::() + } +} + +impl ArchivedFileNode { + pub fn size(&self) -> usize { + self.dead_properties.size() + + self.display_name.as_ref().map_or(0, |n| n.len()) + + self.name.len() + + self + .file + .as_ref() + .map_or(0, |f| f.size.to_native() as usize) + + std::mem::size_of::() + } +} diff --git a/crates/groupware/src/file/mod.rs b/crates/groupware/src/file/mod.rs new file mode 100644 index 0000000..8d000cf --- /dev/null +++ b/crates/groupware/src/file/mod.rs @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod index; +pub mod storage; + +use types::{acl::AclGrant, blob_hash::BlobHash, dead_property::DeadProperty}; + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct FileNode { + pub parent_id: u32, + pub name: String, + pub display_name: Option, + pub file: Option, + pub created: i64, + pub modified: i64, + pub dead_properties: DeadProperty, + pub acls: Vec, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(derive(Debug))] +pub struct FileProperties { + pub blob_hash: BlobHash, + pub size: u32, + pub media_type: Option, + pub executable: bool, +} diff --git a/crates/groupware/src/file/storage.rs b/crates/groupware/src/file/storage.rs new file mode 100644 index 0000000..a8f468d --- /dev/null +++ b/crates/groupware/src/file/storage.rs @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ArchivedFileNode, FileNode}; +use crate::DestroyArchive; +use common::{Server, auth::AccountTenantIds, storage::index::ObjectIndexBuilder}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::collection::{Collection, VanishedCollection}; + +impl FileNode { + pub fn insert( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + set_created: bool, + set_modified: bool, + batch: &mut BatchBuilder, + ) -> trc::Result<&mut BatchBuilder> { + let mut node = self; + let now = now() as i64; + if set_created { + node.created = now; + } + if set_modified { + node.modified = now; + } + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes(node) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } + + pub fn update<'x>( + self, + changed_by: AccountTenantIds, + node: Archive<&ArchivedFileNode>, + account_id: u32, + document_id: u32, + set_modified: bool, + batch: &'x mut BatchBuilder, + ) -> trc::Result<&'x mut BatchBuilder> { + let mut new_node = self; + if set_modified { + new_node.modified = now() as i64; + } + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(node) + .with_changes(new_node) + .with_changed_by(changed_by), + ) + .map(|b| b.commit_point()) + } +} + +impl DestroyArchive> { + pub fn delete( + self, + changed_by: AccountTenantIds, + account_id: u32, + document_id: u32, + batch: &mut BatchBuilder, + path: String, + ) -> trc::Result<()> { + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_current(self.0) + .with_changed_by(changed_by), + )? + .log_vanished_item(VanishedCollection::FileNode, path) + .commit_point(); + Ok(()) + } +} + +impl DestroyArchive> { + pub async fn delete( + self, + server: &Server, + changed_by: AccountTenantIds, + account_id: u32, + delete_path: Option, + ) -> trc::Result<()> { + // Process deletions + let mut batch = BatchBuilder::new(); + self.delete_batch(server, changed_by, account_id, delete_path, &mut batch) + .await?; + // Write changes + if !batch.is_empty() { + server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + Ok(()) + } + + pub async fn delete_batch( + self, + server: &Server, + changed_by: AccountTenantIds, + account_id: u32, + delete_path: Option, + batch: &mut BatchBuilder, + ) -> trc::Result<()> { + // Process deletions + batch + .with_account_id(account_id) + .with_collection(Collection::FileNode); + for document_id in self.0 { + if let Some(node) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await? + { + // Delete record + batch + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(changed_by) + .with_current( + node.to_unarchived::() + .caused_by(trc::location!())?, + ), + ) + .caused_by(trc::location!())? + .commit_point(); + } + } + + if !batch.is_empty() + && let Some(delete_path) = delete_path + { + batch.log_vanished_item(VanishedCollection::FileNode, delete_path); + } + + Ok(()) + } +} diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs new file mode 100644 index 0000000..70b26d3 --- /dev/null +++ b/crates/groupware/src/lib.rs @@ -0,0 +1,287 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use calcard::common::timezone::Tz; +use common::DavResources; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; +use std::borrow::Cow; +use types::collection::{Collection, SyncCollection}; + +pub mod cache; +pub mod calendar; +pub mod contact; +pub mod file; +pub mod scheduling; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavResourceName { + Card, + Cal, + File, + Principal, + Scheduling, +} + +pub const RFC_3986: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'!') + .add(b'"') + .add(b'#') + .add(b'$') + .add(b'%') + .add(b'&') + .add(b'\'') + .add(b'(') + .add(b')') + .add(b'*') + .add(b'+') + .add(b',') + .add(b'/') + .add(b':') + .add(b';') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'@') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + +fn is_pchar(byte: u8) -> bool { + matches!(byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@') +} + +pub fn is_uri_segment(name: &str) -> bool { + let mut bytes = name.as_bytes().iter(); + + while let Some(&byte) = bytes.next() { + if byte == b'%' { + if !bytes.next().is_some_and(u8::is_ascii_hexdigit) + || !bytes.next().is_some_and(u8::is_ascii_hexdigit) + { + return false; + } + } else if !is_pchar(byte) { + return false; + } + } + + true +} + +pub fn encode_path_segment(name: &str) -> Cow<'_, str> { + if is_uri_segment(name) { + Cow::Borrowed(name) + } else { + utf8_percent_encode(name, RFC_3986).into() + } +} + +pub struct DestroyArchive(pub T); + +impl DavResourceName { + pub fn parse(service: &str) -> Option { + hashify::tiny_map!(service.as_bytes(), + "card" => DavResourceName::Card, + "cal" => DavResourceName::Cal, + "file" => DavResourceName::File, + "pal" => DavResourceName::Principal, + "itip" => DavResourceName::Scheduling, + ) + } + + pub fn base_path(&self) -> &'static str { + match self { + DavResourceName::Card => "/dav/card", + DavResourceName::Cal => "/dav/cal", + DavResourceName::File => "/dav/file", + DavResourceName::Principal => "/dav/pal", + DavResourceName::Scheduling => "/dav/itip", + } + } + + pub fn collection_path(&self) -> &'static str { + match self { + DavResourceName::Card => "/dav/card/", + DavResourceName::Cal => "/dav/cal/", + DavResourceName::File => "/dav/file/", + DavResourceName::Principal => "/dav/pal/", + DavResourceName::Scheduling => "/dav/itip/", + } + } + + pub fn name(&self) -> &'static str { + match self { + DavResourceName::Card => "CardDAV", + DavResourceName::Cal => "CalDAV", + DavResourceName::File => "WebDAV", + DavResourceName::Principal => "Principal", + DavResourceName::Scheduling => "Scheduling", + } + } +} + +impl From for Collection { + fn from(value: DavResourceName) -> Self { + match value { + DavResourceName::Card => Collection::AddressBook, + DavResourceName::Cal => Collection::Calendar, + DavResourceName::File => Collection::FileNode, + DavResourceName::Principal => Collection::Principal, + DavResourceName::Scheduling => Collection::CalendarEventNotification, + } + } +} + +impl From for DavResourceName { + fn from(value: Collection) -> Self { + match value { + Collection::AddressBook => DavResourceName::Card, + Collection::Calendar => DavResourceName::Cal, + Collection::FileNode => DavResourceName::File, + Collection::Principal => DavResourceName::Principal, + Collection::CalendarEventNotification => DavResourceName::Scheduling, + _ => unreachable!(), + } + } +} + +impl From for DavResourceName { + fn from(value: SyncCollection) -> Self { + match value { + SyncCollection::AddressBook => DavResourceName::Card, + SyncCollection::Calendar => DavResourceName::Cal, + SyncCollection::FileNode => DavResourceName::File, + SyncCollection::CalendarEventNotification => DavResourceName::Scheduling, + _ => unreachable!(), + } + } +} + +pub trait DavCalendarResource { + fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option; +} + +impl DavCalendarResource for DavResources { + fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option { + self.container_resource_by_id(calendar_id) + .and_then(|c| c.calendar_preferences(account_id)) + .map(|p| p.tz) + } +} + +pub fn strip_mailto_scheme(value: &str) -> &str { + value + .split_once(':') + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("mailto")) + .map_or(value, |(_, address)| address.trim()) +} + +pub fn decode_mailto_address(value: &str) -> Cow<'_, str> { + match value.split_once(':') { + Some((scheme, address)) if scheme.eq_ignore_ascii_case("mailto") => { + let address = address.trim(); + let address = address.split_once('?').map_or(address, |(to, _)| to); + percent_decode_str(address).decode_utf8_lossy() + } + _ => Cow::Borrowed(value), + } +} + +pub fn extract_addr_spec(value: &str) -> Option<&str> { + value + .rsplit_once('<') + .and_then(|(_, rest)| rest.split_once('>')) + .map(|(addr, _)| addr.trim()) + .filter(|addr| !addr.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_segments_from_uris_are_preserved() { + for name in [ + "readme.txt", + "My%20Folder", + "%C3%9Cnterlagen.txt", + "file(1).txt", + "a+b.txt", + "Q&A.txt", + "it's.txt", + "mail@host.txt", + "a:b.txt", + "notes;v=2,rev=3!$*=.txt", + "~backup_1-2.txt", + ] { + assert!(is_uri_segment(name), "{name:?}"); + assert_eq!(encode_path_segment(name), name); + } + } + + #[test] + fn path_segments_from_names_are_encoded() { + for (name, expected) in [ + ("My Folder", "My%20Folder"), + ("Ünterlagen.txt", "%C3%9Cnterlagen.txt"), + ("Ünterlagen 2026.txt", "%C3%9Cnterlagen%202026.txt"), + ("100%", "100%25"), + ("100%2", "100%252"), + ("100%zz", "100%25zz"), + ("a/b.txt", "a%2Fb.txt"), + ("ac.txt", "a%3Cb%3Ec.txt"), + ("a\"b#c?d.txt", "a%22b%23c%3Fd.txt"), + ("a\tb.txt", "a%09b.txt"), + ] { + assert!(!is_uri_segment(name), "{name:?}"); + assert_eq!(encode_path_segment(name), expected, "{name:?}"); + } + } + + #[test] + fn encoded_path_segments_are_stable() { + for name in [ + "My Folder", + "Ünterlagen 2026.txt", + "100%", + "a/b.txt", + "file(1).txt", + ] { + let encoded = encode_path_segment(name).into_owned(); + assert!(is_uri_segment(&encoded), "{encoded:?}"); + assert_eq!(encode_path_segment(&encoded), encoded, "{name:?}"); + } + } +} diff --git a/crates/groupware/src/scheduling/attendee.rs b/crates/groupware/src/scheduling/attendee.rs new file mode 100644 index 0000000..fcf3d50 --- /dev/null +++ b/crates/groupware/src/scheduling/attendee.rs @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + Email, InstanceId, ItipEntries, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, + ItipSnapshots, ItipSummary, + itip::{ + ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope, + itip_export_component, + }, + organizer::organizer_request_full, +}; +use ahash::{AHashMap, AHashSet}; +use calcard::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, ICalendarParameter, + ICalendarParticipationStatus, ICalendarProperty, ICalendarValue, + }, +}; + +pub(crate) fn attendee_handle_update( + new_ical: &ICalendar, + old_itip: ItipSnapshots<'_>, + new_itip: ItipSnapshots<'_>, +) -> Result>, ItipError> { + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: Vec::with_capacity(2), + }; + message + .components + .push(itip_build_envelope(ICalendarMethod::Reply)); + + let mut mail_from = None; + let mut email_rcpt = AHashSet::new(); + let mut new_delegates = AHashSet::new(); + let mut part_stat = &ICalendarParticipationStatus::NeedsAction; + + for (instance_id, instance) in &new_itip.components { + if let Some(old_instance) = old_itip.components.get(instance_id) { + match (instance.local_attendee(), old_instance.local_attendee()) { + (Some(local_attendee), Some(old_local_attendee)) + if local_attendee.email == old_local_attendee.email => + { + // Distinguish a genuine add/remove of a restricted property from a value-only drift + // caused by a client re-encoding the same property + let old_name_counts = count_entry_names(&old_instance.entries); + let new_name_counts = count_entry_names(&instance.entries); + + // Check added fields + let mut send_update = false; + for new_entry in instance.entries.difference(&old_instance.entries) { + match (new_entry.name, &new_entry.value) { + (ICalendarProperty::Exdate, ItipEntryValue::DateTime(date)) + if instance_id == &InstanceId::Main => + { + if let Some((mut cancel_comp, attendee_email)) = attendee_decline( + instance_id, + &old_itip, + old_instance, + &dt_stamp, + &mut email_rcpt, + false, + ) { + // Add EXDATE as RECURRENCE-ID + cancel_comp + .entries + .push(date.to_entry(ICalendarProperty::RecurrenceId)); + part_stat = &ICalendarParticipationStatus::Declined; + + // Add cancel component + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } + _ => { + // Changing these properties is not allowed + if !can_attendee_modify_property( + &instance.comp.component_type, + new_entry.name, + ) { + if name_count(&new_name_counts, new_entry.name) + > name_count(&old_name_counts, new_entry.name) + { + return Err(ItipError::CannotModifyProperty( + new_entry.name.clone(), + )); + } + } else { + send_update = send_update + || (instance.comp.component_type + == ICalendarComponentType::VTodo + && matches!( + new_entry.name, + ICalendarProperty::Status + | ICalendarProperty::PercentComplete + | ICalendarProperty::Completed + )); + } + } + } + } + + // Send participation status update + if local_attendee.is_server_scheduling + && ((local_attendee.part_stat != old_local_attendee.part_stat) + || local_attendee.force_send.is_some() + || send_update) + { + // Build the attendee list + if let Some(new_partstat) = local_attendee.part_stat { + part_stat = new_partstat; + } + let mut attendee_entry_uids = vec![local_attendee.entry_id]; + let old_delegates = old_instance + .external_attendees() + .filter(|a| a.is_delegated_from(old_local_attendee)) + .map(|a| a.email.email.as_str()) + .collect::>(); + for external_attendee in instance.external_attendees() { + if external_attendee.is_delegated_from(local_attendee) { + if external_attendee.send_invite_messages() + && !old_delegates + .contains(&external_attendee.email.email.as_str()) + { + new_delegates.insert(external_attendee.email.email.as_str()); + } + } else if external_attendee.is_delegated_to(local_attendee) { + if external_attendee.send_update_messages() { + email_rcpt.insert(external_attendee.email.email.as_str()); + } + } else { + continue; + } + attendee_entry_uids.push(external_attendee.entry_id); + } + + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(itip_export_component( + instance.comp, + new_itip.uid, + &dt_stamp, + instance.sequence.unwrap_or_default(), + ItipExportAs::Attendee(attendee_entry_uids), + )); + mail_from = Some(&local_attendee.email.email); + } + + // Check removed fields + for removed_entry in old_instance.entries.difference(&instance.entries) { + if !can_attendee_modify_property( + &instance.comp.component_type, + removed_entry.name, + ) && name_count(&old_name_counts, removed_entry.name) + > name_count(&new_name_counts, removed_entry.name) + { + // Removing these properties is not allowed + return Err(ItipError::CannotModifyProperty( + removed_entry.name.clone(), + )); + } + } + } + _ => { + // Change in local attendee email is not allowed + return Err(ItipError::CannotModifyAddress); + } + } + } else if let Some(local_attendee) = instance + .local_attendee() + .filter(|_| instance_id != &InstanceId::Main) + { + let mut attendee_entry_uids = vec![local_attendee.entry_id]; + for external_attendee in instance.external_attendees() { + if external_attendee.is_delegated_from(local_attendee) { + if external_attendee.send_invite_messages() { + new_delegates.insert(external_attendee.email.email.as_str()); + } + } else if external_attendee.is_delegated_to(local_attendee) { + if external_attendee.send_update_messages() { + email_rcpt.insert(external_attendee.email.email.as_str()); + } + } else { + continue; + } + attendee_entry_uids.push(external_attendee.entry_id); + } + + // A new instance has been added + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(itip_export_component( + instance.comp, + new_itip.uid, + &dt_stamp, + instance.sequence.unwrap_or_default(), + ItipExportAs::Attendee(attendee_entry_uids), + )); + mail_from = Some(&local_attendee.email.email); + } else { + return Err(ItipError::CannotModifyInstance); + } + } + + for (instance_id, old_instance) in &old_itip.components { + if !new_itip.components.contains_key(instance_id) { + if instance_id != &InstanceId::Main && old_instance.has_local_attendee() { + // Send cancel message for removed instances + if let Some((cancel_comp, attendee_email)) = attendee_decline( + instance_id, + &old_itip, + old_instance, + &dt_stamp, + &mut email_rcpt, + false, + ) { + // Add cancel component + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } else { + // Removing instances is not allowed + return Err(ItipError::CannotModifyInstance); + } + } + } + + if let Some(from) = mail_from { + email_rcpt.insert(&new_itip.organizer.email.email); + + // Add timezones if needed + itip_add_tz(&mut message, new_ical); + + let mut responses = vec![ItipMessage { + from: from.to_string(), + from_organizer: false, + to: email_rcpt.into_iter().map(|e| e.to_string()).collect(), + summary: ItipSummary::Rsvp { + part_stat: part_stat.clone(), + current: new_itip + .main_instance_or_default() + .build_summary(Some(&new_itip.organizer), &[]), + }, + message, + }]; + + // Invite new delegates + if !new_delegates.is_empty() { + let from = from.to_string(); + let new_delegates = new_delegates + .into_iter() + .map(|e| e.to_string()) + .collect::>(); + if let Ok(messages_) = organizer_request_full(new_ical, &new_itip, None, true) { + for mut message in messages_ { + message.from = from.clone(); + message.to = new_delegates.clone(); + message.from_organizer = false; + responses.push(message); + } + } + } + + Ok(responses) + } else { + Err(ItipError::NothingToSend) + } +} + +pub(crate) fn attendee_decline<'x>( + instance_id: &'x InstanceId, + itip: &'x ItipSnapshots<'x>, + comp: &'x ItipSnapshot<'x>, + dt_stamp: &'x PartialDateTime, + email_rcpt: &mut AHashSet<&'x str>, + skip_needs_action: bool, +) -> Option<(ICalendarComponent, &'x Email)> { + let component = comp.comp; + let mut cancel_comp = ICalendarComponent { + component_type: component.component_type.clone(), + entries: Vec::with_capacity(5), + component_ids: vec![], + }; + + let mut local_attendee = None; + let mut delegated_from = None; + + for attendee in &comp.attendees { + if attendee.email.is_local { + if attendee.is_server_scheduling + && attendee.rsvp.is_none_or(|rsvp| rsvp) + && match attendee.part_stat { + Some( + ICalendarParticipationStatus::Declined + | ICalendarParticipationStatus::Delegated, + ) => attendee.force_send.is_some(), + Some(ICalendarParticipationStatus::NeedsAction) => !skip_needs_action, + _ => true, + } + { + local_attendee = Some(attendee); + } + } else if attendee.delegated_to.iter().any(|d| d.is_local) { + cancel_comp + .entries + .push(component.entries[attendee.entry_id as usize].clone()); + delegated_from = Some(&attendee.email.email); + } + } + + local_attendee.map(|local_attendee| { + cancel_comp.add_property( + ICalendarProperty::Organizer, + ICalendarValue::Text(itip.organizer.email.to_string()), + ); + cancel_comp.add_property_with_params( + ICalendarProperty::Attendee, + [ICalendarParameter::partstat( + ICalendarParticipationStatus::Declined, + )], + ICalendarValue::Text(local_attendee.email.to_string()), + ); + cancel_comp.add_uid(itip.uid); + cancel_comp.add_dtstamp(dt_stamp.clone()); + cancel_comp.add_sequence(comp.sequence.unwrap_or_default()); + cancel_comp.entries.extend( + component + .entries + .iter() + .filter(|e| { + matches!( + e.name, + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Description + | ICalendarProperty::Summary + ) + }) + .cloned(), + ); + + if let InstanceId::Recurrence(recurrence_id) = instance_id { + cancel_comp + .entries + .push(component.entries[recurrence_id.entry_id as usize].clone()); + } + if let Some(delegated_from) = delegated_from { + email_rcpt.insert(delegated_from); + } + + (cancel_comp, &local_attendee.email) + }) +} + +fn count_entry_names<'x>(entries: &'x ItipEntries<'x>) -> AHashMap<&'x ICalendarProperty, usize> { + let mut counts = AHashMap::with_capacity(entries.len()); + for entry in entries { + *counts.entry(entry.name).or_insert(0) += 1; + } + counts +} + +#[inline] +fn name_count(counts: &AHashMap<&ICalendarProperty, usize>, name: &ICalendarProperty) -> usize { + counts.get(name).copied().unwrap_or(0) +} diff --git a/crates/groupware/src/scheduling/event_cancel.rs b/crates/groupware/src/scheduling/event_cancel.rs new file mode 100644 index 0000000..27eac0a --- /dev/null +++ b/crates/groupware/src/scheduling/event_cancel.rs @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + InstanceId, ItipError, ItipMessage, ItipSummary, + attendee::attendee_decline, + itip::{itip_add_tz, itip_build_envelope}, + snapshot::itip_snapshot, +}; +use ahash::AHashSet; +use calcard::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, + ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue, + }, +}; + +pub fn itip_cancel( + ical: &ICalendar, + account_emails: &[String], + is_deletion: bool, +) -> Result, ItipError> { + // Prepare iTIP message + let itip = itip_snapshot(ical, account_emails, false)?; + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: Vec::with_capacity(2), + }; + + if itip.organizer.email.is_local { + // Send cancel message + let mut comp = itip_build_envelope(ICalendarMethod::Cancel); + comp.component_ids.push(1); + message.components.push(comp); + + // Fetch guest emails + let mut recipients = AHashSet::new(); + let mut cancel_guests = AHashSet::new(); + let mut component_type = &ICalendarComponentType::VEvent; + let mut sequence = 0; + for (instance_id, comp) in &itip.components { + component_type = &comp.comp.component_type; + for attendee in &comp.attendees { + if attendee.send_update_messages() { + recipients.insert(attendee.email.email.clone()); + } + cancel_guests.insert(&attendee.email); + } + + // Increment sequence if needed + if instance_id == &InstanceId::Main { + sequence = comp.sequence.unwrap_or_default() + 1; + } + } + + if !recipients.is_empty() && component_type != &ICalendarComponentType::VFreebusy { + let instance = itip.main_instance_or_default(); + message.components.push(build_cancel_component( + instance.comp, + sequence, + dt_stamp, + &[], + )); + + // Add timezones + itip_add_tz(&mut message, ical); + + Ok(ItipMessage { + to: recipients.into_iter().collect(), + summary: ItipSummary::Cancel(instance.build_summary(None, &[])), + from: itip.organizer.email.email, + from_organizer: true, + message, + }) + } else { + Err(ItipError::NothingToSend) + } + } else { + // Send decline message + message + .components + .push(itip_build_envelope(ICalendarMethod::Reply)); + + // Decline attendance for all instances that have local attendees + let mut mail_from = None; + let mut email_rcpt = AHashSet::new(); + for (instance_id, comp) in &itip.components { + if let Some((cancel_comp, attendee_email)) = attendee_decline( + instance_id, + &itip, + comp, + &dt_stamp, + &mut email_rcpt, + is_deletion, + ) { + // Add cancel component + let comp_id = message.components.len() as u32; + message.components[0].component_ids.push(comp_id); + message.components.push(cancel_comp); + mail_from = Some(&attendee_email.email); + } + } + + if let Some(from) = mail_from { + // Add timezone information if needed + itip_add_tz(&mut message, ical); + + email_rcpt.insert(&itip.organizer.email.email); + + Ok(ItipMessage { + from: from.to_string(), + from_organizer: false, + to: email_rcpt.into_iter().map(|e| e.to_string()).collect(), + summary: ItipSummary::Rsvp { + part_stat: ICalendarParticipationStatus::Declined, + current: itip.main_instance_or_default().build_summary(None, &[]), + }, + message, + }) + } else { + Err(ItipError::NothingToSend) + } + } +} + +pub(crate) fn build_cancel_component( + component: &ICalendarComponent, + sequence: i64, + dt_stamp: PartialDateTime, + attendees: &[&str], +) -> ICalendarComponent { + let mut cancel_comp = ICalendarComponent { + component_type: component.component_type.clone(), + entries: Vec::with_capacity(7), + component_ids: vec![], + }; + cancel_comp.add_property( + ICalendarProperty::Status, + ICalendarValue::Status(ICalendarStatus::Cancelled), + ); + cancel_comp.add_dtstamp(dt_stamp); + cancel_comp.add_sequence(sequence); + cancel_comp.entries.extend( + component + .entries + .iter() + .filter(|e| match e.name { + ICalendarProperty::Organizer + | ICalendarProperty::Uid + | ICalendarProperty::Summary + | ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::RecurrenceId + | ICalendarProperty::Created + | ICalendarProperty::LastModified + | ICalendarProperty::Description + | ICalendarProperty::Location => true, + ICalendarProperty::Attendee => { + attendees.is_empty() + || e.values + .first() + .and_then(|v| v.as_text()) + .is_some_and(|email| { + attendees.iter().any(|attendee| { + email + .strip_suffix(attendee) + .is_some_and(|v| v.ends_with(':') || v.is_empty()) + }) + }) + } + _ => false, + }) + .cloned(), + ); + + cancel_comp +} diff --git a/crates/groupware/src/scheduling/event_create.rs b/crates/groupware/src/scheduling/event_create.rs new file mode 100644 index 0000000..8fe59b4 --- /dev/null +++ b/crates/groupware/src/scheduling/event_create.rs @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + ItipError, ItipMessage, itip::itip_finalize, organizer::organizer_request_full, + snapshot::itip_snapshot, +}; +use calcard::icalendar::ICalendar; + +pub fn itip_create( + ical: &mut ICalendar, + account_emails: &[String], +) -> Result>, ItipError> { + let itip = itip_snapshot(ical, account_emails, false)?; + if !itip.organizer.is_server_scheduling { + Err(ItipError::OtherSchedulingAgent) + } else if !itip.organizer.email.is_local { + Err(ItipError::NotOrganizer) + } else { + organizer_request_full(ical, &itip, None, true).inspect(|_| { + itip_finalize(ical, &[]); + }) + } +} diff --git a/crates/groupware/src/scheduling/event_update.rs b/crates/groupware/src/scheduling/event_update.rs new file mode 100644 index 0000000..5e97950 --- /dev/null +++ b/crates/groupware/src/scheduling/event_update.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + ItipError, ItipMessage, attendee::attendee_handle_update, event_cancel::itip_cancel, + itip::itip_finalize, organizer::organizer_handle_update, snapshot::itip_snapshot, +}; +use calcard::icalendar::ICalendar; + +pub fn itip_update( + ical: &mut ICalendar, + old_ical: &ICalendar, + account_emails: &[String], +) -> Result>, ItipError> { + let old_itip = itip_snapshot(old_ical, account_emails, false)?; + match itip_snapshot(ical, account_emails, false) { + Ok(new_itip) => { + let mut sequences = Vec::new(); + if old_itip.organizer.email != new_itip.organizer.email { + // RFC 6638 does not support replacing the organizer + Err(ItipError::OrganizerMismatch) + } else if old_itip.organizer.email.is_local { + organizer_handle_update(old_ical, ical, old_itip, new_itip, &mut sequences) + } else { + attendee_handle_update(ical, old_itip, new_itip) + } + .inspect(|_| { + itip_finalize(ical, &sequences); + }) + } + Err(err) => { + match &err { + ItipError::NoSchedulingInfo + | ItipError::NotOrganizer + | ItipError::NotOrganizerNorAttendee + | ItipError::OtherSchedulingAgent => { + if old_itip.organizer.email.is_local { + // RFC 6638 does not support replacing the organizer, so we cancel the event + itip_cancel(old_ical, account_emails, false).map(|message| vec![message]) + } else { + Err(ItipError::CannotModifyAddress) + } + } + _ => Err(err), + } + } + } +} diff --git a/crates/groupware/src/scheduling/format.rs b/crates/groupware/src/scheduling/format.rs new file mode 100644 index 0000000..ad873d5 --- /dev/null +++ b/crates/groupware/src/scheduling/format.rs @@ -0,0 +1,594 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::ItipValue; +use calcard::{ + common::timezone::Tz, + icalendar::{ICalendarDay, ICalendarFrequency, ICalendarRecurrenceRule, ICalendarWeekday}, +}; +use chrono::{DateTime, NaiveDate, TimeZone, Weekday}; +use common::i18n::{self, Locale, PluralForms}; +use icu_datetime::{DateTimeFormatter, fieldsets}; +use icu_locale_core::{Locale as IcuLocale, locale}; +use icu_plurals::{PluralCategory, PluralRuleType, PluralRules, PluralRulesOptions}; +use std::fmt::Write; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DateStyle { + Short, + Long, +} + +pub struct TextFormatter { + pub locale: &'static Locale, + date_short: DateTimeFormatter, + date_long: DateTimeFormatter, + weekday_short: DateTimeFormatter, + weekday: DateTimeFormatter, + month: DateTimeFormatter, + cardinal: PluralRules, + ordinal: PluralRules, +} + +impl TextFormatter { + pub fn new(language: &str) -> trc::Result { + let locale = i18n::locale_or_default(language); + let icu_locale = IcuLocale::try_from_str(locale.name).unwrap_or(locale!("en-US")); + let failed = |detail: &'static str| { + move |err: icu_datetime::DateTimeFormatterLoadError| { + trc::EventType::Calendar(trc::CalendarEvent::ItipMessageError) + .into_err() + .caused_by(trc::location!()) + .details(detail) + .ctx(trc::Key::Reason, err.to_string()) + } + }; + let plural_prefs = (&icu_locale).into(); + let datetime_prefs = (&icu_locale).into(); + let plural_rules = |options| { + PluralRules::try_new(plural_prefs, options).map_err(|err| { + trc::EventType::Calendar(trc::CalendarEvent::ItipMessageError) + .into_err() + .caused_by(trc::location!()) + .details("Failed to load plural rules") + .ctx(trc::Key::Reason, err.to_string()) + }) + }; + + Ok(Self { + locale, + date_short: DateTimeFormatter::try_new( + datetime_prefs, + fieldsets::YMD::medium().with_time_hm(), + ) + .map_err(failed("Failed to load short date formatter"))?, + date_long: DateTimeFormatter::try_new( + datetime_prefs, + fieldsets::YMD::long().with_time_hm(), + ) + .map_err(failed("Failed to load long date formatter"))?, + weekday_short: DateTimeFormatter::try_new(datetime_prefs, fieldsets::E::short()) + .map_err(failed("Failed to load short weekday formatter"))?, + weekday: DateTimeFormatter::try_new(datetime_prefs, fieldsets::E::long()) + .map_err(failed("Failed to load weekday formatter"))?, + month: DateTimeFormatter::try_new(datetime_prefs, fieldsets::M::long()) + .map_err(failed("Failed to load month formatter"))?, + cardinal: plural_rules(PluralRulesOptions::default())?, + ordinal: plural_rules( + PluralRulesOptions::default().with_type(PluralRuleType::Ordinal), + )?, + }) + } + + pub fn field(&self, out: &mut String, value: &ItipValue, style: DateStyle) { + match value { + ItipValue::Text(text) => out.push_str(text), + ItipValue::Time(time) => { + let tz = Tz::from_id(time.tz_id).unwrap_or(Tz::UTC); + let (weekday, date) = match style { + DateStyle::Short => (&self.weekday_short, &self.date_short), + DateStyle::Long => (&self.weekday, &self.date_long), + }; + let local = tz + .from_utc_datetime( + &DateTime::from_timestamp(time.start, 0) + .unwrap_or_default() + .naive_local(), + ) + .naive_local(); + let _ = write!( + out, + "{}, {}", + weekday.format(&local.date()), + date.format(&local) + ); + + if let Some(name) = tz.name().filter(|name| !name.is_empty()) { + let _ = write!(out, " ({name})"); + } + } + ItipValue::Rrule(rrule) => self.recurrence(out, rrule), + ItipValue::Participants(_) => {} + } + } + + pub fn field_to_string(&self, value: &ItipValue, style: DateStyle) -> String { + let mut out = String::with_capacity(32); + self.field(&mut out, value, style); + out + } + + pub fn recurrence(&self, out: &mut String, rule: &ICalendarRecurrenceRule) { + let start = out.len(); + self.write_frequency(out, &rule.freq, rule.interval.unwrap_or(1)); + + if !rule.byday.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_on, |out| { + self.write_list(out, rule.byday.len(), |out, index| { + self.write_day(out, &rule.byday[index]) + }) + }); + } + + if !rule.byhour.is_empty() || !rule.byminute.is_empty() { + let hours = rule.byhour.len().max(1); + let minutes = rule.byminute.len().max(1); + self.write_clause(out, start, self.locale.calendar_rrule_at, |out| { + self.write_list(out, hours * minutes, |out, index| { + let hour = rule.byhour.get(index / minutes).copied().unwrap_or(0); + let minute = rule.byminute.get(index % minutes).copied().unwrap_or(0); + let _ = write!(out, "{hour:02}:{minute:02}"); + }) + }); + } + + if !rule.bymonthday.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_on_the, |out| { + self.write_list(out, rule.bymonthday.len(), |out, index| { + self.write_signed_ordinal(out, rule.bymonthday[index] as i32) + }) + }); + } + + if !rule.bymonth.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_in, |out| { + self.write_list(out, rule.bymonth.len(), |out, index| { + self.write_month(out, rule.bymonth[index].month()) + }) + }); + } + + if !rule.byyearday.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_on, |out| { + self.write_list(out, rule.byyearday.len(), |out, index| { + self.write_counted( + out, + rule.byyearday[index] as i32, + self.locale.calendar_rrule_year_day, + ) + }) + }); + } + + if !rule.byweekno.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_in, |out| { + self.write_list(out, rule.byweekno.len(), |out, index| { + self.write_counted( + out, + rule.byweekno[index] as i32, + self.locale.calendar_rrule_week_no, + ) + }) + }); + } + + if !rule.bysetpos.is_empty() { + self.write_clause(out, start, self.locale.calendar_rrule_setpos, |out| { + self.write_list(out, rule.bysetpos.len(), |out, index| { + self.write_signed_ordinal(out, rule.bysetpos[index]) + }) + }); + } + + if let Some(count) = rule.count.as_ref() { + if out.len() > start { + out.push_str(", "); + } + let form = self + .cardinal + .category_for(*count) + .plural_form(&self.locale.calendar_rrule_count); + write_number(out, form, "$n", *count as u64); + } + } + + fn write_clause( + &self, + out: &mut String, + start: usize, + template: &str, + write_items: impl FnOnce(&mut String), + ) { + let restore = out.len(); + if out.len() > start { + out.push(' '); + } + + let (before, after) = template.split_once("$list").unwrap_or((template, "")); + out.push_str(before); + let items_start = out.len(); + write_items(out); + + if out.len() == items_start { + out.truncate(restore); + } else { + out.push_str(after); + } + } + + fn write_list( + &self, + out: &mut String, + len: usize, + mut write_item: impl FnMut(&mut String, usize), + ) { + match len { + 0 => {} + 1 => write_item(out, 0), + _ => { + let conjunction = self.locale.calendar_rrule_and; + let (prefix, rest) = conjunction.split_once("$a").unwrap_or(("", conjunction)); + let (separator, suffix) = rest.split_once("$b").unwrap_or((rest, "")); + + out.push_str(prefix); + for index in 0..len - 1 { + if index > 0 { + out.push_str(", "); + } + write_item(out, index); + } + out.push_str(separator); + write_item(out, len - 1); + out.push_str(suffix); + } + } + } + + fn write_frequency(&self, out: &mut String, freq: &ICalendarFrequency, interval: u16) { + let entry = match freq { + ICalendarFrequency::Secondly => self.locale.calendar_rrule_secondly, + ICalendarFrequency::Minutely => self.locale.calendar_rrule_minutely, + ICalendarFrequency::Hourly => self.locale.calendar_rrule_hourly, + ICalendarFrequency::Daily => self.locale.calendar_rrule_daily, + ICalendarFrequency::Weekly => self.locale.calendar_rrule_weekly, + ICalendarFrequency::Monthly => self.locale.calendar_rrule_monthly, + ICalendarFrequency::Yearly => self.locale.calendar_rrule_yearly, + }; + let form = self + .cardinal + .category_for(interval as u32) + .plural_form(&entry); + write_number(out, form, "$n", interval as u64); + } + + fn write_ordinal(&self, out: &mut String, n: u32) { + let form = self + .ordinal + .category_for(n) + .plural_form(&self.locale.calendar_rrule_ordinal); + write_number(out, form, "$n", n as u64); + } + + fn write_signed_ordinal(&self, out: &mut String, value: i32) { + if value < 0 { + let (before, after) = self + .locale + .calendar_rrule_from_end + .split_once("$ordinal") + .unwrap_or((self.locale.calendar_rrule_from_end, "")); + out.push_str(before); + self.write_ordinal(out, value.unsigned_abs()); + out.push_str(after); + } else { + self.write_ordinal(out, value.unsigned_abs()); + } + } + + fn write_counted(&self, out: &mut String, value: i32, template: &str) { + let count = value.unsigned_abs() as u64; + + if value < 0 { + let (before, after) = self + .locale + .calendar_rrule_from_end + .split_once("$ordinal") + .unwrap_or((self.locale.calendar_rrule_from_end, "")); + out.push_str(before); + write_number(out, template, "$n", count); + out.push_str(after); + } else { + write_number(out, template, "$n", count); + } + } + + fn write_weekday(&self, out: &mut String, weekday: ICalendarWeekday) { + let weekday = match weekday { + ICalendarWeekday::Monday => Weekday::Mon, + ICalendarWeekday::Tuesday => Weekday::Tue, + ICalendarWeekday::Wednesday => Weekday::Wed, + ICalendarWeekday::Thursday => Weekday::Thu, + ICalendarWeekday::Friday => Weekday::Fri, + ICalendarWeekday::Saturday => Weekday::Sat, + ICalendarWeekday::Sunday => Weekday::Sun, + }; + + if let Some(date) = NaiveDate::from_isoywd_opt(2024, 1, weekday) { + let _ = write!(out, "{}", self.weekday.format(&date)); + } + } + + fn write_month(&self, out: &mut String, month: u8) { + if let Some(date) = NaiveDate::from_ymd_opt(2024, month.clamp(1, 12) as u32, 1) { + let _ = write!(out, "{}", self.month.format(&date)); + } + } + + fn write_day(&self, out: &mut String, day: &ICalendarDay) { + let Some(occurrence) = day.ordwk.filter(|occurrence| *occurrence != 0) else { + self.write_weekday(out, day.weekday); + return; + }; + + let (wrap_before, wrap_after) = if occurrence < 0 { + let from_end = self.locale.calendar_rrule_from_end; + from_end.split_once("$ordinal").unwrap_or((from_end, "")) + } else { + ("", "") + }; + + out.push_str(wrap_before); + + let mut rest = self.locale.calendar_rrule_nth_weekday; + loop { + let Some((at, placeholder)) = ["$ordinal", "$weekday"] + .into_iter() + .filter_map(|placeholder| rest.find(placeholder).map(|at| (at, placeholder))) + .min() + else { + out.push_str(rest); + break; + }; + + out.push_str(&rest[..at]); + if placeholder == "$ordinal" { + self.write_ordinal(out, occurrence.unsigned_abs() as u32); + } else { + self.write_weekday(out, day.weekday); + } + rest = &rest[at + placeholder.len()..]; + } + + out.push_str(wrap_after); + } +} + +trait PluralCategoryExt { + fn plural_form(&self, forms: &PluralForms) -> &'static str; +} + +impl PluralCategoryExt for PluralCategory { + fn plural_form(&self, forms: &PluralForms) -> &'static str { + match self { + PluralCategory::Zero => forms.zero, + PluralCategory::One => forms.one, + PluralCategory::Two => forms.two, + PluralCategory::Few => forms.few, + PluralCategory::Many => forms.many, + PluralCategory::Other => forms.other, + } + } +} + +fn write_number(out: &mut String, template: &str, placeholder: &str, value: u64) { + let mut rest = template; + + while let Some((before, after)) = rest.split_once(placeholder) { + out.push_str(before); + let _ = write!(out, "{value}"); + rest = after; + } + + out.push_str(rest); +} + +pub fn hyperlink(value: &str) -> Option<&str> { + let (scheme, _) = value.split_once(':')?; + + ["https", "http", "tel", "sip", "sips", "xmpp"] + .iter() + .any(|candidate| scheme.eq_ignore_ascii_case(candidate)) + .then_some(value) +} + +#[cfg(test)] +mod tests { + use super::{PluralCategoryExt, PluralForms, TextFormatter, i18n}; + use calcard::icalendar::{ + ICalendarDay, ICalendarFrequency, ICalendarRecurrenceRule, ICalendarWeekday, + }; + use icu_plurals::PluralCategory; + + fn rule(freq: ICalendarFrequency, interval: Option) -> ICalendarRecurrenceRule { + ICalendarRecurrenceRule { + freq, + interval, + ..Default::default() + } + } + + fn format(language: &str, rule: &ICalendarRecurrenceRule) -> String { + let mut out = String::new(); + TextFormatter::new(language) + .expect("formatter") + .recurrence(&mut out, rule); + out + } + + #[test] + fn every_shipped_locale_loads_icu_data() { + for locale in i18n::ALL_LOCALES { + let formatter = TextFormatter::new(locale.name) + .unwrap_or_else(|err| panic!("{}: {err:?}", locale.name)); + assert_eq!(formatter.locale.name, locale.name); + + let mut out = String::new(); + formatter.recurrence( + &mut out, + &ICalendarRecurrenceRule { + freq: ICalendarFrequency::Monthly, + interval: Some(2), + count: Some(5), + bymonthday: vec![3, -1], + byday: vec![ICalendarDay { + ordwk: Some(2), + weekday: ICalendarWeekday::Monday, + }], + ..Default::default() + }, + ); + assert!(!out.is_empty(), "{} produced no text", locale.name); + } + } + + #[test] + fn plural_form_selects_category_and_falls_back_to_other() { + let forms = PluralForms { + zero: "many", + one: "single", + two: "many", + few: "a few", + many: "many", + other: "many", + }; + assert_eq!(PluralCategory::One.plural_form(&forms), "single"); + assert_eq!(PluralCategory::Few.plural_form(&forms), "a few"); + assert_eq!(PluralCategory::Many.plural_form(&forms), "many"); + assert_eq!(PluralCategory::Other.plural_form(&forms), "many"); + + // Categories a locale omits are filled from "other" at build time + let polish = i18n::locale("pl-PL").expect("locale must exist"); + assert_eq!(polish.calendar_rrule_secondly.many, "Co $n sekund"); + assert_eq!( + polish.calendar_rrule_secondly.zero, + polish.calendar_rrule_secondly.other + ); + } + + #[test] + fn frequency_uses_cardinal_plural_rules() { + assert_eq!( + format("en", &rule(ICalendarFrequency::Weekly, None)), + "Every week" + ); + assert_eq!( + format("en", &rule(ICalendarFrequency::Weekly, Some(2))), + "Every 2 weeks" + ); + + // Polish distinguishes one / few / many, unlike English + assert_eq!( + format("pl", &rule(ICalendarFrequency::Weekly, Some(1))), + "Co tydzień" + ); + assert_eq!( + format("pl", &rule(ICalendarFrequency::Weekly, Some(2))), + "Co 2 tygodnie" + ); + assert_eq!( + format("pl", &rule(ICalendarFrequency::Weekly, Some(5))), + "Co 5 tygodni" + ); + } + + #[test] + fn weekday_names_are_localized_without_translation_keys() { + let mut byday = rule(ICalendarFrequency::Weekly, None); + byday.byday = vec![ICalendarDay { + weekday: ICalendarWeekday::Monday, + ordwk: None, + }]; + + assert_eq!(format("en", &byday), "Every week on Monday"); + assert_eq!(format("es", &byday), "Cada semana los lunes"); + } + + #[test] + fn ordinal_weekday_uses_ordinal_plural_rules() { + let mut byday = rule(ICalendarFrequency::Monthly, None); + byday.byday = vec![ICalendarDay { + weekday: ICalendarWeekday::Tuesday, + ordwk: Some(2), + }]; + assert_eq!(format("en", &byday), "Every month on the 2nd Tuesday"); + + byday.byday = vec![ICalendarDay { + weekday: ICalendarWeekday::Tuesday, + ordwk: Some(3), + }]; + assert_eq!(format("en", &byday), "Every month on the 3rd Tuesday"); + + byday.byday = vec![ICalendarDay { + weekday: ICalendarWeekday::Tuesday, + ordwk: Some(-1), + }]; + assert_eq!( + format("en", &byday), + "Every month on the 1st Tuesday from the end" + ); + } + + #[test] + fn count_is_pluralized_and_appended() { + let mut counted = rule(ICalendarFrequency::Daily, None); + counted.count = Some(1); + assert_eq!(format("en", &counted), "Every day, 1 time"); + + counted.count = Some(5); + assert_eq!(format("en", &counted), "Every day, 5 times"); + } + + #[test] + fn multiple_days_are_joined_with_the_localized_conjunction() { + let mut byday = rule(ICalendarFrequency::Weekly, None); + byday.byday = vec![ + ICalendarDay { + weekday: ICalendarWeekday::Monday, + ordwk: None, + }, + ICalendarDay { + weekday: ICalendarWeekday::Wednesday, + ordwk: None, + }, + ICalendarDay { + weekday: ICalendarWeekday::Friday, + ordwk: None, + }, + ]; + + assert_eq!( + format("en", &byday), + "Every week on Monday, Wednesday and Friday" + ); + } + + #[test] + fn unknown_language_falls_back_to_english_rules() { + assert_eq!( + format("zz", &rule(ICalendarFrequency::Weekly, Some(3))), + "Every 3 weeks" + ); + } +} diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs new file mode 100644 index 0000000..fb05ad1 --- /dev/null +++ b/crates/groupware/src/scheduling/inbound.rs @@ -0,0 +1,624 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + InstanceId, ItipError, ItipMessage, ItipSnapshots, organizer::organizer_request_full, +}; +use ahash::AHashSet; +use calcard::icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, ICalendarValue, + Uri, +}; + +#[derive(Debug)] +pub enum MergeAction { + AddEntries { + component_id: u16, + entries: Vec, + }, + RemoveEntries { + component_id: u16, + entries: AHashSet, + }, + AddParameters { + component_id: u16, + entry_id: u16, + parameters: Vec, + }, + RemoveParameters { + component_id: u16, + entry_id: u16, + parameters: Vec, + }, + AddComponent { + component: ICalendarComponent, + }, + RemoveComponent { + component_id: u16, + }, +} + +pub enum MergeResult { + Actions(Vec), + Message(ItipMessage), + None, +} + +pub fn itip_process_message( + ical: &ICalendar, + snapshots: ItipSnapshots<'_>, + itip: &ICalendar, + itip_snapshots: ItipSnapshots<'_>, + sender: String, +) -> Result { + if snapshots.organizer.email != itip_snapshots.organizer.email { + return Err(ItipError::OrganizerMismatch); + } + + let method = itip_method(itip)?; + let mut merge_actions = Vec::new(); + + if snapshots.organizer.email.is_local { + // Handle attendee updates + if snapshots.organizer.email.email == sender { + return Err(ItipError::OrganizerIsLocalAddress); + } + match method { + ICalendarMethod::Reply => { + handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + } + ICalendarMethod::Refresh => { + return organizer_request_full(ical, &snapshots, None, false).and_then( + |messages| { + messages + .into_iter() + .next() + .map(|mut message| { + message.to = vec![sender]; + MergeResult::Message(message) + }) + .ok_or(ItipError::NothingToSend) + }, + ); + } + _ => return Err(ItipError::UnsupportedMethod(method.clone())), + } + } else { + // Handle organizer and attendees updates + match method { + ICalendarMethod::Request => { + let mut is_full_update = false; + for (instance_id, itip_snapshot) in &itip_snapshots.components { + is_full_update = is_full_update || instance_id == &InstanceId::Main; + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + + if let Some(snapshot) = snapshots.components.get(instance_id) { + // Merge instances + if itip_snapshot.sequence.unwrap_or_default() + >= snapshot.sequence.unwrap_or_default() + { + let mut changed_entries = itip_snapshot + .entries + .symmetric_difference(&snapshot.entries) + .map(|entry| entry.name.clone()) + .collect::>(); + if itip_snapshot.attendees != snapshot.attendees { + changed_entries.insert(ICalendarProperty::Attendee); + } + if itip_snapshot.dtstamp.is_some() + && itip_snapshot.dtstamp != snapshot.dtstamp + { + changed_entries.insert(ICalendarProperty::Dtstamp); + } + changed_entries.insert(ICalendarProperty::Sequence); + + if !changed_entries.is_empty() { + let entries = itip_component + .entries + .iter() + .filter(|entry| changed_entries.contains(&entry.name)) + .cloned() + .collect(); + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: changed_entries, + }); + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries, + }); + } + } else { + return Err(ItipError::OutOfSequence); + } + } else { + // Add instance + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + !matches!(entry.name, ICalendarProperty::Other(_)) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } + } + + if is_full_update { + for (instance_id, snapshot) in &snapshots.components { + if !itip_snapshots.components.contains_key(instance_id) { + // Remove instance + merge_actions.push(MergeAction::RemoveComponent { + component_id: snapshot.comp_id, + }); + } + } + } + } + ICalendarMethod::Add => { + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if !snapshots.components.contains_key(instance_id) { + let itip_component = &itip.components[itip_snapshot.comp_id as usize]; + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + !matches!(entry.name, ICalendarProperty::Other(_)) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } + } + } + ICalendarMethod::Cancel => { + let mut cancel_all_instances = false; + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if let Some(snapshot) = snapshots.components.get(instance_id) { + if itip_snapshot.sequence.unwrap_or_default() + >= snapshot.sequence.unwrap_or_default() + { + // Cancel instance + let itip_component = itip_snapshot.comp; + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: [ + ICalendarProperty::Organizer, + ICalendarProperty::Attendee, + ICalendarProperty::Status, + ICalendarProperty::Sequence, + ] + .into_iter() + .collect(), + }); + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Attendee + ) + }) + .cloned() + .chain([ICalendarEntry { + name: ICalendarProperty::Status, + params: vec![], + values: vec![ICalendarValue::Status( + ICalendarStatus::Cancelled, + )], + }]) + .collect(), + }); + cancel_all_instances = + cancel_all_instances || instance_id == &InstanceId::Main; + } else { + return Err(ItipError::OutOfSequence); + } + } else { + let itip_component = itip_snapshot.comp; + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + !matches!( + entry.name, + ICalendarProperty::Status | ICalendarProperty::Other(_) + ) + }) + .cloned() + .chain([ICalendarEntry { + name: ICalendarProperty::Status, + params: vec![], + values: vec![ICalendarValue::Status( + ICalendarStatus::Cancelled, + )], + }]) + .collect(), + component_ids: vec![], + }, + }); + } + } + + if cancel_all_instances { + // Remove all instances + let itip_main = itip_snapshots.components.get(&InstanceId::Main).unwrap(); + let itip_component = itip_main.comp; + for (instance_id, snapshot) in &snapshots.components { + if !itip_snapshots.components.contains_key(instance_id) { + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: [ + ICalendarProperty::Organizer, + ICalendarProperty::Attendee, + ICalendarProperty::Status, + ] + .into_iter() + .collect(), + }); + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Attendee + ) + }) + .cloned() + .chain([ICalendarEntry { + name: ICalendarProperty::Status, + params: vec![], + values: vec![ICalendarValue::Status( + ICalendarStatus::Cancelled, + )], + }]) + .collect(), + }); + } + } + } + } + ICalendarMethod::Reply + if itip_snapshots.components.values().any(|snapshot| { + snapshot.external_attendees().any(|a| { + a.email.email == sender && a.delegated_from.iter().any(|a| a.is_local) + }) + }) => + { + handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + } + _ => return Err(ItipError::UnsupportedMethod(method.clone())), + } + } + + if !merge_actions.is_empty() { + Ok(MergeResult::Actions(merge_actions)) + } else { + Ok(MergeResult::None) + } +} + +pub fn itip_import_message(ical: &mut ICalendar) -> Result<(), ItipError> { + let mut expect_object_type = None; + for comp in ical.components.iter_mut() { + if comp.component_type.is_scheduling_object() { + match expect_object_type { + Some(expected) if expected != &comp.component_type => { + return Err(ItipError::MultipleObjectTypes); + } + None => { + expect_object_type = Some(&comp.component_type); + } + _ => {} + } + } else if comp.component_type == ICalendarComponentType::VCalendar { + comp.entries + .retain(|entry| !matches!(entry.name, ICalendarProperty::Method)); + } + } + + Ok(()) +} + +fn handle_reply( + snapshots: &ItipSnapshots<'_>, + itip_snapshots: &ItipSnapshots<'_>, + sender: &str, + merge_actions: &mut Vec, +) -> Result<(), ItipError> { + for (instance_id, itip_snapshot) in &itip_snapshots.components { + if let Some(snapshot) = snapshots.components.get(instance_id) { + if let (Some(attendee), Some(updated_attendee)) = ( + snapshot.attendee_by_email(sender), + itip_snapshot.attendee_by_email(sender), + ) { + let itip_component = itip_snapshot.comp; + let changed_part_stat = attendee.part_stat != updated_attendee.part_stat; + let changed_rsvp = attendee.rsvp != updated_attendee.rsvp; + let changed_delegated_to = attendee.delegated_to != updated_attendee.delegated_to; + let has_request_status = !itip_snapshot.request_status.is_empty(); + + if changed_part_stat || changed_rsvp || changed_delegated_to || has_request_status { + // Update participant status + let mut add_parameters = Vec::new(); + let mut remove_parameters = Vec::new(); + if changed_part_stat { + remove_parameters.push(ICalendarParameterName::Partstat); + if let Some(part_stat) = updated_attendee.part_stat { + add_parameters.push(ICalendarParameter::partstat(part_stat.clone())); + } + } + + if changed_rsvp { + remove_parameters.push(ICalendarParameterName::Rsvp); + if let Some(rsvp) = updated_attendee.rsvp { + add_parameters.push(ICalendarParameter::rsvp(rsvp)); + } + } + + if changed_delegated_to { + remove_parameters.push(ICalendarParameterName::DelegatedTo); + if !updated_attendee.delegated_to.is_empty() { + add_parameters.extend(updated_attendee.delegated_to.iter().map( + |email| { + ICalendarParameter::delegated_to(Uri::Location( + email.to_string(), + )) + }, + )); + } + } + + // RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none + remove_parameters.push(ICalendarParameterName::ScheduleStatus); + add_parameters.push(ICalendarParameter::schedule_status( + if has_request_status { + itip_snapshot.request_status.join(",") + } else { + "2.0".to_string() + }, + )); + + merge_actions.push(MergeAction::RemoveParameters { + component_id: snapshot.comp_id, + entry_id: attendee.entry_id, + parameters: remove_parameters, + }); + merge_actions.push(MergeAction::AddParameters { + component_id: snapshot.comp_id, + entry_id: attendee.entry_id, + parameters: add_parameters, + }); + + // Add unknown delegated attendees + for delegated_to in &updated_attendee.delegated_to { + if let Some(itip_delegated) = + itip_snapshot.attendee_by_email(&delegated_to.email) + { + if let Some(delegated) = snapshot.attendee_by_email(&delegated_to.email) + { + if delegated != itip_delegated { + merge_actions.push(MergeAction::RemoveParameters { + component_id: snapshot.comp_id, + entry_id: delegated.entry_id, + parameters: vec![ + ICalendarParameterName::DelegatedTo, + ICalendarParameterName::DelegatedFrom, + ICalendarParameterName::Partstat, + ICalendarParameterName::Rsvp, + ICalendarParameterName::ScheduleStatus, + ICalendarParameterName::Role, + ], + }); + merge_actions.push(MergeAction::AddParameters { + component_id: snapshot.comp_id, + entry_id: delegated.entry_id, + parameters: itip_component.entries + [itip_delegated.entry_id as usize] + .params + .iter() + .filter(|param| { + matches!( + param.name, + ICalendarParameterName::DelegatedTo + | ICalendarParameterName::DelegatedFrom + | ICalendarParameterName::Partstat + | ICalendarParameterName::Rsvp + | ICalendarParameterName::ScheduleStatus + | ICalendarParameterName::Role + ) + }) + .cloned() + .collect(), + }); + } + } else { + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: vec![ + itip_component.entries[itip_delegated.entry_id as usize] + .clone(), + ], + }); + } + } + } + } + + // Add changed properties for VTODO + if snapshot.comp.component_type == ICalendarComponentType::VTodo { + let mut remove_entries = AHashSet::new(); + let mut add_entries = Vec::new(); + + for entry in itip_component.entries.iter() { + if matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + ) { + remove_entries.insert(entry.name.clone()); + add_entries.push(entry.clone()); + } + } + + if !add_entries.is_empty() { + merge_actions.push(MergeAction::RemoveEntries { + component_id: snapshot.comp_id, + entries: remove_entries, + }); + merge_actions.push(MergeAction::AddEntries { + component_id: snapshot.comp_id, + entries: add_entries, + }); + } + } + } else { + return Err(ItipError::SenderIsNotParticipant(sender.to_string())); + } + } else if itip_snapshot.attendee_by_email(sender).is_some() { + // Add component + let itip_component = itip_snapshot.comp; + let is_todo = itip_component.component_type == ICalendarComponentType::VTodo; + merge_actions.push(MergeAction::AddComponent { + component: ICalendarComponent { + component_type: itip_component.component_type.clone(), + entries: itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Attendee + | ICalendarProperty::Uid + | ICalendarProperty::Dtstamp + | ICalendarProperty::Sequence + | ICalendarProperty::RecurrenceId + ) || (is_todo + && matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + )) + }) + .cloned() + .collect(), + component_ids: vec![], + }, + }); + } else { + return Err(ItipError::SenderIsNotParticipant(sender.to_string())); + } + } + + Ok(()) +} + +pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec) { + let mut remove_component_ids: Vec = Vec::new(); + for action in changes { + match action { + MergeAction::AddEntries { + component_id, + entries, + } => { + let component = &mut ical.components[component_id as usize]; + component.entries.extend(entries); + } + MergeAction::RemoveEntries { + component_id, + entries, + } => { + let component = &mut ical.components[component_id as usize]; + component + .entries + .retain(|entry| !entries.contains(&entry.name)); + } + MergeAction::AddParameters { + component_id, + entry_id, + parameters, + } => { + ical.components[component_id as usize].entries[entry_id as usize] + .params + .extend(parameters); + } + MergeAction::RemoveParameters { + component_id, + entry_id, + parameters, + } => { + ical.components[component_id as usize].entries[entry_id as usize] + .params + .retain(|param| !parameters.contains(¶m.name)); + } + MergeAction::AddComponent { component } => { + let comp_id = ical.components.len() as u32; + if let Some(root) = ical + .components + .get_mut(0) + .filter(|c| c.component_type == ICalendarComponentType::VCalendar) + { + root.component_ids.push(comp_id); + ical.components.push(component); + } + } + MergeAction::RemoveComponent { component_id } => { + remove_component_ids.push(component_id as u32); + } + } + } + + if !remove_component_ids.is_empty() { + ical.remove_component_ids(&remove_component_ids); + } +} + +pub fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> { + ical.components + .first() + .and_then(|comp| { + comp.entries.iter().find_map(|entry| { + if entry.name == ICalendarProperty::Method { + entry.values.first().and_then(|value| { + if let ICalendarValue::Method(method) = value { + Some(method) + } else { + None + } + }) + } else { + None + } + }) + }) + .ok_or(ItipError::MissingMethod) +} diff --git a/crates/groupware/src/scheduling/itip.rs b/crates/groupware/src/scheduling/itip.rs new file mode 100644 index 0000000..8191e3f --- /dev/null +++ b/crates/groupware/src/scheduling/itip.rs @@ -0,0 +1,480 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{Email, ItipMessage, ItipMessages, ItipSummary}; +use calcard::{ + common::{IanaString, PartialDateTime}, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarParameterValue, + ICalendarParticipationStatus, ICalendarProperty, ICalendarScheduleAgentValue, + ICalendarValue, + }, +}; +use common::PROD_ID; +use registry::schema::structs::{ + Task, TaskCalendarItipContents, TaskCalendarItipMessage, TaskStatus, +}; +use store::write::BatchBuilder; + +pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent { + ICalendarComponent { + component_type: ICalendarComponentType::VCalendar, + entries: vec![ + ICalendarEntry { + name: ICalendarProperty::Version, + params: vec![], + values: vec![ICalendarValue::Text("2.0".to_string())], + }, + ICalendarEntry { + name: ICalendarProperty::Prodid, + params: vec![], + values: vec![ICalendarValue::Text(PROD_ID.to_string())], + }, + ICalendarEntry { + name: ICalendarProperty::Method, + params: vec![], + values: vec![ICalendarValue::Method(method)], + }, + ], + component_ids: Default::default(), + } +} + +pub fn itip_assign_organizer(ical: &mut ICalendar, organizer_address: &str) -> bool { + let mut assigned = false; + + for component in &mut ical.components { + if !component.component_type.is_scheduling_object() { + continue; + } + + let mut has_organizer = false; + let mut has_attendee = false; + + for entry in &component.entries { + match entry.name { + ICalendarProperty::Organizer => has_organizer = true, + ICalendarProperty::Attendee => has_attendee = true, + _ => {} + } + } + + if has_attendee && !has_organizer { + component.entries.push(ICalendarEntry { + name: ICalendarProperty::Organizer, + params: vec![], + values: vec![ICalendarValue::Text(format!("mailto:{organizer_address}"))], + }); + assigned = true; + } + } + + assigned +} + +pub(crate) const ITIP_STATUS_INVALID_USER: &str = "3.7"; + +fn itip_is_server_scheduling(entry: &ICalendarEntry) -> bool { + !entry.params.iter().any(|param| { + matches!( + (¶m.name, ¶m.value), + ( + ICalendarParameterName::ScheduleAgent, + ICalendarParameterValue::ScheduleAgent( + ICalendarScheduleAgentValue::Client | ICalendarScheduleAgentValue::None + ) + ) + ) + }) +} + +fn itip_unreachable_entries( + ical: &ICalendar, + account_emails: &[String], +) -> Option> { + let (comp_id, entry_id, entry) = ical + .components + .iter() + .enumerate() + .filter(|(_, comp)| comp.component_type.is_scheduling_object()) + .find_map(|(comp_id, comp)| { + comp.entries + .iter() + .enumerate() + .find(|(_, entry)| entry.name == ICalendarProperty::Organizer) + .map(|(entry_id, entry)| (comp_id, entry_id, entry)) + })?; + + if !itip_is_server_scheduling(entry) { + return None; + } + + match Email::new(entry.values.first()?.as_text()?, account_emails) { + Some(email) if !email.is_local => return None, + Some(_) => {} + None => return Some(vec![(comp_id, entry_id)]), + } + + let mut unreachable = Vec::new(); + + for (comp_id, comp) in ical.components.iter().enumerate() { + if !comp.component_type.is_scheduling_object() { + continue; + } + + for (entry_id, entry) in comp.entries.iter().enumerate() { + if entry.name != ICalendarProperty::Attendee || !itip_is_server_scheduling(entry) { + continue; + } + + let rsvp = !entry.params.iter().any(|param| { + matches!( + (¶m.name, ¶m.value), + ( + ICalendarParameterName::Rsvp, + ICalendarParameterValue::Bool(false) + ) + ) + }); + + if rsvp + && entry + .values + .first() + .and_then(|value| value.as_text()) + .is_some_and(|value| Email::new(value, account_emails).is_none()) + { + unreachable.push((comp_id, entry_id)); + } + } + } + + Some(unreachable) +} + +pub fn itip_unreachable_recipient<'x>( + ical: &'x ICalendar, + account_emails: &[String], +) -> Option<&'x str> { + itip_unreachable_entries(ical, account_emails)? + .first() + .and_then(|(comp_id, entry_id)| { + ical.components[*comp_id].entries[*entry_id] + .values + .first() + .and_then(|value| value.as_text()) + }) +} + +pub fn itip_set_unreachable_status(ical: &mut ICalendar, account_emails: &[String]) { + let Some(unreachable) = itip_unreachable_entries(ical, account_emails) else { + return; + }; + + for (comp_id, comp) in ical.components.iter_mut().enumerate() { + if !comp.component_type.is_scheduling_object() { + continue; + } + + for (entry_id, entry) in comp.entries.iter_mut().enumerate() { + if !matches!( + entry.name, + ICalendarProperty::Organizer | ICalendarProperty::Attendee + ) { + continue; + } + + entry.params.retain(|param| { + param.name != ICalendarParameterName::ScheduleStatus + || param.value.as_text() != Some(ITIP_STATUS_INVALID_USER) + }); + + if unreachable.contains(&(comp_id, entry_id)) { + entry.params.push(ICalendarParameter::schedule_status( + ITIP_STATUS_INVALID_USER.to_string(), + )); + } + } + } +} + +pub(crate) enum ItipExportAs<'x> { + Organizer(&'x ICalendarParticipationStatus), + Attendee(Vec), +} + +pub(crate) fn itip_export_component( + component: &ICalendarComponent, + uid: &str, + dt_stamp: &PartialDateTime, + sequence: i64, + export_as: ItipExportAs<'_>, +) -> ICalendarComponent { + let is_todo = component.component_type == ICalendarComponentType::VTodo; + let mut comp = ICalendarComponent { + component_type: component.component_type.clone(), + entries: Vec::with_capacity(component.entries.len() + 1), + component_ids: Default::default(), + }; + + comp.add_dtstamp(dt_stamp.clone()); + comp.add_sequence(sequence); + comp.add_uid(uid); + + for (entry_id, entry) in component.entries.iter().enumerate() { + match (&entry.name, &export_as) { + ( + ICalendarProperty::Organizer | ICalendarProperty::Attendee, + ItipExportAs::Organizer(partstat), + ) => { + let mut new_entry = ICalendarEntry { + name: entry.name.clone(), + params: Vec::with_capacity(entry.params.len()), + values: entry.values.clone(), + }; + let mut has_partstat = false; + let mut rsvp = true; + + for entry in &entry.params { + match &entry.name { + ICalendarParameterName::ScheduleStatus + | ICalendarParameterName::ScheduleAgent + | ICalendarParameterName::ScheduleForceSend => {} + _ => { + match &entry.name { + ICalendarParameterName::Rsvp => { + rsvp = !matches!( + entry.value, + ICalendarParameterValue::Bool(false) + ); + } + ICalendarParameterName::Partstat => { + has_partstat = true; + } + _ => {} + } + + new_entry.params.push(entry.clone()) + } + } + } + + if !has_partstat && rsvp && entry.name == ICalendarProperty::Attendee { + new_entry + .params + .push(ICalendarParameter::partstat((*partstat).clone())); + } + + comp.entries.push(new_entry); + } + ( + ICalendarProperty::Organizer | ICalendarProperty::Attendee, + ItipExportAs::Attendee(attendee_entry_ids), + ) if attendee_entry_ids.contains(&(entry_id as u16)) + || entry.name == ICalendarProperty::Organizer => + { + comp.entries.push(ICalendarEntry { + name: entry.name.clone(), + params: entry + .params + .iter() + .filter(|param| { + !matches!( + ¶m.name, + ICalendarParameterName::ScheduleStatus + | ICalendarParameterName::ScheduleAgent + | ICalendarParameterName::ScheduleForceSend + ) + }) + .cloned() + .collect(), + values: entry.values.clone(), + }); + } + ( + ICalendarProperty::RequestStatus + | ICalendarProperty::Dtstamp + | ICalendarProperty::Sequence + | ICalendarProperty::Uid, + _, + ) => {} + (_, ItipExportAs::Organizer(_)) + | ( + ICalendarProperty::RecurrenceId + | ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Description + | ICalendarProperty::Summary, + _, + ) => { + comp.entries.push(entry.clone()); + } + ( + ICalendarProperty::Status + | ICalendarProperty::PercentComplete + | ICalendarProperty::Completed, + _, + ) if is_todo => { + comp.entries.push(entry.clone()); + } + _ => {} + } + } + + if matches!(export_as, ItipExportAs::Attendee(_)) { + comp.entries.push(ICalendarEntry { + name: ICalendarProperty::RequestStatus, + params: vec![], + values: vec![ + ICalendarValue::Text("2.0".to_string()), + ICalendarValue::Text("Success".to_string()), + ], + }); + } + + comp +} + +pub(crate) fn itip_finalize(ical: &mut ICalendar, scheduling_object_ids: &[u16]) { + for comp in ical.components.iter_mut() { + if comp.component_type.is_scheduling_object() { + // Remove scheduling info from non-updated components + for entry in comp.entries.iter_mut() { + if matches!( + entry.name, + ICalendarProperty::Organizer | ICalendarProperty::Attendee + ) { + entry.params.retain(|param| { + !matches!(param.name, ICalendarParameterName::ScheduleForceSend) + }); + } + } + } + } + + for comp_id in scheduling_object_ids { + let comp = &mut ical.components[*comp_id as usize]; + let mut found_sequence = false; + for entry in &mut comp.entries { + if entry.name == ICalendarProperty::Sequence { + if let Some(ICalendarValue::Integer(seq)) = entry.values.first_mut() { + *seq += 1; + } else { + entry.values = vec![ICalendarValue::Integer(1)]; + } + found_sequence = true; + break; + } + } + + if !found_sequence { + comp.add_sequence(1); + } + } +} + +pub(crate) fn itip_add_tz(message: &mut ICalendar, ical: &ICalendar) { + let mut has_timezones = false; + + if message.components.iter().any(|c| { + has_timezones = has_timezones || c.component_type == ICalendarComponentType::VTimezone; + + !has_timezones + && c.entries.iter().any(|e| { + e.params + .iter() + .any(|p| matches!(p.name, ICalendarParameterName::Tzid)) + }) + }) && !has_timezones + { + message.copy_timezones(ical); + } + + message.add_missing_timezones(); +} + +#[inline] +pub(crate) fn can_attendee_modify_property( + component_type: &ICalendarComponentType, + property: &ICalendarProperty, +) -> bool { + match component_type { + ICalendarComponentType::VEvent | ICalendarComponentType::VJournal => { + matches!( + property, + ICalendarProperty::Exdate + | ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Comment + ) + } + ICalendarComponentType::VTodo => matches!( + property, + ICalendarProperty::Exdate + | ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Status + | ICalendarProperty::PercentComplete + | ICalendarProperty::Completed + | ICalendarProperty::Comment + ), + _ => false, + } +} + +impl ItipMessages { + pub fn new(messages: Vec>) -> Self { + ItipMessages { + messages: messages + .into_iter() + .map(|m| TaskCalendarItipContents { + from: m.from, + i_calendar_data: m.message.to_string(), + is_from_organizer: m.from_organizer, + summary: serde_json::to_string(&m.summary).unwrap_or_default(), + to: m.to.into(), + }) + .collect(), + } + } + + pub fn queue(self, batch: &mut BatchBuilder) -> trc::Result<()> { + batch.schedule_task(Task::CalendarItipMessage(TaskCalendarItipMessage { + account_id: batch.last_account_id().unwrap().into(), + document_id: batch.last_document_id().unwrap().into(), + messages: self.messages.into(), + status: TaskStatus::now(), + })); + + Ok(()) + } +} + +impl From> for ItipMessage { + fn from(message: ItipMessage) -> Self { + ItipMessage { + from: message.from, + from_organizer: message.from_organizer, + to: message.to, + summary: message.summary, + message: message.message.to_string(), + } + } +} + +impl ItipSummary { + pub fn method(&self) -> &str { + match self { + ItipSummary::Invite(_) => ICalendarMethod::Request.as_str(), + ItipSummary::Update { method, .. } => method.as_str(), + ItipSummary::Cancel(_) => ICalendarMethod::Cancel.as_str(), + ItipSummary::Rsvp { .. } => ICalendarMethod::Reply.as_str(), + } + } +} diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs new file mode 100644 index 0000000..cdc7cb8 --- /dev/null +++ b/crates/groupware/src/scheduling/mod.rs @@ -0,0 +1,449 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{decode_mailto_address, extract_addr_spec}; +use ahash::{AHashMap, AHashSet}; +use calcard::{ + common::{IanaString, PartialDateTime}, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarDuration, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParticipationRole, ICalendarParticipationStatus, + ICalendarPeriod, ICalendarProperty, ICalendarRecurrenceRule, + ICalendarScheduleForceSendValue, ICalendarStatus, ICalendarUserTypes, ICalendarValue, Uri, + }, +}; +use indexmap::IndexSet; +use registry::schema::structs::TaskCalendarItipContents; +use std::{fmt::Display, hash::Hash}; +use utils::sanitize_email; + +pub mod attendee; +pub mod event_cancel; +pub mod event_create; +pub mod event_update; +pub mod format; +pub mod inbound; +pub mod itip; +pub mod organizer; +pub mod snapshot; + +#[derive(Debug)] +pub struct ItipSnapshots<'x> { + pub organizer: Organizer<'x>, + pub uid: &'x str, + pub components: AHashMap>, +} + +#[derive(Debug)] +pub struct ItipSnapshot<'x> { + pub comp_id: u16, + pub comp: &'x ICalendarComponent, + pub attendees: AHashSet>, + pub dtstamp: Option<&'x PartialDateTime>, + pub entries: ItipEntries<'x>, + pub sequence: Option, + pub request_status: Vec<&'x str>, +} + +pub type ItipEntries<'x> = IndexSet, ahash::RandomState>; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct ItipEntry<'x> { + pub name: &'x ICalendarProperty, + pub value: ItipEntryValue<'x>, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum ItipEntryValue<'x> { + DateTime(ItipDateTime<'x>), + Period(&'x ICalendarPeriod), + Duration(&'x ICalendarDuration), + Status(&'x ICalendarStatus), + RRule(&'x ICalendarRecurrenceRule), + Text(&'x str), + Integer(i64), +} + +#[derive(Debug)] +pub struct ItipDateTime<'x> { + pub date: &'x PartialDateTime, + pub tz_id: Option<&'x str>, + pub tz_code: u16, + pub timestamp: i64, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InstanceId { + Main, + Recurrence(RecurrenceId), +} + +#[derive(Debug, PartialOrd, Ord)] +pub struct RecurrenceId { + pub entry_id: u16, + pub date: i64, + pub this_and_future: bool, +} + +#[derive(Debug)] +pub struct Attendee<'x> { + pub entry_id: u16, + pub email: Email, + pub name: Option<&'x str>, + pub part_stat: Option<&'x ICalendarParticipationStatus>, + pub delegated_from: Vec, + pub delegated_to: Vec, + pub role: Option<&'x ICalendarParticipationRole>, + pub cu_type: Option<&'x ICalendarUserTypes>, + pub sent_by: Option, + pub rsvp: Option, + pub is_server_scheduling: bool, + pub force_send: Option<&'x ICalendarScheduleForceSendValue>, +} + +#[derive(Debug)] +pub struct Organizer<'x> { + pub entry_id: u16, + pub email: Email, + pub name: Option<&'x str>, + pub is_server_scheduling: bool, + pub force_send: Option<&'x ICalendarScheduleForceSendValue>, +} + +#[derive(Debug)] +pub struct Email { + pub email: String, + pub is_local: bool, +} + +#[derive(Debug)] +pub enum ItipError { + NoSchedulingInfo, + OtherSchedulingAgent, + NotOrganizer, + NotOrganizerNorAttendee, + NothingToSend, + MissingUid, + MultipleUid, + MultipleOrganizer, + MultipleObjectTypes, + MultipleObjectInstances, + CannotModifyProperty(ICalendarProperty), + CannotModifyInstance, + CannotModifyAddress, + OrganizerMismatch, + MissingMethod, + InvalidComponentType, + OutOfSequence, + OrganizerIsLocalAddress, + SenderIsNotOrganizerNorAttendee, + SenderIsNotParticipant(String), + UnknownParticipant(String), + UnsupportedMethod(ICalendarMethod), + ICalendarParseError, + EventNotFound, + EventTooLarge, + QuotaExceeded, + NoDefaultCalendar, + AutoAddDisabled, +} + +#[derive(Debug)] +pub struct ItipMessage { + pub from: String, + pub from_organizer: bool, + pub to: Vec, + pub summary: ItipSummary, + pub message: T, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum ItipSummary { + Invite(Vec), + Update { + method: ICalendarMethod, + current: Vec, + previous: Vec, + }, + Cancel(Vec), + Rsvp { + part_stat: ICalendarParticipationStatus, + current: Vec, + }, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ItipField { + pub name: ICalendarProperty, + pub value: ItipValue, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum ItipValue { + Text(String), + Time(ItipTime), + Rrule(Box), + Participants(Vec), +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ItipTime { + pub start: i64, + pub tz_id: u16, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ItipParticipant { + pub email: String, + pub name: Option, + pub is_organizer: bool, +} + +pub struct ItipMessages { + pub messages: Vec, +} + +impl Attendee<'_> { + pub fn send_invite_messages(&self) -> bool { + !self.email.is_local + && self.is_server_scheduling + && self.rsvp.is_none_or(|rsvp| rsvp) + && (self.force_send.is_some() + || self.part_stat.is_none_or(|part_stat| { + part_stat == &ICalendarParticipationStatus::NeedsAction + })) + } + + pub fn send_update_messages(&self) -> bool { + !self.email.is_local + && self.is_server_scheduling + && self.rsvp.is_none_or(|rsvp| rsvp) + && (self.force_send.is_some() + || self + .part_stat + .is_none_or(|part_stat| part_stat != &ICalendarParticipationStatus::Declined)) + } + + pub fn is_delegated_from(&self, attendee: &Attendee<'_>) -> bool { + self.delegated_from + .iter() + .any(|d| d.email == attendee.email.email) + } + + pub fn is_delegated_to(&self, attendee: &Attendee<'_>) -> bool { + self.delegated_to + .iter() + .any(|d| d.email == attendee.email.email) + } +} + +impl Email { + pub fn new(email: &str, local_addresses: &[String]) -> Option { + let decoded = decode_mailto_address(email.trim()); + let email = sanitize_email(decoded.as_ref()) + .or_else(|| extract_addr_spec(decoded.as_ref()).and_then(sanitize_email))?; + let is_local = local_addresses.contains(&email); + Some(Email { email, is_local }) + } + + pub fn from_uri(uri: &Uri, local_addresses: &[String]) -> Option { + if let Uri::Location(uri) = uri { + Email::new(uri.as_str(), local_addresses) + } else { + None + } + } +} + +pub fn ical_size(ical: &ICalendar) -> usize { + struct SizeWriter(usize); + + impl std::fmt::Write for SizeWriter { + fn write_str(&mut self, text: &str) -> std::fmt::Result { + self.0 += text.len(); + Ok(()) + } + } + + let mut writer = SizeWriter(0); + let _ = ical.write_to(&mut writer); + writer.0 +} + +impl PartialEq for Attendee<'_> { + fn eq(&self, other: &Self) -> bool { + self.email == other.email + && self.part_stat == other.part_stat + && self.delegated_from == other.delegated_from + && self.delegated_to == other.delegated_to + && self.role == other.role + && self.cu_type == other.cu_type + && self.sent_by == other.sent_by + } +} + +impl Eq for Attendee<'_> {} + +impl Hash for Attendee<'_> { + fn hash(&self, state: &mut H) { + self.email.hash(state); + self.part_stat.hash(state); + self.delegated_from.hash(state); + self.delegated_to.hash(state); + self.role.hash(state); + self.cu_type.hash(state); + self.sent_by.hash(state); + } +} + +impl Display for Email { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "mailto:{}", self.email) + } +} + +impl Hash for Email { + fn hash(&self, state: &mut H) { + self.email.hash(state); + } +} + +impl PartialEq for Email { + fn eq(&self, other: &Self) -> bool { + self.email == other.email + } +} + +impl Eq for Email {} + +impl PartialEq for RecurrenceId { + fn eq(&self, other: &Self) -> bool { + self.date == other.date && self.this_and_future == other.this_and_future + } +} + +impl Eq for RecurrenceId {} + +impl Hash for RecurrenceId { + fn hash(&self, state: &mut H) { + self.date.hash(state); + self.this_and_future.hash(state); + } +} + +impl PartialEq for ItipDateTime<'_> { + fn eq(&self, other: &Self) -> bool { + self.timestamp == other.timestamp + } +} +impl Eq for ItipDateTime<'_> {} + +impl Hash for ItipDateTime<'_> { + fn hash(&self, state: &mut H) { + self.timestamp.hash(state); + } +} + +impl ItipDateTime<'_> { + pub fn to_entry(&self, name: ICalendarProperty) -> ICalendarEntry { + ICalendarEntry { + name, + params: self + .tz_id + .map(|tz_id| vec![ICalendarParameter::tzid(tz_id.to_string())]) + .unwrap_or_default(), + values: vec![ICalendarValue::PartialDateTime(Box::new(self.date.clone()))], + } + } +} + +impl ItipError { + pub fn is_jmap_error(&self) -> bool { + matches!( + self, + ItipError::MultipleOrganizer + | ItipError::OrganizerIsLocalAddress + | ItipError::SenderIsNotParticipant(_) + | ItipError::OrganizerMismatch + | ItipError::CannotModifyProperty(_) + | ItipError::CannotModifyInstance + | ItipError::CannotModifyAddress + //| ItipError::MissingUid + | ItipError::MultipleUid + | ItipError::MultipleObjectTypes + | ItipError::MultipleObjectInstances + | ItipError::MissingMethod + | ItipError::InvalidComponentType + | ItipError::OutOfSequence + | ItipError::UnknownParticipant(_) + | ItipError::UnsupportedMethod(_) + ) + } +} + +impl Display for ItipError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ItipError::NoSchedulingInfo => write!(f, "No scheduling information found"), + ItipError::OtherSchedulingAgent => write!(f, "Other scheduling agent"), + ItipError::NotOrganizer => write!(f, "Not the organizer of the event"), + ItipError::NotOrganizerNorAttendee => write!(f, "Not an organizer or attendee"), + ItipError::NothingToSend => write!(f, "No iTIP messages to send"), + ItipError::MissingUid => write!(f, "Missing UID in iCalendar object"), + ItipError::MultipleUid => write!(f, "Multiple UIDs found in iCalendar object"), + ItipError::MultipleOrganizer => { + write!(f, "Multiple organizers found in iCalendar object") + } + ItipError::MultipleObjectTypes => { + write!(f, "Multiple object types found in iCalendar object") + } + ItipError::MultipleObjectInstances => { + write!(f, "Multiple object instances found in iCalendar object") + } + ItipError::CannotModifyProperty(prop) => { + write!(f, "Cannot modify property {}", prop.as_str()) + } + ItipError::CannotModifyInstance => write!(f, "Cannot modify instance of the event"), + ItipError::CannotModifyAddress => write!(f, "Cannot modify address of the event"), + ItipError::OrganizerMismatch => write!(f, "Organizer mismatch in iCalendar object"), + ItipError::MissingMethod => write!(f, "Missing method in the iTIP message"), + ItipError::InvalidComponentType => { + write!(f, "Invalid component type in iCalendar object") + } + ItipError::OutOfSequence => write!(f, "Old sequence number found"), + ItipError::OrganizerIsLocalAddress => { + write!( + f, + "Organizer matches one of the recipient's account addresses" + ) + } + ItipError::SenderIsNotParticipant(participant) => { + write!(f, "Sender {participant:?} is not a participant") + } + ItipError::SenderIsNotOrganizerNorAttendee => { + write!(f, "Sender is neither organizer nor attendee") + } + ItipError::UnknownParticipant(participant) => { + write!(f, "Unknown participant: {}", participant) + } + ItipError::UnsupportedMethod(method) => { + write!(f, "Unsupported method: {}", method.as_str()) + } + ItipError::ICalendarParseError => write!(f, "Failed to parse iCalendar object"), + ItipError::EventNotFound => write!(f, "Event found in index but not in database"), + ItipError::EventTooLarge => write!( + f, + "Applying the iTIP message would exceed the maximum event size" + ), + ItipError::QuotaExceeded => write!(f, "Quota exceeded"), + ItipError::NoDefaultCalendar => write!(f, "No default calendar found for the account"), + ItipError::AutoAddDisabled => { + write!(f, "Auto-adding events is disabled for this account") + } + } + } +} diff --git a/crates/groupware/src/scheduling/organizer.rs b/crates/groupware/src/scheduling/organizer.rs new file mode 100644 index 0000000..265b17e --- /dev/null +++ b/crates/groupware/src/scheduling/organizer.rs @@ -0,0 +1,403 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + InstanceId, ItipError, ItipMessage, ItipSnapshots, ItipSummary, + event_cancel::build_cancel_component, + itip::{ItipExportAs, itip_add_tz, itip_build_envelope, itip_export_component}, +}; +use ahash::{AHashMap, AHashSet}; +use calcard::{ + common::PartialDateTime, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, + ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, + }, +}; +use std::collections::hash_map::Entry; + +pub(crate) fn organizer_handle_update( + old_ical: &ICalendar, + new_ical: &ICalendar, + old_itip: ItipSnapshots<'_>, + new_itip: ItipSnapshots<'_>, + increment_sequences: &mut Vec, +) -> Result>, ItipError> { + let mut changed_instances: Vec<(&InstanceId, &str, &ICalendarMethod)> = Vec::new(); + let mut increment_sequence = false; + let mut changed_properties = AHashSet::new(); + + for (instance_id, instance) in &new_itip.components { + if let Some(old_instance) = old_itip.components.get(instance_id) { + let changed_entries = instance.entries != old_instance.entries; + let changed_attendees = instance.attendees != old_instance.attendees; + + if changed_entries || changed_attendees { + if changed_entries { + for entry in instance.entries.symmetric_difference(&old_instance.entries) { + increment_sequence = increment_sequence + || matches!( + entry.name, + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Status + | ICalendarProperty::Location + ); + changed_properties.insert(entry.name); + } + } + + if changed_attendees { + changed_instances.extend( + old_instance + .external_attendees() + .filter(|attendee| attendee.send_update_messages()) + .map(|attendee| attendee.email.email.as_str()) + .collect::>() + .difference( + &instance + .external_attendees() + .map(|attendee| attendee.email.email.as_str()) + .collect::>(), + ) + .map(|attendee| (instance_id, *attendee, &ICalendarMethod::Cancel)), + ); + changed_properties.insert(&ICalendarProperty::Attendee); + increment_sequence = true; + } + + changed_instances.extend(instance.attendees.iter().filter_map(|attendee| { + if attendee.send_update_messages() { + Some(( + instance_id, + attendee.email.email.as_str(), + &ICalendarMethod::Request, + )) + } else { + None + } + })); + } + } else if instance_id != &InstanceId::Main { + changed_properties.insert(&ICalendarProperty::Exdate); + let method = if matches!(instance.comp.status(), Some(ICalendarStatus::Cancelled)) { + &ICalendarMethod::Cancel + } else { + &ICalendarMethod::Request + }; + + changed_instances.extend(instance.attendees.iter().filter_map(|attendee| { + if attendee.send_invite_messages() { + Some((instance_id, attendee.email.email.as_str(), method)) + } else { + None + } + })); + + increment_sequence = true; + } else { + return Err(ItipError::CannotModifyInstance); + } + } + + for (instance_id, old_instance) in &old_itip.components { + if !new_itip.components.contains_key(instance_id) { + if instance_id != &InstanceId::Main { + changed_instances.extend(old_instance.attendees.iter().filter_map(|attendee| { + if attendee.send_update_messages() { + Some(( + instance_id, + attendee.email.email.as_str(), + &ICalendarMethod::Cancel, + )) + } else { + None + } + })); + changed_properties.insert(&ICalendarProperty::Exdate); + increment_sequence = true; + } else { + return Err(ItipError::CannotModifyInstance); + } + } + } + + if changed_instances.is_empty() { + return Err(ItipError::NothingToSend); + } + + // Remove partial notifications for attendees that receive a full update for the main instance + // or, that will receive both add and remove messages + let mut send_full_update: AHashSet<&str> = AHashSet::new(); + let mut send_partial_update: AHashMap<&str, AHashMap<&ICalendarMethod, Vec<&InstanceId>>> = + AHashMap::new(); + for (instance_id, email, method) in &changed_instances { + if *instance_id == &InstanceId::Main && *method == &ICalendarMethod::Request { + send_full_update.insert(*email); + send_partial_update.remove(email); + } else if !send_full_update.contains(email) { + match send_partial_update.entry(email) { + Entry::Occupied(mut entry) => { + let entry = entry.get_mut(); + let is_empty = entry.is_empty(); + match entry.entry(method) { + Entry::Occupied(mut method_entry) => { + method_entry.get_mut().push(*instance_id); + } + Entry::Vacant(method_entry) if is_empty => { + method_entry.insert(vec![*instance_id]); + } + _ => { + // Switch to full update for this participant + send_full_update.insert(*email); + send_partial_update.remove(email); + } + } + } + Entry::Vacant(entry) => { + entry.insert(AHashMap::from_iter([(*method, vec![*instance_id])])); + } + } + } + } + + // Build summary of changed properties + let new_summary = new_itip + .main_instance_or_default() + .build_summary(Some(&new_itip.organizer), &[]); + let old_summary = old_itip + .main_instance_or_default() + .build_summary(Some(&old_itip.organizer), &new_summary); + + // Prepare full updates + let mut messages = Vec::new(); + if !send_full_update.is_empty() { + match organizer_request_full( + new_ical, + &new_itip, + increment_sequence.then_some(increment_sequences), + false, + ) { + Ok(messages_) => { + for mut message in messages_ { + message.summary = ItipSummary::Update { + method: ICalendarMethod::Request, + current: new_summary.clone(), + previous: old_summary.clone(), + }; + messages.push(message); + } + } + Err(err) => { + if send_partial_update.is_empty() { + return Err(err); + } + } + } + } + + // Prepare partial updates + if !send_partial_update.is_empty() { + // Group updates by email and method + let mut updates: AHashMap<(&ICalendarMethod, Vec<&InstanceId>), Vec<&str>> = + AHashMap::new(); + for (email, partial_updates) in send_partial_update { + for (method, mut instances) in partial_updates { + instances.sort_unstable(); + instances.dedup(); + updates.entry((method, instances)).or_default().push(email); + } + } + + let dt_stamp = PartialDateTime::now(); + for ((method, instances), emails) in updates { + let (mut ical, mut itip, is_cancel) = if matches!(method, ICalendarMethod::Cancel) { + (old_ical, &old_itip, true) + } else { + (new_ical, &new_itip, false) + }; + + // Prepare iTIP message + let mut message = ICalendar { + components: Vec::with_capacity(instances.len() + 1), + }; + message.components.push(itip_build_envelope(method.clone())); + + let mut increment_sequences = Vec::new(); + + for instance_id in instances { + let comp = match itip.components.get(instance_id) { + Some(comp) => comp, + None => { + // New component added with CANCELLED status + ical = new_ical; + itip = &new_itip; + itip.components.get(instance_id).unwrap() + } + }; + // Prepare component for iTIP + let sequence = if increment_sequence { + comp.sequence.unwrap_or_default() + 1 + } else { + comp.sequence.unwrap_or_default() + }; + let orig_component = comp.comp; + let component = if !is_cancel { + if increment_sequence { + increment_sequences.push(comp.comp_id); + } + + // Export component with updated sequence and participation status + itip_export_component( + orig_component, + itip.uid, + &dt_stamp, + sequence, + ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction), + ) + } else { + build_cancel_component(orig_component, sequence, dt_stamp.clone(), &emails) + }; + + // Add component to message + let comp_id = message.components.len() as u32; + message.components.push(component); + message.components[0].component_ids.push(comp_id); + } + + // Add timezones + itip_add_tz(&mut message, ical); + + messages.push(ItipMessage { + from: itip.organizer.email.email.clone(), + from_organizer: true, + to: emails.into_iter().map(|e| e.to_string()).collect(), + summary: if method == &ICalendarMethod::Cancel { + ItipSummary::Cancel( + new_summary + .iter() + .chain(old_summary.iter()) + .map(|summary| (&summary.name, summary)) + .collect::>() + .into_values() + .cloned() + .collect(), + ) + } else { + ItipSummary::Update { + method: method.clone(), + current: new_summary.clone(), + previous: old_summary.clone(), + } + }, + message, + }); + } + } + + Ok(messages) +} + +pub(crate) fn organizer_request_full( + ical: &ICalendar, + itip: &ItipSnapshots<'_>, + mut increment_sequence: Option<&mut Vec>, + is_first_request: bool, +) -> Result>, ItipError> { + // Prepare iTIP message + let dt_stamp = PartialDateTime::now(); + let mut message = ICalendar { + components: vec![ICalendarComponent::default(); ical.components.len()], + }; + message.components[0] = itip_build_envelope(ICalendarMethod::Request); + + let mut recipients = AHashSet::new(); + let mut copy_components = AHashSet::new(); + + for comp in itip.components.values() { + // Skip private components + if comp.attendees.is_empty() { + continue; + } + + // Prepare component for iTIP + let sequence = if let Some(increment_sequence) = &mut increment_sequence { + increment_sequence.push(comp.comp_id); + comp.sequence.unwrap_or_default() + 1 + } else { + comp.sequence.unwrap_or_default() + }; + let orig_component = &ical.components[comp.comp_id as usize]; + let mut component = itip_export_component( + orig_component, + itip.uid, + &dt_stamp, + sequence, + ItipExportAs::Organizer(&ICalendarParticipationStatus::NeedsAction), + ); + + // Add VALARM sub-components + if is_first_request { + for sub_comp_id in &orig_component.component_ids { + if matches!( + ical.components[*sub_comp_id as usize].component_type, + ICalendarComponentType::VAlarm + ) { + copy_components.insert(*sub_comp_id); + component.component_ids.push(*sub_comp_id); + } + } + } + + // Add component to message + message.components[comp.comp_id as usize] = component; + message.components[0] + .component_ids + .push(comp.comp_id as u32); + + // Add attendees + for attendee in &comp.attendees { + if (is_first_request && attendee.send_invite_messages()) + || (!is_first_request && attendee.send_update_messages()) + { + recipients.insert(&attendee.email.email); + } + } + } + + // Copy timezones and alarms + for (comp_id, comp) in ical.components.iter().enumerate() { + if matches!(comp.component_type, ICalendarComponentType::VTimezone) { + copy_components.extend(comp.component_ids.iter().copied()); + message.components[0].component_ids.push(comp_id as u32); + } else if !copy_components.contains(&(comp_id as u32)) { + continue; + } + message.components[comp_id] = comp.clone(); + } + message.components[0].component_ids.sort_unstable(); + message.add_missing_timezones(); + + if !recipients.is_empty() { + Ok(vec![ItipMessage { + from: itip.organizer.email.email.clone(), + from_organizer: true, + to: recipients.into_iter().map(|e| e.to_string()).collect(), + summary: ItipSummary::Invite( + itip.main_instance_or_default() + .build_summary(Some(&itip.organizer), &[]), + ), + message, + }]) + } else { + Err(ItipError::NothingToSend) + } +} diff --git a/crates/groupware/src/scheduling/snapshot.rs b/crates/groupware/src/scheduling/snapshot.rs new file mode 100644 index 0000000..c262431 --- /dev/null +++ b/crates/groupware/src/scheduling/snapshot.rs @@ -0,0 +1,489 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::scheduling::{ + Attendee, Email, InstanceId, ItipDateTime, ItipEntry, ItipEntryValue, ItipError, ItipField, + ItipParticipant, ItipSnapshot, ItipSnapshots, ItipTime, ItipValue, Organizer, RecurrenceId, +}; +use ahash::AHashMap; +use calcard::icalendar::{ + ICalendar, ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, + ICalendarScheduleAgentValue, ICalendarValue, Uri, +}; + +pub fn itip_snapshot<'x, 'y>( + ical: &'x ICalendar, + account_emails: &'y [String], + force_add_client_scheduling: bool, +) -> Result, ItipError> { + if !ical.components.iter().any(|comp| { + comp.component_type.is_scheduling_object() + && comp + .entries + .iter() + .any(|e| matches!(e.name, ICalendarProperty::Organizer)) + }) { + return Err(ItipError::NoSchedulingInfo); + } + + let mut organizer: Option> = None; + let mut uid: Option<&'x str> = None; + let mut components = AHashMap::new(); + let mut expect_object_type = None; + let mut has_local_emails = false; + let mut tz_resolver = None; + + for (comp_id, comp) in ical.components.iter().enumerate() { + if comp.component_type.is_scheduling_object() { + match expect_object_type { + Some(expected) if expected != &comp.component_type => { + return Err(ItipError::MultipleObjectTypes); + } + None => { + expect_object_type = Some(&comp.component_type); + } + _ => {} + } + + let mut sched_comp = ItipSnapshot { + comp_id: comp_id as u16, + comp, + attendees: Default::default(), + dtstamp: Default::default(), + entries: Default::default(), + sequence: Default::default(), + request_status: Default::default(), + }; + let mut instance_id = InstanceId::Main; + + for (entry_id, entry) in comp.entries.iter().enumerate() { + match &entry.name { + ICalendarProperty::Organizer => { + if let Some(email) = entry + .values + .first() + .and_then(|v| v.as_text()) + .and_then(|v| Email::new(v, account_emails)) + { + let mut part = Organizer { + entry_id: entry_id as u16, + email, + is_server_scheduling: true, + name: None, + force_send: None, + }; + has_local_emails |= part.email.is_local; + + for param in &entry.params { + match (¶m.name, ¶m.value) { + ( + ICalendarParameterName::ScheduleAgent, + ICalendarParameterValue::ScheduleAgent( + ICalendarScheduleAgentValue::Client + | ICalendarScheduleAgentValue::None, + ), + ) => { + part.is_server_scheduling = false; + } + ( + ICalendarParameterName::ScheduleForceSend, + ICalendarParameterValue::ScheduleForceSend(force_send), + ) => { + part.force_send = Some(force_send); + } + ( + ICalendarParameterName::Cn, + ICalendarParameterValue::Text(name), + ) => { + part.name = Some(name.as_str()); + } + _ => {} + } + } + + if !part.is_server_scheduling && !force_add_client_scheduling { + return Err(ItipError::OtherSchedulingAgent); + } + + match organizer { + Some(existing_organizer) + if existing_organizer.email.email != part.email.email => + { + return Err(ItipError::MultipleOrganizer); + } + None => { + organizer = Some(part); + } + _ => {} + } + } + } + ICalendarProperty::Attendee => { + if let Some(email) = entry + .values + .first() + .and_then(|v| v.as_text()) + .and_then(|v| Email::new(v, account_emails)) + { + let mut part = Attendee { + entry_id: entry_id as u16, + email, + name: None, + rsvp: None, + is_server_scheduling: true, + force_send: None, + part_stat: None, + delegated_from: vec![], + delegated_to: vec![], + cu_type: None, + role: None, + sent_by: None, + }; + + for param in &entry.params { + match (¶m.name, ¶m.value) { + ( + ICalendarParameterName::ScheduleAgent, + ICalendarParameterValue::ScheduleAgent(agent), + ) => { + part.is_server_scheduling = + agent == &ICalendarScheduleAgentValue::Server; + } + ( + ICalendarParameterName::Rsvp, + ICalendarParameterValue::Bool(rsvp), + ) => { + part.rsvp = Some(*rsvp); + } + ( + ICalendarParameterName::ScheduleForceSend, + ICalendarParameterValue::ScheduleForceSend(force_send), + ) => { + part.force_send = Some(force_send); + } + ( + ICalendarParameterName::Partstat, + ICalendarParameterValue::Partstat(value), + ) => { + part.part_stat = Some(value); + } + ( + ICalendarParameterName::Cutype, + ICalendarParameterValue::Cutype(value), + ) => { + part.cu_type = Some(value); + } + ( + ICalendarParameterName::DelegatedFrom, + ICalendarParameterValue::Uri(uri), + ) => { + if let Some(uri) = Email::from_uri(uri, account_emails) { + part.delegated_from.push(uri); + } + } + ( + ICalendarParameterName::DelegatedTo, + ICalendarParameterValue::Uri(uri), + ) => { + if let Some(uri) = Email::from_uri(uri, account_emails) { + part.delegated_to.push(uri); + } + } + ( + ICalendarParameterName::Role, + ICalendarParameterValue::Role(value), + ) => { + part.role = Some(value); + } + ( + ICalendarParameterName::SentBy, + ICalendarParameterValue::Uri(value), + ) => { + part.sent_by = Email::from_uri(value, account_emails); + } + ( + ICalendarParameterName::Cn, + ICalendarParameterValue::Text(name), + ) => { + part.name = Some(name.as_str()); + } + _ => {} + } + } + + has_local_emails |= part.email.is_local + && (force_add_client_scheduling || part.is_server_scheduling); + + sched_comp.attendees.insert(part); + } + } + ICalendarProperty::Uid => { + if let Some(uid_) = entry + .values + .first() + .and_then(|v| v.as_text()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { + match uid { + Some(existing_uid) if existing_uid != uid_ => { + return Err(ItipError::MultipleUid); + } + None => { + uid = Some(uid_); + } + _ => {} + } + } + } + ICalendarProperty::Sequence => { + if let Some(sequence) = entry.values.first().and_then(|v| v.as_integer()) { + sched_comp.sequence = Some(sequence); + } + } + ICalendarProperty::RecurrenceId => { + if let Some(date) = + entry.values.first().and_then(|v| v.as_partial_date_time()) + { + let mut this_and_future = false; + let mut tz_id = None; + + for param in &entry.params { + match (¶m.name, ¶m.value) { + ( + ICalendarParameterName::Tzid, + ICalendarParameterValue::Text(id), + ) => { + tz_id = Some(id.as_str()); + } + (ICalendarParameterName::Range, _) => { + this_and_future = true; + } + _ => (), + } + } + + instance_id = InstanceId::Recurrence(RecurrenceId { + entry_id: entry_id as u16, + date: date + .to_date_time_with_tz( + tz_resolver + .get_or_insert_with(|| ical.build_tz_resolver()) + .resolve_or_default(tz_id), + ) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| date.to_timestamp().unwrap_or_default()), + this_and_future, + }); + } + } + ICalendarProperty::RequestStatus => { + if let Some(value) = entry.values.first().and_then(|v| v.as_text()) { + sched_comp.request_status.push(value); + } + } + ICalendarProperty::Dtstamp => { + sched_comp.dtstamp = + entry.values.first().and_then(|v| v.as_partial_date_time()); + } + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Status + | ICalendarProperty::Location + | ICalendarProperty::Conference + | ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Priority + | ICalendarProperty::PercentComplete + | ICalendarProperty::Completed => { + let tz_id = entry.tz_id(); + for value in &entry.values { + let value = match value { + ICalendarValue::Uri(Uri::Location(v)) => { + ItipEntryValue::Text(v.as_str()) + } + ICalendarValue::PartialDateTime(date) => { + let tz = tz_resolver + .get_or_insert_with(|| ical.build_tz_resolver()) + .resolve_or_default(tz_id); + ItipEntryValue::DateTime(ItipDateTime { + date: date.as_ref(), + tz_id, + tz_code: tz.as_id(), + timestamp: date + .to_date_time_with_tz(tz) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| { + date.to_timestamp().unwrap_or_default() + }), + }) + } + ICalendarValue::Duration(v) => ItipEntryValue::Duration(v), + ICalendarValue::RecurrenceRule(v) => ItipEntryValue::RRule(v), + ICalendarValue::Period(v) => ItipEntryValue::Period(v), + ICalendarValue::Integer(v) => ItipEntryValue::Integer(*v), + ICalendarValue::Text(v) => ItipEntryValue::Text(v.as_str()), + ICalendarValue::Status(v) => ItipEntryValue::Status(v), + _ => continue, + }; + sched_comp.entries.insert(ItipEntry { + name: &entry.name, + value, + }); + } + } + _ => {} + } + } + + if components.insert(instance_id, sched_comp).is_some() { + return Err(ItipError::MultipleObjectInstances); + } + } + } + + if has_local_emails { + Ok(ItipSnapshots { + organizer: organizer.ok_or(ItipError::NoSchedulingInfo)?, + uid: uid.ok_or(ItipError::MissingUid)?, + components, + }) + } else { + Err(ItipError::NotOrganizerNorAttendee) + } +} + +impl ItipSnapshots<'_> { + pub fn sender_is_organizer_or_attendee(&self, email: &str) -> bool { + self.organizer.email.email == email + || self.components.values().any(|snapshot| { + snapshot + .attendees + .iter() + .any(|attendee| attendee.email.email == email) + }) + } + + pub fn main_instance(&self) -> Option<&ItipSnapshot<'_>> { + self.components.get(&InstanceId::Main) + } + + pub fn main_instance_or_default(&self) -> &ItipSnapshot<'_> { + self.main_instance() + .unwrap_or_else(|| self.components.values().next().unwrap()) + } +} + +impl ItipSnapshot<'_> { + pub fn has_local_attendee(&self) -> bool { + self.attendees + .iter() + .any(|attendee| attendee.email.is_local) + } + + pub fn local_attendee(&self) -> Option<&Attendee<'_>> { + self.attendees + .iter() + .find(|attendee| attendee.email.is_local) + } + + pub fn external_attendees(&self) -> impl Iterator> + '_ { + self.attendees.iter().filter(|item| !item.email.is_local) + } + + pub fn attendee_by_email(&self, email: &str) -> Option<&Attendee<'_>> { + self.attendees + .iter() + .find(|attendee| attendee.email.email == email) + } + + pub fn build_summary( + &self, + include_guests: Option<&Organizer<'_>>, + skip_fields: &[ItipField], + ) -> Vec { + let mut fields = Vec::with_capacity(5); + + for entry in &self.entries { + if matches!( + entry.name, + ICalendarProperty::Summary + | ICalendarProperty::Description + | ICalendarProperty::Dtstart + | ICalendarProperty::Location + | ICalendarProperty::Conference + | ICalendarProperty::Rrule + ) { + let value = match &entry.value { + ItipEntryValue::DateTime(dt) => ItipValue::Time(ItipTime { + start: dt.timestamp, + tz_id: dt.tz_code, + }), + ItipEntryValue::RRule(rule) => ItipValue::Rrule(Box::new((*rule).clone())), + ItipEntryValue::Text(value) => ItipValue::Text(value.to_string()), + _ => continue, + }; + let field = ItipField { + name: entry.name.clone(), + value, + }; + + if !skip_fields.contains(&field) { + fields.push(field); + } + } + } + + if let Some(organizer) = include_guests { + let mut attendees = Vec::with_capacity(self.attendees.len()); + for attendee in &self.attendees { + if attendee.email.email != organizer.email.email { + attendees.push(ItipParticipant { + email: attendee.email.email.to_string(), + name: attendee.name.map(|n| n.to_string()), + is_organizer: false, + }); + } + } + attendees.push(ItipParticipant { + email: organizer.email.email.to_string(), + name: organizer.name.map(|n| n.to_string()), + is_organizer: true, + }); + attendees.sort_by(|a, b| { + if a.is_organizer && !b.is_organizer { + std::cmp::Ordering::Less + } else if !a.is_organizer && b.is_organizer { + std::cmp::Ordering::Greater + } else if let (Some(a_name), Some(b_name)) = (a.name.as_deref(), b.name.as_deref()) + { + match a_name.cmp(b_name) { + std::cmp::Ordering::Equal => a.email.cmp(&b.email), + ord => ord, + } + } else { + a.email.cmp(&b.email) + } + }); + + let field = ItipField { + name: ICalendarProperty::Attendee, + value: ItipValue::Participants(attendees), + }; + + if !skip_fields.contains(&field) { + fields.push(field); + } + } + + fields + } +} diff --git a/crates/http-proto/Cargo.toml b/crates/http-proto/Cargo.toml new file mode 100644 index 0000000..e0f575b --- /dev/null +++ b/crates/http-proto/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "http_proto" +version = "0.16.22" +edition = "2024" + +[dependencies] +common = { path = "../common" } +trc = { path = "../trc" } +registry = { path = "../registry" } +serde = { version = "1.0", features = ["derive"]} +serde_json = "1.0" +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +http-body-util = "0.1.5" +form_urlencoded = "1.2.2" +percent-encoding = "2.3.2" +compact_str = "0.10.0" + +[dev-dependencies] + +[features] +test_mode = [] +dev_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/http-proto/src/context.rs b/crates/http-proto/src/context.rs new file mode 100644 index 0000000..672ad3e --- /dev/null +++ b/crates/http-proto/src/context.rs @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{HttpContext, HttpRequest, HttpSessionData}; +use common::{ + Server, + expr::{functions::ResolveVariable, *}, +}; +use compact_str::{ToCompactString, format_compact}; +use hyper::StatusCode; +use registry::schema::enums::ExpressionVariable; + +impl<'x> HttpContext<'x> { + pub fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self { + Self { session, req } + } + + pub async fn has_endpoint_access(&self, server: &Server) -> StatusCode { + server + .eval_if( + &server.core.network.http.allowed_endpoint, + self, + self.session.session_id, + ) + .await + .unwrap_or(StatusCode::OK) + } +} + +impl ResolveVariable for HttpContext<'_> { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::RemoteIp => self.session.remote_ip.to_compact_string().into(), + ExpressionVariable::RemotePort => self.session.remote_port.into(), + ExpressionVariable::LocalIp => self.session.local_ip.to_compact_string().into(), + ExpressionVariable::LocalPort => self.session.local_port.into(), + ExpressionVariable::IsTls => self.session.is_tls.into(), + ExpressionVariable::Protocol => { + if self.session.is_tls { "https" } else { "http" }.into() + } + ExpressionVariable::Listener => self.session.instance.id.as_str().into(), + ExpressionVariable::Url => self.req.uri().to_compact_string().into(), + ExpressionVariable::Path => self.req.uri().path().into(), + ExpressionVariable::Method => self.req.method().as_str().into(), + ExpressionVariable::Headers => self + .req + .headers() + .iter() + .map(|(h, v)| { + Variable::String( + format_compact!("{}: {}", h.as_str(), v.to_str().unwrap_or_default()) + .into(), + ) + }) + .collect::>() + .into(), + _ => Variable::default(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} diff --git a/crates/http-proto/src/lib.rs b/crates/http-proto/src/lib.rs new file mode 100644 index 0000000..628b543 --- /dev/null +++ b/crates/http-proto/src/lib.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod context; +pub mod request; +pub mod response; + +pub use form_urlencoded; + +use common::network::ServerInstance; +use hyper::StatusCode; +use std::{net::IpAddr, sync::Arc}; + +pub type HttpRequest = hyper::Request; + +pub struct JsonResponse { + status: StatusCode, + inner: T, + no_cache: bool, +} + +pub struct HtmlResponse { + status: StatusCode, + body: String, +} + +pub enum HttpResponseBody { + Text(String), + Binary(Vec), + Stream(http_body_util::combinators::BoxBody), + WebsocketUpgrade(String), + Empty, +} + +pub struct HttpResponse { + status: StatusCode, + builder: hyper::http::response::Builder, + body: HttpResponseBody, +} + +pub struct HttpContext<'x> { + pub session: &'x HttpSessionData, + pub req: &'x HttpRequest, +} + +pub struct HttpSessionData { + pub instance: Arc, + pub local_ip: IpAddr, + pub local_port: u16, + pub remote_ip: IpAddr, + pub remote_port: u16, + pub is_tls: bool, + pub session_id: u64, +} + +pub struct DownloadResponse { + pub filename: String, + pub content_type: String, + pub blob: Vec, +} + +pub struct JsonProblemResponse(pub StatusCode); + +impl JsonResponse { + pub fn new(inner: T) -> Self { + JsonResponse { + inner, + status: StatusCode::OK, + no_cache: false, + } + } + + pub fn with_status(status: StatusCode, inner: T) -> Self { + JsonResponse { + inner, + status, + no_cache: false, + } + } + + pub fn no_cache(mut self) -> Self { + self.no_cache = true; + self + } +} + +impl HtmlResponse { + pub fn new(body: String) -> Self { + HtmlResponse { + body, + status: StatusCode::OK, + } + } + + pub fn with_status(status: StatusCode, body: String) -> Self { + HtmlResponse { body, status } + } +} + +pub trait ToHttpResponse { + fn into_http_response(self) -> HttpResponse; +} diff --git a/crates/http-proto/src/request.rs b/crates/http-proto/src/request.rs new file mode 100644 index 0000000..467dba3 --- /dev/null +++ b/crates/http-proto/src/request.rs @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use compact_str::ToCompactString; +use http_body_util::BodyExt; + +use crate::HttpRequest; + +#[inline] +pub fn decode_path_element(item: &str) -> Cow<'_, str> { + percent_encoding::percent_decode_str(item) + .decode_utf8() + .unwrap_or_else(|_| item.into()) +} + +pub async fn fetch_body( + req: &mut HttpRequest, + max_size: usize, + session_id: u64, +) -> Option> { + let mut bytes = Vec::with_capacity(1024); + while let Some(Ok(frame)) = req.frame().await { + if let Some(data) = frame.data_ref() { + if bytes.len() + data.len() <= max_size || max_size == 0 { + bytes.extend_from_slice(data); + } else { + trc::event!( + Http(trc::HttpEvent::RequestBody), + SpanId = session_id, + Details = req + .headers() + .iter() + .map(|(k, v)| trc::Value::Array(vec![ + k.as_str().to_compact_string().into(), + v.to_str().unwrap_or_default().to_compact_string().into() + ])) + .collect::>(), + Contents = std::str::from_utf8(&bytes) + .unwrap_or("[binary data]") + .to_string(), + Size = bytes.len(), + Limit = max_size, + ); + + return None; + } + } + } + + trc::event!( + Http(trc::HttpEvent::RequestBody), + SpanId = session_id, + Details = req + .headers() + .iter() + .map(|(k, v)| trc::Value::Array(vec![ + k.as_str().to_compact_string().into(), + v.to_str().unwrap_or_default().to_compact_string().into() + ])) + .collect::>(), + Contents = std::str::from_utf8(&bytes) + .unwrap_or("[binary data]") + .to_string(), + Size = bytes.len(), + ); + + bytes.into() +} diff --git a/crates/http-proto/src/response.rs b/crates/http-proto/src/response.rs new file mode 100644 index 0000000..112aa8f --- /dev/null +++ b/crates/http-proto/src/response.rs @@ -0,0 +1,335 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::manager::application::Resource; +use http_body_util::{BodyExt, Full}; +use hyper::{ + StatusCode, + body::Bytes, + header::{self, HeaderName, HeaderValue}, +}; +use serde_json::json; + +use crate::{ + DownloadResponse, HtmlResponse, HttpResponse, HttpResponseBody, JsonProblemResponse, + JsonResponse, ToHttpResponse, +}; + +impl HttpResponse { + pub fn new(status: StatusCode) -> Self { + HttpResponse { + status, + builder: hyper::Response::builder().status(status), + body: HttpResponseBody::Empty, + } + } + + pub fn redirect(location: String) -> Self { + let mut response = HttpResponse::new(StatusCode::FOUND); + response.builder = response + .builder + .status(StatusCode::FOUND) + .header(header::LOCATION, location); + response + } + + pub fn with_content_type(mut self, content_type: V) -> Self + where + V: TryInto, + >::Error: Into, + { + self.builder = self.builder.header(header::CONTENT_TYPE, content_type); + self + } + + pub fn with_status_code(mut self, status: StatusCode) -> Self { + self.status = status; + self.builder = self.builder.status(status); + self + } + + pub fn with_content_length(mut self, content_length: usize) -> Self { + self.builder = self.builder.header(header::CONTENT_LENGTH, content_length); + self + } + + pub fn with_content_range(mut self, content_range: String) -> Self { + self.builder = self.builder.header(header::CONTENT_RANGE, content_range); + self + } + + pub fn with_accept_ranges(mut self) -> Self { + self.builder = self.builder.header(header::ACCEPT_RANGES, "bytes"); + self + } + + pub fn with_etag(mut self, etag: String) -> Self { + self.builder = self.builder.header(header::ETAG, etag); + self + } + + pub fn with_etag_opt(self, etag: Option) -> Self { + if let Some(etag) = etag { + self.with_etag(etag) + } else { + self + } + } + + pub fn with_schedule_tag_opt(mut self, tag: Option) -> Self { + if let Some(tag) = tag { + self.builder = self.builder.header("Schedule-Tag", format!("\"{tag}\"")); + self + } else { + self + } + } + + pub fn with_last_modified(mut self, last_modified: String) -> Self { + self.builder = self.builder.header(header::LAST_MODIFIED, last_modified); + self + } + + pub fn with_lock_token(mut self, token_uri: &str) -> Self { + self.builder = self.builder.header("Lock-Token", format!("<{token_uri}>")); + self + } + + pub fn with_header(mut self, name: K, value: V) -> Self + where + K: TryInto, + >::Error: Into, + V: TryInto, + >::Error: Into, + { + self.builder = self.builder.header(name, value); + self + } + + pub fn with_xml_body(self, body: impl Into) -> Self { + self.with_text_body(body) + .with_content_type("application/xml; charset=utf-8") + } + + pub fn with_text_body(mut self, body: impl Into) -> Self { + let body = body.into(); + let body_len = body.len(); + self.body = HttpResponseBody::Text(body); + self.with_content_length(body_len) + } + + pub fn with_binary_body(mut self, body: impl Into>) -> Self { + let body = body.into(); + let body_len = body.len(); + self.body = HttpResponseBody::Binary(body); + self.with_content_length(body_len) + } + + pub fn with_stream_body( + mut self, + stream: http_body_util::combinators::BoxBody, + ) -> Self { + self.body = HttpResponseBody::Stream(stream); + self + } + + pub fn with_websocket_upgrade(mut self, derived_key: String) -> Self { + self.body = HttpResponseBody::WebsocketUpgrade(derived_key); + self + } + + pub fn with_content_disposition(mut self, content_disposition: V) -> Self + where + V: TryInto, + >::Error: Into, + { + self.builder = self + .builder + .header(header::CONTENT_DISPOSITION, content_disposition); + self + } + + pub fn with_cache_control(mut self, cache_control: V) -> Self + where + V: TryInto, + >::Error: Into, + { + self.builder = self.builder.header(header::CACHE_CONTROL, cache_control); + self + } + + pub fn with_no_store(mut self) -> Self { + self.builder = self + .builder + .header(header::CACHE_CONTROL, "no-store, no-cache, must-revalidate"); + self + } + + pub fn with_no_cache(mut self) -> Self { + self.builder = self.builder.header(header::CACHE_CONTROL, "no-cache"); + self + } + + pub fn with_immutable_cache(mut self) -> Self { + self.builder = self + .builder + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable"); + self + } + + pub fn with_location(mut self, location: V) -> Self + where + V: TryInto, + >::Error: Into, + { + self.builder = self.builder.header(header::LOCATION, location); + self + } + + pub fn with_cors_unrestricted(mut self) -> Self { + self.builder = self + .builder + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header( + header::ACCESS_CONTROL_ALLOW_HEADERS, + "Authorization, Content-Type, Accept, X-Requested-With", + ) + .header( + header::ACCESS_CONTROL_ALLOW_METHODS, + "POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS", + ); + self + } + + pub fn size(&self) -> usize { + match &self.body { + HttpResponseBody::Text(value) => value.len(), + HttpResponseBody::Binary(value) => value.len(), + _ => 0, + } + } + + pub fn build( + self, + ) -> hyper::Response> + { + match self.body { + HttpResponseBody::Text(body) => self.builder.body( + Full::new(Bytes::from(body)) + .map_err(|never| match never {}) + .boxed(), + ), + HttpResponseBody::Binary(body) => self.builder.body( + Full::new(Bytes::from(body)) + .map_err(|never| match never {}) + .boxed(), + ), + HttpResponseBody::Empty => { + let has_content_length = self + .builder + .headers_ref() + .is_some_and(|headers| headers.contains_key(header::CONTENT_LENGTH)); + let builder = if has_content_length { + self.builder + } else { + self.builder.header(header::CONTENT_LENGTH, 0) + }; + + builder.body( + Full::new(Bytes::new()) + .map_err(|never| match never {}) + .boxed(), + ) + } + HttpResponseBody::Stream(stream) => self.builder.body(stream), + HttpResponseBody::WebsocketUpgrade(derived_key) => self + .builder + .header(header::CONNECTION, "upgrade") + .header(header::UPGRADE, "websocket") + .header("Sec-WebSocket-Accept", &derived_key) + .header("Sec-WebSocket-Protocol", "jmap") + .body( + Full::new(Bytes::from("Switching to WebSocket protocol")) + .map_err(|never| match never {}) + .boxed(), + ), + } + .unwrap() + } + + pub fn body(&self) -> &HttpResponseBody { + &self.body + } + + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn headers(&self) -> Option<&hyper::HeaderMap> { + self.builder.headers_ref() + } +} + +impl ToHttpResponse for JsonResponse { + fn into_http_response(self) -> HttpResponse { + let response = HttpResponse::new(self.status) + .with_content_type("application/json; charset=utf-8") + .with_text_body(serde_json::to_string(&self.inner).unwrap_or_default()); + + if self.no_cache { + response.with_no_store() + } else { + response + } + } +} + +impl ToHttpResponse for DownloadResponse { + fn into_http_response(self) -> HttpResponse { + HttpResponse::new(StatusCode::OK) + .with_content_type(self.content_type) + .with_content_disposition(format!( + "attachment; filename=\"{}\"", + self.filename.replace('\"', "\\\"") + )) + .with_cache_control("private, immutable, max-age=31536000") + .with_binary_body(self.blob) + } +} + +impl ToHttpResponse for Resource> { + fn into_http_response(self) -> HttpResponse { + HttpResponse::new(StatusCode::OK) + .with_content_type(self.content_type.as_ref()) + .with_binary_body(self.contents) + } +} + +impl ToHttpResponse for HtmlResponse { + fn into_http_response(self) -> HttpResponse { + HttpResponse::new(self.status) + .with_content_type("text/html; charset=utf-8") + .with_text_body(self.body) + } +} + +impl ToHttpResponse for JsonProblemResponse { + fn into_http_response(self) -> HttpResponse { + HttpResponse::new(self.0) + .with_content_type("application/problem+json") + .with_text_body( + serde_json::to_string(&json!( + { + "type": "about:blank", + "title": self.0.canonical_reason().unwrap_or_default(), + "status": self.0.as_u16(), + "detail": self.0.canonical_reason().unwrap_or_default(), + } + )) + .unwrap_or_default(), + ) + } +} diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml new file mode 100644 index 0000000..b07ea9e --- /dev/null +++ b/crates/http/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "http" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +common = { path = "../common" } +utils = { path = "../utils" } +trc = { path = "../trc" } +email = { path = "../email" } +smtp = { path = "../smtp" } +jmap = { path = "../jmap" } +dav = { path = "../dav" } +scim = { path = "../scim" } +groupware = { path = "../groupware" } +http_proto = { path = "../http-proto" } +jmap_proto = { path = "../jmap-proto" } +types = { path = "../types" } +directory = { path = "../directory" } +services = { path = "../services" } +registry = { path = "../registry" } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +mail-builder = { version = "1.0" } +mail-auth = { version = "0.13", features = ["generate", "arc"] } +tokio = { version = "1.53", features = ["rt"] } +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } +http-body-util = "0.1.5" +async-stream = "0.3.6" +serde = { version = "1.0", features = ["derive"]} +serde_json = "1.0" +chrono = "0.4" +base64 = "0.23" +sha2 = "0.11" +rkyv = { version = "0.8.18", features = ["little_endian"] } +form-data = { version = "0.6.0", features = ["sync"], default-features = false } +mime = "0.3.17" +percent-encoding = "2.3.2" +hashify = { version = "0.2" } + +[dev-dependencies] +flate2 = "1.1" + +[features] +test_mode = [] +dev_mode = [] +enterprise = ["scim/enterprise"] + +[lints] +workspace = true diff --git a/crates/http/src/api/diagnose.rs b/crates/http/src/api/diagnose.rs new file mode 100644 index 0000000..9c2e7dd --- /dev/null +++ b/crates/http/src/api/diagnose.rs @@ -0,0 +1,756 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + config::smtp::{ + queue::{HostOrIp, MxConfig}, + resolver::{Policy, Tlsa}, + }, +}; +use hyper::body::{Bytes, Frame}; +use mail_auth::{IpLookupStrategy, mta_sts::TlsRpt}; +use serde::{Deserialize, Serialize}; +use smtp::outbound::{ + client::{SmtpClient, StartTlsResult}, + dane::{ + dnssec::{TlsaLookup, TlsaResult}, + verify::TlsaVerify, + }, + error::ClientError, + lookup::{DnsLookup, SourceIp, ToNextHop}, + mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy}, +}; +use std::{ + net::{IpAddr, SocketAddr}, + time::{Duration, Instant}, +}; +use tokio::{io::AsyncWriteExt, sync::mpsc}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +pub(crate) enum DeliveryStage { + MxLookupStart { + domain: String, + }, + MxLookupSuccess { + mxs: Vec, + elapsed: u64, + }, + MxLookupError { + reason: String, + elapsed: u64, + }, + MtaStsFetchStart, + MtaStsFetchSuccess { + policy: Policy, + elapsed: u64, + }, + MtaStsFetchError { + reason: String, + elapsed: u64, + }, + MtaStsNotFound { + elapsed: u64, + }, + TlsRptLookupStart, + TlsRptLookupSuccess { + rua: Vec, + elapsed: u64, + }, + TlsRptLookupError { + reason: String, + elapsed: u64, + }, + TlsRptNotFound { + elapsed: u64, + }, + DeliveryAttemptStart { + hostname: String, + }, + MtaStsVerifySuccess, + MtaStsVerifyError { + reason: String, + }, + TlsaLookupStart, + TlsaLookupSuccess { + record: Tlsa, + elapsed: u64, + }, + TlsaNotFound { + elapsed: u64, + reason: String, + }, + TlsaLookupError { + elapsed: u64, + reason: String, + }, + IpLookupStart, + #[serde(rename_all = "camelCase")] + IpLookupSuccess { + remote_ips: Vec, + elapsed: u64, + }, + IpLookupError { + reason: String, + elapsed: u64, + }, + #[serde(rename_all = "camelCase")] + ConnectionStart { + remote_ip: IpAddr, + }, + ConnectionSuccess { + elapsed: u64, + }, + ConnectionError { + elapsed: u64, + reason: String, + }, + ReadGreetingStart, + ReadGreetingSuccess { + elapsed: u64, + }, + ReadGreetingError { + elapsed: u64, + reason: String, + }, + EhloStart, + EhloSuccess { + elapsed: u64, + }, + EhloError { + elapsed: u64, + reason: String, + }, + StartTlsStart, + StartTlsSuccess { + elapsed: u64, + }, + StartTlsError { + elapsed: u64, + reason: String, + }, + DaneVerifySuccess, + DaneVerifyError { + reason: String, + }, + MailFromStart, + MailFromSuccess { + elapsed: u64, + }, + MailFromError { + reason: String, + elapsed: u64, + }, + RcptToStart, + RcptToSuccess { + elapsed: u64, + }, + RcptToError { + reason: String, + elapsed: u64, + }, + QuitStart, + QuitCompleted { + elapsed: u64, + }, + Completed, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct MX { + pub exchanges: Vec, + pub preference: u16, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +pub enum ReportUri { + Mail { email: String }, + Http { url: String }, +} + +impl DeliveryStage { + pub fn to_frame(&self) -> Frame { + let payload = format!( + "event: event\ndata: [{}]\n\n", + serde_json::to_string(self).unwrap_or_default() + ); + Frame::data(Bytes::from(payload)) + } +} + +trait ElapsedMs { + fn elapsed_ms(&self) -> u64; +} + +impl ElapsedMs for Instant { + fn elapsed_ms(&self) -> u64 { + self.elapsed().as_millis() as u64 + } +} +pub(crate) fn spawn_delivery_diagnose( + server: Server, + domain_or_email: String, + timeout: Duration, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(10); + + tokio::spawn(async move { + let _ = delivery_diagnose(tx, server, domain_or_email, timeout).await; + }); + + rx +} + +async fn delivery_diagnose( + tx: mpsc::Sender, + server: Server, + domain_or_email: String, + timeout: Duration, +) -> Result<(), mpsc::error::SendError> { + let (domain, email) = if let Some((_, domain)) = domain_or_email.rsplit_once('@') { + (domain.to_string(), Some(domain_or_email)) + } else { + (domain_or_email, None) + }; + + let local_host = &server.core.network.server_name; + let conn_strategy = server.get_connection_or_default("default", 0); + + tx.send(DeliveryStage::MxLookupStart { + domain: domain.to_string(), + }) + .await?; + + // Lookup MX + let now = Instant::now(); + let mxs = match server + .core + .smtp + .resolvers + .dns + .mx_lookup(&domain, Some(&server.inner.cache.dns_mx)) + .await + { + Ok(mxs) => mxs, + Err(err) => { + tx.send(DeliveryStage::MxLookupError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + + return Ok(()); + } + }; + + // Obtain remote host list + let mx_config = MxConfig { + max_mx: mxs.rrset.len(), + max_multi_homed: 10, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }; + let hosts = if let Some(hosts) = mxs.to_remote_hosts(&domain, &mx_config) { + tx.send(DeliveryStage::MxLookupSuccess { + mxs: mxs + .rrset + .iter() + .map(|mx| MX { + exchanges: mx.exchanges.iter().map(|e| e.to_string()).collect(), + preference: mx.preference, + }) + .collect(), + elapsed: now.elapsed_ms(), + }) + .await?; + + hosts + } else { + tx.send(DeliveryStage::MxLookupError { + reason: "Null MX record".to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + + return Ok(()); + }; + + // Fetch MTA-STS policy + let now = Instant::now(); + tx.send(DeliveryStage::MtaStsFetchStart).await?; + let mta_sts_policy = match server.lookup_mta_sts_policy(&domain, timeout).await { + Ok(policy) => { + tx.send(DeliveryStage::MtaStsFetchSuccess { + policy: policy.as_ref().clone(), + elapsed: now.elapsed_ms(), + }) + .await?; + Some(policy) + } + Err(err) => { + if matches!( + &err, + smtp::outbound::mta_sts::Error::Dns(mail_auth::Error::Dns( + mail_auth::DnsError::RecordNotFound(_) + )) + ) { + tx.send(DeliveryStage::MtaStsNotFound { + elapsed: now.elapsed_ms(), + }) + .await?; + } else { + tx.send(DeliveryStage::MtaStsFetchError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + } + None + } + }; + + // Fetch TLS-RPT settings + let now = Instant::now(); + tx.send(DeliveryStage::TlsRptLookupStart).await?; + match server + .core + .smtp + .resolvers + .dns + .txt_lookup::( + format!("_smtp._tls.{domain}."), + Some(&server.inner.cache.dns_txt), + ) + .await + { + Ok(record) => { + tx.send(DeliveryStage::TlsRptLookupSuccess { + rua: record + .rua + .iter() + .map(|r| match r { + mail_auth::mta_sts::ReportUri::Mail(email) => ReportUri::Mail { + email: email.clone(), + }, + mail_auth::mta_sts::ReportUri::Http(url) => { + ReportUri::Http { url: url.clone() } + } + }) + .collect(), + elapsed: now.elapsed_ms(), + }) + .await?; + } + Err(err) => { + if matches!( + &err, + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_)) + ) { + tx.send(DeliveryStage::TlsRptNotFound { + elapsed: now.elapsed_ms(), + }) + .await?; + } else { + tx.send(DeliveryStage::TlsRptLookupError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + } + } + } + + // Try with each host + 'outer: for host in hosts { + let hostname = host.hostname(); + + tx.send(DeliveryStage::DeliveryAttemptStart { + hostname: hostname.to_string(), + }) + .await?; + + // Verify MTA-STS policy + if let Some(mta_sts_policy) = &mta_sts_policy { + if mta_sts_policy.verify(hostname) { + tx.send(DeliveryStage::MtaStsVerifySuccess).await?; + } else { + tx.send(DeliveryStage::MtaStsVerifyError { + reason: "Not authorized by policy".to_string(), + }) + .await?; + + continue; + } + } + + // Fetch TLSA record + tx.send(DeliveryStage::TlsaLookupStart).await?; + + let now = Instant::now(); + let dane_policy = match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await { + Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => { + tx.send(DeliveryStage::TlsaLookupSuccess { + record: tlsa.as_ref().clone(), + elapsed: now.elapsed_ms(), + }) + .await?; + + Some(tlsa) + } + Ok(TlsaResult::Secure(_)) => { + tx.send(DeliveryStage::TlsaLookupError { + elapsed: now.elapsed_ms(), + reason: "TLSA record does not have end entities".to_string(), + }) + .await?; + + None + } + Ok(TlsaResult::Bogus) => { + tx.send(DeliveryStage::TlsaLookupError { + elapsed: now.elapsed_ms(), + reason: "Bogus TLSA record".to_string(), + }) + .await?; + + None + } + Ok(TlsaResult::Missing) => { + tx.send(DeliveryStage::TlsaNotFound { + elapsed: now.elapsed_ms(), + reason: "No TLSA DNSSEC records found".to_string(), + }) + .await?; + + None + } + Err(err) => { + if matches!( + &err, + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_)) + ) { + tx.send(DeliveryStage::TlsaNotFound { + elapsed: now.elapsed_ms(), + reason: "No TLSA records found for MX".to_string(), + }) + .await?; + } else { + tx.send(DeliveryStage::TlsaLookupError { + elapsed: now.elapsed_ms(), + reason: err.to_string(), + }) + .await?; + } + None + } + }; + + tx.send(DeliveryStage::IpLookupStart).await?; + + let now = Instant::now(); + let remote_ips = match host.fqdn_hostname() { + HostOrIp::Host(hostname) => { + match server + .ip_lookup(&hostname, IpLookupStrategy::Ipv4thenIpv6, usize::MAX, false) + .await + { + Ok((remote_ips, _)) if !remote_ips.is_empty() => remote_ips, + Ok(_) => { + tx.send(DeliveryStage::IpLookupError { + reason: "No IP addresses found for host".to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + continue; + } + Err(err) => { + tx.send(DeliveryStage::IpLookupError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + continue; + } + } + } + HostOrIp::Ip(ip) => vec![ip], + }; + + tx.send(DeliveryStage::IpLookupSuccess { + remote_ips: remote_ips.clone(), + elapsed: now.elapsed_ms(), + }) + .await?; + + for remote_ip in remote_ips { + // Start connection + tx.send(DeliveryStage::ConnectionStart { remote_ip }) + .await?; + + let now = Instant::now(); + let connect = if let Some(ip_host) = conn_strategy.source_ip(remote_ip.is_ipv4()) { + SmtpClient::connect_using(ip_host.ip, SocketAddr::new(remote_ip, 25), timeout, 0) + .await + } else { + SmtpClient::connect(SocketAddr::new(remote_ip, 25), timeout, 0).await + }; + match connect { + Ok(mut client) => { + tx.send(DeliveryStage::ConnectionSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + + // Read greeting + tx.send(DeliveryStage::ReadGreetingStart).await?; + + let now = Instant::now(); + if let Err(status) = client.read_greeting(hostname).await { + tx.send(DeliveryStage::ReadGreetingError { + elapsed: now.elapsed_ms(), + reason: status.to_string(), + }) + .await?; + + continue; + } + tx.send(DeliveryStage::ReadGreetingSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + + // Say EHLO + tx.send(DeliveryStage::EhloStart).await?; + + let now = Instant::now(); + let capabilities = match tokio::time::timeout(timeout, async { + client + .stream + .write_all(format!("EHLO {local_host}\r\n",).as_bytes()) + .await?; + client.stream.flush().await?; + client.read_ehlo().await + }) + .await + { + Ok(Ok(capabilities)) => { + tx.send(DeliveryStage::EhloSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + + capabilities + } + Ok(Err(err)) => { + tx.send(DeliveryStage::EhloError { + elapsed: now.elapsed_ms(), + reason: err.to_string(), + }) + .await?; + + continue; + } + Err(_) => { + tx.send(DeliveryStage::EhloError { + elapsed: now.elapsed_ms(), + reason: "Timed out reading response".to_string(), + }) + .await?; + + continue; + } + }; + + // Start TLS + tx.send(DeliveryStage::StartTlsStart).await?; + + let now = Instant::now(); + let mut client = match client + .try_start_tls( + &server.inner.data.smtp_connectors.pki_verify, + hostname, + &capabilities, + ) + .await + { + StartTlsResult::Success { smtp_client } => { + tx.send(DeliveryStage::StartTlsSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + + smtp_client + } + StartTlsResult::Error { error } => { + tx.send(DeliveryStage::StartTlsError { + elapsed: now.elapsed_ms(), + reason: error.to_string(), + }) + .await?; + + continue; + } + StartTlsResult::Unavailable { response, .. } => { + tx.send(DeliveryStage::StartTlsError { + elapsed: now.elapsed_ms(), + reason: response.map(|r| r.to_string()).unwrap_or_else(|| { + "STARTTLS not advertised by host".to_string() + }), + }) + .await?; + + continue; + } + }; + + // Verify DANE policy + if let Some(dane_policy) = &dane_policy { + if let Err(err) = dane_policy.verify( + 0, + hostname, + &[hostname], + client.tls_connection().peer_certificates(), + ) { + tx.send(DeliveryStage::DaneVerifyError { + reason: err.to_string(), + }) + .await?; + } else { + tx.send(DeliveryStage::DaneVerifySuccess).await?; + } + } + + // Say EHLO again (some SMTP servers require this) + tx.send(DeliveryStage::EhloStart).await?; + + let now = Instant::now(); + match tokio::time::timeout(timeout, async { + client + .stream + .write_all(format!("EHLO {local_host}\r\n",).as_bytes()) + .await?; + client.stream.flush().await?; + client.read_ehlo().await + }) + .await + { + Ok(Ok(_)) => { + tx.send(DeliveryStage::EhloSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + } + Ok(Err(err)) => { + tx.send(DeliveryStage::EhloError { + elapsed: now.elapsed_ms(), + reason: err.to_string(), + }) + .await?; + + continue; + } + Err(_) => { + tx.send(DeliveryStage::EhloError { + elapsed: now.elapsed_ms(), + reason: "Timed out reading response".to_string(), + }) + .await?; + + continue; + } + } + + // Verify recipient + let mut is_success = email.is_none(); + if let Some(email) = &email { + // MAIL FROM + tx.send(DeliveryStage::MailFromStart).await?; + + let now = Instant::now(); + + match client.cmd(b"MAIL FROM:<>\r\n").await.and_then(|r| { + if r.is_positive_completion() { + Ok(r) + } else { + Err(ClientError::UnexpectedReply(Box::new(r))) + } + }) { + Ok(_) => { + tx.send(DeliveryStage::MailFromSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + + // RCPT TO + tx.send(DeliveryStage::RcptToStart).await?; + + let now = Instant::now(); + match client + .cmd(format!("RCPT TO:<{email}>\r\n").as_bytes()) + .await + .and_then(|r| { + if r.is_positive_completion() { + Ok(r) + } else { + Err(ClientError::UnexpectedReply(Box::new(r))) + } + }) { + Ok(_) => { + is_success = true; + tx.send(DeliveryStage::RcptToSuccess { + elapsed: now.elapsed_ms(), + }) + .await?; + } + Err(err) => { + tx.send(DeliveryStage::RcptToError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + } + } + } + Err(err) => { + tx.send(DeliveryStage::MailFromError { + reason: err.to_string(), + elapsed: now.elapsed_ms(), + }) + .await?; + } + } + } + + // QUIT + tx.send(DeliveryStage::QuitStart).await?; + + let now = Instant::now(); + client.quit().await; + tx.send(DeliveryStage::QuitCompleted { + elapsed: now.elapsed_ms(), + }) + .await?; + + if is_success { + break 'outer; + } + } + Err(err) => { + tx.send(DeliveryStage::ConnectionError { + elapsed: now.elapsed_ms(), + reason: err.to_string(), + }) + .await?; + } + } + } + } + + Ok(()) +} diff --git a/crates/http/src/api/mod.rs b/crates/http/src/api/mod.rs new file mode 100644 index 0000000..5185d38 --- /dev/null +++ b/crates/http/src/api/mod.rs @@ -0,0 +1,298 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod diagnose; + +use crate::{ + api::diagnose::{DeliveryStage, spawn_delivery_diagnose}, + auth::{ + authenticate::Authenticator, oauth::auth::OAuthApiHandler, permissions::AccountApiHandler, + }, +}; +use common::{ + Server, + auth::{AccessToken, oauth::GrantType}, + manager::application::Resource, +}; +use groupware::calendar::itip::{ItipIngest, RsvpRequest}; +use http_body_util::{StreamBody, combinators::BoxBody}; +use http_proto::{ + HttpRequest, HttpResponse, HttpSessionData, JsonResponse, ToHttpResponse, + request::{decode_path_element, fetch_body}, +}; +use hyper::{ + Method, StatusCode, + header::{self, CONTENT_ENCODING}, +}; +use jmap::api::{ToJmapHttpResponse, ToRequestError}; +use jmap_proto::error::request::RequestError; +use registry::schema::enums::Permission; +use std::time::Duration; +use utils::url_params::UrlParams; + +pub trait ManagementApi: Sync + Send { + fn handle_api_request( + &self, + req: &mut HttpRequest, + session: &HttpSessionData, + ) -> impl Future> + Send; + + fn management_access_token( + &self, + req: &HttpRequest, + session: &HttpSessionData, + ) -> impl Future> + Send; +} + +impl ManagementApi for Server { + #[allow(unused_variables)] + async fn handle_api_request( + &self, + req: &mut HttpRequest, + session: &HttpSessionData, + ) -> trc::Result { + let is_post = req.method() == Method::POST; + let body = if is_post { + fetch_body(req, 1024 * 1024, session.session_id).await + } else { + None + }; + let path = req.uri().path().split('/').skip(2).collect::>(); + + match path.first().copied().unwrap_or_default() { + "auth" if is_post => { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + Box::pin(self.handle_login_request( + session, + body.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?, + )) + .await + } + "calendar" + if is_post + && path.get(1).copied() == Some("rsvp") + && self.core.groupware.itip_http_rsvp_url.is_some() => + { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + let request = serde_json::from_slice::( + &body.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?, + ) + .map_err(|err| { + trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) + })?; + + self.http_rsvp_handle(request, accept_language(req), session.remote_ip) + .await + .map(|response| JsonResponse::new(response).no_cache().into_http_response()) + } + "discover" => { + if let Some(email) = path.get(1).copied() { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + self.handle_discover_request(session, decode_path_element(email).as_ref()) + .await + } else { + Err(trc::ResourceEvent::NotFound.into_err()) + } + } + "account" => { + // Authenticate request + let (_in_flight, access_token) = self.authenticate_headers(req, session).await?; + self.handle_account_request(&access_token).await + } + "schema" => { + // Authenticate request + let (_in_flight, access_token) = self.authenticate_headers(req, session).await?; + static SCHEMA_JSON: &[u8] = + include_bytes!("../../../../resources/schema/schema.json.gz"); + const SCHEMA_HASH: &str = + include_str!("../../../../resources/schema/schema.json.sha256"); + + if path.get(1).is_some_and(|hash| hash == &SCHEMA_HASH) { + Ok(Resource::new("application/json", SCHEMA_JSON.to_vec()) + .into_http_response() + .with_immutable_cache() + .with_header(CONTENT_ENCODING, "gzip")) + } else { + Ok(HttpResponse::redirect(format!("/api/schema/{SCHEMA_HASH}"))) + } + } + "token" => { + let access_token = self.management_access_token(req, session).await?; + let account_id = access_token.account_id(); + match path.get(1).copied() { + Some("delivery") => { + // Validate the access token + access_token.enforce_permission(Permission::LiveDeliveryTest)?; + + // Issue a live telemetry token valid for 60 seconds + Ok(HttpResponse::new(StatusCode::OK) + .with_no_cache() + .with_text_body( + self.encode_access_token( + GrantType::LiveDelivery, + account_id, + self.account(account_id).await?.name(), + 60, + None, + None, + ) + .await?, + )) + } + Some("tracing") | Some("metrics") => { + Err(trc::ResourceEvent::NotFound + .ctx(trc::Key::Details, "Enterprise feature")) + } + _ => Err(trc::ResourceEvent::NotFound.into_err()), + } + } + "live" => { + let access_token = self.management_access_token(req, session).await?; + let params = UrlParams::new(req.uri().query()); + let account_id = access_token.account_id(); + + match ( + path.get(1).copied().unwrap_or_default(), + path.get(2).copied(), + req.method(), + ) { + ("delivery", Some(target), &Method::GET) => { + // Validate the access token + access_token.enforce_permission(Permission::LiveDeliveryTest)?; + + let timeout = Duration::from_secs( + params + .parse::("timeout") + .filter(|interval| *interval >= 1) + .unwrap_or(30), + ); + + let mut rx = spawn_delivery_diagnose( + self.clone(), + decode_path_element(target).to_lowercase(), + timeout, + ); + + Ok(HttpResponse::new(StatusCode::OK) + .with_content_type("text/event-stream") + .with_cache_control("no-store") + .with_stream_body(BoxBody::new(StreamBody::new( + async_stream::stream! { + while let Some(stage) = rx.recv().await { + yield Ok(stage.to_frame()); + } + yield Ok(DeliveryStage::Completed.to_frame()); + }, + )))) + } + ("tracing" | "metrics", _, &Method::GET) => { + Err(trc::ResourceEvent::NotFound + .ctx(trc::Key::Details, "Enterprise feature")) + } + _ => Err(trc::ResourceEvent::NotFound.into_err()), + } + } + _ => Err(trc::ResourceEvent::NotFound.into_err()), + } + } + + async fn management_access_token( + &self, + req: &HttpRequest, + session: &HttpSessionData, + ) -> trc::Result { + let params = UrlParams::new(req.uri().query()); + if let Some(token) = params.get("token") { + let path = req.uri().path(); + let grant = if path.starts_with("/api/live/delivery") { + Some((GrantType::LiveDelivery, Permission::LiveDeliveryTest)) + } else { + #[cfg(not(feature = "enterprise"))] + { + None + } + }; + + if let Some((grant_type, permission)) = grant { + self.validate_access_token(grant_type.into(), token) + .await + .map(|token_info| { + AccessToken::from_permissions(token_info.account_id, [permission]) + }) + } else { + self.authenticate_headers(req, session) + .await + .map(|(_, token)| token) + } + } else { + self.authenticate_headers(req, session) + .await + .map(|(_, token)| token) + } + } +} + +pub trait ToManageHttpResponse { + fn into_http_response(self, challenge: AuthChallenge) -> HttpResponse; +} + +impl ToManageHttpResponse for &trc::Error { + fn into_http_response(self, challenge: AuthChallenge) -> HttpResponse { + match self.as_ref() { + trc::EventType::Auth( + trc::AuthEvent::Failed | trc::AuthEvent::Error | trc::AuthEvent::TokenExpired, + ) => HttpResponse::unauthorized(challenge), + _ => self.to_request_error().into_http_response(), + } + } +} + +pub fn accept_language(req: &HttpRequest) -> &str { + req.headers() + .get(header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) + .map(|language| { + let language = language.split_once(',').map_or(language, |(l, _)| l); + language.split_once(';').map_or(language, |(l, _)| l).trim() + }) + .filter(|language| !language.is_empty()) + .unwrap_or("en") +} + +const BEARER_CHALLENGE: &str = concat!( + "Bearer realm=\"Stalwart Server\", ", + "resource_metadata=\"/.well-known/oauth-protected-resource\"" +); +const BASIC_CHALLENGE: &str = "Basic realm=\"Stalwart Server\""; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthChallenge { + Bearer, + BearerAndBasic, +} + +pub trait UnauthorizedResponse { + fn unauthorized(challenge: AuthChallenge) -> Self; +} + +impl UnauthorizedResponse for HttpResponse { + fn unauthorized(challenge: AuthChallenge) -> Self { + let response = HttpResponse::new(StatusCode::UNAUTHORIZED) + .with_header(header::WWW_AUTHENTICATE, BEARER_CHALLENGE); + + if challenge == AuthChallenge::BearerAndBasic { + response.with_header(header::WWW_AUTHENTICATE, BASIC_CHALLENGE) + } else { + response + } + .with_content_type("application/problem+json") + .with_text_body(serde_json::to_string(&RequestError::unauthorized()).unwrap_or_default()) + } +} diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs new file mode 100644 index 0000000..5d9f271 --- /dev/null +++ b/crates/http/src/auth/authenticate.rs @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::auth::AccessToken; +use common::{HttpAuthCache, Server, auth::AuthRequest, network::limiter::InFlight}; +use directory::Credentials; +use http_proto::{HttpRequest, HttpSessionData}; +use hyper::header; +use mail_parser::decoders::base64::base64_decode; +use std::future::Future; +use std::time::{Duration, Instant}; + +pub trait Authenticator: Sync + Send { + fn authenticate_headers( + &self, + req: &HttpRequest, + session: &HttpSessionData, + ) -> impl Future, AccessToken)>> + Send; +} + +impl Authenticator for Server { + async fn authenticate_headers( + &self, + req: &HttpRequest, + session: &HttpSessionData, + ) -> trc::Result<(Option, AccessToken)> { + if let Some((mechanism, token)) = req.authorization() { + // Check if the credentials are cached + if let Some(http_cache) = self.inner.cache.http_auth.get(token) { + // Make sure the revision is still valid + if http_cache.expires > Instant::now() { + let access_token = AccessToken::renew( + self.access_token(http_cache.account_id).await?, + http_cache.credential_id, + session.remote_ip, + )?; + + if access_token.revision() == http_cache.revision { + // Enforce authenticated rate limit + return self + .is_http_authenticated_request_allowed(&access_token, session.remote_ip) + .await + .map(|in_flight| (in_flight, access_token)); + } + } + + // If the revision is not valid, remove the cached credentials + self.inner.cache.http_auth.remove(token); + } + + let credentials = if mechanism.eq_ignore_ascii_case("basic") { + // Decode the base64 encoded credentials + decode_plain_auth(token).ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Failed to decode Basic auth request.") + .id(token.to_string()) + .caused_by(trc::location!()) + })? + } else if mechanism.eq_ignore_ascii_case("bearer") { + // Enforce anonymous rate limit + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + Credentials::Bearer { + username: None, + token: token.to_string(), + } + } else { + // Enforce anonymous rate limit + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return Err(trc::AuthEvent::Error + .into_err() + .reason("Unsupported authentication mechanism.") + .details(token.to_string()) + .caused_by(trc::location!())); + }; + + // Authenticate + let access_token = self + .authenticate(&AuthRequest::from_credentials( + credentials, + session.session_id, + session.remote_ip, + )) + .await?; + + // Cache credentials + self.inner.cache.http_auth.insert( + token.into(), + HttpAuthCache { + account_id: access_token.account_id(), + revision: access_token.revision(), + credential_id: access_token.credential_id(), + expires: Instant::now() + + Duration::from_secs(self.core.oauth.oauth_expiry_token), + }, + ); + + // Enforce authenticated rate limit + self.is_http_authenticated_request_allowed(&access_token, session.remote_ip) + .await + .map(|in_flight| (in_flight, access_token)) + } else { + // Enforce anonymous rate limit + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + Err(trc::AuthEvent::Failed + .into_err() + .details("Missing Authorization header.") + .caused_by(trc::location!())) + } + } +} + +pub trait HttpHeaders { + fn authorization(&self) -> Option<(&str, &str)>; + fn authorization_basic(&self) -> Option<&str>; +} + +impl HttpHeaders for HttpRequest { + fn authorization(&self) -> Option<(&str, &str)> { + self.headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.split_once(' ').map(|(l, t)| (l, t.trim()))) + } + + fn authorization_basic(&self) -> Option<&str> { + self.authorization().and_then(|(l, t)| { + if l.eq_ignore_ascii_case("basic") { + Some(t) + } else { + None + } + }) + } +} + +fn decode_plain_auth(token: &str) -> Option { + base64_decode(token.as_bytes()) + .and_then(|token| String::from_utf8(token).ok()) + .and_then(|token| { + token + .split_once(':') + .map(|(login, secret)| Credentials::Basic { + username: login.trim().to_lowercase(), + secret: secret.to_string(), + mfa_token: None, + }) + }) +} diff --git a/crates/http/src/auth/mod.rs b/crates/http/src/auth/mod.rs new file mode 100644 index 0000000..b6089ed --- /dev/null +++ b/crates/http/src/auth/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod authenticate; +pub mod oauth; +pub mod permissions; diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs new file mode 100644 index 0000000..5b640e4 --- /dev/null +++ b/crates/http/src/auth/oauth/auth.rs @@ -0,0 +1,649 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode, PkceCodeChallenge}; +use crate::auth::oauth::{ + OAuthStatus, openid::OpenIdHandler, registration::ClientRegistrationHandler, +}; +use common::{ + KV_OAUTH, Server, + auth::{ + AuthRequest, + authentication::UsernameParts, + oauth::{ + CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, SUPPORTED_SCOPES, USER_CODE_ALPHABET, + USER_CODE_LEN, + client_id::{decode_client_id, scopes_to_mask}, + registration::redirect_uri_matches, + }, + }, +}; +use directory::Credentials; +use http_proto::*; +use std::future::Future; +use store::{ + Serialize, + dispatch::lookup::KeyValue, + write::{Archive, Archiver}, +}; +use store::{ + rand::{ + RngExt, + distr::{Alphanumeric, StandardUniform}, + rng, + }, + write::AlignedBytes, +}; +use trc::AddContext; + +#[derive(Debug, serde::Serialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: [String; 1], + pub scopes_supported: &'static [&'static str], + pub bearer_methods_supported: &'static [&'static str], +} + +#[derive(Debug, serde::Serialize)] +pub struct OAuthMetadata { + pub issuer: String, + pub token_endpoint: String, + pub authorization_endpoint: String, + pub device_authorization_endpoint: String, + pub registration_endpoint: String, + pub introspection_endpoint: String, + pub grant_types_supported: &'static [&'static str], + pub response_types_supported: &'static [&'static str], + pub scopes_supported: &'static [&'static str], + pub token_endpoint_auth_methods_supported: &'static [&'static str], + pub code_challenge_methods_supported: &'static [&'static str], + pub authorization_response_iss_parameter_supported: bool, +} + +pub trait OAuthApiHandler: Sync + Send { + fn handle_discover_request( + &self, + session: &HttpSessionData, + account_name: &str, + ) -> impl Future> + Send; + + fn handle_login_request( + &self, + session: &HttpSessionData, + body: Vec, + ) -> impl Future> + Send; + + fn handle_device_auth( + &self, + req: &mut HttpRequest, + session: &HttpSessionData, + ) -> impl Future> + Send; + + fn handle_oauth_metadata(&self) -> impl Future> + Send; + + fn handle_oauth_protected_resource( + &self, + ) -> impl Future> + Send; +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum LoginRequest { + #[serde(rename_all = "camelCase")] + AuthCode { + account_name: String, + account_secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + mfa_token: Option, + client_id: String, + #[serde(default)] + redirect_uri: Option, + #[serde(default)] + nonce: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + code_challenge: Option, + #[serde(default)] + code_challenge_method: Option, + #[serde(default)] + state: Option, + #[serde(default)] + resource: Vec, + }, + #[serde(rename_all = "camelCase")] + AuthDevice { + account_name: String, + account_secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + mfa_token: Option, + code: String, + }, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum LoginResponse { + Authenticated { client_code: String, iss: String }, + Verified, + MfaRequired, + Failure, +} + +impl OAuthApiHandler for Server { + async fn handle_discover_request( + &self, + session: &HttpSessionData, + account_name: &str, + ) -> trc::Result { + let username = UsernameParts::new(account_name.trim()); + let auth_as = username.auth_as(); + let is_recovery_admin = self + .registry() + .recovery_admin() + .is_some_and(|(user, _)| user.trim().eq_ignore_ascii_case(auth_as.address())); + + if !is_recovery_admin + && let Some(domain_name) = auth_as.domain().filter(|domain| !domain.is_empty()) + && let Some(endpoint) = self + .get_directory_for_domain(domain_name) + .await? + .and_then(|directory| directory.oidc_discovery_document()) + { + Ok(JsonResponse::new(&endpoint.document) + .no_cache() + .into_http_response()) + } else { + self.handle_oidc_metadata(!session.is_tls).await + } + } + + async fn handle_login_request( + &self, + session: &HttpSessionData, + body: Vec, + ) -> trc::Result { + let request = serde_json::from_slice::(&body).map_err(|err| { + trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) + })?; + + let response = match request { + LoginRequest::AuthCode { + account_name, + account_secret, + mfa_token, + client_id, + redirect_uri, + nonce, + scope, + code_challenge, + code_challenge_method, + resource, + .. + } => { + // Validate clientId + if client_id.len() > CLIENT_ID_MAX_LEN { + return Err(trc::AuthEvent::Error + .into_err() + .details("Client ID is too long.")); + } else if redirect_uri + .as_ref() + .is_some_and(|uri| uri.starts_with("http://")) + { + #[cfg(not(feature = "dev_mode"))] + if !self.registry().is_recovery_mode() && code_challenge.is_none() { + return Err(trc::AuthEvent::Error + .into_err() + .details("Redirect URI must be HTTPS.")); + } + } + + // Resolve the client and validate the redirect URI against the registration. + // Stateless client ids are self-describing; otherwise fall back to the registry. + let redirect_uri = redirect_uri.ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("A redirect URI is required.") + })?; + let stateless_client = + decode_client_id(self.core.oauth.oauth_key.as_bytes(), &client_id); + let granted_scope = match &stateless_client { + Some(meta) => { + if !meta + .redirect_uris + .iter() + .any(|uri| redirect_uri_matches(uri, &redirect_uri)) + { + return Err(trc::AuthEvent::Error + .into_err() + .details("Redirect URI does not match the client registration.")); + } + grant_scope(scope.as_deref(), meta.scope_mask) + } + None => scope, + }; + + // Validate Resource Indicators (RFC 8707) + for resource in &resource { + if !is_known_resource( + [self.core.network.server_name.as_str()] + .into_iter() + .chain( + self.core + .network + .info + .services + .values() + .filter_map(|v| v.hostname.as_deref()), + ) + .chain( + self.core + .network + .info + .mxs + .iter() + .filter_map(|mx| mx.hostname.as_deref()), + ), + resource, + ) { + return Err(trc::AuthEvent::Error + .into_err() + .details(format!("Unknown resource indicator: {}", resource))); + } + } + + // Parse and validate PKCE challenge (RFC 7636). + let pkce_challenge = match code_challenge { + Some(challenge) => match code_challenge_method.as_deref().unwrap_or("plain") { + "S256" => PkceCodeChallenge::S256(challenge), + "plain" if stateless_client.is_none() => { + PkceCodeChallenge::Plain(challenge) + } + _ => { + return Err(trc::AuthEvent::Error + .into_err() + .details("Unsupported PKCE code_challenge_method.")); + } + }, + None => { + if stateless_client.is_some() { + return Err(trc::AuthEvent::Error.into_err().details( + "A PKCE code_challenge with the S256 method is required.", + )); + } + PkceCodeChallenge::None + } + }; + + // Authenticate + match self + .authenticate(&AuthRequest { + credentials: Credentials::Basic { + username: account_name, + secret: account_secret, + mfa_token, + }, + session_id: session.session_id, + remote_ip: session.remote_ip, + }) + .await + { + Ok(access_token) => { + // Registry-backed clients are validated once the account is known + if stateless_client.is_none() + && self + .validate_client_registration( + &client_id, + Some(redirect_uri.as_str()), + access_token.account_id(), + ) + .await? + .is_some() + { + return Err(trc::AuthEvent::Error + .into_err() + .details("Invalid client registration.")); + } + + // Generate client code + let client_code = rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Serialize OAuth code + let value = Archiver::new(OAuthCode { + status: OAuthStatus::Authorized, + account_id: access_token.account_id(), + client_id, + nonce, + params: redirect_uri, + code_challenge: pkce_challenge, + scope: granted_scope, + resources: resource, + }) + .untrusted() + .serialize() + .caused_by(trc::location!())?; + + // Insert client code + self.in_memory_store() + .key_set( + KeyValue::with_prefix(KV_OAUTH, client_code.as_bytes(), value) + .expires(self.core.oauth.oauth_expiry_auth_code), + ) + .await?; + + LoginResponse::Authenticated { + client_code, + iss: self.core.network.http.url_https.clone(), + } + } + Err(err) => match *err.as_ref() { + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::MfaRequired + } + trc::EventType::Auth(_) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::Failure + } + trc::EventType::Security(_) => { + trc::error!(err.span_id(session.session_id)); + LoginResponse::Failure + } + _ => { + return Err(err); + } + }, + } + } + LoginRequest::AuthDevice { + account_name, + account_secret, + mfa_token, + code, + } => { + // Obtain code + let mut result = LoginResponse::Failure; + if let Some(auth_code_) = self + .in_memory_store() + .key_get::>(KeyValue::<()>::build_key( + KV_OAUTH, + code.as_bytes(), + )) + .await? + { + let oauth = auth_code_ + .unarchive::() + .caused_by(trc::location!())?; + if oauth.status == OAuthStatus::Pending { + // Authenticate + match self + .authenticate(&AuthRequest { + credentials: Credentials::Basic { + username: account_name, + secret: account_secret, + mfa_token, + }, + session_id: session.session_id, + remote_ip: session.remote_ip, + }) + .await + { + Ok(access_token) => { + let new_oauth_code = OAuthCode { + status: OAuthStatus::Authorized, + account_id: access_token.account_id(), + client_id: oauth.client_id.to_string(), + nonce: oauth.nonce.as_ref().map(|s| s.to_string()), + params: Default::default(), + code_challenge: PkceCodeChallenge::None, + scope: oauth.scope.as_ref().map(|s| s.to_string()), + resources: oauth + .resources + .iter() + .map(|s| s.to_string()) + .collect(), + }; + + // Delete issued user code + self.in_memory_store() + .key_delete(KeyValue::<()>::build_key( + KV_OAUTH, + code.as_bytes(), + )) + .await?; + + // Update device code status + self.in_memory_store() + .key_set( + KeyValue::with_prefix( + KV_OAUTH, + oauth.params.as_bytes(), + Archiver::new(new_oauth_code) + .untrusted() + .serialize() + .caused_by(trc::location!())?, + ) + .expires(self.core.oauth.oauth_expiry_auth_code), + ) + .await?; + + result = LoginResponse::Verified; + } + Err(err) => match *err.as_ref() { + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::MfaRequired; + } + trc::EventType::Auth(_) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::Failure; + } + trc::EventType::Security(_) => { + trc::error!(err.span_id(session.session_id)); + result = LoginResponse::Failure; + } + _ => { + return Err(err); + } + }, + } + } + } + + result + } + }; + + Ok(JsonResponse::new(response).no_cache().into_http_response()) + } + + async fn handle_device_auth( + &self, + req: &mut HttpRequest, + session: &HttpSessionData, + ) -> trc::Result { + // Parse form + let mut form_data = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?; + let client_id = form_data + .remove("client_id") + .filter(|client_id| client_id.len() <= CLIENT_ID_MAX_LEN) + .ok_or_else(|| { + trc::ResourceEvent::BadParameters + .into_err() + .details("Client ID is missing.") + })?; + let nonce = form_data.remove("nonce"); + let scope = form_data + .remove("scope") + .and_then(|scope| grant_scope(Some(&scope), u64::MAX)); + + // Generate device code + let device_code = rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Generate user code + let mut user_code = String::with_capacity(USER_CODE_LEN + 1); + for (pos, ch) in rng() + .sample_iter(StandardUniform) + .take(USER_CODE_LEN) + .map(|v: u64| char::from(USER_CODE_ALPHABET[v as usize % USER_CODE_ALPHABET.len()])) + .enumerate() + { + if pos == USER_CODE_LEN / 2 { + user_code.push('-'); + } + user_code.push(ch); + } + + // Add OAuth status + let oauth_code = Archiver::new(OAuthCode { + status: OAuthStatus::Pending, + account_id: u32::MAX, + client_id, + nonce, + params: device_code.clone(), + code_challenge: PkceCodeChallenge::None, + scope, + resources: Vec::new(), + }) + .untrusted() + .serialize() + .caused_by(trc::location!())?; + + // Insert device code + self.in_memory_store() + .key_set( + KeyValue::with_prefix(KV_OAUTH, device_code.as_bytes(), oauth_code.clone()) + .expires(self.core.oauth.oauth_expiry_user_code), + ) + .await?; + + // Insert user code + self.in_memory_store() + .key_set( + KeyValue::with_prefix(KV_OAUTH, user_code.as_bytes(), oauth_code) + .expires(self.core.oauth.oauth_expiry_user_code), + ) + .await?; + + // Build response + let base_url = &self.core.network.http.url_https; + Ok(JsonResponse::new(DeviceAuthResponse { + verification_uri: format!("{base_url}/device"), + verification_uri_complete: format!("{base_url}/device/?code={user_code}"), + device_code, + user_code, + expires_in: self.core.oauth.oauth_expiry_user_code, + interval: 5, + }) + .no_cache() + .into_http_response()) + } + + async fn handle_oauth_metadata(&self) -> trc::Result { + let base_url = &self.core.network.http.url_https; + + Ok(JsonResponse::new(OAuthMetadata { + authorization_endpoint: format!("{base_url}/login",), + token_endpoint: format!("{base_url}/auth/token"), + device_authorization_endpoint: format!("{base_url}/auth/device"), + introspection_endpoint: format!("{base_url}/auth/introspect"), + registration_endpoint: format!("{base_url}/auth/register"), + grant_types_supported: &[ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + ], + response_types_supported: &["code"], + scopes_supported: SUPPORTED_SCOPES, + token_endpoint_auth_methods_supported: &[ + "none", + "client_secret_post", + "client_secret_basic", + ], + code_challenge_methods_supported: &["S256"], + authorization_response_iss_parameter_supported: true, + issuer: base_url.to_string(), + }) + .into_http_response() + .with_cors_unrestricted()) + } + + async fn handle_oauth_protected_resource(&self) -> trc::Result { + let base_url = &self.core.network.http.url_https; + + Ok(JsonResponse::new(ProtectedResourceMetadata { + resource: base_url.to_string(), + authorization_servers: [base_url.to_string()], + scopes_supported: SUPPORTED_SCOPES, + bearer_methods_supported: &["header"], + }) + .into_http_response() + .with_cors_unrestricted()) + } +} + +fn grant_scope(requested: Option<&str>, registered_mask: u64) -> Option { + let mut granted = String::new(); + for scope in requested.unwrap_or_default().split_ascii_whitespace() { + let bit = scopes_to_mask(scope); + if bit != 0 && registered_mask & bit == bit { + if !granted.is_empty() { + granted.push(' '); + } + granted.push_str(scope); + } + } + + (!granted.is_empty()).then_some(granted) +} + +fn is_known_resource<'x>(hostnames: impl IntoIterator, uri: &str) -> bool { + let Some((scheme, rest)) = uri.split_once("://") else { + return false; + }; + let supported = hashify::tiny_map!(scheme.as_bytes(), + b"http" => true, + b"https" => true, + b"smtp" => true, + b"smtps" => true, + b"imap" => true, + b"imaps" => true, + b"pop3" => true, + b"pop3s" => true, + b"caldav" => true, + b"caldavs" => true, + b"webdav" => true, + b"webdavs" => true, + b"carddav" => true, + b"carddavs" => true, + b"sieve" => true, + b"sieves" => true + ) + .unwrap_or(false); + + let authority = rest.split_once('/').map_or(rest, |(auth, _)| auth); + let host = authority + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.as_bytes().iter().all(|c| c.is_ascii_digit())) + .map_or(authority, |(host, _)| host); + + supported + && hostnames + .into_iter() + .any(|hostname| host.eq_ignore_ascii_case(hostname)) +} diff --git a/crates/http/src/auth/oauth/mod.rs b/crates/http/src/auth/oauth/mod.rs new file mode 100644 index 0000000..d1b91c1 --- /dev/null +++ b/crates/http/src/auth/oauth/mod.rs @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use http_proto::{HttpRequest, request::fetch_body}; +use hyper::header::CONTENT_TYPE; +use serde::{Deserialize, Serialize}; +use utils::map::vec_map::VecMap; + +pub mod auth; +pub mod openid; +pub mod registration; +pub mod token; + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Copy, + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, +)] +#[rkyv(compare(PartialEq))] +pub enum OAuthStatus { + Authorized, + TokenIssued, + Pending, +} + +const MAX_POST_LEN: usize = 2048; + +pub struct OAuth { + pub key: String, + pub expiry_user_code: u64, + pub expiry_auth_code: u64, + pub expiry_token: u64, + pub expiry_refresh_token: u64, + pub expiry_refresh_token_renew: u64, + pub max_auth_attempts: u32, + pub metadata: String, +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)] +pub struct OAuthCode { + pub status: OAuthStatus, + pub account_id: u32, + pub client_id: String, + pub nonce: Option, + pub params: String, + pub code_challenge: PkceCodeChallenge, + pub scope: Option, + pub resources: Vec, +} + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, +)] +#[rkyv(compare(PartialEq))] +pub enum PkceCodeChallenge { + None, + S256(String), + Plain(String), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthGet { + code: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthPost { + code: Option, + email: Option, + password: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthRequest { + client_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceAuthResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodeAuthRequest { + response_type: String, + client_id: String, + redirect_uri: String, + scope: Option, + state: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodeAuthForm { + code: String, + email: Option, + password: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: Option, + pub device_code: Option, + pub client_id: Option, + pub refresh_token: Option, + pub redirect_uri: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum TokenResponse { + Granted(OAuthResponse), + Error { error: ErrorType }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct OAuthResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ErrorType { + #[serde(rename = "invalid_grant")] + InvalidGrant, + #[serde(rename = "invalid_client")] + InvalidClient, + #[serde(rename = "invalid_scope")] + InvalidScope, + #[serde(rename = "invalid_request")] + InvalidRequest, + #[serde(rename = "unauthorized_client")] + UnauthorizedClient, + #[serde(rename = "unsupported_grant_type")] + UnsupportedGrantType, + #[serde(rename = "authorization_pending")] + AuthorizationPending, + #[serde(rename = "slow_down")] + SlowDown, + #[serde(rename = "access_denied")] + AccessDenied, + #[serde(rename = "expired_token")] + ExpiredToken, +} + +impl TokenResponse { + pub fn error(error: ErrorType) -> Self { + TokenResponse::Error { error } + } + + pub fn is_error(&self) -> bool { + matches!(self, TokenResponse::Error { .. }) + } +} + +#[derive(Debug)] +pub struct FormData { + fields: VecMap, +} + +impl FormData { + pub async fn from_request( + req: &mut HttpRequest, + max_len: usize, + session_id: u64, + ) -> trc::Result { + match ( + req.headers() + .get(CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .and_then(|val| val.parse::().ok()), + fetch_body(req, max_len, session_id).await, + ) { + (Some(content_type), Some(body)) => { + let mut fields = VecMap::new(); + if let Some(boundary) = content_type.get_param(mime::BOUNDARY) { + for mut field in + form_data::FormData::new(&body[..], boundary.as_str()).flatten() + { + let value = String::from_utf8_lossy(&field.bytes().unwrap_or_default()) + .into_owned(); + fields.append(field.name, value); + } + } else { + for (key, value) in http_proto::form_urlencoded::parse(&body) { + fields.append(key.into_owned(), value.into_owned()); + } + } + Ok(FormData { fields }) + } + _ => Err(trc::ResourceEvent::BadParameters + .into_err() + .details("Invalid post request")), + } + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.fields.get(key).map(|v| v.as_str()) + } + + pub fn remove(&mut self, key: &str) -> Option { + self.fields.remove(key) + } + + pub fn has_field(&self, key: &str) -> bool { + self.fields.get(key).is_some_and(|v| !v.is_empty()) + } + + pub fn fields(&self) -> impl Iterator { + self.fields.iter() + } +} diff --git a/crates/http/src/auth/oauth/openid.rs b/crates/http/src/auth/oauth/openid.rs new file mode 100644 index 0000000..3407f51 --- /dev/null +++ b/crates/http/src/auth/oauth/openid.rs @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::oauth::SUPPORTED_SCOPES, auth::oauth::oidc::Userinfo}; +use http_proto::*; +use serde::Serialize; +use std::future::Future; + +#[derive(Debug, Serialize)] +pub struct OpenIdMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, + pub jwks_uri: String, + pub registration_endpoint: String, + pub device_authorization_endpoint: String, + pub scopes_supported: &'static [&'static str], + pub response_types_supported: &'static [&'static str], + pub subject_types_supported: &'static [&'static str], + pub grant_types_supported: &'static [&'static str], + pub token_endpoint_auth_methods_supported: &'static [&'static str], + pub id_token_signing_alg_values_supported: &'static [&'static str], + pub claims_supported: &'static [&'static str], + pub code_challenge_methods_supported: &'static [&'static str], + pub authorization_response_iss_parameter_supported: bool, +} + +pub trait OpenIdHandler: Sync + Send { + fn handle_userinfo_request( + &self, + account_id: u32, + ) -> impl Future> + Send; + + fn handle_oidc_metadata( + &self, + strip_base_url: bool, + ) -> impl Future> + Send; +} + +impl OpenIdHandler for Server { + async fn handle_userinfo_request(&self, account_id: u32) -> trc::Result { + let account = self.account(account_id).await?; + + Ok(JsonResponse::new(Userinfo { + sub: Some(account_id.to_string()), + name: account.description().map(|d| d.to_string()), + preferred_username: Some(account.name().to_string()), + email: account.name().to_string().into(), + email_verified: true, + ..Default::default() + }) + .no_cache() + .into_http_response()) + } + + async fn handle_oidc_metadata(&self, strip_base_url: bool) -> trc::Result { + let base_url = if strip_base_url { + #[cfg(feature = "dev_mode")] + { + "http://127.0.0.1:8080" + } + + #[cfg(not(feature = "dev_mode"))] + { + "" + } + } else { + &self.core.network.http.url_https + }; + + Ok(JsonResponse::new(OpenIdMetadata { + authorization_endpoint: format!("{base_url}/login",), + token_endpoint: format!("{base_url}/auth/token"), + userinfo_endpoint: format!("{base_url}/auth/userinfo"), + jwks_uri: format!("{base_url}/auth/jwks.json"), + registration_endpoint: format!("{base_url}/auth/register"), + device_authorization_endpoint: format!("{base_url}/auth/device"), + response_types_supported: &["code"], + grant_types_supported: &[ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + ], + scopes_supported: SUPPORTED_SCOPES, + subject_types_supported: &["public"], + token_endpoint_auth_methods_supported: &[ + "none", + "client_secret_post", + "client_secret_basic", + ], + id_token_signing_alg_values_supported: &[ + "RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "HS256", + "HS384", "HS512", + ], + claims_supported: &[ + "sub", + "name", + "preferred_username", + "email", + "email_verified", + ], + code_challenge_methods_supported: &["S256"], + authorization_response_iss_parameter_supported: true, + issuer: base_url.to_string(), + }) + .into_http_response() + .with_cors_unrestricted()) + } +} diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs new file mode 100644 index 0000000..c1c56f7 --- /dev/null +++ b/crates/http/src/auth/oauth/registration.rs @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ErrorType; +use crate::auth::authenticate::Authenticator; +use common::{ + Server, + auth::{ + BuildAccessToken, + oauth::{ + client_id::{ClientMeta, decode_client_id, encode_client_id, scopes_to_mask}, + registration::{ + ClientRegistrationError, ClientRegistrationRequest, ClientRegistrationResponse, + TokenEndpointAuthMethod, redirect_uri_matches, validate_grant_metadata, + validate_redirect_uri, + }, + }, + }, +}; +use directory::core::secret::{hash_secret, verify_secret_hash}; +use http_proto::{request::fetch_body, *}; +use hyper::StatusCode; +use registry::schema::{ + enums::{PasswordHashAlgorithm, Permission}, + prelude::{ObjectType, Property, UTCDateTime}, + structs::OAuthClient, +}; +use std::future::Future; +use store::{ + rand::{RngExt, distr::Alphanumeric, rng}, + registry::write::{RegistryWrite, RegistryWriteResult}, + write::now, +}; +use trc::{AddContext, AuthEvent}; +use types::id::Id; + +pub trait ClientRegistrationHandler: Sync + Send { + fn handle_oauth_registration_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> impl Future> + Send; + + fn validate_client_registration( + &self, + client_id: &str, + redirect_uri: Option<&str>, + account_id: u32, + ) -> impl Future>> + Send; + + fn verify_client_secret( + &self, + client_id: &str, + client_secret: Option<&str>, + ) -> impl Future>> + Send; +} +impl ClientRegistrationHandler for Server { + async fn handle_oauth_registration_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + // Parse request + let body = fetch_body(req, 20 * 1024, session.session_id).await; + let request = serde_json::from_slice::( + body.as_deref().unwrap_or_default(), + ) + .map_err(|err| { + trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) + })?; + + // Validate redirect URIs and grant metadata (RFC 7591 + OAuth Public Clients profile) + if request.redirect_uris.is_empty() { + return Ok(registration_error( + ClientRegistrationError::invalid_redirect_uri( + "At least one redirect URI is required.", + ), + )); + } + for uri in &request.redirect_uris { + if let Err(err) = validate_redirect_uri(uri) { + return Ok(registration_error(err)); + } + } + if let Err(err) = validate_grant_metadata(&request) { + return Ok(registration_error(err)); + } + + let is_public = matches!( + request.token_endpoint_auth_method, + None | Some(TokenEndpointAuthMethod::None) + ); + + if is_public { + // Public client: issue a stateless, self-describing client id with no database write + if self.core.oauth.allow_anonymous_client_registration { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + } else { + let (_, access_token) = self.authenticate_headers(req, &session).await?; + access_token.enforce_permission(Permission::OAuthClientRegistration)?; + } + + let client_id = encode_client_id( + self.core.oauth.oauth_key.as_bytes(), + &ClientMeta { + redirect_uris: request.redirect_uris.clone(), + scope_mask: scopes_to_mask(request.scope.as_deref().unwrap_or_default()), + client_name: request.client_name.clone(), + }, + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details("Failed to encode client id.") + .reason(err) + .caused_by(trc::location!()) + })?; + + trc::event!( + Auth(AuthEvent::ClientRegistration), + Id = client_id.clone(), + RemoteIp = session.remote_ip + ); + + return Ok(JsonResponse::with_status( + StatusCode::CREATED, + ClientRegistrationResponse { + client_id_issued_at: Some(now()), + client_id, + request, + ..Default::default() + }, + ) + .no_cache() + .into_http_response()); + } + + // Confidential client: authenticate and persist the registration + let (_, access_token) = self.authenticate_headers(req, &session).await?; + access_token.enforce_permission(Permission::OAuthClientRegistration)?; + let tenant_id = access_token.tenant_id(); + + // Generate client ID + let client_id = rng() + .sample_iter(Alphanumeric) + .take(20) + .map(|ch| char::from(ch.to_ascii_lowercase())) + .collect::(); + + // Generate client secret + let client_secret = rng() + .sample_iter(Alphanumeric) + .take(48) + .map(char::from) + .collect::(); + let secret_hash = hash_secret( + PasswordHashAlgorithm::Argon2id, + client_secret.clone().into_bytes(), + ) + .await + .caused_by(trc::location!())?; + + let result = self + .registry() + .write(RegistryWrite::insert( + &OAuthClient { + client_id: client_id.clone(), + description: request.client_name.clone(), + contacts: request.contacts.clone().into(), + member_tenant_id: tenant_id.map(|id| Id::new(id as u64)), + redirect_uris: request.redirect_uris.clone().into(), + logo: request.logo_uri.clone(), + secret: Some(secret_hash), + created_at: UTCDateTime::now(), + ..Default::default() + } + .into(), + )) + .await + .caused_by(trc::location!())?; + + if !matches!(result, RegistryWriteResult::Success(_)) { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to register OAuth client.") + .reason(result.to_string()) + .caused_by(trc::location!())); + } + + trc::event!( + Auth(AuthEvent::ClientRegistration), + Id = client_id.to_string(), + RemoteIp = session.remote_ip + ); + + Ok(JsonResponse::with_status( + StatusCode::CREATED, + ClientRegistrationResponse { + client_id, + client_secret: Some(client_secret), + client_id_issued_at: Some(now()), + client_secret_expires_at: Some(0), + request, + ..Default::default() + }, + ) + .no_cache() + .into_http_response()) + } + + async fn validate_client_registration( + &self, + client_id: &str, + redirect_uri: Option<&str>, + account_id: u32, + ) -> trc::Result> { + // Stateless client ids are self-describing and validated at the authorization endpoint + if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() { + return Ok(None); + } + if !self.core.oauth.require_client_authentication { + return Ok(None); + } + + // Fetch client registration + let found_registration = if let Some(client_id) = self + .registry() + .primary_key( + ObjectType::OAuthClient.into(), + Property::ClientId, + client_id.as_bytes().to_vec(), + ) + .await? + { + if let Some(redirect_uri) = redirect_uri { + let client = self + .registry() + .object::(client_id.id()) + .await? + .ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("OAuth client not found.") + .caused_by(trc::location!()) + .ctx(trc::Key::Id, client_id.id().id()) + })?; + if client + .redirect_uris + .iter() + .any(|uri| redirect_uri_matches(uri, redirect_uri)) + { + return Ok(None); + } + } else { + // Device flow does not require a redirect URI + + return Ok(None); + } + + true + } else { + false + }; + + // Check if the account is allowed to override client registration + if self + .access_token(account_id) + .await + .caused_by(trc::location!())? + .build() + .has_permission(Permission::OAuthClientOverride) + { + return Ok(None); + } + + Ok(Some(if found_registration { + ErrorType::InvalidClient + } else { + ErrorType::InvalidRequest + })) + } + + async fn verify_client_secret( + &self, + client_id: &str, + client_secret: Option<&str>, + ) -> trc::Result> { + // Stateless and unregistered clients have no secret to verify + if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() { + return Ok(None); + } + let Some(client_id) = self + .registry() + .primary_key( + ObjectType::OAuthClient.into(), + Property::ClientId, + client_id.as_bytes().to_vec(), + ) + .await? + else { + return Ok(None); + }; + let Some(client) = self + .registry() + .object::(client_id.id()) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + + match client.secret.as_deref() { + Some(hash) if !hash.is_empty() => match client_secret { + Some(secret) + if verify_secret_hash(hash, secret.as_bytes()) + .await + .caused_by(trc::location!())? => + { + Ok(None) + } + _ => Ok(Some(ErrorType::InvalidClient)), + }, + _ => Ok(None), + } + } +} + +fn registration_error(error: ClientRegistrationError) -> HttpResponse { + JsonResponse::with_status(StatusCode::BAD_REQUEST, error) + .no_cache() + .into_http_response() +} diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs new file mode 100644 index 0000000..5b49980 --- /dev/null +++ b/crates/http/src/auth/oauth/token.rs @@ -0,0 +1,439 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedOAuthStatus, ArchivedPkceCodeChallenge, ErrorType, FormData, MAX_POST_LEN, OAuthCode, + OAuthResponse, OAuthStatus, TokenResponse, registration::ClientRegistrationHandler, +}; +use crate::auth::authenticate::HttpHeaders; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use common::{ + KV_OAUTH, Server, + auth::{ + AccessToken, + oauth::{GrantType, oidc::StandardClaims}, + }, +}; +use http_proto::*; +use hyper::StatusCode; +use sha2::{Digest, Sha256}; +use std::{borrow::Cow, future::Future}; +use store::{ + dispatch::lookup::KeyValue, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; + +pub trait TokenHandler: Sync + Send { + fn handle_token_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> impl Future> + Send; + + fn handle_token_introspect( + &self, + req: &mut HttpRequest, + access_token: &AccessToken, + session_id: u64, + ) -> impl Future> + Send; + + #[allow(clippy::too_many_arguments)] + fn issue_token( + &self, + account_id: u32, + client_id: &str, + issuer: String, + nonce: Option, + scope: Option, + with_refresh_token: bool, + with_id_token: bool, + ) -> impl Future> + Send; +} + +impl TokenHandler for Server { + // Token endpoint + async fn handle_token_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + // Parse form + let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?; + let grant_type = params.get("grant_type").unwrap_or_default(); + let (client_id_cred, client_secret_cred) = client_credentials(req, ¶ms); + + let mut response = TokenResponse::error(ErrorType::InvalidGrant); + + let issuer = self.core.network.http.url_https.to_string(); + + if grant_type.eq_ignore_ascii_case("authorization_code") { + response = if let (Some(code), Some(client_id), Some(redirect_uri)) = ( + params.get("code"), + client_id_cred.as_deref(), + params.get("redirect_uri"), + ) { + // Obtain code + match self + .in_memory_store() + .key_get::>(KeyValue::<()>::build_key( + KV_OAUTH, + code.as_bytes(), + )) + .await? + { + Some(auth_code_) => { + let oauth = auth_code_ + .unarchive::() + .caused_by(trc::location!())?; + if client_id != oauth.client_id || redirect_uri != oauth.params { + TokenResponse::error(ErrorType::InvalidClient) + } else if !verify_pkce(&oauth.code_challenge, params.get("code_verifier")) { + TokenResponse::error(ErrorType::InvalidGrant) + } else if oauth.status == OAuthStatus::Authorized { + // Validate client id + if let Some(error) = self + .validate_client_registration( + client_id, + redirect_uri.into(), + oauth.account_id.into(), + ) + .await? + { + TokenResponse::error(error) + } else if let Some(error) = self + .verify_client_secret(client_id, client_secret_cred.as_deref()) + .await? + { + TokenResponse::error(error) + } else { + // Mark this token as issued + self.in_memory_store() + .key_delete(KeyValue::<()>::build_key( + KV_OAUTH, + code.as_bytes(), + )) + .await?; + + // Issue token + self.issue_token( + oauth.account_id.into(), + &oauth.client_id, + issuer, + oauth.nonce.as_ref().map(|s| s.as_str().into()), + oauth.scope.as_ref().map(|s| s.as_str().into()), + true, + true, + ) + .await + .map(TokenResponse::Granted) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details(err) + .caused_by(trc::location!()) + })? + } + } else { + TokenResponse::error(ErrorType::InvalidGrant) + } + } + None => TokenResponse::error(ErrorType::AccessDenied), + } + } else { + TokenResponse::error(ErrorType::InvalidClient) + }; + } else if grant_type.eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") { + response = TokenResponse::error(ErrorType::ExpiredToken); + + if let (Some(device_code), Some(client_id)) = + (params.get("device_code"), params.get("client_id")) + { + // Obtain code + if let Some(auth_code_) = self + .in_memory_store() + .key_get::>(KeyValue::<()>::build_key( + KV_OAUTH, + device_code.as_bytes(), + )) + .await? + { + let oauth = auth_code_ + .unarchive::() + .caused_by(trc::location!())?; + response = if oauth.client_id != client_id { + TokenResponse::error(ErrorType::InvalidClient) + } else { + match oauth.status { + ArchivedOAuthStatus::Authorized => { + if let Some(error) = self + .validate_client_registration( + client_id, + None, + oauth.account_id.into(), + ) + .await? + { + TokenResponse::error(error) + } else { + // Mark this token as issued + self.in_memory_store() + .key_delete(KeyValue::<()>::build_key( + KV_OAUTH, + device_code.as_bytes(), + )) + .await?; + + // Issue token + self.issue_token( + oauth.account_id.into(), + &oauth.client_id, + issuer, + oauth.nonce.as_ref().map(|s| s.as_str().into()), + oauth.scope.as_ref().map(|s| s.as_str().into()), + true, + true, + ) + .await + .map(TokenResponse::Granted) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details(err) + .caused_by(trc::location!()) + })? + } + } + ArchivedOAuthStatus::Pending => { + TokenResponse::error(ErrorType::AuthorizationPending) + } + ArchivedOAuthStatus::TokenIssued => { + TokenResponse::error(ErrorType::ExpiredToken) + } + } + }; + } + } + } else if grant_type.eq_ignore_ascii_case("refresh_token") { + if let Some(refresh_token) = params.get("refresh_token") { + if let Some(client_id) = client_id_cred.as_deref() + && let Some(error) = self + .verify_client_secret(client_id, client_secret_cred.as_deref()) + .await? + { + return Ok(JsonResponse::with_status( + StatusCode::BAD_REQUEST, + TokenResponse::error(error), + ) + .into_http_response()); + } + response = match self + .validate_access_token(GrantType::RefreshToken.into(), refresh_token) + .await + { + Ok(token_info) => self + .issue_token( + token_info.account_id, + "", + issuer, + None, + None, + token_info.expires_in + <= self.core.oauth.oauth_expiry_refresh_token_renew, + false, + ) + .await + .map(TokenResponse::Granted) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details(err) + .caused_by(trc::location!()) + })?, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to validate refresh token") + .span_id(session.session_id) + ); + TokenResponse::error(ErrorType::InvalidGrant) + } + }; + } else { + response = TokenResponse::error(ErrorType::InvalidRequest); + } + } + + Ok(JsonResponse::with_status( + if response.is_error() { + StatusCode::BAD_REQUEST + } else { + StatusCode::OK + }, + response, + ) + .into_http_response()) + } + + async fn handle_token_introspect( + &self, + req: &mut HttpRequest, + access_token: &AccessToken, + session_id: u64, + ) -> trc::Result { + // Parse token + let token = FormData::from_request(req, 1024, session_id) + .await? + .remove("token") + .ok_or_else(|| { + trc::ResourceEvent::BadParameters + .into_err() + .details("Client ID is missing.") + })?; + + self.introspect_access_token(&token, access_token) + .await + .map(|response| JsonResponse::new(response).no_cache().into_http_response()) + } + + async fn issue_token( + &self, + account_id: u32, + client_id: &str, + issuer: String, + nonce: Option, + scope: Option, + with_refresh_token: bool, + with_id_token: bool, + ) -> trc::Result { + let credential_version = self + .access_token(account_id) + .await + .caused_by(trc::location!())? + .credential_version(); + let account = self.account(account_id).await.caused_by(trc::location!())?; + let account_name = account.name(); + + Ok(OAuthResponse { + access_token: self + .encode_access_token( + GrantType::AccessToken, + account_id, + account_name, + self.core.oauth.oauth_expiry_token, + None, + credential_version.into(), + ) + .await?, + token_type: "bearer".to_string(), + expires_in: self.core.oauth.oauth_expiry_token, + refresh_token: if with_refresh_token { + self.encode_access_token( + GrantType::RefreshToken, + account_id, + account_name, + self.core.oauth.oauth_expiry_refresh_token, + None, + credential_version.into(), + ) + .await? + .into() + } else { + None + }, + id_token: if with_id_token { + match self.issue_id_token( + account_id.to_string(), + issuer, + client_id, + StandardClaims { + nonce, + preferred_username: account.name().to_string().into(), + email: account.name().to_string().into(), + description: account.description().map(|d| d.to_string()), + }, + ) { + Ok(id_token) => Some(id_token), + Err(err) => { + trc::error!(err); + None + } + } + } else { + None + }, + scope, + }) + } +} + +fn client_credentials<'x>( + req: &'x HttpRequest, + params: &'x FormData, +) -> (Option>, Option>) { + let mut client_id = params.get("client_id").map(Cow::Borrowed); + let mut client_secret = params.get("client_secret").map(Cow::Borrowed); + + if (client_id.is_none() || client_secret.is_none()) + && let Some((id, secret)) = req + .authorization_basic() + .and_then(|token| STANDARD.decode(token).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|creds| { + creds + .split_once(':') + .map(|(id, secret)| (id.to_string(), secret.to_string())) + }) + { + if client_id.is_none() { + client_id = Some(Cow::Owned(id)); + } + if client_secret.is_none() { + client_secret = Some(Cow::Owned(secret)); + } + } + + (client_id, client_secret) +} + +fn verify_pkce(stored: &ArchivedPkceCodeChallenge, verifier: Option<&str>) -> bool { + let is_valid_pkce_challenge = |challenge: &str| { + (43..=128).contains(&challenge.len()) + && challenge + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')) + }; + let constant_time_eq = |a: &[u8], b: &[u8]| { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 + }; + + match (stored, verifier) { + (ArchivedPkceCodeChallenge::None, None) => true, + (ArchivedPkceCodeChallenge::Plain(expected), Some(verifier)) + if is_valid_pkce_challenge(verifier) => + { + constant_time_eq(expected.as_bytes(), verifier.as_bytes()) + } + (ArchivedPkceCodeChallenge::S256(expected), Some(verifier)) + if is_valid_pkce_challenge(verifier) => + { + let digest = Sha256::digest(verifier.as_bytes()); + let computed = URL_SAFE_NO_PAD.encode(digest); + constant_time_eq(expected.as_bytes(), computed.as_bytes()) + } + _ => false, + } +} diff --git a/crates/http/src/auth/permissions.rs b/crates/http/src/auth/permissions.rs new file mode 100644 index 0000000..fd86613 --- /dev/null +++ b/crates/http/src/auth/permissions.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + auth::{AccessToken, RECOVERY_ADMIN_ID, permissions::PermissionsListBuilder}, +}; +use http_proto::{HttpResponse, JsonResponse, ToHttpResponse}; +use registry::{ + schema::enums::{Locale, Permission}, + types::EnumImpl, +}; +use serde::Serialize; +use utils::DomainPart; + +#[derive(Debug, Clone, Serialize)] +pub struct Account { + pub permissions: Vec, + pub edition: &'static str, + pub locale: Locale, +} + +pub trait AccountApiHandler: Sync + Send { + fn handle_account_request( + &self, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl AccountApiHandler for Server { + async fn handle_account_request( + &self, + access_token: &AccessToken, + ) -> trc::Result { + #[cfg(not(feature = "enterprise"))] + let edition = "oss"; + + + let account_info = self.account_info(access_token.account_id()).await?; + let is_external_directory = if let Some(domain_name) = account_info.name().try_domain_part() + && self.get_directory_for_domain(domain_name).await?.is_some() + { + true + } else { + false + }; + let is_recovery_admin = access_token.account_id() == RECOVERY_ADMIN_ID; + let permissions = if !self.registry().is_bootstrap_mode() + && let Some(scope) = access_token.access_scope() + { + let mut permissions = scope.permissions.clone(); + + for p in [ + Permission::SysDmarcInternalReportUpdate, + Permission::SysDmarcInternalReportCreate, + Permission::SysDmarcExternalReportUpdate, + Permission::SysDmarcExternalReportCreate, + Permission::SysTlsInternalReportUpdate, + Permission::SysTlsInternalReportCreate, + Permission::SysTlsExternalReportUpdate, + Permission::SysTlsExternalReportCreate, + Permission::SysArfExternalReportCreate, + Permission::SysArfExternalReportUpdate, + Permission::SysQueuedMessageCreate, + Permission::SysLogCreate, + Permission::SysLogDestroy, + Permission::SysLogUpdate, + Permission::SysClusterNodeCreate, + Permission::SysClusterNodeUpdate, + Permission::SysClusterNodeDestroy, + Permission::SysBootstrapGet, + Permission::SysBootstrapUpdate, + ] { + permissions.clear(p.to_id() as usize); + } + + if !self.core.groupware.allow_directory_query { + for p in [ + Permission::JmapPrincipalQuery, + Permission::JmapPrincipalQueryChanges, + Permission::JmapPrincipalGet, + Permission::JmapPrincipalGetAvailability, + Permission::JmapPrincipalChanges, + ] { + permissions.clear(p.to_id() as usize); + } + } + + if is_external_directory || is_recovery_admin { + permissions.clear(Permission::SysAccountPasswordGet.to_id() as usize); + permissions.clear(Permission::SysAccountPasswordUpdate.to_id() as usize); + } + + if is_recovery_admin { + for p in [ + Permission::SysAccountSettingsGet, + Permission::SysAccountSettingsUpdate, + Permission::SysApiKeyCreate, + Permission::SysApiKeyUpdate, + Permission::SysApiKeyDestroy, + Permission::SysApiKeyQuery, + Permission::SysApiKeyGet, + Permission::SysAppPasswordCreate, + Permission::SysAppPasswordUpdate, + Permission::SysAppPasswordDestroy, + Permission::SysAppPasswordQuery, + Permission::SysAppPasswordGet, + ] { + permissions.clear(p.to_id() as usize); + } + } + + permissions.build_permissions_list() + } else if self.registry().is_bootstrap_mode() { + vec![Permission::SysBootstrapGet, Permission::SysBootstrapUpdate] + } else { + Vec::new() + }; + + Ok(JsonResponse::new(Account { + permissions, + edition, + locale: account_info.locale(), + }) + .into_http_response()) + } +} diff --git a/crates/http/src/form/mod.rs b/crates/http/src/form/mod.rs new file mode 100644 index 0000000..d727dfe --- /dev/null +++ b/crates/http/src/form/mod.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::auth::oauth::FormData; +use chrono::Utc; +use common::{ + KV_RATE_LIMIT_CONTACT, Server, + config::network::{ContactForm, FieldOrDefault}, + network::ip_to_bytes, + psl, +}; +use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use http_proto::*; +use hyper::StatusCode; +use mail_auth::common::cache::NoCache; +use mail_builder::{ + MessageBuilder, + headers::{ + HeaderType, + address::{Address, EmailAddress}, + }, + mime::make_boundary, +}; +use serde_json::json; +use std::{borrow::Cow, fmt::Write, future::Future}; +use store::write::BatchBuilder; +use trc::AddContext; + +pub trait FormHandler: Sync + Send { + fn handle_contact_form( + &self, + session: &HttpSessionData, + form: &ContactForm, + form_data: FormData, + ) -> impl Future> + Send; +} + +impl FormHandler for Server { + async fn handle_contact_form( + &self, + session: &HttpSessionData, + form: &ContactForm, + form_data: FormData, + ) -> trc::Result { + // Validate rate + if let Some(rate) = &form.rate + && !session.remote_ip.is_loopback() + && self + .in_memory_store() + .is_rate_allowed( + KV_RATE_LIMIT_CONTACT, + &ip_to_bytes(&session.remote_ip), + rate, + false, + ) + .await + .caused_by(trc::location!())? + .is_some() + { + return Err(trc::LimitEvent::TooManyRequests.into_err()); + } + + // Validate honeypot + if form + .field_honey_pot + .as_ref() + .is_some_and(|field| form_data.has_field(field)) + { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("Honey pot field present")); + } + + // Obtain fields + let from_email = form_data + .get_or_default(&form.from_email) + .trim() + .to_lowercase(); + let from_subject = form_data.get_or_default(&form.from_subject).trim(); + let from_name = form_data.get_or_default(&form.from_name).trim(); + + // Validate email + let mut failure = None; + let mut has_success = false; + if form.validate_domain && from_email != form.from_email.default { + if let Some(domain) = from_email.rsplit_once('@').and_then(|(local, domain)| { + if !local.is_empty() + && domain.contains('.') + && psl::domain(domain.as_bytes()).is_some_and(|d| d.suffix().typ().is_some()) + { + Some(domain) + } else { + None + } + }) { + if self + .core + .smtp + .resolvers + .dns + .mx_lookup(domain, None::<&NoCache<_, _>>) + .await + .is_err() + { + failure = Some(format!("No MX records found for domain {domain:?}. Please enter a valid email address.", ).into()); + } + } else { + failure = Some(Cow::Borrowed("Please enter a valid email address.")); + } + } + + // Discard empty forms + if failure.is_none() && form_data.fields().all(|(_, value)| value.trim().is_empty()) { + failure = Some(Cow::Borrowed("Empty form")); + } + + if failure.is_none() { + // Build body + let mut body = String::with_capacity(1024); + for (field, value) in form_data.fields() { + if !value.is_empty() { + body.push_str(field); + body.push_str(": "); + body.push_str(value); + body.push_str("\r\n"); + } + } + let _ = write!( + &mut body, + "Date: {}\r\n", + Utc::now().format("%a, %d %b %Y %T %z") + ); + let _ = write!( + &mut body, + "IP: {}:{}\r\n", + session.remote_ip, session.remote_port + ); + + // Build message + let message = MessageBuilder::new() + .from((from_name, from_email.as_str())) + .header( + "To", + HeaderType::Address(Address::List( + form.rcpt_to + .iter() + .map(|rcpt| { + Address::Address(EmailAddress { + name: None, + email: rcpt.into(), + }) + }) + .collect(), + )), + ) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .message_id(format!( + "{}@{}", + make_boundary("."), + self.core.network.server_name + )) + .subject(from_subject) + .text_body(body) + .write_to_vec() + .unwrap_or_default(); + + // Reserve and write blob + let (message_blob, blob_hold) = self + .put_temporary_blob(u32::MAX, &message, 60) + .await + .caused_by(trc::location!())?; + + for result in self + .deliver_message(IngestMessage { + sender_address: from_email, + sender_authenticated: false, + recipients: form + .rcpt_to + .iter() + .map(|address| IngestRecipient { + address: address.clone(), + orcpt: None, + spam_percentage: None, + }) + .collect(), + message_blob, + message_size: message.len() as u64, + session_id: session.session_id, + }) + .await + .status + { + match result { + LocalDeliveryStatus::Success => { + has_success = true; + } + LocalDeliveryStatus::TemporaryFailure { reason } + | LocalDeliveryStatus::PermanentFailure { reason, .. } => { + failure = Some(reason) + } + } + } + + // Remove blob hold + let mut batch = BatchBuilder::new(); + batch.clear(blob_hold); + self.store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + // Suppress errors if there is at least one success + if has_success { + failure = None; + } + } + + Ok(JsonResponse::with_status( + if has_success { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }, + json!({ + "data": { + "success": has_success, + "details": failure, + }, + }), + ) + .into_http_response()) + } +} + +impl FormData { + pub fn get_or_default<'x>(&'x self, field: &'x FieldOrDefault) -> &'x str { + if let Some(field_name) = &field.field { + self.get(field_name) + .filter(|f| !f.is_empty()) + .unwrap_or(field.default.as_str()) + } else { + field.default.as_str() + } + } +} diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs new file mode 100644 index 0000000..f5adc5b --- /dev/null +++ b/crates/http/src/lib.rs @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod api; +pub mod auth; +pub mod form; +pub mod request; + +use common::Inner; +use std::sync::Arc; + +#[derive(Clone)] +pub struct HttpSessionManager { + pub inner: Arc, +} + +impl HttpSessionManager { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs new file mode 100644 index 0000000..1913f9a --- /dev/null +++ b/crates/http/src/request.rs @@ -0,0 +1,882 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + HttpSessionManager, + api::{AuthChallenge, ManagementApi, ToManageHttpResponse}, + auth::{ + authenticate::{Authenticator, HttpHeaders}, + oauth::{ + FormData, auth::OAuthApiHandler, openid::OpenIdHandler, + registration::ClientRegistrationHandler, token::TokenHandler, + }, + }, + form::FormHandler, +}; +use common::{ + BuildServer, Inner, KV_ACME, Server, + ipc::PushEvent, + manager::application::Resource, + network::{SessionData, SessionManager, SessionStream}, +}; +use dav::{DavMethod, request::DavRequestHandler}; +use groupware::DavResourceName; +use http_proto::{ + DownloadResponse, HttpContext, HttpRequest, HttpResponse, HttpResponseBody, HttpSessionData, + JsonProblemResponse, ToHttpResponse, form_urlencoded, request::fetch_body, +}; +use hyper::{ + Method, StatusCode, body, + header::{self, CONTENT_ENCODING, CONTENT_TYPE}, + server::conn::http1, + service::service_fn, +}; +use hyper_util::rt::TokioIo; +use jmap::{ + api::{ + ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler, + session::SessionHandler, + }, + blob::{download::BlobDownload, upload::BlobUpload}, + websocket::upgrade::WebSocketUpgrade, +}; +use jmap_proto::request::{Request, capability::Session}; +use percent_encoding::percent_decode_str; +use registry::schema::enums::Permission; +use std::{net::IpAddr, str::FromStr, sync::Arc}; +use store::dispatch::lookup::KeyValue; +use trc::SecurityEvent; +use types::{blob::BlobId, id::Id}; + +static RSVP_PAGE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-rsvp.html.min.gz" +)); + +static LOGIN_PAGE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/login.html.min.gz" +)); + +pub trait ParseHttp: Sync + Send { + fn parse_http_request( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> impl Future> + Send; +} + +impl ParseHttp for Server { + async fn parse_http_request( + &self, + mut req: HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + let mut path = req.uri().path().split('/'); + path.next(); + + // Validate endpoint access + let ctx = HttpContext::new(&session, &req); + match ctx.has_endpoint_access(self).await { + StatusCode::OK => (), + status => { + // Allow loopback address to avoid lockouts + if !session.remote_ip.is_loopback() { + return Ok(JsonProblemResponse(status).into_http_response()); + } + } + } + + match path.next().unwrap_or_default() { + "jmap" => { + match (path.next().unwrap_or_default(), req.method()) { + ("", &Method::POST) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + if let Some(content_type) = req.headers().get(CONTENT_TYPE) { + let is_json = content_type + .to_str() + .ok() + .map(|ct| { + ct.split_once(';') + .map_or(ct, |(m, _)| m) + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !is_json { + return Err(trc::JmapEvent::NotJson + .into_err() + .details("The Content-Type header must be application/json.")); + } + } + + let bytes = fetch_body( + &mut req, + if !access_token.has_permission(Permission::UnlimitedUploads) { + self.core.jmap.upload_max_size + } else { + 0 + }, + session.session_id, + ) + .await + .ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?; + + return Ok(self + .handle_jmap_request( + Request::parse( + &bytes, + self.core.jmap.request_max_calls, + self.core.jmap.request_max_size, + )?, + &access_token, + &session, + ) + .await + .into_http_response()); + } + ("download", &Method::GET) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + if let (Some(_), Some(blob_id), Some(name)) = ( + path.next().and_then(|p| Id::from_str(p).ok()), + path.next().and_then(BlobId::from_base32), + path.next(), + ) { + return match self.blob_download(&blob_id, &access_token).await? { + Some(blob) => Ok(DownloadResponse { + filename: name.to_string(), + content_type: req + .uri() + .query() + .and_then(|q| { + form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == "accept") + .map(|(_, v)| v.into_owned()) + }) + .unwrap_or("application/octet-stream".to_string()), + blob, + } + .into_http_response()), + None => Err(trc::ResourceEvent::NotFound.into_err()), + }; + } + } + ("upload", &Method::POST) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + if let Some(account_id) = path.next().and_then(|p| Id::from_str(p).ok()) { + return match fetch_body( + &mut req, + if !access_token.has_permission(Permission::UnlimitedUploads) { + self.core.jmap.upload_max_size + } else { + 0 + }, + session.session_id, + ) + .await + { + Some(bytes) => Ok(self + .blob_upload( + account_id, + req.headers() + .get(CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or("application/octet-stream"), + &bytes, + &access_token, + ) + .await? + .into_http_response()), + None => Err(trc::LimitEvent::SizeUpload.into_err()), + }; + } + } + ("eventsource", &Method::GET) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self.handle_event_source(req, access_token).await; + } + ("ws", &Method::GET) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self + .upgrade_websocket_connection(req, access_token, session) + .await; + } + ("session", &Method::GET) => { + return if req.headers().contains_key(header::AUTHORIZATION) { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + self.handle_session_resource( + self.core.network.http.url_https.to_string(), + &access_token, + ) + .await + .map(|s| s.into_http_response()) + } else { + Ok(Session::new( + &self.core.network.http.url_https, + &self.core.jmap.capabilities, + ) + .into_http_response()) + }; + } + (_, &Method::OPTIONS) => { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + _ => (), + } + } + "dav" => { + let response = match ( + path.next().and_then(DavResourceName::parse), + DavMethod::parse(req.method()), + ) { + (Some(_), Some(DavMethod::OPTIONS)) => HttpResponse::new(StatusCode::OK) + .with_header( + "DAV", + concat!( + "1, 2, 3, access-control, extended-mkcol, calendar-access, ", + "calendar-auto-schedule, calendar-no-timezone, addressbook" + ), + ) + .with_header( + "Allow", + concat!( + "OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, MKCALENDAR, ", + "MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL" + ), + ), + (Some(resource), Some(method)) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + self.handle_dav_request(req, access_token, &session, resource, method) + .await + } + (_, None) => HttpResponse::new(StatusCode::METHOD_NOT_ALLOWED), + (None, _) => HttpResponse::new(StatusCode::NOT_FOUND), + }; + + return Ok(response); + } + ".well-known" => match (path.next().unwrap_or_default(), req.method()) { + ("jmap", &Method::GET) => { + return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT) + .with_no_cache() + .with_location("/jmap/session")); + } + ("caldav", _) => { + return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT) + .with_no_cache() + .with_location(DavResourceName::Cal.base_path())); + } + ("carddav", _) => { + return Ok(HttpResponse::new(StatusCode::TEMPORARY_REDIRECT) + .with_no_cache() + .with_location(DavResourceName::Card.base_path())); + } + ("oauth-authorization-server", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_oauth_metadata().await; + } + ("oauth-protected-resource", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_oauth_protected_resource().await; + } + ("openid-configuration", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_oidc_metadata(false).await; + } + ("acme-challenge", &Method::GET) if self.has_acme_http_providers() => { + if let Some(token) = path.next() { + return match self + .in_memory_store() + .key_get::(KeyValue::<()>::build_key(KV_ACME, token)) + .await? + { + Some(proof) => Ok(Resource::new("text/plain", proof.into_bytes()) + .into_http_response()), + None => Err(trc::ResourceEvent::NotFound.into_err()), + }; + } + } + ("mta-sts.txt", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return if let Some(policy) = &self.core.smtp.session.mta_sts_policy { + Ok(Resource::new("text/plain", policy.to_string().into_bytes()) + .into_http_response()) + } else { + Err(trc::ResourceEvent::NotFound.into_err()) + }; + } + ("user-agent-configuration.json", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + return Ok(Resource::new( + "application/json", + self.get_pacc_for_domain( + req.headers() + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .map(|h| h.rsplit_once(':').map_or(h, |(h, _)| h)) + .unwrap_or_default(), + ) + .await? + .into_bytes(), + ) + .into_http_response() + .with_cors_unrestricted()); + } + ("mail-v1.xml", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response()); + } + ("autoconfig", &Method::GET) + if path.next().unwrap_or_default() == "mail" + && path.next().unwrap_or_default() == "config-v1.1.xml" => + { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response().with_cors_unrestricted()); + } + (_, &Method::OPTIONS) => { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_cors_unrestricted()); + } + _ => (), + }, + "auth" => match (path.next().unwrap_or_default(), req.method()) { + ("device", &Method::POST) => { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_device_auth(&mut req, &session).await; + } + ("token", &Method::POST) => { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_token_request(&mut req, session).await; + } + ("introspect", &Method::POST) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self + .handle_token_introspect(&mut req, &access_token, session.session_id) + .await; + } + ("userinfo", &Method::GET) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self + .handle_userinfo_request(access_token.account_id()) + .await; + } + ("register", &Method::POST) => { + return self + .handle_oauth_registration_request(&mut req, session) + .await; + } + ("jwks.json", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return Ok(self.core.oauth.oidc_jwks.clone().into_http_response()); + } + (_, &Method::OPTIONS) => { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + _ => (), + }, + "api" => { + // Allow CORS preflight requests + if req.method() == Method::OPTIONS { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + + return Ok(match self.handle_api_request(&mut req, &session).await { + Ok(response) => response, + Err(err) => { + let response = err.into_http_response(AuthChallenge::Bearer); + trc::error!(err.span_id(session.session_id)); + response + } + }); + } + "mail" => { + if req.method() == Method::GET + && path.next().unwrap_or_default() == "config-v1.1.xml" + { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response()); + } + } + "calendar" => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + if self.core.groupware.itip_http_rsvp_url.is_some() + && req.method() == Method::GET + && path.next().unwrap_or_default() == "rsvp" + { + + return Ok(HttpResponse::new(StatusCode::OK) + .with_content_type("text/html; charset=utf-8") + .with_header(CONTENT_ENCODING, "gzip") + .with_binary_body(RSVP_PAGE) + .with_no_store()); + } + } + "autodiscover" | "Autodiscover" | "AutoDiscover" => { + let document_name = path.next().unwrap_or_default(); + if req.method() == Method::POST + && document_name.eq_ignore_ascii_case("autodiscover.xml") + { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self + .handle_autodiscover_request( + fetch_body(&mut req, 8192, session.session_id).await, + ) + .await + .map(|resource| resource.into_http_response()); + } else if document_name.eq_ignore_ascii_case("autodiscover.json") { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + let path_email = path + .map(|segment| percent_decode_str(segment).decode_utf8_lossy().into_owned()) + .find(|segment| segment.contains('@')); + + return self + .handle_autodiscover_v2_request(req.uri().query(), path_email.as_deref()) + .await + .map(|result| match result { + Ok(resource) => resource.into_http_response(), + Err(err) => HttpResponse::new(StatusCode::BAD_REQUEST) + .with_content_type("application/json; charset=utf-8") + .with_text_body(err), + }); + } + } + "robots.txt" => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return Ok( + Resource::new("text/plain", b"User-agent: *\nDisallow: /\n".to_vec()) + .into_http_response(), + ); + } + "healthz" => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + match path.next().unwrap_or_default() { + "live" => { + return Ok(JsonProblemResponse(StatusCode::OK).into_http_response()); + } + "ready" => { + return Ok(JsonProblemResponse({ + if !self.core.storage.data.is_none() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + } + }) + .into_http_response()); + } + _ => (), + } + } + "metrics" => match path.next().unwrap_or_default() { + "prometheus" => { + if let Some(prometheus) = &self.core.metrics.prometheus { + if let Some(auth) = &prometheus.auth + && req + .authorization_basic() + .is_none_or(|secret| secret != auth) + { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid or missing credentials.") + .caused_by(trc::location!())); + } + + return Ok(Resource::new( + "text/plain; version=0.0.4", + self.export_prometheus_metrics().await?.into_bytes(), + ) + .into_http_response()); + } + } + "otel" => { + // Reserved for future use + } + _ => (), + }, + "form" => { + if let Some(form) = &self.core.network.contact_form { + match *req.method() { + Method::POST => { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + let form_data = + FormData::from_request(&mut req, form.max_size, session.session_id) + .await?; + + return self.handle_contact_form(&session, form, form_data).await; + } + Method::OPTIONS => { + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); + } + _ => {} + } + } + } + "login" | "device" => { + return Ok(HttpResponse::new(StatusCode::OK) + .with_content_type("text/html; charset=utf-8") + .with_header(CONTENT_ENCODING, "gzip") + .with_binary_body(LOGIN_PAGE)); + } + external => { + if path.next().is_none() { + if !external.is_empty() { + return Ok(HttpResponse::redirect(format!("/{external}/"))); + } else if let Some(url) = &self.core.network.http.redirect_root { + return Ok(HttpResponse::redirect(url.clone())); + } + } else if let Some(resource) = self + .inner + .data + .applications + .serve( + external, + req.uri() + .path() + .get(external.len() + 2..) + .unwrap_or_default(), + ) + .await? + { + let response = resource.resource.into_http_response(); + return Ok(if !resource.no_cache { + response.with_immutable_cache() + } else { + response.with_no_cache() + }); + } + } + } + + // Block dangerous URLs + let path = req.uri().path(); + if self.is_http_banned_path(path, session.remote_ip).await? { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = session.session_id, + RemoteIp = session.remote_ip, + Path = path.to_string(), + ); + } + + Err(trc::ResourceEvent::NotFound.into_err()) + } +} + +async fn handle_session(inner: Arc, session: SessionData) { + let _in_flight = session.in_flight; + let is_tls = session.stream.is_tls(); + + if let Err(http_err) = http1::Builder::new() + .keep_alive(true) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|req: hyper::Request| { + let instance = session.instance.clone(); + let inner = inner.clone(); + + async move { + let server = inner.build_server(); + + // Obtain remote IP + let remote_ip = if !server.core.network.http.use_forwarded { + trc::event!( + Http(trc::HttpEvent::RequestUrl), + SpanId = session.session_id, + Url = req.uri().to_string(), + ); + + session.remote_ip + } else if let Some(forwarded_for) = req + .headers() + .get(header::FORWARDED) + .and_then(|h| h.to_str().ok()) + .and_then(|h| { + let h = h.to_ascii_lowercase(); + h.split_once("for=").and_then(|(_, rest)| { + let mut start_ip = usize::MAX; + let mut end_ip = usize::MAX; + + for (pos, ch) in rest.char_indices() { + match ch { + '0'..='9' | 'a'..='f' | ':' | '.' => { + if start_ip == usize::MAX { + start_ip = pos; + } + end_ip = pos; + } + '"' | '[' | ' ' if start_ip == usize::MAX => {} + _ => { + break; + } + } + } + + rest.get(start_ip..=end_ip) + .and_then(|h| h.parse::().ok()) + }) + }) + .or_else(|| { + req.headers() + .get("X-Forwarded-For") + .and_then(|h| h.to_str().ok()) + .map(|h| h.split_once(',').map_or(h, |(ip, _)| ip).trim()) + .and_then(|h| h.parse::().ok()) + }) + { + trc::event!( + Http(trc::HttpEvent::RequestUrl), + SpanId = session.session_id, + RemoteIp = forwarded_for, + Url = req.uri().to_string(), + ); + + forwarded_for + } else { + trc::event!( + Http(trc::HttpEvent::XForwardedMissing), + SpanId = session.session_id, + ); + session.remote_ip + }; + + // Check if the remote IP has been blocked + if server.is_ip_blocked(remote_ip) { + trc::event!( + Security(trc::SecurityEvent::IpBlocked), + ListenerId = instance.id.clone(), + RemoteIp = remote_ip, + SpanId = session.session_id, + ); + + return Ok::<_, hyper::Error>( + JsonProblemResponse(StatusCode::FORBIDDEN) + .into_http_response() + .build(), + ); + } + + // Parse HTTP request + let response = match Box::pin(server.parse_http_request( + req, + HttpSessionData { + instance, + local_ip: session.local_ip, + local_port: session.local_port, + remote_ip, + remote_port: session.remote_port, + is_tls, + session_id: session.session_id, + }, + )) + .await + { + Ok(response) => response, + Err(err) => { + let response = err.into_http_response(AuthChallenge::BearerAndBasic); + trc::error!(err.span_id(session.session_id)); + response + } + }; + + trc::event!( + Http(trc::HttpEvent::ResponseBody), + SpanId = session.session_id, + Contents = match response.body() { + HttpResponseBody::Text(value) => + trc::Value::String(value.as_str().into()), + HttpResponseBody::Binary(_) => + trc::Value::String("[binary data]".into()), + HttpResponseBody::Stream(_) => trc::Value::String("[stream]".into()), + _ => trc::Value::None, + }, + Code = response.status().as_u16(), + Size = response.size(), + ); + + // Build response + let mut response = response.build(); + + // Add custom headers + if !server.core.network.http.response_headers.is_empty() { + let headers = response.headers_mut(); + + for (header, value) in &server.core.network.http.response_headers { + headers.insert(header.clone(), value.clone()); + } + } + + Ok::<_, hyper::Error>(response) + } + }), + ) + .with_upgrades() + .await + { + if http_err.is_parse() { + let server = inner.build_server(); + if !server.core.network.http.use_forwarded { + match server.is_scanner_fail2banned(session.remote_ip).await { + Ok(true) => { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = session.session_id, + RemoteIp = session.remote_ip, + Reason = http_err.to_string(), + ); + return; + } + Ok(false) => {} + Err(err) => { + trc::error!( + err.span_id(session.session_id) + .details("Failed to check for fail2ban") + ); + } + } + } + } + + trc::event!( + Http(trc::HttpEvent::Error), + SpanId = session.session_id, + Reason = http_err.to_string(), + ); + } +} + +impl SessionManager for HttpSessionManager { + fn handle(self, session: SessionData) -> impl Future + Send { + handle_session(self.inner, session) + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async { + let _ = self.inner.ipc.push_tx.send(PushEvent::Stop).await; + } + } +} + +#[cfg(test)] +mod tests { + use flate2::read::GzDecoder; + use std::io::Read; + + const PAGES: [(&str, &[u8], &str); 2] = [ + ( + "calendar-rsvp.html", + super::RSVP_PAGE, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/calendar-rsvp.html.min" + )), + ), + ( + "login.html", + super::LOGIN_PAGE, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/html-templates/login.html.min" + )), + ), + ]; + + #[test] + fn gzipped_static_pages_are_in_sync() { + for (name, gzipped, minified) in PAGES { + let mut decoded = String::new(); + GzDecoder::new(gzipped) + .read_to_string(&mut decoded) + .unwrap_or_else(|err| panic!("{name}.min.gz failed to decompress: {err}")); + + assert_eq!( + decoded, minified, + "{name}.min.gz is stale, re-run resources/scripts/minify_html.sh --gzip" + ); + } + } +} diff --git a/crates/imap-proto/Cargo.toml b/crates/imap-proto/Cargo.toml new file mode 100644 index 0000000..7d40a5e --- /dev/null +++ b/crates/imap-proto/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "imap_proto" +version = "0.16.22" +edition = "2024" + +[dependencies] +types = { path = "../types" } +utils = { path = "../utils" } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +ahash = { version = "0.8" } +base64 = "0.23" +chrono = { version = "0.4"} +trc = { path = "../trc" } +hashify = { version = "0.2" } +compact_str = "0.10.0" + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/imap-proto/src/lib.rs b/crates/imap-proto/src/lib.rs new file mode 100644 index 0000000..a8d8b49 --- /dev/null +++ b/crates/imap-proto/src/lib.rs @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use protocol::ObjectId; +use protocol::capability::Capability; +use std::borrow::Cow; + +pub mod parser; +pub mod protocol; +pub mod receiver; +pub mod utf7; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Command { + // Client Commands - Any State + Capability, + #[default] + Noop, + Logout, + + // Client Commands - Not Authenticated State + StartTls, + Authenticate, + Login, + + // Client Commands - Authenticated State + Enable, + Select, + Examine, + Create, + Delete, + Rename, + Subscribe, + Unsubscribe, + List, + Namespace, + Status, + Append, + Idle, + + // Client Commands - Selected State + Close, + Unselect, + Expunge(bool), + Search(bool), + Fetch(bool), + Store(bool), + Copy(bool), + Move(bool), + + // IMAP4rev1 + Lsub, + Check, + + // RFC 5256 + Sort(bool), + Thread(bool), + + // RFC 4314 + SetAcl, + DeleteAcl, + GetAcl, + ListRights, + MyRights, + + // RFC 8437 + Unauthenticate, + + // RFC 2971 + Id, + + // RFC 9208 + GetQuota, + GetQuotaRoot, + + // RFC 9698 + GetJmapAccess, + + // RFC 10022 + UidBatches, +} + +impl Command { + pub fn is_uid(&self) -> bool { + matches!( + self, + Command::Fetch(true) + | Command::Search(true) + | Command::Copy(true) + | Command::Move(true) + | Command::Store(true) + | Command::Expunge(true) + | Command::Sort(true) + | Command::Thread(true) + ) + } + + pub fn requires_uid(&self) -> bool { + matches!( + self, + Command::Fetch(false) + | Command::Search(false) + | Command::Copy(false) + | Command::Move(false) + | Command::Store(false) + | Command::Sort(false) + | Command::Thread(false) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseCode { + Alert, + AlreadyExists, + AppendUid { + uid_validity: u32, + uids: Vec, + }, + AuthenticationFailed, + AuthorizationFailed, + BadCharset, + Cannot, + Capability { + capabilities: Vec, + }, + ClientBug, + Closed, + ContactAdmin, + CopyUid { + uid_validity: u32, + src_uids: Vec, + dest_uids: Vec, + }, + Corruption, + Expired, + ExpungeIssued, + HasChildren, + InUse, + Limit, + NonExistent, + NoPerm, + OverQuota, + Parse, + PermanentFlags, + PrivacyRequired, + ReadOnly, + ReadWrite, + ServerBug, + TryCreate, + UidNext, + UidNotSticky, + UidValidity, + Unavailable, + UnknownCte, + + // CONDSTORE + Modified { + ids: Vec, + }, + HighestModseq { + modseq: u64, + }, + + // ObjectID + ObjectId(ObjectId), + + // USEATTR + UseAttr, + + // UIDONLY + UidRequired, + + // UIDBATCHES + TooFew, + TooMany, + + // MESSAGELIMIT + MessageLimit { + limit: u32, + uid: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusResponse { + pub tag: Option, + pub code: Option, + pub message: Cow<'static, str>, + pub rtype: ResponseType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseType { + Ok, + No, + Bad, + PreAuth, + Bye, +} + +impl ResponseCode { + pub fn highest_modseq(modseq: u64) -> Self { + ResponseCode::HighestModseq { + modseq: if modseq > 0 { modseq + 1 } else { 0 }, + } + } +} + +impl StatusResponse { + pub fn bad(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Bad, + } + } + + pub fn parse_error(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: ResponseCode::Parse.into(), + message: message.into(), + rtype: ResponseType::Bad, + } + } + + pub fn database_failure() -> Self { + StatusResponse::no("Database failure.").with_code(ResponseCode::ContactAdmin) + } + + pub fn completed(command: Command) -> Self { + StatusResponse::ok(format!("{} completed", command)) + } + + pub fn with_code(mut self, code: ResponseCode) -> Self { + self.code = Some(code); + self + } + + pub fn with_tag(mut self, tag: impl Into) -> Self { + self.tag = Some(tag.into()); + self + } + + pub fn no(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::No, + } + } + + pub fn ok(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Ok, + } + } + + pub fn bye(message: impl Into>) -> Self { + StatusResponse { + tag: None, + code: None, + message: message.into(), + rtype: ResponseType::Bye, + } + } +} diff --git a/crates/imap-proto/src/parser/acl.rs b/crates/imap-proto/src/parser/acl.rs new file mode 100644 index 0000000..3629c97 --- /dev/null +++ b/crates/imap-proto/src/parser/acl.rs @@ -0,0 +1,222 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::acl::{self, ModRights, ModRightsOp, Rights}, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +use super::PushUnique; + +/* + + setacl = "SETACL" SP mailbox SP identifier + SP mod-rights + + deleteacl = "DELETEACL" SP mailbox SP identifier + + getacl = "GETACL" SP mailbox + + listrights = "LISTRIGHTS" SP mailbox SP identifier + + myrights = "MYRIGHTS" SP mailbox + +*/ + +impl Request { + pub fn parse_acl(self, is_utf8: bool) -> trc::Result { + let (has_identifier, has_mod_rights) = match self.command { + Command::SetAcl => (true, true), + Command::DeleteAcl | Command::ListRights => (true, false), + Command::GetAcl | Command::MyRights => (false, false), + _ => unreachable!(), + }; + let mut tokens = self.tokens.into_iter(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ); + let identifier = if has_identifier { + tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing identifier."))? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))? + .into() + } else { + None + }; + let mod_rights = if has_mod_rights { + ModRights::parse( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing rights."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))? + .into() + } else { + None + }; + + Ok(acl::Arguments { + tag: self.tag, + mailbox_name, + identifier, + mod_rights, + }) + } +} + +impl ModRights { + pub fn parse(value: &[u8]) -> super::Result { + let mut op = ModRightsOp::Replace; + let mut rights = Vec::with_capacity(value.len()); + for (pos, ch) in value.iter().enumerate() { + rights.push_unique(match ch { + b'l' => Rights::Lookup, + b'r' => Rights::Read, + b's' => Rights::Seen, + b'w' => Rights::Write, + b'i' => Rights::Insert, + b'p' => Rights::Post, + b'k' => Rights::CreateMailbox, + b'x' => Rights::DeleteMailbox, + b't' => Rights::DeleteMessages, + b'e' => Rights::Expunge, + b'a' => Rights::Administer, + // RFC2086 + b'd' => Rights::DeleteMessages, + b'c' => Rights::CreateMailbox, + b'+' if pos == 0 => { + op = ModRightsOp::Add; + continue; + } + b'-' if pos == 0 => { + op = ModRightsOp::Remove; + continue; + } + _ => { + return Err( + format!("Invalid character {:?} in rights.", char::from(*ch)).into(), + ); + } + }) + } + + if !rights.is_empty() { + Ok(ModRights { op, rights }) + } else { + Err("At least one right has to be specified.".into()) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::acl::{self, ModRights, ModRightsOp, Rights}, + receiver::Receiver, + }; + + #[test] + fn parse_acl() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 Setacl INBOX/Drafts Byron lrswikda\r\n", + acl::Arguments { + tag: "A003".into(), + mailbox_name: "INBOX/Drafts".into(), + identifier: Some("Byron".into()), + mod_rights: ModRights { + op: ModRightsOp::Replace, + rights: vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A002 SETACL INBOX/Drafts Chris +cda\r\n", + acl::Arguments { + tag: "A002".into(), + mailbox_name: "INBOX/Drafts".into(), + identifier: Some("Chris".into()), + mod_rights: ModRights { + op: ModRightsOp::Add, + rights: vec![ + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A036 SETACL INBOX/Drafts John -lrswicda\r\n", + acl::Arguments { + tag: "A036".into(), + mailbox_name: "INBOX/Drafts".into(), + identifier: Some("John".into()), + mod_rights: ModRights { + op: ModRightsOp::Remove, + rights: vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ], + } + .into(), + }, + ), + ( + "A001 GETACL INBOX/Drafts\r\n", + acl::Arguments { + tag: "A001".into(), + mailbox_name: "INBOX/Drafts".into(), + identifier: None, + mod_rights: None, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_acl(false) + .unwrap(), + arguments, + "{:?}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/append.rs b/crates/imap-proto/src/parser/append.rs new file mode 100644 index 0000000..7585ede --- /dev/null +++ b/crates/imap-proto/src/parser/append.rs @@ -0,0 +1,351 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::{ + Flag, + append::{self, Message}, + }, + receiver::{Request, Token, bad}, + utf7::utf7_maybe_decode, +}; + +use super::parse_datetime; + +enum State { + None, + Flags, + UTF8, + UTF8Data, +} + +impl Request { + pub fn parse_append(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 0 | 1 => Err(self.into_error("Missing arguments.")), + _ => { + // Obtain mailbox name + let mut tokens = self.tokens.into_iter().peekable(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ); + let mut messages = Vec::new(); + + while tokens.peek().is_some() { + // Parse flags + let mut message = Message { + message: vec![], + flags: vec![], + received_at: None, + }; + let mut state = State::None; + let mut seen_flags = false; + + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisOpen => { + state = match state { + State::None if !seen_flags => { + seen_flags = true; + State::Flags + } + State::UTF8 => State::UTF8Data, + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid opening parenthesis found.", + )); + } + }; + } + Token::ParenthesisClose => match state { + State::None | State::UTF8 => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid closing parenthesis found.", + )); + } + State::Flags => { + state = State::None; + } + State::UTF8Data => { + break; + } + }, + Token::Argument(value) => match state { + State::None => { + if value.eq_ignore_ascii_case(b"utf8") { + state = State::UTF8; + } else if matches!(tokens.peek(), Some(Token::Argument(_))) + && value.len() <= 28 + && !value.contains(&b'\n') + { + if let Ok(date_time) = parse_datetime(&value) { + message.received_at = Some(date_time); + } else { + return Err(bad( + self.tag.to_compact_string(), + "Failed to parse received time.", + )); + } + } else { + message.message = value; + break; + } + } + State::Flags => { + message.flags.push( + Flag::parse_imap(value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + State::UTF8 => { + return Err(bad( + self.tag.to_compact_string(), + "Expected parenthesis after UTF8.", + )); + } + State::UTF8Data => { + if message.message.is_empty() { + message.message = value; + } else { + return Err(bad( + self.tag.to_compact_string(), + "Invalid parameter after message literal.", + )); + } + } + }, + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid arguments.", + )); + } + } + } + + messages.push(message); + } + + Ok(append::Arguments { + tag: self.tag, + mailbox_name, + messages, + }) + } + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + Flag, + append::{self, Message}, + }, + receiver::{Error, Receiver}, + }; + + #[test] + fn parse_append() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 APPEND saved-messages (\\Seen) {1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "saved-messages".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Seen], + received_at: None, + }], + }, + ), + ( + "A003 APPEND \"hello world\" (\\Seen \\Draft $MDNSent) {1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "hello world".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Seen, Flag::Draft, Flag::MDNSent], + received_at: None, + }], + }, + ), + ( + "A003 APPEND \"hi\" ($Junk) \"7-Feb-1994 22:43:04 -0800\" {1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "hi".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Junk], + received_at: Some(760689784), + }], + }, + ), + ( + "A003 APPEND \"hi\" \"20-Nov-2022 23:59:59 +0300\" {1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "hi".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![], + received_at: Some(1668977999), + }], + }, + ), + ( + "A003 APPEND \"hi\" \"20-Nov-2022 23:59:59 +0300\" ~{1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "hi".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![], + received_at: Some(1668977999), + }], + }, + ), + ( + "42 APPEND \"Drafts\" (\\Draft) UTF8 (~{5+}\r\nhello)\r\n", + append::Arguments { + tag: "42".into(), + mailbox_name: "Drafts".into(), + messages: vec![Message { + message: vec![b'h', b'e', b'l', b'l', b'o'], + flags: vec![Flag::Draft], + received_at: None, + }], + }, + ), + ( + "42 APPEND \"Drafts\" (\\Draft) \"20-Nov-2022 23:59:59 +0300\" UTF8 (~{5+}\r\nhello)\r\n", + append::Arguments { + tag: "42".into(), + mailbox_name: "Drafts".into(), + messages: vec![Message { + message: vec![b'h', b'e', b'l', b'l', b'o'], + flags: vec![Flag::Draft], + received_at: Some(1668977999), + }], + }, + ), + ( + "A003 APPEND \"&A8g- \\\"&A9QD1APUA9gD3APcA-+\\\"\" (\\Seen) \"7-Feb-1994 22:43:04 -0800\" {1+}\r\na\r\n", + append::Arguments { + tag: "A003".into(), + mailbox_name: "ψ \"ϔϔϔϘϜϜ+\"".into(), + messages: vec![Message { + message: vec![b'a'], + flags: vec![Flag::Seen], + received_at: Some(760689784), + }], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .expect(command) + .parse_append(false) + .expect(command), + arguments, + "{:?}", + command + ); + } + + // Multiappend + for line in [ + "A003 APPEND saved-messages (\\Seen) UTF8 ({329}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.example.net\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n)", + " (\\Seen) \"7-Feb-1994 22:43:04 -0800\" {295}\r\n", + "Date: Mon, 7 Feb 1994 22:43:04 -0800 (PST)\r\n", + "From: Joe Mooch \r\n", + "Subject: Re: afternoon meeting\r\n", + "To: foobar@blurdybloop.example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n", + "3:30 is fine with me.\r\n\r\n", + ] { + match receiver.parse(&mut line.as_bytes().iter()) { + Ok(request) => { + assert_eq!( + request.parse_append(false).unwrap(), + append::Arguments { + tag: "A003".into(), + mailbox_name: "saved-messages".into(), + messages: vec![ + Message { + message: concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.example.net\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n", + ) + .as_bytes() + .to_vec(), + flags: vec![Flag::Seen], + received_at: None, + }, + Message { + message: concat!( + "Date: Mon, 7 Feb 1994 22:43:04 -0800 (PST)\r\n", + "From: Joe Mooch \r\n", + "Subject: Re: afternoon meeting\r\n", + "To: foobar@blurdybloop.example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n", + "3:30 is fine with me.\r\n", + ) + .as_bytes() + .to_vec(), + flags: vec![Flag::Seen], + received_at: Some(760689784), + } + ], + }, + ); + } + Err(err) => match err { + Error::NeedsMoreData | Error::NeedsLiteral { .. } => (), + Error::Error { response } => panic!("{:?}", response), + }, + } + } + } +} diff --git a/crates/imap-proto/src/parser/authenticate.rs b/crates/imap-proto/src/parser/authenticate.rs new file mode 100644 index 0000000..8285757 --- /dev/null +++ b/crates/imap-proto/src/parser/authenticate.rs @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::authenticate::{self, Mechanism}, + receiver::{Request, bad}, +}; + +impl Request { + pub fn parse_authenticate(self) -> trc::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter(); + Ok(authenticate::Arguments { + mechanism: Mechanism::parse(&tokens.next().unwrap().unwrap_bytes()) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + params: tokens + .filter_map(|token| token.unwrap_string().ok()) + .collect(), + tag: self.tag, + }) + } else { + Err(self.into_error("Authentication mechanism missing.")) + } + } +} + +impl Mechanism { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "PLAIN" => Self::Plain, + "CRAM-MD5" => Self::CramMd5, + "DIGEST-MD5" => Self::DigestMd5, + "SCRAM-SHA-1" => Self::ScramSha1, + "SCRAM-SHA-256" => Self::ScramSha256, + "APOP" => Self::Apop, + "NTLM" => Self::Ntlm, + "GSSAPI" => Self::Gssapi, + "ANONYMOUS" => Self::Anonymous, + "EXTERNAL" => Self::External, + "OAUTHBEARER" => Self::OAuthBearer, + "XOAUTH2" => Self::XOauth2, + ) + .ok_or_else(|| { + format!( + "Unsupported mechanism '{}'.", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::authenticate::{self, Mechanism}, + receiver::Receiver, + }; + + #[test] + fn parse_authenticate() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "a002 AUTHENTICATE \"EXTERNAL\" {16+}\r\nfred@example.com\r\n", + authenticate::Arguments { + tag: "a002".into(), + mechanism: Mechanism::External, + params: vec!["fred@example.com".into()], + }, + ), + ( + "A01 AUTHENTICATE PLAIN\r\n", + authenticate::Arguments { + tag: "A01".into(), + mechanism: Mechanism::Plain, + params: vec![], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_authenticate() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/copy_move.rs b/crates/imap-proto/src/parser/copy_move.rs new file mode 100644 index 0000000..e845bf3 --- /dev/null +++ b/crates/imap-proto/src/parser/copy_move.rs @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::copy_move, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +use super::parse_sequence_set; + +impl Request { + pub fn parse_copy_move(self, is_utf8: bool) -> trc::Result { + if self.tokens.len() > 1 { + let mut tokens = self.tokens.into_iter(); + + Ok(copy_move::Arguments { + sequence_set: parse_sequence_set( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + mailbox_name: utf7_maybe_decode( + tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{Sequence, copy_move}, + receiver::Receiver, + }; + + #[test] + fn parse_copy() { + let mut receiver = Receiver::new(); + + assert_eq!( + receiver + .parse(&mut "A003 COPY 2:4 MEETING\r\n".as_bytes().iter()) + .unwrap() + .parse_copy_move(false) + .unwrap(), + copy_move::Arguments { + sequence_set: Sequence::Range { + start: 2.into(), + end: 4.into(), + }, + mailbox_name: "MEETING".into(), + tag: "A003".into(), + } + ); + assert_eq!( + receiver + .parse(&mut "A003 COPY 2:4 \"You &- Me\"\r\n".as_bytes().iter()) + .unwrap() + .parse_copy_move(false) + .unwrap(), + copy_move::Arguments { + sequence_set: Sequence::Range { + start: 2.into(), + end: 4.into(), + }, + mailbox_name: "You & Me".into(), + tag: "A003".into(), + } + ); + } +} diff --git a/crates/imap-proto/src/parser/create.rs b/crates/imap-proto/src/parser/create.rs new file mode 100644 index 0000000..ee93947 --- /dev/null +++ b/crates/imap-proto/src/parser/create.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString, format_compact}; + +use crate::{ + Command, + protocol::{create, list::Attribute}, + receiver::{Request, Token, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_create(self, is_utf8: bool) -> trc::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ); + let mailbox_role = if let Some(Token::ParenthesisOpen) = tokens.next() { + match tokens.next() { + Some(Token::Argument(param)) if param.eq_ignore_ascii_case(b"USE") => (), + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Failed to parse, expected 'USE'.", + )); + } + } + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected '(' after 'USE'.", + )); + } + match tokens.next() { + Some(Token::Argument(value)) => { + let r = hashify::tiny_map_ignore_case!(value.as_slice(), + "\\Archive" => Some(Attribute::Archive), + "\\Drafts" => Some(Attribute::Drafts), + "\\Junk" => Some(Attribute::Junk), + "\\Sent" => Some(Attribute::Sent), + "\\Trash" => Some(Attribute::Trash), + "\\Important" => Some(Attribute::Important), + "\\Memos" => Some(Attribute::Memos), + "\\Scheduled" => Some(Attribute::Scheduled), + "\\Snoozed" => Some(Attribute::Snoozed), + "\\All" => None, + ); + + match r { + Some(Some(tag)) => Some(tag), + Some(None) => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "A mailbox with the \"\\All\" attribute already exists.", + )); + } + None => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Special use attribute {:?} is not supported.", + String::from_utf8_lossy(&value) + ), + )); + } + } + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Invalid SPECIAL-USE attribute.", + )); + } + } + } else { + None + }; + + Ok(create::Arguments { + mailbox_name, + mailbox_role, + tag: self.tag, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{create, list::Attribute}, + receiver::Receiver, + }; + + #[test] + fn parse_create() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 CREATE 12345\r\n", + create::Arguments { + tag: "A142".into(), + mailbox_name: "12345".into(), + mailbox_role: None, + }, + ), + ( + "A142 CREATE \"my funky mailbox\"\r\n", + create::Arguments { + tag: "A142".into(), + mailbox_name: "my funky mailbox".into(), + mailbox_role: None, + }, + ), + ( + "t1 CREATE \"Important Messages\" (USE (\\Important))\r\n", + create::Arguments { + tag: "t1".into(), + mailbox_name: "Important Messages".into(), + mailbox_role: Some(Attribute::Important), + }, + ), + ( + "A142 CREATE \"Test-ąęć-Test\"\r\n", + create::Arguments { + tag: "A142".into(), + mailbox_name: "Test-ąęć-Test".into(), + mailbox_role: None, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_create(true) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/delete.rs b/crates/imap-proto/src/parser/delete.rs new file mode 100644 index 0000000..58979c0 --- /dev/null +++ b/crates/imap-proto/src/parser/delete.rs @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::delete, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_delete(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 1 => Ok(delete::Arguments { + mailbox_name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::delete, receiver::Receiver}; + + #[test] + fn parse_delete() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 DELETE INBOX\r\n", + delete::Arguments { + mailbox_name: "INBOX".into(), + tag: "A142".into(), + }, + ), + ( + "A142 DELETE \"my funky mailbox\"\r\n", + delete::Arguments { + mailbox_name: "my funky mailbox".into(), + tag: "A142".into(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_delete(true) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/enable.rs b/crates/imap-proto/src/parser/enable.rs new file mode 100644 index 0000000..ead86ab --- /dev/null +++ b/crates/imap-proto/src/parser/enable.rs @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Command, + protocol::{capability::Capability, enable}, + receiver::{Request, bad}, +}; +use compact_str::ToCompactString; + +impl Request { + pub fn parse_enable(self) -> trc::Result { + let len = self.tokens.len(); + if len > 0 { + let mut capabilities = Vec::with_capacity(len); + for capability in self.tokens { + capabilities.push( + Capability::parse(&capability.unwrap_bytes()) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + Ok(enable::Arguments { + tag: self.tag, + capabilities, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +impl Capability { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "IMAP4rev2" => Self::IMAP4rev2, + "STARTTLS" => Self::StartTLS, + "LOGINDISABLED" => Self::LoginDisabled, + "CONDSTORE" => Self::CondStore, + "QRESYNC" => Self::QResync, + "UTF8=ACCEPT" => Self::Utf8Accept, + "OBJECTID+" => Self::ObjectIdPlus, + "UIDONLY" => Self::UidOnly, + ) + .ok_or_else(|| { + format!( + "Unsupported capability '{}'.", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{capability::Capability, enable}, + receiver::Receiver, + }; + + #[test] + fn parse_enable() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "t2 ENABLE IMAP4rev2 CONDSTORE\r\n", + enable::Arguments { + tag: "t2".into(), + capabilities: vec![Capability::IMAP4rev2, Capability::CondStore], + }, + ), + ( + "t3 ENABLE OBJECTID+\r\n", + enable::Arguments { + tag: "t3".into(), + capabilities: vec![Capability::ObjectIdPlus], + }, + ), + ( + "t4 ENABLE CONDSTORE OBJECTID+ UTF8=ACCEPT\r\n", + enable::Arguments { + tag: "t4".into(), + capabilities: vec![ + Capability::CondStore, + Capability::ObjectIdPlus, + Capability::Utf8Accept, + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_enable() + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } + } +} diff --git a/crates/imap-proto/src/parser/fetch.rs b/crates/imap-proto/src/parser/fetch.rs new file mode 100644 index 0000000..d18986f --- /dev/null +++ b/crates/imap-proto/src/parser/fetch.rs @@ -0,0 +1,828 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{PushUnique, parse_number, parse_sequence_set}; +use crate::{ + Command, + protocol::fetch::{self, Attribute, Section}, + receiver::{Request, Token, bad}, +}; +use compact_str::{CompactString, ToCompactString, format_compact}; +use std::borrow::Cow; +use std::iter::Peekable; +use std::vec::IntoIter; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_fetch(self) -> trc::Result { + if self.tokens.len() < 2 { + return Err(self.into_error("Missing parameters.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut attributes = Vec::new(); + let sequence_set = parse_sequence_set( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + let mut in_parentheses = false; + + while let Some(token) = tokens.next() { + match token { + Token::Argument(value) => { + hashify::fnc_map_ignore_case!(value.as_slice(), + "ALL" => { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + ]; + break; + }, + "FULL" => { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + Attribute::Body, + ]; + break; + }, + "FAST" => { + attributes = vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + ]; + break; + }, + "ENVELOPE" => { + attributes.push_unique(Attribute::Envelope); + }, + "FLAGS" => { + attributes.push_unique(Attribute::Flags); + }, + "INTERNALDATE" => { + attributes.push_unique(Attribute::InternalDate); + }, + "BODYSTRUCTURE" => { + attributes.push_unique(Attribute::BodyStructure); + }, + "UID" => { + attributes.push_unique(Attribute::Uid); + }, + "RFC822" => { + attributes.push_unique( + if tokens.peek().is_some_and(|token| token.is_dot()) { + tokens.next(); + let rfc822 = tokens + .next() + .ok_or_else(|| { + bad(self.tag.to_compact_string(), "Missing RFC822 parameter.") + })? + .unwrap_bytes(); + if rfc822.eq_ignore_ascii_case(b"HEADER") { + Attribute::Rfc822Header + } else if rfc822.eq_ignore_ascii_case(b"SIZE") { + Attribute::Rfc822Size + } else if rfc822.eq_ignore_ascii_case(b"TEXT") { + Attribute::Rfc822Text + } else { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Invalid RFC822 parameter {:?}.", + String::from_utf8_lossy(&rfc822) + ), + )); + } + } else { + Attribute::Rfc822 + }, + ); + }, + "BODY" => { + let is_peek = match tokens.peek() { + Some(Token::BracketOpen) => { + tokens.next(); + false + } + Some(Token::Dot) => { + tokens.next(); + if tokens + .next() + .is_none_or( |token| !token.eq_ignore_ascii_case(b"PEEK")) + { + return Err(bad( + self.tag.to_compact_string(), + "Expected 'PEEK' after '.'.", + )); + } + if tokens.next().is_none_or( |token| !token.is_bracket_open()) { + return Err(bad( + self.tag.to_compact_string(), + "Expected '[' after 'BODY.PEEK'", + )); + } + true + } + _ => { + attributes.push_unique(Attribute::Body); + + if !in_parentheses { + break; + } else { + continue; + } + } + }; + + // Parse section-spect + let mut sections = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::BracketClose => break, + Token::Argument(value) => { + let section = if value.eq_ignore_ascii_case(b"HEADER") { + if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + if tokens.next().is_none_or( |token| { + !token.eq_ignore_ascii_case(b"FIELDS") + }) { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected 'FIELDS' after 'HEADER.'.", + )); + } + let is_not = if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + if tokens.next().is_none_or( |token| { + !token.eq_ignore_ascii_case(b"NOT") + }) { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected 'NOT' after 'HEADER.FIELDS.'.", + )); + } + true + } else { + false + }; + if tokens + .next() + .is_none_or( |token| !token.is_parenthesis_open()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected '(' after 'HEADER.FIELDS'.", + )); + } + let mut fields = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + fields.push(String::from_utf8(value).map_err( + |_| bad(self.tag.to_compact_string(),"Invalid UTF-8 in header field name."), + )?); + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected field name.", + )) + } + } + } + Section::HeaderFields { + not: is_not, + fields, + } + } else { + Section::Header + } + } else if value.eq_ignore_ascii_case(b"TEXT") { + Section::Text + } else if value.eq_ignore_ascii_case(b"MIME") { + Section::Mime + } else { + Section::Part { + num: parse_number::(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + } + }; + sections.push(section); + } + Token::Dot => (), + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Invalid token {:?} found in section-spect.", + token + ), + )) + } + } + } + + attributes.push_unique(Attribute::BodySection { + peek: is_peek, + sections, + partial: parse_partial(&mut tokens) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + }); + }, + "BINARY" => { + let (is_peek, is_size) = if let Some(Token::Dot) = tokens.peek() { + tokens.next(); + let param = tokens + .next() + .ok_or({ + bad(self.tag.to_compact_string(),"Missing parameter after 'BINARY.'.") + })? + .unwrap_bytes(); + if param.eq_ignore_ascii_case(b"PEEK") { + (true, false) + } else if param.eq_ignore_ascii_case(b"SIZE") { + (false, true) + } else { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected 'PEEK' or 'SIZE' after 'BINARY.'.", + )); + } + } else { + (false, false) + }; + + // Parse section-part + if tokens.next().is_none_or( |token| !token.is_bracket_open()) { + return Err(bad(self.tag.to_compact_string(), "Expected '[' after 'BINARY'.")); + } + let mut sections = Vec::new(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(value) => { + sections.push( + parse_number::(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + Token::Dot => (), + Token::BracketClose => break, + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Expected part section integer, got {:?}.", + token.to_string() + ), + )) + } + } + } + attributes.push_unique(if !is_size { + Attribute::Binary { + peek: is_peek, + sections, + partial: parse_partial(&mut tokens) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + } + } else { + Attribute::BinarySize { sections } + }); + }, + "PREVIEW" => { + attributes.push_unique(Attribute::Preview { + lazy: if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + let mut is_lazy = false; + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) if value.eq_ignore_ascii_case(b"LAZY") => { + is_lazy = true; + } + _ => (), + } + } + is_lazy + } else { + false + }, + }); + }, + "MODSEQ" => { + attributes.push_unique(Attribute::ModSeq); + }, + "OBJECTID" => { + attributes.push_unique(Attribute::ObjectId); + }, + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!("Invalid attribute {:?}", String::from_utf8_lossy(&value)), + )); + } + ); + + if !in_parentheses { + break; + } + } + Token::ParenthesisOpen => { + if !in_parentheses { + in_parentheses = true; + } else { + return Err(bad( + self.tag.to_compact_string(), + "Unexpected parenthesis open.", + )); + } + } + Token::ParenthesisClose => { + if in_parentheses { + break; + } else { + return Err(bad( + self.tag.to_compact_string(), + "Unexpected parenthesis close.", + )); + } + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!("Invalid fetch argument {:?}.", token.to_string()), + )); + } + } + } + + // CONDSTORE parameters + let mut changed_since = None; + let mut include_vanished = false; + if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"CHANGEDSINCE") => { + changed_since = parse_number::( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing CHANGEDSINCE parameter.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))? + .into(); + } + Token::Argument(param) if param.eq_ignore_ascii_case(b"VANISHED") => { + include_vanished = true; + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + format_compact!("Unsupported parameter '{}'.", token), + )); + } + } + } + } + + if !attributes.is_empty() { + Ok(fetch::Arguments { + tag: self.tag, + sequence_set, + attributes, + changed_since, + include_vanished, + }) + } else { + Err(bad( + CompactString::from_string_buffer(self.tag), + "No data items to fetch specified.", + )) + } + } +} + +pub fn parse_partial(tokens: &mut Peekable>) -> super::Result> { + if tokens.peek().is_none_or(|token| !token.is_lt()) { + return Ok(None); + } + tokens.next(); + + let start = parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing partial start."))? + .unwrap_bytes(), + )?; + + if tokens.next().is_none_or(|token| !token.is_dot()) { + return Err("Expected '.' after partial start.".into()); + } + + let end = parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing partial end."))? + .unwrap_bytes(), + )?; + + if end == 0 { + return Err("Invalid partial range.".into()); + } + + if tokens.next().is_none_or(|token| !token.is_gt()) { + return Err("Expected '>' after range.".into()); + } + + Ok(Some((start, end))) +} + +/* + + fetch = "FETCH" SP sequence-set SP ( + "ALL" / "FULL" / "FAST" / + fetch-att / "(" fetch-att *(SP fetch-att) ")") + + fetch-att = "ENVELOPE" / "FLAGS" / "INTERNALDATE" / + "RFC822" [".HEADER" / ".SIZE" / ".TEXT"] / + "BODY" ["STRUCTURE"] / "UID" / + "BODY" section [partial] / + "BODY.PEEK" section [partial] / + "BINARY" [".PEEK"] section-binary [partial] / + "BINARY.SIZE" section-binary + + partial = "<" number64 "." nz-number64 ">" + ; Partial FETCH request. 0-based offset of + ; the first octet, followed by the number of + ; octets in the fragment. + + section = "[" [section-spec] "]" + + section-binary = "[" [section-part] "]" + + section-msgtext = "HEADER" / + "HEADER.FIELDS" [".NOT"] SP header-list / + "TEXT" + ; top-level or MESSAGE/RFC822 or + ; MESSAGE/GLOBAL part + + section-part = nz-number *("." nz-number) + ; body part reference. + ; Allows for accessing nested body parts. + + section-spec = section-msgtext / (section-part ["." section-text]) + + section-text = section-msgtext / "MIME" + ; text other than actual body part (headers, + ; etc.) + + +*/ + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + Sequence, + fetch::{self, Attribute, Section}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_fetch() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A654 FETCH 2:4 (FLAGS BODY[HEADER.FIELDS (DATE FROM)])\r\n", + fetch::Arguments { + tag: "A654".into(), + sequence_set: Sequence::range(2.into(), 4.into()), + attributes: vec![ + Attribute::Flags, + Attribute::BodySection { + peek: false, + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["DATE".into(), "FROM".into()], + }], + partial: None, + }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 BODY[]\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[HEADER])\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![Section::Header], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY.PEEK[HEADER.FIELDS (X-MAILER)] PREVIEW(LAZY))\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: true, + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["X-MAILER".into()], + }], + partial: None, + }, + Attribute::Preview { lazy: true }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[HEADER.FIELDS.NOT (FROM TO SUBJECT)])\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::BodySection { + peek: false, + sections: vec![Section::HeaderFields { + not: true, + fields: vec!["FROM".into(), "TO".into(), "SUBJECT".into()], + }], + partial: None, + }], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODY[MIME] BODY[TEXT] PREVIEW)\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: false, + sections: vec![Section::Mime], + partial: None, + }, + Attribute::BodySection { + peek: false, + sections: vec![Section::Text], + partial: None, + }, + Attribute::Preview { lazy: false }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (BODYSTRUCTURE ENVELOPE FLAGS INTERNALDATE UID)\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodyStructure, + Attribute::Envelope, + Attribute::Flags, + Attribute::InternalDate, + Attribute::Uid, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 (RFC822 RFC822.HEADER RFC822.SIZE RFC822.TEXT)\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Rfc822, + Attribute::Rfc822Header, + Attribute::Rfc822Size, + Attribute::Rfc822Text, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + concat!( + "A001 FETCH 1 (", + "BODY[4.2.HEADER]<0.20> ", + "BODY.PEEK[3.2.2.2] ", + "BODY[4.2.TEXT]<4.100> ", + "BINARY[1.2.3] ", + "BINARY.PEEK[4] ", + "BINARY[6.5.4]<100.200> ", + "BINARY.PEEK[7]<9.88> ", + "BINARY.SIZE[9.1]", + ")\r\n" + ), + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::BodySection { + peek: false, + sections: vec![ + Section::Part { num: 4 }, + Section::Part { num: 2 }, + Section::Header, + ], + partial: Some((0, 20)), + }, + Attribute::BodySection { + peek: true, + sections: vec![ + Section::Part { num: 3 }, + Section::Part { num: 2 }, + Section::Part { num: 2 }, + Section::Part { num: 2 }, + ], + partial: None, + }, + Attribute::BodySection { + peek: false, + sections: vec![ + Section::Part { num: 4 }, + Section::Part { num: 2 }, + Section::Text, + ], + partial: Some((4, 100)), + }, + Attribute::Binary { + peek: false, + sections: vec![1, 2, 3], + partial: None, + }, + Attribute::Binary { + peek: true, + sections: vec![4], + partial: None, + }, + Attribute::Binary { + peek: false, + sections: vec![6, 5, 4], + partial: Some((100, 200)), + }, + Attribute::Binary { + peek: true, + sections: vec![7], + partial: Some((9, 88)), + }, + Attribute::BinarySize { + sections: vec![9, 1], + }, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 ALL\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 FULL\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + Attribute::Envelope, + Attribute::Body, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A001 FETCH 1 FAST\r\n", + fetch::Arguments { + tag: "A001".into(), + sequence_set: Sequence::number(1), + attributes: vec![ + Attribute::Flags, + Attribute::InternalDate, + Attribute::Rfc822Size, + ], + changed_since: None, + include_vanished: false, + }, + ), + ( + "s100 UID FETCH 1:* (FLAGS MODSEQ) (CHANGEDSINCE 12345 VANISHED)\r\n", + fetch::Arguments { + tag: "s100".into(), + sequence_set: Sequence::range(1.into(), None), + attributes: vec![Attribute::Flags, Attribute::ModSeq], + changed_since: 12345.into(), + include_vanished: true, + }, + ), + ( + "9 UID FETCH 1:* UID (VANISHED CHANGEDSINCE 1)\r\n", + fetch::Arguments { + tag: "9".into(), + sequence_set: Sequence::range(1.into(), None), + attributes: vec![Attribute::Uid], + changed_since: 1.into(), + include_vanished: true, + }, + ), + ( + "A010 FETCH 1:* (OBJECTID)\r\n", + fetch::Arguments { + tag: "A010".into(), + sequence_set: Sequence::range(1.into(), None), + attributes: vec![Attribute::ObjectId], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A011 FETCH 1 (UID OBJECTID FLAGS)\r\n", + fetch::Arguments { + tag: "A011".into(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::Uid, Attribute::ObjectId, Attribute::Flags], + changed_since: None, + include_vanished: false, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_fetch() + .expect(command), + arguments, + "{}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/list.rs b/crates/imap-proto/src/parser/list.rs new file mode 100644 index 0000000..dcd1021 --- /dev/null +++ b/crates/imap-proto/src/parser/list.rs @@ -0,0 +1,382 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString}; + +use crate::{ + Command, + protocol::{ + list::{self, ReturnOption, SelectionOption}, + status::Status, + }, + receiver::{Request, Token, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_list(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 0 | 1 => Err(self.into_error("Missing arguments.")), + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(list::Arguments::Basic { + reference_name: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }) + } + _ => { + let mut tokens = self.tokens.into_iter(); + let mut selection_options = Vec::new(); + let mut return_options = Vec::new(); + let mut mailbox_name = Vec::new(); + + let reference_name = match tokens.next().unwrap() { + Token::ParenthesisOpen => { + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + selection_options.push( + SelectionOption::parse(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid selection option argument.", + )); + } + } + } + tokens + .next() + .ok_or_else(|| { + bad(self.tag.to_compact_string(), "Missing reference name.") + })? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))? + } + token => token + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + }; + + match tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing mailbox name."))? + { + Token::ParenthesisOpen => { + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + token => { + mailbox_name.push( + token + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + } + } + } + token => { + mailbox_name.push(utf7_maybe_decode( + token + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + )); + } + } + + if tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case(b"return")) + { + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + self.tag.to_compact_string(), + "Invalid return option, expected parenthesis.", + )); + } + + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + let mut return_option = ReturnOption::parse(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + if let ReturnOption::Status(status) = &mut return_option { + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Invalid return option, expected parenthesis after STATUS.", + )); + } + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + status.push(Status::parse(&value).map_err( + |v| bad(self.tag.to_compact_string(), v), + )?); + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Invalid status return option argument.", + )); + } + } + } + } + return_options.push(return_option); + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid return option argument.", + )); + } + } + } + } + + Ok(list::Arguments::Extended { + tag: self.tag, + reference_name, + mailbox_name, + selection_options, + return_options, + }) + } + } + } +} + +impl SelectionOption { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "SUBSCRIBED" => Self::Subscribed, + "REMOTE" => Self::Remote, + "RECURSIVEMATCH" => Self::RecursiveMatch, + "SPECIAL-USE" => Self::SpecialUse, + ) + .ok_or_else(|| { + format!( + "Unsupported selection option '{}'.", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +impl ReturnOption { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "SUBSCRIBED" => Self::Subscribed, + "CHILDREN" => Self::Children, + "STATUS" => Self::Status(Vec::with_capacity(2)), + "SPECIAL-USE" => Self::SpecialUse, + ) + .ok_or_else(|| format!("Invalid return option {:?}", String::from_utf8_lossy(value)).into()) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + list::{self, ReturnOption, SelectionOption}, + status::Status, + }, + receiver::Receiver, + }; + + #[test] + fn parse_list() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A682 LIST \"\" *\r\n", + list::Arguments::Basic { + tag: "A682".into(), + reference_name: "".into(), + mailbox_name: "*".into(), + }, + ), + ( + "A02 LIST (SUBSCRIBED) \"\" \"*\"\r\n", + list::Arguments::Extended { + tag: "A02".into(), + reference_name: "".into(), + mailbox_name: vec!["*".into()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A03 LIST () \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "A03".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "A04 LIST (REMOTE) \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "A04".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![SelectionOption::Remote], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "A05 LIST (REMOTE SUBSCRIBED) \"\" \"*\"\r\n", + list::Arguments::Extended { + tag: "A05".into(), + reference_name: "".into(), + mailbox_name: vec!["*".into()], + selection_options: vec![SelectionOption::Remote, SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A06 LIST (REMOTE) \"\" \"*\" RETURN (SUBSCRIBED)\r\n", + list::Arguments::Extended { + tag: "A06".into(), + reference_name: "".into(), + mailbox_name: vec!["*".into()], + selection_options: vec![SelectionOption::Remote], + return_options: vec![ReturnOption::Subscribed], + }, + ), + ( + "C04 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"%\"\r\n", + list::Arguments::Extended { + tag: "C04".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![], + }, + ), + ( + "C04 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"%\" RETURN (CHILDREN)\r\n", + list::Arguments::Extended { + tag: "C04".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![ReturnOption::Children], + }, + ), + ( + "a1 LIST \"\" (\"foo\")\r\n", + list::Arguments::Extended { + tag: "a1".into(), + reference_name: "".into(), + mailbox_name: vec!["foo".into()], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "a3.1 LIST \"\" (% music/rock)\r\n", + list::Arguments::Extended { + tag: "a3.1".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into(), "music/rock".into()], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "BBB LIST \"\" (\"INBOX\" \"Drafts\" \"Sent/%\")\r\n", + list::Arguments::Extended { + tag: "BBB".into(), + reference_name: "".into(), + mailbox_name: vec!["INBOX".into(), "Drafts".into(), "Sent/%".into()], + selection_options: vec![], + return_options: vec![], + }, + ), + ( + "A01 LIST \"\" % RETURN (STATUS (MESSAGES UNSEEN))\r\n", + list::Arguments::Extended { + tag: "A01".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![], + return_options: vec![ReturnOption::Status(vec![ + Status::Messages, + Status::Unseen, + ])], + }, + ), + ( + concat!( + "A02 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" ", + "% RETURN (CHILDREN STATUS (MESSAGES))\r\n" + ), + list::Arguments::Extended { + tag: "A02".into(), + reference_name: "".into(), + mailbox_name: vec!["%".into()], + selection_options: vec![ + SelectionOption::Subscribed, + SelectionOption::RecursiveMatch, + ], + return_options: vec![ + ReturnOption::Children, + ReturnOption::Status(vec![Status::Messages]), + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_list(true) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/login.rs b/crates/imap-proto/src/parser/login.rs new file mode 100644 index 0000000..37eab32 --- /dev/null +++ b/crates/imap-proto/src/parser/login.rs @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::login, + receiver::{Request, bad}, +}; + +impl Request { + pub fn parse_login(self) -> trc::Result { + match self.tokens.len() { + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(login::Arguments { + username: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + password: tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + tag: self.tag, + }) + } + 0 => Err(self.into_error("Missing arguments.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::login, receiver::Receiver}; + + #[test] + fn parse_login() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "a001 LOGIN SMITH SESAME\r\n", + login::Arguments { + tag: "a001".into(), + username: "SMITH".into(), + password: "SESAME".into(), + }, + ), + ( + "A001 LOGIN {11+}\r\nFRED FOOBAR {7+}\r\nfat man\r\n", + login::Arguments { + tag: "A001".into(), + username: "FRED FOOBAR".into(), + password: "fat man".into(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_login() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/lsub.rs b/crates/imap-proto/src/parser/lsub.rs new file mode 100644 index 0000000..ebd601a --- /dev/null +++ b/crates/imap-proto/src/parser/lsub.rs @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::list::{self, SelectionOption}, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_lsub(self, is_utf8: bool) -> trc::Result { + if self.tokens.len() > 1 { + let mut tokens = self.tokens.into_iter(); + + Ok(list::Arguments::Extended { + reference_name: tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing reference name."))? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + mailbox_name: vec![utf7_maybe_decode( + tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing mailbox name."))? + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + )], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + tag: self.tag, + }) + } else { + Err(self.into_error("Missing arguments.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::list::{self, SelectionOption}, + receiver::Receiver, + }; + + #[test] + fn parse_lsub() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A002 LSUB \"#news.\" \"comp.mail.*\"\r\n", + list::Arguments::Extended { + tag: "A002".into(), + reference_name: "#news.".into(), + mailbox_name: vec!["comp.mail.*".into()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ( + "A002 LSUB \"#news.\" \"comp.%\"\r\n", + list::Arguments::Extended { + tag: "A002".into(), + reference_name: "#news.".into(), + mailbox_name: vec!["comp.%".into()], + selection_options: vec![SelectionOption::Subscribed], + return_options: vec![], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_lsub(false) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/mod.rs b/crates/imap-proto/src/parser/mod.rs new file mode 100644 index 0000000..96cf8a0 --- /dev/null +++ b/crates/imap-proto/src/parser/mod.rs @@ -0,0 +1,502 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod append; +pub mod authenticate; +pub mod copy_move; +pub mod create; +pub mod delete; +pub mod enable; +pub mod fetch; +pub mod list; +pub mod login; +pub mod lsub; +pub mod quota; +pub mod rename; +pub mod search; +pub mod select; +pub mod sort; +pub mod status; +pub mod store; +pub mod subscribe; +pub mod thread; +pub mod uidbatches; + +use std::{borrow::Cow, str::FromStr}; + +use chrono::{DateTime, NaiveDate}; + +use crate::{ + Command, + protocol::{Flag, Sequence}, + receiver::CommandParser, +}; + +pub type Result = std::result::Result>; + +impl CommandParser for Command { + fn parse(value: &[u8], uid: bool) -> Option { + hashify::tiny_map!(value, + "CAPABILITY" => Command::Capability, + "NOOP" => Command::Noop, + "LOGOUT" => Command::Logout, + "STARTTLS" => Command::StartTls, + "AUTHENTICATE" => Command::Authenticate, + "LOGIN" => Command::Login, + "ENABLE" => Command::Enable, + "SELECT" => Command::Select, + "EXAMINE" => Command::Examine, + "CREATE" => Command::Create, + "DELETE" => Command::Delete, + "RENAME" => Command::Rename, + "SUBSCRIBE" => Command::Subscribe, + "UNSUBSCRIBE" => Command::Unsubscribe, + "LIST" => Command::List, + "NAMESPACE" => Command::Namespace, + "STATUS" => Command::Status, + "APPEND" => Command::Append, + "IDLE" => Command::Idle, + "CLOSE" => Command::Close, + "UNSELECT" => Command::Unselect, + "EXPUNGE" => Command::Expunge(uid), + "SEARCH" => Command::Search(uid), + "FETCH" => Command::Fetch(uid), + "STORE" => Command::Store(uid), + "COPY" => Command::Copy(uid), + "MOVE" => Command::Move(uid), + "SORT" => Command::Sort(uid), + "THREAD" => Command::Thread(uid), + "LSUB" => Command::Lsub, + "CHECK" => Command::Check, + "SETACL" => Command::SetAcl, + "DELETEACL" => Command::DeleteAcl, + "GETACL" => Command::GetAcl, + "LISTRIGHTS" => Command::ListRights, + "MYRIGHTS" => Command::MyRights, + "UNAUTHENTICATE" => Command::Unauthenticate, + "ID" => Command::Id, + "GETQUOTA" => Command::GetQuota, + "GETQUOTAROOT" => Command::GetQuotaRoot, + "GETJMAPACCESS" => Command::GetJmapAccess, + "UIDBATCHES" => Command::UidBatches, + ) + } + + #[inline(always)] + fn tokenize_brackets(&self) -> bool { + matches!(self, Command::Fetch(_)) + } +} + +impl Flag { + pub fn parse_imap(value: Vec) -> Result { + if !value.is_empty() { + let flag = hashify::tiny_map_ignore_case!(value.as_slice(), + "\\Seen" => Flag::Seen, + "\\Answered" => Flag::Answered, + "\\Flagged" => Flag::Flagged, + "\\Deleted" => Flag::Deleted, + "\\Draft" => Flag::Draft, + "\\Recent" => Flag::Recent, + "\\Important" => Flag::Important, + "$Forwarded" => Flag::Forwarded, + "$MDNSent" => Flag::MDNSent, + "$Junk" => Flag::Junk, + "$NotJunk" => Flag::NotJunk, + "$Phishing" => Flag::Phishing, + "$Important" => Flag::Important, + "$autosent" => Flag::Autosent, + "$canunsubscribe" => Flag::CanUnsubscribe, + "$followed" => Flag::Followed, + "$hasattachment" => Flag::HasAttachment, + "$hasmemo" => Flag::HasMemo, + "$hasnoattachment" => Flag::HasNoAttachment, + "$imported" => Flag::Imported, + "$istrusted" => Flag::IsTrusted, + "$MailFlagBit0" => Flag::MailFlagBit0, + "$MailFlagBit1" => Flag::MailFlagBit1, + "$MailFlagBit2" => Flag::MailFlagBit2, + "$maskedemail" => Flag::MaskedEmail, + "$memo" => Flag::Memo, + "$muted" => Flag::Muted, + "$new" => Flag::New, + "$notify" => Flag::Notify, + "$unsubscribed" => Flag::Unsubscribed, + ); + + if let Some(flag) = flag { + Ok(flag) + } else { + String::from_utf8(value) + .map_err(|_| Cow::from("Invalid UTF-8.")) + .map(|v| Flag::Keyword(v.into_boxed_str())) + } + } else { + Err(Cow::from("Null flags are not allowed.")) + } + } + + pub fn parse_jmap(value: String) -> Self { + if value.starts_with('$') { + hashify::tiny_map_ignore_case!(value.as_bytes(), + "$seen" => Flag::Seen, + "$draft" => Flag::Draft, + "$flagged" => Flag::Flagged, + "$answered" => Flag::Answered, + "$recent" => Flag::Recent, + "$important" => Flag::Important, + "$phishing" => Flag::Phishing, + "$junk" => Flag::Junk, + "$notjunk" => Flag::NotJunk, + "$deleted" => Flag::Deleted, + "$forwarded" => Flag::Forwarded, + "$mdnsent" => Flag::MDNSent, + "$autosent" => Flag::Autosent, + "$canunsubscribe" => Flag::CanUnsubscribe, + "$followed" => Flag::Followed, + "$hasattachment" => Flag::HasAttachment, + "$hasmemo" => Flag::HasMemo, + "$hasnoattachment" => Flag::HasNoAttachment, + "$imported" => Flag::Imported, + "$istrusted" => Flag::IsTrusted, + "$MailFlagBit0" => Flag::MailFlagBit0, + "$MailFlagBit1" => Flag::MailFlagBit1, + "$MailFlagBit2" => Flag::MailFlagBit2, + "$maskedemail" => Flag::MaskedEmail, + "$memo" => Flag::Memo, + "$muted" => Flag::Muted, + "$new" => Flag::New, + "$notify" => Flag::Notify, + "$unsubscribed" => Flag::Unsubscribed, + ) + .unwrap_or_else(|| Flag::Keyword(value.into_boxed_str())) + } else { + let mut keyword = String::with_capacity(value.len()); + for c in value.chars() { + if c.is_ascii_alphanumeric() { + keyword.push(c); + } else { + keyword.push('_'); + } + } + Flag::Keyword(keyword.into_boxed_str()) + } + } +} + +pub fn parse_datetime(value: &[u8]) -> Result { + std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected date/time, found an invalid UTF-8 string.")) + .and_then(|datetime| { + DateTime::parse_from_str(datetime.trim(), "%d-%b-%Y %H:%M:%S %z") + .map_err(|_| Cow::from(format!("Failed to parse date/time '{}'.", datetime))) + .map(|dt| dt.timestamp()) + }) +} + +pub fn parse_date(value: &[u8]) -> Result { + std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected date, found an invalid UTF-8 string.")) + .and_then(|date| { + NaiveDate::parse_from_str(date.trim(), "%d-%b-%Y") + .map_err(|_| Cow::from(format!("Failed to parse date '{}'.", date))) + .map(|dt| { + dt.and_hms_opt(0, 0, 0) + .unwrap_or_default() + .and_utc() + .timestamp() + }) + }) +} + +pub fn parse_number(value: &[u8]) -> Result { + std::str::from_utf8(value) + .map_err(|_| Cow::from("Expected a number, found an invalid UTF-8 string.")) + .and_then(|string| { + string + .parse::() + .map_err(|_| Cow::from(format!("Expected a number, found {:?}.", string))) + }) +} + +pub fn parse_sequence_set(value: &[u8]) -> Result { + let mut sequence_set = Vec::new(); + + let mut range_start = None; + let mut token_start = None; + + let mut is_wildcard = false; + let mut is_range = false; + let mut is_saved_search = false; + + for (mut pos, ch) in value.iter().enumerate() { + let mut add_token = false; + match ch { + b',' => { + add_token = true; + } + b':' => { + if !is_range { + if let Some(from_pos) = token_start { + range_start = + parse_number::(value.get(from_pos..pos).ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?)? + .into(); + token_start = None; + } else if is_wildcard { + is_wildcard = false; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, number expected before ':'.", + String::from_utf8_lossy(value) + ))); + } + is_range = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, ':' appears multiple times.", + String::from_utf8_lossy(value) + ))); + } + } + b'*' => { + if !is_wildcard { + if value.len() == 1 { + return Ok(Sequence::Range { + start: None, + end: None, + }); + } else if token_start.is_none() { + is_wildcard = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, invalid use of '*'.", + String::from_utf8_lossy(value) + ))); + } + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, '*' appears multiple times.", + String::from_utf8_lossy(value) + ))); + } + } + b'$' => { + if value.get(pos + 1).is_none_or(|&ch| ch == b',') { + is_saved_search = true; + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, unexpected token after '$'.", + String::from_utf8_lossy(value) + ))); + } + } + _ => { + if ch.is_ascii_digit() { + if is_wildcard { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, invalid use of '*'.", + String::from_utf8_lossy(value) + ))); + } + if token_start.is_none() { + token_start = pos.into(); + } + } else { + return Err(Cow::from(format!( + "Invalid sequence set {:?}, found invalid character '{}' at position {}.", + String::from_utf8_lossy(value), + ch, + pos + ))); + } + } + } + + if add_token || pos == value.len() - 1 { + if is_range { + sequence_set.push(Sequence::Range { + start: range_start, + end: if !is_wildcard { + if !add_token { + pos += 1; + } + parse_number::( + value + .get( + token_start.ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, expected number.", + String::from_utf8_lossy(value) + )) + })?..pos, + ) + .ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?, + )? + .into() + } else { + is_wildcard = false; + None + }, + }); + is_range = false; + range_start = None; + } else { + if !add_token { + pos += 1; + } + if is_wildcard { + sequence_set.push(Sequence::Range { + start: None, + end: None, + }); + is_wildcard = false; + } else if is_saved_search { + sequence_set.push(Sequence::SavedSearch); + is_saved_search = false; + } else { + sequence_set.push(Sequence::Number { + value: parse_number( + value + .get( + token_start.ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, expected number.", + String::from_utf8_lossy(value) + )) + })?..pos, + ) + .ok_or_else(|| { + Cow::from(format!( + "Invalid sequence set {:?}, parse error.", + String::from_utf8_lossy(value) + )) + })?, + )?, + }); + } + } + token_start = None; + } + } + + match sequence_set.len() { + 1 => Ok(sequence_set.pop().unwrap()), + 0 => Err(Cow::from("Invalid empty sequence set.")), + _ => Ok(Sequence::List { + items: sequence_set, + }), + } +} + +pub trait PushUnique { + fn push_unique(&mut self, value: T); +} + +impl PushUnique for Vec { + fn push_unique(&mut self, value: T) { + if !self.contains(&value) { + self.push(value); + } + } +} + +#[cfg(test)] +mod tests { + use crate::{Command, protocol::Sequence, receiver::CommandParser}; + + #[test] + fn parse_command() { + assert_eq!( + Command::parse(b"GETJMAPACCESS", false), + Some(Command::GetJmapAccess) + ); + assert_eq!(Command::parse(b"NOTACOMMAND", false), None); + } + + #[test] + fn parse_sequence_set() { + for (sequence, expected_result) in [ + ("$", Sequence::SavedSearch), + ( + "*", + Sequence::Range { + start: None, + end: None, + }, + ), + ( + "1,3000:3021", + Sequence::List { + items: vec![ + Sequence::Number { value: 1 }, + Sequence::Range { + start: 3000.into(), + end: 3021.into(), + }, + ], + }, + ), + ( + "2,4:7,9,12:*", + Sequence::List { + items: vec![ + Sequence::Number { value: 2 }, + Sequence::Range { + start: 4.into(), + end: 7.into(), + }, + Sequence::Number { value: 9 }, + Sequence::Range { + start: 12.into(), + end: None, + }, + ], + }, + ), + ( + "*:4,5:7", + Sequence::List { + items: vec![ + Sequence::Range { + start: None, + end: 4.into(), + }, + Sequence::Range { + start: 5.into(), + end: 7.into(), + }, + ], + }, + ), + ( + "2,4,5", + Sequence::List { + items: vec![ + Sequence::Number { value: 2 }, + Sequence::Number { value: 4 }, + Sequence::Number { value: 5 }, + ], + }, + ), + ] { + assert_eq!( + super::parse_sequence_set(sequence.as_bytes()).unwrap(), + expected_result + ); + } + } +} diff --git a/crates/imap-proto/src/parser/quota.rs b/crates/imap-proto/src/parser/quota.rs new file mode 100644 index 0000000..2159a73 --- /dev/null +++ b/crates/imap-proto/src/parser/quota.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::quota, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_get_quota_root(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 1 => Ok(quota::Arguments { + name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } + + pub fn parse_get_quota(self) -> trc::Result { + match self.tokens.len() { + 1 => Ok(quota::Arguments { + name: self + .tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + tag: self.tag, + }), + 0 => Err(self.into_error("Missing quota root.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::quota, receiver::Receiver}; + + #[test] + fn parse_quota() { + let mut receiver = Receiver::new(); + + let (command, arguments) = ( + "A142 GETQUOTAROOT INBOX\r\n", + quota::Arguments { + name: "INBOX".into(), + tag: "A142".into(), + }, + ); + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_get_quota_root(true) + .unwrap(), + arguments + ); + + let (command, arguments) = ( + "A142 GETQUOTA \"my funky mailbox\"\r\n", + quota::Arguments { + name: "my funky mailbox".into(), + tag: "A142".into(), + }, + ); + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_get_quota() + .unwrap(), + arguments + ); + } +} diff --git a/crates/imap-proto/src/parser/rename.rs b/crates/imap-proto/src/parser/rename.rs new file mode 100644 index 0000000..4486c58 --- /dev/null +++ b/crates/imap-proto/src/parser/rename.rs @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::rename, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_rename(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 2 => { + let mut tokens = self.tokens.into_iter(); + Ok(rename::Arguments { + mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + new_mailbox_name: utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }) + } + 0 => Err(self.into_error("Missing argument.")), + 1 => Err(self.into_error("Missing new mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::rename, receiver::Receiver}; + + #[test] + fn parse_rename() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 RENAME \"my funky mailbox\" Private\r\n", + rename::Arguments { + mailbox_name: "my funky mailbox".into(), + new_mailbox_name: "Private".into(), + tag: "A142".into(), + }, + ), + ( + "A142 RENAME {1+}\r\na {1+}\r\nb\r\n", + rename::Arguments { + mailbox_name: "a".into(), + new_mailbox_name: "b".into(), + tag: "A142".into(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_rename(true) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/search.rs b/crates/imap-proto/src/parser/search.rs new file mode 100644 index 0000000..3a75e77 --- /dev/null +++ b/crates/imap-proto/src/parser/search.rs @@ -0,0 +1,890 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; +use std::iter::Peekable; +use std::vec::IntoIter; + +use compact_str::ToCompactString; +use mail_parser::decoders::charsets::DecoderFnc; +use mail_parser::decoders::charsets::map::charset_decoder; + +use crate::Command; +use crate::protocol::search::{self, Filter}; +use crate::protocol::search::{ModSeqEntry, ResultOption}; +use crate::protocol::{Flag, ProtocolVersion}; +use crate::receiver::{Request, Token, bad}; + +use super::{parse_date, parse_number, parse_sequence_set}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_search(self, version: ProtocolVersion) -> trc::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing search criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut result_options = Vec::new(); + let mut decoder = None; + let mut is_esearch = version.is_rev2(); + + loop { + match tokens.peek() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"return") => { + tokens.next(); + is_esearch = true; + result_options = parse_result_options(&mut tokens) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + } + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"charset") => { + tokens.next(); + decoder = charset_decoder( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing charset."))? + .unwrap_bytes(), + ); + } + _ => break, + } + } + + let filter = parse_filters(&mut tokens, decoder) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + match filter.len() { + 0 => Err(bad( + self.tag.to_compact_string(), + "No filters found in command.", + )), + _ => Ok(search::Arguments { + tag: self.tag, + result_options, + filter, + sort: None, + is_esearch, + }), + } + } +} + +pub fn parse_result_options( + tokens: &mut Peekable>, +) -> super::Result> { + let mut result_options = Vec::new(); + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(Cow::from("Invalid result option, expected parenthesis.")); + } + + for token in tokens { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + result_options.push(ResultOption::parse(&value)?); + } + _ => return Err(Cow::from("Invalid result option argument.")), + } + } + + Ok(result_options) +} + +pub fn parse_filters( + tokens: &mut Peekable>, + decoder: Option, +) -> super::Result> { + let mut filters = Vec::new(); + let mut filters_len = 0; + let mut filters_stack = Vec::new(); + let mut operator = Filter::And; + + while let Some(token) = tokens.next() { + let mut found_parenthesis = false; + match token { + Token::Argument(value) => { + hashify::fnc_map_ignore_case!(value.as_slice(), + "ALL" => { + filters.push(Filter::All); + + }, + "ANSWERED" => { + filters.push(Filter::Answered); + + }, + "BCC" => { + filters.push(Filter::Bcc(decode_argument(tokens, decoder)?)); + + }, + "BEFORE" => { + filters.push(Filter::Before(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + }, + "BODY" => { + filters.push(Filter::Body(decode_argument(tokens, decoder)?)); + + + }, + "CC" => { + filters.push(Filter::Cc(decode_argument(tokens, decoder)?)); + + + }, + "DELETED" => { + filters.push(Filter::Deleted); + + + }, + "DRAFT" => { + filters.push(Filter::Draft); + + + }, + "FLAGGED" => { + filters.push(Filter::Flagged); + + + }, + "FROM" => { + filters.push(Filter::From(decode_argument(tokens, decoder)?)); + + + }, + "HEADER" => { + filters.push(Filter::Header( + decode_argument(tokens, decoder)?, + decode_argument(tokens, decoder)?, + )); + + + }, + "KEYWORD" => { + filters.push(Filter::Keyword(Flag::parse_imap( + tokens + .next() + .ok_or_else(|| Cow::from("Expected keyword"))? + .unwrap_bytes(), + )?)); + + + }, + "LARGER" => { + filters.push(Filter::Larger(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "ON" => { + filters.push(Filter::On(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + + }, + "SEEN" => { + filters.push(Filter::Seen); + + + }, + "SENTBEFORE" => { + filters.push(Filter::SentBefore(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + + }, + "SENTON" => { + filters.push(Filter::SentOn(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + + }, + "SENTSINCE" => { + filters.push(Filter::SentSince(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + + }, + "SINCE" => { + filters.push(Filter::Since(parse_date( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected date"))? + .unwrap_bytes(), + )?)); + + + }, + "SMALLER" => { + filters.push(Filter::Smaller(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "SUBJECT" => { + filters.push(Filter::Subject(decode_argument(tokens, decoder)?)); + + + }, + "TEXT" => { + filters.push(Filter::Text(decode_argument(tokens, decoder)?)); + + + }, + "TO" => { + filters.push(Filter::To(decode_argument(tokens, decoder)?)); + + + }, + "UID" => { + filters.push(Filter::Sequence( + parse_sequence_set( + &tokens + .next() + .ok_or_else(|| Cow::from("Missing sequence set."))? + .unwrap_bytes(), + )?, + true, + )); + + + }, + "UNANSWERED" => { + filters.push(Filter::Unanswered); + + + }, + "UNDELETED" => { + filters.push(Filter::Undeleted); + + + }, + "UNDRAFT" => { + filters.push(Filter::Undraft); + + + }, + "UNFLAGGED" => { + filters.push(Filter::Unflagged); + + + }, + "UNKEYWORD" => { + filters.push(Filter::Unkeyword(Flag::parse_imap( + tokens + .next() + .ok_or_else(|| Cow::from("Expected keyword"))? + .unwrap_bytes(), + )?)); + + + }, + "UNSEEN" => { + filters.push(Filter::Unseen); + + + }, + "UIDAFTER" => { + filters.push(Filter::UidAfter(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "UIDBEFORE" => { + filters.push(Filter::UidBefore(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "OLDER" => { + filters.push(Filter::Older(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "YOUNGER" => { + filters.push(Filter::Younger(parse_number::( + &tokens + .next() + .ok_or_else(|| Cow::from("Expected integer"))? + .unwrap_bytes(), + )?)); + + + }, + "OLD" => { + filters.push(Filter::Old); + + }, + "NEW" => { + filters.push(Filter::New); + + }, + "RECENT" => { + filters.push(Filter::Recent); + + }, + "MODSEQ" => { + let param = tokens + .next() + .ok_or_else(|| Cow::from("Missing MODSEQ parameters."))? + .unwrap_bytes(); + if param.is_empty() || param.iter().any(|ch| !ch.is_ascii_digit()) { + if param.len() <= 7 || !param.starts_with(b"/flags/") { + return Err(format!( + "Unsupported MODSEQ parameter '{}'.", + String::from_utf8_lossy(¶m) + ) + .into()); + } + let flag = Flag::parse_imap((param[7..]).to_vec())?; + let mod_seq_entry = match tokens.next() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"all") => { + ModSeqEntry::All(flag) + } + Some(Token::Argument(value)) + if value.eq_ignore_ascii_case(b"shared") => + { + ModSeqEntry::Shared(flag) + } + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"priv") => { + ModSeqEntry::Private(flag) + } + Some(token) => { + return Err( + format!("Unsupported MODSEQ parameter '{}'.", token).into() + ); + } + None => { + return Err("Missing MODSEQ entry-type-req parameter.".into()); + } + }; + filters.push(Filter::ModSeq(( + parse_number::( + &tokens + .next() + .ok_or_else(|| { + Cow::from("Missing MODSEQ mod-sequence-valzer parameter.") + })? + .unwrap_bytes(), + )?, + mod_seq_entry, + ))); + } else { + filters.push(Filter::ModSeq(( + parse_number::(¶m)?, + ModSeqEntry::None, + ))); + } + + }, + "EMAILID" => { + filters.push(Filter::EmailId( + tokens + .next() + .ok_or_else(|| Cow::from("Expected an EMAILID value."))? + .unwrap_string()?, + )); + + }, + "THREADID" => { + filters.push(Filter::ThreadId( + tokens + .next() + .ok_or_else(|| Cow::from("Expected an THREADID value."))? + .unwrap_string()?, + )); + + }, + "OR" => { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(2); + operator = Filter::Or; + continue; + }, + "NOT" => { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(1); + operator = Filter::Not; + continue; + }, + _ => { + filters.push(Filter::Sequence(parse_sequence_set(&value)?, false)); + } + ); + + filters_len += 1; + } + Token::ParenthesisOpen => { + if filters_stack.len() > 10 { + return Err(Cow::from("Too many nested filters")); + } + + filters_stack.push((filters, operator, filters_len)); + filters_len = 0; + filters = Vec::with_capacity(5); + operator = Filter::And; + continue; + } + Token::ParenthesisClose => { + if filters_stack.is_empty() { + return Err(Cow::from("Unexpected parenthesis.")); + } + + found_parenthesis = true; + } + token => return Err(format!("Unexpected token {:?}.", token.to_string()).into()), + } + + if !filters_stack.is_empty() + && (found_parenthesis + || (operator == Filter::Or && filters_len == 2) + || (operator == Filter::Not && filters_len == 1)) + { + while let Some((mut prev_filters, prev_operator, prev_filters_len)) = + filters_stack.pop() + { + if operator == Filter::And && (prev_operator != Filter::Or || filters_len == 1) { + prev_filters.extend(filters); + filters_len += prev_filters_len; + } else { + prev_filters.push(operator); + prev_filters.extend(filters); + prev_filters.push(Filter::End); + filters_len = prev_filters_len + 1; + } + operator = prev_operator; + filters = prev_filters; + + if operator == Filter::And || (operator == Filter::Or && filters_len < 2) { + break; + } + } + } + } + Ok(filters) +} + +pub fn decode_argument( + tokens: &mut Peekable>, + decoder: Option, +) -> super::Result { + let argument = tokens + .next() + .ok_or_else(|| Cow::from("Expected string."))? + .unwrap_bytes(); + + if let Some(decoder) = decoder { + Ok(decoder(&argument)) + } else { + Ok(String::from_utf8(argument).map_err(|_| Cow::from("Invalid UTF-8 argument."))?) + } +} + +impl ResultOption { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!( + value, + "min" => Self::Min, + "max" => Self::Max, + "all" => Self::All, + "count" => Self::Count, + "save" => Self::Save, + "context" => Self::Context, + ) + .ok_or_else(|| { + format!( + "Invalid result option '{}'.", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + Flag, ProtocolVersion, Sequence, + search::{self, Filter, ModSeqEntry, ResultOption}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_search() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A282 SEARCH RETURN (MIN COUNT) FLAGGED SINCE 1-Feb-1994 NOT FROM \"Smith\"\r\n" + .to_vec(), + search::Arguments { + tag: "A282".into(), + result_options: vec![ResultOption::Min, ResultOption::Count], + filter: vec![ + Filter::Flagged, + Filter::Since(760060800), + Filter::Not, + Filter::From("Smith".into()), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"A283 SEARCH RETURN () FLAGGED SINCE 1-Feb-1994 NOT FROM \"Smith\"\r\n".to_vec(), + search::Arguments { + tag: "A283".into(), + result_options: vec![], + filter: vec![ + Filter::Flagged, + Filter::Since(760060800), + Filter::Not, + Filter::From("Smith".into()), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"A301 SEARCH $ SMALLER 4096\r\n".to_vec(), + search::Arguments { + tag: "A301".into(), + result_options: vec![], + filter: vec![Filter::seq_saved_search(), Filter::Smaller(4096)], + is_esearch: true, + sort: None, + }, + ), + ( + "P283 SEARCH CHARSET UTF-8 (OR $ 1,3000:3021) TEXT {8+}\r\nмать\r\n" + .as_bytes() + .to_vec(), + search::Arguments { + tag: "P283".into(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::seq_saved_search(), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::number(1), + Sequence::range(3000.into(), 3021.into()), + ], + }, + false, + ), + Filter::End, + Filter::Text("мать".into()), + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"F282 SEARCH RETURN (SAVE) KEYWORD $Junk\r\n".to_vec(), + search::Arguments { + tag: "F282".into(), + result_options: vec![ResultOption::Save], + filter: vec![Filter::Keyword(Flag::Junk)], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"F282 SEARCH OR OR FROM hello@world.com TO ".to_vec(), + b"test@example.com OR BCC jane@foobar.com ".to_vec(), + b"CC john@doe.com\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "F282".into(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Or, + Filter::From("hello@world.com".into()), + Filter::To("test@example.com".into()), + Filter::End, + Filter::Or, + Filter::Bcc("jane@foobar.com".into()), + Filter::Cc("john@doe.com".into()), + Filter::End, + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH OR SMALLER 10000 OR ".to_vec(), + b"HEADER Subject \"ravioli festival\" ".to_vec(), + b"HEADER From \"dr. ravioli\"\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".into(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Smaller(10000), + Filter::Or, + Filter::Header("Subject".into(), "ravioli festival".into()), + Filter::Header("From".into(), "dr. ravioli".into()), + Filter::End, + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH (DELETED SEEN ANSWERED) ".to_vec(), + b"NOT (FROM john TO jane BCC bill) ".to_vec(), + b"(1,30:* UID 1,2,3,4 $)\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".into(), + result_options: vec![], + filter: vec![ + Filter::Deleted, + Filter::Seen, + Filter::Answered, + Filter::Not, + Filter::From("john".into()), + Filter::To("jane".into()), + Filter::Bcc("bill".into()), + Filter::End, + Filter::Sequence( + Sequence::List { + items: vec![Sequence::number(1), Sequence::range(30.into(), None)], + }, + false, + ), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::number(1), + Sequence::number(2), + Sequence::number(3), + Sequence::number(4), + ], + }, + true, + ), + Filter::seq_saved_search(), + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH *:* UID *:100,100:* ".to_vec(), + b"(FLAGGED (DRAFT (DELETED (ANSWERED)))) ".to_vec(), + b"OR (SENTON 20-Nov-2022) (LARGER 8196)\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".into(), + result_options: vec![], + filter: vec![ + Filter::seq_range(None, None), + Filter::Sequence( + Sequence::List { + items: vec![ + Sequence::range(None, 100.into()), + Sequence::range(100.into(), None), + ], + }, + true, + ), + Filter::Flagged, + Filter::Draft, + Filter::Deleted, + Filter::Answered, + Filter::Or, + Filter::SentOn(1668902400), + Filter::Larger(8196), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"abc SEARCH NOT (FROM john OR TO jane CC bill) ".to_vec(), + b"OR (UNDELETED ALL) ($ NOT FLAGGED) ".to_vec(), + b"(((KEYWORD \"tps report\")))\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "abc".into(), + result_options: vec![], + filter: vec![ + Filter::Not, + Filter::From("john".into()), + Filter::Or, + Filter::To("jane".into()), + Filter::Cc("bill".into()), + Filter::End, + Filter::End, + Filter::Or, + Filter::And, + Filter::Undeleted, + Filter::All, + Filter::End, + Filter::And, + Filter::seq_saved_search(), + Filter::Not, + Filter::Flagged, + Filter::End, + Filter::End, + Filter::End, + Filter::Keyword(Flag::Keyword("tps report".into())), + ], + is_esearch: true, + sort: None, + }, + ), + ( + [ + b"B283 SEARCH RETURN (SAVE MIN MAX) CHARSET KOI8-R TEXT ".to_vec(), + b"{11+}\r\n\xf0\xd2\xc9\xd7\xc5\xd4, \xcd\xc9\xd2\r\n".to_vec(), + ] + .concat(), + search::Arguments { + tag: "B283".into(), + result_options: vec![ResultOption::Save, ResultOption::Min, ResultOption::Max], + filter: vec![Filter::Text("Привет, мир".into())], + is_esearch: true, + sort: None, + }, + ), + ( + b"B283 SEARCH CHARSET BIG5 FROM \"\xa7A\xa6n\xa1A\xa5@\xac\xc9\"\r\n".to_vec(), + search::Arguments { + tag: "B283".into(), + result_options: vec![], + filter: vec![Filter::From("你好,世界".into())], + is_esearch: true, + sort: None, + }, + ), + ( + b"a SEARCH MODSEQ \"/flags/\\draft\" all 620162338\r\n".to_vec(), + search::Arguments { + tag: "a".into(), + result_options: vec![], + filter: vec![Filter::ModSeq((620162338, ModSeqEntry::All(Flag::Draft)))], + is_esearch: true, + sort: None, + }, + ), + ( + b"t SEARCH OR NOT MODSEQ 720162338 LARGER 50000\r\n".to_vec(), + search::Arguments { + tag: "t".into(), + result_options: vec![], + filter: vec![ + Filter::Or, + Filter::Not, + Filter::ModSeq((720162338, ModSeqEntry::None)), + Filter::End, + Filter::Larger(50000), + Filter::End, + ], + is_esearch: true, + sort: None, + }, + ), + ( + b"5 UID SEARCH BEFORE 1-Dec-2023\r\n".to_vec(), + search::Arguments { + tag: "5".into(), + result_options: vec![], + filter: vec![Filter::Before(1701388800)], + is_esearch: true, + sort: None, + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_search(ProtocolVersion::Rev2) + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/parser/select.rs b/crates/imap-proto/src/parser/select.rs new file mode 100644 index 0000000..0e64fc7 --- /dev/null +++ b/crates/imap-proto/src/parser/select.rs @@ -0,0 +1,511 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString, format_compact}; +use std::str::FromStr; +use types::id::Id; + +use crate::{ + Command, + protocol::{ + ObjectId, + select::{self, QResync}, + }, + receiver::{Request, Token, bad}, + utf7::utf7_maybe_decode, +}; + +use super::{parse_number, parse_sequence_set}; + +impl Request { + pub fn parse_select(self, is_utf8: bool) -> trc::Result { + if !self.tokens.is_empty() { + let mut tokens = self.tokens.into_iter().peekable(); + + // Mailbox name + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ); + + // CONDSTORE parameters + let mut condstore = false; + let mut qresync = None; + let mut objectid = None; + match tokens.next() { + Some(Token::ParenthesisOpen) => { + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"CONDSTORE") => { + condstore = true; + } + Token::Argument(param) if param.eq_ignore_ascii_case(b"QRESYNC") => { + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Expected '(' after 'QRESYNC'.", + )); + } + + let uid_validity = parse_number::( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing uidvalidity parameter for QRESYNC.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + let modseq = parse_number::( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing modseq parameter for QRESYNC.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + let mut known_uids = None; + let mut seq_match = None; + let has_seq_match = match tokens.peek() { + Some(Token::Argument(value)) => { + known_uids = parse_sequence_set(value) + .map_err(|v| bad(self.tag.to_compact_string(), v))? + .into(); + tokens.next(); + if matches!(tokens.peek(), Some(Token::ParenthesisOpen)) { + tokens.next(); + true + } else { + false + } + } + Some(Token::ParenthesisOpen) => { + tokens.next(); + true + } + _ => false, + }; + + if has_seq_match { + seq_match = Some(( + parse_sequence_set( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing known-sequence-set parameter for QRESYNC.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + parse_sequence_set( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing known-uid-set parameter for QRESYNC.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + )); + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_close()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Missing ')' for 'QRESYNC'.", + )); + } + } + + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_close()) + { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Missing ')' for 'QRESYNC'.", + )); + } + + qresync = QResync { + uid_validity, + modseq, + known_uids, + seq_match, + } + .into(); + } + Token::Argument(param) if param.eq_ignore_ascii_case(b"OBJECTID") => { + let mut oid = ObjectId::default(); + if matches!(tokens.peek(), Some(Token::ParenthesisOpen)) { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(key) => { + let value = tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Expected value after OBJECTID key.", + ) + })? + .unwrap_bytes(); + let id = std::str::from_utf8(&value) + .ok() + .and_then(|v| Id::from_str(v).ok()); + hashify::fnc_map_ignore_case!(key.as_slice(), + "MAILBOXID" => { oid.mailbox_id = id; }, + "ACCOUNTID" => { oid.account_id = id; }, + "EMAILID" => { oid.email_id = id; }, + "THREADID" => { oid.thread_id = id; }, + _ => {} + ); + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Unexpected value '{}'.", + token + ), + )); + } + } + } + } + objectid = Some(oid); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!("Unexpected value '{}'.", token), + )); + } + } + } + } + Some(token) => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!("Unexpected value '{}'.", token), + )); + } + None => (), + } + + Ok(select::Arguments { + mailbox_name, + tag: self.tag, + condstore, + qresync, + objectid, + }) + } else { + Err(self.into_error("Missing mailbox name.")) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + protocol::{ + ObjectId, Sequence, + select::{self, QResync}, + }, + receiver::Receiver, + }; + use std::str::FromStr; + use types::id::Id; + + #[test] + fn parse_select_objectid() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A1 SELECT \"foo\" (OBJECTID)\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A1".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ( + "A2 SELECT \"foo\" (OBJECTID (MAILBOXID abc ACCOUNTID xyz))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A2".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + account_id: Some(Id::from_str("xyz").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A3 EXAMINE \"foo\" (OBJECTID (MAILBOXID abc))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A3".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A4 SELECT \"foo\" (CONDSTORE OBJECTID)\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A4".into(), + condstore: true, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ( + "A5 SELECT \"foo\" (OBJECTID (FOOBAR baz MAILBOXID abc))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A5".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A6 SELECT \"foo\" (OBJECTID (MAILBOXID 456))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A6".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_select(true) + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } + } + + #[test] + fn parse_select() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 SELECT INBOX\r\n", + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "A142".into(), + condstore: false, + qresync: None, + objectid: None, + }, + ), + ( + "A142 SELECT \"my funky mailbox\"\r\n", + select::Arguments { + mailbox_name: "my funky mailbox".into(), + tag: "A142".into(), + condstore: false, + qresync: None, + objectid: None, + }, + ), + ( + "A142 SELECT INBOX (CONDSTORE)\r\n", + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "A142".into(), + condstore: true, + qresync: None, + objectid: None, + }, + ), + ( + "A142 SELECT INBOX (QRESYNC (3857529045 20010715194032001 1:198))\r\n", + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "A142".into(), + condstore: false, + qresync: QResync { + uid_validity: 3857529045, + modseq: 20010715194032001, + known_uids: Some(Sequence::Range { + start: Some(1), + end: Some(198), + }), + seq_match: None, + } + .into(), + objectid: None, + }, + ), + ( + concat!( + "A03 SELECT INBOX (QRESYNC (67890007 90060115194045000 ", + "41:211,214:541) CONDSTORE)\r\n" + ), + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "A03".into(), + condstore: true, + qresync: QResync { + uid_validity: 67890007, + modseq: 90060115194045000, + known_uids: Some(Sequence::List { + items: vec![ + Sequence::Range { + start: Some(41), + end: Some(211), + }, + Sequence::Range { + start: Some(214), + end: Some(541), + }, + ], + }), + seq_match: None, + } + .into(), + objectid: None, + }, + ), + ( + concat!( + "B04 SELECT INBOX (QRESYNC (67890007 ", + "90060115194045000 1:29997 (5000,7500,9000,9990:9999 15000,", + "22500,27000,29970,29973,29976,29979,29982,29985,29988,29991,", + "29994,29997)))\r\n" + ), + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "B04".into(), + condstore: false, + qresync: QResync { + uid_validity: 67890007, + modseq: 90060115194045000, + known_uids: Some(Sequence::Range { + start: Some(1), + end: Some(29997), + }), + seq_match: Some(( + Sequence::List { + items: vec![ + Sequence::Number { value: 5000 }, + Sequence::Number { value: 7500 }, + Sequence::Number { value: 9000 }, + Sequence::Range { + start: Some(9990), + end: Some(9999), + }, + ], + }, + Sequence::List { + items: vec![ + Sequence::Number { value: 15000 }, + Sequence::Number { value: 22500 }, + Sequence::Number { value: 27000 }, + Sequence::Number { value: 29970 }, + Sequence::Number { value: 29973 }, + Sequence::Number { value: 29976 }, + Sequence::Number { value: 29979 }, + Sequence::Number { value: 29982 }, + Sequence::Number { value: 29985 }, + Sequence::Number { value: 29988 }, + Sequence::Number { value: 29991 }, + Sequence::Number { value: 29994 }, + Sequence::Number { value: 29997 }, + ], + }, + )), + } + .into(), + objectid: None, + }, + ), + ( + "A12 SELECT \"INBOX\" (QRESYNC (1693237464 16582))\r\n", + select::Arguments { + mailbox_name: "INBOX".into(), + tag: "A12".into(), + condstore: false, + qresync: QResync { + uid_validity: 1693237464, + modseq: 16582, + known_uids: None, + seq_match: None, + } + .into(), + objectid: None, + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap_or_else(|err| panic!( + "Failed to parse command '{}': {:?}", + command, err + )) + .parse_select(true) + .unwrap_or_else(|err| panic!( + "Failed to parse command '{}': {:?}", + command, err + )), + arguments, + "Failed to parse {}", + command + ); + } + } +} diff --git a/crates/imap-proto/src/parser/sort.rs b/crates/imap-proto/src/parser/sort.rs new file mode 100644 index 0000000..d3902ce --- /dev/null +++ b/crates/imap-proto/src/parser/sort.rs @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; +use mail_parser::decoders::charsets::map::charset_decoder; + +use crate::{ + Command, + protocol::search::{Arguments, Comparator, Sort}, + receiver::{Request, Token, bad}, +}; + +use super::search::{parse_filters, parse_result_options}; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_sort(self) -> trc::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing sort criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let mut sort = Vec::new(); + + let (result_options, is_esearch) = match tokens.peek() { + Some(Token::Argument(value)) if value.eq_ignore_ascii_case(b"return") => { + tokens.next(); + ( + parse_result_options(&mut tokens) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + true, + ) + } + _ => (Vec::new(), false), + }; + + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + self.tag.to_compact_string(), + "Expected sort criteria between parentheses.", + )); + } + + let mut is_ascending = true; + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + if value.eq_ignore_ascii_case(b"REVERSE") { + is_ascending = false; + } else { + sort.push(Comparator { + sort: Sort::parse(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ascending: is_ascending, + }); + is_ascending = true; + } + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid result option argument.", + )); + } + } + } + + if sort.is_empty() { + return Err(bad(self.tag.to_compact_string(), "Missing sort criteria.")); + } + + let decoder = charset_decoder( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing charset."))? + .unwrap_bytes(), + ); + + let filter = parse_filters(&mut tokens, decoder) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + match filter.len() { + 0 => Err(bad( + self.tag.to_compact_string(), + "No filters found in command.", + )), + _ => Ok(Arguments { + sort: sort.into(), + result_options, + filter, + is_esearch, + tag: self.tag, + }), + } + } +} + +impl Sort { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "ARRIVAL" => Self::Arrival, + "CC" => Self::Cc, + "DATE" => Self::Date, + "FROM" => Self::From, + "SIZE" => Self::Size, + "SUBJECT" => Self::Subject, + "TO" => Self::To, + "DISPLAYFROM" => Self::DisplayFrom, + "DISPLAYTO" => Self::DisplayTo, + ) + .ok_or_else(|| format!("Invalid sort criteria {:?}", String::from_utf8_lossy(value)).into()) + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + Flag, + search::{Arguments, Comparator, Filter, ResultOption, Sort}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_sort() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A282 SORT (SUBJECT) UTF-8 SINCE 1-Feb-1994\r\n".to_vec(), + Arguments { + sort: vec![Comparator { + sort: Sort::Subject, + ascending: true, + }] + .into(), + filter: vec![Filter::Since(760060800)], + result_options: Vec::new(), + is_esearch: false, + tag: "A282".into(), + }, + ), + ( + b"A283 SORT (SUBJECT REVERSE DATE) UTF-8 ALL\r\n".to_vec(), + Arguments { + sort: vec![ + Comparator { + sort: Sort::Subject, + ascending: true, + }, + Comparator { + sort: Sort::Date, + ascending: false, + }, + ] + .into(), + filter: vec![Filter::All], + result_options: Vec::new(), + is_esearch: false, + tag: "A283".into(), + }, + ), + ( + b"A284 SORT (SUBJECT) US-ASCII TEXT \"not in mailbox\"\r\n".to_vec(), + Arguments { + sort: vec![Comparator { + sort: Sort::Subject, + ascending: true, + }] + .into(), + filter: vec![Filter::Text("not in mailbox".into())], + result_options: Vec::new(), + is_esearch: false, + tag: "A284".into(), + }, + ), + ( + [ + b"A284 SORT (REVERSE ARRIVAL FROM) iso-8859-6 SUBJECT ".to_vec(), + b"\"\xe5\xd1\xcd\xc8\xc7 \xc8\xc7\xe4\xd9\xc7\xe4\xe5\"\r\n".to_vec(), + ] + .concat(), + Arguments { + sort: vec![ + Comparator { + sort: Sort::Arrival, + ascending: false, + }, + Comparator { + sort: Sort::From, + ascending: true, + }, + ] + .into(), + filter: vec![Filter::Subject("مرحبا بالعالم".into())], + result_options: Vec::new(), + is_esearch: false, + tag: "A284".into(), + }, + ), + ( + [ + b"E01 UID SORT RETURN (COUNT) (REVERSE DATE) ".to_vec(), + b"UTF-8 UNDELETED UNKEYWORD $Junk\r\n".to_vec(), + ] + .concat(), + Arguments { + sort: vec![Comparator { + sort: Sort::Date, + ascending: false, + }] + .into(), + filter: vec![Filter::Undeleted, Filter::Unkeyword(Flag::Junk)], + result_options: vec![ResultOption::Count], + is_esearch: true, + tag: "E01".into(), + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_sort() + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/parser/status.rs b/crates/imap-proto/src/parser/status.rs new file mode 100644 index 0000000..2f7183c --- /dev/null +++ b/crates/imap-proto/src/parser/status.rs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString}; + +use crate::Command; +use crate::protocol::status; +use crate::protocol::status::Status; +use crate::receiver::{Request, Token, bad}; +use crate::utf7::utf7_maybe_decode; + +impl Request { + pub fn parse_status(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 0..=3 => Err(self.into_error("Missing arguments.")), + len => { + let mut tokens = self.tokens.into_iter(); + let mailbox_name = utf7_maybe_decode( + tokens + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ); + let mut items = Vec::with_capacity(len - 2); + + if tokens + .next() + .is_none_or(|token| !token.is_parenthesis_open()) + { + return Err(bad( + self.tag.to_compact_string(), + "Expected parenthesis after mailbox name.", + )); + } + + #[allow(clippy::while_let_on_iterator)] + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(value) => { + items.push( + Status::parse(&value) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + "Invalid status return option argument.", + )); + } + } + } + + if !items.is_empty() { + Ok(status::Arguments { + tag: self.tag, + mailbox_name, + items, + }) + } else { + Err(bad( + CompactString::from_string_buffer(self.tag), + "At least one status item is required.", + )) + } + } + } + } +} + +impl Status { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "MESSAGES" => Self::Messages, + "UIDNEXT" => Self::UidNext, + "UIDVALIDITY" => Self::UidValidity, + "UNSEEN" => Self::Unseen, + "DELETED" => Self::Deleted, + "SIZE" => Self::Size, + "HIGHESTMODSEQ" => Self::HighestModSeq, + "OBJECTID" => Self::ObjectId, + "RECENT" => Self::Recent, + "DELETED-STORAGE" => Self::DeletedStorage + ) + .ok_or_else(|| { + format!( + "Invalid status option '{}'.", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::status, receiver::Receiver}; + + #[test] + fn parse_status() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A042 STATUS blurdybloop (UIDNEXT MESSAGES)\r\n", + status::Arguments { + tag: "A042".into(), + mailbox_name: "blurdybloop".into(), + items: vec![status::Status::UidNext, status::Status::Messages], + }, + ), + ( + "A043 STATUS foo (OBJECTID)\r\n", + status::Arguments { + tag: "A043".into(), + mailbox_name: "foo".into(), + items: vec![status::Status::ObjectId], + }, + ), + ( + "A044 STATUS foo (MESSAGES OBJECTID UIDVALIDITY)\r\n", + status::Arguments { + tag: "A044".into(), + mailbox_name: "foo".into(), + items: vec![ + status::Status::Messages, + status::Status::ObjectId, + status::Status::UidValidity, + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_status(true) + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } + } +} diff --git a/crates/imap-proto/src/parser/store.rs b/crates/imap-proto/src/parser/store.rs new file mode 100644 index 0000000..e9304f6 --- /dev/null +++ b/crates/imap-proto/src/parser/store.rs @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::{CompactString, ToCompactString, format_compact}; + +use crate::{ + Command, + protocol::{ + Flag, + store::{self, Operation}, + }, + receiver::{Request, Token, bad}, +}; + +use super::{parse_number, parse_sequence_set}; + +impl Request { + pub fn parse_store(self) -> trc::Result { + let mut tokens = self.tokens.into_iter().peekable(); + + // Sequence set + let sequence_set = parse_sequence_set( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing sequence set."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + let mut unchanged_since = None; + + // CONDSTORE parameters + if let Some(Token::ParenthesisOpen) = tokens.peek() { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::Argument(param) if param.eq_ignore_ascii_case(b"UNCHANGEDSINCE") => { + unchanged_since = parse_number::( + &tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing UNCHANGEDSINCE parameter.", + ) + })? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))? + .into(); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(bad( + self.tag.to_compact_string(), + format_compact!("Unsupported parameter '{}'.", token), + )); + } + } + } + } + + // Operation + let operation = tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Missing message data item name.", + ) + })? + .unwrap_bytes(); + let (is_silent, operation) = hashify::tiny_map_ignore_case!(operation.as_slice(), + "FLAGS" => (false, Operation::Set), + "FLAGS.SILENT" => (true, Operation::Set), + "+FLAGS" => (false, Operation::Add), + "+FLAGS.SILENT" => (true, Operation::Add), + "-FLAGS" => (false, Operation::Clear), + "-FLAGS.SILENT" => (true, Operation::Clear), + ) + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + format_compact!( + "Unsupported message data item name: {:?}", + String::from_utf8_lossy(&operation) + ), + ) + })?; + + // Flags + let mut keywords = Vec::new(); + match tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing flags to set."))? + { + Token::ParenthesisOpen => { + for token in tokens { + match token { + Token::Argument(flag) => { + keywords.push( + Flag::parse_imap(flag) + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + Token::ParenthesisClose => { + break; + } + _ => { + return Err(bad(self.tag.to_compact_string(), "Unsupported flag.")); + } + } + } + } + Token::Argument(flag) => { + keywords.push( + Flag::parse_imap(flag).map_err(|v| bad(self.tag.to_compact_string(), v))?, + ); + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + "Invalid flags parameter.", + )); + } + } + + if !keywords.is_empty() || operation == Operation::Set { + Ok(store::Arguments { + tag: self.tag, + sequence_set, + operation, + is_silent, + keywords, + unchanged_since, + }) + } else { + Err(bad(self.tag.to_compact_string(), "Missing flags to set.")) + } + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + Flag, Sequence, + store::{self, Operation}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_store() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A003 STORE 2:4 +FLAGS (\\Deleted)\r\n", + store::Arguments { + sequence_set: Sequence::Range { + start: 2.into(), + end: 4.into(), + }, + is_silent: false, + operation: Operation::Add, + keywords: vec![Flag::Deleted], + tag: "A003".into(), + unchanged_since: None, + }, + ), + ( + "A004 STORE *:100 -FLAGS.SILENT ($Phishing $Junk)\r\n", + store::Arguments { + sequence_set: Sequence::Range { + start: None, + end: 100.into(), + }, + is_silent: true, + operation: Operation::Clear, + keywords: vec![Flag::Phishing, Flag::Junk], + tag: "A004".into(), + unchanged_since: None, + }, + ), + ( + "d105 STORE 7,5,9 (UNCHANGEDSINCE 320162338) +FLAGS.SILENT \\Deleted\r\n", + store::Arguments { + sequence_set: Sequence::List { + items: vec![ + Sequence::Number { value: 7 }, + Sequence::Number { value: 5 }, + Sequence::Number { value: 9 }, + ], + }, + is_silent: true, + operation: Operation::Add, + keywords: vec![Flag::Deleted], + tag: "d105".into(), + unchanged_since: Some(320162338), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_store() + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/subscribe.rs b/crates/imap-proto/src/parser/subscribe.rs new file mode 100644 index 0000000..225632e --- /dev/null +++ b/crates/imap-proto/src/parser/subscribe.rs @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; + +use crate::{ + Command, + protocol::subscribe, + receiver::{Request, bad}, + utf7::utf7_maybe_decode, +}; + +impl Request { + pub fn parse_subscribe(self, is_utf8: bool) -> trc::Result { + match self.tokens.len() { + 1 => Ok(subscribe::Arguments { + mailbox_name: utf7_maybe_decode( + self.tokens + .into_iter() + .next() + .unwrap() + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?, + is_utf8, + ), + tag: self.tag, + }), + 0 => Err(self.into_error("Missing mailbox name.")), + _ => Err(self.into_error("Too many arguments.")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::subscribe, receiver::Receiver}; + + #[test] + fn parse_subscribe() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A142 SUBSCRIBE #news.comp.mail.mime\r\n", + subscribe::Arguments { + mailbox_name: "#news.comp.mail.mime".into(), + tag: "A142".into(), + }, + ), + ( + "A142 SUBSCRIBE \"#news.comp.mail.mime\"\r\n", + subscribe::Arguments { + mailbox_name: "#news.comp.mail.mime".into(), + tag: "A142".into(), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_subscribe(true) + .unwrap(), + arguments + ); + } + } +} diff --git a/crates/imap-proto/src/parser/thread.rs b/crates/imap-proto/src/parser/thread.rs new file mode 100644 index 0000000..e497831 --- /dev/null +++ b/crates/imap-proto/src/parser/thread.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::ToCompactString; +use mail_parser::decoders::charsets::map::charset_decoder; + +use crate::{ + Command, + protocol::thread::{self, Algorithm}, + receiver::{Request, bad}, +}; + +use super::search::parse_filters; + +impl Request { + #[allow(clippy::while_let_on_iterator)] + pub fn parse_thread(self) -> trc::Result { + if self.tokens.is_empty() { + return Err(self.into_error("Missing thread criteria.")); + } + + let mut tokens = self.tokens.into_iter().peekable(); + let algorithm = Algorithm::parse( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing threading algorithm."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + let decoder = charset_decoder( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing charset."))? + .unwrap_bytes(), + ); + + let filter = parse_filters(&mut tokens, decoder) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + match filter.len() { + 0 => Err(bad( + self.tag.to_compact_string(), + "No filters found in command.", + )), + _ => Ok(thread::Arguments { + algorithm, + filter, + tag: self.tag, + }), + } + } +} + +impl Algorithm { + pub fn parse(value: &[u8]) -> super::Result { + hashify::tiny_map_ignore_case!(value, + "ORDEREDSUBJECT" => Self::OrderedSubject, + "REFERENCES" => Self::References, + ) + .ok_or_else(|| { + format!( + "Invalid threading algorithm {:?}", + String::from_utf8_lossy(value) + ) + .into() + }) + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + protocol::{ + search::Filter, + thread::{self, Algorithm}, + }, + receiver::Receiver, + }; + + #[test] + fn parse_thread() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + b"A283 THREAD ORDEREDSUBJECT UTF-8 SINCE 5-MAR-2000\r\n".to_vec(), + thread::Arguments { + algorithm: Algorithm::OrderedSubject, + filter: vec![Filter::Since(952214400)], + tag: "A283".into(), + }, + ), + ( + b"A284 THREAD REFERENCES US-ASCII TEXT \"gewp\"\r\n".to_vec(), + thread::Arguments { + algorithm: Algorithm::References, + filter: vec![Filter::Text("gewp".into())], + tag: "A284".into(), + }, + ), + ] { + let command_str = String::from_utf8_lossy(&command).into_owned(); + + assert_eq!( + receiver + .parse(&mut command.iter()) + .unwrap() + .parse_thread() + .expect(&command_str), + arguments, + "{}", + command_str + ); + } + } +} diff --git a/crates/imap-proto/src/parser/uidbatches.rs b/crates/imap-proto/src/parser/uidbatches.rs new file mode 100644 index 0000000..3d5f161 --- /dev/null +++ b/crates/imap-proto/src/parser/uidbatches.rs @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Command, + protocol::uidbatches, + receiver::{Request, bad}, +}; +use compact_str::ToCompactString; + +use super::parse_number; + +impl Request { + pub fn parse_uidbatches(self) -> trc::Result { + let mut tokens = self.tokens.into_iter(); + let batch_size = parse_number::( + &tokens + .next() + .ok_or_else(|| bad(self.tag.to_compact_string(), "Missing batch size."))? + .unwrap_bytes(), + ) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + if batch_size == 0 { + return Err(bad( + self.tag.to_compact_string(), + "Batch size cannot be zero.", + )); + } + + let batch_range = match tokens.next() { + Some(token) => { + let token = token + .unwrap_string() + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + let (from, to) = token.split_once(':').ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Expected a batch range in the form 'from:to'.", + ) + })?; + let from = parse_number::(from.trim().as_bytes()) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + let to = parse_number::(to.trim().as_bytes()) + .map_err(|v| bad(self.tag.to_compact_string(), v))?; + + if from == 0 || to == 0 { + return Err(bad( + self.tag.to_compact_string(), + "Batch numbers start at one.", + )); + } + + Some((from, to)) + } + None => None, + }; + + if tokens.next().is_some() { + return Err(bad( + self.tag.to_compact_string(), + "Too many arguments for UIDBATCHES.", + )); + } + + Ok(uidbatches::Arguments { + tag: self.tag, + batch_size, + batch_range, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::{protocol::uidbatches, receiver::Receiver}; + + #[test] + fn parse_uidbatches() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A143 UIDBATCHES 2000\r\n", + uidbatches::Arguments { + tag: "A143".into(), + batch_size: 2000, + batch_range: None, + }, + ), + ( + "A302 UIDBATCHES 2000 10:20\r\n", + uidbatches::Arguments { + tag: "A302".into(), + batch_size: 2000, + batch_range: Some((10, 20)), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_uidbatches() + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } + + for command in [ + "A1 UIDBATCHES\r\n", + "A2 UIDBATCHES abc\r\n", + "A3 UIDBATCHES 2000 10\r\n", + "A4 UIDBATCHES 0\r\n", + "A5 UIDBATCHES 2000 0:20\r\n", + "A6 UIDBATCHES 2000 1:2 junk\r\n", + ] { + assert!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_uidbatches() + .is_err(), + "Expected an error for {command}" + ); + } + } +} diff --git a/crates/imap-proto/src/protocol/acl.rs b/crates/imap-proto/src/protocol/acl.rs new file mode 100644 index 0000000..978755b --- /dev/null +++ b/crates/imap-proto/src/protocol/acl.rs @@ -0,0 +1,286 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +/* + + l - lookup (mailbox is visible to LIST/LSUB commands, SUBSCRIBE + mailbox) + r - read (SELECT the mailbox, perform STATUS) + s - keep seen/unseen information across sessions (set or clear + \SEEN flag via STORE, also set \SEEN during APPEND/COPY/ + FETCH BODY[...]) + w - write (set or clear flags other than \SEEN and \DELETED via + STORE, also set them during APPEND/COPY) + i - insert (perform APPEND, COPY into mailbox) + p - post (send mail to submission address for mailbox, + not enforced by IMAP4 itself) + k - create mailboxes (CREATE new sub-mailboxes in any + implementation-defined hierarchy, parent mailbox for the new + mailbox name in RENAME) + x - delete mailbox (DELETE mailbox, old mailbox name in RENAME) + t - delete messages (set or clear \DELETED flag via STORE, set + \DELETED flag during APPEND/COPY) + e - perform EXPUNGE and expunge as a part of CLOSE + a - administer (perform SETACL/DELETEACL/GETACL/LISTRIGHTS) + + // RFC2086 + c - create (CREATE new sub-mailboxes in any implementation-defined + hierarchy) + d - delete (STORE DELETED flag, perform EXPUNGE) + +*/ + +use types::acl::Acl; + +use super::quoted_string; +use crate::utf7::utf7_encode; +use std::fmt::Display; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Rights { + Lookup, + Read, + Seen, + Write, + Insert, + Post, + CreateMailbox, + DeleteMailbox, + DeleteMessages, + Expunge, + Administer, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ModRights { + pub op: ModRightsOp, + pub rights: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ModRightsOp { + Add, + Remove, + Replace, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub identifier: Option, + pub mod_rights: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetAclResponse { + pub mailbox_name: String, + pub permissions: Vec<(String, Vec)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListRightsResponse { + pub mailbox_name: String, + pub identifier: String, + pub permissions: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MyRightsResponse { + pub mailbox_name: String, + pub rights: Vec, +} + +impl GetAclResponse { + pub fn into_bytes(self, is_utf8: bool) -> Vec { + let mut buf = Vec::with_capacity(self.mailbox_name.len() + 10 * self.permissions.len() * 5); + buf.extend_from_slice(b"* ACL "); + if is_utf8 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + for (identifier, rights) in self.permissions { + buf.extend_from_slice(b" "); + quoted_string(&mut buf, &identifier); + buf.extend_from_slice(b" "); + + for right in rights { + buf.push(right.to_char()); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl ListRightsResponse { + pub fn into_bytes(self, is_utf8: bool) -> Vec { + let mut buf = Vec::with_capacity( + self.mailbox_name.len() + self.identifier.len() + 10 * self.permissions.len() * 5, + ); + buf.extend_from_slice(b"* LISTRIGHTS "); + if is_utf8 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" "); + quoted_string(&mut buf, &self.identifier); + for rights in self.permissions { + buf.extend_from_slice(b" "); + for right in rights { + buf.push(right.to_char()); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl MyRightsResponse { + pub fn into_bytes(self, is_utf8: bool) -> Vec { + let mut buf = Vec::with_capacity(self.mailbox_name.len() + 10 + self.rights.len()); + buf.extend_from_slice(b"* MYRIGHTS "); + if is_utf8 { + quoted_string(&mut buf, &self.mailbox_name); + } else { + quoted_string(&mut buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" "); + for right in self.rights { + buf.push(right.to_char()); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl Rights { + pub fn to_char(&self) -> u8 { + match self { + Rights::Lookup => b'l', + Rights::Read => b'r', + Rights::Seen => b's', + Rights::Write => b'w', + Rights::Insert => b'i', + Rights::Post => b'p', + Rights::CreateMailbox => b'k', + Rights::DeleteMailbox => b'x', + Rights::DeleteMessages => b't', + Rights::Expunge => b'e', + Rights::Administer => b'a', + } + } +} + +impl Display for Rights { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Rights::Lookup => write!(f, "l"), + Rights::Read => write!(f, "r"), + Rights::Seen => write!(f, "s"), + Rights::Write => write!(f, "w"), + Rights::Insert => write!(f, "i"), + Rights::Post => write!(f, "p"), + Rights::CreateMailbox => write!(f, "k"), + Rights::DeleteMailbox => write!(f, "x"), + Rights::DeleteMessages => write!(f, "t"), + Rights::Expunge => write!(f, "e"), + Rights::Administer => write!(f, "a"), + } + } +} + +impl From for Acl { + fn from(value: Rights) -> Self { + match value { + Rights::Lookup => Acl::Read, + Rights::Read => Acl::ReadItems, + Rights::Seen => Acl::ModifyItems, + Rights::Write => Acl::ModifyItems, + Rights::Insert => Acl::AddItems, + Rights::Post => Acl::Submit, + Rights::CreateMailbox => Acl::CreateChild, + Rights::DeleteMailbox => Acl::Delete, + Rights::DeleteMessages => Acl::RemoveItems, + Rights::Expunge => Acl::RemoveItems, + Rights::Administer => Acl::Share, + } + } +} + +#[cfg(test)] +mod tests { + + use crate::protocol::acl::{GetAclResponse, ListRightsResponse, MyRightsResponse, Rights}; + + #[test] + fn serialize_acl() { + assert_eq!( + String::from_utf8( + GetAclResponse { + mailbox_name: "INBOX".into(), + permissions: vec![ + ( + "Fred".into(), + vec![ + Rights::Lookup, + Rights::Read, + Rights::Seen, + Rights::Write, + Rights::Insert, + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ] + ), + ( + "David".into(), + vec![ + Rights::CreateMailbox, + Rights::DeleteMessages, + Rights::Administer, + ] + ) + ] + } + .into_bytes(true) + ) + .unwrap(), + "* ACL \"INBOX\" \"Fred\" lrswikta \"David\" kta\r\n" + ); + + assert_eq!( + String::from_utf8( + ListRightsResponse { + mailbox_name: "Deleted Items".into(), + identifier: "Fred".into(), + permissions: vec![ + vec![Rights::Lookup, Rights::Read], + vec![Rights::Administer], + vec![Rights::DeleteMailbox] + ] + } + .into_bytes(true) + ) + .unwrap(), + "* LISTRIGHTS \"Deleted Items\" \"Fred\" lr a x\r\n" + ); + + assert_eq!( + String::from_utf8( + MyRightsResponse { + mailbox_name: "Important".into(), + rights: vec![Rights::Lookup, Rights::Read, Rights::DeleteMailbox] + } + .into_bytes(true) + ) + .unwrap(), + "* MYRIGHTS \"Important\" lrx\r\n" + ); + } +} diff --git a/crates/imap-proto/src/protocol/append.rs b/crates/imap-proto/src/protocol/append.rs new file mode 100644 index 0000000..cd3ad0d --- /dev/null +++ b/crates/imap-proto/src/protocol/append.rs @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Flag; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub message: Vec, + pub flags: Vec, + pub received_at: Option, +} diff --git a/crates/imap-proto/src/protocol/authenticate.rs b/crates/imap-proto/src/protocol/authenticate.rs new file mode 100644 index 0000000..a65b60e --- /dev/null +++ b/crates/imap-proto/src/protocol/authenticate.rs @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mechanism: Mechanism, + pub params: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mechanism { + Plain, + CramMd5, + DigestMd5, + ScramSha1, + ScramSha256, + Apop, + Ntlm, + Gssapi, + Anonymous, + External, + OAuthBearer, + XOauth2, +} + +impl Mechanism { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Mechanism::Plain => b"PLAIN", + Mechanism::CramMd5 => b"CRAM-MD5", + Mechanism::DigestMd5 => b"DIGEST-MD5", + Mechanism::ScramSha1 => b"SCRAM-SHA-1", + Mechanism::ScramSha256 => b"SCRAM-SHA-256", + Mechanism::Apop => b"APOP", + Mechanism::Ntlm => b"NTLM", + Mechanism::Gssapi => b"GSSAPI", + Mechanism::Anonymous => b"ANONYMOUS", + Mechanism::External => b"EXTERNAL", + Mechanism::OAuthBearer => b"OAUTHBEARER", + Mechanism::XOauth2 => b"XOAUTH2", + }); + } + + pub fn into_bytes(self) -> Vec { + let mut buf = Vec::with_capacity(10); + self.serialize(&mut buf); + buf + } +} diff --git a/crates/imap-proto/src/protocol/capability.rs b/crates/imap-proto/src/protocol/capability.rs new file mode 100644 index 0000000..4fb7c57 --- /dev/null +++ b/crates/imap-proto/src/protocol/capability.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, authenticate::Mechanism}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Capability { + IMAP4rev2, + IMAP4rev1, + StartTLS, + LoginDisabled, + Idle, + Namespace, + Id, + Rights, + Children, + MultiAppend, + Binary, + Unselect, + ACL, + UIDPlus, + ESearch, + SASLIR, //SASL-IR + Within, + Enable, + SearchRes, + Sort, + Thread, //THREAD=REFERENCES + ListExtended, //LIST-EXTENDED + ListStatus, //LIST-STATUS + ESort, + SortDisplay, //SORT=DISPLAY + SpecialUse, //SPECIAL-USE + CreateSpecialUse, //CREATE-SPECIAL-USEE + Move, + CondStore, + QResync, + LiteralPlus, //LITERAL+ + UnAuthenticate, + StatusSize, //STATUS=SIZE + ObjectIdPlus, + Preview, + Utf8Accept, + Auth(Mechanism), + Quota, + QuotaResource(QuotaResourceName), + QuotaSet, + JmapAccess, + UidOnly, + UidBatches, + MessageLimit(u32), + SaveLimit(u32), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuotaResourceName { + Storage, + Message, + Mailbox, + AnnotationStorage, +} + +impl Capability { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Capability::Auth(mechanism) => { + buf.extend_from_slice(b"AUTH="); + mechanism.serialize(buf); + return; + } + Capability::IMAP4rev2 => b"IMAP4rev2", + Capability::IMAP4rev1 => b"IMAP4rev1", + Capability::StartTLS => b"STARTTLS", + Capability::LoginDisabled => b"LOGINDISABLED", + Capability::CondStore => b"CONDSTORE", + Capability::QResync => b"QRESYNC", + Capability::LiteralPlus => b"LITERAL+", + Capability::UnAuthenticate => b"UNAUTHENTICATE", + Capability::StatusSize => b"STATUS=SIZE", + Capability::ObjectIdPlus => b"OBJECTID+", + Capability::Preview => b"PREVIEW", + Capability::Idle => b"IDLE", + Capability::Namespace => b"NAMESPACE", + Capability::Id => b"ID", + Capability::Children => b"CHILDREN", + Capability::MultiAppend => b"MULTIAPPEND", + Capability::Binary => b"BINARY", + Capability::Unselect => b"UNSELECT", + Capability::ACL => b"ACL", + Capability::Rights => b"RIGHTS=texk", + Capability::UIDPlus => b"UIDPLUS", + Capability::ESearch => b"ESEARCH", + Capability::SASLIR => b"SASL-IR", + Capability::Within => b"WITHIN", + Capability::Enable => b"ENABLE", + Capability::SearchRes => b"SEARCHRES", + Capability::Sort => b"SORT", + Capability::Thread => b"THREAD=REFERENCES", + Capability::ListExtended => b"LIST-EXTENDED", + Capability::ListStatus => b"LIST-STATUS", + Capability::ESort => b"ESORT", + Capability::SortDisplay => b"SORT=DISPLAY", + Capability::SpecialUse => b"SPECIAL-USE", + Capability::CreateSpecialUse => b"CREATE-SPECIAL-USE", + Capability::Move => b"MOVE", + Capability::Utf8Accept => b"UTF8=ACCEPT", + Capability::Quota => b"QUOTA", + Capability::QuotaResource(quota_resource) => { + buf.extend_from_slice(b"QUOTA=RES-"); + buf.extend_from_slice(match quota_resource { + QuotaResourceName::Storage => b"STORAGE", + QuotaResourceName::Message => b"MESSAGE", + QuotaResourceName::Mailbox => b"MAILBOX", + QuotaResourceName::AnnotationStorage => b"ANNOTATION-STORAGE", + }); + return; + } + Capability::QuotaSet => b"QUOTA=SET", + Capability::JmapAccess => b"JMAPACCESS", + Capability::UidOnly => b"UIDONLY", + Capability::UidBatches => b"UIDBATCHES", + Capability::MessageLimit(limit) => { + buf.extend_from_slice(b"MESSAGELIMIT="); + buf.extend_from_slice(limit.to_string().as_bytes()); + return; + } + Capability::SaveLimit(limit) => { + buf.extend_from_slice(b"SAVELIMIT="); + buf.extend_from_slice(limit.to_string().as_bytes()); + return; + } + }); + } + + pub fn all_capabilities( + is_authenticated: bool, + offer_tls: bool, + allow_auth: bool, + message_limit: u32, + save_limit: u32, + ) -> Vec { + let mut capabilities = vec![ + Capability::IMAP4rev2, + Capability::IMAP4rev1, + Capability::Enable, + Capability::SASLIR, + Capability::LiteralPlus, + Capability::Id, + Capability::Utf8Accept, + ]; + + if is_authenticated { + capabilities.extend([ + Capability::JmapAccess, + Capability::Idle, + Capability::Namespace, + Capability::Children, + Capability::MultiAppend, + Capability::Binary, + Capability::Unselect, + Capability::ACL, + Capability::UIDPlus, + Capability::ESearch, + Capability::Within, + Capability::SearchRes, + Capability::Sort, + Capability::Thread, + Capability::ListExtended, + Capability::ListStatus, + Capability::ESort, + Capability::SortDisplay, + Capability::SpecialUse, + Capability::CreateSpecialUse, + Capability::Move, + Capability::CondStore, + Capability::QResync, + Capability::UnAuthenticate, + Capability::StatusSize, + Capability::ObjectIdPlus, + Capability::Preview, + Capability::Rights, + Capability::Quota, + Capability::QuotaResource(QuotaResourceName::Storage), + Capability::UidOnly, + Capability::UidBatches, + Capability::MessageLimit(message_limit), + Capability::SaveLimit(save_limit), + ]); + } else if allow_auth { + capabilities.extend([ + Capability::Auth(Mechanism::Plain), + Capability::Auth(Mechanism::OAuthBearer), + Capability::Auth(Mechanism::XOauth2), + ]); + } else { + capabilities.push(Capability::LoginDisabled); + } + if offer_tls { + capabilities.push(Capability::StartTLS); + } + + capabilities + } +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(b"* CAPABILITY"); + for capability in self.capabilities.iter() { + buf.push(b' '); + capability.serialize(&mut buf); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ + ImapResponse, + capability::{Capability, Response}, + }; + + #[test] + fn serialize_capability() { + assert_eq!( + &Response { + capabilities: vec![ + Capability::IMAP4rev2, + Capability::StartTLS, + Capability::LoginDisabled + ], + } + .serialize(), + "* CAPABILITY IMAP4rev2 STARTTLS LOGINDISABLED\r\n".as_bytes() + ); + } +} diff --git a/crates/imap-proto/src/protocol/copy_move.rs b/crates/imap-proto/src/protocol/copy_move.rs new file mode 100644 index 0000000..2ae3cdb --- /dev/null +++ b/crates/imap-proto/src/protocol/copy_move.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Sequence; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/create.rs b/crates/imap-proto/src/protocol/create.rs new file mode 100644 index 0000000..7c9c287 --- /dev/null +++ b/crates/imap-proto/src/protocol/create.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::list::Attribute; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub mailbox_role: Option, +} diff --git a/crates/imap-proto/src/protocol/delete.rs b/crates/imap-proto/src/protocol/delete.rs new file mode 100644 index 0000000..2284bc7 --- /dev/null +++ b/crates/imap-proto/src/protocol/delete.rs @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/enable.rs b/crates/imap-proto/src/protocol/enable.rs new file mode 100644 index 0000000..9393a0a --- /dev/null +++ b/crates/imap-proto/src/protocol/enable.rs @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, capability::Capability}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub capabilities: Vec, +} + +pub struct Response { + pub enabled: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + if !self.enabled.is_empty() { + let mut buf = Vec::with_capacity(64); + buf.extend(b"* ENABLED"); + for capability in self.enabled { + buf.push(b' '); + capability.serialize(&mut buf); + } + buf.push(b'\r'); + buf.push(b'\n'); + buf + } else { + Vec::new() + } + } +} diff --git a/crates/imap-proto/src/protocol/expunge.rs b/crates/imap-proto/src/protocol/expunge.rs new file mode 100644 index 0000000..6a3b39d --- /dev/null +++ b/crates/imap-proto/src/protocol/expunge.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, serialize_sequence}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub use_vanished: bool, + pub ids: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + self.serialize_to(&mut buf); + buf + } +} + +impl Response { + pub fn serialize_to(self, buf: &mut Vec) { + if !self.use_vanished { + for (num_deletions, id) in self.ids.into_iter().enumerate() { + buf.extend_from_slice(b"* "); + buf.extend_from_slice( + id.saturating_sub(num_deletions as u32) + .to_string() + .as_bytes(), + ); + buf.extend_from_slice(b" EXPUNGE\r\n"); + } + } else { + Vanished { + earlier: false, + ids: self.ids, + } + .serialize(buf); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Vanished { + pub earlier: bool, + pub ids: Vec, +} + +impl Vanished { + pub fn serialize(&self, buf: &mut Vec) { + if self.earlier { + buf.extend_from_slice(b"* VANISHED (EARLIER) "); + } else { + buf.extend_from_slice(b"* VANISHED "); + } + serialize_sequence(buf, &self.ids); + buf.extend_from_slice(b"\r\n"); + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::ImapResponse; + + #[test] + fn serialize_expunge() { + assert_eq!( + String::from_utf8( + super::Response { + use_vanished: false, + ids: vec![3, 4, 5] + } + .serialize() + ) + .unwrap(), + concat!("* 3 EXPUNGE\r\n", "* 3 EXPUNGE\r\n", "* 3 EXPUNGE\r\n",) + ); + + assert_eq!( + String::from_utf8( + super::Response { + use_vanished: false, + ids: vec![3, 4, 7, 9, 11] + } + .serialize() + ) + .unwrap(), + concat!( + "* 3 EXPUNGE\r\n", + "* 3 EXPUNGE\r\n", + "* 5 EXPUNGE\r\n", + "* 6 EXPUNGE\r\n", + "* 7 EXPUNGE\r\n", + ) + ); + + assert_eq!( + String::from_utf8( + super::Response { + use_vanished: true, + ids: vec![3, 4, 5] + } + .serialize() + ) + .unwrap(), + "* VANISHED 3:5\r\n" + ); + } +} diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs new file mode 100644 index 0000000..8058b03 --- /dev/null +++ b/crates/imap-proto/src/protocol/fetch.rs @@ -0,0 +1,1404 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Flag, ImapResponse, ObjectId, Sequence, literal_string, quoted_or_literal_encoded_string, + quoted_or_literal_encoded_string_or_nil, quoted_or_literal_string, + quoted_or_literal_string_or_nil, quoted_rfc2822_or_nil, quoted_timestamp, +}; +use crate::protocol::literal_string_slice; +use mail_parser::DateTime; +use std::borrow::Cow; +use utils::chained_bytes::SliceRange; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub attributes: Vec, + pub changed_since: Option, + pub include_vanished: bool, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response<'x> { + pub is_uid: bool, + pub is_utf8: bool, + pub items: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchItem<'x> { + pub id: u32, + pub is_uidonly: bool, + pub items: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Attribute { + Envelope, + Flags, + InternalDate, + Rfc822, + Rfc822Size, + Rfc822Header, + Rfc822Text, + Body, + BodyStructure, + BodySection { + peek: bool, + sections: Vec
, + partial: Option<(u32, u32)>, + }, + Uid, + Binary { + peek: bool, + sections: Vec, + partial: Option<(u32, u32)>, + }, + BinarySize { + sections: Vec, + }, + Preview { + lazy: bool, + }, + ModSeq, + ObjectId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Section { + Part { num: u32 }, + Header, + HeaderFields { not: bool, fields: Vec }, + Text, + Mime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataItem<'x> { + Binary { + sections: Vec, + offset: Option, + contents: BodyContents<'x>, + }, + BinarySize { + sections: Vec, + size: usize, + }, + Body { + part: BodyPart<'x>, + }, + BodyStructure { + part: BodyPart<'x>, + }, + BodySection { + sections: Vec
, + origin_octet: Option, + contents: Cow<'x, [u8]>, + }, + Envelope { + envelope: Envelope<'x>, + }, + Flags { + flags: Vec, + }, + InternalDate { + date: i64, + }, + Uid { + uid: u32, + }, + Rfc822 { + contents: SliceRange<'x>, + }, + Rfc822Header { + contents: SliceRange<'x>, + }, + Rfc822Size { + size: usize, + }, + Rfc822Text { + contents: SliceRange<'x>, + }, + Preview { + contents: Option>, + }, + ModSeq { + modseq: u64, + }, + ObjectId(ObjectId), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Address<'x> { + Single(EmailAddress<'x>), + Group(AddressGroup<'x>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressGroup<'x> { + pub name: Option>, + pub addresses: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmailAddress<'x> { + pub name: Option>, + pub address: Cow<'x, str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BodyContents<'x> { + Text(Cow<'x, str>), + Bytes(Cow<'x, [u8]>), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Envelope<'x> { + pub date: Option, + pub subject: Option>, + pub from: Vec>, + pub sender: Vec>, + pub reply_to: Vec>, + pub to: Vec>, + pub cc: Vec>, + pub bcc: Vec>, + pub in_reply_to: Option>, + pub message_id: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::type_complexity)] +pub enum BodyPart<'x> { + Multipart { + body_parts: Vec>, + body_subtype: Cow<'x, str>, + // Extension data + body_parameters: Option, Cow<'x, str>)>>, + extension: BodyPartExtension<'x>, + }, + Basic { + body_type: Option>, + fields: BodyPartFields<'x>, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, + Text { + fields: BodyPartFields<'x>, + body_size_lines: usize, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, + Message { + fields: BodyPartFields<'x>, + envelope: Option>>, + body: Option>>, + body_size_lines: usize, + // Extension data + body_md5: Option>, + extension: BodyPartExtension<'x>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BodyPartFields<'x> { + pub body_subtype: Option>, + pub body_parameters: Option, Cow<'x, str>)>>, + pub body_id: Option>, + pub body_description: Option>, + pub body_encoding: Option>, + pub body_size_octets: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[allow(clippy::type_complexity)] +pub struct BodyPartExtension<'x> { + pub body_disposition: Option<(Cow<'x, str>, Vec<(Cow<'x, str>, Cow<'x, str>)>)>, + pub body_language: Option>>, + pub body_location: Option>, +} + +impl Address<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + match self { + Address::Single(addr) => addr.serialize(buf, is_utf8), + Address::Group(addr) => addr.serialize(buf, is_utf8), + } + } + + pub fn into_owned<'y>(self) -> Address<'y> { + match self { + Address::Single(addr) => Address::Single(addr.into_owned()), + Address::Group(addr) => Address::Group(addr.into_owned()), + } + } +} + +impl EmailAddress<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + buf.push(b'('); + if let Some(name) = &self.name { + quoted_or_literal_encoded_string(buf, name, is_utf8); + } else { + buf.extend_from_slice(b"NIL"); + } + + let addr = if let Some((route, addr)) = self.address.split_once(':') { + buf.push(b' '); + quoted_or_literal_string(buf, route); + buf.push(b' '); + addr + } else { + buf.extend_from_slice(b" NIL "); + &self.address + }; + + if let Some((local, host)) = addr.rsplit_once('@') { + quoted_or_literal_string(buf, local); + buf.push(b' '); + quoted_or_literal_string(buf, host); + } else { + quoted_or_literal_string(buf, &self.address); + buf.extend_from_slice(b" \"\""); + } + buf.push(b')'); + } + + pub fn into_owned<'y>(self) -> EmailAddress<'y> { + EmailAddress { + name: self.name.map(|n| n.into_owned().into()), + address: self.address.into_owned().into(), + } + } +} + +impl AddressGroup<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + buf.extend_from_slice(b"(NIL NIL "); + if let Some(name) = &self.name { + quoted_or_literal_encoded_string(buf, name, is_utf8); + } else { + buf.extend_from_slice(b"\"\""); + } + buf.extend_from_slice(b" NIL)"); + for addr in &self.addresses { + addr.serialize(buf, is_utf8); + } + buf.extend_from_slice(b"(NIL NIL NIL NIL)"); + } + + pub fn into_owned<'y>(self) -> AddressGroup<'y> { + AddressGroup { + name: self.name.map(|n| n.into_owned().into()), + addresses: self + .addresses + .into_iter() + .map(|addr| addr.into_owned()) + .collect(), + } + } +} + +impl<'x> BodyPart<'x> { + pub fn serialize(&self, buf: &mut Vec, is_extended: bool, is_utf8: bool) { + buf.push(b'('); + match self { + BodyPart::Multipart { + body_parts, + body_subtype, + body_parameters, + extension, + } => { + for part in body_parts.iter() { + part.serialize(buf, is_extended, is_utf8); + } + buf.push(b' '); + quoted_or_literal_string(buf, body_subtype); + if is_extended { + if let Some(body_parameters) = body_parameters { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in body_parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_or_literal_string(buf, key); + buf.push(b' '); + quoted_or_literal_encoded_string(buf, value, is_utf8); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b" NIL"); + } + buf.push(b' '); + extension.serialize(buf, is_utf8); + } + } + BodyPart::Basic { + body_type, + fields, + body_md5, + extension, + } => { + quoted_or_literal_string_or_nil(buf, body_type.as_deref()); + buf.push(b' '); + fields.serialize(buf, is_utf8); + if is_extended { + buf.push(b' '); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf, is_utf8); + } + } + BodyPart::Text { + fields, + body_size_lines, + body_md5, + extension, + } => { + buf.extend_from_slice(b"\"text\" "); + fields.serialize(buf, is_utf8); + buf.push(b' '); + buf.extend_from_slice(body_size_lines.to_string().as_bytes()); + if is_extended { + buf.push(b' '); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf, is_utf8); + } + } + BodyPart::Message { + fields, + envelope, + body, + body_size_lines, + body_md5, + extension, + } => { + buf.extend_from_slice(b"\"message\" "); + fields.serialize(buf, is_utf8); + buf.push(b' '); + if let Some(envelope) = envelope { + envelope.serialize(buf, is_utf8); + } else { + buf.extend_from_slice(b"NIL"); + } + buf.push(b' '); + if let Some(body) = body { + body.serialize(buf, is_extended, is_utf8); + } else { + buf.extend_from_slice(b"NIL"); + } + buf.push(b' '); + buf.extend_from_slice(body_size_lines.to_string().as_bytes()); + if is_extended { + buf.push(b' '); + quoted_or_literal_string_or_nil(buf, body_md5.as_deref()); + buf.push(b' '); + extension.serialize(buf, is_utf8); + } + } + } + buf.push(b')'); + } + + pub fn add_part(&mut self, part: BodyPart<'x>) { + match self { + BodyPart::Multipart { body_parts, .. } => body_parts.push(part), + BodyPart::Message { body, .. } => *body = Box::new(part).into(), + _ => debug_assert!(false, "Cannot add a part to a non-multipart body part"), + } + } + + pub fn set_envelope(&mut self, envelope_: Envelope<'x>) { + match self { + BodyPart::Message { envelope, .. } => *envelope = Some(Box::new(envelope_)), + _ => debug_assert!(false, "Cannot set envelope on a non-message body part"), + } + } + + pub fn into_owned<'y>(self) -> BodyPart<'y> { + match self { + BodyPart::Multipart { + body_parts, + body_subtype, + body_parameters, + extension, + } => BodyPart::Multipart { + body_parts: body_parts.into_iter().map(|v| v.into_owned()).collect(), + body_subtype: body_subtype.into_owned().into(), + body_parameters: body_parameters.map(|b| { + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>() + }), + extension: extension.into_owned(), + }, + BodyPart::Basic { + body_type, + fields, + body_md5, + extension, + } => BodyPart::Basic { + body_type: body_type.map(|v| v.into_owned().into()), + fields: fields.into_owned(), + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + BodyPart::Text { + fields, + body_size_lines, + body_md5, + extension, + } => BodyPart::Text { + fields: fields.into_owned(), + body_size_lines, + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + BodyPart::Message { + fields, + envelope, + body, + body_size_lines, + body_md5, + extension, + } => BodyPart::Message { + fields: fields.into_owned(), + envelope: envelope.map(|v| Box::new(v.into_owned())), + body: body.map(|b| Box::new(b.into_owned())), + body_size_lines, + body_md5: body_md5.map(|v| v.into_owned().into()), + extension: extension.into_owned(), + }, + } + } +} + +impl BodyPartFields<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + quoted_or_literal_string_or_nil(buf, self.body_subtype.as_deref()); + if let Some(body_parameters) = &self.body_parameters { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in body_parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_or_literal_string(buf, key); + buf.push(b' '); + quoted_or_literal_encoded_string(buf, value, is_utf8); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b" NIL"); + } + for item in [&self.body_id, &self.body_description, &self.body_encoding] { + buf.push(b' '); + quoted_or_literal_encoded_string_or_nil(buf, item.as_deref(), is_utf8); + } + buf.push(b' '); + buf.extend_from_slice(self.body_size_octets.to_string().as_bytes()); + } + + pub fn into_owned<'y>(self) -> BodyPartFields<'y> { + BodyPartFields { + body_subtype: self.body_subtype.map(|v| v.into_owned().into()), + body_parameters: self.body_parameters.map(|b| { + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>() + }), + body_id: self.body_id.map(|v| v.into_owned().into()), + body_description: self.body_description.map(|v| v.into_owned().into()), + body_encoding: self.body_encoding.map(|v| v.into_owned().into()), + body_size_octets: self.body_size_octets, + } + } +} + +impl BodyPartExtension<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + if let Some((disposition, parameters)) = &self.body_disposition { + buf.push(b'('); + quoted_or_literal_string(buf, disposition); + if !parameters.is_empty() { + buf.extend_from_slice(b" ("); + for (pos, (key, value)) in parameters.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_or_literal_string(buf, key); + buf.push(b' '); + quoted_or_literal_encoded_string(buf, value, is_utf8); + } + buf.extend_from_slice(b"))"); + } else { + buf.extend_from_slice(b" NIL)"); + } + } else { + buf.extend_from_slice(b"NIL"); + } + if let Some(body_language) = &self.body_language { + match body_language.len() { + 0 => buf.extend_from_slice(b" NIL"), + 1 => { + buf.push(b' '); + quoted_or_literal_string(buf, body_language.last().unwrap()); + } + _ => { + buf.extend_from_slice(b" ("); + for (pos, lang) in body_language.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + quoted_or_literal_string(buf, lang); + } + buf.push(b')'); + } + } + } else { + buf.extend_from_slice(b" NIL"); + } + buf.push(b' '); + quoted_or_literal_string_or_nil(buf, self.body_location.as_deref()); + } + + pub fn into_owned<'y>(self) -> BodyPartExtension<'y> { + BodyPartExtension { + body_disposition: self.body_disposition.map(|(a, b)| { + ( + a.into_owned().into(), + b.into_iter() + .map(|(k, v)| (k.into_owned().into(), v.into_owned().into())) + .collect::>(), + ) + }), + body_language: self + .body_language + .map(|v| v.into_iter().map(|a| a.into_owned().into()).collect()), + body_location: self.body_location.map(|v| v.into_owned().into()), + } + } +} + +impl BodyContents<'_> { + pub fn into_owned<'y>(self) -> BodyContents<'y> { + match self { + BodyContents::Text(text) => BodyContents::Text(text.into_owned().into()), + BodyContents::Bytes(bytes) => BodyContents::Bytes(bytes.into_owned().into()), + } + } +} + +impl Section { + pub fn serialize(&self, buf: &mut Vec) { + match self { + Section::Part { num } => { + buf.extend_from_slice(num.to_string().as_bytes()); + } + Section::Header => { + buf.extend_from_slice(b"HEADER"); + } + Section::HeaderFields { not, fields } => { + if !not { + buf.extend_from_slice(b"HEADER.FIELDS "); + } else { + buf.extend_from_slice(b"HEADER.FIELDS.NOT "); + } + buf.push(b'('); + for (pos, field) in fields.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + buf.extend_from_slice(field.as_str().to_ascii_uppercase().as_bytes()); + } + buf.push(b')'); + } + Section::Text => { + buf.extend_from_slice(b"TEXT"); + } + Section::Mime => { + buf.extend_from_slice(b"MIME"); + } + }; + } +} + +static DUMMY_ADDRESS: [Address; 1] = [Address::Single(EmailAddress { + name: None, + address: Cow::Borrowed("unknown@localhost"), +})]; + +impl Envelope<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + buf.push(b'('); + quoted_rfc2822_or_nil(buf, &self.date); + buf.push(b' '); + quoted_or_literal_encoded_string_or_nil(buf, self.subject.as_deref(), is_utf8); + + // Note: [RFC-2822] requires that all messages have a valid + // From header. Therefore, the from, sender, and reply-to + // members in the envelope can not be NIL. + + let from = if !self.from.is_empty() { + &self.from[..] + } else { + &DUMMY_ADDRESS[..] + }; + + self.serialize_addresses(buf, from, is_utf8); + self.serialize_addresses( + buf, + if !self.sender.is_empty() { + &self.sender + } else { + from + }, + is_utf8, + ); + self.serialize_addresses( + buf, + if !self.reply_to.is_empty() { + &self.reply_to + } else { + from + }, + is_utf8, + ); + self.serialize_addresses(buf, &self.to, is_utf8); + self.serialize_addresses(buf, &self.cc, is_utf8); + self.serialize_addresses(buf, &self.bcc, is_utf8); + for item in [&self.in_reply_to, &self.message_id] { + buf.push(b' '); + quoted_or_literal_string_or_nil(buf, item.as_deref()); + } + buf.push(b')'); + } + + fn serialize_addresses(&self, buf: &mut Vec, addresses: &[Address], is_utf8: bool) { + buf.push(b' '); + if !addresses.is_empty() { + buf.push(b'('); + for address in addresses { + address.serialize(buf, is_utf8); + } + buf.push(b')'); + } else { + buf.extend_from_slice(b"NIL"); + } + } + + pub fn into_owned<'y>(self) -> Envelope<'y> { + Envelope { + date: self.date, + subject: self.subject.map(|v| v.into_owned().into()), + from: self.from.into_iter().map(|v| v.into_owned()).collect(), + sender: self.sender.into_iter().map(|v| v.into_owned()).collect(), + reply_to: self.reply_to.into_iter().map(|v| v.into_owned()).collect(), + to: self.to.into_iter().map(|v| v.into_owned()).collect(), + cc: self.cc.into_iter().map(|v| v.into_owned()).collect(), + bcc: self.bcc.into_iter().map(|v| v.into_owned()).collect(), + in_reply_to: self.in_reply_to.map(|v| v.into_owned().into()), + message_id: self.message_id.map(|v| v.into_owned().into()), + } + } +} + +impl DataItem<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + match self { + DataItem::Binary { + sections, + offset, + contents, + } => { + buf.extend_from_slice(b"BINARY["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + buf.extend_from_slice(section.to_string().as_bytes()); + } + if let Some(offset) = offset { + buf.extend_from_slice(b"]<"); + buf.extend_from_slice(offset.to_string().as_bytes()); + buf.extend_from_slice(b"> "); + } else { + buf.extend_from_slice(b"] "); + } + match contents { + BodyContents::Text(text) => { + literal_string(buf, text.as_bytes()); + } + BodyContents::Bytes(bytes) => { + buf.extend_from_slice(b"~{"); + buf.extend_from_slice(bytes.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend_from_slice(bytes); + } + } + } + DataItem::BinarySize { sections, size } => { + buf.extend_from_slice(b"BINARY.SIZE["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + buf.extend_from_slice(section.to_string().as_bytes()); + } + buf.extend_from_slice(b"] "); + buf.extend_from_slice(size.to_string().as_bytes()); + } + DataItem::Body { part } => { + buf.extend_from_slice(b"BODY "); + part.serialize(buf, false, is_utf8); + } + DataItem::BodyStructure { part } => { + buf.extend_from_slice(b"BODYSTRUCTURE "); + part.serialize(buf, true, is_utf8); + } + DataItem::BodySection { + sections, + origin_octet, + contents, + } => { + buf.extend_from_slice(b"BODY["); + for (pos, section) in sections.iter().enumerate() { + if pos > 0 { + buf.push(b'.'); + } + section.serialize(buf); + } + if let Some(origin_octet) = origin_octet { + buf.extend_from_slice(b"]<"); + buf.extend_from_slice(origin_octet.to_string().as_bytes()); + buf.extend_from_slice(b"> "); + } else { + buf.extend_from_slice(b"] "); + } + literal_string(buf, contents); + } + DataItem::Envelope { envelope } => { + buf.extend_from_slice(b"ENVELOPE "); + envelope.serialize(buf, is_utf8); + } + DataItem::Flags { flags } => { + buf.extend_from_slice(b"FLAGS ("); + for (pos, flag) in flags.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + flag.serialize(buf); + } + buf.push(b')'); + } + DataItem::InternalDate { date } => { + buf.extend_from_slice(b"INTERNALDATE "); + quoted_timestamp(buf, *date); + } + DataItem::Uid { uid } => { + buf.extend_from_slice(b"UID "); + buf.extend_from_slice(uid.to_string().as_bytes()); + } + DataItem::Rfc822 { contents } => { + buf.extend_from_slice(b"RFC822 "); + literal_string_slice(buf, contents); + } + DataItem::Rfc822Header { contents } => { + buf.extend_from_slice(b"RFC822.HEADER "); + literal_string_slice(buf, contents); + } + DataItem::Rfc822Size { size } => { + buf.extend_from_slice(b"RFC822.SIZE "); + buf.extend_from_slice(size.to_string().as_bytes()); + } + DataItem::Rfc822Text { contents } => { + buf.extend_from_slice(b"RFC822.TEXT "); + literal_string_slice(buf, contents); + } + DataItem::Preview { contents } => { + buf.extend_from_slice(b"PREVIEW "); + if let Some(contents) = contents { + literal_string(buf, contents); + } else { + buf.extend_from_slice(b"NIL"); + } + } + DataItem::ModSeq { modseq } => { + buf.extend_from_slice(b"MODSEQ ("); + buf.extend_from_slice(modseq.to_string().as_bytes()); + buf.push(b')'); + } + DataItem::ObjectId(object_id) => { + object_id.serialize(buf); + } + } + } +} + +impl FetchItem<'_> { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.id.to_string().as_bytes()); + buf.extend_from_slice(if self.is_uidonly { + b" UIDFETCH (".as_slice() + } else { + b" FETCH (".as_slice() + }); + for (pos, item) in self.items.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + item.serialize(buf, is_utf8); + } + buf.extend_from_slice(b")\r\n"); + } +} + +impl ImapResponse for Response<'_> { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(128); + for item in &self.items { + item.serialize(&mut buf, self.is_utf8); + } + buf + } +} + +/* + + body = "(" (body-type-1part / body-type-mpart) ")" + + body-type-1part = (body-type-basic / body-type-msg / body-type-text) + [SP body-ext-1part] + + body-type-basic = media-basic SP body-fields + ; MESSAGE subtype MUST NOT be "RFC822" or + ; "GLOBAL" + + body-type-mpart = 1*body SP media-subtype + [SP body-ext-mpart] + ; MULTIPART body part + + body-type-msg = media-message SP body-fields SP envelope + SP body SP body-fld-lines + + body-type-text = media-text SP body-fields SP body-fld-lines + + body-fields = body-fld-param SP body-fld-id SP body-fld-desc SP + body-fld-enc SP body-fld-octets + + media-message = DQUOTE "MESSAGE" DQUOTE SP + DQUOTE ("RFC822" / "GLOBAL") DQUOTE + ; Defined in [MIME-IMT] + + media-basic = ((DQUOTE ("APPLICATION" / "AUDIO" / "IMAGE" / + "FONT" / "MESSAGE" / "MODEL" / "VIDEO" ) DQUOTE) + / string) + SP media-subtype + + envelope = "(" env-date SP env-subject SP env-from SP + env-sender SP env-reply-to SP env-to SP env-cc SP + env-bcc SP env-in-reply-to SP env-message-id ")" + + body-fld-lines = number64 + +*/ + +#[cfg(test)] +mod tests { + + use mail_parser::DateTime; + use utils::chained_bytes::SliceRange; + + use crate::protocol::{Flag, ImapResponse}; + + use super::{ + Address, AddressGroup, BodyPart, BodyPartExtension, BodyPartFields, DataItem, EmailAddress, + Envelope, FetchItem, Response, Section, + }; + + #[test] + fn serialize_fetch_data_item() { + for (item, expected_response) in [ + ( + super::DataItem::Envelope { + envelope: Envelope { + date: DateTime::from_timestamp(837570205).into(), + subject: Some("IMAP4rev2 WG mtg summary and minutes".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + sender: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + reply_to: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + to: vec![Address::Single(EmailAddress { + name: None, + address: "imap@cac.washington.edu".into(), + })], + cc: vec![ + Address::Single(EmailAddress { + name: None, + address: "minutes@CNRI.Reston.VA.US".into(), + }), + Address::Single(EmailAddress { + name: Some("John Klensin".into()), + address: "KLENSIN@MIT.EDU".into(), + }), + ], + bcc: vec![], + in_reply_to: None, + message_id: Some("".into()), + }, + }, + concat!( + "ENVELOPE (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"IMAP4rev2 WG mtg summary and minutes\" ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((NIL NIL \"imap\" \"cac.washington.edu\")) ", + "((NIL NIL \"minutes\" \"CNRI.Reston.VA.US\")", + "(\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\")) NIL NIL ", + "\"\")" + ), + ), + ( + super::DataItem::Envelope { + envelope: Envelope { + date: DateTime::from_timestamp(837570205).into(), + subject: Some("Group test".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Bill Foobar".into()), + address: "foobar@example.com".into(), + })], + sender: vec![], + reply_to: vec![], + to: vec![Address::Group(AddressGroup { + name: Some("Friends and Family".into()), + addresses: vec![ + EmailAddress { + name: Some("John Doe".into()), + address: "jdoe@example.com".into(), + }, + EmailAddress { + name: Some("Jane Smith".into()), + address: "jane.smith@example.com".into(), + }, + ], + })], + cc: vec![], + bcc: vec![], + in_reply_to: None, + message_id: Some("".into()), + }, + }, + concat!( + "ENVELOPE (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"Group test\" ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((\"Bill Foobar\" NIL \"foobar\" \"example.com\")) ", + "((NIL NIL \"Friends and Family\" NIL)", + "(\"John Doe\" NIL \"jdoe\" \"example.com\")", + "(\"Jane Smith\" NIL \"jane.smith\" \"example.com\")", + "(NIL NIL NIL NIL)) ", + "NIL NIL NIL \"\")" + ), + ), + ( + super::DataItem::Body { + part: BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![("CHARSET".into(), "US-ASCII".into())].into(), + body_id: None, + body_description: None, + body_encoding: Some("7BIT".into()), + body_size_octets: 2279, + }, + body_size_lines: 48, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + "BODY (\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") NIL NIL \"7BIT\" 2279 48)", + ), + ( + super::DataItem::Body { + part: BodyPart::Message { + fields: BodyPartFields { + body_subtype: Some("RFC822".into()), + body_parameters: None, + body_id: Some("".into()), + body_description: Some("An attached email".into()), + body_encoding: Some("quoted-printable".into()), + body_size_octets: 9323, + }, + envelope: Box::new(Envelope { + date: DateTime::from_timestamp(837570205).into(), + subject: Some("Hello world!".into()), + from: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + sender: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + reply_to: vec![Address::Single(EmailAddress { + name: Some("Terry Gray".into()), + address: "gray@cac.washington.edu".into(), + })], + to: vec![Address::Single(EmailAddress { + name: None, + address: "imap@cac.washington.edu".into(), + })], + cc: vec![], + bcc: vec![], + in_reply_to: None, + message_id: Some("<4234324@domain.com>".into()), + }) + .into(), + body: Box::new(BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("HTML".into()), + body_parameters: None, + body_id: None, + body_description: None, + body_encoding: Some("8BIT".into()), + body_size_octets: 4234, + }, + body_size_lines: 431, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }) + .into(), + body_size_lines: 908, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODY (\"message\" \"RFC822\" NIL \"\" \"An attached email\" ", + "\"quoted-printable\" 9323 (\"Wed, 17 Jul 1996 02:23:25 +0000\" ", + "\"Hello world!\" ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((\"Terry Gray\" NIL \"gray\" \"cac.washington.edu\")) ", + "((NIL NIL \"imap\" \"cac.washington.edu\")) NIL NIL NIL ", + "\"<4234324@domain.com>\") (\"text\" \"HTML\" NIL NIL NIL ", + "\"8BIT\" 4234 431) 908)" + ), + ), + ( + super::DataItem::Body { + part: BodyPart::Multipart { + body_parts: vec![ + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![("CHARSET".into(), "US-ASCII".into())] + .into(), + body_id: None, + body_description: None, + body_encoding: Some("7BIT".into()), + body_size_octets: 1152, + }, + body_size_lines: 23, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![ + ("CHARSET".into(), "US-ASCII".into()), + ("NAME".into(), "cc.diff".into()), + ] + .into(), + body_id: Some( + "<960723163407.20117h@cac.washington.edu>".into(), + ), + body_description: Some("Compiler diff".into()), + body_encoding: Some("BASE64".into()), + body_size_octets: 4554, + }, + body_size_lines: 73, + body_md5: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + ], + body_subtype: "MIXED".into(), + body_parameters: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODY ((\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") ", + "NIL NIL \"7BIT\" 1152 23)", + "(\"text\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\" \"NAME\" \"cc.diff\") ", + "\"<960723163407.20117h@cac.washington.edu>\" \"Compiler diff\" ", + "\"BASE64\" 4554 73) \"MIXED\")", + ), + ), + ( + DataItem::BodyStructure { + part: BodyPart::Multipart { + body_parts: vec![ + BodyPart::Multipart { + body_parts: vec![ + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("PLAIN".into()), + body_parameters: vec![( + "CHARSET".into(), + "UTF-8".into(), + )] + .into(), + body_id: Some("<111@domain.com>".into()), + body_description: Some("Text part".into()), + body_encoding: Some("7BIT".into()), + body_size_octets: 1152, + }, + body_size_lines: 23, + body_md5: Some("8o3456".into()), + extension: BodyPartExtension { + body_disposition: ("inline".into(), vec![]).into(), + body_language: vec!["en-US".into()].into(), + body_location: Some("right here".into()), + }, + }, + BodyPart::Text { + fields: BodyPartFields { + body_subtype: Some("HTML".into()), + body_parameters: vec![( + "CHARSET".into(), + "UTF-8".into(), + )] + .into(), + body_id: Some("<54535@domain.com>".into()), + body_description: Some("HTML part".into()), + body_encoding: Some("8BIT".into()), + body_size_octets: 45345, + }, + body_size_lines: 994, + body_md5: Some("53454".into()), + extension: BodyPartExtension { + body_disposition: ( + "attachment".into(), + vec![("filename".into(), "myfile.txt".into())], + ) + .into(), + body_language: vec!["en-US".into(), "de-DE".into()] + .into(), + body_location: Some("right there".into()), + }, + }, + ], + body_subtype: "ALTERNATIVE".into(), + body_parameters: vec![( + "x-param".into(), + "a very special parameter".into(), + )] + .into(), + extension: BodyPartExtension { + body_disposition: None, + body_language: vec!["en-US".into()].into(), + body_location: Some("unknown".into()), + }, + }, + BodyPart::Basic { + body_type: Some("APPLICATION".into()), + fields: BodyPartFields { + body_subtype: Some("MSWORD".into()), + body_parameters: vec![( + "NAME".into(), + "chimichangas.docx".into(), + )] + .into(), + body_id: Some("<4444@chimi.changa>".into()), + body_description: Some("Chimichangas recipe".into()), + body_encoding: Some("base64".into()), + body_size_octets: 84723, + }, + body_md5: Some("1234".into()), + extension: BodyPartExtension { + body_disposition: ( + "attachment".into(), + vec![("filename".into(), "chimichangas.docx".into())], + ) + .into(), + body_language: vec!["en-MX".into()].into(), + body_location: Some("secret location".into()), + }, + }, + ], + body_subtype: "MIXED".into(), + body_parameters: None, + extension: BodyPartExtension { + body_disposition: None, + body_language: None, + body_location: None, + }, + }, + }, + concat!( + "BODYSTRUCTURE (((\"text\" \"PLAIN\" (\"CHARSET\" \"UTF-8\") ", + "\"<111@domain.com>\" \"Text part\" \"7BIT\" 1152 23 \"8o3456\" ", + "(\"inline\" NIL) \"en-US\" \"right here\")", + "(\"text\" \"HTML\" (\"CHARSET\" \"UTF-8\") ", + "\"<54535@domain.com>\" \"HTML part\" \"8BIT\" 45345 994 \"53454\" ", + "(\"attachment\" (\"filename\" \"myfile.txt\")) ", + "(\"en-US\" \"de-DE\") ", + "\"right there\") \"ALTERNATIVE\" (\"x-param\" ", + "\"a very special parameter\") ", + "NIL \"en-US\" \"unknown\")", + "(\"APPLICATION\" \"MSWORD\" (\"NAME\" \"chimichangas.docx\") ", + "\"<4444@chimi.changa>\" \"Chimichangas recipe\" \"base64\"", + " 84723 \"1234\" ", + "(\"attachment\" (\"filename\" \"chimichangas.docx\")) \"en-MX\" ", + "\"secret location\") \"MIXED\" NIL NIL NIL NIL)", + ), + ), + ( + super::DataItem::Binary { + sections: vec![1, 2, 3], + offset: 10.into(), + contents: super::BodyContents::Bytes(b"hello".to_vec().into()), + }, + "BINARY[1.2.3]<10> ~{5}\r\nhello", + ), + ( + super::DataItem::Binary { + sections: vec![1, 2, 3], + offset: None, + contents: super::BodyContents::Text("hello".into()), + }, + "BINARY[1.2.3] {5}\r\nhello", + ), + ( + super::DataItem::BodySection { + sections: vec![ + Section::Part { num: 1 }, + Section::Part { num: 2 }, + Section::Mime, + ], + origin_octet: 11.into(), + contents: b"howdy"[..].into(), + }, + "BODY[1.2.MIME]<11> {5}\r\nhowdy", + ), + ( + super::DataItem::BodySection { + sections: vec![Section::HeaderFields { + not: true, + fields: vec!["Subject".into(), "x-special".into()], + }], + origin_octet: None, + contents: b"howdy"[..].into(), + }, + "BODY[HEADER.FIELDS.NOT (SUBJECT X-SPECIAL)] {5}\r\nhowdy", + ), + ( + super::DataItem::BodySection { + sections: vec![Section::HeaderFields { + not: false, + fields: vec!["From".into(), "List-Archive".into()], + }], + origin_octet: None, + contents: b"howdy"[..].into(), + }, + "BODY[HEADER.FIELDS (FROM LIST-ARCHIVE)] {5}\r\nhowdy", + ), + ( + super::DataItem::Flags { + flags: vec![Flag::Seen], + }, + "FLAGS (\\Seen)", + ), + ( + super::DataItem::InternalDate { date: 482374938 }, + "INTERNALDATE \"15-Apr-1985 01:02:18 +0000\"", + ), + ] { + let mut buf = Vec::with_capacity(100); + + item.serialize(&mut buf, false); + + assert_eq!(String::from_utf8(buf).unwrap(), expected_response); + } + } + + #[test] + fn serialize_fetch() { + assert_eq!( + String::from_utf8( + Response { + is_uid: false, + is_utf8: false, + items: vec![FetchItem { + id: 123, + is_uidonly: false, + items: vec![ + super::DataItem::Flags { + flags: vec![Flag::Deleted, Flag::Flagged], + }, + super::DataItem::Uid { uid: 983 }, + super::DataItem::Rfc822Size { size: 443 }, + super::DataItem::Rfc822Text { + contents: SliceRange::Single(&b"hi"[..]), + }, + super::DataItem::Rfc822Header { + contents: SliceRange::Single(&b"header"[..]), + }, + ], + }], + } + .serialize(), + ) + .unwrap(), + concat!( + "* 123 FETCH (FLAGS (\\Deleted \\Flagged) ", + "UID 983 ", + "RFC822.SIZE 443 ", + "RFC822.TEXT {2}\r\nhi ", + "RFC822.HEADER {6}\r\nheader)\r\n", + ) + ); + } +} diff --git a/crates/imap-proto/src/protocol/list.rs b/crates/imap-proto/src/protocol/list.rs new file mode 100644 index 0000000..4446a79 --- /dev/null +++ b/crates/imap-proto/src/protocol/list.rs @@ -0,0 +1,414 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utf7::utf7_encode; + +use super::{ + ImapResponse, quoted_string, + status::{Status, StatusItem}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Arguments { + Basic { + tag: String, + reference_name: String, + mailbox_name: String, + }, + Extended { + tag: String, + reference_name: String, + mailbox_name: Vec, + selection_options: Vec, + return_options: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_rev2: bool, + pub is_utf8: bool, + pub is_lsub: bool, + pub list_items: Vec, + pub status_items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectionOption { + Subscribed, + Remote, + RecursiveMatch, + SpecialUse, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReturnOption { + Subscribed, + Children, + Status(Vec), + SpecialUse, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Attribute { + NoInferiors, + NoSelect, + Marked, + Unmarked, + NonExistent, + HasChildren, + HasNoChildren, + Subscribed, + Remote, + All, + Archive, + Drafts, + Flagged, + Junk, + Sent, + Trash, + Important, + Memos, + Scheduled, + Snoozed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChildInfo { + Subscribed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tag { + ChildInfo(Vec), + OldName(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListItem { + pub mailbox_name: String, + pub attributes: Vec, + pub tags: Vec, +} + +impl Arguments { + pub fn is_separator_query(&self) -> bool { + match self { + Arguments::Basic { + mailbox_name, + reference_name, + .. + } => mailbox_name.is_empty() && reference_name.is_empty(), + Arguments::Extended { + mailbox_name, + reference_name, + .. + } => mailbox_name.is_empty() && reference_name.is_empty(), + } + } + + pub fn unwrap_tag(self) -> String { + match self { + Arguments::Basic { tag, .. } => tag, + Arguments::Extended { tag, .. } => tag, + } + } +} + +impl Attribute { + pub fn is_rev1(&self) -> bool { + matches!( + self, + Attribute::NoInferiors | Attribute::NoSelect | Attribute::Marked | Attribute::Unmarked + ) + } + + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Attribute::NoInferiors => b"\\NoInferiors", + Attribute::NoSelect => b"\\NoSelect", + Attribute::Marked => b"\\Marked", + Attribute::Unmarked => b"\\Unmarked", + Attribute::NonExistent => b"\\NonExistent", + Attribute::HasChildren => b"\\HasChildren", + Attribute::HasNoChildren => b"\\HasNoChildren", + Attribute::Subscribed => b"\\Subscribed", + Attribute::Remote => b"\\Remote", + Attribute::All => b"\\All", + Attribute::Archive => b"\\Archive", + Attribute::Drafts => b"\\Drafts", + Attribute::Flagged => b"\\Flagged", + Attribute::Junk => b"\\Junk", + Attribute::Sent => b"\\Sent", + Attribute::Trash => b"\\Trash", + Attribute::Important => b"\\Important", + Attribute::Memos => b"\\Memos", + Attribute::Scheduled => b"\\Scheduled", + Attribute::Snoozed => b"\\Snoozed", + }); + } +} + +impl TryFrom<&str> for Attribute { + type Error = (); + + fn try_from(value: &str) -> Result { + hashify::tiny_map!(value.as_bytes(), + "archive" => Attribute::Archive, + "drafts" => Attribute::Drafts, + "junk" => Attribute::Junk, + "sent" => Attribute::Sent, + "trash" => Attribute::Trash, + "important" => Attribute::Important, + "memos" => Attribute::Memos, + "scheduled" => Attribute::Scheduled, + "snoozed" => Attribute::Snoozed, + ) + .ok_or(()) + } +} + +impl ChildInfo { + pub fn serialize(&self, buf: &mut Vec) { + buf.push(b'\"'); + buf.extend_from_slice(match self { + ChildInfo::Subscribed => b"SUBSCRIBED", + }); + buf.push(b'\"'); + } +} + +impl Tag { + pub fn serialize(&self, buf: &mut Vec) { + match self { + Tag::ChildInfo(child_info) => { + buf.extend_from_slice(b"\"CHILDINFO\" ("); + for (pos, child_info) in child_info.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + child_info.serialize(buf); + } + buf.push(b')'); + } + Tag::OldName(old_name) => { + buf.extend_from_slice(b"\"OLDNAME\" ("); + quoted_string(buf, old_name); + buf.push(b')'); + } + } + } +} + +impl ListItem { + pub fn new(name: impl Into) -> Self { + ListItem { + mailbox_name: name.into(), + attributes: Vec::new(), + tags: Vec::new(), + } + } + + pub fn serialize(&self, buf: &mut Vec, is_rev2: bool, is_utf8: bool, is_lsub: bool) { + let normalized_mailbox_name = utf7_encode(&self.mailbox_name); + if !is_lsub { + buf.extend_from_slice(b"* LIST ("); + } else { + buf.extend_from_slice(b"* LSUB ("); + } + for (pos, attr) in self.attributes.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + attr.serialize(buf); + } + buf.extend_from_slice(b") \"/\" "); + let mut extra_tags = Vec::new(); + + if normalized_mailbox_name != self.mailbox_name { + if is_rev2 || is_utf8 { + quoted_string(buf, &self.mailbox_name); + if is_rev2 { + extra_tags.push(Tag::OldName(normalized_mailbox_name)); + } + } else { + quoted_string(buf, &normalized_mailbox_name); + } + } else { + quoted_string(buf, &self.mailbox_name); + } + + if !extra_tags.is_empty() || !self.tags.is_empty() { + buf.extend_from_slice(b" ("); + for (pos, tag) in extra_tags.iter().chain(self.tags.iter()).enumerate() { + if pos > 0 { + buf.push(b' '); + } + tag.serialize(buf); + } + buf.extend_from_slice(b")\r\n"); + } else { + buf.extend_from_slice(b"\r\n"); + } + } +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(100); + + match (self.list_items.is_empty(), self.status_items.is_empty()) { + (false, false) => { + for (list_item, status_item) in self.list_items.iter().zip(self.status_items.iter()) + { + list_item.serialize(&mut buf, self.is_rev2, self.is_utf8, self.is_lsub); + status_item.serialize(&mut buf, self.is_rev2); + } + } + (false, true) => { + for list_item in &self.list_items { + list_item.serialize(&mut buf, self.is_rev2, self.is_utf8, self.is_lsub); + } + } + (true, false) => { + for status_item in &self.status_items { + status_item.serialize(&mut buf, self.is_rev2); + } + } + _ => (), + } + + buf + } +} + +#[cfg(test)] +mod tests { + + use crate::protocol::{ + ImapResponse, + status::{Status, StatusItem, StatusItemType}, + }; + + use super::{Attribute, ChildInfo, ListItem, Tag}; + + #[test] + fn serialize_list_item() { + for (response, expected_v2, expected_v1) in [ + ( + super::ListItem { + mailbox_name: "".into(), + attributes: vec![], + tags: vec![], + }, + "* LIST () \"/\" \"\"\r\n", + "* LIST () \"/\" \"\"\r\n", + ), + ( + super::ListItem { + mailbox_name: "中國書店".into(), + attributes: vec![Attribute::NoInferiors, Attribute::Drafts], + tags: vec![], + }, + concat!( + "* LIST (\\NoInferiors \\Drafts) \"/\" \"中國書店\" ", + "(\"OLDNAME\" (\"&Ti1XC2b4Xpc-\"))\r\n" + ), + "* LIST (\\NoInferiors \\Drafts) \"/\" \"&Ti1XC2b4Xpc-\"\r\n", + ), + ( + super::ListItem { + mailbox_name: "☺".into(), + attributes: vec![Attribute::Subscribed, Attribute::Remote], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + concat!( + "* LIST (\\Subscribed \\Remote) \"/\" \"☺\" ", + "(\"OLDNAME\" (\"&Jjo-\") \"CHILDINFO\" (\"SUBSCRIBED\"))\r\n" + ), + concat!( + "* LIST (\\Subscribed \\Remote) \"/\" \"&Jjo-\" ", + "(\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n" + ), + ), + ( + super::ListItem { + mailbox_name: "foo".into(), + attributes: vec![Attribute::HasNoChildren], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + "* LIST (\\HasNoChildren) \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + "* LIST (\\HasNoChildren) \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + ), + ] { + let mut buf_1 = Vec::with_capacity(100); + let mut buf_2 = Vec::with_capacity(100); + + response.serialize(&mut buf_1, false, false, false); + response.serialize(&mut buf_2, true, true, false); + + let response_v1 = String::from_utf8(buf_1).unwrap(); + let response_v2 = String::from_utf8(buf_2).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } + + #[test] + fn serialize_list() { + let mut response = super::Response { + list_items: vec![ + ListItem { + mailbox_name: "INBOX".into(), + attributes: vec![Attribute::Subscribed], + tags: vec![], + }, + ListItem { + mailbox_name: "foo".into(), + attributes: vec![], + tags: vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])], + }, + ], + status_items: vec![ + StatusItem { + mailbox_name: "INBOX".into(), + items: vec![(Status::Messages, StatusItemType::Number(17))], + }, + StatusItem { + mailbox_name: "foo".into(), + items: vec![ + (Status::Messages, StatusItemType::Number(30)), + (Status::Unseen, StatusItemType::Number(29)), + ], + }, + ], + is_lsub: false, + is_rev2: true, + is_utf8: true, + }; + let expected_v2 = concat!( + "* LIST (\\Subscribed) \"/\" \"INBOX\"\r\n", + "* STATUS \"INBOX\" (MESSAGES 17)\r\n", + "* LIST () \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + "* STATUS \"foo\" (MESSAGES 30 UNSEEN 29)\r\n", + ); + let expected_v1 = concat!( + "* LSUB (\\Subscribed) \"/\" \"INBOX\"\r\n", + "* LSUB () \"/\" \"foo\" (\"CHILDINFO\" (\"SUBSCRIBED\"))\r\n", + ); + + let response_v2 = String::from_utf8(response.clone().serialize()).unwrap(); + response.is_rev2 = false; + response.is_utf8 = false; + response.is_lsub = true; + response.status_items.clear(); + let response_v1 = String::from_utf8(response.serialize()).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } +} diff --git a/crates/imap-proto/src/protocol/login.rs b/crates/imap-proto/src/protocol/login.rs new file mode 100644 index 0000000..bbe5150 --- /dev/null +++ b/crates/imap-proto/src/protocol/login.rs @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub username: String, + pub password: String, +} diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs new file mode 100644 index 0000000..3c511bb --- /dev/null +++ b/crates/imap-proto/src/protocol/mod.rs @@ -0,0 +1,879 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Command, ResponseCode, ResponseType, StatusResponse}; +use ahash::AHashSet; +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; +use compact_str::CompactString; +use std::{cmp::Ordering, fmt::Display}; +use types::id::Id; +use types::keyword::{ArchivedKeyword, Keyword}; +use utils::chained_bytes::SliceRange; + +pub mod acl; +pub mod append; +pub mod authenticate; +pub mod capability; +pub mod copy_move; +pub mod create; +pub mod delete; +pub mod enable; +pub mod expunge; +pub mod fetch; +pub mod list; +pub mod login; +pub mod namespace; +pub mod quota; +pub mod rename; +pub mod search; +pub mod select; +pub mod status; +pub mod store; +pub mod subscribe; +pub mod thread; +pub mod uidbatches; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolVersion { + Rev1, + Rev2, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ObjectId { + pub mailbox_id: Option, + pub account_id: Option, + pub email_id: Option, + pub thread_id: Option, +} + +impl ObjectId { + pub fn is_empty(&self) -> bool { + self.mailbox_id.is_none() + && self.account_id.is_none() + && self.email_id.is_none() + && self.thread_id.is_none() + } + + pub fn serialize_kvpairs(&self, buf: &mut Vec) { + buf.push(b'('); + let mut first = true; + for (key, value) in [ + (&b"ACCOUNTID "[..], &self.account_id), + (&b"MAILBOXID "[..], &self.mailbox_id), + (&b"EMAILID "[..], &self.email_id), + (&b"THREADID "[..], &self.thread_id), + ] { + if let Some(value) = value { + if !first { + buf.push(b' '); + } + first = false; + buf.extend_from_slice(key); + buf.extend_from_slice(value.to_string().as_bytes()); + } + } + buf.push(b')'); + } + + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"OBJECTID "); + self.serialize_kvpairs(buf); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sequence { + Number { + value: u32, + }, + Range { + start: Option, + end: Option, + }, + SavedSearch, + List { + items: Vec, + }, +} + +impl Sequence { + pub fn number(value: u32) -> Sequence { + Sequence::Number { value } + } + + pub fn range(start: Option, end: Option) -> Sequence { + Sequence::Range { start, end } + } + + pub fn contains(&self, value: u32, max_value: u32) -> bool { + match self { + Sequence::Number { value: number } => *number == value, + Sequence::Range { start, end } => match (start, end) { + (Some(start), Some(end)) => { + value >= *start && value <= *end || value >= *end && value <= *start + } + (Some(range), None) | (None, Some(range)) => { + value >= *range && value <= max_value || value >= max_value && value <= *range + } + (None, None) => value == max_value, + }, + Sequence::List { items } => { + for item in items { + if item.contains(value, max_value) { + return true; + } + } + false + } + Sequence::SavedSearch => false, + } + } + + pub fn is_saved_search(&self) -> bool { + match self { + Sequence::SavedSearch => true, + Sequence::List { items } => items.iter().any(|s| s.is_saved_search()), + _ => false, + } + } + + pub fn expand(&self, max_value: u32) -> AHashSet { + match self { + Sequence::Number { value } => AHashSet::from_iter([*value]), + Sequence::List { items } => { + let mut result = AHashSet::with_capacity(items.len()); + for item in items { + match item { + Sequence::Number { value } => { + result.insert(*value); + } + Sequence::Range { start, end } => { + let start = start.unwrap_or(max_value); + let end = end.unwrap_or(max_value); + match start.cmp(&end) { + Ordering::Equal => { + result.insert(start); + } + Ordering::Less => { + result.extend(start..=end); + } + Ordering::Greater => { + result.extend(end..=start); + } + } + } + _ => (), + } + } + result + } + Sequence::Range { start, end } => { + let mut result = AHashSet::new(); + let start = start.unwrap_or(max_value); + let end = end.unwrap_or(max_value); + match start.cmp(&end) { + Ordering::Equal => { + result.insert(start); + } + Ordering::Less => { + result.extend(start..=end); + } + Ordering::Greater => { + result.extend(end..=start); + } + } + result + } + _ => AHashSet::new(), + } + } +} + +pub trait ImapResponse { + fn serialize(self) -> Vec; +} + +pub fn quoted_string(buf: &mut Vec, text: &str) { + buf.push(b'"'); + for &c in text.as_bytes() { + if c == b'\\' || c == b'"' { + buf.push(b'\\'); + } + buf.push(c); + } + buf.push(b'"'); +} + +pub fn quoted_or_literal_string(buf: &mut Vec, text: &str) { + if text.as_bytes().iter().any(|ch| b"\\\"\r\n".contains(ch)) { + literal_string(buf, text.as_bytes()) + } else { + buf.push(b'"'); + buf.extend_from_slice(text.as_bytes()); + buf.push(b'"'); + } +} +pub fn quoted_or_literal_string_or_nil(buf: &mut Vec, text: Option<&str>) { + if let Some(text) = text { + quoted_or_literal_string(buf, text); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +pub fn quoted_or_literal_encoded_string(buf: &mut Vec, text: &str, is_utf8: bool) { + if is_utf8 || text.is_ascii() { + quoted_or_literal_string(buf, text); + } else { + buf.extend_from_slice(b"\"=?utf-8?B?"); + buf.extend_from_slice(STANDARD.encode(text.as_bytes()).as_bytes()); + buf.extend_from_slice(b"?=\""); + } +} + +pub fn quoted_or_literal_encoded_string_or_nil( + buf: &mut Vec, + text: Option<&str>, + is_utf8: bool, +) { + if let Some(text) = text { + quoted_or_literal_encoded_string(buf, text, is_utf8); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +pub fn quoted_string_or_nil(buf: &mut Vec, text: Option<&str>) { + if let Some(text) = text { + quoted_string(buf, text); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +pub fn literal_string(buf: &mut Vec, text: &[u8]) { + buf.push(b'{'); + buf.extend_from_slice(text.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend_from_slice(text); +} + +pub fn literal_string_slice(buf: &mut Vec, text: &SliceRange<'_>) { + buf.push(b'{'); + buf.extend_from_slice(text.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend(*text); +} + +pub fn quoted_timestamp(buf: &mut Vec, timestamp: i64) { + buf.push(b'"'); + buf.extend_from_slice( + DateTime::::from_timestamp(timestamp, 0) + .unwrap_or_default() + .format("%d-%b-%Y %H:%M:%S %z") + .to_string() + .as_bytes(), + ); + buf.push(b'"'); +} + +pub fn quoted_rfc2822(buf: &mut Vec, timestamp: &mail_parser::DateTime) { + buf.push(b'"'); + buf.extend_from_slice(timestamp.to_rfc822().as_bytes()); + buf.push(b'"'); +} + +pub fn quoted_rfc2822_or_nil(buf: &mut Vec, timestamp: &Option) { + if let Some(timestamp) = timestamp { + quoted_rfc2822(buf, timestamp); + } else { + buf.extend_from_slice(b"NIL"); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Flag { + Seen, + Draft, + Flagged, + Answered, + Recent, + Important, + Phishing, + Junk, + NotJunk, + Deleted, + Forwarded, + MDNSent, + Autosent, + CanUnsubscribe, + Followed, + HasAttachment, + HasMemo, + HasNoAttachment, + Imported, + IsTrusted, + MailFlagBit0, + MailFlagBit1, + MailFlagBit2, + MaskedEmail, + Memo, + Muted, + New, + Notify, + Unsubscribed, + Keyword(Box), +} + +impl Flag { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + Flag::Seen => b"\\Seen", + Flag::Draft => b"\\Draft", + Flag::Flagged => b"\\Flagged", + Flag::Answered => b"\\Answered", + Flag::Recent => b"\\Recent", + Flag::Important => b"\\Important", + Flag::Phishing => b"$Phishing", + Flag::Junk => b"$Junk", + Flag::NotJunk => b"$NotJunk", + Flag::Deleted => b"\\Deleted", + Flag::Forwarded => b"$Forwarded", + Flag::MDNSent => b"$MDNSent", + Flag::Autosent => b"$autosent", + Flag::CanUnsubscribe => b"$canunsubscribe", + Flag::Followed => b"$followed", + Flag::HasAttachment => b"$hasattachment", + Flag::HasMemo => b"$hasmemo", + Flag::HasNoAttachment => b"$hasnoattachment", + Flag::Imported => b"$imported", + Flag::IsTrusted => b"$istrusted", + Flag::MailFlagBit0 => b"$MailFlagBit0", + Flag::MailFlagBit1 => b"$MailFlagBit1", + Flag::MailFlagBit2 => b"$MailFlagBit2", + Flag::MaskedEmail => b"$maskedemail", + Flag::Memo => b"$memo", + Flag::Muted => b"$muted", + Flag::New => b"$new", + Flag::Notify => b"$notify", + Flag::Unsubscribed => b"$unsubscribed", + Flag::Keyword(keyword) => keyword.as_bytes(), + }); + } +} + +impl From for Flag { + fn from(value: Keyword) -> Self { + match value { + Keyword::Seen => Flag::Seen, + Keyword::Draft => Flag::Draft, + Keyword::Flagged => Flag::Flagged, + Keyword::Answered => Flag::Answered, + Keyword::Recent => Flag::Recent, + Keyword::Important => Flag::Important, + Keyword::Phishing => Flag::Phishing, + Keyword::Junk => Flag::Junk, + Keyword::NotJunk => Flag::NotJunk, + Keyword::Deleted => Flag::Deleted, + Keyword::Forwarded => Flag::Forwarded, + Keyword::MdnSent => Flag::MDNSent, + Keyword::Autosent => Flag::Autosent, + Keyword::CanUnsubscribe => Flag::CanUnsubscribe, + Keyword::Followed => Flag::Followed, + Keyword::HasAttachment => Flag::HasAttachment, + Keyword::HasMemo => Flag::HasMemo, + Keyword::HasNoAttachment => Flag::HasNoAttachment, + Keyword::Imported => Flag::Imported, + Keyword::IsTrusted => Flag::IsTrusted, + Keyword::MailFlagBit0 => Flag::MailFlagBit0, + Keyword::MailFlagBit1 => Flag::MailFlagBit1, + Keyword::MailFlagBit2 => Flag::MailFlagBit2, + Keyword::MaskedEmail => Flag::MaskedEmail, + Keyword::Memo => Flag::Memo, + Keyword::Muted => Flag::Muted, + Keyword::New => Flag::New, + Keyword::Notify => Flag::Notify, + Keyword::Unsubscribed => Flag::Unsubscribed, + Keyword::Other(value) => Flag::Keyword(value), + } + } +} + +impl From<&ArchivedKeyword> for Flag { + fn from(value: &ArchivedKeyword) -> Self { + match value { + ArchivedKeyword::Seen => Flag::Seen, + ArchivedKeyword::Draft => Flag::Draft, + ArchivedKeyword::Flagged => Flag::Flagged, + ArchivedKeyword::Answered => Flag::Answered, + ArchivedKeyword::Recent => Flag::Recent, + ArchivedKeyword::Important => Flag::Important, + ArchivedKeyword::Phishing => Flag::Phishing, + ArchivedKeyword::Junk => Flag::Junk, + ArchivedKeyword::NotJunk => Flag::NotJunk, + ArchivedKeyword::Deleted => Flag::Deleted, + ArchivedKeyword::Forwarded => Flag::Forwarded, + ArchivedKeyword::MdnSent => Flag::MDNSent, + ArchivedKeyword::Autosent => Flag::Autosent, + ArchivedKeyword::CanUnsubscribe => Flag::CanUnsubscribe, + ArchivedKeyword::Followed => Flag::Followed, + ArchivedKeyword::HasAttachment => Flag::HasAttachment, + ArchivedKeyword::HasMemo => Flag::HasMemo, + ArchivedKeyword::HasNoAttachment => Flag::HasNoAttachment, + ArchivedKeyword::Imported => Flag::Imported, + ArchivedKeyword::IsTrusted => Flag::IsTrusted, + ArchivedKeyword::MailFlagBit0 => Flag::MailFlagBit0, + ArchivedKeyword::MailFlagBit1 => Flag::MailFlagBit1, + ArchivedKeyword::MailFlagBit2 => Flag::MailFlagBit2, + ArchivedKeyword::MaskedEmail => Flag::MaskedEmail, + ArchivedKeyword::Memo => Flag::Memo, + ArchivedKeyword::Muted => Flag::Muted, + ArchivedKeyword::New => Flag::New, + ArchivedKeyword::Notify => Flag::Notify, + ArchivedKeyword::Unsubscribed => Flag::Unsubscribed, + ArchivedKeyword::Other(value) => Flag::Keyword(value.as_ref().into()), + } + } +} + +impl From for Keyword { + fn from(value: Flag) -> Self { + match value { + Flag::Seen => Keyword::Seen, + Flag::Draft => Keyword::Draft, + Flag::Flagged => Keyword::Flagged, + Flag::Answered => Keyword::Answered, + Flag::Recent => Keyword::Recent, + Flag::Important => Keyword::Important, + Flag::Phishing => Keyword::Phishing, + Flag::Junk => Keyword::Junk, + Flag::NotJunk => Keyword::NotJunk, + Flag::Deleted => Keyword::Deleted, + Flag::Forwarded => Keyword::Forwarded, + Flag::MDNSent => Keyword::MdnSent, + Flag::Autosent => Keyword::Autosent, + Flag::CanUnsubscribe => Keyword::CanUnsubscribe, + Flag::Followed => Keyword::Followed, + Flag::HasAttachment => Keyword::HasAttachment, + Flag::HasMemo => Keyword::HasMemo, + Flag::HasNoAttachment => Keyword::HasNoAttachment, + Flag::Imported => Keyword::Imported, + Flag::IsTrusted => Keyword::IsTrusted, + Flag::MailFlagBit0 => Keyword::MailFlagBit0, + Flag::MailFlagBit1 => Keyword::MailFlagBit1, + Flag::MailFlagBit2 => Keyword::MailFlagBit2, + Flag::MaskedEmail => Keyword::MaskedEmail, + Flag::Memo => Keyword::Memo, + Flag::Muted => Keyword::Muted, + Flag::New => Keyword::New, + Flag::Notify => Keyword::Notify, + Flag::Unsubscribed => Keyword::Unsubscribed, + Flag::Keyword(value) => Keyword::from_boxed_other(value), + } + } +} + +impl ResponseCode { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + ResponseCode::Alert => b"ALERT", + ResponseCode::AlreadyExists => b"ALREADYEXISTS", + ResponseCode::AppendUid { uid_validity, uids } => { + buf.extend_from_slice(b"APPENDUID "); + buf.extend_from_slice(uid_validity.to_string().as_bytes()); + buf.push(b' '); + serialize_sequence(buf, uids); + return; + } + ResponseCode::AuthenticationFailed => b"AUTHENTICATIONFAILED", + ResponseCode::AuthorizationFailed => b"AUTHORIZATIONFAILED", + ResponseCode::BadCharset => b"BADCHARSET", + ResponseCode::Cannot => b"CANNOT", + ResponseCode::Capability { capabilities } => { + buf.extend_from_slice(b"CAPABILITY"); + for capability in capabilities { + buf.push(b' '); + capability.serialize(buf); + } + return; + } + ResponseCode::ClientBug => b"CLIENTBUG", + ResponseCode::Closed => b"CLOSED", + ResponseCode::ContactAdmin => b"CONTACTADMIN", + ResponseCode::CopyUid { + uid_validity, + src_uids, + dest_uids, + } => { + buf.extend_from_slice(b"COPYUID "); + buf.extend_from_slice(uid_validity.to_string().as_bytes()); + buf.push(b' '); + serialize_sequence(buf, src_uids); + buf.push(b' '); + serialize_sequence(buf, dest_uids); + return; + } + ResponseCode::Corruption => b"CORRUPTION", + ResponseCode::Expired => b"EXPIRED", + ResponseCode::ExpungeIssued => b"EXPUNGEISSUED", + ResponseCode::HasChildren => b"HASCHILDREN", + ResponseCode::InUse => b"INUSE", + ResponseCode::Limit => b"LIMIT", + ResponseCode::NonExistent => b"NONEXISTENT", + ResponseCode::NoPerm => b"NOPERM", + ResponseCode::OverQuota => b"OVERQUOTA", + ResponseCode::Parse => b"PARSE", + ResponseCode::PermanentFlags => b"PERMANENTFLAGS", + ResponseCode::PrivacyRequired => b"PRIVACYREQUIRED", + ResponseCode::ReadOnly => b"READ-ONLY", + ResponseCode::ReadWrite => b"READ-WRITE", + ResponseCode::ServerBug => b"SERVERBUG", + ResponseCode::TryCreate => b"TRYCREATE", + ResponseCode::UidNext => b"UIDNEXT", + ResponseCode::UidNotSticky => b"UIDNOTSTICKY", + ResponseCode::UidValidity => b"UIDVALIDITY", + ResponseCode::Unavailable => b"UNAVAILABLE", + ResponseCode::UnknownCte => b"UNKNOWN-CTE", + ResponseCode::Modified { ids } => { + buf.extend_from_slice(b"MODIFIED "); + serialize_sequence(buf, ids); + return; + } + ResponseCode::ObjectId(object_id) => { + object_id.serialize(buf); + return; + } + ResponseCode::HighestModseq { modseq } => { + buf.extend_from_slice(b"HIGHESTMODSEQ "); + buf.extend_from_slice(modseq.to_string().as_bytes()); + return; + } + ResponseCode::UseAttr => b"USEATTR", + ResponseCode::UidRequired => b"UIDREQUIRED", + ResponseCode::TooFew => b"TOOFEW", + ResponseCode::TooMany => b"TOOMANY", + ResponseCode::MessageLimit { limit, uid } => { + buf.extend_from_slice(b"MESSAGELIMIT "); + buf.extend_from_slice(limit.to_string().as_bytes()); + if let Some(uid) = uid { + buf.push(b' '); + buf.extend_from_slice(uid.to_string().as_bytes()); + } + return; + } + }); + } + + pub fn as_str(&self) -> &'static str { + // Only returns the name without arguments + match self { + ResponseCode::Alert => "ALERT", + ResponseCode::AlreadyExists => "ALREADYEXISTS", + ResponseCode::AppendUid { .. } => "APPENDUID", + ResponseCode::AuthenticationFailed => "AUTHENTICATIONFAILED", + ResponseCode::AuthorizationFailed => "AUTHORIZATIONFAILED", + ResponseCode::BadCharset => "BADCHARSET", + ResponseCode::Cannot => "CANNOT", + ResponseCode::Capability { .. } => "CAPABILITY", + ResponseCode::ClientBug => "CLIENTBUG", + ResponseCode::Closed => "CLOSED", + ResponseCode::ContactAdmin => "CONTACTADMIN", + ResponseCode::CopyUid { .. } => "COPYUID", + ResponseCode::Corruption => "CORRUPTION", + ResponseCode::Expired => "EXPIRED", + ResponseCode::ExpungeIssued => "EXPUNGEISSUED", + ResponseCode::HasChildren => "HASCHILDREN", + ResponseCode::InUse => "INUSE", + ResponseCode::Limit => "LIMIT", + ResponseCode::NonExistent => "NONEXISTENT", + ResponseCode::NoPerm => "NOPERM", + ResponseCode::OverQuota => "OVERQUOTA", + ResponseCode::Parse => "PARSE", + ResponseCode::PermanentFlags => "PERMANENTFLAGS", + ResponseCode::PrivacyRequired => "PRIVACYREQUIRED", + ResponseCode::ReadOnly => "READ-ONLY", + ResponseCode::ReadWrite => "READ-WRITE", + ResponseCode::ServerBug => "SERVERBUG", + ResponseCode::TryCreate => "TRYCREATE", + ResponseCode::UidNext => "UIDNEXT", + ResponseCode::UidNotSticky => "UIDNOTSTICKY", + ResponseCode::UidValidity => "UIDVALIDITY", + ResponseCode::Unavailable => "UNAVAILABLE", + ResponseCode::UnknownCte => "UNKNOWN-CTE", + ResponseCode::Modified { .. } => "MODIFIED", + ResponseCode::ObjectId { .. } => "OBJECTID", + ResponseCode::HighestModseq { .. } => "HIGHESTMODSEQ", + ResponseCode::UseAttr => "USEATTR", + ResponseCode::UidRequired => "UIDREQUIRED", + ResponseCode::TooFew => "TOOFEW", + ResponseCode::TooMany => "TOOMANY", + ResponseCode::MessageLimit { .. } => "MESSAGELIMIT", + } + } +} + +impl ResponseType { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(self.as_str().as_bytes()); + } + + pub fn as_str(&self) -> &'static str { + match self { + ResponseType::Ok => "OK", + ResponseType::No => "NO", + ResponseType::Bad => "BAD", + ResponseType::PreAuth => "PREAUTH", + ResponseType::Bye => "BYE", + } + } +} + +impl From for trc::Value { + fn from(value: ResponseCode) -> Self { + trc::Value::String(CompactString::const_new(value.as_str())) + } +} + +impl From for trc::Value { + fn from(value: ResponseType) -> Self { + trc::Value::String(CompactString::const_new(value.as_str())) + } +} + +impl StatusResponse { + pub fn serialize(self, mut buf: Vec) -> Vec { + if let Some(tag) = &self.tag { + buf.extend_from_slice(tag.as_bytes()); + } else { + buf.push(b'*'); + } + buf.push(b' '); + self.rtype.serialize(&mut buf); + buf.push(b' '); + if let Some(code) = &self.code { + buf.push(b'['); + code.serialize(&mut buf); + buf.extend_from_slice(b"] "); + } + buf.extend_from_slice(self.message.as_bytes()); + buf.extend_from_slice(b"\r\n"); + buf + } + + pub fn into_bytes(self) -> Vec { + self.serialize(Vec::with_capacity(16)) + } +} + +pub trait SerializeResponse { + fn serialize(&self) -> Vec; +} + +impl SerializeResponse for trc::Error { + fn serialize(&self) -> Vec { + let mut buf = Vec::with_capacity(128); + if let Some(tag) = self.value_as_str(trc::Key::Id) { + buf.extend_from_slice(tag.as_bytes()); + } else { + buf.push(b'*'); + } + buf.push(b' '); + buf.extend_from_slice(self.value_as_str(trc::Key::Type).unwrap_or("NO").as_bytes()); + buf.push(b' '); + if let Some(code) = self + .value_as_str(trc::Key::Code) + .or_else(|| match self.as_ref() { + trc::EventType::Store(trc::StoreEvent::NotFound) => { + Some(ResponseCode::NonExistent.as_str()) + } + trc::EventType::Store(_) => Some(ResponseCode::ContactAdmin.as_str()), + trc::EventType::Limit(trc::LimitEvent::Quota) => { + Some(ResponseCode::OverQuota.as_str()) + } + trc::EventType::Limit(_) => Some(ResponseCode::Limit.as_str()), + trc::EventType::Auth(_) => Some(ResponseCode::AuthenticationFailed.as_str()), + trc::EventType::Security(_) => Some(ResponseCode::AuthorizationFailed.as_str()), + _ => None, + }) + { + buf.push(b'['); + buf.extend_from_slice(code.as_bytes()); + buf.extend_from_slice(b"] "); + } + buf.extend_from_slice( + self.value_as_str(trc::Key::Details) + .unwrap_or_else(|| self.as_ref().message()) + .as_bytes(), + ); + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl ProtocolVersion { + #[inline(always)] + pub fn is_rev2(&self) -> bool { + matches!(self, ProtocolVersion::Rev2) + } + + #[inline(always)] + pub fn is_rev1(&self) -> bool { + matches!(self, ProtocolVersion::Rev1) + } +} + +pub fn serialize_sequence(buf: &mut Vec, list: &[u32]) { + let mut ids = list.iter().peekable(); + while let Some(&id) = ids.next() { + buf.extend_from_slice(id.to_string().as_bytes()); + let mut range_id = id; + loop { + match ids.peek() { + Some(&&next_id) if next_id == range_id + 1 => { + range_id += 1; + ids.next(); + } + next => { + if range_id != id { + buf.push(b':'); + buf.extend_from_slice(range_id.to_string().as_bytes()); + } + if next.is_some() { + buf.push(b','); + } + break; + } + } + } + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Command::UidBatches => write!(f, "UIDBATCHES"), + Command::Capability => write!(f, "CAPABILITY"), + Command::Noop => write!(f, "NOOP"), + Command::Logout => write!(f, "LOGOUT"), + Command::StartTls => write!(f, "STARTTLS"), + Command::Authenticate => write!(f, "AUTHENTICATE"), + Command::Login => write!(f, "LOGIN"), + Command::Enable => write!(f, "ENABLE"), + Command::Select => write!(f, "SELECT"), + Command::Examine => write!(f, "EXAMINE"), + Command::Create => write!(f, "CREATE"), + Command::Delete => write!(f, "DELETE"), + Command::Rename => write!(f, "RENAME"), + Command::Subscribe => write!(f, "SUBSCRIBE"), + Command::Unsubscribe => write!(f, "UNSUBSCRIBE"), + Command::List => write!(f, "LIST"), + Command::Namespace => write!(f, "NAMESPACE"), + Command::Status => write!(f, "STATUS"), + Command::Append => write!(f, "APPEND"), + Command::Idle => write!(f, "IDLE"), + Command::Close => write!(f, "CLOSE"), + Command::Unselect => write!(f, "UNSELECT"), + Command::Expunge(false) => write!(f, "EXPUNGE"), + Command::Search(false) => write!(f, "SEARCH"), + Command::Fetch(false) => write!(f, "FETCH"), + Command::Store(false) => write!(f, "STORE"), + Command::Copy(false) => write!(f, "COPY"), + Command::Move(false) => write!(f, "MOVE"), + Command::Sort(false) => write!(f, "SORT"), + Command::Thread(false) => write!(f, "THREAD"), + Command::Expunge(true) => write!(f, "UID EXPUNGE"), + Command::Search(true) => write!(f, "UID SEARCH"), + Command::Fetch(true) => write!(f, "UID FETCH"), + Command::Store(true) => write!(f, "UID STORE"), + Command::Copy(true) => write!(f, "UID COPY"), + Command::Move(true) => write!(f, "UID MOVE"), + Command::Sort(true) => write!(f, "UID SORT"), + Command::Thread(true) => write!(f, "UID THREAD"), + Command::Lsub => write!(f, "LSUB"), + Command::Check => write!(f, "CHECK"), + Command::SetAcl => write!(f, "SETACL"), + Command::DeleteAcl => write!(f, "DELETEACL"), + Command::GetAcl => write!(f, "GETACL"), + Command::ListRights => write!(f, "LISTRIGHTS"), + Command::MyRights => write!(f, "MYRIGHTS"), + Command::Unauthenticate => write!(f, "UNAUTHENTICATE"), + Command::Id => write!(f, "ID"), + Command::GetQuota => write!(f, "GETQUOTA"), + Command::GetQuotaRoot => write!(f, "GETQUOTAROOT"), + Command::GetJmapAccess => write!(f, "GETJMAPACCESS"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::parser::parse_sequence_set; + use crate::protocol::ObjectId; + use types::id::Id; + + #[test] + fn serialize_objectid_compound() { + // Empty compound + let mut buf = Vec::new(); + ObjectId::default().serialize(&mut buf); + assert_eq!(String::from_utf8(buf).unwrap(), "OBJECTID ()"); + + // Mailbox context: MAILBOXID + ACCOUNTID + let mut buf = Vec::new(); + ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + } + .serialize(&mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + format!( + "OBJECTID (ACCOUNTID {} MAILBOXID {})", + Id::from(2u32), + Id::from(1u32) + ) + ); + + // Message context: EMAILID + THREADID only + let mut buf = Vec::new(); + ObjectId { + email_id: Some(Id::from_parts(3, 4)), + thread_id: Some(Id::from(3u32)), + ..Default::default() + } + .serialize(&mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + format!( + "OBJECTID (EMAILID {} THREADID {})", + Id::from_parts(3, 4), + Id::from(3u32) + ) + ); + } + + #[test] + fn sequence_set_contains() { + for (sequence, expected_result, max_value) in [ + ("1,5:10", vec![1, 5, 6, 7, 8, 9, 10], 10), + ("2,4:7,9,12:*", vec![2, 4, 5, 6, 7, 9, 12, 13, 14, 15], 15), + ("*:4,5:7", vec![4, 5, 6, 7], 7), + ("2,4,5", vec![2, 4, 5], 5), + ] { + let sequence = parse_sequence_set(sequence.as_bytes()).unwrap(); + + assert_eq!( + (1..=15) + .filter(|num| sequence.contains(*num, max_value)) + .collect::>(), + expected_result + ); + } + } +} diff --git a/crates/imap-proto/src/protocol/namespace.rs b/crates/imap-proto/src/protocol/namespace.rs new file mode 100644 index 0000000..a6e16b6 --- /dev/null +++ b/crates/imap-proto/src/protocol/namespace.rs @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, quoted_string}; + +pub struct Response { + pub shared_prefix: Option, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + if let Some(shared_prefix) = &self.shared_prefix { + buf.extend_from_slice(b"* NAMESPACE ((\"\" \"/\")) (("); + quoted_string(&mut buf, shared_prefix); + buf.extend_from_slice(b" \"/\")) NIL\r\n"); + } else { + buf.extend_from_slice(b"* NAMESPACE ((\"\" \"/\")) NIL NIL\r\n"); + } + buf + } +} diff --git a/crates/imap-proto/src/protocol/quota.rs b/crates/imap-proto/src/protocol/quota.rs new file mode 100644 index 0000000..6399544 --- /dev/null +++ b/crates/imap-proto/src/protocol/quota.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, capability::QuotaResourceName, quoted_string}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub name: String, +} + +pub struct QuotaItem { + pub name: String, + pub resources: Vec, +} + +pub struct QuotaResource { + pub resource: QuotaResourceName, + pub total: u64, + pub used: u64, +} + +pub struct Response { + pub quota_root_items: Vec, + pub quota_items: Vec, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + if !self.quota_root_items.is_empty() { + buf.extend_from_slice(b"* QUOTAROOT"); + for item in &self.quota_root_items { + buf.push(b' '); + quoted_string(&mut buf, item); + } + buf.extend_from_slice(b"\r\n"); + } + + if !self.quota_items.is_empty() { + for item in &self.quota_items { + buf.extend_from_slice(b"* QUOTA "); + quoted_string(&mut buf, &item.name); + buf.extend_from_slice(b" ("); + for (pos, resource) in item.resources.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + + let mut total = resource.total; + let mut used = resource.used; + + match resource.resource { + QuotaResourceName::Storage => { + total /= 1024; + used /= 1024; + + buf.extend_from_slice(b"STORAGE ") + } + QuotaResourceName::Message => buf.extend_from_slice(b"MESSAGE "), + QuotaResourceName::Mailbox => buf.extend_from_slice(b"MAILBOX "), + QuotaResourceName::AnnotationStorage => { + buf.extend_from_slice(b"ANNOTATION-STORAGE ") + } + } + + buf.extend_from_slice(format!("{used} {total}").as_bytes()); + } + buf.extend_from_slice(b")\r\n"); + } + } + + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ImapResponse, capability::QuotaResourceName}; + + use super::{QuotaItem, QuotaResource}; + + #[test] + fn serialize_quota() { + for (response, expected) in [ + ( + super::Response { + quota_root_items: vec!["INBOX".into(), "#test".into()], + quota_items: vec![], + }, + "* QUOTAROOT \"INBOX\" \"#test\"\r\n", + ), + ( + super::Response { + quota_root_items: vec![], + quota_items: vec![QuotaItem { + name: "INBOX".into(), + resources: vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: 1073741824, + used: 1048576, + }], + }], + }, + "* QUOTA \"INBOX\" (STORAGE 1024 1048576)\r\n", + ), + ( + super::Response { + quota_root_items: vec!["my mailbox".into(), "".into()], + quota_items: vec![QuotaItem { + name: "INBOX".into(), + resources: vec![ + QuotaResource { + resource: QuotaResourceName::Storage, + total: 1073741824, + used: 1048576, + }, + QuotaResource { + resource: QuotaResourceName::Message, + total: 100, + used: 2, + }, + ], + }], + }, + concat!( + "* QUOTAROOT \"my mailbox\" \"\"\r\n", + "* QUOTA \"INBOX\" (STORAGE 1024 1048576 MESSAGE 2 100)\r\n" + ), + ), + ] { + assert_eq!(String::from_utf8(response.serialize()).unwrap(), expected); + } + } +} diff --git a/crates/imap-proto/src/protocol/rename.rs b/crates/imap-proto/src/protocol/rename.rs new file mode 100644 index 0000000..2d8493f --- /dev/null +++ b/crates/imap-proto/src/protocol/rename.rs @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub new_mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/search.rs b/crates/imap-proto/src/protocol/search.rs new file mode 100644 index 0000000..f969cdf --- /dev/null +++ b/crates/imap-proto/src/protocol/search.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Flag, Sequence, quoted_string, serialize_sequence}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub is_esearch: bool, + pub sort: Option>, + pub result_options: Vec, + pub filter: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sort { + Arrival, + Cc, + Date, + From, + DisplayFrom, + Size, + Subject, + To, + DisplayTo, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comparator { + pub sort: Sort, + pub ascending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_uid: bool, + pub is_esearch: bool, + pub is_sort: bool, + pub ids: Vec, + pub min: Option, + pub max: Option, + pub count: Option, + pub highest_modseq: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResultOption { + Min, + Max, + All, + Count, + Save, + Context, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Filter { + Sequence(Sequence, bool), + All, + Answered, + Bcc(String), + Before(i64), + Body(String), + Cc(String), + Deleted, + Draft, + Flagged, + From(String), + Header(String, String), + Keyword(Flag), + Larger(u32), + On(i64), + Seen, + SentBefore(i64), + SentOn(i64), + SentSince(i64), + Since(i64), + Smaller(u32), + Subject(String), + Text(String), + To(String), + Unanswered, + Undeleted, + Undraft, + Unflagged, + Unkeyword(Flag), + Unseen, + + // Logical operators + And, + Or, + Not, + End, + + // Imap4rev1 + Recent, + New, + Old, + + // RFC 5032 - WITHIN + Older(u32), + Younger(u32), + + // RFC 4551 - CONDSTORE + ModSeq((u64, ModSeqEntry)), + + // RFC 8474 - ObjectID + EmailId(String), + ThreadId(String), + + // RFC 9738 - MESSAGELIMIT + UidAfter(u32), + UidBefore(u32), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModSeqEntry { + Shared(Flag), + Private(Flag), + All(Flag), + None, +} + +impl Filter { + pub fn seq_saved_search() -> Filter { + Filter::Sequence(Sequence::SavedSearch, false) + } + + pub fn seq_range(start: Option, end: Option) -> Filter { + Filter::Sequence(Sequence::Range { start, end }, false) + } +} + +impl Response { + pub fn serialize(self, tag: &str) -> Vec { + let mut buf = Vec::with_capacity(64); + if self.is_esearch { + buf.extend_from_slice(b"* ESEARCH (TAG "); + quoted_string(&mut buf, tag); + buf.extend_from_slice(b")"); + if self.is_uid { + buf.extend_from_slice(b" UID"); + } + if let Some(count) = &self.count { + buf.extend_from_slice(b" COUNT "); + buf.extend_from_slice(count.to_string().as_bytes()); + } + if let Some(min) = &self.min { + buf.extend_from_slice(b" MIN "); + buf.extend_from_slice(min.to_string().as_bytes()); + } + if let Some(max) = &self.max { + buf.extend_from_slice(b" MAX "); + buf.extend_from_slice(max.to_string().as_bytes()); + } + if !self.ids.is_empty() { + buf.extend_from_slice(b" ALL "); + serialize_sequence(&mut buf, &self.ids); + } + if let Some(highest_modseq) = self.highest_modseq { + buf.extend_from_slice(b" MODSEQ "); + buf.extend_from_slice(highest_modseq.to_string().as_bytes()); + } + } else { + if !self.is_sort { + buf.extend_from_slice(b"* SEARCH"); + } else { + buf.extend_from_slice(b"* SORT"); + } + if !self.ids.is_empty() { + for id in &self.ids { + buf.push(b' '); + buf.extend_from_slice(id.to_string().as_bytes()); + } + } + if let Some(highest_modseq) = self.highest_modseq { + buf.extend_from_slice(b" (MODSEQ "); + buf.extend_from_slice(highest_modseq.to_string().as_bytes()); + buf.push(b')'); + } + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn serialize_search() { + for (mut response, tag, expected_v2, expected_v1) in [ + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![2, 10, 11], + min: 2.into(), + max: 11.into(), + count: 3.into(), + highest_modseq: None, + }, + "A283", + "* ESEARCH (TAG \"A283\") COUNT 3 MIN 2 MAX 11 ALL 2,10:11\r\n", + "* SEARCH 2 10 11\r\n", + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![ + 1, 2, 3, 5, 10, 11, 12, 13, 90, 92, 93, 94, 95, 96, 97, 98, 99, + ], + min: None, + max: None, + count: None, + highest_modseq: None, + }, + "A283", + "* ESEARCH (TAG \"A283\") ALL 1:3,5,10:13,90,92:99\r\n", + "* SEARCH 1 2 3 5 10 11 12 13 90 92 93 94 95 96 97 98 99\r\n", + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![], + min: None, + max: None, + count: None, + highest_modseq: None, + }, + "A283", + "* ESEARCH (TAG \"A283\")\r\n", + "* SEARCH\r\n", + ), + ( + super::Response { + is_uid: false, + is_esearch: true, + is_sort: false, + ids: vec![10, 11, 12, 13, 21], + min: None, + max: None, + count: None, + highest_modseq: 12345.into(), + }, + "A283", + "* ESEARCH (TAG \"A283\") ALL 10:13,21 MODSEQ 12345\r\n", + "* SEARCH 10 11 12 13 21 (MODSEQ 12345)\r\n", + ), + ] { + let response_v2 = String::from_utf8(response.clone().serialize(tag)).unwrap(); + response.is_esearch = false; + let response_v1 = String::from_utf8(response.serialize(tag)).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } +} diff --git a/crates/imap-proto/src/protocol/select.rs b/crates/imap-proto/src/protocol/select.rs new file mode 100644 index 0000000..077a062 --- /dev/null +++ b/crates/imap-proto/src/protocol/select.rs @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ResponseCode, StatusResponse}; + +use super::{ImapResponse, ObjectId, Sequence, list::ListItem}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub condstore: bool, + pub qresync: Option, + pub objectid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QResync { + pub uid_validity: u32, + pub modseq: u64, + pub known_uids: Option, + pub seq_match: Option<(Sequence, Sequence)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HighestModSeq(u64); + +#[derive(Debug, Clone)] +pub struct Response { + pub mailbox: ListItem, + pub total_messages: usize, + pub recent_messages: usize, + pub unseen_seq: u32, + pub uid_validity: u32, + pub uid_next: u32, + pub is_rev2: bool, + pub is_utf8: bool, + pub closed_previous: bool, + pub highest_modseq: Option, + pub objectid: Option, +} + +#[derive(Debug, Clone)] +pub struct Exists { + pub total_messages: usize, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(100); + if self.closed_previous { + buf = StatusResponse::ok("Closed previous mailbox") + .with_code(ResponseCode::Closed) + .serialize(buf); + } + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.total_messages.to_string().as_bytes()); + if !self.is_rev2 && self.recent_messages > 0 { + buf.extend_from_slice( + b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", + ); + } else { + buf.extend_from_slice( + b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + ); + } + if self.is_rev2 { + self.mailbox + .serialize(&mut buf, self.is_rev2, self.is_utf8, false); + } else { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.recent_messages.to_string().as_bytes()); + buf.extend_from_slice(b" RECENT\r\n"); + if self.unseen_seq > 0 { + buf.extend_from_slice(b"* OK [UNSEEN "); + buf.extend_from_slice(self.unseen_seq.to_string().as_bytes()); + buf.extend_from_slice(b"] Unseen messages\r\n"); + } + } + buf.extend_from_slice( + b"* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + ); + buf.extend_from_slice(b"* OK [UIDVALIDITY "); + buf.extend_from_slice(self.uid_validity.to_string().as_bytes()); + buf.extend_from_slice(b"] UIDs valid\r\n* OK [UIDNEXT "); + buf.extend_from_slice(self.uid_next.to_string().as_bytes()); + buf.extend_from_slice(b"] Next predicted UID\r\n"); + if let Some(highest_modseq) = self.highest_modseq { + highest_modseq.serialize(&mut buf); + } + if let Some(objectid) = &self.objectid { + buf.extend_from_slice(b"* OK ["); + objectid.serialize(&mut buf); + buf.extend_from_slice(b"] Object identifiers\r\n"); + } + buf + } +} + +impl HighestModSeq { + pub fn new(modseq: u64) -> Self { + Self(modseq) + } + + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"* OK [HIGHESTMODSEQ "); + buf.extend_from_slice(self.0.to_string().as_bytes()); + buf.extend_from_slice(b"] Highest Modseq\r\n"); + } + + pub fn into_bytes(self) -> Vec { + let mut buf = Vec::with_capacity(40); + self.serialize(&mut buf); + buf + } +} + +impl Exists { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"* "); + buf.extend_from_slice(self.total_messages.to_string().as_bytes()); + buf.extend_from_slice(b" EXISTS\r\n"); + } + + pub fn into_bytes(self) -> Vec { + let mut buf = Vec::with_capacity(15); + self.serialize(&mut buf); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ImapResponse, ObjectId, list::ListItem}; + use types::id::Id; + + use super::HighestModSeq; + + #[test] + fn serialize_select() { + let objectid = ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + }; + let mut objectid_line = b"* OK [".to_vec(); + objectid.serialize(&mut objectid_line); + objectid_line.extend_from_slice(b"] Object identifiers\r\n"); + let objectid_line = String::from_utf8(objectid_line).unwrap(); + + for (mut response, _tag, expected_v2, expected_v1) in [ + ( + super::Response { + mailbox: ListItem::new("INBOX"), + total_messages: 172, + recent_messages: 5, + unseen_seq: 3, + uid_validity: 3857529045, + uid_next: 4392, + closed_previous: false, + is_rev2: true, + is_utf8: true, + highest_modseq: HighestModSeq::new(100).into(), + objectid: Some(objectid.clone()), + }, + "A142", + format!( + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"INBOX\"\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "{}" + ), + objectid_line + ), + format!( + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "{}" + ), + objectid_line + ), + ), + ( + super::Response { + mailbox: ListItem::new("~peter/mail/台北/日本語"), + total_messages: 172, + recent_messages: 5, + unseen_seq: 3, + uid_validity: 3857529045, + uid_next: 4392, + closed_previous: true, + is_rev2: true, + is_utf8: true, + highest_modseq: None, + objectid: Some(objectid.clone()), + }, + "A142", + format!( + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"~peter/mail/台北/日本語\" (\"OLDNAME\" ", + "(\"~peter/mail/&U,BTFw-/&ZeVnLIqe-\"))\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "{}" + ), + objectid_line + ), + format!( + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "{}" + ), + objectid_line + ), + ), + ] { + let response_v2 = String::from_utf8(response.clone().serialize()).unwrap(); + response.is_rev2 = false; + let response_v1 = String::from_utf8(response.serialize()).unwrap(); + + assert_eq!(response_v2, expected_v2); + assert_eq!(response_v1, expected_v1); + } + } +} diff --git a/crates/imap-proto/src/protocol/status.rs b/crates/imap-proto/src/protocol/status.rs new file mode 100644 index 0000000..971adbf --- /dev/null +++ b/crates/imap-proto/src/protocol/status.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utf7::utf7_encode; + +use super::{ObjectId, quoted_string}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Status { + Messages, + UidNext, + UidValidity, + Unseen, + Deleted, + Size, + Recent, + HighestModSeq, + ObjectId, + DeletedStorage, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusItem { + pub mailbox_name: String, + pub items: Vec<(Status, StatusItemType)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatusItemType { + Number(u64), + String(String), + ObjectId(ObjectId), +} + +impl StatusItem { + pub fn serialize(&self, buf: &mut Vec, is_utf8: bool) { + buf.extend_from_slice(b"* STATUS "); + if is_utf8 { + quoted_string(buf, &self.mailbox_name); + } else { + quoted_string(buf, &utf7_encode(&self.mailbox_name)); + } + buf.extend_from_slice(b" ("); + for (pos, (status_item, value)) in self.items.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + + buf.extend_from_slice(match status_item { + Status::Messages => b"MESSAGES ", + Status::UidNext => b"UIDNEXT ", + Status::UidValidity => b"UIDVALIDITY ", + Status::Unseen => b"UNSEEN ", + Status::Deleted => b"DELETED ", + Status::Size => b"SIZE ", + Status::HighestModSeq => b"HIGHESTMODSEQ ", + Status::ObjectId => b"OBJECTID ", + Status::Recent => b"RECENT ", + Status::DeletedStorage => b"DELETED-STORAGE ", + }); + + match value { + StatusItemType::Number(num) => { + buf.extend_from_slice(num.to_string().as_bytes()); + } + StatusItemType::String(str) => { + buf.push(b'('); + buf.extend_from_slice(str.as_bytes()); + buf.push(b')'); + } + StatusItemType::ObjectId(object_id) => { + object_id.serialize_kvpairs(buf); + } + } + } + buf.extend_from_slice(b")\r\n"); + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{ + ObjectId, + status::{Status, StatusItem, StatusItemType}, + }; + use types::id::Id; + + #[test] + fn serialize_status() { + let objectid = ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + }; + let mut kvpairs = Vec::new(); + objectid.serialize_kvpairs(&mut kvpairs); + let kvpairs = String::from_utf8(kvpairs).unwrap(); + + let mut buf = Vec::new(); + StatusItem { + mailbox_name: "blurdybloop".into(), + items: vec![ + (Status::Messages, StatusItemType::Number(231)), + (Status::UidNext, StatusItemType::Number(44292)), + (Status::ObjectId, StatusItemType::ObjectId(objectid.clone())), + ], + } + .serialize(&mut buf, true); + + assert_eq!( + String::from_utf8(buf).unwrap(), + format!("* STATUS \"blurdybloop\" (MESSAGES 231 UIDNEXT 44292 OBJECTID {kvpairs})\r\n") + ); + } +} diff --git a/crates/imap-proto/src/protocol/store.rs b/crates/imap-proto/src/protocol/store.rs new file mode 100644 index 0000000..1094a33 --- /dev/null +++ b/crates/imap-proto/src/protocol/store.rs @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Flag, ImapResponse, Sequence, fetch::FetchItem}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub sequence_set: Sequence, + pub operation: Operation, + pub is_silent: bool, + pub keywords: Vec, + pub unchanged_since: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Operation { + Set, + Add, + Clear, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response<'x> { + pub is_utf8: bool, + pub items: Vec>, +} + +impl ImapResponse for Response<'_> { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + for item in &self.items { + item.serialize(&mut buf, self.is_utf8); + } + buf + } +} diff --git a/crates/imap-proto/src/protocol/subscribe.rs b/crates/imap-proto/src/protocol/subscribe.rs new file mode 100644 index 0000000..2284bc7 --- /dev/null +++ b/crates/imap-proto/src/protocol/subscribe.rs @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub mailbox_name: String, +} diff --git a/crates/imap-proto/src/protocol/thread.rs b/crates/imap-proto/src/protocol/thread.rs new file mode 100644 index 0000000..77a3340 --- /dev/null +++ b/crates/imap-proto/src/protocol/thread.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapResponse, search::Filter}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub filter: Vec, + pub algorithm: Algorithm, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Algorithm { + OrderedSubject, + References, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub is_uid: bool, + pub threads: Vec>, +} + +impl ImapResponse for Response { + fn serialize(self) -> Vec { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(b"* THREAD "); + for thread in &self.threads { + buf.push(b'('); + for (pos, id) in thread.iter().enumerate() { + if pos > 0 { + buf.push(b' '); + } + buf.extend_from_slice(id.to_string().as_bytes()); + } + buf.push(b')'); + } + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::ImapResponse; + + #[test] + fn serialize_thread() { + assert_eq!( + String::from_utf8( + super::Response { + is_uid: true, + threads: vec![vec![2, 10, 11], vec![49], vec![1, 3]], + } + .serialize() + ) + .unwrap(), + "* THREAD (2 10 11)(49)(1 3)\r\n" + ); + } +} diff --git a/crates/imap-proto/src/protocol/uidbatches.rs b/crates/imap-proto/src/protocol/uidbatches.rs new file mode 100644 index 0000000..a881119 --- /dev/null +++ b/crates/imap-proto/src/protocol/uidbatches.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::Write; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + pub tag: String, + pub batch_size: u32, + pub batch_range: Option<(u32, u32)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub ranges: Vec<(u32, u32)>, +} + +impl Response { + pub fn serialize(self, tag: &str) -> Vec { + let mut buf = String::with_capacity(32 + (self.ranges.len() * 16)); + let _ = write!(&mut buf, "* UIDBATCHES (TAG \"{tag}\")"); + for (pos, (high, low)) in self.ranges.iter().enumerate() { + let _ = write!(&mut buf, "{}{high}:{low}", if pos == 0 { ' ' } else { ',' }); + } + buf.push_str("\r\n"); + buf.into_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::Response; + + #[test] + fn serialize_uidbatches() { + assert_eq!( + String::from_utf8( + Response { + ranges: vec![(215295, 99696), (99695, 20351), (20350, 7830), (7829, 1)], + } + .serialize("A143") + ) + .unwrap(), + concat!( + "* UIDBATCHES (TAG \"A143\") ", + "215295:99696,99695:20351,20350:7830,7829:1\r\n" + ) + ); + + assert_eq!( + String::from_utf8(Response { ranges: vec![] }.serialize("A144")).unwrap(), + "* UIDBATCHES (TAG \"A144\")\r\n" + ); + } +} diff --git a/crates/imap-proto/src/receiver.rs b/crates/imap-proto/src/receiver.rs new file mode 100644 index 0000000..945003a --- /dev/null +++ b/crates/imap-proto/src/receiver.rs @@ -0,0 +1,1173 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ResponseCode, ResponseType}; +use compact_str::{CompactString, format_compact}; +use std::fmt::Display; + +#[derive(Debug, Clone)] +pub enum Error { + NeedsMoreData, + NeedsLiteral { size: u32 }, + Error { response: trc::Error }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub tag: String, + pub command: T, + pub tokens: Vec, +} + +pub trait CommandParser: Sized + Default { + fn parse(bytes: &[u8], is_uid: bool) -> Option; + fn tokenize_brackets(&self) -> bool; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Token { + Argument(Vec), + ParenthesisOpen, // ( + ParenthesisClose, // ) + BracketOpen, // [ + BracketClose, // ] + Lt, // < + Gt, // > + Dot, // . + Nil, // NIL +} + +impl Default for Request { + fn default() -> Self { + Self { + tag: String::new(), + command: T::default(), + tokens: Vec::new(), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum State { + Start, + Tag, + Command { is_uid: bool }, + Argument { last_ch: u8 }, + ArgumentQuoted { escaped: bool }, + Literal { non_sync: bool }, + LiteralSeek { size: u32, non_sync: bool }, + LiteralData { remaining: u32 }, + LiteralDiscard { remaining: u32 }, +} + +pub struct Receiver { + buf: ArgumentBuffer, + pub request: Request, + pub state: State, + pub max_request_size: usize, + pub current_request_size: usize, + pub start_state: State, +} + +const ARG_MAX_LEN: usize = 8000; + +struct ArgumentBuffer { + buf: Vec, +} + +impl Receiver { + pub fn new() -> Self { + Receiver { + max_request_size: 25 * 1024 * 1024, // 25MB + ..Default::default() + } + } + + pub fn with_start_state(mut self, state: State) -> Self { + self.state = state; + self.start_state = state; + self + } + + pub fn with_max_request_size(max_request_size: usize) -> Self { + Receiver { + max_request_size, + ..Default::default() + } + } + + pub fn error_reset(&mut self, message: impl Into) -> Error { + let request = std::mem::take(&mut self.request); + let err = Error::err( + if !request.tag.is_empty() { + request.tag.into() + } else { + None + }, + message, + ); + self.buf = ArgumentBuffer::default(); + self.state = self.start_state; + self.current_request_size = 0; + err + } + + fn push_argument(&mut self, in_quote: bool) -> Result<(), Error> { + if !self.buf.is_empty() { + self.current_request_size += self.buf.len(); + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format_compact!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(Token::Argument(self.buf.take())); + } else if in_quote { + self.request.tokens.push(Token::Nil); + } + Ok(()) + } + + fn push_token(&mut self, token: Token) -> Result<(), Error> { + self.current_request_size += 1; + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format_compact!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(token); + Ok(()) + } + + pub fn parse(&mut self, bytes: &mut std::slice::Iter<'_, u8>) -> Result, Error> { + #[allow(clippy::while_let_on_iterator)] + while let Some(&ch) = bytes.next() { + match self.state { + State::Start => { + if !ch.is_ascii_whitespace() { + // SAFETY: This called just once + self.buf.push_unchecked(ch); + self.state = State::Tag; + } + } + State::Tag => match ch { + b' ' => { + if !self.buf.is_empty() { + self.request.tag = + String::from_utf8(self.buf.take()).map_err(|_| { + self.error_reset("Tag is not a valid UTF-8 string.") + })?; + self.state = State::Command { is_uid: false }; + } + } + b'\t' | b'\r' => {} + b'\n' => { + return Err(self.error_reset(format_compact!( + "Missing command after tag {:?}, found CRLF instead.", + self.buf.as_str() + ))); + } + _ => { + self.buf.push_checked(ch, 128).map_err(|_| { + self.error_reset("Tag exceeds maximum length of 128 characters.") + })?; + } + }, + State::Command { is_uid } => { + if ch.is_ascii_alphanumeric() { + self.buf + .push_checked(ch.to_ascii_uppercase(), 15) + .map_err(|_| { + self.error_reset("Command exceeds maximum length of 15 characters.") + })?; + } else if ch.is_ascii_whitespace() { + if !self.buf.is_empty() { + if !self.buf.as_ref().eq_ignore_ascii_case(b"UID") { + self.request.command = T::parse(self.buf.as_ref(), is_uid) + .ok_or_else(|| { + let err = format_compact!( + "Unrecognized command '{}'.", + String::from_utf8_lossy(self.buf.as_ref()) + ); + self.error_reset(err) + })?; + self.buf.clear(); + if ch != b'\n' { + self.state = State::Argument { last_ch: b' ' }; + } else { + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + } else { + self.buf.clear(); + self.state = State::Command { is_uid: true }; + } + } + } else { + return Err(self.error_reset(format_compact!( + "Invalid character {:?} in command name.", + ch as char + ))); + } + } + State::Argument { last_ch } => match ch { + b'\"' if last_ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::ArgumentQuoted { escaped: false }; + } + b'{' if last_ch.is_ascii_whitespace() + || (last_ch == b'~' && self.buf.len() == 1) => + { + if last_ch != b'~' { + self.push_argument(false)?; + } else { + self.buf.clear(); + } + self.state = State::Literal { non_sync: false }; + } + b'(' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisOpen)?; + } + b')' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisClose)?; + } + b'[' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketOpen)?; + } + b']' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketClose)?; + } + b'<' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Lt)?; + } + b'>' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Gt)?; + } + b'.' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Dot)?; + } + b'\n' => { + self.push_argument(false)?; + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + _ if ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::Argument { last_ch: ch }; + } + _ => { + self.buf.push_checked(ch, ARG_MAX_LEN).map_err(|_| { + self.error_reset("Argument exceeds maximum length of 8000 bytes.") + })?; + self.state = State::Argument { last_ch: ch }; + } + }, + State::ArgumentQuoted { escaped } => match ch { + b'\"' => { + if !escaped { + self.push_argument(true)?; + self.state = State::Argument { last_ch: b' ' }; + } else { + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + self.state = State::ArgumentQuoted { escaped: false }; + } + } + b'\\' => { + if escaped { + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + } + self.state = State::ArgumentQuoted { escaped: !escaped }; + } + b'\n' => { + return Err(self.error_reset("Unterminated quoted argument.")); + } + _ => { + if escaped { + // SAFETY: We check the size below + self.buf.push_unchecked(b'\\'); + } + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + self.state = State::ArgumentQuoted { escaped: false }; + } + }, + State::Literal { non_sync } => { + match ch { + b'}' => { + if !self.buf.is_empty() { + let size = self.buf.as_str().parse::().map_err(|_| { + self.error_reset("Literal size is not a valid number.") + })?; + let oversize = self.current_request_size + size as usize + > self.max_request_size; + if oversize && !non_sync { + return Err(self.error_reset(format_compact!( + "Literal exceeds the maximum request size of {} bytes.", + self.max_request_size + ))); + } + self.state = State::LiteralSeek { size, non_sync }; + if !oversize { + self.buf.resize_buffer(size as usize); + } + self.buf.clear(); + } else { + return Err(self.error_reset("Invalid empty literal.")); + } + } + b'+' => { + if !self.buf.is_empty() { + self.state = State::Literal { non_sync: true }; + } else { + return Err(self.error_reset("Invalid non-sync literal.")); + } + } + _ if ch.is_ascii_digit() => { + if !non_sync { + self.buf.push_checked(ch, 15).map_err(|_| { + self.error_reset("Literal size exceeds maximum of 15 digits.") + })?; + } else { + // Digit found after non-sync '+' flag + return Err(self.error_reset("Invalid literal.")); + } + } + _ => { + return Err(self.error_reset(format_compact!( + "Invalid character {:?} in literal.", + ch as char + ))); + } + } + } + State::LiteralSeek { size, non_sync } => { + if ch == b'\n' { + if non_sync + && self.current_request_size + size as usize > self.max_request_size + { + self.state = State::LiteralDiscard { remaining: size }; + } else if size > 0 { + self.state = State::LiteralData { remaining: size }; + if !non_sync { + return Err(Error::NeedsLiteral { size }); + } + } else { + self.state = State::Argument { last_ch: b' ' }; + self.push_token(Token::Nil)?; + if !non_sync { + return Err(Error::NeedsLiteral { size }); + } + } + } else if !ch.is_ascii_whitespace() { + return Err( + self.error_reset("Expected CRLF after literal, found an invalid char.") + ); + } + } + State::LiteralDiscard { remaining } => { + if remaining > 1 { + self.state = State::LiteralDiscard { + remaining: remaining - 1, + }; + } else { + return Err(self.error_reset(format_compact!( + "Literal exceeds the maximum request size of {} bytes.", + self.max_request_size + ))); + } + } + State::LiteralData { remaining } => { + // SAFETY: We checked the size before entering this state + self.buf.push_unchecked(ch); + + if remaining > 1 { + self.state = State::LiteralData { + remaining: remaining - 1, + }; + } else { + self.push_argument(false)?; + self.state = State::Argument { last_ch: b' ' }; + } + } + } + } + + Err(Error::NeedsMoreData) + } +} + +impl ArgumentBuffer { + pub fn new() -> Self { + ArgumentBuffer { + buf: Vec::with_capacity(10), + } + } + + pub fn resize_buffer(&mut self, size: usize) { + if self.buf.capacity() < size { + self.buf.reserve(size - self.buf.capacity()); + } + } + + #[inline(always)] + pub fn push_checked(&mut self, byte: u8, limit: usize) -> Result<(), ()> { + if self.buf.len() < limit { + self.buf.push(byte); + Ok(()) + } else { + Err(()) + } + } + + #[inline(always)] + pub fn push_unchecked(&mut self, byte: u8) { + self.buf.push(byte); + } + + pub fn take(&mut self) -> Vec { + let buf = self.buf.clone(); + self.buf.clear(); + buf + } + + #[inline(always)] + pub fn len(&self) -> usize { + self.buf.len() + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + + #[inline(always)] + pub fn clear(&mut self) { + self.buf.clear(); + } + + #[inline(always)] + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.buf).unwrap_or_default() + } +} + +impl Token { + pub fn unwrap_string(self) -> crate::parser::Result { + match self { + Token::Argument(value) => { + String::from_utf8(value).map_err(|_| "Invalid UTF-8 in argument.".into()) + } + other => Ok(other.to_string()), + } + } + + pub fn unwrap_bytes(self) -> Vec { + match self { + Token::Argument(value) => value, + other => other.as_bytes().to_vec(), + } + } + + pub fn eq_ignore_ascii_case(&self, bytes: &[u8]) -> bool { + match self { + Token::Argument(argument) => argument.eq_ignore_ascii_case(bytes), + Token::ParenthesisOpen => bytes.eq(b"("), + Token::ParenthesisClose => bytes.eq(b")"), + Token::BracketOpen => bytes.eq(b"["), + Token::BracketClose => bytes.eq(b"]"), + Token::Gt => bytes.eq(b">"), + Token::Lt => bytes.eq(b"<"), + Token::Dot => bytes.eq(b"."), + Token::Nil => bytes.is_empty(), + } + } + + pub fn is_parenthesis_open(&self) -> bool { + matches!(self, Token::ParenthesisOpen) + } + + pub fn is_parenthesis_close(&self) -> bool { + matches!(self, Token::ParenthesisClose) + } + + pub fn is_bracket_open(&self) -> bool { + matches!(self, Token::BracketOpen) + } + + pub fn is_bracket_close(&self) -> bool { + matches!(self, Token::BracketClose) + } + + pub fn is_dot(&self) -> bool { + matches!(self, Token::Dot) + } + + pub fn is_lt(&self) -> bool { + matches!(self, Token::Lt) + } + + pub fn is_gt(&self) -> bool { + matches!(self, Token::Gt) + } +} + +impl AsRef<[u8]> for ArgumentBuffer { + fn as_ref(&self) -> &[u8] { + &self.buf + } +} + +impl Default for ArgumentBuffer { + fn default() -> Self { + Self::new() + } +} + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&String::from_utf8_lossy(self.as_bytes())) + } +} + +impl Token { + pub fn as_bytes(&self) -> &[u8] { + match self { + Token::Argument(value) => value, + Token::ParenthesisOpen => b"(", + Token::ParenthesisClose => b")", + Token::BracketOpen => b"[", + Token::BracketClose => b"]", + Token::Gt => b">", + Token::Lt => b"<", + Token::Dot => b".", + Token::Nil => b"", + } + } +} + +impl Error { + pub fn err(tag: Option>, message: impl Into) -> Self { + Error::Error { + response: trc::ImapEvent::Error + .ctx(trc::Key::Details, message) + .ctx_opt(trc::Key::Id, tag.map(Into::into)) + .ctx(trc::Key::Type, ResponseType::Bad) + .code(ResponseCode::Parse), + } + } +} + +impl Default for Receiver { + fn default() -> Self { + Self { + buf: Default::default(), + request: Default::default(), + state: State::Start, + start_state: State::Start, + max_request_size: 25 * 1024 * 1024, + current_request_size: 0, + } + } +} + +impl Request { + pub fn into_error(self, message: impl Into) -> trc::Error { + trc::ImapEvent::Error + .ctx(trc::Key::Details, message) + .ctx(trc::Key::Id, CompactString::from_string_buffer(self.tag)) + } + + pub fn into_parse_error(self, message: impl Into) -> trc::Error { + trc::ImapEvent::Error + .ctx(trc::Key::Details, message) + .ctx(trc::Key::Id, CompactString::from_string_buffer(self.tag)) + .ctx(trc::Key::Code, ResponseCode::Parse) + .ctx(trc::Key::Type, ResponseType::Bad) + } +} + +pub(crate) fn bad(tag: impl Into, message: impl Into) -> trc::Error { + trc::ImapEvent::Error + .ctx(trc::Key::Details, message) + .ctx(trc::Key::Id, tag) + .ctx(trc::Key::Type, ResponseType::Bad) +} + +/* + +astring = 1*ASTRING-CHAR / string + +string = quoted / literal + +literal = "{" number64 ["+"] "}" CRLF *CHAR8 + +quoted = DQUOTE *QUOTED-CHAR DQUOTE + +ASTRING-CHAR = ATOM-CHAR / resp-specials + +atom = 1*ATOM-CHAR + +ATOM-CHAR = + +atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards / + quoted-specials / resp-specials + +resp-specials = "]" + +list-wildcards = "%" / "*" + +quoted-specials = DQUOTE / "\" + +DQUOTE = %x22 ; " (Double Quote) + +*/ + +#[cfg(test)] +mod tests { + + use crate::Command; + + use super::{Error, Receiver, Request, Token}; + + #[test] + fn receiver_parse_ok() { + let mut receiver = Receiver::new(); + + for (frames, expected_requests) in [ + ( + vec!["abcd CAPABILITY\r\n"], + vec![Request { + tag: "abcd".into(), + command: Command::Capability, + tokens: vec![], + }], + ), + ( + vec!["A023 LO", "GOUT\r\n"], + vec![Request { + tag: "A023".into(), + command: Command::Logout, + tokens: vec![], + }], + ), + ( + vec![" A001 AUTHENTICATE GSSAPI \r\n"], + vec![Request { + tag: "A001".into(), + command: Command::Authenticate, + tokens: vec![Token::Argument(b"GSSAPI".to_vec())], + }], + ), + ( + vec!["A03 AUTHENTICATE ", "PLAIN dGVzdAB0ZXN", "0AHRlc3Q=\r\n"], + vec![Request { + tag: "A03".into(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"PLAIN".to_vec()), + Token::Argument(b"dGVzdAB0ZXN0AHRlc3Q=".to_vec()), + ], + }], + ), + ( + vec!["A003 CREATE owatagusiam/\r\n"], + vec![Request { + tag: "A003".into(), + command: Command::Create, + tokens: vec![Token::Argument(b"owatagusiam/".to_vec())], + }], + ), + ( + vec!["A682 LIST \"\" *\r\n"], + vec![Request { + tag: "A682".into(), + command: Command::List, + tokens: vec![Token::Nil, Token::Argument(b"*".to_vec())], + }], + ), + ( + vec!["A03 LIST () \"\" \"%\" RETURN (CHILDREN)\r\n"], + vec![Request { + tag: "A03".into(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"CHILDREN".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A05 LIST (REMOTE SUBSCRIBED) \"\" \"*\"\r\n"], + vec![Request { + tag: "A05".into(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::Argument(b"REMOTE".to_vec()), + Token::Argument(b"SUBSCRIBED".to_vec()), + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"*".to_vec()), + ], + }], + ), + ( + vec!["a1 list \"\" (\"foo\")\r\n"], + vec![Request { + tag: "a1".into(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::ParenthesisOpen, + Token::Argument(b"foo".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["a3.1 LIST \"\" (% music/rock)\r\n"], + vec![Request { + tag: "a3.1".into(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::ParenthesisOpen, + Token::Argument(b"%".to_vec()), + Token::Argument(b"music/rock".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A01 LIST \"\" % RETURN (STATUS (MESSAGES UNSEEN))\r\n"], + vec![Request { + tag: "A01".into(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::Argument(b"UNSEEN".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec![" A01 LiSt \"\" % RETURN ( STATUS ( MESSAGES UNSEEN ) ) \r\n"], + vec![Request { + tag: "A01".into(), + command: Command::List, + tokens: vec![ + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::Argument(b"UNSEEN".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A02 LIST (SUBSCRIBED RECURSIVEMATCH) \"\" % RETURN (STATUS (MESSAGES))\r\n"], + vec![Request { + tag: "A02".into(), + command: Command::List, + tokens: vec![ + Token::ParenthesisOpen, + Token::Argument(b"SUBSCRIBED".to_vec()), + Token::Argument(b"RECURSIVEMATCH".to_vec()), + Token::ParenthesisClose, + Token::Nil, + Token::Argument(b"%".to_vec()), + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"STATUS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MESSAGES".to_vec()), + Token::ParenthesisClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A002 CREATE \"INBOX.Sent Mail\"\r\n"], + vec![Request { + tag: "A002".into(), + command: Command::Create, + tokens: vec![Token::Argument(b"INBOX.Sent Mail".to_vec())], + }], + ), + ( + vec!["A002 CREATE \"Maibox \\\"quo\\\\ted\\\" \"\r\n"], + vec![Request { + tag: "A002".into(), + command: Command::Create, + tokens: vec![Token::Argument(b"Maibox \"quo\\ted\" ".to_vec())], + }], + ), + ( + vec!["A004 COPY 2:4 meeting\r\n"], + vec![Request { + tag: "A004".into(), + command: Command::Copy(false), + tokens: vec![ + Token::Argument(b"2:4".to_vec()), + Token::Argument(b"meeting".to_vec()), + ], + }], + ), + ( + vec![ + "A282 SEARCH RETURN (MIN COU", + "NT) FLAGGED SINCE 1-Feb-1994 ", + "NOT FROM \"Smith\"\r\n", + ], + vec![Request { + tag: "A282".into(), + command: Command::Search(false), + tokens: vec![ + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"MIN".to_vec()), + Token::Argument(b"COUNT".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"FLAGGED".to_vec()), + Token::Argument(b"SINCE".to_vec()), + Token::Argument(b"1-Feb-1994".to_vec()), + Token::Argument(b"NOT".to_vec()), + Token::Argument(b"FROM".to_vec()), + Token::Argument(b"Smith".to_vec()), + ], + }], + ), + ( + vec!["F284 UID STORE $ +FLAGS.Silent (\\Deleted)\r\n"], + vec![Request { + tag: "F284".into(), + command: Command::Store(true), + tokens: vec![ + Token::Argument(b"$".to_vec()), + Token::Argument(b"+FLAGS.Silent".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Deleted".to_vec()), + Token::ParenthesisClose, + ], + }], + ), + ( + vec!["A654 FETCH 2:4 (FLAGS BODY[HEADER.FIELDS (DATE FROM)])\r\n"], + vec![Request { + tag: "A654".into(), + command: Command::Fetch(false), + tokens: vec![ + Token::Argument(b"2:4".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"FLAGS".to_vec()), + Token::Argument(b"BODY".to_vec()), + Token::BracketOpen, + Token::Argument(b"HEADER".to_vec()), + Token::Dot, + Token::Argument(b"FIELDS".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"DATE".to_vec()), + Token::Argument(b"FROM".to_vec()), + Token::ParenthesisClose, + Token::BracketClose, + Token::ParenthesisClose, + ], + }], + ), + ( + vec![ + "B283 UID SEARCH RETURN (SAVE) CHARSET ", + "KOI8-R (OR $ 1,3000:3021) TEXT \"hello world\"\r\n", + ], + vec![Request { + tag: "B283".into(), + command: Command::Search(true), + tokens: vec![ + Token::Argument(b"RETURN".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"SAVE".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"CHARSET".to_vec()), + Token::Argument(b"KOI8-R".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"OR".to_vec()), + Token::Argument(b"$".to_vec()), + Token::Argument(b"1,3000:3021".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"TEXT".to_vec()), + Token::Argument(b"hello world".to_vec()), + ], + }], + ), + ( + vec![ + "P283 SEARCH CHARSET UTF-8 (OR $ 1,3000:3021) ", + "TEXT {8+}\r\nмать\r\n", + ], + vec![Request { + tag: "P283".into(), + command: Command::Search(false), + tokens: vec![ + Token::Argument(b"CHARSET".to_vec()), + Token::Argument(b"UTF-8".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"OR".to_vec()), + Token::Argument(b"$".to_vec()), + Token::Argument(b"1,3000:3021".to_vec()), + Token::ParenthesisClose, + Token::Argument(b"TEXT".to_vec()), + Token::Argument("мать".to_string().into_bytes()), + ], + }], + ), + ( + vec!["A001 LOGIN {11}\r\n", "FRED FOOBAR {7}\r\n", "fat man\r\n"], + vec![Request { + tag: "A001".into(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"FRED FOOBAR".to_vec()), + Token::Argument(b"fat man".to_vec()), + ], + }], + ), + ( + vec!["TAG3 CREATE \"Test-ąęć-Test\"\r\n"], + vec![Request { + tag: "TAG3".into(), + command: Command::Create, + tokens: vec![Token::Argument("Test-ąęć-Test".as_bytes().to_vec())], + }], + ), + ( + vec!["abc LOGIN {0}\r\n", "\r\n"], + vec![Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec!["abc LOGIN {0+}\r\n\r\n"], + vec![Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec![ + "A003 APPEND saved-messages (\\Seen) {297+}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n\r\n", + ], + vec![Request { + tag: "A003".into(), + command: Command::Append, + tokens: vec![ + Token::Argument(b"saved-messages".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Seen".to_vec()), + Token::ParenthesisClose, + Token::Argument( + concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@example.com\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n" + ) + .as_bytes() + .to_vec(), + ), + ], + }], + ), + ( + vec![ + "A003 APPEND saved-messages (\\Seen) {326}\r\n", + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.siam.edu.example\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n\r\n", + ], + vec![Request { + tag: "A003".into(), + command: Command::Append, + tokens: vec![ + Token::Argument(b"saved-messages".to_vec()), + Token::ParenthesisOpen, + Token::Argument(b"\\Seen".to_vec()), + Token::ParenthesisClose, + Token::Argument( + concat!( + "Date: Mon, 7 Feb 1994 21:52:25 -0800 (PST)\r\n", + "From: Fred Foobar \r\n", + "Subject: afternoon meeting\r\n", + "To: mooch@owatagu.siam.edu.example\r\n", + "Message-Id: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n", + "\r\n", + "Hello Joe, do you think we can meet at 3:30 tomorrow?\r\n", + ) + .as_bytes() + .to_vec(), + ), + ], + }], + ), + ( + vec!["001 NOOP\r\n002 CAPABILITY\r\nabc LOGIN hello world\r\n"], + vec![ + Request { + tag: "001".into(), + command: Command::Noop, + tokens: vec![], + }, + Request { + tag: "002".into(), + command: Command::Capability, + tokens: vec![], + }, + Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"hello".to_vec()), + Token::Argument(b"world".to_vec()), + ], + }, + ], + ), + ] { + let mut requests = Vec::new(); + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(err) => panic!("{:?} for frames {:#?}", err, frames), + } + } + } + assert_eq!(requests, expected_requests, "{:#?}", frames); + } + } + + #[test] + fn receiver_parse_invalid() { + let mut receiver = Receiver::::new(); + for invalid in [ + //"\r\n", + //" \r \n", + "a001\r\n", + "a001 unknown\r\n", + "a001 login {abc}\r\n", + "a001 login {+30}\r\n", + "a001 login {30} junk\r\n", + ] { + match receiver.parse(&mut invalid.as_bytes().iter()) { + Err(Error::Error { .. }) => {} + result => panic!("Expecter error, got: {:?}", result), + } + } + } + + #[test] + fn receiver_discard_oversized_non_sync_literal() { + for frames in [ + vec![ + "a1 APPEND inbox (\\Draft) {40+}\r\n", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n", + "b2 NOOP\r\n", + ], + vec![ + "a1 APPEND inbox (\\Draft) {40+}\r\nAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAA\r\nb2 NOOP\r\n", + ], + vec![ + "a1 APPEND inbox (\\Draft) {40+}\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\nb2 NOOP\r\n", + ], + ] { + let mut receiver = Receiver::::with_max_request_size(30); + let mut requests = Vec::new(); + let mut errors = 0; + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(Error::Error { .. }) => errors += 1, + } + } + } + + assert_eq!(errors, 1, "expected a single error for {:#?}", frames); + assert_eq!( + requests, + vec![Request { + tag: "b2".into(), + command: Command::Noop, + tokens: vec![], + }], + "connection did not resync for {:#?}", + frames + ); + } + } +} diff --git a/crates/imap-proto/src/utf7.rs b/crates/imap-proto/src/utf7.rs new file mode 100644 index 0000000..f314e15 --- /dev/null +++ b/crates/imap-proto/src/utf7.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs +// Author: Jeffrey Stedfast + +static UTF_7_RANK: &[u8] = &[ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 62, 63, 255, 255, 255, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, + 255, 255, 255, 255, 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 255, 255, 255, 255, 255, +]; + +static UTF_7_MAP: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"; + +pub fn utf7_decode(text: &str) -> Option { + let mut bytes: Vec = Vec::with_capacity(text.len()); + let mut bits = 0; + let mut v: u32 = 0; + let mut shifted = false; + let mut text = text.chars().peekable(); + + while let Some(ch) = text.next() { + if shifted { + if ch == '-' { + shifted = false; + bits = 0; + v = 0; + } else if ch as usize > 127 { + return None; + } else { + let rank = *UTF_7_RANK.get(ch as usize)?; + + if rank == 0xff { + return None; + } + + v = (v << 6) | rank as u32; + bits += 6; + + if bits >= 16 { + bytes.push(((v >> (bits - 16)) & 0xffff) as u16); + bits -= 16; + } + } + } else if ch == '&' { + match text.peek() { + Some('-') => { + bytes.push(b'&' as u16); + text.next(); + } + Some(_) => { + shifted = true; + } + None => { + bytes.push(ch as u16); + } + } + } else { + bytes.push(ch as u16); + } + } + + String::from_utf16(&bytes).ok() +} + +pub fn utf7_encode(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut shifted = false; + let mut bits = 0; + let mut u: u32 = 0; + + for ch in text.encode_utf16() { + if (0x20..0x7f).contains(&ch) { + if shifted { + if bits > 0 { + result.push(char::from(UTF_7_MAP[((u << (6 - bits)) & 0x3f) as usize])); + } + result.push('-'); + shifted = false; + bits = 0; + } + + if ch == 0x26 { + result.push_str("&-"); + } else { + result.push((ch as u8) as char); + } + } else { + if !shifted { + result.push('&'); + shifted = true; + } + + u = (u << 16) | ch as u32; + bits += 16; + + while bits >= 6 { + result.push(char::from(UTF_7_MAP[((u >> (bits - 6)) & 0x3f) as usize])); + bits -= 6; + } + } + } + + if shifted { + if bits > 0 { + result.push(char::from(UTF_7_MAP[((u << (6 - bits)) & 0x3f) as usize])); + } + result.push('-'); + } + + result +} + +#[inline(always)] +pub fn utf7_maybe_decode(text: String, is_utf8: bool) -> String { + if is_utf8 { + text + } else { + utf7_decode(&text).unwrap_or(text) + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn utf7_decode() { + for (input, expected_result) in [ + ("~peter/mail/&U,BTFw-/&ZeVnLIqe-", "~peter/mail/台北/日本語"), + ("&U,BTF2XlZyyKng-", "台北日本語"), + ("Hello, World&ACE-", "Hello, World!"), + ("Hi Mom -&Jjo--!", "Hi Mom -☺-!"), + ("&ZeVnLIqe-", "日本語"), + ("Item 3 is &AKM-1.", "Item 3 is £1."), + ("Plus minus &- -&- &--", "Plus minus & -& &-"), + ( + "&APw-ber ihre mi&AN8-liche Lage&ADs- &ACI-wir", + "über ihre mißliche Lage; \"wir", + ), + ( + concat!( + "&ACI-The sayings of Confucius,&ACI- James R. Ware, trans. &U,BTFw-:\n", + "&ZYeB9FH6ckh5Pg-, 1980.\n", + "&Vttm+E6UfZM-, &W4tRQ066bOg-, &UxdOrA-: &Ti1XC2b4Xpc-, 1990." + ), + concat!( + "\"The sayings of Confucius,\" James R. Ware, trans. 台北:\n", + "文致出版社, 1980.\n", + "四書五經, 宋元人注, 北京: 中國書店, 1990." + ), + ), + ("Test-ąęć-Test", "Test-ąęć-Test"), + (r#"&A8g- "&A9QD1APUA9gD3APcA-+""#, "ψ \"ϔϔϔϘϜϜ+\""), + ] { + assert_eq!( + super::utf7_decode(input).expect(input), + expected_result, + "while decoding {:?}", + input + ); + } + } + + #[test] + fn utf7_encode() { + for (expected_result, input) in [ + ("~peter/mail/&U,BTFw-/&ZeVnLIqe-", "~peter/mail/台北/日本語"), + ("&U,BTF2XlZyyKng-", "台北日本語"), + ("Hi Mom -&Jjo--!", "Hi Mom -☺-!"), + ("&ZeVnLIqe-", "日本語"), + ("Item 3 is &AKM-1.", "Item 3 is £1."), + ("Plus minus &- -&- &--", "Plus minus & -& &-"), + ("&VMhUyNg93gQ-", "哈哈😄"), + ] { + assert_eq!( + super::utf7_encode(input), + expected_result, + "while encoding {:?}", + expected_result + ); + } + } +} diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml new file mode 100644 index 0000000..8258a73 --- /dev/null +++ b/crates/imap/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "imap" +version = "0.16.22" +edition = "2024" + +[dependencies] +imap_proto = { path = "../imap-proto" } +types = { path = "../types" } +directory = { path = "../directory" } +trc = { path = "../trc" } +store = { path = "../store" } +common = { path = "../common" } +email = { path = "../email" } +nlp = { path = "../nlp" } +utils = { path = "../utils" } +registry = { path = "../registry" } +mail-parser = { version = "0.11", features = ["full_encoding"] } +tokio = { version = "1.53", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +parking_lot = "0.12" +ahash = { version = "0.8" } +md5 = "0.8.1" +rand = "0.10.2" +compact_str = "0.10.0" + +[features] +test_mode = [] + +[lints] +workspace = true diff --git a/crates/imap/src/core/client.rs b/crates/imap/src/core/client.rs new file mode 100644 index 0000000..2de60d8 --- /dev/null +++ b/crates/imap/src/core/client.rs @@ -0,0 +1,519 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{iter::Peekable, sync::Arc, vec::IntoIter}; + +use common::{ + KV_RATE_LIMIT_IMAP, + network::{SessionResult, SessionStream}, +}; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + receiver::{self, Request}, +}; +use trc::SecurityEvent; + +use super::{SelectedMailbox, Session, SessionData, State}; + +impl Session { + pub async fn ingest(&mut self, bytes: &[u8]) -> SessionResult { + trc::event!( + Imap(trc::ImapEvent::RawInput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + let mut bytes = bytes.iter(); + let mut requests = Vec::with_capacity(2); + let mut needs_literal = None; + let mut has_expunge = false; + + loop { + match self.receiver.parse(&mut bytes) { + Ok(request) => match self.is_allowed(request).await { + Ok(request) => { + has_expunge |= + matches!(request.command, Command::Expunge(_) | Command::Close); + requests.push(request); + } + Err(err) => { + if !self.write_error(err).await { + return SessionResult::Close; + } + } + }, + Err(receiver::Error::NeedsMoreData) => { + break; + } + Err(receiver::Error::NeedsLiteral { size }) => { + needs_literal = size.into(); + break; + } + Err(receiver::Error::Error { response }) => { + // Check for port scanners + if matches!( + (&self.state, response.key(trc::Key::Code)), + ( + State::NotAuthenticated { .. }, + Some(trc::Value::String(v)) + ) if v == "PARSE" + ) { + match self.server.is_scanner_fail2banned(self.remote_addr).await { + Ok(true) => { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = self.session_id, + RemoteIp = self.remote_addr, + Reason = "Invalid IMAP command", + ); + + return SessionResult::Close; + } + Ok(false) => {} + Err(err) => { + trc::error!( + err.span_id(self.session_id) + .details("Failed to check for fail2ban") + ); + } + } + } + + if !self.write_error(response).await { + return SessionResult::Close; + } + break; + } + } + } + + let mut requests = requests.into_iter().peekable(); + while let Some(request) = requests.next() { + let result = match request.command { + Command::List | Command::Lsub => self + .handle_list(request) + .await + .map(|_| SessionResult::Continue), + Command::Select | Command::Examine => self + .handle_select(request) + .await + .map(|_| SessionResult::Continue), + Command::Create => self + .handle_create(group_requests(&mut requests, vec![request])) + .await + .map(|_| SessionResult::Continue), + Command::Delete => self + .handle_delete(group_requests(&mut requests, vec![request])) + .await + .map(|_| SessionResult::Continue), + Command::Rename => self + .handle_rename(request) + .await + .map(|_| SessionResult::Continue), + Command::Status => self + .handle_status(group_requests(&mut requests, vec![request])) + .await + .map(|_| SessionResult::Continue), + Command::Append => self + .handle_append(request) + .await + .map(|_| SessionResult::Continue), + Command::Close => self + .handle_close(request) + .await + .map(|_| SessionResult::Continue), + Command::Unselect => self + .handle_unselect(request) + .await + .map(|_| SessionResult::Continue), + Command::Expunge(is_uid) => self + .handle_expunge(request, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Search(is_uid) => self + .handle_search(request, false, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Fetch(_) => self + .handle_fetch(group_requests(&mut requests, vec![request])) + .await + .map(|_| SessionResult::Continue), + Command::Store(is_uid) => self + .handle_store(request, is_uid, !has_expunge) + .await + .map(|_| SessionResult::Continue), + Command::Copy(is_uid) => self + .handle_copy_move(request, false, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Move(is_uid) => self + .handle_copy_move(request, true, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Sort(is_uid) => self + .handle_search(request, true, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Thread(is_uid) => self + .handle_thread(request, is_uid) + .await + .map(|_| SessionResult::Continue), + Command::Idle => self + .handle_idle(request) + .await + .map(|_| SessionResult::Continue), + Command::Subscribe => self + .handle_subscribe(request, true) + .await + .map(|_| SessionResult::Continue), + Command::Unsubscribe => self + .handle_subscribe(request, false) + .await + .map(|_| SessionResult::Continue), + Command::Namespace => self + .handle_namespace(request) + .await + .map(|_| SessionResult::Continue), + Command::Authenticate => Box::pin(self.handle_authenticate(request)) + .await + .map(|_| SessionResult::Continue), + Command::Login => Box::pin(self.handle_login(request)) + .await + .map(|_| SessionResult::Continue), + Command::Capability => self + .handle_capability(request) + .await + .map(|_| SessionResult::Continue), + Command::Enable => self + .handle_enable(request) + .await + .map(|_| SessionResult::Continue), + Command::StartTls => self + .write_bytes( + StatusResponse::ok("Begin TLS negotiation now") + .with_tag(request.tag) + .into_bytes(), + ) + .await + .map(|_| SessionResult::UpgradeTls), + Command::Noop => self + .handle_noop(request) + .await + .map(|_| SessionResult::Continue), + Command::Check => self + .handle_noop(request) + .await + .map(|_| SessionResult::Continue), + Command::Logout => self + .handle_logout(request) + .await + .map(|_| SessionResult::Close), + Command::SetAcl => self + .handle_set_acl(request) + .await + .map(|_| SessionResult::Continue), + Command::DeleteAcl => self + .handle_set_acl(request) + .await + .map(|_| SessionResult::Continue), + Command::GetAcl => self + .handle_get_acl(request) + .await + .map(|_| SessionResult::Continue), + Command::ListRights => self + .handle_list_rights(request) + .await + .map(|_| SessionResult::Continue), + Command::MyRights => self + .handle_my_rights(request) + .await + .map(|_| SessionResult::Continue), + Command::GetQuota => self + .handle_get_quota(request) + .await + .map(|_| SessionResult::Continue), + Command::GetQuotaRoot => self + .handle_get_quota_root(request) + .await + .map(|_| SessionResult::Continue), + Command::Unauthenticate => self + .handle_unauthenticate(request) + .await + .map(|_| SessionResult::Continue), + Command::Id => self + .handle_id(request) + .await + .map(|_| SessionResult::Continue), + Command::GetJmapAccess => self + .handle_jmap_access(request) + .await + .map(|_| SessionResult::Continue), + Command::UidBatches => self + .handle_uidbatches(request) + .await + .map(|_| SessionResult::Continue), + }; + + match result { + Ok(SessionResult::Continue) => (), + Ok(result) => return result, + Err(err) => { + if !self.write_error(err).await { + return SessionResult::Close; + } + } + } + } + + if let Some(needs_literal) = needs_literal + && let Err(err) = self + .write_bytes(format!("+ Ready for {} bytes.\r\n", needs_literal).into_bytes()) + .await + { + self.write_error(err).await; + return SessionResult::Close; + } + + SessionResult::Continue + } +} + +pub fn group_requests( + requests: &mut Peekable>>, + mut grouped_requests: Vec>, +) -> Vec> { + let last_command = grouped_requests.last().unwrap().command; + loop { + match requests.peek() { + Some(request) if request.command == last_command => { + grouped_requests.push(requests.next().unwrap()); + } + _ => break, + } + } + grouped_requests +} + +impl Session { + async fn is_allowed(&self, request: Request) -> trc::Result> { + let state = &self.state; + // Rate limit request + if let State::Authenticated { data } | State::Selected { data, .. } = state + && let Some(rate) = &self.server.core.imap.rate_requests + && data + .server + .in_memory_store() + .is_rate_allowed( + KV_RATE_LIMIT_IMAP, + &data.account_id.to_be_bytes(), + rate, + true, + ) + .await? + .is_some() + { + return Err(trc::LimitEvent::TooManyRequests.into_err()); + } + + match &request.command { + Command::Capability | Command::Noop | Command::Logout | Command::Id => Ok(request), + Command::StartTls => { + if !self.is_tls { + if self.instance.acceptor.is_tls() { + Ok(request) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("TLS is not available.") + .id(request.tag)) + } + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Already in TLS mode.") + .id(request.tag)) + } + } + Command::Authenticate => { + if let State::NotAuthenticated { .. } = state { + if self.is_tls || self.server.core.imap.allow_plain_auth { + Ok(request) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Cannot authenticate over plain-text.") + .code(ResponseCode::PrivacyRequired) + .id(request.tag)) + } + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Already authenticated.") + .id(request.tag)) + } + } + Command::Login => { + if let State::NotAuthenticated { .. } = state { + if self.is_tls || self.server.core.imap.allow_plain_auth { + Ok(request) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("LOGIN is disabled on the clear-text port.") + .id(request.tag)) + } + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Already authenticated.") + .id(request.tag)) + } + } + Command::Enable + | Command::Select + | Command::Examine + | Command::Create + | Command::Delete + | Command::Rename + | Command::Subscribe + | Command::Unsubscribe + | Command::List + | Command::Lsub + | Command::Namespace + | Command::Status + | Command::Append + | Command::Idle + | Command::SetAcl + | Command::DeleteAcl + | Command::GetAcl + | Command::ListRights + | Command::MyRights + | Command::Unauthenticate + | Command::GetQuota + | Command::GetQuotaRoot + | Command::GetJmapAccess => { + if let State::Authenticated { .. } | State::Selected { .. } = state { + Ok(request) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Not authenticated.") + .id(request.tag)) + } + } + Command::Close + | Command::Unselect + | Command::Expunge(_) + | Command::Search(_) + | Command::Fetch(_) + | Command::Store(_) + | Command::Copy(_) + | Command::Move(_) + | Command::Check + | Command::Sort(_) + | Command::Thread(_) + | Command::UidBatches => match state { + State::Selected { mailbox, .. } => { + // RFC 9586 forbids message numbers once UIDONLY is enabled + if self.is_uidonly && request.command.requires_uid() { + Err(trc::ImapEvent::Error + .into_err() + .details("Message numbers are not allowed once UIDONLY is enabled.") + .code(ResponseCode::UidRequired) + .ctx(trc::Key::Type, ResponseType::Bad) + .id(request.tag)) + } else if mailbox.is_select + || !matches!( + request.command, + Command::Store(_) | Command::Expunge(_) | Command::Move(_), + ) + { + Ok(request) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Not permitted in EXAMINE state.") + .id(request.tag)) + } + } + State::Authenticated { .. } => Err(trc::ImapEvent::Error + .into_err() + .details("No mailbox is selected.") + .ctx(trc::Key::Type, ResponseType::Bad) + .id(request.tag)), + State::NotAuthenticated { .. } => Err(trc::ImapEvent::Error + .into_err() + .details("Not authenticated.") + .id(request.tag)), + }, + } + } +} + +impl State { + pub fn auth_failures(&self) -> u32 { + match self { + State::NotAuthenticated { auth_failures, .. } => *auth_failures, + _ => unreachable!(), + } + } + + pub fn session_data(&self) -> Arc> { + match self { + State::Authenticated { data } => data.clone(), + State::Selected { data, .. } => data.clone(), + _ => unreachable!(), + } + } + + pub fn mailbox_state(&self) -> (Arc>, Arc) { + match self { + State::Selected { data, mailbox, .. } => (data.clone(), mailbox.clone()), + _ => unreachable!(), + } + } + + pub fn session_mailbox_state(&self) -> (Arc>, Option>) { + match self { + State::Authenticated { data } => (data.clone(), None), + State::Selected { data, mailbox, .. } => (data.clone(), mailbox.clone().into()), + _ => unreachable!(), + } + } + + pub fn select_data(&self) -> (Arc>, Arc) { + match self { + State::Selected { data, mailbox } => (data.clone(), mailbox.clone()), + _ => unreachable!(), + } + } + + pub fn spawn_task(&self, params: P, fnc: F) -> trc::Result<()> + where + F: FnOnce(P, &super::SessionData) -> R + Send + 'static, + P: Send + Sync + 'static, + R: std::future::Future> + Send + 'static, + { + let data = self.session_data(); + + tokio::spawn(async move { + if let Err(err) = fnc(params, &data).await { + let _ = data.write_error(err).await; + } + }); + + Ok(()) + } + + pub fn is_authenticated(&self) -> bool { + matches!(self, State::Authenticated { .. } | State::Selected { .. }) + } + + pub fn close_mailbox(&self) -> bool { + matches!(self, State::Selected { .. }) + } +} diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs new file mode 100644 index 0000000..60836b9 --- /dev/null +++ b/crates/imap/src/core/mailbox.rs @@ -0,0 +1,438 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Account, MailboxId, MailboxSync, Session, SessionData}; +use crate::core::Mailbox; +use ahash::AHashMap; +use common::{ + auth::AccessToken, + network::{SessionStream, limiter::InFlight}, + sharing::EffectiveAcl, +}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, + mailbox::INBOX_ID, +}; +use imap_proto::protocol::list::Attribute; +use parking_lot::Mutex; +use std::{collections::BTreeMap, sync::atomic::Ordering}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse}; + +impl SessionData { + pub async fn new( + session: &Session, + access_token: AccessToken, + in_flight: Option, + ) -> trc::Result { + let mut session = SessionData { + stream_tx: session.stream_tx.clone(), + server: session.server.clone(), + account_id: access_token.account_id(), + session_id: session.session_id, + mailboxes: Mutex::new(vec![]), + state: access_token.state().into(), + remote_addr: session.remote_addr, + access_token, + in_flight, + }; + + // Fetch mailboxes for the main account + let mut mailboxes = vec![ + session + .fetch_account_mailboxes(session.account_id, None, &session.access_token, None) + .await + .caused_by(trc::location!())? + .unwrap(), + ]; + + // Fetch shared mailboxes + for &account_id in session.access_token.shared_accounts(Collection::Mailbox) { + let prefix: String = format!( + "{}/{}", + session.server.core.email.shared_folder, + session + .server + .account(account_id) + .await + .caused_by(trc::location!())? + .name() + ); + mailboxes.push( + session + .fetch_account_mailboxes(account_id, prefix.into(), &session.access_token, None) + .await + .caused_by(trc::location!())? + .unwrap(), + ); + } + + session.mailboxes = Mutex::new(mailboxes); + + Ok(session) + } + + async fn fetch_account_mailboxes( + &self, + account_id: u32, + mailbox_prefix: Option, + access_token: &AccessToken, + current_state: Option, + ) -> trc::Result> { + let cache = self + .server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + if current_state.is_some_and(|state| state == cache.last_change_id) { + return Ok(None); + } + + let shared_mailbox_ids = if access_token.is_member(account_id) { + None + } else { + cache.shared_mailboxes(access_token, Acl::Read).into() + }; + + // Build special uses + let mut special_uses = AHashMap::new(); + for mailbox in &cache.mailboxes.items { + if shared_mailbox_ids + .as_ref() + .is_none_or(|ids| ids.contains(mailbox.document_id)) + && !matches!(mailbox.role, SpecialUse::None) + { + special_uses.insert(mailbox.role, mailbox.document_id); + } + } + + // Build account + let mut account = Account { + account_id, + prefix: mailbox_prefix, + mailbox_names: BTreeMap::new(), + mailbox_state: AHashMap::with_capacity(cache.mailboxes.items.len()), + last_change_id: cache.last_change_id, + }; + + for mailbox in &cache.mailboxes.items { + if shared_mailbox_ids + .as_ref() + .is_some_and(|ids| !ids.contains(mailbox.document_id)) + { + continue; + } + + // Build mailbox path and map it to its effective id + let mailbox_name = if let Some(prefix) = &account.prefix { + let mut name = String::with_capacity(prefix.len() + mailbox.path.len() + 1); + name.push_str(prefix.as_str()); + name.push('/'); + name.push_str(mailbox.path.as_str()); + name + } else { + mailbox.path.clone() + }; + let effective_mailbox_id = self + .server + .core + .email + .default_folders + .iter() + .find(|f| f.name == mailbox_name || f.aliases.iter().any(|a| a == &mailbox_name)) + .and_then(|f| special_uses.get(&f.special_use)) + .copied() + .unwrap_or(mailbox.document_id); + account + .mailbox_names + .insert(mailbox_name, effective_mailbox_id); + account.mailbox_state.insert( + mailbox.document_id, + Mailbox { + has_children: cache + .mailboxes + .items + .iter() + .any(|child| child.parent_id == mailbox.document_id), + is_subscribed: mailbox.subscribers.contains(&access_token.account_id()), + special_use: match mailbox.role { + SpecialUse::Trash => Some(Attribute::Trash), + SpecialUse::Junk => Some(Attribute::Junk), + SpecialUse::Drafts => Some(Attribute::Drafts), + SpecialUse::Archive => Some(Attribute::Archive), + SpecialUse::Sent => Some(Attribute::Sent), + SpecialUse::Important => Some(Attribute::Important), + SpecialUse::Memos => Some(Attribute::Memos), + SpecialUse::Scheduled => Some(Attribute::Scheduled), + SpecialUse::Snoozed => Some(Attribute::Snoozed), + _ => None, + }, + total_messages: cache.in_mailbox(mailbox.document_id).count() as u64, + total_unseen: cache + .in_mailbox_without_keyword(mailbox.document_id, &Keyword::Seen) + .count() as u64, + total_deleted: cache + .in_mailbox_with_keyword(mailbox.document_id, &Keyword::Deleted) + .count() as u64, + uid_validity: mailbox.uid_validity as u64, + uid_next: self + .get_uid_next(&MailboxId { + account_id, + mailbox_id: mailbox.document_id, + }) + .await + .caused_by(trc::location!())? as u64, + total_deleted_storage: None, + size: None, + }, + ); + } + + Ok(account.into()) + } + + pub async fn synchronize_mailboxes( + &self, + return_changes: bool, + ) -> trc::Result> { + let mut changes = if return_changes { + MailboxSync::default().into() + } else { + None + }; + + // Obtain access token + let access_token = self + .refresh_access_token() + .await + .caused_by(trc::location!())?; + let state = access_token.state(); + + // Shared mailboxes might have changed + let mut added_accounts = Vec::new(); + if self.state.load(Ordering::Relaxed) != state { + // Remove unlinked shared accounts + let mut added_account_ids = Vec::new(); + { + let mut mailboxes = self.mailboxes.lock(); + let mut new_accounts = Vec::with_capacity(mailboxes.len()); + let has_access_to = access_token + .shared_accounts(Collection::Mailbox) + .copied() + .collect::>(); + for account in mailboxes.drain(..) { + if access_token.is_account_id(account.account_id) + || has_access_to.contains(&account.account_id) + { + new_accounts.push(account); + } else { + // Add unshared mailboxes to deleted list + if let Some(changes) = &mut changes { + for (mailbox_name, _) in account.mailbox_names { + changes.deleted.push(mailbox_name); + } + } + } + } + + // Add new shared account ids + for account_id in has_access_to { + if !new_accounts + .iter() + .skip(1) + .any(|m| m.account_id == account_id) + { + added_account_ids.push(account_id); + } + } + *mailboxes = new_accounts; + } + + // Fetch mailboxes for each new shared account + for account_id in added_account_ids { + let prefix: String = format!( + "{}/{}", + self.server.core.email.shared_folder, + self.server + .account(account_id) + .await + .caused_by(trc::location!())? + .name() + ); + added_accounts.push( + self.fetch_account_mailboxes(account_id, prefix.into(), &access_token, None) + .await? + .unwrap(), + ); + } + + // Update state + self.state.store(state, Ordering::Relaxed); + } + + // Fetch mailbox changes for all accounts + let mut changed_accounts = Vec::new(); + let account_states = self + .mailboxes + .lock() + .iter() + .map(|m| (m.account_id, m.prefix.clone(), m.last_change_id)) + .collect::>(); + for (account_id, prefix, last_state) in account_states { + if let Some(changed_account) = self + .fetch_account_mailboxes(account_id, prefix, &access_token, last_state.into()) + .await + .caused_by(trc::location!())? + { + changed_accounts.push(changed_account); + } + } + + // Update mailboxes + if !changed_accounts.is_empty() || !added_accounts.is_empty() { + let mut mailboxes = self.mailboxes.lock(); + + for changed_account in changed_accounts { + if let Some(pos) = mailboxes + .iter() + .position(|a| a.account_id == changed_account.account_id) + { + // Add changes and deletions + if let Some(changes) = &mut changes { + let old_account = &mailboxes[pos]; + let new_account = &changed_account; + + // Add new mailboxes + for (mailbox_name, mailbox_id) in new_account.mailbox_names.iter() { + if let Some(old_mailbox) = old_account.mailbox_state.get(mailbox_id) { + if let Some(mailbox) = new_account.mailbox_state.get(mailbox_id) + && (mailbox.total_messages != old_mailbox.total_messages + || mailbox.total_unseen != old_mailbox.total_unseen) + { + changes.changed.push(mailbox_name.clone()); + } + } else { + changes.added.push(mailbox_name.clone()); + } + } + + // Add deleted mailboxes + for (mailbox_name, mailbox_id) in &old_account.mailbox_names { + if !new_account.mailbox_state.contains_key(mailbox_id) { + changes.deleted.push(mailbox_name.clone()); + } + } + } + + mailboxes[pos] = changed_account; + } else { + // Add newly shared accounts + if let Some(changes) = &mut changes { + changes + .added + .extend(changed_account.mailbox_names.keys().cloned()); + } + + mailboxes.push(changed_account); + } + } + + if !added_accounts.is_empty() { + // Add newly shared accounts + if let Some(changes) = &mut changes { + for added_account in &added_accounts { + changes + .added + .extend(added_account.mailbox_names.keys().cloned()); + } + } + mailboxes.extend(added_accounts); + } + } + + Ok(changes) + } + + pub fn get_mailbox_by_name(&self, mailbox_name: &str) -> Option { + let is_inbox = mailbox_name.eq_ignore_ascii_case("inbox"); + for account in self.mailboxes.lock().iter() { + if account + .prefix + .as_ref() + .is_none_or(|p| mailbox_name.starts_with(p.as_str())) + { + for (mailbox_name_, mailbox_id_) in account.mailbox_names.iter() { + if (!is_inbox && mailbox_name_ == mailbox_name) + || (is_inbox && *mailbox_id_ == INBOX_ID) + { + return MailboxId { + account_id: account.account_id, + mailbox_id: *mailbox_id_, + } + .into(); + } + } + } + } + None + } + + pub fn get_mailbox_by_id(&self, account_id: u32, mailbox_id: u32) -> Option { + for account in self.mailboxes.lock().iter() { + if account.account_id == account_id + && account.mailbox_names.values().any(|id| *id == mailbox_id) + { + return MailboxId { + account_id, + mailbox_id, + } + .into(); + } + } + None + } + + pub async fn check_mailbox_acl( + &self, + account_id: u32, + document_id: u32, + item: Acl, + ) -> trc::Result { + let access_token = self.refresh_access_token().await?; + Ok(access_token.is_member(account_id) + || self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + document_id, + )) + .await + .and_then(|mailbox| { + if let Some(mailbox) = mailbox { + Ok(Some( + mailbox + .unarchive::()? + .acls + .effective_acl(&access_token) + .contains(item), + )) + } else { + Ok(None) + } + })? + .ok_or_else(|| { + trc::ImapEvent::Error + .caused_by(trc::location!()) + .details("Mailbox no longer exists.") + })?) + } +} diff --git a/crates/imap/src/core/message.rs b/crates/imap/src/core/message.rs new file mode 100644 index 0000000..9eedb91 --- /dev/null +++ b/crates/imap/src/core/message.rs @@ -0,0 +1,332 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ImapUidToId, Mailbox, MailboxId, MailboxState, NextMailboxState, SelectedMailbox, SessionData, +}; +use crate::core::ImapId; +use ahash::AHashMap; +use common::network::SessionStream; +use email::cache::MessageCacheFetch; +use imap_proto::protocol::{Sequence, expunge, select::Exists}; +use std::collections::BTreeMap; +use store::{ValueKey, roaring::RoaringBitmap, write::ValueClass}; +use trc::AddContext; +use types::{collection::Collection, field::MailboxField}; + +impl SessionData { + pub async fn fetch_messages( + &self, + mailbox: &MailboxId, + current_state: Option, + ) -> trc::Result> { + let cached_messages = self + .server + .get_cached_messages(mailbox.account_id) + .await + .caused_by(trc::location!())?; + + if current_state.is_some_and(|state| state == cached_messages.emails.change_id) { + return Ok(None); + } + + // Obtain UID next and assign UIDs + let uid_map = cached_messages + .emails + .items + .iter() + .filter_map(|item| { + item.mailboxes.iter().find_map(|m| { + if m.mailbox_id == mailbox.mailbox_id { + Some((m.uid, item.document_id)) + } else { + None + } + }) + }) + .collect::>(); + let mut uid_max = 0; + let mut id_to_imap = AHashMap::with_capacity(uid_map.len()); + let mut uid_to_id = AHashMap::with_capacity(uid_map.len()); + + for (seqnum, (uid, message_id)) in uid_map.into_iter().enumerate() { + if uid > uid_max { + uid_max = uid; + } + id_to_imap.insert( + message_id, + ImapId { + uid, + seqnum: seqnum as u32 + 1, + }, + ); + uid_to_id.insert(uid, message_id); + } + + Ok(Some(MailboxState { + total_messages: id_to_imap.len(), + id_to_imap, + uid_to_id, + uid_max, + modseq: cached_messages.emails.change_id, + next_state: None, + })) + } + + pub async fn synchronize_messages(&self, mailbox: &SelectedMailbox) -> trc::Result { + // Obtain current modseq + let mut current_modseq = mailbox.state.lock().modseq; + if let Some(new_state) = self + .fetch_messages(&mailbox.id, current_modseq.into()) + .await? + { + // Synchronize messages + let mut current_state = mailbox.state.lock(); + current_modseq = new_state.modseq; + + // Add missing uids + let mut deletions = current_state + .next_state + .take() + .map(|state| state.deletions) + .unwrap_or_default(); + let mut id_to_imap = AHashMap::with_capacity(current_state.id_to_imap.len()); + for (id, imap_id) in std::mem::take(&mut current_state.id_to_imap) { + if !new_state.uid_to_id.contains_key(&imap_id.uid) { + // Add to deletions + deletions.push(imap_id); + + // Invalidate entries + current_state.uid_to_id.remove(&imap_id.uid); + } else { + id_to_imap.insert(id, imap_id); + } + } + current_state.id_to_imap = id_to_imap; + + // Update state + current_state.modseq = new_state.modseq; + current_state.next_state = Some(Box::new(NextMailboxState { + next_state: new_state, + deletions, + })); + } + + Ok(current_modseq) + } + + pub async fn write_mailbox_changes( + &self, + mailbox: &SelectedMailbox, + use_vanished: bool, + ) -> trc::Result { + // Resync mailbox + let modseq = self.synchronize_messages(mailbox).await?; + let mut buf = Vec::new(); + { + let mut current_state = mailbox.state.lock(); + if let Some(next_state) = current_state.next_state.take() { + if !next_state.deletions.is_empty() { + let mut ids = next_state + .deletions + .into_iter() + .map(|id| if use_vanished { id.uid } else { id.seqnum }) + .collect::>(); + ids.sort_unstable(); + expunge::Response { use_vanished, ids }.serialize_to(&mut buf); + } + if !buf.is_empty() + || next_state + .next_state + .uid_max + .saturating_sub(current_state.uid_max) + > 0 + { + Exists { + total_messages: next_state.next_state.total_messages, + } + .serialize(&mut buf); + } + *current_state = next_state.next_state; + } + } + if !buf.is_empty() { + self.write_bytes(buf).await?; + } + + Ok(modseq) + } + + pub async fn get_uid_next(&self, mailbox: &MailboxId) -> trc::Result { + self.server + .core + .storage + .data + .get_counter(ValueKey { + account_id: mailbox.account_id, + collection: Collection::Mailbox.into(), + document_id: mailbox.mailbox_id, + class: ValueClass::Property(MailboxField::UidCounter.into()), + }) + .await + .map(|v| (v + 1) as u32) + } + + pub fn mailbox_state(&self, mailbox: &MailboxId) -> Option { + self.mailboxes + .lock() + .iter() + .find(|m| m.account_id == mailbox.account_id) + .and_then(|m| m.mailbox_state.get(&mailbox.mailbox_id)) + .cloned() + } +} + +impl SelectedMailbox { + pub async fn sequence_to_ids( + &self, + sequence: &Sequence, + is_uid: bool, + ) -> trc::Result> { + if !sequence.is_saved_search() { + let mut ids = AHashMap::new(); + let state = self.state.lock(); + let (id_to_imap, uid_max, total_messages) = + if let Some(next) = state.next_state.as_ref() { + ( + &next.next_state.id_to_imap, + next.next_state.uid_max, + next.next_state.total_messages, + ) + } else { + (&state.id_to_imap, state.uid_max, state.total_messages) + }; + + if is_uid { + if !id_to_imap.is_empty() { + for (id, imap_id) in id_to_imap { + if sequence.contains(imap_id.uid, uid_max) { + ids.insert(*id, *imap_id); + } + } + } + } else if !id_to_imap.is_empty() { + for (id, imap_id) in id_to_imap { + if sequence.contains(imap_id.seqnum, total_messages as u32) { + ids.insert(*id, *imap_id); + } + } + } + + Ok(ids) + } else { + let saved_ids = self.get_saved_search().await.ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details("No saved search found.") + })?; + let mut ids = AHashMap::with_capacity(saved_ids.len()); + let state = self.state.lock(); + + for imap_id in saved_ids.iter() { + if let Some(id) = state.uid_to_id.get(&imap_id.uid) { + ids.insert(*id, *imap_id); + } + } + + Ok(ids) + } + } + + pub fn uids_in_range(&self, min: Option, max: Option) -> RoaringBitmap { + let state = self.state.lock(); + let id_to_imap = state + .next_state + .as_ref() + .map_or(&state.id_to_imap, |next| &next.next_state.id_to_imap); + + RoaringBitmap::from_iter(id_to_imap.iter().filter_map(|(id, imap_id)| { + (min.is_none_or(|min| imap_id.uid >= min) && max.is_none_or(|max| imap_id.uid <= max)) + .then_some(*id) + })) + } + + pub fn seqnum_to_uid(&self, seqnum: u32) -> Option { + let state = self.state.lock(); + state + .next_state + .as_ref() + .map_or(&state.id_to_imap, |next| &next.next_state.id_to_imap) + .values() + .find(|imap_id| imap_id.seqnum == seqnum) + .map(|imap_id| imap_id.uid) + } + + pub fn uids_descending(&self) -> Vec { + let mut uids = { + let state = self.state.lock(); + state + .next_state + .as_ref() + .map_or(&state.uid_to_id, |next| &next.next_state.uid_to_id) + .keys() + .copied() + .collect::>() + }; + uids.sort_unstable_by(|a, b| b.cmp(a)); + uids + } + + pub async fn sequence_expand_missing(&self, sequence: &Sequence, is_uid: bool) -> Vec { + let mut deleted_ids = Vec::new(); + if !sequence.is_saved_search() { + let state = self.state.lock(); + if is_uid { + for uid in sequence.expand(state.uid_max) { + if !state.uid_to_id.contains_key(&uid) { + deleted_ids.push(uid); + } + } + } else { + for seqnum in sequence.expand(state.total_messages as u32) { + if seqnum > state.total_messages as u32 { + deleted_ids.push(seqnum); + } + } + } + } else if let Some(saved_ids) = self.get_saved_search().await { + let state = self.state.lock(); + for id in saved_ids.iter() { + if !state.uid_to_id.contains_key(&id.uid) { + deleted_ids.push(if is_uid { id.uid } else { id.seqnum }); + } + } + } + deleted_ids.sort_unstable(); + deleted_ids + } + + pub fn append_messages(&self, ids: Vec, modseq: Option) { + let mut mailbox = self.state.lock(); + if modseq.unwrap_or(0) > mailbox.modseq { + let mut uid_max = 0; + for id in ids { + mailbox.total_messages += 1; + let seqnum = mailbox.total_messages as u32; + mailbox.uid_to_id.insert(id.uid, id.uid); + mailbox.id_to_imap.insert( + id.id, + ImapId { + uid: id.uid, + seqnum, + }, + ); + uid_max = id.uid; + } + mailbox.uid_max = uid_max; + } + } +} diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs new file mode 100644 index 0000000..174bb45 --- /dev/null +++ b/crates/imap/src/core/mod.rs @@ -0,0 +1,239 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use common::{ + Inner, Server, + auth::AccessToken, + network::{ServerInstance, SessionStream, limiter::InFlight}, +}; +use imap_proto::{ + Command, + protocol::{ProtocolVersion, list::Attribute}, + receiver::Receiver, +}; +use std::{ + collections::BTreeMap, + net::IpAddr, + sync::{Arc, atomic::AtomicU32}, +}; +use tokio::{ + io::{ReadHalf, WriteHalf}, + sync::watch, +}; +use trc::AddContext; + +pub mod client; +pub mod mailbox; +pub mod message; +pub mod session; + +#[derive(Clone)] +pub struct ImapSessionManager { + pub inner: Arc, +} + +impl ImapSessionManager { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +pub struct Session { + pub server: Server, + pub instance: Arc, + pub receiver: Receiver, + pub version: ProtocolVersion, + pub state: State, + pub is_tls: bool, + pub is_condstore: bool, + pub is_qresync: bool, + pub is_utf8: bool, + pub is_objectid: bool, + pub is_uidonly: bool, + pub stream_rx: ReadHalf, + pub stream_tx: Arc>>, + pub in_flight: InFlight, + pub remote_addr: IpAddr, + pub session_id: u64, +} + +pub struct SessionData { + pub account_id: u32, + pub access_token: AccessToken, + pub server: Server, + pub session_id: u64, + pub mailboxes: parking_lot::Mutex>, + pub stream_tx: Arc>>, + pub state: AtomicU32, + pub remote_addr: IpAddr, + pub in_flight: Option, +} + +pub struct SelectedMailbox { + pub id: MailboxId, + pub state: parking_lot::Mutex, + pub saved_search: parking_lot::Mutex, + pub is_select: bool, + pub is_condstore: bool, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub struct MailboxId { + pub account_id: u32, + pub mailbox_id: u32, +} + +#[derive(Debug, Clone, Default)] +pub struct Account { + pub account_id: u32, + pub prefix: Option, + pub mailbox_names: BTreeMap, + pub mailbox_state: AHashMap, + pub last_change_id: u64, +} + +#[derive(Debug, Default, Clone)] +pub struct Mailbox { + pub has_children: bool, + pub is_subscribed: bool, + pub special_use: Option, + pub total_messages: u64, + pub total_unseen: u64, + pub total_deleted: u64, + pub total_deleted_storage: Option, + pub uid_validity: u64, + pub uid_next: u64, + pub size: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct MailboxState { + pub uid_max: u32, + pub id_to_imap: AHashMap, + pub uid_to_id: AHashMap, + pub total_messages: usize, + pub modseq: u64, + pub next_state: Option>, +} + +#[derive(Debug, Clone)] +pub struct NextMailboxState { + pub next_state: MailboxState, + pub deletions: Vec, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ImapId { + pub uid: u32, + pub seqnum: u32, +} + +#[derive(Debug, Default)] +pub struct MailboxSync { + pub added: Vec, + pub changed: Vec, + pub deleted: Vec, +} + +pub enum SavedSearch { + InFlight { + rx: watch::Receiver>>, + }, + Results { + items: Arc>, + }, + None, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ImapUidToId { + pub uid: u32, + pub id: u32, +} + +pub enum State { + NotAuthenticated { + auth_failures: u32, + }, + Authenticated { + data: Arc>, + }, + Selected { + data: Arc>, + mailbox: Arc, + }, +} + +impl State { + pub fn try_replace_stream_tx( + self, + new_stream: Arc>>, + ) -> Option> { + match self { + State::NotAuthenticated { auth_failures } => { + State::NotAuthenticated { auth_failures }.into() + } + State::Authenticated { data } => { + Arc::try_unwrap(data).ok().map(|data| State::Authenticated { + data: Arc::new(data.replace_stream_tx(new_stream)), + }) + } + State::Selected { data, mailbox } => { + Arc::try_unwrap(data).ok().map(|data| State::Selected { + data: Arc::new(data.replace_stream_tx(new_stream)), + mailbox, + }) + } + } + } +} + +impl SessionData { + pub async fn refresh_access_token(&self) -> trc::Result { + self.server + .access_token(self.account_id) + .await + .and_then(|inner| { + AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr) + }) + .caused_by(trc::location!()) + } + + pub fn replace_stream_tx( + self, + new_stream: Arc>>, + ) -> SessionData { + SessionData { + account_id: self.account_id, + server: self.server, + session_id: self.session_id, + mailboxes: self.mailboxes, + stream_tx: new_stream, + state: self.state, + in_flight: self.in_flight, + access_token: self.access_token, + remote_addr: self.remote_addr, + } + } +} + +impl MailboxState { + pub fn map_result_id(&self, document_id: u32, is_uid: bool) -> Option<(u32, ImapId)> { + if let Some(imap_id) = self.id_to_imap.get(&document_id) { + Some((if is_uid { imap_id.uid } else { imap_id.seqnum }, *imap_id)) + } else if is_uid { + self.next_state.as_ref().and_then(|s| { + s.next_state + .id_to_imap + .get(&document_id) + .map(|imap_id| (imap_id.uid, *imap_id)) + }) + } else { + None + } + } +} diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs new file mode 100644 index 0000000..3ba4948 --- /dev/null +++ b/crates/imap/src/core/session.rs @@ -0,0 +1,312 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapSessionManager, Session, State}; +use crate::{ + GREETING_WITH_TLS, GREETING_WITH_TLS_LOGIN_DISABLED, GREETING_WITHOUT_TLS, + GREETING_WITHOUT_TLS_LOGIN_DISABLED, +}; +use common::{ + BuildServer, + network::{SessionData, SessionManager, SessionResult, SessionStream, stream::NullIo}, +}; +use imap_proto::{ + protocol::{ProtocolVersion, SerializeResponse}, + receiver::Receiver, +}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::server::TlsStream; + +impl SessionManager for ImapSessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + if let Ok(mut session) = Session::new(session, self).await + && session.handle_conn().await + && session.instance.acceptor.is_tls() + && let Ok(mut session) = session.into_tls().await + { + session.handle_conn().await; + } + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} + +impl Session { + pub async fn handle_conn(&mut self) -> bool { + let mut buf = vec![0; 8192]; + let mut shutdown_rx = self.instance.shutdown_rx.clone(); + + loop { + tokio::select! { + result = tokio::time::timeout( + if !matches!(self.state, State::NotAuthenticated {..}) { + self.server.core.imap.timeout_auth + } else { + self.server.core.imap.timeout_unauth + }, + self.stream_rx.read(&mut buf)) => { + match result { + Ok(Ok(bytes_read)) => { + if bytes_read > 0 { + match self.ingest(&buf[..bytes_read]).await { + SessionResult::Continue => (), + SessionResult::UpgradeTls => { + return true; + } + SessionResult::Close => { + break; + } + } + } else { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + break; + } + }, + Ok(Err(err)) => { + trc::event!( + Network(trc::NetworkEvent::ReadError), + SpanId = self.session_id, + Reason = err.to_string(), + CausedBy = trc::location!() + ); + break; + }, + Err(_) => { + trc::event!( + Network(trc::NetworkEvent::Timeout), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + self.write_bytes(&b"* BYE Connection timed out.\r\n"[..]).await.ok(); + break; + } + } + }, + _ = shutdown_rx.changed() => { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + Reason = "Server shutting down", + CausedBy = trc::location!() + ); + self.write_bytes(&b"* BYE Server shutting down.\r\n"[..]).await.ok(); + break; + } + }; + } + + false + } + + pub async fn new( + mut session: SessionData, + manager: ImapSessionManager, + ) -> Result, ()> { + // Write greeting + let is_tls = session.stream.is_tls(); + let server = manager.inner.build_server(); + let offer_tls = !is_tls && session.instance.acceptor.is_tls(); + let allow_auth = is_tls || server.core.imap.allow_plain_auth; + let greeting = match (offer_tls, allow_auth) { + (true, true) => &GREETING_WITH_TLS, + (true, false) => &GREETING_WITH_TLS_LOGIN_DISABLED, + (false, true) => &GREETING_WITHOUT_TLS, + (false, false) => &GREETING_WITHOUT_TLS_LOGIN_DISABLED, + }; + + if let Err(err) = session.stream.write_all(greeting).await { + trc::event!( + Network(trc::NetworkEvent::WriteError), + Reason = err.to_string(), + SpanId = session.session_id, + Details = "Failed to write to stream" + ); + return Err(()); + } + let _ = session.stream.flush().await; + + // Split stream into read and write halves + let (stream_rx, stream_tx) = tokio::io::split(session.stream); + + Ok(Session { + receiver: Receiver::with_max_request_size(server.core.imap.max_request_size), + version: ProtocolVersion::Rev1, + state: State::NotAuthenticated { auth_failures: 0 }, + is_tls, + is_condstore: false, + is_qresync: false, + is_utf8: false, + is_objectid: false, + is_uidonly: false, + server, + instance: session.instance, + session_id: session.session_id, + in_flight: session.in_flight, + remote_addr: session.remote_ip, + stream_rx, + stream_tx: Arc::new(tokio::sync::Mutex::new(stream_tx)), + }) + } + + pub async fn into_tls(self) -> Result>, ()> { + // Drop references to write half from state + let state = if let Some(state) = + self.state + .try_replace_stream_tx(Arc::new(tokio::sync::Mutex::new( + tokio::io::split(NullIo::default()).1, + ))) { + state + } else { + trc::event!( + Network(trc::NetworkEvent::SplitError), + SpanId = self.session_id, + Details = "Failed to obtain write half state" + ); + return Err(()); + }; + + // Take ownership of WriteHalf and unsplit it from ReadHalf + let stream = if let Ok(stream_tx) = + Arc::try_unwrap(self.stream_tx).map(|mutex| mutex.into_inner()) + { + self.stream_rx.unsplit(stream_tx) + } else { + trc::event!( + Network(trc::NetworkEvent::SplitError), + SpanId = self.session_id, + Details = "Failed to take ownership of write half" + ); + + return Err(()); + }; + + // Upgrade to TLS + let (stream_rx, stream_tx) = + tokio::io::split(self.instance.tls_accept(stream, self.session_id).await?); + let stream_tx = Arc::new(tokio::sync::Mutex::new(stream_tx)); + let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size); + + Ok(Session { + server: self.server, + instance: self.instance, + receiver, + version: self.version, + state: state.try_replace_stream_tx(stream_tx.clone()).unwrap(), + is_tls: true, + is_condstore: self.is_condstore, + is_qresync: self.is_qresync, + is_utf8: self.is_utf8, + is_objectid: self.is_objectid, + is_uidonly: self.is_uidonly, + session_id: self.session_id, + in_flight: self.in_flight, + remote_addr: self.remote_addr, + stream_rx, + stream_tx, + }) + } +} + +impl Session { + pub fn activate_objectid(&mut self) -> Option<&'static [u8]> { + if self.is_objectid { + None + } else { + self.is_objectid = true; + Some(b"* ENABLED OBJECTID+\r\n") + } + } + + pub async fn write_bytes(&self, bytes: impl AsRef<[u8]>) -> trc::Result<()> { + let bytes = bytes.as_ref(); + + trc::event!( + Imap(trc::ImapEvent::RawOutput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + let mut stream = self.stream_tx.lock().await; + if let Err(err) = stream.write_all(bytes).await { + Err(trc::NetworkEvent::WriteError + .into_err() + .reason(err) + .details("Failed to write to stream")) + } else { + let _ = stream.flush().await; + Ok(()) + } + } + + pub async fn write_error(&self, err: trc::Error) -> bool { + if err.should_write_err() { + let disconnect = err.must_disconnect(); + let bytes = err.serialize(); + trc::error!(err.span_id(self.session_id)); + + if let Err(err) = self.write_bytes(bytes).await { + trc::error!(err.span_id(self.session_id)); + false + } else { + !disconnect + } + } else { + trc::error!(err); + + false + } + } +} + +impl super::SessionData { + pub async fn write_bytes(&self, bytes: impl AsRef<[u8]>) -> trc::Result<()> { + let bytes = bytes.as_ref(); + + trc::event!( + Imap(trc::ImapEvent::RawOutput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + let mut stream = self.stream_tx.lock().await; + if let Err(err) = stream.write_all(bytes.as_ref()).await { + Err(trc::NetworkEvent::WriteError + .into_err() + .reason(err) + .details("Failed to write to stream")) + } else { + let _ = stream.flush().await; + Ok(()) + } + } + + pub async fn write_error(&self, err: trc::Error) -> trc::Result<()> { + if err.should_write_err() { + let bytes = err.serialize(); + trc::error!(err.span_id(self.session_id)); + self.write_bytes(bytes).await + } else { + trc::error!(err.span_id(self.session_id)); + Ok(()) + } + } +} diff --git a/crates/imap/src/lib.rs b/crates/imap/src/lib.rs new file mode 100644 index 0000000..3a08b97 --- /dev/null +++ b/crates/imap/src/lib.rs @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use std::sync::LazyLock; + +use imap_proto::{ResponseCode, StatusResponse, protocol::capability::Capability}; + +pub mod core; +pub mod op; + +static SERVER_GREETING: &str = "Stalwart IMAP4rev2 at your service."; + +pub(crate) static GREETING_WITH_TLS: LazyLock> = + LazyLock::new(|| build_greeting(true, true)); + +pub(crate) static GREETING_WITH_TLS_LOGIN_DISABLED: LazyLock> = + LazyLock::new(|| build_greeting(true, false)); + +pub(crate) static GREETING_WITHOUT_TLS: LazyLock> = + LazyLock::new(|| build_greeting(false, true)); + +pub(crate) static GREETING_WITHOUT_TLS_LOGIN_DISABLED: LazyLock> = + LazyLock::new(|| build_greeting(false, false)); + +fn build_greeting(offer_tls: bool, allow_auth: bool) -> Vec { + StatusResponse::ok(SERVER_GREETING) + .with_code(ResponseCode::Capability { + capabilities: Capability::all_capabilities(false, offer_tls, allow_auth, 0, 0), + }) + .into_bytes() +} + +pub struct ImapError; diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs new file mode 100644 index 0000000..8dec273 --- /dev/null +++ b/crates/imap/src/op/acl.rs @@ -0,0 +1,499 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{MailboxId, Session, SessionData, State}, + op::ImapContext, + spawn_op, +}; +use common::{ + auth::AccessToken, ipc::CacheInvalidation, network::SessionStream, sharing::EffectiveAcl, + storage::index::ObjectIndexBuilder, +}; +use compact_str::ToCompactString; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::acl::{ + Arguments, GetAclResponse, ListRightsResponse, ModRightsOp, MyRightsResponse, Rights, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::Collection, +}; +use utils::map::bitmap::Bitmap; + +impl Session { + pub async fn handle_get_acl(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapAclGet)?; + + let op_start = Instant::now(); + let arguments = request.parse_acl(self.is_utf8)?; + let is_utf8 = self.version.is_rev2() || self.is_utf8; + let data = self.state.session_data(); + + spawn_op!(data, { + let (mailbox_id, mailbox_, _) = data + .get_acl_mailbox(&arguments, true) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let mut permissions = Vec::new(); + let mailbox = mailbox_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + + // Add the current user if they are the owner or a group member + if data.access_token.is_member(mailbox_id.account_id) { + let account_name = data + .server + .account(mailbox_id.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .name() + .to_string(); + + permissions.push(( + account_name, + vec![ + Rights::Read, + Rights::Lookup, + Rights::Insert, + Rights::DeleteMessages, + Rights::Expunge, + Rights::Seen, + Rights::Write, + Rights::CreateMailbox, + Rights::DeleteMailbox, + Rights::Post, + Rights::Administer, + ], + )); + } + + for item in mailbox.inner.acls.iter() { + if item.account_id == mailbox_id.account_id { + // Skip the current user, as they are already added above + continue; + } + + let mut rights = Vec::new(); + + for acl in Bitmap::from(&item.grants) { + match acl { + Acl::Read => { + rights.push(Rights::Lookup); + } + Acl::Modify => { + rights.push(Rights::CreateMailbox); + } + Acl::Delete => { + rights.push(Rights::DeleteMailbox); + } + Acl::ReadItems => { + rights.push(Rights::Read); + } + Acl::AddItems => { + rights.push(Rights::Insert); + } + Acl::ModifyItems => { + rights.push(Rights::Write); + rights.push(Rights::Seen); + } + Acl::RemoveItems => { + rights.push(Rights::DeleteMessages); + rights.push(Rights::Expunge); + } + Acl::CreateChild => { + rights.push(Rights::CreateMailbox); + } + Acl::Share => { + rights.push(Rights::Administer); + } + Acl::Submit => { + rights.push(Rights::Post); + } + _ => (), + } + } + + let account_name = data + .server + .account(item.account_id.into()) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .name() + .to_string(); + + permissions.push((account_name, rights)); + } + + trc::event!( + Imap(trc::ImapEvent::GetAcl), + SpanId = data.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = mailbox_id.account_id, + MailboxId = mailbox_id.mailbox_id, + Total = permissions.len(), + Elapsed = op_start.elapsed() + ); + + data.write_bytes( + StatusResponse::completed(Command::GetAcl) + .with_tag(arguments.tag) + .serialize( + GetAclResponse { + mailbox_name: arguments.mailbox_name.to_string(), + permissions, + } + .into_bytes(is_utf8), + ), + ) + .await + }) + } + + pub async fn handle_my_rights(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapMyRights)?; + + let op_start = Instant::now(); + let arguments = request.parse_acl(self.is_utf8)?; + let data = self.state.session_data(); + let is_utf8 = self.version.is_rev2() || self.is_utf8; + + spawn_op!(data, { + let (mailbox_id, mailbox_, access_token) = data + .get_acl_mailbox(&arguments, false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let mailbox = mailbox_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + let rights = if access_token.is_shared(mailbox_id.account_id) { + let acl = mailbox.inner.acls.effective_acl(&access_token); + let mut rights = Vec::with_capacity(5); + if acl.contains(Acl::ReadItems) { + rights.push(Rights::Read); + rights.push(Rights::Lookup); + } + if acl.contains(Acl::AddItems) { + rights.push(Rights::Insert); + } + if acl.contains(Acl::RemoveItems) { + rights.push(Rights::DeleteMessages); + rights.push(Rights::Expunge); + } + if acl.contains(Acl::ModifyItems) { + rights.push(Rights::Seen); + rights.push(Rights::Write); + } + if acl.contains(Acl::CreateChild) { + rights.push(Rights::CreateMailbox); + } + if acl.contains(Acl::Delete) { + rights.push(Rights::DeleteMailbox); + } + if acl.contains(Acl::Submit) { + rights.push(Rights::Post); + } + rights + } else { + vec![ + Rights::Read, + Rights::Lookup, + Rights::Insert, + Rights::DeleteMessages, + Rights::Expunge, + Rights::Seen, + Rights::Write, + Rights::CreateMailbox, + Rights::DeleteMailbox, + Rights::Post, + Rights::Administer, + ] + }; + + trc::event!( + Imap(trc::ImapEvent::MyRights), + SpanId = data.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = mailbox_id.account_id, + MailboxId = mailbox_id.mailbox_id, + Details = rights + .iter() + .map(|r| trc::Value::String(r.to_compact_string())) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + data.write_bytes( + StatusResponse::completed(Command::MyRights) + .with_tag(arguments.tag) + .serialize( + MyRightsResponse { + mailbox_name: arguments.mailbox_name.to_string(), + rights, + } + .into_bytes(is_utf8), + ), + ) + .await + }) + } + + pub async fn handle_set_acl(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapAclSet)?; + + let op_start = Instant::now(); + let command = request.command; + let arguments = request.parse_acl(self.is_utf8)?; + let data = self.state.session_data(); + + spawn_op!(data, { + // Validate mailbox + let (mailbox_id, current_mailbox, _) = data + .get_acl_mailbox(&arguments, true) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let current_mailbox = current_mailbox + .into_deserialized::() + .imap_ctx(&arguments.tag, trc::location!())?; + + // Obtain principal id + let acl_account_id = data + .server + .account_id_from_email(arguments.identifier.as_ref().unwrap(), false) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details("Account does not exist") + .id(arguments.tag.to_string()) + .caused_by(trc::location!()) + })?; + + // Prepare changes + let mut mailbox = current_mailbox.inner.clone(); + let (op, rights) = arguments + .mod_rights + .map(|mr| { + ( + mr.op, + Bitmap::from_iter(mr.rights.into_iter().map(Acl::from)), + ) + }) + .unwrap_or_else(|| (ModRightsOp::Replace, Bitmap::new())); + + if let Some(item) = mailbox + .acls + .iter_mut() + .find(|item| item.account_id == acl_account_id) + { + match op { + ModRightsOp::Replace => { + if !rights.is_empty() { + item.grants = rights; + } else { + mailbox + .acls + .retain(|item| item.account_id != acl_account_id); + } + } + ModRightsOp::Add => { + item.grants.union(&rights); + } + ModRightsOp::Remove => { + for right in rights { + item.grants.remove(right); + } + if item.grants.is_empty() { + mailbox + .acls + .retain(|item| item.account_id != acl_account_id); + } + } + } + } else if !rights.is_empty() { + match op { + ModRightsOp::Add | ModRightsOp::Replace => { + mailbox.acls.push(AclGrant { + account_id: acl_account_id, + grants: rights, + }); + } + ModRightsOp::Remove => (), + } + } + + if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item { + return Err(trc::ImapEvent::Error + .into_err() + .details("Maximum shares per item exceeded") + .caused_by(trc::location!())); + } + + let grants = mailbox + .acls + .iter() + .map(|r| trc::Value::from(r.account_id)) + .collect::>(); + + // Write changes + let mut batch = BatchBuilder::new(); + batch + .with_account_id(mailbox_id.account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id.mailbox_id) + .custom( + ObjectIndexBuilder::new() + .with_changes(mailbox) + .with_current(current_mailbox), + ) + .imap_ctx(&arguments.tag, trc::location!())?; + + if !batch.is_empty() { + data.server + .commit_batch(batch) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } + + // Invalidate ACLs + data.server + .invalidate_caches(CacheInvalidation::AccessToken(acl_account_id).into()) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::SetAcl), + SpanId = data.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = mailbox_id.account_id, + MailboxId = mailbox_id.mailbox_id, + Details = grants, + Elapsed = op_start.elapsed() + ); + + data.write_bytes( + StatusResponse::completed(command) + .with_tag(arguments.tag) + .into_bytes(), + ) + .await + }) + } + + pub async fn handle_list_rights(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapListRights)?; + + let op_start = Instant::now(); + let arguments = request.parse_acl(self.is_utf8)?; + + trc::event!( + Imap(trc::ImapEvent::ListRights), + SpanId = self.session_id, + MailboxName = arguments.mailbox_name.clone(), + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::completed(Command::ListRights) + .with_tag(arguments.tag) + .serialize( + ListRightsResponse { + mailbox_name: arguments.mailbox_name, + identifier: arguments.identifier.unwrap(), + permissions: vec![ + vec![Rights::Read], + vec![Rights::Lookup], + vec![Rights::Write, Rights::Seen], + vec![Rights::Insert], + vec![Rights::Expunge, Rights::DeleteMessages], + vec![Rights::CreateMailbox], + vec![Rights::DeleteMailbox], + vec![Rights::Post], + vec![Rights::Administer], + ], + } + .into_bytes(self.version.is_rev2() || self.is_utf8), + ), + ) + .await + } + + pub fn assert_has_permission(&self, permission: Permission) -> trc::Result { + match &self.state { + State::Authenticated { data } | State::Selected { data, .. } => data + .access_token + .enforce_permission(permission) + .map(|_| true), + State::NotAuthenticated { .. } => Ok(false), + } + } +} + +impl SessionData { + async fn get_acl_mailbox( + &self, + arguments: &Arguments, + validate: bool, + ) -> trc::Result<(MailboxId, Archive, AccessToken)> { + if let Some(mailbox) = self.get_mailbox_by_name(&arguments.mailbox_name) { + if let Some(values) = self + .server + .store() + .get_value::>(ValueKey::archive( + mailbox.account_id, + Collection::Mailbox, + mailbox.mailbox_id, + )) + .await + .caused_by(trc::location!())? + { + let access_token = self + .refresh_access_token() + .await + .caused_by(trc::location!())?; + if !validate + || access_token.is_member(mailbox.account_id) + || values + .unarchive::() + .caused_by(trc::location!())? + .acls + .effective_acl(&access_token) + .contains(Acl::Share) + { + Ok((mailbox, values, access_token)) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("You do not have enough permissions to perform this operation.") + .code(ResponseCode::NoPerm)) + } + } else { + Err(trc::ImapEvent::Error + .caused_by(trc::location!()) + .details("Mailbox does not exist.")) + } + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.")) + } + } +} diff --git a/crates/imap/src/op/append.rs b/crates/imap/src/op/append.rs new file mode 100644 index 0000000..beadb15 --- /dev/null +++ b/crates/imap/src/op/append.rs @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapContext, ToModSeq}; +use crate::{ + core::{ImapUidToId, MailboxId, SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use common::{auth::BuildAccessToken, ipc::PushNotification, network::SessionStream}; +use email::message::ingest::{EmailIngest, IngestEmail, IngestSource}; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::{append::Arguments, select::HighestModSeq}, + receiver::Request, +}; +use mail_parser::MessageParser; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use types::{ + acl::Acl, + keyword::Keyword, + type_state::{DataType, StateChange}, +}; + +impl Session { + pub async fn handle_append(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapAppend)?; + + let op_start = Instant::now(); + let arguments = request.parse_append(self.is_utf8)?; + let (data, selected_mailbox) = self.state.session_mailbox_state(); + + // RFC 9738 makes APPEND atomic, so an oversized MULTIAPPEND stores nothing + let message_limit = self.server.core.imap.max_messages_per_save; + if arguments.messages.len() > message_limit as usize { + return self + .write_bytes( + StatusResponse::no("Too many messages to append, try a smaller subset.") + .with_tag(arguments.tag) + .with_code(ResponseCode::MessageLimit { + limit: message_limit, + uid: None, + }) + .into_bytes(), + ) + .await; + } + + // Refresh mailboxes + data.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Obtain mailbox + let mailbox = if let Some(mailbox) = data.get_mailbox_by_name(&arguments.mailbox_name) { + mailbox + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::TryCreate) + .id(arguments.tag)); + }; + let is_qresync = self.is_qresync; + + spawn_op!(data, { + let response = data + .append_messages(arguments, selected_mailbox, mailbox, is_qresync, op_start) + .await? + .into_bytes(); + + data.write_bytes(response).await + }) + } +} + +impl SessionData { + async fn append_messages( + &self, + arguments: Arguments, + selected_mailbox: Option>, + mailbox: MailboxId, + is_qresync: bool, + op_start: Instant, + ) -> trc::Result { + // Verify ACLs + let account_id = mailbox.account_id; + let mailbox_id = mailbox.mailbox_id; + if !self + .check_mailbox_acl(account_id, mailbox_id, Acl::AddItems) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details( + "You do not have the required permissions to append messages to this mailbox.", + ) + .code(ResponseCode::NoPerm) + .id(arguments.tag)); + } + + // Obtain access token + let access_token = if mailbox.account_id == self.account_id { + self.refresh_access_token() + .await + .imap_ctx(&arguments.tag, trc::location!())? + } else { + self.server + .access_token(mailbox.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .build() + }; + + // Append messages + let mut response = StatusResponse::completed(Command::Append); + let mut created_ids = Vec::with_capacity(arguments.messages.len()); + let mut last_change_id = None; + for message in arguments.messages { + match self + .server + .email_ingest(IngestEmail { + raw_message: &message.message, + message: MessageParser::new().parse(&message.message), + blob_hash: None, + access_token: &access_token, + mailbox_ids: vec![mailbox_id], + keywords: message.flags.into_iter().map(Keyword::from).collect(), + received_at: message.received_at.map(|d| d as u64), + source: IngestSource::Imap { + train_classifier: true, + }, + session_id: self.session_id, + }) + .await + { + Ok(email) => { + created_ids.push(ImapUidToId { + uid: email.imap_uids[0], + id: email.document_id, + }); + last_change_id = Some(email.change_id); + } + Err(err) => { + return Err( + if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) { + err.details("Disk quota exceeded.") + .code(ResponseCode::OverQuota) + } else if err.matches(trc::EventType::Limit(trc::LimitEvent::TenantQuota)) { + err.details("Organization disk quota exceeded.") + .code(ResponseCode::OverQuota) + } else { + err + } + .id(arguments.tag), + ); + } + } + } + + // Broadcast changes + if let Some(change_id) = last_change_id { + self.server + .broadcast_push_notification(PushNotification::StateChange( + StateChange::new(account_id) + .with_change_id(change_id) + .with_change(DataType::Email) + .with_change(DataType::Mailbox) + .with_change(DataType::Thread), + )) + .await; + } + + trc::event!( + Imap(trc::ImapEvent::Append), + SpanId = self.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = account_id, + MailboxId = mailbox_id, + DocumentId = created_ids + .iter() + .map(|r| trc::Value::from(r.id)) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + if !created_ids.is_empty() { + let uids = created_ids.iter().map(|id| id.uid).collect(); + match selected_mailbox { + Some(selected_mailbox) if selected_mailbox.id == mailbox => { + // Write updated modseq + if is_qresync { + self.write_bytes( + HighestModSeq::new(last_change_id.unwrap_or_default().to_modseq()) + .into_bytes(), + ) + .await?; + } + + selected_mailbox.append_messages(created_ids, last_change_id); + } + _ => {} + }; + let uid_validity = self + .mailbox_state(&mailbox) + .map(|m| m.uid_validity as u32) + .unwrap_or_default(); + + response = response.with_code(ResponseCode::AppendUid { uid_validity, uids }); + } + + Ok(response.with_tag(arguments.tag)) + } +} diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs new file mode 100644 index 0000000..1338dc1 --- /dev/null +++ b/crates/imap/src/op/authenticate.rs @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Session, SessionData, State}; +use common::{ + auth::AuthRequest, + network::{SessionStream, limiter::LimiterResult}, +}; +use directory::Credentials; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::{authenticate::Mechanism, capability::Capability}, + receiver::{self, Request}, +}; +use mail_parser::decoders::base64::base64_decode; +use registry::schema::enums::Permission; +use std::sync::Arc; + +impl Session { + pub async fn handle_authenticate(&mut self, request: Request) -> trc::Result<()> { + let mut args = request.parse_authenticate()?; + + match args.mechanism { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => { + if !args.params.is_empty() { + let challenge = base64_decode(args.params.pop().unwrap().as_bytes()) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Failed to decode challenge.") + .id(args.tag.clone()) + .code(ResponseCode::Parse) + })?; + + let credentials = if args.mechanism == Mechanism::Plain { + Credentials::decode_sasl_challenge_plain(&challenge) + } else { + Credentials::decode_sasl_challenge_oauth(&challenge) + } + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Invalid SASL challenge.") + .id(args.tag.clone()) + })?; + + self.authenticate(credentials, args.tag).await + } else { + self.receiver.request = receiver::Request { + tag: args.tag, + command: Command::Authenticate, + tokens: vec![receiver::Token::Argument(args.mechanism.into_bytes())], + }; + self.receiver.state = receiver::State::Argument { last_ch: b' ' }; + self.write_bytes(b"+ \r\n".to_vec()).await + } + } + _ => Err(trc::AuthEvent::Error + .into_err() + .details("Authentication mechanism not supported.") + .id(args.tag) + .code(ResponseCode::Cannot)), + } + } + + pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> { + // Authenticate + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + let auth_failures = self.state.auth_failures(); + if auth_failures < self.server.core.imap.max_auth_failures { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + }; + } else { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } + } + + err.id(tag.clone()) + }) + .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; + + // Enforce concurrency limits + let in_flight = match access_token.is_imap_request_allowed() { + LimiterResult::Allowed(in_flight) => Some(in_flight), + LimiterResult::Forbidden => { + return Err(trc::LimitEvent::ConcurrentRequest + .into_err() + .id(tag.clone())); + } + LimiterResult::Disabled => None, + }; + + // Create session + self.state = State::Authenticated { + data: Arc::new( + SessionData::new(self, access_token, in_flight) + .await + .map_err(|err| err.id(tag.clone()))?, + ), + }; + self.write_bytes( + StatusResponse::ok("Authentication successful") + .with_code(ResponseCode::Capability { + capabilities: Capability::all_capabilities( + true, + !self.is_tls && self.instance.acceptor.is_tls(), + true, + self.server.core.imap.max_messages_per_command, + self.server.core.imap.max_messages_per_save, + ), + }) + .with_tag(tag) + .into_bytes(), + ) + .await + } + + pub async fn handle_unauthenticate(&mut self, request: Request) -> trc::Result<()> { + self.state = State::NotAuthenticated { auth_failures: 0 }; + self.is_condstore = false; + self.is_qresync = false; + self.is_utf8 = false; + self.is_objectid = false; + self.is_uidonly = false; + + self.write_bytes( + StatusResponse::completed(Command::Unauthenticate) + .with_tag(request.tag) + .into_bytes(), + ) + .await + } +} diff --git a/crates/imap/src/op/capability.rs b/crates/imap/src/op/capability.rs new file mode 100644 index 0000000..bc4a32e --- /dev/null +++ b/crates/imap/src/op/capability.rs @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::core::Session; +use common::network::SessionStream; +use imap_proto::{ + Command, StatusResponse, + protocol::{ + ImapResponse, + capability::{Capability, Response}, + quoted_string, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; + +impl Session { + pub async fn handle_capability(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapCapability)?; + + let op_start = Instant::now(); + trc::event!( + Imap(trc::ImapEvent::Capabilities), + SpanId = self.session_id, + Tls = self.is_tls, + Strict = !self.server.core.imap.allow_plain_auth, + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::completed(Command::Capability) + .with_tag(request.tag) + .serialize( + Response { + capabilities: Capability::all_capabilities( + self.state.is_authenticated(), + !self.is_tls && self.instance.acceptor.is_tls(), + self.is_tls || self.server.core.imap.allow_plain_auth, + self.server.core.imap.max_messages_per_command, + self.server.core.imap.max_messages_per_save, + ), + } + .serialize(), + ), + ) + .await + } + + pub async fn handle_id(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapId)?; + + let op_start = Instant::now(); + trc::event!( + Imap(trc::ImapEvent::Id), + SpanId = self.session_id, + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::completed(Command::Id) + .with_tag(request.tag) + .serialize( + concat!( + "* ID (\"name\" \"Stalwart\" \"version\" \"1.0.0\" \"vendor\" \"Stalwart Labs LLC\" ", + "\"support-url\" \"https://stalw.art\")\r\n" + ) + .as_bytes() + .to_vec(), + ), + ) + .await + } + + pub async fn handle_jmap_access(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapCapability)?; + + let op_start = Instant::now(); + trc::event!( + Imap(trc::ImapEvent::Capabilities), + SpanId = self.session_id, + Elapsed = op_start.elapsed() + ); + + let mut response = b"* JMAPACCESS ".to_vec(); + quoted_string( + &mut response, + &format!( + "{}/.well-known/jmap", + self.server.core.network.http.url_https + ), + ); + response.extend_from_slice(b"\r\n"); + + self.write_bytes( + StatusResponse::completed(Command::GetJmapAccess) + .with_tag(request.tag) + .serialize(response), + ) + .await + } +} diff --git a/crates/imap/src/op/close.rs b/crates/imap/src/op/close.rs new file mode 100644 index 0000000..b5a938f --- /dev/null +++ b/crates/imap/src/op/close.rs @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::core::{Session, State}; +use common::network::SessionStream; +use imap_proto::{Command, StatusResponse, receiver::Request}; +use trc::AddContext; + +impl Session { + pub async fn handle_close(&mut self, request: Request) -> trc::Result<()> { + let op_start = Instant::now(); + let (data, mailbox) = self.state.select_data(); + + if mailbox.is_select { + data.expunge(mailbox.clone(), None, u32::MAX, op_start) + .await + .caused_by(trc::location!())?; + } + + trc::event!( + Imap(trc::ImapEvent::Close), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Elapsed = op_start.elapsed() + ); + + self.state = State::Authenticated { data }; + self.write_bytes( + StatusResponse::completed(Command::Close) + .with_tag(request.tag) + .into_bytes(), + ) + .await + } +} diff --git a/crates/imap/src/op/copy_move.rs b/crates/imap/src/op/copy_move.rs new file mode 100644 index 0000000..97de5c2 --- /dev/null +++ b/crates/imap/src/op/copy_move.rs @@ -0,0 +1,701 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ImapContext; +use crate::{ + core::{MailboxId, SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + mailbox::{JUNK_ID, TRASH_ID, UidMailbox}, + message::{ + copy::{CopyMessageError, EmailCopy}, + ingest::EmailIngest, + metadata::MessageData, + }, +}; +use imap_proto::{ + Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, VanishedCollection}, + type_state::{DataType, StateChange}, +}; + +impl Session { + pub async fn handle_copy_move( + &mut self, + request: Request, + is_move: bool, + is_uid: bool, + ) -> trc::Result<()> { + // Validate access + self.assert_has_permission(if is_move { + Permission::ImapMove + } else { + Permission::ImapCopy + })?; + + let op_start = Instant::now(); + let arguments = request.parse_copy_move(self.is_utf8)?; + let (data, src_mailbox) = self.state.mailbox_state(); + let use_vanished = self.is_qresync || self.is_uidonly; + // RFC 9738 places COPY under SAVELIMIT but leaves MOVE under MESSAGELIMIT + let message_limit = if is_move { + self.server.core.imap.max_messages_per_command + } else { + self.server.core.imap.max_messages_per_save + }; + + spawn_op!(data, { + // Refresh mailboxes + data.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Make sure the mailbox exists. + let dest_mailbox = + if let Some(mailbox) = data.get_mailbox_by_name(&arguments.mailbox_name) { + mailbox + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Destination mailbox does not exist.") + .code(ResponseCode::TryCreate) + .id(arguments.tag)); + }; + + // Check that the destination mailbox is not the same as the source mailbox. + if src_mailbox.id.account_id == dest_mailbox.account_id + && src_mailbox.id.mailbox_id == dest_mailbox.mailbox_id + { + return Err(trc::ImapEvent::Error + .into_err() + .details("Source and destination mailboxes are the same.") + .code(ResponseCode::Cannot) + .id(arguments.tag)); + } + + data.copy_move( + arguments, + src_mailbox, + dest_mailbox, + is_move, + is_uid, + use_vanished, + message_limit, + op_start, + ) + .await + }) + } +} + +impl SessionData { + #[allow(clippy::too_many_arguments)] + pub async fn copy_move( + &self, + arguments: Arguments, + src_mailbox: Arc, + dest_mailbox: MailboxId, + is_move: bool, + is_uid: bool, + use_vanished: bool, + message_limit: u32, + op_start: Instant, + ) -> trc::Result<()> { + self.synchronize_messages(&src_mailbox) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Convert IMAP ids to JMAP ids. + let ids = src_mailbox + .sequence_to_ids(&arguments.sequence_set, is_uid) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + if ids.is_empty() { + trc::event!( + Imap(if is_move { + trc::ImapEvent::Move + } else { + trc::ImapEvent::Copy + }), + SpanId = self.session_id, + Source = src_mailbox.id.account_id, + Details = trc::Value::None, + Uid = trc::Value::None, + AccountId = dest_mailbox.account_id, + MailboxId = dest_mailbox.mailbox_id, + Elapsed = op_start.elapsed() + ); + + return self + .write_bytes( + StatusResponse::ok(if is_move { + "No messages were moved." + } else { + "No messages were copied." + }) + .with_tag(arguments.tag) + .into_bytes(), + ) + .await; + } + + // Verify that the user can delete messages from the source mailbox. + if is_move + && !self + .check_mailbox_acl( + src_mailbox.id.account_id, + src_mailbox.id.mailbox_id, + Acl::RemoveItems, + ) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "You do not have the required permissions to ", + "remove messages from the source mailbox." + )) + .code(ResponseCode::NoPerm) + .id(arguments.tag)); + } + + // Verify that the user can append messages to the destination mailbox. + let dest_mailbox_id = dest_mailbox.mailbox_id; + if !self + .check_mailbox_acl(dest_mailbox.account_id, dest_mailbox_id, Acl::AddItems) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "You do not have the required permissions to ", + "add messages to the destination mailbox." + )) + .code(ResponseCode::NoPerm) + .id(arguments.tag)); + } + + // RFC 9738 requires the highest UIDs to be processed first when truncating. + // COPY is atomic, so it is refused outright rather than partially applied. + let mut ids = ids; + let message_limit = message_limit as usize; + let mut limited_uid = None; + if ids.len() > message_limit { + let mut uids = ids.values().map(|imap_id| imap_id.uid).collect::>(); + let cutoff = uids.len() - message_limit; + let lowest_uid = *uids.select_nth_unstable(cutoff).1; + + if !is_move { + return self + .write_bytes( + StatusResponse::no("Too many messages to copy, try a smaller subset.") + .with_tag(arguments.tag) + .with_code(ResponseCode::MessageLimit { + limit: message_limit as u32, + uid: lowest_uid.into(), + }) + .into_bytes(), + ) + .await; + } + + ids.retain(|_, imap_id| imap_id.uid >= lowest_uid); + limited_uid = Some(lowest_uid); + } + + let response = StatusResponse::completed(if is_move { + Command::Move(is_uid) + } else { + Command::Copy(is_uid) + }); + let mut error: Option<(ResponseCode, &'static str)> = None; + let mut did_move = false; + let mut copied_ids = Vec::with_capacity(ids.len()); + + if src_mailbox.id.account_id == dest_mailbox.account_id { + // Mailboxes are in the same account + let account_id = src_mailbox.id.account_id; + let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_id); + let mut batch = BatchBuilder::new(); + + for (id, imap_id) in ids { + // Obtain mailbox tags + let data_ = if let Some(result) = self + .get_message_data(account_id, id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + result + } else { + continue; + }; + + // Deserialize + let data = data_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + + // Make sure the message still belongs to this mailbox + if !data + .inner + .mailboxes + .iter() + .any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id) + { + continue; + } + + // If the message is already in the destination mailbox, skip it. + if let Some(mailbox) = data + .inner + .mailboxes + .iter() + .find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id) + { + copied_ids.push((imap_id.uid, mailbox.uid.to_native())); + + if is_move { + let mut new_data = data.inner.to_builder(); + new_data.remove_mailbox(src_mailbox.id.mailbox_id); + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .imap_ctx(&arguments.tag, trc::location!())? + .log_vanished_item( + VanishedCollection::Email, + (src_mailbox.id.mailbox_id, imap_id.uid), + ) + .commit_point(); + did_move = true; + } + + continue; + } + + // Prepare changes + let mut new_data = data.inner.to_builder(); + + // Add destination folder + new_data.add_mailbox(dest_mailbox_id); + if is_move { + new_data.remove_mailbox(src_mailbox.id.mailbox_id); + } + + // Assign IMAP UIDs + let ids = self + .server + .assign_email_ids( + account_id, + new_data + .mailboxes + .iter() + .filter(|m| m.uid == 0) + .map(|m| m.mailbox_id), + false, + ) + .await + .caused_by(trc::location!())?; + + for (uid_mailbox, uid) in new_data + .mailboxes + .iter_mut() + .filter(|m| m.uid == 0) + .zip(ids) + { + copied_ids.push((imap_id.uid, uid)); + uid_mailbox.uid = uid; + } + + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .imap_ctx(&arguments.tag, trc::location!())?; + if is_move { + batch.log_vanished_item( + VanishedCollection::Email, + (src_mailbox.id.mailbox_id, imap_id.uid), + ); + } + + // Add message to training queue + if dest_mailbox_id.mailbox_id == JUNK_ID { + self.server + .add_account_spam_sample(&mut batch, account_id, id, true, self.session_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } else if src_mailbox.id.mailbox_id == JUNK_ID + && dest_mailbox_id.mailbox_id != TRASH_ID + { + self.server + .add_account_spam_sample(&mut batch, account_id, id, false, self.session_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } + + batch.commit_point(); + + // Update changelog + if is_move { + did_move = true; + } + } + + // Write changes + self.server + .commit_batch(batch) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } else { + // Obtain quota for target account + let src_account_id = src_mailbox.id.account_id; + let mut dest_change_id = None; + let dest_account_id = dest_mailbox.account_id; + let mut destroy_ids = RoaringBitmap::new(); + let cache = self + .server + .get_cached_messages(src_account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let mut dest_cache = None; + for (id, imap_id) in ids { + match self + .server + .copy_message( + src_account_id, + id, + dest_account_id, + vec![dest_mailbox_id], + cache + .email_by_id(&id) + .map(|e| cache.expand_keywords(e).collect()) + .unwrap_or_default(), + None, + self.session_id, + ) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + Ok(email) => { + dest_change_id = email.change_id.into(); + if let Some(assigned_uid) = email.imap_uids.first() { + debug_assert!(*assigned_uid > 0); + copied_ids.push((imap_id.uid, *assigned_uid)); + } + } + Err(CopyMessageError::AlreadyExists(existing_id)) => { + if dest_cache.is_none() { + dest_cache = self + .server + .get_cached_messages(dest_account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .into(); + } + + if let Some(uid) = dest_cache + .as_ref() + .and_then(|cache| cache.email_by_id(&existing_id)) + .and_then(|message| { + message + .mailboxes + .iter() + .find(|mailbox| mailbox.mailbox_id == dest_mailbox_id) + }) + .map(|mailbox| mailbox.uid) + { + copied_ids.push((imap_id.uid, uid)); + } else { + let data_ = if let Some(data_) = self + .get_message_data(dest_account_id, existing_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + data_ + } else { + continue; + }; + let data = data_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + + if let Some(uid) = data.inner.message_uid(dest_mailbox_id) { + copied_ids.push((imap_id.uid, uid)); + } else { + let mut new_data = data.inner.to_builder(); + new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id)); + + let uids = self + .server + .assign_email_ids( + dest_account_id, + new_data + .mailboxes + .iter() + .filter(|m| m.uid == 0) + .map(|m| m.mailbox_id), + false, + ) + .await + .caused_by(trc::location!())?; + + let mut assigned_uid = 0; + for (uid_mailbox, uid) in new_data + .mailboxes + .iter_mut() + .filter(|m| m.uid == 0) + .zip(uids) + { + uid_mailbox.uid = uid; + assigned_uid = uid; + } + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(dest_account_id) + .with_collection(Collection::Email) + .with_document(existing_id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .imap_ctx(&arguments.tag, trc::location!())?; + + dest_change_id = self + .server + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(dest_account_id)) + .imap_ctx(&arguments.tag, trc::location!())? + .into(); + + copied_ids.push((imap_id.uid, assigned_uid)); + } + } + } + Err(CopyMessageError::OverQuota) => { + error = Some((ResponseCode::OverQuota, "Mailbox quota exceeded")); + continue; + } + Err(CopyMessageError::NotFound) => { + continue; + } + }; + + if is_move { + destroy_ids.insert(id); + } + } + + // Untag or delete emails + if !destroy_ids.is_empty() { + let mut batch = BatchBuilder::new(); + self.email_untag_or_delete( + src_account_id, + src_mailbox.id.mailbox_id, + &destroy_ids, + &mut batch, + ) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + self.server + .commit_batch(batch) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + did_move = true; + } + + // Broadcast changes on destination account + if let Some(change_id) = dest_change_id { + self.server + .broadcast_push_notification(PushNotification::StateChange( + StateChange::new(dest_account_id) + .with_change_id(change_id) + .with_change(DataType::Email) + .with_change(DataType::Thread) + .with_change(DataType::Mailbox), + )) + .await; + } + } + + // Map copied JMAP Ids to IMAP UIDs in the destination folder. + if copied_ids.is_empty() { + return if let Some((code, message)) = error { + Err(trc::ImapEvent::Error + .into_err() + .details(message) + .ctx(trc::Key::Code, code) + .id(arguments.tag)) + } else { + trc::event!( + Imap(if is_move { + trc::ImapEvent::Move + } else { + trc::ImapEvent::Copy + }), + SpanId = self.session_id, + Source = src_mailbox.id.account_id, + Details = trc::Value::None, + Uid = trc::Value::None, + AccountId = dest_mailbox.account_id, + MailboxId = dest_mailbox.mailbox_id, + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::ok(if is_move { + "No messages were moved." + } else { + "No messages were copied." + }) + .with_tag(arguments.tag) + .into_bytes(), + ) + .await + }; + } + + // Prepare response + let uid_validity = self + .mailbox_state(&dest_mailbox) + .map(|m| m.uid_validity as u32) + .unwrap_or_default(); + + let mut src_uids = Vec::with_capacity(copied_ids.len()); + let mut dest_uids = Vec::with_capacity(copied_ids.len()); + for (src_uid, dest_uid) in copied_ids { + src_uids.push(src_uid); + dest_uids.push(dest_uid); + } + src_uids.sort_unstable(); + dest_uids.sort_unstable(); + + trc::event!( + Imap(if is_move { + trc::ImapEvent::Move + } else { + trc::ImapEvent::Copy + }), + SpanId = self.session_id, + Source = src_mailbox.id.account_id, + Details = src_uids + .iter() + .map(|r| trc::Value::from(*r)) + .collect::>(), + AccountId = dest_mailbox.account_id, + MailboxId = dest_mailbox.mailbox_id, + Uid = dest_uids + .iter() + .map(|r| trc::Value::from(*r)) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + let response = if is_move { + self.write_bytes( + StatusResponse::ok("Copied UIDs") + .with_code(ResponseCode::CopyUid { + uid_validity, + src_uids, + dest_uids, + }) + .into_bytes(), + ) + .await?; + + if did_move { + // Resynchronize source mailbox on a successful move + self.write_mailbox_changes(&src_mailbox, use_vanished) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } + + let response = response.with_tag(arguments.tag); + match limited_uid { + Some(uid) => response.with_code(ResponseCode::MessageLimit { + limit: message_limit as u32, + uid: uid.into(), + }), + None => response, + } + .into_bytes() + } else { + response + .with_tag(arguments.tag) + .with_code(ResponseCode::CopyUid { + uid_validity, + src_uids, + dest_uids, + }) + .into_bytes() + }; + + self.write_bytes(response).await + } + + pub async fn get_message_data( + &self, + account_id: u32, + id: u32, + ) -> trc::Result>> { + if let Some(data) = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + id, + )) + .await? + { + Ok(Some(data)) + } else { + trc::event!( + Store(trc::StoreEvent::NotFound), + AccountId = account_id, + Collection = Collection::Email, + MessageId = id, + SpanId = self.session_id, + Details = "Message not found" + ); + + Ok(None) + } + } +} diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs new file mode 100644 index 0000000..08c5275 --- /dev/null +++ b/crates/imap/src/op/create.rs @@ -0,0 +1,383 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionData}, + op::ImapContext, + spawn_op, +}; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::{ObjectId, create::Arguments, list::Attribute}, + receiver::Request, +}; +use registry::schema::enums::{Permission, StorageQuota}; +use std::time::Instant; +use store::write::BatchBuilder; +use trc::AddContext; +use types::{acl::Acl, collection::Collection, id::Id, special_use::SpecialUse}; + +impl Session { + pub async fn handle_create(&mut self, requests: Vec>) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapCreate)?; + + let data = self.state.session_data(); + let is_utf8 = self.is_utf8; + let is_objectid = self.is_objectid; + + spawn_op!(data, { + for request in requests { + match request.parse_create(is_utf8) { + Ok(argument) => match data.create_folder(argument, is_objectid).await { + Ok(response) => { + data.write_bytes(response.into_bytes()).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(err) => data.write_error(err).await?, + } + } + + Ok(()) + }) + } +} + +impl SessionData { + pub async fn create_folder( + &self, + arguments: Arguments, + is_objectid: bool, + ) -> trc::Result { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate mailbox name + let params = self + .validate_mailbox_create(&arguments.mailbox_name, arguments.mailbox_role) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + debug_assert!(!params.path.is_empty()); + + // Validate quota + let account = self + .server + .account(params.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let mailbox_count = self + .server + .get_cached_messages(params.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .mailboxes + .items + .len(); + if mailbox_count + params.path.len() + > self + .server + .object_quota(account.object_quotas(), StorageQuota::MaxMailboxes) + as usize + { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "There are too many mailboxes, ", + "please delete some before adding a new one." + )) + .code(ResponseCode::OverQuota) + .id(arguments.tag.clone())); + } + + // Build batch + let mut parent_id = params.parent_mailbox_id.map(|id| id + 1).unwrap_or(0); + let mut create_ids = Vec::with_capacity(params.path.len()); + let mut next_document_id = self + .server + .store() + .assign_document_ids( + params.account_id, + Collection::Mailbox, + params.path.len() as u64, + ) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + for (pos, &path_item) in params.path.iter().enumerate() { + let mut mailbox = email::mailbox::Mailbox::new(path_item).with_parent_id(parent_id); + + if pos == params.path.len() - 1 + && let Some(mailbox_role) = arguments.mailbox_role.map(attr_to_role) + { + mailbox.role = mailbox_role; + } + let mailbox_id = next_document_id; + next_document_id -= 1; + batch + .with_account_id(params.account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(mailbox)) + .imap_ctx(&arguments.tag, trc::location!())? + .commit_point(); + parent_id = mailbox_id + 1; + create_ids.push(mailbox_id); + } + + self.server + .commit_batch(batch) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::CreateMailbox), + SpanId = self.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = params.account_id, + MailboxId = create_ids + .iter() + .map(|&id| trc::Value::from(id)) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + // Build response + let response = StatusResponse::ok("Mailbox created.").with_tag(arguments.tag); + Ok(if is_objectid { + response.with_code(ResponseCode::ObjectId(ObjectId { + mailbox_id: Some(Id::from(parent_id - 1)), + account_id: Some(Id::from(params.account_id)), + ..Default::default() + })) + } else { + response + }) + } + + pub async fn validate_mailbox_create<'x>( + &self, + mailbox_name: &'x str, + mailbox_role: Option, + ) -> trc::Result> { + // Remove leading and trailing separators + let mut name = mailbox_name.trim(); + if let Some(suffix) = name.strip_prefix('/') { + name = suffix.trim(); + }; + if let Some(prefix) = name.strip_suffix('/') { + name = prefix.trim(); + } + if name.is_empty() { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Invalid folder name '{mailbox_name}'.",))); + } + + // Build path + let mut path = Vec::new(); + if name.contains('/') { + // Locate parent mailbox + for path_item in name.split('/') { + let path_item = path_item.trim(); + if path_item.is_empty() { + return Err(trc::ImapEvent::Error + .into_err() + .details("Invalid empty path item.")); + } else if path_item.len() > self.server.core.email.mailbox_name_max_len { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox name is too long.")); + } + path.push(path_item); + } + + if path.len() > self.server.core.email.mailbox_max_depth { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox path is too deep.")); + } + } else { + path.push(name); + } + + // Validate special folders + let mut parent_mailbox_id = None; + let mut parent_mailbox_name = None; + let (account_id, path) = { + let mailboxes = self.mailboxes.lock(); + let (account, full_path, prefix) = + if path.first() == Some(&self.server.core.email.shared_folder.as_str()) { + // Shared Folders// + if path.len() < 3 { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailboxes under root shared folders are not allowed.") + .code(ResponseCode::Cannot)); + } + + // Build path + let root = &mut path[2]; + if root.eq_ignore_ascii_case("INBOX") { + *root = "INBOX"; + } + let full_path = path.join("/"); + let prefix = Some(format!("{}/{}", path[0], path[1])); + + // Locate account + if let Some(account) = mailboxes + .iter() + .skip(1) + .find(|account| account.prefix == prefix) + { + (account, full_path, prefix) + } else { + #[allow(clippy::unnecessary_literal_unwrap)] + return Err(trc::ImapEvent::Error.into_err().details(format!( + "Shared account '{}' not found.", + prefix.unwrap_or_default() + ))); + } + } else if let Some(account) = mailboxes.first() { + let root = &mut path[0]; + if root.eq_ignore_ascii_case("INBOX") { + *root = "INBOX"; + } + + (account, path.join("/"), None) + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Internal server error.") + .caused_by(trc::location!()) + .code(ResponseCode::ContactAdmin)); + }; + + // Locate parent mailbox + if account.mailbox_names.contains_key(&full_path) { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Mailbox '{}' already exists.", full_path)) + .code(ResponseCode::AlreadyExists)); + } + + ( + account.account_id, + if path.len() > 1 { + let mut create_path = Vec::with_capacity(path.len()); + while !path.is_empty() { + let mailbox_name: String = path.join("/"); + if let Some(&mailbox_id) = account.mailbox_names.get(&mailbox_name) { + parent_mailbox_id = mailbox_id.into(); + parent_mailbox_name = mailbox_name.into(); + break; + } else if prefix + .as_ref() + .is_some_and(|prefix| prefix == &mailbox_name) + { + break; + } else { + create_path.push(path.pop().unwrap()); + } + } + create_path.reverse(); + create_path + } else { + path + }, + ) + }; + + // Validate ACLs + if let Some(parent_mailbox_id) = parent_mailbox_id { + if !self + .check_mailbox_acl(account_id, parent_mailbox_id, Acl::CreateChild) + .await? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You are not allowed to create sub mailboxes under this mailbox.") + .code(ResponseCode::NoPerm)); + } + } else if self.account_id != account_id + && !self + .refresh_access_token() + .await + .caused_by(trc::location!())? + .is_member(account_id) + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You are not allowed to create root folders under shared folders.") + .code(ResponseCode::Cannot)); + } + + Ok(CreateParams { + account_id, + path, + parent_mailbox_id, + parent_mailbox_name, + special_use: if let Some(mailbox_role) = mailbox_role { + // Make sure role is unique + let special_use = attr_to_role(mailbox_role); + if self + .server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())? + .mailbox_by_role(&special_use) + .is_some() + { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!( + "A mailbox with role '{}' already exists.", + special_use.as_str().unwrap_or_default() + )) + .code(ResponseCode::UseAttr)); + } + Some(mailbox_role) + } else { + None + }, + is_rename: false, + }) + } +} + +#[derive(Debug)] +pub struct CreateParams<'x> { + pub account_id: u32, + pub path: Vec<&'x str>, + pub parent_mailbox_id: Option, + pub parent_mailbox_name: Option, + pub special_use: Option, + pub is_rename: bool, +} + +#[inline] +fn attr_to_role(attr: Attribute) -> SpecialUse { + match attr { + Attribute::Archive => SpecialUse::Archive, + Attribute::Drafts => SpecialUse::Drafts, + Attribute::Junk => SpecialUse::Junk, + Attribute::Sent => SpecialUse::Sent, + Attribute::Trash => SpecialUse::Trash, + Attribute::Important => SpecialUse::Important, + Attribute::Memos => SpecialUse::Memos, + Attribute::Scheduled => SpecialUse::Scheduled, + Attribute::Snoozed => SpecialUse::Snoozed, + _ => SpecialUse::None, + } +} diff --git a/crates/imap/src/op/delete.rs b/crates/imap/src/op/delete.rs new file mode 100644 index 0000000..60eb356 --- /dev/null +++ b/crates/imap/src/op/delete.rs @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ImapContext; +use crate::{ + core::{Session, SessionData}, + spawn_op, +}; +use common::network::SessionStream; +use email::mailbox::destroy::{MailboxDestroy, MailboxDestroyError}; +use imap_proto::{ + Command, ResponseCode, StatusResponse, protocol::delete::Arguments, receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; + +impl Session { + pub async fn handle_delete(&mut self, requests: Vec>) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapDelete)?; + + let data = self.state.session_data(); + let is_utf8 = self.is_utf8; + + spawn_op!(data, { + for request in requests { + match request.parse_delete(is_utf8) { + Ok(argument) => match data.delete_folder(argument).await { + Ok(response) => { + data.write_bytes(response.into_bytes()).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(response) => data.write_error(response).await?, + } + } + + Ok(()) + }) + } +} + +impl SessionData { + pub async fn delete_folder(&self, arguments: Arguments) -> trc::Result { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate mailbox + let (account_id, mailbox_id) = + if let Some(mailbox) = self.get_mailbox_by_name(&arguments.mailbox_name) { + (mailbox.account_id, mailbox.mailbox_id) + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::TryCreate) + .id(arguments.tag)); + }; + + // Delete message + let access_token = self + .refresh_access_token() + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + if let Err(err) = self + .server + .mailbox_destroy(account_id, mailbox_id, &access_token, true) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + let (code, message) = match err { + MailboxDestroyError::CannotDestroy => { + (ResponseCode::NoPerm, "You cannot delete system mailboxes") + } + MailboxDestroyError::Forbidden => ( + ResponseCode::NoPerm, + "You do not have enough permissions to delete this mailbox", + ), + MailboxDestroyError::HasChildren => { + (ResponseCode::HasChildren, "Mailbox has children") + } + MailboxDestroyError::HasEmails => (ResponseCode::HasChildren, "Mailbox has emails"), + MailboxDestroyError::NotFound => (ResponseCode::NonExistent, "Mailbox not found"), + MailboxDestroyError::AssertionFailed => ( + ResponseCode::Cannot, + "Another process is accessing this mailbox", + ), + }; + + return Err(trc::ImapEvent::Error + .into_err() + .details(message) + .code(code) + .id(arguments.tag)); + } + + // Update mailbox cache + for account in self.mailboxes.lock().iter_mut() { + if account.account_id == account_id { + account.mailbox_names.remove(&arguments.mailbox_name); + account.mailbox_state.remove(&mailbox_id); + break; + } + } + + trc::event!( + Imap(trc::ImapEvent::DeleteMailbox), + SpanId = self.session_id, + MailboxName = arguments.mailbox_name, + AccountId = account_id, + MailboxId = mailbox_id, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("Mailbox deleted.").with_tag(arguments.tag)) + } +} diff --git a/crates/imap/src/op/enable.rs b/crates/imap/src/op/enable.rs new file mode 100644 index 0000000..aa06c51 --- /dev/null +++ b/crates/imap/src/op/enable.rs @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::network::SessionStream; +use imap_proto::{ + Command, StatusResponse, + protocol::{ImapResponse, ProtocolVersion, capability::Capability, enable}, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; + +impl Session { + pub async fn handle_enable(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapEnable)?; + + let op_start = Instant::now(); + + let arguments = request.parse_enable()?; + let mut response = enable::Response { + enabled: Vec::with_capacity(arguments.capabilities.len()), + }; + + for capability in arguments.capabilities { + match capability { + Capability::IMAP4rev2 => { + self.version = ProtocolVersion::Rev2; + self.is_utf8 = true; + } + Capability::IMAP4rev1 => { + self.version = ProtocolVersion::Rev1; + } + Capability::CondStore => { + self.is_condstore = true; + } + Capability::QResync => { + self.is_qresync = true; + self.is_condstore = true; + } + Capability::Utf8Accept => { + self.is_utf8 = true; + } + Capability::ObjectIdPlus => { + self.is_objectid = true; + } + Capability::UidOnly => { + self.is_uidonly = true; + } + _ => { + continue; + } + } + response.enabled.push(capability); + } + + trc::event!( + Imap(trc::ImapEvent::Enable), + SpanId = self.session_id, + Details = response + .enabled + .iter() + .map(|c| trc::Value::from(format!("{c:?}"))) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::ok("ENABLE successful.") + .with_tag(arguments.tag) + .serialize(response.serialize()), + ) + .await + } +} diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs new file mode 100644 index 0000000..80133b9 --- /dev/null +++ b/crates/imap/src/op/expunge.rs @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapContext, ToModSeq}; +use crate::core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData}; +use ahash::AHashMap; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::{delete::EmailDeletion, metadata::MessageData}, +}; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + parser::parse_sequence_set, + receiver::{Request, Token}, +}; +use registry::schema::{ + enums::{IndexDocumentType, Permission}, + structs::{Task, TaskIndexDocument, TaskStatus}, +}; +use std::{sync::Arc, time::Instant}; +use store::{roaring::RoaringBitmap, write::BatchBuilder}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, VanishedCollection}, + keyword::Keyword, +}; + +impl Session { + pub async fn handle_expunge( + &mut self, + request: Request, + is_uid: bool, + ) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapExpunge)?; + + let op_start = Instant::now(); + let (data, mailbox) = self.state.select_data(); + + // Validate ACL + if !data + .check_mailbox_acl( + mailbox.id.account_id, + mailbox.id.mailbox_id, + Acl::RemoveItems, + ) + .await + .imap_ctx(&request.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "You do not have the required permissions ", + "to remove messages from this mailbox." + )) + .code(ResponseCode::NoPerm) + .id(request.tag)); + } + + // Parse sequence to operate on + let sequence = match request.tokens.into_iter().next() { + Some(Token::Argument(value)) if is_uid => { + let sequence = parse_sequence_set(&value).map_err(|err| { + trc::ImapEvent::Error + .into_err() + .details(err) + .ctx(trc::Key::Type, ResponseType::Bad) + .id(request.tag.clone()) + })?; + Some( + mailbox + .sequence_to_ids(&sequence, true) + .await + .map_err(|err| err.id(request.tag.clone()))?, + ) + } + + _ => None, + }; + + // RFC 9738 limits UID EXPUNGE but never a plain EXPUNGE + let message_limit = if is_uid { + self.server.core.imap.max_messages_per_command + } else { + u32::MAX + }; + + // Expunge + let limited_uid = data + .expunge(mailbox.clone(), sequence, message_limit, op_start) + .await + .imap_ctx(&request.tag, trc::location!())?; + + // Clear saved searches + *mailbox.saved_search.lock() = SavedSearch::None; + + // Synchronize messages + let modseq = data + .write_mailbox_changes(&mailbox, self.is_qresync || self.is_uidonly) + .await + .imap_ctx(&request.tag, trc::location!())?; + let mut response = + StatusResponse::completed(Command::Expunge(is_uid)).with_tag(request.tag); + + let mut untagged = Vec::new(); + if let Some(uid) = limited_uid { + let code = ResponseCode::MessageLimit { + limit: message_limit, + uid: uid.into(), + }; + if self.is_condstore { + untagged = StatusResponse::ok("Some messages were not expunged.") + .with_code(code) + .into_bytes(); + } else { + response = response.with_code(code); + } + } + if self.is_condstore { + response = response.with_code(ResponseCode::HighestModseq { + modseq: modseq.to_modseq(), + }); + } + + self.write_bytes(response.serialize(untagged)).await + } +} + +impl SessionData { + pub async fn expunge( + &self, + mailbox: Arc, + sequence: Option>, + message_limit: u32, + op_start: Instant, + ) -> trc::Result> { + // Obtain message ids + let account_id = mailbox.id.account_id; + let mut deleted_ids = RoaringBitmap::from_iter( + self.server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())? + .in_mailbox_with_keyword(mailbox.id.mailbox_id, &Keyword::Deleted) + .map(|m| m.document_id), + ); + + // Filter by sequence + if let Some(sequence) = &sequence { + deleted_ids &= RoaringBitmap::from_iter(sequence.keys()); + } + + // RFC 9738 requires the highest UIDs to be processed first when truncating. + // Only messages the session has a UID for can be ordered, so the count that + // decides whether to truncate has to come from that same set. + let mut limited_uid = None; + if deleted_ids.len() > message_limit as u64 { + let mut uids = { + let state = mailbox.state.lock(); + deleted_ids + .iter() + .filter_map(|id| state.id_to_imap.get(&id).map(|imap_id| (imap_id.uid, id))) + .collect::>() + }; + + if uids.len() > message_limit as usize { + let cutoff = uids.len() - message_limit as usize; + let (below, lowest, _) = uids.select_nth_unstable(cutoff); + limited_uid = Some(lowest.0); + for (_, id) in below { + deleted_ids.remove(*id); + } + } + } + + // Delete ids + let mut batch = BatchBuilder::new(); + let (fully_deleted, thread_ids) = self + .email_untag_or_delete(account_id, mailbox.id.mailbox_id, &deleted_ids, &mut batch) + .await + .caused_by(trc::location!())?; + self.server + .log_emptied_threads(account_id, &mut batch, thread_ids, &fully_deleted) + .await + .caused_by(trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::Expunge), + SpanId = self.session_id, + AccountId = account_id, + MailboxId = mailbox.id.mailbox_id, + DocumentId = deleted_ids.iter().map(trc::Value::from).collect::>(), + Elapsed = op_start.elapsed() + ); + + // Write changes on source account + if !batch.is_empty() { + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + self.server.notify_task_queue(); + } + + Ok(limited_uid) + } + + pub async fn email_untag_or_delete( + &self, + account_id: u32, + mailbox_id: u32, + deleted_ids: &RoaringBitmap, + batch: &mut BatchBuilder, + ) -> trc::Result<(RoaringBitmap, RoaringBitmap)> { + batch + .with_account_id(account_id) + .with_collection(Collection::Email); + + let mut fully_deleted = RoaringBitmap::new(); + let mut thread_ids = RoaringBitmap::new(); + self.server + .archives( + account_id, + Collection::Email, + deleted_ids, + |document_id, data_| { + let metadata = data_ + .to_unarchived::() + .caused_by(trc::location!())?; + + if let Some(message_uid) = metadata.inner.message_uid(mailbox_id) { + // Add vanished items + batch.with_document(document_id); + batch.log_vanished_item( + VanishedCollection::Email, + (mailbox_id, message_uid), + ); + + if metadata.inner.mailboxes.len() == 1 { + // Delete message + fully_deleted.insert(document_id); + thread_ids.insert(metadata.inner.thread_id.to_native()); + batch + .custom( + ObjectIndexBuilder::<_, ()>::new() + .with_changed_by(self.access_token.account_tenant_ids()) + .with_current(metadata), + ) + .caused_by(trc::location!())? + .schedule_task(Task::UnindexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: document_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::now(), + })) + .commit_point(); + } else { + // Untag message from this mailbox and remove Deleted flag + let mut new_metadata = metadata.inner.to_builder(); + new_metadata.remove_mailbox(mailbox_id); + new_metadata.remove_keyword(&Keyword::Deleted); + + // Write changes + batch + .custom( + ObjectIndexBuilder::new() + .with_current(metadata) + .with_changes(new_metadata.seal()), + ) + .caused_by(trc::location!())? + .commit_point(); + } + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok((fully_deleted, thread_ids)) + } +} diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs new file mode 100644 index 0000000..0bd2720 --- /dev/null +++ b/crates/imap/src/op/fetch.rs @@ -0,0 +1,1279 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{FromModSeq, ImapContext}; +use crate::{ + core::{SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use ahash::AHashMap; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::metadata::{ + ArchivedMessageMetadata, ArchivedMessageMetadataContents, ArchivedMetadataHeaderValue, + ArchivedMetadataPartType, DecodedParts, MESSAGE_RECEIVED_MASK, MessageData, + MessageMetadata, MetadataHeaderName, PART_ENCODING_PROBLEM, + }, +}; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + parser::PushUnique, + protocol::{ + Flag, ObjectId, + expunge::Vanished, + fetch::{ + self, Arguments, Attribute, BodyContents, BodyPart, BodyPartExtension, BodyPartFields, + DataItem, Envelope, FetchItem, Section, + }, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{borrow::Cow, sync::Arc, time::Instant}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use store::{ + query::log::{Change, Query}, + rkyv::rend::u16_le, + write::BatchBuilder, +}; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection, VanishedCollection}, + field::EmailField, + id::Id, + keyword::Keyword, +}; +use utils::chained_bytes::{ChainedBytes, SliceRange}; + +impl Session { + pub async fn handle_fetch(&mut self, requests: Vec>) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapFetch)?; + + let (data, mailbox) = self.state.select_data(); + let is_qresync = self.is_qresync; + let is_uidonly = self.is_uidonly; + let is_utf8 = self.is_utf8; + let message_limit = self.server.core.imap.max_messages_per_command; + + let mut ops = Vec::with_capacity(requests.len()); + let mut activate_objectid = false; + + for request in requests { + let is_uid = matches!(request.command, Command::Fetch(true)); + match request.parse_fetch() { + Ok(arguments) => { + let enabled_condstore = if !self.is_condstore + && arguments.changed_since.is_some() + || arguments.attributes.contains(&Attribute::ModSeq) + { + self.is_condstore = true; + true + } else { + false + }; + + if arguments.attributes.contains(&Attribute::ObjectId) { + activate_objectid = true; + } + + ops.push(Ok((is_uid, enabled_condstore, arguments))); + } + Err(err) => { + ops.push(Err(err)); + } + } + } + + if activate_objectid && let Some(enabled) = self.activate_objectid() { + self.write_bytes(enabled).await?; + } + + spawn_op!(data, { + for op in ops { + match op { + Ok((is_uid, enabled_condstore, arguments)) => { + match data + .fetch( + arguments, + mailbox.clone(), + is_uid, + is_qresync, + is_uidonly, + enabled_condstore, + is_utf8, + message_limit, + Instant::now(), + ) + .await + { + Ok(response) => { + data.write_bytes(response.into_bytes()).await?; + } + Err(err) => data.write_error(err).await?, + } + } + Err(err) => data.write_error(err).await?, + } + } + + Ok(()) + }) + } +} + +impl SessionData { + #[allow(clippy::too_many_arguments)] + pub async fn fetch( + &self, + mut arguments: Arguments, + mailbox: Arc, + is_uid: bool, + is_qresync: bool, + is_uidonly: bool, + enabled_condstore: bool, + is_utf8: bool, + message_limit: u32, + op_start: Instant, + ) -> trc::Result { + // Validate VANISHED parameter + if arguments.include_vanished { + if !is_qresync { + return Err(trc::ImapEvent::Error + .into_err() + .details("Enable QRESYNC first to use the VANISHED parameter.") + .ctx(trc::Key::Type, ResponseType::Bad) + .id(arguments.tag)); + } else if !is_uid { + return Err(trc::ImapEvent::Error + .into_err() + .details("VANISHED parameter is only available for UID FETCH.") + .ctx(trc::Key::Type, ResponseType::Bad) + .id(arguments.tag)); + } + } + + // Resync messages if needed + let account_id = mailbox.id.account_id; + let mut modseq = self + .synchronize_messages(&mailbox) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Convert IMAP ids to JMAP ids. + let mut ids = mailbox + .sequence_to_ids(&arguments.sequence_set, is_uid) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Convert state to modseq + if let Some(changed_since) = arguments.changed_since { + // Obtain changes since the modseq. + let changelog = self + .server + .store() + .changes( + account_id, + SyncCollection::Email.into(), + Query::from_modseq(changed_since), + ) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Process changes + let mut changed_ids = AHashMap::new(); + let mut has_vanished = false; + + for change in changelog.changes { + match change { + Change::InsertItem(id) | Change::UpdateItem(id) => { + let id = (id & u32::MAX as u64) as u32; + if let Some(uid) = ids.get(&id) { + changed_ids.insert(id, *uid); + } + if !has_vanished { + has_vanished = matches!(change, Change::UpdateItem(_)); + } + } + Change::DeleteItem(_) => { + has_vanished = true; + } + _ => (), + } + } + + // Send vanished UIDs + if arguments.include_vanished && has_vanished { + // Add to vanished all known destroyed Ids + let vanished = self + .server + .store() + .vanished::<(u32, u32)>( + account_id, + VanishedCollection::Email.into(), + Query::from_modseq(changed_since), + ) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .into_iter() + .filter_map(|(mailbox_id, uid)| { + if mailbox.id.mailbox_id == mailbox_id { + Some(uid) + } else { + None + } + }) + .collect::>(); + + if !vanished.is_empty() { + let mut buf = Vec::with_capacity(vanished.len() * 3); + Vanished { + earlier: true, + ids: vanished, + } + .serialize(&mut buf); + self.write_bytes(buf).await?; + } + } + + // Filter out ids without changes + if changed_ids.is_empty() { + // Condstore was just enabled, return highest modseq. + if enabled_condstore { + self.write_bytes( + StatusResponse::ok("Highest Modseq") + .with_code(ResponseCode::highest_modseq(modseq)) + .into_bytes(), + ) + .await?; + } + + trc::event!( + Imap(trc::ImapEvent::Fetch), + SpanId = self.session_id, + AccountId = account_id, + MailboxId = mailbox.id.mailbox_id, + Elapsed = op_start.elapsed() + ); + + return Ok( + StatusResponse::completed(Command::Fetch(is_uid)).with_tag(arguments.tag) + ); + } + ids = changed_ids; + arguments.attributes.push_unique(Attribute::ModSeq); + } + + // Build properties list + let mut set_seen_flags = false; + let mut needs_blobs = false; + + for attribute in &arguments.attributes { + match attribute { + Attribute::BodySection { sections, .. } + if sections.first().is_some_and(|s| { + matches!(s, Section::Header | Section::HeaderFields { .. }) + }) => {} + Attribute::Body | Attribute::BodyStructure | Attribute::BinarySize { .. } => { + /* + Note that this did not result in \Seen being set, because + RFC822.HEADER response data occurs as a result of a FETCH + of RFC822.HEADER. BODY[HEADER] response data occurs as a + result of a FETCH of BODY[HEADER] (which sets \Seen) or + BODY.PEEK[HEADER] (which does not set \Seen). + */ + needs_blobs = true; + } + Attribute::BodySection { peek, .. } | Attribute::Binary { peek, .. } => { + if mailbox.is_select && !*peek { + set_seen_flags = true; + } + needs_blobs = true; + } + Attribute::Rfc822Text | Attribute::Rfc822 => { + if mailbox.is_select { + set_seen_flags = true; + } + needs_blobs = true; + } + _ => (), + } + } + + if set_seen_flags + && !self + .check_mailbox_acl( + mailbox.id.account_id, + mailbox.id.mailbox_id, + Acl::ModifyItems, + ) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + set_seen_flags = false; + } + + if is_uid { + if arguments.attributes.is_empty() { + arguments.attributes.push(Attribute::Flags); + } else if !arguments.attributes.contains(&Attribute::Uid) { + arguments.attributes.insert(0, Attribute::Uid); + } + } + + // Process each message + let mut batch = BatchBuilder::new(); + let mut ids = ids + .into_iter() + .map(|(id, imap_id)| (imap_id.seqnum, imap_id.uid, id)) + .collect::>(); + ids.sort_unstable_by_key(|(seqnum, _, _)| *seqnum); + + // RFC 9738 requires the highest UIDs to be processed first when truncating + let message_limit = message_limit as usize; + let limited_uid = if ids.len() > message_limit { + ids.drain(..ids.len() - message_limit); + ids.first().map(|(_, uid, _)| *uid) + } else { + None + }; + + let fetched_ids = ids + .iter() + .map(|id| trc::Value::from(id.2)) + .collect::>(); + let message_cache = self + .server + .get_cached_messages(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + for (seqnum, uid, id) in ids { + // Obtain attributes and keywords + let (metadata_, data) = if let (Some(email), Some(data)) = ( + self.server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + id, + EmailField::Metadata, + )) + .await + .imap_ctx(&arguments.tag, trc::location!())?, + message_cache.email_by_id(&id), + ) { + (email, data) + } else { + trc::event!( + Store(trc::StoreEvent::NotFound), + AccountId = account_id, + DocumentId = id, + Collection = Collection::Email, + Details = "Message metadata not found.", + CausedBy = trc::location!(), + ); + continue; + }; + let metadata = metadata_ + .unarchive::() + .imap_ctx(&arguments.tag, trc::location!())?; + let raw_body; + + // Fetch and parse blob + let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()); + if needs_blobs { + // Retrieve raw message if needed + raw_body = self + .server + .blob_store() + .get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + if let Some(raw_body) = &raw_body { + raw_message.append( + raw_body + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + } else { + trc::event!( + Store(trc::StoreEvent::NotFound), + AccountId = account_id, + DocumentId = id, + Collection = Collection::Email, + BlobId = metadata.blob_hash.0.as_slice(), + Details = "Blob not found.", + CausedBy = trc::location!(), + ); + + continue; + } + } + + let message = &metadata.contents[0]; + let decoded = metadata.decode_contents(raw_message.clone()); + + // Build response + let mut items = Vec::with_capacity(arguments.attributes.len()); + let set_seen_flag = set_seen_flags && !message_cache.has_keyword(data, &Keyword::Seen); + + for attribute in &arguments.attributes { + match attribute { + Attribute::Envelope => { + items.push(DataItem::Envelope { + envelope: message.envelope(), + }); + } + Attribute::Flags => { + let mut flags = message_cache + .expand_keywords(data) + .map(Flag::from) + .collect::>(); + if set_seen_flag { + flags.push(Flag::Seen); + } + items.push(DataItem::Flags { flags }); + } + Attribute::InternalDate => { + items.push(DataItem::InternalDate { + date: (metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64, + }); + } + Attribute::Preview { .. } => { + items.push(DataItem::Preview { + contents: if !metadata.preview.is_empty() { + Some(metadata.preview.as_bytes().into()) + } else { + None + }, + }); + } + Attribute::Rfc822Size => { + items.push(DataItem::Rfc822Size { + size: data.size as usize, + }); + } + Attribute::Uid => { + items.push(DataItem::Uid { uid }); + } + Attribute::Rfc822 => { + items.push(DataItem::Rfc822 { + contents: raw_message.get_full_range(), + }); + } + Attribute::Rfc822Header => { + let contents = raw_message.get_slice_range( + 0..u32::from(metadata.root_part().offset_body) as usize, + ); + + if contents != SliceRange::None { + items.push(DataItem::Rfc822Header { contents }); + } + } + Attribute::Rfc822Text => { + items.push(DataItem::Rfc822Text { + contents: raw_message.get_full_range(), + }); + } + Attribute::Body => { + items.push(DataItem::Body { + part: metadata.body_structure(&decoded, false), + }); + } + Attribute::BodyStructure => { + items.push(DataItem::BodyStructure { + part: metadata.body_structure(&decoded, true), + }); + } + Attribute::BodySection { + sections, partial, .. + } => { + if let Some(contents) = metadata.body_section(&decoded, sections, *partial) + { + items.push(DataItem::BodySection { + sections: sections.to_vec(), + origin_octet: partial.map(|(start, _)| start), + contents, + }); + } + } + + Attribute::Binary { + sections, partial, .. + } => match metadata.binary(&decoded, sections, *partial) { + Ok(Some(contents)) => { + items.push(DataItem::Binary { + sections: sections.to_vec(), + offset: partial.map(|(start, _)| start), + contents, + }); + } + Err(_) => { + self.write_error( + trc::ImapEvent::Error + .into_err() + .details(format!( + "Failed to decode part {} of message {}.", + sections + .iter() + .map(|s| s.to_string()) + .collect::>() + .join("."), + if is_uid { uid } else { seqnum } + )) + .code(ResponseCode::UnknownCte), + ) + .await?; + continue; + } + _ => (), + }, + Attribute::BinarySize { sections } => { + if let Some(size) = metadata.binary_size(&decoded, sections) { + items.push(DataItem::BinarySize { + sections: sections.to_vec(), + size, + }); + } + } + Attribute::ModSeq => { + items.push(DataItem::ModSeq { + modseq: data.change_id + 1, + }); + } + Attribute::ObjectId => { + items.push(DataItem::ObjectId(ObjectId { + email_id: Some(Id::from_parts(data.thread_id, id)), + thread_id: Some(Id::from(data.thread_id)), + ..Default::default() + })); + } + } + } + + // Add flags to the response if the message was unseen + if set_seen_flag && !arguments.attributes.contains(&Attribute::Flags) { + let mut flags = message_cache + .expand_keywords(data) + .map(Flag::from) + .collect::>(); + flags.push(Flag::Seen); + items.push(DataItem::Flags { flags }); + } + + // Serialize fetch item + let mut buf = Vec::with_capacity(128); + FetchItem { + id: if is_uidonly { uid } else { seqnum }, + is_uidonly, + items, + } + .serialize(&mut buf, is_utf8); + self.write_bytes(buf).await?; + + // Add to set flags + if set_seen_flag + && let Some(data_) = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + id, + )) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + let data = data_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + let mut new_data = data.inner.to_builder(); + new_data.keywords.push(Keyword::Seen); + + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .imap_ctx(&arguments.tag, trc::location!())? + .commit_point(); + } + } + + // Set Seen ids + if !batch.is_empty() { + match self + .server + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .imap_ctx(&arguments.tag, trc::location!()) + { + Ok(change_id) => { + modseq = change_id; + } + Err(err) => { + if !err.is_assertion_failure() { + return Err(err.id(arguments.tag)); + } + } + } + } + + trc::event!( + Imap(trc::ImapEvent::Fetch), + SpanId = self.session_id, + AccountId = account_id, + MailboxId = mailbox.id.mailbox_id, + DocumentId = fetched_ids, + Details = arguments + .attributes + .iter() + .map(|c| trc::Value::from(format!("{c:?}"))) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + // Condstore was enabled with this command + if enabled_condstore { + self.write_bytes( + StatusResponse::ok("Highest Modseq") + .with_code(ResponseCode::highest_modseq(modseq)) + .into_bytes(), + ) + .await?; + } + + let response = StatusResponse::completed(Command::Fetch(is_uid)).with_tag(arguments.tag); + Ok(match limited_uid { + Some(uid) => response.with_code(ResponseCode::MessageLimit { + limit: message_limit as u32, + uid: uid.into(), + }), + None => response, + }) + } +} + +#[allow(clippy::result_unit_err)] +pub trait AsImapDataItem { + fn body_structure(&'_ self, decoded: &DecodedParts<'_>, is_extended: bool) -> BodyPart<'_>; + fn body_section<'x>( + &self, + decoded: &'x DecodedParts<'x>, + sections: &[Section], + partial: Option<(u32, u32)>, + ) -> Option>; + fn binary<'x>( + &self, + decoded: &'x DecodedParts<'x>, + sections: &[u32], + partial: Option<(u32, u32)>, + ) -> Result>, ()>; + fn binary_size(&self, decoded: &DecodedParts<'_>, sections: &[u32]) -> Option; +} + +#[allow(clippy::result_unit_err)] +pub trait AsImapDataItemPart { + fn as_body_part( + &'_ self, + decoded: &DecodedParts<'_>, + message_id: usize, + part_id: usize, + is_extended: bool, + ) -> BodyPart<'_>; + + fn envelope(&'_ self) -> Envelope<'_>; +} + +impl AsImapDataItemPart for ArchivedMessageMetadataContents { + fn as_body_part( + &'_ self, + decoded: &DecodedParts<'_>, + message_id: usize, + part_id: usize, + is_extended: bool, + ) -> BodyPart<'_> { + let part = &self.parts[part_id]; + let body = decoded.raw_message_section(message_id, part.body_to_end()); + let (is_multipart, is_text) = match &part.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => (false, true), + ArchivedMetadataPartType::Multipart(_) => (true, false), + _ => (false, false), + }; + let content_type = part + .header_value(&MetadataHeaderName::ContentType) + .and_then(|ct| ct.as_content_type()); + + let mut body_md5 = None; + let mut extension = BodyPartExtension::default(); + let mut fields = BodyPartFields::default(); + + if !is_multipart || is_extended { + fields.body_parameters = content_type + .as_ref() + .map(|ct| { + ct.attributes + .iter() + .map(|k| (k.name.as_ref().into(), k.value.as_ref().into())) + .collect::>() + }) + .filter(|p| !p.is_empty()) + } + + if !is_multipart { + fields.body_subtype = content_type + .as_ref() + .and_then(|ct| ct.c_subtype.as_ref().map(|cs| cs.as_ref().into())); + + fields.body_id = part + .header_value(&MetadataHeaderName::ContentId) + .and_then(|id| id.as_text().map(|id| format!("<{}>", id).into())); + + fields.body_description = part + .header_value(&MetadataHeaderName::ContentDescription) + .and_then(|ct| ct.as_text().map(|ct| ct.into())); + + fields.body_encoding = part + .header_value(&MetadataHeaderName::ContentTransferEncoding) + .and_then(|ct| ct.as_text().map(|ct| ct.into())); + + fields.body_size_octets = body.as_ref().map(|b| b.len()).unwrap_or(0); + + if is_text { + if fields.body_subtype.is_none() { + fields.body_subtype = Some("plain".into()); + } + if fields.body_encoding.is_none() { + fields.body_encoding = Some("7bit".into()); + } + if fields.body_parameters.is_none() { + fields.body_parameters = Some(vec![("charset".into(), "us-ascii".into())]); + } + } + } + + if is_extended { + if !is_multipart { + body_md5 = body + .as_ref() + .map(|b| format!("{:x}", md5::compute(b)).into()); + } + + extension.body_disposition = part + .header_value(&MetadataHeaderName::ContentDisposition) + .and_then(|cd| cd.as_content_type()) + .map(|cd| { + ( + cd.c_type.as_ref().into(), + cd.attributes + .iter() + .map(|k| (k.name.as_ref().into(), k.value.as_ref().into())) + .collect::>(), + ) + }); + + extension.body_language = part + .header_value(&MetadataHeaderName::ContentLanguage) + .and_then(|hv| { + hv.as_text_list() + .map(|list| list.iter().map(|item| item.as_ref().into()).collect()) + }); + + extension.body_location = part + .header_value(&MetadataHeaderName::ContentLocation) + .and_then(|ct| ct.as_text().map(|ct| ct.into())); + } + + match &part.body { + ArchivedMetadataPartType::Multipart(parts) => BodyPart::Multipart { + body_parts: Vec::with_capacity(parts.len()), + body_subtype: content_type + .as_ref() + .and_then(|ct| ct.c_subtype.as_ref().map(|cs| cs.as_ref().into())) + .unwrap_or_else(|| "".into()), + body_parameters: fields.body_parameters, + extension, + }, + ArchivedMetadataPartType::Message(_) => BodyPart::Message { + fields, + envelope: None, + body: None, + body_size_lines: 0, + body_md5, + extension, + }, + _ => { + if is_text { + BodyPart::Text { + fields, + body_size_lines: body + .as_ref() + .map(|b| b.iter().filter(|&&ch| ch == b'\n').count()) + .unwrap_or(0), + body_md5, + extension, + } + } else { + BodyPart::Basic { + body_type: content_type + .as_ref() + .map(|ct| Cow::from(ct.c_type.as_ref())), + fields, + body_md5, + extension, + } + } + } + } + } + + fn envelope(&'_ self) -> Envelope<'_> { + let headers = self.root_part(); + Envelope { + date: headers.date(), + subject: headers.subject().map(|s| s.into()), + from: headers + .header_values(&MetadataHeaderName::From) + .flat_map(|a| a.as_imap_address()) + .collect(), + sender: headers + .header_values(&MetadataHeaderName::Sender) + .flat_map(|a| a.as_imap_address()) + .collect(), + reply_to: headers + .header_values(&MetadataHeaderName::ReplyTo) + .flat_map(|a| a.as_imap_address()) + .collect(), + to: headers + .header_values(&MetadataHeaderName::To) + .flat_map(|a| a.as_imap_address()) + .collect(), + cc: headers + .header_values(&MetadataHeaderName::Cc) + .flat_map(|a| a.as_imap_address()) + .collect(), + bcc: headers + .header_values(&MetadataHeaderName::Bcc) + .flat_map(|a| a.as_imap_address()) + .collect(), + in_reply_to: headers.in_reply_to().as_text_list().map(|list| { + let mut irt = String::with_capacity(list.len() * 10); + for (pos, l) in list.iter().enumerate() { + if pos > 0 { + irt.push(' '); + } + irt.push('<'); + irt.push_str(l.as_ref()); + irt.push('>'); + } + irt.into() + }), + message_id: headers.message_id().map(|id| format!("<{}>", id).into()), + } + } +} + +impl AsImapDataItem for ArchivedMessageMetadata { + fn body_structure(&'_ self, decoded: &DecodedParts<'_>, is_extended: bool) -> BodyPart<'_> { + let mut stack = Vec::new(); + let base_part = [u16_le::from_native(0)]; + let mut parts = base_part.as_slice().iter(); + let mut message = &self.contents[0]; + let mut root_part = None; + let mut message_id = 0; + + loop { + while let Some(part_id) = parts.next() { + let part_id = u16::from(part_id) as usize; + let mut part = message.as_body_part(decoded, message_id, part_id, is_extended); + + match &message.parts[part_id].body { + ArchivedMetadataPartType::Message(nested_message_id) => { + let nested_message = self.message_id(*nested_message_id); + part.set_envelope(nested_message.envelope()); + if let Some(root_part) = root_part { + if stack.len() == 10_000 { + debug_assert!(false, "Too much nesting in message metadata"); + return root_part; + } + stack.push((root_part, parts, (message, message_id).into())); + } + root_part = part.into(); + parts = base_part.as_slice().iter(); + message = nested_message; + message_id = u16::from(*nested_message_id) as usize; + continue; + } + ArchivedMetadataPartType::Multipart(subparts) => { + if let Some(root_part) = root_part { + if stack.len() == 10_000 { + debug_assert!(false, "Too much nesting in message metadata"); + return root_part; + } + stack.push((root_part, parts, None)); + } + root_part = part.into(); + parts = subparts.iter(); + continue; + } + _ => (), + } + if let Some(root_part) = &mut root_part { + root_part.add_part(part); + } else { + return part; + } + } + if let Some((mut prev_root_part, prev_parts, prev_message)) = stack.pop() { + if let Some((prev_message, prev_message_id)) = prev_message { + message = prev_message; + message_id = prev_message_id; + } + + prev_root_part.add_part(root_part.unwrap()); + parts = prev_parts; + root_part = prev_root_part.into(); + } else { + break; + } + } + + root_part.unwrap() + } + + fn body_section<'x>( + &self, + decoded: &'x DecodedParts<'x>, + sections: &[Section], + partial: Option<(u32, u32)>, + ) -> Option> { + let mut part = self.root_part(); + if sections.is_empty() { + return Some(get_cow_partial_bytes( + decoded.raw_message_section(0, part.header_to_end())?, + partial, + )); + } + + let mut message = &self.contents[0]; + let mut message_id = 0; + let mut sections_iter = sections.iter().enumerate().peekable(); + + while let Some((section_num, section)) = sections_iter.next() { + match section { + Section::Part { num } => { + part = if let Some(sub_part_ids) = part.sub_parts() { + sub_part_ids + .as_ref() + .get((*num).saturating_sub(1) as usize) + .and_then(|pos| message.parts.as_ref().get(u16::from(*pos) as usize)) + } else if *num == 1 && (section_num == sections.len() - 1 || part.is_message()) + { + Some(part) + } else { + None + }?; + + if let ArchivedMetadataPartType::Message(nested_message_id) = &part.body + && let Some(( + _, + Section::Part { .. } + | Section::Header + | Section::HeaderFields { .. } + | Section::Text, + )) = sections_iter.peek() + { + message = self.message_id(*nested_message_id); + part = message.root_part(); + message_id = u16::from(nested_message_id) as usize; + } + } + Section::Header => { + return Some(get_cow_partial_bytes( + decoded.raw_message_section(message_id, part.header_to_body())?, + partial, + )); + } + Section::HeaderFields { not, fields } => { + let mut headers = Vec::with_capacity( + u32::from(part.offset_body).saturating_sub(u32::from(part.offset_header)) + as usize, + ); + for header in part.headers.iter() { + let header_name = header.name.as_str(); + if fields.iter().any(|f| header_name.eq_ignore_ascii_case(f)) != *not { + headers.extend_from_slice(header_name.as_bytes()); + headers.push(b':'); + headers.extend_from_slice( + &decoded + .raw_message_section(message_id, header.value_range()) + .unwrap_or_default(), + ); + } + } + + headers.extend_from_slice(b"\r\n"); + + return Some(if partial.is_none() { + headers.into() + } else { + get_partial_bytes(&headers, partial).to_vec().into() + }); + } + Section::Text => { + return Some(get_cow_partial_bytes( + decoded.raw_message_section(message_id, part.body_to_end())?, + partial, + )); + } + Section::Mime => { + let mut headers = Vec::with_capacity( + u32::from(part.offset_body).saturating_sub(u32::from(part.offset_header)) + as usize, + ); + for header in part.headers.iter() { + if header.name.is_mime_header() + || header.name.as_str().starts_with("Content-") + { + headers.extend_from_slice(header.name.as_str().as_bytes()); + headers.extend_from_slice(b":"); + headers.extend_from_slice( + &decoded + .raw_message_section(message_id, header.value_range()) + .unwrap_or_default(), + ); + } + } + headers.extend_from_slice(b"\r\n"); + return Some(if partial.is_none() { + headers.into() + } else { + get_partial_bytes(&headers, partial).to_vec().into() + }); + } + } + } + + // BODY[x] should return both headers and body, but most clients + // expect BODY[x] to return only the body, just like BOXY[x.TEXT] does. + + Some(get_cow_partial_bytes( + decoded.raw_message_section(message_id, part.body_to_end())?, + partial, + )) + } + + fn binary<'x>( + &self, + decoded: &'x DecodedParts<'x>, + sections: &[u32], + partial: Option<(u32, u32)>, + ) -> Result>, ()> { + let mut message = &self.contents[0]; + let mut message_id = 0; + let mut part = self.root_part(); + let mut sections_iter = sections.iter().enumerate().peekable(); + + while let Some((section_num, num)) = sections_iter.next() { + part = if let Some(sub_part_ids) = part.sub_parts() { + if let Some(part) = sub_part_ids + .as_ref() + .get((*num).saturating_sub(1) as usize) + .and_then(|pos| message.parts.as_ref().get(u16::from(*pos) as usize)) + { + part + } else { + return Ok(None); + } + } else if *num == 1 && (section_num == sections.len() - 1 || part.is_message()) { + part + } else { + return Ok(None); + }; + + if let (ArchivedMetadataPartType::Message(nested_message), Some(_)) = + (&part.body, sections_iter.peek()) + { + message = self.message_id(*nested_message); + part = message.root_part(); + message_id = u16::from(nested_message) as usize; + } + } + + if (part.flags & PART_ENCODING_PROBLEM) == 0 { + let part_offset = u32::from(part.offset_header) as usize; + Ok(match &part.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => { + BodyContents::Bytes(get_cow_partial_bytes( + decoded + .transfer_decoded_contents(message_id, part) + .unwrap_or_default(), + partial, + )) + .into() + } + ArchivedMetadataPartType::Binary | ArchivedMetadataPartType::InlineBinary => { + BodyContents::Bytes( + get_partial_bytes( + decoded + .binary_part(message_id, part_offset) + .unwrap_or_default(), + partial, + ) + .into(), + ) + .into() + } + ArchivedMetadataPartType::Message(message) => BodyContents::Bytes({ + { + let part = self.message_id(*message).root_part(); + get_cow_partial_bytes( + decoded + .raw_message_section(message_id, part.header_to_end()) + .unwrap_or_default(), + partial, + ) + } + }) + .into(), + ArchivedMetadataPartType::Multipart(_) => { + BodyContents::Bytes(get_cow_partial_bytes( + decoded + .raw_message_section(message_id, part.header_to_end()) + .unwrap_or_default(), + partial, + )) + .into() + } + }) + } else { + Err(()) + } + } + + fn binary_size(&self, decoded: &DecodedParts<'_>, sections: &[u32]) -> Option { + let mut message = &self.contents[0]; + let mut message_id = 0; + let mut part = self.root_part(); + let mut sections_iter = sections.iter().enumerate().peekable(); + + while let Some((section_num, num)) = sections_iter.next() { + part = if let Some(sub_part_ids) = part.sub_parts() { + sub_part_ids + .as_ref() + .get((*num).saturating_sub(1) as usize) + .and_then(|pos| message.parts.as_ref().get(u16::from(pos) as usize)) + } else if *num == 1 && (section_num == sections.len() - 1 || part.is_message()) { + Some(part) + } else { + None + }?; + + if let (ArchivedMetadataPartType::Message(nested_message), Some(_)) = + (&part.body, sections_iter.peek()) + { + message = self.message_id(*nested_message); + message_id = u16::from(nested_message) as usize; + part = message.root_part(); + } + } + + match &part.body { + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html => decoded + .transfer_decoded_contents(message_id, part) + .map(|p| p.len()) + .unwrap_or_default(), + ArchivedMetadataPartType::Binary | ArchivedMetadataPartType::InlineBinary => decoded + .part(message_id, u32::from(part.offset_header) as usize) + .map(|p| p.len()) + .unwrap_or_default(), + ArchivedMetadataPartType::Message(message) => { + self.message_id(*message).root_part().raw_len() + } + ArchivedMetadataPartType::Multipart(_) => part.raw_len(), + } + .into() + } +} + +#[inline(always)] +fn get_partial_bytes(bytes: &[u8], partial: Option<(u32, u32)>) -> &[u8] { + if let Some((start, end)) = partial { + bytes + .get(start as usize..std::cmp::min((start + end) as usize, bytes.len())) + .unwrap_or_default() + } else { + bytes + } +} + +#[inline(always)] +fn get_cow_partial_bytes(bytes: Cow<'_, [u8]>, partial: Option<(u32, u32)>) -> Cow<'_, [u8]> { + if let Some((start, end)) = partial { + let range = start as usize..std::cmp::min((start + end) as usize, bytes.len()); + match bytes { + Cow::Borrowed(bytes) => Cow::Borrowed(bytes.get(range).unwrap_or_default()), + Cow::Owned(bytes) => Cow::Owned(bytes.get(range).unwrap_or_default().to_vec()), + } + } else { + bytes + } +} + +trait AsImapAddress { + fn as_imap_address(&'_ self) -> Vec>; +} + +impl AsImapAddress for ArchivedMetadataHeaderValue { + fn as_imap_address(&'_ self) -> Vec> { + let mut addresses = Vec::new(); + + match self { + ArchivedMetadataHeaderValue::AddressList(list) => { + for addr in list.iter() { + if let Some(email) = addr.address.as_ref() { + addresses.push(fetch::Address::Single(fetch::EmailAddress { + name: addr.name.as_ref().map(|n| n.as_ref().into()), + address: email.as_ref().into(), + })); + } + } + } + ArchivedMetadataHeaderValue::AddressGroup(list) => { + for group in list.iter() { + addresses.push(fetch::Address::Group(fetch::AddressGroup { + name: group.name.as_ref().map(|n| n.as_ref().into()), + addresses: group + .addresses + .iter() + .filter_map(|addr| { + fetch::EmailAddress { + name: addr.name.as_ref().map(|n| n.as_ref().into()), + address: addr.address.as_ref()?.as_ref().into(), + } + .into() + }) + .collect(), + })); + } + } + _ => (), + } + + addresses + } +} diff --git a/crates/imap/src/op/idle.rs b/crates/imap/src/op/idle.rs new file mode 100644 index 0000000..a1a0eb7 --- /dev/null +++ b/crates/imap/src/op/idle.rs @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{SelectedMailbox, Session, SessionData, State}, + op::ImapContext, +}; +use ahash::AHashSet; +use common::{ipc::PushNotification, network::SessionStream}; +use imap_proto::{ + Command, StatusResponse, + protocol::{ + Sequence, fetch, + list::{Attribute, ListItem}, + status::Status, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use store::query::log::Query; +use tokio::io::AsyncReadExt; +use trc::AddContext; +use types::{collection::SyncCollection, type_state::DataType}; +use utils::map::bitmap::Bitmap; + +impl Session { + pub async fn handle_idle(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapIdle)?; + + let op_start = Instant::now(); + let (data, mailbox, types) = match &self.state { + State::Authenticated { data, .. } => { + (data.clone(), None, Bitmap::from_iter([DataType::Mailbox])) + } + State::Selected { data, mailbox, .. } => ( + data.clone(), + mailbox.clone().into(), + Bitmap::from_iter([DataType::Email, DataType::Mailbox, DataType::EmailDelivery]), + ), + _ => unreachable!(), + }; + let is_rev2 = self.version.is_rev2(); + let is_utf8 = self.is_utf8; + let is_uidonly = self.is_uidonly; + let use_vanished = self.is_qresync || is_uidonly; + + // Register with push manager + let mut push_rx = self + .server + .subscribe_push_manager(&data.access_token, types) + .await + .imap_ctx(&request.tag, trc::location!())?; + + // Send continuation response + self.write_bytes(b"+ Idling, send 'DONE' to stop.\r\n".to_vec()) + .await?; + + trc::event!( + Imap(trc::ImapEvent::IdleStart), + SpanId = self.session_id, + Elapsed = op_start.elapsed() + ); + + let op_start = Instant::now(); + let mut buf = vec![0; 4]; + loop { + tokio::select! { + result = tokio::time::timeout(self.server.core.imap.timeout_idle, self.stream_rx.read_exact(&mut buf)) => { + match result { + Ok(Ok(bytes_read)) => { + if bytes_read > 0 { + if (buf[..bytes_read]).windows(4).any(|w| w == b"DONE") { + trc::event!(Imap(trc::ImapEvent::IdleStop), SpanId = self.session_id, Elapsed = op_start.elapsed()); + return self.write_bytes(StatusResponse::completed(Command::Idle) + .with_tag(request.tag) + .into_bytes()).await; + } + } else { + return Err(trc::NetworkEvent::Closed.into_err().details("IMAP connection closed by client.").id(request.tag)); + } + }, + Ok(Err(err)) => { + return Err(trc::NetworkEvent::ReadError.into_err().reason(err).details("IMAP connection error.").id(request.tag)); + }, + Err(_) => { + self.write_bytes(&b"* BYE IDLE timed out.\r\n"[..]).await.ok(); + return Err(trc::NetworkEvent::Timeout.into_err().details("IMAP IDLE timed out.").id(request.tag)); + } + } + } + push_notification = push_rx.recv() => { + if let Some(push_notification) = push_notification { + let mut has_mailbox_changes = false; + let mut has_email_changes = false; + + match push_notification { + PushNotification::StateChange(state_change) => { + for type_state in state_change.types { + match type_state { + DataType::Email | DataType::EmailDelivery => { + has_email_changes = true; + } + DataType::Mailbox => { + has_mailbox_changes = true; + } + _ => {} + } + } + }, + PushNotification::EmailPush(_) => { + has_email_changes = true; + has_mailbox_changes = true; + }, + PushNotification::CalendarAlert(_) => (), + } + + if has_mailbox_changes || has_email_changes { + data.write_changes(&mailbox, has_mailbox_changes, has_email_changes, use_vanished, is_uidonly, is_rev2, is_utf8).await?; + } + } else { + self.write_bytes(&b"* BYE Server shutting down.\r\n"[..]).await.ok(); + return Err(trc::NetworkEvent::Closed.into_err().details("IDLE channel closed.").id(request.tag)); + } + } + } + } + } +} + +impl SessionData { + #[allow(clippy::too_many_arguments)] + pub async fn write_changes( + &self, + mailbox: &Option>, + check_mailboxes: bool, + check_emails: bool, + use_vanished: bool, + is_uidonly: bool, + is_rev2: bool, + is_utf8: bool, + ) -> trc::Result<()> { + // Fetch all changed mailboxes + if check_mailboxes { + let changes = self + .synchronize_mailboxes(true) + .await + .caused_by(trc::location!())? + .unwrap(); + + let mut buf = Vec::with_capacity(64); + + // List deleted mailboxes + for mailbox_name in changes.deleted { + ListItem { + mailbox_name, + attributes: vec![Attribute::NonExistent], + tags: vec![], + } + .serialize(&mut buf, is_rev2, is_utf8, false); + } + + // List added mailboxes + for mailbox_name in changes.added { + ListItem { + mailbox_name, + attributes: vec![], + tags: vec![], + } + .serialize(&mut buf, is_rev2, is_utf8, false); + } + // Obtain status of changed mailboxes + for mailbox_name in changes.changed { + if let Ok(status) = self + .status( + mailbox_name, + &[ + Status::Messages, + Status::Unseen, + Status::UidNext, + Status::UidValidity, + ], + ) + .await + { + status.serialize(&mut buf, is_utf8); + } + } + + if !buf.is_empty() { + self.write_bytes(buf).await?; + } + } + + // Fetch selected mailbox changes + if check_emails { + // Synchronize emails + if let Some(mailbox) = mailbox { + // Obtain changes since last sync + let modseq = mailbox.state.lock().modseq; + let new_state = self + .write_mailbox_changes(mailbox, use_vanished) + .await + .caused_by(trc::location!())?; + if new_state == modseq { + return Ok(()); + } + + // Obtain changed messages + let changelog = self + .server + .store() + .changes( + mailbox.id.account_id, + SyncCollection::Email.into(), + Query::Since(modseq), + ) + .await + .caused_by(trc::location!())?; + let changed_ids = { + let state = mailbox.state.lock(); + changelog + .changes + .into_iter() + .filter_map(|change| { + change.try_unwrap_item_id().and_then(|item_id| { + state + .id_to_imap + .get(&((item_id & u32::MAX as u64) as u32)) + .map(|id| id.uid) + }) + }) + .collect::>() + }; + + if !changed_ids.is_empty() { + let op_start = Instant::now(); + return self + .fetch( + fetch::Arguments { + tag: "".into(), + sequence_set: Sequence::List { + items: changed_ids + .into_iter() + .map(|uid| Sequence::Number { value: uid }) + .collect(), + }, + attributes: vec![fetch::Attribute::Flags, fetch::Attribute::Uid], + changed_since: None, + include_vanished: false, + }, + mailbox.clone(), + true, + use_vanished, + is_uidonly, + false, + is_utf8, + u32::MAX, + op_start, + ) + .await + .caused_by(trc::location!()) + .map(|_| ()); + } + } + } + + Ok(()) + } +} diff --git a/crates/imap/src/op/list.rs b/crates/imap/src/op/list.rs new file mode 100644 index 0000000..6ad8180 --- /dev/null +++ b/crates/imap/src/op/list.rs @@ -0,0 +1,398 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::{ + core::{Session, SessionData}, + spawn_op, +}; +use common::network::SessionStream; + +use imap_proto::{ + Command, StatusResponse, + protocol::{ + ImapResponse, ProtocolVersion, + list::{ + self, Arguments, Attribute, ChildInfo, ListItem, ReturnOption, SelectionOption, Tag, + }, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use trc::StoreEvent; + +use super::ImapContext; + +impl Session { + pub async fn handle_list(&mut self, request: Request) -> trc::Result<()> { + let op_start = Instant::now(); + let command = request.command; + let is_lsub = command == Command::Lsub; + let arguments = if !is_lsub { + // Validate access + self.assert_has_permission(Permission::ImapList)?; + + request.parse_list(self.is_utf8) + } else { + // Validate access + self.assert_has_permission(Permission::ImapLsub)?; + + request.parse_lsub(self.is_utf8) + }?; + + if !arguments.is_separator_query() { + let data = self.state.session_data(); + let version = self.version; + let is_utf8 = self.is_utf8; + + spawn_op!( + data, + data.list(arguments, is_lsub, version, is_utf8, op_start) + .await + ) + } else { + self.write_bytes( + StatusResponse::completed(command) + .with_tag(arguments.unwrap_tag()) + .serialize( + list::Response { + is_rev2: self.version.is_rev2(), + is_utf8: self.is_utf8, + is_lsub, + list_items: vec![ListItem { + mailbox_name: "".into(), + attributes: vec![Attribute::NoSelect], + tags: vec![], + }], + status_items: Vec::new(), + } + .serialize(), + ), + ) + .await + } + } +} + +impl SessionData { + pub async fn list( + &self, + arguments: Arguments, + is_lsub: bool, + version: ProtocolVersion, + is_utf8: bool, + op_start: Instant, + ) -> trc::Result<()> { + let (tag, reference_name, mut patterns, selection_options, return_options) = match arguments + { + Arguments::Basic { + tag, + reference_name, + mailbox_name, + } => ( + tag, + reference_name, + vec![mailbox_name], + Vec::new(), + Vec::new(), + ), + Arguments::Extended { + tag, + reference_name, + mailbox_name, + selection_options, + return_options, + } => ( + tag, + reference_name, + mailbox_name, + selection_options, + return_options, + ), + }; + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&tag, trc::location!())?; + + // Process arguments + let mut filter_subscribed = false; + let mut filter_special_use = false; + let mut recursive_match = false; + let mut include_special_use = true; + let mut include_subscribed = false; + let mut include_children = false; + let mut include_status = None; + for selection_option in &selection_options { + match selection_option { + SelectionOption::Subscribed => { + filter_subscribed = true; + include_subscribed = true; + } + SelectionOption::Remote => (), + SelectionOption::SpecialUse => { + filter_special_use = true; + include_special_use = true; + } + SelectionOption::RecursiveMatch => { + recursive_match = true; + } + } + } + for return_option in &return_options { + match return_option { + ReturnOption::Subscribed => { + include_subscribed = true; + } + ReturnOption::Children => { + include_children = true; + } + ReturnOption::Status(status) => { + include_status = status.into(); + } + ReturnOption::SpecialUse => { + include_special_use = true; + } + } + } + if recursive_match && !filter_subscribed { + return Err(trc::ImapEvent::Error + .into_err() + .details("RECURSIVEMATCH requires the SUBSCRIBED selection option.") + .id(tag)); + } + + // Append reference name + if !patterns.is_empty() && !reference_name.is_empty() { + patterns.iter_mut().for_each(|item| { + *item = format!("{}{}", reference_name, item); + }) + } + + let mut list_items = Vec::with_capacity(10); + + // Add mailboxes + let mut added_shared_folder = false; + for account in self.mailboxes.lock().iter() { + if let Some(prefix) = &account.prefix { + if !added_shared_folder { + if !filter_subscribed + && matches_pattern(&patterns, &self.server.core.email.shared_folder) + { + list_items.push(ListItem { + mailbox_name: self.server.core.email.shared_folder.as_str().into(), + attributes: if include_children { + vec![Attribute::HasChildren, Attribute::NoSelect] + } else { + vec![Attribute::NoSelect] + }, + tags: vec![], + }); + } + added_shared_folder = true; + } + if !filter_subscribed && matches_pattern(&patterns, prefix) { + list_items.push(ListItem { + mailbox_name: prefix.clone(), + attributes: if include_children { + vec![Attribute::HasChildren, Attribute::NoSelect] + } else { + vec![Attribute::NoSelect] + }, + tags: vec![], + }); + } + } + + for (mailbox_name, mailbox_id) in &account.mailbox_names { + if matches_pattern(&patterns, mailbox_name) { + let mailbox = if let Some(mailbox) = account.mailbox_state.get(mailbox_id) { + mailbox + } else { + trc::event!( + Store(StoreEvent::UnexpectedError), + Details = "IMAP mailbox no longer present in account state", + Id = *mailbox_id, + Details = account + .mailbox_state + .keys() + .copied() + .map(trc::Value::from) + .collect::>() + ); + continue; + }; + let mut has_recursive_match = false; + if recursive_match { + let prefix = format!("{}/", mailbox_name); + for (mailbox_name, mailbox_id) in &account.mailbox_names { + if mailbox_name.starts_with(&prefix) + && account.mailbox_state.get(mailbox_id).unwrap().is_subscribed + { + has_recursive_match = true; + break; + } + } + } + if !filter_subscribed || mailbox.is_subscribed || has_recursive_match { + let mut attributes = Vec::with_capacity(2); + if include_children { + attributes.push(if mailbox.has_children { + Attribute::HasChildren + } else { + Attribute::HasNoChildren + }); + } + if include_subscribed && mailbox.is_subscribed { + attributes.push(Attribute::Subscribed); + } + if include_special_use { + if let Some(special_use) = &mailbox.special_use { + attributes.push(*special_use); + } else if filter_special_use { + continue; + } + } + list_items.push(ListItem { + mailbox_name: mailbox_name.clone(), + attributes, + tags: if !has_recursive_match { + vec![] + } else { + vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])] + }, + }); + } + } + } + } + + // Add status response + let mut status_items = Vec::new(); + if let Some(include_status) = include_status { + for list_item in &list_items { + match self + .status(list_item.mailbox_name.clone(), include_status) + .await + .imap_ctx(&tag, trc::location!()) + { + Ok(status_item) => { + status_items.push(status_item); + } + Err(err) => { + self.write_error(err).await?; + } + } + } + } + + trc::event!( + Imap(if !is_lsub { + trc::ImapEvent::List + } else { + trc::ImapEvent::Lsub + }), + SpanId = self.session_id, + Details = list_items + .iter() + .map(|item| trc::Value::from(item.mailbox_name.clone())) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + // Write response + self.write_bytes( + StatusResponse::completed(if !is_lsub { + Command::List + } else { + Command::Lsub + }) + .with_tag(tag) + .serialize( + list::Response { + is_rev2: version.is_rev2(), + is_utf8, + is_lsub, + list_items, + status_items, + } + .serialize(), + ), + ) + .await + } +} + +#[allow(clippy::while_let_on_iterator)] +pub fn matches_pattern(patterns: &[String], mailbox_name: &str) -> bool { + if patterns.is_empty() { + return true; + } + + 'outer: for pattern in patterns { + let mut pattern_bytes = pattern.as_bytes().iter().enumerate().peekable(); + let mut mailbox_name = mailbox_name.as_bytes().iter().peekable(); + + 'inner: while let Some((pos, &ch)) = pattern_bytes.next() { + if ch == b'%' || ch == b'*' { + let mut end_pos = pos; + while let Some(&(_, &next_ch)) = pattern_bytes.peek() { + if next_ch == b'%' || next_ch == b'*' { + break; + } else { + end_pos = pattern_bytes.next().unwrap().0; + } + } + if end_pos > pos { + let match_bytes = &pattern.as_bytes()[pos + 1..end_pos + 1]; + let mut match_count = 0; + let pattern_eof = end_pos == pattern.len() - 1; + + loop { + match mailbox_name.next() { + Some(&ch) => { + if match_bytes[match_count] == ch { + match_count += 1; + if match_count == match_bytes.len() { + if !pattern_eof { + continue 'inner; + } else if mailbox_name.peek().is_none() { + return true; + } else { + // Match needs to be at the end of the string, + // reset counter. + match_count = 0; + } + } + } else if match_count > 0 { + match_count = 0; + } + } + None => continue 'outer, + } + } + } else if ch == b'*' || !mailbox_name.any(|&ch| ch == b'/') { + return true; + } else { + continue 'outer; + } + } else { + match mailbox_name.next() { + Some(&mch) if mch == ch => (), + _ => continue 'outer, + } + } + } + + if mailbox_name.next().is_none() { + return true; + } + } + + false +} diff --git a/crates/imap/src/op/login.rs b/crates/imap/src/op/login.rs new file mode 100644 index 0000000..f3dec4a --- /dev/null +++ b/crates/imap/src/op/login.rs @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::network::SessionStream; +use directory::Credentials; +use imap_proto::{Command, receiver::Request}; + +impl Session { + pub async fn handle_login(&mut self, request: Request) -> trc::Result<()> { + let arguments = request.parse_login()?; + + self.authenticate( + Credentials::Basic { + username: arguments.username.to_string(), + secret: arguments.password.to_string(), + mfa_token: None, + }, + arguments.tag, + ) + .await + } +} diff --git a/crates/imap/src/op/logout.rs b/crates/imap/src/op/logout.rs new file mode 100644 index 0000000..44b9c7f --- /dev/null +++ b/crates/imap/src/op/logout.rs @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::core::Session; +use common::network::SessionStream; +use imap_proto::{Command, StatusResponse, receiver::Request}; + +impl Session { + pub async fn handle_logout(&mut self, request: Request) -> trc::Result<()> { + let op_start = Instant::now(); + + let mut response = + StatusResponse::bye("Stalwart IMAP4rev2 bids you farewell.".to_string()).into_bytes(); + + trc::event!( + Imap(trc::ImapEvent::Logout), + SpanId = self.session_id, + Elapsed = op_start.elapsed() + ); + + response.extend( + StatusResponse::completed(Command::Logout) + .with_tag(request.tag) + .into_bytes(), + ); + self.write_bytes(response).await + } +} diff --git a/crates/imap/src/op/mod.rs b/crates/imap/src/op/mod.rs new file mode 100644 index 0000000..70cdee3 --- /dev/null +++ b/crates/imap/src/op/mod.rs @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ::store::query::log::Query; +use imap_proto::ResponseCode; + +pub mod acl; +pub mod append; +pub mod authenticate; +pub mod capability; +pub mod close; +pub mod copy_move; +pub mod create; +pub mod delete; +pub mod enable; +pub mod expunge; +pub mod fetch; +pub mod idle; +pub mod list; +pub mod login; +pub mod logout; +pub mod namespace; +pub mod noop; +pub mod quota; +pub mod rename; +pub mod search; +pub mod select; +pub mod status; +pub mod store; +pub mod subscribe; +pub mod thread; +pub mod uidbatches; + +trait FromModSeq { + fn from_modseq(modseq: u64) -> Self; +} + +trait ToModSeq { + fn to_modseq(&self) -> u64; +} + +impl FromModSeq for Query { + fn from_modseq(modseq: u64) -> Self { + if modseq > 0 { + Query::Since(modseq - 1) + } else { + Query::All + } + } +} + +impl ToModSeq for u64 { + fn to_modseq(&self) -> u64 { + if *self > 0 { *self + 1 } else { 0 } + } +} + +#[macro_export] +macro_rules! spawn_op { + ($data:expr, $($code:tt)*) => { + { + + tokio::spawn(async move { + let data = &($data); + + if let Err(err) = (async { + $($code)* + }) + .await + { + let _ = data.write_error(err).await; + } + }); + + Ok(())} + }; +} +pub trait ImapContext { + fn imap_ctx(self, tag: &str, location: &'static str) -> trc::Result; +} + +impl ImapContext for trc::Result { + fn imap_ctx(self, tag: &str, location: &'static str) -> trc::Result { + match self { + Ok(value) => Ok(value), + Err(err) => Err( + if !err.matches(trc::EventType::Imap(trc::ImapEvent::Error)) { + err.ctx(trc::Key::Id, tag.to_string()) + .ctx(trc::Key::Details, "Internal Server Error") + .ctx(trc::Key::Code, ResponseCode::ContactAdmin) + .ctx(trc::Key::CausedBy, location) + } else { + err.ctx(trc::Key::Id, tag.to_string()) + }, + ), + } + } +} diff --git a/crates/imap/src/op/namespace.rs b/crates/imap/src/op/namespace.rs new file mode 100644 index 0000000..33ad5c5 --- /dev/null +++ b/crates/imap/src/op/namespace.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::network::SessionStream; +use imap_proto::{ + Command, StatusResponse, + protocol::{ImapResponse, namespace::Response}, + receiver::Request, +}; +use registry::schema::enums::Permission; + +impl Session { + pub async fn handle_namespace(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapNamespace)?; + + trc::event!( + Imap(trc::ImapEvent::Namespace), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + self.write_bytes( + StatusResponse::completed(Command::Namespace) + .with_tag(request.tag) + .serialize( + Response { + shared_prefix: if self.state.session_data().mailboxes.lock().len() > 1 { + Some(self.server.core.email.shared_folder.as_str().into()) + } else { + None + }, + } + .serialize(), + ), + ) + .await + } +} diff --git a/crates/imap/src/op/noop.rs b/crates/imap/src/op/noop.rs new file mode 100644 index 0000000..7ecf5aa --- /dev/null +++ b/crates/imap/src/op/noop.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::core::{Session, State}; +use common::network::SessionStream; +use imap_proto::{Command, StatusResponse, receiver::Request}; + +impl Session { + pub async fn handle_noop(&mut self, request: Request) -> trc::Result<()> { + let op_start = Instant::now(); + + if let State::Selected { data, mailbox, .. } = &self.state { + data.write_changes( + &Some(mailbox.clone()), + false, + true, + self.is_qresync || self.is_uidonly, + self.is_uidonly, + self.version.is_rev2(), + self.is_utf8, + ) + .await?; + } + + trc::event!( + Imap(trc::ImapEvent::Noop), + SpanId = self.session_id, + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + StatusResponse::completed(request.command) + .with_tag(request.tag) + .into_bytes(), + ) + .await + } +} diff --git a/crates/imap/src/op/quota.rs b/crates/imap/src/op/quota.rs new file mode 100644 index 0000000..34ea962 --- /dev/null +++ b/crates/imap/src/op/quota.rs @@ -0,0 +1,210 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionData}, + op::ImapContext, + spawn_op, +}; +use common::network::SessionStream; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::{ + ImapResponse, + capability::QuotaResourceName, + quota::{Arguments, QuotaItem, QuotaResource, Response}, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; + +impl Session { + pub async fn handle_get_quota(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStatus)?; + + let data = self.state.session_data(); + + spawn_op!(data, { + match request.parse_get_quota() { + Ok(argument) => match data.get_quota(argument).await { + Ok(response) => { + data.write_bytes(response).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(err) => data.write_error(err).await?, + } + + Ok(()) + }) + } + + pub async fn handle_get_quota_root(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStatus)?; + + let data = self.state.session_data(); + let is_utf8 = self.is_utf8; + + spawn_op!(data, { + match request.parse_get_quota_root(is_utf8) { + Ok(argument) => match data.get_quota_root(argument).await { + Ok(response) => { + data.write_bytes(response).await?; + } + Err(error) => { + data.write_error(error).await?; + } + }, + Err(err) => data.write_error(err).await?, + } + + Ok(()) + }) + } +} + +impl SessionData { + pub async fn get_quota(&self, arguments: Arguments) -> trc::Result> { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate quota root + let account_id: u32 = arguments + .name + .strip_prefix("#") + .and_then(|id| id.parse().ok()) + .filter(|id| self.access_token.is_member(*id)) + .ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details("Invalid quota root parameter.") + .id(arguments.tag.to_string()) + })?; + + // Obtain access token for mailbox + let account = self + .server + .account(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let used_quota = self + .server + .get_used_quota_account(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::GetQuota), + SpanId = self.session_id, + Id = arguments.name.clone(), + Details = vec![ + trc::Value::from(used_quota), + trc::Value::from(account.disk_quota()) + ], + Elapsed = op_start.elapsed() + ); + + // Build response + let response = Response { + quota_root_items: vec![], + quota_items: vec![QuotaItem { + name: arguments.name, + resources: if account.disk_quota() > 0 { + vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: account.disk_quota(), + used: used_quota as u64, + }] + } else { + vec![] + }, + }], + }; + + Ok(StatusResponse::ok("GETQUOTA successful.") + .with_tag(arguments.tag) + .serialize(response.serialize())) + } + + pub async fn get_quota_root(&self, arguments: Arguments) -> trc::Result> { + let op_start = Instant::now(); + + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate mailbox + let account_id = if let Some(mailbox) = self.get_mailbox_by_name(&arguments.name) { + mailbox.account_id + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::TryCreate) + .id(arguments.tag)); + }; + + // Obtain access token for mailbox + let account = self + .server + .account(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let used_quota = self + .server + .get_used_quota_account(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + trc::event!( + Imap(trc::ImapEvent::GetQuota), + SpanId = self.session_id, + MailboxName = arguments.name.clone(), + Details = vec![ + trc::Value::from(used_quota), + trc::Value::from(account.disk_quota()) + ], + Elapsed = op_start.elapsed() + ); + + // Build response + let response = Response { + quota_root_items: vec![arguments.name, format!("#{account_id}")], + quota_items: vec![QuotaItem { + name: format!("#{account_id}"), + resources: if account.disk_quota() > 0 { + vec![QuotaResource { + resource: QuotaResourceName::Storage, + total: account.disk_quota(), + used: used_quota as u64, + }] + } else { + vec![] + }, + }], + }; + + Ok(StatusResponse::ok("GETQUOTAROOT successful.") + .with_tag(arguments.tag) + .serialize(response.serialize())) + } +} diff --git a/crates/imap/src/op/rename.rs b/crates/imap/src/op/rename.rs new file mode 100644 index 0000000..704f100 --- /dev/null +++ b/crates/imap/src/op/rename.rs @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionData}, + spawn_op, +}; +use common::{network::SessionStream, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder}; +use email::cache::MessageCacheFetch; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + protocol::{ObjectId, rename::Arguments}, + receiver::Request, +}; +use registry::schema::enums::{Permission, StorageQuota}; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{acl::Acl, collection::Collection, id::Id}; + +use super::ImapContext; + +impl Session { + pub async fn handle_rename(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapRename)?; + + let op_start = Instant::now(); + let arguments = request.parse_rename(self.is_utf8)?; + let data = self.state.session_data(); + let is_objectid = self.is_objectid; + + spawn_op!(data, { + let response = data.rename_folder(arguments, is_objectid, op_start).await?; + data.write_bytes(response.into_bytes()).await + }) + } +} + +impl SessionData { + pub async fn rename_folder( + &self, + arguments: Arguments, + is_objectid: bool, + op_start: Instant, + ) -> trc::Result { + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate mailbox name + let mut params = self + .validate_mailbox_create(&arguments.new_mailbox_name, None) + .await + .add_context(|err| err.id(arguments.tag.clone()))?; + params.is_rename = true; + + // Validate source mailbox + let mailbox_id = { + let mut mailbox_id = None; + for account in self.mailboxes.lock().iter() { + if let Some(mailbox_id_) = account.mailbox_names.get(&arguments.mailbox_name) { + if account.account_id == params.account_id { + mailbox_id = (*mailbox_id_).into(); + break; + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("Cannot move mailboxes between accounts.") + .code(ResponseCode::Cannot) + .id(arguments.tag)); + } + } + } + if let Some(mailbox_id) = mailbox_id { + mailbox_id + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Mailbox '{}' not found.", arguments.mailbox_name)) + .code(ResponseCode::NonExistent) + .id(arguments.tag)); + } + }; + + // Obtain mailbox + let mailbox_ = self + .server + .store() + .get_value::>(ValueKey::archive( + params.account_id, + Collection::Mailbox, + mailbox_id, + )) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details(format!("Mailbox '{}' not found.", arguments.mailbox_name)) + .caused_by(trc::location!()) + .code(ResponseCode::NonExistent) + .id(arguments.tag.clone()) + })?; + let mailbox = mailbox_ + .to_unarchived::() + .imap_ctx(&arguments.tag, trc::location!())?; + + // Validate ACL + let access_token = self + .refresh_access_token() + .await + .imap_ctx(&arguments.tag, trc::location!())?; + if access_token.is_shared(params.account_id) + && !mailbox + .inner + .acls + .effective_acl(&access_token) + .contains(Acl::Modify) + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You are not allowed to rename this mailbox.") + .code(ResponseCode::NoPerm) + .id(arguments.tag)); + } + + // Get new mailbox name from path + let new_mailbox_name = params.path.pop().unwrap(); + + // Validate quota + if !params.path.is_empty() { + let account = self + .server + .account(params.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + let mailbox_count = self + .server + .get_cached_messages(params.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .mailboxes + .items + .len(); + if mailbox_count + params.path.len() + > self + .server + .object_quota(account.object_quotas(), StorageQuota::MaxMailboxes) + as usize + { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "There are too many mailboxes, ", + "please delete some before adding a new one." + )) + .code(ResponseCode::OverQuota) + .id(arguments.tag.clone())); + } + } + + // Build batch + let mut parent_id = params.parent_mailbox_id.map(|id| id + 1).unwrap_or(0); + let mut create_ids = Vec::with_capacity(params.path.len()); + let mut next_document_id = self + .server + .store() + .assign_document_ids( + params.account_id, + Collection::Mailbox, + params.path.len() as u64, + ) + .await + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + + for &path_item in params.path.iter() { + let mailbox_id = next_document_id; + next_document_id -= 1; + + batch + .with_account_id(params.account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes( + email::mailbox::Mailbox::new(path_item).with_parent_id(parent_id), + )) + .imap_ctx(&arguments.tag, trc::location!())? + .commit_point(); + + parent_id = mailbox_id + 1; + create_ids.push(mailbox_id); + } + + let mut new_mailbox = mailbox + .deserialize::() + .caused_by(trc::location!())?; + new_mailbox.name = new_mailbox_name.into(); + new_mailbox.parent_id = parent_id; + new_mailbox.uid_validity = rand::random::(); + batch + .with_account_id(params.account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id) + .custom( + ObjectIndexBuilder::new() + .with_current(mailbox) + .with_changes(new_mailbox), + ) + .imap_ctx(&arguments.tag, trc::location!())?; + self.server + .commit_batch(batch) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + let account_id = params.account_id; + + trc::event!( + Imap(trc::ImapEvent::RenameMailbox), + SpanId = self.session_id, + AccountId = account_id, + MailboxName = arguments.new_mailbox_name, + MailboxId = mailbox_id, + Elapsed = op_start.elapsed() + ); + + let response = StatusResponse::completed(Command::Rename).with_tag(arguments.tag); + Ok(if is_objectid { + response.with_code(ResponseCode::ObjectId(ObjectId { + mailbox_id: Some(Id::from(mailbox_id)), + account_id: Some(Id::from(account_id)), + ..Default::default() + })) + } else { + response + }) + } +} diff --git a/crates/imap/src/op/search.rs b/crates/imap/src/op/search.rs new file mode 100644 index 0000000..0e2fb57 --- /dev/null +++ b/crates/imap/src/op/search.rs @@ -0,0 +1,789 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{FromModSeq, ToModSeq}; +use crate::{ + core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use common::network::SessionStream; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + protocol::{ + Sequence, + search::{self, Arguments, Comparator, Filter, Response, ResultOption}, + }, + receiver::Request, +}; +use mail_parser::HeaderName; +use nlp::language::Language; +use registry::schema::enums::Permission; +use std::{str::FromStr, sync::Arc, time::Instant}; +use store::{ + query::log::Query, + roaring::RoaringBitmap, + search::{ + EmailSearchField, SearchComparator, SearchFilter, SearchOperator, SearchQuery, SearchValue, + }, + write::{SearchIndex, now}, +}; +use tokio::sync::watch; +use trc::AddContext; +use types::{collection::SyncCollection, id::Id, keyword::Keyword}; +use utils::map::vec_map::VecMap; + +impl Session { + pub async fn handle_search( + &mut self, + request: Request, + is_sort: bool, + is_uid: bool, + ) -> trc::Result<()> { + let op_start = Instant::now(); + let mut arguments = if !is_sort { + // Validate access + self.assert_has_permission(Permission::ImapSearch)?; + + request.parse_search(self.version) + } else { + // Validate access + self.assert_has_permission(Permission::ImapSort)?; + + request.parse_sort() + }?; + + // RFC 9586 forbids the sequence set criterion once UIDONLY is enabled + if self.is_uidonly + && arguments.filter.iter().any(|filter| { + matches!(filter, Filter::Sequence(sequence, false) if !sequence.is_saved_search()) + }) + { + return Err(trc::ImapEvent::Error + .into_err() + .details("The sequence set search criterion is not allowed once UIDONLY is enabled.") + .code(ResponseCode::UidRequired) + .ctx(trc::Key::Type, ResponseType::Bad) + .id(arguments.tag)); + } + + let (data, mailbox) = self.state.mailbox_state(); + let message_limit = self.server.core.imap.max_messages_per_command; + + // Create channel for results + let (results_tx, prev_saved_search) = + if arguments.result_options.contains(&ResultOption::Save) { + let prev_saved_search = Some(mailbox.get_saved_search().await); + let (tx, rx) = watch::channel(Arc::new(Vec::new())); + *mailbox.saved_search.lock() = SavedSearch::InFlight { rx }; + (tx.into(), prev_saved_search) + } else { + (None, None) + }; + + spawn_op!(data, { + let tag = std::mem::take(&mut arguments.tag); + let bytes = match data + .search( + arguments, + mailbox.clone(), + results_tx, + prev_saved_search.clone(), + is_uid, + message_limit, + op_start, + ) + .await + { + Ok((response, limited_uid)) => { + let response = response.serialize(&tag); + let status = StatusResponse::completed(if !is_sort { + Command::Search(is_uid) + } else { + Command::Sort(is_uid) + }) + .with_tag(tag); + + match limited_uid { + Some(uid) => status.with_code(ResponseCode::MessageLimit { + limit: message_limit, + uid: uid.into(), + }), + None => status, + } + .serialize(response) + } + Err(err) => { + if let Some(prev_saved_search) = prev_saved_search { + *mailbox.saved_search.lock() = prev_saved_search + .map_or(SavedSearch::None, |s| SavedSearch::Results { items: s }); + } + return Err(err.id(tag)); + } + }; + data.write_bytes(bytes).await + }) + } +} + +impl SessionData { + #[allow(clippy::too_many_arguments)] + pub async fn search( + &self, + arguments: Arguments, + mailbox: Arc, + results_tx: Option>>>, + prev_saved_search: Option>>>, + is_uid: bool, + message_limit: u32, + op_start: Instant, + ) -> trc::Result<(search::Response, Option)> { + // Run query + let is_sort = arguments.sort.is_some(); + let (result_set, include_highest_modseq) = self + .query( + arguments.filter, + arguments.sort.unwrap_or_default(), + &mailbox, + &prev_saved_search, + ) + .await?; + + // Obtain modseq + let highest_modseq = if include_highest_modseq { + self.synchronize_messages(&mailbox) + .await? + .to_modseq() + .into() + } else { + None + }; + + // Sort and map ids + let mut min: Option<(u32, ImapId)> = None; + let mut max: Option<(u32, ImapId)> = None; + let mut total = 0; + let results_len = result_set.len(); + let mut saved_results = if results_tx.is_some() { + Some(Vec::with_capacity(results_len)) + } else { + None + }; + let mut imap_ids = Vec::with_capacity(results_len); + mailbox.map_search_results( + result_set.into_iter(), + is_uid, + arguments.result_options.contains(&ResultOption::Min), + arguments.result_options.contains(&ResultOption::Max), + &mut min, + &mut max, + &mut total, + &mut imap_ids, + &mut saved_results, + ); + // RFC 9738 exempts SORT, whose ordering is meaningless once truncated + let mut limited_uid = None; + if !is_sort { + imap_ids.sort_unstable(); + + let message_limit = message_limit as usize; + if imap_ids.len() > message_limit { + let threshold = imap_ids[imap_ids.len() - message_limit]; + imap_ids.drain(..imap_ids.len() - message_limit); + limited_uid = if is_uid { + Some(threshold) + } else { + mailbox.seqnum_to_uid(threshold) + }; + + // RFC 9738 requires the saved search to be truncated to match + if let Some(saved_results) = saved_results.as_mut() { + saved_results.retain(|imap_id| { + if is_uid { + imap_id.uid >= threshold + } else { + imap_id.seqnum >= threshold + } + }); + } + } + } + + // Save results + if let (Some(results_tx), Some(saved_results)) = (results_tx, saved_results) { + let saved_results = Arc::new(saved_results); + *mailbox.saved_search.lock() = SavedSearch::Results { + items: saved_results.clone(), + }; + results_tx.send(saved_results).ok(); + } + + trc::event!( + Imap(if !is_sort { + trc::ImapEvent::Search + } else { + trc::ImapEvent::Sort + }), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Total = total, + Elapsed = op_start.elapsed() + ); + + // Build response + Ok(( + Response { + is_uid, + min: min.map(|(id, _)| id), + max: max.map(|(id, _)| id), + count: if arguments.result_options.contains(&ResultOption::Count) { + Some(total) + } else { + None + }, + ids: if arguments.result_options.is_empty() + || arguments.result_options.contains(&ResultOption::All) + { + imap_ids + } else { + vec![] + }, + is_sort, + is_esearch: arguments.is_esearch, + highest_modseq, + }, + limited_uid, + )) + } + + pub async fn query( + &self, + imap_filter: Vec, + imap_comparator: Vec, + mailbox: &SelectedMailbox, + prev_saved_search: &Option>>>, + ) -> trc::Result<(Vec, bool)> { + // Obtain message ids + let mut filters = Vec::with_capacity(imap_filter.len() + 1); + let cache = self + .server + .get_cached_messages(mailbox.id.account_id) + .await + .caused_by(trc::location!())?; + let message_ids = RoaringBitmap::from_iter( + cache + .in_mailbox(mailbox.id.mailbox_id) + .map(|m| m.document_id), + ); + + // Convert query + let mut include_highest_modseq = false; + for filter in imap_filter { + match filter { + Filter::Sequence(sequence, uid_filter) => { + let mut set = RoaringBitmap::new(); + if let (Sequence::SavedSearch, Some(prev_saved_search)) = + (&sequence, &prev_saved_search) + { + if let Some(prev_saved_search) = prev_saved_search { + let state = mailbox.state.lock(); + for imap_id in prev_saved_search.iter() { + if let Some(id) = state.uid_to_id.get(&imap_id.uid) { + set.insert(*id); + } + } + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details("No saved search found.")); + } + } else { + for id in mailbox.sequence_to_ids(&sequence, uid_filter).await?.keys() { + set.insert(*id); + } + } + filters.push(SearchFilter::is_in_set(set)); + } + Filter::UidAfter(uid) => { + filters.push(SearchFilter::is_in_set(match uid.checked_add(1) { + Some(min) => mailbox.uids_in_range(Some(min), None), + None => RoaringBitmap::new(), + })); + } + Filter::UidBefore(uid) => { + filters.push(SearchFilter::is_in_set(if uid > 1 { + mailbox.uids_in_range(None, Some(uid - 1)) + } else { + RoaringBitmap::new() + })); + } + Filter::All => { + filters.push(SearchFilter::is_in_set(message_ids.clone())); + } + Filter::Answered => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .with_keyword(&Keyword::Answered) + .map(|m| m.document_id), + ))); + } + Filter::Before(date) => { + filters.push(SearchFilter::lt(EmailSearchField::ReceivedAt, date)); + } + Filter::Deleted => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.with_keyword(&Keyword::Deleted).map(|m| m.document_id), + ))); + } + Filter::Draft => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.with_keyword(&Keyword::Draft).map(|m| m.document_id), + ))); + } + Filter::Flagged => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.with_keyword(&Keyword::Flagged).map(|m| m.document_id), + ))); + } + Filter::Keyword(keyword) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .with_keyword(&Keyword::from(keyword)) + .map(|m| m.document_id), + ))); + } + Filter::Larger(size) => { + filters.push(SearchFilter::gt(EmailSearchField::Size, size)); + } + Filter::On(date) => { + filters.push(SearchFilter::And); + filters.push(SearchFilter::ge(EmailSearchField::ReceivedAt, date)); + filters.push(SearchFilter::lt(EmailSearchField::ReceivedAt, date + 86400)); + filters.push(SearchFilter::End); + } + Filter::Seen => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.with_keyword(&Keyword::Seen).map(|m| m.document_id), + ))); + } + Filter::SentBefore(date) => { + filters.push(SearchFilter::lt(EmailSearchField::SentAt, date)); + } + Filter::SentOn(date) => { + filters.push(SearchFilter::And); + filters.push(SearchFilter::ge(EmailSearchField::SentAt, date)); + filters.push(SearchFilter::lt(EmailSearchField::SentAt, date + 86400)); + filters.push(SearchFilter::End); + } + Filter::SentSince(date) => { + filters.push(SearchFilter::ge(EmailSearchField::SentAt, date)); + } + Filter::Since(date) => { + filters.push(SearchFilter::ge(EmailSearchField::ReceivedAt, date)); + } + Filter::Smaller(size) => { + filters.push(SearchFilter::lt(EmailSearchField::Size, size)); + } + Filter::Unanswered => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .without_keyword(&Keyword::Answered) + .map(|m| m.document_id), + ))); + } + Filter::Undeleted => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .without_keyword(&Keyword::Deleted) + .map(|m| m.document_id), + ))); + } + Filter::Undraft => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .without_keyword(&Keyword::Draft) + .map(|m| m.document_id), + ))); + } + Filter::Unflagged => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .without_keyword(&Keyword::Flagged) + .map(|m| m.document_id), + ))); + } + Filter::Unkeyword(keyword) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache + .without_keyword(&Keyword::from(keyword)) + .map(|m| m.document_id), + ))); + } + Filter::Unseen => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.without_keyword(&Keyword::Seen).map(|m| m.document_id), + ))); + } + Filter::Recent => { + //filters.push(SearchFilter::is_in_set(self.get_recent(&mailbox.id))); + } + Filter::New => { + /*filters.push(SearchFilter::And); + filters.push(SearchFilter::is_in_set(self.get_recent(&mailbox.id))); + filters.push(SearchFilter::Not); + filters.push(SearchFilter::is_in_bitmap( + EmailSearchField::Keywords, + Keyword::Seen, + )); + filters.push(SearchFilter::End); + filters.push(SearchFilter::End);*/ + } + Filter::Old => { + /*filters.push(SearchFilter::Not); + filters.push(SearchFilter::is_in_set(self.get_recent(&mailbox.id))); + filters.push(SearchFilter::End);*/ + } + Filter::Older(secs) => { + filters.push(SearchFilter::le( + EmailSearchField::ReceivedAt, + now().saturating_sub(secs as u64), + )); + } + Filter::Younger(secs) => { + filters.push(SearchFilter::ge( + EmailSearchField::ReceivedAt, + now().saturating_sub(secs as u64), + )); + } + Filter::ModSeq((modseq, _)) => { + let mut set = RoaringBitmap::new(); + for id in self + .server + .store() + .changes( + mailbox.id.account_id, + SyncCollection::Email.into(), + Query::from_modseq(modseq), + ) + .await? + .changes + .into_iter() + .filter_map(|change| change.try_unwrap_item_id()) + { + let id = (id & u32::MAX as u64) as u32; + if message_ids.contains(id) { + set.insert(id); + } + } + filters.push(SearchFilter::is_in_set(set)); + include_highest_modseq = true; + } + Filter::EmailId(id) => { + if let Ok(id) = Id::from_str(&id) { + filters.push(SearchFilter::is_in_set( + RoaringBitmap::from_sorted_iter([id.document_id()]).unwrap(), + )); + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Failed to parse email id '{id}'.",))); + } + } + Filter::ThreadId(id) => { + if let Ok(id) = Id::from_str(&id) { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.in_thread(id.document_id()).map(|m| m.document_id), + ))); + } else { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Failed to parse thread id '{id}'.",))); + } + } + Filter::Bcc(text) => { + filters.push(SearchFilter::has_text( + EmailSearchField::Bcc, + text, + Language::None, + )); + } + Filter::Body(text) => { + filters.push(SearchFilter::has_text_detect( + EmailSearchField::Body, + text, + self.server.core.email.default_language, + )); + } + Filter::Cc(text) => { + filters.push(SearchFilter::has_text( + EmailSearchField::Cc, + text, + Language::None, + )); + } + Filter::From(text) => { + filters.push(SearchFilter::has_text( + EmailSearchField::From, + text, + Language::None, + )); + } + Filter::Header(header, value) => { + if let Some(header) = HeaderName::parse(header) { + match header { + HeaderName::Subject => { + filters.push(SearchFilter::has_text_detect( + EmailSearchField::Subject, + value, + self.server.core.email.default_language, + )); + } + header @ (HeaderName::From + | HeaderName::To + | HeaderName::Cc + | HeaderName::Bcc) => { + filters.push(SearchFilter::has_text( + match header { + HeaderName::From => EmailSearchField::From, + HeaderName::To => EmailSearchField::To, + HeaderName::Cc => EmailSearchField::Cc, + HeaderName::Bcc => EmailSearchField::Bcc, + _ => unreachable!(), + }, + value, + Language::None, + )); + } + header => { + let op = if matches!( + header, + HeaderName::MessageId + | HeaderName::InReplyTo + | HeaderName::References + | HeaderName::ResentMessageId + ) || value.is_empty() + { + SearchOperator::Equal + } else { + SearchOperator::Contains + }; + + filters.push(SearchFilter::cond( + EmailSearchField::Headers, + op, + SearchValue::KeyValues( + VecMap::with_capacity(1) + .with_append(header.as_str().to_lowercase(), value), + ), + )); + } + } + } + } + Filter::Subject(text) => { + filters.push(SearchFilter::has_text_detect( + EmailSearchField::Subject, + text, + self.server.core.email.default_language, + )); + } + Filter::Text(text) => { + let (text, language) = + Language::detect(text, self.server.core.email.default_language); + + filters.push(SearchFilter::Or); + filters.push(SearchFilter::has_text( + EmailSearchField::From, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::To, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Cc, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Bcc, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Subject, + &text, + language, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Body, + &text, + language, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Attachment, + text, + language, + )); + filters.push(SearchFilter::End); + } + Filter::To(text) => { + filters.push(SearchFilter::has_text( + EmailSearchField::To, + text, + Language::None, + )); + } + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::End => { + filters.push(SearchFilter::End); + } + } + } + + // Convert comparators + let mut comparators = Vec::with_capacity(imap_comparator.len()); + for comparator in imap_comparator { + comparators.push(match comparator.sort { + search::Sort::Arrival => { + SearchComparator::field(EmailSearchField::ReceivedAt, comparator.ascending) + } + search::Sort::Cc => { + return Err(trc::ImapEvent::Error + .into_err() + .details("Sorting by CC is not supported.")); + } + search::Sort::Date => { + SearchComparator::field(EmailSearchField::SentAt, comparator.ascending) + } + search::Sort::From | search::Sort::DisplayFrom => { + SearchComparator::field(EmailSearchField::From, comparator.ascending) + } + search::Sort::Size => { + SearchComparator::field(EmailSearchField::Size, comparator.ascending) + } + search::Sort::Subject => { + SearchComparator::field(EmailSearchField::Subject, comparator.ascending) + } + search::Sort::To | search::Sort::DisplayTo => { + SearchComparator::field(EmailSearchField::To, comparator.ascending) + } + }); + } + + // Run query + self.server + .search_store() + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(filters) + .with_comparators(comparators) + .with_account_id(mailbox.id.account_id) + .with_mask(message_ids), + ) + .await + .map(|res| (res, include_highest_modseq)) + .caused_by(trc::location!()) + } +} + +impl SelectedMailbox { + pub async fn get_saved_search(&self) -> Option>> { + let mut rx = match &*self.saved_search.lock() { + SavedSearch::InFlight { rx } => rx.clone(), + SavedSearch::Results { items } => { + return Some(items.clone()); + } + SavedSearch::None => { + return None; + } + }; + rx.changed().await.ok(); + let v = rx.borrow(); + Some(v.clone()) + } + + #[allow(clippy::too_many_arguments)] + pub fn map_search_results( + &self, + ids: impl Iterator, + is_uid: bool, + find_min: bool, + find_max: bool, + min: &mut Option<(u32, ImapId)>, + max: &mut Option<(u32, ImapId)>, + total: &mut u32, + imap_ids: &mut Vec, + saved_results: &mut Option>, + ) { + let state = self.state.lock(); + let find_min_or_max = find_min || find_max; + for document_id in ids { + if let Some((id, imap_id)) = state.map_result_id(document_id, is_uid) { + if find_min_or_max { + if find_min { + if let Some((prev_min, _)) = min { + if id < *prev_min { + *min = Some((id, imap_id)); + } + } else { + *min = Some((id, imap_id)); + } + } + if find_max { + if let Some((prev_max, _)) = max { + if id > *prev_max { + *max = Some((id, imap_id)); + } + } else { + *max = Some((id, imap_id)); + } + } + } else { + imap_ids.push(id); + if let Some(r) = saved_results.as_mut() { + r.push(imap_id) + } + } + *total += 1; + } + } + if find_min || find_max { + for (id, imap_id) in [min, max].into_iter().flatten() { + imap_ids.push(*id); + if let Some(r) = saved_results.as_mut() { + r.push(*imap_id) + } + } + } + } +} + +impl SavedSearch { + pub async fn unwrap(&self) -> Option>> { + match self { + SavedSearch::InFlight { rx } => { + let mut rx = rx.clone(); + rx.changed().await.ok(); + let v = rx.borrow(); + Some(v.clone()) + } + SavedSearch::Results { items } => Some(items.clone()), + SavedSearch::None => None, + } + } +} diff --git a/crates/imap/src/op/select.rs b/crates/imap/src/op/select.rs new file mode 100644 index 0000000..7ad3db4 --- /dev/null +++ b/crates/imap/src/op/select.rs @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapContext, ToModSeq}; +use crate::core::{SavedSearch, SelectedMailbox, Session, State}; +use common::network::SessionStream; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + protocol::{ + ImapResponse, ObjectId, Sequence, fetch, + list::ListItem, + select::{HighestModSeq, Response}, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use types::{acl::Acl, id::Id}; + +impl Session { + pub async fn handle_select(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(if request.command == Command::Select { + Permission::ImapSelect + } else { + Permission::ImapExamine + })?; + + let op_start = Instant::now(); + let is_select = request.command == Command::Select; + let command = request.command; + let arguments = request.parse_select(self.is_utf8)?; + let data = self.state.session_data(); + + // Activate OBJECTID+ when the OBJECTID parameter is supplied + if arguments.objectid.is_some() + && let Some(enabled) = self.activate_objectid() + { + self.write_bytes(enabled).await?; + } + + // Once activated, every SELECT/EXAMINE returns the compound OBJECTID response code + let want_objectid = self.is_objectid; + + // Refresh mailboxes + data.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Resolve the mailbox by its object identifiers (with fallback to the name) + let mailbox = arguments + .objectid + .as_ref() + .and_then( + |objectid| match (objectid.account_id, objectid.mailbox_id) { + (Some(account_id), Some(mailbox_id)) => { + data.get_mailbox_by_id(account_id.document_id(), mailbox_id.document_id()) + } + _ => None, + }, + ) + .or_else(|| data.get_mailbox_by_name(&arguments.mailbox_name)); + + if let Some(mailbox) = mailbox { + if !data + .check_mailbox_acl(mailbox.account_id, mailbox.mailbox_id, Acl::ReadItems) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You do not have the required permissions to read this mailbox.") + .code(ResponseCode::NoPerm) + .id(arguments.tag)); + } + + // Try obtaining the mailbox from the cache + let state = data + .fetch_messages(&mailbox, None) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .unwrap(); + + // Synchronize messages + let closed_previous = self.state.close_mailbox(); + let is_condstore = self.is_condstore || arguments.condstore; + + // Build new state + let is_rev2 = self.version.is_rev2(); + let is_utf8 = self.is_utf8; + let mailbox_state = data.mailbox_state(&mailbox).unwrap(); + let total_messages = state.total_messages; + let highest_modseq = if is_condstore { + HighestModSeq::new(state.modseq.to_modseq()).into() + } else { + None + }; + let mailbox = Arc::new(SelectedMailbox { + id: mailbox, + state: parking_lot::Mutex::new(state), + saved_search: parking_lot::Mutex::new(SavedSearch::None), + is_select, + is_condstore, + }); + + // Validate QRESYNC arguments + if let Some(qresync) = arguments.qresync { + if !self.is_qresync { + return Err(trc::ImapEvent::Error + .into_err() + .details("QRESYNC is not enabled.") + .id(arguments.tag)); + } + if self.is_uidonly && qresync.seq_match.is_some() { + return Err(trc::ImapEvent::Error + .into_err() + .details(concat!( + "The QRESYNC sequence matching parameter ", + "is not allowed once UIDONLY is enabled." + )) + .code(ResponseCode::UidRequired) + .ctx(trc::Key::Type, ResponseType::Bad) + .id(arguments.tag)); + } + if qresync.uid_validity == mailbox_state.uid_validity as u32 { + // Send flags for changed messages + data.fetch( + fetch::Arguments { + tag: "".into(), + sequence_set: qresync + .known_uids + .or_else(|| qresync.seq_match.map(|(_, s)| s)) + .unwrap_or(Sequence::Range { + start: 1.into(), + end: None, + }), + attributes: vec![fetch::Attribute::Flags], + changed_since: qresync.modseq.into(), + include_vanished: true, + }, + mailbox.clone(), + true, + true, + self.is_uidonly, + false, + self.is_utf8, + u32::MAX, + Instant::now(), + ) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + } + } + + trc::event!( + Imap(trc::ImapEvent::Select), + SpanId = self.session_id, + MailboxName = arguments.mailbox_name.clone(), + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Total = total_messages, + UidNext = mailbox_state.uid_next, + UidValidity = mailbox_state.uid_validity, + Elapsed = op_start.elapsed() + ); + + // Build response + let response = Response { + mailbox: ListItem::new(arguments.mailbox_name), + total_messages, + recent_messages: 0, + unseen_seq: 0, + uid_validity: mailbox_state.uid_validity as u32, + uid_next: mailbox_state.uid_next as u32, + closed_previous, + is_rev2, + is_utf8, + highest_modseq, + objectid: want_objectid.then(|| ObjectId { + mailbox_id: Some(Id::from(mailbox.id.mailbox_id)), + account_id: Some(Id::from(mailbox.id.account_id)), + ..Default::default() + }), + }; + + // Update state + self.state = State::Selected { data, mailbox }; + + self.write_bytes( + StatusResponse::completed(command) + .with_tag(arguments.tag) + .with_code(if is_select { + ResponseCode::ReadWrite + } else { + ResponseCode::ReadOnly + }) + .serialize(response.serialize()), + ) + .await + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::NonExistent) + .id(arguments.tag)) + } + } + + pub async fn handle_unselect(&mut self, request: Request) -> trc::Result<()> { + self.state.close_mailbox(); + self.state = State::Authenticated { + data: self.state.session_data(), + }; + self.write_bytes( + StatusResponse::completed(Command::Unselect) + .with_tag(request.tag) + .into_bytes(), + ) + .await + } +} diff --git a/crates/imap/src/op/status.rs b/crates/imap/src/op/status.rs new file mode 100644 index 0000000..a1a699f --- /dev/null +++ b/crates/imap/src/op/status.rs @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ToModSeq; +use crate::{ + core::{Mailbox, Session, SessionData}, + op::ImapContext, + spawn_op, +}; +use common::network::SessionStream; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use imap_proto::{ + Command, ResponseCode, StatusResponse, + parser::PushUnique, + protocol::{ + ObjectId, + status::{Status, StatusItem, StatusItemType}, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; +use trc::AddContext; +use types::{acl::Acl, id::Id, keyword::Keyword}; + +impl Session { + pub async fn handle_status(&mut self, requests: Vec>) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStatus)?; + + let is_utf8 = self.is_utf8; + + // Parse requests and activate OBJECTID+ if the OBJECTID attribute is requested + let mut parsed = Vec::with_capacity(requests.len()); + let mut activate = false; + for request in requests { + match request.parse_status(is_utf8) { + Ok(arguments) => { + if arguments.items.contains(&Status::ObjectId) { + activate = true; + } + parsed.push(Ok(arguments)); + } + Err(err) => parsed.push(Err(err)), + } + } + if activate && let Some(enabled) = self.activate_objectid() { + self.write_bytes(enabled).await?; + } + + let data = self.state.session_data(); + + spawn_op!(data, { + let mut did_sync = false; + + for request in parsed { + match request { + Ok(arguments) => { + let op_start = Instant::now(); + let synchronized = if did_sync { + Ok(()) + } else { + // Refresh mailboxes + data.synchronize_mailboxes(false) + .await + .imap_ctx(&arguments.tag, trc::location!()) + .map(|_| ()) + }; + + // Fetch status + let status = match synchronized { + Ok(()) => { + did_sync = true; + data.status(arguments.mailbox_name, &arguments.items) + .await + .imap_ctx(&arguments.tag, trc::location!()) + } + Err(err) => Err(err), + }; + + match status { + Ok(status) => { + trc::event!( + Imap(trc::ImapEvent::Status), + SpanId = data.session_id, + MailboxName = status.mailbox_name.clone(), + Details = arguments + .items + .iter() + .map(|c| trc::Value::from(format!("{c:?}"))) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + let mut buf = Vec::with_capacity(32); + status.serialize(&mut buf, is_utf8); + data.write_bytes( + StatusResponse::completed(Command::Status) + .with_tag(arguments.tag) + .serialize(buf), + ) + .await?; + } + Err(err) => data.write_error(err).await?, + } + } + Err(err) => data.write_error(err).await?, + } + } + + Ok(()) + }) + } +} + +impl SessionData { + pub async fn status(&self, mailbox_name: String, items: &[Status]) -> trc::Result { + // Get mailbox id + let mailbox = if let Some(mailbox) = self.get_mailbox_by_name(&mailbox_name) { + mailbox + } else { + // Some IMAP clients will try to get the status of a mailbox with the NoSelect flag + return if mailbox_name == self.server.core.email.shared_folder + || mailbox_name + .split_once('/') + .is_some_and(|(base_name, path)| { + base_name == self.server.core.email.shared_folder && !path.contains('/') + }) + { + Ok(StatusItem { + mailbox_name, + items: items + .iter() + .map(|item| { + ( + *item, + match item { + Status::Messages + | Status::Size + | Status::Unseen + | Status::Recent + | Status::Deleted + | Status::HighestModSeq + | Status::DeletedStorage => StatusItemType::Number(0), + Status::UidNext | Status::UidValidity => { + StatusItemType::Number(1) + } + Status::ObjectId => { + StatusItemType::ObjectId(ObjectId::default()) + } + }, + ) + }) + .collect(), + }) + } else { + Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::NonExistent)) + }; + }; + + if !self + .check_mailbox_acl(mailbox.account_id, mailbox.mailbox_id, Acl::ReadItems) + .await + .caused_by(trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You do not have the required permissions to read this mailbox.") + .code(ResponseCode::NoPerm)); + } + + // Make sure all requested fields are up to date + let mut items_update = Vec::with_capacity(items.len()); + let mut items_response = Vec::with_capacity(items.len()); + + for account in self.mailboxes.lock().iter_mut() { + if account.account_id == mailbox.account_id { + let mailbox_state = + if let Some(mailbox_state) = account.mailbox_state.get(&mailbox.mailbox_id) { + mailbox_state + } else { + continue; + }; + for item in items { + match item { + Status::Messages => { + items_response.push(( + *item, + StatusItemType::Number(mailbox_state.total_messages), + )); + } + Status::UidNext => { + items_response + .push((*item, StatusItemType::Number(mailbox_state.uid_next))); + } + Status::UidValidity => { + items_response + .push((*item, StatusItemType::Number(mailbox_state.uid_validity))); + } + Status::Unseen => { + items_response + .push((*item, StatusItemType::Number(mailbox_state.total_unseen))); + } + Status::Deleted => { + items_response + .push((*item, StatusItemType::Number(mailbox_state.total_deleted))); + } + Status::DeletedStorage => { + if let Some(value) = mailbox_state.total_deleted_storage { + items_response.push((*item, StatusItemType::Number(value))); + } else { + items_update.push_unique(*item); + } + } + Status::Size => { + if let Some(value) = mailbox_state.size { + items_response.push((*item, StatusItemType::Number(value))); + } else { + items_update.push_unique(*item); + } + } + Status::HighestModSeq => { + items_response.push(( + *item, + StatusItemType::Number(account.last_change_id.to_modseq()), + )); + } + Status::ObjectId => { + items_response.push(( + *item, + StatusItemType::ObjectId(ObjectId { + mailbox_id: Some(Id::from(mailbox.mailbox_id)), + account_id: Some(Id::from(mailbox.account_id)), + ..Default::default() + }), + )); + } + Status::Recent => { + items_response.push((*item, StatusItemType::Number(0))); + } + } + } + break; + } + } + + if !items_update.is_empty() { + // Retrieve latest values + let mut values_update = Vec::with_capacity(items_update.len()); + + let cache = self + .server + .get_cached_messages(mailbox.account_id) + .await + .caused_by(trc::location!())?; + + for item in items_update { + let result = match item { + Status::DeletedStorage => cache + .in_mailbox_with_keyword(mailbox.mailbox_id, &Keyword::Deleted) + .map(|x| x.size) + .sum::() as u64, + Status::Size => cache + .in_mailbox(mailbox.mailbox_id) + .map(|x| x.size) + .sum::() as u64, + _ => { + unreachable!() + } + }; + + items_response.push((item, StatusItemType::Number(result))); + values_update.push((item, result)); + } + + // Update cache + for account in self.mailboxes.lock().iter_mut() { + if account.account_id == mailbox.account_id { + let mailbox_state = account + .mailbox_state + .entry(mailbox.mailbox_id) + .or_insert_with(Mailbox::default); + + for (item, value) in values_update { + match item { + Status::DeletedStorage => { + mailbox_state.total_deleted_storage = value.into() + } + Status::Size => mailbox_state.size = value.into(), + Status::Recent => { + items_response + .iter_mut() + .find(|(i, _)| *i == Status::Recent) + .unwrap() + .1 = StatusItemType::Number(0); + } + _ => { + unreachable!() + } + } + } + + break; + } + } + } + + // Generate response + Ok(StatusItem { + mailbox_name, + items: items_response, + }) + } +} diff --git a/crates/imap/src/op/store.rs b/crates/imap/src/op/store.rs new file mode 100644 index 0000000..8a33843 --- /dev/null +++ b/crates/imap/src/op/store.rs @@ -0,0 +1,472 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{FromModSeq, ImapContext}; +use crate::{ + core::{SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use ahash::AHashSet; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::{ + mailbox::TRASH_ID, + message::{ingest::EmailIngest, metadata::MessageData}, +}; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, + protocol::{ + Flag, ImapResponse, + fetch::{DataItem, FetchItem}, + store::{Arguments, Operation, Response}, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use store::{ + ValueKey, + query::log::{Change, Query}, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + keyword::Keyword, +}; + +impl Session { + pub async fn handle_store( + &mut self, + request: Request, + is_uid: bool, + spawn: bool, + ) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapStore)?; + + let op_start = Instant::now(); + let arguments = request.parse_store()?; + let (data, mailbox) = self.state.select_data(); + let is_condstore = self.is_condstore || mailbox.is_condstore; + let is_utf8 = self.is_utf8; + let is_uidonly = self.is_uidonly; + let message_limit = self.server.core.imap.max_messages_per_command; + + if spawn { + spawn_op!(data, { + let response = data + .store( + arguments, + mailbox, + is_uid, + is_condstore, + is_utf8, + is_uidonly, + message_limit, + op_start, + ) + .await?; + + data.write_bytes(response).await + }) + } else { + let response = data + .store( + arguments, + mailbox, + is_uid, + is_condstore, + is_utf8, + is_uidonly, + message_limit, + op_start, + ) + .await?; + + data.write_bytes(response).await + } + } +} + +impl SessionData { + #[allow(clippy::too_many_arguments)] + pub async fn store( + &self, + arguments: Arguments, + mailbox: Arc, + is_uid: bool, + is_condstore: bool, + is_utf8: bool, + is_uidonly: bool, + message_limit: u32, + op_start: Instant, + ) -> trc::Result> { + // Resync messages if needed + let account_id = mailbox.id.account_id; + self.synchronize_messages(&mailbox) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + // Convert IMAP ids to JMAP ids. + let mut ids = mailbox + .sequence_to_ids(&arguments.sequence_set, is_uid) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + if ids.is_empty() { + return Ok(StatusResponse::completed(Command::Store(is_uid)) + .with_tag(arguments.tag) + .into_bytes()); + } + + // Verify that the user can modify messages in this mailbox. + if !self + .check_mailbox_acl( + mailbox.id.account_id, + mailbox.id.mailbox_id, + Acl::ModifyItems, + ) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details( + "You do not have the required permissions to modify messages in this mailbox.", + ) + .id(arguments.tag) + .code(ResponseCode::NoPerm) + .caused_by(trc::location!())); + } + + // Filter out unchanged since ids + let mut response_code = None; + let mut unchanged_failed = false; + if let Some(unchanged_since) = arguments.unchanged_since { + // Obtain changes since the modseq. + let changelog = self + .server + .store() + .changes( + account_id, + SyncCollection::Email.into(), + Query::from_modseq(unchanged_since), + ) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + let mut modified = mailbox + .sequence_expand_missing(&arguments.sequence_set, is_uid) + .await; + + // Add all IDs that changed in this mailbox + for (id, is_delete) in changelog.changes.into_iter().filter_map(|change| { + change.item_id().map(|id| { + ( + (id & u32::MAX as u64) as u32, + matches!(change, Change::DeleteItem(_)), + ) + }) + }) { + if let Some(imap_id) = ids.remove(&id) { + if is_uid { + modified.push(imap_id.uid); + } else { + modified.push(imap_id.seqnum); + if is_delete { + unchanged_failed = true; + } + } + } + } + + if !modified.is_empty() { + modified.sort_unstable(); + response_code = ResponseCode::Modified { ids: modified }.into(); + } + } + + // Build response + let mut response = if !unchanged_failed { + StatusResponse::completed(Command::Store(is_uid)) + } else { + StatusResponse::no("Some of the messages no longer exist.") + } + .with_tag(arguments.tag); + if let Some(response_code) = response_code { + response = response.with_code(response_code) + } + if ids.is_empty() { + trc::event!( + Imap(trc::ImapEvent::Store), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Type = format!("{:?}", arguments.operation), + Details = arguments + .keywords + .iter() + .map(|c| trc::Value::from(format!("{c:?}"))) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + return Ok(response.into_bytes()); + } + // RFC 9738 requires the highest UIDs to be processed first when truncating. + let message_limit = message_limit as usize; + let mut untagged = Vec::new(); + if ids.len() > message_limit { + let mut uids = ids.values().map(|imap_id| imap_id.uid).collect::>(); + let cutoff = uids.len() - message_limit; + let lowest_uid = *uids.select_nth_unstable(cutoff).1; + ids.retain(|_, imap_id| imap_id.uid >= lowest_uid); + + let code = ResponseCode::MessageLimit { + limit: message_limit as u32, + uid: lowest_uid.into(), + }; + if response.code.is_none() { + response = response.with_code(code); + } else { + untagged = StatusResponse::ok("Some messages were not modified.") + .with_code(code) + .into_bytes(); + } + } + + let mut items = Response { + is_utf8, + items: Vec::with_capacity(ids.len()), + }; + + // Process each change + let set_keywords = arguments + .keywords + .iter() + .map(|k| Keyword::from(k.clone())) + .collect::>(); + let mut changed_mailboxes = AHashSet::new(); + let mut batch = BatchBuilder::new(); + + for (id, imap_id) in &ids { + // Obtain message data + let data_ = if let Some(data) = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + *id, + )) + .await + .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())? + { + data + } else { + continue; + }; + + // Deserialize + let data = data_ + .to_unarchived::() + .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())?; + let mut new_data = data.inner.to_builder(); + + // Apply changes + let mut seen_changed = false; + match arguments.operation { + Operation::Set => { + seen_changed = set_keywords.contains(&Keyword::Seen) + != new_data.has_keyword(&Keyword::Seen); + new_data.set_keywords(set_keywords.clone()); + } + Operation::Add => { + for keyword in &set_keywords { + if new_data.add_keyword(keyword.clone()) && keyword == &Keyword::Seen { + seen_changed = true; + } + } + } + Operation::Clear => { + for keyword in &set_keywords { + if new_data.remove_keyword(keyword) && keyword == &Keyword::Seen { + seen_changed = true; + } + } + } + } + + if !new_data.has_keyword_changes(data.inner) { + continue; + } + + // Train spam filter + let mut train_spam = None; + for keyword in new_data.added_keywords(data.inner) { + if keyword == &Keyword::Junk { + train_spam = Some(true); + break; + } else if keyword == &Keyword::NotJunk && !data.inner.has_mailbox_id(TRASH_ID) { + // Only train as ham if not in Trash (Apple likes to add NotJunk to trashed items, which would be spammy) + train_spam = Some(false); + break; + } + } + if train_spam.is_none() { + for keyword in new_data.removed_keywords(data.inner) { + if keyword == &Keyword::Junk { + if !data.inner.has_mailbox_id(TRASH_ID) { + train_spam = Some(false); + } + break; + } + } + } + + // Convert keywords to flags + let flags = if !arguments.is_silent { + new_data + .keywords + .iter() + .cloned() + .map(Flag::from) + .collect::>() + } else { + vec![] + }; + + // Set all current mailboxes as changed if the Seen tag changed + if seen_changed { + for mailbox_id in new_data.mailboxes.iter() { + changed_mailboxes.insert(mailbox_id.mailbox_id); + } + } + + // Write changes + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(*id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())?; + + // Add spam train task + if let Some(learn_spam) = train_spam { + self.server + .add_account_spam_sample( + &mut batch, + account_id, + *id, + learn_spam, + self.session_id, + ) + .await + .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())?; + } + + // Set commit point + batch.commit_point(); + + // Add item to response + if !arguments.is_silent { + let mut data_items = vec![DataItem::Flags { flags }]; + if is_uid { + data_items.push(DataItem::Uid { uid: imap_id.uid }); + } + items.items.push(FetchItem { + id: if is_uidonly { + imap_id.uid + } else { + imap_id.seqnum + }, + is_uidonly, + items: data_items, + }); + } else if is_condstore { + items.items.push(FetchItem { + id: if is_uidonly { + imap_id.uid + } else { + imap_id.seqnum + }, + is_uidonly, + items: if is_uid { + vec![DataItem::Uid { uid: imap_id.uid }] + } else { + vec![] + }, + }); + } + } + + // Log mailbox changes + if !changed_mailboxes.is_empty() { + for parent_id in changed_mailboxes { + batch.log_container_property_change(SyncCollection::Email, parent_id); + } + } + + // Write changes + if !batch.is_empty() { + match self + .server + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(mailbox.id.account_id)) + .caused_by(trc::location!()) + { + Ok(change_id) => { + if is_condstore { + let modseq = change_id + 1; + for item in items.items.iter_mut() { + item.items.push(DataItem::ModSeq { modseq }); + } + } + } + Err(err) if err.is_assertion_failure() => { + items.items.clear(); + response.rtype = ResponseType::No; + response.message = "Some messages were modified by another process.".into(); + } + Err(err) => { + return Err(err.id(response.tag.unwrap())); + } + } + } + + trc::event!( + Imap(trc::ImapEvent::Store), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + DocumentId = ids + .iter() + .map(|id| trc::Value::from(*id.0)) + .collect::>(), + Type = format!("{:?}", arguments.operation), + Details = arguments + .keywords + .iter() + .map(|c| trc::Value::from(format!("{c:?}"))) + .collect::>(), + Elapsed = op_start.elapsed() + ); + + // Send response + let items = items.serialize(); + Ok(response.serialize(if untagged.is_empty() { + items + } else { + untagged.extend_from_slice(&items); + untagged + })) + } +} diff --git a/crates/imap/src/op/subscribe.rs b/crates/imap/src/op/subscribe.rs new file mode 100644 index 0000000..22e1df6 --- /dev/null +++ b/crates/imap/src/op/subscribe.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ImapContext; +use crate::{ + core::{Session, SessionData}, + spawn_op, +}; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use imap_proto::{Command, ResponseCode, StatusResponse, receiver::Request}; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use types::collection::Collection; + +impl Session { + pub async fn handle_subscribe( + &mut self, + request: Request, + is_subscribe: bool, + ) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapSubscribe)?; + + let op_start = Instant::now(); + let arguments = request.parse_subscribe(self.is_utf8)?; + let data = self.state.session_data(); + + spawn_op!(data, { + let response = data + .subscribe_folder( + arguments.tag, + arguments.mailbox_name, + is_subscribe, + op_start, + ) + .await?; + + data.write_bytes(response.into_bytes()).await + }) + } +} + +impl SessionData { + pub async fn subscribe_folder( + &self, + tag: String, + mailbox_name: String, + subscribe: bool, + op_start: Instant, + ) -> trc::Result { + // Refresh mailboxes + self.synchronize_mailboxes(false) + .await + .imap_ctx(&tag, trc::location!())?; + + // Validate mailbox + let (account_id, mailbox_id) = match self.get_mailbox_by_name(&mailbox_name) { + Some(mailbox) => (mailbox.account_id, mailbox.mailbox_id), + None => { + return Err(trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::NonExistent) + .id(tag) + .caused_by(trc::location!())); + } + }; + + // Verify if mailbox is already subscribed/unsubscribed + for account in self.mailboxes.lock().iter_mut() { + if account.account_id == account_id { + if let Some(mailbox) = account.mailbox_state.get(&mailbox_id) + && mailbox.is_subscribed == subscribe + { + return Err(trc::ImapEvent::Error + .into_err() + .details(if subscribe { + "Mailbox is already subscribed." + } else { + "Mailbox is already unsubscribed." + }) + .id(tag)); + } + break; + } + } + + // Obtain mailbox + let mailbox_ = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + mailbox_id, + )) + .await + .imap_ctx(&tag, trc::location!())? + .ok_or_else(|| { + trc::ImapEvent::Error + .into_err() + .details("Mailbox does not exist.") + .code(ResponseCode::NonExistent) + .id(tag.clone()) + .caused_by(trc::location!()) + })?; + let mailbox = mailbox_ + .to_unarchived::() + .imap_ctx(&tag, trc::location!())?; + + if (subscribe && !mailbox.inner.is_subscribed(self.account_id)) + || (!subscribe && mailbox.inner.is_subscribed(self.account_id)) + { + // Build batch + let mut new_mailbox = mailbox.deserialize().imap_ctx(&tag, trc::location!())?; + if subscribe { + new_mailbox.subscribers.push(self.account_id); + } else { + new_mailbox.remove_subscriber(self.account_id); + } + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id) + .custom( + ObjectIndexBuilder::new() + .with_current(mailbox) + .with_changes(new_mailbox), + ) + .imap_ctx(&tag, trc::location!())?; + self.server + .commit_batch(batch) + .await + .imap_ctx(&tag, trc::location!())?; + + // Update mailbox cache + for account in self.mailboxes.lock().iter_mut() { + if account.account_id == account_id { + if let Some(mailbox) = account.mailbox_state.get_mut(&mailbox_id) { + mailbox.is_subscribed = subscribe; + } + break; + } + } + } + + trc::event!( + Imap(if subscribe { + trc::ImapEvent::Subscribe + } else { + trc::ImapEvent::Unsubscribe + }), + SpanId = self.session_id, + AccountId = account_id, + MailboxId = mailbox_id, + MailboxName = mailbox_name, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok(if subscribe { + "Mailbox subscribed." + } else { + "Mailbox unsubscribed." + }) + .with_tag(tag)) + } +} diff --git a/crates/imap/src/op/thread.rs b/crates/imap/src/op/thread.rs new file mode 100644 index 0000000..94dc836 --- /dev/null +++ b/crates/imap/src/op/thread.rs @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{SelectedMailbox, Session, SessionData}, + spawn_op, +}; +use ahash::AHashMap; +use common::network::SessionStream; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use imap_proto::{ + Command, StatusResponse, + protocol::{ + ImapResponse, + thread::{Arguments, Response}, + }, + receiver::Request, +}; +use registry::schema::enums::Permission; +use std::{sync::Arc, time::Instant}; +use trc::AddContext; + +impl Session { + pub async fn handle_thread( + &mut self, + request: Request, + is_uid: bool, + ) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapThread)?; + + let op_start = Instant::now(); + let command = request.command; + let mut arguments = request.parse_thread()?; + let (data, mailbox) = self.state.mailbox_state(); + + spawn_op!(data, { + let tag = std::mem::take(&mut arguments.tag); + + match data.thread(arguments, mailbox, is_uid, op_start).await { + Ok(response) => { + data.write_bytes( + StatusResponse::completed(command) + .with_tag(tag) + .serialize(response.serialize()), + ) + .await + } + Err(err) => Err(err.id(tag)), + } + }) + } +} + +impl SessionData { + pub async fn thread( + &self, + arguments: Arguments, + mailbox: Arc, + is_uid: bool, + op_start: Instant, + ) -> trc::Result { + // Run query + let (result_set, _) = self + .query(arguments.filter, vec![], &mailbox, &None) + .await?; + + // Synchronize mailbox + if !result_set.is_empty() { + self.synchronize_messages(&mailbox) + .await + .caused_by(trc::location!())?; + } else { + return Ok(Response { + is_uid, + threads: vec![], + }); + } + + // Lock the cache + let cache = self + .server + .get_cached_messages(mailbox.id.account_id) + .await + .caused_by(trc::location!())?; + + // Group messages by thread + let mut threads: AHashMap> = AHashMap::new(); + let state = mailbox.state.lock(); + for document_id in result_set { + if let Some(item) = cache.email_by_id(&document_id) + && let Some((imap_id, _)) = state.map_result_id(document_id, is_uid) + { + threads.entry(item.thread_id).or_default().push(imap_id); + } + } + + let mut threads = threads + .into_iter() + .map(|(_, mut messages)| { + messages.sort_unstable(); + messages + }) + .collect::>(); + threads.sort_unstable(); + + trc::event!( + Imap(trc::ImapEvent::Thread), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Total = threads.len(), + Elapsed = op_start.elapsed() + ); + + // Build response + Ok(Response { is_uid, threads }) + } +} diff --git a/crates/imap/src/op/uidbatches.rs b/crates/imap/src/op/uidbatches.rs new file mode 100644 index 0000000..f80245b --- /dev/null +++ b/crates/imap/src/op/uidbatches.rs @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ImapContext; +use crate::core::Session; +use common::network::SessionStream; +use imap_proto::{ + Command, ResponseCode, ResponseType, StatusResponse, protocol::uidbatches, receiver::Request, +}; +use registry::schema::enums::Permission; +use std::time::Instant; + +impl Session { + pub async fn handle_uidbatches(&mut self, request: Request) -> trc::Result<()> { + // Validate access + self.assert_has_permission(Permission::ImapSearch)?; + + let op_start = Instant::now(); + let arguments = request.parse_uidbatches()?; + let (data, mailbox) = self.state.select_data(); + + let min_batch_size = self.server.core.imap.min_uid_batch_size; + if arguments.batch_size < min_batch_size { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!("Minimum batch size is {min_batch_size}.")) + .code(ResponseCode::TooFew) + .id(arguments.tag)); + } + + if let Some((from, to)) = arguments.batch_range + && from > to + { + return Err(trc::ImapEvent::Error + .into_err() + .details("Batch ranges must be ordered from lowest to highest.") + .code(ResponseCode::ClientBug) + .ctx(trc::Key::Type, ResponseType::Bad) + .id(arguments.tag)); + } + + // Reject oversized requests before doing any work on their behalf + let max_uid_batches = self.server.core.imap.max_uid_batches; + if arguments + .batch_range + .is_some_and(|(from, to)| to - from + 1 > max_uid_batches) + { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!( + "A single UIDBATCHES response is limited to {max_uid_batches} ranges." + )) + .code(ResponseCode::TooMany) + .id(arguments.tag)); + } + + // Resynchronize so that batches reflect the current mailbox contents + data.synchronize_messages(&mailbox) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + + let uids = mailbox.uids_descending(); + let batch_size = arguments.batch_size as usize; + let total_batches = uids.len().div_ceil(batch_size); + + if arguments.batch_range.is_none() && total_batches > max_uid_batches as usize { + return Err(trc::ImapEvent::Error + .into_err() + .details(format!( + "A single UIDBATCHES response is limited to {max_uid_batches} ranges." + )) + .code(ResponseCode::TooMany) + .id(arguments.tag)); + } + + // Batch ranges tile the whole UID space, so each range starts right below + // the previous one and the oldest batch always reaches down to UID 1. + let (first, last) = match arguments.batch_range { + Some((from, to)) => ( + (from as usize - 1).min(total_batches), + (to as usize).min(total_batches), + ), + None => (0, total_batches), + }; + let mut ranges = Vec::with_capacity(last.saturating_sub(first)); + let mut high = uids.first().copied().unwrap_or(0); + for batch in 0..last { + let end = ((batch + 1) * batch_size).min(uids.len()); + let low = if end == uids.len() { 1 } else { uids[end - 1] }; + if batch >= first { + ranges.push((high, low)); + } + high = low.saturating_sub(1); + } + + trc::event!( + Imap(trc::ImapEvent::UidBatches), + SpanId = self.session_id, + AccountId = mailbox.id.account_id, + MailboxId = mailbox.id.mailbox_id, + Limit = arguments.batch_size, + Total = ranges.len(), + Elapsed = op_start.elapsed() + ); + + let response = uidbatches::Response { ranges }.serialize(&arguments.tag); + self.write_bytes( + StatusResponse::completed(Command::UidBatches) + .with_tag(arguments.tag) + .serialize(response), + ) + .await + } +} diff --git a/crates/jmap-proto/Cargo.toml b/crates/jmap-proto/Cargo.toml new file mode 100644 index 0000000..8041c3a --- /dev/null +++ b/crates/jmap-proto/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "jmap_proto" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +types = { path = "../types" } +trc = { path = "../trc" } +registry = { path = "../registry" } +jmap-tools = { version = "0.1" } +calcard = { version = "0.3" } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +serde = { version = "1.0", features = ["derive"]} +ahash = { version = "0.8.12", features = ["serde"] } +serde_json = { version = "1.0", features = ["raw_value"] } +hashify = "0.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = { version = "0.10.0", features = ["rkyv", "serde"] } + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/jmap-proto/src/error/method.rs b/crates/jmap-proto/src/error/method.rs new file mode 100644 index 0000000..8791165 --- /dev/null +++ b/crates/jmap-proto/src/error/method.rs @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::Serialize; +use serde::ser::SerializeMap; +use std::fmt::Display; + +#[derive(Debug)] +pub enum MethodError { + InvalidArguments(String), + RequestTooLarge, + StateMismatch, + AnchorNotFound, + UnsupportedFilter(String), + UnsupportedSort(String), + ServerFail(String), + UnknownMethod(String), + ServerUnavailable, + ServerPartialFail, + InvalidResultReference(String), + Forbidden(String), + AccountNotFound, + AccountNotSupportedByMethod, + AccountReadOnly, + NotFound, + CannotCalculateChanges, + UnknownDataType, +} + +#[derive(Debug)] +pub struct MethodErrorWrapper(trc::Error); + +impl From for MethodErrorWrapper { + fn from(value: trc::Error) -> Self { + MethodErrorWrapper(value) + } +} + +impl Display for MethodError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + MethodError::InvalidArguments(err) => write!(f, "Invalid arguments: {}", err), + MethodError::RequestTooLarge => write!(f, "Request too large"), + MethodError::StateMismatch => write!(f, "State mismatch"), + MethodError::AnchorNotFound => write!(f, "Anchor not found"), + MethodError::UnsupportedFilter(err) => write!(f, "Unsupported filter: {}", err), + MethodError::UnsupportedSort(err) => write!(f, "Unsupported sort: {}", err), + MethodError::ServerFail(err) => write!(f, "Server error: {}", err), + MethodError::UnknownMethod(err) => write!(f, "Unknown method: {}", err), + MethodError::ServerUnavailable => write!(f, "Server unavailable"), + MethodError::ServerPartialFail => write!(f, "Server partial fail"), + MethodError::InvalidResultReference(err) => { + write!(f, "Invalid result reference: {}", err) + } + MethodError::Forbidden(err) => write!(f, "Forbidden: {}", err), + MethodError::AccountNotFound => write!(f, "Account not found"), + MethodError::AccountNotSupportedByMethod => { + write!(f, "Account not supported by method") + } + MethodError::AccountReadOnly => write!(f, "Account read only"), + MethodError::NotFound => write!(f, "Not found"), + MethodError::UnknownDataType => write!(f, "Unknown data type"), + MethodError::CannotCalculateChanges => write!(f, "Cannot calculate changes"), + } + } +} + +impl Serialize for MethodErrorWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(2.into())?; + + let description = self.0.value(trc::Key::Details).and_then(|v| v.as_str()); + + let (error_type, description) = match self.0.as_ref() { + trc::EventType::Jmap(cause) => match cause { + trc::JmapEvent::InvalidArguments => { + ("invalidArguments", description.unwrap_or_default()) + } + trc::JmapEvent::RequestTooLarge => ( + "requestTooLarge", + concat!( + "The number of ids requested by the client exceeds the maximum number ", + "the server is willing to process in a single method call." + ), + ), + trc::JmapEvent::StateMismatch => ( + "stateMismatch", + concat!( + "An \"ifInState\" argument was supplied, but ", + "it does not match the current state." + ), + ), + trc::JmapEvent::AnchorNotFound => ( + "anchorNotFound", + concat!( + "An anchor argument was supplied, but it ", + "cannot be found in the results of the query." + ), + ), + trc::JmapEvent::UnsupportedFilter => { + ("unsupportedFilter", description.unwrap_or_default()) + } + trc::JmapEvent::UnsupportedSort => { + ("unsupportedSort", description.unwrap_or_default()) + } + trc::JmapEvent::NotFound => ("serverPartialFail", { + concat!( + "One or more items are no longer available on the ", + "server, please try again." + ) + }), + trc::JmapEvent::UnknownMethod => ("unknownMethod", description.unwrap_or_default()), + trc::JmapEvent::InvalidResultReference => { + ("invalidResultReference", description.unwrap_or_default()) + } + trc::JmapEvent::Forbidden => ("forbidden", description.unwrap_or_default()), + trc::JmapEvent::AccountNotFound => ( + "accountNotFound", + "The accountId does not correspond to a valid account", + ), + trc::JmapEvent::AccountNotSupportedByMethod => ( + "accountNotSupportedByMethod", + concat!( + "The accountId given corresponds to a valid account, ", + "but the account does not support this method or data type." + ), + ), + trc::JmapEvent::AccountReadOnly => ( + "accountReadOnly", + "This method modifies state, but the account is read-only.", + ), + trc::JmapEvent::UnknownDataType => ( + "unknownDataType", + concat!( + "The server does not recognise this data type, ", + "or the capability to enable it is not present ", + "in the current Request Object." + ), + ), + trc::JmapEvent::CannotCalculateChanges => ( + "cannotCalculateChanges", + concat!( + "The server cannot calculate the changes ", + "between the old and new states." + ), + ), + trc::JmapEvent::UnknownCapability + | trc::JmapEvent::NotJson + | trc::JmapEvent::NotRequest => ( + "serverUnavailable", + concat!( + "This server is temporarily unavailable. ", + "Attempting this same operation later may succeed." + ), + ), + _ => ( + "serverUnavailable", + "This server is temporarily unavailable.", + ), + }, + _ => ( + "serverUnavailable", + concat!( + "This server is temporarily unavailable. ", + "Attempting this same operation later may succeed." + ), + ), + }; + + map.serialize_entry("type", error_type)?; + if !description.is_empty() { + map.serialize_entry("description", description)?; + } + map.end() + } +} diff --git a/crates/jmap-proto/src/error/mod.rs b/crates/jmap-proto/src/error/mod.rs new file mode 100644 index 0000000..e89561c --- /dev/null +++ b/crates/jmap-proto/src/error/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod method; +pub mod request; +pub mod set; diff --git a/crates/jmap-proto/src/error/request.rs b/crates/jmap-proto/src/error/request.rs new file mode 100644 index 0000000..9474bf4 --- /dev/null +++ b/crates/jmap-proto/src/error/request.rs @@ -0,0 +1,425 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, fmt::Display}; + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +pub enum RequestLimitError { + #[serde(rename = "maxSizeRequest")] + SizeRequest, + #[serde(rename = "maxSizeUpload")] + SizeUpload, + #[serde(rename = "maxCallsInRequest")] + CallsIn, + #[serde(rename = "maxConcurrentRequests")] + ConcurrentRequest, + #[serde(rename = "maxConcurrentUpload")] + ConcurrentUpload, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub enum RequestErrorType { + #[serde(rename = "urn:ietf:params:jmap:error:unknownCapability")] + UnknownCapability, + #[serde(rename = "urn:ietf:params:jmap:error:notJSON")] + NotJSON, + #[serde(rename = "urn:ietf:params:jmap:error:notRequest")] + NotRequest, + #[serde(rename = "urn:ietf:params:jmap:error:limit")] + Limit, + #[serde(rename = "about:blank")] + Other, +} + +#[derive(Debug, Clone)] +pub struct RateLimitPolicy { + pub name: &'static str, + pub limit: u64, + pub remaining: u64, + pub window: Option, + pub reset: Option, + pub unit: RateLimitUnit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitUnit { + Requests, + ContentBytes, + ConcurrentRequests, +} + +impl RateLimitUnit { + pub fn as_str(self) -> &'static str { + match self { + RateLimitUnit::Requests => "requests", + RateLimitUnit::ContentBytes => "content-bytes", + RateLimitUnit::ConcurrentRequests => "concurrent-requests", + } + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct RequestError<'x> { + #[serde(rename = "type")] + pub p_type: RequestErrorType, + pub status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + pub detail: Cow<'x, str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip)] + pub rate_limit: Vec, + #[serde(skip)] + pub retry_after: Option, +} + +impl<'x> RequestError<'x> { + pub fn blank( + status: u16, + title: impl Into>, + detail: impl Into>, + ) -> Self { + RequestError { + p_type: RequestErrorType::Other, + status, + title: Some(title.into()), + detail: detail.into(), + limit: None, + rate_limit: Vec::new(), + retry_after: None, + } + } + + pub fn with_rate_limit(mut self, policy: RateLimitPolicy) -> Self { + if let Some(reset) = policy.reset + && self.retry_after.is_none_or(|r| reset > r) + { + self.retry_after = Some(reset); + } + self.rate_limit.push(policy); + self + } + + pub fn with_retry_after(mut self, seconds: u64) -> Self { + if self.retry_after.is_none_or(|r| seconds > r) { + self.retry_after = Some(seconds); + } + self + } + + pub fn internal_server_error() -> Self { + RequestError::blank( + 500, + "Internal Server Error", + concat!( + "There was a problem while processing your request. ", + "Please contact the system administrator if this problem persists." + ), + ) + } + + pub fn unavailable() -> Self { + RequestError::blank( + 503, + "Temporarily Unavailable", + concat!( + "There was a temporary problem while processing your request. ", + "Please try again in a few moments." + ), + ) + } + + pub fn invalid_parameters() -> Self { + RequestError::blank( + 400, + "Invalid Parameters", + "One or multiple parameters could not be parsed.", + ) + } + + pub fn forbidden() -> Self { + RequestError::blank( + 403, + "Forbidden", + "You do not have enough permissions to access this resource.", + ) + } + + pub fn over_blob_quota(max_files: usize, max_bytes: usize) -> Self { + RequestError::blank( + 429, + "Quota exceeded", + format!( + "You have exceeded the blob upload quota of {} files or {} bytes.", + max_files, max_bytes + ), + ) + } + + pub fn over_quota() -> Self { + RequestError::blank( + 403, + "Quota exceeded", + "You have exceeded your account quota.", + ) + } + + pub fn tenant_over_quota() -> Self { + RequestError::blank( + 403, + "Tenant quota exceeded", + "Your organization has exceeded its quota.", + ) + } + + pub fn too_many_requests() -> Self { + RequestError::blank( + 429, + "Too Many Requests", + "Your request has been rate limited. Please try again in a few seconds.", + ) + } + + pub fn too_many_auth_attempts() -> Self { + RequestError::blank( + 429, + "Too Many Authentication Attempts", + "Your request has been rate limited. Please try again in a few minutes.", + ) + } + + pub fn limit(limit_type: RequestLimitError) -> Self { + RequestError { + p_type: RequestErrorType::Limit, + status: 400, + title: None, + detail: match limit_type { + RequestLimitError::SizeRequest => concat!( + "The request is larger than the server ", + "is willing to process." + ), + RequestLimitError::SizeUpload => concat!( + "The uploaded file is larger than the server ", + "is willing to process." + ), + RequestLimitError::CallsIn => concat!( + "The request exceeds the maximum number ", + "of calls in a single request." + ), + RequestLimitError::ConcurrentRequest => concat!( + "The request exceeds the maximum number ", + "of concurrent requests." + ), + RequestLimitError::ConcurrentUpload => concat!( + "The request exceeds the maximum number ", + "of concurrent uploads." + ), + } + .into(), + limit: Some(limit_type), + rate_limit: Vec::new(), + retry_after: None, + } + } + + pub fn not_found() -> Self { + RequestError::blank( + 404, + "Not Found", + "The requested resource does not exist on this server.", + ) + } + + pub fn unauthorized() -> Self { + RequestError::blank(401, "Unauthorized", "You have to authenticate first.") + } + + pub fn unknown_capability(capability: &'_ str) -> RequestError<'_> { + RequestError { + p_type: RequestErrorType::UnknownCapability, + limit: None, + title: None, + status: 400, + detail: format!( + concat!( + "The Request object used capability ", + "'{}', which is not supported", + "by this server." + ), + capability + ) + .into(), + rate_limit: Vec::new(), + retry_after: None, + } + } + + pub fn not_json(detail: &'_ str) -> RequestError<'_> { + RequestError { + p_type: RequestErrorType::NotJSON, + limit: None, + title: None, + status: 400, + detail: format!("Failed to parse JSON: {detail}").into(), + rate_limit: Vec::new(), + retry_after: None, + } + } + + pub fn not_request(detail: impl Into>) -> RequestError<'x> { + RequestError { + p_type: RequestErrorType::NotRequest, + limit: None, + title: None, + status: 400, + detail: detail.into(), + rate_limit: Vec::new(), + retry_after: None, + } + } +} + +impl RateLimitPolicy { + pub fn new(name: &'static str, limit: u64) -> Self { + RateLimitPolicy { + name, + limit, + remaining: 0, + window: None, + reset: None, + unit: RateLimitUnit::Requests, + } + } + + pub fn with_window(mut self, window: u64) -> Self { + self.window = Some(window); + self + } + + pub fn with_reset(mut self, reset: u64) -> Self { + self.reset = Some(reset); + self + } + + pub fn with_remaining(mut self, remaining: u64) -> Self { + self.remaining = remaining; + self + } + + pub fn with_unit(mut self, unit: RateLimitUnit) -> Self { + self.unit = unit; + self + } + + pub fn fmt_policy(&self, out: &mut String) { + use std::fmt::Write; + let _ = write!(out, "\"{}\";q={}", self.name, self.limit); + if let Some(window) = self.window { + let _ = write!(out, ";w={window}"); + } + if !matches!(self.unit, RateLimitUnit::Requests) { + let _ = write!(out, ";qu=\"{}\"", self.unit.as_str()); + } + } + + pub fn fmt_state(&self, out: &mut String) { + use std::fmt::Write; + let _ = write!(out, "\"{}\";r={}", self.name, self.remaining); + if let Some(reset) = self.reset { + let _ = write!(out, ";t={reset}"); + } + } +} + +impl<'x> RequestError<'x> { + pub fn rate_limit_policy_header(&self) -> Option { + if self.rate_limit.is_empty() { + return None; + } + let mut out = String::new(); + for (i, policy) in self.rate_limit.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + policy.fmt_policy(&mut out); + } + Some(out) + } + + pub fn rate_limit_state_header(&self) -> Option { + if self.rate_limit.is_empty() { + return None; + } + let mut out = String::new(); + for (i, policy) in self.rate_limit.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + policy.fmt_state(&mut out); + } + Some(out) + } +} + +impl Display for RequestError<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limit_headers_match_spec() { + // Spec example: RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400 + let mut p1 = String::new(); + RateLimitPolicy::new("burst", 100) + .with_window(60) + .fmt_policy(&mut p1); + assert_eq!(p1, r#""burst";q=100;w=60"#); + + // Spec example: RateLimit-Policy: "peruser";q=65535;qu="content-bytes";w=10 + let mut p2 = String::new(); + RateLimitPolicy::new("peruser", 65535) + .with_window(10) + .with_unit(RateLimitUnit::ContentBytes) + .fmt_policy(&mut p2); + assert_eq!(p2, r#""peruser";q=65535;w=10;qu="content-bytes""#); + + // Spec example: RateLimit: "default";r=50;t=30 + let mut s = String::new(); + RateLimitPolicy::new("default", 100) + .with_remaining(50) + .with_reset(30) + .fmt_state(&mut s); + assert_eq!(s, r#""default";r=50;t=30"#); + + // Two policies in one header + let err = RequestError::too_many_requests() + .with_rate_limit( + RateLimitPolicy::new("burst", 100) + .with_window(60) + .with_reset(30), + ) + .with_rate_limit( + RateLimitPolicy::new("daily", 1000) + .with_window(86400) + .with_reset(3600), + ); + assert_eq!( + err.rate_limit_policy_header().as_deref(), + Some(r#""burst";q=100;w=60, "daily";q=1000;w=86400"#), + ); + assert_eq!( + err.rate_limit_state_header().as_deref(), + Some(r#""burst";r=0;t=30, "daily";r=0;t=3600"#), + ); + assert_eq!(err.retry_after, Some(3600)); + } +} diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs new file mode 100644 index 0000000..e637be5 --- /dev/null +++ b/crates/jmap-proto/src/error/set.rs @@ -0,0 +1,352 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Key, Property}; +use registry::types::{ + error::{PatchError, ValidationError}, + id::ObjectId, +}; +use std::borrow::Cow; +use types::id::Id; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(bound(serialize = "InvalidProperty

: serde::Serialize"))] +#[serde(transparent)] +#[repr(transparent)] +pub struct SetError(Box>); + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(bound(serialize = "InvalidProperty

: serde::Serialize"))] +struct SetErrorInner { + #[serde(rename = "type")] + type_: SetErrorType, + + #[serde(skip_serializing_if = "Option::is_none")] + description: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + properties: Option>>, + + #[serde(rename = "existingId")] + #[serde(skip_serializing_if = "Option::is_none")] + existing_id: Option, + + #[serde(rename = "objectId")] + #[serde(skip_serializing_if = "Option::is_none")] + object_id: Option, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "linkedObjects")] + linked_objects: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "validationErrors")] + validation_errors: Vec, +} + +#[derive(Debug, Clone)] +pub enum InvalidProperty { + Property(Key<'static, T>), + Path(Vec>), +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SetErrorType { + #[serde(rename = "forbidden")] + Forbidden, + #[serde(rename = "overQuota")] + OverQuota, + #[serde(rename = "tooLarge")] + TooLarge, + #[serde(rename = "rateLimit")] + RateLimit, + #[serde(rename = "notFound")] + NotFound, + #[serde(rename = "invalidPatch")] + InvalidPatch, + #[serde(rename = "willDestroy")] + WillDestroy, + #[serde(rename = "invalidProperties")] + InvalidProperties, + #[serde(rename = "singleton")] + Singleton, + #[serde(rename = "mailboxHasChild")] + MailboxHasChild, + #[serde(rename = "mailboxHasEmail")] + MailboxHasEmail, + #[serde(rename = "blobNotFound")] + BlobNotFound, + #[serde(rename = "tooManyKeywords")] + TooManyKeywords, + #[serde(rename = "tooManyMailboxes")] + TooManyMailboxes, + #[serde(rename = "forbiddenFrom")] + ForbiddenFrom, + #[serde(rename = "invalidEmail")] + InvalidEmail, + #[serde(rename = "tooManyRecipients")] + TooManyRecipients, + #[serde(rename = "noRecipients")] + NoRecipients, + #[serde(rename = "invalidRecipients")] + InvalidRecipients, + #[serde(rename = "forbiddenMailFrom")] + ForbiddenMailFrom, + #[serde(rename = "forbiddenToSend")] + ForbiddenToSend, + #[serde(rename = "cannotUnsend")] + CannotUnsend, + #[serde(rename = "alreadyExists")] + AlreadyExists, + #[serde(rename = "invalidScript")] + InvalidScript, + #[serde(rename = "scriptIsActive")] + ScriptIsActive, + #[serde(rename = "addressBookHasContents")] + AddressBookHasContents, + #[serde(rename = "nodeHasChildren")] + NodeHasChildren, + #[serde(rename = "calendarHasEvent")] + CalendarHasEvent, + #[serde(rename = "noSupportedScheduleMethods")] + NoSupportedScheduleMethods, + // Stalwart registry errors + #[serde(rename = "objectIsLinked")] + ObjectIsLinked, + #[serde(rename = "invalidForeignKey")] + InvalidForeignKey, + #[serde(rename = "primaryKeyViolation")] + PrimaryKeyViolation, + #[serde(rename = "validationFailed")] + ValidationFailed, +} + +impl SetErrorType { + pub fn as_str(&self) -> &'static str { + match self { + SetErrorType::Forbidden => "forbidden", + SetErrorType::OverQuota => "overQuota", + SetErrorType::TooLarge => "tooLarge", + SetErrorType::RateLimit => "rateLimit", + SetErrorType::NotFound => "notFound", + SetErrorType::InvalidPatch => "invalidPatch", + SetErrorType::WillDestroy => "willDestroy", + SetErrorType::InvalidProperties => "invalidProperties", + SetErrorType::Singleton => "singleton", + SetErrorType::BlobNotFound => "blobNotFound", + SetErrorType::MailboxHasChild => "mailboxHasChild", + SetErrorType::MailboxHasEmail => "mailboxHasEmail", + SetErrorType::TooManyKeywords => "tooManyKeywords", + SetErrorType::TooManyMailboxes => "tooManyMailboxes", + SetErrorType::ForbiddenFrom => "forbiddenFrom", + SetErrorType::InvalidEmail => "invalidEmail", + SetErrorType::TooManyRecipients => "tooManyRecipients", + SetErrorType::NoRecipients => "noRecipients", + SetErrorType::InvalidRecipients => "invalidRecipients", + SetErrorType::ForbiddenMailFrom => "forbiddenMailFrom", + SetErrorType::ForbiddenToSend => "forbiddenToSend", + SetErrorType::CannotUnsend => "cannotUnsend", + SetErrorType::AlreadyExists => "alreadyExists", + SetErrorType::InvalidScript => "invalidScript", + SetErrorType::ScriptIsActive => "scriptIsActive", + SetErrorType::AddressBookHasContents => "addressBookHasContents", + SetErrorType::NodeHasChildren => "nodeHasChildren", + SetErrorType::CalendarHasEvent => "calendarHasEvent", + SetErrorType::NoSupportedScheduleMethods => "noSupportedScheduleMethods", + SetErrorType::ObjectIsLinked => "objectIsLinked", + SetErrorType::InvalidForeignKey => "invalidForeignKey", + SetErrorType::PrimaryKeyViolation => "primaryKeyViolation", + SetErrorType::ValidationFailed => "validationFailed", + } + } +} + +impl SetError { + pub fn new(type_: SetErrorType) -> Self { + SetError(Box::new(SetErrorInner { + type_, + description: None, + properties: None, + existing_id: None, + object_id: None, + linked_objects: Vec::new(), + validation_errors: Vec::new(), + })) + } + + pub fn with_description(mut self, description: impl Into>) -> Self { + self.0.description = description.into().into(); + self + } + + pub fn error_type(&self) -> &SetErrorType { + &self.0.type_ + } + + pub fn description(&self) -> Option<&str> { + self.0.description.as_deref() + } + + pub fn validation_errors(&self) -> &[ValidationError] { + &self.0.validation_errors + } + + pub fn with_property(mut self, property: impl Into>) -> Self { + self.0.properties = vec![property.into()].into(); + self + } + + pub fn with_properties( + mut self, + properties: impl IntoIterator>>, + ) -> Self { + self.0.properties = properties + .into_iter() + .map(Into::into) + .collect::>() + .into(); + self + } + + pub fn with_object_id(mut self, object_id: ObjectId) -> Self { + self.0.object_id = object_id.into(); + self + } + + pub fn with_object_id_opt(mut self, object_id: Option) -> Self { + self.0.object_id = object_id; + self + } + + pub fn with_linked_objects(mut self, linked_objects: Vec) -> Self { + self.0.linked_objects = linked_objects; + self + } + + pub fn with_validation_errors(mut self, validation_errors: Vec) -> Self { + self.0.validation_errors = validation_errors; + self + } + + pub fn with_existing_id(mut self, id: Id) -> Self { + self.0.existing_id = id.into(); + self + } + + pub fn invalid_properties() -> Self { + Self::new(SetErrorType::InvalidProperties) + } + + pub fn invalid_patch() -> Self { + Self::new(SetErrorType::InvalidPatch) + } + + pub fn forbidden() -> Self { + Self::new(SetErrorType::Forbidden) + } + + pub fn not_found() -> Self { + Self::new(SetErrorType::NotFound) + } + + pub fn blob_not_found() -> Self { + Self::new(SetErrorType::BlobNotFound) + } + + pub fn over_quota() -> Self { + Self::new(SetErrorType::OverQuota).with_description("Account quota exceeded.") + } + + pub fn already_exists() -> Self { + Self::new(SetErrorType::AlreadyExists) + } + + pub fn no_supported_schedule_methods(calendar_address: &str) -> Self { + Self::new(SetErrorType::NoSupportedScheduleMethods).with_description(format!( + "No supported scheduling method for calendar address {calendar_address}." + )) + } + + pub fn too_large() -> Self { + Self::new(SetErrorType::TooLarge) + } + + pub fn will_destroy() -> Self { + Self::new(SetErrorType::WillDestroy).with_description("ID will be destroyed.") + } + + pub fn singleton() -> Self { + Self::new(SetErrorType::Singleton) + .with_description("Singletons cannot be created or destroyed.") + } + + pub fn address_book_has_contents() -> Self { + Self::new(SetErrorType::AddressBookHasContents) + .with_description("Address book is not empty.") + } + + pub fn node_has_children() -> Self { + Self::new(SetErrorType::NodeHasChildren).with_description("Cannot delete non-empty folder.") + } + + pub fn calendar_has_event() -> Self { + Self::new(SetErrorType::CalendarHasEvent).with_description("Calendar is not empty.") + } +} + +impl From for InvalidProperty { + fn from(property: T) -> Self { + InvalidProperty::Property(Key::Property(property)) + } +} + +impl From<(T, T)> for InvalidProperty { + fn from((a, b): (T, T)) -> Self { + InvalidProperty::Path(vec![Key::Property(a), Key::Property(b)]) + } +} + +impl From> for InvalidProperty { + fn from(property: Key<'static, T>) -> Self { + InvalidProperty::Property(property) + } +} + +impl serde::Serialize for InvalidProperty { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + InvalidProperty::Property(p) => p.serialize(serializer), + InvalidProperty::Path(p) => { + use std::fmt::Write; + let mut path = String::with_capacity(64); + for (i, p) in p.iter().enumerate() { + if i > 0 { + path.push('/'); + } + let _ = write!(path, "{}", p.to_string()); + } + path.serialize(serializer) + } + } + } +} + +impl From for SetError { + fn from(err: PatchError) -> Self { + SetError(Box::new(SetErrorInner { + type_: SetErrorType::InvalidPatch, + description: err.message.into(), + properties: Some(vec![InvalidProperty::Property(Key::Owned(err.path))]), + existing_id: None, + object_id: None, + linked_objects: Vec::new(), + validation_errors: Vec::new(), + })) + } +} diff --git a/crates/jmap-proto/src/lib.rs b/crates/jmap-proto/src/lib.rs new file mode 100644 index 0000000..7c051e6 --- /dev/null +++ b/crates/jmap-proto/src/lib.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod error; +pub mod method; +pub mod object; +pub mod references; +pub mod request; +pub mod response; +pub mod types; diff --git a/crates/jmap-proto/src/method/availability.rs b/crates/jmap-proto/src/method/availability.rs new file mode 100644 index 0000000..26fe509 --- /dev/null +++ b/crates/jmap-proto/src/method/availability.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + }, + types::date::UTCDate, +}; +use calcard::jscalendar::{JSCalendar, JSCalendarProperty}; +use serde::{Deserialize, Deserializer, Serialize}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct GetAvailabilityRequest { + pub account_id: Id, + pub id: Id, + pub utc_start: UTCDate, + pub utc_end: UTCDate, + pub show_details: bool, + pub event_properties: Option>>>, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailabilityResponse { + pub list: Vec, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BusyPeriod { + pub utc_start: UTCDate, + pub utc_end: UTCDate, + pub busy_status: Option, + pub event: Option>, +} + +#[derive(Debug, Serialize, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum BusyStatus { + Confirmed, + Tentative, + Unavailable, +} + +impl<'de> DeserializeArguments<'de> for GetAvailabilityRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"utcStart" => { + self.utc_start = map.next_value()?; + }, + b"utcEnd" => { + self.utc_end = map.next_value()?; + }, + b"id" => { + self.id = map.next_value()?; + }, + b"showDetails" => { + self.show_details = map.next_value()?; + }, + b"eventProperties" => { + self.event_properties = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for GetAvailabilityRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} diff --git a/crates/jmap-proto/src/method/changes.rs b/crates/jmap-proto/src/method/changes.rs new file mode 100644 index 0000000..0e3423c --- /dev/null +++ b/crates/jmap-proto/src/method/changes.rs @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + method::PropertyWrapper, + object::JmapObject, + request::deserialize::{DeserializeArguments, deserialize_request}, + types::state::State, +}; +use serde::{Deserialize, Deserializer}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct ChangesRequest { + pub account_id: Id, + pub since_state: State, + pub max_changes: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ChangesResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "oldState")] + pub old_state: State, + + #[serde(rename = "newState")] + pub new_state: State, + + #[serde(rename = "hasMoreChanges")] + pub has_more_changes: bool, + + pub created: Vec, + + pub updated: Vec, + + pub destroyed: Vec, + + #[serde(rename = "updatedProperties")] + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_properties: Option>>, +} + +impl<'de> DeserializeArguments<'de> for ChangesRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"sinceState" => { + self.since_state = map.next_value()?; + }, + b"maxChanges" => { + self.max_changes = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for ChangesRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl ChangesResponse { + pub fn has_changes(&self) -> bool { + !self.created.is_empty() || !self.updated.is_empty() || !self.destroyed.is_empty() + } +} diff --git a/crates/jmap-proto/src/method/copy.rs b/crates/jmap-proto/src/method/copy.rs new file mode 100644 index 0000000..25d61d1 --- /dev/null +++ b/crates/jmap-proto/src/method/copy.rs @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + error::set::SetError, + object::{JmapObject, blob::BlobProperty}, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::MaybeIdReference, + }, + types::state::State, +}; +use jmap_tools::{Key, Map, Value}; +use serde::{Deserialize, Deserializer, Serialize}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone)] +pub struct CopyRequest<'x, T: JmapObject> { + pub from_account_id: Id, + pub if_from_in_state: Option, + pub account_id: Id, + pub if_in_state: Option, + pub create: VecMap, Value<'x, T::Property, T::Element>>, + pub on_success_destroy_original: Option, + pub destroy_from_if_in_state: Option, + pub arguments: T::CopyArguments, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CopyResponse { + #[serde(rename = "fromAccountId")] + pub from_account_id: Id, + + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "oldState")] + pub old_state: State, + + #[serde(rename = "newState")] + pub new_state: State, + + #[serde(rename = "created")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub created: VecMap>, + + #[serde(rename = "notCreated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap>, +} + +#[derive(Debug, Clone, Default)] +pub struct CopyBlobRequest { + pub from_account_id: Id, + pub account_id: Id, + pub blob_ids: Vec>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CopyBlobResponse { + #[serde(rename = "fromAccountId")] + pub from_account_id: Id, + + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "copied")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub copied: VecMap, + + #[serde(rename = "notCopied")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_copied: VecMap, SetError>, +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for CopyRequest<'de, T> { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"ifInState" => { + self.if_in_state = map.next_value()?; + }, + b"fromAccountId" => { + self.from_account_id = crate::request::deserialize_account_id(map)?; + }, + b"ifFromInState" => { + self.if_from_in_state = map.next_value()?; + }, + b"create" => { + self.create = map.next_value()?; + }, + b"onSuccessDestroyOriginal" => { + self.on_success_destroy_original = map.next_value()?; + }, + b"destroyFromIfInState" => { + self.destroy_from_if_in_state = map.next_value()?; + }, + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CopyBlobRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"fromAccountId" => { + self.from_account_id = crate::request::deserialize_account_id(map)?; + }, + b"blobIds" => { + self.blob_ids = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for CopyRequest<'de, T> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'de> Deserialize<'de> for CopyBlobRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'de, T: JmapObject> Default for CopyRequest<'de, T> { + fn default() -> Self { + CopyRequest { + from_account_id: Id::default(), + if_from_in_state: None, + account_id: Id::default(), + if_in_state: None, + create: VecMap::new(), + on_success_destroy_original: None, + destroy_from_if_in_state: None, + arguments: T::CopyArguments::default(), + } + } +} + +impl CopyResponse { + pub fn created(&mut self, id: Id, document_id: impl Into) { + let document_id = document_id.into(); + self.created.append( + id, + Value::Object(Map::from(vec![( + Key::Property(T::ID_PROPERTY), + Value::Element(document_id.into()), + )])), + ); + } +} diff --git a/crates/jmap-proto/src/method/get.rs b/crates/jmap-proto/src/method/get.rs new file mode 100644 index 0000000..942ac33 --- /dev/null +++ b/crates/jmap-proto/src/method/get.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::JmapObject, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::{MaybeIdReference, MaybeResultReference, ResultReference}, + }, + types::state::State, +}; +use jmap_tools::Value; +use serde::{Deserialize, Deserializer}; +use types::id::Id; + +#[derive(Debug, Clone)] +pub struct GetRequest { + pub account_id: Id, + pub ids: Option>>>, + pub properties: Option>>>, + pub arguments: T::GetArguments, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct GetResponse { + #[serde(rename = "accountId")] + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + + pub list: Vec>, + + #[serde(rename = "notFound")] + pub not_found: Vec>, +} + +impl GetResponse { + pub fn push_not_found(&mut self, id: T::Id) { + self.not_found.push(MaybeInvalid::Value(id)); + } +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for GetRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"ids" => { + self.ids = map.next_value::>>>()?.map(MaybeResultReference::Value); + }, + b"properties" => { + self.properties = map.next_value::>>>()?.map(MaybeResultReference::Value); + }, + b"#ids" => { + self.ids = Some(MaybeResultReference::Reference(map.next_value::()?)); + }, + b"#properties" => { + self.properties = Some(MaybeResultReference::Reference(map.next_value::()?)); + }, + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for GetRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Default for GetRequest { + fn default() -> Self { + Self { + account_id: Id::default(), + ids: None, + properties: None, + arguments: T::GetArguments::default(), + } + } +} + +impl GetRequest { + pub fn unwrap_properties(&mut self, default: &[T::Property]) -> Vec { + if let Some(properties_) = self.properties.take().map(|p| p.unwrap()) { + let mut properties = Vec::with_capacity(properties_.len()); + let id_prop = T::ID_PROPERTY; + let mut has_id = false; + + for prop in properties_ { + if let MaybeInvalid::Value(p) = prop { + if p == id_prop { + has_id = true; + } + properties.push(p); + } + } + + if !has_id { + properties.push(id_prop); + } + + properties + } else { + default.to_vec() + } + } + + #[allow(clippy::type_complexity)] + pub fn unwrap_ids( + &mut self, + max_objects_in_get: usize, + ) -> trc::Result<(Option>, Vec>)> { + if let Some(ids) = self.ids.take() { + let ids = ids.unwrap(); + if ids.len() <= max_objects_in_get { + let mut valid = Vec::with_capacity(ids.len()); + let mut invalid = Vec::new(); + for id in ids { + match id { + MaybeIdReference::Id(id) => valid.push(id), + MaybeIdReference::Invalid(s) | MaybeIdReference::Reference(s) => { + invalid.push(MaybeInvalid::Invalid(s)) + } + } + } + Ok((Some(valid), invalid)) + } else { + Err(trc::JmapEvent::RequestTooLarge.into_err()) + } + } else { + Ok((None, Vec::new())) + } + } +} diff --git a/crates/jmap-proto/src/method/import.rs b/crates/jmap-proto/src/method/import.rs new file mode 100644 index 0000000..535a191 --- /dev/null +++ b/crates/jmap-proto/src/method/import.rs @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + error::set::SetError, + method::JmapDict, + object::{ + AnyId, + email::{EmailProperty, EmailValue}, + }, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::{MaybeIdReference, MaybeResultReference, ResultReference}, + }, + response::Response, + types::{date::UTCDate, state::State}, +}; +use jmap_tools::{Key, Value}; +use serde::{Deserialize, Deserializer}; +use types::{blob::BlobId, id::Id, keyword::Keyword}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, Default)] +pub struct ImportEmailRequest { + pub account_id: Id, + pub if_in_state: Option, + pub emails: VecMap, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportEmail { + pub blob_id: MaybeInvalid, + pub mailbox_ids: MaybeResultReference>>, + pub keywords: Vec, + pub received_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ImportEmailResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "oldState")] + #[serde(skip_serializing_if = "Option::is_none")] + pub old_state: Option, + + #[serde(rename = "newState")] + pub new_state: State, + + #[serde(rename = "created")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub created: VecMap>, + + #[serde(rename = "notCreated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap>, +} + +impl<'de> DeserializeArguments<'de> for ImportEmailRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"ifInState" => { + self.if_in_state = map.next_value()?; + }, + b"emails" => { + self.emails = map.next_value()?; + } + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for ImportEmail { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"blobId" => { + self.blob_id = map.next_value()?; + }, + b"keywords" => { + self.keywords = map.next_value::>()?.0; + }, + b"receivedAt" => { + self.received_at = map.next_value()?; + }, + b"mailboxIds" => { + self.mailbox_ids = MaybeResultReference::Value(map.next_value::>>()?.0); + }, + b"#mailboxIds" => { + self.mailbox_ids = MaybeResultReference::Reference(map.next_value::()?); + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for ImportEmail { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'de> Deserialize<'de> for ImportEmailRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl ImportEmailResponse { + pub fn update_created_ids(&self, response: &mut Response) { + for (user_id, obj) in &self.created { + if let Value::Object(obj) = obj + && let Some(Value::Element(EmailValue::Id(id))) = + obj.get(&Key::Property(EmailProperty::Id)) + { + response.created_ids.insert(user_id.clone(), AnyId::Id(*id)); + } + } + } +} diff --git a/crates/jmap-proto/src/method/lookup.rs b/crates/jmap-proto/src/method/lookup.rs new file mode 100644 index 0000000..f724e0b --- /dev/null +++ b/crates/jmap-proto/src/method/lookup.rs @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, +}; +use serde::{Deserialize, Deserializer}; +use types::{blob::BlobId, id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, Default)] +pub struct BlobLookupRequest { + pub account_id: Id, + pub type_names: Vec>, + pub ids: Vec>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobLookupResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "list")] + pub list: Vec, + + #[serde(rename = "notFound")] + pub not_found: Vec, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobInfo { + pub id: BlobId, + #[serde(rename = "matchedIds")] + pub matched_ids: VecMap>, +} + +impl<'de> DeserializeArguments<'de> for BlobLookupRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"typeNames" => { + self.type_names = map.next_value()?; + }, + b"ids" => { + self.ids = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for BlobLookupRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} diff --git a/crates/jmap-proto/src/method/mod.rs b/crates/jmap-proto/src/method/mod.rs new file mode 100644 index 0000000..4aa8f47 --- /dev/null +++ b/crates/jmap-proto/src/method/mod.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use jmap_tools::Property; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, Visitor}, +}; +use std::{borrow::Cow, fmt, str::FromStr}; + +pub mod availability; +pub mod changes; +pub mod copy; +pub mod get; +pub mod import; +pub mod lookup; +pub mod parse; +pub mod query; +pub mod query_changes; +pub mod search_snippet; +pub mod set; +pub mod upload; +pub mod validate; + +#[inline(always)] +fn ahash_is_empty(map: &AHashMap) -> bool { + map.is_empty() +} + +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct PropertyWrapper(pub T); + +impl From for PropertyWrapper { + fn from(value: T) -> Self { + Self(value) + } +} + +impl Serialize for PropertyWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.to_cow().as_ref()) + } +} + +pub(crate) struct JmapDict(pub Vec); + +struct JmapDictVisitor<'de, T: FromStr> { + marker: std::marker::PhantomData<&'de T>, +} + +impl<'de, T: FromStr> Visitor<'de> for JmapDictVisitor<'de, T> { + type Value = JmapDict; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut vec = Vec::with_capacity(3); + + while let Some(key) = access.next_key::>()? { + let key = T::from_str(&key).map_err(|_| de::Error::custom("invalid dictionary key"))?; + if access.next_value::>()?.unwrap_or(false) { + vec.push(key); + } + } + + Ok(JmapDict(vec)) + } +} + +impl<'de, T: FromStr + 'static> Deserialize<'de> for JmapDict { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(JmapDictVisitor { + marker: std::marker::PhantomData, + }) + } +} diff --git a/crates/jmap-proto/src/method/parse.rs b/crates/jmap-proto/src/method/parse.rs new file mode 100644 index 0000000..ca0f261 --- /dev/null +++ b/crates/jmap-proto/src/method/parse.rs @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::JmapObject, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::MaybeIdReference, + }, +}; +use jmap_tools::Value; +use serde::{Deserialize, Deserializer}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone)] +pub struct ParseRequest { + pub account_id: Id, + pub blob_ids: Vec>, + pub properties: Option>>, + pub arguments: T::ParseArguments, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ParseResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "parsed")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub parsed: VecMap>, + + #[serde(rename = "notParsable")] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub not_parsable: Vec, + + #[serde(rename = "notFound")] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub not_found: Vec>, +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for ParseRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"blobIds" => { + self.blob_ids = map.next_value()?; + }, + b"properties" => { + self.properties = map.next_value()?; + }, + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for ParseRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Default for ParseRequest { + fn default() -> Self { + Self { + account_id: Id::default(), + blob_ids: Vec::default(), + properties: None, + arguments: T::ParseArguments::default(), + } + } +} diff --git a/crates/jmap-proto/src/method/query.rs b/crates/jmap-proto/src/method/query.rs new file mode 100644 index 0000000..9445f75 --- /dev/null +++ b/crates/jmap-proto/src/method/query.rs @@ -0,0 +1,389 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::JmapObject, + request::deserialize::{DeserializeArguments, deserialize_request}, + types::state::State, +}; +use serde::{ + Deserialize, Deserializer, + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, +}; +use std::{ + borrow::Cow, + fmt::{self}, +}; +use types::id::Id; + +#[derive(Debug, Clone)] +pub struct QueryRequest { + pub account_id: Id, + pub filter: Vec>, + pub sort: Option>>, + pub position: Option, + pub anchor: Option, + pub anchor_offset: Option, + pub limit: Option, + pub calculate_total: Option, + pub arguments: T::QueryArguments, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct QueryResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "queryState")] + pub query_state: State, + + #[serde(rename = "canCalculateChanges")] + pub can_calculate_changes: bool, + + #[serde(rename = "position")] + pub position: i32, + + #[serde(rename = "ids")] + pub ids: Vec, + + #[serde(rename = "total")] + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + + #[serde(rename = "limit")] + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub enum Filter +where + T: for<'de> DeserializeArguments<'de> + Default, +{ + Property(T), + And, + Or, + Not, + Close, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comparator +where + T: for<'de> DeserializeArguments<'de> + Default, +{ + pub is_ascending: bool, + pub collation: Option, + pub property: T, +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for QueryRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"filter" => { + self.filter = map.next_value::>()?.0; + }, + b"sort" => { + self.sort = map.next_value()?; + }, + b"calculateTotal" => { + self.calculate_total = map.next_value()?; + }, + b"position" => { + self.position = map.next_value()?; + }, + b"anchor" => { + self.anchor = map + .next_value::>>()? + .map(|anchor| anchor.try_unwrap().unwrap_or(Id::from(u64::MAX))); + }, + b"anchorOffset" => { + self.anchor_offset = map.next_value()?; + }, + b"limit" => { + self.limit = map.next_value()?; + }, + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for QueryRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Default for QueryRequest { + fn default() -> Self { + Self { + account_id: Id::default(), + filter: vec![], + sort: None, + position: None, + anchor: None, + anchor_offset: None, + limit: None, + calculate_total: None, + arguments: T::QueryArguments::default(), + } + } +} + +struct FilterMapCollector<'x, T: 'x>(&'x mut Vec>) +where + T: for<'de> DeserializeArguments<'de> + Default; + +struct FilterListCollector<'x, T: 'x>(&'x mut Vec>) +where + T: for<'de> DeserializeArguments<'de> + Default; + +pub struct FilterWrapper(pub Vec>) +where + T: for<'de> DeserializeArguments<'de> + Default; + +impl<'de, T> Deserialize<'de> for FilterWrapper +where + T: for<'de2> DeserializeArguments<'de2> + Default, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut items = Vec::new(); + FilterMapCollector(&mut items) + .deserialize(deserializer) + .map(|_| FilterWrapper(items)) + } +} + +impl<'de, 'x, T> DeserializeSeed<'de> for FilterMapCollector<'x, T> +where + T: for<'de2> DeserializeArguments<'de2> + Default, +{ + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct FilterVisitor<'x, T: 'x>(&'x mut Vec>) + where + T: for<'de2> DeserializeArguments<'de2> + Default; + + impl<'de, 'x, T> Visitor<'de> for FilterVisitor<'x, T> + where + T: for<'de2> DeserializeArguments<'de2> + Default, + { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a filter object") + } + + fn visit_unit(self) -> Result<(), E> + where + E: de::Error, + { + Ok(()) + } + + fn visit_none(self) -> Result<(), E> + where + E: de::Error, + { + Ok(()) + } + + fn visit_map(self, mut map: V) -> Result<(), V::Error> + where + V: MapAccess<'de>, + { + let mut filter = None; + let mut has_multiple_filters = false; + let mut has_conditions = None; + let mut op = None; + + while let Some(key) = map.next_key::>()? { + match key.len() { + 8 if key == "operator" => { + let op_ = hashify::tiny_map!( + map.next_value::<&str>()?.as_bytes(), + "AND" => Filter::And, + "OR" => Filter::Or, + "NOT" => Filter::Not, + ) + .ok_or_else(|| { + de::Error::custom(format!("Unknown filter operator: {}", key)) + })?; + + if let Some(pos) = has_conditions { + self.0[pos] = op_; + } else { + op = Some(op_); + } + } + 10 if key == "conditions" => { + has_conditions = Some(self.0.len()); + self.0.push(op.take().unwrap_or(Filter::And)); + map.next_value_seed(FilterListCollector(self.0))?; + self.0.push(Filter::Close); + } + _ => { + if let Some(filter) = filter { + if !has_multiple_filters { + self.0.push(Filter::And); + has_multiple_filters = true; + } + self.0.push(Filter::Property(filter)); + } + let mut new_filter = T::default(); + new_filter.deserialize_argument(&key, &mut map)?; + filter = Some(new_filter); + } + } + } + + if let Some(filter) = filter { + if has_conditions.is_some() { + return Err(de::Error::custom( + "Cannot mix conditions with property filters", + )); + } + + self.0.push(Filter::Property(filter)); + if has_multiple_filters { + self.0.push(Filter::Close); + } + } + + Ok(()) + } + } + + deserializer.deserialize_any(FilterVisitor(self.0)) + } +} + +impl<'de, 'x, T> DeserializeSeed<'de> for FilterListCollector<'x, T> +where + T: for<'de2> DeserializeArguments<'de2> + Default, +{ + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct FilterVisitor<'x, T: 'x>(&'x mut Vec>) + where + T: for<'de2> DeserializeArguments<'de2> + Default; + + impl<'de, 'x, T> Visitor<'de> for FilterVisitor<'x, T> + where + T: for<'de2> DeserializeArguments<'de2> + Default, + { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a filter list") + } + + fn visit_seq(self, mut seq: A) -> Result<(), A::Error> + where + A: SeqAccess<'de>, + { + while let Some(()) = seq.next_element_seed(FilterMapCollector(self.0))? {} + Ok(()) + } + } + + deserializer.deserialize_seq(FilterVisitor(self.0)) + } +} + +impl<'de, T> DeserializeArguments<'de> for Comparator +where + T: for<'de2> DeserializeArguments<'de2> + Default, +{ + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"isAscending" => { + self.is_ascending = map.next_value()?; + }, + b"collation" => { + self.collation = map.next_value()?; + }, + _ => { + self.property.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T> Deserialize<'de> for Comparator +where + T: for<'de2> DeserializeArguments<'de2> + Default, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Comparator +where + T: for<'de> DeserializeArguments<'de> + Default, +{ + pub fn descending(property: T) -> Self { + Self { + property, + is_ascending: false, + collation: None, + } + } + + pub fn ascending(property: T) -> Self { + Self { + property, + is_ascending: true, + collation: None, + } + } +} + +impl Default for Comparator +where + T: for<'de> DeserializeArguments<'de> + Default, +{ + fn default() -> Self { + Self { + is_ascending: true, + collation: None, + property: T::default(), + } + } +} diff --git a/crates/jmap-proto/src/method/query_changes.rs b/crates/jmap-proto/src/method/query_changes.rs new file mode 100644 index 0000000..a4e3031 --- /dev/null +++ b/crates/jmap-proto/src/method/query_changes.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + method::query::{Comparator, Filter, FilterWrapper, QueryRequest}, + object::JmapObject, + request::deserialize::{DeserializeArguments, deserialize_request}, + types::state::State, +}; +use serde::{Deserialize, Deserializer}; +use types::id::Id; + +#[derive(Debug, Clone)] +pub struct QueryChangesRequest { + pub account_id: Id, + pub filter: Vec>, + pub sort: Option>>, + pub since_query_state: State, + pub max_changes: Option, + pub up_to_id: Option, + pub calculate_total: Option, + pub arguments: T::QueryArguments, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct QueryChangesResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "oldQueryState")] + pub old_query_state: State, + + #[serde(rename = "newQueryState")] + pub new_query_state: State, + + #[serde(rename = "total")] + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + + #[serde(rename = "removed")] + pub removed: Vec, + + #[serde(rename = "added")] + pub added: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AddedItem { + pub id: Id, + pub index: usize, +} + +impl AddedItem { + pub fn new(id: Id, index: usize) -> Self { + Self { id, index } + } +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for QueryChangesRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"filter" => { + self.filter = map.next_value::>()?.0; + }, + b"sort" => { + self.sort = map.next_value()?; + }, + b"sinceQueryState" => { + self.since_query_state = map.next_value()?; + }, + b"maxChanges" => { + self.max_changes = map.next_value()?; + }, + b"upToId" => { + self.up_to_id = map.next_value()?; + }, + b"calculateTotal" => { + self.calculate_total = map.next_value()?; + }, + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for QueryChangesRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Default for QueryChangesRequest { + fn default() -> Self { + Self { + account_id: Id::default(), + filter: Vec::new(), + sort: None, + since_query_state: State::default(), + max_changes: None, + up_to_id: None, + calculate_total: None, + arguments: T::QueryArguments::default(), + } + } +} + +impl From> for QueryRequest { + fn from(request: QueryChangesRequest) -> Self { + QueryRequest { + account_id: request.account_id, + filter: request.filter, + sort: request.sort, + position: None, + anchor: None, + anchor_offset: None, + limit: None, + calculate_total: request.calculate_total, + arguments: request.arguments, + } + } +} diff --git a/crates/jmap-proto/src/method/search_snippet.rs b/crates/jmap-proto/src/method/search_snippet.rs new file mode 100644 index 0000000..71f43d7 --- /dev/null +++ b/crates/jmap-proto/src/method/search_snippet.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::query::Filter; +use crate::{ + method::query::FilterWrapper, + object::email::EmailFilter, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::{MaybeResultReference, ResultReference}, + }, +}; +use serde::{Deserialize, Deserializer}; +use types::id::Id; + +#[derive(Debug, Clone)] +pub struct GetSearchSnippetRequest { + pub account_id: Id, + pub filter: Vec>, + pub email_ids: MaybeResultReference>>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct GetSearchSnippetResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "list")] + pub list: Vec, + + #[serde(rename = "notFound")] + pub not_found: Option>>, +} + +#[derive(serde::Serialize, Clone, Debug)] +pub struct SearchSnippet { + #[serde(rename = "emailId")] + pub email_id: Id, + + pub subject: Option, + + pub preview: Option, +} + +impl<'de> DeserializeArguments<'de> for GetSearchSnippetRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"filter" => { + self.filter = map.next_value::>()?.0; + }, + b"emailIds" => { + self.email_ids = MaybeResultReference::Value(map.next_value::>>()?); + }, + b"#emailIds" => { + self.email_ids = MaybeResultReference::Reference(map.next_value::()?); + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for GetSearchSnippetRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl Default for GetSearchSnippetRequest { + fn default() -> Self { + Self { + account_id: Id::default(), + filter: Vec::new(), + email_ids: MaybeResultReference::Value(Vec::new()), + } + } +} diff --git a/crates/jmap-proto/src/method/set.rs b/crates/jmap-proto/src/method/set.rs new file mode 100644 index 0000000..445f91f --- /dev/null +++ b/crates/jmap-proto/src/method/set.rs @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ahash_is_empty; +use crate::{ + error::set::{InvalidProperty, SetError}, + object::{JmapObject, JmapObjectId}, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + reference::{MaybeResultReference, ResultReference}, + }, + response::Response, + types::state::State, +}; +use ahash::AHashMap; +use jmap_tools::{Key, Map, Value}; +use serde::{Deserialize, Deserializer}; +use types::id::Id; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone)] +#[allow(clippy::type_complexity)] +pub struct SetRequest<'x, T: JmapObject> { + pub account_id: Id, + pub if_in_state: Option, + pub create: Option>>, + pub update: Option, Value<'x, T::Property, T::Element>>>, + pub destroy: Option>>>, + pub arguments: T::SetArguments<'x>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +#[allow(clippy::type_complexity)] +pub struct SetResponse { + #[serde(rename = "accountId")] + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + + #[serde(rename = "oldState")] + #[serde(skip_serializing_if = "Option::is_none")] + pub old_state: Option, + + #[serde(rename = "newState")] + #[serde(skip_serializing_if = "Option::is_none")] + pub new_state: Option, + + #[serde(rename = "created")] + #[serde(skip_serializing_if = "ahash_is_empty")] + pub created: AHashMap>, + + #[serde(rename = "updated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub updated: VecMap>>, + + #[serde(rename = "destroyed")] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub destroyed: Vec, + + #[serde(rename = "notCreated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap>, + + #[serde(rename = "notUpdated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_updated: VecMap, SetError>, + + #[serde(rename = "notDestroyed")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_destroyed: VecMap, SetError>, +} + +impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"ifInState" => { + self.if_in_state = map.next_value()?; + }, + b"create" => { + self.create = map.next_value()?; + }, + b"update" => { + self.update = map.next_value()?; + }, + b"destroy" => { + self.destroy = map.next_value::>>>()?.map(MaybeResultReference::Value); + }, + b"#destroy" => { + self.destroy = Some(MaybeResultReference::Reference(map.next_value::()?)); + } + _ => { + self.arguments.deserialize_argument(key, map)?; + } + ); + + Ok(()) + } +} + +impl<'de, T: JmapObject> Deserialize<'de> for SetRequest<'de, T> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'x, T: JmapObject> Default for SetRequest<'x, T> { + fn default() -> Self { + Self { + account_id: Id::default(), + if_in_state: None, + create: None, + update: None, + destroy: None, + arguments: T::SetArguments::default(), + } + } +} + +impl<'x, T: JmapObject> SetRequest<'x, T> { + pub fn validate(&self, max_objects_in_set: usize) -> trc::Result<()> { + if self.create.as_ref().map_or(0, |objs| objs.len()) + + self.update.as_ref().map_or(0, |objs| objs.len()) + + self.destroy.as_ref().map_or(0, |objs| { + if let MaybeResultReference::Value(ids) = objs { + ids.len() + } else { + 0 + } + }) + > max_objects_in_set + { + Err(trc::JmapEvent::RequestTooLarge.into_err()) + } else { + Ok(()) + } + } + + pub fn has_updates(&self) -> bool { + self.update.as_ref().is_some_and(|objs| !objs.is_empty()) + } + + pub fn has_creates(&self) -> bool { + self.create.as_ref().is_some_and(|objs| !objs.is_empty()) + } + + pub fn unwrap_create(&mut self) -> VecMap> { + self.create.take().unwrap_or_default() + } + + pub fn unwrap_update( + &mut self, + ) -> VecMap, Value<'x, T::Property, T::Element>> { + self.update.take().unwrap_or_default() + } + + pub fn unwrap_destroy(&mut self) -> Vec> { + self.destroy + .take() + .map(|ids| ids.unwrap()) + .unwrap_or_default() + } +} + +impl SetResponse { + pub fn from_request(request: &SetRequest, max_objects: usize) -> trc::Result { + let n_create = request.create.as_ref().map_or(0, |objs| objs.len()); + let n_update = request.update.as_ref().map_or(0, |objs| objs.len()); + let n_destroy = request.destroy.as_ref().map_or(0, |objs| { + if let MaybeResultReference::Value(ids) = objs { + ids.len() + } else { + 0 + } + }); + if n_create + n_update + n_destroy <= max_objects { + Ok(SetResponse { + account_id: if request.account_id.is_valid() { + request.account_id.into() + } else { + None + }, + new_state: None, + old_state: None, + created: AHashMap::with_capacity(n_create), + updated: VecMap::with_capacity(n_update), + destroyed: Vec::with_capacity(n_destroy), + not_created: VecMap::new(), + not_updated: VecMap::new(), + not_destroyed: VecMap::new(), + }) + } else { + Err(trc::JmapEvent::RequestTooLarge.into_err()) + } + } + + pub fn with_state(mut self, state: State) -> Self { + self.old_state = Some(state.clone()); + self.new_state = Some(state); + self + } + + pub fn collect_will_destroy(&mut self, ids: Vec>) -> Vec { + let mut will_destroy = Vec::with_capacity(ids.len()); + for id in ids { + match id { + MaybeInvalid::Value(id) => will_destroy.push(id), + invalid => self.not_destroyed.append(invalid, SetError::not_found()), + } + } + will_destroy + } + + pub fn created(&mut self, id: String, document_id: impl Into) { + self.created.insert( + id, + Value::Object(Map::from(vec![( + Key::Property(T::ID_PROPERTY), + Value::Element(document_id.into().into()), + )])), + ); + } + + pub fn invalid_property_create( + &mut self, + id: String, + property: impl Into>, + ) { + self.not_created.append( + id, + SetError::invalid_properties() + .with_property(property) + .with_description("Invalid property or value.".to_string()), + ); + } + + pub fn invalid_property_update( + &mut self, + id: Id, + property: impl Into>, + ) { + self.not_updated.append( + id, + SetError::invalid_properties() + .with_property(property) + .with_description("Invalid property or value.".to_string()), + ); + } + + pub fn update_created_ids(&self, response: &mut Response) { + for (user_id, obj) in &self.created { + if let Value::Object(obj) = obj + && let Some(Value::Element(id)) = obj.get(&Key::Property(T::ID_PROPERTY)) + && let Some(id) = id.as_any_id() + { + response.created_ids.insert(user_id.clone(), id); + } + } + } + + pub fn get_object_by_id( + &mut self, + id: Id, + ) -> Option<&mut Value<'static, T::Property, T::Element>> { + if let Some(obj) = self.updated.get_mut(&id) { + if let Some(obj) = obj { + return Some(obj); + } else { + *obj = Some(Value::Object(Map::with_capacity(1))); + return obj.as_mut().unwrap().into(); + } + } + + (&mut self.created) + .into_iter() + .map(|(_, obj)| obj) + .find(|obj| { + obj.as_object_and_get(&Key::Property(T::ID_PROPERTY)) + .and_then(|v| v.as_element()) + .and_then(|v| v.as_id()) + .is_some_and(|oid| oid == id) + }) + } + + pub fn has_changes(&self) -> bool { + !self.created.is_empty() || !self.updated.is_empty() || !self.destroyed.is_empty() + } +} diff --git a/crates/jmap-proto/src/method/upload.rs b/crates/jmap-proto/src/method/upload.rs new file mode 100644 index 0000000..a751f59 --- /dev/null +++ b/crates/jmap-proto/src/method/upload.rs @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use super::ahash_is_empty; +use crate::{ + error::set::SetError, + object::{AnyId, blob::BlobProperty}, + request::{ + deserialize::{DeserializeArguments, deserialize_request}, + reference::MaybeIdReference, + }, + response::Response, +}; +use ahash::AHashMap; +use mail_parser::decoders::base64::base64_decode; +use serde::{Deserialize, Deserializer}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, Default)] +pub struct BlobUploadRequest { + pub account_id: Id, + pub create: VecMap, +} + +#[derive(Debug, Clone, Default)] +pub struct UploadObject { + pub type_: Option, + pub data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum DataSourceObject { + Id { + id: MaybeIdReference, + length: Option, + offset: Option, + }, + Value(Vec), + #[default] + Null, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobUploadResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + + #[serde(rename = "created")] + #[serde(skip_serializing_if = "ahash_is_empty")] + pub created: AHashMap, + + #[serde(rename = "notCreated")] + #[serde(skip_serializing_if = "VecMap::is_empty")] + pub not_created: VecMap>, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct BlobUploadResponseObject { + pub id: BlobId, + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub type_: Option, + pub size: usize, +} + +impl<'de> DeserializeArguments<'de> for BlobUploadRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"create" => { + self.create = map.next_value()?; + } + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for UploadObject { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"type" => { + self.type_ = map.next_value()?; + }, + b"data" => { + self.data = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for DataSourceObject { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"data:asText" => { + *self = DataSourceObject::Value(map.next_value::().map(|v| v.into_bytes())?); + }, + b"data:asBase64" => { + *self = DataSourceObject::Value(base64_decode(map.next_value::>()?.as_bytes()).ok_or_else(|| serde::de::Error::custom("Failed to decode base64 data"))?); + }, + b"blobId" => { + match self { + DataSourceObject::Id { id, .. } => { + *id = map.next_value()?; + }, + _ => { + *self = DataSourceObject::Id { + id: map.next_value()?, + length: None, + offset: None, + }; + } + } + }, + b"offset" => { + match self { + DataSourceObject::Id { offset, .. } => { + *offset = map.next_value()?; + }, + _ => { + *self = DataSourceObject::Id { + id: MaybeIdReference::Invalid("".into()), + length: None, + offset: map.next_value()?, + }; + } + } + }, + b"length" => { + match self { + DataSourceObject::Id { length, .. } => { + *length = map.next_value()?; + }, + _ => { + *self = DataSourceObject::Id { + id: MaybeIdReference::Invalid("".into()), + length: map.next_value()?, + offset: None, + }; + } + } + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl BlobUploadResponse { + pub fn update_created_ids(&self, response: &mut Response) { + for (user_id, obj) in &self.created { + response + .created_ids + .insert(user_id.clone(), AnyId::BlobId(obj.id.clone())); + } + } +} + +impl<'de> Deserialize<'de> for DataSourceObject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'de> Deserialize<'de> for UploadObject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} + +impl<'de> Deserialize<'de> for BlobUploadRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} diff --git a/crates/jmap-proto/src/method/validate.rs b/crates/jmap-proto/src/method/validate.rs new file mode 100644 index 0000000..dcce912 --- /dev/null +++ b/crates/jmap-proto/src/method/validate.rs @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + error::set::SetError, + object::sieve::SieveProperty, + request::{ + MaybeInvalid, + deserialize::{DeserializeArguments, deserialize_request}, + }, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct ValidateSieveScriptRequest { + pub account_id: Id, + pub blob_id: MaybeInvalid, +} + +#[derive(Debug, Serialize)] +pub struct ValidateSieveScriptResponse { + #[serde(rename = "accountId")] + pub account_id: Id, + pub error: Option>, +} + +impl<'de> DeserializeArguments<'de> for ValidateSieveScriptRequest { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountId" => { + self.account_id = crate::request::deserialize_account_id(map)?; + }, + b"blobId" => { + self.blob_id = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for ValidateSieveScriptRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_request(deserializer) + } +} diff --git a/crates/jmap-proto/src/object/addressbook.rs b/crates/jmap-proto/src/object/addressbook.rs new file mode 100644 index 0000000..ffff813 --- /dev/null +++ b/crates/jmap-proto/src/object/addressbook.rs @@ -0,0 +1,354 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, + }, + request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{acl::Acl, id::Id, special_use::SpecialUse}; + +#[derive(Debug, Clone, Default)] +pub struct AddressBook; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AddressBookProperty { + Id, + Name, + Description, + SortOrder, + IsDefault, + IsSubscribed, + ShareWith, + MyRights, + + // Other + IdValue(Id), + Rights(AddressBookRight), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AddressBookRight { + MayRead, + MayWrite, + MayShare, + MayDelete, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AddressBookValue { + Id(Id), + IdReference(String), + Role(SpecialUse), +} + +impl Property for AddressBookProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + AddressBookProperty::ShareWith => { + Id::from_str(value).ok().map(AddressBookProperty::IdValue) + } + _ => AddressBookProperty::parse(value, allow_patch), + } + } else { + AddressBookProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + AddressBookProperty::Id => "id", + AddressBookProperty::Name => "name", + AddressBookProperty::Description => "description", + AddressBookProperty::SortOrder => "sortOrder", + AddressBookProperty::IsDefault => "isDefault", + AddressBookProperty::IsSubscribed => "isSubscribed", + AddressBookProperty::ShareWith => "shareWith", + AddressBookProperty::MyRights => "myRights", + AddressBookProperty::Rights(addressbook_right) => addressbook_right.as_str(), + AddressBookProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + AddressBookProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl AddressBookRight { + pub fn as_str(&self) -> &'static str { + match self { + AddressBookRight::MayRead => "mayRead", + AddressBookRight::MayWrite => "mayWrite", + AddressBookRight::MayShare => "mayShare", + AddressBookRight::MayDelete => "mayDelete", + } + } +} + +impl Element for AddressBookValue { + type Property = AddressBookProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + AddressBookProperty::Id => match parse_ref(value) { + MaybeReference::Value(v) => Some(AddressBookValue::Id(v)), + MaybeReference::Reference(v) => Some(AddressBookValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + AddressBookValue::Id(id) => id.to_string().into(), + AddressBookValue::IdReference(r) => format!("#{r}").into(), + AddressBookValue::Role(special_use) => special_use.as_str().unwrap_or_default().into(), + } + } +} + +impl AddressBookProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => AddressBookProperty::Id, + b"name" => AddressBookProperty::Name, + b"description" => AddressBookProperty::Description, + b"sortOrder" => AddressBookProperty::SortOrder, + b"isDefault" => AddressBookProperty::IsDefault, + b"isSubscribed" => AddressBookProperty::IsSubscribed, + b"shareWith" => AddressBookProperty::ShareWith, + b"myRights" => AddressBookProperty::MyRights, + b"mayRead" => AddressBookProperty::Rights(AddressBookRight::MayRead), + b"mayWrite" => AddressBookProperty::Rights(AddressBookRight::MayWrite), + b"mayShare" => AddressBookProperty::Rights(AddressBookRight::MayShare), + b"mayDelete" => AddressBookProperty::Rights(AddressBookRight::MayDelete) + ) + .or_else(|| { + if allow_patch && value.contains('/') { + AddressBookProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &AddressBookProperty { + if let AddressBookProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct AddressBookSetArguments { + pub on_destroy_remove_contents: Option, + pub on_success_set_is_default: Option>, +} + +impl<'de> DeserializeArguments<'de> for AddressBookSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"onDestroyRemoveContents" => { + self.on_destroy_remove_contents = map.next_value()?; + }, + b"onSuccessSetIsDefault" => { + self.on_success_set_is_default = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for AddressBookProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + AddressBookProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for AddressBook { + type Property = AddressBookProperty; + + type Element = AddressBookValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = AddressBookSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = AddressBookProperty::Id; +} + +impl JmapSharedObject for AddressBook { + type Right = AddressBookRight; + + const SHARE_WITH_PROPERTY: Self::Property = AddressBookProperty::ShareWith; +} + +impl From for AddressBookProperty { + fn from(id: Id) -> Self { + AddressBookProperty::IdValue(id) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: AddressBookProperty) -> Result { + if let AddressBookProperty::IdValue(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for AddressBookRight { + type Error = (); + + fn try_from(value: AddressBookProperty) -> Result { + if let AddressBookProperty::Rights(right) = value { + Ok(right) + } else { + Err(()) + } + } +} + +impl From for AddressBookValue { + fn from(id: Id) -> Self { + AddressBookValue::Id(id) + } +} + +impl JmapObjectId for AddressBookValue { + fn as_id(&self) -> Option { + if let AddressBookValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let AddressBookValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let AddressBookValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(new_id) = new_id { + *self = AddressBookValue::Id(new_id); + return true; + } + false + } +} + +impl JmapRight for AddressBookRight { + fn to_acl(&self) -> &'static [Acl] { + match self { + AddressBookRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], + AddressBookRight::MayShare => &[Acl::Share], + AddressBookRight::MayRead => &[Acl::Read, Acl::ReadItems], + AddressBookRight::MayWrite => &[Acl::Modify, Acl::AddItems, Acl::ModifyItems], + } + } + + fn all_rights() -> &'static [Self] { + &[ + AddressBookRight::MayRead, + AddressBookRight::MayWrite, + AddressBookRight::MayDelete, + AddressBookRight::MayShare, + ] + } +} + +impl From for AddressBookProperty { + fn from(right: AddressBookRight) -> Self { + AddressBookProperty::Rights(right) + } +} + +impl JmapObjectId for AddressBookProperty { + fn as_id(&self) -> Option { + if let AddressBookProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let AddressBookProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(new_id) = new_id { + *self = AddressBookProperty::IdValue(new_id); + return true; + } + false + } +} + +impl std::fmt::Display for AddressBookProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/blob.rs b/crates/jmap-proto/src/object/blob.rs new file mode 100644 index 0000000..3ede736 --- /dev/null +++ b/crates/jmap-proto/src/object/blob.rs @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId, MaybeReference, parse_ref}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct Blob; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BlobProperty { + Id, + BlobId, + Type, + Size, + Digest(DigestProperty), + Data(DataProperty), + IsEncodingProblem, + IsTruncated, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DigestProperty { + Sha, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DataProperty { + AsText, + AsBase64, + Default, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BlobValue { + BlobId(BlobId), + IdReference(String), +} + +impl Property for BlobProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + BlobProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + BlobProperty::BlobId => "blobId", + BlobProperty::Id => "id", + BlobProperty::Size => "size", + BlobProperty::Type => "type", + BlobProperty::IsEncodingProblem => "isEncodingProblem", + BlobProperty::IsTruncated => "isTruncated", + BlobProperty::Data(data) => match data { + DataProperty::AsText => "data:asText", + DataProperty::AsBase64 => "data:asBase64", + DataProperty::Default => "data", + }, + BlobProperty::Digest(digest) => match digest { + DigestProperty::Sha => "digest:sha", + DigestProperty::Sha256 => "digest:sha-256", + DigestProperty::Sha512 => "digest:sha-512", + }, + } + .into() + } +} + +impl Element for BlobValue { + type Property = BlobProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + BlobProperty::BlobId => match parse_ref(value) { + MaybeReference::Value(v) => Some(BlobValue::BlobId(v)), + MaybeReference::Reference(v) => Some(BlobValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + BlobValue::BlobId(blob_id) => blob_id.to_string().into(), + BlobValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl BlobProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"blobId" => BlobProperty::BlobId, + b"id" => BlobProperty::Id, + b"size" => BlobProperty::Size, + b"type" => BlobProperty::Type, + b"isEncodingProblem" => BlobProperty::IsEncodingProblem, + b"isTruncated" => BlobProperty::IsTruncated, + b"data:asText" => BlobProperty::Data(DataProperty::AsText), + b"data:asBase64" => BlobProperty::Data(DataProperty::AsBase64), + b"data" => BlobProperty::Data(DataProperty::Default), + b"digest:sha" => BlobProperty::Digest(DigestProperty::Sha), + b"digest:sha-256" => BlobProperty::Digest(DigestProperty::Sha256), + b"digest:sha-512" => BlobProperty::Digest(DigestProperty::Sha512), + ) + } +} + +impl FromStr for BlobProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + BlobProperty::parse(s).ok_or(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct BlobGetArguments { + pub offset: Option, + pub length: Option, +} + +impl<'de> DeserializeArguments<'de> for BlobGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"offset" => { + self.offset = map.next_value()?; + }, + b"length" => { + self.length = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl JmapObject for Blob { + type Property = BlobProperty; + + type Element = BlobValue; + + type Id = BlobId; + + type Filter = (); + + type Comparator = (); + + type GetArguments = BlobGetArguments; + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = BlobProperty::Id; +} + +impl From for BlobValue { + fn from(id: BlobId) -> Self { + BlobValue::BlobId(id) + } +} + +impl JmapObjectId for BlobValue { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + match self { + BlobValue::BlobId(id) => Some(AnyId::BlobId(id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let BlobValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::BlobId(id) = new_id { + *self = BlobValue::BlobId(id); + return true; + } + false + } +} + +impl JmapObjectId for BlobProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/calendar.rs b/crates/jmap-proto/src/object/calendar.rs new file mode 100644 index 0000000..e17c688 --- /dev/null +++ b/crates/jmap-proto/src/object/calendar.rs @@ -0,0 +1,475 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, + }, + request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, + types::date::UTCDate, +}; +use calcard::{ + common::{IanaParse, timezone::Tz}, + icalendar::ICalendarDuration, + jscalendar::{JSCalendarAlertAction, JSCalendarRelativeTo, JSCalendarType}, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::{acl::Acl, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct Calendar; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarProperty { + Id, + Name, + Description, + Color, + SortOrder, + IsSubscribed, + IsVisible, + IsDefault, + IncludeInAvailability, + DefaultAlertsWithTime, + DefaultAlertsWithoutTime, + TimeZone, + ShareWith, + MyRights, + + // Alert object properties + When, + Trigger, + Offset, + RelativeTo, + Action, + Type, + + // Other + IdValue(Id), + Rights(CalendarRight), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarRight { + MayReadFreeBusy, + MayReadItems, + MayWriteAll, + MayWriteOwn, + MayUpdatePrivate, + MayRSVP, + MayShare, + MayDelete, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarValue { + Id(Id), + IdReference(String), + IncludeInAvailability(IncludeInAvailability), + Date(UTCDate), + Timezone(Tz), + Action(JSCalendarAlertAction), + RelativeTo(JSCalendarRelativeTo), + Type(JSCalendarType), + Duration(ICalendarDuration), +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IncludeInAvailability { + #[default] + All, + Attending, + None, +} + +impl Property for CalendarProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + CalendarProperty::ShareWith => { + Id::from_str(value).ok().map(CalendarProperty::IdValue) + } + _ => CalendarProperty::parse(value, allow_patch), + } + } else { + CalendarProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + CalendarProperty::Id => "id", + CalendarProperty::Name => "name", + CalendarProperty::Description => "description", + CalendarProperty::Color => "color", + CalendarProperty::SortOrder => "sortOrder", + CalendarProperty::IsSubscribed => "isSubscribed", + CalendarProperty::IsVisible => "isVisible", + CalendarProperty::IsDefault => "isDefault", + CalendarProperty::IncludeInAvailability => "includeInAvailability", + CalendarProperty::DefaultAlertsWithTime => "defaultAlertsWithTime", + CalendarProperty::DefaultAlertsWithoutTime => "defaultAlertsWithoutTime", + CalendarProperty::TimeZone => "timeZone", + CalendarProperty::ShareWith => "shareWith", + CalendarProperty::MyRights => "myRights", + CalendarProperty::When => "when", + CalendarProperty::Trigger => "trigger", + CalendarProperty::Offset => "offset", + CalendarProperty::RelativeTo => "relativeTo", + CalendarProperty::Action => "action", + CalendarProperty::Type => "@type", + CalendarProperty::Rights(calendar_right) => calendar_right.as_str(), + CalendarProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + CalendarProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl CalendarRight { + pub fn as_str(&self) -> &'static str { + match self { + CalendarRight::MayReadFreeBusy => "mayReadFreeBusy", + CalendarRight::MayReadItems => "mayReadItems", + CalendarRight::MayWriteAll => "mayWriteAll", + CalendarRight::MayWriteOwn => "mayWriteOwn", + CalendarRight::MayUpdatePrivate => "mayUpdatePrivate", + CalendarRight::MayRSVP => "mayRSVP", + CalendarRight::MayShare => "mayShare", + CalendarRight::MayDelete => "mayDelete", + } + } +} + +impl IncludeInAvailability { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"all" => IncludeInAvailability::All, + b"attending" => IncludeInAvailability::Attending, + b"none" => IncludeInAvailability::None, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + IncludeInAvailability::All => "all", + IncludeInAvailability::Attending => "attending", + IncludeInAvailability::None => "none", + } + } +} + +impl Element for CalendarValue { + type Property = CalendarProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + CalendarProperty::Id => match parse_ref(value) { + MaybeReference::Value(v) => Some(CalendarValue::Id(v)), + MaybeReference::Reference(v) => Some(CalendarValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + CalendarProperty::TimeZone => Tz::from_str(value).ok().map(CalendarValue::Timezone), + CalendarProperty::IncludeInAvailability => { + IncludeInAvailability::parse(value).map(CalendarValue::IncludeInAvailability) + } + CalendarProperty::Action => JSCalendarAlertAction::from_str(value) + .ok() + .map(CalendarValue::Action), + CalendarProperty::RelativeTo => JSCalendarRelativeTo::from_str(value) + .ok() + .map(CalendarValue::RelativeTo), + CalendarProperty::When => UTCDate::from_str(value).ok().map(CalendarValue::Date), + CalendarProperty::Offset => { + ICalendarDuration::parse(value.as_bytes()).map(CalendarValue::Duration) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + CalendarValue::Id(id) => id.to_string().into(), + CalendarValue::IdReference(r) => format!("#{r}").into(), + CalendarValue::IncludeInAvailability(include) => include.as_str().into(), + CalendarValue::Date(date) => date.to_string().into(), + CalendarValue::Action(action) => action.as_str().into(), + CalendarValue::RelativeTo(relative) => relative.as_str().into(), + CalendarValue::Type(typ) => typ.as_str().into(), + CalendarValue::Duration(dur) => dur.to_string().into(), + CalendarValue::Timezone(tz) => tz.name().unwrap_or_default(), + } + } +} + +impl CalendarProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => CalendarProperty::Id, + b"name" => CalendarProperty::Name, + b"description" => CalendarProperty::Description, + b"color" => CalendarProperty::Color, + b"sortOrder" => CalendarProperty::SortOrder, + b"isSubscribed" => CalendarProperty::IsSubscribed, + b"isVisible" => CalendarProperty::IsVisible, + b"isDefault" => CalendarProperty::IsDefault, + b"includeInAvailability" => CalendarProperty::IncludeInAvailability, + b"defaultAlertsWithTime" => CalendarProperty::DefaultAlertsWithTime, + b"defaultAlertsWithoutTime" => CalendarProperty::DefaultAlertsWithoutTime, + b"timeZone" => CalendarProperty::TimeZone, + b"shareWith" => CalendarProperty::ShareWith, + b"myRights" => CalendarProperty::MyRights, + b"mayReadFreeBusy" => CalendarProperty::Rights(CalendarRight::MayReadFreeBusy), + b"mayReadItems" => CalendarProperty::Rights(CalendarRight::MayReadItems), + b"mayWriteAll" => CalendarProperty::Rights(CalendarRight::MayWriteAll), + b"mayWriteOwn" => CalendarProperty::Rights(CalendarRight::MayWriteOwn), + b"mayUpdatePrivate" => CalendarProperty::Rights(CalendarRight::MayUpdatePrivate), + b"mayRSVP" => CalendarProperty::Rights(CalendarRight::MayRSVP), + b"mayShare" => CalendarProperty::Rights(CalendarRight::MayShare), + b"mayDelete" => CalendarProperty::Rights(CalendarRight::MayDelete), + b"@type" => CalendarProperty::Type, + b"when" => CalendarProperty::When, + b"trigger" => CalendarProperty::Trigger, + b"offset" => CalendarProperty::Offset, + b"relativeTo" => CalendarProperty::RelativeTo, + b"action" => CalendarProperty::Action, + ) + .or_else(|| { + if allow_patch && value.contains('/') { + CalendarProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &CalendarProperty { + if let CalendarProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct CalendarSetArguments { + pub on_destroy_remove_events: Option, + pub on_success_set_is_default: Option>, +} + +impl<'de> DeserializeArguments<'de> for CalendarSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"onDestroyRemoveEvents" => { + self.on_destroy_remove_events = map.next_value()?; + }, + b"onSuccessSetIsDefault" => { + self.on_success_set_is_default = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for CalendarProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + CalendarProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for Calendar { + type Property = CalendarProperty; + + type Element = CalendarValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = CalendarSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = CalendarProperty::Id; +} + +impl JmapSharedObject for Calendar { + type Right = CalendarRight; + + const SHARE_WITH_PROPERTY: Self::Property = CalendarProperty::ShareWith; +} + +impl From for CalendarProperty { + fn from(id: Id) -> Self { + CalendarProperty::IdValue(id) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: CalendarProperty) -> Result { + if let CalendarProperty::IdValue(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for CalendarRight { + type Error = (); + + fn try_from(value: CalendarProperty) -> Result { + if let CalendarProperty::Rights(right) = value { + Ok(right) + } else { + Err(()) + } + } +} + +impl From for CalendarValue { + fn from(id: Id) -> Self { + CalendarValue::Id(id) + } +} + +impl JmapObjectId for CalendarValue { + fn as_id(&self) -> Option { + if let CalendarValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let CalendarValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let CalendarValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(new_id) = new_id { + *self = CalendarValue::Id(new_id); + return true; + } + false + } +} + +impl JmapRight for CalendarRight { + fn to_acl(&self) -> &'static [Acl] { + match self { + CalendarRight::MayReadFreeBusy => &[Acl::SchedulingReadFreeBusy], + CalendarRight::MayReadItems => &[Acl::Read, Acl::ReadItems], + CalendarRight::MayWriteAll => &[ + Acl::Modify, + Acl::AddItems, + Acl::ModifyItems, + Acl::RemoveItems, + ], + CalendarRight::MayWriteOwn => &[Acl::ModifyItemsOwn], + CalendarRight::MayUpdatePrivate => &[Acl::ModifyPrivateProperties], + CalendarRight::MayRSVP => &[Acl::ModifyRSVP], + CalendarRight::MayShare => &[Acl::Share], + CalendarRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], + } + } + + fn all_rights() -> &'static [Self] { + &[ + CalendarRight::MayReadFreeBusy, + CalendarRight::MayReadItems, + CalendarRight::MayWriteAll, + CalendarRight::MayWriteOwn, + CalendarRight::MayUpdatePrivate, + CalendarRight::MayRSVP, + CalendarRight::MayShare, + CalendarRight::MayDelete, + ] + } +} + +impl From for CalendarProperty { + fn from(right: CalendarRight) -> Self { + CalendarProperty::Rights(right) + } +} + +impl JmapObjectId for CalendarProperty { + fn as_id(&self) -> Option { + if let CalendarProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let CalendarProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(new_id) = new_id { + *self = CalendarProperty::IdValue(new_id); + return true; + } + false + } +} + +impl Display for CalendarProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/calendar_event.rs b/crates/jmap-proto/src/object/calendar_event.rs new file mode 100644 index 0000000..591f9f6 --- /dev/null +++ b/crates/jmap-proto/src/object/calendar_event.rs @@ -0,0 +1,386 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::{MaybeInvalid, deserialize::DeserializeArguments}, +}; +use calcard::{ + common::timezone::Tz, + jscalendar::{JSCalendarDateTime, JSCalendarProperty, JSCalendarValue}, +}; +use jmap_tools::{JsonPointerItem, Key}; +use mail_parser::DateTime; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct CalendarEvent; + +impl JmapObject for CalendarEvent { + type Property = JSCalendarProperty; + + type Element = JSCalendarValue; + + type Id = Id; + + type Filter = CalendarEventFilter; + + type Comparator = CalendarEventComparator; + + type GetArguments = CalendarEventGetArguments; + + type SetArguments<'de> = CalendarEventSetArguments; + + type QueryArguments = CalendarEventQueryArguments; + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = JSCalendarProperty::Id; +} + +impl JmapObjectId for JSCalendarValue { + fn as_id(&self) -> Option { + if let JSCalendarValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + match self { + JSCalendarValue::Id(id) => Some(AnyId::Id(*id)), + JSCalendarValue::BlobId(blob_id) => Some(AnyId::BlobId(blob_id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + match self { + JSCalendarValue::IdReference(r) => Some(r), + _ => None, + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = JSCalendarValue::Id(id); + true + } else { + false + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarEventFilter { + InCalendar(MaybeInvalid), + After(JSCalendarDateTime), + Before(JSCalendarDateTime), + Text(String), + Title(String), + Description(String), + Location(String), + Owner(String), + Attendee(String), + Uid(String), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarEventComparator { + Start, + Uid, + RecurrenceId, + Created, + Updated, + _T(String), +} + +#[derive(Debug, Clone, Default)] +pub struct CalendarEventGetArguments { + pub recurrence_overrides_before: Option, + pub recurrence_overrides_after: Option, + pub reduce_participants: Option, + pub time_zone: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CalendarEventSetArguments { + pub send_scheduling_messages: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CalendarEventQueryArguments { + pub expand_recurrences: Option, + pub time_zone: Option, +} + +impl<'de> DeserializeArguments<'de> for CalendarEventFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"inCalendar" => { + *self = CalendarEventFilter::InCalendar(map.next_value()?); + }, + b"after" => { + *self = CalendarEventFilter::After(map.next_value::()?.0); + }, + b"before" => { + *self = CalendarEventFilter::Before(map.next_value::()?.0); + }, + b"text" => { + *self = CalendarEventFilter::Text(map.next_value::>()?.to_lowercase()); + }, + b"title" => { + *self = CalendarEventFilter::Title(map.next_value::>()?.to_lowercase()); + }, + b"description" => { + *self = CalendarEventFilter::Description(map.next_value::>()?.to_lowercase()); + }, + b"location" => { + *self = CalendarEventFilter::Location(map.next_value::>()?.to_lowercase()); + }, + b"owner" => { + *self = CalendarEventFilter::Owner(map.next_value::>()?.to_lowercase()); + }, + b"attendee" => { + *self = CalendarEventFilter::Attendee(map.next_value::>()?.to_lowercase()); + }, + b"uid" => { + *self = CalendarEventFilter::Uid(map.next_value()?); + }, + _ => { + *self = CalendarEventFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CalendarEventComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"start" => { + *self = CalendarEventComparator::Start; + }, + b"uid" => { + *self = CalendarEventComparator::Uid; + }, + b"recurrenceId" => { + *self = CalendarEventComparator::RecurrenceId; + }, + b"created" => { + *self = CalendarEventComparator::Created; + }, + b"updated" => { + *self = CalendarEventComparator::Updated; + }, + _ => { + *self = CalendarEventComparator::_T(value.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CalendarEventGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"recurrenceOverridesBefore" => { + self.recurrence_overrides_before = map.next_value::>()?.map(|lt| lt.0) + }, + b"recurrenceOverridesAfter" => { + self.recurrence_overrides_after = map.next_value::>()?.map(|lt| lt.0); + }, + b"reduceParticipants" => { + self.reduce_participants = map.next_value()?; + }, + b"timeZone" => { + self.time_zone = map.next_value::>()?.and_then(|s| Tz::from_str(s).ok()); + }, + _ => { + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CalendarEventSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"sendSchedulingMessages" => { + self.send_scheduling_messages = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CalendarEventQueryArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"expandRecurrences" => { + self.expand_recurrences = map.next_value()?; + }, + b"timeZone" => { + self.time_zone = map.next_value::>()?.and_then(|s| Tz::from_str(s).ok()); + }, + _ => { + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl CalendarEventFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + CalendarEventFilter::InCalendar(_) => "inCalendar", + CalendarEventFilter::After(_) => "after", + CalendarEventFilter::Before(_) => "before", + CalendarEventFilter::Text(_) => "text", + CalendarEventFilter::Title(_) => "title", + CalendarEventFilter::Description(_) => "description", + CalendarEventFilter::Location(_) => "location", + CalendarEventFilter::Owner(_) => "owner", + CalendarEventFilter::Attendee(_) => "attendee", + CalendarEventFilter::Uid(_) => "uid", + CalendarEventFilter::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl CalendarEventComparator { + pub fn into_string(self) -> Cow<'static, str> { + match self { + CalendarEventComparator::Start => "start", + CalendarEventComparator::Uid => "uid", + CalendarEventComparator::RecurrenceId => "recurrenceId", + CalendarEventComparator::Created => "created", + CalendarEventComparator::Updated => "updated", + CalendarEventComparator::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl Default for CalendarEventFilter { + fn default() -> Self { + CalendarEventFilter::_T(String::new()) + } +} + +impl Default for CalendarEventComparator { + fn default() -> Self { + CalendarEventComparator::_T(String::new()) + } +} + +struct LocalTime(JSCalendarDateTime); + +impl<'de> serde::Deserialize<'de> for LocalTime { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = <&str>::deserialize(deserializer)?; + + if let Some(dt) = DateTime::parse_rfc3339(value) { + Ok(LocalTime(JSCalendarDateTime { + timestamp: dt.to_timestamp_local(), + is_local: true, + })) + } else { + Err(serde::de::Error::custom(format!( + "Invalid datetime: {}", + value + ))) + } + } +} + +impl JmapObjectId for JSCalendarProperty { + fn as_id(&self) -> Option { + if let JSCalendarProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let JSCalendarProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + match self { + JSCalendarProperty::IdReference(r) => Some(r), + JSCalendarProperty::Pointer(value) => { + let value = value.as_slice(); + match (value.first(), value.get(1)) { + ( + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::CalendarIds))), + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::IdReference( + r, + )))), + ) => Some(r), + _ => None, + } + } + _ => None, + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + if let JSCalendarProperty::Pointer(value) = self { + let value = value.as_mut_slice(); + if let Some(value) = value.get_mut(1) { + *value = JsonPointerItem::Key(Key::Property(JSCalendarProperty::IdValue(id))); + return true; + } + } else { + *self = JSCalendarProperty::IdValue(id); + return true; + } + } + false + } +} diff --git a/crates/jmap-proto/src/object/calendar_event_notification.rs b/crates/jmap-proto/src/object/calendar_event_notification.rs new file mode 100644 index 0000000..64c2db3 --- /dev/null +++ b/crates/jmap-proto/src/object/calendar_event_notification.rs @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::{MaybeInvalid, deserialize::DeserializeArguments}, + types::{date::UTCDate, state::State}, +}; +use calcard::jscalendar::JSCalendar; +use jmap_tools::{Element, Key, Property}; +use serde::Serialize; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct CalendarEventNotification; + +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct CalendarEventNotificationObject { + pub id: Id, + + #[serde(skip_serializing_if = "Option::is_none")] + pub created: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub changed_by: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "type")] + pub notification_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub calendar_event_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_draft: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub event_patch: Option>, +} + +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct PersonObject { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub principal_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub calendar_address: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CalendarEventNotificationGetResponse { + #[serde(rename = "accountId")] + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + + pub list: Vec, + + #[serde(rename = "notFound")] + pub not_found: Vec>, +} + +impl CalendarEventNotificationGetResponse { + pub fn push_not_found(&mut self, id: Id) { + self.not_found.push(crate::request::MaybeInvalid::Value(id)); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarEventNotificationProperty { + Id, + Created, + ChangedBy, + Comment, + Type, + CalendarEventId, + IsDraft, + Event, + EventPatch, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarEventNotificationValue { + Id(Id), + Date(UTCDate), + Type(CalendarEventNotificationType), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarEventNotificationType { + Created, + Updated, + Destroyed, +} + +impl Property for CalendarEventNotificationProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + CalendarEventNotificationProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + CalendarEventNotificationProperty::Id => "id", + CalendarEventNotificationProperty::Created => "created", + CalendarEventNotificationProperty::ChangedBy => "changedBy", + CalendarEventNotificationProperty::Comment => "comment", + CalendarEventNotificationProperty::Type => "type", + CalendarEventNotificationProperty::CalendarEventId => "calendarEventId", + CalendarEventNotificationProperty::IsDraft => "isDraft", + CalendarEventNotificationProperty::Event => "event", + CalendarEventNotificationProperty::EventPatch => "eventPatch", + } + .into() + } +} + +impl Element for CalendarEventNotificationValue { + type Property = CalendarEventNotificationProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + CalendarEventNotificationProperty::Id + | CalendarEventNotificationProperty::CalendarEventId => Id::from_str(value) + .ok() + .map(CalendarEventNotificationValue::Id), + CalendarEventNotificationProperty::Created => UTCDate::from_str(value) + .ok() + .map(CalendarEventNotificationValue::Date), + CalendarEventNotificationProperty::Type => { + CalendarEventNotificationType::parse(value) + .map(CalendarEventNotificationValue::Type) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + CalendarEventNotificationValue::Id(id) => id.to_string().into(), + CalendarEventNotificationValue::Date(date) => date.to_string().into(), + CalendarEventNotificationValue::Type(t) => t.as_str().into(), + } + } +} + +impl CalendarEventNotificationType { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"created" => CalendarEventNotificationType::Created, + b"updated" => CalendarEventNotificationType::Updated, + b"destroyed" => CalendarEventNotificationType::Destroyed, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + CalendarEventNotificationType::Created => "created", + CalendarEventNotificationType::Updated => "updated", + CalendarEventNotificationType::Destroyed => "destroyed", + } + } +} + +impl CalendarEventNotificationProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => CalendarEventNotificationProperty::Id, + b"created" => CalendarEventNotificationProperty::Created, + b"changedBy" => CalendarEventNotificationProperty::ChangedBy, + b"comment" => CalendarEventNotificationProperty::Comment, + b"type" => CalendarEventNotificationProperty::Type, + b"calendarEventId" => CalendarEventNotificationProperty::CalendarEventId, + b"isDraft" => CalendarEventNotificationProperty::IsDraft, + b"event" => CalendarEventNotificationProperty::Event, + b"eventPatch" => CalendarEventNotificationProperty::EventPatch + ) + } +} + +impl FromStr for CalendarEventNotificationProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + CalendarEventNotificationProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for CalendarEventNotification { + type Property = CalendarEventNotificationProperty; + + type Element = CalendarEventNotificationValue; + + type Id = Id; + + type Filter = CalendarEventNotificationFilter; + + type Comparator = CalendarEventNotificationComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = CalendarEventNotificationProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarEventNotificationFilter { + After(UTCDate), + Before(UTCDate), + Type(CalendarEventNotificationType), + CalendarEventIds(Vec>), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CalendarEventNotificationComparator { + Created, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for CalendarEventNotificationFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"after" => { + *self = CalendarEventNotificationFilter::After(map.next_value()?); + }, + b"before" => { + *self = CalendarEventNotificationFilter::Before(map.next_value()?); + }, + b"type" => { + *self = CalendarEventNotificationFilter::Type(map.next_value()?); + }, + b"calendarEventIds" => { + *self = CalendarEventNotificationFilter::CalendarEventIds(map.next_value()?); + }, + _ => { + *self = CalendarEventNotificationFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for CalendarEventNotificationComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"created" => { + *self = CalendarEventNotificationComparator::Created; + }, + _ => { + *self = CalendarEventNotificationComparator::_T(value.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl<'de> serde::Deserialize<'de> for CalendarEventNotificationType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + CalendarEventNotificationType::parse(<&str>::deserialize(deserializer)?) + .ok_or_else(|| serde::de::Error::custom("invalid CalendarEventNotificationType")) + } +} + +impl CalendarEventNotificationFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + CalendarEventNotificationFilter::After(_) => "after", + CalendarEventNotificationFilter::Before(_) => "before", + CalendarEventNotificationFilter::Type(_) => "type", + CalendarEventNotificationFilter::CalendarEventIds(_) => "calendarEventIds", + CalendarEventNotificationFilter::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl CalendarEventNotificationComparator { + pub fn into_string(self) -> Cow<'static, str> { + match self { + CalendarEventNotificationComparator::Created => "created", + CalendarEventNotificationComparator::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl Default for CalendarEventNotificationFilter { + fn default() -> Self { + CalendarEventNotificationFilter::_T(String::new()) + } +} + +impl Default for CalendarEventNotificationComparator { + fn default() -> Self { + CalendarEventNotificationComparator::_T(String::new()) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(_: CalendarEventNotificationProperty) -> Result { + Err(()) + } +} + +impl From for CalendarEventNotificationValue { + fn from(id: Id) -> Self { + CalendarEventNotificationValue::Id(id) + } +} + +impl JmapObjectId for CalendarEventNotificationValue { + fn as_id(&self) -> Option { + if let CalendarEventNotificationValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let CalendarEventNotificationValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl JmapObjectId for CalendarEventNotificationProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl serde::Serialize for CalendarEventNotificationType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Display for CalendarEventNotificationProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/contact.rs b/crates/jmap-proto/src/object/contact.rs new file mode 100644 index 0000000..a399a36 --- /dev/null +++ b/crates/jmap-proto/src/object/contact.rs @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::{MaybeInvalid, deserialize::DeserializeArguments}, + types::date::UTCDate, +}; +use calcard::jscontact::{JSContactProperty, JSContactValue}; +use jmap_tools::{JsonPointerItem, Key}; +use std::borrow::Cow; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct ContactCard; + +impl JmapObject for ContactCard { + type Property = JSContactProperty; + + type Element = JSContactValue; + + type Id = Id; + + type Filter = ContactCardFilter; + + type Comparator = ContactCardComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = JSContactProperty::Id; +} + +impl JmapObjectId for JSContactValue { + fn as_id(&self) -> Option { + if let JSContactValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + match self { + JSContactValue::Id(id) => Some(AnyId::Id(*id)), + JSContactValue::BlobId(id) => Some(AnyId::BlobId(id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + match self { + JSContactValue::IdReference(r) => Some(r), + _ => None, + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = JSContactValue::Id(id); + } + AnyId::BlobId(id) => { + *self = JSContactValue::BlobId(id); + } + } + + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContactCardFilter { + InAddressBook(MaybeInvalid), + Uid(String), + HasMember(String), + Kind(String), + CreatedBefore(UTCDate), + CreatedAfter(UTCDate), + UpdatedBefore(UTCDate), + UpdatedAfter(UTCDate), + Text(String), + Name(String), + NameGiven(String), + NameSurname(String), + NameSurname2(String), + Nickname(String), + Organization(String), + Email(String), + Phone(String), + OnlineService(String), + Address(String), + Note(String), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContactCardComparator { + Created, + Updated, + NameGiven, + NameSurname, + NameSurname2, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for ContactCardFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"inAddressBook" => { + *self = ContactCardFilter::InAddressBook(map.next_value()?); + }, + b"uid" => { + *self = ContactCardFilter::Uid(map.next_value()?); + }, + b"hasMember" => { + *self = ContactCardFilter::HasMember(map.next_value()?); + }, + b"kind" => { + *self = ContactCardFilter::Kind(map.next_value()?); + }, + b"createdBefore" => { + *self = ContactCardFilter::CreatedBefore(map.next_value()?); + }, + b"createdAfter" => { + *self = ContactCardFilter::CreatedAfter(map.next_value()?); + }, + b"updatedBefore" => { + *self = ContactCardFilter::UpdatedBefore(map.next_value()?); + }, + b"updatedAfter" => { + *self = ContactCardFilter::UpdatedAfter(map.next_value()?); + }, + b"text" => { + *self = ContactCardFilter::Text(map.next_value::>()?.to_lowercase()); + }, + b"name" => { + *self = ContactCardFilter::Name(map.next_value::>()?.to_lowercase()); + }, + b"name/given" => { + *self = ContactCardFilter::NameGiven(map.next_value::>()?.to_lowercase()); + }, + b"name/surname" => { + *self = ContactCardFilter::NameSurname(map.next_value::>()?.to_lowercase()); + }, + b"name/surname2" => { + *self = ContactCardFilter::NameSurname2(map.next_value::>()?.to_lowercase()); + }, + b"nickname" => { + *self = ContactCardFilter::Nickname(map.next_value::>()?.to_lowercase()); + }, + b"organization" => { + *self = ContactCardFilter::Organization(map.next_value::>()?.to_lowercase()); + }, + b"email" => { + *self = ContactCardFilter::Email(map.next_value()?); + }, + b"phone" => { + *self = ContactCardFilter::Phone(map.next_value::>()?.to_lowercase()); + }, + b"onlineService" => { + *self = ContactCardFilter::OnlineService(map.next_value::>()?.to_lowercase()); + }, + b"address" => { + *self = ContactCardFilter::Address(map.next_value::>()?.to_lowercase()); + }, + b"note" => { + *self = ContactCardFilter::Note(map.next_value::>()?.to_lowercase()); + }, + _ => { + *self = ContactCardFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for ContactCardComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"created" => { + *self = ContactCardComparator::Created; + }, + b"updated" => { + *self = ContactCardComparator::Updated; + }, + b"name/given" => { + *self = ContactCardComparator::NameGiven; + }, + b"name/surname" => { + *self = ContactCardComparator::NameSurname; + }, + b"name/surname2" => { + *self = ContactCardComparator::NameSurname2; + }, + _ => { + *self = ContactCardComparator::_T(value.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl ContactCardFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ContactCardFilter::InAddressBook(_) => "inAddressBook", + ContactCardFilter::Uid(_) => "uid", + ContactCardFilter::HasMember(_) => "hasMember", + ContactCardFilter::Kind(_) => "kind", + ContactCardFilter::CreatedBefore(_) => "createdBefore", + ContactCardFilter::CreatedAfter(_) => "createdAfter", + ContactCardFilter::UpdatedBefore(_) => "updatedBefore", + ContactCardFilter::UpdatedAfter(_) => "updatedAfter", + ContactCardFilter::Text(_) => "text", + ContactCardFilter::Name(_) => "name", + ContactCardFilter::NameGiven(_) => "name/given", + ContactCardFilter::NameSurname(_) => "name/surname", + ContactCardFilter::NameSurname2(_) => "name/surname2", + ContactCardFilter::Nickname(_) => "nickname", + ContactCardFilter::Organization(_) => "organization", + ContactCardFilter::Email(_) => "email", + ContactCardFilter::Phone(_) => "phone", + ContactCardFilter::OnlineService(_) => "onlineService", + ContactCardFilter::Address(_) => "address", + ContactCardFilter::Note(_) => "note", + ContactCardFilter::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl ContactCardComparator { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ContactCardComparator::Created => "created", + ContactCardComparator::Updated => "updated", + ContactCardComparator::NameGiven => "name/given", + ContactCardComparator::NameSurname => "name/surname", + ContactCardComparator::NameSurname2 => "name/surname2", + ContactCardComparator::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl Default for ContactCardFilter { + fn default() -> Self { + ContactCardFilter::_T(String::new()) + } +} + +impl Default for ContactCardComparator { + fn default() -> Self { + ContactCardComparator::_T(String::new()) + } +} + +impl JmapObjectId for JSContactProperty { + fn as_id(&self) -> Option { + if let JSContactProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let JSContactProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + match self { + JSContactProperty::IdReference(r) => Some(r), + JSContactProperty::Pointer(value) => { + let value = value.as_slice(); + match (value.first(), value.get(1)) { + ( + Some(JsonPointerItem::Key(Key::Property( + JSContactProperty::AddressBookIds, + ))), + Some(JsonPointerItem::Key(Key::Property(JSContactProperty::IdReference( + r, + )))), + ) => Some(r), + _ => None, + } + } + _ => None, + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + if let JSContactProperty::Pointer(value) = self { + let value = value.as_mut_slice(); + if let Some(value) = value.get_mut(1) { + *value = JsonPointerItem::Key(Key::Property(JSContactProperty::IdValue(id))); + return true; + } + } else { + *self = JSContactProperty::IdValue(id); + return true; + } + } + false + } +} diff --git a/crates/jmap-proto/src/object/email.rs b/crates/jmap-proto/src/object/email.rs new file mode 100644 index 0000000..9bf62a6 --- /dev/null +++ b/crates/jmap-proto/src/object/email.rs @@ -0,0 +1,967 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + method::query::{Comparator, Filter}, + object::{AnyId, JmapObject, JmapObjectId, MaybeReference, parse_ref}, + request::{MaybeInvalid, deserialize::DeserializeArguments}, + types::date::UTCDate, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use mail_parser::HeaderName; +use serde::Serialize; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::{blob::BlobId, id::Id, keyword::Keyword}; + +#[derive(Debug, Clone, Default)] +pub struct Email; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EmailProperty { + // Metadata + Id, + BlobId, + ThreadId, + MailboxIds, + Keywords, + Size, + ReceivedAt, + + // Address + Name, + Email, + + // GroupedAddresses + Addresses, + + // Header Fields Properties + Value, + Header(HeaderProperty), + + // Convenience properties + MessageId, + InReplyTo, + References, + Sender, + From, + To, + Cc, + Bcc, + ReplyTo, + Subject, + SentAt, + + // Body Parts + TextBody, + HtmlBody, + Attachments, + PartId, + Headers, + Type, + Charset, + Disposition, + Cid, + Language, + Location, + SubParts, + BodyStructure, + BodyValues, + IsEncodingProblem, + IsTruncated, + HasAttachment, + Preview, + + // Other + Keyword(Keyword), + IdValue(Id), + IdReference(String), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HeaderProperty { + pub form: HeaderForm, + pub header: String, + pub all: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HeaderForm { + Raw, + Text, + Addresses, + GroupedAddresses, + MessageIds, + Date, + URLs, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EmailValue { + Id(Id), + Date(UTCDate), + BlobId(BlobId), + IdReference(String), +} + +impl Property for EmailProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + EmailProperty::Keywords => EmailProperty::Keyword(Keyword::parse(value)).into(), + EmailProperty::MailboxIds => match parse_ref(value) { + MaybeReference::Value(v) => Some(EmailProperty::IdValue(v)), + MaybeReference::Reference(v) => Some(EmailProperty::IdReference(v)), + MaybeReference::ParseError => None, + }, + _ => EmailProperty::parse(value, allow_patch), + } + } else { + EmailProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + EmailProperty::Attachments => "attachments", + EmailProperty::Bcc => "bcc", + EmailProperty::BlobId => "blobId", + EmailProperty::BodyStructure => "bodyStructure", + EmailProperty::BodyValues => "bodyValues", + EmailProperty::Cc => "cc", + EmailProperty::Charset => "charset", + EmailProperty::Cid => "cid", + EmailProperty::Disposition => "disposition", + EmailProperty::Email => "email", + EmailProperty::From => "from", + EmailProperty::HasAttachment => "hasAttachment", + EmailProperty::Headers => "headers", + EmailProperty::HtmlBody => "htmlBody", + EmailProperty::Id => "id", + EmailProperty::InReplyTo => "inReplyTo", + EmailProperty::Keywords => "keywords", + EmailProperty::Language => "language", + EmailProperty::Location => "location", + EmailProperty::MailboxIds => "mailboxIds", + EmailProperty::MessageId => "messageId", + EmailProperty::Name => "name", + EmailProperty::PartId => "partId", + EmailProperty::Preview => "preview", + EmailProperty::ReceivedAt => "receivedAt", + EmailProperty::References => "references", + EmailProperty::ReplyTo => "replyTo", + EmailProperty::Sender => "sender", + EmailProperty::SentAt => "sentAt", + EmailProperty::Size => "size", + EmailProperty::Subject => "subject", + EmailProperty::SubParts => "subParts", + EmailProperty::TextBody => "textBody", + EmailProperty::ThreadId => "threadId", + EmailProperty::To => "to", + EmailProperty::Type => "type", + EmailProperty::Addresses => "addresses", + EmailProperty::Value => "value", + EmailProperty::IsEncodingProblem => "isEncodingProblem", + EmailProperty::IsTruncated => "isTruncated", + EmailProperty::Header(header) => return header.to_string().into(), + EmailProperty::Keyword(keyword) => return keyword.to_string().into(), + EmailProperty::IdValue(id) => return id.to_string().into(), + EmailProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + EmailProperty::IdReference(r) => return format!("#{r}").into(), + } + .into() + } +} + +impl Element for EmailValue { + type Property = EmailProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + EmailProperty::Id | EmailProperty::ThreadId | EmailProperty::MailboxIds => { + match parse_ref(value) { + MaybeReference::Value(v) => Some(EmailValue::Id(v)), + MaybeReference::Reference(v) => Some(EmailValue::IdReference(v)), + MaybeReference::ParseError => None, + } + } + EmailProperty::BlobId => match parse_ref(value) { + MaybeReference::Value(v) => Some(EmailValue::BlobId(v)), + MaybeReference::Reference(v) => Some(EmailValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + EmailProperty::Header(HeaderProperty { + form: HeaderForm::Date, + .. + }) + | EmailProperty::ReceivedAt + | EmailProperty::SentAt => UTCDate::from_str(value).ok().map(EmailValue::Date), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + EmailValue::Id(id) => id.to_string().into(), + EmailValue::Date(utcdate) => utcdate.to_string().into(), + EmailValue::BlobId(blob_id) => blob_id.to_string().into(), + EmailValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl EmailProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + "id" => EmailProperty::Id, + "blobId" => EmailProperty::BlobId, + "threadId" => EmailProperty::ThreadId, + "mailboxIds" => EmailProperty::MailboxIds, + "keywords" => EmailProperty::Keywords, + "size" => EmailProperty::Size, + "receivedAt" => EmailProperty::ReceivedAt, + "name" => EmailProperty::Name, + "email" => EmailProperty::Email, + "addresses" => EmailProperty::Addresses, + "value" => EmailProperty::Value, + "messageId" => EmailProperty::MessageId, + "inReplyTo" => EmailProperty::InReplyTo, + "references" => EmailProperty::References, + "sender" => EmailProperty::Sender, + "from" => EmailProperty::From, + "to" => EmailProperty::To, + "cc" => EmailProperty::Cc, + "bcc" => EmailProperty::Bcc, + "replyTo" => EmailProperty::ReplyTo, + "subject" => EmailProperty::Subject, + "sentAt" => EmailProperty::SentAt, + "textBody" => EmailProperty::TextBody, + "htmlBody" => EmailProperty::HtmlBody, + "attachments" => EmailProperty::Attachments, + "partId" => EmailProperty::PartId, + "headers" => EmailProperty::Headers, + "type" => EmailProperty::Type, + "charset" => EmailProperty::Charset, + "disposition" => EmailProperty::Disposition, + "cid" => EmailProperty::Cid, + "language" => EmailProperty::Language, + "location" => EmailProperty::Location, + "subParts" => EmailProperty::SubParts, + "bodyStructure" => EmailProperty::BodyStructure, + "bodyValues" => EmailProperty::BodyValues, + "isEncodingProblem" => EmailProperty::IsEncodingProblem, + "isTruncated" => EmailProperty::IsTruncated, + "hasAttachment" => EmailProperty::HasAttachment, + "preview" => EmailProperty::Preview + ) + .or_else(|| { + if let Some(header) = value.strip_prefix("header:") { + HeaderProperty::parse(header).map(EmailProperty::Header) + } else if allow_patch && value.contains('/') { + EmailProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &EmailProperty { + if let EmailProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } + + pub fn as_rfc_header(&self) -> HeaderName<'static> { + match self { + EmailProperty::MessageId => HeaderName::MessageId, + EmailProperty::InReplyTo => HeaderName::InReplyTo, + EmailProperty::References => HeaderName::References, + EmailProperty::Sender => HeaderName::Sender, + EmailProperty::From => HeaderName::From, + EmailProperty::To => HeaderName::To, + EmailProperty::Cc => HeaderName::Cc, + EmailProperty::Bcc => HeaderName::Bcc, + EmailProperty::ReplyTo => HeaderName::ReplyTo, + EmailProperty::Subject => HeaderName::Subject, + EmailProperty::SentAt => HeaderName::Date, + _ => unreachable!(), + } + } + + pub fn try_into_id(self) -> Option { + match self { + EmailProperty::IdValue(id) => Some(id), + _ => None, + } + } + + pub fn try_into_keyword(self) -> Option { + match self { + EmailProperty::Keyword(keyword) => Some(keyword), + _ => None, + } + } +} + +impl HeaderProperty { + fn parse(value: &str) -> Option { + let mut result = HeaderProperty { + form: HeaderForm::Raw, + header: String::new(), + all: false, + }; + + for (pos, value) in value.split(':').enumerate() { + match pos { + 0 => { + result.header = value.to_string(); + } + 1 => { + hashify::fnc_map!(value.as_bytes(), + b"asText" => { result.form = HeaderForm::Text;}, + b"asAddresses" => { result.form = HeaderForm::Addresses;}, + b"asGroupedAddresses" => { result.form = HeaderForm::GroupedAddresses;}, + b"asMessageIds" => { result.form = HeaderForm::MessageIds;}, + b"asDate" => { result.form = HeaderForm::Date;}, + b"asURLs" => { result.form = HeaderForm::URLs;}, + b"asRaw" => { result.form = HeaderForm::Raw; }, + b"all" => { result.all = true; }, + _ => { + return None; + } + ); + } + 2 if value == "all" && !result.all => { + result.all = true; + } + _ => return None, + } + } + + if !result.header.is_empty() { + Some(result) + } else { + None + } + } +} + +impl Display for HeaderProperty { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "header:{}", self.header)?; + self.form.fmt(f)?; + if self.all { write!(f, ":all") } else { Ok(()) } + } +} + +impl Display for HeaderForm { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + HeaderForm::Raw => Ok(()), + HeaderForm::Text => write!(f, ":asText"), + HeaderForm::Addresses => write!(f, ":asAddresses"), + HeaderForm::GroupedAddresses => write!(f, ":asGroupedAddresses"), + HeaderForm::MessageIds => write!(f, ":asMessageIds"), + HeaderForm::Date => write!(f, ":asDate"), + HeaderForm::URLs => write!(f, ":asURLs"), + } + } +} + +impl FromStr for EmailProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + EmailProperty::parse(s, false).ok_or(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct EmailGetArguments { + pub body_properties: Option>>, + pub fetch_text_body_values: Option, + pub fetch_html_body_values: Option, + pub fetch_all_body_values: Option, + pub max_body_value_bytes: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct EmailQueryArguments { + pub collapse_threads: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct EmailParseArguments { + pub body_properties: Option>>, + pub fetch_text_body_values: Option, + pub fetch_html_body_values: Option, + pub fetch_all_body_values: Option, + pub max_body_value_bytes: Option, +} + +impl<'de> DeserializeArguments<'de> for EmailGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"bodyProperties" => { + self.body_properties = map.next_value()?; + }, + b"fetchTextBodyValues" => { + self.fetch_text_body_values = map.next_value()?; + }, + b"fetchHTMLBodyValues" => { + self.fetch_html_body_values = map.next_value()?; + }, + b"fetchAllBodyValues" => { + self.fetch_all_body_values = map.next_value()?; + }, + b"maxBodyValueBytes" => { + self.max_body_value_bytes = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for EmailQueryArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "collapseThreads" { + self.collapse_threads = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for EmailParseArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"bodyProperties" => { + self.body_properties = map.next_value()?; + }, + b"fetchTextBodyValues" => { + self.fetch_text_body_values = map.next_value()?; + }, + b"fetchHTMLBodyValues" => { + self.fetch_html_body_values = map.next_value()?; + }, + b"fetchAllBodyValues" => { + self.fetch_all_body_values = map.next_value()?; + }, + b"maxBodyValueBytes" => { + self.max_body_value_bytes = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl JmapObject for Email { + type Property = EmailProperty; + + type Element = EmailValue; + + type Id = Id; + + type Filter = EmailFilter; + + type Comparator = EmailComparator; + + type GetArguments = EmailGetArguments; + + type SetArguments<'de> = (); + + type QueryArguments = EmailQueryArguments; + + type CopyArguments = (); + + type ParseArguments = EmailParseArguments; + + const ID_PROPERTY: Self::Property = EmailProperty::Id; +} + +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum EmailFilter { + InMailbox(Id), + InMailboxOtherThan(Vec), + Before(UTCDate), + After(UTCDate), + MinSize(u32), + MaxSize(u32), + AllInThreadHaveKeyword(Keyword), + SomeInThreadHaveKeyword(Keyword), + NoneInThreadHaveKeyword(Keyword), + HasKeyword(Keyword), + NotKeyword(Keyword), + HasAttachment(bool), + From(String), + To(String), + Cc(String), + Bcc(String), + Subject(String), + Body(String), + Header(Vec), + Text(String), + SentBefore(UTCDate), + SentAfter(UTCDate), + InThread(Id), + Id(Vec), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailComparator { + ReceivedAt, + Size, + From, + To, + Subject, + Cc, + SentAt, + ThreadId, + HasKeyword(Keyword), + AllInThreadHaveKeyword(Keyword), + SomeInThreadHaveKeyword(Keyword), + _T(String), +} + +impl<'de> DeserializeArguments<'de> for EmailFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"inMailbox" => { + *self = EmailFilter::InMailbox(map.next_value()?); + }, + b"inMailboxOtherThan" => { + *self = EmailFilter::InMailboxOtherThan(map.next_value()?); + }, + b"before" => { + *self = EmailFilter::Before(map.next_value()?); + }, + b"after" => { + *self = EmailFilter::After(map.next_value()?); + }, + b"minSize" => { + *self = EmailFilter::MinSize(map.next_value()?); + }, + b"maxSize" => { + *self = EmailFilter::MaxSize(map.next_value()?); + }, + b"allInThreadHaveKeyword" => { + *self = EmailFilter::AllInThreadHaveKeyword(map.next_value()?); + }, + b"someInThreadHaveKeyword" => { + *self = EmailFilter::SomeInThreadHaveKeyword(map.next_value()?); + }, + b"noneInThreadHaveKeyword" => { + *self = EmailFilter::NoneInThreadHaveKeyword(map.next_value()?); + }, + b"hasKeyword" => { + *self = EmailFilter::HasKeyword(map.next_value()?); + }, + b"notKeyword" => { + *self = EmailFilter::NotKeyword(map.next_value()?); + }, + b"hasAttachment" => { + *self = EmailFilter::HasAttachment(map.next_value()?); + }, + b"from" => { + *self = EmailFilter::From(map.next_value()?); + }, + b"to" => { + *self = EmailFilter::To(map.next_value()?); + }, + b"cc" => { + *self = EmailFilter::Cc(map.next_value()?); + }, + b"bcc" => { + *self = EmailFilter::Bcc(map.next_value()?); + }, + b"subject" => { + *self = EmailFilter::Subject(map.next_value()?); + }, + b"body" => { + *self = EmailFilter::Body(map.next_value()?); + }, + b"header" => { + *self = EmailFilter::Header(map.next_value()?); + }, + b"text" => { + *self = EmailFilter::Text(map.next_value()?); + }, + b"sentBefore" => { + *self = EmailFilter::SentBefore(map.next_value()?); + }, + b"sentAfter" => { + *self = EmailFilter::SentAfter(map.next_value()?); + }, + b"inThread" => { + *self = EmailFilter::InThread(map.next_value()?); + }, + b"id" => { + *self = EmailFilter::Id(map.next_value()?); + }, + _ => { + *self = EmailFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for EmailComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"receivedAt" => { + *self = EmailComparator::ReceivedAt; + }, + b"size" => { + *self = EmailComparator::Size; + }, + b"from" => { + *self = EmailComparator::From; + }, + b"to" => { + *self = EmailComparator::To; + }, + b"cc" => { + *self = EmailComparator::Cc; + }, + b"subject" => { + *self = EmailComparator::Subject; + }, + b"sentAt" => { + *self = EmailComparator::SentAt; + }, + b"threadId" => { + *self = EmailComparator::ThreadId; + }, + b"hasKeyword" => { + *self = EmailComparator::HasKeyword(self.take_keyword()); + }, + b"allInThreadHaveKeyword" => { + *self = EmailComparator::AllInThreadHaveKeyword(self.take_keyword()); + }, + b"someInThreadHaveKeyword" => { + *self = EmailComparator::SomeInThreadHaveKeyword(self.take_keyword()); + }, + _ => { + *self = EmailComparator::_T(key.to_string()); + } + ); + } else if key == "keyword" { + let keyword: Keyword = map.next_value()?; + match self { + EmailComparator::HasKeyword(_) => *self = EmailComparator::HasKeyword(keyword), + EmailComparator::AllInThreadHaveKeyword(_) => { + *self = EmailComparator::AllInThreadHaveKeyword(keyword) + } + EmailComparator::SomeInThreadHaveKeyword(_) => { + *self = EmailComparator::SomeInThreadHaveKeyword(keyword) + } + _ => { + *self = EmailComparator::HasKeyword(keyword); + } + } + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for EmailFilter { + fn default() -> Self { + EmailFilter::_T("".to_string()) + } +} + +impl Default for EmailComparator { + fn default() -> Self { + EmailComparator::_T("".to_string()) + } +} + +impl EmailComparator { + fn take_keyword(&mut self) -> Keyword { + match self { + EmailComparator::HasKeyword(k) => { + std::mem::replace(k, Keyword::Other(Default::default())) + } + EmailComparator::AllInThreadHaveKeyword(k) => { + std::mem::replace(k, Keyword::Other(Default::default())) + } + EmailComparator::SomeInThreadHaveKeyword(k) => { + std::mem::replace(k, Keyword::Other(Default::default())) + } + _ => Keyword::Other(Default::default()), + } + } +} + +impl Display for EmailFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + EmailFilter::InMailbox(_) => "inMailbox", + EmailFilter::InMailboxOtherThan(_) => "inMailboxOtherThan", + EmailFilter::Before(_) => "before", + EmailFilter::After(_) => "after", + EmailFilter::MinSize(_) => "minSize", + EmailFilter::MaxSize(_) => "maxSize", + EmailFilter::AllInThreadHaveKeyword(_) => "allInThreadHaveKeyword", + EmailFilter::SomeInThreadHaveKeyword(_) => "someInThreadHaveKeyword", + EmailFilter::NoneInThreadHaveKeyword(_) => "noneInThreadHaveKeyword", + EmailFilter::HasKeyword(_) => "hasKeyword", + EmailFilter::NotKeyword(_) => "notKeyword", + EmailFilter::HasAttachment(_) => "hasAttachment", + EmailFilter::From(_) => "from", + EmailFilter::To(_) => "to", + EmailFilter::Cc(_) => "cc", + EmailFilter::Bcc(_) => "bcc", + EmailFilter::Subject(_) => "subject", + EmailFilter::Body(_) => "body", + EmailFilter::Header(_) => "header", + EmailFilter::Text(_) => "text", + EmailFilter::SentBefore(_) => "sentBefore", + EmailFilter::SentAfter(_) => "sentAfter", + EmailFilter::InThread(_) => "inThread", + EmailFilter::Id(_) => "id", + EmailFilter::_T(v) => v.as_str(), + }) + } +} + +impl Display for EmailComparator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl EmailComparator { + pub fn as_str(&self) -> &str { + match self { + EmailComparator::ReceivedAt => "receivedAt", + EmailComparator::Size => "size", + EmailComparator::From => "from", + EmailComparator::To => "to", + EmailComparator::Subject => "subject", + EmailComparator::Cc => "cc", + EmailComparator::SentAt => "sentAt", + EmailComparator::ThreadId => "threadId", + EmailComparator::HasKeyword(_) => "hasKeyword", + EmailComparator::AllInThreadHaveKeyword(_) => "allInThreadHaveKeyword", + EmailComparator::SomeInThreadHaveKeyword(_) => "someInThreadHaveKeyword", + EmailComparator::_T(v) => v.as_str(), + } + } +} + +impl Serialize for EmailComparator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Filter { + pub fn is_immutable(&self) -> bool { + match self { + Filter::Property(f) => f.is_immutable(), + Filter::And | Filter::Or | Filter::Not | Filter::Close => true, + } + } +} + +impl EmailFilter { + pub fn is_immutable(&self) -> bool { + matches!( + self, + EmailFilter::Before(_) + | EmailFilter::After(_) + | EmailFilter::MinSize(_) + | EmailFilter::MaxSize(_) + | EmailFilter::HasAttachment(_) + | EmailFilter::From(_) + | EmailFilter::To(_) + | EmailFilter::Cc(_) + | EmailFilter::Bcc(_) + | EmailFilter::Subject(_) + | EmailFilter::Body(_) + | EmailFilter::Header(_) + | EmailFilter::Text(_) + | EmailFilter::Id(_) + | EmailFilter::SentBefore(_) + | EmailFilter::SentAfter(_) + ) + } +} + +impl Comparator { + pub fn is_immutable(&self) -> bool { + self.property.is_immutable() + } +} + +impl EmailComparator { + pub fn is_immutable(&self) -> bool { + matches!( + self, + EmailComparator::ReceivedAt + | EmailComparator::Size + | EmailComparator::From + | EmailComparator::To + | EmailComparator::Subject + | EmailComparator::Cc + | EmailComparator::SentAt + ) + } +} + +impl JmapObjectId for EmailValue { + fn as_id(&self) -> Option { + if let EmailValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + match self { + EmailValue::Id(id) => Some(AnyId::Id(*id)), + EmailValue::BlobId(id) => Some(AnyId::BlobId(id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let EmailValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = EmailValue::Id(id); + } + AnyId::BlobId(id) => { + *self = EmailValue::BlobId(id); + } + } + true + } +} + +impl From for EmailValue { + fn from(id: Id) -> Self { + EmailValue::Id(id) + } +} + +impl From for EmailValue { + fn from(id: BlobId) -> Self { + EmailValue::BlobId(id) + } +} + +impl From for EmailValue { + fn from(date: UTCDate) -> Self { + EmailValue::Date(date) + } +} + +impl JmapObjectId for EmailProperty { + fn as_id(&self) -> Option { + if let EmailProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let EmailProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + match self { + EmailProperty::IdReference(r) => Some(r), + EmailProperty::Pointer(value) => { + let value = value.as_slice(); + match (value.first(), value.get(1)) { + ( + Some(JsonPointerItem::Key(Key::Property(EmailProperty::MailboxIds))), + Some(JsonPointerItem::Key(Key::Property(EmailProperty::IdReference(r)))), + ) => Some(r), + _ => None, + } + } + _ => None, + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + if let EmailProperty::Pointer(value) = self { + let value = value.as_mut_slice(); + if let Some(value) = value.get_mut(1) { + *value = JsonPointerItem::Key(Key::Property(EmailProperty::IdValue(id))); + return true; + } + } else { + *self = EmailProperty::IdValue(id); + return true; + } + } + false + } +} diff --git a/crates/jmap-proto/src/object/email_submission.rs b/crates/jmap-proto/src/object/email_submission.rs new file mode 100644 index 0000000..c52780f --- /dev/null +++ b/crates/jmap-proto/src/object/email_submission.rs @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, MaybeReference, + email::{EmailProperty, EmailValue}, + parse_ref, + }, + request::{MaybeInvalid, deserialize::DeserializeArguments, reference::MaybeIdReference}, + types::date::UTCDate, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property, Value}; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, Default)] +pub struct EmailSubmission; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EmailSubmissionProperty { + Id, + IdentityId, + ThreadId, + EmailId, + Envelope, + MailFrom, + RcptTo, + Email, + Parameters, + SendAt, + UndoStatus, + DeliveryStatus, + SmtpReply, + Delivered, + Displayed, + DsnBlobIds, + MdnBlobIds, + + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EmailSubmissionValue { + Id(Id), + Date(UTCDate), + BlobId(BlobId), + UndoStatus(UndoStatus), + Delivered(Delivered), + Displayed(Displayed), + IdReference(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum UndoStatus { + Pending, + Final, + Canceled, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Delivered { + Queued, + Yes, + No, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Displayed { + Yes, + Unknown, +} + +impl Property for EmailSubmissionProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + EmailSubmissionProperty::parse(value, key.is_none()) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + EmailSubmissionProperty::DeliveryStatus => "deliveryStatus", + EmailSubmissionProperty::DsnBlobIds => "dsnBlobIds", + EmailSubmissionProperty::Email => "email", + EmailSubmissionProperty::Envelope => "envelope", + EmailSubmissionProperty::Id => "id", + EmailSubmissionProperty::IdentityId => "identityId", + EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds", + EmailSubmissionProperty::SendAt => "sendAt", + EmailSubmissionProperty::ThreadId => "threadId", + EmailSubmissionProperty::UndoStatus => "undoStatus", + EmailSubmissionProperty::Parameters => "parameters", + EmailSubmissionProperty::SmtpReply => "smtpReply", + EmailSubmissionProperty::Delivered => "delivered", + EmailSubmissionProperty::Displayed => "displayed", + EmailSubmissionProperty::MailFrom => "mailFrom", + EmailSubmissionProperty::RcptTo => "rcptTo", + EmailSubmissionProperty::EmailId => "emailId", + EmailSubmissionProperty::Pointer(json_pointer) => { + return json_pointer.to_string().into(); + } + } + .into() + } +} + +impl Element for EmailSubmissionValue { + type Property = EmailSubmissionProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + EmailSubmissionProperty::Id + | EmailSubmissionProperty::ThreadId + | EmailSubmissionProperty::IdentityId + | EmailSubmissionProperty::EmailId => match parse_ref(value) { + MaybeReference::Value(v) => Some(EmailSubmissionValue::Id(v)), + MaybeReference::Reference(v) => Some(EmailSubmissionValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + EmailSubmissionProperty::MdnBlobIds | EmailSubmissionProperty::DsnBlobIds => { + match parse_ref(value) { + MaybeReference::Value(v) => Some(EmailSubmissionValue::BlobId(v)), + MaybeReference::Reference(v) => Some(EmailSubmissionValue::IdReference(v)), + MaybeReference::ParseError => None, + } + } + EmailSubmissionProperty::SendAt => UTCDate::from_str(value) + .ok() + .map(EmailSubmissionValue::Date), + EmailSubmissionProperty::UndoStatus => { + UndoStatus::parse(value).map(EmailSubmissionValue::UndoStatus) + } + EmailSubmissionProperty::Delivered => { + Delivered::parse(value).map(EmailSubmissionValue::Delivered) + } + EmailSubmissionProperty::Displayed => { + Displayed::parse(value).map(EmailSubmissionValue::Displayed) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + EmailSubmissionValue::Id(id) => id.to_string().into(), + EmailSubmissionValue::Date(utcdate) => utcdate.to_string().into(), + EmailSubmissionValue::BlobId(blob_id) => blob_id.to_string().into(), + EmailSubmissionValue::IdReference(r) => format!("#{r}").into(), + EmailSubmissionValue::UndoStatus(undo_status) => undo_status.as_str().into(), + EmailSubmissionValue::Delivered(delivered) => delivered.as_str().into(), + EmailSubmissionValue::Displayed(displayed) => displayed.as_str().into(), + } + } +} + +impl EmailSubmissionProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + "id" => EmailSubmissionProperty::Id, + "identityId" => EmailSubmissionProperty::IdentityId, + "threadId" => EmailSubmissionProperty::ThreadId, + "emailId" => EmailSubmissionProperty::EmailId, + "envelope" => EmailSubmissionProperty::Envelope, + "mailFrom" => EmailSubmissionProperty::MailFrom, + "rcptTo" => EmailSubmissionProperty::RcptTo, + "email" => EmailSubmissionProperty::Email, + "parameters" => EmailSubmissionProperty::Parameters, + "sendAt" => EmailSubmissionProperty::SendAt, + "undoStatus" => EmailSubmissionProperty::UndoStatus, + "deliveryStatus" => EmailSubmissionProperty::DeliveryStatus, + "smtpReply" => EmailSubmissionProperty::SmtpReply, + "delivered" => EmailSubmissionProperty::Delivered, + "displayed" => EmailSubmissionProperty::Displayed, + "dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds, + "mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds, + ) + .or_else(|| { + if allow_patch && value.contains('/') { + EmailSubmissionProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &EmailSubmissionProperty { + if let EmailSubmissionProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +impl UndoStatus { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"pending" => UndoStatus::Pending, + b"final" => UndoStatus::Final, + b"canceled" => UndoStatus::Canceled, + ) + } + + fn as_str(&self) -> &'static str { + match self { + UndoStatus::Pending => "pending", + UndoStatus::Final => "final", + UndoStatus::Canceled => "canceled", + } + } +} + +impl Delivered { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"queued" => Delivered::Queued, + b"yes" => Delivered::Yes, + b"no" => Delivered::No, + b"unknown" => Delivered::Unknown, + ) + } + + fn as_str(&self) -> &'static str { + match self { + Delivered::Queued => "queued", + Delivered::Yes => "yes", + Delivered::No => "no", + Delivered::Unknown => "unknown", + } + } +} + +impl Displayed { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"yes" => Displayed::Yes, + b"unknown" => Displayed::Unknown, + ) + } + + fn as_str(&self) -> &'static str { + match self { + Displayed::Yes => "yes", + Displayed::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct EmailSubmissionSetArguments<'x> { + pub on_success_update_email: + Option, Value<'x, EmailProperty, EmailValue>>>, + pub on_success_destroy_email: Option>>, +} + +impl<'x> DeserializeArguments<'x> for EmailSubmissionSetArguments<'x> { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + hashify::fnc_map!(key.as_bytes(), + b"onSuccessUpdateEmail" => { + self.on_success_update_email = map.next_value()?; + }, + b"onSuccessDestroyEmail" => { + self.on_success_destroy_email = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for EmailSubmissionProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + EmailSubmissionProperty::parse(s, false).ok_or(()) + } +} + +impl<'de> serde::Deserialize<'de> for UndoStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + UndoStatus::parse(<&str>::deserialize(deserializer)?) + .ok_or_else(|| serde::de::Error::custom("invalid JMAP UndoStatus")) + } +} + +impl JmapObject for EmailSubmission { + type Property = EmailSubmissionProperty; + + type Element = EmailSubmissionValue; + + type Id = Id; + + type Filter = EmailSubmissionFilter; + + type Comparator = EmailSubmissionComparator; + + type GetArguments = (); + + type SetArguments<'de> = EmailSubmissionSetArguments<'de>; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = EmailSubmissionProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailSubmissionFilter { + IdentityIds(Vec>), + EmailIds(Vec>), + ThreadIds(Vec>), + Before(UTCDate), + After(UTCDate), + UndoStatus(UndoStatus), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailSubmissionComparator { + EmailId, + ThreadId, + SentAt, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for EmailSubmissionFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"identityIds" => { + *self = EmailSubmissionFilter::IdentityIds(map.next_value()?); + }, + b"emailIds" => { + *self = EmailSubmissionFilter::EmailIds(map.next_value()?); + }, + b"threadIds" => { + *self = EmailSubmissionFilter::ThreadIds(map.next_value()?); + }, + b"before" => { + *self = EmailSubmissionFilter::Before(map.next_value()?); + }, + b"after" => { + *self = EmailSubmissionFilter::After(map.next_value()?); + }, + b"undoStatus" => { + *self = EmailSubmissionFilter::UndoStatus(map.next_value()?); + }, + _ => { + *self = EmailSubmissionFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for EmailSubmissionComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + + b"emailId" => { + *self = EmailSubmissionComparator::EmailId; + }, + b"threadId" => { + *self = EmailSubmissionComparator::ThreadId; + }, + b"sentAt" => { + *self = EmailSubmissionComparator::SentAt; + }, + _ => { + *self = EmailSubmissionComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for EmailSubmissionFilter { + fn default() -> Self { + EmailSubmissionFilter::_T("".to_string()) + } +} + +impl Default for EmailSubmissionComparator { + fn default() -> Self { + EmailSubmissionComparator::_T("".to_string()) + } +} + +impl From for EmailSubmissionValue { + fn from(id: Id) -> Self { + EmailSubmissionValue::Id(id) + } +} + +impl JmapObjectId for EmailSubmissionValue { + fn as_id(&self) -> Option { + match self { + EmailSubmissionValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + EmailSubmissionValue::Id(id) => Some(AnyId::Id(*id)), + EmailSubmissionValue::BlobId(blob_id) => Some(AnyId::BlobId(blob_id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let EmailSubmissionValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = EmailSubmissionValue::Id(id); + } + AnyId::BlobId(blob_id) => { + *self = EmailSubmissionValue::BlobId(blob_id); + } + } + true + } +} + +impl JmapObjectId for EmailSubmissionProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs new file mode 100644 index 0000000..b8d24db --- /dev/null +++ b/crates/jmap-proto/src/object/file_node.rs @@ -0,0 +1,826 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, + }, + request::{MaybeInvalid, deserialize::DeserializeArguments}, + types::date::UTCDate, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::{acl::Acl, blob::BlobId, id::Id}; +use utils::glob::GlobPattern; + +#[derive(Debug, Clone, Default)] +pub struct FileNode; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeProperty { + Id, + ParentId, + BlobId, + Size, + Name, + Type, + NodeType, + Target, + Created, + Modified, + Accessed, + Changed, + Executable, + Role, + MyRights, + ShareWith, + IsSubscribed, + + IdValue(Id), + Rights(FileNodeRight), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeRight { + MayRead, + MayAddChildren, + MayRename, + MayDelete, + MayModifyContent, + MayShare, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeNodeType { + File, + Directory, + Symlink, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeRole { + Root, + Home, + Temp, + Trash, + Documents, + Downloads, + Music, + Pictures, + Videos, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FileNodeValue { + Id(Id), + Date(UTCDate), + BlobId(BlobId), + IdReference(String), +} + +impl Property for FileNodeProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + FileNodeProperty::ShareWith => { + Id::from_str(value).ok().map(FileNodeProperty::IdValue) + } + _ => FileNodeProperty::parse(value, allow_patch), + } + } else { + FileNodeProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FileNodeProperty::Id => "id", + FileNodeProperty::ParentId => "parentId", + FileNodeProperty::BlobId => "blobId", + FileNodeProperty::Size => "size", + FileNodeProperty::Name => "name", + FileNodeProperty::Type => "type", + FileNodeProperty::NodeType => "nodeType", + FileNodeProperty::Target => "target", + FileNodeProperty::Created => "created", + FileNodeProperty::Modified => "modified", + FileNodeProperty::Accessed => "accessed", + FileNodeProperty::Changed => "changed", + FileNodeProperty::Executable => "executable", + FileNodeProperty::Role => "role", + FileNodeProperty::MyRights => "myRights", + FileNodeProperty::ShareWith => "shareWith", + FileNodeProperty::IsSubscribed => "isSubscribed", + FileNodeProperty::Rights(file_right) => file_right.as_str(), + FileNodeProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + FileNodeProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl FileNodeRight { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeRight::MayRead => "mayRead", + FileNodeRight::MayAddChildren => "mayAddChildren", + FileNodeRight::MayRename => "mayRename", + FileNodeRight::MayDelete => "mayDelete", + FileNodeRight::MayModifyContent => "mayModifyContent", + FileNodeRight::MayShare => "mayShare", + } + } +} + +impl FileNodeNodeType { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeNodeType::File => "file", + FileNodeNodeType::Directory => "directory", + FileNodeNodeType::Symlink => "symlink", + } + } + + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"file" => FileNodeNodeType::File, + b"directory" => FileNodeNodeType::Directory, + b"symlink" => FileNodeNodeType::Symlink, + ) + } +} + +impl FromStr for FileNodeNodeType { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeNodeType::parse(s).ok_or(()) + } +} + +impl Display for FileNodeNodeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FileNodeRole { + pub fn as_str(&self) -> &'static str { + match self { + FileNodeRole::Root => "root", + FileNodeRole::Home => "home", + FileNodeRole::Temp => "temp", + FileNodeRole::Trash => "trash", + FileNodeRole::Documents => "documents", + FileNodeRole::Downloads => "downloads", + FileNodeRole::Music => "music", + FileNodeRole::Pictures => "pictures", + FileNodeRole::Videos => "videos", + } + } + + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"root" => FileNodeRole::Root, + b"home" => FileNodeRole::Home, + b"temp" => FileNodeRole::Temp, + b"trash" => FileNodeRole::Trash, + b"documents" => FileNodeRole::Documents, + b"downloads" => FileNodeRole::Downloads, + b"music" => FileNodeRole::Music, + b"pictures" => FileNodeRole::Pictures, + b"videos" => FileNodeRole::Videos, + ) + } +} + +impl FromStr for FileNodeRole { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeRole::parse(s).ok_or(()) + } +} + +impl Display for FileNodeRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Element for FileNodeValue { + type Property = FileNodeProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + FileNodeProperty::Id | FileNodeProperty::ParentId => match parse_ref(value) { + MaybeReference::Value(v) => Some(FileNodeValue::Id(v)), + MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + FileNodeProperty::BlobId => match parse_ref(value) { + MaybeReference::Value(v) => Some(FileNodeValue::BlobId(v)), + MaybeReference::Reference(v) => Some(FileNodeValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + FileNodeProperty::Created + | FileNodeProperty::Modified + | FileNodeProperty::Accessed + | FileNodeProperty::Changed => { + UTCDate::from_str(value).ok().map(FileNodeValue::Date) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FileNodeValue::Id(id) => id.to_string().into(), + FileNodeValue::Date(utcdate) => utcdate.to_string().into(), + FileNodeValue::BlobId(blob_id) => blob_id.to_string().into(), + FileNodeValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl FileNodeProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => FileNodeProperty::Id, + b"parentId" => FileNodeProperty::ParentId, + b"blobId" => FileNodeProperty::BlobId, + b"size" => FileNodeProperty::Size, + b"name" => FileNodeProperty::Name, + b"type" => FileNodeProperty::Type, + b"nodeType" => FileNodeProperty::NodeType, + b"target" => FileNodeProperty::Target, + b"created" => FileNodeProperty::Created, + b"modified" => FileNodeProperty::Modified, + b"accessed" => FileNodeProperty::Accessed, + b"changed" => FileNodeProperty::Changed, + b"executable" => FileNodeProperty::Executable, + b"role" => FileNodeProperty::Role, + b"myRights" => FileNodeProperty::MyRights, + b"shareWith" => FileNodeProperty::ShareWith, + b"isSubscribed" => FileNodeProperty::IsSubscribed, + b"mayRead" => FileNodeProperty::Rights(FileNodeRight::MayRead), + b"mayAddChildren" => FileNodeProperty::Rights(FileNodeRight::MayAddChildren), + b"mayRename" => FileNodeProperty::Rights(FileNodeRight::MayRename), + b"mayDelete" => FileNodeProperty::Rights(FileNodeRight::MayDelete), + b"mayModifyContent" => FileNodeProperty::Rights(FileNodeRight::MayModifyContent), + b"mayShare" => FileNodeProperty::Rights(FileNodeRight::MayShare), + ) + .or_else(|| { + if allow_patch && value.contains('/') { + FileNodeProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &FileNodeProperty { + if let FileNodeProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeSetArguments { + pub on_destroy_remove_children: Option, + pub on_exists: OnExists, + pub compare_case_insensitively: Option, +} + +pub type FileNodeCopyArguments = FileNodeSetArguments; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OnExists { + #[default] + Reject, + Replace, + Rename, + Newest, +} + +impl<'de> serde::Deserialize<'de> for OnExists { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: Option> = Option::deserialize(deserializer)?; + match value.as_deref() { + Some("replace") => Ok(OnExists::Replace), + Some("rename") => Ok(OnExists::Rename), + Some("newest") => Ok(OnExists::Newest), + None | Some("") => Ok(OnExists::Reject), + Some(other) => Err(serde::de::Error::custom(format!( + "Invalid onExists value: {other:?}" + ))), + } + } +} + +impl<'x> DeserializeArguments<'x> for FileNodeSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + hashify::fnc_map!(key.as_bytes(), + b"onDestroyRemoveChildren" => { + self.on_destroy_remove_children = map.next_value()?; + }, + b"onExists" => { + self.on_exists = map.next_value()?; + }, + b"compareCaseInsensitively" => { + self.compare_case_insensitively = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeGetArguments { + pub fetch_parents: Option, +} + +impl<'x> DeserializeArguments<'x> for FileNodeGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + if key == "fetchParents" { + self.fetch_parents = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FileNodeQueryArguments { + pub depth: Option, +} + +impl<'x> DeserializeArguments<'x> for FileNodeQueryArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'x>, + { + if key == "depth" { + self.depth = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl FromStr for FileNodeProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + FileNodeProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for FileNode { + type Property = FileNodeProperty; + + type Element = FileNodeValue; + + type Id = Id; + + type Filter = FileNodeFilter; + + type Comparator = FileNodeComparator; + + type GetArguments = FileNodeGetArguments; + + type SetArguments<'de> = FileNodeSetArguments; + + type QueryArguments = FileNodeQueryArguments; + + type CopyArguments = FileNodeCopyArguments; + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = FileNodeProperty::Id; +} + +impl JmapSharedObject for FileNode { + type Right = FileNodeRight; + + const SHARE_WITH_PROPERTY: Self::Property = FileNodeProperty::ShareWith; +} + +impl From for FileNodeProperty { + fn from(id: Id) -> Self { + FileNodeProperty::IdValue(id) + } +} + +impl JmapRight for FileNodeRight { + fn to_acl(&self) -> &'static [Acl] { + match self { + FileNodeRight::MayRead => &[Acl::Read, Acl::ReadItems], + FileNodeRight::MayAddChildren => &[Acl::AddItems], + FileNodeRight::MayRename => &[Acl::Modify], + FileNodeRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], + FileNodeRight::MayModifyContent => &[Acl::ModifyItems], + FileNodeRight::MayShare => &[Acl::Share], + } + } + + fn all_rights() -> &'static [Self] { + &[ + FileNodeRight::MayRead, + FileNodeRight::MayAddChildren, + FileNodeRight::MayRename, + FileNodeRight::MayDelete, + FileNodeRight::MayModifyContent, + FileNodeRight::MayShare, + ] + } +} + +impl From for FileNodeProperty { + fn from(right: FileNodeRight) -> Self { + FileNodeProperty::Rights(right) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileNodeFilter { + IsTopLevel(bool), + ParentId(MaybeInvalid), + AncestorId(MaybeInvalid), + DescendantId(MaybeInvalid), + NodeType(String), + Role(String), + HasAnyRole(bool), + BlobId(MaybeInvalid), + IsExecutable(bool), + CreatedBefore(UTCDate), + CreatedAfter(UTCDate), + ModifiedBefore(UTCDate), + ModifiedAfter(UTCDate), + AccessedBefore(UTCDate), + AccessedAfter(UTCDate), + MinSize(u64), + MaxSize(u64), + Name(String), + NameMatch(GlobPattern), + Type(String), + TypeMatch(GlobPattern), + Text(String), + Body(String), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileNodeComparator { + Name, + Size, + Created, + Modified, + Type, + NodeType, + Tree, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for FileNodeFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"isTopLevel" => { + *self = FileNodeFilter::IsTopLevel(map.next_value()?); + }, + b"parentId" => { + *self = FileNodeFilter::ParentId(map.next_value()?); + }, + b"ancestorId" => { + *self = FileNodeFilter::AncestorId(map.next_value()?); + }, + b"descendantId" => { + *self = FileNodeFilter::DescendantId(map.next_value()?); + }, + b"nodeType" => { + *self = FileNodeFilter::NodeType(map.next_value()?); + }, + b"role" => { + *self = FileNodeFilter::Role(map.next_value()?); + }, + b"hasAnyRole" => { + *self = FileNodeFilter::HasAnyRole(map.next_value()?); + }, + b"blobId" => { + *self = FileNodeFilter::BlobId(map.next_value()?); + }, + b"isExecutable" => { + *self = FileNodeFilter::IsExecutable(map.next_value()?); + }, + b"createdBefore" => { + *self = FileNodeFilter::CreatedBefore(map.next_value()?); + }, + b"createdAfter" => { + *self = FileNodeFilter::CreatedAfter(map.next_value()?); + }, + b"modifiedBefore" => { + *self = FileNodeFilter::ModifiedBefore(map.next_value()?); + }, + b"modifiedAfter" => { + *self = FileNodeFilter::ModifiedAfter(map.next_value()?); + }, + b"accessedBefore" => { + *self = FileNodeFilter::AccessedBefore(map.next_value()?); + }, + b"accessedAfter" => { + *self = FileNodeFilter::AccessedAfter(map.next_value()?); + }, + b"minSize" => { + *self = FileNodeFilter::MinSize(map.next_value()?); + }, + b"maxSize" => { + *self = FileNodeFilter::MaxSize(map.next_value()?); + }, + b"name" => { + *self = FileNodeFilter::Name(map.next_value()?); + }, + b"nameMatch" => { + *self = FileNodeFilter::NameMatch(map.next_value()?); + }, + b"type" => { + *self = FileNodeFilter::Type(map.next_value()?); + }, + b"typeMatch" => { + *self = FileNodeFilter::TypeMatch(map.next_value()?); + }, + b"body" => { + *self = FileNodeFilter::Body(map.next_value()?); + }, + b"text" => { + *self = FileNodeFilter::Text(map.next_value()?); + }, + _ => { + *self = FileNodeFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for FileNodeComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"name" => { + *self = FileNodeComparator::Name; + }, + b"size" => { + *self = FileNodeComparator::Size; + }, + b"created" => { + *self = FileNodeComparator::Created; + }, + b"modified" => { + *self = FileNodeComparator::Modified; + }, + b"type" => { + *self = FileNodeComparator::Type; + }, + b"nodeType" => { + *self = FileNodeComparator::NodeType; + }, + b"tree" => { + *self = FileNodeComparator::Tree; + }, + _ => { + *self = FileNodeComparator::_T(value.into_owned()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for FileNodeFilter { + fn default() -> Self { + FileNodeFilter::_T("".to_string()) + } +} + +impl Default for FileNodeComparator { + fn default() -> Self { + FileNodeComparator::_T("".to_string()) + } +} + +impl From for FileNodeValue { + fn from(id: Id) -> Self { + FileNodeValue::Id(id) + } +} + +impl JmapObjectId for FileNodeValue { + fn as_id(&self) -> Option { + match self { + FileNodeValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + FileNodeValue::Id(id) => Some(AnyId::Id(*id)), + FileNodeValue::BlobId(blob_id) => Some(AnyId::BlobId(blob_id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let FileNodeValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = FileNodeValue::Id(id); + } + AnyId::BlobId(blob_id) => { + *self = FileNodeValue::BlobId(blob_id); + } + } + true + } +} + +impl FileNodeFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + FileNodeFilter::IsTopLevel(_) => "isTopLevel", + FileNodeFilter::ParentId(_) => "parentId", + FileNodeFilter::AncestorId(_) => "ancestorId", + FileNodeFilter::DescendantId(_) => "descendantId", + FileNodeFilter::NodeType(_) => "nodeType", + FileNodeFilter::Role(_) => "role", + FileNodeFilter::HasAnyRole(_) => "hasAnyRole", + FileNodeFilter::BlobId(_) => "blobId", + FileNodeFilter::IsExecutable(_) => "isExecutable", + FileNodeFilter::CreatedBefore(_) => "createdBefore", + FileNodeFilter::CreatedAfter(_) => "createdAfter", + FileNodeFilter::ModifiedBefore(_) => "modifiedBefore", + FileNodeFilter::ModifiedAfter(_) => "modifiedAfter", + FileNodeFilter::AccessedBefore(_) => "accessedBefore", + FileNodeFilter::AccessedAfter(_) => "accessedAfter", + FileNodeFilter::MinSize(_) => "minSize", + FileNodeFilter::MaxSize(_) => "maxSize", + FileNodeFilter::Name(_) => "name", + FileNodeFilter::NameMatch(_) => "nameMatch", + FileNodeFilter::Type(_) => "type", + FileNodeFilter::TypeMatch(_) => "typeMatch", + FileNodeFilter::Text(_) => "text", + FileNodeFilter::Body(_) => "body", + FileNodeFilter::_T(s) => return s.into(), + } + .into() + } +} + +impl FileNodeComparator { + pub fn as_str(&self) -> &str { + match self { + FileNodeComparator::Name => "name", + FileNodeComparator::Size => "size", + FileNodeComparator::Created => "created", + FileNodeComparator::Modified => "modified", + FileNodeComparator::Type => "type", + FileNodeComparator::NodeType => "nodeType", + FileNodeComparator::Tree => "tree", + FileNodeComparator::_T(s) => s.as_ref(), + } + } + + pub fn into_string(self) -> Cow<'static, str> { + match self { + FileNodeComparator::Name => "name", + FileNodeComparator::Size => "size", + FileNodeComparator::Created => "created", + FileNodeComparator::Modified => "modified", + FileNodeComparator::Type => "type", + FileNodeComparator::NodeType => "nodeType", + FileNodeComparator::Tree => "tree", + FileNodeComparator::_T(s) => return s.into(), + } + .into() + } +} + +impl serde::Serialize for FileNodeComparator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: FileNodeProperty) -> Result { + if let FileNodeProperty::IdValue(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for FileNodeRight { + type Error = (); + + fn try_from(value: FileNodeProperty) -> Result { + if let FileNodeProperty::Rights(right) = value { + Ok(right) + } else { + Err(()) + } + } +} + +impl JmapObjectId for FileNodeProperty { + fn as_id(&self) -> Option { + if let FileNodeProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let FileNodeProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = FileNodeProperty::IdValue(id); + true + } else { + false + } + } +} + +impl Display for FileNodeProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/identity.rs b/crates/jmap-proto/src/object/identity.rs new file mode 100644 index 0000000..4f9b43d --- /dev/null +++ b/crates/jmap-proto/src/object/identity.rs @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct Identity; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentityProperty { + Id, + Name, + Email, + ReplyTo, + Bcc, + TextSignature, + HtmlSignature, + MayDelete, + + // Other + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentityValue { + Id(Id), +} + +impl Property for IdentityProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + IdentityProperty::parse(value, key.is_none()) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + IdentityProperty::Bcc => "bcc", + IdentityProperty::Email => "email", + IdentityProperty::HtmlSignature => "htmlSignature", + IdentityProperty::Id => "id", + IdentityProperty::MayDelete => "mayDelete", + IdentityProperty::Name => "name", + IdentityProperty::ReplyTo => "replyTo", + IdentityProperty::TextSignature => "textSignature", + IdentityProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + } + .into() + } +} + +impl Element for IdentityValue { + type Property = IdentityProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + IdentityProperty::Id => Id::from_str(value).ok().map(IdentityValue::Id), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + IdentityValue::Id(id) => id.to_string().into(), + } + } +} + +impl IdentityProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => IdentityProperty::Id, + b"name" => IdentityProperty::Name, + b"email" => IdentityProperty::Email, + b"replyTo" => IdentityProperty::ReplyTo, + b"bcc" => IdentityProperty::Bcc, + b"textSignature" => IdentityProperty::TextSignature, + b"htmlSignature" => IdentityProperty::HtmlSignature, + b"mayDelete" => IdentityProperty::MayDelete, + ) + .or_else(|| { + if allow_patch && value.contains('/') { + IdentityProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &IdentityProperty { + if let IdentityProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +impl FromStr for IdentityProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + IdentityProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for Identity { + type Property = IdentityProperty; + + type Element = IdentityValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = IdentityProperty::Id; +} + +impl From for IdentityValue { + fn from(id: Id) -> Self { + IdentityValue::Id(id) + } +} + +impl JmapObjectId for IdentityValue { + fn as_id(&self) -> Option { + match self { + IdentityValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + IdentityValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = IdentityValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for IdentityProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mailbox.rs b/crates/jmap-proto/src/object/mailbox.rs new file mode 100644 index 0000000..dbdfc96 --- /dev/null +++ b/crates/jmap-proto/src/object/mailbox.rs @@ -0,0 +1,516 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{ + AnyId, JmapObject, JmapObjectId, JmapRight, JmapSharedObject, MaybeReference, parse_ref, + }, + request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, +}; +use jmap_tools::{Element, JsonPointer, JsonPointerItem, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{acl::Acl, id::Id, special_use::SpecialUse}; + +#[derive(Debug, Clone, Default)] +pub struct Mailbox; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxProperty { + Id, + Name, + ParentId, + Role, + SortOrder, + TotalEmails, + UnreadEmails, + TotalThreads, + UnreadThreads, + ShareWith, + MyRights, + IsSubscribed, + + // Other + IdValue(Id), + Rights(MailboxRight), + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxRight { + MayReadItems, + MayAddItems, + MayRemoveItems, + MaySetSeen, + MaySetKeywords, + MayCreateChild, + MayRename, + MaySubmit, + MayDelete, + MayShare, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxValue { + Id(Id), + IdReference(String), + Role(SpecialUse), +} + +impl Property for MailboxProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + let allow_patch = key.is_none(); + if let Some(Key::Property(key)) = key { + match key.patch_or_prop() { + MailboxProperty::ShareWith => { + Id::from_str(value).ok().map(MailboxProperty::IdValue) + } + _ => MailboxProperty::parse(value, allow_patch), + } + } else { + MailboxProperty::parse(value, allow_patch) + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailboxProperty::Id => "id", + MailboxProperty::IsSubscribed => "isSubscribed", + MailboxProperty::MyRights => "myRights", + MailboxProperty::Name => "name", + MailboxProperty::ParentId => "parentId", + MailboxProperty::Role => "role", + MailboxProperty::SortOrder => "sortOrder", + MailboxProperty::TotalEmails => "totalEmails", + MailboxProperty::TotalThreads => "totalThreads", + MailboxProperty::UnreadEmails => "unreadEmails", + MailboxProperty::UnreadThreads => "unreadThreads", + MailboxProperty::ShareWith => "shareWith", + MailboxProperty::Rights(mailbox_right) => mailbox_right.as_str(), + MailboxProperty::Pointer(json_pointer) => return json_pointer.to_string().into(), + MailboxProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl MailboxRight { + pub fn as_str(&self) -> &'static str { + match self { + MailboxRight::MayReadItems => "mayReadItems", + MailboxRight::MayAddItems => "mayAddItems", + MailboxRight::MayRemoveItems => "mayRemoveItems", + MailboxRight::MaySetSeen => "maySetSeen", + MailboxRight::MaySetKeywords => "maySetKeywords", + MailboxRight::MayCreateChild => "mayCreateChild", + MailboxRight::MayRename => "mayRename", + MailboxRight::MaySubmit => "maySubmit", + MailboxRight::MayDelete => "mayDelete", + MailboxRight::MayShare => "mayShare", + } + } +} + +impl Element for MailboxValue { + type Property = MailboxProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + MailboxProperty::Id | MailboxProperty::ParentId => match parse_ref(value) { + MaybeReference::Value(v) => Some(MailboxValue::Id(v)), + MaybeReference::Reference(v) => Some(MailboxValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + MailboxProperty::Role => SpecialUse::parse(value).map(MailboxValue::Role), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailboxValue::Id(id) => id.to_string().into(), + MailboxValue::IdReference(r) => format!("#{r}").into(), + MailboxValue::Role(special_use) => special_use.as_str().unwrap_or_default().into(), + } + } +} + +impl MailboxProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => MailboxProperty::Id, + b"name" => MailboxProperty::Name, + b"parentId" => MailboxProperty::ParentId, + b"role" => MailboxProperty::Role, + b"sortOrder" => MailboxProperty::SortOrder, + b"totalEmails" => MailboxProperty::TotalEmails, + b"unreadEmails" => MailboxProperty::UnreadEmails, + b"totalThreads" => MailboxProperty::TotalThreads, + b"unreadThreads" => MailboxProperty::UnreadThreads, + b"shareWith" => MailboxProperty::ShareWith, + b"myRights" => MailboxProperty::MyRights, + b"mayReadItems" => MailboxProperty::Rights(MailboxRight::MayReadItems), + b"mayAddItems" => MailboxProperty::Rights(MailboxRight::MayAddItems), + b"mayRemoveItems" => MailboxProperty::Rights(MailboxRight::MayRemoveItems), + b"maySetSeen" => MailboxProperty::Rights(MailboxRight::MaySetSeen), + b"maySetKeywords" => MailboxProperty::Rights(MailboxRight::MaySetKeywords), + b"mayCreateChild" => MailboxProperty::Rights(MailboxRight::MayCreateChild), + b"mayRename" => MailboxProperty::Rights(MailboxRight::MayRename), + b"maySubmit" => MailboxProperty::Rights(MailboxRight::MaySubmit), + b"mayDelete" => MailboxProperty::Rights(MailboxRight::MayDelete), + b"mayShare" => MailboxProperty::Rights(MailboxRight::MayShare), + b"isSubscribed" => MailboxProperty::IsSubscribed, + ) + .or_else(|| { + if allow_patch && value.contains('/') { + MailboxProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &MailboxProperty { + if let MailboxProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct MailboxSetArguments { + pub on_destroy_remove_emails: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct MailboxQueryArguments { + pub sort_as_tree: Option, + pub filter_as_tree: Option, +} + +impl<'de> DeserializeArguments<'de> for MailboxSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "onDestroyRemoveEmails" { + self.on_destroy_remove_emails = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for MailboxQueryArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"sortAsTree" => { + self.sort_as_tree = map.next_value()?; + }, + b"filterAsTree" => { + self.filter_as_tree = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for MailboxProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + MailboxProperty::parse(s, false).ok_or(()) + } +} + +impl JmapObject for Mailbox { + type Property = MailboxProperty; + + type Element = MailboxValue; + + type Id = Id; + + type Filter = MailboxFilter; + + type Comparator = MailboxComparator; + + type GetArguments = (); + + type SetArguments<'de> = MailboxSetArguments; + + type QueryArguments = MailboxQueryArguments; + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = MailboxProperty::Id; +} + +impl JmapSharedObject for Mailbox { + type Right = MailboxRight; + + const SHARE_WITH_PROPERTY: Self::Property = MailboxProperty::ShareWith; +} + +impl From for MailboxProperty { + fn from(id: Id) -> Self { + MailboxProperty::IdValue(id) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: MailboxProperty) -> Result { + if let MailboxProperty::IdValue(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for MailboxRight { + type Error = (); + + fn try_from(value: MailboxProperty) -> Result { + if let MailboxProperty::Rights(right) = value { + Ok(right) + } else { + Err(()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MailboxFilter { + Name(String), + ParentId(Option>), + Role(Option), + HasAnyRole(bool), + IsSubscribed(bool), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MailboxComparator { + SortOrder, + Name, + ParentId, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for MailboxFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"name" => { + *self = MailboxFilter::Name(map.next_value()?); + }, + b"parentId" => { + *self = MailboxFilter::ParentId(map.next_value()?); + }, + b"role" => { + *self = MailboxFilter::Role(map.next_value::>()?.map(|r| r.0)); + }, + b"hasAnyRole" => { + *self = MailboxFilter::HasAnyRole(map.next_value()?); + }, + b"isSubscribed" => { + *self = MailboxFilter::IsSubscribed(map.next_value()?); + }, + _ => { + *self = MailboxFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for MailboxComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"sortOrder" => { + *self = MailboxComparator::SortOrder; + }, + b"name" => { + *self = MailboxComparator::Name; + }, + b"parentId" => { + *self = MailboxComparator::ParentId; + }, + _ => { + *self = MailboxComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for MailboxFilter { + fn default() -> Self { + MailboxFilter::_T("".to_string()) + } +} + +impl Default for MailboxComparator { + fn default() -> Self { + MailboxComparator::_T("".to_string()) + } +} + +struct RoleWrapper(SpecialUse); + +impl<'de> serde::Deserialize<'de> for RoleWrapper { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SpecialUse::parse(<&str>::deserialize(deserializer)?) + .map(RoleWrapper) + .ok_or_else(|| serde::de::Error::custom("invalid JMAP role")) + } +} + +impl From for MailboxValue { + fn from(id: Id) -> Self { + MailboxValue::Id(id) + } +} + +impl JmapObjectId for MailboxValue { + fn as_id(&self) -> Option { + if let MailboxValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let MailboxValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let MailboxValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = MailboxValue::Id(id); + true + } else { + false + } + } +} + +impl JmapRight for MailboxRight { + fn to_acl(&self) -> &'static [Acl] { + match self { + MailboxRight::MayReadItems => &[Acl::Read, Acl::ReadItems], + MailboxRight::MayAddItems => &[Acl::AddItems], + MailboxRight::MayRemoveItems => &[Acl::RemoveItems], + MailboxRight::MaySetSeen => &[Acl::ModifyItems], + MailboxRight::MaySetKeywords => &[Acl::ModifyItems], + MailboxRight::MayCreateChild => &[Acl::CreateChild], + MailboxRight::MayRename => &[Acl::Modify], + MailboxRight::MaySubmit => &[Acl::Submit], + MailboxRight::MayDelete => &[Acl::Delete], + MailboxRight::MayShare => &[Acl::Share], + } + } + + fn all_rights() -> &'static [Self] { + &[ + MailboxRight::MayReadItems, + MailboxRight::MayAddItems, + MailboxRight::MayRemoveItems, + MailboxRight::MaySetSeen, + MailboxRight::MaySetKeywords, + MailboxRight::MayCreateChild, + MailboxRight::MayRename, + MailboxRight::MaySubmit, + MailboxRight::MayDelete, + MailboxRight::MayShare, + ] + } +} + +impl From for MailboxProperty { + fn from(right: MailboxRight) -> Self { + MailboxProperty::Rights(right) + } +} + +impl JmapObjectId for MailboxProperty { + fn as_id(&self) -> Option { + if let MailboxProperty::IdValue(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let MailboxProperty::IdValue(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = MailboxProperty::IdValue(id); + true + } else { + false + } + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs new file mode 100644 index 0000000..938fe1d --- /dev/null +++ b/crates/jmap-proto/src/object/mod.rs @@ -0,0 +1,211 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::request::deserialize::DeserializeArguments; +use jmap_tools::{Element, Null, Property}; +use serde::Serialize; +use std::{fmt::Debug, str::FromStr}; +use types::{acl::Acl, blob::BlobId, id::Id}; + +pub mod addressbook; +pub mod blob; +pub mod calendar; +pub mod calendar_event; +pub mod calendar_event_notification; +pub mod contact; +pub mod email; +pub mod email_submission; +pub mod file_node; +pub mod identity; +pub mod mailbox; +pub mod participant_identity; +pub mod principal; +pub mod push_subscription; +pub mod quota; +pub mod registry; +pub mod search_snippet; +pub mod share_notification; +pub mod sieve; +pub mod thread; +pub mod vacation_response; + +pub trait JmapObject: std::fmt::Debug { + type Property: Property + JmapObjectId + FromStr + Debug + Sync + Send; + type Element: Element + JmapObjectId + Debug + Sync + Send; + type Id: FromStr + TryFrom + Into + Serialize + Debug + Sync + Send; + + type Filter: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + type Comparator: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + + type GetArguments: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + type SetArguments<'de>: Default + DeserializeArguments<'de> + Debug + Sync + Send; + type QueryArguments: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + type CopyArguments: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + type ParseArguments: Default + for<'de> DeserializeArguments<'de> + Debug + Sync + Send; + + const ID_PROPERTY: Self::Property; +} + +pub trait JmapSharedObject: JmapObject { + type Right: JmapRight + Into + Debug + Clone + Copy + Sync + Send; + + const SHARE_WITH_PROPERTY: Self::Property; +} + +pub trait JmapRight: Clone + Copy + Sized + 'static { + fn all_rights() -> &'static [Self]; + fn to_acl(&self) -> &'static [Acl]; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum AnyId { + Id(Id), + BlobId(BlobId), +} + +pub trait JmapObjectId { + fn as_id(&self) -> Option; + fn as_any_id(&self) -> Option; + fn as_id_ref(&self) -> Option<&str>; + fn try_set_id(&mut self, new_id: AnyId) -> bool; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MaybeReference { + Value(T), + Reference(String), + ParseError, +} + +fn parse_ref(value: &str) -> MaybeReference { + if let Some(reference) = value.strip_prefix('#') { + MaybeReference::Reference(reference.to_string()) + } else { + T::from_str(value) + .map(MaybeReference::Value) + .unwrap_or(MaybeReference::ParseError) + } +} + +impl From for AnyId { + fn from(value: Id) -> Self { + AnyId::Id(value) + } +} + +impl From for AnyId { + fn from(value: BlobId) -> Self { + AnyId::BlobId(value) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(value: AnyId) -> Result { + if let AnyId::Id(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl TryFrom for BlobId { + type Error = (); + + fn try_from(value: AnyId) -> Result { + if let AnyId::BlobId(id) = value { + Ok(id) + } else { + Err(()) + } + } +} + +impl<'de> serde::Deserialize<'de> for AnyId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = <&str>::deserialize(deserializer)?; + if let Some(blob_id) = BlobId::from_base32(value) { + Ok(AnyId::BlobId(blob_id)) + } else if let Ok(id) = Id::from_str(value) { + Ok(AnyId::Id(id)) + } else { + Err(serde::de::Error::custom(format!( + "Invalid AnyId: {}", + value + ))) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +pub struct NullObject; + +impl JmapObject for NullObject { + type Property = Null; + type Element = Null; + type Id = Null; + + type Filter = (); + type Comparator = (); + + type GetArguments = (); + type SetArguments<'de> = (); + type QueryArguments = (); + type CopyArguments = (); + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = Null; +} + +impl JmapRight for Null { + fn all_rights() -> &'static [Self] { + unreachable!() + } + + fn to_acl(&self) -> &'static [Acl] { + unreachable!() + } +} + +impl FromStr for NullObject { + type Err = (); + + fn from_str(_: &str) -> Result { + unreachable!() + } +} + +impl JmapObjectId for Null { + fn as_id(&self) -> Option { + unreachable!() + } + + fn as_any_id(&self) -> Option { + unreachable!() + } + + fn as_id_ref(&self) -> Option<&str> { + unreachable!() + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + unreachable!() + } +} + +impl TryFrom for Null { + type Error = (); + + fn try_from(_: AnyId) -> Result { + unreachable!() + } +} diff --git a/crates/jmap-proto/src/object/participant_identity.rs b/crates/jmap-proto/src/object/participant_identity.rs new file mode 100644 index 0000000..d66f559 --- /dev/null +++ b/crates/jmap-proto/src/object/participant_identity.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct ParticipantIdentity; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ParticipantIdentityProperty { + Id, + Name, + CalendarAddress, + IsDefault, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ParticipantIdentityValue { + Id(Id), +} + +impl Property for ParticipantIdentityProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + ParticipantIdentityProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ParticipantIdentityProperty::Id => "id", + ParticipantIdentityProperty::Name => "name", + ParticipantIdentityProperty::CalendarAddress => "calendarAddress", + ParticipantIdentityProperty::IsDefault => "isDefault", + } + .into() + } +} + +impl Element for ParticipantIdentityValue { + type Property = ParticipantIdentityProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + ParticipantIdentityProperty::Id => { + Id::from_str(value).ok().map(ParticipantIdentityValue::Id) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ParticipantIdentityValue::Id(id) => id.to_string().into(), + } + } +} + +impl ParticipantIdentityProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ParticipantIdentityProperty::Id, + b"name" => ParticipantIdentityProperty::Name, + b"calendarAddress" => ParticipantIdentityProperty::CalendarAddress, + b"isDefault" => ParticipantIdentityProperty::IsDefault + ) + } + + fn as_str(&self) -> &'static str { + match self { + ParticipantIdentityProperty::Id => "id", + ParticipantIdentityProperty::Name => "name", + ParticipantIdentityProperty::CalendarAddress => "calendarAddress", + ParticipantIdentityProperty::IsDefault => "isDefault", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ParticipantIdentitySetArguments { + pub on_success_set_is_default: Option>, +} + +impl<'de> DeserializeArguments<'de> for ParticipantIdentitySetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"onSuccessSetIsDefault" => { + self.on_success_set_is_default = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for ParticipantIdentityProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ParticipantIdentityProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for ParticipantIdentity { + type Property = ParticipantIdentityProperty; + + type Element = ParticipantIdentityValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = ParticipantIdentitySetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ParticipantIdentityProperty::Id; +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(_: ParticipantIdentityProperty) -> Result { + Err(()) + } +} + +impl From for ParticipantIdentityValue { + fn from(id: Id) -> Self { + ParticipantIdentityValue::Id(id) + } +} + +impl JmapObjectId for ParticipantIdentityValue { + fn as_id(&self) -> Option { + let ParticipantIdentityValue::Id(id) = self; + Some(*id) + } + + fn as_any_id(&self) -> Option { + let ParticipantIdentityValue::Id(id) = self; + Some(AnyId::Id(*id)) + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(new_id) = new_id { + *self = ParticipantIdentityValue::Id(new_id); + return true; + } + false + } +} + +impl JmapObjectId for ParticipantIdentityProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl Display for ParticipantIdentityProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} diff --git a/crates/jmap-proto/src/object/principal.rs b/crates/jmap-proto/src/object/principal.rs new file mode 100644 index 0000000..e0af7a7 --- /dev/null +++ b/crates/jmap-proto/src/object/principal.rs @@ -0,0 +1,354 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::id::Id; + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::{capability::Capability, deserialize::DeserializeArguments}, +}; + +#[derive(Debug, Clone, Default)] +pub struct Principal; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PrincipalProperty { + Id, + Type, + Name, + Description, + Email, + Timezone, + Capabilities, + Accounts, + IdValue(Id), + Capability(Capability), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PrincipalValue { + Id(Id), + Type(PrincipalType), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PrincipalType { + Individual, + Group, + Resource, + Location, + Other, +} + +impl Property for PrincipalProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + PrincipalProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + PrincipalProperty::Capabilities => "capabilities", + PrincipalProperty::Description => "description", + PrincipalProperty::Email => "email", + PrincipalProperty::Id => "id", + PrincipalProperty::Name => "name", + PrincipalProperty::Timezone => "timezone", + PrincipalProperty::Type => "type", + PrincipalProperty::Accounts => "accounts", + PrincipalProperty::Capability(cap) => cap.as_str(), + PrincipalProperty::IdValue(id) => return id.to_string().into(), + } + .into() + } +} + +impl Element for PrincipalValue { + type Property = PrincipalProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + PrincipalProperty::Id => Id::from_str(value).ok().map(PrincipalValue::Id), + PrincipalProperty::Type => PrincipalType::parse(value).map(PrincipalValue::Type), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + PrincipalValue::Id(id) => id.to_string().into(), + PrincipalValue::Type(t) => t.as_str().into(), + } + } +} + +impl PrincipalProperty { + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => PrincipalProperty::Id, + b"type" => PrincipalProperty::Type, + b"name" => PrincipalProperty::Name, + b"description" => PrincipalProperty::Description, + b"email" => PrincipalProperty::Email, + b"timeZone" => PrincipalProperty::Timezone, + b"capabilities" => PrincipalProperty::Capabilities, + b"accounts" => PrincipalProperty::Accounts, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + PrincipalProperty::Id => "id", + PrincipalProperty::Type => "type", + PrincipalProperty::Name => "name", + PrincipalProperty::Description => "description", + PrincipalProperty::Email => "email", + PrincipalProperty::Timezone => "timeZone", + PrincipalProperty::Capabilities => "capabilities", + PrincipalProperty::Accounts => "accounts", + PrincipalProperty::Capability(cap) => cap.as_str(), + PrincipalProperty::IdValue(_) => "", + } + } +} + +impl PrincipalType { + pub fn parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + b"individual" => PrincipalType::Individual, + b"group" => PrincipalType::Group, + b"resource" => PrincipalType::Resource, + b"location" => PrincipalType::Location, + b"other" => PrincipalType::Other, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + PrincipalType::Individual => "individual", + PrincipalType::Group => "group", + PrincipalType::Resource => "resource", + PrincipalType::Location => "location", + PrincipalType::Other => "other", + } + } +} + +impl FromStr for PrincipalProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + PrincipalProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for Principal { + type Property = PrincipalProperty; + + type Element = PrincipalValue; + + type Id = Id; + + type Filter = PrincipalFilter; + + type Comparator = PrincipalComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = PrincipalProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrincipalFilter { + AccountIds(Vec), + Email(String), + Name(String), + Text(String), + Type(PrincipalType), + Timezone(String), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrincipalComparator { + Name, + Email, + Type, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for PrincipalFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"accountIds" => { + *self = PrincipalFilter::AccountIds(map.next_value()?); + }, + b"email" => { + *self = PrincipalFilter::Email(map.next_value()?); + }, + b"name" => { + *self = PrincipalFilter::Name(map.next_value()?); + }, + b"text" => { + *self = PrincipalFilter::Text(map.next_value()?); + }, + b"type" => { + *self = PrincipalFilter::Type(map.next_value()?); + }, + b"timeZone" => { + *self = PrincipalFilter::Timezone(map.next_value()?); + }, + _ => { + *self = PrincipalFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for PrincipalComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"name" => { + *self = PrincipalComparator::Name; + }, + b"email" => { + *self = PrincipalComparator::Email; + }, + b"type" => { + *self = PrincipalComparator::Type; + }, + _ => { + *self = PrincipalComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for PrincipalFilter { + fn default() -> Self { + PrincipalFilter::_T("".to_string()) + } +} + +impl Default for PrincipalComparator { + fn default() -> Self { + PrincipalComparator::_T("".to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for PrincipalType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + PrincipalType::parse(<&str>::deserialize(deserializer)?) + .ok_or_else(|| serde::de::Error::custom("invalid JMAP PrincipalType")) + } +} + +impl From for PrincipalValue { + fn from(id: Id) -> Self { + PrincipalValue::Id(id) + } +} + +impl JmapObjectId for PrincipalValue { + fn as_id(&self) -> Option { + if let PrincipalValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let PrincipalValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = PrincipalValue::Id(id); + true + } else { + false + } + } +} + +impl Display for PrincipalFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + PrincipalFilter::AccountIds(_) => "accountIds", + PrincipalFilter::Email(_) => "email", + PrincipalFilter::Name(_) => "name", + PrincipalFilter::Text(_) => "text", + PrincipalFilter::Type(_) => "type", + PrincipalFilter::Timezone(_) => "timezone", + PrincipalFilter::_T(other) => other, + }) + } +} + +impl JmapObjectId for PrincipalProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl Display for PrincipalProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/jmap-proto/src/object/push_subscription.rs b/crates/jmap-proto/src/object/push_subscription.rs new file mode 100644 index 0000000..6137be5 --- /dev/null +++ b/crates/jmap-proto/src/object/push_subscription.rs @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::object::email::{EmailProperty, HeaderForm, HeaderProperty}; +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use crate::types::date::UTCDate; +use jmap_tools::{Element, JsonPointer, JsonPointerItem}; +use jmap_tools::{Key, Property}; +use std::borrow::Cow; +use std::str::FromStr; +use types::{id::Id, type_state::DataType}; + +#[derive(Debug, Clone, Default)] +pub struct PushSubscription; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PushSubscriptionProperty { + Id, + DeviceClientId, + Url, + Keys, + P256dh, + Auth, + VerificationCode, + Expires, + Types, + EmailPush, + + // Other + Pointer(JsonPointer), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PushSubscriptionValue { + Id(Id), + Date(UTCDate), + Types(DataType), +} + +impl Property for PushSubscriptionProperty { + fn try_parse(key: Option<&Key<'_, Self>>, value: &str) -> Option { + PushSubscriptionProperty::parse(value, key.is_none()) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + PushSubscriptionProperty::DeviceClientId => "deviceClientId", + PushSubscriptionProperty::Expires => "expires", + PushSubscriptionProperty::Id => "id", + PushSubscriptionProperty::Keys => "keys", + PushSubscriptionProperty::Types => "types", + PushSubscriptionProperty::Url => "url", + PushSubscriptionProperty::EmailPush => "emailPush", + PushSubscriptionProperty::VerificationCode => "verificationCode", + PushSubscriptionProperty::P256dh => "p256dh", + PushSubscriptionProperty::Auth => "auth", + PushSubscriptionProperty::Pointer(json_pointer) => { + return json_pointer.to_string().into(); + } + } + .into() + } +} + +impl PushSubscriptionProperty { + fn parse(value: &str, allow_patch: bool) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => PushSubscriptionProperty::Id, + b"deviceClientId" => PushSubscriptionProperty::DeviceClientId, + b"url" => PushSubscriptionProperty::Url, + b"keys" => PushSubscriptionProperty::Keys, + b"p256dh" => PushSubscriptionProperty::P256dh, + b"auth" => PushSubscriptionProperty::Auth, + b"verificationCode" => PushSubscriptionProperty::VerificationCode, + b"expires" => PushSubscriptionProperty::Expires, + b"types" => PushSubscriptionProperty::Types, + b"emailPush" => PushSubscriptionProperty::EmailPush, + ) + .or_else(|| { + if allow_patch && value.contains('/') { + PushSubscriptionProperty::Pointer(JsonPointer::parse(value)).into() + } else { + None + } + }) + } + + fn patch_or_prop(&self) -> &PushSubscriptionProperty { + if let PushSubscriptionProperty::Pointer(ptr) = self + && let Some(JsonPointerItem::Key(Key::Property(prop))) = ptr.last() + { + prop + } else { + self + } + } +} + +impl Element for PushSubscriptionValue { + type Property = PushSubscriptionProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop.patch_or_prop() { + PushSubscriptionProperty::Id => { + Id::from_str(value).ok().map(PushSubscriptionValue::Id) + } + PushSubscriptionProperty::Types => { + DataType::parse(value).map(PushSubscriptionValue::Types) + } + PushSubscriptionProperty::Expires => UTCDate::from_str(value) + .ok() + .map(PushSubscriptionValue::Date), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + PushSubscriptionValue::Id(id) => id.to_string().into(), + PushSubscriptionValue::Date(utcdate) => utcdate.to_string().into(), + PushSubscriptionValue::Types(data_type) => data_type.as_str().into(), + } + } +} + +impl FromStr for PushSubscriptionProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + PushSubscriptionProperty::parse(s, false).ok_or(()) + } +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub enum EmailPushProperty { + Id, + BlobId, + ThreadId, + MailboxIds, + Keywords, + Size, + ReceivedAt, + MessageId, + InReplyTo, + References, + Sender, + From, + To, + Cc, + Bcc, + ReplyTo, + Subject, + SentAt, + Preview, + HasAttachment, + BodyStructure, + BodyValues, + TextBody, + HtmlBody, + Attachments, + Headers, + Header(EmailPushHeaderProperty), +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub struct EmailPushHeaderProperty { + pub form: EmailPushHeaderForm, + pub header: String, + pub all: bool, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, Copy, PartialEq, Eq, Default, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +#[repr(u8)] +pub enum EmailPushHeaderForm { + #[default] + Raw = 0, + Text = 1, + Addresses = 2, + GroupedAddresses = 3, + MessageIds = 4, + Date = 5, + Urls = 6, +} + +impl TryFrom<&EmailProperty> for EmailPushProperty { + type Error = (); + + fn try_from(value: &EmailProperty) -> Result { + Ok(match value { + EmailProperty::Id => EmailPushProperty::Id, + EmailProperty::BlobId => EmailPushProperty::BlobId, + EmailProperty::ThreadId => EmailPushProperty::ThreadId, + EmailProperty::MailboxIds => EmailPushProperty::MailboxIds, + EmailProperty::Keywords => EmailPushProperty::Keywords, + EmailProperty::Size => EmailPushProperty::Size, + EmailProperty::ReceivedAt => EmailPushProperty::ReceivedAt, + EmailProperty::MessageId => EmailPushProperty::MessageId, + EmailProperty::InReplyTo => EmailPushProperty::InReplyTo, + EmailProperty::References => EmailPushProperty::References, + EmailProperty::Sender => EmailPushProperty::Sender, + EmailProperty::From => EmailPushProperty::From, + EmailProperty::To => EmailPushProperty::To, + EmailProperty::Cc => EmailPushProperty::Cc, + EmailProperty::Bcc => EmailPushProperty::Bcc, + EmailProperty::ReplyTo => EmailPushProperty::ReplyTo, + EmailProperty::Subject => EmailPushProperty::Subject, + EmailProperty::SentAt => EmailPushProperty::SentAt, + EmailProperty::Preview => EmailPushProperty::Preview, + EmailProperty::HasAttachment => EmailPushProperty::HasAttachment, + EmailProperty::BodyStructure => EmailPushProperty::BodyStructure, + EmailProperty::BodyValues => EmailPushProperty::BodyValues, + EmailProperty::TextBody => EmailPushProperty::TextBody, + EmailProperty::HtmlBody => EmailPushProperty::HtmlBody, + EmailProperty::Attachments => EmailPushProperty::Attachments, + EmailProperty::Headers => EmailPushProperty::Headers, + EmailProperty::Header(header) => EmailPushProperty::Header(EmailPushHeaderProperty { + form: (&header.form).into(), + header: header.header.clone(), + all: header.all, + }), + _ => return Err(()), + }) + } +} + +impl From<&EmailPushProperty> for EmailProperty { + fn from(value: &EmailPushProperty) -> Self { + match value { + EmailPushProperty::Id => EmailProperty::Id, + EmailPushProperty::BlobId => EmailProperty::BlobId, + EmailPushProperty::ThreadId => EmailProperty::ThreadId, + EmailPushProperty::MailboxIds => EmailProperty::MailboxIds, + EmailPushProperty::Keywords => EmailProperty::Keywords, + EmailPushProperty::Size => EmailProperty::Size, + EmailPushProperty::ReceivedAt => EmailProperty::ReceivedAt, + EmailPushProperty::MessageId => EmailProperty::MessageId, + EmailPushProperty::InReplyTo => EmailProperty::InReplyTo, + EmailPushProperty::References => EmailProperty::References, + EmailPushProperty::Sender => EmailProperty::Sender, + EmailPushProperty::From => EmailProperty::From, + EmailPushProperty::To => EmailProperty::To, + EmailPushProperty::Cc => EmailProperty::Cc, + EmailPushProperty::Bcc => EmailProperty::Bcc, + EmailPushProperty::ReplyTo => EmailProperty::ReplyTo, + EmailPushProperty::Subject => EmailProperty::Subject, + EmailPushProperty::SentAt => EmailProperty::SentAt, + EmailPushProperty::Preview => EmailProperty::Preview, + EmailPushProperty::HasAttachment => EmailProperty::HasAttachment, + EmailPushProperty::BodyStructure => EmailProperty::BodyStructure, + EmailPushProperty::BodyValues => EmailProperty::BodyValues, + EmailPushProperty::TextBody => EmailProperty::TextBody, + EmailPushProperty::HtmlBody => EmailProperty::HtmlBody, + EmailPushProperty::Attachments => EmailProperty::Attachments, + EmailPushProperty::Headers => EmailProperty::Headers, + EmailPushProperty::Header(header) => EmailProperty::Header(HeaderProperty { + form: (&header.form).into(), + header: header.header.clone(), + all: header.all, + }), + } + } +} + +impl From<&ArchivedEmailPushProperty> for EmailProperty { + fn from(value: &ArchivedEmailPushProperty) -> Self { + match value { + ArchivedEmailPushProperty::Id => EmailProperty::Id, + ArchivedEmailPushProperty::BlobId => EmailProperty::BlobId, + ArchivedEmailPushProperty::ThreadId => EmailProperty::ThreadId, + ArchivedEmailPushProperty::MailboxIds => EmailProperty::MailboxIds, + ArchivedEmailPushProperty::Keywords => EmailProperty::Keywords, + ArchivedEmailPushProperty::Size => EmailProperty::Size, + ArchivedEmailPushProperty::ReceivedAt => EmailProperty::ReceivedAt, + ArchivedEmailPushProperty::MessageId => EmailProperty::MessageId, + ArchivedEmailPushProperty::InReplyTo => EmailProperty::InReplyTo, + ArchivedEmailPushProperty::References => EmailProperty::References, + ArchivedEmailPushProperty::Sender => EmailProperty::Sender, + ArchivedEmailPushProperty::From => EmailProperty::From, + ArchivedEmailPushProperty::To => EmailProperty::To, + ArchivedEmailPushProperty::Cc => EmailProperty::Cc, + ArchivedEmailPushProperty::Bcc => EmailProperty::Bcc, + ArchivedEmailPushProperty::ReplyTo => EmailProperty::ReplyTo, + ArchivedEmailPushProperty::Subject => EmailProperty::Subject, + ArchivedEmailPushProperty::SentAt => EmailProperty::SentAt, + ArchivedEmailPushProperty::Preview => EmailProperty::Preview, + ArchivedEmailPushProperty::HasAttachment => EmailProperty::HasAttachment, + ArchivedEmailPushProperty::BodyStructure => EmailProperty::BodyStructure, + ArchivedEmailPushProperty::BodyValues => EmailProperty::BodyValues, + ArchivedEmailPushProperty::TextBody => EmailProperty::TextBody, + ArchivedEmailPushProperty::HtmlBody => EmailProperty::HtmlBody, + ArchivedEmailPushProperty::Attachments => EmailProperty::Attachments, + ArchivedEmailPushProperty::Headers => EmailProperty::Headers, + ArchivedEmailPushProperty::Header(header) => EmailProperty::Header(HeaderProperty { + form: (&header.form).into(), + header: header.header.as_str().to_string(), + all: header.all, + }), + } + } +} + +impl From<&ArchivedEmailPushHeaderForm> for HeaderForm { + fn from(value: &ArchivedEmailPushHeaderForm) -> Self { + match value { + ArchivedEmailPushHeaderForm::Raw => HeaderForm::Raw, + ArchivedEmailPushHeaderForm::Text => HeaderForm::Text, + ArchivedEmailPushHeaderForm::Addresses => HeaderForm::Addresses, + ArchivedEmailPushHeaderForm::GroupedAddresses => HeaderForm::GroupedAddresses, + ArchivedEmailPushHeaderForm::MessageIds => HeaderForm::MessageIds, + ArchivedEmailPushHeaderForm::Date => HeaderForm::Date, + ArchivedEmailPushHeaderForm::Urls => HeaderForm::URLs, + } + } +} + +impl From<&HeaderForm> for EmailPushHeaderForm { + fn from(value: &HeaderForm) -> Self { + match value { + HeaderForm::Raw => EmailPushHeaderForm::Raw, + HeaderForm::Text => EmailPushHeaderForm::Text, + HeaderForm::Addresses => EmailPushHeaderForm::Addresses, + HeaderForm::GroupedAddresses => EmailPushHeaderForm::GroupedAddresses, + HeaderForm::MessageIds => EmailPushHeaderForm::MessageIds, + HeaderForm::Date => EmailPushHeaderForm::Date, + HeaderForm::URLs => EmailPushHeaderForm::Urls, + } + } +} + +impl From<&EmailPushHeaderForm> for HeaderForm { + fn from(value: &EmailPushHeaderForm) -> Self { + match value { + EmailPushHeaderForm::Raw => HeaderForm::Raw, + EmailPushHeaderForm::Text => HeaderForm::Text, + EmailPushHeaderForm::Addresses => HeaderForm::Addresses, + EmailPushHeaderForm::GroupedAddresses => HeaderForm::GroupedAddresses, + EmailPushHeaderForm::MessageIds => HeaderForm::MessageIds, + EmailPushHeaderForm::Date => HeaderForm::Date, + EmailPushHeaderForm::Urls => HeaderForm::URLs, + } + } +} + +impl JmapObject for PushSubscription { + type Property = PushSubscriptionProperty; + + type Element = PushSubscriptionValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = PushSubscriptionProperty::Id; +} + +impl From for PushSubscriptionValue { + fn from(id: Id) -> Self { + PushSubscriptionValue::Id(id) + } +} + +impl JmapObjectId for PushSubscriptionValue { + fn as_id(&self) -> Option { + match self { + PushSubscriptionValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + PushSubscriptionValue::Id(id) => Some(AnyId::Id(*id)), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = PushSubscriptionValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for PushSubscriptionProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/quota.rs b/crates/jmap-proto/src/object/quota.rs new file mode 100644 index 0000000..172294e --- /dev/null +++ b/crates/jmap-proto/src/object/quota.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{id::Id, type_state::DataType}; + +#[derive(Debug, Clone, Default)] +pub struct Quota; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum QuotaProperty { + Id, + ResourceType, + Used, + Name, + Scope, + Types, + HardLimit, + WarnLimit, + SoftLimit, + Description, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum QuotaValue { + Id(Id), + Types(DataType), +} + +impl Property for QuotaProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + QuotaProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + QuotaProperty::Description => "description", + QuotaProperty::Id => "id", + QuotaProperty::Name => "name", + QuotaProperty::Types => "types", + QuotaProperty::ResourceType => "resourceType", + QuotaProperty::Used => "used", + QuotaProperty::HardLimit => "hardLimit", + QuotaProperty::Scope => "scope", + QuotaProperty::WarnLimit => "warnLimit", + QuotaProperty::SoftLimit => "softLimit", + } + .into() + } +} + +impl QuotaProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => QuotaProperty::Id, + b"resourceType" => QuotaProperty::ResourceType, + b"used" => QuotaProperty::Used, + b"name" => QuotaProperty::Name, + b"scope" => QuotaProperty::Scope, + b"types" => QuotaProperty::Types, + b"hardLimit" => QuotaProperty::HardLimit, + b"warnLimit" => QuotaProperty::WarnLimit, + b"softLimit" => QuotaProperty::SoftLimit, + b"description" => QuotaProperty::Description, + ) + } +} + +impl Element for QuotaValue { + type Property = QuotaProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + QuotaProperty::Id => Id::from_str(value).ok().map(QuotaValue::Id), + QuotaProperty::Types => DataType::parse(value).map(QuotaValue::Types), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + QuotaValue::Id(id) => id.to_string().into(), + QuotaValue::Types(data_type) => data_type.as_str().into(), + } + } +} + +impl FromStr for QuotaProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + QuotaProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for Quota { + type Property = QuotaProperty; + + type Element = QuotaValue; + + type Id = Id; + + type Filter = QuotaFilter; + + type Comparator = QuotaComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = QuotaProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuotaFilter { + Name(String), + Type(String), + Scope(String), + ResourceType(String), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuotaComparator { + Name, + Type, + Used, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for QuotaFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"name" => { + *self = QuotaFilter::Name(map.next_value()?); + }, + b"type" => { + *self = QuotaFilter::Type(map.next_value()?); + }, + b"scope" => { + *self = QuotaFilter::Scope(map.next_value()?); + }, + b"resourceType" => { + *self = QuotaFilter::ResourceType(map.next_value()?); + }, + _ => { + *self = QuotaFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for QuotaComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"name" => { + *self = QuotaComparator::Name; + }, + b"type" => { + *self = QuotaComparator::Type; + }, + b"used" => { + *self = QuotaComparator::Used; + }, + _ => { + *self = QuotaComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for QuotaFilter { + fn default() -> Self { + QuotaFilter::_T("".to_string()) + } +} + +impl Default for QuotaComparator { + fn default() -> Self { + QuotaComparator::_T("".to_string()) + } +} + +impl From for QuotaValue { + fn from(id: Id) -> Self { + QuotaValue::Id(id) + } +} + +impl JmapObjectId for QuotaValue { + fn as_id(&self) -> Option { + if let QuotaValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + self.as_id().map(AnyId::Id) + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = QuotaValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for QuotaProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/registry.rs b/crates/jmap-proto/src/object/registry.rs new file mode 100644 index 0000000..e4dc73e --- /dev/null +++ b/crates/jmap-proto/src/object/registry.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use registry::{jmap::RegistryValue, schema::prelude::Property, types::EnumImpl}; +use std::borrow::Cow; +use types::id::Id; + +#[derive(Debug)] +pub struct Registry; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegistryFilter { + Property { + property: Property, + operator: RegistryFilterOperator, + value: serde_json::Value, + }, + _T(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryFilterOperator { + Equal, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegistryComparator { + Property(Property), + _T(String), +} + +impl JmapObject for Registry { + type Property = Property; + + type Element = RegistryValue; + + type Id = Id; + + type Filter = RegistryFilter; + + type Comparator = RegistryComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = Property::Id; +} + +impl JmapObjectId for Property { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: super::AnyId) -> bool { + false + } +} + +impl JmapObjectId for RegistryValue { + fn as_id(&self) -> Option { + if let RegistryValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + match self { + RegistryValue::Id(id) => Some(AnyId::Id(*id)), + RegistryValue::BlobId(id) => Some(AnyId::BlobId(id.clone())), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let RegistryValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = RegistryValue::Id(id); + } + AnyId::BlobId(id) => { + *self = RegistryValue::BlobId(id); + } + } + true + } +} + +impl<'de> DeserializeArguments<'de> for RegistryFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if let Some(property) = Property::parse(key) { + let value = map.next_value()?; + *self = RegistryFilter::Property { + property, + operator: RegistryFilterOperator::Equal, + value, + }; + return Ok(()); + } else if let Some((property, operator)) = key.rsplit_once("Is") + && let (Some(property), Some(operator)) = ( + Property::parse(property), + RegistryFilterOperator::parse(operator), + ) + { + let value = map.next_value()?; + *self = RegistryFilter::Property { + property, + operator, + value, + }; + return Ok(()); + } + + *self = RegistryFilter::_T(key.to_string()); + let _ = map.next_value::()?; + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for RegistryComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + + if let Some(property) = Property::parse(value.as_ref()) { + *self = RegistryComparator::Property(property); + } else { + *self = RegistryComparator::_T(value.into_owned()); + } + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl RegistryFilterOperator { + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"GreaterThan" => RegistryFilterOperator::GreaterThan, + b"GreaterThanOrEqual" => RegistryFilterOperator::GreaterThanOrEqual, + b"LessThan" => RegistryFilterOperator::LessThan, + b"LessThanOrEqual" => RegistryFilterOperator::LessThanOrEqual, + ) + } +} + +impl Default for RegistryFilter { + fn default() -> Self { + RegistryFilter::_T("".to_string()) + } +} + +impl Default for RegistryComparator { + fn default() -> Self { + RegistryComparator::_T("".to_string()) + } +} diff --git a/crates/jmap-proto/src/object/search_snippet.rs b/crates/jmap-proto/src/object/search_snippet.rs new file mode 100644 index 0000000..0da60e7 --- /dev/null +++ b/crates/jmap-proto/src/object/search_snippet.rs @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct SearchSnippet; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SearchSnippetProperty { + EmailId, + Subject, + Preview, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SearchSnippetValue { + Id(Id), +} + +impl Property for SearchSnippetProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + SearchSnippetProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SearchSnippetProperty::Preview => "preview", + SearchSnippetProperty::Subject => "subject", + SearchSnippetProperty::EmailId => "emailId", + } + .into() + } +} + +impl Element for SearchSnippetValue { + type Property = SearchSnippetProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + SearchSnippetProperty::EmailId => { + Id::from_str(value).ok().map(SearchSnippetValue::Id) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SearchSnippetValue::Id(id) => id.to_string().into(), + } + } +} + +impl SearchSnippetProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"emailId" => SearchSnippetProperty::EmailId, + b"subject" => SearchSnippetProperty::Subject, + b"preview" => SearchSnippetProperty::Preview, + ) + } +} diff --git a/crates/jmap-proto/src/object/share_notification.rs b/crates/jmap-proto/src/object/share_notification.rs new file mode 100644 index 0000000..afc2944 --- /dev/null +++ b/crates/jmap-proto/src/object/share_notification.rs @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; +use types::{id::Id, type_state::DataType}; + +#[derive(Debug, Clone, Default)] +pub struct ShareNotification; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ShareNotificationProperty { + Id, + Created, + ChangedBy, + ChangedByName, + ChangedByEmail, + ChangedByPrincipalId, + ObjectType, + ObjectAccountId, + ObjectId, + OldRights, + NewRights, + Name, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ShareNotificationValue { + Id(Id), + Date(UTCDate), + ObjectType(DataType), +} + +impl Property for ShareNotificationProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + ShareNotificationProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ShareNotificationProperty::Id => "id", + ShareNotificationProperty::Created => "created", + ShareNotificationProperty::ChangedBy => "changedBy", + ShareNotificationProperty::ChangedByName => "name", + ShareNotificationProperty::ChangedByEmail => "email", + ShareNotificationProperty::ChangedByPrincipalId => "principalId", + ShareNotificationProperty::ObjectType => "objectType", + ShareNotificationProperty::ObjectAccountId => "objectAccountId", + ShareNotificationProperty::ObjectId => "objectId", + ShareNotificationProperty::OldRights => "oldRights", + ShareNotificationProperty::NewRights => "newRights", + ShareNotificationProperty::Name => "name", + } + .into() + } +} + +impl Element for ShareNotificationValue { + type Property = ShareNotificationProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + ShareNotificationProperty::Id + | ShareNotificationProperty::ChangedByPrincipalId + | ShareNotificationProperty::ObjectAccountId + | ShareNotificationProperty::ObjectId => { + Id::from_str(value).ok().map(ShareNotificationValue::Id) + } + ShareNotificationProperty::Created => UTCDate::from_str(value) + .ok() + .map(ShareNotificationValue::Date), + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ShareNotificationValue::Id(id) => id.to_string().into(), + ShareNotificationValue::Date(date) => date.to_string().into(), + ShareNotificationValue::ObjectType(ty) => ty.as_str().into(), + } + } +} + +impl ShareNotificationProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ShareNotificationProperty::Id, + b"created" => ShareNotificationProperty::Created, + b"changedBy" => ShareNotificationProperty::ChangedBy, + b"name" => ShareNotificationProperty::ChangedByName, + b"email" => ShareNotificationProperty::ChangedByEmail, + b"principalId" => ShareNotificationProperty::ChangedByPrincipalId, + b"objectType" => ShareNotificationProperty::ObjectType, + b"objectAccountId" => ShareNotificationProperty::ObjectAccountId, + b"objectId" => ShareNotificationProperty::ObjectId, + b"oldRights" => ShareNotificationProperty::OldRights, + b"newRights" => ShareNotificationProperty::NewRights + ) + } +} + +impl FromStr for ShareNotificationProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ShareNotificationProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for ShareNotification { + type Property = ShareNotificationProperty; + + type Element = ShareNotificationValue; + + type Id = Id; + + type Filter = ShareNotificationFilter; + + type Comparator = ShareNotificationComparator; + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ShareNotificationProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShareNotificationFilter { + After(UTCDate), + Before(UTCDate), + ObjectType(DataType), + ObjectAccountId(Id), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShareNotificationComparator { + Created, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for ShareNotificationFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"after" => { + *self = ShareNotificationFilter::After(map.next_value()?); + }, + b"before" => { + *self = ShareNotificationFilter::Before(map.next_value()?); + }, + b"objectType" => { + *self = ShareNotificationFilter::ObjectType(map.next_value()?); + }, + b"objectAccountId" => { + *self = ShareNotificationFilter::ObjectAccountId(map.next_value()?); + }, + _ => { + *self = ShareNotificationFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for ShareNotificationComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"created" => { + *self = ShareNotificationComparator::Created; + }, + _ => { + *self = ShareNotificationComparator::_T(value.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl ShareNotificationFilter { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ShareNotificationFilter::After(_) => "after", + ShareNotificationFilter::Before(_) => "before", + ShareNotificationFilter::ObjectType(_) => "objectType", + ShareNotificationFilter::ObjectAccountId(_) => "objectAccountId", + ShareNotificationFilter::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl ShareNotificationComparator { + pub fn into_string(self) -> Cow<'static, str> { + match self { + ShareNotificationComparator::Created => "created", + ShareNotificationComparator::_T(s) => return Cow::Owned(s), + } + .into() + } +} + +impl Default for ShareNotificationFilter { + fn default() -> Self { + ShareNotificationFilter::_T(String::new()) + } +} + +impl Default for ShareNotificationComparator { + fn default() -> Self { + ShareNotificationComparator::_T(String::new()) + } +} + +impl TryFrom for Id { + type Error = (); + + fn try_from(_: ShareNotificationProperty) -> Result { + Err(()) + } +} + +impl From for ShareNotificationValue { + fn from(id: Id) -> Self { + ShareNotificationValue::Id(id) + } +} + +impl JmapObjectId for ShareNotificationValue { + fn as_id(&self) -> Option { + if let ShareNotificationValue::Id(id) = self { + Some(*id) + } else { + None + } + } + + fn as_any_id(&self) -> Option { + if let ShareNotificationValue::Id(id) = self { + Some(AnyId::Id(*id)) + } else { + None + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl JmapObjectId for ShareNotificationProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} + +impl Display for ShareNotificationProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/sieve.rs b/crates/jmap-proto/src/object/sieve.rs new file mode 100644 index 0000000..adaf835 --- /dev/null +++ b/crates/jmap-proto/src/object/sieve.rs @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, DeserializeArguments, JmapObject, JmapObjectId, MaybeReference, parse_ref}, + request::reference::MaybeIdReference, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +#[derive(Debug, Clone, Default)] +pub struct Sieve; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SieveProperty { + Id, + Name, + BlobId, + IsActive, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SieveValue { + Id(Id), + BlobId(BlobId), + IdReference(String), +} + +impl Property for SieveProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + SieveProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SieveProperty::BlobId => "blobId", + SieveProperty::Id => "id", + SieveProperty::Name => "name", + SieveProperty::IsActive => "isActive", + } + .into() + } +} + +impl Element for SieveValue { + type Property = SieveProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + SieveProperty::Id => match parse_ref(value) { + MaybeReference::Value(v) => Some(SieveValue::Id(v)), + MaybeReference::Reference(v) => Some(SieveValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + SieveProperty::BlobId => match parse_ref(value) { + MaybeReference::Value(v) => Some(SieveValue::BlobId(v)), + MaybeReference::Reference(v) => Some(SieveValue::IdReference(v)), + MaybeReference::ParseError => None, + }, + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SieveValue::Id(id) => id.to_string().into(), + SieveValue::BlobId(blob_id) => blob_id.to_string().into(), + SieveValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl SieveProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => SieveProperty::Id, + b"name" => SieveProperty::Name, + b"blobId" => SieveProperty::BlobId, + b"isActive" => SieveProperty::IsActive, + ) + } +} + +#[derive(Debug, Clone, Default)] +pub struct SieveSetArguments { + pub on_success_activate_script: Option>, + pub on_success_deactivate_script: Option, +} + +impl<'de> DeserializeArguments<'de> for SieveSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"onSuccessActivateScript" => { + self.on_success_activate_script = map.next_value()?; + }, + b"onSuccessDeactivateScript" => { + self.on_success_deactivate_script = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl FromStr for SieveProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + SieveProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for Sieve { + type Property = SieveProperty; + + type Element = SieveValue; + + type Id = Id; + + type Filter = SieveFilter; + + type Comparator = SieveComparator; + + type GetArguments = (); + + type SetArguments<'de> = SieveSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = SieveProperty::Id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SieveFilter { + Name(String), + IsActive(bool), + _T(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SieveComparator { + Name, + IsActive, + _T(String), +} + +impl<'de> DeserializeArguments<'de> for SieveFilter { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"name" => { + *self = SieveFilter::Name(map.next_value()?); + }, + b"isActive" => { + *self = SieveFilter::IsActive(map.next_value()?); + }, + _ => { + *self = SieveFilter::_T(key.to_string()); + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +impl<'de> DeserializeArguments<'de> for SieveComparator { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "property" { + let value = map.next_value::>()?; + hashify::fnc_map!(value.as_bytes(), + b"name" => { + *self = SieveComparator::Name; + }, + b"isActive" => { + *self = SieveComparator::IsActive; + }, + _ => { + *self = SieveComparator::_T(key.to_string()); + } + ); + } else { + let _ = map.next_value::()?; + } + + Ok(()) + } +} + +impl Default for SieveFilter { + fn default() -> Self { + SieveFilter::_T("".to_string()) + } +} + +impl Default for SieveComparator { + fn default() -> Self { + SieveComparator::_T("".to_string()) + } +} + +impl From for SieveValue { + fn from(id: Id) -> Self { + SieveValue::Id(id) + } +} + +impl JmapObjectId for SieveValue { + fn as_id(&self) -> Option { + match self { + SieveValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + SieveValue::Id(id) => Some(AnyId::Id(*id)), + SieveValue::BlobId(id) => Some(AnyId::BlobId(id.clone())), + SieveValue::IdReference(_) => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + if let SieveValue::IdReference(r) = self { + Some(r) + } else { + None + } + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + match new_id { + AnyId::Id(id) => { + *self = SieveValue::Id(id); + } + AnyId::BlobId(id) => { + *self = SieveValue::BlobId(id); + } + } + true + } +} + +impl JmapObjectId for SieveProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/thread.rs b/crates/jmap-proto/src/object/thread.rs new file mode 100644 index 0000000..4815a84 --- /dev/null +++ b/crates/jmap-proto/src/object/thread.rs @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +use crate::object::{AnyId, JmapObject, JmapObjectId}; + +#[derive(Debug, Clone, Default)] +pub struct Thread; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ThreadProperty { + Id, + EmailIds, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ThreadValue { + Id(Id), +} + +impl Property for ThreadProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + ThreadProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ThreadProperty::Id => "id", + ThreadProperty::EmailIds => "emailIds", + } + .into() + } +} + +impl Element for ThreadValue { + type Property = ThreadProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(_) = key { + Id::from_str(value).ok().map(ThreadValue::Id) + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ThreadValue::Id(id) => id.to_string().into(), + } + } +} + +impl ThreadProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ThreadProperty::Id, + b"emailIds" => ThreadProperty::EmailIds, + ) + } +} + +impl FromStr for ThreadProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ThreadProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for Thread { + type Property = ThreadProperty; + + type Element = ThreadValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ThreadProperty::Id; +} + +impl From for ThreadValue { + fn from(id: Id) -> Self { + ThreadValue::Id(id) + } +} + +impl JmapObjectId for ThreadValue { + fn as_id(&self) -> Option { + match self { + ThreadValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + self.as_id().map(AnyId::Id) + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = ThreadValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for ThreadProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/vacation_response.rs b/crates/jmap-proto/src/object/vacation_response.rs new file mode 100644 index 0000000..be1a9ff --- /dev/null +++ b/crates/jmap-proto/src/object/vacation_response.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct VacationResponse; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum VacationResponseProperty { + Id, + IsEnabled, + FromDate, + ToDate, + Subject, + TextBody, + HtmlBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum VacationResponseValue { + Id(Id), + Date(UTCDate), +} + +impl Property for VacationResponseProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + VacationResponseProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + VacationResponseProperty::HtmlBody => "htmlBody", + VacationResponseProperty::Id => "id", + VacationResponseProperty::TextBody => "textBody", + VacationResponseProperty::FromDate => "fromDate", + VacationResponseProperty::IsEnabled => "isEnabled", + VacationResponseProperty::ToDate => "toDate", + VacationResponseProperty::Subject => "subject", + } + .into() + } +} + +impl Element for VacationResponseValue { + type Property = VacationResponseProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + VacationResponseProperty::Id => { + Id::from_str(value).ok().map(VacationResponseValue::Id) + } + VacationResponseProperty::FromDate | VacationResponseProperty::ToDate => { + UTCDate::from_str(value) + .ok() + .map(VacationResponseValue::Date) + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + VacationResponseValue::Id(id) => id.to_string().into(), + VacationResponseValue::Date(utcdate) => utcdate.to_string().into(), + } + } +} + +impl VacationResponseProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => VacationResponseProperty::Id, + b"isEnabled" => VacationResponseProperty::IsEnabled, + b"fromDate" => VacationResponseProperty::FromDate, + b"toDate" => VacationResponseProperty::ToDate, + b"textBody" => VacationResponseProperty::TextBody, + b"htmlBody" => VacationResponseProperty::HtmlBody, + b"subject" => VacationResponseProperty::Subject, + ) + } +} + +impl FromStr for VacationResponseProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + VacationResponseProperty::parse(s).ok_or(()) + } +} + +impl JmapObject for VacationResponse { + type Property = VacationResponseProperty; + + type Element = VacationResponseValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = VacationResponseProperty::Id; +} + +impl From for VacationResponseValue { + fn from(id: Id) -> Self { + VacationResponseValue::Id(id) + } +} + +impl JmapObjectId for VacationResponseValue { + fn as_id(&self) -> Option { + match self { + VacationResponseValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + VacationResponseValue::Id(id) => Some(AnyId::Id(*id)), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = VacationResponseValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for VacationResponseProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs new file mode 100644 index 0000000..c3cb663 --- /dev/null +++ b/crates/jmap-proto/src/references/eval.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + object::{AnyId, JmapObjectId}, + references::{ + Graph, + jsptr::{EvalResults, ResponsePtr}, + }, + request::reference::ResultReference, + response::{ChangesResponseMethod, GetResponseMethod, Response, ResponseMethod}, +}; +use compact_str::format_compact; +use jmap_tools::{Element, Key, Property, Value}; +use types::{blob::BlobId, id::Id}; + +impl Response<'_> { + pub(crate) fn eval_result_references(&self, rr: &ResultReference) -> trc::Result { + let mut results = EvalResults::default(); + + for response in &self.method_responses { + if response.id == rr.result_of && response.name == rr.name { + let path = rr.path.iter(); + let success = match &response.method { + ResponseMethod::Get(response) => match response { + GetResponseMethod::Email(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Mailbox(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Thread(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Identity(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::EmailSubmission(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::PushSubscription(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Sieve(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::VacationResponse(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Principal(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Quota(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Blob(response) => response.eval_jptr(path, &mut results), + GetResponseMethod::AddressBook(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::ContactCard(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::FileNode(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Calendar(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::CalendarEvent(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::CalendarEventNotification(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::ParticipantIdentity(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::ShareNotification(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::PrincipalAvailability(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::Registry(response) => { + response.eval_jptr(path, &mut results) + } + }, + ResponseMethod::Changes(response) => match response { + ChangesResponseMethod::Email(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::Mailbox(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::Thread(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::Identity(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::EmailSubmission(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::Quota(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::AddressBook(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::ContactCard(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::FileNode(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::Calendar(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::CalendarEvent(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::CalendarEventNotification(response) => { + response.eval_jptr(path, &mut results) + } + ChangesResponseMethod::ShareNotification(response) => { + response.eval_jptr(path, &mut results) + } + }, + ResponseMethod::Query(response) => response.eval_jptr(path, &mut results), + ResponseMethod::QueryChanges(response) => { + response.eval_jptr(path, &mut results) + } + _ => false, + }; + + if success { + return Ok(results); + } + } + } + + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!( + "Result reference to {}#{} not found.", + rr.result_of, + rr.name + ))) + } + + pub(crate) fn eval_id_reference(&self, ir: &str) -> trc::Result { + if let Some(AnyId::Id(id)) = self.created_ids.get(ir) { + Ok(*id) + } else { + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!("Id reference {ir:?} not found."))) + } + } + + pub(crate) fn eval_blob_id_reference(&self, ir: &str) -> trc::Result { + if let Some(AnyId::BlobId(id)) = self.created_ids.get(ir) { + Ok(id.clone()) + } else { + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!("blobId reference {ir:?} not found."))) + } + } +} + +pub(crate) trait EvalObjectReferences { + fn eval_object_references( + &mut self, + response: &Response<'_>, + graph: &mut Graph<'_>, + depth: usize, + max_depth: usize, + eval_strings: bool, + ) -> trc::Result<()>; +} + +impl<'x, P, E> EvalObjectReferences for Value<'x, P, E> +where + P: Property + JmapObjectId, + E: Element + JmapObjectId, +{ + fn eval_object_references( + &mut self, + response: &Response<'_>, + graph: &mut Graph<'_>, + depth: usize, + max_depth: usize, + eval_strings: bool, + ) -> trc::Result<()> { + match self { + Value::Element(element) => { + if let Some(id_ref) = element.as_id_ref() { + if let Some(id) = response.created_ids.get(id_ref) { + if !element.try_set_id(id.clone()) { + return Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details("Id reference points to invalid type.")); + } + } else if let Graph::Some { child_id, graph } = graph { + graph + .entry(child_id.to_string()) + .or_insert_with(Vec::new) + .push(id_ref.to_string()); + } else { + return Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!("Id reference {id_ref:?} not found."))); + } + } + } + Value::Array(items) if depth < max_depth => { + // Resolve references in arrays (e.g. emailIds: [#idRef1, #idRef2]) + for item in items { + item.eval_object_references( + response, + graph, + depth + 1, + max_depth, + eval_strings, + )?; + } + } + Value::Object(items) if depth < max_depth => { + // Resolve references in JMAP sets (e.g. mailboxIds: { "#idRef1": true, "#idRef2": true }) + for (key, value) in items.as_mut_vec() { + if let Key::Property(property) = key + && let Some(id_ref) = property.as_id_ref() + { + if let Some(id) = response.created_ids.get(id_ref) { + if !property.try_set_id(id.clone()) { + return Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details("Id reference points to invalid type.")); + } + } else { + return Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!("Id reference {id_ref:?} not found."))); + } + } else if eval_strings + && let Some(id) = key + .as_string_key() + .and_then(|k| k.strip_prefix('#')) + .and_then(|id_ref| response.created_ids.get(id_ref)) + { + *key = Key::Owned(match id { + AnyId::Id(id) => id.to_string(), + AnyId::BlobId(id) => id.to_string(), + }); + } + + if matches!( + value, + Value::Element(_) | Value::Array(_) | Value::Object(_) + ) { + value.eval_object_references( + response, + graph, + depth + 1, + max_depth, + eval_strings, + )?; + } + } + } + _ => {} + } + + Ok(()) + } +} diff --git a/crates/jmap-proto/src/references/jsptr.rs b/crates/jmap-proto/src/references/jsptr.rs new file mode 100644 index 0000000..ed2eeb1 --- /dev/null +++ b/crates/jmap-proto/src/references/jsptr.rs @@ -0,0 +1,335 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + method::{ + PropertyWrapper, + availability::{BusyPeriod, GetAvailabilityResponse}, + changes::ChangesResponse, + get::GetResponse, + query::QueryResponse, + query_changes::{AddedItem, QueryChangesResponse}, + }, + object::{ + AnyId, JmapObject, JmapObjectId, + calendar_event_notification::{ + CalendarEventNotificationGetResponse, CalendarEventNotificationObject, + }, + }, + request::reference::ResultReference, +}; +use compact_str::format_compact; +use jmap_tools::{Element, JsonPointerItem, JsonPointerIter, Key, Null, Property, Value}; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +pub(crate) trait ResponsePtr { + fn eval_jptr(&self, pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool; +} + +#[derive(Debug, Default)] +#[repr(transparent)] +pub(crate) struct EvalResults(Vec); + +#[derive(Debug)] +pub(crate) enum EvalResult { + Id(AnyId), + Property(Cow<'static, str>), +} + +impl ResponsePtr for Vec +where + T: ResponsePtr, +{ + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next() { + Some(JsonPointerItem::Number(n)) => { + if let Some(v) = self.get(*n as usize) { + v.eval_jptr(pointer, results); + } + } + Some(JsonPointerItem::Wildcard | JsonPointerItem::Root) | None => { + for v in self { + v.eval_jptr(pointer.clone(), results); + } + } + _ => (), + } + + true + } +} + +impl<'ctx, P, E> ResponsePtr for Value<'ctx, P, E> +where + P: Property, + E: Element + JmapObjectId, +{ + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next() { + Some(JsonPointerItem::Key(key)) => { + if let Some(key) = key.as_string_key() + && let Value::Object(map) = self + && let Some(v) = map.get(&Key::Borrowed(key)) + { + v.eval_jptr(pointer, results); + } + } + Some(JsonPointerItem::Number(n)) => match self { + Value::Array(values) => { + if let Some(v) = values.get(*n as usize) { + v.eval_jptr(pointer, results); + } + } + Value::Object(map) => { + let n = Key::Owned(n.to_string()); + if let Some(v) = map.get(&n) { + v.eval_jptr(pointer, results); + } + } + _ => {} + }, + Some(JsonPointerItem::Wildcard) => match self { + Value::Array(values) => { + for v in values { + v.eval_jptr(pointer.clone(), results); + } + } + Value::Object(map) => { + for v in map.values() { + v.eval_jptr(pointer.clone(), results); + } + } + _ => {} + }, + Some(JsonPointerItem::Root) | None => match self { + Value::Element(e) => { + if let Some(id) = e.as_any_id() { + results.0.push(EvalResult::Id(id)); + } + } + Value::Array(list) => { + for item in list { + if let Value::Element(e) = item + && let Some(id) = e.as_any_id() + { + results.0.push(EvalResult::Id(id)); + } + } + } + _ => (), + }, + } + + true + } +} + +impl ResponsePtr for Id { + fn eval_jptr(&self, _pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + results.0.push(EvalResult::Id(AnyId::Id(*self))); + true + } +} + +impl ResponsePtr for BlobId { + fn eval_jptr(&self, _pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + results.0.push(EvalResult::Id(AnyId::BlobId(self.clone()))); + true + } +} + +impl ResponsePtr for PropertyWrapper { + fn eval_jptr(&self, _: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + results.0.push(EvalResult::Property(self.0.to_cow())); + true + } +} + +impl ResponsePtr for GetResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("list") => { + self.list.eval_jptr(pointer, results); + true + } + _ => false, + } + } +} + +impl ResponsePtr for ChangesResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + if let Some(property) = pointer.next().and_then(|item| item.as_string_key()) { + hashify::fnc_map!(property.as_bytes(), + "created" => { + self.created.eval_jptr(pointer, results); + }, + "updated" => { + self.updated.eval_jptr(pointer, results); + }, + "updatedProperties" => { + if let Some(props) = &self.updated_properties { + props.eval_jptr(pointer, results); + } + }, + _ => { + return false; + } + ); + + true + } else { + false + } + } +} + +impl ResponsePtr for QueryResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("ids") => { + self.ids.eval_jptr(pointer, results); + true + } + _ => false, + } + } +} + +impl ResponsePtr for QueryChangesResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("added") => { + self.added.eval_jptr(pointer, results); + true + } + _ => false, + } + } +} + +impl ResponsePtr for AddedItem { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("id") => { + results.0.push(EvalResult::Id(AnyId::Id(self.id))); + true + } + _ => false, + } + } +} + +impl ResponsePtr for CalendarEventNotificationGetResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("list") => { + self.list.eval_jptr(pointer, results); + true + } + _ => false, + } + } +} + +impl ResponsePtr for CalendarEventNotificationObject { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("id") => { + results.0.push(EvalResult::Id(AnyId::Id(self.id))); + true + } + Some("calendarEventId") => { + if let Some(id) = &self.calendar_event_id { + results.0.push(EvalResult::Id(AnyId::Id(*id))); + } + true + } + Some("event") => { + if let Some(event) = &self.event { + event.0.eval_jptr(pointer, results); + } + true + } + _ => false, + } + } +} + +impl ResponsePtr for GetAvailabilityResponse { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("list") => { + self.list.eval_jptr(pointer, results); + true + } + _ => false, + } + } +} + +impl ResponsePtr for BusyPeriod { + fn eval_jptr(&self, mut pointer: JsonPointerIter<'_, Null>, results: &mut EvalResults) -> bool { + match pointer.next().and_then(|item| item.as_string_key()) { + Some("event") => { + if let Some(event) = &self.event { + event.0.eval_jptr(pointer, results); + } + true + } + _ => false, + } + } +} + +impl EvalResults { + pub fn into_ids>( + self, + rr: &ResultReference, + ) -> impl Iterator> { + self.0.into_iter().map(move |id| { + if let EvalResult::Id(any_id) = id { + T::try_from(any_id).map_err(|_| { + trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!( + "Failed to evaluate {rr} result reference: Invalid Id type." + )) + }) + } else { + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!( + "Failed to evaluate {rr} result reference: Invalid Id type." + ))) + } + }) + } + + pub fn into_properties( + self, + rr: &ResultReference, + ) -> impl Iterator> { + self.0.into_iter().map(move |prop| { + if let EvalResult::Property(prop) = prop { + T::from_str(&prop).map_err(|_| { + trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!( + "Failed to evaluate {rr} result reference: Invalid property." + )) + }) + } else { + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!( + "Failed to evaluate {rr} result reference: Invalid property." + ))) + } + }) + } +} diff --git a/crates/jmap-proto/src/references/mod.rs b/crates/jmap-proto/src/references/mod.rs new file mode 100644 index 0000000..f4fa820 --- /dev/null +++ b/crates/jmap-proto/src/references/mod.rs @@ -0,0 +1,944 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use compact_str::format_compact; +use std::collections::HashMap; +use utils::map::vec_map::VecMap; + +pub mod eval; +pub mod jsptr; +pub mod resolve; + +pub(crate) enum Graph<'x> { + Some { + child_id: &'x str, + graph: &'x mut HashMap>, + }, + None, +} + +fn topological_sort( + create: &mut VecMap, + graph: HashMap>, +) -> trc::Result> { + // Make sure all references exist + for (from_id, to_ids) in graph.iter() { + for to_id in to_ids { + if !create.contains_key(to_id) { + return Err(trc::JmapEvent::InvalidResultReference.into_err().details( + format_compact!( + "Invalid reference to non-existing object {to_id:?} from {from_id:?}" + ), + )); + } + } + } + + let mut sorted_create = VecMap::with_capacity(create.len()); + let mut it_stack = Vec::new(); + let keys = graph.keys().cloned().collect::>(); + let mut it = keys.iter(); + + 'main: loop { + while let Some(from_id) = it.next() { + if let Some(to_ids) = graph.get(from_id) { + it_stack.push((it, from_id)); + if it_stack.len() > 1000 { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Cyclical references are not allowed.")); + } + it = to_ids.iter(); + continue; + } else if let Some((id, value)) = create.remove_entry(from_id) { + sorted_create.append(id, value); + if create.is_empty() { + break 'main; + } + } + } + + if let Some((prev_it, from_id)) = it_stack.pop() { + it = prev_it; + if let Some((id, value)) = create.remove_entry(from_id) { + sorted_create.append(id, value); + if create.is_empty() { + break 'main; + } + } + } else { + break; + } + } + + // Add remaining items + if !create.is_empty() { + for (id, value) in std::mem::take(create) { + sorted_create.append(id, value); + } + } + Ok(sorted_create) +} + +#[cfg(test)] +mod tests { + use crate::references::Graph; + use crate::references::eval::EvalObjectReferences; + use crate::{ + method::{changes::ChangesResponse, get::GetResponse, query::QueryResponse}, + object::{ + email::{EmailProperty, EmailValue}, + mailbox::{MailboxProperty, MailboxValue}, + thread::{ThreadProperty, ThreadValue}, + }, + request::{ + Call, GetRequestMethod, Request, RequestMethod, SetRequestMethod, + reference::{MaybeIdReference, MaybeResultReference}, + }, + response::{ChangesResponseMethod, GetResponseMethod, Response, ResponseMethod}, + }; + use jmap_tools::{Key, Map, Value}; + use std::collections::HashMap; + use types::id::Id; + + #[test] + fn eval_value_references() { + let request = Request::parse( + br##"{ + "using":["urn:ietf:params:jmap:mail"], + "methodCalls": [[ "Email/query", { + "accountId": "a", + "filter": { "inMailbox": "a" }, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": true, + "position": 0, + "limit": 10, + "calculateTotal": true + }, "t0" ], + [ "Email/get", { + "accountId": "a", + "#ids": { + "resultOf": "t0", + "name": "Email/query", + "path": "/ids" + }, + "properties": [ "threadId" ] + }, "t1" ], + [ "Thread/get", { + "accountId": "a", + "#ids": { + "resultOf": "t1", + "name": "Email/get", + "path": "/list/*/threadId" + } + }, "t2" ], + [ "Email/get", { + "accountId": "a", + "#ids": { + "resultOf": "t2", + "name": "Thread/get", + "path": "/list/*/emailIds" + }, + "properties": [ "from", "receivedAt", "subject" ] + }, "t3" ]] + }"##, + 100, + 1024 * 1024, + ) + .unwrap(); + + let mut response = Response::new( + 1234, + request.created_ids.unwrap_or_default(), + request.method_calls.len(), + ); + + assert_eq!(request.method_calls.len(), 4); + + for (test_num, mut call) in request.method_calls.into_iter().enumerate() { + match test_num { + 0 => { + response.method_responses.push(Call { + id: call.id, + name: call.name, + method: ResponseMethod::Query(QueryResponse { + account_id: Id::new(1), + query_state: Default::default(), + can_calculate_changes: Default::default(), + position: Default::default(), + ids: vec![Id::new(4), Id::new(5)], + total: Default::default(), + limit: Default::default(), + }), + }); + } + 1 => { + response.resolve_references(&mut call.method).unwrap(); + match call.method { + RequestMethod::Get(GetRequestMethod::Email(req)) => { + assert_eq!( + req.ids, + Some(MaybeResultReference::Value(vec![ + MaybeIdReference::Id(Id::new(4)), + MaybeIdReference::Id(Id::new(5)) + ])) + ); + } + _ => panic!("Expected Email Get Request"), + } + response.method_responses.push(Call { + id: call.id, + name: call.name, + method: ResponseMethod::Get(GetResponseMethod::Email(GetResponse { + account_id: Id::new(1).into(), + state: Default::default(), + list: vec![ + Value::Object(Map::from(vec![( + Key::Property(EmailProperty::ThreadId), + Value::Element(EmailValue::Id(Id::new(9))), + )])), + Value::Object(Map::from(vec![( + Key::Property(EmailProperty::ThreadId), + Value::Element(EmailValue::Id(Id::new(10))), + )])), + ], + not_found: Default::default(), + })), + }); + } + 2 => { + response.resolve_references(&mut call.method).unwrap(); + match call.method { + RequestMethod::Get(GetRequestMethod::Thread(req)) => { + assert_eq!( + req.ids, + Some(MaybeResultReference::Value(vec![ + MaybeIdReference::Id(Id::new(9)), + MaybeIdReference::Id(Id::new(10)) + ])) + ); + } + _ => panic!("Expected Thread Get Request"), + } + response.method_responses.push(Call { + id: call.id, + name: call.name, + method: ResponseMethod::Get(GetResponseMethod::Thread(GetResponse { + account_id: Id::new(1).into(), + state: Default::default(), + list: vec![ + Value::Object(Map::from(vec![( + Key::Property(ThreadProperty::EmailIds), + Value::Array(vec![ + Value::Element(ThreadValue::Id(Id::new(100))), + Value::Element(ThreadValue::Id(Id::new(101))), + ]), + )])), + Value::Object(Map::from(vec![( + Key::Property(ThreadProperty::EmailIds), + Value::Array(vec![ + Value::Element(ThreadValue::Id(Id::new(102))), + Value::Element(ThreadValue::Id(Id::new(103))), + ]), + )])), + ], + not_found: Default::default(), + })), + }); + } + 3 => { + response.resolve_references(&mut call.method).unwrap(); + match call.method { + RequestMethod::Get(GetRequestMethod::Email(req)) => { + assert_eq!( + req.ids, + Some(MaybeResultReference::Value(vec![ + MaybeIdReference::Id(Id::new(100)), + MaybeIdReference::Id(Id::new(101)), + MaybeIdReference::Id(Id::new(102)), + MaybeIdReference::Id(Id::new(103)), + ])) + ); + } + _ => panic!("Expected Mailbox Get Request"), + } + } + _ => panic!("Unexpected invocation {}", test_num), + } + } + } + + #[test] + fn eval_property_references() { + let request = Request::parse( + br##"{ + "using":["urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Mailbox/changes",{ + "accountId":"s", + "sinceState":"srxqk071myhgkyay" + },"0"], + ["Mailbox/get",{ + "accountId":"s", + "#ids":{"name":"Mailbox/changes","path":"/created","resultOf":"0"} + },"1"], + ["Mailbox/get",{ + "accountId":"s", + "#ids":{"name":"Mailbox/changes","path":"/updated","resultOf":"0"}, + "#properties":{"name":"Mailbox/changes","path":"/updatedProperties","resultOf":"0"} + },"2"] + ] + }"##, + 100, + 1024 * 1024, + ) + .unwrap(); + + let mut response = Response::new( + 1234, + request.created_ids.unwrap_or_default(), + request.method_calls.len(), + ); + + assert_eq!(request.method_calls.len(), 3); + + for (test_num, mut call) in request.method_calls.into_iter().enumerate() { + match test_num { + 0 => { + response.method_responses.push(Call { + id: call.id, + name: call.name, + method: ResponseMethod::Changes(ChangesResponseMethod::Mailbox(Box::new( + ChangesResponse { + account_id: Id::new(1), + old_state: Default::default(), + new_state: Default::default(), + has_more_changes: Default::default(), + created: Default::default(), + updated: vec![Id::new(2), Id::new(3)], + destroyed: Default::default(), + updated_properties: Some(vec![ + MailboxProperty::Name.into(), + MailboxProperty::ParentId.into(), + ]), + }, + ))), + }); + } + 1 => { + response.resolve_references(&mut call.method).unwrap(); + match call.method { + RequestMethod::Get(GetRequestMethod::Mailbox(req)) => { + assert_eq!(req.ids, Some(MaybeResultReference::Value(vec![]))); + } + _ => panic!("Expected Mailbox Get Request"), + } + } + 2 => { + response.resolve_references(&mut call.method).unwrap(); + match call.method { + RequestMethod::Get(GetRequestMethod::Mailbox(req)) => { + assert_eq!( + req.ids, + Some(MaybeResultReference::Value(vec![ + MaybeIdReference::Id(Id::new(2)), + MaybeIdReference::Id(Id::new(3)) + ])) + ); + } + _ => panic!("Expected Mailbox Get Request"), + } + } + _ => panic!("Unexpected invocation {}", test_num), + } + } + } + + #[test] + fn eval_create_references() { + let request = Request::parse( + br##"{ + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail" + ], + "methodCalls": [ + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "a": { + "name": "Folder a", + "parentId": "#b" + }, + "b": { + "name": "Folder b", + "parentId": "#c" + }, + "c": { + "name": "Folder c", + "parentId": "#d" + }, + "d": { + "name": "Folder d", + "parentId": "#e" + }, + "e": { + "name": "Folder e", + "parentId": "#f" + }, + "f": { + "name": "Folder f", + "parentId": "#g" + }, + "g": { + "name": "Folder g", + "parentId": null + } + } + }, + "fulltree" + ], + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "a1": { + "name": "Folder a1", + "parentId": null + }, + "b2": { + "name": "Folder b2", + "parentId": "#a1" + }, + "c3": { + "name": "Folder c3", + "parentId": "#a1" + }, + "d4": { + "name": "Folder d4", + "parentId": "#b2" + }, + "e5": { + "name": "Folder e5", + "parentId": "#b2" + }, + "f6": { + "name": "Folder f6", + "parentId": "#d4" + }, + "g7": { + "name": "Folder g7", + "parentId": "#e5" + } + } + }, + "fulltree2" + ], + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "z": { + "name": "Folder Z", + "parentId": "#x" + }, + "y": { + "name": null + }, + "x": { + "name": "Folder X" + } + } + }, + "xyz" + ], + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "a": { + "name": "Folder a", + "parentId": "#b" + }, + "b": { + "name": "Folder b", + "parentId": "#c" + }, + "c": { + "name": "Folder c", + "parentId": "#d" + }, + "d": { + "name": "Folder d", + "parentId": "#a" + } + } + }, + "circular" + ] + ] + }"##, + 100, + 1024 * 1024, + ) + .unwrap(); + + let response = Response::new( + 1234, + request.created_ids.unwrap_or_default(), + request.method_calls.len(), + ); + + for (test_num, mut call) in request.method_calls.into_iter().enumerate() { + match response.resolve_references(&mut call.method) { + Ok(_) => assert!( + (0..3).contains(&test_num), + "Unexpected invocation {}", + test_num + ), + Err(err) => { + assert_eq!(test_num, 3); + assert!( + err.matches(trc::EventType::Jmap(trc::JmapEvent::InvalidArguments)), + "{:?}", + err + ); + continue; + } + } + + if let RequestMethod::Set(SetRequestMethod::Mailbox(request)) = call.method { + if test_num == 0 { + assert_eq!( + request + .create + .unwrap() + .into_iter() + .map(|b| b.0) + .collect::>(), + ["g", "f", "e", "d", "c", "b", "a"] + .iter() + .map(|i| i.to_string()) + .collect::>() + ); + } else if test_num == 1 { + let mut pending_ids = vec!["a1", "b2", "d4", "e5", "f6", "c3", "g7"]; + + for (id, _) in request.create.as_ref().unwrap() { + match id.as_str() { + "a1" => (), + "b2" | "c3" => assert!(!pending_ids.contains(&"a1")), + "d4" | "e5" => assert!(!pending_ids.contains(&"b2")), + "f6" => assert!(!pending_ids.contains(&"d4")), + "g7" => assert!(!pending_ids.contains(&"e5")), + _ => panic!("Unexpected ID"), + } + pending_ids.retain(|i| i != id); + } + + if !pending_ids.is_empty() { + panic!( + "Unexpected order: {:?}", + request + .create + .as_ref() + .unwrap() + .iter() + .map(|b| b.0.to_string()) + .collect::>() + ); + } + } else if test_num == 2 { + assert_eq!( + request + .create + .unwrap() + .into_iter() + .map(|b| b.0) + .collect::>(), + ["x", "z", "y"] + .iter() + .map(|i| i.to_string()) + .collect::>() + ); + } + } else { + panic!("Expected Set Mailbox Request"); + } + } + + let request = Request::parse( + br##"{ + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail" + ], + "methodCalls": [ + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "a": { + "name": "a", + "parentId": "#x" + }, + "b": { + "name": "b", + "parentId": "#y" + }, + "c": { + "name": "c", + "parentId": "#z" + } + } + }, + "ref1" + ], + [ + "Mailbox/set", + { + "accountId": "b", + "create": { + "a1": { + "name": "a1", + "parentId": "#a" + }, + "b2": { + "name": "b2", + "parentId": "#b" + }, + "c3": { + "name": "c3", + "parentId": "#c" + } + } + }, + "red2" + ] + ], + "createdIds": { + "x": "b", + "y": "c", + "z": "d" + } + }"##, + 1024, + 1024 * 1024, + ) + .unwrap(); + + let mut response = Response::new( + 1234, + request.created_ids.unwrap_or_default(), + request.method_calls.len(), + ); + + let mut invocations = request.method_calls.into_iter(); + let mut call = invocations.next().unwrap(); + response.resolve_references(&mut call.method).unwrap(); + + if let RequestMethod::Set(SetRequestMethod::Mailbox(request)) = call.method { + let create = request + .create + .as_ref() + .unwrap() + .iter() + .map(|(p, v)| { + ( + p.as_str(), + v.as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(), + ) + }) + .collect::>(); + assert_eq!( + *create.get("a").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(1))) + ); + assert_eq!( + *create.get("b").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(2))) + ); + assert_eq!( + *create.get("c").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(3))) + ); + } else { + panic!("Expected Mailbox Set Request"); + } + + response + .created_ids + .insert("a".to_string(), Id::new(5).into()); + response + .created_ids + .insert("b".to_string(), Id::new(6).into()); + response + .created_ids + .insert("c".to_string(), Id::new(7).into()); + + let mut call = invocations.next().unwrap(); + response.resolve_references(&mut call.method).unwrap(); + + if let RequestMethod::Set(SetRequestMethod::Mailbox(request)) = call.method { + let create = request + .create + .as_ref() + .unwrap() + .iter() + .map(|(p, v)| { + ( + p.as_str(), + v.as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(), + ) + }) + .collect::>(); + assert_eq!( + *create.get("a1").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(5))) + ); + assert_eq!( + *create.get("b2").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(6))) + ); + assert_eq!( + *create.get("c3").unwrap(), + &Value::Element(MailboxValue::Id(Id::new(7))) + ); + } else { + panic!("Expected Mailbox Set Request"); + } + } + + #[test] + fn eval_nested_element_ref() { + let mut created_ids = HashMap::new(); + created_ids.insert("server-1".to_string(), Id::new(42).into()); + let response = Response::new(0, created_ids, 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![ + ( + Key::Property(MailboxProperty::Name), + Value::Str("inbox".into()), + ), + ( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("server-1".into())), + )])), + ), + ])); + + value + .eval_object_references(&response, &mut Graph::None, 0, 5, true) + .unwrap(); + + let nested = value + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(); + assert_eq!(nested, &Value::Element(MailboxValue::Id(Id::new(42)))); + } + + #[test] + fn eval_array_element_ref() { + let mut created_ids = HashMap::new(); + created_ids.insert("a".to_string(), Id::new(1).into()); + created_ids.insert("b".to_string(), Id::new(2).into()); + let response = Response::new(0, created_ids, 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Array(vec![ + Value::Element(MailboxValue::IdReference("a".into())), + Value::Element(MailboxValue::IdReference("b".into())), + ]), + )])); + + value + .eval_object_references(&response, &mut Graph::None, 0, 5, true) + .unwrap(); + + let arr = value + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_array() + .unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0], Value::Element(MailboxValue::Id(Id::new(1)))); + assert_eq!(arr[1], Value::Element(MailboxValue::Id(Id::new(2)))); + } + + #[test] + fn eval_nested_array_of_objects_with_element_ref() { + let mut created_ids = HashMap::new(); + created_ids.insert("a".to_string(), Id::new(7).into()); + let response = Response::new(0, created_ids, 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Array(vec![Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("a".into())), + )]))]), + )])); + + value + .eval_object_references(&response, &mut Graph::None, 0, 5, true) + .unwrap(); + + let resolved = value + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_array() + .unwrap()[0] + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(); + assert_eq!(resolved, &Value::Element(MailboxValue::Id(Id::new(7)))); + } + + #[test] + fn eval_graph_collects_nested_ref() { + let response = Response::new(0, HashMap::new(), 0); + let mut graph_map: HashMap> = HashMap::new(); + let child_id = "outer".to_string(); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("inner".into())), + )])), + )])); + + { + let mut graph = Graph::Some { + child_id: &child_id, + graph: &mut graph_map, + }; + value + .eval_object_references(&response, &mut graph, 0, 5, true) + .unwrap(); + } + + assert_eq!(graph_map.get("outer"), Some(&vec!["inner".to_string()])); + } + + #[test] + fn eval_unresolved_nested_ref_errors_without_graph() { + let response = Response::new(0, HashMap::new(), 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("missing".into())), + )])), + )])); + + let err = value + .eval_object_references(&response, &mut Graph::None, 0, 5, true) + .unwrap_err(); + assert!( + err.matches(trc::EventType::Jmap(trc::JmapEvent::InvalidResultReference)), + "{:?}", + err + ); + } + + #[test] + fn eval_depth_limit_blocks_walk_into_inner_object() { + let mut created_ids = HashMap::new(); + created_ids.insert("inner".to_string(), Id::new(99).into()); + let response = Response::new(0, created_ids, 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("inner".into())), + )])), + )])), + )])); + + value + .eval_object_references(&response, &mut Graph::None, 0, 2, true) + .unwrap(); + + let deepest = value + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(); + assert_eq!( + deepest, + &Value::Element(MailboxValue::IdReference("inner".into())) + ); + } + + #[test] + fn eval_depth_limit_substitutes_element_at_max_depth() { + let mut created_ids = HashMap::new(); + created_ids.insert("inner".to_string(), Id::new(99).into()); + let response = Response::new(0, created_ids, 0); + + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::IdReference("inner".into())), + )])), + )])); + + value + .eval_object_references(&response, &mut Graph::None, 0, 2, true) + .unwrap(); + + let resolved = value + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap() + .as_object() + .unwrap() + .get(&Key::Property(MailboxProperty::ParentId)) + .unwrap(); + assert_eq!(resolved, &Value::Element(MailboxValue::Id(Id::new(99)))); + } +} diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs new file mode 100644 index 0000000..9080328 --- /dev/null +++ b/crates/jmap-proto/src/references/resolve.rs @@ -0,0 +1,433 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + error::set::SetError, + method::{ + copy::CopyRequest, + get::GetRequest, + import::ImportEmailRequest, + parse::ParseRequest, + search_snippet::GetSearchSnippetRequest, + set::{SetRequest, SetResponse}, + upload::{BlobUploadRequest, DataSourceObject}, + }, + object::{AnyId, JmapObject, JmapObjectId}, + references::{Graph, eval::EvalObjectReferences, topological_sort}, + request::{ + CopyRequestMethod, GetRequestMethod, MaybeInvalid, ParseRequestMethod, RequestMethod, + SetRequestMethod, + reference::{MaybeIdReference, MaybeResultReference}, + }, + response::Response, +}; +use compact_str::format_compact; +use jmap_tools::{Element, Key, Property, Value}; +use std::collections::HashMap; +use types::id::Id; + +impl Response<'_> { + pub fn resolve_references(&self, request: &mut RequestMethod) -> trc::Result<()> { + match request { + RequestMethod::Get(request) => match request { + GetRequestMethod::Email(request) => request.resolve_references(self)?, + GetRequestMethod::Mailbox(request) => request.resolve_references(self)?, + GetRequestMethod::Thread(request) => request.resolve_references(self)?, + GetRequestMethod::Identity(request) => request.resolve_references(self)?, + GetRequestMethod::EmailSubmission(request) => request.resolve_references(self)?, + GetRequestMethod::PushSubscription(request) => request.resolve_references(self)?, + GetRequestMethod::Sieve(request) => request.resolve_references(self)?, + GetRequestMethod::VacationResponse(request) => request.resolve_references(self)?, + GetRequestMethod::Principal(request) => request.resolve_references(self)?, + GetRequestMethod::Quota(request) => request.resolve_references(self)?, + GetRequestMethod::Blob(request) => request.resolve_references(self)?, + GetRequestMethod::AddressBook(request) => request.resolve_references(self)?, + GetRequestMethod::ContactCard(request) => request.resolve_references(self)?, + GetRequestMethod::FileNode(request) => request.resolve_references(self)?, + GetRequestMethod::ShareNotification(request) => request.resolve_references(self)?, + GetRequestMethod::Calendar(request) => request.resolve_references(self)?, + GetRequestMethod::CalendarEvent(request) => request.resolve_references(self)?, + GetRequestMethod::CalendarEventNotification(request) => { + request.resolve_references(self)? + } + GetRequestMethod::ParticipantIdentity(request) => { + request.resolve_references(self)? + } + GetRequestMethod::PrincipalAvailability(_) => (), + GetRequestMethod::Registry(request) => request.resolve_references(self)?, + }, + RequestMethod::Set(request) => match request { + SetRequestMethod::Email(request) => request.resolve_references(self, 2, false)?, + SetRequestMethod::Mailbox(request) => request.resolve_references(self, 1, false)?, + SetRequestMethod::Identity(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::EmailSubmission(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::PushSubscription(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::Sieve(request) => request.resolve_references(self, 1, false)?, + SetRequestMethod::VacationResponse(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::AddressBook(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::ContactCard(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::FileNode(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::ShareNotification(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::Calendar(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::CalendarEvent(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::CalendarEventNotification(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::ParticipantIdentity(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::Registry(request) => request.resolve_references(self, 5, true)?, + }, + RequestMethod::Copy(request) => match request { + CopyRequestMethod::Email(request) => request.resolve_references(self, 1, false)?, + CopyRequestMethod::CalendarEvent(request) => { + request.resolve_references(self, 1, false)? + } + CopyRequestMethod::ContactCard(request) => { + request.resolve_references(self, 1, false)? + } + CopyRequestMethod::FileNode(request) => { + request.resolve_references(self, 1, false)? + } + CopyRequestMethod::Blob(_) => (), + }, + RequestMethod::ImportEmail(request) => request.resolve_references(self)?, + RequestMethod::SearchSnippet(request) => request.resolve_references(self)?, + RequestMethod::UploadBlob(request) => request.resolve_references(self)?, + RequestMethod::Parse(request) => match request { + ParseRequestMethod::Email(request) => request.resolve_references(self)?, + ParseRequestMethod::ContactCard(request) => request.resolve_references(self)?, + ParseRequestMethod::CalendarEvent(request) => request.resolve_references(self)?, + }, + _ => {} + } + + Ok(()) + } +} + +pub trait ResolveCreatedReference +where + P: Property, + E: Element + JmapObjectId, +{ + fn get_created_id(&self, id_ref: &str) -> Option; + + fn resolve_self_references( + &self, + value: &mut Value<'_, P, E>, + depth: usize, + eval_strings: bool, + ) -> Result<(), SetError

> { + match value { + Value::Object(obj) if eval_strings && depth < 5 => { + for (key, value) in obj.as_mut_vec() { + if let Some(id) = key + .as_string_key() + .and_then(|k| k.strip_prefix('#')) + .and_then(|id_ref| self.get_created_id(id_ref)) + { + *key = Key::Owned(match id { + AnyId::Id(id) => id.to_string(), + AnyId::BlobId(id) => id.to_string(), + }); + } + + if matches!(value, Value::Object(_) | Value::Array(_)) { + self.resolve_self_references(value, depth + 1, eval_strings)?; + } + } + } + Value::Element(element) => { + if let Some(id_ref) = element.as_id_ref() { + if let Some(id) = self.get_created_id(id_ref) { + if !element.try_set_id(id) { + return Err(SetError::invalid_properties() + .with_description("Id reference points to invalid type.")); + } + } else { + return Err(SetError::not_found() + .with_description(format!("Id reference {id_ref:?} not found."))); + } + } + } + Value::Array(items) if depth < 5 => { + for item in items { + self.resolve_self_references(item, depth + 1, eval_strings)?; + } + } + _ => {} + } + + Ok(()) + } +} + +pub(crate) trait ResolveReference { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()>; +} + +pub(crate) trait ResolveSetReference { + fn resolve_references( + &mut self, + response: &Response<'_>, + max_depth: usize, + eval_strings: bool, + ) -> trc::Result<()>; +} + +impl ResolveReference for GetRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + // Resolve id references + match &mut self.ids { + Some(MaybeResultReference::Reference(reference)) => { + self.ids = Some(MaybeResultReference::Value( + response + .eval_result_references(reference)? + .into_ids::(reference) + .map(|f| f.map(MaybeIdReference::Id)) + .collect::>()?, + )); + } + Some(MaybeResultReference::Value(ids)) => { + for id in ids { + if let MaybeIdReference::Reference(reference) = id { + if let Some(resolved_id) = response + .created_ids + .get(reference) + .cloned() + .and_then(|v| T::Id::try_from(v).ok()) + { + *id = MaybeIdReference::Id(resolved_id); + } else { + return Err(trc::JmapEvent::InvalidResultReference.into_err().details( + format_compact!( + "Id reference {reference:?} does not exist or is invalid." + ), + )); + } + } + } + } + _ => (), + } + + // Resolve properties references + if let Some(MaybeResultReference::Reference(reference)) = &self.properties { + self.properties = Some(MaybeResultReference::Value( + response + .eval_result_references(reference)? + .into_properties::(reference) + .map(|f| f.map(MaybeInvalid::Value)) + .collect::>()?, + )); + } + + Ok(()) + } +} + +impl<'x, T: JmapObject> ResolveSetReference for SetRequest<'x, T> { + fn resolve_references( + &mut self, + response: &Response<'_>, + max_depth: usize, + eval_strings: bool, + ) -> trc::Result<()> { + // Resolve create references + if let Some(create) = &mut self.create { + let mut graph = HashMap::with_capacity(create.len()); + for (id, obj) in create.iter_mut() { + obj.eval_object_references( + response, + &mut Graph::Some { + child_id: &*id, + graph: &mut graph, + }, + 0, + max_depth, + eval_strings, + )?; + } + + // Perform topological sort + if !graph.is_empty() { + self.create = topological_sort(create, graph)?.into(); + } + } + + // Resolve update references + if let Some(update) = &mut self.update { + for obj in update.values_mut() { + obj.eval_object_references(response, &mut Graph::None, 0, max_depth, eval_strings)?; + } + } + + // Resolve destroy references + if let Some(MaybeResultReference::Reference(reference)) = &self.destroy { + self.destroy = Some(MaybeResultReference::Value( + response + .eval_result_references(reference)? + .into_ids::(reference) + .map(|f| f.map(MaybeInvalid::Value)) + .collect::>()?, + )); + } + + Ok(()) + } +} + +impl<'x, T: JmapObject> ResolveSetReference for CopyRequest<'x, T> { + fn resolve_references( + &mut self, + response: &Response<'_>, + max_depth: usize, + eval_strings: bool, + ) -> trc::Result<()> { + // Resolve create references + for (id, obj) in self.create.iter_mut() { + obj.eval_object_references(response, &mut Graph::None, 0, max_depth, eval_strings)?; + + if let MaybeIdReference::Reference(ir) = id { + *id = MaybeIdReference::Id(response.eval_id_reference(ir)?); + } + } + + Ok(()) + } +} + +impl ResolveReference for ParseRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + // Resolve blobId references + for id in self.blob_ids.iter_mut() { + if let MaybeIdReference::Reference(ir) = id { + *id = MaybeIdReference::Id(response.eval_blob_id_reference(ir)?); + } + } + + Ok(()) + } +} + +impl ResolveReference for ImportEmailRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + // Resolve email mailbox references + for email in self.emails.values_mut() { + match &mut email.mailbox_ids { + MaybeResultReference::Reference(reference) => { + email.mailbox_ids = MaybeResultReference::Value( + response + .eval_result_references(reference)? + .into_ids::(reference) + .map(|f| f.map(MaybeIdReference::Id)) + .collect::>()?, + ); + } + MaybeResultReference::Value(values) => { + for value in values { + if let MaybeIdReference::Reference(ir) = value { + *value = MaybeIdReference::Id(response.eval_id_reference(ir)?); + } + } + } + } + } + + Ok(()) + } +} + +impl ResolveReference for GetSearchSnippetRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + // Resolve emailIds references + if let MaybeResultReference::Reference(reference) = &self.email_ids { + self.email_ids = MaybeResultReference::Value( + response + .eval_result_references(reference)? + .into_ids::(reference) + .map(|f| f.map(MaybeInvalid::Value)) + .collect::>()?, + ); + } + + Ok(()) + } +} + +impl ResolveReference for BlobUploadRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + let mut graph = HashMap::with_capacity(self.create.len()); + for (create_id, object) in self.create.iter_mut() { + for data in &mut object.data { + if let DataSourceObject::Id { id, .. } = data + && let MaybeIdReference::Reference(parent_id) = id + { + match response.created_ids.get(parent_id) { + Some(AnyId::BlobId(blob_id)) => { + *id = MaybeIdReference::Id(blob_id.clone()); + } + Some(_) => { + return Err(trc::JmapEvent::InvalidResultReference.into_err().details( + format_compact!( + "Id reference {parent_id:?} points to invalid type." + ), + )); + } + None => { + graph + .entry(create_id.to_string()) + .or_insert_with(Vec::new) + .push(parent_id.to_string()); + } + } + } + } + } + + // Perform topological sort + if !graph.is_empty() { + self.create = topological_sort(&mut self.create, graph)?; + } + + Ok(()) + } +} + +impl ResolveCreatedReference for SetResponse +where + T: JmapObject, +{ + fn get_created_id(&self, id_ref: &str) -> Option { + self.created + .get(id_ref) + .and_then(|v| v.as_object()) + .and_then(|v| v.get(&Key::Property(T::ID_PROPERTY))) + .and_then(|v| v.as_element()) + .and_then(|v| v.as_any_id()) + } +} diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs new file mode 100644 index 0000000..147ae3e --- /dev/null +++ b/crates/jmap-proto/src/request/capability.rs @@ -0,0 +1,524 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt; + +use crate::{ + object::{email::EmailComparator, file_node::FileNodeComparator}, + response::serialize::serialize_hex, + types::date::UTCDate, +}; +use ahash::AHashMap; +use serde::{Deserialize, Deserializer}; +use types::{id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Session { + #[serde(rename(serialize = "capabilities"))] + pub capabilities: VecMap, + #[serde(rename(serialize = "accounts"))] + pub accounts: VecMap, + #[serde(rename(serialize = "primaryAccounts"))] + pub primary_accounts: VecMap, + #[serde(rename(serialize = "username"))] + pub username: String, + #[serde(rename(serialize = "apiUrl"))] + pub api_url: String, + #[serde(rename(serialize = "downloadUrl"))] + pub download_url: String, + #[serde(rename(serialize = "uploadUrl"))] + pub upload_url: String, + #[serde(rename(serialize = "eventSourceUrl"))] + pub event_source_url: String, + #[serde(rename(serialize = "state"))] + #[serde(serialize_with = "serialize_hex")] + pub state: u32, + #[serde(skip)] + pub base_url: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Account { + #[serde(rename(serialize = "name"))] + pub name: String, + #[serde(rename(serialize = "isPersonal"))] + pub is_personal: bool, + #[serde(rename(serialize = "isReadOnly"))] + pub is_read_only: bool, + #[serde(rename(serialize = "accountCapabilities"))] + pub account_capabilities: VecMap, +} + +#[derive(Debug, Clone, Copy, serde::Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum Capability { + #[serde(rename(serialize = "urn:ietf:params:jmap:core"))] + Core = 1 << 0, + #[serde(rename(serialize = "urn:ietf:params:jmap:mail"))] + Mail = 1 << 1, + #[serde(rename(serialize = "urn:ietf:params:jmap:submission"))] + Submission = 1 << 2, + #[serde(rename(serialize = "urn:ietf:params:jmap:vacationresponse"))] + VacationResponse = 1 << 3, + #[serde(rename(serialize = "urn:ietf:params:jmap:contacts"))] + Contacts = 1 << 4, + #[serde(rename(serialize = "urn:ietf:params:jmap:contacts:parse"))] + ContactsParse = 1 << 5, + #[serde(rename(serialize = "urn:ietf:params:jmap:calendars"))] + Calendars = 1 << 6, + #[serde(rename(serialize = "urn:ietf:params:jmap:calendars:parse"))] + CalendarsParse = 1 << 7, + #[serde(rename(serialize = "urn:ietf:params:jmap:websocket"))] + WebSocket = 1 << 8, + #[serde(rename(serialize = "urn:ietf:params:jmap:sieve"))] + Sieve = 1 << 9, + #[serde(rename(serialize = "urn:ietf:params:jmap:blob"))] + Blob = 1 << 10, + #[serde(rename(serialize = "urn:ietf:params:jmap:quota"))] + Quota = 1 << 11, + #[serde(rename(serialize = "urn:ietf:params:jmap:principals"))] + Principals = 1 << 12, + #[serde(rename(serialize = "urn:ietf:params:jmap:principals:owner"))] + PrincipalsOwner = 1 << 13, + #[serde(rename(serialize = "urn:ietf:params:jmap:principals:availability"))] + PrincipalsAvailability = 1 << 14, + #[serde(rename(serialize = "urn:ietf:params:jmap:filenode"))] + FileNode = 1 << 15, + #[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))] + MailShare = 1 << 16, + #[serde(rename(serialize = "urn:stalwart:jmap"))] + Stalwart = 1 << 17, + #[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))] + WebPushVapid = 1 << 18, + #[serde(rename(serialize = "urn:ietf:params:jmap:emailpush"))] + EmailPush = 1 << 19, +} + +#[derive(Debug, Clone, Copy, Default)] +#[repr(transparent)] +pub struct CapabilityIds(pub u32); + +impl CapabilityIds { + pub fn contains(&self, capability: Capability) -> bool { + self.0 & capability as u32 != 0 + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +#[allow(dead_code)] +pub enum Capabilities { + Core(CoreCapabilities), + Mail(MailCapabilities), + Submission(SubmissionCapabilities), + WebSocket(WebSocketCapabilities), + SieveAccount(SieveAccountCapabilities), + SieveSession(SieveSessionCapabilities), + Blob(BlobCapabilities), + Contacts(ContactsCapabilities), + Principals(PrincipalCapabilities), + PrincipalsAvailability(PrincipalAvailabilityCapabilities), + Calendar(CalendarCapabilities), + FileNode(FileNodeCapabilities), + WebPush(WebPushCapabilities), + Empty(EmptyCapabilities), +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CoreCapabilities { + #[serde(rename(serialize = "maxSizeUpload"))] + pub max_size_upload: u64, + #[serde(rename(serialize = "maxConcurrentUpload"))] + pub max_concurrent_upload: u64, + #[serde(rename(serialize = "maxSizeRequest"))] + pub max_size_request: u64, + #[serde(rename(serialize = "maxConcurrentRequests"))] + pub max_concurrent_requests: u64, + #[serde(rename(serialize = "maxCallsInRequest"))] + pub max_calls_in_request: u64, + #[serde(rename(serialize = "maxObjectsInGet"))] + pub max_objects_in_get: u64, + #[serde(rename(serialize = "maxObjectsInSet"))] + pub max_objects_in_set: u64, + #[serde(rename(serialize = "collationAlgorithms"))] + pub collation_algorithms: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct WebSocketCapabilities { + #[serde(rename(serialize = "url"))] + pub url: String, + #[serde(rename(serialize = "supportsPush"))] + pub supports_push: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SieveSessionCapabilities { + #[serde(rename(serialize = "implementation"))] + pub implementation: &'static str, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SieveAccountCapabilities { + #[serde(rename(serialize = "maxSizeScriptName"))] + pub max_script_name: u64, + #[serde(rename(serialize = "maxSizeScript"))] + pub max_script_size: u64, + #[serde(rename(serialize = "maxNumberScripts"))] + pub max_scripts: u64, + #[serde(rename(serialize = "maxNumberRedirects"))] + pub max_redirects: u64, + #[serde(rename(serialize = "sieveExtensions"))] + pub extensions: Vec, + #[serde(rename(serialize = "notificationMethods"))] + pub notification_methods: Option>, + #[serde(rename(serialize = "externalLists"))] + pub ext_lists: Option>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MailCapabilities { + #[serde(rename(serialize = "maxMailboxesPerEmail"))] + pub max_mailboxes_per_email: Option, + #[serde(rename(serialize = "maxMailboxDepth"))] + pub max_mailbox_depth: u64, + #[serde(rename(serialize = "maxSizeMailboxName"))] + pub max_size_mailbox_name: u64, + #[serde(rename(serialize = "maxSizeAttachmentsPerEmail"))] + pub max_size_attachments_per_email: u64, + #[serde(rename(serialize = "emailQuerySortOptions"))] + pub email_query_sort_options: Vec, + #[serde(rename(serialize = "mayCreateTopLevelMailbox"))] + pub may_create_top_level_mailbox: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SubmissionCapabilities { + #[serde(rename(serialize = "maxDelayedSend"))] + pub max_delayed_send: u64, + #[serde(rename(serialize = "submissionExtensions"))] + pub submission_extensions: VecMap>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct BlobCapabilities { + #[serde(rename(serialize = "maxSizeBlobSet"))] + pub max_size_blob_set: u64, + #[serde(rename(serialize = "maxDataSources"))] + pub max_data_sources: u64, + #[serde(rename(serialize = "supportedTypeNames"))] + pub supported_type_names: Vec, + #[serde(rename(serialize = "supportedDigestAlgorithms"))] + pub supported_digest_algorithms: Vec<&'static str>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CalendarCapabilities { + #[serde(rename(serialize = "maxCalendarsPerEvent"))] + pub max_calendars_per_event: Option, + #[serde(rename(serialize = "minDateTime"))] + pub min_date_time: UTCDate, + #[serde(rename(serialize = "maxDateTime"))] + pub max_date_time: UTCDate, + #[serde(rename(serialize = "maxExpandedQueryDuration"))] + pub max_expanded_query_duration: String, + #[serde(rename(serialize = "maxParticipantsPerEvent"))] + pub max_participants_per_event: Option, + #[serde(rename(serialize = "mayCreateCalendar"))] + pub may_create_calendar: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ContactsCapabilities { + #[serde(rename(serialize = "maxAddressBooksPerCard"))] + pub max_address_books_per_card: Option, + #[serde(rename(serialize = "mayCreateAddressBook"))] + pub may_create_address_book: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PrincipalAvailabilityCapabilities { + #[serde(rename(serialize = "maxAvailabilityDuration"))] + pub max_availability_duration: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PrincipalCapabilities { + #[serde(rename(serialize = "currentUserPrincipalId"))] + pub current_user_principal_id: Option, +} + +/*#[derive(Debug, Clone, serde::Serialize)] +pub struct PrincipalOwnerCapabilities { + #[serde(rename(serialize = "accountIdForPrincipal"))] + pub account_id_for_principal: Id, + + #[serde(rename(serialize = "principalId"))] + pub principal_id: Id, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PrincipalCalendarCapabilities { + #[serde(rename(serialize = "accountIdForPrincipal"))] + pub account_id_for_principal: Option, + #[serde(rename(serialize = "mayGetAvailability"))] + pub may_get_availability: bool, + #[serde(rename(serialize = "mayShareWith"))] + pub may_share_with: bool, + #[serde(rename(serialize = "calendarAddress"))] + pub calendar_address: String, +}*/ + +#[derive(Debug, Clone, serde::Serialize)] +pub struct FileNodeCapabilities { + #[serde(rename(serialize = "maxFileNodeDepth"))] + pub max_file_node_depth: Option, + #[serde(rename(serialize = "maxSizeFileNodeName"))] + pub max_size_file_node_name: u64, + #[serde(rename(serialize = "forbiddenNameChars"))] + pub forbidden_name_chars: Option, + #[serde(rename(serialize = "forbiddenNodeNames"))] + pub forbidden_node_names: Option>, + #[serde(rename(serialize = "fileNodeQuerySortOptions"))] + pub file_node_query_sort_options: Vec, + #[serde(rename(serialize = "mayCreateTopLevelFileNode"))] + pub may_create_top_level_file_node: bool, + #[serde(rename(serialize = "caseInsensitiveNames"))] + pub case_insensitive_names: bool, + #[serde(rename(serialize = "webTrashUrl"))] + pub web_trash_url: Option, + #[serde(rename(serialize = "webUrlTemplate"))] + pub web_url_template: Option, + #[serde(rename(serialize = "webWriteUrlTemplate"))] + pub web_write_url_template: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct WebPushCapabilities { + #[serde(rename(serialize = "applicationServerKey"))] + pub application_server_key: String, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct EmptyCapabilities {} + +#[derive(Default, Clone)] +pub struct BaseCapabilities { + pub session: VecMap, + pub account: AHashMap, +} + +impl Capability { + pub fn as_str(&self) -> &'static str { + match self { + Capability::Core => "urn:ietf:params:jmap:core", + Capability::Mail => "urn:ietf:params:jmap:mail", + Capability::Submission => "urn:ietf:params:jmap:submission", + Capability::VacationResponse => "urn:ietf:params:jmap:vacationresponse", + Capability::Contacts => "urn:ietf:params:jmap:contacts", + Capability::ContactsParse => "urn:ietf:params:jmap:contacts:parse", + Capability::Calendars => "urn:ietf:params:jmap:calendars", + Capability::CalendarsParse => "urn:ietf:params:jmap:calendars:parse", + Capability::WebSocket => "urn:ietf:params:jmap:websocket", + Capability::Sieve => "urn:ietf:params:jmap:sieve", + Capability::Blob => "urn:ietf:params:jmap:blob", + Capability::Quota => "urn:ietf:params:jmap:quota", + Capability::Principals => "urn:ietf:params:jmap:principals", + Capability::PrincipalsOwner => "urn:ietf:params:jmap:principals:owner", + Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability", + Capability::FileNode => "urn:ietf:params:jmap:filenode", + Capability::MailShare => "urn:ietf:params:jmap:mail:share", + Capability::Stalwart => "urn:stalwart:jmap", + Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid", + Capability::EmailPush => "urn:ietf:params:jmap:emailpush", + } + } + + pub fn all_capabilities() -> &'static [Capability] { + &[ + Capability::Core, + Capability::Mail, + Capability::Submission, + Capability::VacationResponse, + Capability::Contacts, + Capability::ContactsParse, + Capability::Calendars, + Capability::CalendarsParse, + Capability::WebSocket, + Capability::Sieve, + Capability::Blob, + Capability::Quota, + Capability::Principals, + Capability::PrincipalsAvailability, + Capability::FileNode, + Capability::MailShare, + Capability::Stalwart, + Capability::WebPushVapid, + Capability::EmailPush, + ] + } +} + +impl Session { + pub fn new(base_url: impl Into, base_capabilities: &BaseCapabilities) -> Session { + let base_url = base_url.into(); + let mut capabilities = base_capabilities.session.clone(); + capabilities.append( + Capability::WebSocket, + Capabilities::WebSocket(WebSocketCapabilities::new(&base_url)), + ); + + Session { + capabilities, + accounts: VecMap::new(), + primary_accounts: VecMap::new(), + username: "".to_string(), + api_url: format!("{}/jmap/", base_url), + download_url: format!( + "{}/jmap/download/{{accountId}}/{{blobId}}/{{name}}?accept={{type}}", + base_url + ), + upload_url: format!("{}/jmap/upload/{{accountId}}/", base_url), + event_source_url: format!( + "{}/jmap/eventsource/?types={{types}}&closeafter={{closeafter}}&ping={{ping}}", + base_url + ), + base_url, + state: 0, + } + } + + pub fn set_state(&mut self, state: u32) { + self.state = state; + } + + pub fn api_url(&self) -> &str { + &self.api_url + } + + pub fn base_url(&self) -> &str { + &self.base_url + } +} + +impl Default for SieveSessionCapabilities { + fn default() -> Self { + Self { + implementation: "Stalwart v1.0.0", + } + } +} + +impl WebSocketCapabilities { + pub fn new(base_url: &str) -> Self { + WebSocketCapabilities { + url: format!( + "ws{}/jmap/ws", + base_url.strip_prefix("http").unwrap_or_default() + ), + supports_push: true, + } + } +} + +impl Capabilities { + pub fn to_account_capabilities( + &self, + current_user_principal_id: Option, + may_create: bool, + ) -> Capabilities { + match self { + Capabilities::Contacts(contacts_capabilities) => { + Capabilities::Contacts(ContactsCapabilities { + may_create_address_book: may_create, + ..contacts_capabilities.clone() + }) + } + Capabilities::Principals(_) => Capabilities::Principals(PrincipalCapabilities { + current_user_principal_id, + }), + Capabilities::Calendar(calendar_capabilities) => { + Capabilities::Calendar(CalendarCapabilities { + may_create_calendar: may_create, + ..calendar_capabilities.clone() + }) + } + Capabilities::FileNode(file_node_capabilities) => { + Capabilities::FileNode(FileNodeCapabilities { + may_create_top_level_file_node: may_create, + ..file_node_capabilities.clone() + }) + } + _ => self.clone(), + } + } +} + +impl Capability { + pub fn parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "urn:ietf:params:jmap:core" => Capability::Core, + "urn:ietf:params:jmap:mail" => Capability::Mail, + "urn:ietf:params:jmap:submission" => Capability::Submission, + "urn:ietf:params:jmap:vacationresponse" => Capability::VacationResponse, + "urn:ietf:params:jmap:contacts" => Capability::Contacts, + "urn:ietf:params:jmap:calendars" => Capability::Calendars, + "urn:ietf:params:jmap:websocket" => Capability::WebSocket, + "urn:ietf:params:jmap:sieve" => Capability::Sieve, + "urn:ietf:params:jmap:blob" => Capability::Blob, + "urn:ietf:params:jmap:quota" => Capability::Quota, + "urn:ietf:params:jmap:principals" => Capability::Principals, + "urn:ietf:params:jmap:principals:owner" => Capability::PrincipalsOwner, + "urn:ietf:params:jmap:filenode" => Capability::FileNode, + "urn:ietf:params:jmap:principals:availability" => Capability::PrincipalsAvailability, + "urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse, + "urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse, + "urn:ietf:params:jmap:mail:share" => Capability::MailShare, + "urn:stalwart:jmap" => Capability::Stalwart, + "urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid, + "urn:ietf:params:jmap:emailpush" => Capability::EmailPush, + ) + } +} + +impl<'de> Deserialize<'de> for CapabilityIds { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct CapabilityIdsVisitor; + + impl<'de> serde::de::Visitor<'de> for CapabilityIdsVisitor { + type Value = CapabilityIds; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an array of capability strings") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut capability_flags = 0u32; + + while let Some(capability_str) = seq.next_element::>()? { + let capability = + Capability::parse(capability_str.as_ref()).ok_or_else(|| { + serde::de::Error::custom(format!( + "Unknown capability: {capability_str:?}" + )) + })?; + + capability_flags |= capability as u32; + } + + Ok(CapabilityIds(capability_flags)) + } + } + + deserializer.deserialize_seq(CapabilityIdsVisitor) + } +} diff --git a/crates/jmap-proto/src/request/deserialize.rs b/crates/jmap-proto/src/request/deserialize.rs new file mode 100644 index 0000000..337786a --- /dev/null +++ b/crates/jmap-proto/src/request/deserialize.rs @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::{ + Deserializer, + de::{self, MapAccess, Visitor}, +}; +use std::{fmt, marker::PhantomData}; + +pub trait DeserializeArguments<'de> { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: MapAccess<'de>; +} + +impl<'de> DeserializeArguments<'de> for () { + fn deserialize_argument(&mut self, _key: &str, map: &mut A) -> Result<(), A::Error> + where + A: MapAccess<'de>, + { + let _: de::IgnoredAny = map.next_value()?; + Ok(()) + } +} + +pub(crate) fn deserialize_request<'de, T, D>(deserializer: D) -> Result +where + T: DeserializeArguments<'de> + Default, + D: Deserializer<'de>, +{ + struct DirectArgumentsVisitor { + _phantom: PhantomData, + } + + impl DirectArgumentsVisitor { + fn new() -> Self { + Self { + _phantom: PhantomData, + } + } + } + + impl<'de, T> Visitor<'de> for DirectArgumentsVisitor + where + T: DeserializeArguments<'de> + Default, + { + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a JMAP request object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut target = T::default(); + + while let Some(key) = map.next_key::<&str>()? { + target + .deserialize_argument(key, &mut map) + .map_err(de::Error::custom)?; + } + + Ok(target) + } + } + + deserializer.deserialize_map(DirectArgumentsVisitor::::new()) +} diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs new file mode 100644 index 0000000..3e45c8c --- /dev/null +++ b/crates/jmap-proto/src/request/method.rs @@ -0,0 +1,455 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::request::capability::Capability; +use registry::{ + schema::prelude::{OBJ_SINGLETON, ObjectType}, + types::EnumImpl, +}; +use std::{borrow::Cow, fmt::Display}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MethodName { + pub obj: MethodObject, + pub fnc: MethodFunction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodObject { + Email, + Mailbox, + Core, + Blob, + PushSubscription, + Thread, + SearchSnippet, + Identity, + EmailSubmission, + VacationResponse, + SieveScript, + Principal, + Quota, + Calendar, + CalendarEvent, + CalendarEventNotification, + AddressBook, + ContactCard, + FileNode, + ParticipantIdentity, + ShareNotification, + Registry(ObjectType), +} + +impl MethodObject { + pub fn capability(&self) -> Capability { + match self { + MethodObject::Email + | MethodObject::Mailbox + | MethodObject::Thread + | MethodObject::SearchSnippet => Capability::Mail, + MethodObject::Core | MethodObject::PushSubscription => Capability::Core, + MethodObject::Blob => Capability::Blob, + MethodObject::Identity | MethodObject::EmailSubmission => Capability::Submission, + MethodObject::VacationResponse => Capability::VacationResponse, + MethodObject::SieveScript => Capability::Sieve, + MethodObject::Principal | MethodObject::ShareNotification => Capability::Principals, + MethodObject::Quota => Capability::Quota, + MethodObject::Calendar + | MethodObject::CalendarEvent + | MethodObject::CalendarEventNotification + | MethodObject::ParticipantIdentity => Capability::Calendars, + MethodObject::AddressBook | MethodObject::ContactCard => Capability::Contacts, + MethodObject::FileNode => Capability::FileNode, + MethodObject::Registry(_) => Capability::Stalwart, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodFunction { + Get, + Set, + Changes, + Query, + QueryChanges, + Copy, + Import, + Parse, + Validate, + Lookup, + Upload, + Echo, + GetAvailability, +} + +impl Display for MethodName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str().as_ref()) + } +} + +impl MethodName { + pub fn new(obj: MethodObject, fnc: MethodFunction) -> Self { + Self { obj, fnc } + } + + pub fn error() -> Self { + Self { + obj: MethodObject::Thread, + fnc: MethodFunction::Echo, + } + } + + pub fn as_str(&self) -> Cow<'static, str> { + match (self.fnc, self.obj) { + (MethodFunction::Get, MethodObject::PushSubscription) => "PushSubscription/get", + (MethodFunction::Set, MethodObject::PushSubscription) => "PushSubscription/set", + + (MethodFunction::Get, MethodObject::Mailbox) => "Mailbox/get", + (MethodFunction::Changes, MethodObject::Mailbox) => "Mailbox/changes", + (MethodFunction::Query, MethodObject::Mailbox) => "Mailbox/query", + (MethodFunction::QueryChanges, MethodObject::Mailbox) => "Mailbox/queryChanges", + (MethodFunction::Set, MethodObject::Mailbox) => "Mailbox/set", + + (MethodFunction::Get, MethodObject::Thread) => "Thread/get", + (MethodFunction::Changes, MethodObject::Thread) => "Thread/changes", + + (MethodFunction::Get, MethodObject::Email) => "Email/get", + (MethodFunction::Changes, MethodObject::Email) => "Email/changes", + (MethodFunction::Query, MethodObject::Email) => "Email/query", + (MethodFunction::QueryChanges, MethodObject::Email) => "Email/queryChanges", + (MethodFunction::Set, MethodObject::Email) => "Email/set", + (MethodFunction::Copy, MethodObject::Email) => "Email/copy", + (MethodFunction::Import, MethodObject::Email) => "Email/import", + (MethodFunction::Parse, MethodObject::Email) => "Email/parse", + + (MethodFunction::Get, MethodObject::SearchSnippet) => "SearchSnippet/get", + + (MethodFunction::Get, MethodObject::Identity) => "Identity/get", + (MethodFunction::Changes, MethodObject::Identity) => "Identity/changes", + (MethodFunction::Set, MethodObject::Identity) => "Identity/set", + + (MethodFunction::Get, MethodObject::EmailSubmission) => "EmailSubmission/get", + (MethodFunction::Changes, MethodObject::EmailSubmission) => "EmailSubmission/changes", + (MethodFunction::Query, MethodObject::EmailSubmission) => "EmailSubmission/query", + (MethodFunction::QueryChanges, MethodObject::EmailSubmission) => { + "EmailSubmission/queryChanges" + } + (MethodFunction::Set, MethodObject::EmailSubmission) => "EmailSubmission/set", + + (MethodFunction::Get, MethodObject::VacationResponse) => "VacationResponse/get", + (MethodFunction::Set, MethodObject::VacationResponse) => "VacationResponse/set", + + (MethodFunction::Get, MethodObject::SieveScript) => "SieveScript/get", + (MethodFunction::Set, MethodObject::SieveScript) => "SieveScript/set", + (MethodFunction::Query, MethodObject::SieveScript) => "SieveScript/query", + (MethodFunction::Validate, MethodObject::SieveScript) => "SieveScript/validate", + + (MethodFunction::Get, MethodObject::Principal) => "Principal/get", + (MethodFunction::Set, MethodObject::Principal) => "Principal/set", + (MethodFunction::Query, MethodObject::Principal) => "Principal/query", + (MethodFunction::Changes, MethodObject::Principal) => "Principal/changes", + (MethodFunction::QueryChanges, MethodObject::Principal) => "Principal/queryChanges", + (MethodFunction::GetAvailability, MethodObject::Principal) => { + "Principal/getAvailability" + } + + (MethodFunction::Get, MethodObject::Quota) => "Quota/get", + (MethodFunction::Changes, MethodObject::Quota) => "Quota/changes", + (MethodFunction::Query, MethodObject::Quota) => "Quota/query", + (MethodFunction::QueryChanges, MethodObject::Quota) => "Quota/queryChanges", + + (MethodFunction::Get, MethodObject::Blob) => "Blob/get", + (MethodFunction::Copy, MethodObject::Blob) => "Blob/copy", + (MethodFunction::Lookup, MethodObject::Blob) => "Blob/lookup", + (MethodFunction::Upload, MethodObject::Blob) => "Blob/upload", + + (MethodFunction::Get, MethodObject::AddressBook) => "AddressBook/get", + (MethodFunction::Changes, MethodObject::AddressBook) => "AddressBook/changes", + (MethodFunction::Set, MethodObject::AddressBook) => "AddressBook/set", + (MethodFunction::Query, MethodObject::AddressBook) => "AddressBook/query", + + (MethodFunction::Get, MethodObject::ContactCard) => "ContactCard/get", + (MethodFunction::Changes, MethodObject::ContactCard) => "ContactCard/changes", + (MethodFunction::Query, MethodObject::ContactCard) => "ContactCard/query", + (MethodFunction::QueryChanges, MethodObject::ContactCard) => "ContactCard/queryChanges", + (MethodFunction::Set, MethodObject::ContactCard) => "ContactCard/set", + (MethodFunction::Copy, MethodObject::ContactCard) => "ContactCard/copy", + (MethodFunction::Parse, MethodObject::ContactCard) => "ContactCard/parse", + + (MethodFunction::Get, MethodObject::FileNode) => "FileNode/get", + (MethodFunction::Changes, MethodObject::FileNode) => "FileNode/changes", + (MethodFunction::Query, MethodObject::FileNode) => "FileNode/query", + (MethodFunction::QueryChanges, MethodObject::FileNode) => "FileNode/queryChanges", + (MethodFunction::Set, MethodObject::FileNode) => "FileNode/set", + (MethodFunction::Copy, MethodObject::FileNode) => "FileNode/copy", + + (MethodFunction::Get, MethodObject::ShareNotification) => "ShareNotification/get", + (MethodFunction::Changes, MethodObject::ShareNotification) => { + "ShareNotification/changes" + } + (MethodFunction::Query, MethodObject::ShareNotification) => "ShareNotification/query", + (MethodFunction::QueryChanges, MethodObject::ShareNotification) => { + "ShareNotification/queryChanges" + } + (MethodFunction::Set, MethodObject::ShareNotification) => "ShareNotification/set", + + (MethodFunction::Get, MethodObject::Calendar) => "Calendar/get", + (MethodFunction::Changes, MethodObject::Calendar) => "Calendar/changes", + (MethodFunction::Set, MethodObject::Calendar) => "Calendar/set", + (MethodFunction::Query, MethodObject::Calendar) => "Calendar/query", + + (MethodFunction::Get, MethodObject::CalendarEvent) => "CalendarEvent/get", + (MethodFunction::Changes, MethodObject::CalendarEvent) => "CalendarEvent/changes", + (MethodFunction::Query, MethodObject::CalendarEvent) => "CalendarEvent/query", + (MethodFunction::QueryChanges, MethodObject::CalendarEvent) => { + "CalendarEvent/queryChanges" + } + (MethodFunction::Set, MethodObject::CalendarEvent) => "CalendarEvent/set", + (MethodFunction::Copy, MethodObject::CalendarEvent) => "CalendarEvent/copy", + (MethodFunction::Parse, MethodObject::CalendarEvent) => "CalendarEvent/parse", + + (MethodFunction::Get, MethodObject::CalendarEventNotification) => { + "CalendarEventNotification/get" + } + (MethodFunction::Changes, MethodObject::CalendarEventNotification) => { + "CalendarEventNotification/changes" + } + (MethodFunction::Query, MethodObject::CalendarEventNotification) => { + "CalendarEventNotification/query" + } + (MethodFunction::QueryChanges, MethodObject::CalendarEventNotification) => { + "CalendarEventNotification/queryChanges" + } + (MethodFunction::Set, MethodObject::CalendarEventNotification) => { + "CalendarEventNotification/set" + } + + (MethodFunction::Get, MethodObject::ParticipantIdentity) => "ParticipantIdentity/get", + (MethodFunction::Changes, MethodObject::ParticipantIdentity) => { + "ParticipantIdentity/changes" + } + (MethodFunction::Set, MethodObject::ParticipantIdentity) => "ParticipantIdentity/set", + + (MethodFunction::Echo, MethodObject::Core) => "Core/echo", + (method, MethodObject::Registry(obj)) => { + return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); + } + _ => "error", + } + .into() + } + + pub fn parse(s: &str) -> Option { + hashify::tiny_map!(s.as_bytes(), + "PushSubscription/get" => (MethodObject::PushSubscription, MethodFunction::Get), + "PushSubscription/set" => (MethodObject::PushSubscription, MethodFunction::Set), + + "Mailbox/get" => (MethodObject::Mailbox, MethodFunction::Get), + "Mailbox/changes" => (MethodObject::Mailbox, MethodFunction::Changes), + "Mailbox/query" => (MethodObject::Mailbox, MethodFunction::Query), + "Mailbox/queryChanges" => (MethodObject::Mailbox, MethodFunction::QueryChanges), + "Mailbox/set" => (MethodObject::Mailbox, MethodFunction::Set), + + "Thread/get" => (MethodObject::Thread, MethodFunction::Get), + "Thread/changes" => (MethodObject::Thread, MethodFunction::Changes), + + "Email/get" => (MethodObject::Email, MethodFunction::Get), + "Email/changes" => (MethodObject::Email, MethodFunction::Changes), + "Email/query" => (MethodObject::Email, MethodFunction::Query), + "Email/queryChanges" => (MethodObject::Email, MethodFunction::QueryChanges), + "Email/set" => (MethodObject::Email, MethodFunction::Set), + "Email/copy" => (MethodObject::Email, MethodFunction::Copy), + "Email/import" => (MethodObject::Email, MethodFunction::Import), + "Email/parse" => (MethodObject::Email, MethodFunction::Parse), + + "SearchSnippet/get" => (MethodObject::SearchSnippet, MethodFunction::Get), + + "Identity/get" => (MethodObject::Identity, MethodFunction::Get), + "Identity/changes" => (MethodObject::Identity, MethodFunction::Changes), + "Identity/set" => (MethodObject::Identity, MethodFunction::Set), + + "EmailSubmission/get" => (MethodObject::EmailSubmission, MethodFunction::Get), + "EmailSubmission/changes" => (MethodObject::EmailSubmission, MethodFunction::Changes), + "EmailSubmission/query" => (MethodObject::EmailSubmission, MethodFunction::Query), + "EmailSubmission/queryChanges" => (MethodObject::EmailSubmission, MethodFunction::QueryChanges), + "EmailSubmission/set" => (MethodObject::EmailSubmission, MethodFunction::Set), + + "VacationResponse/get" => (MethodObject::VacationResponse, MethodFunction::Get), + "VacationResponse/set" => (MethodObject::VacationResponse, MethodFunction::Set), + + "SieveScript/get" => (MethodObject::SieveScript, MethodFunction::Get), + "SieveScript/set" => (MethodObject::SieveScript, MethodFunction::Set), + "SieveScript/query" => (MethodObject::SieveScript, MethodFunction::Query), + "SieveScript/validate" => (MethodObject::SieveScript, MethodFunction::Validate), + + "Principal/get" => (MethodObject::Principal, MethodFunction::Get), + "Principal/set" => (MethodObject::Principal, MethodFunction::Set), + "Principal/query" => (MethodObject::Principal, MethodFunction::Query), + "Principal/changes" => (MethodObject::Principal, MethodFunction::Changes), + "Principal/queryChanges" => (MethodObject::Principal, MethodFunction::QueryChanges), + "Principal/getAvailability" => (MethodObject::Principal, MethodFunction::GetAvailability), + + "Quota/get" => (MethodObject::Quota, MethodFunction::Get), + "Quota/changes" => (MethodObject::Quota, MethodFunction::Changes), + "Quota/query" => (MethodObject::Quota, MethodFunction::Query), + "Quota/queryChanges" => (MethodObject::Quota, MethodFunction::QueryChanges), + + "Blob/get" => (MethodObject::Blob, MethodFunction::Get), + "Blob/copy" => (MethodObject::Blob, MethodFunction::Copy), + "Blob/lookup" => (MethodObject::Blob, MethodFunction::Lookup), + "Blob/upload" => (MethodObject::Blob, MethodFunction::Upload), + + "AddressBook/get" => (MethodObject::AddressBook, MethodFunction::Get), + "AddressBook/changes" => (MethodObject::AddressBook, MethodFunction::Changes), + "AddressBook/set" => (MethodObject::AddressBook, MethodFunction::Set), + "AddressBook/query" => (MethodObject::AddressBook, MethodFunction::Query), + + "ContactCard/get" => (MethodObject::ContactCard, MethodFunction::Get), + "ContactCard/changes" => (MethodObject::ContactCard, MethodFunction::Changes), + "ContactCard/query" => (MethodObject::ContactCard, MethodFunction::Query), + "ContactCard/queryChanges" => (MethodObject::ContactCard, MethodFunction::QueryChanges), + "ContactCard/set" => (MethodObject::ContactCard, MethodFunction::Set), + "ContactCard/copy" => (MethodObject::ContactCard, MethodFunction::Copy), + "ContactCard/parse" => (MethodObject::ContactCard, MethodFunction::Parse), + + "FileNode/get" => (MethodObject::FileNode, MethodFunction::Get), + "FileNode/changes" => (MethodObject::FileNode, MethodFunction::Changes), + "FileNode/query" => (MethodObject::FileNode, MethodFunction::Query), + "FileNode/queryChanges" => (MethodObject::FileNode, MethodFunction::QueryChanges), + "FileNode/set" => (MethodObject::FileNode, MethodFunction::Set), + "FileNode/copy" => (MethodObject::FileNode, MethodFunction::Copy), + + "ShareNotification/get" => (MethodObject::ShareNotification, MethodFunction::Get), + "ShareNotification/changes" => (MethodObject::ShareNotification, MethodFunction::Changes), + "ShareNotification/set" => (MethodObject::ShareNotification, MethodFunction::Set), + "ShareNotification/query" => (MethodObject::ShareNotification, MethodFunction::Query), + "ShareNotification/queryChanges" => (MethodObject::ShareNotification, MethodFunction::QueryChanges), + + "Calendar/get" => (MethodObject::Calendar, MethodFunction::Get), + "Calendar/changes" => (MethodObject::Calendar, MethodFunction::Changes), + "Calendar/set" => (MethodObject::Calendar, MethodFunction::Set), + "Calendar/query" => (MethodObject::Calendar, MethodFunction::Query), + + "CalendarEvent/get" => (MethodObject::CalendarEvent, MethodFunction::Get), + "CalendarEvent/changes" => (MethodObject::CalendarEvent, MethodFunction::Changes), + "CalendarEvent/query" => (MethodObject::CalendarEvent, MethodFunction::Query), + "CalendarEvent/queryChanges" => (MethodObject::CalendarEvent, MethodFunction::QueryChanges), + "CalendarEvent/set" => (MethodObject::CalendarEvent, MethodFunction::Set), + "CalendarEvent/copy" => (MethodObject::CalendarEvent, MethodFunction::Copy), + "CalendarEvent/parse" => (MethodObject::CalendarEvent, MethodFunction::Parse), + + "CalendarEventNotification/get" => (MethodObject::CalendarEventNotification, MethodFunction::Get), + "CalendarEventNotification/changes" => (MethodObject::CalendarEventNotification, MethodFunction::Changes), + "CalendarEventNotification/set" => (MethodObject::CalendarEventNotification, MethodFunction::Set), + "CalendarEventNotification/query" => (MethodObject::CalendarEventNotification, MethodFunction::Query), + "CalendarEventNotification/queryChanges" => (MethodObject::CalendarEventNotification, MethodFunction::QueryChanges), + + "ParticipantIdentity/get" => (MethodObject::ParticipantIdentity, MethodFunction::Get), + "ParticipantIdentity/changes" => (MethodObject::ParticipantIdentity, MethodFunction::Changes), + "ParticipantIdentity/set" => (MethodObject::ParticipantIdentity, MethodFunction::Set), + + "Core/echo" => (MethodObject::Core, MethodFunction::Echo), + + ).or_else(|| { + let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; + let obj = ObjectType::parse(obj)?; + let fnc = hashify::tiny_map!(fnc.as_bytes(), + "get" => MethodFunction::Get, + "set" => MethodFunction::Set, + "query" => MethodFunction::Query, + )?; + + if obj.flags() & OBJ_SINGLETON == 0 || fnc != MethodFunction::Query { + (MethodObject::Registry(obj), fnc).into() + } else { + None + } + }).map(|(obj, fnc)| MethodName { obj, fnc }) + } +} + +impl Display for MethodObject { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + MethodObject::Blob => "Blob", + MethodObject::EmailSubmission => "EmailSubmission", + MethodObject::SearchSnippet => "SearchSnippet", + MethodObject::Identity => "Identity", + MethodObject::VacationResponse => "VacationResponse", + MethodObject::PushSubscription => "PushSubscription", + MethodObject::SieveScript => "SieveScript", + MethodObject::Principal => "Principal", + MethodObject::Core => "Core", + MethodObject::Mailbox => "Mailbox", + MethodObject::Thread => "Thread", + MethodObject::Email => "Email", + MethodObject::Quota => "Quota", + MethodObject::AddressBook => "AddressBook", + MethodObject::ContactCard => "ContactCard", + MethodObject::FileNode => "FileNode", + MethodObject::ParticipantIdentity => "ParticipantIdentity", + MethodObject::Calendar => "Calendar", + MethodObject::CalendarEvent => "CalendarEvent", + MethodObject::CalendarEventNotification => "CalendarEventNotification", + MethodObject::ShareNotification => "ShareNotification", + MethodObject::Registry(obj) => { + f.write_str("x:")?; + return f.write_str(obj.as_str()); + } + }) + } +} + +impl MethodFunction { + pub fn as_str(&self) -> &'static str { + match self { + MethodFunction::Get => "get", + MethodFunction::Set => "set", + MethodFunction::Changes => "changes", + MethodFunction::Query => "query", + MethodFunction::QueryChanges => "queryChanges", + MethodFunction::Copy => "copy", + MethodFunction::Import => "import", + MethodFunction::Parse => "parse", + MethodFunction::Validate => "validate", + MethodFunction::Lookup => "lookup", + MethodFunction::Upload => "upload", + MethodFunction::Echo => "echo", + MethodFunction::GetAvailability => "getAvailability", + } + } +} + +impl MethodObject { + pub fn unwrap_registry(self) -> ObjectType { + match self { + MethodObject::Registry(obj) => obj, + _ => panic!("Not a registry method object"), + } + } +} + +impl<'de> serde::Deserialize<'de> for MethodName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = >::deserialize(deserializer)?; + + MethodName::parse(value.as_ref()) + .ok_or_else(|| serde::de::Error::custom(format!("Invalid method name: {:?}", value))) + } +} + +impl serde::Serialize for MethodName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str().as_ref()) + } +} diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs new file mode 100644 index 0000000..42f6224 --- /dev/null +++ b/crates/jmap-proto/src/request/mod.rs @@ -0,0 +1,290 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod capability; +pub mod deserialize; +pub mod method; +pub mod parser; +pub mod reference; +pub mod websocket; + +use self::method::MethodName; +use crate::{ + method::{ + availability::GetAvailabilityRequest, + changes::ChangesRequest, + copy::{CopyBlobRequest, CopyRequest}, + get::GetRequest, + import::ImportEmailRequest, + lookup::BlobLookupRequest, + parse::ParseRequest, + query::QueryRequest, + query_changes::QueryChangesRequest, + search_snippet::GetSearchSnippetRequest, + set::SetRequest, + upload::BlobUploadRequest, + validate::ValidateSieveScriptRequest, + }, + object::{ + AnyId, addressbook::AddressBook, blob::Blob, calendar::Calendar, + calendar_event::CalendarEvent, calendar_event_notification::CalendarEventNotification, + contact::ContactCard, email::Email, email_submission::EmailSubmission, file_node::FileNode, + identity::Identity, mailbox::Mailbox, participant_identity::ParticipantIdentity, + principal::Principal, push_subscription::PushSubscription, quota::Quota, + registry::Registry, share_notification::ShareNotification, sieve::Sieve, thread::Thread, + vacation_response::VacationResponse, + }, + request::{capability::CapabilityIds, reference::MaybeIdReference}, +}; +use jmap_tools::{Null, Value}; +use std::{collections::HashMap, fmt::Debug, str::FromStr}; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub const INVALID_ACCOUNT_ID: u64 = u64::MAX - 1; + +pub fn deserialize_account_id<'de, A>(map: &mut A) -> Result +where + A: serde::de::MapAccess<'de>, +{ + Ok(map + .next_value::>()? + .try_unwrap() + .unwrap_or_else(|| Id::from(INVALID_ACCOUNT_ID))) +} + +#[derive(Debug)] +pub struct Request<'x> { + pub using: CapabilityIds, + pub method_calls: Vec>>, + pub created_ids: Option>, +} + +#[derive(Debug)] +pub struct Call { + pub id: String, + pub name: MethodName, + pub method: T, +} + +#[derive(Debug)] +pub enum RequestMethod<'x> { + Get(GetRequestMethod), + Set(SetRequestMethod<'x>), + Changes(Box), + Copy(CopyRequestMethod<'x>), + ImportEmail(Box), + Parse(ParseRequestMethod), + Query(QueryRequestMethod), + QueryChanges(QueryChangesRequestMethod), + SearchSnippet(Box), + ValidateScript(Box), + LookupBlob(Box), + UploadBlob(Box), + Echo(Value<'x, Null, Null>), + Error(trc::Error), +} + +#[derive(Debug)] +pub enum GetRequestMethod { + Email(Box>), + Mailbox(Box>), + Thread(Box>), + Identity(Box>), + EmailSubmission(Box>), + PushSubscription(Box>), + Sieve(Box>), + VacationResponse(Box>), + Principal(Box>), + PrincipalAvailability(Box), + Quota(Box>), + Blob(Box>), + AddressBook(Box>), + ContactCard(Box>), + FileNode(Box>), + Calendar(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ParticipantIdentity(Box>), + ShareNotification(Box>), + Registry(Box>), +} + +#[derive(Debug)] +pub enum SetRequestMethod<'x> { + Email(Box>), + Mailbox(Box>), + Identity(Box>), + EmailSubmission(Box>), + PushSubscription(Box>), + Sieve(Box>), + VacationResponse(Box>), + AddressBook(Box>), + ContactCard(Box>), + FileNode(Box>), + ShareNotification(Box>), + Calendar(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ParticipantIdentity(Box>), + Registry(Box>), +} + +#[derive(Debug)] +pub enum CopyRequestMethod<'x> { + Email(Box>), + ContactCard(Box>), + CalendarEvent(Box>), + FileNode(Box>), + Blob(Box), +} + +#[derive(Debug)] +pub enum QueryRequestMethod { + Email(Box>), + Mailbox(Box>), + EmailSubmission(Box>), + Sieve(Box>), + Principal(Box>), + Quota(Box>), + AddressBook(Box>), + ContactCard(Box>), + FileNode(Box>), + Calendar(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ShareNotification(Box>), + Registry(Box>), +} + +#[derive(Debug)] +pub enum QueryChangesRequestMethod { + Email(Box>), + Mailbox(Box>), + EmailSubmission(Box>), + Principal(Box>), + Quota(Box>), + ContactCard(Box>), + FileNode(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ShareNotification(Box>), +} + +#[derive(Debug)] +pub enum ParseRequestMethod { + Email(Box>), + ContactCard(Box>), + CalendarEvent(Box>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaybeInvalid { + Value(V), + Invalid(String), +} + +impl<'de, V: FromStr> serde::Deserialize<'de> for MaybeInvalid { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = <&str>::deserialize(deserializer)?; + + if let Ok(id) = V::from_str(value) { + Ok(MaybeInvalid::Value(id)) + } else { + Ok(MaybeInvalid::Invalid(value.to_string())) + } + } +} + +impl serde::Serialize for MaybeInvalid { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + MaybeInvalid::Value(v) => v.serialize(serializer), + MaybeInvalid::Invalid(s) => serializer.serialize_str(s), + } + } +} + +impl From for MaybeInvalid { + fn from(value: V) -> Self { + MaybeInvalid::Value(value) + } +} + +impl Default for MaybeInvalid { + fn default() -> Self { + MaybeInvalid::Invalid("".to_string()) + } +} + +#[allow(clippy::derivable_impls)] +impl Default for Request<'_> { + fn default() -> Self { + Request { + using: CapabilityIds::default(), + method_calls: Vec::new(), + created_ids: None, + } + } +} + +impl MaybeInvalid +where + T: FromStr, +{ + pub fn try_unwrap(self) -> Option { + match self { + MaybeInvalid::Value(id) => Some(id), + MaybeInvalid::Invalid(_) => None, + } + } +} + +pub trait IntoValid { + type Item; + + fn into_valid(self) -> impl Iterator; +} + +impl IntoValid for Vec> { + type Item = T; + + fn into_valid(self) -> impl Iterator { + self.into_iter().filter_map(|v| v.try_unwrap()) + } +} + +impl IntoValid for Vec> { + type Item = T; + + fn into_valid(self) -> impl Iterator { + self.into_iter().filter_map(|v| v.try_unwrap()) + } +} + +impl IntoValid for VecMap, V> { + type Item = (T, V); + + fn into_valid(self) -> impl Iterator { + self.into_iter() + .filter_map(|(k, v)| k.try_unwrap().map(|k| (k, v))) + } +} + +impl IntoValid for VecMap, V> { + type Item = (T, V); + + fn into_valid(self) -> impl Iterator { + self.into_iter() + .filter_map(|(k, v)| k.try_unwrap().map(|k| (k, v))) + } +} diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs new file mode 100644 index 0000000..19a0dd7 --- /dev/null +++ b/crates/jmap-proto/src/request/parser.rs @@ -0,0 +1,948 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Call, Request, RequestMethod, + method::{MethodFunction, MethodName, MethodObject}, +}; +use crate::request::{ + CopyRequestMethod, GetRequestMethod, ParseRequestMethod, QueryChangesRequestMethod, + QueryRequestMethod, SetRequestMethod, deserialize::DeserializeArguments, +}; +use serde::{ + Deserialize, Deserializer, + de::{self, SeqAccess, Visitor}, +}; +use std::fmt::{self, Display}; + +impl<'x> Request<'x> { + pub fn parse(json: &'x [u8], max_calls: usize, max_size: usize) -> trc::Result { + if json.len() <= max_size { + match serde_json::from_slice::(json) { + Ok(request) => { + if request.method_calls.len() <= max_calls { + Ok(request) + } else { + Err(trc::LimitEvent::CallsIn.into_err()) + } + } + Err(err) => Err(trc::JmapEvent::NotRequest + .into_err() + .reason(err.to_string()) + .details(String::from_utf8_lossy(json).into_owned())), + } + } else { + Err(trc::LimitEvent::SizeRequest.into_err()) + } + } +} + +impl<'de> DeserializeArguments<'de> for Request<'de> { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + hashify::fnc_map!(key.as_bytes(), + b"using" => { + self.using = map.next_value()?; + }, + b"methodCalls" => { + self.method_calls = map.next_value()?; + }, + b"createdIds" => { + self.created_ids = map.next_value()?; + }, + _ => { + let _ = map.next_value::()?; + } + ); + + Ok(()) + } +} + +struct CallVisitor; + +impl<'de> Visitor<'de> for CallVisitor { + type Value = Call>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an array with 3 elements") + } + + fn visit_seq(self, mut seq: V) -> Result>, V::Error> + where + V: SeqAccess<'de>, + { + let method_name = seq + .next_element::>()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let name = match MethodName::parse(method_name.as_ref()) { + Some(name) => name, + None => { + // Ignore the rest of the call + let _ = seq + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + let id = seq + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + + return Ok(Call { + id, + method: RequestMethod::Error( + trc::JmapEvent::UnknownMethod + .into_err() + .details(method_name.to_string()), + ), + name: MethodName::error(), + }); + } + }; + + let method = match (&name.fnc, &name.obj) { + (MethodFunction::Get, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Email(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Mailbox) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Mailbox(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Thread) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Thread(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Identity) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Identity(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::EmailSubmission) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::EmailSubmission(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::PushSubscription) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::PushSubscription(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::SieveScript) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Sieve(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Principal) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Principal(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Quota) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Quota(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Blob) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Blob(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Calendar) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Calendar(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::CalendarEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::CalendarEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::CalendarEventNotification) => { + match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Get(GetRequestMethod::CalendarEventNotification(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::Get, MethodObject::ParticipantIdentity) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ParticipantIdentity(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::AddressBook) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AddressBook(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ContactCard(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ShareNotification(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::SearchSnippet) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::SearchSnippet(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::Registry(_)) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Registry(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Email(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Mailbox) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Mailbox(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Identity) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Identity(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::EmailSubmission) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::EmailSubmission(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::PushSubscription) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::PushSubscription(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::SieveScript) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Sieve(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Calendar) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Calendar(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::CalendarEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::CalendarEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::CalendarEventNotification) => { + match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Set(SetRequestMethod::CalendarEventNotification(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::Set, MethodObject::ParticipantIdentity) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ParticipantIdentity(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::AddressBook) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AddressBook(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ContactCard(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ShareNotification(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Registry(_)) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Registry(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Email(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Mailbox) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Mailbox(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::EmailSubmission) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::EmailSubmission(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::SieveScript) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Sieve(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Principal) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Principal(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Quota) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Quota(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Calendar) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Calendar(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::CalendarEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::CalendarEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::CalendarEventNotification) => { + match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Query(QueryRequestMethod::CalendarEventNotification(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::Query, MethodObject::AddressBook) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::AddressBook(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::ContactCard(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Query(QueryRequestMethod::ShareNotification(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Query, MethodObject::Registry(_)) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Registry(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::Email(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::Mailbox) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::Mailbox(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::EmailSubmission) => { + match seq.next_element() { + Ok(Some(value)) => RequestMethod::QueryChanges( + QueryChangesRequestMethod::EmailSubmission(value), + ), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::QueryChanges, MethodObject::Principal) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::Principal(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::Quota) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::Quota(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::CalendarEvent) => match seq.next_element() + { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::CalendarEvent(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::CalendarEventNotification) => { + match seq.next_element() { + Ok(Some(value)) => RequestMethod::QueryChanges( + QueryChangesRequestMethod::CalendarEventNotification(value), + ), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::QueryChanges, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::ContactCard(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::QueryChanges(QueryChangesRequestMethod::FileNode(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::QueryChanges, MethodObject::ShareNotification) => { + match seq.next_element() { + Ok(Some(value)) => RequestMethod::QueryChanges( + QueryChangesRequestMethod::ShareNotification(value), + ), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::Changes, _) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Changes(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Copy, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::Email(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Copy, MethodObject::Blob) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::Blob(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Copy, MethodObject::CalendarEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::CalendarEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Copy, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::ContactCard(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Copy, MethodObject::FileNode) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Copy(CopyRequestMethod::FileNode(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Lookup, MethodObject::Blob) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::LookupBlob(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Upload, MethodObject::Blob) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::UploadBlob(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Import, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::ImportEmail(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Parse, MethodObject::Email) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Parse(ParseRequestMethod::Email(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Parse, MethodObject::CalendarEvent) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Parse(ParseRequestMethod::CalendarEvent(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Parse, MethodObject::ContactCard) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Parse(ParseRequestMethod::ContactCard(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::GetAvailability, MethodObject::Principal) => { + match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Get(GetRequestMethod::PrincipalAvailability(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } + (MethodFunction::Validate, MethodObject::SieveScript) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::ValidateScript(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Echo, MethodObject::Core) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Echo(value), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + _ => { + return Err(de::Error::custom(format!( + "Invalid method function/object combination: {}", + method_name + ))); + } + }; + + let id = seq + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + + Ok(Call { id, method, name }) + } +} + +impl RequestMethod<'_> { + fn invalid(err: impl Display) -> Self { + RequestMethod::Error( + trc::JmapEvent::InvalidArguments + .into_err() + .details(err.to_string()), + ) + } +} + +impl<'de> Deserialize<'de> for Request<'de> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RequestVisitor; + + impl<'de> Visitor<'de> for RequestVisitor { + type Value = Request<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a JMAP request object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: de::MapAccess<'de>, + { + let mut target = Request::default(); + let mut has_using = false; + let mut has_method_calls = false; + + while let Some(key) = map.next_key::<&str>()? { + match key { + "using" => has_using = true, + "methodCalls" => has_method_calls = true, + _ => {} + } + target + .deserialize_argument(key, &mut map) + .map_err(de::Error::custom)?; + } + + if !has_using || !has_method_calls { + return Err(de::Error::custom( + "Request is missing the \"using\" or \"methodCalls\" property.", + )); + } + + Ok(target) + } + } + + deserializer.deserialize_map(RequestVisitor) + } +} + +impl<'de> Deserialize<'de> for Call> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(CallVisitor) + } +} + +#[cfg(test)] +mod tests { + use crate::request::Request; + + const TEST: &str = r#" + { + "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + "methodCalls": [ + [ "method1", { + "arg1": "arg1data", + "arg2": "arg2data" + }, "c1" ], + [ "Core/echo", { + "hello": true, + "high": 5 + }, "c2" ], + [ "method3", {"hello": [{"a": {"b": true}}]}, "c3" ] + ], + "createdIds": { + "c1": "m1", + "c2": "m2" + } + } + "#; + + const TEST1: &str = r#" + { + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail" + ], + "methodCalls": [ + [ + "Email/query", + { + "accountId": "0", + "filter": { "conditions": [ { "hasKeyword": "music", "maxSize": 455 }, { "hasKeyword": "video" }, { "operator": "AND", "conditions": [ { "subject": "test" }, { "minSize": 100 } ] } ], "operator": "OR" }, + "sort": [ + { + "property": "subject", + "isAscending": true + }, + { + "property": "allInThreadHaveKeyword", + "isAscending": false, + "keyword": "$seen" + }, + { + "keyword": "$junk", + "property": "someInThreadHaveKeyword", + "collation": "i;octet", + "isAscending": false + } + ], + "position": 0, + "limit": 10 + }, + "c1" + ] + ], + "createdIds": {} + } + "#; + + const TEST2: &str = r##" + { + "using": [ + "urn:ietf:params:jmap:submission", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:core" + ], + "methodCalls": [ + [ + "Email/set", + { + "accountId": "c", + "create": { + "c37ee58b-e224-4799-88e6-1d7484e3b782": { + "mailboxIds": { + "9": true + }, + "subject": "test", + "from": [ + { + "name": "Foo", + "email": "foo@bar.com" + } + ], + "to": [ + { + "name": null, + "email": "bar@foo.com" + } + ], + "cc": [], + "bcc": [], + "replyTo": [ + { + "name": null, + "email": "foo@bar.com" + } + ], + "htmlBody": [ + { + "partId": "c37ee58b-e224-4799-88e6-1d7484e3b782", + "type": "text/html" + } + ], + "bodyValues": { + "c37ee58b-e224-4799-88e6-1d7484e3b782": { + "value": "

test email

", + "isEncodingProblem": false, + "isTruncated": false + } + }, + "header:User-Agent:asText": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/113.0" + } + } + }, + "c0" + ], + [ + "EmailSubmission/set", + { + "accountId": "c", + "create": { + "c37ee58b-e224-4799-88e6-1d7484e3b782": { + "identityId": "a", + "emailId": "#c37ee58b-e224-4799-88e6-1d7484e3b782", + "envelope": { + "mailFrom": { + "email": "foo@bar.com" + }, + "rcptTo": [ + { + "email": "bar@foo.com" + } + ] + } + } + }, + "onSuccessUpdateEmail": { + "#c37ee58b-e224-4799-88e6-1d7484e3b782": { + "mailboxIds/d": true, + "mailboxIds/9": null, + "keywords/$seen": true, + "keywords/$draft": null + } + } + }, + "c1" + ] + ] + } + "##; + + const TEST_ESCAPED_SOLIDUS: &str = r#" + { + "using": [ "urn:ietf:params:jmap:core" ], + "methodCalls": [ + [ "Core\/echo", { "hello": true }, "c1" ] + ] + } + "#; + + #[test] + fn parse_request() { + println!("{:#?}", Request::parse(TEST.as_bytes(), 10, 10240)); + println!("{:#?}", Request::parse(TEST1.as_bytes(), 10, 10240)); + println!("{:#?}", Request::parse(TEST2.as_bytes(), 10, 10240)); + } + + #[test] + fn parse_method_name_with_escaped_solidus() { + let request = Request::parse(TEST_ESCAPED_SOLIDUS.as_bytes(), 10, 10240) + .expect("escaped solidus in method name must parse"); + assert_eq!(request.method_calls.len(), 1); + assert!(matches!( + request.method_calls[0].method, + super::RequestMethod::Echo(_) + )); + } +} diff --git a/crates/jmap-proto/src/request/reference.rs b/crates/jmap-proto/src/request/reference.rs new file mode 100644 index 0000000..4460b12 --- /dev/null +++ b/crates/jmap-proto/src/request/reference.rs @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::method::MethodName; +use jmap_tools::{JsonPointer, Null}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResultReference { + #[serde(rename = "resultOf")] + pub result_of: String, + pub name: MethodName, + pub path: JsonPointer, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MaybeIdReference { + Id(V), + Reference(String), + Invalid(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaybeResultReference { + Value(V), + Reference(ResultReference), +} + +impl Display for ResultReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{{ resultOf: {}, name: {}, path: {} }}", + self.result_of, self.name, self.path + ) + } +} + +impl Display for MaybeIdReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MaybeIdReference::Id(id) => write!(f, "{}", id), + MaybeIdReference::Reference(str) => write!(f, "#{}", str), + MaybeIdReference::Invalid(str) => write!(f, "{}", str), + } + } +} + +impl<'de, V: FromStr> serde::Deserialize<'de> for MaybeIdReference { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = >::deserialize(deserializer)?; + + if let Some(reference) = value.strip_prefix('#') { + if reference.is_empty() { + return Ok(MaybeIdReference::Invalid(value.into_owned())); + } + Ok(MaybeIdReference::Reference(reference.to_string())) + } else if let Ok(id) = V::from_str(value.as_ref()) { + Ok(MaybeIdReference::Id(id)) + } else { + Ok(MaybeIdReference::Invalid(value.into_owned())) + } + } +} + +impl FromStr for MaybeIdReference { + type Err = V::Err; + + fn from_str(s: &str) -> Result { + if let Some(reference) = s.strip_prefix('#') { + if reference.is_empty() { + return Ok(MaybeIdReference::Invalid(s.to_string())); + } + Ok(MaybeIdReference::Reference(reference.to_string())) + } else if let Ok(id) = V::from_str(s) { + Ok(MaybeIdReference::Id(id)) + } else { + Ok(MaybeIdReference::Invalid(s.to_string())) + } + } +} + +impl serde::Serialize for MaybeIdReference { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + MaybeIdReference::Id(id) => serializer.serialize_str(&id.to_string()), + MaybeIdReference::Reference(str) => serializer.serialize_str(&format!("#{}", str)), + MaybeIdReference::Invalid(str) => serializer.serialize_str(str), + } + } +} + +impl Default for MaybeResultReference { + fn default() -> Self { + MaybeResultReference::Value(V::default()) + } +} + +impl MaybeResultReference { + pub fn unwrap(self) -> T { + match self { + MaybeResultReference::Value(v) => v, + MaybeResultReference::Reference(_) => T::default(), + } + } +} + +impl MaybeIdReference { + pub fn try_unwrap(self) -> Option { + match self { + MaybeIdReference::Id(id) => Some(id), + _ => None, + } + } +} diff --git a/crates/jmap-proto/src/request/websocket.rs b/crates/jmap-proto/src/request/websocket.rs new file mode 100644 index 0000000..8726bcd --- /dev/null +++ b/crates/jmap-proto/src/request/websocket.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Request; +use crate::{ + error::request::{RequestError, RequestErrorType, RequestLimitError}, + object::AnyId, + request::{Call, deserialize::DeserializeArguments}, + response::{Response, ResponseMethod, serialize::serialize_hex, status::PushObject}, +}; +use serde::{ + Deserialize, Deserializer, + de::{self, MapAccess, Visitor}, +}; +use std::{borrow::Cow, collections::HashMap, fmt}; +use types::type_state::DataType; + +#[derive(Debug)] +pub struct WebSocketRequest<'x> { + pub id: Option, + pub request: Request<'x>, +} + +#[derive(Debug, serde::Serialize)] +pub struct WebSocketResponse<'x> { + #[serde(rename = "@type")] + _type: WebSocketResponseType, + + #[serde(rename = "methodResponses")] + method_responses: Vec>>, + + #[serde(rename = "sessionState")] + #[serde(serialize_with = "serialize_hex")] + session_state: u32, + + #[serde(rename(deserialize = "createdIds"))] + #[serde(skip_serializing_if = "HashMap::is_empty")] + created_ids: HashMap, + + #[serde(rename = "requestId")] + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, +} + +#[derive(Debug, PartialEq, Eq, serde::Serialize)] +pub enum WebSocketResponseType { + Response, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct WebSocketPushEnable { + pub data_types: Vec, + pub push_state: Option, +} + +#[derive(Debug)] +pub enum WebSocketMessage<'x> { + Request(WebSocketRequest<'x>), + PushEnable(WebSocketPushEnable), + PushDisable, +} + +#[derive(serde::Serialize, Debug)] +pub struct WebSocketPushObject { + #[serde(flatten)] + pub push: PushObject, + + #[serde(rename = "pushState")] + #[serde(skip_serializing_if = "Option::is_none")] + pub push_state: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct WebSocketRequestError<'x> { + #[serde(rename = "@type")] + pub type_: WebSocketRequestErrorType, + + #[serde(rename = "type")] + p_type: RequestErrorType, + + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + status: u16, + detail: Cow<'x, str>, + + #[serde(rename = "requestId")] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(serde::Serialize, Debug)] +pub enum WebSocketRequestErrorType { + RequestError, +} + +enum MessageType { + Request, + PushEnable, + PushDisable, + None, +} + +impl<'x> WebSocketMessage<'x> { + pub fn parse(json: &'x [u8], max_calls: usize, max_size: usize) -> trc::Result { + if json.len() <= max_size { + match serde_json::from_slice::(json) { + Ok(WebSocketMessage::Request(req)) + if req.request.method_calls.len() > max_calls => + { + Err(trc::LimitEvent::CallsIn.into_err()) + } + Ok(msg) => Ok(msg), + Err(err) => Err(trc::JmapEvent::NotRequest + .into_err() + .details(format!("Invalid WebSocket JMAP request {err}"))), + } + } else { + Err(trc::LimitEvent::SizeRequest.into_err()) + } + } +} + +impl<'de: 'x, 'x: 'de> Deserialize<'de> for WebSocketMessage<'x> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(WebSocketMessageVisitor) + } +} + +struct WebSocketMessageVisitor; + +impl<'de> Visitor<'de> for WebSocketMessageVisitor { + type Value = WebSocketMessage<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a WebSocketMessage as a map") + } + + fn visit_map(self, mut map: V) -> Result, V::Error> + where + V: MapAccess<'de>, + { + let mut message_type = MessageType::None; + let mut request = WebSocketRequest { + id: None, + request: Request::default(), + }; + let mut push_enable = WebSocketPushEnable::default(); + + let mut found_request_keys = false; + let mut found_push_keys = false; + + while let Some(key) = map.next_key::<&str>()? { + hashify::fnc_map!(key.as_bytes(), + b"@type" => { + message_type = MessageType::parse(map.next_value()?); + }, + b"dataTypes" => { + push_enable.data_types = map.next_value::>>()?.unwrap_or_default(); + found_push_keys = true; + }, + b"pushState" => { + push_enable.push_state = map.next_value()?; + found_push_keys = true; + }, + b"id" => { + request.id = map.next_value()?; + }, + _ => { + request.request.deserialize_argument(key, &mut map)?; + found_request_keys = true; + } + ); + } + + match message_type { + MessageType::Request if found_request_keys => Ok(WebSocketMessage::Request(request)), + MessageType::PushEnable if found_push_keys => { + Ok(WebSocketMessage::PushEnable(push_enable)) + } + MessageType::PushDisable if !found_request_keys && !found_push_keys => { + Ok(WebSocketMessage::PushDisable) + } + _ => Err(de::Error::custom("Invalid WebSocket JMAP request")), + } + } +} + +impl MessageType { + fn parse(s: &str) -> Self { + hashify::tiny_map!(s.as_bytes(), + b"Request" => MessageType::Request, + b"WebSocketPushEnable" => MessageType::PushEnable, + b"WebSocketPushDisable" => MessageType::PushDisable, + ) + .unwrap_or(MessageType::None) + } +} + +impl<'x> WebSocketRequestError<'x> { + pub fn from_error(error: RequestError<'x>, request_id: Option) -> Self { + Self { + type_: WebSocketRequestErrorType::RequestError, + p_type: error.p_type, + limit: error.limit, + status: error.status, + detail: error.detail, + request_id, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +impl<'x> From> for WebSocketRequestError<'x> { + fn from(value: RequestError<'x>) -> Self { + Self::from_error(value, None) + } +} + +impl<'x> WebSocketResponse<'x> { + pub fn from_response(response: Response<'x>, request_id: Option) -> Self { + Self { + _type: WebSocketResponseType::Response, + method_responses: response.method_responses, + session_state: response.session_state, + created_ids: response.created_ids, + request_id, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +impl WebSocketPushObject { + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs new file mode 100644 index 0000000..c9b72ab --- /dev/null +++ b/crates/jmap-proto/src/response/mod.rs @@ -0,0 +1,616 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod serialize; +pub mod status; + +use self::serialize::serialize_hex; +use crate::{ + error::method::MethodErrorWrapper, + method::{ + availability::GetAvailabilityResponse, + changes::ChangesResponse, + copy::{CopyBlobResponse, CopyResponse}, + get::GetResponse, + import::ImportEmailResponse, + lookup::BlobLookupResponse, + parse::ParseResponse, + query::QueryResponse, + query_changes::QueryChangesResponse, + search_snippet::GetSearchSnippetResponse, + set::SetResponse, + upload::BlobUploadResponse, + validate::ValidateSieveScriptResponse, + }, + object::{ + AnyId, + addressbook::AddressBook, + blob::Blob, + calendar::Calendar, + calendar_event::CalendarEvent, + calendar_event_notification::{ + CalendarEventNotification, CalendarEventNotificationGetResponse, + }, + contact::ContactCard, + email::Email, + email_submission::EmailSubmission, + file_node::FileNode, + identity::Identity, + mailbox::Mailbox, + participant_identity::ParticipantIdentity, + principal::Principal, + push_subscription::PushSubscription, + quota::Quota, + registry::Registry, + share_notification::ShareNotification, + sieve::Sieve, + thread::Thread, + vacation_response::VacationResponse, + }, + request::{Call, method::MethodName}, +}; +use jmap_tools::{Null, Value}; +use std::collections::HashMap; + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum ResponseMethod<'x> { + Get(GetResponseMethod), + Set(SetResponseMethod), + Changes(ChangesResponseMethod), + Copy(CopyResponseMethod), + ImportEmail(ImportEmailResponse), + Parse(ParseResponseMethod), + QueryChanges(QueryChangesResponse), + Query(QueryResponse), + SearchSnippet(GetSearchSnippetResponse), + ValidateScript(ValidateSieveScriptResponse), + LookupBlob(BlobLookupResponse), + UploadBlob(BlobUploadResponse), + Echo(Value<'x, Null, Null>), + Error(MethodErrorWrapper), +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum GetResponseMethod { + Email(GetResponse), + Mailbox(GetResponse), + Thread(GetResponse), + Identity(GetResponse), + EmailSubmission(GetResponse), + PushSubscription(GetResponse), + Sieve(GetResponse), + VacationResponse(GetResponse), + Principal(GetResponse), + PrincipalAvailability(GetAvailabilityResponse), + Quota(GetResponse), + Blob(GetResponse), + AddressBook(GetResponse), + ContactCard(GetResponse), + FileNode(GetResponse), + Calendar(GetResponse), + CalendarEvent(GetResponse), + CalendarEventNotification(CalendarEventNotificationGetResponse), + ParticipantIdentity(GetResponse), + ShareNotification(GetResponse), + Registry(GetResponse), +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum SetResponseMethod { + Email(Box>), + Mailbox(Box>), + Identity(Box>), + EmailSubmission(Box>), + PushSubscription(Box>), + Sieve(Box>), + VacationResponse(Box>), + AddressBook(Box>), + ContactCard(Box>), + FileNode(Box>), + ShareNotification(Box>), + Calendar(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ParticipantIdentity(Box>), + Registry(Box>), +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum ChangesResponseMethod { + Email(Box>), + Mailbox(Box>), + Thread(Box>), + Identity(Box>), + EmailSubmission(Box>), + Quota(Box>), + AddressBook(Box>), + ContactCard(Box>), + FileNode(Box>), + Calendar(Box>), + CalendarEvent(Box>), + CalendarEventNotification(Box>), + ShareNotification(Box>), +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum CopyResponseMethod { + Email(CopyResponse), + ContactCard(CopyResponse), + CalendarEvent(CopyResponse), + FileNode(CopyResponse), + Blob(CopyBlobResponse), +} + +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum ParseResponseMethod { + Email(ParseResponse), + ContactCard(ParseResponse), + CalendarEvent(ParseResponse), +} + +#[derive(Debug, serde::Serialize)] +pub struct Response<'x> { + #[serde(rename = "methodResponses")] + pub method_responses: Vec>>, + + #[serde(rename = "sessionState")] + #[serde(serialize_with = "serialize_hex")] + pub session_state: u32, + + #[serde(rename = "createdIds")] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub created_ids: HashMap, +} + +impl<'x> Response<'x> { + pub fn new(session_state: u32, created_ids: HashMap, capacity: usize) -> Self { + Response { + session_state, + created_ids, + method_responses: Vec::with_capacity(capacity), + } + } + + pub fn push_response( + &mut self, + id: String, + name: MethodName, + method: impl Into>, + ) { + self.method_responses.push(Call { + id, + method: method.into(), + name, + }); + } + + pub fn push_error(&mut self, id: String, err: impl Into) { + self.method_responses.push(Call { + id, + method: ResponseMethod::Error(err.into()), + name: MethodName::error(), + }); + } + + pub fn push_created_id(&mut self, create_id: String, id: impl Into) { + self.created_ids.insert(create_id, id.into()); + } +} + +impl From for ResponseMethod<'_> { + fn from(error: trc::Error) -> Self { + ResponseMethod::Error(error.into()) + } +} + +impl<'x, T: Into>> From> for ResponseMethod<'x> { + fn from(result: trc::Result) -> Self { + match result { + Ok(value) => value.into(), + Err(error) => error.into(), + } + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Email(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Mailbox(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Thread(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Identity(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::EmailSubmission(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::PushSubscription(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Sieve(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::VacationResponse(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Principal(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Quota(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Blob(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ContactCard(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::AddressBook(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Registry(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Email(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Mailbox(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Identity(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::EmailSubmission(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::PushSubscription(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Sieve(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::VacationResponse(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AddressBook(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ContactCard(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Registry(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::Email(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::Mailbox(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::Thread(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::Identity(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::EmailSubmission(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::Quota(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::AddressBook(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: CopyResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::Email(value)) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: CopyBlobResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::Blob(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: CopyResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::ContactCard(value)) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: ImportEmailResponse) -> Self { + ResponseMethod::ImportEmail(value) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ParseResponse) -> Self { + ResponseMethod::Parse(ParseResponseMethod::Email(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: ParseResponse) -> Self { + ResponseMethod::Parse(ParseResponseMethod::ContactCard(value)) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: QueryChangesResponse) -> Self { + ResponseMethod::QueryChanges(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: QueryResponse) -> Self { + ResponseMethod::Query(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: GetSearchSnippetResponse) -> Self { + ResponseMethod::SearchSnippet(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: ValidateSieveScriptResponse) -> Self { + ResponseMethod::ValidateScript(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: BlobLookupResponse) -> Self { + ResponseMethod::LookupBlob(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: BlobUploadResponse) -> Self { + ResponseMethod::UploadBlob(value) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: Value<'x, Null, Null>) -> Self { + ResponseMethod::Echo(value) + } +} + +impl<'x> From for ResponseMethod<'x> { + fn from(value: MethodErrorWrapper) -> Self { + ResponseMethod::Error(value) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::FileNode(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::FileNode(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::FileNode(Box::new(response))) + } +} + +impl From for ResponseMethod<'_> { + fn from(response: GetAvailabilityResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::PrincipalAvailability(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Calendar(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Calendar(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::CalendarEvent(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::CalendarEventNotification(Box::new( + response, + ))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::CalendarEvent(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ParticipantIdentity(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ParticipantIdentity(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: ChangesResponse) -> Self { + ResponseMethod::Changes(ChangesResponseMethod::ShareNotification(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ShareNotification(Box::new(response))) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ShareNotification(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(response: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::CalendarEvent(response)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(value: ParseResponse) -> Self { + ResponseMethod::Parse(ParseResponseMethod::CalendarEvent(value)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(value: CopyResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::CalendarEvent(value)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(value: CopyResponse) -> Self { + ResponseMethod::Copy(CopyResponseMethod::FileNode(value)) + } +} + +impl From for ResponseMethod<'_> { + fn from(value: CalendarEventNotificationGetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::CalendarEventNotification(value)) + } +} + +impl From> for ResponseMethod<'_> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::CalendarEventNotification(Box::new( + value, + ))) + } +} diff --git a/crates/jmap-proto/src/response/serialize.rs b/crates/jmap-proto/src/response/serialize.rs new file mode 100644 index 0000000..4e70fec --- /dev/null +++ b/crates/jmap-proto/src/response/serialize.rs @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ResponseMethod; +use crate::request::Call; +use serde::{Serialize, ser::SerializeSeq}; + +impl Serialize for Call> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(3.into())?; + seq.serialize_element(&self.name.to_string())?; + seq.serialize_element(&self.method)?; + seq.serialize_element(&self.id)?; + seq.end() + } +} + +pub fn serialize_hex(value: &u32, serializer: S) -> Result +where + S: serde::Serializer, +{ + format!("{:x}", value).serialize(serializer) +} diff --git a/crates/jmap-proto/src/response/status.rs b/crates/jmap-proto/src/response/status.rs new file mode 100644 index 0000000..d1d7f72 --- /dev/null +++ b/crates/jmap-proto/src/response/status.rs @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::object::email::{EmailProperty, EmailValue}; +use crate::types::state::State; +use jmap_tools::Value; +use types::{id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +#[derive(serde::Serialize, Debug)] +#[serde(tag = "@type")] +pub enum PushObject { + StateChange { + changed: VecMap>, + }, + EmailPush { + #[serde(rename = "accountId")] + account_id: Id, + emails: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + state: Option, + }, + CalendarAlert { + #[serde(rename = "accountId")] + account_id: Id, + #[serde(rename = "calendarEventId")] + calendar_event_id: Id, + uid: String, + #[serde(rename = "recurrenceId")] + recurrence_id: Option, + #[serde(rename = "alertId")] + alert_id: String, + }, +} diff --git a/crates/jmap-proto/src/types/date.rs b/crates/jmap-proto/src/types/date.rs new file mode 100644 index 0000000..9687776 --- /dev/null +++ b/crates/jmap-proto/src/types/date.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{fmt::Display, str::FromStr}; + +#[derive( + rkyv::Archive, + rkyv::Serialize, + rkyv::Deserialize, + Debug, + Default, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, +)] +#[rkyv(derive(Debug), compare(PartialEq))] +pub struct UTCDate { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub tz_before_gmt: bool, + pub tz_hour: u8, + pub tz_minute: u8, +} + +impl FromStr for UTCDate { + type Err = (); + + fn from_str(s: &str) -> Result { + // 2004 - 06 - 28 T 23 : 43 : 45 . 000 Z + // 1969 - 02 - 13 T 23 : 32 : 00 - 03 : 30 + // 0 1 2 3 4 5 6 7 + + let mut pos = 0; + let mut parts = [0u32; 8]; + let mut parts_sizes = [ + 4u32, // Year (0) + 2u32, // Month (1) + 2u32, // Day (2) + 2u32, // Hour (3) + 2u32, // Minute (4) + 2u32, // Second (5) + 2u32, // TZ Hour (6) + 2u32, // TZ Minute (7) + ]; + let mut skip_digits = false; + let mut is_plus = true; + + for ch in s.as_bytes() { + match ch { + b'0'..=b'9' => { + if !skip_digits { + if parts_sizes[pos] > 0 { + parts_sizes[pos] -= 1; + parts[pos] += (ch - b'0') as u32 * u32::pow(10, parts_sizes[pos]); + } else { + break; + } + } + } + b'-' => { + if pos <= 1 { + pos += 1; + } else if pos == 5 { + pos += 1; + is_plus = false; + skip_digits = false; + } else { + break; + } + } + b'T' if pos == 2 => { + pos += 1; + } + b':' if [3, 4, 6].contains(&pos) => { + pos += 1; + } + b'+' if pos == 5 => { + pos += 1; + skip_digits = false; + } + b'.' if pos == 5 => { + skip_digits = true; + } + b'Z' | b'z' => (), + _ => { + break; + } + } + } + + if pos >= 5 { + Ok(UTCDate { + year: parts[0] as u16, + month: parts[1] as u8, + day: parts[2] as u8, + hour: parts[3] as u8, + minute: parts[4] as u8, + second: parts[5] as u8, + tz_hour: parts[6] as u8, + tz_minute: parts[7] as u8, + tz_before_gmt: !is_plus, + }) + } else { + Err(()) + } + } +} + +impl UTCDate { + pub fn from_timestamp(timestamp: i64) -> Self { + // Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days + let (z, seconds) = ((timestamp / 86400) + 719468, timestamp % 86400); + let era: i64 = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe: u64 = (z - era * 146097) as u64; // [0, 146096] + let yoe: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + let y: i64 = (yoe as i64) + era * 400; + let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d: u64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let (h, mn, s) = (seconds / 3600, (seconds / 60) % 60, seconds % 60); + + UTCDate { + year: (y + i64::from(m <= 2)) as u16, + month: m as u8, + day: d as u8, + hour: h as u8, + minute: mn as u8, + second: s as u8, + tz_before_gmt: false, + tz_hour: 0, + tz_minute: 0, + } + } + + pub fn is_valid(&self) -> bool { + (0..=23).contains(&self.tz_hour) + && (1970..=3000).contains(&self.year) + && (0..=59).contains(&self.tz_minute) + && (1..=12).contains(&self.month) + && (1..=31).contains(&self.day) + && (0..=23).contains(&self.hour) + && (0..=59).contains(&self.minute) + && (0..=59).contains(&self.second) + } + + pub fn timestamp(&self) -> i64 { + // Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992 + let month = self.month as u32; + let year_base = 4800; /* Before min year, multiple of 400. */ + let m_adj = month.wrapping_sub(3); /* March-based month. */ + let carry = i64::from(m_adj > month); + let adjust = if carry > 0 { 12 } else { 0 }; + let y_adj = self.year as i64 + year_base - carry; + let month_days = ((m_adj.wrapping_add(adjust)) * 62719 + 769) / 2048; + let leap_days = y_adj / 4 - y_adj / 100 + y_adj / 400; + (y_adj * 365 + leap_days + month_days as i64 + (self.day as i64 - 1) - 2472632) * 86400 + + self.hour as i64 * 3600 + + self.minute as i64 * 60 + + self.second as i64 + + ((self.tz_hour as i64 * 3600 + self.tz_minute as i64 * 60) + * if self.tz_before_gmt { 1 } else { -1 }) + } +} + +impl From<&ArchivedUTCDate> for UTCDate { + fn from(value: &ArchivedUTCDate) -> Self { + UTCDate { + year: value.year.to_native(), + month: value.month, + day: value.day, + hour: value.hour, + minute: value.minute, + second: value.second, + tz_before_gmt: value.tz_before_gmt, + tz_hour: value.tz_hour, + tz_minute: value.tz_minute, + } + } +} + +impl Display for UTCDate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.tz_hour != 0 || self.tz_minute != 0 { + write!( + f, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}", + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + if self.tz_before_gmt && (self.tz_hour > 0 || self.tz_minute > 0) { + "-" + } else { + "+" + }, + self.tz_hour, + self.tz_minute, + ) + } else { + write!( + f, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + self.year, self.month, self.day, self.hour, self.minute, self.second, + ) + } + } +} + +impl serde::Serialize for UTCDate { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for UTCDate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + UTCDate::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid JMAP UTCDate")) + } +} + +impl From for u64 { + fn from(value: UTCDate) -> Self { + value.timestamp() as u64 + } +} + +impl From for UTCDate { + fn from(value: u64) -> Self { + UTCDate::from_timestamp(value as i64) + } +} + +#[cfg(test)] +mod tests { + use crate::types::date::UTCDate; + use std::str::FromStr; + + #[test] + fn parse_jmap_date() { + for (input, expected_result) in [ + ("1997-11-21T09:55:06-06:00", "1997-11-21T09:55:06-06:00"), + ("1997-11-21T09:55:06+00:00", "1997-11-21T09:55:06Z"), + ("2021-01-01T09:55:06+02:00", "2021-01-01T09:55:06+02:00"), + ("2004-06-28T23:43:45.000Z", "2004-06-28T23:43:45Z"), + ("1997-11-21T09:55:06.123+00:00", "1997-11-21T09:55:06Z"), + ( + "2021-01-01T09:55:06.4567+02:00", + "2021-01-01T09:55:06+02:00", + ), + ] { + let date = UTCDate::from_str(input).unwrap(); + assert_eq!(date.to_string(), expected_result); + + let timestamp = date.timestamp(); + assert_eq!(UTCDate::from_timestamp(timestamp).timestamp(), timestamp); + } + } +} diff --git a/crates/jmap-proto/src/types/mod.rs b/crates/jmap-proto/src/types/mod.rs new file mode 100644 index 0000000..4137574 --- /dev/null +++ b/crates/jmap-proto/src/types/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod date; +pub mod state; diff --git a/crates/jmap-proto/src/types/state.rs b/crates/jmap-proto/src/types/state.rs new file mode 100644 index 0000000..8c2af28 --- /dev/null +++ b/crates/jmap-proto/src/types/state.rs @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use types::ChangeId; +use utils::codec::{ + base32_custom::{Base32Reader, Base32Writer}, + leb128::{Leb128Iterator, Leb128Writer}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JMAPIntermediateState { + pub from_id: ChangeId, + pub to_id: ChangeId, + pub items_sent: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum State { + #[default] + Initial, + Exact(ChangeId), + Intermediate(JMAPIntermediateState), +} + +impl From> for State { + fn from(change_id: Option) -> Self { + match change_id { + Some(change_id) => State::Exact(change_id), + None => State::Initial, + } + } +} + +impl State { + pub fn parse(value: &str) -> Option { + let mut it = value.as_bytes().iter(); + + match it.next()? { + b'n' => Some(State::Initial), + b's' => { + let mut reader = Base32Reader::from_iter(it); + reader + .next_leb128::() + .map(|change_id| (change_id != 0).then_some(change_id).into()) + } + b'r' => { + let mut it = Base32Reader::from_iter(it); + + if let (Some(from_id), Some(to_id), Some(items_sent)) = ( + it.next_leb128::(), + it.next_leb128::(), + it.next_leb128::(), + ) { + if items_sent > 0 { + Some(State::Intermediate(JMAPIntermediateState { + from_id, + to_id: from_id.saturating_add(to_id), + items_sent, + })) + } else { + None + } + } else { + None + } + } + _ => None, + } + } + + pub fn new_initial() -> Self { + State::Initial + } + + pub fn new_exact(id: ChangeId) -> Self { + State::Exact(id) + } + + pub fn new_intermediate(from_id: ChangeId, to_id: ChangeId, items_sent: usize) -> Self { + State::Intermediate(JMAPIntermediateState { + from_id, + to_id, + items_sent, + }) + } + + pub fn get_change_id(&self) -> ChangeId { + match self { + State::Exact(id) => *id, + State::Intermediate(intermediate) => intermediate.to_id, + State::Initial => ChangeId::MAX, + } + } +} + +impl serde::Serialize for State { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for State { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + State::parse(<&str>::deserialize(deserializer)?) + .ok_or_else(|| serde::de::Error::custom("invalid JMAP State")) + } +} + +impl std::fmt::Display for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut writer = Base32Writer::with_capacity(10); + + match self { + State::Initial => { + writer.push_char('n'); + } + State::Exact(id) => { + writer.push_char('s'); + writer.write_leb128(*id).unwrap(); + } + State::Intermediate(intermediate) => { + writer.push_char('r'); + writer.write_leb128(intermediate.from_id).unwrap(); + writer + .write_leb128(intermediate.to_id - intermediate.from_id) + .unwrap(); + writer.write_leb128(intermediate.items_sent).unwrap(); + } + } + + f.write_str(&writer.finalize()) + } +} + +#[cfg(test)] +mod tests { + use super::State; + use types::ChangeId; + + #[test] + fn test_state_id() { + for id in [ + State::new_initial(), + State::new_exact(1), + State::new_exact(12345678), + State::new_exact(ChangeId::MAX), + State::new_intermediate(0, 0, 1), + State::new_intermediate(1024, 2048, 100), + State::new_intermediate(12345678, 87654321, 1), + State::new_intermediate(0, 0, 12345678), + State::new_intermediate(0, 87654321, 12345678), + State::new_intermediate(12345678, 87654321, 1), + State::new_intermediate(12345678, 87654321, 12345678), + State::new_intermediate(ChangeId::MAX, ChangeId::MAX, ChangeId::MAX as usize), + ] { + assert_eq!(State::parse(&id.to_string()).unwrap(), id); + } + } + + #[test] + fn test_state_zero_change_id_is_initial() { + assert_eq!( + State::parse(&State::new_exact(0).to_string()).unwrap(), + State::Initial + ); + assert_eq!(State::from(None), State::Initial); + } +} diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml new file mode 100644 index 0000000..8f40ccb --- /dev/null +++ b/crates/jmap/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "jmap" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +nlp = { path = "../nlp" } +http_proto = { path = "../http-proto" } +jmap_proto = { path = "../jmap-proto" } +types = { path = "../types" } +smtp = { path = "../smtp" } +utils = { path = "../utils" } +common = { path = "../common" } +services = { path = "../services" } +directory = { path = "../directory" } +trc = { path = "../trc" } +spam-filter = { path = "../spam-filter" } +email = { path = "../email" } +groupware = { path = "../groupware" } +registry = { path = "../registry" } +calcard = { version = "0.3" } +smtp-proto = { version = "0.2" } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +mail-builder = { version = "1.0" } +mail-auth = { version = "0.13", features = ["generate", "arc"] } +sieve-rs = { version = "0.7", features = ["rkyv"] } +jmap-tools = { version = "0.1", features = ["rkyv"] } +serde = { version = "1.0", features = ["derive"]} +serde_json = "1.0" +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } +http-body-util = "0.1.5" +tokio = { version = "1.53", features = ["rt"] } +futures-util = "0.3.34" +async-stream = "0.3.6" +base64 = "0.23" +p256 = { version = "0.13", features = ["ecdh"] } +sha1 = "0.11" +sha2 = "0.11" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]} +tokio-tungstenite = "0.30" +tungstenite = "0.30" +chrono = "0.4" +rand = "0.10.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } +hashify = "0.2" + +[features] +test_mode = [] +dev_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/jmap/src/addressbook/get.rs b/crates/jmap/src/addressbook/get.rs new file mode 100644 index 0000000..74624b6 --- /dev/null +++ b/crates/jmap/src/addressbook/get.rs @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{cache::GroupwareCache, contact::AddressBook}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::addressbook::{self, AddressBookProperty, AddressBookValue}, +}; +use jmap_tools::{Map, Value}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, + field::PrincipalField, +}; + +pub trait AddressBookGet: Sync + Send { + fn address_book_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl AddressBookGet for Server { + async fn address_book_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsDefault, + AddressBookProperty::IsSubscribed, + AddressBookProperty::ShareWith, + AddressBookProperty::MyRights, + ]); + let account_id = request.account_id.document_id(); + let personal_id = access_token.personal_id(account_id, Collection::AddressBook); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + let address_book_ids = if access_token.is_member(account_id) { + cache.document_ids(true).collect::() + } else { + cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) + }; + let default_address_book_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) + .await + .caused_by(trc::location!())? + .or_else(|| cache.document_ids(true).min()); + + let ids = if let Some(ids) = ids { + ids + } else { + address_book_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(true).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the address_book object + let document_id = id.document_id(); + if !address_book_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + let _address_book = if let Some(address_book) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::AddressBook, + document_id, + )) + .await? + { + address_book + } else { + response.push_not_found(id); + continue; + }; + let address_book = _address_book + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + AddressBookProperty::Id => { + result.insert_unchecked(AddressBookProperty::Id, AddressBookValue::Id(id)); + } + AddressBookProperty::Name => { + result.insert_unchecked( + AddressBookProperty::Name, + address_book.preferences(personal_id).name.to_string(), + ); + } + AddressBookProperty::Description => { + result.insert_unchecked( + AddressBookProperty::Description, + address_book + .preferences(personal_id) + .description + .as_ref() + .map(|v| v.to_string()), + ); + } + AddressBookProperty::SortOrder => { + result.insert_unchecked( + AddressBookProperty::SortOrder, + address_book.preferences(personal_id).sort_order.to_native(), + ); + } + AddressBookProperty::IsDefault => { + result.insert_unchecked( + AddressBookProperty::IsDefault, + default_address_book_id == Some(document_id), + ); + } + AddressBookProperty::IsSubscribed => { + result.insert_unchecked( + AddressBookProperty::IsSubscribed, + address_book + .subscribers + .iter() + .any(|subscriber| *subscriber == personal_id), + ); + } + AddressBookProperty::ShareWith => { + result.insert_unchecked( + AddressBookProperty::ShareWith, + JmapRights::share_with::( + account_id, + access_token, + &address_book + .acls + .iter() + .map(AclGrant::from) + .collect::>(), + ), + ); + } + AddressBookProperty::MyRights => { + result.insert_unchecked( + AddressBookProperty::MyRights, + if access_token.is_shared(account_id) { + JmapRights::rights::( + address_book.acls.effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + }, + ); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/addressbook/mod.rs b/crates/jmap/src/addressbook/mod.rs new file mode 100644 index 0000000..f460aba --- /dev/null +++ b/crates/jmap/src/addressbook/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod set; diff --git a/crates/jmap/src/addressbook/set.rs b/crates/jmap/src/addressbook/set.rs new file mode 100644 index 0000000..9086aa8 --- /dev/null +++ b/crates/jmap/src/addressbook/set.rs @@ -0,0 +1,501 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::acl::{JmapAcl, JmapRights}; +use crate::changes::state::JmapCacheState; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + contact::{AddressBook, AddressBookPreferences, ContactCard}, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::addressbook::{self, AddressBookProperty, AddressBookValue}, + request::{MaybeInvalid, reference::MaybeIdReference}, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use rand::{RngExt, distr::Alphanumeric}; +use store::{ + SerializeInfallible, ValueKey, + ahash::AHashSet, + write::{AlignedBytes, Archive, BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::PrincipalField, + id::Id, +}; + +pub trait AddressBookSet: Sync + Send { + fn address_book_set( + &self, + request: SetRequest<'_, addressbook::AddressBook>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl AddressBookSet for Server { + async fn address_book_set( + &self, + mut request: SetRequest<'_, addressbook::AddressBook>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(true, &request.if_in_state)?); + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let is_shared = access_token.is_shared(account_id); + let mut set_default = None; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + if is_shared { + response.not_created.append( + id, + SetError::forbidden() + .with_description("Cannot create address books in a shared account."), + ); + continue 'create; + } + + let mut address_book = AddressBook { + name: rand::rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(), + preferences: vec![AddressBookPreferences { + account_id, + name: "Address Book".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + // Process changes + if let Err(err) = + update_address_book(None, object, &mut address_book, access_token, account_id) + { + response.not_created.append(id, err); + continue 'create; + } + + // Validate ACLs + if !address_book.acls.is_empty() { + if let Err(err) = self.acl_validate(&address_book.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + + self.refresh_acls(&address_book.acls, None) + .await + .caused_by(trc::location!())?; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::AddressBook, 1) + .await + .caused_by(trc::location!())?; + address_book + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + if let Some(MaybeIdReference::Reference(id_ref)) = + &request.arguments.on_success_set_is_default + && id_ref == &id + { + set_default = Some(document_id); + } + + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain address book + let document_id = id.document_id(); + let address_book_ = if let Some(address_book_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::AddressBook, + document_id, + )) + .await? + { + address_book_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let address_book = address_book_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_address_book = address_book + .deserialize::() + .caused_by(trc::location!())?; + + // Apply changes + let has_acl_changes = match update_address_book( + Some(id), + object, + &mut new_address_book, + access_token, + account_id, + ) { + Ok(has_acl_changes_) => has_acl_changes_, + Err(err) => { + response.not_updated.append(id, err); + continue 'update; + } + }; + + // Validate ACL + if is_shared { + let acl = address_book.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this address book."), + ); + continue 'update; + } + } + if has_acl_changes { + if let Err(err) = self.acl_validate(&new_address_book.acls).await { + response.not_updated.append(id, err.into()); + continue 'update; + } + self.refresh_archived_acls( + &new_address_book.acls, + address_book.inner.acls.as_slice(), + ) + .await + .caused_by(trc::location!())?; + } + + // Update record + new_address_book + .update( + access_token.account_tenant_ids(), + address_book, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + response.updated.append(id, None); + } + + // Process deletions + let mut reset_default_address_book = false; + if !will_destroy.is_empty() { + let mut destroy_children = AHashSet::new(); + let mut destroy_parents = AHashSet::new(); + let default_address_book_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) + .await + .caused_by(trc::location!())?; + + let on_destroy_remove_contents = request + .arguments + .on_destroy_remove_contents + .unwrap_or(false); + + for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(address_book_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::AddressBook, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let address_book = address_book_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if is_shared + && !address_book + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this address book."), + ); + continue; + } + + // Obtain children ids + let children_ids = cache.children_ids(document_id).collect::>(); + if !children_ids.is_empty() && !on_destroy_remove_contents { + response + .not_destroyed + .append(id, SetError::address_book_has_contents()); + continue; + } + destroy_children.extend(children_ids.iter().copied()); + destroy_parents.insert(document_id); + + // Delete record + let delete_path = cache + .container_resource_path_by_id(document_id) + .map(|resource| cache.format_resource(resource)); + DestroyArchive(address_book) + .delete( + access_token.account_tenant_ids(), + account_id, + document_id, + delete_path, + &mut batch, + ) + .caused_by(trc::location!())?; + + if default_address_book_id == Some(document_id) { + reset_default_address_book = true; + } + + response.destroyed.push(id); + } + + // Delete children + if !destroy_children.is_empty() { + for document_id in destroy_children { + if let Some(card_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await? + { + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + if card + .inner + .names + .iter() + .all(|n| destroy_parents.contains(&n.parent_id.to_native())) + { + // Card only belongs to address books being deleted, delete it + DestroyArchive(card).delete_all( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + )?; + } else { + // Unlink addressbook id from card + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card + .names + .retain(|n| !destroy_parents.contains(&n.parent_id)); + new_card.update( + access_token.account_tenant_ids(), + card, + account_id, + document_id, + &mut batch, + )?; + } + } + } + } + } + + // Set default address book + if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_set_is_default { + set_default = Some(id.document_id()); + } + if let Some(default_address_book_id) = set_default { + if response.not_created.is_empty() + && response.not_updated.is_empty() + && response.not_destroyed.is_empty() + { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .set( + PrincipalField::DefaultAddressBookId, + default_address_book_id.serialize(), + ); + } + } else if reset_default_address_book { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultAddressBookId); + } + + // Write changes + if !batch.is_empty() + && let Ok(change_id) = self + .commit_batch(batch) + .await + .caused_by(trc::location!())? + .last_change_id(account_id) + { + self.notify_task_queue(); + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +fn update_address_book( + expected_id: Option, + updates: Value<'_, AddressBookProperty, AddressBookValue>, + address_book: &mut AddressBook, + access_token: &AccessToken, + account_id: u32, +) -> Result> { + let personal_id = access_token.personal_id(account_id, Collection::AddressBook); + let mut has_acl_changes = false; + + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (AddressBookProperty::Name, Value::Str(value)) if (1..=255).contains(&value.len()) => { + address_book.preferences_mut(personal_id).name = value.into_owned(); + } + (AddressBookProperty::Description, Value::Str(value)) if value.len() < 255 => { + address_book.preferences_mut(personal_id).description = value.into_owned().into(); + } + (AddressBookProperty::Description, Value::Null) => { + address_book.preferences_mut(personal_id).description = None; + } + (AddressBookProperty::SortOrder, Value::Number(value)) => { + address_book.preferences_mut(personal_id).sort_order = value.cast_to_u64() as u32; + } + (AddressBookProperty::IsSubscribed, Value::Bool(subscribe)) => { + if subscribe { + if !address_book.subscribers.contains(&personal_id) { + address_book.subscribers.push(personal_id); + } + } else { + address_book.subscribers.retain(|id| *id != personal_id); + } + } + (AddressBookProperty::ShareWith, value) => { + address_book.acls = JmapRights::acl_set::(value)?; + has_acl_changes = true; + } + (AddressBookProperty::Pointer(pointer), value) + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + AddressBookProperty::ShareWith + ))) + ) => + { + let mut pointer = pointer.iter(); + pointer.next(); + + address_book.acls = JmapRights::acl_patch::( + std::mem::take(&mut address_book.acls), + pointer, + value, + )?; + has_acl_changes = true; + } + (AddressBookProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(AddressBookProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + } + + // Validate name + if address_book.preferences(personal_id).name.is_empty() { + return Err(SetError::invalid_properties() + .with_property(AddressBookProperty::Name) + .with_description("Missing name.")); + } + + Ok(has_acl_changes) +} diff --git a/crates/jmap/src/api/acl.rs b/crates/jmap/src/api/acl.rs new file mode 100644 index 0000000..2667bf7 --- /dev/null +++ b/crates/jmap/src/api/acl.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use jmap_proto::{ + error::set::SetError, + object::{JmapRight, JmapSharedObject}, +}; +use jmap_tools::{JsonPointerIter, Key, Map, Property, Value}; +use registry::schema::prelude::ObjectType; +use store::{registry::RegistryQuery, roaring::RoaringBitmap}; +use types::{ + acl::{Acl, AclGrant}, + id::Id, +}; +use utils::map::bitmap::Bitmap; + +pub struct JmapRights; + +impl JmapRights { + pub fn acl_set( + value: Value<'_, T::Property, T::Element>, + ) -> Result, SetError> + where + Id: TryFrom, + T::Right: TryFrom, + { + let mut grants = Vec::new(); + + for (key, value) in value.into_expanded_object() { + let account_id = key + .try_into_property() + .and_then(|p| Id::try_from(p).ok()) + .ok_or_else(|| { + SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description("Invalid account id.") + })? + .document_id(); + + if !grants + .iter() + .any(|item: &AclGrant| item.account_id == account_id) + { + let acls = Self::map_acls::(value)?; + if !acls.is_empty() { + grants.push(AclGrant { + account_id, + grants: acls, + }); + } + } + } + + Ok(grants) + } + + pub fn acl_patch( + mut grants: Vec, + mut path: JsonPointerIter<'_, T::Property>, + value: Value<'_, T::Property, T::Element>, + ) -> Result, SetError> + where + Id: TryFrom, + T::Right: TryFrom, + { + let account_id = path + .next() + .and_then(|item| item.as_property_key()) + .cloned() + .and_then(|p| Id::try_from(p).ok()) + .ok_or_else(|| { + SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description("Invalid account id.") + })? + .document_id(); + + if let Some(right) = path.next() { + if path.next().is_some() { + return Err(SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description("Invalid path for ACL patch.")); + } + + let is_set = match value { + Value::Bool(is_set) => is_set, + Value::Null => false, + _ => { + return Err(SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description("Invalid ACL value.")); + } + }; + + let acl = right + .as_property_key() + .cloned() + .and_then(|p| T::Right::try_from(p).ok()) + .ok_or_else(|| { + SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description(format!( + "Invalid permission {:?}.", + right.to_cow().unwrap_or_default() + )) + })? + .to_acl() + .iter() + .copied(); + + if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id) { + if is_set { + acl_item.grants.insert_many(acl); + } else { + acl_item.grants.remove_many(acl); + if acl_item.grants.is_empty() { + grants.retain(|item| item.account_id != account_id); + } + } + } else if is_set { + grants.push(AclGrant { + account_id, + grants: Bitmap::from_iter(acl), + }); + } + } else { + let acls = Self::map_acls::(value)?; + if !acls.is_empty() { + if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id) + { + acl_item.grants = acls; + } else { + grants.push(AclGrant { + account_id, + grants: acls, + }); + } + } else { + grants.retain(|item| item.account_id != account_id); + } + } + + Ok(grants) + } + + fn map_acls( + value: Value<'_, T::Property, T::Element>, + ) -> Result, SetError> + where + Id: TryFrom, + T::Right: TryFrom, + { + let mut acls = Bitmap::new(); + + for key in value.into_expanded_boolean_set() { + acls.insert_many( + key.as_property() + .and_then(|p| T::Right::try_from(p.clone()).ok()) + .ok_or_else(|| { + SetError::invalid_properties() + .with_property(T::SHARE_WITH_PROPERTY) + .with_description(format!("Invalid permission {:?}.", key.to_string())) + })? + .to_acl() + .iter() + .copied(), + ); + } + + Ok(acls) + } + + pub fn all_rights() -> Value<'static, T::Property, T::Element> { + let rights = T::Right::all_rights(); + let mut obj = Map::with_capacity(rights.len()); + + for right in rights { + obj.insert_unchecked(Key::Property((*right).into()), Value::Bool(true)); + } + + Value::Object(obj) + } + + pub fn rights( + acls: Bitmap, + ) -> Value<'static, T::Property, T::Element> { + let mut obj = Map::with_capacity(3); + + for right in T::Right::all_rights() { + obj.insert_unchecked( + Key::Property((*right).into()), + Value::Bool(right.to_acl().iter().all(|acl| acls.contains(*acl))), + ); + } + + Value::Object(obj) + } + + pub fn share_with( + account_id: u32, + access_token: &AccessToken, + grants: &[AclGrant], + ) -> Value<'static, T::Property, T::Element> + where + T::Property: From, + { + if access_token.is_member(account_id) + || grants.effective_acl(access_token).contains(Acl::Share) + { + let mut share_with = Map::with_capacity(grants.len()); + for grant in grants { + share_with.insert_unchecked( + Key::Property(Id::from(grant.account_id).into()), + Self::rights::(grant.grants), + ); + } + + Value::Object(share_with) + } else { + Value::Null + } + } +} + +pub trait JmapAcl { + fn acl_validate( + &self, + grants: &[AclGrant], + ) -> impl Future> + Send; +} + +pub enum ShareValidationError { + MaxSharesExceeded(usize), + InvalidAccountId(Id), +} + +impl JmapAcl for Server { + async fn acl_validate(&self, grants: &[AclGrant]) -> Result<(), ShareValidationError> { + if grants.len() > self.core.groupware.max_shares_per_item { + return Err(ShareValidationError::MaxSharesExceeded( + self.core.groupware.max_shares_per_item, + )); + } + + let principal_ids = self + .registry() + .query::(RegistryQuery::new(ObjectType::Account)) + .await + .unwrap_or_default(); + + for grant in grants { + if !principal_ids.contains(grant.account_id) { + return Err(ShareValidationError::InvalidAccountId(Id::from( + grant.account_id, + ))); + } + } + + Ok(()) + } +} + +impl From for SetError { + fn from(err: ShareValidationError) -> Self { + match err { + ShareValidationError::MaxSharesExceeded(max) => SetError::invalid_properties() + .with_description(format!( + "Maximum number of shares per item exceeded (max: {max})" + )), + ShareValidationError::InvalidAccountId(id) => SetError::invalid_properties() + .with_description(format!("Account id {id} is invalid.")), + } + } +} diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs new file mode 100644 index 0000000..043411c --- /dev/null +++ b/crates/jmap/src/api/auth.rs @@ -0,0 +1,356 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::auth::AccessToken; +use jmap_proto::{ + method::set::SetRequest, + object::JmapObject, + request::{ + CopyRequestMethod, GetRequestMethod, ParseRequestMethod, QueryChangesRequestMethod, + QueryRequestMethod, RequestMethod, SetRequestMethod, method::MethodObject, + reference::MaybeResultReference, + }, +}; +use registry::schema::enums::Permission; +use types::{collection::Collection, id::Id}; + +pub trait JmapAuthorization { + fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>; + fn assert_has_jmap_permission( + &self, + request: &RequestMethod, + object: MethodObject, + ) -> trc::Result<()>; + fn assert_has_access(&self, to_account_id: Id, to_collection: Collection) + -> trc::Result<&Self>; +} + +impl JmapAuthorization for AccessToken { + fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> { + if self.is_member(account_id.document_id()) { + Ok(self) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details(format!("You are not an owner of account {}", account_id))) + } + } + + fn assert_has_access( + &self, + to_account_id: Id, + to_collection: Collection, + ) -> trc::Result<&Self> { + if self.has_access(to_account_id.document_id(), to_collection) { + Ok(self) + } else { + Err(trc::JmapEvent::Forbidden.into_err().details(format!( + "You do not have access to account {}", + to_account_id + ))) + } + } + + fn assert_has_jmap_permission( + &self, + request: &RequestMethod, + object: MethodObject, + ) -> trc::Result<()> { + let permission = match request { + RequestMethod::Get(m) => match &m { + GetRequestMethod::Email(_) => Permission::JmapEmailGet, + GetRequestMethod::Mailbox(_) => Permission::JmapMailboxGet, + GetRequestMethod::Thread(_) => Permission::JmapThreadGet, + GetRequestMethod::Identity(_) => Permission::JmapIdentityGet, + GetRequestMethod::EmailSubmission(_) => Permission::JmapEmailSubmissionGet, + GetRequestMethod::PushSubscription(_) => Permission::JmapPushSubscriptionGet, + GetRequestMethod::Sieve(_) => Permission::JmapSieveScriptGet, + GetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseGet, + GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, + GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, + GetRequestMethod::Blob(_) => Permission::JmapBlobGet, + GetRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet, + GetRequestMethod::ContactCard(_) => Permission::JmapContactCardGet, + GetRequestMethod::FileNode(_) => Permission::JmapFileNodeGet, + GetRequestMethod::PrincipalAvailability(_) => { + Permission::JmapPrincipalGetAvailability + } + GetRequestMethod::Calendar(_) => Permission::JmapCalendarGet, + GetRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventGet, + GetRequestMethod::CalendarEventNotification(_) => { + Permission::JmapCalendarEventNotificationGet + } + GetRequestMethod::ParticipantIdentity(_) => Permission::JmapParticipantIdentityGet, + GetRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationGet, + GetRequestMethod::Registry(_) => { + let MethodObject::Registry(object_type) = object else { + unreachable!() + }; + object_type.get_permission() + } + }, + RequestMethod::Set(m) => { + return match &m { + SetRequestMethod::Email(s) => validate_set( + s, + self, + Permission::JmapEmailCreate, + Permission::JmapEmailUpdate, + Permission::JmapEmailDestroy, + ), + SetRequestMethod::Mailbox(s) => validate_set( + s, + self, + Permission::JmapMailboxCreate, + Permission::JmapMailboxUpdate, + Permission::JmapMailboxDestroy, + ), + SetRequestMethod::Identity(s) => validate_set( + s, + self, + Permission::JmapIdentityCreate, + Permission::JmapIdentityUpdate, + Permission::JmapIdentityDestroy, + ), + SetRequestMethod::EmailSubmission(s) => validate_set( + s, + self, + Permission::JmapEmailSubmissionCreate, + Permission::JmapEmailSubmissionUpdate, + Permission::JmapEmailSubmissionDestroy, + ), + SetRequestMethod::PushSubscription(s) => validate_set( + s, + self, + Permission::JmapPushSubscriptionCreate, + Permission::JmapPushSubscriptionUpdate, + Permission::JmapPushSubscriptionDestroy, + ), + SetRequestMethod::Sieve(s) => validate_set( + s, + self, + Permission::JmapSieveScriptCreate, + Permission::JmapSieveScriptUpdate, + Permission::JmapSieveScriptDestroy, + ), + SetRequestMethod::VacationResponse(s) => validate_set( + s, + self, + Permission::JmapVacationResponseCreate, + Permission::JmapVacationResponseUpdate, + Permission::JmapVacationResponseDestroy, + ), + SetRequestMethod::AddressBook(s) => validate_set( + s, + self, + Permission::JmapAddressBookCreate, + Permission::JmapAddressBookUpdate, + Permission::JmapAddressBookDestroy, + ), + SetRequestMethod::ContactCard(s) => validate_set( + s, + self, + Permission::JmapContactCardCreate, + Permission::JmapContactCardUpdate, + Permission::JmapContactCardDestroy, + ), + SetRequestMethod::FileNode(s) => validate_set( + s, + self, + Permission::JmapFileNodeCreate, + Permission::JmapFileNodeUpdate, + Permission::JmapFileNodeDestroy, + ), + SetRequestMethod::ShareNotification(s) => validate_set( + s, + self, + Permission::JmapShareNotificationCreate, + Permission::JmapShareNotificationUpdate, + Permission::JmapShareNotificationDestroy, + ), + SetRequestMethod::Calendar(s) => validate_set( + s, + self, + Permission::JmapCalendarCreate, + Permission::JmapCalendarUpdate, + Permission::JmapCalendarDestroy, + ), + SetRequestMethod::CalendarEvent(s) => validate_set( + s, + self, + Permission::JmapCalendarEventCreate, + Permission::JmapCalendarEventUpdate, + Permission::JmapCalendarEventDestroy, + ), + SetRequestMethod::CalendarEventNotification(s) => validate_set( + s, + self, + Permission::JmapCalendarEventNotificationCreate, + Permission::JmapCalendarEventNotificationUpdate, + Permission::JmapCalendarEventNotificationDestroy, + ), + SetRequestMethod::ParticipantIdentity(s) => validate_set( + s, + self, + Permission::JmapParticipantIdentityCreate, + Permission::JmapParticipantIdentityUpdate, + Permission::JmapParticipantIdentityDestroy, + ), + SetRequestMethod::Registry(s) => { + let MethodObject::Registry(object_type) = object else { + unreachable!() + }; + let set_permissions = object_type.set_permission(); + validate_set( + s, + self, + set_permissions[0], + set_permissions[1], + set_permissions[2], + ) + } + }; + } + RequestMethod::Changes(_) => match object { + MethodObject::Email => Permission::JmapEmailChanges, + MethodObject::Mailbox => Permission::JmapMailboxChanges, + MethodObject::Thread => Permission::JmapThreadChanges, + MethodObject::Identity => Permission::JmapIdentityChanges, + MethodObject::EmailSubmission => Permission::JmapEmailSubmissionChanges, + MethodObject::Quota => Permission::JmapQuotaChanges, + MethodObject::ContactCard => Permission::JmapContactCardChanges, + MethodObject::FileNode => Permission::JmapFileNodeChanges, + MethodObject::Calendar => Permission::JmapCalendarChanges, + MethodObject::CalendarEvent => Permission::JmapCalendarEventChanges, + MethodObject::CalendarEventNotification => { + Permission::JmapCalendarEventNotificationChanges + } + MethodObject::ParticipantIdentity => Permission::JmapParticipantIdentityChanges, + MethodObject::ShareNotification => Permission::JmapShareNotificationChanges, + MethodObject::Principal => Permission::JmapPrincipalChanges, + MethodObject::AddressBook => Permission::JmapAddressBookChanges, + MethodObject::Core + | MethodObject::Blob + | MethodObject::PushSubscription + | MethodObject::SearchSnippet + | MethodObject::VacationResponse + | MethodObject::SieveScript + | MethodObject::Registry(_) => Permission::JmapEmailChanges, + }, + RequestMethod::Copy(m) => match &m { + CopyRequestMethod::Email(_) => Permission::JmapEmailCopy, + CopyRequestMethod::Blob(_) => Permission::JmapBlobCopy, + CopyRequestMethod::ContactCard(_) => Permission::JmapContactCardCopy, + CopyRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventCopy, + CopyRequestMethod::FileNode(_) => Permission::JmapFileNodeCopy, + }, + RequestMethod::ImportEmail(_) => Permission::JmapEmailImport, + RequestMethod::Parse(m) => match &m { + ParseRequestMethod::Email(_) => Permission::JmapEmailParse, + ParseRequestMethod::ContactCard(_) => Permission::JmapContactCardParse, + ParseRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventParse, + }, + RequestMethod::QueryChanges(m) => match m { + QueryChangesRequestMethod::Email(_) => Permission::JmapEmailQueryChanges, + QueryChangesRequestMethod::Mailbox(_) => Permission::JmapMailboxQueryChanges, + QueryChangesRequestMethod::EmailSubmission(_) => { + Permission::JmapEmailSubmissionQueryChanges + } + QueryChangesRequestMethod::Principal(_) => Permission::JmapPrincipalQueryChanges, + QueryChangesRequestMethod::Quota(_) => Permission::JmapQuotaQueryChanges, + QueryChangesRequestMethod::ContactCard(_) => { + Permission::JmapContactCardQueryChanges + } + QueryChangesRequestMethod::FileNode(_) => Permission::JmapFileNodeQueryChanges, + QueryChangesRequestMethod::CalendarEvent(_) => { + Permission::JmapCalendarEventQueryChanges + } + QueryChangesRequestMethod::CalendarEventNotification(_) => { + Permission::JmapCalendarEventNotificationQueryChanges + } + QueryChangesRequestMethod::ShareNotification(_) => { + Permission::JmapShareNotificationQueryChanges + } + }, + RequestMethod::Query(m) => match m { + QueryRequestMethod::Email(_) => Permission::JmapEmailQuery, + QueryRequestMethod::Mailbox(_) => Permission::JmapMailboxQuery, + QueryRequestMethod::EmailSubmission(_) => Permission::JmapEmailSubmissionQuery, + QueryRequestMethod::Sieve(_) => Permission::JmapSieveScriptQuery, + QueryRequestMethod::Principal(_) => Permission::JmapPrincipalQuery, + QueryRequestMethod::Quota(_) => Permission::JmapQuotaQuery, + QueryRequestMethod::AddressBook(_) => Permission::JmapAddressBookGet, + QueryRequestMethod::ContactCard(_) => Permission::JmapContactCardQuery, + QueryRequestMethod::FileNode(_) => Permission::JmapFileNodeQuery, + QueryRequestMethod::Calendar(_) => Permission::JmapCalendarGet, + QueryRequestMethod::CalendarEvent(_) => Permission::JmapCalendarEventQuery, + QueryRequestMethod::CalendarEventNotification(_) => { + Permission::JmapCalendarEventNotificationQuery + } + QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery, + QueryRequestMethod::Registry(_) => { + let MethodObject::Registry(object_type) = object else { + unreachable!() + }; + object_type.query_permission() + } + }, + RequestMethod::SearchSnippet(_) => Permission::JmapSearchSnippetGet, + RequestMethod::ValidateScript(_) => Permission::JmapSieveScriptValidate, + RequestMethod::LookupBlob(_) => Permission::JmapBlobLookup, + RequestMethod::UploadBlob(_) => Permission::JmapBlobUpload, + RequestMethod::Echo(_) => Permission::JmapCoreEcho, + RequestMethod::Error(_) => return Ok(()), + }; + + if self.has_permission(permission) { + Ok(()) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to perform this action")) + } + } +} + +fn validate_set( + set: &SetRequest<'_, T>, + access_token: &AccessToken, + create_permission: Permission, + update_permission: Permission, + destroy_permission: Permission, +) -> trc::Result<()> { + let can_create = access_token.has_permission(create_permission); + let can_update = access_token.has_permission(update_permission); + let can_destroy = access_token.has_permission(destroy_permission); + + if can_create && can_update && can_destroy { + Ok(()) + } else if !can_create && !can_update && !can_destroy { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to create, update or destroy objects of this type")) + } else if !can_create && set.create.as_ref().is_some_and(|objs| !objs.is_empty()) { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to create objects of this type")) + } else if !can_update && set.update.as_ref().is_some_and(|objs| !objs.is_empty()) { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to update objects of this type")) + } else if !can_destroy + && set.destroy.as_ref().is_some_and(|objs| match objs { + MaybeResultReference::Value(v) => !v.is_empty(), + MaybeResultReference::Reference(_) => true, + }) + { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to destroy objects of this type")) + } else { + Ok(()) + } +} diff --git a/crates/jmap/src/api/event_source.rs b/crates/jmap/src/api/event_source.rs new file mode 100644 index 0000000..d27483c --- /dev/null +++ b/crates/jmap/src/api/event_source.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::IntoPushObject; +use common::{LONG_1D_SLUMBER, Server, auth::AccessToken, ipc::PushNotification}; +use http_body_util::{StreamBody, combinators::BoxBody}; +use http_proto::*; +use hyper::{ + StatusCode, + body::{Bytes, Frame}, +}; +use jmap_proto::{response::status::PushObject, types::state::State}; +use std::time::{Duration, Instant}; +use std::{future::Future, str::FromStr}; +use types::{id::Id, type_state::DataType}; +use utils::map::{bitmap::Bitmap, vec_map::VecMap}; + +struct Ping { + interval: Duration, + last_ping: Instant, + payload: Bytes, +} + +pub trait EventSourceHandler: Sync + Send { + fn handle_event_source( + &self, + req: HttpRequest, + access_token: AccessToken, + ) -> impl Future> + Send; +} + +impl EventSourceHandler for Server { + async fn handle_event_source( + &self, + req: HttpRequest, + access_token: AccessToken, + ) -> trc::Result { + // Parse query + let mut ping = 0; + let mut types = Bitmap::default(); + let mut close_after_state = false; + + for (key, value) in + http_proto::form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()) + { + hashify::fnc_map!(key.as_bytes(), + "types" => { + for type_state in value.split(',') { + if type_state == "*" { + types = Bitmap::all(); + break; + } else if let Ok(type_state) = DataType::from_str(type_state) { + types.insert(type_state); + } else { + return Err(trc::ResourceEvent::BadParameters.into_err()); + } + } + }, + "closeafter" => match value.as_ref() { + "state" => { + close_after_state = true; + } + "no" => {} + _ => return Err(trc::ResourceEvent::BadParameters.into_err()), + }, + "ping" => match value.parse::() { + Ok(value) => { + ping = value; + } + Err(_) => return Err(trc::ResourceEvent::BadParameters.into_err()), + }, + _ => {} + ); + } + + let mut ping = if ping > 0 { + #[cfg(not(feature = "test_mode"))] + let interval = std::cmp::max(ping, 30); + #[cfg(feature = "test_mode")] + let interval = ping; + let interval_duration = Duration::from_secs(interval as u64); + + Ping { + interval: interval_duration, + last_ping: Instant::now() - interval_duration, + payload: Bytes::from(format!( + "event: ping\ndata: {{\"interval\": {}}}\n\n", + interval + )), + } + .into() + } else { + None + }; + + // Register with push manager + let mut push_rx = self.subscribe_push_manager(&access_token, types).await?; + let mut changed: VecMap> = VecMap::new(); + let throttle = self.core.jmap.event_source_throttle; + + Ok(HttpResponse::new(StatusCode::OK) + .with_content_type("text/event-stream") + .with_cache_control("no-store") + .with_stream_body(BoxBody::new(StreamBody::new(async_stream::stream! { + let mut last_message = Instant::now() - throttle; + let mut timeout = + ping.as_ref().map(|p| p.interval).unwrap_or(LONG_1D_SLUMBER); + + loop { + match tokio::time::timeout(timeout, push_rx.recv()).await { + Ok(Some(notification)) => { + match notification { + PushNotification::StateChange(state_change) => { + for type_state in state_change.types { + changed + .get_mut_or_insert(state_change.account_id.into()) + .set(type_state, State::Exact(state_change.change_id)); + } + } + PushNotification::CalendarAlert(calendar_alert) => { + yield Ok(Frame::data(Bytes::from(format!( + "event: calendarAlert\ndata: {}\n\n", + serde_json::to_string(&calendar_alert.into_push_object()).unwrap() + )))); + } + PushNotification::EmailPush(email_push) => { + let state_change = email_push.to_state_change(); + for type_state in state_change.types { + changed + .get_mut_or_insert(state_change.account_id.into()) + .set(type_state, State::Exact(state_change.change_id)); + } + } + } + } + Ok(None) => { + break; + } + Err(_) => (), + } + + timeout = if !changed.is_empty() { + let elapsed = last_message.elapsed(); + if elapsed >= throttle { + last_message = Instant::now(); + let response = + PushObject::StateChange { changed: std::mem::take(&mut changed) }; + + yield Ok(Frame::data(Bytes::from(format!( + "event: state\ndata: {}\n\n", + serde_json::to_string(&response).unwrap() + )))); + + if close_after_state { + break; + } + + ping.as_ref().map(|p| p.interval).unwrap_or(LONG_1D_SLUMBER) + } else { + throttle - elapsed + } + } else if let Some(ping) = &mut ping { + let elapsed = ping.last_ping.elapsed(); + if elapsed >= ping.interval { + ping.last_ping = Instant::now(); + yield Ok(Frame::data(ping.payload.clone())); + ping.interval + } else { + ping.interval - elapsed + } + } else { + LONG_1D_SLUMBER + }; + } + })))) + } +} diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs new file mode 100644 index 0000000..62377d0 --- /dev/null +++ b/crates/jmap/src/api/mod.rs @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::blob::UploadResponse; +use calcard::jscalendar::JSCalendarDateTime; +use common::ipc::{CalendarAlert, PushNotification}; +use http_proto::{HttpResponse, JsonResponse, ToHttpResponse}; +use hyper::StatusCode; +use jmap_proto::{ + error::request::{RateLimitPolicy, RateLimitUnit, RequestError, RequestLimitError}, + request::capability::Session, + response::{Response, status::PushObject}, + types::state::State, +}; +use types::{id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +pub mod acl; +pub mod auth; +pub mod event_source; +pub mod query; +pub mod request; +pub mod session; + +impl ToHttpResponse for UploadResponse { + fn into_http_response(self) -> HttpResponse { + JsonResponse::new(self).into_http_response() + } +} + +pub trait ToJmapHttpResponse { + fn into_http_response(self) -> HttpResponse; +} + +impl ToJmapHttpResponse for Response<'_> { + fn into_http_response(self) -> HttpResponse { + JsonResponse::new(self).into_http_response() + } +} + +impl ToJmapHttpResponse for Session { + fn into_http_response(self) -> HttpResponse { + JsonResponse::new(self).into_http_response() + } +} + +impl ToJmapHttpResponse for RequestError<'_> { + fn into_http_response(self) -> HttpResponse { + let mut response = + HttpResponse::new(StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_REQUEST)); + if let Some(retry_after) = self.retry_after { + response = response.with_header("Retry-After", retry_after.to_string()); + } + if let Some(policy) = self.rate_limit_policy_header() { + response = response.with_header("RateLimit-Policy", policy); + } + if let Some(state) = self.rate_limit_state_header() { + response = response.with_header("RateLimit", state); + } + response + .with_content_type("application/problem+json") + .with_text_body(serde_json::to_string(&self).unwrap_or_default()) + } +} + +pub trait ToRequestError { + fn to_request_error(&self) -> RequestError<'_>; +} + +impl ToRequestError for trc::Error { + fn to_request_error(&self) -> RequestError<'_> { + let details_or_reason = self + .value(trc::Key::Details) + .or_else(|| self.value(trc::Key::Reason)) + .and_then(|v| v.as_str()); + let details = details_or_reason.unwrap_or_else(|| self.as_ref().message()); + + match self.as_ref() { + trc::EventType::Jmap(cause) => match cause { + trc::JmapEvent::UnknownCapability => RequestError::unknown_capability(details), + trc::JmapEvent::NotJson => RequestError::not_json(details), + trc::JmapEvent::NotRequest => RequestError::not_request(details), + _ => RequestError::invalid_parameters(), + }, + trc::EventType::Limit(cause) => { + let reset = self.value(trc::Key::Expires).and_then(|v| v.to_uint()); + let limit = self.value(trc::Key::Limit).and_then(|v| v.to_uint()); + let total = self.value(trc::Key::Total).and_then(|v| v.to_uint()); + let size = self.value(trc::Key::Size).and_then(|v| v.to_uint()); + + match cause { + trc::LimitEvent::SizeRequest => { + RequestError::limit(RequestLimitError::SizeRequest) + } + trc::LimitEvent::SizeUpload => { + RequestError::limit(RequestLimitError::SizeUpload) + } + trc::LimitEvent::CallsIn => RequestError::limit(RequestLimitError::CallsIn), + trc::LimitEvent::ConcurrentRequest | trc::LimitEvent::ConcurrentConnection => { + let mut policy = + RateLimitPolicy::new("concurrent-requests", limit.unwrap_or(0)) + .with_unit(RateLimitUnit::ConcurrentRequests); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + RequestError::limit(RequestLimitError::ConcurrentRequest) + .with_rate_limit(policy) + } + trc::LimitEvent::ConcurrentUpload => { + let mut policy = + RateLimitPolicy::new("concurrent-uploads", limit.unwrap_or(0)) + .with_unit(RateLimitUnit::ConcurrentRequests); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + RequestError::limit(RequestLimitError::ConcurrentUpload) + .with_rate_limit(policy) + } + trc::LimitEvent::Quota => RequestError::over_quota(), + trc::LimitEvent::TenantQuota => RequestError::tenant_over_quota(), + trc::LimitEvent::BlobQuota => { + let mut err = RequestError::over_blob_quota( + total.unwrap_or(0) as usize, + size.unwrap_or(0) as usize, + ); + if let Some(total) = total { + let mut policy = RateLimitPolicy::new("blob-upload-files", total); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } + if let Some(size) = size { + let mut policy = RateLimitPolicy::new("blob-upload-bytes", size) + .with_unit(RateLimitUnit::ContentBytes); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } + err + } + trc::LimitEvent::TooManyRequests => { + let mut err = RequestError::too_many_requests(); + if let Some(limit) = limit { + let mut policy = RateLimitPolicy::new("requests", limit); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } else if let Some(reset) = reset { + err = err.with_retry_after(reset); + } + err + } + } + } + trc::EventType::Auth(cause) => match cause { + trc::AuthEvent::MfaRequired => { + RequestError::blank(402, "MFA code required", self.as_ref().message()) + } + trc::AuthEvent::TooManyAttempts => { + let mut err = RequestError::too_many_auth_attempts(); + if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) { + err = err.with_retry_after(reset); + } + err + } + _ => RequestError::unauthorized(), + }, + trc::EventType::Security(cause) => match cause { + trc::SecurityEvent::AuthenticationBan + | trc::SecurityEvent::ScanBan + | trc::SecurityEvent::AbuseBan + | trc::SecurityEvent::LoiterBan + | trc::SecurityEvent::IpBlocked => { + let mut err = RequestError::too_many_auth_attempts(); + if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) { + err = err.with_retry_after(reset); + } + err + } + trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { + RequestError::forbidden() + } + trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => { + RequestError::internal_server_error() + } + }, + trc::EventType::Resource(cause) => match cause { + trc::ResourceEvent::NotFound => RequestError::not_found(), + trc::ResourceEvent::BadParameters => RequestError::blank( + StatusCode::BAD_REQUEST.as_u16(), + "Invalid parameters", + details_or_reason.unwrap_or("One or multiple parameters could not be parsed."), + ), + trc::ResourceEvent::Error => RequestError::internal_server_error(), + _ => RequestError::internal_server_error(), + }, + _ => RequestError::internal_server_error(), + } + } +} + +pub(crate) trait IntoPushObject { + fn into_push_object(self) -> PushObject; +} + +pub(crate) fn notifications_into_push_objects( + notifications: Vec, +) -> Vec { + let mut changed: VecMap> = VecMap::new(); + let mut objects = Vec::with_capacity(notifications.len()); + for notification in notifications { + match notification { + PushNotification::StateChange(state_change) => { + for type_state in state_change.types { + changed + .get_mut_or_insert(state_change.account_id.into()) + .set(type_state, State::Exact(state_change.change_id)); + } + } + PushNotification::CalendarAlert(calendar_alert) => { + objects.push(calendar_alert.into_push_object()); + } + PushNotification::EmailPush(email_push) => { + let state_change = email_push.to_state_change(); + for type_state in state_change.types { + changed + .get_mut_or_insert(state_change.account_id.into()) + .set(type_state, State::Exact(state_change.change_id)); + } + } + } + } + + if !changed.is_empty() { + objects.push(PushObject::StateChange { changed }); + } + objects +} + +impl IntoPushObject for CalendarAlert { + fn into_push_object(self) -> PushObject { + PushObject::CalendarAlert { + account_id: self.account_id.into(), + calendar_event_id: self.event_id.into(), + uid: self.uid, + recurrence_id: self + .recurrence_id + .map(|timestamp| JSCalendarDateTime::new(timestamp, true).to_rfc3339()), + alert_id: self.alert_id, + } + } +} diff --git a/crates/jmap/src/api/query.rs b/crates/jmap/src/api/query.rs new file mode 100644 index 0000000..212e93a --- /dev/null +++ b/crates/jmap/src/api/query.rs @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::{ + method::query::{QueryRequest, QueryResponse}, + object::JmapObject, + types::state::State, +}; +use types::id::Id; + +pub struct QueryResponseBuilder { + requested_position: i32, + position: i32, + pub limit: usize, + anchor: u64, + anchor_offset: i32, + pub has_anchor: bool, + pub anchor_found: bool, + index: i32, + + pub response: QueryResponse, +} + +impl QueryResponseBuilder { + pub fn new( + total_results: usize, + max_results: usize, + query_state: State, + request: &QueryRequest, + ) -> Self { + let (limit_total, limit) = if let Some(limit) = request.limit { + if limit > 0 { + let limit = std::cmp::min(limit, max_results); + (std::cmp::min(limit, total_results), limit) + } else { + (0, 0) + } + } else { + (std::cmp::min(max_results, total_results), max_results) + }; + + QueryResponseBuilder { + requested_position: request.position.unwrap_or(0), + position: request.position.unwrap_or(0), + limit: limit_total, + has_anchor: request.anchor.is_some(), + anchor: request.anchor.map(|anchor| anchor.id()).unwrap_or(0), + anchor_offset: request.anchor_offset.unwrap_or(0), + anchor_found: false, + index: 0, + response: QueryResponse { + account_id: request.account_id, + query_state, + can_calculate_changes: true, + position: 0, + ids: vec![], + total: if request.calculate_total.unwrap_or(false) { + Some(total_results) + } else { + None + }, + limit: if total_results > limit { + Some(limit) + } else { + None + }, + }, + } + } + + #[inline(always)] + pub fn add(&mut self, prefix_id: u32, document_id: u32) -> bool { + self.add_id(Id::from_parts(prefix_id, document_id)) + } + + pub fn add_id(&mut self, id: Id) -> bool { + let id_u64 = id.id(); + + // Pagination + if !self.has_anchor { + if self.position >= 0 { + if self.position > 0 { + self.position -= 1; + } else { + self.response.ids.push(id); + if self.response.ids.len() == self.limit { + return false; + } + } + } else { + self.response.ids.push(id); + } + } else { + let current_index = self.index; + self.index += 1; + + if id_u64 == self.anchor { + self.anchor_found = true; + self.position = (current_index + self.anchor_offset).max(0); + } + + if self.anchor_offset >= 0 { + if self.anchor_found && current_index >= self.position { + self.response.ids.push(id); + if self.limit > 0 && self.response.ids.len() == self.limit { + return false; + } + } + } else { + self.response.ids.push(id); + if self.anchor_found + && self.limit > 0 + && self.response.ids.len() >= self.position as usize + self.limit + { + return false; + } + } + } + + true + } + + pub fn is_full(&self) -> bool { + self.response.ids.len() == self.limit + } + + pub fn build(mut self) -> trc::Result { + if self.has_anchor { + if !self.anchor_found { + return Err(trc::JmapEvent::AnchorNotFound.into_err()); + } + + let start = self.position.max(0) as usize; + if self.anchor_offset < 0 { + let start = start.min(self.response.ids.len()); + let end = if self.limit > 0 { + std::cmp::min(start + self.limit, self.response.ids.len()) + } else { + self.response.ids.len() + }; + self.response.ids = self.response.ids[start..end].to_vec(); + } + self.response.position = start as i32; + + return Ok(self.response); + } + + if self.requested_position >= 0 { + self.response.position = if self.position == 0 { + self.requested_position + } else { + 0 + }; + } else { + let position = self.position.unsigned_abs() as usize; + let start_offset = if position < self.response.ids.len() { + self.response.ids.len() - position + } else { + 0 + }; + self.response.position = start_offset as i32; + let end_offset = if self.limit > 0 { + std::cmp::min(start_offset + self.limit, self.response.ids.len()) + } else { + self.response.ids.len() + }; + + self.response.ids = self.response.ids[start_offset..end_offset].to_vec(); + } + + Ok(self.response) + } +} diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs new file mode 100644 index 0000000..8ccd544 --- /dev/null +++ b/crates/jmap/src/api/request.rs @@ -0,0 +1,739 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + addressbook::{get::AddressBookGet, set::AddressBookSet}, + api::auth::JmapAuthorization, + blob::{copy::BlobCopy, get::BlobOperations, upload::BlobUpload}, + calendar::{get::CalendarGet, set::CalendarSet}, + calendar_event::{ + copy::JmapCalendarEventCopy, get::CalendarEventGet, parse::CalendarEventParse, + query::CalendarEventQuery, set::CalendarEventSet, + }, + calendar_event_notification::{ + get::CalendarEventNotificationGet, query::CalendarEventNotificationQuery, + set::CalendarEventNotificationSet, + }, + changes::{get::ChangesLookup, query::QueryChanges}, + contact::{ + copy::JmapContactCardCopy, get::ContactCardGet, parse::ContactCardParse, + query::ContactCardQuery, set::ContactCardSet, + }, + email::{ + copy::JmapEmailCopy, get::EmailGet, import::EmailImport, parse::EmailParse, + query::EmailQuery, set::EmailSet, snippet::EmailSearchSnippet, + }, + file::{copy::FileNodeCopy, get::FileNodeGet, query::FileNodeQuery, set::FileNodeSet}, + identity::{get::IdentityGet, set::IdentitySet}, + mailbox::{get::MailboxGet, query::MailboxQuery, set::MailboxSet}, + participant_identity::{get::ParticipantIdentityGet, set::ParticipantIdentitySet}, + principal::{availability::PrincipalGetAvailability, get::PrincipalGet, query::PrincipalQuery}, + push::{get::PushSubscriptionFetch, set::PushSubscriptionSet}, + quota::{get::QuotaGet, query::QuotaQuery}, + registry::{get::RegistryGet, query::RegistryQuery, set::RegistrySet}, + share_notification::{ + get::ShareNotificationGet, query::ShareNotificationQuery, set::ShareNotificationSet, + }, + sieve::{ + get::SieveScriptGet, query::SieveScriptQuery, set::SieveScriptSet, + validate::SieveScriptValidate, + }, + submission::{get::EmailSubmissionGet, query::EmailSubmissionQuery, set::EmailSubmissionSet}, + thread::get::ThreadGet, + vacation::{get::VacationResponseGet, set::VacationResponseSet}, +}; +use common::{Server, auth::AccessToken}; +use http_proto::HttpSessionData; +use jmap_proto::{ + request::{ + Call, CopyRequestMethod, GetRequestMethod, INVALID_ACCOUNT_ID, ParseRequestMethod, + QueryRequestMethod, Request, RequestMethod, SetRequestMethod, + capability::Capability, + method::{MethodName, MethodObject}, + }, + response::{Response, ResponseMethod, SetResponseMethod}, +}; +use std::future::Future; +use std::time::Instant; +use trc::JmapEvent; +use types::{collection::Collection, id::Id}; + +pub trait RequestHandler: Sync + Send { + fn handle_jmap_request<'x>( + &self, + request: Request<'x>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future> + Send; + + fn handle_method_call<'x>( + &self, + method: RequestMethod<'x>, + method_name: MethodName, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl RequestHandler for Server { + async fn handle_jmap_request<'x>( + &self, + request: Request<'x>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> Response<'x> { + let add_created_ids = request.created_ids.is_some(); + let using = request.using; + let mut response = Response::new( + access_token.state(), + request.created_ids.unwrap_or_default(), + request.method_calls.len(), + ); + + for mut call in request.method_calls { + // Resolve result and id references + if let Err(error) = response.resolve_references(&mut call.method) { + let method_error = error.clone(); + + trc::error!(error.span_id(session.session_id)); + + response.push_response(call.id, MethodName::error(), method_error); + continue; + } + + if !matches!(call.method, RequestMethod::Error(_)) { + let capability = call.name.obj.capability(); + if capability != Capability::Stalwart && !using.contains(capability) { + response.push_response( + call.id, + MethodName::error(), + trc::JmapEvent::UnknownMethod.into_err().details(format!( + "Method {} requires capability {} which is not present in the \"using\" property.", + call.name, + capability.as_str() + )), + ); + continue; + } + } + + loop { + let mut next_call = None; + + // Add response + let method_name = call.name.as_str(); + match self + .handle_method_call( + call.method, + call.name, + access_token, + &mut next_call, + session, + ) + .await + { + Ok(mut method_response) => { + match &mut method_response { + ResponseMethod::Set(set_response) => { + // Add created ids + match set_response { + SetResponseMethod::Email(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::Mailbox(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::Identity(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::EmailSubmission(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::PushSubscription(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::Sieve(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::VacationResponse(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::AddressBook(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::ContactCard(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::FileNode(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::ShareNotification(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::Calendar(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::CalendarEvent(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::ParticipantIdentity(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::CalendarEventNotification(_) => {} + SetResponseMethod::Registry(set_response) => { + set_response.update_created_ids(&mut response); + } + } + } + ResponseMethod::ImportEmail(import_response) => { + // Add created ids + import_response.update_created_ids(&mut response); + } + ResponseMethod::UploadBlob(upload_response) => { + // Add created blobIds + upload_response.update_created_ids(&mut response); + } + _ => {} + } + + response.push_response(call.id, call.name, method_response); + } + Err(error) => { + let method_error = error.clone(); + + trc::error!( + error + .span_id(session.session_id) + .ctx_unique(trc::Key::AccountId, access_token.account_id()) + .caused_by(method_name) + ); + + response.push_error(call.id, method_error); + } + } + + // Process next call + if let Some(next_call) = next_call { + call = next_call; + call.id + .clone_from(&response.method_responses.last().unwrap().id); + } else { + break; + } + } + } + + if !add_created_ids { + response.created_ids.clear(); + } + + response + } + + async fn handle_method_call<'x>( + &self, + method: RequestMethod<'x>, + method_name: MethodName, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> trc::Result> { + let op_start = Instant::now(); + + // Check permissions + access_token.assert_has_jmap_permission(&method, method_name.obj)?; + + // Handle method + let response = match method { + RequestMethod::Get(req) => match req { + GetRequestMethod::Email(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_get(*req, access_token).await?.into() + } + GetRequestMethod::Mailbox(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_get(*req, access_token).await?.into() + } + GetRequestMethod::Thread(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.thread_get(*req, access_token).await?.into() + } + GetRequestMethod::Identity(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.identity_get(*req).await?.into() + } + GetRequestMethod::EmailSubmission(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.email_submission_get(*req).await?.into() + } + GetRequestMethod::PushSubscription(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + self.push_subscription_get(*req, access_token).await?.into() + } + GetRequestMethod::Sieve(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.sieve_script_get(*req).await?.into() + } + GetRequestMethod::VacationResponse(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.vacation_response_get(*req).await?.into() + } + GetRequestMethod::Principal(req) => { + self.principal_get(*req, access_token).await?.into() + } + GetRequestMethod::Quota(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.quota_get(*req, access_token).await?.into() + } + GetRequestMethod::Blob(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.blob_get(*req, access_token).await?.into() + } + GetRequestMethod::AddressBook(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::AddressBook)?; + + self.address_book_get(*req, access_token).await?.into() + } + GetRequestMethod::ContactCard(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::ContactCard)?; + + self.contact_card_get(*req, access_token).await?.into() + } + GetRequestMethod::FileNode(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_get(*req, access_token).await?.into() + } + GetRequestMethod::PrincipalAvailability(req) => self + .principal_get_availability(*req, access_token) + .await? + .into(), + GetRequestMethod::Calendar(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Calendar)?; + + self.calendar_get(*req, access_token).await?.into() + } + GetRequestMethod::CalendarEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?; + + self.calendar_event_get(*req, access_token).await?.into() + } + GetRequestMethod::CalendarEventNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.calendar_event_notification_get(*req, access_token) + .await? + .into() + } + GetRequestMethod::ParticipantIdentity(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.participant_identity_get(*req).await?.into() + } + GetRequestMethod::ShareNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.share_notification_get(*req).await?.into() + } + GetRequestMethod::Registry(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + Box::pin(self.registry_get( + method_name.obj.unwrap_registry(), + *req, + access_token, + )) + .await? + .into() + } + }, + RequestMethod::Query(req) => match req { + QueryRequestMethod::Email(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_query(*req, access_token).await?.into() + } + QueryRequestMethod::Mailbox(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_query(*req, access_token).await?.into() + } + QueryRequestMethod::EmailSubmission(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.email_submission_query(*req).await?.into() + } + QueryRequestMethod::Sieve(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.sieve_script_query(*req).await?.into() + } + QueryRequestMethod::Principal(req) => { + self.principal_query(*req, access_token).await?.into() + } + QueryRequestMethod::Quota(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.quota_query(*req, access_token).await?.into() + } + QueryRequestMethod::AddressBook(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::AddressBook)?; + + self.address_book_query(*req, access_token).await?.into() + } + QueryRequestMethod::ContactCard(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::ContactCard)?; + + self.contact_card_query(*req, access_token).await?.into() + } + QueryRequestMethod::FileNode(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_query(*req, access_token).await?.into() + } + QueryRequestMethod::Calendar(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Calendar)?; + + self.calendar_query(*req, access_token).await?.into() + } + QueryRequestMethod::CalendarEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?; + + self.calendar_event_query(*req, access_token).await?.into() + } + QueryRequestMethod::CalendarEventNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.calendar_event_notification_query(*req, access_token) + .await? + .into() + } + QueryRequestMethod::ShareNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.share_notification_query(*req).await?.into() + } + QueryRequestMethod::Registry(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + Box::pin(self.registry_query( + method_name.obj.unwrap_registry(), + *req, + access_token, + )) + .await? + .into() + } + }, + RequestMethod::Set(req) => match req { + SetRequestMethod::Email(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_set(*req, access_token, session).await?.into() + } + SetRequestMethod::Mailbox(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Mailbox)?; + + self.mailbox_set(*req, access_token).await?.into() + } + SetRequestMethod::Identity(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.identity_set(*req).await?.into() + } + SetRequestMethod::EmailSubmission(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.email_submission_set(*req, &session.instance, next_call) + .await? + .into() + } + SetRequestMethod::PushSubscription(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + self.push_subscription_set(*req, access_token).await?.into() + } + SetRequestMethod::Sieve(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.sieve_script_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::VacationResponse(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.vacation_response_set(*req, access_token).await?.into() + } + SetRequestMethod::AddressBook(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::AddressBook)?; + + self.address_book_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::ContactCard(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::ContactCard)?; + + self.contact_card_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::FileNode(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::FileNode)?; + + self.file_node_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::ShareNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.share_notification_set(*req).await?.into() + } + SetRequestMethod::Calendar(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Calendar)?; + + self.calendar_set(*req, access_token, session).await?.into() + } + SetRequestMethod::CalendarEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?; + + self.calendar_event_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::CalendarEventNotification(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.calendar_event_notification_set(*req, access_token, session) + .await? + .into() + } + SetRequestMethod::ParticipantIdentity(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.participant_identity_set(*req).await?.into() + } + SetRequestMethod::Registry(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + Box::pin(self.registry_set( + method_name.obj.unwrap_registry(), + *req, + access_token, + session, + )) + .await? + .into() + } + }, + RequestMethod::Changes(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + + self.changes(*req, method_name.obj, access_token) + .await? + .into_method_response() + } + RequestMethod::Copy(req) => match req { + CopyRequestMethod::Email(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?; + + access_token + .assert_has_access(req.account_id, Collection::Email)? + .assert_has_access(req.from_account_id, Collection::Email)?; + + self.email_copy(*req, access_token, next_call, session) + .await? + .into() + } + CopyRequestMethod::Blob(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.blob_copy(*req, access_token).await?.into() + } + CopyRequestMethod::ContactCard(mut req) => { + resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?; + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + + access_token + .assert_has_access(req.account_id, Collection::ContactCard)? + .assert_has_access(req.from_account_id, Collection::ContactCard)?; + + self.contact_card_copy(*req, access_token, next_call, session) + .await? + .into() + } + CopyRequestMethod::CalendarEvent(mut req) => { + resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?; + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + + access_token + .assert_has_access(req.account_id, Collection::CalendarEvent)? + .assert_has_access(req.from_account_id, Collection::CalendarEvent)?; + + self.calendar_event_copy(*req, access_token, next_call, session) + .await? + .into() + } + CopyRequestMethod::FileNode(mut req) => { + resolve_account_id(&mut req.from_account_id, method_name.obj, access_token)?; + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + + access_token + .assert_has_access(req.account_id, Collection::FileNode)? + .assert_has_access(req.from_account_id, Collection::FileNode)?; + + self.file_node_copy(*req, access_token, next_call, session) + .await? + .into() + } + }, + RequestMethod::ImportEmail(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_import(*req, access_token, session).await?.into() + } + RequestMethod::Parse(req) => match req { + ParseRequestMethod::Email(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_parse(*req, access_token).await?.into() + } + ParseRequestMethod::ContactCard(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::ContactCard)?; + + self.contact_card_parse(*req, access_token).await?.into() + } + ParseRequestMethod::CalendarEvent(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::CalendarEvent)?; + + self.calendar_event_parse(*req, access_token).await?.into() + } + }, + RequestMethod::QueryChanges(req) => self.query_changes(req, access_token).await?.into(), + RequestMethod::SearchSnippet(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_has_access(req.account_id, Collection::Email)?; + + self.email_search_snippet(*req, access_token).await?.into() + } + RequestMethod::ValidateScript(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.sieve_script_validate(*req, access_token).await?.into() + } + RequestMethod::LookupBlob(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.blob_lookup(*req).await?.into() + } + RequestMethod::UploadBlob(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + access_token.assert_is_member(req.account_id)?; + + self.blob_upload_many(*req, access_token).await?.into() + } + RequestMethod::Echo(req) => req.into(), + RequestMethod::Error(error) => return Err(error), + }; + + trc::event!( + Jmap(JmapEvent::MethodCall), + Id = method_name.as_str(), + SpanId = session.session_id, + AccountId = access_token.account_id(), + Elapsed = op_start.elapsed(), + ); + + Ok(response) + } +} + +pub(crate) fn resolve_account_id( + account_id: &mut Id, + obj: MethodObject, + access_token: &AccessToken, +) -> trc::Result<()> { + if account_id.id() < INVALID_ACCOUNT_ID { + Ok(()) + } else if matches!( + obj, + MethodObject::Core | MethodObject::PushSubscription | MethodObject::Registry(_) + ) { + *account_id = Id::from(access_token.account_id()); + Ok(()) + } else if account_id.id() == INVALID_ACCOUNT_ID { + Err(trc::JmapEvent::AccountNotFound.into_err()) + } else { + Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("The \"accountId\" property is required.")) + } +} diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs new file mode 100644 index 0000000..2c0fe96 --- /dev/null +++ b/crates/jmap/src/api/session.rs @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use jmap_proto::request::capability::{ + Account, Capabilities, Capability, EmptyCapabilities, Session, +}; +use registry::schema::enums::Permission; +use std::future::Future; +use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub trait SessionHandler: Sync + Send { + fn handle_session_resource( + &self, + base_url: String, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl SessionHandler for Server { + async fn handle_session_resource( + &self, + base_url: String, + access_token: &AccessToken, + ) -> trc::Result { + let mut session = Session::new(base_url, &self.core.jmap.capabilities); + session.set_state(access_token.state()); + let account_capabilities = &self.core.jmap.capabilities.account; + + // Set primary account + let account = self + .account(access_token.account_id()) + .await + .caused_by(trc::location!())?; + session.username = account.name().to_string(); + let account_id = Id::from(access_token.account_id()); + let mut account = Account { + name: account.name().to_string(), + is_personal: true, + is_read_only: false, + account_capabilities: VecMap::with_capacity(account_capabilities.len()), + }; + for capability in access_token.account_capabilities() { + session.primary_accounts.append(capability, account_id); + account.account_capabilities.append( + capability, + account_capabilities + .get(&capability) + .map(|v| v.to_account_capabilities(account_id.into(), true)) + .unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())), + ); + } + session.accounts.append(account_id, account); + + // Add secondary accounts + for &account_id in access_token.secondary_ids() { + let is_owner = access_token.is_member(account_id); + let Some(account) = self + .try_account(account_id) + .await + .caused_by(trc::location!())? + else { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountId = account_id, + Reason = "Skipping orphan secondary account id in session", + ); + continue; + }; + + let account_id = Id::from(account_id); + let mut account = Account { + name: account.name().to_string(), + is_personal: false, + is_read_only: false, + account_capabilities: VecMap::with_capacity(account_capabilities.len()), + }; + for capability in access_token.account_capabilities() { + account.account_capabilities.append( + capability, + account_capabilities + .get(&capability) + .map(|v| v.to_account_capabilities(account_id.into(), is_owner)) + .unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())), + ); + } + session.accounts.append(account_id, account); + } + + Ok(session) + } +} + +trait AccountCapabilities { + fn account_capabilities(&self) -> impl Iterator; +} + +impl AccountCapabilities for AccessToken { + fn account_capabilities(&self) -> impl Iterator { + Capability::all_capabilities() + .iter() + .filter(move |capability| { + let permission = match capability { + Capability::Mail | Capability::MailShare | Capability::EmailPush => { + Permission::JmapEmailGet + } + Capability::Submission => Permission::JmapEmailSubmissionCreate, + Capability::VacationResponse => Permission::JmapVacationResponseGet, + Capability::Contacts => Permission::JmapContactCardGet, + Capability::ContactsParse => Permission::JmapContactCardParse, + Capability::Calendars => Permission::JmapCalendarEventGet, + Capability::CalendarsParse => Permission::JmapCalendarEventParse, + Capability::Sieve => Permission::JmapSieveScriptGet, + Capability::Blob => Permission::JmapBlobGet, + Capability::Quota => Permission::JmapQuotaGet, + Capability::FileNode => Permission::JmapFileNodeGet, + Capability::WebSocket + | Capability::Principals + | Capability::PrincipalsAvailability + | Capability::Stalwart => return true, + Capability::Core | Capability::PrincipalsOwner | Capability::WebPushVapid => { + return false; + } + }; + self.has_permission(permission) + }) + .copied() + } +} diff --git a/crates/jmap/src/blob/copy.rs b/crates/jmap/src/blob/copy.rs new file mode 100644 index 0000000..0d79d09 --- /dev/null +++ b/crates/jmap/src/blob/copy.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::download::BlobDownload; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::copy::{CopyBlobRequest, CopyBlobResponse}, + request::MaybeInvalid, +}; +use registry::schema::enums::Permission; +use std::future::Future; +use store::write::{BatchBuilder, BlobLink, BlobOp, now}; +use trc::AddContext; +use types::blob::{BlobClass, BlobId}; +use utils::map::vec_map::VecMap; + +pub trait BlobCopy: Sync + Send { + fn blob_copy( + &self, + request: CopyBlobRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl BlobCopy for Server { + async fn blob_copy( + &self, + request: CopyBlobRequest, + access_token: &AccessToken, + ) -> trc::Result { + let mut response = CopyBlobResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + copied: VecMap::with_capacity(request.blob_ids.len()), + not_copied: VecMap::new(), + }; + let account_id = request.account_id.document_id(); + + for blob_id in request.blob_ids { + let blob_id = match blob_id { + MaybeInvalid::Value(blob_id) => blob_id, + invalid => { + response.not_copied.append( + invalid, + SetError::new(SetErrorType::BlobNotFound).with_description( + "blobId does not exist or not enough permissions to access it.", + ), + ); + continue; + } + }; + if self.has_access_blob(&blob_id, access_token).await? { + // Enforce quota + if !access_token.has_permission(Permission::UnlimitedUploads) + && !self + .blob_has_quota(account_id, 1) + .await + .caused_by(trc::location!())? + .allowed + { + response.not_copied.append( + blob_id, + SetError::over_quota().with_description(format!( + "You have exceeded the blob quota of {} files or {} bytes.", + self.core.jmap.upload_tmp_quota_amount, + self.core.jmap.upload_tmp_quota_size + )), + ); + continue; + } + + let mut batch = BatchBuilder::new(); + let until = now() + self.core.jmap.upload_tmp_ttl; + batch.with_account_id(account_id).set( + BlobOp::Link { + hash: blob_id.hash.clone(), + to: BlobLink::Temporary { until }, + }, + vec![], + ); + self.store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + let dest_blob_id = BlobId { + hash: blob_id.hash.clone(), + class: BlobClass::Reserved { + account_id, + expires: until, + }, + section: blob_id.section.clone(), + }; + + response.copied.append(blob_id, dest_blob_id); + } else { + response.not_copied.append( + blob_id, + SetError::new(SetErrorType::BlobNotFound).with_description( + "blobId does not exist or not enough permissions to access it.", + ), + ); + } + } + + Ok(response) + } +} diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs new file mode 100644 index 0000000..741aa9a --- /dev/null +++ b/crates/jmap/src/blob/download.rs @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use email::cache::MessageCacheFetch; +use email::cache::email::MessageCacheAccess; +use email::message::metadata::MessageMetadata; +use groupware::cache::GroupwareCache; +use registry::schema::enums::Permission; +use std::future::Future; +use store::ValueKey; +use store::write::{AlignedBytes, Archive}; +use trc::AddContext; +use types::acl::Acl; +use types::blob::{BlobClass, BlobId}; +use types::collection::{Collection, SyncCollection}; +use types::field::EmailField; +use utils::chained_bytes::ChainedBytes; + +pub trait BlobDownload: Sync + Send { + fn blob_download( + &self, + blob_id: &BlobId, + access_token: &AccessToken, + ) -> impl Future>>> + Send; + + fn has_access_blob( + &self, + blob_id: &BlobId, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl BlobDownload for Server { + #[allow(clippy::blocks_in_conditions)] + async fn blob_download( + &self, + blob_id: &BlobId, + access_token: &AccessToken, + ) -> trc::Result>> { + if self.has_access_blob(blob_id, access_token).await? { + if let Some(section) = &blob_id.section { + self.get_blob_section(&blob_id.hash, section) + .await + .caused_by(trc::location!()) + } else { + let blob = self + .blob_store() + .get_blob(blob_id.hash.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!()); + match (&blob_id.class, blob) { + ( + BlobClass::Linked { + account_id, + collection, + document_id, + }, + Ok(Some(data)), + ) if *collection == Collection::Email as u8 => { + let Some(archive) = self + .store() + .get_value::>(ValueKey::property( + *account_id, + Collection::Email, + *document_id, + EmailField::Metadata, + )) + .await + .caused_by(trc::location!())? + else { + return Ok(Some(data)); + }; + let metadata = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let body_offset = metadata.inner.blob_body_offset.to_native(); + if metadata.inner.root_part().offset_body.to_native() != body_offset { + let raw_message = ChainedBytes::new( + metadata.inner.raw_headers.as_ref(), + ) + .with_last(data.get(body_offset as usize..).unwrap_or_default()); + Ok(Some(raw_message.to_bytes())) + } else { + Ok(Some(data)) + } + } + (_, blob) => blob, + } + } + } else { + Ok(None) + } + } + + async fn has_access_blob( + &self, + blob_id: &BlobId, + access_token: &AccessToken, + ) -> trc::Result { + Ok( + (blob_id.class.is_superuser() && access_token.has_permission(Permission::FetchAnyBlob)) + || (self + .store() + .blob_has_access(&blob_id.hash, &blob_id.class) + .await + .caused_by(trc::location!())? + && match &blob_id.class { + BlobClass::Linked { + account_id, + collection, + document_id, + } => { + if access_token.is_member(*account_id) { + true + } else { + match Collection::from(*collection) { + Collection::Email => self + .get_cached_messages(*account_id) + .await + .caused_by(trc::location!())? + .shared_messages(access_token, Acl::ReadItems) + .contains(*document_id), + collection @ (Collection::FileNode + | Collection::ContactCard + | Collection::CalendarEvent) => self + .fetch_dav_resources( + access_token.account_id(), + *account_id, + SyncCollection::from(collection), + ) + .await + .caused_by(trc::location!())? + .shared_items(access_token, [Acl::ReadItems], true) + .contains(*document_id), + _ => false, + } + } + } + BlobClass::Reserved { account_id, .. } => { + access_token.is_member(*account_id) + } + }), + ) + } +} diff --git a/crates/jmap/src/blob/get.rs b/crates/jmap/src/blob/get.rs new file mode 100644 index 0000000..aaa6081 --- /dev/null +++ b/crates/jmap/src/blob/get.rs @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::download::BlobDownload; +use common::{Server, auth::AccessToken}; +use email::message::metadata::MessageData; +use jmap_proto::{ + method::{ + get::{GetRequest, GetResponse}, + lookup::{BlobInfo, BlobLookupRequest, BlobLookupResponse}, + }, + object::blob::{Blob, BlobProperty, BlobValue, DataProperty, DigestProperty}, + request::{IntoValid, MaybeInvalid}, +}; +use jmap_tools::{Map, Value}; +use mail_builder::encoders::Base64Encoder; +use sha1::{Digest, Sha1}; +use sha2::{Sha256, Sha512}; +use std::future::Future; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{blob::BlobClass, collection::Collection, id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +pub trait BlobOperations: Sync + Send { + fn blob_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; + + fn blob_lookup( + &self, + request: BlobLookupRequest, + ) -> impl Future> + Send; +} + +impl BlobOperations for Server { + async fn blob_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let ids = ids.unwrap_or_default(); + let properties = request.unwrap_properties(&[ + BlobProperty::Id, + BlobProperty::Data(DataProperty::Default), + BlobProperty::Size, + ]); + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + let range_from = request.arguments.offset.unwrap_or(0); + let range_to = request + .arguments + .length + .map(|length| range_from.saturating_add(length)) + .unwrap_or(usize::MAX); + + for blob_id in ids { + if let Some(bytes) = self.blob_download(&blob_id, access_token).await? { + let mut blob = Map::with_capacity(properties.len()); + let bytes_range = if range_from == 0 && range_to == usize::MAX { + &bytes[..] + } else { + let range_to = if range_to != usize::MAX && range_to > bytes.len() { + blob.insert_unchecked(BlobProperty::IsTruncated, true); + bytes.len() + } else { + range_to + }; + bytes.get(range_from..range_to).unwrap_or_default() + }; + + for property in &properties { + let mut property = property.clone(); + let value: Value<'static, BlobProperty, BlobValue> = match &property { + BlobProperty::Id => Value::Element(BlobValue::BlobId(blob_id.clone())), + BlobProperty::Size => bytes.len().into(), + BlobProperty::Digest(digest) => match digest { + DigestProperty::Sha => { + let mut hasher = Sha1::new(); + hasher.update(bytes_range); + String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + } + DigestProperty::Sha256 => { + let mut hasher = Sha256::new(); + hasher.update(bytes_range); + String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + } + DigestProperty::Sha512 => { + let mut hasher = Sha512::new(); + hasher.update(bytes_range); + String::from_utf8( + Base64Encoder::new() + .encode(&hasher.finalize()[..]) + .unwrap_or_default(), + ) + .unwrap() + } + } + .into(), + BlobProperty::Data(data) => match data { + DataProperty::AsText => match std::str::from_utf8(bytes_range) { + Ok(text) => text.to_string().into(), + Err(_) => { + blob.insert_unchecked(BlobProperty::IsEncodingProblem, true); + Value::Null + } + }, + DataProperty::AsBase64 => String::from_utf8( + Base64Encoder::new().encode(bytes_range).unwrap_or_default(), + ) + .unwrap() + .into(), + DataProperty::Default => match std::str::from_utf8(bytes_range) { + Ok(text) => { + property = BlobProperty::Data(DataProperty::AsText); + text.to_string().into() + } + Err(_) => { + property = BlobProperty::Data(DataProperty::AsBase64); + blob.insert_unchecked(BlobProperty::IsEncodingProblem, true); + String::from_utf8( + Base64Encoder::new() + .encode(bytes_range) + .unwrap_or_default(), + ) + .unwrap() + .into() + } + }, + }, + _ => Value::Null, + }; + blob.insert_unchecked(property, value); + } + + // Add result to response + response.list.push(blob.into()); + } else { + response.push_not_found(blob_id); + } + } + + Ok(response) + } + + async fn blob_lookup(&self, request: BlobLookupRequest) -> trc::Result { + let mut include_email = false; + let mut include_mailbox = false; + let mut include_thread = false; + + let type_names = request + .type_names + .into_iter() + .map(|tn| match tn { + MaybeInvalid::Value(value) => { + match &value { + DataType::Email => { + include_email = true; + } + DataType::Mailbox => { + include_mailbox = true; + } + DataType::Thread => { + include_thread = true; + } + _ => (), + } + + Ok(value) + } + MaybeInvalid::Invalid(_) => Err(trc::JmapEvent::UnknownDataType.into_err()), + }) + .collect::, _>>()?; + let req_account_id = request.account_id.document_id(); + let mut response = BlobLookupResponse { + account_id: request.account_id, + list: Vec::with_capacity(request.ids.len()), + not_found: vec![], + }; + + for id in request.ids.into_valid() { + let mut matched_ids = VecMap::new(); + + match &id.class { + BlobClass::Linked { + account_id, + collection, + document_id, + } if *account_id == req_account_id => { + let collection = Collection::from(*collection); + if collection == Collection::Email { + if let Some(data_) = self + .store() + .get_value::>(ValueKey::archive( + req_account_id, + Collection::Email, + *document_id, + )) + .await? + { + let data = data_ + .unarchive::() + .caused_by(trc::location!())?; + if include_email { + matched_ids.append( + DataType::Email, + vec![Id::from_parts(u32::from(data.thread_id), *document_id)], + ); + } + if include_thread { + matched_ids.append( + DataType::Thread, + vec![Id::from(u32::from(data.thread_id))], + ); + } + if include_mailbox { + matched_ids.append( + DataType::Mailbox, + data.mailboxes + .iter() + .map(|m| { + debug_assert!(m.uid != 0); + Id::from(u32::from(m.mailbox_id)) + }) + .collect::>(), + ); + } + } + } else { + match DataType::try_from(collection) { + Ok(data_type) if type_names.contains(&data_type) => { + matched_ids.append(data_type, vec![Id::from(*document_id)]); + } + _ => (), + } + } + } + BlobClass::Reserved { account_id, .. } if *account_id == req_account_id => {} + _ => { + response.not_found.push(id); + continue; + } + } + + response.list.push(BlobInfo { id, matched_ids }); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/blob/mod.rs b/crates/jmap/src/blob/mod.rs new file mode 100644 index 0000000..d409834 --- /dev/null +++ b/crates/jmap/src/blob/mod.rs @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use types::{blob::BlobId, id::Id}; + +pub mod copy; +pub mod download; +pub mod get; +pub mod upload; + +#[derive(Debug, serde::Serialize)] +pub struct UploadResponse { + #[serde(rename(serialize = "accountId"))] + account_id: Id, + #[serde(rename(serialize = "blobId"))] + blob_id: BlobId, + #[serde(rename(serialize = "type"))] + c_type: String, + size: usize, +} diff --git a/crates/jmap/src/blob/upload.rs b/crates/jmap/src/blob/upload.rs new file mode 100644 index 0000000..8411e6c --- /dev/null +++ b/crates/jmap/src/blob/upload.rs @@ -0,0 +1,252 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{UploadResponse, download::BlobDownload}; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + error::set::SetError, + method::upload::{ + BlobUploadRequest, BlobUploadResponse, BlobUploadResponseObject, DataSourceObject, + }, + request::reference::MaybeIdReference, +}; +use registry::schema::enums::Permission; +use std::future::Future; +use trc::AddContext; +use types::id::Id; + +#[cfg(feature = "test_mode")] +pub static DISABLE_UPLOAD_QUOTA: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); + +pub trait BlobUpload: Sync + Send { + fn blob_upload_many( + &self, + request: BlobUploadRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; + + fn blob_upload( + &self, + account_id: Id, + content_type: &str, + data: &[u8], + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl BlobUpload for Server { + async fn blob_upload_many( + &self, + request: BlobUploadRequest, + access_token: &AccessToken, + ) -> trc::Result { + let mut response = BlobUploadResponse { + account_id: request.account_id, + created: Default::default(), + not_created: Default::default(), + }; + let account_id = request.account_id.document_id(); + + if request.create.len() > self.core.jmap.set_max_objects { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + + 'outer: for (create_id, upload_object) in request.create { + let mut data = Vec::new(); + + for data_source in upload_object.data { + let bytes = match data_source { + DataSourceObject::Id { id, length, offset } => { + let id = match id { + MaybeIdReference::Id(id) => id, + MaybeIdReference::Reference(reference) => { + if let Some(obj) = response.created.get(&reference) { + obj.id.clone() + } else { + response.not_created.append( + create_id, + SetError::not_found().with_description(format!( + "Id reference {reference:?} not found." + )), + ); + continue 'outer; + } + } + MaybeIdReference::Invalid(id) => { + response.not_created.append( + create_id, + SetError::invalid_properties() + .with_description(format!("Invalid blobId {id}.")), + ); + continue 'outer; + } + }; + + if !self.has_access_blob(&id, access_token).await? { + response.not_created.append( + create_id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {id}." + )), + ); + continue 'outer; + } + + let offset = offset.unwrap_or(0); + let length = length + .map(|length| length.saturating_add(offset)) + .unwrap_or(usize::MAX); + let bytes = if let Some(section) = &id.section { + self.get_blob_section(&id.hash, section) + .await? + .map(|bytes| { + if offset == 0 && length == usize::MAX { + bytes + } else { + bytes + .get(offset..std::cmp::min(length, bytes.len())) + .unwrap_or_default() + .to_vec() + } + }) + } else { + self.blob_store() + .get_blob(id.hash.as_slice(), offset..length) + .await? + }; + if let Some(bytes) = bytes { + bytes + } else { + response.not_created.append( + create_id, + SetError::blob_not_found() + .with_description(format!("BlobId {id} not found.")), + ); + continue 'outer; + } + } + DataSourceObject::Value(bytes) => bytes, + DataSourceObject::Null => { + response.not_created.append( + create_id, + SetError::invalid_properties() + .with_description("Invalid DataSourceObject."), + ); + continue 'outer; + } + }; + + if bytes.len() + data.len() < self.core.jmap.upload_max_size { + data.extend(bytes); + } else { + response.not_created.append( + create_id, + SetError::too_large().with_description(format!( + "Upload size exceeds maximum of {} bytes.", + self.core.jmap.upload_max_size + )), + ); + continue 'outer; + } + } + + if data.is_empty() { + response.not_created.append( + create_id, + SetError::invalid_properties() + .with_description("Must specify at least one valid DataSourceObject."), + ); + continue 'outer; + } + + // Enforce quota + if !access_token.has_permission(Permission::UnlimitedUploads) + && !self + .blob_has_quota(account_id, data.len()) + .await + .caused_by(trc::location!())? + .allowed + { + response.not_created.append( + create_id, + SetError::over_quota().with_description(format!( + "You have exceeded the blob upload quota of {} files or {} bytes.", + self.core.jmap.upload_tmp_quota_amount, + self.core.jmap.upload_tmp_quota_size + )), + ); + continue 'outer; + } + + // Write blob + response.created.insert( + create_id, + BlobUploadResponseObject { + id: self.put_jmap_blob(account_id, &data).await?, + type_: upload_object.type_, + size: data.len(), + }, + ); + } + + Ok(response) + } + + async fn blob_upload( + &self, + account_id: Id, + content_type: &str, + data: &[u8], + access_token: &AccessToken, + ) -> trc::Result { + // Limit concurrent uploads + let _in_flight = self + .is_upload_allowed(access_token) + .caused_by(trc::location!())?; + + #[cfg(feature = "test_mode")] + { + // Used for concurrent upload tests + if data == b"sleep" { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + // Enforce quota + if !access_token.has_permission(Permission::UnlimitedUploads) { + let status = self + .blob_has_quota(account_id.document_id(), data.len()) + .await + .caused_by(trc::location!())?; + if !status.allowed { + let err = Err(trc::LimitEvent::BlobQuota + .into_err() + .ctx(trc::Key::Size, self.core.jmap.upload_tmp_quota_size) + .ctx(trc::Key::Total, self.core.jmap.upload_tmp_quota_amount) + .ctx(trc::Key::Expires, status.expires_in)); + + #[cfg(feature = "test_mode")] + if !DISABLE_UPLOAD_QUOTA.load(std::sync::atomic::Ordering::Relaxed) { + return err; + } + + #[cfg(not(feature = "test_mode"))] + return err; + } + } + + Ok(UploadResponse { + account_id, + blob_id: self + .put_jmap_blob(account_id.document_id(), data) + .await + .caused_by(trc::location!())?, + c_type: content_type.to_string(), + size: data.len(), + }) + } +} diff --git a/crates/jmap/src/calendar/get.rs b/crates/jmap/src/calendar/get.rs new file mode 100644 index 0000000..76c36c9 --- /dev/null +++ b/crates/jmap/src/calendar/get.rs @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState}; +use calcard::jscalendar::{JSCalendarAlertAction, JSCalendarRelativeTo, JSCalendarType}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{ + cache::GroupwareCache, + calendar::{ + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ArchivedDefaultAlert, CALENDAR_INVISIBLE, + CALENDAR_SUBSCRIBED, Calendar, + }, +}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability}, +}; +use jmap_tools::{Key, Map, Value}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, + field::PrincipalField, +}; + +pub trait CalendarGet: Sync + Send { + fn calendar_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl CalendarGet for Server { + async fn calendar_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + CalendarProperty::Color, + CalendarProperty::SortOrder, + CalendarProperty::IsSubscribed, + CalendarProperty::IsVisible, + CalendarProperty::IsDefault, + CalendarProperty::IncludeInAvailability, + CalendarProperty::DefaultAlertsWithTime, + CalendarProperty::DefaultAlertsWithoutTime, + CalendarProperty::TimeZone, + CalendarProperty::ShareWith, + CalendarProperty::MyRights, + ]); + let account_id = request.account_id.document_id(); + let personal_id = access_token.personal_id(account_id, Collection::Calendar); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + let is_owner = access_token.is_member(account_id); + let calendar_ids = if is_owner { + cache.document_ids(true).collect::() + } else { + cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) + }; + let default_calendar_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())? + .or_else(|| cache.document_ids(true).min()); + + let ids = if let Some(ids) = ids { + ids + } else { + calendar_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(true).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the calendar object + let document_id = id.document_id(); + if !calendar_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + let _calendar = if let Some(calendar) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + document_id, + )) + .await? + { + calendar + } else { + response.push_not_found(id); + continue; + }; + let calendar = _calendar + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + CalendarProperty::Id => { + result.insert_unchecked(CalendarProperty::Id, CalendarValue::Id(id)); + } + CalendarProperty::Name => { + result.insert_unchecked( + CalendarProperty::Name, + calendar.preferences(personal_id).name.to_string(), + ); + } + CalendarProperty::Description => { + result.insert_unchecked( + CalendarProperty::Description, + calendar + .preferences(personal_id) + .description + .as_ref() + .map(|v| v.to_string()), + ); + } + CalendarProperty::SortOrder => { + result.insert_unchecked( + CalendarProperty::SortOrder, + calendar.preferences(personal_id).sort_order.to_native(), + ); + } + CalendarProperty::IsDefault => { + result.insert_unchecked( + CalendarProperty::IsDefault, + default_calendar_id == Some(document_id), + ); + } + CalendarProperty::IsSubscribed => { + result.insert_unchecked( + CalendarProperty::IsSubscribed, + Value::Bool( + calendar.preferences(personal_id).flags & CALENDAR_SUBSCRIBED != 0, + ), + ); + } + CalendarProperty::Color => { + result.insert_unchecked( + CalendarProperty::Color, + calendar + .preferences(personal_id) + .color + .as_ref() + .map(|c| c.to_string()), + ); + } + CalendarProperty::IsVisible => { + result.insert_unchecked( + CalendarProperty::IsVisible, + Value::Bool( + calendar.preferences(personal_id).flags & CALENDAR_INVISIBLE == 0, + ), + ); + } + CalendarProperty::IncludeInAvailability => { + result.insert_unchecked( + CalendarProperty::IncludeInAvailability, + Value::Element(CalendarValue::IncludeInAvailability( + IncludeInAvailability::from_flags( + calendar.preferences(personal_id).flags.to_native(), + ) + .unwrap_or(if is_owner { + IncludeInAvailability::All + } else { + IncludeInAvailability::None + }), + )), + ); + } + CalendarProperty::DefaultAlertsWithTime => { + result.insert_unchecked( + CalendarProperty::DefaultAlertsWithTime, + Value::Object(Map::from_iter( + calendar + .default_alerts(personal_id, true) + .map(default_alarm_to_value), + )), + ); + } + CalendarProperty::DefaultAlertsWithoutTime => { + result.insert_unchecked( + CalendarProperty::DefaultAlertsWithoutTime, + Value::Object(Map::from_iter( + calendar + .default_alerts(personal_id, false) + .map(default_alarm_to_value), + )), + ); + } + CalendarProperty::TimeZone => { + result.insert_unchecked( + CalendarProperty::TimeZone, + calendar + .preferences(personal_id) + .time_zone + .tz() + .map(|tz| Value::Element(CalendarValue::Timezone(tz))) + .unwrap_or(Value::Null), + ); + } + CalendarProperty::ShareWith => { + result.insert_unchecked( + CalendarProperty::ShareWith, + JmapRights::share_with::( + account_id, + access_token, + &calendar.acls.iter().map(AclGrant::from).collect::>(), + ), + ); + } + CalendarProperty::MyRights => { + result.insert_unchecked( + CalendarProperty::MyRights, + if access_token.is_shared(account_id) { + JmapRights::rights::( + calendar.acls.effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + }, + ); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } +} + +fn default_alarm_to_value( + alarm: &ArchivedDefaultAlert, +) -> ( + Key<'static, CalendarProperty>, + Value<'static, CalendarProperty, CalendarValue>, +) { + ( + Key::Owned(alarm.id.to_string()), + Value::Object(Map::from(vec![ + ( + Key::Property(CalendarProperty::Type), + Value::Element(CalendarValue::Type(JSCalendarType::Alert)), + ), + ( + Key::Property(CalendarProperty::Action), + Value::Element(CalendarValue::Action(if alarm.flags & ALERT_EMAIL != 0 { + JSCalendarAlertAction::Email + } else { + JSCalendarAlertAction::Display + })), + ), + ( + Key::Property(CalendarProperty::Trigger), + Value::Object(Map::from(vec![ + ( + Key::Property(CalendarProperty::Type), + Value::Element(CalendarValue::Type(JSCalendarType::OffsetTrigger)), + ), + ( + Key::Property(CalendarProperty::Offset), + Value::Element(CalendarValue::Duration(alarm.offset.to_native())), + ), + ( + Key::Property(CalendarProperty::RelativeTo), + Value::Element(CalendarValue::RelativeTo( + if alarm.flags & ALERT_RELATIVE_TO_END != 0 { + JSCalendarRelativeTo::End + } else { + JSCalendarRelativeTo::Start + }, + )), + ), + ])), + ), + ])), + ) +} diff --git a/crates/jmap/src/calendar/mod.rs b/crates/jmap/src/calendar/mod.rs new file mode 100644 index 0000000..10517a2 --- /dev/null +++ b/crates/jmap/src/calendar/mod.rs @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use groupware::calendar::{ + CALENDAR_AVAILABILITY_ALL, CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE, +}; +use jmap_proto::object::calendar::IncludeInAvailability; + +pub mod get; +pub mod set; + +pub(crate) trait Availability: Sized { + fn from_flags(flags: u16) -> Option; +} + +impl Availability for IncludeInAvailability { + fn from_flags(flags: u16) -> Option { + if flags & CALENDAR_AVAILABILITY_ALL != 0 { + Some(IncludeInAvailability::All) + } else if flags & CALENDAR_AVAILABILITY_ATTENDING != 0 { + Some(IncludeInAvailability::Attending) + } else if flags & CALENDAR_AVAILABILITY_NONE != 0 { + Some(IncludeInAvailability::None) + } else { + None + } + } +} diff --git a/crates/jmap/src/calendar/set.rs b/crates/jmap/src/calendar/set.rs new file mode 100644 index 0000000..f41f10b --- /dev/null +++ b/crates/jmap/src/calendar/set.rs @@ -0,0 +1,670 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::acl::{JmapAcl, JmapRights}; +use crate::changes::state::JmapCacheState; +use calcard::jscalendar::{JSCalendarAlertAction, JSCalendarRelativeTo, JSCalendarType}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + calendar::{ + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ALERT_WITH_TIME, CALENDAR_AVAILABILITY_ALL, + CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE, + CALENDAR_SUBSCRIBED, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, + }, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::calendar::{self, CalendarProperty, CalendarValue, IncludeInAvailability}, + request::{MaybeInvalid, reference::MaybeIdReference}, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Map, Value}; +use rand::{RngExt, distr::Alphanumeric}; +use store::{ + SerializeInfallible, ValueKey, + ahash::AHashSet, + write::{AlignedBytes, Archive, BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::PrincipalField, + id::Id, +}; + +pub trait CalendarSet: Sync + Send { + fn calendar_set( + &self, + request: SetRequest<'_, calendar::Calendar>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl CalendarSet for Server { + async fn calendar_set( + &self, + mut request: SetRequest<'_, calendar::Calendar>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(true, &request.if_in_state)?); + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let is_shared = access_token.is_shared(account_id); + let mut set_default = None; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + if is_shared { + response.not_created.append( + id, + SetError::forbidden() + .with_description("Cannot create calendars in a shared account."), + ); + continue 'create; + } + + let mut calendar = Calendar { + name: rand::rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(), + preferences: vec![CalendarPreferences { + account_id, + name: "".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + // Process changes + if let Err(err) = update_calendar(None, object, &mut calendar, access_token, account_id) + { + response.not_created.append(id, err); + continue 'create; + } + + // Validate ACLs + if !calendar.acls.is_empty() { + if let Err(err) = self.acl_validate(&calendar.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + + self.refresh_acls(&calendar.acls, None) + .await + .caused_by(trc::location!())?; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::Calendar, 1) + .await + .caused_by(trc::location!())?; + calendar + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + if let Some(MaybeIdReference::Reference(id_ref)) = + &request.arguments.on_success_set_is_default + && id_ref == &id + { + set_default = Some(document_id); + } + + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain calendar + let document_id = id.document_id(); + let calendar_ = if let Some(calendar_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + document_id, + )) + .await? + { + calendar_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_calendar = calendar + .deserialize::() + .caused_by(trc::location!())?; + + // Apply changes + let has_acl_changes = match update_calendar( + Some(id), + object, + &mut new_calendar, + access_token, + account_id, + ) { + Ok(has_acl_changes_) => has_acl_changes_, + Err(err) => { + response.not_updated.append(id, err); + continue 'update; + } + }; + + // Validate ACL + if is_shared { + let acl = calendar.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this calendar."), + ); + continue 'update; + } + } + if has_acl_changes { + if let Err(err) = self.acl_validate(&new_calendar.acls).await { + response.not_updated.append(id, err.into()); + continue 'update; + } + self.refresh_archived_acls(&new_calendar.acls, calendar.inner.acls.as_slice()) + .await + .caused_by(trc::location!())?; + } + + // Update record + new_calendar + .update( + access_token.account_tenant_ids(), + calendar, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + response.updated.append(id, None); + } + + // Process deletions + let mut reset_default_calendar = false; + if !will_destroy.is_empty() { + let mut destroy_children = AHashSet::new(); + let mut destroy_parents = AHashSet::new(); + let default_calendar_id = self + .store() + .get_value::(ValueKey { + account_id, + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) + .await + .caused_by(trc::location!())?; + let on_destroy_remove_events = + request.arguments.on_destroy_remove_events.unwrap_or(false); + for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(calendar_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if is_shared + && !calendar + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this calendar."), + ); + continue; + } + + // Obtain children ids + let children_ids = cache.children_ids(document_id).collect::>(); + if !children_ids.is_empty() && !on_destroy_remove_events { + response + .not_destroyed + .append(id, SetError::calendar_has_event()); + continue; + } + destroy_children.extend(children_ids.iter().copied()); + destroy_parents.insert(document_id); + + // Delete record + let delete_path = cache + .container_resource_path_by_id(document_id) + .map(|resource| cache.format_resource(resource)); + DestroyArchive(calendar) + .delete( + access_token.account_tenant_ids(), + account_id, + document_id, + delete_path, + &mut batch, + ) + .caused_by(trc::location!())?; + + if default_calendar_id == Some(document_id) { + reset_default_calendar = true; + } + + response.destroyed.push(id); + } + + // Delete children + if !destroy_children.is_empty() { + let account_info = self + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + for document_id in destroy_children { + if let Some(event_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + if event + .inner + .names + .iter() + .all(|n| destroy_parents.contains(&n.parent_id.to_native())) + { + // Event only belongs to calendars being deleted, delete it + DestroyArchive(event).delete_all( + &account_info, + account_id, + document_id, + false, + &mut batch, + )?; + } else { + // Unlink calendar id from event + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event + .names + .retain(|n| !destroy_parents.contains(&n.parent_id)); + new_event.update( + access_token.account_tenant_ids(), + event, + account_id, + document_id, + &mut batch, + )?; + } + } + } + } + } + + // Set default calendar + if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_set_is_default { + set_default = Some(id.document_id()); + } + if let Some(default_calendar_id) = set_default { + if response.not_created.is_empty() + && response.not_updated.is_empty() + && response.not_destroyed.is_empty() + { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .set( + PrincipalField::DefaultCalendarId, + default_calendar_id.serialize(), + ); + } + } else if reset_default_calendar { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::DefaultCalendarId); + } + + // Write changes + if !batch.is_empty() + && let Ok(change_id) = self + .commit_batch(batch) + .await + .caused_by(trc::location!())? + .last_change_id(account_id) + { + self.notify_task_queue(); + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +fn update_calendar( + expected_id: Option, + updates: Value<'_, CalendarProperty, CalendarValue>, + calendar: &mut Calendar, + access_token: &AccessToken, + account_id: u32, +) -> Result> { + let personal_id = access_token.personal_id(account_id, Collection::Calendar); + let mut has_acl_changes = false; + + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (CalendarProperty::Name, Value::Str(value)) if (1..=255).contains(&value.len()) => { + calendar.preferences_mut(personal_id).name = value.into_owned(); + } + (CalendarProperty::Description, Value::Str(value)) if value.len() < 255 => { + calendar.preferences_mut(personal_id).description = value.into_owned().into(); + } + (CalendarProperty::Description, Value::Null) => { + calendar.preferences_mut(personal_id).description = None; + } + (CalendarProperty::Color, Value::Str(value)) if value.len() < 16 => { + calendar.preferences_mut(personal_id).color = value.into_owned().into(); + } + (CalendarProperty::Color, Value::Null) => { + calendar.preferences_mut(personal_id).color = None; + } + (CalendarProperty::TimeZone, Value::Element(CalendarValue::Timezone(tz))) => { + calendar.preferences_mut(personal_id).time_zone = Timezone::IANA(tz.as_id()); + } + (CalendarProperty::TimeZone, Value::Null) => { + calendar.preferences_mut(personal_id).time_zone = Timezone::Default; + } + (CalendarProperty::SortOrder, Value::Number(value)) => { + calendar.preferences_mut(personal_id).sort_order = value.cast_to_u64() as u32; + } + (CalendarProperty::IsSubscribed, Value::Bool(subscribe)) => { + if subscribe { + calendar.preferences_mut(personal_id).flags |= CALENDAR_SUBSCRIBED; + } else { + calendar.preferences_mut(personal_id).flags &= !CALENDAR_SUBSCRIBED; + } + } + (CalendarProperty::IsVisible, Value::Bool(visible)) => { + if visible { + calendar.preferences_mut(personal_id).flags &= !CALENDAR_INVISIBLE; + } else { + calendar.preferences_mut(personal_id).flags |= CALENDAR_INVISIBLE; + } + } + ( + CalendarProperty::IncludeInAvailability, + Value::Element(CalendarValue::IncludeInAvailability(availability)), + ) => { + let flags = &mut calendar.preferences_mut(personal_id).flags; + + match availability { + IncludeInAvailability::All => { + *flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ATTENDING); + *flags |= CALENDAR_AVAILABILITY_ALL; + } + IncludeInAvailability::Attending => { + *flags &= !(CALENDAR_AVAILABILITY_NONE | CALENDAR_AVAILABILITY_ALL); + *flags |= CALENDAR_AVAILABILITY_ATTENDING; + } + IncludeInAvailability::None => { + *flags &= !(CALENDAR_AVAILABILITY_ATTENDING | CALENDAR_AVAILABILITY_ALL); + *flags |= CALENDAR_AVAILABILITY_NONE; + } + } + } + ( + property @ (CalendarProperty::DefaultAlertsWithTime + | CalendarProperty::DefaultAlertsWithoutTime), + Value::Object(value), + ) => { + let with_time = matches!(property, CalendarProperty::DefaultAlertsWithTime); + let alerts = &mut calendar.preferences_mut(personal_id).default_alerts; + + alerts.retain(|alert| (alert.flags & ALERT_WITH_TIME != 0) != with_time); + + for (key, value) in value.into_vec() { + if let Value::Object(value) = value { + alerts.push(value_to_default_alert( + key.to_string().into_owned(), + value, + with_time, + )?); + } + } + } + (CalendarProperty::ShareWith, value) => { + calendar.acls = JmapRights::acl_set::(value)?; + has_acl_changes = true; + } + (CalendarProperty::Pointer(pointer), value) => { + let mut ptr_iter = pointer.iter(); + + match ptr_iter.next() { + Some(JsonPointerItem::Key(Key::Property(CalendarProperty::ShareWith))) => { + calendar.acls = JmapRights::acl_patch::( + std::mem::take(&mut calendar.acls), + ptr_iter, + value, + )?; + has_acl_changes = true; + } + Some(JsonPointerItem::Key(Key::Property( + property @ (CalendarProperty::DefaultAlertsWithTime + | CalendarProperty::DefaultAlertsWithoutTime), + ))) => match (ptr_iter.next(), ptr_iter.next()) { + ( + Some(key @ (JsonPointerItem::Key(_) | JsonPointerItem::Number(_))), + None, + ) => { + let id = match key { + JsonPointerItem::Key(key) => key.to_string().into_owned(), + JsonPointerItem::Number(n) => n.to_string(), + _ => unreachable!(), + }; + let with_time = + matches!(property, CalendarProperty::DefaultAlertsWithTime); + let alerts = &mut calendar.preferences_mut(personal_id).default_alerts; + alerts.retain(|alert| { + (alert.flags & ALERT_WITH_TIME != 0) != with_time || alert.id != id + }); + + if let Value::Object(value) = value { + alerts.push(value_to_default_alert(id, value, with_time)?); + } + } + _ => { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Pointer(pointer)) + .with_description("Field could not be patched.")); + } + }, + _ => { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Pointer(pointer)) + .with_description("Field could not be patched.")); + } + } + } + (CalendarProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property) + .with_description("Field could not be set.")); + } + } + } + + // Validate name + if calendar.preferences(personal_id).name.is_empty() { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Name) + .with_description("Missing name.")); + } + + Ok(has_acl_changes) +} + +fn value_to_default_alert( + id: String, + value: Map<'_, CalendarProperty, CalendarValue>, + with_time: bool, +) -> Result> { + let mut alert = DefaultAlert { + id, + ..Default::default() + }; + let mut has_offset = false; + + for (key, value) in value.into_vec() { + let Key::Property(key) = key else { + continue; + }; + + match (key, value) { + (CalendarProperty::Type, Value::Element(CalendarValue::Type(value))) + if value != JSCalendarType::Alert => + { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Trigger) + .with_description("Invalid alert object type.")); + } + ( + CalendarProperty::Action, + Value::Element(CalendarValue::Action(JSCalendarAlertAction::Email)), + ) => { + alert.flags |= ALERT_EMAIL; + } + (CalendarProperty::Trigger, Value::Object(value)) => { + for (key, value) in value.into_vec() { + let Key::Property(key) = key else { + continue; + }; + + match (key, value) { + ( + CalendarProperty::RelativeTo, + Value::Element(CalendarValue::RelativeTo(JSCalendarRelativeTo::End)), + ) => { + alert.flags |= ALERT_RELATIVE_TO_END; + } + ( + CalendarProperty::Offset, + Value::Element(CalendarValue::Duration(value)), + ) => { + alert.offset = value; + has_offset = true; + } + (CalendarProperty::Offset, Value::Element(CalendarValue::Type(value))) + if value != JSCalendarType::OffsetTrigger => + { + return Err(SetError::invalid_properties() + .with_property(CalendarProperty::Trigger) + .with_description("Invalid alert trigger type.")); + } + _ => {} + } + } + } + _ => {} + } + } + + if has_offset { + if with_time { + alert.flags |= ALERT_WITH_TIME; + } + + Ok(alert) + } else { + Err(SetError::invalid_properties() + .with_property(CalendarProperty::Trigger) + .with_description("Missing alert offset.")) + } +} diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs new file mode 100644 index 0000000..0502c79 --- /dev/null +++ b/crates/jmap/src/calendar_event/copy.rs @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + calendar_event::{CalendarSyntheticId, set::CalendarEventSet}, + changes::state::JmapCacheState, +}; +use calcard::jscalendar::JSCalendarProperty; +use common::{Server, auth::AccessToken}; +use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::calendar_event, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, + types::state::State, +}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::vec_map::VecMap; + +pub trait JmapCalendarEventCopy: Sync + Send { + fn calendar_event_copy<'x>( + &self, + request: CopyRequest<'x, calendar_event::CalendarEvent>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl JmapCalendarEventCopy for Server { + async fn calendar_event_copy<'x>( + &self, + request: CopyRequest<'x, calendar_event::CalendarEvent>, + access_token: &AccessToken, + next_call: &mut Option>>, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + let from_calendar_event_ids = if access_token.is_member(from_account_id) { + from_cache.document_ids(false).collect::() + } else { + from_cache.shared_items(access_token, [Acl::ReadItems], true) + }; + + let can_add_calendars = if access_token.is_shared(account_id) { + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into() + } else { + None + }; + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + let mut destroy_ids = Vec::new(); + + // Obtain account info + let account_info = self + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + + // Prepare batch + let mut batch = BatchBuilder::new(); + + 'create: for (id, create) in request.create.into_valid() { + let from_calendar_event_id = id.document_id(); + if !from_calendar_event_ids.contains(from_calendar_event_id) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + } + if id.is_synthetic() { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description(format!( + "Item {} is a synthetic id and cannot be copied.", + id + )), + ); + continue; + } + + let Some(_calendar_event) = self + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::CalendarEvent, + from_calendar_event_id, + )) + .await? + else { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + }; + + let calendar_event = _calendar_event + .deserialize::() + .caused_by(trc::location!())?; + + match self + .create_calendar_event( + &cache, + &mut batch, + access_token, + account_id, + &account_info, + false, + &can_add_calendars, + calendar_event.data.event.into_jscalendar(), + create, + ) + .await? + { + Ok(document_id) => { + response.created(id, document_id); + + // Add to destroy list + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(id)); + } + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + } + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + self.notify_task_queue(); + + response.new_state = State::Exact(change_id); + } + + // Destroy ids + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::CalendarEvent, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::CalendarEvent(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs new file mode 100644 index 0000000..ae2cdab --- /dev/null +++ b/crates/jmap/src/calendar_event/get.rs @@ -0,0 +1,656 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{calendar_event::CalendarSyntheticId, changes::state::JmapCacheState}; +use calcard::{ + common::{PartialDateTime, timezone::Tz}, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, + ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, + ICalendarProperty, ICalendarValue, + }, + jscalendar::{ + JSCalendarDateTime, JSCalendarProperty, JSCalendarValue, import::ConversionOptions, + }, +}; +use chrono::DateTime; +use common::{Server, auth::AccessToken}; +use groupware::{ + cache::GroupwareCache, + calendar::{ + CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, + PREF_USE_DEFAULT_ALERTS, expand::CalendarEventExpansion, + }, +}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::{JmapObjectId, calendar_event}, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use std::{borrow::Cow, str::FromStr}; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub trait CalendarEventGet: Sync + Send { + fn calendar_event_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl CalendarEventGet for Server { + async fn calendar_event_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let return_all_properties = request.properties.is_none(); + let properties = request.unwrap_properties(&[]); + let account_id = request.account_id.document_id(); + let personal_id = access_token.personal_id(account_id, Collection::Calendar); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + let calendar_event_ids = if access_token.is_member(account_id) { + cache.document_ids(false).collect::() + } else { + cache.shared_items(access_token, [Acl::ReadItems], true) + }; + let (mut ids, has_synthetic_ids) = if let Some(rr) = request.ids.take() { + let rr = rr.unwrap(); + if rr.len() > self.core.jmap.get_max_objects { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + let mut ids = Vec::with_capacity(rr.len()); + let mut has_synthetic_ids = false; + + for id in rr.into_valid() { + has_synthetic_ids |= id.is_synthetic(); + ids.push(id); + } + + (ids, has_synthetic_ids) + } else { + ( + calendar_event_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>(), + false, + ) + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + let mut return_converted_props = !return_all_properties; + let mut return_is_origin = false; + let mut return_utc_dates = false; + + let (jmap_properties, jscal_properties) = if !return_all_properties { + let mut jmap_properties = Vec::with_capacity(4); + let mut jscal_properties = Vec::with_capacity(properties.len()); + + for property in properties { + match property { + JSCalendarProperty::Id + | JSCalendarProperty::BaseEventId + | JSCalendarProperty::CalendarIds + | JSCalendarProperty::IsDraft + | JSCalendarProperty::UseDefaultAlerts + | JSCalendarProperty::MayInviteSelf + | JSCalendarProperty::MayInviteOthers + | JSCalendarProperty::HideAttendees => { + jmap_properties.push(property); + } + JSCalendarProperty::UtcStart | JSCalendarProperty::UtcEnd => { + return_utc_dates = true; + jmap_properties.push(property); + } + JSCalendarProperty::IsOrigin => { + return_is_origin = true; + jmap_properties.push(property); + } + _ => { + if matches!(property, JSCalendarProperty::ICalendar) { + return_converted_props = true; + } + + jscal_properties.push(property); + } + } + } + (jmap_properties, jscal_properties) + } else { + return_is_origin = true; + ( + vec![ + JSCalendarProperty::Id, + JSCalendarProperty::CalendarIds, + JSCalendarProperty::IsDraft, + JSCalendarProperty::IsOrigin, + ], + vec![], + ) + }; + let current_account_info = self + .account_info(access_token.account_id()) + .await + .caused_by(trc::location!())?; + let return_is_origin = if return_is_origin { + if account_id == access_token.account_id() { + Some(Cow::Borrowed(¤t_account_info)) + } else { + Some( + self.account_info(account_id) + .await + .map(Cow::Owned) + .caused_by(trc::location!())?, + ) + } + } else { + None + }; + + // Sort by baseId + let mut original_order: Option> = None; + if has_synthetic_ids { + original_order = Some(ids.iter().enumerate().map(|(i, id)| (*id, i)).collect()); + ids.sort_unstable_by_key(|id| id.document_id()); + } + let mut ids = ids.into_iter().peekable(); + + // Process arguments + let override_range = if request.arguments.recurrence_overrides_after.is_some() + || request.arguments.recurrence_overrides_before.is_some() + { + let after = request + .arguments + .recurrence_overrides_after + .map(|v| v.timestamp) + .unwrap_or(i64::MIN); + let before = request + .arguments + .recurrence_overrides_before + .map(|v| v.timestamp) + .unwrap_or(i64::MAX); + if after < before { + Some(after..before) + } else { + None + } + } else { + None + }; + let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC); + let reduce_participants = request.arguments.reduce_participants.unwrap_or(false); + + while let Some(id) = ids.next() { + // Obtain the calendar_event object + let document_id = id.document_id(); + if !calendar_event_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + + let Some(_calendar_event) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + else { + response.push_not_found(id); + continue; + }; + let mut calendar_event = _calendar_event + .deserialize::() + .caused_by(trc::location!())?; + + // Extract recurrence keys from synthetic ids + let mut recurrence_keys = AHashSet::new(); + let mut include_base_event = false; + if let Some(recurrence_key) = id.recurrence_key() { + recurrence_keys.insert(recurrence_key); + } else { + include_base_event = true; + } + while let Some(next_id) = ids.peek() { + if next_id.document_id() == document_id { + if let Some(recurrence_key) = next_id.recurrence_key() { + recurrence_keys.insert(recurrence_key); + } else { + include_base_event = true; + } + ids.next(); + } else { + break; + } + } + + // Reduce participants + if reduce_participants { + for component in &mut calendar_event.data.event.components { + if component.component_type.is_scheduling_object() { + component.entries.retain(|entry| match &entry.name { + ICalendarProperty::Attendee => { + entry.parameters(&ICalendarParameterName::Role).any(|role| { + matches!( + role, + ICalendarParameterValue::Role( + ICalendarParticipationRole::Owner, + ), + ) + }) || entry.calendar_address().is_some_and(|addr| { + current_account_info + .addresses() + .iter() + .any(|a| a.eq_ignore_ascii_case(addr)) + }) + } + _ => true, + }); + } + } + } + + // Expand synthetic ids + let mut results = Vec::with_capacity(recurrence_keys.len() + 1); + if !recurrence_keys.is_empty() { + let ical = &calendar_event.data.event; + if let Some(expansions) = calendar_event + .data + .expand_from_ids(&mut recurrence_keys, default_tz) + { + for expansion in expansions { + let Some(recurrence_key) = expansion.recurrence_key() else { + continue; + }; + let component = &ical.components[expansion.comp_id as usize]; + let source_component = component; + let is_recurrent = component.is_recurrent(); + let is_recurrent_or_override = + is_recurrent || component.is_recurrence_override(); + let mut has_duration = false; + let component_ids = &component.component_ids; + let mut tz = None; + let mut component = ICalendarComponent { + component_type: component.component_type.clone(), + component_ids: Vec::new(), + entries: component + .entries + .iter() + .filter(|entry| match &entry.name { + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Exdate + | ICalendarProperty::Exrule + | ICalendarProperty::Rdate + | ICalendarProperty::Rrule + | ICalendarProperty::RecurrenceId => { + if let Some(new_tz) = entry + .tz_id() + .and_then(|id| Tz::from_str(id).ok()) + .filter(|tz| *tz != Tz::UTC) + { + tz = Some(new_tz); + } + false + } + ICalendarProperty::Due + | ICalendarProperty::Completed + | ICalendarProperty::Created => is_recurrent, + ICalendarProperty::Duration => { + has_duration = true; + true + } + _ => true, + }) + .cloned() + .collect::>(), + }; + + let tz = tz.unwrap_or(default_tz); + let tz_name = tz.name().unwrap_or_default().to_string(); + + let start_timestamp = DateTime::from_timestamp(expansion.start, 0) + .map(|dt| dt.with_timezone(&tz)) + .map(|dt| dt.naive_local()) + .map(|dt| dt.and_utc().timestamp()) + .unwrap_or(expansion.start); + + let end_timestamp = DateTime::from_timestamp(expansion.end, 0) + .map(|dt| dt.with_timezone(&tz)) + .map(|dt| dt.naive_local()) + .map(|dt| dt.and_utc().timestamp()) + .unwrap_or(expansion.end); + + component.entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstart, + params: vec![ICalendarParameter::tzid(tz_name.clone())], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_naive_timestamp(start_timestamp), + ))], + }); + + if is_recurrent_or_override { + component.entries.push( + source_component + .property(&ICalendarProperty::RecurrenceId) + .filter(|entry| { + entry + .parameters(&ICalendarParameterName::Range) + .next() + .is_none() + || calendar_event + .data + .expand_single(expansion.comp_id, default_tz) + .is_some_and(|first| { + first.start_naive == expansion.start_naive + }) + }) + .cloned() + .unwrap_or_else(|| ICalendarEntry { + name: ICalendarProperty::RecurrenceId, + params: vec![ICalendarParameter::tzid(tz_name.clone())], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_naive_timestamp(start_timestamp), + ))], + }), + ); + } + + if !has_duration { + component.entries.push(ICalendarEntry { + name: ICalendarProperty::Dtend, + params: vec![ICalendarParameter::tzid(tz_name)], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_naive_timestamp(end_timestamp), + ))], + }); + } + + let mut expanded_ical = ICalendar { + components: vec![ + ICalendarComponent { + component_type: ICalendarComponentType::VCalendar, + entries: vec![], + component_ids: vec![1], + }, + component, + ], + }; + + if !component_ids.is_empty() { + for component_id in component_ids { + let mut sub_component = + ical.components[*component_id as usize].clone(); + sub_component.component_ids.clear(); + let component_id = expanded_ical.components.len() as u32; + expanded_ical.components.push(sub_component); + expanded_ical.components[1].component_ids.push(component_id); + } + } + + results.push(( + ::new(recurrence_key, document_id), + expanded_ical, + expansion, + )); + } + } + + for recurrence_key in recurrence_keys { + response.push_not_found(::new( + recurrence_key, + document_id, + )); + } + } + + if include_base_event { + let mut event = std::mem::take(&mut calendar_event.data.event); + + // Obtain UTC start/end if requested + let expansion = if return_utc_dates + && let Some(expansion) = event + .components + .iter() + .position(|c| { + c.component_type.is_scheduling_object() && !c.is_recurrence_override() + }) + .and_then(|comp_id| { + calendar_event + .data + .expand_single(comp_id as u32, default_tz) + }) { + expansion + } else { + CalendarEventExpansion::default() + }; + + // Remove recurrence ids + if let Some(range) = &override_range { + let remove_ids = event + .components + .iter() + .enumerate() + .filter_map(|(comp_id, c)| { + if c.is_recurrence_override() + && let Some(timestamp) = c + .property(&ICalendarProperty::RecurrenceId) + .and_then(|p| p.values.first()) + .and_then(|v| v.as_partial_date_time()) + .and_then(|v| v.to_date_time()) + .and_then(|v| v.to_date_time_with_tz(default_tz)) + .map(|v| v.timestamp()) + && !range.contains(×tamp) + { + Some(comp_id as u32) + } else { + None + } + }) + .collect::>(); + if !remove_ids.is_empty() { + for component in &mut event.components { + component + .component_ids + .retain(|id| !remove_ids.contains(id)); + } + } + } + + results.push((Id::from(document_id), event, expansion)); + } + + for (id, ical, expansion) in results { + let is_origin = return_is_origin.as_ref().is_some_and(|account| { + ical.components + .iter() + .find(|c| c.component_type.is_scheduling_object()) + .and_then(|c| c.property(&ICalendarProperty::Organizer)) + .and_then(|v| v.calendar_address()) + .is_none_or(|v| { + account + .addresses() + .iter() + .any(|a| a.eq_ignore_ascii_case(v)) + }) + }); + + let jscal = ical + .into_jscalendar_with_opt::( + ConversionOptions::default() + .include_ical_components(return_converted_props) + .return_first(true), + ) + .into_inner(); + let mut result = if return_all_properties { + jscal.into_object().unwrap() + } else { + let is_synthetic = id.is_synthetic(); + let is_null_for_synthetic = |property: &JSCalendarProperty| { + is_synthetic + && matches!( + property, + JSCalendarProperty::RecurrenceRule + | JSCalendarProperty::RecurrenceOverrides + ) + }; + let mut result = + Map::from_iter(jscal.into_expanded_object().filter(|(k, _)| { + k.as_property().is_some_and(|p| { + jscal_properties.contains(p) && !is_null_for_synthetic(p) + }) + })); + for property in jscal_properties + .iter() + .filter(|property| is_null_for_synthetic(property)) + { + result.insert_unchecked(property.clone(), Value::Null); + } + result + }; + + for property in &jmap_properties { + match property { + JSCalendarProperty::Id => { + result.insert_unchecked( + JSCalendarProperty::Id, + Value::Element(JSCalendarValue::Id(id)), + ); + } + JSCalendarProperty::BaseEventId => { + result.insert_unchecked( + JSCalendarProperty::BaseEventId, + if id.is_synthetic() { + Value::Element(JSCalendarValue::Id(id.document_id().into())) + } else { + Value::Null + }, + ); + } + JSCalendarProperty::CalendarIds => { + let mut obj = Map::with_capacity(calendar_event.names.len()); + for id in calendar_event.names.iter() { + obj.insert_unchecked( + JSCalendarProperty::IdValue(Id::from(id.parent_id)), + true, + ); + } + result.insert_unchecked( + JSCalendarProperty::CalendarIds, + Value::Object(obj), + ); + } + JSCalendarProperty::IsDraft => { + result.insert_unchecked( + JSCalendarProperty::IsDraft, + Value::Bool(calendar_event.flags & EVENT_DRAFT != 0), + ); + } + JSCalendarProperty::IsOrigin => { + result.insert_unchecked( + JSCalendarProperty::IsOrigin, + Value::Bool(is_origin), + ); + } + JSCalendarProperty::MayInviteSelf => { + result.insert_unchecked( + JSCalendarProperty::MayInviteSelf, + Value::Bool(calendar_event.flags & EVENT_INVITE_SELF != 0), + ); + } + JSCalendarProperty::MayInviteOthers => { + result.insert_unchecked( + JSCalendarProperty::MayInviteOthers, + Value::Bool(calendar_event.flags & EVENT_INVITE_OTHERS != 0), + ); + } + JSCalendarProperty::HideAttendees => { + result.insert_unchecked( + JSCalendarProperty::HideAttendees, + Value::Bool(calendar_event.flags & EVENT_HIDE_ATTENDEES != 0), + ); + } + + JSCalendarProperty::UtcStart => { + result.insert_unchecked( + JSCalendarProperty::UtcStart, + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + expansion.start, + false, + ))), + ); + } + JSCalendarProperty::UtcEnd => { + result.insert_unchecked( + JSCalendarProperty::UtcEnd, + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + expansion.end, + false, + ))), + ); + } + JSCalendarProperty::UseDefaultAlerts => { + result.insert_unchecked( + JSCalendarProperty::UseDefaultAlerts, + Value::Bool( + calendar_event + .preferences(personal_id) + .is_some_and(|v| v.flags & PREF_USE_DEFAULT_ALERTS != 0), + ), + ); + } + + _ => {} + } + } + + response.list.push(result.into()); + } + } + + // Restore original order + if let Some(original_order) = original_order { + response.list.sort_by_key(|obj| { + obj.as_object() + .unwrap() + .get(&Key::Property(JSCalendarProperty::::Id)) + .and_then(|v| v.as_element()) + .and_then(|v: &JSCalendarValue| v.as_id()) + .and_then(|id| original_order.get(&id)) + .cloned() + .unwrap_or(usize::MAX) + }); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/calendar_event/mod.rs b/crates/jmap/src/calendar_event/mod.rs new file mode 100644 index 0000000..ff4ec86 --- /dev/null +++ b/crates/jmap/src/calendar_event/mod.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::jscalendar::JSCalendarProperty; +use common::Server; +use groupware::calendar::expand::RecurrenceKey; +use jmap_proto::error::set::SetError; +use trc::AddContext; +use types::{collection::Collection, field::CalendarEventField, id::Id}; + +pub mod copy; +pub mod get; +pub mod parse; +pub mod query; +pub mod set; + +/* + +TODO: Not yet implemented: + +- CalendarEvent + - Per-user properties (However, the database schema is ready to support this) + - mayInviteSelf, mayInviteOthers and hideAttendees (stored but not enforced) + +- Principal/getAvailability + - If there are overlapping BusyPeriod time ranges with different "busyStatus" properties + the server MUST choose the value in the following order: confirmed > unavailable > tentative. + - Return event properties + +*/ + +pub trait CalendarSyntheticId { + fn new(key: RecurrenceKey, document_id: u32) -> Self; + + fn is_synthetic(&self) -> bool; + + fn recurrence_key(&self) -> Option; +} + +impl CalendarSyntheticId for Id { + fn new(key: RecurrenceKey, document_id: u32) -> Id { + Id::from_parts(key.prefix(), document_id) + } + + fn recurrence_key(&self) -> Option { + RecurrenceKey::from_prefix(self.prefix_id()) + } + + fn is_synthetic(&self) -> bool { + self.prefix_id() != 0 + } +} + +pub(super) async fn assert_is_unique_uid( + server: &Server, + account_id: u32, + uid: Option<&str>, +) -> trc::Result>>> { + if let Some(uid) = uid + && server + .document_exists( + account_id, + Collection::CalendarEvent, + CalendarEventField::Uid, + uid.as_bytes(), + ) + .await + .caused_by(trc::location!())? + { + Ok(Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Uid) + .with_description(format!( + "An event with UID {uid} already exists.", + )))) + } else { + Ok(Ok(())) + } +} diff --git a/crates/jmap/src/calendar_event/parse.rs b/crates/jmap/src/calendar_event/parse.rs new file mode 100644 index 0000000..b6507aa --- /dev/null +++ b/crates/jmap/src/calendar_event/parse.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::blob::download::BlobDownload; +use calcard::{ + icalendar::ICalendar, + jscalendar::{JSCalendarProperty, import::ConversionOptions}, +}; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::parse::{ParseRequest, ParseResponse}, + object::calendar_event::CalendarEvent, + request::{IntoValid, MaybeInvalid}, +}; +use jmap_tools::{Key, Value}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +pub trait CalendarEventParse: Sync + Send { + fn calendar_event_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl CalendarEventParse for Server { + async fn calendar_event_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> trc::Result> { + if request.blob_ids.len() > self.core.jmap.calendar_parse_max_items { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + let return_all_properties = request.properties.is_none(); + let properties = request + .properties + .map(|v| v.into_valid().collect::>()) + .unwrap_or_default(); + + let mut response = ParseResponse { + account_id: request.account_id, + parsed: VecMap::with_capacity(request.blob_ids.len()), + not_parsable: vec![], + not_found: vec![], + }; + + for blob_id in request.blob_ids.into_valid() { + // Fetch raw message to parse + let raw_vcard = match self.blob_download(&blob_id, access_token).await? { + Some(raw_vcard) => raw_vcard, + None => { + response.not_found.push(MaybeInvalid::Value(blob_id)); + continue; + } + }; + let Ok(vcard) = ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) + else { + response.not_parsable.push(blob_id); + continue; + }; + let mut js_calendar_entries = vcard + .into_jscalendar_with_opt::(ConversionOptions::default()) + .into_inner() + .into_object() + .unwrap() + .remove(&Key::Property(JSCalendarProperty::Entries)) + .unwrap() + .into_array() + .unwrap(); + + if !return_all_properties { + for entry in &mut js_calendar_entries { + entry + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| k.as_property().is_some_and(|k| properties.contains(k))); + } + } + + response + .parsed + .append(blob_id, Value::Array(js_calendar_entries)); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs new file mode 100644 index 0000000..8de01dd --- /dev/null +++ b/crates/jmap/src/calendar_event/query.rs @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use calcard::{common::timezone::Tz, jscalendar::JSCalendarDateTime}; +use chrono::offset::TimeZone; +use common::{Server, auth::AccessToken}; +use groupware::{ + cache::GroupwareCache, + calendar::{CalendarEvent, expand::RecurrenceKey}, +}; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::{ + calendar, + calendar_event::{self, CalendarEventComparator, CalendarEventFilter}, + }, + request::MaybeInvalid, + types::state::State, +}; +use nlp::language::Language; +use std::{cmp::Ordering, sync::Arc}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + search::{CalendarSearchField, SearchComparator, SearchFilter, SearchQuery}, + write::{AlignedBytes, Archive, SearchIndex}, +}; +use trc::AddContext; +use types::{ + TimeRange, + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub trait CalendarEventQuery: Sync + Send { + fn calendar_event_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; + + fn calendar_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl CalendarEventQuery for Server { + async fn calendar_event_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC); + let mut filter: Option = None; + + // Extract from/to arguments + for cond in &request.filter { + if let Filter::Property(CalendarEventFilter::After(date)) = cond { + if let Some(after) = local_timestamp(date, default_tz) { + filter.get_or_insert_default().start = after; + } + } else if let Filter::Property(CalendarEventFilter::Before(date)) = cond + && let Some(before) = local_timestamp(date, default_tz) + { + filter.get_or_insert_default().end = before; + } + } + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + CalendarEventFilter::InCalendar(MaybeInvalid::Value(id)) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.children_ids(id.document_id()), + ))) + } + CalendarEventFilter::Uid(uid) => { + filters.push(SearchFilter::eq(CalendarSearchField::Uid, uid)); + } + CalendarEventFilter::Text(value) => { + let (text, language) = + Language::detect(value, self.core.email.default_language); + filters.push(SearchFilter::Or); + filters.push(SearchFilter::has_text( + CalendarSearchField::Title, + text.clone(), + language, + )); + filters.push(SearchFilter::has_text( + CalendarSearchField::Description, + text.clone(), + language, + )); + filters.push(SearchFilter::has_text( + CalendarSearchField::Location, + text.clone(), + language, + )); + filters.push(SearchFilter::has_text( + CalendarSearchField::Owner, + text.clone(), + language, + )); + filters.push(SearchFilter::has_text( + CalendarSearchField::Attendee, + text, + language, + )); + filters.push(SearchFilter::End); + } + CalendarEventFilter::Title(title) => { + filters.push(SearchFilter::has_text_detect( + CalendarSearchField::Title, + title, + self.core.email.default_language, + )); + } + CalendarEventFilter::Description(description) => { + filters.push(SearchFilter::has_text_detect( + CalendarSearchField::Description, + description, + self.core.email.default_language, + )); + } + CalendarEventFilter::Location(location) => { + filters.push(SearchFilter::has_text_detect( + CalendarSearchField::Location, + location, + self.core.email.default_language, + )); + } + CalendarEventFilter::Owner(owner) => { + filters.push(SearchFilter::has_text( + CalendarSearchField::Owner, + owner, + Language::None, + )); + } + CalendarEventFilter::Attendee(attendee) => { + filters.push(SearchFilter::has_text( + CalendarSearchField::Attendee, + attendee, + Language::None, + )); + } + CalendarEventFilter::After(after) => { + /* + The end of the event, or any recurrence of the event, in the time zone given + as the "timeZone" argument, must be after this date to match the condition. + */ + if let Some(after) = local_timestamp(&after, default_tz) { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + r.event_time_range() + .and_then(|(_, end)| (after < end).then_some(r.document_id)) + }), + ))); + } + } + CalendarEventFilter::Before(before) => { + /* + The start of the event, or any recurrence of the event, in the time zone given + as the "timeZone" argument, must be before this date to match the condition. + */ + + if let Some(before) = local_timestamp(&before, default_tz) { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + r.event_time_range().and_then(|(start, _)| { + (before > start).then_some(r.document_id) + }) + }), + ))); + } + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(unsupported.into_string())); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let expand_recurrences = request.arguments.expand_recurrences.unwrap_or(false); + let comparators = if !expand_recurrences { + request + .sort + .take() + .unwrap_or_default() + .into_iter() + .map(|comparator| match comparator.property { + CalendarEventComparator::Start | CalendarEventComparator::RecurrenceId => { + Ok(SearchComparator::field( + CalendarSearchField::Start, + comparator.is_ascending, + )) + } + CalendarEventComparator::Uid => Ok(SearchComparator::field( + CalendarSearchField::Uid, + comparator.is_ascending, + )), + CalendarEventComparator::Created | CalendarEventComparator::Updated => { + Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(comparator.property.into_string().into_owned())) + } + CalendarEventComparator::_T(other) => Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(other.to_string())), + }) + .collect::, _>>()? + } else { + vec![] + }; + + let results = self + .search_store() + .query_account( + SearchQuery::new(SearchIndex::Calendar) + .with_filters(filters) + .with_comparators(comparators) + .with_account_id(account_id) + .with_mask(if access_token.is_shared(account_id) { + cache.shared_items(access_token, [Acl::ReadItems], true) + } else { + cache.document_ids(false).collect() + }), + ) + .await?; + + // Extract comparators + let comparators = request + .sort + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or_default(); + + if expand_recurrences && !results.is_empty() { + let Some(time_range) = filter.filter(|f| f.start != i64::MIN && f.end != i64::MAX) + else { + return Err(trc::JmapEvent::InvalidArguments.into_err().details( + "Both 'after' and 'before' filters are required when expanding recurrences", + )); + }; + let max_instances = self.core.groupware.max_ical_instances; + let mut expanded_results = Vec::with_capacity(results.len() as usize); + let has_uid_comparator = comparators + .iter() + .any(|c| matches!(c.property, CalendarEventComparator::Uid)); + + for document_id in results { + let Some(_calendar_event) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + else { + continue; + }; + let calendar_event = _calendar_event + .unarchive::() + .caused_by(trc::location!())?; + + // Expand recurrences + let uid = if has_uid_comparator { + Arc::new( + calendar_event + .data + .event + .uids() + .next() + .unwrap_or_default() + .to_string(), + ) + } else { + Arc::new(String::new()) + }; + for expansion in calendar_event + .data + .expand(default_tz, time_range) + .unwrap_or_default() + { + let Some(recurrence_key) = expansion.recurrence_key() else { + continue; + }; + if expanded_results.len() < max_instances { + expanded_results.push(SearchResult { + created: calendar_event.created.to_native().to_be_bytes(), + updated: calendar_event.modified.to_native().to_be_bytes(), + start: expansion.start.to_be_bytes(), + uid: uid.clone(), + document_id, + recurrence_key, + }); + } else { + return Err(trc::JmapEvent::InvalidArguments.into_err().details( + "The number of expanded recurrences exceeds the server limit", + )); + } + } + } + + let mut response = QueryResponseBuilder::new( + expanded_results.len(), + self.core.jmap.query_max_results, + cache.get_state(false), + &request, + ); + // Sort results + if !expanded_results.is_empty() { + expanded_results.sort_by(|a, b| { + for comparator in comparators { + let ordering = if comparator.is_ascending { + a.get_property(&comparator.property) + .cmp(b.get_property(&comparator.property)) + } else { + b.get_property(&comparator.property) + .cmp(a.get_property(&comparator.property)) + }; + + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal + }); + + // Add results + for result in expanded_results { + if !response.add(result.recurrence_key.prefix(), result.document_id) { + break; + } + } + } + response.build() + } else { + let mut response = QueryResponseBuilder::new( + results.len(), + self.core.jmap.query_max_results, + cache.get_state(false), + &request, + ); + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + response.build() + } + } + + async fn calendar_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + + let results = cache.document_ids(true).collect::>(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + State::Initial, + &request, + ); + + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + + response.build() + } +} + +fn local_timestamp(dt: &JSCalendarDateTime, tz: Tz) -> Option { + tz.from_local_datetime(&dt.to_naive_date_time()?) + .single() + .map(|dt| dt.timestamp()) +} + +#[derive(Debug)] +struct SearchResult { + recurrence_key: RecurrenceKey, + document_id: u32, + start: [u8; std::mem::size_of::()], + created: [u8; std::mem::size_of::()], + updated: [u8; std::mem::size_of::()], + uid: Arc, +} + +impl SearchResult { + fn get_property(&self, comparator: &CalendarEventComparator) -> &[u8] { + match comparator { + CalendarEventComparator::Uid => self.uid.as_bytes(), + CalendarEventComparator::Start | CalendarEventComparator::RecurrenceId => { + self.start.as_ref() + } + CalendarEventComparator::Created => self.created.as_ref(), + CalendarEventComparator::Updated => self.updated.as_ref(), + CalendarEventComparator::_T(_) => &[], + } + } +} diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs new file mode 100644 index 0000000..0a8264c --- /dev/null +++ b/crates/jmap/src/calendar_event/set.rs @@ -0,0 +1,1779 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::calendar_event::{CalendarSyntheticId, assert_is_unique_uid}; +use crate::changes::state::JmapCacheState; +use calcard::{ + common::{PartialDateTime, timezone::Tz}, + icalendar::{ + ICalendar, ICalendarAction, ICalendarComponent, ICalendarComponentType, ICalendarDuration, + ICalendarEntry, ICalendarParameter, ICalendarParameterValue, ICalendarProperty, + ICalendarRelated, ICalendarValue, + }, + jscalendar::{JSCalendar, JSCalendarDateTime, JSCalendarProperty, JSCalendarValue}, +}; +use chrono::DateTime; +use common::{ + DavName, DavResources, Server, + auth::{AccessToken, AccountInfo}, +}; +use groupware::{ + DestroyArchive, + cache::GroupwareCache, + calendar::{ + ALERT_EMAIL, ALERT_RELATIVE_TO_END, ArchivedDefaultAlert, Calendar, CalendarEvent, + CalendarEventData, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, + EVENT_INVITE_SELF, PREF_USE_DEFAULT_ALERTS, + expand::{CalendarEventExpansion, ComponentRecurrenceId, RecurrenceKey, resolve_local}, + itip::ItipSendStatus, + }, + scheduling::{ + ItipMessages, + event_create::itip_create, + event_update::itip_update, + itip::{itip_assign_organizer, itip_unreachable_recipient}, + }, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::calendar_event, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Map, Value}; +use std::{borrow::Cow, str::FromStr}; +use store::{ + ValueKey, + ahash::AHashSet, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, now, serialize::rkyv_deserialize}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection, VanishedCollection}, + id::Id, +}; + +pub trait CalendarEventSet: Sync + Send { + fn calendar_event_set( + &self, + request: SetRequest<'_, calendar_event::CalendarEvent>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; + + #[allow(clippy::too_many_arguments)] + fn create_calendar_event( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account_id: u32, + account_info: &AccountInfo, + send_scheduling_messages: bool, + can_add_calendars: &Option, + js_calendar_event: JSCalendar<'_, Id, BlobId>, + updates: Value<'_, JSCalendarProperty, JSCalendarValue>, + ) -> impl Future>>>>; +} + +impl CalendarEventSet for Server { + async fn calendar_event_set( + &self, + mut request: SetRequest<'_, calendar_event::CalendarEvent>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await?; + let account_info = self + .scheduling_account_info(access_token.account_id(), account_id) + .await + .caused_by(trc::location!())?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(false, &request.if_in_state)?); + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + + // Obtain calendarIds + let (can_add_calendars, can_delete_calendars, can_modify_calendars) = + if access_token.is_shared(account_id) { + ( + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::RemoveItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::ModifyItems], true) + .into(), + ) + } else { + (None, None, None) + }; + + // Process creates + let mut batch = BatchBuilder::new(); + let send_scheduling_messages = request.arguments.send_scheduling_messages.unwrap_or(false); + 'create: for (id, object) in request.unwrap_create() { + match self + .create_calendar_event( + &cache, + &mut batch, + access_token, + account_id, + &account_info, + send_scheduling_messages, + &can_add_calendars, + JSCalendar::default(), + object, + ) + .await? + { + Ok(document_id) => { + response.created(id, document_id); + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + } + } + + // Group updates and instance removals by event + let has_synthetic_ids = will_destroy.iter().any(|id| id.is_synthetic()) + || request.update.as_ref().is_some_and(|update| { + update + .iter() + .any(|(id, _)| matches!(id, MaybeInvalid::Value(id) if id.is_synthetic())) + }); + let is_destroyed_event = |document_id: u32| { + will_destroy + .iter() + .any(|id| !id.is_synthetic() && id.document_id() == document_id) + }; + let will_be_destroyed = |id: Id| { + will_destroy.iter().any(|destroy_id| { + *destroy_id == id + || (!destroy_id.is_synthetic() && destroy_id.document_id() == id.document_id()) + }) + }; + let mut updates: Vec = + Vec::with_capacity(request.update.as_ref().map_or(0, |update| update.len())); + for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue; + } + }; + let document_id = id.document_id(); + if will_be_destroyed(id) { + response.not_updated.append(id, SetError::will_destroy()); + continue; + } + let update = EventUpdate::for_document(&mut updates, document_id, has_synthetic_ids); + if let Some(recurrence_key) = id.recurrence_key() { + update.instances.push(InstanceOp { + id, + recurrence_key, + patch: Some(object), + target: None, + is_destroy: false, + }); + } else if update.base_id.is_none() { + update.base_id = Some(id); + update.base_patch = Some(object); + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description("Duplicate event id."), + ); + } + } + for id in will_destroy.iter().copied() { + let Some(recurrence_key) = id.recurrence_key() else { + continue; + }; + let document_id = id.document_id(); + if is_destroyed_event(document_id) { + response.not_destroyed.append(id, SetError::will_destroy()); + continue; + } + EventUpdate::for_document(&mut updates, document_id, has_synthetic_ids) + .instances + .push(InstanceOp { + id, + recurrence_key, + patch: None, + target: None, + is_destroy: true, + }); + } + let mut destroy_events = will_destroy; + if has_synthetic_ids { + destroy_events.retain(|id| !id.is_synthetic()); + } + + // Process updates + 'update: for mut update in updates { + let document_id = update.document_id; + if update.base_id.is_some() && !update.instances.is_empty() { + update.fail( + &mut response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description(concat!( + "A base event and its instances cannot be modified ", + "in the same request." + )), + ); + continue 'update; + } + let calendar_event_ = if let Some(calendar_event_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + { + calendar_event_ + } else { + update.fail(&mut response, SetError::not_found()); + continue 'update; + }; + let calendar_event = calendar_event_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_calendar_event = calendar_event + .deserialize::() + .caused_by(trc::location!())?; + + // Resolve synthetic ids into recurrence instances + let mut has_instances = false; + if !update.instances.is_empty() { + match update.plan_instances(&new_calendar_event.data, &mut response) { + InstancePlan::Instances => { + has_instances = true; + } + InstancePlan::BaseEvent => {} + InstancePlan::DestroyEvent(id) => { + destroy_events.push(id); + continue 'update; + } + InstancePlan::Nothing => { + continue 'update; + } + } + } + + let mut js_calendar_group = + std::mem::take(&mut new_calendar_event.data.event).into_jscalendar::(); + + // Apply per-instance changes to the recurrence overrides of the base event + if has_instances && !update.apply_instances(&mut js_calendar_group, &mut response) { + continue 'update; + } + + // Process changes + if let Err(err) = update_calendar_event( + access_token.personal_id(account_id, Collection::Calendar), + update.base_id, + update.base_patch.take().unwrap_or_default(), + &mut new_calendar_event, + &mut js_calendar_group, + ) { + update.fail(&mut response, err); + continue 'update; + } + + // Convert JSCalendar to iCalendar + let Some(ical) = js_calendar_group.into_icalendar() else { + update.fail( + &mut response, + SetError::invalid_properties() + .with_description("Failed to convert calendar event to iCalendar."), + ); + continue 'update; + }; + new_calendar_event.data.event = ical; + stamp_updated(&mut new_calendar_event.data.event, now() as i64); + + // Assign an organizer when participants were added to an event that had none + if let Some(organizer_address) = account_info.addresses().first() { + itip_assign_organizer(&mut new_calendar_event.data.event, organizer_address); + } + + // Validate UID + match ( + new_calendar_event.data.event.uids().next(), + calendar_event.inner.data.event.uids().next(), + ) { + (Some(old_uid), Some(new_uid)) if old_uid == new_uid => {} + (None, None) | (None, Some(_)) => {} + _ => { + update.fail( + &mut response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Uid) + .with_description("You cannot change the UID of a calendar event."), + ); + continue 'update; + } + } + + // Validate new calendarIds + for calendar_id in new_calendar_event.added_calendar_ids(calendar_event.inner) { + if !cache.has_container_id(&calendar_id) { + update.fail( + &mut response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::CalendarIds) + .with_description(format!( + "calendarId {} does not exist.", + Id::from(calendar_id) + )), + ); + continue 'update; + } else if can_add_calendars + .as_ref() + .is_some_and(|ids| !ids.contains(calendar_id)) + { + update.fail( + &mut response, + SetError::forbidden().with_description(format!( + "You are not allowed to add calendar events to calendar {}.", + Id::from(calendar_id) + )), + ); + continue 'update; + } + } + + // Validate deleted calendarIds + if let Some(can_delete_calendars) = &can_delete_calendars { + for calendar_id in new_calendar_event.removed_calendar_ids(calendar_event.inner) { + if !can_delete_calendars.contains(calendar_id) { + update.fail( + &mut response, + SetError::forbidden().with_description(format!( + "You are not allowed to remove calendar events from calendar {}.", + Id::from(calendar_id) + )), + ); + continue 'update; + } + } + } + + // Validate changed calendarIds + if let Some(can_modify_calendars) = &can_modify_calendars { + for calendar_id in new_calendar_event.unchanged_calendar_ids(calendar_event.inner) { + if !can_modify_calendars.contains(calendar_id) { + update.fail( + &mut response, + SetError::forbidden().with_description(format!( + "You are not allowed to modify calendar {}.", + Id::from(calendar_id) + )), + ); + continue 'update; + } + } + } + + // Check size and quota + new_calendar_event.size = new_calendar_event.data.event.size() as u32; + if new_calendar_event.size as usize > self.core.groupware.max_ical_size { + update.fail( + &mut response, + SetError::invalid_properties().with_description(format!( + "Event size {} exceeds the maximum allowed size of {} bytes.", + new_calendar_event.size, self.core.groupware.max_ical_size + )), + ); + continue 'update; + } + + // Obtain previous alarm + let now = now() as i64; + let prev_email_alarm = calendar_event.inner.data.next_alarm(now, Tz::Floating); + + // Build event + let mut next_email_alarm = None; + new_calendar_event.data = CalendarEventData::new( + new_calendar_event.data.event, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + + // Scheduling + let mut itip_messages = None; + let itip_status = if send_scheduling_messages { + ItipSendStatus::resolve( + self, + access_token, + &account_info, + new_calendar_event.data.event_range_end(), + ) + } else { + ItipSendStatus::NotRequested + }; + if itip_status.is_send() { + if let Some(calendar_address) = itip_unreachable_recipient( + &new_calendar_event.data.event, + account_info.addresses(), + ) { + update.fail( + &mut response, + SetError::no_supported_schedule_methods(calendar_address), + ); + continue 'update; + } + + let result = if new_calendar_event.schedule_tag.is_some() { + let old_ical = rkyv_deserialize(&calendar_event.inner.data.event) + .caused_by(trc::location!())?; + + itip_update( + &mut new_calendar_event.data.event, + &old_ical, + account_info.addresses(), + ) + } else { + itip_create(&mut new_calendar_event.data.event, account_info.addresses()) + }; + + match result { + Ok(messages) => { + let mut is_organizer = false; + if messages + .iter() + .map(|r| { + is_organizer = r.from_organizer; + r.to.len() + }) + .sum::() + < self.core.groupware.itip_outbound_max_recipients + { + // Only update schedule tag if the user is the organizer + if is_organizer { + if let Some(schedule_tag) = &mut new_calendar_event.schedule_tag { + *schedule_tag += 1; + } else { + new_calendar_event.schedule_tag = Some(1); + } + } + + itip_messages = Some(ItipMessages::new(messages)); + } else { + update.fail( + &mut response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Participants) + .with_description(concat!( + "The number of scheduling message recipients ", + "exceeds the maximum allowed." + )), + ); + continue 'update; + } + } + Err(err) => { + if err.is_jmap_error() { + update.fail( + &mut response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Participants) + .with_description(err.to_string()), + ); + continue 'update; + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + Reason = err.to_string(), + ); + + // Event changed, but there are no iTIP messages to send + if let Some(schedule_tag) = &mut new_calendar_event.schedule_tag { + *schedule_tag += 1; + } + } + } + } else if let Some(reason) = itip_status.reason() { + if itip_status.is_denied() { + update.fail( + &mut response, + SetError::forbidden().with_description(reason), + ); + continue 'update; + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + Reason = reason, + ); + } + + // Validate quota + let extra_bytes = (new_calendar_event.size as u64) + .saturating_sub(u32::from(calendar_event.inner.size) as u64); + if extra_bytes > 0 { + match self + .has_available_quota(account_info.account(), extra_bytes) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + update.fail(&mut response, SetError::over_quota()); + continue 'update; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + } + + // Update record + let vanished_paths = new_calendar_event + .removed_calendar_ids(calendar_event.inner) + .filter_map(|calendar_id| { + cache.format_resource_path_by_parent(document_id, calendar_id) + }) + .collect::>(); + new_calendar_event + .update( + access_token.account_tenant_ids(), + calendar_event, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + for path in vanished_paths { + batch.log_vanished_item(VanishedCollection::Calendar, path); + } + if prev_email_alarm != next_email_alarm { + if let Some(prev_alarm) = prev_email_alarm { + prev_alarm.delete_task(&mut batch); + } + if let Some(next_alarm) = next_email_alarm { + next_alarm.write_task(&mut batch); + } + } + if let Some(itip_messages) = itip_messages { + itip_messages + .queue(&mut batch) + .caused_by(trc::location!())?; + } + + update.succeed(&mut response); + } + + // Process deletions + 'destroy: for id in destroy_events { + let document_id = id.document_id(); + + if !cache.has_item_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + } + + let Some(calendar_event_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let calendar_event = calendar_event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if let Some(can_delete_calendars) = &can_delete_calendars { + for name in calendar_event.inner.names.iter() { + let parent_id = name.parent_id.to_native(); + if !can_delete_calendars.contains(parent_id) { + response.not_destroyed.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to remove events from calendar {}.", + Id::from(parent_id) + )), + ); + continue 'destroy; + } + } + } + + // Scheduling + let itip_status = if send_scheduling_messages { + ItipSendStatus::resolve( + self, + access_token, + &account_info, + calendar_event.inner.data.event_range_end(), + ) + } else { + ItipSendStatus::NotRequested + }; + if let Some(reason) = itip_status.reason() { + if itip_status.is_denied() { + response + .not_destroyed + .append(id, SetError::forbidden().with_description(reason)); + continue 'destroy; + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + Reason = reason, + ); + } + + // Delete event + DestroyArchive(calendar_event) + .delete_all( + &account_info, + account_id, + document_id, + itip_status.is_send(), + &mut batch, + ) + .caused_by(trc::location!())?; + + for path in cache.format_resource_paths_by_id(document_id) { + batch.log_vanished_item(VanishedCollection::Calendar, path); + } + + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + self.notify_task_queue(); + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } + + async fn create_calendar_event( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account_id: u32, + account_info: &AccountInfo, + send_scheduling_messages: bool, + can_add_calendars: &Option, + mut js_calendar_group: JSCalendar<'_, Id, BlobId>, + updates: Value<'_, JSCalendarProperty, JSCalendarValue>, + ) -> trc::Result>>> { + // Process changes + let mut event = CalendarEvent::default(); + let use_default_alerts = match update_calendar_event( + access_token.personal_id(account_id, Collection::Calendar), + None, + updates, + &mut event, + &mut js_calendar_group, + ) { + Ok(use_default_alerts) => use_default_alerts, + Err(err) => { + return Ok(Err(err)); + } + }; + + // Convert JSCalendar to iCalendar + let Some(mut ical) = js_calendar_group.into_icalendar() else { + return Ok(Err(SetError::invalid_properties().with_description( + "Failed to convert calendar event to iCalendar.", + ))); + }; + stamp_updated(&mut ical, now() as i64); + + // Generate a UID when the client omitted one + if ical.uids().next().is_none() { + let uid = generate_uid(); + for component in &mut ical.components { + if component.component_type.is_event_or_todo() { + component.add_uid(&uid); + } + } + } + + // Verify that the calendar ids valid + let default_alert_comp_id = ical.components.len(); + for name in &event.names { + if !cache.has_container_id(&name.parent_id) { + return Ok(Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::CalendarIds) + .with_description(format!( + "calendarId {} does not exist.", + Id::from(name.parent_id) + )))); + } else if can_add_calendars + .as_ref() + .is_some_and(|ids| !ids.contains(name.parent_id)) + { + return Ok(Err(SetError::forbidden().with_description(format!( + "You are not allowed to add calendar events to calendar {}.", + Id::from(name.parent_id) + )))); + } else if let Some(show_without_time) = use_default_alerts + && let Some(_calendar) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Calendar, + name.parent_id, + )) + .await? + { + ical.components.extend( + _calendar + .unarchive::() + .caused_by(trc::location!())? + .default_alerts( + access_token.personal_id(account_id, Collection::Calendar), + !show_without_time, + ) + .map(default_alert_to_ical), + ); + } + } + + // Add default alarms + if ical.components.len() > default_alert_comp_id { + let component_ids = default_alert_comp_id as u32..ical.components.len() as u32; + for component in &mut ical.components { + if component.component_type.is_event_or_todo() + && !component.is_recurrence_override() + { + component.component_ids.extend(component_ids.clone()); + } + } + } + + // Assign an organizer when the event has participants but none was provided + if let Some(organizer_address) = account_info.addresses().first() { + itip_assign_organizer(&mut ical, organizer_address); + } + + // Validate UID + if let Err(err) = assert_is_unique_uid(self, account_id, ical.uids().next()).await? { + return Ok(Err(err)); + } + + // Check size and quota + let size = ical.size(); + if size > self.core.groupware.max_ical_size { + return Ok(Err(SetError::invalid_properties().with_description( + format!( + "Event size {} exceeds the maximum allowed size of {} bytes.", + size, self.core.groupware.max_ical_size + ), + ))); + } + + // Build event + let mut next_email_alarm = None; + event.data = CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + event.size = size as u32; + + // Scheduling + let mut itip_messages = None; + let itip_status = if send_scheduling_messages { + ItipSendStatus::resolve( + self, + access_token, + account_info, + event.data.event_range_end(), + ) + } else { + ItipSendStatus::NotRequested + }; + if itip_status.is_send() { + if let Some(calendar_address) = + itip_unreachable_recipient(&event.data.event, account_info.addresses()) + { + return Ok(Err(SetError::no_supported_schedule_methods( + calendar_address, + ))); + } + + match itip_create(&mut event.data.event, account_info.addresses()) { + Ok(messages) => { + if messages.iter().map(|r| r.to.len()).sum::() + < self.core.groupware.itip_outbound_max_recipients + { + event.schedule_tag = Some(1); + itip_messages = Some(ItipMessages::new(messages)); + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Participants) + .with_description(concat!( + "The number of scheduling message recipients ", + "exceeds the maximum allowed." + )))); + } + } + Err(err) => { + if err.is_jmap_error() { + return Ok(Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Participants) + .with_description(err.to_string()))); + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + Reason = err.to_string(), + ); + } + } + } else if let Some(reason) = itip_status.reason() { + if itip_status.is_denied() { + return Ok(Err(SetError::forbidden().with_description(reason))); + } + + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + Reason = reason, + ); + } + + // Validate quota + match self + .has_available_quota(account_info.account(), size as u64) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + return Ok(Err(SetError::over_quota())); + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::CalendarEvent, 1) + .await + .caused_by(trc::location!())?; + event + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + next_email_alarm, + batch, + ) + .caused_by(trc::location!())?; + + if let Some(itip_messages) = itip_messages { + itip_messages.queue(batch).caused_by(trc::location!())?; + } + + Ok(Ok(document_id)) + } +} + +fn stamp_updated(ical: &mut ICalendar, timestamp: i64) { + let dtstamp = PartialDateTime::from_utc_timestamp(timestamp); + for component in &mut ical.components { + if !component.component_type.is_event_or_todo() { + continue; + } + if let Some(entry) = component + .entries + .iter_mut() + .find(|entry| entry.name == ICalendarProperty::Dtstamp) + { + entry.values = vec![ICalendarValue::PartialDateTime(Box::new(dtstamp.clone()))]; + } else { + component.add_dtstamp(dtstamp.clone()); + } + } +} + +fn update_calendar_event<'x>( + personal_id: u32, + expected_id: Option, + updates: Value<'x, JSCalendarProperty, JSCalendarValue>, + event: &mut CalendarEvent, + js_calendar_group: &mut JSCalendar<'x, Id, BlobId>, +) -> Result, SetError>> { + // Extract event + let js_calendar_events = js_calendar_group + .0 + .as_object_mut() + .unwrap() + .get_mut(&Key::Property(JSCalendarProperty::Entries)) + .unwrap() + .as_array_mut() + .unwrap(); + + let js_calendar_event = if let Some(js_calendar_event) = js_calendar_events.first_mut() { + js_calendar_event + } else { + js_calendar_events.push(Value::Object(Map::new())); + js_calendar_events.first_mut().unwrap() + }; + + let mut utc_start = None; + let mut utc_end = None; + let mut use_default_alerts = false; + let mut show_without_time = false; + let mut entries = js_calendar_event.as_object_mut().unwrap(); + + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (JSCalendarProperty::IsDraft, Value::Bool(set)) => { + if set { + event.flags |= EVENT_DRAFT; + } else { + event.flags &= !EVENT_DRAFT; + } + } + (JSCalendarProperty::MayInviteSelf, Value::Bool(set)) => { + if set { + event.flags |= EVENT_INVITE_SELF; + } else { + event.flags &= !EVENT_INVITE_SELF; + } + } + (JSCalendarProperty::MayInviteOthers, Value::Bool(set)) => { + if set { + event.flags |= EVENT_INVITE_OTHERS; + } else { + event.flags &= !EVENT_INVITE_OTHERS; + } + } + (JSCalendarProperty::HideAttendees, Value::Bool(set)) => { + if set { + event.flags |= EVENT_HIDE_ATTENDEES; + } else { + event.flags &= !EVENT_HIDE_ATTENDEES; + } + } + (JSCalendarProperty::UseDefaultAlerts, Value::Bool(set)) => { + use_default_alerts = set; + if set { + event.preferences_mut(personal_id).flags |= PREF_USE_DEFAULT_ALERTS; + } else if let Some(preferences) = event + .preferences + .iter_mut() + .find(|p| p.account_id == personal_id) + { + preferences.flags &= !PREF_USE_DEFAULT_ALERTS; + } + } + (JSCalendarProperty::UtcStart, Value::Element(JSCalendarValue::DateTime(start))) => { + utc_start = Some(start.timestamp); + } + (JSCalendarProperty::UtcEnd, Value::Element(JSCalendarValue::DateTime(end))) => { + utc_end = Some(end.timestamp); + } + (JSCalendarProperty::CalendarIds, value) => { + patch_parent_ids(&mut event.names, None, value)?; + } + (JSCalendarProperty::Pointer(pointer), value) => { + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + JSCalendarProperty::CalendarIds + ))) + ) { + let mut pointer = pointer.iter(); + pointer.next(); + patch_parent_ids(&mut event.names, pointer.next(), value)?; + } else if !js_calendar_event.patch_jptr(pointer.iter(), value) { + return Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Pointer(pointer)) + .with_description("Patch operation failed.")); + } + entries = js_calendar_event.as_object_mut().unwrap(); + } + (JSCalendarProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description("This property is immutable.")); + } + } + ( + property @ (JSCalendarProperty::BaseEventId + | JSCalendarProperty::IsOrigin + | JSCalendarProperty::Method), + value, + ) => { + if entries.get(&Key::Property(property.clone())) != Some(&value) { + return Err(SetError::invalid_properties() + .with_property(property) + .with_description("This property is immutable.")); + } + } + ( + property @ (JSCalendarProperty::IsDraft + | JSCalendarProperty::MayInviteSelf + | JSCalendarProperty::MayInviteOthers + | JSCalendarProperty::HideAttendees + | JSCalendarProperty::UseDefaultAlerts + | JSCalendarProperty::UtcStart + | JSCalendarProperty::UtcEnd), + _, + ) => { + return Err(SetError::invalid_properties() + .with_property(property) + .with_description("Invalid value.")); + } + ( + property @ (JSCalendarProperty::Locations | JSCalendarProperty::Participants), + Value::Object(values), + ) => { + for (_, value) in values.iter() { + if let Some(values) = value + .as_object_and_get(&Key::Property(JSCalendarProperty::Links)) + .and_then(|v| v.as_object()) + { + for (_, value) in values.iter() { + if value.as_object().is_some_and(|v| { + v.keys() + .any(|k| matches!(k, Key::Property(JSCalendarProperty::BlobId))) + }) { + return Err(SetError::invalid_properties() + .with_property(property) + .with_description("blobIds in links is not supported.")); + } + } + } + } + entries.insert(property, Value::Object(values)); + } + (property, value) => { + if let (JSCalendarProperty::ShowWithoutTime, Value::Bool(set)) = (&property, &value) + { + show_without_time = *set; + } + + entries.insert(property, value); + } + } + } + + // Validate UTC start/end + if let (Some(mut start), Some(mut end)) = (utc_start, utc_end) { + if start >= end { + return Err(SetError::invalid_properties() + .with_properties([JSCalendarProperty::UtcStart, JSCalendarProperty::UtcEnd]) + .with_description("utcStart must be before utcEnd.")); + } + + if let Some(timezone) = entries + .get(&Key::Property(JSCalendarProperty::TimeZone)) + .and_then(|v| v.as_str()) + .and_then(|tz| Tz::from_str(tz.as_ref()).ok()) + { + if let Some(dt_start) = + DateTime::from_timestamp(start, 0).map(|dt| dt.with_timezone(&timezone)) + { + start = dt_start.naive_local().and_utc().timestamp(); + } + if let Some(dt_end) = + DateTime::from_timestamp(end, 0).map(|dt| dt.with_timezone(&timezone)) + { + end = dt_end.naive_local().and_utc().timestamp(); + } + } else { + entries.insert( + Key::Property(JSCalendarProperty::TimeZone), + Value::Str(Cow::Borrowed("Etc/UTC")), + ); + } + + entries.insert( + Key::Property(JSCalendarProperty::Start), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + start, true, + ))), + ); + entries.insert( + Key::Property(JSCalendarProperty::Duration), + Value::Element(JSCalendarValue::Duration(ICalendarDuration::from_seconds( + end - start, + ))), + ); + } else if utc_start.is_some() || utc_end.is_some() { + return Err(SetError::invalid_properties() + .with_properties([JSCalendarProperty::UtcStart, JSCalendarProperty::UtcEnd]) + .with_description("Both utcStart and utcEnd must be provided.")); + } + + // Make sure the calendar_event belongs to at least one calendar + if event.names.is_empty() { + return Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::CalendarIds) + .with_description("Event has to belong to at least one calendar.")); + } + + Ok(use_default_alerts.then_some(show_without_time)) +} + +struct EventUpdate<'x> { + document_id: u32, + base_id: Option, + base_patch: Option, JSCalendarValue>>, + instances: Vec>, +} + +struct InstanceOp<'x> { + id: Id, + recurrence_key: RecurrenceKey, + patch: Option, JSCalendarValue>>, + target: Option, + is_destroy: bool, +} + +struct InstanceTarget { + is_override: bool, + recurrence_id: i64, + recurrence_id_naive: i64, + start_naive: i64, + duration: i64, +} + +enum InstancePlan { + Instances, + BaseEvent, + DestroyEvent(Id), + Nothing, +} + +enum InstanceResolution { + Instance(InstanceTarget), + BaseEvent, + ThisAndFuture, + NotFound, +} + +trait JSCalendarEvent<'x> { + fn event_mut( + &mut self, + ) -> Option<&mut Value<'x, JSCalendarProperty, JSCalendarValue>>; +} + +impl<'x> JSCalendarEvent<'x> for JSCalendar<'x, Id, BlobId> { + fn event_mut( + &mut self, + ) -> Option<&mut Value<'x, JSCalendarProperty, JSCalendarValue>> { + self.0 + .as_object_mut()? + .get_mut(&Key::Property(JSCalendarProperty::Entries))? + .as_array_mut()? + .first_mut() + } +} + +impl<'x> EventUpdate<'x> { + fn for_document<'y>( + updates: &'y mut Vec>, + document_id: u32, + has_synthetic_ids: bool, + ) -> &'y mut EventUpdate<'x> { + let index = if has_synthetic_ids { + updates + .iter() + .position(|update| update.document_id == document_id) + } else { + None + }; + + match index { + Some(index) => &mut updates[index], + None => { + updates.push(EventUpdate { + document_id, + base_id: None, + base_patch: None, + instances: Vec::new(), + }); + updates.last_mut().unwrap() + } + } + } + + fn fail( + &self, + response: &mut SetResponse, + err: SetError>, + ) { + if let Some(id) = self.base_id { + response.not_updated.append(id, err.clone()); + } + for instance in &self.instances { + instance.fail(response, err.clone()); + } + } + + fn succeed(&self, response: &mut SetResponse) { + if let Some(id) = self.base_id { + response.updated.append(id, None); + } + for instance in &self.instances { + if instance.is_destroy { + response.destroyed.push(instance.id); + } else { + response.updated.append(instance.id, None); + } + } + } + + fn plan_instances( + &mut self, + data: &CalendarEventData, + response: &mut SetResponse, + ) -> InstancePlan { + let mut recurrence_keys = self + .instances + .iter() + .map(|instance| instance.recurrence_key) + .collect::>(); + let expansions = data + .expand_from_ids(&mut recurrence_keys, Tz::UTC) + .unwrap_or_default(); + let uid = data.event.uids().next(); + let mut has_base_event = false; + + self.instances.retain_mut(|instance| { + let mut matches = expansions + .iter() + .filter(|expansion| expansion.recurrence_key() == Some(instance.recurrence_key)); + let resolution = match (matches.next(), matches.next()) { + (Some(expansion), None) => InstanceTarget::resolve(expansion, data, uid), + _ => InstanceResolution::NotFound, + }; + + match resolution { + InstanceResolution::Instance(target) => { + instance.target = Some(target); + true + } + InstanceResolution::BaseEvent => { + has_base_event = true; + true + } + InstanceResolution::ThisAndFuture => { + instance.fail( + response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description(concat!( + "Occurrences of a this-and-future change cannot be ", + "modified individually." + )), + ); + false + } + InstanceResolution::NotFound => { + instance.fail(response, SetError::not_found()); + false + } + } + }); + + if has_base_event { + if self.instances.len() > 1 { + self.fail( + response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description(concat!( + "A base event and its instances cannot be modified ", + "in the same request." + )), + ); + return InstancePlan::Nothing; + } + + let instance = self.instances.pop().unwrap(); + return if instance.is_destroy { + InstancePlan::DestroyEvent(instance.id) + } else { + self.base_id = Some(instance.id); + self.base_patch = instance.patch; + InstancePlan::BaseEvent + }; + } + + if self.instances.is_empty() { + InstancePlan::Nothing + } else { + InstancePlan::Instances + } + } + + fn apply_instances( + &mut self, + js_calendar_group: &mut JSCalendar<'x, Id, BlobId>, + response: &mut SetResponse, + ) -> bool { + let Some(js_calendar_event) = js_calendar_group.event_mut() else { + self.fail( + response, + SetError::invalid_properties() + .with_description("Failed to convert calendar event to JSCalendar."), + ); + return false; + }; + let tz = js_calendar_event + .as_object_and_get(&Key::Property(JSCalendarProperty::TimeZone)) + .and_then(|tz| tz.as_str()) + .and_then(|tz| Tz::from_str(tz.as_ref()).ok()) + .unwrap_or(Tz::UTC); + let duration = js_calendar_event + .as_object_and_get(&Key::Property(JSCalendarProperty::Duration)) + .cloned(); + + self.instances.retain_mut(|instance| { + let Some(target) = instance.target.take() else { + return false; + }; + let key = match target.find_override(js_calendar_event, tz) { + Some(key) => key, + None if !target.is_override => { + JSCalendarDateTime::new(target.recurrence_id_naive, true) + } + None => { + instance.fail( + response, + SetError::invalid_properties() + .with_property(JSCalendarProperty::RecurrenceOverrides) + .with_description( + "Failed to resolve the recurrence id of this instance.", + ), + ); + return false; + } + }; + + match target.apply( + js_calendar_event, + key, + duration.as_ref(), + instance.patch.take(), + instance.id, + ) { + Ok(_) => true, + Err(err) => { + instance.fail(response, err); + false + } + } + }); + + !self.instances.is_empty() + } +} + +impl InstanceOp<'_> { + fn fail( + &self, + response: &mut SetResponse, + err: SetError>, + ) { + if self.is_destroy { + response.not_destroyed.append(self.id, err); + } else { + response.not_updated.append(self.id, err); + } + } +} + +impl InstanceTarget { + fn resolve( + expansion: &CalendarEventExpansion, + data: &CalendarEventData, + uid: Option<&str>, + ) -> InstanceResolution { + let Some(component) = data.event.components.get(expansion.comp_id as usize) else { + return InstanceResolution::NotFound; + }; + if component + .property(&ICalendarProperty::Uid) + .and_then(|entry| entry.values.first()) + .and_then(|value| value.as_text()) + .is_some_and(|value| uid.is_some_and(|uid| uid != value)) + { + return InstanceResolution::NotFound; + } + + let is_override = component.is_recurrence_override(); + if !is_override && !component.is_recurrent() { + return InstanceResolution::BaseEvent; + } + + if is_override && expansion.own_recurrence_id.is_none() { + return if data + .component_tz(expansion.comp_id) + .and_then(|component_tz| component.recurrence_id(component_tz)) + .is_none() + { + InstanceResolution::NotFound + } else { + InstanceResolution::ThisAndFuture + }; + } + let recurrence_id = expansion.recurrence_id(); + + InstanceResolution::Instance(InstanceTarget { + is_override, + recurrence_id: recurrence_id.utc, + recurrence_id_naive: recurrence_id.naive, + start_naive: expansion.start_naive, + duration: expansion.end - expansion.start, + }) + } + + fn find_override( + &self, + js_calendar_event: &Value<'_, JSCalendarProperty, JSCalendarValue>, + tz: Tz, + ) -> Option { + js_calendar_event + .as_object_and_get(&Key::Property(JSCalendarProperty::RecurrenceOverrides))? + .as_object()? + .keys() + .filter_map(|key| match key { + Key::Property(JSCalendarProperty::DateTime(date_time)) => Some(date_time), + _ => None, + }) + .find(|date_time| { + date_time.timestamp == self.recurrence_id_naive + || resolve_local(tz, date_time.timestamp) == Some(self.recurrence_id) + }) + .cloned() + } + + fn apply<'x>( + &self, + js_calendar_event: &mut Value<'x, JSCalendarProperty, JSCalendarValue>, + key: JSCalendarDateTime, + duration: Option<&Value<'x, JSCalendarProperty, JSCalendarValue>>, + patch: Option, JSCalendarValue>>, + id: Id, + ) -> Result<(), SetError>> { + let invalid_event = + || SetError::invalid_properties().with_description("Failed to parse stored event."); + let key = Key::Property(JSCalendarProperty::DateTime(key)); + + let patch = match patch { + Some(patch) => patch.into_object().ok_or_else(|| { + SetError::invalid_properties() + .with_property(JSCalendarProperty::RecurrenceOverrides) + .with_description("Expected a patch object.") + })?, + None => { + js_calendar_event + .as_object_mut() + .ok_or_else(invalid_event)? + .insert_or_get_mut( + Key::Property(JSCalendarProperty::RecurrenceOverrides), + Value::Object(Map::new()), + ) + .as_object_mut() + .ok_or_else(invalid_event)? + .insert( + key, + Value::Object(Map::from(vec![( + Key::Property(JSCalendarProperty::Excluded), + Value::Bool(true), + )])), + ); + + return Ok(()); + } + }; + + for (property, value) in patch.iter() { + Self::validate(property, value, id)?; + } + + let instance = js_calendar_event + .as_object_mut() + .ok_or_else(invalid_event)? + .insert_or_get_mut( + Key::Property(JSCalendarProperty::RecurrenceOverrides), + Value::Object(Map::new()), + ) + .as_object_mut() + .ok_or_else(invalid_event)? + .insert_or_get_mut(key, Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(invalid_event)?; + + if !instance.contains_key(&Key::Property(JSCalendarProperty::Start)) { + instance.insert( + Key::Property(JSCalendarProperty::Start), + Value::Element(JSCalendarValue::DateTime(JSCalendarDateTime::new( + self.start_naive, + true, + ))), + ); + } + if !instance.contains_key(&Key::Property(JSCalendarProperty::Duration)) { + instance.insert( + Key::Property(JSCalendarProperty::Duration), + duration.cloned().unwrap_or_else(|| { + Value::Element(JSCalendarValue::Duration(ICalendarDuration::from_seconds( + self.duration, + ))) + }), + ); + } + + for (property, value) in patch.into_vec() { + if matches!(Self::validate(&property, &value, id), Ok(true)) { + instance.insert(property, value); + } + } + + Ok(()) + } + + fn validate( + property: &Key<'_, JSCalendarProperty>, + value: &Value<'_, JSCalendarProperty, JSCalendarValue>, + id: Id, + ) -> Result>> { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + let rejected = SetError::invalid_properties() + .with_property(property.clone()) + .with_description("This property cannot be modified on a single occurrence."); + + match property { + JSCalendarProperty::Id => { + if crate::matches_id(value, id) { + Ok(false) + } else { + Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::Id) + .with_description("This property is immutable.")) + } + } + JSCalendarProperty::Pointer(pointer) => { + let mut tokens = pointer.iter(); + let (Some(JsonPointerItem::Key(Key::Property(first))), third) = + (tokens.next(), tokens.nth(1)) + else { + return Err(rejected); + }; + + if Self::is_event_property(first) { + Err(rejected) + } else { + Ok(!Self::is_inherited_property(first) + && !matches!( + (first, third), + ( + JSCalendarProperty::Participants, + Some(JsonPointerItem::Key(Key::Property( + JSCalendarProperty::CalendarAddress + ))) + ) + )) + } + } + property if Self::is_event_property(property) => Err(rejected), + property => Ok(!Self::is_inherited_property(property)), + } + } + + fn is_event_property(property: &JSCalendarProperty) -> bool { + matches!( + property, + JSCalendarProperty::BaseEventId + | JSCalendarProperty::CalendarIds + | JSCalendarProperty::IsDraft + | JSCalendarProperty::IsOrigin + | JSCalendarProperty::UtcStart + | JSCalendarProperty::UtcEnd + | JSCalendarProperty::UseDefaultAlerts + | JSCalendarProperty::MayInviteSelf + | JSCalendarProperty::MayInviteOthers + | JSCalendarProperty::HideAttendees + ) + } + + fn is_inherited_property(property: &JSCalendarProperty) -> bool { + matches!( + property, + JSCalendarProperty::Type + | JSCalendarProperty::Method + | JSCalendarProperty::OrganizerCalendarAddress + | JSCalendarProperty::Privacy + | JSCalendarProperty::ProdId + | JSCalendarProperty::RecurrenceId + | JSCalendarProperty::RecurrenceIdTimeZone + | JSCalendarProperty::SentBy + | JSCalendarProperty::Uid + | JSCalendarProperty::RecurrenceOverrides + | JSCalendarProperty::RecurrenceRule + | JSCalendarProperty::RelatedTo + ) + } +} + +fn patch_parent_ids( + current: &mut Vec, + patch: Option<&JsonPointerItem>>, + update: Value<'_, JSCalendarProperty, JSCalendarValue>, +) -> Result<(), SetError>> { + match (patch, update) { + ( + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::IdValue(id)))), + Value::Bool(false) | Value::Null, + ) => { + let id = id.document_id(); + current.retain(|name| name.parent_id != id); + Ok(()) + } + ( + Some(JsonPointerItem::Key(Key::Property(JSCalendarProperty::IdValue(id)))), + Value::Bool(true), + ) => { + let id = id.document_id(); + if !current.iter().any(|name| name.parent_id == id) { + current.push(DavName::new_with_rand_name(id)); + } + Ok(()) + } + (None, Value::Object(object)) => { + let mut new_ids = object + .into_expanded_boolean_set() + .filter_map(|id| { + if let Key::Property(JSCalendarProperty::IdValue(id)) = id { + Some(id.document_id()) + } else { + None + } + }) + .collect::>(); + + current.retain(|name| new_ids.remove(&name.parent_id)); + + for id in new_ids { + current.push(DavName::new_with_rand_name(id)); + } + + Ok(()) + } + _ => Err(SetError::invalid_properties() + .with_property(JSCalendarProperty::CalendarIds) + .with_description("Invalid patch operation for calendarIds.")), + } +} + +fn default_alert_to_ical(alert: &ArchivedDefaultAlert) -> ICalendarComponent { + let flags = alert.flags.to_native(); + ICalendarComponent { + component_type: ICalendarComponentType::VAlarm, + entries: vec![ + ICalendarEntry::new(ICalendarProperty::Action).with_value( + if flags & ALERT_EMAIL != 0 { + ICalendarValue::Action(ICalendarAction::Email) + } else { + ICalendarValue::Action(ICalendarAction::Display) + }, + ), + ICalendarEntry::new(ICalendarProperty::Trigger) + .with_param_opt((flags & ALERT_RELATIVE_TO_END != 0).then_some( + ICalendarParameter::related(ICalendarParameterValue::Related( + ICalendarRelated::End, + )), + )) + .with_value(ICalendarValue::Duration(alert.offset.to_native())), + ], + component_ids: vec![], + } +} + +fn generate_uid() -> String { + let mut bytes = rand::random::<[u8; 16]>(); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ) +} diff --git a/crates/jmap/src/calendar_event_notification/get.rs b/crates/jmap/src/calendar_event_notification/get.rs new file mode 100644 index 0000000..e8a12e2 --- /dev/null +++ b/crates/jmap/src/calendar_event_notification/get.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::JmapCacheState; +use calcard::{ + icalendar::{ArchivedICalendarProperty, ICalendar}, + jscalendar::import::ConversionOptions, +}; +use common::{Server, auth::AccessToken}; +use groupware::{ + cache::GroupwareCache, + calendar::{ + ArchivedChangedBy, CalendarEventNotification, EVENT_NOTIFICATION_IS_CHANGE, + EVENT_NOTIFICATION_IS_DRAFT, + }, +}; +use jmap_proto::{ + method::get::GetRequest, + object::calendar_event_notification::{ + self, CalendarEventNotificationGetResponse, CalendarEventNotificationObject, + CalendarEventNotificationProperty, CalendarEventNotificationType, PersonObject, + }, + types::date::UTCDate, +}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, serialize::rkyv_deserialize}, +}; +use trc::AddContext; +use types::{ + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub trait CalendarEventNotificationGet: Sync + Send { + fn calendar_event_notification_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl CalendarEventNotificationGet for Server { + async fn calendar_event_notification_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::Created, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::ChangedBy, + ]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::CalendarEventNotification, + ) + .await + .caused_by(trc::location!())?; + + let ids = if let Some(ids) = ids { + ids + } else { + cache + .document_ids(false) + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = CalendarEventNotificationGetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the event object + let document_id = id.document_id(); + let _event = if let Some(event) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEventNotification, + document_id, + )) + .await? + { + event + } else { + response.push_not_found(id); + continue; + }; + let event = _event + .unarchive::() + .caused_by(trc::location!())?; + let mut result = CalendarEventNotificationObject { + id, + ..Default::default() + }; + for property in &properties { + match property { + CalendarEventNotificationProperty::Id => {} + CalendarEventNotificationProperty::Created => { + result.created = Some(UTCDate::from_timestamp(event.created.to_native())); + } + CalendarEventNotificationProperty::CalendarEventId => { + result.calendar_event_id = + event.event_id.as_ref().map(|id| id.to_native().into()); + } + CalendarEventNotificationProperty::ChangedBy => { + let mut changed_by = PersonObject::default(); + + match &event.changed_by { + ArchivedChangedBy::PrincipalId(id) => { + if let Ok(account) = self.account(id.to_native()).await { + changed_by.name = + account.description().unwrap_or(account.name()).to_string(); + changed_by.email = account.name().to_string().into(); + } + changed_by.principal_id = Some(id.to_native().into()); + } + ArchivedChangedBy::CalendarAddress(email) => { + changed_by.email = Some(email.to_string()); + changed_by.calendar_address = Some(format!("mailto:{email}")); + } + } + + result.changed_by = Some(changed_by); + } + CalendarEventNotificationProperty::Comment => { + result.comment = event + .event + .components + .iter() + .filter(|c| c.component_type.is_scheduling_object()) + .flat_map(|c| c.entries.iter()) + .find(|e| matches!(e.name, ArchivedICalendarProperty::Comment)) + .and_then(|e| e.values.first().and_then(|v| v.as_text())) + .map(|v| v.to_string()); + } + CalendarEventNotificationProperty::Type => { + result.notification_type = + Some(if event.flags & EVENT_NOTIFICATION_IS_CHANGE != 0 { + CalendarEventNotificationType::Updated + } else if !event.event.components.is_empty() { + CalendarEventNotificationType::Created + } else { + CalendarEventNotificationType::Destroyed + }); + } + CalendarEventNotificationProperty::IsDraft => { + result.is_draft = Some(event.flags & EVENT_NOTIFICATION_IS_DRAFT != 0); + } + CalendarEventNotificationProperty::Event => { + if event.flags & EVENT_NOTIFICATION_IS_CHANGE == 0 && result.event.is_none() + { + let js_event = rkyv_deserialize::<_, ICalendar>(&event.event) + .caused_by(trc::location!())? + .into_jscalendar_with_opt::( + ConversionOptions::default() + .include_ical_components(false) + .return_first(true), + ); + result.event = js_event.into(); + } + } + CalendarEventNotificationProperty::EventPatch => { + if event.flags & EVENT_NOTIFICATION_IS_CHANGE != 0 + && result.event_patch.is_none() + { + let js_event = rkyv_deserialize::<_, ICalendar>(&event.event) + .caused_by(trc::location!())? + .into_jscalendar_with_opt::( + ConversionOptions::default() + .include_ical_components(false) + .return_first(true), + ); + result.event_patch = js_event.into(); + } + } + } + } + response.list.push(result); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/calendar_event_notification/mod.rs b/crates/jmap/src/calendar_event_notification/mod.rs new file mode 100644 index 0000000..c036acc --- /dev/null +++ b/crates/jmap/src/calendar_event_notification/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/calendar_event_notification/query.rs b/crates/jmap/src/calendar_event_notification/query.rs new file mode 100644 index 0000000..bc5ec2f --- /dev/null +++ b/crates/jmap/src/calendar_event_notification/query.rs @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::calendar_event_notification::{ + CalendarEventNotification, CalendarEventNotificationComparator, + CalendarEventNotificationFilter, + }, + request::IntoValid, +}; +use store::{ + IterateParams, U32_LEN, U64_LEN, ValueKey, + ahash::AHashSet, + roaring::RoaringBitmap, + search::{SearchFilter, SearchQuery}, + write::{IndexPropertyClass, SearchIndex, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::CalendarNotificationField, +}; + +pub trait CalendarEventNotificationQuery: Sync + Send { + fn calendar_event_notification_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +struct Notification { + document_id: u32, + created: u64, + event_id: u32, +} + +impl CalendarEventNotificationQuery for Server { + async fn calendar_event_notification_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::CalendarEventNotification, + ) + .await?; + let mut notifications = Vec::with_capacity(16); + let mut document_ids = RoaringBitmap::new(); + + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: 0, + }), + }, + ValueKey { + account_id, + collection: Collection::CalendarEventNotification.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: CalendarNotificationField::CreatedToId.into(), + value: u64::MAX, + }), + }, + ) + .ascending(), + |key, value| { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + notifications.push(Notification { + document_id, + created: key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?, + event_id: value.deserialize_be_u32(0)?, + }); + document_ids.insert(document_id); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + CalendarEventNotificationFilter::Before(before) => { + let before = before.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + notifications + .iter() + .filter_map(|n| (n.created < before).then_some(n.document_id)), + ))) + } + CalendarEventNotificationFilter::After(after) => { + let after = after.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + notifications + .iter() + .filter_map(|n| (n.created > after).then_some(n.document_id)), + ))) + } + CalendarEventNotificationFilter::CalendarEventIds(ids) => { + let ids = ids + .into_valid() + .map(|id| id.document_id()) + .collect::>(); + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + notifications + .iter() + .filter_map(|n| ids.contains(&n.event_id).then_some(n.document_id)), + ))) + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(unsupported.into_string())); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + // Parse sort criteria + let mut is_ascending = true; + for comparator in request.sort.take().unwrap_or_default() { + match comparator.property { + CalendarEventNotificationComparator::Created => { + is_ascending = comparator.is_ascending; + } + CalendarEventNotificationComparator::_T(unsupported) => { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(unsupported)); + } + }; + } + if !is_ascending { + notifications.reverse(); + } + + let results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_mask(document_ids) + .filter() + .into_bitmap(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + cache.get_state(false), + &request, + ); + + if !results.is_empty() { + let results = results.into_iter().collect::>(); + for notification in notifications { + if results.contains(¬ification.document_id) + && !response.add(0, notification.document_id) + { + break; + } + } + } + + response.build() + } +} diff --git a/crates/jmap/src/calendar_event_notification/set.rs b/crates/jmap/src/calendar_event_notification/set.rs new file mode 100644 index 0000000..e6bdddf --- /dev/null +++ b/crates/jmap/src/calendar_event_notification/set.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use groupware::{DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::calendar_event_notification, + request::IntoValid, + types::state::State, +}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::collection::{Collection, SyncCollection}; + +pub trait CalendarEventNotificationSet: Sync + Send { + fn calendar_event_notification_set( + &self, + request: SetRequest<'_, calendar_event_notification::CalendarEventNotification>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future< + Output = trc::Result>, + > + Send; +} + +impl CalendarEventNotificationSet for Server { + async fn calendar_event_notification_set( + &self, + mut request: SetRequest<'_, calendar_event_notification::CalendarEventNotification>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::CalendarEventNotification, + ) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + + let mut batch = BatchBuilder::new(); + for (id, _) in request.unwrap_create() { + response.not_created.append( + id, + SetError::forbidden().with_description("Cannot create event notifications."), + ); + } + + // Process updates + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Cannot update event notifications."), + ); + } + + // Process deletions + for id in request.unwrap_destroy().into_valid() { + let document_id = id.document_id(); + + if !cache.has_item_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let _event = if let Some(event) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEventNotification, + document_id, + )) + .await? + { + event + } else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + let event = _event + .to_unarchived::() + .caused_by(trc::location!())?; + + DestroyArchive(event) + .delete( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs new file mode 100644 index 0000000..821b1e2 --- /dev/null +++ b/crates/jmap/src/changes/get.rs @@ -0,0 +1,434 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::auth::JmapAuthorization, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}; +use groupware::cache::GroupwareCache; +use jmap_proto::{ + method::changes::{ChangesRequest, ChangesResponse}, + object::{JmapObject, NullObject, mailbox::MailboxProperty}, + request::method::MethodObject, + response::{ChangesResponseMethod, ResponseMethod}, + types::state::State, +}; +use std::future::Future; +use store::{ + query::log::{Change, Query}, + roaring::RoaringBitmap, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; + +pub trait ChangesLookup: Sync + Send { + fn changes( + &self, + request: ChangesRequest, + object: MethodObject, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +pub struct IntermediateChangesResponse { + pub response: ChangesResponse, + pub object: MethodObject, + pub only_container_changes: bool, +} + +impl ChangesLookup for Server { + async fn changes( + &self, + request: ChangesRequest, + object: MethodObject, + access_token: &AccessToken, + ) -> trc::Result { + // Map collection and validate ACLs + let (collection, is_container) = match object { + MethodObject::Email => { + access_token.assert_has_access(request.account_id, Collection::Email)?; + (SyncCollection::Email, false) + } + MethodObject::Mailbox => { + access_token.assert_has_access(request.account_id, Collection::Mailbox)?; + + (SyncCollection::Email, true) + } + MethodObject::Thread => { + access_token.assert_has_access(request.account_id, Collection::Email)?; + + (SyncCollection::Thread, true) + } + MethodObject::Identity => { + access_token.assert_is_member(request.account_id)?; + + (SyncCollection::Identity, false) + } + MethodObject::EmailSubmission => { + access_token.assert_is_member(request.account_id)?; + + (SyncCollection::EmailSubmission, false) + } + MethodObject::AddressBook => { + access_token.assert_has_access(request.account_id, Collection::AddressBook)?; + + (SyncCollection::AddressBook, true) + } + MethodObject::ContactCard => { + access_token.assert_has_access(request.account_id, Collection::ContactCard)?; + + (SyncCollection::AddressBook, false) + } + MethodObject::FileNode => { + access_token.assert_has_access(request.account_id, Collection::FileNode)?; + + (SyncCollection::FileNode, false) + } + MethodObject::Calendar => { + access_token.assert_has_access(request.account_id, Collection::Calendar)?; + + (SyncCollection::Calendar, true) + } + MethodObject::CalendarEvent => { + access_token.assert_has_access(request.account_id, Collection::CalendarEvent)?; + + (SyncCollection::Calendar, false) + } + MethodObject::CalendarEventNotification => { + access_token.assert_is_member(request.account_id)?; + + (SyncCollection::CalendarEventNotification, false) + } + MethodObject::ShareNotification => { + access_token.assert_is_member(request.account_id)?; + + (SyncCollection::ShareNotification, false) + } + _ => { + return Err(trc::JmapEvent::CannotCalculateChanges.into_err()); + } + }; + let max_changes = std::cmp::min( + request + .max_changes + .filter(|n| *n != 0) + .unwrap_or(usize::MAX), + self.core.jmap.changes_max_results, + ); + let mut response: ChangesResponse = ChangesResponse { + account_id: request.account_id, + old_state: request.since_state.clone(), + new_state: State::Initial, + has_more_changes: false, + created: vec![], + updated: vec![], + destroyed: vec![], + updated_properties: None, + }; + let account_id = request.account_id.document_id(); + + let allowed_ids: Option = if access_token.is_member(account_id) { + None + } else { + Some(match object { + MethodObject::Email => self + .get_cached_messages(account_id) + .await? + .shared_messages(access_token, Acl::ReadItems), + MethodObject::Mailbox => self + .get_cached_messages(account_id) + .await? + .shared_mailboxes(access_token, Acl::Read), + MethodObject::Thread => { + let cache = self.get_cached_messages(account_id).await?; + let shared = cache.shared_messages(access_token, Acl::ReadItems); + let mut threads = RoaringBitmap::new(); + for item in &cache.emails.items { + if shared.contains(item.document_id) { + threads.insert(item.thread_id); + } + } + threads + } + MethodObject::AddressBook => self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await? + .shared_containers(access_token, [Acl::Read, Acl::ReadItems], true), + MethodObject::ContactCard => self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await? + .shared_items(access_token, [Acl::ReadItems], true), + MethodObject::Calendar => self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await? + .shared_containers(access_token, [Acl::Read, Acl::ReadItems], true), + MethodObject::CalendarEvent => self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await? + .shared_items(access_token, [Acl::ReadItems], true), + MethodObject::FileNode => self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await? + .shared_documents(access_token, [Acl::Read, Acl::ReadItems], true), + _ => RoaringBitmap::new(), + }) + }; + + let (items_sent, changelog) = match &request.since_state { + State::Initial => { + let changelog = self + .store() + .changes(account_id, collection.into(), Query::All) + .await?; + if changelog.changes.is_empty() && changelog.from_change_id == 0 { + return Ok(IntermediateChangesResponse { + response, + object, + only_container_changes: false, + }); + } + + (0, changelog) + } + State::Exact(change_id) => { + let last_state = match collection { + SyncCollection::Calendar | SyncCollection::AddressBook => self + .fetch_dav_resources(access_token.account_id(), account_id, collection) + .await + .caused_by(trc::location!())? + .get_state(is_container) + .into(), + SyncCollection::Email => self + .get_cached_messages(account_id) + .await? + .get_state(is_container) + .into(), + _ => None, + }; + + if let Some(last_state) = last_state { + response.new_state = last_state; + + if response.new_state == State::Exact(*change_id) { + return Ok(IntermediateChangesResponse { + response, + object, + only_container_changes: false, + }); + } + } + + ( + 0, + self.store() + .changes(account_id, collection.into(), Query::Since(*change_id)) + .await?, + ) + } + State::Intermediate(intermediate_state) => { + let changelog = self + .store() + .changes( + account_id, + collection.into(), + Query::RangeInclusive(intermediate_state.from_id, intermediate_state.to_id), + ) + .await?; + if (is_container + && intermediate_state.items_sent >= changelog.total_container_changes()) + || (!is_container + && intermediate_state.items_sent >= changelog.total_item_changes()) + { + ( + 0, + self.store() + .changes( + account_id, + collection.into(), + Query::Since(intermediate_state.to_id), + ) + .await?, + ) + } else { + (intermediate_state.items_sent, changelog) + } + } + }; + + if (changelog.is_truncated || changelog.from_change_id == 0) + && request.since_state != State::Initial + { + return Err(trc::JmapEvent::CannotCalculateChanges.into_err().details( + if changelog.is_truncated { + "Change log is truncated" + } else { + "Since state is invalid" + }, + )); + } + + let mut changes = changelog + .changes + .into_iter() + .filter(|change| { + (is_container && change.is_container_change()) + || (!is_container && change.is_item_change()) + }) + .filter(|change| { + allowed_ids.as_ref().is_none_or(|allowed| { + let id = if is_container { + change.container_id() + } else { + change.item_id() + }; + id.is_some_and(|id| allowed.contains(id as u32)) + }) + }) + .skip(items_sent) + .peekable(); + + let mut items_changed = false; + for change in (&mut changes).take(max_changes) { + match change { + Change::InsertContainer(item) | Change::InsertItem(item) => { + response.created.push(item.into()); + } + Change::UpdateContainer(item) | Change::UpdateItem(item) => { + response.updated.push(item.into()); + items_changed = true; + } + Change::DeleteContainer(item) | Change::DeleteItem(item) => { + response.destroyed.push(item.into()); + } + Change::UpdateContainerProperty(item) => { + response.updated.push(item.into()); + } + }; + } + + let change_id = (if is_container { + changelog.container_change_id + } else { + changelog.item_change_id + }) + .unwrap_or(changelog.to_change_id); + + response.has_more_changes = changes.peek().is_some(); + if response.has_more_changes { + response.new_state = State::new_intermediate( + changelog.from_change_id, + change_id, + items_sent + max_changes, + ); + } else if response.new_state == State::Initial { + response.new_state = State::new_exact(change_id) + } + + Ok(IntermediateChangesResponse { + only_container_changes: is_container && !response.updated.is_empty() && !items_changed, + response, + object, + }) + } +} + +impl IntermediateChangesResponse { + pub fn into_method_response(self) -> ResponseMethod<'static> { + ResponseMethod::Changes(match self.object { + MethodObject::Email => ChangesResponseMethod::Email(transmute_response(self.response)), + MethodObject::Mailbox => { + let mut response = transmute_response(self.response); + if self.only_container_changes { + response.updated_properties = vec![ + MailboxProperty::TotalEmails.into(), + MailboxProperty::UnreadEmails.into(), + MailboxProperty::TotalThreads.into(), + MailboxProperty::UnreadThreads.into(), + ] + .into(); + } + ChangesResponseMethod::Mailbox(response) + } + MethodObject::Thread => { + ChangesResponseMethod::Thread(transmute_response(self.response)) + } + MethodObject::Identity => { + ChangesResponseMethod::Identity(transmute_response(self.response)) + } + MethodObject::EmailSubmission => { + ChangesResponseMethod::EmailSubmission(transmute_response(self.response)) + } + MethodObject::AddressBook => { + ChangesResponseMethod::AddressBook(transmute_response(self.response)) + } + MethodObject::ContactCard => { + ChangesResponseMethod::ContactCard(transmute_response(self.response)) + } + MethodObject::FileNode => { + ChangesResponseMethod::FileNode(transmute_response(self.response)) + } + MethodObject::Calendar => { + ChangesResponseMethod::Calendar(transmute_response(self.response)) + } + MethodObject::CalendarEvent => { + ChangesResponseMethod::CalendarEvent(transmute_response(self.response)) + } + MethodObject::CalendarEventNotification => { + ChangesResponseMethod::CalendarEventNotification(transmute_response(self.response)) + } + MethodObject::ShareNotification => { + ChangesResponseMethod::ShareNotification(transmute_response(self.response)) + } + MethodObject::ParticipantIdentity + | MethodObject::Core + | MethodObject::Blob + | MethodObject::PushSubscription + | MethodObject::SearchSnippet + | MethodObject::VacationResponse + | MethodObject::SieveScript + | MethodObject::Principal + | MethodObject::Quota + | MethodObject::Registry(_) => unreachable!(), + }) + } +} + +fn transmute_response( + response: ChangesResponse, +) -> Box> { + Box::new(ChangesResponse { + account_id: response.account_id, + old_state: response.old_state, + new_state: response.new_state, + has_more_changes: response.has_more_changes, + created: response.created, + updated: response.updated, + destroyed: response.destroyed, + updated_properties: None, + }) +} diff --git a/crates/jmap/src/changes/mod.rs b/crates/jmap/src/changes/mod.rs new file mode 100644 index 0000000..39a5a4e --- /dev/null +++ b/crates/jmap/src/changes/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod state; diff --git a/crates/jmap/src/changes/query.rs b/crates/jmap/src/changes/query.rs new file mode 100644 index 0000000..77e391f --- /dev/null +++ b/crates/jmap/src/changes/query.rs @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::get::ChangesLookup; +use crate::{ + api::request::resolve_account_id, calendar_event::query::CalendarEventQuery, + calendar_event_notification::query::CalendarEventNotificationQuery, + contact::query::ContactCardQuery, email::query::EmailQuery, file::query::FileNodeQuery, + mailbox::query::MailboxQuery, share_notification::query::ShareNotificationQuery, + submission::query::EmailSubmissionQuery, +}; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::{ + changes::{ChangesRequest, ChangesResponse}, + query_changes::{AddedItem, QueryChangesRequest, QueryChangesResponse}, + }, + object::{JmapObject, NullObject}, + request::{QueryChangesRequestMethod, method::MethodObject}, +}; +use std::future::Future; + +pub trait QueryChanges: Sync + Send { + fn query_changes( + &self, + request: QueryChangesRequestMethod, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl QueryChanges for Server { + async fn query_changes( + &self, + request: QueryChangesRequestMethod, + access_token: &AccessToken, + ) -> trc::Result { + let mut response; + let mut is_mutable = true; + let results; + let changes; + let has_changes; + let up_to_id; + + match request { + QueryChangesRequestMethod::Email(mut request) => { + // Query changes + resolve_account_id(&mut request.account_id, MethodObject::Email, access_token)?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::Email, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + is_mutable = request.filter.iter().any(|f| !f.is_immutable()) + || request + .sort + .as_ref() + .is_some_and(|sort| sort.iter().any(|s| !s.is_immutable())); + + results = self.email_query((*request).into(), access_token).await?; + } + QueryChangesRequestMethod::Mailbox(mut request) => { + // Query changes + resolve_account_id(&mut request.account_id, MethodObject::Mailbox, access_token)?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::Mailbox, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self.mailbox_query((*request).into(), access_token).await?; + } + QueryChangesRequestMethod::EmailSubmission(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::EmailSubmission, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::EmailSubmission, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self.email_submission_query((*request).into()).await?; + } + QueryChangesRequestMethod::ContactCard(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::ContactCard, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::ContactCard, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self + .contact_card_query((*request).into(), access_token) + .await?; + } + QueryChangesRequestMethod::FileNode(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::FileNode, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::FileNode, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self + .file_node_query((*request).into(), access_token) + .await?; + } + QueryChangesRequestMethod::CalendarEvent(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::CalendarEvent, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::CalendarEvent, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self + .calendar_event_query((*request).into(), access_token) + .await?; + } + QueryChangesRequestMethod::CalendarEventNotification(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::CalendarEventNotification, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::CalendarEventNotification, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self + .calendar_event_notification_query((*request).into(), access_token) + .await?; + } + QueryChangesRequestMethod::ShareNotification(mut request) => { + // Query changes + resolve_account_id( + &mut request.account_id, + MethodObject::ShareNotification, + access_token, + )?; + changes = self + .changes( + build_changes_request(&request), + MethodObject::ShareNotification, + access_token, + ) + .await? + .response; + let calculate_total = request.calculate_total.unwrap_or(false); + has_changes = changes.has_changes(); + response = build_query_changes_response(&request, &changes); + + if !has_changes && !calculate_total { + return Ok(response); + } + + up_to_id = request.up_to_id; + results = self.share_notification_query((*request).into()).await?; + } + QueryChangesRequestMethod::Principal(_) => { + return Err(trc::JmapEvent::CannotCalculateChanges.into_err()); + } + QueryChangesRequestMethod::Quota(_) => { + return Err(trc::JmapEvent::CannotCalculateChanges.into_err()); + } + } + + if has_changes { + if is_mutable { + for (index, id) in results.ids.into_iter().enumerate() { + if changes.created.contains(&id) || changes.updated.contains(&id) { + response.added.push(AddedItem::new(id, index)); + } + } + + response.removed = changes.updated; + } else { + for (index, id) in results.ids.into_iter().enumerate() { + if changes.created.contains(&id) { + response.added.push(AddedItem::new(id, index)); + } + if matches!(up_to_id, Some(up_to_id) if up_to_id == id) { + break; + } + } + } + + if !changes.destroyed.is_empty() { + response.removed.extend(changes.destroyed); + } + } + response.total = results.total; + + Ok(response) + } +} + +fn build_changes_request(req: &QueryChangesRequest) -> ChangesRequest { + ChangesRequest { + account_id: req.account_id, + since_state: req.since_query_state.clone(), + max_changes: req.max_changes, + } +} + +fn build_query_changes_response( + req: &QueryChangesRequest, + changes: &ChangesResponse, +) -> QueryChangesResponse { + QueryChangesResponse { + account_id: req.account_id, + old_query_state: changes.old_state.clone(), + new_query_state: changes.new_state.clone(), + total: None, + removed: vec![], + added: vec![], + } +} diff --git a/crates/jmap/src/changes/state.rs b/crates/jmap/src/changes/state.rs new file mode 100644 index 0000000..076fda7 --- /dev/null +++ b/crates/jmap/src/changes/state.rs @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{DavResources, MessageStoreCache, Server}; +use jmap_proto::types::state::State; +use std::future::Future; +use trc::AddContext; +use types::{ChangeId, collection::SyncCollection}; + +pub trait StateManager: Sync + Send { + fn get_state( + &self, + account_id: u32, + collection: SyncCollection, + ) -> impl Future> + Send; + + fn assert_state( + &self, + account_id: u32, + collection: SyncCollection, + if_in_state: &Option, + ) -> impl Future> + Send; +} + +pub trait JmapCacheState: Sync + Send { + fn get_state(&self, is_container: bool) -> State; + + fn assert_state(&self, is_container: bool, if_in_state: &Option) -> trc::Result { + let old_state: State = self.get_state(is_container); + if let Some(if_in_state) = if_in_state + && &old_state != if_in_state + { + return Err(trc::JmapEvent::StateMismatch.into_err()); + } + Ok(old_state) + } +} + +impl StateManager for Server { + async fn get_state(&self, account_id: u32, collection: SyncCollection) -> trc::Result { + self.core + .storage + .data + .get_last_change_id(account_id, collection.into()) + .await + .caused_by(trc::location!()) + .map(State::from) + } + + async fn assert_state( + &self, + account_id: u32, + collection: SyncCollection, + if_in_state: &Option, + ) -> trc::Result { + let old_state: State = self.get_state(account_id, collection).await?; + if let Some(if_in_state) = if_in_state + && &old_state != if_in_state + { + return Err(trc::JmapEvent::StateMismatch.into_err()); + } + + Ok(old_state) + } +} + +#[inline(always)] +fn cache_state(change_id: ChangeId) -> State { + (change_id != 0).then_some(change_id).into() +} + +impl JmapCacheState for MessageStoreCache { + fn get_state(&self, is_container: bool) -> State { + cache_state(if is_container { + self.mailboxes.change_id + } else { + self.emails.change_id + }) + } +} + +impl JmapCacheState for DavResources { + fn get_state(&self, is_container: bool) -> State { + cache_state(if is_container { + self.container_change_id + } else { + self.item_change_id + }) + } +} diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs new file mode 100644 index 0000000..a6e68b4 --- /dev/null +++ b/crates/jmap/src/contact/copy.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{changes::state::JmapCacheState, contact::set::ContactCardSet}; +use common::{Server, auth::AccessToken}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::contact, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, + types::state::State, +}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::vec_map::VecMap; + +pub trait JmapContactCardCopy: Sync + Send { + fn contact_card_copy<'x>( + &self, + request: CopyRequest<'x, contact::ContactCard>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl JmapContactCardCopy for Server { + async fn contact_card_copy<'x>( + &self, + request: CopyRequest<'x, contact::ContactCard>, + access_token: &AccessToken, + next_call: &mut Option>>, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + let account = self.account(account_id).await.caused_by(trc::location!())?; + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::AddressBook, + ) + .await + .caused_by(trc::location!())?; + let from_contact_ids = if access_token.is_member(from_account_id) { + from_cache.document_ids(false).collect::() + } else { + from_cache.shared_items(access_token, [Acl::ReadItems], true) + }; + + let can_add_address_books = if access_token.is_shared(account_id) { + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into() + } else { + None + }; + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + let mut destroy_ids = Vec::new(); + + // Obtain quota + let mut batch = BatchBuilder::new(); + + 'create: for (id, create) in request.create.into_valid() { + let from_contact_id = id.document_id(); + if !from_contact_ids.contains(from_contact_id) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + } + + let Some(_contact) = self + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::ContactCard, + from_contact_id, + )) + .await? + else { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + }; + + let contact = _contact + .deserialize::() + .caused_by(trc::location!())?; + + match self + .create_contact_card( + &cache, + &mut batch, + access_token, + &account, + account_id, + &can_add_address_books, + contact.card.into_jscontact(), + create, + ) + .await? + { + Ok(document_id) => { + response.created(id, document_id); + + // Add to destroy list + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(id)); + } + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + } + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id); + } + + // Destroy ids + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::ContactCard, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::ContactCard(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/contact/get.rs b/crates/jmap/src/contact/get.rs new file mode 100644 index 0000000..c0baf25 --- /dev/null +++ b/crates/jmap/src/contact/get.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::JmapCacheState; +use calcard::jscontact::{JSContactProperty, JSContactValue, import::ConversionOptions}; +use common::{Server, auth::AccessToken}; +use groupware::{cache::GroupwareCache, contact::ContactCard}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::contact, +}; +use jmap_tools::{Map, Value}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub trait ContactCardGet: Sync + Send { + fn contact_card_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl ContactCardGet for Server { + async fn contact_card_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let return_all_properties = request.properties.is_none(); + let properties = + request.unwrap_properties(&[JSContactProperty::Id, JSContactProperty::AddressBookIds]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + let contact_ids = if access_token.is_member(account_id) { + cache.document_ids(false).collect::() + } else { + cache.shared_items(access_token, [Acl::ReadItems], true) + }; + let ids = if let Some(ids) = ids { + ids + } else { + contact_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + let mut return_id = return_all_properties; + let mut return_address_book_ids = return_all_properties; + let mut return_converted_props = !return_all_properties; + + if !return_all_properties { + for property in &properties { + match property { + JSContactProperty::Id => { + return_id = true; + } + JSContactProperty::AddressBookIds => { + return_address_book_ids = true; + } + JSContactProperty::VCard => { + return_converted_props = true; + } + _ => {} + } + } + } + + for id in ids { + // Obtain the contact object + let document_id = id.document_id(); + if !contact_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + + let _contact = if let Some(contact) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await? + { + contact + } else { + response.push_not_found(id); + continue; + }; + + let contact = _contact + .deserialize::() + .caused_by(trc::location!())?; + + let jscontact = contact + .card + .into_jscontact_with_options::( + ConversionOptions::default().include_vcard_parameters(return_converted_props), + ) + .into_inner(); + let mut result = if return_all_properties { + jscontact.into_object().unwrap() + } else { + Map::from_iter( + jscontact + .into_expanded_object() + .filter(|(k, _)| k.as_property().is_some_and(|p| properties.contains(p))), + ) + }; + + if return_id { + result.insert_unchecked( + JSContactProperty::Id, + Value::Element(JSContactValue::Id(id)), + ); + } + + if return_address_book_ids { + let mut obj = Map::with_capacity(contact.names.len()); + for id in contact.names.iter() { + obj.insert_unchecked(JSContactProperty::IdValue(Id::from(id.parent_id)), true); + } + result.insert_unchecked(JSContactProperty::AddressBookIds, Value::Object(obj)); + } + + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/contact/mod.rs b/crates/jmap/src/contact/mod.rs new file mode 100644 index 0000000..0f5cb5f --- /dev/null +++ b/crates/jmap/src/contact/mod.rs @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::jscontact::JSContactProperty; +use common::{DavName, DavResources, Server}; +use jmap_proto::error::set::SetError; +use trc::AddContext; +use types::{collection::Collection, field::ContactField, id::Id}; + +pub mod copy; +pub mod get; +pub mod parse; +pub mod query; +pub mod set; + +pub(super) async fn assert_is_unique_uid( + server: &Server, + resources: &DavResources, + account_id: u32, + addressbook_ids: &[DavName], + uid: Option<&str>, +) -> trc::Result>>> { + if let Some(uid) = uid { + let hits = server + .document_ids_matching( + account_id, + Collection::ContactCard, + ContactField::Uid, + uid.as_bytes(), + ) + .await + .caused_by(trc::location!())?; + if !hits.is_empty() { + for document_id in resources + .paths + .iter() + .filter(move |item| { + item.parent_id + .is_some_and(|id| addressbook_ids.iter().any(|ab| ab.parent_id == id)) + }) + .map(|path| resources.resources[path.resource_idx].document_id) + { + if hits.contains(document_id) { + return Ok(Err(SetError::invalid_properties() + .with_property(JSContactProperty::Uid) + .with_description(format!( + "Contact with UID {uid} already exists with id {}.", + Id::from(document_id) + )))); + } + } + } + } + + Ok(Ok(())) +} diff --git a/crates/jmap/src/contact/parse.rs b/crates/jmap/src/contact/parse.rs new file mode 100644 index 0000000..bfd64a6 --- /dev/null +++ b/crates/jmap/src/contact/parse.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::blob::download::BlobDownload; +use calcard::vcard::VCard; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::parse::{ParseRequest, ParseResponse}, + object::contact::ContactCard, + request::{IntoValid, MaybeInvalid}, +}; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +pub trait ContactCardParse: Sync + Send { + fn contact_card_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl ContactCardParse for Server { + async fn contact_card_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> trc::Result> { + if request.blob_ids.len() > self.core.jmap.contact_parse_max_items { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + let return_all_properties = request.properties.is_none(); + let properties = request + .properties + .map(|v| v.into_valid().collect::>()) + .unwrap_or_default(); + + let mut response = ParseResponse { + account_id: request.account_id, + parsed: VecMap::with_capacity(request.blob_ids.len()), + not_parsable: vec![], + not_found: vec![], + }; + + for blob_id in request.blob_ids.into_valid() { + // Fetch raw message to parse + let raw_vcard = match self.blob_download(&blob_id, access_token).await? { + Some(raw_vcard) => raw_vcard, + None => { + response.not_found.push(MaybeInvalid::Value(blob_id)); + continue; + } + }; + let Ok(vcard) = VCard::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) + else { + response.not_parsable.push(blob_id); + continue; + }; + let mut js_contact = vcard.into_jscontact::(); + + if !return_all_properties { + js_contact + .0 + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| k.as_property().is_some_and(|k| properties.contains(k))); + } + + response.parsed.append(blob_id, js_contact.into_inner()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/contact/query.rs b/crates/jmap/src/contact/query.rs new file mode 100644 index 0000000..840629a --- /dev/null +++ b/crates/jmap/src/contact/query.rs @@ -0,0 +1,371 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::{ + addressbook::AddressBook, + contact::{ContactCard, ContactCardComparator, ContactCardFilter}, + }, + request::MaybeInvalid, + types::state::State, +}; +use store::{ + IterateParams, U32_LEN, U64_LEN, ValueKey, + roaring::RoaringBitmap, + search::{ContactSearchField, SearchComparator, SearchFilter, SearchQuery}, + write::{IndexPropertyClass, SearchIndex, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + field::ContactField, +}; +use utils::sanitize_email; + +pub trait ContactCardQuery: Sync + Send { + fn contact_card_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; + + fn address_book_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +#[derive(Clone)] +struct CreatedUpdated { + document_id: u32, + created: u64, + updated: u64, +} + +impl ContactCardQuery for Server { + async fn contact_card_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + let mut created_to_updated = Vec::new(); + + if request.filter.iter().any(|cond| { + matches!( + cond, + Filter::Property( + ContactCardFilter::CreatedBefore(_) + | ContactCardFilter::CreatedAfter(_) + | ContactCardFilter::UpdatedBefore(_) + | ContactCardFilter::UpdatedAfter(_) + ) + ) + }) || request.sort.as_ref().is_some_and(|v| { + v.iter().any(|sort| { + matches!( + sort.property, + ContactCardComparator::Created | ContactCardComparator::Updated + ) + }) + }) { + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::ContactCard.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: ContactField::CreatedToUpdated.into(), + value: 0, + }), + }, + ValueKey { + account_id, + collection: Collection::ContactCard.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: ContactField::CreatedToUpdated.into(), + value: u64::MAX, + }), + }, + ) + .ascending(), + |key, value| { + created_to_updated.push(CreatedUpdated { + document_id: key.deserialize_be_u32(key.len() - U32_LEN)?, + created: key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?, + updated: value.deserialize_be_u64(0)?, + }); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + } + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + ContactCardFilter::InAddressBook(MaybeInvalid::Value(id)) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.children_ids(id.document_id()), + ))) + } + ContactCardFilter::Name(value) + | ContactCardFilter::NameGiven(value) + | ContactCardFilter::NameSurname(value) + | ContactCardFilter::NameSurname2(value) => { + filters.push(SearchFilter::has_keyword(ContactSearchField::Name, value)); + } + ContactCardFilter::Nickname(value) => { + filters.push(SearchFilter::has_keyword( + ContactSearchField::Nickname, + value, + )); + } + ContactCardFilter::Organization(value) => { + filters.push(SearchFilter::has_keyword( + ContactSearchField::Organization, + value, + )); + } + ContactCardFilter::Phone(value) => { + filters.push(SearchFilter::has_keyword(ContactSearchField::Phone, value)); + } + ContactCardFilter::OnlineService(value) => { + filters.push(SearchFilter::has_keyword( + ContactSearchField::OnlineService, + value, + )); + } + ContactCardFilter::Address(value) => { + filters.push(SearchFilter::has_keyword( + ContactSearchField::Address, + value, + )); + } + ContactCardFilter::Note(value) => { + filters.push(SearchFilter::has_text_detect( + ContactSearchField::Note, + value, + self.core.email.default_language, + )); + } + ContactCardFilter::HasMember(value) => { + filters.push(SearchFilter::has_keyword(ContactSearchField::Member, value)); + } + ContactCardFilter::Kind(value) => { + filters.push(SearchFilter::eq(ContactSearchField::Kind, value)); + } + ContactCardFilter::Uid(value) => { + filters.push(SearchFilter::eq(ContactSearchField::Uid, value)) + } + ContactCardFilter::Email(email) => filters.push(SearchFilter::has_keyword( + ContactSearchField::Email, + sanitize_email(&email).unwrap_or(email), + )), + ContactCardFilter::Text(value) => { + filters.push(SearchFilter::Or); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Name, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Nickname, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Organization, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Email, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Phone, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::OnlineService, + value.clone(), + )); + filters.push(SearchFilter::has_keyword( + ContactSearchField::Address, + value.clone(), + )); + filters.push(SearchFilter::has_text_detect( + ContactSearchField::Note, + value, + self.core.email.default_language, + )); + filters.push(SearchFilter::End); + } + ContactCardFilter::CreatedBefore(before) => { + let before = before.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + created_to_updated + .iter() + .filter_map(|cu| (cu.created < before).then_some(cu.document_id)), + ))); + } + ContactCardFilter::CreatedAfter(after) => { + let after = after.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + created_to_updated + .iter() + .filter_map(|cu| (cu.created > after).then_some(cu.document_id)), + ))); + } + ContactCardFilter::UpdatedBefore(before) => { + let before = before.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + created_to_updated + .iter() + .filter_map(|cu| (cu.updated < before).then_some(cu.document_id)), + ))); + } + ContactCardFilter::UpdatedAfter(after) => { + let after = after.timestamp() as u64; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + created_to_updated + .iter() + .filter_map(|cu| (cu.updated > after).then_some(cu.document_id)), + ))); + } + unsupported => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(unsupported.into_string())); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let comparators = request + .sort + .take() + .unwrap_or_default() + .into_iter() + .map(|comparator| match comparator.property { + ContactCardComparator::Created => Ok(SearchComparator::sorted_set( + created_to_updated + .iter() + .enumerate() + .map(|(idx, u)| (u.document_id, idx as u32)) + .collect(), + comparator.is_ascending, + )), + ContactCardComparator::Updated => { + let mut updated = created_to_updated.clone(); + updated.sort_by_key(|a| a.updated); + Ok(SearchComparator::sorted_set( + updated + .iter() + .enumerate() + .map(|(idx, u)| (u.document_id, idx as u32)) + .collect(), + comparator.is_ascending, + )) + } + other => Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(other.into_string())), + }) + .collect::, _>>()?; + + let results = self + .search_store() + .query_account( + SearchQuery::new(SearchIndex::Contacts) + .with_filters(filters) + .with_comparators(comparators) + .with_account_id(account_id) + .with_mask(if access_token.is_shared(account_id) { + cache.shared_items(access_token, [Acl::ReadItems], true) + } else { + cache.document_ids(false).collect() + }), + ) + .await?; + + let mut response = QueryResponseBuilder::new( + results.len(), + self.core.jmap.query_max_results, + cache.get_state(false), + &request, + ); + + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + + response.build() + } + + async fn address_book_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + + let results = cache.document_ids(true).collect::>(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + State::Initial, + &request, + ); + + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + + response.build() + } +} diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs new file mode 100644 index 0000000..b67b13a --- /dev/null +++ b/crates/jmap/src/contact/set.rs @@ -0,0 +1,595 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::JmapCacheState; +use crate::contact::assert_is_unique_uid; +use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue}; +use common::{ + DavName, DavResources, Server, + auth::{AccessToken, AccountCache}, +}; +use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::contact, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Value}; +use store::{ + ValueKey, + ahash::AHashSet, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + blob::BlobId, + collection::{Collection, SyncCollection, VanishedCollection}, + id::Id, +}; + +pub trait ContactCardSet: Sync + Send { + fn contact_card_set( + &self, + request: SetRequest<'_, contact::ContactCard>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; + + #[allow(clippy::too_many_arguments)] + fn create_contact_card( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account: &AccountCache, + account_id: u32, + can_add_address_books: &Option, + js_contact: JSContact<'_, Id, BlobId>, + updates: Value<'_, JSContactProperty, JSContactValue>, + ) -> impl Future>>>>; +} + +impl ContactCardSet for Server { + async fn contact_card_set( + &self, + mut request: SetRequest<'_, contact::ContactCard>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let account = self.account(account_id).await.caused_by(trc::location!())?; + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::AddressBook, + ) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(false, &request.if_in_state)?); + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + + // Obtain addressBookIds + let (can_add_address_books, can_delete_address_books, can_modify_address_books) = + if access_token.is_shared(account_id) { + ( + cache + .shared_containers(access_token, [Acl::AddItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::RemoveItems], true) + .into(), + cache + .shared_containers(access_token, [Acl::ModifyItems], true) + .into(), + ) + } else { + (None, None, None) + }; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + match self + .create_contact_card( + &cache, + &mut batch, + access_token, + &account, + account_id, + &can_add_address_books, + JSContact::default(), + object, + ) + .await? + { + Ok(document_id) => { + response.created(id, document_id); + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + } + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain contact card + let document_id = id.document_id(); + let contact_card_ = if let Some(contact_card_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await? + { + contact_card_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let contact_card = contact_card_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_contact_card = contact_card + .deserialize::() + .caused_by(trc::location!())?; + let mut js_contact = new_contact_card.card.into_jscontact(); + + // Process changes + if let Err(err) = update_contact_card( + Some(id), + object, + &mut new_contact_card.names, + &mut js_contact, + ) { + response.not_updated.append(id, err); + continue 'update; + } + + // Convert JSContact to vCard + if let Some(vcard) = js_contact.into_vcard() { + new_contact_card.size = vcard.size() as u32; + new_contact_card.card = vcard; + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_description("Failed to convert contact to vCard."), + ); + continue 'update; + } + + // Validate UID + match (new_contact_card.card.uid(), contact_card.inner.card.uid()) { + (Some(old_uid), Some(new_uid)) if old_uid == new_uid => {} + (None, None) | (None, Some(_)) => {} + _ => { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(JSContactProperty::Uid) + .with_description("You cannot change the UID of a contact."), + ); + continue 'update; + } + } + + // Validate new addressBookIds + for addressbook_id in new_contact_card.added_addressbook_ids(contact_card.inner) { + if !cache.has_container_id(&addressbook_id) { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description(format!( + "addressBookId {} does not exist.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } else if can_add_address_books + .as_ref() + .is_some_and(|ids| !ids.contains(addressbook_id)) + { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add contacts to address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + + // Validate deleted addressBookIds + if let Some(can_delete_address_books) = &can_delete_address_books { + for addressbook_id in new_contact_card.removed_addressbook_ids(contact_card.inner) { + if !can_delete_address_books.contains(addressbook_id) { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to remove contacts from address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + } + + // Validate changed addressBookIds + if let Some(can_modify_address_books) = &can_modify_address_books { + for addressbook_id in new_contact_card.unchanged_addressbook_ids(contact_card.inner) + { + if !can_modify_address_books.contains(addressbook_id) { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to modify address book {}.", + Id::from(addressbook_id) + )), + ); + continue 'update; + } + } + } + + // Check size and quota + if new_contact_card.size as usize > self.core.groupware.max_vcard_size { + response.not_updated.append( + id, + SetError::invalid_properties().with_description(format!( + "Contact size {} exceeds the maximum allowed size of {} bytes.", + new_contact_card.size, self.core.groupware.max_vcard_size + )), + ); + continue 'update; + } + let extra_bytes = (new_contact_card.size as u64) + .saturating_sub(u32::from(contact_card.inner.size) as u64); + if extra_bytes > 0 { + match self.has_available_quota(&account, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + } + + // Update record + let vanished_paths = new_contact_card + .removed_addressbook_ids(contact_card.inner) + .filter_map(|addressbook_id| { + cache.format_resource_path_by_parent(document_id, addressbook_id) + }) + .collect::>(); + new_contact_card + .update( + access_token.account_tenant_ids(), + contact_card, + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + for path in vanished_paths { + batch.log_vanished_item(VanishedCollection::AddressBook, path); + } + response.updated.append(id, None); + } + + // Process deletions + 'destroy: for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_item_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(contact_card_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let contact_card = contact_card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if let Some(can_delete_address_books) = &can_delete_address_books { + for name in contact_card.inner.names.iter() { + let parent_id = name.parent_id.to_native(); + if !can_delete_address_books.contains(parent_id) { + response.not_destroyed.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to remove contacts from address book {}.", + Id::from(parent_id) + )), + ); + continue 'destroy; + } + } + } + + // Delete record + DestroyArchive(contact_card) + .delete_all( + access_token.account_tenant_ids(), + account_id, + document_id, + &mut batch, + ) + .caused_by(trc::location!())?; + + for path in cache.format_resource_paths_by_id(document_id) { + batch.log_vanished_item(VanishedCollection::AddressBook, path); + } + + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + self.notify_task_queue(); + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } + + async fn create_contact_card( + &self, + cache: &DavResources, + batch: &mut BatchBuilder, + access_token: &AccessToken, + account: &AccountCache, + account_id: u32, + can_add_address_books: &Option, + mut js_contact: JSContact<'_, Id, BlobId>, + updates: Value<'_, JSContactProperty, JSContactValue>, + ) -> trc::Result>>> { + // Process changes + let mut names = Vec::new(); + if let Err(err) = update_contact_card(None, updates, &mut names, &mut js_contact) { + return Ok(Err(err)); + } + + // Verify that the address book ids valid + for name in &names { + if !cache.has_container_id(&name.parent_id) { + return Ok(Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description(format!( + "addressBookId {} does not exist.", + Id::from(name.parent_id) + )))); + } else if can_add_address_books + .as_ref() + .is_some_and(|ids| !ids.contains(name.parent_id)) + { + return Ok(Err(SetError::forbidden().with_description(format!( + "You are not allowed to add contacts to address book {}.", + Id::from(name.parent_id) + )))); + } + } + + // Convert JSContact to vCard + let Some(card) = js_contact.into_vcard() else { + return Ok(Err(SetError::invalid_properties() + .with_description("Failed to convert contact to vCard."))); + }; + + // Validate UID + if let Err(err) = assert_is_unique_uid(self, cache, account_id, &names, card.uid()).await? { + return Ok(Err(err)); + } + + // Check size and quota + let size = card.size(); + if size > self.core.groupware.max_vcard_size { + return Ok(Err(SetError::invalid_properties().with_description( + format!( + "Contact size {} exceeds the maximum allowed size of {} bytes.", + size, self.core.groupware.max_vcard_size + ), + ))); + } + match self.has_available_quota(account, size as u64).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + return Ok(Err(SetError::over_quota())); + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::ContactCard, 1) + .await + .caused_by(trc::location!())?; + ContactCard { + names, + size: size as u32, + card, + ..Default::default() + } + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + batch, + ) + .caused_by(trc::location!()) + .map(|_| Ok(document_id)) + } +} + +fn update_contact_card<'x>( + expected_id: Option, + updates: Value<'x, JSContactProperty, JSContactValue>, + addressbooks: &mut Vec, + js_contact: &mut JSContact<'x, Id, BlobId>, +) -> Result<(), SetError>> { + let mut entries = js_contact.0.as_object_mut().unwrap(); + + for (property, value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (JSContactProperty::AddressBookIds, value) => { + patch_parent_ids(addressbooks, None, value)?; + } + (JSContactProperty::Pointer(pointer), value) => { + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + JSContactProperty::AddressBookIds + ))) + ) { + let mut pointer = pointer.iter(); + pointer.next(); + patch_parent_ids(addressbooks, pointer.next(), value)?; + } else if !js_contact.0.patch_jptr(pointer.iter(), value) { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::Pointer(pointer)) + .with_description("Patch operation failed.")); + } + entries = js_contact.0.as_object_mut().unwrap(); + } + (JSContactProperty::Media, Value::Object(media)) => { + for (_, value) in media.iter() { + if value.as_object().is_some_and(|v| { + v.keys() + .any(|k| matches!(k, Key::Property(JSContactProperty::BlobId))) + }) { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::Media) + .with_description("blobIds in media is not supported.")); + } + } + entries.insert(JSContactProperty::Media, Value::Object(media)); + } + (JSContactProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, value) => { + entries.insert(property, value); + } + } + } + + // Make sure the contact belongs to at least one address book + if addressbooks.is_empty() { + return Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description("Contact has to belong to at least one address book.")); + } + + Ok(()) +} + +fn patch_parent_ids( + current: &mut Vec, + patch: Option<&JsonPointerItem>>, + update: Value<'_, JSContactProperty, JSContactValue>, +) -> Result<(), SetError>> { + match (patch, update) { + ( + Some(JsonPointerItem::Key(Key::Property(JSContactProperty::IdValue(id)))), + Value::Bool(false) | Value::Null, + ) => { + let id = id.document_id(); + current.retain(|name| name.parent_id != id); + Ok(()) + } + ( + Some(JsonPointerItem::Key(Key::Property(JSContactProperty::IdValue(id)))), + Value::Bool(true), + ) => { + let id = id.document_id(); + if !current.iter().any(|name| name.parent_id == id) { + current.push(DavName::new_with_rand_name(id)); + } + Ok(()) + } + (None, Value::Object(object)) => { + let mut new_ids = object + .into_expanded_boolean_set() + .filter_map(|id| { + if let Key::Property(JSContactProperty::IdValue(id)) = id { + Some(id.document_id()) + } else { + None + } + }) + .collect::>(); + + current.retain(|name| new_ids.remove(&name.parent_id)); + + for id in new_ids { + current.push(DavName::new_with_rand_name(id)); + } + + Ok(()) + } + _ => Err(SetError::invalid_properties() + .with_property(JSContactProperty::AddressBookIds) + .with_description("Invalid patch operation for addressBookIds.")), + } +} diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs new file mode 100644 index 0000000..dfb7779 --- /dev/null +++ b/crates/jmap/src/email/copy.rs @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + changes::state::JmapCacheState, + email::{PatchResult, handle_email_patch, ingested_into_object}, +}; +use common::{Server, auth::AccessToken}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, + message::copy::{CopyMessageError, EmailCopy}, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::email::{Email, EmailProperty, EmailValue}, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, +}; +use jmap_tools::{Key, Value}; +use std::future::Future; +use trc::AddContext; +use types::acl::Acl; +use utils::map::vec_map::VecMap; + +pub trait JmapEmailCopy: Sync + Send { + fn email_copy<'x>( + &self, + request: CopyRequest<'x, Email>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl JmapEmailCopy for Server { + async fn email_copy<'x>( + &self, + request: CopyRequest<'x, Email>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + let cache = self.get_cached_messages(account_id).await?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .get_cached_messages(from_account_id) + .await + .caused_by(trc::location!())?; + let from_message_ids = if access_token.is_member(from_account_id) { + from_cache.email_document_ids() + } else { + from_cache.shared_messages(access_token, Acl::ReadItems) + }; + + let can_add_mailbox_ids = if access_token.is_shared(account_id) { + cache.shared_mailboxes(access_token, Acl::AddItems).into() + } else { + None + }; + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + let mut destroy_ids = Vec::new(); + + 'create: for (id, create) in request.create.into_valid() { + let mut from_message_id = None; + let mut mailboxes = Vec::new(); + let mut keywords = Vec::new(); + let mut received_at = None; + + for (property, value) in create.into_expanded_object() { + match (property, value) { + (Key::Property(EmailProperty::Id), Value::Element(EmailValue::Id(src))) => { + from_message_id = Some(src); + } + (Key::Property(EmailProperty::MailboxIds), Value::Object(ids)) => { + mailboxes = ids + .into_expanded_boolean_set() + .filter_map(|id| { + id.try_into_property()?.try_into_id()?.document_id().into() + }) + .collect(); + } + (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + keywords = keywords_ + .into_expanded_boolean_set() + .filter_map(|id| id.try_into_property()?.try_into_keyword()) + .collect(); + } + (Key::Property(EmailProperty::Pointer(pointer)), value) => { + match handle_email_patch(&pointer, value) { + PatchResult::SetKeyword(keyword) => { + if !keywords.contains(keyword) { + keywords.push(keyword.clone()); + } + } + PatchResult::RemoveKeyword(keyword) => { + keywords.retain(|k| k != keyword); + } + PatchResult::AddMailbox(id) => { + if !mailboxes.contains(&id) { + mailboxes.push(id); + } + } + PatchResult::RemoveMailbox(id) => { + mailboxes.retain(|mid| mid != &id); + } + PatchResult::Invalid(set_error) => { + response.not_created.append(id, set_error); + continue 'create; + } + } + } + ( + Key::Property(EmailProperty::ReceivedAt), + Value::Element(EmailValue::Date(value)), + ) => { + received_at = value.into(); + } + (property, _) => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Invalid property or value.".to_string()), + ); + continue 'create; + } + } + } + + let Some(from_message_id) = from_message_id else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::Id) + .with_description("Missing or invalid \"id\" property."), + ); + continue 'create; + }; + if !from_message_ids.contains(from_message_id.document_id()) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue 'create; + } + + // Make sure message belongs to at least one mailbox + if mailboxes.is_empty() { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description("Message has to belong to at least one mailbox."), + ); + continue 'create; + } + + // Verify that the mailboxIds are valid + for mailbox_id in &mailboxes { + if !cache.has_mailbox_id(mailbox_id) { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description(format!("mailboxId {mailbox_id} does not exist.")), + ); + continue 'create; + } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {mailbox_id}." + )), + ); + continue 'create; + } + } + + // Add response + match self + .copy_message( + from_account_id, + from_message_id.document_id(), + account_id, + mailboxes, + keywords, + received_at.map(|dt| dt.timestamp() as u64), + session.session_id, + ) + .await? + { + Ok(email) => { + response + .created + .append(id, ingested_into_object(email).into()); + } + Err(err) => { + response.not_created.append( + id, + match err { + CopyMessageError::NotFound => SetError::not_found() + .with_description("Message not found in account."), + CopyMessageError::OverQuota => SetError::over_quota(), + CopyMessageError::AlreadyExists(existing) => SetError::already_exists() + .with_existing_id(types::id::Id::from(existing)), + }, + ); + } + } + + // Add to destroy list + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(from_message_id)); + } + } + + // Update state + if !response.created.is_empty() { + response.new_state = self.get_cached_messages(account_id).await?.get_state(false); + } + + // Destroy ids + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::Email, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::Email(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs new file mode 100644 index 0000000..cf4804d --- /dev/null +++ b/crates/jmap/src/email/get.rs @@ -0,0 +1,449 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::JmapCacheState; +use common::{Server, auth::AccessToken}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::{ + body::{ToBodyPart, truncate_html, truncate_plain}, + headers::{HeaderToValue, IntoForm}, + metadata::{ + ArchivedMetadataPartType, MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK, + MessageMetadata, MetadataHeaderName, PART_ENCODING_PROBLEM, + }, + }, +}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::email::{Email, EmailProperty, EmailValue, HeaderForm}, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use mail_parser::HeaderValue; +use std::future::Future; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::{AddContext, StoreEvent}; +use types::{ + acl::Acl, + blob::{BlobClass, BlobId}, + blob_hash::BlobHash, + collection::Collection, + field::EmailField, + id::Id, +}; +use utils::chained_bytes::ChainedBytes; + +pub trait EmailGet: Sync + Send { + fn email_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl EmailGet for Server { + async fn email_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + EmailProperty::Id, + EmailProperty::BlobId, + EmailProperty::ThreadId, + EmailProperty::MailboxIds, + EmailProperty::Keywords, + EmailProperty::Size, + EmailProperty::ReceivedAt, + EmailProperty::MessageId, + EmailProperty::InReplyTo, + EmailProperty::References, + EmailProperty::Sender, + EmailProperty::From, + EmailProperty::To, + EmailProperty::Cc, + EmailProperty::Bcc, + EmailProperty::ReplyTo, + EmailProperty::Subject, + EmailProperty::SentAt, + EmailProperty::HasAttachment, + EmailProperty::Preview, + EmailProperty::BodyValues, + EmailProperty::TextBody, + EmailProperty::HtmlBody, + EmailProperty::Attachments, + ]); + let body_properties = request + .arguments + .body_properties + .map(|v| v.into_valid().collect()) + .unwrap_or_else(|| { + vec![ + EmailProperty::PartId, + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::Name, + EmailProperty::Type, + EmailProperty::Charset, + EmailProperty::Disposition, + EmailProperty::Cid, + EmailProperty::Language, + EmailProperty::Location, + ] + }); + let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false); + let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false); + let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false); + let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0); + + let account_id = request.account_id.document_id(); + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + let message_ids = if access_token.is_member(account_id) { + cache.email_document_ids() + } else { + cache.shared_messages(access_token, Acl::ReadItems) + }; + + let ids = if let Some(ids) = ids { + ids + } else { + cache + .emails + .items + .iter() + .take(self.core.jmap.get_max_objects) + .map(|item| Id::from_parts(item.thread_id, item.document_id)) + .collect() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + // Check if we need to fetch the raw headers or body + let mut needs_body = false; + for property in &properties { + if matches!( + property, + EmailProperty::BodyValues + | EmailProperty::TextBody + | EmailProperty::HtmlBody + | EmailProperty::Attachments + | EmailProperty::BodyStructure + ) { + needs_body = true; + break; + } + } + + for id in ids { + // Obtain the email object + if !message_ids.contains(id.document_id()) { + response.push_not_found(id); + continue; + } + let metadata_ = match self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + id.document_id(), + EmailField::Metadata, + )) + .await? + { + Some(metadata) => metadata, + None => { + response.push_not_found(id); + continue; + } + }; + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + + // Obtain message data + let data = match cache.email_by_id(&id.document_id()) { + Some(data) => data, + None => { + response.push_not_found(id); + continue; + } + }; + + // Retrieve raw message if needed + let blob_hash = BlobHash::from(&metadata.blob_hash); + let raw_body; + let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()); + if needs_body { + raw_body = self + .blob_store() + .get_blob(blob_hash.as_slice(), 0..usize::MAX) + .await?; + + if let Some(raw_body) = &raw_body { + raw_message.append( + raw_body + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + } else { + trc::event!( + Store(StoreEvent::NotFound), + AccountId = account_id, + DocumentId = id.document_id(), + Collection = Collection::Email, + BlobId = blob_hash.to_hex(), + Details = "Blob not found.", + CausedBy = trc::location!(), + ); + + response.push_not_found(id); + continue; + } + } + let blob_id = BlobId { + hash: blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::Email.into(), + document_id: id.document_id(), + }, + section: None, + }; + + // Prepare response + let mut email: Map<'_, EmailProperty, EmailValue> = + Map::with_capacity(properties.len()); + let contents = &metadata.contents[0]; + let root_part = &contents.parts[0]; + let blob_body_offset = metadata.blob_body_offset.to_native() as isize + - root_part.offset_body.to_native() as isize; + for property in &properties { + match property { + EmailProperty::Id => { + email.insert_unchecked(EmailProperty::Id, Id::from(*id)); + } + EmailProperty::ThreadId => { + email.insert_unchecked(EmailProperty::ThreadId, Id::from(id.prefix_id())); + } + EmailProperty::BlobId => { + email.insert_unchecked(EmailProperty::BlobId, blob_id.clone()); + } + EmailProperty::MailboxIds => { + let mut obj = Map::with_capacity(data.mailboxes.len()); + for id in data.mailboxes.iter() { + debug_assert!(id.uid != 0); + obj.insert_unchecked( + EmailProperty::IdValue(Id::from(id.mailbox_id)), + true, + ); + } + + email.insert_unchecked(property.clone(), Value::Object(obj)); + } + EmailProperty::Keywords => { + let mut obj = Map::with_capacity(2); + for keyword in cache.expand_keywords(data) { + obj.insert_unchecked(EmailProperty::Keyword(keyword), true); + } + email.insert_unchecked(property.clone(), Value::Object(obj)); + } + EmailProperty::Size => { + email.insert_unchecked(EmailProperty::Size, data.size); + } + EmailProperty::ReceivedAt => { + email.insert_unchecked( + EmailProperty::ReceivedAt, + EmailValue::Date(UTCDate::from_timestamp( + (metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64, + )), + ); + } + EmailProperty::Preview => { + if !metadata.preview.is_empty() { + email.insert_unchecked( + EmailProperty::Preview, + metadata.preview.to_string(), + ); + } + } + EmailProperty::HasAttachment => { + email.insert_unchecked( + EmailProperty::HasAttachment, + (metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0, + ); + } + EmailProperty::Subject => { + email.insert_unchecked( + EmailProperty::Subject, + root_part + .header_value(&MetadataHeaderName::Subject) + .map(|value| HeaderValue::from(value).into_form(&HeaderForm::Text)) + .unwrap_or_default(), + ); + } + EmailProperty::SentAt => { + email.insert_unchecked( + EmailProperty::SentAt, + root_part + .header_value(&MetadataHeaderName::Date) + .map(|value| HeaderValue::from(value).into_form(&HeaderForm::Date)) + .unwrap_or_default(), + ); + } + EmailProperty::MessageId + | EmailProperty::InReplyTo + | EmailProperty::References => { + email.insert_unchecked( + property.clone(), + root_part + .header_value(&match property { + EmailProperty::MessageId => MetadataHeaderName::MessageId, + EmailProperty::InReplyTo => MetadataHeaderName::InReplyTo, + EmailProperty::References => MetadataHeaderName::References, + _ => unreachable!(), + }) + .map(|value| { + HeaderValue::from(value).into_form(&HeaderForm::MessageIds) + }) + .unwrap_or_default(), + ); + } + + EmailProperty::Sender + | EmailProperty::From + | EmailProperty::To + | EmailProperty::Cc + | EmailProperty::Bcc + | EmailProperty::ReplyTo => { + email.insert_unchecked( + property.clone(), + root_part + .header_value(&match property { + EmailProperty::Sender => MetadataHeaderName::Sender, + EmailProperty::From => MetadataHeaderName::From, + EmailProperty::To => MetadataHeaderName::To, + EmailProperty::Cc => MetadataHeaderName::Cc, + EmailProperty::Bcc => MetadataHeaderName::Bcc, + EmailProperty::ReplyTo => MetadataHeaderName::ReplyTo, + _ => unreachable!(), + }) + .map(|value| { + HeaderValue::from(value).into_form(&HeaderForm::Addresses) + }) + .unwrap_or_default(), + ); + } + EmailProperty::Header(_) => { + email.insert_unchecked( + property.clone(), + root_part.header_to_value(property, &raw_message), + ); + } + EmailProperty::Headers => { + email.insert_unchecked( + EmailProperty::Headers, + root_part.headers_to_value(&raw_message), + ); + } + EmailProperty::TextBody + | EmailProperty::HtmlBody + | EmailProperty::Attachments => { + let list = match property { + EmailProperty::TextBody => &contents.text_body, + EmailProperty::HtmlBody => &contents.html_body, + EmailProperty::Attachments => &contents.attachments, + _ => unreachable!(), + } + .iter(); + email.insert_unchecked( + property.clone(), + list.map(|part_id| { + contents.to_body_part( + u16::from(part_id) as u32, + &body_properties, + &raw_message, + &blob_id, + blob_body_offset, + ) + }) + .collect::>(), + ); + } + EmailProperty::BodyStructure => { + email.insert_unchecked( + EmailProperty::BodyStructure, + contents.to_body_part( + 0, + &body_properties, + &raw_message, + &blob_id, + blob_body_offset, + ), + ); + } + EmailProperty::BodyValues => { + let mut body_values = Map::with_capacity(contents.parts.len()); + for (part_id, part) in contents.parts.iter().enumerate() { + if part.is_text_mime_type() + && (fetch_all_body_values + || (fetch_html_body_values + && contents.is_html_part(part_id as u16)) + || (fetch_text_body_values + && contents.is_text_part(part_id as u16))) + { + let contents = part.decode_contents(&raw_message); + + let (is_truncated, value) = match &part.body { + ArchivedMetadataPartType::Text => { + truncate_plain(contents.as_str(), max_body_value_bytes) + } + ArchivedMetadataPartType::Html => { + truncate_html(contents.as_str(), max_body_value_bytes) + } + _ => unreachable!(), + }; + + body_values.insert_unchecked( + Key::Owned(part_id.to_string()), + Map::with_capacity(3) + .with_key_value( + EmailProperty::IsEncodingProblem, + (part.flags & PART_ENCODING_PROBLEM) != 0, + ) + .with_key_value(EmailProperty::IsTruncated, is_truncated) + .with_key_value(EmailProperty::Value, value), + ); + } + } + email.insert_unchecked(EmailProperty::BodyValues, body_values); + } + + _ => { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details(format!("Invalid property {property:?}"))); + } + } + } + response.list.push(email.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs new file mode 100644 index 0000000..030fe2b --- /dev/null +++ b/crates/jmap/src/email/import.rs @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object, +}; +use common::{Server, auth::AccessToken, ipc::PushNotification}; +use email::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, + mailbox::JUNK_ID, + message::ingest::{EmailIngest, IngestEmail, IngestSource}, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::import::{ImportEmailRequest, ImportEmailResponse}, + object::email::EmailProperty, + request::MaybeInvalid, + types::state::State, +}; +use mail_parser::{HeaderName, MessageParser}; +use std::future::Future; +use types::{ + acl::Acl, + id::Id, + keyword::Keyword, + type_state::{DataType, StateChange}, +}; +use utils::map::vec_map::VecMap; + +pub trait EmailImport: Sync + Send { + fn email_import( + &self, + request: ImportEmailRequest, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future> + Send; +} + +impl EmailImport for Server { + async fn email_import( + &self, + request: ImportEmailRequest, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> trc::Result { + // Validate state + let account_id = request.account_id.document_id(); + let cache = self.get_cached_messages(account_id).await?; + let old_state: State = cache.assert_state(false, &request.if_in_state)?; + let can_add_mailbox_ids = if access_token.is_shared(account_id) { + cache.shared_mailboxes(access_token, Acl::AddItems).into() + } else { + None + }; + + // Obtain import access token + let import_access_token = if account_id != access_token.account_id() { + #[cfg(feature = "test_mode")] + { + AccessToken::from_id_maybe_invalid(account_id).into() + } + + #[cfg(not(feature = "test_mode"))] + { + use common::auth::BuildAccessToken; + use trc::AddContext; + self.access_token(account_id) + .await + .caused_by(trc::location!())? + .build() + .into() + } + } else { + None + }; + + let mut response = ImportEmailResponse { + account_id: request.account_id, + new_state: old_state.clone(), + old_state: old_state.into(), + created: VecMap::with_capacity(request.emails.len()), + not_created: VecMap::new(), + }; + + let mut last_change_id = None; + 'outer: for (id, email) in request.emails { + // Validate mailboxIds + let mailbox_ids = email + .mailbox_ids + .unwrap() + .into_iter() + .filter_map(|m| m.try_unwrap().map(|m| m.document_id())) + .collect::>(); + if mailbox_ids.is_empty() { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description("Message must belong to at least one mailbox."), + ); + continue; + } + for mailbox_id in &mailbox_ids { + if !cache.has_mailbox_id(mailbox_id) { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description(format!( + "Mailbox {} does not exist.", + Id::from(*mailbox_id) + )), + ); + continue 'outer; + } else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {}.", + Id::from(*mailbox_id) + )), + ); + continue 'outer; + } + } + + let MaybeInvalid::Value(blob_id) = email.blob_id else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::BlobId) + .with_description("Invalid blob id."), + ); + continue; + }; + + // Fetch raw message to import + let raw_message = match self.blob_download(&blob_id, access_token).await? { + Some(raw_message) => raw_message, + None => { + response.not_created.append( + id, + SetError::new(SetErrorType::BlobNotFound) + .with_description(format!("BlobId {} not found.", blob_id)), + ); + continue; + } + }; + + // Import message + let parsed = MessageParser::new().parse(&raw_message); + let is_valid_message = parsed.as_ref().is_some_and(|message| { + message + .headers() + .iter() + .any(|header| !matches!(header.name, HeaderName::Other(_))) + }); + if !is_valid_message { + response.not_created.append( + id, + SetError::new(SetErrorType::InvalidEmail) + .with_description("Blob does not contain a valid RFC 5322 message."), + ); + continue; + } + match self + .email_ingest(IngestEmail { + raw_message: &raw_message, + message: parsed, + blob_hash: Some(&blob_id.hash), + access_token: import_access_token.as_ref().unwrap_or(access_token), + source: IngestSource::Jmap { + train_classifier: email + .keywords + .iter() + .any(|k| matches!(k, Keyword::Junk | Keyword::NotJunk)) + || mailbox_ids.contains(&JUNK_ID), + }, + mailbox_ids, + keywords: email.keywords, + received_at: email.received_at.map(|r| r.into()), + session_id: session.session_id, + }) + .await + { + Ok(email) => { + last_change_id = Some(email.change_id); + response + .created + .append(id, ingested_into_object(email).into()); + } + Err(mut err) => match err.as_ref() { + trc::EventType::Limit(trc::LimitEvent::Quota) => { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota) + .with_description("You have exceeded your disk quota."), + ); + } + trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) => { + response.not_created.append( + id, + SetError::new(SetErrorType::InvalidEmail).with_description( + err.take_value(trc::Key::Reason) + .and_then(|v| v.into_string()) + .unwrap(), + ), + ); + } + _ => { + return Err(err); + } + }, + } + } + + // Update state + if let Some(change_id) = last_change_id { + self.broadcast_push_notification(PushNotification::StateChange( + StateChange::new(account_id) + .with_change_id(change_id) + .with_change(DataType::Email) + .with_change(DataType::Mailbox) + .with_change(DataType::Thread), + )) + .await; + + response.new_state = self.get_cached_messages(account_id).await?.get_state(false); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/email/mod.rs b/crates/jmap/src/email/mod.rs new file mode 100644 index 0000000..ccd4b55 --- /dev/null +++ b/crates/jmap/src/email/mod.rs @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use email::message::ingest::IngestedEmail; +use jmap_proto::{ + error::set::SetError, + object::email::{EmailProperty, EmailValue}, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value}; +use types::{id::Id, keyword::Keyword}; + +pub mod copy; +pub mod get; +pub mod import; +pub mod parse; +pub mod query; +pub mod set; +pub mod snippet; + +fn ingested_into_object(email: IngestedEmail) -> Map<'static, EmailProperty, EmailValue> { + Map::with_capacity(3) + .with_key_value( + EmailProperty::Id, + Id::from_parts(email.thread_id, email.document_id), + ) + .with_key_value(EmailProperty::ThreadId, Id::from(email.thread_id)) + .with_key_value(EmailProperty::BlobId, email.blob_id) + .with_key_value(EmailProperty::Size, email.size) +} + +pub(crate) enum PatchResult<'x> { + SetKeyword(&'x Keyword), + RemoveKeyword(&'x Keyword), + AddMailbox(u32), + RemoveMailbox(u32), + Invalid(SetError), +} + +pub(crate) fn handle_email_patch<'x>( + pointer: &'x JsonPointer, + value: Value<'_, EmailProperty, EmailValue>, +) -> PatchResult<'x> { + let mut pointer_iter = pointer.iter(); + + match (pointer_iter.next(), pointer_iter.next()) { + ( + Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keywords))), + Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keyword(keyword)))), + ) => match value { + Value::Bool(true) => return PatchResult::SetKeyword(keyword), + Value::Bool(false) | Value::Null => return PatchResult::RemoveKeyword(keyword), + _ => (), + }, + ( + Some(JsonPointerItem::Key(Key::Property(EmailProperty::MailboxIds))), + Some(JsonPointerItem::Key(Key::Property(EmailProperty::IdValue(id)))), + ) => match value { + Value::Bool(true) => return PatchResult::AddMailbox(id.document_id()), + Value::Bool(false) | Value::Null => { + return PatchResult::RemoveMailbox(id.document_id()); + } + _ => (), + }, + _ => (), + } + + PatchResult::Invalid( + SetError::invalid_properties() + .with_property(EmailProperty::Pointer(pointer.clone())) + .with_description("Invalid patch value".to_string()), + ) +} diff --git a/crates/jmap/src/email/parse.rs b/crates/jmap/src/email/parse.rs new file mode 100644 index 0000000..8c89bdc --- /dev/null +++ b/crates/jmap/src/email/parse.rs @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::blob::download::BlobDownload; +use common::{Server, auth::AccessToken}; +use email::message::index::PREVIEW_LENGTH; +use email::message::{ + body::{ToBodyPart, TruncateBody}, + headers::HeaderToValue, +}; +use jmap_proto::{ + method::parse::{ParseRequest, ParseResponse}, + object::email::{Email, EmailProperty}, + request::{IntoValid, MaybeInvalid, reference::MaybeIdReference}, +}; +use jmap_tools::{Key, Map, Value}; +use mail_parser::{ + HeaderName, MessageParser, MimeHeaders, PartType, decoders::html::html_to_text, + parsers::preview::preview_text, +}; +use std::future::Future; +use utils::{chained_bytes::ChainedBytes, map::vec_map::VecMap}; + +pub trait EmailParse: Sync + Send { + fn email_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl EmailParse for Server { + async fn email_parse( + &self, + request: ParseRequest, + access_token: &AccessToken, + ) -> trc::Result> { + if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + let properties = request + .properties + .map(|v| v.into_valid().collect()) + .unwrap_or_else(|| { + vec![ + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::ReceivedAt, + EmailProperty::MessageId, + EmailProperty::InReplyTo, + EmailProperty::References, + EmailProperty::Sender, + EmailProperty::From, + EmailProperty::To, + EmailProperty::Cc, + EmailProperty::Bcc, + EmailProperty::ReplyTo, + EmailProperty::Subject, + EmailProperty::SentAt, + EmailProperty::HasAttachment, + EmailProperty::Preview, + EmailProperty::BodyValues, + EmailProperty::TextBody, + EmailProperty::HtmlBody, + EmailProperty::Attachments, + ] + }); + let body_properties = request + .arguments + .body_properties + .map(|v| v.into_valid().collect()) + .unwrap_or_else(|| { + vec![ + EmailProperty::PartId, + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::Name, + EmailProperty::Type, + EmailProperty::Charset, + EmailProperty::Disposition, + EmailProperty::Cid, + EmailProperty::Language, + EmailProperty::Location, + ] + }); + let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false); + let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false); + let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false); + let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0); + + let mut response = ParseResponse { + account_id: request.account_id, + parsed: VecMap::with_capacity(request.blob_ids.len()), + not_parsable: vec![], + not_found: vec![], + }; + + for blob_id in request.blob_ids { + let blob_id = match blob_id { + MaybeIdReference::Id(blob_id) => blob_id, + MaybeIdReference::Invalid(s) | MaybeIdReference::Reference(s) => { + response.not_found.push(MaybeInvalid::Invalid(s)); + continue; + } + }; + // Fetch raw message to parse + let raw_message = match self.blob_download(&blob_id, access_token).await? { + Some(raw_message) => raw_message, + None => { + response.not_found.push(MaybeInvalid::Value(blob_id)); + continue; + } + }; + let message = match MessageParser::new().parse(&raw_message).filter(|message| { + message + .root_part() + .headers() + .iter() + .any(|header| !matches!(header.name, HeaderName::Other(_))) + }) { + Some(message) => message, + None => { + response.not_parsable.push(blob_id); + continue; + } + }; + let raw_message = ChainedBytes::new(&raw_message); + + // Prepare response + let mut email = Map::with_capacity(properties.len()); + for property in &properties { + match property { + EmailProperty::BlobId => { + email.insert_unchecked(EmailProperty::BlobId, blob_id.clone()); + } + + EmailProperty::Size => { + email.insert_unchecked( + EmailProperty::Size, + Value::Number(raw_message.len().into()), + ); + } + EmailProperty::HasAttachment => { + email.insert_unchecked( + EmailProperty::HasAttachment, + Value::Bool(message.parts.iter().enumerate().any(|(part_id, part)| { + let part_id = part_id as u32; + match &part.body { + PartType::Html(_) | PartType::Text(_) => { + !message.text_body.contains(&part_id) + && !message.html_body.contains(&part_id) + } + PartType::Binary(_) | PartType::Message(_) => true, + _ => false, + } + })), + ); + } + EmailProperty::Preview => { + email.insert_unchecked( + EmailProperty::Preview, + match message + .text_body + .first() + .or_else(|| message.html_body.first()) + .and_then(|idx| message.parts.get(*idx as usize)) + .map(|part| &part.body) + { + Some(PartType::Text(text)) => { + preview_text(text.replace('\r', "").into(), PREVIEW_LENGTH) + .into() + } + Some(PartType::Html(html)) => preview_text( + html_to_text(html).replace('\r', "").into(), + PREVIEW_LENGTH, + ) + .into(), + _ => Value::Null, + }, + ); + } + EmailProperty::MessageId + | EmailProperty::InReplyTo + | EmailProperty::References + | EmailProperty::Sender + | EmailProperty::From + | EmailProperty::To + | EmailProperty::Cc + | EmailProperty::Bcc + | EmailProperty::ReplyTo + | EmailProperty::Subject + | EmailProperty::SentAt + | EmailProperty::Header(_) => { + email.insert_unchecked( + property.clone(), + message.parts[0] + .headers + .header_to_value(property, &raw_message), + ); + } + EmailProperty::Headers => { + email.insert_unchecked( + EmailProperty::Headers, + message.parts[0].headers.headers_to_value(&raw_message), + ); + } + EmailProperty::TextBody + | EmailProperty::HtmlBody + | EmailProperty::Attachments => { + let list = match property { + EmailProperty::TextBody => &message.text_body, + EmailProperty::HtmlBody => &message.html_body, + EmailProperty::Attachments => &message.attachments, + _ => unreachable!(), + } + .iter(); + email.insert_unchecked( + property.clone(), + list.map(|part_id| { + message.parts.to_body_part( + *part_id, + &body_properties, + &raw_message, + &blob_id, + 0, + ) + }) + .collect::>(), + ); + } + EmailProperty::BodyStructure => { + email.insert_unchecked( + EmailProperty::BodyStructure, + message.parts.to_body_part( + 0, + &body_properties, + &raw_message, + &blob_id, + 0, + ), + ); + } + EmailProperty::BodyValues => { + let mut body_values = Map::with_capacity(message.parts.len()); + for (part_id, part) in message.parts.iter().enumerate() { + let part_id = part_id as u32; + if part.is_text() + && part + .content_type() + .is_none_or(|ct| ct.ctype().eq_ignore_ascii_case("text")) + && (fetch_all_body_values + || (fetch_html_body_values + && message.html_body.contains(&part_id)) + || (fetch_text_body_values + && message.text_body.contains(&part_id))) + { + let (is_truncated, value) = + part.body.truncate(max_body_value_bytes); + body_values.insert_unchecked( + Key::Owned(part_id.to_string()), + Map::with_capacity(3) + .with_key_value( + EmailProperty::IsEncodingProblem, + part.is_encoding_problem, + ) + .with_key_value(EmailProperty::IsTruncated, is_truncated) + .with_key_value(EmailProperty::Value, value), + ); + } + } + email.insert_unchecked(EmailProperty::BodyValues, body_values); + } + EmailProperty::Id + | EmailProperty::ThreadId + | EmailProperty::Keywords + | EmailProperty::MailboxIds + | EmailProperty::ReceivedAt => { + email.insert_unchecked(property.clone(), Value::Null); + } + + _ => { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details(format!("Invalid property {property:?}"))); + } + } + } + response.parsed.append(blob_id, email.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/email/query.rs b/crates/jmap/src/email/query.rs new file mode 100644 index 0000000..a5cedbb --- /dev/null +++ b/crates/jmap/src/email/query.rs @@ -0,0 +1,441 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use common::{MessageStoreCache, Server, auth::AccessToken}; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::email::{Email, EmailComparator, EmailFilter}, +}; +use mail_parser::HeaderName; +use nlp::language::Language; +use std::future::Future; +use store::{ + ahash::{AHashMap, AHashSet}, + roaring::RoaringBitmap, + search::{ + EmailSearchField, SearchComparator, SearchFilter, SearchOperator, SearchQuery, SearchValue, + }, + write::SearchIndex, +}; +use trc::AddContext; +use types::{acl::Acl, keyword::Keyword}; +use utils::map::vec_map::VecMap; + +pub trait EmailQuery: Sync + Send { + fn email_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl EmailQuery for Server { + async fn email_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cached_messages = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + for filter in std::mem::take(&mut request.filter) { + match filter { + Filter::Property(cond) => match cond { + EmailFilter::Text(text) => { + let (text, language) = + Language::detect(text, self.core.email.default_language); + + filters.push(SearchFilter::Or); + filters.push(SearchFilter::has_text( + EmailSearchField::From, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::To, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Cc, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Bcc, + &text, + Language::None, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Subject, + &text, + language, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Body, + &text, + language, + )); + filters.push(SearchFilter::has_text( + EmailSearchField::Attachment, + text, + language, + )); + filters.push(SearchFilter::End); + } + EmailFilter::From(text) => filters.push(SearchFilter::has_text( + EmailSearchField::From, + text, + Language::None, + )), + EmailFilter::To(text) => filters.push(SearchFilter::has_text( + EmailSearchField::To, + text, + Language::None, + )), + EmailFilter::Cc(text) => filters.push(SearchFilter::has_text( + EmailSearchField::Cc, + text, + Language::None, + )), + EmailFilter::Bcc(text) => filters.push(SearchFilter::has_text( + EmailSearchField::Bcc, + text, + Language::None, + )), + EmailFilter::Subject(text) => filters.push(SearchFilter::has_text_detect( + EmailSearchField::Subject, + text, + self.core.email.default_language, + )), + EmailFilter::Body(text) => filters.push(SearchFilter::has_text_detect( + EmailSearchField::Body, + text, + self.core.email.default_language, + )), + EmailFilter::Header(header) => { + let mut header = header.into_iter(); + let header_name = header.next().ok_or_else(|| { + trc::JmapEvent::InvalidArguments + .into_err() + .details("Header name is missing.".to_string()) + })?; + + if let Some(header_name) = HeaderName::parse(header_name) { + let value = header.next(); + let op = if matches!( + header_name, + HeaderName::MessageId + | HeaderName::InReplyTo + | HeaderName::References + | HeaderName::ResentMessageId + ) || value.is_none() + { + SearchOperator::Equal + } else { + SearchOperator::Contains + }; + + filters.push(SearchFilter::cond( + EmailSearchField::Headers, + op, + SearchValue::KeyValues(VecMap::with_capacity(1).with_append( + header_name.as_str().to_lowercase(), + value.unwrap_or_default(), + )), + )); + } + } + EmailFilter::InMailbox(mailbox) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cached_messages + .in_mailbox(mailbox.document_id()) + .map(|item| item.document_id), + ))) + } + EmailFilter::InMailboxOtherThan(mailboxes) => { + let mailboxes = mailboxes + .into_iter() + .map(|m| m.document_id()) + .collect::>(); + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cached_messages.emails.items.iter().filter_map(|item| { + if item + .mailboxes + .iter() + .any(|mb| !mailboxes.contains(&mb.mailbox_id)) + { + Some(item.document_id) + } else { + None + } + }), + ))); + } + EmailFilter::Before(date) => filters.push(SearchFilter::lt( + EmailSearchField::ReceivedAt, + date.timestamp(), + )), + EmailFilter::After(date) => filters.push(SearchFilter::gt( + EmailSearchField::ReceivedAt, + date.timestamp(), + )), + EmailFilter::MinSize(size) => { + filters.push(SearchFilter::ge(EmailSearchField::Size, size)) + } + EmailFilter::MaxSize(size) => { + filters.push(SearchFilter::lt(EmailSearchField::Size, size)) + } + EmailFilter::AllInThreadHaveKeyword(keyword) => filters.push( + SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, true)), + ), + EmailFilter::SomeInThreadHaveKeyword(keyword) => filters.push( + SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, false)), + ), + EmailFilter::NoneInThreadHaveKeyword(keyword) => { + filters.push(SearchFilter::Not); + filters.push(SearchFilter::is_in_set(thread_keywords( + &cached_messages, + keyword, + false, + ))); + filters.push(SearchFilter::End); + } + EmailFilter::HasKeyword(keyword) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cached_messages + .with_keyword(&keyword) + .map(|item| item.document_id), + ))); + } + EmailFilter::NotKeyword(keyword) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cached_messages + .without_keyword(&keyword) + .map(|item| item.document_id), + ))); + } + EmailFilter::HasAttachment(has_attach) => { + filters.push(SearchFilter::eq( + EmailSearchField::HasAttachment, + has_attach, + )); + } + + // Non-standard + EmailFilter::Id(ids) => { + let mut set = RoaringBitmap::new(); + for id in ids { + set.insert(id.document_id()); + } + filters.push(SearchFilter::is_in_set(set)); + } + EmailFilter::SentBefore(date) => { + filters.push(SearchFilter::lt(EmailSearchField::SentAt, date.timestamp())) + } + EmailFilter::SentAfter(date) => { + filters.push(SearchFilter::gt(EmailSearchField::SentAt, date.timestamp())) + } + EmailFilter::InThread(id) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cached_messages + .in_thread(id.document_id()) + .map(|item| item.document_id), + ))) + } + other => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(other.to_string())); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + // Parse sort criteria + let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); + for comparator in request + .sort + .take() + .filter(|s| !s.is_empty()) + .unwrap_or_default() + { + comparators.push(match comparator.property { + EmailComparator::ReceivedAt => { + SearchComparator::field(EmailSearchField::ReceivedAt, comparator.is_ascending) + } + EmailComparator::Size => { + SearchComparator::field(EmailSearchField::Size, comparator.is_ascending) + } + EmailComparator::From => { + SearchComparator::field(EmailSearchField::From, comparator.is_ascending) + } + EmailComparator::To => { + SearchComparator::field(EmailSearchField::To, comparator.is_ascending) + } + EmailComparator::Subject => { + SearchComparator::field(EmailSearchField::Subject, comparator.is_ascending) + } + EmailComparator::SentAt => { + SearchComparator::field(EmailSearchField::SentAt, comparator.is_ascending) + } + EmailComparator::HasKeyword(keyword) => SearchComparator::set( + RoaringBitmap::from_iter( + cached_messages + .with_keyword(&keyword) + .map(|item| item.document_id), + ), + comparator.is_ascending, + ), + EmailComparator::AllInThreadHaveKeyword(keyword) => SearchComparator::set( + thread_keywords(&cached_messages, keyword, true), + comparator.is_ascending, + ), + EmailComparator::SomeInThreadHaveKeyword(keyword) => SearchComparator::set( + thread_keywords(&cached_messages, keyword, false), + comparator.is_ascending, + ), + // Non-standard + EmailComparator::Cc => { + SearchComparator::field(EmailSearchField::Cc, comparator.is_ascending) + } + + other => { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(other.to_string())); + } + }); + } + + let results = self + .search_store() + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(filters) + .with_comparators(comparators) + .with_account_id(account_id) + .with_mask(if access_token.is_shared(account_id) { + cached_messages.shared_messages(access_token, Acl::ReadItems) + } else { + cached_messages + .emails + .items + .iter() + .map(|item| item.document_id) + .collect() + }), + ) + .await?; + + let collapse_threads = request.arguments.collapse_threads.unwrap_or(false); + let total_results = if collapse_threads { + let mut seen_thread_ids = AHashSet::new(); + results + .iter() + .filter_map(|document_id| { + cached_messages + .email_by_id(document_id) + .map(|email| email.thread_id) + }) + .filter(|thread_id| seen_thread_ids.insert(*thread_id)) + .count() + } else { + results.len() + }; + + let mut response = QueryResponseBuilder::new( + total_results, + self.core.jmap.query_max_results, + cached_messages.get_state(false), + &request, + ); + + if !results.is_empty() { + let mut seen_thread_ids = AHashSet::new(); + + for document_id in results { + let Some(thread_id) = cached_messages + .email_by_id(&document_id) + .map(|email| email.thread_id) + else { + continue; + }; + if collapse_threads && !seen_thread_ids.insert(thread_id) { + continue; + } + + if !response.add(thread_id, document_id) { + break; + } + } + } + + response.build() + } +} + +fn thread_keywords(cache: &MessageStoreCache, keyword: Keyword, match_all: bool) -> RoaringBitmap { + let keyword_doc_ids = + RoaringBitmap::from_iter(cache.with_keyword(&keyword).map(|item| item.document_id)); + if keyword_doc_ids.is_empty() { + return keyword_doc_ids; + } + let mut not_matched_ids = RoaringBitmap::new(); + let mut matched_ids = RoaringBitmap::new(); + + let mut thread_map: AHashMap = AHashMap::new(); + + for item in &cache.emails.items { + thread_map + .entry(item.thread_id) + .or_default() + .insert(item.document_id); + } + + for item in &cache.emails.items { + let keyword_doc_id = item.document_id; + if !keyword_doc_ids.contains(keyword_doc_id) + || matched_ids.contains(keyword_doc_id) + || not_matched_ids.contains(keyword_doc_id) + { + continue; + } + + if let Some(thread_doc_ids) = thread_map.get(&item.thread_id) { + let mut thread_tag_intersection = thread_doc_ids.clone(); + thread_tag_intersection &= &keyword_doc_ids; + + if (match_all && &thread_tag_intersection == thread_doc_ids) + || (!match_all && !thread_tag_intersection.is_empty()) + { + matched_ids |= thread_doc_ids; + } else if !thread_tag_intersection.is_empty() { + not_matched_ids |= &thread_tag_intersection; + } + } + } + + matched_ids +} diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs new file mode 100644 index 0000000..7120b70 --- /dev/null +++ b/crates/jmap/src/email/set.rs @@ -0,0 +1,1233 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + blob::download::BlobDownload, + changes::state::JmapCacheState, + email::{PatchResult, handle_email_patch, ingested_into_object}, +}; +use common::{ + Server, auth::AccessToken, ipc::PushNotification, storage::index::ObjectIndexBuilder, +}; +use email::message::headers::{BuildHeader, ValueToHeader}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, + mailbox::{JUNK_ID, TRASH_ID, UidMailbox}, + message::{ + delete::EmailDeletion, + ingest::{EmailIngest, IngestEmail, IngestSource}, + metadata::MessageData, + }, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::email::{Email, EmailProperty, EmailValue}, + references::resolve::ResolveCreatedReference, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{Key, Value}; +use mail_builder::{ + MessageBuilder, + headers::{ + HeaderType, address::Address, content_type::ContentType, date::Date, message_id::MessageId, + raw::Raw, text::Text, + }, + mime::{BodyPart, MimePart}, +}; +use mail_parser::MessageParser; +use std::future::Future; +use std::{borrow::Cow, collections::HashMap}; +use store::{ + ValueKey, + ahash::AHashMap, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection, VanishedCollection}, + id::Id, + keyword::{ArchivedKeyword, Keyword}, + type_state::{DataType, StateChange}, +}; + +pub trait EmailSet: Sync + Send { + fn email_set( + &self, + request: SetRequest<'_, Email>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl EmailSet for Server { + async fn email_set( + &self, + mut request: SetRequest<'_, Email>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> trc::Result> { + // Prepare response + let account_id = request.account_id.document_id(); + let cache = self.get_cached_messages(account_id).await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(false, &request.if_in_state)?); + + // Obtain mailboxIds + let (can_add_mailbox_ids, can_delete_mailbox_ids, can_modify_mailbox_ids) = + if access_token.is_shared(account_id) { + ( + cache.shared_mailboxes(access_token, Acl::AddItems).into(), + cache + .shared_mailboxes(access_token, Acl::RemoveItems) + .into(), + cache + .shared_mailboxes(access_token, Acl::ModifyItems) + .into(), + ) + } else { + (None, None, None) + }; + + // Obtain import access token + let import_access_token = if account_id != access_token.account_id() { + #[cfg(feature = "test_mode")] + { + AccessToken::from_id_maybe_invalid(account_id).into() + } + + #[cfg(not(feature = "test_mode"))] + { + use common::auth::BuildAccessToken; + self.access_token(account_id) + .await + .caused_by(trc::location!())? + .build() + .into() + } + } else { + None + }; + + let mut last_change_id = None; + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + + // Process creates + 'create: for (id, object) in request.unwrap_create() { + let Value::Object(mut object) = object else { + continue; + }; + + let has_body_structure = + object.contains_key(&Key::Property(EmailProperty::BodyStructure)); + let mut builder = MessageBuilder::new(); + let mut mailboxes = Vec::new(); + let mut keywords = Vec::new(); + let mut received_at = None; + + // Parse body values + let body_values = object + .remove(&Key::Property(EmailProperty::BodyValues)) + .and_then(|obj| obj.into_object()) + .and_then(|obj| { + let mut values = HashMap::with_capacity(obj.len()); + for (key, value) in obj.into_vec() { + let id = key.into_string(); + if let Value::Object(mut bv) = value { + values.insert( + id, + bv.remove(&Key::Property(EmailProperty::Value))? + .into_string()?, + ); + } else { + return None; + } + } + Some(values) + }); + let mut size_attachments = 0; + + // Parse properties + for (property, mut value) in object.into_vec() { + if let Err(err) = response.resolve_self_references(&mut value, 0, false) { + response.not_created.append(id, err); + continue 'create; + }; + let Key::Property(property) = property else { + response.invalid_property_create(id, property.into_owned()); + continue 'create; + }; + + match (property, value) { + (EmailProperty::MailboxIds, Value::Object(ids)) => { + mailboxes = ids + .into_expanded_boolean_set() + .filter_map(|id| { + id.try_into_property()?.try_into_id()?.document_id().into() + }) + .collect(); + } + (EmailProperty::Keywords, Value::Object(keywords_)) => { + keywords = keywords_ + .into_expanded_boolean_set() + .filter_map(|id| id.try_into_property()?.try_into_keyword()) + .collect(); + } + (EmailProperty::Pointer(pointer), value) => { + match handle_email_patch(&pointer, value) { + PatchResult::SetKeyword(keyword) => { + if !keywords.contains(keyword) { + keywords.push(keyword.clone()); + } + } + PatchResult::RemoveKeyword(keyword) => { + keywords.retain(|k| k != keyword); + } + PatchResult::AddMailbox(id) => { + if !mailboxes.contains(&id) { + mailboxes.push(id); + } + } + PatchResult::RemoveMailbox(id) => { + mailboxes.retain(|mid| mid != &id); + } + PatchResult::Invalid(set_error) => { + response.not_created.append(id, set_error); + continue 'create; + } + } + } + ( + header @ (EmailProperty::MessageId + | EmailProperty::InReplyTo + | EmailProperty::References), + Value::Array(values), + ) => { + builder = builder.header( + header.as_rfc_header(), + MessageId { + id: values + .into_iter() + .filter_map(|value| value.into_string()) + .collect(), + }, + ); + } + + ( + header @ (EmailProperty::Sender + | EmailProperty::From + | EmailProperty::To + | EmailProperty::Cc + | EmailProperty::Bcc + | EmailProperty::ReplyTo), + value, + ) => { + if let Some(addresses) = value.try_into_address_list() { + builder = + builder.header(header.as_rfc_header(), Address::List(addresses)); + } else { + response.invalid_property_create(id, header); + continue 'create; + } + } + (EmailProperty::Subject, Value::Str(value)) => { + builder = builder.subject(value); + } + + (EmailProperty::ReceivedAt, Value::Element(EmailValue::Date(value))) => { + received_at = (value.timestamp() as u64).into(); + } + + (EmailProperty::SentAt, Value::Element(EmailValue::Date(value))) => { + builder = builder.date(Date::new(value.timestamp())); + } + + ( + property @ (EmailProperty::TextBody + | EmailProperty::HtmlBody + | EmailProperty::Attachments + | EmailProperty::BodyStructure), + value, + ) => { + // Validate request + let (values, expected_content_type) = match property { + EmailProperty::BodyStructure => (vec![value], None), + EmailProperty::TextBody | EmailProperty::HtmlBody + if !has_body_structure => + { + let values = value.into_array().unwrap_or_default(); + if values.len() <= 1 { + ( + values, + Some(match property { + EmailProperty::TextBody => "text/plain", + EmailProperty::HtmlBody => "text/html", + _ => unreachable!(), + }), + ) + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(property) + .with_description("Only one part is allowed."), + ); + continue 'create; + } + } + EmailProperty::Attachments if !has_body_structure => { + (value.into_array().unwrap_or_default(), None) + } + _ => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_properties([property, EmailProperty::BodyStructure]) + .with_description( + "Cannot set both properties on a same request.", + ), + ); + continue 'create; + } + }; + + // Iterate parts + let mut values_stack = Vec::new(); + let mut values = values.into_iter(); + let mut parts = Vec::new(); + loop { + while let Some(value) = values.next() { + let mut blob_id = None; + let mut part_id = None; + let mut content_type = None; + let mut content_disposition = None; + let mut name = None; + let mut charset = None; + let mut subparts = None; + let mut has_size = false; + let mut headers: Vec<(Cow, HeaderType)> = Vec::new(); + + if let Some(obj) = value.into_object() { + for (body_property, value) in obj.into_vec() { + let Key::Property(body_property) = body_property else { + continue; + }; + + match (body_property, value) { + (EmailProperty::Type, Value::Str(value)) => { + content_type = value.into_owned().into(); + } + (EmailProperty::PartId, Value::Str(value)) => { + part_id = value.into_owned().into(); + } + ( + EmailProperty::BlobId, + Value::Element(EmailValue::BlobId(value)), + ) => { + blob_id = value.into(); + } + (EmailProperty::Disposition, Value::Str(value)) => { + content_disposition = value.into_owned().into(); + } + (EmailProperty::Name, Value::Str(value)) => { + name = value.into_owned().into(); + } + (EmailProperty::Charset, Value::Str(value)) => { + charset = value.into_owned().into(); + } + (EmailProperty::Language, Value::Array(values)) => { + headers.push(( + "Content-Language".into(), + Text::new( + values + .into_iter() + .filter_map(|v| v.into_string()) + .fold( + String::with_capacity(64), + |mut h, v| { + if !h.is_empty() { + h.push_str(", "); + } + h.push_str(&v); + h + }, + ), + ) + .into(), + )); + } + (EmailProperty::Cid, Value::Str(value)) => { + headers.push(( + "Content-ID".into(), + MessageId::new(value).into(), + )); + } + (EmailProperty::Location, Value::Str(value)) => { + headers.push(( + "Content-Location".into(), + Text::new(value).into(), + )); + } + (EmailProperty::Header(header), Value::Str(value)) + if !header.header.eq_ignore_ascii_case( + "content-transfer-encoding", + ) => + { + headers.push(( + header.header.into(), + Raw::from(value).into(), + )); + } + ( + EmailProperty::Header(header), + Value::Array(values), + ) if !header.header.eq_ignore_ascii_case( + "content-transfer-encoding", + ) => + { + for value in values { + if let Some(value) = value.into_string() { + headers.push(( + header.header.clone().into(), + Raw::from(value).into(), + )); + } + } + } + (EmailProperty::Headers, _) => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(( + property, + EmailProperty::Headers, + )) + .with_description( + "Headers have to be set individually.", + ), + ); + continue 'create; + } + (EmailProperty::Size, _) => { + has_size = true; + } + (EmailProperty::SubParts, Value::Array(values)) => { + subparts = values.into(); + } + (body_property, value) if value != Value::Null => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, body_property)) + .with_description("Cannot set property."), + ); + continue 'create; + } + _ => {} + } + } + } + + // Validate content-type + let content_type = + content_type.unwrap_or_else(|| "text/plain".to_string()); + let is_multipart = content_type.starts_with("multipart/"); + if is_multipart { + if !matches!(property, EmailProperty::BodyStructure) { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, EmailProperty::Type)) + .with_description("Multiparts can only be set with bodyStructure."), + ); + continue 'create; + } + } else if expected_content_type + .as_ref() + .is_some_and(|v| v != &content_type) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, EmailProperty::Type)) + .with_description(format!( + "Expected one body part of type \"{}\"", + expected_content_type.unwrap() + )), + ); + continue 'create; + } + + // Validate partId/blobId + match (blob_id.is_some(), part_id.is_some()) { + (true, true) if !is_multipart => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_properties([(property.clone(), EmailProperty::BlobId), (property, EmailProperty::PartId)]) + .with_description( + "Cannot specify both \"partId\" and \"blobId\".", + ), + ); + continue 'create; + } + (false, false) if !is_multipart => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_description("Expected a \"partId\" or \"blobId\" field in body part."), + ); + continue 'create; + } + (false, true) if !is_multipart && has_size => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, EmailProperty::Size)) + .with_description( + "Cannot specify \"size\" when providing a \"partId\".", + ), + ); + continue 'create; + } + (true, _) | (_, true) if is_multipart => { + response.not_created.append( + id, + SetError::invalid_properties() + .with_properties([(property.clone(), EmailProperty::BlobId), (property, EmailProperty::PartId)]) + .with_description( + "Cannot specify \"partId\" or \"blobId\" in multipart body parts.", + ), + ); + continue 'create; + } + _ => (), + } + + // Set Content-Type and Content-Disposition + let mut content_type = ContentType::new(content_type); + if !is_multipart { + if let Some(charset) = charset { + if part_id.is_none() { + content_type + .attributes + .push(("charset".into(), charset.into())); + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, EmailProperty::Charset)) + .with_description( + "Cannot specify a character set when providing a \"partId\".", + ), + ); + continue 'create; + } + } else if part_id.is_some() { + content_type + .attributes + .push(("charset".into(), "utf-8".into())); + } + match (content_disposition, name) { + (Some(disposition), Some(filename)) => { + headers.push(( + "Content-Disposition".into(), + ContentType::new(disposition) + .attribute("filename", filename) + .into(), + )); + } + (Some(disposition), None) => { + headers.push(( + "Content-Disposition".into(), + ContentType::new(disposition).into(), + )); + } + (None, Some(filename)) => { + content_type + .attributes + .push(("name".into(), filename.into())); + } + (None, None) => (), + }; + } + headers.push(("Content-Type".into(), content_type.into())); + + // In test, sort headers to avoid randomness + #[cfg(feature = "test_mode")] + { + headers.sort_unstable_by(|a, b| match a.0.cmp(&b.0) { + std::cmp::Ordering::Equal => a.1.cmp(&b.1), + ord => ord, + }); + } + // Retrieve contents + parts.push(MimePart { + headers, + contents: if !is_multipart { + if let Some(blob_id) = blob_id { + match self.blob_download(&blob_id, access_token).await? { + Some(contents) => { + BodyPart::Binary(contents.into()) + } + None => { + response.not_created.append( + id, + SetError::new(SetErrorType::BlobNotFound).with_description( + format!("blobId {blob_id} does not exist on this server.") + ), + ); + continue 'create; + } + } + } else if let Some(part_id) = part_id { + if let Some(contents) = + body_values.as_ref().and_then(|bv| bv.get(&part_id)) + { + BodyPart::Text(contents.as_ref().into()) + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property((property, EmailProperty::PartId)) + .with_description(format!( + "Missing body value for partId {part_id:?}" + )), + ); + continue 'create; + } + } else { + unreachable!() + } + } else { + BodyPart::Multipart(vec![]) + }, + }); + + // Check attachment sizes + if !is_multipart { + size_attachments += parts.last().unwrap().size(); + if self.core.email.mail_attachments_max_size > 0 + && size_attachments + > self.core.email.mail_attachments_max_size + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(property) + .with_description(format!( + "Message exceeds maximum size of {} bytes.", + self.core.email.mail_attachments_max_size + )), + ); + continue 'create; + } + } else if let Some(subparts) = subparts { + values_stack.push((values, parts)); + parts = Vec::with_capacity(subparts.len()); + values = subparts.into_iter(); + continue; + } + } + + if let Some((prev_values, mut prev_parts)) = values_stack.pop() { + values = prev_values; + prev_parts.last_mut().unwrap().contents = + BodyPart::Multipart(parts); + parts = prev_parts; + } else { + break; + } + } + + match property { + EmailProperty::TextBody => { + builder.text_body = parts.pop(); + } + EmailProperty::HtmlBody => { + builder.html_body = parts.pop(); + } + EmailProperty::Attachments => { + builder.attachments = parts.into(); + } + _ => { + builder.body = parts.pop(); + } + } + } + + (EmailProperty::Header(header), value) => { + match builder.build_header(header, value) { + Ok(builder_) => { + builder = builder_; + } + Err(header) => { + response.invalid_property_create(id, EmailProperty::Header(header)); + continue 'create; + } + } + } + + (_, Value::Null) => (), + + (property, _) => { + response.invalid_property_create(id, property); + continue 'create; + } + } + } + + // Make sure message belongs to at least one mailbox + if mailboxes.is_empty() { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description("Message has to belong to at least one mailbox."), + ); + continue 'create; + } + + // Verify that the mailboxIds are valid + for mailbox_id in &mailboxes { + if !cache.has_mailbox_id(mailbox_id) { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description(format!( + "mailboxId {} does not exist.", + Id::from(*mailbox_id) + )), + ); + continue 'create; + } else if can_add_mailbox_ids + .as_ref() + .is_some_and(|ids| !ids.contains(*mailbox_id)) + { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {}.", + Id::from(*mailbox_id) + )), + ); + continue 'create; + } + } + + // Make sure the message is not empty + if builder.headers.is_empty() + && builder.body.is_none() + && builder.html_body.is_none() + && builder.text_body.is_none() + && builder.attachments.is_none() + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_description("Message has to have at least one header or body part."), + ); + continue 'create; + } + + match builder + .headers + .iter() + .position(|(name, _)| name.eq_ignore_ascii_case("Message-ID")) + { + Some(pos) => { + builder.headers[pos].0 = Cow::Borrowed("Message-ID"); + } + None => { + builder = builder.message_id(self.core.network.message_id()); + } + } + + // In test, sort headers to avoid randomness + #[cfg(feature = "test_mode")] + { + builder + .headers + .sort_unstable_by(|a, b| match a.0.cmp(&b.0) { + std::cmp::Ordering::Equal => a.1.cmp(&b.1), + ord => ord, + }); + } + + // Build message + let mut raw_message = Vec::with_capacity((4 * size_attachments / 3) + 1024); + builder.write_to(&mut raw_message).unwrap_or_default(); + + // Ingest message + match self + .email_ingest(IngestEmail { + raw_message: &raw_message, + message: MessageParser::new().parse(&raw_message), + blob_hash: None, + access_token: import_access_token.as_ref().unwrap_or(access_token), + mailbox_ids: mailboxes, + keywords, + received_at, + source: IngestSource::Jmap { + train_classifier: true, + }, + session_id: session.session_id, + }) + .await + { + Ok(message) => { + last_change_id = message.change_id.into(); + response + .created + .insert(id, ingested_into_object(message).into()); + } + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota) + .with_description("You have exceeded your disk quota."), + ); + } + Err(err) => return Err(err), + } + } + + // Process updates + let mut batch = BatchBuilder::new(); + let mut changed_mailboxes: AHashMap> = AHashMap::new(); + let mut will_update = Vec::with_capacity(request.update.as_ref().map_or(0, |u| u.len())); + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain message data + let document_id = id.document_id(); + let data_ = match self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + document_id, + )) + .await? + { + Some(data) => data, + None => { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + } + }; + let data = data_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_data = data.inner.to_builder(); + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response.resolve_self_references(&mut value, 0, false) { + response.not_updated.append(id, err); + continue 'update; + }; + + match (property, value) { + (Key::Property(EmailProperty::MailboxIds), Value::Object(ids)) => { + new_data.set_mailboxes( + ids.into_expanded_boolean_set() + .filter_map(|id| { + UidMailbox::new_unassigned( + id.try_into_property()?.try_into_id()?.document_id(), + ) + .into() + }) + .collect(), + ); + } + (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + new_data.set_keywords( + keywords_ + .into_expanded_boolean_set() + .filter_map(|keyword| { + keyword.try_into_property()?.try_into_keyword() + }) + .collect(), + ); + } + (Key::Property(EmailProperty::Pointer(pointer)), value) => { + match handle_email_patch(&pointer, value) { + PatchResult::SetKeyword(keyword) => { + new_data.add_keyword(keyword.clone()); + } + PatchResult::RemoveKeyword(keyword) => { + new_data.remove_keyword(keyword); + } + PatchResult::AddMailbox(id) => { + new_data.add_mailbox(UidMailbox::new_unassigned(id)); + } + PatchResult::RemoveMailbox(id) => { + new_data.remove_mailbox(id); + } + PatchResult::Invalid(set_error) => { + response.not_updated.append(id, set_error); + continue 'update; + } + } + } + (Key::Property(EmailProperty::Id), value) => { + if !crate::matches_id(&value, id) { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::Id) + .with_description("The id property is immutable."), + ); + continue 'update; + } + } + (property, _) => { + response.invalid_property_update(id, property.into_owned()); + continue 'update; + } + } + } + + let has_keyword_changes = new_data.has_keyword_changes(data.inner); + let has_mailbox_changes = new_data.has_mailbox_changes(data.inner); + if !has_keyword_changes && !has_mailbox_changes { + response.updated.append(id, None); + continue 'update; + } + + // Process keywords + let mut train_spam = None; + if has_keyword_changes { + // Verify permissions on shared accounts + if can_modify_mailbox_ids.as_ref().is_some_and(|ids| { + !new_data + .mailboxes + .iter() + .any(|mb| ids.contains(mb.mailbox_id)) + }) { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify keywords."), + ); + continue 'update; + } + + // Process keyword changes + let mut changed_seen = false; + for keyword in new_data.added_keywords(data.inner) { + match keyword { + Keyword::Seen => { + changed_seen = true; + } + Keyword::Junk => { + train_spam = Some(true); + } + Keyword::NotJunk => { + train_spam = Some(false); + } + _ => {} + } + } + for keyword in new_data.removed_keywords(data.inner) { + match keyword { + ArchivedKeyword::Seen => { + changed_seen = true; + } + ArchivedKeyword::Junk if train_spam.is_none() => { + train_spam = Some(false); + } + _ => {} + } + } + + // Set all current mailboxes as changed if the Seen tag changed + if changed_seen { + for mailbox_id in new_data.mailboxes.iter() { + changed_mailboxes.insert(mailbox_id.mailbox_id, Vec::new()); + } + } + } + + // Process mailboxes + if has_mailbox_changes { + // Make sure the message is at least in one mailbox + if new_data.mailboxes.is_empty() { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description("Message has to belong to at least one mailbox."), + ); + continue 'update; + } + + // Make sure all new mailboxIds are valid + for mailbox_id in new_data.added_mailboxes(data.inner) { + if cache.has_mailbox_id(&mailbox_id.mailbox_id) { + // Verify permissions on shared accounts + if can_add_mailbox_ids + .as_ref() + .is_none_or(|ids| ids.contains(mailbox_id.mailbox_id)) + { + if mailbox_id.mailbox_id == JUNK_ID { + train_spam = Some(true); + } + + changed_mailboxes.insert(mailbox_id.mailbox_id, Vec::new()); + } else { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to add messages to mailbox {}.", + Id::from(mailbox_id.mailbox_id) + )), + ); + continue 'update; + } + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(EmailProperty::MailboxIds) + .with_description(format!( + "mailboxId {} does not exist.", + Id::from(mailbox_id.mailbox_id) + )), + ); + continue 'update; + } + } + + // Add all removed mailboxes to change list + for mailbox_id in new_data.removed_mailboxes(data.inner) { + // Verify permissions on shared accounts + if can_delete_mailbox_ids + .as_ref() + .is_none_or(|ids| ids.contains(u32::from(mailbox_id.mailbox_id))) + { + if mailbox_id.mailbox_id == JUNK_ID + && !new_data + .mailboxes + .iter() + .any(|mb| mb.mailbox_id == TRASH_ID) + { + train_spam = Some(false); + } + + changed_mailboxes + .entry(mailbox_id.mailbox_id.to_native()) + .or_default() + .push(mailbox_id.uid.to_native()); + } else { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You are not allowed to delete messages from mailbox {}.", + mailbox_id.mailbox_id + )), + ); + continue 'update; + } + } + + // Obtain IMAP UIDs for added mailboxes + let ids = self + .assign_email_ids( + account_id, + new_data + .mailboxes + .iter() + .filter(|m| m.uid == 0) + .map(|m| m.mailbox_id), + false, + ) + .await + .caused_by(trc::location!())?; + for (uid_mailbox, uid) in new_data + .mailboxes + .iter_mut() + .filter(|m| m.uid == 0) + .zip(ids) + { + uid_mailbox.uid = uid; + } + } + + // Write changes + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data.seal()), + ) + .caused_by(trc::location!())?; + + if let Some(train_spam) = train_spam { + self.add_account_spam_sample( + &mut batch, + account_id, + document_id, + train_spam, + session.session_id, + ) + .await + .caused_by(trc::location!())?; + } + + batch.commit_point(); + will_update.push(id); + } + + if !batch.is_empty() { + // Log mailbox changes + for (parent_id, deleted_uids) in changed_mailboxes { + batch.log_container_property_change(SyncCollection::Email, parent_id); + for deleted_uid in deleted_uids { + batch.log_vanished_item(VanishedCollection::Email, (parent_id, deleted_uid)); + } + } + + match self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + { + Ok(change_id) => { + last_change_id = change_id.into(); + + // Add to updated list + for id in will_update { + response.updated.append(id, None); + } + } + Err(err) if err.is_assertion_failure() => { + for id in will_update { + response.not_updated.append( + id, + SetError::forbidden().with_description( + "Another process modified this message, please try again.", + ), + ); + } + } + Err(err) => { + return Err(err.caused_by(trc::location!())); + } + } + } + + // Process deletions + if !will_destroy.is_empty() { + let email_ids = cache.email_document_ids(); + let can_destroy_message_ids = if access_token.is_shared(account_id) { + cache.shared_messages(access_token, Acl::RemoveItems).into() + } else { + None + }; + let mut destroy_ids = RoaringBitmap::new(); + for destroy_id in will_destroy { + let document_id = destroy_id.document_id(); + + if email_ids.contains(document_id) { + if !matches!(&can_destroy_message_ids, Some(ids) if !ids.contains(document_id)) + { + destroy_ids.insert(document_id); + response.destroyed.push(destroy_id); + } else { + response.not_destroyed.append( + destroy_id, + SetError::forbidden() + .with_description("You are not allowed to delete this message."), + ); + } + } else { + response + .not_destroyed + .append(destroy_id, SetError::not_found()); + } + } + + if !destroy_ids.is_empty() { + // Batch delete messages + let mut batch = BatchBuilder::new(); + let not_destroyed = self + .emails_delete( + account_id, + access_token.tenant_id(), + &mut batch, + destroy_ids, + ) + .await?; + if !batch.is_empty() { + last_change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())? + .into(); + self.notify_task_queue(); + } + + // Mark messages that were not found as not destroyed (this should not occur in practice) + if !not_destroyed.is_empty() { + let mut destroyed = Vec::with_capacity(response.destroyed.len()); + + for destroy_id in response.destroyed { + if not_destroyed.contains(destroy_id.document_id()) { + response + .not_destroyed + .append(destroy_id, SetError::not_found()); + } else { + destroyed.push(destroy_id); + } + } + + response.destroyed = destroyed; + } + } + } + + // Update state + if let Some(change_id) = last_change_id { + if response.updated.is_empty() && response.destroyed.is_empty() { + // Message ingest does not broadcast state changes + self.broadcast_push_notification(PushNotification::StateChange( + StateChange::new(account_id) + .with_change_id(change_id) + .with_change(DataType::Email) + .with_change(DataType::Mailbox) + .with_change(DataType::Thread), + )) + .await; + } + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/email/snippet.rs b/crates/jmap/src/email/snippet.rs new file mode 100644 index 0000000..0127abe --- /dev/null +++ b/crates/jmap/src/email/snippet.rs @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::metadata::{ + ArchivedMetadataPartType, DecodedPartContent, MessageMetadata, MetadataHeaderName, + }, +}; +use jmap_proto::{ + method::{ + query::Filter, + search_snippet::{GetSearchSnippetRequest, GetSearchSnippetResponse, SearchSnippet}, + }, + object::email::EmailFilter, + request::MaybeInvalid, +}; +use mail_parser::decoders::html::html_to_text; +use nlp::language::{Language, search_snippet::generate_snippet, stemmer::Stemmer}; +use std::future::Future; +use store::{ + ValueKey, + backend::MAX_TOKEN_LENGTH, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{acl::Acl, collection::Collection, field::EmailField}; +use utils::chained_bytes::ChainedBytes; + +pub trait EmailSearchSnippet: Sync + Send { + fn email_search_snippet( + &self, + request: GetSearchSnippetRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl EmailSearchSnippet for Server { + async fn email_search_snippet( + &self, + request: GetSearchSnippetRequest, + access_token: &AccessToken, + ) -> trc::Result { + let mut filter_stack = vec![]; + let mut include_term = true; + let mut terms = vec![]; + let mut is_exact = false; + let mut language = self.core.email.default_language; + + for cond in request.filter { + match cond { + Filter::Property(cond) => { + if let EmailFilter::Text(text) + | EmailFilter::Subject(text) + | EmailFilter::Body(text) = cond + && include_term + { + let (text, language_) = + Language::detect(text, self.core.email.default_language); + language = language_; + if (text.starts_with('"') && text.ends_with('"')) + || (text.starts_with('\'') && text.ends_with('\'')) + { + for token in language.tokenize_text(&text, MAX_TOKEN_LENGTH) { + terms.push(token.word.into_owned()); + } + is_exact = true; + } else { + for token in Stemmer::new(&text, language, MAX_TOKEN_LENGTH) { + terms.push(token.word.into_owned()); + if let Some(stemmed_word) = token.stemmed_word { + terms.push(stemmed_word.into_owned()); + } + } + } + } + } + Filter::And | Filter::Or => { + filter_stack.push(cond); + } + Filter::Not => { + filter_stack.push(cond); + include_term = !include_term; + } + Filter::Close => { + if matches!(filter_stack.pop(), Some(Filter::Not)) { + include_term = !include_term; + } + } + } + } + let account_id = request.account_id.document_id(); + let cached_messages = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + let document_ids = if access_token.is_member(account_id) { + cached_messages.email_document_ids() + } else { + cached_messages.shared_messages(access_token, Acl::ReadItems) + }; + + let email_ids = request.email_ids.unwrap(); + let mut response = GetSearchSnippetResponse { + account_id: request.account_id, + list: Vec::with_capacity(email_ids.len()), + not_found: None, + }; + let mut not_found = Vec::new(); + + if email_ids.len() > self.core.jmap.snippet_max_results { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + + for email_id in email_ids { + let email_id = match email_id { + MaybeInvalid::Value(email_id) => email_id, + invalid => { + not_found.push(invalid); + continue; + } + }; + let document_id = email_id.document_id(); + let mut snippet = SearchSnippet { + email_id, + subject: None, + preview: None, + }; + if !document_ids.contains(document_id) { + not_found.push(MaybeInvalid::Value(email_id)); + continue; + } else if terms.is_empty() { + response.list.push(snippet); + continue; + } + let metadata_ = match self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await? + { + Some(metadata) => metadata, + None => { + not_found.push(MaybeInvalid::Value(email_id)); + continue; + } + }; + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + + // Add subject snippet + let contents = &metadata.contents[0]; + if let Some(subject) = contents + .root_part() + .header_value(&MetadataHeaderName::Subject) + .and_then(|v| v.as_text()) + .and_then(|v| generate_snippet(v, &terms, language, is_exact)) + { + snippet.subject = subject.into(); + } + + // Download message + let raw_body = if let Some(raw_body) = self + .blob_store() + .get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX) + .await? + { + raw_body + } else { + trc::event!( + Store(trc::StoreEvent::NotFound), + AccountId = account_id, + DocumentId = email_id.document_id(), + Collection = Collection::Email, + BlobId = metadata.blob_hash.0.as_slice(), + Details = "Blob not found.", + CausedBy = trc::location!(), + ); + + not_found.push(MaybeInvalid::Value(email_id)); + continue; + }; + let raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()).with_last( + raw_body + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + + // Find a matching part + 'outer: for part in contents.parts.iter() { + match &part.body { + ArchivedMetadataPartType::Text => { + let text = match part.decode_contents(&raw_message) { + DecodedPartContent::Text(text) => text, + _ => unreachable!(), + }; + + if let Some(body) = generate_snippet(&text, &terms, language, is_exact) { + snippet.preview = body.into(); + break; + } + } + ArchivedMetadataPartType::Html => { + let text = match part.decode_contents(&raw_message) { + DecodedPartContent::Text(html) => html_to_text(&html), + _ => unreachable!(), + }; + + if let Some(body) = generate_snippet(&text, &terms, language, is_exact) { + snippet.preview = body.into(); + break; + } + } + ArchivedMetadataPartType::Message(message) => { + for part in metadata.contents[u16::from(message) as usize].parts.iter() { + if let ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html = + part.body + { + let text = match (part.decode_contents(&raw_message), &part.body) { + ( + DecodedPartContent::Text(text), + ArchivedMetadataPartType::Text, + ) => text, + ( + DecodedPartContent::Text(html), + ArchivedMetadataPartType::Html, + ) => html_to_text(&html).into(), + _ => unreachable!(), + }; + + if let Some(body) = + generate_snippet(&text, &terms, language, is_exact) + { + snippet.preview = body.into(); + break 'outer; + } + } + } + } + _ => (), + } + } + //} + + response.list.push(snippet); + } + + if !not_found.is_empty() { + response.not_found = Some(not_found); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs new file mode 100644 index 0000000..e1dd511 --- /dev/null +++ b/crates/jmap/src/file/copy.rs @@ -0,0 +1,469 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::acl::JmapAcl, + blob::download::BlobDownload, + changes::state::JmapCacheState, + file::set::{ + Collision, NoResolver, fetch_existing_modified, find_sibling_collision, pick_unique_rename, + update_file_node, validate_file_node_hierarchy, + }, +}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::{ + copy::{CopyRequest, CopyResponse}, + set::SetRequest, + }, + object::file_node::{self, FileNodeProperty, OnExists}, + request::{ + Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::MaybeResultReference, + }, + types::state::State, +}; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, +}; +use utils::map::vec_map::VecMap; + +pub trait FileNodeCopy: Sync + Send { + fn file_node_copy<'x>( + &self, + request: CopyRequest<'x, file_node::FileNode>, + access_token: &AccessToken, + next_call: &mut Option>>, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl FileNodeCopy for Server { + async fn file_node_copy<'x>( + &self, + request: CopyRequest<'x, file_node::FileNode>, + access_token: &AccessToken, + next_call: &mut Option>>, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let from_account_id = request.from_account_id.document_id(); + + if account_id == from_account_id { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("From accountId is equal to fromAccountId")); + } + + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let old_state = cache.assert_state(false, &request.if_in_state)?; + let mut response = CopyResponse { + from_account_id: request.from_account_id, + account_id: request.account_id, + new_state: old_state.clone(), + old_state, + created: VecMap::with_capacity(request.create.len()), + not_created: VecMap::new(), + }; + + let from_cache = self + .fetch_dav_resources( + access_token.account_id(), + from_account_id, + SyncCollection::FileNode, + ) + .await + .caused_by(trc::location!())?; + let from_node_ids = if access_token.is_member(from_account_id) { + from_cache + .resources + .iter() + .map(|r| r.document_id) + .collect::() + } else { + let mut readable = + from_cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true); + readable |= from_cache.shared_items(access_token, [Acl::ReadItems], true); + readable + }; + + let is_shared = access_token.is_shared(account_id); + let can_add_to = if is_shared { + Some(cache.shared_containers(access_token, [Acl::AddItems], true)) + } else { + None + }; + let on_exists = request.arguments.on_exists; + let case_insensitive = request + .arguments + .compare_case_insensitively + .unwrap_or(false); + let on_destroy_remove_children = request + .arguments + .on_destroy_remove_children + .unwrap_or(false); + let on_success_delete = request.on_success_destroy_original.unwrap_or(false); + + let mut batch = BatchBuilder::new(); + let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); + let mut implicit_destroys: AHashSet = AHashSet::new(); + let mut created_folders = AHashMap::new(); + let mut destroy_ids = Vec::new(); + + 'create: for (id, create) in request.create.into_valid() { + let from_document_id = id.document_id(); + if !from_node_ids.contains(from_document_id) { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + } + + let Some(source) = self + .store() + .get_value::>(ValueKey::archive( + from_account_id, + Collection::FileNode, + from_document_id, + )) + .await + .caused_by(trc::location!())? + else { + response.not_created.append( + id, + SetError::not_found().with_description(format!( + "Item {} not found in account {}.", + id, response.from_account_id + )), + ); + continue; + }; + + let mut file_node = source + .deserialize::() + .caused_by(trc::location!())?; + // ACLs are account-scoped; do not carry the source account's grants over. + file_node.acls.clear(); + + let has_acl_changes = + match update_file_node(None, create, &mut file_node, true, &NoResolver) { + Ok(result) => { + if let Some(blob_id) = result.blob_id { + let file_details = file_node.file.get_or_insert_default(); + if !self.has_access_blob(&blob_id, access_token).await? { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {blob_id}." + )), + ); + continue 'create; + } else if let Some(blob_contents) = self + .blob_store() + .get_blob(blob_id.hash.as_slice(), 0..usize::MAX) + .await? + { + file_details.size = blob_contents.len() as u32; + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Blob could not be found."), + ); + continue 'create; + } + file_details.blob_hash = blob_id.hash; + } + + if file_node + .file + .as_ref() + .is_some_and(|f| f.blob_hash.is_empty()) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Missing blob id."), + ); + continue 'create; + } + + result.has_acl_changes + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + }; + + if let Err(err) = + validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders) + { + response.not_created.append(id, err); + continue 'create; + } + + if file_node.modified == 0 { + file_node.modified = now() as i64; + } + + let renamed = match find_sibling_collision( + None, + &file_node, + &cache, + &pending_names, + case_insensitive, + ) { + Collision::None => false, + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = + fetch_existing_modified(self.store(), account_id, existing).await?; + if file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_created.append( + id, + SetError::already_exists() + .with_existing_id(types::id::Id::from(existing)), + ); + continue 'create; + } + } + other => other, + }; + match effective { + OnExists::Reject => { + response.not_created.append( + id, + SetError::already_exists() + .with_existing_id(types::id::Id::from(existing)), + ); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_created + .append(id, SetError::node_has_children()); + continue 'create; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), + } + } + Collision::Pending => match on_exists { + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = crate::file::set::pending_key(&file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(types::id::Id::from(*doc_id)); + } + response.not_created.append(id, err); + continue 'create; + } + }, + }; + + // Permission and ACL inheritance for the destination parent + if file_node.parent_id > 0 { + let parent_id = file_node.parent_id - 1; + + // The user must be allowed to add children to the destination parent + if let Some(allowed) = &can_add_to + && !created_folders.contains_key(&parent_id) + && !allowed.contains(parent_id) + { + response.not_created.append( + id, + SetError::forbidden().with_description( + "You are not allowed to create file nodes in this folder.", + ), + ); + continue 'create; + } + + let parent_acls = created_folders.get(&parent_id).cloned().or_else(|| { + cache + .container_resource_by_id(parent_id) + .and_then(|r| r.acls()) + .map(|a| a.to_vec()) + }); + if !has_acl_changes { + if let Some(parent_acls) = parent_acls { + file_node.acls = parent_acls; + } + } else if is_shared + && parent_acls + .is_none_or(|acls| !acls.effective_acl(access_token).contains(Acl::Share)) + { + response.not_created.append( + id, + SetError::forbidden() + .with_description("You are not allowed to share this file node."), + ); + continue 'create; + } + } else if is_shared { + response.not_created.append( + id, + SetError::forbidden() + .with_description("Cannot create top-level folder in a shared account."), + ); + continue 'create; + } + + if !file_node.acls.is_empty() { + if let Err(err) = self.acl_validate(&file_node.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + self.refresh_acls(&file_node.acls, None) + .await + .caused_by(trc::location!())?; + } + + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + if file_node.file.is_none() { + created_folders.insert(document_id, file_node.acls.clone()); + } + pending_names.insert( + crate::file::set::pending_key(&file_node, case_insensitive), + None, + ); + let final_name = file_node.name.clone(); + let set_created = file_node.created == 0; + let set_modified = file_node.modified == 0; + file_node + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + set_created, + set_modified, + &mut batch, + ) + .caused_by(trc::location!())?; + response.created(id, document_id); + if renamed + && let Some(value) = response.created.get_mut(&id) + && let jmap_tools::Value::Object(map) = value + { + map.insert_unchecked( + jmap_tools::Key::Property(FileNodeProperty::Name), + jmap_tools::Value::Str(std::borrow::Cow::Owned(final_name)), + ); + } + + if on_success_delete { + destroy_ids.push(MaybeInvalid::Value(id)); + } + } + + for did in &implicit_destroys { + let Some(node) = cache.any_resource_path_by_id(*did) else { + continue; + }; + let mut ids = cache.subtree(node.path()).collect::>(); + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let sorted = ids.into_iter().map(|a| a.document_id()).collect::>(); + groupware::DestroyArchive(sorted) + .delete_batch( + self, + access_token.account_tenant_ids(), + account_id, + cache.format_resource(node).into(), + &mut batch, + ) + .await + .caused_by(trc::location!())?; + } + + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + response.new_state = State::Exact(change_id); + } + + if on_success_delete && !destroy_ids.is_empty() { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::FileNode, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::FileNode(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/file/get.rs b/crates/jmap/src/file/get.rs new file mode 100644 index 0000000..6846b80 --- /dev/null +++ b/crates/jmap/src/file/get.rs @@ -0,0 +1,309 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{cache::GroupwareCache, file::FileNode}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::file_node::{self, FileNodeNodeType, FileNodeProperty, FileNodeValue}, + types::date::UTCDate, +}; +use jmap_tools::{Map, Value}; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, now}, +}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + blob::{BlobClass, BlobId}, + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, +}; + +pub trait FileNodeGet: Sync + Send { + fn file_node_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl FileNodeGet for Server { + async fn file_node_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + FileNodeProperty::Id, + FileNodeProperty::ParentId, + FileNodeProperty::NodeType, + FileNodeProperty::BlobId, + FileNodeProperty::Target, + FileNodeProperty::Size, + FileNodeProperty::Name, + FileNodeProperty::Type, + FileNodeProperty::Created, + FileNodeProperty::Modified, + FileNodeProperty::Accessed, + FileNodeProperty::Changed, + FileNodeProperty::Executable, + FileNodeProperty::IsSubscribed, + FileNodeProperty::MyRights, + FileNodeProperty::ShareWith, + FileNodeProperty::Role, + ]); + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await?; + // TODO: draft-14 section 5 case 2 - ancestors of shared nodes should be discoverable with mayRead=false + let file_node_ids = if access_token.is_member(account_id) { + cache + .resources + .iter() + .map(|r| r.document_id) + .collect::() + } else { + cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true) + }; + + let mut ids = if let Some(ids) = ids { + ids + } else { + file_node_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + + if request.arguments.fetch_parents.unwrap_or(false) { + let mut seen: RoaringBitmap = ids.iter().map(|i| i.document_id()).collect(); + let mut extra: Vec = Vec::new(); + for id in &ids { + let mut current = cache + .any_resource_path_by_id(id.document_id()) + .and_then(|r| r.parent_id()); + while let Some(parent_id) = current { + if !seen.insert(parent_id) { + break; + } + if file_node_ids.contains(parent_id) { + extra.push(parent_id.into()); + } + current = cache + .container_resource_by_id(parent_id) + .and_then(|r| r.parent_id()); + } + } + ids.extend(extra); + } + let mut response = GetResponse { + account_id: request.account_id.into(), + state: cache.get_state(false).into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the file_node object + let document_id = id.document_id(); + if !file_node_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + let _file_node = if let Some(file_node) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await? + { + file_node + } else { + response.push_not_found(id); + continue; + }; + let file_node = _file_node + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + FileNodeProperty::Id => { + result.insert_unchecked(FileNodeProperty::Id, FileNodeValue::Id(id)); + } + FileNodeProperty::Name => { + result.insert_unchecked(FileNodeProperty::Name, file_node.name.to_string()); + } + FileNodeProperty::ShareWith => { + result.insert_unchecked( + FileNodeProperty::ShareWith, + JmapRights::share_with::( + account_id, + access_token, + &file_node + .acls + .iter() + .map(AclGrant::from) + .collect::>(), + ), + ); + } + FileNodeProperty::MyRights => { + result.insert_unchecked( + FileNodeProperty::MyRights, + if access_token.is_shared(account_id) { + JmapRights::rights::( + file_node.acls.effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + }, + ); + } + FileNodeProperty::ParentId => { + let parent_id = file_node.parent_id.to_native(); + + result.insert_unchecked( + FileNodeProperty::ParentId, + if parent_id > 0 { + Value::Element(FileNodeValue::Id((parent_id - 1).into())) + } else { + Value::Null + }, + ); + } + FileNodeProperty::BlobId => { + result.insert_unchecked( + FileNodeProperty::BlobId, + if let Some(file) = file_node.file.as_ref() { + Value::Element(FileNodeValue::BlobId(BlobId::new( + BlobHash::from(&file.blob_hash), + BlobClass::Linked { + account_id, + collection: Collection::FileNode.into(), + document_id: id.document_id(), + }, + ))) + } else { + Value::Null + }, + ); + } + FileNodeProperty::Size => { + result.insert_unchecked( + FileNodeProperty::Size, + if let Some(file) = file_node.file.as_ref() { + Value::Number(file.size.to_native().into()) + } else { + Value::Null + }, + ); + } + FileNodeProperty::Type => { + result.insert_unchecked( + FileNodeProperty::Type, + if let Some(file) = file_node.file.as_ref() { + Value::Str( + file.media_type + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()) + .into(), + ) + } else { + Value::Null + }, + ); + } + FileNodeProperty::Executable => { + result.insert_unchecked( + FileNodeProperty::Executable, + if let Some(file) = file_node.file.as_ref() { + Value::Bool(file.executable) + } else { + Value::Null + }, + ); + } + FileNodeProperty::Created => { + result.insert_unchecked( + FileNodeProperty::Created, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + file_node.created.to_native(), + ))), + ); + } + FileNodeProperty::Modified => { + result.insert_unchecked( + FileNodeProperty::Modified, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + file_node.modified.to_native(), + ))), + ); + } + FileNodeProperty::Accessed => { + // TODO: needs serialization change (per-user accessed timestamp); returns now() as a placeholder + result.insert_unchecked( + FileNodeProperty::Accessed, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + now() as i64 + ))), + ); + } + FileNodeProperty::Changed => { + // TODO: needs serialization change (dedicated server-set changed timestamp); returns modified as a placeholder + result.insert_unchecked( + FileNodeProperty::Changed, + Value::Element(FileNodeValue::Date(UTCDate::from_timestamp( + file_node.modified.to_native(), + ))), + ); + } + FileNodeProperty::NodeType => { + let node_type = if file_node.file.is_some() { + FileNodeNodeType::File + } else { + FileNodeNodeType::Directory + }; + result.insert_unchecked( + FileNodeProperty::NodeType, + Value::Str(node_type.as_str().into()), + ); + } + FileNodeProperty::Target => { + result.insert_unchecked(FileNodeProperty::Target, Value::Null); + } + FileNodeProperty::Role => { + result.insert_unchecked(FileNodeProperty::Role, Value::Null); + } + FileNodeProperty::IsSubscribed => { + // TODO: needs serialization change (per-user subscription state); always true for now + result.insert_unchecked(FileNodeProperty::IsSubscribed, Value::Bool(true)); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/file/mod.rs b/crates/jmap/src/file/mod.rs new file mode 100644 index 0000000..a8757a9 --- /dev/null +++ b/crates/jmap/src/file/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod copy; +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/file/query.rs b/crates/jmap/src/file/query.rs new file mode 100644 index 0000000..bff5cc1 --- /dev/null +++ b/crates/jmap/src/file/query.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use groupware::cache::GroupwareCache; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::file_node::{FileNode, FileNodeComparator, FileNodeFilter}, + request::MaybeInvalid, +}; +use store::{ + ahash::AHashMap, + roaring::RoaringBitmap, + search::{SearchFilter, SearchQuery}, + write::SearchIndex, +}; +use types::{acl::Acl, collection::SyncCollection}; + +pub trait FileNodeQuery: Sync + Send { + fn file_node_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl FileNodeQuery for Server { + async fn file_node_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await?; + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + FileNodeFilter::AncestorId(MaybeInvalid::Value(id)) => { + if let Some(resource) = + cache.container_resource_path_by_id(id.document_id()) + { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.subtree(resource.path()).map(|r| r.document_id()), + ))) + } else { + filters.push(SearchFilter::is_in_set(RoaringBitmap::new())); + } + } + FileNodeFilter::DescendantId(MaybeInvalid::Value(id)) => { + let mut ancestors = RoaringBitmap::new(); + let mut current = cache + .any_resource_path_by_id(id.document_id()) + .and_then(|r| r.parent_id()); + while let Some(parent_id) = current { + if !ancestors.insert(parent_id) { + break; + } + current = cache + .container_resource_by_id(parent_id) + .and_then(|r| r.parent_id()); + } + filters.push(SearchFilter::is_in_set(ancestors)); + } + FileNodeFilter::ParentId(MaybeInvalid::Value(id)) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.children_ids(id.document_id()), + ))); + } + FileNodeFilter::IsTopLevel(is_top_level) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if is_top_level == r.parent_id().is_none() { + Some(r.document_id) + } else { + None + } + }), + ))); + } + FileNodeFilter::NodeType(node_type) => { + let want_container = match node_type.as_str() { + "directory" => Some(true), + "file" => Some(false), + _ => None, + }; + let set = match want_container { + Some(is_container) => { + RoaringBitmap::from_iter(cache.resources.iter().filter_map(|r| { + if r.is_container() == is_container { + Some(r.document_id) + } else { + None + } + })) + } + // TODO: support symlink nodeType once target storage exists + None => RoaringBitmap::new(), + }; + filters.push(SearchFilter::is_in_set(set)); + } + FileNodeFilter::Name(name) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if r.container_name().is_some_and(|n| n == name) { + Some(r.document_id) + } else { + None + } + }), + ))); + } + FileNodeFilter::NameMatch(name) => { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if r.container_name().is_some_and(|n| name.matches(n)) { + Some(r.document_id) + } else { + None + } + }), + ))); + } + FileNodeFilter::MinSize(size) => { + let size = size as u32; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if r.size().is_some_and(|s| s >= size) { + Some(r.document_id) + } else { + None + } + }), + ))); + } + FileNodeFilter::MaxSize(size) => { + let size = size as u32; + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + cache.resources.iter().filter_map(|r| { + if r.size().is_some_and(|s| s <= size) { + Some(r.document_id) + } else { + None + } + }), + ))); + } + // TODO: filters below require fetching archives or new indexes; ignore for now + FileNodeFilter::Role(_) + | FileNodeFilter::HasAnyRole(_) + | FileNodeFilter::BlobId(_) + | FileNodeFilter::IsExecutable(_) + | FileNodeFilter::CreatedBefore(_) + | FileNodeFilter::CreatedAfter(_) + | FileNodeFilter::ModifiedBefore(_) + | FileNodeFilter::ModifiedAfter(_) + | FileNodeFilter::AccessedBefore(_) + | FileNodeFilter::AccessedAfter(_) + | FileNodeFilter::Type(_) + | FileNodeFilter::TypeMatch(_) + | FileNodeFilter::Text(_) + | FileNodeFilter::Body(_) + | FileNodeFilter::AncestorId(_) + | FileNodeFilter::DescendantId(_) + | FileNodeFilter::ParentId(_) + | FileNodeFilter::_T(_) => {} + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_mask(if access_token.is_shared(account_id) { + cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true) + } else { + cache.resources.iter().map(|r| r.document_id).collect() + }) + .filter() + .into_bitmap(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + cache.get_state(false), + &request, + ); + + // Only name, size and nodeType can be sorted from the cache. + // TODO: created/modified/type/tree sorts require archive or hierarchy traversal + let sortable = request + .sort + .as_deref() + .unwrap_or_default() + .iter() + .filter(|c| { + matches!( + c.property, + FileNodeComparator::Name + | FileNodeComparator::Size + | FileNodeComparator::NodeType + ) + }) + .collect::>(); + + if sortable.is_empty() { + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + } else { + let by_id = cache + .resources + .iter() + .map(|r| (r.document_id, r)) + .collect::>(); + let mut ids = results.iter().collect::>(); + ids.sort_unstable_by(|a, b| { + for cmp in &sortable { + let ra = by_id.get(a); + let rb = by_id.get(b); + let ordering = match cmp.property { + FileNodeComparator::Name => ra + .and_then(|r| r.container_name()) + .cmp(&rb.and_then(|r| r.container_name())), + FileNodeComparator::Size => { + ra.and_then(|r| r.size()).cmp(&rb.and_then(|r| r.size())) + } + FileNodeComparator::NodeType => { + // Directories sort before files + let a_dir = ra.map(|r| r.is_container()).unwrap_or(false); + let b_dir = rb.map(|r| r.is_container()).unwrap_or(false); + b_dir.cmp(&a_dir) + } + _ => std::cmp::Ordering::Equal, + }; + let ordering = if cmp.is_ascending { + ordering + } else { + ordering.reverse() + }; + if ordering != std::cmp::Ordering::Equal { + return ordering; + } + } + a.cmp(b) + }); + for document_id in ids { + if !response.add(0, document_id) { + break; + } + } + } + + response.build() + } +} diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs new file mode 100644 index 0000000..0b3d282 --- /dev/null +++ b/crates/jmap/src/file/set.rs @@ -0,0 +1,1031 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::acl::{JmapAcl, JmapRights}, + blob::download::BlobDownload, + changes::state::JmapCacheState, +}; +use common::{DavResourceMetadata, DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::{ + AnyId, + file_node::{self, FileNodeProperty, FileNodeValue, OnExists}, + }, + references::resolve::ResolveCreatedReference, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + blob::BlobId, + collection::{Collection, SyncCollection}, + id::Id, +}; + +const FORBIDDEN_NAME_CHARS: &str = "/<>:\"\\|?*"; +const FORBIDDEN_NODE_NAMES: &[&str] = &[ + ".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", + "LPT9", +]; + +pub trait FileNodeSet: Sync + Send { + fn file_node_set( + &self, + request: SetRequest<'_, file_node::FileNode>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +impl FileNodeSet for Server { + async fn file_node_set( + &self, + mut request: SetRequest<'_, file_node::FileNode>, + access_token: &AccessToken, + _session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(false, &request.if_in_state)?); + let mut will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let is_shared = access_token.is_shared(account_id); + let on_destroy_remove_children = request + .arguments + .on_destroy_remove_children + .unwrap_or(false); + let on_exists = request.arguments.on_exists; + let case_insensitive = request + .arguments + .compare_case_insensitively + .unwrap_or(false); + let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); + let mut implicit_destroys: AHashSet = AHashSet::new(); + + // Process creates + let mut batch = BatchBuilder::new(); + let mut created_folders = AHashMap::new(); + 'create: for (id, object) in request.unwrap_create() { + let mut file_node = FileNode::default(); + + // Process changes + let has_acl_changes = + match update_file_node(None, object, &mut file_node, true, &response) { + Ok(result) => { + if let Some(blob_id) = result.blob_id { + let file_details = file_node.file.get_or_insert_default(); + if !self.has_access_blob(&blob_id, access_token).await? { + response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {blob_id}." + )), + ); + continue; + } else if let Some(blob_contents) = self + .blob_store() + .get_blob(blob_id.hash.as_slice(), 0..usize::MAX) + .await? + { + file_details.size = blob_contents.len() as u32; + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Blob could not be found."), + ); + continue 'create; + } + + file_details.blob_hash = blob_id.hash; + } + + // Validate blob hash + if file_node + .file + .as_ref() + .is_some_and(|f| f.blob_hash.is_empty()) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Missing blob id."), + ); + continue 'create; + } + + result.has_acl_changes + } + Err(err) => { + response.not_created.append(id, err); + continue 'create; + } + }; + + // Validate hierarchy + if let Err(err) = + validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders) + { + response.not_created.append(id, err); + continue 'create; + } + + if file_node.modified == 0 { + file_node.modified = now() as i64; + } + + let renamed = match find_sibling_collision( + None, + &file_node, + &cache, + &pending_names, + case_insensitive, + ) { + Collision::None => false, + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = + fetch_existing_modified(self.store(), account_id, existing).await?; + if file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_created.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'create; + } + } + other => other, + }; + match effective { + OnExists::Reject => { + response.not_created.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_created + .append(id, SetError::node_has_children()); + continue 'create; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), + } + } + Collision::Pending => match on_exists { + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = pending_key(&file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(Id::from(*doc_id)); + } + response.not_created.append(id, err); + continue 'create; + } + OnExists::Rename => { + file_node.name = pick_unique_rename( + &file_node.name, + None, + file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + }, + }; + + // Inherit ACLs from parent + if file_node.parent_id > 0 { + let parent_id = file_node.parent_id - 1; + let parent_acls = created_folders.get(&parent_id).cloned().or_else(|| { + cache + .container_resource_by_id(parent_id) + .and_then(|r| r.acls()) + .map(|a| a.to_vec()) + }); + if !has_acl_changes { + if let Some(parent_acls) = parent_acls { + file_node.acls = parent_acls; + } + } else if is_shared + && parent_acls + .is_none_or(|acls| !acls.effective_acl(access_token).contains(Acl::Share)) + { + response.not_created.append( + id, + SetError::forbidden() + .with_description("You are not allowed to share this file node."), + ); + continue 'create; + } + } + + // Validate ACLs + if !file_node.acls.is_empty() { + if let Err(err) = self.acl_validate(&file_node.acls).await { + response.not_created.append(id, err.into()); + continue 'create; + } + + self.refresh_acls(&file_node.acls, None) + .await + .caused_by(trc::location!())?; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::FileNode, 1) + .await + .caused_by(trc::location!())?; + if file_node.file.is_none() { + created_folders.insert(document_id, file_node.acls.clone()); + } + let final_name = file_node.name.clone(); + pending_names.insert(pending_key(&file_node, case_insensitive), None); + let set_created = file_node.created == 0; + let set_modified = file_node.modified == 0; + file_node + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + set_created, + set_modified, + &mut batch, + ) + .caused_by(trc::location!())?; + let create_id = id.clone(); + response.created(id, document_id); + if renamed && let Some(Value::Object(map)) = response.created.get_mut(&create_id) { + map.insert_unchecked( + Key::Property(FileNodeProperty::Name), + Value::Str(std::borrow::Cow::Owned(final_name)), + ); + } + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) || implicit_destroys.contains(&id.document_id()) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain file node + let document_id = id.document_id(); + let file_node_ = if let Some(file_node_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await? + { + file_node_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let file_node = file_node_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_file_node = file_node + .deserialize::() + .caused_by(trc::location!())?; + + // Apply changes + let (has_acl_changes, modified_set) = + match update_file_node(Some(id), object, &mut new_file_node, false, &response) { + Ok(result) => { + let modified_set = result.modified_set; + if let Some(blob_id) = result.blob_id { + let file_details = new_file_node.file.get_or_insert_default(); + if !self.has_access_blob(&blob_id, access_token).await? { + response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "You do not have access to blobId {blob_id}." + )), + ); + continue; + } else if let Some(blob_contents) = self + .blob_store() + .get_blob(blob_id.hash.as_slice(), 0..usize::MAX) + .await? + { + file_details.size = blob_contents.len() as u32; + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(FileNodeProperty::BlobId) + .with_description("Blob could not be found."), + ); + continue 'update; + } + + file_details.blob_hash = blob_id.hash; + } + + (result.has_acl_changes, modified_set) + } + Err(err) => { + response.not_updated.append(id, err); + continue 'update; + } + }; + + // Validate hierarchy + if let Err(err) = validate_file_node_hierarchy( + Some(document_id), + &new_file_node, + is_shared, + &cache, + &created_folders, + ) { + response.not_updated.append(id, err); + continue 'update; + } + + let renamed = match find_sibling_collision( + Some(document_id), + &new_file_node, + &cache, + &pending_names, + case_insensitive, + ) { + Collision::None => false, + Collision::Existing(existing) => { + let effective = match on_exists { + OnExists::Newest => { + let existing_modified = + fetch_existing_modified(self.store(), account_id, existing).await?; + if new_file_node.modified > existing_modified { + OnExists::Replace + } else { + response.not_updated.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'update; + } + } + other => other, + }; + match effective { + OnExists::Reject => { + response.not_updated.append( + id, + SetError::already_exists().with_existing_id(Id::from(existing)), + ); + continue 'update; + } + OnExists::Rename => { + new_file_node.name = pick_unique_rename( + &new_file_node.name, + Some(document_id), + new_file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + OnExists::Replace => { + if let Some(target) = cache.any_resource_path_by_id(existing) { + let subtree_len = cache.subtree(target.path()).count(); + if subtree_len > 1 && !on_destroy_remove_children { + response + .not_updated + .append(id, SetError::node_has_children()); + continue 'update; + } + } + implicit_destroys.insert(existing); + false + } + OnExists::Newest => unreachable!(), + } + } + Collision::Pending => match on_exists { + OnExists::Reject | OnExists::Replace | OnExists::Newest => { + let key = pending_key(&new_file_node, case_insensitive); + let mut err = SetError::already_exists(); + if let Some(Some(doc_id)) = pending_names.get(&key) { + err = err.with_existing_id(Id::from(*doc_id)); + } + response.not_updated.append(id, err); + continue 'update; + } + OnExists::Rename => { + new_file_node.name = pick_unique_rename( + &new_file_node.name, + Some(document_id), + new_file_node.parent_id, + &cache, + &pending_names, + case_insensitive, + ); + true + } + }, + }; + + // Validate ACL + if is_shared { + let acl = file_node.inner.acls.effective_acl(access_token); + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this file node."), + ); + continue 'update; + } + } + if has_acl_changes { + if let Err(err) = self.acl_validate(&new_file_node.acls).await { + response.not_updated.append(id, err.into()); + continue 'update; + } + self.refresh_acls( + &new_file_node.acls, + Some( + file_node + .inner + .acls + .iter() + .map(AclGrant::from) + .collect::>() + .as_slice(), + ), + ) + .await + .caused_by(trc::location!())?; + } + + let final_name = new_file_node.name.clone(); + pending_names.insert( + pending_key(&new_file_node, case_insensitive), + Some(document_id), + ); + // Update record. Bump modified to now() unless the client supplied a value. + new_file_node + .update( + access_token.account_tenant_ids(), + file_node, + account_id, + document_id, + !modified_set, + &mut batch, + ) + .caused_by(trc::location!())?; + let updated_value = if renamed { + let mut map = jmap_tools::Map::with_capacity(1); + map.insert_unchecked( + Key::Property(FileNodeProperty::Name), + Value::Str(std::borrow::Cow::Owned(final_name)), + ); + Some(Value::Object(map)) + } else { + None + }; + response.updated.append(id, updated_value); + } + + // Process deletions + for did in &implicit_destroys { + let id = Id::from(*did); + if !will_destroy.contains(&id) { + will_destroy.push(id); + } + } + let mut destroy_ids = AHashSet::with_capacity(will_destroy.len()); + 'destroy: for id in will_destroy { + let document_id = id.document_id(); + + let Some(file_node) = cache.any_resource_path_by_id(document_id) else { + response.not_destroyed.append(id, SetError::not_found()); + continue 'destroy; + }; + + // Find ids to delete + let mut ids = cache.subtree(file_node.path()).collect::>(); + if ids.is_empty() { + debug_assert!(false, "Resource found in cache but not in subtree"); + continue 'destroy; + } + + // Sort ids descending from the deepest to the root + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_seq())); + let mut sorted_ids = Vec::with_capacity(ids.len()); + sorted_ids.extend(ids.into_iter().map(|a| a.document_id())); + + // Validate not already deleted + for child_id in &sorted_ids { + if !destroy_ids.insert(*child_id) { + response.not_destroyed.append( + id, + SetError::will_destroy().with_description( + "File node or one of its children is already marked for deletion.", + ), + ); + continue 'destroy; + } + } + + // Validate ACLs + if !access_token.is_member(account_id) { + let permissions = cache.shared_containers(access_token, [Acl::Delete], false); + if permissions.len() < sorted_ids.len() as u64 + || !sorted_ids.iter().all(|id| permissions.contains(*id)) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this file node."), + ); + continue 'destroy; + } + } + + // Obtain children ids + if sorted_ids.len() > 1 && !on_destroy_remove_children { + response + .not_destroyed + .append(id, SetError::node_has_children()); + continue 'destroy; + } + + // Delete record + response + .destroyed + .extend(sorted_ids.iter().copied().map(Id::from)); + + DestroyArchive(sorted_ids) + .delete_batch( + self, + access_token.account_tenant_ids(), + account_id, + cache.format_resource(file_node).into(), + &mut batch, + ) + .await?; + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +pub(super) struct UpdateResult { + pub(super) has_acl_changes: bool, + pub(super) blob_id: Option, + pub(super) modified_set: bool, +} + +pub(super) struct NoResolver; + +impl ResolveCreatedReference for NoResolver { + fn get_created_id(&self, _: &str) -> Option { + None + } +} + +pub(super) fn update_file_node>( + expected_id: Option, + updates: Value<'_, FileNodeProperty, FileNodeValue>, + file_node: &mut FileNode, + is_create: bool, + resolver: &R, +) -> Result> { + let mut has_acl_changes = false; + let mut blob_id = None; + let mut pending_size: Option = None; + let mut pending_type: Option> = None; + let mut pending_executable: Option = None; + let mut modified_set = false; + + for (property, mut value) in updates.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + resolver.resolve_self_references(&mut value, 0, false)?; + + match (property, value) { + (FileNodeProperty::Name, Value::Str(value)) => { + if !(1..=255).contains(&value.len()) { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Name) + .with_description("Name must be between 1 and 255 octets.")); + } else if value.contains(|c: char| FORBIDDEN_NAME_CHARS.contains(c)) { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Name) + .with_description("Name contains a forbidden character.")); + } else if FORBIDDEN_NODE_NAMES + .iter() + .any(|n| n.eq_ignore_ascii_case(value.as_ref())) + { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Name) + .with_description("Name is reserved and cannot be used.")); + } + file_node.name = value.into_owned(); + } + (FileNodeProperty::ParentId, Value::Element(FileNodeValue::Id(value))) => { + file_node.parent_id = value.document_id() + 1; + } + (FileNodeProperty::ParentId, Value::Null) => { + file_node.parent_id = 0; + } + (FileNodeProperty::BlobId, Value::Element(FileNodeValue::BlobId(value))) => { + if file_node + .file + .as_ref() + .is_none_or(|f| f.blob_hash != value.hash) + { + blob_id = Some(value); + } + } + (FileNodeProperty::BlobId, Value::Null) => {} + (FileNodeProperty::Size, Value::Number(value)) => { + let value = value.cast_to_u64(); + if value > u32::MAX as u64 { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Size) + .with_description("size is too large.")); + } + pending_size = Some(value as u32); + } + (FileNodeProperty::Size, Value::Null) => { + pending_size = Some(0); + } + (FileNodeProperty::Type, Value::Str(value)) + if (1..=256).contains(&value.len()) && value.contains('/') => + { + // TODO: validate full RFC 6838 Section 4.2 ABNF for media types + pending_type = Some(Some(value.into_owned())); + } + (FileNodeProperty::Type, Value::Null) => { + pending_type = Some(None); + } + (FileNodeProperty::Executable, Value::Bool(value)) => { + pending_executable = Some(value); + } + (FileNodeProperty::Executable, Value::Null) => { + pending_executable = Some(false); + } + (FileNodeProperty::Created, Value::Element(FileNodeValue::Date(value))) + if is_create => + { + file_node.created = value.timestamp(); + } + (FileNodeProperty::Created, _) => { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Created) + .with_description("created is immutable after creation.")); + } + (FileNodeProperty::Modified, Value::Element(FileNodeValue::Date(value))) => { + file_node.modified = value.timestamp(); + modified_set = true; + } + (FileNodeProperty::Modified, Value::Null) => { + file_node.modified = now() as i64; + modified_set = true; + } + // TODO: persist accessed per-user (draft-13 section 3.1) + (FileNodeProperty::Accessed, _) => {} + (FileNodeProperty::NodeType, _) if is_create => {} + (FileNodeProperty::NodeType, _) => { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::NodeType) + .with_description("nodeType is immutable after creation.")); + } + // TODO: implement symlink target storage and resolution + (FileNodeProperty::Target, _) => {} + (FileNodeProperty::Changed, _) => { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Changed) + .with_description("changed is server-set and not settable by clients.")); + } + // TODO: store and validate FileNode role for directories + (FileNodeProperty::Role, _) => {} + (FileNodeProperty::ShareWith, value) => { + file_node.acls = JmapRights::acl_set::(value)?; + has_acl_changes = true; + } + (FileNodeProperty::Pointer(pointer), value) + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + FileNodeProperty::ShareWith + ))) + ) => + { + let mut pointer = pointer.iter(); + pointer.next(); + + file_node.acls = JmapRights::acl_patch::( + std::mem::take(&mut file_node.acls), + pointer, + value, + )?; + has_acl_changes = true; + } + (FileNodeProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + } + + let will_be_file = file_node.file.is_some() || blob_id.is_some(); + if will_be_file { + let file = file_node.file.get_or_insert_default(); + if let Some(size) = pending_size { + file.size = size; + } + if let Some(media_type) = pending_type { + file.media_type = media_type; + } + if let Some(executable) = pending_executable { + file.executable = executable; + } + } else { + let sets_non_null = matches!(pending_type, Some(Some(_))) + || matches!(pending_size, Some(s) if s != 0) + || matches!(pending_executable, Some(true)); + if sets_non_null { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Type) + .with_description("size, type and executable may only be set on file nodes.")); + } + } + + // Validate name + if file_node.name.is_empty() { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::Name) + .with_description("Missing name.")); + } + + Ok(UpdateResult { + has_acl_changes, + blob_id, + modified_set, + }) +} + +pub(super) fn validate_file_node_hierarchy( + document_id: Option, + node: &FileNode, + is_shared: bool, + cache: &DavResources, + created_folders: &AHashMap>, +) -> Result<(), SetError> { + if node.parent_id == 0 { + if is_shared && document_id.is_none() { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("Cannot create top-level folder in a shared account.")); + } + } else { + let parent_id = node.parent_id - 1; + + if let Some(document_id) = document_id { + if document_id == parent_id { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("A file node cannot be its own parent.")); + } + + // Validate circular references + if let Some(file) = cache.container_resource_path_by_id(document_id) + && cache + .subtree(file.path()) + .any(|r| r.document_id() == parent_id) + { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("Circular reference in parent ids.")); + } + } + + // Make sure the parent is a container + if !created_folders.contains_key(&parent_id) + && cache.container_resource_by_id(parent_id).is_none() + { + return Err(SetError::invalid_properties() + .with_property(FileNodeProperty::ParentId) + .with_description("Parent ID does not exist or is not a folder.")); + } + } + + Ok(()) +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub(super) enum Collision { + None, + Existing(u32), + Pending, +} + +pub(super) async fn fetch_existing_modified( + store: &store::Store, + account_id: u32, + document_id: u32, +) -> trc::Result { + Ok(store + .get_value::>(ValueKey::archive( + account_id, + Collection::FileNode, + document_id, + )) + .await? + .map(|arch| { + arch.unarchive::() + .map(|node| node.modified.to_native()) + .unwrap_or(0) + }) + .unwrap_or(0)) +} + +pub(super) fn names_equal(a: &str, b: &str, case_insensitive: bool) -> bool { + if case_insensitive { + a.eq_ignore_ascii_case(b) + } else { + a == b + } +} + +pub(super) fn pending_key(node: &FileNode, case_insensitive: bool) -> (u32, String) { + ( + node.parent_id, + if case_insensitive { + node.name.to_lowercase() + } else { + node.name.clone() + }, + ) +} + +pub(super) fn find_sibling_collision( + document_id: Option, + node: &FileNode, + cache: &DavResources, + pending: &AHashMap<(u32, String), Option>, + case_insensitive: bool, +) -> Collision { + let node_parent_id = if node.parent_id == 0 { + None + } else { + Some(node.parent_id - 1) + }; + for resource in &cache.resources { + if let DavResourceMetadata::File { + name, parent_id, .. + } = &resource.data + && document_id.is_none_or(|id| id != resource.document_id) + && node_parent_id == *parent_id + && names_equal(&node.name, name, case_insensitive) + { + return Collision::Existing(resource.document_id); + } + } + if pending.contains_key(&pending_key(node, case_insensitive)) { + return Collision::Pending; + } + Collision::None +} + +pub(super) fn pick_unique_rename( + base: &str, + document_id: Option, + parent_id: u32, + cache: &DavResources, + pending: &AHashMap<(u32, String), Option>, + case_insensitive: bool, +) -> String { + let (stem, ext) = match base.rfind('.') { + Some(i) if i > 0 && i < base.len() - 1 => (&base[..i], &base[i..]), + _ => (base, ""), + }; + let fold = |s: &str| { + if case_insensitive { + s.to_lowercase() + } else { + s.to_string() + } + }; + let node_parent_id = if parent_id == 0 { + None + } else { + Some(parent_id - 1) + }; + + // Collect all sibling names once, instead of rescanning per probe. + let mut taken: AHashSet = AHashSet::new(); + for resource in &cache.resources { + if let DavResourceMetadata::File { + name, parent_id, .. + } = &resource.data + && document_id.is_none_or(|id| id != resource.document_id) + && node_parent_id == *parent_id + { + taken.insert(fold(name)); + } + } + for ((pending_parent, pending_name), _) in pending { + if *pending_parent == parent_id { + taken.insert(fold(pending_name)); + } + } + + for n in 2u32.. { + let candidate = format!("{stem} ({n}){ext}"); + if !taken.contains(&fold(&candidate)) { + return candidate; + } + } + unreachable!() +} diff --git a/crates/jmap/src/identity/get.rs b/crates/jmap/src/identity/get.rs new file mode 100644 index 0000000..2a19603 --- /dev/null +++ b/crates/jmap/src/identity/get.rs @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::StateManager; +use common::{Server, storage::index::ObjectIndexBuilder}; +use email::identity::{ArchivedEmailAddress, Identity}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::identity::{self, IdentityProperty, IdentityValue}, +}; +use jmap_tools::{Map, Value}; +use std::{collections::BTreeSet, future::Future}; +use store::{ + SerializeInfallible, ValueKey, + rkyv::{option::ArchivedOption, vec::ArchivedVec}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue}, + xxhash_rust::xxh3::Xxh3, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::{Field, IdentityField, PrincipalField}, +}; + +pub trait IdentityGet: Sync + Send { + fn identity_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; + + fn identity_get_or_create( + &self, + account_id: u32, + ) -> impl Future> + Send; +} + +impl IdentityGet for Server { + async fn identity_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + IdentityProperty::Id, + IdentityProperty::Name, + IdentityProperty::Email, + IdentityProperty::ReplyTo, + IdentityProperty::Bcc, + IdentityProperty::TextSignature, + IdentityProperty::HtmlSignature, + IdentityProperty::MayDelete, + ]); + let account_id = request.account_id.document_id(); + let identity_ids = self.identity_get_or_create(account_id).await?; + let ids = if let Some(ids) = ids { + ids + } else { + identity_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: self + .get_state(account_id, SyncCollection::Identity) + .await? + .into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the identity object + let document_id = id.document_id(); + if !identity_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + let _identity = if let Some(identity) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Identity, + document_id, + )) + .await? + { + identity + } else { + response.push_not_found(id); + continue; + }; + let identity = _identity + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + IdentityProperty::Id => { + result.insert_unchecked(IdentityProperty::Id, IdentityValue::Id(id)); + } + IdentityProperty::MayDelete => { + result.insert_unchecked(IdentityProperty::MayDelete, Value::Bool(true)); + } + IdentityProperty::Name => { + result.insert_unchecked(IdentityProperty::Name, identity.name.to_string()); + } + IdentityProperty::Email => { + result + .insert_unchecked(IdentityProperty::Email, identity.email.to_string()); + } + IdentityProperty::TextSignature => { + result.insert_unchecked( + IdentityProperty::TextSignature, + identity.text_signature.to_string(), + ); + } + IdentityProperty::HtmlSignature => { + result.insert_unchecked( + IdentityProperty::HtmlSignature, + identity.html_signature.to_string(), + ); + } + IdentityProperty::Bcc => { + result + .insert_unchecked(IdentityProperty::Bcc, email_to_value(&identity.bcc)); + } + IdentityProperty::ReplyTo => { + result.insert_unchecked( + IdentityProperty::ReplyTo, + email_to_value(&identity.reply_to), + ); + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } + + async fn identity_get_or_create(&self, account_id: u32) -> trc::Result { + // Obtain account info + let account_info = self + .account_info(account_id) + .await + .caused_by(trc::location!())?; + let addresses = account_info + .addresses() + .iter() + .map(|a| a.as_str()) + .collect::>(); + + let mut hasher = Xxh3::new(); + for address in &addresses { + hasher.update(address.as_bytes()); + hasher.update(b"\n"); + } + let addresses_hash = hasher.digest(); + let stored_hash = self + .store() + .get_value::(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::IdentityAddresses, + )) + .await + .caused_by(trc::location!())?; + + let mut identity_ids = self + .document_ids(account_id, Collection::Identity, IdentityField::DocumentId) + .await?; + if stored_hash == Some(addresses_hash) { + return Ok(identity_ids); + } + + // Determine which addresses are missing and which identities are no longer valid + let member_of = &account_info.account().id_member_of; + let mut missing_addresses = addresses.clone(); + let mut obsolete_ids = Vec::new(); + for document_id in &identity_ids { + if let Some(identity) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Identity, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let email = identity + .unarchive::() + .caused_by(trc::location!())? + .email + .as_str(); + + if addresses.contains(email) { + missing_addresses.remove(email); + } else if !self + .account_id_from_email(email, true) + .await + .caused_by(trc::location!())? + .is_some_and(|id| id == account_id || member_of.contains(&id)) + { + obsolete_ids.push(document_id); + } + } + } + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Identity); + + // Create identities for the new addresses + if !missing_addresses.is_empty() { + let name = account_info.description().unwrap_or(account_info.name()); + let mut next_document_id = self + .store() + .assign_document_ids( + account_id, + Collection::Identity, + missing_addresses.len() as u64, + ) + .await + .caused_by(trc::location!())?; + for email in missing_addresses { + let name = if name.is_empty() { + email.to_string() + } else { + name.to_string() + }; + let document_id = next_document_id; + next_document_id -= 1; + batch + .with_document(document_id) + .tag(IdentityField::DocumentId) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(Identity { + name, + email: email.to_string(), + ..Default::default() + })) + .caused_by(trc::location!())? + .commit_point(); + identity_ids.insert(document_id); + } + } + + // Delete identities whose address no longer belongs to this account + for document_id in obsolete_ids { + batch + .with_document(document_id) + .untag(IdentityField::DocumentId) + .clear(Field::ARCHIVE) + .log_item_delete(SyncCollection::Identity, None) + .commit_point(); + identity_ids.remove(document_id); + } + + batch + .with_collection(Collection::Principal) + .with_document(0) + .assert_value( + PrincipalField::IdentityAddresses, + stored_hash.map_or(AssertValue::None, AssertValue::U64), + ) + .set( + PrincipalField::IdentityAddresses, + addresses_hash.serialize(), + ); + + match self.commit_batch(batch).await { + Ok(_) => Ok(identity_ids), + Err(err) if err.is_assertion_failure() => self + .document_ids(account_id, Collection::Identity, IdentityField::DocumentId) + .await + .caused_by(trc::location!()), + Err(err) => Err(err.caused_by(trc::location!())), + } + } +} + +fn email_to_value( + email: &ArchivedOption>, +) -> Value<'static, IdentityProperty, IdentityValue> { + if let ArchivedOption::Some(email) = email { + Value::Array( + email + .iter() + .map(|email| { + Value::Object( + Map::with_capacity(2) + .with_key_value(IdentityProperty::Name, &email.name) + .with_key_value(IdentityProperty::Email, &email.email), + ) + }) + .collect(), + ) + } else { + Value::Null + } +} diff --git a/crates/jmap/src/identity/mod.rs b/crates/jmap/src/identity/mod.rs new file mode 100644 index 0000000..f460aba --- /dev/null +++ b/crates/jmap/src/identity/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod set; diff --git a/crates/jmap/src/identity/set.rs b/crates/jmap/src/identity/set.rs new file mode 100644 index 0000000..f52cd71 --- /dev/null +++ b/crates/jmap/src/identity/set.rs @@ -0,0 +1,344 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, storage::index::ObjectIndexBuilder}; +use email::identity::{EmailAddress, Identity}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::identity::{self, IdentityProperty, IdentityValue}, + references::resolve::ResolveCreatedReference, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{Key, Value}; +use registry::schema::enums::StorageQuota; +use std::future::Future; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::{Field, IdentityField}, + id::Id, +}; +use utils::sanitize_email; + +pub trait IdentitySet: Sync + Send { + fn identity_set( + &self, + request: SetRequest<'_, identity::Identity>, + ) -> impl Future>> + Send; +} + +impl IdentitySet for Server { + async fn identity_set( + &self, + mut request: SetRequest<'_, identity::Identity>, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let identity_ids = self + .document_ids(account_id, Collection::Identity, IdentityField::DocumentId) + .await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let account_info = self + .account_info(account_id) + .await + .caused_by(trc::location!())?; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + let mut identity = Identity::default(); + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response + .resolve_self_references(&mut value, 0, false) + .and_then(|_| { + validate_identity_value(None, &property, value, &mut identity, true) + }) + { + response.not_created.append(id, err); + continue 'create; + } + } + + // Validate email address + if !identity.email.is_empty() { + if !account_info + .addresses() + .iter() + .any(|e| e == &identity.email) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(IdentityProperty::Email) + .with_description( + "E-mail address not configured for this account.".to_string(), + ), + ); + continue 'create; + } + } else { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(IdentityProperty::Email) + .with_description("Missing e-mail address."), + ); + continue 'create; + } + + // Validate quota + if identity_ids.len() + >= self.object_quota( + account_info.object_quotas(), + StorageQuota::MaxEmailIdentities, + ) as u64 + { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many identities, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::Identity, 1) + .await + .caused_by(trc::location!())?; + batch + .with_account_id(account_id) + .with_collection(Collection::Identity) + .with_document(document_id) + .tag(IdentityField::DocumentId) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(identity)) + .caused_by(trc::location!())? + .commit_point(); + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain identity + let document_id = id.document_id(); + let identity_ = if let Some(identity_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Identity, + document_id, + )) + .await? + { + identity_ + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + let identity = identity_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_identity = identity + .deserialize::() + .caused_by(trc::location!())?; + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response + .resolve_self_references(&mut value, 0, false) + .and_then(|_| { + validate_identity_value( + Some(id), + &property, + value, + &mut new_identity, + false, + ) + }) + { + response.not_updated.append(id, err); + continue 'update; + } + } + + // Update record + batch + .with_account_id(account_id) + .with_collection(Collection::Identity) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(identity) + .with_changes(new_identity), + ) + .caused_by(trc::location!())? + .commit_point(); + response.updated.append(id, None); + } + + // Process deletions + for id in will_destroy { + let document_id = id.document_id(); + if identity_ids.contains(document_id) { + // Update record + batch + .with_account_id(account_id) + .with_collection(Collection::Identity) + .with_document(document_id) + .untag(IdentityField::DocumentId) + .clear(Field::ARCHIVE) + .log_item_delete(SyncCollection::Identity, None) + .commit_point(); + response.destroyed.push(id); + } else { + response.not_destroyed.append(id, SetError::not_found()); + } + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + + response.new_state = State::Exact(change_id).into(); + } + + Ok(response) + } +} + +fn validate_identity_value( + expected_id: Option, + property: &Key<'_, IdentityProperty>, + value: Value<'_, IdentityProperty, IdentityValue>, + identity: &mut Identity, + is_create: bool, +) -> Result<(), SetError> { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (IdentityProperty::Name, Value::Str(value)) if value.len() < 255 => { + identity.name = value.into_owned(); + } + (IdentityProperty::Email, Value::Str(value)) if is_create && value.len() < 255 => { + identity.email = sanitize_email(&value).ok_or_else(|| { + SetError::invalid_properties() + .with_property(IdentityProperty::Email) + .with_description("Invalid e-mail address.") + })?; + } + (IdentityProperty::TextSignature, Value::Str(value)) if value.len() < 2048 => { + identity.text_signature = value.into_owned(); + } + (IdentityProperty::HtmlSignature, Value::Str(value)) if value.len() < 2048 => { + identity.html_signature = value.into_owned(); + } + (IdentityProperty::ReplyTo | IdentityProperty::Bcc, Value::Array(value)) => { + let mut addresses = Vec::with_capacity(value.len()); + for addr in value { + let mut address = EmailAddress { + name: None, + email: "".into(), + }; + let mut is_valid = false; + if let Value::Object(obj) = addr { + for (key, value) in obj.into_vec() { + match (key, value) { + (Key::Property(IdentityProperty::Email), Value::Str(value)) + if value.len() < 255 => + { + is_valid = true; + address.email = value.into_owned(); + } + (Key::Property(IdentityProperty::Name), Value::Str(value)) + if value.len() < 255 => + { + address.name = Some(value.into_owned()); + } + (Key::Property(IdentityProperty::Name), Value::Null) => (), + _ => { + is_valid = false; + break; + } + } + } + } + + if is_valid && !address.email.is_empty() { + addresses.push(address); + } else { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Invalid e-mail address object.")); + } + } + + match property { + IdentityProperty::ReplyTo => { + identity.reply_to = Some(addresses); + } + IdentityProperty::Bcc => { + identity.bcc = Some(addresses); + } + _ => unreachable!(), + } + } + (IdentityProperty::Name, Value::Null) => { + identity.name.clear(); + } + (IdentityProperty::TextSignature, Value::Null) => { + identity.text_signature.clear(); + } + (IdentityProperty::HtmlSignature, Value::Null) => { + identity.html_signature.clear(); + } + (IdentityProperty::ReplyTo, Value::Null) => identity.reply_to = None, + (IdentityProperty::Bcc, Value::Null) => identity.bcc = None, + (IdentityProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(IdentityProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + + Ok(()) +} diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs new file mode 100644 index 0000000..085de80 --- /dev/null +++ b/crates/jmap/src/lib.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use jmap_proto::object::JmapObjectId; +use jmap_tools::{Element, Property, Value}; +use std::str::FromStr; +use types::id::Id; + +pub(crate) fn matches_id( + value: &Value<'_, P, E>, + id: Id, +) -> bool { + match value { + Value::Element(element) => element.as_id() == Some(id), + Value::Str(value) => Id::from_str(value.as_ref()).is_ok_and(|value| value == id), + _ => false, + } +} + +pub mod addressbook; +pub mod api; +pub mod blob; +pub mod calendar; +pub mod calendar_event; +pub mod calendar_event_notification; +pub mod changes; +pub mod contact; +pub mod email; +pub mod file; +pub mod identity; +pub mod mailbox; +pub mod participant_identity; +pub mod principal; +pub mod push; +pub mod quota; +pub mod registry; +pub mod share_notification; +pub mod sieve; +pub mod submission; +pub mod thread; +pub mod vacation; +pub mod websocket; diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs new file mode 100644 index 0000000..c5e4062 --- /dev/null +++ b/crates/jmap/src/mailbox/get.rs @@ -0,0 +1,164 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::mailbox::{Mailbox, MailboxProperty, MailboxValue}, +}; +use jmap_tools::{Map, Value}; +use std::future::Future; +use store::ahash::AHashSet; +use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse}; + +use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; + +pub trait MailboxGet: Sync + Send { + fn mailbox_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl MailboxGet for Server { + async fn mailbox_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + MailboxProperty::Id, + MailboxProperty::Name, + MailboxProperty::ParentId, + MailboxProperty::Role, + MailboxProperty::SortOrder, + MailboxProperty::IsSubscribed, + MailboxProperty::TotalEmails, + MailboxProperty::UnreadEmails, + MailboxProperty::TotalThreads, + MailboxProperty::UnreadThreads, + MailboxProperty::MyRights, + ]); + let account_id = request.account_id.document_id(); + let personal_id = access_token.personal_id(account_id, Collection::Mailbox); + let cache = self.get_cached_messages(account_id).await?; + let shared_ids = if access_token.is_shared(account_id) { + cache.shared_mailboxes(access_token, Acl::Read).into() + } else { + None + }; + let ids = if let Some(ids) = ids { + ids + } else { + cache + .mailboxes + .index + .keys() + .filter(|id| shared_ids.as_ref().is_none_or(|ids| ids.contains(**id))) + .copied() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: Some(cache.get_state(true)), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the mailbox object + let document_id = id.document_id(); + let cached_mailbox = if let Some(mailbox) = + cache.mailbox_by_id(&document_id).filter(|_| { + shared_ids + .as_ref() + .is_none_or(|ids| ids.contains(document_id)) + }) { + mailbox + } else { + response.push_not_found(id); + continue; + }; + + let mut mailbox = Map::with_capacity(properties.len()); + + for property in &properties { + let value = match property { + MailboxProperty::Id => Value::Element(MailboxValue::Id(id)), + MailboxProperty::Name => Value::Str(cached_mailbox.name.to_string().into()), + MailboxProperty::Role => match cached_mailbox.role { + SpecialUse::None => Value::Null, + role => Value::Element(MailboxValue::Role(role)), + }, + MailboxProperty::SortOrder => { + Value::Number(cached_mailbox.sort_order().unwrap_or_default().into()) + } + MailboxProperty::ParentId => { + if let Some(parent_id) = cached_mailbox.parent_id() { + Value::Element(MailboxValue::Id(parent_id.into())) + } else { + Value::Null + } + } + MailboxProperty::TotalEmails => { + Value::Number(cache.in_mailbox(document_id).count().into()) + } + MailboxProperty::UnreadEmails => Value::Number( + cache + .in_mailbox_without_keyword(document_id, &Keyword::Seen) + .count() + .into(), + ), + MailboxProperty::TotalThreads => Value::Number( + cache + .in_mailbox(document_id) + .map(|m| m.thread_id) + .collect::>() + .len() + .into(), + ), + MailboxProperty::UnreadThreads => Value::Number( + cache + .in_mailbox_without_keyword(document_id, &Keyword::Seen) + .map(|m| m.thread_id) + .collect::>() + .len() + .into(), + ), + MailboxProperty::MyRights => { + if access_token.is_shared(account_id) { + JmapRights::rights::( + cached_mailbox.acls.as_slice().effective_acl(access_token), + ) + } else { + JmapRights::all_rights::() + } + } + MailboxProperty::IsSubscribed => { + Value::Bool(cached_mailbox.subscribers.contains(&personal_id)) + } + MailboxProperty::ShareWith => JmapRights::share_with::( + account_id, + access_token, + &cached_mailbox.acls, + ), + _ => Value::Null, + }; + + mailbox.insert_unchecked(property.clone(), value); + } + + // Add result to response + response.list.push(mailbox.into()); + } + Ok(response) + } +} diff --git a/crates/jmap/src/mailbox/mod.rs b/crates/jmap/src/mailbox/mod.rs new file mode 100644 index 0000000..c036acc --- /dev/null +++ b/crates/jmap/src/mailbox/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs new file mode 100644 index 0000000..ef917b5 --- /dev/null +++ b/crates/jmap/src/mailbox/query.rs @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; +use common::{Server, auth::AccessToken}; +use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}; +use jmap_proto::{ + method::query::{Comparator, Filter, QueryRequest, QueryResponse}, + object::mailbox::{Mailbox, MailboxComparator, MailboxFilter}, +}; +use std::{collections::BTreeMap, future::Future}; +use store::{ + ahash::AHashMap, + roaring::RoaringBitmap, + search::{SearchComparator, SearchFilter, SearchQuery}, + write::SearchIndex, +}; +use types::{acl::Acl, collection::Collection, special_use::SpecialUse}; + +pub trait MailboxQuery: Sync + Send { + fn mailbox_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl MailboxQuery for Server { + async fn mailbox_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let personal_id = access_token.personal_id(account_id, Collection::Mailbox); + let sort_as_tree = request.arguments.sort_as_tree.unwrap_or(false); + let filter_as_tree = request.arguments.filter_as_tree.unwrap_or(false); + let mut filters = Vec::with_capacity(request.filter.len()); + let mailboxes = self.get_cached_messages(account_id).await?; + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => { + match cond { + MailboxFilter::ParentId(parent_id) => { + let parent_id = parent_id + .and_then(|id| id.try_unwrap().map(|id| id.document_id())) + .unwrap_or(u32::MAX); + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| mailbox.parent_id == parent_id) + .map(|m| m.document_id) + .collect::(), + )); + } + MailboxFilter::Name(name) => { + #[cfg(any(feature = "dev_mode", feature = "test_mode"))] + { + // Used for concurrent requests tests + if name == "__sleep" { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + let name = name.to_lowercase(); + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| mailbox.name.to_lowercase().contains(&name)) + .map(|m| m.document_id) + .collect::(), + )); + } + MailboxFilter::Role(role) => { + if let Some(role) = role { + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| mailbox.role == role) + .map(|m| m.document_id) + .collect::(), + )); + } else { + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| matches!(mailbox.role, SpecialUse::None)) + .map(|m| m.document_id) + .collect::(), + )); + } + } + MailboxFilter::HasAnyRole(has_role) => { + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| { + matches!(mailbox.role, SpecialUse::None) != has_role + }) + .map(|m| m.document_id) + .collect::(), + )); + } + MailboxFilter::IsSubscribed(is_subscribed) => { + filters.push(SearchFilter::is_in_set( + mailboxes + .mailboxes + .items + .iter() + .filter(|mailbox| { + mailbox.subscribers.contains(&personal_id) == is_subscribed + }) + .map(|m| m.document_id) + .collect::(), + )); + } + MailboxFilter::_T(other) => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(other)); + } + } + } + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len())); + + // Sort as tree + if sort_as_tree { + let sorted_set = mailboxes + .mailboxes + .items + .iter() + .map(|mailbox| (mailbox.path.as_str(), mailbox.document_id)) + .collect::>(); + comparators.push(SearchComparator::sorted_set( + sorted_set + .into_iter() + .enumerate() + .map(|(i, (_, v))| (v, i as u32)) + .collect(), + true, + )); + } + + // Parse sort criteria + for comparator in request + .sort + .take() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| vec![Comparator::ascending(MailboxComparator::ParentId)]) + { + comparators.push(match comparator.property { + MailboxComparator::Name => { + let sorted_set = mailboxes + .mailboxes + .items + .iter() + .map(|mailbox| (mailbox.name.as_str(), mailbox.document_id)) + .collect::>(); + + SearchComparator::sorted_set( + sorted_set + .into_iter() + .enumerate() + .map(|(i, (_, v))| (v, i as u32)) + .collect(), + comparator.is_ascending, + ) + } + MailboxComparator::SortOrder => { + let sorted_set = mailboxes + .mailboxes + .items + .iter() + .map(|mailbox| (mailbox.document_id, mailbox.sort_order)) + .collect::>(); + + SearchComparator::sorted_set(sorted_set, comparator.is_ascending) + } + MailboxComparator::ParentId => { + let sorted_set = mailboxes + .mailboxes + .items + .iter() + .map(|mailbox| { + ( + mailbox.document_id, + mailbox.parent_id().map(|id| id + 1).unwrap_or_default(), + ) + }) + .collect::>(); + + SearchComparator::sorted_set(sorted_set, comparator.is_ascending) + } + + MailboxComparator::_T(other) => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other)); + } + }); + } + + let mut results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_comparators(comparators) + .with_mask(if access_token.is_shared(account_id) { + mailboxes.shared_mailboxes(access_token, Acl::Read) + } else { + mailboxes + .mailboxes + .items + .iter() + .map(|m| m.document_id) + .collect() + }) + .filter(); + + // Filter as tree + if filter_as_tree { + let mut new_results = RoaringBitmap::new(); + + for document_id in results.results() { + let mut check_id = document_id; + for _ in 0..self.core.email.mailbox_max_depth { + if let Some(mailbox) = mailboxes.mailbox_by_id(&check_id) { + if let Some(parent_id) = mailbox.parent_id() { + if results.results().contains(parent_id) { + check_id = parent_id; + } else { + break; + } + } else { + new_results.insert(document_id); + } + } + } + } + + results.update_results(new_results); + } + + let mut response = QueryResponseBuilder::new( + results.results().len() as usize, + self.core.jmap.query_max_results, + mailboxes.get_state(true), + &request, + ); + + for document_id in results.into_sorted() { + if !response.add(0, document_id) { + break; + } + } + + response.build() + } +} diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs new file mode 100644 index 0000000..430c469 --- /dev/null +++ b/crates/jmap/src/mailbox/set.rs @@ -0,0 +1,633 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::acl::{JmapAcl, JmapRights}, + changes::state::JmapCacheState, +}; +use common::{ + Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder, +}; +#[allow(unused_imports)] +use email::mailbox::{INBOX_ID, JUNK_ID, TRASH_ID, UidMailbox}; +use email::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, + mailbox::{ + Mailbox, + destroy::{MailboxDestroy, MailboxDestroyError}, + }, +}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::mailbox::{self, MailboxProperty, MailboxValue}, + references::resolve::ResolveCreatedReference, + request::MaybeInvalid, + types::state::State, +}; +use jmap_tools::{JsonPointerItem, Key, Map, Value}; +use registry::schema::enums::StorageQuota; +use std::future::Future; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue}, +}; +use trc::AddContext; +use types::{ + acl::Acl, collection::Collection, field::MailboxField, id::Id, special_use::SpecialUse, +}; + +pub struct SetContext<'x> { + account_id: u32, + access_token: &'x AccessToken, + is_shared: bool, + response: SetResponse, + mailbox_ids: RoaringBitmap, + will_destroy: Vec, +} + +pub trait MailboxSet: Sync + Send { + fn mailbox_set( + &self, + request: SetRequest<'_, mailbox::Mailbox>, + access_token: &AccessToken, + ) -> impl Future>> + Send; + + fn mailbox_set_item( + &self, + changes_: Map<'_, MailboxProperty, MailboxValue>, + update: Option<(u32, Archive)>, + ctx: &SetContext, + ) -> impl Future< + Output = trc::Result< + Result, SetError>, + >, + > + Send; +} + +impl MailboxSet for Server { + #[allow(clippy::blocks_in_conditions)] + async fn mailbox_set( + &self, + mut request: SetRequest<'_, mailbox::Mailbox>, + access_token: &AccessToken, + ) -> trc::Result> { + // Prepare response + let account_id = request.account_id.document_id(); + let on_destroy_remove_emails = request.arguments.on_destroy_remove_emails.unwrap_or(false); + let cache = self.get_cached_messages(account_id).await?; + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state(cache.assert_state(true, &request.if_in_state)?); + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let mut ctx = SetContext { + account_id, + is_shared: access_token.is_shared(account_id), + access_token, + response, + mailbox_ids: RoaringBitmap::from_iter(cache.mailboxes.index.keys()), + will_destroy, + }; + let mut change_id = None; + let account_info = self.account(account_id).await?; + + // Process creates + let mut batch = BatchBuilder::new(); + 'create: for (id, object) in request.unwrap_create() { + let Some(object) = object.into_object() else { + continue; + }; + + // Validate quota + if ctx.mailbox_ids.len() + >= self.object_quota(account_info.object_quotas(), StorageQuota::MaxMailboxes) + as u64 + { + ctx.response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many mailboxes, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + + match self.mailbox_set_item(object, None, &ctx).await? { + Ok(builder) => { + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox); + + let parent_id = builder.changes().unwrap().parent_id; + if parent_id > 0 { + batch + .with_document(parent_id - 1) + .assert_value(MailboxField::Archive, AssertValue::Some); + } + + let document_id = self + .store() + .assign_document_ids(account_id, Collection::Mailbox, 1) + .await + .caused_by(trc::location!())?; + + batch + .with_document(document_id) + .custom(builder) + .caused_by(trc::location!())? + .commit_point(); + + ctx.mailbox_ids.insert(document_id); + ctx.response.created(id, document_id); + } + Err(err) => { + ctx.response.not_created.append(id, err); + continue 'create; + } + } + } + + if !batch.is_empty() { + change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())? + .into(); + } + + // Process updates + let mut will_update = Vec::with_capacity(request.update.as_ref().map_or(0, |u| u.len())); + let mut batch = BatchBuilder::new(); + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + ctx.response + .not_updated + .append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if ctx.will_destroy.contains(&id) { + ctx.response + .not_updated + .append(id, SetError::will_destroy()); + continue 'update; + } + let Some(object) = object.into_object() else { + continue 'update; + }; + + // Obtain mailbox + let document_id = id.document_id(); + if let Some(mailbox) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + document_id, + )) + .await? + { + // Validate ACL + let mailbox = mailbox + .into_deserialized::() + .caused_by(trc::location!())?; + if ctx.is_shared { + let acl = mailbox.inner.acls.effective_acl(access_token); + let subscription_only = object.keys().all(|key| { + matches!( + key, + Key::Property(MailboxProperty::IsSubscribed | MailboxProperty::Id) + ) + }); + if subscription_only { + if !acl.contains(Acl::Read) { + ctx.response.not_updated.append( + id, + SetError::forbidden().with_description( + "You are not allowed to access this mailbox.", + ), + ); + continue 'update; + } + } else if !acl.contains(Acl::Modify) { + ctx.response.not_updated.append( + id, + SetError::forbidden() + .with_description("You are not allowed to modify this mailbox."), + ); + continue 'update; + } else if object.contains_key(&Key::Property(MailboxProperty::ShareWith)) + && !acl.contains(Acl::Share) + { + ctx.response.not_updated.append( + id, + SetError::forbidden().with_description( + "You are not allowed to change the permissions of this mailbox.", + ), + ); + continue 'update; + } + } + + match self + .mailbox_set_item(object, (document_id, mailbox).into(), &ctx) + .await? + { + Ok(builder) => { + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox); + + let parent_id = builder.changes().unwrap().parent_id; + if parent_id > 0 { + batch + .with_document(parent_id - 1) + .assert_value(MailboxField::Archive, AssertValue::Some); + } + + batch + .with_document(document_id) + .custom(builder) + .caused_by(trc::location!())? + .commit_point(); + will_update.push(id); + } + Err(err) => { + ctx.response.not_updated.append(id, err); + continue 'update; + } + } + } else { + ctx.response.not_updated.append(id, SetError::not_found()); + } + } + + if !batch.is_empty() { + match self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + { + Ok(change_id_) => { + change_id = Some(change_id_); + for id in will_update { + ctx.response.updated.append(id, None); + } + } + Err(err) if err.is_assertion_failure() => { + for id in will_update { + ctx.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Another process modified this mailbox, please try again.", + ), + ); + } + } + Err(err) => { + return Err(err.caused_by(trc::location!())); + } + } + } + + // Process deletions + for id in ctx.will_destroy { + match self + .mailbox_destroy( + account_id, + id.document_id(), + ctx.access_token, + on_destroy_remove_emails, + ) + .await? + { + Ok(change_id_) => { + if change_id_.is_some() { + change_id = change_id_; + } + ctx.response.destroyed.push(id); + } + Err(err) => { + ctx.response.not_destroyed.append( + id, + match err { + MailboxDestroyError::CannotDestroy => SetError::forbidden() + .with_description( + "You are not allowed to delete Inbox, Junk or Trash folders.", + ), + MailboxDestroyError::Forbidden => SetError::forbidden() + .with_description("You are not allowed to delete this mailbox."), + MailboxDestroyError::HasChildren => { + SetError::new(SetErrorType::MailboxHasChild) + .with_description("Mailbox has at least one children.") + } + MailboxDestroyError::HasEmails => { + SetError::new(SetErrorType::MailboxHasEmail) + .with_description("Mailbox is not empty.") + } + MailboxDestroyError::NotFound => SetError::not_found(), + MailboxDestroyError::AssertionFailed => SetError::forbidden() + .with_description(concat!( + "Another process modified a message in this mailbox ", + "while deleting it, please try again." + )), + }, + ); + } + } + } + + // Write changes + if let Some(change_id) = change_id { + ctx.response.new_state = State::Exact(change_id).into(); + } + + Ok(ctx.response) + } + + #[allow(clippy::blocks_in_conditions)] + async fn mailbox_set_item( + &self, + changes_: Map<'_, MailboxProperty, MailboxValue>, + update: Option<(u32, Archive)>, + ctx: &SetContext<'_>, + ) -> trc::Result, SetError>> { + // Parse properties + let mut changes = update + .as_ref() + .map(|(_, obj)| obj.inner.clone()) + .unwrap_or_else(|| Mailbox::new(String::new())); + let mut has_acl_changes = false; + for (property, mut value) in changes_.into_vec() { + if let Err(err) = ctx.response.resolve_self_references(&mut value, 0, false) { + return Ok(Err(err)); + }; + match (&property, value) { + (Key::Property(MailboxProperty::Name), Value::Str(value)) => { + let value = value.trim(); + if !value.is_empty() && value.len() < self.core.email.mailbox_name_max_len { + changes.name = value.into(); + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::Name) + .with_description( + if !value.is_empty() { + "Mailbox name is too long." + } else { + "Mailbox name cannot be empty." + } + .to_string(), + ))); + } + } + ( + Key::Property(MailboxProperty::ParentId), + Value::Element(MailboxValue::Id(value)), + ) => { + let parent_id = value.document_id(); + if ctx.will_destroy.contains(&value) { + return Ok(Err(SetError::will_destroy() + .with_description("Parent ID will be destroyed."))); + } else if !ctx.mailbox_ids.contains(parent_id) { + return Ok(Err(SetError::invalid_properties() + .with_description("Parent ID does not exist."))); + } + changes.parent_id = parent_id + 1; + } + (Key::Property(MailboxProperty::ParentId), Value::Null) => { + changes.parent_id = 0; + } + (Key::Property(MailboxProperty::IsSubscribed), Value::Bool(subscribe)) => { + let account_id = ctx + .access_token + .personal_id(ctx.account_id, Collection::Mailbox); + if subscribe { + if !changes.subscribers.contains(&account_id) { + changes.subscribers.push(account_id); + } + } else { + changes.subscribers.retain(|id| *id != account_id); + } + } + ( + Key::Property(MailboxProperty::Role), + Value::Element(MailboxValue::Role(role)), + ) => { + changes.role = role; + } + (Key::Property(MailboxProperty::Role), Value::Null) => { + changes.role = SpecialUse::None; + } + (Key::Property(MailboxProperty::SortOrder), Value::Number(value)) => { + changes.sort_order = Some(value.cast_to_u64() as u32); + } + (Key::Property(MailboxProperty::ShareWith), value) => { + match JmapRights::acl_set::(value) { + Ok(acls) => { + has_acl_changes = true; + changes.acls = acls; + continue; + } + Err(err) => { + return Ok(Err(err)); + } + } + } + (Key::Property(MailboxProperty::Pointer(pointer)), value) + if matches!( + pointer.first(), + Some(JsonPointerItem::Key(Key::Property( + MailboxProperty::ShareWith + ))) + ) => + { + let mut pointer = pointer.iter(); + pointer.next(); + + match JmapRights::acl_patch::(changes.acls, pointer, value) { + Ok(acls) => { + has_acl_changes = true; + changes.acls = acls; + continue; + } + Err(err) => { + return Ok(Err(err)); + } + } + } + + (Key::Property(MailboxProperty::Id), value) => { + if update + .as_ref() + .map(|(document_id, _)| Id::from(*document_id)) + .is_none_or(|expected| !crate::matches_id(&value, expected)) + { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::Id) + .with_description("The id property is immutable.".to_string()))); + } + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Invalid property or value.".to_string()))); + } + } + } + + // Validate depth and circular parent-child relationship + if update + .as_ref() + .is_none_or(|(_, m)| m.inner.parent_id != changes.parent_id) + { + let mut mailbox_parent_id = changes.parent_id; + let current_mailbox_id = update + .as_ref() + .map_or(u32::MAX, |(mailbox_id, _)| *mailbox_id + 1); + let mut success = false; + for depth in 0..self.core.email.mailbox_max_depth { + if mailbox_parent_id == current_mailbox_id { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::ParentId) + .with_description("Mailbox cannot be a parent of itself."))); + } else if mailbox_parent_id == 0 { + if depth == 0 && ctx.is_shared { + return Ok(Err(SetError::forbidden() + .with_description("You are not allowed to create root folders."))); + } + success = true; + break; + } + let parent_document_id = mailbox_parent_id - 1; + + if let Some(mailbox_) = self + .store() + .get_value::>(ValueKey::archive( + ctx.account_id, + Collection::Mailbox, + parent_document_id, + )) + .await? + { + let mailbox = mailbox_ + .unarchive::() + .caused_by(trc::location!())?; + if depth == 0 + && ctx.is_shared + && !mailbox + .acls + .effective_acl(ctx.access_token) + .contains(Acl::CreateChild) + { + return Ok(Err(SetError::forbidden().with_description( + "You are not allowed to create sub mailboxes under this mailbox.", + ))); + } + + mailbox_parent_id = mailbox.parent_id.into(); + } else if ctx.mailbox_ids.contains(parent_document_id) { + // Parent mailbox is probably created within the same request + success = true; + break; + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::ParentId) + .with_description("Mailbox parent does not exist."))); + } + } + + if !success { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::ParentId) + .with_description( + "Mailbox parent-child relationship is too deep.", + ))); + } + } + + let cached_mailboxes = self.get_cached_messages(ctx.account_id).await?; + + // Verify that the mailbox role is unique. + if update + .as_ref() + .is_none_or(|(_, m)| m.inner.role != changes.role) + { + if !matches!(changes.role, SpecialUse::None) + && cached_mailboxes.mailbox_by_role(&changes.role).is_some() + { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::Role) + .with_description(format!( + "A mailbox with role '{}' already exists.", + changes.role.as_str().unwrap_or_default() + )))); + } + + // Role of internal folders cannot be modified + if update.as_ref().is_some_and(|(document_id, _)| { + *document_id == INBOX_ID || *document_id == TRASH_ID || *document_id == JUNK_ID + }) { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::Role) + .with_description( + "You are not allowed to change the role of Inbox, Junk or Trash folders.", + ))); + } + } + + // Verify that the mailbox name is unique. + if !changes.name.is_empty() { + // Obtain parent mailbox id + let lower_name = changes.name.to_lowercase(); + if update + .as_ref() + .is_none_or(|(_, m)| m.inner.name != changes.name) + && let Some(existing) = cached_mailboxes.mailboxes.items.iter().find(|m| { + m.name.to_lowercase() == lower_name + && m.parent_id().map_or(0, |id| id + 1) == changes.parent_id + }) + { + return Ok(Err(SetError::already_exists() + .with_existing_id(Id::from(existing.document_id)) + .with_description(format!( + "A mailbox with name '{}' already exists.", + changes.name + )))); + } + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(MailboxProperty::Name) + .with_description("Mailbox name cannot be empty."))); + } + + // Refresh ACLs + let current = update.map(|(_, current)| current); + if has_acl_changes { + if !changes.acls.is_empty() + && let Err(err) = self.acl_validate(&changes.acls).await + { + return Ok(Err(err.into())); + } + + self.refresh_acls( + &changes.acls, + current.as_ref().map(|m| m.inner.acls.as_slice()), + ) + .await + .caused_by(trc::location!())?; + } + + // Validate + Ok(Ok(ObjectIndexBuilder::new() + .with_changes(changes) + .with_current_opt(current))) + } +} diff --git a/crates/jmap/src/participant_identity/get.rs b/crates/jmap/src/participant_identity/get.rs new file mode 100644 index 0000000..00eb39a --- /dev/null +++ b/crates/jmap/src/participant_identity/get.rs @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::Server; +use groupware::calendar::{ParticipantIdentities, ParticipantIdentity}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::participant_identity::{self, ParticipantIdentityProperty, ParticipantIdentityValue}, +}; +use jmap_tools::{Map, Value}; +use store::{ + Serialize, ValueKey, + write::{AlignedBytes, Archive, Archiver, BatchBuilder}, +}; +use trc::AddContext; +use types::{collection::Collection, field::PrincipalField, id::Id}; + +pub trait ParticipantIdentityGet: Sync + Send { + fn participant_identity_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; + + fn participant_identity_get_or_create( + &self, + account_id: u32, + ) -> impl Future>>> + Send; +} + +impl ParticipantIdentityGet for Server { + async fn participant_identity_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ]); + let account_id = request.account_id.document_id(); + let identities = self.participant_identity_get_or_create(account_id).await?; + + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found: not_found_ids, + }; + + let Some(identities) = identities else { + for id in ids.unwrap_or_default() { + response.push_not_found(id); + } + return Ok(response); + }; + + let identities = identities + .unarchive::() + .caused_by(trc::location!())?; + + let ids = if let Some(ids) = ids { + ids + } else { + identities + .identities + .iter() + .take(self.core.jmap.get_max_objects) + .map(|i| Id::from(i.id.to_native())) + .collect::>() + }; + + for id in ids { + // Obtain the identity object + let document_id = id.document_id(); + let Some(identity) = identities.identities.iter().find(|i| i.id == document_id) else { + response.push_not_found(id); + continue; + }; + + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + let value = match &property { + ParticipantIdentityProperty::Id => { + Value::Element(ParticipantIdentityValue::Id(id)) + } + ParticipantIdentityProperty::Name => Value::Str( + identity + .name + .as_ref() + .map(|n| n.as_str()) + .unwrap_or(identities.default_name.as_str()) + .to_string() + .into(), + ), + ParticipantIdentityProperty::CalendarAddress => { + Value::Str(identity.calendar_address.to_string().into()) + } + ParticipantIdentityProperty::IsDefault => { + Value::Bool(identities.default == document_id) + } + }; + result.insert_unchecked(property.clone(), value); + } + response.list.push(result.into()); + } + + Ok(response) + } + + async fn participant_identity_get_or_create( + &self, + account_id: u32, + ) -> trc::Result>> { + if let Some(identities) = self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::ParticipantIdentities, + )) + .await? + { + return Ok(Some(identities)); + } + + // Obtain account info + let account_info = self + .account_info(account_id) + .await + .caused_by(trc::location!())?; + let name = account_info.description().unwrap_or(account_info.name()); + + // Build identities + let identities = ParticipantIdentities { + identities: account_info + .addresses() + .iter() + .enumerate() + .map(|(id, email)| ParticipantIdentity { + id: id as u32, + name: None, + calendar_address: format!("mailto:{email}"), + }) + .collect(), + default: 0, + default_name: name.to_string(), + }; + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .set( + PrincipalField::ParticipantIdentities, + Archiver::new(identities) + .serialize() + .caused_by(trc::location!())?, + ); + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + self.store() + .get_value::>(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::ParticipantIdentities, + )) + .await + } +} diff --git a/crates/jmap/src/participant_identity/mod.rs b/crates/jmap/src/participant_identity/mod.rs new file mode 100644 index 0000000..f460aba --- /dev/null +++ b/crates/jmap/src/participant_identity/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod set; diff --git a/crates/jmap/src/participant_identity/set.rs b/crates/jmap/src/participant_identity/set.rs new file mode 100644 index 0000000..e43041d --- /dev/null +++ b/crates/jmap/src/participant_identity/set.rs @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::participant_identity::get::ParticipantIdentityGet; +use common::Server; +use groupware::{ + calendar::{ParticipantIdentities, ParticipantIdentity}, + strip_mailto_scheme, +}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::participant_identity::{self, ParticipantIdentityProperty, ParticipantIdentityValue}, + request::{MaybeInvalid, reference::MaybeIdReference}, +}; +use jmap_tools::{Key, Value}; +use registry::schema::prelude::StorageQuota; +use store::{ + Serialize, + ahash::AHashSet, + write::{Archiver, BatchBuilder}, +}; +use trc::AddContext; +use types::{collection::Collection, field::PrincipalField, id::Id}; +use utils::sanitize_email; + +pub trait ParticipantIdentitySet: Sync + Send { + fn participant_identity_set( + &self, + request: SetRequest<'_, participant_identity::ParticipantIdentity>, + ) -> impl Future>> + Send; +} + +impl ParticipantIdentitySet for Server { + async fn participant_identity_set( + &self, + mut request: SetRequest<'_, participant_identity::ParticipantIdentity>, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let (identity_archive, mut identities) = + match self.participant_identity_get_or_create(account_id).await? { + Some(archive) => { + let identities = archive + .deserialize::() + .caused_by(trc::location!())?; + + (Some(archive), identities) + } + None => (None, ParticipantIdentities::default()), + }; + + let account_info = self + .account_info(account_id) + .await + .caused_by(trc::location!())?; + + // Obtain allowed emails + let allowed_emails = account_info + .addresses() + .iter() + .map(|v| v.as_str()) + .collect::>(); + + // Process creates + let mut has_changes = false; + 'create: for (id, object) in request.unwrap_create() { + let mut identity = ParticipantIdentity::default(); + + if let Err(err) = validate_identity_value(None, object, &mut identity, &allowed_emails) + { + response.not_created.append(id, err); + continue 'create; + } + + if identities + .identities + .iter() + .any(|i| i.calendar_address == identity.calendar_address) + { + response.not_created.append( + id, + SetError::invalid_properties() + .with_property(ParticipantIdentityProperty::CalendarAddress) + .with_description("Calendar address already in use.".to_string()), + ); + continue 'create; + } + + // Validate quota + if identities.identities.len() + >= self.object_quota( + account_info.object_quotas(), + StorageQuota::MaxParticipantIdentities, + ) as usize + { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many identities, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + + let document_id = identities + .identities + .iter() + .map(|i| i.id) + .max() + .unwrap_or_default() + + 1; + identity.id = document_id; + identities.identities.push(identity); + + if let Some(MaybeIdReference::Reference(id_ref)) = + &request.arguments.on_success_set_is_default + && id_ref == &id + { + identities.default = document_id; + } + + has_changes = true; + response.created(id, document_id); + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + let Some(identity) = identities + .identities + .iter_mut() + .find(|i| i.id == id.document_id()) + else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + + if let Err(err) = validate_identity_value(Some(id), object, identity, &allowed_emails) { + response.not_updated.append(id, err); + continue 'update; + } + + has_changes = true; + response.updated.append(id, None); + } + + // Process deletions + for id in &will_destroy { + let document_id = id.document_id(); + if identities.identities.iter().any(|i| i.id == document_id) { + response.destroyed.push(*id); + } else { + response.not_destroyed.append(*id, SetError::not_found()); + } + } + if !response.destroyed.is_empty() { + has_changes = true; + identities + .identities + .retain(|i| !response.destroyed.iter().any(|id| id.document_id() == i.id)); + } + + if let Some(MaybeIdReference::Id(id)) = request.arguments.on_success_set_is_default { + let id = id.document_id(); + if identities.identities.iter().any(|i| i.id == id) { + identities.default = id; + has_changes = true; + } + } + + // Write changes + if has_changes { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0); + if let Some(archive) = identity_archive { + batch.assert_value(PrincipalField::ParticipantIdentities, archive); + } + batch.set( + PrincipalField::ParticipantIdentities, + Archiver::new(identities) + .serialize() + .caused_by(trc::location!())?, + ); + + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + Ok(response) + } +} + +fn validate_identity_value( + expected_id: Option, + update: Value<'_, ParticipantIdentityProperty, ParticipantIdentityValue>, + identity: &mut ParticipantIdentity, + allowed_emails: &AHashSet<&str>, +) -> Result<(), SetError> { + for (property, value) in update.into_expanded_object() { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (ParticipantIdentityProperty::Name, Value::Str(value)) if value.len() < 255 => { + identity.name = value.into_owned().into(); + } + (ParticipantIdentityProperty::CalendarAddress, Value::Str(value)) => { + if identity.calendar_address != value { + let email = sanitize_email(strip_mailto_scheme(&value)); + + if let Some(email) = email { + if allowed_emails.iter().any(|e| e == &email) { + identity.calendar_address = format!("mailto:{email}"); + } else { + return Err(SetError::invalid_properties() + .with_property(ParticipantIdentityProperty::CalendarAddress) + .with_description( + "Calendar address not configured for this account.".to_string(), + )); + } + } else { + return Err(SetError::invalid_properties() + .with_property(ParticipantIdentityProperty::CalendarAddress) + .with_description("Invalid or missing calendar address.".to_string())); + } + } + } + (ParticipantIdentityProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(ParticipantIdentityProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + } + + // Validate email address + if !identity.calendar_address.is_empty() { + Ok(()) + } else { + Err(SetError::invalid_properties() + .with_property(ParticipantIdentityProperty::CalendarAddress) + .with_description("Missing calendar address.")) + } +} diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs new file mode 100644 index 0000000..e24010f --- /dev/null +++ b/crates/jmap/src/principal/availability.rs @@ -0,0 +1,427 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{calendar::Availability, calendar_event::CalendarSyntheticId}; +use calcard::{ + common::timezone::Tz, + icalendar::{ + ArchivedICalendarClassification, ArchivedICalendarParameterValue, + ArchivedICalendarParticipationStatus, ArchivedICalendarProperty, ArchivedICalendarStatus, + ArchivedICalendarTransparency, ArchivedICalendarValue, ICalendarParameterName, + }, + jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue}, +}; +use common::{ + Server, TinyCalendarPreferences, + auth::{AccessToken, BuildAccessToken}, +}; +use groupware::{ + cache::GroupwareCache, + calendar::{CALENDAR_SUBSCRIBED, CalendarEvent, expand::RecurrenceKey}, + strip_mailto_scheme, +}; +use jmap_proto::{ + method::availability::{ + BusyPeriod, BusyStatus, GetAvailabilityRequest, GetAvailabilityResponse, + }, + object::calendar::IncludeInAvailability, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use registry::schema::enums::Permission; +use std::{collections::hash_map::Entry, future::Future}; +use store::{ + ValueKey, + ahash::AHashMap, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + TimeRange, + acl::Acl, + collection::{Collection, SyncCollection}, + id::Id, +}; +use utils::sanitize_email; + +pub trait PrincipalGetAvailability: Sync + Send { + fn principal_get_availability( + &self, + request: GetAvailabilityRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl PrincipalGetAvailability for Server { + async fn principal_get_availability( + &self, + request: GetAvailabilityRequest, + access_token: &AccessToken, + ) -> trc::Result { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::JmapPrincipalGetAvailability) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + + // Process parameters + if !request.id.is_valid() { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Missing principal id")); + } + let properties = request + .event_properties + .map(|props| props.into_valid().collect::>()) + .unwrap_or_default(); + if properties + .iter() + .any(|p| !matches!(p, JSCalendarProperty::Id | JSCalendarProperty::BaseEventId)) + { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Only 'id' and 'baseEventId' properties are supported in results")); + } + let return_event_details = !properties.is_empty(); + let max_instances = self.core.groupware.max_ical_instances; + let filter = TimeRange { + start: request.utc_start.timestamp(), + end: request.utc_end.timestamp(), + }; + let principal_id = request.id.document_id(); + let principal = self + .access_token(principal_id) + .await + .caused_by(trc::location!())? + .build(); + let principal_account = self + .account_info(principal_id) + .await + .caused_by(trc::location!())?; + let mut periods = Vec::new(); + + for account_id in principal.all_ids_by_collection(Collection::Calendar) { + let resources = self + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::Calendar, + ) + .await + .caused_by(trc::location!())?; + + // Obtain shared ids + let is_account_owner = principal_id == account_id; + let shared_ids = if !access_token.is_member(account_id) { + // Condition: The user has the "mayReadFreeBusy" permission for the calendar. + let shared_ids = resources.shared_items( + access_token, + [Acl::ReadItems, Acl::SchedulingReadFreeBusy], + true, + ); + if shared_ids.is_empty() { + continue; + } + + shared_ids.into() + } else { + None + }; + + // Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument. + let mut preferences_cache: AHashMap> = + AHashMap::default(); + 'next_event: for resource in resources.resources.iter().filter(|r| { + r.event_time_range().is_some_and(|(start, end)| { + shared_ids + .as_ref() + .is_none_or(|ids| ids.contains(r.document_id)) + && filter.is_in_range(false, start, end) + }) + }) { + // Obtain calendar settings + let mut include_in_availability = None; + let mut default_tz = Tz::UTC; + let mut is_subscribed = is_account_owner; + for calendar_id in resource + .child_names() + .unwrap_or_default() + .iter() + .map(|n| n.parent_id) + { + match preferences_cache.entry(calendar_id) { + Entry::Occupied(e) => { + if let Some(prefs) = e.get() { + default_tz = prefs.tz; + is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0; + include_in_availability = + IncludeInAvailability::from_flags(prefs.flags); + } + } + Entry::Vacant(e) => { + if let Some(prefs) = resources + .container_resource_by_id(calendar_id) + .and_then(|r| r.calendar_preferences(principal_id)) + { + default_tz = prefs.tz; + is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0; + include_in_availability = + IncludeInAvailability::from_flags(prefs.flags); + e.insert(Some(prefs)); + } else { + e.insert(None); + } + } + } + } + let include_in_availability = include_in_availability.unwrap_or({ + if is_account_owner { + IncludeInAvailability::All + } else { + IncludeInAvailability::None + } + }); + + if !is_subscribed || include_in_availability == IncludeInAvailability::None { + continue 'next_event; + } + + // Fetch event + let document_id = resource.document_id; + let Some(archive) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let event = archive + .unarchive::() + .caused_by(trc::location!())?; + + // Find the component ids that match the criteria + let mut matching_component_ids = AHashMap::new(); + 'next_component: for (component_id, component) in + event.data.event.components.iter().enumerate() + { + if !component.component_type.is_event_or_todo() { + continue 'next_component; + } + + let mut is_cancelled = false; + let mut is_main_event = true; + let mut busy_status = None; + + for entry in component.entries.iter() { + match (&entry.name, entry.values.first()) { + ( + ArchivedICalendarProperty::Status, + Some(ArchivedICalendarValue::Status( + ArchivedICalendarStatus::Cancelled, + )), + ) => { + // The "status" property of the event is not "cancelled". + is_cancelled = true; + } + (ArchivedICalendarProperty::RecurrenceId, _) => { + is_main_event = false; + } + ( + ArchivedICalendarProperty::Class, + Some(ArchivedICalendarValue::Classification( + ArchivedICalendarClassification::Confidential, + )), + ) => { + // Condition: The event's "privacy" property is not "secret". + continue 'next_component; + } + ( + ArchivedICalendarProperty::Transp, + Some(ArchivedICalendarValue::Transparency( + ArchivedICalendarTransparency::Transparent, + )), + ) => { + // Condition: The "freeBusyStatus" property of the event is "busy" (or omitted, as this is the default). + continue 'next_component; + } + (ArchivedICalendarProperty::Attendee, Some(value)) + if include_in_availability == IncludeInAvailability::Attending => + { + if let Some(attendee) = value.as_text().and_then(|attendee| { + sanitize_email(strip_mailto_scheme(attendee)) + }) { + // Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative". + if principal_account.addresses().contains(&attendee) { + busy_status = Some( + entry + .parameters(&ICalendarParameterName::Partstat) + .next() + .map(|v| { + match v { + ArchivedICalendarParameterValue::Partstat( + ArchivedICalendarParticipationStatus::Accepted, + ) => BusyStatus::Confirmed, + ArchivedICalendarParameterValue::Partstat( + ArchivedICalendarParticipationStatus::Tentative, + ) => BusyStatus::Tentative, + ArchivedICalendarParameterValue::Partstat( + ArchivedICalendarParticipationStatus::Declined, + ) => { + is_cancelled = true; + BusyStatus::Unavailable + } + _ => BusyStatus::Unavailable, + } + }) + .unwrap_or(BusyStatus::Unavailable), + ); + } + } + } + _ => (), + } + } + + if is_cancelled { + if is_main_event { + continue 'next_event; + } else { + continue 'next_component; + } + } + + let busy_status = if let Some(busy_status) = busy_status { + busy_status + } else if include_in_availability == IncludeInAvailability::All { + BusyStatus::Confirmed + } else { + continue 'next_component; + }; + + matching_component_ids.insert(component_id as u32, busy_status); + } + + if matching_component_ids.is_empty() { + // No events matched the criteria + continue 'next_event; + } + + for expansion in event.data.expand(default_tz, filter).unwrap_or_default() { + let Some(busy_status) = matching_component_ids.get(&expansion.comp_id) else { + continue; + }; + let Some(recurrence_key) = expansion.recurrence_key() else { + continue; + }; + if periods.len() < max_instances { + periods.push(FreeBusyResult { + utc_start: expansion.start, + utc_end: expansion.end, + busy_status: *busy_status, + recurrence_key, + document_id, + }); + } else { + return Err(trc::JmapEvent::RequestTooLarge + .into_err() + .details("The number of expanded instances exceeds the server limit")); + } + } + } + } + + let mut result = GetAvailabilityResponse { + list: Vec::with_capacity(periods.len()), + }; + + if periods.is_empty() { + return Ok(result); + } + + // Sort by busy status and start time + periods.sort_unstable_by(|a, b| { + a.busy_status + .cmp(&b.busy_status) + .then_with(|| a.utc_start.cmp(&b.utc_start)) + }); + + if return_event_details { + for period in periods { + result.list.push(period.into()); + } + } else { + // Merge intervals with same busy status + let mut start_time = periods[0].utc_start; + let mut end_time = periods[0].utc_end; + let mut current_status = periods[0].busy_status; + + for curr in periods.iter().skip(1) { + if curr.utc_start <= end_time && curr.busy_status == current_status { + end_time = end_time.max(curr.utc_end); + } else { + result.list.push(BusyPeriod { + utc_start: UTCDate::from_timestamp(start_time), + utc_end: UTCDate::from_timestamp(end_time), + busy_status: Some(current_status), + event: None, + }); + start_time = curr.utc_start; + end_time = curr.utc_end; + current_status = curr.busy_status; + } + } + + result.list.push(BusyPeriod { + utc_start: UTCDate::from_timestamp(start_time), + utc_end: UTCDate::from_timestamp(end_time), + busy_status: Some(current_status), + event: None, + }); + } + + Ok(result) + } +} + +struct FreeBusyResult { + utc_start: i64, + utc_end: i64, + busy_status: BusyStatus, + recurrence_key: RecurrenceKey, + document_id: u32, +} + +impl From for BusyPeriod { + fn from(value: FreeBusyResult) -> Self { + BusyPeriod { + utc_start: UTCDate::from_timestamp(value.utc_start), + utc_end: UTCDate::from_timestamp(value.utc_end), + busy_status: Some(value.busy_status), + event: JSCalendar(Value::Object(Map::from(vec![ + ( + Key::Property(JSCalendarProperty::Id), + Value::Element(JSCalendarValue::Id(::new( + value.recurrence_key, + value.document_id, + ))), + ), + ( + Key::Property(JSCalendarProperty::BaseEventId), + Value::Element(JSCalendarValue::Id(Id::from(value.document_id))), + ), + ]))) + .into(), + } + } +} diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs new file mode 100644 index 0000000..0071e3d --- /dev/null +++ b/crates/jmap/src/principal/get.rs @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::principal::{Principal, PrincipalProperty, PrincipalType, PrincipalValue}, + request::capability::Capability, + types::state::State, +}; +use jmap_tools::{Key, Map, Value}; +use registry::schema::prelude::{ObjectType, Permission}; +use std::future::Future; +use store::{registry::RegistryQuery, roaring::RoaringBitmap}; +use trc::AddContext; + +pub trait PrincipalGet: Sync + Send { + fn principal_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl PrincipalGet for Server { + async fn principal_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::JmapPrincipalGet) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + PrincipalProperty::Id, + PrincipalProperty::Type, + PrincipalProperty::Name, + PrincipalProperty::Description, + PrincipalProperty::Email, + ]); + + // Return all principals + let principal_ids = self + .registry() + .query::( + RegistryQuery::new(ObjectType::Account).with_tenant(access_token.tenant_id()), + ) + .await + .caused_by(trc::location!())?; + + let ids = if let Some(ids) = ids { + ids + } else { + principal_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: State::Initial.into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the principal + let document_id = id.document_id(); + if !principal_ids.contains(document_id) { + response.push_not_found(id); + continue; + }; + let principal = self + .account(document_id) + .await + .caused_by(trc::location!())?; + + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + let value = match property { + PrincipalProperty::Id => Value::Element(PrincipalValue::Id(id)), + PrincipalProperty::Type => { + Value::Element(PrincipalValue::Type(if principal.is_user_account() { + PrincipalType::Individual + } else { + PrincipalType::Group + })) + } + PrincipalProperty::Name => Value::Str(principal.name().to_string().into()), + PrincipalProperty::Description => principal + .description() + .map(|v| Value::Str(v.to_string().into())) + .unwrap_or(Value::Null), + PrincipalProperty::Email => Value::Str(principal.name().to_string().into()), + PrincipalProperty::Accounts => Value::Object(Map::from(vec![( + Key::Property(PrincipalProperty::IdValue(id)), + Value::Object(Map::from_iter( + [ + Capability::Mail, + Capability::Contacts, + Capability::Calendars, + Capability::FileNode, + Capability::Principals, + ] + .iter() + .map(|cap| { + ( + Key::Property(PrincipalProperty::Capability(*cap)), + Value::Object(Map::new()), + ) + }) + .chain([ + ( + Key::Property(PrincipalProperty::Capability( + Capability::PrincipalsOwner, + )), + Value::Object(Map::from(vec![ + ( + Key::Borrowed("accountIdForPrincipal"), + Value::Element(PrincipalValue::Id(id)), + ), + ( + Key::Borrowed("principalId"), + Value::Element(PrincipalValue::Id(id)), + ), + ])), + ), + ( + Key::Property(PrincipalProperty::Capability( + Capability::Calendars, + )), + Value::Object(Map::from(vec![ + ( + Key::Borrowed("accountId"), + Value::Element(PrincipalValue::Id(id)), + ), + (Key::Borrowed("mayGetAvailability"), Value::Bool(true)), + (Key::Borrowed("mayShareWith"), Value::Bool(true)), + ( + Key::Borrowed("calendarAddress"), + Value::Str( + format!("mailto:{}", principal.name()).into(), + ), + ), + ])), + ), + ]), + )), + )])), + PrincipalProperty::Capabilities => Value::Object(Map::from_iter( + [ + Capability::Mail, + Capability::Contacts, + Capability::Calendars, + Capability::FileNode, + Capability::Principals, + ] + .iter() + .map(|cap| { + ( + Key::Property(PrincipalProperty::Capability(*cap)), + Value::Object(Map::new()), + ) + }), + )), + _ => Value::Null, + }; + + result.insert_unchecked(property.clone(), value); + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/principal/mod.rs b/crates/jmap/src/principal/mod.rs new file mode 100644 index 0000000..db9e3b2 --- /dev/null +++ b/crates/jmap/src/principal/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod availability; +pub mod get; +pub mod query; diff --git a/crates/jmap/src/principal/query.rs b/crates/jmap/src/principal/query.rs new file mode 100644 index 0000000..e38de82 --- /dev/null +++ b/crates/jmap/src/principal/query.rs @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::query::QueryResponseBuilder; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::principal::{Principal, PrincipalFilter, PrincipalType}, + types::state::State, +}; +use registry::{ + schema::{ + enums::AccountType, + prelude::{ObjectType, Permission, Property}, + }, + types::EnumImpl, +}; +use std::future::Future; +use store::{ + registry::RegistryQuery, + roaring::RoaringBitmap, + search::{SearchFilter, SearchQuery}, + write::SearchIndex, +}; +use trc::AddContext; + +pub trait PrincipalQuery: Sync + Send { + fn principal_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl PrincipalQuery for Server { + async fn principal_query( + &self, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::JmapPrincipalQuery) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + + let principal_ids = self + .registry() + .query::( + RegistryQuery::new(ObjectType::Account).with_tenant(access_token.tenant_id()), + ) + .await + .caused_by(trc::location!())?; + + let mut filters = Vec::with_capacity(request.filter.len()); + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + PrincipalFilter::Name(name) | PrincipalFilter::Email(name) => { + filters.push(SearchFilter::is_in_set( + match self.account_id_from_email(&name, false).await? { + Some(account_id) => { + RoaringBitmap::from_sorted_iter([account_id]).unwrap() + } + None => RoaringBitmap::new(), + }, + )); + } + PrincipalFilter::AccountIds(ids) => { + filters.push(SearchFilter::is_in_set( + ids.into_iter() + .filter_map(|id| { + let id = id.document_id(); + if principal_ids.contains(id) { + Some(id) + } else { + None + } + }) + .collect::(), + )); + } + PrincipalFilter::Text(text) => { + filters.push(SearchFilter::is_in_set( + self.registry() + .query::( + RegistryQuery::new(ObjectType::Account) + .with_tenant(access_token.tenant_id()) + .text(Property::Text, text), + ) + .await + .caused_by(trc::location!())?, + )); + } + PrincipalFilter::Type(principal_type) => { + let typ = match principal_type { + PrincipalType::Individual => AccountType::User, + PrincipalType::Group => AccountType::Group, + _ => { + filters.push(SearchFilter::is_in_set(Default::default())); + continue; + } + }; + + filters.push(SearchFilter::is_in_set( + self.registry() + .query::( + RegistryQuery::new(ObjectType::Account) + .equal(Property::Type, typ.to_id()) + .with_tenant(access_token.tenant_id()), + ) + .await + .caused_by(trc::location!())?, + )); + } + other => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(other.to_string())); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_mask(principal_ids) + .filter() + .into_bitmap(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + State::Initial, + &request, + ); + + for document_id in results { + if !response.add(0, document_id) { + break; + } + } + + response.build() + } +} diff --git a/crates/jmap/src/push/get.rs b/crates/jmap/src/push/get.rs new file mode 100644 index 0000000..db7ffb4 --- /dev/null +++ b/crates/jmap/src/push/get.rs @@ -0,0 +1,417 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken, ipc::PushEvent}; +use email::push::{PushSubscriptions, Urgency}; +use jmap_proto::{ + method::{ + get::{GetRequest, GetResponse}, + query::ArchivedFilter, + }, + object::{ + email::{ArchivedEmailFilter, EmailFilter, EmailProperty}, + push_subscription::{self, PushSubscriptionProperty, PushSubscriptionValue}, + }, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use std::future::Future; +use store::{ + Serialize, ValueKey, + write::{AlignedBytes, Archive, Archiver, BatchBuilder, now}, +}; +use trc::{AddContext, ServerEvent}; +use types::{collection::Collection, field::PrincipalField, id::Id}; +use utils::map::bitmap::Bitmap; + +pub trait PushSubscriptionFetch: Sync + Send { + fn push_subscription_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl PushSubscriptionFetch for Server { + async fn push_subscription_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + PushSubscriptionProperty::Id, + PushSubscriptionProperty::DeviceClientId, + PushSubscriptionProperty::VerificationCode, + PushSubscriptionProperty::Expires, + PushSubscriptionProperty::Types, + ]); + + let account_id = access_token.account_id(); + + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found: not_found_ids, + }; + + let Some(subscriptions_) = self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::PushSubscriptions, + )) + .await? + else { + for id in ids.unwrap_or_default() { + response.push_not_found(id); + } + return Ok(response); + }; + let subscriptions = subscriptions_ + .to_unarchived::() + .caused_by(trc::location!())?; + + let ids = if let Some(ids) = ids { + ids + } else { + subscriptions + .inner + .subscriptions + .iter() + .take(self.core.jmap.get_max_objects) + .map(|s| Id::from(s.id.to_native())) + .collect::>() + }; + + for id in ids { + // Obtain the push subscription object + let document_id = id.document_id(); + let Some(push) = subscriptions + .inner + .subscriptions + .iter() + .find(|p| p.id.to_native() == document_id) + else { + response.push_not_found(id); + continue; + }; + + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + PushSubscriptionProperty::Id => { + result.insert_unchecked(PushSubscriptionProperty::Id, id); + } + PushSubscriptionProperty::Url | PushSubscriptionProperty::Keys => { + return Err(trc::JmapEvent::Forbidden.into_err().details( + "The 'url' and 'keys' properties are not readable".to_string(), + )); + } + PushSubscriptionProperty::DeviceClientId => { + result.insert_unchecked( + PushSubscriptionProperty::DeviceClientId, + &push.device_client_id, + ); + } + PushSubscriptionProperty::Types => { + let mut types = Vec::new(); + for typ in Bitmap::from(&push.types).into_iter() { + types.push(Value::Element(PushSubscriptionValue::Types(typ))); + } + result + .insert_unchecked(PushSubscriptionProperty::Types, Value::Array(types)); + } + PushSubscriptionProperty::Expires => { + if push.expires > 0 { + result.insert_unchecked( + PushSubscriptionProperty::Expires, + Value::Element(PushSubscriptionValue::Date( + UTCDate::from_timestamp(u64::from(push.expires) as i64), + )), + ); + } else { + result.insert_unchecked(PushSubscriptionProperty::Expires, Value::Null); + } + } + PushSubscriptionProperty::EmailPush => { + if push.email_push.is_empty() { + result + .insert_unchecked(PushSubscriptionProperty::EmailPush, Value::Null); + } else { + let mut configs = Map::with_capacity(push.email_push.len()); + for config in push.email_push.iter() { + let properties = config + .properties + .iter() + .map(|property| { + Value::Str(EmailProperty::from(property).to_cow()) + }) + .collect(); + let obj = Map::with_capacity(3) + .with_key_value( + Key::Borrowed("filter"), + archived_filter_node(&mut config.filter.iter().peekable()) + .unwrap_or(Value::Null), + ) + .with_key_value( + Key::Borrowed("properties"), + Value::Array(properties), + ) + .with_key_value( + Key::Borrowed("urgency"), + Value::Str(Urgency::from(&config.urgency).as_str().into()), + ); + configs.insert_unchecked( + Key::Owned(Id::from(config.account_id.to_native()).as_string()), + Value::Object(obj), + ); + } + result.insert_unchecked( + PushSubscriptionProperty::EmailPush, + Value::Object(configs), + ); + } + } + property => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + response.list.push(result.into()); + } + + // Purge old subscriptions + let current_time = now(); + if subscriptions + .inner + .subscriptions + .iter() + .any(|s| s.expires.to_native() < current_time) + { + let mut updated_subscriptions = subscriptions.deserialize::()?; + updated_subscriptions + .subscriptions + .retain(|s| s.expires >= current_time); + let mut batch = BatchBuilder::new(); + + if updated_subscriptions.subscriptions.is_empty() { + batch + .with_account_id(u32::MAX) + .with_collection(Collection::Principal) + .with_account_id(account_id) + .tag(PrincipalField::PushSubscriptions); + } + + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .assert_value(PrincipalField::PushSubscriptions, subscriptions); + + if !updated_subscriptions.subscriptions.is_empty() { + batch.set( + PrincipalField::PushSubscriptions, + Archiver::new(updated_subscriptions) + .serialize() + .caused_by(trc::location!())?, + ); + } else { + batch.clear(PrincipalField::PushSubscriptions); + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + // Update push servers + if self + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::PushServerUpdate { + account_id, + broadcast: true, + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push updates.", + CausedBy = trc::location!() + ); + } + } + + Ok(response) + } +} + +fn archived_filter_node<'a, I>( + tokens: &mut std::iter::Peekable, +) -> Option> +where + I: Iterator>, +{ + match tokens.next()? { + operator @ (ArchivedFilter::And | ArchivedFilter::Or | ArchivedFilter::Not) => { + let operator = match operator { + ArchivedFilter::And => "AND", + ArchivedFilter::Or => "OR", + ArchivedFilter::Not => "NOT", + _ => unreachable!(), + }; + let mut conditions = Vec::new(); + while let Some(token) = tokens.peek() { + if matches!(token, ArchivedFilter::Close) { + tokens.next(); + break; + } + if let Some(condition) = archived_filter_node(tokens) { + conditions.push(condition); + } + } + Some(Value::Object( + Map::with_capacity(2) + .with_key_value(Key::Borrowed("operator"), Value::Str(operator.into())) + .with_key_value(Key::Borrowed("conditions"), Value::Array(conditions)), + )) + } + ArchivedFilter::Property(filter) => Some(archived_condition_to_value(filter)), + ArchivedFilter::Close => None, + } +} + +fn archived_condition_to_value( + filter: &ArchivedEmailFilter, +) -> Value<'static, PushSubscriptionProperty, PushSubscriptionValue> { + let (key, value): ( + &'static str, + Value<'static, PushSubscriptionProperty, PushSubscriptionValue>, + ) = match filter { + ArchivedEmailFilter::InMailbox(id) => ("inMailbox", Id::from(id).as_string().into()), + ArchivedEmailFilter::InMailboxOtherThan(ids) => ( + "inMailboxOtherThan", + Value::Array( + ids.iter() + .map(|id| Id::from(id).as_string().into()) + .collect(), + ), + ), + ArchivedEmailFilter::Before(date) => ("before", UTCDate::from(date).to_string().into()), + ArchivedEmailFilter::After(date) => ("after", UTCDate::from(date).to_string().into()), + ArchivedEmailFilter::MinSize(size) => ("minSize", (size.to_native() as u64).into()), + ArchivedEmailFilter::MaxSize(size) => ("maxSize", (size.to_native() as u64).into()), + ArchivedEmailFilter::AllInThreadHaveKeyword(keyword) => { + ("allInThreadHaveKeyword", keyword.to_string().into()) + } + ArchivedEmailFilter::SomeInThreadHaveKeyword(keyword) => { + ("someInThreadHaveKeyword", keyword.to_string().into()) + } + ArchivedEmailFilter::NoneInThreadHaveKeyword(keyword) => { + ("noneInThreadHaveKeyword", keyword.to_string().into()) + } + ArchivedEmailFilter::HasKeyword(keyword) => ("hasKeyword", keyword.to_string().into()), + ArchivedEmailFilter::NotKeyword(keyword) => ("notKeyword", keyword.to_string().into()), + ArchivedEmailFilter::HasAttachment(value) => ("hasAttachment", (*value).into()), + ArchivedEmailFilter::From(value) => ("from", value.as_str().to_string().into()), + ArchivedEmailFilter::To(value) => ("to", value.as_str().to_string().into()), + ArchivedEmailFilter::Cc(value) => ("cc", value.as_str().to_string().into()), + ArchivedEmailFilter::Bcc(value) => ("bcc", value.as_str().to_string().into()), + ArchivedEmailFilter::Subject(value) => ("subject", value.as_str().to_string().into()), + ArchivedEmailFilter::Body(value) => ("body", value.as_str().to_string().into()), + ArchivedEmailFilter::Header(values) => ( + "header", + Value::Array( + values + .iter() + .map(|value| value.as_str().to_string().into()) + .collect(), + ), + ), + ArchivedEmailFilter::Text(value) => ("text", value.as_str().to_string().into()), + ArchivedEmailFilter::SentBefore(date) => { + ("sentBefore", UTCDate::from(date).to_string().into()) + } + ArchivedEmailFilter::SentAfter(date) => { + ("sentAfter", UTCDate::from(date).to_string().into()) + } + ArchivedEmailFilter::InThread(id) => ("inThread", Id::from(id).as_string().into()), + ArchivedEmailFilter::Id(ids) => ( + "id", + Value::Array( + ids.iter() + .map(|id| Id::from(id).as_string().into()) + .collect(), + ), + ), + ArchivedEmailFilter::_T(_) => return Value::Object(Map::with_capacity(0)), + }; + Value::Object(Map::with_capacity(1).with_key_value(Key::Borrowed(key), value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use jmap_proto::method::query::{Filter, FilterWrapper}; + use serde::Deserialize; + + fn parse_filter(json: &str) -> Vec> { + let value: Value = + serde_json::from_str(json).expect("valid filter json"); + FilterWrapper::::deserialize(&value) + .expect("parseable filter") + .0 + } + + fn store_and_serialize( + filter: &[Filter], + ) -> Value<'static, PushSubscriptionProperty, PushSubscriptionValue> { + let bytes = rkyv::to_bytes::(&filter.to_vec()).expect("serialize"); + let archived = rkyv::access::< + > as rkyv::Archive>::Archived, + rkyv::rancor::Error, + >(&bytes) + .expect("access archived"); + archived_filter_node(&mut archived.iter().peekable()).unwrap_or(Value::Null) + } + + #[test] + fn email_push_filter_round_trips() { + let mailbox_a = Id::from_parts(0, 10).to_string(); + let mailbox_b = Id::from_parts(0, 20).to_string(); + + for json in [ + format!(r#"{{"inMailbox":"{mailbox_a}"}}"#), + format!(r#"{{"inMailbox":"{mailbox_a}","hasKeyword":"$seen"}}"#), + format!( + r#"{{"operator":"OR","conditions":[{{"inMailbox":"{mailbox_a}"}},{{"hasKeyword":"$notify"}}]}}"# + ), + format!( + r#"{{"operator":"AND","conditions":[{{"inMailbox":"{mailbox_a}"}},{{"operator":"NOT","conditions":[{{"hasKeyword":"$junk"}}]}}]}}"# + ), + format!( + r#"{{"operator":"OR","conditions":[{{"subject":"hello"}},{{"operator":"AND","conditions":[{{"from":"alice@example.com"}},{{"inMailboxOtherThan":["{mailbox_a}","{mailbox_b}"]}},{{"minSize":1024}},{{"hasAttachment":true}}]}}]}}"# + ), + ] { + let parsed = parse_filter(&json); + let serialized = store_and_serialize(&parsed); + let round_tripped = FilterWrapper::::deserialize(&serialized) + .expect("reparseable filter") + .0; + + assert_eq!(parsed, round_tripped, "filter did not round-trip: {json}"); + } + } + + #[test] + fn empty_filter_serializes_to_null() { + assert!(matches!(store_and_serialize(&[]), Value::Null)); + } +} diff --git a/crates/jmap/src/push/mod.rs b/crates/jmap/src/push/mod.rs new file mode 100644 index 0000000..f460aba --- /dev/null +++ b/crates/jmap/src/push/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod set; diff --git a/crates/jmap/src/push/set.rs b/crates/jmap/src/push/set.rs new file mode 100644 index 0000000..b764083 --- /dev/null +++ b/crates/jmap/src/push/set.rs @@ -0,0 +1,596 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{ + Engine, alphabet, + engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, +}; +use common::{Server, auth::AccessToken, ipc::PushEvent, network::is_global_ip}; +use email::push::{EmailPush, Keys, PushSubscription, PushSubscriptions, Urgency}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::{ + query::FilterWrapper, + set::{SetRequest, SetResponse}, + }, + object::{ + email::{EmailFilter, EmailProperty}, + push_subscription::{ + self, EmailPushProperty, PushSubscriptionProperty, PushSubscriptionValue, + }, + }, + references::resolve::ResolveCreatedReference, + request::MaybeInvalid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use rand::distr::Alphanumeric; +use registry::schema::enums::StorageQuota; +use reqwest::Url; +use std::future::Future; +use std::net::IpAddr; +use std::str::FromStr; +use store::{ + Serialize, ValueKey, + rand::{RngExt, rng}, + write::{AlignedBytes, Archive, Archiver, BatchBuilder, now}, +}; +use trc::{AddContext, ServerEvent}; +use types::{collection::Collection, field::PrincipalField, id::Id}; +use utils::map::bitmap::Bitmap; + +const EXPIRES_MAX: i64 = 7 * 24 * 3600; // 7 days +const VERIFICATION_CODE_LEN: usize = 32; +const URL_SAFE_INDIFFERENT: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); + +pub trait PushSubscriptionSet: Sync + Send { + fn push_subscription_set( + &self, + request: SetRequest<'_, push_subscription::PushSubscription>, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl PushSubscriptionSet for Server { + async fn push_subscription_set( + &self, + mut request: SetRequest<'_, push_subscription::PushSubscription>, + access_token: &AccessToken, + ) -> trc::Result> { + // Load existing push subscriptions + let account_id = access_token.account_id(); + let subscriptions_archive = self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::PushSubscriptions, + )) + .await?; + let mut subscriptions = if let Some(subscriptions) = &subscriptions_archive { + subscriptions + .deserialize::() + .caused_by(trc::location!())? + } else { + PushSubscriptions::default() + }; + + let num_subscriptions = subscriptions.subscriptions.len(); + let mut max_id = 0; + let current_time = now(); + subscriptions.subscriptions.retain(|s| { + max_id = max_id.max(s.id); + + s.expires > current_time + }); + let mut has_changes = num_subscriptions != subscriptions.subscriptions.len(); + + // Prepare response + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + let account = self.account(account_id).await.caused_by(trc::location!())?; + + // Process creates + 'create: for (id, object) in request.unwrap_create() { + let mut push = PushSubscription::default(); + + if subscriptions.subscriptions.len() + >= self.object_quota(account.object_quotas(), StorageQuota::MaxPushSubscriptions) + as usize + { + response.not_created.append(id, SetError::new(SetErrorType::OverQuota).with_description( + "There are too many subscriptions, please delete some before adding a new one.", + )); + continue 'create; + } + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response + .resolve_self_references(&mut value, 0, false) + .and_then(|_| { + validate_push_value(None, &property, value, &mut push, true, access_token) + }) + { + response.not_created.append(id, err); + continue 'create; + } + } + + if push.device_client_id.is_empty() || push.url.is_empty() { + response.not_created.append( + id, + SetError::invalid_properties() + .with_properties([ + PushSubscriptionProperty::DeviceClientId, + PushSubscriptionProperty::Url, + ]) + .with_description("Missing required properties"), + ); + continue 'create; + } + + // Add expiry time if missing + if push.expires == 0 { + push.expires = now() + EXPIRES_MAX as u64; + } + let expires = UTCDate::from_timestamp(push.expires as i64); + + // Generate random verification code + push.verification_code = rng() + .sample_iter(Alphanumeric) + .take(VERIFICATION_CODE_LEN) + .map(char::from) + .collect::(); + + // Set id + max_id += 1; + let document_id = max_id; + push.id = document_id; + + // Insert record + subscriptions.subscriptions.push(push); + response.created.insert( + id, + Map::with_capacity(1) + .with_key_value( + PushSubscriptionProperty::Id, + PushSubscriptionValue::Id(document_id.into()), + ) + .with_key_value(PushSubscriptionProperty::Keys, Value::Null) + .with_key_value( + PushSubscriptionProperty::Expires, + PushSubscriptionValue::Date(expires), + ) + .into(), + ); + has_changes = true; + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain push subscription + let document_id = id.document_id(); + let Some(push) = subscriptions + .subscriptions + .iter_mut() + .find(|p| p.id == document_id) + else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response + .resolve_self_references(&mut value, 0, false) + .and_then(|_| { + validate_push_value(Some(id), &property, value, push, false, access_token) + }) + { + response.not_updated.append(id, err); + continue 'update; + } + } + + has_changes = true; + response.updated.append(id, None); + } + + // Process deletions + for id in will_destroy { + let document_id = id.document_id(); + if let Some(idx) = subscriptions + .subscriptions + .iter() + .position(|p| p.id == document_id) + { + subscriptions.subscriptions.swap_remove(idx); + has_changes = true; + response.destroyed.push(id); + } else { + response.not_destroyed.append(id, SetError::not_found()); + } + } + + // Update push subscriptions + if has_changes { + // Save changes + let mut batch = BatchBuilder::new(); + + if subscriptions_archive.is_none() { + batch + .with_account_id(u32::MAX) + .with_collection(Collection::Principal) + .with_document(account_id) + .tag(PrincipalField::PushSubscriptions); + } else if subscriptions.subscriptions.is_empty() { + batch + .with_account_id(u32::MAX) + .with_collection(Collection::Principal) + .with_document(account_id) + .untag(PrincipalField::PushSubscriptions); + } + + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0); + + if let Some(subscriptions_archive) = subscriptions_archive { + batch.assert_value(PrincipalField::PushSubscriptions, subscriptions_archive); + } + + if !subscriptions.subscriptions.is_empty() { + batch.set( + PrincipalField::PushSubscriptions, + Archiver::new(subscriptions) + .serialize() + .caused_by(trc::location!())?, + ); + } else { + batch.clear(PrincipalField::PushSubscriptions); + } + + self.commit_batch(batch).await.caused_by(trc::location!())?; + + // Notify push manager + if self + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::PushServerUpdate { + account_id, + broadcast: true, + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push updates.", + CausedBy = trc::location!() + ); + } + } + + Ok(response) + } +} + +fn validate_push_value( + expected_id: Option, + property: &Key, + value: Value<'_, PushSubscriptionProperty, PushSubscriptionValue>, + push: &mut PushSubscription, + is_create: bool, + access_token: &AccessToken, +) -> Result<(), SetError> { + let Key::Property(property) = property else { + return Err(SetError::invalid_properties() + .with_property(property.to_owned()) + .with_description("Invalid property.")); + }; + + match (property, value) { + (PushSubscriptionProperty::DeviceClientId, Value::Str(value)) + if is_create && value.len() < 255 => + { + push.device_client_id = value.into_owned(); + } + (PushSubscriptionProperty::Url, Value::Str(value)) if is_create && value.len() < 512 => { + validate_push_url(value.as_ref()).map_err(|description| { + SetError::invalid_properties() + .with_property(property.clone()) + .with_description(description) + })?; + push.url = value.into_owned(); + } + (PushSubscriptionProperty::Keys, Value::Object(value)) if is_create && value.len() == 2 => { + if let (Some(auth), Some(p256dh)) = ( + value + .get(&Key::Property(PushSubscriptionProperty::Auth)) + .and_then(|v| v.as_str()) + .and_then(|v| URL_SAFE_INDIFFERENT.decode(v.as_ref()).ok()), + value + .get(&Key::Property(PushSubscriptionProperty::P256dh)) + .and_then(|v| v.as_str()) + .and_then(|v| URL_SAFE_INDIFFERENT.decode(v.as_ref()).ok()), + ) { + if p256::PublicKey::from_sec1_bytes(&p256dh).is_err() { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Invalid P-256 ECDH public key.")); + } + if auth.len() != 16 { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Invalid auth secret, expected 16 octets.")); + } + push.keys = Some(Keys { auth, p256dh }); + } else { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Failed to decode keys.")); + } + } + (PushSubscriptionProperty::Expires, Value::Element(PushSubscriptionValue::Date(value))) => { + let current_time = now() as i64; + let expires = value.timestamp(); + push.expires = if expires > current_time && (expires - current_time) > EXPIRES_MAX { + current_time + EXPIRES_MAX + } else { + expires + } as u64; + } + (PushSubscriptionProperty::Expires, Value::Null) => { + push.expires = now() + EXPIRES_MAX as u64; + } + (PushSubscriptionProperty::Types, Value::Array(value)) => { + push.types.clear(); + + for item in value { + if let Value::Element(PushSubscriptionValue::Types(dt)) = item { + push.types.insert(dt); + } else { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Invalid data type.")); + } + } + } + (PushSubscriptionProperty::VerificationCode, Value::Str(value)) if !is_create => { + if push.verification_code == value { + push.verified = true; + } else { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Verification code does not match.".to_string())); + } + } + (PushSubscriptionProperty::Keys, Value::Null) => { + push.keys = None; + } + (PushSubscriptionProperty::Types, Value::Null) => { + push.types = Bitmap::all(); + } + (PushSubscriptionProperty::VerificationCode, Value::Null) => {} + (PushSubscriptionProperty::EmailPush, Value::Null) => { + push.email_push.clear(); + } + (PushSubscriptionProperty::EmailPush, Value::Object(configs)) => { + push.email_push = parse_email_push(&configs, access_token)?; + } + (PushSubscriptionProperty::Id, value) => { + if !expected_id.is_some_and(|expected| crate::matches_id(&value, expected)) { + return Err(SetError::invalid_properties() + .with_property(PushSubscriptionProperty::Id) + .with_description("The id property is immutable.")); + } + } + (property, _) => { + return Err(SetError::invalid_properties() + .with_property(property.clone()) + .with_description("Field could not be set.")); + } + } + + if is_create && push.types.is_empty() { + push.types = Bitmap::all(); + } + + Ok(()) +} + +fn validate_push_url(url: &str) -> Result<(), &'static str> { + let url = Url::parse(url).map_err(|_| "Invalid push subscription URL.")?; + + if url.scheme() != "https" { + return Err("Push subscription URLs must use the https scheme."); + } + + if !url.username().is_empty() || url.password().is_some() { + return Err("Push subscription URLs must not contain credentials."); + } + + let Some(host) = url.host_str() else { + return Err("Push subscription URLs must contain a host."); + }; + + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + + if let Ok(ip) = host.parse::() { + #[cfg(feature = "test_mode")] + if ip.is_loopback() { + return Ok(()); + } + + if !is_global_ip(&ip) { + return Err("Push subscription URLs must not point to a local or reserved IP address."); + } + } + + Ok(()) +} + +fn parse_email_push( + configs: &Map<'_, PushSubscriptionProperty, PushSubscriptionValue>, + access_token: &AccessToken, +) -> Result, SetError> { + let mut result = Vec::with_capacity(configs.as_vec().len()); + for (account_key, config) in configs.iter() { + let account_id = Id::from_str(account_key.to_string().as_ref()) + .map(|id| id.document_id()) + .map_err(|_| email_push_error("Invalid account id in emailPush map."))?; + if !access_token.is_member(account_id) { + return Err(SetError::forbidden() + .with_description("No access to one of the accounts in the emailPush map.")); + } + let Some(config) = config.as_object() else { + return Err(email_push_error("EmailPushConfig must be an object.")); + }; + let mut email_push = EmailPush { + account_id, + ..Default::default() + }; + for (key, value) in config.iter() { + let key = key.to_string(); + hashify::fnc_map!(key.as_bytes(), + b"filter" => { + email_push.filter = as serde::Deserialize>::deserialize(value) + .map(|wrapper| wrapper.0) + .map_err(|_| email_push_error("Invalid filter."))?; + }, + b"properties" => { + let Some(properties) = value.as_array() else { + return Err(email_push_error( + "EmailPushConfig properties must be an array.", + )); + }; + for property in properties { + let Some(name) = property.as_str() else { + return Err(email_push_error("Email property must be a string.")); + }; + let property = ::try_parse(None, name.as_ref()) + .ok_or_else(|| email_push_error("Unknown email property."))?; + email_push.properties.push( + EmailPushProperty::try_from(&property) + .map_err(|_| email_push_error("Unsupported email push property."))?, + ); + } + }, + b"urgency" => { + email_push.urgency = parse_urgency(value)?; + }, + _ => { + return Err(email_push_error("Unknown EmailPushConfig property.")); + } + ); + } + result.push(email_push); + } + Ok(result) +} + +fn parse_urgency( + value: &Value<'_, PushSubscriptionProperty, PushSubscriptionValue>, +) -> Result> { + value + .as_str() + .and_then(|value| { + hashify::tiny_map!(value.as_bytes(), + "very-low" => Urgency::VeryLow, + "low" => Urgency::Low, + "normal" => Urgency::Normal, + "high" => Urgency::High, + ) + }) + .ok_or_else(|| email_push_error("Invalid urgency value.")) +} + +fn email_push_error(description: &'static str) -> SetError { + SetError::invalid_properties() + .with_property(PushSubscriptionProperty::EmailPush) + .with_description(description) +} + +#[cfg(test)] +mod tests { + use super::validate_push_url; + + #[test] + fn push_url_validation() { + for url in [ + "https://push.example.org/subscription/123", + "https://push.example.org:8443/subscription/123", + "HTTPS://push.example.org/subscription/123", + "https://8.8.8.8/push", + "https://[2606:4700::1111]/push", + "https://[64:ff9b::808:808]/push", + ] { + assert!(validate_push_url(url).is_ok(), "expected {url} to be valid"); + } + + for url in [ + "http://push.example.org/push", + "ftp://push.example.org/push", + "https://user:pass@push.example.org/push", + "not a url", + "https://", + "https://10.0.0.1/push", + "https://192.168.1.1/push", + "https://169.254.169.254/latest/meta-data/", + "https://100.100.100.200/push", + "https://[fd00::1]/push", + "https://[fe80::1]/push", + "https://[::ffff:127.0.0.1]/push", + "https://[64:ff9b::7f00:1]/push", + ] { + assert!( + validate_push_url(url).is_err(), + "expected {url} to be rejected" + ); + } + + for url in [ + "https://127.0.0.1/push", + "https://0177.0.0.1/push", + "https://2130706433/push", + "https://0x7f000001/push", + "https://[::1]/push", + ] { + let result = validate_push_url(url); + + if cfg!(feature = "test_mode") { + assert!( + result.is_ok(), + "expected {url} to be allowed under test_mode" + ); + } else { + assert!(result.is_err(), "expected {url} to be rejected"); + } + } + } +} diff --git a/crates/jmap/src/quota/get.rs b/crates/jmap/src/quota/get.rs new file mode 100644 index 0000000..b06875c --- /dev/null +++ b/crates/jmap/src/quota/get.rs @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::quota::{Quota, QuotaProperty, QuotaValue}, + types::state::State, +}; +use jmap_tools::{Map, Value}; +use std::{borrow::Cow, future::Future}; +use trc::AddContext; +use types::{id::Id, type_state::DataType}; + +pub trait QuotaGet: Sync + Send { + fn quota_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl QuotaGet for Server { + async fn quota_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + QuotaProperty::Id, + QuotaProperty::ResourceType, + QuotaProperty::Used, + QuotaProperty::WarnLimit, + QuotaProperty::SoftLimit, + QuotaProperty::HardLimit, + QuotaProperty::Scope, + QuotaProperty::Name, + QuotaProperty::Description, + QuotaProperty::Types, + ]); + let account_id = request.account_id.document_id(); + let account = self.account(account_id).await.caused_by(trc::location!())?; + let quota_ids = if account.disk_quota() > 0 { + vec![0u32] + } else { + vec![] + }; + let ids = if let Some(ids) = ids { + ids + } else { + quota_ids.iter().map(|id| Id::from(*id)).collect() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: State::Initial.into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + let account = if account_id == access_token.account_id() { + Cow::Borrowed(&account) + } else { + Cow::Owned(self.account(account_id).await.caused_by(trc::location!())?) + }; + + for id in ids { + // Obtain the sieve script object + let document_id = id.document_id(); + if !quota_ids.contains(&document_id) { + response.push_not_found(id); + continue; + } + + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + let value = match property { + QuotaProperty::Id => Value::Element(id.into()), + QuotaProperty::ResourceType => "octets".to_string().into(), + QuotaProperty::Used => { + (self.get_used_quota_account(account_id).await?.max(0) as u64).into() + } + QuotaProperty::HardLimit => account.as_ref().disk_quota().into(), + QuotaProperty::Scope => "account".to_string().into(), + QuotaProperty::Name => account.as_ref().name().to_string().into(), + QuotaProperty::Description => account + .as_ref() + .description + .as_ref() + .map(|s| s.to_string()) + .into(), + QuotaProperty::Types => vec![ + Value::Element(QuotaValue::Types(DataType::Email)), + Value::Element(QuotaValue::Types(DataType::SieveScript)), + Value::Element(QuotaValue::Types(DataType::FileNode)), + Value::Element(QuotaValue::Types(DataType::CalendarEvent)), + Value::Element(QuotaValue::Types(DataType::ContactCard)), + ] + .into(), + + _ => Value::Null, + }; + result.insert_unchecked(property.clone(), value); + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/quota/mod.rs b/crates/jmap/src/quota/mod.rs new file mode 100644 index 0000000..4a00aca --- /dev/null +++ b/crates/jmap/src/quota/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; diff --git a/crates/jmap/src/quota/query.rs b/crates/jmap/src/quota/query.rs new file mode 100644 index 0000000..ab137c1 --- /dev/null +++ b/crates/jmap/src/quota/query.rs @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::query::{QueryRequest, QueryResponse}, + object::quota::Quota, + types::state::State, +}; +use std::future::Future; +use types::id::Id; + +pub trait QuotaQuery: Sync + Send { + fn quota_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl QuotaQuery for Server { + async fn quota_query( + &self, + request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + Ok(QueryResponse { + account_id: request.account_id, + query_state: State::Initial, + can_calculate_changes: false, + position: 0, + ids: if self.account(access_token.account_id()).await?.disk_quota() > 0 { + vec![Id::new(0)] + } else { + vec![] + }, + total: Some(1), + limit: None, + }) + } +} diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs new file mode 100644 index 0000000..074eefa --- /dev/null +++ b/crates/jmap/src/registry/get.rs @@ -0,0 +1,402 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::{ + EnterpriseRegistry, + mapping::{ + RegistryGetResponse, account::account_get, bootstrap::bootstrap_get, + cluster::cluster_node_get, log::log_get, queued_message::queued_message_get, + report::report_get, spam_sample::spam_sample_get, task::task_get, + }, +}; +use common::{Server, auth::AccessToken, network::dkim::generate_dkim_public_key}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::registry::Registry, +}; +use jmap_tools::Key; +use registry::{ + jmap::{IntoValue, JmapValue, RegistryValue}, + schema::{ + enums::Permission, + prelude::{ + OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType, + Property, + }, + structs::Account, + }, + types::id::ObjectId, +}; +use store::{ahash::AHashSet, registry::RegistryQuery}; +use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub trait RegistryGet: Sync + Send { + fn registry_get( + &self, + object_type: ObjectType, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl RegistryGet for Server { + async fn registry_get( + &self, + object_type: ObjectType, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + // Initial assertions + if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) { + return Err(trc::JmapEvent::Forbidden.into_err().details(concat!( + "The server is in bootstrap mode. Only the 'Bootstrap' object type ", + "can be accessed until the bootstrap process is complete.", + ))); + } + self.assert_enterprise_object(object_type)?; + + let object_flags = object_type.flags(); + let is_tenant_filtered = + (object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some(); + let is_account_filtered = (object_flags & OBJ_FILTER_ACCOUNT) != 0 + && !access_token.has_permission(Permission::Impersonate); + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let has_properties = request.properties.is_some(); + let mut get = RegistryGetResponse { + access_token, + server: self, + account_id: request.account_id.document_id(), + object_type, + ids, + properties: request + .properties + .take() + .map(|p| p.unwrap()) + .unwrap_or_default() + .into_iter() + .filter_map(|prop| prop.try_unwrap()) + .collect::>(), + response: GetResponse { + account_id: request.account_id.into(), + state: None, + list: vec![], + not_found: not_found_ids, + }, + object_flags, + is_tenant_filtered, + is_account_filtered, + }; + if has_properties { + get.properties.insert(Property::Id); + } + + match object_type { + ObjectType::AcmeProvider + | ObjectType::AddressBook + | ObjectType::AiModel + | ObjectType::Alert + | ObjectType::AllowedIp + | ObjectType::Application + | ObjectType::Asn + | ObjectType::Authentication + | ObjectType::BlobStore + | ObjectType::BlockedIp + | ObjectType::Cache + | ObjectType::Calendar + | ObjectType::CalendarAlarm + | ObjectType::CalendarScheduling + | ObjectType::Certificate + | ObjectType::Coordinator + | ObjectType::DataRetention + | ObjectType::DataStore + | ObjectType::Directory + | ObjectType::DkimReportSettings + | ObjectType::DmarcReportSettings + | ObjectType::DnsResolver + | ObjectType::DnsServer + | ObjectType::Email + | ObjectType::Enterprise + | ObjectType::EventTracingLevel + | ObjectType::FileStorage + | ObjectType::Http + | ObjectType::HttpForm + | ObjectType::HttpLookup + | ObjectType::Imap + | ObjectType::InMemoryStore + | ObjectType::Jmap + | ObjectType::SystemSettings + | ObjectType::MemoryLookupKey + | ObjectType::MemoryLookupKeyValue + | ObjectType::Metrics + | ObjectType::MetricsStore + | ObjectType::MtaConnectionStrategy + | ObjectType::MtaDeliverySchedule + | ObjectType::MtaExtensions + | ObjectType::MtaHook + | ObjectType::MtaInboundSession + | ObjectType::MtaInboundThrottle + | ObjectType::MtaMilter + | ObjectType::MtaOutboundStrategy + | ObjectType::MtaOutboundThrottle + | ObjectType::MtaQueueQuota + | ObjectType::MtaRoute + | ObjectType::MtaStageAuth + | ObjectType::MtaStageConnect + | ObjectType::MtaStageData + | ObjectType::MtaStageEhlo + | ObjectType::MtaStageMail + | ObjectType::MtaStageRcpt + | ObjectType::MtaSts + | ObjectType::MtaTlsStrategy + | ObjectType::MtaVirtualQueue + | ObjectType::NetworkListener + | ObjectType::ClusterRole + | ObjectType::OidcProvider + | ObjectType::ReportSettings + | ObjectType::Search + | ObjectType::SearchStore + | ObjectType::Security + | ObjectType::SenderAuth + | ObjectType::Sharing + | ObjectType::SieveSystemInterpreter + | ObjectType::SieveSystemScript + | ObjectType::SieveUserInterpreter + | ObjectType::SieveUserScript + | ObjectType::SpamClassifier + | ObjectType::SpamDnsblServer + | ObjectType::SpamDnsblSettings + | ObjectType::SpamFileExtension + | ObjectType::SpamLlm + | ObjectType::SpamPyzor + | ObjectType::SpamRule + | ObjectType::SpamSettings + | ObjectType::SpamTag + | ObjectType::SpfReportSettings + | ObjectType::StoreLookup + | ObjectType::TaskManager + | ObjectType::TlsReportSettings + | ObjectType::Tracer + | ObjectType::TracingStore + | ObjectType::WebDav + | ObjectType::WebHook + | ObjectType::Account + | ObjectType::DsnReportSettings + | ObjectType::MailingList + | ObjectType::OAuthClient + | ObjectType::Role + | ObjectType::Tenant + | ObjectType::MaskedEmail + | ObjectType::PublicKey + | ObjectType::DkimSignature + | ObjectType::Domain => { + let is_singleton = (get.object_flags & OBJ_SINGLETON) != 0; + + let ids = if let Some(ids) = get.ids.take() { + ids + } else { + self.registry() + .query::>( + RegistryQuery::new(object_type) + .with_tenant(access_token.tenant_id()) + .with_account_opt(is_account_filtered.then_some(get.account_id)) + .with_limit(self.core.jmap.get_max_objects), + ) + .await + .caused_by(trc::location!())? + }; + get.response.list.reserve(ids.len()); + + for id in ids { + let object = if let Some(object) = self + .registry() + .get(ObjectId::new(object_type, id)) + .await + .caused_by(trc::location!())? + { + if (is_tenant_filtered + && access_token.tenant_id().map(Id::from) + != object.inner.member_tenant_id()) + || (is_account_filtered + && object.inner.account_id() != Some(Id::from(get.account_id))) + { + get.not_found(id); + continue; + } + object + } else if id.is_singleton() && is_singleton { + Object::from(object_type) + } else { + get.not_found(id); + continue; + }; + + let mut extra_properties: VecMap = VecMap::new(); + match &object.inner { + ObjectInner::DkimSignature(obj) + if get.properties.is_empty() + || get.properties.contains(&Property::PublicKey) => + { + if let Ok(public_key) = generate_dkim_public_key(obj).await { + extra_properties + .append(Property::PublicKey, JmapValue::Str(public_key.into())); + } + } + ObjectInner::Account(obj) => { + if get.properties.is_empty() + || get.properties.contains(&Property::UsedDiskQuota) + { + let quota = self.get_used_quota_account(id.document_id()).await?; + extra_properties.append( + Property::UsedDiskQuota, + JmapValue::Number(quota.into()), + ); + } + if get.properties.is_empty() + || get.properties.contains(&Property::EmailAddress) + { + let (name, domain_id) = match &obj { + Account::User(obj) => (obj.name.as_str(), obj.domain_id), + Account::Group(obj) => (obj.name.as_str(), obj.domain_id), + }; + let domain = self.domain_by_id(domain_id.document_id()).await?; + let email = format!( + "{}@{}", + name, + domain.as_ref().map(|d| d.name()).unwrap_or_default() + ); + extra_properties + .append(Property::EmailAddress, JmapValue::Str(email.into())); + } + } + ObjectInner::MailingList(obj) + if get.properties.is_empty() + || get.properties.contains(&Property::EmailAddress) => + { + let domain = self.domain_by_id(obj.domain_id.document_id()).await?; + let email = format!( + "{}@{}", + obj.name, + domain.as_ref().map(|d| d.name()).unwrap_or_default() + ); + extra_properties + .append(Property::EmailAddress, JmapValue::Str(email.into())); + } + ObjectInner::Tenant(obj) + if get.properties.is_empty() + || get.properties.contains(&Property::UsedDiskQuota) => + { + let quota = self.get_used_quota_tenant(id.document_id()).await?; + extra_properties + .append(Property::UsedDiskQuota, JmapValue::Number(quota.into())); + } + ObjectInner::Domain(obj) + if get.properties.is_empty() + || get.properties.contains(&Property::DnsZoneFile) => + { + extra_properties.append( + Property::DnsZoneFile, + JmapValue::Str(self.build_bind_dns_records(id, obj).await?.into()), + ); + } + ObjectInner::AcmeProvider(obj) + if get.properties.is_empty() + || get.properties.contains(&Property::Description) => + { + let mut description = obj.directory.clone(); + let account = obj + .account_uri + .rsplit('/') + .find(|segment| !segment.is_empty()) + .unwrap_or(obj.account_uri.as_str()); + if !account.is_empty() { + description.push_str(" ("); + description.push_str(account); + description.push(')'); + } + extra_properties + .append(Property::Description, JmapValue::Str(description.into())); + } + _ => {} + } + + let mut object = object.into_value(); + if !extra_properties.is_empty() + && let JmapValue::Object(obj) = &mut object + { + for (key, value) in extra_properties { + obj.insert_unchecked(key, value); + } + } + + get.insert(id, object); + } + + Ok(get.into_response()) + } + ObjectType::QueuedMessage => { + queued_message_get(get).await.map(|get| get.into_response()) + } + ObjectType::Task => task_get(get).await.map(|get| get.into_response()), + ObjectType::ClusterNode => cluster_node_get(get).await.map(|get| get.into_response()), + ObjectType::ArfExternalReport + | ObjectType::DmarcExternalReport + | ObjectType::TlsExternalReport + | ObjectType::DmarcInternalReport + | ObjectType::TlsInternalReport => report_get(get).await.map(|get| get.into_response()), + + ObjectType::SpamTrainingSample => { + spam_sample_get(get).await.map(|get| get.into_response()) + } + ObjectType::Log => log_get(get).await.map(|get| get.into_response()), + ObjectType::Bootstrap => bootstrap_get(get).await.map(|get| get.into_response()), + ObjectType::AccountSettings + | ObjectType::ApiKey + | ObjectType::AccountPassword + | ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()), + ObjectType::Action => Ok(get.not_found_any().into_response()), + #[cfg(not(feature = "enterprise"))] + _ => Ok(get.not_found_any().into_response()), + } + } +} + +impl RegistryGetResponse<'_> { + pub fn insert(&mut self, id: Id, mut object: JmapValue<'static>) { + let object_map = object.as_object_mut().unwrap(); + + if self.is_tenant_filtered && self.access_token.tenant_id().is_some() { + object_map.remove(&Key::Property(Property::MemberTenantId)); + } else if self.is_account_filtered { + object_map.remove(&Key::Property(Property::AccountId)); + } + + object_map.insert_unchecked(Property::Id, RegistryValue::Id(id)); + if !self.properties.is_empty() { + object_map.as_mut_vec().retain_mut(|(prop, _)| { + prop.as_property() + .is_some_and(|prop| self.properties.contains(prop)) + }); + } + self.response.list.push(object); + } + + pub fn not_found(&mut self, id: Id) { + self.response.push_not_found(id); + } + + pub fn not_found_any(mut self) -> Self { + for id in self.ids.take().unwrap_or_default() { + self.response.push_not_found(id); + } + self + } + + pub fn into_response(self) -> GetResponse { + self.response + } +} diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs new file mode 100644 index 0000000..4a67652 --- /dev/null +++ b/crates/jmap/src/registry/mapping/account.rs @@ -0,0 +1,974 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + mapping::{ + RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse, + principal::build_set_error, + }, + query::RegistryQueryFilters, + set::map_write_error, + }, +}; +use common::{ + Server, + auth::{ + AccessToken, Permissions, + credential::{ApiKey, AppPassword}, + permissions::BuildPermissions, + }, + cache::invalidate::CacheInvalidationBuilder, + ipc::CacheInvalidation, + storage::encryption::{EncryptionMethod, parse_public_key}, +}; +use directory::core::secret::{SecretVerificationResult, hash_secret, verify_mfa_secret_hash}; +use jmap_proto::{error::set::SetError, request::MaybeInvalid, types::state::State}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value}; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch, RegistryValue}, + schema::{ + enums::{CredentialType, StorageQuota}, + prelude::{MASKED_PASSWORD, Object, ObjectInner, ObjectType, Property}, + structs::{ + Account, AccountPassword, AccountSettings, Credential, CredentialPermissions, + EncryptionAtRest, OtpAuth, PublicKey, SecondaryCredential, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId}, +}; +use std::str::FromStr; +use store::{ + registry::{ + RegistryFilterOp, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::now, +}; +use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub(crate) async fn account_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + let item_id = Id::from(set.account_id); + let Some(object) = set + .server + .registry() + .get(ObjectId::new(ObjectType::Account, item_id)) + .await? + else { + set.fail_all(SetError::not_found()); + return Ok(set); + }; + let revision = object.revision; + let old_account = if let ObjectInner::Account(Account::User(account)) = object.inner { + account + } else { + set.fail_all(SetError::not_found()); + return Ok(set); + }; + let mut account = old_account.clone(); + + match set.object_type { + ObjectType::AccountSettings => { + 'outer: for (id, value) in set.update.drain(..) { + if id != Id::singleton() { + set.response.not_updated.append(id, SetError::not_found()); + } + + for (key, value) in value.into_expanded_object() { + if let Key::Property( + property @ (Property::EncryptionAtRest + | Property::Locale + | Property::Description + | Property::TimeZone), + ) = key + { + let ptr = + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]); + if let Err(err) = + account.patch(JsonPointerPatch::new(&ptr).with_create(false), value) + { + set.response.not_updated.append(id, err.into()); + break 'outer; + } + } else { + set.response.not_updated.append( + id, + SetError::invalid_properties().with_property(key.into_owned()), + ); + break 'outer; + } + } + + if account.encryption_at_rest != old_account.encryption_at_rest + && let Some(algorithm) = + unsupported_pgp_algorithm(set.server, &account.encryption_at_rest).await? + { + account = old_account.clone(); + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::EncryptionAtRest) + .with_description(format!( + "{algorithm} is only supported for S/MIME encryption, but the selected public key is an OpenPGP key." + )), + ); + break 'outer; + } + + set.response.updated.append(id, None); + } + } + ObjectType::AccountPassword => { + if let Some(old_credential) = account.credentials.values_mut().find_map(|credential| { + if let Credential::Password(pass) = credential { + Some(pass) + } else { + None + } + }) { + 'outer: for (id, value) in set.update.drain(..) { + if id != Id::singleton() { + set.response.not_updated.append(id, SetError::not_found()); + } + + let mut account_pass = AccountPassword { + secret: None, + current_secret: None, + otp_auth: OtpAuth { + otp_code: None, + otp_url: if old_credential.otp_auth.is_some() { + Some(MASKED_PASSWORD.to_string()) + } else { + None + }, + }, + }; + + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + + match account_pass + .patch(JsonPointerPatch::new(&ptr).with_create(false), value) + { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { .. }) + | Ok(MaybeUnpatched::UnpatchedMany { .. }) => { + set.response + .not_updated + .append(id, SetError::invalid_properties()); + continue 'outer; + } + Err(err) => { + set.response.not_updated.append(id, err.into()); + continue 'outer; + } + } + } + + let is_empty_secret = account_pass + .secret + .as_ref() + .is_none_or(|secret| secret == MASKED_PASSWORD); + let is_empty_otp = account_pass.otp_auth.otp_url.as_deref() + == Some(MASKED_PASSWORD) + || (account_pass.otp_auth.otp_url.is_none() + && old_credential.otp_auth.is_none()); + if !is_empty_secret || !is_empty_otp { + let user_provided_secret = if !is_empty_secret { + account_pass.secret.as_ref().unwrap() + } else { + old_credential.secret.as_str() + }; + if is_empty_otp { + account_pass.otp_auth.otp_url = old_credential.otp_auth.clone(); + } + + // Password changes are not supported when using external directories + if (user_provided_secret != old_credential.secret + || account_pass.otp_auth.otp_url != old_credential.otp_auth) + && set + .server + .domain_by_id(account.domain_id.document_id()) + .await? + .and_then(|domain| { + set.server.get_directory_for_cached_domain(&domain) + }) + .is_some() + { + set.response.not_updated.append( + id, + SetError::forbidden().with_description("Operation not allowed."), + ); + continue 'outer; + } + + if user_provided_secret != old_credential.secret + || account_pass.otp_auth.otp_url != old_credential.otp_auth + { + if old_credential.secret.is_empty() { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Cannot set a password or OTP auth on an account that doesn't have one.", + ), + ); + continue 'outer; + } + + let current_otp_code = account_pass.otp_auth.otp_code; + if let Some(current_secret) = account_pass.current_secret { + match verify_mfa_secret_hash( + old_credential.otp_auth.as_deref(), + current_otp_code.as_deref(), + &old_credential.secret, + current_secret.as_ref(), + ) + .await? + { + SecretVerificationResult::Valid => {} + SecretVerificationResult::Invalid => { + let account = set.server.account(set.account_id).await?; + if set.server.has_auth_fail2ban() + && set + .server + .is_auth_fail2banned( + set.remote_ip, + account.name().into(), + ) + .await? + { + return Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .details( + "Too many failed password change attempts.", + ) + .ctx(trc::Key::RemoteIp, set.remote_ip) + .ctx( + trc::Key::AccountName, + account.name().to_string(), + )); + } else { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current secret is incorrect.", + ), + ); + continue 'outer; + } + } + SecretVerificationResult::MissingMfaToken => { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current OTP code is required to change the password or OTP auth.", + ), + ); + continue 'outer; + } + } + + if user_provided_secret != old_credential.secret { + if let Err(err) = + set.server.is_secure_password(user_provided_secret, &[]) + { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::Secret) + .with_description(err), + ); + continue 'outer; + } + + if let Some(expires_at) = + set.server.core.network.security.password_default_expiration + { + old_credential.expires_at = + Some(UTCDateTime::from_timestamp( + (now() + expires_at) as i64, + )); + } else if old_credential + .expires_at + .is_some_and(|exp| exp.timestamp() <= now() as i64) + { + old_credential.expires_at = None; + } + + old_credential.secret = hash_secret( + set.server.core.network.security.password_hash_algorithm, + user_provided_secret.as_bytes().to_vec(), + ) + .await + .caused_by(trc::location!())?; + } + + if account_pass.otp_auth.otp_url != old_credential.otp_auth { + old_credential.otp_auth = account_pass.otp_auth.otp_url; + } + } else { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current secret must be provided to change the password or OTP auth.", + ), + ); + continue 'outer; + } + } + } + + set.response.updated.append(id, None); + break; + } + } else { + set.fail_all( + SetError::forbidden() + .with_description("Your account does not support password changes"), + ); + } + } + + ObjectType::AppPassword | ObjectType::ApiKey => { + // Process creations + if !set.create.is_empty() { + let account_cache = set.server.account(set.account_id).await?; + let app_pass_quota = set + .server + .object_quota(account_cache.object_quotas(), StorageQuota::MaxAppPasswords); + let api_key_quota = set + .server + .object_quota(account_cache.object_quotas(), StorageQuota::MaxApiKeys); + let mut last_credential_id = 0; + let mut app_pass_total = 0; + let mut api_key_total = 0; + + for credential in account.credentials.values() { + match credential { + Credential::Password(c) => { + let credential_id = c.credential_id.id(); + if credential_id > last_credential_id { + last_credential_id = credential_id; + } + } + Credential::AppPassword(c) => { + let credential_id = c.credential_id.id(); + if credential_id > last_credential_id { + last_credential_id = credential_id; + } + app_pass_total += 1; + } + Credential::ApiKey(c) => { + let credential_id = c.credential_id.id(); + if credential_id > last_credential_id { + last_credential_id = credential_id; + } + api_key_total += 1; + } + } + } + + 'outer: for (id, value) in set.create.drain() { + let mut credential = SecondaryCredential::default(); + + // Patch object + match credential.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + Ok(MaybeUnpatched::Patched) => {} + Ok( + MaybeUnpatched::Unpatched { .. } | MaybeUnpatched::UnpatchedMany { .. }, + ) => { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_description("Cannot set property during creation."), + ); + continue 'outer; + } + Err(err) => { + set.response.not_created.append(id, err.into()); + continue 'outer; + } + } + + // Validate credential + match set.object_type { + ObjectType::AppPassword => { + if app_pass_total >= app_pass_quota { + set.response.not_created.append( + id, + SetError::over_quota().with_description(format!( + "You have exceeded your quota of {} app passwords.", + app_pass_quota + )), + ); + continue 'outer; + } + if let Err(err) = + validate_credential_permissions(set.access_token, &credential) + { + set.response.not_created.append(id, err); + continue 'outer; + } + + // Assign id + last_credential_id += 1; + app_pass_total += 1; + credential.credential_id = last_credential_id.into(); + + // Generate App password and hash secret + let app_pass = AppPassword::new(last_credential_id as u32); + credential.secret = hash_secret( + set.server.core.network.security.password_hash_algorithm, + app_pass.secret.to_vec(), + ) + .await + .caused_by(trc::location!())?; + + // Add credential to account + account + .credentials + .push(Credential::AppPassword(credential)); + + set.response.created.insert( + id, + Value::Object(Map::from(vec![ + ( + Key::Property(Property::Id), + Value::Element(RegistryValue::Id( + last_credential_id.into(), + )), + ), + ( + Key::Property(Property::Secret), + Value::Str(app_pass.build().into()), + ), + ])), + ); + } + ObjectType::ApiKey => { + if api_key_total >= api_key_quota { + set.response.not_created.append( + id, + SetError::over_quota().with_description(format!( + "You have exceeded your quota of {} API keys.", + api_key_quota + )), + ); + continue 'outer; + } + if let Err(err) = + validate_credential_permissions(set.access_token, &credential) + { + set.response.not_created.append(id, err); + continue 'outer; + } + + // Assign id + last_credential_id += 1; + api_key_total += 1; + credential.credential_id = last_credential_id.into(); + + // Generate API key and hash secret + let api_key = ApiKey::new(set.account_id, last_credential_id as u32); + credential.secret = hash_secret( + set.server.core.network.security.password_hash_algorithm, + api_key.secret.to_vec(), + ) + .await + .caused_by(trc::location!())?; + + // Add credential to account + account.credentials.push(Credential::ApiKey(credential)); + + set.response.created.insert( + id, + Value::Object(Map::from(vec![ + ( + Key::Property(Property::Id), + Value::Element(RegistryValue::Id( + last_credential_id.into(), + )), + ), + ( + Key::Property(Property::Secret), + Value::Str(api_key.build().into()), + ), + ])), + ); + } + _ => unreachable!(), + } + } + } + + // Process updates + 'outer: for (id, value) in set.update.drain(..) { + if let Some(mut old_credential) = account + .credentials + .values_mut() + .find(|credential| credential.credential_id() == id) + { + let mut credential = old_credential.clone(); + let mut unpatched_properties = VecMap::new(); + + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + + match credential + .patch(JsonPointerPatch::new(&ptr).with_create(false), value) + { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched_properties.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + if unpatched_properties.is_empty() { + unpatched_properties = properties; + } else { + unpatched_properties.extend(properties); + } + } + Err(err) => { + set.response.not_updated.append(id, err.into()); + continue 'outer; + } + } + } + + if &credential == old_credential { + set.response.updated.append(id, None); + continue 'outer; + } + + match (&mut credential, &mut old_credential) { + ( + Credential::AppPassword(credential), + Credential::AppPassword(old_credential), + ) + | (Credential::ApiKey(credential), Credential::ApiKey(old_credential)) + if credential.secret != old_credential.secret => + { + // Paranoid check, this is verified in the patch implementation + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Cannot change the value of an app password or API key.", + ), + ); + continue 'outer; + } + _ => {} + } + + if let Credential::AppPassword(new_sc) | Credential::ApiKey(new_sc) = + &credential + && let Credential::AppPassword(old_sc) | Credential::ApiKey(old_sc) = + &*old_credential + && old_sc.permissions != new_sc.permissions + && let Err(err) = validate_credential_permissions(set.access_token, new_sc) + { + set.response.not_updated.append(id, err); + continue 'outer; + } + + *old_credential = credential; + + set.response.updated.append(id, None); + } else { + set.response.not_updated.append(id, SetError::not_found()); + } + } + + // Process deletions + for id in set.destroy.drain(..) { + if let Some(idx) = account + .credentials + .0 + .inner + .iter_mut() + .position(|c| c.value.credential_id() == id) + { + let credentials = &mut account.credentials.inner_mut().inner; + if !matches!(credentials[idx].value, Credential::Password(_)) { + credentials.remove(idx); + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append( + id, + SetError::forbidden().with_description( + "Users are not allowed to destroy their own credentials.", + ), + ); + } + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + } + _ => unreachable!(), + } + + if account != old_account { + let mut cache_invalidator = CacheInvalidationBuilder::default(); + if account.encryption_at_rest != old_account.encryption_at_rest + || account.description != old_account.description + || account.locale != old_account.locale + { + cache_invalidator.invalidate(CacheInvalidation::Account(set.account_id)); + } + if account.credentials != old_account.credentials { + cache_invalidator.invalidate(CacheInvalidation::AccessToken(set.account_id)); + } + + let object = Object::new(ObjectInner::Account(Account::User(account))); + let old_object = Object::with_revision( + ObjectInner::Account(Account::User(old_account.clone())), + revision, + ); + + match set + .server + .registry() + .write(RegistryWrite::Update { + object: &object, + id: item_id, + old_object: &old_object, + }) + .await? + { + RegistryWriteResult::Success(_) => { + // Invalidate caches + set.server.invalidate_caches(cache_invalidator).await?; + } + err => { + let err = map_write_error(err); + let failed_create = set + .response + .created + .into_keys() + .map(|id| (id, err.clone())) + .collect::>(); + let failed_update = set + .response + .updated + .into_keys() + .map(|id| (MaybeInvalid::Value(id), err.clone())) + .collect::>(); + let failed_delete = set + .response + .destroyed + .into_iter() + .map(|id| (MaybeInvalid::Value(id), err.clone())) + .collect::>(); + + set.response.not_created.extend(failed_create); + set.response.not_updated.extend(failed_update); + set.response.not_destroyed.extend(failed_delete); + set.response.created = Default::default(); + set.response.updated = Default::default(); + set.response.destroyed = Default::default(); + } + } + } + + Ok(set) +} + +pub(crate) async fn account_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let Some(Account::User(account)) = get + .server + .registry() + .object::(get.account_id.into()) + .await? + else { + return Ok(get.not_found_any()); + }; + + match get.object_type { + ObjectType::AccountSettings => { + let mut ids = get + .ids + .take() + .unwrap_or_else(|| vec![Id::singleton()]) + .into_iter(); + + for id in ids.by_ref() { + if id == Id::singleton() { + get.insert( + id, + AccountSettings { + encryption_at_rest: account.encryption_at_rest, + locale: account.locale, + description: account.description, + time_zone: account.time_zone, + } + .into_value(), + ); + break; + } else { + get.not_found(id); + } + } + + get.response.not_found.extend(ids.map(MaybeInvalid::Value)); + } + ObjectType::AccountPassword => { + let mut ids = get + .ids + .take() + .unwrap_or_else(|| vec![Id::singleton()]) + .into_iter(); + + for id in ids.by_ref() { + if id == Id::singleton() + && let Some(pass) = account.credentials.iter().find_map(|pass| { + if let Credential::Password(pass) = pass { + Some(pass) + } else { + None + } + }) + { + get.insert( + id, + AccountPassword { + current_secret: None, + otp_auth: OtpAuth { + otp_code: None, + otp_url: if pass.otp_auth.is_some() { + MASKED_PASSWORD.to_string().into() + } else { + None + }, + }, + secret: MASKED_PASSWORD.to_string().into(), + } + .into_value(), + ); + break; + } else { + get.not_found(id); + } + } + + get.response.not_found.extend(ids.map(MaybeInvalid::Value)); + } + ObjectType::ApiKey | ObjectType::AppPassword => { + let mut ids = if let Some(ids) = get.ids.take() { + ids + } else { + account + .credentials + .values() + .map(|credential| credential.credential_id()) + .collect::>() + }; + + for credential in account.credentials { + match (credential, get.object_type) { + (Credential::AppPassword(pass), ObjectType::AppPassword) + | (Credential::ApiKey(pass), ObjectType::ApiKey) + if ids.contains(&pass.credential_id) => + { + let id = pass.credential_id; + let mut credential = pass.into_value(); + credential + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| !matches!(k, Key::Property(Property::CredentialId))); + get.insert(id, credential); + ids.retain(|i| i != &id); + } + _ => {} + } + } + + for id in ids { + get.not_found(id); + } + } + _ => unreachable!(), + } + + Ok(get) +} + +pub(crate) async fn credential_query( + mut query: RegistryQueryResponse<'_>, +) -> trc::Result { + let Some(Account::User(account)) = query + .server + .registry() + .object::(query.request.account_id) + .await? + else { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("Account not found.")); + }; + + let credential_type = match query.object_type { + ObjectType::AppPassword => CredentialType::AppPassword, + ObjectType::ApiKey => CredentialType::ApiKey, + _ => unreachable!(), + }; + let mut expires_at_filter = None; + + query + .request + .extract_filters(|property, op, value| match property { + Property::ExpiresAt => { + if let Some(value) = value + .as_str() + .and_then(|value| UTCDateTime::from_str(value).ok()) + { + expires_at_filter = Some((op, value)); + true + } else { + false + } + } + _ => false, + })?; + + let mut matches = Vec::new(); + for credential in account.credentials.iter() { + if credential.object_type() == credential_type { + let (credential_id, expires_at) = match credential { + Credential::AppPassword(credential) => { + (credential.credential_id, credential.expires_at) + } + Credential::ApiKey(credential) => (credential.credential_id, credential.expires_at), + _ => unreachable!(), + }; + if expires_at_filter.is_none_or(|(op, filter_value)| { + expires_at.is_some_and(|expires_at| match op { + RegistryFilterOp::Equal => expires_at == filter_value, + RegistryFilterOp::GreaterThan => expires_at > filter_value, + RegistryFilterOp::GreaterEqualThan => expires_at >= filter_value, + RegistryFilterOp::LowerThan => expires_at < filter_value, + RegistryFilterOp::LowerEqualThan => expires_at <= filter_value, + RegistryFilterOp::TextMatch => false, + }) + }) { + matches.push((credential_id, expires_at)); + } + } + } + + let params = query + .request + .extract_parameters(query.server.core.jmap.query_max_results, None)?; + + match params.sort_by { + Property::ExpiresAt => { + if params.sort_ascending { + matches.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + } else { + matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + } + } + Property::Id => { + if params.sort_ascending { + matches.sort_by_key(|a| a.0); + } else { + matches.sort_by_key(|b| std::cmp::Reverse(b.0)); + } + } + property => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!( + "Property {} is not supported for sorting", + property + ))); + } + } + + // Build response + let mut response = QueryResponseBuilder::new( + matches.len(), + query.server.core.jmap.query_max_results, + State::Initial, + &query.request, + ); + + for (id, _) in matches { + if !response.add_id(id) { + break; + } + } + + Ok(response) +} + +async fn unsupported_pgp_algorithm( + server: &Server, + encryption_at_rest: &EncryptionAtRest, +) -> trc::Result> { + let (settings, algorithm) = match encryption_at_rest { + EncryptionAtRest::Aes256Gcm(settings) => (settings, "AES-256-GCM"), + EncryptionAtRest::ChaCha20Poly1305(settings) => (settings, "ChaCha20-Poly1305"), + _ => return Ok(None), + }; + + if let Some(public_key) = server + .registry() + .object::(settings.public_key) + .await + .caused_by(trc::location!())? + && matches!( + parse_public_key(&public_key), + Ok(Some(params)) if params.method == EncryptionMethod::PGP + ) + { + Ok(Some(algorithm)) + } else { + Ok(None) + } +} + +pub(crate) fn validate_credential_permissions( + access_token: &AccessToken, + credential: &SecondaryCredential, +) -> Result<(), SetError> { + let effective = match &credential.permissions { + CredentialPermissions::Inherit => access_token.account_permissions().clone(), + CredentialPermissions::Disable(list) => { + let mut effective = access_token.account_permissions().clone(); + effective.clear_many(&Permissions::from_permission(list.permissions.as_slice())); + effective + } + CredentialPermissions::Replace(list) => { + Permissions::from_permission(list.permissions.as_slice()) + } + }; + + access_token + .can_grant_permissions(effective) + .map_err(build_set_error) +} diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs new file mode 100644 index 0000000..88ece99 --- /dev/null +++ b/crates/jmap/src/registry/mapping/action.rs @@ -0,0 +1,575 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error}; +use common::{ + Server, + config::mailstore::spamfilter::SpamFilterAction, + ipc::{BroadcastEvent, QueueEvent, RegistryChange}, +}; +use jmap_proto::error::set::{SetError, SetErrorType}; +use jmap_tools::{JsonPointer, Key}; +use mail_auth::{ + AuthenticatedMessage, Dkim2Result, DkimResult, DmarcResult, dkim2::Envelope as Dkim2Envelope, + dmarc::verify::DmarcParameters, spf::verify::SpfParameters, +}; +use mail_parser::MessageParser; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch}, + schema::{ + enums::{SpamClassifyParameters, SpamClassifyResult, SpamClassifyTagDisposition}, + prelude::{ObjectType, Property}, + structs::{Action, DmarcTroubleshoot, SpamClassify, SpamClassifyTag}, + }, + types::{EnumImpl, ObjectImpl}, +}; +use smtp_proto::{MAIL_BODY_7BIT, MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; +use spam_filter::{ + SpamFilterInput, + analysis::{init::SpamFilterInit, score::SpamFilterAnalyzeScore}, +}; +use std::time::Instant; +use store::{registry::bootstrap::Bootstrap, write::now}; +use utils::map::vec_map::VecMap; + +pub(crate) async fn action_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + // Actions cannot be uodated or destroyed, so we fail all updates and destroys. + set.fail_all_update("Actions cannot be updated"); + set.fail_all_destroy("Actions cannot be destroyed"); + + // Process creations + 'outer: for (id, value) in set.create.drain() { + let mut action = Action::default(); + if let Err(err) = action.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; + } + + let mut validation_errors = Vec::new(); + if !action.validate(&mut validation_errors) { + set.response.not_created.append( + id, + SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(validation_errors), + ); + continue 'outer; + } + + if !set.access_token.has_permission(action.permission()) { + set.response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "Insufficient permissions to perform action of type {}", + action.object_type().as_str() + )), + ); + continue 'outer; + } + + match action { + Action::ReloadSettings + | Action::ReloadTlsCertificates + | Action::ReloadLookupStores + | Action::ReloadBlockedIps => { + let object = match action { + Action::ReloadSettings => ObjectType::DataStore, + Action::ReloadTlsCertificates => ObjectType::Certificate, + Action::ReloadLookupStores => ObjectType::StoreLookup, + Action::ReloadBlockedIps => ObjectType::BlockedIp, + _ => unreachable!(), + }; + let result = + Box::pin(set.server.reload_registry(RegistryChange::Reload(object))).await?; + + if !result.has_errors() { + set.server + .cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload( + object, + ))) + .await; + set.response.created(id, now()); + } else { + set.response + .not_created + .append(id, map_bootstrap_error(result.errors)); + } + } + Action::InvalidateCaches => { + set.server.invalidate_all_local_caches(); + set.server + .cluster_broadcast(BroadcastEvent::CacheInvalidateAll) + .await; + set.response.created(id, now()); + } + Action::InvalidateNegativeCaches => { + set.server.invalidate_all_local_negative_caches(); + set.server + .cluster_broadcast(BroadcastEvent::CacheInvalidateNegative) + .await; + set.response.created(id, now()); + } + Action::PauseMtaQueue => { + let _ = set + .server + .inner + .ipc + .queue_tx + .send(QueueEvent::Paused(true)) + .await; + set.server + .cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: false }) + .await; + set.response.created(id, now()); + } + Action::ResumeMtaQueue => { + let _ = set + .server + .inner + .ipc + .queue_tx + .send(QueueEvent::Paused(false)) + .await; + set.server + .cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: true }) + .await; + set.response.created(id, now()); + } + Action::TroubleshootDmarc(troubleshoot) => { + if let Some(result) = dmarc_troubleshoot(set.server, troubleshoot).await { + let mut result = result.into_value(); + result + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| { + !matches!( + k, + Key::Property( + Property::Message + | Property::RemoteIp + | Property::EhloDomain + | Property::MailFrom + | Property::To + ) + ) + }); + set.response.created.insert(id, result); + } else { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::Body) + .with_description( + "Failed to parse the message for DMARC troubleshooting".to_string(), + ), + ); + } + } + Action::ClassifySpam(classify) => { + if let Some(result) = classify_spam(set.server, classify).await { + let mut result = result.into_value(); + result + .as_object_mut() + .unwrap() + .as_mut_vec() + .retain(|(k, _)| { + !matches!( + k, + Key::Property( + Property::Message + | Property::RemoteIp + | Property::EhloDomain + | Property::AuthenticatedAs + | Property::IsTls + | Property::EnvFrom + | Property::EnvFromParameters + | Property::EnvRcptTo + ) + ) + }); + set.response.created.insert(id, result); + } else { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::Message) + .with_description( + "Failed to parse the message for spam classification".to_string(), + ), + ); + } + } + Action::UpdateApps => { + let mut bp = Bootstrap::new_uninitialized(set.server.registry().clone()); + set.server.inner.data.applications.reload(&mut bp).await; + if bp.errors.is_empty() { + set.server + .inner + .data + .applications + .unpack_all(set.server, true) + .await; + set.server + .cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload( + ObjectType::Application, + ))) + .await; + set.response.created(id, now()); + } else { + set.response + .not_created + .append(id, map_bootstrap_error(bp.errors)); + } + } + } + } + + Ok(set) +} + +async fn classify_spam(server: &Server, mut request: SpamClassify) -> Option { + // Built spam filter input + let raw_message = request.message.as_bytes(); + let message = MessageParser::new() + .parse(raw_message) + .filter(|m| m.root_part().headers().iter().any(|h| !h.name.is_other()))?; + + let remote_ip = request.remote_ip.into_inner(); + let ehlo_domain = request.ehlo_domain.to_lowercase(); + let mail_from = request.env_from.to_lowercase(); + let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain); + let local_host = &server.core.network.server_name; + + let spf_ehlo_result = server + .core + .smtp + .resolvers + .dns + .verify_spf( + server + .inner + .cache + .build_auth_parameters(SpfParameters::verify_ehlo( + remote_ip, + &ehlo_domain, + local_host, + )), + ) + .await; + + let iprev_result = server + .core + .smtp + .resolvers + .dns + .verify_iprev(server.inner.cache.build_auth_parameters(remote_ip)) + .await; + + let spf_mail_from_result = if let Some(mail_from_domain) = mail_from_domain { + server + .core + .smtp + .resolvers + .dns + .check_host(server.inner.cache.build_auth_parameters(SpfParameters::new( + remote_ip, + mail_from_domain, + &ehlo_domain, + local_host, + &mail_from, + ))) + .await + } else { + server + .core + .smtp + .resolvers + .dns + .check_host(server.inner.cache.build_auth_parameters(SpfParameters::new( + remote_ip, + &ehlo_domain, + &ehlo_domain, + local_host, + &format!("postmaster@{ehlo_domain}"), + ))) + .await + }; + + let auth_message = AuthenticatedMessage::from_parsed(&message, raw_message, true); + + let dkim_output = server + .core + .smtp + .resolvers + .dns + .verify_dkim(server.inner.cache.build_auth_parameters(&auth_message)) + .await; + + let arc_output = server + .core + .smtp + .resolvers + .dns + .verify_arc(server.inner.cache.build_auth_parameters(&auth_message)) + .await; + + let dkim2_output = server + .core + .smtp + .resolvers + .dns + .verify_dkim2( + server.inner.cache.build_auth_parameters(&auth_message), + Dkim2Envelope { + mail_from: &mail_from, + rcpt_to: request.env_rcpt_to.iter(), + }, + ) + .await; + + let dmarc_output = server + .core + .smtp + .resolvers + .dns + .verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters { + message: &auth_message, + dkim_output: &dkim_output, + dkim2_output: Some(&dkim2_output), + rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()), + spf_output: &spf_mail_from_result, + })) + .await; + let dmarc_result = dmarc_output.result(); + let dmarc_policy = dmarc_output.policy(); + + let asn_geo = server.lookup_asn_country(remote_ip).await; + + let input = SpamFilterInput { + message: &message, + span_id: 0, + arc_result: Some(&arc_output), + spf_ehlo_result: Some(&spf_ehlo_result), + spf_mail_from_result: Some(&spf_mail_from_result), + dkim_result: dkim_output.as_slice(), + dkim2_result: Some(&dkim2_output), + dmarc_result: Some(&dmarc_result), + dmarc_policy: Some(&dmarc_policy), + iprev_result: Some(&iprev_result), + remote_ip, + ehlo_domain: Some(ehlo_domain.as_str()), + authenticated_as: request.authenticated_as.as_deref(), + asn: asn_geo.asn.as_ref().map(|a| a.id), + country: asn_geo.country.as_ref().map(|c| c.as_str()), + is_tls: request.is_tls, + env_from: &request.env_from, + env_from_flags: match request.env_from_parameters { + Some(SpamClassifyParameters::Bit7) => MAIL_BODY_7BIT, + Some(SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent) => MAIL_BODY_BINARYMIME, + Some(SpamClassifyParameters::BinaryMime) => MAIL_BODY_8BITMIME, + Some(SpamClassifyParameters::SmtpUtf8) => MAIL_SMTPUTF8, + None => 0, + }, + env_rcpt_orig_to: request.env_rcpt_to.iter().map(String::as_str).collect(), + env_rcpt_rewritten_to: request.env_rcpt_to.iter().map(String::as_str).collect(), + is_test: true, + is_train: false, + }; + + // Classify + let mut ctx = server.spam_filter_init(input); + let result = server.spam_filter_classify(&mut ctx).await; + + // Build response + request.result = match result { + SpamFilterAction::Allow(result) => { + request.score = (result.score as f64).into(); + if result.is_spam { + SpamClassifyResult::Spam + } else { + SpamClassifyResult::Ham + } + } + SpamFilterAction::Discard => SpamClassifyResult::Discard, + SpamFilterAction::Reject | SpamFilterAction::Disabled => SpamClassifyResult::Reject, + }; + + request.tags = VecMap::with_capacity(ctx.result.tags.len()); + for tag in ctx.result.tags { + let (score, disposition) = match server.core.spam.lists.scores.get(&tag) { + Some(SpamFilterAction::Allow(score)) => (*score, SpamClassifyTagDisposition::Score), + Some(SpamFilterAction::Discard) => (0.0, SpamClassifyTagDisposition::Discard), + _ => (0.0, SpamClassifyTagDisposition::Reject), + }; + request.tags.append( + tag, + SpamClassifyTag { + disposition, + score: (score as f64).into(), + }, + ); + } + + Some(request) +} + +async fn dmarc_troubleshoot( + server: &Server, + mut request: DmarcTroubleshoot, +) -> Option { + let remote_ip = request.remote_ip.into_inner(); + let ehlo_domain = request.ehlo_domain.to_lowercase(); + let mail_from = request.mail_from.to_lowercase(); + let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain); + + let local_host = &server.core.network.server_name; + + let now = Instant::now(); + let ehlo_spf_output = server + .core + .smtp + .resolvers + .dns + .verify_spf( + server + .inner + .cache + .build_auth_parameters(SpfParameters::verify_ehlo( + remote_ip, + &ehlo_domain, + local_host, + )), + ) + .await; + + let iprev = server + .core + .smtp + .resolvers + .dns + .verify_iprev(server.inner.cache.build_auth_parameters(remote_ip)) + .await; + let mail_spf_output = if let Some(mail_from_domain) = mail_from_domain { + server + .core + .smtp + .resolvers + .dns + .check_host(server.inner.cache.build_auth_parameters(SpfParameters::new( + remote_ip, + mail_from_domain, + &ehlo_domain, + local_host, + &mail_from, + ))) + .await + } else { + server + .core + .smtp + .resolvers + .dns + .check_host(server.inner.cache.build_auth_parameters(SpfParameters::new( + remote_ip, + &ehlo_domain, + &ehlo_domain, + local_host, + &format!("postmaster@{ehlo_domain}"), + ))) + .await + }; + + let body = request + .message + .take() + .unwrap_or_else(|| format!("From: {mail_from}\r\nSubject: test\r\n\r\ntest")); + let auth_message = AuthenticatedMessage::parse_with_opts(body.as_bytes(), None, true)?; + + let dkim_output = server + .core + .smtp + .resolvers + .dns + .verify_dkim(server.inner.cache.build_auth_parameters(&auth_message)) + .await; + let dkim_pass = dkim_output + .iter() + .any(|d| matches!(d.result(), DkimResult::Pass)); + + let dkim2_output = server + .core + .smtp + .resolvers + .dns + .verify_dkim2( + server.inner.cache.build_auth_parameters(&auth_message), + Dkim2Envelope { + mail_from: &mail_from, + rcpt_to: request.to.iter(), + }, + ) + .await; + let dkim2_pass = matches!(dkim2_output.result(), Dkim2Result::Pass); + + let arc_output = server + .core + .smtp + .resolvers + .dns + .verify_arc(server.inner.cache.build_auth_parameters(&auth_message)) + .await; + + let dmarc_output = server + .core + .smtp + .resolvers + .dns + .verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters { + message: &auth_message, + dkim_output: &dkim_output, + dkim2_output: Some(&dkim2_output), + rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()), + spf_output: &mail_spf_output, + })) + .await; + let dmarc_result = dmarc_output.result(); + let dmarc_pass = dmarc_result == DmarcResult::Pass; + + request.spf_ehlo_domain = ehlo_spf_output.domain().to_string(); + request.spf_ehlo_result = (&ehlo_spf_output).into(); + request.spf_mail_from_domain = mail_spf_output.domain().to_string(); + request.spf_mail_from_result = (&mail_spf_output).into(); + request.ip_rev_ptr = iprev + .ptr + .as_ref() + .map(|ptr| { + ptr.iter() + .map(|label| label.to_string()) + .collect::>() + }) + .unwrap_or_default() + .into(); + request.ip_rev_result = (&iprev).into(); + request.dkim_pass = dkim_pass; + request.dkim2_result = dkim2_output.result().into(); + request.dkim2_pass = dkim2_pass; + request.dkim_results = dkim_output + .iter() + .map(|result| result.result().into()) + .collect(); + request.arc_result = arc_output.result().into(); + request.dmarc_result = (&dmarc_result).into(); + request.dmarc_policy = (&dmarc_output.policy()).into(); + request.dmarc_pass = dmarc_pass; + request.elapsed = now.elapsed().into(); + + Some(request) +} diff --git a/crates/jmap/src/registry/mapping/bootstrap.rs b/crates/jmap/src/registry/mapping/bootstrap.rs new file mode 100644 index 0000000..900abdb --- /dev/null +++ b/crates/jmap/src/registry/mapping/bootstrap.rs @@ -0,0 +1,691 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::{ + mapping::{RegistryGetResponse, RegistrySetResponse, map_bootstrap_error}, + set::map_write_error, +}; +use common::{ + DATABASE_SCHEMA_VERSION, Server, config::storage::Storage, + network::acme::account::acme_create_account, psl, +}; +use directory::core::secret::hash_secret; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + request::MaybeInvalid, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use rand::{RngExt, distr::Alphanumeric, rng}; +use registry::{ + jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + schema::{ + enums::{AcmeChallengeType, DnsRecordType}, + prelude::{Object, Property}, + structs::{ + Account, AcmeProvider, BlobStore, Bootstrap, CertificateManagement, + CertificateManagementProperties, Credential, DataStore, Directory, DirectoryBootstrap, + DkimManagement, DkimManagementProperties, DnsManagement, DnsManagementProperties, + DnsServer, DnsServerBootstrap, Domain, InMemoryStore, PasswordCredential, RocksDbStore, + SearchStore, SystemSettings, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus, + Tracer, TracerLog, UserAccount, UserRoles, + }, + }, + types::{ObjectImpl, list::List, map::Map}, +}; +use std::time::Duration; +use store::{ + RegistryStore, SUBSPACE_PROPERTY, Store, + registry::write::{RegistryWrite, RegistryWriteResult}, + write::{AnyKey, BatchBuilder}, +}; +use types::id::Id; +use utils::{DomainPart, is_valid_domain}; + +pub(crate) async fn bootstrap_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + if !get.server.registry().is_bootstrap_mode() { + get.not_found(Id::singleton()); + return Ok(get); + } + + let mut ids = get + .ids + .take() + .unwrap_or_else(|| vec![Id::singleton()]) + .into_iter(); + + for id in ids.by_ref() { + if id == Id::singleton() { + get.insert( + Id::singleton(), + build_default_bootstrap(get.server).into_value(), + ); + break; + } else { + get.not_found(id); + } + } + + get.response.not_found.extend(ids.map(MaybeInvalid::Value)); + Ok(get) +} + +pub(crate) async fn bootstrap_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + if !set.server.registry().is_bootstrap_mode() { + set.fail_all_create("This operation is only allowed bootstrap mode"); + set.fail_all_update("This operation is only allowed bootstrap mode"); + set.fail_all_destroy("This operation is only allowed bootstrap mode"); + return Ok(set); + } + + set.fail_all_create("Bootstrap objects can only be updated"); + set.fail_all_destroy("Bootstrap objects cannot be deleted"); + + let mut bootstrap = build_default_bootstrap(set.server); + + 'outer: for (id, value) in set.update.drain(..) { + if id != Id::singleton() { + set.response.not_updated.append(id, SetError::not_found()); + continue; + } + + for (key, value) in value.into_expanded_object() { + if let Key::Property(property) = key { + let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]); + if let Err(err) = + bootstrap.patch(JsonPointerPatch::new(&ptr).with_create(false), value) + { + set.response.not_updated.append(id, err.into()); + break 'outer; + } + } else { + set.response.not_updated.append( + id, + SetError::invalid_properties().with_property(key.into_owned()), + ); + break 'outer; + } + } + + let mut validation_errors = Vec::new(); + if !bootstrap.validate(&mut validation_errors) { + set.response.not_updated.append( + id, + SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(validation_errors), + ); + break; + } + + // Validate domain name and hostname + let server_hostname = bootstrap + .server_hostname + .trim() + .to_lowercase() + .to_ascii_domain() + .map(|hostname| hostname.into_owned()) + .unwrap_or_default(); + let domain_name = bootstrap + .default_domain + .trim() + .to_lowercase() + .to_ascii_domain() + .map(|domain| domain.into_owned()) + .unwrap_or_default(); + if !is_valid_domain(&server_hostname) { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::ServerHostname) + .with_description("Invalid server hostname"), + ); + break; + } + if !is_valid_domain(&domain_name) { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DefaultDomain) + .with_description("Invalid default domain"), + ); + break; + } + + // Build store + let store = match Store::build(bootstrap.data_store.clone()).await { + Ok(store) => store, + Err(err) => { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description(err), + ); + break; + } + }; + + // Create tables (SQL only) + if let Err(err) = store.create_tables().await { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description(format!("Failed to initialize data store: {err}")), + ); + break; + } + + // Make sure this is blank deployment + let probe = store.get_value::(AnyKey { + subspace: SUBSPACE_PROPERTY, + key: vec![0u8], + }); + match tokio::time::timeout(Duration::from_secs(30), probe).await { + Ok(Ok(None)) => {} + Ok(Ok(Some(DATABASE_SCHEMA_VERSION))) => { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description("The selected data store has already been initialized."), + ); + break; + } + Ok(Ok(Some(_))) => { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description(concat!( + "The selected data store contains information from an older version. ", + "Please follow the upgrade instructions at ", + "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" + )), + ); + break; + } + Ok(Err(err)) => { + trc::error!(err.caused_by(trc::location!())); + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description( + "Failed to initialize data store, check logs for details.", + ), + ); + break; + } + Err(_elapsed) => { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description(concat!( + "Timed out probing the data store after 30 seconds. ", + "Check that the backend is reachable: for FoundationDB verify ", + "the cluster file points at reachable coordinators, for SQL ", + "verify the host and credentials, and for S3 verify the endpoint ", + "and bucket. See the server logs for details." + )), + ); + break; + } + }; + + // Validate stores and registry + let tmp_registry = set.server.registry(); + for (property, object) in [ + ( + Property::BlobStore, + Some(bootstrap.blob_store.clone().into()), + ), + ( + Property::SearchStore, + Some(bootstrap.search_store.clone().into()), + ), + ( + Property::InMemoryStore, + Some(bootstrap.in_memory_store.clone().into()), + ), + ( + Property::Directory, + map_directory(&bootstrap.directory).map(Into::into), + ), + ( + Property::DnsServer, + map_dns_server(&bootstrap.dns_server).map(Into::into), + ), + (Property::Tracer, Some(bootstrap.tracer.clone().into())), + ] { + if let Some(object) = object { + match write_object(tmp_registry, &object).await { + Ok(_) => {} + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(property)); + break 'outer; + } + } + } + } + let mut bp_check = + store::registry::bootstrap::Bootstrap::new_uninitialized(tmp_registry.clone()) + .with_data_store(store.clone()); + let _ = Storage::parse(&mut bp_check).await; + if !bp_check.errors.is_empty() { + set.response + .not_updated + .append(id, map_bootstrap_error(bp_check.errors)); + break 'outer; + } + + // Create inner store + let registry = + RegistryStore::from_inner_bootstrapped(set.server.registry().initialize_inner(store)); + + // Save datastore + if let Err(err) = registry.write_data_store(&bootstrap.data_store).await { + let details = format!("Failed to save data store settings: {err}"); + trc::error!(err.caused_by(trc::location!())); + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::DataStore) + .with_description(details), + ); + break; + } + + // Write stores and traces to registry + for (property, object) in [ + (Property::BlobStore, bootstrap.blob_store.into()), + (Property::SearchStore, bootstrap.search_store.into()), + (Property::InMemoryStore, bootstrap.in_memory_store.into()), + (Property::Tracer, bootstrap.tracer.into()), + ] { + match write_object(®istry, &object).await { + Ok(_) => {} + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(property)); + break 'outer; + } + } + } + + // Write directory and dns server to registry + let mut directory_id = None; + let mut dns_server_id = None; + if let Some(directory) = map_directory(&bootstrap.directory) { + match write_object(®istry, &directory.into()).await { + Ok(id) => { + directory_id = Some(id); + } + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::Directory)); + break 'outer; + } + } + } + if let Some(dns_server) = map_dns_server(&bootstrap.dns_server) { + match write_object(®istry, &dns_server.into()).await { + Ok(id) => { + dns_server_id = Some(id); + } + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::DnsServer)); + break 'outer; + } + } + } + + // Create ACME provider if needed + let mut acme_provider_id = None; + if bootstrap.request_tls_certificate { + let mut acme_provider = AcmeProvider { + challenge_type: if dns_server_id.is_some() { + AcmeChallengeType::Dns01 + } else { + AcmeChallengeType::TlsAlpn01 + }, + contact: Map::new(vec![format!("postmaster@{domain_name}")]), + #[cfg(not(feature = "dev_mode"))] + directory: "https://acme-v02.api.letsencrypt.org/directory".to_string(), + #[cfg(feature = "dev_mode")] + directory: "https://localhost:14000/dir".to_string(), + ..Default::default() + }; + if let Err(err) = acme_create_account(&mut acme_provider, None).await { + trc::error!(trc::ResourceEvent::Error.into_err().reason(err)); + } else { + match write_object(®istry, &acme_provider.into()).await { + Ok(id) => { + acme_provider_id = Some(id); + } + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::DataStore)); + break 'outer; + } + } + } + } + + // Create domain + let publish_records = Map::new(vec![ + DnsRecordType::Dkim, + DnsRecordType::Spf, + DnsRecordType::Dmarc, + DnsRecordType::Srv, + DnsRecordType::MtaSts, + DnsRecordType::TlsRpt, + DnsRecordType::AutoConfig, + DnsRecordType::AutoConfigLegacy, + DnsRecordType::AutoDiscover, + ]); + let domain = Domain { + name: domain_name.clone(), + is_enabled: true, + certificate_management: if let Some(acme_provider_id) = acme_provider_id { + CertificateManagement::Automatic(CertificateManagementProperties { + acme_provider_id, + subject_alternative_names: Default::default(), + }) + } else { + CertificateManagement::Manual + }, + dkim_management: if bootstrap.generate_dkim_keys { + DkimManagement::Automatic(DkimManagementProperties::default()) + } else { + DkimManagement::Manual + }, + dns_management: if let Some(dns_server_id) = dns_server_id { + DnsManagement::Automatic(DnsManagementProperties { + dns_server_id, + origin: None, + publish_records: publish_records.clone(), + }) + } else { + DnsManagement::Manual + }, + directory_id, + ..Default::default() + }; + let domain_id = match write_object(®istry, &domain.into()).await { + Ok(id) => id, + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::DefaultDomain)); + break 'outer; + } + }; + + // Write system settings + let system_settings = SystemSettings { + default_hostname: bootstrap.server_hostname, + default_domain_id: domain_id, + ..Default::default() + }; + match write_object(®istry, &system_settings.into()).await { + Ok(_) => {} + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::DefaultDomain)); + break 'outer; + } + } + + // Create tasks + let mut batch = BatchBuilder::new(); + if dns_server_id.is_some() { + batch.schedule_task(Task::DnsManagement(TaskDnsManagement { + domain_id, + update_records: publish_records, + on_success_renew_certificate: acme_provider_id.is_some(), + status: TaskStatus::now(), + })); + } else if acme_provider_id.is_some() { + batch.schedule_task(Task::AcmeRenewal(TaskDomainManagement { + domain_id, + status: TaskStatus::now(), + })); + } + if bootstrap.generate_dkim_keys { + batch.schedule_task(Task::DkimManagement(TaskDomainManagement { + domain_id, + status: TaskStatus::now(), + })); + } + if !batch.is_empty() { + match registry.store().write(batch.build_all()).await { + Ok(_) => {} + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + } + } + } + + // Create admin account + let mut response = None; + if directory_id.is_none() { + let secret = rng() + .sample_iter(Alphanumeric) + .take(16) + .map(char::from) + .collect::(); + let account = Account::User(UserAccount { + name: "admin".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + credential_id: Id::new(0), + secret: hash_secret( + set.server.core.network.security.password_hash_algorithm, + secret.clone().into_bytes(), + ) + .await + .unwrap_or_default(), + ..Default::default() + })]), + roles: UserRoles::Admin, + description: "System administrator".to_string().into(), + ..Default::default() + }); + match write_object(®istry, &account.into()).await { + Ok(_) => { + response = Some(JmapValue::Object(jmap_tools::Map::from_iter([ + ( + Key::Property(Property::Username), + JmapValue::Str(format!("admin@{domain_name}").into()), + ), + ( + Key::Property(Property::Secret), + JmapValue::Str(secret.into()), + ), + ]))); + } + Err(err) => { + set.response + .not_updated + .append(id, err.with_property(Property::DefaultDomain)); + break 'outer; + } + } + } + + set.response.updated.append(id, response); + break; + } + + Ok(set) +} + +async fn write_object(registry: &RegistryStore, object: &Object) -> Result> { + match registry.write(RegistryWrite::insert(object)).await { + Ok(RegistryWriteResult::Success(id)) => Ok(id), + Ok(err) => Err(map_write_error(err)), + Err(err) => { + let details = format!("Failed to save settings: {err}"); + trc::error!(err.caused_by(trc::location!())); + Err(SetError::invalid_properties().with_description(details)) + } + } +} + +fn map_directory(directory: &DirectoryBootstrap) -> Option { + match directory { + DirectoryBootstrap::Internal => None, + DirectoryBootstrap::Ldap(ldap_directory) => Directory::Ldap(ldap_directory.clone()).into(), + DirectoryBootstrap::Sql(sql_directory) => Directory::Sql(sql_directory.clone()).into(), + DirectoryBootstrap::Oidc(oidc_directory) => Directory::Oidc(oidc_directory.clone()).into(), + } +} + +fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option { + match dns_server { + DnsServerBootstrap::Manual | DnsServerBootstrap::Deprecated1 => None, + DnsServerBootstrap::Tsig(dns_server_tsig) => { + DnsServer::Tsig(dns_server_tsig.clone()).into() + } + DnsServerBootstrap::Cloudflare(dns_server_cloudflare) => { + DnsServer::Cloudflare(dns_server_cloudflare.clone()).into() + } + DnsServerBootstrap::DigitalOcean(dns_server_cloud) => { + DnsServer::DigitalOcean(dns_server_cloud.clone()).into() + } + DnsServerBootstrap::DeSEC(dns_server_cloud) => { + DnsServer::DeSEC(dns_server_cloud.clone()).into() + } + DnsServerBootstrap::Ovh(dns_server_ovh) => DnsServer::Ovh(dns_server_ovh.clone()).into(), + DnsServerBootstrap::Bunny(dns_server_cloud) => { + DnsServer::Bunny(dns_server_cloud.clone()).into() + } + DnsServerBootstrap::Porkbun(dns_server_porkbun) => { + DnsServer::Porkbun(dns_server_porkbun.clone()).into() + } + DnsServerBootstrap::Dnsimple(dns_server_dnsimple) => { + DnsServer::Dnsimple(dns_server_dnsimple.clone()).into() + } + DnsServerBootstrap::Spaceship(dns_server_spaceship) => { + DnsServer::Spaceship(dns_server_spaceship.clone()).into() + } + DnsServerBootstrap::Route53(dns_server_route53) => { + DnsServer::Route53(dns_server_route53.clone()).into() + } + DnsServerBootstrap::GoogleCloudDns(dns_server_google_cloud_dns) => { + DnsServer::GoogleCloudDns(dns_server_google_cloud_dns.clone()).into() + } + DnsServerBootstrap::Alidns(inner) => DnsServer::Alidns(inner.clone()).into(), + DnsServerBootstrap::ArvanCloud(inner) => DnsServer::ArvanCloud(inner.clone()).into(), + DnsServerBootstrap::Autodns(inner) => DnsServer::Autodns(inner.clone()).into(), + DnsServerBootstrap::AzureDns(inner) => DnsServer::AzureDns(inner.clone()).into(), + DnsServerBootstrap::BaiduCloud(inner) => DnsServer::BaiduCloud(inner.clone()).into(), + DnsServerBootstrap::BluecatV2(inner) => DnsServer::BluecatV2(inner.clone()).into(), + DnsServerBootstrap::ClouDns(inner) => DnsServer::ClouDns(inner.clone()).into(), + DnsServerBootstrap::Constellix(inner) => DnsServer::Constellix(inner.clone()).into(), + DnsServerBootstrap::Cpanel(inner) => DnsServer::Cpanel(inner.clone()).into(), + DnsServerBootstrap::Ddnss(inner) => DnsServer::Ddnss(inner.clone()).into(), + DnsServerBootstrap::DnsMadeEasy(inner) => DnsServer::DnsMadeEasy(inner.clone()).into(), + DnsServerBootstrap::Domeneshop(inner) => DnsServer::Domeneshop(inner.clone()).into(), + DnsServerBootstrap::Dreamhost(inner) => DnsServer::Dreamhost(inner.clone()).into(), + DnsServerBootstrap::DuckDns(inner) => DnsServer::DuckDns(inner.clone()).into(), + DnsServerBootstrap::Dynu(inner) => DnsServer::Dynu(inner.clone()).into(), + DnsServerBootstrap::EasyDns(inner) => DnsServer::EasyDns(inner.clone()).into(), + DnsServerBootstrap::EdgeDns(inner) => DnsServer::EdgeDns(inner.clone()).into(), + DnsServerBootstrap::Exoscale(inner) => DnsServer::Exoscale(inner.clone()).into(), + DnsServerBootstrap::FreeMyIp(inner) => DnsServer::FreeMyIp(inner.clone()).into(), + DnsServerBootstrap::GandiV5(inner) => DnsServer::GandiV5(inner.clone()).into(), + DnsServerBootstrap::Gcore(inner) => DnsServer::Gcore(inner.clone()).into(), + DnsServerBootstrap::Glesys(inner) => DnsServer::Glesys(inner.clone()).into(), + DnsServerBootstrap::Godaddy(inner) => DnsServer::Godaddy(inner.clone()).into(), + DnsServerBootstrap::Hetzner(inner) => DnsServer::Hetzner(inner.clone()).into(), + DnsServerBootstrap::HostingDe(inner) => DnsServer::HostingDe(inner.clone()).into(), + DnsServerBootstrap::Hostinger(inner) => DnsServer::Hostinger(inner.clone()).into(), + DnsServerBootstrap::HuaweiCloud(inner) => DnsServer::HuaweiCloud(inner.clone()).into(), + DnsServerBootstrap::Hurricane(inner) => DnsServer::Hurricane(inner.clone()).into(), + DnsServerBootstrap::IbmCloud(inner) => DnsServer::IbmCloud(inner.clone()).into(), + DnsServerBootstrap::Infoblox(inner) => DnsServer::Infoblox(inner.clone()).into(), + DnsServerBootstrap::Infomaniak(inner) => DnsServer::Infomaniak(inner.clone()).into(), + DnsServerBootstrap::Inwx(inner) => DnsServer::Inwx(inner.clone()).into(), + DnsServerBootstrap::Ionos(inner) => DnsServer::Ionos(inner.clone()).into(), + DnsServerBootstrap::Ipv64(inner) => DnsServer::Ipv64(inner.clone()).into(), + DnsServerBootstrap::Joker(inner) => DnsServer::Joker(inner.clone()).into(), + DnsServerBootstrap::Lightsail(inner) => DnsServer::Lightsail(inner.clone()).into(), + DnsServerBootstrap::Linode(inner) => DnsServer::Linode(inner.clone()).into(), + DnsServerBootstrap::LuaDns(inner) => DnsServer::LuaDns(inner.clone()).into(), + DnsServerBootstrap::MythicBeasts(inner) => DnsServer::MythicBeasts(inner.clone()).into(), + DnsServerBootstrap::Namecheap(inner) => DnsServer::Namecheap(inner.clone()).into(), + DnsServerBootstrap::NameDotCom(inner) => DnsServer::NameDotCom(inner.clone()).into(), + DnsServerBootstrap::NameSilo(inner) => DnsServer::NameSilo(inner.clone()).into(), + DnsServerBootstrap::Netcup(inner) => DnsServer::Netcup(inner.clone()).into(), + DnsServerBootstrap::Netlify(inner) => DnsServer::Netlify(inner.clone()).into(), + DnsServerBootstrap::Nifcloud(inner) => DnsServer::Nifcloud(inner.clone()).into(), + DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(), + DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(), + DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(), + DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(), + DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(), + DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(), + DnsServerBootstrap::Transip(inner) => DnsServer::Transip(inner.clone()).into(), + DnsServerBootstrap::UltraDns(inner) => DnsServer::UltraDns(inner.clone()).into(), + DnsServerBootstrap::Vercel(inner) => DnsServer::Vercel(inner.clone()).into(), + DnsServerBootstrap::Volcengine(inner) => DnsServer::Volcengine(inner.clone()).into(), + DnsServerBootstrap::Vultr(inner) => DnsServer::Vultr(inner.clone()).into(), + DnsServerBootstrap::WebSupport(inner) => DnsServer::WebSupport(inner.clone()).into(), + DnsServerBootstrap::YandexCloud(inner) => DnsServer::YandexCloud(inner.clone()).into(), + } +} + +// FreeBSD keeps variable application data under /var/db (hier(7)) +// rather than FHS /var/lib. +const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") { + "/var/db/stalwart/" +} else { + "/var/lib/stalwart/" +}; + +fn build_default_bootstrap(server: &Server) -> Bootstrap { + let server_hostname = server.registry().local_hostname().to_string(); + let default_domain = psl::domain_str(&server_hostname) + .unwrap_or("example.org") + .to_string(); + + Bootstrap { + data_store: DataStore::RocksDb(RocksDbStore { + path: DEFAULT_DATA_PATH.to_string(), + ..Default::default() + }), + blob_store: BlobStore::Default, + search_store: SearchStore::Default, + in_memory_store: InMemoryStore::Default, + directory: DirectoryBootstrap::Internal, + tracer: Tracer::Log(TracerLog { + path: "/var/log/stalwart/".to_string(), + prefix: "stalwart".to_string(), + ansi: true, + enable: true, + ..Default::default() + }), + server_hostname, + default_domain, + request_tls_certificate: true, + generate_dkim_keys: true, + dns_server: DnsServerBootstrap::Manual, + } +} diff --git a/crates/jmap/src/registry/mapping/cluster.rs b/crates/jmap/src/registry/mapping/cluster.rs new file mode 100644 index 0000000..bfe05ed --- /dev/null +++ b/crates/jmap/src/registry/mapping/cluster.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::{object::registry::RegistryComparator, types::state::State}; +use registry::{jmap::IntoValue, schema::prelude::Property}; +use store::ahash::AHashSet; + +use crate::{ + api::query::QueryResponseBuilder, + registry::mapping::{RegistryGetResponse, RegistryQueryResponse}, +}; + +pub(crate) async fn cluster_node_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let nodes = get.server.registry().cluster_node_list().await?; + let mut ids = get + .ids + .take() + .unwrap_or_default() + .into_iter() + .map(|id| id.id()) + .collect::>(); + + for node in nodes { + if ids.is_empty() || ids.remove(&node.node_id) { + get.insert(node.node_id.into(), node.into_value()); + } + } + + for id in ids { + get.not_found(id.into()); + } + + Ok(get) +} + +pub(crate) async fn cluster_node_query( + req: RegistryQueryResponse<'_>, +) -> trc::Result { + if req + .request + .sort + .as_ref() + .and_then(|sort| sort.first()) + .is_some_and(|comp| { + !matches!( + comp.property, + RegistryComparator::Property(Property::NodeId) + ) + }) + { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details("Only sorting by 'nodeId' is supported for cluster nodes".to_string())); + } + + let nodes = req.server.registry().cluster_node_list().await?; + + // Build response + let mut response = QueryResponseBuilder::new( + nodes.len(), + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + for node in nodes { + if !response.add_id(node.node_id.into()) { + break; + } + } + + Ok(response) +} diff --git a/crates/jmap/src/registry/mapping/dkim.rs b/crates/jmap/src/registry/mapping/dkim.rs new file mode 100644 index 0000000..3126bcf --- /dev/null +++ b/crates/jmap/src/registry/mapping/dkim.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ + ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, +}; +use common::config::smtp::auth::DkimSigners; +use jmap_proto::error::set::SetError; +use registry::schema::{enums::TenantStorageQuota, structs::DkimSignature}; + +pub(crate) async fn validate_dkim_signature( + set: &RegistrySetResponse<'_>, + key: &mut DkimSignature, + old_key: Option<&DkimSignature>, +) -> ValidationResult { + let response = if old_key.is_none() { + match validate_tenant_quota( + set.server, + set.access_token, + TenantStorageQuota::MaxDkimKeys, + ) + .await? + { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + } + } else { + ObjectResponse::default() + }; + + if old_key.is_none_or(|old_key| old_key.private_key() != key.private_key()) + && let Err(err) = DkimSigners::default() + .insert("example.com".to_string(), key.clone()) + .await + { + return Ok(Err(SetError::invalid_properties().with_description( + format!("Failed to validate DKIM signature: {err}"), + ))); + } + + Ok(Ok(response)) +} diff --git a/crates/jmap/src/registry/mapping/domain.rs b/crates/jmap/src/registry/mapping/domain.rs new file mode 100644 index 0000000..e1e370e --- /dev/null +++ b/crates/jmap/src/registry/mapping/domain.rs @@ -0,0 +1,209 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ + ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, +}; +use common::network::{dkim::generate_dkim_selector, dns::update::DnsUpdater}; +use jmap_proto::error::set::{SetError, SetErrorType}; +use registry::{ + schema::{ + enums::{AcmeChallengeType, DkimSignatureType, DnsRecordType, TenantStorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + AcmeProvider, CertificateManagement, DkimManagement, DkimManagementProperties, + DnsManagement, DnsServer, Domain, Task, TaskDnsManagement, TaskDomainManagement, + TaskStatus, + }, + }, + types::map::Map, +}; +use types::id::Id; + +pub(crate) async fn validate_domain( + set: &RegistrySetResponse<'_>, + domain: &mut Domain, + old_domain: Option<&Domain>, + tasks: &mut Vec, +) -> ValidationResult { + let response = if old_domain.is_none() { + match validate_tenant_quota(set.server, set.access_token, TenantStorageQuota::MaxDomains) + .await? + { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + } + } else { + ObjectResponse::default() + }; + + // Validate DKIM selector template + if let DkimManagement::Automatic(DkimManagementProperties { + selector_template, .. + }) = &domain.dkim_management + && old_domain.is_none_or(|old| { + matches!( + &old.dkim_management, + DkimManagement::Automatic(DkimManagementProperties { + selector_template: old_selector_template, + .. + }) if old_selector_template != selector_template + ) + }) + && let Err(err) = + generate_dkim_selector(selector_template, DkimSignatureType::Dkim1RsaSha256) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::SelectorTemplate) + .with_description(err))); + } + + // Validate that names and aliases do not collide with another domain + let registry = set.server.registry(); + if old_domain.is_none_or(|old| old.name != domain.name) + && let Some(existing) = registry + .primary_key( + ObjectType::Domain.into(), + Property::Aliases, + domain.name.as_bytes().to_vec(), + ) + .await? + { + return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(Property::Name) + .with_object_id(existing))); + } + + for alias in domain.aliases.iter() { + if alias == &domain.name + || old_domain.is_some_and(|old| old.aliases.contains(alias) || &old.name == alias) + { + continue; + } + + for index in [Property::Name, Property::Aliases] { + if let Some(existing) = registry + .primary_key(ObjectType::Domain.into(), index, alias.as_bytes().to_vec()) + .await? + { + return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(Property::Aliases) + .with_object_id(existing))); + } + } + } + + // Schedule DNS update task + let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_)) + && old_domain + .is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_))); + let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management + && old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_))) + { + let on_success_renew_certificate = old_domain.is_none() + && matches!( + domain.certificate_management, + CertificateManagement::Automatic(_) + ); + tasks.push(Task::DnsManagement(TaskDnsManagement { + domain_id: Id::default(), + update_records: Map::new( + details + .publish_records + .iter() + .filter(|&&r| r != DnsRecordType::Dkim || !will_trigger_dkim) + .copied() + .collect(), + ), + on_success_renew_certificate, + status: TaskStatus::now(), + })); + on_success_renew_certificate + } else { + false + }; + + // Schedule DKIM key rotation task + if will_trigger_dkim { + tasks.push(Task::DkimManagement(TaskDomainManagement { + domain_id: Id::default(), + status: TaskStatus::now(), + })); + } + + // Schedule ACME renewal task if needed + if !will_trigger_acme + && let CertificateManagement::Automatic(details) = &domain.certificate_management + && old_domain.is_none_or(|old| { + !matches!( + old.certificate_management, + CertificateManagement::Automatic(_) + ) + }) + { + let Some(provider) = set + .server + .registry() + .object::(details.acme_provider_id) + .await? + else { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::AcmeProviderId) + .with_description("ACME provider not found"))); + }; + + if matches!(provider.challenge_type, AcmeChallengeType::Dns01) + && !matches!(domain.dns_management, DnsManagement::Automatic(_)) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::AcmeProviderId) + .with_description( + "ACME provider requires automatic DNS management", + ))); + } + + tasks.push(Task::AcmeRenewal(TaskDomainManagement { + domain_id: Id::default(), + status: TaskStatus::now(), + })); + } + + Ok(Ok(response)) +} + +pub(crate) async fn validate_dns_server( + set: &RegistrySetResponse<'_>, + dns: &mut DnsServer, + old_dns: Option<&DnsServer>, +) -> ValidationResult { + let response = if old_dns.is_none() { + match validate_tenant_quota( + set.server, + set.access_token, + TenantStorageQuota::MaxDnsServers, + ) + .await? + { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + } + } else { + ObjectResponse::default() + }; + + if old_dns.is_none_or(|old_dns| old_dns != dns) + && let Err(err) = DnsUpdater::build(dns.clone(), set.server.core.clone()).await + { + return Ok(Err(SetError::invalid_properties() + .with_description(format!("Failed to build DNS server: {err}")))); + } + + Ok(Ok(response)) +} diff --git a/crates/jmap/src/registry/mapping/log.rs b/crates/jmap/src/registry/mapping/log.rs new file mode 100644 index 0000000..9dc8bc9 --- /dev/null +++ b/crates/jmap/src/registry/mapping/log.rs @@ -0,0 +1,510 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse}, + query::RegistryQueryFilters, + }, +}; +use chrono::DateTime; +use jmap_proto::types::state::State; +use registry::{ + jmap::IntoValue, + schema::{enums::TracingLevel, prelude::Property, structs::Log}, + types::{EnumImpl, datetime::UTCDateTime}, +}; +use std::{ + borrow::Cow, + fs::{self, File}, + io::{self, BufRead, BufReader, Read, Seek, SeekFrom}, + path::Path, +}; +use store::ahash::AHashMap; +use tokio::sync::oneshot; +use trc::EventType; +use types::id::Id; + +pub(crate) async fn log_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let Some(path) = get.server.core.metrics.log_path.clone() else { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("No log tracers configured on the server")); + }; + + let ids = get.ids.take(); + + if ids.as_ref().is_none_or(|ids| !ids.is_empty()) { + // TODO: Use worker pool + let limit = get.server.core.jmap.get_max_objects; + let (tx, rx) = oneshot::channel(); + tokio::task::spawn_blocking(move || { + let _ = tx.send(read_log_entries(path, ids, limit)); + }); + + rx.await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + })? + .map_err(|err| { + trc::EventType::Telemetry(trc::TelemetryEvent::LogError) + .reason(err) + .details("Failed to read log files") + .caused_by(trc::location!()) + })? + .into_iter() + .for_each(|(id, log)| { + get.insert(id, log.into_value()); + }); + } + + Ok(get) +} + +pub(crate) async fn log_query( + mut req: RegistryQueryResponse<'_>, +) -> trc::Result { + let Some(path) = req.server.core.metrics.log_path.clone() else { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("No log tracers configured on the server")); + }; + + let mut filter = None; + + req.request + .extract_filters(|property, _, value| match property { + Property::Text => { + if let serde_json::Value::String(due) = value { + filter = Some(due); + true + } else { + false + } + } + _ => false, + })?; + + let anchor = req.request.anchor.map(|id| id.id()).unwrap_or(0); + let limit = std::cmp::min( + req.request.limit.unwrap_or(usize::MAX), + req.server.core.jmap.query_max_results, + ); + + let params = req + .request + .extract_parameters(req.server.core.jmap.query_max_results, Property::Id.into())?; + + if params.sort_by != Property::Id { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details("Only sorting by 'id' is supported for logs")); + } + + if req.request.position.unwrap_or(0) != 0 { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Pagination is only possible using anchors for logs")); + } + + let (tx, rx) = oneshot::channel(); + + tokio::task::spawn_blocking(move || { + let _ = tx.send(read_log_offsets(path, filter.as_deref(), anchor, limit)); + }); + + // Build response + let mut response = QueryResponseBuilder::new( + req.server.core.jmap.query_max_results, + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + response.response.ids = rx + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + })? + .map_err(|err| { + trc::EventType::Telemetry(trc::TelemetryEvent::LogError) + .reason(err) + .details("Failed to read log files") + .caused_by(trc::location!()) + })?; + response.anchor_found = true; + + Ok(response) +} + +fn read_log_offsets( + path: impl AsRef, + filter: Option<&str>, + anchor: u64, + limit: usize, +) -> io::Result> { + let mut logs = fs::read_dir(path)?.collect::, _>>()?; + logs.sort_by_key(|b| std::cmp::Reverse(b.file_name())); + + let mut entries = Vec::with_capacity(limit); + let mut file_number = 0u64; + let mut found_anchor = anchor == 0; + let file_anchor = anchor >> 48; + + 'outer: for log in logs.into_iter() { + if !log.file_type()?.is_file() { + continue; + } + + if !found_anchor && file_anchor != file_number { + file_number += 1; + continue; + } + + let file = File::open(log.path())?; + let file_size = file.metadata()?.len(); + let mut rev_lines = RevLines::new(file); + rev_lines.0.init_reader()?; + + let mut offset = file_size; + + for line in rev_lines { + let line = line?; + offset = offset.saturating_sub(line.len() as u64 + 1); // +1 for the newline character + + if !is_log_header(&line) { + continue; + } + + let id = (file_number << 48) | offset; + + if !found_anchor { + found_anchor = id == anchor; + continue; + } + + if filter.is_none_or(|filter| line.contains(filter)) { + entries.push(Id::from(id)); + if entries.len() == limit { + break 'outer; + } + } + } + + file_number += 1; + } + + Ok(entries) +} + +fn read_log_entries( + path: impl AsRef, + ids: Option>, + limit: usize, +) -> io::Result> { + let path = path.as_ref(); + let ids = if let Some(mut ids) = ids { + ids.truncate(limit); + ids + } else { + read_log_offsets(path, None, 0, limit)? + }; + + let mut logs = fs::read_dir(path)?.collect::, _>>()?; + + // Sort the entries by file name in reverse order. + logs.sort_by_key(|b| std::cmp::Reverse(b.file_name())); + + let mut entries = Vec::with_capacity(ids.len()); + + // Group files and offsets + let mut offset_map = AHashMap::new(); + let total_ids = ids.len(); + for id in ids { + let file_number = id.id() >> 48; + let offset = id.id() & 0xFFFFFFFFFFFF; + offset_map + .entry(file_number) + .or_insert_with(Vec::new) + .push(offset); + } + + let mut file_number = 0u64; + let mut line = String::with_capacity(256); + + 'outer: for log in logs.into_iter() { + if !log.file_type()?.is_file() { + continue; + } + + if let Some(offsets) = offset_map.get(&file_number) { + let mut reader = BufReader::new(File::open(log.path())?); + + for offset in offsets { + // seek to the offset and read the line + reader.seek(SeekFrom::Start(*offset))?; + line.clear(); + reader.read_line(&mut line)?; + + if let Some(log) = log_from_line(&line) { + entries.push((Id::from((file_number << 48) | *offset), log)); + + if entries.len() == total_ids { + break 'outer; + } + } + } + } + + file_number += 1; + } + + Ok(entries) +} + +fn is_log_header(line: &str) -> bool { + let line = strip_ansi(line); + let bytes = line.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_digit() { + return false; + } + let Some((timestamp, _)) = line.split_once(' ') else { + return false; + }; + DateTime::parse_from_rfc3339(timestamp).is_ok() +} + +fn log_from_line(line: &str) -> Option { + let line = strip_ansi(line); + let (timestamp, rest) = line.split_once(' ')?; + let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?; + let (level, rest) = rest.trim().split_once(' ')?; + let (_, rest) = rest.trim().split_once(" (")?; + let (event_id, details) = rest.split_once(")")?; + + Some(Log { + timestamp: UTCDateTime::from_timestamp(timestamp.timestamp()), + level: TracingLevel::parse(&level.to_ascii_lowercase()).unwrap_or(TracingLevel::Info), + event: EventType::parse(event_id)?, + details: details.trim().to_string(), + }) +} + +fn strip_ansi(line: &str) -> Cow<'_, str> { + if !line.contains('\x1b') { + return Cow::Borrowed(line); + } + + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c != '\x1b' { + out.push(c); + continue; + } + match chars.next() { + Some('[') => { + for c in chars.by_ref() { + if matches!(c as u32, 0x40..=0x7e) { + break; + } + } + } + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' { + break; + } + if c == '\x1b' { + chars.next(); + break; + } + } + } + _ => {} + } + } + Cow::Owned(out) +} + +/* + * SPDX-FileCopyrightText: 2017 Michael Coyne + * + * SPDX-License-Identifier: MIT + */ + +// Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs + +static DEFAULT_SIZE: usize = 4096; +static LF_BYTE: u8 = b'\n'; + +/// `RevLines` struct +pub struct RawRevLines { + reader: BufReader, + reader_cursor: u64, + buffer: Vec, + buffer_end: usize, + read_len: usize, +} + +impl RawRevLines { + /// Create a new `RawRevLines` struct from a Reader. + /// Internal buffering for iteration will default to 4096 bytes at a time. + pub fn new(reader: R) -> RawRevLines { + RawRevLines::with_capacity(DEFAULT_SIZE, reader) + } + + /// Create a new `RawRevLines` struct from a Reader`. + /// Internal buffering for iteration will use `cap` bytes at a time. + pub fn with_capacity(cap: usize, reader: R) -> RawRevLines { + RawRevLines { + reader: BufReader::new(reader), + reader_cursor: u64::MAX, + buffer: vec![0; cap], + buffer_end: 0, + read_len: 0, + } + } + + pub fn init_reader(&mut self) -> io::Result<()> { + // Move cursor to the end of the file and store the cursor position + self.reader_cursor = self.reader.seek(SeekFrom::End(0))?; + // Next read will be the full buffer size or the remaining bytes in the file + self.read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize); + // Move cursor just before the next bytes to read + self.reader.seek_relative(-(self.read_len as i64))?; + // Update the cursor position + self.reader_cursor -= self.read_len as u64; + + self.read_to_buffer()?; + + // Handle any trailing new line characters for the reader + // so the first next call does not return Some("") + if self.buffer_end > 0 + && let Some(last_byte) = self.buffer.get(self.buffer_end - 1) + && *last_byte == LF_BYTE + { + self.buffer_end -= 1; + } + + Ok(()) + } + + fn read_to_buffer(&mut self) -> io::Result<()> { + // Read the next bytes into the buffer, self.read_len was already prepared for that + self.reader.read_exact(&mut self.buffer[0..self.read_len])?; + // Specify which part of the buffer is valid + self.buffer_end = self.read_len; + + // Determine what the next read length will be + let next_read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize); + // Move the cursor just in front of the next read + self.reader + .seek_relative(-((self.read_len + next_read_len) as i64))?; + // Update cursor position + self.reader_cursor -= next_read_len as u64; + + // Store the next read length, it'll be used in the next call + self.read_len = next_read_len; + + Ok(()) + } + + fn next_line(&mut self) -> io::Result>> { + // Reader cursor will only ever be u64::MAX if the reader has not been initialized + // If by some chance the reader is initialized with a file of length u64::MAX this will still work, + // as some read length value is subtracted from the cursor position right away + if self.reader_cursor == u64::MAX { + self.init_reader()?; + } + + // For most sane scenarios, where size of the buffer is greater than the length of the line, + // the result will only contain one and at most two elements, making the flattening trivial. + // At the same time, instead of pushing one element at a time, it allows us to copy a subslice of the buffer, + // which is very performant on modern architectures. + let mut result: Vec> = Vec::new(); + + 'outer: loop { + // Current buffer was read to completion, read new contents + if self.buffer_end == 0 { + // Read the of minimum between the desired + // buffer size or remaining length of the reader + self.read_to_buffer()?; + } + + // If buffer_end is still 0, it means the reader is empty + if self.buffer_end == 0 { + if result.is_empty() { + return Ok(None); + } else { + break; + } + } + + let buffer_length = self.buffer_end; + + for ch in self.buffer[..self.buffer_end].iter().rev() { + self.buffer_end -= 1; + // Found a new line character to break on + if *ch == LF_BYTE { + result.push(self.buffer[self.buffer_end + 1..buffer_length].to_vec()); + break 'outer; + } + } + + result.push(self.buffer[..buffer_length].to_vec()); + } + + Ok(Some(result.into_iter().rev().flatten().collect())) + } +} + +impl Iterator for RawRevLines { + type Item = io::Result>; + + fn next(&mut self) -> Option>> { + self.next_line().transpose() + } +} + +pub struct RevLines(RawRevLines); + +impl RevLines { + /// Create a new `RawRevLines` struct from a Reader. + /// Internal buffering for iteration will default to 4096 bytes at a time. + pub fn new(reader: R) -> RevLines { + RevLines(RawRevLines::new(reader)) + } + + /// Create a new `RawRevLines` struct from a Reader`. + /// Internal buffering for iteration will use `cap` bytes at a time. + pub fn with_capacity(cap: usize, reader: R) -> RevLines { + RevLines(RawRevLines::with_capacity(cap, reader)) + } +} + +impl Iterator for RevLines { + type Item = Result; + + fn next(&mut self) -> Option> { + let line = match self.0.next_line().transpose()? { + Ok(line) => line, + Err(error) => return Some(Err(error)), + }; + + Some( + String::from_utf8(line) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid UTF-8")), + ) + } +} diff --git a/crates/jmap/src/registry/mapping/mod.rs b/crates/jmap/src/registry/mapping/mod.rs new file mode 100644 index 0000000..3a34a28 --- /dev/null +++ b/crates/jmap/src/registry/mapping/mod.rs @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::{get::GetResponse, query::QueryRequest, set::SetResponse}, + object::registry::Registry, +}; +use jmap_tools::Map; +use registry::{ + jmap::{JmapValue, RegistryValue}, + schema::prelude::{ObjectType, Property}, + types::error::Error, +}; +use std::net::IpAddr; +use store::ahash::AHashSet; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub mod account; +pub mod action; +pub mod bootstrap; +pub mod cluster; +pub mod dkim; +pub mod domain; +pub mod log; +pub mod principal; +pub mod public_key; +pub mod queued_message; +pub mod report; +pub mod sieve; +pub mod spam_sample; +pub mod task; +pub mod tls; + + +pub(crate) struct RegistryGetResponse<'x> { + pub server: &'x Server, + pub access_token: &'x AccessToken, + pub account_id: u32, + pub ids: Option>, + pub properties: AHashSet, + pub response: GetResponse, + pub object_type: ObjectType, + pub object_flags: u64, + pub is_tenant_filtered: bool, + pub is_account_filtered: bool, +} + +pub(crate) struct RegistrySetResponse<'x> { + pub server: &'x Server, + pub remote_ip: IpAddr, + pub access_token: &'x AccessToken, + pub account_id: u32, + pub create: VecMap>, + pub update: Vec<(Id, JmapValue<'x>)>, + pub destroy: Vec, + pub response: SetResponse, + pub object_type: ObjectType, + pub is_tenant_filtered: bool, + pub is_account_filtered: bool, +} + +pub(crate) struct RegistryQueryResponse<'x> { + pub server: &'x Server, + pub access_token: &'x AccessToken, + pub object_type: ObjectType, + pub request: QueryRequest, +} + +pub type ValidationResult = trc::Result>>; + +pub struct ObjectResponse { + pub id: Option, + pub object: Map<'static, Property, RegistryValue>, +} + +impl ObjectResponse { + pub fn new(id: Id, object: Map<'static, Property, RegistryValue>) -> Self { + Self { + id: Some(id), + object, + } + } +} + +impl Default for ObjectResponse { + fn default() -> Self { + Self { + id: None, + object: Map::with_capacity(1), + } + } +} + +pub(crate) fn map_bootstrap_error(error: Vec) -> SetError { + match error.into_iter().next().unwrap() { + Error::Validation { object_id, errors } => SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(errors) + .with_object_id(object_id), + Error::Build { object_id, message } => SetError::new(SetErrorType::ValidationFailed) + .with_description(message) + .with_object_id(object_id), + Error::Internal { object_id, error } => SetError::new(SetErrorType::Forbidden) + .with_description(error.to_string()) + .with_object_id_opt(object_id), + Error::NotFound { object_id } => { + SetError::new(SetErrorType::NotFound).with_object_id(object_id) + } + } +} diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs new file mode 100644 index 0000000..4682938 --- /dev/null +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ObjectResponse, ValidationResult}; +use common::{ + Server, + auth::{AccessToken, Permissions, PermissionsGroup, permissions::BuildPermissions}, +}; +use directory::core::secret::{hash_secret, is_password_hash}; +use jmap_proto::error::set::SetError; +use registry::{schema::structs::TaskStatus, types::datetime::UTCDateTime}; +use registry::{ + schema::{ + enums::{AccountType, Permission, TenantStorageQuota}, + prelude::{MASKED_PASSWORD, ObjectType, Property}, + structs::{Account, Credential, Role, Task, TaskDestroyAccount}, + }, + types::EnumImpl, +}; +use store::{ + registry::{RegistryObjectCounter, RegistryQuery}, + write::{BatchBuilder, RegistryClass, ValueClass, now}, +}; +use trc::AddContext; +use types::id::Id; + +#[derive(Clone, Copy)] +pub enum AccountUpdate<'x> { + Update(&'x Account), + Create(&'x str), +} + +pub async fn validate_account( + server: &Server, + access_token: &AccessToken, + mut account: &mut Account, + old_account: AccountUpdate<'_>, +) -> ValidationResult { + + let is_external_directory = if let Account::User(account) = account { + server + .domain_by_id(account.domain_id.document_id()) + .await? + .and_then(|domain| server.get_directory_for_cached_domain(&domain)) + .is_some() + } else { + false + }; + let recover_account_id = if server.registry().is_recovery_mode() + && let AccountUpdate::Create(client_id) = old_account + && let Some(account_id) = client_id + .strip_prefix("restore-") + .and_then(|id| id.parse::().ok()) + { + Some(account_id) + } else { + None + }; + + let validate_permissions = match (&mut account, old_account) { + (Account::User(account), AccountUpdate::Update(Account::User(old_account))) => { + // Validate credentials + let has_password = account.credentials.values().any(|credential| { + matches!(credential, Credential::Password(credential) if credential.credential_id.is_valid()) + }); + let mut max_credential_id = 0; + let mut has_new_credentials = false; + for credential in account.credentials.values_mut() { + let credential_id = credential.credential_id(); + + if credential_id.is_valid() && credential_id.id() > max_credential_id { + max_credential_id = credential_id.id(); + } + + if let Some(old_credential) = old_account + .credentials + .values() + .find(|c| c.credential_id() == credential_id) + { + if credential != old_credential { + match (credential, old_credential) { + ( + Credential::Password(credential), + Credential::Password(old_credential), + ) => { + if is_external_directory { + return Ok(Err(SetError::forbidden().with_description( + "Cannot change credentials for accounts in an external directory.", + ))); + } + + // Reset the original password if the client accidentally sent the masked password + if credential.secret == MASKED_PASSWORD { + credential.secret = old_credential.secret.clone(); + } + if credential + .otp_auth + .as_ref() + .is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD) + { + credential.otp_auth = old_credential.otp_auth.clone(); + } + + if credential.secret != old_credential.secret { + if credential.expires_at == old_credential.expires_at + && credential + .expires_at + .is_some_and(|exp| exp.timestamp() <= now() as i64) + && let Some(expires_at) = + server.core.network.security.password_default_expiration + { + credential.expires_at = Some(UTCDateTime::from_timestamp( + (now() + expires_at) as i64, + )); + } + + if !(matches!( + credential.secret.as_bytes().first(), + Some(&b'$' | &b'{') + ) && is_password_hash(&credential.secret)) + { + if let Err(err) = + server.is_secure_password(&credential.secret, &[]) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Secret) + .with_description(err))); + } + + credential.secret = hash_secret( + server.core.network.security.password_hash_algorithm, + std::mem::take(&mut credential.secret).into_bytes(), + ) + .await + .caused_by(trc::location!())?; + } + } + } + ( + Credential::AppPassword(credential), + Credential::AppPassword(old_credential), + ) + | ( + Credential::ApiKey(credential), + Credential::ApiKey(old_credential), + ) => { + // Reset the original password if the client accidentally sent the masked password + if credential.secret == MASKED_PASSWORD { + credential.secret = old_credential.secret.clone(); + } + + if credential.secret != old_credential.secret { + return Ok(Err(SetError::forbidden().with_description( + "Cannot change app password or API credentials through this method.", + ))); + } + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description("Credential type cannot be changed."))); + } + } + } + } else if let Err(err) = validate_credential_creation( + server, + credential, + is_external_directory, + has_password, + ) + .await? + { + return Ok(Err(err)); + } else { + has_new_credentials = true; + } + } + + if has_new_credentials { + for credential in account.credentials.values_mut() { + if !credential.credential_id().is_valid() { + max_credential_id += 1; + credential.set_credential_id(Id::from(max_credential_id)); + } + } + } + + account.permissions != old_account.permissions || account.roles != old_account.roles + } + (Account::Group(account), AccountUpdate::Update(Account::Group(old_account))) => { + account.permissions != old_account.permissions || account.roles != old_account.roles + } + (Account::User(account), AccountUpdate::Create(_)) => { + // Validate tenant quotas + if let Err(err) = + validate_tenant_quota(server, access_token, TenantStorageQuota::MaxAccounts).await? + { + return Ok(Err(err)); + } + + // Validate credentials + for (index, credential) in account.credentials.values_mut().enumerate() { + if let Err(err) = validate_credential_creation( + server, + credential, + is_external_directory, + index > 0, + ) + .await? + { + return Ok(Err(err)); + } + credential.set_credential_id(Id::from(index as u64)); + } + + true + } + (Account::Group(_), AccountUpdate::Create(_)) => { + // Validate tenant quotas + if let Err(err) = + validate_tenant_quota(server, access_token, TenantStorageQuota::MaxGroups).await? + { + return Ok(Err(err)); + } + + true + } + (Account::User(_), AccountUpdate::Update(Account::Group(_))) + | (Account::Group(_), AccountUpdate::Update(Account::User(_))) => { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Type) + .with_description( + "Cannot change the type of an existing account.", + ))); + } + }; + + let mut result = if validate_permissions { + Ok(server + .can_set_permissions(access_token, account) + .await? + .map(|_| ObjectResponse::default()) + .map_err(build_set_error)) + } else { + Ok(Ok(ObjectResponse::default())) + }; + + if let Some(account_id) = recover_account_id + && let Ok(Ok(result)) = &mut result + { + restore_account_id(server, account_id).await?; + result.id = Some(account_id.into()); + } + + result +} + +async fn validate_credential_creation( + server: &Server, + credential: &mut Credential, + is_external_directory: bool, + has_password: bool, +) -> trc::Result>> { + match credential { + Credential::Password(credential) => { + if is_external_directory { + return Ok(Err(SetError::forbidden().with_description( + "Cannot set credentials for accounts in an external directory.", + ))); + } else if has_password { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description("Only one password credential is allowed."))); + } + + if credential.expires_at.is_none() + && let Some(expires_at) = server.core.network.security.password_default_expiration + { + credential.expires_at = + Some(UTCDateTime::from_timestamp((now() + expires_at) as i64)); + } + + if matches!(credential.secret.as_bytes().first(), Some(&b'$' | &b'{')) + && is_password_hash(&credential.secret) + { + Ok(Ok(())) + } else if let Err(err) = server.is_secure_password(&credential.secret, &[]) { + Ok(Err(SetError::invalid_properties() + .with_property(Property::Secret) + .with_description(err))) + } else { + credential.secret = hash_secret( + server.core.network.security.password_hash_algorithm, + std::mem::take(&mut credential.secret).into_bytes(), + ) + .await + .caused_by(trc::location!())?; + Ok(Ok(())) + } + } + Credential::AppPassword(_) | Credential::ApiKey(_) => { + Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description( + "Secondary credentials cannot be set directly.", + ))) + } + } +} + +pub(crate) async fn validate_role( + server: &Server, + access_token: &AccessToken, + role: &mut Role, + old_role: Option<&Role>, +) -> ValidationResult { + if old_role.is_none() { + // Validate tenant quotas + if let Err(err) = + validate_tenant_quota(server, access_token, TenantStorageQuota::MaxRoles).await? + { + return Ok(Err(err)); + } + } + + if old_role.is_none_or(|old_role| { + old_role.enabled_permissions != role.enabled_permissions + || old_role.disabled_permissions != role.disabled_permissions + || old_role.role_ids != role.role_ids + }) { + Ok(access_token + .can_grant_permissions( + PermissionsGroup { + enabled: Permissions::from_permission(role.enabled_permissions.as_slice()), + disabled: Permissions::from_permission(role.disabled_permissions.as_slice()), + merge: false, + } + .finalize(), + ) + .map(|_| ObjectResponse::default()) + .map_err(build_set_error)) + } else { + Ok(Ok(ObjectResponse::default())) + } +} + + +#[cfg(not(feature = "enterprise"))] +pub async fn validate_tenant_quota( + _server: &Server, + _access_token: &AccessToken, + _quota: TenantStorageQuota, +) -> ValidationResult { + ValidationResult::Ok(Ok(ObjectResponse::default())) +} + +pub async fn schedule_account_destruction( + server: &Server, + account_id: Id, + account: &Account, +) -> trc::Result<()> { + + #[cfg(not(feature = "enterprise"))] + let status = TaskStatus::now(); + + let (account_domain_id, account_name, account_type) = match account { + Account::User(account) => (account.domain_id, account.name.clone(), AccountType::User), + Account::Group(account) => (account.domain_id, account.name.clone(), AccountType::Group), + }; + + let mut batch = BatchBuilder::new(); + batch.schedule_task(Task::DestroyAccount(TaskDestroyAccount { + account_domain_id, + account_id, + account_name, + account_type, + status, + })); + + server.store().write(batch.build_all()).await?; + server.notify_task_queue(); + + Ok(()) +} + +pub(crate) fn build_set_error(permissions: Vec) -> SetError { + let mut missing_permissions = String::with_capacity(16); + let mut total_missing = permissions.len(); + for permission in permissions.into_iter().take(5) { + if !missing_permissions.is_empty() { + missing_permissions.push_str(", "); + } + missing_permissions.push_str(permission.as_str()); + total_missing -= 1; + } + if total_missing > 0 { + missing_permissions.push_str(&format!(" and {} more", total_missing)); + } + + SetError::forbidden().with_description(format!( + "You are not authorized to grant permissions: {}", + missing_permissions + )) +} + +async fn restore_account_id(server: &Server, id: u32) -> trc::Result<()> { + // Obtain current counter value + let object_id = ObjectType::Account.to_id(); + let last_id = server + .store() + .get_counter(ValueClass::Registry(RegistryClass::IdCounter { object_id })) + .await + .caused_by(trc::location!())? + .cast_unsigned() as u32; + + if last_id < id { + let mut id_batch = BatchBuilder::new(); + id_batch.add_and_get( + ValueClass::Registry(RegistryClass::IdCounter { object_id }), + (id - last_id) as i64, + ); + let last_id = server + .store() + .write(id_batch.build_all()) + .await + .and_then(|v| v.last_counter_id())?; + + if last_id < id as i64 { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to update id counter") + .caused_by(trc::location!())); + } + } + + Ok(()) +} diff --git a/crates/jmap/src/registry/mapping/public_key.rs b/crates/jmap/src/registry/mapping/public_key.rs new file mode 100644 index 0000000..042d081 --- /dev/null +++ b/crates/jmap/src/registry/mapping/public_key.rs @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult}; +use common::storage::encryption::parse_public_key; +use jmap_proto::error::set::SetError; +use registry::schema::{ + enums::StorageQuota, + prelude::{ObjectType, Property}, + structs::PublicKey, +}; +use store::registry::{RegistryObjectCounter, RegistryQuery}; + +pub(crate) async fn validate_public_key( + set: &RegistrySetResponse<'_>, + key: &mut PublicKey, + old_key: Option<&PublicKey>, +) -> ValidationResult { + let response = ObjectResponse::default(); + + if let Some(old_key) = old_key { + if key.key == old_key.key { + return Ok(Ok(response)); + } + } else { + // Validate quotas + let num_keys = set + .server + .registry() + .query::( + RegistryQuery::new(ObjectType::PublicKey).with_account(set.account_id), + ) + .await? + .0 as u32; + let account = set.server.account(set.account_id).await?; + let key_quota = set + .server + .object_quota(account.object_quotas(), StorageQuota::MaxPublicKeys); + if num_keys >= key_quota { + return Ok(Err(SetError::over_quota().with_description(format!( + "You have exceeded your quota of {} public keys.", + key_quota + )))); + } + } + + if !key.key.ends_with('\n') { + key.key.push('\n'); + } + + match parse_public_key(key) { + Ok(Some(_)) => Ok(Ok(response)), + Ok(None) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Key) + .with_description("No valid public key found."))), + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Key) + .with_description(err.into_owned()))), + } +} diff --git a/crates/jmap/src/registry/mapping/queued_message.rs b/crates/jmap/src/registry/mapping/queued_message.rs new file mode 100644 index 0000000..599eaf7 --- /dev/null +++ b/crates/jmap/src/registry/mapping/queued_message.rs @@ -0,0 +1,762 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse}, + query::RegistryQueryFilters, + }, +}; +use common::{ + Server, + config::smtp::queue::{ArchivedQueueExpiry, QueueName}, + ipc::QueueEvent, +}; +use jmap_proto::{error::set::SetError, object::registry::RegistryComparator, types::state::State}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch}, + schema::{ + enums::{DeliveryErrorType, MessageFlag, RecipientFlag}, + prelude::{ObjectType, Property}, + structs::{ + DeliveryError, QueueExpiry, QueueExpiryAttempts, QueueExpiryTtl, QueuedMessage, + QueuedRecipient, RecipientStatus, ServerResponse, + }, + }, + types::{datetime::UTCDateTime, ipaddr::IpAddr, map::Map}, +}; +use smtp::queue::{ + self, ArchivedError, ArchivedErrorDetails, ArchivedMessage, ArchivedStatus, ErrorDetails, + FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, FROM_UNAUTHENTICATED, + FROM_UNAUTHENTICATED_DMARC, Message, MessageWrapper, RCPT_DSN_SENT, Schedule, Status, + rcpt_spam_percentage, spool::SmtpSpool, +}; +use std::str::FromStr; +use store::{ + Deserialize, IterateParams, U64_LEN, ValueKey, + ahash::AHashSet, + registry::{RegistryFilterOp, RegistryQuery}, + write::{AlignedBytes, Archive, QueueClass, ValueClass, key::DeserializeBigEndian, now}, +}; +use trc::AddContext; +use types::{blob::BlobId, blob_hash::BlobHash, id::Id}; +use utils::{DomainPart, map::vec_map::VecMap}; + +pub(crate) async fn queued_message_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + // Fail all create operations + set.fail_all_create("Queued messages cannot be created"); + + // Obtain tenant domains + let tenant_domains = if let Some(tenant_id) = set.access_token.tenant_id() { + Some(tenant_domains(set.server, tenant_id).await?) + } else { + None + }; + + // Process update operations + let mut refresh_queue = false; + 'outer: for (id, value) in set.update.drain(..) { + let queue_id = id.id(); + let Some(archive) = set.server.read_message_archive(queue_id).await? else { + set.response.not_updated.append(id, SetError::not_found()); + continue; + }; + let archived_message = archive.to_unarchived::()?; + if !tenant_domains.as_ref().is_none_or(|domains| { + archived_message + .inner + .return_path + .try_domain_part() + .is_some_and(|domain| domains.contains(domain)) + }) { + set.response.not_updated.append(id, SetError::not_found()); + continue; + } + + // Process patches + let mut message = map_message(archived_message.inner); + message.next_retry = None; + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + if let Err(err) = message.patch(JsonPointerPatch::new(&ptr).with_create(false), value) { + set.response.not_updated.append(id, err.into()); + continue 'outer; + } + } + let set_next_retry = message.next_retry; + + // Process changes + let mut has_changes = false; + let mut modified_rcpts = AHashSet::new(); + let mut queued_message = archived_message.deserialize()?; + let prev_events = queued_message.next_events(); + if queued_message.env_id.as_deref() != message.env_id.as_deref() { + queued_message.env_id = message.env_id.as_deref().map(|v| v.into()); + has_changes = true; + } + if queued_message.priority as i64 != message.priority { + queued_message.priority = message.priority as i16; + has_changes = true; + } + for (idx, rcpt) in queued_message.recipients.iter_mut().enumerate() { + if !message + .recipients + .iter() + .any(|(address, _)| address.as_str() == rcpt.address.as_ref()) + { + rcpt.status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: queue::Error::Io("Delivery canceled.".into()), + }); + has_changes = true; + modified_rcpts.insert(idx); + } + } + for (address, rcpt) in message.recipients.into_iter() { + let Some((idx, queued_rcpt)) = queued_message + .recipients + .iter_mut() + .enumerate() + .find(|(_, r)| r.address.as_ref() == address.as_str()) + else { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_description(format!("Recipient '{address}' does not exist")), + ); + continue 'outer; + }; + let mut changed = false; + if rcpt.orcpt.as_deref() != queued_rcpt.orcpt.as_deref() { + queued_rcpt.orcpt = rcpt.orcpt.as_deref().map(|v| v.into()); + changed = true; + } + let expiry = match rcpt.expires { + QueueExpiry::Ttl(ttl) => common::config::smtp::queue::QueueExpiry::Ttl( + (ttl.expires_at.timestamp() as u64).saturating_sub(queued_message.created), + ), + QueueExpiry::Attempts(attempts) => { + common::config::smtp::queue::QueueExpiry::Attempts( + attempts.expires_attempts as u32, + ) + } + }; + if expiry != queued_rcpt.expires { + queued_rcpt.expires = expiry; + changed = true; + } + + for (due, count, field) in [ + (rcpt.retry_due, rcpt.retry_count, &mut queued_rcpt.retry), + (rcpt.notify_due, rcpt.notify_count, &mut queued_rcpt.notify), + ] { + let schedule = Schedule { + due: due.timestamp() as u64, + inner: count as u32, + }; + if schedule != *field { + *field = schedule; + changed = true; + } + } + + if let Some(next_retry) = set_next_retry + && !matches!(queued_rcpt.status, Status::PermanentFailure(_)) + { + let new_due = next_retry.timestamp() as u64; + if queued_rcpt.retry.due != new_due { + queued_rcpt.retry.due = new_due; + changed = true; + } + } + + if matches!(rcpt.status, RecipientStatus::Scheduled) + && !matches!(queued_rcpt.status, Status::Scheduled) + { + queued_rcpt.status = Status::Scheduled; + changed = true; + } + + if changed { + has_changes = true; + modified_rcpts.insert(idx); + } + } + + if has_changes { + // Delete message if there are no pending deliveries + let message = MessageWrapper::new(queued_message, queue_id, QueueName::default()); + let is_success = if message.message.recipients.iter().any(|recipient| { + matches!( + recipient.status, + Status::TemporaryFailure(_) | Status::Scheduled + ) + }) { + message + .save_registry_changes(set.server, prev_events, modified_rcpts) + .await + } else { + message.remove_registry(set.server, prev_events).await + }; + + if !is_success { + set.response.not_updated.append( + id, + SetError::forbidden().with_description("Queue update operation failed"), + ); + continue; + } + + refresh_queue = true; + } + + set.response.updated.append(id, None); + } + + if refresh_queue { + let _ = set + .server + .inner + .ipc + .queue_tx + .send(QueueEvent::Refresh) + .await; + } + + // Process destroy operations + for id in set.destroy.drain(..) { + let Some(message) = set.server.read_message(id.id(), QueueName::default()).await else { + set.response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + if tenant_domains.as_ref().is_none_or(|domains| { + message + .message + .return_path + .try_domain_part() + .is_some_and(|domain| domains.contains(domain)) + }) { + if message.remove(set.server, None).await { + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append( + id, + SetError::forbidden().with_description("Queue delete operation failed"), + ); + } + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + + Ok(set) +} + +pub(crate) async fn queued_message_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let client_ids = get.ids.is_some(); + let ids = if let Some(ids) = get.ids.take() { + ids + } else { + queued_ids(get.server, get.server.core.jmap.get_max_objects) + .await? + .into_iter() + .map(Id::from) + .collect() + }; + + // Obtain tenant domains + let tenant_domains = if let Some(tenant_id) = get.access_token.tenant_id() { + Some(tenant_domains(get.server, tenant_id).await?) + } else { + None + }; + + for id in ids { + let Some(message_archive) = get.server.read_message_archive(id.id()).await? else { + if client_ids { + get.not_found(id); + } + continue; + }; + let message_in = message_archive.unarchive::()?; + if tenant_domains.as_ref().is_none_or(|domains| { + message_in + .return_path + .try_domain_part() + .is_some_and(|domain| domains.contains(domain)) + }) { + get.insert(id, map_message(message_in).into_value()); + } else if client_ids { + get.not_found(id); + } + } + + Ok(get) +} + +pub(crate) async fn queued_message_query( + mut req: RegistryQueryResponse<'_>, +) -> trc::Result { + let mut due_from = 0u64; + let mut due_to = u64::MAX; + let mut queue_name = None; + let mut filter_text = None; + let mut filter_from = None; + let mut filter_to = None; + + // Obtain tenant domains + let tenant_domains = if let Some(tenant_id) = req.access_token.tenant_id() { + Some(tenant_domains(req.server, tenant_id).await?) + } else { + None + }; + + req.request + .extract_filters(|property, op, value| match property { + Property::Due => { + if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) { + let due = due.timestamp() as u64; + let (from, to) = match op { + RegistryFilterOp::Equal => (due, due), + RegistryFilterOp::GreaterThan => (due + 1, u64::MAX), + RegistryFilterOp::GreaterEqualThan => (due, u64::MAX), + RegistryFilterOp::LowerThan => (0, due - 1), + RegistryFilterOp::LowerEqualThan => (0, due), + _ => return false, + }; + + // Intersect with existing range + due_from = due_from.max(from); + due_to = due_to.min(to); + + due_from <= due_to + } else { + false + } + } + Property::QueueName => { + if let Some(value) = value.as_str().and_then(QueueName::new) { + queue_name = Some(value); + true + } else { + false + } + } + Property::ReturnPath => { + if let serde_json::Value::String(name) = value { + filter_from = Some(name); + true + } else { + false + } + } + Property::To => { + if let serde_json::Value::String(name) = value { + filter_to = Some(name); + true + } else { + false + } + } + Property::Text => { + if let serde_json::Value::String(name) = value { + filter_text = Some(name); + true + } else { + false + } + } + _ => false, + })?; + + if req + .request + .sort + .as_ref() + .and_then(|sort| sort.first()) + .is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due))) + { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details("Only sorting by 'due' is supported for queued messages".to_string())); + } + + let params = req + .request + .extract_parameters(req.server.core.jmap.query_max_results, None)?; + + let has_filters = filter_text.is_some() || filter_from.is_some() || filter_to.is_some(); + if has_filters || tenant_domains.is_some() { + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))); + + let mut results = Vec::with_capacity(8); + req.server + .core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let message_ = as Deserialize>::deserialize(value) + .add_context(|ctx| ctx.ctx(trc::Key::Key, key))?; + let message = message_ + .unarchive::() + .add_context(|ctx| ctx.ctx(trc::Key::Key, key))?; + + if let Some(due) = message.next_delivery_event(queue_name) + && tenant_domains + .as_ref() + .is_none_or(|domains| message.has_domain(domains)) + && (due_from..=due_to).contains(&due) + && queue_name + .as_ref() + .is_none_or(|q| message.recipients.iter().any(|r| &r.queue == q)) + && (!has_filters + || (filter_text + .as_ref() + .map(|text| { + message.return_path.contains(text) + || message + .recipients + .iter() + .any(|r| r.address().contains(text)) + }) + .unwrap_or_else(|| { + filter_from + .as_ref() + .is_none_or(|from| message.return_path.contains(from)) + && filter_to.as_ref().is_none_or(|to| { + message + .recipients + .iter() + .any(|r| r.address().contains(to)) + }) + }))) + { + results.push((key.deserialize_be_u64(0)?, due)); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + // Build response + let mut response = QueryResponseBuilder::new( + results.len(), + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + if params.sort_ascending { + results.sort_by_key(|(_, due)| *due); + } else { + results.sort_by_key(|(_, due)| u64::MAX - *due); + } + + for (id, _) in results { + if !response.add_id(id.into()) { + break; + } + } + + Ok(response) + } else { + // Build response + let mut response = QueryResponseBuilder::new( + req.server.core.jmap.query_max_results, + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + let mut total = 0; + if let Some(anchor) = req.request.anchor { + let anchor_id = anchor.id(); + if let Some(archive) = req.server.read_message_archive(anchor_id).await? + && let Ok(archived) = archive.unarchive::() + && let Some(anchor_due) = archived.next_delivery_event(queue_name) + && anchor_due >= due_from + && anchor_due <= due_to + { + if params.sort_ascending { + due_from = anchor_due; + } else { + due_to = anchor_due; + } + } + } + + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: due_from, + queue_id: 0, + queue_name: [0; 8], + }, + ))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: due_to, + queue_id: u64::MAX, + queue_name: [u8::MAX; 8], + }, + ))); + + let mut seen_ids = AHashSet::with_capacity(8); + req.server + .store() + .iterate( + IterateParams::new(from_key, to_key) + .set_ascending(params.sort_ascending) + .no_values(), + |key, _| { + let id = key.deserialize_be_u64(U64_LEN)?; + if queue_name.is_none_or(|queue_name| { + queue_name.as_slice() == key.get(U64_LEN * 2..).unwrap_or_default() + }) && seen_ids.insert(id) + { + total += 1; + if response.response.total.is_some() { + if !response.is_full() { + response.add_id(id.into()); + } + Ok(true) + } else { + Ok(response.add_id(id.into())) + } + } else { + Ok(true) + } + }, + ) + .await + .caused_by(trc::location!())?; + + if response.response.total.is_some() { + response.response.total = Some(total); + } + + if let Some(limit) = response.response.limit + && total < limit + { + response.response.limit = None; + } + + Ok(response) + } +} + + +#[cfg(not(feature = "enterprise"))] +async fn tenant_domains(_server: &Server, _tenant_id: u32) -> trc::Result> { + Ok(AHashSet::new()) +} + +fn map_message(message_in: &ArchivedMessage) -> QueuedMessage { + let mut message_out = QueuedMessage { + blob_id: BlobId::new(BlobHash::from(&message_in.blob_hash), Default::default()), + created_at: UTCDateTime::from_timestamp(message_in.created.to_native() as i64), + env_id: message_in.env_id.as_ref().map(|v| v.to_string()), + flags: Map::with_capacity(1), + priority: message_in.priority.to_native() as i64, + received_from_ip: IpAddr(message_in.received_from_ip.as_ipaddr()), + received_via_port: message_in.received_via_port.to_native() as u64, + recipients: VecMap::with_capacity(message_in.recipients.len()), + return_path: if !message_in.return_path.is_empty() { + message_in.return_path.to_string() + } else { + "<>".to_string() + }, + size: message_in.size.to_native(), + next_retry: UTCDateTime::from_timestamp( + message_in + .next_delivery_event(None) + .unwrap_or_else(now) + .cast_signed(), + ) + .into(), + next_notify: message_in + .next_notify_event(None) + .map(|ts| UTCDateTime::from_timestamp(ts.cast_signed())), + }; + + // Parse flags + let flags = message_in.flags.to_native(); + for (bit, flag) in [ + (FROM_AUTHENTICATED, MessageFlag::Authenticated), + (FROM_UNAUTHENTICATED, MessageFlag::Unauthenticated), + ( + FROM_UNAUTHENTICATED_DMARC, + MessageFlag::UnauthenticatedDmarc, + ), + (FROM_DSN, MessageFlag::Dsn), + (FROM_REPORT, MessageFlag::Report), + (FROM_AUTOGENERATED, MessageFlag::Autogenerated), + ] { + if flags & bit != 0 { + message_out.flags.push(flag); + } + } + + // Parse recipients + for rcpt_in in message_in.recipients.iter() { + let mut rcpt_out = QueuedRecipient { + expires: match &rcpt_in.expires { + ArchivedQueueExpiry::Ttl(ttl) => QueueExpiry::Ttl(QueueExpiryTtl { + expires_at: UTCDateTime::from_timestamp( + message_in.created.to_native() as i64 + ttl.to_native() as i64, + ), + }), + ArchivedQueueExpiry::Attempts(attempts) => { + QueueExpiry::Attempts(QueueExpiryAttempts { + expires_attempts: attempts.to_native() as u64, + }) + } + }, + flags: Default::default(), + notify_count: rcpt_in.notify.inner.to_native() as u64, + notify_due: UTCDateTime::from_timestamp(rcpt_in.notify.due.to_native() as i64), + orcpt: rcpt_in.orcpt.as_ref().map(|v| v.to_string()), + queue_name: rcpt_in.queue.as_str().to_string(), + retry_count: rcpt_in.retry.inner.to_native() as u64, + retry_due: UTCDateTime::from_timestamp(rcpt_in.retry.due.to_native() as i64), + status: match &rcpt_in.status { + ArchivedStatus::Scheduled => RecipientStatus::Scheduled, + ArchivedStatus::Completed(status) => RecipientStatus::Completed(ServerResponse { + response_code: (status.response.code.to_native() as u64).into(), + response_enhanced: build_enhanced_code(&status.response.esc).into(), + response_hostname: status.hostname.to_string().into(), + response_message: status.response.message.to_string().into(), + }), + ArchivedStatus::TemporaryFailure(status) => { + RecipientStatus::TemporaryFailure(map_error_details(status)) + } + ArchivedStatus::PermanentFailure(status) => { + RecipientStatus::PermanentFailure(map_error_details(status)) + } + }, + }; + + // Parse recipient flags + let rcpt_flags = rcpt_in.flags.to_native(); + for (bit, flag) in [(RCPT_DSN_SENT, RecipientFlag::DsnSent)] { + if rcpt_flags & bit != 0 { + rcpt_out.flags.push(flag); + } + } + if rcpt_spam_percentage(rcpt_flags).is_some_and(|percentage| percentage >= 50) { + rcpt_out.flags.push(RecipientFlag::SpamPayload); + } + + message_out + .recipients + .append(rcpt_in.address.to_string(), rcpt_out); + } + + message_out +} + +fn map_error_details(err_in: &ArchivedErrorDetails) -> DeliveryError { + let mut err_out = DeliveryError { + response_hostname: err_in.entity.to_string().into(), + ..Default::default() + }; + + match &err_in.details { + ArchivedError::DnsError(e) => { + err_out.error_type = DeliveryErrorType::DnsError; + err_out.error_message = e.to_string().into(); + } + ArchivedError::UnexpectedResponse(e) => { + err_out.error_type = DeliveryErrorType::UnexpectedResponse; + err_out.error_command = e.command.to_string().into(); + err_out.response_code = (e.response.code.to_native() as u64).into(); + err_out.response_enhanced = build_enhanced_code(&e.response.esc).into(); + err_out.response_message = e.response.message.to_string().into(); + } + ArchivedError::ConnectionError(e) => { + err_out.error_type = DeliveryErrorType::ConnectionError; + err_out.error_message = e.to_string().into(); + } + ArchivedError::TlsError(e) => { + err_out.error_type = DeliveryErrorType::TlsError; + err_out.error_message = e.to_string().into(); + } + ArchivedError::DaneError(e) => { + err_out.error_type = DeliveryErrorType::DaneError; + err_out.error_message = e.to_string().into(); + } + ArchivedError::MtaStsError(e) => { + err_out.error_type = DeliveryErrorType::MtaStsError; + err_out.error_message = e.to_string().into(); + } + ArchivedError::RateLimited => { + err_out.error_type = DeliveryErrorType::RateLimited; + } + ArchivedError::ConcurrencyLimited => { + err_out.error_type = DeliveryErrorType::ConcurrencyLimited; + } + ArchivedError::Io(e) => { + err_out.error_type = DeliveryErrorType::Io; + err_out.error_message = e.to_string().into(); + } + } + + err_out +} + +fn build_enhanced_code(esc: &[u8; 3]) -> String { + format!("{}.{}.{}", esc[0], esc[1], esc[2]) +} + +async fn queued_ids(server: &Server, max_results: usize) -> trc::Result> { + let mut events = AHashSet::with_capacity(8); + + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: 0, + queue_id: 0, + queue_name: [0; 8], + }, + ))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: u64::MAX, + queue_id: u64::MAX, + queue_name: [u8::MAX; 8], + }, + ))); + + server + .store() + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + events.insert(key.deserialize_be_u64(U64_LEN)?); + + Ok(events.len() < max_results) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| events) +} diff --git a/crates/jmap/src/registry/mapping/report.rs b/crates/jmap/src/registry/mapping/report.rs new file mode 100644 index 0000000..da01a22 --- /dev/null +++ b/crates/jmap/src/registry/mapping/report.rs @@ -0,0 +1,421 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse}, + query::RegistryQueryFilters, + }, +}; +use jmap_proto::{error::set::SetError, types::state::State}; +use jmap_tools::{Key, Value}; +use registry::{ + jmap::IntoValue, + schema::prelude::{Object, ObjectInner, ObjectType, Property}, + types::{EnumImpl, datetime::UTCDateTime}, +}; +use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex}; +use std::str::FromStr; +use store::{ + U64_LEN, ValueKey, + registry::{RegistryFilter, RegistryFilterValue, RegistryQuery}, + write::{BatchBuilder, RegistryClass, ValueClass, key::KeySerializer}, +}; +use trc::AddContext; +use types::id::Id; + +pub(crate) async fn report_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + let object_id = set.object_type.to_id(); + + // Reports cannot be created + set.fail_all_create("Reports cannot be created"); + + let mut batch = BatchBuilder::new(); + if matches!( + set.object_type, + ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport + ) { + let now = UTCDateTime::now(); + 'outer: for (id, value) in set.update.drain(..) { + // Extract new deliverAt value + let mut deliver_at = None; + for (key, value) in value.into_expanded_object() { + match (key, value) { + (Key::Property(Property::DeliverAt), Value::Str(deliver_at_)) => { + deliver_at = UTCDateTime::from_str(deliver_at_.as_ref()) + .ok() + .filter(|da| *da > now); + if deliver_at.is_none() { + set.response.not_updated.append( + id, + SetError::invalid_patch() + .with_property(Property::DeliverAt) + .with_description("Invalid value for property"), + ); + continue 'outer; + } + } + (Key::Property(Property::Id), _) => {} + (key, _) => { + set.response.not_updated.append( + id, + SetError::invalid_properties().with_property(key.into_owned()), + ); + continue 'outer; + } + } + } + let Some(deliver_at) = deliver_at else { + set.response.not_updated.append( + id, + SetError::invalid_patch() + .with_property(Key::Property(Property::DeliverAt)) + .with_description("Missing required property"), + ); + continue; + }; + + let item_id = id.id(); + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + if let Some(mut report_obj) = set + .server + .store() + .get_value::(ValueKey::from(key.clone())) + .await? + { + match &mut report_obj.inner { + ObjectInner::DmarcInternalReport(report) => { + report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at); + } + ObjectInner::TlsInternalReport(report) => { + report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at); + } + _ => {} + } + batch.commit_point(); + + set.response.updated.append(id, None); + } else { + set.response.not_updated.append(id, SetError::not_found()); + } + } + } else { + // External reports cannot be updated + set.fail_all_update("External reports cannot be updated"); + } + + // Process reports to destroy + let tenant_id = set.access_token.tenant_id().map(Id::from); + for id in set.destroy.drain(..) { + let item_id = id.id(); + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + if let Some(report) = set + .server + .store() + .get_value::(ValueKey::from(key)) + .await? + .filter(|report| { + !set.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id + }) + { + match &report.inner { + ObjectInner::DmarcExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::TlsExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::ArfExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::DmarcInternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::TlsInternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + _ => {} + } + batch.commit_point(); + + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + + if !batch.is_empty() { + set.server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(set) +} + +pub(crate) async fn report_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let object_id = get.object_type.to_id(); + let ids = if let Some(ids) = get.ids.take() { + ids + } else if matches!( + get.object_type, + ObjectType::DmarcExternalReport + | ObjectType::TlsExternalReport + | ObjectType::ArfExternalReport + ) { + if get.is_tenant_filtered { + get.server.registry().query::>( + RegistryQuery::new(get.object_type) + .with_tenant(get.access_token.tenant_id()) + .with_limit(get.server.core.jmap.get_max_objects), + ) + } else { + get.server.registry().query::>( + RegistryQuery::new(get.object_type) + .greater_than(Property::ExpiresAt, 0u64) + .with_limit(get.server.core.jmap.get_max_objects), + ) + } + .await? + } else { + get.server + .registry() + .query::>( + RegistryQuery::new(get.object_type) + .filter(RegistryFilter::greater_than( + Property::Domain, + RegistryFilterValue::Bytes(vec![]), + true, + )) + .with_limit(get.server.core.jmap.get_max_objects), + ) + .await? + }; + + let tenant_id = get.access_token.tenant_id().map(Id::from); + for id in ids { + if let Some(report) = get + .server + .store() + .get_value::(ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: id.id(), + }))) + .await? + .filter(|report| { + !get.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id + }) + { + get.insert(id, report.into_value()); + } else { + get.not_found(id); + } + } + + Ok(get) +} + +pub(crate) async fn report_query( + mut req: RegistryQueryResponse<'_>, +) -> trc::Result { + let mut query = store::registry::RegistryQuery::new(req.object_type) + .with_tenant(req.access_token.tenant_id()); + let is_internal = matches!( + req.object_type, + ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport + ); + + req.request + .extract_filters(|property, op, value| match property { + Property::Domain => { + if let serde_json::Value::String(value) = value { + match req.object_type { + ObjectType::DmarcInternalReport => { + query.filters.push(RegistryFilter::greater_than_or_equal( + property, + RegistryFilterValue::Bytes( + KeySerializer::new(value.len() + U64_LEN) + .write(value.as_str()) + .write(0u64) + .finalize(), + ), + true, + )); + query.filters.push(RegistryFilter::less_than_or_equal( + property, + RegistryFilterValue::Bytes( + KeySerializer::new(value.len() + U64_LEN) + .write(value.as_str()) + .write(u64::MAX) + .finalize(), + ), + true, + )); + + true + } + ObjectType::TlsInternalReport => { + query + .filters + .push(RegistryFilter::equal(property, value, true)); + true + } + _ => false, + } + } else { + false + } + } + Property::Text if !is_internal => { + if let serde_json::Value::String(value) = value { + query.filters.push(RegistryFilter::text(property, value)); + true + } else { + false + } + } + Property::MemberTenantId if !is_internal => { + if req.access_token.tenant_id().is_none() + && let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok()) + { + query + .filters + .push(RegistryFilter::equal(property, id.id(), false)); + true + } else { + false + } + } + Property::TotalFailedSessions | Property::TotalSuccessfulSessions if !is_internal => { + if let Some(value) = value.as_u64() { + query.filters.push(store::registry::RegistryFilter { + property, + op, + value: value.into(), + is_pk: false, + }); + true + } else { + false + } + } + Property::ExpiresAt if !is_internal => { + if let Some(value) = value + .as_str() + .and_then(|value| UTCDateTime::from_str(value).ok()) + { + query.filters.push(store::registry::RegistryFilter { + property, + op, + value: (value.timestamp() as u64).into(), + is_pk: false, + }); + true + } else { + false + } + } + _ => false, + })?; + + let params = req + .request + .extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?; + + if !query.has_filters() { + if is_internal { + query.filters.push(RegistryFilter::greater_than( + Property::Domain, + RegistryFilterValue::Bytes(vec![]), + true, + )); + } else { + query.filters.push(RegistryFilter::greater_than( + Property::ExpiresAt, + 0u64, + false, + )); + } + } + if let Some(limit) = params.limit { + query = query.with_limit(limit); + if let Some(anchor) = params.anchor { + query = query.with_anchor(anchor); + } else if let Some(position) = params.position { + query = query.with_index_start(position); + } + } + + let matches = req.server.registry().query::>(query).await?; + let results = match params.sort_by { + Property::Id => { + let mut results = matches; + if !params.sort_ascending { + results.sort_unstable_by(|a, b| b.cmp(a)); + } + results + } + Property::Domain if is_internal => { + if !matches.is_empty() { + req.server + .registry() + .sort_by_pk( + req.object_type, + Property::Domain, + Some(matches), + params.sort_ascending, + ) + .await? + } else { + vec![] + } + } + Property::ExpiresAt if !is_internal => { + if !matches.is_empty() { + req.server + .registry() + .sort_by_index( + req.object_type, + Property::ExpiresAt, + Some(matches), + params.sort_ascending, + ) + .await? + } else { + vec![] + } + } + property => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!( + "Property {} is not supported for sorting", + property + ))); + } + }; + + // Build response + let mut response = QueryResponseBuilder::new( + results.len(), + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + for id in results { + if !response.add_id(id) { + break; + } + } + + Ok(response) +} diff --git a/crates/jmap/src/registry/mapping/sieve.rs b/crates/jmap/src/registry/mapping/sieve.rs new file mode 100644 index 0000000..dbd5dd2 --- /dev/null +++ b/crates/jmap/src/registry/mapping/sieve.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ObjectResponse, ValidationResult}; +use common::Server; +use jmap_proto::error::set::SetError; +use registry::schema::prelude::Property; + +pub(crate) async fn validate_sieve_script( + server: &Server, + script: &str, + old_script: Option<&str>, + is_system_script: bool, +) -> ValidationResult { + if old_script.is_none_or(|old_script| old_script != script) { + if is_system_script { + if let Err(err) = server + .core + .sieve + .trusted_compiler + .compile(script.as_bytes()) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Contents) + .with_description(format!( + "Failed to compile system Sieve script: {err}" + )))); + } + } else { + if let Err(err) = server + .core + .sieve + .untrusted_compiler + .compile(script.as_bytes()) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Contents) + .with_description(format!( + "Failed to compile user Sieve script: {err}" + )))); + } + } + } + + Ok(Ok(ObjectResponse::default())) +} diff --git a/crates/jmap/src/registry/mapping/spam_sample.rs b/crates/jmap/src/registry/mapping/spam_sample.rs new file mode 100644 index 0000000..f78df66 --- /dev/null +++ b/crates/jmap/src/registry/mapping/spam_sample.rs @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + blob::download::BlobDownload, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse}, + query::RegistryQueryFilters, + }, +}; +use jmap_proto::{error::set::SetError, types::state::State}; +use jmap_tools::JsonPointer; +use mail_parser::{MessageParser, parsers::fields::thread::thread_name}; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch}, + schema::{ + enums::Permission, + prelude::{ObjectType, Property}, + structs::SpamTrainingSample, + }, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId}, +}; +use std::str::FromStr; +use store::{ + SerializeInfallible, ValueKey, + registry::RegistryQuery, + write::{BatchBuilder, BlobLink, BlobOp, RegistryClass, ValueClass, now}, +}; +use trc::AddContext; +use types::{blob::BlobClass, id::Id}; + +pub(crate) async fn spam_sample_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + // Spam samples cannot be modified + set.fail_all_update("Spam training samples cannot be modified."); + + let mut batch = BatchBuilder::new(); + let object_id = set.object_type.to_id(); + + // Process samples to create + let hold_samples_for = set + .server + .core + .spam + .classifier + .as_ref() + .map(|config| config.hold_samples_for); + let now = now(); + 'outer: for (id, value) in set.create.drain() { + let mut sample = SpamTrainingSample::default(); + let Some(expires_at) = hold_samples_for.map(|d| now + d) else { + set.response.not_created.append( + id, + SetError::forbidden() + .with_description("Spam classifier is not configured on the server"), + ); + continue; + }; + if let Err(err) = sample.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])) + .with_create(true) + .with_can_set_account(!set.is_account_filtered), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; + }; + + if sample.blob_id.hash.is_empty() { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::BlobId) + .with_description("blobId is required"), + ); + continue; + } + + let Some(bytes) = set + .server + .blob_download(&sample.blob_id, set.access_token) + .await? + else { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::BlobId) + .with_description("blobId does not exist or is not accessible"), + ); + continue; + }; + + if bytes.len() > set.server.core.email.mail_max_size { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::BlobId) + .with_description(format!( + "blob size exceeds maximum of {} bytes", + set.server.core.email.mail_max_size + )), + ); + continue; + } + + let Some(message) = MessageParser::new().parse(&bytes) else { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::BlobId) + .with_description("Blob content is not a valid email message"), + ); + continue; + }; + + let subject = message.subject().map(thread_name).unwrap_or_default(); + let from = message + .from() + .and_then(|from| from.first().and_then(|addr| addr.address())) + .unwrap_or_default(); + if subject.is_empty() && from.is_empty() { + set.response.not_created.append( + id, + SetError::invalid_properties() + .with_property(Property::BlobId) + .with_description("Email message must have a subject or a from header"), + ); + continue; + } + + sample.subject = subject.to_string(); + sample.from = from.to_lowercase(); + sample.expires_at = UTCDateTime::from_timestamp(expires_at as i64); + if set.is_account_filtered { + sample.account_id = Some(set.account_id.into()); + } + + // Write sample to store + let item_id = set.server.registry().assign_id(); + batch + .set( + BlobOp::Link { + hash: sample.blob_id.hash.clone(), + to: BlobLink::Temporary { until: expires_at }, + }, + ObjectId::new(ObjectType::SpamTrainingSample, item_id.into()).serialize(), + ) + .set( + ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: sample + .account_id + .map(|id| id.id()) + .unwrap_or(u32::MAX as u64) + .serialize(), + }), + vec![], + ) + .set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + sample.to_pickled_vec(), + ); + + set.response.created(id, item_id); + } + + // Process samples to destroy + for id in set.destroy.drain(..) { + let item_id = id.id(); + + if let Some(sample) = set + .server + .store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::Item { + object_id, + item_id: id.id(), + }, + ))) + .await? + .filter(|sample| { + !set.is_account_filtered + || sample + .account_id + .is_some_and(|account_id| account_id.document_id() == set.account_id) + }) + { + let account_id = sample + .account_id + .map(|id| id.document_id()) + .unwrap_or(u32::MAX); + + batch + .with_account_id(account_id) + .clear(BlobOp::Link { + hash: sample.blob_id.hash, + to: BlobLink::Temporary { + until: sample.expires_at.timestamp() as u64, + }, + }) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id, + })) + .clear(ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (account_id as u64).serialize(), + })) + .commit_point(); + + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + + if !batch.is_empty() { + set.server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(set) +} + +pub(crate) async fn spam_sample_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let object_id = get.object_type.to_id(); + let ids = if let Some(ids) = get.ids.take() { + ids + } else { + let query = if !get.is_account_filtered { + RegistryQuery::new(get.object_type).greater_than_or_equal(Property::AccountId, 0u64) + } else { + RegistryQuery::new(get.object_type).with_account(get.account_id) + } + .with_limit(get.server.core.jmap.get_max_objects); + + get.server.registry().query::>(query).await? + }; + + for id in ids { + if let Some(mut sample) = get + .server + .store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::Item { + object_id, + item_id: id.id(), + }, + ))) + .await? + .filter(|sample| { + !get.is_account_filtered + || sample + .account_id + .is_some_and(|account_id| account_id.document_id() == get.account_id) + }) + { + if get.is_account_filtered { + sample.blob_id.class = BlobClass::Reserved { + account_id: get.account_id, + expires: sample.expires_at.timestamp() as u64, + }; + } + + get.insert(id, sample.into_value()); + } else { + get.not_found(id); + } + } + + Ok(get) +} + +pub(crate) async fn spam_sample_query( + mut req: RegistryQueryResponse<'_>, +) -> trc::Result { + let can_impersonate = req.access_token.has_permission(Permission::Impersonate); + let mut account_id = None; + + req.request + .extract_filters(|property, _, value| match property { + Property::AccountId if can_impersonate => { + if let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok()) { + account_id = Some(id); + true + } else { + false + } + } + + _ => false, + })?; + + let mut query = if let Some(account_id) = account_id { + RegistryQuery::new(req.object_type).with_account(account_id.document_id()) + } else if !can_impersonate { + RegistryQuery::new(req.object_type).with_account(req.request.account_id.document_id()) + } else { + RegistryQuery::new(req.object_type).greater_than_or_equal(Property::AccountId, 0u64) + }; + + let params = req + .request + .extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?; + + if let Some(limit) = params.limit { + query = query.with_limit(limit); + if let Some(anchor) = params.anchor { + query = query.with_anchor(anchor); + } else if let Some(position) = params.position { + query = query.with_index_start(position); + } + } + + let mut results = req.server.registry().query::>(query).await?; + + match params.sort_by { + Property::Id => { + if !params.sort_ascending { + results.sort_unstable_by(|a, b| b.cmp(a)); + } + } + property => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!( + "Property {} is not supported for sorting", + property + ))); + } + } + + // Build response + let mut response = QueryResponseBuilder::new( + results.len(), + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + for id in results { + if !response.add_id(id) { + break; + } + } + + Ok(response) +} diff --git a/crates/jmap/src/registry/mapping/task.rs b/crates/jmap/src/registry/mapping/task.rs new file mode 100644 index 0000000..9669c6c --- /dev/null +++ b/crates/jmap/src/registry/mapping/task.rs @@ -0,0 +1,527 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse}, + query::RegistryQueryFilters, + }, +}; +use common::Server; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + object::registry::RegistryComparator, + types::state::State, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch}, + schema::{ + enums::{TaskStatusType, TaskType}, + prelude::Property, + structs::Task, + }, + types::{ + EnumImpl, ObjectImpl, + datetime::UTCDateTime, + index::{IndexBuilder, IndexKey}, + }, +}; +use services::task_manager::lock::TaskLockManager; +use std::str::FromStr; +use store::{ + IterateParams, SerializeInfallible, U64_LEN, ValueKey, + registry::RegistryFilterOp, + write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::id::Id; + +pub(crate) async fn task_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + let mut batch = BatchBuilder::new(); + let mut locked_tasks = Vec::new(); + + // Process creations + 'outer: for (id, value) in set.create.drain() { + let mut task = Task::default(); + if let Err(err) = task.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])) + .with_create(true) + .with_can_set_account(true), + value, + ) { + set.response.not_created.append(id, err.into()); + continue 'outer; + } + + let mut validation_errors = Vec::new(); + if !task.validate(&mut validation_errors) { + set.response.not_created.append( + id, + SetError::new(SetErrorType::ValidationFailed) + .with_validation_errors(validation_errors), + ); + continue 'outer; + } + + if !set.access_token.has_permission(task.permission()) { + set.response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "Insufficient permissions to create task of type {}", + task.object_type().as_str() + )), + ); + continue 'outer; + } + + let task_type = task.object_type(); + match task_type { + TaskType::IndexDocument + | TaskType::UnindexDocument + | TaskType::IndexTrace + | TaskType::AccountMaintenance + | TaskType::TenantMaintenance + | TaskType::StoreMaintenance + | TaskType::SpamFilterMaintenance + | TaskType::AcmeRenewal + | TaskType::DkimManagement + | TaskType::DnsManagement => { + let mut index = IndexBuilder::default(); + task.index(&mut index); + + // Validate foreign keys + for key in index.keys { + if let IndexKey::ForeignKey { + object_id: foreign_id, + .. + } = key + && !set + .server + .store() + .key_exists(ValueKey::from(ValueClass::Registry( + RegistryClass::IndexId { + object_id: foreign_id.object().to_id(), + item_id: foreign_id.id().id(), + }, + ))) + .await + .caused_by(trc::location!())? + { + set.response.not_created.append( + id, + SetError::new(SetErrorType::InvalidForeignKey) + .with_object_id(foreign_id), + ); + continue 'outer; + } + } + + let task_id = set.server.registry().assign_id(); + batch.schedule_task_with_id(task_id, task).commit_point(); + set.response.created(id, task_id); + } + TaskType::CalendarAlarmEmail + | TaskType::CalendarAlarmNotification + | TaskType::CalendarItipMessage + | TaskType::MergeThreads + | TaskType::DmarcReport + | TaskType::TlsReport + | TaskType::DestroyAccount + | TaskType::RestoreArchivedItem => { + set.response.not_created.append( + id, + SetError::forbidden().with_description(format!( + "{} is an internal task type that cannot be created by clients", + task_type.as_str() + )), + ); + } + } + } + + // Process updates + 'outer: for (id, value) in set.update.drain(..) { + let task_id = id.id(); + let Some(mut task) = set + .server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: task_id }, + ))) + .await? + else { + set.response.not_updated.append(id, SetError::not_found()); + continue; + }; + + if !set.access_token.has_permission(task.permission()) { + set.response.not_updated.append( + id, + SetError::forbidden().with_description(format!( + "Insufficient permissions to update task of type {}", + task.object_type().as_str() + )), + ); + continue 'outer; + } + + if !set.server.try_lock_task(task_id).await { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Task is currently being processed and cannot be updated".to_string(), + ), + ); + continue; + } + locked_tasks.push(task_id); + + let old_timestamp = task.due_timestamp(); + let old_status = task.status().clone(); + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + + if let Err(err) = task.patch( + JsonPointerPatch::new(&ptr) + .with_create(false) + .with_can_set_account(true), + value, + ) { + set.response.not_updated.append(id, err.into()); + continue 'outer; + } + } + + if task.status() != &old_status { + let timestamp = task.due_timestamp(); + if timestamp != old_timestamp { + batch + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: task_id, + due: old_timestamp, + })) + .set( + ValueClass::TaskQueue(TaskQueueClass::Due { + id: task_id, + due: timestamp, + }), + task.object_type().to_id().serialize(), + ); + } + + batch + .set( + ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id }), + task.to_pickled_vec(), + ) + .commit_point(); + } + + set.response.updated.append(id, None); + } + + // Process destructions + for id in set.destroy.drain(..) { + let task_id = id.id(); + let Some(task) = set + .server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: task_id }, + ))) + .await? + else { + set.response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + if !set.access_token.has_permission(task.permission()) { + set.response.not_destroyed.append( + id, + SetError::forbidden().with_description(format!( + "Insufficient permissions to destroy task of type {}", + task.object_type().as_str() + )), + ); + continue; + } + + locked_tasks.push(task_id); + + let due = task.due_timestamp(); + + + #[cfg(not(feature = "enterprise"))] + if let Task::DestroyAccount(_) = task { + set.response.not_destroyed.append( + id, + SetError::forbidden().with_description( + "Account recovery is not supported in this deployment".to_string(), + ), + ); + continue; + } + + batch + .clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id })) + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: task_id, + due, + })) + .commit_point(); + + set.response.destroyed.push(id); + } + + let has_changes = !batch.is_empty(); + if has_changes { + set.server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + for task_id in locked_tasks { + set.server.remove_index_lock(task_id).await; + } + + if has_changes { + set.server.notify_task_queue(); + } + + Ok(set) +} + +pub(crate) async fn task_get( + mut get: RegistryGetResponse<'_>, +) -> trc::Result> { + let ids = if let Some(ids) = get.ids.take() { + ids + } else { + task_ids(get.server, get.server.core.jmap.get_max_objects).await? + }; + let has_due_field = get.properties.is_empty() || get.properties.contains(&Property::Due); + + for id in ids { + if let Some(task) = get + .server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: id.id() }, + ))) + .await? + { + let due = task.due_timestamp(); + let mut task = task.into_value(); + if has_due_field && due != u64::MAX { + task.as_object_mut().unwrap().insert_unchecked( + Property::Due, + UTCDateTime::from_timestamp(due as i64).into_value(), + ); + } + + get.insert(id, task); + } else { + get.not_found(id); + } + } + + Ok(get) +} + +pub(crate) async fn task_query( + mut req: RegistryQueryResponse<'_>, +) -> trc::Result { + let mut due_from = 1u64; + let mut due_to = u64::MAX; + let mut typ = None; + + req.request + .extract_filters(|property, op, value| match property { + Property::Due => { + if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) { + let due = due.timestamp() as u64; + let (from, to) = match op { + RegistryFilterOp::Equal => (due, due), + RegistryFilterOp::GreaterThan => (due + 1, u64::MAX), + RegistryFilterOp::GreaterEqualThan => (due, u64::MAX), + RegistryFilterOp::LowerThan => (0, due - 1), + RegistryFilterOp::LowerEqualThan => (0, due), + _ => return false, + }; + + // Intersect with existing range + due_from = due_from.max(from); + due_to = due_to.min(to); + + due_from <= due_to + } else { + false + } + } + Property::Status => { + if let Some(typ) = value.as_str().and_then(TaskStatusType::parse) { + if typ == TaskStatusType::Failed { + due_from = u64::MAX; + due_to = u64::MAX; + } + true + } else { + false + } + } + Property::Type => { + if let Some(typ_) = value.as_str().and_then(TaskType::parse) { + typ = Some(typ_); + true + } else { + false + } + } + _ => false, + })?; + + let anchor_id = req.request.anchor.map(|anchor| anchor.id()); + if req + .request + .sort + .as_ref() + .and_then(|sort| sort.first()) + .is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due))) + { + return Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details("Only sorting by 'due' is supported for tasks".to_string())); + } + + let params = req + .request + .extract_parameters(req.server.core.jmap.query_max_results, None)?; + + let mut from_id = 0u64; + let mut to_id = u64::MAX; + if let Some(anchor_id) = anchor_id + && let Some(anchor_task) = req + .server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: anchor_id }, + ))) + .await + .caused_by(trc::location!())? + { + let anchor_due = anchor_task.due_timestamp(); + if anchor_due >= due_from && anchor_due <= due_to { + if params.sort_ascending { + due_from = anchor_due; + from_id = anchor_id; + } else { + due_to = anchor_due; + to_id = anchor_id; + } + } + } + + // Build response + let mut response = QueryResponseBuilder::new( + req.server.core.jmap.query_max_results + 1, + req.server.core.jmap.query_max_results, + State::Initial, + &req.request, + ); + + let mut total = 0; + let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { + id: from_id, + due: due_from, + })); + let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { + id: to_id, + due: due_to, + })); + + req.server + .store() + .iterate( + IterateParams::new(from_key, to_key) + .set_ascending(params.sort_ascending) + .set_values(typ.is_some()), + |key, value| { + if let Some(typ) = typ { + let task_type = + TaskType::from_id(value.deserialize_be_u16(0)?).ok_or_else(|| { + trc::StoreEvent::DataCorruption + .into_err() + .ctx(trc::Key::Key, key.to_vec()) + .ctx(trc::Key::Value, value.to_vec()) + .caused_by(trc::location!()) + })?; + if task_type != typ { + return Ok(true); + } + } + + let id = key.deserialize_be_u64(U64_LEN)?; + total += 1; + if response.response.total.is_some() { + if !response.is_full() { + response.add_id(id.into()); + } + Ok(true) + } else { + Ok(response.add_id(id.into())) + } + }, + ) + .await + .caused_by(trc::location!())?; + + if response.response.total.is_some() { + response.response.total = Some(total); + } + + if let Some(limit) = response.response.limit + && total < limit + { + response.response.limit = None; + } + + Ok(response) +} + +async fn task_ids(server: &Server, max_results: usize) -> trc::Result> { + let mut tasks = Vec::with_capacity(8); + let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 1 })); + let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { + id: u64::MAX, + due: u64::MAX, + })); + + server + .store() + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + tasks.push(key.deserialize_be_u64(U64_LEN)?.into()); + + Ok(tasks.len() < max_results) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| tasks) +} diff --git a/crates/jmap/src/registry/mapping/tls.rs b/crates/jmap/src/registry/mapping/tls.rs new file mode 100644 index 0000000..632845f --- /dev/null +++ b/crates/jmap/src/registry/mapping/tls.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ + ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, +}; +use common::network::acme::{ + ParsedCert, + account::{EabSettings, acme_create_account}, +}; +use jmap_proto::error::set::SetError; +use registry::{ + jmap::JmapValue, + schema::{ + enums::TenantStorageQuota, + prelude::Property, + structs::{AcmeProvider, Certificate}, + }, + types::{datetime::UTCDateTime, map::Map}, +}; +use utils::map::vec_map::VecMap; + +pub(crate) async fn validate_acme_provider( + set: &RegistrySetResponse<'_>, + provider: &mut AcmeProvider, + unpatched_properties: VecMap>, +) -> ValidationResult { + let response = match validate_tenant_quota( + set.server, + set.access_token, + TenantStorageQuota::MaxAcmeProviders, + ) + .await? + { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + }; + + // Obtain EAB credentials + let mut eab_key_id = None; + let mut eab_hmac_key = None; + for (key, value) in unpatched_properties { + match (key, value) { + (Property::EabKeyId, JmapValue::Str(value)) => { + eab_key_id = Some(value); + } + (Property::EabHmacKey, JmapValue::Str(value)) => { + eab_hmac_key = Some(value); + } + (_, JmapValue::Null) => {} + _ => { + return Ok(Err(SetError::invalid_properties().with_property(key))); + } + } + } + + let eab = if let (Some(key_id), Some(hmac_key)) = (eab_key_id, eab_hmac_key) { + match EabSettings::new(key_id.into_owned(), hmac_key.as_ref()) { + Ok(eab) => Some(eab), + Err(err) => { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::EabKeyId) + .with_property(Property::EabHmacKey) + .with_description(format!("Invalid EAB credentials: {err}")))); + } + } + } else { + None + }; + + match acme_create_account(provider, eab).await { + Ok(_) => Ok(Ok(response)), + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Directory) + .with_description(format!("Failed to create ACME account: {err}")))), + } +} + +pub(crate) async fn validate_certificate( + cert: &mut Certificate, + old_cert: Option<&Certificate>, +) -> ValidationResult { + if old_cert.is_none_or(|old_cert| old_cert.certificate != cert.certificate) { + match cert.certificate.value().await { + Ok(pem) => match ParsedCert::parse(pem.as_ref()) { + Ok(parsed) => { + cert.not_valid_after = + UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp()); + cert.not_valid_before = + UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp()); + cert.issuer = parsed.issuer; + cert.subject_alternative_names = Map::new(parsed.sans); + + Ok(Ok(ObjectResponse::default())) + } + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Certificate) + .with_description(format!("Failed to read certificate: {err}")))), + }, + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Certificate) + .with_description(format!("Failed to read certificate: {err}")))), + } + } else { + Ok(Ok(ObjectResponse::default())) + } +} diff --git a/crates/jmap/src/registry/mod.rs b/crates/jmap/src/registry/mod.rs new file mode 100644 index 0000000..6bb01bd --- /dev/null +++ b/crates/jmap/src/registry/mod.rs @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::Server; +use registry::schema::prelude::ObjectType; + +pub mod get; +pub mod mapping; +pub mod query; +pub mod set; + +pub trait EnterpriseRegistry { + fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()>; +} + +impl EnterpriseRegistry for Server { + fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> { + if !matches!( + object_type, + ObjectType::MaskedEmail + | ObjectType::ArchivedItem + | ObjectType::Metric + | ObjectType::Trace + ) { + return Ok(()); + } + + + Err(trc::JmapEvent::Forbidden.into_err().details(concat!( + "This feature is only available in the Enterprise edition. ", + "Obtain your trial license at https://license.stalw.art/trial." + ))) + } +} diff --git a/crates/jmap/src/registry/query.rs b/crates/jmap/src/registry/query.rs new file mode 100644 index 0000000..22ce60f --- /dev/null +++ b/crates/jmap/src/registry/query.rs @@ -0,0 +1,416 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + api::query::QueryResponseBuilder, + registry::{ + EnterpriseRegistry, + mapping::{ + RegistryQueryResponse, account::credential_query, cluster::cluster_node_query, + log::log_query, queued_message::queued_message_query, report::report_query, + spam_sample::spam_sample_query, task::task_query, + }, + }, +}; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + method::query::{Comparator, Filter, QueryRequest, QueryResponse}, + object::registry::{Registry, RegistryComparator, RegistryFilter, RegistryFilterOperator}, + types::state::State, +}; +use registry::{ + schema::{ + enums::{AccountType, Permission}, + prelude::{ObjectType, Property}, + }, + types::{ + EnumImpl, + index::{IndexSchemaType, IndexSchemaValueType}, + ipmask::IpAddrOrMask, + }, +}; +use std::str::FromStr; +use store::registry::{RegistryFilterOp, RegistryFilterValue}; +use types::id::Id; + +pub trait RegistryQuery: Sync + Send { + fn registry_query( + &self, + object_type: ObjectType, + request: QueryRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl RegistryQuery for Server { + async fn registry_query( + &self, + object_type: ObjectType, + mut request: QueryRequest, + access_token: &AccessToken, + ) -> trc::Result { + // Initial assertions + if self.registry().is_bootstrap_mode() { + return Err(trc::JmapEvent::Forbidden.into_err().details(concat!( + "The server is in bootstrap mode. Only the 'Bootstrap' object type ", + "can be accessed until the bootstrap process is complete.", + ))); + } + self.assert_enterprise_object(object_type)?; + + match object_type { + ObjectType::ArfExternalReport + | ObjectType::DmarcExternalReport + | ObjectType::TlsExternalReport + | ObjectType::DmarcInternalReport + | ObjectType::TlsInternalReport => report_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::SpamTrainingSample => spam_sample_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::QueuedMessage => queued_message_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::ClusterNode => cluster_node_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::ApiKey | ObjectType::AppPassword => { + credential_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()) + } + + ObjectType::Task => task_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::Log => log_query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + + ObjectType::Action => Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Actions cannot be queried")), + + _ => { + let mut query = store::registry::RegistryQuery::new(object_type) + .with_tenant(access_token.tenant_id()); + let can_impersonate = access_token.has_permission(Permission::Impersonate); + if !can_impersonate { + query = query.with_account(request.account_id.document_id()); + } + let indexes = object_type.indexes(); + request.extract_filters(|property, op, value| match property { + Property::MemberTenantId if access_token.tenant_id().is_some() => true, + Property::AccountId if !can_impersonate => true, + property => { + let Some(index) = indexes.iter().find(|i| i.prop == property) else { + return false; + }; + let is_pk = index.typ == IndexSchemaType::Unique; + + let value = match (index.value, value) { + (IndexSchemaValueType::Keyword, serde_json::Value::String(value)) => { + Some(RegistryFilterValue::from(value)) + } + (IndexSchemaValueType::Text, serde_json::Value::String(value)) => { + query.push_text(property, value); + return true; + } + (IndexSchemaValueType::Number, serde_json::Value::Number(value)) => { + value + .as_i64() + .map(|value| RegistryFilterValue::from(value as u64)) + } + (IndexSchemaValueType::Enum, serde_json::Value::String(value)) + if (property == Property::Type + && object_type == ObjectType::Account) => + { + AccountType::parse(&value) + .map(|id| RegistryFilterValue::from(id.to_id())) + } + (IndexSchemaValueType::Boolean, serde_json::Value::Bool(value)) => { + Some(RegistryFilterValue::from(value)) + } + (IndexSchemaValueType::Id, serde_json::Value::String(value)) => { + Id::from_str(&value) + .ok() + .map(|id| RegistryFilterValue::from(id.id())) + } + (IndexSchemaValueType::IpMask, serde_json::Value::String(value)) => { + IpAddrOrMask::from_str(&value) + .ok() + .map(|ip| RegistryFilterValue::Bytes(ip.to_index_key())) + } + _ => None, + }; + + if let Some(value) = value { + query.filters.push(store::registry::RegistryFilter { + property, + op, + value, + is_pk, + }); + + true + } else { + false + } + } + })?; + + let params = request + .extract_parameters(self.core.jmap.query_max_results, Some(Property::Id))?; + if let Some(limit) = params.limit { + query = query.with_limit(limit); + if let Some(anchor) = params.anchor { + query = query.with_anchor(anchor); + } else if let Some(position) = params.position { + query = query.with_index_start(position); + } + } + + let matches = if query.has_filters() || params.sort_by == Property::Id { + let matches = self.registry().query::>(query).await?; + if matches.is_empty() { + return QueryResponseBuilder::new( + 0, + self.core.jmap.query_max_results, + State::Initial, + &request, + ) + .build(); + } + matches.into() + } else { + None + }; + + let results = match params.sort_by { + Property::Id => { + let mut results = matches.unwrap(); + if !params.sort_ascending { + results.sort_unstable_by(|a, b| b.cmp(a)); + } + results + } + property => { + let Some(index) = indexes + .iter() + .find(|i| i.prop == property && i.value != IndexSchemaValueType::Text) + else { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details( + format!("Property {} is not supported for sorting", property), + )); + }; + + if index.typ == IndexSchemaType::Search { + self.registry() + .sort_by_index( + object_type, + index.prop, + matches, + params.sort_ascending, + ) + .await? + } else { + self.registry() + .sort_by_pk(object_type, index.prop, matches, params.sort_ascending) + .await? + } + } + }; + + // Build response + let mut response = QueryResponseBuilder::new( + results.len(), + self.core.jmap.query_max_results, + State::Initial, + &request, + ); + + for id in results { + if !response.add_id(id) { + break; + } + } + + response.build() + } + } + } +} + +pub(crate) trait RegistryQueryFilters { + fn extract_filters( + &mut self, + cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool, + ) -> trc::Result<()>; + + fn extract_parameters( + &mut self, + max_results: usize, + external_filter: Option, + ) -> trc::Result; +} + +pub(crate) struct RegistryQueryParameters { + pub sort_by: Property, + pub sort_ascending: bool, + pub anchor: Option, + pub position: Option, + pub limit: Option, +} + +impl RegistryQueryFilters for QueryRequest { + fn extract_filters( + &mut self, + mut cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool, + ) -> trc::Result<()> { + for cond in std::mem::take(&mut self.filter) { + match cond { + Filter::Property(cond) => match cond { + RegistryFilter::Property { + property, + operator, + value, + } => { + let operator = match operator { + RegistryFilterOperator::Equal => RegistryFilterOp::Equal, + RegistryFilterOperator::GreaterThan => RegistryFilterOp::GreaterThan, + RegistryFilterOperator::GreaterThanOrEqual => { + RegistryFilterOp::GreaterEqualThan + } + RegistryFilterOperator::LessThan => RegistryFilterOp::LowerThan, + RegistryFilterOperator::LessThanOrEqual => { + RegistryFilterOp::LowerEqualThan + } + }; + if !cb(property, operator, value) { + return Err(trc::JmapEvent::UnsupportedFilter.into_err().details( + format!( + "Filter on property {} is not supported or invalid", + property + ), + )); + } + } + RegistryFilter::_T(other) => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details(other.to_string())); + } + }, + Filter::And | Filter::Close => {} + Filter::Or | Filter::Not => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details("Only AND is supported in filters".to_string())); + } + } + } + + Ok(()) + } + + fn extract_parameters( + &mut self, + max_results: usize, + external_filter: Option, + ) -> trc::Result { + #[cfg(feature = "test_mode")] + let comparator = self + .sort + .take() + .unwrap_or_default() + .into_iter() + .next() + .unwrap_or_else(|| Comparator::ascending(RegistryComparator::Property(Property::Id))); + #[cfg(not(feature = "test_mode"))] + let comparator = self + .sort + .take() + .unwrap_or_default() + .into_iter() + .next() + .unwrap_or_else(|| Comparator::descending(RegistryComparator::Property(Property::Id))); + + match comparator.property { + RegistryComparator::Property(property) => { + if external_filter.is_some_and(|f| f == property) + && !self.calculate_total.unwrap_or(false) + && self.anchor_offset.is_none_or(|offset| offset == 0) + && self.position.is_none_or(|pos| pos > 0) + { + Ok(RegistryQueryParameters { + sort_by: property, + sort_ascending: comparator.is_ascending, + anchor: self.anchor.take().map(|anchor| anchor.id()), + position: self.position.take().map(|pos| pos as u64), + limit: self + .limit + .take() + .map(|limit| std::cmp::min(limit, max_results)) + .unwrap_or(max_results) + .into(), + }) + } else { + Ok(RegistryQueryParameters { + sort_by: property, + sort_ascending: comparator.is_ascending, + anchor: None, + position: None, + limit: None, + }) + } + } + RegistryComparator::_T(other) => Err(trc::JmapEvent::UnsupportedSort + .into_err() + .details(format!("Property {} is not supported for sorting", other))), + } + } +} diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs new file mode 100644 index 0000000..cc90454 --- /dev/null +++ b/crates/jmap/src/registry/set.rs @@ -0,0 +1,946 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::{ + EnterpriseRegistry, + mapping::{ + ObjectResponse, RegistrySetResponse, + account::account_set, + action::action_set, + bootstrap::bootstrap_set, + dkim::validate_dkim_signature, + domain::{validate_dns_server, validate_domain}, + map_bootstrap_error, + principal::{ + AccountUpdate, schedule_account_destruction, validate_account, validate_role, + validate_tenant_quota, + }, + public_key::validate_public_key, + queued_message::queued_message_set, + report::report_set, + sieve::validate_sieve_script, + spam_sample::spam_sample_set, + task::task_set, + tls::{validate_acme_provider, validate_certificate}, + }, +}; +use common::{ + Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder, + expr::if_block::BootstrapExprExt, ipc::CacheInvalidation, + manager::application::WebApplicationManager, +}; +use directory::core::secret::{hash_secret, is_password_hash}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::registry::Registry, + references::resolve::ResolveCreatedReference, + request::{IntoValid, MaybeInvalid}, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use registry::{ + jmap::{JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryValue}, + schema::{ + enums::{Permission, TenantStorageQuota}, + prelude::{ + OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType, + Property, + }, + structs::{ + Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript, + SieveUserScript, Task, + }, + }, + types::id::ObjectId, +}; +use std::borrow::Cow; +use store::{ + registry::{ + bootstrap::Bootstrap, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::BatchBuilder, +}; +use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub trait RegistrySet: Sync + Send { + fn registry_set( + &self, + object_type: ObjectType, + request: SetRequest<'_, Registry>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; +} + +#[allow(clippy::large_enum_variant)] +enum Modification { + Create { + client_id: String, + object: Option, + }, + Update { + id: Id, + object: Object, + }, +} + +impl RegistrySet for Server { + async fn registry_set( + &self, + object_type: ObjectType, + mut request: SetRequest<'_, Registry>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> trc::Result> { + // Initial assertions + if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) { + return Err(trc::JmapEvent::Forbidden.into_err().details(concat!( + "The server is in bootstrap mode. Only the 'Bootstrap' object type ", + "can be modified until the bootstrap process is complete.", + ))); + } + self.assert_enterprise_object(object_type)?; + + let object_flags = object_type.flags(); + let is_singleton = (object_flags & OBJ_SINGLETON) != 0; + let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0; + let is_tenant_filtered = + (object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some(); + let can_set_tenant = access_token.tenant_id().is_none(); + let can_set_account = access_token.has_permission(Permission::Impersonate); + let is_account_filtered = has_account_id && !can_set_account; + + // Build response + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + + // Initial create validation for singletons + let create = request.unwrap_create(); + + // Initial destroy validation for singletons + let mut destroy = request.unwrap_destroy().into_valid().collect::>(); + if is_singleton && !destroy.is_empty() { + response.not_destroyed.extend( + destroy + .drain(..) + .map(|id| (MaybeInvalid::Value(id), SetError::singleton())), + ); + } + + // Update validation for willDestroy + let update = request + .unwrap_update() + .into_valid() + .filter_map(|(id, value)| { + if is_singleton { + if id.is_singleton() { + Some((id, value)) + } else { + response.not_updated.append(id, SetError::not_found()); + None + } + } else if !destroy.contains(&id) { + Some((id, value)) + } else { + response.not_updated.append(id, SetError::will_destroy()); + None + } + }) + .collect::>(); + + let mut set = RegistrySetResponse { + access_token, + server: self, + remote_ip: session.remote_ip, + account_id: request.account_id.document_id(), + object_type, + response, + is_tenant_filtered, + is_account_filtered, + create, + update, + destroy, + }; + match object_type { + ObjectType::AddressBook + | ObjectType::Asn + | ObjectType::Authentication + | ObjectType::BlobStore + | ObjectType::Cache + | ObjectType::Calendar + | ObjectType::CalendarAlarm + | ObjectType::CalendarScheduling + | ObjectType::Coordinator + | ObjectType::DataRetention + | ObjectType::DataStore + | ObjectType::DkimReportSettings + | ObjectType::DmarcReportSettings + | ObjectType::DnsResolver + | ObjectType::Email + | ObjectType::Enterprise + | ObjectType::FileStorage + | ObjectType::Http + | ObjectType::HttpForm + | ObjectType::Imap + | ObjectType::InMemoryStore + | ObjectType::Jmap + | ObjectType::SystemSettings + | ObjectType::Metrics + | ObjectType::MetricsStore + | ObjectType::MtaConnectionStrategy + | ObjectType::MtaExtensions + | ObjectType::MtaInboundSession + | ObjectType::MtaOutboundStrategy + | ObjectType::MtaOutboundThrottle + | ObjectType::MtaStageAuth + | ObjectType::MtaStageConnect + | ObjectType::MtaStageData + | ObjectType::MtaStageEhlo + | ObjectType::MtaStageMail + | ObjectType::MtaStageRcpt + | ObjectType::MtaSts + | ObjectType::OidcProvider + | ObjectType::ReportSettings + | ObjectType::Search + | ObjectType::SearchStore + | ObjectType::Security + | ObjectType::SenderAuth + | ObjectType::Sharing + | ObjectType::SieveSystemInterpreter + | ObjectType::SieveUserInterpreter + | ObjectType::SpamClassifier + | ObjectType::SpamDnsblSettings + | ObjectType::SpamLlm + | ObjectType::SpamPyzor + | ObjectType::SpamSettings + | ObjectType::SpfReportSettings + | ObjectType::TaskManager + | ObjectType::TlsReportSettings + | ObjectType::TracingStore + | ObjectType::WebDav + | ObjectType::DsnReportSettings + | ObjectType::AcmeProvider + | ObjectType::AiModel + | ObjectType::Alert + | ObjectType::AllowedIp + | ObjectType::Application + | ObjectType::BlockedIp + | ObjectType::Certificate + | ObjectType::Directory + | ObjectType::DnsServer + | ObjectType::EventTracingLevel + | ObjectType::HttpLookup + | ObjectType::MemoryLookupKey + | ObjectType::MemoryLookupKeyValue + | ObjectType::MtaVirtualQueue + | ObjectType::MtaQueueQuota + | ObjectType::MtaRoute + | ObjectType::MtaDeliverySchedule + | ObjectType::MtaInboundThrottle + | ObjectType::MtaTlsStrategy + | ObjectType::MtaMilter + | ObjectType::MtaHook + | ObjectType::NetworkListener + | ObjectType::ClusterRole + | ObjectType::SieveSystemScript + | ObjectType::SieveUserScript + | ObjectType::SpamDnsblServer + | ObjectType::SpamFileExtension + | ObjectType::SpamRule + | ObjectType::SpamTag + | ObjectType::StoreLookup + | ObjectType::Tracer + | ObjectType::WebHook + | ObjectType::PublicKey + | ObjectType::DkimSignature + | ObjectType::MaskedEmail + | ObjectType::Account + | ObjectType::MailingList + | ObjectType::OAuthClient + | ObjectType::Role + | ObjectType::Tenant + | ObjectType::Domain => { + // Bundle modifications together + let mut modifications = Vec::with_capacity(set.create.len() + set.update.len()); + for (id, value) in set.create.drain() { + if is_singleton + && let Some(object) = self + .registry() + .get(ObjectId::new(object_type, Id::singleton())) + .await + .caused_by(trc::location!())? + { + modifications.push(( + Modification::Create { + client_id: id, + object: Some(object), + }, + value, + Object::from(set.object_type), + )); + } else { + modifications.push(( + Modification::Create { + client_id: id, + object: None, + }, + value, + Object::from(set.object_type), + )); + } + } + for (id, value) in set.update.drain(..) { + if let Some(object) = self + .registry() + .get(ObjectId::new(object_type, id)) + .await + .caused_by(trc::location!())? + { + if (is_tenant_filtered + && access_token.tenant_id().map(Id::from) + != object.inner.member_tenant_id()) + || (is_account_filtered + && object.inner.account_id() != Some(Id::from(set.account_id))) + { + set.response.not_updated.append(id, SetError::not_found()); + continue; + } + + modifications.push(( + Modification::Update { + id, + object: object.clone(), + }, + value, + object, + )); + } else if is_singleton { + modifications.push(( + Modification::Update { + id, + object: Object::from(set.object_type), + }, + value, + Object::from(set.object_type), + )); + } else { + set.response.not_updated.append(id, SetError::not_found()); + } + } + + // Process modifications + let mut cache_invalidator = CacheInvalidationBuilder::default(); + 'outer: for (modification, mut value, mut new_object) in modifications { + // Initial validations + let is_create = matches!(modification, Modification::Create { .. }); + let mut unpatched_properties = VecMap::new(); + + if let Err(err) = set.response.resolve_self_references(&mut value, 0, true) { + set.failed(modification, err); + continue 'outer; + }; + + if is_create + || value + .as_object() + .unwrap() + .get(&Key::Property(Property::Type)) + .and_then(|v| v.as_str()) + .is_some_and(|t| new_object.object_variant().is_some_and(|v| v != t)) + { + // Patch object + match new_object.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])) + .with_create(true) + .with_can_set_tenant(can_set_tenant) + .with_can_set_account(can_set_account), + value, + ) { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched_properties.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + unpatched_properties = properties; + } + Err(err) => { + set.failed(modification, err.into()); + continue 'outer; + } + } + + // Add tenantId for tenant filtered objects + if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id() + { + new_object.inner.set_member_tenant_id(tenant_id.into()); + } + + // Add accountId + if has_account_id { + new_object.inner.set_account_id(set.account_id.into()); + } + } else { + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(Property::Type) => { + continue; + } + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property( + prop, + ))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + + // Patch object + match new_object.patch( + JsonPointerPatch::new(&ptr) + .with_create(false) + .with_can_set_tenant(can_set_tenant) + .with_can_set_account(can_set_account), + value, + ) { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched_properties.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + if unpatched_properties.is_empty() { + unpatched_properties = properties; + } else { + unpatched_properties.extend(properties); + } + } + Err(err) => { + set.failed(modification, err.into()); + continue 'outer; + } + } + } + } + + // Validate objects + let mut tasks = Vec::new(); + let result = match &mut new_object.inner { + ObjectInner::Account(account) => { + validate_account(self, access_token, account, modification.as_account()) + .await? + } + ObjectInner::Role(role) => { + validate_role(self, access_token, role, modification.as_role()).await? + } + ObjectInner::PublicKey(key) => { + validate_public_key(&set, key, modification.as_public_key()).await? + } + ObjectInner::DkimSignature(key) => { + validate_dkim_signature(&set, key, modification.as_dkim_signature()) + .await? + } + ObjectInner::Domain(domain) => { + validate_domain(&set, domain, modification.as_domain(), &mut tasks) + .await? + } + ObjectInner::DnsServer(dns) => { + validate_dns_server(&set, dns, modification.as_dns_server()).await? + } + ObjectInner::MailingList(_) if is_create => { + validate_tenant_quota( + self, + access_token, + TenantStorageQuota::MaxMailingLists, + ) + .await? + } + ObjectInner::OAuthClient(client) => { + if let Some(secret) = client.secret.as_mut() + && !secret.is_empty() + && !(matches!(secret.as_bytes().first(), Some(&b'$' | &b'{')) + && is_password_hash(secret)) + { + *secret = hash_secret( + set.server.core.network.security.password_hash_algorithm, + std::mem::take(secret).into_bytes(), + ) + .await + .caused_by(trc::location!())?; + } + if is_create { + validate_tenant_quota( + self, + access_token, + TenantStorageQuota::MaxOauthClients, + ) + .await? + } else { + Ok(ObjectResponse::default()) + } + } + ObjectInner::Directory(_) if is_create => { + validate_tenant_quota( + self, + access_token, + TenantStorageQuota::MaxDirectories, + ) + .await? + } + ObjectInner::AcmeProvider(provider) if is_create => { + validate_acme_provider(&set, provider, unpatched_properties).await? + } + ObjectInner::Certificate(cert) => { + validate_certificate(cert, modification.as_certificate()).await? + } + ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => { + validate_sieve_script( + set.server, + contents, + modification.as_sieve_script(), + false, + ) + .await? + } + ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => { + validate_sieve_script( + set.server, + contents, + modification.as_sieve_script(), + true, + ) + .await? + } + _ => Ok(ObjectResponse::default()), + }; + + let mut response = match result { + Ok(response) => response, + Err(err) => { + set.failed(modification, err); + continue 'outer; + } + }; + + // Validate expressions + if let Some(expressions) = new_object.inner.expression_ctxs() { + let mut bp = Bootstrap::new_uninitialized(self.registry().clone()); + + for expression in expressions { + bp.compile_expr(ObjectId::new(object_type, 0u64.into()), &expression); + if !bp.errors.is_empty() { + set.failed( + modification, + map_bootstrap_error(bp.errors) + .with_object_id_opt(None) + .with_property(expression.property), + ); + continue 'outer; + } + } + } + + // Save object + let result = match &modification { + Modification::Create { client_id, object } => { + if let Some(object) = object { + if object.inner != new_object.inner { + self.registry() + .write(RegistryWrite::update( + Id::singleton(), + &new_object, + object, + )) + .await? + } else { + set.response.created(client_id.to_string(), Id::singleton()); + continue; + } + } else { + self.registry() + .write(RegistryWrite::Insert { + object: &new_object, + id: response.id, + }) + .await? + } + } + Modification::Update { id, object } => { + if object.inner != new_object.inner { + if !(is_singleton && object.revision == 0) { + self.registry() + .write(RegistryWrite::update(*id, &new_object, object)) + .await? + } else { + self.registry() + .write(RegistryWrite::insert(&new_object)) + .await? + } + } else { + set.response.updated.append(*id, None); + continue; + } + } + }; + + let object_id = match (modification, result) { + (Modification::Update { id, object }, RegistryWriteResult::Success(_)) => { + cache_invalidator.process_update(id, &object, &new_object); + if let ( + ObjectInner::Application(previous), + ObjectInner::Application(updated), + ) = (&object.inner, &new_object.inner) + && previous.resource_url != updated.resource_url + && let Err(err) = + WebApplicationManager::delete_bundle(self, id).await + { + trc::error!( + err.details("Failed to delete cached application bundle") + ); + } + set.response.updated.append( + id, + if !response.object.is_empty() { + Some(JmapValue::Object(response.object)) + } else { + None + }, + ); + Some(id) + } + ( + Modification::Create { client_id, .. }, + RegistryWriteResult::Success(id), + ) => { + cache_invalidator.process_create(&new_object); + response.object.insert(Property::Id, RegistryValue::Id(id)); + set.response + .created + .insert(client_id, JmapValue::Object(response.object)); + Some(id) + } + (Modification::Update { id, .. }, err) => { + set.response.not_updated.append(id, map_write_error(err)); + None + } + (Modification::Create { client_id, .. }, err) => { + set.response + .not_created + .append(client_id, map_write_error(err)); + None + } + }; + + // Dispatch tasks + if !tasks.is_empty() + && let Some(object_id) = object_id + { + let mut batch = BatchBuilder::new(); + for mut task in tasks.drain(..) { + match &mut task { + Task::AcmeRenewal(task) => task.domain_id = object_id, + Task::DkimManagement(task) => task.domain_id = object_id, + Task::DnsManagement(task) => task.domain_id = object_id, + _ => unreachable!(), + } + batch.schedule_task(task); + } + set.server.store().write(batch.build_all()).await?; + set.server.notify_task_queue(); + } + } + + // Process destroy + for id in set.destroy.drain(..) { + let object_id = ObjectId::new(object_type, id); + if let Some(object) = self + .registry() + .get(object_id) + .await + .caused_by(trc::location!())? + .filter(|object| { + !((is_tenant_filtered + && access_token.tenant_id().map(Id::from) + != object.inner.member_tenant_id()) + || (is_account_filtered + && object.inner.account_id() != Some(Id::from(set.account_id)))) + }) + { + match self + .registry() + .write(RegistryWrite::Delete { + object_id, + object: Some(&object), + allowed_orphan_types: if object_type == ObjectType::Account { + &[ObjectType::PublicKey, ObjectType::MaskedEmail] + } else { + &[] + }, + }) + .await? + { + RegistryWriteResult::Success(_) => { + if let ObjectInner::Account(account) = &object.inner { + for sharee_id in self + .store() + .acl_revoke_all(id.document_id()) + .await + .caused_by(trc::location!())? + { + cache_invalidator + .invalidate(CacheInvalidation::AccessToken(sharee_id)); + } + + schedule_account_destruction(set.server, id, account).await?; + } + + if matches!(object.inner, ObjectInner::Application(_)) + && let Err(err) = + WebApplicationManager::delete_bundle(self, id).await + { + trc::error!( + err.details("Failed to delete cached application bundle") + ); + } + + cache_invalidator.process_delete(id, &object); + set.response.destroyed.push(id); + } + err => { + set.response.not_destroyed.append(id, map_write_error(err)); + } + } + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + + // Finalize cache invalidation + self.invalidate_caches(cache_invalidator).await?; + + Ok(set.into_response()) + } + ObjectType::ArfExternalReport + | ObjectType::DmarcExternalReport + | ObjectType::TlsExternalReport + | ObjectType::DmarcInternalReport + | ObjectType::TlsInternalReport => report_set(set).await.map(|set| set.into_response()), + + ObjectType::SpamTrainingSample => { + spam_sample_set(set).await.map(|set| set.into_response()) + } + + ObjectType::AccountSettings + | ObjectType::ApiKey + | ObjectType::AccountPassword + | ObjectType::AppPassword => Box::pin(account_set(set)) + .await + .map(|set| set.into_response()), + + ObjectType::QueuedMessage => { + queued_message_set(set).await.map(|set| set.into_response()) + } + + ObjectType::Task => task_set(set).await.map(|set| set.into_response()), + + ObjectType::Action => Box::pin(action_set(set)) + .await + .map(|set| set.into_response()), + + ObjectType::Bootstrap => Box::pin(bootstrap_set(set)) + .await + .map(|set| set.into_response()), + + ObjectType::Log | ObjectType::Metric | ObjectType::Trace | ObjectType::ClusterNode => { + set.fail_all_create("Telemetry objects cannot be created"); + set.fail_all_update("Telemetry objects cannot be modified"); + set.fail_all_destroy("Telemetry objects cannot be deleted"); + Ok(set.into_response()) + } + #[cfg(not(feature = "enterprise"))] + _ => { + set.fail_all_create("Enterprise objects cannot be created"); + set.fail_all_update("Enterprise objects cannot be modified"); + set.fail_all_destroy("Enterprise objects cannot be deleted"); + Ok(set.into_response()) + } + } + } +} + +impl RegistrySetResponse<'_> { + fn failed(&mut self, modification: Modification, error: SetError) { + match modification { + Modification::Create { client_id, .. } => { + self.response.not_created.append(client_id, error) + } + Modification::Update { id, .. } => self.response.not_updated.append(id, error), + } + } + + pub fn fail_all(&mut self, error: SetError) { + for (client_id, _) in self.create.drain() { + self.response.not_created.append(client_id, error.clone()); + } + for (id, _) in self.update.drain(..) { + self.response.not_updated.append(id, error.clone()); + } + for id in self.destroy.drain(..) { + self.response.not_destroyed.append(id, error.clone()); + } + } + + pub fn fail_all_create(&mut self, error: impl Into>) { + let error = error.into(); + for (client_id, _) in self.create.drain() { + self.response.not_created.append( + client_id, + SetError::forbidden().with_description(error.clone()), + ); + } + } + + pub fn fail_all_update(&mut self, error: impl Into>) { + let error = error.into(); + for (id, _) in self.update.drain(..) { + self.response + .not_updated + .append(id, SetError::forbidden().with_description(error.clone())); + } + } + + pub fn fail_all_destroy(&mut self, error: impl Into>) { + let error = error.into(); + for id in self.destroy.drain(..) { + self.response + .not_destroyed + .append(id, SetError::forbidden().with_description(error.clone())); + } + } + + fn into_response(self) -> SetResponse { + self.response + } +} + +impl Modification { + fn as_account(&self) -> AccountUpdate<'_> { + match self { + Modification::Create { client_id, .. } => AccountUpdate::Create(client_id), + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Account(account) => AccountUpdate::Update(account), + _ => unreachable!(), + }, + } + } + + fn as_role(&self) -> Option<&Role> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Role(role) => Some(role), + _ => None, + }, + } + } + + fn as_public_key(&self) -> Option<&PublicKey> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::PublicKey(key) => Some(key), + _ => None, + }, + } + } + + fn as_dkim_signature(&self) -> Option<&DkimSignature> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::DkimSignature(key) => Some(key), + _ => None, + }, + } + } + + fn as_domain(&self) -> Option<&Domain> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Domain(domain) => Some(domain), + _ => None, + }, + } + } + + fn as_dns_server(&self) -> Option<&DnsServer> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::DnsServer(dns) => Some(dns), + _ => None, + }, + } + } + + fn as_certificate(&self) -> Option<&Certificate> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Certificate(cert) => Some(cert), + _ => None, + }, + } + } + + fn as_sieve_script(&self) -> Option<&str> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) + | ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => { + Some(contents.as_str()) + } + _ => None, + }, + } + } +} + +pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError { + match err { + RegistryWriteResult::CannotDeleteLinked { + object_id, + linked_objects, + } => SetError::new(SetErrorType::ObjectIsLinked) + .with_object_id(object_id) + .with_linked_objects(linked_objects), + RegistryWriteResult::InvalidSingletonId => SetError::invalid_properties() + .with_property(Property::Id) + .with_description("Invalid singleton id"), + RegistryWriteResult::CannotDeleteSingleton => { + SetError::forbidden().with_description("Singleton objects cannot be deleted") + } + RegistryWriteResult::InvalidForeignKey { object_id } => { + SetError::new(SetErrorType::InvalidForeignKey).with_object_id(object_id) + } + RegistryWriteResult::PrimaryKeyConflict { + property, + existing_id, + } => SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(property) + .with_object_id(existing_id), + RegistryWriteResult::ValidationError { errors } => { + SetError::new(SetErrorType::ValidationFailed).with_validation_errors(errors) + } + RegistryWriteResult::NotSupported => SetError::forbidden() + .with_description("The requested action is not supported by the registry store"), + RegistryWriteResult::NotFound { .. } => SetError::not_found(), + RegistryWriteResult::Success(_) => unreachable!(), + } +} diff --git a/crates/jmap/src/share_notification/get.rs b/crates/jmap/src/share_notification/get.rs new file mode 100644 index 0000000..a9da1f2 --- /dev/null +++ b/crates/jmap/src/share_notification/get.rs @@ -0,0 +1,287 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::AccountCache, sharing::notification::ShareNotification}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::{ + JmapRight, + addressbook::AddressBookRight, + calendar::CalendarRight, + file_node::FileNodeRight, + mailbox::MailboxRight, + share_notification::{self, ShareNotificationProperty, ShareNotificationValue}, + }, + request::IntoValid, + types::{date::UTCDate, state::State}, +}; +use jmap_tools::{Key, Map, Value}; +use std::{sync::Arc, time::Duration}; +use store::{ + Deserialize, IterateParams, LogKey, U64_LEN, + ahash::{AHashMap, AHashSet}, + write::key::DeserializeBigEndian, +}; +use trc::AddContext; +use types::{ + acl::Acl, + collection::{Collection, SyncCollection}, + id::Id, + type_state::DataType, +}; +use utils::{map::bitmap::Bitmap, snowflake::SnowflakeIdGenerator}; + +pub trait ShareNotificationGet: Sync + Send { + fn share_notification_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; +} + +impl ShareNotificationGet for Server { + async fn share_notification_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let properties = request.unwrap_properties(&[ + ShareNotificationProperty::Id, + ShareNotificationProperty::Name, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::Created, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ]); + + let account_id = request.account_id.document_id(); + let mut min_id = u64::MAX; + let mut max_id = 0u64; + + let mut account_cache: AHashMap> = AHashMap::new(); + + let mut ids = if let Some(ids) = request.ids.take() { + let ids = ids.unwrap(); + if ids.len() <= self.core.jmap.get_max_objects { + ids.into_valid() + .map(|id| { + let id_num = *id.as_ref(); + if id_num < min_id { + min_id = id_num; + } + if id_num > max_id { + max_id = id_num; + } + id_num + }) + .collect::>() + } else { + return Err(trc::JmapEvent::RequestTooLarge.into_err()); + } + } else { + AHashSet::new() + }; + let has_ids = !ids.is_empty(); + + if min_id == u64::MAX { + min_id = SnowflakeIdGenerator::from_duration( + self.core + .email + .share_notification_max_history + .unwrap_or(Duration::from_secs(30 * 86400)), + ) + .unwrap_or_default(); + } + + if max_id == 0 { + max_id = u64::MAX; + } + + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::with_capacity(ids.len()), + not_found: vec![], + }; + let mut notifications = Vec::new(); + + self.store() + .iterate( + IterateParams::new( + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: min_id, + }, + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: max_id.saturating_add(1), + }, + ) + .descending(), + |key, value| { + let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?; + if response.state.is_none() { + response.state = Some(State::Exact(change_id)); + } + + if !has_ids || ids.remove(&change_id) { + notifications.push(( + change_id, + ShareNotification::deserialize(value).caused_by(trc::location!())?, + )); + } + + Ok((!has_ids || !ids.is_empty()) + && notifications.len() < self.core.jmap.get_max_objects) + }, + ) + .await + .caused_by(trc::location!())?; + + for (change_id, notification) in notifications { + let changed_by_account = + if let Some(account) = account_cache.get(¬ification.changed_by) { + account.clone() + } else { + let account = if let Ok(account) = self.account(notification.changed_by).await { + account + } else { + continue; + }; + + account_cache.insert(notification.changed_by, account.clone()); + account + }; + + response.list.push(build_share_notification( + change_id, + notification, + &changed_by_account, + &properties, + )); + } + + if response.state.is_none() { + response.state = Some(State::Initial); + } + + for id in ids { + response.push_not_found(Id::from(id)); + } + + Ok(response) + } +} + +fn build_share_notification( + id: u64, + mut notification: ShareNotification, + changed_by: &AccountCache, + properties: &[ShareNotificationProperty], +) -> Value<'static, ShareNotificationProperty, ShareNotificationValue> { + let mut result = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + ShareNotificationProperty::Id => Value::Element(ShareNotificationValue::Id(id.into())), + ShareNotificationProperty::Created => Value::Element(ShareNotificationValue::Date( + UTCDate::from_timestamp(SnowflakeIdGenerator::to_timestamp(id) as i64), + )), + ShareNotificationProperty::ChangedBy => Value::Object(Map::from(vec![ + ( + Key::Property(ShareNotificationProperty::ChangedByPrincipalId), + Value::Element(ShareNotificationValue::Id(notification.changed_by.into())), + ), + ( + Key::Property(ShareNotificationProperty::ChangedByName), + Value::Str( + changed_by + .description() + .unwrap_or(changed_by.name()) + .to_string() + .into(), + ), + ), + ( + Key::Property(ShareNotificationProperty::ChangedByEmail), + Value::Str(changed_by.name().to_string().into()), + ), + ])), + ShareNotificationProperty::ObjectType => DataType::try_from(notification.object_type) + .ok() + .map(|typ| Value::Element(ShareNotificationValue::ObjectType(typ))) + .unwrap_or(Value::Null), + ShareNotificationProperty::ObjectAccountId => Value::Element( + ShareNotificationValue::Id(notification.object_account_id.into()), + ), + ShareNotificationProperty::ObjectId => { + Value::Element(ShareNotificationValue::Id(notification.object_id.into())) + } + ShareNotificationProperty::OldRights => { + map_rights(notification.object_type, notification.old_rights) + } + ShareNotificationProperty::NewRights => { + map_rights(notification.object_type, notification.new_rights) + } + ShareNotificationProperty::Name => { + Value::Str(std::mem::take(&mut notification.name).into()) + } + _ => Value::Null, + }; + + result.insert_unchecked(property.clone(), value); + } + + Value::Object(result) +} + +fn map_rights( + object_type: Collection, + rights: Bitmap, +) -> Value<'static, ShareNotificationProperty, ShareNotificationValue> { + let mut obj = Map::with_capacity(3); + + match object_type { + Collection::Calendar | Collection::CalendarEvent => { + for right in CalendarRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); + } + } + Collection::AddressBook | Collection::ContactCard => { + for right in AddressBookRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); + } + } + Collection::FileNode => { + for right in FileNodeRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); + } + } + Collection::Mailbox | Collection::Email => { + for right in MailboxRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); + } + } + _ => {} + } + + Value::Object(obj) +} diff --git a/crates/jmap/src/share_notification/mod.rs b/crates/jmap/src/share_notification/mod.rs new file mode 100644 index 0000000..c036acc --- /dev/null +++ b/crates/jmap/src/share_notification/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/share_notification/query.rs b/crates/jmap/src/share_notification/query.rs new file mode 100644 index 0000000..556dcef --- /dev/null +++ b/crates/jmap/src/share_notification/query.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::query::QueryResponseBuilder; +use common::{Server, sharing::notification::ShareNotification}; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::share_notification::{self, ShareNotificationFilter}, + types::state::State, +}; +use std::time::Duration; +use store::{Deserialize, IterateParams, LogKey, U64_LEN, write::key::DeserializeBigEndian}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + id::Id, +}; +use utils::snowflake::SnowflakeIdGenerator; + +pub trait ShareNotificationQuery: Sync + Send { + fn share_notification_query( + &self, + request: QueryRequest, + ) -> impl Future> + Send; +} + +impl ShareNotificationQuery for Server { + async fn share_notification_query( + &self, + mut request: QueryRequest, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut from_change_id = SnowflakeIdGenerator::from_duration( + self.core + .email + .share_notification_max_history + .unwrap_or(Duration::from_secs(30 * 86400)), + ) + .unwrap_or_default(); + let mut to_change_id = u64::MAX; + let mut collection = None; + let mut object_type = None; + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + ShareNotificationFilter::After(utcdate) => { + from_change_id = + SnowflakeIdGenerator::from_timestamp(utcdate.timestamp() as u64) + .unwrap_or(0); + } + ShareNotificationFilter::Before(utcdate) => { + to_change_id = + SnowflakeIdGenerator::from_timestamp(utcdate.timestamp() as u64) + .unwrap_or(u64::MAX); + } + ShareNotificationFilter::ObjectType(typ) => { + collection = Collection::try_from(typ).ok(); + } + ShareNotificationFilter::ObjectAccountId(id) => { + object_type = Some(id.document_id()); + } + ShareNotificationFilter::_T(other) => { + return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other)); + } + }, + Filter::And | Filter::Or | Filter::Not | Filter::Close => { + return Err(trc::JmapEvent::UnsupportedFilter + .into_err() + .details("Logical operators are not supported")); + } + } + } + + let mut results = Vec::new(); + self.store() + .iterate( + IterateParams::new( + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: from_change_id, + }, + LogKey { + account_id, + collection: SyncCollection::ShareNotification.into(), + change_id: to_change_id, + }, + ) + .descending(), + |key, value| { + let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?; + + if collection.is_some() || object_type.is_some() { + let notification = + ShareNotification::deserialize(value).caused_by(trc::location!())?; + if collection.is_some_and(|c| c != notification.object_type) + || object_type.is_some_and(|o| o != notification.object_account_id) + { + return Ok(true); + } + } + + results.push(Id::from(change_id)); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + let mut response = QueryResponseBuilder::new( + results.len(), + self.core.jmap.query_max_results, + State::Initial, + &request, + ); + + for id in results { + if !response.add_id(id) { + break; + } + } + + response.build() + } +} diff --git a/crates/jmap/src/share_notification/set.rs b/crates/jmap/src/share_notification/set.rs new file mode 100644 index 0000000..3cf30b9 --- /dev/null +++ b/crates/jmap/src/share_notification/set.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::Server; +use jmap_proto::{ + error::set::SetError, + method::set::{SetRequest, SetResponse}, + object::share_notification::ShareNotification, + request::IntoValid, +}; +use store::write::{BatchBuilder, ValueClass}; +use trc::AddContext; + +pub trait ShareNotificationSet: Sync + Send { + fn share_notification_set( + &self, + request: SetRequest<'_, ShareNotification>, + ) -> impl Future>> + Send; +} + +impl ShareNotificationSet for Server { + async fn share_notification_set( + &self, + mut request: SetRequest<'_, ShareNotification>, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + + for (id, _) in request.unwrap_create() { + response.not_created.append( + id, + SetError::forbidden().with_description("Cannot create share notifications."), + ); + } + + // Process updates + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Cannot update share notifications."), + ); + } + + // Process deletions + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + for id in request.unwrap_destroy().into_valid() { + batch.clear(ValueClass::ShareNotification { + notification_id: id.id(), + notify_account_id: account_id, + }); + response.destroyed.push(id); + } + + // Write changes + if !batch.is_empty() { + self.commit_batch(batch).await.caused_by(trc::location!())?; + } + + Ok(response) + } +} diff --git a/crates/jmap/src/sieve/get.rs b/crates/jmap/src/sieve/get.rs new file mode 100644 index 0000000..7abda92 --- /dev/null +++ b/crates/jmap/src/sieve/get.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::StateManager; +use common::Server; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::sieve::{Sieve, SieveProperty, SieveValue}, +}; +use jmap_tools::{Map, Value}; +use std::future::Future; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + blob::{BlobClass, BlobId, BlobSection}, + collection::{Collection, SyncCollection}, + field::SieveField, +}; + +pub trait SieveScriptGet: Sync + Send { + fn sieve_script_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; +} + +impl SieveScriptGet for Server { + async fn sieve_script_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + SieveProperty::Id, + SieveProperty::Name, + SieveProperty::BlobId, + SieveProperty::IsActive, + ]); + let account_id = request.account_id.document_id(); + let script_ids = self + .document_ids(account_id, Collection::SieveScript, SieveField::Name) + .await?; + let ids = if let Some(ids) = ids { + ids + } else { + script_ids + .iter() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect::>() + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: self + .get_state(account_id, SyncCollection::SieveScript) + .await? + .into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + let active_script_id = self.sieve_script_get_active_id(account_id).await?; + + for id in ids { + // Obtain the sieve script object + let document_id = id.document_id(); + if !script_ids.contains(document_id) { + response.push_not_found(id); + continue; + } + let sieve_ = if let Some(sieve) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + { + sieve + } else { + response.push_not_found(id); + continue; + }; + let sieve = sieve_ + .unarchive::() + .caused_by(trc::location!())?; + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + match property { + SieveProperty::Id => { + result.insert_unchecked(SieveProperty::Id, id); + } + SieveProperty::Name => { + result.insert_unchecked(SieveProperty::Name, &sieve.name); + } + SieveProperty::IsActive => { + result.insert_unchecked( + SieveProperty::IsActive, + active_script_id == Some(document_id), + ); + } + SieveProperty::BlobId => { + let blob_id = BlobId { + hash: (&sieve.blob_hash).into(), + class: BlobClass::Linked { + account_id, + collection: Collection::SieveScript.into(), + document_id, + }, + section: BlobSection { + size: u32::from(sieve.size) as usize, + ..Default::default() + } + .into(), + }; + + result.insert_unchecked( + SieveProperty::BlobId, + Value::Element(SieveValue::BlobId(blob_id)), + ); + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } +} diff --git a/crates/jmap/src/sieve/mod.rs b/crates/jmap/src/sieve/mod.rs new file mode 100644 index 0000000..3127224 --- /dev/null +++ b/crates/jmap/src/sieve/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; +pub mod validate; diff --git a/crates/jmap/src/sieve/query.rs b/crates/jmap/src/sieve/query.rs new file mode 100644 index 0000000..3e319d0 --- /dev/null +++ b/crates/jmap/src/sieve/query.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::StateManager}; +use common::Server; +use email::sieve::ingest::SieveScriptIngest; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::sieve::{Sieve, SieveComparator, SieveFilter}, +}; +use std::future::Future; +use store::{ + IndexKeyPrefix, IterateParams, U32_LEN, + roaring::RoaringBitmap, + search::{SearchFilter, SearchQuery}, + write::{SearchIndex, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::SieveField, +}; + +pub trait SieveScriptQuery: Sync + Send { + fn sieve_script_query( + &self, + request: QueryRequest, + ) -> impl Future> + Send; +} + +impl SieveScriptQuery for Server { + async fn sieve_script_query( + &self, + mut request: QueryRequest, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + let mut filters = Vec::with_capacity(request.filter.len()); + let active_script_id = if request + .filter + .iter() + .any(|f| matches!(f, Filter::Property(SieveFilter::IsActive(_)))) + || request.sort.as_ref().is_some_and(|s| { + s.iter() + .any(|c| matches!(c.property, SieveComparator::IsActive)) + }) { + self.sieve_script_get_active_id(account_id).await? + } else { + None + }; + + let mut document_ids = RoaringBitmap::new(); + let mut names = Vec::new(); + self.store() + .iterate( + IterateParams::new( + IndexKeyPrefix { + account_id, + collection: Collection::SieveScript.into(), + field: SieveField::Name.into(), + }, + IndexKeyPrefix { + account_id, + collection: Collection::SieveScript.into(), + field: u8::from(SieveField::Name) + 1, + }, + ) + .no_values(), + |key, _| { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + + names.push(( + document_id, + key.get(IndexKeyPrefix::len()..key.len() - U32_LEN) + .and_then(|v| std::str::from_utf8(v).ok()) + .unwrap_or_default() + .to_string(), + )); + + document_ids.insert(document_id); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + SieveFilter::Name(name) => { + let name = name.to_lowercase(); + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + names + .iter() + .filter_map(|(id, n)| (n.contains(&name)).then_some(*id)) + .collect::>(), + ))); + } + SieveFilter::IsActive(is_active) => { + if is_active { + if let Some(active_script_id) = active_script_id { + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter([ + active_script_id, + ]))); + } else { + // No active script, so no results + filters.push(SearchFilter::is_in_set(RoaringBitmap::new())); + } + } else { + let mut inactive_set = document_ids.clone(); + if let Some(active_script_id) = active_script_id { + inactive_set.remove(active_script_id); + } + filters.push(SearchFilter::is_in_set(inactive_set)); + } + } + SieveFilter::_T(other) => { + return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other)); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + // Parse sort criteria + let mut sort_by_active = None; + for comparator in request + .sort + .take() + .filter(|s| !s.is_empty()) + .unwrap_or_default() + { + match comparator.property { + SieveComparator::Name => { + if !comparator.is_ascending { + names.reverse(); + } + } + SieveComparator::IsActive => { + sort_by_active = Some(comparator.is_ascending); + } + SieveComparator::_T(other) => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other)); + } + }; + } + + let mut results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_mask(document_ids) + .filter() + .into_bitmap(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + self.get_state(account_id, SyncCollection::SieveScript) + .await?, + &request, + ); + + if !results.is_empty() { + if matches!(sort_by_active, Some(true)) + && results.remove(active_script_id.unwrap_or_default()) + && !response.add(0, active_script_id.unwrap()) + { + return response.build(); + } + + let mut last_id = None; + for (document_id, _) in names { + if results.contains(document_id) { + if sort_by_active.is_some() && Some(document_id) == active_script_id { + last_id = Some(document_id); + } else if !response.add(0, document_id) { + return response.build(); + } + } + } + + if let Some(active_id) = last_id { + response.add(0, active_id); + } + } + + response.build() + } +} diff --git a/crates/jmap/src/sieve/set.rs b/crates/jmap/src/sieve/set.rs new file mode 100644 index 0000000..c947aed --- /dev/null +++ b/crates/jmap/src/sieve/set.rs @@ -0,0 +1,589 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{blob::download::BlobDownload, changes::state::StateManager}; +use common::{ + Server, + auth::{AccessToken, AccountCache}, + storage::index::ObjectIndexBuilder, +}; +use email::sieve::{ + ArchivedSieveScript, SieveScript, delete::SieveScriptDelete, ingest::SieveScriptIngest, +}; +use http_proto::HttpSessionData; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::sieve::{Sieve, SieveProperty, SieveValue}, + references::resolve::ResolveCreatedReference, + request::{MaybeInvalid, reference::MaybeIdReference}, + types::state::State, +}; +use jmap_tools::{Key, Map, Value}; +use rand::distr::Alphanumeric; +use registry::schema::enums::StorageQuota; +use sieve::compiler::ErrorType; +use std::future::Future; +use store::{ + Serialize, SerializeInfallible, ValueKey, + rand::{RngExt, rng}, + write::{AlignedBytes, Archive, Archiver, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + blob::{BlobClass, BlobId, BlobSection}, + collection::{Collection, SyncCollection}, + field::{PrincipalField, SieveField}, + id::Id, +}; + +pub struct SetContext<'x> { + account_id: u32, + access_token: &'x AccessToken, + account_cache: &'x AccountCache, + response: SetResponse, +} + +pub trait SieveScriptSet: Sync + Send { + fn sieve_script_set( + &self, + request: SetRequest<'_, Sieve>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> impl Future>> + Send; + + #[allow(clippy::type_complexity)] + fn sieve_set_item<'x>( + &self, + changes_: Value<'_, SieveProperty, SieveValue>, + update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, + ctx: &SetContext, + session_id: u64, + ) -> impl Future, SetError>>> + Send; +} + +impl SieveScriptSet for Server { + async fn sieve_script_set( + &self, + mut request: SetRequest<'_, Sieve>, + access_token: &AccessToken, + session: &HttpSessionData, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let sieve_ids = self + .document_ids(account_id, Collection::SieveScript, SieveField::Name) + .await?; + let account = self.account(account_id).await.caused_by(trc::location!())?; + let mut ctx = SetContext { + account_id, + access_token, + account_cache: &account, + response: SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state( + self.assert_state( + account_id, + SyncCollection::SieveScript, + &request.if_in_state, + ) + .await?, + ), + }; + let will_destroy = ctx.response.collect_will_destroy(request.unwrap_destroy()); + + // Validate active script id + if let Some(MaybeIdReference::Id(id)) = &request.arguments.on_success_activate_script + && !sieve_ids.contains(id.document_id()) + { + request.arguments.on_success_activate_script = None; + } + + // Process creates + let mut batch = BatchBuilder::new(); + let mut activations = Vec::new(); + for (id, object) in request.unwrap_create() { + if sieve_ids.len() + < self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64 + { + match self + .sieve_set_item(object, None, &ctx, session.session_id) + .await? + { + Ok(mut result) => { + // Store blob + let sieve = &mut result.builder.changes_mut().unwrap(); + let (blob_hash, blob_hold) = self + .put_temporary_blob( + account_id, + result.blob_update.as_ref().unwrap(), + 60, + ) + .await?; + sieve.blob_hash = blob_hash; + let blob_size = sieve.size as usize; + let blob_hash = sieve.blob_hash.clone(); + + // Write record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::SieveScript, 1) + .await + .caused_by(trc::location!())?; + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .custom( + result + .builder + .with_changed_by(ctx.access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .clear(blob_hold) + .commit_point(); + + // Set isActive if needed + if let Some(set_item) = result.set_item { + activations.push((document_id, set_item)); + } + + let mut result = Map::with_capacity(1) + .with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into())) + .with_key_value( + SieveProperty::BlobId, + SieveValue::BlobId(BlobId { + hash: blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::SieveScript.into(), + document_id, + }, + section: BlobSection { + size: blob_size, + ..Default::default() + } + .into(), + }), + ); + + // Update active script if needed + if let Some(MaybeIdReference::Reference(id_ref)) = + &request.arguments.on_success_activate_script + && id_ref == &id + { + request.arguments.on_success_activate_script = + Some(MaybeIdReference::Id(Id::from(document_id))); + result.insert_unchecked(SieveProperty::IsActive, true); + } + + // Add result with updated blobId + ctx.response.created.insert(id, result.into()); + } + Err(err) => { + ctx.response.not_created.append(id, err); + } + } + } else { + ctx.response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many sieve scripts, ", + "please delete some before adding a new one." + )), + ); + } + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + ctx.response + .not_updated + .append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + ctx.response + .not_updated + .append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain sieve script + let document_id = id.document_id(); + if let Some(sieve_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + { + let sieve = sieve_ + .to_unarchived::() + .caused_by(trc::location!())?; + + match self + .sieve_set_item( + object, + (document_id, sieve).into(), + &ctx, + session.session_id, + ) + .await? + { + Ok(mut result) => { + // Prepare write batch + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id); + + let blob_id = if let Some(blob) = result.blob_update.take() { + // Store blob + let sieve = &mut result.builder.changes_mut().unwrap(); + let (blob_hash, blob_hold) = + self.put_temporary_blob(account_id, &blob, 60).await?; + sieve.blob_hash = blob_hash; + batch.clear(blob_hold); + + BlobId { + hash: sieve.blob_hash.clone(), + class: BlobClass::Linked { + account_id, + collection: Collection::SieveScript.into(), + document_id, + }, + section: BlobSection { + size: sieve.size as usize, + ..Default::default() + } + .into(), + } + .into() + } else { + None + }; + + // Set isActive if needed + if let Some(set_item) = result.set_item { + activations.push((document_id, set_item)); + } + + // Write record + batch + .custom( + result + .builder + .with_changed_by(ctx.access_token.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .commit_point(); + + // Update blobId property if needed + let mut result = Map::with_capacity(1); + if let Some(blob_id) = blob_id { + result.insert_unchecked( + SieveProperty::BlobId, + SieveValue::BlobId(blob_id), + ); + } + + // Add active script property if needed + if let Some(MaybeIdReference::Id(id)) = + &request.arguments.on_success_activate_script + && document_id == id.document_id() + { + result.insert_unchecked(SieveProperty::IsActive, true); + } + + // Add result + ctx.response.updated.append( + id, + if !result.is_empty() { + Value::Object(result).into() + } else { + None + }, + ); + } + Err(err) => { + ctx.response.not_updated.append(id, err); + continue 'update; + } + } + } else { + ctx.response.not_updated.append(id, SetError::not_found()); + } + } + + // Process deletions + let active_script_id = self.sieve_script_get_active_id(account_id).await?; + for id in will_destroy { + let document_id = id.document_id(); + if sieve_ids.contains(document_id) { + if active_script_id != Some(document_id) { + if self + .sieve_script_delete(account_id, document_id, ctx.access_token, &mut batch) + .await? + { + ctx.response.destroyed.push(id); + } else { + ctx.response.not_destroyed.append(id, SetError::not_found()); + } + } else { + ctx.response.not_destroyed.append( + id, + SetError::new(SetErrorType::ScriptIsActive) + .with_description("Deactivate Sieve script before deletion."), + ); + } + } else { + ctx.response.not_destroyed.append(id, SetError::not_found()); + } + } + + // Non-standard script activation handling + let mut on_success_deactivate_script = request + .arguments + .on_success_deactivate_script + .unwrap_or(false); + if activations.len() == 1 { + let (document_id, set_item) = activations[0]; + let is_active = active_script_id.is_some_and(|active_id| active_id == document_id); + if set_item { + if request.arguments.on_success_activate_script.is_none() && !is_active { + request.arguments.on_success_activate_script = + Some(MaybeIdReference::Id(document_id.into())); + } + } else if !on_success_deactivate_script && is_active { + on_success_deactivate_script = true; + } + } + + // Activate / deactivate scripts + if ctx.response.not_created.is_empty() + && ctx.response.not_updated.is_empty() + && ctx.response.not_destroyed.is_empty() + && (request.arguments.on_success_activate_script.is_some() + || on_success_deactivate_script) + { + if let Some(MaybeIdReference::Id(id)) = request.arguments.on_success_activate_script { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .set(PrincipalField::ActiveScriptId, id.document_id().serialize()); + } else if on_success_deactivate_script { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::ActiveScriptId); + } + } + + // Write changes + if !batch.is_empty() + && let Ok(change_id) = self + .commit_batch(batch) + .await + .caused_by(trc::location!())? + .last_change_id(account_id) + { + ctx.response.new_state = State::Exact(change_id).into(); + } + + Ok(ctx.response) + } + + #[allow(clippy::blocks_in_conditions)] + async fn sieve_set_item<'x>( + &self, + changes_: Value<'_, SieveProperty, SieveValue>, + update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, + ctx: &SetContext<'_>, + session_id: u64, + ) -> trc::Result, SetError>> { + // Vacation script cannot be modified + if update + .as_ref() + .is_some_and(|(_, obj)| obj.inner.name.eq_ignore_ascii_case("vacation")) + { + return Ok(Err(SetError::forbidden().with_description(concat!( + "The 'vacation' script cannot be modified, ", + "use VacationResponse/set instead." + )))); + } + + // Parse properties + let mut set_item = None; + let mut changes = update + .as_ref() + .map(|(_, obj)| obj.deserialize().unwrap_or_default()) + .unwrap_or_default(); + let mut blob_id = None; + for (property, mut value) in changes_.into_expanded_object() { + if let Err(err) = ctx.response.resolve_self_references(&mut value, 0, false) { + return Ok(Err(err)); + }; + match (&property, value) { + (Key::Property(SieveProperty::Name), Value::Str(value)) => { + if value.len() > self.core.email.sieve_max_script_name { + return Ok(Err(SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Script name is too long."))); + } else if value.eq_ignore_ascii_case("vacation") { + return Ok(Err(SetError::forbidden() + .with_property(property.into_owned()) + .with_description( + "The 'vacation' name is reserved, please use a different name.", + ))); + } else if update + .as_ref() + .is_none_or(|(_, obj)| obj.inner.name != value.as_ref()) + && let Some(id) = self + .document_ids_matching( + ctx.account_id, + Collection::SieveScript, + SieveField::Name, + value.as_bytes(), + ) + .await? + .min() + { + return Ok(Err(SetError::already_exists() + .with_existing_id(id.into()) + .with_description(format!( + "A sieve script with name '{}' already exists.", + value + )))); + } + + changes.name = value.into_owned(); + } + ( + Key::Property(SieveProperty::BlobId), + Value::Element(SieveValue::BlobId(value)), + ) => { + blob_id = value.into(); + continue; + } + (Key::Property(SieveProperty::Name), Value::Null) => { + continue; + } + (Key::Property(SieveProperty::IsActive), Value::Bool(value)) => { + set_item = Some(value); + continue; + } + (Key::Property(SieveProperty::Id), value) => { + if update + .as_ref() + .map(|(document_id, _)| Id::from(*document_id)) + .is_none_or(|expected| !crate::matches_id(&value, expected)) + { + return Ok(Err(SetError::invalid_properties() + .with_property(SieveProperty::Id) + .with_description("The id property is immutable.".to_string()))); + } + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Invalid property or value.".to_string()))); + } + } + } + + if update.is_none() { + // Add name if missing + if changes.name.is_empty() { + changes.name = rng() + .sample_iter(Alphanumeric) + .take(15) + .map(char::from) + .collect::(); + } + } + + let blob_update = if let Some(blob_id) = blob_id { + if update.as_ref().is_none_or( |(document_id, _)| { + !matches!(blob_id.class, BlobClass::Linked { account_id, collection, document_id: d } if account_id == ctx.account_id && collection == u8::from(Collection::SieveScript) && *document_id == d) + }) { + // Check access + if let Some(mut bytes) = self.blob_download(&blob_id, ctx.access_token).await? { + // Check quota + match self + .has_available_quota(ctx.account_cache, bytes.len() as u64) + .await + { + Ok(_) => (), + Err(err) => { + if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) + || err.matches(trc::EventType::Limit(trc::LimitEvent::TenantQuota)) + { + trc::error!(err.account_id(ctx.account_id).span_id(session_id)); + return Ok(Err(SetError::over_quota())); + } else { + return Err(err); + } + } + } + + // Compile script + match self.core.sieve.untrusted_compiler.compile(&bytes) { + Ok(script) => { + changes.size = bytes.len() as u32; + bytes.extend(Archiver::new(script).untrusted().serialize().caused_by(trc::location!())?); + bytes.into() + } + Err(err) => { + return Ok(Err(SetError::new( + if let ErrorType::ScriptTooLong = &err.error_type() { + SetErrorType::TooLarge + } else { + SetErrorType::InvalidScript + }, + ) + .with_description(err.to_string()))); + } + } + } else { + return Ok(Err(SetError::new(SetErrorType::BlobNotFound) + .with_property(SieveProperty::BlobId) + .with_description("Blob does not exist."))); + } + } else { + None + } + } else if update.is_none() { + return Ok(Err(SetError::invalid_properties() + .with_property(SieveProperty::BlobId) + .with_description("Missing blobId."))); + } else { + None + }; + + // Validate + Ok(Ok(SetItemResponse { + builder: ObjectIndexBuilder::new() + .with_changes(changes) + .with_current_opt(update.map(|(_, current)| current)), + blob_update, + set_item, + })) + } +} + +pub struct SetItemResponse<'x> { + builder: ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>, + blob_update: Option>, + set_item: Option, +} diff --git a/crates/jmap/src/sieve/validate.rs b/crates/jmap/src/sieve/validate.rs new file mode 100644 index 0000000..84b33c2 --- /dev/null +++ b/crates/jmap/src/sieve/validate.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::blob::download::BlobDownload; +use common::{Server, auth::AccessToken}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::validate::{ValidateSieveScriptRequest, ValidateSieveScriptResponse}, + request::MaybeInvalid, +}; +use std::future::Future; + +pub trait SieveScriptValidate: Sync + Send { + fn sieve_script_validate( + &self, + request: ValidateSieveScriptRequest, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl SieveScriptValidate for Server { + async fn sieve_script_validate( + &self, + request: ValidateSieveScriptRequest, + access_token: &AccessToken, + ) -> trc::Result { + Ok(ValidateSieveScriptResponse { + account_id: request.account_id, + error: match request.blob_id { + MaybeInvalid::Value(blob_id) => { + match self + .blob_download(&blob_id, access_token) + .await? + .map(|bytes| self.core.sieve.untrusted_compiler.compile(&bytes)) + { + Some(Ok(_)) => None, + Some(Err(err)) => SetError::new(SetErrorType::InvalidScript) + .with_description(err.to_string()) + .into(), + None => SetError::new(SetErrorType::BlobNotFound).into(), + } + } + MaybeInvalid::Invalid(_) => SetError::new(SetErrorType::BlobNotFound).into(), + }, + }) + } +} diff --git a/crates/jmap/src/submission/get.rs b/crates/jmap/src/submission/get.rs new file mode 100644 index 0000000..fff7f8a --- /dev/null +++ b/crates/jmap/src/submission/get.rs @@ -0,0 +1,324 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::StateManager; +use common::Server; +use email::submission::{ + ArchivedAddress, ArchivedEnvelope, ArchivedUndoStatus, Delivered, DeliveryStatus, + EmailSubmission, +}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::email_submission::{self, Displayed, EmailSubmissionProperty, EmailSubmissionValue}, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use smtp::queue::{ArchivedError, ArchivedErrorDetails, ArchivedStatus, Message, spool::SmtpSpool}; +use smtp_proto::ArchivedResponse; +use std::future::Future; +use store::{ + IterateParams, U32_LEN, ValueKey, + rkyv::option::ArchivedOption, + write::{ + AlignedBytes, Archive, IndexPropertyClass, ValueClass, key::DeserializeBigEndian, now, + }, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::EmailSubmissionField, + id::Id, +}; +use utils::map::vec_map::VecMap; + +pub trait EmailSubmissionGet: Sync + Send { + fn email_submission_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; +} + +impl EmailSubmissionGet for Server { + async fn email_submission_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let properties = request.unwrap_properties(&[ + EmailSubmissionProperty::Id, + EmailSubmissionProperty::EmailId, + EmailSubmissionProperty::IdentityId, + EmailSubmissionProperty::ThreadId, + EmailSubmissionProperty::Envelope, + EmailSubmissionProperty::SendAt, + EmailSubmissionProperty::UndoStatus, + EmailSubmissionProperty::DeliveryStatus, + EmailSubmissionProperty::DsnBlobIds, + EmailSubmissionProperty::MdnBlobIds, + ]); + let account_id = request.account_id.document_id(); + let ids = if let Some(ids) = ids { + ids + } else { + let mut ids = Vec::with_capacity(16); + + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::EmailSubmission.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: now() - (3 * 86400), + }), + }, + ValueKey { + account_id, + collection: Collection::EmailSubmission.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: u64::MAX, + }), + }, + ) + .ascending() + .no_values(), + |key, _| { + ids.push(Id::from(key.deserialize_be_u32(key.len() - U32_LEN)?)); + + Ok(ids.len() < self.core.jmap.get_max_objects) + }, + ) + .await + .caused_by(trc::location!())?; + + ids + }; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: self + .get_state(account_id, SyncCollection::EmailSubmission) + .await? + .into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + for id in ids { + // Obtain the email_submission object + let document_id = id.document_id(); + let submission_ = if let Some(submission) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::EmailSubmission, + document_id, + )) + .await? + { + submission + } else { + response.push_not_found(id); + continue; + }; + let submission = submission_ + .unarchive::() + .caused_by(trc::location!())?; + + // Obtain queueId + let mut delivery_status = submission + .delivery_status + .iter() + .map(|(k, v)| (k.to_string(), DeliveryStatus::from(v))) + .collect::>(); + let mut queued_status = None; + if let Some(queue_id) = submission.queue_id.as_ref().map(u64::from) { + if let Some(queued_message_) = self + .read_message_archive(queue_id) + .await + .caused_by(trc::location!())? + { + let queued_message = queued_message_ + .unarchive::() + .caused_by(trc::location!())?; + for rcpt in queued_message.recipients.iter() { + *delivery_status.get_mut_or_insert(rcpt.address().to_string()) = + DeliveryStatus { + smtp_reply: match &rcpt.status { + ArchivedStatus::Completed(reply) => { + format_archived_response(&reply.response) + } + ArchivedStatus::TemporaryFailure(reply) + | ArchivedStatus::PermanentFailure(reply) => { + format_archived_error_details(reply) + } + ArchivedStatus::Scheduled => "250 2.1.5 Queued".to_string(), + }, + delivered: match &rcpt.status { + ArchivedStatus::Scheduled + | ArchivedStatus::TemporaryFailure(_) => Delivered::Queued, + ArchivedStatus::Completed(_) => Delivered::Yes, + ArchivedStatus::PermanentFailure(_) => Delivered::No, + }, + displayed: false, + }; + } + queued_status = email_submission::UndoStatus::Pending.into(); + } else { + queued_status = email_submission::UndoStatus::Final.into(); + } + } + let undo_status = match submission.undo_status { + ArchivedUndoStatus::Canceled => email_submission::UndoStatus::Canceled, + ArchivedUndoStatus::Pending => { + queued_status.unwrap_or(email_submission::UndoStatus::Pending) + } + ArchivedUndoStatus::Final => { + queued_status.unwrap_or(email_submission::UndoStatus::Final) + } + }; + + let mut result = Map::with_capacity(properties.len()); + for property in &properties { + let value = match property { + EmailSubmissionProperty::Id => Value::Element(id.into()), + EmailSubmissionProperty::DeliveryStatus => { + let mut status = Map::with_capacity(delivery_status.len()); + + for (rcpt, delivery_status) in std::mem::take(&mut delivery_status) { + status.insert_unchecked( + Key::Owned(rcpt), + Map::with_capacity(3) + .with_key_value( + EmailSubmissionProperty::Delivered, + EmailSubmissionValue::Delivered( + match delivery_status.delivered { + Delivered::Queued => { + email_submission::Delivered::Queued + } + Delivered::Yes => email_submission::Delivered::Yes, + Delivered::No => email_submission::Delivered::No, + Delivered::Unknown => { + email_submission::Delivered::Unknown + } + }, + ), + ) + .with_key_value( + EmailSubmissionProperty::SmtpReply, + delivery_status.smtp_reply, + ) + .with_key_value( + EmailSubmissionProperty::Displayed, + Value::Element(EmailSubmissionValue::Displayed( + Displayed::Unknown, + )), + ), + ); + } + + Value::Object(status) + } + EmailSubmissionProperty::UndoStatus => { + Value::Element(EmailSubmissionValue::UndoStatus(undo_status.clone())) + } + EmailSubmissionProperty::EmailId => Value::Element( + Id::from_parts( + u32::from(submission.thread_id), + u32::from(submission.email_id), + ) + .into(), + ), + EmailSubmissionProperty::IdentityId => { + Value::Element(Id::from(u32::from(submission.identity_id)).into()) + } + EmailSubmissionProperty::ThreadId => { + Value::Element(Id::from(u32::from(submission.thread_id)).into()) + } + EmailSubmissionProperty::Envelope => build_envelope(&submission.envelope), + EmailSubmissionProperty::SendAt => Value::Element(EmailSubmissionValue::Date( + UTCDate::from_timestamp(u64::from(submission.send_at) as i64), + )), + EmailSubmissionProperty::MdnBlobIds | EmailSubmissionProperty::DsnBlobIds => { + Value::Array(vec![]) + } + _ => Value::Null, + }; + + result.insert_unchecked(property.clone(), value); + } + response.list.push(result.into()); + } + + Ok(response) + } +} + +fn build_envelope( + envelope: &ArchivedEnvelope, +) -> Value<'static, EmailSubmissionProperty, EmailSubmissionValue> { + Map::with_capacity(2) + .with_key_value( + EmailSubmissionProperty::MailFrom, + build_address(&envelope.mail_from), + ) + .with_key_value( + EmailSubmissionProperty::RcptTo, + Value::Array(envelope.rcpt_to.iter().map(build_address).collect()), + ) + .into() +} + +fn build_address( + envelope: &ArchivedAddress, +) -> Value<'static, EmailSubmissionProperty, EmailSubmissionValue> { + Map::with_capacity(2) + .with_key_value( + EmailSubmissionProperty::Email, + Value::Str(envelope.email.to_string().into()), + ) + .with_key_value( + EmailSubmissionProperty::Parameters, + if let ArchivedOption::Some(params) = &envelope.parameters { + Value::Object(Map::from_iter( + params + .iter() + .map(|(k, v)| (Key::Owned(k.to_string()), v.into())), + )) + } else { + Value::Null + }, + ) + .into() +} + +fn format_archived_response(response: &ArchivedResponse>) -> String { + format!( + "Code: {}, Enhanced code: {}.{}.{}, Message: {}", + response.code, + response.esc[0], + response.esc[1], + response.esc[2], + response.message.replace('\n', " "), + ) +} + +fn format_archived_error_details(response: &ArchivedErrorDetails) -> String { + match &response.details { + ArchivedError::UnexpectedResponse(response) => format_archived_response(&response.response), + ArchivedError::DnsError(details) + | ArchivedError::Io(details) + | ArchivedError::ConnectionError(details) + | ArchivedError::TlsError(details) + | ArchivedError::DaneError(details) + | ArchivedError::MtaStsError(details) => details.to_string(), + ArchivedError::RateLimited => "Rate limited".to_string(), + ArchivedError::ConcurrencyLimited => "Concurrency limited".to_string(), + } +} diff --git a/crates/jmap/src/submission/mod.rs b/crates/jmap/src/submission/mod.rs new file mode 100644 index 0000000..c036acc --- /dev/null +++ b/crates/jmap/src/submission/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod query; +pub mod set; diff --git a/crates/jmap/src/submission/query.rs b/crates/jmap/src/submission/query.rs new file mode 100644 index 0000000..280a225 --- /dev/null +++ b/crates/jmap/src/submission/query.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{api::query::QueryResponseBuilder, changes::state::StateManager}; +use common::Server; +use email::submission::UndoStatus; +use jmap_proto::{ + method::query::{Filter, QueryRequest, QueryResponse}, + object::email_submission::{self, EmailSubmissionComparator, EmailSubmissionFilter}, + request::IntoValid, +}; +use std::future::Future; +use store::{ + IterateParams, U32_LEN, U64_LEN, ValueKey, + ahash::AHashSet, + roaring::RoaringBitmap, + search::{SearchFilter, SearchQuery}, + write::{ + IndexPropertyClass, QueueClass, SearchIndex, ValueClass, key::DeserializeBigEndian, now, + }, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::EmailSubmissionField, +}; + +pub trait EmailSubmissionQuery: Sync + Send { + fn email_submission_query( + &self, + request: QueryRequest, + ) -> impl Future> + Send; +} + +struct Submission { + document_id: u32, + send_at: u64, + email_id: u32, + thread_id: u32, + identity_id: u32, + queue_id: u64, + undo_status: u8, +} + +impl EmailSubmissionQuery for Server { + async fn email_submission_query( + &self, + mut request: QueryRequest, + ) -> trc::Result { + let account_id = request.account_id.document_id(); + + let mut submissions = Vec::with_capacity(16); + let mut document_ids = RoaringBitmap::new(); + + self.store() + .iterate( + IterateParams::new( + ValueKey { + account_id, + collection: Collection::EmailSubmission.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: now() - (3 * 86400), + }), + }, + ValueKey { + account_id, + collection: Collection::EmailSubmission.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Integer { + property: EmailSubmissionField::Metadata.into(), + value: u64::MAX, + }), + }, + ) + .ascending(), + |key, value| { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + + submissions.push(Submission { + document_id, + send_at: key.deserialize_be_u64(key.len() - U32_LEN - U64_LEN)?, + email_id: value.deserialize_be_u32(0)?, + thread_id: value.deserialize_be_u32(U32_LEN)?, + identity_id: value.deserialize_be_u32(U32_LEN + U32_LEN)?, + queue_id: if value.len() == (U32_LEN * 3) + U64_LEN + 1 { + value.deserialize_be_u64(U32_LEN * 3)? + } else { + 0 + }, + undo_status: value.last().copied().unwrap(), + }); + + document_ids.insert(document_id); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if request + .filter + .iter() + .any(|cond| matches!(cond, Filter::Property(EmailSubmissionFilter::UndoStatus(_)))) + { + let pending = UndoStatus::Pending.as_index(); + for submission in submissions + .iter_mut() + .filter(|s| s.undo_status == pending && s.queue_id != 0) + { + if !self + .store() + .key_exists(ValueKey::from(ValueClass::Queue(QueueClass::Message( + submission.queue_id, + )))) + .await + .caused_by(trc::location!())? + { + submission.undo_status = UndoStatus::Final.as_index(); + } + } + } + + let mut filters = Vec::with_capacity(request.filter.len()); + for cond in std::mem::take(&mut request.filter) { + match cond { + Filter::Property(cond) => match cond { + EmailSubmissionFilter::IdentityIds(ids) => { + let ids = ids + .into_valid() + .map(|id| id.document_id()) + .collect::>(); + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| ids.contains(&s.identity_id)) + .map(|s| s.document_id), + ))); + } + EmailSubmissionFilter::EmailIds(ids) => { + let ids = ids + .into_valid() + .map(|id| id.document_id()) + .collect::>(); + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| ids.contains(&s.email_id)) + .map(|s| s.document_id), + ))); + } + EmailSubmissionFilter::ThreadIds(ids) => { + let ids = ids + .into_valid() + .map(|id| id.document_id()) + .collect::>(); + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| ids.contains(&s.thread_id)) + .map(|s| s.document_id), + ))); + } + EmailSubmissionFilter::UndoStatus(undo_status) => { + let undo_status = match undo_status { + email_submission::UndoStatus::Pending => UndoStatus::Pending, + email_submission::UndoStatus::Final => UndoStatus::Final, + email_submission::UndoStatus::Canceled => UndoStatus::Canceled, + } + .as_index(); + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| s.undo_status == undo_status) + .map(|s| s.document_id), + ))); + } + EmailSubmissionFilter::Before(before) => { + let before = before.timestamp() as u64; + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| s.send_at < before) + .map(|s| s.document_id), + ))); + } + EmailSubmissionFilter::After(after) => { + let after = after.timestamp() as u64; + + filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter( + submissions + .iter() + .filter(|s| s.send_at > after) + .map(|s| s.document_id), + ))); + } + + EmailSubmissionFilter::_T(other) => { + return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(other)); + } + }, + Filter::And => { + filters.push(SearchFilter::And); + } + Filter::Or => { + filters.push(SearchFilter::Or); + } + Filter::Not => { + filters.push(SearchFilter::Not); + } + Filter::Close => { + filters.push(SearchFilter::End); + } + } + } + + let results = SearchQuery::new(SearchIndex::InMemory) + .with_filters(filters) + .with_mask(document_ids) + .filter() + .into_bitmap(); + + let mut response = QueryResponseBuilder::new( + results.len() as usize, + self.core.jmap.query_max_results, + self.get_state(account_id, SyncCollection::EmailSubmission) + .await?, + &request, + ); + + if !results.is_empty() { + if let Some(comparator) = request.sort.take().unwrap_or_default().into_iter().next() { + match comparator.property { + EmailSubmissionComparator::EmailId => { + if comparator.is_ascending { + submissions.sort_by_key(|s| s.email_id); + } else { + submissions.sort_by_key(|s| u32::MAX - s.email_id); + } + } + EmailSubmissionComparator::ThreadId => { + if comparator.is_ascending { + submissions.sort_by_key(|s| s.thread_id); + } else { + submissions.sort_by_key(|s| u32::MAX - s.thread_id); + } + } + EmailSubmissionComparator::SentAt => { + if !comparator.is_ascending { + submissions.reverse(); + } + } + EmailSubmissionComparator::_T(other) => { + return Err(trc::JmapEvent::UnsupportedSort.into_err().details(other)); + } + } + } + + for submission in submissions { + if results.contains(submission.document_id) + && !response.add(0, submission.document_id) + { + break; + } + } + } + + response.build() + } +} diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs new file mode 100644 index 0000000..90d8b5c --- /dev/null +++ b/crates/jmap/src/submission/set.rs @@ -0,0 +1,881 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + config::smtp::queue::QueueName, + network::{ServerInstance, stream::NullIo}, + storage::index::ObjectIndexBuilder, +}; +use email::{ + identity::Identity, + message::metadata::{ArchivedMetadataHeaderName, ArchivedMetadataHeaderValue, MessageMetadata}, + submission::{Address, Delivered, DeliveryStatus, EmailSubmission, UndoStatus}, +}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::email_submission::{self, EmailSubmissionProperty, EmailSubmissionValue}, + references::resolve::ResolveCreatedReference, + request::{ + Call, MaybeInvalid, RequestMethod, SetRequestMethod, + method::{MethodFunction, MethodName, MethodObject}, + reference::{MaybeIdReference, MaybeResultReference}, + }, + types::{date::UTCDate, state::State}, +}; +use jmap_tools::{Key, Map, Value}; +use smtp::{ + core::{Session, SessionData}, + queue::spool::SmtpSpool, +}; +use smtp_proto::{MailFrom, RcptTo, request::parser::Rfc5321Parser}; +use std::{borrow::Cow, future::Future}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use trc::AddContext; +use types::{collection::Collection, field::EmailField, id::Id}; +use utils::{map::vec_map::VecMap, sanitize_email}; + +pub trait EmailSubmissionSet: Sync + Send { + fn email_submission_set<'x>( + &self, + request: SetRequest<'x, email_submission::EmailSubmission>, + instance: &Arc, + next_call: &mut Option>>, + ) -> impl Future>> + Send; + + fn send_message( + &self, + account_id: u32, + response: &SetResponse, + instance: &Arc, + object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, + ) -> impl Future< + Output = trc::Result>>, + > + Send; +} + +impl EmailSubmissionSet for Server { + async fn email_submission_set<'x>( + &self, + mut request: SetRequest<'x, email_submission::EmailSubmission>, + instance: &Arc, + next_call: &mut Option>>, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; + let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); + + // Process creates + let mut success_email_ids = HashMap::new(); + let mut batch = BatchBuilder::new(); + for (id, object) in request.unwrap_create() { + match self + .send_message(account_id, &response, instance, object) + .await? + { + Ok(submission) => { + // Add id mapping + success_email_ids.insert( + id.clone(), + Id::from_parts(submission.thread_id, submission.email_id), + ); + + let send_at = submission.send_at; + let undo_status = match submission.undo_status { + UndoStatus::Pending => email_submission::UndoStatus::Pending, + UndoStatus::Final => email_submission::UndoStatus::Final, + UndoStatus::Canceled => email_submission::UndoStatus::Canceled, + }; + + // Insert record + let document_id = self + .store() + .assign_document_ids(account_id, Collection::EmailSubmission, 1) + .await + .caused_by(trc::location!())?; + batch + .with_account_id(account_id) + .with_collection(Collection::EmailSubmission) + .with_document(document_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(submission)) + .caused_by(trc::location!())? + .commit_point(); + + response.created.insert( + id, + Value::Object( + Map::with_capacity(3) + .with_key_value( + EmailSubmissionProperty::Id, + Value::Element(Id::from(document_id).into()), + ) + .with_key_value( + EmailSubmissionProperty::SendAt, + Value::Element(EmailSubmissionValue::Date( + UTCDate::from_timestamp(send_at as i64), + )), + ) + .with_key_value( + EmailSubmissionProperty::UndoStatus, + Value::Element(EmailSubmissionValue::UndoStatus(undo_status)), + ), + ), + ); + } + Err(err) => { + response.not_created.append(id, err); + } + } + } + + // Process updates + 'update: for (id, object) in request.unwrap_update() { + let id = match id { + MaybeInvalid::Value(id) => id, + invalid => { + response.not_updated.append(invalid, SetError::not_found()); + continue 'update; + } + }; + // Make sure id won't be destroyed + if will_destroy.contains(&id) { + response.not_updated.append(id, SetError::will_destroy()); + continue 'update; + } + + // Obtain submission + let document_id = id.document_id(); + let submission = if let Some(submission) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::EmailSubmission, + document_id, + )) + .await? + { + submission + .into_deserialized::() + .caused_by(trc::location!())? + } else { + response.not_updated.append(id, SetError::not_found()); + continue 'update; + }; + + let mut queue_id = u64::MAX; + let mut undo_status = None; + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response.resolve_self_references(&mut value, 0, false) { + response.not_updated.append(id, err); + continue 'update; + }; + + if matches!(&property, Key::Property(EmailSubmissionProperty::Id)) { + if !crate::matches_id(&value, id) { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Id) + .with_description("The id property is immutable."), + ); + continue 'update; + } + continue; + } + + if let ( + Key::Property(EmailSubmissionProperty::UndoStatus), + Value::Element(EmailSubmissionValue::UndoStatus(undo_status_)), + Some(queue_id_), + ) = (&property, value, submission.inner.queue_id) + { + undo_status = undo_status_.into(); + queue_id = queue_id_; + } else { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Field could not be set."), + ); + continue 'update; + } + } + + match undo_status { + Some(email_submission::UndoStatus::Canceled) => { + if let Some(queue_message) = + self.read_message(queue_id, QueueName::default()).await + { + // Delete message from queue + queue_message.remove(self, None).await; + + // Update record + let mut new_submission = submission.inner.clone(); + new_submission.undo_status = UndoStatus::Canceled; + batch + .with_account_id(account_id) + .with_collection(Collection::EmailSubmission) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(submission) + .with_changes(new_submission), + ) + .caused_by(trc::location!())? + .commit_point(); + response.updated.append(id, None); + } else { + response.not_updated.append( + id, + SetError::new(SetErrorType::CannotUnsend).with_description( + "The requested message is no longer in the queue.", + ), + ); + } + } + Some(_) => { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(EmailSubmissionProperty::UndoStatus) + .with_description("Email submissions can only be cancelled."), + ); + } + None => { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_description("No properties to set were found."), + ); + } + } + } + + // Process deletions + for id in will_destroy { + let document_id = id.document_id(); + if let Some(submission) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::EmailSubmission, + document_id, + )) + .await? + { + // Update record + batch + .with_account_id(account_id) + .with_collection(Collection::EmailSubmission) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<_, ()>::new().with_current( + submission + .to_unarchived::() + .caused_by(trc::location!())?, + ), + ) + .caused_by(trc::location!())? + .commit_point(); + response.destroyed.push(id); + } else { + response.not_destroyed.append(id, SetError::not_found()); + } + } + + // Write changes + if !batch.is_empty() { + let change_id = self + .commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?; + response.new_state = State::Exact(change_id).into(); + } + + // On success + if (request + .arguments + .on_success_destroy_email + .as_ref() + .is_some_and(|p| !p.is_empty()) + || request + .arguments + .on_success_update_email + .as_ref() + .is_some_and(|p| !p.is_empty())) + && response.has_changes() + { + *next_call = Call { + id: String::new(), + name: MethodName::new(MethodObject::Email, MethodFunction::Set), + method: RequestMethod::Set(SetRequestMethod::Email(Box::new(SetRequest { + account_id: request.account_id, + if_in_state: None, + create: None, + update: request.arguments.on_success_update_email.map(|update| { + update + .into_iter() + .filter_map(|(id, value)| { + ( + match id { + MaybeIdReference::Id(id) => MaybeInvalid::Value(id), + MaybeIdReference::Reference(id_ref) => { + MaybeInvalid::Value(*(success_email_ids.get(&id_ref)?)) + } + MaybeIdReference::Invalid(id) => MaybeInvalid::Invalid(id), + }, + value, + ) + .into() + }) + .collect() + }), + destroy: request.arguments.on_success_destroy_email.map(|ids| { + MaybeResultReference::Value( + ids.into_iter() + .filter_map(|id| match id { + MaybeIdReference::Id(id) => Some(id), + MaybeIdReference::Reference(id_ref) => { + success_email_ids.get(&id_ref).copied() + } + MaybeIdReference::Invalid(_) => None, + }) + .map(MaybeInvalid::Value) + .collect(), + ) + }), + arguments: Default::default(), + }))), + } + .into(); + } + + Ok(response) + } + + async fn send_message( + &self, + account_id: u32, + response: &SetResponse, + instance: &Arc, + object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, + ) -> trc::Result>> { + let mut submission = EmailSubmission { + email_id: u32::MAX, + identity_id: u32::MAX, + thread_id: u32::MAX, + ..Default::default() + }; + let mut mail_from: Option>> = None; + let mut rcpt_to: Vec>> = Vec::new(); + + for (property, mut value) in object.into_expanded_object() { + if let Err(err) = response.resolve_self_references(&mut value, 0, false) { + return Ok(Err(err)); + }; + + match (&property, value) { + ( + Key::Property(EmailSubmissionProperty::EmailId), + Value::Element(EmailSubmissionValue::Id(value)), + ) => { + submission.email_id = value.document_id(); + submission.thread_id = value.prefix_id(); + } + ( + Key::Property(EmailSubmissionProperty::IdentityId), + Value::Element(EmailSubmissionValue::Id(value)), + ) => { + submission.identity_id = value.document_id(); + } + (Key::Property(EmailSubmissionProperty::Envelope), Value::Object(value)) => { + for (property, value) in value.into_vec() { + match (&property, value) { + (Key::Property(EmailSubmissionProperty::MailFrom), value) => { + match parse_envelope_address(value) { + Ok((addr, params, smtp_params)) => { + match Rfc5321Parser::new( + &mut smtp_params + .as_ref() + .map_or(&b"\n"[..], |p| p.as_bytes()) + .iter(), + ) + .mail_from_parameters(addr.into()) + { + Ok(addr) => { + submission.envelope.mail_from = Address { + email: addr.address.as_ref().to_string(), + parameters: params, + }; + mail_from = from_into_static(addr).into(); + } + Err(err) => { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description(format!( + "Failed to parse mailFrom parameters: {err}." + )))); + } + } + } + Err(err) => { + return Ok(Err(err)); + } + } + } + ( + Key::Property(EmailSubmissionProperty::RcptTo), + Value::Array(value), + ) => { + for addr in value { + match parse_envelope_address(addr) { + Ok((addr, params, smtp_params)) => { + match Rfc5321Parser::new( + &mut smtp_params + .as_ref() + .map_or(&b"\n"[..], |p| p.as_bytes()) + .iter(), + ) + .rcpt_to_parameters(addr.into()) + { + Ok(addr) => { + if !rcpt_to + .iter() + .any(|rcpt| rcpt.address == addr.address) + { + submission.envelope.rcpt_to.push(Address { + email: addr + .address + .as_ref() + .to_string(), + parameters: params, + }); + rcpt_to.push(rcpt_into_static(addr)); + } + } + Err(err) => { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description(format!( + "Failed to parse rcptTo parameters: {err}." + )))); + } + } + } + Err(err) => { + return Ok(Err(err)); + } + } + } + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description("Invalid object property."))); + } + } + } + } + (Key::Property(EmailSubmissionProperty::Envelope), Value::Null) => { + continue; + } + (Key::Property(EmailSubmissionProperty::UndoStatus), Value::Element(_)) => { + continue; + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Field could not be set."))); + } + } + } + + // Make sure we have all required fields. + if submission.email_id == u32::MAX || submission.identity_id == u32::MAX { + return Ok(Err(SetError::invalid_properties() + .with_properties([ + EmailSubmissionProperty::EmailId, + EmailSubmissionProperty::IdentityId, + ]) + .with_description( + "emailId and identityId properties are required.", + ))); + } + + // Fetch identity's mailFrom + let identity_mail_from = if let Some(identity) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Identity, + submission.identity_id, + )) + .await? + { + identity + .unarchive::() + .caused_by(trc::location!())? + .email + .to_string() + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::IdentityId) + .with_description("Identity not found."))); + }; + + // Make sure the envelope address matches the identity email address + let mail_from = if let Some(mail_from) = mail_from { + if !mail_from.address.eq_ignore_ascii_case(&identity_mail_from) { + return Ok(Err(SetError::new(SetErrorType::ForbiddenFrom) + .with_description( + "Envelope mailFrom does not match identity email address.", + ))); + } + mail_from + } else { + submission.envelope.mail_from = Address { + email: identity_mail_from.clone(), + parameters: None, + }; + MailFrom { + address: Cow::Owned(identity_mail_from), + ..Default::default() + } + }; + + // Obtain message metadata + let metadata_ = if let Some(metadata) = self + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + submission.email_id, + EmailField::Metadata, + )) + .await? + { + metadata + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::EmailId) + .with_description("Email not found."))); + }; + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + + // Add recipients to envelope if missing + let mut bcc_header = None; + if rcpt_to.is_empty() { + for header in metadata.contents[0].parts[0].headers.iter() { + if matches!( + header.name, + ArchivedMetadataHeaderName::To + | ArchivedMetadataHeaderName::Cc + | ArchivedMetadataHeaderName::Bcc + ) { + if matches!(header.name, ArchivedMetadataHeaderName::Bcc) { + bcc_header = Some(header); + } + match &header.value { + ArchivedMetadataHeaderValue::AddressList(addr) => { + for address in addr.iter() { + if let Some(address) = address + .address + .as_ref() + .map(|v| v.as_ref()) + .and_then(sanitize_email) + && !rcpt_to.iter().any(|rcpt| rcpt.address == address) + { + submission.envelope.rcpt_to.push(Address { + email: address.to_string(), + parameters: None, + }); + rcpt_to.push(RcptTo { + address: Cow::Owned(address), + ..Default::default() + }); + } + } + } + ArchivedMetadataHeaderValue::AddressGroup(groups) => { + for group in groups.iter() { + for address in group.addresses.iter() { + if let Some(address) = address + .address + .as_ref() + .map(|v| v.as_ref()) + .and_then(sanitize_email) + && !rcpt_to.iter().any(|rcpt| rcpt.address == address) + { + submission.envelope.rcpt_to.push(Address { + email: address.to_string(), + parameters: None, + }); + rcpt_to.push(RcptTo { + address: Cow::Owned(address), + ..Default::default() + }); + } + } + } + } + _ => {} + } + } + } + + if rcpt_to.is_empty() { + return Ok(Err(SetError::new(SetErrorType::NoRecipients) + .with_description("No recipients found in email."))); + } + } else { + bcc_header = metadata.contents[0].parts[0] + .headers + .iter() + .find(|header| matches!(header.name, ArchivedMetadataHeaderName::Bcc)); + } + + // Update sendAt + submission.send_at = if mail_from.hold_until > 0 { + mail_from.hold_until + } else if mail_from.hold_for > 0 { + mail_from.hold_for + now() + } else { + now() + }; + + // Obtain raw message + let mut message = if let Some(message) = self + .blob_store() + .get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX) + .await? + { + if message.len() > self.core.email.mail_max_size { + return Ok(Err(SetError::new(SetErrorType::InvalidEmail) + .with_description(format!( + "Message exceeds maximum size of {} bytes.", + self.core.email.mail_max_size + )))); + } + + message + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::EmailId) + .with_description("Blob for email not found."))); + }; + + // Remove BCC header if present + if let Some(bcc_header) = bcc_header { + let mut new_message = Vec::with_capacity(message.len()); + let range = bcc_header.name_value_range(); + new_message.extend_from_slice(&message[..range.start]); + new_message.extend_from_slice(&message[range.end..]); + message = new_message; + } + + // Begin local SMTP session + let mut session = Session::::local( + self.clone(), + instance.clone(), + SessionData::local( + self.account_info(account_id) + .await + .caused_by(trc::location!())?, + None, + vec![], + vec![], + 0, + ), + ); + + // Spawn SMTP session to avoid overflowing the stack + let handle = tokio::spawn(async move { + // MAIL FROM + let _ = session.handle_mail_from(mail_from).await; + if let Some(error) = session.has_failed() { + return Err(SetError::new(SetErrorType::ForbiddenMailFrom) + .with_description(format!("Server rejected MAIL-FROM: {}", error.trim()))); + } + + // RCPT TO + let mut responses = Vec::new(); + let mut has_success = false; + session.params.rcpt_errors_wait = Duration::from_secs(0); + for rcpt in rcpt_to { + let addr = rcpt.address.clone(); + let _ = session.handle_rcpt_to(rcpt).await; + let response = session.has_failed(); + if response.is_none() { + has_success = true; + } + responses.push((addr, response)); + } + + // DATA + if has_success { + session.data.message = message; + let response = session.queue_message().await; + if let smtp::core::State::Accepted(queue_id) = session.state { + Ok((responses, Some(queue_id))) + } else { + Err( + SetError::new(SetErrorType::ForbiddenToSend).with_description(format!( + "Server rejected DATA: {}", + std::str::from_utf8(&response).unwrap().trim() + )), + ) + } + } else { + Ok((responses, None)) + } + }); + + match handle.await { + Ok(Ok((responses, queue_id))) => { + // Set queue ID + if let Some(queue_id) = queue_id { + submission.queue_id = Some(queue_id); + } + + // Set responses + submission.undo_status = if submission.queue_id.is_some() { + UndoStatus::Pending + } else { + UndoStatus::Final + }; + submission.delivery_status = responses + .into_iter() + .map(|(addr, response)| { + ( + addr.to_string(), + DeliveryStatus { + delivered: if response.is_none() { + Delivered::Unknown + } else { + Delivered::No + }, + smtp_reply: response + .map(|s| s.to_string()) + .unwrap_or_else(|| "250 2.1.5 Queued".to_string()), + displayed: false, + }, + ) + }) + .collect(); + + Ok(Ok(submission)) + } + Ok(Err(err)) => Ok(Err(err)), + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .caused_by(trc::location!()) + .details("Join Error")), + } + } +} + +#[allow(clippy::type_complexity)] +fn parse_envelope_address( + envelope: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, +) -> Result< + ( + String, + Option>>, + Option, + ), + SetError, +> { + if let Value::Object(mut envelope) = envelope { + if let Some(Value::Str(addr)) = + envelope.remove(&Key::Property(EmailSubmissionProperty::Email)) + { + if let Some(addr) = sanitize_email(&addr) { + if let Some(Value::Object(params)) = + envelope.remove(&Key::Property(EmailSubmissionProperty::Parameters)) + { + let mut params_text = String::new(); + let mut params_list = VecMap::with_capacity(params.len()); + + for (k, v) in params.into_vec() { + let k = k.into_string(); + if !k.is_empty() { + if !params_text.is_empty() { + params_text.push(' '); + } + params_text.push_str(&k); + if let Value::Str(v) = v { + params_text.push('='); + params_text.push_str(&v); + params_list.append(k, Some(v.into_owned())); + } else { + params_list.append(k, None); + } + } + } + params_text.push('\n'); + + Ok((addr.to_string(), Some(params_list), Some(params_text))) + } else { + Ok((addr.to_string(), None, None)) + } + } else { + Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description(format!("Invalid e-mail address {addr:?}."))) + } + } else { + Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description("Missing e-mail address field.")) + } + } else { + Err(SetError::invalid_properties() + .with_property(EmailSubmissionProperty::Envelope) + .with_description("Invalid envelope object.")) + } +} + +fn from_into_static(from: MailFrom>) -> MailFrom> { + MailFrom { + address: from.address.into_owned().into(), + flags: from.flags, + size: from.size, + trans_id: from.trans_id.map(Cow::into_owned).map(Cow::Owned), + by: from.by, + env_id: from.env_id.map(Cow::into_owned).map(Cow::Owned), + solicit: from.solicit.map(Cow::into_owned).map(Cow::Owned), + mtrk: from + .mtrk + .map(smtp_proto::Mtrk::into_owned) + .map(|v| smtp_proto::Mtrk { + certifier: Cow::Owned(v.certifier), + timeout: v.timeout, + }), + auth: from.auth.map(Cow::into_owned).map(Cow::Owned), + hold_for: from.hold_for, + hold_until: from.hold_until, + mt_priority: from.mt_priority, + } +} + +fn rcpt_into_static(rcpt: RcptTo>) -> RcptTo> { + RcptTo { + address: rcpt.address.into_owned().into(), + orcpt: rcpt.orcpt.map(Cow::into_owned).map(Cow::Owned), + rrvs: rcpt.rrvs, + flags: rcpt.flags, + } +} diff --git a/crates/jmap/src/thread/get.rs b/crates/jmap/src/thread/get.rs new file mode 100644 index 0000000..654b1b1 --- /dev/null +++ b/crates/jmap/src/thread/get.rs @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::StateManager; +use common::{Server, auth::AccessToken}; +use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::thread::{Thread, ThreadProperty, ThreadValue}, + request::MaybeInvalid, +}; +use jmap_tools::Map; +use std::future::Future; +use store::{ + ahash::AHashMap, + roaring::RoaringBitmap, + search::{EmailSearchField, SearchComparator, SearchField, SearchQuery}, + write::SearchIndex, +}; +use trc::AddContext; +use types::{acl::Acl, collection::SyncCollection, id::Id}; + +pub trait ThreadGet: Sync + Send { + fn thread_get( + &self, + request: GetRequest, + access_token: &AccessToken, + ) -> impl Future>> + Send; +} + +impl ThreadGet for Server { + async fn thread_get( + &self, + mut request: GetRequest, + access_token: &AccessToken, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + let shared_ids = if access_token.is_shared(account_id) { + Some(cache.shared_messages(access_token, Acl::ReadItems)) + } else { + None + }; + let mut thread_map: AHashMap = AHashMap::with_capacity(32); + let mut all_ids = RoaringBitmap::new(); + for item in &cache.emails.items { + if shared_ids + .as_ref() + .is_some_and(|ids| !ids.contains(item.document_id)) + { + continue; + } + thread_map + .entry(item.thread_id) + .or_default() + .insert(item.document_id); + all_ids.insert(item.document_id); + } + + let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?; + let ids = if let Some(ids) = ids { + ids + } else { + thread_map + .keys() + .copied() + .take(self.core.jmap.get_max_objects) + .map(Into::into) + .collect() + }; + let add_email_ids = request.properties.is_none_or(|p| { + p.unwrap() + .contains(&MaybeInvalid::Value(ThreadProperty::EmailIds)) + }); + let mut response = GetResponse { + account_id: request.account_id.into(), + state: self + .get_state(account_id, SyncCollection::Thread) + .await? + .into(), + list: Vec::with_capacity(ids.len()), + not_found: not_found_ids, + }; + + let ordered_ids = if add_email_ids && !all_ids.is_empty() { + Some( + self.search_store() + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_account_id(account_id) + .with_mask(all_ids) + .with_comparator(SearchComparator::Field { + field: SearchField::Email(EmailSearchField::ReceivedAt), + ascending: true, + }), + ) + .await?, + ) + } else { + None + }; + + for id in ids { + let thread_id = id.document_id(); + if let Some(mut document_ids) = thread_map.remove(&thread_id) { + let mut thread: Map<'_, ThreadProperty, ThreadValue> = + Map::with_capacity(2).with_key_value(ThreadProperty::Id, id); + if let Some(ordered_ids) = &ordered_ids { + let mut ids = Vec::with_capacity(document_ids.len() as usize); + for &id in ordered_ids.iter() { + if document_ids.remove(id) { + ids.push(Id::from_parts(thread_id, id)); + } + } + for id in document_ids.iter() { + ids.push(Id::from_parts(thread_id, id)); + } + + thread.insert_unchecked(ThreadProperty::EmailIds, ids); + } + response.list.push(thread.into()); + } else { + response.push_not_found(id); + } + } + + Ok(response) + } +} diff --git a/crates/jmap/src/thread/mod.rs b/crates/jmap/src/thread/mod.rs new file mode 100644 index 0000000..9f12b32 --- /dev/null +++ b/crates/jmap/src/thread/mod.rs @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; diff --git a/crates/jmap/src/vacation/get.rs b/crates/jmap/src/vacation/get.rs new file mode 100644 index 0000000..ab2b8ef --- /dev/null +++ b/crates/jmap/src/vacation/get.rs @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::changes::state::StateManager; +use common::Server; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; +use jmap_proto::{ + method::get::{GetRequest, GetResponse}, + object::vacation_response::{ + VacationResponse, VacationResponseProperty, VacationResponseValue, + }, + request::{ + MaybeInvalid, + reference::{MaybeIdReference, MaybeResultReference}, + }, + types::date::UTCDate, +}; +use jmap_tools::{Map, Value}; +use std::future::Future; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::SieveField, + id::Id, +}; + +pub trait VacationResponseGet: Sync + Send { + fn vacation_response_get( + &self, + request: GetRequest, + ) -> impl Future>> + Send; + + fn get_vacation_sieve_script_id( + &self, + account_id: u32, + ) -> impl Future>> + Send; +} + +impl VacationResponseGet for Server { + async fn vacation_response_get( + &self, + mut request: GetRequest, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let properties = request.unwrap_properties(&[ + VacationResponseProperty::Id, + VacationResponseProperty::IsEnabled, + VacationResponseProperty::FromDate, + VacationResponseProperty::ToDate, + VacationResponseProperty::Subject, + VacationResponseProperty::TextBody, + VacationResponseProperty::HtmlBody, + ]); + let mut response = GetResponse { + account_id: request.account_id.into(), + state: self + .get_state(account_id, SyncCollection::SieveScript) + .await? + .into(), + list: Vec::with_capacity(1), + not_found: vec![], + }; + + let do_get = if let Some(MaybeResultReference::Value(ids)) = request.ids { + let mut do_get = false; + for id in ids { + match id { + MaybeIdReference::Id(id) if id.is_singleton() => { + do_get = true; + } + MaybeIdReference::Id(id) => { + response.push_not_found(id); + } + MaybeIdReference::Invalid(s) | MaybeIdReference::Reference(s) => { + response.not_found.push(MaybeInvalid::Invalid(s)); + } + } + } + do_get + } else { + true + }; + if do_get { + let mut result = Map::with_capacity(properties.len()); + if let Some(document_id) = self.get_vacation_sieve_script_id(account_id).await? + && let Some(sieve_) = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + { + let active_script_id = self.sieve_script_get_active_id(account_id).await?; + let sieve = sieve_ + .unarchive::() + .caused_by(trc::location!())?; + let vacation = sieve.vacation_response.as_ref(); + for property in &properties { + match property { + VacationResponseProperty::Id => { + result.insert_unchecked(VacationResponseProperty::Id, Id::singleton()); + } + VacationResponseProperty::IsEnabled => { + result.insert_unchecked( + VacationResponseProperty::IsEnabled, + active_script_id == Some(document_id), + ); + } + VacationResponseProperty::FromDate => { + result.insert_unchecked( + VacationResponseProperty::FromDate, + vacation.and_then(|r| { + r.from_date + .as_ref() + .map(u64::from) + .map(UTCDate::from) + .map(|v| Value::Element(VacationResponseValue::Date(v))) + }), + ); + } + VacationResponseProperty::ToDate => { + result.insert_unchecked( + VacationResponseProperty::ToDate, + vacation.and_then(|r| { + r.to_date + .as_ref() + .map(u64::from) + .map(UTCDate::from) + .map(|v| Value::Element(VacationResponseValue::Date(v))) + }), + ); + } + VacationResponseProperty::Subject => { + result.insert_unchecked( + VacationResponseProperty::Subject, + vacation.and_then(|r| r.subject.as_ref()), + ); + } + VacationResponseProperty::TextBody => { + result.insert_unchecked( + VacationResponseProperty::TextBody, + vacation.and_then(|r| r.text_body.as_ref()), + ); + } + VacationResponseProperty::HtmlBody => { + result.insert_unchecked( + VacationResponseProperty::HtmlBody, + vacation.and_then(|r| r.html_body.as_ref()), + ); + } + } + } + } else { + for property in &properties { + match property { + VacationResponseProperty::Id => { + result.insert_unchecked(VacationResponseProperty::Id, Id::singleton()); + } + VacationResponseProperty::IsEnabled => { + result.insert_unchecked(VacationResponseProperty::IsEnabled, false); + } + _ => { + result.insert_unchecked(property.clone(), Value::Null); + } + } + } + } + response.list.push(result.into()); + } + + Ok(response) + } + + async fn get_vacation_sieve_script_id(&self, account_id: u32) -> trc::Result> { + self.document_ids_matching( + account_id, + Collection::SieveScript, + SieveField::Name, + "vacation".as_bytes(), + ) + .await + .map(|r| r.min()) + } +} diff --git a/crates/jmap/src/vacation/mod.rs b/crates/jmap/src/vacation/mod.rs new file mode 100644 index 0000000..f460aba --- /dev/null +++ b/crates/jmap/src/vacation/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod get; +pub mod set; diff --git a/crates/jmap/src/vacation/set.rs b/crates/jmap/src/vacation/set.rs new file mode 100644 index 0000000..bcdb326 --- /dev/null +++ b/crates/jmap/src/vacation/set.rs @@ -0,0 +1,456 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::get::VacationResponseGet; +use crate::changes::state::StateManager; +use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; +use email::sieve::{SieveScript, VacationResponse, ingest::SieveScriptIngest}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::set::{SetRequest, SetResponse}, + object::vacation_response::{self, VacationResponseProperty, VacationResponseValue}, + references::resolve::ResolveCreatedReference, + request::IntoValid, + types::{date::UTCDate, state::State}, +}; +use jmap_tools::{Key, Map, Value}; +use mail_builder::MessageBuilder; +use mail_parser::decoders::html::html_to_text; +use std::borrow::Cow; +use std::future::Future; +use store::{ + Serialize, SerializeInfallible, ValueKey, + write::{AlignedBytes, Archive, Archiver, BatchBuilder}, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::PrincipalField, + id::Id, +}; + +pub trait VacationResponseSet: Sync + Send { + fn vacation_response_set( + &self, + request: SetRequest<'_, vacation_response::VacationResponse>, + access_token: &AccessToken, + ) -> impl Future>> + Send; + + fn build_script(&self, obj: &mut SieveScript) -> trc::Result>; +} + +impl VacationResponseSet for Server { + async fn vacation_response_set( + &self, + mut request: SetRequest<'_, vacation_response::VacationResponse>, + access_token: &AccessToken, + ) -> trc::Result> { + let account_id = request.account_id.document_id(); + let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? + .with_state( + self.assert_state( + account_id, + SyncCollection::SieveScript, + &request.if_in_state, + ) + .await?, + ); + + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + + // Process set or update requests + let create_id = None; + let mut changes = None; + match (request.create, request.update) { + (Some(create), Some(update)) if !create.is_empty() && !update.is_empty() => { + return Err(trc::JmapEvent::InvalidArguments + .into_err() + .details("Creating and updating on the same request is not allowed.")); + } + (Some(create), _) if !create.is_empty() => { + for (id, _obj) in create { + response.not_created.append(id, SetError::singleton()); + } + } + (_, Some(update)) if !update.is_empty() => { + for (id, obj) in update.into_valid() { + if id.is_singleton() { + changes = Some(obj); + } else { + response.not_updated.append( + id, + SetError::new(SetErrorType::NotFound).with_description("ID not found."), + ); + } + } + } + _ => { + return Ok(response); + } + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript); + + // Process changes + let active_script_id = self.sieve_script_get_active_id(account_id).await?; + if let Some(changes) = changes { + // Obtain current script + let document_id = self.get_vacation_sieve_script_id(account_id).await?; + + let (mut sieve, prev_sieve) = if let Some(document_id) = document_id { + let prev_sieve = self + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await? + .ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .caused_by(trc::location!()) + })? + .into_deserialized::() + .caused_by(trc::location!())?; + let mut sieve = prev_sieve.inner.clone(); + if sieve.vacation_response.is_none() { + sieve.vacation_response = VacationResponse::default().into(); + } + + (sieve, Some(prev_sieve)) + } else { + ( + SieveScript { + name: "vacation".into(), + blob_hash: Default::default(), + size: 0, + vacation_response: VacationResponse::default().into(), + }, + None, + ) + }; + + // Parse properties + let mut is_active = document_id.is_some_and(|id| active_script_id == Some(id)); + let mut build_script = create_id.is_some(); + let vacation = sieve.vacation_response.as_mut().unwrap(); + + for (property, mut value) in changes.into_expanded_object() { + if let Err(err) = response.resolve_self_references(&mut value, 0, false) { + return Ok(set_error(response, create_id, err)); + }; + + match (&property, value) { + (Key::Property(VacationResponseProperty::Subject), Value::Str(value)) + if value.len() < 512 => + { + build_script = true; + vacation.subject = Some(value.into_owned()); + } + (Key::Property(VacationResponseProperty::HtmlBody), Value::Str(value)) + if value.len() < 2048 => + { + build_script = true; + vacation.html_body = Some(value.into_owned()); + } + (Key::Property(VacationResponseProperty::TextBody), Value::Str(value)) + if value.len() < 2048 => + { + build_script = true; + vacation.text_body = Some(value.into_owned()); + } + ( + Key::Property(VacationResponseProperty::FromDate), + Value::Element(VacationResponseValue::Date(date)), + ) => { + vacation.from_date = Some(date.timestamp() as u64); + build_script = true; + } + ( + Key::Property(VacationResponseProperty::ToDate), + Value::Element(VacationResponseValue::Date(date)), + ) => { + vacation.to_date = Some(date.timestamp() as u64); + build_script = true; + } + (Key::Property(VacationResponseProperty::IsEnabled), Value::Bool(value)) => { + is_active = value; + } + (Key::Property(VacationResponseProperty::IsEnabled), Value::Null) => { + is_active = false; + } + ( + Key::Property( + VacationResponseProperty::Subject + | VacationResponseProperty::HtmlBody + | VacationResponseProperty::TextBody + | VacationResponseProperty::ToDate + | VacationResponseProperty::FromDate, + ), + Value::Null, + ) => { + if create_id.is_none() { + build_script = true; + match property { + Key::Property(VacationResponseProperty::Subject) => { + vacation.subject = None; + } + Key::Property(VacationResponseProperty::HtmlBody) => { + vacation.html_body = None; + } + Key::Property(VacationResponseProperty::TextBody) => { + vacation.text_body = None; + } + Key::Property(VacationResponseProperty::FromDate) => { + vacation.from_date = None; + } + Key::Property(VacationResponseProperty::ToDate) => { + vacation.to_date = None; + } + _ => unreachable!(), + } + } + } + (Key::Property(VacationResponseProperty::Id), value) => { + if create_id.is_some() || !crate::matches_id(&value, Id::singleton()) { + return Ok(set_error( + response, + create_id, + SetError::invalid_properties() + .with_property(VacationResponseProperty::Id) + .with_description("The id property is immutable."), + )); + } + } + _ => { + return Ok(set_error( + response, + create_id, + SetError::invalid_properties() + .with_property(property.into_owned()) + .with_description("Field could not be set."), + )); + } + } + } + + let mut obj = ObjectIndexBuilder::new() + .with_current_opt(prev_sieve) + .with_changes(sieve) + .with_changed_by(access_token.account_tenant_ids()); + + // Update id + let document_id = if let Some(document_id) = document_id { + batch.with_document(document_id); + document_id + } else { + let document_id = self + .store() + .assign_document_ids(account_id, Collection::SieveScript, 1) + .await + .caused_by(trc::location!())?; + batch.with_document(document_id); + document_id + }; + + // Create sieve script only if there are changes + if build_script { + // Upload new blob + let (blob_hash, blob_hold) = self + .put_temporary_blob( + account_id, + &self.build_script(obj.changes_mut().unwrap())?, + 60, + ) + .await?; + obj.changes_mut().unwrap().blob_hash = blob_hash; + batch.clear(blob_hold); + }; + batch.custom(obj).caused_by(trc::location!())?; + + // Deactivate other sieve scripts + let was_active = active_script_id == Some(document_id); + if is_active { + if !was_active { + batch + .with_collection(Collection::Principal) + .with_document(0) + .set(PrincipalField::ActiveScriptId, document_id.serialize()); + } + } else if was_active { + batch + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::ActiveScriptId); + } + + // Write changes + if !batch.is_empty() { + response.new_state = Some(State::Exact( + self.commit_batch(batch) + .await + .and_then(|ids| ids.last_change_id(account_id)) + .caused_by(trc::location!())?, + )); + } + + // Add result + if let Some(create_id) = create_id { + response.created.insert( + create_id, + Map::with_capacity(1) + .with_key_value(VacationResponseProperty::Id, Id::singleton()) + .into(), + ); + } else { + response.updated.append(Id::singleton(), None); + } + } + + Ok(response) + } + + fn build_script(&self, obj: &mut SieveScript) -> trc::Result> { + // Build Sieve script + let mut script = Vec::with_capacity(1024); + script.extend_from_slice(b"require [\"vacation\", \"relational\", \"date\"];\r\n\r\n"); + let mut num_blocks = 0; + + // Add start date + if let Some(value) = obj.vacation_response.as_ref().and_then(|v| v.from_date) { + script.extend_from_slice(b"if currentdate :value \"ge\" \"iso8601\" \""); + script.extend_from_slice(UTCDate::from(value).to_string().as_bytes()); + script.extend_from_slice(b"\" {\r\n"); + num_blocks += 1; + } + + // Add end date + if let Some(value) = obj.vacation_response.as_ref().and_then(|v| v.to_date) { + script.extend_from_slice(b"if currentdate :value \"le\" \"iso8601\" \""); + script.extend_from_slice(UTCDate::from(value).to_string().as_bytes()); + script.extend_from_slice(b"\" {\r\n"); + num_blocks += 1; + } + + script.extend_from_slice(b"vacation :mime "); + if let Some(value) = obj + .vacation_response + .as_ref() + .and_then(|v| v.subject.as_ref()) + { + script.extend_from_slice(b":subject \""); + for &ch in value.as_bytes().iter() { + match ch { + b'\\' | b'\"' => { + script.push(b'\\'); + } + b'\r' | b'\n' => { + continue; + } + _ => (), + } + script.push(ch); + } + script.extend_from_slice(b"\" "); + } + + let mut text_body = if let Some(value) = obj + .vacation_response + .as_ref() + .and_then(|v| v.text_body.as_ref()) + { + Cow::from(value.as_str()).into() + } else { + None + }; + let html_body = if let Some(value) = obj + .vacation_response + .as_ref() + .and_then(|v| v.html_body.as_ref()) + { + Cow::from(value.as_str()).into() + } else { + None + }; + match (&html_body, &text_body) { + (Some(html_body), None) => { + text_body = Cow::from(html_to_text(html_body.as_ref())).into(); + } + (None, None) => { + text_body = Cow::from("I am away.").into(); + } + _ => (), + } + + let mut builder = MessageBuilder::new(); + let mut body_len = 0; + if let Some(html_body) = html_body { + body_len = html_body.len(); + builder = builder.html_body(html_body); + } + if let Some(text_body) = text_body { + body_len += text_body.len(); + builder = builder.text_body(text_body); + } + let mut message_body = Vec::with_capacity(body_len + 128); + builder.write_body(&mut message_body).ok(); + + script.push(b'\"'); + for ch in message_body { + if b"\\\"".contains(&ch) { + script.push(b'\\'); + } + script.push(ch); + } + script.extend_from_slice(b"\";\r\n"); + + // Close blocks + for _ in 0..num_blocks { + script.extend_from_slice(b"}\r\n"); + } + + match self.core.sieve.untrusted_compiler.compile(&script) { + Ok(compiled_script) => { + // Update blob length + obj.size = script.len() as u32; + + // Serialize script + script.extend( + Archiver::new(compiled_script) + .untrusted() + .serialize() + .caused_by(trc::location!())?, + ); + + Ok(script) + } + Err(err) => Err(trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + .details("Vacation Sieve Script failed to compile.")), + } + } +} + +fn set_error( + mut response: SetResponse, + id: Option, + err: SetError, +) -> SetResponse { + if let Some(id) = id { + response.not_created.append(id, err); + } else { + response.not_updated.append(Id::singleton(), err); + } + response +} diff --git a/crates/jmap/src/websocket/mod.rs b/crates/jmap/src/websocket/mod.rs new file mode 100644 index 0000000..cac751d --- /dev/null +++ b/crates/jmap/src/websocket/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod stream; +pub mod upgrade; diff --git a/crates/jmap/src/websocket/stream.rs b/crates/jmap/src/websocket/stream.rs new file mode 100644 index 0000000..234de7d --- /dev/null +++ b/crates/jmap/src/websocket/stream.rs @@ -0,0 +1,269 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::api::{ToRequestError, notifications_into_push_objects, request::RequestHandler}; +use common::{Server, auth::AccessToken, ipc::PushNotification}; +use futures_util::{SinkExt, StreamExt}; +use http_proto::HttpSessionData; +use hyper::upgrade::Upgraded; +use hyper_util::rt::TokioIo; +use jmap_proto::{ + error::request::RequestError, + request::websocket::{ + WebSocketMessage, WebSocketPushObject, WebSocketRequestError, WebSocketResponse, + }, +}; +use std::future::Future; +use std::time::Instant; +use tokio_tungstenite::WebSocketStream; +use trc::JmapEvent; +use tungstenite::Message; +use types::type_state::{DataType, StateChange}; +use utils::map::bitmap::Bitmap; + +pub trait WebSocketHandler: Sync + Send { + fn handle_websocket_stream( + &self, + stream: WebSocketStream>, + access_token: AccessToken, + session: HttpSessionData, + ) -> impl Future + Send; +} + +impl WebSocketHandler for Server { + async fn handle_websocket_stream( + &self, + mut stream: WebSocketStream>, + access_token: AccessToken, + session: HttpSessionData, + ) { + trc::event!( + Jmap(JmapEvent::WebsocketStart), + SpanId = session.session_id, + AccountId = access_token.account_id(), + ); + + // Set timeouts + let throttle = self.core.jmap.web_socket_throttle; + let timeout = self.core.jmap.web_socket_timeout; + let heartbeat = self.core.jmap.web_socket_heartbeat; + let mut last_request = Instant::now(); + let mut last_changes_sent = Instant::now() - throttle; + let mut last_heartbeat = Instant::now() - heartbeat; + let mut next_event = heartbeat; + + // Register with push manager + let mut push_rx = match self + .subscribe_push_manager(&access_token, Bitmap::all()) + .await + { + Ok(push_rx) => push_rx, + Err(err) => { + trc::error!( + err.details("Failed to subscribe to push manager") + .span_id(session.session_id) + ); + + let _ = stream + .send(Message::Text( + WebSocketRequestError::from(RequestError::internal_server_error()) + .to_json() + .into(), + )) + .await; + return; + } + }; + + let mut notifications = Vec::new(); + let mut change_types: Bitmap = Bitmap::new(); + + loop { + tokio::select! { + event = tokio::time::timeout(next_event, stream.next()) => { + match event { + Ok(Some(Ok(event))) => { + match event { + Message::Text(text) => { + let response = match WebSocketMessage::parse( + text.as_bytes(), + self.core.jmap.request_max_calls, + self.core.jmap.request_max_size, + ) { + Ok(WebSocketMessage::Request(request)) => { + let response = self + .handle_jmap_request( + request.request, + &access_token, + &session, + ) + .await; + WebSocketResponse::from_response(response, request.id) + .to_json() + } + Ok(WebSocketMessage::PushEnable(push_enable)) => { + change_types = if !push_enable.data_types.is_empty() { + push_enable.data_types.into() + } else { + Bitmap::all() + }; + continue; + } + Ok(WebSocketMessage::PushDisable) => { + change_types = Bitmap::new(); + continue; + } + Err(err) => { + let response = WebSocketRequestError::from(err.to_request_error()).to_json(); + trc::error!(err.details("Failed to parse WebSocket message").span_id(session.session_id)); + response + }, + }; + if let Err(err) = stream.send(Message::Text(response.into())).await { + trc::event!(Jmap(JmapEvent::WebsocketError), + Details = "Failed to send text message", + SpanId = session.session_id, + Reason = err.to_string() + ); + } + } + Message::Ping(bytes) => { + if let Err(err) = stream.send(Message::Pong(bytes)).await { + trc::event!(Jmap(JmapEvent::WebsocketError), + Details = "Failed to send pong message", + SpanId = session.session_id, + Reason = err.to_string() + ); + } + } + Message::Close(frame) => { + let _ = stream.close(frame).await; + break; + } + _ => (), + } + + last_request = Instant::now(); + last_heartbeat = Instant::now(); + } + Ok(Some(Err(err))) => { + trc::event!(Jmap(JmapEvent::WebsocketError), + Details = "Websocket error", + SpanId = session.session_id, + Reason = err.to_string() + ); + break; + } + Ok(None) => break, + Err(_) => { + // Verify timeout + if last_request.elapsed() > timeout { + trc::event!( + Jmap(JmapEvent::WebsocketStop), + SpanId = session.session_id, + Reason = "Idle client" + ); + + break; + } + } + } + } + push_notification = push_rx.recv() => { + if let Some(push_notification) = push_notification { + match push_notification { + PushNotification::StateChange(state_change) => { + let mut types = state_change.types; + types.intersection(&change_types); + + if !types.is_empty() { + notifications.push(PushNotification::StateChange( + StateChange { + account_id: state_change.account_id, + types, + change_id: state_change.change_id, + } + )); + } + }, + PushNotification::EmailPush(email_push) => { + let state_change = email_push.to_state_change(); + let mut types = state_change.types; + types.intersection(&change_types); + + if !types.is_empty() { + notifications.push(PushNotification::StateChange( + StateChange { + account_id: state_change.account_id, + types, + change_id: state_change.change_id, + } + )); + } + }, + PushNotification::CalendarAlert(calendar_alert) => { + if change_types.contains(DataType::CalendarAlert) { + notifications.push(PushNotification::CalendarAlert( + calendar_alert + )); + } + }, + } + + } else { + trc::event!( + Jmap(JmapEvent::WebsocketStop), + SpanId = session.session_id, + Reason = "State manager channel closed" + ); + + break; + } + } + } + + if !notifications.is_empty() { + // Send any queued changes + let elapsed = last_changes_sent.elapsed(); + if elapsed >= throttle { + for push in notifications_into_push_objects(std::mem::take(&mut notifications)) + { + let payload = WebSocketPushObject { + push, + push_state: None, + }; + if let Err(err) = stream.send(Message::Text(payload.to_json().into())).await + { + trc::event!( + Jmap(JmapEvent::WebsocketError), + Details = "Failed to send state change message.", + SpanId = session.session_id, + Reason = err.to_string() + ); + } + } + last_changes_sent = Instant::now(); + last_heartbeat = Instant::now(); + next_event = heartbeat; + } else { + next_event = throttle - elapsed; + } + } else if last_heartbeat.elapsed() > heartbeat { + if let Err(err) = stream.send(Message::Ping(Vec::::new().into())).await { + trc::event!( + Jmap(JmapEvent::WebsocketError), + Details = "Failed to send ping message.", + SpanId = session.session_id, + Reason = err.to_string() + ); + break; + } + last_heartbeat = Instant::now(); + next_event = heartbeat; + } + } + } +} diff --git a/crates/jmap/src/websocket/upgrade.rs b/crates/jmap/src/websocket/upgrade.rs new file mode 100644 index 0000000..945b58e --- /dev/null +++ b/crates/jmap/src/websocket/upgrade.rs @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::stream::WebSocketHandler; +use common::{Server, auth::AccessToken}; +use http_proto::*; +use hyper::StatusCode; +use hyper_util::rt::TokioIo; +use std::future::Future; +use tokio_tungstenite::WebSocketStream; +use trc::JmapEvent; +use tungstenite::{handshake::derive_accept_key, protocol::Role}; + +pub trait WebSocketUpgrade: Sync + Send { + fn upgrade_websocket_connection( + &self, + req: HttpRequest, + access_token: AccessToken, + session: HttpSessionData, + ) -> impl Future> + Send; +} + +impl WebSocketUpgrade for Server { + async fn upgrade_websocket_connection( + &self, + req: HttpRequest, + access_token: AccessToken, + session: HttpSessionData, + ) -> trc::Result { + let headers = req.headers(); + let header_has_token = |name: hyper::header::HeaderName, token: &str| { + headers + .get(name) + .and_then(|h| h.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) + }) + }; + if !header_has_token(hyper::header::CONNECTION, "Upgrade") + || !header_has_token(hyper::header::UPGRADE, "websocket") + { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("WebSocket upgrade failed") + .ctx( + trc::Key::Reason, + "Missing or Invalid Connection or Upgrade headers.", + )); + } + let derived_key = match ( + headers + .get("Sec-WebSocket-Key") + .and_then(|h| h.to_str().ok()), + headers + .get("Sec-WebSocket-Version") + .and_then(|h| h.to_str().ok()), + ) { + (Some(key), Some("13")) => derive_accept_key(key.as_bytes()), + _ => { + return Err(trc::ResourceEvent::BadParameters + .into_err() + .details("WebSocket upgrade failed") + .ctx( + trc::Key::Reason, + "Missing or Invalid Sec-WebSocket-Key headers.", + )); + } + }; + + // Spawn WebSocket connection + let jmap = self.clone(); + tokio::spawn(async move { + // Upgrade connection + let session_id = session.session_id; + match hyper::upgrade::on(req).await { + Ok(upgraded) => { + Box::pin( + jmap.handle_websocket_stream( + WebSocketStream::from_raw_socket( + TokioIo::new(upgraded), + Role::Server, + None, + ) + .await, + access_token, + session, + ), + ) + .await; + } + Err(err) => { + trc::event!( + Jmap(JmapEvent::WebsocketError), + Details = "Websocket upgrade failed", + SpanId = session_id, + Reason = err.to_string() + ); + } + } + }); + + Ok(HttpResponse::new(StatusCode::SWITCHING_PROTOCOLS).with_websocket_upgrade(derived_key)) + } +} diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml new file mode 100644 index 0000000..66e1175 --- /dev/null +++ b/crates/main/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "stalwart" +description = "Stalwart Mail and Collaboration Server" +authors = [ "Stalwart Labs LLC "] +repository = "https://github.com/stalwartlabs/stalwart" +homepage = "https://stalw.art" +keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"] +categories = ["email"] +license = "AGPL-3.0-only OR LicenseRef-SEL" +version = "0.16.22" +edition = "2024" + +[[bin]] +name = "stalwart" +path = "src/main.rs" + +[dependencies] +store = { path = "../store" } +coordinator = { path = "../coordinator" } +jmap = { path = "../jmap" } +types = { path = "../types" } +smtp = { path = "../smtp" } +smtp-proto = { version = "0.2", features = ["rkyv", "serde"] } +imap = { path = "../imap" } +pop3 = { path = "../pop3" } +spam-filter = { path = "../spam-filter" } +managesieve = { path = "../managesieve" } +common = { path = "../common" } +email = { path = "../email" } +directory = { path = "../directory" } +http = { path = "../http" } +scim = { path = "../scim" } +dav = { path = "../dav" } +groupware = { path = "../groupware" } +services = { path = "../services" } +trc = { path = "../trc" } +utils = { path = "../utils" } +registry = { path = "../registry" } +http_proto = { path = "../http-proto" } +migration = { path = "../migration" } +tokio = { version = "1.53", features = ["full"] } +rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } + +[target.'cfg(not(any(target_env = "msvc", target_os = "freebsd")))'.dependencies] +tikv-jemallocator = "0.7" + +[features] +#default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "azure", "nats", "enterprise", "zenoh", "kafka"] +#default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "azure", "nats", "enterprise"] +default = ["rocks"] +sqlite = ["store/sqlite", "directory/sqlite"] +foundationdb = ["store/foundation", "common/foundation"] +postgres = ["store/postgres", "directory/postgres"] +mysql = ["store/mysql", "directory/mysql"] +rocks = ["store/rocks"] +s3 = ["store/s3"] +redis = ["store/redis", "coordinator/redis"] +azure = ["store/azure"] +nats = ["coordinator/nats"] +zenoh = ["coordinator/zenoh"] +kafka = ["coordinator/kafka"] +enterprise = [ "jmap/enterprise", + "smtp/enterprise", + "common/enterprise", + "store/enterprise", + "managesieve/enterprise", + "directory/enterprise", + "email/enterprise", + "spam-filter/enterprise", + "http/enterprise", + "scim/enterprise", + "dav/enterprise", + "groupware/enterprise", + "trc/enterprise", + "services/enterprise", + "migration/enterprise" ] +dev_mode = [ "common/dev_mode", + "trc/dev_mode", + "dav/dev_mode", + "http/dev_mode", + "http_proto/dev_mode", + "jmap/dev_mode" ] + +[lints] +workspace = true diff --git a/crates/main/src/main.rs b/crates/main/src/main.rs new file mode 100644 index 0000000..2b62819 --- /dev/null +++ b/crates/main/src/main.rs @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] +#![warn(clippy::cast_possible_truncation)] +#![warn(clippy::cast_possible_wrap)] +#![warn(clippy::cast_sign_loss)] + +use common::{BuildServer, config::server::ServerProtocol, manager::boot::BootManager}; +use http::HttpSessionManager; +use imap::core::ImapSessionManager; +use managesieve::core::ManageSieveSessionManager; +use pop3::Pop3SessionManager; +use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber}; +use smtp::{StartQueueManager, core::SmtpSessionManager}; +use std::time::Duration; +use trc::Collector; +use utils::wait_for_shutdown; + +#[cfg(feature = "dev_mode")] +pub mod test_data; + +#[cfg(not(any(target_env = "msvc", target_os = "freebsd")))] +use tikv_jemallocator::Jemalloc; + +#[cfg(not(any(target_env = "msvc", target_os = "freebsd")))] +#[global_allocator] +static GLOBAL: Jemalloc = Jemalloc; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + // Install AWS-LC-RS as the default Rustls crypto provider + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install aws-lc-rs as the default rustls crypto provider"); + + // Build the shared outbound TLS configurations + utils::http::init_shared_tls_configs(); + + // Load config and apply macros + let mut init = Box::pin(BootManager::init()).await; + + // Migrate database + if let Err(err) = migration::try_migrate(&init.inner.build_server()).await { + trc::event!( + Server(trc::ServerEvent::StartupError), + Details = "Failed to migrate database, aborting startup.", + Reason = err, + ); + return Ok(()); + } + + // Init services + init.start_services().await; + init.start_queue_manager(); + + // Log configuration errors + init.bootstrap.log_errors(); + init.bootstrap.log_warnings(); + + + #[cfg(feature = "dev_mode")] + if std::env::var("INSERT_TEST_DATA").is_ok() { + let server = init.inner.build_server(); + test_data::insert_test_data(&server).await; + server.insert_test_metrics().await; + } + + // Spawn servers + let (shutdown_tx, shutdown_rx) = init.servers.spawn(|server, acceptor, shutdown_rx| { + match &server.protocol { + ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn( + SmtpSessionManager::new(init.inner.clone()), + init.inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Http => server.spawn( + HttpSessionManager::new(init.inner.clone()), + init.inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Imap => server.spawn( + ImapSessionManager::new(init.inner.clone()), + init.inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Pop3 => server.spawn( + Pop3SessionManager::new(init.inner.clone()), + init.inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::ManageSieve => server.spawn( + ManageSieveSessionManager::new(init.inner.clone()), + init.inner.clone(), + acceptor, + shutdown_rx, + ), + }; + }); + + // Start broadcast subscriber + spawn_broadcast_subscriber(init.inner, shutdown_rx); + + // Wait for shutdown signal + wait_for_shutdown().await; + + // Shutdown collector + Collector::shutdown(); + + // Stop services + let _ = shutdown_tx.send(true); + + // Wait for services to finish + tokio::time::sleep(Duration::from_secs(1)).await; + + Ok(()) +} diff --git a/crates/main/src/test_data.rs b/crates/main/src/test_data.rs new file mode 100644 index 0000000..cc7d91c --- /dev/null +++ b/crates/main/src/test_data.rs @@ -0,0 +1,1425 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + config::smtp::queue::{QueueExpiry, QueueName}, +}; +use registry::{ + schema::{ + enums::{ + ArfAuthFailureType, ArfDeliveryResult, ArfFeedbackType, ArfIdentityAlignment, + DkimAuthResult, DmarcAlignment, DmarcDiscovery, DmarcDisposition, DmarcResult, + SpfAuthResult, SpfDomainScope, TlsPolicyType, TlsResultType, + }, + prelude::ObjectType, + structs::{ + ArfExternalReport, ArfFeedbackReport, DmarcDkimResult, DmarcExternalReport, + DmarcInternalReport, DmarcReport, DmarcReportRecord, DmarcSpfResult, TlsExternalReport, + TlsFailureDetails, TlsInternalReport, TlsReport, TlsReportPolicy, + }, + }, + types::{ + EnumImpl, ObjectImpl, datetime::UTCDateTime, float::Float, ipaddr::IpAddr, list::List, + map::Map, + }, +}; +use smtp::{ + queue::{ + Error, ErrorDetails, FROM_AUTHENTICATED, FROM_DSN, FROM_REPORT, HostResponse, Message, + MessageWrapper, RCPT_DSN_SENT, Recipient, Schedule, Status, UnexpectedResponse, + rcpt_spam_flag, + }, + reporting::index::{ExternalReportIndex, InternalReportIndex}, +}; +use smtp_proto::Response; +use std::net::Ipv4Addr; +use store::write::{BatchBuilder, RegistryClass, ValueClass}; +use types::blob_hash::BlobHash; + +pub async fn insert_test_data(server: &Server) { + let mut hashes = Vec::new(); + let future = one_year_from_now_u64(); + for message in sample_raw_messages() { + let (hash, _) = server + .put_temporary_blob(u32::MAX, message.as_bytes(), future) + .await + .unwrap(); + hashes.push(hash); + } + + for message in sample_queued_messages(hashes) { + let qm = MessageWrapper::new( + message, + server.inner.data.queue_id_gen.generate(), + QueueName::default(), + ); + assert!(qm.save_changes(server, None).await); + } + + for report in sample_tls_internal_reports() { + let object_id = ObjectType::TlsInternalReport.to_id(); + let item_id = server.inner.data.queue_id_gen.generate(); + let mut batch = BatchBuilder::new(); + batch.clear(report.primary_key()); + report.write_ops(&mut batch, item_id, true); + let report_bytes = report.to_pickled_vec(); + batch.set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + report_bytes, + ); + server.store().write(batch.build_all()).await.unwrap(); + } + + for report in sample_dmarc_internal_reports() { + let object_id = ObjectType::DmarcInternalReport.to_id(); + let item_id = server.inner.data.queue_id_gen.generate(); + let mut batch = BatchBuilder::new(); + batch.clear(report.primary_key()); + report.write_ops(&mut batch, item_id, true); + let report_bytes = report.to_pickled_vec(); + batch.set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + report_bytes, + ); + server.store().write(batch.build_all()).await.unwrap(); + } + + for report in sample_tls_external_reports() { + let mut batch = BatchBuilder::new(); + let item_id = server.inner.data.queue_id_gen.generate(); + report.write_ops(&mut batch, item_id, true); + server.store().write(batch.build_all()).await.unwrap(); + } + + for report in sample_dmarc_external_reports() { + let mut batch = BatchBuilder::new(); + let item_id = server.inner.data.queue_id_gen.generate(); + report.write_ops(&mut batch, item_id, true); + server.store().write(batch.build_all()).await.unwrap(); + } + + for report in sample_arf_external_reports() { + let mut batch = BatchBuilder::new(); + let item_id = server.inner.data.queue_id_gen.generate(); + report.write_ops(&mut batch, item_id, true); + server.store().write(batch.build_all()).await.unwrap(); + } +} + +fn sample_queued_messages(blob_hashes: Vec) -> Vec { + assert!( + blob_hashes.len() >= 3, + "Need at least 3 blob hashes for sample queued messages" + ); + let future = one_year_from_now_u64(); + let now = store::write::now(); + let raw_messages = sample_raw_messages(); + + vec![ + // Message 1: Normal outbound message with two recipients, one scheduled and one completed + Message { + created: now, + blob_hash: blob_hashes[0].clone(), + return_path: "sender@myserver.com".into(), + recipients: vec![ + Recipient { + address: "alice@example.com".into(), + retry: Schedule { + due: future, + inner: 0, + }, + notify: Schedule { + due: future, + inner: 0, + }, + expires: QueueExpiry::Ttl(365 * 24 * 3600), + queue: Default::default(), + status: Status::Scheduled, + flags: 0, + orcpt: None, + }, + Recipient { + address: "bob@example.org".into(), + retry: Schedule { + due: future, + inner: 2, + }, + notify: Schedule { + due: future, + inner: 1, + }, + expires: QueueExpiry::Ttl(365 * 24 * 3600), + queue: Default::default(), + status: Status::Completed(HostResponse { + hostname: "mx.example.org".into(), + response: Response { + code: 250, + esc: [2, 1, 5], + message: "OK".into(), + }, + }), + flags: RCPT_DSN_SENT, + orcpt: Some("rfc822;bob@example.org".into()), + }, + ], + received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), + received_via_port: 25, + flags: FROM_AUTHENTICATED, + env_id: Some("env-001".into()), + priority: 0, + size: raw_messages[0].len() as u64, + metadata: Box::new([]), + }, + // Message 2: DSN bounce message with a temporary failure recipient + Message { + created: now, + blob_hash: blob_hashes[1].clone(), + return_path: "".into(), + recipients: vec![Recipient { + address: "postmaster@remote.net".into(), + retry: Schedule { + due: future, + inner: 3, + }, + notify: Schedule { + due: future, + inner: 0, + }, + expires: QueueExpiry::Ttl(365 * 24 * 3600), + queue: Default::default(), + status: Status::TemporaryFailure(ErrorDetails { + entity: "mx.remote.net".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "RCPT TO".into(), + response: Response { + code: 450, + esc: [4, 2, 1], + message: "Mailbox temporarily unavailable".into(), + }, + }), + }), + flags: 0, + orcpt: None, + }], + received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + received_via_port: 587, + flags: FROM_DSN, + env_id: None, + priority: -5, + size: raw_messages[1].len() as u64, + metadata: Box::new([]), + }, + // Message 3: Report message with a temporary failure recipient + Message { + created: now, + blob_hash: blob_hashes[2].clone(), + return_path: "reports@myserver.com".into(), + recipients: vec![Recipient { + address: "abuse@bigcorp.com".into(), + retry: Schedule { + due: future, + inner: 0, + }, + notify: Schedule { + due: future, + inner: 2, + }, + expires: QueueExpiry::Ttl(365 * 24 * 3600), + queue: Default::default(), + status: Status::TemporaryFailure(ErrorDetails { + entity: "mx.bigcorp.com".into(), + details: Error::ConnectionError("Rejected by policy".into()), + }), + flags: rcpt_spam_flag(72), + orcpt: None, + }], + received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5)), + received_via_port: 465, + flags: FROM_REPORT | FROM_AUTHENTICATED, + env_id: Some("env-report-99".into()), + priority: 10, + size: raw_messages[2].len() as u64, + metadata: Box::new([]), + }, + ] +} + +fn sample_raw_messages() -> Vec { + vec![ + // Raw message 1: Normal outbound email (matches Message 1) + concat!( + "From: sender@myserver.com\r\n", + "To: alice@example.com, bob@example.org\r\n", + "Subject: Quarterly Report Q1 2027\r\n", + "Date: Sat, 12 Apr 2027 09:00:00 +0000\r\n", + "Message-ID: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", + "\r\n", + "Hi team,\r\n", + "\r\n", + "Please find attached the quarterly report for Q1 2027.\r\n", + "Let me know if you have any questions.\r\n", + "\r\n", + "Best regards,\r\n", + "The Sender\r\n", + ) + .to_string(), + // Raw message 2: DSN bounce (matches Message 2) + concat!( + "From: <>\r\n", + "To: postmaster@remote.net\r\n", + "Subject: Delivery Status Notification (Failure)\r\n", + "Date: Sat, 12 Apr 2027 09:05:00 +0000\r\n", + "Message-ID: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: multipart/report; report-type=delivery-status;\r\n", + " boundary=\"boundary-dsn-002\"\r\n", + "\r\n", + "--boundary-dsn-002\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", + "\r\n", + "This is an automatically generated Delivery Status Notification.\r\n", + "Delivery to the following recipient failed temporarily:\r\n", + "\r\n", + " postmaster@remote.net\r\n", + "\r\n", + "The server will retry delivery.\r\n", + "\r\n", + "--boundary-dsn-002\r\n", + "Content-Type: message/delivery-status\r\n", + "\r\n", + "Reporting-MTA: dns; myserver.com\r\n", + "Arrival-Date: Sat, 12 Apr 2027 09:00:00 +0000\r\n", + "\r\n", + "Final-Recipient: rfc822; postmaster@remote.net\r\n", + "Action: delayed\r\n", + "Status: 4.2.1\r\n", + "Remote-MTA: dns; mx.remote.net\r\n", + "Diagnostic-Code: smtp; 450 Mailbox temporarily unavailable\r\n", + "\r\n", + "--boundary-dsn-002--\r\n", + ) + .to_string(), + // Raw message 3: Report message (matches Message 3) + concat!( + "From: reports@myserver.com\r\n", + "To: abuse@bigcorp.com\r\n", + "Subject: DMARC Aggregate Report for bigcorp.com\r\n", + "Date: Sat, 12 Apr 2027 09:10:00 +0000\r\n", + "Message-ID: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: multipart/mixed;\r\n", + " boundary=\"boundary-report-003\"\r\n", + "\r\n", + "--boundary-report-003\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", + "\r\n", + "This is a DMARC aggregate report for the domain bigcorp.com\r\n", + "generated by myserver.com.\r\n", + "\r\n", + "Report period: 2027-04-11T00:00:00Z to 2027-04-12T00:00:00Z\r\n", + "\r\n", + "--boundary-report-003\r\n", + "Content-Type: application/gzip\r\n", + "Content-Disposition: attachment;\r\n", + " filename=\"myserver.com!bigcorp.com!1744329600!1744416000.xml.gz\"\r\n", + "Content-Transfer-Encoding: base64\r\n", + "\r\n", + "H4sIAAAAAAAAA2NgGAWjYBSMglEwCkbBKBgFo2AUDAIAAP//\r\n", + "\r\n", + "--boundary-report-003--\r\n", + ) + .to_string(), + ] +} + +fn sample_tls_internal_reports() -> Vec { + let future = one_year_from_now(); + + vec![ + // Report 1: Successful TLS sessions, STS policy + TlsInternalReport { + created_at: now(), + deliver_at: future, + domain: "example.com".to_string(), + http_rua: Map::new(vec!["https://example.com/tlsrpt".to_string()]), + mail_rua: Map::new(vec!["mailto:tls-reports@example.com".to_string()]), + policy_identifiers: Map::new(vec![1]), + report: TlsReport { + contact_info: Some("admin@myserver.com".to_string()), + date_range_end: now(), + date_range_start: days_ago(1), + organization_name: Some("My Mail Server".to_string()), + policies: List::from(vec![TlsReportPolicy { + failure_details: List::from(vec![]), + mx_hosts: Map::new(vec!["mx1.example.com".to_string()]), + policy_domain: "example.com".to_string(), + policy_strings: Map::new(vec![ + "mode: enforce".to_string(), + "max_age: 86400".to_string(), + ]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 0, + total_successful_sessions: 150, + }]), + report_id: "tls-int-report-001".to_string(), + }, + }, + // Report 2: Mixed results with certificate mismatch failures + TlsInternalReport { + created_at: now(), + deliver_at: future, + domain: "secure-mail.org".to_string(), + http_rua: Map::new(vec![]), + mail_rua: Map::new(vec![ + "mailto:tlsrpt@secure-mail.org".to_string(), + "mailto:security@secure-mail.org".to_string(), + ]), + policy_identifiers: Map::new(vec![2, 3]), + report: TlsReport { + contact_info: Some("postmaster@myserver.com".to_string()), + date_range_end: now(), + date_range_start: days_ago(1), + organization_name: Some("My Mail Server".to_string()), + policies: List::from(vec![TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "Certificate CN does not match hostname".to_string(), + ), + failed_session_count: 5, + failure_reason_code: Some("certificate-host-mismatch".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 10, + )))), + receiving_mx_helo: Some("mx.secure-mail.org".to_string()), + receiving_mx_hostname: Some("mx.secure-mail.org".to_string()), + result_type: TlsResultType::CertificateHostMismatch, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 1, + )))), + }]), + mx_hosts: Map::new(vec!["mx.secure-mail.org".to_string()]), + policy_domain: "secure-mail.org".to_string(), + policy_strings: Map::new(vec!["mode: testing".to_string()]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 5, + total_successful_sessions: 95, + }]), + report_id: "tls-int-report-002".to_string(), + }, + }, + // Report 3: DANE/TLSA policy with validation failure + TlsInternalReport { + created_at: now(), + deliver_at: future, + domain: "dane-enabled.net".to_string(), + http_rua: Map::new(vec!["https://dane-enabled.net/tlsrpt".to_string()]), + mail_rua: Map::new(vec![]), + policy_identifiers: Map::new(vec![4]), + report: TlsReport { + contact_info: None, + date_range_end: now(), + date_range_start: days_ago(1), + organization_name: Some("My Mail Server".to_string()), + policies: List::from(vec![TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: None, + failed_session_count: 2, + failure_reason_code: Some("tlsa-invalid".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 25, + )))), + receiving_mx_helo: Some("mail.dane-enabled.net".to_string()), + receiving_mx_hostname: Some("mail.dane-enabled.net".to_string()), + result_type: TlsResultType::TlsaInvalid, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 1, + )))), + }]), + mx_hosts: Map::new(vec!["mail.dane-enabled.net".to_string()]), + policy_domain: "dane-enabled.net".to_string(), + policy_strings: Map::new(vec![]), + policy_type: TlsPolicyType::Tlsa, + total_failed_sessions: 2, + total_successful_sessions: 48, + }]), + report_id: "tls-int-report-003".to_string(), + }, + }, + ] +} + +fn sample_dmarc_internal_reports() -> Vec { + let future = one_year_from_now(); + + vec![ + // Report 1: Clean domain with all-pass records + DmarcInternalReport { + created_at: now(), + deliver_at: future, + domain: "trusted-sender.com".to_string(), + policy_identifier: 100, + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "dmarc@myserver.com".to_string(), + errors: Map::new(vec![]), + extensions: List::from(vec![]), + extra_contact_info: Some("https://myserver.com/dmarc".to_string()), + org_name: "My Mail Server".to_string(), + policy_adkim: DmarcAlignment::Relaxed, + policy_aspf: DmarcAlignment::Relaxed, + policy_disposition: DmarcDisposition::None, + policy_domain: "trusted-sender.com".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::Quarantine, + policy_testing_mode: false, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![DmarcReportRecord { + count: 500, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "trusted-sender.com".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "selector1".to_string(), + }]), + envelope_from: "trusted-sender.com".to_string(), + envelope_to: Some("myserver.com".to_string()), + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "trusted-sender.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 93, 184, 216, 34, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "trusted-sender.com".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }]), + report_id: "dmarc-int-001".to_string(), + version: Float::from(1.0), + generator: Some("My Mail Server".to_string()), + policy_np: DmarcDisposition::Quarantine, + policy_discovery_method: DmarcDiscovery::Psl, + }, + rua: Map::new(vec!["mailto:dmarc-rua@trusted-sender.com".to_string()]), + }, + // Report 2: Domain with DKIM failure and strict alignment + DmarcInternalReport { + created_at: now(), + deliver_at: future, + domain: "strict-domain.org".to_string(), + policy_identifier: 200, + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "dmarc@myserver.com".to_string(), + errors: Map::new(vec!["DKIM signature verification failed".to_string()]), + extensions: List::from(vec![]), + extra_contact_info: None, + org_name: "My Mail Server".to_string(), + policy_adkim: DmarcAlignment::Strict, + policy_aspf: DmarcAlignment::Strict, + policy_disposition: DmarcDisposition::Reject, + policy_domain: "strict-domain.org".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::Reject, + policy_testing_mode: false, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![DmarcReportRecord { + count: 12, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "strict-domain.org".to_string(), + human_result: Some("signature verification failed".to_string()), + result: DkimAuthResult::Fail, + selector: "dkim2024".to_string(), + }]), + envelope_from: "strict-domain.org".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Fail, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "strict-domain.org".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 50, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "strict-domain.org".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }]), + report_id: "dmarc-int-002".to_string(), + version: Float::from(1.0), + generator: Some("My Mail Server".to_string()), + policy_np: DmarcDisposition::Reject, + policy_discovery_method: DmarcDiscovery::Psl, + }, + rua: Map::new(vec!["mailto:dmarc@strict-domain.org".to_string()]), + }, + // Report 3: Domain in testing mode with multiple record types + DmarcInternalReport { + created_at: now(), + deliver_at: future, + domain: "new-policy.io".to_string(), + policy_identifier: 300, + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "dmarc@myserver.com".to_string(), + errors: Map::new(vec![]), + extensions: List::from(vec![]), + extra_contact_info: None, + org_name: "My Mail Server".to_string(), + policy_adkim: DmarcAlignment::Relaxed, + policy_aspf: DmarcAlignment::Strict, + policy_disposition: DmarcDisposition::Quarantine, + policy_domain: "new-policy.io".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::None, + policy_testing_mode: true, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![ + DmarcReportRecord { + count: 200, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "new-policy.io".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "sel1".to_string(), + }]), + envelope_from: "new-policy.io".to_string(), + envelope_to: Some("myserver.com".to_string()), + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "new-policy.io".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 5, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "new-policy.io".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 3, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "new-policy.io".to_string(), + human_result: Some("no signature found".to_string()), + result: DkimAuthResult::None, + selector: "".to_string(), + }]), + envelope_from: "spoofed.example".to_string(), + envelope_to: Some("myserver.com".to_string()), + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Fail, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "new-policy.io".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 99)))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "spoofed.example".to_string(), + human_result: Some("SPF record not found".to_string()), + result: SpfAuthResult::None, + scope: SpfDomainScope::MailFrom, + }]), + }, + ]), + report_id: "dmarc-int-003".to_string(), + version: Float::from(1.0), + generator: Some("My Mail Server".to_string()), + policy_np: DmarcDisposition::None, + policy_discovery_method: DmarcDiscovery::Treewalk, + }, + rua: Map::new(vec![ + "mailto:dmarc@new-policy.io".to_string(), + "mailto:dmarc-backup@new-policy.io".to_string(), + ]), + }, + ] +} + +fn sample_tls_external_reports() -> Vec { + let future = one_year_from_now(); + + vec![ + // Report 1: Clean report from a large provider + TlsExternalReport { + expires_at: future, + from: "tls-reports@bigprovider.com".to_string(), + member_tenant_id: None, + received_at: now(), + report: TlsReport { + contact_info: Some("postmaster@bigprovider.com".to_string()), + date_range_end: now(), + date_range_start: days_ago(1), + organization_name: Some("Big Provider Inc.".to_string()), + policies: List::from(vec![ + TlsReportPolicy { + failure_details: List::from(vec![]), + mx_hosts: Map::new(vec![ + "mx1.myserver.com".to_string(), + "mx2.myserver.com".to_string(), + ]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec![ + "mode: enforce".to_string(), + "max_age: 604800".to_string(), + ]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 0, + total_successful_sessions: 12500, + }, + TlsReportPolicy { + failure_details: List::from(vec![]), + mx_hosts: Map::new(vec!["mx3.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec![]), + policy_type: TlsPolicyType::Tlsa, + total_failed_sessions: 0, + total_successful_sessions: 3200, + }, + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "Fallback to plaintext after STARTTLS failure".to_string(), + ), + failed_session_count: 2, + failure_reason_code: Some("starttls-not-supported".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 50, + )))), + receiving_mx_helo: Some("backup-mx.myserver.com".to_string()), + receiving_mx_hostname: Some("backup-mx.myserver.com".to_string()), + result_type: TlsResultType::StartTlsNotSupported, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 5, + )))), + }]), + mx_hosts: Map::new(vec!["backup-mx.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec!["mode: testing".to_string()]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 2, + total_successful_sessions: 50, + }, + ]), + report_id: "tls-ext-001-bigprovider".to_string(), + }, + subject: "TLS-RPT report for myserver.com".to_string(), + to: Map::new(vec!["tls-rpt@myserver.com".to_string()]), + }, + // Report 2: Report with expired certificate failures + TlsExternalReport { + expires_at: future, + from: "noreply@securemail.org".to_string(), + member_tenant_id: None, + received_at: now(), + report: TlsReport { + contact_info: None, + date_range_end: now(), + date_range_start: days_ago(1), + organization_name: Some("SecureMail".to_string()), + policies: List::from(vec![ + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "Certificate expired 2 days ago".to_string(), + ), + failed_session_count: 30, + failure_reason_code: Some("certificate-expired".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 10, + )))), + receiving_mx_helo: Some("mx1.myserver.com".to_string()), + receiving_mx_hostname: Some("mx1.myserver.com".to_string()), + result_type: TlsResultType::CertificateExpired, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 1, + )))), + }]), + mx_hosts: Map::new(vec!["mx1.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec!["mode: enforce".to_string()]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 30, + total_successful_sessions: 70, + }, + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "CN=old.myserver.com does not match mx2.myserver.com".to_string(), + ), + failed_session_count: 15, + failure_reason_code: Some("certificate-host-mismatch".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 11, + )))), + receiving_mx_helo: Some("mx2.myserver.com".to_string()), + receiving_mx_hostname: Some("mx2.myserver.com".to_string()), + result_type: TlsResultType::CertificateHostMismatch, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 2, + )))), + }]), + mx_hosts: Map::new(vec!["mx2.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec!["mode: enforce".to_string()]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 15, + total_successful_sessions: 85, + }, + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "Untrusted CA in certificate chain".to_string(), + ), + failed_session_count: 8, + failure_reason_code: Some("certificate-not-trusted".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 12, + )))), + receiving_mx_helo: Some("mx3.myserver.com".to_string()), + receiving_mx_hostname: Some("mx3.myserver.com".to_string()), + result_type: TlsResultType::CertificateNotTrusted, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 3, + )))), + }]), + mx_hosts: Map::new(vec!["mx3.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec!["mode: testing".to_string()]), + policy_type: TlsPolicyType::Sts, + total_failed_sessions: 8, + total_successful_sessions: 120, + }, + ]), + report_id: "tls-ext-002-securemail".to_string(), + }, + subject: "SMTP TLS Reporting for myserver.com".to_string(), + to: Map::new(vec!["tls-rpt@myserver.com".to_string()]), + }, + // Report 3: DANE report with no policy found + TlsExternalReport { + expires_at: future, + from: "reports@mailhoster.net".to_string(), + member_tenant_id: None, + received_at: now(), + report: TlsReport { + contact_info: Some("abuse@mailhoster.net".to_string()), + date_range_end: now(), + date_range_start: days_ago(7), + organization_name: Some("MailHoster".to_string()), + policies: List::from(vec![ + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: None, + failed_session_count: 10, + failure_reason_code: None, + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 25, + )))), + receiving_mx_helo: Some("mx2.myserver.com".to_string()), + receiving_mx_hostname: Some("mx2.myserver.com".to_string()), + result_type: TlsResultType::StartTlsNotSupported, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 50, + )))), + }]), + mx_hosts: Map::new(vec!["mx2.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec![]), + policy_type: TlsPolicyType::NoPolicyFound, + total_failed_sessions: 10, + total_successful_sessions: 0, + }, + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "DANE TLSA record invalid for mx1.myserver.com".to_string(), + ), + failed_session_count: 20, + failure_reason_code: Some("tlsa-invalid".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 26, + )))), + receiving_mx_helo: Some("mx1.myserver.com".to_string()), + receiving_mx_hostname: Some("mx1.myserver.com".to_string()), + result_type: TlsResultType::TlsaInvalid, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 51, + )))), + }]), + mx_hosts: Map::new(vec!["mx1.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec![]), + policy_type: TlsPolicyType::Tlsa, + total_failed_sessions: 20, + total_successful_sessions: 180, + }, + TlsReportPolicy { + failure_details: List::from(vec![TlsFailureDetails { + additional_information: Some( + "DNSSEC validation failed for _25._tcp.mx1.myserver.com" + .to_string(), + ), + failed_session_count: 5, + failure_reason_code: Some("dnssec-invalid".to_string()), + receiving_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 27, + )))), + receiving_mx_helo: Some("mx1.myserver.com".to_string()), + receiving_mx_hostname: Some("mx1.myserver.com".to_string()), + result_type: TlsResultType::DnssecInvalid, + sending_mta_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 52, + )))), + }]), + mx_hosts: Map::new(vec!["mx1.myserver.com".to_string()]), + policy_domain: "myserver.com".to_string(), + policy_strings: Map::new(vec![]), + policy_type: TlsPolicyType::Tlsa, + total_failed_sessions: 5, + total_successful_sessions: 95, + }, + ]), + report_id: "tls-ext-003-mailhoster".to_string(), + }, + subject: "TLS Report: myserver.com".to_string(), + to: Map::new(vec!["tls-rpt@myserver.com".to_string()]), + }, + ] +} + +fn sample_dmarc_external_reports() -> Vec { + let future = one_year_from_now(); + + vec![ + // Report 1: Google-style aggregate report, all pass + DmarcExternalReport { + expires_at: future, + from: "noreply-dmarc@google.com".to_string(), + member_tenant_id: None, + received_at: now(), + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "noreply-dmarc@google.com".to_string(), + errors: Map::new(vec![]), + extensions: List::from(vec![]), + extra_contact_info: Some( + "https://support.google.com/a/answer/10032169".to_string(), + ), + org_name: "google.com".to_string(), + policy_adkim: DmarcAlignment::Relaxed, + policy_aspf: DmarcAlignment::Relaxed, + policy_disposition: DmarcDisposition::None, + policy_domain: "myserver.com".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::None, + policy_testing_mode: false, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![ + DmarcReportRecord { + count: 1500, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "google".to_string(), + }]), + envelope_from: "myserver.com".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "myserver.com".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 320, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "selector2".to_string(), + }]), + envelope_from: "myserver.com".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "myserver.com".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 7, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "sub.myserver.com".to_string(), + human_result: Some("DKIM signature uses subdomain".to_string()), + result: DkimAuthResult::Pass, + selector: "google".to_string(), + }]), + envelope_from: "sub.myserver.com".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 3)))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "sub.myserver.com".to_string(), + human_result: Some( + "SPF alignment failed: subdomain mismatch".to_string(), + ), + result: SpfAuthResult::SoftFail, + scope: SpfDomainScope::MailFrom, + }]), + }, + ]), + report_id: "dmarc-ext-001-google".to_string(), + version: Float::from(1.0), + generator: Some("google.com".to_string()), + policy_np: DmarcDisposition::None, + policy_discovery_method: DmarcDiscovery::Psl, + }, + subject: "Report domain: myserver.com Submitter: google.com".to_string(), + to: Map::new(vec!["dmarc-rua@myserver.com".to_string()]), + }, + // Report 2: Report showing spoofing attempts from an unknown source + DmarcExternalReport { + expires_at: future, + from: "dmarc@yahoo.com".to_string(), + member_tenant_id: None, + received_at: now(), + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "dmarc@yahoo.com".to_string(), + errors: Map::new(vec![]), + extensions: List::from(vec![]), + extra_contact_info: None, + org_name: "Yahoo! Inc.".to_string(), + policy_adkim: DmarcAlignment::Strict, + policy_aspf: DmarcAlignment::Strict, + policy_disposition: DmarcDisposition::Reject, + policy_domain: "myserver.com".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::Reject, + policy_testing_mode: false, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![ + DmarcReportRecord { + count: 8, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: Some("bad signature".to_string()), + result: DkimAuthResult::Fail, + selector: "default".to_string(), + }]), + envelope_from: "attacker.example".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Fail, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 222, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "attacker.example".to_string(), + human_result: Some("domain not found".to_string()), + result: SpfAuthResult::Fail, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 15, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "phisher.net".to_string(), + human_result: Some( + "no valid DKIM signature for myserver.com".to_string(), + ), + result: DkimAuthResult::None, + selector: "".to_string(), + }]), + envelope_from: "phisher.net".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Fail, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 100, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "phisher.net".to_string(), + human_result: Some("SPF domain mismatch".to_string()), + result: SpfAuthResult::Fail, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 2, + dkim_results: List::from(vec![ + DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: Some("signature expired".to_string()), + result: DkimAuthResult::Fail, + selector: "selector1".to_string(), + }, + DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: Some("body hash mismatch".to_string()), + result: DkimAuthResult::Fail, + selector: "selector2".to_string(), + }, + ]), + envelope_from: "compromised-relay.example".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Fail, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 33, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "compromised-relay.example".to_string(), + human_result: None, + result: SpfAuthResult::PermError, + scope: SpfDomainScope::MailFrom, + }]), + }, + ]), + report_id: "dmarc-ext-002-yahoo".to_string(), + version: Float::from(1.0), + generator: Some("Yahoo! Inc.".to_string()), + policy_np: DmarcDisposition::Reject, + policy_discovery_method: DmarcDiscovery::Psl, + }, + subject: "Report domain: myserver.com Submitter: yahoo.com".to_string(), + to: Map::new(vec!["dmarc-rua@myserver.com".to_string()]), + }, + // Report 3: Microsoft report with forwarded mail override + DmarcExternalReport { + expires_at: future, + from: "dmarc-noreply@microsoft.com".to_string(), + member_tenant_id: None, + received_at: now(), + report: DmarcReport { + date_range_begin: days_ago(1), + date_range_end: now(), + email: "dmarc-noreply@microsoft.com".to_string(), + errors: Map::new(vec![]), + extensions: List::from(vec![]), + extra_contact_info: None, + org_name: "Microsoft Corporation".to_string(), + policy_adkim: DmarcAlignment::Relaxed, + policy_aspf: DmarcAlignment::Relaxed, + policy_disposition: DmarcDisposition::Quarantine, + policy_domain: "myserver.com".to_string(), + policy_failure_reporting_options: Map::new(vec![]), + policy_subdomain_disposition: DmarcDisposition::Quarantine, + policy_testing_mode: false, + policy_version: Some("DMARC1".to_string()), + records: List::from(vec![ + DmarcReportRecord { + count: 25, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "selector1".to_string(), + }]), + envelope_from: "forwarder.example.com".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 100, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "forwarder.example.com".to_string(), + human_result: None, + result: SpfAuthResult::SoftFail, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 800, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: None, + result: DkimAuthResult::Pass, + selector: "selector1".to_string(), + }]), + envelope_from: "myserver.com".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Pass, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "myserver.com".to_string(), + human_result: None, + result: SpfAuthResult::Pass, + scope: SpfDomainScope::MailFrom, + }]), + }, + DmarcReportRecord { + count: 40, + dkim_results: List::from(vec![DmarcDkimResult { + domain: "myserver.com".to_string(), + human_result: Some( + "DKIM signature OK but SPF failed via mailing list".to_string(), + ), + result: DkimAuthResult::Pass, + selector: "selector1".to_string(), + }]), + envelope_from: "mailinglist.example.org".to_string(), + envelope_to: None, + evaluated_disposition: Default::default(), + evaluated_dkim: DmarcResult::Pass, + evaluated_spf: DmarcResult::Fail, + extensions: List::from(vec![]), + header_from: "myserver.com".to_string(), + policy_override_reasons: List::from(vec![]), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 200, + )))), + spf_results: List::from(vec![DmarcSpfResult { + domain: "mailinglist.example.org".to_string(), + human_result: Some("Mailing list rewrite".to_string()), + result: SpfAuthResult::Fail, + scope: SpfDomainScope::MailFrom, + }]), + }, + ]), + report_id: "dmarc-ext-003-msft".to_string(), + version: Float::from(1.0), + generator: Some("Microsoft Corporation".to_string()), + policy_np: DmarcDisposition::Quarantine, + policy_discovery_method: DmarcDiscovery::Treewalk, + }, + subject: "Report domain: myserver.com Submitter: microsoft.com".to_string(), + to: Map::new(vec!["dmarc-rua@myserver.com".to_string()]), + }, + ] +} + +fn sample_arf_external_reports() -> Vec { + let future = one_year_from_now(); + + vec![ + // Report 1: Abuse complaint from a user + ArfExternalReport { + expires_at: future, + from: "fbl@isp-provider.com".to_string(), + member_tenant_id: None, + received_at: now(), + report: ArfFeedbackReport { + arrival_date: Some(days_ago(1)), + auth_failure: ArfAuthFailureType::Unspecified, + authentication_results: Map::new(vec![ + "dkim=pass header.d=myserver.com".to_string(), + "spf=pass smtp.mailfrom=myserver.com".to_string(), + ]), + delivery_result: ArfDeliveryResult::Delivered, + dkim_adsp_dns: None, + dkim_canonicalized_body: None, + dkim_canonicalized_header: None, + dkim_domain: Some("myserver.com".to_string()), + dkim_identity: None, + dkim_selector: Some("selector1".to_string()), + dkim_selector_dns: None, + feedback_type: ArfFeedbackType::Abuse, + headers: Some( + "From: newsletter@myserver.com\r\nTo: user@isp-provider.com\r\nSubject: Weekly Newsletter\r\nDate: Mon, 10 Apr 2027 10:00:00 +0000" + .to_string(), + ), + identity_alignment: ArfIdentityAlignment::DkimSpf, + incidents: 1, + message: Some("User marked this message as spam".to_string()), + original_envelope_id: Some("env-newsletter-001".to_string()), + original_mail_from: Some("newsletter@myserver.com".to_string()), + original_rcpt_to: Some("user@isp-provider.com".to_string()), + reported_domains: Map::new(vec!["myserver.com".to_string()]), + reported_uris: Map::new(vec![]), + reporting_mta: Some("fbl.isp-provider.com".to_string()), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))), + source_port: Some(25), + spf_dns: None, + user_agent: Some("ISP-FBL/2.0".to_string()), + version: 1, + }, + subject: "FBL report from isp-provider.com".to_string(), + to: Map::new(vec!["abuse@myserver.com".to_string()]), + }, + // Report 2: Auth failure report (DKIM) + ArfExternalReport { + expires_at: future, + from: "authfail@receiver.org".to_string(), + member_tenant_id: None, + received_at: now(), + report: ArfFeedbackReport { + arrival_date: Some(days_ago(2)), + auth_failure: ArfAuthFailureType::Signature, + authentication_results: Map::new(vec![ + "dkim=fail header.d=myserver.com".to_string(), + ]), + delivery_result: ArfDeliveryResult::Reject, + dkim_adsp_dns: None, + dkim_canonicalized_body: Some("base64bodyhash==".to_string()), + dkim_canonicalized_header: Some("base64headerhash==".to_string()), + dkim_domain: Some("myserver.com".to_string()), + dkim_identity: Some("@myserver.com".to_string()), + dkim_selector: Some("selector1".to_string()), + dkim_selector_dns: Some( + "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUA".to_string(), + ), + feedback_type: ArfFeedbackType::AuthFailure, + headers: Some( + "From: info@myserver.com\r\nTo: contact@receiver.org\r\nSubject: Important Update" + .to_string(), + ), + identity_alignment: ArfIdentityAlignment::None, + incidents: 3, + message: None, + original_envelope_id: None, + original_mail_from: Some("info@myserver.com".to_string()), + original_rcpt_to: Some("contact@receiver.org".to_string()), + reported_domains: Map::new(vec!["myserver.com".to_string()]), + reported_uris: Map::new(vec![]), + reporting_mta: Some("mx.receiver.org".to_string()), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))), + source_port: Some(587), + spf_dns: None, + user_agent: Some("ReceiverMTA/1.0".to_string()), + version: 1, + }, + subject: "Authentication failure report for myserver.com".to_string(), + to: Map::new(vec!["abuse@myserver.com".to_string()]), + }, + // Report 3: Fraud/phishing report + ArfExternalReport { + expires_at: future, + from: "reports@antiphish.net".to_string(), + member_tenant_id: None, + received_at: now(), + report: ArfFeedbackReport { + arrival_date: Some(days_ago(3)), + auth_failure: ArfAuthFailureType::Dmarc, + authentication_results: Map::new(vec![ + "dmarc=fail header.from=myserver.com".to_string(), + "spf=fail smtp.mailfrom=spoofed.example".to_string(), + ]), + delivery_result: ArfDeliveryResult::Policy, + dkim_adsp_dns: None, + dkim_canonicalized_body: None, + dkim_canonicalized_header: None, + dkim_domain: None, + dkim_identity: None, + dkim_selector: None, + dkim_selector_dns: None, + feedback_type: ArfFeedbackType::Fraud, + headers: Some( + "From: security@myserver.com\r\nTo: victim@antiphish.net\r\nSubject: Urgent: Verify your account" + .to_string(), + ), + identity_alignment: ArfIdentityAlignment::None, + incidents: 50, + message: Some("Phishing attempt impersonating myserver.com".to_string()), + original_envelope_id: None, + original_mail_from: Some("spoofer@spoofed.example".to_string()), + original_rcpt_to: Some("victim@antiphish.net".to_string()), + reported_domains: Map::new(vec![ + "myserver.com".to_string(), + "spoofed.example".to_string(), + ]), + reported_uris: Map::new(vec![ + "https://evil-site.example/phish".to_string(), + ]), + reporting_mta: Some("gateway.antiphish.net".to_string()), + source_ip: Some(IpAddr(std::net::IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 77, + )))), + source_port: Some(25), + spf_dns: Some("v=spf1 -all".to_string()), + user_agent: Some("AntiPhish/3.0".to_string()), + version: 1, + }, + subject: "Fraud report: phishing attempt using myserver.com".to_string(), + to: Map::new(vec![ + "abuse@myserver.com".to_string(), + "security@myserver.com".to_string(), + ]), + }, + ] +} + +fn one_year_from_now_u64() -> u64 { + store::write::now() + 365 * 24 * 3600 +} + +fn one_year_from_now() -> UTCDateTime { + UTCDateTime::from_timestamp(store::write::now().cast_signed() + 365 * 24 * 3600) +} + +fn now() -> UTCDateTime { + UTCDateTime::from_timestamp(store::write::now().cast_signed()) +} + +fn days_ago(days: i64) -> UTCDateTime { + UTCDateTime::from_timestamp(store::write::now().cast_signed() - days * 24 * 3600) +} diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml new file mode 100644 index 0000000..945be75 --- /dev/null +++ b/crates/managesieve/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "managesieve" +version = "0.16.22" +edition = "2024" + +[dependencies] +imap_proto = { path = "../imap-proto" } +imap = { path = "../imap" } +types = { path = "../types" } +jmap_proto = { path = "../jmap-proto" } +directory = { path = "../directory" } +common = { path = "../common" } +store = { path = "../store" } +email = { path = "../email" } +trc = { path = "../trc" } +registry = { path = "../registry" } +mail-parser = { version = "0.11", features = ["full_encoding"] } +sieve-rs = { version = "0.7", features = ["rkyv"] } +tokio = { version = "1.53", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +compact_str = "0.10.0" + +[features] +test_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/managesieve/src/core/client.rs b/crates/managesieve/src/core/client.rs new file mode 100644 index 0000000..cd13085 --- /dev/null +++ b/crates/managesieve/src/core/client.rs @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Command, ResponseCode, SerializeResponse, Session, State}; +use common::{ + KV_RATE_LIMIT_IMAP, + network::{SessionResult, SessionStream}, +}; +use imap_proto::receiver::{self, Request}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use trc::{AddContext, SecurityEvent}; +use types::{collection::Collection, field::SieveField}; + +impl Session { + pub async fn ingest(&mut self, bytes: &[u8]) -> SessionResult { + let mut bytes = bytes.iter(); + let mut requests = Vec::with_capacity(2); + let mut needs_literal = None; + + loop { + match self.receiver.parse(&mut bytes) { + Ok(request) => match self.validate_request(request).await { + Ok(request) => { + requests.push(request); + } + Err(err) => { + let mut disconnect = err.must_disconnect(); + + if let Err(err) = self.write_error(err).await { + trc::error!(err.span_id(self.session_id)); + disconnect = true; + } + + if disconnect { + return SessionResult::Close; + } + } + }, + Err(receiver::Error::NeedsMoreData) => { + break; + } + Err(receiver::Error::NeedsLiteral { size }) => { + needs_literal = size.into(); + break; + } + Err(receiver::Error::Error { response }) => { + // Check for port scanners + if matches!( + (&self.state, response.key(trc::Key::Code)), + ( + State::NotAuthenticated { .. }, + Some(trc::Value::String(v)) + ) if v == "PARSE" + ) { + match self.server.is_scanner_fail2banned(self.remote_addr).await { + Ok(true) => { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = self.session_id, + RemoteIp = self.remote_addr, + Reason = "Invalid ManageSieve command", + ); + + return SessionResult::Close; + } + Ok(false) => {} + Err(err) => { + trc::error!( + err.span_id(self.session_id) + .details("Failed to check for fail2ban") + ); + } + } + } + + if let Err(err) = self.write_error(response).await { + trc::error!(err.span_id(self.session_id)); + return SessionResult::Close; + } + break; + } + } + } + + for request in requests { + let command = request.command; + match match command { + Command::ListScripts => self.handle_listscripts().await, + Command::PutScript => self.handle_putscript(request).await, + Command::SetActive => self.handle_setactive(request).await, + Command::GetScript => self.handle_getscript(request).await, + Command::DeleteScript => self.handle_deletescript(request).await, + Command::RenameScript => self.handle_renamescript(request).await, + Command::CheckScript => self.handle_checkscript(request).await, + Command::HaveSpace => self.handle_havespace(request).await, + Command::Capability => self.handle_capability("").await, + Command::Authenticate => Box::pin(self.handle_authenticate(request)).await, + Command::StartTls => self.handle_start_tls().await, + Command::Logout => self.handle_logout().await, + Command::Noop => self.handle_noop(request).await, + Command::Unauthenticate => self.handle_unauthenticate().await, + } { + Ok(response) => { + if let Err(err) = self.write(&response).await { + trc::error!(err.span_id(self.session_id)); + return SessionResult::Close; + } + + match command { + Command::Logout => return SessionResult::Close, + Command::StartTls => return SessionResult::UpgradeTls, + _ => (), + } + } + Err(err) => { + let mut disconnect = err.must_disconnect(); + + if let Err(err) = self.write_error(err).await { + trc::error!(err.span_id(self.session_id)); + disconnect = true; + } + + if disconnect { + return SessionResult::Close; + } + } + } + } + + if let Some(needs_literal) = needs_literal + && let Err(err) = self + .write(format!("OK Ready for {} bytes.\r\n", needs_literal).as_bytes()) + .await + { + trc::error!(err.span_id(self.session_id)); + return SessionResult::Close; + } + + SessionResult::Continue + } + + async fn validate_request(&self, command: Request) -> trc::Result> { + match &command.command { + Command::Capability | Command::Logout | Command::Noop => Ok(command), + Command::Authenticate => { + if let State::NotAuthenticated { .. } = &self.state { + if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + Ok(command) + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .code(ResponseCode::EncryptNeeded) + .details("Cannot authenticate over plain-text.")) + } + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Already authenticated.")) + } + } + Command::StartTls => { + if !self.stream.is_tls() { + Ok(command) + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Already in TLS mode.")) + } + } + Command::HaveSpace + | Command::PutScript + | Command::ListScripts + | Command::SetActive + | Command::GetScript + | Command::DeleteScript + | Command::RenameScript + | Command::CheckScript + | Command::Unauthenticate => { + if let State::Authenticated { access_token, .. } = &self.state { + if let Some(rate) = &self.server.core.imap.rate_requests { + if self + .server + .in_memory_store() + .is_rate_allowed( + KV_RATE_LIMIT_IMAP, + &access_token.account_id().to_be_bytes(), + rate, + true, + ) + .await + .caused_by(trc::location!())? + .is_none() + { + Ok(command) + } else { + Err(trc::LimitEvent::TooManyRequests + .into_err() + .code(ResponseCode::TryLater)) + } + } else { + Ok(command) + } + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Not authenticated.")) + } + } + } + } +} + +impl Session { + #[inline(always)] + pub async fn write(&mut self, bytes: &[u8]) -> trc::Result<()> { + trc::event!( + ManageSieve(trc::ManageSieveEvent::RawOutput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + self.stream.write_all(bytes).await.map_err(|err| { + trc::NetworkEvent::WriteError + .into_err() + .reason(err) + .caused_by(trc::location!()) + })?; + self.stream.flush().await.map_err(|err| { + trc::NetworkEvent::FlushError + .into_err() + .reason(err) + .caused_by(trc::location!()) + })?; + + Ok(()) + } + + pub async fn write_error(&mut self, error: trc::Error) -> trc::Result<()> { + let bytes = error.serialize(); + trc::error!(error.span_id(self.session_id)); + self.write(&bytes).await + } + + #[inline(always)] + pub async fn read(&mut self, bytes: &mut [u8]) -> trc::Result { + let len = self.stream.read(bytes).await.map_err(|err| { + trc::NetworkEvent::ReadError + .into_err() + .reason(err) + .caused_by(trc::location!()) + })?; + + trc::event!( + ManageSieve(trc::ManageSieveEvent::RawInput), + SpanId = self.session_id, + Size = len, + Contents = trc::Value::from_maybe_string(bytes.get(0..len).unwrap_or_default()), + ); + + Ok(len) + } +} + +impl Session { + pub async fn get_script_id(&self, account_id: u32, name: &str) -> trc::Result { + self.server + .document_ids_matching( + account_id, + Collection::SieveScript, + SieveField::Name, + name.to_lowercase().as_bytes(), + ) + .await + .caused_by(trc::location!()) + .and_then(|results| { + results.min().ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .code(ResponseCode::NonExistent) + .reason("There is no script by that name") + }) + }) + } +} diff --git a/crates/managesieve/src/core/mod.rs b/crates/managesieve/src/core/mod.rs new file mode 100644 index 0000000..ce1239c --- /dev/null +++ b/crates/managesieve/src/core/mod.rs @@ -0,0 +1,314 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod client; +pub mod session; + +use std::{borrow::Cow, net::IpAddr, sync::Arc}; + +use common::{ + Inner, Server, + auth::AccessToken, + network::{ServerInstance, limiter::InFlight}, +}; + +use compact_str::CompactString; +use imap_proto::receiver::{CommandParser, Receiver}; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub struct Session { + pub server: Server, + pub instance: Arc, + pub receiver: Receiver, + pub state: State, + pub remote_addr: IpAddr, + pub stream: T, + pub session_id: u64, + pub in_flight: InFlight, +} + +pub enum State { + NotAuthenticated { + auth_failures: u32, + }, + Authenticated { + access_token: AccessToken, + in_flight: Option, + }, +} + +impl State { + pub fn access_token(&self) -> &AccessToken { + match self { + State::Authenticated { access_token, .. } => access_token, + State::NotAuthenticated { .. } => unreachable!("Not authenticated"), + } + } +} + +#[derive(Clone)] +pub struct ManageSieveSessionManager { + pub inner: Arc, +} + +impl ManageSieveSessionManager { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Command { + Authenticate, + StartTls, + Logout, + Capability, + HaveSpace, + PutScript, + ListScripts, + SetActive, + GetScript, + DeleteScript, + RenameScript, + CheckScript, + #[default] + Noop, + Unauthenticate, +} + +impl CommandParser for Command { + fn parse(value: &[u8], _is_uid: bool) -> Option { + match value { + b"AUTHENTICATE" => Some(Command::Authenticate), + b"STARTTLS" => Some(Command::StartTls), + b"LOGOUT" => Some(Command::Logout), + b"CAPABILITY" => Some(Command::Capability), + b"HAVESPACE" => Some(Command::HaveSpace), + b"PUTSCRIPT" => Some(Command::PutScript), + b"LISTSCRIPTS" => Some(Command::ListScripts), + b"SETACTIVE" => Some(Command::SetActive), + b"GETSCRIPT" => Some(Command::GetScript), + b"DELETESCRIPT" => Some(Command::DeleteScript), + b"RENAMESCRIPT" => Some(Command::RenameScript), + b"CHECKSCRIPT" => Some(Command::CheckScript), + b"NOOP" => Some(Command::Noop), + b"UNAUTHENTICATE" => Some(Command::Unauthenticate), + _ => None, + } + } + + fn tokenize_brackets(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusResponse { + pub code: Option, + pub message: Cow<'static, str>, + pub rtype: ResponseType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseType { + Ok, + No, + Bye, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseCode { + AuthTooWeak, + EncryptNeeded, + Quota, + QuotaMaxScripts, + QuotaMaxSize, + Referral, + Sasl, + TransitionNeeded, + TryLater, + Active, + NonExistent, + AlreadyExists, + Tag(String), + Warnings, +} + +impl ResponseCode { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(match self { + ResponseCode::AuthTooWeak => b"AUTH-TOO-WEAK", + ResponseCode::EncryptNeeded => b"ENCRYPT-NEEDED", + ResponseCode::Quota => b"QUOTA", + ResponseCode::QuotaMaxScripts => b"QUOTA/MAXSCRIPTS", + ResponseCode::QuotaMaxSize => b"QUOTA/MAXSIZE", + ResponseCode::Referral => b"REFERRAL", + ResponseCode::Sasl => b"SASL", + ResponseCode::TransitionNeeded => b"TRANSITION-NEEDED", + ResponseCode::TryLater => b"TRYLATER", + ResponseCode::Active => b"ACTIVE", + ResponseCode::NonExistent => b"NONEXISTENT", + ResponseCode::AlreadyExists => b"ALREADYEXISTS", + ResponseCode::Tag(tag) => { + buf.extend_from_slice(b"TAG {"); + buf.extend_from_slice(tag.len().to_string().as_bytes()); + buf.extend_from_slice(b"}\r\n"); + buf.extend_from_slice(tag.as_bytes()); + return; + } + ResponseCode::Warnings => b"WARNINGS", + }); + } + + pub fn as_str(&self) -> &'static str { + match self { + ResponseCode::AuthTooWeak => "AUTH-TOO-WEAK", + ResponseCode::EncryptNeeded => "ENCRYPT-NEEDED", + ResponseCode::Quota => "QUOTA", + ResponseCode::QuotaMaxScripts => "QUOTA/MAXSCRIPTS", + ResponseCode::QuotaMaxSize => "QUOTA/MAXSIZE", + ResponseCode::Referral => "REFERRAL", + ResponseCode::Sasl => "SASL", + ResponseCode::TransitionNeeded => "TRANSITION-NEEDED", + ResponseCode::TryLater => "TRYLATER", + ResponseCode::Active => "ACTIVE", + ResponseCode::NonExistent => "NONEXISTENT", + ResponseCode::AlreadyExists => "ALREADYEXISTS", + ResponseCode::Tag(_) => "TAG", + ResponseCode::Warnings => "WARNINGS", + } + } +} + +impl ResponseType { + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(self.as_str().as_bytes()); + } + + pub fn as_str(&self) -> &'static str { + match self { + ResponseType::Ok => "OK", + ResponseType::No => "NO", + ResponseType::Bye => "BYE", + } + } +} + +impl StatusResponse { + pub fn serialize(self, mut buf: Vec) -> Vec { + self.rtype.serialize(&mut buf); + if let Some(code) = &self.code { + buf.extend_from_slice(b" ("); + code.serialize(&mut buf); + buf.push(b')'); + } + if !self.message.is_empty() { + buf.extend_from_slice(b" \""); + for ch in self.message.as_bytes() { + if b"\"\\".contains(ch) { + buf.push(b'\\'); + } + buf.push(*ch); + } + buf.push(b'\"'); + } + buf.extend_from_slice(b"\r\n"); + buf + } + + pub fn into_bytes(self) -> Vec { + self.serialize(Vec::with_capacity(16)) + } + + pub fn with_code(mut self, code: ResponseCode) -> Self { + self.code = Some(code); + self + } + + pub fn no(message: impl Into>) -> Self { + StatusResponse { + code: None, + message: message.into(), + rtype: ResponseType::No, + } + } + + pub fn ok(message: impl Into>) -> Self { + StatusResponse { + code: None, + message: message.into(), + rtype: ResponseType::Ok, + } + } + + pub fn bye(message: impl Into>) -> Self { + StatusResponse { + code: None, + message: message.into(), + rtype: ResponseType::Bye, + } + } + + pub fn database_failure() -> Self { + StatusResponse { + code: Some(ResponseCode::TryLater), + message: Cow::Borrowed("Database failure"), + rtype: ResponseType::No, + } + } +} + +pub trait SerializeResponse { + fn serialize(&self) -> Vec; +} + +impl SerializeResponse for trc::Error { + fn serialize(&self) -> Vec { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(self.value_as_str(trc::Key::Type).unwrap_or("NO").as_bytes()); + if let Some(code) = self + .value_as_str(trc::Key::Code) + .or_else(|| match self.as_ref() { + trc::EventType::Store(trc::StoreEvent::NotFound) => { + Some(ResponseCode::NonExistent.as_str()) + } + trc::EventType::Store(_) => Some(ResponseCode::TryLater.as_str()), + trc::EventType::Limit(trc::LimitEvent::Quota) => Some(ResponseCode::Quota.as_str()), + trc::EventType::Limit(_) => Some(ResponseCode::TryLater.as_str()), + _ => None, + }) + { + buf.extend_from_slice(b" ("); + buf.extend_from_slice(code.as_bytes()); + buf.push(b')'); + } + let message = self + .value_as_str(trc::Key::Details) + .unwrap_or_else(|| self.as_ref().message()); + buf.extend_from_slice(b" \""); + for ch in message.as_bytes() { + if b"\"\\".contains(ch) { + buf.push(b'\\'); + } + buf.push(*ch); + } + buf.push(b'\"'); + buf.extend_from_slice(b"\r\n"); + buf + } +} + +impl From for trc::Value { + fn from(value: ResponseCode) -> Self { + trc::Value::String(CompactString::const_new(value.as_str())) + } +} + +impl From for trc::Value { + fn from(value: ResponseType) -> Self { + trc::Value::String(CompactString::const_new(value.as_str())) + } +} diff --git a/crates/managesieve/src/core/session.rs b/crates/managesieve/src/core/session.rs new file mode 100644 index 0000000..4de2f93 --- /dev/null +++ b/crates/managesieve/src/core/session.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ManageSieveSessionManager, Session, State}; +use crate::SERVER_GREETING; +use common::{ + BuildServer, + network::{SessionData, SessionManager, SessionResult, SessionStream}, +}; +use imap_proto::receiver::{self, Receiver}; +use tokio_rustls::server::TlsStream; + +impl SessionManager for ManageSieveSessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + // Create session + let server = self.inner.build_server(); + let mut session = Session { + receiver: Receiver::with_max_request_size(server.core.imap.max_request_size) + .with_start_state(receiver::State::Command { is_uid: false }), + server, + instance: session.instance, + state: State::NotAuthenticated { auth_failures: 0 }, + session_id: session.session_id, + stream: session.stream, + in_flight: session.in_flight, + remote_addr: session.remote_ip, + }; + + if session + .write(&session.handle_capability(SERVER_GREETING).await.unwrap()) + .await + .is_ok() + && session.handle_conn().await + && session.instance.acceptor.is_tls() + && let Ok(mut session) = session.into_tls().await + { + let _ = session + .write(&session.handle_capability(SERVER_GREETING).await.unwrap()) + .await; + session.handle_conn().await; + } + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} + +impl Session { + pub async fn handle_conn(&mut self) -> bool { + let mut buf = vec![0; 8192]; + let mut shutdown_rx = self.instance.shutdown_rx.clone(); + + loop { + tokio::select! { + result = tokio::time::timeout( + if !matches!(self.state, State::NotAuthenticated {..}) { + self.server.core.imap.timeout_auth + } else { + self.server.core.imap.timeout_unauth + }, + self.read(&mut buf)) => { + match result { + Ok(Ok(bytes_read)) => { + if bytes_read > 0 { + match self.ingest(&buf[..bytes_read]).await { + SessionResult::Continue => (), + SessionResult::UpgradeTls => { + return true; + } + SessionResult::Close => { + break; + } + } + } else { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + break; + } + } + Ok(Err(err)) => { + trc::event!( + Network(trc::NetworkEvent::ReadError), + SpanId = self.session_id, + Reason = err, + CausedBy = trc::location!() + ); + break; + } + Err(_) => { + trc::event!( + Network(trc::NetworkEvent::Timeout), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + self + .write(b"BYE \"Connection timed out.\"\r\n") + .await + .ok(); + break; + } + } + }, + _ = shutdown_rx.changed() => { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + Reason = "Server shutting down", + CausedBy = trc::location!() + ); + self.write(b"BYE \"Server shutting down.\"\r\n").await.ok(); + break; + } + }; + } + + false + } + + pub async fn into_tls(self) -> Result>, ()> { + let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size) + .with_start_state(receiver::State::Command { is_uid: false }); + + Ok(Session { + stream: self + .instance + .tls_accept(self.stream, self.session_id) + .await?, + state: self.state, + instance: self.instance, + in_flight: self.in_flight, + session_id: self.session_id, + server: self.server, + receiver, + remote_addr: self.remote_addr, + }) + } +} diff --git a/crates/managesieve/src/lib.rs b/crates/managesieve/src/lib.rs new file mode 100644 index 0000000..0237a69 --- /dev/null +++ b/crates/managesieve/src/lib.rs @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod core; +pub mod op; + +static SERVER_GREETING: &str = "Stalwart ManageSieve at your service."; + +#[cfg(test)] +mod tests { + use imap_proto::receiver::{Error, Receiver, Request, State, Token}; + + use crate::core::Command; + + #[test] + fn receiver_parse_managesieve() { + let mut receiver = Receiver::new().with_start_state(State::Command { is_uid: false }); + + for (frames, expected_requests) in [ + ( + vec!["Authenticate \"DIGEST-MD5\"\r\n"], + vec![Request { + tag: "".into(), + command: Command::Authenticate, + tokens: vec![Token::Argument(b"DIGEST-MD5".to_vec())], + }], + ), + ( + vec![ + " AUTHENTICATE \"GSSAPI\" {56+}\r\n", + "cnNwYXV0aD1lYTQwZjYwMzM1YzQyN2I1NTI3Yjg0ZGJhYmNkZmZmZA==\r\n", + ], + vec![Request { + tag: "".into(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"GSSAPI".to_vec()), + Token::Argument( + b"cnNwYXV0aD1lYTQwZjYwMzM1YzQyN2I1NTI3Yjg0ZGJhYmNkZmZmZA==".to_vec(), + ), + ], + }], + ), + ( + vec!["Authenticate \"PLAIN\" \"QJIrweAPyo6Q1T9xu\"\r\n"], + vec![Request { + tag: "".into(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"PLAIN".to_vec()), + Token::Argument(b"QJIrweAPyo6Q1T9xu".to_vec()), + ], + }], + ), + ( + vec!["StartTls\r\n"], + vec![Request { + tag: "".into(), + command: Command::StartTls, + tokens: vec![], + }], + ), + ( + vec!["HAVESPACE \"myscript\" 999999\r\n"], + vec![Request { + tag: "".into(), + command: Command::HaveSpace, + tokens: vec![ + Token::Argument(b"myscript".to_vec()), + Token::Argument(b"999999".to_vec()), + ], + }], + ), + ( + vec![ + "Putscript \"foo\" {31+}\r\n", + "#comment\r\n", + "InvalidSieveCommand\r\n\r\n", + ], + vec![Request { + tag: "".into(), + command: Command::PutScript, + tokens: vec![ + Token::Argument(b"foo".to_vec()), + Token::Argument(b"#comment\r\nInvalidSieveCommand\r\n".to_vec()), + ], + }], + ), + ( + vec!["Listscripts\r\n"], + vec![Request { + tag: "".into(), + command: Command::ListScripts, + tokens: vec![], + }], + ), + ( + vec!["Setactive \"baz\"\r\n"], + vec![Request { + tag: "".into(), + command: Command::SetActive, + tokens: vec![Token::Argument(b"baz".to_vec())], + }], + ), + ( + vec!["Renamescript \"foo\" \"bar\"\r\n"], + vec![Request { + tag: "".into(), + command: Command::RenameScript, + tokens: vec![ + Token::Argument(b"foo".to_vec()), + Token::Argument(b"bar".to_vec()), + ], + }], + ), + ( + vec!["NOOP \"STARTTLS-SYNC-42\"\r\n"], + vec![Request { + tag: "".into(), + command: Command::Noop, + tokens: vec![Token::Argument(b"STARTTLS-SYNC-42".to_vec())], + }], + ), + ] { + let mut requests = Vec::new(); + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(err) => panic!("{:?} for frames {:#?}", err, frames), + } + } + } + assert_eq!(requests, expected_requests, "{:#?}", frames); + } + } +} diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs new file mode 100644 index 0000000..3f117cf --- /dev/null +++ b/crates/managesieve/src/op/authenticate.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Command, Session, State, StatusResponse}; +use common::{ + auth::AuthRequest, + network::{SessionStream, limiter::LimiterResult}, +}; +use directory::Credentials; +use imap_proto::{ + protocol::authenticate::Mechanism, + receiver::{self, Request}, +}; +use mail_parser::decoders::base64::base64_decode; +use registry::schema::enums::Permission; + +impl Session { + pub async fn handle_authenticate(&mut self, request: Request) -> trc::Result> { + if request.tokens.is_empty() { + return Err(trc::AuthEvent::Error + .into_err() + .details("Authentication mechanism missing.")); + } + + let mut tokens = request.tokens.into_iter(); + let mechanism = Mechanism::parse(&tokens.next().unwrap().unwrap_bytes()) + .map_err(|err| trc::AuthEvent::Error.into_err().details(err))?; + let mut params: Vec = tokens + .filter_map(|token| token.unwrap_string().ok()) + .collect(); + + let credentials = match mechanism { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => { + if !params.is_empty() { + base64_decode(params.pop().unwrap().as_bytes()) + .and_then(|challenge| { + if mechanism == Mechanism::Plain { + Credentials::decode_sasl_challenge_plain(&challenge) + } else { + Credentials::decode_sasl_challenge_oauth(&challenge) + } + }) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Failed to decode challenge.") + })? + } else { + self.receiver.request = receiver::Request { + tag: "".into(), + command: Command::Authenticate, + tokens: vec![receiver::Token::Argument(mechanism.into_bytes())], + }; + self.receiver.state = receiver::State::Argument { last_ch: b' ' }; + return Ok(b"{0}\r\n".to_vec()); + } + } + _ => { + return Err(trc::AuthEvent::Error + .into_err() + .details("Authentication mechanism not supported.")); + } + }; + + // Authenticate + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + match &self.state { + State::NotAuthenticated { auth_failures } + if *auth_failures < self.server.core.imap.max_auth_failures => + { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + }; + } + _ => { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } + } + } + + err + }) + .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; + + // Enforce concurrency limits + let in_flight = match access_token.is_imap_request_allowed() { + LimiterResult::Allowed(in_flight) => Some(in_flight), + LimiterResult::Forbidden => { + return Err(trc::LimitEvent::ConcurrentRequest.into_err()); + } + LimiterResult::Disabled => None, + }; + + // Create session + self.state = State::Authenticated { + access_token, + in_flight, + }; + + Ok(StatusResponse::ok("Authentication successful").into_bytes()) + } + + pub async fn handle_unauthenticate(&mut self) -> trc::Result> { + self.state = State::NotAuthenticated { auth_failures: 0 }; + + trc::event!( + ManageSieve(trc::ManageSieveEvent::Unauthenticate), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + Ok(StatusResponse::ok("Unauthenticate successful.").into_bytes()) + } +} diff --git a/crates/managesieve/src/op/capability.rs b/crates/managesieve/src/op/capability.rs new file mode 100644 index 0000000..c0ceff6 --- /dev/null +++ b/crates/managesieve/src/op/capability.rs @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Session, StatusResponse}; +use common::network::SessionStream; +use jmap_proto::request::capability::Capabilities; +use std::time::Instant; + +impl Session { + pub async fn handle_capability(&self, message: &'static str) -> trc::Result> { + let op_start = Instant::now(); + + let mut response = Vec::with_capacity(128); + response.extend_from_slice(b"\"IMPLEMENTATION\" \"Stalwart ManageSieve\"\r\n"); + response.extend_from_slice(b"\"VERSION\" \"1.0\"\r\n"); + if !self.stream.is_tls() { + response.extend_from_slice(b"\"STARTTLS\"\r\n"); + } + if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + response.extend_from_slice(b"\"SASL\" \"PLAIN OAUTHBEARER XOAUTH2\"\r\n"); + } else { + response.extend_from_slice(b"\"SASL\" \"OAUTHBEARER XOAUTH2\"\r\n"); + }; + if let Some(sieve) = + self.server + .core + .jmap + .capabilities + .account + .iter() + .find_map(|(_, item)| { + if let Capabilities::SieveAccount(sieve) = item { + Some(sieve) + } else { + None + } + }) + { + response.extend_from_slice(b"\"SIEVE\" \""); + response.extend_from_slice(sieve.extensions.join(" ").as_bytes()); + response.extend_from_slice(b"\"\r\n"); + if let Some(notification_methods) = &sieve.notification_methods { + response.extend_from_slice(b"\"NOTIFY\" \""); + response.extend_from_slice(notification_methods.join(" ").as_bytes()); + response.extend_from_slice(b"\"\r\n"); + } + if sieve.max_redirects > 0 { + response.extend_from_slice(b"\"MAXREDIRECTS\" \""); + response.extend_from_slice(sieve.max_redirects.to_string().as_bytes()); + response.extend_from_slice(b"\"\r\n"); + } + } else { + response.extend_from_slice(b"\"SIEVE\" \"\"\r\n"); + } + + trc::event!( + ManageSieve(trc::ManageSieveEvent::Capabilities), + SpanId = self.session_id, + Tls = self.stream.is_tls(), + Strict = !self.server.core.imap.allow_plain_auth, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok(message).serialize(response)) + } +} diff --git a/crates/managesieve/src/op/checkscript.rs b/crates/managesieve/src/op/checkscript.rs new file mode 100644 index 0000000..a07b453 --- /dev/null +++ b/crates/managesieve/src/op/checkscript.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::network::SessionStream; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; + +use crate::core::{Command, Session, StatusResponse}; + +impl Session { + pub async fn handle_checkscript(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveCheckScript)?; + + let op_start = Instant::now(); + + if request.tokens.is_empty() { + return Err(trc::ManageSieveEvent::Error + .into_err() + .details("Expected script as a parameter.")); + } + + let script = request.tokens.into_iter().next().unwrap().unwrap_bytes(); + self.server + .core + .sieve + .untrusted_compiler + .compile(&script) + .map(|_| { + trc::event!( + ManageSieve(trc::ManageSieveEvent::CheckScript), + SpanId = self.session_id, + Size = script.len(), + Elapsed = op_start.elapsed() + ); + + StatusResponse::ok("Script is valid.").into_bytes() + }) + .map_err(|err| { + trc::ManageSieveEvent::Error + .into_err() + .details(err.to_string()) + }) + } +} diff --git a/crates/managesieve/src/op/deletescript.rs b/crates/managesieve/src/op/deletescript.rs new file mode 100644 index 0000000..5b720ae --- /dev/null +++ b/crates/managesieve/src/op/deletescript.rs @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; +use common::network::SessionStream; +use email::sieve::{delete::SieveScriptDelete, ingest::SieveScriptIngest}; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::write::BatchBuilder; +use trc::AddContext; + +impl Session { + pub async fn handle_deletescript(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveDeleteScript)?; + + let op_start = Instant::now(); + + let name = request + .tokens + .into_iter() + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script name as a parameter.") + })?; + + let access_token = self.state.access_token(); + let account_id = access_token.account_id(); + let document_id = self.get_script_id(account_id, &name).await?; + let mut batch = BatchBuilder::new(); + + let active_script_id = self.server.sieve_script_get_active_id(account_id).await?; + if active_script_id != Some(document_id) { + if self + .server + .sieve_script_delete(account_id, document_id, access_token, &mut batch) + .await + .caused_by(trc::location!())? + { + if !batch.is_empty() { + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + trc::event!( + ManageSieve(trc::ManageSieveEvent::DeleteScript), + SpanId = self.session_id, + Id = name, + DocumentId = document_id, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("Deleted.").into_bytes()) + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Script not found")) + } + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("You may not delete an active script") + .code(ResponseCode::Active)) + } + } +} diff --git a/crates/managesieve/src/op/getscript.rs b/crates/managesieve/src/op/getscript.rs new file mode 100644 index 0000000..5895f5c --- /dev/null +++ b/crates/managesieve/src/op/getscript.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; +use common::network::SessionStream; +use email::sieve::SieveScript; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{blob::BlobSection, blob_hash::BlobHash, collection::Collection}; + +impl Session { + pub async fn handle_getscript(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveGetScript)?; + + let op_start = Instant::now(); + let name = request + .tokens + .into_iter() + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script name as a parameter.") + })?; + let account_id = self.state.access_token().account_id(); + let document_id = self.get_script_id(account_id, &name).await?; + let sieve_ = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Script not found") + .code(ResponseCode::NonExistent) + })?; + let sieve = sieve_ + .unarchive::() + .caused_by(trc::location!())?; + let blob_size = u32::from(sieve.size) as usize; + let script = self + .server + .get_blob_section( + &BlobHash::from(&sieve.blob_hash), + &BlobSection { + size: blob_size, + ..Default::default() + }, + ) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Script blob not found") + .code(ResponseCode::NonExistent) + })?; + debug_assert_eq!(script.len(), blob_size); + + let mut response = Vec::with_capacity(script.len() + 32); + response.push(b'{'); + response.extend_from_slice(blob_size.to_string().as_bytes()); + response.extend_from_slice(b"}\r\n"); + response.extend(script); + response.extend_from_slice(b"\r\n"); + + trc::event!( + ManageSieve(trc::ManageSieveEvent::GetScript), + SpanId = self.session_id, + Id = name, + DocumentId = document_id, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("").serialize(response)) + } +} diff --git a/crates/managesieve/src/op/havespace.rs b/crates/managesieve/src/op/havespace.rs new file mode 100644 index 0000000..48bdf5b --- /dev/null +++ b/crates/managesieve/src/op/havespace.rs @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::network::SessionStream; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; +use trc::AddContext; + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; + +impl Session { + pub async fn handle_havespace(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveHaveSpace)?; + + let op_start = Instant::now(); + let mut tokens = request.tokens.into_iter(); + let name = tokens + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script name as a parameter.") + })?; + let size: usize = tokens + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script size as a parameter.") + })? + .parse::() + .map_err(|_| { + trc::ManageSieveEvent::Error + .into_err() + .details("Invalid size parameter.") + })?; + + // Validate name + let account_id = self.state.access_token().account_id(); + let account = self.server.account(account_id).await?; + self.validate_name(account_id, &name).await?; + + // Validate quota + if account.disk_quota() == 0 + || size as i64 + + self + .server + .get_used_quota_account(account_id) + .await + .caused_by(trc::location!())? + <= account.disk_quota() as i64 + { + trc::event!( + ManageSieve(trc::ManageSieveEvent::HaveSpace), + SpanId = self.session_id, + Size = size, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("").into_bytes()) + } else { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Quota exceeded.") + .code(ResponseCode::QuotaMaxSize)) + } + } +} diff --git a/crates/managesieve/src/op/listscripts.rs b/crates/managesieve/src/op/listscripts.rs new file mode 100644 index 0000000..4d09f29 --- /dev/null +++ b/crates/managesieve/src/op/listscripts.rs @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Session, StatusResponse}; +use common::network::SessionStream; +use email::sieve::{SieveScript, ingest::SieveScriptIngest}; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{collection::Collection, field::SieveField}; + +impl Session { + pub async fn handle_listscripts(&mut self) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveListScripts)?; + + let op_start = Instant::now(); + let account_id = self.state.access_token().account_id(); + let document_ids = self + .server + .document_ids(account_id, Collection::SieveScript, SieveField::Name) + .await + .caused_by(trc::location!())?; + + if document_ids.is_empty() { + return Ok(StatusResponse::ok("").into_bytes()); + } + + let mut response = Vec::with_capacity(128); + let count = document_ids.len(); + let active_script_id = self.server.sieve_script_get_active_id(account_id).await?; + + for document_id in document_ids { + if let Some(script_) = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let script = script_ + .unarchive::() + .caused_by(trc::location!())?; + response.push(b'\"'); + for ch in script.name.as_bytes() { + if b"\\\"".contains(ch) { + response.push(b'\\'); + } + response.push(*ch); + } + if active_script_id == Some(document_id) { + response.extend_from_slice(b"\" ACTIVE\r\n"); + } else { + response.extend_from_slice(b"\"\r\n"); + } + } + } + + trc::event!( + ManageSieve(trc::ManageSieveEvent::ListScripts), + SpanId = self.session_id, + Total = count, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("").serialize(response)) + } +} diff --git a/crates/managesieve/src/op/logout.rs b/crates/managesieve/src/op/logout.rs new file mode 100644 index 0000000..e32240f --- /dev/null +++ b/crates/managesieve/src/op/logout.rs @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::core::{Session, StatusResponse}; + +impl Session { + pub async fn handle_logout(&mut self) -> trc::Result> { + trc::event!( + ManageSieve(trc::ManageSieveEvent::Logout), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + Ok(StatusResponse::ok("Stalwart ManageSieve bids you farewell.").into_bytes()) + } +} diff --git a/crates/managesieve/src/op/mod.rs b/crates/managesieve/src/op/mod.rs new file mode 100644 index 0000000..f8a54dc --- /dev/null +++ b/crates/managesieve/src/op/mod.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Session, State, StatusResponse}; +use common::network::SessionStream; +use registry::schema::enums::Permission; + +pub mod authenticate; +pub mod capability; +pub mod checkscript; +pub mod deletescript; +pub mod getscript; +pub mod havespace; +pub mod listscripts; +pub mod logout; +pub mod noop; +pub mod putscript; +pub mod renamescript; +pub mod setactive; + +impl Session { + pub async fn handle_start_tls(&self) -> trc::Result> { + trc::event!( + ManageSieve(trc::ManageSieveEvent::StartTls), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + Ok(StatusResponse::ok("Begin TLS negotiation now").into_bytes()) + } + + pub fn assert_has_permission(&self, permission: Permission) -> trc::Result { + match &self.state { + State::Authenticated { access_token, .. } => { + access_token.enforce_permission(permission).map(|_| true) + } + State::NotAuthenticated { .. } => Ok(false), + } + } +} diff --git a/crates/managesieve/src/op/noop.rs b/crates/managesieve/src/op/noop.rs new file mode 100644 index 0000000..c07aaaf --- /dev/null +++ b/crates/managesieve/src/op/noop.rs @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use imap_proto::receiver::Request; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; + +impl Session { + pub async fn handle_noop(&mut self, request: Request) -> trc::Result> { + trc::event!( + ManageSieve(trc::ManageSieveEvent::Noop), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + Ok(if let Some(tag) = request + .tokens + .into_iter() + .next() + .and_then(|t| t.unwrap_string().ok()) + { + StatusResponse::ok("Done").with_code(ResponseCode::Tag(tag)) + } else { + StatusResponse::ok("Done") + } + .into_bytes()) + } +} diff --git a/crates/managesieve/src/op/putscript.rs b/crates/managesieve/src/op/putscript.rs new file mode 100644 index 0000000..4752c23 --- /dev/null +++ b/crates/managesieve/src/op/putscript.rs @@ -0,0 +1,241 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::sieve::SieveScript; +use imap_proto::receiver::Request; +use registry::schema::enums::{Permission, StorageQuota}; +use sieve::compiler::ErrorType; +use std::time::Instant; +use store::{ + Serialize, ValueKey, + write::{AlignedBytes, Archive, Archiver, BatchBuilder}, +}; +use trc::AddContext; +use types::{collection::Collection, field::SieveField}; + +impl Session { + pub async fn handle_putscript(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SievePutScript)?; + + let op_start = Instant::now(); + let mut tokens = request.tokens.into_iter(); + let name = tokens + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script name as a parameter.") + })? + .trim() + .to_string(); + let mut script_bytes = tokens + .next() + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script as a parameter.") + })? + .unwrap_bytes(); + let script_size = script_bytes.len() as i64; + + // Check quota + let access_token = self.state.access_token(); + let account_id = access_token.account_id(); + let account = self.server.account(account_id).await?; + self.server + .has_available_quota(&account, script_bytes.len() as u64) + .await + .caused_by(trc::location!())?; + + if self + .server + .document_ids(account_id, Collection::SieveScript, SieveField::Name) + .await + .caused_by(trc::location!())? + .len() + >= self + .server + .object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) + as u64 + { + return Err(trc::ManageSieveEvent::Error + .into_err() + .details("Too many scripts.") + .code(ResponseCode::QuotaMaxScripts)); + } + + // Compile script + match self + .server + .core + .sieve + .untrusted_compiler + .compile(&script_bytes) + { + Ok(compiled_script) => { + script_bytes.extend( + Archiver::new(compiled_script) + .untrusted() + .serialize() + .caused_by(trc::location!())?, + ); + } + Err(err) => { + return Err(if let ErrorType::ScriptTooLong = &err.error_type() { + trc::ManageSieveEvent::Error + .into_err() + .details(err.to_string()) + .code(ResponseCode::QuotaMaxSize) + } else { + trc::ManageSieveEvent::Error + .into_err() + .details(err.to_string()) + }); + } + } + + // Validate name + if let Some(document_id) = self.validate_name(account_id, &name).await? { + // Obtain script values + let script_ = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Script not found") + .code(ResponseCode::NonExistent) + })?; + let script = script_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Write script blob + let (blob_hash, blob_hold) = self + .server + .put_temporary_blob(account_id, &script_bytes, 60) + .await?; + + // Write record + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_changes( + script + .deserialize() + .caused_by(trc::location!())? + .with_size(script_size as u32) + .with_blob_hash(blob_hash.clone()), + ) + .with_current(script) + .with_changed_by(account.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .clear(blob_hold); + + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + trc::event!( + ManageSieve(trc::ManageSieveEvent::UpdateScript), + SpanId = self.session_id, + Id = name.to_string(), + DocumentId = document_id, + Size = script_size, + Elapsed = op_start.elapsed(), + ); + } else { + // Write script blob + let (blob_hash, blob_hold) = self + .server + .put_temporary_blob(account_id, &script_bytes, 60) + .await?; + + // Write record + let mut batch = BatchBuilder::new(); + let document_id = self + .server + .store() + .assign_document_ids(account_id, Collection::SieveScript, 1) + .await + .caused_by(trc::location!())?; + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .custom( + ObjectIndexBuilder::<(), _>::new() + .with_changes( + SieveScript::new(name.clone(), blob_hash.clone()) + .with_size(script_size as u32), + ) + .with_changed_by(account.account_tenant_ids()), + ) + .caused_by(trc::location!())? + .clear(blob_hold); + + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + trc::event!( + ManageSieve(trc::ManageSieveEvent::CreateScript), + SpanId = self.session_id, + Id = name, + DocumentId = document_id, + Elapsed = op_start.elapsed() + ); + } + + Ok(StatusResponse::ok("Success.").into_bytes()) + } + + pub async fn validate_name(&self, account_id: u32, name: &str) -> trc::Result> { + if name.is_empty() { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Script name cannot be empty.")) + } else if name.len() > self.server.core.email.sieve_max_script_name { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("Script name is too long.")) + } else if name.eq_ignore_ascii_case("vacation") { + Err(trc::ManageSieveEvent::Error + .into_err() + .details("The 'vacation' name is reserved, please use a different name.")) + } else { + Ok(self + .server + .document_ids_matching( + account_id, + Collection::SieveScript, + SieveField::Name, + name.to_lowercase().as_bytes(), + ) + .await + .caused_by(trc::location!())? + .min()) + } + } +} diff --git a/crates/managesieve/src/op/renamescript.rs b/crates/managesieve/src/op/renamescript.rs new file mode 100644 index 0000000..fef3ce1 --- /dev/null +++ b/crates/managesieve/src/op/renamescript.rs @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::{Command, ResponseCode, Session, StatusResponse}; +use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; +use email::sieve::SieveScript; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use trc::AddContext; +use types::collection::Collection; + +impl Session { + pub async fn handle_renamescript(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveRenameScript)?; + + let op_start = Instant::now(); + let mut tokens = request.tokens.into_iter(); + let name = tokens + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected old script name as a parameter.") + })? + .trim() + .to_string(); + let new_name = tokens + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected new script name as a parameter.") + })? + .trim() + .to_string(); + + // Validate name + if name == new_name { + return Ok(StatusResponse::ok("Old and new script names are the same.").into_bytes()); + } + let account_id = self.state.access_token().account_id(); + let document_id = self.get_script_id(account_id, &name).await?; + if self.validate_name(account_id, &new_name).await?.is_some() { + return Err(trc::ManageSieveEvent::Error + .into_err() + .details(format!("A sieve script with name '{name}' already exists.",)) + .code(ResponseCode::AlreadyExists)); + } + + // Obtain script values + let script = self + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::SieveScript, + document_id, + )) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Script not found") + .code(ResponseCode::NonExistent) + })? + .into_deserialized::() + .caused_by(trc::location!())?; + + // Write record + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::SieveScript) + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_changes(script.inner.clone().with_name(new_name.clone())) + .with_current(script), + ) + .caused_by(trc::location!())?; + if !batch.is_empty() { + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + } + + trc::event!( + ManageSieve(trc::ManageSieveEvent::RenameScript), + SpanId = self.session_id, + Id = new_name, + DocumentId = document_id, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("Success.").into_bytes()) + } +} diff --git a/crates/managesieve/src/op/setactive.rs b/crates/managesieve/src/op/setactive.rs new file mode 100644 index 0000000..1cb74cd --- /dev/null +++ b/crates/managesieve/src/op/setactive.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::network::SessionStream; +use imap_proto::receiver::Request; +use registry::schema::enums::Permission; +use store::{SerializeInfallible, write::BatchBuilder}; +use trc::AddContext; +use types::{collection::Collection, field::PrincipalField}; + +use crate::core::{Command, Session, StatusResponse}; + +impl Session { + pub async fn handle_setactive(&mut self, request: Request) -> trc::Result> { + // Validate access + self.assert_has_permission(Permission::SieveSetActive)?; + + let op_start = Instant::now(); + let name = request + .tokens + .into_iter() + .next() + .and_then(|s| s.unwrap_string().ok()) + .ok_or_else(|| { + trc::ManageSieveEvent::Error + .into_err() + .details("Expected script name as a parameter.") + })?; + + // De/activate script + let account_id = self.state.access_token().account_id(); + let mut batch = BatchBuilder::new(); + if !name.is_empty() { + let document_id = self.get_script_id(account_id, &name).await?; + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .set(PrincipalField::ActiveScriptId, document_id.serialize()); + } else { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::ActiveScriptId); + } + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + + trc::event!( + ManageSieve(trc::ManageSieveEvent::SetActive), + SpanId = self.session_id, + Id = name, + Elapsed = op_start.elapsed() + ); + + Ok(StatusResponse::ok("Success").into_bytes()) + } +} diff --git a/crates/migration/Cargo.toml b/crates/migration/Cargo.toml new file mode 100644 index 0000000..d6b2186 --- /dev/null +++ b/crates/migration/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "migration" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +trc = { path = "../trc" } +types = { path = "../types" } +common = { path = "../common" } +email = { path = "../email" } +spam-filter = { path = "../spam-filter" } +registry = { path = "../registry" } +tokio = { version = "1.53", features = ["net", "macros"] } + +[features] +test_mode = [] +enterprise = [] + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/migration/src/destroy.rs b/crates/migration/src/destroy.rs new file mode 100644 index 0000000..2477d14 --- /dev/null +++ b/crates/migration/src/destroy.rs @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use store::{ + Store, U64_LEN, + write::{AnyKey, key::KeySerializer}, +}; +use trc::AddContext; + +pub async fn destroy_subspace(store: &Store, subspace: u8) -> trc::Result<()> { + store + .delete_range( + AnyKey { + subspace, + key: KeySerializer::new(U64_LEN).write(0u8).finalize(), + }, + AnyKey { + subspace, + key: KeySerializer::new(U64_LEN) + .write(&[u8::MAX; 64][..]) + .finalize(), + }, + ) + .await + .caused_by(trc::location!()) +} diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs new file mode 100644 index 0000000..3066afb --- /dev/null +++ b/crates/migration/src/lib.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use crate::v016::migrate_v0_16; +use common::{DATABASE_SCHEMA_VERSION, Server}; +use store::{ + IterateParams, SUBSPACE_PROPERTY, SUBSPACE_QUEUE_MESSAGE, SUBSPACE_REPORT_IN, + SUBSPACE_REPORT_OUT, SerializeInfallible, + write::{AnyClass, AnyKey, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +pub mod destroy; +pub mod v016; + +pub async fn try_migrate(server: &Server) -> trc::Result<()> { + match server + .store() + .get_value::(AnyKey { + subspace: SUBSPACE_PROPERTY, + key: vec![0u8], + }) + .await + .caused_by(trc::location!())? + { + Some(DATABASE_SCHEMA_VERSION) => { + if !std::env::var("DANGER_FORCE_MIGRATE").is_ok_and(|v| v == "1") { + return Ok(()); + } + } + Some(0..=4) => { + abort(concat!( + "You must first upgrade to version 0.15, please read ", + "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" + )); + } + Some(5) => { + if !server.registry().is_recovery_mode() { + abort(concat!( + "Upgrading to version 0.16 is a multi-step process, please read ", + "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" + )); + } + } + + Some(version) => { + panic!( + "Unknown database schema version, expected {} or below, found {}", + DATABASE_SCHEMA_VERSION, version + ); + } + _ => { + if is_new_install(server).await.caused_by(trc::location!())? { + write_schema_version(server).await?; + return Ok(()); + } else { + abort(concat!( + "You must first upgrade to version 0.15, please read ", + "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" + )); + } + } + } + + migrate_v0_16(server).await?; + write_schema_version(server).await +} + +async fn write_schema_version(server: &Server) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set( + ValueClass::Any(AnyClass { + subspace: SUBSPACE_PROPERTY, + key: vec![0u8], + }), + DATABASE_SCHEMA_VERSION.serialize(), + ); + + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + Ok(()) +} + +fn abort(message: &str) -> ! { + eprintln!("Migration aborted: {message}"); + panic!("Migration aborted: {message}"); +} + +async fn is_new_install(server: &Server) -> trc::Result { + for subspace in [ + SUBSPACE_QUEUE_MESSAGE, + SUBSPACE_REPORT_IN, + SUBSPACE_REPORT_OUT, + SUBSPACE_PROPERTY, + ] { + let mut has_data = false; + + server + .store() + .iterate( + IterateParams::new( + AnyKey { + subspace, + key: vec![0u8], + }, + AnyKey { + subspace, + key: vec![u8::MAX; 16], + }, + ) + .no_values(), + |_, _| { + has_data = true; + + Ok(false) + }, + ) + .await + .caused_by(trc::location!())?; + + if has_data { + return Ok(false); + } + } + + Ok(true) +} diff --git a/crates/migration/src/v016.rs b/crates/migration/src/v016.rs new file mode 100644 index 0000000..8614d23 --- /dev/null +++ b/crates/migration/src/v016.rs @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::destroy::destroy_subspace; +use common::{Server, manager::SPAM_TRAINER_KEY}; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{ArchivedEmail, ArchivedItem}, + }, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId}, +}; +use spam_filter::modules::classifier::SpamTrainer; +use store::{ + Deserialize, IterateParams, SUBSPACE_BLOB_LINK, SUBSPACE_DIRECTORY, SUBSPACE_QUOTA, + SUBSPACE_REPORT_IN, SUBSPACE_REPORT_OUT, SUBSPACE_TASK_QUEUE, SUBSPACE_TELEMETRY_METRIC, + SUBSPACE_TELEMETRY_SPAN, Serialize, SerializeInfallible, U32_LEN, U64_LEN, + search::{SearchField, SearchFilter, SearchQuery}, + write::{ + AlignedBytes, AnyClass, AnyKey, Archive, Archiver, BatchBuilder, RegistryClass, + SearchIndex, ValueClass, key::DeserializeBigEndian, now, + }, +}; +use trc::AddContext; +use types::{blob::BlobId, blob_hash::BLOB_HASH_LEN}; + +const LEGACY_SUBSPACE_BLOB_EXTRA: u8 = b'j'; // Now SUBSPACE_DELETED_ITEMS +const LEGACY_SUBSPACE_BITMAP_ID: u8 = b'b'; // Now SUBSPACE_REGISTRY_IDX +const LEGACY_SUBSPACE_SETTINGS: u8 = b's'; // Now SUBSPACE_REGISTRY +const LEGACY_SUBSPACE_FTS_INDEX: u8 = b'g'; // Now SUBSPACE_REGISTRY_PK +const LEGACY_SUBSPACE_TELEMETRY_INDEX: u8 = b'w'; // Now SUBSPACE_SPAM_SAMPLES + +pub async fn migrate_v0_16(server: &Server) -> trc::Result<()> { + // Delete tracing index + server + .search_store() + .unindex( + SearchQuery::new(SearchIndex::Tracing) + .with_filter(SearchFilter::lt(SearchField::Id, u64::MAX)), + ) + .await + .caused_by(trc::location!())?; + + // Delete old quotas + server + .store() + .delete_range( + AnyKey { + subspace: SUBSPACE_QUOTA, + key: vec![0x04], + }, + AnyKey { + subspace: SUBSPACE_QUOTA, + key: vec![0x05], + }, + ) + .await + .caused_by(trc::location!())?; + + // Destroy old and incompatible subspaces + for namespace in [ + LEGACY_SUBSPACE_BLOB_EXTRA, + LEGACY_SUBSPACE_TELEMETRY_INDEX, + LEGACY_SUBSPACE_SETTINGS, + LEGACY_SUBSPACE_BITMAP_ID, + LEGACY_SUBSPACE_FTS_INDEX, + SUBSPACE_REPORT_IN, + SUBSPACE_REPORT_OUT, + SUBSPACE_DIRECTORY, + SUBSPACE_TELEMETRY_METRIC, + SUBSPACE_TELEMETRY_SPAN, + SUBSPACE_TASK_QUEUE, + ] { + destroy_subspace(server.store(), namespace) + .await + .caused_by(trc::location!())?; + } + + // Migrate blob links + migrate_blob_links(server).await?; + + // Migrate spam model + migrate_spam_model(server).await?; + + Ok(()) +} + +async fn migrate_spam_model(server: &Server) -> trc::Result<()> { + let Some(mut trainer) = server + .blob_store() + .get_blob(SPAM_TRAINER_KEY, 0..usize::MAX) + .await + .and_then(|archive| match archive { + Some(archive) => as Deserialize>::deserialize(&archive) + .and_then(|archive| archive.deserialize_untrusted::()) + .map(Some), + None => Ok(None), + }) + .caused_by(trc::location!())? + else { + return Ok(()); + }; + + if trainer.last_id == 0 { + return Ok(()); + } + + if let Some(config) = &server.core.spam.classifier { + trainer.reservoir.ham.total_seen = std::cmp::min( + config.min_ham_samples + config.reservoir_capacity as u64, + trainer.reservoir.ham.total_seen, + ); + trainer.reservoir.spam.total_seen = std::cmp::min( + config.min_spam_samples + config.reservoir_capacity as u64, + trainer.reservoir.spam.total_seen, + ); + } else { + trainer.reservoir.ham.total_seen = std::cmp::min( + trainer.reservoir.ham.buffer.len() as u64, + trainer.reservoir.ham.total_seen, + ); + trainer.reservoir.spam.total_seen = std::cmp::min( + trainer.reservoir.spam.buffer.len() as u64, + trainer.reservoir.spam.total_seen, + ); + } + + trainer.reservoir.ham.buffer.clear(); + trainer.reservoir.spam.buffer.clear(); + trainer.last_id = 0; + + server + .blob_store() + .put_blob( + SPAM_TRAINER_KEY, + &Archiver::new(trainer) + .serialize() + .caused_by(trc::location!())?, + server.core.email.compression, + ) + .await + .caused_by(trc::location!())?; + + Ok(()) +} + +async fn migrate_blob_links(server: &Server) -> trc::Result<()> { + let mut delete_keys = Vec::new(); + let mut archived_items = Vec::new(); + let now = now(); + + server + .store() + .iterate( + IterateParams::new( + AnyKey { + subspace: SUBSPACE_BLOB_LINK, + key: vec![0u8], + }, + AnyKey { + subspace: SUBSPACE_BLOB_LINK, + key: vec![u8::MAX; 32], + }, + ), + |key, value| { + const TEMP_LINK: usize = BLOB_HASH_LEN + U32_LEN + U64_LEN; + + const QUOTA_LINK: u8 = 0; + const UNDELETE_LINK: u8 = 1; + const SPAM_SAMPLE_LINK: u8 = 2; + + if key.len() == TEMP_LINK && value.len() == 1 { + let until = key.deserialize_be_u64(BLOB_HASH_LEN + U32_LEN)?; + if until > now { + let account_id = key.deserialize_be_u32(BLOB_HASH_LEN)?; + let hash = types::blob_hash::BlobHash::try_from_hash_slice( + key.get(0..BLOB_HASH_LEN).ok_or_else(|| { + trc::Error::corrupted_key(key, None, trc::location!()) + })?, + ) + .unwrap(); + + match value.first().copied() { + Some(UNDELETE_LINK) => { + archived_items.push((key.to_vec(), account_id, hash, until)); + } + Some(SPAM_SAMPLE_LINK | QUOTA_LINK) => { + delete_keys.push(key.to_vec()); + } + _ => {} + } + } + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + // Delete spam samples and quota links + let mut batch = BatchBuilder::new(); + for key in delete_keys { + batch.clear(ValueClass::Any(AnyClass { + subspace: SUBSPACE_BLOB_LINK, + key, + })); + + if batch.is_large_batch() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + } + if !batch.is_empty() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + // Migrate spam samples + let mut batch = BatchBuilder::new(); + let id_gen = &server.inner.data.registry_id_gen; + let mut last_id = 0; + for (key, account_id, blob_hash, until) in archived_items { + let item = ArchivedItem::Email(ArchivedEmail { + account_id: account_id.into(), + blob_id: BlobId::new(blob_hash.clone(), Default::default()), + archived_until: UTCDateTime::from_timestamp(until as i64), + archived_at: UTCDateTime::now(), + from: "Unavailable".to_string(), + received_at: UTCDateTime::now(), + subject: "...".to_string(), + size: 0, + }) + .to_pickled_vec(); + let object_id = ObjectType::ArchivedItem.to_id(); + + loop { + let new_id = id_gen.generate(); + if new_id != last_id { + last_id = new_id; + break; + } else { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + let item_id = last_id; + + batch + .set( + ValueClass::Any(AnyClass { + subspace: SUBSPACE_BLOB_LINK, + key, + }), + ObjectId::new(ObjectType::ArchivedItem, item_id.into()).serialize(), + ) + .set( + ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (account_id as u64).serialize(), + }), + vec![], + ) + .set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + item, + ); + + if batch.is_large_batch() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + } + + if !batch.is_empty() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(()) +} diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml new file mode 100644 index 0000000..1056faf --- /dev/null +++ b/crates/nlp/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nlp" +version = "0.16.22" +edition = "2024" + +[dependencies] +xxhash-rust = { version = "0.8.18", features = ["xxh3"] } +serde = { version = "1.0", features = ["derive"]} +ahash = { version = "0.8.12", features = ["serde"] } +whatlang = "0.18" # Language detection +rust-stemmers = "1.2" # Stemmers +jieba-rs = "0.10" # Chinese stemmer +psl = "2" +maplit = "1.0.2" +hashify = "0.2.9" +rand = "0.10.2" +rkyv = { version = "0.8.18", features = ["little_endian"] } + +[features] +test_mode = [] + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/nlp/src/classifier/adam.rs b/crates/nlp/src/classifier/adam.rs new file mode 100644 index 0000000..31fff5d --- /dev/null +++ b/crates/nlp/src/classifier/adam.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::{Optimizer, model::FhClassifier}; + +pub struct Adam { + parameters: Vec, + bias: f32, + learning_rate: f32, + beta1: f32, + beta2: f32, + epsilon: f32, + t: f32, + m0: Vec, + v0: Vec, + m_bias: f32, + v_bias: f32, + + // Step info + bias2_sqrt: f32, + alpha_t: f32, +} + +impl Adam { + pub fn new(n_parameters: usize, learning_rate: f32) -> Self { + Adam { + parameters: vec![0.0; n_parameters], + learning_rate, + beta1: 0.9, + beta2: 0.999, + epsilon: 1e-8, + t: 0.0, + m0: vec![0.0; n_parameters], + v0: vec![0.0; n_parameters], + m_bias: 0.0, + v_bias: 0.0, + bias: 0.0, + bias2_sqrt: 0.0, + alpha_t: 0.0, + } + } + + pub fn with_hyperparams(mut self, beta1: f32, beta2: f32, epsilon: f32) -> Self { + self.beta1 = beta1; + self.beta2 = beta2; + self.epsilon = epsilon; + self + } + + pub fn with_initial_weights(self, value: f32) -> Self { + Adam { + parameters: vec![value; self.parameters.len()], + ..self + } + } +} + +impl Optimizer for Adam { + #[inline(always)] + fn step(&mut self) { + self.t += 1.0; + let bias1 = 1.0 - self.beta1.powf(self.t); + self.bias2_sqrt = (1.0 - self.beta2.powf(self.t)).sqrt(); + self.alpha_t = self.learning_rate / bias1; + } + + #[inline(always)] + fn update_param(&mut self, i: usize, g: f32) { + self.m0[i] = self.beta1 * self.m0[i] + (1.0 - self.beta1) * g; + self.v0[i] = self.beta2 * self.v0[i] + (1.0 - self.beta2) * g * g; + self.parameters[i] -= + self.alpha_t * self.m0[i] / (self.v0[i].sqrt() / self.bias2_sqrt + self.epsilon); + } + + #[inline(always)] + fn update_bias(&mut self, g: f32) { + self.m_bias = self.beta1 * self.m_bias + (1.0 - self.beta1) * g; + self.v_bias = self.beta2 * self.v_bias + (1.0 - self.beta2) * g * g; + self.bias -= + self.alpha_t * self.m_bias / (self.v_bias.sqrt() / self.bias2_sqrt + self.epsilon); + } + + #[inline(always)] + fn get_param(&self, idx: usize) -> f32 { + self.parameters[idx] + } + + #[inline(always)] + fn get_bias(&self) -> f32 { + self.bias + } + + #[inline(always)] + fn get_param_mut(&mut self, idx: usize) -> &mut f32 { + &mut self.parameters[idx] + } + + fn build_classifier(&self) -> FhClassifier { + FhClassifier { + parameters: self.parameters.clone(), + bias: self.bias, + } + } + + fn num_parameters(&self) -> usize { + self.parameters.len() + } +} diff --git a/crates/nlp/src/classifier/feature.rs b/crates/nlp/src/classifier/feature.rs new file mode 100644 index 0000000..dfd9863 --- /dev/null +++ b/crates/nlp/src/classifier/feature.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashMap; +use xxhash_rust::xxh3::xxh3_64_with_seed; + +#[derive(Debug)] +pub struct Sample { + pub features: Vec, + pub class: f32, +} + +pub struct FhFeatureBuilder { + pub(super) weight_mask: u64, +} + +#[derive(Debug)] +pub struct FhFeature { + pub idx: usize, + pub weight: f32, +} + +#[derive(Debug)] +pub struct CcfhFeature { + pub idx_w1: usize, + pub idx_w2: usize, + pub idx_i: usize, + pub weight: f32, +} + +pub struct CcfhFeatureBuilder { + pub(super) weight_mask: u64, + pub(super) indicator_mask: u64, +} + +pub trait FeatureWeight { + fn idx(&self) -> usize; + fn weight(&self) -> f32; + fn weight_mut(&mut self) -> &mut f32; +} + +pub trait UnprocessedFeature { + fn prefix(&self) -> u16; + fn value(&self) -> &[u8]; +} + +impl FeatureWeight for FhFeature { + fn weight(&self) -> f32 { + self.weight + } + + fn weight_mut(&mut self) -> &mut f32 { + &mut self.weight + } + + fn idx(&self) -> usize { + self.idx + } +} + +impl FeatureWeight for CcfhFeature { + fn weight(&self) -> f32 { + self.weight + } + + fn weight_mut(&mut self) -> &mut f32 { + &mut self.weight + } + + fn idx(&self) -> usize { + self.idx_w1 + } +} + +impl FeatureBuilder for FhFeatureBuilder { + type Feature = FhFeature; + + fn build_feature(&self, bytes: &[u8], weight: f32) -> FhFeature { + let hash1 = xxh3_64_with_seed(bytes, 0); + let sign = if hash1 & (1 << 63) == 0 { 1.0 } else { -1.0 }; + + FhFeature { + idx: (hash1 & self.weight_mask) as usize, + weight: sign * weight, + } + } +} + +impl FeatureBuilder for CcfhFeatureBuilder { + type Feature = CcfhFeature; + + fn build_feature(&self, bytes: &[u8], weight: f32) -> CcfhFeature { + let hash1 = xxh3_64_with_seed(bytes, 0); + let hash2 = xxh3_64_with_seed(bytes, 0x9E3779B97F4A7C15); + let hash3 = xxh3_64_with_seed(bytes, 0x517CC1B727220A95); + let sign = if hash3 & (1 << 63) == 0 { 1.0 } else { -1.0 }; + + CcfhFeature { + idx_w1: (hash1 & self.weight_mask) as usize, + idx_w2: (hash2 & self.weight_mask) as usize, + idx_i: (hash3 & self.indicator_mask) as usize, + weight: sign * weight, + } + } +} + +pub trait FeatureBuilder { + // Feature type associated type + type Feature: FeatureWeight; + + fn build_feature(&self, bytes: &[u8], weight: f32) -> Self::Feature; + + fn scale(&self, features: &mut HashMap) { + // Log frequency scaling + for x in features.values_mut() { + *x = x.ln_1p(); + } + } + + fn build( + &self, + features_in: &HashMap, + account_id: Option, + l2_normalize: bool, + ) -> Vec { + let mut features_out = Vec::with_capacity(features_in.len()); + let mut buf = Vec::with_capacity(2 + 4 + 63); + for (feature, count) in features_in { + buf.extend_from_slice(&feature.prefix().to_be_bytes()); + buf.extend_from_slice(feature.value()); + features_out.push(self.build_feature(&buf, *count)); + + if let Some(account_id) = account_id { + buf.extend_from_slice(&account_id.to_be_bytes()); + features_out.push(self.build_feature(&buf, *count)); + } + + buf.clear(); + } + + // L2 normalization + if l2_normalize { + let sum_of_squares = features_out + .iter() + .map(|f| f.weight() as f64 * f.weight() as f64) + .sum::(); + if sum_of_squares > 0.0 { + let norm = sum_of_squares.sqrt() as f32; + for feature in &mut features_out { + *feature.weight_mut() /= norm; + } + } + } + + features_out + } +} + +impl Sample { + pub fn new(features: Vec, class: bool) -> Self { + Self { + features, + class: if class { 1.0 } else { 0.0 }, + } + } +} + +impl AsRef> for Sample { + fn as_ref(&self) -> &Sample { + self + } +} diff --git a/crates/nlp/src/classifier/ftrl.rs b/crates/nlp/src/classifier/ftrl.rs new file mode 100644 index 0000000..19cf0bb --- /dev/null +++ b/crates/nlp/src/classifier/ftrl.rs @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::{Optimizer, model::FhClassifier}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)] +pub struct Ftrl { + alpha: f64, + beta: f64, + l1_ratio: f64, + l2_ratio: f64, + zn: Vec, + zn_bias: Zn, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Clone, Copy, Debug, Default)] +pub struct Zn { + z: f32, + n: f64, +} + +impl Ftrl { + pub fn new(n_features: usize) -> Self { + Ftrl { + alpha: 2.0, + beta: 1.0, + l1_ratio: 0.001, + l2_ratio: 0.0001, + zn: vec![Zn::default(); n_features], + zn_bias: Zn::default(), + } + } + + pub fn with_hyperparams(mut self, alpha: f64, beta: f64, l1_ratio: f64, l2_ratio: f64) -> Self { + self.alpha = alpha; + self.beta = beta; + self.l1_ratio = l1_ratio; + self.l2_ratio = l2_ratio; + self + } + + pub fn set_hyperparams(&mut self, alpha: f64, beta: f64, l1_ratio: f64, l2_ratio: f64) { + self.alpha = alpha; + self.beta = beta; + self.l1_ratio = l1_ratio; + self.l2_ratio = l2_ratio; + } + + pub fn with_initial_weights(self, value: f32) -> Self { + Ftrl { + zn: vec![Zn { z: value, n: 0.0 }; self.zn.len()], + ..self + } + } +} + +impl Optimizer for Ftrl { + #[inline(always)] + fn update_param(&mut self, idx: usize, grad: f32) { + let zn = &mut self.zn[idx]; + let current_w = if zn.z.abs() as f64 <= self.l1_ratio { + 0.0 + } else { + -(zn.z - zn.z.signum() * self.l1_ratio as f32) + / (self.l2_ratio + (self.beta + zn.n.sqrt()) / self.alpha) as f32 + }; + let grad = grad as f64; + let grad_sq = grad * grad; + let sigma = ((zn.n + grad_sq).sqrt() - zn.n.sqrt()) / self.alpha; + zn.z += (grad - sigma * current_w as f64) as f32; + zn.n += grad_sq; + } + + #[inline(always)] + fn update_bias(&mut self, grad: f32) { + let current_bias = -self.zn_bias.z + / ((self.zn_bias.n.sqrt() + self.beta) / self.alpha + self.l2_ratio) as f32; + let grad = grad as f64; + let grad_sq = grad * grad; + let sigma = ((self.zn_bias.n + grad_sq).sqrt() - self.zn_bias.n.sqrt()) / self.alpha; + self.zn_bias.z += (grad - sigma * current_bias as f64) as f32; + self.zn_bias.n += grad_sq; + } + + #[inline(always)] + fn get_param(&self, idx: usize) -> f32 { + let zn = self.zn[idx]; + if zn.z.abs() as f64 <= self.l1_ratio { + 0.0 + } else { + -(zn.z - zn.z.signum() * self.l1_ratio as f32) + / (self.l2_ratio + (self.beta + zn.n.sqrt()) / self.alpha) as f32 + } + } + + #[inline(always)] + fn get_bias(&self) -> f32 { + -self.zn_bias.z / ((self.zn_bias.n.sqrt() + self.beta) / self.alpha + self.l2_ratio) as f32 + } + + fn step(&mut self) {} + + #[inline(always)] + fn get_param_mut(&mut self, idx: usize) -> &mut f32 { + &mut self.zn[idx].z + } + + fn build_classifier(&self) -> FhClassifier { + FhClassifier { + parameters: self + .zn + .iter() + .map(|zn| { + if zn.z.abs() as f64 <= self.l1_ratio { + 0.0 + } else { + -(zn.z - zn.z.signum() * self.l1_ratio as f32) + / (self.l2_ratio + (self.beta + zn.n.sqrt()) / self.alpha) as f32 + } + }) + .collect(), + bias: self.get_bias(), + } + } + + fn num_parameters(&self) -> usize { + self.zn.len() + } +} diff --git a/crates/nlp/src/classifier/mod.rs b/crates/nlp/src/classifier/mod.rs new file mode 100644 index 0000000..1b6a25b --- /dev/null +++ b/crates/nlp/src/classifier/mod.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::model::FhClassifier; + +pub mod adam; +pub mod feature; +pub mod ftrl; +pub mod model; +pub mod reservoir; +pub mod sgd; +pub mod train; + +const MAX_DLOSS: f32 = 1e4; + +pub trait Optimizer { + fn step(&mut self); + fn update_param(&mut self, i: usize, g: f32); + fn update_bias(&mut self, g: f32); + fn get_param(&self, idx: usize) -> f32; + fn get_param_mut(&mut self, idx: usize) -> &mut f32; + fn get_bias(&self) -> f32; + fn build_classifier(&self) -> FhClassifier; + fn num_parameters(&self) -> usize; +} + +#[inline(always)] +fn sigmoid(z: f32) -> f32 { + let z = z.clamp(-35.0, 35.0); + if z >= 0.0 { + 1.0 / (1.0 + (-z).exp()) + } else { + let exp_z = z.exp(); + exp_z / (1.0 + exp_z) + } +} + +#[inline(always)] +fn gradient(y: f32, p: f32) -> f32 { + if p > -16.0 { + let exp_tmp = (-p).exp(); + ((1.0 - y) - y * exp_tmp) / (1.0 + exp_tmp) + } else { + p.exp() - y + } +} diff --git a/crates/nlp/src/classifier/model.rs b/crates/nlp/src/classifier/model.rs new file mode 100644 index 0000000..414357b --- /dev/null +++ b/crates/nlp/src/classifier/model.rs @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::{ + feature::{CcfhFeature, CcfhFeatureBuilder, FhFeature, FhFeatureBuilder}, + sigmoid, +}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub struct FhClassifier { + pub(crate) parameters: Vec, + pub(crate) bias: f32, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub struct CcfhClassifier { + pub(crate) parameters: Vec, + pub(crate) indicators: Vec, + pub(crate) bias: f32, +} + +impl FhClassifier { + pub fn predict_proba_sample(&self, features: &[FhFeature]) -> f32 { + let mut z: f32 = 0.0; + + for f in features { + z += self.parameters[f.idx] * f.weight; + } + + sigmoid(z + self.bias) + } + + pub fn predict(&self, features: &[FhFeature]) -> f32 { + if self.predict_proba_sample(features) > 0.7 { + 1.0 + } else { + 0.0 + } + } + + pub fn predict_batch(&self, test: I) -> Vec + where + I: IntoIterator, + I::Item: AsRef>, + { + test.into_iter() + .map(|features| self.predict(features.as_ref())) + .collect() + } + + pub fn feature_builder(&self) -> FhFeatureBuilder { + FhFeatureBuilder { + weight_mask: (self.parameters.len() - 1) as u64, + } + } + + pub fn parameters(&self) -> &[f32] { + &self.parameters + } + + pub fn bias(&self) -> f32 { + self.bias + } +} + +impl CcfhClassifier { + pub fn predict_proba_sample(&self, features: &[CcfhFeature]) -> f32 { + let mut z: f32 = 0.0; + for f in features { + let q = self.indicators[f.idx_i]; + let v1 = self.parameters[f.idx_w1]; + let v2 = self.parameters[f.idx_w2]; + z += (q * v1 + (1.0 - q) * v2) * f.weight; + } + sigmoid(z + self.bias) + } + + pub fn predict(&self, features: &[CcfhFeature]) -> f32 { + if self.predict_proba_sample(features) >= 0.5 { + 1.0 + } else { + 0.0 + } + } + + pub fn predict_batch(&self, test: I) -> Vec + where + I: IntoIterator, + I::Item: AsRef>, + { + test.into_iter() + .map(|features| self.predict(features.as_ref())) + .collect() + } + + pub fn feature_builder(&self) -> CcfhFeatureBuilder { + CcfhFeatureBuilder { + weight_mask: (self.parameters.len() - 1) as u64, + indicator_mask: (self.indicators.len() - 1) as u64, + } + } + + pub fn is_active(&self) -> bool { + !self.parameters.is_empty() + } +} diff --git a/crates/nlp/src/classifier/reservoir.rs b/crates/nlp/src/classifier/reservoir.rs new file mode 100644 index 0000000..2182724 --- /dev/null +++ b/crates/nlp/src/classifier/reservoir.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use rand::{RngExt, seq::IndexedRandom}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)] +pub struct SampleReservoir { + pub spam: SampleReservoirClass, + pub ham: SampleReservoirClass, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)] +pub struct SampleReservoirClass { + pub buffer: Vec, + pub total_seen: u64, +} + +impl SampleReservoir { + pub fn update_reservoir(&mut self, item: &T, is_spam: bool, capacity: usize) { + let class = if is_spam { + &mut self.spam + } else { + &mut self.ham + }; + + class.total_seen += 1; + + if class.buffer.len() < capacity { + class.buffer.push(item.clone()); + } else if let Some(buf) = class + .buffer + .get_mut(rand::rng().random_range(0..class.total_seen as usize)) + { + *buf = item.clone(); + } + } + + pub fn update_counts(&mut self, is_spam: bool) { + let class = if is_spam { + &mut self.spam + } else { + &mut self.ham + }; + + class.total_seen += 1; + } + + pub fn replay_samples( + &mut self, + count_needed: usize, + is_spam: bool, + ) -> impl Iterator { + (if is_spam { + &mut self.spam + } else { + &mut self.ham + }) + .buffer + .sample(&mut rand::rng(), count_needed) + } + + pub fn remove_sample(&mut self, item: &T, is_spam: bool) { + let class = if is_spam { + &mut self.spam + } else { + &mut self.ham + }; + + if let Some(pos) = class.buffer.iter().position(|x| x == item) { + class.buffer.swap_remove(pos); + } + } +} + +impl Default for SampleReservoir { + fn default() -> Self { + SampleReservoir { + spam: SampleReservoirClass { + buffer: Vec::new(), + total_seen: 0, + }, + ham: SampleReservoirClass { + buffer: Vec::new(), + total_seen: 0, + }, + } + } +} diff --git a/crates/nlp/src/classifier/sgd.rs b/crates/nlp/src/classifier/sgd.rs new file mode 100644 index 0000000..08c0464 --- /dev/null +++ b/crates/nlp/src/classifier/sgd.rs @@ -0,0 +1,451 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::{Optimizer, gradient, model::FhClassifier}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub struct Sgd { + parameters: Vec, + bias: f32, + alpha: f64, + l1_ratio: f64, + l2_ratio: f64, + t: f64, + w_scale: f32, + optimal_init: f64, + eta: f32, + u: f32, + q: Vec, +} + +impl Sgd { + pub fn new(n_features: usize, alpha: f64, l1_ratio: f64, l2_ratio: f64) -> Self { + let typw = (1.0 / alpha.sqrt()).sqrt(); + let initial_eta0 = typw / 1.0_f64.max(gradient(1.0, -typw as f32) as f64); + let optimal_init = 1.0 / (initial_eta0 * alpha); + + Sgd { + parameters: vec![0.0; n_features], + bias: 0.0, + alpha, + l1_ratio, + l2_ratio, + t: 0.0, + w_scale: 1.0, + optimal_init, + eta: initial_eta0 as f32, + u: 0.0, + q: vec![0.0; n_features], + } + } + + pub fn with_initial_parameters(self, value: f32) -> Self { + Sgd { + parameters: vec![value; self.parameters.len()], + ..self + } + } + + fn maybe_rescale(&mut self) { + if !(1e-6..=1e6).contains(&self.w_scale) { + for w in &mut self.parameters { + *w *= self.w_scale; + } + self.w_scale = 1.0; + } + } + + #[inline(always)] + fn apply_l1_penalty(&mut self) { + if self.l1_ratio > 0.0 { + for (z, q) in self.parameters.iter_mut().zip(self.q.iter_mut()) { + let z_orig = *z; + let scaled_z = *z * self.w_scale; + if scaled_z > 0.0 { + *z = (*z - (self.u + *q) / self.w_scale).max(0.0); + } else if scaled_z < 0.0 { + *z = (*z + (self.u - *q) / self.w_scale).min(0.0); + } + *q += self.w_scale * (z_orig - *z); + } + } + } +} + +impl Optimizer for Sgd { + fn step(&mut self) { + self.t += 1.0; + self.eta = (1.0 / ((self.alpha) * (self.optimal_init + self.t - 1.0))) as f32; + self.w_scale *= 1.0 - ((1.0 - self.l1_ratio) as f32 * self.eta * self.l2_ratio as f32); + self.u += self.eta * self.l1_ratio as f32 * self.alpha as f32; + } + + fn update_param(&mut self, i: usize, g: f32) { + self.parameters[i] += (-self.eta * g) / self.w_scale; + } + + fn update_bias(&mut self, g: f32) { + self.bias += -self.eta * g; + self.maybe_rescale(); + self.apply_l1_penalty(); + } + + #[inline(always)] + fn get_param(&self, idx: usize) -> f32 { + self.parameters[idx] * self.w_scale + } + + #[inline(always)] + fn get_bias(&self) -> f32 { + self.bias + } + + #[inline(always)] + fn get_param_mut(&mut self, idx: usize) -> &mut f32 { + &mut self.parameters[idx] + } + + fn build_classifier(&self) -> FhClassifier { + FhClassifier { + parameters: self.parameters.iter().map(|w| w * self.w_scale).collect(), + bias: self.bias, + } + } + + fn num_parameters(&self) -> usize { + self.parameters.len() + } +} + +#[cfg(test)] +pub mod tests { + use crate::classifier::{ + Optimizer, + adam::Adam, + feature::{ + CcfhFeature, CcfhFeatureBuilder, FeatureBuilder, FhFeature, FhFeatureBuilder, Sample, + UnprocessedFeature, + }, + ftrl::Ftrl, + train::{CcfhTrainer, FhTrainer}, + }; + use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; + use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader}, + time::Instant, + }; + + #[ignore] + #[test] + fn text_classifier() { + let reader = BufReader::new( + File::open("/Users/me/code/playground/phishing_email.csv") + .expect("Could not open file"), + ); + let mut samples = Vec::with_capacity(1024); + + let time = Instant::now(); + + for line in reader.lines().skip(1) { + let line = line.unwrap(); + let (text, class) = line.trim().rsplit_once(',').unwrap(); + //let (class, text) = line.trim().split_once(',').unwrap(); + let text = text.trim_start_matches('"').trim_end_matches('"'); + + samples.push((text.to_string(), class == "1")); + } + + println!("Loaded {} samples in {:?}", samples.len(), time.elapsed()); + + samples.shuffle(&mut StdRng::seed_from_u64(42)); + + let (train_samples, test_samples) = train_test_split(&samples, 0.2); + + println!( + "Training samples: {}, Testing samples: {}", + train_samples.len(), + test_samples.len() + ); + + const FH_SIZE: usize = 16; + const CCFH_SIZE: usize = FH_SIZE - 2; + let mut rng = StdRng::seed_from_u64(42); + + let fh_builder = FhFeatureBuilder { + weight_mask: (1 << FH_SIZE) - 1, + }; + let mut fh_train_samples = build_fh_samples(train_samples.as_slice(), &fh_builder); + fh_train_samples.shuffle(&mut rng); + let fh_test_samples = build_fh_samples(test_samples.as_slice(), &fh_builder); + let ccfh_builder = CcfhFeatureBuilder { + weight_mask: (1 << FH_SIZE) - 1, + indicator_mask: (1 << CCFH_SIZE) - 1, + }; + let mut ccfh_train_samples = build_ccfh_samples(train_samples.as_slice(), &ccfh_builder); + ccfh_train_samples.shuffle(&mut rng); + let ccfh_test_samples = build_ccfh_samples(test_samples.as_slice(), &ccfh_builder); + + fh_model_stats( + "FTRL", + FhTrainer::new(Ftrl::new(1 << FH_SIZE)), + &fh_train_samples, + &fh_test_samples, + ); + + ccfh_model_stats( + "FTRL + FTRL", + CcfhTrainer::new( + Ftrl::new(1 << FH_SIZE), + Ftrl::new(1 << CCFH_SIZE).with_initial_weights(0.5), + ), + &ccfh_train_samples, + &ccfh_test_samples, + ); + + fh_model_stats( + "Adam", + FhTrainer::new(Adam::new(1 << FH_SIZE, 0.01)), + &fh_train_samples, + &fh_test_samples, + ); + + ccfh_model_stats( + "Adam + Adam", + CcfhTrainer::new( + Adam::new(1 << FH_SIZE, 0.01), + Adam::new(1 << CCFH_SIZE, 0.01).with_initial_weights(0.5), + ), + &ccfh_train_samples, + &ccfh_test_samples, + ); + + /*fh_model_stats( + "SGD", + FhTrainer::new(Sgd::new(1 << FH_SIZE, 0.0001, 0.0, 0.0001)), + &fh_train_samples, + &fh_test_samples, + ); + + ccfh_model_stats( + "FTRL + SGD", + CcfhTrainer::new( + Ftrl::new(1 << FH_SIZE), + Sgd::new(1 << CCFH_SIZE, 0.0001, 0.0, 0.0001).with_initial_parameters(0.5), + ), + &ccfh_train_samples, + &ccfh_test_samples, + );*/ + } + + fn fh_model_stats( + name: &str, + mut model: FhTrainer, + train_samples: &[Sample], + test_samples: &[Sample], + ) { + print!("⏳ Training {}... ", name); + let time = Instant::now(); + let mut batch = Vec::new(); + for sample in train_samples { + batch.push(sample); + if batch.len() == 128 { + model.fit(&mut batch, 5); + batch.clear(); + } + } + if !batch.is_empty() { + model.fit(&mut batch, 5); + } + println!(" trained in {:?}", time.elapsed()); + let y_pred = model + .build_classifier() + .predict_batch(test_samples.iter().map(|s| &s.features)); + let y_train: Vec = test_samples.iter().map(|s| s.class).collect(); + println!("Accuracy: {:.4}", accuracy_score(&y_train, &y_pred)); + println!("Precision: {:.4}", precision_score(&y_train, &y_pred, 1.0)); + println!("Recall: {:.4}", recall_score(&y_train, &y_pred, 1.0)); + println!("F1 Score: {:.4}", f1_score(&y_train, &y_pred, 1.0)); + } + + fn ccfh_model_stats( + name: &str, + mut model: CcfhTrainer, + train_samples: &[Sample], + test_samples: &[Sample], + ) { + print!("⏳ Training {}... ", name); + let time = Instant::now(); + let mut batch = Vec::new(); + for sample in train_samples { + batch.push(sample); + if batch.len() == 128 { + model.fit(&mut batch, 5); + batch.clear(); + } + } + if !batch.is_empty() { + model.fit(&mut batch, 5); + } + println!(" trained in {:?}", time.elapsed()); + let y_pred = model + .build_classifier() + .predict_batch(test_samples.iter().map(|s| &s.features)); + let y_train: Vec = test_samples.iter().map(|s| s.class).collect(); + println!("Accuracy: {:.4}", accuracy_score(&y_train, &y_pred)); + println!("Precision: {:.4}", precision_score(&y_train, &y_pred, 1.0)); + println!("Recall: {:.4}", recall_score(&y_train, &y_pred, 1.0)); + println!("F1 Score: {:.4}", f1_score(&y_train, &y_pred, 1.0)); + } + + fn accuracy_score(y_true: &[f32], y_pred: &[f32]) -> f32 { + y_true + .iter() + .zip(y_pred.iter()) + .filter(|(true_val, pred_val)| **true_val == **pred_val) + .count() as f32 + / y_true.len() as f32 + } + + fn precision_score(y_true: &[f32], y_pred: &[f32], positive_class: f32) -> f32 { + let true_positives = y_true + .iter() + .zip(y_pred.iter()) + .filter(|(true_val, pred_val)| { + **pred_val == positive_class && **true_val == positive_class + }) + .count() as f32; + + let predicted_positives = y_pred + .iter() + .filter(|pred_val| **pred_val == positive_class) + .count() as f32; + + if predicted_positives == 0.0 { + 0.0 + } else { + true_positives / predicted_positives + } + } + + fn recall_score(y_true: &[f32], y_pred: &[f32], positive_class: f32) -> f32 { + let true_positives = y_true + .iter() + .zip(y_pred.iter()) + .filter(|(true_val, pred_val)| { + **pred_val == positive_class && **true_val == positive_class + }) + .count() as f32; + + let actual_positives = y_true + .iter() + .filter(|true_val| **true_val == positive_class) + .count() as f32; + + if actual_positives == 0.0 { + 0.0 + } else { + true_positives / actual_positives + } + } + + fn f1_score(y_true: &[f32], y_pred: &[f32], positive_class: f32) -> f32 { + let precision = precision_score(y_true, y_pred, positive_class); + let recall = recall_score(y_true, y_pred, positive_class); + + if precision + recall == 0.0 { + 0.0 + } else { + 2.0 * (precision * recall) / (precision + recall) + } + } + + #[allow(clippy::type_complexity)] + pub fn train_test_split( + data: &[(String, bool)], + test_size: f32, + ) -> (Vec<(&String, bool)>, Vec<(&String, bool)>) { + let mut class_0: Vec<(&String, bool)> = Vec::new(); + let mut class_1: Vec<(&String, bool)> = Vec::new(); + + for (sample, class) in data { + if !*class { + class_0.push((sample, *class)); + } else { + class_1.push((sample, *class)); + } + } + + let test_count_0 = (class_0.len() as f32 * test_size).round() as usize; + let test_count_1 = (class_1.len() as f32 * test_size).round() as usize; + + let (test_0, train_0) = class_0.split_at(test_count_0); + let (test_1, train_1) = class_1.split_at(test_count_1); + + let mut train = Vec::new(); + let mut test = Vec::new(); + + train.extend_from_slice(train_0); + train.extend_from_slice(train_1); + test.extend_from_slice(test_0); + test.extend_from_slice(test_1); + + (train, test) + } + + pub fn build_fh_samples( + data: &[(&String, bool)], + builder: &FhFeatureBuilder, + ) -> Vec> { + let mut samples = Vec::with_capacity(data.len()); + + for (text, class) in data { + let mut sample: HashMap = HashMap::new(); + for word in text.split_whitespace() { + *sample.entry(word.to_string()).or_default() += 1.0; + } + builder.scale(&mut sample); + samples.push(Sample { + features: builder.build(&sample, 12345.into(), true), + class: if *class { 1.0 } else { 0.0 }, + }); + } + + samples + } + + pub fn build_ccfh_samples( + data: &[(&String, bool)], + builder: &CcfhFeatureBuilder, + ) -> Vec> { + let mut samples = Vec::with_capacity(data.len()); + + for (text, class) in data { + let mut sample: HashMap = HashMap::new(); + for word in text.split_whitespace() { + *sample.entry(word.to_string()).or_default() += 1.0; + } + builder.scale(&mut sample); + samples.push(Sample { + features: builder.build(&sample, 12345.into(), true), + class: if *class { 1.0 } else { 0.0 }, + }); + } + + samples + } + + impl UnprocessedFeature for String { + fn prefix(&self) -> u16 { + 0 + } + + fn value(&self) -> &[u8] { + self.as_bytes() + } + } +} diff --git a/crates/nlp/src/classifier/train.rs b/crates/nlp/src/classifier/train.rs new file mode 100644 index 0000000..c778599 --- /dev/null +++ b/crates/nlp/src/classifier/train.rs @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::classifier::{ + MAX_DLOSS, Optimizer, + feature::{CcfhFeature, CcfhFeatureBuilder, FhFeature, FhFeatureBuilder, Sample}, + gradient, + model::{CcfhClassifier, FhClassifier}, +}; +use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub struct FhTrainer { + pub optimizer: T, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)] +pub struct CcfhTrainer { + pub w_optimizer: W, + pub i_optimizer: I, +} + +impl FhTrainer { + pub fn new(optimizer: T) -> Self { + FhTrainer { optimizer } + } + + pub fn fit(&mut self, samples: &mut [impl AsRef>], num_epochs: usize) { + for _ in 0..num_epochs { + samples.shuffle(&mut StdRng::seed_from_u64(42)); + + for sample in samples.iter() { + let sample = sample.as_ref(); + let mut dot: f32 = 0.0; + for f in &sample.features { + dot += self.optimizer.get_param(f.idx) * f.weight; + } + let p = dot + self.optimizer.get_bias(); + let dloss = gradient(sample.class, p).clamp(-MAX_DLOSS, MAX_DLOSS); + + self.optimizer.step(); + + for f in &sample.features { + self.optimizer.update_param(f.idx, dloss * f.weight); + } + + self.optimizer.update_bias(dloss); + } + } + } + + pub fn feature_builder(&self) -> FhFeatureBuilder { + FhFeatureBuilder { + weight_mask: (self.optimizer.num_parameters() - 1) as u64, + } + } + + pub fn build_classifier(&self) -> FhClassifier { + self.optimizer.build_classifier() + } + + pub fn optimizer(&self) -> &T { + &self.optimizer + } + + pub fn optimizer_mut(&mut self) -> &mut T { + &mut self.optimizer + } +} + +impl CcfhTrainer { + pub fn new(w_optimizer: W, i_optimizer: I) -> Self { + CcfhTrainer { + w_optimizer, + i_optimizer, + } + } + + pub fn fit(&mut self, samples: &mut [impl AsRef>], num_epochs: usize) { + for _ in 0..num_epochs { + samples.shuffle(&mut StdRng::seed_from_u64(42)); + + for sample in samples.iter() { + let sample = sample.as_ref(); + let mut dot: f32 = 0.0; + for f in &sample.features { + let q = self.i_optimizer.get_param(f.idx_i); + let v1 = self.w_optimizer.get_param(f.idx_w1); + let v2 = self.w_optimizer.get_param(f.idx_w2); + dot += (q * v1 + (1.0 - q) * v2) * f.weight; + } + let p = dot + self.w_optimizer.get_bias(); + let dloss = gradient(sample.class, p).clamp(-MAX_DLOSS, MAX_DLOSS); + + self.w_optimizer.step(); + self.i_optimizer.step(); + + for f in &sample.features { + let q = self.i_optimizer.get_param(f.idx_i); + let v1 = self.w_optimizer.get_param(f.idx_w1); + let v2 = self.w_optimizer.get_param(f.idx_w2); + + // Update weights + let d_v1 = f.weight * q; + let d_v2 = f.weight * (1.0 - q); + self.w_optimizer.update_param(f.idx_w1, dloss * d_v1); + self.w_optimizer.update_param(f.idx_w2, dloss * d_v2); + + // Update indicator + let d_q = (v1 - v2) * f.weight; + self.i_optimizer.update_param(f.idx_i, dloss * d_q); + let fi = self.i_optimizer.get_param_mut(f.idx_i); + *fi = fi.clamp(0.0, 1.0); + } + + self.w_optimizer.update_bias(dloss); + } + } + } + + pub fn feature_builder(&self) -> CcfhFeatureBuilder { + CcfhFeatureBuilder { + weight_mask: (self.w_optimizer.num_parameters() - 1) as u64, + indicator_mask: (self.i_optimizer.num_parameters() - 1) as u64, + } + } + + pub fn build_classifier(&self) -> CcfhClassifier { + let w_classifier = self.w_optimizer.build_classifier(); + let i_classifier = self.i_optimizer.build_classifier(); + + CcfhClassifier { + parameters: w_classifier.parameters, + indicators: i_classifier.parameters, + bias: w_classifier.bias, + } + } + + pub fn w_optimizer(&self) -> &W { + &self.w_optimizer + } + + pub fn w_optimizer_mut(&mut self) -> &mut W { + &mut self.w_optimizer + } + + pub fn i_optimizer(&self) -> &I { + &self.i_optimizer + } + + pub fn i_optimizer_mut(&mut self) -> &mut I { + &mut self.i_optimizer + } +} diff --git a/crates/nlp/src/language/detect.rs b/crates/nlp/src/language/detect.rs new file mode 100644 index 0000000..657f806 --- /dev/null +++ b/crates/nlp/src/language/detect.rs @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Language; +use ahash::AHashMap; +use whatlang::{Lang, detect}; + +pub const MIN_LANGUAGE_SCORE: f64 = 0.6; + +#[derive(Debug)] +struct WeightedAverage { + weight: usize, + occurrences: usize, + confidence: f64, +} + +#[derive(Debug)] +pub struct LanguageDetector { + lang_detected: AHashMap, +} + +impl Default for LanguageDetector { + fn default() -> Self { + Self::new() + } +} + +impl LanguageDetector { + pub fn new() -> LanguageDetector { + LanguageDetector { + lang_detected: AHashMap::default(), + } + } + + pub fn detect(&mut self, text: &str, min_score: f64) -> Language { + if let Some((language, confidence)) = LanguageDetector::detect_single(text) { + let w = self + .lang_detected + .entry(language) + .or_insert_with(|| WeightedAverage { + weight: 0, + confidence: 0.0, + occurrences: 0, + }); + w.occurrences += 1; + w.weight += text.len(); + w.confidence += confidence * text.len() as f64; + if confidence < min_score { + Language::Unknown + } else { + language + } + } else { + Language::Unknown + } + } + + pub fn most_frequent_language(&self) -> Option { + self.lang_detected + .iter() + .filter(|(l, _)| !matches!(l, Language::None)) + .max_by(|(_, a), (_, b)| { + ((a.confidence / a.weight as f64) * a.occurrences as f64) + .partial_cmp(&((b.confidence / b.weight as f64) * b.occurrences as f64)) + .unwrap_or(std::cmp::Ordering::Less) + }) + .map(|(l, _)| *l) + } + + pub fn detect_single(text: &str) -> Option<(Language, f64)> { + detect(text).map(|info| { + ( + match info.lang() { + Lang::Epo => Language::Esperanto, + Lang::Eng => Language::English, + Lang::Rus => Language::Russian, + Lang::Cmn => Language::Mandarin, + Lang::Spa => Language::Spanish, + Lang::Por => Language::Portuguese, + Lang::Ita => Language::Italian, + Lang::Ben => Language::Bengali, + Lang::Fra => Language::French, + Lang::Deu => Language::German, + Lang::Ukr => Language::Ukrainian, + Lang::Kat => Language::Georgian, + Lang::Ara => Language::Arabic, + Lang::Hin => Language::Hindi, + Lang::Jpn => Language::Japanese, + Lang::Heb => Language::Hebrew, + Lang::Yid => Language::Yiddish, + Lang::Pol => Language::Polish, + Lang::Amh => Language::Amharic, + Lang::Jav => Language::Javanese, + Lang::Kor => Language::Korean, + Lang::Nob => Language::Bokmal, + Lang::Dan => Language::Danish, + Lang::Swe => Language::Swedish, + Lang::Fin => Language::Finnish, + Lang::Tur => Language::Turkish, + Lang::Nld => Language::Dutch, + Lang::Hun => Language::Hungarian, + Lang::Ces => Language::Czech, + Lang::Ell => Language::Greek, + Lang::Bul => Language::Bulgarian, + Lang::Bel => Language::Belarusian, + Lang::Mar => Language::Marathi, + Lang::Kan => Language::Kannada, + Lang::Ron => Language::Romanian, + Lang::Slv => Language::Slovene, + Lang::Hrv => Language::Croatian, + Lang::Srp => Language::Serbian, + Lang::Mkd => Language::Macedonian, + Lang::Lit => Language::Lithuanian, + Lang::Lav => Language::Latvian, + Lang::Est => Language::Estonian, + Lang::Tam => Language::Tamil, + Lang::Vie => Language::Vietnamese, + Lang::Urd => Language::Urdu, + Lang::Tha => Language::Thai, + Lang::Guj => Language::Gujarati, + Lang::Uzb => Language::Uzbek, + Lang::Pan => Language::Punjabi, + Lang::Aze => Language::Azerbaijani, + Lang::Ind => Language::Indonesian, + Lang::Tel => Language::Telugu, + Lang::Pes => Language::Persian, + Lang::Mal => Language::Malayalam, + Lang::Ori => Language::Oriya, + Lang::Mya => Language::Burmese, + Lang::Nep => Language::Nepali, + Lang::Sin => Language::Sinhalese, + Lang::Khm => Language::Khmer, + Lang::Tuk => Language::Turkmen, + Lang::Aka => Language::Akan, + Lang::Zul => Language::Zulu, + Lang::Sna => Language::Shona, + Lang::Afr => Language::Afrikaans, + Lang::Lat => Language::Latin, + Lang::Slk => Language::Slovak, + Lang::Cat => Language::Catalan, + Lang::Tgl => Language::Tagalog, + Lang::Hye => Language::Armenian, + _ => Language::Unknown, + }, + info.confidence(), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_languages() { + let inputs = [ + ( + "The quick brown fox jumps over the lazy dog", + Language::English, + ), + ( + "Jovencillo emponzoñado de whisky: ¡qué figurota exhibe!", + Language::Spanish, + ), + ( + "Ma la volpe col suo balzo ha raggiunto il quieto Fido", + Language::Italian, + ), + ( + "Jaz em prisão bota que vexa dez cegonhas felizes", + Language::Portuguese, + ), + ( + "Zwölf Boxkämpfer jagten Victor quer über den großen Sylter Deich", + Language::German, + ), + ("עטלף אבק נס דרך מזגן שהתפוצץ כי חם", Language::Hebrew), + ( + "Съешь ещё этих мягких французских булок, да выпей же чаю", + Language::Russian, + ), + ( + "Чуєш їх, доцю, га? Кумедна ж ти, прощайся без ґольфів!", + Language::Ukrainian, + ), + ( + "Љубазни фењерџија чађавог лица хоће да ми покаже штос", + Language::Serbian, + ), + ( + "Pijamalı hasta yağız şoföre çabucak güvendi", + Language::Turkish, + ), + ("己所不欲,勿施于人。", Language::Mandarin), + ("井の中の蛙大海を知らず", Language::Japanese), + ("시작이 반이다", Language::Korean), + ]; + + let mut detector = LanguageDetector::new(); + + for input in inputs.iter() { + assert_eq!(detector.detect(input.0, 0.0), input.1); + } + } + + #[test] + fn weighted_language() { + let mut detector = LanguageDetector::new(); + for lang in [ + (Language::Spanish, 0.5, 70), + (Language::Japanese, 0.2, 100), + (Language::Japanese, 0.3, 100), + (Language::Japanese, 0.4, 200), + (Language::English, 0.7, 50), + ] + .iter() + { + let w = detector + .lang_detected + .entry(lang.0) + .or_insert_with(|| WeightedAverage { + weight: 0, + confidence: 0.0, + occurrences: 0, + }); + w.occurrences += 1; + w.weight += lang.2; + w.confidence += lang.1 * lang.2 as f64; + } + assert_eq!(detector.most_frequent_language(), Some(Language::Japanese)); + } +} diff --git a/crates/nlp/src/language/mod.rs b/crates/nlp/src/language/mod.rs new file mode 100644 index 0000000..7c1b786 --- /dev/null +++ b/crates/nlp/src/language/mod.rs @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod detect; +pub mod search_snippet; +pub mod stemmer; +pub mod stopwords; + +use self::detect::LanguageDetector; +use crate::tokenizers::{ + Token, chinese::ChineseTokenizer, japanese::JapaneseTokenizer, space::SpaceTokenizer, + word::WordTokenizer, +}; +use std::borrow::Cow; + +pub type LanguageTokenizer<'x> = Box>> + 'x + Sync + Send>; + +impl Language { + pub fn tokenize_text<'x>( + &self, + text: &'x str, + max_token_length: usize, + ) -> LanguageTokenizer<'x> { + match self { + Language::Japanese => Box::new( + JapaneseTokenizer::new(WordTokenizer::new(text, usize::MAX)) + .filter(move |t| t.word.len() <= max_token_length), + ), + Language::Mandarin => Box::new( + ChineseTokenizer::new(WordTokenizer::new(text, usize::MAX)) + .filter(move |t| t.word.len() <= max_token_length), + ), + Language::None => { + Box::new( + SpaceTokenizer::new(text, max_token_length).map(|word| Token { + word: word.into(), + from: 0, + to: 0, + }), + ) + } + _ => Box::new(WordTokenizer::new(text, max_token_length)), + } + } +} + +#[derive( + Debug, PartialEq, Clone, Copy, Hash, Eq, serde::Serialize, serde::Deserialize, Default, +)] +pub enum Language { + Esperanto = 0, + #[default] + English = 1, + Russian = 2, + Mandarin = 3, + Spanish = 4, + Portuguese = 5, + Italian = 6, + Bengali = 7, + French = 8, + German = 9, + Ukrainian = 10, + Georgian = 11, + Arabic = 12, + Hindi = 13, + Japanese = 14, + Hebrew = 15, + Yiddish = 16, + Polish = 17, + Amharic = 18, + Javanese = 19, + Korean = 20, + Bokmal = 21, + Danish = 22, + Swedish = 23, + Finnish = 24, + Turkish = 25, + Dutch = 26, + Hungarian = 27, + Czech = 28, + Greek = 29, + Bulgarian = 30, + Belarusian = 31, + Marathi = 32, + Kannada = 33, + Romanian = 34, + Slovene = 35, + Croatian = 36, + Serbian = 37, + Macedonian = 38, + Lithuanian = 39, + Latvian = 40, + Estonian = 41, + Tamil = 42, + Vietnamese = 43, + Urdu = 44, + Thai = 45, + Gujarati = 46, + Uzbek = 47, + Punjabi = 48, + Azerbaijani = 49, + Indonesian = 50, + Telugu = 51, + Persian = 52, + Malayalam = 53, + Oriya = 54, + Burmese = 55, + Nepali = 56, + Sinhalese = 57, + Khmer = 58, + Turkmen = 59, + Akan = 60, + Zulu = 61, + Shona = 62, + Afrikaans = 63, + Latin = 64, + Slovak = 65, + Catalan = 66, + Tagalog = 67, + Armenian = 68, + Unknown = 69, + None = 70, +} + +impl Language { + pub fn is_unknown(&self) -> bool { + matches!(self, Language::Unknown) + } + + pub fn from_iso_639(code: &str) -> Option { + hashify::map!( + code.split_once(['-', '_']).map(|c| c.0).unwrap_or(code).as_bytes(), + Language, + "en" => Language::English, + "es" => Language::Spanish, + "pt" => Language::Portuguese, + "it" => Language::Italian, + "fr" => Language::French, + "de" => Language::German, + "da" => Language::Danish, + "ru" => Language::Russian, + "zh" => Language::Mandarin, + "ja" => Language::Japanese, + "ar" => Language::Arabic, + "hi" => Language::Hindi, + "ko" => Language::Korean, + "bn" => Language::Bengali, + "he" => Language::Hebrew, + "ur" => Language::Urdu, + "fa" => Language::Persian, + "ml" => Language::Malayalam, + "or" => Language::Oriya, + "my" => Language::Burmese, + "ne" => Language::Nepali, + "si" => Language::Sinhalese, + "km" => Language::Khmer, + "tk" => Language::Turkmen, + "am" => Language::Amharic, + "az" => Language::Azerbaijani, + "id" => Language::Indonesian, + "te" => Language::Telugu, + "ta" => Language::Tamil, + "vi" => Language::Vietnamese, + "gu" => Language::Gujarati, + "pa" => Language::Punjabi, + "uz" => Language::Uzbek, + "hy" => Language::Armenian, + "ka" => Language::Georgian, + "la" => Language::Latin, + "sl" => Language::Slovene, + "hr" => Language::Croatian, + "sr" => Language::Serbian, + "mk" => Language::Macedonian, + "lt" => Language::Lithuanian, + "lv" => Language::Latvian, + "et" => Language::Estonian, + "tl" => Language::Tagalog, + "af" => Language::Afrikaans, + "zu" => Language::Zulu, + "sn" => Language::Shona, + "ak" => Language::Akan, + "ca" => Language::Catalan, + "el" => Language::Greek, + "sv" => Language::Swedish, + "pl" => Language::Polish, + "nl" => Language::Dutch, + "fi" => Language::Finnish, + "hu" => Language::Hungarian, + "tr" => Language::Turkish, + "ro" => Language::Romanian, + "nb" => Language::Bokmal, + "no" => Language::Bokmal, + "yi" => Language::Yiddish, + "eo" => Language::Esperanto, + "uk" => Language::Ukrainian, + "cs" => Language::Czech, + "sk" => Language::Slovak, + "bg" => Language::Bulgarian, + "be" => Language::Belarusian, + "th" => Language::Thai, + "mr" => Language::Marathi, + "kn" => Language::Kannada, + "jv" => Language::Javanese + ) + .copied() + } +} + +impl Language { + pub fn detect(text: String, default: Language) -> (String, Language) { + if let Some((l, t)) = text + .split_once(':') + .and_then(|(l, t)| (Language::from_iso_639(l)?, t).into()) + { + (t.to_string(), l) + } else { + let l = LanguageDetector::detect_single(&text) + .and_then(|(l, c)| if c > 0.3 { Some(l) } else { None }) + .unwrap_or(default); + (text, l) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_639_codes() { + for (code, expected) in [ + ("nl", Language::Dutch), + ("fi", Language::Finnish), + ("hu", Language::Hungarian), + ("tr", Language::Turkish), + ("ro", Language::Romanian), + ("nb", Language::Bokmal), + ("no", Language::Bokmal), + ("yi", Language::Yiddish), + ("eo", Language::Esperanto), + ("uk", Language::Ukrainian), + ("cs", Language::Czech), + ("sk", Language::Slovak), + ("bg", Language::Bulgarian), + ("be", Language::Belarusian), + ("th", Language::Thai), + ("mr", Language::Marathi), + ("kn", Language::Kannada), + ("jv", Language::Javanese), + ("en", Language::English), + ("pl", Language::Polish), + ] { + assert_eq!(Language::from_iso_639(code), Some(expected), "{code}"); + } + } + + #[test] + fn iso_639_locale_suffixes() { + for code in ["nl_NL", "nl_BE", "nl_NL@euro", "nl-NL"] { + assert_eq!( + Language::from_iso_639(code), + Some(Language::Dutch), + "{code}" + ); + } + assert_eq!(Language::from_iso_639("xx_XX"), None); + } +} diff --git a/crates/nlp/src/language/search_snippet.rs b/crates/nlp/src/language/search_snippet.rs new file mode 100644 index 0000000..c4ddfe6 --- /dev/null +++ b/crates/nlp/src/language/search_snippet.rs @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Language; + +fn escape_char(c: char, string: &mut String) { + match c { + '&' => string.push_str("&"), + '<' => string.push_str("<"), + '>' => string.push_str(">"), + '"' => string.push_str("""), + '\n' | '\r' => string.push(' '), + _ => string.push(c), + } +} + +fn escape_char_len(c: char) -> usize { + match c { + '&' => "&".len(), + '<' => "<".len(), + '>' => ">".len(), + '"' => """.len(), + '\r' | '\n' => 1, + _ => c.len_utf8(), + } +} + +pub struct Term { + offset: usize, + len: usize, +} + +pub fn generate_snippet( + text: &str, + needles: &[impl AsRef], + language: Language, + is_exact: bool, +) -> Option { + let mut terms = Vec::new(); + if is_exact { + let tokens = language.tokenize_text(text, 200).collect::>(); + for tokens in tokens.windows(needles.len()) { + if needles + .iter() + .zip(tokens) + .all(|(needle, token)| needle.as_ref() == token.word.as_ref()) + { + for token in tokens { + terms.push(Term { + offset: token.from, + len: token.to - token.from, + }); + } + } + } + } else { + for token in language.tokenize_text(text, 200) { + if needles.iter().any(|needle| { + let needle = needle.as_ref(); + needle == token.word.as_ref() || needle.len() > 2 && token.word.contains(needle) + }) { + terms.push(Term { + offset: token.from, + len: token.to - token.from, + }); + } + } + } + if terms.is_empty() { + return None; + } + + let mut snippet = String::with_capacity(text.len()); + let start_offset = terms.first()?.offset; + + if start_offset > 0 { + let mut word_count = 0; + let mut from_offset = 0; + let mut last_is_space = false; + + if text.len() > 240 { + for (pos, char) in text.get(0..start_offset)?.char_indices().rev() { + // Add up to 2 words or 40 characters of context + if char.is_whitespace() { + if !last_is_space { + word_count += 1; + if word_count == 3 { + break; + } + last_is_space = true; + } + } else { + last_is_space = false; + } + from_offset = pos; + if start_offset - from_offset >= 40 { + break; + } + } + } + + last_is_space = false; + for char in text.get(from_offset..start_offset)?.chars() { + if !char.is_whitespace() { + last_is_space = false; + } else { + if last_is_space { + continue; + } + last_is_space = true; + } + escape_char(char, &mut snippet); + } + } + + let mut terms = terms.iter().peekable(); + + 'outer: while let Some(term) = terms.next() { + if snippet.len() + ("".len() * 2) + term.len + 1 > 255 { + break; + } + + snippet.push_str(""); + snippet.push_str(text.get(term.offset..term.offset + term.len)?); + snippet.push_str(""); + + let next_offset = if let Some(next_term) = terms.peek() { + next_term.offset + } else { + text.len() + }; + + let mut last_is_space = false; + for char in text.get(term.offset + term.len..next_offset)?.chars() { + if !char.is_whitespace() { + last_is_space = false; + } else { + if last_is_space { + continue; + } + last_is_space = true; + } + + if snippet.len() + escape_char_len(char) <= 255 { + escape_char(char, &mut snippet); + } else { + break 'outer; + } + } + } + + Some(snippet) +} + +#[cfg(test)] +mod tests { + use crate::language::{Language, search_snippet::generate_snippet}; + + #[test] + fn search_snippets() { + let inputs = [ + ( + vec![ + "Help a friend from Abidjan Côte d'Ivoire", + concat!( + "When my mother died when she was given birth to me, my father took me so ", + "special because I am motherless. Before the death of my late father on 22nd June ", + "2013 in a private hospital here in Abidjan Côte d'Ivoire. He secretly called me on his ", + "bedside and told me that he has a sum of $7.5M (Seven Million five Hundred ", + "Thousand Dollars) left in a suspense account in a local bank here in Abidjan Côte ", + "d'Ivoire, that he used my name as his only daughter for the next of kin in deposit of ", + "the fund. ", + "I am 24year old. Dear I am honorably seeking your assistance in the following ways. ", + "1) To provide any bank account where this money would be transferred into. ", + "2) To serve as the guardian of this fund. ", + "3) To make arrangement for me to come over to your country to further my ", + "education and to secure a residential permit for me in your country. ", + "Moreover, I am willing to offer you 30 percent of the total sum as compensation for ", + "your effort input after the successful transfer of this fund to your nominated ", + "account overseas." + ), + ], + vec![ + ( + vec!["côte"], + vec![ + "Help a friend from Abidjan Côte d'Ivoire", + concat!( + "in Abidjan Côte d'Ivoire. He secretly called me on his bedside ", + "and told me that he has a sum of $7.5M (Seven Million five Hundred Thousand ", + "Dollars) left in a suspense account in a local bank here in Abidjan ", + "Côte d'Ivoire, that " + ), + ], + ), + ( + vec!["your", "country"], + vec![concat!( + "honorably seeking your assistance in the following ways. ", + "1) To provide any bank account where this money would be transferred into. 2) ", + "To serve as the guardian of this fund. 3) To make arrangement for me to come ", + "over to your " + )], + ), + ( + vec!["overseas"], + vec!["nominated account overseas."], + ), + ], + ), + ( + vec![ + "孫子兵法", + concat!( + "<\"孫子兵法:\">", + "孫子曰:兵者,國之大事,死生之地,存亡之道,不可不察也。", + "孫子曰:凡用兵之法,馳車千駟,革車千乘,帶甲十萬;千里饋糧,則內外之費賓客之用,膠漆之材,", + "車甲之奉,日費千金,然後十萬之師舉矣。", + "孫子曰:凡用兵之法,全國為上,破國次之;全旅為上,破旅次之;全卒為上,破卒次之;全伍為上,破伍次之。", + "是故百戰百勝,非善之善者也;不戰而屈人之兵,善之善者也。", + "孫子曰:昔之善戰者,先為不可勝,以待敵之可勝,不可勝在己,可勝在敵。故善戰者,能為不可勝,不能使敵必可勝。", + "故曰:勝可知,而不可為。", + "兵者,詭道也。故能而示之不能,用而示之不用,近而示之遠,遠而示之近。利而誘之,亂而取之,實而備之,強而避之,", + "怒而撓之,卑而驕之,佚而勞之,親而離之。攻其無備,出其不意,此兵家之勝,不可先傳也。", + "夫未戰而廟算勝者,得算多也;未戰而廟算不勝者,得算少也;多算勝,少算不勝,而況於無算乎?吾以此觀之,勝負見矣。", + "孫子曰:凡治眾如治寡,分數是也。鬥眾如鬥寡,形名是也。三軍之眾,可使必受敵而無敗者,奇正是也。兵之所加,", + "如以碬投卵者,虛實是也。", + ), + ], + vec![ + ( + vec!["孫子兵法"], + vec![ + "孫子兵法", + concat!( + "<"孫子兵法:">孫子曰:兵者,國之大事,死生之地,存亡之道,", + "不可不察也。孫子曰:凡用兵之法,馳車千駟,革車千乘,帶甲十萬;千里饋糧,則內外之費賓客之用,膠" + ), + ], + ), + ( + vec!["孫子曰"], + vec![concat!( + "<"孫子兵法:">孫子曰:兵者,國之大事,死生之地,存亡之道,", + "不可不察也。孫子曰:凡用兵之法,馳車千駟,革車千乘,帶甲十萬;千里饋糧,則內外之費賓", + )], + ), + ], + ), + ]; + + for (parts, tests) in inputs { + for (needles, snippets) in tests { + let mut results = Vec::new(); + + for part in &parts { + if let Some(matched) = + generate_snippet(part, &needles, Language::English, false) + { + results.push(matched); + } + } + + assert_eq!(snippets, results); + } + } + } +} diff --git a/crates/nlp/src/language/stemmer.rs b/crates/nlp/src/language/stemmer.rs new file mode 100644 index 0000000..e69cd4c --- /dev/null +++ b/crates/nlp/src/language/stemmer.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use rust_stemmers::Algorithm; + +use super::{Language, LanguageTokenizer}; + +#[derive(Debug, PartialEq, Eq)] +pub struct StemmedToken<'x> { + pub word: Cow<'x, str>, + pub stemmed_word: Option>, + pub from: usize, // Word offset in the text part + pub to: usize, // Word length +} + +pub struct Stemmer<'x> { + stemmer: Option, + tokenizer: LanguageTokenizer<'x>, +} + +impl<'x> Stemmer<'x> { + pub fn new(text: &'x str, language: Language, max_token_length: usize) -> Stemmer<'x> { + Stemmer { + tokenizer: language.tokenize_text(text, max_token_length), + stemmer: STEMMER_MAP[language as usize].map(rust_stemmers::Stemmer::create), + } + } +} + +impl<'x> Iterator for Stemmer<'x> { + type Item = StemmedToken<'x>; + + fn next(&mut self) -> Option { + let token = self.tokenizer.next()?; + Some(StemmedToken { + stemmed_word: self.stemmer.as_ref().and_then(|stemmer| { + match stemmer.stem(&token.word) { + Cow::Owned(text) if text.len() != token.word.len() || text != token.word => { + Some(text.into()) + } + _ => None, + } + }), + word: token.word, + from: token.from, + to: token.to, + }) + } +} + +pub static STEMMER_MAP: &[Option] = &[ + None, // Esperanto = 0, + Some(Algorithm::English), // English = 1, + Some(Algorithm::Russian), // Russian = 2, + None, // Mandarin = 3, + Some(Algorithm::Spanish), // Spanish = 4, + Some(Algorithm::Portuguese), // Portuguese = 5, + Some(Algorithm::Italian), // Italian = 6, + None, // Bengali = 7, + Some(Algorithm::French), // French = 8, + Some(Algorithm::German), // German = 9, + None, // Ukrainian = 10, + None, // Georgian = 11, + Some(Algorithm::Arabic), // Arabic = 12, + None, // Hindi = 13, + None, // Japanese = 14, + None, // Hebrew = 15, + None, // Yiddish = 16, + None, // Polish = 17, + None, // Amharic = 18, + None, // Javanese = 19, + None, // Korean = 20, + Some(Algorithm::Norwegian), // Bokmal = 21, + Some(Algorithm::Danish), // Danish = 22, + Some(Algorithm::Swedish), // Swedish = 23, + Some(Algorithm::Finnish), // Finnish = 24, + Some(Algorithm::Turkish), // Turkish = 25, + Some(Algorithm::Dutch), // Dutch = 26, + Some(Algorithm::Hungarian), // Hungarian = 27, + None, // Czech = 28, + Some(Algorithm::Greek), // Greek = 29, + None, // Bulgarian = 30, + None, // Belarusian = 31, + None, // Marathi = 32, + None, // Kannada = 33, + Some(Algorithm::Romanian), // Romanian = 34, + None, // Slovene = 35, + None, // Croatian = 36, + None, // Serbian = 37, + None, // Macedonian = 38, + None, // Lithuanian = 39, + None, // Latvian = 40, + None, // Estonian = 41, + Some(Algorithm::Tamil), // Tamil = 42, + None, // Vietnamese = 43, + None, // Urdu = 44, + None, // Thai = 45, + None, // Gujarati = 46, + None, // Uzbek = 47, + None, // Punjabi = 48, + None, // Azerbaijani = 49, + None, // Indonesian = 50, + None, // Telugu = 51, + None, // Persian = 52, + None, // Malayalam = 53, + None, // Oriya = 54, + None, // Burmese = 55, + None, // Nepali = 56, + None, // Sinhalese = 57, + None, // Khmer = 58, + None, // Turkmen = 59, + None, // Akan = 60, + None, // Zulu = 61, + None, // Shona = 62, + None, // Afrikaans = 63, + None, // Latin = 64, + None, // Slovak = 65, + None, // Catalan = 66, + None, // Tagalog = 67, + None, // Armenian = 68, + None, // Unknown = 69, + None, // None = 70, +]; + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn stemmer() { + let inputs = [ + ( + "love loving lovingly loved lovely", + Language::English, + "love", + ), + ("querer queremos quer", Language::Spanish, "quer"), + ]; + + for (input, language, result) in inputs { + for token in Stemmer::new(input, language, 40) { + assert_eq!(token.stemmed_word.unwrap_or(token.word), result); + } + } + } +} diff --git a/crates/nlp/src/language/stopwords.rs b/crates/nlp/src/language/stopwords.rs new file mode 100644 index 0000000..f40fd64 --- /dev/null +++ b/crates/nlp/src/language/stopwords.rs @@ -0,0 +1,4234 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub type StopwordFnc = fn(&str) -> bool; + +pub static STOP_WORDS: &[Option] = &[ + None, // Esperanto = 0, + Some(english), // English = 1, + Some(russian), // Russian = 2, + None, // Mandarin = 3, + Some(spanish), // Spanish = 4, + Some(portuguese), // Portuguese = 5, + Some(italian), // Italian = 6, + None, // Bengali = 7, + Some(french), // French = 8, + Some(german), // German = 9, + None, // Ukrainian = 10, + None, // Georgian = 11, + Some(arabic), // Arabic = 12, + None, // Hindi = 13, + None, // Japanese = 14, + None, // Hebrew = 15, + None, // Yiddish = 16, + None, // Polish = 17, + None, // Amharic = 18, + None, // Javanese = 19, + None, // Korean = 20, + Some(norwegian), // Bokmal = 21, + Some(danish), // Danish = 22, + Some(swedish), // Swedish = 23, + Some(finnish), // Finnish = 24, + Some(turkish), // Turkish = 25, + Some(dutch), // Dutch = 26, + Some(hungarian), // Hungarian = 27, + None, // Czech = 28, + Some(greek), // Greek = 29, + None, // Bulgarian = 30, + None, // Belarusian = 31, + None, // Marathi = 32, + None, // Kannada = 33, + Some(romanian), // Romanian = 34, + None, // Slovene = 35, + None, // Croatian = 36, + None, // Serbian = 37, + None, // Macedonian = 38, + None, // Lithuanian = 39, + None, // Latvian = 40, + None, // Estonian = 41, + None, // Tamil = 42, + None, // Vietnamese = 43, + None, // Urdu = 44, + None, // Thai = 45, + None, // Gujarati = 46, + None, // Uzbek = 47, + None, // Punjabi = 48, + Some(azarbaijani), // Azerbaijani = 49, + None, // Indonesian = 50, + None, // Telugu = 51, + None, // Persian = 52, + None, // Malayalam = 53, + None, // Oriya = 54, + None, // Burmese = 55, + Some(nepali), // Nepali = 56, + None, // Sinhalese = 57, + None, // Khmer = 58, + None, // Turkmen = 59, + None, // Akan = 60, + None, // Zulu = 61, + None, // Shona = 62, + None, // Afrikaans = 63, + None, // Latin = 64, + None, // Slovak = 65, + None, // Catalan = 66, + None, // Tagalog = 67, + None, // Armenian = 68, + None, // Unknown = 69, + None, // None = 70, +]; + +fn arabic(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "آه", + "آي", + "أف", + "أم", + "أن", + "أو", + "أي", + "إذ", + "إن", + "إي", + "بخ", + "بس", + "بك", + "بل", + "به", + "بي", + "ته", + "تي", + "ثم", + "ذا", + "ذه", + "ذو", + "ذي", + "عل", + "عن", + "في", + "قد", + "كل", + "كم", + "كي", + "لا", + "لك", + "لم", + "لن", + "له", + "لو", + "لي", + "ما", + "مذ", + "مع", + "من", + "مه", + "ها", + "هل", + "هم", + "هن", + "هو", + "هي", + "يا", + "آها", + "أقل", + "ألا", + "أما", + "أنا", + "أنت", + "أنى", + "أوه", + "أين", + "إذا", + "إذن", + "إلا", + "إلى", + "إما", + "إنا", + "إنه", + "إيه", + "بعد", + "بعض", + "بكم", + "بكن", + "بلى", + "بما", + "بمن", + "بنا", + "بها", + "بهم", + "بهن", + "بيد", + "بين", + "تلك", + "تين", + "ثمة", + "حتى", + "حيث", + "حين", + "خلا", + "دون", + "ذات", + "ذاك", + "ذان", + "ذلك", + "ذوا", + "ذين", + "ريث", + "سوف", + "سوى", + "عدا", + "عسى", + "على", + "عما", + "عند", + "غير", + "فإن", + "فلا", + "فمن", + "فيم", + "فيه", + "كأن", + "كأي", + "كذا", + "كلا", + "كما", + "كيت", + "كيف", + "لئن", + "لدى", + "لست", + "لسن", + "لعل", + "لكم", + "لكن", + "لكي", + "لما", + "لنا", + "لها", + "لهم", + "لهن", + "ليت", + "ليس", + "متى", + "مما", + "ممن", + "منذ", + "منه", + "نحن", + "نحو", + "نعم", + "هاك", + "هذا", + "هذه", + "هذي", + "هلا", + "هما", + "هنا", + "هيا", + "هيت", + "وإذ", + "وإن", + "ولا", + "ولو", + "وما", + "ومن", + "وهو", + "أكثر", + "أنتم", + "أنتن", + "أيها", + "إذما", + "إليك", + "إنما", + "التي", + "الذي", + "بكما", + "بهما", + "تلكم", + "تينك", + "حاشا", + "حبذا", + "ذانك", + "ذلكم", + "ذلكن", + "ذينك", + "شتان", + "عليك", + "عليه", + "فإذا", + "فيما", + "فيها", + "كأين", + "كذلك", + "كلتا", + "كلما", + "لستم", + "لستن", + "لسنا", + "لكما", + "لهما", + "لولا", + "لوما", + "ليسا", + "ليست", + "ماذا", + "منها", + "مهما", + "هاته", + "هاتي", + "هذان", + "هذين", + "هكذا", + "هناك", + "وإذا", + "ولكن", + "أنتما", + "أولئك", + "أولاء", + "أينما", + "إليكم", + "إليكن", + "الذين", + "بماذا", + "تلكما", + "حيثما", + "ذلكما", + "ذواتا", + "ذواتي", + "كأنما", + "كيفما", + "لستما", + "لكنما", + "لكيلا", + "ليستا", + "ليسوا", + "هؤلاء", + "هاتان", + "هاتين", + "هاهنا", + "هنالك", + "هيهات", + "والذي", + "إليكما", + "اللائي", + "اللاتي", + "اللتان", + "اللتيا", + "اللتين", + "اللذان", + "اللذين", + "كلاهما", + "كليكما", + "كليهما", + "لاسيما", + "والذين", + "اللواتي", + ) +} + +fn azarbaijani(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "ad", + "altmış", + "altı", + "amma", + "arasında", + "artıq", + "ay", + "az", + "bax", + "belə", + "beş", + "bilər", + "bir", + "biraz", + "biri", + "birşey", + "biz", + "bizim", + "bizlər", + "bu", + "buna", + "bundan", + "bunların", + "bunu", + "bunun", + "buradan", + "bütün", + "bəli", + "bəlkə", + "bəy", + "bəzi", + "bəzən", + "ci", + "çox", + "cu", + "cü", + "çünki", + "cı", + "da", + "daha", + "dedi", + "deyil", + "dir", + "doqquz", + "doqsan", + "dörd", + "düz", + "də", + "dək", + "dən", + "dəqiqə", + "edir", + "edən", + "elə", + "et", + "etdi", + "etmə", + "etmək", + "faiz", + "gilə", + "görə", + "ha", + "haqqında", + "harada", + "heç", + "hə", + "həm", + "həmin", + "həmişə", + "hər", + "idi", + "iki", + "il", + "ildə", + "ilk", + "ilə", + "in", + "indi", + "istifadə", + "isə", + "iyirmi", + "ki", + "kim", + "kimi", + "kimə", + "lakin", + "lap", + "mirşey", + "məhz", + "mən", + "mənə", + "niyə", + "nə", + "nəhayət", + "o", + "obirisi", + "of", + "olan", + "olar", + "olaraq", + "oldu", + "olduğu", + "olmadı", + "olmaz", + "olmuşdur", + "olsun", + "olur", + "on", + "ona", + "ondan", + "onlar", + "onlardan", + "onların", + "onsuzda", + "onu", + "onun", + "oradan", + "otuz", + "öz", + "özü", + "qarşı", + "qədər", + "qırx", + "saat", + "sadəcə", + "saniyə", + "siz", + "sizin", + "sizlər", + "sonra", + "səhv", + "səkkiz", + "səksən", + "sən", + "sənin", + "sənə", + "təəssüf", + "ü", + "üç", + "üçün", + "var", + "və", + "xan", + "xanım", + "xeyr", + "ya", + "yalnız", + "yaxşı", + "yeddi", + "yenə", + "yetmiş", + "yox", + "yoxdur", + "yoxsa", + "yüz", + "yəni", + "zaman", + "ı", + "ə", + "əgər", + "əlbəttə", + "əlli", + "ən", + "əslində", + ) +} + +fn danish(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "ad", + "af", + "alle", + "alt", + "anden", + "at", + "blev", + "blive", + "bliver", + "da", + "de", + "dem", + "den", + "denne", + "der", + "deres", + "det", + "dette", + "dig", + "din", + "disse", + "dog", + "du", + "efter", + "eller", + "en", + "end", + "er", + "et", + "for", + "fra", + "ham", + "han", + "hans", + "har", + "havde", + "have", + "hende", + "hendes", + "her", + "hos", + "hun", + "hvad", + "hvis", + "hvor", + "i", + "ikke", + "ind", + "jeg", + "jer", + "jo", + "kunne", + "man", + "mange", + "med", + "meget", + "men", + "mig", + "min", + "mine", + "mit", + "mod", + "når", + "ned", + "noget", + "nogle", + "nu", + "og", + "også", + "om", + "op", + "os", + "over", + "på", + "sådan", + "selv", + "sig", + "sin", + "sine", + "sit", + "skal", + "skulle", + "som", + "thi", + "til", + "ud", + "under", + "var", + "være", + "været", + "vi", + "vil", + "ville", + "vor", + ) +} + +fn dutch(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "aan", + "al", + "alles", + "als", + "altijd", + "andere", + "ben", + "bij", + "daar", + "dan", + "dat", + "de", + "der", + "deze", + "die", + "dit", + "doch", + "doen", + "door", + "dus", + "een", + "eens", + "en", + "er", + "ge", + "geen", + "geweest", + "haar", + "had", + "heb", + "hebben", + "heeft", + "hem", + "het", + "hier", + "hij", + "hoe", + "hun", + "iemand", + "iets", + "ik", + "in", + "is", + "ja", + "je", + "kan", + "kon", + "kunnen", + "maar", + "me", + "meer", + "men", + "met", + "mij", + "mijn", + "moet", + "na", + "naar", + "niet", + "niets", + "nog", + "nu", + "of", + "om", + "omdat", + "onder", + "ons", + "ook", + "op", + "over", + "reeds", + "te", + "tegen", + "toch", + "toen", + "tot", + "u", + "uit", + "uw", + "van", + "veel", + "voor", + "want", + "waren", + "was", + "wat", + "werd", + "wezen", + "wie", + "wil", + "worden", + "wordt", + "zal", + "ze", + "zelf", + "zich", + "zij", + "zijn", + "zo", + "zonder", + "zou", + ) +} + +fn english(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "about", + "above", + "after", + "again", + "against", + "ain", + "all", + "am", + "an", + "and", + "any", + "are", + "aren", + "aren't", + "as", + "at", + "be", + "because", + "been", + "before", + "being", + "below", + "between", + "both", + "but", + "by", + "can", + "couldn", + "couldn't", + "d", + "did", + "didn", + "didn't", + "do", + "does", + "doesn", + "doesn't", + "doing", + "don", + "don't", + "down", + "during", + "each", + "few", + "for", + "from", + "further", + "had", + "hadn", + "hadn't", + "has", + "hasn", + "hasn't", + "have", + "haven", + "haven't", + "having", + "he", + "her", + "here", + "hers", + "herself", + "him", + "himself", + "his", + "how", + "i", + "if", + "in", + "into", + "is", + "isn", + "isn't", + "it", + "it's", + "its", + "itself", + "just", + "ll", + "m", + "ma", + "me", + "mightn", + "mightn't", + "more", + "most", + "mustn", + "mustn't", + "my", + "myself", + "needn", + "needn't", + "no", + "nor", + "not", + "now", + "o", + "of", + "off", + "on", + "once", + "only", + "or", + "other", + "our", + "ours", + "ourselves", + "out", + "over", + "own", + "re", + "s", + "same", + "shan", + "shan't", + "she", + "she's", + "should", + "should've", + "shouldn", + "shouldn't", + "so", + "some", + "such", + "t", + "than", + "that", + "that'll", + "the", + "their", + "theirs", + "them", + "themselves", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "to", + "too", + "under", + "until", + "up", + "ve", + "very", + "was", + "wasn", + "wasn't", + "we", + "were", + "weren", + "weren't", + "what", + "when", + "where", + "which", + "while", + "who", + "whom", + "why", + "will", + "with", + "won", + "won't", + "wouldn", + "wouldn't", + "y", + "you", + "you'd", + "you'll", + "you're", + "you've", + "your", + "yours", + "yourself", + "yourselves", + ) +} + +fn finnish(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "ei", + "eivät", + "emme", + "en", + "et", + "että", + "ette", + "hän", + "häneen", + "hänellä", + "hänelle", + "häneltä", + "hänen", + "hänessä", + "hänestä", + "hänet", + "häntä", + "he", + "heidän", + "heidät", + "heihin", + "heillä", + "heille", + "heiltä", + "heissä", + "heistä", + "heitä", + "itse", + "ja", + "johon", + "joiden", + "joihin", + "joiksi", + "joilla", + "joille", + "joilta", + "joina", + "joissa", + "joista", + "joita", + "joka", + "joksi", + "jolla", + "jolle", + "jolta", + "jona", + "jonka", + "jos", + "jossa", + "josta", + "jota", + "jotka", + "kanssa", + "keiden", + "keihin", + "keiksi", + "keillä", + "keille", + "keiltä", + "keinä", + "keissä", + "keistä", + "keitä", + "keneen", + "keneksi", + "kenellä", + "kenelle", + "keneltä", + "kenen", + "kenenä", + "kenessä", + "kenestä", + "kenet", + "ketä", + "ketkä", + "koska", + "kuin", + "kuka", + "kun", + "me", + "meidän", + "meidät", + "meihin", + "meillä", + "meille", + "meiltä", + "meissä", + "meistä", + "meitä", + "mihin", + "mikä", + "miksi", + "millä", + "mille", + "miltä", + "minä", + "minkä", + "minua", + "minulla", + "minulle", + "minulta", + "minun", + "minussa", + "minusta", + "minut", + "minuun", + "missä", + "mistä", + "mitä", + "mitkä", + "mukaan", + "mutta", + "näiden", + "näihin", + "näiksi", + "näillä", + "näille", + "näiltä", + "näinä", + "näissä", + "näistä", + "näitä", + "nämä", + "ne", + "niiden", + "niihin", + "niiksi", + "niillä", + "niille", + "niiltä", + "niin", + "niinä", + "niissä", + "niistä", + "niitä", + "noiden", + "noihin", + "noiksi", + "noilla", + "noille", + "noilta", + "noin", + "noina", + "noissa", + "noista", + "noita", + "nuo", + "nyt", + "ole", + "olemme", + "olen", + "olet", + "olette", + "oli", + "olimme", + "olin", + "olisi", + "olisimme", + "olisin", + "olisit", + "olisitte", + "olisivat", + "olit", + "olitte", + "olivat", + "olla", + "olleet", + "ollut", + "on", + "ovat", + "poikki", + "se", + "sekä", + "sen", + "siihen", + "siinä", + "siitä", + "siksi", + "sillä", + "sille", + "siltä", + "sinä", + "sinua", + "sinulla", + "sinulle", + "sinulta", + "sinun", + "sinussa", + "sinusta", + "sinut", + "sinuun", + "sitä", + "tähän", + "tai", + "täksi", + "tallä", + "tälle", + "tältä", + "tämä", + "tämän", + "tänä", + "tässä", + "tästä", + "tätä", + "te", + "teidän", + "teidät", + "teihin", + "teillä", + "teille", + "teiltä", + "teissä", + "teistä", + "teitä", + "tuo", + "tuohon", + "tuoksi", + "tuolla", + "tuolle", + "tuolta", + "tuon", + "tuona", + "tuossa", + "tuosta", + "tuotä", + "vaan", + "vai", + "vaikka", + "yli", + ) +} + +fn french(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "à", + "ai", + "aie", + "aient", + "aies", + "ait", + "as", + "au", + "aura", + "aurai", + "auraient", + "aurais", + "aurait", + "auras", + "aurez", + "auriez", + "aurions", + "aurons", + "auront", + "aux", + "avaient", + "avais", + "avait", + "avec", + "avez", + "aviez", + "avions", + "avons", + "ayant", + "ayante", + "ayantes", + "ayants", + "ayez", + "ayons", + "c", + "ce", + "ces", + "d", + "dans", + "de", + "des", + "du", + "elle", + "en", + "es", + "est", + "et", + "étaient", + "étais", + "était", + "étant", + "étante", + "étantes", + "étants", + "été", + "étée", + "étées", + "étés", + "êtes", + "étiez", + "étions", + "eu", + "eue", + "eues", + "eûmes", + "eurent", + "eus", + "eusse", + "eussent", + "eusses", + "eussiez", + "eussions", + "eut", + "eût", + "eûtes", + "eux", + "fûmes", + "furent", + "fus", + "fusse", + "fussent", + "fusses", + "fussiez", + "fussions", + "fut", + "fût", + "fûtes", + "il", + "j", + "je", + "l", + "la", + "le", + "leur", + "lui", + "m", + "ma", + "mais", + "me", + "même", + "mes", + "moi", + "mon", + "n", + "ne", + "nos", + "notre", + "nous", + "on", + "ont", + "ou", + "par", + "pas", + "pour", + "qu", + "que", + "qui", + "s", + "sa", + "se", + "sera", + "serai", + "seraient", + "serais", + "serait", + "seras", + "serez", + "seriez", + "serions", + "serons", + "seront", + "ses", + "soient", + "sois", + "soit", + "sommes", + "son", + "sont", + "soyez", + "soyons", + "suis", + "sur", + "t", + "ta", + "te", + "tes", + "toi", + "ton", + "tu", + "un", + "une", + "vos", + "votre", + "vous", + "y", + ) +} + +fn german(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "aber", + "alle", + "allem", + "allen", + "aller", + "alles", + "als", + "also", + "am", + "an", + "ander", + "andere", + "anderem", + "anderen", + "anderer", + "anderes", + "anderm", + "andern", + "anderr", + "anders", + "auch", + "auf", + "aus", + "bei", + "bin", + "bis", + "bist", + "da", + "damit", + "dann", + "das", + "dasselbe", + "dazu", + "daß", + "dein", + "deine", + "deinem", + "deinen", + "deiner", + "deines", + "dem", + "demselben", + "den", + "denn", + "denselben", + "der", + "derer", + "derselbe", + "derselben", + "des", + "desselben", + "dessen", + "dich", + "die", + "dies", + "diese", + "dieselbe", + "dieselben", + "diesem", + "diesen", + "dieser", + "dieses", + "dir", + "doch", + "dort", + "du", + "durch", + "ein", + "eine", + "einem", + "einen", + "einer", + "eines", + "einig", + "einige", + "einigem", + "einigen", + "einiger", + "einiges", + "einmal", + "er", + "es", + "etwas", + "euch", + "euer", + "eure", + "eurem", + "euren", + "eurer", + "eures", + "für", + "gegen", + "gewesen", + "hab", + "habe", + "haben", + "hat", + "hatte", + "hatten", + "hier", + "hin", + "hinter", + "ich", + "ihm", + "ihn", + "ihnen", + "ihr", + "ihre", + "ihrem", + "ihren", + "ihrer", + "ihres", + "im", + "in", + "indem", + "ins", + "ist", + "jede", + "jedem", + "jeden", + "jeder", + "jedes", + "jene", + "jenem", + "jenen", + "jener", + "jenes", + "jetzt", + "kann", + "kein", + "keine", + "keinem", + "keinen", + "keiner", + "keines", + "können", + "könnte", + "machen", + "man", + "manche", + "manchem", + "manchen", + "mancher", + "manches", + "mein", + "meine", + "meinem", + "meinen", + "meiner", + "meines", + "mich", + "mir", + "mit", + "muss", + "musste", + "nach", + "nicht", + "nichts", + "noch", + "nun", + "nur", + "ob", + "oder", + "ohne", + "sehr", + "sein", + "seine", + "seinem", + "seinen", + "seiner", + "seines", + "selbst", + "sich", + "sie", + "sind", + "so", + "solche", + "solchem", + "solchen", + "solcher", + "solches", + "soll", + "sollte", + "sondern", + "sonst", + "über", + "um", + "und", + "uns", + "unser", + "unsere", + "unserem", + "unseren", + "unseres", + "unter", + "viel", + "vom", + "von", + "vor", + "während", + "war", + "waren", + "warst", + "was", + "weg", + "weil", + "weiter", + "welche", + "welchem", + "welchen", + "welcher", + "welches", + "wenn", + "werde", + "werden", + "wie", + "wieder", + "will", + "wir", + "wird", + "wirst", + "wo", + "wollen", + "wollte", + "würde", + "würden", + "zu", + "zum", + "zur", + "zwar", + "zwischen", + ) +} + +fn greek(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "η", + "κ", + "ο", + "ἃ", + "ἡ", + "ἢ", + "ἣ", + "ἤ", + "ἥ", + "ὁ", + "ὃ", + "ὅ", + "ὦ", + "ᾧ", + "δ'", + "αν", + "αἱ", + "αἳ", + "αἵ", + "αὖ", + "γα", + "γε", + "δέ", + "δή", + "δε", + "δὲ", + "δὴ", + "δ’", + "επ", + "εἰ", + "εἴ", + "θα", + "κι", + "μή", + "μα", + "με", + "μη", + "μὴ", + "να", + "οι", + "οἱ", + "οἳ", + "οὐ", + "οὗ", + "σε", + "σύ", + "σὺ", + "τά", + "τί", + "τα", + "τε", + "τι", + "το", + "τό", + "τὰ", + "τὸ", + "τῇ", + "τῷ", + "ωσ", + "ἀπ", + "ἀφ", + "ἂν", + "ἄν", + "ἐκ", + "ἐν", + "ἐξ", + "ἐφ", + "ἧς", + "ὃν", + "ὃς", + "ὅς", + "ὅσ", + "ὑπ", + "ὡς", + "ὡσ", + "ὥς", + "δι'", + "γα^", + "απο", + "γάρ", + "για", + "γὰρ", + "δαί", + "δαὶ", + "δεν", + "διά", + "διὰ", + "εαν", + "ενω", + "επι", + "εἰς", + "εἰσ", + "καί", + "καθ", + "και", + "κατ", + "καὶ", + "κἀν", + "κἂν", + "μέν", + "μεθ", + "μετ", + "μην", + "μἐν", + "μὲν", + "μὴν", + "οσο", + "οτι", + "οἷς", + "οὐδ", + "οὐκ", + "οὐχ", + "οὓς", + "οὖν", + "παρ", + "που", + "ποῦ", + "προ", + "πρὸ", + "πως", + "πωσ", + "στη", + "στο", + "σόσ", + "σύν", + "σὸς", + "σὺν", + "τήν", + "τίς", + "τίσ", + "την", + "τησ", + "τις", + "τισ", + "τοί", + "τοι", + "τον", + "του", + "τοῦ", + "των", + "τόν", + "τὰς", + "τὴν", + "τὸν", + "τῆς", + "τῆσ", + "τῶν", + "ἀπό", + "ἀπὸ", + "ἄρα", + "ἅμα", + "ἐάν", + "ἐγώ", + "ἐγὼ", + "ἐπί", + "ἐπὶ", + "ἐὰν", + "ἔτι", + "ἵνα", + "ὅδε", + "ὅτε", + "ὅτι", + "ὑπό", + "ὑπὸ", + "ἀλλ'", + "αλλα", + "αντι", + "αυτα", + "αυτη", + "αυτο", + "γοῦν", + "δαίσ", + "δαὶς", + "εἰμί", + "εἰμὶ", + "εἴμι", + "εἴτε", + "ισωσ", + "κατά", + "κατα", + "κατὰ", + "μήτε", + "μετά", + "μετα", + "μετὰ", + "ομωσ", + "οπωσ", + "οὐδέ", + "οὐδὲ", + "οὐχὶ", + "οὔτε", + "οὕτω", + "παρά", + "παρα", + "παρὰ", + "περί", + "περὶ", + "ποια", + "ποιο", + "ποτε", + "προσ", + "πρόσ", + "πρὸς", + "στην", + "στον", + "ταῖς", + "τινα", + "τοτε", + "τούσ", + "τοὺς", + "τοῖς", + "τότε", + "ἀλλά", + "ἀλλὰ", + "ἀλλ’", + "ἐμόσ", + "ἐμὸς", + "ἐπεὶ", + "ἐστι", + "ὅθεν", + "ὅπερ", + "ὑμόσ", + "ὑπέρ", + "ὑπὲρ", + "ὥστε", + "αυτεσ", + "αυτοι", + "αυτοσ", + "αυτων", + "αὐτόσ", + "αὐτὸς", + "ειμαι", + "ειναι", + "εισαι", + "ειστε", + "οὐδὲν", + "οὕτως", + "οὕτωσ", + "οὗτος", + "οὗτοσ", + "ποιεσ", + "ποιοι", + "ποιοσ", + "ποιων", + "ἄλλος", + "ἄλλοσ", + "ὅστις", + "ὅστισ", + "αυτουσ", + "εκεινα", + "εκεινη", + "εκεινο", + "καίτοι", + "οὐδείσ", + "οὐδεὶς", + "ποιουσ", + "ἑαυτοῦ", + "ειμαστε", + "εκεινεσ", + "εκεινοι", + "εκεινοσ", + "εκεινων", + "εκεινουσ", + "τοιοῦτος", + "τοιοῦτοσ", + ) +} + +fn hungarian(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "abban", + "ahhoz", + "ahogy", + "ahol", + "aki", + "akik", + "akkor", + "alatt", + "által", + "általában", + "amely", + "amelyek", + "amelyekben", + "amelyeket", + "amelyet", + "amelynek", + "ami", + "amíg", + "amikor", + "amit", + "amolyan", + "annak", + "arra", + "arról", + "át", + "az", + "azért", + "azok", + "azon", + "azonban", + "azt", + "aztán", + "azután", + "azzal", + "bár", + "be", + "belül", + "benne", + "cikk", + "cikkek", + "cikkeket", + "csak", + "de", + "e", + "ebben", + "eddig", + "egész", + "egy", + "egyéb", + "egyes", + "egyetlen", + "egyik", + "egyre", + "ehhez", + "ekkor", + "el", + "elég", + "ellen", + "elõ", + "elõször", + "elõtt", + "elsõ", + "emilyen", + "én", + "ennek", + "éppen", + "erre", + "és", + "ez", + "ezek", + "ezen", + "ezért", + "ezt", + "ezzel", + "fel", + "felé", + "hanem", + "hiszen", + "hogy", + "hogyan", + "igen", + "így", + "ill", + "ill.", + "illetve", + "ilyen", + "ilyenkor", + "ismét", + "ison", + "itt", + "jó", + "jobban", + "jól", + "kell", + "kellett", + "keressünk", + "keresztül", + "ki", + "kívül", + "között", + "közül", + "legalább", + "legyen", + "lehet", + "lehetett", + "lenne", + "lenni", + "lesz", + "lett", + "maga", + "magát", + "majd", + "már", + "más", + "másik", + "meg", + "még", + "mellett", + "mely", + "melyek", + "mert", + "mi", + "miért", + "míg", + "mikor", + "milyen", + "minden", + "mindenki", + "mindent", + "mindig", + "mint", + "mintha", + "mit", + "mivel", + "most", + "nagy", + "nagyobb", + "nagyon", + "ne", + "néha", + "néhány", + "nekem", + "neki", + "nélkül", + "nem", + "nincs", + "õ", + "õk", + "õket", + "olyan", + "össze", + "ott", + "pedig", + "persze", + "rá", + "s", + "saját", + "sem", + "semmi", + "sok", + "sokat", + "sokkal", + "számára", + "szemben", + "szerint", + "szinte", + "talán", + "tehát", + "teljes", + "több", + "tovább", + "továbbá", + "úgy", + "ugyanis", + "új", + "újabb", + "újra", + "után", + "utána", + "utolsó", + "vagy", + "vagyis", + "vagyok", + "valaki", + "valami", + "valamint", + "való", + "van", + "vannak", + "vele", + "vissza", + "viszont", + "volna", + "volt", + "voltak", + "voltam", + "voltunk", + ) +} + +fn italian(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "abbia", + "abbiamo", + "abbiano", + "abbiate", + "ad", + "agl", + "agli", + "ai", + "al", + "all", + "alla", + "alle", + "allo", + "anche", + "avemmo", + "avendo", + "avesse", + "avessero", + "avessi", + "avessimo", + "aveste", + "avesti", + "avete", + "aveva", + "avevamo", + "avevano", + "avevate", + "avevi", + "avevo", + "avrà", + "avrai", + "avranno", + "avrebbe", + "avrebbero", + "avrei", + "avremmo", + "avremo", + "avreste", + "avresti", + "avrete", + "avrò", + "avuta", + "avute", + "avuti", + "avuto", + "c", + "che", + "chi", + "ci", + "coi", + "col", + "come", + "con", + "contro", + "cui", + "da", + "dagl", + "dagli", + "dai", + "dal", + "dall", + "dalla", + "dalle", + "dallo", + "degl", + "degli", + "dei", + "del", + "dell", + "della", + "delle", + "dello", + "di", + "dov", + "dove", + "e", + "è", + "ebbe", + "ebbero", + "ebbi", + "ed", + "era", + "erano", + "eravamo", + "eravate", + "eri", + "ero", + "essendo", + "faccia", + "facciamo", + "facciano", + "facciate", + "faccio", + "facemmo", + "facendo", + "facesse", + "facessero", + "facessi", + "facessimo", + "faceste", + "facesti", + "faceva", + "facevamo", + "facevano", + "facevate", + "facevi", + "facevo", + "fai", + "fanno", + "farà", + "farai", + "faranno", + "farebbe", + "farebbero", + "farei", + "faremmo", + "faremo", + "fareste", + "faresti", + "farete", + "farò", + "fece", + "fecero", + "feci", + "fosse", + "fossero", + "fossi", + "fossimo", + "foste", + "fosti", + "fu", + "fui", + "fummo", + "furono", + "gli", + "ha", + "hai", + "hanno", + "ho", + "i", + "il", + "in", + "io", + "l", + "la", + "le", + "lei", + "li", + "lo", + "loro", + "lui", + "ma", + "mi", + "mia", + "mie", + "miei", + "mio", + "ne", + "negl", + "negli", + "nei", + "nel", + "nell", + "nella", + "nelle", + "nello", + "noi", + "non", + "nostra", + "nostre", + "nostri", + "nostro", + "o", + "per", + "perché", + "più", + "quale", + "quanta", + "quante", + "quanti", + "quanto", + "quella", + "quelle", + "quelli", + "quello", + "questa", + "queste", + "questi", + "questo", + "sarà", + "sarai", + "saranno", + "sarebbe", + "sarebbero", + "sarei", + "saremmo", + "saremo", + "sareste", + "saresti", + "sarete", + "sarò", + "se", + "sei", + "si", + "sia", + "siamo", + "siano", + "siate", + "siete", + "sono", + "sta", + "stai", + "stando", + "stanno", + "starà", + "starai", + "staranno", + "starebbe", + "starebbero", + "starei", + "staremmo", + "staremo", + "stareste", + "staresti", + "starete", + "starò", + "stava", + "stavamo", + "stavano", + "stavate", + "stavi", + "stavo", + "stemmo", + "stesse", + "stessero", + "stessi", + "stessimo", + "steste", + "stesti", + "stette", + "stettero", + "stetti", + "stia", + "stiamo", + "stiano", + "stiate", + "sto", + "su", + "sua", + "sue", + "sugl", + "sugli", + "sui", + "sul", + "sull", + "sulla", + "sulle", + "sullo", + "suo", + "suoi", + "ti", + "tra", + "tu", + "tua", + "tue", + "tuo", + "tuoi", + "tutti", + "tutto", + "un", + "una", + "uno", + "vi", + "voi", + "vostra", + "vostre", + "vostri", + "vostro", + ) +} + +fn norwegian(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "å", + "alle", + "at", + "av", + "både", + "båe", + "bare", + "begge", + "ble", + "blei", + "bli", + "blir", + "blitt", + "da", + "då", + "de", + "deg", + "dei", + "deim", + "deira", + "deires", + "dem", + "den", + "denne", + "der", + "dere", + "deres", + "det", + "dette", + "di", + "din", + "disse", + "ditt", + "du", + "dykk", + "dykkar", + "eg", + "ein", + "eit", + "eitt", + "eller", + "elles", + "en", + "enn", + "er", + "et", + "ett", + "etter", + "for", + "før", + "fordi", + "fra", + "ha", + "hadde", + "han", + "hans", + "har", + "hennar", + "henne", + "hennes", + "her", + "hjå", + "ho", + "hoe", + "honom", + "hoss", + "hossen", + "hun", + "hva", + "hvem", + "hver", + "hvilke", + "hvilken", + "hvis", + "hvor", + "hvordan", + "hvorfor", + "i", + "ikke", + "ikkje", + "ingen", + "ingi", + "inkje", + "inn", + "inni", + "ja", + "jeg", + "kan", + "kom", + "korleis", + "korso", + "kun", + "kunne", + "kva", + "kvar", + "kvarhelst", + "kven", + "kvi", + "kvifor", + "man", + "mange", + "me", + "med", + "medan", + "meg", + "meget", + "mellom", + "men", + "mi", + "min", + "mine", + "mitt", + "mot", + "mykje", + "nå", + "når", + "ned", + "no", + "noe", + "noen", + "noka", + "noko", + "nokon", + "nokor", + "nokre", + "og", + "også", + "om", + "opp", + "oss", + "over", + "på", + "så", + "samme", + "sånn", + "seg", + "selv", + "si", + "sia", + "sidan", + "siden", + "sin", + "sine", + "sitt", + "sjøl", + "skal", + "skulle", + "slik", + "so", + "som", + "somme", + "somt", + "til", + "um", + "upp", + "ut", + "uten", + "var", + "vår", + "være", + "vart", + "vært", + "varte", + "ved", + "vere", + "verte", + "vi", + "vil", + "ville", + "vore", + "vors", + "vort", + ) +} + +fn nepali(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "छ", + "त", + "न", + "म", + "र", + "अब", + "आए", + "उप", + "एक", + "ओठ", + "औं", + "का", + "कि", + "के", + "को", + "गए", + "छु", + "छू", + "जब", + "जे", + "जो", + "तर", + "तल", + "ती", + "नि", + "नै", + "नौ", + "भए", + "भन", + "भर", + "मा", + "यस", + "या", + "यी", + "यो", + "ले", + "सो", + "हो", + "कम से कम", + "अझै", + "अरु", + "अलग", + "आदि", + "आफू", + "आयो", + "कतै", + "कसै", + "किन", + "गयौ", + "गरि", + "गरी", + "गैर", + "चार", + "छन्", + "छैन", + "छौं", + "जान", + "जुन", + "ठीक", + "तथा", + "तिर", + "तीन", + "थिए", + "दिए", + "दुई", + "पछि", + "पटक", + "पनि", + "बने", + "बरु", + "बीच", + "भने", + "भन्", + "यति", + "यदि", + "यसो", + "रही", + "रूप", + "लाई", + "संग", + "सधै", + "सबै", + "समय", + "सही", + "सात", + "साथ", + "हरे", + "हुन", + "अन्य", + "आजको", + "आत्म", + "उनको", + "उनले", + "एउटै", + "एकदम", + "कसरी", + "कुनै", + "कुरा", + "केही", + "कोही", + "गरेर", + "गरौं", + "गर्छ", + "गर्न", + "चाले", + "जबकि", + "जसको", + "जसमा", + "जसले", + "जहाँ", + "तपाई", + "तिनी", + "तिमी", + "त्यो", + "थिएन", + "थियो", + "देखि", + "देखे", + "धेरै", + "नत्र", + "नयाँ", + "पर्छ", + "पाँच", + "प्लस", + "फेरी", + "बारे", + "भएको", + "मलाई", + "माथि", + "मेरो", + "यसको", + "यसरी", + "यहाँ", + "राखे", + "लगभग", + "लागि", + "शायद", + "संगै", + "सक्छ", + "सम्म", + "साथै", + "सायद", + "सारा", + "सोही", + "हरेक", + "हुने", + "हुन्", + "अक्सर", + "अगाडी", + "अर्को", + "आफ्नै", + "आफ्नो", + "कसैले", + "कृपया", + "गरेका", + "गरेको", + "गर्छु", + "गर्दै", + "गर्नु", + "गर्ने", + "चाहिए", + "जसबाट", + "जसलाई", + "जस्तै", + "जस्तो", + "जाहिर", + "तापनी", + "देखेर", + "नजिकै", + "निम्न", + "पक्का", + "पक्कै", + "पहिले", + "पहिलो", + "पूर्व", + "प्रति", + "बाहिर", + "बाहेक", + "बिशेष", + "बीचमा", + "भन्छु", + "भन्दा", + "भन्ने", + "भित्र", + "मात्र", + "मुख्य", + "यसपछि", + "यस्तो", + "रहेका", + "रहेको", + "राख्छ", + "सट्टा", + "सम्भव", + "हुन्छ", + "अनुसार", + "अन्यथा", + "अरुलाई", + "अर्थात", + "आफूलाई", + "उदाहरण", + "उहालाई", + "किनभने", + "क्रमशः", + "जताततै", + "तत्काल", + "तपाईको", + "तेस्रो", + "त्यहाँ", + "त्सपछि", + "त्सैले", + "देखियो", + "देखेको", + "दोस्रो", + "निम्ति", + "पाँचौं", + "प्रतेक", + "भन्छन्", + "भित्री", + "यथोचित", + "यद्यपि", + "राम्रो", + "वरीपरी", + "सबैलाई", + "स्पष्ट", + "अन्यत्र", + "अर्थात्", + "कहाँबाट", + "चाहन्छु", + "तदनुसार", + "तिनीहरू", + "देखिन्छ", + "पछिल्लो", + "पर्थ्यो", + "पहिल्यै", + "बिरुद्ध", + "यसबाहेक", + "साँच्चै", + "अन्तर्गत", + "तुरुन्तै", + "तेस्कारण", + "दिनुभएको", + "पर्याप्त", + "भन्नुभयो", + "यहाँसम्म", + "वास्तवमा", + "गर्नुपर्छ", + "जस्तोसुकै", + "तिनीहरुको", + "दिनुहुन्छ", + "निर्दिष्ट", + "कहिलेकाहीं", + "चाहनुहुन्छ", + "तिनिहरुलाई", + "निम्नानुसार", + ) +} + +fn portuguese(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "à", + "ao", + "aos", + "aquela", + "aquelas", + "aquele", + "aqueles", + "aquilo", + "as", + "às", + "até", + "com", + "como", + "da", + "das", + "de", + "dela", + "delas", + "dele", + "deles", + "depois", + "do", + "dos", + "e", + "ela", + "elas", + "ele", + "eles", + "em", + "entre", + "era", + "eram", + "éramos", + "essa", + "essas", + "esse", + "esses", + "esta", + "está", + "estamos", + "estão", + "estas", + "estava", + "estavam", + "estávamos", + "este", + "esteja", + "estejam", + "estejamos", + "estes", + "esteve", + "estive", + "estivemos", + "estiver", + "estivera", + "estiveram", + "estivéramos", + "estiverem", + "estivermos", + "estivesse", + "estivessem", + "estivéssemos", + "estou", + "eu", + "foi", + "fomos", + "for", + "fora", + "foram", + "fôramos", + "forem", + "formos", + "fosse", + "fossem", + "fôssemos", + "fui", + "há", + "haja", + "hajam", + "hajamos", + "hão", + "havemos", + "hei", + "houve", + "houvemos", + "houver", + "houvera", + "houverá", + "houveram", + "houvéramos", + "houverão", + "houverei", + "houverem", + "houveremos", + "houveria", + "houveriam", + "houveríamos", + "houvermos", + "houvesse", + "houvessem", + "houvéssemos", + "isso", + "isto", + "já", + "lhe", + "lhes", + "mais", + "mas", + "me", + "mesmo", + "meu", + "meus", + "minha", + "minhas", + "muito", + "na", + "não", + "nas", + "nem", + "no", + "nos", + "nós", + "nossa", + "nossas", + "nosso", + "nossos", + "num", + "numa", + "o", + "os", + "ou", + "para", + "pela", + "pelas", + "pelo", + "pelos", + "por", + "qual", + "quando", + "que", + "quem", + "são", + "se", + "seja", + "sejam", + "sejamos", + "sem", + "será", + "serão", + "serei", + "seremos", + "seria", + "seriam", + "seríamos", + "seu", + "seus", + "só", + "somos", + "sou", + "sua", + "suas", + "também", + "te", + "tem", + "tém", + "temos", + "tenha", + "tenham", + "tenhamos", + "tenho", + "terá", + "terão", + "terei", + "teremos", + "teria", + "teriam", + "teríamos", + "teu", + "teus", + "teve", + "tinha", + "tinham", + "tínhamos", + "tive", + "tivemos", + "tiver", + "tivera", + "tiveram", + "tivéramos", + "tiverem", + "tivermos", + "tivesse", + "tivessem", + "tivéssemos", + "tu", + "tua", + "tuas", + "um", + "uma", + "você", + "vocês", + "vos", + ) +} + +fn romanian(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "abia", + "acea", + "aceasta", + "această", + "aceea", + "aceeasi", + "acei", + "aceia", + "acel", + "acela", + "acelasi", + "acele", + "acelea", + "acest", + "acesta", + "aceste", + "acestea", + "acestei", + "acestia", + "acestui", + "aceşti", + "aceştia", + "adica", + "ai", + "aia", + "aibă", + "aici", + "al", + "ala", + "ale", + "alea", + "alt", + "alta", + "altceva", + "altcineva", + "alte", + "altfel", + "alti", + "altii", + "altul", + "am", + "anume", + "apoi", + "ar", + "are", + "as", + "asa", + "asta", + "astea", + "astfel", + "asupra", + "atare", + "atat", + "atata", + "atatea", + "atatia", + "ati", + "atit", + "atita", + "atitea", + "atitia", + "atunci", + "au", + "avea", + "avem", + "aveţi", + "avut", + "aş", + "aţi", + "ba", + "ca", + "cam", + "cand", + "care", + "careia", + "carora", + "caruia", + "cat", + "cât", + "câte", + "catre", + "câtva", + "câţi", + "ce", + "cea", + "ceea", + "cei", + "ceilalti", + "cel", + "cele", + "celor", + "ceva", + "chiar", + "ci", + "cind", + "cînd", + "cine", + "cineva", + "cit", + "cît", + "cita", + "cite", + "cîte", + "citeva", + "citi", + "citiva", + "cîtva", + "cîţi", + "cu", + "cui", + "cum", + "cumva", + "că", + "căci", + "cărei", + "căror", + "cărui", + "către", + "da", + "daca", + "dacă", + "dar", + "dat", + "dată", + "dau", + "de", + "deasupra", + "deci", + "decit", + "deja", + "desi", + "despre", + "deşi", + "din", + "dintr", + "dintr-", + "dintre", + "doar", + "doi", + "doilea", + "două", + "drept", + "dupa", + "după", + "dă", + "e", + "ea", + "ei", + "el", + "ele", + "era", + "eram", + "este", + "eu", + "eşti", + "face", + "fara", + "fata", + "fel", + "fi", + "fie", + "fiecare", + "fii", + "fim", + "fiu", + "fiţi", + "foarte", + "fost", + "fără", + "i", + "ia", + "iar", + "ii", + "îi", + "il", + "îl", + "imi", + "îmi", + "in", + "în", + "inainte", + "inapoi", + "inca", + "incit", + "insa", + "intr", + "intre", + "isi", + "iti", + "îţi", + "la", + "lângă", + "le", + "li", + "lîngă", + "lor", + "lui", + "m", + "ma", + "mai", + "mâine", + "mea", + "mei", + "mele", + "mereu", + "meu", + "mi", + "mie", + "mîine", + "mine", + "mod", + "mult", + "multa", + "multe", + "multi", + "multă", + "mulţi", + "mă", + "ne", + "ni", + "nici", + "nimeni", + "nimic", + "niste", + "nişte", + "noastre", + "noastră", + "noi", + "nostri", + "nostru", + "nou", + "noua", + "nouă", + "noştri", + "nu", + "numai", + "o", + "or", + "ori", + "oricând", + "oricare", + "oricât", + "orice", + "oricînd", + "oricine", + "oricît", + "oricum", + "oriunde", + "pai", + "până", + "parca", + "patra", + "patru", + "pe", + "pentru", + "peste", + "pic", + "pina", + "pînă", + "poate", + "pot", + "prea", + "prima", + "primul", + "prin", + "printr-", + "putini", + "puţin", + "puţina", + "puţină", + "sa", + "sa-mi", + "sa-ti", + "sai", + "sale", + "sau", + "se", + "si", + "sint", + "sintem", + "spate", + "spre", + "sub", + "sunt", + "suntem", + "sunteţi", + "sus", + "să", + "săi", + "său", + "t", + "ta", + "tale", + "te", + "ti", + "tine", + "toata", + "toate", + "toată", + "tocmai", + "tot", + "toti", + "totul", + "totusi", + "totuşi", + "toţi", + "trei", + "treia", + "treilea", + "tu", + "tuturor", + "tăi", + "tău", + "u", + "ul", + "ului", + "un", + "una", + "unde", + "undeva", + "unei", + "uneia", + "unele", + "uneori", + "unii", + "unor", + "unora", + "unu", + "unui", + "unuia", + "unul", + "v", + "va", + "vi", + "voastre", + "voastră", + "voi", + "vom", + "vor", + "vostru", + "vouă", + "voştri", + "vreo", + "vreun", + "vă", + "zi", + "zice", + "şi", + "ţi", + "ţie", + "ăla", + "ălea", + "ăsta", + "ăstea", + "ăştia", + ) +} + +fn russian(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "а", + "в", + "ж", + "и", + "к", + "о", + "с", + "у", + "я", + "бы", + "во", + "вы", + "да", + "до", + "ее", + "ей", + "же", + "за", + "из", + "им", + "их", + "ли", + "мы", + "на", + "не", + "ни", + "но", + "ну", + "об", + "он", + "от", + "по", + "со", + "то", + "ты", + "уж", + "без", + "был", + "вам", + "вас", + "вот", + "все", + "всю", + "где", + "два", + "для", + "его", + "ему", + "еще", + "или", + "как", + "кто", + "мне", + "мой", + "моя", + "над", + "нас", + "нее", + "ней", + "нет", + "ним", + "них", + "она", + "они", + "под", + "при", + "про", + "раз", + "сам", + "так", + "там", + "тем", + "том", + "тот", + "три", + "тут", + "уже", + "чем", + "что", + "эти", + "эту", + "была", + "были", + "было", + "быть", + "ведь", + "всех", + "даже", + "если", + "есть", + "куда", + "меня", + "надо", + "него", + "один", + "свою", + "себе", + "себя", + "тебя", + "того", + "тоже", + "хоть", + "чего", + "чтоб", + "чуть", + "этой", + "этом", + "этот", + "более", + "будет", + "будто", + "вдруг", + "всего", + "зачем", + "здесь", + "какая", + "какой", + "когда", + "лучше", + "между", + "много", + "может", + "можно", + "опять", + "перед", + "после", + "потом", + "почти", + "разве", + "такой", + "тогда", + "через", + "чтобы", + "этого", + "больше", + "всегда", + "другой", + "иногда", + "нельзя", + "нибудь", + "ничего", + "потому", + "сейчас", + "совсем", + "теперь", + "только", + "хорошо", + "впрочем", + "конечно", + "наконец", + "никогда", + ) +} + +fn spanish(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "a", + "al", + "algo", + "algunas", + "algunos", + "ante", + "antes", + "como", + "con", + "contra", + "cual", + "cuando", + "de", + "del", + "desde", + "donde", + "durante", + "e", + "el", + "él", + "ella", + "ellas", + "ellos", + "en", + "entre", + "era", + "erais", + "éramos", + "eran", + "eras", + "eres", + "es", + "esa", + "esas", + "ese", + "eso", + "esos", + "esta", + "está", + "estaba", + "estabais", + "estábamos", + "estaban", + "estabas", + "estad", + "estada", + "estadas", + "estado", + "estados", + "estáis", + "estamos", + "están", + "estando", + "estar", + "estará", + "estarán", + "estarás", + "estaré", + "estaréis", + "estaremos", + "estaría", + "estaríais", + "estaríamos", + "estarían", + "estarías", + "estas", + "estás", + "este", + "esté", + "estéis", + "estemos", + "estén", + "estés", + "esto", + "estos", + "estoy", + "estuve", + "estuviera", + "estuvierais", + "estuviéramos", + "estuvieran", + "estuvieras", + "estuvieron", + "estuviese", + "estuvieseis", + "estuviésemos", + "estuviesen", + "estuvieses", + "estuvimos", + "estuviste", + "estuvisteis", + "estuvo", + "fue", + "fuera", + "fuerais", + "fuéramos", + "fueran", + "fueras", + "fueron", + "fuese", + "fueseis", + "fuésemos", + "fuesen", + "fueses", + "fui", + "fuimos", + "fuiste", + "fuisteis", + "ha", + "habéis", + "había", + "habíais", + "habíamos", + "habían", + "habías", + "habida", + "habidas", + "habido", + "habidos", + "habiendo", + "habrá", + "habrán", + "habrás", + "habré", + "habréis", + "habremos", + "habría", + "habríais", + "habríamos", + "habrían", + "habrías", + "han", + "has", + "hasta", + "hay", + "haya", + "hayáis", + "hayamos", + "hayan", + "hayas", + "he", + "hemos", + "hube", + "hubiera", + "hubierais", + "hubiéramos", + "hubieran", + "hubieras", + "hubieron", + "hubiese", + "hubieseis", + "hubiésemos", + "hubiesen", + "hubieses", + "hubimos", + "hubiste", + "hubisteis", + "hubo", + "la", + "las", + "le", + "les", + "lo", + "los", + "más", + "me", + "mi", + "mí", + "mía", + "mías", + "mío", + "míos", + "mis", + "mucho", + "muchos", + "muy", + "nada", + "ni", + "no", + "nos", + "nosotras", + "nosotros", + "nuestra", + "nuestras", + "nuestro", + "nuestros", + "o", + "os", + "otra", + "otras", + "otro", + "otros", + "para", + "pero", + "poco", + "por", + "porque", + "que", + "qué", + "quien", + "quienes", + "se", + "sea", + "seáis", + "seamos", + "sean", + "seas", + "sentid", + "sentida", + "sentidas", + "sentido", + "sentidos", + "será", + "serán", + "serás", + "seré", + "seréis", + "seremos", + "sería", + "seríais", + "seríamos", + "serían", + "serías", + "sí", + "siente", + "sin", + "sintiendo", + "sobre", + "sois", + "somos", + "son", + "soy", + "su", + "sus", + "suya", + "suyas", + "suyo", + "suyos", + "también", + "tanto", + "te", + "tendrá", + "tendrán", + "tendrás", + "tendré", + "tendréis", + "tendremos", + "tendría", + "tendríais", + "tendríamos", + "tendrían", + "tendrías", + "tened", + "tenéis", + "tenemos", + "tenga", + "tengáis", + "tengamos", + "tengan", + "tengas", + "tengo", + "tenía", + "teníais", + "teníamos", + "tenían", + "tenías", + "tenida", + "tenidas", + "tenido", + "tenidos", + "teniendo", + "ti", + "tiene", + "tienen", + "tienes", + "todo", + "todos", + "tu", + "tú", + "tus", + "tuve", + "tuviera", + "tuvierais", + "tuviéramos", + "tuvieran", + "tuvieras", + "tuvieron", + "tuviese", + "tuvieseis", + "tuviésemos", + "tuviesen", + "tuvieses", + "tuvimos", + "tuviste", + "tuvisteis", + "tuvo", + "tuya", + "tuyas", + "tuyo", + "tuyos", + "un", + "una", + "uno", + "unos", + "vosostras", + "vosostros", + "vuestra", + "vuestras", + "vuestro", + "vuestros", + "y", + "ya", + "yo", + ) +} + +fn swedish(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "alla", + "allt", + "än", + "är", + "åt", + "att", + "av", + "blev", + "bli", + "blir", + "blivit", + "då", + "där", + "de", + "dem", + "den", + "denna", + "deras", + "dess", + "dessa", + "det", + "detta", + "dig", + "din", + "dina", + "ditt", + "du", + "efter", + "ej", + "eller", + "en", + "er", + "era", + "ert", + "ett", + "för", + "från", + "ha", + "hade", + "han", + "hans", + "har", + "här", + "henne", + "hennes", + "hon", + "honom", + "hur", + "i", + "icke", + "ingen", + "inom", + "inte", + "jag", + "ju", + "kan", + "kunde", + "man", + "med", + "mellan", + "men", + "mig", + "min", + "mina", + "mitt", + "mot", + "mycket", + "någon", + "något", + "några", + "när", + "ni", + "nu", + "och", + "om", + "oss", + "över", + "på", + "så", + "sådan", + "sådana", + "sådant", + "samma", + "sedan", + "sig", + "sin", + "sina", + "sitta", + "själv", + "skulle", + "som", + "till", + "under", + "upp", + "ut", + "utan", + "vad", + "var", + "vår", + "vara", + "våra", + "varför", + "varit", + "varje", + "vars", + "vart", + "vårt", + "vem", + "vi", + "vid", + "vilka", + "vilkas", + "vilken", + "vilket", + ) +} + +fn turkish(input: &str) -> bool { + hashify::set!( + input.as_bytes(), + "acaba", + "ama", + "aslında", + "az", + "bazı", + "belki", + "biri", + "birkaç", + "birşey", + "biz", + "bu", + "çok", + "çünkü", + "da", + "daha", + "de", + "defa", + "diye", + "en", + "eğer", + "gibi", + "hem", + "hep", + "hepsi", + "her", + "hiç", + "için", + "ile", + "ise", + "kez", + "ki", + "kim", + "mu", + "mü", + "mı", + "nasıl", + "ne", + "neden", + "nerde", + "nerede", + "nereye", + "niçin", + "niye", + "o", + "sanki", + "siz", + "tüm", + "ve", + "veya", + "ya", + "yani", + "şey", + "şu", + ) +} + +/* +Not yet available for auto-detection + +static KAZAKH: Set<&'static str> = phf_set! { + "", + "е", + "о", + "я", + "ә", + "ай", + "ал", + "ау", + "ах", + "ей", + "еш", + "ие", + "кә", + "ой", + "ол", + "ох", + "па", + "уа", + "эй", + "эх", + "әй", + "өз", + "өй", + "ана", + "арс", + "аһа", + "бар", + "беу", + "біз", + "бұл", + "жоқ", + "кәһ", + "мен", + "моһ", + "осы", + "оһо", + "пай", + "сен", + "сол", + "соң", + "сіз", + "тек", + "тәк", + "уай", + "уау", + "ура", + "шек", + "ырс", + "ырқ", + "ыңқ", + "ірк", + "қап", + "құр", + "үйт", + "әні", + "өзі", + "арс-ұрс", + "пай-пай", + "паһ-паһ", + "қош-қош", + "анау", + "барқ", + "бері", + "бойы", + "болп", + "борт", + "былп", + "бүйт", + "бәрі", + "гүрс", + "гөрі", + "дүрс", + "дүңк", + "емес", + "жалп", + "желп", + "жуық", + "кірт", + "күрт", + "күңк", + "кәне", + "кәні", + "маңқ", + "морт", + "мына", + "мышы", + "мыңқ", + "міне", + "одан", + "олар", + "онда", + "оның", + "оған", + "пфша", + "пырс", + "пішә", + "сарт", + "саңқ", + "сона", + "сыңқ", + "тарс", + "таяу", + "тағы", + "таңқ", + "тырс", + "тыңқ", + "түге", + "шаңқ", + "шырт", + "шіңк", + "шәйт", + "ғана", + "қана", + "қолп", + "қорс", + "қоса", + "қыңқ", + "үшін", + "әйда", + "әрне", + "өзге", + "өзім", + "өзің", + "жалт-жалт", + "жалт-жұлт", + "сарт-сұрт", + "тарс-тұрс", + "шаңқ-шаңқ", + "шаңқ-шұңқ", + "қалт-қалт", + "қалт-құлт", + "қаңқ-қаңқ", + "қаңқ-құңқ", + "барша", + "бетер", + "бізге", + "бірақ", + "бірге", + "біреу", + "бүкіл", + "бұрын", + "дейін", + "ешбір", + "ешкім", + "кейін", + "күллі", + "күшім", + "маған", + "менде", + "менен", + "менің", + "мынау", + "пішту", + "сайын", + "салым", + "саған", + "сенде", + "сенен", + "сенің", + "солай", + "сонау", + "сорап", + "сізге", + "таман", + "тарта", + "түгел", + "шақты", + "шейін", + "ғұрлы", + "қарай", + "қатар", + "құрау", + "әрбір", + "әрине", + "әркім", + "әттең", + "әукім", + "өзіме", + "өзіне", + "сенен онан", + "арбаң-арбаң", + "батыр-бұтыр", + "далаң-далаң", + "митың-митың", + "салаң-сұлаң", + "құрау-құрау", + "ыржың-тыржың", + "алайда", + "алатау", + "алақай", + "арнайы", + "арқылы", + "барлық", + "бізбен", + "бізден", + "біздер", + "біздің", + "бұндай", + "дәнеңе", + "ештеме", + "кейбір", + "кәнеки", + "мұндай", + "оларға", + "онымен", + "осылай", + "осынау", + "себебі", + "сияқты", + "сондай", + "сізбен", + "сізден", + "сіздер", + "сіздің", + "тағыда", + "туралы", + "шамалы", + "шіркін", + "ғұрлым", + "қаралы", + "әлдене", + "өзінің", + "бүгжең-бүгжең", + "тарбаң-тарбаң", + "қайқаң-құйқаң", + "қаңғыр-күңгір", + "бойымен", + "бірдеме", + "бірнеше", + "ешқайсы", + "ешқашан", + "менімен", + "олардан", + "олардың", + "олармен", + "осындай", + "сенімен", + "сонымен", + "япырмай", + "әйтпесе", + "әлдекім", + "әншейін", + "әрқайсы", + "әрқалай", + "өзімнің", + "өйткені", + "әттеген-ай", + "арсалаң-арсалаң", + "ербелең-ербелең", + "қызараң-қызараң", + "айтпақшы", + "біздерге", + "дегенмен", + "ешқандай", + "кейбіреу", + "масқарай", + "мәссаған", + "ойпырмай", + "сіздерге", + "қайсыбір", + "әлденеше", + "алдақашан", + "біздерден", + "біздердің", + "біздермен", + "бәрекелді", + "сондықтан", + "сіздерден", + "сіздердің", + "сіздермен", + "әйткенмен", + "әлдеқалай", + "әлдеқашан", + "әттегенай", + "әлдеқайдан", + "астапыралла", + "жаракімалла", +}; +*/ diff --git a/crates/nlp/src/lib.rs b/crates/nlp/src/lib.rs new file mode 100644 index 0000000..a92aeda --- /dev/null +++ b/crates/nlp/src/lib.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod classifier; +pub mod language; +pub mod tokenizers; diff --git a/crates/nlp/src/tokenizers/chinese.rs b/crates/nlp/src/tokenizers/chinese.rs new file mode 100644 index 0000000..8c4a607 --- /dev/null +++ b/crates/nlp/src/tokenizers/chinese.rs @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, sync::LazyLock, vec::IntoIter}; + +use jieba_rs::Jieba; + +use super::{InnerToken, Token}; + +pub(crate) static JIEBA: LazyLock = LazyLock::new(Jieba::new); + +pub struct ChineseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + tokenizer: T, + tokens: IntoIter>, + phantom: std::marker::PhantomData<&'x str>, +} + +impl<'x, T, I> ChineseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + pub fn new(tokenizer: T) -> Self { + ChineseTokenizer { + tokenizer, + tokens: Vec::new().into_iter(), + phantom: std::marker::PhantomData, + } + } +} + +impl<'x, T, I> Iterator for ChineseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + type Item = Token; + + fn next(&mut self) -> Option { + loop { + if let Some(token) = self.tokens.next() { + return Some(token); + } else { + let token = self.tokenizer.next()?; + if token.word.is_alphabetic_8bit() { + let mut token_to = token.from; + match token.word.unwrap_alphabetic() { + Cow::Borrowed(word) => { + self.tokens = JIEBA + .cut(word, false) + .into_iter() + .map(|cut| { + let token_from = token_to; + token_to += cut.word.len(); + Token { + word: I::new_alphabetic(cut.word), + from: token_from, + to: token_to, + } + }) + .collect::>() + .into_iter(); + } + Cow::Owned(word) => { + self.tokens = JIEBA + .cut(&word, false) + .into_iter() + .map(|cut| { + let token_from = token_to; + token_to += cut.word.len(); + Token { + word: I::new_alphabetic(cut.word.to_string()), + from: token_from, + to: token_to, + } + }) + .collect::>() + .into_iter(); + } + } + } else { + return token.into(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::tokenizers::{Token, chinese::ChineseTokenizer, word::WordTokenizer}; + + #[test] + fn chinese_tokenizer() { + assert_eq!( + ChineseTokenizer::new(WordTokenizer::new( + "孫子曰:兵者,國之大事,死生之地,存亡之道,不可不察也。", + 40 + ),) + .collect::>(), + vec![ + Token { + word: "孫".into(), + from: 0, + to: 3 + }, + Token { + word: "子".into(), + from: 3, + to: 6 + }, + Token { + word: "曰".into(), + from: 6, + to: 9 + }, + Token { + word: "兵".into(), + from: 12, + to: 15 + }, + Token { + word: "者".into(), + from: 15, + to: 18 + }, + Token { + word: "國".into(), + from: 21, + to: 24 + }, + Token { + word: "之".into(), + from: 24, + to: 27 + }, + Token { + word: "大事".into(), + from: 27, + to: 33 + }, + Token { + word: "死".into(), + from: 36, + to: 39 + }, + Token { + word: "生".into(), + from: 39, + to: 42 + }, + Token { + word: "之".into(), + from: 42, + to: 45 + }, + Token { + word: "地".into(), + from: 45, + to: 48 + }, + Token { + word: "存亡".into(), + from: 51, + to: 57 + }, + Token { + word: "之".into(), + from: 57, + to: 60 + }, + Token { + word: "道".into(), + from: 60, + to: 63 + }, + Token { + word: "不可不".into(), + from: 66, + to: 75 + }, + Token { + word: "察".into(), + from: 75, + to: 78 + }, + Token { + word: "也".into(), + from: 78, + to: 81 + } + ] + ); + } +} diff --git a/crates/nlp/src/tokenizers/japanese.rs b/crates/nlp/src/tokenizers/japanese.rs new file mode 100644 index 0000000..123f536 --- /dev/null +++ b/crates/nlp/src/tokenizers/japanese.rs @@ -0,0 +1,407 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{InnerToken, Token}; +use maplit::hashmap; +use std::collections::HashMap; +use std::vec::IntoIter; +use std::{hash::Hash, sync::LazyLock}; + +pub struct JapaneseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + tokenizer: T, + tokens: IntoIter>, + phantom: std::marker::PhantomData<&'x str>, +} + +impl<'x, T, I> JapaneseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + pub fn new(tokenizer: T) -> Self { + JapaneseTokenizer { + tokenizer, + tokens: Vec::new().into_iter(), + phantom: std::marker::PhantomData, + } + } +} + +impl<'x, T, I> Iterator for JapaneseTokenizer<'x, T, I> +where + T: Iterator>, + I: InnerToken<'x>, +{ + type Item = Token; + + fn next(&mut self) -> Option { + loop { + if let Some(token) = self.tokens.next() { + return Some(token); + } else { + let token = self.tokenizer.next()?; + if token.word.is_alphabetic_8bit() { + let mut token_to = token.from; + self.tokens = tokenize(token.word.unwrap_alphabetic().as_ref()) + .into_iter() + .map(|word| { + let token_from = token_to; + token_to += word.len(); + Token { + word: I::new_alphabetic(word.to_string()), + from: token_from, + to: token_to, + } + }) + .collect::>() + .into_iter(); + } else { + return token.into(); + } + } + } + } +} + +// Ported from https://github.com/woxtu/rust-tinysegmenter, MIT license + +const BIAS: i32 = -332; + +fn get_score(d: &HashMap, s: &T) -> i32 { + d.get(s).cloned().unwrap_or(0) +} + +fn get_ctype(c: char) -> char { + match c as u32 { + 0x4E00 | 0x4E8C | 0x4E09 | 0x56DB | 0x4E94 | 0x516D | 0x4E03 | 0x516B | 0x4E5D | 0x5341 => { + 'M' + } + 0x767E | 0x5343 | 0x4E07 | 0x5104 | 0x5146 => 'M', + 0x4E00..=0x9FA0 | 0x3005 | 0x3006 | 0x30F5 | 0x30F6 => 'H', + 0x3041..=0x3093 => 'I', + 0x30A1..=0x30F4 | 0x30FC | 0xFF71..=0xFF9D | 0xFF9E | 0xFF70 => 'K', + 0x61..=0x7A | 0x41..=0x5A | 0xFF41..=0xFF5A | 0xFF21..=0xFF3A => 'A', + 0x30..=0x3a | 0xFF10..=0xFF19 => 'N', + _ => 'O', + } +} + +pub fn tokenize(s: &str) -> Vec { + if s.is_empty() { + return Vec::new(); + } + + let mut result = Vec::with_capacity(s.chars().count()); + + let segments = [B3, B2, B1] + .into_iter() + .chain(s.chars()) + .chain([E1, E2, E3]) + .collect::>(); + + let ctypes = ['O'; 3] + .into_iter() + .chain(s.chars().map(get_ctype)) + .chain(['O'; 3]) + .collect::>(); + + let mut word = segments[3].to_string(); + let mut p = vec!['U'; 3]; + + for index in 4..segments.len() - 3 { + let mut score = BIAS; + let w = &segments[index - 3..index + 3]; + let c = &ctypes[index - 3..index + 3]; + + score += get_score(&*UP1, &p[0]); + score += get_score(&*UP2, &p[1]); + score += get_score(&*UP3, &p[2]); + score += get_score(&*BP1, &(p[0], p[1])); + score += get_score(&*BP2, &(p[1], p[2])); + score += get_score(&*UW1, &w[0]); + score += get_score(&*UW2, &w[1]); + score += get_score(&*UW3, &w[2]); + score += get_score(&*UW4, &w[3]); + score += get_score(&*UW5, &w[4]); + score += get_score(&*UW6, &w[5]); + score += get_score(&*BW1, &(w[1], w[2])); + score += get_score(&*BW2, &(w[2], w[3])); + score += get_score(&*BW3, &(w[3], w[4])); + score += get_score(&*TW1, &(w[0], w[1], w[2])); + score += get_score(&*TW2, &(w[1], w[2], w[3])); + score += get_score(&*TW3, &(w[2], w[3], w[4])); + score += get_score(&*TW4, &(w[3], w[4], w[5])); + score += get_score(&*UC1, &c[0]); + score += get_score(&*UC2, &c[1]); + score += get_score(&*UC3, &c[2]); + score += get_score(&*UC4, &c[3]); + score += get_score(&*UC5, &c[4]); + score += get_score(&*UC6, &c[5]); + score += get_score(&*BC1, &(c[1], c[2])); + score += get_score(&*BC2, &(c[2], c[3])); + score += get_score(&*BC3, &(c[3], c[4])); + score += get_score(&*TC1, &(c[0], c[1], c[2])); + score += get_score(&*TC2, &(c[1], c[2], c[3])); + score += get_score(&*TC3, &(c[2], c[3], c[4])); + score += get_score(&*TC4, &(c[3], c[4], c[5])); + score += get_score(&*UQ1, &(p[0], c[0])); + score += get_score(&*UQ2, &(p[1], c[1])); + score += get_score(&*UQ3, &(p[2], c[2])); + score += get_score(&*BQ1, &(p[1], c[1], c[2])); + score += get_score(&*BQ2, &(p[1], c[2], c[3])); + score += get_score(&*BQ3, &(p[2], c[1], c[2])); + score += get_score(&*BQ4, &(p[2], c[2], c[3])); + score += get_score(&*TQ1, &(p[1], c[0], c[1], c[2])); + score += get_score(&*TQ2, &(p[1], c[1], c[2], c[3])); + score += get_score(&*TQ3, &(p[2], c[0], c[1], c[2])); + score += get_score(&*TQ4, &(p[2], c[1], c[2], c[3])); + + p.remove(0); + p.push(if score < 0 { 'O' } else { 'B' }); + + if 0 < score { + result.push(word.clone()); + word.clear(); + } + word.push(segments[index]); + } + + result.push(word.clone()); + result +} + +const B1: char = '\u{F0000}'; +const B2: char = '\u{F0001}'; +const B3: char = '\u{F0002}'; +const E1: char = '\u{F0003}'; +const E2: char = '\u{F0004}'; +const E3: char = '\u{F0005}'; + +static BC1: LazyLock> = LazyLock::new(|| { + hashmap! { ('H', 'H') => 6, ('I', 'I') => 2461, ('K', 'H') => 406, ('O', 'H') => -1378, } +}); +static BC2: LazyLock> = LazyLock::new(|| { + hashmap! { ('A', 'A') => -3267, ('A', 'I') => 2744, ('A', 'N') => -878, ('H', 'H') => -4070, ('H', 'M') => -1711, ('H', 'N') => 4012, ('H', 'O') => 3761, ('I', 'A') => 1327, ('I', 'H') => -1184, ('I', 'I') => -1332, ('I', 'K') => 1721, ('I', 'O') => 5492, ('K', 'I') => 3831, ('K', 'K') => -8741, ('M', 'H') => -3132, ('M', 'K') => 3334, ('O', 'O') => -2920, } +}); +static BC3: LazyLock> = LazyLock::new(|| { + hashmap! { ('H', 'H') => 996, ('H', 'I') => 626, ('H', 'K') => -721, ('H', 'N') => -1307, ('H', 'O') => -836, ('I', 'H') => -301, ('K', 'K') => 2762, ('M', 'K') => 1079, ('M', 'M') => 4034, ('O', 'A') => -1652, ('O', 'H') => 266, } +}); +static BP1: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'B') => 295, ('O', 'B') => 304, ('O', 'O') => -125, ('U', 'B') => 352, } +}); +static BP2: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'O') => 60, ('O', 'O') => -1762, } +}); +static BQ1: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H') => 1150, ('B', 'H', 'M') => 1521, ('B', 'I', 'I') => -1158, ('B', 'I', 'M') => 886, ('B', 'M', 'H') => 1208, ('B', 'N', 'H') => 449, ('B', 'O', 'H') => -91, ('B', 'O', 'O') => -2597, ('O', 'H', 'I') => 451, ('O', 'I', 'H') => -296, ('O', 'K', 'A') => 1851, ('O', 'K', 'H') => -1020, ('O', 'K', 'K') => 904, ('O', 'O', 'O') => 2965, } +}); +static BQ2: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H') => 118, ('B', 'H', 'I') => -1159, ('B', 'H', 'M') => 466, ('B', 'I', 'H') => -919, ('B', 'K', 'K') => -1720, ('B', 'K', 'O') => 864, ('O', 'H', 'H') => -1139, ('O', 'H', 'M') => -181, ('O', 'I', 'H') => 153, ('U', 'H', 'I') => -1146, } +}); +static BQ3: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H') => -792, ('B', 'H', 'I') => 2664, ('B', 'I', 'I') => -299, ('B', 'K', 'I') => 419, ('B', 'M', 'H') => 937, ('B', 'M', 'M') => 8335, ('B', 'N', 'N') => 998, ('B', 'O', 'H') => 775, ('O', 'H', 'H') => 2174, ('O', 'H', 'M') => 439, ('O', 'I', 'I') => 280, ('O', 'K', 'H') => 1798, ('O', 'K', 'I') => -793, ('O', 'K', 'O') => -2242, ('O', 'M', 'H') => -2402, ('O', 'O', 'O') => 11699, } +}); +static BQ4: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H') => -3895, ('B', 'I', 'H') => 3761, ('B', 'I', 'I') => -4654, ('B', 'I', 'K') => 1348, ('B', 'K', 'K') => -1806, ('B', 'M', 'I') => -3385, ('B', 'O', 'O') => -12396, ('O', 'A', 'H') => 926, ('O', 'H', 'H') => 266, ('O', 'H', 'K') => -2036, ('O', 'N', 'N') => -973, } +}); +static BW1: LazyLock> = LazyLock::new(|| { + hashmap! { (',', 'と') => 660, (',', '同') => 727, (B1, 'あ') => 1404, (B1, '同') => 542, ('、', 'と') => 660, ('、', '同') => 727, ('」', 'と') => 1682, ('あ', 'っ') => 1505, ('い', 'う') => 1743, ('い', 'っ') => -2055, ('い', 'る') => 672, ('う', 'し') => -4817, ('う', 'ん') => 665, ('か', 'ら') => 3472, ('が', 'ら') => 600, ('こ', 'う') => -790, ('こ', 'と') => 2083, ('こ', 'ん') => -1262, ('さ', 'ら') => -4143, ('さ', 'ん') => 4573, ('し', 'た') => 2641, ('し', 'て') => 1104, ('す', 'で') => -3399, ('そ', 'こ') => 1977, ('そ', 'れ') => -871, ('た', 'ち') => 1122, ('た', 'め') => 601, ('っ', 'た') => 3463, ('つ', 'い') => -802, ('て', 'い') => 805, ('て', 'き') => 1249, ('で', 'き') => 1127, ('で', 'す') => 3445, ('で', 'は') => 844, ('と', 'い') => -4915, ('と', 'み') => 1922, ('ど', 'こ') => 3887, ('な', 'い') => 5713, ('な', 'っ') => 3015, ('な', 'ど') => 7379, ('な', 'ん') => -1113, ('に', 'し') => 2468, ('に', 'は') => 1498, ('に', 'も') => 1671, ('に', '対') => -912, ('の', '一') => -501, ('の', '中') => 741, ('ま', 'せ') => 2448, ('ま', 'で') => 1711, ('ま', 'ま') => 2600, ('ま', 'る') => -2155, ('や', 'む') => -1947, ('よ', 'っ') => -2565, ('れ', 'た') => 2369, ('れ', 'で') => -913, ('を', 'し') => 1860, ('を', '見') => 731, ('亡', 'く') => -1886, ('京', '都') => 2558, ('取', 'り') => -2784, ('大', 'き') => -2604, ('大', '阪') => 1497, ('平', '方') => -2314, ('引', 'き') => -1336, ('日', '本') => -195, ('本', '当') => -2423, ('毎', '日') => -2113, ('目', '指') => -724, ('」', 'と') => 1682, } +}); +static BW2: LazyLock> = LazyLock::new(|| { + hashmap! { ('.', '.') => -11822, ('1', '1') => -669, ('―', '―') => -5730, ('−', '−') => -13175, ('い', 'う') => -1609, ('う', 'か') => 2490, ('か', 'し') => -1350, ('か', 'も') => -602, ('か', 'ら') => -7194, ('か', 'れ') => 4612, ('が', 'い') => 853, ('が', 'ら') => -3198, ('き', 'た') => 1941, ('く', 'な') => -1597, ('こ', 'と') => -8392, ('こ', 'の') => -4193, ('さ', 'せ') => 4533, ('さ', 'れ') => 13168, ('さ', 'ん') => -3977, ('し', 'い') => -1819, ('し', 'か') => -545, ('し', 'た') => 5078, ('し', 'て') => 972, ('し', 'な') => 939, ('そ', 'の') => -3744, ('た', 'い') => -1253, ('た', 'た') => -662, ('た', 'だ') => -3857, ('た', 'ち') => -786, ('た', 'と') => 1224, ('た', 'は') => -939, ('っ', 'た') => 4589, ('っ', 'て') => 1647, ('っ', 'と') => -2094, ('て', 'い') => 6144, ('て', 'き') => 3640, ('て', 'く') => 2551, ('て', 'は') => -3110, ('て', 'も') => -3065, ('で', 'い') => 2666, ('で', 'き') => -1528, ('で', 'し') => -3828, ('で', 'す') => -4761, ('で', 'も') => -4203, ('と', 'い') => 1890, ('と', 'こ') => -1746, ('と', 'と') => -2279, ('と', 'の') => 720, ('と', 'み') => 5168, ('と', 'も') => -3941, ('な', 'い') => -2488, ('な', 'が') => -1313, ('な', 'ど') => -6509, ('な', 'の') => 2614, ('な', 'ん') => 3099, ('に', 'お') => -1615, ('に', 'し') => 2748, ('に', 'な') => 2454, ('に', 'よ') => -7236, ('に', '対') => -14943, ('に', '従') => -4688, ('に', '関') => -11388, ('の', 'か') => 2093, ('の', 'で') => -7059, ('の', 'に') => -6041, ('の', 'の') => -6125, ('は', 'い') => 1073, ('は', 'が') => -1033, ('は', 'ず') => -2532, ('ば', 'れ') => 1813, ('ま', 'し') => -1316, ('ま', 'で') => -6621, ('ま', 'れ') => 5409, ('め', 'て') => -3153, ('も', 'い') => 2230, ('も', 'の') => -10713, ('ら', 'か') => -944, ('ら', 'し') => -1611, ('ら', 'に') => -1897, ('り', 'し') => 651, ('り', 'ま') => 1620, ('れ', 'た') => 4270, ('れ', 'て') => 849, ('れ', 'ば') => 4114, ('ろ', 'う') => 6067, ('わ', 'れ') => 7901, ('を', '通') => -11877, ('ん', 'だ') => 728, ('ん', 'な') => -4115, ('一', '人') => 602, ('一', '方') => -1375, ('一', '日') => 970, ('一', '部') => -1051, ('上', 'が') => -4479, ('会', '社') => -1116, ('出', 'て') => 2163, ('分', 'の') => -7758, ('同', '党') => 970, ('同', '日') => -913, ('大', '阪') => -2471, ('委', '員') => -1250, ('少', 'な') => -1050, ('年', '度') => -8669, ('年', '間') => -1626, ('府', '県') => -2363, ('手', '権') => -1982, ('新', '聞') => -4066, ('日', '新') => -722, ('日', '本') => -7068, ('日', '米') => 3372, ('曜', '日') => -601, ('朝', '鮮') => -2355, ('本', '人') => -2697, ('東', '京') => -1543, ('然', 'と') => -1384, ('社', '会') => -1276, ('立', 'て') => -990, ('第', 'に') => -1612, ('米', '国') => -4268, ('1', '1') => -669, ('ク', '゙') => 1319,} +}); +static BW3: LazyLock> = LazyLock::new(|| { + hashmap! { ('あ', 'た') => -2194, ('あ', 'り') => 719, ('あ', 'る') => 3846, ('い', '.') => -1185, ('い', '。') => -1185, ('い', 'い') => 5308, ('い', 'え') => 2079, ('い', 'く') => 3029, ('い', 'た') => 2056, ('い', 'っ') => 1883, ('い', 'る') => 5600, ('い', 'わ') => 1527, ('う', 'ち') => 1117, ('う', 'と') => 4798, ('え', 'と') => 1454, ('か', '.') => 2857, ('か', '。') => 2857, ('か', 'け') => -743, ('か', 'っ') => -4098, ('か', 'に') => -669, ('か', 'ら') => 6520, ('か', 'り') => -2670, ('が', ',') => 1816, ('が', '、') => 1816, ('が', 'き') => -4855, ('が', 'け') => -1127, ('が', 'っ') => -913, ('が', 'ら') => -4977, ('が', 'り') => -2064, ('き', 'た') => 1645, ('け', 'ど') => 1374, ('こ', 'と') => 7397, ('こ', 'の') => 1542, ('こ', 'ろ') => -2757, ('さ', 'い') => -714, ('さ', 'を') => 976, ('し', ',') => 1557, ('し', '、') => 1557, ('し', 'い') => -3714, ('し', 'た') => 3562, ('し', 'て') => 1449, ('し', 'な') => 2608, ('し', 'ま') => 1200, ('す', '.') => -1310, ('す', '。') => -1310, ('す', 'る') => 6521, ('ず', ',') => 3426, ('ず', '、') => 3426, ('ず', 'に') => 841, ('そ', 'う') => 428, ('た', '.') => 8875, ('た', '。') => 8875, ('た', 'い') => -594, ('た', 'の') => 812, ('た', 'り') => -1183, ('た', 'る') => -853, ('だ', '.') => 4098, ('だ', '。') => 4098, ('だ', 'っ') => 1004, ('っ', 'た') => -4748, ('っ', 'て') => 300, ('て', 'い') => 6240, ('て', 'お') => 855, ('て', 'も') => 302, ('で', 'す') => 1437, ('で', 'に') => -1482, ('で', 'は') => 2295, ('と', 'う') => -1387, ('と', 'し') => 2266, ('と', 'の') => 541, ('と', 'も') => -3543, ('ど', 'う') => 4664, ('な', 'い') => 1796, ('な', 'く') => -903, ('な', 'ど') => 2135, ('に', ',') => -1021, ('に', '、') => -1021, ('に', 'し') => 1771, ('に', 'な') => 1906, ('に', 'は') => 2644, ('の', ',') => -724, ('の', '、') => -724, ('の', '子') => -1000, ('は', ',') => 1337, ('は', '、') => 1337, ('べ', 'き') => 2181, ('ま', 'し') => 1113, ('ま', 'す') => 6943, ('ま', 'っ') => -1549, ('ま', 'で') => 6154, ('ま', 'れ') => -793, ('ら', 'し') => 1479, ('ら', 'れ') => 6820, ('る', 'る') => 3818, ('れ', ',') => 854, ('れ', '、') => 854, ('れ', 'た') => 1850, ('れ', 'て') => 1375, ('れ', 'ば') => -3246, ('れ', 'る') => 1091, ('わ', 'れ') => -605, ('ん', 'だ') => 606, ('ん', 'で') => 798, ('カ', '月') => 990, ('会', '議') => 860, ('入', 'り') => 1232, ('大', '会') => 2217, ('始', 'め') => 1681, ('市', ' ') => 965, ('新', '聞') => -5055, ('日', ',') => 974, ('日', '、') => 974, ('社', '会') => 2024, ('カ', '月') => 990, } +}); + +static TC1: LazyLock> = LazyLock::new(|| { + hashmap! { ('A', 'A', 'A') => 1093, ('H', 'H', 'H') => 1029, ('H', 'H', 'M') => 580, ('H', 'I', 'I') => 998, ('H', 'O', 'H') => -390, ('H', 'O', 'M') => -331, ('I', 'H', 'I') => 1169, ('I', 'O', 'H') => -142, ('I', 'O', 'I') => -1015, ('I', 'O', 'M') => 467, ('M', 'M', 'H') => 187, ('O', 'O', 'I') => -1832, } +}); +static TC2: LazyLock> = LazyLock::new(|| { + hashmap! { ('H', 'H', 'O') => 2088, ('H', 'I', 'I') => -1023, ('H', 'M', 'M') => -1154, ('I', 'H', 'I') => -1965, ('K', 'K', 'H') => 703, ('O', 'I', 'I') => -2649, } +}); +static TC3: LazyLock> = LazyLock::new(|| { + hashmap! { ('A', 'A', 'A') => -294, ('H', 'H', 'H') => 346, ('H', 'H', 'I') => -341, ('H', 'I', 'I') => -1088, ('H', 'I', 'K') => 731, ('H', 'O', 'H') => -1486, ('I', 'H', 'H') => 128, ('I', 'H', 'I') => -3041, ('I', 'H', 'O') => -1935, ('I', 'I', 'H') => -825, ('I', 'I', 'M') => -1035, ('I', 'O', 'I') => -542, ('K', 'H', 'H') => -1216, ('K', 'K', 'A') => 491, ('K', 'K', 'H') => -1217, ('K', 'O', 'K') => -1009, ('M', 'H', 'H') => -2694, ('M', 'H', 'M') => -457, ('M', 'H', 'O') => 123, ('M', 'M', 'H') => -471, ('N', 'N', 'H') => -1689, ('N', 'N', 'O') => 662, ('O', 'H', 'O') => -3393, } +}); +static TC4: LazyLock> = LazyLock::new(|| { + hashmap! { ('H', 'H', 'H') => -203, ('H', 'H', 'I') => 1344, ('H', 'H', 'K') => 365, ('H', 'H', 'M') => -122, ('H', 'H', 'N') => 182, ('H', 'H', 'O') => 669, ('H', 'I', 'H') => 804, ('H', 'I', 'I') => 679, ('H', 'O', 'H') => 446, ('I', 'H', 'H') => 695, ('I', 'H', 'O') => -2324, ('I', 'I', 'H') => 321, ('I', 'I', 'I') => 1497, ('I', 'I', 'O') => 656, ('I', 'O', 'O') => 54, ('K', 'A', 'K') => 4845, ('K', 'K', 'A') => 3386, ('K', 'K', 'K') => 3065, ('M', 'H', 'H') => -405, ('M', 'H', 'I') => 201, ('M', 'M', 'H') => -241, ('M', 'M', 'M') => 661, ('M', 'O', 'M') => 841, } +}); +static TQ1: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H', 'H') => -227, ('B', 'H', 'H', 'I') => 316, ('B', 'H', 'I', 'H') => -132, ('B', 'I', 'H', 'H') => 60, ('B', 'I', 'I', 'I') => 1595, ('B', 'N', 'H', 'H') => -744, ('B', 'O', 'H', 'H') => 225, ('B', 'O', 'O', 'O') => -908, ('O', 'A', 'K', 'K') => 482, ('O', 'H', 'H', 'H') => 281, ('O', 'H', 'I', 'H') => 249, ('O', 'I', 'H', 'I') => 200, ('O', 'I', 'I', 'H') => -68, } +}); +static TQ2: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'I', 'H', 'H') => -1401, ('B', 'I', 'I', 'I') => -1033, ('B', 'K', 'A', 'K') => -543, ('B', 'O', 'O', 'O') => -5591, } +}); +static TQ3: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H', 'H') => 478, ('B', 'H', 'H', 'M') => -1073, ('B', 'H', 'I', 'H') => 222, ('B', 'H', 'I', 'I') => -504, ('B', 'I', 'I', 'H') => -116, ('B', 'I', 'I', 'I') => -105, ('B', 'M', 'H', 'I') => -863, ('B', 'M', 'H', 'M') => -464, ('B', 'O', 'M', 'H') => 620, ('O', 'H', 'H', 'H') => 346, ('O', 'H', 'H', 'I') => 1729, ('O', 'H', 'I', 'I') => 997, ('O', 'H', 'M', 'H') => 481, ('O', 'I', 'H', 'H') => 623, ('O', 'I', 'I', 'H') => 1344, ('O', 'K', 'A', 'K') => 2792, ('O', 'K', 'H', 'H') => 587, ('O', 'K', 'K', 'A') => 679, ('O', 'O', 'H', 'H') => 110, ('O', 'O', 'I', 'I') => -685, } +}); +static TQ4: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H', 'H', 'H') => -721, ('B', 'H', 'H', 'M') => -3604, ('B', 'H', 'I', 'I') => -966, ('B', 'I', 'I', 'H') => -607, ('B', 'I', 'I', 'I') => -2181, ('O', 'A', 'A', 'A') => -2763, ('O', 'A', 'K', 'K') => 180, ('O', 'H', 'H', 'H') => -294, ('O', 'H', 'H', 'I') => 2446, ('O', 'H', 'H', 'O') => 480, ('O', 'H', 'I', 'H') => -1573, ('O', 'I', 'H', 'H') => 1935, ('O', 'I', 'H', 'I') => -493, ('O', 'I', 'I', 'H') => 626, ('O', 'I', 'I', 'I') => -4007, ('O', 'K', 'A', 'K') => -8156, } +}); +static TW1: LazyLock> = LazyLock::new(|| { + hashmap! { ('に', 'つ', 'い') => -4681, ('東', '京', '都') => 2026, } +}); +static TW2: LazyLock> = LazyLock::new(|| { + hashmap! { ('あ', 'る', '程') => -2049, ('い', 'っ', 'た') => -1256, ('こ', 'ろ', 'が') => -2434, ('し', 'ょ', 'う') => 3873, ('そ', 'の', '後') => -4430, ('だ', 'っ', 'て') => -1049, ('て', 'い', 'た') => 1833, ('と', 'し', 'て') => -4657, ('と', 'も', 'に') => -4517, ('も', 'の', 'で') => 1882, ('一', '気', 'に') => -792, ('初', 'め', 'て') => -1512, ('同', '時', 'に') => -8097, ('大', 'き', 'な') => -1255, ('対', 'し', 'て') => -2721, ('社', '会', '党') => -3216, } +}); +static TW3: LazyLock> = LazyLock::new(|| { + hashmap! { ('い', 'た', 'だ') => -1734, ('し', 'て', 'い') => 1314, ('と', 'し', 'て') => -4314, ('に', 'つ', 'い') => -5483, ('に', 'と', 'っ') => -5989, ('に', '当', 'た') => -6247, ('の', 'で', ',') => -727, ('の', 'で', '、') => -727, ('の', 'も', 'の') => -600, ('れ', 'か', 'ら') => -3752, ('十', '二', '月') => -2287, } +}); +static TW4: LazyLock> = LazyLock::new(|| { + hashmap! { ('い', 'う', '.') => 8576, ('い', 'う', '。') => 8576, ('か', 'ら', 'な') => -2348, ('し', 'て', 'い') => 2958, ('た', 'が', ',') => 1516, ('た', 'が', '、') => 1516, ('て', 'い', 'る') => 1538, ('と', 'い', 'う') => 1349, ('ま', 'し', 'た') => 5543, ('ま', 'せ', 'ん') => 1097, ('よ', 'う', 'と') => -4258, ('よ', 'る', 'と') => 5865, } +}); + +static UC1: LazyLock> = LazyLock::new(|| { + hashmap! { 'A' => 484, 'K' => 93, 'M' => 645, 'O' => -505, } +}); +static UC2: LazyLock> = LazyLock::new(|| { + hashmap! { 'A' => 819, 'H' => 1059, 'I' => 409, 'M' => 3987, 'N' => 5775, 'O' => 646, } +}); +static UC3: LazyLock> = LazyLock::new(|| { + hashmap! { 'A' => -1370, 'I' => 2311, } +}); +static UC4: LazyLock> = LazyLock::new(|| { + hashmap! { 'A' => -2643, 'H' => 1809, 'I' => -1032, 'K' => -3450, 'M' => 3565, 'N' => 3876, 'O' => 6646, } +}); +static UC5: LazyLock> = LazyLock::new(|| { + hashmap! { 'H' => 313, 'I' => -1238, 'K' => -799, 'M' => 539, 'O' => -831, } +}); +static UC6: LazyLock> = LazyLock::new(|| { + hashmap! { 'H' => -506, 'I' => -253, 'K' => 87, 'M' => 247, 'O' => -387, } +}); +static UP1: LazyLock> = LazyLock::new(|| { + hashmap! { 'O' => -214, } +}); +static UP2: LazyLock> = LazyLock::new(|| { + hashmap! { 'B' => 69, 'O' => 935, } +}); +static UP3: LazyLock> = LazyLock::new(|| { + hashmap! { 'B' => 189, } +}); +static UQ1: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H') => 21, ('B', 'I') => -12, ('B', 'K') => -99, ('B', 'N') => 142, ('B', 'O') => -56, ('O', 'H') => -95, ('O', 'I') => 477, ('O', 'K') => 410, ('O', 'O') => -2422, } +}); +static UQ2: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'H') => 216, ('B', 'I') => 113, ('O', 'K') => 1759, } +}); +static UQ3: LazyLock> = LazyLock::new(|| { + hashmap! { ('B', 'A') => -479, ('B', 'H') => 42, ('B', 'I') => 1913, ('B', 'K') => -7198, ('B', 'M') => 3160, ('B', 'N') => 6427, ('B', 'O') => 14761, ('O', 'I') => -827, ('O', 'N') => -3212, } +}); +static UW1: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => 156, '、' => 156, '「' => -463, 'あ' => -941, 'う' => -127, 'が' => -553, 'き' => 121, 'こ' => 505, 'で' => -201, 'と' => -547, 'ど' => -123, 'に' => -789, 'の' => -185, 'は' => -847, 'も' => -466, 'や' => -470, 'よ' => 182, 'ら' => -292, 'り' => 208, 'れ' => 169, 'を' => -446, 'ん' => -137, '・' => -135, '主' => -402, '京' => -268, '区' => -912, '午' => 871, '国' => -460, '大' => 561, '委' => 729, '市' => -411, '日' => -141, '理' => 361, '生' => -408, '県' => -386, '都' => -718, '「' => -463, '・' => -135, } +}); +static UW2: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => -829, '、' => -829, '〇' => 892, '「' => -645, '」' => 3145, 'あ' => -538, 'い' => 505, 'う' => 134, 'お' => -502, 'か' => 1454, 'が' => -856, 'く' => -412, 'こ' => 1141, 'さ' => 878, 'ざ' => 540, 'し' => 1529, 'す' => -675, 'せ' => 300, 'そ' => -1011, 'た' => 188, 'だ' => 1837, 'つ' => -949, 'て' => -291, 'で' => -268, 'と' => -981, 'ど' => 1273, 'な' => 1063, 'に' => -1764, 'の' => 130, 'は' => -409, 'ひ' => -1273, 'べ' => 1261, 'ま' => 600, 'も' => -1263, 'や' => -402, 'よ' => 1639, 'り' => -579, 'る' => -694, 'れ' => 571, 'を' => -2516, 'ん' => 2095, 'ア' => -587, 'カ' => 306, 'キ' => 568, 'ッ' => 831, '三' => -758, '不' => -2150, '世' => -302, '中' => -968, '主' => -861, '事' => 492, '人' => -123, '会' => 978, '保' => 362, '入' => 548, '初' => -3025, '副' => -1566, '北' => -3414, '区' => -422, '大' => -1769, '天' => -865, '太' => -483, '子' => -1519, '学' => 760, '実' => 1023, '小' => -2009, '市' => -813, '年' => -1060, '強' => 1067, '手' => -1519, '揺' => -1033, '政' => 1522, '文' => -1355, '新' => -1682, '日' => -1815, '明' => -1462, '最' => -630, '朝' => -1843, '本' => -1650, '東' => -931, '果' => -665, '次' => -2378, '民' => -180, '気' => -1740, '理' => 752, '発' => 529, '目' => -1584, '相' => -242, '県' => -1165, '立' => -763, '第' => 810, '米' => 509, '自' => -1353, '行' => 838, '西' => -744, '見' => -3874, '調' => 1010, '議' => 1198, '込' => 3041, '開' => 1758, '間' => -1257, '「' => -645, '」' => 3145, 'ッ' => 831, 'ア' => -587, 'カ' => 306, 'キ' => 568, } +}); +static UW3: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => 4889, '1' => -800, '−' => -1723, '、' => 4889, '々' => -2311, '〇' => 5827, '」' => 2670, '〓' => -3573, 'あ' => -2696, 'い' => 1006, 'う' => 2342, 'え' => 1983, 'お' => -4864, 'か' => -1163, 'が' => 3271, 'く' => 1004, 'け' => 388, 'げ' => 401, 'こ' => -3552, 'ご' => -3116, 'さ' => -1058, 'し' => -395, 'す' => 584, 'せ' => 3685, 'そ' => -5228, 'た' => 842, 'ち' => -521, 'っ' => -1444, 'つ' => -1081, 'て' => 6167, 'で' => 2318, 'と' => 1691, 'ど' => -899, 'な' => -2788, 'に' => 2745, 'の' => 4056, 'は' => 4555, 'ひ' => -2171, 'ふ' => -1798, 'へ' => 1199, 'ほ' => -5516, 'ま' => -4384, 'み' => -120, 'め' => 1205, 'も' => 2323, 'や' => -788, 'よ' => -202, 'ら' => 727, 'り' => 649, 'る' => 5905, 'れ' => 2773, 'わ' => -1207, 'を' => 6620, 'ん' => -518, 'ア' => 551, 'グ' => 1319, 'ス' => 874, 'ッ' => -1350, 'ト' => 521, 'ム' => 1109, 'ル' => 1591, 'ロ' => 2201, 'ン' => 278, '・' => -3794, '一' => -1619, '下' => -1759, '世' => -2087, '両' => 3815, '中' => 653, '主' => -758, '予' => -1193, '二' => 974, '人' => 2742, '今' => 792, '他' => 1889, '以' => -1368, '低' => 811, '何' => 4265, '作' => -361, '保' => -2439, '元' => 4858, '党' => 3593, '全' => 1574, '公' => -3030, '六' => 755, '共' => -1880, '円' => 5807, '再' => 3095, '分' => 457, '初' => 2475, '別' => 1129, '前' => 2286, '副' => 4437, '力' => 365, '動' => -949, '務' => -1872, '化' => 1327, '北' => -1038, '区' => 4646, '千' => -2309, '午' => -783, '協' => -1006, '口' => 483, '右' => 1233, '各' => 3588, '合' => -241, '同' => 3906, '和' => -837, '員' => 4513, '国' => 642, '型' => 1389, '場' => 1219, '外' => -241, '妻' => 2016, '学' => -1356, '安' => -423, '実' => -1008, '家' => 1078, '小' => -513, '少' => -3102, '州' => 1155, '市' => 3197, '平' => -1804, '年' => 2416, '広' => -1030, '府' => 1605, '度' => 1452, '建' => -2352, '当' => -3885, '得' => 1905, '思' => -1291, '性' => 1822, '戸' => -488, '指' => -3973, '政' => -2013, '教' => -1479, '数' => 3222, '文' => -1489, '新' => 1764, '日' => 2099, '旧' => 5792, '昨' => -661, '時' => -1248, '曜' => -951, '最' => -937, '月' => 4125, '期' => 360, '李' => 3094, '村' => 364, '東' => -805, '核' => 5156, '森' => 2438, '業' => 484, '氏' => 2613, '民' => -1694, '決' => -1073, '法' => 1868, '海' => -495, '無' => 979, '物' => 461, '特' => -3850, '生' => -273, '用' => 914, '町' => 1215, '的' => 7313, '直' => -1835, '省' => 792, '県' => 6293, '知' => -1528, '私' => 4231, '税' => 401, '立' => -960, '第' => 1201, '米' => 7767, '系' => 3066, '約' => 3663, '級' => 1384, '統' => -4229, '総' => 1163, '線' => 1255, '者' => 6457, '能' => 725, '自' => -2869, '英' => 785, '見' => 1044, '調' => -562, '財' => -733, '費' => 1777, '車' => 1835, '軍' => 1375, '込' => -1504, '通' => -1136, '選' => -681, '郎' => 1026, '郡' => 4404, '部' => 1200, '金' => 2163, '長' => 421, '開' => -1432, '間' => 1302, '関' => -1282, '雨' => 2009, '電' => -1045, '非' => 2066, '駅' => 1620, '1' => -800, '」' => 2670, '・' => -3794, 'ッ' => -1350, 'ア' => 551, 'ス' => 874, 'ト' => 521, 'ム' => 1109, 'ル' => 1591, 'ロ' => 2201, 'ン' => 278, } +}); +static UW4: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => 3930, '.' => 3508, '―' => -4841, '、' => 3930, '。' => 3508, '〇' => 4999, '「' => 1895, '」' => 3798, '〓' => -5156, 'あ' => 4752, 'い' => -3435, 'う' => -640, 'え' => -2514, 'お' => 2405, 'か' => 530, 'が' => 6006, 'き' => -4482, 'ぎ' => -3821, 'く' => -3788, 'け' => -4376, 'げ' => -4734, 'こ' => 2255, 'ご' => 1979, 'さ' => 2864, 'し' => -843, 'じ' => -2506, 'す' => -731, 'ず' => 1251, 'せ' => 181, 'そ' => 4091, 'た' => 5034, 'だ' => 5408, 'ち' => -3654, 'っ' => -5882, 'つ' => -1659, 'て' => 3994, 'で' => 7410, 'と' => 4547, 'な' => 5433, 'に' => 6499, 'ぬ' => 1853, 'ね' => 1413, 'の' => 7396, 'は' => 8578, 'ば' => 1940, 'ひ' => 4249, 'び' => -4134, 'ふ' => 1345, 'へ' => 6665, 'べ' => -744, 'ほ' => 1464, 'ま' => 1051, 'み' => -2082, 'む' => -882, 'め' => -5046, 'も' => 4169, 'ゃ' => -2666, 'や' => 2795, 'ょ' => -1544, 'よ' => 3351, 'ら' => -2922, 'り' => -9726, 'る' => -14896, 'れ' => -2613, 'ろ' => -4570, 'わ' => -1783, 'を' => 13150, 'ん' => -2352, 'カ' => 2145, 'コ' => 1789, 'セ' => 1287, 'ッ' => -724, 'ト' => -403, 'メ' => -1635, 'ラ' => -881, 'リ' => -541, 'ル' => -856, 'ン' => -3637, '・' => -4371, 'ー' => -11870, '一' => -2069, '中' => 2210, '予' => 782, '事' => -190, '井' => -1768, '人' => 1036, '以' => 544, '会' => 950, '体' => -1286, '作' => 530, '側' => 4292, '先' => 601, '党' => -2006, '共' => -1212, '内' => 584, '円' => 788, '初' => 1347, '前' => 1623, '副' => 3879, '力' => -302, '動' => -740, '務' => -2715, '化' => 776, '区' => 4517, '協' => 1013, '参' => 1555, '合' => -1834, '和' => -681, '員' => -910, '器' => -851, '回' => 1500, '国' => -619, '園' => -1200, '地' => 866, '場' => -1410, '塁' => -2094, '士' => -1413, '多' => 1067, '大' => 571, '子' => -4802, '学' => -1397, '定' => -1057, '寺' => -809, '小' => 1910, '屋' => -1328, '山' => -1500, '島' => -2056, '川' => -2667, '市' => 2771, '年' => 374, '庁' => -4556, '後' => 456, '性' => 553, '感' => 916, '所' => -1566, '支' => 856, '改' => 787, '政' => 2182, '教' => 704, '文' => 522, '方' => -856, '日' => 1798, '時' => 1829, '最' => 845, '月' => -9066, '木' => -485, '来' => -442, '校' => -360, '業' => -1043, '氏' => 5388, '民' => -2716, '気' => -910, '沢' => -939, '済' => -543, '物' => -735, '率' => 672, '球' => -1267, '生' => -1286, '産' => -1101, '田' => -2900, '町' => 1826, '的' => 2586, '目' => 922, '省' => -3485, '県' => 2997, '空' => -867, '立' => -2112, '第' => 788, '米' => 2937, '系' => 786, '約' => 2171, '経' => 1146, '統' => -1169, '総' => 940, '線' => -994, '署' => 749, '者' => 2145, '能' => -730, '般' => -852, '行' => -792, '規' => 792, '警' => -1184, '議' => -244, '谷' => -1000, '賞' => 730, '車' => -1481, '軍' => 1158, '輪' => -1433, '込' => -3370, '近' => 929, '道' => -1291, '選' => 2596, '郎' => -4866, '都' => 1192, '野' => -1100, '銀' => -2213, '長' => 357, '間' => -2344, '院' => -2297, '際' => -2604, '電' => -878, '領' => -1659, '題' => -792, '館' => -1984, '首' => 1749, '高' => 2120, '「' => 1895, '」' => 3798, '・' => -4371, 'ッ' => -724, 'ー' => -11870, 'カ' => 2145, 'コ' => 1789, 'セ' => 1287, 'ト' => -403, 'メ' => -1635, 'ラ' => -881, 'リ' => -541, 'ル' => -856, 'ン' => -3637, } +}); +static UW5: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => 465, '.' => -299, '1' => -514, E2 => -32768, ']' => -2762, '、' => 465, '。' => -299, '「' => 363, 'あ' => 1655, 'い' => 331, 'う' => -503, 'え' => 1199, 'お' => 527, 'か' => 647, 'が' => -421, 'き' => 1624, 'ぎ' => 1971, 'く' => 312, 'げ' => -983, 'さ' => -1537, 'し' => -1371, 'す' => -852, 'だ' => -1186, 'ち' => 1093, 'っ' => 52, 'つ' => 921, 'て' => -18, 'で' => -850, 'と' => -127, 'ど' => 1682, 'な' => -787, 'に' => -1224, 'の' => -635, 'は' => -578, 'べ' => 1001, 'み' => 502, 'め' => 865, 'ゃ' => 3350, 'ょ' => 854, 'り' => -208, 'る' => 429, 'れ' => 504, 'わ' => 419, 'を' => -1264, 'ん' => 327, 'イ' => 241, 'ル' => 451, 'ン' => -343, '中' => -871, '京' => 722, '会' => -1153, '党' => -654, '務' => 3519, '区' => -901, '告' => 848, '員' => 2104, '大' => -1296, '学' => -548, '定' => 1785, '嵐' => -1304, '市' => -2991, '席' => 921, '年' => 1763, '思' => 872, '所' => -814, '挙' => 1618, '新' => -1682, '日' => 218, '月' => -4353, '査' => 932, '格' => 1356, '機' => -1508, '氏' => -1347, '田' => 240, '町' => -3912, '的' => -3149, '相' => 1319, '省' => -1052, '県' => -4003, '研' => -997, '社' => -278, '空' => -813, '統' => 1955, '者' => -2233, '表' => 663, '語' => -1073, '議' => 1219, '選' => -1018, '郎' => -368, '長' => 786, '間' => 1191, '題' => 2368, '館' => -689, '1' => -514, '「' => 363, 'イ' => 241, 'ル' => 451, 'ン' => -343, } +}); +static UW6: LazyLock> = LazyLock::new(|| { + hashmap! { ',' => 227, '.' => 808, '1' => -270, E1 => 306, '、' => 227, '。' => 808, 'あ' => -307, 'う' => 189, 'か' => 241, 'が' => -73, 'く' => -121, 'こ' => -200, 'じ' => 1782, 'す' => 383, 'た' => -428, 'っ' => 573, 'て' => -1014, 'で' => 101, 'と' => -105, 'な' => -253, 'に' => -149, 'の' => -417, 'は' => -236, 'も' => -206, 'り' => 187, 'る' => -135, 'を' => 195, 'ル' => -673, 'ン' => -496, '一' => -277, '中' => 201, '件' => -800, '会' => 624, '前' => 302, '区' => 1792, '員' => -1212, '委' => 798, '学' => -960, '市' => 887, '広' => -695, '後' => 535, '業' => -697, '相' => 753, '社' => -507, '福' => 974, '空' => -822, '者' => 1811, '連' => 463, '郎' => 1082, '1' => -270, 'ル' => -673, 'ン' => -496, } +}); + +#[cfg(test)] +mod tests { + use crate::tokenizers::{Token, japanese::JapaneseTokenizer, word::WordTokenizer}; + + #[test] + fn japanese_tokenizer() { + assert_eq!( + JapaneseTokenizer::new(WordTokenizer::new( + "お先に失礼します あなたの名前は何ですか 123 abc-872", + 40 + )) + .collect::>(), + vec![ + Token { + word: "お先".into(), + from: 0, + to: 6 + }, + Token { + word: "に".into(), + from: 6, + to: 9 + }, + Token { + word: "失礼".into(), + from: 9, + to: 15 + }, + Token { + word: "し".into(), + from: 15, + to: 18 + }, + Token { + word: "ます".into(), + from: 18, + to: 24 + }, + Token { + word: "あなた".into(), + from: 25, + to: 34 + }, + Token { + word: "の".into(), + from: 34, + to: 37 + }, + Token { + word: "名前".into(), + from: 37, + to: 43 + }, + Token { + word: "は".into(), + from: 43, + to: 46 + }, + Token { + word: "何".into(), + from: 46, + to: 49 + }, + Token { + word: "です".into(), + from: 49, + to: 55 + }, + Token { + word: "か".into(), + from: 55, + to: 58 + }, + Token { + word: "123".into(), + from: 59, + to: 62 + }, + Token { + word: "abc".into(), + from: 63, + to: 66 + }, + Token { + word: "872".into(), + from: 67, + to: 70 + } + ] + ); + } +} diff --git a/crates/nlp/src/tokenizers/mod.rs b/crates/nlp/src/tokenizers/mod.rs new file mode 100644 index 0000000..9682eea --- /dev/null +++ b/crates/nlp/src/tokenizers/mod.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod chinese; +pub mod japanese; +pub mod space; +pub mod stream; +pub mod types; +pub mod word; + +use std::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub word: T, + pub from: usize, + pub to: usize, +} + +pub trait InnerToken<'x>: Sized { + fn new_alphabetic(value: impl Into>) -> Self; + fn unwrap_alphabetic(self) -> Cow<'x, str>; + fn is_alphabetic(&self) -> bool; + fn is_alphabetic_8bit(&self) -> bool; +} + +impl<'x> InnerToken<'x> for Cow<'x, str> { + fn new_alphabetic(value: impl Into>) -> Self { + value.into() + } + + fn is_alphabetic(&self) -> bool { + true + } + + fn is_alphabetic_8bit(&self) -> bool { + !self.is_ascii() + } + + fn unwrap_alphabetic(self) -> Cow<'x, str> { + self + } +} + +impl Token { + pub fn new(offset: usize, len: usize, word: T) -> Token { + debug_assert!(offset <= u32::MAX as usize); + debug_assert!(len <= u8::MAX as usize); + Token { + from: offset, + to: offset + len, + word, + } + } +} diff --git a/crates/nlp/src/tokenizers/space.rs b/crates/nlp/src/tokenizers/space.rs new file mode 100644 index 0000000..9dbec5e --- /dev/null +++ b/crates/nlp/src/tokenizers/space.rs @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::str::Chars; + +pub struct SpaceTokenizer<'x> { + iterator: Chars<'x>, + token: String, + max_token_length: usize, +} + +impl SpaceTokenizer<'_> { + pub fn new(text: &'_ str, max_token_length: usize) -> SpaceTokenizer<'_> { + SpaceTokenizer { + iterator: text.chars(), + token: String::new(), + max_token_length, + } + } +} + +impl Iterator for SpaceTokenizer<'_> { + type Item = String; + + fn next(&mut self) -> Option { + for ch in self.iterator.by_ref() { + if ch.is_alphanumeric() { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + self.token.push(ch); + } + } else { + self.token.push(ch); + } + } else if !self.token.is_empty() { + if self.token.len() < self.max_token_length { + return Some(std::mem::take(&mut self.token)); + } else { + self.token.clear(); + } + } + } + + if !self.token.is_empty() { + if self.token.len() < self.max_token_length { + return Some(std::mem::take(&mut self.token)); + } else { + self.token.clear(); + } + } + + None + } +} diff --git a/crates/nlp/src/tokenizers/stream.rs b/crates/nlp/src/tokenizers/stream.rs new file mode 100644 index 0000000..40f02f7 --- /dev/null +++ b/crates/nlp/src/tokenizers/stream.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + language::{ + Language, + detect::{LanguageDetector, MIN_LANGUAGE_SCORE}, + stemmer::STEMMER_MAP, + stopwords::{STOP_WORDS, StopwordFnc}, + }, + tokenizers::{chinese::JIEBA, japanese}, +}; +use std::borrow::Cow; + +pub struct WordStemTokenizer { + stemmer: Stemmer, + stop_words: Option, +} + +enum Stemmer { + IndoEuropean(rust_stemmers::Stemmer), + Mandarin, + Japanese, + None, +} + +impl WordStemTokenizer { + pub fn new(text: &str) -> Self { + // Detect language + let (mut language, score) = + LanguageDetector::detect_single(text).unwrap_or((Language::English, 1.0)); + if score < MIN_LANGUAGE_SCORE { + language = Language::English; + } + + Self { + stemmer: match language { + Language::Mandarin => Stemmer::Mandarin, + Language::Japanese => Stemmer::Japanese, + _ => STEMMER_MAP[language as usize] + .map(|algo| Stemmer::IndoEuropean(rust_stemmers::Stemmer::create(algo))) + .unwrap_or(Stemmer::None), + }, + stop_words: STOP_WORDS[language as usize], + } + } + + pub fn tokenize<'x>(&self, word: &'x str, mut cb: impl FnMut(Cow<'x, str>)) { + if self.stop_words.is_some_and(|sw| sw(word)) { + return; + } + match &self.stemmer { + Stemmer::IndoEuropean(stemmer) => { + cb(stemmer.stem(word)); + } + Stemmer::Mandarin => { + for token in JIEBA.cut(word, false) { + cb(Cow::from(token.word)); + } + } + Stemmer::Japanese => { + for word in japanese::tokenize(word) { + cb(Cow::from(word)); + } + } + Stemmer::None => { + cb(Cow::from(word)); + } + } + } +} + +#[cfg(test)] +pub mod tests { + use crate::tokenizers::{ + stream::WordStemTokenizer, + types::{TokenType, TypesTokenizer}, + }; + + #[test] + fn stream_tokenizer() { + let inputs = [ + ( + "The quick brown fox jumps over the lazy dog", + vec!["quick", "brown", "fox", "jump", "lazi", "dog"], + ), + ( + "Jovencillo emponzoñado de whisky: ¡qué figurota exhibe!", + vec!["jovencill", "emponzoñ", "whisky", "figurot", "exhib"], + ), + ( + "Ma la volpe col suo balzo ha raggiunto il quieto Fido", + vec!["volp", "balz", "raggiunt", "quiet", "fid"], + ), + ( + "Jaz em prisão bota que vexa dez cegonhas felizes", + vec!["jaz", "prisã", "bot", "vex", "dez", "cegonh", "feliz"], + ), + ( + "Zwölf Boxkämpfer jagten Victor quer über den großen Sylter Deich", + vec![ + "zwolf", "boxkampf", "jagt", "victor", "quer", "gross", "sylt", "deich", + ], + ), + ( + "עטלף אבק נס דרך מזגן שהתפוצץ כי חם", + vec!["עטלף", "אבק", "נס", "דרך", "מזגן", "שהתפוצץ", "כי", "חם"], + ), + ( + "Съешь ещё этих мягких французских булок, да выпей же чаю", + vec![ + "съеш", + "ещё", + "эт", + "мягк", + "французск", + "булок", + "вып", + "ча", + ], + ), + ( + "Чуєш їх, доцю, га? Кумедна ж ти, прощайся без ґольфів!", + vec![ + "чуєш", + "їх", + "доцю", + "га", + "кумедна", + "ж", + "ти", + "прощайся", + "без", + "ґольфів", + ], + ), + ( + "Љубазни фењерџија чађавог лица хоће да ми покаже штос", + vec![ + "љубазни", + "фењерџија", + "чађавог", + "лица", + "хоће", + "да", + "ми", + "покаже", + "штос", + ], + ), + ( + "Pijamalı hasta yağız şoföre çabucak güvendi", + vec!["pijamalı", "hasta", "yağız", "şoför", "çabucak", "güvendi"], + ), + ("己所不欲,勿施于人。", vec!["己所不欲", "勿施于人"]), + ( + "井の中の蛙大海を知らず", + vec!["井", "の", "中", "の", "蛙大", "海", "を", "知ら", "ず"], + ), + ("시작이 반이다", vec!["시작이", "반이다"]), + ]; + + for (input, expect) in inputs.iter() { + let tokenizer = WordStemTokenizer::new(input); + let mut result = Vec::new(); + for token in TypesTokenizer::new(&input.to_lowercase()) { + if let TokenType::Alphabetic(word) = token.word { + tokenizer.tokenize(word, |t| { + result.push(t.into_owned()); + }); + } + } + + assert_eq!(&result, expect,); + } + } +} diff --git a/crates/nlp/src/tokenizers/types.rs b/crates/nlp/src/tokenizers/types.rs new file mode 100644 index 0000000..7818d3b --- /dev/null +++ b/crates/nlp/src/tokenizers/types.rs @@ -0,0 +1,2877 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::str::CharIndices; + +use super::Token; + +#[derive(Debug)] +pub struct TypesTokenizer<'x> { + text: &'x str, + iter: CharIndices<'x>, + tokens: Vec>>, + peek_pos: usize, + last_ch_is_space: bool, + last_token_is_dot: bool, + eof: bool, + tokenize_urls: bool, + tokenize_urls_without_scheme: bool, + tokenize_emails: bool, + tokenize_numbers: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenType { + Alphabetic(T), + Alphanumeric(T), + Integer(T), + Other(char), + Punctuation(char), + Space, + + // Detected types + Url(U), + UrlNoScheme(U), + UrlNoHost(T), + IpAddr(I), + Email(E), + Float(T), +} + +impl Copy for Token> {} + +impl<'x> Iterator for TypesTokenizer<'x> { + type Item = Token>; + + fn next(&mut self) -> Option { + let token = self.peek()?; + let last_is_dot = self.last_token_is_dot; + self.last_token_is_dot = matches!(token.word, TokenType::Punctuation('.')); + + // Try parsing URL with scheme + if self.tokenize_urls + && matches!( + token.word, + TokenType::Alphabetic(t) | TokenType::Alphanumeric(t) + if t.len() <= 8 && t.is_ascii()) + && self.try_skip_url_scheme() + { + if let Some(url) = self.try_parse_url(token.into()) { + self.peek_advance(); + return Some(url); + } else { + self.peek_rewind(); + } + } + + // Try parsing email + if self.tokenize_emails && token.word.is_email_atom() { + self.peek_rewind(); + if let Some(email) = self.try_parse_email() { + self.peek_advance(); + return Some(email); + } + self.peek_rewind(); + } + + // Try parsing URL without scheme + if self.tokenize_urls_without_scheme && token.word.is_domain_atom(true) { + self.peek_rewind(); + if let Some(url) = self.try_parse_url(None) { + self.peek_advance(); + return Some(url); + } + self.peek_rewind(); + } + + // Try parsing currencies and floating point numbers + if self.tokenize_numbers + && !last_is_dot + && let Some(num) = self.try_parse_number() + { + self.peek_advance(); + return Some(num); + } + + self.peek_rewind(); + self.next_() + } +} + +impl<'x> TypesTokenizer<'x> { + pub fn new(text: &'x str) -> Self { + Self { + text, + iter: text.char_indices(), + tokens: Vec::new(), + eof: false, + peek_pos: 0, + last_ch_is_space: false, + last_token_is_dot: false, + tokenize_urls: true, + tokenize_urls_without_scheme: true, + tokenize_emails: true, + tokenize_numbers: true, + } + } + + pub fn tokenize_urls(mut self, tokenize: bool) -> Self { + self.tokenize_urls = tokenize; + self + } + + pub fn tokenize_urls_without_scheme(mut self, tokenize: bool) -> Self { + self.tokenize_urls_without_scheme = tokenize; + self + } + + pub fn tokenize_emails(mut self, tokenize: bool) -> Self { + self.tokenize_emails = tokenize; + self + } + + pub fn tokenize_numbers(mut self, tokenize: bool) -> Self { + self.tokenize_numbers = tokenize; + self + } + + fn consume(&mut self) -> bool { + let mut has_alpha = false; + let mut has_number = false; + + let mut start_pos = usize::MAX; + let mut end_pos = usize::MAX; + + let mut stop_char = None; + + for (pos, ch) in self.iter.by_ref() { + if ch.is_alphabetic() { + has_alpha = true; + } else if ch.is_ascii_digit() { + has_number = true; + } else { + let last_was_space = self.last_ch_is_space; + self.last_ch_is_space = ch.is_whitespace(); + stop_char = Token { + word: if self.last_ch_is_space { + if last_was_space { + continue; + } else { + TokenType::Space + } + } else if ch.is_ascii() { + TokenType::Punctuation(ch) + } else { + TokenType::Other(ch) + }, + from: pos, + to: pos + ch.len_utf8(), + } + .into(); + break; + } + self.last_ch_is_space = false; + + if start_pos == usize::MAX { + start_pos = pos; + } + end_pos = pos + ch.len_utf8(); + } + + if start_pos != usize::MAX { + let text = &self.text[start_pos..end_pos]; + + self.tokens.push(Token { + word: if has_alpha && has_number { + TokenType::Alphanumeric(text) + } else if has_alpha { + TokenType::Alphabetic(text) + } else { + TokenType::Integer(text) + }, + from: start_pos, + to: end_pos, + }); + if let Some(stop_char) = stop_char { + self.tokens.push(stop_char); + } + true + } else if let Some(stop_char) = stop_char { + self.tokens.push(stop_char); + true + } else { + self.eof = true; + false + } + } + + fn next_(&mut self) -> Option>> { + if self.tokens.is_empty() && !self.eof { + self.consume(); + } + if !self.tokens.is_empty() { + Some(self.tokens.remove(0)) + } else { + None + } + } + + fn peek(&mut self) -> Option>> { + while self.tokens.len() <= self.peek_pos && !self.eof { + self.consume(); + } + self.tokens.get(self.peek_pos).map(|t| { + self.peek_pos += 1; + *t + }) + } + + fn peek_advance(&mut self) { + if self.peek_pos > 0 { + self.tokens.drain(..self.peek_pos); + self.peek_pos = 0; + } + } + + #[inline(always)] + fn peek_rewind(&mut self) { + self.peek_pos = 0; + } + + fn try_parse_url( + &mut self, + scheme_token: Option>>, + ) -> Option>> { + let (has_scheme, allow_blank_host) = scheme_token.as_ref().map_or((false, false), |t| { + ( + true, + matches!(t.word, TokenType::Alphabetic(s) if s.eq_ignore_ascii_case("file")), + ) + }); + if has_scheme { + let restore_pos = self.peek_pos; + let mut has_user_info = false; + while let Some(token) = self.peek() { + match token.word { + TokenType::Punctuation('@') => { + has_user_info = true; + break; + } + TokenType::Alphabetic(_) + | TokenType::Alphanumeric(_) + | TokenType::Integer(_) + | TokenType::Punctuation( + '-' | '.' | '_' | '~' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' + | ',' | ';' | '=' | ':', + ) => (), + _ => break, + } + } + + if !has_user_info { + self.peek_pos = restore_pos; + } + } + + // Try parsing hostname + let mut is_valid_host = true; + let (host_start_pos, mut end_pos, is_ip) = if has_scheme { + let mut start_pos = usize::MAX; + let mut end_pos = usize::MAX; + let mut restore_pos = self.peek_pos; + + let mut text_count = 0; + let mut int_count = 0; + let mut dot_count = 0; + let mut is_ipv6 = false; + + let mut last_label_is_tld = false; + + while let Some(token) = self.peek() { + match token.word { + TokenType::Alphabetic(text) | TokenType::Alphanumeric(text) => { + last_label_is_tld = text.len() >= 2 + && psl::Psl::find( + &psl::List, + [text.to_ascii_lowercase().as_bytes()].into_iter(), + ) + .typ + .is_some(); + text_count += 1; + } + TokenType::Integer(text) => { + if text.len() <= 3 { + int_count += 1; + } + } + TokenType::Punctuation('.') => { + dot_count += 1; + continue; + } + TokenType::Punctuation('[') if start_pos == usize::MAX => { + let (_, to) = self.try_parse_ipv6(token.from)?; + start_pos = token.from; + end_pos = to; + restore_pos = self.peek_pos; + is_ipv6 = true; + break; + } + TokenType::Punctuation( + '-' | '_' | '~' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' + | ';' | '=' | ':' | '%', + ) => { + continue; + } + TokenType::Punctuation('/') if allow_blank_host => { + // Allow file://../ urls + end_pos = token.from; + restore_pos = self.peek_pos - 1; + break; + } + _ => break, + } + + if start_pos == usize::MAX { + start_pos = token.from; + } + end_pos = token.to; + restore_pos = self.peek_pos; + } + + self.peek_pos = restore_pos; + let is_ip = is_ipv6 || (int_count == 4 && dot_count == 3 && text_count == 0); + if end_pos != usize::MAX { + is_valid_host = + (last_label_is_tld && dot_count >= 1 && (text_count + int_count) >= 2) || is_ip; + (start_pos, end_pos, is_ip) + } else { + return None; + } + } else { + // Strict hostname parsing + self.try_parse_hostname()? + }; + + // Try parsing port + let start_pos = scheme_token.map(|t| t.from).unwrap_or(host_start_pos); + let mut restore_pos = self.peek_pos; + let mut has_port = false; + let mut last_is_colon = false; + let mut found_query_start = false; + while let Some(token) = self.peek() { + match token.word { + TokenType::Punctuation(':') if !last_is_colon && !has_port => { + last_is_colon = true; + } + TokenType::Integer(_) if last_is_colon => { + has_port = true; + last_is_colon = false; + restore_pos = self.peek_pos; + end_pos = token.to; + } + TokenType::Punctuation('/' | '?') if !last_is_colon => { + found_query_start = true; + end_pos = token.to; + break; + } + _ => { + self.peek_pos = restore_pos; + break; + } + } + } + + // Try parsing query + if found_query_start { + restore_pos = self.peek_pos; + let mut p_count = 0; + let mut b_count = 0; + let mut c_count = 0; + let mut seen_quote = false; + while let Some(token) = self.peek() { + match token.word { + TokenType::Alphabetic(_) + | TokenType::Alphanumeric(_) + | TokenType::Integer(_) + | TokenType::Other(_) => {} + TokenType::Punctuation('(') => { + p_count += 1; + continue; + } + TokenType::Punctuation('[') => { + b_count += 1; + continue; + } + TokenType::Punctuation('{') => { + c_count += 1; + continue; + } + TokenType::Punctuation(')') if p_count > 0 => { + p_count -= 1; + } + TokenType::Punctuation(']') if b_count > 0 => { + b_count -= 1; + } + TokenType::Punctuation('}') if c_count > 0 => { + c_count -= 1; + } + TokenType::Punctuation('\'') => { + if !seen_quote { + seen_quote = true; + continue; + } else { + seen_quote = false; + } + } + TokenType::Punctuation('/') => {} + TokenType::Punctuation( + '-' | '_' | '~' | '!' | '$' | '&' | '*' | '+' | ',' | ';' | '=' | ':' | '%' + | '?' | '.' | '@', + ) => { + continue; + } + _ => break, + } + end_pos = token.to; + restore_pos = self.peek_pos; + } + self.peek_pos = restore_pos; + } + + let word = &self.text[start_pos..end_pos]; + Token { + word: if has_scheme { + if is_valid_host { + TokenType::Url(word) + } else { + TokenType::UrlNoHost(word) + } + } else if is_ip && !found_query_start { + TokenType::IpAddr(word) + } else { + TokenType::UrlNoScheme(word) + }, + from: start_pos, + to: end_pos, + } + .into() + } + + fn try_parse_email(&mut self) -> Option>> { + // Start token is a valid local part atom + let start_token = self.peek()?; + let mut last_is_dot = false; + + // Find local part + loop { + let token = self.peek()?; + if token.to - start_token.from > 255 { + return None; + } + match token.word { + word if word.is_email_atom() => { + last_is_dot = false; + } + TokenType::Punctuation('@') if !last_is_dot => { + break; + } + TokenType::Punctuation('.') if !last_is_dot => { + last_is_dot = true; + } + _ => { + return None; + } + } + } + + // Obtain domain part + let (_, end_pos, _) = self.try_parse_hostname()?; + + Token { + word: TokenType::Email(&self.text[start_token.from..end_pos]), + from: start_token.from, + to: end_pos, + } + .into() + } + + fn try_parse_hostname(&mut self) -> Option<(usize, usize, bool)> { + let mut last_ch = u8::MAX; + let mut has_int = false; + let mut has_alpha = false; + let mut last_label_is_tld = false; + + let mut dot_count = 0; + let mut start_pos = usize::MAX; + let mut end_pos = usize::MAX; + let mut restore_pos = self.peek_pos; + + while let Some(token) = self.peek() { + match token.word { + TokenType::Punctuation('.') if last_ch == 0 && start_pos != usize::MAX => { + last_ch = b'.'; + dot_count += 1; + continue; + } + TokenType::Punctuation('-') if last_ch == 0 || last_ch == b'-' => { + last_ch = b'-'; + continue; + } + TokenType::Punctuation('[') if start_pos == usize::MAX => { + return self + .try_parse_ipv6(token.from) + .map(|(from, to)| (from, to, true)); + } + TokenType::Alphabetic(text) | TokenType::Alphanumeric(text) if text.len() <= 63 => { + last_label_is_tld = text.len() >= 2 + && psl::Psl::find( + &psl::List, + [text.to_ascii_lowercase().as_bytes()].into_iter(), + ) + .typ + .is_some(); + has_alpha = true; + last_ch = 0; + } + TokenType::Other(_) => { + has_alpha = true; + last_label_is_tld = false; + last_ch = 0; + } + TokenType::Integer(text) => { + if text.len() <= 3 { + has_int = true; + } + last_label_is_tld = false; + last_ch = 0; + } + _ => { + break; + } + } + + if start_pos == usize::MAX { + start_pos = token.from; + } + end_pos = token.to; + restore_pos = self.peek_pos; + + if end_pos - start_pos > 255 { + return None; + } + } + self.peek_pos = restore_pos; + + if last_ch == b'.' { + dot_count -= 1; + } + + let is_ipv4 = has_int && !has_alpha && dot_count == 3; + if end_pos != usize::MAX && dot_count >= 1 && (last_label_is_tld || is_ipv4) { + (start_pos, end_pos, is_ipv4).into() + } else { + None + } + } + + fn try_parse_ipv6(&mut self, start_pos: usize) -> Option<(usize, usize)> { + let mut found_colon = false; + let mut last_ch = u8::MAX; + + while let Some(token) = self.peek() { + match token.word { + TokenType::Integer(_) | TokenType::Alphanumeric(_) => { + last_ch = 0; + } + TokenType::Punctuation(':') if last_ch != b'.' => { + found_colon = true; + last_ch = b':'; + } + TokenType::Punctuation('.') if last_ch == 0 => { + last_ch = b'.'; + } + TokenType::Punctuation(']') if found_colon && last_ch == 0 => { + return (start_pos, token.to).into(); + } + _ => return None, + } + } + + None + } + + fn try_parse_number(&mut self) -> Option>> { + self.peek_rewind(); + let mut start_pos = usize::MAX; + let mut end_pos = usize::MAX; + let mut restore_pos = self.peek_pos; + + let mut seen_integer = 0; + let mut seen_dot = false; + + while let Some(token) = self.peek() { + match token.word { + TokenType::Punctuation('-') if start_pos == usize::MAX => {} + TokenType::Integer(_) if seen_integer == 0 || seen_dot => { + seen_integer += 1; + } + TokenType::Punctuation('.') if seen_integer != 0 => { + if !seen_dot { + seen_dot = true; + continue; + } else { + // Avoid parsing num.num.num as floats + return None; + } + } + _ => break, + } + + if start_pos == usize::MAX { + start_pos = token.from; + } + end_pos = token.to; + restore_pos = self.peek_pos; + } + + self.peek_pos = restore_pos; + + if seen_integer > 0 { + let text = &self.text[start_pos..end_pos]; + + Token { + word: if seen_integer == 2 { + TokenType::Float(text) + } else { + TokenType::Integer(text) + }, + from: start_pos, + to: end_pos, + } + .into() + } else { + None + } + } + + fn try_skip_url_scheme(&mut self) -> bool { + enum State { + None, + PlusAlpha, + Colon, + Slash1, + Slash2, + } + let mut state = State::None; + + while let Some(token) = self.peek() { + state = match (token.word, state) { + (TokenType::Punctuation(':'), State::None | State::Colon) => State::Slash1, + (TokenType::Punctuation('/'), State::Slash1) => State::Slash2, + (TokenType::Punctuation('/'), State::Slash2) => return true, + (TokenType::Punctuation('+'), State::None) => State::PlusAlpha, + (TokenType::Alphabetic(t) | TokenType::Alphanumeric(t), State::PlusAlpha) + if t.is_ascii() => + { + State::Colon + } + _ => break, + }; + } + self.peek_rewind(); + false + } +} + +impl TokenType { + fn is_email_atom(&self) -> bool { + matches!( + self, + TokenType::Alphabetic(_) + | TokenType::Integer(_) + | TokenType::Alphanumeric(_) + | TokenType::Other(_) + | TokenType::Punctuation( + '!' | '#' + | '$' + | '%' + | '&' + | '\'' + | '*' + | '+' + | '-' + | '/' + | '=' + | '?' + | '^' + | '_' + | '`' + | '{' + | '|' + | '}' + | '~', + ) + ) + } + + fn is_domain_atom(&self, is_start: bool) -> bool { + matches!( + self, + TokenType::Alphabetic(_) + | TokenType::Integer(_) + | TokenType::Alphanumeric(_) + | TokenType::Other(_) + ) || (!is_start && matches!(self, TokenType::Punctuation('-'))) + } +} + +#[cfg(test)] +mod test { + + use super::{TokenType, TypesTokenizer}; + + #[test] + fn type_tokenizer() { + // Credits: test suite from linkify crate + for (text, expected) in [ + ("", vec![]), + ("foo", vec![TokenType::Alphabetic("foo")]), + (":", vec![TokenType::Punctuation(':')]), + ( + "://", + vec![ + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + ], + ), + ( + ":::", + vec![ + TokenType::Punctuation(':'), + TokenType::Punctuation(':'), + TokenType::Punctuation(':'), + ], + ), + ( + "://foo", + vec![ + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ( + "1://foo", + vec![ + TokenType::Integer("1"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ( + "123://foo", + vec![ + TokenType::Integer("123"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ( + "+://foo", + vec![ + TokenType::Punctuation('+'), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ( + "-://foo", + vec![ + TokenType::Punctuation('-'), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ( + ".://foo", + vec![ + TokenType::Punctuation('.'), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("foo"), + ], + ), + ("1abc://foo", vec![TokenType::UrlNoHost("1abc://foo")]), + ("a://foo", vec![TokenType::UrlNoHost("a://foo")]), + ("a123://foo", vec![TokenType::UrlNoHost("a123://foo")]), + ("a123b://foo", vec![TokenType::UrlNoHost("a123b://foo")]), + ("a+b://foo", vec![TokenType::UrlNoHost("a+b://foo")]), + ( + "a-b://foo", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('-'), + TokenType::UrlNoHost("b://foo"), + ], + ), + ( + "a.b://foo", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('.'), + TokenType::UrlNoHost("b://foo"), + ], + ), + ("ABC://foo", vec![TokenType::UrlNoHost("ABC://foo")]), + ( + ".http://example.org/", + vec![ + TokenType::Punctuation('.'), + TokenType::Url("http://example.org/"), + ], + ), + ( + "1.http://example.org/", + vec![ + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::Url("http://example.org/"), + ], + ), + ( + "ab://", + vec![ + TokenType::Alphabetic("ab"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + ], + ), + ( + "file://", + vec![ + TokenType::Alphabetic("file"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + ], + ), + ( + "file:// ", + vec![ + TokenType::Alphabetic("file"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Space, + ], + ), + ( + "\"file://\"", + vec![ + TokenType::Punctuation('"'), + TokenType::Alphabetic("file"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Punctuation('"'), + ], + ), + ( + "\"file://...\", ", + vec![ + TokenType::Punctuation('"'), + TokenType::Alphabetic("file"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('"'), + TokenType::Punctuation(','), + TokenType::Space, + ], + ), + ( + "file://somefile", + vec![TokenType::UrlNoHost("file://somefile")], + ), + ( + "file://../relative", + vec![TokenType::UrlNoHost("file://../relative")], + ), + ( + "http://a.", + vec![ + TokenType::UrlNoHost("http://a"), + TokenType::Punctuation('.'), + ], + ), + ("http://127.0.0.1", vec![TokenType::Url("http://127.0.0.1")]), + ( + "http://127.0.0.1/", + vec![TokenType::Url("http://127.0.0.1/")], + ), + ("ab://c", vec![TokenType::UrlNoHost("ab://c")]), + ( + "http://example.org/", + vec![TokenType::Url("http://example.org/")], + ), + ( + "http://example.org/123", + vec![TokenType::Url("http://example.org/123")], + ), + ( + "http://example.org/?foo=test&bar=123", + vec![TokenType::Url("http://example.org/?foo=test&bar=123")], + ), + ( + "http://example.org/?foo=%20", + vec![TokenType::Url("http://example.org/?foo=%20")], + ), + ( + "http://example.org/%3C", + vec![TokenType::Url("http://example.org/%3C")], + ), + ("example.org/", vec![TokenType::UrlNoScheme("example.org/")]), + ( + "example.org/123", + vec![TokenType::UrlNoScheme("example.org/123")], + ), + ( + "example.org/?foo=test&bar=123", + vec![TokenType::UrlNoScheme("example.org/?foo=test&bar=123")], + ), + ( + "example.org/?foo=%20", + vec![TokenType::UrlNoScheme("example.org/?foo=%20")], + ), + ( + "example.org/%3C", + vec![TokenType::UrlNoScheme("example.org/%3C")], + ), + ( + "foo http://example.org/", + vec![ + TokenType::Alphabetic("foo"), + TokenType::Space, + TokenType::Url("http://example.org/"), + ], + ), + ( + "http://example.org/ bar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/\tbar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/\nbar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/\u{b}bar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/\u{c}bar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/\rbar", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "foo example.org/", + vec![ + TokenType::Alphabetic("foo"), + TokenType::Space, + TokenType::UrlNoScheme("example.org/"), + ], + ), + ( + "example.org/ bar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/\tbar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/\nbar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/\u{b}bar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/\u{c}bar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/\rbar", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Space, + TokenType::Alphabetic("bar"), + ], + ), + ( + "http://example.org/<", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('<'), + ], + ), + ( + "http://example.org/>", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org/<>", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('<'), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org/\0", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('\0'), + ], + ), + ( + "http://example.org/\u{e}", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('\u{e}'), + ], + ), + ( + "http://example.org/\u{7f}", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('\u{7f}'), + ], + ), + ( + "http://example.org/\u{9f}", + vec![TokenType::Url("http://example.org/\u{9f}")], + ), + ( + "http://example.org/foo|bar", + vec![ + TokenType::Url("http://example.org/foo"), + TokenType::Punctuation('|'), + TokenType::Alphabetic("bar"), + ], + ), + ( + "example.org/<", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('<'), + ], + ), + ( + "example.org/>", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org/<>", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('<'), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org/\0", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('\0'), + ], + ), + ( + "example.org/\u{e}", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('\u{e}'), + ], + ), + ( + "example.org/\u{7f}", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('\u{7f}'), + ], + ), + ( + "example.org/\u{9f}", + vec![TokenType::UrlNoScheme("example.org/\u{9f}")], + ), + ( + "http://example.org/.", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('.'), + ], + ), + ( + "http://example.org/..", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + ], + ), + ( + "http://example.org/,", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation(','), + ], + ), + ( + "http://example.org/:", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation(':'), + ], + ), + ( + "http://example.org/?", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('?'), + ], + ), + ( + "http://example.org/!", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('!'), + ], + ), + ( + "http://example.org/;", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation(';'), + ], + ), + ( + "example.org/.", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('.'), + ], + ), + ( + "example.org/..", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + ], + ), + ( + "example.org/,", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(','), + ], + ), + ( + "example.org/:", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(':'), + ], + ), + ( + "example.org/?", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('?'), + ], + ), + ( + "example.org/!", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('!'), + ], + ), + ( + "example.org/;", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(';'), + ], + ), + ( + "http://example.org/a(b)", + vec![TokenType::Url("http://example.org/a(b)")], + ), + ( + "http://example.org/a[b]", + vec![TokenType::Url("http://example.org/a[b]")], + ), + ( + "http://example.org/a{b}", + vec![TokenType::Url("http://example.org/a{b}")], + ), + ( + "http://example.org/a'b'", + vec![TokenType::Url("http://example.org/a'b'")], + ), + ( + "(http://example.org/)", + vec![ + TokenType::Punctuation('('), + TokenType::Url("http://example.org/"), + TokenType::Punctuation(')'), + ], + ), + ( + "[http://example.org/]", + vec![ + TokenType::Punctuation('['), + TokenType::Url("http://example.org/"), + TokenType::Punctuation(']'), + ], + ), + ( + "{http://example.org/}", + vec![ + TokenType::Punctuation('{'), + TokenType::Url("http://example.org/"), + TokenType::Punctuation('}'), + ], + ), + ( + "\"http://example.org/\"", + vec![ + TokenType::Punctuation('"'), + TokenType::Url("http://example.org/"), + TokenType::Punctuation('"'), + ], + ), + ( + "'http://example.org/'", + vec![ + TokenType::Punctuation('\''), + TokenType::Url("http://example.org/"), + TokenType::Punctuation('\''), + ], + ), + ( + "example.org/a(b)", + vec![TokenType::UrlNoScheme("example.org/a(b)")], + ), + ( + "example.org/a[b]", + vec![TokenType::UrlNoScheme("example.org/a[b]")], + ), + ( + "example.org/a{b}", + vec![TokenType::UrlNoScheme("example.org/a{b}")], + ), + ( + "example.org/a'b'", + vec![TokenType::UrlNoScheme("example.org/a'b'")], + ), + ( + "(example.org/)", + vec![ + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(')'), + ], + ), + ( + "[example.org/]", + vec![ + TokenType::Punctuation('['), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(']'), + ], + ), + ( + "{example.org/}", + vec![ + TokenType::Punctuation('{'), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('}'), + ], + ), + ( + "\"example.org/\"", + vec![ + TokenType::Punctuation('"'), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('"'), + ], + ), + ( + "'example.org/'", + vec![ + TokenType::Punctuation('\''), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('\''), + ], + ), + ( + "((http://example.org/))", + vec![ + TokenType::Punctuation('('), + TokenType::Punctuation('('), + TokenType::Url("http://example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation(')'), + ], + ), + ( + "((http://example.org/a(b)))", + vec![ + TokenType::Punctuation('('), + TokenType::Punctuation('('), + TokenType::Url("http://example.org/a(b)"), + TokenType::Punctuation(')'), + TokenType::Punctuation(')'), + ], + ), + ( + "[(http://example.org/)]", + vec![ + TokenType::Punctuation('['), + TokenType::Punctuation('('), + TokenType::Url("http://example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation(']'), + ], + ), + ( + "(http://example.org/).", + vec![ + TokenType::Punctuation('('), + TokenType::Url("http://example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation('.'), + ], + ), + ( + "(http://example.org/.)", + vec![ + TokenType::Punctuation('('), + TokenType::Url("http://example.org/"), + TokenType::Punctuation('.'), + TokenType::Punctuation(')'), + ], + ), + ( + "http://example.org/>", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org/(", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('('), + ], + ), + ( + "http://example.org/(.", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('('), + TokenType::Punctuation('.'), + ], + ), + ( + "http://example.org/]()", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation(']'), + TokenType::Punctuation('('), + TokenType::Punctuation(')'), + ], + ), + ( + "((example.org/))", + vec![ + TokenType::Punctuation('('), + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation(')'), + ], + ), + ( + "((example.org/a(b)))", + vec![ + TokenType::Punctuation('('), + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/a(b)"), + TokenType::Punctuation(')'), + TokenType::Punctuation(')'), + ], + ), + ( + "[(example.org/)]", + vec![ + TokenType::Punctuation('['), + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation(']'), + ], + ), + ( + "(example.org/).", + vec![ + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation('.'), + ], + ), + ( + "(example.org/.)", + vec![ + TokenType::Punctuation('('), + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('.'), + TokenType::Punctuation(')'), + ], + ), + ( + "example.org/>", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org/(", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('('), + ], + ), + ( + "example.org/(.", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('('), + TokenType::Punctuation('.'), + ], + ), + ( + "example.org/]()", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation(']'), + TokenType::Punctuation('('), + TokenType::Punctuation(')'), + ], + ), + ( + "'https://example.org'", + vec![ + TokenType::Punctuation('\''), + TokenType::Url("https://example.org"), + TokenType::Punctuation('\''), + ], + ), + ( + "\"https://example.org\"", + vec![ + TokenType::Punctuation('"'), + TokenType::Url("https://example.org"), + TokenType::Punctuation('"'), + ], + ), + ( + "''https://example.org''", + vec![ + TokenType::Punctuation('\''), + TokenType::Punctuation('\''), + TokenType::Url("https://example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('\''), + ], + ), + ( + "'https://example.org''", + vec![ + TokenType::Punctuation('\''), + TokenType::Url("https://example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('\''), + ], + ), + ( + "'https://example.org", + vec![ + TokenType::Punctuation('\''), + TokenType::Url("https://example.org"), + ], + ), + ( + "http://example.org/'_(foo)", + vec![TokenType::Url("http://example.org/'_(foo)")], + ), + ( + "http://example.org/'_(foo)'", + vec![TokenType::Url("http://example.org/'_(foo)'")], + ), + ( + "http://example.org/''", + vec![TokenType::Url("http://example.org/''")], + ), + ( + "http://example.org/'''", + vec![ + TokenType::Url("http://example.org/''"), + TokenType::Punctuation('\''), + ], + ), + ( + "http://example.org/'.", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('\''), + TokenType::Punctuation('.'), + ], + ), + ( + "http://example.org/'a", + vec![TokenType::Url("http://example.org/'a")], + ), + ( + "http://example.org/it's", + vec![TokenType::Url("http://example.org/it's")], + ), + ( + "example.org/'_(foo)", + vec![TokenType::UrlNoScheme("example.org/'_(foo)")], + ), + ( + "example.org/'_(foo)'", + vec![TokenType::UrlNoScheme("example.org/'_(foo)'")], + ), + ( + "example.org/''", + vec![TokenType::UrlNoScheme("example.org/''")], + ), + ( + "example.org/'''", + vec![ + TokenType::UrlNoScheme("example.org/''"), + TokenType::Punctuation('\''), + ], + ), + ( + "example.org/'.", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('\''), + TokenType::Punctuation('.'), + ], + ), + ( + "example.org/'a", + vec![TokenType::UrlNoScheme("example.org/'a")], + ), + ( + "example.org/it's", + vec![TokenType::UrlNoScheme("example.org/it's")], + ), + ( + "http://example.org/\"a", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('"'), + TokenType::Alphabetic("a"), + ], + ), + ( + "http://example.org/\"a\"", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('"'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('"'), + ], + ), + ( + "http://example.org/`a", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('`'), + TokenType::Alphabetic("a"), + ], + ), + ( + "http://example.org/`a`", + vec![ + TokenType::Url("http://example.org/"), + TokenType::Punctuation('`'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('`'), + ], + ), + ( + "https://example.org*", + vec![ + TokenType::Url("https://example.org"), + TokenType::Punctuation('*'), + ], + ), + ( + "https://example.org/*", + vec![ + TokenType::Url("https://example.org/"), + TokenType::Punctuation('*'), + ], + ), + ( + "https://example.org/**", + vec![ + TokenType::Url("https://example.org/"), + TokenType::Punctuation('*'), + TokenType::Punctuation('*'), + ], + ), + ( + "https://example.org/*/a", + vec![TokenType::Url("https://example.org/*/a")], + ), + ( + "example.org/`a", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('`'), + TokenType::Alphabetic("a"), + ], + ), + ( + "example.org/`a`", + vec![ + TokenType::UrlNoScheme("example.org/"), + TokenType::Punctuation('`'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('`'), + ], + ), + ( + "http://example.org\">", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org'>", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org\"/>", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation('/'), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org'/>", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('/'), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org

", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('<'), + TokenType::Alphabetic("p"), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org

", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("p"), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org\">", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org'>", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org\"/>", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation('/'), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org'/>", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation('/'), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org

", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('<'), + TokenType::Alphabetic("p"), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org

", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("p"), + TokenType::Punctuation('>'), + ], + ), + ( + "http://example.org\");", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation(')'), + TokenType::Punctuation(';'), + ], + ), + ( + "http://example.org');", + vec![ + TokenType::Url("http://example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation(')'), + TokenType::Punctuation(';'), + ], + ), + ( + "", + vec![ + TokenType::Punctuation('<'), + TokenType::Alphabetic("img"), + TokenType::Space, + TokenType::Alphabetic("src"), + TokenType::Punctuation('='), + TokenType::Punctuation('"'), + TokenType::Url("http://example.org/test.svg"), + TokenType::Punctuation('"'), + TokenType::Punctuation('>'), + ], + ), + ( + "
", + vec![ + TokenType::Punctuation('<'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Alphabetic("a"), + TokenType::Space, + TokenType::Alphabetic("href"), + TokenType::Punctuation('='), + TokenType::Punctuation('"'), + TokenType::Url("http://example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + ], + ), + ( + "
", + vec![ + TokenType::Punctuation('<'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Alphabetic("a"), + TokenType::Space, + TokenType::Alphabetic("href"), + TokenType::Punctuation('='), + TokenType::Punctuation('"'), + TokenType::Url("http://example.org"), + TokenType::Punctuation('"'), + TokenType::Space, + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('>'), + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + ], + ), + ( + "
\n \n
", + vec![ + TokenType::Punctuation('<'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + TokenType::Space, + TokenType::Punctuation('<'), + TokenType::Alphabetic("img"), + TokenType::Space, + TokenType::Alphabetic("src"), + TokenType::Punctuation('='), + TokenType::Punctuation('"'), + TokenType::Url("http://example.org/test3.jpg"), + TokenType::Punctuation('"'), + TokenType::Space, + TokenType::Punctuation('/'), + TokenType::Punctuation('>'), + TokenType::Space, + TokenType::Punctuation('<'), + TokenType::Punctuation('/'), + TokenType::Alphabetic("div"), + TokenType::Punctuation('>'), + ], + ), + ( + "example.org\");", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('"'), + TokenType::Punctuation(')'), + TokenType::Punctuation(';'), + ], + ), + ( + "example.org');", + vec![ + TokenType::UrlNoScheme("example.org"), + TokenType::Punctuation('\''), + TokenType::Punctuation(')'), + TokenType::Punctuation(';'), + ], + ), + ( + "http://example.org/", + vec![TokenType::Url("http://example.org/")], + ), + ( + "http://example.org/a/", + vec![TokenType::Url("http://example.org/a/")], + ), + ( + "http://example.org//", + vec![TokenType::Url("http://example.org//")], + ), + ("example.org/", vec![TokenType::UrlNoScheme("example.org/")]), + ( + "example.org/a/", + vec![TokenType::UrlNoScheme("example.org/a/")], + ), + ( + "example.org//", + vec![TokenType::UrlNoScheme("example.org//")], + ), + ( + "http://one.org/ http://two.org/", + vec![ + TokenType::Url("http://one.org/"), + TokenType::Space, + TokenType::Url("http://two.org/"), + ], + ), + ( + "http://one.org/ : http://two.org/", + vec![ + TokenType::Url("http://one.org/"), + TokenType::Space, + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::Url("http://two.org/"), + ], + ), + ( + "(http://one.org/)(http://two.org/)", + vec![ + TokenType::Punctuation('('), + TokenType::Url("http://one.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation('('), + TokenType::Url("http://two.org/"), + TokenType::Punctuation(')'), + ], + ), + ( + "one.org/ two.org/", + vec![ + TokenType::UrlNoScheme("one.org/"), + TokenType::Space, + TokenType::UrlNoScheme("two.org/"), + ], + ), + ( + "one.org/ : two.org/", + vec![ + TokenType::UrlNoScheme("one.org/"), + TokenType::Space, + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::UrlNoScheme("two.org/"), + ], + ), + ( + "(one.org/)(two.org/)", + vec![ + TokenType::Punctuation('('), + TokenType::UrlNoScheme("one.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation('('), + TokenType::UrlNoScheme("two.org/"), + TokenType::Punctuation(')'), + ], + ), + ( + "http://one.org/ two.org/", + vec![ + TokenType::Url("http://one.org/"), + TokenType::Space, + TokenType::UrlNoScheme("two.org/"), + ], + ), + ( + "one.org/ : http://two.org/", + vec![ + TokenType::UrlNoScheme("one.org/"), + TokenType::Space, + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::Url("http://two.org/"), + ], + ), + ( + "(http://one.org/)(two.org/)", + vec![ + TokenType::Punctuation('('), + TokenType::Url("http://one.org/"), + TokenType::Punctuation(')'), + TokenType::Punctuation('('), + TokenType::UrlNoScheme("two.org/"), + TokenType::Punctuation(')'), + ], + ), + ( + "http://üñîçøðé.com", + vec![TokenType::Url("http://üñîçøðé.com")], + ), + ( + "http://üñîçøðé.com/ä", + vec![TokenType::Url("http://üñîçøðé.com/ä")], + ), + ( + "http://example.org/¡", + vec![TokenType::Url("http://example.org/¡")], + ), + ( + "http://example.org/¢", + vec![TokenType::Url("http://example.org/¢")], + ), + ( + "http://example.org/😀", + vec![TokenType::Url("http://example.org/😀")], + ), + ( + "http://example.org/¢/", + vec![TokenType::Url("http://example.org/¢/")], + ), + ( + "http://xn--c1h.example.com/", + vec![TokenType::Url("http://xn--c1h.example.com/")], + ), + ("üñîçøðé.com", vec![TokenType::UrlNoScheme("üñîçøðé.com")]), + ( + "üñîçøðé.com/ä", + vec![TokenType::UrlNoScheme("üñîçøðé.com/ä")], + ), + ( + "example.org/¡", + vec![TokenType::UrlNoScheme("example.org/¡")], + ), + ( + "example.org/¢", + vec![TokenType::UrlNoScheme("example.org/¢")], + ), + ( + "example.org/😀", + vec![TokenType::UrlNoScheme("example.org/😀")], + ), + ( + "example.org/¢/", + vec![TokenType::UrlNoScheme("example.org/¢/")], + ), + ( + "xn--c1h.example.com/", + vec![TokenType::UrlNoScheme("xn--c1h.example.com/")], + ), + ( + "example.", + vec![ + TokenType::Alphabetic("example"), + TokenType::Punctuation('.'), + ], + ), + ( + "example./", + vec![ + TokenType::Alphabetic("example"), + TokenType::Punctuation('.'), + TokenType::Punctuation('/'), + ], + ), + ( + "foo.com.", + vec![ + TokenType::UrlNoScheme("foo.com"), + TokenType::Punctuation('.'), + ], + ), + ( + "example.c", + vec![ + TokenType::Alphabetic("example"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("c"), + ], + ), + ("example.co", vec![TokenType::UrlNoScheme("example.co")]), + ("example.com", vec![TokenType::UrlNoScheme("example.com")]), + ("e.com", vec![TokenType::UrlNoScheme("e.com")]), + ( + "exampl.e.c", + vec![ + TokenType::Alphabetic("exampl"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("e"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("c"), + ], + ), + ("exampl.e.co", vec![TokenType::UrlNoScheme("exampl.e.co")]), + ( + "e.xample.c", + vec![ + TokenType::Alphabetic("e"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("xample"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("c"), + ], + ), + ("e.xample.co", vec![TokenType::UrlNoScheme("e.xample.co")]), + ( + "v1.1.1", + vec![ + TokenType::Alphanumeric("v1"), + TokenType::Punctuation('.'), + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::Integer("1"), + ], + ), + ( + "foo.bar@example.org", + vec![TokenType::Email("foo.bar@example.org")], + ), + ( + "example.com@example.com", + vec![TokenType::Email("example.com@example.com")], + ), + ( + "Look, no scheme: example.org/foo email@foo.com", + vec![ + TokenType::Alphabetic("Look"), + TokenType::Punctuation(','), + TokenType::Space, + TokenType::Alphabetic("no"), + TokenType::Space, + TokenType::Alphabetic("scheme"), + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::UrlNoScheme("example.org/foo"), + TokenType::Space, + TokenType::Email("email@foo.com"), + ], + ), + ( + "Web:\nwww.foobar.co\nE-Mail:\n bar@foobar.co (bla bla bla)", + vec![ + TokenType::Alphabetic("Web"), + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::UrlNoScheme("www.foobar.co"), + TokenType::Space, + TokenType::Alphabetic("E"), + TokenType::Punctuation('-'), + TokenType::Alphabetic("Mail"), + TokenType::Punctuation(':'), + TokenType::Space, + TokenType::Email("bar@foobar.co"), + TokenType::Space, + TokenType::Punctuation('('), + TokenType::Alphabetic("bla"), + TokenType::Space, + TokenType::Alphabetic("bla"), + TokenType::Space, + TokenType::Alphabetic("bla"), + TokenType::Punctuation(')'), + ], + ), + ( + "upi://pay?pa=XXXXXXX&pn=XXXXX", + vec![TokenType::UrlNoHost("upi://pay?pa=XXXXXXX&pn=XXXXX")], + ), + ( + "https://example.org?pa=XXXXXXX&pn=XXXXX", + vec![TokenType::Url("https://example.org?pa=XXXXXXX&pn=XXXXX")], + ), + ( + "website https://domain.com", + vec![ + TokenType::Alphabetic("website"), + TokenType::Space, + TokenType::Url("https://domain.com"), + ], + ), + ("a12.b-c.com", vec![TokenType::UrlNoScheme("a12.b-c.com")]), + ( + "v1.2.3", + vec![ + TokenType::Alphanumeric("v1"), + TokenType::Punctuation('.'), + TokenType::Integer("2"), + TokenType::Punctuation('.'), + TokenType::Integer("3"), + ], + ), + ( + "https://12-7.0.0.1/", + vec![TokenType::UrlNoHost("https://12-7.0.0.1/")], + ), + ( + "https://user:pass@example.com/", + vec![TokenType::Url("https://user:pass@example.com/")], + ), + ( + "https://user:-.!$@example.com/", + vec![TokenType::Url("https://user:-.!$@example.com/")], + ), + ( + "https://user:!$&'()*+,;=@example.com/", + vec![TokenType::Url("https://user:!$&'()*+,;=@example.com/")], + ), + ( + "https://user:pass@ex@mple.com/", + vec![ + TokenType::UrlNoHost("https://user:pass@ex"), + TokenType::Punctuation('@'), + TokenType::UrlNoScheme("mple.com/"), + ], + ), + ( + "https://localhost:8080!", + vec![ + TokenType::UrlNoHost("https://localhost:8080"), + TokenType::Punctuation('!'), + ], + ), + ( + "https://localhost:8080/", + vec![TokenType::UrlNoHost("https://localhost:8080/")], + ), + ( + "https://user:pass@example.com:8080/hi", + vec![TokenType::Url("https://user:pass@example.com:8080/hi")], + ), + ( + "https://127.0.0.1/", + vec![TokenType::Url("https://127.0.0.1/")], + ), + ("1.0.0.0", vec![TokenType::IpAddr("1.0.0.0")]), + ( + "1.0.0.0/foo/bar", + vec![TokenType::UrlNoScheme("1.0.0.0/foo/bar")], + ), + ("1.0 ", vec![TokenType::Float("1.0"), TokenType::Space]), + ( + "1.0.0", + vec![ + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::Integer("0"), + TokenType::Punctuation('.'), + TokenType::Integer("0"), + ], + ), + ( + "1.0.0.0.0", + vec![ + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::IpAddr("0.0.0.0"), + ], + ), + ( + "1.0.0.", + vec![ + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::Integer("0"), + TokenType::Punctuation('.'), + TokenType::Integer("0"), + TokenType::Punctuation('.'), + ], + ), + ( + "https://example.com.:8080/test", + vec![TokenType::Url("https://example.com.:8080/test")], + ), + ( + "https://example.org'", + vec![ + TokenType::Url("https://example.org"), + TokenType::Punctuation('\''), + ], + ), + ( + "https://example.org'a@example.com", + vec![TokenType::Url("https://example.org'a@example.com")], + ), + ( + "https://a.com'https://b.com", + vec![ + TokenType::UrlNoHost("https://a.com'https"), + TokenType::Punctuation(':'), + TokenType::Punctuation('/'), + TokenType::Punctuation('/'), + TokenType::UrlNoScheme("b.com"), + ], + ), + ( + "https://example.com...", + vec![ + TokenType::Url("https://example.com"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + ], + ), + ( + "www.example..com", + vec![ + TokenType::Alphabetic("www"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("example"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Alphabetic("com"), + ], + ), + ( + "https://.www.example.com", + vec![TokenType::Url("https://.www.example.com")], + ), + ( + "-a.com", + vec![TokenType::Punctuation('-'), TokenType::UrlNoScheme("a.com")], + ), + ("https://a.-b.com", vec![TokenType::Url("https://a.-b.com")]), + ( + "a-.com", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('-'), + TokenType::Punctuation('.'), + TokenType::Alphabetic("com"), + ], + ), + ( + "a.b-.com", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("b"), + TokenType::Punctuation('-'), + TokenType::Punctuation('.'), + TokenType::Alphabetic("com"), + ], + ), + ("https://a.b-.com", vec![TokenType::Url("https://a.b-.com")]), + ( + "https://example.com-/", + vec![ + TokenType::Url("https://example.com"), + TokenType::Punctuation('-'), + TokenType::Punctuation('/'), + ], + ), + ( + "https://example.org-", + vec![ + TokenType::Url("https://example.org"), + TokenType::Punctuation('-'), + ], + ), + ( + "example.com@about", + vec![ + TokenType::UrlNoScheme("example.com"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("about"), + ], + ), + ( + "example.com/@about", + vec![TokenType::UrlNoScheme("example.com/@about")], + ), + ( + "https://example.com/@about", + vec![TokenType::Url("https://example.com/@about")], + ), + ( + "info@v1.1.1", + vec![ + TokenType::Alphabetic("info"), + TokenType::Punctuation('@'), + TokenType::Alphanumeric("v1"), + TokenType::Punctuation('.'), + TokenType::Integer("1"), + TokenType::Punctuation('.'), + TokenType::Integer("1"), + ], + ), + ("file:///", vec![TokenType::UrlNoHost("file:///")]), + ( + "file:///home/foo", + vec![TokenType::UrlNoHost("file:///home/foo")], + ), + ( + "file://localhost/home/foo", + vec![TokenType::UrlNoHost("file://localhost/home/foo")], + ), + ( + "facetime://+19995551234", + vec![TokenType::UrlNoHost("facetime://+19995551234")], + ), + ( + "test://123'456!!!", + vec![ + TokenType::UrlNoHost("test://123'456"), + TokenType::Punctuation('!'), + TokenType::Punctuation('!'), + TokenType::Punctuation('!'), + ], + ), + ( + "test://123'456...", + vec![ + TokenType::UrlNoHost("test://123'456"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + ], + ), + ( + "test://123'456!!!/", + vec![ + TokenType::UrlNoHost("test://123'456"), + TokenType::Punctuation('!'), + TokenType::Punctuation('!'), + TokenType::Punctuation('!'), + TokenType::Punctuation('/'), + ], + ), + ( + "test://123'456.../", + vec![ + TokenType::UrlNoHost("test://123'456"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Punctuation('/'), + ], + ), + ( + "1abc://example.com", + vec![TokenType::Url("1abc://example.com")], + ), + ( + "¡¢example.com", + vec![TokenType::UrlNoScheme("¡¢example.com")], + ), + ("foo", vec![TokenType::Alphabetic("foo")]), + ("@", vec![TokenType::Punctuation('@')]), + ( + "a@", + vec![TokenType::Alphabetic("a"), TokenType::Punctuation('@')], + ), + ( + "@a", + vec![TokenType::Punctuation('@'), TokenType::Alphabetic("a")], + ), + ( + "@@@", + vec![ + TokenType::Punctuation('@'), + TokenType::Punctuation('@'), + TokenType::Punctuation('@'), + ], + ), + ("foo@example.com", vec![TokenType::Email("foo@example.com")]), + ( + "foo.bar@example.com", + vec![TokenType::Email("foo.bar@example.com")], + ), + ( + "#!$%&'*+-/=?^_`{}|~@example.org", + vec![TokenType::Email("#!$%&'*+-/=?^_`{}|~@example.org")], + ), + ( + "foo a@b.com", + vec![ + TokenType::Alphabetic("foo"), + TokenType::Space, + TokenType::Email("a@b.com"), + ], + ), + ( + "a@b.com foo", + vec![ + TokenType::Email("a@b.com"), + TokenType::Space, + TokenType::Alphabetic("foo"), + ], + ), + ( + "\na@b.com", + vec![TokenType::Space, TokenType::Email("a@b.com")], + ), + ( + "a@b.com\n", + vec![TokenType::Email("a@b.com"), TokenType::Space], + ), + ( + "(a@example.com)", + vec![ + TokenType::Punctuation('('), + TokenType::Email("a@example.com"), + TokenType::Punctuation(')'), + ], + ), + ( + "\"a@example.com\"", + vec![ + TokenType::Punctuation('"'), + TokenType::Email("a@example.com"), + TokenType::Punctuation('"'), + ], + ), + ( + "\"a@example.com\"", + vec![ + TokenType::Punctuation('"'), + TokenType::Email("a@example.com"), + TokenType::Punctuation('"'), + ], + ), + ( + ",a@example.com,", + vec![ + TokenType::Punctuation(','), + TokenType::Email("a@example.com"), + TokenType::Punctuation(','), + ], + ), + ( + ":a@example.com:", + vec![ + TokenType::Punctuation(':'), + TokenType::Email("a@example.com"), + TokenType::Punctuation(':'), + ], + ), + ( + ";a@example.com;", + vec![ + TokenType::Punctuation(';'), + TokenType::Email("a@example.com"), + TokenType::Punctuation(';'), + ], + ), + ( + ".@example.com", + vec![ + TokenType::Punctuation('.'), + TokenType::Punctuation('@'), + TokenType::UrlNoScheme("example.com"), + ], + ), + ( + "foo.@example.com", + vec![ + TokenType::Alphabetic("foo"), + TokenType::Punctuation('.'), + TokenType::Punctuation('@'), + TokenType::UrlNoScheme("example.com"), + ], + ), + ( + ".foo@example.com", + vec![ + TokenType::Punctuation('.'), + TokenType::Email("foo@example.com"), + ], + ), + ( + ".foo@example.com", + vec![ + TokenType::Punctuation('.'), + TokenType::Email("foo@example.com"), + ], + ), + ( + "a..b@example.com", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('.'), + TokenType::Punctuation('.'), + TokenType::Email("b@example.com"), + ], + ), + ( + "a@example.com.", + vec![ + TokenType::Email("a@example.com"), + TokenType::Punctuation('.'), + ], + ), + ( + "a@b", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("b"), + ], + ), + ( + "a@b.", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("b"), + TokenType::Punctuation('.'), + ], + ), + ( + "a@b.com.", + vec![TokenType::Email("a@b.com"), TokenType::Punctuation('.')], + ), + ( + "a@example.com-", + vec![ + TokenType::Email("a@example.com"), + TokenType::Punctuation('-'), + ], + ), + ("a@foo-bar.com", vec![TokenType::Email("a@foo-bar.com")]), + ( + "a@-foo.com", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Punctuation('-'), + TokenType::UrlNoScheme("foo.com"), + ], + ), + ( + "a@b-.", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("b"), + TokenType::Punctuation('-'), + TokenType::Punctuation('.'), + ], + ), + ( + "a@b", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("b"), + ], + ), + ( + "a@b.", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("b"), + TokenType::Punctuation('.'), + ], + ), + ( + "a@example.com b@example.com", + vec![ + TokenType::Email("a@example.com"), + TokenType::Space, + TokenType::Email("b@example.com"), + ], + ), + ( + "a@example.com @ b@example.com", + vec![ + TokenType::Email("a@example.com"), + TokenType::Space, + TokenType::Punctuation('@'), + TokenType::Space, + TokenType::Email("b@example.com"), + ], + ), + ( + "a@xy.com;b@xy.com,c@xy.com", + vec![ + TokenType::Email("a@xy.com"), + TokenType::Punctuation(';'), + TokenType::Email("b@xy.com"), + TokenType::Punctuation(','), + TokenType::Email("c@xy.com"), + ], + ), + ( + "üñîçøðé@example.com", + vec![TokenType::Email("üñîçøðé@example.com")], + ), + ( + "üñîçøðé@üñîçøðé.com", + vec![TokenType::Email("üñîçøðé@üñîçøðé.com")], + ), + ("www@example.com", vec![TokenType::Email("www@example.com")]), + ( + "a@a.xyϸ", + vec![ + TokenType::Alphabetic("a"), + TokenType::Punctuation('@'), + TokenType::Alphabetic("a"), + TokenType::Punctuation('.'), + TokenType::Alphabetic("xyϸ"), + ], + ), + ( + "100 -100 100.00 -100.00 $100 $100.00", + vec![ + TokenType::Integer("100"), + TokenType::Space, + TokenType::Integer("-100"), + TokenType::Space, + TokenType::Float("100.00"), + TokenType::Space, + TokenType::Float("-100.00"), + TokenType::Space, + TokenType::Punctuation('$'), + TokenType::Integer("100"), + TokenType::Space, + TokenType::Punctuation('$'), + TokenType::Float("100.00"), + ], + ), + ( + " - 100 100 . 00", + vec![ + TokenType::Space, + TokenType::Punctuation('-'), + TokenType::Space, + TokenType::Integer("100"), + TokenType::Space, + TokenType::Integer("100"), + TokenType::Space, + TokenType::Punctuation('.'), + TokenType::Space, + TokenType::Integer("00"), + ], + ), + ( + "send $100.00 to user@domain.com or visit domain.com/pay-me!", + vec![ + TokenType::Alphabetic("send"), + TokenType::Space, + TokenType::Punctuation('$'), + TokenType::Float("100.00"), + TokenType::Space, + TokenType::Alphabetic("to"), + TokenType::Space, + TokenType::Email("user@domain.com"), + TokenType::Space, + TokenType::Alphabetic("or"), + TokenType::Space, + TokenType::Alphabetic("visit"), + TokenType::Space, + TokenType::UrlNoScheme("domain.com/pay-me"), + TokenType::Punctuation('!'), + ], + ), + ( + "vEⓡ𝔂 𝔽𝕌Ňℕy ţ乇𝕏𝓣 wWiIiIIttHh l133t5p3/-\\|<", + vec![ + TokenType::Alphabetic("vEⓡ𝔂"), + TokenType::Space, + TokenType::Alphabetic("𝔽𝕌Ňℕy"), + TokenType::Space, + TokenType::Alphabetic("ţ乇𝕏𝓣"), + TokenType::Space, + TokenType::Alphabetic("wWiIiIIttHh"), + TokenType::Space, + TokenType::Alphanumeric("l133t5p3"), + TokenType::Punctuation('/'), + TokenType::Punctuation('-'), + TokenType::Punctuation('\\'), + TokenType::Punctuation('|'), + TokenType::Punctuation('<'), + ], + ), + ] { + let result = TypesTokenizer::new(text) + .map(|t| t.word) + .collect::>(); + + assert_eq!(result, expected, "text: {:?}", text); + + /*print!("({text:?}, "); + print!("vec!["); + for (pos, item) in result.into_iter().enumerate() { + if pos > 0 { + print!(", "); + } + print!("TokenType::{:?}", item); + } + println!("]),");*/ + } + } +} diff --git a/crates/nlp/src/tokenizers/word.rs b/crates/nlp/src/tokenizers/word.rs new file mode 100644 index 0000000..18ef22b --- /dev/null +++ b/crates/nlp/src/tokenizers/word.rs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, str::CharIndices}; + +use super::Token; + +pub struct WordTokenizer<'x> { + max_token_length: usize, + text: &'x str, + iterator: CharIndices<'x>, +} + +impl WordTokenizer<'_> { + pub fn new(text: &'_ str, max_token_length: usize) -> WordTokenizer<'_> { + WordTokenizer { + max_token_length, + text, + iterator: text.char_indices(), + } + } +} + +/// Parses indo-european text into lowercase tokens. +impl<'x> Iterator for WordTokenizer<'x> { + type Item = Token>; + + fn next(&mut self) -> Option { + while let Some((token_start, ch)) = self.iterator.next() { + if ch.is_alphanumeric() { + let mut is_uppercase = ch.is_uppercase(); + let token_end = (&mut self.iterator) + .filter_map(|(pos, ch)| { + if ch.is_alphanumeric() { + if !is_uppercase && ch.is_uppercase() { + is_uppercase = true; + } + None + } else { + pos.into() + } + }) + .next() + .unwrap_or(self.text.len()); + + let token_len = token_end - token_start; + if token_end > token_start && token_len <= self.max_token_length { + return Token::new( + token_start, + token_len, + if is_uppercase { + self.text[token_start..token_end].to_lowercase().into() + } else { + self.text[token_start..token_end].into() + }, + ) + .into(); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indo_european_tokenizer() { + let inputs = [ + ( + "The quick brown fox jumps over the lazy dog", + vec![ + Token::new(0, 3, "the".into()), + Token::new(4, 5, "quick".into()), + Token::new(10, 5, "brown".into()), + Token::new(16, 3, "fox".into()), + Token::new(20, 5, "jumps".into()), + Token::new(26, 4, "over".into()), + Token::new(31, 3, "the".into()), + Token::new(35, 4, "lazy".into()), + Token::new(40, 3, "dog".into()), + ], + ), + ( + "Jovencillo EMPONZOÑADO de whisky: ¡qué figurota exhibe!", + vec![ + Token::new(0, 10, "jovencillo".into()), + Token::new(11, 12, "emponzoñado".into()), + Token::new(24, 2, "de".into()), + Token::new(27, 6, "whisky".into()), + Token::new(37, 4, "qué".into()), + Token::new(42, 8, "figurota".into()), + Token::new(51, 6, "exhibe".into()), + ], + ), + ( + "ZWÖLF Boxkämpfer jagten Victor quer über den großen Sylter Deich", + vec![ + Token::new(0, 6, "zwölf".into()), + Token::new(7, 11, "boxkämpfer".into()), + Token::new(19, 6, "jagten".into()), + Token::new(26, 6, "victor".into()), + Token::new(33, 4, "quer".into()), + Token::new(38, 5, "über".into()), + Token::new(44, 3, "den".into()), + Token::new(48, 7, "großen".into()), + Token::new(56, 6, "sylter".into()), + Token::new(63, 5, "deich".into()), + ], + ), + ( + "Съешь ещё этих мягких французских булок, да выпей же чаю", + vec![ + Token::new(0, 10, "съешь".into()), + Token::new(11, 6, "ещё".into()), + Token::new(18, 8, "этих".into()), + Token::new(27, 12, "мягких".into()), + Token::new(40, 22, "французских".into()), + Token::new(63, 10, "булок".into()), + Token::new(75, 4, "да".into()), + Token::new(80, 10, "выпей".into()), + Token::new(91, 4, "же".into()), + Token::new(96, 6, "чаю".into()), + ], + ), + ( + "Pijamalı hasta yağız şoföre çabucak güvendi", + vec![ + Token::new(0, 9, "pijamalı".into()), + Token::new(10, 5, "hasta".into()), + Token::new(16, 7, "yağız".into()), + Token::new(24, 8, "şoföre".into()), + Token::new(33, 8, "çabucak".into()), + Token::new(42, 8, "güvendi".into()), + ], + ), + ]; + + for (input, tokens) in inputs.iter() { + for (pos, token) in WordTokenizer::new(input, 40).enumerate() { + assert_eq!(token, tokens[pos]); + } + } + } +} diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml new file mode 100644 index 0000000..b344b3e --- /dev/null +++ b/crates/pop3/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pop3" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +common = { path = "../common" } +directory = { path = "../directory" } +imap = { path = "../imap" } +utils = { path = "../utils" } +trc = { path = "../trc" } +types = { path = "../types" } +email = { path = "../email" } +registry = { path = "../registry" } +mail-parser = { version = "0.11", features = ["full_encoding"] } +tokio = { version = "1.53", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } + +[features] +test_mode = [] + +[lints] +workspace = true diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs new file mode 100644 index 0000000..ab171bd --- /dev/null +++ b/crates/pop3/src/client.rs @@ -0,0 +1,269 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Session, State, + protocol::{Command, Mechanism, request::Error}, +}; +use common::{ + KV_RATE_LIMIT_IMAP, + network::{SessionResult, SessionStream}, +}; +use directory::Credentials; +use trc::{AddContext, SecurityEvent}; + +impl Session { + pub async fn ingest(&mut self, bytes: &[u8]) -> SessionResult { + trc::event!( + Pop3(trc::Pop3Event::RawInput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + let mut bytes = bytes.iter(); + let mut requests = Vec::with_capacity(2); + + loop { + match self.receiver.parse(&mut bytes) { + Ok(request) => { + // Group delete requests when possible + match (request, requests.last_mut()) { + (Command::Dele { msg }, Some(Ok(Command::DeleMany { msgs }))) => { + msgs.push(msg); + } + (Command::Dele { msg }, Some(Ok(Command::Dele { msg: other_msg }))) => { + let request = Ok(Command::DeleMany { + msgs: vec![*other_msg, msg], + }); + requests.pop(); + requests.push(request); + } + (request, _) => { + requests.push(Ok(request)); + } + } + } + Err(Error::NeedsMoreData) => { + break; + } + Err(Error::Parse(err)) => { + // Check for port scanners + if matches!(&self.state, State::NotAuthenticated { .. },) { + match self.server.is_scanner_fail2banned(self.remote_addr).await { + Ok(true) => { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = self.session_id, + RemoteIp = self.remote_addr, + Reason = "Invalid POP3 command", + ); + + return SessionResult::Close; + } + Ok(false) => {} + Err(err) => { + trc::error!( + err.span_id(self.session_id) + .details("Failed to check for fail2ban") + ); + } + } + } + requests.push(Err(trc::Pop3Event::Error.into_err().details(err))); + } + } + } + + for request in requests { + let result = match request { + Ok(command) => match self.validate_request(command).await { + Ok(command) => match command { + Command::User { name } => { + if let State::NotAuthenticated { username, .. } = &mut self.state { + let response = format!("{name} is a valid mailbox"); + *username = Some(name); + self.write_ok(response) + .await + .map(|_| SessionResult::Continue) + } else { + unreachable!(); + } + } + Command::Pass { string } => { + let username = + if let State::NotAuthenticated { username, .. } = &mut self.state { + username.take().unwrap() + } else { + unreachable!() + }; + Box::pin(self.handle_auth(Credentials::Basic { + username, + secret: string, + mfa_token: None, + })) + .await + .map(|_| SessionResult::Continue) + } + Command::Quit => self.handle_quit().await.map(|_| SessionResult::Close), + Command::Stat => self.handle_stat().await.map(|_| SessionResult::Continue), + Command::List { msg } => { + self.handle_list(msg).await.map(|_| SessionResult::Continue) + } + Command::Retr { msg } => self + .handle_fetch(msg, None) + .await + .map(|_| SessionResult::Continue), + Command::Dele { msg } => self + .handle_dele(vec![msg]) + .await + .map(|_| SessionResult::Continue), + Command::DeleMany { msgs } => self + .handle_dele(msgs) + .await + .map(|_| SessionResult::Continue), + Command::Top { msg, n } => self + .handle_fetch(msg, n.into()) + .await + .map(|_| SessionResult::Continue), + Command::Uidl { msg } => { + self.handle_uidl(msg).await.map(|_| SessionResult::Continue) + } + Command::Noop => { + trc::event!( + Pop3(trc::Pop3Event::Noop), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + self.write_ok("NOOP").await.map(|_| SessionResult::Continue) + } + Command::Rset => self.handle_rset().await.map(|_| SessionResult::Continue), + Command::Capa => self.handle_capa().await.map(|_| SessionResult::Continue), + Command::Stls => { + self.handle_stls().await.map(|_| SessionResult::UpgradeTls) + } + Command::Utf8 => self.handle_utf8().await.map(|_| SessionResult::Continue), + Command::Auth { mechanism, params } => { + Box::pin(self.handle_sasl(mechanism, params)) + .await + .map(|_| SessionResult::Continue) + } + Command::Apop { .. } => Err(trc::Pop3Event::Error + .into_err() + .details("APOP not supported.")), + }, + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + + match result { + Ok(SessionResult::Continue) => (), + Ok(result) => return result, + Err(err) => { + if !self.write_err(err).await { + return SessionResult::Close; + } + } + } + } + + SessionResult::Continue + } + + async fn validate_request( + &self, + command: Command, + ) -> trc::Result> { + match &command { + Command::Capa | Command::Quit | Command::Noop => Ok(command), + Command::Auth { + mechanism: Mechanism::Plain, + .. + } + | Command::User { .. } + | Command::Pass { .. } + | Command::Apop { .. } => { + if let State::NotAuthenticated { username, .. } = &self.state { + if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + if !matches!(command, Command::Pass { .. }) || username.is_some() { + Ok(command) + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Username was not provided.")) + } + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Cannot authenticate over plain-text.")) + } + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Already authenticated.")) + } + } + Command::Auth { .. } => { + if let State::NotAuthenticated { .. } = &self.state { + Ok(command) + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Already authenticated.")) + } + } + Command::Stls => { + if !self.stream.is_tls() { + Ok(command) + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Already in TLS mode.")) + } + } + + Command::List { .. } + | Command::Retr { .. } + | Command::Dele { .. } + | Command::DeleMany { .. } + | Command::Top { .. } + | Command::Uidl { .. } + | Command::Utf8 + | Command::Stat + | Command::Rset => { + if let State::Authenticated { mailbox, .. } = &self.state { + if let Some(rate) = &self.server.core.imap.rate_requests { + if self + .server + .in_memory_store() + .is_rate_allowed( + KV_RATE_LIMIT_IMAP, + &mailbox.account_id.to_be_bytes(), + rate, + true, + ) + .await + .caused_by(trc::location!())? + .is_none() + { + Ok(command) + } else { + Err(trc::LimitEvent::TooManyRequests.into_err()) + } + } else { + Ok(command) + } + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Not authenticated.")) + } + } + } + } +} diff --git a/crates/pop3/src/lib.rs b/crates/pop3/src/lib.rs new file mode 100644 index 0000000..d800fd7 --- /dev/null +++ b/crates/pop3/src/lib.rs @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use std::{net::IpAddr, sync::Arc}; + +use common::{ + Inner, Server, + auth::AccessToken, + network::{ServerInstance, SessionStream, limiter::InFlight}, +}; +use mailbox::Mailbox; +use protocol::request::Parser; + +pub mod client; +pub mod mailbox; +pub mod op; +pub mod protocol; +pub mod session; + +static SERVER_GREETING: &str = "+OK Stalwart POP3 at your service.\r\n"; + +#[derive(Clone)] +pub struct Pop3SessionManager { + pub inner: Arc, +} + +impl Pop3SessionManager { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +pub struct Session { + pub server: Server, + pub instance: Arc, + pub receiver: Parser, + pub state: State, + pub stream: T, + pub in_flight: InFlight, + pub remote_addr: IpAddr, + pub session_id: u64, +} + +pub enum State { + NotAuthenticated { + auth_failures: u32, + username: Option, + }, + Authenticated { + mailbox: Mailbox, + in_flight: Option, + access_token: AccessToken, + }, +} + +impl State { + pub fn mailbox(&self) -> &Mailbox { + match self { + State::Authenticated { mailbox, .. } => mailbox, + _ => unreachable!(), + } + } + + pub fn mailbox_mut(&mut self) -> &mut Mailbox { + match self { + State::Authenticated { mailbox, .. } => mailbox, + _ => unreachable!(), + } + } + + pub fn access_token(&self) -> &AccessToken { + match self { + State::Authenticated { access_token, .. } => access_token, + _ => unreachable!(), + } + } +} diff --git a/crates/pop3/src/mailbox.rs b/crates/pop3/src/mailbox.rs new file mode 100644 index 0000000..1747355 --- /dev/null +++ b/crates/pop3/src/mailbox.rs @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Session; +use common::network::SessionStream; +use email::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, + mailbox::INBOX_ID, +}; +use std::collections::BTreeMap; +use trc::AddContext; +use types::special_use::SpecialUse; + +#[derive(Default)] +pub struct Mailbox { + pub messages: Vec, + pub account_id: u32, + pub uid_validity: u32, + pub total: u32, + pub size: u32, +} + +pub struct Message { + pub id: u32, + pub uid: u32, + pub size: u32, + pub deleted: bool, +} + +impl Session { + pub async fn fetch_mailbox(&self, account_id: u32) -> trc::Result { + // Obtain UID validity + let cache = self + .server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + if cache.emails.items.is_empty() { + return Ok(Mailbox::default()); + } + + let uid_validity = cache + .mailbox_by_role(&SpecialUse::Inbox) + .map(|x| x.uid_validity) + .unwrap_or_default(); + + // Sort by UID + let message_map = cache + .emails + .items + .iter() + .filter_map(|message| { + message + .mailboxes + .iter() + .find(|m| m.mailbox_id == INBOX_ID) + .map(|m| (m.uid, (message.document_id, message.size))) + }) + .collect::>(); + + // Create mailbox + let mut mailbox = Mailbox { + messages: Vec::with_capacity(message_map.len()), + uid_validity, + account_id, + ..Default::default() + }; + for (uid, (id, size)) in message_map { + mailbox.messages.push(Message { + id, + uid, + size, + deleted: false, + }); + mailbox.total += 1; + mailbox.size += size; + } + + Ok(mailbox) + } +} diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs new file mode 100644 index 0000000..cf423e3 --- /dev/null +++ b/crates/pop3/src/op/authenticate.rs @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Session, State, + protocol::{Command, Mechanism, request}, +}; +use common::{ + auth::AuthRequest, + network::{SessionStream, limiter::LimiterResult}, +}; +use directory::Credentials; +use mail_parser::decoders::base64::base64_decode; +use registry::schema::enums::Permission; + +impl Session { + pub async fn handle_sasl( + &mut self, + mechanism: Mechanism, + mut params: Vec, + ) -> trc::Result<()> { + match mechanism { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => { + if !params.is_empty() { + let credentials = base64_decode(params.pop().unwrap().as_bytes()) + .and_then(|challenge| { + if mechanism == Mechanism::Plain { + Credentials::decode_sasl_challenge_plain(&challenge) + } else { + Credentials::decode_sasl_challenge_oauth(&challenge) + } + }) + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Invalid SASL challenge") + })?; + + Box::pin(self.handle_auth(credentials)).await + } else { + // TODO: This hack is temporary until the SASL library is developed + self.receiver.state = request::State::Argument { + request: Command::Auth { + mechanism: mechanism.as_str().as_bytes().to_vec(), + params: vec![], + }, + num: 1, + last_is_space: true, + }; + + self.write_bytes("+\r\n").await + } + } + _ => Err(trc::AuthEvent::Error + .into_err() + .details("Authentication mechanism not supported.")), + } + } + + pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> { + // Authenticate + let access_token = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.session_id, + self.remote_addr, + )) + .await + .map_err(|err| { + if err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)) { + match &self.state { + State::NotAuthenticated { + auth_failures, + username, + } if *auth_failures < self.server.core.imap.max_auth_failures => { + self.state = State::NotAuthenticated { + auth_failures: auth_failures + 1, + username: username.clone(), + }; + } + _ => { + return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err); + } + } + } + + err + }) + .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; + + // Enforce concurrency limits + let in_flight = match access_token.is_imap_request_allowed() { + LimiterResult::Allowed(in_flight) => Some(in_flight), + LimiterResult::Forbidden => { + return Err(trc::LimitEvent::ConcurrentRequest.into_err()); + } + LimiterResult::Disabled => None, + }; + + // Fetch mailbox + let mailbox = self.fetch_mailbox(access_token.account_id()).await?; + + // Create session + self.state = State::Authenticated { + in_flight, + mailbox, + access_token, + }; + self.write_ok("Authentication successful").await + } +} diff --git a/crates/pop3/src/op/delete.rs b/crates/pop3/src/op/delete.rs new file mode 100644 index 0000000..20ddc83 --- /dev/null +++ b/crates/pop3/src/op/delete.rs @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::network::SessionStream; +use email::message::delete::EmailDeletion; +use registry::schema::enums::Permission; +use store::{roaring::RoaringBitmap, write::BatchBuilder}; +use trc::AddContext; + +use crate::{Session, State, protocol::response::Response}; + +impl Session { + pub async fn handle_dele(&mut self, msgs: Vec) -> trc::Result<()> { + // Validate access + self.state + .access_token() + .enforce_permission(Permission::Pop3Dele)?; + + let op_start = Instant::now(); + let mailbox = self.state.mailbox_mut(); + let mut response = Vec::new(); + + for msg in &msgs { + if let Some(message) = mailbox.messages.get_mut(msg.saturating_sub(1) as usize) { + if !message.deleted { + response.extend_from_slice(format!("+OK message {msg} deleted\r\n").as_bytes()); + message.deleted = true; + } else { + response.extend_from_slice( + format!("-ERR message {msg} already deleted\r\n").as_bytes(), + ); + } + } else { + response.extend_from_slice("-ERR no such message\r\n".as_bytes()); + } + } + + trc::event!( + Pop3(trc::Pop3Event::Delete), + SpanId = self.session_id, + Total = msgs.len(), + Elapsed = op_start.elapsed() + ); + + self.write_bytes(response).await + } + + pub async fn handle_rset(&mut self) -> trc::Result<()> { + let op_start = Instant::now(); + let mut count = 0; + let mailbox = self.state.mailbox_mut(); + for message in &mut mailbox.messages { + if message.deleted { + count += 1; + message.deleted = false; + } + } + + trc::event!( + Pop3(trc::Pop3Event::Reset), + SpanId = self.session_id, + Total = count as u64, + Elapsed = op_start.elapsed() + ); + + self.write_ok(format!("{count} messages undeleted")).await + } + + pub async fn handle_quit(&mut self) -> trc::Result<()> { + let op_start = Instant::now(); + let mut deleted_docs = Vec::new(); + + if let State::Authenticated { mailbox, .. } = &self.state { + let mut deleted = RoaringBitmap::new(); + for message in &mailbox.messages { + if message.deleted { + deleted.insert(message.id); + deleted_docs.push(trc::Value::from(message.id)); + } + } + + if !deleted.is_empty() { + let num_deleted = deleted.len(); + let mut batch = BatchBuilder::new(); + let not_deleted = self + .server + .emails_delete( + mailbox.account_id, + self.state.access_token().tenant_id(), + &mut batch, + deleted, + ) + .await + .caused_by(trc::location!())?; + + if !batch.is_empty() { + self.server + .commit_batch(batch) + .await + .caused_by(trc::location!())?; + self.server.notify_task_queue(); + } + if not_deleted.is_empty() { + self.write_ok(format!( + "Stalwart POP3 bids you farewell ({num_deleted} messages deleted)." + )) + .await?; + } else { + self.write_bytes( + Response::Err::("Some messages could not be deleted".into()) + .serialize(), + ) + .await?; + } + } else { + self.write_ok("Stalwart POP3 bids you farewell (no messages deleted).") + .await?; + } + } else { + self.write_ok("Stalwart POP3 bids you farewell.").await?; + } + + trc::event!( + Pop3(trc::Pop3Event::Quit), + SpanId = self.session_id, + DocumentId = deleted_docs, + Elapsed = op_start.elapsed() + ); + + Ok(()) + } +} diff --git a/crates/pop3/src/op/fetch.rs b/crates/pop3/src/op/fetch.rs new file mode 100644 index 0000000..fedc41a --- /dev/null +++ b/crates/pop3/src/op/fetch.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Session, protocol::response::Response}; +use common::network::SessionStream; +use email::message::metadata::MessageMetadata; +use registry::schema::enums::Permission; +use std::time::Instant; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{collection::Collection, field::EmailField}; +use utils::chained_bytes::ChainedBytes; + +impl Session { + pub async fn handle_fetch(&mut self, msg: u32, lines: Option) -> trc::Result<()> { + // Validate access + self.state + .access_token() + .enforce_permission(Permission::Pop3Retr)?; + + let op_start = Instant::now(); + let mailbox = self.state.mailbox(); + if let Some(message) = mailbox.messages.get(msg.saturating_sub(1) as usize) { + if let Some(metadata_) = self + .server + .store() + .get_value::>(ValueKey::property( + mailbox.account_id, + Collection::Email, + message.id, + EmailField::Metadata, + )) + .await + .caused_by(trc::location!())? + { + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + if let Some(bytes) = self + .server + .blob_store() + .get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + { + trc::event!( + Pop3(trc::Pop3Event::Fetch), + SpanId = self.session_id, + DocumentId = message.id, + Elapsed = op_start.elapsed() + ); + + let bytes = ChainedBytes::new(metadata.raw_headers.as_ref()) + .with_last( + bytes + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ) + .get_full_range(); + + self.write_bytes( + Response::Message:: { + bytes, + lines: lines.unwrap_or(0), + } + .serialize(), + ) + .await + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Failed to fetch message. Perhaps another session deleted it?") + .caused_by(trc::location!())) + } + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("Failed to fetch message. Perhaps another session deleted it?") + .caused_by(trc::location!())) + } + } else { + Err(trc::Pop3Event::Error.into_err().details("No such message.")) + } + } +} diff --git a/crates/pop3/src/op/list.rs b/crates/pop3/src/op/list.rs new file mode 100644 index 0000000..0723217 --- /dev/null +++ b/crates/pop3/src/op/list.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::network::SessionStream; +use registry::schema::enums::Permission; + +use crate::{Session, protocol::response::Response}; + +impl Session { + pub async fn handle_list(&mut self, msg: Option) -> trc::Result<()> { + // Validate access + self.state + .access_token() + .enforce_permission(Permission::Pop3List)?; + + let op_start = Instant::now(); + let mailbox = self.state.mailbox(); + if let Some(msg) = msg { + if let Some(message) = mailbox.messages.get(msg.saturating_sub(1) as usize) { + trc::event!( + Pop3(trc::Pop3Event::ListMessage), + SpanId = self.session_id, + DocumentId = message.id, + Size = message.size, + Elapsed = op_start.elapsed() + ); + + self.write_ok(format!("{} {}", msg, message.size)).await + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("No such message.") + .caused_by(trc::location!())) + } + } else { + trc::event!( + Pop3(trc::Pop3Event::List), + SpanId = self.session_id, + Total = mailbox.messages.len(), + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + Response::List(mailbox.messages.iter().map(|m| m.size).collect::>()) + .serialize(), + ) + .await + } + } + + pub async fn handle_uidl(&mut self, msg: Option) -> trc::Result<()> { + // Validate access + self.state + .access_token() + .enforce_permission(Permission::Pop3Uidl)?; + + let op_start = Instant::now(); + let mailbox = self.state.mailbox(); + if let Some(msg) = msg { + if let Some(message) = mailbox.messages.get(msg.saturating_sub(1) as usize) { + trc::event!( + Pop3(trc::Pop3Event::UidlMessage), + SpanId = self.session_id, + DocumentId = message.id, + Uid = message.uid, + UidValidity = mailbox.uid_validity, + Elapsed = op_start.elapsed() + ); + + self.write_ok(format!("{} {}{}", msg, mailbox.uid_validity, message.uid)) + .await + } else { + Err(trc::Pop3Event::Error + .into_err() + .details("No such message.") + .caused_by(trc::location!())) + } + } else { + trc::event!( + Pop3(trc::Pop3Event::Uidl), + SpanId = self.session_id, + Total = mailbox.messages.len(), + Elapsed = op_start.elapsed() + ); + + self.write_bytes( + Response::List( + mailbox + .messages + .iter() + .map(|m| format!("{}{}", mailbox.uid_validity, m.uid)) + .collect::>(), + ) + .serialize(), + ) + .await + } + } + + pub async fn handle_stat(&mut self) -> trc::Result<()> { + // Validate access + self.state + .access_token() + .enforce_permission(Permission::Pop3Stat)?; + + let op_start = Instant::now(); + let mailbox = self.state.mailbox(); + + trc::event!( + Pop3(trc::Pop3Event::Stat), + SpanId = self.session_id, + Total = mailbox.total, + Size = mailbox.size, + Elapsed = op_start.elapsed() + ); + + self.write_ok(format!("{} {}", mailbox.total, mailbox.size)) + .await + } +} diff --git a/crates/pop3/src/op/mod.rs b/crates/pop3/src/op/mod.rs new file mode 100644 index 0000000..6086f16 --- /dev/null +++ b/crates/pop3/src/op/mod.rs @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::network::SessionStream; + +use crate::{ + Session, + protocol::{Mechanism, response::Response}, +}; + +pub mod authenticate; +pub mod delete; +pub mod fetch; +pub mod list; + +impl Session { + pub async fn handle_capa(&mut self) -> trc::Result<()> { + let mechanisms = if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { + vec![Mechanism::Plain, Mechanism::OAuthBearer, Mechanism::XOauth2] + } else { + vec![Mechanism::OAuthBearer, Mechanism::XOauth2] + }; + + trc::event!( + Pop3(trc::Pop3Event::Capabilities), + SpanId = self.session_id, + Tls = self.stream.is_tls(), + Strict = !self.server.core.imap.allow_plain_auth, + Elapsed = trc::Value::Duration(0) + ); + + self.write_bytes( + Response::Capability:: { + mechanisms, + stls: !self.stream.is_tls(), + } + .serialize(), + ) + .await + } + + pub async fn handle_stls(&mut self) -> trc::Result<()> { + trc::event!( + Pop3(trc::Pop3Event::StartTls), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + self.write_ok("Begin TLS negotiation now").await + } + + pub async fn handle_utf8(&mut self) -> trc::Result<()> { + trc::event!( + Pop3(trc::Pop3Event::Utf8), + SpanId = self.session_id, + Elapsed = trc::Value::Duration(0) + ); + + self.write_ok("UTF8 enabled").await + } +} diff --git a/crates/pop3/src/protocol/mod.rs b/crates/pop3/src/protocol/mod.rs new file mode 100644 index 0000000..07d207c --- /dev/null +++ b/crates/pop3/src/protocol/mod.rs @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod request; +pub mod response; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Command { + // Authorization state + User { + name: T, + }, + Pass { + string: T, + }, + Apop { + name: T, + digest: T, + }, + Quit, + + // Transaction state + Stat, + List { + msg: Option, + }, + Retr { + msg: u32, + }, + Dele { + msg: u32, + }, + DeleMany { + msgs: Vec, + }, + #[default] + Noop, + Rset, + Top { + msg: u32, + n: u32, + }, + Uidl { + msg: Option, + }, + + // Extensions + Capa, + Stls, + Utf8, + Auth { + mechanism: M, + params: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mechanism { + Plain, + CramMd5, + DigestMd5, + ScramSha1, + ScramSha256, + Apop, + Ntlm, + Gssapi, + Anonymous, + External, + OAuthBearer, + XOauth2, +} diff --git a/crates/pop3/src/protocol/request.rs b/crates/pop3/src/protocol/request.rs new file mode 100644 index 0000000..bc440fa --- /dev/null +++ b/crates/pop3/src/protocol/request.rs @@ -0,0 +1,455 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use super::{Command, Mechanism}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + NeedsMoreData, + Parse(Cow<'static, str>), +} + +#[derive(Default, Debug)] +pub enum State { + #[default] + Init, + Command { + buf: [u8; 4], + len: usize, + }, + Argument { + request: Command, Vec>, + num: usize, + last_is_space: bool, + }, + Error { + reason: Cow<'static, str>, + }, +} + +#[derive(Default)] +pub struct Parser { + pub state: State, +} + +const MAX_ARG_LEN: usize = 256; + +impl Parser { + pub fn parse( + &mut self, + bytes: &mut std::slice::Iter<'_, u8>, + ) -> Result, Error> { + for &byte in bytes { + match &mut self.state { + State::Init => match byte { + b' ' | b'\t' | b'\r' | b'\n' => {} + b'a'..=b'z' => { + self.state = State::Command { + buf: [byte, 0, 0, 0], + len: 1, + }; + } + b'A'..=b'Z' => { + self.state = State::Command { + buf: [byte | 0x20, 0, 0, 0], + len: 1, + }; + } + _ => { + self.state = State::Error { + reason: "Invalid command".into(), + }; + } + }, + State::Command { buf, len } => match byte { + b'a'..=b'z' | b'8' if *len < 4 => { + buf[*len] = byte; + *len += 1; + } + b'A'..=b'Z' if *len < 4 => { + buf[*len] = byte | 0x20; + *len += 1; + } + b' ' | b'\t' if *len == 4 || *len == 3 => match Command::parse(buf) { + Ok(request) => { + self.state = State::Argument { + request, + num: 0, + last_is_space: true, + }; + } + Err(err) => { + self.state = State::Error { reason: err }; + } + }, + b'\r' => {} + b'\n' if *len == 4 || *len == 3 => match Command::parse(buf) { + Ok(request) => { + self.state = State::Init; + return request.finalize(0); + } + Err(err) => { + self.state = State::Init; + return Err(Error::Parse(err)); + } + }, + _ => { + self.state = State::Error { + reason: "Invalid command".into(), + }; + } + }, + State::Argument { + request, + num, + last_is_space, + } => match byte { + b' ' | b'\t' => { + *last_is_space = true; + } + b'\r' => {} + b'\n' => { + let request = std::mem::take(request).finalize(*num); + self.state = State::Init; + return request; + } + _ => { + if *last_is_space { + *num += 1; + } + + match request.update_argument(*num, byte) { + Ok(_) => { + *last_is_space = false; + } + Err(err) => { + self.state = State::Error { reason: err }; + } + } + } + }, + State::Error { reason } => { + if byte == b'\n' { + let reason = std::mem::take(reason); + self.state = State::Init; + return Err(Error::Parse(reason)); + } + } + } + } + + Err(Error::NeedsMoreData) + } +} + +impl Command, Vec> { + pub fn parse(bytes: &[u8; 4]) -> Result> { + match (bytes[0], bytes[1], bytes[2], bytes[3]) { + (b'u', b's', b'e', b'r') => Ok(Self::User { name: Vec::new() }), + (b'u', b'i', b'd', b'l') => Ok(Self::Uidl { msg: None }), + (b'u', b't', b'f', b'8') => Ok(Self::Utf8), + (b'p', b'a', b's', b's') => Ok(Self::Pass { string: Vec::new() }), + (b'a', b'p', b'o', b'p') => Ok(Self::Apop { + name: Vec::new(), + digest: Vec::new(), + }), + (b'a', b'u', b't', b'h') => Ok(Self::Auth { + mechanism: Vec::new(), + params: Vec::new(), + }), + (b'q', b'u', b'i', b't') => Ok(Self::Quit), + (b'l', b'i', b's', b't') => Ok(Self::List { msg: None }), + (b'r', b'e', b't', b'r') => Ok(Self::Retr { msg: 0 }), + (b'r', b's', b'e', b't') => Ok(Self::Rset), + (b'd', b'e', b'l', b'e') => Ok(Self::Dele { msg: 0 }), + (b'n', b'o', b'o', b'p') => Ok(Self::Noop), + (b't', b'o', b'p', 0) => Ok(Self::Top { msg: 0, n: 0 }), + (b'c', b'a', b'p', b'a') => Ok(Self::Capa), + (b's', b't', b'l', b's') => Ok(Self::Stls), + (b's', b't', b'a', b't') => Ok(Self::Stat), + _ => Err("Invalid command".into()), + } + } + + pub fn update_argument(&mut self, arg_num: usize, byte: u8) -> Result<(), Cow<'static, str>> { + match self { + Command::User { name } if arg_num == 1 && name.len() < MAX_ARG_LEN => { + name.push(byte); + Ok(()) + } + Command::Pass { string } if arg_num == 1 && string.len() < MAX_ARG_LEN => { + string.push(byte); + Ok(()) + } + Command::Apop { name, digest } + if arg_num <= 2 && name.len() < MAX_ARG_LEN && digest.len() < MAX_ARG_LEN => + { + if arg_num == 1 { + name.push(byte); + } else { + digest.push(byte); + } + Ok(()) + } + Command::List { msg } if arg_num == 1 => add_digit(msg.get_or_insert(0), byte), + Command::Retr { msg } if arg_num == 1 => add_digit(msg, byte), + Command::Dele { msg } if arg_num == 1 => add_digit(msg, byte), + Command::Top { msg, n } if arg_num <= 2 => { + if arg_num == 1 { + add_digit(msg, byte) + } else { + add_digit(n, byte) + } + } + Command::Uidl { msg } if arg_num == 1 => add_digit(msg.get_or_insert(0), byte), + Command::Auth { mechanism, params } + if arg_num <= 4 + && mechanism.len() < 64 + && params.iter().map(|p| p.len()).sum::() < (MAX_ARG_LEN * 4) => + { + if arg_num == 1 { + mechanism.push(byte); + } else { + if params.len() < arg_num - 1 { + params.push(Vec::new()); + } + params.last_mut().unwrap().push(byte); + } + Ok(()) + } + _ => Err("Too many arguments".into()), + } + } + + pub fn finalize(self, num_args: usize) -> Result, Error> { + match self { + Command::User { name } if num_args == 1 => { + into_string(name).map(|name| Command::User { name }) + } + Command::Pass { string } if num_args == 1 => { + into_string(string).map(|string| Command::Pass { string }) + } + Command::Apop { name, digest } if num_args == 2 => { + let name = into_string(name)?; + let digest = into_string(digest)?; + Ok(Command::Apop { name, digest }) + } + Command::Quit => Ok(Command::Quit), + Command::Stat => Ok(Command::Stat), + Command::List { msg } => Ok(Command::List { msg }), + Command::Retr { msg } if num_args == 1 => Ok(Command::Retr { msg }), + Command::Dele { msg } if num_args == 1 => Ok(Command::Dele { msg }), + Command::Noop => Ok(Command::Noop), + Command::Rset => Ok(Command::Rset), + Command::Top { msg, n } if num_args == 2 => Ok(Command::Top { msg, n }), + Command::Uidl { msg } => Ok(Command::Uidl { msg }), + Command::Capa => Ok(Command::Capa), + Command::Stls => Ok(Command::Stls), + Command::Utf8 => Ok(Command::Utf8), + Command::Auth { mechanism, params } if num_args >= 1 => { + let mechanism = Mechanism::parse(&mechanism)?; + let params = params + .into_iter() + .map(into_string) + .collect::>()?; + + Ok(Command::Auth { mechanism, params }) + } + _ => Err(Error::Parse("Missing arguments".into())), + } + } +} + +#[inline(always)] +fn into_string(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| Error::Parse("Invalid UTF-8".into())) +} + +#[inline(always)] +fn add_digit(num: &mut u32, byte: u8) -> Result<(), Cow<'static, str>> { + if byte.is_ascii_digit() { + *num = num + .checked_mul(10) + .and_then(|n| n.checked_add((byte - b'0') as u32)) + .ok_or("Numeric argument out of range")?; + Ok(()) + } else { + Err("Invalid digit".into()) + } +} + +impl Mechanism { + pub fn parse(value: &[u8]) -> Result { + if value.eq_ignore_ascii_case(b"PLAIN") { + Ok(Self::Plain) + } else if value.eq_ignore_ascii_case(b"CRAM-MD5") { + Ok(Self::CramMd5) + } else if value.eq_ignore_ascii_case(b"DIGEST-MD5") { + Ok(Self::DigestMd5) + } else if value.eq_ignore_ascii_case(b"SCRAM-SHA-1") { + Ok(Self::ScramSha1) + } else if value.eq_ignore_ascii_case(b"SCRAM-SHA-256") { + Ok(Self::ScramSha256) + } else if value.eq_ignore_ascii_case(b"APOP") { + Ok(Self::Apop) + } else if value.eq_ignore_ascii_case(b"NTLM") { + Ok(Self::Ntlm) + } else if value.eq_ignore_ascii_case(b"GSSAPI") { + Ok(Self::Gssapi) + } else if value.eq_ignore_ascii_case(b"ANONYMOUS") { + Ok(Self::Anonymous) + } else if value.eq_ignore_ascii_case(b"EXTERNAL") { + Ok(Self::External) + } else if value.eq_ignore_ascii_case(b"OAUTHBEARER") { + Ok(Self::OAuthBearer) + } else if value.eq_ignore_ascii_case(b"XOAUTH2") { + Ok(Self::XOauth2) + } else { + Err(Error::Parse( + format!( + "Unsupported mechanism '{}'.", + String::from_utf8_lossy(value) + ) + .into(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use crate::protocol::{Command, Mechanism, request::Error}; + + use super::Parser; + + #[test] + fn parse_command() { + let mut parser = Parser::default(); + let mut chunked = String::new(); + let mut chunked_expected = Vec::new(); + + for (cmd, request) in [ + ("QuiT", Command::Quit), + (" \r\n NOOP ", Command::Noop), + ("STAT ", Command::Stat), + ("LIST ", Command::List { msg: None }), + (" list 100 ", Command::List { msg: 100.into() }), + ("retr 55", Command::Retr { msg: 55 }), + ("DELE 99", Command::Dele { msg: 99 }), + (" rset ", Command::Rset), + ("top 8000 1234", Command::Top { msg: 8000, n: 1234 }), + ("uidl", Command::Uidl { msg: None }), + ("uidl 000099999", Command::Uidl { msg: 99999.into() }), + ( + "USER test", + Command::User { + name: "test".to_string(), + }, + ), + ( + "PASS secret", + Command::Pass { + string: "secret".to_string(), + }, + ), + ( + "APOP mrose c4c9334bac560ecc979e58001b3e22fb", + Command::Apop { + name: "mrose".to_string(), + digest: "c4c9334bac560ecc979e58001b3e22fb".to_string(), + }, + ), + ("utf8", Command::Utf8), + ("capa", Command::Capa), + ( + "AUTH GSSAPI", + Command::Auth { + mechanism: Mechanism::Gssapi, + params: vec![], + }, + ), + ( + "AUTH PLAIN dGVzdAB0ZXN0AHRlc3Q=", + Command::Auth { + mechanism: Mechanism::Plain, + params: vec!["dGVzdAB0ZXN0AHRlc3Q=".to_string()], + }, + ), + ] { + assert_eq!( + parser.parse(&mut cmd.as_bytes().iter()), + Err(Error::NeedsMoreData) + ); + assert_eq!( + parser.parse(&mut b"\r\n".iter()), + Ok(request.clone()), + "{:?}", + cmd + ); + chunked.push_str(cmd); + chunked.push_str("\r\n"); + chunked_expected.push(request); + } + + for chunk_size in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512] { + let mut parser = Parser::default(); + let mut requests = Vec::new(); + + for chunk in chunked.as_bytes().chunks(chunk_size) { + let mut chunk = chunk.iter(); + loop { + match parser.parse(&mut chunk) { + Ok(request) => { + requests.push(request); + } + Err(Error::NeedsMoreData) => break, + Err(err) => { + panic!("Unexpected error on chunk size {chunk_size}: {err:?}"); + } + } + } + } + + assert_eq!(requests, chunked_expected, "Chunk size: {}", chunk_size); + } + + for cmd in [ + "user", + "pass", + "user a b", + "pass c d", + "apop", + "apop a", + "apop a b c", + "quit 1", + "stat 1", + "list 1 2", + "retr", + "retr 1 2", + "dele", + "dele 1 2", + "noop 1", + "rset 1", + "top", + "top 1 2 3", + "uidl 1 2 3", + "capa 1", + "stls 1", + "utf8 1", + "auth", + "auth unknown", + ] { + assert_eq!( + parser.parse(&mut cmd.as_bytes().iter()), + Err(Error::NeedsMoreData) + ); + let result = parser.parse(&mut b"\r\n".iter()); + assert!(result.is_err(), "{:?}", result); + } + } +} diff --git a/crates/pop3/src/protocol/response.rs b/crates/pop3/src/protocol/response.rs new file mode 100644 index 0000000..007594b --- /dev/null +++ b/crates/pop3/src/protocol/response.rs @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Mechanism; +use std::{borrow::Cow, fmt::Display}; +use utils::chained_bytes::SliceRange; + +pub enum Response<'x, T> { + Ok(Cow<'static, str>), + Err(Cow<'static, str>), + List(Vec), + Message { + bytes: SliceRange<'x>, + lines: u32, + }, + Capability { + mechanisms: Vec, + stls: bool, + }, +} + +impl<'x, T: Display> Response<'x, T> { + pub fn serialize(&self) -> Vec { + match self { + Response::Ok(message) => { + let mut buf = Vec::with_capacity(message.len() + 6); + buf.extend_from_slice(b"+OK "); + buf.extend_from_slice(message.as_bytes()); + buf.extend_from_slice(b"\r\n"); + buf + } + Response::Err(message) => { + let mut buf = Vec::with_capacity(message.len() + 6); + buf.extend_from_slice(b"-ERR "); + buf.extend_from_slice(message.as_bytes()); + buf.extend_from_slice(b"\r\n"); + buf + } + Response::List(octets) => { + let mut buf = Vec::with_capacity(octets.len() * 8 + 10); + buf.extend_from_slice(format!("+OK {} messages\r\n", octets.len()).as_bytes()); + for (num, octet) in octets.iter().enumerate() { + buf.extend_from_slice((num + 1).to_string().as_bytes()); + buf.extend_from_slice(b" "); + buf.extend_from_slice(octet.to_string().as_bytes()); + buf.extend_from_slice(b"\r\n"); + } + buf.extend_from_slice(b".\r\n"); + buf + } + Response::Message { bytes, lines } => { + let mut buf = Vec::with_capacity(bytes.len() + 10); + buf.extend_from_slice(b"+OK "); + buf.extend_from_slice(bytes.len().to_string().as_bytes()); + buf.extend_from_slice(b" octets\r\n"); + + let mut line_count = 0; + let mut last_byte = 0; + + // Transparency procedure + for &byte in bytes.into_iter() { + // POP3 requires that lines end with CRLF, do this check to ensure that + if byte == b'\n' && last_byte != b'\r' { + buf.push(b'\r'); + } + + if byte == b'.' && last_byte == b'\n' { + buf.push(b'.'); + } + buf.push(byte); + last_byte = byte; + + if *lines > 0 && byte == b'\n' { + line_count += 1; + if line_count == *lines { + break; + } + } + } + + if last_byte != b'\n' { + buf.extend_from_slice(b"\r\n"); + } + + buf.extend_from_slice(b".\r\n"); + buf + } + Response::Capability { mechanisms, stls } => { + let mut buf = Vec::with_capacity(256); + buf.extend_from_slice(b"+OK Capability list follows\r\n"); + if !mechanisms.is_empty() { + if mechanisms.contains(&Mechanism::Plain) { + buf.extend_from_slice(b"USER\r\n"); + } + buf.extend_from_slice(b"SASL"); + for mechanism in mechanisms { + buf.extend_from_slice(b" "); + buf.extend_from_slice(mechanism.as_str().as_bytes()); + } + buf.extend_from_slice(b"\r\n"); + } + + if *stls { + buf.extend_from_slice(b"STLS\r\n"); + } + + for capa in [ + "TOP", + "RESP-CODES", + "PIPELINING", + "EXPIRE NEVER", + "UIDL", + "UTF8", + "IMPLEMENTATION Stalwart Server", + ] { + buf.extend_from_slice(capa.as_bytes()); + buf.extend_from_slice(b"\r\n"); + } + + buf.extend_from_slice(b".\r\n"); + buf + } + } + } +} + +impl Mechanism { + pub fn as_str(&self) -> &'static str { + match self { + Mechanism::Plain => "PLAIN", + Mechanism::CramMd5 => "CRAM-MD5", + Mechanism::DigestMd5 => "DIGEST-MD5", + Mechanism::ScramSha1 => "SCRAM-SHA-1", + Mechanism::ScramSha256 => "SCRAM-SHA-256", + Mechanism::Apop => "APOP", + Mechanism::Ntlm => "NTLM", + Mechanism::Gssapi => "GSSAPI", + Mechanism::Anonymous => "ANONYMOUS", + Mechanism::External => "EXTERNAL", + Mechanism::OAuthBearer => "OAUTHBEARER", + Mechanism::XOauth2 => "XOAUTH2", + } + } +} + +pub trait SerializeResponse { + fn serialize(&self) -> Vec; +} + +impl SerializeResponse for trc::Error { + fn serialize(&self) -> Vec { + let message = self + .value_as_str(trc::Key::Details) + .unwrap_or_else(|| self.as_ref().message()); + let mut buf = Vec::with_capacity(message.len() + 6); + buf.extend_from_slice(b"-ERR "); + buf.extend_from_slice(message.as_bytes()); + buf.extend_from_slice(b"\r\n"); + buf + } +} + +#[cfg(test)] +mod tests { + use super::Response; + use crate::protocol::Mechanism; + use utils::chained_bytes::SliceRange; + + #[test] + fn serialize_response() { + for (cmd, expected) in [ + ( + Response::Ok("message 1 deleted".into()), + "+OK message 1 deleted\r\n", + ), + ( + Response::Err("permission denied".into()), + "-ERR permission denied\r\n", + ), + ( + Response::List(vec![100, 200, 300]), + "+OK 3 messages\r\n1 100\r\n2 200\r\n3 300\r\n.\r\n", + ), + ( + Response::Capability { + mechanisms: vec![Mechanism::Plain, Mechanism::CramMd5], + stls: true, + }, + concat!( + "+OK Capability list follows\r\n", + "USER\r\n", + "SASL PLAIN CRAM-MD5\r\n", + "STLS\r\n", + "TOP\r\n", + "RESP-CODES\r\n", + "PIPELINING\r\n", + "EXPIRE NEVER\r\n", + "UIDL\r\n", + "UTF8\r\n", + "IMPLEMENTATION Stalwart Server\r\n.\r\n" + ), + ), + ( + Response::Message { + bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"), + lines: 0, + }, + "+OK 35 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n", + ), + ] { + assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap()); + } + } +} diff --git a/crates/pop3/src/session.rs b/crates/pop3/src/session.rs new file mode 100644 index 0000000..63c8eb0 --- /dev/null +++ b/crates/pop3/src/session.rs @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Pop3SessionManager, SERVER_GREETING, Session, State, + protocol::{ + request::Parser, + response::{Response, SerializeResponse}, + }, +}; +use common::{ + BuildServer, + network::{SessionData, SessionManager, SessionResult, SessionStream}, +}; +use std::borrow::Cow; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::server::TlsStream; + +impl SessionManager for Pop3SessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let mut session = Session { + server: self.inner.build_server(), + instance: session.instance, + receiver: Parser::default(), + state: State::NotAuthenticated { + auth_failures: 0, + username: None, + }, + stream: session.stream, + in_flight: session.in_flight, + remote_addr: session.remote_ip, + session_id: session.session_id, + }; + + if session + .write_bytes(SERVER_GREETING.as_bytes()) + .await + .is_ok() + && session.handle_conn().await + && session.instance.acceptor.is_tls() + && let Ok(mut session) = session.into_tls().await + { + session.handle_conn().await; + } + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} + +impl Session { + pub async fn handle_conn(&mut self) -> bool { + let mut buf = vec![0; 8192]; + let mut shutdown_rx = self.instance.shutdown_rx.clone(); + + loop { + tokio::select! { + result = tokio::time::timeout( + if !matches!(self.state, State::NotAuthenticated {..}) { + self.server.core.imap.timeout_auth + } else { + self.server.core.imap.timeout_unauth + }, + self.stream.read(&mut buf)) => { + match result { + Ok(Ok(bytes_read)) => { + if bytes_read > 0 { + match self.ingest(&buf[..bytes_read]).await { + SessionResult::Continue => (), + SessionResult::UpgradeTls => { + return true; + } + SessionResult::Close => { + break; + } + } + } else { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + break; + } + }, + Ok(Err(err)) => { + trc::event!( + Network(trc::NetworkEvent::ReadError), + SpanId = self.session_id, + Reason = err.to_string() , + CausedBy = trc::location!() + ); + break; + }, + Err(_) => { + trc::event!( + Network(trc::NetworkEvent::Timeout), + SpanId = self.session_id, + CausedBy = trc::location!() + ); + + self.write_bytes(&b"-ERR Connection timed out.\r\n"[..]).await.ok(); + break; + } + } + }, + _ = shutdown_rx.changed() => { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.session_id, + Reason = "Server shutting down", + CausedBy = trc::location!() + ); + + self.write_bytes(&b"* BYE Server shutting down.\r\n"[..]).await.ok(); + break; + } + }; + } + + false + } + + pub async fn into_tls(self) -> Result>, ()> { + Ok(Session { + stream: self + .instance + .tls_accept(self.stream, self.session_id) + .await?, + server: self.server, + instance: self.instance, + receiver: Parser::default(), + state: self.state, + session_id: self.session_id, + in_flight: self.in_flight, + remote_addr: self.remote_addr, + }) + } +} + +impl Session { + pub async fn write_bytes(&mut self, bytes: impl AsRef<[u8]>) -> trc::Result<()> { + let bytes = bytes.as_ref(); + + trc::event!( + Pop3(trc::Pop3Event::RawOutput), + SpanId = self.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + self.stream.write_all(bytes.as_ref()).await.map_err(|err| { + trc::NetworkEvent::WriteError + .into_err() + .reason(err) + .caused_by(trc::location!()) + })?; + self.stream.flush().await.map_err(|err| { + trc::NetworkEvent::WriteError + .into_err() + .reason(err) + .caused_by(trc::location!()) + }) + } + + pub async fn write_ok(&mut self, message: impl Into>) -> trc::Result<()> { + self.write_bytes(Response::Ok::(message.into()).serialize()) + .await + } + + pub async fn write_err(&mut self, err: trc::Error) -> bool { + let disconnect = err.must_disconnect(); + let response = err.serialize(); + let write_err = err.should_write_err(); + + trc::error!(err.span_id(self.session_id)); + + if write_err && let Err(err) = self.write_bytes(response).await { + trc::error!(err.span_id(self.session_id)); + return false; + } + + !disconnect + } +} diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml new file mode 100644 index 0000000..ef5c7dc --- /dev/null +++ b/crates/registry/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "registry" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +trc = { path = "../trc" } +types = { path = "../types" } +serde = { version = "1.0", features = ["derive"]} +hashify = "0.2.9" +ahash = { version = "0.8" } +jmap-tools = { version = "0.1" } +mail-auth = { version = "0.13" } +tokio = { version = "1.53", features = ["fs"] } +lz4_flex = { version = "0.14", features = ["alloc"], default-features = false } + +[features] +test_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/registry/src/jmap/mod.rs b/crates/registry/src/jmap/mod.rs new file mode 100644 index 0000000..a1d31cb --- /dev/null +++ b/crates/registry/src/jmap/mod.rs @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + schema::prelude::Property, + types::{error::PatchError, string::StringValidator}, +}; +use jmap_tools::{JsonPointer, Value}; +use std::fmt::Debug; +use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; + +pub mod patch; +pub mod properties; +pub mod ser; + +pub type JmapValue<'x> = Value<'x, Property, RegistryValue>; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RegistryValue { + Id(Id), + BlobId(BlobId), + IdReference(String), +} + +pub type PatchResult<'x> = Result, PatchError>; + +pub enum MaybeUnpatched<'x> { + Unpatched { + property: Property, + value: JmapValue<'x>, + }, + UnpatchedMany { + properties: VecMap>, + }, + Patched, +} + +#[derive(Clone)] +pub struct JsonPointerPatch<'x> { + ptr: &'x JsonPointer, + pos: usize, + validators: &'x [StringValidator], + is_create: bool, + can_set_tenant: bool, + can_set_account: bool, +} + +pub trait RegistryJsonPatch: Debug + Default { + fn patch<'x>(&mut self, pointer: JsonPointerPatch<'_>, value: JmapValue<'x>) + -> PatchResult<'x>; +} +pub trait RegistryJsonPropertyPatch: Debug + Default { + fn patch_property<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x>; +} + +pub trait RegistryJsonEnumPatch: Debug { + fn patch<'x>(&mut self, pointer: JsonPointerPatch<'_>, value: JmapValue<'x>) + -> PatchResult<'x>; +} + +pub trait IntoValue { + fn into_value(self) -> JmapValue<'static>; +} diff --git a/crates/registry/src/jmap/patch.rs b/crates/registry/src/jmap/patch.rs new file mode 100644 index 0000000..b3c9239 --- /dev/null +++ b/crates/registry/src/jmap/patch.rs @@ -0,0 +1,435 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonEnumPatch, + RegistryJsonPatch, RegistryJsonPropertyPatch, RegistryValue, + }, + schema::prelude::Property, + types::{ + EnumImpl, + error::PatchError, + map::MapItem, + string::{StringValidator, StringValidatorResult}, + }, +}; +use jmap_tools::{JsonPointer, JsonPointerItem, Key, Value}; +use utils::map::vec_map::VecMap; + +impl<'x> JsonPointerPatch<'x> { + pub fn new(ptr: &'x JsonPointer) -> Self { + Self { + ptr, + pos: 0, + validators: &[], + is_create: false, + can_set_tenant: false, + can_set_account: false, + } + } + + pub fn cloned_with_ptr(&self, ptr: &'x JsonPointer) -> Self { + Self { + ptr, + pos: 0, + validators: &[], + is_create: self.is_create, + can_set_tenant: self.can_set_tenant, + can_set_account: self.can_set_account, + } + } + + pub fn cloned(&self) -> Self { + Self { + ptr: self.ptr, + pos: 0, + validators: &[], + is_create: self.is_create, + can_set_tenant: self.can_set_tenant, + can_set_account: self.can_set_account, + } + } + + pub fn with_create(mut self, is_create: bool) -> Self { + self.is_create = is_create; + self + } + + pub fn with_can_set_tenant(mut self, can_set_tenant: bool) -> Self { + self.can_set_tenant = can_set_tenant; + self + } + + pub fn with_can_set_account(mut self, can_set_account: bool) -> Self { + self.can_set_account = can_set_account; + self + } + + pub fn with_validators(mut self, validators: &'x [StringValidator]) -> Self { + self.validators = validators; + self + } + + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option<&JsonPointerItem> { + self.ptr.as_slice().get(self.pos).inspect(|_| self.pos += 1) + } + + pub fn next_property(&mut self) -> Option { + self.next().and_then(|item| { + if let JsonPointerItem::Key(Key::Property(prop)) = item { + Some(*prop) + } else { + None + } + }) + } + + pub fn peek(&self) -> Option<&JsonPointerItem> { + self.ptr.as_slice().get(self.pos) + } + + pub fn path(&self) -> String { + self.ptr.to_string() + } + + pub fn has_next(&self) -> bool { + self.ptr.as_slice().len() > self.pos + } + + pub fn assert_eof(&self) -> PatchResult<'static> { + if self.has_next() { + Err(PatchError::new(self.cloned(), "Invalid JSON Pointer path")) + } else { + Ok(MaybeUnpatched::Patched) + } + } + + pub fn assert_read_only(self) -> Result { + if self.is_create { + Ok(self) + } else { + Err(PatchError::new( + self.cloned(), + "Cannot modify read-only property", + )) + } + } + + pub fn assert_server_set(self) -> PatchResult<'static> { + Err(PatchError::new( + self.cloned(), + "Cannot modify server set property", + )) + } + + pub fn assert_can_set_tenant(self) -> Result { + if self.can_set_tenant { + Ok(self) + } else { + Err(PatchError::new( + self.cloned(), + "Cannot modify memberTenantId property", + )) + } + } + + pub fn assert_can_set_account(self) -> Result { + if self.can_set_account { + Ok(self) + } else { + Err(PatchError::new( + self.cloned(), + "Cannot modify accountId property", + )) + } + } +} + +impl RegistryJsonPatch for Option { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Value::Null = value { + *self = None; + pointer.assert_eof() + } else if let Some(inner) = self { + inner.patch(pointer, value) + } else { + let mut inner = T::default(); + inner.patch(pointer, value).inspect(|_| *self = Some(inner)) + } + } +} + +impl RegistryJsonEnumPatch for Option { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Value::Null = value { + *self = None; + pointer.assert_eof() + } else if let Some(inner) = self { + inner.patch(pointer, value) + } else { + let mut inner = T::default(); + inner.patch(pointer, value).inspect(|_| *self = Some(inner)) + } + } +} + +impl RegistryJsonPatch for String { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(value) = value.into_string().filter(|v| !v.is_empty()) { + let mut value = value.into_owned(); + + for validator in pointer.validators { + match validator.validate(&value) { + StringValidatorResult::Valid => {} + StringValidatorResult::Replace(new_value) => value = new_value, + StringValidatorResult::Invalid(err) => { + return Err(PatchError::new(pointer, err)); + } + } + } + + *self = value; + pointer.assert_eof() + } else { + Err(PatchError::new(pointer, "Invalid value for property.")) + } + } +} + +impl RegistryJsonPatch for bool { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_bool() { + *self = new_value; + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + "Invalid value for boolean property (expected true or false)", + )) + } + } +} + +impl RegistryJsonPatch for u64 { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_u64() { + *self = new_value; + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + "Invalid value for unsigned integer property (expected non-negative integer)", + )) + } + } +} + +impl RegistryJsonPatch for i64 { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_i64() { + *self = new_value; + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + "Invalid value for signed integer property (expected integer)", + )) + } + } +} + +impl RegistryJsonPatch for trc::Key { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: super::JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_str().and_then(|v| trc::Key::parse(v.as_ref())) { + *self = new_value; + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + format!("Invalid value {:?} for enum type {:?}.", value, self), + )) + } + } +} + +impl RegistryJsonEnumPatch for T { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_str().and_then(|v| T::parse(v.as_ref())) { + *self = new_value; + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + format!("Invalid value {:?} for enum type {:?}.", value, self), + )) + } + } +} + +impl RegistryJsonPatch for VecMap { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (pointer.next(), value) { + (Some(JsonPointerItem::Number(idx)), value) => { + if let Some(key) = K::try_from_integer(*idx) { + return if matches!(value, Value::Null) && !pointer.has_next() { + self.remove(&key); + Ok(MaybeUnpatched::Patched) + } else { + self.get_mut_or_insert(key).patch(pointer, value) + }; + } + } + (Some(JsonPointerItem::Key(key)), value) => { + if let Some(key) = K::try_from_string(key.to_string().as_ref()) { + return if matches!(value, Value::Null) && !pointer.has_next() { + self.remove(&key); + Ok(MaybeUnpatched::Patched) + } else { + self.get_mut_or_insert(key).patch(pointer, value) + }; + } + } + (None, Value::Object(items)) => { + self.clear(); + for (key, value) in items.into_vec() { + if let Some(key) = K::try_from_string(key.to_string().as_ref()) { + let mut inner = V::default(); + inner.patch(pointer.clone(), value)?; + self.set(key, inner); + } else { + return Err(PatchError::new( + pointer.clone(), + "Invalid key for object property", + )); + } + } + return Ok(MaybeUnpatched::Patched); + } + _ => {} + } + + Err(PatchError::new( + pointer, + "Invalid value for object property", + )) + } +} + +impl RegistryJsonPatch for T { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if pointer.has_next() { + self.patch_property(pointer, value) + } else if let Some(object) = value.into_object() { + let mut ptr = JsonPointer::new(vec![JsonPointerItem::Root]); + let mut unpatched = VecMap::new(); + for (key, value) in object.into_vec() { + if let Some(property) = key.as_property() { + if *property != Property::Type { + ptr.as_mut_slice()[0] = JsonPointerItem::Key(Key::Property(*property)); + match self.patch_property(pointer.cloned_with_ptr(&ptr), value) { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + unpatched.extend(properties); + } + Err(mut e) => { + if !e.path.is_empty() { + if !pointer.ptr.as_slice().is_empty() { + e.path = format!("{}/{}", pointer.path(), e.path); + } + } else { + e.path = JsonPointer::new( + pointer + .ptr + .as_slice() + .iter() + .cloned() + .chain([JsonPointerItem::Key(Key::Property(*property))]) + .collect(), + ) + .to_string(); + } + return Err(e); + } + } + } + } else { + return Err(PatchError::new(pointer.clone(), "Invalid key for object")); + } + } + if unpatched.is_empty() { + Ok(MaybeUnpatched::Patched) + } else { + Ok(MaybeUnpatched::UnpatchedMany { + properties: unpatched, + }) + } + } else { + Err(PatchError::new(pointer, "Invalid value type for object")) + } + } +} + +pub fn object_type( + pointer: &JsonPointerPatch<'_>, + value: &Value<'_, Property, RegistryValue>, +) -> Result { + value + .as_object() + .and_then(|obj| obj.get(&jmap_tools::Key::Property(Property::Type))) + .and_then(|v| v.as_str()) + .and_then(|v| T::parse(v.as_ref())) + .ok_or_else(|| { + PatchError::new( + pointer.clone(), + "Missing or invalid '@type' property in object", + ) + }) +} diff --git a/crates/registry/src/jmap/properties.rs b/crates/registry/src/jmap/properties.rs new file mode 100644 index 0000000..df900a4 --- /dev/null +++ b/crates/registry/src/jmap/properties.rs @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{jmap::RegistryValue, schema::prelude::Property, types::EnumImpl}; +use jmap_tools::Key; +use std::{borrow::Cow, str::FromStr}; +use types::{blob::BlobId, id::Id}; + +impl jmap_tools::Property for Property { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + Property::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + self.as_str().into() + } +} + +impl FromStr for Property { + type Err = (); + + fn from_str(s: &str) -> Result { + Property::parse(s).ok_or(()) + } +} + +impl jmap_tools::Element for RegistryValue { + type Property = Property; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + if let Key::Property(prop) = key { + match prop { + Property::Id + | Property::MemberGroupIds + | Property::MemberTenantId + | Property::RoleIds + | Property::DnsServerId + | Property::DirectoryId + | Property::DomainId + | Property::AccountId + | Property::DefaultDomainId + | Property::DefaultCertificateId + | Property::DefaultUserRoleIds + | Property::DefaultGroupRoleIds + | Property::DefaultTenantRoleIds + | Property::DefaultAdminRoleIds + | Property::ListenerIds + | Property::PublicKey + | Property::QueueId + | Property::ModelId + | Property::AcmeProviderId => { + if let Some(reference) = value.strip_prefix('#') { + Some(RegistryValue::IdReference(reference.to_string())) + } else { + Id::from_str(value).map(RegistryValue::Id).ok() + } + } + Property::BlobId => { + if let Some(reference) = value.strip_prefix('#') { + Some(RegistryValue::IdReference(reference.to_string())) + } else { + BlobId::from_str(value).map(RegistryValue::BlobId).ok() + } + } + _ => None, + } + } else { + None + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + RegistryValue::Id(id) => id.to_string().into(), + RegistryValue::BlobId(blob_id) => blob_id.to_string().into(), + RegistryValue::IdReference(r) => format!("#{r}").into(), + } + } +} + +impl From for RegistryValue { + fn from(id: Id) -> Self { + RegistryValue::Id(id) + } +} + +impl From for RegistryValue { + fn from(id: BlobId) -> Self { + RegistryValue::BlobId(id) + } +} diff --git a/crates/registry/src/jmap/ser.rs b/crates/registry/src/jmap/ser.rs new file mode 100644 index 0000000..3cdb64d --- /dev/null +++ b/crates/registry/src/jmap/ser.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{IntoValue, JmapValue}, + schema::prelude::Property, + types::EnumImpl, +}; +use jmap_tools::Key; +use std::fmt::Debug; +use utils::map::vec_map::VecMap; + +impl IntoValue for Option { + fn into_value(self) -> JmapValue<'static> { + match self { + Some(value) => value.into_value(), + None => JmapValue::Null, + } + } +} + +impl IntoValue for String { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.into()) + } +} + +impl IntoValue for bool { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Bool(self) + } +} + +impl IntoValue for u64 { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Number(self.into()) + } +} + +impl IntoValue for i64 { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Number(self.into()) + } +} + +impl IntoValue for T { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.as_str().into()) + } +} + +trait MapKey: Sized + PartialEq + Eq + Debug { + fn to_key(self) -> Key<'static, Property>; +} + +impl MapKey for String { + fn to_key(self) -> Key<'static, Property> { + Key::Owned(self) + } +} + +impl MapKey for u32 { + fn to_key(self) -> Key<'static, Property> { + Key::Owned(self.to_string()) + } +} + +impl MapKey for T { + fn to_key(self) -> Key<'static, Property> { + Key::Borrowed(self.as_str()) + } +} + +impl IntoValue for VecMap { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(self.len()); + for (k, v) in self { + map.insert_unchecked(k.to_key(), v.into_value()); + } + JmapValue::Object(map) + } +} + +impl IntoValue for trc::Key { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.as_str().into()) + } +} diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs new file mode 100644 index 0000000..0772aef --- /dev/null +++ b/crates/registry/src/lib.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod jmap; +pub mod pickle; +pub mod schema; +pub mod types; +pub mod utils; diff --git a/crates/registry/src/pickle.rs b/crates/registry/src/pickle.rs new file mode 100644 index 0000000..dcfcc9f --- /dev/null +++ b/crates/registry/src/pickle.rs @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::types::EnumImpl; +use std::{borrow::Cow, collections::HashMap}; +use utils::{ + codec::leb128::{Leb128_, Leb128Reader, Leb128Writer}, + map::vec_map::VecMap, +}; + +const COMPRESS_MARKER: u8 = 1 << 7; +const COMPRESS_WATERMARK: usize = 8192; + +pub trait Pickle: Sized { + fn pickle(&self, out: &mut Vec); + fn unpickle(stream: &mut PickledStream<'_>) -> Option; +} + +pub struct PickledStream<'x> { + data: Cow<'x, [u8]>, + pos: usize, + version: u8, +} + +pub(crate) fn maybe_compress_pickle(input: Vec) -> Vec { + let input_len = input.len() - 1; // Exclude the version byte + if input_len > COMPRESS_WATERMARK { + let (version, input) = input.split_first().unwrap(); + let mut bytes: Vec = vec![ + version | COMPRESS_MARKER; + lz4_flex::block::get_maximum_output_size(input_len) + + 1 + + std::mem::size_of::() + ]; + + // Compress the data + let compressed_len = + lz4_flex::compress_into(input, &mut bytes[std::mem::size_of::() + 1..]).unwrap(); + if compressed_len < input_len { + // Prepend the length of the uncompressed data + bytes[1..(std::mem::size_of::() + 1)] + .copy_from_slice(&(input_len as u32).to_le_bytes()); + + // Truncate to the actual size + bytes.truncate(compressed_len + std::mem::size_of::() + 1); + return bytes; + } + } + input +} + +impl<'x> PickledStream<'x> { + pub fn new(data: &'x [u8]) -> Option { + let (marker, data) = data.split_first()?; + let version = marker & !COMPRESS_MARKER; + if marker & COMPRESS_MARKER != 0 { + lz4_flex::block::decompress_size_prepended(data) + .ok() + .map(|data| PickledStream { + data: Cow::Owned(data), + pos: 0, + version, + }) + } else { + PickledStream { + data: Cow::Borrowed(data), + pos: 0, + version, + } + .into() + } + } + + pub fn read(&mut self) -> Option { + self.data.get(self.pos).copied().inspect(|_| self.pos += 1) + } + + pub fn read_leb128(&mut self) -> Option { + self.data + .get(self.pos..) + .and_then(|bytes| bytes.read_leb128()) + .map(|(value, read_bytes)| { + self.pos += read_bytes; + value + }) + } + + #[inline(always)] + pub fn read_bytes(&mut self, len: usize) -> Option<&'_ [u8]> { + self.data.get(self.pos..self.pos + len).inspect(|_| { + self.pos += len; + }) + } + + #[inline(always)] + pub fn eof(&self) -> bool { + self.pos >= self.data.len() + } + + #[inline(always)] + pub fn bytes(&self) -> &'_ [u8] { + self.data.as_ref() + } + + #[inline(always)] + pub fn version(&self) -> u8 { + self.version + } +} + +impl Pickle for u16 { + fn pickle(&self, out: &mut Vec) { + let _ = out.write_leb128(*self); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + stream.read_leb128() + } +} + +impl Pickle for u64 { + fn pickle(&self, out: &mut Vec) { + let _ = out.write_leb128(*self); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + stream.read_leb128() + } +} + +impl Pickle for u32 { + fn pickle(&self, out: &mut Vec) { + let _ = out.write_leb128(*self); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + stream.read_leb128() + } +} + +impl Pickle for i64 { + fn pickle(&self, out: &mut Vec) { + let _ = out.write_leb128(*self as u64); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + stream.read_leb128::().map(|v| v as i64) + } +} + +impl Pickle for bool { + fn pickle(&self, out: &mut Vec) { + out.push(if *self { 1 } else { 0 }); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + match stream.read()? { + 0 => Some(false), + 1 => Some(true), + _ => None, + } + } +} + +impl Pickle for String { + fn pickle(&self, out: &mut Vec) { + (self.len() as u32).pickle(out); + out.extend_from_slice(self.as_bytes()); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + u32::unpickle(stream) + .and_then(|len| stream.read_bytes(len as usize)) + .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok()) + } +} + +impl Pickle for T { + fn pickle(&self, out: &mut Vec) { + self.to_id().pickle(out); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + u16::unpickle(stream).and_then(Self::from_id) + } +} + +impl Pickle for Option +where + T: Pickle, +{ + fn pickle(&self, out: &mut Vec) { + match self { + Some(value) => { + out.push(1); + value.pickle(out); + } + None => { + out.push(0); + } + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + match stream.read()? { + 0 => Some(None), + 1 => T::unpickle(stream).map(Some), + _ => None, + } + } +} + +impl Pickle for HashMap +where + K: Pickle + std::hash::Hash + Eq, + V: Pickle, + S: std::hash::BuildHasher + Default, +{ + fn pickle(&self, out: &mut Vec) { + (self.len() as u32).pickle(out); + for (key, value) in self { + key.pickle(out); + value.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut map = HashMap::with_capacity_and_hasher(len, S::default()); + for _ in 0..len { + let key = K::unpickle(stream)?; + let value = V::unpickle(stream)?; + map.insert(key, value); + } + Some(map) + } +} + +impl Pickle for VecMap +where + K: Pickle + std::hash::Hash + Eq, + V: Pickle, +{ + fn pickle(&self, out: &mut Vec) { + (self.len() as u32).pickle(out); + for (key, value) in self { + key.pickle(out); + value.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut map = VecMap::with_capacity(len); + for _ in 0..len { + let key = K::unpickle(stream)?; + let value = V::unpickle(stream)?; + map.append(key, value); + } + Some(map) + } +} + +impl Pickle for trc::Key { + fn pickle(&self, out: &mut Vec) { + self.to_id().pickle(out); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + u16::unpickle(stream).and_then(Self::from_id) + } +} diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs new file mode 100644 index 0000000..c6c5266 --- /dev/null +++ b/crates/registry/src/schema/enums.rs @@ -0,0 +1,3995 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AccountType { + #[default] + User = 0, + Group = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AcmeChallengeType { + #[default] + TlsAlpn01 = 0, + DnsPersist01 = 1, + Dns01 = 2, + Http01 = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AcmeRenewBefore { + #[default] + R12 = 0, + R23 = 1, + R34 = 2, + R45 = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ActionType { + #[default] + ReloadSettings = 0, + ReloadTlsCertificates = 1, + ReloadLookupStores = 2, + ReloadBlockedIps = 3, + UpdateApps = 4, + TroubleshootDmarc = 5, + ClassifySpam = 6, + InvalidateCaches = 7, + InvalidateNegativeCaches = 8, + PauseMtaQueue = 9, + ResumeMtaQueue = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AiModelType { + #[default] + Chat = 0, + Text = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AlertEmailType { + #[default] + Disabled = 0, + Enabled = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AlertEventType { + #[default] + Disabled = 0, + Enabled = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArchivedItemStatus { + #[default] + Archived = 0, + RequestRestore = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArchivedItemType { + #[default] + Email = 0, + FileNode = 1, + CalendarEvent = 2, + ContactCard = 3, + SieveScript = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArfAuthFailureType { + #[default] + Adsp = 0, + BodyHash = 1, + Revoked = 2, + Signature = 3, + Spf = 4, + Dmarc = 5, + Unspecified = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArfDeliveryResult { + #[default] + Delivered = 0, + Spam = 1, + Policy = 2, + Reject = 3, + Other = 4, + Unspecified = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArfFeedbackType { + #[default] + Abuse = 0, + AuthFailure = 1, + Fraud = 2, + NotSpam = 3, + Virus = 4, + Other = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArfIdentityAlignment { + #[default] + None = 0, + Spf = 1, + Dkim = 2, + DkimSpf = 3, + Unspecified = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AsnType { + #[default] + Disabled = 0, + Resource = 1, + Dns = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AzureEnvironment { + #[default] + Public = 0, + China = 1, + UsGovernment = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum BlobStoreBaseType { + #[default] + S3 = 0, + Azure = 1, + FileSystem = 2, + FoundationDb = 3, + PostgreSql = 4, + MySql = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum BlobStoreType { + #[default] + Default = 0, + Sharded = 1, + S3 = 2, + Azure = 3, + FileSystem = 4, + FoundationDb = 5, + PostgreSql = 6, + MySql = 7, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum BlockReason { + #[default] + RcptToFailure = 0, + AuthFailure = 1, + Loitering = 2, + PortScanning = 3, + Manual = 4, + Other = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CertificateManagementType { + #[default] + Manual = 0, + Automatic = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ClusterListenerGroupType { + #[default] + EnableAll = 0, + DisableAll = 1, + EnableSome = 2, + DisableSome = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ClusterNodeStatus { + #[default] + Active = 0, + Stale = 1, + Inactive = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ClusterTaskGroupType { + #[default] + EnableAll = 0, + DisableAll = 1, + EnableSome = 2, + DisableSome = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ClusterTaskType { + #[default] + StoreMaintenance = 0, + AccountMaintenance = 1, + MetricsCalculate = 2, + MetricsPush = 3, + PushNotifications = 4, + SearchIndexing = 5, + SpamClassifierTraining = 6, + OutboundMta = 7, + TaskQueueProcessing = 8, + TaskScheduler = 9, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CompressionAlgo { + #[default] + Lz4 = 0, + None = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CoordinatorType { + #[default] + Disabled = 0, + Default = 1, + Kafka = 2, + Nats = 3, + Zenoh = 4, + Redis = 5, + RedisCluster = 6, + RedisSentinel = 7, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CredentialPermissionsType { + #[default] + Inherit = 0, + Disable = 1, + Replace = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CredentialType { + #[default] + Password = 0, + AppPassword = 1, + ApiKey = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CronType { + #[default] + Daily = 0, + Weekly = 1, + Hourly = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DataStoreType { + #[default] + RocksDb = 0, + Sqlite = 1, + FoundationDb = 2, + PostgreSql = 3, + MySql = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DeliveryErrorType { + #[default] + DnsError = 0, + UnexpectedResponse = 1, + ConnectionError = 2, + TlsError = 3, + DaneError = 4, + MtaStsError = 5, + RateLimited = 6, + ConcurrencyLimited = 7, + Io = 8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DirectoryBootstrapType { + #[default] + Internal = 0, + Ldap = 1, + Sql = 2, + Oidc = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DirectoryType { + #[default] + Ldap = 0, + Sql = 1, + Oidc = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Dkim2Flag { + #[default] + Donotmodify = 0, + Donotexplode = 1, + Feedback = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimAuthResult { + #[default] + None = 0, + Pass = 1, + Fail = 2, + Policy = 3, + Neutral = 4, + TempError = 5, + PermError = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimCanonicalization { + #[default] + RelaxedRelaxed = 0, + SimpleSimple = 1, + RelaxedSimple = 2, + SimpleRelaxed = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimHash { + #[default] + Sha256 = 0, + Sha1 = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimManagementType { + #[default] + Automatic = 0, + Manual = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimRotationStage { + #[default] + Active = 0, + Pending = 1, + Retiring = 2, + Retired = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimSignatureType { + #[default] + Dkim1Ed25519Sha256 = 0, + Dkim1RsaSha256 = 1, + Dkim2Ed25519Sha256 = 2, + Dkim2RsaSha256 = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcActionDisposition { + #[default] + None = 0, + Pass = 1, + Quarantine = 2, + Reject = 3, + Unspecified = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcAlignment { + #[default] + Relaxed = 0, + Strict = 1, + Unspecified = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcDiscovery { + #[default] + Psl = 0, + Treewalk = 1, + Unspecified = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcDisposition { + #[default] + None = 0, + Quarantine = 1, + Reject = 2, + Unspecified = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcPolicyOverride { + #[default] + Forwarded = 0, + SampledOut = 1, + TrustedForwarder = 2, + MailingList = 3, + LocalPolicy = 4, + Other = 5, + PolicyTestMode = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcResult { + #[default] + Pass = 0, + Fail = 1, + Unspecified = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcTroubleshootAuthResultType { + #[default] + Pass = 0, + Fail = 1, + SoftFail = 2, + TempError = 3, + PermError = 4, + Neutral = 5, + None = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsManagementType { + #[default] + Manual = 0, + Automatic = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsPublishStatus { + #[default] + Synced = 0, + Pending = 1, + Failed = 2, + Unknown = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsRecordType { + #[default] + Dkim = 0, + Tlsa = 1, + Spf = 2, + Mx = 3, + Dmarc = 4, + Srv = 5, + MtaSts = 6, + TlsRpt = 7, + Caa = 8, + AutoConfig = 9, + AutoConfigLegacy = 10, + AutoDiscover = 11, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsResolverProtocol { + #[default] + Tls = 0, + Udp = 1, + Tcp = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsResolverType { + #[default] + System = 0, + Custom = 1, + Cloudflare = 2, + Quad9 = 3, + Google = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsServerBootstrapType { + #[default] + Manual = 0, + Tsig = 1, + Deprecated1 = 2, + Cloudflare = 3, + DigitalOcean = 4, + DeSEC = 5, + Ovh = 6, + Bunny = 7, + Porkbun = 8, + Dnsimple = 9, + Spaceship = 10, + Route53 = 11, + GoogleCloudDns = 12, + Alidns = 13, + ArvanCloud = 14, + Autodns = 15, + AzureDns = 16, + BaiduCloud = 17, + BluecatV2 = 18, + ClouDns = 19, + Constellix = 20, + Cpanel = 21, + Ddnss = 22, + DnsMadeEasy = 23, + Domeneshop = 24, + Dreamhost = 25, + DuckDns = 26, + Dynu = 27, + EasyDns = 28, + EdgeDns = 29, + Exoscale = 30, + FreeMyIp = 31, + GandiV5 = 32, + Gcore = 33, + Glesys = 34, + Godaddy = 35, + Hetzner = 36, + HostingDe = 37, + Hostinger = 38, + HuaweiCloud = 39, + Hurricane = 40, + IbmCloud = 41, + Infoblox = 42, + Infomaniak = 43, + Inwx = 44, + Ionos = 45, + Ipv64 = 46, + Joker = 47, + Lightsail = 48, + Linode = 49, + LuaDns = 50, + MythicBeasts = 51, + Namecheap = 52, + NameDotCom = 53, + NameSilo = 54, + Netcup = 55, + Netlify = 56, + Nifcloud = 57, + Ns1 = 58, + OracleCloud = 59, + Plesk = 60, + Safedns = 61, + Scaleway = 62, + TencentCloud = 63, + Transip = 64, + UltraDns = 65, + Vercel = 66, + Volcengine = 67, + Vultr = 68, + WebSupport = 69, + YandexCloud = 70, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsServerType { + #[default] + Tsig = 0, + Deprecated1 = 1, + Cloudflare = 2, + DigitalOcean = 3, + DeSEC = 4, + Ovh = 5, + Bunny = 6, + Porkbun = 7, + Dnsimple = 8, + Spaceship = 9, + Route53 = 10, + GoogleCloudDns = 11, + Alidns = 12, + ArvanCloud = 13, + Autodns = 14, + AzureDns = 15, + BaiduCloud = 16, + BluecatV2 = 17, + ClouDns = 18, + Constellix = 19, + Cpanel = 20, + Ddnss = 21, + DnsMadeEasy = 22, + Domeneshop = 23, + Dreamhost = 24, + DuckDns = 25, + Dynu = 26, + EasyDns = 27, + EdgeDns = 28, + Exoscale = 29, + FreeMyIp = 30, + GandiV5 = 31, + Gcore = 32, + Glesys = 33, + Godaddy = 34, + Hetzner = 35, + HostingDe = 36, + Hostinger = 37, + HuaweiCloud = 38, + Hurricane = 39, + IbmCloud = 40, + Infoblox = 41, + Infomaniak = 42, + Inwx = 43, + Ionos = 44, + Ipv64 = 45, + Joker = 46, + Lightsail = 47, + Linode = 48, + LuaDns = 49, + MythicBeasts = 50, + Namecheap = 51, + NameDotCom = 52, + NameSilo = 53, + Netcup = 54, + Netlify = 55, + Nifcloud = 56, + Ns1 = 57, + OracleCloud = 58, + Plesk = 59, + Safedns = 60, + Scaleway = 61, + TencentCloud = 62, + Transip = 63, + UltraDns = 64, + Vercel = 65, + Volcengine = 66, + Vultr = 67, + WebSupport = 68, + YandexCloud = 69, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum EncryptionAtRestType { + #[default] + Disabled = 0, + Aes128 = 1, + Aes256 = 2, + Aes256Gcm = 3, + ChaCha20Poly1305 = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum EventPolicy { + #[default] + Include = 0, + Exclude = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ExpressionConstant { + #[default] + Relaxed = 0, + Strict = 1, + Disable = 2, + Optional = 3, + Require = 4, + Ipv4Only = 5, + Ipv6Only = 6, + Ipv6ThenIpv4 = 7, + Ipv4ThenIpv6 = 8, + Hourly = 9, + Daily = 10, + Weekly = 11, + Login = 12, + Plain = 13, + Xoauth2 = 14, + Oauthbearer = 15, + Mixer = 16, + Stanag4406 = 17, + Nsep = 18, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ExpressionVariable { + #[default] + Asn = 0, + Attributes = 1, + AuthenticatedAs = 2, + Authority = 3, + Bcc = 4, + BccDomain = 5, + BccLocal = 6, + BccName = 7, + Body = 8, + BodyHtml = 9, + BodyRaw = 10, + BodyText = 11, + BodyWords = 12, + Cc = 13, + CcDomain = 14, + CcLocal = 15, + CcName = 16, + Country = 17, + Domain = 18, + Email = 19, + EmailLower = 20, + EnvFrom = 21, + EnvFromDomain = 22, + EnvFromLocal = 23, + EnvTo = 24, + ExpiresIn = 25, + From = 26, + FromDomain = 27, + FromLocal = 28, + FromName = 29, + Headers = 30, + HeloDomain = 31, + Host = 32, + Ip = 33, + IpReverse = 34, + IsTls = 35, + IsV4 = 36, + IsV6 = 37, + LastError = 38, + LastStatus = 39, + Listener = 40, + Local = 41, + LocalIp = 42, + LocalPort = 43, + Location = 44, + Method = 45, + Mx = 46, + Name = 47, + NameLower = 48, + NotifyNum = 49, + Octets = 50, + Path = 51, + PathQuery = 52, + Port = 53, + Priority = 54, + Protocol = 55, + Query = 56, + QueueAge = 57, + QueueName = 58, + Raw = 59, + RawLower = 60, + Rcpt = 61, + RcptDomain = 62, + ReceivedFromIp = 63, + ReceivedViaPort = 64, + Recipients = 65, + RemoteIp = 66, + RemoteIpPtr = 67, + RemotePort = 68, + ReplyTo = 69, + ReplyToDomain = 70, + ReplyToLocal = 71, + ReplyToName = 72, + RetryNum = 73, + ReverseIp = 74, + Scheme = 75, + Sender = 76, + SenderDomain = 77, + Size = 78, + Sld = 79, + Source = 80, + Subject = 81, + SubjectThread = 82, + SubjectWords = 83, + To = 84, + ToDomain = 85, + ToLocal = 86, + ToName = 87, + Url = 88, + UrlOriginal = 89, + Value = 90, + ValueLower = 91, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum FailureReportingOption { + #[default] + All = 0, + Any = 1, + DkimFailure = 2, + SpfFailure = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum HttpAuthType { + #[default] + Unauthenticated = 0, + Basic = 1, + Bearer = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum HttpLookupFormatType { + #[default] + Csv = 0, + List = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum InMemoryStoreBaseType { + #[default] + Redis = 0, + RedisCluster = 1, + RedisSentinel = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum InMemoryStoreType { + #[default] + Default = 0, + Sharded = 1, + Redis = 2, + RedisCluster = 3, + RedisSentinel = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum IndexDocumentType { + #[default] + Email = 0, + Calendar = 1, + Contacts = 2, + File = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum IpProtocol { + #[default] + Udp = 0, + Tcp = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum JokerAuthType { + #[default] + ApiKey = 0, + UsernamePassword = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum JwtSignatureAlgorithm { + #[default] + Es256 = 0, + Es384 = 1, + Ps256 = 2, + Ps384 = 3, + Ps512 = 4, + Rs256 = 5, + Rs384 = 6, + Rs512 = 7, + Hs256 = 8, + Hs384 = 9, + Hs512 = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Locale { + #[default] + EnUS = 0, + AaDJ = 1, + AaER = 2, + SsyER = 3, + AaET = 4, + AfZA = 5, + AgrPE = 6, + AkGH = 7, + AmET = 8, + AnES = 9, + AnpIN = 10, + ArAE = 11, + ArBH = 12, + ArDZ = 13, + ArEG = 14, + ArIN = 15, + ArIQ = 16, + ArJO = 17, + ArKW = 18, + ArLB = 19, + ArLY = 20, + ArMA = 21, + ArOM = 22, + ArQA = 23, + ArSA = 24, + ArSD = 25, + ArSS = 26, + ArSY = 27, + ArTN = 28, + ArYE = 29, + AsIN = 30, + AstES = 31, + AycPE = 32, + AzAZ = 33, + AzIR = 34, + BeBY = 35, + BeLatnBY = 36, + BemZM = 37, + BerDZ = 38, + BerMA = 39, + BgBG = 40, + BhbIN = 41, + BhoIN = 42, + BhoNP = 43, + BiVU = 44, + BnBD = 45, + BnIN = 46, + BoCN = 47, + BoIN = 48, + BrFR = 49, + Es419 = 50, + BrxIN = 51, + BsBA = 52, + BynER = 53, + CaAD = 54, + CaES = 55, + En150 = 56, + CaESValencia = 57, + CaFR = 58, + CaIT = 59, + CeRU = 60, + ChrUS = 61, + CmnTW = 62, + CrhUA = 63, + CsCZ = 64, + CsbPL = 65, + CvRU = 66, + CyGB = 67, + DaDK = 68, + DeAT = 69, + DeBE = 71, + En001 = 72, + DeCH = 73, + DeDE = 74, + DeIT = 76, + DeLI = 77, + DeLU = 78, + Ar001 = 79, + DoiIN = 80, + DsbDE = 81, + DvMV = 82, + DzBT = 83, + ElCY = 84, + ElGR = 85, + EnAG = 87, + EnAU = 88, + EnBW = 89, + EnCA = 90, + EnDK = 91, + EnGB = 92, + EnHK = 93, + EnIE = 94, + EnIL = 96, + EnIN = 97, + EnNG = 98, + EnNZ = 99, + EnPH = 100, + EnSC = 101, + EnSG = 102, + EnZA = 104, + EnZM = 105, + EnZW = 106, + Eo = 107, + EsAR = 108, + EsBO = 109, + EsCL = 110, + EsCO = 111, + EsCR = 112, + EsCU = 113, + EsDO = 114, + EsEC = 115, + EsES = 116, + EsGT = 118, + EsHN = 119, + EsMX = 120, + EsNI = 121, + EsPA = 122, + EsPE = 123, + EsPR = 124, + EsPY = 125, + EsSV = 126, + EsUS = 127, + EsUY = 128, + EsVE = 129, + EtEE = 130, + EuES = 131, + ZhHans = 132, + FaIR = 133, + FfSN = 134, + FiFI = 135, + FilPH = 137, + FoFO = 138, + FrBE = 139, + FrCA = 141, + FrCH = 142, + FrFR = 143, + FrLU = 145, + ZhHant = 146, + FurIT = 147, + FyDE = 148, + FyNL = 149, + GaIE = 150, + ZhHantHK = 151, + GdGB = 152, + GezER = 153, + SrLatnBA = 154, + GezET = 155, + SrLatnME = 156, + GlES = 157, + AzLatnAZ = 158, + GuIN = 159, + GvGB = 160, + HaNG = 161, + HakTW = 162, + HeIL = 163, + HiIN = 164, + HifFJ = 165, + HneIN = 166, + HrHR = 167, + HsbDE = 168, + HtHT = 169, + HuHU = 170, + HyAM = 171, + IaFR = 172, + IdID = 173, + IgNG = 174, + IkCA = 175, + IsIS = 176, + ItCH = 177, + ItIT = 178, + IuCA = 180, + JaJP = 181, + KaGE = 182, + KabDZ = 183, + KkKZ = 184, + KlGL = 185, + KmKH = 186, + KnIN = 187, + KoKR = 188, + KokIN = 189, + KsIN = 190, + KsDevaIN = 191, + KuTR = 192, + KwGB = 193, + KyKG = 194, + LbLU = 195, + LgUG = 196, + LiBE = 197, + LiNL = 198, + LijIT = 199, + LnCD = 200, + LoLA = 201, + LtLT = 202, + LvLV = 203, + LzhTW = 204, + MagIN = 205, + MaiIN = 206, + MaiNP = 207, + MfeMU = 208, + MgMG = 209, + MhrRU = 210, + MiNZ = 211, + MiqNI = 212, + MjwIN = 213, + MkMK = 214, + MlIN = 215, + MnMN = 216, + MniIN = 217, + MnwMM = 218, + MrIN = 219, + MsMY = 220, + MtMT = 221, + MyMM = 222, + NanTW = 223, + NanLatnTW = 224, + NbNO = 225, + NdsDE = 226, + NdsNL = 227, + NeNP = 228, + NhnMX = 229, + NiuNU = 230, + NiuNZ = 231, + NlAW = 232, + NlBE = 233, + NlNL = 235, + NnNO = 237, + NrZA = 238, + NsoZA = 239, + OcFR = 240, + OmET = 241, + OmKE = 242, + OrIN = 243, + OsRU = 244, + PaIN = 245, + PaPK = 246, + PapAW = 247, + PapCW = 248, + PlPL = 249, + PsAF = 250, + PtBR = 251, + PtPT = 252, + QuzPE = 254, + RajIN = 255, + RoRO = 256, + RuRU = 257, + RuUA = 258, + RwRW = 259, + SaIN = 260, + SahRU = 261, + SatIN = 262, + ScIT = 263, + SdIN = 264, + SdDevaIN = 265, + SeNO = 266, + SgsLT = 267, + ShnMM = 268, + ShsCA = 269, + SiLK = 270, + SidET = 271, + SkSK = 272, + SlSI = 273, + SmWS = 274, + SoDJ = 275, + SoET = 276, + SoKE = 277, + SoSO = 278, + SqAL = 279, + SqMK = 280, + SrME = 281, + SrRS = 282, + SrLatnRS = 283, + SsZA = 284, + StZA = 285, + SvFI = 286, + SvSE = 288, + SwKE = 289, + SwTZ = 290, + SzlPL = 291, + TaIN = 292, + TaLK = 293, + TcyIN = 294, + TeIN = 295, + TgTJ = 296, + ThTH = 297, + TheNP = 298, + TiER = 299, + TiET = 300, + TigER = 301, + TkTM = 302, + TlPH = 303, + TnZA = 304, + ToTO = 305, + TpiPG = 306, + TrCY = 307, + TrTR = 308, + TsZA = 309, + TtRU = 310, + TtLatnRU = 311, + UgCN = 312, + UkUA = 313, + UnmUS = 314, + UrIN = 315, + UrPK = 316, + UzUZ = 317, + UzCyrlUZ = 318, + VeZA = 319, + ViVN = 320, + WaBE = 321, + UzLatnUZ = 322, + WaeCH = 323, + WalET = 324, + WoSN = 325, + XhZA = 326, + YiUS = 327, + YoNG = 328, + YueHK = 329, + YuwPG = 330, + ZhCN = 331, + ZhHK = 332, + ZhSG = 333, + ZhTW = 334, + ZuZA = 335, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum LogRotateFrequency { + #[default] + Daily = 0, + Hourly = 1, + Minutely = 2, + Never = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum LookupStoreType { + #[default] + PostgreSql = 0, + MySql = 1, + Sqlite = 2, + Sharded = 3, + Redis = 4, + RedisCluster = 5, + RedisSentinel = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MessageFlag { + #[default] + Authenticated = 0, + Unauthenticated = 1, + UnauthenticatedDmarc = 2, + Dsn = 3, + Report = 4, + Autogenerated = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MetricType { + #[default] + Counter = 0, + Gauge = 1, + Histogram = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MetricsOtelType { + #[default] + Disabled = 0, + Http = 1, + Grpc = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MetricsPrometheusType { + #[default] + Disabled = 0, + Enabled = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MetricsStoreType { + #[default] + Disabled = 0, + Default = 1, + FoundationDb = 2, + PostgreSql = 3, + MySql = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MilterVersion { + #[default] + V2 = 0, + V6 = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ModelSize { + #[default] + V16 = 0, + V17 = 1, + V18 = 2, + V19 = 3, + V20 = 4, + V21 = 5, + V22 = 6, + V23 = 7, + V24 = 8, + V25 = 9, + V26 = 10, + V27 = 11, + V28 = 12, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaDeliveryExpirationType { + #[default] + Ttl = 0, + Attempts = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaDeliveryScheduleIntervalsOrDefaultType { + #[default] + Default = 0, + Custom = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaInboundThrottleKey { + #[default] + Listener = 0, + RemoteIp = 1, + LocalIp = 2, + AuthenticatedAs = 3, + HeloDomain = 4, + Sender = 5, + SenderDomain = 6, + Rcpt = 7, + RcptDomain = 8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaIpStrategy { + #[default] + V4ThenV6 = 0, + V6ThenV4 = 1, + V4Only = 2, + V6Only = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaOutboundThrottleKey { + #[default] + Mx = 0, + RemoteIp = 1, + LocalIp = 2, + Sender = 3, + SenderDomain = 4, + RcptDomain = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaProtocol { + #[default] + Smtp = 0, + Lmtp = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaQueueQuotaKey { + #[default] + Sender = 0, + SenderDomain = 1, + Rcpt = 2, + RcptDomain = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaRequiredOrOptional { + #[default] + Optional = 0, + Require = 1, + Disable = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaRouteType { + #[default] + Mx = 0, + Relay = 1, + Local = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaStage { + #[default] + Connect = 0, + Ehlo = 1, + Auth = 2, + Mail = 3, + Rcpt = 4, + Data = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum NetworkListenerProtocol { + #[default] + Smtp = 0, + Lmtp = 1, + Http = 2, + Imap = 3, + Pop3 = 4, + ManageSieve = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum OvhEndpoint { + #[default] + OvhEu = 0, + OvhCa = 1, + KimsufiEu = 2, + KimsufiCa = 3, + SoyoustartEu = 4, + SoyoustartCa = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PasswordHashAlgorithm { + #[default] + Argon2id = 0, + Bcrypt = 1, + Scrypt = 2, + Pbkdf2 = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PasswordStrength { + #[default] + Zero = 0, + One = 1, + Two = 2, + Three = 3, + Four = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Permission { + #[default] + Authenticate = 0, + AuthenticateWithAlias = 1, + InteractAi = 2, + Impersonate = 3, + UnlimitedRequests = 4, + UnlimitedUploads = 5, + FetchAnyBlob = 6, + EmailSend = 7, + EmailReceive = 8, + CalendarAlarmsSend = 9, + CalendarSchedulingSend = 10, + CalendarSchedulingReceive = 11, + JmapPushSubscriptionGet = 12, + JmapPushSubscriptionCreate = 13, + JmapPushSubscriptionUpdate = 14, + JmapPushSubscriptionDestroy = 15, + JmapMailboxGet = 16, + JmapMailboxChanges = 17, + JmapMailboxQuery = 18, + JmapMailboxQueryChanges = 19, + JmapMailboxCreate = 20, + JmapMailboxUpdate = 21, + JmapMailboxDestroy = 22, + JmapThreadGet = 23, + JmapThreadChanges = 24, + JmapEmailGet = 25, + JmapEmailChanges = 26, + JmapEmailQuery = 27, + JmapEmailQueryChanges = 28, + JmapEmailCreate = 29, + JmapEmailUpdate = 30, + JmapEmailDestroy = 31, + JmapEmailCopy = 32, + JmapEmailImport = 33, + JmapEmailParse = 34, + JmapSearchSnippetGet = 35, + JmapIdentityGet = 36, + JmapIdentityChanges = 37, + JmapIdentityCreate = 38, + JmapIdentityUpdate = 39, + JmapIdentityDestroy = 40, + JmapEmailSubmissionGet = 41, + JmapEmailSubmissionChanges = 42, + JmapEmailSubmissionQuery = 43, + JmapEmailSubmissionQueryChanges = 44, + JmapEmailSubmissionCreate = 45, + JmapEmailSubmissionUpdate = 46, + JmapEmailSubmissionDestroy = 47, + JmapVacationResponseGet = 48, + JmapVacationResponseCreate = 49, + JmapVacationResponseUpdate = 50, + JmapVacationResponseDestroy = 51, + JmapSieveScriptGet = 52, + JmapSieveScriptQuery = 53, + JmapSieveScriptValidate = 54, + JmapSieveScriptCreate = 55, + JmapSieveScriptUpdate = 56, + JmapSieveScriptDestroy = 57, + JmapPrincipalGet = 58, + JmapPrincipalQuery = 59, + JmapPrincipalChanges = 60, + JmapPrincipalQueryChanges = 61, + JmapPrincipalGetAvailability = 62, + JmapPrincipalCreate = 63, + JmapPrincipalUpdate = 64, + JmapPrincipalDestroy = 65, + JmapQuotaGet = 66, + JmapQuotaChanges = 67, + JmapQuotaQuery = 68, + JmapQuotaQueryChanges = 69, + JmapBlobGet = 70, + JmapBlobCopy = 71, + JmapBlobLookup = 72, + JmapBlobUpload = 73, + JmapAddressBookGet = 74, + JmapAddressBookChanges = 75, + JmapAddressBookCreate = 76, + JmapAddressBookUpdate = 77, + JmapAddressBookDestroy = 78, + JmapContactCardGet = 79, + JmapContactCardChanges = 80, + JmapContactCardQuery = 81, + JmapContactCardQueryChanges = 82, + JmapContactCardCreate = 83, + JmapContactCardUpdate = 84, + JmapContactCardDestroy = 85, + JmapContactCardCopy = 86, + JmapContactCardParse = 87, + JmapFileNodeGet = 88, + JmapFileNodeChanges = 89, + JmapFileNodeQuery = 90, + JmapFileNodeQueryChanges = 91, + JmapFileNodeCreate = 92, + JmapFileNodeUpdate = 93, + JmapFileNodeDestroy = 94, + JmapFileNodeCopy = 659, + JmapShareNotificationGet = 95, + JmapShareNotificationChanges = 96, + JmapShareNotificationQuery = 97, + JmapShareNotificationQueryChanges = 98, + JmapShareNotificationCreate = 99, + JmapShareNotificationUpdate = 100, + JmapShareNotificationDestroy = 101, + JmapCalendarGet = 102, + JmapCalendarChanges = 103, + JmapCalendarCreate = 104, + JmapCalendarUpdate = 105, + JmapCalendarDestroy = 106, + JmapCalendarEventGet = 107, + JmapCalendarEventChanges = 108, + JmapCalendarEventQuery = 109, + JmapCalendarEventQueryChanges = 110, + JmapCalendarEventCreate = 111, + JmapCalendarEventUpdate = 112, + JmapCalendarEventDestroy = 113, + JmapCalendarEventCopy = 114, + JmapCalendarEventParse = 115, + JmapCalendarEventNotificationGet = 116, + JmapCalendarEventNotificationChanges = 117, + JmapCalendarEventNotificationQuery = 118, + JmapCalendarEventNotificationQueryChanges = 119, + JmapCalendarEventNotificationCreate = 120, + JmapCalendarEventNotificationUpdate = 121, + JmapCalendarEventNotificationDestroy = 122, + JmapParticipantIdentityGet = 123, + JmapParticipantIdentityChanges = 124, + JmapParticipantIdentityCreate = 125, + JmapParticipantIdentityUpdate = 126, + JmapParticipantIdentityDestroy = 127, + JmapCoreEcho = 128, + ImapAuthenticate = 129, + ImapAclGet = 130, + ImapAclSet = 131, + ImapMyRights = 132, + ImapListRights = 133, + ImapAppend = 134, + ImapCapability = 135, + ImapId = 136, + ImapCopy = 137, + ImapMove = 138, + ImapCreate = 139, + ImapDelete = 140, + ImapEnable = 141, + ImapExpunge = 142, + ImapFetch = 143, + ImapIdle = 144, + ImapList = 145, + ImapLsub = 146, + ImapNamespace = 147, + ImapRename = 148, + ImapSearch = 149, + ImapSort = 150, + ImapSelect = 151, + ImapExamine = 152, + ImapStatus = 153, + ImapStore = 154, + ImapSubscribe = 155, + ImapThread = 156, + Pop3Authenticate = 157, + Pop3List = 158, + Pop3Uidl = 159, + Pop3Stat = 160, + Pop3Retr = 161, + Pop3Dele = 162, + SieveAuthenticate = 163, + SieveListScripts = 164, + SieveSetActive = 165, + SieveGetScript = 166, + SievePutScript = 167, + SieveDeleteScript = 168, + SieveRenameScript = 169, + SieveCheckScript = 170, + SieveHaveSpace = 171, + DavSyncCollection = 172, + DavExpandProperty = 173, + DavPrincipalAcl = 174, + DavPrincipalList = 175, + DavPrincipalMatch = 176, + DavPrincipalSearch = 177, + DavPrincipalSearchPropSet = 178, + DavFilePropFind = 179, + DavFilePropPatch = 180, + DavFileGet = 181, + DavFileMkCol = 182, + DavFileDelete = 183, + DavFilePut = 184, + DavFileCopy = 185, + DavFileMove = 186, + DavFileLock = 187, + DavFileAcl = 188, + DavCardPropFind = 189, + DavCardPropPatch = 190, + DavCardGet = 191, + DavCardMkCol = 192, + DavCardDelete = 193, + DavCardPut = 194, + DavCardCopy = 195, + DavCardMove = 196, + DavCardLock = 197, + DavCardAcl = 198, + DavCardQuery = 199, + DavCardMultiGet = 200, + DavCalPropFind = 201, + DavCalPropPatch = 202, + DavCalGet = 203, + DavCalMkCol = 204, + DavCalDelete = 205, + DavCalPut = 206, + DavCalCopy = 207, + DavCalMove = 208, + DavCalLock = 209, + DavCalAcl = 210, + DavCalQuery = 211, + DavCalMultiGet = 212, + DavCalFreeBusyQuery = 213, + OAuthClientRegistration = 214, + OAuthClientOverride = 215, + LiveTracing = 216, + LiveMetrics = 217, + LiveDeliveryTest = 218, + ScimAccess = 660, + SysAccountGet = 219, + SysAccountCreate = 220, + SysAccountUpdate = 221, + SysAccountDestroy = 222, + SysAccountQuery = 223, + SysAccountPasswordGet = 224, + SysAccountPasswordUpdate = 225, + SysAccountSettingsGet = 226, + SysAccountSettingsUpdate = 227, + SysAcmeProviderGet = 228, + SysAcmeProviderCreate = 229, + SysAcmeProviderUpdate = 230, + SysAcmeProviderDestroy = 231, + SysAcmeProviderQuery = 232, + ActionReloadSettings = 233, + ActionReloadTlsCertificates = 234, + ActionReloadLookupStores = 235, + ActionReloadBlockedIps = 236, + ActionUpdateApps = 237, + ActionTroubleshootDmarc = 238, + ActionClassifySpam = 239, + ActionInvalidateCaches = 240, + ActionInvalidateNegativeCaches = 241, + ActionPauseMtaQueue = 242, + ActionResumeMtaQueue = 243, + SysActionGet = 244, + SysActionCreate = 245, + SysActionUpdate = 246, + SysActionDestroy = 247, + SysActionQuery = 248, + SysAddressBookGet = 249, + SysAddressBookUpdate = 250, + SysAiModelGet = 251, + SysAiModelCreate = 252, + SysAiModelUpdate = 253, + SysAiModelDestroy = 254, + SysAiModelQuery = 255, + SysAlertGet = 256, + SysAlertCreate = 257, + SysAlertUpdate = 258, + SysAlertDestroy = 259, + SysAlertQuery = 260, + SysAllowedIpGet = 261, + SysAllowedIpCreate = 262, + SysAllowedIpUpdate = 263, + SysAllowedIpDestroy = 264, + SysAllowedIpQuery = 265, + SysApiKeyGet = 266, + SysApiKeyCreate = 267, + SysApiKeyUpdate = 268, + SysApiKeyDestroy = 269, + SysApiKeyQuery = 270, + SysAppPasswordGet = 271, + SysAppPasswordCreate = 272, + SysAppPasswordUpdate = 273, + SysAppPasswordDestroy = 274, + SysAppPasswordQuery = 275, + SysApplicationGet = 276, + SysApplicationCreate = 277, + SysApplicationUpdate = 278, + SysApplicationDestroy = 279, + SysApplicationQuery = 280, + SysArchivedItemGet = 281, + SysArchivedItemCreate = 282, + SysArchivedItemUpdate = 283, + SysArchivedItemDestroy = 284, + SysArchivedItemQuery = 285, + SysArfExternalReportGet = 286, + SysArfExternalReportCreate = 287, + SysArfExternalReportUpdate = 288, + SysArfExternalReportDestroy = 289, + SysArfExternalReportQuery = 290, + SysAsnGet = 291, + SysAsnUpdate = 292, + SysAuthenticationGet = 293, + SysAuthenticationUpdate = 294, + SysBlobStoreGet = 295, + SysBlobStoreUpdate = 296, + SysBlockedIpGet = 297, + SysBlockedIpCreate = 298, + SysBlockedIpUpdate = 299, + SysBlockedIpDestroy = 300, + SysBlockedIpQuery = 301, + SysBootstrapGet = 302, + SysBootstrapUpdate = 303, + SysCacheGet = 304, + SysCacheUpdate = 305, + SysCalendarGet = 306, + SysCalendarUpdate = 307, + SysCalendarAlarmGet = 308, + SysCalendarAlarmUpdate = 309, + SysCalendarSchedulingGet = 310, + SysCalendarSchedulingUpdate = 311, + SysCertificateGet = 312, + SysCertificateCreate = 313, + SysCertificateUpdate = 314, + SysCertificateDestroy = 315, + SysCertificateQuery = 316, + SysClusterNodeGet = 317, + SysClusterNodeCreate = 318, + SysClusterNodeUpdate = 319, + SysClusterNodeDestroy = 320, + SysClusterNodeQuery = 321, + SysClusterRoleGet = 322, + SysClusterRoleCreate = 323, + SysClusterRoleUpdate = 324, + SysClusterRoleDestroy = 325, + SysClusterRoleQuery = 326, + SysCoordinatorGet = 327, + SysCoordinatorUpdate = 328, + SysDataRetentionGet = 329, + SysDataRetentionUpdate = 330, + SysDataStoreGet = 331, + SysDataStoreUpdate = 332, + SysDirectoryGet = 333, + SysDirectoryCreate = 334, + SysDirectoryUpdate = 335, + SysDirectoryDestroy = 336, + SysDirectoryQuery = 337, + SysDkimReportSettingsGet = 338, + SysDkimReportSettingsUpdate = 339, + SysDkimSignatureGet = 340, + SysDkimSignatureCreate = 341, + SysDkimSignatureUpdate = 342, + SysDkimSignatureDestroy = 343, + SysDkimSignatureQuery = 344, + SysDmarcExternalReportGet = 345, + SysDmarcExternalReportCreate = 346, + SysDmarcExternalReportUpdate = 347, + SysDmarcExternalReportDestroy = 348, + SysDmarcExternalReportQuery = 349, + SysDmarcInternalReportGet = 350, + SysDmarcInternalReportCreate = 351, + SysDmarcInternalReportUpdate = 352, + SysDmarcInternalReportDestroy = 353, + SysDmarcInternalReportQuery = 354, + SysDmarcReportSettingsGet = 355, + SysDmarcReportSettingsUpdate = 356, + SysDnsResolverGet = 357, + SysDnsResolverUpdate = 358, + SysDnsServerGet = 359, + SysDnsServerCreate = 360, + SysDnsServerUpdate = 361, + SysDnsServerDestroy = 362, + SysDnsServerQuery = 363, + SysDomainGet = 364, + SysDomainCreate = 365, + SysDomainUpdate = 366, + SysDomainDestroy = 367, + SysDomainQuery = 368, + SysDsnReportSettingsGet = 369, + SysDsnReportSettingsUpdate = 370, + SysEmailGet = 371, + SysEmailUpdate = 372, + SysEnterpriseGet = 373, + SysEnterpriseUpdate = 374, + SysEventTracingLevelGet = 375, + SysEventTracingLevelCreate = 376, + SysEventTracingLevelUpdate = 377, + SysEventTracingLevelDestroy = 378, + SysEventTracingLevelQuery = 379, + SysFileStorageGet = 380, + SysFileStorageUpdate = 381, + SysHttpGet = 382, + SysHttpUpdate = 383, + SysHttpFormGet = 384, + SysHttpFormUpdate = 385, + SysHttpLookupGet = 386, + SysHttpLookupCreate = 387, + SysHttpLookupUpdate = 388, + SysHttpLookupDestroy = 389, + SysHttpLookupQuery = 390, + SysImapGet = 391, + SysImapUpdate = 392, + SysInMemoryStoreGet = 393, + SysInMemoryStoreUpdate = 394, + SysJmapGet = 395, + SysJmapUpdate = 396, + SysLogGet = 397, + SysLogCreate = 398, + SysLogUpdate = 399, + SysLogDestroy = 400, + SysLogQuery = 401, + SysMailingListGet = 402, + SysMailingListCreate = 403, + SysMailingListUpdate = 404, + SysMailingListDestroy = 405, + SysMailingListQuery = 406, + SysMaskedEmailGet = 407, + SysMaskedEmailCreate = 408, + SysMaskedEmailUpdate = 409, + SysMaskedEmailDestroy = 410, + SysMaskedEmailQuery = 411, + SysMemoryLookupKeyGet = 412, + SysMemoryLookupKeyCreate = 413, + SysMemoryLookupKeyUpdate = 414, + SysMemoryLookupKeyDestroy = 415, + SysMemoryLookupKeyQuery = 416, + SysMemoryLookupKeyValueGet = 417, + SysMemoryLookupKeyValueCreate = 418, + SysMemoryLookupKeyValueUpdate = 419, + SysMemoryLookupKeyValueDestroy = 420, + SysMemoryLookupKeyValueQuery = 421, + SysMetricGet = 422, + SysMetricCreate = 423, + SysMetricUpdate = 424, + SysMetricDestroy = 425, + SysMetricQuery = 426, + SysMetricsGet = 427, + SysMetricsUpdate = 428, + SysMetricsStoreGet = 429, + SysMetricsStoreUpdate = 430, + SysMtaConnectionStrategyGet = 431, + SysMtaConnectionStrategyCreate = 432, + SysMtaConnectionStrategyUpdate = 433, + SysMtaConnectionStrategyDestroy = 434, + SysMtaConnectionStrategyQuery = 435, + SysMtaDeliveryScheduleGet = 436, + SysMtaDeliveryScheduleCreate = 437, + SysMtaDeliveryScheduleUpdate = 438, + SysMtaDeliveryScheduleDestroy = 439, + SysMtaDeliveryScheduleQuery = 440, + SysMtaExtensionsGet = 441, + SysMtaExtensionsUpdate = 442, + SysMtaHookGet = 443, + SysMtaHookCreate = 444, + SysMtaHookUpdate = 445, + SysMtaHookDestroy = 446, + SysMtaHookQuery = 447, + SysMtaInboundSessionGet = 448, + SysMtaInboundSessionUpdate = 449, + SysMtaInboundThrottleGet = 450, + SysMtaInboundThrottleCreate = 451, + SysMtaInboundThrottleUpdate = 452, + SysMtaInboundThrottleDestroy = 453, + SysMtaInboundThrottleQuery = 454, + SysMtaMilterGet = 455, + SysMtaMilterCreate = 456, + SysMtaMilterUpdate = 457, + SysMtaMilterDestroy = 458, + SysMtaMilterQuery = 459, + SysMtaOutboundStrategyGet = 460, + SysMtaOutboundStrategyUpdate = 461, + SysMtaOutboundThrottleGet = 462, + SysMtaOutboundThrottleCreate = 463, + SysMtaOutboundThrottleUpdate = 464, + SysMtaOutboundThrottleDestroy = 465, + SysMtaOutboundThrottleQuery = 466, + SysMtaQueueQuotaGet = 467, + SysMtaQueueQuotaCreate = 468, + SysMtaQueueQuotaUpdate = 469, + SysMtaQueueQuotaDestroy = 470, + SysMtaQueueQuotaQuery = 471, + SysMtaRouteGet = 472, + SysMtaRouteCreate = 473, + SysMtaRouteUpdate = 474, + SysMtaRouteDestroy = 475, + SysMtaRouteQuery = 476, + SysMtaStageAuthGet = 477, + SysMtaStageAuthUpdate = 478, + SysMtaStageConnectGet = 479, + SysMtaStageConnectUpdate = 480, + SysMtaStageDataGet = 481, + SysMtaStageDataUpdate = 482, + SysMtaStageEhloGet = 483, + SysMtaStageEhloUpdate = 484, + SysMtaStageMailGet = 485, + SysMtaStageMailUpdate = 486, + SysMtaStageRcptGet = 487, + SysMtaStageRcptUpdate = 488, + SysMtaStsGet = 489, + SysMtaStsUpdate = 490, + SysMtaTlsStrategyGet = 491, + SysMtaTlsStrategyCreate = 492, + SysMtaTlsStrategyUpdate = 493, + SysMtaTlsStrategyDestroy = 494, + SysMtaTlsStrategyQuery = 495, + SysMtaVirtualQueueGet = 496, + SysMtaVirtualQueueCreate = 497, + SysMtaVirtualQueueUpdate = 498, + SysMtaVirtualQueueDestroy = 499, + SysMtaVirtualQueueQuery = 500, + SysNetworkListenerGet = 501, + SysNetworkListenerCreate = 502, + SysNetworkListenerUpdate = 503, + SysNetworkListenerDestroy = 504, + SysNetworkListenerQuery = 505, + SysOAuthClientGet = 506, + SysOAuthClientCreate = 507, + SysOAuthClientUpdate = 508, + SysOAuthClientDestroy = 509, + SysOAuthClientQuery = 510, + SysOidcProviderGet = 511, + SysOidcProviderUpdate = 512, + SysPublicKeyGet = 513, + SysPublicKeyCreate = 514, + SysPublicKeyUpdate = 515, + SysPublicKeyDestroy = 516, + SysPublicKeyQuery = 517, + SysQueuedMessageGet = 518, + SysQueuedMessageCreate = 519, + SysQueuedMessageUpdate = 520, + SysQueuedMessageDestroy = 521, + SysQueuedMessageQuery = 522, + SysReportSettingsGet = 523, + SysReportSettingsUpdate = 524, + SysRoleGet = 525, + SysRoleCreate = 526, + SysRoleUpdate = 527, + SysRoleDestroy = 528, + SysRoleQuery = 529, + SysSearchGet = 530, + SysSearchUpdate = 531, + SysSearchStoreGet = 532, + SysSearchStoreUpdate = 533, + SysSecurityGet = 534, + SysSecurityUpdate = 535, + SysSenderAuthGet = 536, + SysSenderAuthUpdate = 537, + SysSharingGet = 538, + SysSharingUpdate = 539, + SysSieveSystemInterpreterGet = 540, + SysSieveSystemInterpreterUpdate = 541, + SysSieveSystemScriptGet = 542, + SysSieveSystemScriptCreate = 543, + SysSieveSystemScriptUpdate = 544, + SysSieveSystemScriptDestroy = 545, + SysSieveSystemScriptQuery = 546, + SysSieveUserInterpreterGet = 547, + SysSieveUserInterpreterUpdate = 548, + SysSieveUserScriptGet = 549, + SysSieveUserScriptCreate = 550, + SysSieveUserScriptUpdate = 551, + SysSieveUserScriptDestroy = 552, + SysSieveUserScriptQuery = 553, + SysSpamClassifierGet = 554, + SysSpamClassifierUpdate = 555, + SysSpamDnsblServerGet = 556, + SysSpamDnsblServerCreate = 557, + SysSpamDnsblServerUpdate = 558, + SysSpamDnsblServerDestroy = 559, + SysSpamDnsblServerQuery = 560, + SysSpamDnsblSettingsGet = 561, + SysSpamDnsblSettingsUpdate = 562, + SysSpamFileExtensionGet = 563, + SysSpamFileExtensionCreate = 564, + SysSpamFileExtensionUpdate = 565, + SysSpamFileExtensionDestroy = 566, + SysSpamFileExtensionQuery = 567, + SysSpamLlmGet = 568, + SysSpamLlmUpdate = 569, + SysSpamPyzorGet = 570, + SysSpamPyzorUpdate = 571, + SysSpamRuleGet = 572, + SysSpamRuleCreate = 573, + SysSpamRuleUpdate = 574, + SysSpamRuleDestroy = 575, + SysSpamRuleQuery = 576, + SysSpamSettingsGet = 577, + SysSpamSettingsUpdate = 578, + SysSpamTagGet = 579, + SysSpamTagCreate = 580, + SysSpamTagUpdate = 581, + SysSpamTagDestroy = 582, + SysSpamTagQuery = 583, + SysSpamTrainingSampleGet = 584, + SysSpamTrainingSampleCreate = 585, + SysSpamTrainingSampleUpdate = 586, + SysSpamTrainingSampleDestroy = 587, + SysSpamTrainingSampleQuery = 588, + SysSpfReportSettingsGet = 589, + SysSpfReportSettingsUpdate = 590, + SysStoreLookupGet = 591, + SysStoreLookupCreate = 592, + SysStoreLookupUpdate = 593, + SysStoreLookupDestroy = 594, + SysStoreLookupQuery = 595, + SysSystemSettingsGet = 596, + SysSystemSettingsUpdate = 597, + TaskIndexDocument = 598, + TaskUnindexDocument = 599, + TaskIndexTrace = 600, + TaskCalendarAlarmEmail = 601, + TaskCalendarAlarmNotification = 602, + TaskCalendarItipMessage = 603, + TaskMergeThreads = 604, + TaskDmarcReport = 605, + TaskTlsReport = 606, + TaskRestoreArchivedItem = 607, + TaskDestroyAccount = 608, + TaskAccountMaintenance = 609, + TaskTenantMaintenance = 610, + TaskStoreMaintenance = 611, + TaskSpamFilterMaintenance = 612, + TaskAcmeRenewal = 613, + TaskDkimManagement = 614, + TaskDnsManagement = 615, + SysTaskGet = 616, + SysTaskCreate = 617, + SysTaskUpdate = 618, + SysTaskDestroy = 619, + SysTaskQuery = 620, + SysTaskManagerGet = 621, + SysTaskManagerUpdate = 622, + SysTenantGet = 623, + SysTenantCreate = 624, + SysTenantUpdate = 625, + SysTenantDestroy = 626, + SysTenantQuery = 627, + SysTlsExternalReportGet = 628, + SysTlsExternalReportCreate = 629, + SysTlsExternalReportUpdate = 630, + SysTlsExternalReportDestroy = 631, + SysTlsExternalReportQuery = 632, + SysTlsInternalReportGet = 633, + SysTlsInternalReportCreate = 634, + SysTlsInternalReportUpdate = 635, + SysTlsInternalReportDestroy = 636, + SysTlsInternalReportQuery = 637, + SysTlsReportSettingsGet = 638, + SysTlsReportSettingsUpdate = 639, + SysTraceGet = 640, + SysTraceCreate = 641, + SysTraceUpdate = 642, + SysTraceDestroy = 643, + SysTraceQuery = 644, + SysTracerGet = 645, + SysTracerCreate = 646, + SysTracerUpdate = 647, + SysTracerDestroy = 648, + SysTracerQuery = 649, + SysTracingStoreGet = 650, + SysTracingStoreUpdate = 651, + SysWebDavGet = 652, + SysWebDavUpdate = 653, + SysWebHookGet = 654, + SysWebHookCreate = 655, + SysWebHookUpdate = 656, + SysWebHookDestroy = 657, + SysWebHookQuery = 658, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PermissionsType { + #[default] + Inherit = 0, + Merge = 1, + Replace = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PolicyEnforcement { + #[default] + Enforce = 0, + Testing = 1, + Disable = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PostgreSqlRecyclingMethod { + #[default] + Fast = 0, + Verified = 1, + Clean = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ProviderInfo { + #[default] + ProviderName = 0, + ProviderShortName = 1, + UserDocumentation = 2, + DeveloperDocumentation = 3, + ContactUri = 4, + LogoUrl = 5, + LogoWidth = 6, + LogoHeight = 7, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PublicStringOptionalType { + #[default] + None = 0, + Value = 1, + EnvironmentVariable = 2, + File = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PublicTextType { + #[default] + Text = 0, + EnvironmentVariable = 1, + File = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum QueueExpiryType { + #[default] + Ttl = 0, + Attempts = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum RecipientFlag { + #[default] + DsnSent = 0, + SpamPayload = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum RecipientStatusType { + #[default] + Scheduled = 0, + Completed = 1, + TemporaryFailure = 2, + PermanentFailure = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum RedisProtocol { + #[default] + Resp2 = 0, + Resp3 = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum RolesType { + #[default] + Default = 0, + Custom = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum S3StoreRegionType { + #[default] + UsEast1 = 0, + UsEast2 = 1, + UsWest1 = 2, + UsWest2 = 3, + CaCentral1 = 4, + AfSouth1 = 5, + ApEast1 = 6, + ApSouth1 = 7, + ApNortheast1 = 8, + ApNortheast2 = 9, + ApNortheast3 = 10, + ApSoutheast1 = 11, + ApSoutheast2 = 12, + CnNorth1 = 13, + CnNorthwest1 = 14, + EuNorth1 = 15, + EuCentral1 = 16, + EuCentral2 = 17, + EuWest1 = 18, + EuWest2 = 19, + EuWest3 = 20, + IlCentral1 = 21, + MeSouth1 = 22, + SaEast1 = 23, + DoNyc3 = 24, + DoAms3 = 25, + DoSgp1 = 26, + DoFra1 = 27, + Yandex = 28, + WaUsEast1 = 29, + WaUsEast2 = 30, + WaUsCentral1 = 31, + WaUsWest1 = 32, + WaCaCentral1 = 33, + WaEuCentral1 = 34, + WaEuCentral2 = 35, + WaEuWest1 = 36, + WaEuWest2 = 37, + WaApNortheast1 = 38, + WaApNortheast2 = 39, + WaApSoutheast1 = 40, + WaApSoutheast2 = 41, + Custom = 42, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchCalendarField { + #[default] + Title = 0, + Description = 1, + Location = 2, + Owner = 3, + Attendee = 4, + Start = 5, + Uid = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchContactField { + #[default] + Member = 0, + Kind = 1, + Name = 2, + Nickname = 3, + Organization = 4, + Email = 5, + Phone = 6, + OnlineService = 7, + Address = 8, + Note = 9, + Uid = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchEmailField { + #[default] + From = 0, + To = 1, + Cc = 2, + Bcc = 3, + Subject = 4, + Body = 5, + Attachment = 6, + ReceivedAt = 7, + SentAt = 8, + Size = 9, + HasAttachment = 10, + Headers = 11, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchFileField { + #[default] + Name = 0, + Content = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchStoreType { + #[default] + Default = 0, + ElasticSearch = 1, + Meilisearch = 2, + FoundationDb = 3, + PostgreSql = 4, + MySql = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SearchTracingField { + #[default] + EventType = 0, + QueueId = 1, + Keywords = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SecretKeyOptionalType { + #[default] + None = 0, + Value = 1, + EnvironmentVariable = 2, + File = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SecretKeyType { + #[default] + Value = 0, + EnvironmentVariable = 1, + File = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SecretTextOptionalType { + #[default] + None = 0, + Text = 1, + EnvironmentVariable = 2, + File = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SecretTextType { + #[default] + Text = 0, + EnvironmentVariable = 1, + File = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ServiceProtocol { + #[default] + Jmap = 0, + Imap = 1, + Pop3 = 2, + Smtp = 3, + Caldav = 4, + Carddav = 5, + Webdav = 6, + Managesieve = 7, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SieveCapability { + #[default] + Envelope = 0, + EnvelopeDsn = 1, + EnvelopeDeliverby = 2, + Fileinto = 3, + EncodedCharacter = 4, + ComparatorElbonia = 5, + ComparatorIOctet = 6, + ComparatorIAsciiCasemap = 7, + ComparatorIAsciiNumeric = 8, + Body = 9, + Convert = 10, + Copy = 11, + Relational = 12, + Date = 13, + Index = 14, + Duplicate = 15, + Variables = 16, + Editheader = 17, + Foreverypart = 18, + Mime = 19, + Replace = 20, + Enclose = 21, + Extracttext = 22, + Enotify = 23, + RedirectDsn = 24, + RedirectDeliverby = 25, + Environment = 26, + Reject = 27, + Ereject = 28, + Extlists = 29, + Subaddress = 30, + Vacation = 31, + VacationSeconds = 32, + Fcc = 33, + Mailbox = 34, + Mailboxid = 35, + Mboxmetadata = 36, + Servermetadata = 37, + SpecialUse = 38, + Imap4flags = 39, + Ihave = 40, + Imapsieve = 41, + Include = 42, + Regex = 43, + Spamtest = 44, + Spamtestplus = 45, + Virustest = 46, + VndStalwartWhile = 47, + VndStalwartExpressions = 48, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Sig0Algorithm { + #[default] + EcdsaP256Sha256 = 0, + EcdsaP384Sha384 = 1, + Ed25519 = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamClassifierModelType { + #[default] + FtrlFh = 0, + FtrlCcfh = 1, + Disabled = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamClassifyParameters { + #[default] + Bit7 = 0, + Bit8Mime8BitMIMEMessageContent = 1, + BinaryMime = 2, + SmtpUtf8 = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamClassifyResult { + #[default] + Spam = 0, + Ham = 1, + Reject = 2, + Discard = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamClassifyTagDisposition { + #[default] + Score = 0, + Reject = 1, + Discard = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamDnsblServerType { + #[default] + Any = 0, + Url = 1, + Domain = 2, + Email = 3, + Ip = 4, + Header = 5, + Body = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamLlmType { + #[default] + Disable = 0, + Enable = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamRuleType { + #[default] + Any = 0, + Url = 1, + Domain = 2, + Email = 3, + Ip = 4, + Header = 5, + Body = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamTagType { + #[default] + Score = 0, + Discard = 1, + Reject = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpecialUse { + #[default] + Inbox = 0, + Trash = 1, + Junk = 2, + Drafts = 3, + Archive = 4, + Sent = 5, + Shared = 6, + Important = 7, + Memos = 8, + Scheduled = 9, + Snoozed = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpfAuthResult { + #[default] + None = 0, + Neutral = 1, + Pass = 2, + Fail = 3, + SoftFail = 4, + TempError = 5, + PermError = 6, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpfDomainScope { + #[default] + Helo = 0, + MailFrom = 1, + Unspecified = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SqlAuthStoreType { + #[default] + Default = 0, + PostgreSql = 1, + MySql = 2, + Sqlite = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum StorageQuota { + #[default] + MaxEmails = 0, + MaxMailboxes = 1, + MaxEmailSubmissions = 2, + MaxEmailIdentities = 3, + MaxParticipantIdentities = 4, + MaxSieveScripts = 5, + MaxPushSubscriptions = 6, + MaxCalendars = 7, + MaxCalendarEvents = 8, + MaxCalendarEventNotifications = 9, + MaxAddressBooks = 10, + MaxContactCards = 11, + MaxFiles = 12, + MaxFolders = 13, + MaxMaskedAddresses = 14, + MaxAppPasswords = 15, + MaxApiKeys = 16, + MaxPublicKeys = 17, + MaxDiskQuota = 18, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SubAddressingType { + #[default] + Enabled = 0, + Custom = 1, + Disabled = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskAccountMaintenanceType { + #[default] + Purge = 0, + Reindex = 1, + RecalculateImapUid = 2, + RecalculateQuota = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskRetryStrategyType { + #[default] + ExponentialBackoff = 0, + FixedDelay = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskSpamFilterMaintenanceType { + #[default] + Train = 0, + Retrain = 1, + Abort = 2, + Reset = 3, + UpdateRules = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskStatusType { + #[default] + Pending = 0, + Retry = 1, + Failed = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskStoreMaintenanceType { + #[default] + ReindexAccounts = 0, + ReindexTelemetry = 1, + PurgeAccounts = 2, + PurgeData = 3, + PurgeBlob = 4, + ResetRateLimiters = 5, + ResetUserQuotas = 6, + ResetTenantQuotas = 7, + ResetBlobQuotas = 8, + RemoveAuthTokens = 9, + RemoveLockQueueMessage = 10, + RemoveLockTask = 11, + RemoveLockDav = 12, + RemoveSieveId = 13, + RemoveGreylist = 14, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskTenantMaintenanceType { + #[default] + RecalculateQuota = 0, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskType { + #[default] + IndexDocument = 0, + UnindexDocument = 1, + IndexTrace = 2, + CalendarAlarmEmail = 3, + CalendarAlarmNotification = 4, + CalendarItipMessage = 5, + MergeThreads = 6, + DmarcReport = 7, + TlsReport = 8, + RestoreArchivedItem = 9, + DestroyAccount = 10, + AccountMaintenance = 11, + TenantMaintenance = 12, + StoreMaintenance = 13, + SpamFilterMaintenance = 14, + AcmeRenewal = 15, + DkimManagement = 16, + DnsManagement = 17, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TenantStorageQuota { + #[default] + MaxAccounts = 0, + MaxGroups = 1, + MaxDomains = 2, + MaxMailingLists = 3, + MaxRoles = 4, + MaxOauthClients = 5, + MaxDkimKeys = 6, + MaxDnsServers = 7, + MaxDirectories = 8, + MaxAcmeProviders = 9, + MaxDiskQuota = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TimeZone { + #[default] + AfricaAbidjan = 0, + AfricaAccra = 1, + AfricaAddisAbaba = 2, + AfricaAlgiers = 3, + AfricaAsmara = 4, + AfricaAsmera = 5, + AfricaBamako = 6, + AfricaBangui = 7, + AfricaBanjul = 8, + AfricaBissau = 9, + AfricaBlantyre = 10, + AfricaBrazzaville = 11, + AfricaBujumbura = 12, + AfricaCairo = 13, + AfricaCasablanca = 14, + AfricaCeuta = 15, + AfricaConakry = 16, + AfricaDakar = 17, + AfricaDarEsSalaam = 18, + AfricaDjibouti = 19, + AfricaDouala = 20, + AfricaElAaiun = 21, + AfricaFreetown = 22, + AfricaGaborone = 23, + AfricaHarare = 24, + AfricaJohannesburg = 25, + AfricaJuba = 26, + AfricaKampala = 27, + AfricaKhartoum = 28, + AfricaKigali = 29, + AfricaKinshasa = 30, + AfricaLagos = 31, + AfricaLibreville = 32, + AfricaLome = 33, + AfricaLuanda = 34, + AfricaLubumbashi = 35, + AfricaLusaka = 36, + AfricaMalabo = 37, + AfricaMaputo = 38, + AfricaMaseru = 39, + AfricaMbabane = 40, + AfricaMogadishu = 41, + AfricaMonrovia = 42, + AfricaNairobi = 43, + AfricaNdjamena = 44, + AfricaNiamey = 45, + AfricaNouakchott = 46, + AfricaOuagadougou = 47, + AfricaPortoNovo = 48, + AfricaSaoTome = 49, + AfricaTimbuktu = 50, + AfricaTripoli = 51, + AfricaTunis = 52, + AfricaWindhoek = 53, + AmericaAdak = 54, + AmericaAnchorage = 55, + AmericaAnguilla = 56, + AmericaAntigua = 57, + AmericaAraguaina = 58, + AmericaArgentinaBuenosAires = 59, + AmericaArgentinaCatamarca = 60, + AmericaArgentinaComodRivadavia = 61, + AmericaArgentinaCordoba = 62, + AmericaArgentinaJujuy = 63, + AmericaArgentinaLaRioja = 64, + AmericaArgentinaMendoza = 65, + AmericaArgentinaRioGallegos = 66, + AmericaArgentinaSalta = 67, + AmericaArgentinaSanJuan = 68, + AmericaArgentinaSanLuis = 69, + AmericaArgentinaTucuman = 70, + AmericaArgentinaUshuaia = 71, + AmericaAruba = 72, + AmericaAsuncion = 73, + AmericaAtikokan = 74, + AmericaAtka = 75, + AmericaBahia = 76, + AmericaBahiaBanderas = 77, + AmericaBarbados = 78, + AmericaBelem = 79, + AmericaBelize = 80, + AmericaBlancSablon = 81, + AmericaBoaVista = 82, + AmericaBogota = 83, + AmericaBoise = 84, + AmericaBuenosAires = 85, + AmericaCambridgeBay = 86, + AmericaCampoGrande = 87, + AmericaCancun = 88, + AmericaCaracas = 89, + AmericaCatamarca = 90, + AmericaCayenne = 91, + AmericaCayman = 92, + AmericaChicago = 93, + AmericaChihuahua = 94, + AmericaCiudadJuarez = 95, + AmericaCoralHarbour = 96, + AmericaCordoba = 97, + AmericaCostaRica = 98, + AmericaCoyhaique = 99, + AmericaCreston = 100, + AmericaCuiaba = 101, + AmericaCuracao = 102, + AmericaDanmarkshavn = 103, + AmericaDawson = 104, + AmericaDawsonCreek = 105, + AmericaDenver = 106, + AmericaDetroit = 107, + AmericaDominica = 108, + AmericaEdmonton = 109, + AmericaEirunepe = 110, + AmericaElSalvador = 111, + AmericaEnsenada = 112, + AmericaFortNelson = 113, + AmericaFortWayne = 114, + AmericaFortaleza = 115, + AmericaGlaceBay = 116, + AmericaGodthab = 117, + AmericaGooseBay = 118, + AmericaGrandTurk = 119, + AmericaGrenada = 120, + AmericaGuadeloupe = 121, + AmericaGuatemala = 122, + AmericaGuayaquil = 123, + AmericaGuyana = 124, + AmericaHalifax = 125, + AmericaHavana = 126, + AmericaHermosillo = 127, + AmericaIndianaIndianapolis = 128, + AmericaIndianaKnox = 129, + AmericaIndianaMarengo = 130, + AmericaIndianaPetersburg = 131, + AmericaIndianaTellCity = 132, + AmericaIndianaVevay = 133, + AmericaIndianaVincennes = 134, + AmericaIndianaWinamac = 135, + AmericaIndianapolis = 136, + AmericaInuvik = 137, + AmericaIqaluit = 138, + AmericaJamaica = 139, + AmericaJujuy = 140, + AmericaJuneau = 141, + AmericaKentuckyLouisville = 142, + AmericaKentuckyMonticello = 143, + AmericaKnoxIN = 144, + AmericaKralendijk = 145, + AmericaLaPaz = 146, + AmericaLima = 147, + AmericaLosAngeles = 148, + AmericaLouisville = 149, + AmericaLowerPrinces = 150, + AmericaMaceio = 151, + AmericaManagua = 152, + AmericaManaus = 153, + AmericaMarigot = 154, + AmericaMartinique = 155, + AmericaMatamoros = 156, + AmericaMazatlan = 157, + AmericaMendoza = 158, + AmericaMenominee = 159, + AmericaMerida = 160, + AmericaMetlakatla = 161, + AmericaMexicoCity = 162, + AmericaMiquelon = 163, + AmericaMoncton = 164, + AmericaMonterrey = 165, + AmericaMontevideo = 166, + AmericaMontreal = 167, + AmericaMontserrat = 168, + AmericaNassau = 169, + AmericaNewYork = 170, + AmericaNipigon = 171, + AmericaNome = 172, + AmericaNoronha = 173, + AmericaNorthDakotaBeulah = 174, + AmericaNorthDakotaCenter = 175, + AmericaNorthDakotaNewSalem = 176, + AmericaNuuk = 177, + AmericaOjinaga = 178, + AmericaPanama = 179, + AmericaPangnirtung = 180, + AmericaParamaribo = 181, + AmericaPhoenix = 182, + AmericaPortAuPrince = 183, + AmericaPortOfSpain = 184, + AmericaPortoAcre = 185, + AmericaPortoVelho = 186, + AmericaPuertoRico = 187, + AmericaPuntaArenas = 188, + AmericaRainyRiver = 189, + AmericaRankinInlet = 190, + AmericaRecife = 191, + AmericaRegina = 192, + AmericaResolute = 193, + AmericaRioBranco = 194, + AmericaRosario = 195, + AmericaSantaIsabel = 196, + AmericaSantarem = 197, + AmericaSantiago = 198, + AmericaSantoDomingo = 199, + AmericaSaoPaulo = 200, + AmericaScoresbysund = 201, + AmericaShiprock = 202, + AmericaSitka = 203, + AmericaStBarthelemy = 204, + AmericaStJohns = 205, + AmericaStKitts = 206, + AmericaStLucia = 207, + AmericaStThomas = 208, + AmericaStVincent = 209, + AmericaSwiftCurrent = 210, + AmericaTegucigalpa = 211, + AmericaThule = 212, + AmericaThunderBay = 213, + AmericaTijuana = 214, + AmericaToronto = 215, + AmericaTortola = 216, + AmericaVancouver = 217, + AmericaVirgin = 218, + AmericaWhitehorse = 219, + AmericaWinnipeg = 220, + AmericaYakutat = 221, + AmericaYellowknife = 222, + AntarcticaCasey = 223, + AntarcticaDavis = 224, + AntarcticaDumontDUrville = 225, + AntarcticaMacquarie = 226, + AntarcticaMawson = 227, + AntarcticaMcMurdo = 228, + AntarcticaPalmer = 229, + AntarcticaRothera = 230, + AntarcticaSouthPole = 231, + AntarcticaSyowa = 232, + AntarcticaTroll = 233, + AntarcticaVostok = 234, + ArcticLongyearbyen = 235, + AsiaAden = 236, + AsiaAlmaty = 237, + AsiaAmman = 238, + AsiaAnadyr = 239, + AsiaAqtau = 240, + AsiaAqtobe = 241, + AsiaAshgabat = 242, + AsiaAshkhabad = 243, + AsiaAtyrau = 244, + AsiaBaghdad = 245, + AsiaBahrain = 246, + AsiaBaku = 247, + AsiaBangkok = 248, + AsiaBarnaul = 249, + AsiaBeirut = 250, + AsiaBishkek = 251, + AsiaBrunei = 252, + AsiaCalcutta = 253, + AsiaChita = 254, + AsiaChoibalsan = 255, + AsiaChongqing = 256, + AsiaChungking = 257, + AsiaColombo = 258, + AsiaDacca = 259, + AsiaDamascus = 260, + AsiaDhaka = 261, + AsiaDili = 262, + AsiaDubai = 263, + AsiaDushanbe = 264, + AsiaFamagusta = 265, + AsiaGaza = 266, + AsiaHarbin = 267, + AsiaHebron = 268, + AsiaHoChiMinh = 269, + AsiaHongKong = 270, + AsiaHovd = 271, + AsiaIrkutsk = 272, + AsiaIstanbul = 273, + AsiaJakarta = 274, + AsiaJayapura = 275, + AsiaJerusalem = 276, + AsiaKabul = 277, + AsiaKamchatka = 278, + AsiaKarachi = 279, + AsiaKashgar = 280, + AsiaKathmandu = 281, + AsiaKatmandu = 282, + AsiaKhandyga = 283, + AsiaKolkata = 284, + AsiaKrasnoyarsk = 285, + AsiaKualaLumpur = 286, + AsiaKuching = 287, + AsiaKuwait = 288, + AsiaMacao = 289, + AsiaMacau = 290, + AsiaMagadan = 291, + AsiaMakassar = 292, + AsiaManila = 293, + AsiaMuscat = 294, + AsiaNicosia = 295, + AsiaNovokuznetsk = 296, + AsiaNovosibirsk = 297, + AsiaOmsk = 298, + AsiaOral = 299, + AsiaPhnomPenh = 300, + AsiaPontianak = 301, + AsiaPyongyang = 302, + AsiaQatar = 303, + AsiaQostanay = 304, + AsiaQyzylorda = 305, + AsiaRangoon = 306, + AsiaRiyadh = 307, + AsiaSaigon = 308, + AsiaSakhalin = 309, + AsiaSamarkand = 310, + AsiaSeoul = 311, + AsiaShanghai = 312, + AsiaSingapore = 313, + AsiaSrednekolymsk = 314, + AsiaTaipei = 315, + AsiaTashkent = 316, + AsiaTbilisi = 317, + AsiaTehran = 318, + AsiaTelAviv = 319, + AsiaThimbu = 320, + AsiaThimphu = 321, + AsiaTokyo = 322, + AsiaTomsk = 323, + AsiaUjungPandang = 324, + AsiaUlaanbaatar = 325, + AsiaUlanBator = 326, + AsiaUrumqi = 327, + AsiaUstNera = 328, + AsiaVientiane = 329, + AsiaVladivostok = 330, + AsiaYakutsk = 331, + AsiaYangon = 332, + AsiaYekaterinburg = 333, + AsiaYerevan = 334, + AtlanticAzores = 335, + AtlanticBermuda = 336, + AtlanticCanary = 337, + AtlanticCapeVerde = 338, + AtlanticFaeroe = 339, + AtlanticFaroe = 340, + AtlanticJanMayen = 341, + AtlanticMadeira = 342, + AtlanticReykjavik = 343, + AtlanticSouthGeorgia = 344, + AtlanticStHelena = 345, + AtlanticStanley = 346, + AustraliaACT = 347, + AustraliaAdelaide = 348, + AustraliaBrisbane = 349, + AustraliaBrokenHill = 350, + AustraliaCanberra = 351, + AustraliaCurrie = 352, + AustraliaDarwin = 353, + AustraliaEucla = 354, + AustraliaHobart = 355, + AustraliaLHI = 356, + AustraliaLindeman = 357, + AustraliaLordHowe = 358, + AustraliaMelbourne = 359, + AustraliaNSW = 360, + AustraliaNorth = 361, + AustraliaPerth = 362, + AustraliaQueensland = 363, + AustraliaSouth = 364, + AustraliaSydney = 365, + AustraliaTasmania = 366, + AustraliaVictoria = 367, + AustraliaWest = 368, + AustraliaYancowinna = 369, + BrazilAcre = 370, + BrazilDeNoronha = 371, + BrazilEast = 372, + BrazilWest = 373, + CET = 374, + CST6CDT = 375, + CanadaAtlantic = 376, + CanadaCentral = 377, + CanadaEastern = 378, + CanadaMountain = 379, + CanadaNewfoundland = 380, + CanadaPacific = 381, + CanadaSaskatchewan = 382, + CanadaYukon = 383, + ChileContinental = 384, + ChileEasterIsland = 385, + Cuba = 386, + EET = 387, + EST = 388, + EST5EDT = 389, + Egypt = 390, + Eire = 391, + EtcGMT = 392, + EtcGMTPlus0 = 393, + EtcGMTPlus1 = 394, + EtcGMTPlus10 = 395, + EtcGMTPlus11 = 396, + EtcGMTPlus12 = 397, + EtcGMTPlus2 = 398, + EtcGMTPlus3 = 399, + EtcGMTPlus4 = 400, + EtcGMTPlus5 = 401, + EtcGMTPlus6 = 402, + EtcGMTPlus7 = 403, + EtcGMTPlus8 = 404, + EtcGMTPlus9 = 405, + EtcGMTMinus0 = 406, + EtcGMTMinus1 = 407, + EtcGMTMinus10 = 408, + EtcGMTMinus11 = 409, + EtcGMTMinus12 = 410, + EtcGMTMinus13 = 411, + EtcGMTMinus14 = 412, + EtcGMTMinus2 = 413, + EtcGMTMinus3 = 414, + EtcGMTMinus4 = 415, + EtcGMTMinus5 = 416, + EtcGMTMinus6 = 417, + EtcGMTMinus7 = 418, + EtcGMTMinus8 = 419, + EtcGMTMinus9 = 420, + EtcGMT0 = 421, + EtcGreenwich = 422, + EtcUCT = 423, + EtcUTC = 424, + EtcUniversal = 425, + EtcZulu = 426, + EuropeAmsterdam = 427, + EuropeAndorra = 428, + EuropeAstrakhan = 429, + EuropeAthens = 430, + EuropeBelfast = 431, + EuropeBelgrade = 432, + EuropeBerlin = 433, + EuropeBratislava = 434, + EuropeBrussels = 435, + EuropeBucharest = 436, + EuropeBudapest = 437, + EuropeBusingen = 438, + EuropeChisinau = 439, + EuropeCopenhagen = 440, + EuropeDublin = 441, + EuropeGibraltar = 442, + EuropeGuernsey = 443, + EuropeHelsinki = 444, + EuropeIsleOfMan = 445, + EuropeIstanbul = 446, + EuropeJersey = 447, + EuropeKaliningrad = 448, + EuropeKiev = 449, + EuropeKirov = 450, + EuropeKyiv = 451, + EuropeLisbon = 452, + EuropeLjubljana = 453, + EuropeLondon = 454, + EuropeLuxembourg = 455, + EuropeMadrid = 456, + EuropeMalta = 457, + EuropeMariehamn = 458, + EuropeMinsk = 459, + EuropeMonaco = 460, + EuropeMoscow = 461, + EuropeNicosia = 462, + EuropeOslo = 463, + EuropeParis = 464, + EuropePodgorica = 465, + EuropePrague = 466, + EuropeRiga = 467, + EuropeRome = 468, + EuropeSamara = 469, + EuropeSanMarino = 470, + EuropeSarajevo = 471, + EuropeSaratov = 472, + EuropeSimferopol = 473, + EuropeSkopje = 474, + EuropeSofia = 475, + EuropeStockholm = 476, + EuropeTallinn = 477, + EuropeTirane = 478, + EuropeTiraspol = 479, + EuropeUlyanovsk = 480, + EuropeUzhgorod = 481, + EuropeVaduz = 482, + EuropeVatican = 483, + EuropeVienna = 484, + EuropeVilnius = 485, + EuropeVolgograd = 486, + EuropeWarsaw = 487, + EuropeZagreb = 488, + EuropeZaporozhye = 489, + EuropeZurich = 490, + Factory = 491, + GB = 492, + GBEire = 493, + GMT = 494, + GMTPlus0 = 495, + GMTMinus0 = 496, + GMT0 = 497, + Greenwich = 498, + HST = 499, + Hongkong = 500, + Iceland = 501, + IndianAntananarivo = 502, + IndianChagos = 503, + IndianChristmas = 504, + IndianCocos = 505, + IndianComoro = 506, + IndianKerguelen = 507, + IndianMahe = 508, + IndianMaldives = 509, + IndianMauritius = 510, + IndianMayotte = 511, + IndianReunion = 512, + Iran = 513, + Israel = 514, + Jamaica = 515, + Japan = 516, + Kwajalein = 517, + Libya = 518, + MET = 519, + MST = 520, + MST7MDT = 521, + MexicoBajaNorte = 522, + MexicoBajaSur = 523, + MexicoGeneral = 524, + NZ = 525, + NZCHAT = 526, + Navajo = 527, + PRC = 528, + PST8PDT = 529, + PacificApia = 530, + PacificAuckland = 531, + PacificBougainville = 532, + PacificChatham = 533, + PacificChuuk = 534, + PacificEaster = 535, + PacificEfate = 536, + PacificEnderbury = 537, + PacificFakaofo = 538, + PacificFiji = 539, + PacificFunafuti = 540, + PacificGalapagos = 541, + PacificGambier = 542, + PacificGuadalcanal = 543, + PacificGuam = 544, + PacificHonolulu = 545, + PacificJohnston = 546, + PacificKanton = 547, + PacificKiritimati = 548, + PacificKosrae = 549, + PacificKwajalein = 550, + PacificMajuro = 551, + PacificMarquesas = 552, + PacificMidway = 553, + PacificNauru = 554, + PacificNiue = 555, + PacificNorfolk = 556, + PacificNoumea = 557, + PacificPagoPago = 558, + PacificPalau = 559, + PacificPitcairn = 560, + PacificPohnpei = 561, + PacificPonape = 562, + PacificPortMoresby = 563, + PacificRarotonga = 564, + PacificSaipan = 565, + PacificSamoa = 566, + PacificTahiti = 567, + PacificTarawa = 568, + PacificTongatapu = 569, + PacificTruk = 570, + PacificWake = 571, + PacificWallis = 572, + PacificYap = 573, + Poland = 574, + Portugal = 575, + ROC = 576, + ROK = 577, + Singapore = 578, + Turkey = 579, + UCT = 580, + USAlaska = 581, + USAleutian = 582, + USArizona = 583, + USCentral = 584, + USEastIndiana = 585, + USEastern = 586, + USHawaii = 587, + USIndianaStarke = 588, + USMichigan = 589, + USMountain = 590, + USPacific = 591, + USSamoa = 592, + UTC = 593, + Universal = 594, + WSU = 595, + WET = 596, + Zulu = 597, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsCipherSuite { + #[default] + Tls13Aes256GcmSha384 = 0, + Tls13Aes128GcmSha256 = 1, + Tls13Chacha20Poly1305Sha256 = 2, + TlsEcdheEcdsaWithAes256GcmSha384 = 3, + TlsEcdheEcdsaWithAes128GcmSha256 = 4, + TlsEcdheEcdsaWithChacha20Poly1305Sha256 = 5, + TlsEcdheRsaWithAes256GcmSha384 = 6, + TlsEcdheRsaWithAes128GcmSha256 = 7, + TlsEcdheRsaWithChacha20Poly1305Sha256 = 8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsPolicyType { + #[default] + Tlsa = 0, + Sts = 1, + NoPolicyFound = 2, + Other = 3, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsResultType { + #[default] + StartTlsNotSupported = 0, + CertificateHostMismatch = 1, + CertificateExpired = 2, + CertificateNotTrusted = 3, + ValidationFailure = 4, + TlsaInvalid = 5, + DnssecInvalid = 6, + DaneRequired = 7, + StsPolicyFetchError = 8, + StsPolicyInvalid = 9, + StsWebpkiInvalid = 10, + Other = 11, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsVersion { + #[default] + Tls12 = 0, + Tls13 = 1, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TraceValueType { + #[default] + String = 0, + UnsignedInt = 1, + Integer = 2, + Boolean = 3, + Float = 4, + UTCDateTime = 5, + Duration = 6, + IpAddr = 7, + List = 8, + Event = 9, + Null = 10, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TracerType { + #[default] + Log = 0, + Stdout = 1, + Journal = 2, + OtelHttp = 3, + OtelGrpc = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TracingLevel { + #[default] + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, + Trace = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TracingLevelOpt { + #[default] + Disable = 0, + Error = 1, + Warn = 2, + Info = 3, + Debug = 4, + Trace = 5, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TracingStoreType { + #[default] + Disabled = 0, + Default = 1, + FoundationDb = 2, + PostgreSql = 3, + MySql = 4, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TsigAlgorithm { + #[default] + HmacMd5 = 0, + Gss = 1, + HmacSha1 = 2, + HmacSha224 = 3, + HmacSha256 = 4, + HmacSha256128 = 5, + HmacSha384 = 6, + HmacSha384192 = 7, + HmacSha512 = 8, + HmacSha512256 = 9, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum UserRolesType { + #[default] + User = 0, + Admin = 1, + Custom = 2, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum VCardVersion { + #[default] + V4 = 0, + V3 = 1, +} + +pub static HTTP_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemotePort, + ExpressionVariable::LocalIp, + ExpressionVariable::LocalPort, + ExpressionVariable::Protocol, + ExpressionVariable::IsTls, + ExpressionVariable::Url, + ExpressionVariable::Path, + ExpressionVariable::Headers, + ExpressionVariable::Method, +]; + +pub static MTA_CONNECTION_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemotePort, + ExpressionVariable::LocalIp, + ExpressionVariable::LocalPort, + ExpressionVariable::Protocol, + ExpressionVariable::IsTls, + ExpressionVariable::Asn, + ExpressionVariable::Country, +]; + +pub static MTA_EHLO_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemotePort, + ExpressionVariable::LocalIp, + ExpressionVariable::LocalPort, + ExpressionVariable::Protocol, + ExpressionVariable::IsTls, + ExpressionVariable::HeloDomain, + ExpressionVariable::Asn, + ExpressionVariable::Country, +]; + +pub static MTA_MAIL_FROM_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemotePort, + ExpressionVariable::LocalIp, + ExpressionVariable::LocalPort, + ExpressionVariable::Protocol, + ExpressionVariable::IsTls, + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, +]; + +pub static MTA_QUEUE_HOST_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::RcptDomain, + ExpressionVariable::Rcpt, + ExpressionVariable::Recipients, + ExpressionVariable::Mx, + ExpressionVariable::Priority, + ExpressionVariable::RemoteIp, + ExpressionVariable::LocalIp, + ExpressionVariable::RetryNum, + ExpressionVariable::NotifyNum, + ExpressionVariable::ExpiresIn, + ExpressionVariable::LastStatus, + ExpressionVariable::LastError, + ExpressionVariable::QueueName, + ExpressionVariable::QueueAge, + ExpressionVariable::ReceivedFromIp, + ExpressionVariable::ReceivedViaPort, + ExpressionVariable::Source, + ExpressionVariable::Size, +]; + +pub static MTA_QUEUE_RCPT_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Rcpt, + ExpressionVariable::RcptDomain, + ExpressionVariable::Recipients, + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::Priority, + ExpressionVariable::RetryNum, + ExpressionVariable::NotifyNum, + ExpressionVariable::ExpiresIn, + ExpressionVariable::LastStatus, + ExpressionVariable::LastError, + ExpressionVariable::QueueName, + ExpressionVariable::QueueAge, + ExpressionVariable::ReceivedFromIp, + ExpressionVariable::ReceivedViaPort, + ExpressionVariable::Source, + ExpressionVariable::Size, +]; + +pub static MTA_QUEUE_SENDER_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::Priority, + ExpressionVariable::RetryNum, + ExpressionVariable::NotifyNum, + ExpressionVariable::ExpiresIn, + ExpressionVariable::LastStatus, + ExpressionVariable::LastError, +]; + +pub static MTA_RCPT_DOMAIN_VARIABLE: &[ExpressionVariable] = &[ExpressionVariable::RcptDomain]; + +pub static MTA_RCPT_TO_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::Recipients, + ExpressionVariable::Rcpt, + ExpressionVariable::RcptDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemotePort, + ExpressionVariable::LocalIp, + ExpressionVariable::LocalPort, + ExpressionVariable::Protocol, + ExpressionVariable::IsTls, + ExpressionVariable::Priority, + ExpressionVariable::HeloDomain, + ExpressionVariable::Asn, + ExpressionVariable::Country, +]; + +pub static MTA_RCPT_VARIABLE: &[ExpressionVariable] = &[ExpressionVariable::Rcpt]; + +pub static SPAM_DEFAULT_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static SPAM_EMAIL_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Email, + ExpressionVariable::Value, + ExpressionVariable::Name, + ExpressionVariable::Local, + ExpressionVariable::Domain, + ExpressionVariable::Sld, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static SPAM_GENERIC_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Value, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static SPAM_HEADER_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Name, + ExpressionVariable::NameLower, + ExpressionVariable::Value, + ExpressionVariable::ValueLower, + ExpressionVariable::Attributes, + ExpressionVariable::Raw, + ExpressionVariable::RawLower, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static SPAM_IP_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Ip, + ExpressionVariable::Value, + ExpressionVariable::ReverseIp, + ExpressionVariable::IpReverse, + ExpressionVariable::Octets, + ExpressionVariable::IsV4, + ExpressionVariable::IsV6, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static SPAM_URL_VARIABLE: &[ExpressionVariable] = &[ + ExpressionVariable::Url, + ExpressionVariable::UrlOriginal, + ExpressionVariable::Value, + ExpressionVariable::PathQuery, + ExpressionVariable::Path, + ExpressionVariable::Query, + ExpressionVariable::Scheme, + ExpressionVariable::Authority, + ExpressionVariable::Host, + ExpressionVariable::Sld, + ExpressionVariable::Port, + ExpressionVariable::RemoteIp, + ExpressionVariable::RemoteIpPtr, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Asn, + ExpressionVariable::Country, + ExpressionVariable::IsTls, + ExpressionVariable::EnvFrom, + ExpressionVariable::EnvFromLocal, + ExpressionVariable::EnvFromDomain, + ExpressionVariable::EnvTo, + ExpressionVariable::From, + ExpressionVariable::FromName, + ExpressionVariable::FromLocal, + ExpressionVariable::FromDomain, + ExpressionVariable::ReplyTo, + ExpressionVariable::ReplyToName, + ExpressionVariable::ReplyToLocal, + ExpressionVariable::ReplyToDomain, + ExpressionVariable::To, + ExpressionVariable::ToName, + ExpressionVariable::ToLocal, + ExpressionVariable::ToDomain, + ExpressionVariable::Cc, + ExpressionVariable::CcName, + ExpressionVariable::CcLocal, + ExpressionVariable::CcDomain, + ExpressionVariable::Bcc, + ExpressionVariable::BccName, + ExpressionVariable::BccLocal, + ExpressionVariable::BccDomain, + ExpressionVariable::Body, + ExpressionVariable::BodyText, + ExpressionVariable::BodyHtml, + ExpressionVariable::BodyWords, + ExpressionVariable::BodyRaw, + ExpressionVariable::Subject, + ExpressionVariable::SubjectThread, + ExpressionVariable::SubjectWords, + ExpressionVariable::Location, +]; + +pub static MTA_AGGREGATE_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Hourly, + ExpressionConstant::Daily, + ExpressionConstant::Weekly, + ExpressionConstant::Disable, +]; + +pub static MTA_AUTH_TYPE_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Login, + ExpressionConstant::Plain, + ExpressionConstant::Xoauth2, + ExpressionConstant::Oauthbearer, +]; + +pub static MTA_IP_STRATEGY_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Ipv4Only, + ExpressionConstant::Ipv6Only, + ExpressionConstant::Ipv6ThenIpv4, + ExpressionConstant::Ipv4ThenIpv6, +]; + +pub static MTA_PRIORITY_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Mixer, + ExpressionConstant::Stanag4406, + ExpressionConstant::Nsep, +]; + +pub static MTA_REQUIRE_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Optional, + ExpressionConstant::Require, + ExpressionConstant::Disable, +]; + +pub static MTA_VERIFY_CONSTANT: &[ExpressionConstant] = &[ + ExpressionConstant::Relaxed, + ExpressionConstant::Strict, + ExpressionConstant::Disable, +]; diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs new file mode 100644 index 0000000..bcfa372 --- /dev/null +++ b/crates/registry/src/schema/enums_impl.rs @@ -0,0 +1,14555 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::schema::prelude::*; + +impl EnumImpl for AccountType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"User" => AccountType::User, + b"Group" => AccountType::Group, + } + } + + fn as_str(&self) -> &'static str { + match self { + AccountType::User => "User", + AccountType::Group => "Group", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AccountType::User), + 1 => Some(AccountType::Group), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for AccountType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AccountType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AcmeChallengeType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"TlsAlpn01" => AcmeChallengeType::TlsAlpn01, + b"DnsPersist01" => AcmeChallengeType::DnsPersist01, + b"Dns01" => AcmeChallengeType::Dns01, + b"Http01" => AcmeChallengeType::Http01, + } + } + + fn as_str(&self) -> &'static str { + match self { + AcmeChallengeType::TlsAlpn01 => "TlsAlpn01", + AcmeChallengeType::DnsPersist01 => "DnsPersist01", + AcmeChallengeType::Dns01 => "Dns01", + AcmeChallengeType::Http01 => "Http01", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AcmeChallengeType::TlsAlpn01), + 1 => Some(AcmeChallengeType::DnsPersist01), + 2 => Some(AcmeChallengeType::Dns01), + 3 => Some(AcmeChallengeType::Http01), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for AcmeChallengeType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AcmeChallengeType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AcmeRenewBefore { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"R12" => AcmeRenewBefore::R12, + b"R23" => AcmeRenewBefore::R23, + b"R34" => AcmeRenewBefore::R34, + b"R45" => AcmeRenewBefore::R45, + } + } + + fn as_str(&self) -> &'static str { + match self { + AcmeRenewBefore::R12 => "R12", + AcmeRenewBefore::R23 => "R23", + AcmeRenewBefore::R34 => "R34", + AcmeRenewBefore::R45 => "R45", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AcmeRenewBefore::R12), + 1 => Some(AcmeRenewBefore::R23), + 2 => Some(AcmeRenewBefore::R34), + 3 => Some(AcmeRenewBefore::R45), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for AcmeRenewBefore { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AcmeRenewBefore { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ActionType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"ReloadSettings" => ActionType::ReloadSettings, + b"ReloadTlsCertificates" => ActionType::ReloadTlsCertificates, + b"ReloadLookupStores" => ActionType::ReloadLookupStores, + b"ReloadBlockedIps" => ActionType::ReloadBlockedIps, + b"UpdateApps" => ActionType::UpdateApps, + b"TroubleshootDmarc" => ActionType::TroubleshootDmarc, + b"ClassifySpam" => ActionType::ClassifySpam, + b"InvalidateCaches" => ActionType::InvalidateCaches, + b"InvalidateNegativeCaches" => ActionType::InvalidateNegativeCaches, + b"PauseMtaQueue" => ActionType::PauseMtaQueue, + b"ResumeMtaQueue" => ActionType::ResumeMtaQueue, + } + } + + fn as_str(&self) -> &'static str { + match self { + ActionType::ReloadSettings => "ReloadSettings", + ActionType::ReloadTlsCertificates => "ReloadTlsCertificates", + ActionType::ReloadLookupStores => "ReloadLookupStores", + ActionType::ReloadBlockedIps => "ReloadBlockedIps", + ActionType::UpdateApps => "UpdateApps", + ActionType::TroubleshootDmarc => "TroubleshootDmarc", + ActionType::ClassifySpam => "ClassifySpam", + ActionType::InvalidateCaches => "InvalidateCaches", + ActionType::InvalidateNegativeCaches => "InvalidateNegativeCaches", + ActionType::PauseMtaQueue => "PauseMtaQueue", + ActionType::ResumeMtaQueue => "ResumeMtaQueue", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ActionType::ReloadSettings), + 1 => Some(ActionType::ReloadTlsCertificates), + 2 => Some(ActionType::ReloadLookupStores), + 3 => Some(ActionType::ReloadBlockedIps), + 4 => Some(ActionType::UpdateApps), + 5 => Some(ActionType::TroubleshootDmarc), + 6 => Some(ActionType::ClassifySpam), + 7 => Some(ActionType::InvalidateCaches), + 8 => Some(ActionType::InvalidateNegativeCaches), + 9 => Some(ActionType::PauseMtaQueue), + 10 => Some(ActionType::ResumeMtaQueue), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for ActionType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ActionType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AiModelType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Chat" => AiModelType::Chat, + b"Text" => AiModelType::Text, + } + } + + fn as_str(&self) -> &'static str { + match self { + AiModelType::Chat => "Chat", + AiModelType::Text => "Text", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AiModelType::Chat), + 1 => Some(AiModelType::Text), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for AiModelType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AiModelType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AlertEmailType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => AlertEmailType::Disabled, + b"Enabled" => AlertEmailType::Enabled, + } + } + + fn as_str(&self) -> &'static str { + match self { + AlertEmailType::Disabled => "Disabled", + AlertEmailType::Enabled => "Enabled", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AlertEmailType::Disabled), + 1 => Some(AlertEmailType::Enabled), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for AlertEmailType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AlertEmailType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AlertEventType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => AlertEventType::Disabled, + b"Enabled" => AlertEventType::Enabled, + } + } + + fn as_str(&self) -> &'static str { + match self { + AlertEventType::Disabled => "Disabled", + AlertEventType::Enabled => "Enabled", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AlertEventType::Disabled), + 1 => Some(AlertEventType::Enabled), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for AlertEventType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AlertEventType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArchivedItemStatus { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"archived" => ArchivedItemStatus::Archived, + b"requestRestore" => ArchivedItemStatus::RequestRestore, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArchivedItemStatus::Archived => "archived", + ArchivedItemStatus::RequestRestore => "requestRestore", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArchivedItemStatus::Archived), + 1 => Some(ArchivedItemStatus::RequestRestore), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for ArchivedItemStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArchivedItemStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArchivedItemType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Email" => ArchivedItemType::Email, + b"FileNode" => ArchivedItemType::FileNode, + b"CalendarEvent" => ArchivedItemType::CalendarEvent, + b"ContactCard" => ArchivedItemType::ContactCard, + b"SieveScript" => ArchivedItemType::SieveScript, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArchivedItemType::Email => "Email", + ArchivedItemType::FileNode => "FileNode", + ArchivedItemType::CalendarEvent => "CalendarEvent", + ArchivedItemType::ContactCard => "ContactCard", + ArchivedItemType::SieveScript => "SieveScript", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArchivedItemType::Email), + 1 => Some(ArchivedItemType::FileNode), + 2 => Some(ArchivedItemType::CalendarEvent), + 3 => Some(ArchivedItemType::ContactCard), + 4 => Some(ArchivedItemType::SieveScript), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for ArchivedItemType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArchivedItemType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArfAuthFailureType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"adsp" => ArfAuthFailureType::Adsp, + b"bodyHash" => ArfAuthFailureType::BodyHash, + b"revoked" => ArfAuthFailureType::Revoked, + b"signature" => ArfAuthFailureType::Signature, + b"spf" => ArfAuthFailureType::Spf, + b"dmarc" => ArfAuthFailureType::Dmarc, + b"unspecified" => ArfAuthFailureType::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArfAuthFailureType::Adsp => "adsp", + ArfAuthFailureType::BodyHash => "bodyHash", + ArfAuthFailureType::Revoked => "revoked", + ArfAuthFailureType::Signature => "signature", + ArfAuthFailureType::Spf => "spf", + ArfAuthFailureType::Dmarc => "dmarc", + ArfAuthFailureType::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArfAuthFailureType::Adsp), + 1 => Some(ArfAuthFailureType::BodyHash), + 2 => Some(ArfAuthFailureType::Revoked), + 3 => Some(ArfAuthFailureType::Signature), + 4 => Some(ArfAuthFailureType::Spf), + 5 => Some(ArfAuthFailureType::Dmarc), + 6 => Some(ArfAuthFailureType::Unspecified), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for ArfAuthFailureType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArfAuthFailureType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArfDeliveryResult { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"delivered" => ArfDeliveryResult::Delivered, + b"spam" => ArfDeliveryResult::Spam, + b"policy" => ArfDeliveryResult::Policy, + b"reject" => ArfDeliveryResult::Reject, + b"other" => ArfDeliveryResult::Other, + b"unspecified" => ArfDeliveryResult::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArfDeliveryResult::Delivered => "delivered", + ArfDeliveryResult::Spam => "spam", + ArfDeliveryResult::Policy => "policy", + ArfDeliveryResult::Reject => "reject", + ArfDeliveryResult::Other => "other", + ArfDeliveryResult::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArfDeliveryResult::Delivered), + 1 => Some(ArfDeliveryResult::Spam), + 2 => Some(ArfDeliveryResult::Policy), + 3 => Some(ArfDeliveryResult::Reject), + 4 => Some(ArfDeliveryResult::Other), + 5 => Some(ArfDeliveryResult::Unspecified), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for ArfDeliveryResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArfDeliveryResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArfFeedbackType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"abuse" => ArfFeedbackType::Abuse, + b"authFailure" => ArfFeedbackType::AuthFailure, + b"fraud" => ArfFeedbackType::Fraud, + b"notSpam" => ArfFeedbackType::NotSpam, + b"virus" => ArfFeedbackType::Virus, + b"other" => ArfFeedbackType::Other, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArfFeedbackType::Abuse => "abuse", + ArfFeedbackType::AuthFailure => "authFailure", + ArfFeedbackType::Fraud => "fraud", + ArfFeedbackType::NotSpam => "notSpam", + ArfFeedbackType::Virus => "virus", + ArfFeedbackType::Other => "other", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArfFeedbackType::Abuse), + 1 => Some(ArfFeedbackType::AuthFailure), + 2 => Some(ArfFeedbackType::Fraud), + 3 => Some(ArfFeedbackType::NotSpam), + 4 => Some(ArfFeedbackType::Virus), + 5 => Some(ArfFeedbackType::Other), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for ArfFeedbackType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArfFeedbackType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ArfIdentityAlignment { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"none" => ArfIdentityAlignment::None, + b"spf" => ArfIdentityAlignment::Spf, + b"dkim" => ArfIdentityAlignment::Dkim, + b"dkimSpf" => ArfIdentityAlignment::DkimSpf, + b"unspecified" => ArfIdentityAlignment::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + ArfIdentityAlignment::None => "none", + ArfIdentityAlignment::Spf => "spf", + ArfIdentityAlignment::Dkim => "dkim", + ArfIdentityAlignment::DkimSpf => "dkimSpf", + ArfIdentityAlignment::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ArfIdentityAlignment::None), + 1 => Some(ArfIdentityAlignment::Spf), + 2 => Some(ArfIdentityAlignment::Dkim), + 3 => Some(ArfIdentityAlignment::DkimSpf), + 4 => Some(ArfIdentityAlignment::Unspecified), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for ArfIdentityAlignment { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ArfIdentityAlignment { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AsnType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => AsnType::Disabled, + b"Resource" => AsnType::Resource, + b"Dns" => AsnType::Dns, + } + } + + fn as_str(&self) -> &'static str { + match self { + AsnType::Disabled => "Disabled", + AsnType::Resource => "Resource", + AsnType::Dns => "Dns", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AsnType::Disabled), + 1 => Some(AsnType::Resource), + 2 => Some(AsnType::Dns), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for AsnType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AsnType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for AzureEnvironment { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"public" => AzureEnvironment::Public, + b"china" => AzureEnvironment::China, + b"us-government" => AzureEnvironment::UsGovernment, + } + } + + fn as_str(&self) -> &'static str { + match self { + AzureEnvironment::Public => "public", + AzureEnvironment::China => "china", + AzureEnvironment::UsGovernment => "us-government", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(AzureEnvironment::Public), + 1 => Some(AzureEnvironment::China), + 2 => Some(AzureEnvironment::UsGovernment), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for AzureEnvironment { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for AzureEnvironment { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for BlobStoreBaseType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"S3" => BlobStoreBaseType::S3, + b"Azure" => BlobStoreBaseType::Azure, + b"FileSystem" => BlobStoreBaseType::FileSystem, + b"FoundationDb" => BlobStoreBaseType::FoundationDb, + b"PostgreSql" => BlobStoreBaseType::PostgreSql, + b"MySql" => BlobStoreBaseType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + BlobStoreBaseType::S3 => "S3", + BlobStoreBaseType::Azure => "Azure", + BlobStoreBaseType::FileSystem => "FileSystem", + BlobStoreBaseType::FoundationDb => "FoundationDb", + BlobStoreBaseType::PostgreSql => "PostgreSql", + BlobStoreBaseType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(BlobStoreBaseType::S3), + 1 => Some(BlobStoreBaseType::Azure), + 2 => Some(BlobStoreBaseType::FileSystem), + 3 => Some(BlobStoreBaseType::FoundationDb), + 4 => Some(BlobStoreBaseType::PostgreSql), + 5 => Some(BlobStoreBaseType::MySql), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for BlobStoreBaseType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for BlobStoreBaseType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for BlobStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => BlobStoreType::Default, + b"Sharded" => BlobStoreType::Sharded, + b"S3" => BlobStoreType::S3, + b"Azure" => BlobStoreType::Azure, + b"FileSystem" => BlobStoreType::FileSystem, + b"FoundationDb" => BlobStoreType::FoundationDb, + b"PostgreSql" => BlobStoreType::PostgreSql, + b"MySql" => BlobStoreType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + BlobStoreType::Default => "Default", + BlobStoreType::Sharded => "Sharded", + BlobStoreType::S3 => "S3", + BlobStoreType::Azure => "Azure", + BlobStoreType::FileSystem => "FileSystem", + BlobStoreType::FoundationDb => "FoundationDb", + BlobStoreType::PostgreSql => "PostgreSql", + BlobStoreType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(BlobStoreType::Default), + 1 => Some(BlobStoreType::Sharded), + 2 => Some(BlobStoreType::S3), + 3 => Some(BlobStoreType::Azure), + 4 => Some(BlobStoreType::FileSystem), + 5 => Some(BlobStoreType::FoundationDb), + 6 => Some(BlobStoreType::PostgreSql), + 7 => Some(BlobStoreType::MySql), + _ => None, + } + } + + const COUNT: usize = 8; +} + +impl serde::Serialize for BlobStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for BlobStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for BlockReason { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"rcptToFailure" => BlockReason::RcptToFailure, + b"authFailure" => BlockReason::AuthFailure, + b"loitering" => BlockReason::Loitering, + b"portScanning" => BlockReason::PortScanning, + b"manual" => BlockReason::Manual, + b"other" => BlockReason::Other, + } + } + + fn as_str(&self) -> &'static str { + match self { + BlockReason::RcptToFailure => "rcptToFailure", + BlockReason::AuthFailure => "authFailure", + BlockReason::Loitering => "loitering", + BlockReason::PortScanning => "portScanning", + BlockReason::Manual => "manual", + BlockReason::Other => "other", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(BlockReason::RcptToFailure), + 1 => Some(BlockReason::AuthFailure), + 2 => Some(BlockReason::Loitering), + 3 => Some(BlockReason::PortScanning), + 4 => Some(BlockReason::Manual), + 5 => Some(BlockReason::Other), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for BlockReason { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for BlockReason { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CertificateManagementType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Manual" => CertificateManagementType::Manual, + b"Automatic" => CertificateManagementType::Automatic, + } + } + + fn as_str(&self) -> &'static str { + match self { + CertificateManagementType::Manual => "Manual", + CertificateManagementType::Automatic => "Automatic", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CertificateManagementType::Manual), + 1 => Some(CertificateManagementType::Automatic), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for CertificateManagementType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CertificateManagementType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ClusterListenerGroupType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"EnableAll" => ClusterListenerGroupType::EnableAll, + b"DisableAll" => ClusterListenerGroupType::DisableAll, + b"EnableSome" => ClusterListenerGroupType::EnableSome, + b"DisableSome" => ClusterListenerGroupType::DisableSome, + } + } + + fn as_str(&self) -> &'static str { + match self { + ClusterListenerGroupType::EnableAll => "EnableAll", + ClusterListenerGroupType::DisableAll => "DisableAll", + ClusterListenerGroupType::EnableSome => "EnableSome", + ClusterListenerGroupType::DisableSome => "DisableSome", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ClusterListenerGroupType::EnableAll), + 1 => Some(ClusterListenerGroupType::DisableAll), + 2 => Some(ClusterListenerGroupType::EnableSome), + 3 => Some(ClusterListenerGroupType::DisableSome), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for ClusterListenerGroupType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ClusterListenerGroupType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ClusterNodeStatus { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"active" => ClusterNodeStatus::Active, + b"stale" => ClusterNodeStatus::Stale, + b"inactive" => ClusterNodeStatus::Inactive, + } + } + + fn as_str(&self) -> &'static str { + match self { + ClusterNodeStatus::Active => "active", + ClusterNodeStatus::Stale => "stale", + ClusterNodeStatus::Inactive => "inactive", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ClusterNodeStatus::Active), + 1 => Some(ClusterNodeStatus::Stale), + 2 => Some(ClusterNodeStatus::Inactive), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for ClusterNodeStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ClusterNodeStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ClusterTaskGroupType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"EnableAll" => ClusterTaskGroupType::EnableAll, + b"DisableAll" => ClusterTaskGroupType::DisableAll, + b"EnableSome" => ClusterTaskGroupType::EnableSome, + b"DisableSome" => ClusterTaskGroupType::DisableSome, + } + } + + fn as_str(&self) -> &'static str { + match self { + ClusterTaskGroupType::EnableAll => "EnableAll", + ClusterTaskGroupType::DisableAll => "DisableAll", + ClusterTaskGroupType::EnableSome => "EnableSome", + ClusterTaskGroupType::DisableSome => "DisableSome", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ClusterTaskGroupType::EnableAll), + 1 => Some(ClusterTaskGroupType::DisableAll), + 2 => Some(ClusterTaskGroupType::EnableSome), + 3 => Some(ClusterTaskGroupType::DisableSome), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for ClusterTaskGroupType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ClusterTaskGroupType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ClusterTaskType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"storeMaintenance" => ClusterTaskType::StoreMaintenance, + b"accountMaintenance" => ClusterTaskType::AccountMaintenance, + b"metricsCalculate" => ClusterTaskType::MetricsCalculate, + b"metricsPush" => ClusterTaskType::MetricsPush, + b"pushNotifications" => ClusterTaskType::PushNotifications, + b"searchIndexing" => ClusterTaskType::SearchIndexing, + b"spamClassifierTraining" => ClusterTaskType::SpamClassifierTraining, + b"outboundMta" => ClusterTaskType::OutboundMta, + b"taskQueueProcessing" => ClusterTaskType::TaskQueueProcessing, + b"taskScheduler" => ClusterTaskType::TaskScheduler, + } + } + + fn as_str(&self) -> &'static str { + match self { + ClusterTaskType::StoreMaintenance => "storeMaintenance", + ClusterTaskType::AccountMaintenance => "accountMaintenance", + ClusterTaskType::MetricsCalculate => "metricsCalculate", + ClusterTaskType::MetricsPush => "metricsPush", + ClusterTaskType::PushNotifications => "pushNotifications", + ClusterTaskType::SearchIndexing => "searchIndexing", + ClusterTaskType::SpamClassifierTraining => "spamClassifierTraining", + ClusterTaskType::OutboundMta => "outboundMta", + ClusterTaskType::TaskQueueProcessing => "taskQueueProcessing", + ClusterTaskType::TaskScheduler => "taskScheduler", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ClusterTaskType::StoreMaintenance), + 1 => Some(ClusterTaskType::AccountMaintenance), + 2 => Some(ClusterTaskType::MetricsCalculate), + 3 => Some(ClusterTaskType::MetricsPush), + 4 => Some(ClusterTaskType::PushNotifications), + 5 => Some(ClusterTaskType::SearchIndexing), + 6 => Some(ClusterTaskType::SpamClassifierTraining), + 7 => Some(ClusterTaskType::OutboundMta), + 8 => Some(ClusterTaskType::TaskQueueProcessing), + 9 => Some(ClusterTaskType::TaskScheduler), + _ => None, + } + } + + const COUNT: usize = 10; +} + +impl serde::Serialize for ClusterTaskType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ClusterTaskType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CompressionAlgo { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"lz4" => CompressionAlgo::Lz4, + b"none" => CompressionAlgo::None, + } + } + + fn as_str(&self) -> &'static str { + match self { + CompressionAlgo::Lz4 => "lz4", + CompressionAlgo::None => "none", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CompressionAlgo::Lz4), + 1 => Some(CompressionAlgo::None), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for CompressionAlgo { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CompressionAlgo { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CoordinatorType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => CoordinatorType::Disabled, + b"Default" => CoordinatorType::Default, + b"Kafka" => CoordinatorType::Kafka, + b"Nats" => CoordinatorType::Nats, + b"Zenoh" => CoordinatorType::Zenoh, + b"Redis" => CoordinatorType::Redis, + b"RedisCluster" => CoordinatorType::RedisCluster, + b"RedisSentinel" => CoordinatorType::RedisSentinel, + } + } + + fn as_str(&self) -> &'static str { + match self { + CoordinatorType::Disabled => "Disabled", + CoordinatorType::Default => "Default", + CoordinatorType::Kafka => "Kafka", + CoordinatorType::Nats => "Nats", + CoordinatorType::Zenoh => "Zenoh", + CoordinatorType::Redis => "Redis", + CoordinatorType::RedisCluster => "RedisCluster", + CoordinatorType::RedisSentinel => "RedisSentinel", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CoordinatorType::Disabled), + 1 => Some(CoordinatorType::Default), + 2 => Some(CoordinatorType::Kafka), + 3 => Some(CoordinatorType::Nats), + 4 => Some(CoordinatorType::Zenoh), + 5 => Some(CoordinatorType::Redis), + 6 => Some(CoordinatorType::RedisCluster), + 7 => Some(CoordinatorType::RedisSentinel), + _ => None, + } + } + + const COUNT: usize = 8; +} + +impl serde::Serialize for CoordinatorType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CoordinatorType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CredentialPermissionsType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Inherit" => CredentialPermissionsType::Inherit, + b"Disable" => CredentialPermissionsType::Disable, + b"Replace" => CredentialPermissionsType::Replace, + } + } + + fn as_str(&self) -> &'static str { + match self { + CredentialPermissionsType::Inherit => "Inherit", + CredentialPermissionsType::Disable => "Disable", + CredentialPermissionsType::Replace => "Replace", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CredentialPermissionsType::Inherit), + 1 => Some(CredentialPermissionsType::Disable), + 2 => Some(CredentialPermissionsType::Replace), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for CredentialPermissionsType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CredentialPermissionsType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CredentialType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Password" => CredentialType::Password, + b"AppPassword" => CredentialType::AppPassword, + b"ApiKey" => CredentialType::ApiKey, + } + } + + fn as_str(&self) -> &'static str { + match self { + CredentialType::Password => "Password", + CredentialType::AppPassword => "AppPassword", + CredentialType::ApiKey => "ApiKey", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CredentialType::Password), + 1 => Some(CredentialType::AppPassword), + 2 => Some(CredentialType::ApiKey), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for CredentialType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CredentialType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for CronType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Daily" => CronType::Daily, + b"Weekly" => CronType::Weekly, + b"Hourly" => CronType::Hourly, + } + } + + fn as_str(&self) -> &'static str { + match self { + CronType::Daily => "Daily", + CronType::Weekly => "Weekly", + CronType::Hourly => "Hourly", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(CronType::Daily), + 1 => Some(CronType::Weekly), + 2 => Some(CronType::Hourly), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for CronType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for CronType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DataStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"RocksDb" => DataStoreType::RocksDb, + b"Sqlite" => DataStoreType::Sqlite, + b"FoundationDb" => DataStoreType::FoundationDb, + b"PostgreSql" => DataStoreType::PostgreSql, + b"MySql" => DataStoreType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + DataStoreType::RocksDb => "RocksDb", + DataStoreType::Sqlite => "Sqlite", + DataStoreType::FoundationDb => "FoundationDb", + DataStoreType::PostgreSql => "PostgreSql", + DataStoreType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DataStoreType::RocksDb), + 1 => Some(DataStoreType::Sqlite), + 2 => Some(DataStoreType::FoundationDb), + 3 => Some(DataStoreType::PostgreSql), + 4 => Some(DataStoreType::MySql), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for DataStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DataStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DeliveryErrorType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"dnsError" => DeliveryErrorType::DnsError, + b"unexpectedResponse" => DeliveryErrorType::UnexpectedResponse, + b"connectionError" => DeliveryErrorType::ConnectionError, + b"tlsError" => DeliveryErrorType::TlsError, + b"daneError" => DeliveryErrorType::DaneError, + b"mtaStsError" => DeliveryErrorType::MtaStsError, + b"rateLimited" => DeliveryErrorType::RateLimited, + b"concurrencyLimited" => DeliveryErrorType::ConcurrencyLimited, + b"io" => DeliveryErrorType::Io, + } + } + + fn as_str(&self) -> &'static str { + match self { + DeliveryErrorType::DnsError => "dnsError", + DeliveryErrorType::UnexpectedResponse => "unexpectedResponse", + DeliveryErrorType::ConnectionError => "connectionError", + DeliveryErrorType::TlsError => "tlsError", + DeliveryErrorType::DaneError => "daneError", + DeliveryErrorType::MtaStsError => "mtaStsError", + DeliveryErrorType::RateLimited => "rateLimited", + DeliveryErrorType::ConcurrencyLimited => "concurrencyLimited", + DeliveryErrorType::Io => "io", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DeliveryErrorType::DnsError), + 1 => Some(DeliveryErrorType::UnexpectedResponse), + 2 => Some(DeliveryErrorType::ConnectionError), + 3 => Some(DeliveryErrorType::TlsError), + 4 => Some(DeliveryErrorType::DaneError), + 5 => Some(DeliveryErrorType::MtaStsError), + 6 => Some(DeliveryErrorType::RateLimited), + 7 => Some(DeliveryErrorType::ConcurrencyLimited), + 8 => Some(DeliveryErrorType::Io), + _ => None, + } + } + + const COUNT: usize = 9; +} + +impl serde::Serialize for DeliveryErrorType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DeliveryErrorType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DirectoryBootstrapType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Internal" => DirectoryBootstrapType::Internal, + b"Ldap" => DirectoryBootstrapType::Ldap, + b"Sql" => DirectoryBootstrapType::Sql, + b"Oidc" => DirectoryBootstrapType::Oidc, + } + } + + fn as_str(&self) -> &'static str { + match self { + DirectoryBootstrapType::Internal => "Internal", + DirectoryBootstrapType::Ldap => "Ldap", + DirectoryBootstrapType::Sql => "Sql", + DirectoryBootstrapType::Oidc => "Oidc", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DirectoryBootstrapType::Internal), + 1 => Some(DirectoryBootstrapType::Ldap), + 2 => Some(DirectoryBootstrapType::Sql), + 3 => Some(DirectoryBootstrapType::Oidc), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DirectoryBootstrapType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DirectoryBootstrapType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DirectoryType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Ldap" => DirectoryType::Ldap, + b"Sql" => DirectoryType::Sql, + b"Oidc" => DirectoryType::Oidc, + } + } + + fn as_str(&self) -> &'static str { + match self { + DirectoryType::Ldap => "Ldap", + DirectoryType::Sql => "Sql", + DirectoryType::Oidc => "Oidc", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DirectoryType::Ldap), + 1 => Some(DirectoryType::Sql), + 2 => Some(DirectoryType::Oidc), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DirectoryType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DirectoryType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for Dkim2Flag { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"donotmodify" => Dkim2Flag::Donotmodify, + b"donotexplode" => Dkim2Flag::Donotexplode, + b"feedback" => Dkim2Flag::Feedback, + } + } + + fn as_str(&self) -> &'static str { + match self { + Dkim2Flag::Donotmodify => "donotmodify", + Dkim2Flag::Donotexplode => "donotexplode", + Dkim2Flag::Feedback => "feedback", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(Dkim2Flag::Donotmodify), + 1 => Some(Dkim2Flag::Donotexplode), + 2 => Some(Dkim2Flag::Feedback), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for Dkim2Flag { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Dkim2Flag { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimAuthResult { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"none" => DkimAuthResult::None, + b"pass" => DkimAuthResult::Pass, + b"fail" => DkimAuthResult::Fail, + b"policy" => DkimAuthResult::Policy, + b"neutral" => DkimAuthResult::Neutral, + b"tempError" => DkimAuthResult::TempError, + b"permError" => DkimAuthResult::PermError, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimAuthResult::None => "none", + DkimAuthResult::Pass => "pass", + DkimAuthResult::Fail => "fail", + DkimAuthResult::Policy => "policy", + DkimAuthResult::Neutral => "neutral", + DkimAuthResult::TempError => "tempError", + DkimAuthResult::PermError => "permError", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimAuthResult::None), + 1 => Some(DkimAuthResult::Pass), + 2 => Some(DkimAuthResult::Fail), + 3 => Some(DkimAuthResult::Policy), + 4 => Some(DkimAuthResult::Neutral), + 5 => Some(DkimAuthResult::TempError), + 6 => Some(DkimAuthResult::PermError), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for DkimAuthResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimAuthResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimCanonicalization { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"relaxed/relaxed" => DkimCanonicalization::RelaxedRelaxed, + b"simple/simple" => DkimCanonicalization::SimpleSimple, + b"relaxed/simple" => DkimCanonicalization::RelaxedSimple, + b"simple/relaxed" => DkimCanonicalization::SimpleRelaxed, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimCanonicalization::RelaxedRelaxed => "relaxed/relaxed", + DkimCanonicalization::SimpleSimple => "simple/simple", + DkimCanonicalization::RelaxedSimple => "relaxed/simple", + DkimCanonicalization::SimpleRelaxed => "simple/relaxed", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimCanonicalization::RelaxedRelaxed), + 1 => Some(DkimCanonicalization::SimpleSimple), + 2 => Some(DkimCanonicalization::RelaxedSimple), + 3 => Some(DkimCanonicalization::SimpleRelaxed), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DkimCanonicalization { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimCanonicalization { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimHash { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"sha256" => DkimHash::Sha256, + b"sha1" => DkimHash::Sha1, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimHash::Sha256 => "sha256", + DkimHash::Sha1 => "sha1", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimHash::Sha256), + 1 => Some(DkimHash::Sha1), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for DkimHash { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimHash { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimManagementType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Automatic" => DkimManagementType::Automatic, + b"Manual" => DkimManagementType::Manual, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimManagementType::Automatic => "Automatic", + DkimManagementType::Manual => "Manual", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimManagementType::Automatic), + 1 => Some(DkimManagementType::Manual), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for DkimManagementType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimManagementType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimRotationStage { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"active" => DkimRotationStage::Active, + b"pending" => DkimRotationStage::Pending, + b"retiring" => DkimRotationStage::Retiring, + b"retired" => DkimRotationStage::Retired, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimRotationStage::Active => "active", + DkimRotationStage::Pending => "pending", + DkimRotationStage::Retiring => "retiring", + DkimRotationStage::Retired => "retired", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimRotationStage::Active), + 1 => Some(DkimRotationStage::Pending), + 2 => Some(DkimRotationStage::Retiring), + 3 => Some(DkimRotationStage::Retired), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DkimRotationStage { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimRotationStage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DkimSignatureType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Dkim1Ed25519Sha256" => DkimSignatureType::Dkim1Ed25519Sha256, + b"Dkim1RsaSha256" => DkimSignatureType::Dkim1RsaSha256, + b"Dkim2Ed25519Sha256" => DkimSignatureType::Dkim2Ed25519Sha256, + b"Dkim2RsaSha256" => DkimSignatureType::Dkim2RsaSha256, + } + } + + fn as_str(&self) -> &'static str { + match self { + DkimSignatureType::Dkim1Ed25519Sha256 => "Dkim1Ed25519Sha256", + DkimSignatureType::Dkim1RsaSha256 => "Dkim1RsaSha256", + DkimSignatureType::Dkim2Ed25519Sha256 => "Dkim2Ed25519Sha256", + DkimSignatureType::Dkim2RsaSha256 => "Dkim2RsaSha256", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DkimSignatureType::Dkim1Ed25519Sha256), + 1 => Some(DkimSignatureType::Dkim1RsaSha256), + 2 => Some(DkimSignatureType::Dkim2Ed25519Sha256), + 3 => Some(DkimSignatureType::Dkim2RsaSha256), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DkimSignatureType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DkimSignatureType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcActionDisposition { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"none" => DmarcActionDisposition::None, + b"pass" => DmarcActionDisposition::Pass, + b"quarantine" => DmarcActionDisposition::Quarantine, + b"reject" => DmarcActionDisposition::Reject, + b"unspecified" => DmarcActionDisposition::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcActionDisposition::None => "none", + DmarcActionDisposition::Pass => "pass", + DmarcActionDisposition::Quarantine => "quarantine", + DmarcActionDisposition::Reject => "reject", + DmarcActionDisposition::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcActionDisposition::None), + 1 => Some(DmarcActionDisposition::Pass), + 2 => Some(DmarcActionDisposition::Quarantine), + 3 => Some(DmarcActionDisposition::Reject), + 4 => Some(DmarcActionDisposition::Unspecified), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for DmarcActionDisposition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcActionDisposition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcAlignment { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"relaxed" => DmarcAlignment::Relaxed, + b"strict" => DmarcAlignment::Strict, + b"unspecified" => DmarcAlignment::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcAlignment::Relaxed => "relaxed", + DmarcAlignment::Strict => "strict", + DmarcAlignment::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcAlignment::Relaxed), + 1 => Some(DmarcAlignment::Strict), + 2 => Some(DmarcAlignment::Unspecified), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DmarcAlignment { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcAlignment { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcDiscovery { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"psl" => DmarcDiscovery::Psl, + b"treewalk" => DmarcDiscovery::Treewalk, + b"unspecified" => DmarcDiscovery::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcDiscovery::Psl => "psl", + DmarcDiscovery::Treewalk => "treewalk", + DmarcDiscovery::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcDiscovery::Psl), + 1 => Some(DmarcDiscovery::Treewalk), + 2 => Some(DmarcDiscovery::Unspecified), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DmarcDiscovery { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcDiscovery { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcDisposition { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"none" => DmarcDisposition::None, + b"quarantine" => DmarcDisposition::Quarantine, + b"reject" => DmarcDisposition::Reject, + b"unspecified" => DmarcDisposition::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcDisposition::None => "none", + DmarcDisposition::Quarantine => "quarantine", + DmarcDisposition::Reject => "reject", + DmarcDisposition::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcDisposition::None), + 1 => Some(DmarcDisposition::Quarantine), + 2 => Some(DmarcDisposition::Reject), + 3 => Some(DmarcDisposition::Unspecified), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DmarcDisposition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcDisposition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcPolicyOverride { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Forwarded" => DmarcPolicyOverride::Forwarded, + b"SampledOut" => DmarcPolicyOverride::SampledOut, + b"TrustedForwarder" => DmarcPolicyOverride::TrustedForwarder, + b"MailingList" => DmarcPolicyOverride::MailingList, + b"LocalPolicy" => DmarcPolicyOverride::LocalPolicy, + b"Other" => DmarcPolicyOverride::Other, + b"PolicyTestMode" => DmarcPolicyOverride::PolicyTestMode, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcPolicyOverride::Forwarded => "Forwarded", + DmarcPolicyOverride::SampledOut => "SampledOut", + DmarcPolicyOverride::TrustedForwarder => "TrustedForwarder", + DmarcPolicyOverride::MailingList => "MailingList", + DmarcPolicyOverride::LocalPolicy => "LocalPolicy", + DmarcPolicyOverride::Other => "Other", + DmarcPolicyOverride::PolicyTestMode => "PolicyTestMode", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcPolicyOverride::Forwarded), + 1 => Some(DmarcPolicyOverride::SampledOut), + 2 => Some(DmarcPolicyOverride::TrustedForwarder), + 3 => Some(DmarcPolicyOverride::MailingList), + 4 => Some(DmarcPolicyOverride::LocalPolicy), + 5 => Some(DmarcPolicyOverride::Other), + 6 => Some(DmarcPolicyOverride::PolicyTestMode), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for DmarcPolicyOverride { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcPolicyOverride { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcResult { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"pass" => DmarcResult::Pass, + b"fail" => DmarcResult::Fail, + b"unspecified" => DmarcResult::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcResult::Pass => "pass", + DmarcResult::Fail => "fail", + DmarcResult::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcResult::Pass), + 1 => Some(DmarcResult::Fail), + 2 => Some(DmarcResult::Unspecified), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DmarcResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DmarcTroubleshootAuthResultType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Pass" => DmarcTroubleshootAuthResultType::Pass, + b"Fail" => DmarcTroubleshootAuthResultType::Fail, + b"SoftFail" => DmarcTroubleshootAuthResultType::SoftFail, + b"TempError" => DmarcTroubleshootAuthResultType::TempError, + b"PermError" => DmarcTroubleshootAuthResultType::PermError, + b"Neutral" => DmarcTroubleshootAuthResultType::Neutral, + b"None" => DmarcTroubleshootAuthResultType::None, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcTroubleshootAuthResultType::Pass => "Pass", + DmarcTroubleshootAuthResultType::Fail => "Fail", + DmarcTroubleshootAuthResultType::SoftFail => "SoftFail", + DmarcTroubleshootAuthResultType::TempError => "TempError", + DmarcTroubleshootAuthResultType::PermError => "PermError", + DmarcTroubleshootAuthResultType::Neutral => "Neutral", + DmarcTroubleshootAuthResultType::None => "None", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcTroubleshootAuthResultType::Pass), + 1 => Some(DmarcTroubleshootAuthResultType::Fail), + 2 => Some(DmarcTroubleshootAuthResultType::SoftFail), + 3 => Some(DmarcTroubleshootAuthResultType::TempError), + 4 => Some(DmarcTroubleshootAuthResultType::PermError), + 5 => Some(DmarcTroubleshootAuthResultType::Neutral), + 6 => Some(DmarcTroubleshootAuthResultType::None), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for DmarcTroubleshootAuthResultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcTroubleshootAuthResultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsManagementType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Manual" => DnsManagementType::Manual, + b"Automatic" => DnsManagementType::Automatic, + } + } + + fn as_str(&self) -> &'static str { + match self { + DnsManagementType::Manual => "Manual", + DnsManagementType::Automatic => "Automatic", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsManagementType::Manual), + 1 => Some(DnsManagementType::Automatic), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for DnsManagementType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsManagementType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsPublishStatus { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"synced" => DnsPublishStatus::Synced, + b"pending" => DnsPublishStatus::Pending, + b"failed" => DnsPublishStatus::Failed, + b"unknown" => DnsPublishStatus::Unknown, + } + } + + fn as_str(&self) -> &'static str { + match self { + DnsPublishStatus::Synced => "synced", + DnsPublishStatus::Pending => "pending", + DnsPublishStatus::Failed => "failed", + DnsPublishStatus::Unknown => "unknown", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsPublishStatus::Synced), + 1 => Some(DnsPublishStatus::Pending), + 2 => Some(DnsPublishStatus::Failed), + 3 => Some(DnsPublishStatus::Unknown), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for DnsPublishStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsPublishStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsRecordType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"dkim" => DnsRecordType::Dkim, + b"tlsa" => DnsRecordType::Tlsa, + b"spf" => DnsRecordType::Spf, + b"mx" => DnsRecordType::Mx, + b"dmarc" => DnsRecordType::Dmarc, + b"srv" => DnsRecordType::Srv, + b"mtaSts" => DnsRecordType::MtaSts, + b"tlsRpt" => DnsRecordType::TlsRpt, + b"caa" => DnsRecordType::Caa, + b"autoConfig" => DnsRecordType::AutoConfig, + b"autoConfigLegacy" => DnsRecordType::AutoConfigLegacy, + b"autoDiscover" => DnsRecordType::AutoDiscover, + } + } + + fn as_str(&self) -> &'static str { + match self { + DnsRecordType::Dkim => "dkim", + DnsRecordType::Tlsa => "tlsa", + DnsRecordType::Spf => "spf", + DnsRecordType::Mx => "mx", + DnsRecordType::Dmarc => "dmarc", + DnsRecordType::Srv => "srv", + DnsRecordType::MtaSts => "mtaSts", + DnsRecordType::TlsRpt => "tlsRpt", + DnsRecordType::Caa => "caa", + DnsRecordType::AutoConfig => "autoConfig", + DnsRecordType::AutoConfigLegacy => "autoConfigLegacy", + DnsRecordType::AutoDiscover => "autoDiscover", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsRecordType::Dkim), + 1 => Some(DnsRecordType::Tlsa), + 2 => Some(DnsRecordType::Spf), + 3 => Some(DnsRecordType::Mx), + 4 => Some(DnsRecordType::Dmarc), + 5 => Some(DnsRecordType::Srv), + 6 => Some(DnsRecordType::MtaSts), + 7 => Some(DnsRecordType::TlsRpt), + 8 => Some(DnsRecordType::Caa), + 9 => Some(DnsRecordType::AutoConfig), + 10 => Some(DnsRecordType::AutoConfigLegacy), + 11 => Some(DnsRecordType::AutoDiscover), + _ => None, + } + } + + const COUNT: usize = 12; +} + +impl serde::Serialize for DnsRecordType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsRecordType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsResolverProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"tls" => DnsResolverProtocol::Tls, + b"udp" => DnsResolverProtocol::Udp, + b"tcp" => DnsResolverProtocol::Tcp, + } + } + + fn as_str(&self) -> &'static str { + match self { + DnsResolverProtocol::Tls => "tls", + DnsResolverProtocol::Udp => "udp", + DnsResolverProtocol::Tcp => "tcp", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsResolverProtocol::Tls), + 1 => Some(DnsResolverProtocol::Udp), + 2 => Some(DnsResolverProtocol::Tcp), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DnsResolverProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsResolverProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsResolverType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"System" => DnsResolverType::System, + b"Custom" => DnsResolverType::Custom, + b"Cloudflare" => DnsResolverType::Cloudflare, + b"Quad9" => DnsResolverType::Quad9, + b"Google" => DnsResolverType::Google, + } + } + + fn as_str(&self) -> &'static str { + match self { + DnsResolverType::System => "System", + DnsResolverType::Custom => "Custom", + DnsResolverType::Cloudflare => "Cloudflare", + DnsResolverType::Quad9 => "Quad9", + DnsResolverType::Google => "Google", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsResolverType::System), + 1 => Some(DnsResolverType::Custom), + 2 => Some(DnsResolverType::Cloudflare), + 3 => Some(DnsResolverType::Quad9), + 4 => Some(DnsResolverType::Google), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for DnsResolverType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsResolverType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsServerBootstrapType { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + DnsServerBootstrapType, + b"Manual" => DnsServerBootstrapType::Manual, + b"Tsig" => DnsServerBootstrapType::Tsig, + b"Deprecated1" => DnsServerBootstrapType::Deprecated1, + b"Cloudflare" => DnsServerBootstrapType::Cloudflare, + b"DigitalOcean" => DnsServerBootstrapType::DigitalOcean, + b"DeSEC" => DnsServerBootstrapType::DeSEC, + b"Ovh" => DnsServerBootstrapType::Ovh, + b"Bunny" => DnsServerBootstrapType::Bunny, + b"Porkbun" => DnsServerBootstrapType::Porkbun, + b"Dnsimple" => DnsServerBootstrapType::Dnsimple, + b"Spaceship" => DnsServerBootstrapType::Spaceship, + b"Route53" => DnsServerBootstrapType::Route53, + b"GoogleCloudDns" => DnsServerBootstrapType::GoogleCloudDns, + b"Alidns" => DnsServerBootstrapType::Alidns, + b"ArvanCloud" => DnsServerBootstrapType::ArvanCloud, + b"Autodns" => DnsServerBootstrapType::Autodns, + b"AzureDns" => DnsServerBootstrapType::AzureDns, + b"BaiduCloud" => DnsServerBootstrapType::BaiduCloud, + b"BluecatV2" => DnsServerBootstrapType::BluecatV2, + b"ClouDns" => DnsServerBootstrapType::ClouDns, + b"Constellix" => DnsServerBootstrapType::Constellix, + b"Cpanel" => DnsServerBootstrapType::Cpanel, + b"Ddnss" => DnsServerBootstrapType::Ddnss, + b"DnsMadeEasy" => DnsServerBootstrapType::DnsMadeEasy, + b"Domeneshop" => DnsServerBootstrapType::Domeneshop, + b"Dreamhost" => DnsServerBootstrapType::Dreamhost, + b"DuckDns" => DnsServerBootstrapType::DuckDns, + b"Dynu" => DnsServerBootstrapType::Dynu, + b"EasyDns" => DnsServerBootstrapType::EasyDns, + b"EdgeDns" => DnsServerBootstrapType::EdgeDns, + b"Exoscale" => DnsServerBootstrapType::Exoscale, + b"FreeMyIp" => DnsServerBootstrapType::FreeMyIp, + b"GandiV5" => DnsServerBootstrapType::GandiV5, + b"Gcore" => DnsServerBootstrapType::Gcore, + b"Glesys" => DnsServerBootstrapType::Glesys, + b"Godaddy" => DnsServerBootstrapType::Godaddy, + b"Hetzner" => DnsServerBootstrapType::Hetzner, + b"HostingDe" => DnsServerBootstrapType::HostingDe, + b"Hostinger" => DnsServerBootstrapType::Hostinger, + b"HuaweiCloud" => DnsServerBootstrapType::HuaweiCloud, + b"Hurricane" => DnsServerBootstrapType::Hurricane, + b"IbmCloud" => DnsServerBootstrapType::IbmCloud, + b"Infoblox" => DnsServerBootstrapType::Infoblox, + b"Infomaniak" => DnsServerBootstrapType::Infomaniak, + b"Inwx" => DnsServerBootstrapType::Inwx, + b"Ionos" => DnsServerBootstrapType::Ionos, + b"Ipv64" => DnsServerBootstrapType::Ipv64, + b"Joker" => DnsServerBootstrapType::Joker, + b"Lightsail" => DnsServerBootstrapType::Lightsail, + b"Linode" => DnsServerBootstrapType::Linode, + b"LuaDns" => DnsServerBootstrapType::LuaDns, + b"MythicBeasts" => DnsServerBootstrapType::MythicBeasts, + b"Namecheap" => DnsServerBootstrapType::Namecheap, + b"NameDotCom" => DnsServerBootstrapType::NameDotCom, + b"NameSilo" => DnsServerBootstrapType::NameSilo, + b"Netcup" => DnsServerBootstrapType::Netcup, + b"Netlify" => DnsServerBootstrapType::Netlify, + b"Nifcloud" => DnsServerBootstrapType::Nifcloud, + b"Ns1" => DnsServerBootstrapType::Ns1, + b"OracleCloud" => DnsServerBootstrapType::OracleCloud, + b"Plesk" => DnsServerBootstrapType::Plesk, + b"Safedns" => DnsServerBootstrapType::Safedns, + b"Scaleway" => DnsServerBootstrapType::Scaleway, + b"TencentCloud" => DnsServerBootstrapType::TencentCloud, + b"Transip" => DnsServerBootstrapType::Transip, + b"UltraDns" => DnsServerBootstrapType::UltraDns, + b"Vercel" => DnsServerBootstrapType::Vercel, + b"Volcengine" => DnsServerBootstrapType::Volcengine, + b"Vultr" => DnsServerBootstrapType::Vultr, + b"WebSupport" => DnsServerBootstrapType::WebSupport, + b"YandexCloud" => DnsServerBootstrapType::YandexCloud, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + DnsServerBootstrapType::Manual => "Manual", + DnsServerBootstrapType::Tsig => "Tsig", + DnsServerBootstrapType::Deprecated1 => "Deprecated1", + DnsServerBootstrapType::Cloudflare => "Cloudflare", + DnsServerBootstrapType::DigitalOcean => "DigitalOcean", + DnsServerBootstrapType::DeSEC => "DeSEC", + DnsServerBootstrapType::Ovh => "Ovh", + DnsServerBootstrapType::Bunny => "Bunny", + DnsServerBootstrapType::Porkbun => "Porkbun", + DnsServerBootstrapType::Dnsimple => "Dnsimple", + DnsServerBootstrapType::Spaceship => "Spaceship", + DnsServerBootstrapType::Route53 => "Route53", + DnsServerBootstrapType::GoogleCloudDns => "GoogleCloudDns", + DnsServerBootstrapType::Alidns => "Alidns", + DnsServerBootstrapType::ArvanCloud => "ArvanCloud", + DnsServerBootstrapType::Autodns => "Autodns", + DnsServerBootstrapType::AzureDns => "AzureDns", + DnsServerBootstrapType::BaiduCloud => "BaiduCloud", + DnsServerBootstrapType::BluecatV2 => "BluecatV2", + DnsServerBootstrapType::ClouDns => "ClouDns", + DnsServerBootstrapType::Constellix => "Constellix", + DnsServerBootstrapType::Cpanel => "Cpanel", + DnsServerBootstrapType::Ddnss => "Ddnss", + DnsServerBootstrapType::DnsMadeEasy => "DnsMadeEasy", + DnsServerBootstrapType::Domeneshop => "Domeneshop", + DnsServerBootstrapType::Dreamhost => "Dreamhost", + DnsServerBootstrapType::DuckDns => "DuckDns", + DnsServerBootstrapType::Dynu => "Dynu", + DnsServerBootstrapType::EasyDns => "EasyDns", + DnsServerBootstrapType::EdgeDns => "EdgeDns", + DnsServerBootstrapType::Exoscale => "Exoscale", + DnsServerBootstrapType::FreeMyIp => "FreeMyIp", + DnsServerBootstrapType::GandiV5 => "GandiV5", + DnsServerBootstrapType::Gcore => "Gcore", + DnsServerBootstrapType::Glesys => "Glesys", + DnsServerBootstrapType::Godaddy => "Godaddy", + DnsServerBootstrapType::Hetzner => "Hetzner", + DnsServerBootstrapType::HostingDe => "HostingDe", + DnsServerBootstrapType::Hostinger => "Hostinger", + DnsServerBootstrapType::HuaweiCloud => "HuaweiCloud", + DnsServerBootstrapType::Hurricane => "Hurricane", + DnsServerBootstrapType::IbmCloud => "IbmCloud", + DnsServerBootstrapType::Infoblox => "Infoblox", + DnsServerBootstrapType::Infomaniak => "Infomaniak", + DnsServerBootstrapType::Inwx => "Inwx", + DnsServerBootstrapType::Ionos => "Ionos", + DnsServerBootstrapType::Ipv64 => "Ipv64", + DnsServerBootstrapType::Joker => "Joker", + DnsServerBootstrapType::Lightsail => "Lightsail", + DnsServerBootstrapType::Linode => "Linode", + DnsServerBootstrapType::LuaDns => "LuaDns", + DnsServerBootstrapType::MythicBeasts => "MythicBeasts", + DnsServerBootstrapType::Namecheap => "Namecheap", + DnsServerBootstrapType::NameDotCom => "NameDotCom", + DnsServerBootstrapType::NameSilo => "NameSilo", + DnsServerBootstrapType::Netcup => "Netcup", + DnsServerBootstrapType::Netlify => "Netlify", + DnsServerBootstrapType::Nifcloud => "Nifcloud", + DnsServerBootstrapType::Ns1 => "Ns1", + DnsServerBootstrapType::OracleCloud => "OracleCloud", + DnsServerBootstrapType::Plesk => "Plesk", + DnsServerBootstrapType::Safedns => "Safedns", + DnsServerBootstrapType::Scaleway => "Scaleway", + DnsServerBootstrapType::TencentCloud => "TencentCloud", + DnsServerBootstrapType::Transip => "Transip", + DnsServerBootstrapType::UltraDns => "UltraDns", + DnsServerBootstrapType::Vercel => "Vercel", + DnsServerBootstrapType::Volcengine => "Volcengine", + DnsServerBootstrapType::Vultr => "Vultr", + DnsServerBootstrapType::WebSupport => "WebSupport", + DnsServerBootstrapType::YandexCloud => "YandexCloud", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsServerBootstrapType::Manual), + 1 => Some(DnsServerBootstrapType::Tsig), + 2 => Some(DnsServerBootstrapType::Deprecated1), + 3 => Some(DnsServerBootstrapType::Cloudflare), + 4 => Some(DnsServerBootstrapType::DigitalOcean), + 5 => Some(DnsServerBootstrapType::DeSEC), + 6 => Some(DnsServerBootstrapType::Ovh), + 7 => Some(DnsServerBootstrapType::Bunny), + 8 => Some(DnsServerBootstrapType::Porkbun), + 9 => Some(DnsServerBootstrapType::Dnsimple), + 10 => Some(DnsServerBootstrapType::Spaceship), + 11 => Some(DnsServerBootstrapType::Route53), + 12 => Some(DnsServerBootstrapType::GoogleCloudDns), + 13 => Some(DnsServerBootstrapType::Alidns), + 14 => Some(DnsServerBootstrapType::ArvanCloud), + 15 => Some(DnsServerBootstrapType::Autodns), + 16 => Some(DnsServerBootstrapType::AzureDns), + 17 => Some(DnsServerBootstrapType::BaiduCloud), + 18 => Some(DnsServerBootstrapType::BluecatV2), + 19 => Some(DnsServerBootstrapType::ClouDns), + 20 => Some(DnsServerBootstrapType::Constellix), + 21 => Some(DnsServerBootstrapType::Cpanel), + 22 => Some(DnsServerBootstrapType::Ddnss), + 23 => Some(DnsServerBootstrapType::DnsMadeEasy), + 24 => Some(DnsServerBootstrapType::Domeneshop), + 25 => Some(DnsServerBootstrapType::Dreamhost), + 26 => Some(DnsServerBootstrapType::DuckDns), + 27 => Some(DnsServerBootstrapType::Dynu), + 28 => Some(DnsServerBootstrapType::EasyDns), + 29 => Some(DnsServerBootstrapType::EdgeDns), + 30 => Some(DnsServerBootstrapType::Exoscale), + 31 => Some(DnsServerBootstrapType::FreeMyIp), + 32 => Some(DnsServerBootstrapType::GandiV5), + 33 => Some(DnsServerBootstrapType::Gcore), + 34 => Some(DnsServerBootstrapType::Glesys), + 35 => Some(DnsServerBootstrapType::Godaddy), + 36 => Some(DnsServerBootstrapType::Hetzner), + 37 => Some(DnsServerBootstrapType::HostingDe), + 38 => Some(DnsServerBootstrapType::Hostinger), + 39 => Some(DnsServerBootstrapType::HuaweiCloud), + 40 => Some(DnsServerBootstrapType::Hurricane), + 41 => Some(DnsServerBootstrapType::IbmCloud), + 42 => Some(DnsServerBootstrapType::Infoblox), + 43 => Some(DnsServerBootstrapType::Infomaniak), + 44 => Some(DnsServerBootstrapType::Inwx), + 45 => Some(DnsServerBootstrapType::Ionos), + 46 => Some(DnsServerBootstrapType::Ipv64), + 47 => Some(DnsServerBootstrapType::Joker), + 48 => Some(DnsServerBootstrapType::Lightsail), + 49 => Some(DnsServerBootstrapType::Linode), + 50 => Some(DnsServerBootstrapType::LuaDns), + 51 => Some(DnsServerBootstrapType::MythicBeasts), + 52 => Some(DnsServerBootstrapType::Namecheap), + 53 => Some(DnsServerBootstrapType::NameDotCom), + 54 => Some(DnsServerBootstrapType::NameSilo), + 55 => Some(DnsServerBootstrapType::Netcup), + 56 => Some(DnsServerBootstrapType::Netlify), + 57 => Some(DnsServerBootstrapType::Nifcloud), + 58 => Some(DnsServerBootstrapType::Ns1), + 59 => Some(DnsServerBootstrapType::OracleCloud), + 60 => Some(DnsServerBootstrapType::Plesk), + 61 => Some(DnsServerBootstrapType::Safedns), + 62 => Some(DnsServerBootstrapType::Scaleway), + 63 => Some(DnsServerBootstrapType::TencentCloud), + 64 => Some(DnsServerBootstrapType::Transip), + 65 => Some(DnsServerBootstrapType::UltraDns), + 66 => Some(DnsServerBootstrapType::Vercel), + 67 => Some(DnsServerBootstrapType::Volcengine), + 68 => Some(DnsServerBootstrapType::Vultr), + 69 => Some(DnsServerBootstrapType::WebSupport), + 70 => Some(DnsServerBootstrapType::YandexCloud), + _ => None, + } + } + + const COUNT: usize = 71; +} + +impl serde::Serialize for DnsServerBootstrapType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsServerBootstrapType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for DnsServerType { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + DnsServerType, + b"Tsig" => DnsServerType::Tsig, + b"Deprecated1" => DnsServerType::Deprecated1, + b"Cloudflare" => DnsServerType::Cloudflare, + b"DigitalOcean" => DnsServerType::DigitalOcean, + b"DeSEC" => DnsServerType::DeSEC, + b"Ovh" => DnsServerType::Ovh, + b"Bunny" => DnsServerType::Bunny, + b"Porkbun" => DnsServerType::Porkbun, + b"Dnsimple" => DnsServerType::Dnsimple, + b"Spaceship" => DnsServerType::Spaceship, + b"Route53" => DnsServerType::Route53, + b"GoogleCloudDns" => DnsServerType::GoogleCloudDns, + b"Alidns" => DnsServerType::Alidns, + b"ArvanCloud" => DnsServerType::ArvanCloud, + b"Autodns" => DnsServerType::Autodns, + b"AzureDns" => DnsServerType::AzureDns, + b"BaiduCloud" => DnsServerType::BaiduCloud, + b"BluecatV2" => DnsServerType::BluecatV2, + b"ClouDns" => DnsServerType::ClouDns, + b"Constellix" => DnsServerType::Constellix, + b"Cpanel" => DnsServerType::Cpanel, + b"Ddnss" => DnsServerType::Ddnss, + b"DnsMadeEasy" => DnsServerType::DnsMadeEasy, + b"Domeneshop" => DnsServerType::Domeneshop, + b"Dreamhost" => DnsServerType::Dreamhost, + b"DuckDns" => DnsServerType::DuckDns, + b"Dynu" => DnsServerType::Dynu, + b"EasyDns" => DnsServerType::EasyDns, + b"EdgeDns" => DnsServerType::EdgeDns, + b"Exoscale" => DnsServerType::Exoscale, + b"FreeMyIp" => DnsServerType::FreeMyIp, + b"GandiV5" => DnsServerType::GandiV5, + b"Gcore" => DnsServerType::Gcore, + b"Glesys" => DnsServerType::Glesys, + b"Godaddy" => DnsServerType::Godaddy, + b"Hetzner" => DnsServerType::Hetzner, + b"HostingDe" => DnsServerType::HostingDe, + b"Hostinger" => DnsServerType::Hostinger, + b"HuaweiCloud" => DnsServerType::HuaweiCloud, + b"Hurricane" => DnsServerType::Hurricane, + b"IbmCloud" => DnsServerType::IbmCloud, + b"Infoblox" => DnsServerType::Infoblox, + b"Infomaniak" => DnsServerType::Infomaniak, + b"Inwx" => DnsServerType::Inwx, + b"Ionos" => DnsServerType::Ionos, + b"Ipv64" => DnsServerType::Ipv64, + b"Joker" => DnsServerType::Joker, + b"Lightsail" => DnsServerType::Lightsail, + b"Linode" => DnsServerType::Linode, + b"LuaDns" => DnsServerType::LuaDns, + b"MythicBeasts" => DnsServerType::MythicBeasts, + b"Namecheap" => DnsServerType::Namecheap, + b"NameDotCom" => DnsServerType::NameDotCom, + b"NameSilo" => DnsServerType::NameSilo, + b"Netcup" => DnsServerType::Netcup, + b"Netlify" => DnsServerType::Netlify, + b"Nifcloud" => DnsServerType::Nifcloud, + b"Ns1" => DnsServerType::Ns1, + b"OracleCloud" => DnsServerType::OracleCloud, + b"Plesk" => DnsServerType::Plesk, + b"Safedns" => DnsServerType::Safedns, + b"Scaleway" => DnsServerType::Scaleway, + b"TencentCloud" => DnsServerType::TencentCloud, + b"Transip" => DnsServerType::Transip, + b"UltraDns" => DnsServerType::UltraDns, + b"Vercel" => DnsServerType::Vercel, + b"Volcengine" => DnsServerType::Volcengine, + b"Vultr" => DnsServerType::Vultr, + b"WebSupport" => DnsServerType::WebSupport, + b"YandexCloud" => DnsServerType::YandexCloud, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + DnsServerType::Tsig => "Tsig", + DnsServerType::Deprecated1 => "Deprecated1", + DnsServerType::Cloudflare => "Cloudflare", + DnsServerType::DigitalOcean => "DigitalOcean", + DnsServerType::DeSEC => "DeSEC", + DnsServerType::Ovh => "Ovh", + DnsServerType::Bunny => "Bunny", + DnsServerType::Porkbun => "Porkbun", + DnsServerType::Dnsimple => "Dnsimple", + DnsServerType::Spaceship => "Spaceship", + DnsServerType::Route53 => "Route53", + DnsServerType::GoogleCloudDns => "GoogleCloudDns", + DnsServerType::Alidns => "Alidns", + DnsServerType::ArvanCloud => "ArvanCloud", + DnsServerType::Autodns => "Autodns", + DnsServerType::AzureDns => "AzureDns", + DnsServerType::BaiduCloud => "BaiduCloud", + DnsServerType::BluecatV2 => "BluecatV2", + DnsServerType::ClouDns => "ClouDns", + DnsServerType::Constellix => "Constellix", + DnsServerType::Cpanel => "Cpanel", + DnsServerType::Ddnss => "Ddnss", + DnsServerType::DnsMadeEasy => "DnsMadeEasy", + DnsServerType::Domeneshop => "Domeneshop", + DnsServerType::Dreamhost => "Dreamhost", + DnsServerType::DuckDns => "DuckDns", + DnsServerType::Dynu => "Dynu", + DnsServerType::EasyDns => "EasyDns", + DnsServerType::EdgeDns => "EdgeDns", + DnsServerType::Exoscale => "Exoscale", + DnsServerType::FreeMyIp => "FreeMyIp", + DnsServerType::GandiV5 => "GandiV5", + DnsServerType::Gcore => "Gcore", + DnsServerType::Glesys => "Glesys", + DnsServerType::Godaddy => "Godaddy", + DnsServerType::Hetzner => "Hetzner", + DnsServerType::HostingDe => "HostingDe", + DnsServerType::Hostinger => "Hostinger", + DnsServerType::HuaweiCloud => "HuaweiCloud", + DnsServerType::Hurricane => "Hurricane", + DnsServerType::IbmCloud => "IbmCloud", + DnsServerType::Infoblox => "Infoblox", + DnsServerType::Infomaniak => "Infomaniak", + DnsServerType::Inwx => "Inwx", + DnsServerType::Ionos => "Ionos", + DnsServerType::Ipv64 => "Ipv64", + DnsServerType::Joker => "Joker", + DnsServerType::Lightsail => "Lightsail", + DnsServerType::Linode => "Linode", + DnsServerType::LuaDns => "LuaDns", + DnsServerType::MythicBeasts => "MythicBeasts", + DnsServerType::Namecheap => "Namecheap", + DnsServerType::NameDotCom => "NameDotCom", + DnsServerType::NameSilo => "NameSilo", + DnsServerType::Netcup => "Netcup", + DnsServerType::Netlify => "Netlify", + DnsServerType::Nifcloud => "Nifcloud", + DnsServerType::Ns1 => "Ns1", + DnsServerType::OracleCloud => "OracleCloud", + DnsServerType::Plesk => "Plesk", + DnsServerType::Safedns => "Safedns", + DnsServerType::Scaleway => "Scaleway", + DnsServerType::TencentCloud => "TencentCloud", + DnsServerType::Transip => "Transip", + DnsServerType::UltraDns => "UltraDns", + DnsServerType::Vercel => "Vercel", + DnsServerType::Volcengine => "Volcengine", + DnsServerType::Vultr => "Vultr", + DnsServerType::WebSupport => "WebSupport", + DnsServerType::YandexCloud => "YandexCloud", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DnsServerType::Tsig), + 1 => Some(DnsServerType::Deprecated1), + 2 => Some(DnsServerType::Cloudflare), + 3 => Some(DnsServerType::DigitalOcean), + 4 => Some(DnsServerType::DeSEC), + 5 => Some(DnsServerType::Ovh), + 6 => Some(DnsServerType::Bunny), + 7 => Some(DnsServerType::Porkbun), + 8 => Some(DnsServerType::Dnsimple), + 9 => Some(DnsServerType::Spaceship), + 10 => Some(DnsServerType::Route53), + 11 => Some(DnsServerType::GoogleCloudDns), + 12 => Some(DnsServerType::Alidns), + 13 => Some(DnsServerType::ArvanCloud), + 14 => Some(DnsServerType::Autodns), + 15 => Some(DnsServerType::AzureDns), + 16 => Some(DnsServerType::BaiduCloud), + 17 => Some(DnsServerType::BluecatV2), + 18 => Some(DnsServerType::ClouDns), + 19 => Some(DnsServerType::Constellix), + 20 => Some(DnsServerType::Cpanel), + 21 => Some(DnsServerType::Ddnss), + 22 => Some(DnsServerType::DnsMadeEasy), + 23 => Some(DnsServerType::Domeneshop), + 24 => Some(DnsServerType::Dreamhost), + 25 => Some(DnsServerType::DuckDns), + 26 => Some(DnsServerType::Dynu), + 27 => Some(DnsServerType::EasyDns), + 28 => Some(DnsServerType::EdgeDns), + 29 => Some(DnsServerType::Exoscale), + 30 => Some(DnsServerType::FreeMyIp), + 31 => Some(DnsServerType::GandiV5), + 32 => Some(DnsServerType::Gcore), + 33 => Some(DnsServerType::Glesys), + 34 => Some(DnsServerType::Godaddy), + 35 => Some(DnsServerType::Hetzner), + 36 => Some(DnsServerType::HostingDe), + 37 => Some(DnsServerType::Hostinger), + 38 => Some(DnsServerType::HuaweiCloud), + 39 => Some(DnsServerType::Hurricane), + 40 => Some(DnsServerType::IbmCloud), + 41 => Some(DnsServerType::Infoblox), + 42 => Some(DnsServerType::Infomaniak), + 43 => Some(DnsServerType::Inwx), + 44 => Some(DnsServerType::Ionos), + 45 => Some(DnsServerType::Ipv64), + 46 => Some(DnsServerType::Joker), + 47 => Some(DnsServerType::Lightsail), + 48 => Some(DnsServerType::Linode), + 49 => Some(DnsServerType::LuaDns), + 50 => Some(DnsServerType::MythicBeasts), + 51 => Some(DnsServerType::Namecheap), + 52 => Some(DnsServerType::NameDotCom), + 53 => Some(DnsServerType::NameSilo), + 54 => Some(DnsServerType::Netcup), + 55 => Some(DnsServerType::Netlify), + 56 => Some(DnsServerType::Nifcloud), + 57 => Some(DnsServerType::Ns1), + 58 => Some(DnsServerType::OracleCloud), + 59 => Some(DnsServerType::Plesk), + 60 => Some(DnsServerType::Safedns), + 61 => Some(DnsServerType::Scaleway), + 62 => Some(DnsServerType::TencentCloud), + 63 => Some(DnsServerType::Transip), + 64 => Some(DnsServerType::UltraDns), + 65 => Some(DnsServerType::Vercel), + 66 => Some(DnsServerType::Volcengine), + 67 => Some(DnsServerType::Vultr), + 68 => Some(DnsServerType::WebSupport), + 69 => Some(DnsServerType::YandexCloud), + _ => None, + } + } + + const COUNT: usize = 70; +} + +impl serde::Serialize for DnsServerType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DnsServerType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for EncryptionAtRestType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => EncryptionAtRestType::Disabled, + b"Aes128" => EncryptionAtRestType::Aes128, + b"Aes256" => EncryptionAtRestType::Aes256, + b"Aes256Gcm" => EncryptionAtRestType::Aes256Gcm, + b"ChaCha20Poly1305" => EncryptionAtRestType::ChaCha20Poly1305, + } + } + + fn as_str(&self) -> &'static str { + match self { + EncryptionAtRestType::Disabled => "Disabled", + EncryptionAtRestType::Aes128 => "Aes128", + EncryptionAtRestType::Aes256 => "Aes256", + EncryptionAtRestType::Aes256Gcm => "Aes256Gcm", + EncryptionAtRestType::ChaCha20Poly1305 => "ChaCha20Poly1305", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(EncryptionAtRestType::Disabled), + 1 => Some(EncryptionAtRestType::Aes128), + 2 => Some(EncryptionAtRestType::Aes256), + 3 => Some(EncryptionAtRestType::Aes256Gcm), + 4 => Some(EncryptionAtRestType::ChaCha20Poly1305), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for EncryptionAtRestType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for EncryptionAtRestType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for EventPolicy { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"include" => EventPolicy::Include, + b"exclude" => EventPolicy::Exclude, + } + } + + fn as_str(&self) -> &'static str { + match self { + EventPolicy::Include => "include", + EventPolicy::Exclude => "exclude", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(EventPolicy::Include), + 1 => Some(EventPolicy::Exclude), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for EventPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for EventPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ExpressionConstant { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"relaxed" => ExpressionConstant::Relaxed, + b"strict" => ExpressionConstant::Strict, + b"disable" => ExpressionConstant::Disable, + b"optional" => ExpressionConstant::Optional, + b"require" => ExpressionConstant::Require, + b"ipv4_only" => ExpressionConstant::Ipv4Only, + b"ipv6_only" => ExpressionConstant::Ipv6Only, + b"ipv6_then_ipv4" => ExpressionConstant::Ipv6ThenIpv4, + b"ipv4_then_ipv6" => ExpressionConstant::Ipv4ThenIpv6, + b"hourly" => ExpressionConstant::Hourly, + b"daily" => ExpressionConstant::Daily, + b"weekly" => ExpressionConstant::Weekly, + b"login" => ExpressionConstant::Login, + b"plain" => ExpressionConstant::Plain, + b"xoauth2" => ExpressionConstant::Xoauth2, + b"oauthbearer" => ExpressionConstant::Oauthbearer, + b"mixer" => ExpressionConstant::Mixer, + b"stanag4406" => ExpressionConstant::Stanag4406, + b"nsep" => ExpressionConstant::Nsep, + } + } + + fn as_str(&self) -> &'static str { + match self { + ExpressionConstant::Relaxed => "relaxed", + ExpressionConstant::Strict => "strict", + ExpressionConstant::Disable => "disable", + ExpressionConstant::Optional => "optional", + ExpressionConstant::Require => "require", + ExpressionConstant::Ipv4Only => "ipv4_only", + ExpressionConstant::Ipv6Only => "ipv6_only", + ExpressionConstant::Ipv6ThenIpv4 => "ipv6_then_ipv4", + ExpressionConstant::Ipv4ThenIpv6 => "ipv4_then_ipv6", + ExpressionConstant::Hourly => "hourly", + ExpressionConstant::Daily => "daily", + ExpressionConstant::Weekly => "weekly", + ExpressionConstant::Login => "login", + ExpressionConstant::Plain => "plain", + ExpressionConstant::Xoauth2 => "xoauth2", + ExpressionConstant::Oauthbearer => "oauthbearer", + ExpressionConstant::Mixer => "mixer", + ExpressionConstant::Stanag4406 => "stanag4406", + ExpressionConstant::Nsep => "nsep", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ExpressionConstant::Relaxed), + 1 => Some(ExpressionConstant::Strict), + 2 => Some(ExpressionConstant::Disable), + 3 => Some(ExpressionConstant::Optional), + 4 => Some(ExpressionConstant::Require), + 5 => Some(ExpressionConstant::Ipv4Only), + 6 => Some(ExpressionConstant::Ipv6Only), + 7 => Some(ExpressionConstant::Ipv6ThenIpv4), + 8 => Some(ExpressionConstant::Ipv4ThenIpv6), + 9 => Some(ExpressionConstant::Hourly), + 10 => Some(ExpressionConstant::Daily), + 11 => Some(ExpressionConstant::Weekly), + 12 => Some(ExpressionConstant::Login), + 13 => Some(ExpressionConstant::Plain), + 14 => Some(ExpressionConstant::Xoauth2), + 15 => Some(ExpressionConstant::Oauthbearer), + 16 => Some(ExpressionConstant::Mixer), + 17 => Some(ExpressionConstant::Stanag4406), + 18 => Some(ExpressionConstant::Nsep), + _ => None, + } + } + + const COUNT: usize = 19; +} + +impl serde::Serialize for ExpressionConstant { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ExpressionConstant { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ExpressionVariable { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + ExpressionVariable, + b"asn" => ExpressionVariable::Asn, + b"attributes" => ExpressionVariable::Attributes, + b"authenticated_as" => ExpressionVariable::AuthenticatedAs, + b"authority" => ExpressionVariable::Authority, + b"bcc" => ExpressionVariable::Bcc, + b"bcc.domain" => ExpressionVariable::BccDomain, + b"bcc.local" => ExpressionVariable::BccLocal, + b"bcc.name" => ExpressionVariable::BccName, + b"body" => ExpressionVariable::Body, + b"body.html" => ExpressionVariable::BodyHtml, + b"body.raw" => ExpressionVariable::BodyRaw, + b"body.text" => ExpressionVariable::BodyText, + b"body.words" => ExpressionVariable::BodyWords, + b"cc" => ExpressionVariable::Cc, + b"cc.domain" => ExpressionVariable::CcDomain, + b"cc.local" => ExpressionVariable::CcLocal, + b"cc.name" => ExpressionVariable::CcName, + b"country" => ExpressionVariable::Country, + b"domain" => ExpressionVariable::Domain, + b"email" => ExpressionVariable::Email, + b"email_lower" => ExpressionVariable::EmailLower, + b"env_from" => ExpressionVariable::EnvFrom, + b"env_from.domain" => ExpressionVariable::EnvFromDomain, + b"env_from.local" => ExpressionVariable::EnvFromLocal, + b"env_to" => ExpressionVariable::EnvTo, + b"expires_in" => ExpressionVariable::ExpiresIn, + b"from" => ExpressionVariable::From, + b"from.domain" => ExpressionVariable::FromDomain, + b"from.local" => ExpressionVariable::FromLocal, + b"from.name" => ExpressionVariable::FromName, + b"headers" => ExpressionVariable::Headers, + b"helo_domain" => ExpressionVariable::HeloDomain, + b"host" => ExpressionVariable::Host, + b"ip" => ExpressionVariable::Ip, + b"ip_reverse" => ExpressionVariable::IpReverse, + b"is_tls" => ExpressionVariable::IsTls, + b"is_v4" => ExpressionVariable::IsV4, + b"is_v6" => ExpressionVariable::IsV6, + b"last_error" => ExpressionVariable::LastError, + b"last_status" => ExpressionVariable::LastStatus, + b"listener" => ExpressionVariable::Listener, + b"local" => ExpressionVariable::Local, + b"local_ip" => ExpressionVariable::LocalIp, + b"local_port" => ExpressionVariable::LocalPort, + b"location" => ExpressionVariable::Location, + b"method" => ExpressionVariable::Method, + b"mx" => ExpressionVariable::Mx, + b"name" => ExpressionVariable::Name, + b"name_lower" => ExpressionVariable::NameLower, + b"notify_num" => ExpressionVariable::NotifyNum, + b"octets" => ExpressionVariable::Octets, + b"path" => ExpressionVariable::Path, + b"path_query" => ExpressionVariable::PathQuery, + b"port" => ExpressionVariable::Port, + b"priority" => ExpressionVariable::Priority, + b"protocol" => ExpressionVariable::Protocol, + b"query" => ExpressionVariable::Query, + b"queue_age" => ExpressionVariable::QueueAge, + b"queue_name" => ExpressionVariable::QueueName, + b"raw" => ExpressionVariable::Raw, + b"raw_lower" => ExpressionVariable::RawLower, + b"rcpt" => ExpressionVariable::Rcpt, + b"rcpt_domain" => ExpressionVariable::RcptDomain, + b"received_from_ip" => ExpressionVariable::ReceivedFromIp, + b"received_via_port" => ExpressionVariable::ReceivedViaPort, + b"recipients" => ExpressionVariable::Recipients, + b"remote_ip" => ExpressionVariable::RemoteIp, + b"remote_ip.ptr" => ExpressionVariable::RemoteIpPtr, + b"remote_port" => ExpressionVariable::RemotePort, + b"reply_to" => ExpressionVariable::ReplyTo, + b"reply_to.domain" => ExpressionVariable::ReplyToDomain, + b"reply_to.local" => ExpressionVariable::ReplyToLocal, + b"reply_to.name" => ExpressionVariable::ReplyToName, + b"retry_num" => ExpressionVariable::RetryNum, + b"reverse_ip" => ExpressionVariable::ReverseIp, + b"scheme" => ExpressionVariable::Scheme, + b"sender" => ExpressionVariable::Sender, + b"sender_domain" => ExpressionVariable::SenderDomain, + b"size" => ExpressionVariable::Size, + b"sld" => ExpressionVariable::Sld, + b"source" => ExpressionVariable::Source, + b"subject" => ExpressionVariable::Subject, + b"subject.thread" => ExpressionVariable::SubjectThread, + b"subject.words" => ExpressionVariable::SubjectWords, + b"to" => ExpressionVariable::To, + b"to.domain" => ExpressionVariable::ToDomain, + b"to.local" => ExpressionVariable::ToLocal, + b"to.name" => ExpressionVariable::ToName, + b"url" => ExpressionVariable::Url, + b"url_original" => ExpressionVariable::UrlOriginal, + b"value" => ExpressionVariable::Value, + b"value_lower" => ExpressionVariable::ValueLower, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + ExpressionVariable::Asn => "asn", + ExpressionVariable::Attributes => "attributes", + ExpressionVariable::AuthenticatedAs => "authenticated_as", + ExpressionVariable::Authority => "authority", + ExpressionVariable::Bcc => "bcc", + ExpressionVariable::BccDomain => "bcc.domain", + ExpressionVariable::BccLocal => "bcc.local", + ExpressionVariable::BccName => "bcc.name", + ExpressionVariable::Body => "body", + ExpressionVariable::BodyHtml => "body.html", + ExpressionVariable::BodyRaw => "body.raw", + ExpressionVariable::BodyText => "body.text", + ExpressionVariable::BodyWords => "body.words", + ExpressionVariable::Cc => "cc", + ExpressionVariable::CcDomain => "cc.domain", + ExpressionVariable::CcLocal => "cc.local", + ExpressionVariable::CcName => "cc.name", + ExpressionVariable::Country => "country", + ExpressionVariable::Domain => "domain", + ExpressionVariable::Email => "email", + ExpressionVariable::EmailLower => "email_lower", + ExpressionVariable::EnvFrom => "env_from", + ExpressionVariable::EnvFromDomain => "env_from.domain", + ExpressionVariable::EnvFromLocal => "env_from.local", + ExpressionVariable::EnvTo => "env_to", + ExpressionVariable::ExpiresIn => "expires_in", + ExpressionVariable::From => "from", + ExpressionVariable::FromDomain => "from.domain", + ExpressionVariable::FromLocal => "from.local", + ExpressionVariable::FromName => "from.name", + ExpressionVariable::Headers => "headers", + ExpressionVariable::HeloDomain => "helo_domain", + ExpressionVariable::Host => "host", + ExpressionVariable::Ip => "ip", + ExpressionVariable::IpReverse => "ip_reverse", + ExpressionVariable::IsTls => "is_tls", + ExpressionVariable::IsV4 => "is_v4", + ExpressionVariable::IsV6 => "is_v6", + ExpressionVariable::LastError => "last_error", + ExpressionVariable::LastStatus => "last_status", + ExpressionVariable::Listener => "listener", + ExpressionVariable::Local => "local", + ExpressionVariable::LocalIp => "local_ip", + ExpressionVariable::LocalPort => "local_port", + ExpressionVariable::Location => "location", + ExpressionVariable::Method => "method", + ExpressionVariable::Mx => "mx", + ExpressionVariable::Name => "name", + ExpressionVariable::NameLower => "name_lower", + ExpressionVariable::NotifyNum => "notify_num", + ExpressionVariable::Octets => "octets", + ExpressionVariable::Path => "path", + ExpressionVariable::PathQuery => "path_query", + ExpressionVariable::Port => "port", + ExpressionVariable::Priority => "priority", + ExpressionVariable::Protocol => "protocol", + ExpressionVariable::Query => "query", + ExpressionVariable::QueueAge => "queue_age", + ExpressionVariable::QueueName => "queue_name", + ExpressionVariable::Raw => "raw", + ExpressionVariable::RawLower => "raw_lower", + ExpressionVariable::Rcpt => "rcpt", + ExpressionVariable::RcptDomain => "rcpt_domain", + ExpressionVariable::ReceivedFromIp => "received_from_ip", + ExpressionVariable::ReceivedViaPort => "received_via_port", + ExpressionVariable::Recipients => "recipients", + ExpressionVariable::RemoteIp => "remote_ip", + ExpressionVariable::RemoteIpPtr => "remote_ip.ptr", + ExpressionVariable::RemotePort => "remote_port", + ExpressionVariable::ReplyTo => "reply_to", + ExpressionVariable::ReplyToDomain => "reply_to.domain", + ExpressionVariable::ReplyToLocal => "reply_to.local", + ExpressionVariable::ReplyToName => "reply_to.name", + ExpressionVariable::RetryNum => "retry_num", + ExpressionVariable::ReverseIp => "reverse_ip", + ExpressionVariable::Scheme => "scheme", + ExpressionVariable::Sender => "sender", + ExpressionVariable::SenderDomain => "sender_domain", + ExpressionVariable::Size => "size", + ExpressionVariable::Sld => "sld", + ExpressionVariable::Source => "source", + ExpressionVariable::Subject => "subject", + ExpressionVariable::SubjectThread => "subject.thread", + ExpressionVariable::SubjectWords => "subject.words", + ExpressionVariable::To => "to", + ExpressionVariable::ToDomain => "to.domain", + ExpressionVariable::ToLocal => "to.local", + ExpressionVariable::ToName => "to.name", + ExpressionVariable::Url => "url", + ExpressionVariable::UrlOriginal => "url_original", + ExpressionVariable::Value => "value", + ExpressionVariable::ValueLower => "value_lower", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ExpressionVariable::Asn), + 1 => Some(ExpressionVariable::Attributes), + 2 => Some(ExpressionVariable::AuthenticatedAs), + 3 => Some(ExpressionVariable::Authority), + 4 => Some(ExpressionVariable::Bcc), + 5 => Some(ExpressionVariable::BccDomain), + 6 => Some(ExpressionVariable::BccLocal), + 7 => Some(ExpressionVariable::BccName), + 8 => Some(ExpressionVariable::Body), + 9 => Some(ExpressionVariable::BodyHtml), + 10 => Some(ExpressionVariable::BodyRaw), + 11 => Some(ExpressionVariable::BodyText), + 12 => Some(ExpressionVariable::BodyWords), + 13 => Some(ExpressionVariable::Cc), + 14 => Some(ExpressionVariable::CcDomain), + 15 => Some(ExpressionVariable::CcLocal), + 16 => Some(ExpressionVariable::CcName), + 17 => Some(ExpressionVariable::Country), + 18 => Some(ExpressionVariable::Domain), + 19 => Some(ExpressionVariable::Email), + 20 => Some(ExpressionVariable::EmailLower), + 21 => Some(ExpressionVariable::EnvFrom), + 22 => Some(ExpressionVariable::EnvFromDomain), + 23 => Some(ExpressionVariable::EnvFromLocal), + 24 => Some(ExpressionVariable::EnvTo), + 25 => Some(ExpressionVariable::ExpiresIn), + 26 => Some(ExpressionVariable::From), + 27 => Some(ExpressionVariable::FromDomain), + 28 => Some(ExpressionVariable::FromLocal), + 29 => Some(ExpressionVariable::FromName), + 30 => Some(ExpressionVariable::Headers), + 31 => Some(ExpressionVariable::HeloDomain), + 32 => Some(ExpressionVariable::Host), + 33 => Some(ExpressionVariable::Ip), + 34 => Some(ExpressionVariable::IpReverse), + 35 => Some(ExpressionVariable::IsTls), + 36 => Some(ExpressionVariable::IsV4), + 37 => Some(ExpressionVariable::IsV6), + 38 => Some(ExpressionVariable::LastError), + 39 => Some(ExpressionVariable::LastStatus), + 40 => Some(ExpressionVariable::Listener), + 41 => Some(ExpressionVariable::Local), + 42 => Some(ExpressionVariable::LocalIp), + 43 => Some(ExpressionVariable::LocalPort), + 44 => Some(ExpressionVariable::Location), + 45 => Some(ExpressionVariable::Method), + 46 => Some(ExpressionVariable::Mx), + 47 => Some(ExpressionVariable::Name), + 48 => Some(ExpressionVariable::NameLower), + 49 => Some(ExpressionVariable::NotifyNum), + 50 => Some(ExpressionVariable::Octets), + 51 => Some(ExpressionVariable::Path), + 52 => Some(ExpressionVariable::PathQuery), + 53 => Some(ExpressionVariable::Port), + 54 => Some(ExpressionVariable::Priority), + 55 => Some(ExpressionVariable::Protocol), + 56 => Some(ExpressionVariable::Query), + 57 => Some(ExpressionVariable::QueueAge), + 58 => Some(ExpressionVariable::QueueName), + 59 => Some(ExpressionVariable::Raw), + 60 => Some(ExpressionVariable::RawLower), + 61 => Some(ExpressionVariable::Rcpt), + 62 => Some(ExpressionVariable::RcptDomain), + 63 => Some(ExpressionVariable::ReceivedFromIp), + 64 => Some(ExpressionVariable::ReceivedViaPort), + 65 => Some(ExpressionVariable::Recipients), + 66 => Some(ExpressionVariable::RemoteIp), + 67 => Some(ExpressionVariable::RemoteIpPtr), + 68 => Some(ExpressionVariable::RemotePort), + 69 => Some(ExpressionVariable::ReplyTo), + 70 => Some(ExpressionVariable::ReplyToDomain), + 71 => Some(ExpressionVariable::ReplyToLocal), + 72 => Some(ExpressionVariable::ReplyToName), + 73 => Some(ExpressionVariable::RetryNum), + 74 => Some(ExpressionVariable::ReverseIp), + 75 => Some(ExpressionVariable::Scheme), + 76 => Some(ExpressionVariable::Sender), + 77 => Some(ExpressionVariable::SenderDomain), + 78 => Some(ExpressionVariable::Size), + 79 => Some(ExpressionVariable::Sld), + 80 => Some(ExpressionVariable::Source), + 81 => Some(ExpressionVariable::Subject), + 82 => Some(ExpressionVariable::SubjectThread), + 83 => Some(ExpressionVariable::SubjectWords), + 84 => Some(ExpressionVariable::To), + 85 => Some(ExpressionVariable::ToDomain), + 86 => Some(ExpressionVariable::ToLocal), + 87 => Some(ExpressionVariable::ToName), + 88 => Some(ExpressionVariable::Url), + 89 => Some(ExpressionVariable::UrlOriginal), + 90 => Some(ExpressionVariable::Value), + 91 => Some(ExpressionVariable::ValueLower), + _ => None, + } + } + + const COUNT: usize = 92; +} + +impl serde::Serialize for ExpressionVariable { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ExpressionVariable { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for FailureReportingOption { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"all" => FailureReportingOption::All, + b"any" => FailureReportingOption::Any, + b"dkimFailure" => FailureReportingOption::DkimFailure, + b"spfFailure" => FailureReportingOption::SpfFailure, + } + } + + fn as_str(&self) -> &'static str { + match self { + FailureReportingOption::All => "all", + FailureReportingOption::Any => "any", + FailureReportingOption::DkimFailure => "dkimFailure", + FailureReportingOption::SpfFailure => "spfFailure", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(FailureReportingOption::All), + 1 => Some(FailureReportingOption::Any), + 2 => Some(FailureReportingOption::DkimFailure), + 3 => Some(FailureReportingOption::SpfFailure), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for FailureReportingOption { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for FailureReportingOption { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for HttpAuthType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Unauthenticated" => HttpAuthType::Unauthenticated, + b"Basic" => HttpAuthType::Basic, + b"Bearer" => HttpAuthType::Bearer, + } + } + + fn as_str(&self) -> &'static str { + match self { + HttpAuthType::Unauthenticated => "Unauthenticated", + HttpAuthType::Basic => "Basic", + HttpAuthType::Bearer => "Bearer", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(HttpAuthType::Unauthenticated), + 1 => Some(HttpAuthType::Basic), + 2 => Some(HttpAuthType::Bearer), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for HttpAuthType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for HttpAuthType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for HttpLookupFormatType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Csv" => HttpLookupFormatType::Csv, + b"List" => HttpLookupFormatType::List, + } + } + + fn as_str(&self) -> &'static str { + match self { + HttpLookupFormatType::Csv => "Csv", + HttpLookupFormatType::List => "List", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(HttpLookupFormatType::Csv), + 1 => Some(HttpLookupFormatType::List), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for HttpLookupFormatType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for HttpLookupFormatType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for InMemoryStoreBaseType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Redis" => InMemoryStoreBaseType::Redis, + b"RedisCluster" => InMemoryStoreBaseType::RedisCluster, + b"RedisSentinel" => InMemoryStoreBaseType::RedisSentinel, + } + } + + fn as_str(&self) -> &'static str { + match self { + InMemoryStoreBaseType::Redis => "Redis", + InMemoryStoreBaseType::RedisCluster => "RedisCluster", + InMemoryStoreBaseType::RedisSentinel => "RedisSentinel", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(InMemoryStoreBaseType::Redis), + 1 => Some(InMemoryStoreBaseType::RedisCluster), + 2 => Some(InMemoryStoreBaseType::RedisSentinel), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for InMemoryStoreBaseType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for InMemoryStoreBaseType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for InMemoryStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => InMemoryStoreType::Default, + b"Sharded" => InMemoryStoreType::Sharded, + b"Redis" => InMemoryStoreType::Redis, + b"RedisCluster" => InMemoryStoreType::RedisCluster, + b"RedisSentinel" => InMemoryStoreType::RedisSentinel, + } + } + + fn as_str(&self) -> &'static str { + match self { + InMemoryStoreType::Default => "Default", + InMemoryStoreType::Sharded => "Sharded", + InMemoryStoreType::Redis => "Redis", + InMemoryStoreType::RedisCluster => "RedisCluster", + InMemoryStoreType::RedisSentinel => "RedisSentinel", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(InMemoryStoreType::Default), + 1 => Some(InMemoryStoreType::Sharded), + 2 => Some(InMemoryStoreType::Redis), + 3 => Some(InMemoryStoreType::RedisCluster), + 4 => Some(InMemoryStoreType::RedisSentinel), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for InMemoryStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for InMemoryStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for IndexDocumentType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"email" => IndexDocumentType::Email, + b"calendar" => IndexDocumentType::Calendar, + b"contacts" => IndexDocumentType::Contacts, + b"file" => IndexDocumentType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + IndexDocumentType::Email => "email", + IndexDocumentType::Calendar => "calendar", + IndexDocumentType::Contacts => "contacts", + IndexDocumentType::File => "file", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(IndexDocumentType::Email), + 1 => Some(IndexDocumentType::Calendar), + 2 => Some(IndexDocumentType::Contacts), + 3 => Some(IndexDocumentType::File), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for IndexDocumentType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for IndexDocumentType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for IpProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"udp" => IpProtocol::Udp, + b"tcp" => IpProtocol::Tcp, + } + } + + fn as_str(&self) -> &'static str { + match self { + IpProtocol::Udp => "udp", + IpProtocol::Tcp => "tcp", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(IpProtocol::Udp), + 1 => Some(IpProtocol::Tcp), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for IpProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for IpProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for JokerAuthType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"ApiKey" => JokerAuthType::ApiKey, + b"UsernamePassword" => JokerAuthType::UsernamePassword, + } + } + + fn as_str(&self) -> &'static str { + match self { + JokerAuthType::ApiKey => "ApiKey", + JokerAuthType::UsernamePassword => "UsernamePassword", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(JokerAuthType::ApiKey), + 1 => Some(JokerAuthType::UsernamePassword), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for JokerAuthType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for JokerAuthType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for JwtSignatureAlgorithm { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"es256" => JwtSignatureAlgorithm::Es256, + b"es384" => JwtSignatureAlgorithm::Es384, + b"ps256" => JwtSignatureAlgorithm::Ps256, + b"ps384" => JwtSignatureAlgorithm::Ps384, + b"ps512" => JwtSignatureAlgorithm::Ps512, + b"rs256" => JwtSignatureAlgorithm::Rs256, + b"rs384" => JwtSignatureAlgorithm::Rs384, + b"rs512" => JwtSignatureAlgorithm::Rs512, + b"hs256" => JwtSignatureAlgorithm::Hs256, + b"hs384" => JwtSignatureAlgorithm::Hs384, + b"hs512" => JwtSignatureAlgorithm::Hs512, + } + } + + fn as_str(&self) -> &'static str { + match self { + JwtSignatureAlgorithm::Es256 => "es256", + JwtSignatureAlgorithm::Es384 => "es384", + JwtSignatureAlgorithm::Ps256 => "ps256", + JwtSignatureAlgorithm::Ps384 => "ps384", + JwtSignatureAlgorithm::Ps512 => "ps512", + JwtSignatureAlgorithm::Rs256 => "rs256", + JwtSignatureAlgorithm::Rs384 => "rs384", + JwtSignatureAlgorithm::Rs512 => "rs512", + JwtSignatureAlgorithm::Hs256 => "hs256", + JwtSignatureAlgorithm::Hs384 => "hs384", + JwtSignatureAlgorithm::Hs512 => "hs512", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(JwtSignatureAlgorithm::Es256), + 1 => Some(JwtSignatureAlgorithm::Es384), + 2 => Some(JwtSignatureAlgorithm::Ps256), + 3 => Some(JwtSignatureAlgorithm::Ps384), + 4 => Some(JwtSignatureAlgorithm::Ps512), + 5 => Some(JwtSignatureAlgorithm::Rs256), + 6 => Some(JwtSignatureAlgorithm::Rs384), + 7 => Some(JwtSignatureAlgorithm::Rs512), + 8 => Some(JwtSignatureAlgorithm::Hs256), + 9 => Some(JwtSignatureAlgorithm::Hs384), + 10 => Some(JwtSignatureAlgorithm::Hs512), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for JwtSignatureAlgorithm { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for JwtSignatureAlgorithm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for Locale { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + Locale, + b"en-US" => Locale::EnUS, + b"aa-DJ" => Locale::AaDJ, + b"aa-ER" => Locale::AaER, + b"ssy-ER" => Locale::SsyER, + b"aa-ET" => Locale::AaET, + b"af-ZA" => Locale::AfZA, + b"agr-PE" => Locale::AgrPE, + b"ak-GH" => Locale::AkGH, + b"am-ET" => Locale::AmET, + b"an-ES" => Locale::AnES, + b"anp-IN" => Locale::AnpIN, + b"ar-AE" => Locale::ArAE, + b"ar-BH" => Locale::ArBH, + b"ar-DZ" => Locale::ArDZ, + b"ar-EG" => Locale::ArEG, + b"ar-IN" => Locale::ArIN, + b"ar-IQ" => Locale::ArIQ, + b"ar-JO" => Locale::ArJO, + b"ar-KW" => Locale::ArKW, + b"ar-LB" => Locale::ArLB, + b"ar-LY" => Locale::ArLY, + b"ar-MA" => Locale::ArMA, + b"ar-OM" => Locale::ArOM, + b"ar-QA" => Locale::ArQA, + b"ar-SA" => Locale::ArSA, + b"ar-SD" => Locale::ArSD, + b"ar-SS" => Locale::ArSS, + b"ar-SY" => Locale::ArSY, + b"ar-TN" => Locale::ArTN, + b"ar-YE" => Locale::ArYE, + b"as-IN" => Locale::AsIN, + b"ast-ES" => Locale::AstES, + b"ayc-PE" => Locale::AycPE, + b"az-AZ" => Locale::AzAZ, + b"az-IR" => Locale::AzIR, + b"be-BY" => Locale::BeBY, + b"be-Latn-BY" => Locale::BeLatnBY, + b"bem-ZM" => Locale::BemZM, + b"ber-DZ" => Locale::BerDZ, + b"ber-MA" => Locale::BerMA, + b"bg-BG" => Locale::BgBG, + b"bhb-IN" => Locale::BhbIN, + b"bho-IN" => Locale::BhoIN, + b"bho-NP" => Locale::BhoNP, + b"bi-VU" => Locale::BiVU, + b"bn-BD" => Locale::BnBD, + b"bn-IN" => Locale::BnIN, + b"bo-CN" => Locale::BoCN, + b"bo-IN" => Locale::BoIN, + b"br-FR" => Locale::BrFR, + b"es-419" => Locale::Es419, + b"brx-IN" => Locale::BrxIN, + b"bs-BA" => Locale::BsBA, + b"byn-ER" => Locale::BynER, + b"ca-AD" => Locale::CaAD, + b"ca-ES" => Locale::CaES, + b"en-150" => Locale::En150, + b"ca-ES-valencia" => Locale::CaESValencia, + b"ca-FR" => Locale::CaFR, + b"ca-IT" => Locale::CaIT, + b"ce-RU" => Locale::CeRU, + b"chr-US" => Locale::ChrUS, + b"cmn-TW" => Locale::CmnTW, + b"crh-UA" => Locale::CrhUA, + b"cs-CZ" => Locale::CsCZ, + b"csb-PL" => Locale::CsbPL, + b"cv-RU" => Locale::CvRU, + b"cy-GB" => Locale::CyGB, + b"da-DK" => Locale::DaDK, + b"de-AT" => Locale::DeAT, + b"de-BE" => Locale::DeBE, + b"en-001" => Locale::En001, + b"de-CH" => Locale::DeCH, + b"de-DE" => Locale::DeDE, + b"de-IT" => Locale::DeIT, + b"de-LI" => Locale::DeLI, + b"de-LU" => Locale::DeLU, + b"ar-001" => Locale::Ar001, + b"doi-IN" => Locale::DoiIN, + b"dsb-DE" => Locale::DsbDE, + b"dv-MV" => Locale::DvMV, + b"dz-BT" => Locale::DzBT, + b"el-CY" => Locale::ElCY, + b"el-GR" => Locale::ElGR, + b"en-AG" => Locale::EnAG, + b"en-AU" => Locale::EnAU, + b"en-BW" => Locale::EnBW, + b"en-CA" => Locale::EnCA, + b"en-DK" => Locale::EnDK, + b"en-GB" => Locale::EnGB, + b"en-HK" => Locale::EnHK, + b"en-IE" => Locale::EnIE, + b"en-IL" => Locale::EnIL, + b"en-IN" => Locale::EnIN, + b"en-NG" => Locale::EnNG, + b"en-NZ" => Locale::EnNZ, + b"en-PH" => Locale::EnPH, + b"en-SC" => Locale::EnSC, + b"en-SG" => Locale::EnSG, + b"en-ZA" => Locale::EnZA, + b"en-ZM" => Locale::EnZM, + b"en-ZW" => Locale::EnZW, + b"eo" => Locale::Eo, + b"es-AR" => Locale::EsAR, + b"es-BO" => Locale::EsBO, + b"es-CL" => Locale::EsCL, + b"es-CO" => Locale::EsCO, + b"es-CR" => Locale::EsCR, + b"es-CU" => Locale::EsCU, + b"es-DO" => Locale::EsDO, + b"es-EC" => Locale::EsEC, + b"es-ES" => Locale::EsES, + b"es-GT" => Locale::EsGT, + b"es-HN" => Locale::EsHN, + b"es-MX" => Locale::EsMX, + b"es-NI" => Locale::EsNI, + b"es-PA" => Locale::EsPA, + b"es-PE" => Locale::EsPE, + b"es-PR" => Locale::EsPR, + b"es-PY" => Locale::EsPY, + b"es-SV" => Locale::EsSV, + b"es-US" => Locale::EsUS, + b"es-UY" => Locale::EsUY, + b"es-VE" => Locale::EsVE, + b"et-EE" => Locale::EtEE, + b"eu-ES" => Locale::EuES, + b"zh-Hans" => Locale::ZhHans, + b"fa-IR" => Locale::FaIR, + b"ff-SN" => Locale::FfSN, + b"fi-FI" => Locale::FiFI, + b"fil-PH" => Locale::FilPH, + b"fo-FO" => Locale::FoFO, + b"fr-BE" => Locale::FrBE, + b"fr-CA" => Locale::FrCA, + b"fr-CH" => Locale::FrCH, + b"fr-FR" => Locale::FrFR, + b"fr-LU" => Locale::FrLU, + b"zh-Hant" => Locale::ZhHant, + b"fur-IT" => Locale::FurIT, + b"fy-DE" => Locale::FyDE, + b"fy-NL" => Locale::FyNL, + b"ga-IE" => Locale::GaIE, + b"zh-Hant-HK" => Locale::ZhHantHK, + b"gd-GB" => Locale::GdGB, + b"gez-ER" => Locale::GezER, + b"sr-Latn-BA" => Locale::SrLatnBA, + b"gez-ET" => Locale::GezET, + b"sr-Latn-ME" => Locale::SrLatnME, + b"gl-ES" => Locale::GlES, + b"az-Latn-AZ" => Locale::AzLatnAZ, + b"gu-IN" => Locale::GuIN, + b"gv-GB" => Locale::GvGB, + b"ha-NG" => Locale::HaNG, + b"hak-TW" => Locale::HakTW, + b"he-IL" => Locale::HeIL, + b"hi-IN" => Locale::HiIN, + b"hif-FJ" => Locale::HifFJ, + b"hne-IN" => Locale::HneIN, + b"hr-HR" => Locale::HrHR, + b"hsb-DE" => Locale::HsbDE, + b"ht-HT" => Locale::HtHT, + b"hu-HU" => Locale::HuHU, + b"hy-AM" => Locale::HyAM, + b"ia-FR" => Locale::IaFR, + b"id-ID" => Locale::IdID, + b"ig-NG" => Locale::IgNG, + b"ik-CA" => Locale::IkCA, + b"is-IS" => Locale::IsIS, + b"it-CH" => Locale::ItCH, + b"it-IT" => Locale::ItIT, + b"iu-CA" => Locale::IuCA, + b"ja-JP" => Locale::JaJP, + b"ka-GE" => Locale::KaGE, + b"kab-DZ" => Locale::KabDZ, + b"kk-KZ" => Locale::KkKZ, + b"kl-GL" => Locale::KlGL, + b"km-KH" => Locale::KmKH, + b"kn-IN" => Locale::KnIN, + b"ko-KR" => Locale::KoKR, + b"kok-IN" => Locale::KokIN, + b"ks-IN" => Locale::KsIN, + b"ks-Deva-IN" => Locale::KsDevaIN, + b"ku-TR" => Locale::KuTR, + b"kw-GB" => Locale::KwGB, + b"ky-KG" => Locale::KyKG, + b"lb-LU" => Locale::LbLU, + b"lg-UG" => Locale::LgUG, + b"li-BE" => Locale::LiBE, + b"li-NL" => Locale::LiNL, + b"lij-IT" => Locale::LijIT, + b"ln-CD" => Locale::LnCD, + b"lo-LA" => Locale::LoLA, + b"lt-LT" => Locale::LtLT, + b"lv-LV" => Locale::LvLV, + b"lzh-TW" => Locale::LzhTW, + b"mag-IN" => Locale::MagIN, + b"mai-IN" => Locale::MaiIN, + b"mai-NP" => Locale::MaiNP, + b"mfe-MU" => Locale::MfeMU, + b"mg-MG" => Locale::MgMG, + b"mhr-RU" => Locale::MhrRU, + b"mi-NZ" => Locale::MiNZ, + b"miq-NI" => Locale::MiqNI, + b"mjw-IN" => Locale::MjwIN, + b"mk-MK" => Locale::MkMK, + b"ml-IN" => Locale::MlIN, + b"mn-MN" => Locale::MnMN, + b"mni-IN" => Locale::MniIN, + b"mnw-MM" => Locale::MnwMM, + b"mr-IN" => Locale::MrIN, + b"ms-MY" => Locale::MsMY, + b"mt-MT" => Locale::MtMT, + b"my-MM" => Locale::MyMM, + b"nan-TW" => Locale::NanTW, + b"nan-Latn-TW" => Locale::NanLatnTW, + b"nb-NO" => Locale::NbNO, + b"nds-DE" => Locale::NdsDE, + b"nds-NL" => Locale::NdsNL, + b"ne-NP" => Locale::NeNP, + b"nhn-MX" => Locale::NhnMX, + b"niu-NU" => Locale::NiuNU, + b"niu-NZ" => Locale::NiuNZ, + b"nl-AW" => Locale::NlAW, + b"nl-BE" => Locale::NlBE, + b"nl-NL" => Locale::NlNL, + b"nn-NO" => Locale::NnNO, + b"nr-ZA" => Locale::NrZA, + b"nso-ZA" => Locale::NsoZA, + b"oc-FR" => Locale::OcFR, + b"om-ET" => Locale::OmET, + b"om-KE" => Locale::OmKE, + b"or-IN" => Locale::OrIN, + b"os-RU" => Locale::OsRU, + b"pa-IN" => Locale::PaIN, + b"pa-PK" => Locale::PaPK, + b"pap-AW" => Locale::PapAW, + b"pap-CW" => Locale::PapCW, + b"pl-PL" => Locale::PlPL, + b"ps-AF" => Locale::PsAF, + b"pt-BR" => Locale::PtBR, + b"pt-PT" => Locale::PtPT, + b"quz-PE" => Locale::QuzPE, + b"raj-IN" => Locale::RajIN, + b"ro-RO" => Locale::RoRO, + b"ru-RU" => Locale::RuRU, + b"ru-UA" => Locale::RuUA, + b"rw-RW" => Locale::RwRW, + b"sa-IN" => Locale::SaIN, + b"sah-RU" => Locale::SahRU, + b"sat-IN" => Locale::SatIN, + b"sc-IT" => Locale::ScIT, + b"sd-IN" => Locale::SdIN, + b"sd-Deva-IN" => Locale::SdDevaIN, + b"se-NO" => Locale::SeNO, + b"sgs-LT" => Locale::SgsLT, + b"shn-MM" => Locale::ShnMM, + b"shs-CA" => Locale::ShsCA, + b"si-LK" => Locale::SiLK, + b"sid-ET" => Locale::SidET, + b"sk-SK" => Locale::SkSK, + b"sl-SI" => Locale::SlSI, + b"sm-WS" => Locale::SmWS, + b"so-DJ" => Locale::SoDJ, + b"so-ET" => Locale::SoET, + b"so-KE" => Locale::SoKE, + b"so-SO" => Locale::SoSO, + b"sq-AL" => Locale::SqAL, + b"sq-MK" => Locale::SqMK, + b"sr-ME" => Locale::SrME, + b"sr-RS" => Locale::SrRS, + b"sr-Latn-RS" => Locale::SrLatnRS, + b"ss-ZA" => Locale::SsZA, + b"st-ZA" => Locale::StZA, + b"sv-FI" => Locale::SvFI, + b"sv-SE" => Locale::SvSE, + b"sw-KE" => Locale::SwKE, + b"sw-TZ" => Locale::SwTZ, + b"szl-PL" => Locale::SzlPL, + b"ta-IN" => Locale::TaIN, + b"ta-LK" => Locale::TaLK, + b"tcy-IN" => Locale::TcyIN, + b"te-IN" => Locale::TeIN, + b"tg-TJ" => Locale::TgTJ, + b"th-TH" => Locale::ThTH, + b"the-NP" => Locale::TheNP, + b"ti-ER" => Locale::TiER, + b"ti-ET" => Locale::TiET, + b"tig-ER" => Locale::TigER, + b"tk-TM" => Locale::TkTM, + b"tl-PH" => Locale::TlPH, + b"tn-ZA" => Locale::TnZA, + b"to-TO" => Locale::ToTO, + b"tpi-PG" => Locale::TpiPG, + b"tr-CY" => Locale::TrCY, + b"tr-TR" => Locale::TrTR, + b"ts-ZA" => Locale::TsZA, + b"tt-RU" => Locale::TtRU, + b"tt-Latn-RU" => Locale::TtLatnRU, + b"ug-CN" => Locale::UgCN, + b"uk-UA" => Locale::UkUA, + b"unm-US" => Locale::UnmUS, + b"ur-IN" => Locale::UrIN, + b"ur-PK" => Locale::UrPK, + b"uz-UZ" => Locale::UzUZ, + b"uz-Cyrl-UZ" => Locale::UzCyrlUZ, + b"ve-ZA" => Locale::VeZA, + b"vi-VN" => Locale::ViVN, + b"wa-BE" => Locale::WaBE, + b"uz-Latn-UZ" => Locale::UzLatnUZ, + b"wae-CH" => Locale::WaeCH, + b"wal-ET" => Locale::WalET, + b"wo-SN" => Locale::WoSN, + b"xh-ZA" => Locale::XhZA, + b"yi-US" => Locale::YiUS, + b"yo-NG" => Locale::YoNG, + b"yue-HK" => Locale::YueHK, + b"yuw-PG" => Locale::YuwPG, + b"zh-CN" => Locale::ZhCN, + b"zh-HK" => Locale::ZhHK, + b"zh-SG" => Locale::ZhSG, + b"zh-TW" => Locale::ZhTW, + b"zu-ZA" => Locale::ZuZA, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + Locale::EnUS => "en-US", + Locale::AaDJ => "aa-DJ", + Locale::AaER => "aa-ER", + Locale::SsyER => "ssy-ER", + Locale::AaET => "aa-ET", + Locale::AfZA => "af-ZA", + Locale::AgrPE => "agr-PE", + Locale::AkGH => "ak-GH", + Locale::AmET => "am-ET", + Locale::AnES => "an-ES", + Locale::AnpIN => "anp-IN", + Locale::ArAE => "ar-AE", + Locale::ArBH => "ar-BH", + Locale::ArDZ => "ar-DZ", + Locale::ArEG => "ar-EG", + Locale::ArIN => "ar-IN", + Locale::ArIQ => "ar-IQ", + Locale::ArJO => "ar-JO", + Locale::ArKW => "ar-KW", + Locale::ArLB => "ar-LB", + Locale::ArLY => "ar-LY", + Locale::ArMA => "ar-MA", + Locale::ArOM => "ar-OM", + Locale::ArQA => "ar-QA", + Locale::ArSA => "ar-SA", + Locale::ArSD => "ar-SD", + Locale::ArSS => "ar-SS", + Locale::ArSY => "ar-SY", + Locale::ArTN => "ar-TN", + Locale::ArYE => "ar-YE", + Locale::AsIN => "as-IN", + Locale::AstES => "ast-ES", + Locale::AycPE => "ayc-PE", + Locale::AzAZ => "az-AZ", + Locale::AzIR => "az-IR", + Locale::BeBY => "be-BY", + Locale::BeLatnBY => "be-Latn-BY", + Locale::BemZM => "bem-ZM", + Locale::BerDZ => "ber-DZ", + Locale::BerMA => "ber-MA", + Locale::BgBG => "bg-BG", + Locale::BhbIN => "bhb-IN", + Locale::BhoIN => "bho-IN", + Locale::BhoNP => "bho-NP", + Locale::BiVU => "bi-VU", + Locale::BnBD => "bn-BD", + Locale::BnIN => "bn-IN", + Locale::BoCN => "bo-CN", + Locale::BoIN => "bo-IN", + Locale::BrFR => "br-FR", + Locale::Es419 => "es-419", + Locale::BrxIN => "brx-IN", + Locale::BsBA => "bs-BA", + Locale::BynER => "byn-ER", + Locale::CaAD => "ca-AD", + Locale::CaES => "ca-ES", + Locale::En150 => "en-150", + Locale::CaESValencia => "ca-ES-valencia", + Locale::CaFR => "ca-FR", + Locale::CaIT => "ca-IT", + Locale::CeRU => "ce-RU", + Locale::ChrUS => "chr-US", + Locale::CmnTW => "cmn-TW", + Locale::CrhUA => "crh-UA", + Locale::CsCZ => "cs-CZ", + Locale::CsbPL => "csb-PL", + Locale::CvRU => "cv-RU", + Locale::CyGB => "cy-GB", + Locale::DaDK => "da-DK", + Locale::DeAT => "de-AT", + Locale::DeBE => "de-BE", + Locale::En001 => "en-001", + Locale::DeCH => "de-CH", + Locale::DeDE => "de-DE", + Locale::DeIT => "de-IT", + Locale::DeLI => "de-LI", + Locale::DeLU => "de-LU", + Locale::Ar001 => "ar-001", + Locale::DoiIN => "doi-IN", + Locale::DsbDE => "dsb-DE", + Locale::DvMV => "dv-MV", + Locale::DzBT => "dz-BT", + Locale::ElCY => "el-CY", + Locale::ElGR => "el-GR", + Locale::EnAG => "en-AG", + Locale::EnAU => "en-AU", + Locale::EnBW => "en-BW", + Locale::EnCA => "en-CA", + Locale::EnDK => "en-DK", + Locale::EnGB => "en-GB", + Locale::EnHK => "en-HK", + Locale::EnIE => "en-IE", + Locale::EnIL => "en-IL", + Locale::EnIN => "en-IN", + Locale::EnNG => "en-NG", + Locale::EnNZ => "en-NZ", + Locale::EnPH => "en-PH", + Locale::EnSC => "en-SC", + Locale::EnSG => "en-SG", + Locale::EnZA => "en-ZA", + Locale::EnZM => "en-ZM", + Locale::EnZW => "en-ZW", + Locale::Eo => "eo", + Locale::EsAR => "es-AR", + Locale::EsBO => "es-BO", + Locale::EsCL => "es-CL", + Locale::EsCO => "es-CO", + Locale::EsCR => "es-CR", + Locale::EsCU => "es-CU", + Locale::EsDO => "es-DO", + Locale::EsEC => "es-EC", + Locale::EsES => "es-ES", + Locale::EsGT => "es-GT", + Locale::EsHN => "es-HN", + Locale::EsMX => "es-MX", + Locale::EsNI => "es-NI", + Locale::EsPA => "es-PA", + Locale::EsPE => "es-PE", + Locale::EsPR => "es-PR", + Locale::EsPY => "es-PY", + Locale::EsSV => "es-SV", + Locale::EsUS => "es-US", + Locale::EsUY => "es-UY", + Locale::EsVE => "es-VE", + Locale::EtEE => "et-EE", + Locale::EuES => "eu-ES", + Locale::ZhHans => "zh-Hans", + Locale::FaIR => "fa-IR", + Locale::FfSN => "ff-SN", + Locale::FiFI => "fi-FI", + Locale::FilPH => "fil-PH", + Locale::FoFO => "fo-FO", + Locale::FrBE => "fr-BE", + Locale::FrCA => "fr-CA", + Locale::FrCH => "fr-CH", + Locale::FrFR => "fr-FR", + Locale::FrLU => "fr-LU", + Locale::ZhHant => "zh-Hant", + Locale::FurIT => "fur-IT", + Locale::FyDE => "fy-DE", + Locale::FyNL => "fy-NL", + Locale::GaIE => "ga-IE", + Locale::ZhHantHK => "zh-Hant-HK", + Locale::GdGB => "gd-GB", + Locale::GezER => "gez-ER", + Locale::SrLatnBA => "sr-Latn-BA", + Locale::GezET => "gez-ET", + Locale::SrLatnME => "sr-Latn-ME", + Locale::GlES => "gl-ES", + Locale::AzLatnAZ => "az-Latn-AZ", + Locale::GuIN => "gu-IN", + Locale::GvGB => "gv-GB", + Locale::HaNG => "ha-NG", + Locale::HakTW => "hak-TW", + Locale::HeIL => "he-IL", + Locale::HiIN => "hi-IN", + Locale::HifFJ => "hif-FJ", + Locale::HneIN => "hne-IN", + Locale::HrHR => "hr-HR", + Locale::HsbDE => "hsb-DE", + Locale::HtHT => "ht-HT", + Locale::HuHU => "hu-HU", + Locale::HyAM => "hy-AM", + Locale::IaFR => "ia-FR", + Locale::IdID => "id-ID", + Locale::IgNG => "ig-NG", + Locale::IkCA => "ik-CA", + Locale::IsIS => "is-IS", + Locale::ItCH => "it-CH", + Locale::ItIT => "it-IT", + Locale::IuCA => "iu-CA", + Locale::JaJP => "ja-JP", + Locale::KaGE => "ka-GE", + Locale::KabDZ => "kab-DZ", + Locale::KkKZ => "kk-KZ", + Locale::KlGL => "kl-GL", + Locale::KmKH => "km-KH", + Locale::KnIN => "kn-IN", + Locale::KoKR => "ko-KR", + Locale::KokIN => "kok-IN", + Locale::KsIN => "ks-IN", + Locale::KsDevaIN => "ks-Deva-IN", + Locale::KuTR => "ku-TR", + Locale::KwGB => "kw-GB", + Locale::KyKG => "ky-KG", + Locale::LbLU => "lb-LU", + Locale::LgUG => "lg-UG", + Locale::LiBE => "li-BE", + Locale::LiNL => "li-NL", + Locale::LijIT => "lij-IT", + Locale::LnCD => "ln-CD", + Locale::LoLA => "lo-LA", + Locale::LtLT => "lt-LT", + Locale::LvLV => "lv-LV", + Locale::LzhTW => "lzh-TW", + Locale::MagIN => "mag-IN", + Locale::MaiIN => "mai-IN", + Locale::MaiNP => "mai-NP", + Locale::MfeMU => "mfe-MU", + Locale::MgMG => "mg-MG", + Locale::MhrRU => "mhr-RU", + Locale::MiNZ => "mi-NZ", + Locale::MiqNI => "miq-NI", + Locale::MjwIN => "mjw-IN", + Locale::MkMK => "mk-MK", + Locale::MlIN => "ml-IN", + Locale::MnMN => "mn-MN", + Locale::MniIN => "mni-IN", + Locale::MnwMM => "mnw-MM", + Locale::MrIN => "mr-IN", + Locale::MsMY => "ms-MY", + Locale::MtMT => "mt-MT", + Locale::MyMM => "my-MM", + Locale::NanTW => "nan-TW", + Locale::NanLatnTW => "nan-Latn-TW", + Locale::NbNO => "nb-NO", + Locale::NdsDE => "nds-DE", + Locale::NdsNL => "nds-NL", + Locale::NeNP => "ne-NP", + Locale::NhnMX => "nhn-MX", + Locale::NiuNU => "niu-NU", + Locale::NiuNZ => "niu-NZ", + Locale::NlAW => "nl-AW", + Locale::NlBE => "nl-BE", + Locale::NlNL => "nl-NL", + Locale::NnNO => "nn-NO", + Locale::NrZA => "nr-ZA", + Locale::NsoZA => "nso-ZA", + Locale::OcFR => "oc-FR", + Locale::OmET => "om-ET", + Locale::OmKE => "om-KE", + Locale::OrIN => "or-IN", + Locale::OsRU => "os-RU", + Locale::PaIN => "pa-IN", + Locale::PaPK => "pa-PK", + Locale::PapAW => "pap-AW", + Locale::PapCW => "pap-CW", + Locale::PlPL => "pl-PL", + Locale::PsAF => "ps-AF", + Locale::PtBR => "pt-BR", + Locale::PtPT => "pt-PT", + Locale::QuzPE => "quz-PE", + Locale::RajIN => "raj-IN", + Locale::RoRO => "ro-RO", + Locale::RuRU => "ru-RU", + Locale::RuUA => "ru-UA", + Locale::RwRW => "rw-RW", + Locale::SaIN => "sa-IN", + Locale::SahRU => "sah-RU", + Locale::SatIN => "sat-IN", + Locale::ScIT => "sc-IT", + Locale::SdIN => "sd-IN", + Locale::SdDevaIN => "sd-Deva-IN", + Locale::SeNO => "se-NO", + Locale::SgsLT => "sgs-LT", + Locale::ShnMM => "shn-MM", + Locale::ShsCA => "shs-CA", + Locale::SiLK => "si-LK", + Locale::SidET => "sid-ET", + Locale::SkSK => "sk-SK", + Locale::SlSI => "sl-SI", + Locale::SmWS => "sm-WS", + Locale::SoDJ => "so-DJ", + Locale::SoET => "so-ET", + Locale::SoKE => "so-KE", + Locale::SoSO => "so-SO", + Locale::SqAL => "sq-AL", + Locale::SqMK => "sq-MK", + Locale::SrME => "sr-ME", + Locale::SrRS => "sr-RS", + Locale::SrLatnRS => "sr-Latn-RS", + Locale::SsZA => "ss-ZA", + Locale::StZA => "st-ZA", + Locale::SvFI => "sv-FI", + Locale::SvSE => "sv-SE", + Locale::SwKE => "sw-KE", + Locale::SwTZ => "sw-TZ", + Locale::SzlPL => "szl-PL", + Locale::TaIN => "ta-IN", + Locale::TaLK => "ta-LK", + Locale::TcyIN => "tcy-IN", + Locale::TeIN => "te-IN", + Locale::TgTJ => "tg-TJ", + Locale::ThTH => "th-TH", + Locale::TheNP => "the-NP", + Locale::TiER => "ti-ER", + Locale::TiET => "ti-ET", + Locale::TigER => "tig-ER", + Locale::TkTM => "tk-TM", + Locale::TlPH => "tl-PH", + Locale::TnZA => "tn-ZA", + Locale::ToTO => "to-TO", + Locale::TpiPG => "tpi-PG", + Locale::TrCY => "tr-CY", + Locale::TrTR => "tr-TR", + Locale::TsZA => "ts-ZA", + Locale::TtRU => "tt-RU", + Locale::TtLatnRU => "tt-Latn-RU", + Locale::UgCN => "ug-CN", + Locale::UkUA => "uk-UA", + Locale::UnmUS => "unm-US", + Locale::UrIN => "ur-IN", + Locale::UrPK => "ur-PK", + Locale::UzUZ => "uz-UZ", + Locale::UzCyrlUZ => "uz-Cyrl-UZ", + Locale::VeZA => "ve-ZA", + Locale::ViVN => "vi-VN", + Locale::WaBE => "wa-BE", + Locale::UzLatnUZ => "uz-Latn-UZ", + Locale::WaeCH => "wae-CH", + Locale::WalET => "wal-ET", + Locale::WoSN => "wo-SN", + Locale::XhZA => "xh-ZA", + Locale::YiUS => "yi-US", + Locale::YoNG => "yo-NG", + Locale::YueHK => "yue-HK", + Locale::YuwPG => "yuw-PG", + Locale::ZhCN => "zh-CN", + Locale::ZhHK => "zh-HK", + Locale::ZhSG => "zh-SG", + Locale::ZhTW => "zh-TW", + Locale::ZuZA => "zu-ZA", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(Locale::EnUS), + 1 => Some(Locale::AaDJ), + 2 => Some(Locale::AaER), + 3 => Some(Locale::SsyER), + 4 => Some(Locale::AaET), + 5 => Some(Locale::AfZA), + 6 => Some(Locale::AgrPE), + 7 => Some(Locale::AkGH), + 8 => Some(Locale::AmET), + 9 => Some(Locale::AnES), + 10 => Some(Locale::AnpIN), + 11 => Some(Locale::ArAE), + 12 => Some(Locale::ArBH), + 13 => Some(Locale::ArDZ), + 14 => Some(Locale::ArEG), + 15 => Some(Locale::ArIN), + 16 => Some(Locale::ArIQ), + 17 => Some(Locale::ArJO), + 18 => Some(Locale::ArKW), + 19 => Some(Locale::ArLB), + 20 => Some(Locale::ArLY), + 21 => Some(Locale::ArMA), + 22 => Some(Locale::ArOM), + 23 => Some(Locale::ArQA), + 24 => Some(Locale::ArSA), + 25 => Some(Locale::ArSD), + 26 => Some(Locale::ArSS), + 27 => Some(Locale::ArSY), + 28 => Some(Locale::ArTN), + 29 => Some(Locale::ArYE), + 30 => Some(Locale::AsIN), + 31 => Some(Locale::AstES), + 32 => Some(Locale::AycPE), + 33 => Some(Locale::AzAZ), + 34 => Some(Locale::AzIR), + 35 => Some(Locale::BeBY), + 36 => Some(Locale::BeLatnBY), + 37 => Some(Locale::BemZM), + 38 => Some(Locale::BerDZ), + 39 => Some(Locale::BerMA), + 40 => Some(Locale::BgBG), + 41 => Some(Locale::BhbIN), + 42 => Some(Locale::BhoIN), + 43 => Some(Locale::BhoNP), + 44 => Some(Locale::BiVU), + 45 => Some(Locale::BnBD), + 46 => Some(Locale::BnIN), + 47 => Some(Locale::BoCN), + 48 => Some(Locale::BoIN), + 49 => Some(Locale::BrFR), + 50 => Some(Locale::Es419), + 51 => Some(Locale::BrxIN), + 52 => Some(Locale::BsBA), + 53 => Some(Locale::BynER), + 54 => Some(Locale::CaAD), + 55 => Some(Locale::CaES), + 56 => Some(Locale::En150), + 57 => Some(Locale::CaESValencia), + 58 => Some(Locale::CaFR), + 59 => Some(Locale::CaIT), + 60 => Some(Locale::CeRU), + 61 => Some(Locale::ChrUS), + 62 => Some(Locale::CmnTW), + 63 => Some(Locale::CrhUA), + 64 => Some(Locale::CsCZ), + 65 => Some(Locale::CsbPL), + 66 => Some(Locale::CvRU), + 67 => Some(Locale::CyGB), + 68 => Some(Locale::DaDK), + 69 => Some(Locale::DeAT), + 71 => Some(Locale::DeBE), + 72 => Some(Locale::En001), + 73 => Some(Locale::DeCH), + 74 => Some(Locale::DeDE), + 76 => Some(Locale::DeIT), + 77 => Some(Locale::DeLI), + 78 => Some(Locale::DeLU), + 79 => Some(Locale::Ar001), + 80 => Some(Locale::DoiIN), + 81 => Some(Locale::DsbDE), + 82 => Some(Locale::DvMV), + 83 => Some(Locale::DzBT), + 84 => Some(Locale::ElCY), + 85 => Some(Locale::ElGR), + 87 => Some(Locale::EnAG), + 88 => Some(Locale::EnAU), + 89 => Some(Locale::EnBW), + 90 => Some(Locale::EnCA), + 91 => Some(Locale::EnDK), + 92 => Some(Locale::EnGB), + 93 => Some(Locale::EnHK), + 94 => Some(Locale::EnIE), + 96 => Some(Locale::EnIL), + 97 => Some(Locale::EnIN), + 98 => Some(Locale::EnNG), + 99 => Some(Locale::EnNZ), + 100 => Some(Locale::EnPH), + 101 => Some(Locale::EnSC), + 102 => Some(Locale::EnSG), + 104 => Some(Locale::EnZA), + 105 => Some(Locale::EnZM), + 106 => Some(Locale::EnZW), + 107 => Some(Locale::Eo), + 108 => Some(Locale::EsAR), + 109 => Some(Locale::EsBO), + 110 => Some(Locale::EsCL), + 111 => Some(Locale::EsCO), + 112 => Some(Locale::EsCR), + 113 => Some(Locale::EsCU), + 114 => Some(Locale::EsDO), + 115 => Some(Locale::EsEC), + 116 => Some(Locale::EsES), + 118 => Some(Locale::EsGT), + 119 => Some(Locale::EsHN), + 120 => Some(Locale::EsMX), + 121 => Some(Locale::EsNI), + 122 => Some(Locale::EsPA), + 123 => Some(Locale::EsPE), + 124 => Some(Locale::EsPR), + 125 => Some(Locale::EsPY), + 126 => Some(Locale::EsSV), + 127 => Some(Locale::EsUS), + 128 => Some(Locale::EsUY), + 129 => Some(Locale::EsVE), + 130 => Some(Locale::EtEE), + 131 => Some(Locale::EuES), + 132 => Some(Locale::ZhHans), + 133 => Some(Locale::FaIR), + 134 => Some(Locale::FfSN), + 135 => Some(Locale::FiFI), + 137 => Some(Locale::FilPH), + 138 => Some(Locale::FoFO), + 139 => Some(Locale::FrBE), + 141 => Some(Locale::FrCA), + 142 => Some(Locale::FrCH), + 143 => Some(Locale::FrFR), + 145 => Some(Locale::FrLU), + 146 => Some(Locale::ZhHant), + 147 => Some(Locale::FurIT), + 148 => Some(Locale::FyDE), + 149 => Some(Locale::FyNL), + 150 => Some(Locale::GaIE), + 151 => Some(Locale::ZhHantHK), + 152 => Some(Locale::GdGB), + 153 => Some(Locale::GezER), + 154 => Some(Locale::SrLatnBA), + 155 => Some(Locale::GezET), + 156 => Some(Locale::SrLatnME), + 157 => Some(Locale::GlES), + 158 => Some(Locale::AzLatnAZ), + 159 => Some(Locale::GuIN), + 160 => Some(Locale::GvGB), + 161 => Some(Locale::HaNG), + 162 => Some(Locale::HakTW), + 163 => Some(Locale::HeIL), + 164 => Some(Locale::HiIN), + 165 => Some(Locale::HifFJ), + 166 => Some(Locale::HneIN), + 167 => Some(Locale::HrHR), + 168 => Some(Locale::HsbDE), + 169 => Some(Locale::HtHT), + 170 => Some(Locale::HuHU), + 171 => Some(Locale::HyAM), + 172 => Some(Locale::IaFR), + 173 => Some(Locale::IdID), + 174 => Some(Locale::IgNG), + 175 => Some(Locale::IkCA), + 176 => Some(Locale::IsIS), + 177 => Some(Locale::ItCH), + 178 => Some(Locale::ItIT), + 180 => Some(Locale::IuCA), + 181 => Some(Locale::JaJP), + 182 => Some(Locale::KaGE), + 183 => Some(Locale::KabDZ), + 184 => Some(Locale::KkKZ), + 185 => Some(Locale::KlGL), + 186 => Some(Locale::KmKH), + 187 => Some(Locale::KnIN), + 188 => Some(Locale::KoKR), + 189 => Some(Locale::KokIN), + 190 => Some(Locale::KsIN), + 191 => Some(Locale::KsDevaIN), + 192 => Some(Locale::KuTR), + 193 => Some(Locale::KwGB), + 194 => Some(Locale::KyKG), + 195 => Some(Locale::LbLU), + 196 => Some(Locale::LgUG), + 197 => Some(Locale::LiBE), + 198 => Some(Locale::LiNL), + 199 => Some(Locale::LijIT), + 200 => Some(Locale::LnCD), + 201 => Some(Locale::LoLA), + 202 => Some(Locale::LtLT), + 203 => Some(Locale::LvLV), + 204 => Some(Locale::LzhTW), + 205 => Some(Locale::MagIN), + 206 => Some(Locale::MaiIN), + 207 => Some(Locale::MaiNP), + 208 => Some(Locale::MfeMU), + 209 => Some(Locale::MgMG), + 210 => Some(Locale::MhrRU), + 211 => Some(Locale::MiNZ), + 212 => Some(Locale::MiqNI), + 213 => Some(Locale::MjwIN), + 214 => Some(Locale::MkMK), + 215 => Some(Locale::MlIN), + 216 => Some(Locale::MnMN), + 217 => Some(Locale::MniIN), + 218 => Some(Locale::MnwMM), + 219 => Some(Locale::MrIN), + 220 => Some(Locale::MsMY), + 221 => Some(Locale::MtMT), + 222 => Some(Locale::MyMM), + 223 => Some(Locale::NanTW), + 224 => Some(Locale::NanLatnTW), + 225 => Some(Locale::NbNO), + 226 => Some(Locale::NdsDE), + 227 => Some(Locale::NdsNL), + 228 => Some(Locale::NeNP), + 229 => Some(Locale::NhnMX), + 230 => Some(Locale::NiuNU), + 231 => Some(Locale::NiuNZ), + 232 => Some(Locale::NlAW), + 233 => Some(Locale::NlBE), + 235 => Some(Locale::NlNL), + 237 => Some(Locale::NnNO), + 238 => Some(Locale::NrZA), + 239 => Some(Locale::NsoZA), + 240 => Some(Locale::OcFR), + 241 => Some(Locale::OmET), + 242 => Some(Locale::OmKE), + 243 => Some(Locale::OrIN), + 244 => Some(Locale::OsRU), + 245 => Some(Locale::PaIN), + 246 => Some(Locale::PaPK), + 247 => Some(Locale::PapAW), + 248 => Some(Locale::PapCW), + 249 => Some(Locale::PlPL), + 250 => Some(Locale::PsAF), + 251 => Some(Locale::PtBR), + 252 => Some(Locale::PtPT), + 254 => Some(Locale::QuzPE), + 255 => Some(Locale::RajIN), + 256 => Some(Locale::RoRO), + 257 => Some(Locale::RuRU), + 258 => Some(Locale::RuUA), + 259 => Some(Locale::RwRW), + 260 => Some(Locale::SaIN), + 261 => Some(Locale::SahRU), + 262 => Some(Locale::SatIN), + 263 => Some(Locale::ScIT), + 264 => Some(Locale::SdIN), + 265 => Some(Locale::SdDevaIN), + 266 => Some(Locale::SeNO), + 267 => Some(Locale::SgsLT), + 268 => Some(Locale::ShnMM), + 269 => Some(Locale::ShsCA), + 270 => Some(Locale::SiLK), + 271 => Some(Locale::SidET), + 272 => Some(Locale::SkSK), + 273 => Some(Locale::SlSI), + 274 => Some(Locale::SmWS), + 275 => Some(Locale::SoDJ), + 276 => Some(Locale::SoET), + 277 => Some(Locale::SoKE), + 278 => Some(Locale::SoSO), + 279 => Some(Locale::SqAL), + 280 => Some(Locale::SqMK), + 281 => Some(Locale::SrME), + 282 => Some(Locale::SrRS), + 283 => Some(Locale::SrLatnRS), + 284 => Some(Locale::SsZA), + 285 => Some(Locale::StZA), + 286 => Some(Locale::SvFI), + 288 => Some(Locale::SvSE), + 289 => Some(Locale::SwKE), + 290 => Some(Locale::SwTZ), + 291 => Some(Locale::SzlPL), + 292 => Some(Locale::TaIN), + 293 => Some(Locale::TaLK), + 294 => Some(Locale::TcyIN), + 295 => Some(Locale::TeIN), + 296 => Some(Locale::TgTJ), + 297 => Some(Locale::ThTH), + 298 => Some(Locale::TheNP), + 299 => Some(Locale::TiER), + 300 => Some(Locale::TiET), + 301 => Some(Locale::TigER), + 302 => Some(Locale::TkTM), + 303 => Some(Locale::TlPH), + 304 => Some(Locale::TnZA), + 305 => Some(Locale::ToTO), + 306 => Some(Locale::TpiPG), + 307 => Some(Locale::TrCY), + 308 => Some(Locale::TrTR), + 309 => Some(Locale::TsZA), + 310 => Some(Locale::TtRU), + 311 => Some(Locale::TtLatnRU), + 312 => Some(Locale::UgCN), + 313 => Some(Locale::UkUA), + 314 => Some(Locale::UnmUS), + 315 => Some(Locale::UrIN), + 316 => Some(Locale::UrPK), + 317 => Some(Locale::UzUZ), + 318 => Some(Locale::UzCyrlUZ), + 319 => Some(Locale::VeZA), + 320 => Some(Locale::ViVN), + 321 => Some(Locale::WaBE), + 322 => Some(Locale::UzLatnUZ), + 323 => Some(Locale::WaeCH), + 324 => Some(Locale::WalET), + 325 => Some(Locale::WoSN), + 326 => Some(Locale::XhZA), + 327 => Some(Locale::YiUS), + 328 => Some(Locale::YoNG), + 329 => Some(Locale::YueHK), + 330 => Some(Locale::YuwPG), + 331 => Some(Locale::ZhCN), + 332 => Some(Locale::ZhHK), + 333 => Some(Locale::ZhSG), + 334 => Some(Locale::ZhTW), + 335 => Some(Locale::ZuZA), + 70 | 75 | 86 | 95 | 103 | 117 | 136 | 140 | 144 | 179 | 234 | 236 | 253 | 287 => { + Some(Locale::EnUS) + } + _ => None, + } + } + + const COUNT: usize = 336; +} + +impl serde::Serialize for Locale { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Locale { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for LogRotateFrequency { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"daily" => LogRotateFrequency::Daily, + b"hourly" => LogRotateFrequency::Hourly, + b"minutely" => LogRotateFrequency::Minutely, + b"never" => LogRotateFrequency::Never, + } + } + + fn as_str(&self) -> &'static str { + match self { + LogRotateFrequency::Daily => "daily", + LogRotateFrequency::Hourly => "hourly", + LogRotateFrequency::Minutely => "minutely", + LogRotateFrequency::Never => "never", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(LogRotateFrequency::Daily), + 1 => Some(LogRotateFrequency::Hourly), + 2 => Some(LogRotateFrequency::Minutely), + 3 => Some(LogRotateFrequency::Never), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for LogRotateFrequency { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for LogRotateFrequency { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for LookupStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"PostgreSql" => LookupStoreType::PostgreSql, + b"MySql" => LookupStoreType::MySql, + b"Sqlite" => LookupStoreType::Sqlite, + b"Sharded" => LookupStoreType::Sharded, + b"Redis" => LookupStoreType::Redis, + b"RedisCluster" => LookupStoreType::RedisCluster, + b"RedisSentinel" => LookupStoreType::RedisSentinel, + } + } + + fn as_str(&self) -> &'static str { + match self { + LookupStoreType::PostgreSql => "PostgreSql", + LookupStoreType::MySql => "MySql", + LookupStoreType::Sqlite => "Sqlite", + LookupStoreType::Sharded => "Sharded", + LookupStoreType::Redis => "Redis", + LookupStoreType::RedisCluster => "RedisCluster", + LookupStoreType::RedisSentinel => "RedisSentinel", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(LookupStoreType::PostgreSql), + 1 => Some(LookupStoreType::MySql), + 2 => Some(LookupStoreType::Sqlite), + 3 => Some(LookupStoreType::Sharded), + 4 => Some(LookupStoreType::Redis), + 5 => Some(LookupStoreType::RedisCluster), + 6 => Some(LookupStoreType::RedisSentinel), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for LookupStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for LookupStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MessageFlag { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"authenticated" => MessageFlag::Authenticated, + b"unauthenticated" => MessageFlag::Unauthenticated, + b"unauthenticatedDmarc" => MessageFlag::UnauthenticatedDmarc, + b"dsn" => MessageFlag::Dsn, + b"report" => MessageFlag::Report, + b"autogenerated" => MessageFlag::Autogenerated, + } + } + + fn as_str(&self) -> &'static str { + match self { + MessageFlag::Authenticated => "authenticated", + MessageFlag::Unauthenticated => "unauthenticated", + MessageFlag::UnauthenticatedDmarc => "unauthenticatedDmarc", + MessageFlag::Dsn => "dsn", + MessageFlag::Report => "report", + MessageFlag::Autogenerated => "autogenerated", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MessageFlag::Authenticated), + 1 => Some(MessageFlag::Unauthenticated), + 2 => Some(MessageFlag::UnauthenticatedDmarc), + 3 => Some(MessageFlag::Dsn), + 4 => Some(MessageFlag::Report), + 5 => Some(MessageFlag::Autogenerated), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for MessageFlag { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MessageFlag { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MetricType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Counter" => MetricType::Counter, + b"Gauge" => MetricType::Gauge, + b"Histogram" => MetricType::Histogram, + } + } + + fn as_str(&self) -> &'static str { + match self { + MetricType::Counter => "Counter", + MetricType::Gauge => "Gauge", + MetricType::Histogram => "Histogram", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MetricType::Counter), + 1 => Some(MetricType::Gauge), + 2 => Some(MetricType::Histogram), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for MetricType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MetricType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MetricsOtelType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => MetricsOtelType::Disabled, + b"Http" => MetricsOtelType::Http, + b"Grpc" => MetricsOtelType::Grpc, + } + } + + fn as_str(&self) -> &'static str { + match self { + MetricsOtelType::Disabled => "Disabled", + MetricsOtelType::Http => "Http", + MetricsOtelType::Grpc => "Grpc", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MetricsOtelType::Disabled), + 1 => Some(MetricsOtelType::Http), + 2 => Some(MetricsOtelType::Grpc), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for MetricsOtelType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MetricsOtelType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MetricsPrometheusType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => MetricsPrometheusType::Disabled, + b"Enabled" => MetricsPrometheusType::Enabled, + } + } + + fn as_str(&self) -> &'static str { + match self { + MetricsPrometheusType::Disabled => "Disabled", + MetricsPrometheusType::Enabled => "Enabled", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MetricsPrometheusType::Disabled), + 1 => Some(MetricsPrometheusType::Enabled), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for MetricsPrometheusType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MetricsPrometheusType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MetricsStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => MetricsStoreType::Disabled, + b"Default" => MetricsStoreType::Default, + b"FoundationDb" => MetricsStoreType::FoundationDb, + b"PostgreSql" => MetricsStoreType::PostgreSql, + b"MySql" => MetricsStoreType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + MetricsStoreType::Disabled => "Disabled", + MetricsStoreType::Default => "Default", + MetricsStoreType::FoundationDb => "FoundationDb", + MetricsStoreType::PostgreSql => "PostgreSql", + MetricsStoreType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MetricsStoreType::Disabled), + 1 => Some(MetricsStoreType::Default), + 2 => Some(MetricsStoreType::FoundationDb), + 3 => Some(MetricsStoreType::PostgreSql), + 4 => Some(MetricsStoreType::MySql), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for MetricsStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MetricsStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MilterVersion { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"v2" => MilterVersion::V2, + b"v6" => MilterVersion::V6, + } + } + + fn as_str(&self) -> &'static str { + match self { + MilterVersion::V2 => "v2", + MilterVersion::V6 => "v6", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MilterVersion::V2), + 1 => Some(MilterVersion::V6), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for MilterVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MilterVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ModelSize { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"16" => ModelSize::V16, + b"17" => ModelSize::V17, + b"18" => ModelSize::V18, + b"19" => ModelSize::V19, + b"20" => ModelSize::V20, + b"21" => ModelSize::V21, + b"22" => ModelSize::V22, + b"23" => ModelSize::V23, + b"24" => ModelSize::V24, + b"25" => ModelSize::V25, + b"26" => ModelSize::V26, + b"27" => ModelSize::V27, + b"28" => ModelSize::V28, + } + } + + fn as_str(&self) -> &'static str { + match self { + ModelSize::V16 => "16", + ModelSize::V17 => "17", + ModelSize::V18 => "18", + ModelSize::V19 => "19", + ModelSize::V20 => "20", + ModelSize::V21 => "21", + ModelSize::V22 => "22", + ModelSize::V23 => "23", + ModelSize::V24 => "24", + ModelSize::V25 => "25", + ModelSize::V26 => "26", + ModelSize::V27 => "27", + ModelSize::V28 => "28", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ModelSize::V16), + 1 => Some(ModelSize::V17), + 2 => Some(ModelSize::V18), + 3 => Some(ModelSize::V19), + 4 => Some(ModelSize::V20), + 5 => Some(ModelSize::V21), + 6 => Some(ModelSize::V22), + 7 => Some(ModelSize::V23), + 8 => Some(ModelSize::V24), + 9 => Some(ModelSize::V25), + 10 => Some(ModelSize::V26), + 11 => Some(ModelSize::V27), + 12 => Some(ModelSize::V28), + _ => None, + } + } + + const COUNT: usize = 13; +} + +impl serde::Serialize for ModelSize { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ModelSize { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaDeliveryExpirationType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Ttl" => MtaDeliveryExpirationType::Ttl, + b"Attempts" => MtaDeliveryExpirationType::Attempts, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaDeliveryExpirationType::Ttl => "Ttl", + MtaDeliveryExpirationType::Attempts => "Attempts", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaDeliveryExpirationType::Ttl), + 1 => Some(MtaDeliveryExpirationType::Attempts), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for MtaDeliveryExpirationType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaDeliveryExpirationType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaDeliveryScheduleIntervalsOrDefaultType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => MtaDeliveryScheduleIntervalsOrDefaultType::Default, + b"Custom" => MtaDeliveryScheduleIntervalsOrDefaultType::Custom, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaDeliveryScheduleIntervalsOrDefaultType::Default => "Default", + MtaDeliveryScheduleIntervalsOrDefaultType::Custom => "Custom", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaDeliveryScheduleIntervalsOrDefaultType::Default), + 1 => Some(MtaDeliveryScheduleIntervalsOrDefaultType::Custom), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for MtaDeliveryScheduleIntervalsOrDefaultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaDeliveryScheduleIntervalsOrDefaultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaInboundThrottleKey { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"listener" => MtaInboundThrottleKey::Listener, + b"remoteIp" => MtaInboundThrottleKey::RemoteIp, + b"localIp" => MtaInboundThrottleKey::LocalIp, + b"authenticatedAs" => MtaInboundThrottleKey::AuthenticatedAs, + b"heloDomain" => MtaInboundThrottleKey::HeloDomain, + b"sender" => MtaInboundThrottleKey::Sender, + b"senderDomain" => MtaInboundThrottleKey::SenderDomain, + b"rcpt" => MtaInboundThrottleKey::Rcpt, + b"rcptDomain" => MtaInboundThrottleKey::RcptDomain, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaInboundThrottleKey::Listener => "listener", + MtaInboundThrottleKey::RemoteIp => "remoteIp", + MtaInboundThrottleKey::LocalIp => "localIp", + MtaInboundThrottleKey::AuthenticatedAs => "authenticatedAs", + MtaInboundThrottleKey::HeloDomain => "heloDomain", + MtaInboundThrottleKey::Sender => "sender", + MtaInboundThrottleKey::SenderDomain => "senderDomain", + MtaInboundThrottleKey::Rcpt => "rcpt", + MtaInboundThrottleKey::RcptDomain => "rcptDomain", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaInboundThrottleKey::Listener), + 1 => Some(MtaInboundThrottleKey::RemoteIp), + 2 => Some(MtaInboundThrottleKey::LocalIp), + 3 => Some(MtaInboundThrottleKey::AuthenticatedAs), + 4 => Some(MtaInboundThrottleKey::HeloDomain), + 5 => Some(MtaInboundThrottleKey::Sender), + 6 => Some(MtaInboundThrottleKey::SenderDomain), + 7 => Some(MtaInboundThrottleKey::Rcpt), + 8 => Some(MtaInboundThrottleKey::RcptDomain), + _ => None, + } + } + + const COUNT: usize = 9; +} + +impl serde::Serialize for MtaInboundThrottleKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaInboundThrottleKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaIpStrategy { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"v4ThenV6" => MtaIpStrategy::V4ThenV6, + b"v6ThenV4" => MtaIpStrategy::V6ThenV4, + b"v4Only" => MtaIpStrategy::V4Only, + b"v6Only" => MtaIpStrategy::V6Only, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaIpStrategy::V4ThenV6 => "v4ThenV6", + MtaIpStrategy::V6ThenV4 => "v6ThenV4", + MtaIpStrategy::V4Only => "v4Only", + MtaIpStrategy::V6Only => "v6Only", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaIpStrategy::V4ThenV6), + 1 => Some(MtaIpStrategy::V6ThenV4), + 2 => Some(MtaIpStrategy::V4Only), + 3 => Some(MtaIpStrategy::V6Only), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for MtaIpStrategy { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaIpStrategy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaOutboundThrottleKey { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"mx" => MtaOutboundThrottleKey::Mx, + b"remoteIp" => MtaOutboundThrottleKey::RemoteIp, + b"localIp" => MtaOutboundThrottleKey::LocalIp, + b"sender" => MtaOutboundThrottleKey::Sender, + b"senderDomain" => MtaOutboundThrottleKey::SenderDomain, + b"rcptDomain" => MtaOutboundThrottleKey::RcptDomain, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaOutboundThrottleKey::Mx => "mx", + MtaOutboundThrottleKey::RemoteIp => "remoteIp", + MtaOutboundThrottleKey::LocalIp => "localIp", + MtaOutboundThrottleKey::Sender => "sender", + MtaOutboundThrottleKey::SenderDomain => "senderDomain", + MtaOutboundThrottleKey::RcptDomain => "rcptDomain", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaOutboundThrottleKey::Mx), + 1 => Some(MtaOutboundThrottleKey::RemoteIp), + 2 => Some(MtaOutboundThrottleKey::LocalIp), + 3 => Some(MtaOutboundThrottleKey::Sender), + 4 => Some(MtaOutboundThrottleKey::SenderDomain), + 5 => Some(MtaOutboundThrottleKey::RcptDomain), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for MtaOutboundThrottleKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaOutboundThrottleKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"smtp" => MtaProtocol::Smtp, + b"lmtp" => MtaProtocol::Lmtp, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaProtocol::Smtp => "smtp", + MtaProtocol::Lmtp => "lmtp", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaProtocol::Smtp), + 1 => Some(MtaProtocol::Lmtp), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for MtaProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaQueueQuotaKey { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"sender" => MtaQueueQuotaKey::Sender, + b"senderDomain" => MtaQueueQuotaKey::SenderDomain, + b"rcpt" => MtaQueueQuotaKey::Rcpt, + b"rcptDomain" => MtaQueueQuotaKey::RcptDomain, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaQueueQuotaKey::Sender => "sender", + MtaQueueQuotaKey::SenderDomain => "senderDomain", + MtaQueueQuotaKey::Rcpt => "rcpt", + MtaQueueQuotaKey::RcptDomain => "rcptDomain", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaQueueQuotaKey::Sender), + 1 => Some(MtaQueueQuotaKey::SenderDomain), + 2 => Some(MtaQueueQuotaKey::Rcpt), + 3 => Some(MtaQueueQuotaKey::RcptDomain), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for MtaQueueQuotaKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaQueueQuotaKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaRequiredOrOptional { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"optional" => MtaRequiredOrOptional::Optional, + b"require" => MtaRequiredOrOptional::Require, + b"disable" => MtaRequiredOrOptional::Disable, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaRequiredOrOptional::Optional => "optional", + MtaRequiredOrOptional::Require => "require", + MtaRequiredOrOptional::Disable => "disable", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaRequiredOrOptional::Optional), + 1 => Some(MtaRequiredOrOptional::Require), + 2 => Some(MtaRequiredOrOptional::Disable), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for MtaRequiredOrOptional { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaRequiredOrOptional { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaRouteType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Mx" => MtaRouteType::Mx, + b"Relay" => MtaRouteType::Relay, + b"Local" => MtaRouteType::Local, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaRouteType::Mx => "Mx", + MtaRouteType::Relay => "Relay", + MtaRouteType::Local => "Local", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaRouteType::Mx), + 1 => Some(MtaRouteType::Relay), + 2 => Some(MtaRouteType::Local), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for MtaRouteType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaRouteType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for MtaStage { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"connect" => MtaStage::Connect, + b"ehlo" => MtaStage::Ehlo, + b"auth" => MtaStage::Auth, + b"mail" => MtaStage::Mail, + b"rcpt" => MtaStage::Rcpt, + b"data" => MtaStage::Data, + } + } + + fn as_str(&self) -> &'static str { + match self { + MtaStage::Connect => "connect", + MtaStage::Ehlo => "ehlo", + MtaStage::Auth => "auth", + MtaStage::Mail => "mail", + MtaStage::Rcpt => "rcpt", + MtaStage::Data => "data", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(MtaStage::Connect), + 1 => Some(MtaStage::Ehlo), + 2 => Some(MtaStage::Auth), + 3 => Some(MtaStage::Mail), + 4 => Some(MtaStage::Rcpt), + 5 => Some(MtaStage::Data), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for MtaStage { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MtaStage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for NetworkListenerProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"smtp" => NetworkListenerProtocol::Smtp, + b"lmtp" => NetworkListenerProtocol::Lmtp, + b"http" => NetworkListenerProtocol::Http, + b"imap" => NetworkListenerProtocol::Imap, + b"pop3" => NetworkListenerProtocol::Pop3, + b"manageSieve" => NetworkListenerProtocol::ManageSieve, + } + } + + fn as_str(&self) -> &'static str { + match self { + NetworkListenerProtocol::Smtp => "smtp", + NetworkListenerProtocol::Lmtp => "lmtp", + NetworkListenerProtocol::Http => "http", + NetworkListenerProtocol::Imap => "imap", + NetworkListenerProtocol::Pop3 => "pop3", + NetworkListenerProtocol::ManageSieve => "manageSieve", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(NetworkListenerProtocol::Smtp), + 1 => Some(NetworkListenerProtocol::Lmtp), + 2 => Some(NetworkListenerProtocol::Http), + 3 => Some(NetworkListenerProtocol::Imap), + 4 => Some(NetworkListenerProtocol::Pop3), + 5 => Some(NetworkListenerProtocol::ManageSieve), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for NetworkListenerProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for NetworkListenerProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for OvhEndpoint { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"ovh-eu" => OvhEndpoint::OvhEu, + b"ovh-ca" => OvhEndpoint::OvhCa, + b"kimsufi-eu" => OvhEndpoint::KimsufiEu, + b"kimsufi-ca" => OvhEndpoint::KimsufiCa, + b"soyoustart-eu" => OvhEndpoint::SoyoustartEu, + b"soyoustart-ca" => OvhEndpoint::SoyoustartCa, + } + } + + fn as_str(&self) -> &'static str { + match self { + OvhEndpoint::OvhEu => "ovh-eu", + OvhEndpoint::OvhCa => "ovh-ca", + OvhEndpoint::KimsufiEu => "kimsufi-eu", + OvhEndpoint::KimsufiCa => "kimsufi-ca", + OvhEndpoint::SoyoustartEu => "soyoustart-eu", + OvhEndpoint::SoyoustartCa => "soyoustart-ca", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(OvhEndpoint::OvhEu), + 1 => Some(OvhEndpoint::OvhCa), + 2 => Some(OvhEndpoint::KimsufiEu), + 3 => Some(OvhEndpoint::KimsufiCa), + 4 => Some(OvhEndpoint::SoyoustartEu), + 5 => Some(OvhEndpoint::SoyoustartCa), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for OvhEndpoint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for OvhEndpoint { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PasswordHashAlgorithm { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"argon2id" => PasswordHashAlgorithm::Argon2id, + b"bcrypt" => PasswordHashAlgorithm::Bcrypt, + b"scrypt" => PasswordHashAlgorithm::Scrypt, + b"pbkdf2" => PasswordHashAlgorithm::Pbkdf2, + } + } + + fn as_str(&self) -> &'static str { + match self { + PasswordHashAlgorithm::Argon2id => "argon2id", + PasswordHashAlgorithm::Bcrypt => "bcrypt", + PasswordHashAlgorithm::Scrypt => "scrypt", + PasswordHashAlgorithm::Pbkdf2 => "pbkdf2", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PasswordHashAlgorithm::Argon2id), + 1 => Some(PasswordHashAlgorithm::Bcrypt), + 2 => Some(PasswordHashAlgorithm::Scrypt), + 3 => Some(PasswordHashAlgorithm::Pbkdf2), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for PasswordHashAlgorithm { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PasswordHashAlgorithm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PasswordStrength { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"zero" => PasswordStrength::Zero, + b"one" => PasswordStrength::One, + b"two" => PasswordStrength::Two, + b"three" => PasswordStrength::Three, + b"four" => PasswordStrength::Four, + } + } + + fn as_str(&self) -> &'static str { + match self { + PasswordStrength::Zero => "zero", + PasswordStrength::One => "one", + PasswordStrength::Two => "two", + PasswordStrength::Three => "three", + PasswordStrength::Four => "four", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PasswordStrength::Zero), + 1 => Some(PasswordStrength::One), + 2 => Some(PasswordStrength::Two), + 3 => Some(PasswordStrength::Three), + 4 => Some(PasswordStrength::Four), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for PasswordStrength { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PasswordStrength { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for Permission { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + Permission, + b"authenticate" => Permission::Authenticate, + b"authenticateWithAlias" => Permission::AuthenticateWithAlias, + b"interactAi" => Permission::InteractAi, + b"impersonate" => Permission::Impersonate, + b"unlimitedRequests" => Permission::UnlimitedRequests, + b"unlimitedUploads" => Permission::UnlimitedUploads, + b"fetchAnyBlob" => Permission::FetchAnyBlob, + b"emailSend" => Permission::EmailSend, + b"emailReceive" => Permission::EmailReceive, + b"calendarAlarmsSend" => Permission::CalendarAlarmsSend, + b"calendarSchedulingSend" => Permission::CalendarSchedulingSend, + b"calendarSchedulingReceive" => Permission::CalendarSchedulingReceive, + b"jmapPushSubscriptionGet" => Permission::JmapPushSubscriptionGet, + b"jmapPushSubscriptionCreate" => Permission::JmapPushSubscriptionCreate, + b"jmapPushSubscriptionUpdate" => Permission::JmapPushSubscriptionUpdate, + b"jmapPushSubscriptionDestroy" => Permission::JmapPushSubscriptionDestroy, + b"jmapMailboxGet" => Permission::JmapMailboxGet, + b"jmapMailboxChanges" => Permission::JmapMailboxChanges, + b"jmapMailboxQuery" => Permission::JmapMailboxQuery, + b"jmapMailboxQueryChanges" => Permission::JmapMailboxQueryChanges, + b"jmapMailboxCreate" => Permission::JmapMailboxCreate, + b"jmapMailboxUpdate" => Permission::JmapMailboxUpdate, + b"jmapMailboxDestroy" => Permission::JmapMailboxDestroy, + b"jmapThreadGet" => Permission::JmapThreadGet, + b"jmapThreadChanges" => Permission::JmapThreadChanges, + b"jmapEmailGet" => Permission::JmapEmailGet, + b"jmapEmailChanges" => Permission::JmapEmailChanges, + b"jmapEmailQuery" => Permission::JmapEmailQuery, + b"jmapEmailQueryChanges" => Permission::JmapEmailQueryChanges, + b"jmapEmailCreate" => Permission::JmapEmailCreate, + b"jmapEmailUpdate" => Permission::JmapEmailUpdate, + b"jmapEmailDestroy" => Permission::JmapEmailDestroy, + b"jmapEmailCopy" => Permission::JmapEmailCopy, + b"jmapEmailImport" => Permission::JmapEmailImport, + b"jmapEmailParse" => Permission::JmapEmailParse, + b"jmapSearchSnippetGet" => Permission::JmapSearchSnippetGet, + b"jmapIdentityGet" => Permission::JmapIdentityGet, + b"jmapIdentityChanges" => Permission::JmapIdentityChanges, + b"jmapIdentityCreate" => Permission::JmapIdentityCreate, + b"jmapIdentityUpdate" => Permission::JmapIdentityUpdate, + b"jmapIdentityDestroy" => Permission::JmapIdentityDestroy, + b"jmapEmailSubmissionGet" => Permission::JmapEmailSubmissionGet, + b"jmapEmailSubmissionChanges" => Permission::JmapEmailSubmissionChanges, + b"jmapEmailSubmissionQuery" => Permission::JmapEmailSubmissionQuery, + b"jmapEmailSubmissionQueryChanges" => Permission::JmapEmailSubmissionQueryChanges, + b"jmapEmailSubmissionCreate" => Permission::JmapEmailSubmissionCreate, + b"jmapEmailSubmissionUpdate" => Permission::JmapEmailSubmissionUpdate, + b"jmapEmailSubmissionDestroy" => Permission::JmapEmailSubmissionDestroy, + b"jmapVacationResponseGet" => Permission::JmapVacationResponseGet, + b"jmapVacationResponseCreate" => Permission::JmapVacationResponseCreate, + b"jmapVacationResponseUpdate" => Permission::JmapVacationResponseUpdate, + b"jmapVacationResponseDestroy" => Permission::JmapVacationResponseDestroy, + b"jmapSieveScriptGet" => Permission::JmapSieveScriptGet, + b"jmapSieveScriptQuery" => Permission::JmapSieveScriptQuery, + b"jmapSieveScriptValidate" => Permission::JmapSieveScriptValidate, + b"jmapSieveScriptCreate" => Permission::JmapSieveScriptCreate, + b"jmapSieveScriptUpdate" => Permission::JmapSieveScriptUpdate, + b"jmapSieveScriptDestroy" => Permission::JmapSieveScriptDestroy, + b"jmapPrincipalGet" => Permission::JmapPrincipalGet, + b"jmapPrincipalQuery" => Permission::JmapPrincipalQuery, + b"jmapPrincipalChanges" => Permission::JmapPrincipalChanges, + b"jmapPrincipalQueryChanges" => Permission::JmapPrincipalQueryChanges, + b"jmapPrincipalGetAvailability" => Permission::JmapPrincipalGetAvailability, + b"jmapPrincipalCreate" => Permission::JmapPrincipalCreate, + b"jmapPrincipalUpdate" => Permission::JmapPrincipalUpdate, + b"jmapPrincipalDestroy" => Permission::JmapPrincipalDestroy, + b"jmapQuotaGet" => Permission::JmapQuotaGet, + b"jmapQuotaChanges" => Permission::JmapQuotaChanges, + b"jmapQuotaQuery" => Permission::JmapQuotaQuery, + b"jmapQuotaQueryChanges" => Permission::JmapQuotaQueryChanges, + b"jmapBlobGet" => Permission::JmapBlobGet, + b"jmapBlobCopy" => Permission::JmapBlobCopy, + b"jmapBlobLookup" => Permission::JmapBlobLookup, + b"jmapBlobUpload" => Permission::JmapBlobUpload, + b"jmapAddressBookGet" => Permission::JmapAddressBookGet, + b"jmapAddressBookChanges" => Permission::JmapAddressBookChanges, + b"jmapAddressBookCreate" => Permission::JmapAddressBookCreate, + b"jmapAddressBookUpdate" => Permission::JmapAddressBookUpdate, + b"jmapAddressBookDestroy" => Permission::JmapAddressBookDestroy, + b"jmapContactCardGet" => Permission::JmapContactCardGet, + b"jmapContactCardChanges" => Permission::JmapContactCardChanges, + b"jmapContactCardQuery" => Permission::JmapContactCardQuery, + b"jmapContactCardQueryChanges" => Permission::JmapContactCardQueryChanges, + b"jmapContactCardCreate" => Permission::JmapContactCardCreate, + b"jmapContactCardUpdate" => Permission::JmapContactCardUpdate, + b"jmapContactCardDestroy" => Permission::JmapContactCardDestroy, + b"jmapContactCardCopy" => Permission::JmapContactCardCopy, + b"jmapContactCardParse" => Permission::JmapContactCardParse, + b"jmapFileNodeGet" => Permission::JmapFileNodeGet, + b"jmapFileNodeChanges" => Permission::JmapFileNodeChanges, + b"jmapFileNodeQuery" => Permission::JmapFileNodeQuery, + b"jmapFileNodeQueryChanges" => Permission::JmapFileNodeQueryChanges, + b"jmapFileNodeCreate" => Permission::JmapFileNodeCreate, + b"jmapFileNodeUpdate" => Permission::JmapFileNodeUpdate, + b"jmapFileNodeDestroy" => Permission::JmapFileNodeDestroy, + b"jmapFileNodeCopy" => Permission::JmapFileNodeCopy, + b"jmapShareNotificationGet" => Permission::JmapShareNotificationGet, + b"jmapShareNotificationChanges" => Permission::JmapShareNotificationChanges, + b"jmapShareNotificationQuery" => Permission::JmapShareNotificationQuery, + b"jmapShareNotificationQueryChanges" => Permission::JmapShareNotificationQueryChanges, + b"jmapShareNotificationCreate" => Permission::JmapShareNotificationCreate, + b"jmapShareNotificationUpdate" => Permission::JmapShareNotificationUpdate, + b"jmapShareNotificationDestroy" => Permission::JmapShareNotificationDestroy, + b"jmapCalendarGet" => Permission::JmapCalendarGet, + b"jmapCalendarChanges" => Permission::JmapCalendarChanges, + b"jmapCalendarCreate" => Permission::JmapCalendarCreate, + b"jmapCalendarUpdate" => Permission::JmapCalendarUpdate, + b"jmapCalendarDestroy" => Permission::JmapCalendarDestroy, + b"jmapCalendarEventGet" => Permission::JmapCalendarEventGet, + b"jmapCalendarEventChanges" => Permission::JmapCalendarEventChanges, + b"jmapCalendarEventQuery" => Permission::JmapCalendarEventQuery, + b"jmapCalendarEventQueryChanges" => Permission::JmapCalendarEventQueryChanges, + b"jmapCalendarEventCreate" => Permission::JmapCalendarEventCreate, + b"jmapCalendarEventUpdate" => Permission::JmapCalendarEventUpdate, + b"jmapCalendarEventDestroy" => Permission::JmapCalendarEventDestroy, + b"jmapCalendarEventCopy" => Permission::JmapCalendarEventCopy, + b"jmapCalendarEventParse" => Permission::JmapCalendarEventParse, + b"jmapCalendarEventNotificationGet" => Permission::JmapCalendarEventNotificationGet, + b"jmapCalendarEventNotificationChanges" => Permission::JmapCalendarEventNotificationChanges, + b"jmapCalendarEventNotificationQuery" => Permission::JmapCalendarEventNotificationQuery, + b"jmapCalendarEventNotificationQueryChanges" => Permission::JmapCalendarEventNotificationQueryChanges, + b"jmapCalendarEventNotificationCreate" => Permission::JmapCalendarEventNotificationCreate, + b"jmapCalendarEventNotificationUpdate" => Permission::JmapCalendarEventNotificationUpdate, + b"jmapCalendarEventNotificationDestroy" => Permission::JmapCalendarEventNotificationDestroy, + b"jmapParticipantIdentityGet" => Permission::JmapParticipantIdentityGet, + b"jmapParticipantIdentityChanges" => Permission::JmapParticipantIdentityChanges, + b"jmapParticipantIdentityCreate" => Permission::JmapParticipantIdentityCreate, + b"jmapParticipantIdentityUpdate" => Permission::JmapParticipantIdentityUpdate, + b"jmapParticipantIdentityDestroy" => Permission::JmapParticipantIdentityDestroy, + b"jmapCoreEcho" => Permission::JmapCoreEcho, + b"imapAuthenticate" => Permission::ImapAuthenticate, + b"imapAclGet" => Permission::ImapAclGet, + b"imapAclSet" => Permission::ImapAclSet, + b"imapMyRights" => Permission::ImapMyRights, + b"imapListRights" => Permission::ImapListRights, + b"imapAppend" => Permission::ImapAppend, + b"imapCapability" => Permission::ImapCapability, + b"imapId" => Permission::ImapId, + b"imapCopy" => Permission::ImapCopy, + b"imapMove" => Permission::ImapMove, + b"imapCreate" => Permission::ImapCreate, + b"imapDelete" => Permission::ImapDelete, + b"imapEnable" => Permission::ImapEnable, + b"imapExpunge" => Permission::ImapExpunge, + b"imapFetch" => Permission::ImapFetch, + b"imapIdle" => Permission::ImapIdle, + b"imapList" => Permission::ImapList, + b"imapLsub" => Permission::ImapLsub, + b"imapNamespace" => Permission::ImapNamespace, + b"imapRename" => Permission::ImapRename, + b"imapSearch" => Permission::ImapSearch, + b"imapSort" => Permission::ImapSort, + b"imapSelect" => Permission::ImapSelect, + b"imapExamine" => Permission::ImapExamine, + b"imapStatus" => Permission::ImapStatus, + b"imapStore" => Permission::ImapStore, + b"imapSubscribe" => Permission::ImapSubscribe, + b"imapThread" => Permission::ImapThread, + b"pop3Authenticate" => Permission::Pop3Authenticate, + b"pop3List" => Permission::Pop3List, + b"pop3Uidl" => Permission::Pop3Uidl, + b"pop3Stat" => Permission::Pop3Stat, + b"pop3Retr" => Permission::Pop3Retr, + b"pop3Dele" => Permission::Pop3Dele, + b"sieveAuthenticate" => Permission::SieveAuthenticate, + b"sieveListScripts" => Permission::SieveListScripts, + b"sieveSetActive" => Permission::SieveSetActive, + b"sieveGetScript" => Permission::SieveGetScript, + b"sievePutScript" => Permission::SievePutScript, + b"sieveDeleteScript" => Permission::SieveDeleteScript, + b"sieveRenameScript" => Permission::SieveRenameScript, + b"sieveCheckScript" => Permission::SieveCheckScript, + b"sieveHaveSpace" => Permission::SieveHaveSpace, + b"davSyncCollection" => Permission::DavSyncCollection, + b"davExpandProperty" => Permission::DavExpandProperty, + b"davPrincipalAcl" => Permission::DavPrincipalAcl, + b"davPrincipalList" => Permission::DavPrincipalList, + b"davPrincipalMatch" => Permission::DavPrincipalMatch, + b"davPrincipalSearch" => Permission::DavPrincipalSearch, + b"davPrincipalSearchPropSet" => Permission::DavPrincipalSearchPropSet, + b"davFilePropFind" => Permission::DavFilePropFind, + b"davFilePropPatch" => Permission::DavFilePropPatch, + b"davFileGet" => Permission::DavFileGet, + b"davFileMkCol" => Permission::DavFileMkCol, + b"davFileDelete" => Permission::DavFileDelete, + b"davFilePut" => Permission::DavFilePut, + b"davFileCopy" => Permission::DavFileCopy, + b"davFileMove" => Permission::DavFileMove, + b"davFileLock" => Permission::DavFileLock, + b"davFileAcl" => Permission::DavFileAcl, + b"davCardPropFind" => Permission::DavCardPropFind, + b"davCardPropPatch" => Permission::DavCardPropPatch, + b"davCardGet" => Permission::DavCardGet, + b"davCardMkCol" => Permission::DavCardMkCol, + b"davCardDelete" => Permission::DavCardDelete, + b"davCardPut" => Permission::DavCardPut, + b"davCardCopy" => Permission::DavCardCopy, + b"davCardMove" => Permission::DavCardMove, + b"davCardLock" => Permission::DavCardLock, + b"davCardAcl" => Permission::DavCardAcl, + b"davCardQuery" => Permission::DavCardQuery, + b"davCardMultiGet" => Permission::DavCardMultiGet, + b"davCalPropFind" => Permission::DavCalPropFind, + b"davCalPropPatch" => Permission::DavCalPropPatch, + b"davCalGet" => Permission::DavCalGet, + b"davCalMkCol" => Permission::DavCalMkCol, + b"davCalDelete" => Permission::DavCalDelete, + b"davCalPut" => Permission::DavCalPut, + b"davCalCopy" => Permission::DavCalCopy, + b"davCalMove" => Permission::DavCalMove, + b"davCalLock" => Permission::DavCalLock, + b"davCalAcl" => Permission::DavCalAcl, + b"davCalQuery" => Permission::DavCalQuery, + b"davCalMultiGet" => Permission::DavCalMultiGet, + b"davCalFreeBusyQuery" => Permission::DavCalFreeBusyQuery, + b"oAuthClientRegistration" => Permission::OAuthClientRegistration, + b"oAuthClientOverride" => Permission::OAuthClientOverride, + b"liveTracing" => Permission::LiveTracing, + b"liveMetrics" => Permission::LiveMetrics, + b"liveDeliveryTest" => Permission::LiveDeliveryTest, + b"scimAccess" => Permission::ScimAccess, + b"sysAccountGet" => Permission::SysAccountGet, + b"sysAccountCreate" => Permission::SysAccountCreate, + b"sysAccountUpdate" => Permission::SysAccountUpdate, + b"sysAccountDestroy" => Permission::SysAccountDestroy, + b"sysAccountQuery" => Permission::SysAccountQuery, + b"sysAccountPasswordGet" => Permission::SysAccountPasswordGet, + b"sysAccountPasswordUpdate" => Permission::SysAccountPasswordUpdate, + b"sysAccountSettingsGet" => Permission::SysAccountSettingsGet, + b"sysAccountSettingsUpdate" => Permission::SysAccountSettingsUpdate, + b"sysAcmeProviderGet" => Permission::SysAcmeProviderGet, + b"sysAcmeProviderCreate" => Permission::SysAcmeProviderCreate, + b"sysAcmeProviderUpdate" => Permission::SysAcmeProviderUpdate, + b"sysAcmeProviderDestroy" => Permission::SysAcmeProviderDestroy, + b"sysAcmeProviderQuery" => Permission::SysAcmeProviderQuery, + b"actionReloadSettings" => Permission::ActionReloadSettings, + b"actionReloadTlsCertificates" => Permission::ActionReloadTlsCertificates, + b"actionReloadLookupStores" => Permission::ActionReloadLookupStores, + b"actionReloadBlockedIps" => Permission::ActionReloadBlockedIps, + b"actionUpdateApps" => Permission::ActionUpdateApps, + b"actionTroubleshootDmarc" => Permission::ActionTroubleshootDmarc, + b"actionClassifySpam" => Permission::ActionClassifySpam, + b"actionInvalidateCaches" => Permission::ActionInvalidateCaches, + b"actionInvalidateNegativeCaches" => Permission::ActionInvalidateNegativeCaches, + b"actionPauseMtaQueue" => Permission::ActionPauseMtaQueue, + b"actionResumeMtaQueue" => Permission::ActionResumeMtaQueue, + b"sysActionGet" => Permission::SysActionGet, + b"sysActionCreate" => Permission::SysActionCreate, + b"sysActionUpdate" => Permission::SysActionUpdate, + b"sysActionDestroy" => Permission::SysActionDestroy, + b"sysActionQuery" => Permission::SysActionQuery, + b"sysAddressBookGet" => Permission::SysAddressBookGet, + b"sysAddressBookUpdate" => Permission::SysAddressBookUpdate, + b"sysAiModelGet" => Permission::SysAiModelGet, + b"sysAiModelCreate" => Permission::SysAiModelCreate, + b"sysAiModelUpdate" => Permission::SysAiModelUpdate, + b"sysAiModelDestroy" => Permission::SysAiModelDestroy, + b"sysAiModelQuery" => Permission::SysAiModelQuery, + b"sysAlertGet" => Permission::SysAlertGet, + b"sysAlertCreate" => Permission::SysAlertCreate, + b"sysAlertUpdate" => Permission::SysAlertUpdate, + b"sysAlertDestroy" => Permission::SysAlertDestroy, + b"sysAlertQuery" => Permission::SysAlertQuery, + b"sysAllowedIpGet" => Permission::SysAllowedIpGet, + b"sysAllowedIpCreate" => Permission::SysAllowedIpCreate, + b"sysAllowedIpUpdate" => Permission::SysAllowedIpUpdate, + b"sysAllowedIpDestroy" => Permission::SysAllowedIpDestroy, + b"sysAllowedIpQuery" => Permission::SysAllowedIpQuery, + b"sysApiKeyGet" => Permission::SysApiKeyGet, + b"sysApiKeyCreate" => Permission::SysApiKeyCreate, + b"sysApiKeyUpdate" => Permission::SysApiKeyUpdate, + b"sysApiKeyDestroy" => Permission::SysApiKeyDestroy, + b"sysApiKeyQuery" => Permission::SysApiKeyQuery, + b"sysAppPasswordGet" => Permission::SysAppPasswordGet, + b"sysAppPasswordCreate" => Permission::SysAppPasswordCreate, + b"sysAppPasswordUpdate" => Permission::SysAppPasswordUpdate, + b"sysAppPasswordDestroy" => Permission::SysAppPasswordDestroy, + b"sysAppPasswordQuery" => Permission::SysAppPasswordQuery, + b"sysApplicationGet" => Permission::SysApplicationGet, + b"sysApplicationCreate" => Permission::SysApplicationCreate, + b"sysApplicationUpdate" => Permission::SysApplicationUpdate, + b"sysApplicationDestroy" => Permission::SysApplicationDestroy, + b"sysApplicationQuery" => Permission::SysApplicationQuery, + b"sysArchivedItemGet" => Permission::SysArchivedItemGet, + b"sysArchivedItemCreate" => Permission::SysArchivedItemCreate, + b"sysArchivedItemUpdate" => Permission::SysArchivedItemUpdate, + b"sysArchivedItemDestroy" => Permission::SysArchivedItemDestroy, + b"sysArchivedItemQuery" => Permission::SysArchivedItemQuery, + b"sysArfExternalReportGet" => Permission::SysArfExternalReportGet, + b"sysArfExternalReportCreate" => Permission::SysArfExternalReportCreate, + b"sysArfExternalReportUpdate" => Permission::SysArfExternalReportUpdate, + b"sysArfExternalReportDestroy" => Permission::SysArfExternalReportDestroy, + b"sysArfExternalReportQuery" => Permission::SysArfExternalReportQuery, + b"sysAsnGet" => Permission::SysAsnGet, + b"sysAsnUpdate" => Permission::SysAsnUpdate, + b"sysAuthenticationGet" => Permission::SysAuthenticationGet, + b"sysAuthenticationUpdate" => Permission::SysAuthenticationUpdate, + b"sysBlobStoreGet" => Permission::SysBlobStoreGet, + b"sysBlobStoreUpdate" => Permission::SysBlobStoreUpdate, + b"sysBlockedIpGet" => Permission::SysBlockedIpGet, + b"sysBlockedIpCreate" => Permission::SysBlockedIpCreate, + b"sysBlockedIpUpdate" => Permission::SysBlockedIpUpdate, + b"sysBlockedIpDestroy" => Permission::SysBlockedIpDestroy, + b"sysBlockedIpQuery" => Permission::SysBlockedIpQuery, + b"sysBootstrapGet" => Permission::SysBootstrapGet, + b"sysBootstrapUpdate" => Permission::SysBootstrapUpdate, + b"sysCacheGet" => Permission::SysCacheGet, + b"sysCacheUpdate" => Permission::SysCacheUpdate, + b"sysCalendarGet" => Permission::SysCalendarGet, + b"sysCalendarUpdate" => Permission::SysCalendarUpdate, + b"sysCalendarAlarmGet" => Permission::SysCalendarAlarmGet, + b"sysCalendarAlarmUpdate" => Permission::SysCalendarAlarmUpdate, + b"sysCalendarSchedulingGet" => Permission::SysCalendarSchedulingGet, + b"sysCalendarSchedulingUpdate" => Permission::SysCalendarSchedulingUpdate, + b"sysCertificateGet" => Permission::SysCertificateGet, + b"sysCertificateCreate" => Permission::SysCertificateCreate, + b"sysCertificateUpdate" => Permission::SysCertificateUpdate, + b"sysCertificateDestroy" => Permission::SysCertificateDestroy, + b"sysCertificateQuery" => Permission::SysCertificateQuery, + b"sysClusterNodeGet" => Permission::SysClusterNodeGet, + b"sysClusterNodeCreate" => Permission::SysClusterNodeCreate, + b"sysClusterNodeUpdate" => Permission::SysClusterNodeUpdate, + b"sysClusterNodeDestroy" => Permission::SysClusterNodeDestroy, + b"sysClusterNodeQuery" => Permission::SysClusterNodeQuery, + b"sysClusterRoleGet" => Permission::SysClusterRoleGet, + b"sysClusterRoleCreate" => Permission::SysClusterRoleCreate, + b"sysClusterRoleUpdate" => Permission::SysClusterRoleUpdate, + b"sysClusterRoleDestroy" => Permission::SysClusterRoleDestroy, + b"sysClusterRoleQuery" => Permission::SysClusterRoleQuery, + b"sysCoordinatorGet" => Permission::SysCoordinatorGet, + b"sysCoordinatorUpdate" => Permission::SysCoordinatorUpdate, + b"sysDataRetentionGet" => Permission::SysDataRetentionGet, + b"sysDataRetentionUpdate" => Permission::SysDataRetentionUpdate, + b"sysDataStoreGet" => Permission::SysDataStoreGet, + b"sysDataStoreUpdate" => Permission::SysDataStoreUpdate, + b"sysDirectoryGet" => Permission::SysDirectoryGet, + b"sysDirectoryCreate" => Permission::SysDirectoryCreate, + b"sysDirectoryUpdate" => Permission::SysDirectoryUpdate, + b"sysDirectoryDestroy" => Permission::SysDirectoryDestroy, + b"sysDirectoryQuery" => Permission::SysDirectoryQuery, + b"sysDkimReportSettingsGet" => Permission::SysDkimReportSettingsGet, + b"sysDkimReportSettingsUpdate" => Permission::SysDkimReportSettingsUpdate, + b"sysDkimSignatureGet" => Permission::SysDkimSignatureGet, + b"sysDkimSignatureCreate" => Permission::SysDkimSignatureCreate, + b"sysDkimSignatureUpdate" => Permission::SysDkimSignatureUpdate, + b"sysDkimSignatureDestroy" => Permission::SysDkimSignatureDestroy, + b"sysDkimSignatureQuery" => Permission::SysDkimSignatureQuery, + b"sysDmarcExternalReportGet" => Permission::SysDmarcExternalReportGet, + b"sysDmarcExternalReportCreate" => Permission::SysDmarcExternalReportCreate, + b"sysDmarcExternalReportUpdate" => Permission::SysDmarcExternalReportUpdate, + b"sysDmarcExternalReportDestroy" => Permission::SysDmarcExternalReportDestroy, + b"sysDmarcExternalReportQuery" => Permission::SysDmarcExternalReportQuery, + b"sysDmarcInternalReportGet" => Permission::SysDmarcInternalReportGet, + b"sysDmarcInternalReportCreate" => Permission::SysDmarcInternalReportCreate, + b"sysDmarcInternalReportUpdate" => Permission::SysDmarcInternalReportUpdate, + b"sysDmarcInternalReportDestroy" => Permission::SysDmarcInternalReportDestroy, + b"sysDmarcInternalReportQuery" => Permission::SysDmarcInternalReportQuery, + b"sysDmarcReportSettingsGet" => Permission::SysDmarcReportSettingsGet, + b"sysDmarcReportSettingsUpdate" => Permission::SysDmarcReportSettingsUpdate, + b"sysDnsResolverGet" => Permission::SysDnsResolverGet, + b"sysDnsResolverUpdate" => Permission::SysDnsResolverUpdate, + b"sysDnsServerGet" => Permission::SysDnsServerGet, + b"sysDnsServerCreate" => Permission::SysDnsServerCreate, + b"sysDnsServerUpdate" => Permission::SysDnsServerUpdate, + b"sysDnsServerDestroy" => Permission::SysDnsServerDestroy, + b"sysDnsServerQuery" => Permission::SysDnsServerQuery, + b"sysDomainGet" => Permission::SysDomainGet, + b"sysDomainCreate" => Permission::SysDomainCreate, + b"sysDomainUpdate" => Permission::SysDomainUpdate, + b"sysDomainDestroy" => Permission::SysDomainDestroy, + b"sysDomainQuery" => Permission::SysDomainQuery, + b"sysDsnReportSettingsGet" => Permission::SysDsnReportSettingsGet, + b"sysDsnReportSettingsUpdate" => Permission::SysDsnReportSettingsUpdate, + b"sysEmailGet" => Permission::SysEmailGet, + b"sysEmailUpdate" => Permission::SysEmailUpdate, + b"sysEnterpriseGet" => Permission::SysEnterpriseGet, + b"sysEnterpriseUpdate" => Permission::SysEnterpriseUpdate, + b"sysEventTracingLevelGet" => Permission::SysEventTracingLevelGet, + b"sysEventTracingLevelCreate" => Permission::SysEventTracingLevelCreate, + b"sysEventTracingLevelUpdate" => Permission::SysEventTracingLevelUpdate, + b"sysEventTracingLevelDestroy" => Permission::SysEventTracingLevelDestroy, + b"sysEventTracingLevelQuery" => Permission::SysEventTracingLevelQuery, + b"sysFileStorageGet" => Permission::SysFileStorageGet, + b"sysFileStorageUpdate" => Permission::SysFileStorageUpdate, + b"sysHttpGet" => Permission::SysHttpGet, + b"sysHttpUpdate" => Permission::SysHttpUpdate, + b"sysHttpFormGet" => Permission::SysHttpFormGet, + b"sysHttpFormUpdate" => Permission::SysHttpFormUpdate, + b"sysHttpLookupGet" => Permission::SysHttpLookupGet, + b"sysHttpLookupCreate" => Permission::SysHttpLookupCreate, + b"sysHttpLookupUpdate" => Permission::SysHttpLookupUpdate, + b"sysHttpLookupDestroy" => Permission::SysHttpLookupDestroy, + b"sysHttpLookupQuery" => Permission::SysHttpLookupQuery, + b"sysImapGet" => Permission::SysImapGet, + b"sysImapUpdate" => Permission::SysImapUpdate, + b"sysInMemoryStoreGet" => Permission::SysInMemoryStoreGet, + b"sysInMemoryStoreUpdate" => Permission::SysInMemoryStoreUpdate, + b"sysJmapGet" => Permission::SysJmapGet, + b"sysJmapUpdate" => Permission::SysJmapUpdate, + b"sysLogGet" => Permission::SysLogGet, + b"sysLogCreate" => Permission::SysLogCreate, + b"sysLogUpdate" => Permission::SysLogUpdate, + b"sysLogDestroy" => Permission::SysLogDestroy, + b"sysLogQuery" => Permission::SysLogQuery, + b"sysMailingListGet" => Permission::SysMailingListGet, + b"sysMailingListCreate" => Permission::SysMailingListCreate, + b"sysMailingListUpdate" => Permission::SysMailingListUpdate, + b"sysMailingListDestroy" => Permission::SysMailingListDestroy, + b"sysMailingListQuery" => Permission::SysMailingListQuery, + b"sysMaskedEmailGet" => Permission::SysMaskedEmailGet, + b"sysMaskedEmailCreate" => Permission::SysMaskedEmailCreate, + b"sysMaskedEmailUpdate" => Permission::SysMaskedEmailUpdate, + b"sysMaskedEmailDestroy" => Permission::SysMaskedEmailDestroy, + b"sysMaskedEmailQuery" => Permission::SysMaskedEmailQuery, + b"sysMemoryLookupKeyGet" => Permission::SysMemoryLookupKeyGet, + b"sysMemoryLookupKeyCreate" => Permission::SysMemoryLookupKeyCreate, + b"sysMemoryLookupKeyUpdate" => Permission::SysMemoryLookupKeyUpdate, + b"sysMemoryLookupKeyDestroy" => Permission::SysMemoryLookupKeyDestroy, + b"sysMemoryLookupKeyQuery" => Permission::SysMemoryLookupKeyQuery, + b"sysMemoryLookupKeyValueGet" => Permission::SysMemoryLookupKeyValueGet, + b"sysMemoryLookupKeyValueCreate" => Permission::SysMemoryLookupKeyValueCreate, + b"sysMemoryLookupKeyValueUpdate" => Permission::SysMemoryLookupKeyValueUpdate, + b"sysMemoryLookupKeyValueDestroy" => Permission::SysMemoryLookupKeyValueDestroy, + b"sysMemoryLookupKeyValueQuery" => Permission::SysMemoryLookupKeyValueQuery, + b"sysMetricGet" => Permission::SysMetricGet, + b"sysMetricCreate" => Permission::SysMetricCreate, + b"sysMetricUpdate" => Permission::SysMetricUpdate, + b"sysMetricDestroy" => Permission::SysMetricDestroy, + b"sysMetricQuery" => Permission::SysMetricQuery, + b"sysMetricsGet" => Permission::SysMetricsGet, + b"sysMetricsUpdate" => Permission::SysMetricsUpdate, + b"sysMetricsStoreGet" => Permission::SysMetricsStoreGet, + b"sysMetricsStoreUpdate" => Permission::SysMetricsStoreUpdate, + b"sysMtaConnectionStrategyGet" => Permission::SysMtaConnectionStrategyGet, + b"sysMtaConnectionStrategyCreate" => Permission::SysMtaConnectionStrategyCreate, + b"sysMtaConnectionStrategyUpdate" => Permission::SysMtaConnectionStrategyUpdate, + b"sysMtaConnectionStrategyDestroy" => Permission::SysMtaConnectionStrategyDestroy, + b"sysMtaConnectionStrategyQuery" => Permission::SysMtaConnectionStrategyQuery, + b"sysMtaDeliveryScheduleGet" => Permission::SysMtaDeliveryScheduleGet, + b"sysMtaDeliveryScheduleCreate" => Permission::SysMtaDeliveryScheduleCreate, + b"sysMtaDeliveryScheduleUpdate" => Permission::SysMtaDeliveryScheduleUpdate, + b"sysMtaDeliveryScheduleDestroy" => Permission::SysMtaDeliveryScheduleDestroy, + b"sysMtaDeliveryScheduleQuery" => Permission::SysMtaDeliveryScheduleQuery, + b"sysMtaExtensionsGet" => Permission::SysMtaExtensionsGet, + b"sysMtaExtensionsUpdate" => Permission::SysMtaExtensionsUpdate, + b"sysMtaHookGet" => Permission::SysMtaHookGet, + b"sysMtaHookCreate" => Permission::SysMtaHookCreate, + b"sysMtaHookUpdate" => Permission::SysMtaHookUpdate, + b"sysMtaHookDestroy" => Permission::SysMtaHookDestroy, + b"sysMtaHookQuery" => Permission::SysMtaHookQuery, + b"sysMtaInboundSessionGet" => Permission::SysMtaInboundSessionGet, + b"sysMtaInboundSessionUpdate" => Permission::SysMtaInboundSessionUpdate, + b"sysMtaInboundThrottleGet" => Permission::SysMtaInboundThrottleGet, + b"sysMtaInboundThrottleCreate" => Permission::SysMtaInboundThrottleCreate, + b"sysMtaInboundThrottleUpdate" => Permission::SysMtaInboundThrottleUpdate, + b"sysMtaInboundThrottleDestroy" => Permission::SysMtaInboundThrottleDestroy, + b"sysMtaInboundThrottleQuery" => Permission::SysMtaInboundThrottleQuery, + b"sysMtaMilterGet" => Permission::SysMtaMilterGet, + b"sysMtaMilterCreate" => Permission::SysMtaMilterCreate, + b"sysMtaMilterUpdate" => Permission::SysMtaMilterUpdate, + b"sysMtaMilterDestroy" => Permission::SysMtaMilterDestroy, + b"sysMtaMilterQuery" => Permission::SysMtaMilterQuery, + b"sysMtaOutboundStrategyGet" => Permission::SysMtaOutboundStrategyGet, + b"sysMtaOutboundStrategyUpdate" => Permission::SysMtaOutboundStrategyUpdate, + b"sysMtaOutboundThrottleGet" => Permission::SysMtaOutboundThrottleGet, + b"sysMtaOutboundThrottleCreate" => Permission::SysMtaOutboundThrottleCreate, + b"sysMtaOutboundThrottleUpdate" => Permission::SysMtaOutboundThrottleUpdate, + b"sysMtaOutboundThrottleDestroy" => Permission::SysMtaOutboundThrottleDestroy, + b"sysMtaOutboundThrottleQuery" => Permission::SysMtaOutboundThrottleQuery, + b"sysMtaQueueQuotaGet" => Permission::SysMtaQueueQuotaGet, + b"sysMtaQueueQuotaCreate" => Permission::SysMtaQueueQuotaCreate, + b"sysMtaQueueQuotaUpdate" => Permission::SysMtaQueueQuotaUpdate, + b"sysMtaQueueQuotaDestroy" => Permission::SysMtaQueueQuotaDestroy, + b"sysMtaQueueQuotaQuery" => Permission::SysMtaQueueQuotaQuery, + b"sysMtaRouteGet" => Permission::SysMtaRouteGet, + b"sysMtaRouteCreate" => Permission::SysMtaRouteCreate, + b"sysMtaRouteUpdate" => Permission::SysMtaRouteUpdate, + b"sysMtaRouteDestroy" => Permission::SysMtaRouteDestroy, + b"sysMtaRouteQuery" => Permission::SysMtaRouteQuery, + b"sysMtaStageAuthGet" => Permission::SysMtaStageAuthGet, + b"sysMtaStageAuthUpdate" => Permission::SysMtaStageAuthUpdate, + b"sysMtaStageConnectGet" => Permission::SysMtaStageConnectGet, + b"sysMtaStageConnectUpdate" => Permission::SysMtaStageConnectUpdate, + b"sysMtaStageDataGet" => Permission::SysMtaStageDataGet, + b"sysMtaStageDataUpdate" => Permission::SysMtaStageDataUpdate, + b"sysMtaStageEhloGet" => Permission::SysMtaStageEhloGet, + b"sysMtaStageEhloUpdate" => Permission::SysMtaStageEhloUpdate, + b"sysMtaStageMailGet" => Permission::SysMtaStageMailGet, + b"sysMtaStageMailUpdate" => Permission::SysMtaStageMailUpdate, + b"sysMtaStageRcptGet" => Permission::SysMtaStageRcptGet, + b"sysMtaStageRcptUpdate" => Permission::SysMtaStageRcptUpdate, + b"sysMtaStsGet" => Permission::SysMtaStsGet, + b"sysMtaStsUpdate" => Permission::SysMtaStsUpdate, + b"sysMtaTlsStrategyGet" => Permission::SysMtaTlsStrategyGet, + b"sysMtaTlsStrategyCreate" => Permission::SysMtaTlsStrategyCreate, + b"sysMtaTlsStrategyUpdate" => Permission::SysMtaTlsStrategyUpdate, + b"sysMtaTlsStrategyDestroy" => Permission::SysMtaTlsStrategyDestroy, + b"sysMtaTlsStrategyQuery" => Permission::SysMtaTlsStrategyQuery, + b"sysMtaVirtualQueueGet" => Permission::SysMtaVirtualQueueGet, + b"sysMtaVirtualQueueCreate" => Permission::SysMtaVirtualQueueCreate, + b"sysMtaVirtualQueueUpdate" => Permission::SysMtaVirtualQueueUpdate, + b"sysMtaVirtualQueueDestroy" => Permission::SysMtaVirtualQueueDestroy, + b"sysMtaVirtualQueueQuery" => Permission::SysMtaVirtualQueueQuery, + b"sysNetworkListenerGet" => Permission::SysNetworkListenerGet, + b"sysNetworkListenerCreate" => Permission::SysNetworkListenerCreate, + b"sysNetworkListenerUpdate" => Permission::SysNetworkListenerUpdate, + b"sysNetworkListenerDestroy" => Permission::SysNetworkListenerDestroy, + b"sysNetworkListenerQuery" => Permission::SysNetworkListenerQuery, + b"sysOAuthClientGet" => Permission::SysOAuthClientGet, + b"sysOAuthClientCreate" => Permission::SysOAuthClientCreate, + b"sysOAuthClientUpdate" => Permission::SysOAuthClientUpdate, + b"sysOAuthClientDestroy" => Permission::SysOAuthClientDestroy, + b"sysOAuthClientQuery" => Permission::SysOAuthClientQuery, + b"sysOidcProviderGet" => Permission::SysOidcProviderGet, + b"sysOidcProviderUpdate" => Permission::SysOidcProviderUpdate, + b"sysPublicKeyGet" => Permission::SysPublicKeyGet, + b"sysPublicKeyCreate" => Permission::SysPublicKeyCreate, + b"sysPublicKeyUpdate" => Permission::SysPublicKeyUpdate, + b"sysPublicKeyDestroy" => Permission::SysPublicKeyDestroy, + b"sysPublicKeyQuery" => Permission::SysPublicKeyQuery, + b"sysQueuedMessageGet" => Permission::SysQueuedMessageGet, + b"sysQueuedMessageCreate" => Permission::SysQueuedMessageCreate, + b"sysQueuedMessageUpdate" => Permission::SysQueuedMessageUpdate, + b"sysQueuedMessageDestroy" => Permission::SysQueuedMessageDestroy, + b"sysQueuedMessageQuery" => Permission::SysQueuedMessageQuery, + b"sysReportSettingsGet" => Permission::SysReportSettingsGet, + b"sysReportSettingsUpdate" => Permission::SysReportSettingsUpdate, + b"sysRoleGet" => Permission::SysRoleGet, + b"sysRoleCreate" => Permission::SysRoleCreate, + b"sysRoleUpdate" => Permission::SysRoleUpdate, + b"sysRoleDestroy" => Permission::SysRoleDestroy, + b"sysRoleQuery" => Permission::SysRoleQuery, + b"sysSearchGet" => Permission::SysSearchGet, + b"sysSearchUpdate" => Permission::SysSearchUpdate, + b"sysSearchStoreGet" => Permission::SysSearchStoreGet, + b"sysSearchStoreUpdate" => Permission::SysSearchStoreUpdate, + b"sysSecurityGet" => Permission::SysSecurityGet, + b"sysSecurityUpdate" => Permission::SysSecurityUpdate, + b"sysSenderAuthGet" => Permission::SysSenderAuthGet, + b"sysSenderAuthUpdate" => Permission::SysSenderAuthUpdate, + b"sysSharingGet" => Permission::SysSharingGet, + b"sysSharingUpdate" => Permission::SysSharingUpdate, + b"sysSieveSystemInterpreterGet" => Permission::SysSieveSystemInterpreterGet, + b"sysSieveSystemInterpreterUpdate" => Permission::SysSieveSystemInterpreterUpdate, + b"sysSieveSystemScriptGet" => Permission::SysSieveSystemScriptGet, + b"sysSieveSystemScriptCreate" => Permission::SysSieveSystemScriptCreate, + b"sysSieveSystemScriptUpdate" => Permission::SysSieveSystemScriptUpdate, + b"sysSieveSystemScriptDestroy" => Permission::SysSieveSystemScriptDestroy, + b"sysSieveSystemScriptQuery" => Permission::SysSieveSystemScriptQuery, + b"sysSieveUserInterpreterGet" => Permission::SysSieveUserInterpreterGet, + b"sysSieveUserInterpreterUpdate" => Permission::SysSieveUserInterpreterUpdate, + b"sysSieveUserScriptGet" => Permission::SysSieveUserScriptGet, + b"sysSieveUserScriptCreate" => Permission::SysSieveUserScriptCreate, + b"sysSieveUserScriptUpdate" => Permission::SysSieveUserScriptUpdate, + b"sysSieveUserScriptDestroy" => Permission::SysSieveUserScriptDestroy, + b"sysSieveUserScriptQuery" => Permission::SysSieveUserScriptQuery, + b"sysSpamClassifierGet" => Permission::SysSpamClassifierGet, + b"sysSpamClassifierUpdate" => Permission::SysSpamClassifierUpdate, + b"sysSpamDnsblServerGet" => Permission::SysSpamDnsblServerGet, + b"sysSpamDnsblServerCreate" => Permission::SysSpamDnsblServerCreate, + b"sysSpamDnsblServerUpdate" => Permission::SysSpamDnsblServerUpdate, + b"sysSpamDnsblServerDestroy" => Permission::SysSpamDnsblServerDestroy, + b"sysSpamDnsblServerQuery" => Permission::SysSpamDnsblServerQuery, + b"sysSpamDnsblSettingsGet" => Permission::SysSpamDnsblSettingsGet, + b"sysSpamDnsblSettingsUpdate" => Permission::SysSpamDnsblSettingsUpdate, + b"sysSpamFileExtensionGet" => Permission::SysSpamFileExtensionGet, + b"sysSpamFileExtensionCreate" => Permission::SysSpamFileExtensionCreate, + b"sysSpamFileExtensionUpdate" => Permission::SysSpamFileExtensionUpdate, + b"sysSpamFileExtensionDestroy" => Permission::SysSpamFileExtensionDestroy, + b"sysSpamFileExtensionQuery" => Permission::SysSpamFileExtensionQuery, + b"sysSpamLlmGet" => Permission::SysSpamLlmGet, + b"sysSpamLlmUpdate" => Permission::SysSpamLlmUpdate, + b"sysSpamPyzorGet" => Permission::SysSpamPyzorGet, + b"sysSpamPyzorUpdate" => Permission::SysSpamPyzorUpdate, + b"sysSpamRuleGet" => Permission::SysSpamRuleGet, + b"sysSpamRuleCreate" => Permission::SysSpamRuleCreate, + b"sysSpamRuleUpdate" => Permission::SysSpamRuleUpdate, + b"sysSpamRuleDestroy" => Permission::SysSpamRuleDestroy, + b"sysSpamRuleQuery" => Permission::SysSpamRuleQuery, + b"sysSpamSettingsGet" => Permission::SysSpamSettingsGet, + b"sysSpamSettingsUpdate" => Permission::SysSpamSettingsUpdate, + b"sysSpamTagGet" => Permission::SysSpamTagGet, + b"sysSpamTagCreate" => Permission::SysSpamTagCreate, + b"sysSpamTagUpdate" => Permission::SysSpamTagUpdate, + b"sysSpamTagDestroy" => Permission::SysSpamTagDestroy, + b"sysSpamTagQuery" => Permission::SysSpamTagQuery, + b"sysSpamTrainingSampleGet" => Permission::SysSpamTrainingSampleGet, + b"sysSpamTrainingSampleCreate" => Permission::SysSpamTrainingSampleCreate, + b"sysSpamTrainingSampleUpdate" => Permission::SysSpamTrainingSampleUpdate, + b"sysSpamTrainingSampleDestroy" => Permission::SysSpamTrainingSampleDestroy, + b"sysSpamTrainingSampleQuery" => Permission::SysSpamTrainingSampleQuery, + b"sysSpfReportSettingsGet" => Permission::SysSpfReportSettingsGet, + b"sysSpfReportSettingsUpdate" => Permission::SysSpfReportSettingsUpdate, + b"sysStoreLookupGet" => Permission::SysStoreLookupGet, + b"sysStoreLookupCreate" => Permission::SysStoreLookupCreate, + b"sysStoreLookupUpdate" => Permission::SysStoreLookupUpdate, + b"sysStoreLookupDestroy" => Permission::SysStoreLookupDestroy, + b"sysStoreLookupQuery" => Permission::SysStoreLookupQuery, + b"sysSystemSettingsGet" => Permission::SysSystemSettingsGet, + b"sysSystemSettingsUpdate" => Permission::SysSystemSettingsUpdate, + b"taskIndexDocument" => Permission::TaskIndexDocument, + b"taskUnindexDocument" => Permission::TaskUnindexDocument, + b"taskIndexTrace" => Permission::TaskIndexTrace, + b"taskCalendarAlarmEmail" => Permission::TaskCalendarAlarmEmail, + b"taskCalendarAlarmNotification" => Permission::TaskCalendarAlarmNotification, + b"taskCalendarItipMessage" => Permission::TaskCalendarItipMessage, + b"taskMergeThreads" => Permission::TaskMergeThreads, + b"taskDmarcReport" => Permission::TaskDmarcReport, + b"taskTlsReport" => Permission::TaskTlsReport, + b"taskRestoreArchivedItem" => Permission::TaskRestoreArchivedItem, + b"taskDestroyAccount" => Permission::TaskDestroyAccount, + b"taskAccountMaintenance" => Permission::TaskAccountMaintenance, + b"taskTenantMaintenance" => Permission::TaskTenantMaintenance, + b"taskStoreMaintenance" => Permission::TaskStoreMaintenance, + b"taskSpamFilterMaintenance" => Permission::TaskSpamFilterMaintenance, + b"taskAcmeRenewal" => Permission::TaskAcmeRenewal, + b"taskDkimManagement" => Permission::TaskDkimManagement, + b"taskDnsManagement" => Permission::TaskDnsManagement, + b"sysTaskGet" => Permission::SysTaskGet, + b"sysTaskCreate" => Permission::SysTaskCreate, + b"sysTaskUpdate" => Permission::SysTaskUpdate, + b"sysTaskDestroy" => Permission::SysTaskDestroy, + b"sysTaskQuery" => Permission::SysTaskQuery, + b"sysTaskManagerGet" => Permission::SysTaskManagerGet, + b"sysTaskManagerUpdate" => Permission::SysTaskManagerUpdate, + b"sysTenantGet" => Permission::SysTenantGet, + b"sysTenantCreate" => Permission::SysTenantCreate, + b"sysTenantUpdate" => Permission::SysTenantUpdate, + b"sysTenantDestroy" => Permission::SysTenantDestroy, + b"sysTenantQuery" => Permission::SysTenantQuery, + b"sysTlsExternalReportGet" => Permission::SysTlsExternalReportGet, + b"sysTlsExternalReportCreate" => Permission::SysTlsExternalReportCreate, + b"sysTlsExternalReportUpdate" => Permission::SysTlsExternalReportUpdate, + b"sysTlsExternalReportDestroy" => Permission::SysTlsExternalReportDestroy, + b"sysTlsExternalReportQuery" => Permission::SysTlsExternalReportQuery, + b"sysTlsInternalReportGet" => Permission::SysTlsInternalReportGet, + b"sysTlsInternalReportCreate" => Permission::SysTlsInternalReportCreate, + b"sysTlsInternalReportUpdate" => Permission::SysTlsInternalReportUpdate, + b"sysTlsInternalReportDestroy" => Permission::SysTlsInternalReportDestroy, + b"sysTlsInternalReportQuery" => Permission::SysTlsInternalReportQuery, + b"sysTlsReportSettingsGet" => Permission::SysTlsReportSettingsGet, + b"sysTlsReportSettingsUpdate" => Permission::SysTlsReportSettingsUpdate, + b"sysTraceGet" => Permission::SysTraceGet, + b"sysTraceCreate" => Permission::SysTraceCreate, + b"sysTraceUpdate" => Permission::SysTraceUpdate, + b"sysTraceDestroy" => Permission::SysTraceDestroy, + b"sysTraceQuery" => Permission::SysTraceQuery, + b"sysTracerGet" => Permission::SysTracerGet, + b"sysTracerCreate" => Permission::SysTracerCreate, + b"sysTracerUpdate" => Permission::SysTracerUpdate, + b"sysTracerDestroy" => Permission::SysTracerDestroy, + b"sysTracerQuery" => Permission::SysTracerQuery, + b"sysTracingStoreGet" => Permission::SysTracingStoreGet, + b"sysTracingStoreUpdate" => Permission::SysTracingStoreUpdate, + b"sysWebDavGet" => Permission::SysWebDavGet, + b"sysWebDavUpdate" => Permission::SysWebDavUpdate, + b"sysWebHookGet" => Permission::SysWebHookGet, + b"sysWebHookCreate" => Permission::SysWebHookCreate, + b"sysWebHookUpdate" => Permission::SysWebHookUpdate, + b"sysWebHookDestroy" => Permission::SysWebHookDestroy, + b"sysWebHookQuery" => Permission::SysWebHookQuery, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + Permission::Authenticate => "authenticate", + Permission::AuthenticateWithAlias => "authenticateWithAlias", + Permission::InteractAi => "interactAi", + Permission::Impersonate => "impersonate", + Permission::UnlimitedRequests => "unlimitedRequests", + Permission::UnlimitedUploads => "unlimitedUploads", + Permission::FetchAnyBlob => "fetchAnyBlob", + Permission::EmailSend => "emailSend", + Permission::EmailReceive => "emailReceive", + Permission::CalendarAlarmsSend => "calendarAlarmsSend", + Permission::CalendarSchedulingSend => "calendarSchedulingSend", + Permission::CalendarSchedulingReceive => "calendarSchedulingReceive", + Permission::JmapPushSubscriptionGet => "jmapPushSubscriptionGet", + Permission::JmapPushSubscriptionCreate => "jmapPushSubscriptionCreate", + Permission::JmapPushSubscriptionUpdate => "jmapPushSubscriptionUpdate", + Permission::JmapPushSubscriptionDestroy => "jmapPushSubscriptionDestroy", + Permission::JmapMailboxGet => "jmapMailboxGet", + Permission::JmapMailboxChanges => "jmapMailboxChanges", + Permission::JmapMailboxQuery => "jmapMailboxQuery", + Permission::JmapMailboxQueryChanges => "jmapMailboxQueryChanges", + Permission::JmapMailboxCreate => "jmapMailboxCreate", + Permission::JmapMailboxUpdate => "jmapMailboxUpdate", + Permission::JmapMailboxDestroy => "jmapMailboxDestroy", + Permission::JmapThreadGet => "jmapThreadGet", + Permission::JmapThreadChanges => "jmapThreadChanges", + Permission::JmapEmailGet => "jmapEmailGet", + Permission::JmapEmailChanges => "jmapEmailChanges", + Permission::JmapEmailQuery => "jmapEmailQuery", + Permission::JmapEmailQueryChanges => "jmapEmailQueryChanges", + Permission::JmapEmailCreate => "jmapEmailCreate", + Permission::JmapEmailUpdate => "jmapEmailUpdate", + Permission::JmapEmailDestroy => "jmapEmailDestroy", + Permission::JmapEmailCopy => "jmapEmailCopy", + Permission::JmapEmailImport => "jmapEmailImport", + Permission::JmapEmailParse => "jmapEmailParse", + Permission::JmapSearchSnippetGet => "jmapSearchSnippetGet", + Permission::JmapIdentityGet => "jmapIdentityGet", + Permission::JmapIdentityChanges => "jmapIdentityChanges", + Permission::JmapIdentityCreate => "jmapIdentityCreate", + Permission::JmapIdentityUpdate => "jmapIdentityUpdate", + Permission::JmapIdentityDestroy => "jmapIdentityDestroy", + Permission::JmapEmailSubmissionGet => "jmapEmailSubmissionGet", + Permission::JmapEmailSubmissionChanges => "jmapEmailSubmissionChanges", + Permission::JmapEmailSubmissionQuery => "jmapEmailSubmissionQuery", + Permission::JmapEmailSubmissionQueryChanges => "jmapEmailSubmissionQueryChanges", + Permission::JmapEmailSubmissionCreate => "jmapEmailSubmissionCreate", + Permission::JmapEmailSubmissionUpdate => "jmapEmailSubmissionUpdate", + Permission::JmapEmailSubmissionDestroy => "jmapEmailSubmissionDestroy", + Permission::JmapVacationResponseGet => "jmapVacationResponseGet", + Permission::JmapVacationResponseCreate => "jmapVacationResponseCreate", + Permission::JmapVacationResponseUpdate => "jmapVacationResponseUpdate", + Permission::JmapVacationResponseDestroy => "jmapVacationResponseDestroy", + Permission::JmapSieveScriptGet => "jmapSieveScriptGet", + Permission::JmapSieveScriptQuery => "jmapSieveScriptQuery", + Permission::JmapSieveScriptValidate => "jmapSieveScriptValidate", + Permission::JmapSieveScriptCreate => "jmapSieveScriptCreate", + Permission::JmapSieveScriptUpdate => "jmapSieveScriptUpdate", + Permission::JmapSieveScriptDestroy => "jmapSieveScriptDestroy", + Permission::JmapPrincipalGet => "jmapPrincipalGet", + Permission::JmapPrincipalQuery => "jmapPrincipalQuery", + Permission::JmapPrincipalChanges => "jmapPrincipalChanges", + Permission::JmapPrincipalQueryChanges => "jmapPrincipalQueryChanges", + Permission::JmapPrincipalGetAvailability => "jmapPrincipalGetAvailability", + Permission::JmapPrincipalCreate => "jmapPrincipalCreate", + Permission::JmapPrincipalUpdate => "jmapPrincipalUpdate", + Permission::JmapPrincipalDestroy => "jmapPrincipalDestroy", + Permission::JmapQuotaGet => "jmapQuotaGet", + Permission::JmapQuotaChanges => "jmapQuotaChanges", + Permission::JmapQuotaQuery => "jmapQuotaQuery", + Permission::JmapQuotaQueryChanges => "jmapQuotaQueryChanges", + Permission::JmapBlobGet => "jmapBlobGet", + Permission::JmapBlobCopy => "jmapBlobCopy", + Permission::JmapBlobLookup => "jmapBlobLookup", + Permission::JmapBlobUpload => "jmapBlobUpload", + Permission::JmapAddressBookGet => "jmapAddressBookGet", + Permission::JmapAddressBookChanges => "jmapAddressBookChanges", + Permission::JmapAddressBookCreate => "jmapAddressBookCreate", + Permission::JmapAddressBookUpdate => "jmapAddressBookUpdate", + Permission::JmapAddressBookDestroy => "jmapAddressBookDestroy", + Permission::JmapContactCardGet => "jmapContactCardGet", + Permission::JmapContactCardChanges => "jmapContactCardChanges", + Permission::JmapContactCardQuery => "jmapContactCardQuery", + Permission::JmapContactCardQueryChanges => "jmapContactCardQueryChanges", + Permission::JmapContactCardCreate => "jmapContactCardCreate", + Permission::JmapContactCardUpdate => "jmapContactCardUpdate", + Permission::JmapContactCardDestroy => "jmapContactCardDestroy", + Permission::JmapContactCardCopy => "jmapContactCardCopy", + Permission::JmapContactCardParse => "jmapContactCardParse", + Permission::JmapFileNodeGet => "jmapFileNodeGet", + Permission::JmapFileNodeChanges => "jmapFileNodeChanges", + Permission::JmapFileNodeQuery => "jmapFileNodeQuery", + Permission::JmapFileNodeQueryChanges => "jmapFileNodeQueryChanges", + Permission::JmapFileNodeCreate => "jmapFileNodeCreate", + Permission::JmapFileNodeUpdate => "jmapFileNodeUpdate", + Permission::JmapFileNodeDestroy => "jmapFileNodeDestroy", + Permission::JmapFileNodeCopy => "jmapFileNodeCopy", + Permission::JmapShareNotificationGet => "jmapShareNotificationGet", + Permission::JmapShareNotificationChanges => "jmapShareNotificationChanges", + Permission::JmapShareNotificationQuery => "jmapShareNotificationQuery", + Permission::JmapShareNotificationQueryChanges => "jmapShareNotificationQueryChanges", + Permission::JmapShareNotificationCreate => "jmapShareNotificationCreate", + Permission::JmapShareNotificationUpdate => "jmapShareNotificationUpdate", + Permission::JmapShareNotificationDestroy => "jmapShareNotificationDestroy", + Permission::JmapCalendarGet => "jmapCalendarGet", + Permission::JmapCalendarChanges => "jmapCalendarChanges", + Permission::JmapCalendarCreate => "jmapCalendarCreate", + Permission::JmapCalendarUpdate => "jmapCalendarUpdate", + Permission::JmapCalendarDestroy => "jmapCalendarDestroy", + Permission::JmapCalendarEventGet => "jmapCalendarEventGet", + Permission::JmapCalendarEventChanges => "jmapCalendarEventChanges", + Permission::JmapCalendarEventQuery => "jmapCalendarEventQuery", + Permission::JmapCalendarEventQueryChanges => "jmapCalendarEventQueryChanges", + Permission::JmapCalendarEventCreate => "jmapCalendarEventCreate", + Permission::JmapCalendarEventUpdate => "jmapCalendarEventUpdate", + Permission::JmapCalendarEventDestroy => "jmapCalendarEventDestroy", + Permission::JmapCalendarEventCopy => "jmapCalendarEventCopy", + Permission::JmapCalendarEventParse => "jmapCalendarEventParse", + Permission::JmapCalendarEventNotificationGet => "jmapCalendarEventNotificationGet", + Permission::JmapCalendarEventNotificationChanges => { + "jmapCalendarEventNotificationChanges" + } + Permission::JmapCalendarEventNotificationQuery => "jmapCalendarEventNotificationQuery", + Permission::JmapCalendarEventNotificationQueryChanges => { + "jmapCalendarEventNotificationQueryChanges" + } + Permission::JmapCalendarEventNotificationCreate => { + "jmapCalendarEventNotificationCreate" + } + Permission::JmapCalendarEventNotificationUpdate => { + "jmapCalendarEventNotificationUpdate" + } + Permission::JmapCalendarEventNotificationDestroy => { + "jmapCalendarEventNotificationDestroy" + } + Permission::JmapParticipantIdentityGet => "jmapParticipantIdentityGet", + Permission::JmapParticipantIdentityChanges => "jmapParticipantIdentityChanges", + Permission::JmapParticipantIdentityCreate => "jmapParticipantIdentityCreate", + Permission::JmapParticipantIdentityUpdate => "jmapParticipantIdentityUpdate", + Permission::JmapParticipantIdentityDestroy => "jmapParticipantIdentityDestroy", + Permission::JmapCoreEcho => "jmapCoreEcho", + Permission::ImapAuthenticate => "imapAuthenticate", + Permission::ImapAclGet => "imapAclGet", + Permission::ImapAclSet => "imapAclSet", + Permission::ImapMyRights => "imapMyRights", + Permission::ImapListRights => "imapListRights", + Permission::ImapAppend => "imapAppend", + Permission::ImapCapability => "imapCapability", + Permission::ImapId => "imapId", + Permission::ImapCopy => "imapCopy", + Permission::ImapMove => "imapMove", + Permission::ImapCreate => "imapCreate", + Permission::ImapDelete => "imapDelete", + Permission::ImapEnable => "imapEnable", + Permission::ImapExpunge => "imapExpunge", + Permission::ImapFetch => "imapFetch", + Permission::ImapIdle => "imapIdle", + Permission::ImapList => "imapList", + Permission::ImapLsub => "imapLsub", + Permission::ImapNamespace => "imapNamespace", + Permission::ImapRename => "imapRename", + Permission::ImapSearch => "imapSearch", + Permission::ImapSort => "imapSort", + Permission::ImapSelect => "imapSelect", + Permission::ImapExamine => "imapExamine", + Permission::ImapStatus => "imapStatus", + Permission::ImapStore => "imapStore", + Permission::ImapSubscribe => "imapSubscribe", + Permission::ImapThread => "imapThread", + Permission::Pop3Authenticate => "pop3Authenticate", + Permission::Pop3List => "pop3List", + Permission::Pop3Uidl => "pop3Uidl", + Permission::Pop3Stat => "pop3Stat", + Permission::Pop3Retr => "pop3Retr", + Permission::Pop3Dele => "pop3Dele", + Permission::SieveAuthenticate => "sieveAuthenticate", + Permission::SieveListScripts => "sieveListScripts", + Permission::SieveSetActive => "sieveSetActive", + Permission::SieveGetScript => "sieveGetScript", + Permission::SievePutScript => "sievePutScript", + Permission::SieveDeleteScript => "sieveDeleteScript", + Permission::SieveRenameScript => "sieveRenameScript", + Permission::SieveCheckScript => "sieveCheckScript", + Permission::SieveHaveSpace => "sieveHaveSpace", + Permission::DavSyncCollection => "davSyncCollection", + Permission::DavExpandProperty => "davExpandProperty", + Permission::DavPrincipalAcl => "davPrincipalAcl", + Permission::DavPrincipalList => "davPrincipalList", + Permission::DavPrincipalMatch => "davPrincipalMatch", + Permission::DavPrincipalSearch => "davPrincipalSearch", + Permission::DavPrincipalSearchPropSet => "davPrincipalSearchPropSet", + Permission::DavFilePropFind => "davFilePropFind", + Permission::DavFilePropPatch => "davFilePropPatch", + Permission::DavFileGet => "davFileGet", + Permission::DavFileMkCol => "davFileMkCol", + Permission::DavFileDelete => "davFileDelete", + Permission::DavFilePut => "davFilePut", + Permission::DavFileCopy => "davFileCopy", + Permission::DavFileMove => "davFileMove", + Permission::DavFileLock => "davFileLock", + Permission::DavFileAcl => "davFileAcl", + Permission::DavCardPropFind => "davCardPropFind", + Permission::DavCardPropPatch => "davCardPropPatch", + Permission::DavCardGet => "davCardGet", + Permission::DavCardMkCol => "davCardMkCol", + Permission::DavCardDelete => "davCardDelete", + Permission::DavCardPut => "davCardPut", + Permission::DavCardCopy => "davCardCopy", + Permission::DavCardMove => "davCardMove", + Permission::DavCardLock => "davCardLock", + Permission::DavCardAcl => "davCardAcl", + Permission::DavCardQuery => "davCardQuery", + Permission::DavCardMultiGet => "davCardMultiGet", + Permission::DavCalPropFind => "davCalPropFind", + Permission::DavCalPropPatch => "davCalPropPatch", + Permission::DavCalGet => "davCalGet", + Permission::DavCalMkCol => "davCalMkCol", + Permission::DavCalDelete => "davCalDelete", + Permission::DavCalPut => "davCalPut", + Permission::DavCalCopy => "davCalCopy", + Permission::DavCalMove => "davCalMove", + Permission::DavCalLock => "davCalLock", + Permission::DavCalAcl => "davCalAcl", + Permission::DavCalQuery => "davCalQuery", + Permission::DavCalMultiGet => "davCalMultiGet", + Permission::DavCalFreeBusyQuery => "davCalFreeBusyQuery", + Permission::OAuthClientRegistration => "oAuthClientRegistration", + Permission::OAuthClientOverride => "oAuthClientOverride", + Permission::LiveTracing => "liveTracing", + Permission::LiveMetrics => "liveMetrics", + Permission::LiveDeliveryTest => "liveDeliveryTest", + Permission::ScimAccess => "scimAccess", + Permission::SysAccountGet => "sysAccountGet", + Permission::SysAccountCreate => "sysAccountCreate", + Permission::SysAccountUpdate => "sysAccountUpdate", + Permission::SysAccountDestroy => "sysAccountDestroy", + Permission::SysAccountQuery => "sysAccountQuery", + Permission::SysAccountPasswordGet => "sysAccountPasswordGet", + Permission::SysAccountPasswordUpdate => "sysAccountPasswordUpdate", + Permission::SysAccountSettingsGet => "sysAccountSettingsGet", + Permission::SysAccountSettingsUpdate => "sysAccountSettingsUpdate", + Permission::SysAcmeProviderGet => "sysAcmeProviderGet", + Permission::SysAcmeProviderCreate => "sysAcmeProviderCreate", + Permission::SysAcmeProviderUpdate => "sysAcmeProviderUpdate", + Permission::SysAcmeProviderDestroy => "sysAcmeProviderDestroy", + Permission::SysAcmeProviderQuery => "sysAcmeProviderQuery", + Permission::ActionReloadSettings => "actionReloadSettings", + Permission::ActionReloadTlsCertificates => "actionReloadTlsCertificates", + Permission::ActionReloadLookupStores => "actionReloadLookupStores", + Permission::ActionReloadBlockedIps => "actionReloadBlockedIps", + Permission::ActionUpdateApps => "actionUpdateApps", + Permission::ActionTroubleshootDmarc => "actionTroubleshootDmarc", + Permission::ActionClassifySpam => "actionClassifySpam", + Permission::ActionInvalidateCaches => "actionInvalidateCaches", + Permission::ActionInvalidateNegativeCaches => "actionInvalidateNegativeCaches", + Permission::ActionPauseMtaQueue => "actionPauseMtaQueue", + Permission::ActionResumeMtaQueue => "actionResumeMtaQueue", + Permission::SysActionGet => "sysActionGet", + Permission::SysActionCreate => "sysActionCreate", + Permission::SysActionUpdate => "sysActionUpdate", + Permission::SysActionDestroy => "sysActionDestroy", + Permission::SysActionQuery => "sysActionQuery", + Permission::SysAddressBookGet => "sysAddressBookGet", + Permission::SysAddressBookUpdate => "sysAddressBookUpdate", + Permission::SysAiModelGet => "sysAiModelGet", + Permission::SysAiModelCreate => "sysAiModelCreate", + Permission::SysAiModelUpdate => "sysAiModelUpdate", + Permission::SysAiModelDestroy => "sysAiModelDestroy", + Permission::SysAiModelQuery => "sysAiModelQuery", + Permission::SysAlertGet => "sysAlertGet", + Permission::SysAlertCreate => "sysAlertCreate", + Permission::SysAlertUpdate => "sysAlertUpdate", + Permission::SysAlertDestroy => "sysAlertDestroy", + Permission::SysAlertQuery => "sysAlertQuery", + Permission::SysAllowedIpGet => "sysAllowedIpGet", + Permission::SysAllowedIpCreate => "sysAllowedIpCreate", + Permission::SysAllowedIpUpdate => "sysAllowedIpUpdate", + Permission::SysAllowedIpDestroy => "sysAllowedIpDestroy", + Permission::SysAllowedIpQuery => "sysAllowedIpQuery", + Permission::SysApiKeyGet => "sysApiKeyGet", + Permission::SysApiKeyCreate => "sysApiKeyCreate", + Permission::SysApiKeyUpdate => "sysApiKeyUpdate", + Permission::SysApiKeyDestroy => "sysApiKeyDestroy", + Permission::SysApiKeyQuery => "sysApiKeyQuery", + Permission::SysAppPasswordGet => "sysAppPasswordGet", + Permission::SysAppPasswordCreate => "sysAppPasswordCreate", + Permission::SysAppPasswordUpdate => "sysAppPasswordUpdate", + Permission::SysAppPasswordDestroy => "sysAppPasswordDestroy", + Permission::SysAppPasswordQuery => "sysAppPasswordQuery", + Permission::SysApplicationGet => "sysApplicationGet", + Permission::SysApplicationCreate => "sysApplicationCreate", + Permission::SysApplicationUpdate => "sysApplicationUpdate", + Permission::SysApplicationDestroy => "sysApplicationDestroy", + Permission::SysApplicationQuery => "sysApplicationQuery", + Permission::SysArchivedItemGet => "sysArchivedItemGet", + Permission::SysArchivedItemCreate => "sysArchivedItemCreate", + Permission::SysArchivedItemUpdate => "sysArchivedItemUpdate", + Permission::SysArchivedItemDestroy => "sysArchivedItemDestroy", + Permission::SysArchivedItemQuery => "sysArchivedItemQuery", + Permission::SysArfExternalReportGet => "sysArfExternalReportGet", + Permission::SysArfExternalReportCreate => "sysArfExternalReportCreate", + Permission::SysArfExternalReportUpdate => "sysArfExternalReportUpdate", + Permission::SysArfExternalReportDestroy => "sysArfExternalReportDestroy", + Permission::SysArfExternalReportQuery => "sysArfExternalReportQuery", + Permission::SysAsnGet => "sysAsnGet", + Permission::SysAsnUpdate => "sysAsnUpdate", + Permission::SysAuthenticationGet => "sysAuthenticationGet", + Permission::SysAuthenticationUpdate => "sysAuthenticationUpdate", + Permission::SysBlobStoreGet => "sysBlobStoreGet", + Permission::SysBlobStoreUpdate => "sysBlobStoreUpdate", + Permission::SysBlockedIpGet => "sysBlockedIpGet", + Permission::SysBlockedIpCreate => "sysBlockedIpCreate", + Permission::SysBlockedIpUpdate => "sysBlockedIpUpdate", + Permission::SysBlockedIpDestroy => "sysBlockedIpDestroy", + Permission::SysBlockedIpQuery => "sysBlockedIpQuery", + Permission::SysBootstrapGet => "sysBootstrapGet", + Permission::SysBootstrapUpdate => "sysBootstrapUpdate", + Permission::SysCacheGet => "sysCacheGet", + Permission::SysCacheUpdate => "sysCacheUpdate", + Permission::SysCalendarGet => "sysCalendarGet", + Permission::SysCalendarUpdate => "sysCalendarUpdate", + Permission::SysCalendarAlarmGet => "sysCalendarAlarmGet", + Permission::SysCalendarAlarmUpdate => "sysCalendarAlarmUpdate", + Permission::SysCalendarSchedulingGet => "sysCalendarSchedulingGet", + Permission::SysCalendarSchedulingUpdate => "sysCalendarSchedulingUpdate", + Permission::SysCertificateGet => "sysCertificateGet", + Permission::SysCertificateCreate => "sysCertificateCreate", + Permission::SysCertificateUpdate => "sysCertificateUpdate", + Permission::SysCertificateDestroy => "sysCertificateDestroy", + Permission::SysCertificateQuery => "sysCertificateQuery", + Permission::SysClusterNodeGet => "sysClusterNodeGet", + Permission::SysClusterNodeCreate => "sysClusterNodeCreate", + Permission::SysClusterNodeUpdate => "sysClusterNodeUpdate", + Permission::SysClusterNodeDestroy => "sysClusterNodeDestroy", + Permission::SysClusterNodeQuery => "sysClusterNodeQuery", + Permission::SysClusterRoleGet => "sysClusterRoleGet", + Permission::SysClusterRoleCreate => "sysClusterRoleCreate", + Permission::SysClusterRoleUpdate => "sysClusterRoleUpdate", + Permission::SysClusterRoleDestroy => "sysClusterRoleDestroy", + Permission::SysClusterRoleQuery => "sysClusterRoleQuery", + Permission::SysCoordinatorGet => "sysCoordinatorGet", + Permission::SysCoordinatorUpdate => "sysCoordinatorUpdate", + Permission::SysDataRetentionGet => "sysDataRetentionGet", + Permission::SysDataRetentionUpdate => "sysDataRetentionUpdate", + Permission::SysDataStoreGet => "sysDataStoreGet", + Permission::SysDataStoreUpdate => "sysDataStoreUpdate", + Permission::SysDirectoryGet => "sysDirectoryGet", + Permission::SysDirectoryCreate => "sysDirectoryCreate", + Permission::SysDirectoryUpdate => "sysDirectoryUpdate", + Permission::SysDirectoryDestroy => "sysDirectoryDestroy", + Permission::SysDirectoryQuery => "sysDirectoryQuery", + Permission::SysDkimReportSettingsGet => "sysDkimReportSettingsGet", + Permission::SysDkimReportSettingsUpdate => "sysDkimReportSettingsUpdate", + Permission::SysDkimSignatureGet => "sysDkimSignatureGet", + Permission::SysDkimSignatureCreate => "sysDkimSignatureCreate", + Permission::SysDkimSignatureUpdate => "sysDkimSignatureUpdate", + Permission::SysDkimSignatureDestroy => "sysDkimSignatureDestroy", + Permission::SysDkimSignatureQuery => "sysDkimSignatureQuery", + Permission::SysDmarcExternalReportGet => "sysDmarcExternalReportGet", + Permission::SysDmarcExternalReportCreate => "sysDmarcExternalReportCreate", + Permission::SysDmarcExternalReportUpdate => "sysDmarcExternalReportUpdate", + Permission::SysDmarcExternalReportDestroy => "sysDmarcExternalReportDestroy", + Permission::SysDmarcExternalReportQuery => "sysDmarcExternalReportQuery", + Permission::SysDmarcInternalReportGet => "sysDmarcInternalReportGet", + Permission::SysDmarcInternalReportCreate => "sysDmarcInternalReportCreate", + Permission::SysDmarcInternalReportUpdate => "sysDmarcInternalReportUpdate", + Permission::SysDmarcInternalReportDestroy => "sysDmarcInternalReportDestroy", + Permission::SysDmarcInternalReportQuery => "sysDmarcInternalReportQuery", + Permission::SysDmarcReportSettingsGet => "sysDmarcReportSettingsGet", + Permission::SysDmarcReportSettingsUpdate => "sysDmarcReportSettingsUpdate", + Permission::SysDnsResolverGet => "sysDnsResolverGet", + Permission::SysDnsResolverUpdate => "sysDnsResolverUpdate", + Permission::SysDnsServerGet => "sysDnsServerGet", + Permission::SysDnsServerCreate => "sysDnsServerCreate", + Permission::SysDnsServerUpdate => "sysDnsServerUpdate", + Permission::SysDnsServerDestroy => "sysDnsServerDestroy", + Permission::SysDnsServerQuery => "sysDnsServerQuery", + Permission::SysDomainGet => "sysDomainGet", + Permission::SysDomainCreate => "sysDomainCreate", + Permission::SysDomainUpdate => "sysDomainUpdate", + Permission::SysDomainDestroy => "sysDomainDestroy", + Permission::SysDomainQuery => "sysDomainQuery", + Permission::SysDsnReportSettingsGet => "sysDsnReportSettingsGet", + Permission::SysDsnReportSettingsUpdate => "sysDsnReportSettingsUpdate", + Permission::SysEmailGet => "sysEmailGet", + Permission::SysEmailUpdate => "sysEmailUpdate", + Permission::SysEnterpriseGet => "sysEnterpriseGet", + Permission::SysEnterpriseUpdate => "sysEnterpriseUpdate", + Permission::SysEventTracingLevelGet => "sysEventTracingLevelGet", + Permission::SysEventTracingLevelCreate => "sysEventTracingLevelCreate", + Permission::SysEventTracingLevelUpdate => "sysEventTracingLevelUpdate", + Permission::SysEventTracingLevelDestroy => "sysEventTracingLevelDestroy", + Permission::SysEventTracingLevelQuery => "sysEventTracingLevelQuery", + Permission::SysFileStorageGet => "sysFileStorageGet", + Permission::SysFileStorageUpdate => "sysFileStorageUpdate", + Permission::SysHttpGet => "sysHttpGet", + Permission::SysHttpUpdate => "sysHttpUpdate", + Permission::SysHttpFormGet => "sysHttpFormGet", + Permission::SysHttpFormUpdate => "sysHttpFormUpdate", + Permission::SysHttpLookupGet => "sysHttpLookupGet", + Permission::SysHttpLookupCreate => "sysHttpLookupCreate", + Permission::SysHttpLookupUpdate => "sysHttpLookupUpdate", + Permission::SysHttpLookupDestroy => "sysHttpLookupDestroy", + Permission::SysHttpLookupQuery => "sysHttpLookupQuery", + Permission::SysImapGet => "sysImapGet", + Permission::SysImapUpdate => "sysImapUpdate", + Permission::SysInMemoryStoreGet => "sysInMemoryStoreGet", + Permission::SysInMemoryStoreUpdate => "sysInMemoryStoreUpdate", + Permission::SysJmapGet => "sysJmapGet", + Permission::SysJmapUpdate => "sysJmapUpdate", + Permission::SysLogGet => "sysLogGet", + Permission::SysLogCreate => "sysLogCreate", + Permission::SysLogUpdate => "sysLogUpdate", + Permission::SysLogDestroy => "sysLogDestroy", + Permission::SysLogQuery => "sysLogQuery", + Permission::SysMailingListGet => "sysMailingListGet", + Permission::SysMailingListCreate => "sysMailingListCreate", + Permission::SysMailingListUpdate => "sysMailingListUpdate", + Permission::SysMailingListDestroy => "sysMailingListDestroy", + Permission::SysMailingListQuery => "sysMailingListQuery", + Permission::SysMaskedEmailGet => "sysMaskedEmailGet", + Permission::SysMaskedEmailCreate => "sysMaskedEmailCreate", + Permission::SysMaskedEmailUpdate => "sysMaskedEmailUpdate", + Permission::SysMaskedEmailDestroy => "sysMaskedEmailDestroy", + Permission::SysMaskedEmailQuery => "sysMaskedEmailQuery", + Permission::SysMemoryLookupKeyGet => "sysMemoryLookupKeyGet", + Permission::SysMemoryLookupKeyCreate => "sysMemoryLookupKeyCreate", + Permission::SysMemoryLookupKeyUpdate => "sysMemoryLookupKeyUpdate", + Permission::SysMemoryLookupKeyDestroy => "sysMemoryLookupKeyDestroy", + Permission::SysMemoryLookupKeyQuery => "sysMemoryLookupKeyQuery", + Permission::SysMemoryLookupKeyValueGet => "sysMemoryLookupKeyValueGet", + Permission::SysMemoryLookupKeyValueCreate => "sysMemoryLookupKeyValueCreate", + Permission::SysMemoryLookupKeyValueUpdate => "sysMemoryLookupKeyValueUpdate", + Permission::SysMemoryLookupKeyValueDestroy => "sysMemoryLookupKeyValueDestroy", + Permission::SysMemoryLookupKeyValueQuery => "sysMemoryLookupKeyValueQuery", + Permission::SysMetricGet => "sysMetricGet", + Permission::SysMetricCreate => "sysMetricCreate", + Permission::SysMetricUpdate => "sysMetricUpdate", + Permission::SysMetricDestroy => "sysMetricDestroy", + Permission::SysMetricQuery => "sysMetricQuery", + Permission::SysMetricsGet => "sysMetricsGet", + Permission::SysMetricsUpdate => "sysMetricsUpdate", + Permission::SysMetricsStoreGet => "sysMetricsStoreGet", + Permission::SysMetricsStoreUpdate => "sysMetricsStoreUpdate", + Permission::SysMtaConnectionStrategyGet => "sysMtaConnectionStrategyGet", + Permission::SysMtaConnectionStrategyCreate => "sysMtaConnectionStrategyCreate", + Permission::SysMtaConnectionStrategyUpdate => "sysMtaConnectionStrategyUpdate", + Permission::SysMtaConnectionStrategyDestroy => "sysMtaConnectionStrategyDestroy", + Permission::SysMtaConnectionStrategyQuery => "sysMtaConnectionStrategyQuery", + Permission::SysMtaDeliveryScheduleGet => "sysMtaDeliveryScheduleGet", + Permission::SysMtaDeliveryScheduleCreate => "sysMtaDeliveryScheduleCreate", + Permission::SysMtaDeliveryScheduleUpdate => "sysMtaDeliveryScheduleUpdate", + Permission::SysMtaDeliveryScheduleDestroy => "sysMtaDeliveryScheduleDestroy", + Permission::SysMtaDeliveryScheduleQuery => "sysMtaDeliveryScheduleQuery", + Permission::SysMtaExtensionsGet => "sysMtaExtensionsGet", + Permission::SysMtaExtensionsUpdate => "sysMtaExtensionsUpdate", + Permission::SysMtaHookGet => "sysMtaHookGet", + Permission::SysMtaHookCreate => "sysMtaHookCreate", + Permission::SysMtaHookUpdate => "sysMtaHookUpdate", + Permission::SysMtaHookDestroy => "sysMtaHookDestroy", + Permission::SysMtaHookQuery => "sysMtaHookQuery", + Permission::SysMtaInboundSessionGet => "sysMtaInboundSessionGet", + Permission::SysMtaInboundSessionUpdate => "sysMtaInboundSessionUpdate", + Permission::SysMtaInboundThrottleGet => "sysMtaInboundThrottleGet", + Permission::SysMtaInboundThrottleCreate => "sysMtaInboundThrottleCreate", + Permission::SysMtaInboundThrottleUpdate => "sysMtaInboundThrottleUpdate", + Permission::SysMtaInboundThrottleDestroy => "sysMtaInboundThrottleDestroy", + Permission::SysMtaInboundThrottleQuery => "sysMtaInboundThrottleQuery", + Permission::SysMtaMilterGet => "sysMtaMilterGet", + Permission::SysMtaMilterCreate => "sysMtaMilterCreate", + Permission::SysMtaMilterUpdate => "sysMtaMilterUpdate", + Permission::SysMtaMilterDestroy => "sysMtaMilterDestroy", + Permission::SysMtaMilterQuery => "sysMtaMilterQuery", + Permission::SysMtaOutboundStrategyGet => "sysMtaOutboundStrategyGet", + Permission::SysMtaOutboundStrategyUpdate => "sysMtaOutboundStrategyUpdate", + Permission::SysMtaOutboundThrottleGet => "sysMtaOutboundThrottleGet", + Permission::SysMtaOutboundThrottleCreate => "sysMtaOutboundThrottleCreate", + Permission::SysMtaOutboundThrottleUpdate => "sysMtaOutboundThrottleUpdate", + Permission::SysMtaOutboundThrottleDestroy => "sysMtaOutboundThrottleDestroy", + Permission::SysMtaOutboundThrottleQuery => "sysMtaOutboundThrottleQuery", + Permission::SysMtaQueueQuotaGet => "sysMtaQueueQuotaGet", + Permission::SysMtaQueueQuotaCreate => "sysMtaQueueQuotaCreate", + Permission::SysMtaQueueQuotaUpdate => "sysMtaQueueQuotaUpdate", + Permission::SysMtaQueueQuotaDestroy => "sysMtaQueueQuotaDestroy", + Permission::SysMtaQueueQuotaQuery => "sysMtaQueueQuotaQuery", + Permission::SysMtaRouteGet => "sysMtaRouteGet", + Permission::SysMtaRouteCreate => "sysMtaRouteCreate", + Permission::SysMtaRouteUpdate => "sysMtaRouteUpdate", + Permission::SysMtaRouteDestroy => "sysMtaRouteDestroy", + Permission::SysMtaRouteQuery => "sysMtaRouteQuery", + Permission::SysMtaStageAuthGet => "sysMtaStageAuthGet", + Permission::SysMtaStageAuthUpdate => "sysMtaStageAuthUpdate", + Permission::SysMtaStageConnectGet => "sysMtaStageConnectGet", + Permission::SysMtaStageConnectUpdate => "sysMtaStageConnectUpdate", + Permission::SysMtaStageDataGet => "sysMtaStageDataGet", + Permission::SysMtaStageDataUpdate => "sysMtaStageDataUpdate", + Permission::SysMtaStageEhloGet => "sysMtaStageEhloGet", + Permission::SysMtaStageEhloUpdate => "sysMtaStageEhloUpdate", + Permission::SysMtaStageMailGet => "sysMtaStageMailGet", + Permission::SysMtaStageMailUpdate => "sysMtaStageMailUpdate", + Permission::SysMtaStageRcptGet => "sysMtaStageRcptGet", + Permission::SysMtaStageRcptUpdate => "sysMtaStageRcptUpdate", + Permission::SysMtaStsGet => "sysMtaStsGet", + Permission::SysMtaStsUpdate => "sysMtaStsUpdate", + Permission::SysMtaTlsStrategyGet => "sysMtaTlsStrategyGet", + Permission::SysMtaTlsStrategyCreate => "sysMtaTlsStrategyCreate", + Permission::SysMtaTlsStrategyUpdate => "sysMtaTlsStrategyUpdate", + Permission::SysMtaTlsStrategyDestroy => "sysMtaTlsStrategyDestroy", + Permission::SysMtaTlsStrategyQuery => "sysMtaTlsStrategyQuery", + Permission::SysMtaVirtualQueueGet => "sysMtaVirtualQueueGet", + Permission::SysMtaVirtualQueueCreate => "sysMtaVirtualQueueCreate", + Permission::SysMtaVirtualQueueUpdate => "sysMtaVirtualQueueUpdate", + Permission::SysMtaVirtualQueueDestroy => "sysMtaVirtualQueueDestroy", + Permission::SysMtaVirtualQueueQuery => "sysMtaVirtualQueueQuery", + Permission::SysNetworkListenerGet => "sysNetworkListenerGet", + Permission::SysNetworkListenerCreate => "sysNetworkListenerCreate", + Permission::SysNetworkListenerUpdate => "sysNetworkListenerUpdate", + Permission::SysNetworkListenerDestroy => "sysNetworkListenerDestroy", + Permission::SysNetworkListenerQuery => "sysNetworkListenerQuery", + Permission::SysOAuthClientGet => "sysOAuthClientGet", + Permission::SysOAuthClientCreate => "sysOAuthClientCreate", + Permission::SysOAuthClientUpdate => "sysOAuthClientUpdate", + Permission::SysOAuthClientDestroy => "sysOAuthClientDestroy", + Permission::SysOAuthClientQuery => "sysOAuthClientQuery", + Permission::SysOidcProviderGet => "sysOidcProviderGet", + Permission::SysOidcProviderUpdate => "sysOidcProviderUpdate", + Permission::SysPublicKeyGet => "sysPublicKeyGet", + Permission::SysPublicKeyCreate => "sysPublicKeyCreate", + Permission::SysPublicKeyUpdate => "sysPublicKeyUpdate", + Permission::SysPublicKeyDestroy => "sysPublicKeyDestroy", + Permission::SysPublicKeyQuery => "sysPublicKeyQuery", + Permission::SysQueuedMessageGet => "sysQueuedMessageGet", + Permission::SysQueuedMessageCreate => "sysQueuedMessageCreate", + Permission::SysQueuedMessageUpdate => "sysQueuedMessageUpdate", + Permission::SysQueuedMessageDestroy => "sysQueuedMessageDestroy", + Permission::SysQueuedMessageQuery => "sysQueuedMessageQuery", + Permission::SysReportSettingsGet => "sysReportSettingsGet", + Permission::SysReportSettingsUpdate => "sysReportSettingsUpdate", + Permission::SysRoleGet => "sysRoleGet", + Permission::SysRoleCreate => "sysRoleCreate", + Permission::SysRoleUpdate => "sysRoleUpdate", + Permission::SysRoleDestroy => "sysRoleDestroy", + Permission::SysRoleQuery => "sysRoleQuery", + Permission::SysSearchGet => "sysSearchGet", + Permission::SysSearchUpdate => "sysSearchUpdate", + Permission::SysSearchStoreGet => "sysSearchStoreGet", + Permission::SysSearchStoreUpdate => "sysSearchStoreUpdate", + Permission::SysSecurityGet => "sysSecurityGet", + Permission::SysSecurityUpdate => "sysSecurityUpdate", + Permission::SysSenderAuthGet => "sysSenderAuthGet", + Permission::SysSenderAuthUpdate => "sysSenderAuthUpdate", + Permission::SysSharingGet => "sysSharingGet", + Permission::SysSharingUpdate => "sysSharingUpdate", + Permission::SysSieveSystemInterpreterGet => "sysSieveSystemInterpreterGet", + Permission::SysSieveSystemInterpreterUpdate => "sysSieveSystemInterpreterUpdate", + Permission::SysSieveSystemScriptGet => "sysSieveSystemScriptGet", + Permission::SysSieveSystemScriptCreate => "sysSieveSystemScriptCreate", + Permission::SysSieveSystemScriptUpdate => "sysSieveSystemScriptUpdate", + Permission::SysSieveSystemScriptDestroy => "sysSieveSystemScriptDestroy", + Permission::SysSieveSystemScriptQuery => "sysSieveSystemScriptQuery", + Permission::SysSieveUserInterpreterGet => "sysSieveUserInterpreterGet", + Permission::SysSieveUserInterpreterUpdate => "sysSieveUserInterpreterUpdate", + Permission::SysSieveUserScriptGet => "sysSieveUserScriptGet", + Permission::SysSieveUserScriptCreate => "sysSieveUserScriptCreate", + Permission::SysSieveUserScriptUpdate => "sysSieveUserScriptUpdate", + Permission::SysSieveUserScriptDestroy => "sysSieveUserScriptDestroy", + Permission::SysSieveUserScriptQuery => "sysSieveUserScriptQuery", + Permission::SysSpamClassifierGet => "sysSpamClassifierGet", + Permission::SysSpamClassifierUpdate => "sysSpamClassifierUpdate", + Permission::SysSpamDnsblServerGet => "sysSpamDnsblServerGet", + Permission::SysSpamDnsblServerCreate => "sysSpamDnsblServerCreate", + Permission::SysSpamDnsblServerUpdate => "sysSpamDnsblServerUpdate", + Permission::SysSpamDnsblServerDestroy => "sysSpamDnsblServerDestroy", + Permission::SysSpamDnsblServerQuery => "sysSpamDnsblServerQuery", + Permission::SysSpamDnsblSettingsGet => "sysSpamDnsblSettingsGet", + Permission::SysSpamDnsblSettingsUpdate => "sysSpamDnsblSettingsUpdate", + Permission::SysSpamFileExtensionGet => "sysSpamFileExtensionGet", + Permission::SysSpamFileExtensionCreate => "sysSpamFileExtensionCreate", + Permission::SysSpamFileExtensionUpdate => "sysSpamFileExtensionUpdate", + Permission::SysSpamFileExtensionDestroy => "sysSpamFileExtensionDestroy", + Permission::SysSpamFileExtensionQuery => "sysSpamFileExtensionQuery", + Permission::SysSpamLlmGet => "sysSpamLlmGet", + Permission::SysSpamLlmUpdate => "sysSpamLlmUpdate", + Permission::SysSpamPyzorGet => "sysSpamPyzorGet", + Permission::SysSpamPyzorUpdate => "sysSpamPyzorUpdate", + Permission::SysSpamRuleGet => "sysSpamRuleGet", + Permission::SysSpamRuleCreate => "sysSpamRuleCreate", + Permission::SysSpamRuleUpdate => "sysSpamRuleUpdate", + Permission::SysSpamRuleDestroy => "sysSpamRuleDestroy", + Permission::SysSpamRuleQuery => "sysSpamRuleQuery", + Permission::SysSpamSettingsGet => "sysSpamSettingsGet", + Permission::SysSpamSettingsUpdate => "sysSpamSettingsUpdate", + Permission::SysSpamTagGet => "sysSpamTagGet", + Permission::SysSpamTagCreate => "sysSpamTagCreate", + Permission::SysSpamTagUpdate => "sysSpamTagUpdate", + Permission::SysSpamTagDestroy => "sysSpamTagDestroy", + Permission::SysSpamTagQuery => "sysSpamTagQuery", + Permission::SysSpamTrainingSampleGet => "sysSpamTrainingSampleGet", + Permission::SysSpamTrainingSampleCreate => "sysSpamTrainingSampleCreate", + Permission::SysSpamTrainingSampleUpdate => "sysSpamTrainingSampleUpdate", + Permission::SysSpamTrainingSampleDestroy => "sysSpamTrainingSampleDestroy", + Permission::SysSpamTrainingSampleQuery => "sysSpamTrainingSampleQuery", + Permission::SysSpfReportSettingsGet => "sysSpfReportSettingsGet", + Permission::SysSpfReportSettingsUpdate => "sysSpfReportSettingsUpdate", + Permission::SysStoreLookupGet => "sysStoreLookupGet", + Permission::SysStoreLookupCreate => "sysStoreLookupCreate", + Permission::SysStoreLookupUpdate => "sysStoreLookupUpdate", + Permission::SysStoreLookupDestroy => "sysStoreLookupDestroy", + Permission::SysStoreLookupQuery => "sysStoreLookupQuery", + Permission::SysSystemSettingsGet => "sysSystemSettingsGet", + Permission::SysSystemSettingsUpdate => "sysSystemSettingsUpdate", + Permission::TaskIndexDocument => "taskIndexDocument", + Permission::TaskUnindexDocument => "taskUnindexDocument", + Permission::TaskIndexTrace => "taskIndexTrace", + Permission::TaskCalendarAlarmEmail => "taskCalendarAlarmEmail", + Permission::TaskCalendarAlarmNotification => "taskCalendarAlarmNotification", + Permission::TaskCalendarItipMessage => "taskCalendarItipMessage", + Permission::TaskMergeThreads => "taskMergeThreads", + Permission::TaskDmarcReport => "taskDmarcReport", + Permission::TaskTlsReport => "taskTlsReport", + Permission::TaskRestoreArchivedItem => "taskRestoreArchivedItem", + Permission::TaskDestroyAccount => "taskDestroyAccount", + Permission::TaskAccountMaintenance => "taskAccountMaintenance", + Permission::TaskTenantMaintenance => "taskTenantMaintenance", + Permission::TaskStoreMaintenance => "taskStoreMaintenance", + Permission::TaskSpamFilterMaintenance => "taskSpamFilterMaintenance", + Permission::TaskAcmeRenewal => "taskAcmeRenewal", + Permission::TaskDkimManagement => "taskDkimManagement", + Permission::TaskDnsManagement => "taskDnsManagement", + Permission::SysTaskGet => "sysTaskGet", + Permission::SysTaskCreate => "sysTaskCreate", + Permission::SysTaskUpdate => "sysTaskUpdate", + Permission::SysTaskDestroy => "sysTaskDestroy", + Permission::SysTaskQuery => "sysTaskQuery", + Permission::SysTaskManagerGet => "sysTaskManagerGet", + Permission::SysTaskManagerUpdate => "sysTaskManagerUpdate", + Permission::SysTenantGet => "sysTenantGet", + Permission::SysTenantCreate => "sysTenantCreate", + Permission::SysTenantUpdate => "sysTenantUpdate", + Permission::SysTenantDestroy => "sysTenantDestroy", + Permission::SysTenantQuery => "sysTenantQuery", + Permission::SysTlsExternalReportGet => "sysTlsExternalReportGet", + Permission::SysTlsExternalReportCreate => "sysTlsExternalReportCreate", + Permission::SysTlsExternalReportUpdate => "sysTlsExternalReportUpdate", + Permission::SysTlsExternalReportDestroy => "sysTlsExternalReportDestroy", + Permission::SysTlsExternalReportQuery => "sysTlsExternalReportQuery", + Permission::SysTlsInternalReportGet => "sysTlsInternalReportGet", + Permission::SysTlsInternalReportCreate => "sysTlsInternalReportCreate", + Permission::SysTlsInternalReportUpdate => "sysTlsInternalReportUpdate", + Permission::SysTlsInternalReportDestroy => "sysTlsInternalReportDestroy", + Permission::SysTlsInternalReportQuery => "sysTlsInternalReportQuery", + Permission::SysTlsReportSettingsGet => "sysTlsReportSettingsGet", + Permission::SysTlsReportSettingsUpdate => "sysTlsReportSettingsUpdate", + Permission::SysTraceGet => "sysTraceGet", + Permission::SysTraceCreate => "sysTraceCreate", + Permission::SysTraceUpdate => "sysTraceUpdate", + Permission::SysTraceDestroy => "sysTraceDestroy", + Permission::SysTraceQuery => "sysTraceQuery", + Permission::SysTracerGet => "sysTracerGet", + Permission::SysTracerCreate => "sysTracerCreate", + Permission::SysTracerUpdate => "sysTracerUpdate", + Permission::SysTracerDestroy => "sysTracerDestroy", + Permission::SysTracerQuery => "sysTracerQuery", + Permission::SysTracingStoreGet => "sysTracingStoreGet", + Permission::SysTracingStoreUpdate => "sysTracingStoreUpdate", + Permission::SysWebDavGet => "sysWebDavGet", + Permission::SysWebDavUpdate => "sysWebDavUpdate", + Permission::SysWebHookGet => "sysWebHookGet", + Permission::SysWebHookCreate => "sysWebHookCreate", + Permission::SysWebHookUpdate => "sysWebHookUpdate", + Permission::SysWebHookDestroy => "sysWebHookDestroy", + Permission::SysWebHookQuery => "sysWebHookQuery", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(Permission::Authenticate), + 1 => Some(Permission::AuthenticateWithAlias), + 2 => Some(Permission::InteractAi), + 3 => Some(Permission::Impersonate), + 4 => Some(Permission::UnlimitedRequests), + 5 => Some(Permission::UnlimitedUploads), + 6 => Some(Permission::FetchAnyBlob), + 7 => Some(Permission::EmailSend), + 8 => Some(Permission::EmailReceive), + 9 => Some(Permission::CalendarAlarmsSend), + 10 => Some(Permission::CalendarSchedulingSend), + 11 => Some(Permission::CalendarSchedulingReceive), + 12 => Some(Permission::JmapPushSubscriptionGet), + 13 => Some(Permission::JmapPushSubscriptionCreate), + 14 => Some(Permission::JmapPushSubscriptionUpdate), + 15 => Some(Permission::JmapPushSubscriptionDestroy), + 16 => Some(Permission::JmapMailboxGet), + 17 => Some(Permission::JmapMailboxChanges), + 18 => Some(Permission::JmapMailboxQuery), + 19 => Some(Permission::JmapMailboxQueryChanges), + 20 => Some(Permission::JmapMailboxCreate), + 21 => Some(Permission::JmapMailboxUpdate), + 22 => Some(Permission::JmapMailboxDestroy), + 23 => Some(Permission::JmapThreadGet), + 24 => Some(Permission::JmapThreadChanges), + 25 => Some(Permission::JmapEmailGet), + 26 => Some(Permission::JmapEmailChanges), + 27 => Some(Permission::JmapEmailQuery), + 28 => Some(Permission::JmapEmailQueryChanges), + 29 => Some(Permission::JmapEmailCreate), + 30 => Some(Permission::JmapEmailUpdate), + 31 => Some(Permission::JmapEmailDestroy), + 32 => Some(Permission::JmapEmailCopy), + 33 => Some(Permission::JmapEmailImport), + 34 => Some(Permission::JmapEmailParse), + 35 => Some(Permission::JmapSearchSnippetGet), + 36 => Some(Permission::JmapIdentityGet), + 37 => Some(Permission::JmapIdentityChanges), + 38 => Some(Permission::JmapIdentityCreate), + 39 => Some(Permission::JmapIdentityUpdate), + 40 => Some(Permission::JmapIdentityDestroy), + 41 => Some(Permission::JmapEmailSubmissionGet), + 42 => Some(Permission::JmapEmailSubmissionChanges), + 43 => Some(Permission::JmapEmailSubmissionQuery), + 44 => Some(Permission::JmapEmailSubmissionQueryChanges), + 45 => Some(Permission::JmapEmailSubmissionCreate), + 46 => Some(Permission::JmapEmailSubmissionUpdate), + 47 => Some(Permission::JmapEmailSubmissionDestroy), + 48 => Some(Permission::JmapVacationResponseGet), + 49 => Some(Permission::JmapVacationResponseCreate), + 50 => Some(Permission::JmapVacationResponseUpdate), + 51 => Some(Permission::JmapVacationResponseDestroy), + 52 => Some(Permission::JmapSieveScriptGet), + 53 => Some(Permission::JmapSieveScriptQuery), + 54 => Some(Permission::JmapSieveScriptValidate), + 55 => Some(Permission::JmapSieveScriptCreate), + 56 => Some(Permission::JmapSieveScriptUpdate), + 57 => Some(Permission::JmapSieveScriptDestroy), + 58 => Some(Permission::JmapPrincipalGet), + 59 => Some(Permission::JmapPrincipalQuery), + 60 => Some(Permission::JmapPrincipalChanges), + 61 => Some(Permission::JmapPrincipalQueryChanges), + 62 => Some(Permission::JmapPrincipalGetAvailability), + 63 => Some(Permission::JmapPrincipalCreate), + 64 => Some(Permission::JmapPrincipalUpdate), + 65 => Some(Permission::JmapPrincipalDestroy), + 66 => Some(Permission::JmapQuotaGet), + 67 => Some(Permission::JmapQuotaChanges), + 68 => Some(Permission::JmapQuotaQuery), + 69 => Some(Permission::JmapQuotaQueryChanges), + 70 => Some(Permission::JmapBlobGet), + 71 => Some(Permission::JmapBlobCopy), + 72 => Some(Permission::JmapBlobLookup), + 73 => Some(Permission::JmapBlobUpload), + 74 => Some(Permission::JmapAddressBookGet), + 75 => Some(Permission::JmapAddressBookChanges), + 76 => Some(Permission::JmapAddressBookCreate), + 77 => Some(Permission::JmapAddressBookUpdate), + 78 => Some(Permission::JmapAddressBookDestroy), + 79 => Some(Permission::JmapContactCardGet), + 80 => Some(Permission::JmapContactCardChanges), + 81 => Some(Permission::JmapContactCardQuery), + 82 => Some(Permission::JmapContactCardQueryChanges), + 83 => Some(Permission::JmapContactCardCreate), + 84 => Some(Permission::JmapContactCardUpdate), + 85 => Some(Permission::JmapContactCardDestroy), + 86 => Some(Permission::JmapContactCardCopy), + 87 => Some(Permission::JmapContactCardParse), + 88 => Some(Permission::JmapFileNodeGet), + 89 => Some(Permission::JmapFileNodeChanges), + 90 => Some(Permission::JmapFileNodeQuery), + 91 => Some(Permission::JmapFileNodeQueryChanges), + 92 => Some(Permission::JmapFileNodeCreate), + 93 => Some(Permission::JmapFileNodeUpdate), + 94 => Some(Permission::JmapFileNodeDestroy), + 659 => Some(Permission::JmapFileNodeCopy), + 95 => Some(Permission::JmapShareNotificationGet), + 96 => Some(Permission::JmapShareNotificationChanges), + 97 => Some(Permission::JmapShareNotificationQuery), + 98 => Some(Permission::JmapShareNotificationQueryChanges), + 99 => Some(Permission::JmapShareNotificationCreate), + 100 => Some(Permission::JmapShareNotificationUpdate), + 101 => Some(Permission::JmapShareNotificationDestroy), + 102 => Some(Permission::JmapCalendarGet), + 103 => Some(Permission::JmapCalendarChanges), + 104 => Some(Permission::JmapCalendarCreate), + 105 => Some(Permission::JmapCalendarUpdate), + 106 => Some(Permission::JmapCalendarDestroy), + 107 => Some(Permission::JmapCalendarEventGet), + 108 => Some(Permission::JmapCalendarEventChanges), + 109 => Some(Permission::JmapCalendarEventQuery), + 110 => Some(Permission::JmapCalendarEventQueryChanges), + 111 => Some(Permission::JmapCalendarEventCreate), + 112 => Some(Permission::JmapCalendarEventUpdate), + 113 => Some(Permission::JmapCalendarEventDestroy), + 114 => Some(Permission::JmapCalendarEventCopy), + 115 => Some(Permission::JmapCalendarEventParse), + 116 => Some(Permission::JmapCalendarEventNotificationGet), + 117 => Some(Permission::JmapCalendarEventNotificationChanges), + 118 => Some(Permission::JmapCalendarEventNotificationQuery), + 119 => Some(Permission::JmapCalendarEventNotificationQueryChanges), + 120 => Some(Permission::JmapCalendarEventNotificationCreate), + 121 => Some(Permission::JmapCalendarEventNotificationUpdate), + 122 => Some(Permission::JmapCalendarEventNotificationDestroy), + 123 => Some(Permission::JmapParticipantIdentityGet), + 124 => Some(Permission::JmapParticipantIdentityChanges), + 125 => Some(Permission::JmapParticipantIdentityCreate), + 126 => Some(Permission::JmapParticipantIdentityUpdate), + 127 => Some(Permission::JmapParticipantIdentityDestroy), + 128 => Some(Permission::JmapCoreEcho), + 129 => Some(Permission::ImapAuthenticate), + 130 => Some(Permission::ImapAclGet), + 131 => Some(Permission::ImapAclSet), + 132 => Some(Permission::ImapMyRights), + 133 => Some(Permission::ImapListRights), + 134 => Some(Permission::ImapAppend), + 135 => Some(Permission::ImapCapability), + 136 => Some(Permission::ImapId), + 137 => Some(Permission::ImapCopy), + 138 => Some(Permission::ImapMove), + 139 => Some(Permission::ImapCreate), + 140 => Some(Permission::ImapDelete), + 141 => Some(Permission::ImapEnable), + 142 => Some(Permission::ImapExpunge), + 143 => Some(Permission::ImapFetch), + 144 => Some(Permission::ImapIdle), + 145 => Some(Permission::ImapList), + 146 => Some(Permission::ImapLsub), + 147 => Some(Permission::ImapNamespace), + 148 => Some(Permission::ImapRename), + 149 => Some(Permission::ImapSearch), + 150 => Some(Permission::ImapSort), + 151 => Some(Permission::ImapSelect), + 152 => Some(Permission::ImapExamine), + 153 => Some(Permission::ImapStatus), + 154 => Some(Permission::ImapStore), + 155 => Some(Permission::ImapSubscribe), + 156 => Some(Permission::ImapThread), + 157 => Some(Permission::Pop3Authenticate), + 158 => Some(Permission::Pop3List), + 159 => Some(Permission::Pop3Uidl), + 160 => Some(Permission::Pop3Stat), + 161 => Some(Permission::Pop3Retr), + 162 => Some(Permission::Pop3Dele), + 163 => Some(Permission::SieveAuthenticate), + 164 => Some(Permission::SieveListScripts), + 165 => Some(Permission::SieveSetActive), + 166 => Some(Permission::SieveGetScript), + 167 => Some(Permission::SievePutScript), + 168 => Some(Permission::SieveDeleteScript), + 169 => Some(Permission::SieveRenameScript), + 170 => Some(Permission::SieveCheckScript), + 171 => Some(Permission::SieveHaveSpace), + 172 => Some(Permission::DavSyncCollection), + 173 => Some(Permission::DavExpandProperty), + 174 => Some(Permission::DavPrincipalAcl), + 175 => Some(Permission::DavPrincipalList), + 176 => Some(Permission::DavPrincipalMatch), + 177 => Some(Permission::DavPrincipalSearch), + 178 => Some(Permission::DavPrincipalSearchPropSet), + 179 => Some(Permission::DavFilePropFind), + 180 => Some(Permission::DavFilePropPatch), + 181 => Some(Permission::DavFileGet), + 182 => Some(Permission::DavFileMkCol), + 183 => Some(Permission::DavFileDelete), + 184 => Some(Permission::DavFilePut), + 185 => Some(Permission::DavFileCopy), + 186 => Some(Permission::DavFileMove), + 187 => Some(Permission::DavFileLock), + 188 => Some(Permission::DavFileAcl), + 189 => Some(Permission::DavCardPropFind), + 190 => Some(Permission::DavCardPropPatch), + 191 => Some(Permission::DavCardGet), + 192 => Some(Permission::DavCardMkCol), + 193 => Some(Permission::DavCardDelete), + 194 => Some(Permission::DavCardPut), + 195 => Some(Permission::DavCardCopy), + 196 => Some(Permission::DavCardMove), + 197 => Some(Permission::DavCardLock), + 198 => Some(Permission::DavCardAcl), + 199 => Some(Permission::DavCardQuery), + 200 => Some(Permission::DavCardMultiGet), + 201 => Some(Permission::DavCalPropFind), + 202 => Some(Permission::DavCalPropPatch), + 203 => Some(Permission::DavCalGet), + 204 => Some(Permission::DavCalMkCol), + 205 => Some(Permission::DavCalDelete), + 206 => Some(Permission::DavCalPut), + 207 => Some(Permission::DavCalCopy), + 208 => Some(Permission::DavCalMove), + 209 => Some(Permission::DavCalLock), + 210 => Some(Permission::DavCalAcl), + 211 => Some(Permission::DavCalQuery), + 212 => Some(Permission::DavCalMultiGet), + 213 => Some(Permission::DavCalFreeBusyQuery), + 214 => Some(Permission::OAuthClientRegistration), + 215 => Some(Permission::OAuthClientOverride), + 216 => Some(Permission::LiveTracing), + 217 => Some(Permission::LiveMetrics), + 218 => Some(Permission::LiveDeliveryTest), + 660 => Some(Permission::ScimAccess), + 219 => Some(Permission::SysAccountGet), + 220 => Some(Permission::SysAccountCreate), + 221 => Some(Permission::SysAccountUpdate), + 222 => Some(Permission::SysAccountDestroy), + 223 => Some(Permission::SysAccountQuery), + 224 => Some(Permission::SysAccountPasswordGet), + 225 => Some(Permission::SysAccountPasswordUpdate), + 226 => Some(Permission::SysAccountSettingsGet), + 227 => Some(Permission::SysAccountSettingsUpdate), + 228 => Some(Permission::SysAcmeProviderGet), + 229 => Some(Permission::SysAcmeProviderCreate), + 230 => Some(Permission::SysAcmeProviderUpdate), + 231 => Some(Permission::SysAcmeProviderDestroy), + 232 => Some(Permission::SysAcmeProviderQuery), + 233 => Some(Permission::ActionReloadSettings), + 234 => Some(Permission::ActionReloadTlsCertificates), + 235 => Some(Permission::ActionReloadLookupStores), + 236 => Some(Permission::ActionReloadBlockedIps), + 237 => Some(Permission::ActionUpdateApps), + 238 => Some(Permission::ActionTroubleshootDmarc), + 239 => Some(Permission::ActionClassifySpam), + 240 => Some(Permission::ActionInvalidateCaches), + 241 => Some(Permission::ActionInvalidateNegativeCaches), + 242 => Some(Permission::ActionPauseMtaQueue), + 243 => Some(Permission::ActionResumeMtaQueue), + 244 => Some(Permission::SysActionGet), + 245 => Some(Permission::SysActionCreate), + 246 => Some(Permission::SysActionUpdate), + 247 => Some(Permission::SysActionDestroy), + 248 => Some(Permission::SysActionQuery), + 249 => Some(Permission::SysAddressBookGet), + 250 => Some(Permission::SysAddressBookUpdate), + 251 => Some(Permission::SysAiModelGet), + 252 => Some(Permission::SysAiModelCreate), + 253 => Some(Permission::SysAiModelUpdate), + 254 => Some(Permission::SysAiModelDestroy), + 255 => Some(Permission::SysAiModelQuery), + 256 => Some(Permission::SysAlertGet), + 257 => Some(Permission::SysAlertCreate), + 258 => Some(Permission::SysAlertUpdate), + 259 => Some(Permission::SysAlertDestroy), + 260 => Some(Permission::SysAlertQuery), + 261 => Some(Permission::SysAllowedIpGet), + 262 => Some(Permission::SysAllowedIpCreate), + 263 => Some(Permission::SysAllowedIpUpdate), + 264 => Some(Permission::SysAllowedIpDestroy), + 265 => Some(Permission::SysAllowedIpQuery), + 266 => Some(Permission::SysApiKeyGet), + 267 => Some(Permission::SysApiKeyCreate), + 268 => Some(Permission::SysApiKeyUpdate), + 269 => Some(Permission::SysApiKeyDestroy), + 270 => Some(Permission::SysApiKeyQuery), + 271 => Some(Permission::SysAppPasswordGet), + 272 => Some(Permission::SysAppPasswordCreate), + 273 => Some(Permission::SysAppPasswordUpdate), + 274 => Some(Permission::SysAppPasswordDestroy), + 275 => Some(Permission::SysAppPasswordQuery), + 276 => Some(Permission::SysApplicationGet), + 277 => Some(Permission::SysApplicationCreate), + 278 => Some(Permission::SysApplicationUpdate), + 279 => Some(Permission::SysApplicationDestroy), + 280 => Some(Permission::SysApplicationQuery), + 281 => Some(Permission::SysArchivedItemGet), + 282 => Some(Permission::SysArchivedItemCreate), + 283 => Some(Permission::SysArchivedItemUpdate), + 284 => Some(Permission::SysArchivedItemDestroy), + 285 => Some(Permission::SysArchivedItemQuery), + 286 => Some(Permission::SysArfExternalReportGet), + 287 => Some(Permission::SysArfExternalReportCreate), + 288 => Some(Permission::SysArfExternalReportUpdate), + 289 => Some(Permission::SysArfExternalReportDestroy), + 290 => Some(Permission::SysArfExternalReportQuery), + 291 => Some(Permission::SysAsnGet), + 292 => Some(Permission::SysAsnUpdate), + 293 => Some(Permission::SysAuthenticationGet), + 294 => Some(Permission::SysAuthenticationUpdate), + 295 => Some(Permission::SysBlobStoreGet), + 296 => Some(Permission::SysBlobStoreUpdate), + 297 => Some(Permission::SysBlockedIpGet), + 298 => Some(Permission::SysBlockedIpCreate), + 299 => Some(Permission::SysBlockedIpUpdate), + 300 => Some(Permission::SysBlockedIpDestroy), + 301 => Some(Permission::SysBlockedIpQuery), + 302 => Some(Permission::SysBootstrapGet), + 303 => Some(Permission::SysBootstrapUpdate), + 304 => Some(Permission::SysCacheGet), + 305 => Some(Permission::SysCacheUpdate), + 306 => Some(Permission::SysCalendarGet), + 307 => Some(Permission::SysCalendarUpdate), + 308 => Some(Permission::SysCalendarAlarmGet), + 309 => Some(Permission::SysCalendarAlarmUpdate), + 310 => Some(Permission::SysCalendarSchedulingGet), + 311 => Some(Permission::SysCalendarSchedulingUpdate), + 312 => Some(Permission::SysCertificateGet), + 313 => Some(Permission::SysCertificateCreate), + 314 => Some(Permission::SysCertificateUpdate), + 315 => Some(Permission::SysCertificateDestroy), + 316 => Some(Permission::SysCertificateQuery), + 317 => Some(Permission::SysClusterNodeGet), + 318 => Some(Permission::SysClusterNodeCreate), + 319 => Some(Permission::SysClusterNodeUpdate), + 320 => Some(Permission::SysClusterNodeDestroy), + 321 => Some(Permission::SysClusterNodeQuery), + 322 => Some(Permission::SysClusterRoleGet), + 323 => Some(Permission::SysClusterRoleCreate), + 324 => Some(Permission::SysClusterRoleUpdate), + 325 => Some(Permission::SysClusterRoleDestroy), + 326 => Some(Permission::SysClusterRoleQuery), + 327 => Some(Permission::SysCoordinatorGet), + 328 => Some(Permission::SysCoordinatorUpdate), + 329 => Some(Permission::SysDataRetentionGet), + 330 => Some(Permission::SysDataRetentionUpdate), + 331 => Some(Permission::SysDataStoreGet), + 332 => Some(Permission::SysDataStoreUpdate), + 333 => Some(Permission::SysDirectoryGet), + 334 => Some(Permission::SysDirectoryCreate), + 335 => Some(Permission::SysDirectoryUpdate), + 336 => Some(Permission::SysDirectoryDestroy), + 337 => Some(Permission::SysDirectoryQuery), + 338 => Some(Permission::SysDkimReportSettingsGet), + 339 => Some(Permission::SysDkimReportSettingsUpdate), + 340 => Some(Permission::SysDkimSignatureGet), + 341 => Some(Permission::SysDkimSignatureCreate), + 342 => Some(Permission::SysDkimSignatureUpdate), + 343 => Some(Permission::SysDkimSignatureDestroy), + 344 => Some(Permission::SysDkimSignatureQuery), + 345 => Some(Permission::SysDmarcExternalReportGet), + 346 => Some(Permission::SysDmarcExternalReportCreate), + 347 => Some(Permission::SysDmarcExternalReportUpdate), + 348 => Some(Permission::SysDmarcExternalReportDestroy), + 349 => Some(Permission::SysDmarcExternalReportQuery), + 350 => Some(Permission::SysDmarcInternalReportGet), + 351 => Some(Permission::SysDmarcInternalReportCreate), + 352 => Some(Permission::SysDmarcInternalReportUpdate), + 353 => Some(Permission::SysDmarcInternalReportDestroy), + 354 => Some(Permission::SysDmarcInternalReportQuery), + 355 => Some(Permission::SysDmarcReportSettingsGet), + 356 => Some(Permission::SysDmarcReportSettingsUpdate), + 357 => Some(Permission::SysDnsResolverGet), + 358 => Some(Permission::SysDnsResolverUpdate), + 359 => Some(Permission::SysDnsServerGet), + 360 => Some(Permission::SysDnsServerCreate), + 361 => Some(Permission::SysDnsServerUpdate), + 362 => Some(Permission::SysDnsServerDestroy), + 363 => Some(Permission::SysDnsServerQuery), + 364 => Some(Permission::SysDomainGet), + 365 => Some(Permission::SysDomainCreate), + 366 => Some(Permission::SysDomainUpdate), + 367 => Some(Permission::SysDomainDestroy), + 368 => Some(Permission::SysDomainQuery), + 369 => Some(Permission::SysDsnReportSettingsGet), + 370 => Some(Permission::SysDsnReportSettingsUpdate), + 371 => Some(Permission::SysEmailGet), + 372 => Some(Permission::SysEmailUpdate), + 373 => Some(Permission::SysEnterpriseGet), + 374 => Some(Permission::SysEnterpriseUpdate), + 375 => Some(Permission::SysEventTracingLevelGet), + 376 => Some(Permission::SysEventTracingLevelCreate), + 377 => Some(Permission::SysEventTracingLevelUpdate), + 378 => Some(Permission::SysEventTracingLevelDestroy), + 379 => Some(Permission::SysEventTracingLevelQuery), + 380 => Some(Permission::SysFileStorageGet), + 381 => Some(Permission::SysFileStorageUpdate), + 382 => Some(Permission::SysHttpGet), + 383 => Some(Permission::SysHttpUpdate), + 384 => Some(Permission::SysHttpFormGet), + 385 => Some(Permission::SysHttpFormUpdate), + 386 => Some(Permission::SysHttpLookupGet), + 387 => Some(Permission::SysHttpLookupCreate), + 388 => Some(Permission::SysHttpLookupUpdate), + 389 => Some(Permission::SysHttpLookupDestroy), + 390 => Some(Permission::SysHttpLookupQuery), + 391 => Some(Permission::SysImapGet), + 392 => Some(Permission::SysImapUpdate), + 393 => Some(Permission::SysInMemoryStoreGet), + 394 => Some(Permission::SysInMemoryStoreUpdate), + 395 => Some(Permission::SysJmapGet), + 396 => Some(Permission::SysJmapUpdate), + 397 => Some(Permission::SysLogGet), + 398 => Some(Permission::SysLogCreate), + 399 => Some(Permission::SysLogUpdate), + 400 => Some(Permission::SysLogDestroy), + 401 => Some(Permission::SysLogQuery), + 402 => Some(Permission::SysMailingListGet), + 403 => Some(Permission::SysMailingListCreate), + 404 => Some(Permission::SysMailingListUpdate), + 405 => Some(Permission::SysMailingListDestroy), + 406 => Some(Permission::SysMailingListQuery), + 407 => Some(Permission::SysMaskedEmailGet), + 408 => Some(Permission::SysMaskedEmailCreate), + 409 => Some(Permission::SysMaskedEmailUpdate), + 410 => Some(Permission::SysMaskedEmailDestroy), + 411 => Some(Permission::SysMaskedEmailQuery), + 412 => Some(Permission::SysMemoryLookupKeyGet), + 413 => Some(Permission::SysMemoryLookupKeyCreate), + 414 => Some(Permission::SysMemoryLookupKeyUpdate), + 415 => Some(Permission::SysMemoryLookupKeyDestroy), + 416 => Some(Permission::SysMemoryLookupKeyQuery), + 417 => Some(Permission::SysMemoryLookupKeyValueGet), + 418 => Some(Permission::SysMemoryLookupKeyValueCreate), + 419 => Some(Permission::SysMemoryLookupKeyValueUpdate), + 420 => Some(Permission::SysMemoryLookupKeyValueDestroy), + 421 => Some(Permission::SysMemoryLookupKeyValueQuery), + 422 => Some(Permission::SysMetricGet), + 423 => Some(Permission::SysMetricCreate), + 424 => Some(Permission::SysMetricUpdate), + 425 => Some(Permission::SysMetricDestroy), + 426 => Some(Permission::SysMetricQuery), + 427 => Some(Permission::SysMetricsGet), + 428 => Some(Permission::SysMetricsUpdate), + 429 => Some(Permission::SysMetricsStoreGet), + 430 => Some(Permission::SysMetricsStoreUpdate), + 431 => Some(Permission::SysMtaConnectionStrategyGet), + 432 => Some(Permission::SysMtaConnectionStrategyCreate), + 433 => Some(Permission::SysMtaConnectionStrategyUpdate), + 434 => Some(Permission::SysMtaConnectionStrategyDestroy), + 435 => Some(Permission::SysMtaConnectionStrategyQuery), + 436 => Some(Permission::SysMtaDeliveryScheduleGet), + 437 => Some(Permission::SysMtaDeliveryScheduleCreate), + 438 => Some(Permission::SysMtaDeliveryScheduleUpdate), + 439 => Some(Permission::SysMtaDeliveryScheduleDestroy), + 440 => Some(Permission::SysMtaDeliveryScheduleQuery), + 441 => Some(Permission::SysMtaExtensionsGet), + 442 => Some(Permission::SysMtaExtensionsUpdate), + 443 => Some(Permission::SysMtaHookGet), + 444 => Some(Permission::SysMtaHookCreate), + 445 => Some(Permission::SysMtaHookUpdate), + 446 => Some(Permission::SysMtaHookDestroy), + 447 => Some(Permission::SysMtaHookQuery), + 448 => Some(Permission::SysMtaInboundSessionGet), + 449 => Some(Permission::SysMtaInboundSessionUpdate), + 450 => Some(Permission::SysMtaInboundThrottleGet), + 451 => Some(Permission::SysMtaInboundThrottleCreate), + 452 => Some(Permission::SysMtaInboundThrottleUpdate), + 453 => Some(Permission::SysMtaInboundThrottleDestroy), + 454 => Some(Permission::SysMtaInboundThrottleQuery), + 455 => Some(Permission::SysMtaMilterGet), + 456 => Some(Permission::SysMtaMilterCreate), + 457 => Some(Permission::SysMtaMilterUpdate), + 458 => Some(Permission::SysMtaMilterDestroy), + 459 => Some(Permission::SysMtaMilterQuery), + 460 => Some(Permission::SysMtaOutboundStrategyGet), + 461 => Some(Permission::SysMtaOutboundStrategyUpdate), + 462 => Some(Permission::SysMtaOutboundThrottleGet), + 463 => Some(Permission::SysMtaOutboundThrottleCreate), + 464 => Some(Permission::SysMtaOutboundThrottleUpdate), + 465 => Some(Permission::SysMtaOutboundThrottleDestroy), + 466 => Some(Permission::SysMtaOutboundThrottleQuery), + 467 => Some(Permission::SysMtaQueueQuotaGet), + 468 => Some(Permission::SysMtaQueueQuotaCreate), + 469 => Some(Permission::SysMtaQueueQuotaUpdate), + 470 => Some(Permission::SysMtaQueueQuotaDestroy), + 471 => Some(Permission::SysMtaQueueQuotaQuery), + 472 => Some(Permission::SysMtaRouteGet), + 473 => Some(Permission::SysMtaRouteCreate), + 474 => Some(Permission::SysMtaRouteUpdate), + 475 => Some(Permission::SysMtaRouteDestroy), + 476 => Some(Permission::SysMtaRouteQuery), + 477 => Some(Permission::SysMtaStageAuthGet), + 478 => Some(Permission::SysMtaStageAuthUpdate), + 479 => Some(Permission::SysMtaStageConnectGet), + 480 => Some(Permission::SysMtaStageConnectUpdate), + 481 => Some(Permission::SysMtaStageDataGet), + 482 => Some(Permission::SysMtaStageDataUpdate), + 483 => Some(Permission::SysMtaStageEhloGet), + 484 => Some(Permission::SysMtaStageEhloUpdate), + 485 => Some(Permission::SysMtaStageMailGet), + 486 => Some(Permission::SysMtaStageMailUpdate), + 487 => Some(Permission::SysMtaStageRcptGet), + 488 => Some(Permission::SysMtaStageRcptUpdate), + 489 => Some(Permission::SysMtaStsGet), + 490 => Some(Permission::SysMtaStsUpdate), + 491 => Some(Permission::SysMtaTlsStrategyGet), + 492 => Some(Permission::SysMtaTlsStrategyCreate), + 493 => Some(Permission::SysMtaTlsStrategyUpdate), + 494 => Some(Permission::SysMtaTlsStrategyDestroy), + 495 => Some(Permission::SysMtaTlsStrategyQuery), + 496 => Some(Permission::SysMtaVirtualQueueGet), + 497 => Some(Permission::SysMtaVirtualQueueCreate), + 498 => Some(Permission::SysMtaVirtualQueueUpdate), + 499 => Some(Permission::SysMtaVirtualQueueDestroy), + 500 => Some(Permission::SysMtaVirtualQueueQuery), + 501 => Some(Permission::SysNetworkListenerGet), + 502 => Some(Permission::SysNetworkListenerCreate), + 503 => Some(Permission::SysNetworkListenerUpdate), + 504 => Some(Permission::SysNetworkListenerDestroy), + 505 => Some(Permission::SysNetworkListenerQuery), + 506 => Some(Permission::SysOAuthClientGet), + 507 => Some(Permission::SysOAuthClientCreate), + 508 => Some(Permission::SysOAuthClientUpdate), + 509 => Some(Permission::SysOAuthClientDestroy), + 510 => Some(Permission::SysOAuthClientQuery), + 511 => Some(Permission::SysOidcProviderGet), + 512 => Some(Permission::SysOidcProviderUpdate), + 513 => Some(Permission::SysPublicKeyGet), + 514 => Some(Permission::SysPublicKeyCreate), + 515 => Some(Permission::SysPublicKeyUpdate), + 516 => Some(Permission::SysPublicKeyDestroy), + 517 => Some(Permission::SysPublicKeyQuery), + 518 => Some(Permission::SysQueuedMessageGet), + 519 => Some(Permission::SysQueuedMessageCreate), + 520 => Some(Permission::SysQueuedMessageUpdate), + 521 => Some(Permission::SysQueuedMessageDestroy), + 522 => Some(Permission::SysQueuedMessageQuery), + 523 => Some(Permission::SysReportSettingsGet), + 524 => Some(Permission::SysReportSettingsUpdate), + 525 => Some(Permission::SysRoleGet), + 526 => Some(Permission::SysRoleCreate), + 527 => Some(Permission::SysRoleUpdate), + 528 => Some(Permission::SysRoleDestroy), + 529 => Some(Permission::SysRoleQuery), + 530 => Some(Permission::SysSearchGet), + 531 => Some(Permission::SysSearchUpdate), + 532 => Some(Permission::SysSearchStoreGet), + 533 => Some(Permission::SysSearchStoreUpdate), + 534 => Some(Permission::SysSecurityGet), + 535 => Some(Permission::SysSecurityUpdate), + 536 => Some(Permission::SysSenderAuthGet), + 537 => Some(Permission::SysSenderAuthUpdate), + 538 => Some(Permission::SysSharingGet), + 539 => Some(Permission::SysSharingUpdate), + 540 => Some(Permission::SysSieveSystemInterpreterGet), + 541 => Some(Permission::SysSieveSystemInterpreterUpdate), + 542 => Some(Permission::SysSieveSystemScriptGet), + 543 => Some(Permission::SysSieveSystemScriptCreate), + 544 => Some(Permission::SysSieveSystemScriptUpdate), + 545 => Some(Permission::SysSieveSystemScriptDestroy), + 546 => Some(Permission::SysSieveSystemScriptQuery), + 547 => Some(Permission::SysSieveUserInterpreterGet), + 548 => Some(Permission::SysSieveUserInterpreterUpdate), + 549 => Some(Permission::SysSieveUserScriptGet), + 550 => Some(Permission::SysSieveUserScriptCreate), + 551 => Some(Permission::SysSieveUserScriptUpdate), + 552 => Some(Permission::SysSieveUserScriptDestroy), + 553 => Some(Permission::SysSieveUserScriptQuery), + 554 => Some(Permission::SysSpamClassifierGet), + 555 => Some(Permission::SysSpamClassifierUpdate), + 556 => Some(Permission::SysSpamDnsblServerGet), + 557 => Some(Permission::SysSpamDnsblServerCreate), + 558 => Some(Permission::SysSpamDnsblServerUpdate), + 559 => Some(Permission::SysSpamDnsblServerDestroy), + 560 => Some(Permission::SysSpamDnsblServerQuery), + 561 => Some(Permission::SysSpamDnsblSettingsGet), + 562 => Some(Permission::SysSpamDnsblSettingsUpdate), + 563 => Some(Permission::SysSpamFileExtensionGet), + 564 => Some(Permission::SysSpamFileExtensionCreate), + 565 => Some(Permission::SysSpamFileExtensionUpdate), + 566 => Some(Permission::SysSpamFileExtensionDestroy), + 567 => Some(Permission::SysSpamFileExtensionQuery), + 568 => Some(Permission::SysSpamLlmGet), + 569 => Some(Permission::SysSpamLlmUpdate), + 570 => Some(Permission::SysSpamPyzorGet), + 571 => Some(Permission::SysSpamPyzorUpdate), + 572 => Some(Permission::SysSpamRuleGet), + 573 => Some(Permission::SysSpamRuleCreate), + 574 => Some(Permission::SysSpamRuleUpdate), + 575 => Some(Permission::SysSpamRuleDestroy), + 576 => Some(Permission::SysSpamRuleQuery), + 577 => Some(Permission::SysSpamSettingsGet), + 578 => Some(Permission::SysSpamSettingsUpdate), + 579 => Some(Permission::SysSpamTagGet), + 580 => Some(Permission::SysSpamTagCreate), + 581 => Some(Permission::SysSpamTagUpdate), + 582 => Some(Permission::SysSpamTagDestroy), + 583 => Some(Permission::SysSpamTagQuery), + 584 => Some(Permission::SysSpamTrainingSampleGet), + 585 => Some(Permission::SysSpamTrainingSampleCreate), + 586 => Some(Permission::SysSpamTrainingSampleUpdate), + 587 => Some(Permission::SysSpamTrainingSampleDestroy), + 588 => Some(Permission::SysSpamTrainingSampleQuery), + 589 => Some(Permission::SysSpfReportSettingsGet), + 590 => Some(Permission::SysSpfReportSettingsUpdate), + 591 => Some(Permission::SysStoreLookupGet), + 592 => Some(Permission::SysStoreLookupCreate), + 593 => Some(Permission::SysStoreLookupUpdate), + 594 => Some(Permission::SysStoreLookupDestroy), + 595 => Some(Permission::SysStoreLookupQuery), + 596 => Some(Permission::SysSystemSettingsGet), + 597 => Some(Permission::SysSystemSettingsUpdate), + 598 => Some(Permission::TaskIndexDocument), + 599 => Some(Permission::TaskUnindexDocument), + 600 => Some(Permission::TaskIndexTrace), + 601 => Some(Permission::TaskCalendarAlarmEmail), + 602 => Some(Permission::TaskCalendarAlarmNotification), + 603 => Some(Permission::TaskCalendarItipMessage), + 604 => Some(Permission::TaskMergeThreads), + 605 => Some(Permission::TaskDmarcReport), + 606 => Some(Permission::TaskTlsReport), + 607 => Some(Permission::TaskRestoreArchivedItem), + 608 => Some(Permission::TaskDestroyAccount), + 609 => Some(Permission::TaskAccountMaintenance), + 610 => Some(Permission::TaskTenantMaintenance), + 611 => Some(Permission::TaskStoreMaintenance), + 612 => Some(Permission::TaskSpamFilterMaintenance), + 613 => Some(Permission::TaskAcmeRenewal), + 614 => Some(Permission::TaskDkimManagement), + 615 => Some(Permission::TaskDnsManagement), + 616 => Some(Permission::SysTaskGet), + 617 => Some(Permission::SysTaskCreate), + 618 => Some(Permission::SysTaskUpdate), + 619 => Some(Permission::SysTaskDestroy), + 620 => Some(Permission::SysTaskQuery), + 621 => Some(Permission::SysTaskManagerGet), + 622 => Some(Permission::SysTaskManagerUpdate), + 623 => Some(Permission::SysTenantGet), + 624 => Some(Permission::SysTenantCreate), + 625 => Some(Permission::SysTenantUpdate), + 626 => Some(Permission::SysTenantDestroy), + 627 => Some(Permission::SysTenantQuery), + 628 => Some(Permission::SysTlsExternalReportGet), + 629 => Some(Permission::SysTlsExternalReportCreate), + 630 => Some(Permission::SysTlsExternalReportUpdate), + 631 => Some(Permission::SysTlsExternalReportDestroy), + 632 => Some(Permission::SysTlsExternalReportQuery), + 633 => Some(Permission::SysTlsInternalReportGet), + 634 => Some(Permission::SysTlsInternalReportCreate), + 635 => Some(Permission::SysTlsInternalReportUpdate), + 636 => Some(Permission::SysTlsInternalReportDestroy), + 637 => Some(Permission::SysTlsInternalReportQuery), + 638 => Some(Permission::SysTlsReportSettingsGet), + 639 => Some(Permission::SysTlsReportSettingsUpdate), + 640 => Some(Permission::SysTraceGet), + 641 => Some(Permission::SysTraceCreate), + 642 => Some(Permission::SysTraceUpdate), + 643 => Some(Permission::SysTraceDestroy), + 644 => Some(Permission::SysTraceQuery), + 645 => Some(Permission::SysTracerGet), + 646 => Some(Permission::SysTracerCreate), + 647 => Some(Permission::SysTracerUpdate), + 648 => Some(Permission::SysTracerDestroy), + 649 => Some(Permission::SysTracerQuery), + 650 => Some(Permission::SysTracingStoreGet), + 651 => Some(Permission::SysTracingStoreUpdate), + 652 => Some(Permission::SysWebDavGet), + 653 => Some(Permission::SysWebDavUpdate), + 654 => Some(Permission::SysWebHookGet), + 655 => Some(Permission::SysWebHookCreate), + 656 => Some(Permission::SysWebHookUpdate), + 657 => Some(Permission::SysWebHookDestroy), + 658 => Some(Permission::SysWebHookQuery), + _ => None, + } + } + + const COUNT: usize = 661; +} + +impl serde::Serialize for Permission { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Permission { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PermissionsType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Inherit" => PermissionsType::Inherit, + b"Merge" => PermissionsType::Merge, + b"Replace" => PermissionsType::Replace, + } + } + + fn as_str(&self) -> &'static str { + match self { + PermissionsType::Inherit => "Inherit", + PermissionsType::Merge => "Merge", + PermissionsType::Replace => "Replace", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PermissionsType::Inherit), + 1 => Some(PermissionsType::Merge), + 2 => Some(PermissionsType::Replace), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for PermissionsType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PermissionsType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PolicyEnforcement { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"enforce" => PolicyEnforcement::Enforce, + b"testing" => PolicyEnforcement::Testing, + b"disable" => PolicyEnforcement::Disable, + } + } + + fn as_str(&self) -> &'static str { + match self { + PolicyEnforcement::Enforce => "enforce", + PolicyEnforcement::Testing => "testing", + PolicyEnforcement::Disable => "disable", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PolicyEnforcement::Enforce), + 1 => Some(PolicyEnforcement::Testing), + 2 => Some(PolicyEnforcement::Disable), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for PolicyEnforcement { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PolicyEnforcement { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PostgreSqlRecyclingMethod { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"fast" => PostgreSqlRecyclingMethod::Fast, + b"verified" => PostgreSqlRecyclingMethod::Verified, + b"clean" => PostgreSqlRecyclingMethod::Clean, + } + } + + fn as_str(&self) -> &'static str { + match self { + PostgreSqlRecyclingMethod::Fast => "fast", + PostgreSqlRecyclingMethod::Verified => "verified", + PostgreSqlRecyclingMethod::Clean => "clean", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PostgreSqlRecyclingMethod::Fast), + 1 => Some(PostgreSqlRecyclingMethod::Verified), + 2 => Some(PostgreSqlRecyclingMethod::Clean), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for PostgreSqlRecyclingMethod { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PostgreSqlRecyclingMethod { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ProviderInfo { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"providerName" => ProviderInfo::ProviderName, + b"providerShortName" => ProviderInfo::ProviderShortName, + b"userDocumentation" => ProviderInfo::UserDocumentation, + b"developerDocumentation" => ProviderInfo::DeveloperDocumentation, + b"contactUri" => ProviderInfo::ContactUri, + b"logoUrl" => ProviderInfo::LogoUrl, + b"logoWidth" => ProviderInfo::LogoWidth, + b"logoHeight" => ProviderInfo::LogoHeight, + } + } + + fn as_str(&self) -> &'static str { + match self { + ProviderInfo::ProviderName => "providerName", + ProviderInfo::ProviderShortName => "providerShortName", + ProviderInfo::UserDocumentation => "userDocumentation", + ProviderInfo::DeveloperDocumentation => "developerDocumentation", + ProviderInfo::ContactUri => "contactUri", + ProviderInfo::LogoUrl => "logoUrl", + ProviderInfo::LogoWidth => "logoWidth", + ProviderInfo::LogoHeight => "logoHeight", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ProviderInfo::ProviderName), + 1 => Some(ProviderInfo::ProviderShortName), + 2 => Some(ProviderInfo::UserDocumentation), + 3 => Some(ProviderInfo::DeveloperDocumentation), + 4 => Some(ProviderInfo::ContactUri), + 5 => Some(ProviderInfo::LogoUrl), + 6 => Some(ProviderInfo::LogoWidth), + 7 => Some(ProviderInfo::LogoHeight), + _ => None, + } + } + + const COUNT: usize = 8; +} + +impl serde::Serialize for ProviderInfo { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ProviderInfo { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PublicStringOptionalType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"None" => PublicStringOptionalType::None, + b"Value" => PublicStringOptionalType::Value, + b"EnvironmentVariable" => PublicStringOptionalType::EnvironmentVariable, + b"File" => PublicStringOptionalType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + PublicStringOptionalType::None => "None", + PublicStringOptionalType::Value => "Value", + PublicStringOptionalType::EnvironmentVariable => "EnvironmentVariable", + PublicStringOptionalType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PublicStringOptionalType::None), + 1 => Some(PublicStringOptionalType::Value), + 2 => Some(PublicStringOptionalType::EnvironmentVariable), + 3 => Some(PublicStringOptionalType::File), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for PublicStringOptionalType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PublicStringOptionalType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for PublicTextType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Text" => PublicTextType::Text, + b"EnvironmentVariable" => PublicTextType::EnvironmentVariable, + b"File" => PublicTextType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + PublicTextType::Text => "Text", + PublicTextType::EnvironmentVariable => "EnvironmentVariable", + PublicTextType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(PublicTextType::Text), + 1 => Some(PublicTextType::EnvironmentVariable), + 2 => Some(PublicTextType::File), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for PublicTextType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for PublicTextType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for QueueExpiryType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Ttl" => QueueExpiryType::Ttl, + b"Attempts" => QueueExpiryType::Attempts, + } + } + + fn as_str(&self) -> &'static str { + match self { + QueueExpiryType::Ttl => "Ttl", + QueueExpiryType::Attempts => "Attempts", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(QueueExpiryType::Ttl), + 1 => Some(QueueExpiryType::Attempts), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for QueueExpiryType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for QueueExpiryType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for RecipientFlag { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"dsnSent" => RecipientFlag::DsnSent, + b"spamPayload" => RecipientFlag::SpamPayload, + } + } + + fn as_str(&self) -> &'static str { + match self { + RecipientFlag::DsnSent => "dsnSent", + RecipientFlag::SpamPayload => "spamPayload", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(RecipientFlag::DsnSent), + 1 => Some(RecipientFlag::SpamPayload), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for RecipientFlag { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for RecipientFlag { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for RecipientStatusType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Scheduled" => RecipientStatusType::Scheduled, + b"Completed" => RecipientStatusType::Completed, + b"TemporaryFailure" => RecipientStatusType::TemporaryFailure, + b"PermanentFailure" => RecipientStatusType::PermanentFailure, + } + } + + fn as_str(&self) -> &'static str { + match self { + RecipientStatusType::Scheduled => "Scheduled", + RecipientStatusType::Completed => "Completed", + RecipientStatusType::TemporaryFailure => "TemporaryFailure", + RecipientStatusType::PermanentFailure => "PermanentFailure", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(RecipientStatusType::Scheduled), + 1 => Some(RecipientStatusType::Completed), + 2 => Some(RecipientStatusType::TemporaryFailure), + 3 => Some(RecipientStatusType::PermanentFailure), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for RecipientStatusType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for RecipientStatusType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for RedisProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"resp2" => RedisProtocol::Resp2, + b"resp3" => RedisProtocol::Resp3, + } + } + + fn as_str(&self) -> &'static str { + match self { + RedisProtocol::Resp2 => "resp2", + RedisProtocol::Resp3 => "resp3", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(RedisProtocol::Resp2), + 1 => Some(RedisProtocol::Resp3), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for RedisProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for RedisProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for RolesType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => RolesType::Default, + b"Custom" => RolesType::Custom, + } + } + + fn as_str(&self) -> &'static str { + match self { + RolesType::Default => "Default", + RolesType::Custom => "Custom", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(RolesType::Default), + 1 => Some(RolesType::Custom), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for RolesType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for RolesType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for S3StoreRegionType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"UsEast1" => S3StoreRegionType::UsEast1, + b"UsEast2" => S3StoreRegionType::UsEast2, + b"UsWest1" => S3StoreRegionType::UsWest1, + b"UsWest2" => S3StoreRegionType::UsWest2, + b"CaCentral1" => S3StoreRegionType::CaCentral1, + b"AfSouth1" => S3StoreRegionType::AfSouth1, + b"ApEast1" => S3StoreRegionType::ApEast1, + b"ApSouth1" => S3StoreRegionType::ApSouth1, + b"ApNortheast1" => S3StoreRegionType::ApNortheast1, + b"ApNortheast2" => S3StoreRegionType::ApNortheast2, + b"ApNortheast3" => S3StoreRegionType::ApNortheast3, + b"ApSoutheast1" => S3StoreRegionType::ApSoutheast1, + b"ApSoutheast2" => S3StoreRegionType::ApSoutheast2, + b"CnNorth1" => S3StoreRegionType::CnNorth1, + b"CnNorthwest1" => S3StoreRegionType::CnNorthwest1, + b"EuNorth1" => S3StoreRegionType::EuNorth1, + b"EuCentral1" => S3StoreRegionType::EuCentral1, + b"EuCentral2" => S3StoreRegionType::EuCentral2, + b"EuWest1" => S3StoreRegionType::EuWest1, + b"EuWest2" => S3StoreRegionType::EuWest2, + b"EuWest3" => S3StoreRegionType::EuWest3, + b"IlCentral1" => S3StoreRegionType::IlCentral1, + b"MeSouth1" => S3StoreRegionType::MeSouth1, + b"SaEast1" => S3StoreRegionType::SaEast1, + b"DoNyc3" => S3StoreRegionType::DoNyc3, + b"DoAms3" => S3StoreRegionType::DoAms3, + b"DoSgp1" => S3StoreRegionType::DoSgp1, + b"DoFra1" => S3StoreRegionType::DoFra1, + b"Yandex" => S3StoreRegionType::Yandex, + b"WaUsEast1" => S3StoreRegionType::WaUsEast1, + b"WaUsEast2" => S3StoreRegionType::WaUsEast2, + b"WaUsCentral1" => S3StoreRegionType::WaUsCentral1, + b"WaUsWest1" => S3StoreRegionType::WaUsWest1, + b"WaCaCentral1" => S3StoreRegionType::WaCaCentral1, + b"WaEuCentral1" => S3StoreRegionType::WaEuCentral1, + b"WaEuCentral2" => S3StoreRegionType::WaEuCentral2, + b"WaEuWest1" => S3StoreRegionType::WaEuWest1, + b"WaEuWest2" => S3StoreRegionType::WaEuWest2, + b"WaApNortheast1" => S3StoreRegionType::WaApNortheast1, + b"WaApNortheast2" => S3StoreRegionType::WaApNortheast2, + b"WaApSoutheast1" => S3StoreRegionType::WaApSoutheast1, + b"WaApSoutheast2" => S3StoreRegionType::WaApSoutheast2, + b"Custom" => S3StoreRegionType::Custom, + } + } + + fn as_str(&self) -> &'static str { + match self { + S3StoreRegionType::UsEast1 => "UsEast1", + S3StoreRegionType::UsEast2 => "UsEast2", + S3StoreRegionType::UsWest1 => "UsWest1", + S3StoreRegionType::UsWest2 => "UsWest2", + S3StoreRegionType::CaCentral1 => "CaCentral1", + S3StoreRegionType::AfSouth1 => "AfSouth1", + S3StoreRegionType::ApEast1 => "ApEast1", + S3StoreRegionType::ApSouth1 => "ApSouth1", + S3StoreRegionType::ApNortheast1 => "ApNortheast1", + S3StoreRegionType::ApNortheast2 => "ApNortheast2", + S3StoreRegionType::ApNortheast3 => "ApNortheast3", + S3StoreRegionType::ApSoutheast1 => "ApSoutheast1", + S3StoreRegionType::ApSoutheast2 => "ApSoutheast2", + S3StoreRegionType::CnNorth1 => "CnNorth1", + S3StoreRegionType::CnNorthwest1 => "CnNorthwest1", + S3StoreRegionType::EuNorth1 => "EuNorth1", + S3StoreRegionType::EuCentral1 => "EuCentral1", + S3StoreRegionType::EuCentral2 => "EuCentral2", + S3StoreRegionType::EuWest1 => "EuWest1", + S3StoreRegionType::EuWest2 => "EuWest2", + S3StoreRegionType::EuWest3 => "EuWest3", + S3StoreRegionType::IlCentral1 => "IlCentral1", + S3StoreRegionType::MeSouth1 => "MeSouth1", + S3StoreRegionType::SaEast1 => "SaEast1", + S3StoreRegionType::DoNyc3 => "DoNyc3", + S3StoreRegionType::DoAms3 => "DoAms3", + S3StoreRegionType::DoSgp1 => "DoSgp1", + S3StoreRegionType::DoFra1 => "DoFra1", + S3StoreRegionType::Yandex => "Yandex", + S3StoreRegionType::WaUsEast1 => "WaUsEast1", + S3StoreRegionType::WaUsEast2 => "WaUsEast2", + S3StoreRegionType::WaUsCentral1 => "WaUsCentral1", + S3StoreRegionType::WaUsWest1 => "WaUsWest1", + S3StoreRegionType::WaCaCentral1 => "WaCaCentral1", + S3StoreRegionType::WaEuCentral1 => "WaEuCentral1", + S3StoreRegionType::WaEuCentral2 => "WaEuCentral2", + S3StoreRegionType::WaEuWest1 => "WaEuWest1", + S3StoreRegionType::WaEuWest2 => "WaEuWest2", + S3StoreRegionType::WaApNortheast1 => "WaApNortheast1", + S3StoreRegionType::WaApNortheast2 => "WaApNortheast2", + S3StoreRegionType::WaApSoutheast1 => "WaApSoutheast1", + S3StoreRegionType::WaApSoutheast2 => "WaApSoutheast2", + S3StoreRegionType::Custom => "Custom", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(S3StoreRegionType::UsEast1), + 1 => Some(S3StoreRegionType::UsEast2), + 2 => Some(S3StoreRegionType::UsWest1), + 3 => Some(S3StoreRegionType::UsWest2), + 4 => Some(S3StoreRegionType::CaCentral1), + 5 => Some(S3StoreRegionType::AfSouth1), + 6 => Some(S3StoreRegionType::ApEast1), + 7 => Some(S3StoreRegionType::ApSouth1), + 8 => Some(S3StoreRegionType::ApNortheast1), + 9 => Some(S3StoreRegionType::ApNortheast2), + 10 => Some(S3StoreRegionType::ApNortheast3), + 11 => Some(S3StoreRegionType::ApSoutheast1), + 12 => Some(S3StoreRegionType::ApSoutheast2), + 13 => Some(S3StoreRegionType::CnNorth1), + 14 => Some(S3StoreRegionType::CnNorthwest1), + 15 => Some(S3StoreRegionType::EuNorth1), + 16 => Some(S3StoreRegionType::EuCentral1), + 17 => Some(S3StoreRegionType::EuCentral2), + 18 => Some(S3StoreRegionType::EuWest1), + 19 => Some(S3StoreRegionType::EuWest2), + 20 => Some(S3StoreRegionType::EuWest3), + 21 => Some(S3StoreRegionType::IlCentral1), + 22 => Some(S3StoreRegionType::MeSouth1), + 23 => Some(S3StoreRegionType::SaEast1), + 24 => Some(S3StoreRegionType::DoNyc3), + 25 => Some(S3StoreRegionType::DoAms3), + 26 => Some(S3StoreRegionType::DoSgp1), + 27 => Some(S3StoreRegionType::DoFra1), + 28 => Some(S3StoreRegionType::Yandex), + 29 => Some(S3StoreRegionType::WaUsEast1), + 30 => Some(S3StoreRegionType::WaUsEast2), + 31 => Some(S3StoreRegionType::WaUsCentral1), + 32 => Some(S3StoreRegionType::WaUsWest1), + 33 => Some(S3StoreRegionType::WaCaCentral1), + 34 => Some(S3StoreRegionType::WaEuCentral1), + 35 => Some(S3StoreRegionType::WaEuCentral2), + 36 => Some(S3StoreRegionType::WaEuWest1), + 37 => Some(S3StoreRegionType::WaEuWest2), + 38 => Some(S3StoreRegionType::WaApNortheast1), + 39 => Some(S3StoreRegionType::WaApNortheast2), + 40 => Some(S3StoreRegionType::WaApSoutheast1), + 41 => Some(S3StoreRegionType::WaApSoutheast2), + 42 => Some(S3StoreRegionType::Custom), + _ => None, + } + } + + const COUNT: usize = 43; +} + +impl serde::Serialize for S3StoreRegionType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for S3StoreRegionType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchCalendarField { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"title" => SearchCalendarField::Title, + b"description" => SearchCalendarField::Description, + b"location" => SearchCalendarField::Location, + b"owner" => SearchCalendarField::Owner, + b"attendee" => SearchCalendarField::Attendee, + b"start" => SearchCalendarField::Start, + b"uid" => SearchCalendarField::Uid, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchCalendarField::Title => "title", + SearchCalendarField::Description => "description", + SearchCalendarField::Location => "location", + SearchCalendarField::Owner => "owner", + SearchCalendarField::Attendee => "attendee", + SearchCalendarField::Start => "start", + SearchCalendarField::Uid => "uid", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchCalendarField::Title), + 1 => Some(SearchCalendarField::Description), + 2 => Some(SearchCalendarField::Location), + 3 => Some(SearchCalendarField::Owner), + 4 => Some(SearchCalendarField::Attendee), + 5 => Some(SearchCalendarField::Start), + 6 => Some(SearchCalendarField::Uid), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for SearchCalendarField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchCalendarField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchContactField { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"member" => SearchContactField::Member, + b"kind" => SearchContactField::Kind, + b"name" => SearchContactField::Name, + b"nickname" => SearchContactField::Nickname, + b"organization" => SearchContactField::Organization, + b"email" => SearchContactField::Email, + b"phone" => SearchContactField::Phone, + b"onlineService" => SearchContactField::OnlineService, + b"address" => SearchContactField::Address, + b"note" => SearchContactField::Note, + b"uid" => SearchContactField::Uid, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchContactField::Member => "member", + SearchContactField::Kind => "kind", + SearchContactField::Name => "name", + SearchContactField::Nickname => "nickname", + SearchContactField::Organization => "organization", + SearchContactField::Email => "email", + SearchContactField::Phone => "phone", + SearchContactField::OnlineService => "onlineService", + SearchContactField::Address => "address", + SearchContactField::Note => "note", + SearchContactField::Uid => "uid", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchContactField::Member), + 1 => Some(SearchContactField::Kind), + 2 => Some(SearchContactField::Name), + 3 => Some(SearchContactField::Nickname), + 4 => Some(SearchContactField::Organization), + 5 => Some(SearchContactField::Email), + 6 => Some(SearchContactField::Phone), + 7 => Some(SearchContactField::OnlineService), + 8 => Some(SearchContactField::Address), + 9 => Some(SearchContactField::Note), + 10 => Some(SearchContactField::Uid), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for SearchContactField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchContactField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchEmailField { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"from" => SearchEmailField::From, + b"to" => SearchEmailField::To, + b"cc" => SearchEmailField::Cc, + b"bcc" => SearchEmailField::Bcc, + b"subject" => SearchEmailField::Subject, + b"body" => SearchEmailField::Body, + b"attachment" => SearchEmailField::Attachment, + b"receivedAt" => SearchEmailField::ReceivedAt, + b"sentAt" => SearchEmailField::SentAt, + b"size" => SearchEmailField::Size, + b"hasAttachment" => SearchEmailField::HasAttachment, + b"headers" => SearchEmailField::Headers, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchEmailField::From => "from", + SearchEmailField::To => "to", + SearchEmailField::Cc => "cc", + SearchEmailField::Bcc => "bcc", + SearchEmailField::Subject => "subject", + SearchEmailField::Body => "body", + SearchEmailField::Attachment => "attachment", + SearchEmailField::ReceivedAt => "receivedAt", + SearchEmailField::SentAt => "sentAt", + SearchEmailField::Size => "size", + SearchEmailField::HasAttachment => "hasAttachment", + SearchEmailField::Headers => "headers", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchEmailField::From), + 1 => Some(SearchEmailField::To), + 2 => Some(SearchEmailField::Cc), + 3 => Some(SearchEmailField::Bcc), + 4 => Some(SearchEmailField::Subject), + 5 => Some(SearchEmailField::Body), + 6 => Some(SearchEmailField::Attachment), + 7 => Some(SearchEmailField::ReceivedAt), + 8 => Some(SearchEmailField::SentAt), + 9 => Some(SearchEmailField::Size), + 10 => Some(SearchEmailField::HasAttachment), + 11 => Some(SearchEmailField::Headers), + _ => None, + } + } + + const COUNT: usize = 12; +} + +impl serde::Serialize for SearchEmailField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchEmailField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchFileField { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"name" => SearchFileField::Name, + b"content" => SearchFileField::Content, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchFileField::Name => "name", + SearchFileField::Content => "content", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchFileField::Name), + 1 => Some(SearchFileField::Content), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for SearchFileField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchFileField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => SearchStoreType::Default, + b"ElasticSearch" => SearchStoreType::ElasticSearch, + b"Meilisearch" => SearchStoreType::Meilisearch, + b"FoundationDb" => SearchStoreType::FoundationDb, + b"PostgreSql" => SearchStoreType::PostgreSql, + b"MySql" => SearchStoreType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchStoreType::Default => "Default", + SearchStoreType::ElasticSearch => "ElasticSearch", + SearchStoreType::Meilisearch => "Meilisearch", + SearchStoreType::FoundationDb => "FoundationDb", + SearchStoreType::PostgreSql => "PostgreSql", + SearchStoreType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchStoreType::Default), + 1 => Some(SearchStoreType::ElasticSearch), + 2 => Some(SearchStoreType::Meilisearch), + 3 => Some(SearchStoreType::FoundationDb), + 4 => Some(SearchStoreType::PostgreSql), + 5 => Some(SearchStoreType::MySql), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for SearchStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SearchTracingField { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"eventType" => SearchTracingField::EventType, + b"queueId" => SearchTracingField::QueueId, + b"keywords" => SearchTracingField::Keywords, + } + } + + fn as_str(&self) -> &'static str { + match self { + SearchTracingField::EventType => "eventType", + SearchTracingField::QueueId => "queueId", + SearchTracingField::Keywords => "keywords", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SearchTracingField::EventType), + 1 => Some(SearchTracingField::QueueId), + 2 => Some(SearchTracingField::Keywords), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SearchTracingField { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SearchTracingField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SecretKeyOptionalType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"None" => SecretKeyOptionalType::None, + b"Value" => SecretKeyOptionalType::Value, + b"EnvironmentVariable" => SecretKeyOptionalType::EnvironmentVariable, + b"File" => SecretKeyOptionalType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + SecretKeyOptionalType::None => "None", + SecretKeyOptionalType::Value => "Value", + SecretKeyOptionalType::EnvironmentVariable => "EnvironmentVariable", + SecretKeyOptionalType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SecretKeyOptionalType::None), + 1 => Some(SecretKeyOptionalType::Value), + 2 => Some(SecretKeyOptionalType::EnvironmentVariable), + 3 => Some(SecretKeyOptionalType::File), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for SecretKeyOptionalType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SecretKeyOptionalType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SecretKeyType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Value" => SecretKeyType::Value, + b"EnvironmentVariable" => SecretKeyType::EnvironmentVariable, + b"File" => SecretKeyType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + SecretKeyType::Value => "Value", + SecretKeyType::EnvironmentVariable => "EnvironmentVariable", + SecretKeyType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SecretKeyType::Value), + 1 => Some(SecretKeyType::EnvironmentVariable), + 2 => Some(SecretKeyType::File), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SecretKeyType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SecretKeyType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SecretTextOptionalType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"None" => SecretTextOptionalType::None, + b"Text" => SecretTextOptionalType::Text, + b"EnvironmentVariable" => SecretTextOptionalType::EnvironmentVariable, + b"File" => SecretTextOptionalType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + SecretTextOptionalType::None => "None", + SecretTextOptionalType::Text => "Text", + SecretTextOptionalType::EnvironmentVariable => "EnvironmentVariable", + SecretTextOptionalType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SecretTextOptionalType::None), + 1 => Some(SecretTextOptionalType::Text), + 2 => Some(SecretTextOptionalType::EnvironmentVariable), + 3 => Some(SecretTextOptionalType::File), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for SecretTextOptionalType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SecretTextOptionalType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SecretTextType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Text" => SecretTextType::Text, + b"EnvironmentVariable" => SecretTextType::EnvironmentVariable, + b"File" => SecretTextType::File, + } + } + + fn as_str(&self) -> &'static str { + match self { + SecretTextType::Text => "Text", + SecretTextType::EnvironmentVariable => "EnvironmentVariable", + SecretTextType::File => "File", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SecretTextType::Text), + 1 => Some(SecretTextType::EnvironmentVariable), + 2 => Some(SecretTextType::File), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SecretTextType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SecretTextType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for ServiceProtocol { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"jmap" => ServiceProtocol::Jmap, + b"imap" => ServiceProtocol::Imap, + b"pop3" => ServiceProtocol::Pop3, + b"smtp" => ServiceProtocol::Smtp, + b"caldav" => ServiceProtocol::Caldav, + b"carddav" => ServiceProtocol::Carddav, + b"webdav" => ServiceProtocol::Webdav, + b"managesieve" => ServiceProtocol::Managesieve, + } + } + + fn as_str(&self) -> &'static str { + match self { + ServiceProtocol::Jmap => "jmap", + ServiceProtocol::Imap => "imap", + ServiceProtocol::Pop3 => "pop3", + ServiceProtocol::Smtp => "smtp", + ServiceProtocol::Caldav => "caldav", + ServiceProtocol::Carddav => "carddav", + ServiceProtocol::Webdav => "webdav", + ServiceProtocol::Managesieve => "managesieve", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ServiceProtocol::Jmap), + 1 => Some(ServiceProtocol::Imap), + 2 => Some(ServiceProtocol::Pop3), + 3 => Some(ServiceProtocol::Smtp), + 4 => Some(ServiceProtocol::Caldav), + 5 => Some(ServiceProtocol::Carddav), + 6 => Some(ServiceProtocol::Webdav), + 7 => Some(ServiceProtocol::Managesieve), + _ => None, + } + } + + const COUNT: usize = 8; +} + +impl serde::Serialize for ServiceProtocol { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ServiceProtocol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SieveCapability { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"envelope" => SieveCapability::Envelope, + b"envelope-dsn" => SieveCapability::EnvelopeDsn, + b"envelope-deliverby" => SieveCapability::EnvelopeDeliverby, + b"fileinto" => SieveCapability::Fileinto, + b"encoded-character" => SieveCapability::EncodedCharacter, + b"comparator-elbonia" => SieveCapability::ComparatorElbonia, + b"comparator-i;octet" => SieveCapability::ComparatorIOctet, + b"comparator-i;ascii-casemap" => SieveCapability::ComparatorIAsciiCasemap, + b"comparator-i;ascii-numeric" => SieveCapability::ComparatorIAsciiNumeric, + b"body" => SieveCapability::Body, + b"convert" => SieveCapability::Convert, + b"copy" => SieveCapability::Copy, + b"relational" => SieveCapability::Relational, + b"date" => SieveCapability::Date, + b"index" => SieveCapability::Index, + b"duplicate" => SieveCapability::Duplicate, + b"variables" => SieveCapability::Variables, + b"editheader" => SieveCapability::Editheader, + b"foreverypart" => SieveCapability::Foreverypart, + b"mime" => SieveCapability::Mime, + b"replace" => SieveCapability::Replace, + b"enclose" => SieveCapability::Enclose, + b"extracttext" => SieveCapability::Extracttext, + b"enotify" => SieveCapability::Enotify, + b"redirect-dsn" => SieveCapability::RedirectDsn, + b"redirect-deliverby" => SieveCapability::RedirectDeliverby, + b"environment" => SieveCapability::Environment, + b"reject" => SieveCapability::Reject, + b"ereject" => SieveCapability::Ereject, + b"extlists" => SieveCapability::Extlists, + b"subaddress" => SieveCapability::Subaddress, + b"vacation" => SieveCapability::Vacation, + b"vacation-seconds" => SieveCapability::VacationSeconds, + b"fcc" => SieveCapability::Fcc, + b"mailbox" => SieveCapability::Mailbox, + b"mailboxid" => SieveCapability::Mailboxid, + b"mboxmetadata" => SieveCapability::Mboxmetadata, + b"servermetadata" => SieveCapability::Servermetadata, + b"special-use" => SieveCapability::SpecialUse, + b"imap4flags" => SieveCapability::Imap4flags, + b"ihave" => SieveCapability::Ihave, + b"imapsieve" => SieveCapability::Imapsieve, + b"include" => SieveCapability::Include, + b"regex" => SieveCapability::Regex, + b"spamtest" => SieveCapability::Spamtest, + b"spamtestplus" => SieveCapability::Spamtestplus, + b"virustest" => SieveCapability::Virustest, + b"vnd.stalwart.while" => SieveCapability::VndStalwartWhile, + b"vnd.stalwart.expressions" => SieveCapability::VndStalwartExpressions, + } + } + + fn as_str(&self) -> &'static str { + match self { + SieveCapability::Envelope => "envelope", + SieveCapability::EnvelopeDsn => "envelope-dsn", + SieveCapability::EnvelopeDeliverby => "envelope-deliverby", + SieveCapability::Fileinto => "fileinto", + SieveCapability::EncodedCharacter => "encoded-character", + SieveCapability::ComparatorElbonia => "comparator-elbonia", + SieveCapability::ComparatorIOctet => "comparator-i;octet", + SieveCapability::ComparatorIAsciiCasemap => "comparator-i;ascii-casemap", + SieveCapability::ComparatorIAsciiNumeric => "comparator-i;ascii-numeric", + SieveCapability::Body => "body", + SieveCapability::Convert => "convert", + SieveCapability::Copy => "copy", + SieveCapability::Relational => "relational", + SieveCapability::Date => "date", + SieveCapability::Index => "index", + SieveCapability::Duplicate => "duplicate", + SieveCapability::Variables => "variables", + SieveCapability::Editheader => "editheader", + SieveCapability::Foreverypart => "foreverypart", + SieveCapability::Mime => "mime", + SieveCapability::Replace => "replace", + SieveCapability::Enclose => "enclose", + SieveCapability::Extracttext => "extracttext", + SieveCapability::Enotify => "enotify", + SieveCapability::RedirectDsn => "redirect-dsn", + SieveCapability::RedirectDeliverby => "redirect-deliverby", + SieveCapability::Environment => "environment", + SieveCapability::Reject => "reject", + SieveCapability::Ereject => "ereject", + SieveCapability::Extlists => "extlists", + SieveCapability::Subaddress => "subaddress", + SieveCapability::Vacation => "vacation", + SieveCapability::VacationSeconds => "vacation-seconds", + SieveCapability::Fcc => "fcc", + SieveCapability::Mailbox => "mailbox", + SieveCapability::Mailboxid => "mailboxid", + SieveCapability::Mboxmetadata => "mboxmetadata", + SieveCapability::Servermetadata => "servermetadata", + SieveCapability::SpecialUse => "special-use", + SieveCapability::Imap4flags => "imap4flags", + SieveCapability::Ihave => "ihave", + SieveCapability::Imapsieve => "imapsieve", + SieveCapability::Include => "include", + SieveCapability::Regex => "regex", + SieveCapability::Spamtest => "spamtest", + SieveCapability::Spamtestplus => "spamtestplus", + SieveCapability::Virustest => "virustest", + SieveCapability::VndStalwartWhile => "vnd.stalwart.while", + SieveCapability::VndStalwartExpressions => "vnd.stalwart.expressions", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SieveCapability::Envelope), + 1 => Some(SieveCapability::EnvelopeDsn), + 2 => Some(SieveCapability::EnvelopeDeliverby), + 3 => Some(SieveCapability::Fileinto), + 4 => Some(SieveCapability::EncodedCharacter), + 5 => Some(SieveCapability::ComparatorElbonia), + 6 => Some(SieveCapability::ComparatorIOctet), + 7 => Some(SieveCapability::ComparatorIAsciiCasemap), + 8 => Some(SieveCapability::ComparatorIAsciiNumeric), + 9 => Some(SieveCapability::Body), + 10 => Some(SieveCapability::Convert), + 11 => Some(SieveCapability::Copy), + 12 => Some(SieveCapability::Relational), + 13 => Some(SieveCapability::Date), + 14 => Some(SieveCapability::Index), + 15 => Some(SieveCapability::Duplicate), + 16 => Some(SieveCapability::Variables), + 17 => Some(SieveCapability::Editheader), + 18 => Some(SieveCapability::Foreverypart), + 19 => Some(SieveCapability::Mime), + 20 => Some(SieveCapability::Replace), + 21 => Some(SieveCapability::Enclose), + 22 => Some(SieveCapability::Extracttext), + 23 => Some(SieveCapability::Enotify), + 24 => Some(SieveCapability::RedirectDsn), + 25 => Some(SieveCapability::RedirectDeliverby), + 26 => Some(SieveCapability::Environment), + 27 => Some(SieveCapability::Reject), + 28 => Some(SieveCapability::Ereject), + 29 => Some(SieveCapability::Extlists), + 30 => Some(SieveCapability::Subaddress), + 31 => Some(SieveCapability::Vacation), + 32 => Some(SieveCapability::VacationSeconds), + 33 => Some(SieveCapability::Fcc), + 34 => Some(SieveCapability::Mailbox), + 35 => Some(SieveCapability::Mailboxid), + 36 => Some(SieveCapability::Mboxmetadata), + 37 => Some(SieveCapability::Servermetadata), + 38 => Some(SieveCapability::SpecialUse), + 39 => Some(SieveCapability::Imap4flags), + 40 => Some(SieveCapability::Ihave), + 41 => Some(SieveCapability::Imapsieve), + 42 => Some(SieveCapability::Include), + 43 => Some(SieveCapability::Regex), + 44 => Some(SieveCapability::Spamtest), + 45 => Some(SieveCapability::Spamtestplus), + 46 => Some(SieveCapability::Virustest), + 47 => Some(SieveCapability::VndStalwartWhile), + 48 => Some(SieveCapability::VndStalwartExpressions), + _ => None, + } + } + + const COUNT: usize = 49; +} + +impl serde::Serialize for SieveCapability { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SieveCapability { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for Sig0Algorithm { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"ecdsa-p256-sha256" => Sig0Algorithm::EcdsaP256Sha256, + b"ecdsa-p384-sha384" => Sig0Algorithm::EcdsaP384Sha384, + b"ed25519" => Sig0Algorithm::Ed25519, + } + } + + fn as_str(&self) -> &'static str { + match self { + Sig0Algorithm::EcdsaP256Sha256 => "ecdsa-p256-sha256", + Sig0Algorithm::EcdsaP384Sha384 => "ecdsa-p384-sha384", + Sig0Algorithm::Ed25519 => "ed25519", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(Sig0Algorithm::EcdsaP256Sha256), + 1 => Some(Sig0Algorithm::EcdsaP384Sha384), + 2 => Some(Sig0Algorithm::Ed25519), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for Sig0Algorithm { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Sig0Algorithm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamClassifierModelType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"FtrlFh" => SpamClassifierModelType::FtrlFh, + b"FtrlCcfh" => SpamClassifierModelType::FtrlCcfh, + b"Disabled" => SpamClassifierModelType::Disabled, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamClassifierModelType::FtrlFh => "FtrlFh", + SpamClassifierModelType::FtrlCcfh => "FtrlCcfh", + SpamClassifierModelType::Disabled => "Disabled", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamClassifierModelType::FtrlFh), + 1 => Some(SpamClassifierModelType::FtrlCcfh), + 2 => Some(SpamClassifierModelType::Disabled), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SpamClassifierModelType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamClassifierModelType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamClassifyParameters { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"bit7" => SpamClassifyParameters::Bit7, + b"bit8Mime - 8-bit MIME message content" => SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent, + b"binaryMime" => SpamClassifyParameters::BinaryMime, + b"smtpUtf8" => SpamClassifyParameters::SmtpUtf8, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamClassifyParameters::Bit7 => "bit7", + SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent => { + "bit8Mime - 8-bit MIME message content" + } + SpamClassifyParameters::BinaryMime => "binaryMime", + SpamClassifyParameters::SmtpUtf8 => "smtpUtf8", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamClassifyParameters::Bit7), + 1 => Some(SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent), + 2 => Some(SpamClassifyParameters::BinaryMime), + 3 => Some(SpamClassifyParameters::SmtpUtf8), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for SpamClassifyParameters { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamClassifyParameters { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamClassifyResult { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"spam" => SpamClassifyResult::Spam, + b"ham" => SpamClassifyResult::Ham, + b"reject" => SpamClassifyResult::Reject, + b"discard" => SpamClassifyResult::Discard, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamClassifyResult::Spam => "spam", + SpamClassifyResult::Ham => "ham", + SpamClassifyResult::Reject => "reject", + SpamClassifyResult::Discard => "discard", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamClassifyResult::Spam), + 1 => Some(SpamClassifyResult::Ham), + 2 => Some(SpamClassifyResult::Reject), + 3 => Some(SpamClassifyResult::Discard), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for SpamClassifyResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamClassifyResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamClassifyTagDisposition { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"score" => SpamClassifyTagDisposition::Score, + b"reject" => SpamClassifyTagDisposition::Reject, + b"discard" => SpamClassifyTagDisposition::Discard, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamClassifyTagDisposition::Score => "score", + SpamClassifyTagDisposition::Reject => "reject", + SpamClassifyTagDisposition::Discard => "discard", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamClassifyTagDisposition::Score), + 1 => Some(SpamClassifyTagDisposition::Reject), + 2 => Some(SpamClassifyTagDisposition::Discard), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SpamClassifyTagDisposition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamClassifyTagDisposition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamDnsblServerType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Any" => SpamDnsblServerType::Any, + b"Url" => SpamDnsblServerType::Url, + b"Domain" => SpamDnsblServerType::Domain, + b"Email" => SpamDnsblServerType::Email, + b"Ip" => SpamDnsblServerType::Ip, + b"Header" => SpamDnsblServerType::Header, + b"Body" => SpamDnsblServerType::Body, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamDnsblServerType::Any => "Any", + SpamDnsblServerType::Url => "Url", + SpamDnsblServerType::Domain => "Domain", + SpamDnsblServerType::Email => "Email", + SpamDnsblServerType::Ip => "Ip", + SpamDnsblServerType::Header => "Header", + SpamDnsblServerType::Body => "Body", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamDnsblServerType::Any), + 1 => Some(SpamDnsblServerType::Url), + 2 => Some(SpamDnsblServerType::Domain), + 3 => Some(SpamDnsblServerType::Email), + 4 => Some(SpamDnsblServerType::Ip), + 5 => Some(SpamDnsblServerType::Header), + 6 => Some(SpamDnsblServerType::Body), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for SpamDnsblServerType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamDnsblServerType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamLlmType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disable" => SpamLlmType::Disable, + b"Enable" => SpamLlmType::Enable, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamLlmType::Disable => "Disable", + SpamLlmType::Enable => "Enable", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamLlmType::Disable), + 1 => Some(SpamLlmType::Enable), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for SpamLlmType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamLlmType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamRuleType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Any" => SpamRuleType::Any, + b"Url" => SpamRuleType::Url, + b"Domain" => SpamRuleType::Domain, + b"Email" => SpamRuleType::Email, + b"Ip" => SpamRuleType::Ip, + b"Header" => SpamRuleType::Header, + b"Body" => SpamRuleType::Body, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamRuleType::Any => "Any", + SpamRuleType::Url => "Url", + SpamRuleType::Domain => "Domain", + SpamRuleType::Email => "Email", + SpamRuleType::Ip => "Ip", + SpamRuleType::Header => "Header", + SpamRuleType::Body => "Body", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamRuleType::Any), + 1 => Some(SpamRuleType::Url), + 2 => Some(SpamRuleType::Domain), + 3 => Some(SpamRuleType::Email), + 4 => Some(SpamRuleType::Ip), + 5 => Some(SpamRuleType::Header), + 6 => Some(SpamRuleType::Body), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for SpamRuleType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamRuleType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpamTagType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Score" => SpamTagType::Score, + b"Discard" => SpamTagType::Discard, + b"Reject" => SpamTagType::Reject, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpamTagType::Score => "Score", + SpamTagType::Discard => "Discard", + SpamTagType::Reject => "Reject", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpamTagType::Score), + 1 => Some(SpamTagType::Discard), + 2 => Some(SpamTagType::Reject), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SpamTagType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpamTagType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpecialUse { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"inbox" => SpecialUse::Inbox, + b"trash" => SpecialUse::Trash, + b"junk" => SpecialUse::Junk, + b"drafts" => SpecialUse::Drafts, + b"archive" => SpecialUse::Archive, + b"sent" => SpecialUse::Sent, + b"shared" => SpecialUse::Shared, + b"important" => SpecialUse::Important, + b"memos" => SpecialUse::Memos, + b"scheduled" => SpecialUse::Scheduled, + b"snoozed" => SpecialUse::Snoozed, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpecialUse::Inbox => "inbox", + SpecialUse::Trash => "trash", + SpecialUse::Junk => "junk", + SpecialUse::Drafts => "drafts", + SpecialUse::Archive => "archive", + SpecialUse::Sent => "sent", + SpecialUse::Shared => "shared", + SpecialUse::Important => "important", + SpecialUse::Memos => "memos", + SpecialUse::Scheduled => "scheduled", + SpecialUse::Snoozed => "snoozed", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpecialUse::Inbox), + 1 => Some(SpecialUse::Trash), + 2 => Some(SpecialUse::Junk), + 3 => Some(SpecialUse::Drafts), + 4 => Some(SpecialUse::Archive), + 5 => Some(SpecialUse::Sent), + 6 => Some(SpecialUse::Shared), + 7 => Some(SpecialUse::Important), + 8 => Some(SpecialUse::Memos), + 9 => Some(SpecialUse::Scheduled), + 10 => Some(SpecialUse::Snoozed), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for SpecialUse { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpecialUse { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpfAuthResult { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"none" => SpfAuthResult::None, + b"neutral" => SpfAuthResult::Neutral, + b"pass" => SpfAuthResult::Pass, + b"fail" => SpfAuthResult::Fail, + b"softFail" => SpfAuthResult::SoftFail, + b"tempError" => SpfAuthResult::TempError, + b"permError" => SpfAuthResult::PermError, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpfAuthResult::None => "none", + SpfAuthResult::Neutral => "neutral", + SpfAuthResult::Pass => "pass", + SpfAuthResult::Fail => "fail", + SpfAuthResult::SoftFail => "softFail", + SpfAuthResult::TempError => "tempError", + SpfAuthResult::PermError => "permError", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpfAuthResult::None), + 1 => Some(SpfAuthResult::Neutral), + 2 => Some(SpfAuthResult::Pass), + 3 => Some(SpfAuthResult::Fail), + 4 => Some(SpfAuthResult::SoftFail), + 5 => Some(SpfAuthResult::TempError), + 6 => Some(SpfAuthResult::PermError), + _ => None, + } + } + + const COUNT: usize = 7; +} + +impl serde::Serialize for SpfAuthResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpfAuthResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SpfDomainScope { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"helo" => SpfDomainScope::Helo, + b"mailFrom" => SpfDomainScope::MailFrom, + b"unspecified" => SpfDomainScope::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + SpfDomainScope::Helo => "helo", + SpfDomainScope::MailFrom => "mailFrom", + SpfDomainScope::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SpfDomainScope::Helo), + 1 => Some(SpfDomainScope::MailFrom), + 2 => Some(SpfDomainScope::Unspecified), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SpfDomainScope { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SpfDomainScope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SqlAuthStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Default" => SqlAuthStoreType::Default, + b"PostgreSql" => SqlAuthStoreType::PostgreSql, + b"MySql" => SqlAuthStoreType::MySql, + b"Sqlite" => SqlAuthStoreType::Sqlite, + } + } + + fn as_str(&self) -> &'static str { + match self { + SqlAuthStoreType::Default => "Default", + SqlAuthStoreType::PostgreSql => "PostgreSql", + SqlAuthStoreType::MySql => "MySql", + SqlAuthStoreType::Sqlite => "Sqlite", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SqlAuthStoreType::Default), + 1 => Some(SqlAuthStoreType::PostgreSql), + 2 => Some(SqlAuthStoreType::MySql), + 3 => Some(SqlAuthStoreType::Sqlite), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for SqlAuthStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SqlAuthStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for StorageQuota { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"maxEmails" => StorageQuota::MaxEmails, + b"maxMailboxes" => StorageQuota::MaxMailboxes, + b"maxEmailSubmissions" => StorageQuota::MaxEmailSubmissions, + b"maxEmailIdentities" => StorageQuota::MaxEmailIdentities, + b"maxParticipantIdentities" => StorageQuota::MaxParticipantIdentities, + b"maxSieveScripts" => StorageQuota::MaxSieveScripts, + b"maxPushSubscriptions" => StorageQuota::MaxPushSubscriptions, + b"maxCalendars" => StorageQuota::MaxCalendars, + b"maxCalendarEvents" => StorageQuota::MaxCalendarEvents, + b"maxCalendarEventNotifications" => StorageQuota::MaxCalendarEventNotifications, + b"maxAddressBooks" => StorageQuota::MaxAddressBooks, + b"maxContactCards" => StorageQuota::MaxContactCards, + b"maxFiles" => StorageQuota::MaxFiles, + b"maxFolders" => StorageQuota::MaxFolders, + b"maxMaskedAddresses" => StorageQuota::MaxMaskedAddresses, + b"maxAppPasswords" => StorageQuota::MaxAppPasswords, + b"maxApiKeys" => StorageQuota::MaxApiKeys, + b"maxPublicKeys" => StorageQuota::MaxPublicKeys, + b"maxDiskQuota" => StorageQuota::MaxDiskQuota, + } + } + + fn as_str(&self) -> &'static str { + match self { + StorageQuota::MaxEmails => "maxEmails", + StorageQuota::MaxMailboxes => "maxMailboxes", + StorageQuota::MaxEmailSubmissions => "maxEmailSubmissions", + StorageQuota::MaxEmailIdentities => "maxEmailIdentities", + StorageQuota::MaxParticipantIdentities => "maxParticipantIdentities", + StorageQuota::MaxSieveScripts => "maxSieveScripts", + StorageQuota::MaxPushSubscriptions => "maxPushSubscriptions", + StorageQuota::MaxCalendars => "maxCalendars", + StorageQuota::MaxCalendarEvents => "maxCalendarEvents", + StorageQuota::MaxCalendarEventNotifications => "maxCalendarEventNotifications", + StorageQuota::MaxAddressBooks => "maxAddressBooks", + StorageQuota::MaxContactCards => "maxContactCards", + StorageQuota::MaxFiles => "maxFiles", + StorageQuota::MaxFolders => "maxFolders", + StorageQuota::MaxMaskedAddresses => "maxMaskedAddresses", + StorageQuota::MaxAppPasswords => "maxAppPasswords", + StorageQuota::MaxApiKeys => "maxApiKeys", + StorageQuota::MaxPublicKeys => "maxPublicKeys", + StorageQuota::MaxDiskQuota => "maxDiskQuota", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(StorageQuota::MaxEmails), + 1 => Some(StorageQuota::MaxMailboxes), + 2 => Some(StorageQuota::MaxEmailSubmissions), + 3 => Some(StorageQuota::MaxEmailIdentities), + 4 => Some(StorageQuota::MaxParticipantIdentities), + 5 => Some(StorageQuota::MaxSieveScripts), + 6 => Some(StorageQuota::MaxPushSubscriptions), + 7 => Some(StorageQuota::MaxCalendars), + 8 => Some(StorageQuota::MaxCalendarEvents), + 9 => Some(StorageQuota::MaxCalendarEventNotifications), + 10 => Some(StorageQuota::MaxAddressBooks), + 11 => Some(StorageQuota::MaxContactCards), + 12 => Some(StorageQuota::MaxFiles), + 13 => Some(StorageQuota::MaxFolders), + 14 => Some(StorageQuota::MaxMaskedAddresses), + 15 => Some(StorageQuota::MaxAppPasswords), + 16 => Some(StorageQuota::MaxApiKeys), + 17 => Some(StorageQuota::MaxPublicKeys), + 18 => Some(StorageQuota::MaxDiskQuota), + _ => None, + } + } + + const COUNT: usize = 19; +} + +impl serde::Serialize for StorageQuota { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for StorageQuota { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for SubAddressingType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Enabled" => SubAddressingType::Enabled, + b"Custom" => SubAddressingType::Custom, + b"Disabled" => SubAddressingType::Disabled, + } + } + + fn as_str(&self) -> &'static str { + match self { + SubAddressingType::Enabled => "Enabled", + SubAddressingType::Custom => "Custom", + SubAddressingType::Disabled => "Disabled", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(SubAddressingType::Enabled), + 1 => Some(SubAddressingType::Custom), + 2 => Some(SubAddressingType::Disabled), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for SubAddressingType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SubAddressingType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskAccountMaintenanceType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"purge" => TaskAccountMaintenanceType::Purge, + b"reindex" => TaskAccountMaintenanceType::Reindex, + b"recalculateImapUid" => TaskAccountMaintenanceType::RecalculateImapUid, + b"recalculateQuota" => TaskAccountMaintenanceType::RecalculateQuota, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskAccountMaintenanceType::Purge => "purge", + TaskAccountMaintenanceType::Reindex => "reindex", + TaskAccountMaintenanceType::RecalculateImapUid => "recalculateImapUid", + TaskAccountMaintenanceType::RecalculateQuota => "recalculateQuota", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskAccountMaintenanceType::Purge), + 1 => Some(TaskAccountMaintenanceType::Reindex), + 2 => Some(TaskAccountMaintenanceType::RecalculateImapUid), + 3 => Some(TaskAccountMaintenanceType::RecalculateQuota), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for TaskAccountMaintenanceType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskAccountMaintenanceType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskRetryStrategyType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"ExponentialBackoff" => TaskRetryStrategyType::ExponentialBackoff, + b"FixedDelay" => TaskRetryStrategyType::FixedDelay, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskRetryStrategyType::ExponentialBackoff => "ExponentialBackoff", + TaskRetryStrategyType::FixedDelay => "FixedDelay", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskRetryStrategyType::ExponentialBackoff), + 1 => Some(TaskRetryStrategyType::FixedDelay), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for TaskRetryStrategyType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskRetryStrategyType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskSpamFilterMaintenanceType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"train" => TaskSpamFilterMaintenanceType::Train, + b"retrain" => TaskSpamFilterMaintenanceType::Retrain, + b"abort" => TaskSpamFilterMaintenanceType::Abort, + b"reset" => TaskSpamFilterMaintenanceType::Reset, + b"updateRules" => TaskSpamFilterMaintenanceType::UpdateRules, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskSpamFilterMaintenanceType::Train => "train", + TaskSpamFilterMaintenanceType::Retrain => "retrain", + TaskSpamFilterMaintenanceType::Abort => "abort", + TaskSpamFilterMaintenanceType::Reset => "reset", + TaskSpamFilterMaintenanceType::UpdateRules => "updateRules", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskSpamFilterMaintenanceType::Train), + 1 => Some(TaskSpamFilterMaintenanceType::Retrain), + 2 => Some(TaskSpamFilterMaintenanceType::Abort), + 3 => Some(TaskSpamFilterMaintenanceType::Reset), + 4 => Some(TaskSpamFilterMaintenanceType::UpdateRules), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for TaskSpamFilterMaintenanceType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskSpamFilterMaintenanceType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskStatusType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Pending" => TaskStatusType::Pending, + b"Retry" => TaskStatusType::Retry, + b"Failed" => TaskStatusType::Failed, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskStatusType::Pending => "Pending", + TaskStatusType::Retry => "Retry", + TaskStatusType::Failed => "Failed", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskStatusType::Pending), + 1 => Some(TaskStatusType::Retry), + 2 => Some(TaskStatusType::Failed), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for TaskStatusType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskStatusType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskStoreMaintenanceType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"reindexAccounts" => TaskStoreMaintenanceType::ReindexAccounts, + b"reindexTelemetry" => TaskStoreMaintenanceType::ReindexTelemetry, + b"purgeAccounts" => TaskStoreMaintenanceType::PurgeAccounts, + b"purgeData" => TaskStoreMaintenanceType::PurgeData, + b"purgeBlob" => TaskStoreMaintenanceType::PurgeBlob, + b"resetRateLimiters" => TaskStoreMaintenanceType::ResetRateLimiters, + b"resetUserQuotas" => TaskStoreMaintenanceType::ResetUserQuotas, + b"resetTenantQuotas" => TaskStoreMaintenanceType::ResetTenantQuotas, + b"resetBlobQuotas" => TaskStoreMaintenanceType::ResetBlobQuotas, + b"removeAuthTokens" => TaskStoreMaintenanceType::RemoveAuthTokens, + b"removeLockQueueMessage" => TaskStoreMaintenanceType::RemoveLockQueueMessage, + b"removeLockTask" => TaskStoreMaintenanceType::RemoveLockTask, + b"removeLockDav" => TaskStoreMaintenanceType::RemoveLockDav, + b"removeSieveId" => TaskStoreMaintenanceType::RemoveSieveId, + b"removeGreylist" => TaskStoreMaintenanceType::RemoveGreylist, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskStoreMaintenanceType::ReindexAccounts => "reindexAccounts", + TaskStoreMaintenanceType::ReindexTelemetry => "reindexTelemetry", + TaskStoreMaintenanceType::PurgeAccounts => "purgeAccounts", + TaskStoreMaintenanceType::PurgeData => "purgeData", + TaskStoreMaintenanceType::PurgeBlob => "purgeBlob", + TaskStoreMaintenanceType::ResetRateLimiters => "resetRateLimiters", + TaskStoreMaintenanceType::ResetUserQuotas => "resetUserQuotas", + TaskStoreMaintenanceType::ResetTenantQuotas => "resetTenantQuotas", + TaskStoreMaintenanceType::ResetBlobQuotas => "resetBlobQuotas", + TaskStoreMaintenanceType::RemoveAuthTokens => "removeAuthTokens", + TaskStoreMaintenanceType::RemoveLockQueueMessage => "removeLockQueueMessage", + TaskStoreMaintenanceType::RemoveLockTask => "removeLockTask", + TaskStoreMaintenanceType::RemoveLockDav => "removeLockDav", + TaskStoreMaintenanceType::RemoveSieveId => "removeSieveId", + TaskStoreMaintenanceType::RemoveGreylist => "removeGreylist", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskStoreMaintenanceType::ReindexAccounts), + 1 => Some(TaskStoreMaintenanceType::ReindexTelemetry), + 2 => Some(TaskStoreMaintenanceType::PurgeAccounts), + 3 => Some(TaskStoreMaintenanceType::PurgeData), + 4 => Some(TaskStoreMaintenanceType::PurgeBlob), + 5 => Some(TaskStoreMaintenanceType::ResetRateLimiters), + 6 => Some(TaskStoreMaintenanceType::ResetUserQuotas), + 7 => Some(TaskStoreMaintenanceType::ResetTenantQuotas), + 8 => Some(TaskStoreMaintenanceType::ResetBlobQuotas), + 9 => Some(TaskStoreMaintenanceType::RemoveAuthTokens), + 10 => Some(TaskStoreMaintenanceType::RemoveLockQueueMessage), + 11 => Some(TaskStoreMaintenanceType::RemoveLockTask), + 12 => Some(TaskStoreMaintenanceType::RemoveLockDav), + 13 => Some(TaskStoreMaintenanceType::RemoveSieveId), + 14 => Some(TaskStoreMaintenanceType::RemoveGreylist), + _ => None, + } + } + + const COUNT: usize = 15; +} + +impl serde::Serialize for TaskStoreMaintenanceType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskStoreMaintenanceType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskTenantMaintenanceType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"recalculateQuota" => TaskTenantMaintenanceType::RecalculateQuota, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskTenantMaintenanceType::RecalculateQuota => "recalculateQuota", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskTenantMaintenanceType::RecalculateQuota), + _ => None, + } + } + + const COUNT: usize = 1; +} + +impl serde::Serialize for TaskTenantMaintenanceType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskTenantMaintenanceType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TaskType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"IndexDocument" => TaskType::IndexDocument, + b"UnindexDocument" => TaskType::UnindexDocument, + b"IndexTrace" => TaskType::IndexTrace, + b"CalendarAlarmEmail" => TaskType::CalendarAlarmEmail, + b"CalendarAlarmNotification" => TaskType::CalendarAlarmNotification, + b"CalendarItipMessage" => TaskType::CalendarItipMessage, + b"MergeThreads" => TaskType::MergeThreads, + b"DmarcReport" => TaskType::DmarcReport, + b"TlsReport" => TaskType::TlsReport, + b"RestoreArchivedItem" => TaskType::RestoreArchivedItem, + b"DestroyAccount" => TaskType::DestroyAccount, + b"AccountMaintenance" => TaskType::AccountMaintenance, + b"TenantMaintenance" => TaskType::TenantMaintenance, + b"StoreMaintenance" => TaskType::StoreMaintenance, + b"SpamFilterMaintenance" => TaskType::SpamFilterMaintenance, + b"AcmeRenewal" => TaskType::AcmeRenewal, + b"DkimManagement" => TaskType::DkimManagement, + b"DnsManagement" => TaskType::DnsManagement, + } + } + + fn as_str(&self) -> &'static str { + match self { + TaskType::IndexDocument => "IndexDocument", + TaskType::UnindexDocument => "UnindexDocument", + TaskType::IndexTrace => "IndexTrace", + TaskType::CalendarAlarmEmail => "CalendarAlarmEmail", + TaskType::CalendarAlarmNotification => "CalendarAlarmNotification", + TaskType::CalendarItipMessage => "CalendarItipMessage", + TaskType::MergeThreads => "MergeThreads", + TaskType::DmarcReport => "DmarcReport", + TaskType::TlsReport => "TlsReport", + TaskType::RestoreArchivedItem => "RestoreArchivedItem", + TaskType::DestroyAccount => "DestroyAccount", + TaskType::AccountMaintenance => "AccountMaintenance", + TaskType::TenantMaintenance => "TenantMaintenance", + TaskType::StoreMaintenance => "StoreMaintenance", + TaskType::SpamFilterMaintenance => "SpamFilterMaintenance", + TaskType::AcmeRenewal => "AcmeRenewal", + TaskType::DkimManagement => "DkimManagement", + TaskType::DnsManagement => "DnsManagement", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TaskType::IndexDocument), + 1 => Some(TaskType::UnindexDocument), + 2 => Some(TaskType::IndexTrace), + 3 => Some(TaskType::CalendarAlarmEmail), + 4 => Some(TaskType::CalendarAlarmNotification), + 5 => Some(TaskType::CalendarItipMessage), + 6 => Some(TaskType::MergeThreads), + 7 => Some(TaskType::DmarcReport), + 8 => Some(TaskType::TlsReport), + 9 => Some(TaskType::RestoreArchivedItem), + 10 => Some(TaskType::DestroyAccount), + 11 => Some(TaskType::AccountMaintenance), + 12 => Some(TaskType::TenantMaintenance), + 13 => Some(TaskType::StoreMaintenance), + 14 => Some(TaskType::SpamFilterMaintenance), + 15 => Some(TaskType::AcmeRenewal), + 16 => Some(TaskType::DkimManagement), + 17 => Some(TaskType::DnsManagement), + _ => None, + } + } + + const COUNT: usize = 18; +} + +impl serde::Serialize for TaskType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TaskType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TenantStorageQuota { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"maxAccounts" => TenantStorageQuota::MaxAccounts, + b"maxGroups" => TenantStorageQuota::MaxGroups, + b"maxDomains" => TenantStorageQuota::MaxDomains, + b"maxMailingLists" => TenantStorageQuota::MaxMailingLists, + b"maxRoles" => TenantStorageQuota::MaxRoles, + b"maxOauthClients" => TenantStorageQuota::MaxOauthClients, + b"maxDkimKeys" => TenantStorageQuota::MaxDkimKeys, + b"maxDnsServers" => TenantStorageQuota::MaxDnsServers, + b"maxDirectories" => TenantStorageQuota::MaxDirectories, + b"maxAcmeProviders" => TenantStorageQuota::MaxAcmeProviders, + b"maxDiskQuota" => TenantStorageQuota::MaxDiskQuota, + } + } + + fn as_str(&self) -> &'static str { + match self { + TenantStorageQuota::MaxAccounts => "maxAccounts", + TenantStorageQuota::MaxGroups => "maxGroups", + TenantStorageQuota::MaxDomains => "maxDomains", + TenantStorageQuota::MaxMailingLists => "maxMailingLists", + TenantStorageQuota::MaxRoles => "maxRoles", + TenantStorageQuota::MaxOauthClients => "maxOauthClients", + TenantStorageQuota::MaxDkimKeys => "maxDkimKeys", + TenantStorageQuota::MaxDnsServers => "maxDnsServers", + TenantStorageQuota::MaxDirectories => "maxDirectories", + TenantStorageQuota::MaxAcmeProviders => "maxAcmeProviders", + TenantStorageQuota::MaxDiskQuota => "maxDiskQuota", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TenantStorageQuota::MaxAccounts), + 1 => Some(TenantStorageQuota::MaxGroups), + 2 => Some(TenantStorageQuota::MaxDomains), + 3 => Some(TenantStorageQuota::MaxMailingLists), + 4 => Some(TenantStorageQuota::MaxRoles), + 5 => Some(TenantStorageQuota::MaxOauthClients), + 6 => Some(TenantStorageQuota::MaxDkimKeys), + 7 => Some(TenantStorageQuota::MaxDnsServers), + 8 => Some(TenantStorageQuota::MaxDirectories), + 9 => Some(TenantStorageQuota::MaxAcmeProviders), + 10 => Some(TenantStorageQuota::MaxDiskQuota), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for TenantStorageQuota { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TenantStorageQuota { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TimeZone { + fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + TimeZone, + b"Africa/Abidjan" => TimeZone::AfricaAbidjan, + b"Africa/Accra" => TimeZone::AfricaAccra, + b"Africa/Addis_Ababa" => TimeZone::AfricaAddisAbaba, + b"Africa/Algiers" => TimeZone::AfricaAlgiers, + b"Africa/Asmara" => TimeZone::AfricaAsmara, + b"Africa/Asmera" => TimeZone::AfricaAsmera, + b"Africa/Bamako" => TimeZone::AfricaBamako, + b"Africa/Bangui" => TimeZone::AfricaBangui, + b"Africa/Banjul" => TimeZone::AfricaBanjul, + b"Africa/Bissau" => TimeZone::AfricaBissau, + b"Africa/Blantyre" => TimeZone::AfricaBlantyre, + b"Africa/Brazzaville" => TimeZone::AfricaBrazzaville, + b"Africa/Bujumbura" => TimeZone::AfricaBujumbura, + b"Africa/Cairo" => TimeZone::AfricaCairo, + b"Africa/Casablanca" => TimeZone::AfricaCasablanca, + b"Africa/Ceuta" => TimeZone::AfricaCeuta, + b"Africa/Conakry" => TimeZone::AfricaConakry, + b"Africa/Dakar" => TimeZone::AfricaDakar, + b"Africa/Dar_es_Salaam" => TimeZone::AfricaDarEsSalaam, + b"Africa/Djibouti" => TimeZone::AfricaDjibouti, + b"Africa/Douala" => TimeZone::AfricaDouala, + b"Africa/El_Aaiun" => TimeZone::AfricaElAaiun, + b"Africa/Freetown" => TimeZone::AfricaFreetown, + b"Africa/Gaborone" => TimeZone::AfricaGaborone, + b"Africa/Harare" => TimeZone::AfricaHarare, + b"Africa/Johannesburg" => TimeZone::AfricaJohannesburg, + b"Africa/Juba" => TimeZone::AfricaJuba, + b"Africa/Kampala" => TimeZone::AfricaKampala, + b"Africa/Khartoum" => TimeZone::AfricaKhartoum, + b"Africa/Kigali" => TimeZone::AfricaKigali, + b"Africa/Kinshasa" => TimeZone::AfricaKinshasa, + b"Africa/Lagos" => TimeZone::AfricaLagos, + b"Africa/Libreville" => TimeZone::AfricaLibreville, + b"Africa/Lome" => TimeZone::AfricaLome, + b"Africa/Luanda" => TimeZone::AfricaLuanda, + b"Africa/Lubumbashi" => TimeZone::AfricaLubumbashi, + b"Africa/Lusaka" => TimeZone::AfricaLusaka, + b"Africa/Malabo" => TimeZone::AfricaMalabo, + b"Africa/Maputo" => TimeZone::AfricaMaputo, + b"Africa/Maseru" => TimeZone::AfricaMaseru, + b"Africa/Mbabane" => TimeZone::AfricaMbabane, + b"Africa/Mogadishu" => TimeZone::AfricaMogadishu, + b"Africa/Monrovia" => TimeZone::AfricaMonrovia, + b"Africa/Nairobi" => TimeZone::AfricaNairobi, + b"Africa/Ndjamena" => TimeZone::AfricaNdjamena, + b"Africa/Niamey" => TimeZone::AfricaNiamey, + b"Africa/Nouakchott" => TimeZone::AfricaNouakchott, + b"Africa/Ouagadougou" => TimeZone::AfricaOuagadougou, + b"Africa/Porto-Novo" => TimeZone::AfricaPortoNovo, + b"Africa/Sao_Tome" => TimeZone::AfricaSaoTome, + b"Africa/Timbuktu" => TimeZone::AfricaTimbuktu, + b"Africa/Tripoli" => TimeZone::AfricaTripoli, + b"Africa/Tunis" => TimeZone::AfricaTunis, + b"Africa/Windhoek" => TimeZone::AfricaWindhoek, + b"America/Adak" => TimeZone::AmericaAdak, + b"America/Anchorage" => TimeZone::AmericaAnchorage, + b"America/Anguilla" => TimeZone::AmericaAnguilla, + b"America/Antigua" => TimeZone::AmericaAntigua, + b"America/Araguaina" => TimeZone::AmericaAraguaina, + b"America/Argentina/Buenos_Aires" => TimeZone::AmericaArgentinaBuenosAires, + b"America/Argentina/Catamarca" => TimeZone::AmericaArgentinaCatamarca, + b"America/Argentina/ComodRivadavia" => TimeZone::AmericaArgentinaComodRivadavia, + b"America/Argentina/Cordoba" => TimeZone::AmericaArgentinaCordoba, + b"America/Argentina/Jujuy" => TimeZone::AmericaArgentinaJujuy, + b"America/Argentina/La_Rioja" => TimeZone::AmericaArgentinaLaRioja, + b"America/Argentina/Mendoza" => TimeZone::AmericaArgentinaMendoza, + b"America/Argentina/Rio_Gallegos" => TimeZone::AmericaArgentinaRioGallegos, + b"America/Argentina/Salta" => TimeZone::AmericaArgentinaSalta, + b"America/Argentina/San_Juan" => TimeZone::AmericaArgentinaSanJuan, + b"America/Argentina/San_Luis" => TimeZone::AmericaArgentinaSanLuis, + b"America/Argentina/Tucuman" => TimeZone::AmericaArgentinaTucuman, + b"America/Argentina/Ushuaia" => TimeZone::AmericaArgentinaUshuaia, + b"America/Aruba" => TimeZone::AmericaAruba, + b"America/Asuncion" => TimeZone::AmericaAsuncion, + b"America/Atikokan" => TimeZone::AmericaAtikokan, + b"America/Atka" => TimeZone::AmericaAtka, + b"America/Bahia" => TimeZone::AmericaBahia, + b"America/Bahia_Banderas" => TimeZone::AmericaBahiaBanderas, + b"America/Barbados" => TimeZone::AmericaBarbados, + b"America/Belem" => TimeZone::AmericaBelem, + b"America/Belize" => TimeZone::AmericaBelize, + b"America/Blanc-Sablon" => TimeZone::AmericaBlancSablon, + b"America/Boa_Vista" => TimeZone::AmericaBoaVista, + b"America/Bogota" => TimeZone::AmericaBogota, + b"America/Boise" => TimeZone::AmericaBoise, + b"America/Buenos_Aires" => TimeZone::AmericaBuenosAires, + b"America/Cambridge_Bay" => TimeZone::AmericaCambridgeBay, + b"America/Campo_Grande" => TimeZone::AmericaCampoGrande, + b"America/Cancun" => TimeZone::AmericaCancun, + b"America/Caracas" => TimeZone::AmericaCaracas, + b"America/Catamarca" => TimeZone::AmericaCatamarca, + b"America/Cayenne" => TimeZone::AmericaCayenne, + b"America/Cayman" => TimeZone::AmericaCayman, + b"America/Chicago" => TimeZone::AmericaChicago, + b"America/Chihuahua" => TimeZone::AmericaChihuahua, + b"America/Ciudad_Juarez" => TimeZone::AmericaCiudadJuarez, + b"America/Coral_Harbour" => TimeZone::AmericaCoralHarbour, + b"America/Cordoba" => TimeZone::AmericaCordoba, + b"America/Costa_Rica" => TimeZone::AmericaCostaRica, + b"America/Coyhaique" => TimeZone::AmericaCoyhaique, + b"America/Creston" => TimeZone::AmericaCreston, + b"America/Cuiaba" => TimeZone::AmericaCuiaba, + b"America/Curacao" => TimeZone::AmericaCuracao, + b"America/Danmarkshavn" => TimeZone::AmericaDanmarkshavn, + b"America/Dawson" => TimeZone::AmericaDawson, + b"America/Dawson_Creek" => TimeZone::AmericaDawsonCreek, + b"America/Denver" => TimeZone::AmericaDenver, + b"America/Detroit" => TimeZone::AmericaDetroit, + b"America/Dominica" => TimeZone::AmericaDominica, + b"America/Edmonton" => TimeZone::AmericaEdmonton, + b"America/Eirunepe" => TimeZone::AmericaEirunepe, + b"America/El_Salvador" => TimeZone::AmericaElSalvador, + b"America/Ensenada" => TimeZone::AmericaEnsenada, + b"America/Fort_Nelson" => TimeZone::AmericaFortNelson, + b"America/Fort_Wayne" => TimeZone::AmericaFortWayne, + b"America/Fortaleza" => TimeZone::AmericaFortaleza, + b"America/Glace_Bay" => TimeZone::AmericaGlaceBay, + b"America/Godthab" => TimeZone::AmericaGodthab, + b"America/Goose_Bay" => TimeZone::AmericaGooseBay, + b"America/Grand_Turk" => TimeZone::AmericaGrandTurk, + b"America/Grenada" => TimeZone::AmericaGrenada, + b"America/Guadeloupe" => TimeZone::AmericaGuadeloupe, + b"America/Guatemala" => TimeZone::AmericaGuatemala, + b"America/Guayaquil" => TimeZone::AmericaGuayaquil, + b"America/Guyana" => TimeZone::AmericaGuyana, + b"America/Halifax" => TimeZone::AmericaHalifax, + b"America/Havana" => TimeZone::AmericaHavana, + b"America/Hermosillo" => TimeZone::AmericaHermosillo, + b"America/Indiana/Indianapolis" => TimeZone::AmericaIndianaIndianapolis, + b"America/Indiana/Knox" => TimeZone::AmericaIndianaKnox, + b"America/Indiana/Marengo" => TimeZone::AmericaIndianaMarengo, + b"America/Indiana/Petersburg" => TimeZone::AmericaIndianaPetersburg, + b"America/Indiana/Tell_City" => TimeZone::AmericaIndianaTellCity, + b"America/Indiana/Vevay" => TimeZone::AmericaIndianaVevay, + b"America/Indiana/Vincennes" => TimeZone::AmericaIndianaVincennes, + b"America/Indiana/Winamac" => TimeZone::AmericaIndianaWinamac, + b"America/Indianapolis" => TimeZone::AmericaIndianapolis, + b"America/Inuvik" => TimeZone::AmericaInuvik, + b"America/Iqaluit" => TimeZone::AmericaIqaluit, + b"America/Jamaica" => TimeZone::AmericaJamaica, + b"America/Jujuy" => TimeZone::AmericaJujuy, + b"America/Juneau" => TimeZone::AmericaJuneau, + b"America/Kentucky/Louisville" => TimeZone::AmericaKentuckyLouisville, + b"America/Kentucky/Monticello" => TimeZone::AmericaKentuckyMonticello, + b"America/Knox_IN" => TimeZone::AmericaKnoxIN, + b"America/Kralendijk" => TimeZone::AmericaKralendijk, + b"America/La_Paz" => TimeZone::AmericaLaPaz, + b"America/Lima" => TimeZone::AmericaLima, + b"America/Los_Angeles" => TimeZone::AmericaLosAngeles, + b"America/Louisville" => TimeZone::AmericaLouisville, + b"America/Lower_Princes" => TimeZone::AmericaLowerPrinces, + b"America/Maceio" => TimeZone::AmericaMaceio, + b"America/Managua" => TimeZone::AmericaManagua, + b"America/Manaus" => TimeZone::AmericaManaus, + b"America/Marigot" => TimeZone::AmericaMarigot, + b"America/Martinique" => TimeZone::AmericaMartinique, + b"America/Matamoros" => TimeZone::AmericaMatamoros, + b"America/Mazatlan" => TimeZone::AmericaMazatlan, + b"America/Mendoza" => TimeZone::AmericaMendoza, + b"America/Menominee" => TimeZone::AmericaMenominee, + b"America/Merida" => TimeZone::AmericaMerida, + b"America/Metlakatla" => TimeZone::AmericaMetlakatla, + b"America/Mexico_City" => TimeZone::AmericaMexicoCity, + b"America/Miquelon" => TimeZone::AmericaMiquelon, + b"America/Moncton" => TimeZone::AmericaMoncton, + b"America/Monterrey" => TimeZone::AmericaMonterrey, + b"America/Montevideo" => TimeZone::AmericaMontevideo, + b"America/Montreal" => TimeZone::AmericaMontreal, + b"America/Montserrat" => TimeZone::AmericaMontserrat, + b"America/Nassau" => TimeZone::AmericaNassau, + b"America/New_York" => TimeZone::AmericaNewYork, + b"America/Nipigon" => TimeZone::AmericaNipigon, + b"America/Nome" => TimeZone::AmericaNome, + b"America/Noronha" => TimeZone::AmericaNoronha, + b"America/North_Dakota/Beulah" => TimeZone::AmericaNorthDakotaBeulah, + b"America/North_Dakota/Center" => TimeZone::AmericaNorthDakotaCenter, + b"America/North_Dakota/New_Salem" => TimeZone::AmericaNorthDakotaNewSalem, + b"America/Nuuk" => TimeZone::AmericaNuuk, + b"America/Ojinaga" => TimeZone::AmericaOjinaga, + b"America/Panama" => TimeZone::AmericaPanama, + b"America/Pangnirtung" => TimeZone::AmericaPangnirtung, + b"America/Paramaribo" => TimeZone::AmericaParamaribo, + b"America/Phoenix" => TimeZone::AmericaPhoenix, + b"America/Port-au-Prince" => TimeZone::AmericaPortAuPrince, + b"America/Port_of_Spain" => TimeZone::AmericaPortOfSpain, + b"America/Porto_Acre" => TimeZone::AmericaPortoAcre, + b"America/Porto_Velho" => TimeZone::AmericaPortoVelho, + b"America/Puerto_Rico" => TimeZone::AmericaPuertoRico, + b"America/Punta_Arenas" => TimeZone::AmericaPuntaArenas, + b"America/Rainy_River" => TimeZone::AmericaRainyRiver, + b"America/Rankin_Inlet" => TimeZone::AmericaRankinInlet, + b"America/Recife" => TimeZone::AmericaRecife, + b"America/Regina" => TimeZone::AmericaRegina, + b"America/Resolute" => TimeZone::AmericaResolute, + b"America/Rio_Branco" => TimeZone::AmericaRioBranco, + b"America/Rosario" => TimeZone::AmericaRosario, + b"America/Santa_Isabel" => TimeZone::AmericaSantaIsabel, + b"America/Santarem" => TimeZone::AmericaSantarem, + b"America/Santiago" => TimeZone::AmericaSantiago, + b"America/Santo_Domingo" => TimeZone::AmericaSantoDomingo, + b"America/Sao_Paulo" => TimeZone::AmericaSaoPaulo, + b"America/Scoresbysund" => TimeZone::AmericaScoresbysund, + b"America/Shiprock" => TimeZone::AmericaShiprock, + b"America/Sitka" => TimeZone::AmericaSitka, + b"America/St_Barthelemy" => TimeZone::AmericaStBarthelemy, + b"America/St_Johns" => TimeZone::AmericaStJohns, + b"America/St_Kitts" => TimeZone::AmericaStKitts, + b"America/St_Lucia" => TimeZone::AmericaStLucia, + b"America/St_Thomas" => TimeZone::AmericaStThomas, + b"America/St_Vincent" => TimeZone::AmericaStVincent, + b"America/Swift_Current" => TimeZone::AmericaSwiftCurrent, + b"America/Tegucigalpa" => TimeZone::AmericaTegucigalpa, + b"America/Thule" => TimeZone::AmericaThule, + b"America/Thunder_Bay" => TimeZone::AmericaThunderBay, + b"America/Tijuana" => TimeZone::AmericaTijuana, + b"America/Toronto" => TimeZone::AmericaToronto, + b"America/Tortola" => TimeZone::AmericaTortola, + b"America/Vancouver" => TimeZone::AmericaVancouver, + b"America/Virgin" => TimeZone::AmericaVirgin, + b"America/Whitehorse" => TimeZone::AmericaWhitehorse, + b"America/Winnipeg" => TimeZone::AmericaWinnipeg, + b"America/Yakutat" => TimeZone::AmericaYakutat, + b"America/Yellowknife" => TimeZone::AmericaYellowknife, + b"Antarctica/Casey" => TimeZone::AntarcticaCasey, + b"Antarctica/Davis" => TimeZone::AntarcticaDavis, + b"Antarctica/DumontDUrville" => TimeZone::AntarcticaDumontDUrville, + b"Antarctica/Macquarie" => TimeZone::AntarcticaMacquarie, + b"Antarctica/Mawson" => TimeZone::AntarcticaMawson, + b"Antarctica/McMurdo" => TimeZone::AntarcticaMcMurdo, + b"Antarctica/Palmer" => TimeZone::AntarcticaPalmer, + b"Antarctica/Rothera" => TimeZone::AntarcticaRothera, + b"Antarctica/South_Pole" => TimeZone::AntarcticaSouthPole, + b"Antarctica/Syowa" => TimeZone::AntarcticaSyowa, + b"Antarctica/Troll" => TimeZone::AntarcticaTroll, + b"Antarctica/Vostok" => TimeZone::AntarcticaVostok, + b"Arctic/Longyearbyen" => TimeZone::ArcticLongyearbyen, + b"Asia/Aden" => TimeZone::AsiaAden, + b"Asia/Almaty" => TimeZone::AsiaAlmaty, + b"Asia/Amman" => TimeZone::AsiaAmman, + b"Asia/Anadyr" => TimeZone::AsiaAnadyr, + b"Asia/Aqtau" => TimeZone::AsiaAqtau, + b"Asia/Aqtobe" => TimeZone::AsiaAqtobe, + b"Asia/Ashgabat" => TimeZone::AsiaAshgabat, + b"Asia/Ashkhabad" => TimeZone::AsiaAshkhabad, + b"Asia/Atyrau" => TimeZone::AsiaAtyrau, + b"Asia/Baghdad" => TimeZone::AsiaBaghdad, + b"Asia/Bahrain" => TimeZone::AsiaBahrain, + b"Asia/Baku" => TimeZone::AsiaBaku, + b"Asia/Bangkok" => TimeZone::AsiaBangkok, + b"Asia/Barnaul" => TimeZone::AsiaBarnaul, + b"Asia/Beirut" => TimeZone::AsiaBeirut, + b"Asia/Bishkek" => TimeZone::AsiaBishkek, + b"Asia/Brunei" => TimeZone::AsiaBrunei, + b"Asia/Calcutta" => TimeZone::AsiaCalcutta, + b"Asia/Chita" => TimeZone::AsiaChita, + b"Asia/Choibalsan" => TimeZone::AsiaChoibalsan, + b"Asia/Chongqing" => TimeZone::AsiaChongqing, + b"Asia/Chungking" => TimeZone::AsiaChungking, + b"Asia/Colombo" => TimeZone::AsiaColombo, + b"Asia/Dacca" => TimeZone::AsiaDacca, + b"Asia/Damascus" => TimeZone::AsiaDamascus, + b"Asia/Dhaka" => TimeZone::AsiaDhaka, + b"Asia/Dili" => TimeZone::AsiaDili, + b"Asia/Dubai" => TimeZone::AsiaDubai, + b"Asia/Dushanbe" => TimeZone::AsiaDushanbe, + b"Asia/Famagusta" => TimeZone::AsiaFamagusta, + b"Asia/Gaza" => TimeZone::AsiaGaza, + b"Asia/Harbin" => TimeZone::AsiaHarbin, + b"Asia/Hebron" => TimeZone::AsiaHebron, + b"Asia/Ho_Chi_Minh" => TimeZone::AsiaHoChiMinh, + b"Asia/Hong_Kong" => TimeZone::AsiaHongKong, + b"Asia/Hovd" => TimeZone::AsiaHovd, + b"Asia/Irkutsk" => TimeZone::AsiaIrkutsk, + b"Asia/Istanbul" => TimeZone::AsiaIstanbul, + b"Asia/Jakarta" => TimeZone::AsiaJakarta, + b"Asia/Jayapura" => TimeZone::AsiaJayapura, + b"Asia/Jerusalem" => TimeZone::AsiaJerusalem, + b"Asia/Kabul" => TimeZone::AsiaKabul, + b"Asia/Kamchatka" => TimeZone::AsiaKamchatka, + b"Asia/Karachi" => TimeZone::AsiaKarachi, + b"Asia/Kashgar" => TimeZone::AsiaKashgar, + b"Asia/Kathmandu" => TimeZone::AsiaKathmandu, + b"Asia/Katmandu" => TimeZone::AsiaKatmandu, + b"Asia/Khandyga" => TimeZone::AsiaKhandyga, + b"Asia/Kolkata" => TimeZone::AsiaKolkata, + b"Asia/Krasnoyarsk" => TimeZone::AsiaKrasnoyarsk, + b"Asia/Kuala_Lumpur" => TimeZone::AsiaKualaLumpur, + b"Asia/Kuching" => TimeZone::AsiaKuching, + b"Asia/Kuwait" => TimeZone::AsiaKuwait, + b"Asia/Macao" => TimeZone::AsiaMacao, + b"Asia/Macau" => TimeZone::AsiaMacau, + b"Asia/Magadan" => TimeZone::AsiaMagadan, + b"Asia/Makassar" => TimeZone::AsiaMakassar, + b"Asia/Manila" => TimeZone::AsiaManila, + b"Asia/Muscat" => TimeZone::AsiaMuscat, + b"Asia/Nicosia" => TimeZone::AsiaNicosia, + b"Asia/Novokuznetsk" => TimeZone::AsiaNovokuznetsk, + b"Asia/Novosibirsk" => TimeZone::AsiaNovosibirsk, + b"Asia/Omsk" => TimeZone::AsiaOmsk, + b"Asia/Oral" => TimeZone::AsiaOral, + b"Asia/Phnom_Penh" => TimeZone::AsiaPhnomPenh, + b"Asia/Pontianak" => TimeZone::AsiaPontianak, + b"Asia/Pyongyang" => TimeZone::AsiaPyongyang, + b"Asia/Qatar" => TimeZone::AsiaQatar, + b"Asia/Qostanay" => TimeZone::AsiaQostanay, + b"Asia/Qyzylorda" => TimeZone::AsiaQyzylorda, + b"Asia/Rangoon" => TimeZone::AsiaRangoon, + b"Asia/Riyadh" => TimeZone::AsiaRiyadh, + b"Asia/Saigon" => TimeZone::AsiaSaigon, + b"Asia/Sakhalin" => TimeZone::AsiaSakhalin, + b"Asia/Samarkand" => TimeZone::AsiaSamarkand, + b"Asia/Seoul" => TimeZone::AsiaSeoul, + b"Asia/Shanghai" => TimeZone::AsiaShanghai, + b"Asia/Singapore" => TimeZone::AsiaSingapore, + b"Asia/Srednekolymsk" => TimeZone::AsiaSrednekolymsk, + b"Asia/Taipei" => TimeZone::AsiaTaipei, + b"Asia/Tashkent" => TimeZone::AsiaTashkent, + b"Asia/Tbilisi" => TimeZone::AsiaTbilisi, + b"Asia/Tehran" => TimeZone::AsiaTehran, + b"Asia/Tel_Aviv" => TimeZone::AsiaTelAviv, + b"Asia/Thimbu" => TimeZone::AsiaThimbu, + b"Asia/Thimphu" => TimeZone::AsiaThimphu, + b"Asia/Tokyo" => TimeZone::AsiaTokyo, + b"Asia/Tomsk" => TimeZone::AsiaTomsk, + b"Asia/Ujung_Pandang" => TimeZone::AsiaUjungPandang, + b"Asia/Ulaanbaatar" => TimeZone::AsiaUlaanbaatar, + b"Asia/Ulan_Bator" => TimeZone::AsiaUlanBator, + b"Asia/Urumqi" => TimeZone::AsiaUrumqi, + b"Asia/Ust-Nera" => TimeZone::AsiaUstNera, + b"Asia/Vientiane" => TimeZone::AsiaVientiane, + b"Asia/Vladivostok" => TimeZone::AsiaVladivostok, + b"Asia/Yakutsk" => TimeZone::AsiaYakutsk, + b"Asia/Yangon" => TimeZone::AsiaYangon, + b"Asia/Yekaterinburg" => TimeZone::AsiaYekaterinburg, + b"Asia/Yerevan" => TimeZone::AsiaYerevan, + b"Atlantic/Azores" => TimeZone::AtlanticAzores, + b"Atlantic/Bermuda" => TimeZone::AtlanticBermuda, + b"Atlantic/Canary" => TimeZone::AtlanticCanary, + b"Atlantic/Cape_Verde" => TimeZone::AtlanticCapeVerde, + b"Atlantic/Faeroe" => TimeZone::AtlanticFaeroe, + b"Atlantic/Faroe" => TimeZone::AtlanticFaroe, + b"Atlantic/Jan_Mayen" => TimeZone::AtlanticJanMayen, + b"Atlantic/Madeira" => TimeZone::AtlanticMadeira, + b"Atlantic/Reykjavik" => TimeZone::AtlanticReykjavik, + b"Atlantic/South_Georgia" => TimeZone::AtlanticSouthGeorgia, + b"Atlantic/St_Helena" => TimeZone::AtlanticStHelena, + b"Atlantic/Stanley" => TimeZone::AtlanticStanley, + b"Australia/ACT" => TimeZone::AustraliaACT, + b"Australia/Adelaide" => TimeZone::AustraliaAdelaide, + b"Australia/Brisbane" => TimeZone::AustraliaBrisbane, + b"Australia/Broken_Hill" => TimeZone::AustraliaBrokenHill, + b"Australia/Canberra" => TimeZone::AustraliaCanberra, + b"Australia/Currie" => TimeZone::AustraliaCurrie, + b"Australia/Darwin" => TimeZone::AustraliaDarwin, + b"Australia/Eucla" => TimeZone::AustraliaEucla, + b"Australia/Hobart" => TimeZone::AustraliaHobart, + b"Australia/LHI" => TimeZone::AustraliaLHI, + b"Australia/Lindeman" => TimeZone::AustraliaLindeman, + b"Australia/Lord_Howe" => TimeZone::AustraliaLordHowe, + b"Australia/Melbourne" => TimeZone::AustraliaMelbourne, + b"Australia/NSW" => TimeZone::AustraliaNSW, + b"Australia/North" => TimeZone::AustraliaNorth, + b"Australia/Perth" => TimeZone::AustraliaPerth, + b"Australia/Queensland" => TimeZone::AustraliaQueensland, + b"Australia/South" => TimeZone::AustraliaSouth, + b"Australia/Sydney" => TimeZone::AustraliaSydney, + b"Australia/Tasmania" => TimeZone::AustraliaTasmania, + b"Australia/Victoria" => TimeZone::AustraliaVictoria, + b"Australia/West" => TimeZone::AustraliaWest, + b"Australia/Yancowinna" => TimeZone::AustraliaYancowinna, + b"Brazil/Acre" => TimeZone::BrazilAcre, + b"Brazil/DeNoronha" => TimeZone::BrazilDeNoronha, + b"Brazil/East" => TimeZone::BrazilEast, + b"Brazil/West" => TimeZone::BrazilWest, + b"CET" => TimeZone::CET, + b"CST6CDT" => TimeZone::CST6CDT, + b"Canada/Atlantic" => TimeZone::CanadaAtlantic, + b"Canada/Central" => TimeZone::CanadaCentral, + b"Canada/Eastern" => TimeZone::CanadaEastern, + b"Canada/Mountain" => TimeZone::CanadaMountain, + b"Canada/Newfoundland" => TimeZone::CanadaNewfoundland, + b"Canada/Pacific" => TimeZone::CanadaPacific, + b"Canada/Saskatchewan" => TimeZone::CanadaSaskatchewan, + b"Canada/Yukon" => TimeZone::CanadaYukon, + b"Chile/Continental" => TimeZone::ChileContinental, + b"Chile/EasterIsland" => TimeZone::ChileEasterIsland, + b"Cuba" => TimeZone::Cuba, + b"EET" => TimeZone::EET, + b"EST" => TimeZone::EST, + b"EST5EDT" => TimeZone::EST5EDT, + b"Egypt" => TimeZone::Egypt, + b"Eire" => TimeZone::Eire, + b"Etc/GMT" => TimeZone::EtcGMT, + b"Etc/GMT+0" => TimeZone::EtcGMTPlus0, + b"Etc/GMT+1" => TimeZone::EtcGMTPlus1, + b"Etc/GMT+10" => TimeZone::EtcGMTPlus10, + b"Etc/GMT+11" => TimeZone::EtcGMTPlus11, + b"Etc/GMT+12" => TimeZone::EtcGMTPlus12, + b"Etc/GMT+2" => TimeZone::EtcGMTPlus2, + b"Etc/GMT+3" => TimeZone::EtcGMTPlus3, + b"Etc/GMT+4" => TimeZone::EtcGMTPlus4, + b"Etc/GMT+5" => TimeZone::EtcGMTPlus5, + b"Etc/GMT+6" => TimeZone::EtcGMTPlus6, + b"Etc/GMT+7" => TimeZone::EtcGMTPlus7, + b"Etc/GMT+8" => TimeZone::EtcGMTPlus8, + b"Etc/GMT+9" => TimeZone::EtcGMTPlus9, + b"Etc/GMT-0" => TimeZone::EtcGMTMinus0, + b"Etc/GMT-1" => TimeZone::EtcGMTMinus1, + b"Etc/GMT-10" => TimeZone::EtcGMTMinus10, + b"Etc/GMT-11" => TimeZone::EtcGMTMinus11, + b"Etc/GMT-12" => TimeZone::EtcGMTMinus12, + b"Etc/GMT-13" => TimeZone::EtcGMTMinus13, + b"Etc/GMT-14" => TimeZone::EtcGMTMinus14, + b"Etc/GMT-2" => TimeZone::EtcGMTMinus2, + b"Etc/GMT-3" => TimeZone::EtcGMTMinus3, + b"Etc/GMT-4" => TimeZone::EtcGMTMinus4, + b"Etc/GMT-5" => TimeZone::EtcGMTMinus5, + b"Etc/GMT-6" => TimeZone::EtcGMTMinus6, + b"Etc/GMT-7" => TimeZone::EtcGMTMinus7, + b"Etc/GMT-8" => TimeZone::EtcGMTMinus8, + b"Etc/GMT-9" => TimeZone::EtcGMTMinus9, + b"Etc/GMT0" => TimeZone::EtcGMT0, + b"Etc/Greenwich" => TimeZone::EtcGreenwich, + b"Etc/UCT" => TimeZone::EtcUCT, + b"Etc/UTC" => TimeZone::EtcUTC, + b"Etc/Universal" => TimeZone::EtcUniversal, + b"Etc/Zulu" => TimeZone::EtcZulu, + b"Europe/Amsterdam" => TimeZone::EuropeAmsterdam, + b"Europe/Andorra" => TimeZone::EuropeAndorra, + b"Europe/Astrakhan" => TimeZone::EuropeAstrakhan, + b"Europe/Athens" => TimeZone::EuropeAthens, + b"Europe/Belfast" => TimeZone::EuropeBelfast, + b"Europe/Belgrade" => TimeZone::EuropeBelgrade, + b"Europe/Berlin" => TimeZone::EuropeBerlin, + b"Europe/Bratislava" => TimeZone::EuropeBratislava, + b"Europe/Brussels" => TimeZone::EuropeBrussels, + b"Europe/Bucharest" => TimeZone::EuropeBucharest, + b"Europe/Budapest" => TimeZone::EuropeBudapest, + b"Europe/Busingen" => TimeZone::EuropeBusingen, + b"Europe/Chisinau" => TimeZone::EuropeChisinau, + b"Europe/Copenhagen" => TimeZone::EuropeCopenhagen, + b"Europe/Dublin" => TimeZone::EuropeDublin, + b"Europe/Gibraltar" => TimeZone::EuropeGibraltar, + b"Europe/Guernsey" => TimeZone::EuropeGuernsey, + b"Europe/Helsinki" => TimeZone::EuropeHelsinki, + b"Europe/Isle_of_Man" => TimeZone::EuropeIsleOfMan, + b"Europe/Istanbul" => TimeZone::EuropeIstanbul, + b"Europe/Jersey" => TimeZone::EuropeJersey, + b"Europe/Kaliningrad" => TimeZone::EuropeKaliningrad, + b"Europe/Kiev" => TimeZone::EuropeKiev, + b"Europe/Kirov" => TimeZone::EuropeKirov, + b"Europe/Kyiv" => TimeZone::EuropeKyiv, + b"Europe/Lisbon" => TimeZone::EuropeLisbon, + b"Europe/Ljubljana" => TimeZone::EuropeLjubljana, + b"Europe/London" => TimeZone::EuropeLondon, + b"Europe/Luxembourg" => TimeZone::EuropeLuxembourg, + b"Europe/Madrid" => TimeZone::EuropeMadrid, + b"Europe/Malta" => TimeZone::EuropeMalta, + b"Europe/Mariehamn" => TimeZone::EuropeMariehamn, + b"Europe/Minsk" => TimeZone::EuropeMinsk, + b"Europe/Monaco" => TimeZone::EuropeMonaco, + b"Europe/Moscow" => TimeZone::EuropeMoscow, + b"Europe/Nicosia" => TimeZone::EuropeNicosia, + b"Europe/Oslo" => TimeZone::EuropeOslo, + b"Europe/Paris" => TimeZone::EuropeParis, + b"Europe/Podgorica" => TimeZone::EuropePodgorica, + b"Europe/Prague" => TimeZone::EuropePrague, + b"Europe/Riga" => TimeZone::EuropeRiga, + b"Europe/Rome" => TimeZone::EuropeRome, + b"Europe/Samara" => TimeZone::EuropeSamara, + b"Europe/San_Marino" => TimeZone::EuropeSanMarino, + b"Europe/Sarajevo" => TimeZone::EuropeSarajevo, + b"Europe/Saratov" => TimeZone::EuropeSaratov, + b"Europe/Simferopol" => TimeZone::EuropeSimferopol, + b"Europe/Skopje" => TimeZone::EuropeSkopje, + b"Europe/Sofia" => TimeZone::EuropeSofia, + b"Europe/Stockholm" => TimeZone::EuropeStockholm, + b"Europe/Tallinn" => TimeZone::EuropeTallinn, + b"Europe/Tirane" => TimeZone::EuropeTirane, + b"Europe/Tiraspol" => TimeZone::EuropeTiraspol, + b"Europe/Ulyanovsk" => TimeZone::EuropeUlyanovsk, + b"Europe/Uzhgorod" => TimeZone::EuropeUzhgorod, + b"Europe/Vaduz" => TimeZone::EuropeVaduz, + b"Europe/Vatican" => TimeZone::EuropeVatican, + b"Europe/Vienna" => TimeZone::EuropeVienna, + b"Europe/Vilnius" => TimeZone::EuropeVilnius, + b"Europe/Volgograd" => TimeZone::EuropeVolgograd, + b"Europe/Warsaw" => TimeZone::EuropeWarsaw, + b"Europe/Zagreb" => TimeZone::EuropeZagreb, + b"Europe/Zaporozhye" => TimeZone::EuropeZaporozhye, + b"Europe/Zurich" => TimeZone::EuropeZurich, + b"Factory" => TimeZone::Factory, + b"GB" => TimeZone::GB, + b"GB-Eire" => TimeZone::GBEire, + b"GMT" => TimeZone::GMT, + b"GMT+0" => TimeZone::GMTPlus0, + b"GMT-0" => TimeZone::GMTMinus0, + b"GMT0" => TimeZone::GMT0, + b"Greenwich" => TimeZone::Greenwich, + b"HST" => TimeZone::HST, + b"Hongkong" => TimeZone::Hongkong, + b"Iceland" => TimeZone::Iceland, + b"Indian/Antananarivo" => TimeZone::IndianAntananarivo, + b"Indian/Chagos" => TimeZone::IndianChagos, + b"Indian/Christmas" => TimeZone::IndianChristmas, + b"Indian/Cocos" => TimeZone::IndianCocos, + b"Indian/Comoro" => TimeZone::IndianComoro, + b"Indian/Kerguelen" => TimeZone::IndianKerguelen, + b"Indian/Mahe" => TimeZone::IndianMahe, + b"Indian/Maldives" => TimeZone::IndianMaldives, + b"Indian/Mauritius" => TimeZone::IndianMauritius, + b"Indian/Mayotte" => TimeZone::IndianMayotte, + b"Indian/Reunion" => TimeZone::IndianReunion, + b"Iran" => TimeZone::Iran, + b"Israel" => TimeZone::Israel, + b"Jamaica" => TimeZone::Jamaica, + b"Japan" => TimeZone::Japan, + b"Kwajalein" => TimeZone::Kwajalein, + b"Libya" => TimeZone::Libya, + b"MET" => TimeZone::MET, + b"MST" => TimeZone::MST, + b"MST7MDT" => TimeZone::MST7MDT, + b"Mexico/BajaNorte" => TimeZone::MexicoBajaNorte, + b"Mexico/BajaSur" => TimeZone::MexicoBajaSur, + b"Mexico/General" => TimeZone::MexicoGeneral, + b"NZ" => TimeZone::NZ, + b"NZ-CHAT" => TimeZone::NZCHAT, + b"Navajo" => TimeZone::Navajo, + b"PRC" => TimeZone::PRC, + b"PST8PDT" => TimeZone::PST8PDT, + b"Pacific/Apia" => TimeZone::PacificApia, + b"Pacific/Auckland" => TimeZone::PacificAuckland, + b"Pacific/Bougainville" => TimeZone::PacificBougainville, + b"Pacific/Chatham" => TimeZone::PacificChatham, + b"Pacific/Chuuk" => TimeZone::PacificChuuk, + b"Pacific/Easter" => TimeZone::PacificEaster, + b"Pacific/Efate" => TimeZone::PacificEfate, + b"Pacific/Enderbury" => TimeZone::PacificEnderbury, + b"Pacific/Fakaofo" => TimeZone::PacificFakaofo, + b"Pacific/Fiji" => TimeZone::PacificFiji, + b"Pacific/Funafuti" => TimeZone::PacificFunafuti, + b"Pacific/Galapagos" => TimeZone::PacificGalapagos, + b"Pacific/Gambier" => TimeZone::PacificGambier, + b"Pacific/Guadalcanal" => TimeZone::PacificGuadalcanal, + b"Pacific/Guam" => TimeZone::PacificGuam, + b"Pacific/Honolulu" => TimeZone::PacificHonolulu, + b"Pacific/Johnston" => TimeZone::PacificJohnston, + b"Pacific/Kanton" => TimeZone::PacificKanton, + b"Pacific/Kiritimati" => TimeZone::PacificKiritimati, + b"Pacific/Kosrae" => TimeZone::PacificKosrae, + b"Pacific/Kwajalein" => TimeZone::PacificKwajalein, + b"Pacific/Majuro" => TimeZone::PacificMajuro, + b"Pacific/Marquesas" => TimeZone::PacificMarquesas, + b"Pacific/Midway" => TimeZone::PacificMidway, + b"Pacific/Nauru" => TimeZone::PacificNauru, + b"Pacific/Niue" => TimeZone::PacificNiue, + b"Pacific/Norfolk" => TimeZone::PacificNorfolk, + b"Pacific/Noumea" => TimeZone::PacificNoumea, + b"Pacific/Pago_Pago" => TimeZone::PacificPagoPago, + b"Pacific/Palau" => TimeZone::PacificPalau, + b"Pacific/Pitcairn" => TimeZone::PacificPitcairn, + b"Pacific/Pohnpei" => TimeZone::PacificPohnpei, + b"Pacific/Ponape" => TimeZone::PacificPonape, + b"Pacific/Port_Moresby" => TimeZone::PacificPortMoresby, + b"Pacific/Rarotonga" => TimeZone::PacificRarotonga, + b"Pacific/Saipan" => TimeZone::PacificSaipan, + b"Pacific/Samoa" => TimeZone::PacificSamoa, + b"Pacific/Tahiti" => TimeZone::PacificTahiti, + b"Pacific/Tarawa" => TimeZone::PacificTarawa, + b"Pacific/Tongatapu" => TimeZone::PacificTongatapu, + b"Pacific/Truk" => TimeZone::PacificTruk, + b"Pacific/Wake" => TimeZone::PacificWake, + b"Pacific/Wallis" => TimeZone::PacificWallis, + b"Pacific/Yap" => TimeZone::PacificYap, + b"Poland" => TimeZone::Poland, + b"Portugal" => TimeZone::Portugal, + b"ROC" => TimeZone::ROC, + b"ROK" => TimeZone::ROK, + b"Singapore" => TimeZone::Singapore, + b"Turkey" => TimeZone::Turkey, + b"UCT" => TimeZone::UCT, + b"US/Alaska" => TimeZone::USAlaska, + b"US/Aleutian" => TimeZone::USAleutian, + b"US/Arizona" => TimeZone::USArizona, + b"US/Central" => TimeZone::USCentral, + b"US/East-Indiana" => TimeZone::USEastIndiana, + b"US/Eastern" => TimeZone::USEastern, + b"US/Hawaii" => TimeZone::USHawaii, + b"US/Indiana-Starke" => TimeZone::USIndianaStarke, + b"US/Michigan" => TimeZone::USMichigan, + b"US/Mountain" => TimeZone::USMountain, + b"US/Pacific" => TimeZone::USPacific, + b"US/Samoa" => TimeZone::USSamoa, + b"UTC" => TimeZone::UTC, + b"Universal" => TimeZone::Universal, + b"W-SU" => TimeZone::WSU, + b"WET" => TimeZone::WET, + b"Zulu" => TimeZone::Zulu, + } + .copied() + } + + fn as_str(&self) -> &'static str { + match self { + TimeZone::AfricaAbidjan => "Africa/Abidjan", + TimeZone::AfricaAccra => "Africa/Accra", + TimeZone::AfricaAddisAbaba => "Africa/Addis_Ababa", + TimeZone::AfricaAlgiers => "Africa/Algiers", + TimeZone::AfricaAsmara => "Africa/Asmara", + TimeZone::AfricaAsmera => "Africa/Asmera", + TimeZone::AfricaBamako => "Africa/Bamako", + TimeZone::AfricaBangui => "Africa/Bangui", + TimeZone::AfricaBanjul => "Africa/Banjul", + TimeZone::AfricaBissau => "Africa/Bissau", + TimeZone::AfricaBlantyre => "Africa/Blantyre", + TimeZone::AfricaBrazzaville => "Africa/Brazzaville", + TimeZone::AfricaBujumbura => "Africa/Bujumbura", + TimeZone::AfricaCairo => "Africa/Cairo", + TimeZone::AfricaCasablanca => "Africa/Casablanca", + TimeZone::AfricaCeuta => "Africa/Ceuta", + TimeZone::AfricaConakry => "Africa/Conakry", + TimeZone::AfricaDakar => "Africa/Dakar", + TimeZone::AfricaDarEsSalaam => "Africa/Dar_es_Salaam", + TimeZone::AfricaDjibouti => "Africa/Djibouti", + TimeZone::AfricaDouala => "Africa/Douala", + TimeZone::AfricaElAaiun => "Africa/El_Aaiun", + TimeZone::AfricaFreetown => "Africa/Freetown", + TimeZone::AfricaGaborone => "Africa/Gaborone", + TimeZone::AfricaHarare => "Africa/Harare", + TimeZone::AfricaJohannesburg => "Africa/Johannesburg", + TimeZone::AfricaJuba => "Africa/Juba", + TimeZone::AfricaKampala => "Africa/Kampala", + TimeZone::AfricaKhartoum => "Africa/Khartoum", + TimeZone::AfricaKigali => "Africa/Kigali", + TimeZone::AfricaKinshasa => "Africa/Kinshasa", + TimeZone::AfricaLagos => "Africa/Lagos", + TimeZone::AfricaLibreville => "Africa/Libreville", + TimeZone::AfricaLome => "Africa/Lome", + TimeZone::AfricaLuanda => "Africa/Luanda", + TimeZone::AfricaLubumbashi => "Africa/Lubumbashi", + TimeZone::AfricaLusaka => "Africa/Lusaka", + TimeZone::AfricaMalabo => "Africa/Malabo", + TimeZone::AfricaMaputo => "Africa/Maputo", + TimeZone::AfricaMaseru => "Africa/Maseru", + TimeZone::AfricaMbabane => "Africa/Mbabane", + TimeZone::AfricaMogadishu => "Africa/Mogadishu", + TimeZone::AfricaMonrovia => "Africa/Monrovia", + TimeZone::AfricaNairobi => "Africa/Nairobi", + TimeZone::AfricaNdjamena => "Africa/Ndjamena", + TimeZone::AfricaNiamey => "Africa/Niamey", + TimeZone::AfricaNouakchott => "Africa/Nouakchott", + TimeZone::AfricaOuagadougou => "Africa/Ouagadougou", + TimeZone::AfricaPortoNovo => "Africa/Porto-Novo", + TimeZone::AfricaSaoTome => "Africa/Sao_Tome", + TimeZone::AfricaTimbuktu => "Africa/Timbuktu", + TimeZone::AfricaTripoli => "Africa/Tripoli", + TimeZone::AfricaTunis => "Africa/Tunis", + TimeZone::AfricaWindhoek => "Africa/Windhoek", + TimeZone::AmericaAdak => "America/Adak", + TimeZone::AmericaAnchorage => "America/Anchorage", + TimeZone::AmericaAnguilla => "America/Anguilla", + TimeZone::AmericaAntigua => "America/Antigua", + TimeZone::AmericaAraguaina => "America/Araguaina", + TimeZone::AmericaArgentinaBuenosAires => "America/Argentina/Buenos_Aires", + TimeZone::AmericaArgentinaCatamarca => "America/Argentina/Catamarca", + TimeZone::AmericaArgentinaComodRivadavia => "America/Argentina/ComodRivadavia", + TimeZone::AmericaArgentinaCordoba => "America/Argentina/Cordoba", + TimeZone::AmericaArgentinaJujuy => "America/Argentina/Jujuy", + TimeZone::AmericaArgentinaLaRioja => "America/Argentina/La_Rioja", + TimeZone::AmericaArgentinaMendoza => "America/Argentina/Mendoza", + TimeZone::AmericaArgentinaRioGallegos => "America/Argentina/Rio_Gallegos", + TimeZone::AmericaArgentinaSalta => "America/Argentina/Salta", + TimeZone::AmericaArgentinaSanJuan => "America/Argentina/San_Juan", + TimeZone::AmericaArgentinaSanLuis => "America/Argentina/San_Luis", + TimeZone::AmericaArgentinaTucuman => "America/Argentina/Tucuman", + TimeZone::AmericaArgentinaUshuaia => "America/Argentina/Ushuaia", + TimeZone::AmericaAruba => "America/Aruba", + TimeZone::AmericaAsuncion => "America/Asuncion", + TimeZone::AmericaAtikokan => "America/Atikokan", + TimeZone::AmericaAtka => "America/Atka", + TimeZone::AmericaBahia => "America/Bahia", + TimeZone::AmericaBahiaBanderas => "America/Bahia_Banderas", + TimeZone::AmericaBarbados => "America/Barbados", + TimeZone::AmericaBelem => "America/Belem", + TimeZone::AmericaBelize => "America/Belize", + TimeZone::AmericaBlancSablon => "America/Blanc-Sablon", + TimeZone::AmericaBoaVista => "America/Boa_Vista", + TimeZone::AmericaBogota => "America/Bogota", + TimeZone::AmericaBoise => "America/Boise", + TimeZone::AmericaBuenosAires => "America/Buenos_Aires", + TimeZone::AmericaCambridgeBay => "America/Cambridge_Bay", + TimeZone::AmericaCampoGrande => "America/Campo_Grande", + TimeZone::AmericaCancun => "America/Cancun", + TimeZone::AmericaCaracas => "America/Caracas", + TimeZone::AmericaCatamarca => "America/Catamarca", + TimeZone::AmericaCayenne => "America/Cayenne", + TimeZone::AmericaCayman => "America/Cayman", + TimeZone::AmericaChicago => "America/Chicago", + TimeZone::AmericaChihuahua => "America/Chihuahua", + TimeZone::AmericaCiudadJuarez => "America/Ciudad_Juarez", + TimeZone::AmericaCoralHarbour => "America/Coral_Harbour", + TimeZone::AmericaCordoba => "America/Cordoba", + TimeZone::AmericaCostaRica => "America/Costa_Rica", + TimeZone::AmericaCoyhaique => "America/Coyhaique", + TimeZone::AmericaCreston => "America/Creston", + TimeZone::AmericaCuiaba => "America/Cuiaba", + TimeZone::AmericaCuracao => "America/Curacao", + TimeZone::AmericaDanmarkshavn => "America/Danmarkshavn", + TimeZone::AmericaDawson => "America/Dawson", + TimeZone::AmericaDawsonCreek => "America/Dawson_Creek", + TimeZone::AmericaDenver => "America/Denver", + TimeZone::AmericaDetroit => "America/Detroit", + TimeZone::AmericaDominica => "America/Dominica", + TimeZone::AmericaEdmonton => "America/Edmonton", + TimeZone::AmericaEirunepe => "America/Eirunepe", + TimeZone::AmericaElSalvador => "America/El_Salvador", + TimeZone::AmericaEnsenada => "America/Ensenada", + TimeZone::AmericaFortNelson => "America/Fort_Nelson", + TimeZone::AmericaFortWayne => "America/Fort_Wayne", + TimeZone::AmericaFortaleza => "America/Fortaleza", + TimeZone::AmericaGlaceBay => "America/Glace_Bay", + TimeZone::AmericaGodthab => "America/Godthab", + TimeZone::AmericaGooseBay => "America/Goose_Bay", + TimeZone::AmericaGrandTurk => "America/Grand_Turk", + TimeZone::AmericaGrenada => "America/Grenada", + TimeZone::AmericaGuadeloupe => "America/Guadeloupe", + TimeZone::AmericaGuatemala => "America/Guatemala", + TimeZone::AmericaGuayaquil => "America/Guayaquil", + TimeZone::AmericaGuyana => "America/Guyana", + TimeZone::AmericaHalifax => "America/Halifax", + TimeZone::AmericaHavana => "America/Havana", + TimeZone::AmericaHermosillo => "America/Hermosillo", + TimeZone::AmericaIndianaIndianapolis => "America/Indiana/Indianapolis", + TimeZone::AmericaIndianaKnox => "America/Indiana/Knox", + TimeZone::AmericaIndianaMarengo => "America/Indiana/Marengo", + TimeZone::AmericaIndianaPetersburg => "America/Indiana/Petersburg", + TimeZone::AmericaIndianaTellCity => "America/Indiana/Tell_City", + TimeZone::AmericaIndianaVevay => "America/Indiana/Vevay", + TimeZone::AmericaIndianaVincennes => "America/Indiana/Vincennes", + TimeZone::AmericaIndianaWinamac => "America/Indiana/Winamac", + TimeZone::AmericaIndianapolis => "America/Indianapolis", + TimeZone::AmericaInuvik => "America/Inuvik", + TimeZone::AmericaIqaluit => "America/Iqaluit", + TimeZone::AmericaJamaica => "America/Jamaica", + TimeZone::AmericaJujuy => "America/Jujuy", + TimeZone::AmericaJuneau => "America/Juneau", + TimeZone::AmericaKentuckyLouisville => "America/Kentucky/Louisville", + TimeZone::AmericaKentuckyMonticello => "America/Kentucky/Monticello", + TimeZone::AmericaKnoxIN => "America/Knox_IN", + TimeZone::AmericaKralendijk => "America/Kralendijk", + TimeZone::AmericaLaPaz => "America/La_Paz", + TimeZone::AmericaLima => "America/Lima", + TimeZone::AmericaLosAngeles => "America/Los_Angeles", + TimeZone::AmericaLouisville => "America/Louisville", + TimeZone::AmericaLowerPrinces => "America/Lower_Princes", + TimeZone::AmericaMaceio => "America/Maceio", + TimeZone::AmericaManagua => "America/Managua", + TimeZone::AmericaManaus => "America/Manaus", + TimeZone::AmericaMarigot => "America/Marigot", + TimeZone::AmericaMartinique => "America/Martinique", + TimeZone::AmericaMatamoros => "America/Matamoros", + TimeZone::AmericaMazatlan => "America/Mazatlan", + TimeZone::AmericaMendoza => "America/Mendoza", + TimeZone::AmericaMenominee => "America/Menominee", + TimeZone::AmericaMerida => "America/Merida", + TimeZone::AmericaMetlakatla => "America/Metlakatla", + TimeZone::AmericaMexicoCity => "America/Mexico_City", + TimeZone::AmericaMiquelon => "America/Miquelon", + TimeZone::AmericaMoncton => "America/Moncton", + TimeZone::AmericaMonterrey => "America/Monterrey", + TimeZone::AmericaMontevideo => "America/Montevideo", + TimeZone::AmericaMontreal => "America/Montreal", + TimeZone::AmericaMontserrat => "America/Montserrat", + TimeZone::AmericaNassau => "America/Nassau", + TimeZone::AmericaNewYork => "America/New_York", + TimeZone::AmericaNipigon => "America/Nipigon", + TimeZone::AmericaNome => "America/Nome", + TimeZone::AmericaNoronha => "America/Noronha", + TimeZone::AmericaNorthDakotaBeulah => "America/North_Dakota/Beulah", + TimeZone::AmericaNorthDakotaCenter => "America/North_Dakota/Center", + TimeZone::AmericaNorthDakotaNewSalem => "America/North_Dakota/New_Salem", + TimeZone::AmericaNuuk => "America/Nuuk", + TimeZone::AmericaOjinaga => "America/Ojinaga", + TimeZone::AmericaPanama => "America/Panama", + TimeZone::AmericaPangnirtung => "America/Pangnirtung", + TimeZone::AmericaParamaribo => "America/Paramaribo", + TimeZone::AmericaPhoenix => "America/Phoenix", + TimeZone::AmericaPortAuPrince => "America/Port-au-Prince", + TimeZone::AmericaPortOfSpain => "America/Port_of_Spain", + TimeZone::AmericaPortoAcre => "America/Porto_Acre", + TimeZone::AmericaPortoVelho => "America/Porto_Velho", + TimeZone::AmericaPuertoRico => "America/Puerto_Rico", + TimeZone::AmericaPuntaArenas => "America/Punta_Arenas", + TimeZone::AmericaRainyRiver => "America/Rainy_River", + TimeZone::AmericaRankinInlet => "America/Rankin_Inlet", + TimeZone::AmericaRecife => "America/Recife", + TimeZone::AmericaRegina => "America/Regina", + TimeZone::AmericaResolute => "America/Resolute", + TimeZone::AmericaRioBranco => "America/Rio_Branco", + TimeZone::AmericaRosario => "America/Rosario", + TimeZone::AmericaSantaIsabel => "America/Santa_Isabel", + TimeZone::AmericaSantarem => "America/Santarem", + TimeZone::AmericaSantiago => "America/Santiago", + TimeZone::AmericaSantoDomingo => "America/Santo_Domingo", + TimeZone::AmericaSaoPaulo => "America/Sao_Paulo", + TimeZone::AmericaScoresbysund => "America/Scoresbysund", + TimeZone::AmericaShiprock => "America/Shiprock", + TimeZone::AmericaSitka => "America/Sitka", + TimeZone::AmericaStBarthelemy => "America/St_Barthelemy", + TimeZone::AmericaStJohns => "America/St_Johns", + TimeZone::AmericaStKitts => "America/St_Kitts", + TimeZone::AmericaStLucia => "America/St_Lucia", + TimeZone::AmericaStThomas => "America/St_Thomas", + TimeZone::AmericaStVincent => "America/St_Vincent", + TimeZone::AmericaSwiftCurrent => "America/Swift_Current", + TimeZone::AmericaTegucigalpa => "America/Tegucigalpa", + TimeZone::AmericaThule => "America/Thule", + TimeZone::AmericaThunderBay => "America/Thunder_Bay", + TimeZone::AmericaTijuana => "America/Tijuana", + TimeZone::AmericaToronto => "America/Toronto", + TimeZone::AmericaTortola => "America/Tortola", + TimeZone::AmericaVancouver => "America/Vancouver", + TimeZone::AmericaVirgin => "America/Virgin", + TimeZone::AmericaWhitehorse => "America/Whitehorse", + TimeZone::AmericaWinnipeg => "America/Winnipeg", + TimeZone::AmericaYakutat => "America/Yakutat", + TimeZone::AmericaYellowknife => "America/Yellowknife", + TimeZone::AntarcticaCasey => "Antarctica/Casey", + TimeZone::AntarcticaDavis => "Antarctica/Davis", + TimeZone::AntarcticaDumontDUrville => "Antarctica/DumontDUrville", + TimeZone::AntarcticaMacquarie => "Antarctica/Macquarie", + TimeZone::AntarcticaMawson => "Antarctica/Mawson", + TimeZone::AntarcticaMcMurdo => "Antarctica/McMurdo", + TimeZone::AntarcticaPalmer => "Antarctica/Palmer", + TimeZone::AntarcticaRothera => "Antarctica/Rothera", + TimeZone::AntarcticaSouthPole => "Antarctica/South_Pole", + TimeZone::AntarcticaSyowa => "Antarctica/Syowa", + TimeZone::AntarcticaTroll => "Antarctica/Troll", + TimeZone::AntarcticaVostok => "Antarctica/Vostok", + TimeZone::ArcticLongyearbyen => "Arctic/Longyearbyen", + TimeZone::AsiaAden => "Asia/Aden", + TimeZone::AsiaAlmaty => "Asia/Almaty", + TimeZone::AsiaAmman => "Asia/Amman", + TimeZone::AsiaAnadyr => "Asia/Anadyr", + TimeZone::AsiaAqtau => "Asia/Aqtau", + TimeZone::AsiaAqtobe => "Asia/Aqtobe", + TimeZone::AsiaAshgabat => "Asia/Ashgabat", + TimeZone::AsiaAshkhabad => "Asia/Ashkhabad", + TimeZone::AsiaAtyrau => "Asia/Atyrau", + TimeZone::AsiaBaghdad => "Asia/Baghdad", + TimeZone::AsiaBahrain => "Asia/Bahrain", + TimeZone::AsiaBaku => "Asia/Baku", + TimeZone::AsiaBangkok => "Asia/Bangkok", + TimeZone::AsiaBarnaul => "Asia/Barnaul", + TimeZone::AsiaBeirut => "Asia/Beirut", + TimeZone::AsiaBishkek => "Asia/Bishkek", + TimeZone::AsiaBrunei => "Asia/Brunei", + TimeZone::AsiaCalcutta => "Asia/Calcutta", + TimeZone::AsiaChita => "Asia/Chita", + TimeZone::AsiaChoibalsan => "Asia/Choibalsan", + TimeZone::AsiaChongqing => "Asia/Chongqing", + TimeZone::AsiaChungking => "Asia/Chungking", + TimeZone::AsiaColombo => "Asia/Colombo", + TimeZone::AsiaDacca => "Asia/Dacca", + TimeZone::AsiaDamascus => "Asia/Damascus", + TimeZone::AsiaDhaka => "Asia/Dhaka", + TimeZone::AsiaDili => "Asia/Dili", + TimeZone::AsiaDubai => "Asia/Dubai", + TimeZone::AsiaDushanbe => "Asia/Dushanbe", + TimeZone::AsiaFamagusta => "Asia/Famagusta", + TimeZone::AsiaGaza => "Asia/Gaza", + TimeZone::AsiaHarbin => "Asia/Harbin", + TimeZone::AsiaHebron => "Asia/Hebron", + TimeZone::AsiaHoChiMinh => "Asia/Ho_Chi_Minh", + TimeZone::AsiaHongKong => "Asia/Hong_Kong", + TimeZone::AsiaHovd => "Asia/Hovd", + TimeZone::AsiaIrkutsk => "Asia/Irkutsk", + TimeZone::AsiaIstanbul => "Asia/Istanbul", + TimeZone::AsiaJakarta => "Asia/Jakarta", + TimeZone::AsiaJayapura => "Asia/Jayapura", + TimeZone::AsiaJerusalem => "Asia/Jerusalem", + TimeZone::AsiaKabul => "Asia/Kabul", + TimeZone::AsiaKamchatka => "Asia/Kamchatka", + TimeZone::AsiaKarachi => "Asia/Karachi", + TimeZone::AsiaKashgar => "Asia/Kashgar", + TimeZone::AsiaKathmandu => "Asia/Kathmandu", + TimeZone::AsiaKatmandu => "Asia/Katmandu", + TimeZone::AsiaKhandyga => "Asia/Khandyga", + TimeZone::AsiaKolkata => "Asia/Kolkata", + TimeZone::AsiaKrasnoyarsk => "Asia/Krasnoyarsk", + TimeZone::AsiaKualaLumpur => "Asia/Kuala_Lumpur", + TimeZone::AsiaKuching => "Asia/Kuching", + TimeZone::AsiaKuwait => "Asia/Kuwait", + TimeZone::AsiaMacao => "Asia/Macao", + TimeZone::AsiaMacau => "Asia/Macau", + TimeZone::AsiaMagadan => "Asia/Magadan", + TimeZone::AsiaMakassar => "Asia/Makassar", + TimeZone::AsiaManila => "Asia/Manila", + TimeZone::AsiaMuscat => "Asia/Muscat", + TimeZone::AsiaNicosia => "Asia/Nicosia", + TimeZone::AsiaNovokuznetsk => "Asia/Novokuznetsk", + TimeZone::AsiaNovosibirsk => "Asia/Novosibirsk", + TimeZone::AsiaOmsk => "Asia/Omsk", + TimeZone::AsiaOral => "Asia/Oral", + TimeZone::AsiaPhnomPenh => "Asia/Phnom_Penh", + TimeZone::AsiaPontianak => "Asia/Pontianak", + TimeZone::AsiaPyongyang => "Asia/Pyongyang", + TimeZone::AsiaQatar => "Asia/Qatar", + TimeZone::AsiaQostanay => "Asia/Qostanay", + TimeZone::AsiaQyzylorda => "Asia/Qyzylorda", + TimeZone::AsiaRangoon => "Asia/Rangoon", + TimeZone::AsiaRiyadh => "Asia/Riyadh", + TimeZone::AsiaSaigon => "Asia/Saigon", + TimeZone::AsiaSakhalin => "Asia/Sakhalin", + TimeZone::AsiaSamarkand => "Asia/Samarkand", + TimeZone::AsiaSeoul => "Asia/Seoul", + TimeZone::AsiaShanghai => "Asia/Shanghai", + TimeZone::AsiaSingapore => "Asia/Singapore", + TimeZone::AsiaSrednekolymsk => "Asia/Srednekolymsk", + TimeZone::AsiaTaipei => "Asia/Taipei", + TimeZone::AsiaTashkent => "Asia/Tashkent", + TimeZone::AsiaTbilisi => "Asia/Tbilisi", + TimeZone::AsiaTehran => "Asia/Tehran", + TimeZone::AsiaTelAviv => "Asia/Tel_Aviv", + TimeZone::AsiaThimbu => "Asia/Thimbu", + TimeZone::AsiaThimphu => "Asia/Thimphu", + TimeZone::AsiaTokyo => "Asia/Tokyo", + TimeZone::AsiaTomsk => "Asia/Tomsk", + TimeZone::AsiaUjungPandang => "Asia/Ujung_Pandang", + TimeZone::AsiaUlaanbaatar => "Asia/Ulaanbaatar", + TimeZone::AsiaUlanBator => "Asia/Ulan_Bator", + TimeZone::AsiaUrumqi => "Asia/Urumqi", + TimeZone::AsiaUstNera => "Asia/Ust-Nera", + TimeZone::AsiaVientiane => "Asia/Vientiane", + TimeZone::AsiaVladivostok => "Asia/Vladivostok", + TimeZone::AsiaYakutsk => "Asia/Yakutsk", + TimeZone::AsiaYangon => "Asia/Yangon", + TimeZone::AsiaYekaterinburg => "Asia/Yekaterinburg", + TimeZone::AsiaYerevan => "Asia/Yerevan", + TimeZone::AtlanticAzores => "Atlantic/Azores", + TimeZone::AtlanticBermuda => "Atlantic/Bermuda", + TimeZone::AtlanticCanary => "Atlantic/Canary", + TimeZone::AtlanticCapeVerde => "Atlantic/Cape_Verde", + TimeZone::AtlanticFaeroe => "Atlantic/Faeroe", + TimeZone::AtlanticFaroe => "Atlantic/Faroe", + TimeZone::AtlanticJanMayen => "Atlantic/Jan_Mayen", + TimeZone::AtlanticMadeira => "Atlantic/Madeira", + TimeZone::AtlanticReykjavik => "Atlantic/Reykjavik", + TimeZone::AtlanticSouthGeorgia => "Atlantic/South_Georgia", + TimeZone::AtlanticStHelena => "Atlantic/St_Helena", + TimeZone::AtlanticStanley => "Atlantic/Stanley", + TimeZone::AustraliaACT => "Australia/ACT", + TimeZone::AustraliaAdelaide => "Australia/Adelaide", + TimeZone::AustraliaBrisbane => "Australia/Brisbane", + TimeZone::AustraliaBrokenHill => "Australia/Broken_Hill", + TimeZone::AustraliaCanberra => "Australia/Canberra", + TimeZone::AustraliaCurrie => "Australia/Currie", + TimeZone::AustraliaDarwin => "Australia/Darwin", + TimeZone::AustraliaEucla => "Australia/Eucla", + TimeZone::AustraliaHobart => "Australia/Hobart", + TimeZone::AustraliaLHI => "Australia/LHI", + TimeZone::AustraliaLindeman => "Australia/Lindeman", + TimeZone::AustraliaLordHowe => "Australia/Lord_Howe", + TimeZone::AustraliaMelbourne => "Australia/Melbourne", + TimeZone::AustraliaNSW => "Australia/NSW", + TimeZone::AustraliaNorth => "Australia/North", + TimeZone::AustraliaPerth => "Australia/Perth", + TimeZone::AustraliaQueensland => "Australia/Queensland", + TimeZone::AustraliaSouth => "Australia/South", + TimeZone::AustraliaSydney => "Australia/Sydney", + TimeZone::AustraliaTasmania => "Australia/Tasmania", + TimeZone::AustraliaVictoria => "Australia/Victoria", + TimeZone::AustraliaWest => "Australia/West", + TimeZone::AustraliaYancowinna => "Australia/Yancowinna", + TimeZone::BrazilAcre => "Brazil/Acre", + TimeZone::BrazilDeNoronha => "Brazil/DeNoronha", + TimeZone::BrazilEast => "Brazil/East", + TimeZone::BrazilWest => "Brazil/West", + TimeZone::CET => "CET", + TimeZone::CST6CDT => "CST6CDT", + TimeZone::CanadaAtlantic => "Canada/Atlantic", + TimeZone::CanadaCentral => "Canada/Central", + TimeZone::CanadaEastern => "Canada/Eastern", + TimeZone::CanadaMountain => "Canada/Mountain", + TimeZone::CanadaNewfoundland => "Canada/Newfoundland", + TimeZone::CanadaPacific => "Canada/Pacific", + TimeZone::CanadaSaskatchewan => "Canada/Saskatchewan", + TimeZone::CanadaYukon => "Canada/Yukon", + TimeZone::ChileContinental => "Chile/Continental", + TimeZone::ChileEasterIsland => "Chile/EasterIsland", + TimeZone::Cuba => "Cuba", + TimeZone::EET => "EET", + TimeZone::EST => "EST", + TimeZone::EST5EDT => "EST5EDT", + TimeZone::Egypt => "Egypt", + TimeZone::Eire => "Eire", + TimeZone::EtcGMT => "Etc/GMT", + TimeZone::EtcGMTPlus0 => "Etc/GMT+0", + TimeZone::EtcGMTPlus1 => "Etc/GMT+1", + TimeZone::EtcGMTPlus10 => "Etc/GMT+10", + TimeZone::EtcGMTPlus11 => "Etc/GMT+11", + TimeZone::EtcGMTPlus12 => "Etc/GMT+12", + TimeZone::EtcGMTPlus2 => "Etc/GMT+2", + TimeZone::EtcGMTPlus3 => "Etc/GMT+3", + TimeZone::EtcGMTPlus4 => "Etc/GMT+4", + TimeZone::EtcGMTPlus5 => "Etc/GMT+5", + TimeZone::EtcGMTPlus6 => "Etc/GMT+6", + TimeZone::EtcGMTPlus7 => "Etc/GMT+7", + TimeZone::EtcGMTPlus8 => "Etc/GMT+8", + TimeZone::EtcGMTPlus9 => "Etc/GMT+9", + TimeZone::EtcGMTMinus0 => "Etc/GMT-0", + TimeZone::EtcGMTMinus1 => "Etc/GMT-1", + TimeZone::EtcGMTMinus10 => "Etc/GMT-10", + TimeZone::EtcGMTMinus11 => "Etc/GMT-11", + TimeZone::EtcGMTMinus12 => "Etc/GMT-12", + TimeZone::EtcGMTMinus13 => "Etc/GMT-13", + TimeZone::EtcGMTMinus14 => "Etc/GMT-14", + TimeZone::EtcGMTMinus2 => "Etc/GMT-2", + TimeZone::EtcGMTMinus3 => "Etc/GMT-3", + TimeZone::EtcGMTMinus4 => "Etc/GMT-4", + TimeZone::EtcGMTMinus5 => "Etc/GMT-5", + TimeZone::EtcGMTMinus6 => "Etc/GMT-6", + TimeZone::EtcGMTMinus7 => "Etc/GMT-7", + TimeZone::EtcGMTMinus8 => "Etc/GMT-8", + TimeZone::EtcGMTMinus9 => "Etc/GMT-9", + TimeZone::EtcGMT0 => "Etc/GMT0", + TimeZone::EtcGreenwich => "Etc/Greenwich", + TimeZone::EtcUCT => "Etc/UCT", + TimeZone::EtcUTC => "Etc/UTC", + TimeZone::EtcUniversal => "Etc/Universal", + TimeZone::EtcZulu => "Etc/Zulu", + TimeZone::EuropeAmsterdam => "Europe/Amsterdam", + TimeZone::EuropeAndorra => "Europe/Andorra", + TimeZone::EuropeAstrakhan => "Europe/Astrakhan", + TimeZone::EuropeAthens => "Europe/Athens", + TimeZone::EuropeBelfast => "Europe/Belfast", + TimeZone::EuropeBelgrade => "Europe/Belgrade", + TimeZone::EuropeBerlin => "Europe/Berlin", + TimeZone::EuropeBratislava => "Europe/Bratislava", + TimeZone::EuropeBrussels => "Europe/Brussels", + TimeZone::EuropeBucharest => "Europe/Bucharest", + TimeZone::EuropeBudapest => "Europe/Budapest", + TimeZone::EuropeBusingen => "Europe/Busingen", + TimeZone::EuropeChisinau => "Europe/Chisinau", + TimeZone::EuropeCopenhagen => "Europe/Copenhagen", + TimeZone::EuropeDublin => "Europe/Dublin", + TimeZone::EuropeGibraltar => "Europe/Gibraltar", + TimeZone::EuropeGuernsey => "Europe/Guernsey", + TimeZone::EuropeHelsinki => "Europe/Helsinki", + TimeZone::EuropeIsleOfMan => "Europe/Isle_of_Man", + TimeZone::EuropeIstanbul => "Europe/Istanbul", + TimeZone::EuropeJersey => "Europe/Jersey", + TimeZone::EuropeKaliningrad => "Europe/Kaliningrad", + TimeZone::EuropeKiev => "Europe/Kiev", + TimeZone::EuropeKirov => "Europe/Kirov", + TimeZone::EuropeKyiv => "Europe/Kyiv", + TimeZone::EuropeLisbon => "Europe/Lisbon", + TimeZone::EuropeLjubljana => "Europe/Ljubljana", + TimeZone::EuropeLondon => "Europe/London", + TimeZone::EuropeLuxembourg => "Europe/Luxembourg", + TimeZone::EuropeMadrid => "Europe/Madrid", + TimeZone::EuropeMalta => "Europe/Malta", + TimeZone::EuropeMariehamn => "Europe/Mariehamn", + TimeZone::EuropeMinsk => "Europe/Minsk", + TimeZone::EuropeMonaco => "Europe/Monaco", + TimeZone::EuropeMoscow => "Europe/Moscow", + TimeZone::EuropeNicosia => "Europe/Nicosia", + TimeZone::EuropeOslo => "Europe/Oslo", + TimeZone::EuropeParis => "Europe/Paris", + TimeZone::EuropePodgorica => "Europe/Podgorica", + TimeZone::EuropePrague => "Europe/Prague", + TimeZone::EuropeRiga => "Europe/Riga", + TimeZone::EuropeRome => "Europe/Rome", + TimeZone::EuropeSamara => "Europe/Samara", + TimeZone::EuropeSanMarino => "Europe/San_Marino", + TimeZone::EuropeSarajevo => "Europe/Sarajevo", + TimeZone::EuropeSaratov => "Europe/Saratov", + TimeZone::EuropeSimferopol => "Europe/Simferopol", + TimeZone::EuropeSkopje => "Europe/Skopje", + TimeZone::EuropeSofia => "Europe/Sofia", + TimeZone::EuropeStockholm => "Europe/Stockholm", + TimeZone::EuropeTallinn => "Europe/Tallinn", + TimeZone::EuropeTirane => "Europe/Tirane", + TimeZone::EuropeTiraspol => "Europe/Tiraspol", + TimeZone::EuropeUlyanovsk => "Europe/Ulyanovsk", + TimeZone::EuropeUzhgorod => "Europe/Uzhgorod", + TimeZone::EuropeVaduz => "Europe/Vaduz", + TimeZone::EuropeVatican => "Europe/Vatican", + TimeZone::EuropeVienna => "Europe/Vienna", + TimeZone::EuropeVilnius => "Europe/Vilnius", + TimeZone::EuropeVolgograd => "Europe/Volgograd", + TimeZone::EuropeWarsaw => "Europe/Warsaw", + TimeZone::EuropeZagreb => "Europe/Zagreb", + TimeZone::EuropeZaporozhye => "Europe/Zaporozhye", + TimeZone::EuropeZurich => "Europe/Zurich", + TimeZone::Factory => "Factory", + TimeZone::GB => "GB", + TimeZone::GBEire => "GB-Eire", + TimeZone::GMT => "GMT", + TimeZone::GMTPlus0 => "GMT+0", + TimeZone::GMTMinus0 => "GMT-0", + TimeZone::GMT0 => "GMT0", + TimeZone::Greenwich => "Greenwich", + TimeZone::HST => "HST", + TimeZone::Hongkong => "Hongkong", + TimeZone::Iceland => "Iceland", + TimeZone::IndianAntananarivo => "Indian/Antananarivo", + TimeZone::IndianChagos => "Indian/Chagos", + TimeZone::IndianChristmas => "Indian/Christmas", + TimeZone::IndianCocos => "Indian/Cocos", + TimeZone::IndianComoro => "Indian/Comoro", + TimeZone::IndianKerguelen => "Indian/Kerguelen", + TimeZone::IndianMahe => "Indian/Mahe", + TimeZone::IndianMaldives => "Indian/Maldives", + TimeZone::IndianMauritius => "Indian/Mauritius", + TimeZone::IndianMayotte => "Indian/Mayotte", + TimeZone::IndianReunion => "Indian/Reunion", + TimeZone::Iran => "Iran", + TimeZone::Israel => "Israel", + TimeZone::Jamaica => "Jamaica", + TimeZone::Japan => "Japan", + TimeZone::Kwajalein => "Kwajalein", + TimeZone::Libya => "Libya", + TimeZone::MET => "MET", + TimeZone::MST => "MST", + TimeZone::MST7MDT => "MST7MDT", + TimeZone::MexicoBajaNorte => "Mexico/BajaNorte", + TimeZone::MexicoBajaSur => "Mexico/BajaSur", + TimeZone::MexicoGeneral => "Mexico/General", + TimeZone::NZ => "NZ", + TimeZone::NZCHAT => "NZ-CHAT", + TimeZone::Navajo => "Navajo", + TimeZone::PRC => "PRC", + TimeZone::PST8PDT => "PST8PDT", + TimeZone::PacificApia => "Pacific/Apia", + TimeZone::PacificAuckland => "Pacific/Auckland", + TimeZone::PacificBougainville => "Pacific/Bougainville", + TimeZone::PacificChatham => "Pacific/Chatham", + TimeZone::PacificChuuk => "Pacific/Chuuk", + TimeZone::PacificEaster => "Pacific/Easter", + TimeZone::PacificEfate => "Pacific/Efate", + TimeZone::PacificEnderbury => "Pacific/Enderbury", + TimeZone::PacificFakaofo => "Pacific/Fakaofo", + TimeZone::PacificFiji => "Pacific/Fiji", + TimeZone::PacificFunafuti => "Pacific/Funafuti", + TimeZone::PacificGalapagos => "Pacific/Galapagos", + TimeZone::PacificGambier => "Pacific/Gambier", + TimeZone::PacificGuadalcanal => "Pacific/Guadalcanal", + TimeZone::PacificGuam => "Pacific/Guam", + TimeZone::PacificHonolulu => "Pacific/Honolulu", + TimeZone::PacificJohnston => "Pacific/Johnston", + TimeZone::PacificKanton => "Pacific/Kanton", + TimeZone::PacificKiritimati => "Pacific/Kiritimati", + TimeZone::PacificKosrae => "Pacific/Kosrae", + TimeZone::PacificKwajalein => "Pacific/Kwajalein", + TimeZone::PacificMajuro => "Pacific/Majuro", + TimeZone::PacificMarquesas => "Pacific/Marquesas", + TimeZone::PacificMidway => "Pacific/Midway", + TimeZone::PacificNauru => "Pacific/Nauru", + TimeZone::PacificNiue => "Pacific/Niue", + TimeZone::PacificNorfolk => "Pacific/Norfolk", + TimeZone::PacificNoumea => "Pacific/Noumea", + TimeZone::PacificPagoPago => "Pacific/Pago_Pago", + TimeZone::PacificPalau => "Pacific/Palau", + TimeZone::PacificPitcairn => "Pacific/Pitcairn", + TimeZone::PacificPohnpei => "Pacific/Pohnpei", + TimeZone::PacificPonape => "Pacific/Ponape", + TimeZone::PacificPortMoresby => "Pacific/Port_Moresby", + TimeZone::PacificRarotonga => "Pacific/Rarotonga", + TimeZone::PacificSaipan => "Pacific/Saipan", + TimeZone::PacificSamoa => "Pacific/Samoa", + TimeZone::PacificTahiti => "Pacific/Tahiti", + TimeZone::PacificTarawa => "Pacific/Tarawa", + TimeZone::PacificTongatapu => "Pacific/Tongatapu", + TimeZone::PacificTruk => "Pacific/Truk", + TimeZone::PacificWake => "Pacific/Wake", + TimeZone::PacificWallis => "Pacific/Wallis", + TimeZone::PacificYap => "Pacific/Yap", + TimeZone::Poland => "Poland", + TimeZone::Portugal => "Portugal", + TimeZone::ROC => "ROC", + TimeZone::ROK => "ROK", + TimeZone::Singapore => "Singapore", + TimeZone::Turkey => "Turkey", + TimeZone::UCT => "UCT", + TimeZone::USAlaska => "US/Alaska", + TimeZone::USAleutian => "US/Aleutian", + TimeZone::USArizona => "US/Arizona", + TimeZone::USCentral => "US/Central", + TimeZone::USEastIndiana => "US/East-Indiana", + TimeZone::USEastern => "US/Eastern", + TimeZone::USHawaii => "US/Hawaii", + TimeZone::USIndianaStarke => "US/Indiana-Starke", + TimeZone::USMichigan => "US/Michigan", + TimeZone::USMountain => "US/Mountain", + TimeZone::USPacific => "US/Pacific", + TimeZone::USSamoa => "US/Samoa", + TimeZone::UTC => "UTC", + TimeZone::Universal => "Universal", + TimeZone::WSU => "W-SU", + TimeZone::WET => "WET", + TimeZone::Zulu => "Zulu", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TimeZone::AfricaAbidjan), + 1 => Some(TimeZone::AfricaAccra), + 2 => Some(TimeZone::AfricaAddisAbaba), + 3 => Some(TimeZone::AfricaAlgiers), + 4 => Some(TimeZone::AfricaAsmara), + 5 => Some(TimeZone::AfricaAsmera), + 6 => Some(TimeZone::AfricaBamako), + 7 => Some(TimeZone::AfricaBangui), + 8 => Some(TimeZone::AfricaBanjul), + 9 => Some(TimeZone::AfricaBissau), + 10 => Some(TimeZone::AfricaBlantyre), + 11 => Some(TimeZone::AfricaBrazzaville), + 12 => Some(TimeZone::AfricaBujumbura), + 13 => Some(TimeZone::AfricaCairo), + 14 => Some(TimeZone::AfricaCasablanca), + 15 => Some(TimeZone::AfricaCeuta), + 16 => Some(TimeZone::AfricaConakry), + 17 => Some(TimeZone::AfricaDakar), + 18 => Some(TimeZone::AfricaDarEsSalaam), + 19 => Some(TimeZone::AfricaDjibouti), + 20 => Some(TimeZone::AfricaDouala), + 21 => Some(TimeZone::AfricaElAaiun), + 22 => Some(TimeZone::AfricaFreetown), + 23 => Some(TimeZone::AfricaGaborone), + 24 => Some(TimeZone::AfricaHarare), + 25 => Some(TimeZone::AfricaJohannesburg), + 26 => Some(TimeZone::AfricaJuba), + 27 => Some(TimeZone::AfricaKampala), + 28 => Some(TimeZone::AfricaKhartoum), + 29 => Some(TimeZone::AfricaKigali), + 30 => Some(TimeZone::AfricaKinshasa), + 31 => Some(TimeZone::AfricaLagos), + 32 => Some(TimeZone::AfricaLibreville), + 33 => Some(TimeZone::AfricaLome), + 34 => Some(TimeZone::AfricaLuanda), + 35 => Some(TimeZone::AfricaLubumbashi), + 36 => Some(TimeZone::AfricaLusaka), + 37 => Some(TimeZone::AfricaMalabo), + 38 => Some(TimeZone::AfricaMaputo), + 39 => Some(TimeZone::AfricaMaseru), + 40 => Some(TimeZone::AfricaMbabane), + 41 => Some(TimeZone::AfricaMogadishu), + 42 => Some(TimeZone::AfricaMonrovia), + 43 => Some(TimeZone::AfricaNairobi), + 44 => Some(TimeZone::AfricaNdjamena), + 45 => Some(TimeZone::AfricaNiamey), + 46 => Some(TimeZone::AfricaNouakchott), + 47 => Some(TimeZone::AfricaOuagadougou), + 48 => Some(TimeZone::AfricaPortoNovo), + 49 => Some(TimeZone::AfricaSaoTome), + 50 => Some(TimeZone::AfricaTimbuktu), + 51 => Some(TimeZone::AfricaTripoli), + 52 => Some(TimeZone::AfricaTunis), + 53 => Some(TimeZone::AfricaWindhoek), + 54 => Some(TimeZone::AmericaAdak), + 55 => Some(TimeZone::AmericaAnchorage), + 56 => Some(TimeZone::AmericaAnguilla), + 57 => Some(TimeZone::AmericaAntigua), + 58 => Some(TimeZone::AmericaAraguaina), + 59 => Some(TimeZone::AmericaArgentinaBuenosAires), + 60 => Some(TimeZone::AmericaArgentinaCatamarca), + 61 => Some(TimeZone::AmericaArgentinaComodRivadavia), + 62 => Some(TimeZone::AmericaArgentinaCordoba), + 63 => Some(TimeZone::AmericaArgentinaJujuy), + 64 => Some(TimeZone::AmericaArgentinaLaRioja), + 65 => Some(TimeZone::AmericaArgentinaMendoza), + 66 => Some(TimeZone::AmericaArgentinaRioGallegos), + 67 => Some(TimeZone::AmericaArgentinaSalta), + 68 => Some(TimeZone::AmericaArgentinaSanJuan), + 69 => Some(TimeZone::AmericaArgentinaSanLuis), + 70 => Some(TimeZone::AmericaArgentinaTucuman), + 71 => Some(TimeZone::AmericaArgentinaUshuaia), + 72 => Some(TimeZone::AmericaAruba), + 73 => Some(TimeZone::AmericaAsuncion), + 74 => Some(TimeZone::AmericaAtikokan), + 75 => Some(TimeZone::AmericaAtka), + 76 => Some(TimeZone::AmericaBahia), + 77 => Some(TimeZone::AmericaBahiaBanderas), + 78 => Some(TimeZone::AmericaBarbados), + 79 => Some(TimeZone::AmericaBelem), + 80 => Some(TimeZone::AmericaBelize), + 81 => Some(TimeZone::AmericaBlancSablon), + 82 => Some(TimeZone::AmericaBoaVista), + 83 => Some(TimeZone::AmericaBogota), + 84 => Some(TimeZone::AmericaBoise), + 85 => Some(TimeZone::AmericaBuenosAires), + 86 => Some(TimeZone::AmericaCambridgeBay), + 87 => Some(TimeZone::AmericaCampoGrande), + 88 => Some(TimeZone::AmericaCancun), + 89 => Some(TimeZone::AmericaCaracas), + 90 => Some(TimeZone::AmericaCatamarca), + 91 => Some(TimeZone::AmericaCayenne), + 92 => Some(TimeZone::AmericaCayman), + 93 => Some(TimeZone::AmericaChicago), + 94 => Some(TimeZone::AmericaChihuahua), + 95 => Some(TimeZone::AmericaCiudadJuarez), + 96 => Some(TimeZone::AmericaCoralHarbour), + 97 => Some(TimeZone::AmericaCordoba), + 98 => Some(TimeZone::AmericaCostaRica), + 99 => Some(TimeZone::AmericaCoyhaique), + 100 => Some(TimeZone::AmericaCreston), + 101 => Some(TimeZone::AmericaCuiaba), + 102 => Some(TimeZone::AmericaCuracao), + 103 => Some(TimeZone::AmericaDanmarkshavn), + 104 => Some(TimeZone::AmericaDawson), + 105 => Some(TimeZone::AmericaDawsonCreek), + 106 => Some(TimeZone::AmericaDenver), + 107 => Some(TimeZone::AmericaDetroit), + 108 => Some(TimeZone::AmericaDominica), + 109 => Some(TimeZone::AmericaEdmonton), + 110 => Some(TimeZone::AmericaEirunepe), + 111 => Some(TimeZone::AmericaElSalvador), + 112 => Some(TimeZone::AmericaEnsenada), + 113 => Some(TimeZone::AmericaFortNelson), + 114 => Some(TimeZone::AmericaFortWayne), + 115 => Some(TimeZone::AmericaFortaleza), + 116 => Some(TimeZone::AmericaGlaceBay), + 117 => Some(TimeZone::AmericaGodthab), + 118 => Some(TimeZone::AmericaGooseBay), + 119 => Some(TimeZone::AmericaGrandTurk), + 120 => Some(TimeZone::AmericaGrenada), + 121 => Some(TimeZone::AmericaGuadeloupe), + 122 => Some(TimeZone::AmericaGuatemala), + 123 => Some(TimeZone::AmericaGuayaquil), + 124 => Some(TimeZone::AmericaGuyana), + 125 => Some(TimeZone::AmericaHalifax), + 126 => Some(TimeZone::AmericaHavana), + 127 => Some(TimeZone::AmericaHermosillo), + 128 => Some(TimeZone::AmericaIndianaIndianapolis), + 129 => Some(TimeZone::AmericaIndianaKnox), + 130 => Some(TimeZone::AmericaIndianaMarengo), + 131 => Some(TimeZone::AmericaIndianaPetersburg), + 132 => Some(TimeZone::AmericaIndianaTellCity), + 133 => Some(TimeZone::AmericaIndianaVevay), + 134 => Some(TimeZone::AmericaIndianaVincennes), + 135 => Some(TimeZone::AmericaIndianaWinamac), + 136 => Some(TimeZone::AmericaIndianapolis), + 137 => Some(TimeZone::AmericaInuvik), + 138 => Some(TimeZone::AmericaIqaluit), + 139 => Some(TimeZone::AmericaJamaica), + 140 => Some(TimeZone::AmericaJujuy), + 141 => Some(TimeZone::AmericaJuneau), + 142 => Some(TimeZone::AmericaKentuckyLouisville), + 143 => Some(TimeZone::AmericaKentuckyMonticello), + 144 => Some(TimeZone::AmericaKnoxIN), + 145 => Some(TimeZone::AmericaKralendijk), + 146 => Some(TimeZone::AmericaLaPaz), + 147 => Some(TimeZone::AmericaLima), + 148 => Some(TimeZone::AmericaLosAngeles), + 149 => Some(TimeZone::AmericaLouisville), + 150 => Some(TimeZone::AmericaLowerPrinces), + 151 => Some(TimeZone::AmericaMaceio), + 152 => Some(TimeZone::AmericaManagua), + 153 => Some(TimeZone::AmericaManaus), + 154 => Some(TimeZone::AmericaMarigot), + 155 => Some(TimeZone::AmericaMartinique), + 156 => Some(TimeZone::AmericaMatamoros), + 157 => Some(TimeZone::AmericaMazatlan), + 158 => Some(TimeZone::AmericaMendoza), + 159 => Some(TimeZone::AmericaMenominee), + 160 => Some(TimeZone::AmericaMerida), + 161 => Some(TimeZone::AmericaMetlakatla), + 162 => Some(TimeZone::AmericaMexicoCity), + 163 => Some(TimeZone::AmericaMiquelon), + 164 => Some(TimeZone::AmericaMoncton), + 165 => Some(TimeZone::AmericaMonterrey), + 166 => Some(TimeZone::AmericaMontevideo), + 167 => Some(TimeZone::AmericaMontreal), + 168 => Some(TimeZone::AmericaMontserrat), + 169 => Some(TimeZone::AmericaNassau), + 170 => Some(TimeZone::AmericaNewYork), + 171 => Some(TimeZone::AmericaNipigon), + 172 => Some(TimeZone::AmericaNome), + 173 => Some(TimeZone::AmericaNoronha), + 174 => Some(TimeZone::AmericaNorthDakotaBeulah), + 175 => Some(TimeZone::AmericaNorthDakotaCenter), + 176 => Some(TimeZone::AmericaNorthDakotaNewSalem), + 177 => Some(TimeZone::AmericaNuuk), + 178 => Some(TimeZone::AmericaOjinaga), + 179 => Some(TimeZone::AmericaPanama), + 180 => Some(TimeZone::AmericaPangnirtung), + 181 => Some(TimeZone::AmericaParamaribo), + 182 => Some(TimeZone::AmericaPhoenix), + 183 => Some(TimeZone::AmericaPortAuPrince), + 184 => Some(TimeZone::AmericaPortOfSpain), + 185 => Some(TimeZone::AmericaPortoAcre), + 186 => Some(TimeZone::AmericaPortoVelho), + 187 => Some(TimeZone::AmericaPuertoRico), + 188 => Some(TimeZone::AmericaPuntaArenas), + 189 => Some(TimeZone::AmericaRainyRiver), + 190 => Some(TimeZone::AmericaRankinInlet), + 191 => Some(TimeZone::AmericaRecife), + 192 => Some(TimeZone::AmericaRegina), + 193 => Some(TimeZone::AmericaResolute), + 194 => Some(TimeZone::AmericaRioBranco), + 195 => Some(TimeZone::AmericaRosario), + 196 => Some(TimeZone::AmericaSantaIsabel), + 197 => Some(TimeZone::AmericaSantarem), + 198 => Some(TimeZone::AmericaSantiago), + 199 => Some(TimeZone::AmericaSantoDomingo), + 200 => Some(TimeZone::AmericaSaoPaulo), + 201 => Some(TimeZone::AmericaScoresbysund), + 202 => Some(TimeZone::AmericaShiprock), + 203 => Some(TimeZone::AmericaSitka), + 204 => Some(TimeZone::AmericaStBarthelemy), + 205 => Some(TimeZone::AmericaStJohns), + 206 => Some(TimeZone::AmericaStKitts), + 207 => Some(TimeZone::AmericaStLucia), + 208 => Some(TimeZone::AmericaStThomas), + 209 => Some(TimeZone::AmericaStVincent), + 210 => Some(TimeZone::AmericaSwiftCurrent), + 211 => Some(TimeZone::AmericaTegucigalpa), + 212 => Some(TimeZone::AmericaThule), + 213 => Some(TimeZone::AmericaThunderBay), + 214 => Some(TimeZone::AmericaTijuana), + 215 => Some(TimeZone::AmericaToronto), + 216 => Some(TimeZone::AmericaTortola), + 217 => Some(TimeZone::AmericaVancouver), + 218 => Some(TimeZone::AmericaVirgin), + 219 => Some(TimeZone::AmericaWhitehorse), + 220 => Some(TimeZone::AmericaWinnipeg), + 221 => Some(TimeZone::AmericaYakutat), + 222 => Some(TimeZone::AmericaYellowknife), + 223 => Some(TimeZone::AntarcticaCasey), + 224 => Some(TimeZone::AntarcticaDavis), + 225 => Some(TimeZone::AntarcticaDumontDUrville), + 226 => Some(TimeZone::AntarcticaMacquarie), + 227 => Some(TimeZone::AntarcticaMawson), + 228 => Some(TimeZone::AntarcticaMcMurdo), + 229 => Some(TimeZone::AntarcticaPalmer), + 230 => Some(TimeZone::AntarcticaRothera), + 231 => Some(TimeZone::AntarcticaSouthPole), + 232 => Some(TimeZone::AntarcticaSyowa), + 233 => Some(TimeZone::AntarcticaTroll), + 234 => Some(TimeZone::AntarcticaVostok), + 235 => Some(TimeZone::ArcticLongyearbyen), + 236 => Some(TimeZone::AsiaAden), + 237 => Some(TimeZone::AsiaAlmaty), + 238 => Some(TimeZone::AsiaAmman), + 239 => Some(TimeZone::AsiaAnadyr), + 240 => Some(TimeZone::AsiaAqtau), + 241 => Some(TimeZone::AsiaAqtobe), + 242 => Some(TimeZone::AsiaAshgabat), + 243 => Some(TimeZone::AsiaAshkhabad), + 244 => Some(TimeZone::AsiaAtyrau), + 245 => Some(TimeZone::AsiaBaghdad), + 246 => Some(TimeZone::AsiaBahrain), + 247 => Some(TimeZone::AsiaBaku), + 248 => Some(TimeZone::AsiaBangkok), + 249 => Some(TimeZone::AsiaBarnaul), + 250 => Some(TimeZone::AsiaBeirut), + 251 => Some(TimeZone::AsiaBishkek), + 252 => Some(TimeZone::AsiaBrunei), + 253 => Some(TimeZone::AsiaCalcutta), + 254 => Some(TimeZone::AsiaChita), + 255 => Some(TimeZone::AsiaChoibalsan), + 256 => Some(TimeZone::AsiaChongqing), + 257 => Some(TimeZone::AsiaChungking), + 258 => Some(TimeZone::AsiaColombo), + 259 => Some(TimeZone::AsiaDacca), + 260 => Some(TimeZone::AsiaDamascus), + 261 => Some(TimeZone::AsiaDhaka), + 262 => Some(TimeZone::AsiaDili), + 263 => Some(TimeZone::AsiaDubai), + 264 => Some(TimeZone::AsiaDushanbe), + 265 => Some(TimeZone::AsiaFamagusta), + 266 => Some(TimeZone::AsiaGaza), + 267 => Some(TimeZone::AsiaHarbin), + 268 => Some(TimeZone::AsiaHebron), + 269 => Some(TimeZone::AsiaHoChiMinh), + 270 => Some(TimeZone::AsiaHongKong), + 271 => Some(TimeZone::AsiaHovd), + 272 => Some(TimeZone::AsiaIrkutsk), + 273 => Some(TimeZone::AsiaIstanbul), + 274 => Some(TimeZone::AsiaJakarta), + 275 => Some(TimeZone::AsiaJayapura), + 276 => Some(TimeZone::AsiaJerusalem), + 277 => Some(TimeZone::AsiaKabul), + 278 => Some(TimeZone::AsiaKamchatka), + 279 => Some(TimeZone::AsiaKarachi), + 280 => Some(TimeZone::AsiaKashgar), + 281 => Some(TimeZone::AsiaKathmandu), + 282 => Some(TimeZone::AsiaKatmandu), + 283 => Some(TimeZone::AsiaKhandyga), + 284 => Some(TimeZone::AsiaKolkata), + 285 => Some(TimeZone::AsiaKrasnoyarsk), + 286 => Some(TimeZone::AsiaKualaLumpur), + 287 => Some(TimeZone::AsiaKuching), + 288 => Some(TimeZone::AsiaKuwait), + 289 => Some(TimeZone::AsiaMacao), + 290 => Some(TimeZone::AsiaMacau), + 291 => Some(TimeZone::AsiaMagadan), + 292 => Some(TimeZone::AsiaMakassar), + 293 => Some(TimeZone::AsiaManila), + 294 => Some(TimeZone::AsiaMuscat), + 295 => Some(TimeZone::AsiaNicosia), + 296 => Some(TimeZone::AsiaNovokuznetsk), + 297 => Some(TimeZone::AsiaNovosibirsk), + 298 => Some(TimeZone::AsiaOmsk), + 299 => Some(TimeZone::AsiaOral), + 300 => Some(TimeZone::AsiaPhnomPenh), + 301 => Some(TimeZone::AsiaPontianak), + 302 => Some(TimeZone::AsiaPyongyang), + 303 => Some(TimeZone::AsiaQatar), + 304 => Some(TimeZone::AsiaQostanay), + 305 => Some(TimeZone::AsiaQyzylorda), + 306 => Some(TimeZone::AsiaRangoon), + 307 => Some(TimeZone::AsiaRiyadh), + 308 => Some(TimeZone::AsiaSaigon), + 309 => Some(TimeZone::AsiaSakhalin), + 310 => Some(TimeZone::AsiaSamarkand), + 311 => Some(TimeZone::AsiaSeoul), + 312 => Some(TimeZone::AsiaShanghai), + 313 => Some(TimeZone::AsiaSingapore), + 314 => Some(TimeZone::AsiaSrednekolymsk), + 315 => Some(TimeZone::AsiaTaipei), + 316 => Some(TimeZone::AsiaTashkent), + 317 => Some(TimeZone::AsiaTbilisi), + 318 => Some(TimeZone::AsiaTehran), + 319 => Some(TimeZone::AsiaTelAviv), + 320 => Some(TimeZone::AsiaThimbu), + 321 => Some(TimeZone::AsiaThimphu), + 322 => Some(TimeZone::AsiaTokyo), + 323 => Some(TimeZone::AsiaTomsk), + 324 => Some(TimeZone::AsiaUjungPandang), + 325 => Some(TimeZone::AsiaUlaanbaatar), + 326 => Some(TimeZone::AsiaUlanBator), + 327 => Some(TimeZone::AsiaUrumqi), + 328 => Some(TimeZone::AsiaUstNera), + 329 => Some(TimeZone::AsiaVientiane), + 330 => Some(TimeZone::AsiaVladivostok), + 331 => Some(TimeZone::AsiaYakutsk), + 332 => Some(TimeZone::AsiaYangon), + 333 => Some(TimeZone::AsiaYekaterinburg), + 334 => Some(TimeZone::AsiaYerevan), + 335 => Some(TimeZone::AtlanticAzores), + 336 => Some(TimeZone::AtlanticBermuda), + 337 => Some(TimeZone::AtlanticCanary), + 338 => Some(TimeZone::AtlanticCapeVerde), + 339 => Some(TimeZone::AtlanticFaeroe), + 340 => Some(TimeZone::AtlanticFaroe), + 341 => Some(TimeZone::AtlanticJanMayen), + 342 => Some(TimeZone::AtlanticMadeira), + 343 => Some(TimeZone::AtlanticReykjavik), + 344 => Some(TimeZone::AtlanticSouthGeorgia), + 345 => Some(TimeZone::AtlanticStHelena), + 346 => Some(TimeZone::AtlanticStanley), + 347 => Some(TimeZone::AustraliaACT), + 348 => Some(TimeZone::AustraliaAdelaide), + 349 => Some(TimeZone::AustraliaBrisbane), + 350 => Some(TimeZone::AustraliaBrokenHill), + 351 => Some(TimeZone::AustraliaCanberra), + 352 => Some(TimeZone::AustraliaCurrie), + 353 => Some(TimeZone::AustraliaDarwin), + 354 => Some(TimeZone::AustraliaEucla), + 355 => Some(TimeZone::AustraliaHobart), + 356 => Some(TimeZone::AustraliaLHI), + 357 => Some(TimeZone::AustraliaLindeman), + 358 => Some(TimeZone::AustraliaLordHowe), + 359 => Some(TimeZone::AustraliaMelbourne), + 360 => Some(TimeZone::AustraliaNSW), + 361 => Some(TimeZone::AustraliaNorth), + 362 => Some(TimeZone::AustraliaPerth), + 363 => Some(TimeZone::AustraliaQueensland), + 364 => Some(TimeZone::AustraliaSouth), + 365 => Some(TimeZone::AustraliaSydney), + 366 => Some(TimeZone::AustraliaTasmania), + 367 => Some(TimeZone::AustraliaVictoria), + 368 => Some(TimeZone::AustraliaWest), + 369 => Some(TimeZone::AustraliaYancowinna), + 370 => Some(TimeZone::BrazilAcre), + 371 => Some(TimeZone::BrazilDeNoronha), + 372 => Some(TimeZone::BrazilEast), + 373 => Some(TimeZone::BrazilWest), + 374 => Some(TimeZone::CET), + 375 => Some(TimeZone::CST6CDT), + 376 => Some(TimeZone::CanadaAtlantic), + 377 => Some(TimeZone::CanadaCentral), + 378 => Some(TimeZone::CanadaEastern), + 379 => Some(TimeZone::CanadaMountain), + 380 => Some(TimeZone::CanadaNewfoundland), + 381 => Some(TimeZone::CanadaPacific), + 382 => Some(TimeZone::CanadaSaskatchewan), + 383 => Some(TimeZone::CanadaYukon), + 384 => Some(TimeZone::ChileContinental), + 385 => Some(TimeZone::ChileEasterIsland), + 386 => Some(TimeZone::Cuba), + 387 => Some(TimeZone::EET), + 388 => Some(TimeZone::EST), + 389 => Some(TimeZone::EST5EDT), + 390 => Some(TimeZone::Egypt), + 391 => Some(TimeZone::Eire), + 392 => Some(TimeZone::EtcGMT), + 393 => Some(TimeZone::EtcGMTPlus0), + 394 => Some(TimeZone::EtcGMTPlus1), + 395 => Some(TimeZone::EtcGMTPlus10), + 396 => Some(TimeZone::EtcGMTPlus11), + 397 => Some(TimeZone::EtcGMTPlus12), + 398 => Some(TimeZone::EtcGMTPlus2), + 399 => Some(TimeZone::EtcGMTPlus3), + 400 => Some(TimeZone::EtcGMTPlus4), + 401 => Some(TimeZone::EtcGMTPlus5), + 402 => Some(TimeZone::EtcGMTPlus6), + 403 => Some(TimeZone::EtcGMTPlus7), + 404 => Some(TimeZone::EtcGMTPlus8), + 405 => Some(TimeZone::EtcGMTPlus9), + 406 => Some(TimeZone::EtcGMTMinus0), + 407 => Some(TimeZone::EtcGMTMinus1), + 408 => Some(TimeZone::EtcGMTMinus10), + 409 => Some(TimeZone::EtcGMTMinus11), + 410 => Some(TimeZone::EtcGMTMinus12), + 411 => Some(TimeZone::EtcGMTMinus13), + 412 => Some(TimeZone::EtcGMTMinus14), + 413 => Some(TimeZone::EtcGMTMinus2), + 414 => Some(TimeZone::EtcGMTMinus3), + 415 => Some(TimeZone::EtcGMTMinus4), + 416 => Some(TimeZone::EtcGMTMinus5), + 417 => Some(TimeZone::EtcGMTMinus6), + 418 => Some(TimeZone::EtcGMTMinus7), + 419 => Some(TimeZone::EtcGMTMinus8), + 420 => Some(TimeZone::EtcGMTMinus9), + 421 => Some(TimeZone::EtcGMT0), + 422 => Some(TimeZone::EtcGreenwich), + 423 => Some(TimeZone::EtcUCT), + 424 => Some(TimeZone::EtcUTC), + 425 => Some(TimeZone::EtcUniversal), + 426 => Some(TimeZone::EtcZulu), + 427 => Some(TimeZone::EuropeAmsterdam), + 428 => Some(TimeZone::EuropeAndorra), + 429 => Some(TimeZone::EuropeAstrakhan), + 430 => Some(TimeZone::EuropeAthens), + 431 => Some(TimeZone::EuropeBelfast), + 432 => Some(TimeZone::EuropeBelgrade), + 433 => Some(TimeZone::EuropeBerlin), + 434 => Some(TimeZone::EuropeBratislava), + 435 => Some(TimeZone::EuropeBrussels), + 436 => Some(TimeZone::EuropeBucharest), + 437 => Some(TimeZone::EuropeBudapest), + 438 => Some(TimeZone::EuropeBusingen), + 439 => Some(TimeZone::EuropeChisinau), + 440 => Some(TimeZone::EuropeCopenhagen), + 441 => Some(TimeZone::EuropeDublin), + 442 => Some(TimeZone::EuropeGibraltar), + 443 => Some(TimeZone::EuropeGuernsey), + 444 => Some(TimeZone::EuropeHelsinki), + 445 => Some(TimeZone::EuropeIsleOfMan), + 446 => Some(TimeZone::EuropeIstanbul), + 447 => Some(TimeZone::EuropeJersey), + 448 => Some(TimeZone::EuropeKaliningrad), + 449 => Some(TimeZone::EuropeKiev), + 450 => Some(TimeZone::EuropeKirov), + 451 => Some(TimeZone::EuropeKyiv), + 452 => Some(TimeZone::EuropeLisbon), + 453 => Some(TimeZone::EuropeLjubljana), + 454 => Some(TimeZone::EuropeLondon), + 455 => Some(TimeZone::EuropeLuxembourg), + 456 => Some(TimeZone::EuropeMadrid), + 457 => Some(TimeZone::EuropeMalta), + 458 => Some(TimeZone::EuropeMariehamn), + 459 => Some(TimeZone::EuropeMinsk), + 460 => Some(TimeZone::EuropeMonaco), + 461 => Some(TimeZone::EuropeMoscow), + 462 => Some(TimeZone::EuropeNicosia), + 463 => Some(TimeZone::EuropeOslo), + 464 => Some(TimeZone::EuropeParis), + 465 => Some(TimeZone::EuropePodgorica), + 466 => Some(TimeZone::EuropePrague), + 467 => Some(TimeZone::EuropeRiga), + 468 => Some(TimeZone::EuropeRome), + 469 => Some(TimeZone::EuropeSamara), + 470 => Some(TimeZone::EuropeSanMarino), + 471 => Some(TimeZone::EuropeSarajevo), + 472 => Some(TimeZone::EuropeSaratov), + 473 => Some(TimeZone::EuropeSimferopol), + 474 => Some(TimeZone::EuropeSkopje), + 475 => Some(TimeZone::EuropeSofia), + 476 => Some(TimeZone::EuropeStockholm), + 477 => Some(TimeZone::EuropeTallinn), + 478 => Some(TimeZone::EuropeTirane), + 479 => Some(TimeZone::EuropeTiraspol), + 480 => Some(TimeZone::EuropeUlyanovsk), + 481 => Some(TimeZone::EuropeUzhgorod), + 482 => Some(TimeZone::EuropeVaduz), + 483 => Some(TimeZone::EuropeVatican), + 484 => Some(TimeZone::EuropeVienna), + 485 => Some(TimeZone::EuropeVilnius), + 486 => Some(TimeZone::EuropeVolgograd), + 487 => Some(TimeZone::EuropeWarsaw), + 488 => Some(TimeZone::EuropeZagreb), + 489 => Some(TimeZone::EuropeZaporozhye), + 490 => Some(TimeZone::EuropeZurich), + 491 => Some(TimeZone::Factory), + 492 => Some(TimeZone::GB), + 493 => Some(TimeZone::GBEire), + 494 => Some(TimeZone::GMT), + 495 => Some(TimeZone::GMTPlus0), + 496 => Some(TimeZone::GMTMinus0), + 497 => Some(TimeZone::GMT0), + 498 => Some(TimeZone::Greenwich), + 499 => Some(TimeZone::HST), + 500 => Some(TimeZone::Hongkong), + 501 => Some(TimeZone::Iceland), + 502 => Some(TimeZone::IndianAntananarivo), + 503 => Some(TimeZone::IndianChagos), + 504 => Some(TimeZone::IndianChristmas), + 505 => Some(TimeZone::IndianCocos), + 506 => Some(TimeZone::IndianComoro), + 507 => Some(TimeZone::IndianKerguelen), + 508 => Some(TimeZone::IndianMahe), + 509 => Some(TimeZone::IndianMaldives), + 510 => Some(TimeZone::IndianMauritius), + 511 => Some(TimeZone::IndianMayotte), + 512 => Some(TimeZone::IndianReunion), + 513 => Some(TimeZone::Iran), + 514 => Some(TimeZone::Israel), + 515 => Some(TimeZone::Jamaica), + 516 => Some(TimeZone::Japan), + 517 => Some(TimeZone::Kwajalein), + 518 => Some(TimeZone::Libya), + 519 => Some(TimeZone::MET), + 520 => Some(TimeZone::MST), + 521 => Some(TimeZone::MST7MDT), + 522 => Some(TimeZone::MexicoBajaNorte), + 523 => Some(TimeZone::MexicoBajaSur), + 524 => Some(TimeZone::MexicoGeneral), + 525 => Some(TimeZone::NZ), + 526 => Some(TimeZone::NZCHAT), + 527 => Some(TimeZone::Navajo), + 528 => Some(TimeZone::PRC), + 529 => Some(TimeZone::PST8PDT), + 530 => Some(TimeZone::PacificApia), + 531 => Some(TimeZone::PacificAuckland), + 532 => Some(TimeZone::PacificBougainville), + 533 => Some(TimeZone::PacificChatham), + 534 => Some(TimeZone::PacificChuuk), + 535 => Some(TimeZone::PacificEaster), + 536 => Some(TimeZone::PacificEfate), + 537 => Some(TimeZone::PacificEnderbury), + 538 => Some(TimeZone::PacificFakaofo), + 539 => Some(TimeZone::PacificFiji), + 540 => Some(TimeZone::PacificFunafuti), + 541 => Some(TimeZone::PacificGalapagos), + 542 => Some(TimeZone::PacificGambier), + 543 => Some(TimeZone::PacificGuadalcanal), + 544 => Some(TimeZone::PacificGuam), + 545 => Some(TimeZone::PacificHonolulu), + 546 => Some(TimeZone::PacificJohnston), + 547 => Some(TimeZone::PacificKanton), + 548 => Some(TimeZone::PacificKiritimati), + 549 => Some(TimeZone::PacificKosrae), + 550 => Some(TimeZone::PacificKwajalein), + 551 => Some(TimeZone::PacificMajuro), + 552 => Some(TimeZone::PacificMarquesas), + 553 => Some(TimeZone::PacificMidway), + 554 => Some(TimeZone::PacificNauru), + 555 => Some(TimeZone::PacificNiue), + 556 => Some(TimeZone::PacificNorfolk), + 557 => Some(TimeZone::PacificNoumea), + 558 => Some(TimeZone::PacificPagoPago), + 559 => Some(TimeZone::PacificPalau), + 560 => Some(TimeZone::PacificPitcairn), + 561 => Some(TimeZone::PacificPohnpei), + 562 => Some(TimeZone::PacificPonape), + 563 => Some(TimeZone::PacificPortMoresby), + 564 => Some(TimeZone::PacificRarotonga), + 565 => Some(TimeZone::PacificSaipan), + 566 => Some(TimeZone::PacificSamoa), + 567 => Some(TimeZone::PacificTahiti), + 568 => Some(TimeZone::PacificTarawa), + 569 => Some(TimeZone::PacificTongatapu), + 570 => Some(TimeZone::PacificTruk), + 571 => Some(TimeZone::PacificWake), + 572 => Some(TimeZone::PacificWallis), + 573 => Some(TimeZone::PacificYap), + 574 => Some(TimeZone::Poland), + 575 => Some(TimeZone::Portugal), + 576 => Some(TimeZone::ROC), + 577 => Some(TimeZone::ROK), + 578 => Some(TimeZone::Singapore), + 579 => Some(TimeZone::Turkey), + 580 => Some(TimeZone::UCT), + 581 => Some(TimeZone::USAlaska), + 582 => Some(TimeZone::USAleutian), + 583 => Some(TimeZone::USArizona), + 584 => Some(TimeZone::USCentral), + 585 => Some(TimeZone::USEastIndiana), + 586 => Some(TimeZone::USEastern), + 587 => Some(TimeZone::USHawaii), + 588 => Some(TimeZone::USIndianaStarke), + 589 => Some(TimeZone::USMichigan), + 590 => Some(TimeZone::USMountain), + 591 => Some(TimeZone::USPacific), + 592 => Some(TimeZone::USSamoa), + 593 => Some(TimeZone::UTC), + 594 => Some(TimeZone::Universal), + 595 => Some(TimeZone::WSU), + 596 => Some(TimeZone::WET), + 597 => Some(TimeZone::Zulu), + _ => None, + } + } + + const COUNT: usize = 598; +} + +impl serde::Serialize for TimeZone { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TimeZone { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TlsCipherSuite { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"tls13-aes-256-gcm-sha384" => TlsCipherSuite::Tls13Aes256GcmSha384, + b"tls13-aes-128-gcm-sha256" => TlsCipherSuite::Tls13Aes128GcmSha256, + b"tls13-chacha20-poly1305-sha256" => TlsCipherSuite::Tls13Chacha20Poly1305Sha256, + b"tls-ecdhe-ecdsa-with-aes-256-gcm-sha384" => TlsCipherSuite::TlsEcdheEcdsaWithAes256GcmSha384, + b"tls-ecdhe-ecdsa-with-aes-128-gcm-sha256" => TlsCipherSuite::TlsEcdheEcdsaWithAes128GcmSha256, + b"tls-ecdhe-ecdsa-with-chacha20-poly1305-sha256" => TlsCipherSuite::TlsEcdheEcdsaWithChacha20Poly1305Sha256, + b"tls-ecdhe-rsa-with-aes-256-gcm-sha384" => TlsCipherSuite::TlsEcdheRsaWithAes256GcmSha384, + b"tls-ecdhe-rsa-with-aes-128-gcm-sha256" => TlsCipherSuite::TlsEcdheRsaWithAes128GcmSha256, + b"tls-ecdhe-rsa-with-chacha20-poly1305-sha256" => TlsCipherSuite::TlsEcdheRsaWithChacha20Poly1305Sha256, + } + } + + fn as_str(&self) -> &'static str { + match self { + TlsCipherSuite::Tls13Aes256GcmSha384 => "tls13-aes-256-gcm-sha384", + TlsCipherSuite::Tls13Aes128GcmSha256 => "tls13-aes-128-gcm-sha256", + TlsCipherSuite::Tls13Chacha20Poly1305Sha256 => "tls13-chacha20-poly1305-sha256", + TlsCipherSuite::TlsEcdheEcdsaWithAes256GcmSha384 => { + "tls-ecdhe-ecdsa-with-aes-256-gcm-sha384" + } + TlsCipherSuite::TlsEcdheEcdsaWithAes128GcmSha256 => { + "tls-ecdhe-ecdsa-with-aes-128-gcm-sha256" + } + TlsCipherSuite::TlsEcdheEcdsaWithChacha20Poly1305Sha256 => { + "tls-ecdhe-ecdsa-with-chacha20-poly1305-sha256" + } + TlsCipherSuite::TlsEcdheRsaWithAes256GcmSha384 => { + "tls-ecdhe-rsa-with-aes-256-gcm-sha384" + } + TlsCipherSuite::TlsEcdheRsaWithAes128GcmSha256 => { + "tls-ecdhe-rsa-with-aes-128-gcm-sha256" + } + TlsCipherSuite::TlsEcdheRsaWithChacha20Poly1305Sha256 => { + "tls-ecdhe-rsa-with-chacha20-poly1305-sha256" + } + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TlsCipherSuite::Tls13Aes256GcmSha384), + 1 => Some(TlsCipherSuite::Tls13Aes128GcmSha256), + 2 => Some(TlsCipherSuite::Tls13Chacha20Poly1305Sha256), + 3 => Some(TlsCipherSuite::TlsEcdheEcdsaWithAes256GcmSha384), + 4 => Some(TlsCipherSuite::TlsEcdheEcdsaWithAes128GcmSha256), + 5 => Some(TlsCipherSuite::TlsEcdheEcdsaWithChacha20Poly1305Sha256), + 6 => Some(TlsCipherSuite::TlsEcdheRsaWithAes256GcmSha384), + 7 => Some(TlsCipherSuite::TlsEcdheRsaWithAes128GcmSha256), + 8 => Some(TlsCipherSuite::TlsEcdheRsaWithChacha20Poly1305Sha256), + _ => None, + } + } + + const COUNT: usize = 9; +} + +impl serde::Serialize for TlsCipherSuite { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TlsCipherSuite { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TlsPolicyType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"tlsa" => TlsPolicyType::Tlsa, + b"sts" => TlsPolicyType::Sts, + b"noPolicyFound" => TlsPolicyType::NoPolicyFound, + b"other" => TlsPolicyType::Other, + } + } + + fn as_str(&self) -> &'static str { + match self { + TlsPolicyType::Tlsa => "tlsa", + TlsPolicyType::Sts => "sts", + TlsPolicyType::NoPolicyFound => "noPolicyFound", + TlsPolicyType::Other => "other", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TlsPolicyType::Tlsa), + 1 => Some(TlsPolicyType::Sts), + 2 => Some(TlsPolicyType::NoPolicyFound), + 3 => Some(TlsPolicyType::Other), + _ => None, + } + } + + const COUNT: usize = 4; +} + +impl serde::Serialize for TlsPolicyType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TlsPolicyType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TlsResultType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"startTlsNotSupported" => TlsResultType::StartTlsNotSupported, + b"certificateHostMismatch" => TlsResultType::CertificateHostMismatch, + b"certificateExpired" => TlsResultType::CertificateExpired, + b"certificateNotTrusted" => TlsResultType::CertificateNotTrusted, + b"validationFailure" => TlsResultType::ValidationFailure, + b"tlsaInvalid" => TlsResultType::TlsaInvalid, + b"dnssecInvalid" => TlsResultType::DnssecInvalid, + b"daneRequired" => TlsResultType::DaneRequired, + b"stsPolicyFetchError" => TlsResultType::StsPolicyFetchError, + b"stsPolicyInvalid" => TlsResultType::StsPolicyInvalid, + b"stsWebpkiInvalid" => TlsResultType::StsWebpkiInvalid, + b"other" => TlsResultType::Other, + } + } + + fn as_str(&self) -> &'static str { + match self { + TlsResultType::StartTlsNotSupported => "startTlsNotSupported", + TlsResultType::CertificateHostMismatch => "certificateHostMismatch", + TlsResultType::CertificateExpired => "certificateExpired", + TlsResultType::CertificateNotTrusted => "certificateNotTrusted", + TlsResultType::ValidationFailure => "validationFailure", + TlsResultType::TlsaInvalid => "tlsaInvalid", + TlsResultType::DnssecInvalid => "dnssecInvalid", + TlsResultType::DaneRequired => "daneRequired", + TlsResultType::StsPolicyFetchError => "stsPolicyFetchError", + TlsResultType::StsPolicyInvalid => "stsPolicyInvalid", + TlsResultType::StsWebpkiInvalid => "stsWebpkiInvalid", + TlsResultType::Other => "other", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TlsResultType::StartTlsNotSupported), + 1 => Some(TlsResultType::CertificateHostMismatch), + 2 => Some(TlsResultType::CertificateExpired), + 3 => Some(TlsResultType::CertificateNotTrusted), + 4 => Some(TlsResultType::ValidationFailure), + 5 => Some(TlsResultType::TlsaInvalid), + 6 => Some(TlsResultType::DnssecInvalid), + 7 => Some(TlsResultType::DaneRequired), + 8 => Some(TlsResultType::StsPolicyFetchError), + 9 => Some(TlsResultType::StsPolicyInvalid), + 10 => Some(TlsResultType::StsWebpkiInvalid), + 11 => Some(TlsResultType::Other), + _ => None, + } + } + + const COUNT: usize = 12; +} + +impl serde::Serialize for TlsResultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TlsResultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TlsVersion { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"tls12" => TlsVersion::Tls12, + b"tls13" => TlsVersion::Tls13, + } + } + + fn as_str(&self) -> &'static str { + match self { + TlsVersion::Tls12 => "tls12", + TlsVersion::Tls13 => "tls13", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TlsVersion::Tls12), + 1 => Some(TlsVersion::Tls13), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for TlsVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TlsVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TraceValueType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"String" => TraceValueType::String, + b"UnsignedInt" => TraceValueType::UnsignedInt, + b"Integer" => TraceValueType::Integer, + b"Boolean" => TraceValueType::Boolean, + b"Float" => TraceValueType::Float, + b"UTCDateTime" => TraceValueType::UTCDateTime, + b"Duration" => TraceValueType::Duration, + b"IpAddr" => TraceValueType::IpAddr, + b"List" => TraceValueType::List, + b"Event" => TraceValueType::Event, + b"Null" => TraceValueType::Null, + } + } + + fn as_str(&self) -> &'static str { + match self { + TraceValueType::String => "String", + TraceValueType::UnsignedInt => "UnsignedInt", + TraceValueType::Integer => "Integer", + TraceValueType::Boolean => "Boolean", + TraceValueType::Float => "Float", + TraceValueType::UTCDateTime => "UTCDateTime", + TraceValueType::Duration => "Duration", + TraceValueType::IpAddr => "IpAddr", + TraceValueType::List => "List", + TraceValueType::Event => "Event", + TraceValueType::Null => "Null", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TraceValueType::String), + 1 => Some(TraceValueType::UnsignedInt), + 2 => Some(TraceValueType::Integer), + 3 => Some(TraceValueType::Boolean), + 4 => Some(TraceValueType::Float), + 5 => Some(TraceValueType::UTCDateTime), + 6 => Some(TraceValueType::Duration), + 7 => Some(TraceValueType::IpAddr), + 8 => Some(TraceValueType::List), + 9 => Some(TraceValueType::Event), + 10 => Some(TraceValueType::Null), + _ => None, + } + } + + const COUNT: usize = 11; +} + +impl serde::Serialize for TraceValueType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TraceValueType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TracerType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Log" => TracerType::Log, + b"Stdout" => TracerType::Stdout, + b"Journal" => TracerType::Journal, + b"OtelHttp" => TracerType::OtelHttp, + b"OtelGrpc" => TracerType::OtelGrpc, + } + } + + fn as_str(&self) -> &'static str { + match self { + TracerType::Log => "Log", + TracerType::Stdout => "Stdout", + TracerType::Journal => "Journal", + TracerType::OtelHttp => "OtelHttp", + TracerType::OtelGrpc => "OtelGrpc", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TracerType::Log), + 1 => Some(TracerType::Stdout), + 2 => Some(TracerType::Journal), + 3 => Some(TracerType::OtelHttp), + 4 => Some(TracerType::OtelGrpc), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for TracerType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TracerType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TracingLevel { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"error" => TracingLevel::Error, + b"warn" => TracingLevel::Warn, + b"info" => TracingLevel::Info, + b"debug" => TracingLevel::Debug, + b"trace" => TracingLevel::Trace, + } + } + + fn as_str(&self) -> &'static str { + match self { + TracingLevel::Error => "error", + TracingLevel::Warn => "warn", + TracingLevel::Info => "info", + TracingLevel::Debug => "debug", + TracingLevel::Trace => "trace", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TracingLevel::Error), + 1 => Some(TracingLevel::Warn), + 2 => Some(TracingLevel::Info), + 3 => Some(TracingLevel::Debug), + 4 => Some(TracingLevel::Trace), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for TracingLevel { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TracingLevel { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TracingLevelOpt { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"disable" => TracingLevelOpt::Disable, + b"error" => TracingLevelOpt::Error, + b"warn" => TracingLevelOpt::Warn, + b"info" => TracingLevelOpt::Info, + b"debug" => TracingLevelOpt::Debug, + b"trace" => TracingLevelOpt::Trace, + } + } + + fn as_str(&self) -> &'static str { + match self { + TracingLevelOpt::Disable => "disable", + TracingLevelOpt::Error => "error", + TracingLevelOpt::Warn => "warn", + TracingLevelOpt::Info => "info", + TracingLevelOpt::Debug => "debug", + TracingLevelOpt::Trace => "trace", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TracingLevelOpt::Disable), + 1 => Some(TracingLevelOpt::Error), + 2 => Some(TracingLevelOpt::Warn), + 3 => Some(TracingLevelOpt::Info), + 4 => Some(TracingLevelOpt::Debug), + 5 => Some(TracingLevelOpt::Trace), + _ => None, + } + } + + const COUNT: usize = 6; +} + +impl serde::Serialize for TracingLevelOpt { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TracingLevelOpt { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TracingStoreType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Disabled" => TracingStoreType::Disabled, + b"Default" => TracingStoreType::Default, + b"FoundationDb" => TracingStoreType::FoundationDb, + b"PostgreSql" => TracingStoreType::PostgreSql, + b"MySql" => TracingStoreType::MySql, + } + } + + fn as_str(&self) -> &'static str { + match self { + TracingStoreType::Disabled => "Disabled", + TracingStoreType::Default => "Default", + TracingStoreType::FoundationDb => "FoundationDb", + TracingStoreType::PostgreSql => "PostgreSql", + TracingStoreType::MySql => "MySql", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TracingStoreType::Disabled), + 1 => Some(TracingStoreType::Default), + 2 => Some(TracingStoreType::FoundationDb), + 3 => Some(TracingStoreType::PostgreSql), + 4 => Some(TracingStoreType::MySql), + _ => None, + } + } + + const COUNT: usize = 5; +} + +impl serde::Serialize for TracingStoreType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TracingStoreType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for TsigAlgorithm { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"hmac-md5" => TsigAlgorithm::HmacMd5, + b"gss" => TsigAlgorithm::Gss, + b"hmac-sha1" => TsigAlgorithm::HmacSha1, + b"hmac-sha224" => TsigAlgorithm::HmacSha224, + b"hmac-sha256" => TsigAlgorithm::HmacSha256, + b"hmac-sha256-128" => TsigAlgorithm::HmacSha256128, + b"hmac-sha384" => TsigAlgorithm::HmacSha384, + b"hmac-sha384-192" => TsigAlgorithm::HmacSha384192, + b"hmac-sha512" => TsigAlgorithm::HmacSha512, + b"hmac-sha512-256" => TsigAlgorithm::HmacSha512256, + } + } + + fn as_str(&self) -> &'static str { + match self { + TsigAlgorithm::HmacMd5 => "hmac-md5", + TsigAlgorithm::Gss => "gss", + TsigAlgorithm::HmacSha1 => "hmac-sha1", + TsigAlgorithm::HmacSha224 => "hmac-sha224", + TsigAlgorithm::HmacSha256 => "hmac-sha256", + TsigAlgorithm::HmacSha256128 => "hmac-sha256-128", + TsigAlgorithm::HmacSha384 => "hmac-sha384", + TsigAlgorithm::HmacSha384192 => "hmac-sha384-192", + TsigAlgorithm::HmacSha512 => "hmac-sha512", + TsigAlgorithm::HmacSha512256 => "hmac-sha512-256", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(TsigAlgorithm::HmacMd5), + 1 => Some(TsigAlgorithm::Gss), + 2 => Some(TsigAlgorithm::HmacSha1), + 3 => Some(TsigAlgorithm::HmacSha224), + 4 => Some(TsigAlgorithm::HmacSha256), + 5 => Some(TsigAlgorithm::HmacSha256128), + 6 => Some(TsigAlgorithm::HmacSha384), + 7 => Some(TsigAlgorithm::HmacSha384192), + 8 => Some(TsigAlgorithm::HmacSha512), + 9 => Some(TsigAlgorithm::HmacSha512256), + _ => None, + } + } + + const COUNT: usize = 10; +} + +impl serde::Serialize for TsigAlgorithm { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for TsigAlgorithm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for UserRolesType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"User" => UserRolesType::User, + b"Admin" => UserRolesType::Admin, + b"Custom" => UserRolesType::Custom, + } + } + + fn as_str(&self) -> &'static str { + match self { + UserRolesType::User => "User", + UserRolesType::Admin => "Admin", + UserRolesType::Custom => "Custom", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(UserRolesType::User), + 1 => Some(UserRolesType::Admin), + 2 => Some(UserRolesType::Custom), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for UserRolesType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for UserRolesType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for VCardVersion { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"v4" => VCardVersion::V4, + b"v3" => VCardVersion::V3, + } + } + + fn as_str(&self) -> &'static str { + match self { + VCardVersion::V4 => "v4", + VCardVersion::V3 => "v3", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(VCardVersion::V4), + 1 => Some(VCardVersion::V3), + _ => None, + } + } + + const COUNT: usize = 2; +} + +impl serde::Serialize for VCardVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for VCardVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} diff --git a/crates/registry/src/schema/mod.rs b/crates/registry/src/schema/mod.rs new file mode 100644 index 0000000..4ee88b4 --- /dev/null +++ b/crates/registry/src/schema/mod.rs @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + schema::{ + enums::{TracingLevel, TracingLevelOpt}, + prelude::{Object, ObjectInner, Property}, + }, + types::EnumImpl, +}; +use std::{cmp::Ordering, fmt::Display}; +use trc::TOTAL_EVENT_COUNT; + +#[allow(clippy::derivable_impls)] +pub mod enums; +pub mod enums_impl; +pub mod prelude; +#[allow(clippy::large_enum_variant)] +pub mod properties; +pub mod properties_impl; +#[allow(clippy::large_enum_variant)] +pub mod structs; +#[allow(clippy::needless_borrows_for_generic_args)] +#[allow(clippy::len_zero)] +#[allow(clippy::collapsible_if)] +#[allow(clippy::derivable_impls)] +#[allow(clippy::field_reassign_with_default)] +pub mod structs_impl; + +impl Display for Property { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl From for trc::Level { + fn from(level: TracingLevelOpt) -> Self { + match level { + TracingLevelOpt::Error => trc::Level::Error, + TracingLevelOpt::Warn => trc::Level::Warn, + TracingLevelOpt::Info => trc::Level::Info, + TracingLevelOpt::Debug => trc::Level::Debug, + TracingLevelOpt::Trace => trc::Level::Trace, + TracingLevelOpt::Disable => trc::Level::Disable, + } + } +} + +impl From for trc::Level { + fn from(level: TracingLevel) -> Self { + match level { + TracingLevel::Error => trc::Level::Error, + TracingLevel::Warn => trc::Level::Warn, + TracingLevel::Info => trc::Level::Info, + TracingLevel::Debug => trc::Level::Debug, + TracingLevel::Trace => trc::Level::Trace, + } + } +} + +impl EnumImpl for trc::EventType { + const COUNT: usize = TOTAL_EVENT_COUNT; + + fn parse(s: &str) -> Option { + trc::EventType::parse(s) + } + + fn as_str(&self) -> &'static str { + trc::EventType::as_str(self) + } + + fn from_id(id: u16) -> Option { + trc::EventType::from_id(id) + } + + fn to_id(&self) -> u16 { + trc::EventType::to_id(self) + } +} + +impl EnumImpl for trc::MetricType { + const COUNT: usize = TOTAL_EVENT_COUNT; + + fn parse(s: &str) -> Option { + trc::MetricType::parse(s) + } + + fn as_str(&self) -> &'static str { + trc::MetricType::as_str(self) + } + + fn from_id(id: u16) -> Option { + trc::MetricType::from_id(id) + } + + fn to_id(&self) -> u16 { + trc::MetricType::to_id(self) + } +} + +impl PartialOrd for Property { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Property { + fn cmp(&self, other: &Self) -> Ordering { + self.to_id().cmp(&other.to_id()) + } +} + +impl> From for Object { + fn from(value: T) -> Self { + Object { + inner: value.into(), + revision: 0, + } + } +} + +impl Object { + pub fn new(inner: ObjectInner) -> Self { + Object { inner, revision: 0 } + } + + pub fn with_revision(inner: ObjectInner, revision: u64) -> Self { + Object { inner, revision } + } +} + +impl From for String { + fn from(value: Property) -> Self { + value.as_str().to_string() + } +} diff --git a/crates/registry/src/schema/prelude.rs b/crates/registry/src/schema/prelude.rs new file mode 100644 index 0000000..1567196 --- /dev/null +++ b/crates/registry/src/schema/prelude.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ +pub use crate::jmap::IntoValue; +pub use crate::jmap::JmapValue; +pub use crate::jmap::MaybeUnpatched; +pub use crate::jmap::PatchResult; +pub use crate::jmap::RegistryJsonEnumPatch; +pub use crate::jmap::{ + JsonPointerPatch, RegistryJsonPatch, RegistryJsonPropertyPatch, patch::object_type, +}; +pub use crate::pickle::Pickle; +pub use crate::schema::enums::*; +pub use crate::schema::properties::*; +pub use crate::schema::structs::*; +pub use crate::types::EnumImpl; +pub use crate::types::ObjectImpl; +pub use crate::types::datetime::UTCDateTime; +pub use crate::types::duration::Duration; +pub use crate::types::error::*; +pub use crate::types::float::Float; +pub use crate::types::index::{IndexBuilder, IndexSchema, IndexSchemaType, IndexSchemaValueType}; +pub use crate::types::ipaddr::IpAddr; +pub use crate::types::ipmask::IpAddrOrMask; +pub use crate::types::list::List; +pub use crate::types::map::Map; +pub use crate::types::socketaddr::SocketAddr; +pub use crate::types::string::StringValidator; +pub use serde::{Deserialize, Serialize}; +pub use std::borrow::Cow; +pub use std::str::FromStr; +pub use types::blob::BlobId; +pub use types::id::Id; +pub use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone)] +pub struct Object { + pub inner: ObjectInner, + pub revision: u64, +} + +#[derive(Debug)] +pub struct ExpressionContext<'x> { + pub expr: &'x Expression, + pub default: Option, + pub property: Property, + pub allowed_variables: &'static [ExpressionVariable], + pub allowed_constants: &'static [ExpressionConstant], +} + +pub const OBJ_SINGLETON: u64 = 1; +pub const OBJ_SEQ_ID: u64 = 1 << 1; +pub const OBJ_FILTER_ACCOUNT: u64 = 1 << 2; +pub const OBJ_FILTER_TENANT: u64 = 1 << 3; + +pub const MASKED_PASSWORD: &str = "****"; diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs new file mode 100644 index 0000000..1580020 --- /dev/null +++ b/crates/registry/src/schema/properties.rs @@ -0,0 +1,1191 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::schema::prelude::*; +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ObjectInner { + Account(Account), + AccountPassword(AccountPassword), + AccountSettings(AccountSettings), + AcmeProvider(AcmeProvider), + Action(Action), + AddressBook(AddressBook), + AiModel(AiModel), + Alert(Alert), + AllowedIp(AllowedIp), + ApiKey(ApiKey), + AppPassword(AppPassword), + Application(Application), + ArchivedItem(ArchivedItem), + ArfExternalReport(ArfExternalReport), + Asn(Asn), + Authentication(Authentication), + BlobStore(BlobStore), + BlockedIp(BlockedIp), + Bootstrap(Bootstrap), + Cache(Cache), + Calendar(Calendar), + CalendarAlarm(CalendarAlarm), + CalendarScheduling(CalendarScheduling), + Certificate(Certificate), + ClusterNode(ClusterNode), + ClusterRole(ClusterRole), + Coordinator(Coordinator), + DataRetention(DataRetention), + DataStore(DataStore), + Directory(Directory), + DkimReportSettings(DkimReportSettings), + DkimSignature(DkimSignature), + DmarcExternalReport(DmarcExternalReport), + DmarcInternalReport(DmarcInternalReport), + DmarcReportSettings(DmarcReportSettings), + DnsResolver(DnsResolver), + DnsServer(DnsServer), + Domain(Domain), + DsnReportSettings(DsnReportSettings), + Email(Email), + Enterprise(Enterprise), + EventTracingLevel(EventTracingLevel), + FileStorage(FileStorage), + Http(Http), + HttpForm(HttpForm), + HttpLookup(HttpLookup), + Imap(Imap), + InMemoryStore(InMemoryStore), + Jmap(Jmap), + Log(Log), + MailingList(MailingList), + MaskedEmail(MaskedEmail), + MemoryLookupKey(MemoryLookupKey), + MemoryLookupKeyValue(MemoryLookupKeyValue), + Metric(Metric), + Metrics(Metrics), + MetricsStore(MetricsStore), + MtaConnectionStrategy(MtaConnectionStrategy), + MtaDeliverySchedule(MtaDeliverySchedule), + MtaExtensions(MtaExtensions), + MtaHook(MtaHook), + MtaInboundSession(MtaInboundSession), + MtaInboundThrottle(MtaInboundThrottle), + MtaMilter(MtaMilter), + MtaOutboundStrategy(MtaOutboundStrategy), + MtaOutboundThrottle(MtaOutboundThrottle), + MtaQueueQuota(MtaQueueQuota), + MtaRoute(MtaRoute), + MtaStageAuth(MtaStageAuth), + MtaStageConnect(MtaStageConnect), + MtaStageData(MtaStageData), + MtaStageEhlo(MtaStageEhlo), + MtaStageMail(MtaStageMail), + MtaStageRcpt(MtaStageRcpt), + MtaSts(MtaSts), + MtaTlsStrategy(MtaTlsStrategy), + MtaVirtualQueue(MtaVirtualQueue), + NetworkListener(NetworkListener), + OAuthClient(OAuthClient), + OidcProvider(OidcProvider), + PublicKey(PublicKey), + QueuedMessage(QueuedMessage), + ReportSettings(ReportSettings), + Role(Role), + Search(Search), + SearchStore(SearchStore), + Security(Security), + SenderAuth(SenderAuth), + Sharing(Sharing), + SieveSystemInterpreter(SieveSystemInterpreter), + SieveSystemScript(SieveSystemScript), + SieveUserInterpreter(SieveUserInterpreter), + SieveUserScript(SieveUserScript), + SpamClassifier(SpamClassifier), + SpamDnsblServer(SpamDnsblServer), + SpamDnsblSettings(SpamDnsblSettings), + SpamFileExtension(SpamFileExtension), + SpamLlm(SpamLlm), + SpamPyzor(SpamPyzor), + SpamRule(SpamRule), + SpamSettings(SpamSettings), + SpamTag(SpamTag), + SpamTrainingSample(SpamTrainingSample), + SpfReportSettings(SpfReportSettings), + StoreLookup(StoreLookup), + SystemSettings(SystemSettings), + Task(Task), + TaskManager(TaskManager), + Tenant(Tenant), + TlsExternalReport(TlsExternalReport), + TlsInternalReport(TlsInternalReport), + TlsReportSettings(TlsReportSettings), + Trace(Trace), + Tracer(Tracer), + TracingStore(TracingStore), + WebDav(WebDav), + WebHook(WebHook), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ObjectType { + Account = 0, + AccountPassword = 1, + AccountSettings = 2, + AcmeProvider = 3, + Action = 4, + AddressBook = 5, + AiModel = 6, + Alert = 7, + AllowedIp = 8, + ApiKey = 9, + AppPassword = 10, + Application = 11, + ArchivedItem = 12, + ArfExternalReport = 13, + Asn = 14, + Authentication = 15, + BlobStore = 16, + BlockedIp = 17, + Bootstrap = 18, + Cache = 19, + Calendar = 20, + CalendarAlarm = 21, + CalendarScheduling = 22, + Certificate = 23, + ClusterNode = 24, + ClusterRole = 25, + Coordinator = 26, + DataRetention = 27, + DataStore = 28, + Directory = 29, + DkimReportSettings = 30, + DkimSignature = 31, + DmarcExternalReport = 32, + DmarcInternalReport = 33, + DmarcReportSettings = 34, + DnsResolver = 35, + DnsServer = 36, + Domain = 37, + DsnReportSettings = 38, + Email = 39, + Enterprise = 40, + EventTracingLevel = 41, + FileStorage = 42, + Http = 43, + HttpForm = 44, + HttpLookup = 45, + Imap = 46, + InMemoryStore = 47, + Jmap = 48, + Log = 49, + MailingList = 50, + MaskedEmail = 51, + MemoryLookupKey = 52, + MemoryLookupKeyValue = 53, + Metric = 54, + Metrics = 55, + MetricsStore = 56, + MtaConnectionStrategy = 57, + MtaDeliverySchedule = 58, + MtaExtensions = 59, + MtaHook = 60, + MtaInboundSession = 61, + MtaInboundThrottle = 62, + MtaMilter = 63, + MtaOutboundStrategy = 64, + MtaOutboundThrottle = 65, + MtaQueueQuota = 66, + MtaRoute = 67, + MtaStageAuth = 68, + MtaStageConnect = 69, + MtaStageData = 70, + MtaStageEhlo = 71, + MtaStageMail = 72, + MtaStageRcpt = 73, + MtaSts = 74, + MtaTlsStrategy = 75, + MtaVirtualQueue = 76, + NetworkListener = 77, + OAuthClient = 78, + OidcProvider = 79, + PublicKey = 80, + QueuedMessage = 81, + ReportSettings = 82, + Role = 83, + Search = 84, + SearchStore = 85, + Security = 86, + SenderAuth = 87, + Sharing = 88, + SieveSystemInterpreter = 89, + SieveSystemScript = 90, + SieveUserInterpreter = 91, + SieveUserScript = 92, + SpamClassifier = 93, + SpamDnsblServer = 94, + SpamDnsblSettings = 95, + SpamFileExtension = 96, + SpamLlm = 97, + SpamPyzor = 98, + SpamRule = 99, + SpamSettings = 100, + SpamTag = 101, + SpamTrainingSample = 102, + SpfReportSettings = 103, + StoreLookup = 104, + SystemSettings = 105, + Task = 106, + TaskManager = 107, + Tenant = 108, + TlsExternalReport = 109, + TlsInternalReport = 110, + TlsReportSettings = 111, + Trace = 112, + Tracer = 113, + TracingStore = 114, + WebDav = 115, + WebHook = 116, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Property { + Type = 0, + AbuseBanPeriod = 678, + AbuseBanRate = 677, + AccessKey = 118, + AccessKeyId = 327, + AccessToken = 890, + AccessTokenExpiry = 619, + AccessTokens = 132, + AccountDomainId = 810, + AccountId = 57, + AccountIdentifier = 315, + AccountKey = 15, + AccountName = 809, + AccountSwitchKey = 891, + AccountType = 811, + AccountUri = 16, + Accounts = 151, + AcmeProviderId = 182, + AddAuthResultsHeader = 554, + AddDateHeader = 555, + AddDeliveredToHeader = 556, + AddMessageIdHeader = 557, + AddReceivedHeader = 558, + AddReceivedSpfHeader = 559, + AddReturnPathHeader = 560, + AdditionalInformation = 838, + Address = 44, + Addresses = 579, + AggregateContactInfo = 268, + AggregateDkimSignDomain = 274, + AggregateFromAddress = 269, + AggregateFromName = 270, + AggregateMaxReportSize = 271, + AggregateOrgName = 272, + AggregateSendFrequency = 273, + AggregateSubject = 275, + AlarmId = 798, + Algorithms = 225, + Aliases = 339, + AllowCount = 768, + AllowDirectoryQueries = 695, + AllowExternalRcpts = 164, + AllowInvalidCerts = 26, + AllowPlainTextAuth = 424, + AllowRelaying = 348, + AllowScimProvisioning = 932, + AllowSpamTraining = 369, + AllowedEndpoints = 398, + AllowedIps = 49, + AllowedNotifyUris = 712, + Alpha = 388, + AnonymousClientRegistration = 614, + Ansi = 858, + ApiKey = 325, + ApiUser = 892, + ApplicationKey = 321, + ApplicationSecret = 322, + ArcResult = 292, + ArcVerify = 690, + ArchiveDeletedAccountsFor = 203, + ArchiveDeletedItemsFor = 202, + ArchivedAt = 58, + ArchivedItemType = 820, + ArchivedUntil = 59, + ArrivalDate = 68, + AsnUrls = 102, + AttemptNumber = 829, + Attempts = 303, + AttrClass = 470, + AttrDescription = 471, + AttrEmail = 472, + AttrEmailAlias = 473, + AttrMemberOf = 474, + AttrSecret = 475, + AttrSecretChanged = 476, + Auid = 215, + Auth = 897, + AuthBanPeriod = 680, + AuthBanRate = 679, + AuthCodeExpiry = 616, + AuthCodeMaxAttempts = 613, + AuthFailure = 81, + AuthId = 886, + AuthSecret = 501, + AuthToken = 314, + AuthUsername = 502, + AuthenticatedAs = 740, + AuthenticationResults = 69, + AutoAddInvitations = 171, + AutoUpdateFrequency = 53, + BaseDn = 463, + BaseUrl = 882, + BearerToken = 403, + Beta = 389, + Bind = 589, + BindAuthentication = 466, + BindDn = 464, + BindSecret = 465, + BlobCleanupSchedule = 200, + BlobId = 60, + BlobSize = 655, + BlobStore = 126, + BlockCount = 766, + Body = 38, + Brokers = 459, + Bucket = 658, + BufferSize = 656, + Buffered = 863, + CacheSize = 929, + Canonicalization = 216, + CapacityClient = 584, + CapacityReadBuffer = 585, + CapacitySubscription = 586, + CatchAllAddress = 346, + Categories = 759, + Certificate = 176, + CertificateManagement = 342, + ChallengeType = 10, + ChangesMaxResults = 435, + Chunking = 517, + ClaimGroups = 612, + ClaimName = 611, + ClaimUsername = 609, + Cleartext = 693, + ClientId = 604, + ClientIp = 898, + ClientSecret = 878, + ClientToken = 889, + ClusterFile = 382, + ColumnClass = 781, + ColumnDescription = 782, + ColumnEmail = 779, + ColumnSecret = 780, + Comment = 240, + CompartmentOcid = 905, + CompressionAlgorithm = 359, + Concurrency = 304, + Condition = 34, + Confidence = 760, + Config = 873, + ConfigName = 883, + ConnectTimeout = 505, + Connection = 539, + ConsumerKey = 323, + Contact = 11, + ContactInfo = 844, + Contacts = 133, + Container = 117, + Content = 65, + ContentTypes = 758, + Contents = 708, + Context = 877, + Count = 258, + Create = 367, + CreatedAt = 46, + CreatedBy = 486, + CredentialId = 627, + Credentials = 588, + CurrentSecret = 4, + CustomEndpoint = 662, + CustomRegion = 663, + CustomRule = 787, + CustomerNumber = 899, + Dane = 569, + DataCleanupSchedule = 199, + DataStore = 125, + DataTimeout = 506, + Database = 575, + DatacenterId = 383, + DateRangeBegin = 245, + DateRangeEnd = 246, + DateRangeStart = 845, + Day = 192, + DeadPropertyMaxSize = 868, + DefaultAdminRoleIds = 108, + DefaultCertificateId = 790, + DefaultDisplayName = 20, + DefaultDomain = 122, + DefaultDomainId = 789, + DefaultExpiryDuplicate = 709, + DefaultExpiryVacation = 710, + DefaultFolders = 360, + DefaultFromAddress = 405, + DefaultFromName = 697, + DefaultGroupRoleIds = 106, + DefaultHostname = 788, + DefaultHrefName = 21, + DefaultLanguage = 665, + DefaultName = 408, + DefaultReturnPath = 701, + DefaultSubject = 411, + DefaultSubjectPrefix = 714, + DefaultTenantRoleIds = 107, + DefaultUserRoleIds = 105, + Definition = 235, + Delay = 825, + DeleteAfter = 229, + DeleteAfterUse = 777, + DeliverAt = 238, + DeliverBy = 518, + DeliverTo = 404, + DeliveryResult = 82, + Depth = 381, + Description = 6, + Details = 297, + Directory = 12, + DirectoryId = 104, + DisableCapabilities = 711, + DisabledPermissions = 629, + DiscardAfter = 872, + Disposition = 747, + Dkim2Pass = 917, + Dkim2Result = 916, + DkimAdspDns = 83, + DkimCanonicalizedBody = 84, + DkimCanonicalizedHeader = 85, + DkimDomain = 86, + DkimIdentity = 87, + DkimManagement = 343, + DkimPass = 291, + DkimResults = 266, + DkimSelector = 88, + DkimSelectorDns = 89, + DkimSignDomain = 231, + DkimSignatures = 155, + DkimStrict = 686, + DkimVerify = 687, + DmarcPass = 294, + DmarcPolicy = 295, + DmarcResult = 293, + DmarcVerify = 691, + DnsIpv4 = 134, + DnsIpv6 = 135, + DnsManagement = 344, + DnsMtaSts = 136, + DnsMx = 137, + DnsPtr = 138, + DnsRbl = 139, + DnsServer = 130, + DnsServerId = 300, + DnsTlsa = 140, + DnsTxt = 141, + DnsView = 894, + DnsZoneFile = 345, + DocumentId = 804, + DocumentType = 814, + Domain = 232, + DomainId = 221, + DomainLimit = 750, + DomainNames = 147, + DomainNamesNegative = 148, + Domains = 146, + Dsn = 519, + Due = 797, + DuplicateExpiry = 699, + Duration = 515, + EabHmacKey = 13, + EabKeyId = 14, + EhloDomain = 283, + EhloHostname = 503, + EhloTimeout = 507, + Elapsed = 296, + Else = 375, + Email = 242, + EmailAddress = 393, + EmailAddresses = 149, + EmailAddressesNegative = 150, + EmailAlert = 35, + EmailDomain = 488, + EmailLimit = 751, + EmailPrefix = 487, + EmailTemplate = 174, + Enable = 37, + EnableAssistedDiscovery = 865, + EnableEdns = 305, + EnableHsts = 399, + EnableLogExporter = 860, + EnableSpamFilter = 562, + EnableSpanExporter = 861, + Enabled = 50, + EnabledPermissions = 628, + EncryptAtRest = 358, + EncryptOnAppend = 357, + EncryptionAtRest = 9, + EncryptionKey = 622, + Endpoint = 499, + EnvFrom = 742, + EnvFromParameters = 743, + EnvId = 639, + EnvRcptTo = 744, + EnvelopeFrom = 264, + EnvelopeTo = 263, + Environment = 881, + ErrorCommand = 210, + ErrorMessage = 209, + ErrorType = 208, + Errors = 247, + EvaluatedDisposition = 259, + EvaluatedDkim = 260, + EvaluatedSpf = 261, + Event = 372, + EventAlert = 36, + EventEnd = 801, + EventEndTz = 803, + EventId = 799, + EventMessage = 43, + EventSourceThrottle = 447, + EventStart = 800, + EventStartTz = 802, + Events = 142, + EventsPolicy = 855, + Expire = 217, + Expires = 100, + ExpiresAt = 47, + ExpiresAttempts = 632, + Expiry = 512, + Expn = 520, + ExpungeSchedule = 198, + ExpungeSchedulingInboxAfter = 197, + ExpungeShareNotifyAfter = 196, + ExpungeSubmissionsAfter = 195, + ExpungeTrashAfter = 194, + Extension = 754, + Extensions = 257, + ExternalId = 933, + ExtraContactInfo = 243, + Factor = 821, + FailOnTimeout = 490, + FailedAt = 826, + FailedAttemptNumber = 827, + FailedSessionCount = 837, + FailureDetails = 851, + FailureDkimSignDomain = 279, + FailureFromAddress = 276, + FailureFromName = 277, + FailureReason = 828, + FailureReasonCode = 839, + FailureSendFrequency = 278, + FailureSubject = 280, + FeatureL2Normalize = 738, + FeatureLogScale = 739, + FeedbackType = 67, + FieldEmail = 406, + FieldHoneyPot = 407, + FieldName = 409, + FieldSubject = 412, + FilePath = 676, + Files = 144, + FilterLogin = 467, + FilterMailbox = 468, + FilterMemberOf = 469, + Fingerprint = 902, + Flags = 638, + FlagsAction = 537, + FlagsProtocol = 538, + FolderId = 909, + ForDomain = 485, + Format = 415, + From = 62, + FromAddress = 39, + FromEmail = 165, + FromName = 40, + FutureRelease = 521, + GenerateDkimKeys = 124, + Generator = 918, + GeoUrls = 103, + GetMaxResults = 436, + GreetingTimeout = 508, + GreylistFor = 770, + GroupClass = 477, + GroupId = 460, + HeaderFrom = 265, + Headers = 93, + HoldMetricsFor = 206, + HoldMtaReportsFor = 204, + HoldSamplesFor = 730, + HoldTracesFor = 205, + Host = 333, + HostedZoneId = 331, + Hostname = 185, + Hour = 190, + HttpAuth = 32, + HttpHeaders = 33, + HttpRsvpEnable = 168, + HttpRsvpLinkExpiry = 169, + HttpRsvpTemplate = 175, + HttpRsvpUrl = 170, + HttpRua = 842, + HumanResult = 234, + ICalendarData = 807, + Id = 1, + IdTokenExpiry = 621, + IdentityAlignment = 91, + If = 376, + ImpersonateServiceAccount = 320, + ImplicitTls = 546, + InMemoryStore = 128, + InboundReportAddresses = 651, + InboundReportForwarding = 652, + InboundReportMaxSize = 928, + Incidents = 70, + IncludeSource = 352, + IndexAsn = 94, + IndexAsnName = 95, + IndexBatchSize = 664, + IndexCalendar = 667, + IndexCalendarFields = 668, + IndexContactFields = 670, + IndexContacts = 669, + IndexCountry = 96, + IndexEmail = 671, + IndexEmailFields = 672, + IndexKey = 421, + IndexTelemetry = 673, + IndexTracingFields = 674, + IndexValue = 422, + IndicatorParameters = 736, + InitialDelay = 822, + Interval = 500, + Intervals = 516, + IpLimit = 752, + IpLookupStrategy = 543, + IpRevPtr = 290, + IpRevResult = 289, + IsActive = 707, + IsArchive = 755, + IsBad = 756, + IsEnabled = 340, + IsFromOrganizer = 806, + IsGlobPattern = 491, + IsGzipped = 416, + IsNz = 757, + IsSenderAllowed = 564, + IsSpam = 776, + IsTls = 741, + Issuer = 181, + IssuerUrl = 606, + ItipMaxSize = 172, + Jitter = 824, + Key = 334, + KeyName = 337, + KeyPrefix = 120, + KeyValues = 853, + L1Ratio = 391, + L2Ratio = 392, + LastRenewal = 186, + LearnHamFromCard = 727, + LearnHamFromReply = 735, + LearnSpamFromRblHits = 728, + LearnSpamFromTraps = 729, + Level = 373, + LicenseKey = 370, + Line = 875, + ListenerIds = 183, + Listeners = 188, + LivePropertyMaxSize = 869, + Locale = 7, + Logo = 341, + LogoUrl = 371, + LoiterBanPeriod = 682, + LoiterBanRate = 681, + Lossy = 854, + MachineId = 384, + MailExchangers = 793, + MailFrom = 284, + MailFromTimeout = 509, + MailRua = 841, + MailingLists = 154, + MaintenanceType = 796, + ManagedZone = 318, + Match = 374, + MaxAddressBooks = 23, + MaxAge = 566, + MaxAllowedPacket = 576, + MaxApiKeys = 115, + MaxAppPasswords = 114, + MaxAttachmentSize = 353, + MaxAttempts = 511, + MaxAttendees = 157, + MaxAuthFailures = 425, + MaxCalendars = 160, + MaxChangesHistory = 201, + MaxConcurrent = 426, + MaxConcurrentRequests = 439, + MaxConcurrentUploads = 442, + MaxConnections = 603, + MaxContacts = 24, + MaxCpuCycles = 702, + MaxDelay = 823, + MaxDuration = 530, + MaxEntries = 417, + MaxEntrySize = 418, + MaxEventNotifications = 163, + MaxEvents = 161, + MaxFailures = 547, + MaxFiles = 378, + MaxFolders = 379, + MaxHeaderSize = 715, + MaxICalendarSize = 159, + MaxIdentities = 363, + MaxIncludes = 716, + MaxLocalVars = 717, + MaxLockTimeout = 866, + MaxLocks = 867, + MaxMailboxDepth = 355, + MaxMailboxNameLength = 356, + MaxMailboxes = 364, + MaxMaskedAddresses = 365, + MaxMatchVars = 718, + MaxMessageSize = 354, + MaxMessages = 361, + MaxMessagesPerCommand = 924, + MaxMessagesPerSave = 927, + MaxMethodCalls = 438, + MaxMultihomed = 544, + MaxMxHosts = 545, + MaxNestedBlocks = 720, + MaxNestedForEvery = 721, + MaxNestedIncludes = 703, + MaxNestedTests = 722, + MaxOutMessages = 704, + MaxParticipantIdentities = 162, + MaxPublicKeys = 366, + MaxPushSize = 923, + MaxReceivedHeaders = 561, + MaxRecipients = 173, + MaxReconnects = 580, + MaxRecurrenceExpansions = 158, + MaxRedirects = 705, + MaxReportSize = 852, + MaxRequestRate = 427, + MaxRequestSize = 428, + MaxResponseSize = 527, + MaxResults = 871, + MaxRetries = 18, + MaxRetryWait = 648, + MaxScriptNameLength = 719, + MaxScriptSize = 723, + MaxScripts = 726, + MaxShares = 696, + MaxSize = 101, + MaxStringLength = 724, + MaxSubmissions = 362, + MaxSubscriptions = 458, + MaxUidBatches = 926, + MaxUploadCount = 444, + MaxUploadSize = 443, + MaxVCardSize = 22, + MaxVarNameLength = 725, + MaxVarSize = 706, + MemberGroupIds = 864, + MemberTenantId = 19, + Message = 92, + MessageIdHostname = 698, + MessageIds = 819, + Messages = 145, + Metric = 493, + Metrics = 497, + MetricsCollectionInterval = 207, + MetricsPolicy = 498, + MinHamSamples = 731, + MinRetryWait = 649, + MinSpamSamples = 732, + MinTriggerInterval = 166, + MinUidBatchSize = 925, + Minute = 191, + Mode = 567, + Model = 28, + ModelId = 764, + ModelType = 30, + MtPriority = 522, + MtaSts = 570, + MtaStsTimeout = 572, + Multiline = 859, + MustMatchSender = 550, + MxHosts = 568, + Name = 25, + Namespace = 414, + NegativeTtl = 156, + NextNotify = 634, + NextRetry = 633, + NextTransitionAt = 223, + NoCapabilityCheck = 700, + NoEcho = 587, + NoSoliciting = 523, + NodeId = 184, + NotValidAfter = 179, + NotValidBefore = 180, + Notify = 513, + NotifyCount = 642, + NotifyDue = 643, + NumFeatures = 390, + NumReplicas = 350, + NumShards = 351, + OauthClientId = 930, + OnSuccessRenewCertificate = 813, + OpenTelemetry = 495, + Options = 630, + Orcpt = 645, + OrgName = 241, + OrganizationName = 843, + Origin = 301, + OriginalEnvelopeId = 71, + OriginalMailFrom = 72, + OriginalRcptTo = 73, + OtpAuth = 5, + OtpCode = 625, + OtpUrl = 626, + OutboundReportDomain = 653, + OutboundReportSubmitter = 654, + OverrideProxyTrustedNetworks = 590, + OverrideType = 239, + OvhEndpoint = 324, + Parameters = 737, + ParseLimitContact = 433, + ParseLimitEmail = 434, + ParseLimitEvent = 432, + Password = 876, + PasswordDefaultExpiry = 113, + PasswordHashAlgorithm = 109, + PasswordMaxLength = 111, + PasswordMinLength = 110, + PasswordMinStrength = 112, + Path = 380, + Period = 646, + Permissions = 48, + PingInterval = 583, + Pipelining = 524, + Policies = 846, + PolicyAdkim = 250, + PolicyAspf = 251, + PolicyDiscoveryMethod = 920, + PolicyDisposition = 252, + PolicyDomain = 248, + PolicyFailureReportingOptions = 255, + PolicyIdentifier = 237, + PolicyIdentifiers = 840, + PolicyNp = 919, + PolicyOverrideReasons = 262, + PolicyStrings = 848, + PolicySubdomainDisposition = 253, + PolicyTestingMode = 254, + PolicyType = 847, + PolicyVersion = 249, + PollInterval = 489, + PollingInterval = 311, + PoolMaxConnections = 478, + PoolMinConnections = 577, + PoolRecyclingMethod = 631, + PoolTimeoutCreate = 479, + PoolTimeoutRecycle = 480, + PoolTimeoutWait = 481, + PoolWorkers = 657, + Port = 299, + PreferredChain = 910, + Prefix = 856, + PreserveIntermediates = 306, + Priority = 483, + PrivateKey = 177, + PrivateKeyPassword = 904, + PrivateKeyPem = 903, + PrivateZone = 319, + PrivateZoneOnly = 332, + Profile = 661, + ProjectId = 317, + Prometheus = 496, + Prompt = 765, + PropagationDelay = 313, + PropagationTimeout = 312, + ProtectedHeaders = 713, + Protocol = 298, + ProtocolVersion = 533, + ProviderInfo = 795, + ProxyTrustedNetworks = 792, + PublicKey = 218, + PublishRecords = 302, + PushAttemptWait = 448, + PushMaxAttempts = 449, + PushRequestTimeout = 452, + PushRetryWait = 450, + PushShardsTotal = 454, + PushThrottle = 451, + PushVerifyTimeout = 453, + QueryEmailAliases = 786, + QueryLogin = 783, + QueryMaxResults = 437, + QueryMemberOf = 785, + QueryRecipient = 784, + QueueId = 514, + QueueName = 644, + Quotas = 394, + Rate = 532, + RateLimit = 410, + RateLimitAnonymous = 397, + RateLimitAuthenticated = 396, + Ratio = 767, + RcptToTimeout = 510, + ReadFromReplicas = 650, + ReadReplicas = 578, + Reason = 45, + ReceivedAt = 63, + ReceivedFromIp = 636, + ReceivedViaPort = 637, + ReceivingIp = 836, + ReceivingMxHelo = 835, + ReceivingMxHostname = 834, + Recipients = 484, + Records = 256, + RecurrenceId = 805, + RedirectRoot = 911, + RedirectUris = 605, + Refresh = 419, + RefreshTokenExpiry = 617, + RefreshTokenRenewal = 618, + Region = 330, + RejectNonFqdn = 563, + RemoteIp = 282, + RenewBefore = 17, + Report = 66, + ReportAddressUri = 349, + ReportId = 244, + ReportedDomains = 74, + ReportedUris = 75, + ReportingMta = 76, + RequestMaxSize = 870, + RequestTlsCertificate = 123, + Require = 551, + RequireAudience = 607, + RequireClientRegistration = 615, + RequireScopes = 608, + RequireTls = 525, + ReservoirCapacity = 733, + ResourceGroup = 880, + ResourceUrl = 51, + ResponseCode = 212, + ResponseEnhanced = 213, + ResponseHeaders = 401, + ResponseHostname = 211, + ResponseMessage = 214, + ResponsePosCategory = 761, + ResponsePosConfidence = 762, + ResponsePosExplanation = 763, + Result = 233, + ResultType = 832, + RetireAfter = 228, + Retry = 420, + RetryCount = 640, + RetryDue = 641, + ReturnPath = 635, + ReuseKey = 912, + ReverseIpVerify = 692, + Rewrite = 565, + RoleIds = 193, + Roles = 152, + Rotate = 857, + RotateAfter = 227, + Route = 540, + Rua = 236, + Sandbox = 896, + SasToken = 119, + SaslMechanisms = 549, + ScanBanPaths = 683, + ScanBanPeriod = 685, + ScanBanRate = 684, + Schedule = 541, + Scheduling = 143, + Scheme = 908, + Scope = 281, + Score = 745, + ScoreDiscard = 771, + ScoreReject = 772, + ScoreSpam = 773, + Script = 553, + SearchStore = 127, + Secret = 3, + SecretAccessKey = 328, + SecretApiKey = 326, + SecretId = 906, + SecretKey = 659, + SecurityToken = 660, + Selector = 222, + SelectorTemplate = 226, + SendFrequency = 230, + SendingMtaIp = 833, + SentinelSecret = 915, + SentinelUsername = 914, + Separator = 97, + ServerHostname = 121, + Servers = 308, + ServiceAccountJson = 316, + ServiceName = 913, + Services = 794, + SessionToken = 329, + SetMaxObjects = 440, + ShardIndex = 830, + SharedSecret = 895, + Sig0Algorithm = 336, + SignatureAlgorithm = 623, + SignatureKey = 624, + SignerName = 335, + Size = 64, + SkipDeploy = 885, + SkipFirst = 423, + SmtpGreeting = 552, + SnippetMaxResults = 441, + SocketBacklog = 591, + SocketNoDelay = 592, + SocketReceiveBufferSize = 593, + SocketReuseAddress = 594, + SocketReusePort = 595, + SocketSendBufferSize = 596, + SocketTosV4 = 597, + SocketTtl = 598, + SourceIp = 77, + SourceIps = 504, + SourcePort = 78, + SpamFilterRulesUrl = 775, + SpfDns = 90, + SpfEhloDomain = 285, + SpfEhloResult = 286, + SpfEhloVerify = 688, + SpfFromVerify = 689, + SpfMailFromDomain = 287, + SpfMailFromResult = 288, + SpfResults = 267, + Stage = 224, + Stages = 529, + StartTime = 56, + StartTls = 571, + Status = 61, + StorageAccount = 116, + Store = 778, + Stores = 694, + Strategy = 816, + SubAddressing = 347, + SubAuthId = 887, + Subject = 41, + SubjectAlternativeNames = 178, + Subscribe = 368, + SubscriptionId = 879, + Sum = 494, + Summary = 808, + SupportedLanguages = 666, + Tag = 748, + Tags = 746, + TaskTypes = 189, + Tasks = 187, + TcpOnError = 307, + TeamId = 907, + TempFailOnError = 528, + Temperature = 27, + Template = 167, + TenancyOcid = 900, + TenantId = 831, + Tenants = 153, + Text = 2, + Then = 377, + ThirdParty = 219, + ThirdPartyHash = 220, + ThreadName = 818, + ThreadPoolSize = 791, + ThreadsPerNode = 574, + Throttle = 862, + TimeZone = 8, + Timeout = 29, + TimeoutAnonymous = 429, + TimeoutAuthenticated = 430, + TimeoutCommand = 534, + TimeoutConnect = 535, + TimeoutConnection = 581, + TimeoutData = 536, + TimeoutIdle = 431, + TimeoutMessage = 461, + TimeoutRequest = 582, + TimeoutSession = 462, + Timestamp = 482, + Title = 55, + Tls = 542, + TlsDisableCipherSuites = 599, + TlsDisableProtocols = 600, + TlsIgnoreClientOrder = 601, + TlsImplicit = 602, + TlsTimeout = 573, + To = 42, + Token = 888, + TotalDeadline = 817, + TotalFailedSessions = 850, + TotalSuccessfulSessions = 849, + TraceId = 815, + Tracer = 129, + TrainFrequency = 734, + TransactionRetryDelay = 385, + TransactionRetryLimit = 386, + TransactionTimeout = 387, + TransferLimit = 531, + TrustContacts = 769, + TrustReplies = 774, + TsigAlgorithm = 338, + Ttl = 310, + UnpackDirectory = 54, + UpdateRecords = 812, + UploadQuota = 445, + UploadTtl = 446, + Url = 31, + UrlLimit = 753, + UrlPrefix = 52, + Urls = 647, + UsePermissiveCors = 400, + UseTls = 309, + UseXForwarded = 402, + UsedDiskQuota = 395, + UserAgent = 79, + UserCodeExpiry = 620, + UserOcid = 901, + Username = 131, + UsernameDomain = 610, + VCardVersion = 931, + ValidateDomain = 413, + Value = 492, + VariableName = 675, + VerifyAfterWrite = 874, + Version = 80, + ViewName = 884, + Vrfy = 526, + WaitOnFail = 548, + WapiVersion = 893, + WebPushContact = 922, + WebPushKey = 921, + WebsocketHeartbeat = 455, + WebsocketThrottle = 456, + WebsocketTimeout = 457, + Zone = 749, + ZoneIpV4 = 98, + ZoneIpV6 = 99, +} diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs new file mode 100644 index 0000000..052ec52 --- /dev/null +++ b/crates/registry/src/schema/properties_impl.rs @@ -0,0 +1,7943 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::schema::prelude::*; + +impl EnumImpl for ObjectType { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"Account" => ObjectType::Account, + b"AccountPassword" => ObjectType::AccountPassword, + b"AccountSettings" => ObjectType::AccountSettings, + b"AcmeProvider" => ObjectType::AcmeProvider, + b"Action" => ObjectType::Action, + b"AddressBook" => ObjectType::AddressBook, + b"AiModel" => ObjectType::AiModel, + b"Alert" => ObjectType::Alert, + b"AllowedIp" => ObjectType::AllowedIp, + b"ApiKey" => ObjectType::ApiKey, + b"AppPassword" => ObjectType::AppPassword, + b"Application" => ObjectType::Application, + b"ArchivedItem" => ObjectType::ArchivedItem, + b"ArfExternalReport" => ObjectType::ArfExternalReport, + b"Asn" => ObjectType::Asn, + b"Authentication" => ObjectType::Authentication, + b"BlobStore" => ObjectType::BlobStore, + b"BlockedIp" => ObjectType::BlockedIp, + b"Bootstrap" => ObjectType::Bootstrap, + b"Cache" => ObjectType::Cache, + b"Calendar" => ObjectType::Calendar, + b"CalendarAlarm" => ObjectType::CalendarAlarm, + b"CalendarScheduling" => ObjectType::CalendarScheduling, + b"Certificate" => ObjectType::Certificate, + b"ClusterNode" => ObjectType::ClusterNode, + b"ClusterRole" => ObjectType::ClusterRole, + b"Coordinator" => ObjectType::Coordinator, + b"DataRetention" => ObjectType::DataRetention, + b"DataStore" => ObjectType::DataStore, + b"Directory" => ObjectType::Directory, + b"DkimReportSettings" => ObjectType::DkimReportSettings, + b"DkimSignature" => ObjectType::DkimSignature, + b"DmarcExternalReport" => ObjectType::DmarcExternalReport, + b"DmarcInternalReport" => ObjectType::DmarcInternalReport, + b"DmarcReportSettings" => ObjectType::DmarcReportSettings, + b"DnsResolver" => ObjectType::DnsResolver, + b"DnsServer" => ObjectType::DnsServer, + b"Domain" => ObjectType::Domain, + b"DsnReportSettings" => ObjectType::DsnReportSettings, + b"Email" => ObjectType::Email, + b"Enterprise" => ObjectType::Enterprise, + b"EventTracingLevel" => ObjectType::EventTracingLevel, + b"FileStorage" => ObjectType::FileStorage, + b"Http" => ObjectType::Http, + b"HttpForm" => ObjectType::HttpForm, + b"HttpLookup" => ObjectType::HttpLookup, + b"Imap" => ObjectType::Imap, + b"InMemoryStore" => ObjectType::InMemoryStore, + b"Jmap" => ObjectType::Jmap, + b"Log" => ObjectType::Log, + b"MailingList" => ObjectType::MailingList, + b"MaskedEmail" => ObjectType::MaskedEmail, + b"MemoryLookupKey" => ObjectType::MemoryLookupKey, + b"MemoryLookupKeyValue" => ObjectType::MemoryLookupKeyValue, + b"Metric" => ObjectType::Metric, + b"Metrics" => ObjectType::Metrics, + b"MetricsStore" => ObjectType::MetricsStore, + b"MtaConnectionStrategy" => ObjectType::MtaConnectionStrategy, + b"MtaDeliverySchedule" => ObjectType::MtaDeliverySchedule, + b"MtaExtensions" => ObjectType::MtaExtensions, + b"MtaHook" => ObjectType::MtaHook, + b"MtaInboundSession" => ObjectType::MtaInboundSession, + b"MtaInboundThrottle" => ObjectType::MtaInboundThrottle, + b"MtaMilter" => ObjectType::MtaMilter, + b"MtaOutboundStrategy" => ObjectType::MtaOutboundStrategy, + b"MtaOutboundThrottle" => ObjectType::MtaOutboundThrottle, + b"MtaQueueQuota" => ObjectType::MtaQueueQuota, + b"MtaRoute" => ObjectType::MtaRoute, + b"MtaStageAuth" => ObjectType::MtaStageAuth, + b"MtaStageConnect" => ObjectType::MtaStageConnect, + b"MtaStageData" => ObjectType::MtaStageData, + b"MtaStageEhlo" => ObjectType::MtaStageEhlo, + b"MtaStageMail" => ObjectType::MtaStageMail, + b"MtaStageRcpt" => ObjectType::MtaStageRcpt, + b"MtaSts" => ObjectType::MtaSts, + b"MtaTlsStrategy" => ObjectType::MtaTlsStrategy, + b"MtaVirtualQueue" => ObjectType::MtaVirtualQueue, + b"NetworkListener" => ObjectType::NetworkListener, + b"OAuthClient" => ObjectType::OAuthClient, + b"OidcProvider" => ObjectType::OidcProvider, + b"PublicKey" => ObjectType::PublicKey, + b"QueuedMessage" => ObjectType::QueuedMessage, + b"ReportSettings" => ObjectType::ReportSettings, + b"Role" => ObjectType::Role, + b"Search" => ObjectType::Search, + b"SearchStore" => ObjectType::SearchStore, + b"Security" => ObjectType::Security, + b"SenderAuth" => ObjectType::SenderAuth, + b"Sharing" => ObjectType::Sharing, + b"SieveSystemInterpreter" => ObjectType::SieveSystemInterpreter, + b"SieveSystemScript" => ObjectType::SieveSystemScript, + b"SieveUserInterpreter" => ObjectType::SieveUserInterpreter, + b"SieveUserScript" => ObjectType::SieveUserScript, + b"SpamClassifier" => ObjectType::SpamClassifier, + b"SpamDnsblServer" => ObjectType::SpamDnsblServer, + b"SpamDnsblSettings" => ObjectType::SpamDnsblSettings, + b"SpamFileExtension" => ObjectType::SpamFileExtension, + b"SpamLlm" => ObjectType::SpamLlm, + b"SpamPyzor" => ObjectType::SpamPyzor, + b"SpamRule" => ObjectType::SpamRule, + b"SpamSettings" => ObjectType::SpamSettings, + b"SpamTag" => ObjectType::SpamTag, + b"SpamTrainingSample" => ObjectType::SpamTrainingSample, + b"SpfReportSettings" => ObjectType::SpfReportSettings, + b"StoreLookup" => ObjectType::StoreLookup, + b"SystemSettings" => ObjectType::SystemSettings, + b"Task" => ObjectType::Task, + b"TaskManager" => ObjectType::TaskManager, + b"Tenant" => ObjectType::Tenant, + b"TlsExternalReport" => ObjectType::TlsExternalReport, + b"TlsInternalReport" => ObjectType::TlsInternalReport, + b"TlsReportSettings" => ObjectType::TlsReportSettings, + b"Trace" => ObjectType::Trace, + b"Tracer" => ObjectType::Tracer, + b"TracingStore" => ObjectType::TracingStore, + b"WebDav" => ObjectType::WebDav, + b"WebHook" => ObjectType::WebHook, + } + } + + fn as_str(&self) -> &'static str { + match self { + ObjectType::Account => "Account", + ObjectType::AccountPassword => "AccountPassword", + ObjectType::AccountSettings => "AccountSettings", + ObjectType::AcmeProvider => "AcmeProvider", + ObjectType::Action => "Action", + ObjectType::AddressBook => "AddressBook", + ObjectType::AiModel => "AiModel", + ObjectType::Alert => "Alert", + ObjectType::AllowedIp => "AllowedIp", + ObjectType::ApiKey => "ApiKey", + ObjectType::AppPassword => "AppPassword", + ObjectType::Application => "Application", + ObjectType::ArchivedItem => "ArchivedItem", + ObjectType::ArfExternalReport => "ArfExternalReport", + ObjectType::Asn => "Asn", + ObjectType::Authentication => "Authentication", + ObjectType::BlobStore => "BlobStore", + ObjectType::BlockedIp => "BlockedIp", + ObjectType::Bootstrap => "Bootstrap", + ObjectType::Cache => "Cache", + ObjectType::Calendar => "Calendar", + ObjectType::CalendarAlarm => "CalendarAlarm", + ObjectType::CalendarScheduling => "CalendarScheduling", + ObjectType::Certificate => "Certificate", + ObjectType::ClusterNode => "ClusterNode", + ObjectType::ClusterRole => "ClusterRole", + ObjectType::Coordinator => "Coordinator", + ObjectType::DataRetention => "DataRetention", + ObjectType::DataStore => "DataStore", + ObjectType::Directory => "Directory", + ObjectType::DkimReportSettings => "DkimReportSettings", + ObjectType::DkimSignature => "DkimSignature", + ObjectType::DmarcExternalReport => "DmarcExternalReport", + ObjectType::DmarcInternalReport => "DmarcInternalReport", + ObjectType::DmarcReportSettings => "DmarcReportSettings", + ObjectType::DnsResolver => "DnsResolver", + ObjectType::DnsServer => "DnsServer", + ObjectType::Domain => "Domain", + ObjectType::DsnReportSettings => "DsnReportSettings", + ObjectType::Email => "Email", + ObjectType::Enterprise => "Enterprise", + ObjectType::EventTracingLevel => "EventTracingLevel", + ObjectType::FileStorage => "FileStorage", + ObjectType::Http => "Http", + ObjectType::HttpForm => "HttpForm", + ObjectType::HttpLookup => "HttpLookup", + ObjectType::Imap => "Imap", + ObjectType::InMemoryStore => "InMemoryStore", + ObjectType::Jmap => "Jmap", + ObjectType::Log => "Log", + ObjectType::MailingList => "MailingList", + ObjectType::MaskedEmail => "MaskedEmail", + ObjectType::MemoryLookupKey => "MemoryLookupKey", + ObjectType::MemoryLookupKeyValue => "MemoryLookupKeyValue", + ObjectType::Metric => "Metric", + ObjectType::Metrics => "Metrics", + ObjectType::MetricsStore => "MetricsStore", + ObjectType::MtaConnectionStrategy => "MtaConnectionStrategy", + ObjectType::MtaDeliverySchedule => "MtaDeliverySchedule", + ObjectType::MtaExtensions => "MtaExtensions", + ObjectType::MtaHook => "MtaHook", + ObjectType::MtaInboundSession => "MtaInboundSession", + ObjectType::MtaInboundThrottle => "MtaInboundThrottle", + ObjectType::MtaMilter => "MtaMilter", + ObjectType::MtaOutboundStrategy => "MtaOutboundStrategy", + ObjectType::MtaOutboundThrottle => "MtaOutboundThrottle", + ObjectType::MtaQueueQuota => "MtaQueueQuota", + ObjectType::MtaRoute => "MtaRoute", + ObjectType::MtaStageAuth => "MtaStageAuth", + ObjectType::MtaStageConnect => "MtaStageConnect", + ObjectType::MtaStageData => "MtaStageData", + ObjectType::MtaStageEhlo => "MtaStageEhlo", + ObjectType::MtaStageMail => "MtaStageMail", + ObjectType::MtaStageRcpt => "MtaStageRcpt", + ObjectType::MtaSts => "MtaSts", + ObjectType::MtaTlsStrategy => "MtaTlsStrategy", + ObjectType::MtaVirtualQueue => "MtaVirtualQueue", + ObjectType::NetworkListener => "NetworkListener", + ObjectType::OAuthClient => "OAuthClient", + ObjectType::OidcProvider => "OidcProvider", + ObjectType::PublicKey => "PublicKey", + ObjectType::QueuedMessage => "QueuedMessage", + ObjectType::ReportSettings => "ReportSettings", + ObjectType::Role => "Role", + ObjectType::Search => "Search", + ObjectType::SearchStore => "SearchStore", + ObjectType::Security => "Security", + ObjectType::SenderAuth => "SenderAuth", + ObjectType::Sharing => "Sharing", + ObjectType::SieveSystemInterpreter => "SieveSystemInterpreter", + ObjectType::SieveSystemScript => "SieveSystemScript", + ObjectType::SieveUserInterpreter => "SieveUserInterpreter", + ObjectType::SieveUserScript => "SieveUserScript", + ObjectType::SpamClassifier => "SpamClassifier", + ObjectType::SpamDnsblServer => "SpamDnsblServer", + ObjectType::SpamDnsblSettings => "SpamDnsblSettings", + ObjectType::SpamFileExtension => "SpamFileExtension", + ObjectType::SpamLlm => "SpamLlm", + ObjectType::SpamPyzor => "SpamPyzor", + ObjectType::SpamRule => "SpamRule", + ObjectType::SpamSettings => "SpamSettings", + ObjectType::SpamTag => "SpamTag", + ObjectType::SpamTrainingSample => "SpamTrainingSample", + ObjectType::SpfReportSettings => "SpfReportSettings", + ObjectType::StoreLookup => "StoreLookup", + ObjectType::SystemSettings => "SystemSettings", + ObjectType::Task => "Task", + ObjectType::TaskManager => "TaskManager", + ObjectType::Tenant => "Tenant", + ObjectType::TlsExternalReport => "TlsExternalReport", + ObjectType::TlsInternalReport => "TlsInternalReport", + ObjectType::TlsReportSettings => "TlsReportSettings", + ObjectType::Trace => "Trace", + ObjectType::Tracer => "Tracer", + ObjectType::TracingStore => "TracingStore", + ObjectType::WebDav => "WebDav", + ObjectType::WebHook => "WebHook", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(ObjectType::Account), + 1 => Some(ObjectType::AccountPassword), + 2 => Some(ObjectType::AccountSettings), + 3 => Some(ObjectType::AcmeProvider), + 4 => Some(ObjectType::Action), + 5 => Some(ObjectType::AddressBook), + 6 => Some(ObjectType::AiModel), + 7 => Some(ObjectType::Alert), + 8 => Some(ObjectType::AllowedIp), + 9 => Some(ObjectType::ApiKey), + 10 => Some(ObjectType::AppPassword), + 11 => Some(ObjectType::Application), + 12 => Some(ObjectType::ArchivedItem), + 13 => Some(ObjectType::ArfExternalReport), + 14 => Some(ObjectType::Asn), + 15 => Some(ObjectType::Authentication), + 16 => Some(ObjectType::BlobStore), + 17 => Some(ObjectType::BlockedIp), + 18 => Some(ObjectType::Bootstrap), + 19 => Some(ObjectType::Cache), + 20 => Some(ObjectType::Calendar), + 21 => Some(ObjectType::CalendarAlarm), + 22 => Some(ObjectType::CalendarScheduling), + 23 => Some(ObjectType::Certificate), + 24 => Some(ObjectType::ClusterNode), + 25 => Some(ObjectType::ClusterRole), + 26 => Some(ObjectType::Coordinator), + 27 => Some(ObjectType::DataRetention), + 28 => Some(ObjectType::DataStore), + 29 => Some(ObjectType::Directory), + 30 => Some(ObjectType::DkimReportSettings), + 31 => Some(ObjectType::DkimSignature), + 32 => Some(ObjectType::DmarcExternalReport), + 33 => Some(ObjectType::DmarcInternalReport), + 34 => Some(ObjectType::DmarcReportSettings), + 35 => Some(ObjectType::DnsResolver), + 36 => Some(ObjectType::DnsServer), + 37 => Some(ObjectType::Domain), + 38 => Some(ObjectType::DsnReportSettings), + 39 => Some(ObjectType::Email), + 40 => Some(ObjectType::Enterprise), + 41 => Some(ObjectType::EventTracingLevel), + 42 => Some(ObjectType::FileStorage), + 43 => Some(ObjectType::Http), + 44 => Some(ObjectType::HttpForm), + 45 => Some(ObjectType::HttpLookup), + 46 => Some(ObjectType::Imap), + 47 => Some(ObjectType::InMemoryStore), + 48 => Some(ObjectType::Jmap), + 49 => Some(ObjectType::Log), + 50 => Some(ObjectType::MailingList), + 51 => Some(ObjectType::MaskedEmail), + 52 => Some(ObjectType::MemoryLookupKey), + 53 => Some(ObjectType::MemoryLookupKeyValue), + 54 => Some(ObjectType::Metric), + 55 => Some(ObjectType::Metrics), + 56 => Some(ObjectType::MetricsStore), + 57 => Some(ObjectType::MtaConnectionStrategy), + 58 => Some(ObjectType::MtaDeliverySchedule), + 59 => Some(ObjectType::MtaExtensions), + 60 => Some(ObjectType::MtaHook), + 61 => Some(ObjectType::MtaInboundSession), + 62 => Some(ObjectType::MtaInboundThrottle), + 63 => Some(ObjectType::MtaMilter), + 64 => Some(ObjectType::MtaOutboundStrategy), + 65 => Some(ObjectType::MtaOutboundThrottle), + 66 => Some(ObjectType::MtaQueueQuota), + 67 => Some(ObjectType::MtaRoute), + 68 => Some(ObjectType::MtaStageAuth), + 69 => Some(ObjectType::MtaStageConnect), + 70 => Some(ObjectType::MtaStageData), + 71 => Some(ObjectType::MtaStageEhlo), + 72 => Some(ObjectType::MtaStageMail), + 73 => Some(ObjectType::MtaStageRcpt), + 74 => Some(ObjectType::MtaSts), + 75 => Some(ObjectType::MtaTlsStrategy), + 76 => Some(ObjectType::MtaVirtualQueue), + 77 => Some(ObjectType::NetworkListener), + 78 => Some(ObjectType::OAuthClient), + 79 => Some(ObjectType::OidcProvider), + 80 => Some(ObjectType::PublicKey), + 81 => Some(ObjectType::QueuedMessage), + 82 => Some(ObjectType::ReportSettings), + 83 => Some(ObjectType::Role), + 84 => Some(ObjectType::Search), + 85 => Some(ObjectType::SearchStore), + 86 => Some(ObjectType::Security), + 87 => Some(ObjectType::SenderAuth), + 88 => Some(ObjectType::Sharing), + 89 => Some(ObjectType::SieveSystemInterpreter), + 90 => Some(ObjectType::SieveSystemScript), + 91 => Some(ObjectType::SieveUserInterpreter), + 92 => Some(ObjectType::SieveUserScript), + 93 => Some(ObjectType::SpamClassifier), + 94 => Some(ObjectType::SpamDnsblServer), + 95 => Some(ObjectType::SpamDnsblSettings), + 96 => Some(ObjectType::SpamFileExtension), + 97 => Some(ObjectType::SpamLlm), + 98 => Some(ObjectType::SpamPyzor), + 99 => Some(ObjectType::SpamRule), + 100 => Some(ObjectType::SpamSettings), + 101 => Some(ObjectType::SpamTag), + 102 => Some(ObjectType::SpamTrainingSample), + 103 => Some(ObjectType::SpfReportSettings), + 104 => Some(ObjectType::StoreLookup), + 105 => Some(ObjectType::SystemSettings), + 106 => Some(ObjectType::Task), + 107 => Some(ObjectType::TaskManager), + 108 => Some(ObjectType::Tenant), + 109 => Some(ObjectType::TlsExternalReport), + 110 => Some(ObjectType::TlsInternalReport), + 111 => Some(ObjectType::TlsReportSettings), + 112 => Some(ObjectType::Trace), + 113 => Some(ObjectType::Tracer), + 114 => Some(ObjectType::TracingStore), + 115 => Some(ObjectType::WebDav), + 116 => Some(ObjectType::WebHook), + _ => None, + } + } + + const COUNT: usize = 117; +} + +impl serde::Serialize for ObjectType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for ObjectType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl EnumImpl for Property { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"@type" => Property::Type, + b"abuseBanPeriod" => Property::AbuseBanPeriod, + b"abuseBanRate" => Property::AbuseBanRate, + b"accessKey" => Property::AccessKey, + b"accessKeyId" => Property::AccessKeyId, + b"accessToken" => Property::AccessToken, + b"accessTokenExpiry" => Property::AccessTokenExpiry, + b"accessTokens" => Property::AccessTokens, + b"accountDomainId" => Property::AccountDomainId, + b"accountId" => Property::AccountId, + b"accountIdentifier" => Property::AccountIdentifier, + b"accountKey" => Property::AccountKey, + b"accountName" => Property::AccountName, + b"accountSwitchKey" => Property::AccountSwitchKey, + b"accountType" => Property::AccountType, + b"accountUri" => Property::AccountUri, + b"accounts" => Property::Accounts, + b"acmeProviderId" => Property::AcmeProviderId, + b"addAuthResultsHeader" => Property::AddAuthResultsHeader, + b"addDateHeader" => Property::AddDateHeader, + b"addDeliveredToHeader" => Property::AddDeliveredToHeader, + b"addMessageIdHeader" => Property::AddMessageIdHeader, + b"addReceivedHeader" => Property::AddReceivedHeader, + b"addReceivedSpfHeader" => Property::AddReceivedSpfHeader, + b"addReturnPathHeader" => Property::AddReturnPathHeader, + b"additionalInformation" => Property::AdditionalInformation, + b"address" => Property::Address, + b"addresses" => Property::Addresses, + b"aggregateContactInfo" => Property::AggregateContactInfo, + b"aggregateDkimSignDomain" => Property::AggregateDkimSignDomain, + b"aggregateFromAddress" => Property::AggregateFromAddress, + b"aggregateFromName" => Property::AggregateFromName, + b"aggregateMaxReportSize" => Property::AggregateMaxReportSize, + b"aggregateOrgName" => Property::AggregateOrgName, + b"aggregateSendFrequency" => Property::AggregateSendFrequency, + b"aggregateSubject" => Property::AggregateSubject, + b"alarmId" => Property::AlarmId, + b"algorithms" => Property::Algorithms, + b"aliases" => Property::Aliases, + b"allowCount" => Property::AllowCount, + b"allowDirectoryQueries" => Property::AllowDirectoryQueries, + b"allowExternalRcpts" => Property::AllowExternalRcpts, + b"allowInvalidCerts" => Property::AllowInvalidCerts, + b"allowPlainTextAuth" => Property::AllowPlainTextAuth, + b"allowRelaying" => Property::AllowRelaying, + b"allowScimProvisioning" => Property::AllowScimProvisioning, + b"allowSpamTraining" => Property::AllowSpamTraining, + b"allowedEndpoints" => Property::AllowedEndpoints, + b"allowedIps" => Property::AllowedIps, + b"allowedNotifyUris" => Property::AllowedNotifyUris, + b"alpha" => Property::Alpha, + b"anonymousClientRegistration" => Property::AnonymousClientRegistration, + b"ansi" => Property::Ansi, + b"apiKey" => Property::ApiKey, + b"apiUser" => Property::ApiUser, + b"applicationKey" => Property::ApplicationKey, + b"applicationSecret" => Property::ApplicationSecret, + b"arcResult" => Property::ArcResult, + b"arcVerify" => Property::ArcVerify, + b"archiveDeletedAccountsFor" => Property::ArchiveDeletedAccountsFor, + b"archiveDeletedItemsFor" => Property::ArchiveDeletedItemsFor, + b"archivedAt" => Property::ArchivedAt, + b"archivedItemType" => Property::ArchivedItemType, + b"archivedUntil" => Property::ArchivedUntil, + b"arrivalDate" => Property::ArrivalDate, + b"asnUrls" => Property::AsnUrls, + b"attemptNumber" => Property::AttemptNumber, + b"attempts" => Property::Attempts, + b"attrClass" => Property::AttrClass, + b"attrDescription" => Property::AttrDescription, + b"attrEmail" => Property::AttrEmail, + b"attrEmailAlias" => Property::AttrEmailAlias, + b"attrMemberOf" => Property::AttrMemberOf, + b"attrSecret" => Property::AttrSecret, + b"attrSecretChanged" => Property::AttrSecretChanged, + b"auid" => Property::Auid, + b"auth" => Property::Auth, + b"authBanPeriod" => Property::AuthBanPeriod, + b"authBanRate" => Property::AuthBanRate, + b"authCodeExpiry" => Property::AuthCodeExpiry, + b"authCodeMaxAttempts" => Property::AuthCodeMaxAttempts, + b"authFailure" => Property::AuthFailure, + b"authId" => Property::AuthId, + b"authSecret" => Property::AuthSecret, + b"authToken" => Property::AuthToken, + b"authUsername" => Property::AuthUsername, + b"authenticatedAs" => Property::AuthenticatedAs, + b"authenticationResults" => Property::AuthenticationResults, + b"autoAddInvitations" => Property::AutoAddInvitations, + b"autoUpdateFrequency" => Property::AutoUpdateFrequency, + b"baseDn" => Property::BaseDn, + b"baseUrl" => Property::BaseUrl, + b"bearerToken" => Property::BearerToken, + b"beta" => Property::Beta, + b"bind" => Property::Bind, + b"bindAuthentication" => Property::BindAuthentication, + b"bindDn" => Property::BindDn, + b"bindSecret" => Property::BindSecret, + b"blobCleanupSchedule" => Property::BlobCleanupSchedule, + b"blobId" => Property::BlobId, + b"blobSize" => Property::BlobSize, + b"blobStore" => Property::BlobStore, + b"blockCount" => Property::BlockCount, + b"body" => Property::Body, + b"brokers" => Property::Brokers, + b"bucket" => Property::Bucket, + b"bufferSize" => Property::BufferSize, + b"buffered" => Property::Buffered, + b"cacheSize" => Property::CacheSize, + b"canonicalization" => Property::Canonicalization, + b"capacityClient" => Property::CapacityClient, + b"capacityReadBuffer" => Property::CapacityReadBuffer, + b"capacitySubscription" => Property::CapacitySubscription, + b"catchAllAddress" => Property::CatchAllAddress, + b"categories" => Property::Categories, + b"certificate" => Property::Certificate, + b"certificateManagement" => Property::CertificateManagement, + b"challengeType" => Property::ChallengeType, + b"changesMaxResults" => Property::ChangesMaxResults, + b"chunking" => Property::Chunking, + b"claimGroups" => Property::ClaimGroups, + b"claimName" => Property::ClaimName, + b"claimUsername" => Property::ClaimUsername, + b"cleartext" => Property::Cleartext, + b"clientId" => Property::ClientId, + b"clientIp" => Property::ClientIp, + b"clientSecret" => Property::ClientSecret, + b"clientToken" => Property::ClientToken, + b"clusterFile" => Property::ClusterFile, + b"columnClass" => Property::ColumnClass, + b"columnDescription" => Property::ColumnDescription, + b"columnEmail" => Property::ColumnEmail, + b"columnSecret" => Property::ColumnSecret, + b"comment" => Property::Comment, + b"compartmentOcid" => Property::CompartmentOcid, + b"compressionAlgorithm" => Property::CompressionAlgorithm, + b"concurrency" => Property::Concurrency, + b"condition" => Property::Condition, + b"confidence" => Property::Confidence, + b"config" => Property::Config, + b"configName" => Property::ConfigName, + b"connectTimeout" => Property::ConnectTimeout, + b"connection" => Property::Connection, + b"consumerKey" => Property::ConsumerKey, + b"contact" => Property::Contact, + b"contactInfo" => Property::ContactInfo, + b"contacts" => Property::Contacts, + b"container" => Property::Container, + b"content" => Property::Content, + b"contentTypes" => Property::ContentTypes, + b"contents" => Property::Contents, + b"context" => Property::Context, + b"count" => Property::Count, + b"create" => Property::Create, + b"createdAt" => Property::CreatedAt, + b"createdBy" => Property::CreatedBy, + b"credentialId" => Property::CredentialId, + b"credentials" => Property::Credentials, + b"currentSecret" => Property::CurrentSecret, + b"customEndpoint" => Property::CustomEndpoint, + b"customRegion" => Property::CustomRegion, + b"customRule" => Property::CustomRule, + b"customerNumber" => Property::CustomerNumber, + b"dane" => Property::Dane, + b"dataCleanupSchedule" => Property::DataCleanupSchedule, + b"dataStore" => Property::DataStore, + b"dataTimeout" => Property::DataTimeout, + b"database" => Property::Database, + b"datacenterId" => Property::DatacenterId, + b"dateRangeBegin" => Property::DateRangeBegin, + b"dateRangeEnd" => Property::DateRangeEnd, + b"dateRangeStart" => Property::DateRangeStart, + b"day" => Property::Day, + b"deadPropertyMaxSize" => Property::DeadPropertyMaxSize, + b"defaultAdminRoleIds" => Property::DefaultAdminRoleIds, + b"defaultCertificateId" => Property::DefaultCertificateId, + b"defaultDisplayName" => Property::DefaultDisplayName, + b"defaultDomain" => Property::DefaultDomain, + b"defaultDomainId" => Property::DefaultDomainId, + b"defaultExpiryDuplicate" => Property::DefaultExpiryDuplicate, + b"defaultExpiryVacation" => Property::DefaultExpiryVacation, + b"defaultFolders" => Property::DefaultFolders, + b"defaultFromAddress" => Property::DefaultFromAddress, + b"defaultFromName" => Property::DefaultFromName, + b"defaultGroupRoleIds" => Property::DefaultGroupRoleIds, + b"defaultHostname" => Property::DefaultHostname, + b"defaultHrefName" => Property::DefaultHrefName, + b"defaultLanguage" => Property::DefaultLanguage, + b"defaultName" => Property::DefaultName, + b"defaultReturnPath" => Property::DefaultReturnPath, + b"defaultSubject" => Property::DefaultSubject, + b"defaultSubjectPrefix" => Property::DefaultSubjectPrefix, + b"defaultTenantRoleIds" => Property::DefaultTenantRoleIds, + b"defaultUserRoleIds" => Property::DefaultUserRoleIds, + b"definition" => Property::Definition, + b"delay" => Property::Delay, + b"deleteAfter" => Property::DeleteAfter, + b"deleteAfterUse" => Property::DeleteAfterUse, + b"deliverAt" => Property::DeliverAt, + b"deliverBy" => Property::DeliverBy, + b"deliverTo" => Property::DeliverTo, + b"deliveryResult" => Property::DeliveryResult, + b"depth" => Property::Depth, + b"description" => Property::Description, + b"details" => Property::Details, + b"directory" => Property::Directory, + b"directoryId" => Property::DirectoryId, + b"disableCapabilities" => Property::DisableCapabilities, + b"disabledPermissions" => Property::DisabledPermissions, + b"discardAfter" => Property::DiscardAfter, + b"disposition" => Property::Disposition, + b"dkim2Pass" => Property::Dkim2Pass, + b"dkim2Result" => Property::Dkim2Result, + b"dkimAdspDns" => Property::DkimAdspDns, + b"dkimCanonicalizedBody" => Property::DkimCanonicalizedBody, + b"dkimCanonicalizedHeader" => Property::DkimCanonicalizedHeader, + b"dkimDomain" => Property::DkimDomain, + b"dkimIdentity" => Property::DkimIdentity, + b"dkimManagement" => Property::DkimManagement, + b"dkimPass" => Property::DkimPass, + b"dkimResults" => Property::DkimResults, + b"dkimSelector" => Property::DkimSelector, + b"dkimSelectorDns" => Property::DkimSelectorDns, + b"dkimSignDomain" => Property::DkimSignDomain, + b"dkimSignatures" => Property::DkimSignatures, + b"dkimStrict" => Property::DkimStrict, + b"dkimVerify" => Property::DkimVerify, + b"dmarcPass" => Property::DmarcPass, + b"dmarcPolicy" => Property::DmarcPolicy, + b"dmarcResult" => Property::DmarcResult, + b"dmarcVerify" => Property::DmarcVerify, + b"dnsIpv4" => Property::DnsIpv4, + b"dnsIpv6" => Property::DnsIpv6, + b"dnsManagement" => Property::DnsManagement, + b"dnsMtaSts" => Property::DnsMtaSts, + b"dnsMx" => Property::DnsMx, + b"dnsPtr" => Property::DnsPtr, + b"dnsRbl" => Property::DnsRbl, + b"dnsServer" => Property::DnsServer, + b"dnsServerId" => Property::DnsServerId, + b"dnsTlsa" => Property::DnsTlsa, + b"dnsTxt" => Property::DnsTxt, + b"dnsView" => Property::DnsView, + b"dnsZoneFile" => Property::DnsZoneFile, + b"documentId" => Property::DocumentId, + b"documentType" => Property::DocumentType, + b"domain" => Property::Domain, + b"domainId" => Property::DomainId, + b"domainLimit" => Property::DomainLimit, + b"domainNames" => Property::DomainNames, + b"domainNamesNegative" => Property::DomainNamesNegative, + b"domains" => Property::Domains, + b"dsn" => Property::Dsn, + b"due" => Property::Due, + b"duplicateExpiry" => Property::DuplicateExpiry, + b"duration" => Property::Duration, + b"eabHmacKey" => Property::EabHmacKey, + b"eabKeyId" => Property::EabKeyId, + b"ehloDomain" => Property::EhloDomain, + b"ehloHostname" => Property::EhloHostname, + b"ehloTimeout" => Property::EhloTimeout, + b"elapsed" => Property::Elapsed, + b"else" => Property::Else, + b"email" => Property::Email, + b"emailAddress" => Property::EmailAddress, + b"emailAddresses" => Property::EmailAddresses, + b"emailAddressesNegative" => Property::EmailAddressesNegative, + b"emailAlert" => Property::EmailAlert, + b"emailDomain" => Property::EmailDomain, + b"emailLimit" => Property::EmailLimit, + b"emailPrefix" => Property::EmailPrefix, + b"emailTemplate" => Property::EmailTemplate, + b"enable" => Property::Enable, + b"enableAssistedDiscovery" => Property::EnableAssistedDiscovery, + b"enableEdns" => Property::EnableEdns, + b"enableHsts" => Property::EnableHsts, + b"enableLogExporter" => Property::EnableLogExporter, + b"enableSpamFilter" => Property::EnableSpamFilter, + b"enableSpanExporter" => Property::EnableSpanExporter, + b"enabled" => Property::Enabled, + b"enabledPermissions" => Property::EnabledPermissions, + b"encryptAtRest" => Property::EncryptAtRest, + b"encryptOnAppend" => Property::EncryptOnAppend, + b"encryptionAtRest" => Property::EncryptionAtRest, + b"encryptionKey" => Property::EncryptionKey, + b"endpoint" => Property::Endpoint, + b"envFrom" => Property::EnvFrom, + b"envFromParameters" => Property::EnvFromParameters, + b"envId" => Property::EnvId, + b"envRcptTo" => Property::EnvRcptTo, + b"envelopeFrom" => Property::EnvelopeFrom, + b"envelopeTo" => Property::EnvelopeTo, + b"environment" => Property::Environment, + b"errorCommand" => Property::ErrorCommand, + b"errorMessage" => Property::ErrorMessage, + b"errorType" => Property::ErrorType, + b"errors" => Property::Errors, + b"evaluatedDisposition" => Property::EvaluatedDisposition, + b"evaluatedDkim" => Property::EvaluatedDkim, + b"evaluatedSpf" => Property::EvaluatedSpf, + b"event" => Property::Event, + b"eventAlert" => Property::EventAlert, + b"eventEnd" => Property::EventEnd, + b"eventEndTz" => Property::EventEndTz, + b"eventId" => Property::EventId, + b"eventMessage" => Property::EventMessage, + b"eventSourceThrottle" => Property::EventSourceThrottle, + b"eventStart" => Property::EventStart, + b"eventStartTz" => Property::EventStartTz, + b"events" => Property::Events, + b"eventsPolicy" => Property::EventsPolicy, + b"expire" => Property::Expire, + b"expires" => Property::Expires, + b"expiresAt" => Property::ExpiresAt, + b"expiresAttempts" => Property::ExpiresAttempts, + b"expiry" => Property::Expiry, + b"expn" => Property::Expn, + b"expungeSchedule" => Property::ExpungeSchedule, + b"expungeSchedulingInboxAfter" => Property::ExpungeSchedulingInboxAfter, + b"expungeShareNotifyAfter" => Property::ExpungeShareNotifyAfter, + b"expungeSubmissionsAfter" => Property::ExpungeSubmissionsAfter, + b"expungeTrashAfter" => Property::ExpungeTrashAfter, + b"extension" => Property::Extension, + b"extensions" => Property::Extensions, + b"externalId" => Property::ExternalId, + b"extraContactInfo" => Property::ExtraContactInfo, + b"factor" => Property::Factor, + b"failOnTimeout" => Property::FailOnTimeout, + b"failedAt" => Property::FailedAt, + b"failedAttemptNumber" => Property::FailedAttemptNumber, + b"failedSessionCount" => Property::FailedSessionCount, + b"failureDetails" => Property::FailureDetails, + b"failureDkimSignDomain" => Property::FailureDkimSignDomain, + b"failureFromAddress" => Property::FailureFromAddress, + b"failureFromName" => Property::FailureFromName, + b"failureReason" => Property::FailureReason, + b"failureReasonCode" => Property::FailureReasonCode, + b"failureSendFrequency" => Property::FailureSendFrequency, + b"failureSubject" => Property::FailureSubject, + b"featureL2Normalize" => Property::FeatureL2Normalize, + b"featureLogScale" => Property::FeatureLogScale, + b"feedbackType" => Property::FeedbackType, + b"fieldEmail" => Property::FieldEmail, + b"fieldHoneyPot" => Property::FieldHoneyPot, + b"fieldName" => Property::FieldName, + b"fieldSubject" => Property::FieldSubject, + b"filePath" => Property::FilePath, + b"files" => Property::Files, + b"filterLogin" => Property::FilterLogin, + b"filterMailbox" => Property::FilterMailbox, + b"filterMemberOf" => Property::FilterMemberOf, + b"fingerprint" => Property::Fingerprint, + b"flags" => Property::Flags, + b"flagsAction" => Property::FlagsAction, + b"flagsProtocol" => Property::FlagsProtocol, + b"folderId" => Property::FolderId, + b"forDomain" => Property::ForDomain, + b"format" => Property::Format, + b"from" => Property::From, + b"fromAddress" => Property::FromAddress, + b"fromEmail" => Property::FromEmail, + b"fromName" => Property::FromName, + b"futureRelease" => Property::FutureRelease, + b"generateDkimKeys" => Property::GenerateDkimKeys, + b"generator" => Property::Generator, + b"geoUrls" => Property::GeoUrls, + b"getMaxResults" => Property::GetMaxResults, + b"greetingTimeout" => Property::GreetingTimeout, + b"greylistFor" => Property::GreylistFor, + b"groupClass" => Property::GroupClass, + b"groupId" => Property::GroupId, + b"headerFrom" => Property::HeaderFrom, + b"headers" => Property::Headers, + b"holdMetricsFor" => Property::HoldMetricsFor, + b"holdMtaReportsFor" => Property::HoldMtaReportsFor, + b"holdSamplesFor" => Property::HoldSamplesFor, + b"holdTracesFor" => Property::HoldTracesFor, + b"host" => Property::Host, + b"hostedZoneId" => Property::HostedZoneId, + b"hostname" => Property::Hostname, + b"hour" => Property::Hour, + b"httpAuth" => Property::HttpAuth, + b"httpHeaders" => Property::HttpHeaders, + b"httpRsvpEnable" => Property::HttpRsvpEnable, + b"httpRsvpLinkExpiry" => Property::HttpRsvpLinkExpiry, + b"httpRsvpTemplate" => Property::HttpRsvpTemplate, + b"httpRsvpUrl" => Property::HttpRsvpUrl, + b"httpRua" => Property::HttpRua, + b"humanResult" => Property::HumanResult, + b"iCalendarData" => Property::ICalendarData, + b"id" => Property::Id, + b"idTokenExpiry" => Property::IdTokenExpiry, + b"identityAlignment" => Property::IdentityAlignment, + b"if" => Property::If, + b"impersonateServiceAccount" => Property::ImpersonateServiceAccount, + b"implicitTls" => Property::ImplicitTls, + b"inMemoryStore" => Property::InMemoryStore, + b"inboundReportAddresses" => Property::InboundReportAddresses, + b"inboundReportForwarding" => Property::InboundReportForwarding, + b"inboundReportMaxSize" => Property::InboundReportMaxSize, + b"incidents" => Property::Incidents, + b"includeSource" => Property::IncludeSource, + b"indexAsn" => Property::IndexAsn, + b"indexAsnName" => Property::IndexAsnName, + b"indexBatchSize" => Property::IndexBatchSize, + b"indexCalendar" => Property::IndexCalendar, + b"indexCalendarFields" => Property::IndexCalendarFields, + b"indexContactFields" => Property::IndexContactFields, + b"indexContacts" => Property::IndexContacts, + b"indexCountry" => Property::IndexCountry, + b"indexEmail" => Property::IndexEmail, + b"indexEmailFields" => Property::IndexEmailFields, + b"indexKey" => Property::IndexKey, + b"indexTelemetry" => Property::IndexTelemetry, + b"indexTracingFields" => Property::IndexTracingFields, + b"indexValue" => Property::IndexValue, + b"indicatorParameters" => Property::IndicatorParameters, + b"initialDelay" => Property::InitialDelay, + b"interval" => Property::Interval, + b"intervals" => Property::Intervals, + b"ipLimit" => Property::IpLimit, + b"ipLookupStrategy" => Property::IpLookupStrategy, + b"ipRevPtr" => Property::IpRevPtr, + b"ipRevResult" => Property::IpRevResult, + b"isActive" => Property::IsActive, + b"isArchive" => Property::IsArchive, + b"isBad" => Property::IsBad, + b"isEnabled" => Property::IsEnabled, + b"isFromOrganizer" => Property::IsFromOrganizer, + b"isGlobPattern" => Property::IsGlobPattern, + b"isGzipped" => Property::IsGzipped, + b"isNz" => Property::IsNz, + b"isSenderAllowed" => Property::IsSenderAllowed, + b"isSpam" => Property::IsSpam, + b"isTls" => Property::IsTls, + b"issuer" => Property::Issuer, + b"issuerUrl" => Property::IssuerUrl, + b"itipMaxSize" => Property::ItipMaxSize, + b"jitter" => Property::Jitter, + b"key" => Property::Key, + b"keyName" => Property::KeyName, + b"keyPrefix" => Property::KeyPrefix, + b"keyValues" => Property::KeyValues, + b"l1Ratio" => Property::L1Ratio, + b"l2Ratio" => Property::L2Ratio, + b"lastRenewal" => Property::LastRenewal, + b"learnHamFromCard" => Property::LearnHamFromCard, + b"learnHamFromReply" => Property::LearnHamFromReply, + b"learnSpamFromRblHits" => Property::LearnSpamFromRblHits, + b"learnSpamFromTraps" => Property::LearnSpamFromTraps, + b"level" => Property::Level, + b"licenseKey" => Property::LicenseKey, + b"line" => Property::Line, + b"listenerIds" => Property::ListenerIds, + b"listeners" => Property::Listeners, + b"livePropertyMaxSize" => Property::LivePropertyMaxSize, + b"locale" => Property::Locale, + b"logo" => Property::Logo, + b"logoUrl" => Property::LogoUrl, + b"loiterBanPeriod" => Property::LoiterBanPeriod, + b"loiterBanRate" => Property::LoiterBanRate, + b"lossy" => Property::Lossy, + b"machineId" => Property::MachineId, + b"mailExchangers" => Property::MailExchangers, + b"mailFrom" => Property::MailFrom, + b"mailFromTimeout" => Property::MailFromTimeout, + b"mailRua" => Property::MailRua, + b"mailingLists" => Property::MailingLists, + b"maintenanceType" => Property::MaintenanceType, + b"managedZone" => Property::ManagedZone, + b"match" => Property::Match, + b"maxAddressBooks" => Property::MaxAddressBooks, + b"maxAge" => Property::MaxAge, + b"maxAllowedPacket" => Property::MaxAllowedPacket, + b"maxApiKeys" => Property::MaxApiKeys, + b"maxAppPasswords" => Property::MaxAppPasswords, + b"maxAttachmentSize" => Property::MaxAttachmentSize, + b"maxAttempts" => Property::MaxAttempts, + b"maxAttendees" => Property::MaxAttendees, + b"maxAuthFailures" => Property::MaxAuthFailures, + b"maxCalendars" => Property::MaxCalendars, + b"maxChangesHistory" => Property::MaxChangesHistory, + b"maxConcurrent" => Property::MaxConcurrent, + b"maxConcurrentRequests" => Property::MaxConcurrentRequests, + b"maxConcurrentUploads" => Property::MaxConcurrentUploads, + b"maxConnections" => Property::MaxConnections, + b"maxContacts" => Property::MaxContacts, + b"maxCpuCycles" => Property::MaxCpuCycles, + b"maxDelay" => Property::MaxDelay, + b"maxDuration" => Property::MaxDuration, + b"maxEntries" => Property::MaxEntries, + b"maxEntrySize" => Property::MaxEntrySize, + b"maxEventNotifications" => Property::MaxEventNotifications, + b"maxEvents" => Property::MaxEvents, + b"maxFailures" => Property::MaxFailures, + b"maxFiles" => Property::MaxFiles, + b"maxFolders" => Property::MaxFolders, + b"maxHeaderSize" => Property::MaxHeaderSize, + b"maxICalendarSize" => Property::MaxICalendarSize, + b"maxIdentities" => Property::MaxIdentities, + b"maxIncludes" => Property::MaxIncludes, + b"maxLocalVars" => Property::MaxLocalVars, + b"maxLockTimeout" => Property::MaxLockTimeout, + b"maxLocks" => Property::MaxLocks, + b"maxMailboxDepth" => Property::MaxMailboxDepth, + b"maxMailboxNameLength" => Property::MaxMailboxNameLength, + b"maxMailboxes" => Property::MaxMailboxes, + b"maxMaskedAddresses" => Property::MaxMaskedAddresses, + b"maxMatchVars" => Property::MaxMatchVars, + b"maxMessageSize" => Property::MaxMessageSize, + b"maxMessages" => Property::MaxMessages, + b"maxMessagesPerCommand" => Property::MaxMessagesPerCommand, + b"maxMessagesPerSave" => Property::MaxMessagesPerSave, + b"maxMethodCalls" => Property::MaxMethodCalls, + b"maxMultihomed" => Property::MaxMultihomed, + b"maxMxHosts" => Property::MaxMxHosts, + b"maxNestedBlocks" => Property::MaxNestedBlocks, + b"maxNestedForEvery" => Property::MaxNestedForEvery, + b"maxNestedIncludes" => Property::MaxNestedIncludes, + b"maxNestedTests" => Property::MaxNestedTests, + b"maxOutMessages" => Property::MaxOutMessages, + b"maxParticipantIdentities" => Property::MaxParticipantIdentities, + b"maxPublicKeys" => Property::MaxPublicKeys, + b"maxPushSize" => Property::MaxPushSize, + b"maxReceivedHeaders" => Property::MaxReceivedHeaders, + b"maxRecipients" => Property::MaxRecipients, + b"maxReconnects" => Property::MaxReconnects, + b"maxRecurrenceExpansions" => Property::MaxRecurrenceExpansions, + b"maxRedirects" => Property::MaxRedirects, + b"maxReportSize" => Property::MaxReportSize, + b"maxRequestRate" => Property::MaxRequestRate, + b"maxRequestSize" => Property::MaxRequestSize, + b"maxResponseSize" => Property::MaxResponseSize, + b"maxResults" => Property::MaxResults, + b"maxRetries" => Property::MaxRetries, + b"maxRetryWait" => Property::MaxRetryWait, + b"maxScriptNameLength" => Property::MaxScriptNameLength, + b"maxScriptSize" => Property::MaxScriptSize, + b"maxScripts" => Property::MaxScripts, + b"maxShares" => Property::MaxShares, + b"maxSize" => Property::MaxSize, + b"maxStringLength" => Property::MaxStringLength, + b"maxSubmissions" => Property::MaxSubmissions, + b"maxSubscriptions" => Property::MaxSubscriptions, + b"maxUidBatches" => Property::MaxUidBatches, + b"maxUploadCount" => Property::MaxUploadCount, + b"maxUploadSize" => Property::MaxUploadSize, + b"maxVCardSize" => Property::MaxVCardSize, + b"maxVarNameLength" => Property::MaxVarNameLength, + b"maxVarSize" => Property::MaxVarSize, + b"memberGroupIds" => Property::MemberGroupIds, + b"memberTenantId" => Property::MemberTenantId, + b"message" => Property::Message, + b"messageIdHostname" => Property::MessageIdHostname, + b"messageIds" => Property::MessageIds, + b"messages" => Property::Messages, + b"metric" => Property::Metric, + b"metrics" => Property::Metrics, + b"metricsCollectionInterval" => Property::MetricsCollectionInterval, + b"metricsPolicy" => Property::MetricsPolicy, + b"minHamSamples" => Property::MinHamSamples, + b"minRetryWait" => Property::MinRetryWait, + b"minSpamSamples" => Property::MinSpamSamples, + b"minTriggerInterval" => Property::MinTriggerInterval, + b"minUidBatchSize" => Property::MinUidBatchSize, + b"minute" => Property::Minute, + b"mode" => Property::Mode, + b"model" => Property::Model, + b"modelId" => Property::ModelId, + b"modelType" => Property::ModelType, + b"mtPriority" => Property::MtPriority, + b"mtaSts" => Property::MtaSts, + b"mtaStsTimeout" => Property::MtaStsTimeout, + b"multiline" => Property::Multiline, + b"mustMatchSender" => Property::MustMatchSender, + b"mxHosts" => Property::MxHosts, + b"name" => Property::Name, + b"namespace" => Property::Namespace, + b"negativeTtl" => Property::NegativeTtl, + b"nextNotify" => Property::NextNotify, + b"nextRetry" => Property::NextRetry, + b"nextTransitionAt" => Property::NextTransitionAt, + b"noCapabilityCheck" => Property::NoCapabilityCheck, + b"noEcho" => Property::NoEcho, + b"noSoliciting" => Property::NoSoliciting, + b"nodeId" => Property::NodeId, + b"notValidAfter" => Property::NotValidAfter, + b"notValidBefore" => Property::NotValidBefore, + b"notify" => Property::Notify, + b"notifyCount" => Property::NotifyCount, + b"notifyDue" => Property::NotifyDue, + b"numFeatures" => Property::NumFeatures, + b"numReplicas" => Property::NumReplicas, + b"numShards" => Property::NumShards, + b"oauthClientId" => Property::OauthClientId, + b"onSuccessRenewCertificate" => Property::OnSuccessRenewCertificate, + b"openTelemetry" => Property::OpenTelemetry, + b"options" => Property::Options, + b"orcpt" => Property::Orcpt, + b"orgName" => Property::OrgName, + b"organizationName" => Property::OrganizationName, + b"origin" => Property::Origin, + b"originalEnvelopeId" => Property::OriginalEnvelopeId, + b"originalMailFrom" => Property::OriginalMailFrom, + b"originalRcptTo" => Property::OriginalRcptTo, + b"otpAuth" => Property::OtpAuth, + b"otpCode" => Property::OtpCode, + b"otpUrl" => Property::OtpUrl, + b"outboundReportDomain" => Property::OutboundReportDomain, + b"outboundReportSubmitter" => Property::OutboundReportSubmitter, + b"overrideProxyTrustedNetworks" => Property::OverrideProxyTrustedNetworks, + b"overrideType" => Property::OverrideType, + b"ovhEndpoint" => Property::OvhEndpoint, + b"parameters" => Property::Parameters, + b"parseLimitContact" => Property::ParseLimitContact, + b"parseLimitEmail" => Property::ParseLimitEmail, + b"parseLimitEvent" => Property::ParseLimitEvent, + b"password" => Property::Password, + b"passwordDefaultExpiry" => Property::PasswordDefaultExpiry, + b"passwordHashAlgorithm" => Property::PasswordHashAlgorithm, + b"passwordMaxLength" => Property::PasswordMaxLength, + b"passwordMinLength" => Property::PasswordMinLength, + b"passwordMinStrength" => Property::PasswordMinStrength, + b"path" => Property::Path, + b"period" => Property::Period, + b"permissions" => Property::Permissions, + b"pingInterval" => Property::PingInterval, + b"pipelining" => Property::Pipelining, + b"policies" => Property::Policies, + b"policyAdkim" => Property::PolicyAdkim, + b"policyAspf" => Property::PolicyAspf, + b"policyDiscoveryMethod" => Property::PolicyDiscoveryMethod, + b"policyDisposition" => Property::PolicyDisposition, + b"policyDomain" => Property::PolicyDomain, + b"policyFailureReportingOptions" => Property::PolicyFailureReportingOptions, + b"policyIdentifier" => Property::PolicyIdentifier, + b"policyIdentifiers" => Property::PolicyIdentifiers, + b"policyNp" => Property::PolicyNp, + b"policyOverrideReasons" => Property::PolicyOverrideReasons, + b"policyStrings" => Property::PolicyStrings, + b"policySubdomainDisposition" => Property::PolicySubdomainDisposition, + b"policyTestingMode" => Property::PolicyTestingMode, + b"policyType" => Property::PolicyType, + b"policyVersion" => Property::PolicyVersion, + b"pollInterval" => Property::PollInterval, + b"pollingInterval" => Property::PollingInterval, + b"poolMaxConnections" => Property::PoolMaxConnections, + b"poolMinConnections" => Property::PoolMinConnections, + b"poolRecyclingMethod" => Property::PoolRecyclingMethod, + b"poolTimeoutCreate" => Property::PoolTimeoutCreate, + b"poolTimeoutRecycle" => Property::PoolTimeoutRecycle, + b"poolTimeoutWait" => Property::PoolTimeoutWait, + b"poolWorkers" => Property::PoolWorkers, + b"port" => Property::Port, + b"preferredChain" => Property::PreferredChain, + b"prefix" => Property::Prefix, + b"preserveIntermediates" => Property::PreserveIntermediates, + b"priority" => Property::Priority, + b"privateKey" => Property::PrivateKey, + b"privateKeyPassword" => Property::PrivateKeyPassword, + b"privateKeyPem" => Property::PrivateKeyPem, + b"privateZone" => Property::PrivateZone, + b"privateZoneOnly" => Property::PrivateZoneOnly, + b"profile" => Property::Profile, + b"projectId" => Property::ProjectId, + b"prometheus" => Property::Prometheus, + b"prompt" => Property::Prompt, + b"propagationDelay" => Property::PropagationDelay, + b"propagationTimeout" => Property::PropagationTimeout, + b"protectedHeaders" => Property::ProtectedHeaders, + b"protocol" => Property::Protocol, + b"protocolVersion" => Property::ProtocolVersion, + b"providerInfo" => Property::ProviderInfo, + b"proxyTrustedNetworks" => Property::ProxyTrustedNetworks, + b"publicKey" => Property::PublicKey, + b"publishRecords" => Property::PublishRecords, + b"pushAttemptWait" => Property::PushAttemptWait, + b"pushMaxAttempts" => Property::PushMaxAttempts, + b"pushRequestTimeout" => Property::PushRequestTimeout, + b"pushRetryWait" => Property::PushRetryWait, + b"pushShardsTotal" => Property::PushShardsTotal, + b"pushThrottle" => Property::PushThrottle, + b"pushVerifyTimeout" => Property::PushVerifyTimeout, + b"queryEmailAliases" => Property::QueryEmailAliases, + b"queryLogin" => Property::QueryLogin, + b"queryMaxResults" => Property::QueryMaxResults, + b"queryMemberOf" => Property::QueryMemberOf, + b"queryRecipient" => Property::QueryRecipient, + b"queueId" => Property::QueueId, + b"queueName" => Property::QueueName, + b"quotas" => Property::Quotas, + b"rate" => Property::Rate, + b"rateLimit" => Property::RateLimit, + b"rateLimitAnonymous" => Property::RateLimitAnonymous, + b"rateLimitAuthenticated" => Property::RateLimitAuthenticated, + b"ratio" => Property::Ratio, + b"rcptToTimeout" => Property::RcptToTimeout, + b"readFromReplicas" => Property::ReadFromReplicas, + b"readReplicas" => Property::ReadReplicas, + b"reason" => Property::Reason, + b"receivedAt" => Property::ReceivedAt, + b"receivedFromIp" => Property::ReceivedFromIp, + b"receivedViaPort" => Property::ReceivedViaPort, + b"receivingIp" => Property::ReceivingIp, + b"receivingMxHelo" => Property::ReceivingMxHelo, + b"receivingMxHostname" => Property::ReceivingMxHostname, + b"recipients" => Property::Recipients, + b"records" => Property::Records, + b"recurrenceId" => Property::RecurrenceId, + b"redirectRoot" => Property::RedirectRoot, + b"redirectUris" => Property::RedirectUris, + b"refresh" => Property::Refresh, + b"refreshTokenExpiry" => Property::RefreshTokenExpiry, + b"refreshTokenRenewal" => Property::RefreshTokenRenewal, + b"region" => Property::Region, + b"rejectNonFqdn" => Property::RejectNonFqdn, + b"remoteIp" => Property::RemoteIp, + b"renewBefore" => Property::RenewBefore, + b"report" => Property::Report, + b"reportAddressUri" => Property::ReportAddressUri, + b"reportId" => Property::ReportId, + b"reportedDomains" => Property::ReportedDomains, + b"reportedUris" => Property::ReportedUris, + b"reportingMta" => Property::ReportingMta, + b"requestMaxSize" => Property::RequestMaxSize, + b"requestTlsCertificate" => Property::RequestTlsCertificate, + b"require" => Property::Require, + b"requireAudience" => Property::RequireAudience, + b"requireClientRegistration" => Property::RequireClientRegistration, + b"requireScopes" => Property::RequireScopes, + b"requireTls" => Property::RequireTls, + b"reservoirCapacity" => Property::ReservoirCapacity, + b"resourceGroup" => Property::ResourceGroup, + b"resourceUrl" => Property::ResourceUrl, + b"responseCode" => Property::ResponseCode, + b"responseEnhanced" => Property::ResponseEnhanced, + b"responseHeaders" => Property::ResponseHeaders, + b"responseHostname" => Property::ResponseHostname, + b"responseMessage" => Property::ResponseMessage, + b"responsePosCategory" => Property::ResponsePosCategory, + b"responsePosConfidence" => Property::ResponsePosConfidence, + b"responsePosExplanation" => Property::ResponsePosExplanation, + b"result" => Property::Result, + b"resultType" => Property::ResultType, + b"retireAfter" => Property::RetireAfter, + b"retry" => Property::Retry, + b"retryCount" => Property::RetryCount, + b"retryDue" => Property::RetryDue, + b"returnPath" => Property::ReturnPath, + b"reuseKey" => Property::ReuseKey, + b"reverseIpVerify" => Property::ReverseIpVerify, + b"rewrite" => Property::Rewrite, + b"roleIds" => Property::RoleIds, + b"roles" => Property::Roles, + b"rotate" => Property::Rotate, + b"rotateAfter" => Property::RotateAfter, + b"route" => Property::Route, + b"rua" => Property::Rua, + b"sandbox" => Property::Sandbox, + b"sasToken" => Property::SasToken, + b"saslMechanisms" => Property::SaslMechanisms, + b"scanBanPaths" => Property::ScanBanPaths, + b"scanBanPeriod" => Property::ScanBanPeriod, + b"scanBanRate" => Property::ScanBanRate, + b"schedule" => Property::Schedule, + b"scheduling" => Property::Scheduling, + b"scheme" => Property::Scheme, + b"scope" => Property::Scope, + b"score" => Property::Score, + b"scoreDiscard" => Property::ScoreDiscard, + b"scoreReject" => Property::ScoreReject, + b"scoreSpam" => Property::ScoreSpam, + b"script" => Property::Script, + b"searchStore" => Property::SearchStore, + b"secret" => Property::Secret, + b"secretAccessKey" => Property::SecretAccessKey, + b"secretApiKey" => Property::SecretApiKey, + b"secretId" => Property::SecretId, + b"secretKey" => Property::SecretKey, + b"securityToken" => Property::SecurityToken, + b"selector" => Property::Selector, + b"selectorTemplate" => Property::SelectorTemplate, + b"sendFrequency" => Property::SendFrequency, + b"sendingMtaIp" => Property::SendingMtaIp, + b"sentinelSecret" => Property::SentinelSecret, + b"sentinelUsername" => Property::SentinelUsername, + b"separator" => Property::Separator, + b"serverHostname" => Property::ServerHostname, + b"servers" => Property::Servers, + b"serviceAccountJson" => Property::ServiceAccountJson, + b"serviceName" => Property::ServiceName, + b"services" => Property::Services, + b"sessionToken" => Property::SessionToken, + b"setMaxObjects" => Property::SetMaxObjects, + b"shardIndex" => Property::ShardIndex, + b"sharedSecret" => Property::SharedSecret, + b"sig0Algorithm" => Property::Sig0Algorithm, + b"signatureAlgorithm" => Property::SignatureAlgorithm, + b"signatureKey" => Property::SignatureKey, + b"signerName" => Property::SignerName, + b"size" => Property::Size, + b"skipDeploy" => Property::SkipDeploy, + b"skipFirst" => Property::SkipFirst, + b"smtpGreeting" => Property::SmtpGreeting, + b"snippetMaxResults" => Property::SnippetMaxResults, + b"socketBacklog" => Property::SocketBacklog, + b"socketNoDelay" => Property::SocketNoDelay, + b"socketReceiveBufferSize" => Property::SocketReceiveBufferSize, + b"socketReuseAddress" => Property::SocketReuseAddress, + b"socketReusePort" => Property::SocketReusePort, + b"socketSendBufferSize" => Property::SocketSendBufferSize, + b"socketTosV4" => Property::SocketTosV4, + b"socketTtl" => Property::SocketTtl, + b"sourceIp" => Property::SourceIp, + b"sourceIps" => Property::SourceIps, + b"sourcePort" => Property::SourcePort, + b"spamFilterRulesUrl" => Property::SpamFilterRulesUrl, + b"spfDns" => Property::SpfDns, + b"spfEhloDomain" => Property::SpfEhloDomain, + b"spfEhloResult" => Property::SpfEhloResult, + b"spfEhloVerify" => Property::SpfEhloVerify, + b"spfFromVerify" => Property::SpfFromVerify, + b"spfMailFromDomain" => Property::SpfMailFromDomain, + b"spfMailFromResult" => Property::SpfMailFromResult, + b"spfResults" => Property::SpfResults, + b"stage" => Property::Stage, + b"stages" => Property::Stages, + b"startTime" => Property::StartTime, + b"startTls" => Property::StartTls, + b"status" => Property::Status, + b"storageAccount" => Property::StorageAccount, + b"store" => Property::Store, + b"stores" => Property::Stores, + b"strategy" => Property::Strategy, + b"subAddressing" => Property::SubAddressing, + b"subAuthId" => Property::SubAuthId, + b"subject" => Property::Subject, + b"subjectAlternativeNames" => Property::SubjectAlternativeNames, + b"subscribe" => Property::Subscribe, + b"subscriptionId" => Property::SubscriptionId, + b"sum" => Property::Sum, + b"summary" => Property::Summary, + b"supportedLanguages" => Property::SupportedLanguages, + b"tag" => Property::Tag, + b"tags" => Property::Tags, + b"taskTypes" => Property::TaskTypes, + b"tasks" => Property::Tasks, + b"tcpOnError" => Property::TcpOnError, + b"teamId" => Property::TeamId, + b"tempFailOnError" => Property::TempFailOnError, + b"temperature" => Property::Temperature, + b"template" => Property::Template, + b"tenancyOcid" => Property::TenancyOcid, + b"tenantId" => Property::TenantId, + b"tenants" => Property::Tenants, + b"text" => Property::Text, + b"then" => Property::Then, + b"thirdParty" => Property::ThirdParty, + b"thirdPartyHash" => Property::ThirdPartyHash, + b"threadName" => Property::ThreadName, + b"threadPoolSize" => Property::ThreadPoolSize, + b"threadsPerNode" => Property::ThreadsPerNode, + b"throttle" => Property::Throttle, + b"timeZone" => Property::TimeZone, + b"timeout" => Property::Timeout, + b"timeoutAnonymous" => Property::TimeoutAnonymous, + b"timeoutAuthenticated" => Property::TimeoutAuthenticated, + b"timeoutCommand" => Property::TimeoutCommand, + b"timeoutConnect" => Property::TimeoutConnect, + b"timeoutConnection" => Property::TimeoutConnection, + b"timeoutData" => Property::TimeoutData, + b"timeoutIdle" => Property::TimeoutIdle, + b"timeoutMessage" => Property::TimeoutMessage, + b"timeoutRequest" => Property::TimeoutRequest, + b"timeoutSession" => Property::TimeoutSession, + b"timestamp" => Property::Timestamp, + b"title" => Property::Title, + b"tls" => Property::Tls, + b"tlsDisableCipherSuites" => Property::TlsDisableCipherSuites, + b"tlsDisableProtocols" => Property::TlsDisableProtocols, + b"tlsIgnoreClientOrder" => Property::TlsIgnoreClientOrder, + b"tlsImplicit" => Property::TlsImplicit, + b"tlsTimeout" => Property::TlsTimeout, + b"to" => Property::To, + b"token" => Property::Token, + b"totalDeadline" => Property::TotalDeadline, + b"totalFailedSessions" => Property::TotalFailedSessions, + b"totalSuccessfulSessions" => Property::TotalSuccessfulSessions, + b"traceId" => Property::TraceId, + b"tracer" => Property::Tracer, + b"trainFrequency" => Property::TrainFrequency, + b"transactionRetryDelay" => Property::TransactionRetryDelay, + b"transactionRetryLimit" => Property::TransactionRetryLimit, + b"transactionTimeout" => Property::TransactionTimeout, + b"transferLimit" => Property::TransferLimit, + b"trustContacts" => Property::TrustContacts, + b"trustReplies" => Property::TrustReplies, + b"tsigAlgorithm" => Property::TsigAlgorithm, + b"ttl" => Property::Ttl, + b"unpackDirectory" => Property::UnpackDirectory, + b"updateRecords" => Property::UpdateRecords, + b"uploadQuota" => Property::UploadQuota, + b"uploadTtl" => Property::UploadTtl, + b"url" => Property::Url, + b"urlLimit" => Property::UrlLimit, + b"urlPrefix" => Property::UrlPrefix, + b"urls" => Property::Urls, + b"usePermissiveCors" => Property::UsePermissiveCors, + b"useTls" => Property::UseTls, + b"useXForwarded" => Property::UseXForwarded, + b"usedDiskQuota" => Property::UsedDiskQuota, + b"userAgent" => Property::UserAgent, + b"userCodeExpiry" => Property::UserCodeExpiry, + b"userOcid" => Property::UserOcid, + b"username" => Property::Username, + b"usernameDomain" => Property::UsernameDomain, + b"vCardVersion" => Property::VCardVersion, + b"validateDomain" => Property::ValidateDomain, + b"value" => Property::Value, + b"variableName" => Property::VariableName, + b"verifyAfterWrite" => Property::VerifyAfterWrite, + b"version" => Property::Version, + b"viewName" => Property::ViewName, + b"vrfy" => Property::Vrfy, + b"waitOnFail" => Property::WaitOnFail, + b"wapiVersion" => Property::WapiVersion, + b"webPushContact" => Property::WebPushContact, + b"webPushKey" => Property::WebPushKey, + b"websocketHeartbeat" => Property::WebsocketHeartbeat, + b"websocketThrottle" => Property::WebsocketThrottle, + b"websocketTimeout" => Property::WebsocketTimeout, + b"zone" => Property::Zone, + b"zoneIpV4" => Property::ZoneIpV4, + b"zoneIpV6" => Property::ZoneIpV6, + } + } + + fn as_str(&self) -> &'static str { + match self { + Property::Type => "@type", + Property::AbuseBanPeriod => "abuseBanPeriod", + Property::AbuseBanRate => "abuseBanRate", + Property::AccessKey => "accessKey", + Property::AccessKeyId => "accessKeyId", + Property::AccessToken => "accessToken", + Property::AccessTokenExpiry => "accessTokenExpiry", + Property::AccessTokens => "accessTokens", + Property::AccountDomainId => "accountDomainId", + Property::AccountId => "accountId", + Property::AccountIdentifier => "accountIdentifier", + Property::AccountKey => "accountKey", + Property::AccountName => "accountName", + Property::AccountSwitchKey => "accountSwitchKey", + Property::AccountType => "accountType", + Property::AccountUri => "accountUri", + Property::Accounts => "accounts", + Property::AcmeProviderId => "acmeProviderId", + Property::AddAuthResultsHeader => "addAuthResultsHeader", + Property::AddDateHeader => "addDateHeader", + Property::AddDeliveredToHeader => "addDeliveredToHeader", + Property::AddMessageIdHeader => "addMessageIdHeader", + Property::AddReceivedHeader => "addReceivedHeader", + Property::AddReceivedSpfHeader => "addReceivedSpfHeader", + Property::AddReturnPathHeader => "addReturnPathHeader", + Property::AdditionalInformation => "additionalInformation", + Property::Address => "address", + Property::Addresses => "addresses", + Property::AggregateContactInfo => "aggregateContactInfo", + Property::AggregateDkimSignDomain => "aggregateDkimSignDomain", + Property::AggregateFromAddress => "aggregateFromAddress", + Property::AggregateFromName => "aggregateFromName", + Property::AggregateMaxReportSize => "aggregateMaxReportSize", + Property::AggregateOrgName => "aggregateOrgName", + Property::AggregateSendFrequency => "aggregateSendFrequency", + Property::AggregateSubject => "aggregateSubject", + Property::AlarmId => "alarmId", + Property::Algorithms => "algorithms", + Property::Aliases => "aliases", + Property::AllowCount => "allowCount", + Property::AllowDirectoryQueries => "allowDirectoryQueries", + Property::AllowExternalRcpts => "allowExternalRcpts", + Property::AllowInvalidCerts => "allowInvalidCerts", + Property::AllowPlainTextAuth => "allowPlainTextAuth", + Property::AllowRelaying => "allowRelaying", + Property::AllowScimProvisioning => "allowScimProvisioning", + Property::AllowSpamTraining => "allowSpamTraining", + Property::AllowedEndpoints => "allowedEndpoints", + Property::AllowedIps => "allowedIps", + Property::AllowedNotifyUris => "allowedNotifyUris", + Property::Alpha => "alpha", + Property::AnonymousClientRegistration => "anonymousClientRegistration", + Property::Ansi => "ansi", + Property::ApiKey => "apiKey", + Property::ApiUser => "apiUser", + Property::ApplicationKey => "applicationKey", + Property::ApplicationSecret => "applicationSecret", + Property::ArcResult => "arcResult", + Property::ArcVerify => "arcVerify", + Property::ArchiveDeletedAccountsFor => "archiveDeletedAccountsFor", + Property::ArchiveDeletedItemsFor => "archiveDeletedItemsFor", + Property::ArchivedAt => "archivedAt", + Property::ArchivedItemType => "archivedItemType", + Property::ArchivedUntil => "archivedUntil", + Property::ArrivalDate => "arrivalDate", + Property::AsnUrls => "asnUrls", + Property::AttemptNumber => "attemptNumber", + Property::Attempts => "attempts", + Property::AttrClass => "attrClass", + Property::AttrDescription => "attrDescription", + Property::AttrEmail => "attrEmail", + Property::AttrEmailAlias => "attrEmailAlias", + Property::AttrMemberOf => "attrMemberOf", + Property::AttrSecret => "attrSecret", + Property::AttrSecretChanged => "attrSecretChanged", + Property::Auid => "auid", + Property::Auth => "auth", + Property::AuthBanPeriod => "authBanPeriod", + Property::AuthBanRate => "authBanRate", + Property::AuthCodeExpiry => "authCodeExpiry", + Property::AuthCodeMaxAttempts => "authCodeMaxAttempts", + Property::AuthFailure => "authFailure", + Property::AuthId => "authId", + Property::AuthSecret => "authSecret", + Property::AuthToken => "authToken", + Property::AuthUsername => "authUsername", + Property::AuthenticatedAs => "authenticatedAs", + Property::AuthenticationResults => "authenticationResults", + Property::AutoAddInvitations => "autoAddInvitations", + Property::AutoUpdateFrequency => "autoUpdateFrequency", + Property::BaseDn => "baseDn", + Property::BaseUrl => "baseUrl", + Property::BearerToken => "bearerToken", + Property::Beta => "beta", + Property::Bind => "bind", + Property::BindAuthentication => "bindAuthentication", + Property::BindDn => "bindDn", + Property::BindSecret => "bindSecret", + Property::BlobCleanupSchedule => "blobCleanupSchedule", + Property::BlobId => "blobId", + Property::BlobSize => "blobSize", + Property::BlobStore => "blobStore", + Property::BlockCount => "blockCount", + Property::Body => "body", + Property::Brokers => "brokers", + Property::Bucket => "bucket", + Property::BufferSize => "bufferSize", + Property::Buffered => "buffered", + Property::CacheSize => "cacheSize", + Property::Canonicalization => "canonicalization", + Property::CapacityClient => "capacityClient", + Property::CapacityReadBuffer => "capacityReadBuffer", + Property::CapacitySubscription => "capacitySubscription", + Property::CatchAllAddress => "catchAllAddress", + Property::Categories => "categories", + Property::Certificate => "certificate", + Property::CertificateManagement => "certificateManagement", + Property::ChallengeType => "challengeType", + Property::ChangesMaxResults => "changesMaxResults", + Property::Chunking => "chunking", + Property::ClaimGroups => "claimGroups", + Property::ClaimName => "claimName", + Property::ClaimUsername => "claimUsername", + Property::Cleartext => "cleartext", + Property::ClientId => "clientId", + Property::ClientIp => "clientIp", + Property::ClientSecret => "clientSecret", + Property::ClientToken => "clientToken", + Property::ClusterFile => "clusterFile", + Property::ColumnClass => "columnClass", + Property::ColumnDescription => "columnDescription", + Property::ColumnEmail => "columnEmail", + Property::ColumnSecret => "columnSecret", + Property::Comment => "comment", + Property::CompartmentOcid => "compartmentOcid", + Property::CompressionAlgorithm => "compressionAlgorithm", + Property::Concurrency => "concurrency", + Property::Condition => "condition", + Property::Confidence => "confidence", + Property::Config => "config", + Property::ConfigName => "configName", + Property::ConnectTimeout => "connectTimeout", + Property::Connection => "connection", + Property::ConsumerKey => "consumerKey", + Property::Contact => "contact", + Property::ContactInfo => "contactInfo", + Property::Contacts => "contacts", + Property::Container => "container", + Property::Content => "content", + Property::ContentTypes => "contentTypes", + Property::Contents => "contents", + Property::Context => "context", + Property::Count => "count", + Property::Create => "create", + Property::CreatedAt => "createdAt", + Property::CreatedBy => "createdBy", + Property::CredentialId => "credentialId", + Property::Credentials => "credentials", + Property::CurrentSecret => "currentSecret", + Property::CustomEndpoint => "customEndpoint", + Property::CustomRegion => "customRegion", + Property::CustomRule => "customRule", + Property::CustomerNumber => "customerNumber", + Property::Dane => "dane", + Property::DataCleanupSchedule => "dataCleanupSchedule", + Property::DataStore => "dataStore", + Property::DataTimeout => "dataTimeout", + Property::Database => "database", + Property::DatacenterId => "datacenterId", + Property::DateRangeBegin => "dateRangeBegin", + Property::DateRangeEnd => "dateRangeEnd", + Property::DateRangeStart => "dateRangeStart", + Property::Day => "day", + Property::DeadPropertyMaxSize => "deadPropertyMaxSize", + Property::DefaultAdminRoleIds => "defaultAdminRoleIds", + Property::DefaultCertificateId => "defaultCertificateId", + Property::DefaultDisplayName => "defaultDisplayName", + Property::DefaultDomain => "defaultDomain", + Property::DefaultDomainId => "defaultDomainId", + Property::DefaultExpiryDuplicate => "defaultExpiryDuplicate", + Property::DefaultExpiryVacation => "defaultExpiryVacation", + Property::DefaultFolders => "defaultFolders", + Property::DefaultFromAddress => "defaultFromAddress", + Property::DefaultFromName => "defaultFromName", + Property::DefaultGroupRoleIds => "defaultGroupRoleIds", + Property::DefaultHostname => "defaultHostname", + Property::DefaultHrefName => "defaultHrefName", + Property::DefaultLanguage => "defaultLanguage", + Property::DefaultName => "defaultName", + Property::DefaultReturnPath => "defaultReturnPath", + Property::DefaultSubject => "defaultSubject", + Property::DefaultSubjectPrefix => "defaultSubjectPrefix", + Property::DefaultTenantRoleIds => "defaultTenantRoleIds", + Property::DefaultUserRoleIds => "defaultUserRoleIds", + Property::Definition => "definition", + Property::Delay => "delay", + Property::DeleteAfter => "deleteAfter", + Property::DeleteAfterUse => "deleteAfterUse", + Property::DeliverAt => "deliverAt", + Property::DeliverBy => "deliverBy", + Property::DeliverTo => "deliverTo", + Property::DeliveryResult => "deliveryResult", + Property::Depth => "depth", + Property::Description => "description", + Property::Details => "details", + Property::Directory => "directory", + Property::DirectoryId => "directoryId", + Property::DisableCapabilities => "disableCapabilities", + Property::DisabledPermissions => "disabledPermissions", + Property::DiscardAfter => "discardAfter", + Property::Disposition => "disposition", + Property::Dkim2Pass => "dkim2Pass", + Property::Dkim2Result => "dkim2Result", + Property::DkimAdspDns => "dkimAdspDns", + Property::DkimCanonicalizedBody => "dkimCanonicalizedBody", + Property::DkimCanonicalizedHeader => "dkimCanonicalizedHeader", + Property::DkimDomain => "dkimDomain", + Property::DkimIdentity => "dkimIdentity", + Property::DkimManagement => "dkimManagement", + Property::DkimPass => "dkimPass", + Property::DkimResults => "dkimResults", + Property::DkimSelector => "dkimSelector", + Property::DkimSelectorDns => "dkimSelectorDns", + Property::DkimSignDomain => "dkimSignDomain", + Property::DkimSignatures => "dkimSignatures", + Property::DkimStrict => "dkimStrict", + Property::DkimVerify => "dkimVerify", + Property::DmarcPass => "dmarcPass", + Property::DmarcPolicy => "dmarcPolicy", + Property::DmarcResult => "dmarcResult", + Property::DmarcVerify => "dmarcVerify", + Property::DnsIpv4 => "dnsIpv4", + Property::DnsIpv6 => "dnsIpv6", + Property::DnsManagement => "dnsManagement", + Property::DnsMtaSts => "dnsMtaSts", + Property::DnsMx => "dnsMx", + Property::DnsPtr => "dnsPtr", + Property::DnsRbl => "dnsRbl", + Property::DnsServer => "dnsServer", + Property::DnsServerId => "dnsServerId", + Property::DnsTlsa => "dnsTlsa", + Property::DnsTxt => "dnsTxt", + Property::DnsView => "dnsView", + Property::DnsZoneFile => "dnsZoneFile", + Property::DocumentId => "documentId", + Property::DocumentType => "documentType", + Property::Domain => "domain", + Property::DomainId => "domainId", + Property::DomainLimit => "domainLimit", + Property::DomainNames => "domainNames", + Property::DomainNamesNegative => "domainNamesNegative", + Property::Domains => "domains", + Property::Dsn => "dsn", + Property::Due => "due", + Property::DuplicateExpiry => "duplicateExpiry", + Property::Duration => "duration", + Property::EabHmacKey => "eabHmacKey", + Property::EabKeyId => "eabKeyId", + Property::EhloDomain => "ehloDomain", + Property::EhloHostname => "ehloHostname", + Property::EhloTimeout => "ehloTimeout", + Property::Elapsed => "elapsed", + Property::Else => "else", + Property::Email => "email", + Property::EmailAddress => "emailAddress", + Property::EmailAddresses => "emailAddresses", + Property::EmailAddressesNegative => "emailAddressesNegative", + Property::EmailAlert => "emailAlert", + Property::EmailDomain => "emailDomain", + Property::EmailLimit => "emailLimit", + Property::EmailPrefix => "emailPrefix", + Property::EmailTemplate => "emailTemplate", + Property::Enable => "enable", + Property::EnableAssistedDiscovery => "enableAssistedDiscovery", + Property::EnableEdns => "enableEdns", + Property::EnableHsts => "enableHsts", + Property::EnableLogExporter => "enableLogExporter", + Property::EnableSpamFilter => "enableSpamFilter", + Property::EnableSpanExporter => "enableSpanExporter", + Property::Enabled => "enabled", + Property::EnabledPermissions => "enabledPermissions", + Property::EncryptAtRest => "encryptAtRest", + Property::EncryptOnAppend => "encryptOnAppend", + Property::EncryptionAtRest => "encryptionAtRest", + Property::EncryptionKey => "encryptionKey", + Property::Endpoint => "endpoint", + Property::EnvFrom => "envFrom", + Property::EnvFromParameters => "envFromParameters", + Property::EnvId => "envId", + Property::EnvRcptTo => "envRcptTo", + Property::EnvelopeFrom => "envelopeFrom", + Property::EnvelopeTo => "envelopeTo", + Property::Environment => "environment", + Property::ErrorCommand => "errorCommand", + Property::ErrorMessage => "errorMessage", + Property::ErrorType => "errorType", + Property::Errors => "errors", + Property::EvaluatedDisposition => "evaluatedDisposition", + Property::EvaluatedDkim => "evaluatedDkim", + Property::EvaluatedSpf => "evaluatedSpf", + Property::Event => "event", + Property::EventAlert => "eventAlert", + Property::EventEnd => "eventEnd", + Property::EventEndTz => "eventEndTz", + Property::EventId => "eventId", + Property::EventMessage => "eventMessage", + Property::EventSourceThrottle => "eventSourceThrottle", + Property::EventStart => "eventStart", + Property::EventStartTz => "eventStartTz", + Property::Events => "events", + Property::EventsPolicy => "eventsPolicy", + Property::Expire => "expire", + Property::Expires => "expires", + Property::ExpiresAt => "expiresAt", + Property::ExpiresAttempts => "expiresAttempts", + Property::Expiry => "expiry", + Property::Expn => "expn", + Property::ExpungeSchedule => "expungeSchedule", + Property::ExpungeSchedulingInboxAfter => "expungeSchedulingInboxAfter", + Property::ExpungeShareNotifyAfter => "expungeShareNotifyAfter", + Property::ExpungeSubmissionsAfter => "expungeSubmissionsAfter", + Property::ExpungeTrashAfter => "expungeTrashAfter", + Property::Extension => "extension", + Property::Extensions => "extensions", + Property::ExternalId => "externalId", + Property::ExtraContactInfo => "extraContactInfo", + Property::Factor => "factor", + Property::FailOnTimeout => "failOnTimeout", + Property::FailedAt => "failedAt", + Property::FailedAttemptNumber => "failedAttemptNumber", + Property::FailedSessionCount => "failedSessionCount", + Property::FailureDetails => "failureDetails", + Property::FailureDkimSignDomain => "failureDkimSignDomain", + Property::FailureFromAddress => "failureFromAddress", + Property::FailureFromName => "failureFromName", + Property::FailureReason => "failureReason", + Property::FailureReasonCode => "failureReasonCode", + Property::FailureSendFrequency => "failureSendFrequency", + Property::FailureSubject => "failureSubject", + Property::FeatureL2Normalize => "featureL2Normalize", + Property::FeatureLogScale => "featureLogScale", + Property::FeedbackType => "feedbackType", + Property::FieldEmail => "fieldEmail", + Property::FieldHoneyPot => "fieldHoneyPot", + Property::FieldName => "fieldName", + Property::FieldSubject => "fieldSubject", + Property::FilePath => "filePath", + Property::Files => "files", + Property::FilterLogin => "filterLogin", + Property::FilterMailbox => "filterMailbox", + Property::FilterMemberOf => "filterMemberOf", + Property::Fingerprint => "fingerprint", + Property::Flags => "flags", + Property::FlagsAction => "flagsAction", + Property::FlagsProtocol => "flagsProtocol", + Property::FolderId => "folderId", + Property::ForDomain => "forDomain", + Property::Format => "format", + Property::From => "from", + Property::FromAddress => "fromAddress", + Property::FromEmail => "fromEmail", + Property::FromName => "fromName", + Property::FutureRelease => "futureRelease", + Property::GenerateDkimKeys => "generateDkimKeys", + Property::Generator => "generator", + Property::GeoUrls => "geoUrls", + Property::GetMaxResults => "getMaxResults", + Property::GreetingTimeout => "greetingTimeout", + Property::GreylistFor => "greylistFor", + Property::GroupClass => "groupClass", + Property::GroupId => "groupId", + Property::HeaderFrom => "headerFrom", + Property::Headers => "headers", + Property::HoldMetricsFor => "holdMetricsFor", + Property::HoldMtaReportsFor => "holdMtaReportsFor", + Property::HoldSamplesFor => "holdSamplesFor", + Property::HoldTracesFor => "holdTracesFor", + Property::Host => "host", + Property::HostedZoneId => "hostedZoneId", + Property::Hostname => "hostname", + Property::Hour => "hour", + Property::HttpAuth => "httpAuth", + Property::HttpHeaders => "httpHeaders", + Property::HttpRsvpEnable => "httpRsvpEnable", + Property::HttpRsvpLinkExpiry => "httpRsvpLinkExpiry", + Property::HttpRsvpTemplate => "httpRsvpTemplate", + Property::HttpRsvpUrl => "httpRsvpUrl", + Property::HttpRua => "httpRua", + Property::HumanResult => "humanResult", + Property::ICalendarData => "iCalendarData", + Property::Id => "id", + Property::IdTokenExpiry => "idTokenExpiry", + Property::IdentityAlignment => "identityAlignment", + Property::If => "if", + Property::ImpersonateServiceAccount => "impersonateServiceAccount", + Property::ImplicitTls => "implicitTls", + Property::InMemoryStore => "inMemoryStore", + Property::InboundReportAddresses => "inboundReportAddresses", + Property::InboundReportForwarding => "inboundReportForwarding", + Property::InboundReportMaxSize => "inboundReportMaxSize", + Property::Incidents => "incidents", + Property::IncludeSource => "includeSource", + Property::IndexAsn => "indexAsn", + Property::IndexAsnName => "indexAsnName", + Property::IndexBatchSize => "indexBatchSize", + Property::IndexCalendar => "indexCalendar", + Property::IndexCalendarFields => "indexCalendarFields", + Property::IndexContactFields => "indexContactFields", + Property::IndexContacts => "indexContacts", + Property::IndexCountry => "indexCountry", + Property::IndexEmail => "indexEmail", + Property::IndexEmailFields => "indexEmailFields", + Property::IndexKey => "indexKey", + Property::IndexTelemetry => "indexTelemetry", + Property::IndexTracingFields => "indexTracingFields", + Property::IndexValue => "indexValue", + Property::IndicatorParameters => "indicatorParameters", + Property::InitialDelay => "initialDelay", + Property::Interval => "interval", + Property::Intervals => "intervals", + Property::IpLimit => "ipLimit", + Property::IpLookupStrategy => "ipLookupStrategy", + Property::IpRevPtr => "ipRevPtr", + Property::IpRevResult => "ipRevResult", + Property::IsActive => "isActive", + Property::IsArchive => "isArchive", + Property::IsBad => "isBad", + Property::IsEnabled => "isEnabled", + Property::IsFromOrganizer => "isFromOrganizer", + Property::IsGlobPattern => "isGlobPattern", + Property::IsGzipped => "isGzipped", + Property::IsNz => "isNz", + Property::IsSenderAllowed => "isSenderAllowed", + Property::IsSpam => "isSpam", + Property::IsTls => "isTls", + Property::Issuer => "issuer", + Property::IssuerUrl => "issuerUrl", + Property::ItipMaxSize => "itipMaxSize", + Property::Jitter => "jitter", + Property::Key => "key", + Property::KeyName => "keyName", + Property::KeyPrefix => "keyPrefix", + Property::KeyValues => "keyValues", + Property::L1Ratio => "l1Ratio", + Property::L2Ratio => "l2Ratio", + Property::LastRenewal => "lastRenewal", + Property::LearnHamFromCard => "learnHamFromCard", + Property::LearnHamFromReply => "learnHamFromReply", + Property::LearnSpamFromRblHits => "learnSpamFromRblHits", + Property::LearnSpamFromTraps => "learnSpamFromTraps", + Property::Level => "level", + Property::LicenseKey => "licenseKey", + Property::Line => "line", + Property::ListenerIds => "listenerIds", + Property::Listeners => "listeners", + Property::LivePropertyMaxSize => "livePropertyMaxSize", + Property::Locale => "locale", + Property::Logo => "logo", + Property::LogoUrl => "logoUrl", + Property::LoiterBanPeriod => "loiterBanPeriod", + Property::LoiterBanRate => "loiterBanRate", + Property::Lossy => "lossy", + Property::MachineId => "machineId", + Property::MailExchangers => "mailExchangers", + Property::MailFrom => "mailFrom", + Property::MailFromTimeout => "mailFromTimeout", + Property::MailRua => "mailRua", + Property::MailingLists => "mailingLists", + Property::MaintenanceType => "maintenanceType", + Property::ManagedZone => "managedZone", + Property::Match => "match", + Property::MaxAddressBooks => "maxAddressBooks", + Property::MaxAge => "maxAge", + Property::MaxAllowedPacket => "maxAllowedPacket", + Property::MaxApiKeys => "maxApiKeys", + Property::MaxAppPasswords => "maxAppPasswords", + Property::MaxAttachmentSize => "maxAttachmentSize", + Property::MaxAttempts => "maxAttempts", + Property::MaxAttendees => "maxAttendees", + Property::MaxAuthFailures => "maxAuthFailures", + Property::MaxCalendars => "maxCalendars", + Property::MaxChangesHistory => "maxChangesHistory", + Property::MaxConcurrent => "maxConcurrent", + Property::MaxConcurrentRequests => "maxConcurrentRequests", + Property::MaxConcurrentUploads => "maxConcurrentUploads", + Property::MaxConnections => "maxConnections", + Property::MaxContacts => "maxContacts", + Property::MaxCpuCycles => "maxCpuCycles", + Property::MaxDelay => "maxDelay", + Property::MaxDuration => "maxDuration", + Property::MaxEntries => "maxEntries", + Property::MaxEntrySize => "maxEntrySize", + Property::MaxEventNotifications => "maxEventNotifications", + Property::MaxEvents => "maxEvents", + Property::MaxFailures => "maxFailures", + Property::MaxFiles => "maxFiles", + Property::MaxFolders => "maxFolders", + Property::MaxHeaderSize => "maxHeaderSize", + Property::MaxICalendarSize => "maxICalendarSize", + Property::MaxIdentities => "maxIdentities", + Property::MaxIncludes => "maxIncludes", + Property::MaxLocalVars => "maxLocalVars", + Property::MaxLockTimeout => "maxLockTimeout", + Property::MaxLocks => "maxLocks", + Property::MaxMailboxDepth => "maxMailboxDepth", + Property::MaxMailboxNameLength => "maxMailboxNameLength", + Property::MaxMailboxes => "maxMailboxes", + Property::MaxMaskedAddresses => "maxMaskedAddresses", + Property::MaxMatchVars => "maxMatchVars", + Property::MaxMessageSize => "maxMessageSize", + Property::MaxMessages => "maxMessages", + Property::MaxMessagesPerCommand => "maxMessagesPerCommand", + Property::MaxMessagesPerSave => "maxMessagesPerSave", + Property::MaxMethodCalls => "maxMethodCalls", + Property::MaxMultihomed => "maxMultihomed", + Property::MaxMxHosts => "maxMxHosts", + Property::MaxNestedBlocks => "maxNestedBlocks", + Property::MaxNestedForEvery => "maxNestedForEvery", + Property::MaxNestedIncludes => "maxNestedIncludes", + Property::MaxNestedTests => "maxNestedTests", + Property::MaxOutMessages => "maxOutMessages", + Property::MaxParticipantIdentities => "maxParticipantIdentities", + Property::MaxPublicKeys => "maxPublicKeys", + Property::MaxPushSize => "maxPushSize", + Property::MaxReceivedHeaders => "maxReceivedHeaders", + Property::MaxRecipients => "maxRecipients", + Property::MaxReconnects => "maxReconnects", + Property::MaxRecurrenceExpansions => "maxRecurrenceExpansions", + Property::MaxRedirects => "maxRedirects", + Property::MaxReportSize => "maxReportSize", + Property::MaxRequestRate => "maxRequestRate", + Property::MaxRequestSize => "maxRequestSize", + Property::MaxResponseSize => "maxResponseSize", + Property::MaxResults => "maxResults", + Property::MaxRetries => "maxRetries", + Property::MaxRetryWait => "maxRetryWait", + Property::MaxScriptNameLength => "maxScriptNameLength", + Property::MaxScriptSize => "maxScriptSize", + Property::MaxScripts => "maxScripts", + Property::MaxShares => "maxShares", + Property::MaxSize => "maxSize", + Property::MaxStringLength => "maxStringLength", + Property::MaxSubmissions => "maxSubmissions", + Property::MaxSubscriptions => "maxSubscriptions", + Property::MaxUidBatches => "maxUidBatches", + Property::MaxUploadCount => "maxUploadCount", + Property::MaxUploadSize => "maxUploadSize", + Property::MaxVCardSize => "maxVCardSize", + Property::MaxVarNameLength => "maxVarNameLength", + Property::MaxVarSize => "maxVarSize", + Property::MemberGroupIds => "memberGroupIds", + Property::MemberTenantId => "memberTenantId", + Property::Message => "message", + Property::MessageIdHostname => "messageIdHostname", + Property::MessageIds => "messageIds", + Property::Messages => "messages", + Property::Metric => "metric", + Property::Metrics => "metrics", + Property::MetricsCollectionInterval => "metricsCollectionInterval", + Property::MetricsPolicy => "metricsPolicy", + Property::MinHamSamples => "minHamSamples", + Property::MinRetryWait => "minRetryWait", + Property::MinSpamSamples => "minSpamSamples", + Property::MinTriggerInterval => "minTriggerInterval", + Property::MinUidBatchSize => "minUidBatchSize", + Property::Minute => "minute", + Property::Mode => "mode", + Property::Model => "model", + Property::ModelId => "modelId", + Property::ModelType => "modelType", + Property::MtPriority => "mtPriority", + Property::MtaSts => "mtaSts", + Property::MtaStsTimeout => "mtaStsTimeout", + Property::Multiline => "multiline", + Property::MustMatchSender => "mustMatchSender", + Property::MxHosts => "mxHosts", + Property::Name => "name", + Property::Namespace => "namespace", + Property::NegativeTtl => "negativeTtl", + Property::NextNotify => "nextNotify", + Property::NextRetry => "nextRetry", + Property::NextTransitionAt => "nextTransitionAt", + Property::NoCapabilityCheck => "noCapabilityCheck", + Property::NoEcho => "noEcho", + Property::NoSoliciting => "noSoliciting", + Property::NodeId => "nodeId", + Property::NotValidAfter => "notValidAfter", + Property::NotValidBefore => "notValidBefore", + Property::Notify => "notify", + Property::NotifyCount => "notifyCount", + Property::NotifyDue => "notifyDue", + Property::NumFeatures => "numFeatures", + Property::NumReplicas => "numReplicas", + Property::NumShards => "numShards", + Property::OauthClientId => "oauthClientId", + Property::OnSuccessRenewCertificate => "onSuccessRenewCertificate", + Property::OpenTelemetry => "openTelemetry", + Property::Options => "options", + Property::Orcpt => "orcpt", + Property::OrgName => "orgName", + Property::OrganizationName => "organizationName", + Property::Origin => "origin", + Property::OriginalEnvelopeId => "originalEnvelopeId", + Property::OriginalMailFrom => "originalMailFrom", + Property::OriginalRcptTo => "originalRcptTo", + Property::OtpAuth => "otpAuth", + Property::OtpCode => "otpCode", + Property::OtpUrl => "otpUrl", + Property::OutboundReportDomain => "outboundReportDomain", + Property::OutboundReportSubmitter => "outboundReportSubmitter", + Property::OverrideProxyTrustedNetworks => "overrideProxyTrustedNetworks", + Property::OverrideType => "overrideType", + Property::OvhEndpoint => "ovhEndpoint", + Property::Parameters => "parameters", + Property::ParseLimitContact => "parseLimitContact", + Property::ParseLimitEmail => "parseLimitEmail", + Property::ParseLimitEvent => "parseLimitEvent", + Property::Password => "password", + Property::PasswordDefaultExpiry => "passwordDefaultExpiry", + Property::PasswordHashAlgorithm => "passwordHashAlgorithm", + Property::PasswordMaxLength => "passwordMaxLength", + Property::PasswordMinLength => "passwordMinLength", + Property::PasswordMinStrength => "passwordMinStrength", + Property::Path => "path", + Property::Period => "period", + Property::Permissions => "permissions", + Property::PingInterval => "pingInterval", + Property::Pipelining => "pipelining", + Property::Policies => "policies", + Property::PolicyAdkim => "policyAdkim", + Property::PolicyAspf => "policyAspf", + Property::PolicyDiscoveryMethod => "policyDiscoveryMethod", + Property::PolicyDisposition => "policyDisposition", + Property::PolicyDomain => "policyDomain", + Property::PolicyFailureReportingOptions => "policyFailureReportingOptions", + Property::PolicyIdentifier => "policyIdentifier", + Property::PolicyIdentifiers => "policyIdentifiers", + Property::PolicyNp => "policyNp", + Property::PolicyOverrideReasons => "policyOverrideReasons", + Property::PolicyStrings => "policyStrings", + Property::PolicySubdomainDisposition => "policySubdomainDisposition", + Property::PolicyTestingMode => "policyTestingMode", + Property::PolicyType => "policyType", + Property::PolicyVersion => "policyVersion", + Property::PollInterval => "pollInterval", + Property::PollingInterval => "pollingInterval", + Property::PoolMaxConnections => "poolMaxConnections", + Property::PoolMinConnections => "poolMinConnections", + Property::PoolRecyclingMethod => "poolRecyclingMethod", + Property::PoolTimeoutCreate => "poolTimeoutCreate", + Property::PoolTimeoutRecycle => "poolTimeoutRecycle", + Property::PoolTimeoutWait => "poolTimeoutWait", + Property::PoolWorkers => "poolWorkers", + Property::Port => "port", + Property::PreferredChain => "preferredChain", + Property::Prefix => "prefix", + Property::PreserveIntermediates => "preserveIntermediates", + Property::Priority => "priority", + Property::PrivateKey => "privateKey", + Property::PrivateKeyPassword => "privateKeyPassword", + Property::PrivateKeyPem => "privateKeyPem", + Property::PrivateZone => "privateZone", + Property::PrivateZoneOnly => "privateZoneOnly", + Property::Profile => "profile", + Property::ProjectId => "projectId", + Property::Prometheus => "prometheus", + Property::Prompt => "prompt", + Property::PropagationDelay => "propagationDelay", + Property::PropagationTimeout => "propagationTimeout", + Property::ProtectedHeaders => "protectedHeaders", + Property::Protocol => "protocol", + Property::ProtocolVersion => "protocolVersion", + Property::ProviderInfo => "providerInfo", + Property::ProxyTrustedNetworks => "proxyTrustedNetworks", + Property::PublicKey => "publicKey", + Property::PublishRecords => "publishRecords", + Property::PushAttemptWait => "pushAttemptWait", + Property::PushMaxAttempts => "pushMaxAttempts", + Property::PushRequestTimeout => "pushRequestTimeout", + Property::PushRetryWait => "pushRetryWait", + Property::PushShardsTotal => "pushShardsTotal", + Property::PushThrottle => "pushThrottle", + Property::PushVerifyTimeout => "pushVerifyTimeout", + Property::QueryEmailAliases => "queryEmailAliases", + Property::QueryLogin => "queryLogin", + Property::QueryMaxResults => "queryMaxResults", + Property::QueryMemberOf => "queryMemberOf", + Property::QueryRecipient => "queryRecipient", + Property::QueueId => "queueId", + Property::QueueName => "queueName", + Property::Quotas => "quotas", + Property::Rate => "rate", + Property::RateLimit => "rateLimit", + Property::RateLimitAnonymous => "rateLimitAnonymous", + Property::RateLimitAuthenticated => "rateLimitAuthenticated", + Property::Ratio => "ratio", + Property::RcptToTimeout => "rcptToTimeout", + Property::ReadFromReplicas => "readFromReplicas", + Property::ReadReplicas => "readReplicas", + Property::Reason => "reason", + Property::ReceivedAt => "receivedAt", + Property::ReceivedFromIp => "receivedFromIp", + Property::ReceivedViaPort => "receivedViaPort", + Property::ReceivingIp => "receivingIp", + Property::ReceivingMxHelo => "receivingMxHelo", + Property::ReceivingMxHostname => "receivingMxHostname", + Property::Recipients => "recipients", + Property::Records => "records", + Property::RecurrenceId => "recurrenceId", + Property::RedirectRoot => "redirectRoot", + Property::RedirectUris => "redirectUris", + Property::Refresh => "refresh", + Property::RefreshTokenExpiry => "refreshTokenExpiry", + Property::RefreshTokenRenewal => "refreshTokenRenewal", + Property::Region => "region", + Property::RejectNonFqdn => "rejectNonFqdn", + Property::RemoteIp => "remoteIp", + Property::RenewBefore => "renewBefore", + Property::Report => "report", + Property::ReportAddressUri => "reportAddressUri", + Property::ReportId => "reportId", + Property::ReportedDomains => "reportedDomains", + Property::ReportedUris => "reportedUris", + Property::ReportingMta => "reportingMta", + Property::RequestMaxSize => "requestMaxSize", + Property::RequestTlsCertificate => "requestTlsCertificate", + Property::Require => "require", + Property::RequireAudience => "requireAudience", + Property::RequireClientRegistration => "requireClientRegistration", + Property::RequireScopes => "requireScopes", + Property::RequireTls => "requireTls", + Property::ReservoirCapacity => "reservoirCapacity", + Property::ResourceGroup => "resourceGroup", + Property::ResourceUrl => "resourceUrl", + Property::ResponseCode => "responseCode", + Property::ResponseEnhanced => "responseEnhanced", + Property::ResponseHeaders => "responseHeaders", + Property::ResponseHostname => "responseHostname", + Property::ResponseMessage => "responseMessage", + Property::ResponsePosCategory => "responsePosCategory", + Property::ResponsePosConfidence => "responsePosConfidence", + Property::ResponsePosExplanation => "responsePosExplanation", + Property::Result => "result", + Property::ResultType => "resultType", + Property::RetireAfter => "retireAfter", + Property::Retry => "retry", + Property::RetryCount => "retryCount", + Property::RetryDue => "retryDue", + Property::ReturnPath => "returnPath", + Property::ReuseKey => "reuseKey", + Property::ReverseIpVerify => "reverseIpVerify", + Property::Rewrite => "rewrite", + Property::RoleIds => "roleIds", + Property::Roles => "roles", + Property::Rotate => "rotate", + Property::RotateAfter => "rotateAfter", + Property::Route => "route", + Property::Rua => "rua", + Property::Sandbox => "sandbox", + Property::SasToken => "sasToken", + Property::SaslMechanisms => "saslMechanisms", + Property::ScanBanPaths => "scanBanPaths", + Property::ScanBanPeriod => "scanBanPeriod", + Property::ScanBanRate => "scanBanRate", + Property::Schedule => "schedule", + Property::Scheduling => "scheduling", + Property::Scheme => "scheme", + Property::Scope => "scope", + Property::Score => "score", + Property::ScoreDiscard => "scoreDiscard", + Property::ScoreReject => "scoreReject", + Property::ScoreSpam => "scoreSpam", + Property::Script => "script", + Property::SearchStore => "searchStore", + Property::Secret => "secret", + Property::SecretAccessKey => "secretAccessKey", + Property::SecretApiKey => "secretApiKey", + Property::SecretId => "secretId", + Property::SecretKey => "secretKey", + Property::SecurityToken => "securityToken", + Property::Selector => "selector", + Property::SelectorTemplate => "selectorTemplate", + Property::SendFrequency => "sendFrequency", + Property::SendingMtaIp => "sendingMtaIp", + Property::SentinelSecret => "sentinelSecret", + Property::SentinelUsername => "sentinelUsername", + Property::Separator => "separator", + Property::ServerHostname => "serverHostname", + Property::Servers => "servers", + Property::ServiceAccountJson => "serviceAccountJson", + Property::ServiceName => "serviceName", + Property::Services => "services", + Property::SessionToken => "sessionToken", + Property::SetMaxObjects => "setMaxObjects", + Property::ShardIndex => "shardIndex", + Property::SharedSecret => "sharedSecret", + Property::Sig0Algorithm => "sig0Algorithm", + Property::SignatureAlgorithm => "signatureAlgorithm", + Property::SignatureKey => "signatureKey", + Property::SignerName => "signerName", + Property::Size => "size", + Property::SkipDeploy => "skipDeploy", + Property::SkipFirst => "skipFirst", + Property::SmtpGreeting => "smtpGreeting", + Property::SnippetMaxResults => "snippetMaxResults", + Property::SocketBacklog => "socketBacklog", + Property::SocketNoDelay => "socketNoDelay", + Property::SocketReceiveBufferSize => "socketReceiveBufferSize", + Property::SocketReuseAddress => "socketReuseAddress", + Property::SocketReusePort => "socketReusePort", + Property::SocketSendBufferSize => "socketSendBufferSize", + Property::SocketTosV4 => "socketTosV4", + Property::SocketTtl => "socketTtl", + Property::SourceIp => "sourceIp", + Property::SourceIps => "sourceIps", + Property::SourcePort => "sourcePort", + Property::SpamFilterRulesUrl => "spamFilterRulesUrl", + Property::SpfDns => "spfDns", + Property::SpfEhloDomain => "spfEhloDomain", + Property::SpfEhloResult => "spfEhloResult", + Property::SpfEhloVerify => "spfEhloVerify", + Property::SpfFromVerify => "spfFromVerify", + Property::SpfMailFromDomain => "spfMailFromDomain", + Property::SpfMailFromResult => "spfMailFromResult", + Property::SpfResults => "spfResults", + Property::Stage => "stage", + Property::Stages => "stages", + Property::StartTime => "startTime", + Property::StartTls => "startTls", + Property::Status => "status", + Property::StorageAccount => "storageAccount", + Property::Store => "store", + Property::Stores => "stores", + Property::Strategy => "strategy", + Property::SubAddressing => "subAddressing", + Property::SubAuthId => "subAuthId", + Property::Subject => "subject", + Property::SubjectAlternativeNames => "subjectAlternativeNames", + Property::Subscribe => "subscribe", + Property::SubscriptionId => "subscriptionId", + Property::Sum => "sum", + Property::Summary => "summary", + Property::SupportedLanguages => "supportedLanguages", + Property::Tag => "tag", + Property::Tags => "tags", + Property::TaskTypes => "taskTypes", + Property::Tasks => "tasks", + Property::TcpOnError => "tcpOnError", + Property::TeamId => "teamId", + Property::TempFailOnError => "tempFailOnError", + Property::Temperature => "temperature", + Property::Template => "template", + Property::TenancyOcid => "tenancyOcid", + Property::TenantId => "tenantId", + Property::Tenants => "tenants", + Property::Text => "text", + Property::Then => "then", + Property::ThirdParty => "thirdParty", + Property::ThirdPartyHash => "thirdPartyHash", + Property::ThreadName => "threadName", + Property::ThreadPoolSize => "threadPoolSize", + Property::ThreadsPerNode => "threadsPerNode", + Property::Throttle => "throttle", + Property::TimeZone => "timeZone", + Property::Timeout => "timeout", + Property::TimeoutAnonymous => "timeoutAnonymous", + Property::TimeoutAuthenticated => "timeoutAuthenticated", + Property::TimeoutCommand => "timeoutCommand", + Property::TimeoutConnect => "timeoutConnect", + Property::TimeoutConnection => "timeoutConnection", + Property::TimeoutData => "timeoutData", + Property::TimeoutIdle => "timeoutIdle", + Property::TimeoutMessage => "timeoutMessage", + Property::TimeoutRequest => "timeoutRequest", + Property::TimeoutSession => "timeoutSession", + Property::Timestamp => "timestamp", + Property::Title => "title", + Property::Tls => "tls", + Property::TlsDisableCipherSuites => "tlsDisableCipherSuites", + Property::TlsDisableProtocols => "tlsDisableProtocols", + Property::TlsIgnoreClientOrder => "tlsIgnoreClientOrder", + Property::TlsImplicit => "tlsImplicit", + Property::TlsTimeout => "tlsTimeout", + Property::To => "to", + Property::Token => "token", + Property::TotalDeadline => "totalDeadline", + Property::TotalFailedSessions => "totalFailedSessions", + Property::TotalSuccessfulSessions => "totalSuccessfulSessions", + Property::TraceId => "traceId", + Property::Tracer => "tracer", + Property::TrainFrequency => "trainFrequency", + Property::TransactionRetryDelay => "transactionRetryDelay", + Property::TransactionRetryLimit => "transactionRetryLimit", + Property::TransactionTimeout => "transactionTimeout", + Property::TransferLimit => "transferLimit", + Property::TrustContacts => "trustContacts", + Property::TrustReplies => "trustReplies", + Property::TsigAlgorithm => "tsigAlgorithm", + Property::Ttl => "ttl", + Property::UnpackDirectory => "unpackDirectory", + Property::UpdateRecords => "updateRecords", + Property::UploadQuota => "uploadQuota", + Property::UploadTtl => "uploadTtl", + Property::Url => "url", + Property::UrlLimit => "urlLimit", + Property::UrlPrefix => "urlPrefix", + Property::Urls => "urls", + Property::UsePermissiveCors => "usePermissiveCors", + Property::UseTls => "useTls", + Property::UseXForwarded => "useXForwarded", + Property::UsedDiskQuota => "usedDiskQuota", + Property::UserAgent => "userAgent", + Property::UserCodeExpiry => "userCodeExpiry", + Property::UserOcid => "userOcid", + Property::Username => "username", + Property::UsernameDomain => "usernameDomain", + Property::VCardVersion => "vCardVersion", + Property::ValidateDomain => "validateDomain", + Property::Value => "value", + Property::VariableName => "variableName", + Property::VerifyAfterWrite => "verifyAfterWrite", + Property::Version => "version", + Property::ViewName => "viewName", + Property::Vrfy => "vrfy", + Property::WaitOnFail => "waitOnFail", + Property::WapiVersion => "wapiVersion", + Property::WebPushContact => "webPushContact", + Property::WebPushKey => "webPushKey", + Property::WebsocketHeartbeat => "websocketHeartbeat", + Property::WebsocketThrottle => "websocketThrottle", + Property::WebsocketTimeout => "websocketTimeout", + Property::Zone => "zone", + Property::ZoneIpV4 => "zoneIpV4", + Property::ZoneIpV6 => "zoneIpV6", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(Property::Type), + 678 => Some(Property::AbuseBanPeriod), + 677 => Some(Property::AbuseBanRate), + 118 => Some(Property::AccessKey), + 327 => Some(Property::AccessKeyId), + 890 => Some(Property::AccessToken), + 619 => Some(Property::AccessTokenExpiry), + 132 => Some(Property::AccessTokens), + 810 => Some(Property::AccountDomainId), + 57 => Some(Property::AccountId), + 315 => Some(Property::AccountIdentifier), + 15 => Some(Property::AccountKey), + 809 => Some(Property::AccountName), + 891 => Some(Property::AccountSwitchKey), + 811 => Some(Property::AccountType), + 16 => Some(Property::AccountUri), + 151 => Some(Property::Accounts), + 182 => Some(Property::AcmeProviderId), + 554 => Some(Property::AddAuthResultsHeader), + 555 => Some(Property::AddDateHeader), + 556 => Some(Property::AddDeliveredToHeader), + 557 => Some(Property::AddMessageIdHeader), + 558 => Some(Property::AddReceivedHeader), + 559 => Some(Property::AddReceivedSpfHeader), + 560 => Some(Property::AddReturnPathHeader), + 838 => Some(Property::AdditionalInformation), + 44 => Some(Property::Address), + 579 => Some(Property::Addresses), + 268 => Some(Property::AggregateContactInfo), + 274 => Some(Property::AggregateDkimSignDomain), + 269 => Some(Property::AggregateFromAddress), + 270 => Some(Property::AggregateFromName), + 271 => Some(Property::AggregateMaxReportSize), + 272 => Some(Property::AggregateOrgName), + 273 => Some(Property::AggregateSendFrequency), + 275 => Some(Property::AggregateSubject), + 798 => Some(Property::AlarmId), + 225 => Some(Property::Algorithms), + 339 => Some(Property::Aliases), + 768 => Some(Property::AllowCount), + 695 => Some(Property::AllowDirectoryQueries), + 164 => Some(Property::AllowExternalRcpts), + 26 => Some(Property::AllowInvalidCerts), + 424 => Some(Property::AllowPlainTextAuth), + 348 => Some(Property::AllowRelaying), + 932 => Some(Property::AllowScimProvisioning), + 369 => Some(Property::AllowSpamTraining), + 398 => Some(Property::AllowedEndpoints), + 49 => Some(Property::AllowedIps), + 712 => Some(Property::AllowedNotifyUris), + 388 => Some(Property::Alpha), + 614 => Some(Property::AnonymousClientRegistration), + 858 => Some(Property::Ansi), + 325 => Some(Property::ApiKey), + 892 => Some(Property::ApiUser), + 321 => Some(Property::ApplicationKey), + 322 => Some(Property::ApplicationSecret), + 292 => Some(Property::ArcResult), + 690 => Some(Property::ArcVerify), + 203 => Some(Property::ArchiveDeletedAccountsFor), + 202 => Some(Property::ArchiveDeletedItemsFor), + 58 => Some(Property::ArchivedAt), + 820 => Some(Property::ArchivedItemType), + 59 => Some(Property::ArchivedUntil), + 68 => Some(Property::ArrivalDate), + 102 => Some(Property::AsnUrls), + 829 => Some(Property::AttemptNumber), + 303 => Some(Property::Attempts), + 470 => Some(Property::AttrClass), + 471 => Some(Property::AttrDescription), + 472 => Some(Property::AttrEmail), + 473 => Some(Property::AttrEmailAlias), + 474 => Some(Property::AttrMemberOf), + 475 => Some(Property::AttrSecret), + 476 => Some(Property::AttrSecretChanged), + 215 => Some(Property::Auid), + 897 => Some(Property::Auth), + 680 => Some(Property::AuthBanPeriod), + 679 => Some(Property::AuthBanRate), + 616 => Some(Property::AuthCodeExpiry), + 613 => Some(Property::AuthCodeMaxAttempts), + 81 => Some(Property::AuthFailure), + 886 => Some(Property::AuthId), + 501 => Some(Property::AuthSecret), + 314 => Some(Property::AuthToken), + 502 => Some(Property::AuthUsername), + 740 => Some(Property::AuthenticatedAs), + 69 => Some(Property::AuthenticationResults), + 171 => Some(Property::AutoAddInvitations), + 53 => Some(Property::AutoUpdateFrequency), + 463 => Some(Property::BaseDn), + 882 => Some(Property::BaseUrl), + 403 => Some(Property::BearerToken), + 389 => Some(Property::Beta), + 589 => Some(Property::Bind), + 466 => Some(Property::BindAuthentication), + 464 => Some(Property::BindDn), + 465 => Some(Property::BindSecret), + 200 => Some(Property::BlobCleanupSchedule), + 60 => Some(Property::BlobId), + 655 => Some(Property::BlobSize), + 126 => Some(Property::BlobStore), + 766 => Some(Property::BlockCount), + 38 => Some(Property::Body), + 459 => Some(Property::Brokers), + 658 => Some(Property::Bucket), + 656 => Some(Property::BufferSize), + 863 => Some(Property::Buffered), + 929 => Some(Property::CacheSize), + 216 => Some(Property::Canonicalization), + 584 => Some(Property::CapacityClient), + 585 => Some(Property::CapacityReadBuffer), + 586 => Some(Property::CapacitySubscription), + 346 => Some(Property::CatchAllAddress), + 759 => Some(Property::Categories), + 176 => Some(Property::Certificate), + 342 => Some(Property::CertificateManagement), + 10 => Some(Property::ChallengeType), + 435 => Some(Property::ChangesMaxResults), + 517 => Some(Property::Chunking), + 612 => Some(Property::ClaimGroups), + 611 => Some(Property::ClaimName), + 609 => Some(Property::ClaimUsername), + 693 => Some(Property::Cleartext), + 604 => Some(Property::ClientId), + 898 => Some(Property::ClientIp), + 878 => Some(Property::ClientSecret), + 889 => Some(Property::ClientToken), + 382 => Some(Property::ClusterFile), + 781 => Some(Property::ColumnClass), + 782 => Some(Property::ColumnDescription), + 779 => Some(Property::ColumnEmail), + 780 => Some(Property::ColumnSecret), + 240 => Some(Property::Comment), + 905 => Some(Property::CompartmentOcid), + 359 => Some(Property::CompressionAlgorithm), + 304 => Some(Property::Concurrency), + 34 => Some(Property::Condition), + 760 => Some(Property::Confidence), + 873 => Some(Property::Config), + 883 => Some(Property::ConfigName), + 505 => Some(Property::ConnectTimeout), + 539 => Some(Property::Connection), + 323 => Some(Property::ConsumerKey), + 11 => Some(Property::Contact), + 844 => Some(Property::ContactInfo), + 133 => Some(Property::Contacts), + 117 => Some(Property::Container), + 65 => Some(Property::Content), + 758 => Some(Property::ContentTypes), + 708 => Some(Property::Contents), + 877 => Some(Property::Context), + 258 => Some(Property::Count), + 367 => Some(Property::Create), + 46 => Some(Property::CreatedAt), + 486 => Some(Property::CreatedBy), + 627 => Some(Property::CredentialId), + 588 => Some(Property::Credentials), + 4 => Some(Property::CurrentSecret), + 662 => Some(Property::CustomEndpoint), + 663 => Some(Property::CustomRegion), + 787 => Some(Property::CustomRule), + 899 => Some(Property::CustomerNumber), + 569 => Some(Property::Dane), + 199 => Some(Property::DataCleanupSchedule), + 125 => Some(Property::DataStore), + 506 => Some(Property::DataTimeout), + 575 => Some(Property::Database), + 383 => Some(Property::DatacenterId), + 245 => Some(Property::DateRangeBegin), + 246 => Some(Property::DateRangeEnd), + 845 => Some(Property::DateRangeStart), + 192 => Some(Property::Day), + 868 => Some(Property::DeadPropertyMaxSize), + 108 => Some(Property::DefaultAdminRoleIds), + 790 => Some(Property::DefaultCertificateId), + 20 => Some(Property::DefaultDisplayName), + 122 => Some(Property::DefaultDomain), + 789 => Some(Property::DefaultDomainId), + 709 => Some(Property::DefaultExpiryDuplicate), + 710 => Some(Property::DefaultExpiryVacation), + 360 => Some(Property::DefaultFolders), + 405 => Some(Property::DefaultFromAddress), + 697 => Some(Property::DefaultFromName), + 106 => Some(Property::DefaultGroupRoleIds), + 788 => Some(Property::DefaultHostname), + 21 => Some(Property::DefaultHrefName), + 665 => Some(Property::DefaultLanguage), + 408 => Some(Property::DefaultName), + 701 => Some(Property::DefaultReturnPath), + 411 => Some(Property::DefaultSubject), + 714 => Some(Property::DefaultSubjectPrefix), + 107 => Some(Property::DefaultTenantRoleIds), + 105 => Some(Property::DefaultUserRoleIds), + 235 => Some(Property::Definition), + 825 => Some(Property::Delay), + 229 => Some(Property::DeleteAfter), + 777 => Some(Property::DeleteAfterUse), + 238 => Some(Property::DeliverAt), + 518 => Some(Property::DeliverBy), + 404 => Some(Property::DeliverTo), + 82 => Some(Property::DeliveryResult), + 381 => Some(Property::Depth), + 6 => Some(Property::Description), + 297 => Some(Property::Details), + 12 => Some(Property::Directory), + 104 => Some(Property::DirectoryId), + 711 => Some(Property::DisableCapabilities), + 629 => Some(Property::DisabledPermissions), + 872 => Some(Property::DiscardAfter), + 747 => Some(Property::Disposition), + 917 => Some(Property::Dkim2Pass), + 916 => Some(Property::Dkim2Result), + 83 => Some(Property::DkimAdspDns), + 84 => Some(Property::DkimCanonicalizedBody), + 85 => Some(Property::DkimCanonicalizedHeader), + 86 => Some(Property::DkimDomain), + 87 => Some(Property::DkimIdentity), + 343 => Some(Property::DkimManagement), + 291 => Some(Property::DkimPass), + 266 => Some(Property::DkimResults), + 88 => Some(Property::DkimSelector), + 89 => Some(Property::DkimSelectorDns), + 231 => Some(Property::DkimSignDomain), + 155 => Some(Property::DkimSignatures), + 686 => Some(Property::DkimStrict), + 687 => Some(Property::DkimVerify), + 294 => Some(Property::DmarcPass), + 295 => Some(Property::DmarcPolicy), + 293 => Some(Property::DmarcResult), + 691 => Some(Property::DmarcVerify), + 134 => Some(Property::DnsIpv4), + 135 => Some(Property::DnsIpv6), + 344 => Some(Property::DnsManagement), + 136 => Some(Property::DnsMtaSts), + 137 => Some(Property::DnsMx), + 138 => Some(Property::DnsPtr), + 139 => Some(Property::DnsRbl), + 130 => Some(Property::DnsServer), + 300 => Some(Property::DnsServerId), + 140 => Some(Property::DnsTlsa), + 141 => Some(Property::DnsTxt), + 894 => Some(Property::DnsView), + 345 => Some(Property::DnsZoneFile), + 804 => Some(Property::DocumentId), + 814 => Some(Property::DocumentType), + 232 => Some(Property::Domain), + 221 => Some(Property::DomainId), + 750 => Some(Property::DomainLimit), + 147 => Some(Property::DomainNames), + 148 => Some(Property::DomainNamesNegative), + 146 => Some(Property::Domains), + 519 => Some(Property::Dsn), + 797 => Some(Property::Due), + 699 => Some(Property::DuplicateExpiry), + 515 => Some(Property::Duration), + 13 => Some(Property::EabHmacKey), + 14 => Some(Property::EabKeyId), + 283 => Some(Property::EhloDomain), + 503 => Some(Property::EhloHostname), + 507 => Some(Property::EhloTimeout), + 296 => Some(Property::Elapsed), + 375 => Some(Property::Else), + 242 => Some(Property::Email), + 393 => Some(Property::EmailAddress), + 149 => Some(Property::EmailAddresses), + 150 => Some(Property::EmailAddressesNegative), + 35 => Some(Property::EmailAlert), + 488 => Some(Property::EmailDomain), + 751 => Some(Property::EmailLimit), + 487 => Some(Property::EmailPrefix), + 174 => Some(Property::EmailTemplate), + 37 => Some(Property::Enable), + 865 => Some(Property::EnableAssistedDiscovery), + 305 => Some(Property::EnableEdns), + 399 => Some(Property::EnableHsts), + 860 => Some(Property::EnableLogExporter), + 562 => Some(Property::EnableSpamFilter), + 861 => Some(Property::EnableSpanExporter), + 50 => Some(Property::Enabled), + 628 => Some(Property::EnabledPermissions), + 358 => Some(Property::EncryptAtRest), + 357 => Some(Property::EncryptOnAppend), + 9 => Some(Property::EncryptionAtRest), + 622 => Some(Property::EncryptionKey), + 499 => Some(Property::Endpoint), + 742 => Some(Property::EnvFrom), + 743 => Some(Property::EnvFromParameters), + 639 => Some(Property::EnvId), + 744 => Some(Property::EnvRcptTo), + 264 => Some(Property::EnvelopeFrom), + 263 => Some(Property::EnvelopeTo), + 881 => Some(Property::Environment), + 210 => Some(Property::ErrorCommand), + 209 => Some(Property::ErrorMessage), + 208 => Some(Property::ErrorType), + 247 => Some(Property::Errors), + 259 => Some(Property::EvaluatedDisposition), + 260 => Some(Property::EvaluatedDkim), + 261 => Some(Property::EvaluatedSpf), + 372 => Some(Property::Event), + 36 => Some(Property::EventAlert), + 801 => Some(Property::EventEnd), + 803 => Some(Property::EventEndTz), + 799 => Some(Property::EventId), + 43 => Some(Property::EventMessage), + 447 => Some(Property::EventSourceThrottle), + 800 => Some(Property::EventStart), + 802 => Some(Property::EventStartTz), + 142 => Some(Property::Events), + 855 => Some(Property::EventsPolicy), + 217 => Some(Property::Expire), + 100 => Some(Property::Expires), + 47 => Some(Property::ExpiresAt), + 632 => Some(Property::ExpiresAttempts), + 512 => Some(Property::Expiry), + 520 => Some(Property::Expn), + 198 => Some(Property::ExpungeSchedule), + 197 => Some(Property::ExpungeSchedulingInboxAfter), + 196 => Some(Property::ExpungeShareNotifyAfter), + 195 => Some(Property::ExpungeSubmissionsAfter), + 194 => Some(Property::ExpungeTrashAfter), + 754 => Some(Property::Extension), + 257 => Some(Property::Extensions), + 933 => Some(Property::ExternalId), + 243 => Some(Property::ExtraContactInfo), + 821 => Some(Property::Factor), + 490 => Some(Property::FailOnTimeout), + 826 => Some(Property::FailedAt), + 827 => Some(Property::FailedAttemptNumber), + 837 => Some(Property::FailedSessionCount), + 851 => Some(Property::FailureDetails), + 279 => Some(Property::FailureDkimSignDomain), + 276 => Some(Property::FailureFromAddress), + 277 => Some(Property::FailureFromName), + 828 => Some(Property::FailureReason), + 839 => Some(Property::FailureReasonCode), + 278 => Some(Property::FailureSendFrequency), + 280 => Some(Property::FailureSubject), + 738 => Some(Property::FeatureL2Normalize), + 739 => Some(Property::FeatureLogScale), + 67 => Some(Property::FeedbackType), + 406 => Some(Property::FieldEmail), + 407 => Some(Property::FieldHoneyPot), + 409 => Some(Property::FieldName), + 412 => Some(Property::FieldSubject), + 676 => Some(Property::FilePath), + 144 => Some(Property::Files), + 467 => Some(Property::FilterLogin), + 468 => Some(Property::FilterMailbox), + 469 => Some(Property::FilterMemberOf), + 902 => Some(Property::Fingerprint), + 638 => Some(Property::Flags), + 537 => Some(Property::FlagsAction), + 538 => Some(Property::FlagsProtocol), + 909 => Some(Property::FolderId), + 485 => Some(Property::ForDomain), + 415 => Some(Property::Format), + 62 => Some(Property::From), + 39 => Some(Property::FromAddress), + 165 => Some(Property::FromEmail), + 40 => Some(Property::FromName), + 521 => Some(Property::FutureRelease), + 124 => Some(Property::GenerateDkimKeys), + 918 => Some(Property::Generator), + 103 => Some(Property::GeoUrls), + 436 => Some(Property::GetMaxResults), + 508 => Some(Property::GreetingTimeout), + 770 => Some(Property::GreylistFor), + 477 => Some(Property::GroupClass), + 460 => Some(Property::GroupId), + 265 => Some(Property::HeaderFrom), + 93 => Some(Property::Headers), + 206 => Some(Property::HoldMetricsFor), + 204 => Some(Property::HoldMtaReportsFor), + 730 => Some(Property::HoldSamplesFor), + 205 => Some(Property::HoldTracesFor), + 333 => Some(Property::Host), + 331 => Some(Property::HostedZoneId), + 185 => Some(Property::Hostname), + 190 => Some(Property::Hour), + 32 => Some(Property::HttpAuth), + 33 => Some(Property::HttpHeaders), + 168 => Some(Property::HttpRsvpEnable), + 169 => Some(Property::HttpRsvpLinkExpiry), + 175 => Some(Property::HttpRsvpTemplate), + 170 => Some(Property::HttpRsvpUrl), + 842 => Some(Property::HttpRua), + 234 => Some(Property::HumanResult), + 807 => Some(Property::ICalendarData), + 1 => Some(Property::Id), + 621 => Some(Property::IdTokenExpiry), + 91 => Some(Property::IdentityAlignment), + 376 => Some(Property::If), + 320 => Some(Property::ImpersonateServiceAccount), + 546 => Some(Property::ImplicitTls), + 128 => Some(Property::InMemoryStore), + 651 => Some(Property::InboundReportAddresses), + 652 => Some(Property::InboundReportForwarding), + 928 => Some(Property::InboundReportMaxSize), + 70 => Some(Property::Incidents), + 352 => Some(Property::IncludeSource), + 94 => Some(Property::IndexAsn), + 95 => Some(Property::IndexAsnName), + 664 => Some(Property::IndexBatchSize), + 667 => Some(Property::IndexCalendar), + 668 => Some(Property::IndexCalendarFields), + 670 => Some(Property::IndexContactFields), + 669 => Some(Property::IndexContacts), + 96 => Some(Property::IndexCountry), + 671 => Some(Property::IndexEmail), + 672 => Some(Property::IndexEmailFields), + 421 => Some(Property::IndexKey), + 673 => Some(Property::IndexTelemetry), + 674 => Some(Property::IndexTracingFields), + 422 => Some(Property::IndexValue), + 736 => Some(Property::IndicatorParameters), + 822 => Some(Property::InitialDelay), + 500 => Some(Property::Interval), + 516 => Some(Property::Intervals), + 752 => Some(Property::IpLimit), + 543 => Some(Property::IpLookupStrategy), + 290 => Some(Property::IpRevPtr), + 289 => Some(Property::IpRevResult), + 707 => Some(Property::IsActive), + 755 => Some(Property::IsArchive), + 756 => Some(Property::IsBad), + 340 => Some(Property::IsEnabled), + 806 => Some(Property::IsFromOrganizer), + 491 => Some(Property::IsGlobPattern), + 416 => Some(Property::IsGzipped), + 757 => Some(Property::IsNz), + 564 => Some(Property::IsSenderAllowed), + 776 => Some(Property::IsSpam), + 741 => Some(Property::IsTls), + 181 => Some(Property::Issuer), + 606 => Some(Property::IssuerUrl), + 172 => Some(Property::ItipMaxSize), + 824 => Some(Property::Jitter), + 334 => Some(Property::Key), + 337 => Some(Property::KeyName), + 120 => Some(Property::KeyPrefix), + 853 => Some(Property::KeyValues), + 391 => Some(Property::L1Ratio), + 392 => Some(Property::L2Ratio), + 186 => Some(Property::LastRenewal), + 727 => Some(Property::LearnHamFromCard), + 735 => Some(Property::LearnHamFromReply), + 728 => Some(Property::LearnSpamFromRblHits), + 729 => Some(Property::LearnSpamFromTraps), + 373 => Some(Property::Level), + 370 => Some(Property::LicenseKey), + 875 => Some(Property::Line), + 183 => Some(Property::ListenerIds), + 188 => Some(Property::Listeners), + 869 => Some(Property::LivePropertyMaxSize), + 7 => Some(Property::Locale), + 341 => Some(Property::Logo), + 371 => Some(Property::LogoUrl), + 682 => Some(Property::LoiterBanPeriod), + 681 => Some(Property::LoiterBanRate), + 854 => Some(Property::Lossy), + 384 => Some(Property::MachineId), + 793 => Some(Property::MailExchangers), + 284 => Some(Property::MailFrom), + 509 => Some(Property::MailFromTimeout), + 841 => Some(Property::MailRua), + 154 => Some(Property::MailingLists), + 796 => Some(Property::MaintenanceType), + 318 => Some(Property::ManagedZone), + 374 => Some(Property::Match), + 23 => Some(Property::MaxAddressBooks), + 566 => Some(Property::MaxAge), + 576 => Some(Property::MaxAllowedPacket), + 115 => Some(Property::MaxApiKeys), + 114 => Some(Property::MaxAppPasswords), + 353 => Some(Property::MaxAttachmentSize), + 511 => Some(Property::MaxAttempts), + 157 => Some(Property::MaxAttendees), + 425 => Some(Property::MaxAuthFailures), + 160 => Some(Property::MaxCalendars), + 201 => Some(Property::MaxChangesHistory), + 426 => Some(Property::MaxConcurrent), + 439 => Some(Property::MaxConcurrentRequests), + 442 => Some(Property::MaxConcurrentUploads), + 603 => Some(Property::MaxConnections), + 24 => Some(Property::MaxContacts), + 702 => Some(Property::MaxCpuCycles), + 823 => Some(Property::MaxDelay), + 530 => Some(Property::MaxDuration), + 417 => Some(Property::MaxEntries), + 418 => Some(Property::MaxEntrySize), + 163 => Some(Property::MaxEventNotifications), + 161 => Some(Property::MaxEvents), + 547 => Some(Property::MaxFailures), + 378 => Some(Property::MaxFiles), + 379 => Some(Property::MaxFolders), + 715 => Some(Property::MaxHeaderSize), + 159 => Some(Property::MaxICalendarSize), + 363 => Some(Property::MaxIdentities), + 716 => Some(Property::MaxIncludes), + 717 => Some(Property::MaxLocalVars), + 866 => Some(Property::MaxLockTimeout), + 867 => Some(Property::MaxLocks), + 355 => Some(Property::MaxMailboxDepth), + 356 => Some(Property::MaxMailboxNameLength), + 364 => Some(Property::MaxMailboxes), + 365 => Some(Property::MaxMaskedAddresses), + 718 => Some(Property::MaxMatchVars), + 354 => Some(Property::MaxMessageSize), + 361 => Some(Property::MaxMessages), + 924 => Some(Property::MaxMessagesPerCommand), + 927 => Some(Property::MaxMessagesPerSave), + 438 => Some(Property::MaxMethodCalls), + 544 => Some(Property::MaxMultihomed), + 545 => Some(Property::MaxMxHosts), + 720 => Some(Property::MaxNestedBlocks), + 721 => Some(Property::MaxNestedForEvery), + 703 => Some(Property::MaxNestedIncludes), + 722 => Some(Property::MaxNestedTests), + 704 => Some(Property::MaxOutMessages), + 162 => Some(Property::MaxParticipantIdentities), + 366 => Some(Property::MaxPublicKeys), + 923 => Some(Property::MaxPushSize), + 561 => Some(Property::MaxReceivedHeaders), + 173 => Some(Property::MaxRecipients), + 580 => Some(Property::MaxReconnects), + 158 => Some(Property::MaxRecurrenceExpansions), + 705 => Some(Property::MaxRedirects), + 852 => Some(Property::MaxReportSize), + 427 => Some(Property::MaxRequestRate), + 428 => Some(Property::MaxRequestSize), + 527 => Some(Property::MaxResponseSize), + 871 => Some(Property::MaxResults), + 18 => Some(Property::MaxRetries), + 648 => Some(Property::MaxRetryWait), + 719 => Some(Property::MaxScriptNameLength), + 723 => Some(Property::MaxScriptSize), + 726 => Some(Property::MaxScripts), + 696 => Some(Property::MaxShares), + 101 => Some(Property::MaxSize), + 724 => Some(Property::MaxStringLength), + 362 => Some(Property::MaxSubmissions), + 458 => Some(Property::MaxSubscriptions), + 926 => Some(Property::MaxUidBatches), + 444 => Some(Property::MaxUploadCount), + 443 => Some(Property::MaxUploadSize), + 22 => Some(Property::MaxVCardSize), + 725 => Some(Property::MaxVarNameLength), + 706 => Some(Property::MaxVarSize), + 864 => Some(Property::MemberGroupIds), + 19 => Some(Property::MemberTenantId), + 92 => Some(Property::Message), + 698 => Some(Property::MessageIdHostname), + 819 => Some(Property::MessageIds), + 145 => Some(Property::Messages), + 493 => Some(Property::Metric), + 497 => Some(Property::Metrics), + 207 => Some(Property::MetricsCollectionInterval), + 498 => Some(Property::MetricsPolicy), + 731 => Some(Property::MinHamSamples), + 649 => Some(Property::MinRetryWait), + 732 => Some(Property::MinSpamSamples), + 166 => Some(Property::MinTriggerInterval), + 925 => Some(Property::MinUidBatchSize), + 191 => Some(Property::Minute), + 567 => Some(Property::Mode), + 28 => Some(Property::Model), + 764 => Some(Property::ModelId), + 30 => Some(Property::ModelType), + 522 => Some(Property::MtPriority), + 570 => Some(Property::MtaSts), + 572 => Some(Property::MtaStsTimeout), + 859 => Some(Property::Multiline), + 550 => Some(Property::MustMatchSender), + 568 => Some(Property::MxHosts), + 25 => Some(Property::Name), + 414 => Some(Property::Namespace), + 156 => Some(Property::NegativeTtl), + 634 => Some(Property::NextNotify), + 633 => Some(Property::NextRetry), + 223 => Some(Property::NextTransitionAt), + 700 => Some(Property::NoCapabilityCheck), + 587 => Some(Property::NoEcho), + 523 => Some(Property::NoSoliciting), + 184 => Some(Property::NodeId), + 179 => Some(Property::NotValidAfter), + 180 => Some(Property::NotValidBefore), + 513 => Some(Property::Notify), + 642 => Some(Property::NotifyCount), + 643 => Some(Property::NotifyDue), + 390 => Some(Property::NumFeatures), + 350 => Some(Property::NumReplicas), + 351 => Some(Property::NumShards), + 930 => Some(Property::OauthClientId), + 813 => Some(Property::OnSuccessRenewCertificate), + 495 => Some(Property::OpenTelemetry), + 630 => Some(Property::Options), + 645 => Some(Property::Orcpt), + 241 => Some(Property::OrgName), + 843 => Some(Property::OrganizationName), + 301 => Some(Property::Origin), + 71 => Some(Property::OriginalEnvelopeId), + 72 => Some(Property::OriginalMailFrom), + 73 => Some(Property::OriginalRcptTo), + 5 => Some(Property::OtpAuth), + 625 => Some(Property::OtpCode), + 626 => Some(Property::OtpUrl), + 653 => Some(Property::OutboundReportDomain), + 654 => Some(Property::OutboundReportSubmitter), + 590 => Some(Property::OverrideProxyTrustedNetworks), + 239 => Some(Property::OverrideType), + 324 => Some(Property::OvhEndpoint), + 737 => Some(Property::Parameters), + 433 => Some(Property::ParseLimitContact), + 434 => Some(Property::ParseLimitEmail), + 432 => Some(Property::ParseLimitEvent), + 876 => Some(Property::Password), + 113 => Some(Property::PasswordDefaultExpiry), + 109 => Some(Property::PasswordHashAlgorithm), + 111 => Some(Property::PasswordMaxLength), + 110 => Some(Property::PasswordMinLength), + 112 => Some(Property::PasswordMinStrength), + 380 => Some(Property::Path), + 646 => Some(Property::Period), + 48 => Some(Property::Permissions), + 583 => Some(Property::PingInterval), + 524 => Some(Property::Pipelining), + 846 => Some(Property::Policies), + 250 => Some(Property::PolicyAdkim), + 251 => Some(Property::PolicyAspf), + 920 => Some(Property::PolicyDiscoveryMethod), + 252 => Some(Property::PolicyDisposition), + 248 => Some(Property::PolicyDomain), + 255 => Some(Property::PolicyFailureReportingOptions), + 237 => Some(Property::PolicyIdentifier), + 840 => Some(Property::PolicyIdentifiers), + 919 => Some(Property::PolicyNp), + 262 => Some(Property::PolicyOverrideReasons), + 848 => Some(Property::PolicyStrings), + 253 => Some(Property::PolicySubdomainDisposition), + 254 => Some(Property::PolicyTestingMode), + 847 => Some(Property::PolicyType), + 249 => Some(Property::PolicyVersion), + 489 => Some(Property::PollInterval), + 311 => Some(Property::PollingInterval), + 478 => Some(Property::PoolMaxConnections), + 577 => Some(Property::PoolMinConnections), + 631 => Some(Property::PoolRecyclingMethod), + 479 => Some(Property::PoolTimeoutCreate), + 480 => Some(Property::PoolTimeoutRecycle), + 481 => Some(Property::PoolTimeoutWait), + 657 => Some(Property::PoolWorkers), + 299 => Some(Property::Port), + 910 => Some(Property::PreferredChain), + 856 => Some(Property::Prefix), + 306 => Some(Property::PreserveIntermediates), + 483 => Some(Property::Priority), + 177 => Some(Property::PrivateKey), + 904 => Some(Property::PrivateKeyPassword), + 903 => Some(Property::PrivateKeyPem), + 319 => Some(Property::PrivateZone), + 332 => Some(Property::PrivateZoneOnly), + 661 => Some(Property::Profile), + 317 => Some(Property::ProjectId), + 496 => Some(Property::Prometheus), + 765 => Some(Property::Prompt), + 313 => Some(Property::PropagationDelay), + 312 => Some(Property::PropagationTimeout), + 713 => Some(Property::ProtectedHeaders), + 298 => Some(Property::Protocol), + 533 => Some(Property::ProtocolVersion), + 795 => Some(Property::ProviderInfo), + 792 => Some(Property::ProxyTrustedNetworks), + 218 => Some(Property::PublicKey), + 302 => Some(Property::PublishRecords), + 448 => Some(Property::PushAttemptWait), + 449 => Some(Property::PushMaxAttempts), + 452 => Some(Property::PushRequestTimeout), + 450 => Some(Property::PushRetryWait), + 454 => Some(Property::PushShardsTotal), + 451 => Some(Property::PushThrottle), + 453 => Some(Property::PushVerifyTimeout), + 786 => Some(Property::QueryEmailAliases), + 783 => Some(Property::QueryLogin), + 437 => Some(Property::QueryMaxResults), + 785 => Some(Property::QueryMemberOf), + 784 => Some(Property::QueryRecipient), + 514 => Some(Property::QueueId), + 644 => Some(Property::QueueName), + 394 => Some(Property::Quotas), + 532 => Some(Property::Rate), + 410 => Some(Property::RateLimit), + 397 => Some(Property::RateLimitAnonymous), + 396 => Some(Property::RateLimitAuthenticated), + 767 => Some(Property::Ratio), + 510 => Some(Property::RcptToTimeout), + 650 => Some(Property::ReadFromReplicas), + 578 => Some(Property::ReadReplicas), + 45 => Some(Property::Reason), + 63 => Some(Property::ReceivedAt), + 636 => Some(Property::ReceivedFromIp), + 637 => Some(Property::ReceivedViaPort), + 836 => Some(Property::ReceivingIp), + 835 => Some(Property::ReceivingMxHelo), + 834 => Some(Property::ReceivingMxHostname), + 484 => Some(Property::Recipients), + 256 => Some(Property::Records), + 805 => Some(Property::RecurrenceId), + 911 => Some(Property::RedirectRoot), + 605 => Some(Property::RedirectUris), + 419 => Some(Property::Refresh), + 617 => Some(Property::RefreshTokenExpiry), + 618 => Some(Property::RefreshTokenRenewal), + 330 => Some(Property::Region), + 563 => Some(Property::RejectNonFqdn), + 282 => Some(Property::RemoteIp), + 17 => Some(Property::RenewBefore), + 66 => Some(Property::Report), + 349 => Some(Property::ReportAddressUri), + 244 => Some(Property::ReportId), + 74 => Some(Property::ReportedDomains), + 75 => Some(Property::ReportedUris), + 76 => Some(Property::ReportingMta), + 870 => Some(Property::RequestMaxSize), + 123 => Some(Property::RequestTlsCertificate), + 551 => Some(Property::Require), + 607 => Some(Property::RequireAudience), + 615 => Some(Property::RequireClientRegistration), + 608 => Some(Property::RequireScopes), + 525 => Some(Property::RequireTls), + 733 => Some(Property::ReservoirCapacity), + 880 => Some(Property::ResourceGroup), + 51 => Some(Property::ResourceUrl), + 212 => Some(Property::ResponseCode), + 213 => Some(Property::ResponseEnhanced), + 401 => Some(Property::ResponseHeaders), + 211 => Some(Property::ResponseHostname), + 214 => Some(Property::ResponseMessage), + 761 => Some(Property::ResponsePosCategory), + 762 => Some(Property::ResponsePosConfidence), + 763 => Some(Property::ResponsePosExplanation), + 233 => Some(Property::Result), + 832 => Some(Property::ResultType), + 228 => Some(Property::RetireAfter), + 420 => Some(Property::Retry), + 640 => Some(Property::RetryCount), + 641 => Some(Property::RetryDue), + 635 => Some(Property::ReturnPath), + 912 => Some(Property::ReuseKey), + 692 => Some(Property::ReverseIpVerify), + 565 => Some(Property::Rewrite), + 193 => Some(Property::RoleIds), + 152 => Some(Property::Roles), + 857 => Some(Property::Rotate), + 227 => Some(Property::RotateAfter), + 540 => Some(Property::Route), + 236 => Some(Property::Rua), + 896 => Some(Property::Sandbox), + 119 => Some(Property::SasToken), + 549 => Some(Property::SaslMechanisms), + 683 => Some(Property::ScanBanPaths), + 685 => Some(Property::ScanBanPeriod), + 684 => Some(Property::ScanBanRate), + 541 => Some(Property::Schedule), + 143 => Some(Property::Scheduling), + 908 => Some(Property::Scheme), + 281 => Some(Property::Scope), + 745 => Some(Property::Score), + 771 => Some(Property::ScoreDiscard), + 772 => Some(Property::ScoreReject), + 773 => Some(Property::ScoreSpam), + 553 => Some(Property::Script), + 127 => Some(Property::SearchStore), + 3 => Some(Property::Secret), + 328 => Some(Property::SecretAccessKey), + 326 => Some(Property::SecretApiKey), + 906 => Some(Property::SecretId), + 659 => Some(Property::SecretKey), + 660 => Some(Property::SecurityToken), + 222 => Some(Property::Selector), + 226 => Some(Property::SelectorTemplate), + 230 => Some(Property::SendFrequency), + 833 => Some(Property::SendingMtaIp), + 915 => Some(Property::SentinelSecret), + 914 => Some(Property::SentinelUsername), + 97 => Some(Property::Separator), + 121 => Some(Property::ServerHostname), + 308 => Some(Property::Servers), + 316 => Some(Property::ServiceAccountJson), + 913 => Some(Property::ServiceName), + 794 => Some(Property::Services), + 329 => Some(Property::SessionToken), + 440 => Some(Property::SetMaxObjects), + 830 => Some(Property::ShardIndex), + 895 => Some(Property::SharedSecret), + 336 => Some(Property::Sig0Algorithm), + 623 => Some(Property::SignatureAlgorithm), + 624 => Some(Property::SignatureKey), + 335 => Some(Property::SignerName), + 64 => Some(Property::Size), + 885 => Some(Property::SkipDeploy), + 423 => Some(Property::SkipFirst), + 552 => Some(Property::SmtpGreeting), + 441 => Some(Property::SnippetMaxResults), + 591 => Some(Property::SocketBacklog), + 592 => Some(Property::SocketNoDelay), + 593 => Some(Property::SocketReceiveBufferSize), + 594 => Some(Property::SocketReuseAddress), + 595 => Some(Property::SocketReusePort), + 596 => Some(Property::SocketSendBufferSize), + 597 => Some(Property::SocketTosV4), + 598 => Some(Property::SocketTtl), + 77 => Some(Property::SourceIp), + 504 => Some(Property::SourceIps), + 78 => Some(Property::SourcePort), + 775 => Some(Property::SpamFilterRulesUrl), + 90 => Some(Property::SpfDns), + 285 => Some(Property::SpfEhloDomain), + 286 => Some(Property::SpfEhloResult), + 688 => Some(Property::SpfEhloVerify), + 689 => Some(Property::SpfFromVerify), + 287 => Some(Property::SpfMailFromDomain), + 288 => Some(Property::SpfMailFromResult), + 267 => Some(Property::SpfResults), + 224 => Some(Property::Stage), + 529 => Some(Property::Stages), + 56 => Some(Property::StartTime), + 571 => Some(Property::StartTls), + 61 => Some(Property::Status), + 116 => Some(Property::StorageAccount), + 778 => Some(Property::Store), + 694 => Some(Property::Stores), + 816 => Some(Property::Strategy), + 347 => Some(Property::SubAddressing), + 887 => Some(Property::SubAuthId), + 41 => Some(Property::Subject), + 178 => Some(Property::SubjectAlternativeNames), + 368 => Some(Property::Subscribe), + 879 => Some(Property::SubscriptionId), + 494 => Some(Property::Sum), + 808 => Some(Property::Summary), + 666 => Some(Property::SupportedLanguages), + 748 => Some(Property::Tag), + 746 => Some(Property::Tags), + 189 => Some(Property::TaskTypes), + 187 => Some(Property::Tasks), + 307 => Some(Property::TcpOnError), + 907 => Some(Property::TeamId), + 528 => Some(Property::TempFailOnError), + 27 => Some(Property::Temperature), + 167 => Some(Property::Template), + 900 => Some(Property::TenancyOcid), + 831 => Some(Property::TenantId), + 153 => Some(Property::Tenants), + 2 => Some(Property::Text), + 377 => Some(Property::Then), + 219 => Some(Property::ThirdParty), + 220 => Some(Property::ThirdPartyHash), + 818 => Some(Property::ThreadName), + 791 => Some(Property::ThreadPoolSize), + 574 => Some(Property::ThreadsPerNode), + 862 => Some(Property::Throttle), + 8 => Some(Property::TimeZone), + 29 => Some(Property::Timeout), + 429 => Some(Property::TimeoutAnonymous), + 430 => Some(Property::TimeoutAuthenticated), + 534 => Some(Property::TimeoutCommand), + 535 => Some(Property::TimeoutConnect), + 581 => Some(Property::TimeoutConnection), + 536 => Some(Property::TimeoutData), + 431 => Some(Property::TimeoutIdle), + 461 => Some(Property::TimeoutMessage), + 582 => Some(Property::TimeoutRequest), + 462 => Some(Property::TimeoutSession), + 482 => Some(Property::Timestamp), + 55 => Some(Property::Title), + 542 => Some(Property::Tls), + 599 => Some(Property::TlsDisableCipherSuites), + 600 => Some(Property::TlsDisableProtocols), + 601 => Some(Property::TlsIgnoreClientOrder), + 602 => Some(Property::TlsImplicit), + 573 => Some(Property::TlsTimeout), + 42 => Some(Property::To), + 888 => Some(Property::Token), + 817 => Some(Property::TotalDeadline), + 850 => Some(Property::TotalFailedSessions), + 849 => Some(Property::TotalSuccessfulSessions), + 815 => Some(Property::TraceId), + 129 => Some(Property::Tracer), + 734 => Some(Property::TrainFrequency), + 385 => Some(Property::TransactionRetryDelay), + 386 => Some(Property::TransactionRetryLimit), + 387 => Some(Property::TransactionTimeout), + 531 => Some(Property::TransferLimit), + 769 => Some(Property::TrustContacts), + 774 => Some(Property::TrustReplies), + 338 => Some(Property::TsigAlgorithm), + 310 => Some(Property::Ttl), + 54 => Some(Property::UnpackDirectory), + 812 => Some(Property::UpdateRecords), + 445 => Some(Property::UploadQuota), + 446 => Some(Property::UploadTtl), + 31 => Some(Property::Url), + 753 => Some(Property::UrlLimit), + 52 => Some(Property::UrlPrefix), + 647 => Some(Property::Urls), + 400 => Some(Property::UsePermissiveCors), + 309 => Some(Property::UseTls), + 402 => Some(Property::UseXForwarded), + 395 => Some(Property::UsedDiskQuota), + 79 => Some(Property::UserAgent), + 620 => Some(Property::UserCodeExpiry), + 901 => Some(Property::UserOcid), + 131 => Some(Property::Username), + 610 => Some(Property::UsernameDomain), + 931 => Some(Property::VCardVersion), + 413 => Some(Property::ValidateDomain), + 492 => Some(Property::Value), + 675 => Some(Property::VariableName), + 874 => Some(Property::VerifyAfterWrite), + 80 => Some(Property::Version), + 884 => Some(Property::ViewName), + 526 => Some(Property::Vrfy), + 548 => Some(Property::WaitOnFail), + 893 => Some(Property::WapiVersion), + 922 => Some(Property::WebPushContact), + 921 => Some(Property::WebPushKey), + 455 => Some(Property::WebsocketHeartbeat), + 456 => Some(Property::WebsocketThrottle), + 457 => Some(Property::WebsocketTimeout), + 749 => Some(Property::Zone), + 98 => Some(Property::ZoneIpV4), + 99 => Some(Property::ZoneIpV6), + _ => None, + } + } + + const COUNT: usize = 934; +} + +impl serde::Serialize for Property { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Property { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + +impl ObjectType { + pub fn flags(&self) -> u64 { + match self { + ObjectType::Account => Account::FLAGS, + ObjectType::AccountPassword => AccountPassword::FLAGS, + ObjectType::AccountSettings => AccountSettings::FLAGS, + ObjectType::AcmeProvider => AcmeProvider::FLAGS, + ObjectType::Action => Action::FLAGS, + ObjectType::AddressBook => AddressBook::FLAGS, + ObjectType::AiModel => AiModel::FLAGS, + ObjectType::Alert => Alert::FLAGS, + ObjectType::AllowedIp => AllowedIp::FLAGS, + ObjectType::ApiKey => ApiKey::FLAGS, + ObjectType::AppPassword => AppPassword::FLAGS, + ObjectType::Application => Application::FLAGS, + ObjectType::ArchivedItem => ArchivedItem::FLAGS, + ObjectType::ArfExternalReport => ArfExternalReport::FLAGS, + ObjectType::Asn => Asn::FLAGS, + ObjectType::Authentication => Authentication::FLAGS, + ObjectType::BlobStore => BlobStore::FLAGS, + ObjectType::BlockedIp => BlockedIp::FLAGS, + ObjectType::Bootstrap => Bootstrap::FLAGS, + ObjectType::Cache => Cache::FLAGS, + ObjectType::Calendar => Calendar::FLAGS, + ObjectType::CalendarAlarm => CalendarAlarm::FLAGS, + ObjectType::CalendarScheduling => CalendarScheduling::FLAGS, + ObjectType::Certificate => Certificate::FLAGS, + ObjectType::ClusterNode => ClusterNode::FLAGS, + ObjectType::ClusterRole => ClusterRole::FLAGS, + ObjectType::Coordinator => Coordinator::FLAGS, + ObjectType::DataRetention => DataRetention::FLAGS, + ObjectType::DataStore => DataStore::FLAGS, + ObjectType::Directory => Directory::FLAGS, + ObjectType::DkimReportSettings => DkimReportSettings::FLAGS, + ObjectType::DkimSignature => DkimSignature::FLAGS, + ObjectType::DmarcExternalReport => DmarcExternalReport::FLAGS, + ObjectType::DmarcInternalReport => DmarcInternalReport::FLAGS, + ObjectType::DmarcReportSettings => DmarcReportSettings::FLAGS, + ObjectType::DnsResolver => DnsResolver::FLAGS, + ObjectType::DnsServer => DnsServer::FLAGS, + ObjectType::Domain => Domain::FLAGS, + ObjectType::DsnReportSettings => DsnReportSettings::FLAGS, + ObjectType::Email => Email::FLAGS, + ObjectType::Enterprise => Enterprise::FLAGS, + ObjectType::EventTracingLevel => EventTracingLevel::FLAGS, + ObjectType::FileStorage => FileStorage::FLAGS, + ObjectType::Http => Http::FLAGS, + ObjectType::HttpForm => HttpForm::FLAGS, + ObjectType::HttpLookup => HttpLookup::FLAGS, + ObjectType::Imap => Imap::FLAGS, + ObjectType::InMemoryStore => InMemoryStore::FLAGS, + ObjectType::Jmap => Jmap::FLAGS, + ObjectType::Log => Log::FLAGS, + ObjectType::MailingList => MailingList::FLAGS, + ObjectType::MaskedEmail => MaskedEmail::FLAGS, + ObjectType::MemoryLookupKey => MemoryLookupKey::FLAGS, + ObjectType::MemoryLookupKeyValue => MemoryLookupKeyValue::FLAGS, + ObjectType::Metric => Metric::FLAGS, + ObjectType::Metrics => Metrics::FLAGS, + ObjectType::MetricsStore => MetricsStore::FLAGS, + ObjectType::MtaConnectionStrategy => MtaConnectionStrategy::FLAGS, + ObjectType::MtaDeliverySchedule => MtaDeliverySchedule::FLAGS, + ObjectType::MtaExtensions => MtaExtensions::FLAGS, + ObjectType::MtaHook => MtaHook::FLAGS, + ObjectType::MtaInboundSession => MtaInboundSession::FLAGS, + ObjectType::MtaInboundThrottle => MtaInboundThrottle::FLAGS, + ObjectType::MtaMilter => MtaMilter::FLAGS, + ObjectType::MtaOutboundStrategy => MtaOutboundStrategy::FLAGS, + ObjectType::MtaOutboundThrottle => MtaOutboundThrottle::FLAGS, + ObjectType::MtaQueueQuota => MtaQueueQuota::FLAGS, + ObjectType::MtaRoute => MtaRoute::FLAGS, + ObjectType::MtaStageAuth => MtaStageAuth::FLAGS, + ObjectType::MtaStageConnect => MtaStageConnect::FLAGS, + ObjectType::MtaStageData => MtaStageData::FLAGS, + ObjectType::MtaStageEhlo => MtaStageEhlo::FLAGS, + ObjectType::MtaStageMail => MtaStageMail::FLAGS, + ObjectType::MtaStageRcpt => MtaStageRcpt::FLAGS, + ObjectType::MtaSts => MtaSts::FLAGS, + ObjectType::MtaTlsStrategy => MtaTlsStrategy::FLAGS, + ObjectType::MtaVirtualQueue => MtaVirtualQueue::FLAGS, + ObjectType::NetworkListener => NetworkListener::FLAGS, + ObjectType::OAuthClient => OAuthClient::FLAGS, + ObjectType::OidcProvider => OidcProvider::FLAGS, + ObjectType::PublicKey => PublicKey::FLAGS, + ObjectType::QueuedMessage => QueuedMessage::FLAGS, + ObjectType::ReportSettings => ReportSettings::FLAGS, + ObjectType::Role => Role::FLAGS, + ObjectType::Search => Search::FLAGS, + ObjectType::SearchStore => SearchStore::FLAGS, + ObjectType::Security => Security::FLAGS, + ObjectType::SenderAuth => SenderAuth::FLAGS, + ObjectType::Sharing => Sharing::FLAGS, + ObjectType::SieveSystemInterpreter => SieveSystemInterpreter::FLAGS, + ObjectType::SieveSystemScript => SieveSystemScript::FLAGS, + ObjectType::SieveUserInterpreter => SieveUserInterpreter::FLAGS, + ObjectType::SieveUserScript => SieveUserScript::FLAGS, + ObjectType::SpamClassifier => SpamClassifier::FLAGS, + ObjectType::SpamDnsblServer => SpamDnsblServer::FLAGS, + ObjectType::SpamDnsblSettings => SpamDnsblSettings::FLAGS, + ObjectType::SpamFileExtension => SpamFileExtension::FLAGS, + ObjectType::SpamLlm => SpamLlm::FLAGS, + ObjectType::SpamPyzor => SpamPyzor::FLAGS, + ObjectType::SpamRule => SpamRule::FLAGS, + ObjectType::SpamSettings => SpamSettings::FLAGS, + ObjectType::SpamTag => SpamTag::FLAGS, + ObjectType::SpamTrainingSample => SpamTrainingSample::FLAGS, + ObjectType::SpfReportSettings => SpfReportSettings::FLAGS, + ObjectType::StoreLookup => StoreLookup::FLAGS, + ObjectType::SystemSettings => SystemSettings::FLAGS, + ObjectType::Task => Task::FLAGS, + ObjectType::TaskManager => TaskManager::FLAGS, + ObjectType::Tenant => Tenant::FLAGS, + ObjectType::TlsExternalReport => TlsExternalReport::FLAGS, + ObjectType::TlsInternalReport => TlsInternalReport::FLAGS, + ObjectType::TlsReportSettings => TlsReportSettings::FLAGS, + ObjectType::Trace => Trace::FLAGS, + ObjectType::Tracer => Tracer::FLAGS, + ObjectType::TracingStore => TracingStore::FLAGS, + ObjectType::WebDav => WebDav::FLAGS, + ObjectType::WebHook => WebHook::FLAGS, + } + } + + pub fn indexes(&self) -> Vec { + match self { + ObjectType::Account => vec![ + IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::Type, + IndexSchemaType::Search, + IndexSchemaValueType::Enum, + ), + IndexSchema::new( + Property::DomainId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + IndexSchema::new( + Property::ExternalId, + IndexSchemaType::Search, + IndexSchemaValueType::Keyword, + ), + IndexSchema::new( + Property::MemberGroupIds, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + IndexSchema::new( + Property::Name, + IndexSchemaType::Search, + IndexSchemaValueType::Keyword, + ), + ], + ObjectType::AcmeProvider => vec![ + IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + ], + ObjectType::AllowedIp => vec![IndexSchema::new( + Property::Address, + IndexSchemaType::Unique, + IndexSchemaValueType::IpMask, + )], + ObjectType::ArchivedItem => vec![IndexSchema::new( + Property::AccountId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::BlockedIp => vec![IndexSchema::new( + Property::Address, + IndexSchemaType::Unique, + IndexSchemaValueType::IpMask, + )], + ObjectType::Certificate => vec![IndexSchema::new( + Property::SubjectAlternativeNames, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + )], + ObjectType::ClusterRole => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::Directory => vec![IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::DkimSignature => vec![ + IndexSchema::new( + Property::DomainId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + ], + ObjectType::DnsServer => vec![IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::Domain => vec![ + IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::Aliases, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + ), + ], + ObjectType::MailingList => vec![ + IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + ], + ObjectType::MaskedEmail => vec![IndexSchema::new( + Property::AccountId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::MemoryLookupKey => vec![IndexSchema::new( + Property::Namespace, + IndexSchemaType::Search, + IndexSchemaValueType::Keyword, + )], + ObjectType::MemoryLookupKeyValue => vec![IndexSchema::new( + Property::Namespace, + IndexSchemaType::Search, + IndexSchemaValueType::Keyword, + )], + ObjectType::MtaConnectionStrategy => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::MtaDeliverySchedule => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::MtaRoute => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::MtaTlsStrategy => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::MtaVirtualQueue => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::NetworkListener => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::OAuthClient => vec![ + IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::ClientId, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + ], + ObjectType::PublicKey => vec![IndexSchema::new( + Property::AccountId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::Role => vec![ + IndexSchema::new( + Property::Description, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + ), + IndexSchema::new( + Property::MemberTenantId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + ), + ], + ObjectType::SieveSystemScript => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SieveUserScript => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SpamDnsblServer => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SpamFileExtension => vec![IndexSchema::new( + Property::Extension, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SpamRule => vec![IndexSchema::new( + Property::Name, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SpamTag => vec![IndexSchema::new( + Property::Tag, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + )], + ObjectType::SpamTrainingSample => vec![IndexSchema::new( + Property::AccountId, + IndexSchemaType::Search, + IndexSchemaValueType::Id, + )], + ObjectType::Tenant => vec![IndexSchema::new( + Property::Text, + IndexSchemaType::Search, + IndexSchemaValueType::Text, + )], + _ => vec![], + } + } + + pub fn get_permission(&self) -> Permission { + match self { + ObjectType::Account => Permission::SysAccountGet, + ObjectType::AccountPassword => Permission::SysAccountPasswordGet, + ObjectType::AccountSettings => Permission::SysAccountSettingsGet, + ObjectType::AcmeProvider => Permission::SysAcmeProviderGet, + ObjectType::Action => Permission::SysActionGet, + ObjectType::AddressBook => Permission::SysAddressBookGet, + ObjectType::AiModel => Permission::SysAiModelGet, + ObjectType::Alert => Permission::SysAlertGet, + ObjectType::AllowedIp => Permission::SysAllowedIpGet, + ObjectType::ApiKey => Permission::SysApiKeyGet, + ObjectType::AppPassword => Permission::SysAppPasswordGet, + ObjectType::Application => Permission::SysApplicationGet, + ObjectType::ArchivedItem => Permission::SysArchivedItemGet, + ObjectType::ArfExternalReport => Permission::SysArfExternalReportGet, + ObjectType::Asn => Permission::SysAsnGet, + ObjectType::Authentication => Permission::SysAuthenticationGet, + ObjectType::BlobStore => Permission::SysBlobStoreGet, + ObjectType::BlockedIp => Permission::SysBlockedIpGet, + ObjectType::Bootstrap => Permission::SysBootstrapGet, + ObjectType::Cache => Permission::SysCacheGet, + ObjectType::Calendar => Permission::SysCalendarGet, + ObjectType::CalendarAlarm => Permission::SysCalendarAlarmGet, + ObjectType::CalendarScheduling => Permission::SysCalendarSchedulingGet, + ObjectType::Certificate => Permission::SysCertificateGet, + ObjectType::ClusterNode => Permission::SysClusterNodeGet, + ObjectType::ClusterRole => Permission::SysClusterRoleGet, + ObjectType::Coordinator => Permission::SysCoordinatorGet, + ObjectType::DataRetention => Permission::SysDataRetentionGet, + ObjectType::DataStore => Permission::SysDataStoreGet, + ObjectType::Directory => Permission::SysDirectoryGet, + ObjectType::DkimReportSettings => Permission::SysDkimReportSettingsGet, + ObjectType::DkimSignature => Permission::SysDkimSignatureGet, + ObjectType::DmarcExternalReport => Permission::SysDmarcExternalReportGet, + ObjectType::DmarcInternalReport => Permission::SysDmarcInternalReportGet, + ObjectType::DmarcReportSettings => Permission::SysDmarcReportSettingsGet, + ObjectType::DnsResolver => Permission::SysDnsResolverGet, + ObjectType::DnsServer => Permission::SysDnsServerGet, + ObjectType::Domain => Permission::SysDomainGet, + ObjectType::DsnReportSettings => Permission::SysDsnReportSettingsGet, + ObjectType::Email => Permission::SysEmailGet, + ObjectType::Enterprise => Permission::SysEnterpriseGet, + ObjectType::EventTracingLevel => Permission::SysEventTracingLevelGet, + ObjectType::FileStorage => Permission::SysFileStorageGet, + ObjectType::Http => Permission::SysHttpGet, + ObjectType::HttpForm => Permission::SysHttpFormGet, + ObjectType::HttpLookup => Permission::SysHttpLookupGet, + ObjectType::Imap => Permission::SysImapGet, + ObjectType::InMemoryStore => Permission::SysInMemoryStoreGet, + ObjectType::Jmap => Permission::SysJmapGet, + ObjectType::Log => Permission::SysLogGet, + ObjectType::MailingList => Permission::SysMailingListGet, + ObjectType::MaskedEmail => Permission::SysMaskedEmailGet, + ObjectType::MemoryLookupKey => Permission::SysMemoryLookupKeyGet, + ObjectType::MemoryLookupKeyValue => Permission::SysMemoryLookupKeyValueGet, + ObjectType::Metric => Permission::SysMetricGet, + ObjectType::Metrics => Permission::SysMetricsGet, + ObjectType::MetricsStore => Permission::SysMetricsStoreGet, + ObjectType::MtaConnectionStrategy => Permission::SysMtaConnectionStrategyGet, + ObjectType::MtaDeliverySchedule => Permission::SysMtaDeliveryScheduleGet, + ObjectType::MtaExtensions => Permission::SysMtaExtensionsGet, + ObjectType::MtaHook => Permission::SysMtaHookGet, + ObjectType::MtaInboundSession => Permission::SysMtaInboundSessionGet, + ObjectType::MtaInboundThrottle => Permission::SysMtaInboundThrottleGet, + ObjectType::MtaMilter => Permission::SysMtaMilterGet, + ObjectType::MtaOutboundStrategy => Permission::SysMtaOutboundStrategyGet, + ObjectType::MtaOutboundThrottle => Permission::SysMtaOutboundThrottleGet, + ObjectType::MtaQueueQuota => Permission::SysMtaQueueQuotaGet, + ObjectType::MtaRoute => Permission::SysMtaRouteGet, + ObjectType::MtaStageAuth => Permission::SysMtaStageAuthGet, + ObjectType::MtaStageConnect => Permission::SysMtaStageConnectGet, + ObjectType::MtaStageData => Permission::SysMtaStageDataGet, + ObjectType::MtaStageEhlo => Permission::SysMtaStageEhloGet, + ObjectType::MtaStageMail => Permission::SysMtaStageMailGet, + ObjectType::MtaStageRcpt => Permission::SysMtaStageRcptGet, + ObjectType::MtaSts => Permission::SysMtaStsGet, + ObjectType::MtaTlsStrategy => Permission::SysMtaTlsStrategyGet, + ObjectType::MtaVirtualQueue => Permission::SysMtaVirtualQueueGet, + ObjectType::NetworkListener => Permission::SysNetworkListenerGet, + ObjectType::OAuthClient => Permission::SysOAuthClientGet, + ObjectType::OidcProvider => Permission::SysOidcProviderGet, + ObjectType::PublicKey => Permission::SysPublicKeyGet, + ObjectType::QueuedMessage => Permission::SysQueuedMessageGet, + ObjectType::ReportSettings => Permission::SysReportSettingsGet, + ObjectType::Role => Permission::SysRoleGet, + ObjectType::Search => Permission::SysSearchGet, + ObjectType::SearchStore => Permission::SysSearchStoreGet, + ObjectType::Security => Permission::SysSecurityGet, + ObjectType::SenderAuth => Permission::SysSenderAuthGet, + ObjectType::Sharing => Permission::SysSharingGet, + ObjectType::SieveSystemInterpreter => Permission::SysSieveSystemInterpreterGet, + ObjectType::SieveSystemScript => Permission::SysSieveSystemScriptGet, + ObjectType::SieveUserInterpreter => Permission::SysSieveUserInterpreterGet, + ObjectType::SieveUserScript => Permission::SysSieveUserScriptGet, + ObjectType::SpamClassifier => Permission::SysSpamClassifierGet, + ObjectType::SpamDnsblServer => Permission::SysSpamDnsblServerGet, + ObjectType::SpamDnsblSettings => Permission::SysSpamDnsblSettingsGet, + ObjectType::SpamFileExtension => Permission::SysSpamFileExtensionGet, + ObjectType::SpamLlm => Permission::SysSpamLlmGet, + ObjectType::SpamPyzor => Permission::SysSpamPyzorGet, + ObjectType::SpamRule => Permission::SysSpamRuleGet, + ObjectType::SpamSettings => Permission::SysSpamSettingsGet, + ObjectType::SpamTag => Permission::SysSpamTagGet, + ObjectType::SpamTrainingSample => Permission::SysSpamTrainingSampleGet, + ObjectType::SpfReportSettings => Permission::SysSpfReportSettingsGet, + ObjectType::StoreLookup => Permission::SysStoreLookupGet, + ObjectType::SystemSettings => Permission::SysSystemSettingsGet, + ObjectType::Task => Permission::SysTaskGet, + ObjectType::TaskManager => Permission::SysTaskManagerGet, + ObjectType::Tenant => Permission::SysTenantGet, + ObjectType::TlsExternalReport => Permission::SysTlsExternalReportGet, + ObjectType::TlsInternalReport => Permission::SysTlsInternalReportGet, + ObjectType::TlsReportSettings => Permission::SysTlsReportSettingsGet, + ObjectType::Trace => Permission::SysTraceGet, + ObjectType::Tracer => Permission::SysTracerGet, + ObjectType::TracingStore => Permission::SysTracingStoreGet, + ObjectType::WebDav => Permission::SysWebDavGet, + ObjectType::WebHook => Permission::SysWebHookGet, + } + } + + pub fn query_permission(&self) -> Permission { + match self { + ObjectType::Account => Permission::SysAccountQuery, + ObjectType::AcmeProvider => Permission::SysAcmeProviderQuery, + ObjectType::Action => Permission::SysActionQuery, + ObjectType::AiModel => Permission::SysAiModelQuery, + ObjectType::Alert => Permission::SysAlertQuery, + ObjectType::AllowedIp => Permission::SysAllowedIpQuery, + ObjectType::ApiKey => Permission::SysApiKeyQuery, + ObjectType::AppPassword => Permission::SysAppPasswordQuery, + ObjectType::Application => Permission::SysApplicationQuery, + ObjectType::ArchivedItem => Permission::SysArchivedItemQuery, + ObjectType::ArfExternalReport => Permission::SysArfExternalReportQuery, + ObjectType::BlockedIp => Permission::SysBlockedIpQuery, + ObjectType::Certificate => Permission::SysCertificateQuery, + ObjectType::ClusterNode => Permission::SysClusterNodeQuery, + ObjectType::ClusterRole => Permission::SysClusterRoleQuery, + ObjectType::Directory => Permission::SysDirectoryQuery, + ObjectType::DkimSignature => Permission::SysDkimSignatureQuery, + ObjectType::DmarcExternalReport => Permission::SysDmarcExternalReportQuery, + ObjectType::DmarcInternalReport => Permission::SysDmarcInternalReportQuery, + ObjectType::DnsServer => Permission::SysDnsServerQuery, + ObjectType::Domain => Permission::SysDomainQuery, + ObjectType::EventTracingLevel => Permission::SysEventTracingLevelQuery, + ObjectType::HttpLookup => Permission::SysHttpLookupQuery, + ObjectType::Log => Permission::SysLogQuery, + ObjectType::MailingList => Permission::SysMailingListQuery, + ObjectType::MaskedEmail => Permission::SysMaskedEmailQuery, + ObjectType::MemoryLookupKey => Permission::SysMemoryLookupKeyQuery, + ObjectType::MemoryLookupKeyValue => Permission::SysMemoryLookupKeyValueQuery, + ObjectType::Metric => Permission::SysMetricQuery, + ObjectType::MtaConnectionStrategy => Permission::SysMtaConnectionStrategyQuery, + ObjectType::MtaDeliverySchedule => Permission::SysMtaDeliveryScheduleQuery, + ObjectType::MtaHook => Permission::SysMtaHookQuery, + ObjectType::MtaInboundThrottle => Permission::SysMtaInboundThrottleQuery, + ObjectType::MtaMilter => Permission::SysMtaMilterQuery, + ObjectType::MtaOutboundThrottle => Permission::SysMtaOutboundThrottleQuery, + ObjectType::MtaQueueQuota => Permission::SysMtaQueueQuotaQuery, + ObjectType::MtaRoute => Permission::SysMtaRouteQuery, + ObjectType::MtaTlsStrategy => Permission::SysMtaTlsStrategyQuery, + ObjectType::MtaVirtualQueue => Permission::SysMtaVirtualQueueQuery, + ObjectType::NetworkListener => Permission::SysNetworkListenerQuery, + ObjectType::OAuthClient => Permission::SysOAuthClientQuery, + ObjectType::PublicKey => Permission::SysPublicKeyQuery, + ObjectType::QueuedMessage => Permission::SysQueuedMessageQuery, + ObjectType::Role => Permission::SysRoleQuery, + ObjectType::SieveSystemScript => Permission::SysSieveSystemScriptQuery, + ObjectType::SieveUserScript => Permission::SysSieveUserScriptQuery, + ObjectType::SpamDnsblServer => Permission::SysSpamDnsblServerQuery, + ObjectType::SpamFileExtension => Permission::SysSpamFileExtensionQuery, + ObjectType::SpamRule => Permission::SysSpamRuleQuery, + ObjectType::SpamTag => Permission::SysSpamTagQuery, + ObjectType::SpamTrainingSample => Permission::SysSpamTrainingSampleQuery, + ObjectType::StoreLookup => Permission::SysStoreLookupQuery, + ObjectType::Task => Permission::SysTaskQuery, + ObjectType::Tenant => Permission::SysTenantQuery, + ObjectType::TlsExternalReport => Permission::SysTlsExternalReportQuery, + ObjectType::TlsInternalReport => Permission::SysTlsInternalReportQuery, + ObjectType::Trace => Permission::SysTraceQuery, + ObjectType::Tracer => Permission::SysTracerQuery, + ObjectType::WebHook => Permission::SysWebHookQuery, + _ => unreachable!(), + } + } + + pub fn set_permission(&self) -> [Permission; 3] { + match self { + ObjectType::Account => [ + Permission::SysAccountCreate, + Permission::SysAccountUpdate, + Permission::SysAccountDestroy, + ], + ObjectType::AccountPassword => [ + Permission::SysAccountPasswordUpdate, + Permission::SysAccountPasswordUpdate, + Permission::SysAccountPasswordUpdate, + ], + ObjectType::AccountSettings => [ + Permission::SysAccountSettingsUpdate, + Permission::SysAccountSettingsUpdate, + Permission::SysAccountSettingsUpdate, + ], + ObjectType::AcmeProvider => [ + Permission::SysAcmeProviderCreate, + Permission::SysAcmeProviderUpdate, + Permission::SysAcmeProviderDestroy, + ], + ObjectType::Action => [ + Permission::SysActionCreate, + Permission::SysActionUpdate, + Permission::SysActionDestroy, + ], + ObjectType::AddressBook => [ + Permission::SysAddressBookUpdate, + Permission::SysAddressBookUpdate, + Permission::SysAddressBookUpdate, + ], + ObjectType::AiModel => [ + Permission::SysAiModelCreate, + Permission::SysAiModelUpdate, + Permission::SysAiModelDestroy, + ], + ObjectType::Alert => [ + Permission::SysAlertCreate, + Permission::SysAlertUpdate, + Permission::SysAlertDestroy, + ], + ObjectType::AllowedIp => [ + Permission::SysAllowedIpCreate, + Permission::SysAllowedIpUpdate, + Permission::SysAllowedIpDestroy, + ], + ObjectType::ApiKey => [ + Permission::SysApiKeyCreate, + Permission::SysApiKeyUpdate, + Permission::SysApiKeyDestroy, + ], + ObjectType::AppPassword => [ + Permission::SysAppPasswordCreate, + Permission::SysAppPasswordUpdate, + Permission::SysAppPasswordDestroy, + ], + ObjectType::Application => [ + Permission::SysApplicationCreate, + Permission::SysApplicationUpdate, + Permission::SysApplicationDestroy, + ], + ObjectType::ArchivedItem => [ + Permission::SysArchivedItemCreate, + Permission::SysArchivedItemUpdate, + Permission::SysArchivedItemDestroy, + ], + ObjectType::ArfExternalReport => [ + Permission::SysArfExternalReportCreate, + Permission::SysArfExternalReportUpdate, + Permission::SysArfExternalReportDestroy, + ], + ObjectType::Asn => [ + Permission::SysAsnUpdate, + Permission::SysAsnUpdate, + Permission::SysAsnUpdate, + ], + ObjectType::Authentication => [ + Permission::SysAuthenticationUpdate, + Permission::SysAuthenticationUpdate, + Permission::SysAuthenticationUpdate, + ], + ObjectType::BlobStore => [ + Permission::SysBlobStoreUpdate, + Permission::SysBlobStoreUpdate, + Permission::SysBlobStoreUpdate, + ], + ObjectType::BlockedIp => [ + Permission::SysBlockedIpCreate, + Permission::SysBlockedIpUpdate, + Permission::SysBlockedIpDestroy, + ], + ObjectType::Bootstrap => [ + Permission::SysBootstrapUpdate, + Permission::SysBootstrapUpdate, + Permission::SysBootstrapUpdate, + ], + ObjectType::Cache => [ + Permission::SysCacheUpdate, + Permission::SysCacheUpdate, + Permission::SysCacheUpdate, + ], + ObjectType::Calendar => [ + Permission::SysCalendarUpdate, + Permission::SysCalendarUpdate, + Permission::SysCalendarUpdate, + ], + ObjectType::CalendarAlarm => [ + Permission::SysCalendarAlarmUpdate, + Permission::SysCalendarAlarmUpdate, + Permission::SysCalendarAlarmUpdate, + ], + ObjectType::CalendarScheduling => [ + Permission::SysCalendarSchedulingUpdate, + Permission::SysCalendarSchedulingUpdate, + Permission::SysCalendarSchedulingUpdate, + ], + ObjectType::Certificate => [ + Permission::SysCertificateCreate, + Permission::SysCertificateUpdate, + Permission::SysCertificateDestroy, + ], + ObjectType::ClusterNode => [ + Permission::SysClusterNodeCreate, + Permission::SysClusterNodeUpdate, + Permission::SysClusterNodeDestroy, + ], + ObjectType::ClusterRole => [ + Permission::SysClusterRoleCreate, + Permission::SysClusterRoleUpdate, + Permission::SysClusterRoleDestroy, + ], + ObjectType::Coordinator => [ + Permission::SysCoordinatorUpdate, + Permission::SysCoordinatorUpdate, + Permission::SysCoordinatorUpdate, + ], + ObjectType::DataRetention => [ + Permission::SysDataRetentionUpdate, + Permission::SysDataRetentionUpdate, + Permission::SysDataRetentionUpdate, + ], + ObjectType::DataStore => [ + Permission::SysDataStoreUpdate, + Permission::SysDataStoreUpdate, + Permission::SysDataStoreUpdate, + ], + ObjectType::Directory => [ + Permission::SysDirectoryCreate, + Permission::SysDirectoryUpdate, + Permission::SysDirectoryDestroy, + ], + ObjectType::DkimReportSettings => [ + Permission::SysDkimReportSettingsUpdate, + Permission::SysDkimReportSettingsUpdate, + Permission::SysDkimReportSettingsUpdate, + ], + ObjectType::DkimSignature => [ + Permission::SysDkimSignatureCreate, + Permission::SysDkimSignatureUpdate, + Permission::SysDkimSignatureDestroy, + ], + ObjectType::DmarcExternalReport => [ + Permission::SysDmarcExternalReportCreate, + Permission::SysDmarcExternalReportUpdate, + Permission::SysDmarcExternalReportDestroy, + ], + ObjectType::DmarcInternalReport => [ + Permission::SysDmarcInternalReportCreate, + Permission::SysDmarcInternalReportUpdate, + Permission::SysDmarcInternalReportDestroy, + ], + ObjectType::DmarcReportSettings => [ + Permission::SysDmarcReportSettingsUpdate, + Permission::SysDmarcReportSettingsUpdate, + Permission::SysDmarcReportSettingsUpdate, + ], + ObjectType::DnsResolver => [ + Permission::SysDnsResolverUpdate, + Permission::SysDnsResolverUpdate, + Permission::SysDnsResolverUpdate, + ], + ObjectType::DnsServer => [ + Permission::SysDnsServerCreate, + Permission::SysDnsServerUpdate, + Permission::SysDnsServerDestroy, + ], + ObjectType::Domain => [ + Permission::SysDomainCreate, + Permission::SysDomainUpdate, + Permission::SysDomainDestroy, + ], + ObjectType::DsnReportSettings => [ + Permission::SysDsnReportSettingsUpdate, + Permission::SysDsnReportSettingsUpdate, + Permission::SysDsnReportSettingsUpdate, + ], + ObjectType::Email => [ + Permission::SysEmailUpdate, + Permission::SysEmailUpdate, + Permission::SysEmailUpdate, + ], + ObjectType::Enterprise => [ + Permission::SysEnterpriseUpdate, + Permission::SysEnterpriseUpdate, + Permission::SysEnterpriseUpdate, + ], + ObjectType::EventTracingLevel => [ + Permission::SysEventTracingLevelCreate, + Permission::SysEventTracingLevelUpdate, + Permission::SysEventTracingLevelDestroy, + ], + ObjectType::FileStorage => [ + Permission::SysFileStorageUpdate, + Permission::SysFileStorageUpdate, + Permission::SysFileStorageUpdate, + ], + ObjectType::Http => [ + Permission::SysHttpUpdate, + Permission::SysHttpUpdate, + Permission::SysHttpUpdate, + ], + ObjectType::HttpForm => [ + Permission::SysHttpFormUpdate, + Permission::SysHttpFormUpdate, + Permission::SysHttpFormUpdate, + ], + ObjectType::HttpLookup => [ + Permission::SysHttpLookupCreate, + Permission::SysHttpLookupUpdate, + Permission::SysHttpLookupDestroy, + ], + ObjectType::Imap => [ + Permission::SysImapUpdate, + Permission::SysImapUpdate, + Permission::SysImapUpdate, + ], + ObjectType::InMemoryStore => [ + Permission::SysInMemoryStoreUpdate, + Permission::SysInMemoryStoreUpdate, + Permission::SysInMemoryStoreUpdate, + ], + ObjectType::Jmap => [ + Permission::SysJmapUpdate, + Permission::SysJmapUpdate, + Permission::SysJmapUpdate, + ], + ObjectType::Log => [ + Permission::SysLogCreate, + Permission::SysLogUpdate, + Permission::SysLogDestroy, + ], + ObjectType::MailingList => [ + Permission::SysMailingListCreate, + Permission::SysMailingListUpdate, + Permission::SysMailingListDestroy, + ], + ObjectType::MaskedEmail => [ + Permission::SysMaskedEmailCreate, + Permission::SysMaskedEmailUpdate, + Permission::SysMaskedEmailDestroy, + ], + ObjectType::MemoryLookupKey => [ + Permission::SysMemoryLookupKeyCreate, + Permission::SysMemoryLookupKeyUpdate, + Permission::SysMemoryLookupKeyDestroy, + ], + ObjectType::MemoryLookupKeyValue => [ + Permission::SysMemoryLookupKeyValueCreate, + Permission::SysMemoryLookupKeyValueUpdate, + Permission::SysMemoryLookupKeyValueDestroy, + ], + ObjectType::Metric => [ + Permission::SysMetricCreate, + Permission::SysMetricUpdate, + Permission::SysMetricDestroy, + ], + ObjectType::Metrics => [ + Permission::SysMetricsUpdate, + Permission::SysMetricsUpdate, + Permission::SysMetricsUpdate, + ], + ObjectType::MetricsStore => [ + Permission::SysMetricsStoreUpdate, + Permission::SysMetricsStoreUpdate, + Permission::SysMetricsStoreUpdate, + ], + ObjectType::MtaConnectionStrategy => [ + Permission::SysMtaConnectionStrategyCreate, + Permission::SysMtaConnectionStrategyUpdate, + Permission::SysMtaConnectionStrategyDestroy, + ], + ObjectType::MtaDeliverySchedule => [ + Permission::SysMtaDeliveryScheduleCreate, + Permission::SysMtaDeliveryScheduleUpdate, + Permission::SysMtaDeliveryScheduleDestroy, + ], + ObjectType::MtaExtensions => [ + Permission::SysMtaExtensionsUpdate, + Permission::SysMtaExtensionsUpdate, + Permission::SysMtaExtensionsUpdate, + ], + ObjectType::MtaHook => [ + Permission::SysMtaHookCreate, + Permission::SysMtaHookUpdate, + Permission::SysMtaHookDestroy, + ], + ObjectType::MtaInboundSession => [ + Permission::SysMtaInboundSessionUpdate, + Permission::SysMtaInboundSessionUpdate, + Permission::SysMtaInboundSessionUpdate, + ], + ObjectType::MtaInboundThrottle => [ + Permission::SysMtaInboundThrottleCreate, + Permission::SysMtaInboundThrottleUpdate, + Permission::SysMtaInboundThrottleDestroy, + ], + ObjectType::MtaMilter => [ + Permission::SysMtaMilterCreate, + Permission::SysMtaMilterUpdate, + Permission::SysMtaMilterDestroy, + ], + ObjectType::MtaOutboundStrategy => [ + Permission::SysMtaOutboundStrategyUpdate, + Permission::SysMtaOutboundStrategyUpdate, + Permission::SysMtaOutboundStrategyUpdate, + ], + ObjectType::MtaOutboundThrottle => [ + Permission::SysMtaOutboundThrottleCreate, + Permission::SysMtaOutboundThrottleUpdate, + Permission::SysMtaOutboundThrottleDestroy, + ], + ObjectType::MtaQueueQuota => [ + Permission::SysMtaQueueQuotaCreate, + Permission::SysMtaQueueQuotaUpdate, + Permission::SysMtaQueueQuotaDestroy, + ], + ObjectType::MtaRoute => [ + Permission::SysMtaRouteCreate, + Permission::SysMtaRouteUpdate, + Permission::SysMtaRouteDestroy, + ], + ObjectType::MtaStageAuth => [ + Permission::SysMtaStageAuthUpdate, + Permission::SysMtaStageAuthUpdate, + Permission::SysMtaStageAuthUpdate, + ], + ObjectType::MtaStageConnect => [ + Permission::SysMtaStageConnectUpdate, + Permission::SysMtaStageConnectUpdate, + Permission::SysMtaStageConnectUpdate, + ], + ObjectType::MtaStageData => [ + Permission::SysMtaStageDataUpdate, + Permission::SysMtaStageDataUpdate, + Permission::SysMtaStageDataUpdate, + ], + ObjectType::MtaStageEhlo => [ + Permission::SysMtaStageEhloUpdate, + Permission::SysMtaStageEhloUpdate, + Permission::SysMtaStageEhloUpdate, + ], + ObjectType::MtaStageMail => [ + Permission::SysMtaStageMailUpdate, + Permission::SysMtaStageMailUpdate, + Permission::SysMtaStageMailUpdate, + ], + ObjectType::MtaStageRcpt => [ + Permission::SysMtaStageRcptUpdate, + Permission::SysMtaStageRcptUpdate, + Permission::SysMtaStageRcptUpdate, + ], + ObjectType::MtaSts => [ + Permission::SysMtaStsUpdate, + Permission::SysMtaStsUpdate, + Permission::SysMtaStsUpdate, + ], + ObjectType::MtaTlsStrategy => [ + Permission::SysMtaTlsStrategyCreate, + Permission::SysMtaTlsStrategyUpdate, + Permission::SysMtaTlsStrategyDestroy, + ], + ObjectType::MtaVirtualQueue => [ + Permission::SysMtaVirtualQueueCreate, + Permission::SysMtaVirtualQueueUpdate, + Permission::SysMtaVirtualQueueDestroy, + ], + ObjectType::NetworkListener => [ + Permission::SysNetworkListenerCreate, + Permission::SysNetworkListenerUpdate, + Permission::SysNetworkListenerDestroy, + ], + ObjectType::OAuthClient => [ + Permission::SysOAuthClientCreate, + Permission::SysOAuthClientUpdate, + Permission::SysOAuthClientDestroy, + ], + ObjectType::OidcProvider => [ + Permission::SysOidcProviderUpdate, + Permission::SysOidcProviderUpdate, + Permission::SysOidcProviderUpdate, + ], + ObjectType::PublicKey => [ + Permission::SysPublicKeyCreate, + Permission::SysPublicKeyUpdate, + Permission::SysPublicKeyDestroy, + ], + ObjectType::QueuedMessage => [ + Permission::SysQueuedMessageCreate, + Permission::SysQueuedMessageUpdate, + Permission::SysQueuedMessageDestroy, + ], + ObjectType::ReportSettings => [ + Permission::SysReportSettingsUpdate, + Permission::SysReportSettingsUpdate, + Permission::SysReportSettingsUpdate, + ], + ObjectType::Role => [ + Permission::SysRoleCreate, + Permission::SysRoleUpdate, + Permission::SysRoleDestroy, + ], + ObjectType::Search => [ + Permission::SysSearchUpdate, + Permission::SysSearchUpdate, + Permission::SysSearchUpdate, + ], + ObjectType::SearchStore => [ + Permission::SysSearchStoreUpdate, + Permission::SysSearchStoreUpdate, + Permission::SysSearchStoreUpdate, + ], + ObjectType::Security => [ + Permission::SysSecurityUpdate, + Permission::SysSecurityUpdate, + Permission::SysSecurityUpdate, + ], + ObjectType::SenderAuth => [ + Permission::SysSenderAuthUpdate, + Permission::SysSenderAuthUpdate, + Permission::SysSenderAuthUpdate, + ], + ObjectType::Sharing => [ + Permission::SysSharingUpdate, + Permission::SysSharingUpdate, + Permission::SysSharingUpdate, + ], + ObjectType::SieveSystemInterpreter => [ + Permission::SysSieveSystemInterpreterUpdate, + Permission::SysSieveSystemInterpreterUpdate, + Permission::SysSieveSystemInterpreterUpdate, + ], + ObjectType::SieveSystemScript => [ + Permission::SysSieveSystemScriptCreate, + Permission::SysSieveSystemScriptUpdate, + Permission::SysSieveSystemScriptDestroy, + ], + ObjectType::SieveUserInterpreter => [ + Permission::SysSieveUserInterpreterUpdate, + Permission::SysSieveUserInterpreterUpdate, + Permission::SysSieveUserInterpreterUpdate, + ], + ObjectType::SieveUserScript => [ + Permission::SysSieveUserScriptCreate, + Permission::SysSieveUserScriptUpdate, + Permission::SysSieveUserScriptDestroy, + ], + ObjectType::SpamClassifier => [ + Permission::SysSpamClassifierUpdate, + Permission::SysSpamClassifierUpdate, + Permission::SysSpamClassifierUpdate, + ], + ObjectType::SpamDnsblServer => [ + Permission::SysSpamDnsblServerCreate, + Permission::SysSpamDnsblServerUpdate, + Permission::SysSpamDnsblServerDestroy, + ], + ObjectType::SpamDnsblSettings => [ + Permission::SysSpamDnsblSettingsUpdate, + Permission::SysSpamDnsblSettingsUpdate, + Permission::SysSpamDnsblSettingsUpdate, + ], + ObjectType::SpamFileExtension => [ + Permission::SysSpamFileExtensionCreate, + Permission::SysSpamFileExtensionUpdate, + Permission::SysSpamFileExtensionDestroy, + ], + ObjectType::SpamLlm => [ + Permission::SysSpamLlmUpdate, + Permission::SysSpamLlmUpdate, + Permission::SysSpamLlmUpdate, + ], + ObjectType::SpamPyzor => [ + Permission::SysSpamPyzorUpdate, + Permission::SysSpamPyzorUpdate, + Permission::SysSpamPyzorUpdate, + ], + ObjectType::SpamRule => [ + Permission::SysSpamRuleCreate, + Permission::SysSpamRuleUpdate, + Permission::SysSpamRuleDestroy, + ], + ObjectType::SpamSettings => [ + Permission::SysSpamSettingsUpdate, + Permission::SysSpamSettingsUpdate, + Permission::SysSpamSettingsUpdate, + ], + ObjectType::SpamTag => [ + Permission::SysSpamTagCreate, + Permission::SysSpamTagUpdate, + Permission::SysSpamTagDestroy, + ], + ObjectType::SpamTrainingSample => [ + Permission::SysSpamTrainingSampleCreate, + Permission::SysSpamTrainingSampleUpdate, + Permission::SysSpamTrainingSampleDestroy, + ], + ObjectType::SpfReportSettings => [ + Permission::SysSpfReportSettingsUpdate, + Permission::SysSpfReportSettingsUpdate, + Permission::SysSpfReportSettingsUpdate, + ], + ObjectType::StoreLookup => [ + Permission::SysStoreLookupCreate, + Permission::SysStoreLookupUpdate, + Permission::SysStoreLookupDestroy, + ], + ObjectType::SystemSettings => [ + Permission::SysSystemSettingsUpdate, + Permission::SysSystemSettingsUpdate, + Permission::SysSystemSettingsUpdate, + ], + ObjectType::Task => [ + Permission::SysTaskCreate, + Permission::SysTaskUpdate, + Permission::SysTaskDestroy, + ], + ObjectType::TaskManager => [ + Permission::SysTaskManagerUpdate, + Permission::SysTaskManagerUpdate, + Permission::SysTaskManagerUpdate, + ], + ObjectType::Tenant => [ + Permission::SysTenantCreate, + Permission::SysTenantUpdate, + Permission::SysTenantDestroy, + ], + ObjectType::TlsExternalReport => [ + Permission::SysTlsExternalReportCreate, + Permission::SysTlsExternalReportUpdate, + Permission::SysTlsExternalReportDestroy, + ], + ObjectType::TlsInternalReport => [ + Permission::SysTlsInternalReportCreate, + Permission::SysTlsInternalReportUpdate, + Permission::SysTlsInternalReportDestroy, + ], + ObjectType::TlsReportSettings => [ + Permission::SysTlsReportSettingsUpdate, + Permission::SysTlsReportSettingsUpdate, + Permission::SysTlsReportSettingsUpdate, + ], + ObjectType::Trace => [ + Permission::SysTraceCreate, + Permission::SysTraceUpdate, + Permission::SysTraceDestroy, + ], + ObjectType::Tracer => [ + Permission::SysTracerCreate, + Permission::SysTracerUpdate, + Permission::SysTracerDestroy, + ], + ObjectType::TracingStore => [ + Permission::SysTracingStoreUpdate, + Permission::SysTracingStoreUpdate, + Permission::SysTracingStoreUpdate, + ], + ObjectType::WebDav => [ + Permission::SysWebDavUpdate, + Permission::SysWebDavUpdate, + Permission::SysWebDavUpdate, + ], + ObjectType::WebHook => [ + Permission::SysWebHookCreate, + Permission::SysWebHookUpdate, + Permission::SysWebHookDestroy, + ], + } + } +} + +impl ObjectInner { + pub fn member_tenant_id(&self) -> Option { + match self { + ObjectInner::Account(Account::User(obj)) => obj.member_tenant_id, + ObjectInner::Account(Account::Group(obj)) => obj.member_tenant_id, + ObjectInner::AcmeProvider(obj) => obj.member_tenant_id, + ObjectInner::ArfExternalReport(obj) => obj.member_tenant_id, + ObjectInner::Directory(Directory::Ldap(obj)) => obj.member_tenant_id, + ObjectInner::Directory(Directory::Sql(obj)) => obj.member_tenant_id, + ObjectInner::Directory(Directory::Oidc(obj)) => obj.member_tenant_id, + ObjectInner::DkimSignature(DkimSignature::Dkim1Ed25519Sha256(obj)) => { + obj.member_tenant_id + } + ObjectInner::DkimSignature(DkimSignature::Dkim1RsaSha256(obj)) => obj.member_tenant_id, + ObjectInner::DkimSignature(DkimSignature::Dkim2Ed25519Sha256(obj)) => { + obj.member_tenant_id + } + ObjectInner::DkimSignature(DkimSignature::Dkim2RsaSha256(obj)) => obj.member_tenant_id, + ObjectInner::DmarcExternalReport(obj) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Tsig(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Cloudflare(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::DigitalOcean(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::DeSEC(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Ovh(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Bunny(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Porkbun(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Dnsimple(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Spaceship(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Route53(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::GoogleCloudDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Alidns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::ArvanCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Autodns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::AzureDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::BaiduCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::BluecatV2(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::ClouDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Constellix(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Cpanel(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Ddnss(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::DnsMadeEasy(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Domeneshop(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Dreamhost(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::DuckDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Dynu(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::EasyDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::EdgeDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Exoscale(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::FreeMyIp(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::GandiV5(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Gcore(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Glesys(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Godaddy(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Hetzner(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::HostingDe(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Hostinger(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::HuaweiCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Hurricane(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::IbmCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Infoblox(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Infomaniak(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Inwx(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Ionos(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Ipv64(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Joker(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Lightsail(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Linode(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::LuaDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::MythicBeasts(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Namecheap(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::NameDotCom(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::NameSilo(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Netcup(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Netlify(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Nifcloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Ns1(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::OracleCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Plesk(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Safedns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Scaleway(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::TencentCloud(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Transip(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::UltraDns(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Vercel(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Volcengine(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id, + ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id, + ObjectInner::Domain(obj) => obj.member_tenant_id, + ObjectInner::MailingList(obj) => obj.member_tenant_id, + ObjectInner::OAuthClient(obj) => obj.member_tenant_id, + ObjectInner::Role(obj) => obj.member_tenant_id, + ObjectInner::TlsExternalReport(obj) => obj.member_tenant_id, + _ => None, + } + } + + pub fn set_member_tenant_id(&mut self, id: Id) { + match self { + ObjectInner::Account(Account::User(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::Account(Account::Group(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::AcmeProvider(obj) => obj.member_tenant_id = Some(id), + ObjectInner::ArfExternalReport(obj) => obj.member_tenant_id = Some(id), + ObjectInner::Directory(Directory::Ldap(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::Directory(Directory::Sql(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::Directory(Directory::Oidc(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DkimSignature(DkimSignature::Dkim1Ed25519Sha256(obj)) => { + obj.member_tenant_id = Some(id) + } + ObjectInner::DkimSignature(DkimSignature::Dkim1RsaSha256(obj)) => { + obj.member_tenant_id = Some(id) + } + ObjectInner::DkimSignature(DkimSignature::Dkim2Ed25519Sha256(obj)) => { + obj.member_tenant_id = Some(id) + } + ObjectInner::DkimSignature(DkimSignature::Dkim2RsaSha256(obj)) => { + obj.member_tenant_id = Some(id) + } + ObjectInner::DmarcExternalReport(obj) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Tsig(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Cloudflare(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::DigitalOcean(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::DeSEC(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Ovh(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Bunny(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Porkbun(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Dnsimple(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Spaceship(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Route53(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::GoogleCloudDns(obj)) => { + obj.member_tenant_id = Some(id) + } + ObjectInner::DnsServer(DnsServer::Alidns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::ArvanCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Autodns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::AzureDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::BaiduCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::BluecatV2(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::ClouDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Constellix(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Cpanel(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Ddnss(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::DnsMadeEasy(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Domeneshop(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Dreamhost(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::DuckDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Dynu(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::EasyDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::EdgeDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Exoscale(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::FreeMyIp(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::GandiV5(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Gcore(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Glesys(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Godaddy(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Hetzner(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::HostingDe(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Hostinger(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::HuaweiCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Hurricane(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::IbmCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Infoblox(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Infomaniak(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Inwx(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Ionos(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Ipv64(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Joker(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Lightsail(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Linode(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::LuaDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::MythicBeasts(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Namecheap(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::NameDotCom(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::NameSilo(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Netcup(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Netlify(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Nifcloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Ns1(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::OracleCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Plesk(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Safedns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Scaleway(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::TencentCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Transip(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::UltraDns(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Vercel(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Volcengine(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id = Some(id), + ObjectInner::Domain(obj) => obj.member_tenant_id = Some(id), + ObjectInner::MailingList(obj) => obj.member_tenant_id = Some(id), + ObjectInner::OAuthClient(obj) => obj.member_tenant_id = Some(id), + ObjectInner::Role(obj) => obj.member_tenant_id = Some(id), + ObjectInner::TlsExternalReport(obj) => obj.member_tenant_id = Some(id), + _ => {} + } + } + + pub fn account_id(&self) -> Option { + match self { + ObjectInner::ArchivedItem(ArchivedItem::Email(obj)) => Some(obj.account_id), + ObjectInner::ArchivedItem(ArchivedItem::FileNode(obj)) => Some(obj.account_id), + ObjectInner::ArchivedItem(ArchivedItem::CalendarEvent(obj)) => Some(obj.account_id), + ObjectInner::ArchivedItem(ArchivedItem::ContactCard(obj)) => Some(obj.account_id), + ObjectInner::ArchivedItem(ArchivedItem::SieveScript(obj)) => Some(obj.account_id), + ObjectInner::MaskedEmail(obj) => Some(obj.account_id), + ObjectInner::PublicKey(obj) => Some(obj.account_id), + ObjectInner::SpamTrainingSample(obj) => obj.account_id, + ObjectInner::Task(Task::IndexDocument(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::UnindexDocument(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::CalendarAlarmEmail(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::CalendarAlarmNotification(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::CalendarItipMessage(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::MergeThreads(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::RestoreArchivedItem(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::DestroyAccount(obj)) => Some(obj.account_id), + ObjectInner::Task(Task::AccountMaintenance(obj)) => Some(obj.account_id), + _ => None, + } + } + + pub fn set_account_id(&mut self, id: Id) { + match self { + ObjectInner::ArchivedItem(ArchivedItem::Email(obj)) => obj.account_id = id, + ObjectInner::ArchivedItem(ArchivedItem::FileNode(obj)) => obj.account_id = id, + ObjectInner::ArchivedItem(ArchivedItem::CalendarEvent(obj)) => obj.account_id = id, + ObjectInner::ArchivedItem(ArchivedItem::ContactCard(obj)) => obj.account_id = id, + ObjectInner::ArchivedItem(ArchivedItem::SieveScript(obj)) => obj.account_id = id, + ObjectInner::MaskedEmail(obj) => obj.account_id = id, + ObjectInner::PublicKey(obj) => obj.account_id = id, + ObjectInner::SpamTrainingSample(obj) => obj.account_id = Some(id), + ObjectInner::Task(Task::IndexDocument(obj)) => obj.account_id = id, + ObjectInner::Task(Task::UnindexDocument(obj)) => obj.account_id = id, + ObjectInner::Task(Task::CalendarAlarmEmail(obj)) => obj.account_id = id, + ObjectInner::Task(Task::CalendarAlarmNotification(obj)) => obj.account_id = id, + ObjectInner::Task(Task::CalendarItipMessage(obj)) => obj.account_id = id, + ObjectInner::Task(Task::MergeThreads(obj)) => obj.account_id = id, + ObjectInner::Task(Task::RestoreArchivedItem(obj)) => obj.account_id = id, + ObjectInner::Task(Task::DestroyAccount(obj)) => obj.account_id = id, + ObjectInner::Task(Task::AccountMaintenance(obj)) => obj.account_id = id, + _ => {} + } + } + + pub fn to_pickled_vec(&self) -> Vec { + match &self { + ObjectInner::Account(obj) => obj.to_pickled_vec(), + ObjectInner::AccountPassword(obj) => obj.to_pickled_vec(), + ObjectInner::AccountSettings(obj) => obj.to_pickled_vec(), + ObjectInner::AcmeProvider(obj) => obj.to_pickled_vec(), + ObjectInner::Action(obj) => obj.to_pickled_vec(), + ObjectInner::AddressBook(obj) => obj.to_pickled_vec(), + ObjectInner::AiModel(obj) => obj.to_pickled_vec(), + ObjectInner::Alert(obj) => obj.to_pickled_vec(), + ObjectInner::AllowedIp(obj) => obj.to_pickled_vec(), + ObjectInner::ApiKey(obj) => obj.to_pickled_vec(), + ObjectInner::AppPassword(obj) => obj.to_pickled_vec(), + ObjectInner::Application(obj) => obj.to_pickled_vec(), + ObjectInner::ArchivedItem(obj) => obj.to_pickled_vec(), + ObjectInner::ArfExternalReport(obj) => obj.to_pickled_vec(), + ObjectInner::Asn(obj) => obj.to_pickled_vec(), + ObjectInner::Authentication(obj) => obj.to_pickled_vec(), + ObjectInner::BlobStore(obj) => obj.to_pickled_vec(), + ObjectInner::BlockedIp(obj) => obj.to_pickled_vec(), + ObjectInner::Bootstrap(obj) => obj.to_pickled_vec(), + ObjectInner::Cache(obj) => obj.to_pickled_vec(), + ObjectInner::Calendar(obj) => obj.to_pickled_vec(), + ObjectInner::CalendarAlarm(obj) => obj.to_pickled_vec(), + ObjectInner::CalendarScheduling(obj) => obj.to_pickled_vec(), + ObjectInner::Certificate(obj) => obj.to_pickled_vec(), + ObjectInner::ClusterNode(obj) => obj.to_pickled_vec(), + ObjectInner::ClusterRole(obj) => obj.to_pickled_vec(), + ObjectInner::Coordinator(obj) => obj.to_pickled_vec(), + ObjectInner::DataRetention(obj) => obj.to_pickled_vec(), + ObjectInner::DataStore(obj) => obj.to_pickled_vec(), + ObjectInner::Directory(obj) => obj.to_pickled_vec(), + ObjectInner::DkimReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::DkimSignature(obj) => obj.to_pickled_vec(), + ObjectInner::DmarcExternalReport(obj) => obj.to_pickled_vec(), + ObjectInner::DmarcInternalReport(obj) => obj.to_pickled_vec(), + ObjectInner::DmarcReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::DnsResolver(obj) => obj.to_pickled_vec(), + ObjectInner::DnsServer(obj) => obj.to_pickled_vec(), + ObjectInner::Domain(obj) => obj.to_pickled_vec(), + ObjectInner::DsnReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::Email(obj) => obj.to_pickled_vec(), + ObjectInner::Enterprise(obj) => obj.to_pickled_vec(), + ObjectInner::EventTracingLevel(obj) => obj.to_pickled_vec(), + ObjectInner::FileStorage(obj) => obj.to_pickled_vec(), + ObjectInner::Http(obj) => obj.to_pickled_vec(), + ObjectInner::HttpForm(obj) => obj.to_pickled_vec(), + ObjectInner::HttpLookup(obj) => obj.to_pickled_vec(), + ObjectInner::Imap(obj) => obj.to_pickled_vec(), + ObjectInner::InMemoryStore(obj) => obj.to_pickled_vec(), + ObjectInner::Jmap(obj) => obj.to_pickled_vec(), + ObjectInner::Log(obj) => obj.to_pickled_vec(), + ObjectInner::MailingList(obj) => obj.to_pickled_vec(), + ObjectInner::MaskedEmail(obj) => obj.to_pickled_vec(), + ObjectInner::MemoryLookupKey(obj) => obj.to_pickled_vec(), + ObjectInner::MemoryLookupKeyValue(obj) => obj.to_pickled_vec(), + ObjectInner::Metric(obj) => obj.to_pickled_vec(), + ObjectInner::Metrics(obj) => obj.to_pickled_vec(), + ObjectInner::MetricsStore(obj) => obj.to_pickled_vec(), + ObjectInner::MtaConnectionStrategy(obj) => obj.to_pickled_vec(), + ObjectInner::MtaDeliverySchedule(obj) => obj.to_pickled_vec(), + ObjectInner::MtaExtensions(obj) => obj.to_pickled_vec(), + ObjectInner::MtaHook(obj) => obj.to_pickled_vec(), + ObjectInner::MtaInboundSession(obj) => obj.to_pickled_vec(), + ObjectInner::MtaInboundThrottle(obj) => obj.to_pickled_vec(), + ObjectInner::MtaMilter(obj) => obj.to_pickled_vec(), + ObjectInner::MtaOutboundStrategy(obj) => obj.to_pickled_vec(), + ObjectInner::MtaOutboundThrottle(obj) => obj.to_pickled_vec(), + ObjectInner::MtaQueueQuota(obj) => obj.to_pickled_vec(), + ObjectInner::MtaRoute(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageAuth(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageConnect(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageData(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageEhlo(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageMail(obj) => obj.to_pickled_vec(), + ObjectInner::MtaStageRcpt(obj) => obj.to_pickled_vec(), + ObjectInner::MtaSts(obj) => obj.to_pickled_vec(), + ObjectInner::MtaTlsStrategy(obj) => obj.to_pickled_vec(), + ObjectInner::MtaVirtualQueue(obj) => obj.to_pickled_vec(), + ObjectInner::NetworkListener(obj) => obj.to_pickled_vec(), + ObjectInner::OAuthClient(obj) => obj.to_pickled_vec(), + ObjectInner::OidcProvider(obj) => obj.to_pickled_vec(), + ObjectInner::PublicKey(obj) => obj.to_pickled_vec(), + ObjectInner::QueuedMessage(obj) => obj.to_pickled_vec(), + ObjectInner::ReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::Role(obj) => obj.to_pickled_vec(), + ObjectInner::Search(obj) => obj.to_pickled_vec(), + ObjectInner::SearchStore(obj) => obj.to_pickled_vec(), + ObjectInner::Security(obj) => obj.to_pickled_vec(), + ObjectInner::SenderAuth(obj) => obj.to_pickled_vec(), + ObjectInner::Sharing(obj) => obj.to_pickled_vec(), + ObjectInner::SieveSystemInterpreter(obj) => obj.to_pickled_vec(), + ObjectInner::SieveSystemScript(obj) => obj.to_pickled_vec(), + ObjectInner::SieveUserInterpreter(obj) => obj.to_pickled_vec(), + ObjectInner::SieveUserScript(obj) => obj.to_pickled_vec(), + ObjectInner::SpamClassifier(obj) => obj.to_pickled_vec(), + ObjectInner::SpamDnsblServer(obj) => obj.to_pickled_vec(), + ObjectInner::SpamDnsblSettings(obj) => obj.to_pickled_vec(), + ObjectInner::SpamFileExtension(obj) => obj.to_pickled_vec(), + ObjectInner::SpamLlm(obj) => obj.to_pickled_vec(), + ObjectInner::SpamPyzor(obj) => obj.to_pickled_vec(), + ObjectInner::SpamRule(obj) => obj.to_pickled_vec(), + ObjectInner::SpamSettings(obj) => obj.to_pickled_vec(), + ObjectInner::SpamTag(obj) => obj.to_pickled_vec(), + ObjectInner::SpamTrainingSample(obj) => obj.to_pickled_vec(), + ObjectInner::SpfReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::StoreLookup(obj) => obj.to_pickled_vec(), + ObjectInner::SystemSettings(obj) => obj.to_pickled_vec(), + ObjectInner::Task(obj) => obj.to_pickled_vec(), + ObjectInner::TaskManager(obj) => obj.to_pickled_vec(), + ObjectInner::Tenant(obj) => obj.to_pickled_vec(), + ObjectInner::TlsExternalReport(obj) => obj.to_pickled_vec(), + ObjectInner::TlsInternalReport(obj) => obj.to_pickled_vec(), + ObjectInner::TlsReportSettings(obj) => obj.to_pickled_vec(), + ObjectInner::Trace(obj) => obj.to_pickled_vec(), + ObjectInner::Tracer(obj) => obj.to_pickled_vec(), + ObjectInner::TracingStore(obj) => obj.to_pickled_vec(), + ObjectInner::WebDav(obj) => obj.to_pickled_vec(), + ObjectInner::WebHook(obj) => obj.to_pickled_vec(), + } + } + + pub fn unpickle( + object: ObjectType, + stream: &mut crate::pickle::PickledStream<'_>, + ) -> Option { + match object { + ObjectType::Account => Pickle::unpickle(stream).map(ObjectInner::Account), + ObjectType::AccountPassword => { + Pickle::unpickle(stream).map(ObjectInner::AccountPassword) + } + ObjectType::AccountSettings => { + Pickle::unpickle(stream).map(ObjectInner::AccountSettings) + } + ObjectType::AcmeProvider => Pickle::unpickle(stream).map(ObjectInner::AcmeProvider), + ObjectType::Action => Pickle::unpickle(stream).map(ObjectInner::Action), + ObjectType::AddressBook => Pickle::unpickle(stream).map(ObjectInner::AddressBook), + ObjectType::AiModel => Pickle::unpickle(stream).map(ObjectInner::AiModel), + ObjectType::Alert => Pickle::unpickle(stream).map(ObjectInner::Alert), + ObjectType::AllowedIp => Pickle::unpickle(stream).map(ObjectInner::AllowedIp), + ObjectType::ApiKey => Pickle::unpickle(stream).map(ObjectInner::ApiKey), + ObjectType::AppPassword => Pickle::unpickle(stream).map(ObjectInner::AppPassword), + ObjectType::Application => Pickle::unpickle(stream).map(ObjectInner::Application), + ObjectType::ArchivedItem => Pickle::unpickle(stream).map(ObjectInner::ArchivedItem), + ObjectType::ArfExternalReport => { + Pickle::unpickle(stream).map(ObjectInner::ArfExternalReport) + } + ObjectType::Asn => Pickle::unpickle(stream).map(ObjectInner::Asn), + ObjectType::Authentication => Pickle::unpickle(stream).map(ObjectInner::Authentication), + ObjectType::BlobStore => Pickle::unpickle(stream).map(ObjectInner::BlobStore), + ObjectType::BlockedIp => Pickle::unpickle(stream).map(ObjectInner::BlockedIp), + ObjectType::Bootstrap => Pickle::unpickle(stream).map(ObjectInner::Bootstrap), + ObjectType::Cache => Pickle::unpickle(stream).map(ObjectInner::Cache), + ObjectType::Calendar => Pickle::unpickle(stream).map(ObjectInner::Calendar), + ObjectType::CalendarAlarm => Pickle::unpickle(stream).map(ObjectInner::CalendarAlarm), + ObjectType::CalendarScheduling => { + Pickle::unpickle(stream).map(ObjectInner::CalendarScheduling) + } + ObjectType::Certificate => Pickle::unpickle(stream).map(ObjectInner::Certificate), + ObjectType::ClusterNode => Pickle::unpickle(stream).map(ObjectInner::ClusterNode), + ObjectType::ClusterRole => Pickle::unpickle(stream).map(ObjectInner::ClusterRole), + ObjectType::Coordinator => Pickle::unpickle(stream).map(ObjectInner::Coordinator), + ObjectType::DataRetention => Pickle::unpickle(stream).map(ObjectInner::DataRetention), + ObjectType::DataStore => Pickle::unpickle(stream).map(ObjectInner::DataStore), + ObjectType::Directory => Pickle::unpickle(stream).map(ObjectInner::Directory), + ObjectType::DkimReportSettings => { + Pickle::unpickle(stream).map(ObjectInner::DkimReportSettings) + } + ObjectType::DkimSignature => Pickle::unpickle(stream).map(ObjectInner::DkimSignature), + ObjectType::DmarcExternalReport => { + Pickle::unpickle(stream).map(ObjectInner::DmarcExternalReport) + } + ObjectType::DmarcInternalReport => { + Pickle::unpickle(stream).map(ObjectInner::DmarcInternalReport) + } + ObjectType::DmarcReportSettings => { + Pickle::unpickle(stream).map(ObjectInner::DmarcReportSettings) + } + ObjectType::DnsResolver => Pickle::unpickle(stream).map(ObjectInner::DnsResolver), + ObjectType::DnsServer => Pickle::unpickle(stream).map(ObjectInner::DnsServer), + ObjectType::Domain => Pickle::unpickle(stream).map(ObjectInner::Domain), + ObjectType::DsnReportSettings => { + Pickle::unpickle(stream).map(ObjectInner::DsnReportSettings) + } + ObjectType::Email => Pickle::unpickle(stream).map(ObjectInner::Email), + ObjectType::Enterprise => Pickle::unpickle(stream).map(ObjectInner::Enterprise), + ObjectType::EventTracingLevel => { + Pickle::unpickle(stream).map(ObjectInner::EventTracingLevel) + } + ObjectType::FileStorage => Pickle::unpickle(stream).map(ObjectInner::FileStorage), + ObjectType::Http => Pickle::unpickle(stream).map(ObjectInner::Http), + ObjectType::HttpForm => Pickle::unpickle(stream).map(ObjectInner::HttpForm), + ObjectType::HttpLookup => Pickle::unpickle(stream).map(ObjectInner::HttpLookup), + ObjectType::Imap => Pickle::unpickle(stream).map(ObjectInner::Imap), + ObjectType::InMemoryStore => Pickle::unpickle(stream).map(ObjectInner::InMemoryStore), + ObjectType::Jmap => Pickle::unpickle(stream).map(ObjectInner::Jmap), + ObjectType::Log => Pickle::unpickle(stream).map(ObjectInner::Log), + ObjectType::MailingList => Pickle::unpickle(stream).map(ObjectInner::MailingList), + ObjectType::MaskedEmail => Pickle::unpickle(stream).map(ObjectInner::MaskedEmail), + ObjectType::MemoryLookupKey => { + Pickle::unpickle(stream).map(ObjectInner::MemoryLookupKey) + } + ObjectType::MemoryLookupKeyValue => { + Pickle::unpickle(stream).map(ObjectInner::MemoryLookupKeyValue) + } + ObjectType::Metric => Pickle::unpickle(stream).map(ObjectInner::Metric), + ObjectType::Metrics => Pickle::unpickle(stream).map(ObjectInner::Metrics), + ObjectType::MetricsStore => Pickle::unpickle(stream).map(ObjectInner::MetricsStore), + ObjectType::MtaConnectionStrategy => { + Pickle::unpickle(stream).map(ObjectInner::MtaConnectionStrategy) + } + ObjectType::MtaDeliverySchedule => { + Pickle::unpickle(stream).map(ObjectInner::MtaDeliverySchedule) + } + ObjectType::MtaExtensions => Pickle::unpickle(stream).map(ObjectInner::MtaExtensions), + ObjectType::MtaHook => Pickle::unpickle(stream).map(ObjectInner::MtaHook), + ObjectType::MtaInboundSession => { + Pickle::unpickle(stream).map(ObjectInner::MtaInboundSession) + } + ObjectType::MtaInboundThrottle => { + Pickle::unpickle(stream).map(ObjectInner::MtaInboundThrottle) + } + ObjectType::MtaMilter => Pickle::unpickle(stream).map(ObjectInner::MtaMilter), + ObjectType::MtaOutboundStrategy => { + Pickle::unpickle(stream).map(ObjectInner::MtaOutboundStrategy) + } + ObjectType::MtaOutboundThrottle => { + Pickle::unpickle(stream).map(ObjectInner::MtaOutboundThrottle) + } + ObjectType::MtaQueueQuota => Pickle::unpickle(stream).map(ObjectInner::MtaQueueQuota), + ObjectType::MtaRoute => Pickle::unpickle(stream).map(ObjectInner::MtaRoute), + ObjectType::MtaStageAuth => Pickle::unpickle(stream).map(ObjectInner::MtaStageAuth), + ObjectType::MtaStageConnect => { + Pickle::unpickle(stream).map(ObjectInner::MtaStageConnect) + } + ObjectType::MtaStageData => Pickle::unpickle(stream).map(ObjectInner::MtaStageData), + ObjectType::MtaStageEhlo => Pickle::unpickle(stream).map(ObjectInner::MtaStageEhlo), + ObjectType::MtaStageMail => Pickle::unpickle(stream).map(ObjectInner::MtaStageMail), + ObjectType::MtaStageRcpt => Pickle::unpickle(stream).map(ObjectInner::MtaStageRcpt), + ObjectType::MtaSts => Pickle::unpickle(stream).map(ObjectInner::MtaSts), + ObjectType::MtaTlsStrategy => Pickle::unpickle(stream).map(ObjectInner::MtaTlsStrategy), + ObjectType::MtaVirtualQueue => { + Pickle::unpickle(stream).map(ObjectInner::MtaVirtualQueue) + } + ObjectType::NetworkListener => { + Pickle::unpickle(stream).map(ObjectInner::NetworkListener) + } + ObjectType::OAuthClient => Pickle::unpickle(stream).map(ObjectInner::OAuthClient), + ObjectType::OidcProvider => Pickle::unpickle(stream).map(ObjectInner::OidcProvider), + ObjectType::PublicKey => Pickle::unpickle(stream).map(ObjectInner::PublicKey), + ObjectType::QueuedMessage => Pickle::unpickle(stream).map(ObjectInner::QueuedMessage), + ObjectType::ReportSettings => Pickle::unpickle(stream).map(ObjectInner::ReportSettings), + ObjectType::Role => Pickle::unpickle(stream).map(ObjectInner::Role), + ObjectType::Search => Pickle::unpickle(stream).map(ObjectInner::Search), + ObjectType::SearchStore => Pickle::unpickle(stream).map(ObjectInner::SearchStore), + ObjectType::Security => Pickle::unpickle(stream).map(ObjectInner::Security), + ObjectType::SenderAuth => Pickle::unpickle(stream).map(ObjectInner::SenderAuth), + ObjectType::Sharing => Pickle::unpickle(stream).map(ObjectInner::Sharing), + ObjectType::SieveSystemInterpreter => { + Pickle::unpickle(stream).map(ObjectInner::SieveSystemInterpreter) + } + ObjectType::SieveSystemScript => { + Pickle::unpickle(stream).map(ObjectInner::SieveSystemScript) + } + ObjectType::SieveUserInterpreter => { + Pickle::unpickle(stream).map(ObjectInner::SieveUserInterpreter) + } + ObjectType::SieveUserScript => { + Pickle::unpickle(stream).map(ObjectInner::SieveUserScript) + } + ObjectType::SpamClassifier => Pickle::unpickle(stream).map(ObjectInner::SpamClassifier), + ObjectType::SpamDnsblServer => { + Pickle::unpickle(stream).map(ObjectInner::SpamDnsblServer) + } + ObjectType::SpamDnsblSettings => { + Pickle::unpickle(stream).map(ObjectInner::SpamDnsblSettings) + } + ObjectType::SpamFileExtension => { + Pickle::unpickle(stream).map(ObjectInner::SpamFileExtension) + } + ObjectType::SpamLlm => Pickle::unpickle(stream).map(ObjectInner::SpamLlm), + ObjectType::SpamPyzor => Pickle::unpickle(stream).map(ObjectInner::SpamPyzor), + ObjectType::SpamRule => Pickle::unpickle(stream).map(ObjectInner::SpamRule), + ObjectType::SpamSettings => Pickle::unpickle(stream).map(ObjectInner::SpamSettings), + ObjectType::SpamTag => Pickle::unpickle(stream).map(ObjectInner::SpamTag), + ObjectType::SpamTrainingSample => { + Pickle::unpickle(stream).map(ObjectInner::SpamTrainingSample) + } + ObjectType::SpfReportSettings => { + Pickle::unpickle(stream).map(ObjectInner::SpfReportSettings) + } + ObjectType::StoreLookup => Pickle::unpickle(stream).map(ObjectInner::StoreLookup), + ObjectType::SystemSettings => Pickle::unpickle(stream).map(ObjectInner::SystemSettings), + ObjectType::Task => Pickle::unpickle(stream).map(ObjectInner::Task), + ObjectType::TaskManager => Pickle::unpickle(stream).map(ObjectInner::TaskManager), + ObjectType::Tenant => Pickle::unpickle(stream).map(ObjectInner::Tenant), + ObjectType::TlsExternalReport => { + Pickle::unpickle(stream).map(ObjectInner::TlsExternalReport) + } + ObjectType::TlsInternalReport => { + Pickle::unpickle(stream).map(ObjectInner::TlsInternalReport) + } + ObjectType::TlsReportSettings => { + Pickle::unpickle(stream).map(ObjectInner::TlsReportSettings) + } + ObjectType::Trace => Pickle::unpickle(stream).map(ObjectInner::Trace), + ObjectType::Tracer => Pickle::unpickle(stream).map(ObjectInner::Tracer), + ObjectType::TracingStore => Pickle::unpickle(stream).map(ObjectInner::TracingStore), + ObjectType::WebDav => Pickle::unpickle(stream).map(ObjectInner::WebDav), + ObjectType::WebHook => Pickle::unpickle(stream).map(ObjectInner::WebHook), + } + } + + pub fn deserialize<'de, D>(object: ObjectType, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match object { + ObjectType::Account => Account::deserialize(deserializer).map(ObjectInner::Account), + ObjectType::AccountPassword => { + AccountPassword::deserialize(deserializer).map(ObjectInner::AccountPassword) + } + ObjectType::AccountSettings => { + AccountSettings::deserialize(deserializer).map(ObjectInner::AccountSettings) + } + ObjectType::AcmeProvider => { + AcmeProvider::deserialize(deserializer).map(ObjectInner::AcmeProvider) + } + ObjectType::Action => Action::deserialize(deserializer).map(ObjectInner::Action), + ObjectType::AddressBook => { + AddressBook::deserialize(deserializer).map(ObjectInner::AddressBook) + } + ObjectType::AiModel => AiModel::deserialize(deserializer).map(ObjectInner::AiModel), + ObjectType::Alert => Alert::deserialize(deserializer).map(ObjectInner::Alert), + ObjectType::AllowedIp => { + AllowedIp::deserialize(deserializer).map(ObjectInner::AllowedIp) + } + ObjectType::ApiKey => ApiKey::deserialize(deserializer).map(ObjectInner::ApiKey), + ObjectType::AppPassword => { + AppPassword::deserialize(deserializer).map(ObjectInner::AppPassword) + } + ObjectType::Application => { + Application::deserialize(deserializer).map(ObjectInner::Application) + } + ObjectType::ArchivedItem => { + ArchivedItem::deserialize(deserializer).map(ObjectInner::ArchivedItem) + } + ObjectType::ArfExternalReport => { + ArfExternalReport::deserialize(deserializer).map(ObjectInner::ArfExternalReport) + } + ObjectType::Asn => Asn::deserialize(deserializer).map(ObjectInner::Asn), + ObjectType::Authentication => { + Authentication::deserialize(deserializer).map(ObjectInner::Authentication) + } + ObjectType::BlobStore => { + BlobStore::deserialize(deserializer).map(ObjectInner::BlobStore) + } + ObjectType::BlockedIp => { + BlockedIp::deserialize(deserializer).map(ObjectInner::BlockedIp) + } + ObjectType::Bootstrap => { + Bootstrap::deserialize(deserializer).map(ObjectInner::Bootstrap) + } + ObjectType::Cache => Cache::deserialize(deserializer).map(ObjectInner::Cache), + ObjectType::Calendar => Calendar::deserialize(deserializer).map(ObjectInner::Calendar), + ObjectType::CalendarAlarm => { + CalendarAlarm::deserialize(deserializer).map(ObjectInner::CalendarAlarm) + } + ObjectType::CalendarScheduling => { + CalendarScheduling::deserialize(deserializer).map(ObjectInner::CalendarScheduling) + } + ObjectType::Certificate => { + Certificate::deserialize(deserializer).map(ObjectInner::Certificate) + } + ObjectType::ClusterNode => { + ClusterNode::deserialize(deserializer).map(ObjectInner::ClusterNode) + } + ObjectType::ClusterRole => { + ClusterRole::deserialize(deserializer).map(ObjectInner::ClusterRole) + } + ObjectType::Coordinator => { + Coordinator::deserialize(deserializer).map(ObjectInner::Coordinator) + } + ObjectType::DataRetention => { + DataRetention::deserialize(deserializer).map(ObjectInner::DataRetention) + } + ObjectType::DataStore => { + DataStore::deserialize(deserializer).map(ObjectInner::DataStore) + } + ObjectType::Directory => { + Directory::deserialize(deserializer).map(ObjectInner::Directory) + } + ObjectType::DkimReportSettings => { + DkimReportSettings::deserialize(deserializer).map(ObjectInner::DkimReportSettings) + } + ObjectType::DkimSignature => { + DkimSignature::deserialize(deserializer).map(ObjectInner::DkimSignature) + } + ObjectType::DmarcExternalReport => { + DmarcExternalReport::deserialize(deserializer).map(ObjectInner::DmarcExternalReport) + } + ObjectType::DmarcInternalReport => { + DmarcInternalReport::deserialize(deserializer).map(ObjectInner::DmarcInternalReport) + } + ObjectType::DmarcReportSettings => { + DmarcReportSettings::deserialize(deserializer).map(ObjectInner::DmarcReportSettings) + } + ObjectType::DnsResolver => { + DnsResolver::deserialize(deserializer).map(ObjectInner::DnsResolver) + } + ObjectType::DnsServer => { + DnsServer::deserialize(deserializer).map(ObjectInner::DnsServer) + } + ObjectType::Domain => Domain::deserialize(deserializer).map(ObjectInner::Domain), + ObjectType::DsnReportSettings => { + DsnReportSettings::deserialize(deserializer).map(ObjectInner::DsnReportSettings) + } + ObjectType::Email => Email::deserialize(deserializer).map(ObjectInner::Email), + ObjectType::Enterprise => { + Enterprise::deserialize(deserializer).map(ObjectInner::Enterprise) + } + ObjectType::EventTracingLevel => { + EventTracingLevel::deserialize(deserializer).map(ObjectInner::EventTracingLevel) + } + ObjectType::FileStorage => { + FileStorage::deserialize(deserializer).map(ObjectInner::FileStorage) + } + ObjectType::Http => Http::deserialize(deserializer).map(ObjectInner::Http), + ObjectType::HttpForm => HttpForm::deserialize(deserializer).map(ObjectInner::HttpForm), + ObjectType::HttpLookup => { + HttpLookup::deserialize(deserializer).map(ObjectInner::HttpLookup) + } + ObjectType::Imap => Imap::deserialize(deserializer).map(ObjectInner::Imap), + ObjectType::InMemoryStore => { + InMemoryStore::deserialize(deserializer).map(ObjectInner::InMemoryStore) + } + ObjectType::Jmap => Jmap::deserialize(deserializer).map(ObjectInner::Jmap), + ObjectType::Log => Log::deserialize(deserializer).map(ObjectInner::Log), + ObjectType::MailingList => { + MailingList::deserialize(deserializer).map(ObjectInner::MailingList) + } + ObjectType::MaskedEmail => { + MaskedEmail::deserialize(deserializer).map(ObjectInner::MaskedEmail) + } + ObjectType::MemoryLookupKey => { + MemoryLookupKey::deserialize(deserializer).map(ObjectInner::MemoryLookupKey) + } + ObjectType::MemoryLookupKeyValue => MemoryLookupKeyValue::deserialize(deserializer) + .map(ObjectInner::MemoryLookupKeyValue), + ObjectType::Metric => Metric::deserialize(deserializer).map(ObjectInner::Metric), + ObjectType::Metrics => Metrics::deserialize(deserializer).map(ObjectInner::Metrics), + ObjectType::MetricsStore => { + MetricsStore::deserialize(deserializer).map(ObjectInner::MetricsStore) + } + ObjectType::MtaConnectionStrategy => MtaConnectionStrategy::deserialize(deserializer) + .map(ObjectInner::MtaConnectionStrategy), + ObjectType::MtaDeliverySchedule => { + MtaDeliverySchedule::deserialize(deserializer).map(ObjectInner::MtaDeliverySchedule) + } + ObjectType::MtaExtensions => { + MtaExtensions::deserialize(deserializer).map(ObjectInner::MtaExtensions) + } + ObjectType::MtaHook => MtaHook::deserialize(deserializer).map(ObjectInner::MtaHook), + ObjectType::MtaInboundSession => { + MtaInboundSession::deserialize(deserializer).map(ObjectInner::MtaInboundSession) + } + ObjectType::MtaInboundThrottle => { + MtaInboundThrottle::deserialize(deserializer).map(ObjectInner::MtaInboundThrottle) + } + ObjectType::MtaMilter => { + MtaMilter::deserialize(deserializer).map(ObjectInner::MtaMilter) + } + ObjectType::MtaOutboundStrategy => { + MtaOutboundStrategy::deserialize(deserializer).map(ObjectInner::MtaOutboundStrategy) + } + ObjectType::MtaOutboundThrottle => { + MtaOutboundThrottle::deserialize(deserializer).map(ObjectInner::MtaOutboundThrottle) + } + ObjectType::MtaQueueQuota => { + MtaQueueQuota::deserialize(deserializer).map(ObjectInner::MtaQueueQuota) + } + ObjectType::MtaRoute => MtaRoute::deserialize(deserializer).map(ObjectInner::MtaRoute), + ObjectType::MtaStageAuth => { + MtaStageAuth::deserialize(deserializer).map(ObjectInner::MtaStageAuth) + } + ObjectType::MtaStageConnect => { + MtaStageConnect::deserialize(deserializer).map(ObjectInner::MtaStageConnect) + } + ObjectType::MtaStageData => { + MtaStageData::deserialize(deserializer).map(ObjectInner::MtaStageData) + } + ObjectType::MtaStageEhlo => { + MtaStageEhlo::deserialize(deserializer).map(ObjectInner::MtaStageEhlo) + } + ObjectType::MtaStageMail => { + MtaStageMail::deserialize(deserializer).map(ObjectInner::MtaStageMail) + } + ObjectType::MtaStageRcpt => { + MtaStageRcpt::deserialize(deserializer).map(ObjectInner::MtaStageRcpt) + } + ObjectType::MtaSts => MtaSts::deserialize(deserializer).map(ObjectInner::MtaSts), + ObjectType::MtaTlsStrategy => { + MtaTlsStrategy::deserialize(deserializer).map(ObjectInner::MtaTlsStrategy) + } + ObjectType::MtaVirtualQueue => { + MtaVirtualQueue::deserialize(deserializer).map(ObjectInner::MtaVirtualQueue) + } + ObjectType::NetworkListener => { + NetworkListener::deserialize(deserializer).map(ObjectInner::NetworkListener) + } + ObjectType::OAuthClient => { + OAuthClient::deserialize(deserializer).map(ObjectInner::OAuthClient) + } + ObjectType::OidcProvider => { + OidcProvider::deserialize(deserializer).map(ObjectInner::OidcProvider) + } + ObjectType::PublicKey => { + PublicKey::deserialize(deserializer).map(ObjectInner::PublicKey) + } + ObjectType::QueuedMessage => { + QueuedMessage::deserialize(deserializer).map(ObjectInner::QueuedMessage) + } + ObjectType::ReportSettings => { + ReportSettings::deserialize(deserializer).map(ObjectInner::ReportSettings) + } + ObjectType::Role => Role::deserialize(deserializer).map(ObjectInner::Role), + ObjectType::Search => Search::deserialize(deserializer).map(ObjectInner::Search), + ObjectType::SearchStore => { + SearchStore::deserialize(deserializer).map(ObjectInner::SearchStore) + } + ObjectType::Security => Security::deserialize(deserializer).map(ObjectInner::Security), + ObjectType::SenderAuth => { + SenderAuth::deserialize(deserializer).map(ObjectInner::SenderAuth) + } + ObjectType::Sharing => Sharing::deserialize(deserializer).map(ObjectInner::Sharing), + ObjectType::SieveSystemInterpreter => SieveSystemInterpreter::deserialize(deserializer) + .map(ObjectInner::SieveSystemInterpreter), + ObjectType::SieveSystemScript => { + SieveSystemScript::deserialize(deserializer).map(ObjectInner::SieveSystemScript) + } + ObjectType::SieveUserInterpreter => SieveUserInterpreter::deserialize(deserializer) + .map(ObjectInner::SieveUserInterpreter), + ObjectType::SieveUserScript => { + SieveUserScript::deserialize(deserializer).map(ObjectInner::SieveUserScript) + } + ObjectType::SpamClassifier => { + SpamClassifier::deserialize(deserializer).map(ObjectInner::SpamClassifier) + } + ObjectType::SpamDnsblServer => { + SpamDnsblServer::deserialize(deserializer).map(ObjectInner::SpamDnsblServer) + } + ObjectType::SpamDnsblSettings => { + SpamDnsblSettings::deserialize(deserializer).map(ObjectInner::SpamDnsblSettings) + } + ObjectType::SpamFileExtension => { + SpamFileExtension::deserialize(deserializer).map(ObjectInner::SpamFileExtension) + } + ObjectType::SpamLlm => SpamLlm::deserialize(deserializer).map(ObjectInner::SpamLlm), + ObjectType::SpamPyzor => { + SpamPyzor::deserialize(deserializer).map(ObjectInner::SpamPyzor) + } + ObjectType::SpamRule => SpamRule::deserialize(deserializer).map(ObjectInner::SpamRule), + ObjectType::SpamSettings => { + SpamSettings::deserialize(deserializer).map(ObjectInner::SpamSettings) + } + ObjectType::SpamTag => SpamTag::deserialize(deserializer).map(ObjectInner::SpamTag), + ObjectType::SpamTrainingSample => { + SpamTrainingSample::deserialize(deserializer).map(ObjectInner::SpamTrainingSample) + } + ObjectType::SpfReportSettings => { + SpfReportSettings::deserialize(deserializer).map(ObjectInner::SpfReportSettings) + } + ObjectType::StoreLookup => { + StoreLookup::deserialize(deserializer).map(ObjectInner::StoreLookup) + } + ObjectType::SystemSettings => { + SystemSettings::deserialize(deserializer).map(ObjectInner::SystemSettings) + } + ObjectType::Task => Task::deserialize(deserializer).map(ObjectInner::Task), + ObjectType::TaskManager => { + TaskManager::deserialize(deserializer).map(ObjectInner::TaskManager) + } + ObjectType::Tenant => Tenant::deserialize(deserializer).map(ObjectInner::Tenant), + ObjectType::TlsExternalReport => { + TlsExternalReport::deserialize(deserializer).map(ObjectInner::TlsExternalReport) + } + ObjectType::TlsInternalReport => { + TlsInternalReport::deserialize(deserializer).map(ObjectInner::TlsInternalReport) + } + ObjectType::TlsReportSettings => { + TlsReportSettings::deserialize(deserializer).map(ObjectInner::TlsReportSettings) + } + ObjectType::Trace => Trace::deserialize(deserializer).map(ObjectInner::Trace), + ObjectType::Tracer => Tracer::deserialize(deserializer).map(ObjectInner::Tracer), + ObjectType::TracingStore => { + TracingStore::deserialize(deserializer).map(ObjectInner::TracingStore) + } + ObjectType::WebDav => WebDav::deserialize(deserializer).map(ObjectInner::WebDav), + ObjectType::WebHook => WebHook::deserialize(deserializer).map(ObjectInner::WebHook), + } + } + + pub fn expression_ctxs(&self) -> Option>> { + match &self { + ObjectInner::Alert(obj) => Some(obj.expression_ctxs()), + ObjectInner::DkimReportSettings(obj) => Some(obj.expression_ctxs()), + ObjectInner::DmarcReportSettings(obj) => Some(obj.expression_ctxs()), + ObjectInner::DsnReportSettings(obj) => Some(obj.expression_ctxs()), + ObjectInner::Http(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaExtensions(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaHook(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaInboundSession(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaInboundThrottle(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaMilter(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaOutboundStrategy(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaOutboundThrottle(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaQueueQuota(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageAuth(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageConnect(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageData(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageEhlo(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageMail(obj) => Some(obj.expression_ctxs()), + ObjectInner::MtaStageRcpt(obj) => Some(obj.expression_ctxs()), + ObjectInner::ReportSettings(obj) => Some(obj.expression_ctxs()), + ObjectInner::SenderAuth(obj) => Some(obj.expression_ctxs()), + ObjectInner::SieveSystemInterpreter(obj) => Some(obj.expression_ctxs()), + ObjectInner::SieveUserInterpreter(obj) => Some(obj.expression_ctxs()), + ObjectInner::SpamDnsblServer(obj) => Some(obj.expression_ctxs()), + ObjectInner::SpamRule(obj) => Some(obj.expression_ctxs()), + ObjectInner::SpfReportSettings(obj) => Some(obj.expression_ctxs()), + ObjectInner::TlsReportSettings(obj) => Some(obj.expression_ctxs()), + _ => None, + } + } +} + +impl Object { + pub fn flags(&self) -> u64 { + match &self.inner { + ObjectInner::Account(_) => Account::FLAGS, + ObjectInner::AccountPassword(_) => AccountPassword::FLAGS, + ObjectInner::AccountSettings(_) => AccountSettings::FLAGS, + ObjectInner::AcmeProvider(_) => AcmeProvider::FLAGS, + ObjectInner::Action(_) => Action::FLAGS, + ObjectInner::AddressBook(_) => AddressBook::FLAGS, + ObjectInner::AiModel(_) => AiModel::FLAGS, + ObjectInner::Alert(_) => Alert::FLAGS, + ObjectInner::AllowedIp(_) => AllowedIp::FLAGS, + ObjectInner::ApiKey(_) => ApiKey::FLAGS, + ObjectInner::AppPassword(_) => AppPassword::FLAGS, + ObjectInner::Application(_) => Application::FLAGS, + ObjectInner::ArchivedItem(_) => ArchivedItem::FLAGS, + ObjectInner::ArfExternalReport(_) => ArfExternalReport::FLAGS, + ObjectInner::Asn(_) => Asn::FLAGS, + ObjectInner::Authentication(_) => Authentication::FLAGS, + ObjectInner::BlobStore(_) => BlobStore::FLAGS, + ObjectInner::BlockedIp(_) => BlockedIp::FLAGS, + ObjectInner::Bootstrap(_) => Bootstrap::FLAGS, + ObjectInner::Cache(_) => Cache::FLAGS, + ObjectInner::Calendar(_) => Calendar::FLAGS, + ObjectInner::CalendarAlarm(_) => CalendarAlarm::FLAGS, + ObjectInner::CalendarScheduling(_) => CalendarScheduling::FLAGS, + ObjectInner::Certificate(_) => Certificate::FLAGS, + ObjectInner::ClusterNode(_) => ClusterNode::FLAGS, + ObjectInner::ClusterRole(_) => ClusterRole::FLAGS, + ObjectInner::Coordinator(_) => Coordinator::FLAGS, + ObjectInner::DataRetention(_) => DataRetention::FLAGS, + ObjectInner::DataStore(_) => DataStore::FLAGS, + ObjectInner::Directory(_) => Directory::FLAGS, + ObjectInner::DkimReportSettings(_) => DkimReportSettings::FLAGS, + ObjectInner::DkimSignature(_) => DkimSignature::FLAGS, + ObjectInner::DmarcExternalReport(_) => DmarcExternalReport::FLAGS, + ObjectInner::DmarcInternalReport(_) => DmarcInternalReport::FLAGS, + ObjectInner::DmarcReportSettings(_) => DmarcReportSettings::FLAGS, + ObjectInner::DnsResolver(_) => DnsResolver::FLAGS, + ObjectInner::DnsServer(_) => DnsServer::FLAGS, + ObjectInner::Domain(_) => Domain::FLAGS, + ObjectInner::DsnReportSettings(_) => DsnReportSettings::FLAGS, + ObjectInner::Email(_) => Email::FLAGS, + ObjectInner::Enterprise(_) => Enterprise::FLAGS, + ObjectInner::EventTracingLevel(_) => EventTracingLevel::FLAGS, + ObjectInner::FileStorage(_) => FileStorage::FLAGS, + ObjectInner::Http(_) => Http::FLAGS, + ObjectInner::HttpForm(_) => HttpForm::FLAGS, + ObjectInner::HttpLookup(_) => HttpLookup::FLAGS, + ObjectInner::Imap(_) => Imap::FLAGS, + ObjectInner::InMemoryStore(_) => InMemoryStore::FLAGS, + ObjectInner::Jmap(_) => Jmap::FLAGS, + ObjectInner::Log(_) => Log::FLAGS, + ObjectInner::MailingList(_) => MailingList::FLAGS, + ObjectInner::MaskedEmail(_) => MaskedEmail::FLAGS, + ObjectInner::MemoryLookupKey(_) => MemoryLookupKey::FLAGS, + ObjectInner::MemoryLookupKeyValue(_) => MemoryLookupKeyValue::FLAGS, + ObjectInner::Metric(_) => Metric::FLAGS, + ObjectInner::Metrics(_) => Metrics::FLAGS, + ObjectInner::MetricsStore(_) => MetricsStore::FLAGS, + ObjectInner::MtaConnectionStrategy(_) => MtaConnectionStrategy::FLAGS, + ObjectInner::MtaDeliverySchedule(_) => MtaDeliverySchedule::FLAGS, + ObjectInner::MtaExtensions(_) => MtaExtensions::FLAGS, + ObjectInner::MtaHook(_) => MtaHook::FLAGS, + ObjectInner::MtaInboundSession(_) => MtaInboundSession::FLAGS, + ObjectInner::MtaInboundThrottle(_) => MtaInboundThrottle::FLAGS, + ObjectInner::MtaMilter(_) => MtaMilter::FLAGS, + ObjectInner::MtaOutboundStrategy(_) => MtaOutboundStrategy::FLAGS, + ObjectInner::MtaOutboundThrottle(_) => MtaOutboundThrottle::FLAGS, + ObjectInner::MtaQueueQuota(_) => MtaQueueQuota::FLAGS, + ObjectInner::MtaRoute(_) => MtaRoute::FLAGS, + ObjectInner::MtaStageAuth(_) => MtaStageAuth::FLAGS, + ObjectInner::MtaStageConnect(_) => MtaStageConnect::FLAGS, + ObjectInner::MtaStageData(_) => MtaStageData::FLAGS, + ObjectInner::MtaStageEhlo(_) => MtaStageEhlo::FLAGS, + ObjectInner::MtaStageMail(_) => MtaStageMail::FLAGS, + ObjectInner::MtaStageRcpt(_) => MtaStageRcpt::FLAGS, + ObjectInner::MtaSts(_) => MtaSts::FLAGS, + ObjectInner::MtaTlsStrategy(_) => MtaTlsStrategy::FLAGS, + ObjectInner::MtaVirtualQueue(_) => MtaVirtualQueue::FLAGS, + ObjectInner::NetworkListener(_) => NetworkListener::FLAGS, + ObjectInner::OAuthClient(_) => OAuthClient::FLAGS, + ObjectInner::OidcProvider(_) => OidcProvider::FLAGS, + ObjectInner::PublicKey(_) => PublicKey::FLAGS, + ObjectInner::QueuedMessage(_) => QueuedMessage::FLAGS, + ObjectInner::ReportSettings(_) => ReportSettings::FLAGS, + ObjectInner::Role(_) => Role::FLAGS, + ObjectInner::Search(_) => Search::FLAGS, + ObjectInner::SearchStore(_) => SearchStore::FLAGS, + ObjectInner::Security(_) => Security::FLAGS, + ObjectInner::SenderAuth(_) => SenderAuth::FLAGS, + ObjectInner::Sharing(_) => Sharing::FLAGS, + ObjectInner::SieveSystemInterpreter(_) => SieveSystemInterpreter::FLAGS, + ObjectInner::SieveSystemScript(_) => SieveSystemScript::FLAGS, + ObjectInner::SieveUserInterpreter(_) => SieveUserInterpreter::FLAGS, + ObjectInner::SieveUserScript(_) => SieveUserScript::FLAGS, + ObjectInner::SpamClassifier(_) => SpamClassifier::FLAGS, + ObjectInner::SpamDnsblServer(_) => SpamDnsblServer::FLAGS, + ObjectInner::SpamDnsblSettings(_) => SpamDnsblSettings::FLAGS, + ObjectInner::SpamFileExtension(_) => SpamFileExtension::FLAGS, + ObjectInner::SpamLlm(_) => SpamLlm::FLAGS, + ObjectInner::SpamPyzor(_) => SpamPyzor::FLAGS, + ObjectInner::SpamRule(_) => SpamRule::FLAGS, + ObjectInner::SpamSettings(_) => SpamSettings::FLAGS, + ObjectInner::SpamTag(_) => SpamTag::FLAGS, + ObjectInner::SpamTrainingSample(_) => SpamTrainingSample::FLAGS, + ObjectInner::SpfReportSettings(_) => SpfReportSettings::FLAGS, + ObjectInner::StoreLookup(_) => StoreLookup::FLAGS, + ObjectInner::SystemSettings(_) => SystemSettings::FLAGS, + ObjectInner::Task(_) => Task::FLAGS, + ObjectInner::TaskManager(_) => TaskManager::FLAGS, + ObjectInner::Tenant(_) => Tenant::FLAGS, + ObjectInner::TlsExternalReport(_) => TlsExternalReport::FLAGS, + ObjectInner::TlsInternalReport(_) => TlsInternalReport::FLAGS, + ObjectInner::TlsReportSettings(_) => TlsReportSettings::FLAGS, + ObjectInner::Trace(_) => Trace::FLAGS, + ObjectInner::Tracer(_) => Tracer::FLAGS, + ObjectInner::TracingStore(_) => TracingStore::FLAGS, + ObjectInner::WebDav(_) => WebDav::FLAGS, + ObjectInner::WebHook(_) => WebHook::FLAGS, + } + } + + pub fn object_type(&self) -> ObjectType { + match &self.inner { + ObjectInner::Account(_) => ObjectType::Account, + ObjectInner::AccountPassword(_) => ObjectType::AccountPassword, + ObjectInner::AccountSettings(_) => ObjectType::AccountSettings, + ObjectInner::AcmeProvider(_) => ObjectType::AcmeProvider, + ObjectInner::Action(_) => ObjectType::Action, + ObjectInner::AddressBook(_) => ObjectType::AddressBook, + ObjectInner::AiModel(_) => ObjectType::AiModel, + ObjectInner::Alert(_) => ObjectType::Alert, + ObjectInner::AllowedIp(_) => ObjectType::AllowedIp, + ObjectInner::ApiKey(_) => ObjectType::ApiKey, + ObjectInner::AppPassword(_) => ObjectType::AppPassword, + ObjectInner::Application(_) => ObjectType::Application, + ObjectInner::ArchivedItem(_) => ObjectType::ArchivedItem, + ObjectInner::ArfExternalReport(_) => ObjectType::ArfExternalReport, + ObjectInner::Asn(_) => ObjectType::Asn, + ObjectInner::Authentication(_) => ObjectType::Authentication, + ObjectInner::BlobStore(_) => ObjectType::BlobStore, + ObjectInner::BlockedIp(_) => ObjectType::BlockedIp, + ObjectInner::Bootstrap(_) => ObjectType::Bootstrap, + ObjectInner::Cache(_) => ObjectType::Cache, + ObjectInner::Calendar(_) => ObjectType::Calendar, + ObjectInner::CalendarAlarm(_) => ObjectType::CalendarAlarm, + ObjectInner::CalendarScheduling(_) => ObjectType::CalendarScheduling, + ObjectInner::Certificate(_) => ObjectType::Certificate, + ObjectInner::ClusterNode(_) => ObjectType::ClusterNode, + ObjectInner::ClusterRole(_) => ObjectType::ClusterRole, + ObjectInner::Coordinator(_) => ObjectType::Coordinator, + ObjectInner::DataRetention(_) => ObjectType::DataRetention, + ObjectInner::DataStore(_) => ObjectType::DataStore, + ObjectInner::Directory(_) => ObjectType::Directory, + ObjectInner::DkimReportSettings(_) => ObjectType::DkimReportSettings, + ObjectInner::DkimSignature(_) => ObjectType::DkimSignature, + ObjectInner::DmarcExternalReport(_) => ObjectType::DmarcExternalReport, + ObjectInner::DmarcInternalReport(_) => ObjectType::DmarcInternalReport, + ObjectInner::DmarcReportSettings(_) => ObjectType::DmarcReportSettings, + ObjectInner::DnsResolver(_) => ObjectType::DnsResolver, + ObjectInner::DnsServer(_) => ObjectType::DnsServer, + ObjectInner::Domain(_) => ObjectType::Domain, + ObjectInner::DsnReportSettings(_) => ObjectType::DsnReportSettings, + ObjectInner::Email(_) => ObjectType::Email, + ObjectInner::Enterprise(_) => ObjectType::Enterprise, + ObjectInner::EventTracingLevel(_) => ObjectType::EventTracingLevel, + ObjectInner::FileStorage(_) => ObjectType::FileStorage, + ObjectInner::Http(_) => ObjectType::Http, + ObjectInner::HttpForm(_) => ObjectType::HttpForm, + ObjectInner::HttpLookup(_) => ObjectType::HttpLookup, + ObjectInner::Imap(_) => ObjectType::Imap, + ObjectInner::InMemoryStore(_) => ObjectType::InMemoryStore, + ObjectInner::Jmap(_) => ObjectType::Jmap, + ObjectInner::Log(_) => ObjectType::Log, + ObjectInner::MailingList(_) => ObjectType::MailingList, + ObjectInner::MaskedEmail(_) => ObjectType::MaskedEmail, + ObjectInner::MemoryLookupKey(_) => ObjectType::MemoryLookupKey, + ObjectInner::MemoryLookupKeyValue(_) => ObjectType::MemoryLookupKeyValue, + ObjectInner::Metric(_) => ObjectType::Metric, + ObjectInner::Metrics(_) => ObjectType::Metrics, + ObjectInner::MetricsStore(_) => ObjectType::MetricsStore, + ObjectInner::MtaConnectionStrategy(_) => ObjectType::MtaConnectionStrategy, + ObjectInner::MtaDeliverySchedule(_) => ObjectType::MtaDeliverySchedule, + ObjectInner::MtaExtensions(_) => ObjectType::MtaExtensions, + ObjectInner::MtaHook(_) => ObjectType::MtaHook, + ObjectInner::MtaInboundSession(_) => ObjectType::MtaInboundSession, + ObjectInner::MtaInboundThrottle(_) => ObjectType::MtaInboundThrottle, + ObjectInner::MtaMilter(_) => ObjectType::MtaMilter, + ObjectInner::MtaOutboundStrategy(_) => ObjectType::MtaOutboundStrategy, + ObjectInner::MtaOutboundThrottle(_) => ObjectType::MtaOutboundThrottle, + ObjectInner::MtaQueueQuota(_) => ObjectType::MtaQueueQuota, + ObjectInner::MtaRoute(_) => ObjectType::MtaRoute, + ObjectInner::MtaStageAuth(_) => ObjectType::MtaStageAuth, + ObjectInner::MtaStageConnect(_) => ObjectType::MtaStageConnect, + ObjectInner::MtaStageData(_) => ObjectType::MtaStageData, + ObjectInner::MtaStageEhlo(_) => ObjectType::MtaStageEhlo, + ObjectInner::MtaStageMail(_) => ObjectType::MtaStageMail, + ObjectInner::MtaStageRcpt(_) => ObjectType::MtaStageRcpt, + ObjectInner::MtaSts(_) => ObjectType::MtaSts, + ObjectInner::MtaTlsStrategy(_) => ObjectType::MtaTlsStrategy, + ObjectInner::MtaVirtualQueue(_) => ObjectType::MtaVirtualQueue, + ObjectInner::NetworkListener(_) => ObjectType::NetworkListener, + ObjectInner::OAuthClient(_) => ObjectType::OAuthClient, + ObjectInner::OidcProvider(_) => ObjectType::OidcProvider, + ObjectInner::PublicKey(_) => ObjectType::PublicKey, + ObjectInner::QueuedMessage(_) => ObjectType::QueuedMessage, + ObjectInner::ReportSettings(_) => ObjectType::ReportSettings, + ObjectInner::Role(_) => ObjectType::Role, + ObjectInner::Search(_) => ObjectType::Search, + ObjectInner::SearchStore(_) => ObjectType::SearchStore, + ObjectInner::Security(_) => ObjectType::Security, + ObjectInner::SenderAuth(_) => ObjectType::SenderAuth, + ObjectInner::Sharing(_) => ObjectType::Sharing, + ObjectInner::SieveSystemInterpreter(_) => ObjectType::SieveSystemInterpreter, + ObjectInner::SieveSystemScript(_) => ObjectType::SieveSystemScript, + ObjectInner::SieveUserInterpreter(_) => ObjectType::SieveUserInterpreter, + ObjectInner::SieveUserScript(_) => ObjectType::SieveUserScript, + ObjectInner::SpamClassifier(_) => ObjectType::SpamClassifier, + ObjectInner::SpamDnsblServer(_) => ObjectType::SpamDnsblServer, + ObjectInner::SpamDnsblSettings(_) => ObjectType::SpamDnsblSettings, + ObjectInner::SpamFileExtension(_) => ObjectType::SpamFileExtension, + ObjectInner::SpamLlm(_) => ObjectType::SpamLlm, + ObjectInner::SpamPyzor(_) => ObjectType::SpamPyzor, + ObjectInner::SpamRule(_) => ObjectType::SpamRule, + ObjectInner::SpamSettings(_) => ObjectType::SpamSettings, + ObjectInner::SpamTag(_) => ObjectType::SpamTag, + ObjectInner::SpamTrainingSample(_) => ObjectType::SpamTrainingSample, + ObjectInner::SpfReportSettings(_) => ObjectType::SpfReportSettings, + ObjectInner::StoreLookup(_) => ObjectType::StoreLookup, + ObjectInner::SystemSettings(_) => ObjectType::SystemSettings, + ObjectInner::Task(_) => ObjectType::Task, + ObjectInner::TaskManager(_) => ObjectType::TaskManager, + ObjectInner::Tenant(_) => ObjectType::Tenant, + ObjectInner::TlsExternalReport(_) => ObjectType::TlsExternalReport, + ObjectInner::TlsInternalReport(_) => ObjectType::TlsInternalReport, + ObjectInner::TlsReportSettings(_) => ObjectType::TlsReportSettings, + ObjectInner::Trace(_) => ObjectType::Trace, + ObjectInner::Tracer(_) => ObjectType::Tracer, + ObjectInner::TracingStore(_) => ObjectType::TracingStore, + ObjectInner::WebDav(_) => ObjectType::WebDav, + ObjectInner::WebHook(_) => ObjectType::WebHook, + } + } + + pub fn object_variant(&self) -> Option<&'static str> { + match &self.inner { + ObjectInner::Account(obj) => Some(obj.object_type().as_str()), + ObjectInner::Action(obj) => Some(obj.object_type().as_str()), + ObjectInner::ArchivedItem(obj) => Some(obj.object_type().as_str()), + ObjectInner::Asn(obj) => Some(obj.object_type().as_str()), + ObjectInner::BlobStore(obj) => Some(obj.object_type().as_str()), + ObjectInner::Coordinator(obj) => Some(obj.object_type().as_str()), + ObjectInner::DataStore(obj) => Some(obj.object_type().as_str()), + ObjectInner::Directory(obj) => Some(obj.object_type().as_str()), + ObjectInner::DkimSignature(obj) => Some(obj.object_type().as_str()), + ObjectInner::DnsResolver(obj) => Some(obj.object_type().as_str()), + ObjectInner::DnsServer(obj) => Some(obj.object_type().as_str()), + ObjectInner::InMemoryStore(obj) => Some(obj.object_type().as_str()), + ObjectInner::Metric(obj) => Some(obj.object_type().as_str()), + ObjectInner::MetricsStore(obj) => Some(obj.object_type().as_str()), + ObjectInner::MtaRoute(obj) => Some(obj.object_type().as_str()), + ObjectInner::SearchStore(obj) => Some(obj.object_type().as_str()), + ObjectInner::SpamDnsblServer(obj) => Some(obj.object_type().as_str()), + ObjectInner::SpamLlm(obj) => Some(obj.object_type().as_str()), + ObjectInner::SpamRule(obj) => Some(obj.object_type().as_str()), + ObjectInner::SpamTag(obj) => Some(obj.object_type().as_str()), + ObjectInner::Task(obj) => Some(obj.object_type().as_str()), + ObjectInner::Tracer(obj) => Some(obj.object_type().as_str()), + ObjectInner::TracingStore(obj) => Some(obj.object_type().as_str()), + _ => None, + } + } + + pub fn validate(&self, errors: &mut Vec) -> bool { + match &self.inner { + ObjectInner::Account(obj) => obj.validate(errors), + ObjectInner::AccountPassword(obj) => obj.validate(errors), + ObjectInner::AccountSettings(obj) => obj.validate(errors), + ObjectInner::AcmeProvider(obj) => obj.validate(errors), + ObjectInner::Action(obj) => obj.validate(errors), + ObjectInner::AddressBook(obj) => obj.validate(errors), + ObjectInner::AiModel(obj) => obj.validate(errors), + ObjectInner::Alert(obj) => obj.validate(errors), + ObjectInner::AllowedIp(obj) => obj.validate(errors), + ObjectInner::ApiKey(obj) => obj.validate(errors), + ObjectInner::AppPassword(obj) => obj.validate(errors), + ObjectInner::Application(obj) => obj.validate(errors), + ObjectInner::ArchivedItem(obj) => obj.validate(errors), + ObjectInner::ArfExternalReport(obj) => obj.validate(errors), + ObjectInner::Asn(obj) => obj.validate(errors), + ObjectInner::Authentication(obj) => obj.validate(errors), + ObjectInner::BlobStore(obj) => obj.validate(errors), + ObjectInner::BlockedIp(obj) => obj.validate(errors), + ObjectInner::Bootstrap(obj) => obj.validate(errors), + ObjectInner::Cache(obj) => obj.validate(errors), + ObjectInner::Calendar(obj) => obj.validate(errors), + ObjectInner::CalendarAlarm(obj) => obj.validate(errors), + ObjectInner::CalendarScheduling(obj) => obj.validate(errors), + ObjectInner::Certificate(obj) => obj.validate(errors), + ObjectInner::ClusterNode(obj) => obj.validate(errors), + ObjectInner::ClusterRole(obj) => obj.validate(errors), + ObjectInner::Coordinator(obj) => obj.validate(errors), + ObjectInner::DataRetention(obj) => obj.validate(errors), + ObjectInner::DataStore(obj) => obj.validate(errors), + ObjectInner::Directory(obj) => obj.validate(errors), + ObjectInner::DkimReportSettings(obj) => obj.validate(errors), + ObjectInner::DkimSignature(obj) => obj.validate(errors), + ObjectInner::DmarcExternalReport(obj) => obj.validate(errors), + ObjectInner::DmarcInternalReport(obj) => obj.validate(errors), + ObjectInner::DmarcReportSettings(obj) => obj.validate(errors), + ObjectInner::DnsResolver(obj) => obj.validate(errors), + ObjectInner::DnsServer(obj) => obj.validate(errors), + ObjectInner::Domain(obj) => obj.validate(errors), + ObjectInner::DsnReportSettings(obj) => obj.validate(errors), + ObjectInner::Email(obj) => obj.validate(errors), + ObjectInner::Enterprise(obj) => obj.validate(errors), + ObjectInner::EventTracingLevel(obj) => obj.validate(errors), + ObjectInner::FileStorage(obj) => obj.validate(errors), + ObjectInner::Http(obj) => obj.validate(errors), + ObjectInner::HttpForm(obj) => obj.validate(errors), + ObjectInner::HttpLookup(obj) => obj.validate(errors), + ObjectInner::Imap(obj) => obj.validate(errors), + ObjectInner::InMemoryStore(obj) => obj.validate(errors), + ObjectInner::Jmap(obj) => obj.validate(errors), + ObjectInner::Log(obj) => obj.validate(errors), + ObjectInner::MailingList(obj) => obj.validate(errors), + ObjectInner::MaskedEmail(obj) => obj.validate(errors), + ObjectInner::MemoryLookupKey(obj) => obj.validate(errors), + ObjectInner::MemoryLookupKeyValue(obj) => obj.validate(errors), + ObjectInner::Metric(obj) => obj.validate(errors), + ObjectInner::Metrics(obj) => obj.validate(errors), + ObjectInner::MetricsStore(obj) => obj.validate(errors), + ObjectInner::MtaConnectionStrategy(obj) => obj.validate(errors), + ObjectInner::MtaDeliverySchedule(obj) => obj.validate(errors), + ObjectInner::MtaExtensions(obj) => obj.validate(errors), + ObjectInner::MtaHook(obj) => obj.validate(errors), + ObjectInner::MtaInboundSession(obj) => obj.validate(errors), + ObjectInner::MtaInboundThrottle(obj) => obj.validate(errors), + ObjectInner::MtaMilter(obj) => obj.validate(errors), + ObjectInner::MtaOutboundStrategy(obj) => obj.validate(errors), + ObjectInner::MtaOutboundThrottle(obj) => obj.validate(errors), + ObjectInner::MtaQueueQuota(obj) => obj.validate(errors), + ObjectInner::MtaRoute(obj) => obj.validate(errors), + ObjectInner::MtaStageAuth(obj) => obj.validate(errors), + ObjectInner::MtaStageConnect(obj) => obj.validate(errors), + ObjectInner::MtaStageData(obj) => obj.validate(errors), + ObjectInner::MtaStageEhlo(obj) => obj.validate(errors), + ObjectInner::MtaStageMail(obj) => obj.validate(errors), + ObjectInner::MtaStageRcpt(obj) => obj.validate(errors), + ObjectInner::MtaSts(obj) => obj.validate(errors), + ObjectInner::MtaTlsStrategy(obj) => obj.validate(errors), + ObjectInner::MtaVirtualQueue(obj) => obj.validate(errors), + ObjectInner::NetworkListener(obj) => obj.validate(errors), + ObjectInner::OAuthClient(obj) => obj.validate(errors), + ObjectInner::OidcProvider(obj) => obj.validate(errors), + ObjectInner::PublicKey(obj) => obj.validate(errors), + ObjectInner::QueuedMessage(obj) => obj.validate(errors), + ObjectInner::ReportSettings(obj) => obj.validate(errors), + ObjectInner::Role(obj) => obj.validate(errors), + ObjectInner::Search(obj) => obj.validate(errors), + ObjectInner::SearchStore(obj) => obj.validate(errors), + ObjectInner::Security(obj) => obj.validate(errors), + ObjectInner::SenderAuth(obj) => obj.validate(errors), + ObjectInner::Sharing(obj) => obj.validate(errors), + ObjectInner::SieveSystemInterpreter(obj) => obj.validate(errors), + ObjectInner::SieveSystemScript(obj) => obj.validate(errors), + ObjectInner::SieveUserInterpreter(obj) => obj.validate(errors), + ObjectInner::SieveUserScript(obj) => obj.validate(errors), + ObjectInner::SpamClassifier(obj) => obj.validate(errors), + ObjectInner::SpamDnsblServer(obj) => obj.validate(errors), + ObjectInner::SpamDnsblSettings(obj) => obj.validate(errors), + ObjectInner::SpamFileExtension(obj) => obj.validate(errors), + ObjectInner::SpamLlm(obj) => obj.validate(errors), + ObjectInner::SpamPyzor(obj) => obj.validate(errors), + ObjectInner::SpamRule(obj) => obj.validate(errors), + ObjectInner::SpamSettings(obj) => obj.validate(errors), + ObjectInner::SpamTag(obj) => obj.validate(errors), + ObjectInner::SpamTrainingSample(obj) => obj.validate(errors), + ObjectInner::SpfReportSettings(obj) => obj.validate(errors), + ObjectInner::StoreLookup(obj) => obj.validate(errors), + ObjectInner::SystemSettings(obj) => obj.validate(errors), + ObjectInner::Task(obj) => obj.validate(errors), + ObjectInner::TaskManager(obj) => obj.validate(errors), + ObjectInner::Tenant(obj) => obj.validate(errors), + ObjectInner::TlsExternalReport(obj) => obj.validate(errors), + ObjectInner::TlsInternalReport(obj) => obj.validate(errors), + ObjectInner::TlsReportSettings(obj) => obj.validate(errors), + ObjectInner::Trace(obj) => obj.validate(errors), + ObjectInner::Tracer(obj) => obj.validate(errors), + ObjectInner::TracingStore(obj) => obj.validate(errors), + ObjectInner::WebDav(obj) => obj.validate(errors), + ObjectInner::WebHook(obj) => obj.validate(errors), + } + } + + pub fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match &self.inner { + ObjectInner::Account(obj) => obj.index(i), + ObjectInner::AccountPassword(obj) => obj.index(i), + ObjectInner::AccountSettings(obj) => obj.index(i), + ObjectInner::AcmeProvider(obj) => obj.index(i), + ObjectInner::Action(obj) => obj.index(i), + ObjectInner::AddressBook(obj) => obj.index(i), + ObjectInner::AiModel(obj) => obj.index(i), + ObjectInner::Alert(obj) => obj.index(i), + ObjectInner::AllowedIp(obj) => obj.index(i), + ObjectInner::ApiKey(obj) => obj.index(i), + ObjectInner::AppPassword(obj) => obj.index(i), + ObjectInner::Application(obj) => obj.index(i), + ObjectInner::ArchivedItem(obj) => obj.index(i), + ObjectInner::ArfExternalReport(obj) => obj.index(i), + ObjectInner::Asn(obj) => obj.index(i), + ObjectInner::Authentication(obj) => obj.index(i), + ObjectInner::BlobStore(obj) => obj.index(i), + ObjectInner::BlockedIp(obj) => obj.index(i), + ObjectInner::Bootstrap(obj) => obj.index(i), + ObjectInner::Cache(obj) => obj.index(i), + ObjectInner::Calendar(obj) => obj.index(i), + ObjectInner::CalendarAlarm(obj) => obj.index(i), + ObjectInner::CalendarScheduling(obj) => obj.index(i), + ObjectInner::Certificate(obj) => obj.index(i), + ObjectInner::ClusterNode(obj) => obj.index(i), + ObjectInner::ClusterRole(obj) => obj.index(i), + ObjectInner::Coordinator(obj) => obj.index(i), + ObjectInner::DataRetention(obj) => obj.index(i), + ObjectInner::DataStore(obj) => obj.index(i), + ObjectInner::Directory(obj) => obj.index(i), + ObjectInner::DkimReportSettings(obj) => obj.index(i), + ObjectInner::DkimSignature(obj) => obj.index(i), + ObjectInner::DmarcExternalReport(obj) => obj.index(i), + ObjectInner::DmarcInternalReport(obj) => obj.index(i), + ObjectInner::DmarcReportSettings(obj) => obj.index(i), + ObjectInner::DnsResolver(obj) => obj.index(i), + ObjectInner::DnsServer(obj) => obj.index(i), + ObjectInner::Domain(obj) => obj.index(i), + ObjectInner::DsnReportSettings(obj) => obj.index(i), + ObjectInner::Email(obj) => obj.index(i), + ObjectInner::Enterprise(obj) => obj.index(i), + ObjectInner::EventTracingLevel(obj) => obj.index(i), + ObjectInner::FileStorage(obj) => obj.index(i), + ObjectInner::Http(obj) => obj.index(i), + ObjectInner::HttpForm(obj) => obj.index(i), + ObjectInner::HttpLookup(obj) => obj.index(i), + ObjectInner::Imap(obj) => obj.index(i), + ObjectInner::InMemoryStore(obj) => obj.index(i), + ObjectInner::Jmap(obj) => obj.index(i), + ObjectInner::Log(obj) => obj.index(i), + ObjectInner::MailingList(obj) => obj.index(i), + ObjectInner::MaskedEmail(obj) => obj.index(i), + ObjectInner::MemoryLookupKey(obj) => obj.index(i), + ObjectInner::MemoryLookupKeyValue(obj) => obj.index(i), + ObjectInner::Metric(obj) => obj.index(i), + ObjectInner::Metrics(obj) => obj.index(i), + ObjectInner::MetricsStore(obj) => obj.index(i), + ObjectInner::MtaConnectionStrategy(obj) => obj.index(i), + ObjectInner::MtaDeliverySchedule(obj) => obj.index(i), + ObjectInner::MtaExtensions(obj) => obj.index(i), + ObjectInner::MtaHook(obj) => obj.index(i), + ObjectInner::MtaInboundSession(obj) => obj.index(i), + ObjectInner::MtaInboundThrottle(obj) => obj.index(i), + ObjectInner::MtaMilter(obj) => obj.index(i), + ObjectInner::MtaOutboundStrategy(obj) => obj.index(i), + ObjectInner::MtaOutboundThrottle(obj) => obj.index(i), + ObjectInner::MtaQueueQuota(obj) => obj.index(i), + ObjectInner::MtaRoute(obj) => obj.index(i), + ObjectInner::MtaStageAuth(obj) => obj.index(i), + ObjectInner::MtaStageConnect(obj) => obj.index(i), + ObjectInner::MtaStageData(obj) => obj.index(i), + ObjectInner::MtaStageEhlo(obj) => obj.index(i), + ObjectInner::MtaStageMail(obj) => obj.index(i), + ObjectInner::MtaStageRcpt(obj) => obj.index(i), + ObjectInner::MtaSts(obj) => obj.index(i), + ObjectInner::MtaTlsStrategy(obj) => obj.index(i), + ObjectInner::MtaVirtualQueue(obj) => obj.index(i), + ObjectInner::NetworkListener(obj) => obj.index(i), + ObjectInner::OAuthClient(obj) => obj.index(i), + ObjectInner::OidcProvider(obj) => obj.index(i), + ObjectInner::PublicKey(obj) => obj.index(i), + ObjectInner::QueuedMessage(obj) => obj.index(i), + ObjectInner::ReportSettings(obj) => obj.index(i), + ObjectInner::Role(obj) => obj.index(i), + ObjectInner::Search(obj) => obj.index(i), + ObjectInner::SearchStore(obj) => obj.index(i), + ObjectInner::Security(obj) => obj.index(i), + ObjectInner::SenderAuth(obj) => obj.index(i), + ObjectInner::Sharing(obj) => obj.index(i), + ObjectInner::SieveSystemInterpreter(obj) => obj.index(i), + ObjectInner::SieveSystemScript(obj) => obj.index(i), + ObjectInner::SieveUserInterpreter(obj) => obj.index(i), + ObjectInner::SieveUserScript(obj) => obj.index(i), + ObjectInner::SpamClassifier(obj) => obj.index(i), + ObjectInner::SpamDnsblServer(obj) => obj.index(i), + ObjectInner::SpamDnsblSettings(obj) => obj.index(i), + ObjectInner::SpamFileExtension(obj) => obj.index(i), + ObjectInner::SpamLlm(obj) => obj.index(i), + ObjectInner::SpamPyzor(obj) => obj.index(i), + ObjectInner::SpamRule(obj) => obj.index(i), + ObjectInner::SpamSettings(obj) => obj.index(i), + ObjectInner::SpamTag(obj) => obj.index(i), + ObjectInner::SpamTrainingSample(obj) => obj.index(i), + ObjectInner::SpfReportSettings(obj) => obj.index(i), + ObjectInner::StoreLookup(obj) => obj.index(i), + ObjectInner::SystemSettings(obj) => obj.index(i), + ObjectInner::Task(obj) => obj.index(i), + ObjectInner::TaskManager(obj) => obj.index(i), + ObjectInner::Tenant(obj) => obj.index(i), + ObjectInner::TlsExternalReport(obj) => obj.index(i), + ObjectInner::TlsInternalReport(obj) => obj.index(i), + ObjectInner::TlsReportSettings(obj) => obj.index(i), + ObjectInner::Trace(obj) => obj.index(i), + ObjectInner::Tracer(obj) => obj.index(i), + ObjectInner::TracingStore(obj) => obj.index(i), + ObjectInner::WebDav(obj) => obj.index(i), + ObjectInner::WebHook(obj) => obj.index(i), + } + } + + pub fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match &mut self.inner { + ObjectInner::Account(obj) => obj.patch(pointer, value), + ObjectInner::AccountPassword(obj) => obj.patch(pointer, value), + ObjectInner::AccountSettings(obj) => obj.patch(pointer, value), + ObjectInner::AcmeProvider(obj) => obj.patch(pointer, value), + ObjectInner::Action(obj) => obj.patch(pointer, value), + ObjectInner::AddressBook(obj) => obj.patch(pointer, value), + ObjectInner::AiModel(obj) => obj.patch(pointer, value), + ObjectInner::Alert(obj) => obj.patch(pointer, value), + ObjectInner::AllowedIp(obj) => obj.patch(pointer, value), + ObjectInner::ApiKey(obj) => obj.patch(pointer, value), + ObjectInner::AppPassword(obj) => obj.patch(pointer, value), + ObjectInner::Application(obj) => obj.patch(pointer, value), + ObjectInner::ArchivedItem(obj) => obj.patch(pointer, value), + ObjectInner::ArfExternalReport(obj) => obj.patch(pointer, value), + ObjectInner::Asn(obj) => obj.patch(pointer, value), + ObjectInner::Authentication(obj) => obj.patch(pointer, value), + ObjectInner::BlobStore(obj) => obj.patch(pointer, value), + ObjectInner::BlockedIp(obj) => obj.patch(pointer, value), + ObjectInner::Bootstrap(obj) => obj.patch(pointer, value), + ObjectInner::Cache(obj) => obj.patch(pointer, value), + ObjectInner::Calendar(obj) => obj.patch(pointer, value), + ObjectInner::CalendarAlarm(obj) => obj.patch(pointer, value), + ObjectInner::CalendarScheduling(obj) => obj.patch(pointer, value), + ObjectInner::Certificate(obj) => obj.patch(pointer, value), + ObjectInner::ClusterNode(obj) => obj.patch(pointer, value), + ObjectInner::ClusterRole(obj) => obj.patch(pointer, value), + ObjectInner::Coordinator(obj) => obj.patch(pointer, value), + ObjectInner::DataRetention(obj) => obj.patch(pointer, value), + ObjectInner::DataStore(obj) => obj.patch(pointer, value), + ObjectInner::Directory(obj) => obj.patch(pointer, value), + ObjectInner::DkimReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::DkimSignature(obj) => obj.patch(pointer, value), + ObjectInner::DmarcExternalReport(obj) => obj.patch(pointer, value), + ObjectInner::DmarcInternalReport(obj) => obj.patch(pointer, value), + ObjectInner::DmarcReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::DnsResolver(obj) => obj.patch(pointer, value), + ObjectInner::DnsServer(obj) => obj.patch(pointer, value), + ObjectInner::Domain(obj) => obj.patch(pointer, value), + ObjectInner::DsnReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::Email(obj) => obj.patch(pointer, value), + ObjectInner::Enterprise(obj) => obj.patch(pointer, value), + ObjectInner::EventTracingLevel(obj) => obj.patch(pointer, value), + ObjectInner::FileStorage(obj) => obj.patch(pointer, value), + ObjectInner::Http(obj) => obj.patch(pointer, value), + ObjectInner::HttpForm(obj) => obj.patch(pointer, value), + ObjectInner::HttpLookup(obj) => obj.patch(pointer, value), + ObjectInner::Imap(obj) => obj.patch(pointer, value), + ObjectInner::InMemoryStore(obj) => obj.patch(pointer, value), + ObjectInner::Jmap(obj) => obj.patch(pointer, value), + ObjectInner::Log(obj) => obj.patch(pointer, value), + ObjectInner::MailingList(obj) => obj.patch(pointer, value), + ObjectInner::MaskedEmail(obj) => obj.patch(pointer, value), + ObjectInner::MemoryLookupKey(obj) => obj.patch(pointer, value), + ObjectInner::MemoryLookupKeyValue(obj) => obj.patch(pointer, value), + ObjectInner::Metric(obj) => obj.patch(pointer, value), + ObjectInner::Metrics(obj) => obj.patch(pointer, value), + ObjectInner::MetricsStore(obj) => obj.patch(pointer, value), + ObjectInner::MtaConnectionStrategy(obj) => obj.patch(pointer, value), + ObjectInner::MtaDeliverySchedule(obj) => obj.patch(pointer, value), + ObjectInner::MtaExtensions(obj) => obj.patch(pointer, value), + ObjectInner::MtaHook(obj) => obj.patch(pointer, value), + ObjectInner::MtaInboundSession(obj) => obj.patch(pointer, value), + ObjectInner::MtaInboundThrottle(obj) => obj.patch(pointer, value), + ObjectInner::MtaMilter(obj) => obj.patch(pointer, value), + ObjectInner::MtaOutboundStrategy(obj) => obj.patch(pointer, value), + ObjectInner::MtaOutboundThrottle(obj) => obj.patch(pointer, value), + ObjectInner::MtaQueueQuota(obj) => obj.patch(pointer, value), + ObjectInner::MtaRoute(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageAuth(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageConnect(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageData(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageEhlo(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageMail(obj) => obj.patch(pointer, value), + ObjectInner::MtaStageRcpt(obj) => obj.patch(pointer, value), + ObjectInner::MtaSts(obj) => obj.patch(pointer, value), + ObjectInner::MtaTlsStrategy(obj) => obj.patch(pointer, value), + ObjectInner::MtaVirtualQueue(obj) => obj.patch(pointer, value), + ObjectInner::NetworkListener(obj) => obj.patch(pointer, value), + ObjectInner::OAuthClient(obj) => obj.patch(pointer, value), + ObjectInner::OidcProvider(obj) => obj.patch(pointer, value), + ObjectInner::PublicKey(obj) => obj.patch(pointer, value), + ObjectInner::QueuedMessage(obj) => obj.patch(pointer, value), + ObjectInner::ReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::Role(obj) => obj.patch(pointer, value), + ObjectInner::Search(obj) => obj.patch(pointer, value), + ObjectInner::SearchStore(obj) => obj.patch(pointer, value), + ObjectInner::Security(obj) => obj.patch(pointer, value), + ObjectInner::SenderAuth(obj) => obj.patch(pointer, value), + ObjectInner::Sharing(obj) => obj.patch(pointer, value), + ObjectInner::SieveSystemInterpreter(obj) => obj.patch(pointer, value), + ObjectInner::SieveSystemScript(obj) => obj.patch(pointer, value), + ObjectInner::SieveUserInterpreter(obj) => obj.patch(pointer, value), + ObjectInner::SieveUserScript(obj) => obj.patch(pointer, value), + ObjectInner::SpamClassifier(obj) => obj.patch(pointer, value), + ObjectInner::SpamDnsblServer(obj) => obj.patch(pointer, value), + ObjectInner::SpamDnsblSettings(obj) => obj.patch(pointer, value), + ObjectInner::SpamFileExtension(obj) => obj.patch(pointer, value), + ObjectInner::SpamLlm(obj) => obj.patch(pointer, value), + ObjectInner::SpamPyzor(obj) => obj.patch(pointer, value), + ObjectInner::SpamRule(obj) => obj.patch(pointer, value), + ObjectInner::SpamSettings(obj) => obj.patch(pointer, value), + ObjectInner::SpamTag(obj) => obj.patch(pointer, value), + ObjectInner::SpamTrainingSample(obj) => obj.patch(pointer, value), + ObjectInner::SpfReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::StoreLookup(obj) => obj.patch(pointer, value), + ObjectInner::SystemSettings(obj) => obj.patch(pointer, value), + ObjectInner::Task(obj) => obj.patch(pointer, value), + ObjectInner::TaskManager(obj) => obj.patch(pointer, value), + ObjectInner::Tenant(obj) => obj.patch(pointer, value), + ObjectInner::TlsExternalReport(obj) => obj.patch(pointer, value), + ObjectInner::TlsInternalReport(obj) => obj.patch(pointer, value), + ObjectInner::TlsReportSettings(obj) => obj.patch(pointer, value), + ObjectInner::Trace(obj) => obj.patch(pointer, value), + ObjectInner::Tracer(obj) => obj.patch(pointer, value), + ObjectInner::TracingStore(obj) => obj.patch(pointer, value), + ObjectInner::WebDav(obj) => obj.patch(pointer, value), + ObjectInner::WebHook(obj) => obj.patch(pointer, value), + } + } +} + +impl IntoValue for Object { + fn into_value(self) -> JmapValue<'static> { + match self.inner { + ObjectInner::Account(obj) => obj.into_value(), + ObjectInner::AccountPassword(obj) => obj.into_value(), + ObjectInner::AccountSettings(obj) => obj.into_value(), + ObjectInner::AcmeProvider(obj) => obj.into_value(), + ObjectInner::Action(obj) => obj.into_value(), + ObjectInner::AddressBook(obj) => obj.into_value(), + ObjectInner::AiModel(obj) => obj.into_value(), + ObjectInner::Alert(obj) => obj.into_value(), + ObjectInner::AllowedIp(obj) => obj.into_value(), + ObjectInner::ApiKey(obj) => obj.into_value(), + ObjectInner::AppPassword(obj) => obj.into_value(), + ObjectInner::Application(obj) => obj.into_value(), + ObjectInner::ArchivedItem(obj) => obj.into_value(), + ObjectInner::ArfExternalReport(obj) => obj.into_value(), + ObjectInner::Asn(obj) => obj.into_value(), + ObjectInner::Authentication(obj) => obj.into_value(), + ObjectInner::BlobStore(obj) => obj.into_value(), + ObjectInner::BlockedIp(obj) => obj.into_value(), + ObjectInner::Bootstrap(obj) => obj.into_value(), + ObjectInner::Cache(obj) => obj.into_value(), + ObjectInner::Calendar(obj) => obj.into_value(), + ObjectInner::CalendarAlarm(obj) => obj.into_value(), + ObjectInner::CalendarScheduling(obj) => obj.into_value(), + ObjectInner::Certificate(obj) => obj.into_value(), + ObjectInner::ClusterNode(obj) => obj.into_value(), + ObjectInner::ClusterRole(obj) => obj.into_value(), + ObjectInner::Coordinator(obj) => obj.into_value(), + ObjectInner::DataRetention(obj) => obj.into_value(), + ObjectInner::DataStore(obj) => obj.into_value(), + ObjectInner::Directory(obj) => obj.into_value(), + ObjectInner::DkimReportSettings(obj) => obj.into_value(), + ObjectInner::DkimSignature(obj) => obj.into_value(), + ObjectInner::DmarcExternalReport(obj) => obj.into_value(), + ObjectInner::DmarcInternalReport(obj) => obj.into_value(), + ObjectInner::DmarcReportSettings(obj) => obj.into_value(), + ObjectInner::DnsResolver(obj) => obj.into_value(), + ObjectInner::DnsServer(obj) => obj.into_value(), + ObjectInner::Domain(obj) => obj.into_value(), + ObjectInner::DsnReportSettings(obj) => obj.into_value(), + ObjectInner::Email(obj) => obj.into_value(), + ObjectInner::Enterprise(obj) => obj.into_value(), + ObjectInner::EventTracingLevel(obj) => obj.into_value(), + ObjectInner::FileStorage(obj) => obj.into_value(), + ObjectInner::Http(obj) => obj.into_value(), + ObjectInner::HttpForm(obj) => obj.into_value(), + ObjectInner::HttpLookup(obj) => obj.into_value(), + ObjectInner::Imap(obj) => obj.into_value(), + ObjectInner::InMemoryStore(obj) => obj.into_value(), + ObjectInner::Jmap(obj) => obj.into_value(), + ObjectInner::Log(obj) => obj.into_value(), + ObjectInner::MailingList(obj) => obj.into_value(), + ObjectInner::MaskedEmail(obj) => obj.into_value(), + ObjectInner::MemoryLookupKey(obj) => obj.into_value(), + ObjectInner::MemoryLookupKeyValue(obj) => obj.into_value(), + ObjectInner::Metric(obj) => obj.into_value(), + ObjectInner::Metrics(obj) => obj.into_value(), + ObjectInner::MetricsStore(obj) => obj.into_value(), + ObjectInner::MtaConnectionStrategy(obj) => obj.into_value(), + ObjectInner::MtaDeliverySchedule(obj) => obj.into_value(), + ObjectInner::MtaExtensions(obj) => obj.into_value(), + ObjectInner::MtaHook(obj) => obj.into_value(), + ObjectInner::MtaInboundSession(obj) => obj.into_value(), + ObjectInner::MtaInboundThrottle(obj) => obj.into_value(), + ObjectInner::MtaMilter(obj) => obj.into_value(), + ObjectInner::MtaOutboundStrategy(obj) => obj.into_value(), + ObjectInner::MtaOutboundThrottle(obj) => obj.into_value(), + ObjectInner::MtaQueueQuota(obj) => obj.into_value(), + ObjectInner::MtaRoute(obj) => obj.into_value(), + ObjectInner::MtaStageAuth(obj) => obj.into_value(), + ObjectInner::MtaStageConnect(obj) => obj.into_value(), + ObjectInner::MtaStageData(obj) => obj.into_value(), + ObjectInner::MtaStageEhlo(obj) => obj.into_value(), + ObjectInner::MtaStageMail(obj) => obj.into_value(), + ObjectInner::MtaStageRcpt(obj) => obj.into_value(), + ObjectInner::MtaSts(obj) => obj.into_value(), + ObjectInner::MtaTlsStrategy(obj) => obj.into_value(), + ObjectInner::MtaVirtualQueue(obj) => obj.into_value(), + ObjectInner::NetworkListener(obj) => obj.into_value(), + ObjectInner::OAuthClient(obj) => obj.into_value(), + ObjectInner::OidcProvider(obj) => obj.into_value(), + ObjectInner::PublicKey(obj) => obj.into_value(), + ObjectInner::QueuedMessage(obj) => obj.into_value(), + ObjectInner::ReportSettings(obj) => obj.into_value(), + ObjectInner::Role(obj) => obj.into_value(), + ObjectInner::Search(obj) => obj.into_value(), + ObjectInner::SearchStore(obj) => obj.into_value(), + ObjectInner::Security(obj) => obj.into_value(), + ObjectInner::SenderAuth(obj) => obj.into_value(), + ObjectInner::Sharing(obj) => obj.into_value(), + ObjectInner::SieveSystemInterpreter(obj) => obj.into_value(), + ObjectInner::SieveSystemScript(obj) => obj.into_value(), + ObjectInner::SieveUserInterpreter(obj) => obj.into_value(), + ObjectInner::SieveUserScript(obj) => obj.into_value(), + ObjectInner::SpamClassifier(obj) => obj.into_value(), + ObjectInner::SpamDnsblServer(obj) => obj.into_value(), + ObjectInner::SpamDnsblSettings(obj) => obj.into_value(), + ObjectInner::SpamFileExtension(obj) => obj.into_value(), + ObjectInner::SpamLlm(obj) => obj.into_value(), + ObjectInner::SpamPyzor(obj) => obj.into_value(), + ObjectInner::SpamRule(obj) => obj.into_value(), + ObjectInner::SpamSettings(obj) => obj.into_value(), + ObjectInner::SpamTag(obj) => obj.into_value(), + ObjectInner::SpamTrainingSample(obj) => obj.into_value(), + ObjectInner::SpfReportSettings(obj) => obj.into_value(), + ObjectInner::StoreLookup(obj) => obj.into_value(), + ObjectInner::SystemSettings(obj) => obj.into_value(), + ObjectInner::Task(obj) => obj.into_value(), + ObjectInner::TaskManager(obj) => obj.into_value(), + ObjectInner::Tenant(obj) => obj.into_value(), + ObjectInner::TlsExternalReport(obj) => obj.into_value(), + ObjectInner::TlsInternalReport(obj) => obj.into_value(), + ObjectInner::TlsReportSettings(obj) => obj.into_value(), + ObjectInner::Trace(obj) => obj.into_value(), + ObjectInner::Tracer(obj) => obj.into_value(), + ObjectInner::TracingStore(obj) => obj.into_value(), + ObjectInner::WebDav(obj) => obj.into_value(), + ObjectInner::WebHook(obj) => obj.into_value(), + } + } +} + +impl From for ObjectInner { + fn from(obj: ObjectType) -> Self { + match obj { + ObjectType::Account => ObjectInner::Account(Default::default()), + ObjectType::AccountPassword => ObjectInner::AccountPassword(Default::default()), + ObjectType::AccountSettings => ObjectInner::AccountSettings(Default::default()), + ObjectType::AcmeProvider => ObjectInner::AcmeProvider(Default::default()), + ObjectType::Action => ObjectInner::Action(Default::default()), + ObjectType::AddressBook => ObjectInner::AddressBook(Default::default()), + ObjectType::AiModel => ObjectInner::AiModel(Default::default()), + ObjectType::Alert => ObjectInner::Alert(Default::default()), + ObjectType::AllowedIp => ObjectInner::AllowedIp(Default::default()), + ObjectType::ApiKey => ObjectInner::ApiKey(Default::default()), + ObjectType::AppPassword => ObjectInner::AppPassword(Default::default()), + ObjectType::Application => ObjectInner::Application(Default::default()), + ObjectType::ArchivedItem => ObjectInner::ArchivedItem(Default::default()), + ObjectType::ArfExternalReport => ObjectInner::ArfExternalReport(Default::default()), + ObjectType::Asn => ObjectInner::Asn(Default::default()), + ObjectType::Authentication => ObjectInner::Authentication(Default::default()), + ObjectType::BlobStore => ObjectInner::BlobStore(Default::default()), + ObjectType::BlockedIp => ObjectInner::BlockedIp(Default::default()), + ObjectType::Bootstrap => ObjectInner::Bootstrap(Default::default()), + ObjectType::Cache => ObjectInner::Cache(Default::default()), + ObjectType::Calendar => ObjectInner::Calendar(Default::default()), + ObjectType::CalendarAlarm => ObjectInner::CalendarAlarm(Default::default()), + ObjectType::CalendarScheduling => ObjectInner::CalendarScheduling(Default::default()), + ObjectType::Certificate => ObjectInner::Certificate(Default::default()), + ObjectType::ClusterNode => ObjectInner::ClusterNode(Default::default()), + ObjectType::ClusterRole => ObjectInner::ClusterRole(Default::default()), + ObjectType::Coordinator => ObjectInner::Coordinator(Default::default()), + ObjectType::DataRetention => ObjectInner::DataRetention(Default::default()), + ObjectType::DataStore => ObjectInner::DataStore(Default::default()), + ObjectType::Directory => ObjectInner::Directory(Default::default()), + ObjectType::DkimReportSettings => ObjectInner::DkimReportSettings(Default::default()), + ObjectType::DkimSignature => ObjectInner::DkimSignature(Default::default()), + ObjectType::DmarcExternalReport => ObjectInner::DmarcExternalReport(Default::default()), + ObjectType::DmarcInternalReport => ObjectInner::DmarcInternalReport(Default::default()), + ObjectType::DmarcReportSettings => ObjectInner::DmarcReportSettings(Default::default()), + ObjectType::DnsResolver => ObjectInner::DnsResolver(Default::default()), + ObjectType::DnsServer => ObjectInner::DnsServer(Default::default()), + ObjectType::Domain => ObjectInner::Domain(Default::default()), + ObjectType::DsnReportSettings => ObjectInner::DsnReportSettings(Default::default()), + ObjectType::Email => ObjectInner::Email(Default::default()), + ObjectType::Enterprise => ObjectInner::Enterprise(Default::default()), + ObjectType::EventTracingLevel => ObjectInner::EventTracingLevel(Default::default()), + ObjectType::FileStorage => ObjectInner::FileStorage(Default::default()), + ObjectType::Http => ObjectInner::Http(Default::default()), + ObjectType::HttpForm => ObjectInner::HttpForm(Default::default()), + ObjectType::HttpLookup => ObjectInner::HttpLookup(Default::default()), + ObjectType::Imap => ObjectInner::Imap(Default::default()), + ObjectType::InMemoryStore => ObjectInner::InMemoryStore(Default::default()), + ObjectType::Jmap => ObjectInner::Jmap(Default::default()), + ObjectType::Log => ObjectInner::Log(Default::default()), + ObjectType::MailingList => ObjectInner::MailingList(Default::default()), + ObjectType::MaskedEmail => ObjectInner::MaskedEmail(Default::default()), + ObjectType::MemoryLookupKey => ObjectInner::MemoryLookupKey(Default::default()), + ObjectType::MemoryLookupKeyValue => { + ObjectInner::MemoryLookupKeyValue(Default::default()) + } + ObjectType::Metric => ObjectInner::Metric(Default::default()), + ObjectType::Metrics => ObjectInner::Metrics(Default::default()), + ObjectType::MetricsStore => ObjectInner::MetricsStore(Default::default()), + ObjectType::MtaConnectionStrategy => { + ObjectInner::MtaConnectionStrategy(Default::default()) + } + ObjectType::MtaDeliverySchedule => ObjectInner::MtaDeliverySchedule(Default::default()), + ObjectType::MtaExtensions => ObjectInner::MtaExtensions(Default::default()), + ObjectType::MtaHook => ObjectInner::MtaHook(Default::default()), + ObjectType::MtaInboundSession => ObjectInner::MtaInboundSession(Default::default()), + ObjectType::MtaInboundThrottle => ObjectInner::MtaInboundThrottle(Default::default()), + ObjectType::MtaMilter => ObjectInner::MtaMilter(Default::default()), + ObjectType::MtaOutboundStrategy => ObjectInner::MtaOutboundStrategy(Default::default()), + ObjectType::MtaOutboundThrottle => ObjectInner::MtaOutboundThrottle(Default::default()), + ObjectType::MtaQueueQuota => ObjectInner::MtaQueueQuota(Default::default()), + ObjectType::MtaRoute => ObjectInner::MtaRoute(Default::default()), + ObjectType::MtaStageAuth => ObjectInner::MtaStageAuth(Default::default()), + ObjectType::MtaStageConnect => ObjectInner::MtaStageConnect(Default::default()), + ObjectType::MtaStageData => ObjectInner::MtaStageData(Default::default()), + ObjectType::MtaStageEhlo => ObjectInner::MtaStageEhlo(Default::default()), + ObjectType::MtaStageMail => ObjectInner::MtaStageMail(Default::default()), + ObjectType::MtaStageRcpt => ObjectInner::MtaStageRcpt(Default::default()), + ObjectType::MtaSts => ObjectInner::MtaSts(Default::default()), + ObjectType::MtaTlsStrategy => ObjectInner::MtaTlsStrategy(Default::default()), + ObjectType::MtaVirtualQueue => ObjectInner::MtaVirtualQueue(Default::default()), + ObjectType::NetworkListener => ObjectInner::NetworkListener(Default::default()), + ObjectType::OAuthClient => ObjectInner::OAuthClient(Default::default()), + ObjectType::OidcProvider => ObjectInner::OidcProvider(Default::default()), + ObjectType::PublicKey => ObjectInner::PublicKey(Default::default()), + ObjectType::QueuedMessage => ObjectInner::QueuedMessage(Default::default()), + ObjectType::ReportSettings => ObjectInner::ReportSettings(Default::default()), + ObjectType::Role => ObjectInner::Role(Default::default()), + ObjectType::Search => ObjectInner::Search(Default::default()), + ObjectType::SearchStore => ObjectInner::SearchStore(Default::default()), + ObjectType::Security => ObjectInner::Security(Default::default()), + ObjectType::SenderAuth => ObjectInner::SenderAuth(Default::default()), + ObjectType::Sharing => ObjectInner::Sharing(Default::default()), + ObjectType::SieveSystemInterpreter => { + ObjectInner::SieveSystemInterpreter(Default::default()) + } + ObjectType::SieveSystemScript => ObjectInner::SieveSystemScript(Default::default()), + ObjectType::SieveUserInterpreter => { + ObjectInner::SieveUserInterpreter(Default::default()) + } + ObjectType::SieveUserScript => ObjectInner::SieveUserScript(Default::default()), + ObjectType::SpamClassifier => ObjectInner::SpamClassifier(Default::default()), + ObjectType::SpamDnsblServer => ObjectInner::SpamDnsblServer(Default::default()), + ObjectType::SpamDnsblSettings => ObjectInner::SpamDnsblSettings(Default::default()), + ObjectType::SpamFileExtension => ObjectInner::SpamFileExtension(Default::default()), + ObjectType::SpamLlm => ObjectInner::SpamLlm(Default::default()), + ObjectType::SpamPyzor => ObjectInner::SpamPyzor(Default::default()), + ObjectType::SpamRule => ObjectInner::SpamRule(Default::default()), + ObjectType::SpamSettings => ObjectInner::SpamSettings(Default::default()), + ObjectType::SpamTag => ObjectInner::SpamTag(Default::default()), + ObjectType::SpamTrainingSample => ObjectInner::SpamTrainingSample(Default::default()), + ObjectType::SpfReportSettings => ObjectInner::SpfReportSettings(Default::default()), + ObjectType::StoreLookup => ObjectInner::StoreLookup(Default::default()), + ObjectType::SystemSettings => ObjectInner::SystemSettings(Default::default()), + ObjectType::Task => ObjectInner::Task(Default::default()), + ObjectType::TaskManager => ObjectInner::TaskManager(Default::default()), + ObjectType::Tenant => ObjectInner::Tenant(Default::default()), + ObjectType::TlsExternalReport => ObjectInner::TlsExternalReport(Default::default()), + ObjectType::TlsInternalReport => ObjectInner::TlsInternalReport(Default::default()), + ObjectType::TlsReportSettings => ObjectInner::TlsReportSettings(Default::default()), + ObjectType::Trace => ObjectInner::Trace(Default::default()), + ObjectType::Tracer => ObjectInner::Tracer(Default::default()), + ObjectType::TracingStore => ObjectInner::TracingStore(Default::default()), + ObjectType::WebDav => ObjectInner::WebDav(Default::default()), + ObjectType::WebHook => ObjectInner::WebHook(Default::default()), + } + } +} + +impl From for ObjectInner { + fn from(value: Account) -> Self { + ObjectInner::Account(value) + } +} + +impl From for Account { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Account(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AccountPassword) -> Self { + ObjectInner::AccountPassword(value) + } +} + +impl From for AccountPassword { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AccountPassword(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AccountSettings) -> Self { + ObjectInner::AccountSettings(value) + } +} + +impl From for AccountSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AccountSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AcmeProvider) -> Self { + ObjectInner::AcmeProvider(value) + } +} + +impl From for AcmeProvider { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AcmeProvider(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Action) -> Self { + ObjectInner::Action(value) + } +} + +impl From for Action { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Action(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AddressBook) -> Self { + ObjectInner::AddressBook(value) + } +} + +impl From for AddressBook { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AddressBook(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AiModel) -> Self { + ObjectInner::AiModel(value) + } +} + +impl From for AiModel { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AiModel(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Alert) -> Self { + ObjectInner::Alert(value) + } +} + +impl From for Alert { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Alert(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AllowedIp) -> Self { + ObjectInner::AllowedIp(value) + } +} + +impl From for AllowedIp { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AllowedIp(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ApiKey) -> Self { + ObjectInner::ApiKey(value) + } +} + +impl From for ApiKey { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ApiKey(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: AppPassword) -> Self { + ObjectInner::AppPassword(value) + } +} + +impl From for AppPassword { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::AppPassword(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Application) -> Self { + ObjectInner::Application(value) + } +} + +impl From for Application { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Application(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ArchivedItem) -> Self { + ObjectInner::ArchivedItem(value) + } +} + +impl From for ArchivedItem { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ArchivedItem(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ArfExternalReport) -> Self { + ObjectInner::ArfExternalReport(value) + } +} + +impl From for ArfExternalReport { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ArfExternalReport(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Asn) -> Self { + ObjectInner::Asn(value) + } +} + +impl From for Asn { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Asn(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Authentication) -> Self { + ObjectInner::Authentication(value) + } +} + +impl From for Authentication { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Authentication(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: BlobStore) -> Self { + ObjectInner::BlobStore(value) + } +} + +impl From for BlobStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::BlobStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: BlockedIp) -> Self { + ObjectInner::BlockedIp(value) + } +} + +impl From for BlockedIp { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::BlockedIp(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Bootstrap) -> Self { + ObjectInner::Bootstrap(value) + } +} + +impl From for Bootstrap { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Bootstrap(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Cache) -> Self { + ObjectInner::Cache(value) + } +} + +impl From for Cache { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Cache(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Calendar) -> Self { + ObjectInner::Calendar(value) + } +} + +impl From for Calendar { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Calendar(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: CalendarAlarm) -> Self { + ObjectInner::CalendarAlarm(value) + } +} + +impl From for CalendarAlarm { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::CalendarAlarm(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: CalendarScheduling) -> Self { + ObjectInner::CalendarScheduling(value) + } +} + +impl From for CalendarScheduling { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::CalendarScheduling(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Certificate) -> Self { + ObjectInner::Certificate(value) + } +} + +impl From for Certificate { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Certificate(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ClusterNode) -> Self { + ObjectInner::ClusterNode(value) + } +} + +impl From for ClusterNode { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ClusterNode(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ClusterRole) -> Self { + ObjectInner::ClusterRole(value) + } +} + +impl From for ClusterRole { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ClusterRole(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Coordinator) -> Self { + ObjectInner::Coordinator(value) + } +} + +impl From for Coordinator { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Coordinator(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DataRetention) -> Self { + ObjectInner::DataRetention(value) + } +} + +impl From for DataRetention { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DataRetention(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DataStore) -> Self { + ObjectInner::DataStore(value) + } +} + +impl From for DataStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DataStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Directory) -> Self { + ObjectInner::Directory(value) + } +} + +impl From for Directory { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Directory(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DkimReportSettings) -> Self { + ObjectInner::DkimReportSettings(value) + } +} + +impl From for DkimReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DkimReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DkimSignature) -> Self { + ObjectInner::DkimSignature(value) + } +} + +impl From for DkimSignature { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DkimSignature(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DmarcExternalReport) -> Self { + ObjectInner::DmarcExternalReport(value) + } +} + +impl From for DmarcExternalReport { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DmarcExternalReport(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DmarcInternalReport) -> Self { + ObjectInner::DmarcInternalReport(value) + } +} + +impl From for DmarcInternalReport { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DmarcInternalReport(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DmarcReportSettings) -> Self { + ObjectInner::DmarcReportSettings(value) + } +} + +impl From for DmarcReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DmarcReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DnsResolver) -> Self { + ObjectInner::DnsResolver(value) + } +} + +impl From for DnsResolver { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DnsResolver(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DnsServer) -> Self { + ObjectInner::DnsServer(value) + } +} + +impl From for DnsServer { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DnsServer(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Domain) -> Self { + ObjectInner::Domain(value) + } +} + +impl From for Domain { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Domain(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: DsnReportSettings) -> Self { + ObjectInner::DsnReportSettings(value) + } +} + +impl From for DsnReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::DsnReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Email) -> Self { + ObjectInner::Email(value) + } +} + +impl From for Email { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Email(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Enterprise) -> Self { + ObjectInner::Enterprise(value) + } +} + +impl From for Enterprise { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Enterprise(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: EventTracingLevel) -> Self { + ObjectInner::EventTracingLevel(value) + } +} + +impl From for EventTracingLevel { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::EventTracingLevel(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: FileStorage) -> Self { + ObjectInner::FileStorage(value) + } +} + +impl From for FileStorage { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::FileStorage(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Http) -> Self { + ObjectInner::Http(value) + } +} + +impl From for Http { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Http(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: HttpForm) -> Self { + ObjectInner::HttpForm(value) + } +} + +impl From for HttpForm { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::HttpForm(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: HttpLookup) -> Self { + ObjectInner::HttpLookup(value) + } +} + +impl From for HttpLookup { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::HttpLookup(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Imap) -> Self { + ObjectInner::Imap(value) + } +} + +impl From for Imap { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Imap(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: InMemoryStore) -> Self { + ObjectInner::InMemoryStore(value) + } +} + +impl From for InMemoryStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::InMemoryStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Jmap) -> Self { + ObjectInner::Jmap(value) + } +} + +impl From for Jmap { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Jmap(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Log) -> Self { + ObjectInner::Log(value) + } +} + +impl From for Log { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Log(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MailingList) -> Self { + ObjectInner::MailingList(value) + } +} + +impl From for MailingList { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MailingList(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MaskedEmail) -> Self { + ObjectInner::MaskedEmail(value) + } +} + +impl From for MaskedEmail { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MaskedEmail(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MemoryLookupKey) -> Self { + ObjectInner::MemoryLookupKey(value) + } +} + +impl From for MemoryLookupKey { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MemoryLookupKey(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MemoryLookupKeyValue) -> Self { + ObjectInner::MemoryLookupKeyValue(value) + } +} + +impl From for MemoryLookupKeyValue { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MemoryLookupKeyValue(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Metric) -> Self { + ObjectInner::Metric(value) + } +} + +impl From for Metric { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Metric(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Metrics) -> Self { + ObjectInner::Metrics(value) + } +} + +impl From for Metrics { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Metrics(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MetricsStore) -> Self { + ObjectInner::MetricsStore(value) + } +} + +impl From for MetricsStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MetricsStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaConnectionStrategy) -> Self { + ObjectInner::MtaConnectionStrategy(value) + } +} + +impl From for MtaConnectionStrategy { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaConnectionStrategy(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaDeliverySchedule) -> Self { + ObjectInner::MtaDeliverySchedule(value) + } +} + +impl From for MtaDeliverySchedule { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaDeliverySchedule(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaExtensions) -> Self { + ObjectInner::MtaExtensions(value) + } +} + +impl From for MtaExtensions { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaExtensions(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaHook) -> Self { + ObjectInner::MtaHook(value) + } +} + +impl From for MtaHook { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaHook(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaInboundSession) -> Self { + ObjectInner::MtaInboundSession(value) + } +} + +impl From for MtaInboundSession { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaInboundSession(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaInboundThrottle) -> Self { + ObjectInner::MtaInboundThrottle(value) + } +} + +impl From for MtaInboundThrottle { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaInboundThrottle(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaMilter) -> Self { + ObjectInner::MtaMilter(value) + } +} + +impl From for MtaMilter { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaMilter(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaOutboundStrategy) -> Self { + ObjectInner::MtaOutboundStrategy(value) + } +} + +impl From for MtaOutboundStrategy { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaOutboundStrategy(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaOutboundThrottle) -> Self { + ObjectInner::MtaOutboundThrottle(value) + } +} + +impl From for MtaOutboundThrottle { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaOutboundThrottle(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaQueueQuota) -> Self { + ObjectInner::MtaQueueQuota(value) + } +} + +impl From for MtaQueueQuota { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaQueueQuota(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaRoute) -> Self { + ObjectInner::MtaRoute(value) + } +} + +impl From for MtaRoute { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaRoute(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageAuth) -> Self { + ObjectInner::MtaStageAuth(value) + } +} + +impl From for MtaStageAuth { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageAuth(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageConnect) -> Self { + ObjectInner::MtaStageConnect(value) + } +} + +impl From for MtaStageConnect { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageConnect(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageData) -> Self { + ObjectInner::MtaStageData(value) + } +} + +impl From for MtaStageData { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageData(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageEhlo) -> Self { + ObjectInner::MtaStageEhlo(value) + } +} + +impl From for MtaStageEhlo { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageEhlo(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageMail) -> Self { + ObjectInner::MtaStageMail(value) + } +} + +impl From for MtaStageMail { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageMail(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaStageRcpt) -> Self { + ObjectInner::MtaStageRcpt(value) + } +} + +impl From for MtaStageRcpt { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaStageRcpt(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaSts) -> Self { + ObjectInner::MtaSts(value) + } +} + +impl From for MtaSts { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaSts(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaTlsStrategy) -> Self { + ObjectInner::MtaTlsStrategy(value) + } +} + +impl From for MtaTlsStrategy { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaTlsStrategy(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: MtaVirtualQueue) -> Self { + ObjectInner::MtaVirtualQueue(value) + } +} + +impl From for MtaVirtualQueue { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::MtaVirtualQueue(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: NetworkListener) -> Self { + ObjectInner::NetworkListener(value) + } +} + +impl From for NetworkListener { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::NetworkListener(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: OAuthClient) -> Self { + ObjectInner::OAuthClient(value) + } +} + +impl From for OAuthClient { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::OAuthClient(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: OidcProvider) -> Self { + ObjectInner::OidcProvider(value) + } +} + +impl From for OidcProvider { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::OidcProvider(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: PublicKey) -> Self { + ObjectInner::PublicKey(value) + } +} + +impl From for PublicKey { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::PublicKey(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: QueuedMessage) -> Self { + ObjectInner::QueuedMessage(value) + } +} + +impl From for QueuedMessage { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::QueuedMessage(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: ReportSettings) -> Self { + ObjectInner::ReportSettings(value) + } +} + +impl From for ReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::ReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Role) -> Self { + ObjectInner::Role(value) + } +} + +impl From for Role { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Role(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Search) -> Self { + ObjectInner::Search(value) + } +} + +impl From for Search { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Search(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SearchStore) -> Self { + ObjectInner::SearchStore(value) + } +} + +impl From for SearchStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SearchStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Security) -> Self { + ObjectInner::Security(value) + } +} + +impl From for Security { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Security(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SenderAuth) -> Self { + ObjectInner::SenderAuth(value) + } +} + +impl From for SenderAuth { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SenderAuth(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Sharing) -> Self { + ObjectInner::Sharing(value) + } +} + +impl From for Sharing { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Sharing(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SieveSystemInterpreter) -> Self { + ObjectInner::SieveSystemInterpreter(value) + } +} + +impl From for SieveSystemInterpreter { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SieveSystemInterpreter(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SieveSystemScript) -> Self { + ObjectInner::SieveSystemScript(value) + } +} + +impl From for SieveSystemScript { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SieveSystemScript(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SieveUserInterpreter) -> Self { + ObjectInner::SieveUserInterpreter(value) + } +} + +impl From for SieveUserInterpreter { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SieveUserInterpreter(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SieveUserScript) -> Self { + ObjectInner::SieveUserScript(value) + } +} + +impl From for SieveUserScript { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SieveUserScript(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamClassifier) -> Self { + ObjectInner::SpamClassifier(value) + } +} + +impl From for SpamClassifier { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamClassifier(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamDnsblServer) -> Self { + ObjectInner::SpamDnsblServer(value) + } +} + +impl From for SpamDnsblServer { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamDnsblServer(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamDnsblSettings) -> Self { + ObjectInner::SpamDnsblSettings(value) + } +} + +impl From for SpamDnsblSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamDnsblSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamFileExtension) -> Self { + ObjectInner::SpamFileExtension(value) + } +} + +impl From for SpamFileExtension { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamFileExtension(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamLlm) -> Self { + ObjectInner::SpamLlm(value) + } +} + +impl From for SpamLlm { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamLlm(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamPyzor) -> Self { + ObjectInner::SpamPyzor(value) + } +} + +impl From for SpamPyzor { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamPyzor(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamRule) -> Self { + ObjectInner::SpamRule(value) + } +} + +impl From for SpamRule { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamRule(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamSettings) -> Self { + ObjectInner::SpamSettings(value) + } +} + +impl From for SpamSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamTag) -> Self { + ObjectInner::SpamTag(value) + } +} + +impl From for SpamTag { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamTag(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpamTrainingSample) -> Self { + ObjectInner::SpamTrainingSample(value) + } +} + +impl From for SpamTrainingSample { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpamTrainingSample(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SpfReportSettings) -> Self { + ObjectInner::SpfReportSettings(value) + } +} + +impl From for SpfReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SpfReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: StoreLookup) -> Self { + ObjectInner::StoreLookup(value) + } +} + +impl From for StoreLookup { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::StoreLookup(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: SystemSettings) -> Self { + ObjectInner::SystemSettings(value) + } +} + +impl From for SystemSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::SystemSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Task) -> Self { + ObjectInner::Task(value) + } +} + +impl From for Task { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Task(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: TaskManager) -> Self { + ObjectInner::TaskManager(value) + } +} + +impl From for TaskManager { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::TaskManager(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Tenant) -> Self { + ObjectInner::Tenant(value) + } +} + +impl From for Tenant { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Tenant(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: TlsExternalReport) -> Self { + ObjectInner::TlsExternalReport(value) + } +} + +impl From for TlsExternalReport { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::TlsExternalReport(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: TlsInternalReport) -> Self { + ObjectInner::TlsInternalReport(value) + } +} + +impl From for TlsInternalReport { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::TlsInternalReport(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: TlsReportSettings) -> Self { + ObjectInner::TlsReportSettings(value) + } +} + +impl From for TlsReportSettings { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::TlsReportSettings(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Trace) -> Self { + ObjectInner::Trace(value) + } +} + +impl From for Trace { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Trace(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: Tracer) -> Self { + ObjectInner::Tracer(value) + } +} + +impl From for Tracer { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::Tracer(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: TracingStore) -> Self { + ObjectInner::TracingStore(value) + } +} + +impl From for TracingStore { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::TracingStore(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: WebDav) -> Self { + ObjectInner::WebDav(value) + } +} + +impl From for WebDav { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::WebDav(obj) => obj, + _ => unreachable!(), + } + } +} + +impl From for ObjectInner { + fn from(value: WebHook) -> Self { + ObjectInner::WebHook(value) + } +} + +impl From for WebHook { + fn from(obj: Object) -> Self { + match obj.inner { + ObjectInner::WebHook(obj) => obj, + _ => unreachable!(), + } + } +} diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs new file mode 100644 index 0000000..15aaf74 --- /dev/null +++ b/crates/registry/src/schema/structs.rs @@ -0,0 +1,6295 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::schema::prelude::*; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Account { + User(UserAccount), + Group(GroupAccount), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AccountPassword { + #[serde(rename = "secret")] + pub secret: Option, + #[serde(rename = "currentSecret")] + pub current_secret: Option, + #[serde(rename = "otpAuth")] + pub otp_auth: OtpAuth, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AccountSettings { + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "locale")] + pub locale: Locale, + #[serde(rename = "timeZone")] + pub time_zone: Option, + #[serde(rename = "encryptionAtRest")] + pub encryption_at_rest: EncryptionAtRest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AcmeProvider { + #[serde(rename = "challengeType")] + pub challenge_type: AcmeChallengeType, + #[serde(rename = "contact")] + pub contact: Map, + #[serde(rename = "directory")] + pub directory: String, + #[serde(rename = "accountKey")] + pub account_key: String, + #[serde(rename = "accountUri")] + pub account_uri: String, + #[serde(rename = "renewBefore")] + pub renew_before: AcmeRenewBefore, + #[serde(rename = "maxRetries")] + pub max_retries: i64, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "preferredChain")] + pub preferred_chain: Option, + #[serde(rename = "reuseKey")] + pub reuse_key: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Action { + ReloadSettings, + ReloadTlsCertificates, + ReloadLookupStores, + ReloadBlockedIps, + UpdateApps, + TroubleshootDmarc(DmarcTroubleshoot), + ClassifySpam(SpamClassify), + InvalidateCaches, + InvalidateNegativeCaches, + PauseMtaQueue, + ResumeMtaQueue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AddressBook { + #[serde(rename = "defaultDisplayName")] + pub default_display_name: Option, + #[serde(rename = "defaultHrefName")] + pub default_href_name: Option, + #[serde(rename = "maxVCardSize")] + pub max_v_card_size: u64, + #[serde(rename = "maxAddressBooks")] + pub max_address_books: Option, + #[serde(rename = "maxContacts")] + pub max_contacts: Option, + #[serde(rename = "vCardVersion")] + pub v_card_version: VCardVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AiModel { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "temperature")] + pub temperature: Float, + #[serde(rename = "model")] + pub model: String, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "modelType")] + pub model_type: AiModelType, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Alert { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "emailAlert")] + pub email_alert: AlertEmail, + #[serde(rename = "eventAlert")] + pub event_alert: AlertEvent, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum AlertEmail { + Disabled, + Enabled(AlertEmailProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AlertEmailProperties { + #[serde(rename = "body")] + pub body: String, + #[serde(rename = "fromAddress")] + pub from_address: String, + #[serde(rename = "fromName")] + pub from_name: Option, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "to")] + pub to: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum AlertEvent { + Disabled, + Enabled(AlertEventProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AlertEventProperties { + #[serde(rename = "eventMessage")] + pub event_message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AllowedIp { + #[serde(rename = "address")] + pub address: IpAddrOrMask, + #[serde(rename = "reason")] + pub reason: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ApiKey { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "secret")] + pub secret: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "permissions")] + pub permissions: CredentialPermissions, + #[serde(rename = "allowedIps")] + pub allowed_ips: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AppPassword { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "secret")] + pub secret: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "permissions")] + pub permissions: CredentialPermissions, + #[serde(rename = "allowedIps")] + pub allowed_ips: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Application { + #[serde(rename = "enabled")] + pub enabled: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "resourceUrl")] + pub resource_url: String, + #[serde(rename = "urlPrefix")] + pub url_prefix: Map, + #[serde(rename = "autoUpdateFrequency")] + pub auto_update_frequency: Duration, + #[serde(rename = "unpackDirectory")] + pub unpack_directory: Option, + #[serde(rename = "oauthClientId")] + pub oauth_client_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArchivedCalendarEvent { + #[serde(rename = "title")] + pub title: String, + #[serde(rename = "startTime")] + pub start_time: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "archivedAt")] + pub archived_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "blobId")] + pub blob_id: BlobId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArchivedContactCard { + #[serde(rename = "name")] + pub name: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "archivedAt")] + pub archived_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "blobId")] + pub blob_id: BlobId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArchivedEmail { + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "receivedAt")] + pub received_at: UTCDateTime, + #[serde(rename = "size")] + pub size: u64, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "archivedAt")] + pub archived_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "blobId")] + pub blob_id: BlobId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArchivedFileNode { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "archivedAt")] + pub archived_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "blobId")] + pub blob_id: BlobId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum ArchivedItem { + Email(ArchivedEmail), + FileNode(ArchivedFileNode), + CalendarEvent(ArchivedCalendarEvent), + ContactCard(ArchivedContactCard), + SieveScript(ArchivedSieveScript), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArchivedSieveScript { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "content")] + pub content: String, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "archivedAt")] + pub archived_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "blobId")] + pub blob_id: BlobId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArfExternalReport { + #[serde(rename = "report")] + pub report: ArfFeedbackReport, + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "to")] + pub to: Map, + #[serde(rename = "receivedAt")] + pub received_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: UTCDateTime, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ArfFeedbackReport { + #[serde(rename = "feedbackType")] + pub feedback_type: ArfFeedbackType, + #[serde(rename = "arrivalDate")] + pub arrival_date: Option, + #[serde(rename = "authenticationResults")] + pub authentication_results: Map, + #[serde(rename = "incidents")] + pub incidents: u64, + #[serde(rename = "originalEnvelopeId")] + pub original_envelope_id: Option, + #[serde(rename = "originalMailFrom")] + pub original_mail_from: Option, + #[serde(rename = "originalRcptTo")] + pub original_rcpt_to: Option, + #[serde(rename = "reportedDomains")] + pub reported_domains: Map, + #[serde(rename = "reportedUris")] + pub reported_uris: Map, + #[serde(rename = "reportingMta")] + pub reporting_mta: Option, + #[serde(rename = "sourceIp")] + pub source_ip: Option, + #[serde(rename = "sourcePort")] + pub source_port: Option, + #[serde(rename = "userAgent")] + pub user_agent: Option, + #[serde(rename = "version")] + pub version: u64, + #[serde(rename = "authFailure")] + pub auth_failure: ArfAuthFailureType, + #[serde(rename = "deliveryResult")] + pub delivery_result: ArfDeliveryResult, + #[serde(rename = "dkimAdspDns")] + pub dkim_adsp_dns: Option, + #[serde(rename = "dkimCanonicalizedBody")] + pub dkim_canonicalized_body: Option, + #[serde(rename = "dkimCanonicalizedHeader")] + pub dkim_canonicalized_header: Option, + #[serde(rename = "dkimDomain")] + pub dkim_domain: Option, + #[serde(rename = "dkimIdentity")] + pub dkim_identity: Option, + #[serde(rename = "dkimSelector")] + pub dkim_selector: Option, + #[serde(rename = "dkimSelectorDns")] + pub dkim_selector_dns: Option, + #[serde(rename = "spfDns")] + pub spf_dns: Option, + #[serde(rename = "identityAlignment")] + pub identity_alignment: ArfIdentityAlignment, + #[serde(rename = "message")] + pub message: Option, + #[serde(rename = "headers")] + pub headers: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Asn { + Disabled, + Resource(AsnResource), + Dns(AsnDns), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AsnDns { + #[serde(rename = "indexAsn")] + pub index_asn: u64, + #[serde(rename = "indexAsnName")] + pub index_asn_name: Option, + #[serde(rename = "indexCountry")] + pub index_country: Option, + #[serde(rename = "separator")] + pub separator: String, + #[serde(rename = "zoneIpV4")] + pub zone_ip_v4: String, + #[serde(rename = "zoneIpV6")] + pub zone_ip_v6: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AsnResource { + #[serde(rename = "expires")] + pub expires: Duration, + #[serde(rename = "maxSize")] + pub max_size: u64, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "asnUrls")] + pub asn_urls: Map, + #[serde(rename = "geoUrls")] + pub geo_urls: Map, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Authentication { + #[serde(rename = "directoryId")] + pub directory_id: Option, + #[serde(rename = "defaultUserRoleIds")] + pub default_user_role_ids: Map, + #[serde(rename = "defaultGroupRoleIds")] + pub default_group_role_ids: Map, + #[serde(rename = "defaultTenantRoleIds")] + pub default_tenant_role_ids: Map, + #[serde(rename = "defaultAdminRoleIds")] + pub default_admin_role_ids: Map, + #[serde(rename = "passwordHashAlgorithm")] + pub password_hash_algorithm: PasswordHashAlgorithm, + #[serde(rename = "passwordMinLength")] + pub password_min_length: u64, + #[serde(rename = "passwordMaxLength")] + pub password_max_length: u64, + #[serde(rename = "passwordMinStrength")] + pub password_min_strength: PasswordStrength, + #[serde(rename = "passwordDefaultExpiry")] + pub password_default_expiry: Option, + #[serde(rename = "maxAppPasswords")] + pub max_app_passwords: Option, + #[serde(rename = "maxApiKeys")] + pub max_api_keys: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct AzureStore { + #[serde(rename = "storageAccount")] + pub storage_account: String, + #[serde(rename = "container")] + pub container: String, + #[serde(rename = "accessKey")] + pub access_key: SecretKeyOptional, + #[serde(rename = "sasToken")] + pub sas_token: SecretKeyOptional, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "maxRetries")] + pub max_retries: u64, + #[serde(rename = "keyPrefix")] + pub key_prefix: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum BlobStore { + Default, + Sharded(ShardedBlobStore), + S3(S3Store), + Azure(AzureStore), + FileSystem(FileSystemStore), + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum BlobStoreBase { + S3(S3Store), + Azure(AzureStore), + FileSystem(FileSystemStore), + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct BlockedIp { + #[serde(rename = "address")] + pub address: IpAddrOrMask, + #[serde(rename = "reason")] + pub reason: BlockReason, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Bootstrap { + #[serde(rename = "serverHostname")] + pub server_hostname: String, + #[serde(rename = "defaultDomain")] + pub default_domain: String, + #[serde(rename = "requestTlsCertificate")] + pub request_tls_certificate: bool, + #[serde(rename = "generateDkimKeys")] + pub generate_dkim_keys: bool, + #[serde(rename = "dataStore")] + pub data_store: DataStore, + #[serde(rename = "blobStore")] + pub blob_store: BlobStore, + #[serde(rename = "searchStore")] + pub search_store: SearchStore, + #[serde(rename = "inMemoryStore")] + pub in_memory_store: InMemoryStore, + #[serde(rename = "directory")] + pub directory: DirectoryBootstrap, + #[serde(rename = "tracer")] + pub tracer: Tracer, + #[serde(rename = "dnsServer")] + pub dns_server: DnsServerBootstrap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Cache { + #[serde(rename = "accessTokens")] + pub access_tokens: u64, + #[serde(rename = "contacts")] + pub contacts: u64, + #[serde(rename = "dnsIpv4")] + pub dns_ipv4: u64, + #[serde(rename = "dnsIpv6")] + pub dns_ipv6: u64, + #[serde(rename = "dnsMtaSts")] + pub dns_mta_sts: u64, + #[serde(rename = "dnsMx")] + pub dns_mx: u64, + #[serde(rename = "dnsPtr")] + pub dns_ptr: u64, + #[serde(rename = "dnsRbl")] + pub dns_rbl: u64, + #[serde(rename = "dnsTlsa")] + pub dns_tlsa: u64, + #[serde(rename = "dnsTxt")] + pub dns_txt: u64, + #[serde(rename = "events")] + pub events: u64, + #[serde(rename = "scheduling")] + pub scheduling: u64, + #[serde(rename = "files")] + pub files: u64, + #[serde(rename = "httpAuth")] + pub http_auth: u64, + #[serde(rename = "messages")] + pub messages: u64, + #[serde(rename = "domains")] + pub domains: u64, + #[serde(rename = "domainNames")] + pub domain_names: u64, + #[serde(rename = "domainNamesNegative")] + pub domain_names_negative: u64, + #[serde(rename = "emailAddresses")] + pub email_addresses: u64, + #[serde(rename = "emailAddressesNegative")] + pub email_addresses_negative: u64, + #[serde(rename = "accounts")] + pub accounts: u64, + #[serde(rename = "roles")] + pub roles: u64, + #[serde(rename = "tenants")] + pub tenants: u64, + #[serde(rename = "mailingLists")] + pub mailing_lists: u64, + #[serde(rename = "dkimSignatures")] + pub dkim_signatures: u64, + #[serde(rename = "negativeTtl")] + pub negative_ttl: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Calendar { + #[serde(rename = "defaultDisplayName")] + pub default_display_name: Option, + #[serde(rename = "defaultHrefName")] + pub default_href_name: Option, + #[serde(rename = "maxAttendees")] + pub max_attendees: u64, + #[serde(rename = "maxRecurrenceExpansions")] + pub max_recurrence_expansions: u64, + #[serde(rename = "maxICalendarSize")] + pub max_i_calendar_size: u64, + #[serde(rename = "maxCalendars")] + pub max_calendars: Option, + #[serde(rename = "maxEvents")] + pub max_events: Option, + #[serde(rename = "maxParticipantIdentities")] + pub max_participant_identities: Option, + #[serde(rename = "maxEventNotifications")] + pub max_event_notifications: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CalendarAlarm { + #[serde(rename = "allowExternalRcpts")] + pub allow_external_rcpts: bool, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "fromEmail")] + pub from_email: Option, + #[serde(rename = "fromName")] + pub from_name: String, + #[serde(rename = "minTriggerInterval")] + pub min_trigger_interval: Duration, + #[serde(rename = "template")] + pub template: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CalendarScheduling { + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "httpRsvpEnable")] + pub http_rsvp_enable: bool, + #[serde(rename = "httpRsvpLinkExpiry")] + pub http_rsvp_link_expiry: Duration, + #[serde(rename = "httpRsvpUrl")] + pub http_rsvp_url: Option, + #[serde(rename = "autoAddInvitations")] + pub auto_add_invitations: bool, + #[serde(rename = "itipMaxSize")] + pub itip_max_size: u64, + #[serde(rename = "maxRecipients")] + pub max_recipients: u64, + #[serde(rename = "emailTemplate")] + pub email_template: Option, + #[serde(rename = "httpRsvpTemplate")] + pub http_rsvp_template: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Certificate { + #[serde(rename = "certificate")] + pub certificate: PublicText, + #[serde(rename = "privateKey")] + pub private_key: SecretText, + #[serde(rename = "subjectAlternativeNames")] + pub subject_alternative_names: Map, + #[serde(rename = "notValidAfter")] + pub not_valid_after: UTCDateTime, + #[serde(rename = "notValidBefore")] + pub not_valid_before: UTCDateTime, + #[serde(rename = "issuer")] + pub issuer: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum CertificateManagement { + Manual, + Automatic(CertificateManagementProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CertificateManagementProperties { + #[serde(rename = "acmeProviderId")] + pub acme_provider_id: Id, + #[serde(rename = "subjectAlternativeNames")] + pub subject_alternative_names: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum ClusterListenerGroup { + EnableAll, + DisableAll, + EnableSome(ClusterListenerGroupProperties), + DisableSome(ClusterListenerGroupProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ClusterListenerGroupProperties { + #[serde(rename = "listenerIds")] + pub listener_ids: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ClusterNode { + #[serde(rename = "nodeId")] + pub node_id: u64, + #[serde(rename = "hostname")] + pub hostname: String, + #[serde(rename = "lastRenewal")] + pub last_renewal: UTCDateTime, + #[serde(rename = "status")] + pub status: ClusterNodeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ClusterRole { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "tasks")] + pub tasks: ClusterTaskGroup, + #[serde(rename = "listeners")] + pub listeners: ClusterListenerGroup, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum ClusterTaskGroup { + EnableAll, + DisableAll, + EnableSome(ClusterTaskGroupProperties), + DisableSome(ClusterTaskGroupProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ClusterTaskGroupProperties { + #[serde(rename = "taskTypes")] + pub task_types: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Coordinator { + Disabled, + Default, + Kafka(KafkaCoordinator), + Nats(NatsCoordinator), + Zenoh(ZenohCoordinator), + Redis(RedisStore), + RedisCluster(RedisClusterStore), + RedisSentinel(RedisSentinelStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Credential { + Password(PasswordCredential), + AppPassword(SecondaryCredential), + ApiKey(SecondaryCredential), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum CredentialPermissions { + Inherit, + Disable(CredentialPermissionsList), + Replace(CredentialPermissionsList), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CredentialPermissionsList { + #[serde(rename = "permissions")] + pub permissions: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Cron { + Daily(CronDaily), + Weekly(CronWeekly), + Hourly(CronHourly), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CronDaily { + #[serde(rename = "hour")] + pub hour: u64, + #[serde(rename = "minute")] + pub minute: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CronHourly { + #[serde(rename = "minute")] + pub minute: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CronWeekly { + #[serde(rename = "day")] + pub day: u64, + #[serde(rename = "hour")] + pub hour: u64, + #[serde(rename = "minute")] + pub minute: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CustomRoles { + #[serde(rename = "roleIds")] + pub role_ids: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DataRetention { + #[serde(rename = "expungeTrashAfter")] + pub expunge_trash_after: Option, + #[serde(rename = "expungeSubmissionsAfter")] + pub expunge_submissions_after: Option, + #[serde(rename = "expungeShareNotifyAfter")] + pub expunge_share_notify_after: Option, + #[serde(rename = "expungeSchedulingInboxAfter")] + pub expunge_scheduling_inbox_after: Option, + #[serde(rename = "expungeSchedule")] + pub expunge_schedule: Cron, + #[serde(rename = "dataCleanupSchedule")] + pub data_cleanup_schedule: Cron, + #[serde(rename = "blobCleanupSchedule")] + pub blob_cleanup_schedule: Cron, + #[serde(rename = "maxChangesHistory")] + pub max_changes_history: Option, + #[serde(rename = "archiveDeletedItemsFor")] + pub archive_deleted_items_for: Option, + #[serde(rename = "archiveDeletedAccountsFor")] + pub archive_deleted_accounts_for: Option, + #[serde(rename = "holdMtaReportsFor")] + pub hold_mta_reports_for: Option, + #[serde(rename = "holdTracesFor")] + pub hold_traces_for: Option, + #[serde(rename = "holdMetricsFor")] + pub hold_metrics_for: Option, + #[serde(rename = "metricsCollectionInterval")] + pub metrics_collection_interval: Cron, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DataStore { + RocksDb(RocksDbStore), + Sqlite(SqliteStore), + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DeliveryError { + #[serde(rename = "errorType")] + pub error_type: DeliveryErrorType, + #[serde(rename = "errorMessage")] + pub error_message: Option, + #[serde(rename = "errorCommand")] + pub error_command: Option, + #[serde(rename = "responseHostname")] + pub response_hostname: Option, + #[serde(rename = "responseCode")] + pub response_code: Option, + #[serde(rename = "responseEnhanced")] + pub response_enhanced: Option, + #[serde(rename = "responseMessage")] + pub response_message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Directory { + Ldap(LdapDirectory), + Sql(SqlDirectory), + Oidc(OidcDirectory), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DirectoryBootstrap { + Internal, + Ldap(LdapDirectory), + Sql(SqlDirectory), + Oidc(OidcDirectory), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Dkim1Signature { + #[serde(rename = "auid")] + pub auid: Option, + #[serde(rename = "canonicalization")] + pub canonicalization: DkimCanonicalization, + #[serde(rename = "expire")] + pub expire: Option, + #[serde(rename = "headers")] + pub headers: Map, + #[serde(rename = "privateKey")] + pub private_key: SecretText, + #[serde(rename = "report")] + pub report: bool, + #[serde(rename = "thirdParty")] + pub third_party: Option, + #[serde(rename = "thirdPartyHash")] + pub third_party_hash: Option, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "selector")] + pub selector: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "nextTransitionAt")] + pub next_transition_at: Option, + #[serde(rename = "stage")] + pub stage: DkimRotationStage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Dkim2Signature { + #[serde(rename = "flags")] + pub flags: Map, + #[serde(rename = "privateKey")] + pub private_key: SecretText, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "selector")] + pub selector: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "nextTransitionAt")] + pub next_transition_at: Option, + #[serde(rename = "stage")] + pub stage: DkimRotationStage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DkimManagement { + Automatic(DkimManagementProperties), + Manual, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DkimManagementProperties { + #[serde(rename = "algorithms")] + pub algorithms: Map, + #[serde(rename = "selectorTemplate")] + pub selector_template: String, + #[serde(rename = "rotateAfter")] + pub rotate_after: Duration, + #[serde(rename = "retireAfter")] + pub retire_after: Duration, + #[serde(rename = "deleteAfter")] + pub delete_after: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DkimReportSettings { + #[serde(rename = "fromAddress")] + pub from_address: Expression, + #[serde(rename = "fromName")] + pub from_name: Expression, + #[serde(rename = "sendFrequency")] + pub send_frequency: Expression, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, + #[serde(rename = "subject")] + pub subject: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DkimSignature { + Dkim1Ed25519Sha256(Dkim1Signature), + Dkim1RsaSha256(Dkim1Signature), + Dkim2Ed25519Sha256(Dkim2Signature), + Dkim2RsaSha256(Dkim2Signature), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcDkimResult { + #[serde(rename = "domain")] + pub domain: String, + #[serde(rename = "selector")] + pub selector: String, + #[serde(rename = "result")] + pub result: DkimAuthResult, + #[serde(rename = "humanResult")] + pub human_result: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcExtension { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "definition")] + pub definition: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcExternalReport { + #[serde(rename = "report")] + pub report: DmarcReport, + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "to")] + pub to: Map, + #[serde(rename = "receivedAt")] + pub received_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: UTCDateTime, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcInternalReport { + #[serde(rename = "rua")] + pub rua: Map, + #[serde(rename = "policyIdentifier")] + pub policy_identifier: u64, + #[serde(rename = "report")] + pub report: DmarcReport, + #[serde(rename = "domain")] + pub domain: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "deliverAt")] + pub deliver_at: UTCDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcPolicyOverrideReason { + #[serde(rename = "overrideType")] + pub override_type: DmarcPolicyOverride, + #[serde(rename = "comment")] + pub comment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcReport { + #[serde(rename = "version")] + pub version: Float, + #[serde(rename = "orgName")] + pub org_name: String, + #[serde(rename = "email")] + pub email: String, + #[serde(rename = "extraContactInfo")] + pub extra_contact_info: Option, + #[serde(rename = "reportId")] + pub report_id: String, + #[serde(rename = "dateRangeBegin")] + pub date_range_begin: UTCDateTime, + #[serde(rename = "dateRangeEnd")] + pub date_range_end: UTCDateTime, + #[serde(rename = "errors")] + pub errors: Map, + #[serde(rename = "policyDomain")] + pub policy_domain: String, + #[serde(rename = "policyVersion")] + pub policy_version: Option, + #[serde(rename = "policyAdkim")] + pub policy_adkim: DmarcAlignment, + #[serde(rename = "policyAspf")] + pub policy_aspf: DmarcAlignment, + #[serde(rename = "policyDisposition")] + pub policy_disposition: DmarcDisposition, + #[serde(rename = "policySubdomainDisposition")] + pub policy_subdomain_disposition: DmarcDisposition, + #[serde(rename = "policyTestingMode")] + pub policy_testing_mode: bool, + #[serde(rename = "policyFailureReportingOptions")] + pub policy_failure_reporting_options: Map, + #[serde(rename = "records")] + pub records: List, + #[serde(rename = "extensions")] + pub extensions: List, + #[serde(rename = "generator")] + pub generator: Option, + #[serde(rename = "policyNp")] + pub policy_np: DmarcDisposition, + #[serde(rename = "policyDiscoveryMethod")] + pub policy_discovery_method: DmarcDiscovery, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcReportRecord { + #[serde(rename = "sourceIp")] + pub source_ip: Option, + #[serde(rename = "count")] + pub count: u64, + #[serde(rename = "evaluatedDisposition")] + pub evaluated_disposition: DmarcActionDisposition, + #[serde(rename = "evaluatedDkim")] + pub evaluated_dkim: DmarcResult, + #[serde(rename = "evaluatedSpf")] + pub evaluated_spf: DmarcResult, + #[serde(rename = "policyOverrideReasons")] + pub policy_override_reasons: List, + #[serde(rename = "envelopeTo")] + pub envelope_to: Option, + #[serde(rename = "envelopeFrom")] + pub envelope_from: String, + #[serde(rename = "headerFrom")] + pub header_from: String, + #[serde(rename = "dkimResults")] + pub dkim_results: List, + #[serde(rename = "spfResults")] + pub spf_results: List, + #[serde(rename = "extensions")] + pub extensions: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcReportSettings { + #[serde(rename = "aggregateContactInfo")] + pub aggregate_contact_info: Expression, + #[serde(rename = "aggregateFromAddress")] + pub aggregate_from_address: Expression, + #[serde(rename = "aggregateFromName")] + pub aggregate_from_name: Expression, + #[serde(rename = "aggregateMaxReportSize")] + pub aggregate_max_report_size: Expression, + #[serde(rename = "aggregateOrgName")] + pub aggregate_org_name: Expression, + #[serde(rename = "aggregateSendFrequency")] + pub aggregate_send_frequency: Expression, + #[serde(rename = "aggregateDkimSignDomain")] + pub aggregate_dkim_sign_domain: Expression, + #[serde(rename = "aggregateSubject")] + pub aggregate_subject: Expression, + #[serde(rename = "failureFromAddress")] + pub failure_from_address: Expression, + #[serde(rename = "failureFromName")] + pub failure_from_name: Expression, + #[serde(rename = "failureSendFrequency")] + pub failure_send_frequency: Expression, + #[serde(rename = "failureDkimSignDomain")] + pub failure_dkim_sign_domain: Expression, + #[serde(rename = "failureSubject")] + pub failure_subject: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcSpfResult { + #[serde(rename = "domain")] + pub domain: String, + #[serde(rename = "scope")] + pub scope: SpfDomainScope, + #[serde(rename = "result")] + pub result: SpfAuthResult, + #[serde(rename = "humanResult")] + pub human_result: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcTroubleshoot { + #[serde(rename = "remoteIp")] + pub remote_ip: IpAddr, + #[serde(rename = "ehloDomain")] + pub ehlo_domain: String, + #[serde(rename = "mailFrom")] + pub mail_from: String, + #[serde(rename = "to")] + pub to: Map, + #[serde(rename = "message")] + pub message: Option, + #[serde(rename = "spfEhloDomain")] + pub spf_ehlo_domain: String, + #[serde(rename = "spfEhloResult")] + pub spf_ehlo_result: DmarcTroubleshootAuthResult, + #[serde(rename = "spfMailFromDomain")] + pub spf_mail_from_domain: String, + #[serde(rename = "spfMailFromResult")] + pub spf_mail_from_result: DmarcTroubleshootAuthResult, + #[serde(rename = "ipRevResult")] + pub ip_rev_result: DmarcTroubleshootAuthResult, + #[serde(rename = "ipRevPtr")] + pub ip_rev_ptr: Map, + #[serde(rename = "dkimResults")] + pub dkim_results: List, + #[serde(rename = "dkimPass")] + pub dkim_pass: bool, + #[serde(rename = "dkim2Result")] + pub dkim2_result: DmarcTroubleshootAuthResult, + #[serde(rename = "dkim2Pass")] + pub dkim2_pass: bool, + #[serde(rename = "arcResult")] + pub arc_result: DmarcTroubleshootAuthResult, + #[serde(rename = "dmarcResult")] + pub dmarc_result: DmarcTroubleshootAuthResult, + #[serde(rename = "dmarcPass")] + pub dmarc_pass: bool, + #[serde(rename = "dmarcPolicy")] + pub dmarc_policy: DmarcDisposition, + #[serde(rename = "elapsed")] + pub elapsed: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DmarcTroubleshootAuthResult { + Pass, + Fail(DmarcTroubleshootDetails), + SoftFail(DmarcTroubleshootDetails), + TempError(DmarcTroubleshootDetails), + PermError(DmarcTroubleshootDetails), + Neutral(DmarcTroubleshootDetails), + None, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DmarcTroubleshootDetails { + #[serde(rename = "details")] + pub details: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsCustomResolver { + #[serde(rename = "protocol")] + pub protocol: DnsResolverProtocol, + #[serde(rename = "address")] + pub address: IpAddr, + #[serde(rename = "port")] + pub port: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DnsManagement { + Manual, + Automatic(DnsManagementProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsManagementProperties { + #[serde(rename = "dnsServerId")] + pub dns_server_id: Id, + #[serde(rename = "origin")] + pub origin: Option, + #[serde(rename = "publishRecords")] + pub publish_records: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DnsResolver { + System(DnsResolverCommon), + Custom(DnsResolverCustom), + Cloudflare(DnsResolverTls), + Quad9(DnsResolverTls), + Google(DnsResolverCommon), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsResolverCommon { + #[serde(rename = "attempts")] + pub attempts: u64, + #[serde(rename = "concurrency")] + pub concurrency: u64, + #[serde(rename = "enableEdns")] + pub enable_edns: bool, + #[serde(rename = "preserveIntermediates")] + pub preserve_intermediates: bool, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "tcpOnError")] + pub tcp_on_error: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsResolverCustom { + #[serde(rename = "servers")] + pub servers: List, + #[serde(rename = "attempts")] + pub attempts: u64, + #[serde(rename = "concurrency")] + pub concurrency: u64, + #[serde(rename = "enableEdns")] + pub enable_edns: bool, + #[serde(rename = "preserveIntermediates")] + pub preserve_intermediates: bool, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "tcpOnError")] + pub tcp_on_error: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsResolverTls { + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "attempts")] + pub attempts: u64, + #[serde(rename = "concurrency")] + pub concurrency: u64, + #[serde(rename = "enableEdns")] + pub enable_edns: bool, + #[serde(rename = "preserveIntermediates")] + pub preserve_intermediates: bool, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "tcpOnError")] + pub tcp_on_error: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DnsServer { + Tsig(DnsServerTsig), + Deprecated1, + Cloudflare(DnsServerCloudflare), + DigitalOcean(DnsServerCloud), + DeSEC(DnsServerCloud), + Ovh(DnsServerOvh), + Bunny(DnsServerCloud), + Porkbun(DnsServerPorkbun), + Dnsimple(DnsServerDnsimple), + Spaceship(DnsServerSpaceship), + Route53(DnsServerRoute53), + GoogleCloudDns(DnsServerGoogleCloudDns), + Alidns(DnsServerAlidns), + ArvanCloud(DnsServerCloud), + Autodns(DnsServerAutodns), + AzureDns(DnsServerAzureDns), + BaiduCloud(DnsServerBaiduCloud), + BluecatV2(DnsServerBluecatV2), + ClouDns(DnsServerClouDns), + Constellix(DnsServerConstellix), + Cpanel(DnsServerCpanel), + Ddnss(DnsServerCloud), + DnsMadeEasy(DnsServerDnsMadeEasy), + Domeneshop(DnsServerDomeneshop), + Dreamhost(DnsServerCloud), + DuckDns(DnsServerCloud), + Dynu(DnsServerCloud), + EasyDns(DnsServerEasyDns), + EdgeDns(DnsServerEdgeDns), + Exoscale(DnsServerExoscale), + FreeMyIp(DnsServerCloud), + GandiV5(DnsServerCloud), + Gcore(DnsServerCloud), + Glesys(DnsServerGlesys), + Godaddy(DnsServerGodaddy), + Hetzner(DnsServerCloud), + HostingDe(DnsServerCloud), + Hostinger(DnsServerCloud), + HuaweiCloud(DnsServerHuaweiCloud), + Hurricane(DnsServerHurricane), + IbmCloud(DnsServerIbmCloud), + Infoblox(DnsServerInfoblox), + Infomaniak(DnsServerCloud), + Inwx(DnsServerInwx), + Ionos(DnsServerCloud), + Ipv64(DnsServerCloud), + Joker(DnsServerJoker), + Lightsail(DnsServerLightsail), + Linode(DnsServerCloud), + LuaDns(DnsServerLuaDns), + MythicBeasts(DnsServerMythicBeasts), + Namecheap(DnsServerNamecheap), + NameDotCom(DnsServerNameDotCom), + NameSilo(DnsServerCloud), + Netcup(DnsServerNetcup), + Netlify(DnsServerCloud), + Nifcloud(DnsServerNifcloud), + Ns1(DnsServerCloud), + OracleCloud(DnsServerOracleCloud), + Plesk(DnsServerPlesk), + Safedns(DnsServerCloud), + Scaleway(DnsServerCloud), + TencentCloud(DnsServerTencentCloud), + Transip(DnsServerTransip), + UltraDns(DnsServerUltraDns), + Vercel(DnsServerVercel), + Volcengine(DnsServerVolcengine), + Vultr(DnsServerCloud), + WebSupport(DnsServerWebSupport), + YandexCloud(DnsServerYandexCloud), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerAlidns { + #[serde(rename = "accessKey")] + pub access_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "region")] + pub region: Option, + #[serde(rename = "securityToken")] + pub security_token: SecretKeyOptional, + #[serde(rename = "line")] + pub line: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerAutodns { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "context")] + pub context: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerAzureDns { + #[serde(rename = "tenantId")] + pub tenant_id: String, + #[serde(rename = "clientId")] + pub client_id: String, + #[serde(rename = "clientSecret")] + pub client_secret: SecretKey, + #[serde(rename = "subscriptionId")] + pub subscription_id: String, + #[serde(rename = "resourceGroup")] + pub resource_group: String, + #[serde(rename = "environment")] + pub environment: AzureEnvironment, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerBaiduCloud { + #[serde(rename = "accessKey")] + pub access_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerBluecatV2 { + #[serde(rename = "baseUrl")] + pub base_url: String, + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "configName")] + pub config_name: String, + #[serde(rename = "viewName")] + pub view_name: String, + #[serde(rename = "skipDeploy")] + pub skip_deploy: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum DnsServerBootstrap { + Manual, + Tsig(DnsServerTsig), + Deprecated1, + Cloudflare(DnsServerCloudflare), + DigitalOcean(DnsServerCloud), + DeSEC(DnsServerCloud), + Ovh(DnsServerOvh), + Bunny(DnsServerCloud), + Porkbun(DnsServerPorkbun), + Dnsimple(DnsServerDnsimple), + Spaceship(DnsServerSpaceship), + Route53(DnsServerRoute53), + GoogleCloudDns(DnsServerGoogleCloudDns), + Alidns(DnsServerAlidns), + ArvanCloud(DnsServerCloud), + Autodns(DnsServerAutodns), + AzureDns(DnsServerAzureDns), + BaiduCloud(DnsServerBaiduCloud), + BluecatV2(DnsServerBluecatV2), + ClouDns(DnsServerClouDns), + Constellix(DnsServerConstellix), + Cpanel(DnsServerCpanel), + Ddnss(DnsServerCloud), + DnsMadeEasy(DnsServerDnsMadeEasy), + Domeneshop(DnsServerDomeneshop), + Dreamhost(DnsServerCloud), + DuckDns(DnsServerCloud), + Dynu(DnsServerCloud), + EasyDns(DnsServerEasyDns), + EdgeDns(DnsServerEdgeDns), + Exoscale(DnsServerExoscale), + FreeMyIp(DnsServerCloud), + GandiV5(DnsServerCloud), + Gcore(DnsServerCloud), + Glesys(DnsServerGlesys), + Godaddy(DnsServerGodaddy), + Hetzner(DnsServerCloud), + HostingDe(DnsServerCloud), + Hostinger(DnsServerCloud), + HuaweiCloud(DnsServerHuaweiCloud), + Hurricane(DnsServerHurricane), + IbmCloud(DnsServerIbmCloud), + Infoblox(DnsServerInfoblox), + Infomaniak(DnsServerCloud), + Inwx(DnsServerInwx), + Ionos(DnsServerCloud), + Ipv64(DnsServerCloud), + Joker(DnsServerJoker), + Lightsail(DnsServerLightsail), + Linode(DnsServerCloud), + LuaDns(DnsServerLuaDns), + MythicBeasts(DnsServerMythicBeasts), + Namecheap(DnsServerNamecheap), + NameDotCom(DnsServerNameDotCom), + NameSilo(DnsServerCloud), + Netcup(DnsServerNetcup), + Netlify(DnsServerCloud), + Nifcloud(DnsServerNifcloud), + Ns1(DnsServerCloud), + OracleCloud(DnsServerOracleCloud), + Plesk(DnsServerPlesk), + Safedns(DnsServerCloud), + Scaleway(DnsServerCloud), + TencentCloud(DnsServerTencentCloud), + Transip(DnsServerTransip), + UltraDns(DnsServerUltraDns), + Vercel(DnsServerVercel), + Volcengine(DnsServerVolcengine), + Vultr(DnsServerCloud), + WebSupport(DnsServerWebSupport), + YandexCloud(DnsServerYandexCloud), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerClouDns { + #[serde(rename = "authId")] + pub auth_id: Option, + #[serde(rename = "subAuthId")] + pub sub_auth_id: Option, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerCloud { + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerCloudflare { + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerConstellix { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerCpanel { + #[serde(rename = "baseUrl")] + pub base_url: String, + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "token")] + pub token: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerDnsMadeEasy { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerDnsimple { + #[serde(rename = "authToken")] + pub auth_token: SecretKey, + #[serde(rename = "accountIdentifier")] + pub account_identifier: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerDomeneshop { + #[serde(rename = "authToken")] + pub auth_token: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerEasyDns { + #[serde(rename = "token")] + pub token: String, + #[serde(rename = "key")] + pub key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerEdgeDns { + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "clientToken")] + pub client_token: String, + #[serde(rename = "clientSecret")] + pub client_secret: SecretKey, + #[serde(rename = "accessToken")] + pub access_token: SecretKey, + #[serde(rename = "accountSwitchKey")] + pub account_switch_key: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerExoscale { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerGlesys { + #[serde(rename = "apiUser")] + pub api_user: String, + #[serde(rename = "apiKey")] + pub api_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerGodaddy { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerGoogleCloudDns { + #[serde(rename = "serviceAccountJson")] + pub service_account_json: SecretText, + #[serde(rename = "projectId")] + pub project_id: String, + #[serde(rename = "managedZone")] + pub managed_zone: Option, + #[serde(rename = "privateZone")] + pub private_zone: bool, + #[serde(rename = "impersonateServiceAccount")] + pub impersonate_service_account: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerHuaweiCloud { + #[serde(rename = "accessKey")] + pub access_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "region")] + pub region: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerHurricane { + #[serde(rename = "credentials")] + pub credentials: List, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerIbmCloud { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "apiKey")] + pub api_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerInfoblox { + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: Option, + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "wapiVersion")] + pub wapi_version: Option, + #[serde(rename = "dnsView")] + pub dns_view: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerInwx { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "sharedSecret")] + pub shared_secret: SecretKeyOptional, + #[serde(rename = "sandbox")] + pub sandbox: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerJoker { + #[serde(rename = "auth")] + pub auth: JokerAuth, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerLightsail { + #[serde(rename = "accessKeyId")] + pub access_key_id: String, + #[serde(rename = "secretAccessKey")] + pub secret_access_key: SecretKey, + #[serde(rename = "sessionToken")] + pub session_token: SecretKeyOptional, + #[serde(rename = "region")] + pub region: Option, + #[serde(rename = "domain")] + pub domain: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerLuaDns { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "authToken")] + pub auth_token: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerMythicBeasts { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerNameDotCom { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "authToken")] + pub auth_token: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerNamecheap { + #[serde(rename = "apiUser")] + pub api_user: String, + #[serde(rename = "apiKey")] + pub api_key: SecretKey, + #[serde(rename = "clientIp")] + pub client_ip: String, + #[serde(rename = "username")] + pub username: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerNetcup { + #[serde(rename = "customerNumber")] + pub customer_number: String, + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerNifcloud { + #[serde(rename = "accessKey")] + pub access_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerOracleCloud { + #[serde(rename = "tenancyOcid")] + pub tenancy_ocid: String, + #[serde(rename = "userOcid")] + pub user_ocid: String, + #[serde(rename = "fingerprint")] + pub fingerprint: String, + #[serde(rename = "privateKeyPem")] + pub private_key_pem: SecretText, + #[serde(rename = "privateKeyPassword")] + pub private_key_password: SecretKeyOptional, + #[serde(rename = "region")] + pub region: String, + #[serde(rename = "compartmentOcid")] + pub compartment_ocid: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerOvh { + #[serde(rename = "applicationKey")] + pub application_key: String, + #[serde(rename = "applicationSecret")] + pub application_secret: SecretKey, + #[serde(rename = "consumerKey")] + pub consumer_key: SecretKey, + #[serde(rename = "ovhEndpoint")] + pub ovh_endpoint: OvhEndpoint, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerPlesk { + #[serde(rename = "baseUrl")] + pub base_url: String, + #[serde(rename = "apiKey")] + pub api_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerPorkbun { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secretApiKey")] + pub secret_api_key: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerRoute53 { + #[serde(rename = "accessKeyId")] + pub access_key_id: String, + #[serde(rename = "secretAccessKey")] + pub secret_access_key: SecretKey, + #[serde(rename = "sessionToken")] + pub session_token: SecretKeyOptional, + #[serde(rename = "region")] + pub region: String, + #[serde(rename = "hostedZoneId")] + pub hosted_zone_id: Option, + #[serde(rename = "privateZoneOnly")] + pub private_zone_only: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerSpaceship { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerTencentCloud { + #[serde(rename = "secretId")] + pub secret_id: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "region")] + pub region: Option, + #[serde(rename = "sessionToken")] + pub session_token: SecretKeyOptional, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerTransip { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "privateKeyPem")] + pub private_key_pem: SecretText, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerTsig { + #[serde(rename = "host")] + pub host: IpAddr, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "keyName")] + pub key_name: String, + #[serde(rename = "key")] + pub key: SecretKey, + #[serde(rename = "protocol")] + pub protocol: IpProtocol, + #[serde(rename = "tsigAlgorithm")] + pub tsig_algorithm: TsigAlgorithm, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerUltraDns { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, + #[serde(rename = "endpoint")] + pub endpoint: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerVercel { + #[serde(rename = "authToken")] + pub auth_token: SecretKey, + #[serde(rename = "teamId")] + pub team_id: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerVolcengine { + #[serde(rename = "accessKey")] + pub access_key: String, + #[serde(rename = "secretKey")] + pub secret_key: SecretKey, + #[serde(rename = "region")] + pub region: Option, + #[serde(rename = "host")] + pub host: Option, + #[serde(rename = "scheme")] + pub scheme: Option, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerWebSupport { + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "secret")] + pub secret: SecretKey, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DnsServerYandexCloud { + #[serde(rename = "apiKey")] + pub api_key: SecretText, + #[serde(rename = "folderId")] + pub folder_id: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "ttl")] + pub ttl: Duration, + #[serde(rename = "pollingInterval")] + pub polling_interval: Duration, + #[serde(rename = "propagationTimeout")] + pub propagation_timeout: Duration, + #[serde(rename = "propagationDelay")] + pub propagation_delay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Domain { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "aliases")] + pub aliases: Map, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "logo")] + pub logo: Option, + #[serde(rename = "certificateManagement")] + pub certificate_management: CertificateManagement, + #[serde(rename = "dkimManagement")] + pub dkim_management: DkimManagement, + #[serde(rename = "dnsManagement")] + pub dns_management: DnsManagement, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "directoryId")] + pub directory_id: Option, + #[serde(rename = "catchAllAddress")] + pub catch_all_address: Option, + #[serde(rename = "subAddressing")] + pub sub_addressing: SubAddressing, + #[serde(rename = "allowRelaying")] + pub allow_relaying: bool, + #[serde(rename = "reportAddressUri")] + pub report_address_uri: Option, + #[serde(rename = "allowScimProvisioning")] + pub allow_scim_provisioning: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DsnReportSettings { + #[serde(rename = "fromAddress")] + pub from_address: Expression, + #[serde(rename = "fromName")] + pub from_name: Expression, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ElasticSearchStore { + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "numReplicas")] + pub num_replicas: u64, + #[serde(rename = "numShards")] + pub num_shards: u64, + #[serde(rename = "includeSource")] + pub include_source: bool, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Email { + #[serde(rename = "maxAttachmentSize")] + pub max_attachment_size: u64, + #[serde(rename = "maxMessageSize")] + pub max_message_size: u64, + #[serde(rename = "maxMailboxDepth")] + pub max_mailbox_depth: u64, + #[serde(rename = "maxMailboxNameLength")] + pub max_mailbox_name_length: u64, + #[serde(rename = "encryptOnAppend")] + pub encrypt_on_append: bool, + #[serde(rename = "encryptAtRest")] + pub encrypt_at_rest: bool, + #[serde(rename = "compressionAlgorithm")] + pub compression_algorithm: CompressionAlgo, + #[serde(rename = "defaultFolders")] + pub default_folders: VecMap, + #[serde(rename = "maxMessages")] + pub max_messages: Option, + #[serde(rename = "maxSubmissions")] + pub max_submissions: Option, + #[serde(rename = "maxIdentities")] + pub max_identities: Option, + #[serde(rename = "maxMailboxes")] + pub max_mailboxes: Option, + #[serde(rename = "maxMaskedAddresses")] + pub max_masked_addresses: Option, + #[serde(rename = "maxPublicKeys")] + pub max_public_keys: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct EmailAlias { + #[serde(rename = "enabled")] + pub enabled: bool, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "description")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct EmailFolder { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "create")] + pub create: bool, + #[serde(rename = "subscribe")] + pub subscribe: bool, + #[serde(rename = "aliases")] + pub aliases: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum EncryptionAtRest { + Disabled, + Aes128(EncryptionSettings), + Aes256(EncryptionSettings), + Aes256Gcm(EncryptionSettings), + ChaCha20Poly1305(EncryptionSettings), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct EncryptionSettings { + #[serde(rename = "publicKey")] + pub public_key: Id, + #[serde(rename = "encryptOnAppend")] + pub encrypt_on_append: bool, + #[serde(rename = "allowSpamTraining")] + pub allow_spam_training: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Enterprise { + #[serde(rename = "apiKey")] + pub api_key: SecretKeyOptional, + #[serde(rename = "licenseKey")] + pub license_key: SecretKeyOptional, + #[serde(rename = "logoUrl")] + pub logo_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct EventTracingLevel { + #[serde(rename = "event")] + pub event: trc::EventType, + #[serde(rename = "level")] + pub level: TracingLevelOpt, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Expression { + #[serde(rename = "match")] + pub match_: List, + #[serde(rename = "else")] + pub else_: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ExpressionMatch { + #[serde(rename = "if")] + pub if_: String, + #[serde(rename = "then")] + pub then: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FileStorage { + #[serde(rename = "maxSize")] + pub max_size: u64, + #[serde(rename = "maxFiles")] + pub max_files: Option, + #[serde(rename = "maxFolders")] + pub max_folders: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FileSystemStore { + #[serde(rename = "path")] + pub path: String, + #[serde(rename = "depth")] + pub depth: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FoundationDbStore { + #[serde(rename = "clusterFile")] + pub cluster_file: Option, + #[serde(rename = "datacenterId")] + pub datacenter_id: Option, + #[serde(rename = "machineId")] + pub machine_id: Option, + #[serde(rename = "transactionRetryDelay")] + pub transaction_retry_delay: Option, + #[serde(rename = "transactionRetryLimit")] + pub transaction_retry_limit: Option, + #[serde(rename = "transactionTimeout")] + pub transaction_timeout: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct FtrlParameters { + #[serde(rename = "alpha")] + pub alpha: Float, + #[serde(rename = "beta")] + pub beta: Float, + #[serde(rename = "numFeatures")] + pub num_features: ModelSize, + #[serde(rename = "l1Ratio")] + pub l1_ratio: Float, + #[serde(rename = "l2Ratio")] + pub l2_ratio: Float, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct GroupAccount { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "roles")] + pub roles: Roles, + #[serde(rename = "quotas")] + pub quotas: VecMap, + #[serde(rename = "permissions")] + pub permissions: Permissions, + #[serde(rename = "aliases")] + pub aliases: List, + #[serde(rename = "locale")] + pub locale: Locale, + #[serde(rename = "timeZone")] + pub time_zone: Option, + #[serde(rename = "externalId")] + pub external_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Http { + #[serde(rename = "rateLimitAuthenticated")] + pub rate_limit_authenticated: Option, + #[serde(rename = "rateLimitAnonymous")] + pub rate_limit_anonymous: Option, + #[serde(rename = "allowedEndpoints")] + pub allowed_endpoints: Expression, + #[serde(rename = "enableHsts")] + pub enable_hsts: bool, + #[serde(rename = "usePermissiveCors")] + pub use_permissive_cors: bool, + #[serde(rename = "responseHeaders")] + pub response_headers: VecMap, + #[serde(rename = "useXForwarded")] + pub use_x_forwarded: bool, + #[serde(rename = "redirectRoot")] + pub redirect_root: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum HttpAuth { + Unauthenticated, + Basic(HttpAuthBasic), + Bearer(HttpAuthBearer), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpAuthBasic { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "secret")] + pub secret: SecretKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpAuthBearer { + #[serde(rename = "bearerToken")] + pub bearer_token: SecretKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpForm { + #[serde(rename = "deliverTo")] + pub deliver_to: Map, + #[serde(rename = "defaultFromAddress")] + pub default_from_address: String, + #[serde(rename = "fieldEmail")] + pub field_email: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "fieldHoneyPot")] + pub field_honey_pot: Option, + #[serde(rename = "maxSize")] + pub max_size: u64, + #[serde(rename = "defaultName")] + pub default_name: String, + #[serde(rename = "fieldName")] + pub field_name: Option, + #[serde(rename = "rateLimit")] + pub rate_limit: Option, + #[serde(rename = "defaultSubject")] + pub default_subject: String, + #[serde(rename = "fieldSubject")] + pub field_subject: Option, + #[serde(rename = "validateDomain")] + pub validate_domain: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpLookup { + #[serde(rename = "namespace")] + pub namespace: String, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "format")] + pub format: HttpLookupFormat, + #[serde(rename = "isGzipped")] + pub is_gzipped: bool, + #[serde(rename = "maxEntries")] + pub max_entries: u64, + #[serde(rename = "maxEntrySize")] + pub max_entry_size: u64, + #[serde(rename = "maxSize")] + pub max_size: u64, + #[serde(rename = "refresh")] + pub refresh: Duration, + #[serde(rename = "retry")] + pub retry: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "url")] + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpLookupCsv { + #[serde(rename = "indexKey")] + pub index_key: u64, + #[serde(rename = "indexValue")] + pub index_value: Option, + #[serde(rename = "separator")] + pub separator: String, + #[serde(rename = "skipFirst")] + pub skip_first: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum HttpLookupFormat { + Csv(HttpLookupCsv), + List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HurricaneCredential { + #[serde(rename = "zone")] + pub zone: String, + #[serde(rename = "secret")] + pub secret: SecretKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Imap { + #[serde(rename = "allowPlainTextAuth")] + pub allow_plain_text_auth: bool, + #[serde(rename = "maxAuthFailures")] + pub max_auth_failures: u64, + #[serde(rename = "maxConcurrent")] + pub max_concurrent: Option, + #[serde(rename = "maxRequestRate")] + pub max_request_rate: Option, + #[serde(rename = "maxRequestSize")] + pub max_request_size: u64, + #[serde(rename = "timeoutAnonymous")] + pub timeout_anonymous: Duration, + #[serde(rename = "timeoutAuthenticated")] + pub timeout_authenticated: Duration, + #[serde(rename = "timeoutIdle")] + pub timeout_idle: Duration, + #[serde(rename = "maxMessagesPerCommand")] + pub max_messages_per_command: u64, + #[serde(rename = "minUidBatchSize")] + pub min_uid_batch_size: u64, + #[serde(rename = "maxUidBatches")] + pub max_uid_batches: u64, + #[serde(rename = "maxMessagesPerSave")] + pub max_messages_per_save: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum InMemoryStore { + Default, + Sharded(ShardedInMemoryStore), + Redis(RedisStore), + RedisCluster(RedisClusterStore), + RedisSentinel(RedisSentinelStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum InMemoryStoreBase { + Redis(RedisStore), + RedisCluster(RedisClusterStore), + RedisSentinel(RedisSentinelStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Jmap { + #[serde(rename = "parseLimitEvent")] + pub parse_limit_event: u64, + #[serde(rename = "parseLimitContact")] + pub parse_limit_contact: u64, + #[serde(rename = "parseLimitEmail")] + pub parse_limit_email: u64, + #[serde(rename = "changesMaxResults")] + pub changes_max_results: u64, + #[serde(rename = "getMaxResults")] + pub get_max_results: u64, + #[serde(rename = "queryMaxResults")] + pub query_max_results: u64, + #[serde(rename = "maxMethodCalls")] + pub max_method_calls: u64, + #[serde(rename = "maxConcurrentRequests")] + pub max_concurrent_requests: Option, + #[serde(rename = "maxRequestSize")] + pub max_request_size: u64, + #[serde(rename = "setMaxObjects")] + pub set_max_objects: u64, + #[serde(rename = "snippetMaxResults")] + pub snippet_max_results: u64, + #[serde(rename = "maxConcurrentUploads")] + pub max_concurrent_uploads: Option, + #[serde(rename = "maxUploadSize")] + pub max_upload_size: u64, + #[serde(rename = "maxUploadCount")] + pub max_upload_count: u64, + #[serde(rename = "uploadQuota")] + pub upload_quota: u64, + #[serde(rename = "uploadTtl")] + pub upload_ttl: Duration, + #[serde(rename = "eventSourceThrottle")] + pub event_source_throttle: Duration, + #[serde(rename = "pushAttemptWait")] + pub push_attempt_wait: Duration, + #[serde(rename = "pushMaxAttempts")] + pub push_max_attempts: u64, + #[serde(rename = "pushRetryWait")] + pub push_retry_wait: Duration, + #[serde(rename = "pushThrottle")] + pub push_throttle: Duration, + #[serde(rename = "pushRequestTimeout")] + pub push_request_timeout: Duration, + #[serde(rename = "pushVerifyTimeout")] + pub push_verify_timeout: Duration, + #[serde(rename = "pushShardsTotal")] + pub push_shards_total: u64, + #[serde(rename = "websocketHeartbeat")] + pub websocket_heartbeat: Duration, + #[serde(rename = "websocketThrottle")] + pub websocket_throttle: Duration, + #[serde(rename = "websocketTimeout")] + pub websocket_timeout: Duration, + #[serde(rename = "maxSubscriptions")] + pub max_subscriptions: Option, + #[serde(rename = "webPushKey")] + pub web_push_key: SecretTextOptional, + #[serde(rename = "webPushContact")] + pub web_push_contact: Option, + #[serde(rename = "maxPushSize")] + pub max_push_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum JokerAuth { + ApiKey(JokerAuthApiKey), + UsernamePassword(JokerAuthUsernamePassword), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct JokerAuthApiKey { + #[serde(rename = "apiKey")] + pub api_key: SecretKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct JokerAuthUsernamePassword { + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "password")] + pub password: SecretKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct KafkaCoordinator { + #[serde(rename = "brokers")] + pub brokers: Map, + #[serde(rename = "groupId")] + pub group_id: String, + #[serde(rename = "timeoutMessage")] + pub timeout_message: Duration, + #[serde(rename = "timeoutSession")] + pub timeout_session: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct LdapDirectory { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "baseDn")] + pub base_dn: String, + #[serde(rename = "bindDn")] + pub bind_dn: Option, + #[serde(rename = "bindSecret")] + pub bind_secret: SecretKeyOptional, + #[serde(rename = "bindAuthentication")] + pub bind_authentication: bool, + #[serde(rename = "filterLogin")] + pub filter_login: String, + #[serde(rename = "filterMailbox")] + pub filter_mailbox: String, + #[serde(rename = "filterMemberOf")] + pub filter_member_of: Option, + #[serde(rename = "attrClass")] + pub attr_class: Map, + #[serde(rename = "attrDescription")] + pub attr_description: Map, + #[serde(rename = "attrEmail")] + pub attr_email: Map, + #[serde(rename = "attrEmailAlias")] + pub attr_email_alias: Map, + #[serde(rename = "attrMemberOf")] + pub attr_member_of: Map, + #[serde(rename = "attrSecret")] + pub attr_secret: Map, + #[serde(rename = "attrSecretChanged")] + pub attr_secret_changed: Map, + #[serde(rename = "groupClass")] + pub group_class: String, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: u64, + #[serde(rename = "poolTimeoutCreate")] + pub pool_timeout_create: Duration, + #[serde(rename = "poolTimeoutRecycle")] + pub pool_timeout_recycle: Duration, + #[serde(rename = "poolTimeoutWait")] + pub pool_timeout_wait: Duration, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Log { + #[serde(rename = "timestamp")] + pub timestamp: UTCDateTime, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "event")] + pub event: trc::EventType, + #[serde(rename = "details")] + pub details: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum LookupStore { + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), + Sqlite(SqliteStore), + Sharded(ShardedInMemoryStore), + Redis(RedisStore), + RedisCluster(RedisClusterStore), + RedisSentinel(RedisSentinelStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MailExchanger { + #[serde(rename = "hostname")] + pub hostname: Option, + #[serde(rename = "priority")] + pub priority: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MailingList { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "aliases")] + pub aliases: List, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "recipients")] + pub recipients: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskedEmail { + #[serde(rename = "enabled")] + pub enabled: bool, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "email")] + pub email: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "forDomain")] + pub for_domain: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "createdBy")] + pub created_by: Option, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "url")] + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MeilisearchStore { + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "pollInterval")] + pub poll_interval: Duration, + #[serde(rename = "maxRetries")] + pub max_retries: u64, + #[serde(rename = "failOnTimeout")] + pub fail_on_timeout: bool, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryLookupKey { + #[serde(rename = "namespace")] + pub namespace: String, + #[serde(rename = "key")] + pub key: String, + #[serde(rename = "isGlobPattern")] + pub is_glob_pattern: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryLookupKeyValue { + #[serde(rename = "namespace")] + pub namespace: String, + #[serde(rename = "key")] + pub key: String, + #[serde(rename = "value")] + pub value: String, + #[serde(rename = "isGlobPattern")] + pub is_glob_pattern: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Metric { + Counter(MetricCount), + Gauge(MetricCount), + Histogram(MetricSum), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricCount { + #[serde(rename = "count")] + pub count: u64, + #[serde(rename = "metric")] + pub metric: trc::MetricType, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricSum { + #[serde(rename = "count")] + pub count: u64, + #[serde(rename = "sum")] + pub sum: u64, + #[serde(rename = "metric")] + pub metric: trc::MetricType, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Metrics { + #[serde(rename = "openTelemetry")] + pub open_telemetry: MetricsOtel, + #[serde(rename = "prometheus")] + pub prometheus: MetricsPrometheus, + #[serde(rename = "metrics")] + pub metrics: Map, + #[serde(rename = "metricsPolicy")] + pub metrics_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MetricsOtel { + Disabled, + Http(MetricsOtelHttp), + Grpc(MetricsOtelGrpc), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricsOtelGrpc { + #[serde(rename = "endpoint")] + pub endpoint: Option, + #[serde(rename = "interval")] + pub interval: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricsOtelHttp { + #[serde(rename = "endpoint")] + pub endpoint: String, + #[serde(rename = "interval")] + pub interval: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MetricsPrometheus { + Disabled, + Enabled(MetricsPrometheusProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MetricsPrometheusProperties { + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "authUsername")] + pub auth_username: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MetricsStore { + Disabled, + Default, + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaConnectionIpHost { + #[serde(rename = "ehloHostname")] + pub ehlo_hostname: Option, + #[serde(rename = "sourceIp")] + pub source_ip: IpAddr, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaConnectionStrategy { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "ehloHostname")] + pub ehlo_hostname: Option, + #[serde(rename = "sourceIps")] + pub source_ips: List, + #[serde(rename = "connectTimeout")] + pub connect_timeout: Duration, + #[serde(rename = "dataTimeout")] + pub data_timeout: Duration, + #[serde(rename = "ehloTimeout")] + pub ehlo_timeout: Duration, + #[serde(rename = "greetingTimeout")] + pub greeting_timeout: Duration, + #[serde(rename = "mailFromTimeout")] + pub mail_from_timeout: Duration, + #[serde(rename = "rcptToTimeout")] + pub rcpt_to_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MtaDeliveryExpiration { + Ttl(MtaDeliveryExpirationTtl), + Attempts(MtaDeliveryExpirationAttempts), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaDeliveryExpirationAttempts { + #[serde(rename = "maxAttempts")] + pub max_attempts: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaDeliveryExpirationTtl { + #[serde(rename = "expire")] + pub expire: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaDeliverySchedule { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "expiry")] + pub expiry: MtaDeliveryExpiration, + #[serde(rename = "notify")] + pub notify: MtaDeliveryScheduleIntervalsOrDefault, + #[serde(rename = "queueId")] + pub queue_id: Id, + #[serde(rename = "retry")] + pub retry: MtaDeliveryScheduleIntervalsOrDefault, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaDeliveryScheduleInterval { + #[serde(rename = "duration")] + pub duration: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaDeliveryScheduleIntervals { + #[serde(rename = "intervals")] + pub intervals: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MtaDeliveryScheduleIntervalsOrDefault { + Default, + Custom(MtaDeliveryScheduleIntervals), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaExtensions { + #[serde(rename = "chunking")] + pub chunking: Expression, + #[serde(rename = "deliverBy")] + pub deliver_by: Expression, + #[serde(rename = "dsn")] + pub dsn: Expression, + #[serde(rename = "expn")] + pub expn: Expression, + #[serde(rename = "futureRelease")] + pub future_release: Expression, + #[serde(rename = "mtPriority")] + pub mt_priority: Expression, + #[serde(rename = "noSoliciting")] + pub no_soliciting: Expression, + #[serde(rename = "pipelining")] + pub pipelining: Expression, + #[serde(rename = "requireTls")] + pub require_tls: Expression, + #[serde(rename = "vrfy")] + pub vrfy: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaHook { + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "enable")] + pub enable: Expression, + #[serde(rename = "maxResponseSize")] + pub max_response_size: u64, + #[serde(rename = "tempFailOnError")] + pub temp_fail_on_error: bool, + #[serde(rename = "stages")] + pub stages: Map, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaInboundSession { + #[serde(rename = "maxDuration")] + pub max_duration: Expression, + #[serde(rename = "timeout")] + pub timeout: Expression, + #[serde(rename = "transferLimit")] + pub transfer_limit: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaInboundThrottle { + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "key")] + pub key: Map, + #[serde(rename = "match")] + pub match_: Expression, + #[serde(rename = "rate")] + pub rate: Rate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaMilter { + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "enable")] + pub enable: Expression, + #[serde(rename = "hostname")] + pub hostname: String, + #[serde(rename = "maxResponseSize")] + pub max_response_size: u64, + #[serde(rename = "tempFailOnError")] + pub temp_fail_on_error: bool, + #[serde(rename = "protocolVersion")] + pub protocol_version: MilterVersion, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "stages")] + pub stages: Map, + #[serde(rename = "timeoutCommand")] + pub timeout_command: Duration, + #[serde(rename = "timeoutConnect")] + pub timeout_connect: Duration, + #[serde(rename = "timeoutData")] + pub timeout_data: Duration, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "flagsAction")] + pub flags_action: Option, + #[serde(rename = "flagsProtocol")] + pub flags_protocol: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaOutboundStrategy { + #[serde(rename = "connection")] + pub connection: Expression, + #[serde(rename = "route")] + pub route: Expression, + #[serde(rename = "schedule")] + pub schedule: Expression, + #[serde(rename = "tls")] + pub tls: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaOutboundThrottle { + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "key")] + pub key: Map, + #[serde(rename = "match")] + pub match_: Expression, + #[serde(rename = "rate")] + pub rate: Rate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaQueueQuota { + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "key")] + pub key: Map, + #[serde(rename = "match")] + pub match_: Expression, + #[serde(rename = "messages")] + pub messages: Option, + #[serde(rename = "size")] + pub size: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum MtaRoute { + Mx(MtaRouteMx), + Relay(MtaRouteRelay), + Local(MtaRouteCommon), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaRouteCommon { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaRouteMx { + #[serde(rename = "ipLookupStrategy")] + pub ip_lookup_strategy: MtaIpStrategy, + #[serde(rename = "maxMultihomed")] + pub max_multihomed: u64, + #[serde(rename = "maxMxHosts")] + pub max_mx_hosts: u64, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaRouteRelay { + #[serde(rename = "address")] + pub address: String, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "protocol")] + pub protocol: MtaProtocol, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "implicitTls")] + pub implicit_tls: bool, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageAuth { + #[serde(rename = "maxFailures")] + pub max_failures: Expression, + #[serde(rename = "waitOnFail")] + pub wait_on_fail: Expression, + #[serde(rename = "saslMechanisms")] + pub sasl_mechanisms: Expression, + #[serde(rename = "mustMatchSender")] + pub must_match_sender: Expression, + #[serde(rename = "require")] + pub require: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageConnect { + #[serde(rename = "smtpGreeting")] + pub smtp_greeting: Expression, + #[serde(rename = "hostname")] + pub hostname: Expression, + #[serde(rename = "script")] + pub script: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageData { + #[serde(rename = "addAuthResultsHeader")] + pub add_auth_results_header: Expression, + #[serde(rename = "addDateHeader")] + pub add_date_header: Expression, + #[serde(rename = "addDeliveredToHeader")] + pub add_delivered_to_header: bool, + #[serde(rename = "addMessageIdHeader")] + pub add_message_id_header: Expression, + #[serde(rename = "addReceivedHeader")] + pub add_received_header: Expression, + #[serde(rename = "addReceivedSpfHeader")] + pub add_received_spf_header: Expression, + #[serde(rename = "addReturnPathHeader")] + pub add_return_path_header: Expression, + #[serde(rename = "maxMessages")] + pub max_messages: Expression, + #[serde(rename = "maxReceivedHeaders")] + pub max_received_headers: Expression, + #[serde(rename = "maxMessageSize")] + pub max_message_size: Expression, + #[serde(rename = "script")] + pub script: Expression, + #[serde(rename = "enableSpamFilter")] + pub enable_spam_filter: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageEhlo { + #[serde(rename = "rejectNonFqdn")] + pub reject_non_fqdn: Expression, + #[serde(rename = "require")] + pub require: Expression, + #[serde(rename = "script")] + pub script: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageMail { + #[serde(rename = "isSenderAllowed")] + pub is_sender_allowed: Expression, + #[serde(rename = "rewrite")] + pub rewrite: Expression, + #[serde(rename = "script")] + pub script: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaStageRcpt { + #[serde(rename = "maxFailures")] + pub max_failures: Expression, + #[serde(rename = "waitOnFail")] + pub wait_on_fail: Expression, + #[serde(rename = "maxRecipients")] + pub max_recipients: Expression, + #[serde(rename = "allowRelaying")] + pub allow_relaying: Expression, + #[serde(rename = "rewrite")] + pub rewrite: Expression, + #[serde(rename = "script")] + pub script: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaSts { + #[serde(rename = "maxAge")] + pub max_age: Duration, + #[serde(rename = "mode")] + pub mode: PolicyEnforcement, + #[serde(rename = "mxHosts")] + pub mx_hosts: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaTlsStrategy { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "dane")] + pub dane: MtaRequiredOrOptional, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "mtaSts")] + pub mta_sts: MtaRequiredOrOptional, + #[serde(rename = "startTls")] + pub start_tls: MtaRequiredOrOptional, + #[serde(rename = "mtaStsTimeout")] + pub mta_sts_timeout: Duration, + #[serde(rename = "tlsTimeout")] + pub tls_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaVirtualQueue { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "threadsPerNode")] + pub threads_per_node: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MySqlSettings { + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "database")] + pub database: String, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MySqlStore { + #[serde(rename = "timeout")] + pub timeout: Option, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "maxAllowedPacket")] + pub max_allowed_packet: Option, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: Option, + #[serde(rename = "poolMinConnections")] + pub pool_min_connections: Option, + #[serde(rename = "readReplicas")] + pub read_replicas: List, + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "database")] + pub database: String, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct NatsCoordinator { + #[serde(rename = "addresses")] + pub addresses: Map, + #[serde(rename = "maxReconnects")] + pub max_reconnects: Option, + #[serde(rename = "timeoutConnection")] + pub timeout_connection: Duration, + #[serde(rename = "timeoutRequest")] + pub timeout_request: Duration, + #[serde(rename = "pingInterval")] + pub ping_interval: Duration, + #[serde(rename = "capacityClient")] + pub capacity_client: u64, + #[serde(rename = "capacityReadBuffer")] + pub capacity_read_buffer: u64, + #[serde(rename = "capacitySubscription")] + pub capacity_subscription: u64, + #[serde(rename = "noEcho")] + pub no_echo: bool, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "credentials")] + pub credentials: SecretTextOptional, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct NetworkListener { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "bind")] + pub bind: Map, + #[serde(rename = "protocol")] + pub protocol: NetworkListenerProtocol, + #[serde(rename = "overrideProxyTrustedNetworks")] + pub override_proxy_trusted_networks: Map, + #[serde(rename = "socketBacklog")] + pub socket_backlog: Option, + #[serde(rename = "socketNoDelay")] + pub socket_no_delay: bool, + #[serde(rename = "socketReceiveBufferSize")] + pub socket_receive_buffer_size: Option, + #[serde(rename = "socketReuseAddress")] + pub socket_reuse_address: bool, + #[serde(rename = "socketReusePort")] + pub socket_reuse_port: bool, + #[serde(rename = "socketSendBufferSize")] + pub socket_send_buffer_size: Option, + #[serde(rename = "socketTosV4")] + pub socket_tos_v4: Option, + #[serde(rename = "socketTtl")] + pub socket_ttl: Option, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "tlsDisableCipherSuites")] + pub tls_disable_cipher_suites: Map, + #[serde(rename = "tlsDisableProtocols")] + pub tls_disable_protocols: Map, + #[serde(rename = "tlsIgnoreClientOrder")] + pub tls_ignore_client_order: bool, + #[serde(rename = "tlsImplicit")] + pub tls_implicit: bool, + #[serde(rename = "tlsTimeout")] + pub tls_timeout: Option, + #[serde(rename = "maxConnections")] + pub max_connections: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct OAuthClient { + #[serde(rename = "clientId")] + pub client_id: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "contacts")] + pub contacts: Map, + #[serde(rename = "secret")] + pub secret: Option, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "redirectUris")] + pub redirect_uris: Map, + #[serde(rename = "logo")] + pub logo: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct OidcDirectory { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "issuerUrl")] + pub issuer_url: String, + #[serde(rename = "requireAudience")] + pub require_audience: Option, + #[serde(rename = "requireScopes")] + pub require_scopes: Map, + #[serde(rename = "claimUsername")] + pub claim_username: String, + #[serde(rename = "usernameDomain")] + pub username_domain: Option, + #[serde(rename = "claimName")] + pub claim_name: Option, + #[serde(rename = "claimGroups")] + pub claim_groups: Option, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct OidcProvider { + #[serde(rename = "authCodeMaxAttempts")] + pub auth_code_max_attempts: u64, + #[serde(rename = "anonymousClientRegistration")] + pub anonymous_client_registration: bool, + #[serde(rename = "requireClientRegistration")] + pub require_client_registration: bool, + #[serde(rename = "authCodeExpiry")] + pub auth_code_expiry: Duration, + #[serde(rename = "refreshTokenExpiry")] + pub refresh_token_expiry: Duration, + #[serde(rename = "refreshTokenRenewal")] + pub refresh_token_renewal: Duration, + #[serde(rename = "accessTokenExpiry")] + pub access_token_expiry: Duration, + #[serde(rename = "userCodeExpiry")] + pub user_code_expiry: Duration, + #[serde(rename = "idTokenExpiry")] + pub id_token_expiry: Duration, + #[serde(rename = "encryptionKey")] + pub encryption_key: SecretKey, + #[serde(rename = "signatureAlgorithm")] + pub signature_algorithm: JwtSignatureAlgorithm, + #[serde(rename = "signatureKey")] + pub signature_key: SecretText, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct OtpAuth { + #[serde(rename = "otpCode")] + pub otp_code: Option, + #[serde(rename = "otpUrl")] + pub otp_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PasswordCredential { + #[serde(rename = "credentialId")] + pub credential_id: Id, + #[serde(rename = "secret")] + pub secret: String, + #[serde(rename = "otpAuth")] + pub otp_auth: Option, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "allowedIps")] + pub allowed_ips: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Permissions { + Inherit, + Merge(PermissionsList), + Replace(PermissionsList), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PermissionsList { + #[serde(rename = "enabledPermissions")] + pub enabled_permissions: Map, + #[serde(rename = "disabledPermissions")] + pub disabled_permissions: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PostgreSqlSettings { + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "database")] + pub database: String, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "options")] + pub options: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PostgreSqlStore { + #[serde(rename = "timeout")] + pub timeout: Option, + #[serde(rename = "useTls")] + pub use_tls: bool, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: Option, + #[serde(rename = "poolRecyclingMethod")] + pub pool_recycling_method: PostgreSqlRecyclingMethod, + #[serde(rename = "readReplicas")] + pub read_replicas: List, + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "database")] + pub database: String, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "options")] + pub options: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PublicKey { + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "key")] + pub key: String, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "emailAddresses")] + pub email_addresses: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum PublicStringOptional { + None, + Value(PublicStringValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PublicStringValue { + #[serde(rename = "value")] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum PublicText { + Text(PublicTextValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PublicTextValue { + #[serde(rename = "value")] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum QueueExpiry { + Ttl(QueueExpiryTtl), + Attempts(QueueExpiryAttempts), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QueueExpiryAttempts { + #[serde(rename = "expiresAttempts")] + pub expires_attempts: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QueueExpiryTtl { + #[serde(rename = "expiresAt")] + pub expires_at: UTCDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QueuedMessage { + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "nextRetry")] + pub next_retry: Option, + #[serde(rename = "nextNotify")] + pub next_notify: Option, + #[serde(rename = "blobId")] + pub blob_id: BlobId, + #[serde(rename = "returnPath")] + pub return_path: String, + #[serde(rename = "recipients")] + pub recipients: VecMap, + #[serde(rename = "receivedFromIp")] + pub received_from_ip: IpAddr, + #[serde(rename = "receivedViaPort")] + pub received_via_port: u64, + #[serde(rename = "flags")] + pub flags: Map, + #[serde(rename = "envId")] + pub env_id: Option, + #[serde(rename = "priority")] + pub priority: i64, + #[serde(rename = "size")] + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QueuedRecipient { + #[serde(rename = "retryCount")] + pub retry_count: u64, + #[serde(rename = "retryDue")] + pub retry_due: UTCDateTime, + #[serde(rename = "notifyCount")] + pub notify_count: u64, + #[serde(rename = "notifyDue")] + pub notify_due: UTCDateTime, + #[serde(rename = "expires")] + pub expires: QueueExpiry, + #[serde(rename = "queueName")] + pub queue_name: String, + #[serde(rename = "status")] + pub status: RecipientStatus, + #[serde(rename = "flags")] + pub flags: Map, + #[serde(rename = "orcpt")] + pub orcpt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Rate { + #[serde(rename = "count")] + pub count: u64, + #[serde(rename = "period")] + pub period: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum RecipientStatus { + Scheduled, + Completed(ServerResponse), + TemporaryFailure(DeliveryError), + PermanentFailure(DeliveryError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct RedisClusterStore { + #[serde(rename = "urls")] + pub urls: Map, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "maxRetryWait")] + pub max_retry_wait: Option, + #[serde(rename = "minRetryWait")] + pub min_retry_wait: Option, + #[serde(rename = "maxRetries")] + pub max_retries: Option, + #[serde(rename = "readFromReplicas")] + pub read_from_replicas: bool, + #[serde(rename = "protocolVersion")] + pub protocol_version: RedisProtocol, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: u64, + #[serde(rename = "poolTimeoutCreate")] + pub pool_timeout_create: Option, + #[serde(rename = "poolTimeoutWait")] + pub pool_timeout_wait: Option, + #[serde(rename = "poolTimeoutRecycle")] + pub pool_timeout_recycle: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct RedisSentinelStore { + #[serde(rename = "urls")] + pub urls: Map, + #[serde(rename = "serviceName")] + pub service_name: String, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "authUsername")] + pub auth_username: Option, + #[serde(rename = "authSecret")] + pub auth_secret: SecretKeyOptional, + #[serde(rename = "sentinelUsername")] + pub sentinel_username: Option, + #[serde(rename = "sentinelSecret")] + pub sentinel_secret: SecretKeyOptional, + #[serde(rename = "protocolVersion")] + pub protocol_version: RedisProtocol, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: u64, + #[serde(rename = "poolTimeoutCreate")] + pub pool_timeout_create: Option, + #[serde(rename = "poolTimeoutWait")] + pub pool_timeout_wait: Option, + #[serde(rename = "poolTimeoutRecycle")] + pub pool_timeout_recycle: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct RedisStore { + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: u64, + #[serde(rename = "poolTimeoutCreate")] + pub pool_timeout_create: Option, + #[serde(rename = "poolTimeoutWait")] + pub pool_timeout_wait: Option, + #[serde(rename = "poolTimeoutRecycle")] + pub pool_timeout_recycle: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ReportSettings { + #[serde(rename = "inboundReportAddresses")] + pub inbound_report_addresses: Map, + #[serde(rename = "inboundReportForwarding")] + pub inbound_report_forwarding: bool, + #[serde(rename = "outboundReportDomain")] + pub outbound_report_domain: Option, + #[serde(rename = "outboundReportSubmitter")] + pub outbound_report_submitter: Expression, + #[serde(rename = "inboundReportMaxSize")] + pub inbound_report_max_size: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct RocksDbStore { + #[serde(rename = "path")] + pub path: String, + #[serde(rename = "blobSize")] + pub blob_size: u64, + #[serde(rename = "bufferSize")] + pub buffer_size: u64, + #[serde(rename = "poolWorkers")] + pub pool_workers: Option, + #[serde(rename = "cacheSize")] + pub cache_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Role { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "roleIds")] + pub role_ids: Map, + #[serde(rename = "enabledPermissions")] + pub enabled_permissions: Map, + #[serde(rename = "disabledPermissions")] + pub disabled_permissions: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Roles { + Default, + Custom(CustomRoles), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct S3Store { + #[serde(rename = "region")] + pub region: S3StoreRegion, + #[serde(rename = "bucket")] + pub bucket: String, + #[serde(rename = "accessKey")] + pub access_key: PublicStringOptional, + #[serde(rename = "secretKey")] + pub secret_key: SecretKeyOptional, + #[serde(rename = "securityToken")] + pub security_token: SecretKeyOptional, + #[serde(rename = "sessionToken")] + pub session_token: SecretKeyOptional, + #[serde(rename = "profile")] + pub profile: Option, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "maxRetries")] + pub max_retries: u64, + #[serde(rename = "keyPrefix")] + pub key_prefix: Option, + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "verifyAfterWrite")] + pub verify_after_write: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct S3StoreCustomRegion { + #[serde(rename = "customEndpoint")] + pub custom_endpoint: String, + #[serde(rename = "customRegion")] + pub custom_region: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum S3StoreRegion { + UsEast1, + UsEast2, + UsWest1, + UsWest2, + CaCentral1, + AfSouth1, + ApEast1, + ApSouth1, + ApNortheast1, + ApNortheast2, + ApNortheast3, + ApSoutheast1, + ApSoutheast2, + CnNorth1, + CnNorthwest1, + EuNorth1, + EuCentral1, + EuCentral2, + EuWest1, + EuWest2, + EuWest3, + IlCentral1, + MeSouth1, + SaEast1, + DoNyc3, + DoAms3, + DoSgp1, + DoFra1, + Yandex, + WaUsEast1, + WaUsEast2, + WaUsCentral1, + WaUsWest1, + WaCaCentral1, + WaEuCentral1, + WaEuCentral2, + WaEuWest1, + WaEuWest2, + WaApNortheast1, + WaApNortheast2, + WaApSoutheast1, + WaApSoutheast2, + Custom(S3StoreCustomRegion), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Search { + #[serde(rename = "indexBatchSize")] + pub index_batch_size: u64, + #[serde(rename = "defaultLanguage")] + pub default_language: Locale, + #[serde(rename = "supportedLanguages")] + pub supported_languages: Map, + #[serde(rename = "indexCalendar")] + pub index_calendar: bool, + #[serde(rename = "indexCalendarFields")] + pub index_calendar_fields: Map, + #[serde(rename = "indexContacts")] + pub index_contacts: bool, + #[serde(rename = "indexContactFields")] + pub index_contact_fields: Map, + #[serde(rename = "indexEmail")] + pub index_email: bool, + #[serde(rename = "indexEmailFields")] + pub index_email_fields: Map, + #[serde(rename = "indexTelemetry")] + pub index_telemetry: bool, + #[serde(rename = "indexTracingFields")] + pub index_tracing_fields: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SearchStore { + Default, + ElasticSearch(ElasticSearchStore), + Meilisearch(MeilisearchStore), + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SecondaryCredential { + #[serde(rename = "credentialId")] + pub credential_id: Id, + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "secret")] + pub secret: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "permissions")] + pub permissions: CredentialPermissions, + #[serde(rename = "allowedIps")] + pub allowed_ips: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SecretKey { + Value(SecretKeyValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SecretKeyEnvironmentVariable { + #[serde(rename = "variableName")] + pub variable_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SecretKeyFile { + #[serde(rename = "filePath")] + pub file_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SecretKeyOptional { + None, + Value(SecretKeyValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SecretKeyValue { + #[serde(rename = "secret")] + pub secret: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SecretText { + Text(SecretTextValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SecretTextOptional { + None, + Text(SecretTextValue), + EnvironmentVariable(SecretKeyEnvironmentVariable), + File(SecretKeyFile), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SecretTextValue { + #[serde(rename = "secret")] + pub secret: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Security { + #[serde(rename = "abuseBanRate")] + pub abuse_ban_rate: Option, + #[serde(rename = "abuseBanPeriod")] + pub abuse_ban_period: Option, + #[serde(rename = "authBanRate")] + pub auth_ban_rate: Option, + #[serde(rename = "authBanPeriod")] + pub auth_ban_period: Option, + #[serde(rename = "loiterBanRate")] + pub loiter_ban_rate: Option, + #[serde(rename = "loiterBanPeriod")] + pub loiter_ban_period: Option, + #[serde(rename = "scanBanPaths")] + pub scan_ban_paths: Map, + #[serde(rename = "scanBanRate")] + pub scan_ban_rate: Option, + #[serde(rename = "scanBanPeriod")] + pub scan_ban_period: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SenderAuth { + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, + #[serde(rename = "dkimStrict")] + pub dkim_strict: bool, + #[serde(rename = "dkimVerify")] + pub dkim_verify: Expression, + #[serde(rename = "spfEhloVerify")] + pub spf_ehlo_verify: Expression, + #[serde(rename = "spfFromVerify")] + pub spf_from_verify: Expression, + #[serde(rename = "arcVerify")] + pub arc_verify: Expression, + #[serde(rename = "dmarcVerify")] + pub dmarc_verify: Expression, + #[serde(rename = "reverseIpVerify")] + pub reverse_ip_verify: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ServerResponse { + #[serde(rename = "responseHostname")] + pub response_hostname: Option, + #[serde(rename = "responseCode")] + pub response_code: Option, + #[serde(rename = "responseEnhanced")] + pub response_enhanced: Option, + #[serde(rename = "responseMessage")] + pub response_message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Service { + #[serde(rename = "hostname")] + pub hostname: Option, + #[serde(rename = "cleartext")] + pub cleartext: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShardedBlobStore { + #[serde(rename = "stores")] + pub stores: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShardedInMemoryStore { + #[serde(rename = "stores")] + pub stores: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Sharing { + #[serde(rename = "allowDirectoryQueries")] + pub allow_directory_queries: bool, + #[serde(rename = "maxShares")] + pub max_shares: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SieveSystemInterpreter { + #[serde(rename = "defaultFromAddress")] + pub default_from_address: Expression, + #[serde(rename = "defaultFromName")] + pub default_from_name: Expression, + #[serde(rename = "messageIdHostname")] + pub message_id_hostname: Option, + #[serde(rename = "duplicateExpiry")] + pub duplicate_expiry: Duration, + #[serde(rename = "noCapabilityCheck")] + pub no_capability_check: bool, + #[serde(rename = "defaultReturnPath")] + pub default_return_path: Expression, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, + #[serde(rename = "maxCpuCycles")] + pub max_cpu_cycles: u64, + #[serde(rename = "maxNestedIncludes")] + pub max_nested_includes: u64, + #[serde(rename = "maxOutMessages")] + pub max_out_messages: u64, + #[serde(rename = "maxReceivedHeaders")] + pub max_received_headers: u64, + #[serde(rename = "maxRedirects")] + pub max_redirects: u64, + #[serde(rename = "maxVarSize")] + pub max_var_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SieveSystemScript { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "isActive")] + pub is_active: bool, + #[serde(rename = "contents")] + pub contents: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SieveUserInterpreter { + #[serde(rename = "defaultExpiryDuplicate")] + pub default_expiry_duplicate: Duration, + #[serde(rename = "defaultExpiryVacation")] + pub default_expiry_vacation: Duration, + #[serde(rename = "disableCapabilities")] + pub disable_capabilities: Map, + #[serde(rename = "allowedNotifyUris")] + pub allowed_notify_uris: Map, + #[serde(rename = "protectedHeaders")] + pub protected_headers: Map, + #[serde(rename = "defaultSubject")] + pub default_subject: String, + #[serde(rename = "defaultSubjectPrefix")] + pub default_subject_prefix: String, + #[serde(rename = "maxCpuCycles")] + pub max_cpu_cycles: u64, + #[serde(rename = "maxHeaderSize")] + pub max_header_size: u64, + #[serde(rename = "maxIncludes")] + pub max_includes: u64, + #[serde(rename = "maxLocalVars")] + pub max_local_vars: u64, + #[serde(rename = "maxMatchVars")] + pub max_match_vars: u64, + #[serde(rename = "maxScriptNameLength")] + pub max_script_name_length: u64, + #[serde(rename = "maxNestedBlocks")] + pub max_nested_blocks: u64, + #[serde(rename = "maxNestedForEvery")] + pub max_nested_for_every: u64, + #[serde(rename = "maxNestedIncludes")] + pub max_nested_includes: u64, + #[serde(rename = "maxNestedTests")] + pub max_nested_tests: u64, + #[serde(rename = "maxOutMessages")] + pub max_out_messages: u64, + #[serde(rename = "maxReceivedHeaders")] + pub max_received_headers: u64, + #[serde(rename = "maxRedirects")] + pub max_redirects: u64, + #[serde(rename = "maxScriptSize")] + pub max_script_size: u64, + #[serde(rename = "maxStringLength")] + pub max_string_length: u64, + #[serde(rename = "maxVarNameLength")] + pub max_var_name_length: u64, + #[serde(rename = "maxVarSize")] + pub max_var_size: u64, + #[serde(rename = "maxScripts")] + pub max_scripts: Option, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SieveUserScript { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "isActive")] + pub is_active: bool, + #[serde(rename = "contents")] + pub contents: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamClassifier { + #[serde(rename = "model")] + pub model: SpamClassifierModel, + #[serde(rename = "learnHamFromCard")] + pub learn_ham_from_card: bool, + #[serde(rename = "learnSpamFromRblHits")] + pub learn_spam_from_rbl_hits: u64, + #[serde(rename = "learnSpamFromTraps")] + pub learn_spam_from_traps: bool, + #[serde(rename = "holdSamplesFor")] + pub hold_samples_for: Duration, + #[serde(rename = "minHamSamples")] + pub min_ham_samples: u64, + #[serde(rename = "minSpamSamples")] + pub min_spam_samples: u64, + #[serde(rename = "reservoirCapacity")] + pub reservoir_capacity: u64, + #[serde(rename = "trainFrequency")] + pub train_frequency: Option, + #[serde(rename = "learnHamFromReply")] + pub learn_ham_from_reply: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamClassifierFtrlCcfh { + #[serde(rename = "indicatorParameters")] + pub indicator_parameters: FtrlParameters, + #[serde(rename = "parameters")] + pub parameters: FtrlParameters, + #[serde(rename = "featureL2Normalize")] + pub feature_l2_normalize: bool, + #[serde(rename = "featureLogScale")] + pub feature_log_scale: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamClassifierFtrlFh { + #[serde(rename = "parameters")] + pub parameters: FtrlParameters, + #[serde(rename = "featureL2Normalize")] + pub feature_l2_normalize: bool, + #[serde(rename = "featureLogScale")] + pub feature_log_scale: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SpamClassifierModel { + FtrlFh(SpamClassifierFtrlFh), + FtrlCcfh(SpamClassifierFtrlCcfh), + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamClassify { + #[serde(rename = "message")] + pub message: String, + #[serde(rename = "remoteIp")] + pub remote_ip: IpAddr, + #[serde(rename = "ehloDomain")] + pub ehlo_domain: String, + #[serde(rename = "authenticatedAs")] + pub authenticated_as: Option, + #[serde(rename = "isTls")] + pub is_tls: bool, + #[serde(rename = "envFrom")] + pub env_from: String, + #[serde(rename = "envFromParameters")] + pub env_from_parameters: Option, + #[serde(rename = "envRcptTo")] + pub env_rcpt_to: Map, + #[serde(rename = "score")] + pub score: Float, + #[serde(rename = "tags")] + pub tags: VecMap, + #[serde(rename = "result")] + pub result: SpamClassifyResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamClassifyTag { + #[serde(rename = "score")] + pub score: Float, + #[serde(rename = "disposition")] + pub disposition: SpamClassifyTagDisposition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SpamDnsblServer { + Any(SpamDnsblServerAny), + Url(SpamDnsblServerUrl), + Domain(SpamDnsblServerDomain), + Email(SpamDnsblServerEmail), + Ip(SpamDnsblServerIp), + Header(SpamDnsblServerHeader), + Body(SpamDnsblServerBody), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerAny { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerBody { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerDomain { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerEmail { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerHeader { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerIp { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblServerUrl { + #[serde(rename = "tag")] + pub tag: Expression, + #[serde(rename = "zone")] + pub zone: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamDnsblSettings { + #[serde(rename = "domainLimit")] + pub domain_limit: u64, + #[serde(rename = "emailLimit")] + pub email_limit: u64, + #[serde(rename = "ipLimit")] + pub ip_limit: u64, + #[serde(rename = "urlLimit")] + pub url_limit: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamFileExtension { + #[serde(rename = "extension")] + pub extension: String, + #[serde(rename = "isArchive")] + pub is_archive: bool, + #[serde(rename = "isBad")] + pub is_bad: bool, + #[serde(rename = "isNz")] + pub is_nz: bool, + #[serde(rename = "contentTypes")] + pub content_types: Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SpamLlm { + Disable, + Enable(SpamLlmProperties), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamLlmProperties { + #[serde(rename = "categories")] + pub categories: Map, + #[serde(rename = "confidence")] + pub confidence: Map, + #[serde(rename = "responsePosCategory")] + pub response_pos_category: u64, + #[serde(rename = "responsePosConfidence")] + pub response_pos_confidence: Option, + #[serde(rename = "responsePosExplanation")] + pub response_pos_explanation: Option, + #[serde(rename = "modelId")] + pub model_id: Id, + #[serde(rename = "prompt")] + pub prompt: String, + #[serde(rename = "separator")] + pub separator: String, + #[serde(rename = "temperature")] + pub temperature: Float, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamPyzor { + #[serde(rename = "blockCount")] + pub block_count: u64, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "host")] + pub host: String, + #[serde(rename = "port")] + pub port: u64, + #[serde(rename = "ratio")] + pub ratio: Float, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "allowCount")] + pub allow_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SpamRule { + Any(SpamRuleAny), + Url(SpamRuleUrl), + Domain(SpamRuleDomain), + Email(SpamRuleEmail), + Ip(SpamRuleIp), + Header(SpamRuleHeader), + Body(SpamRuleBody), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleAny { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleBody { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleDomain { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleEmail { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleHeader { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleIp { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamRuleUrl { + #[serde(rename = "condition")] + pub condition: Expression, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "priority")] + pub priority: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamSettings { + #[serde(rename = "trustContacts")] + pub trust_contacts: bool, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "greylistFor")] + pub greylist_for: Option, + #[serde(rename = "scoreDiscard")] + pub score_discard: Float, + #[serde(rename = "scoreReject")] + pub score_reject: Float, + #[serde(rename = "scoreSpam")] + pub score_spam: Float, + #[serde(rename = "trustReplies")] + pub trust_replies: bool, + #[serde(rename = "spamFilterRulesUrl")] + pub spam_filter_rules_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SpamTag { + Score(SpamTagScore), + Discard(SpamTagAction), + Reject(SpamTagAction), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamTagAction { + #[serde(rename = "tag")] + pub tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamTagScore { + #[serde(rename = "tag")] + pub tag: String, + #[serde(rename = "score")] + pub score: Float, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpamTrainingSample { + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "blobId")] + pub blob_id: BlobId, + #[serde(rename = "isSpam")] + pub is_spam: bool, + #[serde(rename = "accountId")] + pub account_id: Option, + #[serde(rename = "expiresAt")] + pub expires_at: UTCDateTime, + #[serde(rename = "deleteAfterUse")] + pub delete_after_use: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SpfReportSettings { + #[serde(rename = "fromAddress")] + pub from_address: Expression, + #[serde(rename = "fromName")] + pub from_name: Expression, + #[serde(rename = "sendFrequency")] + pub send_frequency: Expression, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, + #[serde(rename = "subject")] + pub subject: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SqlAuthStore { + Default, + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), + Sqlite(SqliteStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SqlDirectory { + #[serde(rename = "description")] + pub description: String, + #[serde(rename = "store")] + pub store: SqlAuthStore, + #[serde(rename = "columnEmail")] + pub column_email: String, + #[serde(rename = "columnSecret")] + pub column_secret: String, + #[serde(rename = "columnClass")] + pub column_class: Option, + #[serde(rename = "columnDescription")] + pub column_description: Option, + #[serde(rename = "queryLogin")] + pub query_login: String, + #[serde(rename = "queryRecipient")] + pub query_recipient: String, + #[serde(rename = "queryMemberOf")] + pub query_member_of: Option, + #[serde(rename = "queryEmailAliases")] + pub query_email_aliases: Option, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SqliteStore { + #[serde(rename = "path")] + pub path: String, + #[serde(rename = "poolWorkers")] + pub pool_workers: Option, + #[serde(rename = "poolMaxConnections")] + pub pool_max_connections: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct StoreLookup { + #[serde(rename = "namespace")] + pub namespace: String, + #[serde(rename = "store")] + pub store: LookupStore, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum SubAddressing { + Enabled, + Custom(SubAddressingCustom), + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SubAddressingCustom { + #[serde(rename = "customRule")] + pub custom_rule: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SystemSettings { + #[serde(rename = "defaultHostname")] + pub default_hostname: String, + #[serde(rename = "defaultDomainId")] + pub default_domain_id: Id, + #[serde(rename = "defaultCertificateId")] + pub default_certificate_id: Option, + #[serde(rename = "threadPoolSize")] + pub thread_pool_size: Option, + #[serde(rename = "maxConnections")] + pub max_connections: u64, + #[serde(rename = "proxyTrustedNetworks")] + pub proxy_trusted_networks: Map, + #[serde(rename = "mailExchangers")] + pub mail_exchangers: List, + #[serde(rename = "services")] + pub services: VecMap, + #[serde(rename = "providerInfo")] + pub provider_info: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Task { + IndexDocument(TaskIndexDocument), + UnindexDocument(TaskIndexDocument), + IndexTrace(TaskIndexTrace), + CalendarAlarmEmail(TaskCalendarAlarmEmail), + CalendarAlarmNotification(TaskCalendarAlarmNotification), + CalendarItipMessage(TaskCalendarItipMessage), + MergeThreads(TaskMergeThreads), + DmarcReport(TaskDmarcReport), + TlsReport(TaskTlsReport), + RestoreArchivedItem(TaskRestoreArchivedItem), + DestroyAccount(TaskDestroyAccount), + AccountMaintenance(TaskAccountMaintenance), + TenantMaintenance(TaskTenantMaintenance), + StoreMaintenance(TaskStoreMaintenance), + SpamFilterMaintenance(TaskSpamFilterMaintenance), + AcmeRenewal(TaskDomainManagement), + DkimManagement(TaskDomainManagement), + DnsManagement(TaskDnsManagement), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskAccountMaintenance { + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "maintenanceType")] + pub maintenance_type: TaskAccountMaintenanceType, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskCalendarAlarmEmail { + #[serde(rename = "alarmId")] + pub alarm_id: u64, + #[serde(rename = "eventId")] + pub event_id: u64, + #[serde(rename = "eventStart")] + pub event_start: UTCDateTime, + #[serde(rename = "eventEnd")] + pub event_end: UTCDateTime, + #[serde(rename = "eventStartTz")] + pub event_start_tz: u64, + #[serde(rename = "eventEndTz")] + pub event_end_tz: u64, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "documentId")] + pub document_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskCalendarAlarmNotification { + #[serde(rename = "alarmId")] + pub alarm_id: u64, + #[serde(rename = "eventId")] + pub event_id: u64, + #[serde(rename = "recurrenceId")] + pub recurrence_id: Option, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "documentId")] + pub document_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskCalendarItipContents { + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "to")] + pub to: Map, + #[serde(rename = "isFromOrganizer")] + pub is_from_organizer: bool, + #[serde(rename = "iCalendarData")] + pub i_calendar_data: String, + #[serde(rename = "summary")] + pub summary: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskCalendarItipMessage { + #[serde(rename = "messages")] + pub messages: List, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "documentId")] + pub document_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskDestroyAccount { + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "accountName")] + pub account_name: String, + #[serde(rename = "accountDomainId")] + pub account_domain_id: Id, + #[serde(rename = "accountType")] + pub account_type: AccountType, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskDmarcReport { + #[serde(rename = "reportId")] + pub report_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskDnsManagement { + #[serde(rename = "updateRecords")] + pub update_records: Map, + #[serde(rename = "onSuccessRenewCertificate")] + pub on_success_renew_certificate: bool, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskDomainManagement { + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskIndexDocument { + #[serde(rename = "documentType")] + pub document_type: IndexDocumentType, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "documentId")] + pub document_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskIndexTrace { + #[serde(rename = "traceId")] + pub trace_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskManager { + #[serde(rename = "maxAttempts")] + pub max_attempts: u64, + #[serde(rename = "strategy")] + pub strategy: TaskRetryStrategy, + #[serde(rename = "totalDeadline")] + pub total_deadline: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskMergeThreads { + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "threadName")] + pub thread_name: String, + #[serde(rename = "messageIds")] + pub message_ids: Map, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskRestoreArchivedItem { + #[serde(rename = "blobId")] + pub blob_id: BlobId, + #[serde(rename = "archivedItemType")] + pub archived_item_type: ArchivedItemType, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "archivedUntil")] + pub archived_until: UTCDateTime, + #[serde(rename = "accountId")] + pub account_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum TaskRetryStrategy { + ExponentialBackoff(TaskRetryStrategyBackoff), + FixedDelay(TaskRetryStrategyFixed), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskRetryStrategyBackoff { + #[serde(rename = "factor")] + pub factor: Float, + #[serde(rename = "initialDelay")] + pub initial_delay: Duration, + #[serde(rename = "maxDelay")] + pub max_delay: Duration, + #[serde(rename = "jitter")] + pub jitter: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskRetryStrategyFixed { + #[serde(rename = "delay")] + pub delay: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskSpamFilterMaintenance { + #[serde(rename = "maintenanceType")] + pub maintenance_type: TaskSpamFilterMaintenanceType, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum TaskStatus { + Pending(TaskStatusPending), + Retry(TaskStatusRetry), + Failed(TaskStatusFailed), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskStatusFailed { + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "failedAt")] + pub failed_at: UTCDateTime, + #[serde(rename = "failedAttemptNumber")] + pub failed_attempt_number: u64, + #[serde(rename = "failureReason")] + pub failure_reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskStatusPending { + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "due")] + pub due: UTCDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskStatusRetry { + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "due")] + pub due: UTCDateTime, + #[serde(rename = "attemptNumber")] + pub attempt_number: u64, + #[serde(rename = "failureReason")] + pub failure_reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskStoreMaintenance { + #[serde(rename = "maintenanceType")] + pub maintenance_type: TaskStoreMaintenanceType, + #[serde(rename = "shardIndex")] + pub shard_index: Option, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskTenantMaintenance { + #[serde(rename = "tenantId")] + pub tenant_id: Id, + #[serde(rename = "maintenanceType")] + pub maintenance_type: TaskTenantMaintenanceType, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TaskTlsReport { + #[serde(rename = "reportId")] + pub report_id: Id, + #[serde(rename = "status")] + pub status: TaskStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Tenant { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "logo")] + pub logo: Option, + #[serde(rename = "roles")] + pub roles: Roles, + #[serde(rename = "permissions")] + pub permissions: Permissions, + #[serde(rename = "quotas")] + pub quotas: VecMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsExternalReport { + #[serde(rename = "report")] + pub report: TlsReport, + #[serde(rename = "from")] + pub from: String, + #[serde(rename = "subject")] + pub subject: String, + #[serde(rename = "to")] + pub to: Map, + #[serde(rename = "receivedAt")] + pub received_at: UTCDateTime, + #[serde(rename = "expiresAt")] + pub expires_at: UTCDateTime, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsFailureDetails { + #[serde(rename = "resultType")] + pub result_type: TlsResultType, + #[serde(rename = "sendingMtaIp")] + pub sending_mta_ip: Option, + #[serde(rename = "receivingMxHostname")] + pub receiving_mx_hostname: Option, + #[serde(rename = "receivingMxHelo")] + pub receiving_mx_helo: Option, + #[serde(rename = "receivingIp")] + pub receiving_ip: Option, + #[serde(rename = "failedSessionCount")] + pub failed_session_count: u64, + #[serde(rename = "additionalInformation")] + pub additional_information: Option, + #[serde(rename = "failureReasonCode")] + pub failure_reason_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsInternalReport { + #[serde(rename = "policyIdentifiers")] + pub policy_identifiers: Map, + #[serde(rename = "mailRua")] + pub mail_rua: Map, + #[serde(rename = "httpRua")] + pub http_rua: Map, + #[serde(rename = "report")] + pub report: TlsReport, + #[serde(rename = "domain")] + pub domain: String, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "deliverAt")] + pub deliver_at: UTCDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsReport { + #[serde(rename = "organizationName")] + pub organization_name: Option, + #[serde(rename = "contactInfo")] + pub contact_info: Option, + #[serde(rename = "reportId")] + pub report_id: String, + #[serde(rename = "dateRangeStart")] + pub date_range_start: UTCDateTime, + #[serde(rename = "dateRangeEnd")] + pub date_range_end: UTCDateTime, + #[serde(rename = "policies")] + pub policies: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsReportPolicy { + #[serde(rename = "policyType")] + pub policy_type: TlsPolicyType, + #[serde(rename = "policyStrings")] + pub policy_strings: Map, + #[serde(rename = "policyDomain")] + pub policy_domain: String, + #[serde(rename = "mxHosts")] + pub mx_hosts: Map, + #[serde(rename = "totalSuccessfulSessions")] + pub total_successful_sessions: u64, + #[serde(rename = "totalFailedSessions")] + pub total_failed_sessions: u64, + #[serde(rename = "failureDetails")] + pub failure_details: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TlsReportSettings { + #[serde(rename = "contactInfo")] + pub contact_info: Expression, + #[serde(rename = "fromAddress")] + pub from_address: Expression, + #[serde(rename = "fromName")] + pub from_name: Expression, + #[serde(rename = "maxReportSize")] + pub max_report_size: Expression, + #[serde(rename = "orgName")] + pub org_name: Expression, + #[serde(rename = "sendFrequency")] + pub send_frequency: Expression, + #[serde(rename = "dkimSignDomain")] + pub dkim_sign_domain: Expression, + #[serde(rename = "subject")] + pub subject: Expression, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Trace { + #[serde(rename = "events")] + pub events: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceEvent { + #[serde(rename = "event")] + pub event: trc::EventType, + #[serde(rename = "timestamp")] + pub timestamp: UTCDateTime, + #[serde(rename = "keyValues")] + pub key_values: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceKeyValue { + #[serde(rename = "key")] + pub key: trc::Key, + #[serde(rename = "value")] + pub value: TraceValue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum TraceValue { + String(TraceValueString), + UnsignedInt(TraceValueUnsignedInt), + Integer(TraceValueInteger), + Boolean(TraceValueBoolean), + Float(TraceValueFloat), + UTCDateTime(TraceValueUTCDateTime), + Duration(TraceValueDuration), + IpAddr(TraceValueIpAddr), + List(TraceValueList), + Event(TraceValueEvent), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueBoolean { + #[serde(rename = "value")] + pub value: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueDuration { + #[serde(rename = "value")] + pub value: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueEvent { + #[serde(rename = "event")] + pub event: trc::EventType, + #[serde(rename = "value")] + pub value: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueFloat { + #[serde(rename = "value")] + pub value: Float, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueInteger { + #[serde(rename = "value")] + pub value: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueIpAddr { + #[serde(rename = "value")] + pub value: IpAddr, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueList { + #[serde(rename = "value")] + pub value: List, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueString { + #[serde(rename = "value")] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueUTCDateTime { + #[serde(rename = "value")] + pub value: UTCDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TraceValueUnsignedInt { + #[serde(rename = "value")] + pub value: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum Tracer { + Log(TracerLog), + Stdout(TracerStdout), + Journal(TracerCommon), + OtelHttp(TracerOtelHttp), + OtelGrpc(TracerOtelGrpc), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracerCommon { + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracerLog { + #[serde(rename = "path")] + pub path: String, + #[serde(rename = "prefix")] + pub prefix: String, + #[serde(rename = "rotate")] + pub rotate: LogRotateFrequency, + #[serde(rename = "ansi")] + pub ansi: bool, + #[serde(rename = "multiline")] + pub multiline: bool, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracerOtelGrpc { + #[serde(rename = "endpoint")] + pub endpoint: Option, + #[serde(rename = "enableLogExporter")] + pub enable_log_exporter: bool, + #[serde(rename = "enableSpanExporter")] + pub enable_span_exporter: bool, + #[serde(rename = "throttle")] + pub throttle: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracerOtelHttp { + #[serde(rename = "endpoint")] + pub endpoint: String, + #[serde(rename = "enableLogExporter")] + pub enable_log_exporter: bool, + #[serde(rename = "enableSpanExporter")] + pub enable_span_exporter: bool, + #[serde(rename = "throttle")] + pub throttle: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracerStdout { + #[serde(rename = "buffered")] + pub buffered: bool, + #[serde(rename = "ansi")] + pub ansi: bool, + #[serde(rename = "multiline")] + pub multiline: bool, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum TracingStore { + Disabled, + Default, + FoundationDb(FoundationDbStore), + PostgreSql(PostgreSqlStore), + MySql(MySqlStore), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct UserAccount { + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "domainId")] + pub domain_id: Id, + #[serde(rename = "credentials")] + pub credentials: List, + #[serde(rename = "createdAt")] + pub created_at: UTCDateTime, + #[serde(rename = "memberGroupIds")] + pub member_group_ids: Map, + #[serde(rename = "memberTenantId")] + pub member_tenant_id: Option, + #[serde(rename = "roles")] + pub roles: UserRoles, + #[serde(rename = "permissions")] + pub permissions: Permissions, + #[serde(rename = "quotas")] + pub quotas: VecMap, + #[serde(rename = "aliases")] + pub aliases: List, + #[serde(rename = "externalId")] + pub external_id: Option, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "locale")] + pub locale: Locale, + #[serde(rename = "timeZone")] + pub time_zone: Option, + #[serde(rename = "encryptionAtRest")] + pub encryption_at_rest: EncryptionAtRest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum UserRoles { + User, + Admin, + Custom(CustomRoles), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebDav { + #[serde(rename = "enableAssistedDiscovery")] + pub enable_assisted_discovery: bool, + #[serde(rename = "maxLockTimeout")] + pub max_lock_timeout: Duration, + #[serde(rename = "maxLocks")] + pub max_locks: u64, + #[serde(rename = "deadPropertyMaxSize")] + pub dead_property_max_size: Option, + #[serde(rename = "livePropertyMaxSize")] + pub live_property_max_size: u64, + #[serde(rename = "requestMaxSize")] + pub request_max_size: u64, + #[serde(rename = "maxResults")] + pub max_results: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebHook { + #[serde(rename = "allowInvalidCerts")] + pub allow_invalid_certs: bool, + #[serde(rename = "signatureKey")] + pub signature_key: SecretKeyOptional, + #[serde(rename = "throttle")] + pub throttle: Duration, + #[serde(rename = "timeout")] + pub timeout: Duration, + #[serde(rename = "discardAfter")] + pub discard_after: Duration, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "httpAuth")] + pub http_auth: HttpAuth, + #[serde(rename = "httpHeaders")] + pub http_headers: VecMap, + #[serde(rename = "enable")] + pub enable: bool, + #[serde(rename = "level")] + pub level: TracingLevel, + #[serde(rename = "lossy")] + pub lossy: bool, + #[serde(rename = "events")] + pub events: Map, + #[serde(rename = "eventsPolicy")] + pub events_policy: EventPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ZenohCoordinator { + #[serde(rename = "config")] + pub config: String, +} diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs new file mode 100644 index 0000000..2596655 --- /dev/null +++ b/crates/registry/src/schema/structs_impl.rs @@ -0,0 +1,47468 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::schema::prelude::*; + +impl ObjectImpl for Account { + const FLAGS: u64 = OBJ_FILTER_TENANT | OBJ_SEQ_ID; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Account; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Account::User(inner) => inner.validate(errors), + Account::Group(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + Account::User(object) => { + i.typ(0); + object.index(i); + } + Account::Group(object) => { + i.typ(1); + object.index(i); + } + } + } +} + +impl Default for Account { + fn default() -> Self { + Account::User(Default::default()) + } +} + +impl Pickle for Account { + fn pickle(&self, out: &mut Vec) { + match self { + Account::User(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Account::Group(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Account::User), + 1 => Pickle::unpickle(stream).map(Account::Group), + _ => None, + } + } +} + +impl IntoValue for Account { + fn into_value(self) -> JmapValue<'static> { + match self { + Account::User(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("User".into())); + obj + } + Account::Group(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Group".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Account { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + AccountType::User => *self = Account::User(Default::default()), + AccountType::Group => *self = Account::Group(Default::default()), + } + } + match self { + Account::User(inner) => inner.patch(pointer, value), + Account::Group(inner) => inner.patch(pointer, value), + } + } +} + +impl Account { + pub fn object_type(&self) -> AccountType { + match self { + Account::User(_) => AccountType::User, + Account::Group(_) => AccountType::Group, + } + } +} + +impl ObjectImpl for AccountPassword { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::AccountPassword; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.secret { + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + } + if let Some(value) = &self.current_secret { + if value.is_empty() { + errors.push(ValidationError::required(Property::CurrentSecret)); + } + } + let value = &self.otp_auth; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for AccountPassword { + fn pickle(&self, out: &mut Vec) { + self.secret.pickle(out); + self.current_secret.pickle(out); + self.otp_auth.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.secret = Pickle::unpickle(stream)?; + this.current_secret = Pickle::unpickle(stream)?; + this.otp_auth = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AccountPassword { + fn default() -> Self { + Self { + secret: Default::default(), + current_secret: Default::default(), + otp_auth: Default::default(), + } + } +} + +impl IntoValue for AccountPassword { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + if self.secret.is_some() { + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + } + if self.current_secret.is_some() { + map.insert_unchecked( + Property::CurrentSecret, + JmapValue::Str(MASKED_PASSWORD.into()), + ); + } + map.insert_unchecked(Property::OtpAuth, self.otp_auth.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AccountPassword { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::CurrentSecret) => self.current_secret.patch(pointer, value), + Some(Property::OtpAuth) => self.otp_auth.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for AccountSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::AccountSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.encryption_at_rest; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + self.encryption_at_rest.index(i); + } +} + +impl Pickle for AccountSettings { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.locale.pickle(out); + self.time_zone.pickle(out); + self.encryption_at_rest.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.locale = Pickle::unpickle(stream)?; + this.time_zone = Pickle::unpickle(stream)?; + this.encryption_at_rest = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AccountSettings { + fn default() -> Self { + Self { + description: Default::default(), + locale: Locale::EnUS, + time_zone: Default::default(), + encryption_at_rest: Default::default(), + } + } +} + +impl IntoValue for AccountSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Locale, self.locale.into_value()); + map.insert_unchecked(Property::TimeZone, self.time_zone.into_value()); + map.insert_unchecked( + Property::EncryptionAtRest, + self.encryption_at_rest.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AccountSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Locale) => self.locale.patch(pointer, value), + Some(Property::TimeZone) => self.time_zone.patch(pointer, value), + Some(Property::EncryptionAtRest) => self.encryption_at_rest.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for AcmeProvider { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 2; + const OBJECT: ObjectType = ObjectType::AcmeProvider; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.contact; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Contact)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Contact, 1)); + } + let value = &self.directory; + if value.is_empty() { + errors.push(ValidationError::required(Property::Directory)); + } + let value = &self.account_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccountKey)); + } + let value = &self.account_uri; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccountUri)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + if let Some(value) = &self.preferred_chain { + if value.is_empty() { + errors.push(ValidationError::required(Property::PreferredChain)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + for value in self.contact.iter() { + i.text(Property::Text, value); + } + i.text(Property::Text, &self.directory); + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for AcmeProvider { + fn pickle(&self, out: &mut Vec) { + self.challenge_type.pickle(out); + self.contact.pickle(out); + self.directory.pickle(out); + self.account_key.pickle(out); + self.account_uri.pickle(out); + self.renew_before.pickle(out); + self.max_retries.pickle(out); + self.member_tenant_id.pickle(out); + self.preferred_chain.pickle(out); + self.reuse_key.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.challenge_type = Pickle::unpickle(stream)?; + this.contact = Pickle::unpickle(stream)?; + this.directory = Pickle::unpickle(stream)?; + this.account_key = Pickle::unpickle(stream)?; + this.account_uri = Pickle::unpickle(stream)?; + this.renew_before = Pickle::unpickle(stream)?; + this.max_retries = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.preferred_chain = Pickle::unpickle(stream)?; + } + if stream.version() >= 2 { + this.reuse_key = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for AcmeProvider { + fn default() -> Self { + Self { + challenge_type: AcmeChallengeType::TlsAlpn01, + contact: Default::default(), + directory: "https://acme-v02.api.letsencrypt.org/directory".to_string(), + account_key: Default::default(), + account_uri: Default::default(), + renew_before: AcmeRenewBefore::R23, + max_retries: 10i64, + member_tenant_id: Default::default(), + preferred_chain: Default::default(), + reuse_key: false, + } + } +} + +impl IntoValue for AcmeProvider { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::ChallengeType, self.challenge_type.into_value()); + map.insert_unchecked(Property::Contact, self.contact.into_value()); + map.insert_unchecked(Property::Directory, self.directory.into_value()); + map.insert_unchecked(Property::AccountKey, JmapValue::Str(MASKED_PASSWORD.into())); + map.insert_unchecked(Property::AccountUri, self.account_uri.into_value()); + map.insert_unchecked(Property::RenewBefore, self.renew_before.into_value()); + map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::PreferredChain, self.preferred_chain.into_value()); + map.insert_unchecked(Property::ReuseKey, self.reuse_key.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AcmeProvider { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ChallengeType) => self.challenge_type.patch(pointer, value), + Some(Property::Contact) => self + .contact + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Directory) => self.directory.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Trim]), + value, + ), + Some(property @ Property::EabHmacKey) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(property @ Property::EabKeyId) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(Property::AccountKey) => pointer.assert_server_set(), + Some(Property::AccountUri) => pointer.assert_server_set(), + Some(Property::RenewBefore) => self.renew_before.patch(pointer, value), + Some(Property::MaxRetries) => self.max_retries.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::PreferredChain) => self + .preferred_chain + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ReuseKey) => self.reuse_key.patch(pointer, value), + Some(Property::Description) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Action { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Action; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Action::ReloadSettings => true, + Action::ReloadTlsCertificates => true, + Action::ReloadLookupStores => true, + Action::ReloadBlockedIps => true, + Action::UpdateApps => true, + Action::TroubleshootDmarc(inner) => inner.validate(errors), + Action::ClassifySpam(inner) => inner.validate(errors), + Action::InvalidateCaches => true, + Action::InvalidateNegativeCaches => true, + Action::PauseMtaQueue => true, + Action::ResumeMtaQueue => true, + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for Action { + fn default() -> Self { + Action::ReloadSettings + } +} + +impl Pickle for Action { + fn pickle(&self, out: &mut Vec) { + match self { + Action::ReloadSettings => { + 0u16.pickle(out); + } + Action::ReloadTlsCertificates => { + 1u16.pickle(out); + } + Action::ReloadLookupStores => { + 2u16.pickle(out); + } + Action::ReloadBlockedIps => { + 3u16.pickle(out); + } + Action::UpdateApps => { + 4u16.pickle(out); + } + Action::TroubleshootDmarc(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + Action::ClassifySpam(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + Action::InvalidateCaches => { + 7u16.pickle(out); + } + Action::InvalidateNegativeCaches => { + 8u16.pickle(out); + } + Action::PauseMtaQueue => { + 9u16.pickle(out); + } + Action::ResumeMtaQueue => { + 10u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(Action::ReloadSettings), + 1 => Some(Action::ReloadTlsCertificates), + 2 => Some(Action::ReloadLookupStores), + 3 => Some(Action::ReloadBlockedIps), + 4 => Some(Action::UpdateApps), + 5 => Pickle::unpickle(stream).map(Action::TroubleshootDmarc), + 6 => Pickle::unpickle(stream).map(Action::ClassifySpam), + 7 => Some(Action::InvalidateCaches), + 8 => Some(Action::InvalidateNegativeCaches), + 9 => Some(Action::PauseMtaQueue), + 10 => Some(Action::ResumeMtaQueue), + _ => None, + } + } +} + +impl IntoValue for Action { + fn into_value(self) -> JmapValue<'static> { + match self { + Action::ReloadSettings => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ReloadSettings".into())); + JmapValue::Object(obj) + } + Action::ReloadTlsCertificates => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked( + Property::Type, + JmapValue::Str("ReloadTlsCertificates".into()), + ); + JmapValue::Object(obj) + } + Action::ReloadLookupStores => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ReloadLookupStores".into())); + JmapValue::Object(obj) + } + Action::ReloadBlockedIps => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ReloadBlockedIps".into())); + JmapValue::Object(obj) + } + Action::UpdateApps => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("UpdateApps".into())); + JmapValue::Object(obj) + } + Action::TroubleshootDmarc(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TroubleshootDmarc".into())); + obj + } + Action::ClassifySpam(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ClassifySpam".into())); + obj + } + Action::InvalidateCaches => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("InvalidateCaches".into())); + JmapValue::Object(obj) + } + Action::InvalidateNegativeCaches => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked( + Property::Type, + JmapValue::Str("InvalidateNegativeCaches".into()), + ); + JmapValue::Object(obj) + } + Action::PauseMtaQueue => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("PauseMtaQueue".into())); + JmapValue::Object(obj) + } + Action::ResumeMtaQueue => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ResumeMtaQueue".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for Action { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + ActionType::ReloadSettings => *self = Action::ReloadSettings, + ActionType::ReloadTlsCertificates => *self = Action::ReloadTlsCertificates, + ActionType::ReloadLookupStores => *self = Action::ReloadLookupStores, + ActionType::ReloadBlockedIps => *self = Action::ReloadBlockedIps, + ActionType::UpdateApps => *self = Action::UpdateApps, + ActionType::TroubleshootDmarc => { + *self = Action::TroubleshootDmarc(Default::default()) + } + ActionType::ClassifySpam => *self = Action::ClassifySpam(Default::default()), + ActionType::InvalidateCaches => *self = Action::InvalidateCaches, + ActionType::InvalidateNegativeCaches => *self = Action::InvalidateNegativeCaches, + ActionType::PauseMtaQueue => *self = Action::PauseMtaQueue, + ActionType::ResumeMtaQueue => *self = Action::ResumeMtaQueue, + } + } + match self { + Action::ReloadSettings => pointer.assert_eof(), + Action::ReloadTlsCertificates => pointer.assert_eof(), + Action::ReloadLookupStores => pointer.assert_eof(), + Action::ReloadBlockedIps => pointer.assert_eof(), + Action::UpdateApps => pointer.assert_eof(), + Action::TroubleshootDmarc(inner) => inner.patch(pointer, value), + Action::ClassifySpam(inner) => inner.patch(pointer, value), + Action::InvalidateCaches => pointer.assert_eof(), + Action::InvalidateNegativeCaches => pointer.assert_eof(), + Action::PauseMtaQueue => pointer.assert_eof(), + Action::ResumeMtaQueue => pointer.assert_eof(), + } + } +} + +impl Action { + pub fn object_type(&self) -> ActionType { + match self { + Action::ReloadSettings => ActionType::ReloadSettings, + Action::ReloadTlsCertificates => ActionType::ReloadTlsCertificates, + Action::ReloadLookupStores => ActionType::ReloadLookupStores, + Action::ReloadBlockedIps => ActionType::ReloadBlockedIps, + Action::UpdateApps => ActionType::UpdateApps, + Action::TroubleshootDmarc(_) => ActionType::TroubleshootDmarc, + Action::ClassifySpam(_) => ActionType::ClassifySpam, + Action::InvalidateCaches => ActionType::InvalidateCaches, + Action::InvalidateNegativeCaches => ActionType::InvalidateNegativeCaches, + Action::PauseMtaQueue => ActionType::PauseMtaQueue, + Action::ResumeMtaQueue => ActionType::ResumeMtaQueue, + } + } +} + +impl ObjectImpl for AddressBook { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::AddressBook; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.default_display_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultDisplayName)); + } + } + if let Some(value) = &self.default_href_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultHrefName)); + } + } + if let Some(value) = &self.max_address_books { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAddressBooks, 1)); + } + } + if let Some(value) = &self.max_contacts { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxContacts, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for AddressBook { + fn pickle(&self, out: &mut Vec) { + self.default_display_name.pickle(out); + self.default_href_name.pickle(out); + self.max_v_card_size.pickle(out); + self.max_address_books.pickle(out); + self.max_contacts.pickle(out); + self.v_card_version.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.default_display_name = Pickle::unpickle(stream)?; + this.default_href_name = Pickle::unpickle(stream)?; + this.max_v_card_size = Pickle::unpickle(stream)?; + this.max_address_books = Pickle::unpickle(stream)?; + this.max_contacts = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.v_card_version = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for AddressBook { + fn default() -> Self { + Self { + default_display_name: Some("Stalwart Address Book".to_string()), + default_href_name: Some("default".to_string()), + max_v_card_size: 524288u64, + max_address_books: Some(250u64), + max_contacts: Default::default(), + v_card_version: VCardVersion::V4, + } + } +} + +impl IntoValue for AddressBook { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked( + Property::DefaultDisplayName, + self.default_display_name.into_value(), + ); + map.insert_unchecked( + Property::DefaultHrefName, + self.default_href_name.into_value(), + ); + map.insert_unchecked(Property::MaxVCardSize, self.max_v_card_size.into_value()); + map.insert_unchecked( + Property::MaxAddressBooks, + self.max_address_books.into_value(), + ); + map.insert_unchecked(Property::MaxContacts, self.max_contacts.into_value()); + map.insert_unchecked(Property::VCardVersion, self.v_card_version.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AddressBook { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DefaultDisplayName) => self + .default_display_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DefaultHrefName) => self + .default_href_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MaxVCardSize) => self.max_v_card_size.patch(pointer, value), + Some(Property::MaxAddressBooks) => self.max_address_books.patch(pointer, value), + Some(Property::MaxContacts) => self.max_contacts.patch(pointer, value), + Some(Property::VCardVersion) => self.v_card_version.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for AiModel { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::AiModel; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.temperature; + if *value > Float::new(1.0) { + errors.push(ValidationError::max_value(Property::Temperature, 1)); + } + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::Temperature, 0)); + } + let value = &self.model; + if value.is_empty() { + errors.push(ValidationError::required(Property::Model)); + } + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for AiModel { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.allow_invalid_certs.pickle(out); + self.temperature.pickle(out); + self.model.pickle(out); + self.timeout.pickle(out); + self.model_type.pickle(out); + self.url.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.temperature = Pickle::unpickle(stream)?; + this.model = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.model_type = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AiModel { + fn default() -> Self { + Self { + name: Default::default(), + allow_invalid_certs: false, + temperature: Float::new(0.7f64), + model: Default::default(), + timeout: Duration::from_millis(120000), + model_type: AiModelType::Chat, + url: Default::default(), + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for AiModel { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::Temperature, self.temperature.into_value()); + map.insert_unchecked(Property::Model, self.model.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::ModelType, self.model_type.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AiModel { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self + .name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::Temperature) => self.temperature.patch(pointer, value), + Some(Property::Model) => self + .model + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::ModelType) => self.model_type.patch(pointer, value), + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Alert { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Alert; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.email_alert; + value.validate(errors); + let value = &self.event_alert; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Alert { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: &[], + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for Alert { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.email_alert.pickle(out); + self.event_alert.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.email_alert = Pickle::unpickle(stream)?; + this.event_alert = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Alert { + fn default() -> Self { + Self { + condition: Default::default(), + email_alert: Default::default(), + event_alert: Default::default(), + enable: true, + } + } +} + +impl IntoValue for Alert { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::EmailAlert, self.email_alert.into_value()); + map.insert_unchecked(Property::EventAlert, self.event_alert.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Alert { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::EmailAlert) => self.email_alert.patch(pointer, value), + Some(Property::EventAlert) => self.event_alert.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl AlertEmail { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + AlertEmail::Disabled => true, + AlertEmail::Enabled(inner) => inner.validate(errors), + } + } +} + +impl Default for AlertEmail { + fn default() -> Self { + AlertEmail::Disabled + } +} + +impl Pickle for AlertEmail { + fn pickle(&self, out: &mut Vec) { + match self { + AlertEmail::Disabled => { + 0u16.pickle(out); + } + AlertEmail::Enabled(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(AlertEmail::Disabled), + 1 => Pickle::unpickle(stream).map(AlertEmail::Enabled), + _ => None, + } + } +} + +impl IntoValue for AlertEmail { + fn into_value(self) -> JmapValue<'static> { + match self { + AlertEmail::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + AlertEmail::Enabled(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Enabled".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for AlertEmail { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + AlertEmailType::Disabled => *self = AlertEmail::Disabled, + AlertEmailType::Enabled => *self = AlertEmail::Enabled(Default::default()), + } + } + match self { + AlertEmail::Disabled => pointer.assert_eof(), + AlertEmail::Enabled(inner) => inner.patch(pointer, value), + } + } +} + +impl AlertEmail { + pub fn object_type(&self) -> AlertEmailType { + match self { + AlertEmail::Disabled => AlertEmailType::Disabled, + AlertEmail::Enabled(_) => AlertEmailType::Enabled, + } + } +} + +impl AlertEmailProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.body; + if value.is_empty() { + errors.push(ValidationError::required(Property::Body)); + } + let value = &self.from_address; + if value.is_empty() { + errors.push(ValidationError::required(Property::FromAddress)); + } + if let Some(value) = &self.from_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::FromName)); + } + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::To, 1)); + } + errors.len() == neb + } +} + +impl Pickle for AlertEmailProperties { + fn pickle(&self, out: &mut Vec) { + self.body.pickle(out); + self.from_address.pickle(out); + self.from_name.pickle(out); + self.subject.pickle(out); + self.to.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.body = Pickle::unpickle(stream)?; + this.from_address = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AlertEmailProperties { + fn default() -> Self { + Self { + body: Default::default(), + from_address: Default::default(), + from_name: Default::default(), + subject: Default::default(), + to: Default::default(), + } + } +} + +impl IntoValue for AlertEmailProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Body, self.body.into_value()); + map.insert_unchecked(Property::FromAddress, self.from_address.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AlertEmailProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Body) => self.body.patch(pointer, value), + Some(Property::FromAddress) => self + .from_address + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::FromName) => self.from_name.patch(pointer, value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::To) => self + .to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl AlertEvent { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + AlertEvent::Disabled => true, + AlertEvent::Enabled(inner) => inner.validate(errors), + } + } +} + +impl Default for AlertEvent { + fn default() -> Self { + AlertEvent::Disabled + } +} + +impl Pickle for AlertEvent { + fn pickle(&self, out: &mut Vec) { + match self { + AlertEvent::Disabled => { + 0u16.pickle(out); + } + AlertEvent::Enabled(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(AlertEvent::Disabled), + 1 => Pickle::unpickle(stream).map(AlertEvent::Enabled), + _ => None, + } + } +} + +impl IntoValue for AlertEvent { + fn into_value(self) -> JmapValue<'static> { + match self { + AlertEvent::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + AlertEvent::Enabled(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Enabled".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for AlertEvent { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + AlertEventType::Disabled => *self = AlertEvent::Disabled, + AlertEventType::Enabled => *self = AlertEvent::Enabled(Default::default()), + } + } + match self { + AlertEvent::Disabled => pointer.assert_eof(), + AlertEvent::Enabled(inner) => inner.patch(pointer, value), + } + } +} + +impl AlertEvent { + pub fn object_type(&self) -> AlertEventType { + match self { + AlertEvent::Disabled => AlertEventType::Disabled, + AlertEvent::Enabled(_) => AlertEventType::Enabled, + } + } +} + +impl AlertEventProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.event_message { + if value.is_empty() { + errors.push(ValidationError::required(Property::EventMessage)); + } + } + errors.len() == neb + } +} + +impl Pickle for AlertEventProperties { + fn pickle(&self, out: &mut Vec) { + self.event_message.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.event_message = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AlertEventProperties { + fn default() -> Self { + Self { + event_message: Default::default(), + } + } +} + +impl IntoValue for AlertEventProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::EventMessage, self.event_message.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AlertEventProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::EventMessage) => self.event_message.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for AllowedIp { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::AllowedIp; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.address; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Address, value)); + } + if let Some(value) = &self.reason { + if value.is_empty() { + errors.push(ValidationError::required(Property::Reason)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Address, &self.address); + } +} + +impl Pickle for AllowedIp { + fn pickle(&self, out: &mut Vec) { + self.address.pickle(out); + self.reason.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.address = Pickle::unpickle(stream)?; + this.reason = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AllowedIp { + fn default() -> Self { + Self { + address: Default::default(), + reason: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + } + } +} + +impl IntoValue for AllowedIp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Address, self.address.into_value()); + map.insert_unchecked(Property::Reason, self.reason.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AllowedIp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Address) => self.address.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Trim]), + value, + ), + Some(Property::Reason) => self + .reason + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::CreatedAt) => self.created_at.patch(pointer.assert_read_only()?, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ApiKey { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::ApiKey; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + let value = &self.permissions; + value.validate(errors); + let value = &self.allowed_ips; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::AllowedIps, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for ApiKey { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.secret.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + self.permissions.pickle(out); + self.allowed_ips.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.allowed_ips = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ApiKey { + fn default() -> Self { + Self { + description: Default::default(), + secret: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + permissions: Default::default(), + allowed_ips: Default::default(), + } + } +} + +impl IntoValue for ApiKey { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::AllowedIps, self.allowed_ips.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ApiKey { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Secret) => pointer.assert_server_set(), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::AllowedIps) => self.allowed_ips.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for AppPassword { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::AppPassword; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + let value = &self.permissions; + value.validate(errors); + let value = &self.allowed_ips; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::AllowedIps, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for AppPassword { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.secret.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + self.permissions.pickle(out); + self.allowed_ips.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.allowed_ips = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AppPassword { + fn default() -> Self { + Self { + description: Default::default(), + secret: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + permissions: Default::default(), + allowed_ips: Default::default(), + } + } +} + +impl IntoValue for AppPassword { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::AllowedIps, self.allowed_ips.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AppPassword { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Secret) => pointer.assert_server_set(), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::AllowedIps) => self.allowed_ips.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Application { + const FLAGS: u64 = 0; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Application; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.resource_url; + if value.is_empty() { + errors.push(ValidationError::required(Property::ResourceUrl)); + } + let value = &self.url_prefix; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::UrlPrefix)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::UrlPrefix, 1)); + } + if let Some(value) = &self.unpack_directory { + if value.is_empty() { + errors.push(ValidationError::required(Property::UnpackDirectory)); + } + } + if let Some(value) = &self.oauth_client_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::OauthClientId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Application { + fn pickle(&self, out: &mut Vec) { + self.enabled.pickle(out); + self.description.pickle(out); + self.resource_url.pickle(out); + self.url_prefix.pickle(out); + self.auto_update_frequency.pickle(out); + self.unpack_directory.pickle(out); + self.oauth_client_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enabled = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.resource_url = Pickle::unpickle(stream)?; + this.url_prefix = Pickle::unpickle(stream)?; + this.auto_update_frequency = Pickle::unpickle(stream)?; + this.unpack_directory = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.oauth_client_id = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for Application { + fn default() -> Self { + Self { + enabled: true, + description: Default::default(), + resource_url: Default::default(), + url_prefix: Default::default(), + auto_update_frequency: Duration::from_millis(7776000000), + unpack_directory: Default::default(), + oauth_client_id: Default::default(), + } + } +} + +impl IntoValue for Application { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Enabled, self.enabled.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::ResourceUrl, self.resource_url.into_value()); + map.insert_unchecked(Property::UrlPrefix, self.url_prefix.into_value()); + map.insert_unchecked( + Property::AutoUpdateFrequency, + self.auto_update_frequency.into_value(), + ); + map.insert_unchecked( + Property::UnpackDirectory, + self.unpack_directory.into_value(), + ); + map.insert_unchecked(Property::OauthClientId, self.oauth_client_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Application { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enabled) => self.enabled.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ResourceUrl) => self + .resource_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::UrlPrefix) => self + .url_prefix + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AutoUpdateFrequency) => self.auto_update_frequency.patch(pointer, value), + Some(Property::UnpackDirectory) => self + .unpack_directory + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::OauthClientId) => self + .oauth_client_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ArchivedCalendarEvent { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.title; + if value.is_empty() { + errors.push(ValidationError::required(Property::Title)); + } + if let Some(value) = &self.start_time { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::StartTime, value)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.archived_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for ArchivedCalendarEvent { + fn pickle(&self, out: &mut Vec) { + self.title.pickle(out); + self.start_time.pickle(out); + self.created_at.pickle(out); + self.account_id.pickle(out); + self.archived_at.pickle(out); + self.archived_until.pickle(out); + self.blob_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.title = Pickle::unpickle(stream)?; + this.start_time = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.archived_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArchivedCalendarEvent { + fn default() -> Self { + Self { + title: Default::default(), + start_time: Default::default(), + created_at: Default::default(), + account_id: Default::default(), + archived_at: Default::default(), + archived_until: Default::default(), + blob_id: Default::default(), + } + } +} + +impl IntoValue for ArchivedCalendarEvent { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Title, self.title.into_value()); + map.insert_unchecked(Property::StartTime, self.start_time.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ArchivedAt, self.archived_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArchivedCalendarEvent { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Title) => self.title.patch(pointer, value), + Some(Property::StartTime) => self.start_time.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_can_set_account()?, value), + Some(Property::ArchivedAt) => self.archived_at.patch(pointer, value), + Some(Property::ArchivedUntil) => self.archived_until.patch(pointer, value), + Some(Property::BlobId) => self.blob_id.patch(pointer, value), + Some(property @ Property::Status) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ArchivedContactCard { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.name { + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.archived_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for ArchivedContactCard { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.created_at.pickle(out); + self.account_id.pickle(out); + self.archived_at.pickle(out); + self.archived_until.pickle(out); + self.blob_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.archived_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArchivedContactCard { + fn default() -> Self { + Self { + name: Default::default(), + created_at: Default::default(), + account_id: Default::default(), + archived_at: Default::default(), + archived_until: Default::default(), + blob_id: Default::default(), + } + } +} + +impl IntoValue for ArchivedContactCard { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ArchivedAt, self.archived_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArchivedContactCard { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_can_set_account()?, value), + Some(Property::ArchivedAt) => self.archived_at.patch(pointer, value), + Some(Property::ArchivedUntil) => self.archived_until.patch(pointer, value), + Some(Property::BlobId) => self.blob_id.patch(pointer, value), + Some(property @ Property::Status) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ArchivedEmail { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.received_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivedAt, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.archived_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for ArchivedEmail { + fn pickle(&self, out: &mut Vec) { + self.from.pickle(out); + self.subject.pickle(out); + self.received_at.pickle(out); + self.size.pickle(out); + self.account_id.pickle(out); + self.archived_at.pickle(out); + self.archived_until.pickle(out); + self.blob_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.received_at = Pickle::unpickle(stream)?; + this.size = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.archived_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArchivedEmail { + fn default() -> Self { + Self { + from: Default::default(), + subject: Default::default(), + received_at: Default::default(), + size: 0u64, + account_id: Default::default(), + archived_at: Default::default(), + archived_until: Default::default(), + blob_id: Default::default(), + } + } +} + +impl IntoValue for ArchivedEmail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::ReceivedAt, self.received_at.into_value()); + map.insert_unchecked(Property::Size, self.size.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ArchivedAt, self.archived_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArchivedEmail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::From) => self.from.patch(pointer, value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::ReceivedAt) => self.received_at.patch(pointer, value), + Some(Property::Size) => self.size.patch(pointer.assert_read_only()?, value), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_can_set_account()?, value), + Some(Property::ArchivedAt) => self.archived_at.patch(pointer, value), + Some(Property::ArchivedUntil) => self.archived_until.patch(pointer, value), + Some(Property::BlobId) => self.blob_id.patch(pointer, value), + Some(property @ Property::Status) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ArchivedFileNode { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.archived_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for ArchivedFileNode { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.created_at.pickle(out); + self.account_id.pickle(out); + self.archived_at.pickle(out); + self.archived_until.pickle(out); + self.blob_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.archived_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArchivedFileNode { + fn default() -> Self { + Self { + name: Default::default(), + created_at: Default::default(), + account_id: Default::default(), + archived_at: Default::default(), + archived_until: Default::default(), + blob_id: Default::default(), + } + } +} + +impl IntoValue for ArchivedFileNode { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ArchivedAt, self.archived_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArchivedFileNode { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_can_set_account()?, value), + Some(Property::ArchivedAt) => self.archived_at.patch(pointer, value), + Some(Property::ArchivedUntil) => self.archived_until.patch(pointer, value), + Some(Property::BlobId) => self.blob_id.patch(pointer, value), + Some(property @ Property::Status) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ArchivedItem { + const FLAGS: u64 = OBJ_FILTER_ACCOUNT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::ArchivedItem; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + ArchivedItem::Email(inner) => inner.validate(errors), + ArchivedItem::FileNode(inner) => inner.validate(errors), + ArchivedItem::CalendarEvent(inner) => inner.validate(errors), + ArchivedItem::ContactCard(inner) => inner.validate(errors), + ArchivedItem::SieveScript(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + ArchivedItem::Email(object) => { + object.index(i); + } + ArchivedItem::FileNode(object) => { + object.index(i); + } + ArchivedItem::CalendarEvent(object) => { + object.index(i); + } + ArchivedItem::ContactCard(object) => { + object.index(i); + } + ArchivedItem::SieveScript(object) => { + object.index(i); + } + } + } +} + +impl Default for ArchivedItem { + fn default() -> Self { + ArchivedItem::Email(Default::default()) + } +} + +impl Pickle for ArchivedItem { + fn pickle(&self, out: &mut Vec) { + match self { + ArchivedItem::Email(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + ArchivedItem::FileNode(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + ArchivedItem::CalendarEvent(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + ArchivedItem::ContactCard(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + ArchivedItem::SieveScript(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(ArchivedItem::Email), + 1 => Pickle::unpickle(stream).map(ArchivedItem::FileNode), + 2 => Pickle::unpickle(stream).map(ArchivedItem::CalendarEvent), + 3 => Pickle::unpickle(stream).map(ArchivedItem::ContactCard), + 4 => Pickle::unpickle(stream).map(ArchivedItem::SieveScript), + _ => None, + } + } +} + +impl IntoValue for ArchivedItem { + fn into_value(self) -> JmapValue<'static> { + match self { + ArchivedItem::Email(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Email".into())); + obj + } + ArchivedItem::FileNode(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FileNode".into())); + obj + } + ArchivedItem::CalendarEvent(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("CalendarEvent".into())); + obj + } + ArchivedItem::ContactCard(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ContactCard".into())); + obj + } + ArchivedItem::SieveScript(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("SieveScript".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for ArchivedItem { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + ArchivedItemType::Email => *self = ArchivedItem::Email(Default::default()), + ArchivedItemType::FileNode => *self = ArchivedItem::FileNode(Default::default()), + ArchivedItemType::CalendarEvent => { + *self = ArchivedItem::CalendarEvent(Default::default()) + } + ArchivedItemType::ContactCard => { + *self = ArchivedItem::ContactCard(Default::default()) + } + ArchivedItemType::SieveScript => { + *self = ArchivedItem::SieveScript(Default::default()) + } + } + } + match self { + ArchivedItem::Email(inner) => inner.patch(pointer, value), + ArchivedItem::FileNode(inner) => inner.patch(pointer, value), + ArchivedItem::CalendarEvent(inner) => inner.patch(pointer, value), + ArchivedItem::ContactCard(inner) => inner.patch(pointer, value), + ArchivedItem::SieveScript(inner) => inner.patch(pointer, value), + } + } +} + +impl ArchivedItem { + pub fn object_type(&self) -> ArchivedItemType { + match self { + ArchivedItem::Email(_) => ArchivedItemType::Email, + ArchivedItem::FileNode(_) => ArchivedItemType::FileNode, + ArchivedItem::CalendarEvent(_) => ArchivedItemType::CalendarEvent, + ArchivedItem::ContactCard(_) => ArchivedItemType::ContactCard, + ArchivedItem::SieveScript(_) => ArchivedItemType::SieveScript, + } + } +} + +impl ArchivedSieveScript { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.content; + if value.is_empty() { + errors.push(ValidationError::required(Property::Content)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.archived_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for ArchivedSieveScript { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.created_at.pickle(out); + self.content.pickle(out); + self.account_id.pickle(out); + self.archived_at.pickle(out); + self.archived_until.pickle(out); + self.blob_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.content = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.archived_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArchivedSieveScript { + fn default() -> Self { + Self { + name: Default::default(), + created_at: Default::default(), + content: Default::default(), + account_id: Default::default(), + archived_at: Default::default(), + archived_until: Default::default(), + blob_id: Default::default(), + } + } +} + +impl IntoValue for ArchivedSieveScript { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::Content, self.content.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ArchivedAt, self.archived_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArchivedSieveScript { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::Content) => self.content.patch(pointer, value), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_can_set_account()?, value), + Some(Property::ArchivedAt) => self.archived_at.patch(pointer, value), + Some(Property::ArchivedUntil) => self.archived_until.patch(pointer, value), + Some(Property::BlobId) => self.blob_id.patch(pointer, value), + Some(property @ Property::Status) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ArfExternalReport { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::ArfExternalReport; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.report; + value.validate(errors); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + let value = &self.received_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivedAt, value)); + } + let value = &self.expires_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + } +} + +impl Pickle for ArfExternalReport { + fn pickle(&self, out: &mut Vec) { + self.report.pickle(out); + self.from.pickle(out); + self.subject.pickle(out); + self.to.pickle(out); + self.received_at.pickle(out); + self.expires_at.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.report = Pickle::unpickle(stream)?; + this.from = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + this.received_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArfExternalReport { + fn default() -> Self { + Self { + report: Default::default(), + from: Default::default(), + subject: Default::default(), + to: Default::default(), + received_at: Default::default(), + expires_at: Default::default(), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for ArfExternalReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + map.insert_unchecked(Property::ReceivedAt, self.received_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArfExternalReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::From) => self + .from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::To) => self + .to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::ReceivedAt) => self.received_at.patch(pointer, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ArfFeedbackReport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.arrival_date { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArrivalDate, value)); + } + } + let value = &self.authentication_results; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthenticationResults)); + } + } + if let Some(value) = &self.original_envelope_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::OriginalEnvelopeId)); + } + } + if let Some(value) = &self.original_mail_from { + if value.is_empty() { + errors.push(ValidationError::required(Property::OriginalMailFrom)); + } + } + if let Some(value) = &self.original_rcpt_to { + if value.is_empty() { + errors.push(ValidationError::required(Property::OriginalRcptTo)); + } + } + let value = &self.reported_domains; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportedDomains)); + } + } + let value = &self.reported_uris; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportedUris)); + } + } + if let Some(value) = &self.reporting_mta { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportingMta)); + } + } + if let Some(value) = &self.source_ip { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::SourceIp, value)); + } + } + if let Some(value) = &self.source_port { + if *value < 1 { + errors.push(ValidationError::min_value(Property::SourcePort, 1)); + } + if *value > 65535 { + errors.push(ValidationError::max_value(Property::SourcePort, 65535)); + } + } + if let Some(value) = &self.user_agent { + if value.is_empty() { + errors.push(ValidationError::required(Property::UserAgent)); + } + } + if let Some(value) = &self.dkim_adsp_dns { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimAdspDns)); + } + } + if let Some(value) = &self.dkim_canonicalized_body { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimCanonicalizedBody)); + } + } + if let Some(value) = &self.dkim_canonicalized_header { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimCanonicalizedHeader)); + } + } + if let Some(value) = &self.dkim_domain { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimDomain)); + } + } + if let Some(value) = &self.dkim_identity { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimIdentity)); + } + } + if let Some(value) = &self.dkim_selector { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimSelector)); + } + } + if let Some(value) = &self.dkim_selector_dns { + if value.is_empty() { + errors.push(ValidationError::required(Property::DkimSelectorDns)); + } + } + if let Some(value) = &self.spf_dns { + if value.is_empty() { + errors.push(ValidationError::required(Property::SpfDns)); + } + } + if let Some(value) = &self.message { + if value.is_empty() { + errors.push(ValidationError::required(Property::Message)); + } + } + if let Some(value) = &self.headers { + if value.is_empty() { + errors.push(ValidationError::required(Property::Headers)); + } + } + errors.len() == neb + } +} + +impl Pickle for ArfFeedbackReport { + fn pickle(&self, out: &mut Vec) { + self.feedback_type.pickle(out); + self.arrival_date.pickle(out); + self.authentication_results.pickle(out); + self.incidents.pickle(out); + self.original_envelope_id.pickle(out); + self.original_mail_from.pickle(out); + self.original_rcpt_to.pickle(out); + self.reported_domains.pickle(out); + self.reported_uris.pickle(out); + self.reporting_mta.pickle(out); + self.source_ip.pickle(out); + self.source_port.pickle(out); + self.user_agent.pickle(out); + self.version.pickle(out); + self.auth_failure.pickle(out); + self.delivery_result.pickle(out); + self.dkim_adsp_dns.pickle(out); + self.dkim_canonicalized_body.pickle(out); + self.dkim_canonicalized_header.pickle(out); + self.dkim_domain.pickle(out); + self.dkim_identity.pickle(out); + self.dkim_selector.pickle(out); + self.dkim_selector_dns.pickle(out); + self.spf_dns.pickle(out); + self.identity_alignment.pickle(out); + self.message.pickle(out); + self.headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.feedback_type = Pickle::unpickle(stream)?; + this.arrival_date = Pickle::unpickle(stream)?; + this.authentication_results = Pickle::unpickle(stream)?; + this.incidents = Pickle::unpickle(stream)?; + this.original_envelope_id = Pickle::unpickle(stream)?; + this.original_mail_from = Pickle::unpickle(stream)?; + this.original_rcpt_to = Pickle::unpickle(stream)?; + this.reported_domains = Pickle::unpickle(stream)?; + this.reported_uris = Pickle::unpickle(stream)?; + this.reporting_mta = Pickle::unpickle(stream)?; + this.source_ip = Pickle::unpickle(stream)?; + this.source_port = Pickle::unpickle(stream)?; + this.user_agent = Pickle::unpickle(stream)?; + this.version = Pickle::unpickle(stream)?; + this.auth_failure = Pickle::unpickle(stream)?; + this.delivery_result = Pickle::unpickle(stream)?; + this.dkim_adsp_dns = Pickle::unpickle(stream)?; + this.dkim_canonicalized_body = Pickle::unpickle(stream)?; + this.dkim_canonicalized_header = Pickle::unpickle(stream)?; + this.dkim_domain = Pickle::unpickle(stream)?; + this.dkim_identity = Pickle::unpickle(stream)?; + this.dkim_selector = Pickle::unpickle(stream)?; + this.dkim_selector_dns = Pickle::unpickle(stream)?; + this.spf_dns = Pickle::unpickle(stream)?; + this.identity_alignment = Pickle::unpickle(stream)?; + this.message = Pickle::unpickle(stream)?; + this.headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ArfFeedbackReport { + fn default() -> Self { + Self { + feedback_type: Default::default(), + arrival_date: Default::default(), + authentication_results: Default::default(), + incidents: 0u64, + original_envelope_id: Default::default(), + original_mail_from: Default::default(), + original_rcpt_to: Default::default(), + reported_domains: Default::default(), + reported_uris: Default::default(), + reporting_mta: Default::default(), + source_ip: Default::default(), + source_port: Default::default(), + user_agent: Default::default(), + version: 1u64, + auth_failure: Default::default(), + delivery_result: Default::default(), + dkim_adsp_dns: Default::default(), + dkim_canonicalized_body: Default::default(), + dkim_canonicalized_header: Default::default(), + dkim_domain: Default::default(), + dkim_identity: Default::default(), + dkim_selector: Default::default(), + dkim_selector_dns: Default::default(), + spf_dns: Default::default(), + identity_alignment: Default::default(), + message: Default::default(), + headers: Default::default(), + } + } +} + +impl IntoValue for ArfFeedbackReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(29); + map.insert_unchecked(Property::FeedbackType, self.feedback_type.into_value()); + map.insert_unchecked(Property::ArrivalDate, self.arrival_date.into_value()); + map.insert_unchecked( + Property::AuthenticationResults, + self.authentication_results.into_value(), + ); + map.insert_unchecked(Property::Incidents, self.incidents.into_value()); + map.insert_unchecked( + Property::OriginalEnvelopeId, + self.original_envelope_id.into_value(), + ); + map.insert_unchecked( + Property::OriginalMailFrom, + self.original_mail_from.into_value(), + ); + map.insert_unchecked(Property::OriginalRcptTo, self.original_rcpt_to.into_value()); + map.insert_unchecked( + Property::ReportedDomains, + self.reported_domains.into_value(), + ); + map.insert_unchecked(Property::ReportedUris, self.reported_uris.into_value()); + map.insert_unchecked(Property::ReportingMta, self.reporting_mta.into_value()); + map.insert_unchecked(Property::SourceIp, self.source_ip.into_value()); + map.insert_unchecked(Property::SourcePort, self.source_port.into_value()); + map.insert_unchecked(Property::UserAgent, self.user_agent.into_value()); + map.insert_unchecked(Property::Version, self.version.into_value()); + map.insert_unchecked(Property::AuthFailure, self.auth_failure.into_value()); + map.insert_unchecked(Property::DeliveryResult, self.delivery_result.into_value()); + map.insert_unchecked(Property::DkimAdspDns, self.dkim_adsp_dns.into_value()); + map.insert_unchecked( + Property::DkimCanonicalizedBody, + self.dkim_canonicalized_body.into_value(), + ); + map.insert_unchecked( + Property::DkimCanonicalizedHeader, + self.dkim_canonicalized_header.into_value(), + ); + map.insert_unchecked(Property::DkimDomain, self.dkim_domain.into_value()); + map.insert_unchecked(Property::DkimIdentity, self.dkim_identity.into_value()); + map.insert_unchecked(Property::DkimSelector, self.dkim_selector.into_value()); + map.insert_unchecked( + Property::DkimSelectorDns, + self.dkim_selector_dns.into_value(), + ); + map.insert_unchecked(Property::SpfDns, self.spf_dns.into_value()); + map.insert_unchecked( + Property::IdentityAlignment, + self.identity_alignment.into_value(), + ); + map.insert_unchecked(Property::Message, self.message.into_value()); + map.insert_unchecked(Property::Headers, self.headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ArfFeedbackReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::FeedbackType) => self.feedback_type.patch(pointer, value), + Some(Property::ArrivalDate) => self.arrival_date.patch(pointer, value), + Some(Property::AuthenticationResults) => { + self.authentication_results.patch(pointer, value) + } + Some(Property::Incidents) => self.incidents.patch(pointer, value), + Some(Property::OriginalEnvelopeId) => self.original_envelope_id.patch(pointer, value), + Some(Property::OriginalMailFrom) => self + .original_mail_from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::OriginalRcptTo) => self + .original_rcpt_to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::ReportedDomains) => self + .reported_domains + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::ReportedUris) => self.reported_uris.patch(pointer, value), + Some(Property::ReportingMta) => self.reporting_mta.patch(pointer, value), + Some(Property::SourceIp) => self.source_ip.patch(pointer, value), + Some(Property::SourcePort) => self.source_port.patch(pointer, value), + Some(Property::UserAgent) => self.user_agent.patch(pointer, value), + Some(Property::Version) => self.version.patch(pointer, value), + Some(Property::AuthFailure) => self.auth_failure.patch(pointer, value), + Some(Property::DeliveryResult) => self.delivery_result.patch(pointer, value), + Some(Property::DkimAdspDns) => self.dkim_adsp_dns.patch(pointer, value), + Some(Property::DkimCanonicalizedBody) => { + self.dkim_canonicalized_body.patch(pointer, value) + } + Some(Property::DkimCanonicalizedHeader) => { + self.dkim_canonicalized_header.patch(pointer, value) + } + Some(Property::DkimDomain) => self.dkim_domain.patch(pointer, value), + Some(Property::DkimIdentity) => self.dkim_identity.patch(pointer, value), + Some(Property::DkimSelector) => self.dkim_selector.patch(pointer, value), + Some(Property::DkimSelectorDns) => self.dkim_selector_dns.patch(pointer, value), + Some(Property::SpfDns) => self.spf_dns.patch(pointer, value), + Some(Property::IdentityAlignment) => self.identity_alignment.patch(pointer, value), + Some(Property::Message) => self.message.patch(pointer, value), + Some(Property::Headers) => self.headers.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Asn { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Asn; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Asn::Disabled => true, + Asn::Resource(inner) => inner.validate(errors), + Asn::Dns(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for Asn { + fn default() -> Self { + Asn::Disabled + } +} + +impl Pickle for Asn { + fn pickle(&self, out: &mut Vec) { + match self { + Asn::Disabled => { + 0u16.pickle(out); + } + Asn::Resource(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Asn::Dns(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(Asn::Disabled), + 1 => Pickle::unpickle(stream).map(Asn::Resource), + 2 => Pickle::unpickle(stream).map(Asn::Dns), + _ => None, + } + } +} + +impl IntoValue for Asn { + fn into_value(self) -> JmapValue<'static> { + match self { + Asn::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + Asn::Resource(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Resource".into())); + obj + } + Asn::Dns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dns".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Asn { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + AsnType::Disabled => *self = Asn::Disabled, + AsnType::Resource => *self = Asn::Resource(Default::default()), + AsnType::Dns => *self = Asn::Dns(Default::default()), + } + } + match self { + Asn::Disabled => pointer.assert_eof(), + Asn::Resource(inner) => inner.patch(pointer, value), + Asn::Dns(inner) => inner.patch(pointer, value), + } + } +} + +impl Asn { + pub fn object_type(&self) -> AsnType { + match self { + Asn::Disabled => AsnType::Disabled, + Asn::Resource(_) => AsnType::Resource, + Asn::Dns(_) => AsnType::Dns, + } + } +} + +impl AsnDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.separator; + if value.is_empty() { + errors.push(ValidationError::required(Property::Separator)); + } + let value = &self.zone_ip_v4; + if value.is_empty() { + errors.push(ValidationError::required(Property::ZoneIpV4)); + } + let value = &self.zone_ip_v6; + if value.is_empty() { + errors.push(ValidationError::required(Property::ZoneIpV6)); + } + errors.len() == neb + } +} + +impl Pickle for AsnDns { + fn pickle(&self, out: &mut Vec) { + self.index_asn.pickle(out); + self.index_asn_name.pickle(out); + self.index_country.pickle(out); + self.separator.pickle(out); + self.zone_ip_v4.pickle(out); + self.zone_ip_v6.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.index_asn = Pickle::unpickle(stream)?; + this.index_asn_name = Pickle::unpickle(stream)?; + this.index_country = Pickle::unpickle(stream)?; + this.separator = Pickle::unpickle(stream)?; + this.zone_ip_v4 = Pickle::unpickle(stream)?; + this.zone_ip_v6 = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AsnDns { + fn default() -> Self { + Self { + index_asn: 0u64, + index_asn_name: Default::default(), + index_country: Default::default(), + separator: "|".to_string(), + zone_ip_v4: Default::default(), + zone_ip_v6: Default::default(), + } + } +} + +impl IntoValue for AsnDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::IndexAsn, self.index_asn.into_value()); + map.insert_unchecked(Property::IndexAsnName, self.index_asn_name.into_value()); + map.insert_unchecked(Property::IndexCountry, self.index_country.into_value()); + map.insert_unchecked(Property::Separator, self.separator.into_value()); + map.insert_unchecked(Property::ZoneIpV4, self.zone_ip_v4.into_value()); + map.insert_unchecked(Property::ZoneIpV6, self.zone_ip_v6.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AsnDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IndexAsn) => self.index_asn.patch(pointer, value), + Some(Property::IndexAsnName) => self.index_asn_name.patch(pointer, value), + Some(Property::IndexCountry) => self.index_country.patch(pointer, value), + Some(Property::Separator) => self + .separator + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ZoneIpV4) => self + .zone_ip_v4 + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ZoneIpV6) => self + .zone_ip_v6 + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl AsnResource { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.asn_urls; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AsnUrls)); + } + } + let value = &self.geo_urls; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::GeoUrls)); + } + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for AsnResource { + fn pickle(&self, out: &mut Vec) { + self.expires.pickle(out); + self.max_size.pickle(out); + self.timeout.pickle(out); + self.asn_urls.pickle(out); + self.geo_urls.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.expires = Pickle::unpickle(stream)?; + this.max_size = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.asn_urls = Pickle::unpickle(stream)?; + this.geo_urls = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AsnResource { + fn default() -> Self { + Self { + expires: Duration::from_millis(86400000), + max_size: 104857600, + timeout: Duration::from_millis(300000), + asn_urls: Default::default(), + geo_urls: Default::default(), + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for AsnResource { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Expires, self.expires.into_value()); + map.insert_unchecked(Property::MaxSize, self.max_size.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::AsnUrls, self.asn_urls.into_value()); + map.insert_unchecked(Property::GeoUrls, self.geo_urls.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AsnResource { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Expires) => self.expires.patch(pointer, value), + Some(Property::MaxSize) => self.max_size.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AsnUrls) => self + .asn_urls + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::GeoUrls) => self + .geo_urls + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Authentication { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Authentication; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.directory_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DirectoryId)); + } + } + let value = &self.default_user_role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultUserRoleIds)); + } + } + let value = &self.default_group_role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultGroupRoleIds)); + } + } + let value = &self.default_tenant_role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultTenantRoleIds)); + } + } + let value = &self.default_admin_role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultAdminRoleIds)); + } + } + let value = &self.password_min_length; + if *value < 1 { + errors.push(ValidationError::min_value(Property::PasswordMinLength, 1)); + } + if *value > 100 { + errors.push(ValidationError::max_value(Property::PasswordMinLength, 100)); + } + let value = &self.password_max_length; + if *value < 1 { + errors.push(ValidationError::min_value(Property::PasswordMaxLength, 1)); + } + if *value > 1000 { + errors.push(ValidationError::max_value( + Property::PasswordMaxLength, + 1000, + )); + } + if let Some(value) = &self.max_app_passwords { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAppPasswords, 1)); + } + } + if let Some(value) = &self.max_api_keys { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxApiKeys, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Directory, self.directory_id, None); + for id in self.default_user_role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + for id in self.default_group_role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + for id in self.default_tenant_role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + for id in self.default_admin_role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + } +} + +impl Pickle for Authentication { + fn pickle(&self, out: &mut Vec) { + self.directory_id.pickle(out); + self.default_user_role_ids.pickle(out); + self.default_group_role_ids.pickle(out); + self.default_tenant_role_ids.pickle(out); + self.default_admin_role_ids.pickle(out); + self.password_hash_algorithm.pickle(out); + self.password_min_length.pickle(out); + self.password_max_length.pickle(out); + self.password_min_strength.pickle(out); + self.password_default_expiry.pickle(out); + self.max_app_passwords.pickle(out); + self.max_api_keys.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.directory_id = Pickle::unpickle(stream)?; + this.default_user_role_ids = Pickle::unpickle(stream)?; + this.default_group_role_ids = Pickle::unpickle(stream)?; + this.default_tenant_role_ids = Pickle::unpickle(stream)?; + this.default_admin_role_ids = Pickle::unpickle(stream)?; + this.password_hash_algorithm = Pickle::unpickle(stream)?; + this.password_min_length = Pickle::unpickle(stream)?; + this.password_max_length = Pickle::unpickle(stream)?; + this.password_min_strength = Pickle::unpickle(stream)?; + this.password_default_expiry = Pickle::unpickle(stream)?; + this.max_app_passwords = Pickle::unpickle(stream)?; + this.max_api_keys = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Authentication { + fn default() -> Self { + Self { + directory_id: Default::default(), + default_user_role_ids: Default::default(), + default_group_role_ids: Default::default(), + default_tenant_role_ids: Default::default(), + default_admin_role_ids: Default::default(), + password_hash_algorithm: PasswordHashAlgorithm::Argon2id, + password_min_length: 8u64, + password_max_length: 128u64, + password_min_strength: PasswordStrength::Three, + password_default_expiry: Default::default(), + max_app_passwords: Some(5u64), + max_api_keys: Some(5u64), + } + } +} + +impl IntoValue for Authentication { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::DirectoryId, self.directory_id.into_value()); + map.insert_unchecked( + Property::DefaultUserRoleIds, + self.default_user_role_ids.into_value(), + ); + map.insert_unchecked( + Property::DefaultGroupRoleIds, + self.default_group_role_ids.into_value(), + ); + map.insert_unchecked( + Property::DefaultTenantRoleIds, + self.default_tenant_role_ids.into_value(), + ); + map.insert_unchecked( + Property::DefaultAdminRoleIds, + self.default_admin_role_ids.into_value(), + ); + map.insert_unchecked( + Property::PasswordHashAlgorithm, + self.password_hash_algorithm.into_value(), + ); + map.insert_unchecked( + Property::PasswordMinLength, + self.password_min_length.into_value(), + ); + map.insert_unchecked( + Property::PasswordMaxLength, + self.password_max_length.into_value(), + ); + map.insert_unchecked( + Property::PasswordMinStrength, + self.password_min_strength.into_value(), + ); + map.insert_unchecked( + Property::PasswordDefaultExpiry, + self.password_default_expiry.into_value(), + ); + map.insert_unchecked( + Property::MaxAppPasswords, + self.max_app_passwords.into_value(), + ); + map.insert_unchecked(Property::MaxApiKeys, self.max_api_keys.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Authentication { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DirectoryId) => self.directory_id.patch(pointer, value), + Some(Property::DefaultUserRoleIds) => self.default_user_role_ids.patch(pointer, value), + Some(Property::DefaultGroupRoleIds) => { + self.default_group_role_ids.patch(pointer, value) + } + Some(Property::DefaultTenantRoleIds) => { + self.default_tenant_role_ids.patch(pointer, value) + } + Some(Property::DefaultAdminRoleIds) => { + self.default_admin_role_ids.patch(pointer, value) + } + Some(Property::PasswordHashAlgorithm) => { + self.password_hash_algorithm.patch(pointer, value) + } + Some(Property::PasswordMinLength) => self.password_min_length.patch(pointer, value), + Some(Property::PasswordMaxLength) => self.password_max_length.patch(pointer, value), + Some(Property::PasswordMinStrength) => self.password_min_strength.patch(pointer, value), + Some(Property::PasswordDefaultExpiry) => { + self.password_default_expiry.patch(pointer, value) + } + Some(Property::MaxAppPasswords) => self.max_app_passwords.patch(pointer, value), + Some(Property::MaxApiKeys) => self.max_api_keys.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl AzureStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.storage_account; + if value.is_empty() { + errors.push(ValidationError::required(Property::StorageAccount)); + } + let value = &self.container; + if value.is_empty() { + errors.push(ValidationError::required(Property::Container)); + } + let value = &self.access_key; + value.validate(errors); + let value = &self.sas_token; + value.validate(errors); + let value = &self.max_retries; + if *value > 10 { + errors.push(ValidationError::max_value(Property::MaxRetries, 10)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRetries, 1)); + } + if let Some(value) = &self.key_prefix { + if value.is_empty() { + errors.push(ValidationError::required(Property::KeyPrefix)); + } + } + errors.len() == neb + } +} + +impl Pickle for AzureStore { + fn pickle(&self, out: &mut Vec) { + self.storage_account.pickle(out); + self.container.pickle(out); + self.access_key.pickle(out); + self.sas_token.pickle(out); + self.timeout.pickle(out); + self.max_retries.pickle(out); + self.key_prefix.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.storage_account = Pickle::unpickle(stream)?; + this.container = Pickle::unpickle(stream)?; + this.access_key = Pickle::unpickle(stream)?; + this.sas_token = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.max_retries = Pickle::unpickle(stream)?; + this.key_prefix = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for AzureStore { + fn default() -> Self { + Self { + storage_account: Default::default(), + container: Default::default(), + access_key: Default::default(), + sas_token: Default::default(), + timeout: Duration::from_millis(30000), + max_retries: 3u64, + key_prefix: Default::default(), + } + } +} + +impl IntoValue for AzureStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::StorageAccount, self.storage_account.into_value()); + map.insert_unchecked(Property::Container, self.container.into_value()); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SasToken, self.sas_token.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value()); + map.insert_unchecked(Property::KeyPrefix, self.key_prefix.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for AzureStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::StorageAccount) => self + .storage_account + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Container) => self + .container + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AccessKey) => self.access_key.patch(pointer, value), + Some(Property::SasToken) => self.sas_token.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::MaxRetries) => self.max_retries.patch(pointer, value), + Some(Property::KeyPrefix) => self + .key_prefix + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for BlobStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::BlobStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + BlobStore::Default => true, + BlobStore::Sharded(inner) => inner.validate(errors), + BlobStore::S3(inner) => inner.validate(errors), + BlobStore::Azure(inner) => inner.validate(errors), + BlobStore::FileSystem(inner) => inner.validate(errors), + BlobStore::FoundationDb(inner) => inner.validate(errors), + BlobStore::PostgreSql(inner) => inner.validate(errors), + BlobStore::MySql(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for BlobStore { + fn default() -> Self { + BlobStore::Default + } +} + +impl Pickle for BlobStore { + fn pickle(&self, out: &mut Vec) { + match self { + BlobStore::Default => { + 0u16.pickle(out); + } + BlobStore::Sharded(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + BlobStore::S3(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + BlobStore::Azure(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + BlobStore::FileSystem(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + BlobStore::FoundationDb(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + BlobStore::PostgreSql(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + BlobStore::MySql(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(BlobStore::Default), + 1 => Pickle::unpickle(stream).map(BlobStore::Sharded), + 2 => Pickle::unpickle(stream).map(BlobStore::S3), + 3 => Pickle::unpickle(stream).map(BlobStore::Azure), + 4 => Pickle::unpickle(stream).map(BlobStore::FileSystem), + 5 => Pickle::unpickle(stream).map(BlobStore::FoundationDb), + 6 => Pickle::unpickle(stream).map(BlobStore::PostgreSql), + 7 => Pickle::unpickle(stream).map(BlobStore::MySql), + _ => None, + } + } +} + +impl IntoValue for BlobStore { + fn into_value(self) -> JmapValue<'static> { + match self { + BlobStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + BlobStore::Sharded(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sharded".into())); + obj + } + BlobStore::S3(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("S3".into())); + obj + } + BlobStore::Azure(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Azure".into())); + obj + } + BlobStore::FileSystem(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FileSystem".into())); + obj + } + BlobStore::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + BlobStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + BlobStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for BlobStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + BlobStoreType::Default => *self = BlobStore::Default, + BlobStoreType::Sharded => *self = BlobStore::Sharded(Default::default()), + BlobStoreType::S3 => *self = BlobStore::S3(Default::default()), + BlobStoreType::Azure => *self = BlobStore::Azure(Default::default()), + BlobStoreType::FileSystem => *self = BlobStore::FileSystem(Default::default()), + BlobStoreType::FoundationDb => *self = BlobStore::FoundationDb(Default::default()), + BlobStoreType::PostgreSql => *self = BlobStore::PostgreSql(Default::default()), + BlobStoreType::MySql => *self = BlobStore::MySql(Default::default()), + } + } + match self { + BlobStore::Default => pointer.assert_eof(), + BlobStore::Sharded(inner) => inner.patch(pointer, value), + BlobStore::S3(inner) => inner.patch(pointer, value), + BlobStore::Azure(inner) => inner.patch(pointer, value), + BlobStore::FileSystem(inner) => inner.patch(pointer, value), + BlobStore::FoundationDb(inner) => inner.patch(pointer, value), + BlobStore::PostgreSql(inner) => inner.patch(pointer, value), + BlobStore::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl BlobStore { + pub fn object_type(&self) -> BlobStoreType { + match self { + BlobStore::Default => BlobStoreType::Default, + BlobStore::Sharded(_) => BlobStoreType::Sharded, + BlobStore::S3(_) => BlobStoreType::S3, + BlobStore::Azure(_) => BlobStoreType::Azure, + BlobStore::FileSystem(_) => BlobStoreType::FileSystem, + BlobStore::FoundationDb(_) => BlobStoreType::FoundationDb, + BlobStore::PostgreSql(_) => BlobStoreType::PostgreSql, + BlobStore::MySql(_) => BlobStoreType::MySql, + } + } +} + +impl BlobStoreBase { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + BlobStoreBase::S3(inner) => inner.validate(errors), + BlobStoreBase::Azure(inner) => inner.validate(errors), + BlobStoreBase::FileSystem(inner) => inner.validate(errors), + BlobStoreBase::FoundationDb(inner) => inner.validate(errors), + BlobStoreBase::PostgreSql(inner) => inner.validate(errors), + BlobStoreBase::MySql(inner) => inner.validate(errors), + } + } +} + +impl Default for BlobStoreBase { + fn default() -> Self { + BlobStoreBase::S3(Default::default()) + } +} + +impl Pickle for BlobStoreBase { + fn pickle(&self, out: &mut Vec) { + match self { + BlobStoreBase::S3(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + BlobStoreBase::Azure(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + BlobStoreBase::FileSystem(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + BlobStoreBase::FoundationDb(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + BlobStoreBase::PostgreSql(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + BlobStoreBase::MySql(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(BlobStoreBase::S3), + 1 => Pickle::unpickle(stream).map(BlobStoreBase::Azure), + 2 => Pickle::unpickle(stream).map(BlobStoreBase::FileSystem), + 3 => Pickle::unpickle(stream).map(BlobStoreBase::FoundationDb), + 4 => Pickle::unpickle(stream).map(BlobStoreBase::PostgreSql), + 5 => Pickle::unpickle(stream).map(BlobStoreBase::MySql), + _ => None, + } + } +} + +impl IntoValue for BlobStoreBase { + fn into_value(self) -> JmapValue<'static> { + match self { + BlobStoreBase::S3(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("S3".into())); + obj + } + BlobStoreBase::Azure(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Azure".into())); + obj + } + BlobStoreBase::FileSystem(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FileSystem".into())); + obj + } + BlobStoreBase::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + BlobStoreBase::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + BlobStoreBase::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for BlobStoreBase { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + BlobStoreBaseType::S3 => *self = BlobStoreBase::S3(Default::default()), + BlobStoreBaseType::Azure => *self = BlobStoreBase::Azure(Default::default()), + BlobStoreBaseType::FileSystem => { + *self = BlobStoreBase::FileSystem(Default::default()) + } + BlobStoreBaseType::FoundationDb => { + *self = BlobStoreBase::FoundationDb(Default::default()) + } + BlobStoreBaseType::PostgreSql => { + *self = BlobStoreBase::PostgreSql(Default::default()) + } + BlobStoreBaseType::MySql => *self = BlobStoreBase::MySql(Default::default()), + } + } + match self { + BlobStoreBase::S3(inner) => inner.patch(pointer, value), + BlobStoreBase::Azure(inner) => inner.patch(pointer, value), + BlobStoreBase::FileSystem(inner) => inner.patch(pointer, value), + BlobStoreBase::FoundationDb(inner) => inner.patch(pointer, value), + BlobStoreBase::PostgreSql(inner) => inner.patch(pointer, value), + BlobStoreBase::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl BlobStoreBase { + pub fn object_type(&self) -> BlobStoreBaseType { + match self { + BlobStoreBase::S3(_) => BlobStoreBaseType::S3, + BlobStoreBase::Azure(_) => BlobStoreBaseType::Azure, + BlobStoreBase::FileSystem(_) => BlobStoreBaseType::FileSystem, + BlobStoreBase::FoundationDb(_) => BlobStoreBaseType::FoundationDb, + BlobStoreBase::PostgreSql(_) => BlobStoreBaseType::PostgreSql, + BlobStoreBase::MySql(_) => BlobStoreBaseType::MySql, + } + } +} + +impl ObjectImpl for BlockedIp { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::BlockedIp; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.address; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Address, value)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Address, &self.address); + } +} + +impl Pickle for BlockedIp { + fn pickle(&self, out: &mut Vec) { + self.address.pickle(out); + self.reason.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.address = Pickle::unpickle(stream)?; + this.reason = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for BlockedIp { + fn default() -> Self { + Self { + address: Default::default(), + reason: BlockReason::Manual, + created_at: Default::default(), + expires_at: Default::default(), + } + } +} + +impl IntoValue for BlockedIp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Address, self.address.into_value()); + map.insert_unchecked(Property::Reason, self.reason.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for BlockedIp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Address) => self.address.patch(pointer.assert_read_only()?, value), + Some(Property::Reason) => self.reason.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Bootstrap { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Bootstrap; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.server_hostname; + if value.is_empty() { + errors.push(ValidationError::required(Property::ServerHostname)); + } + let value = &self.default_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultDomain)); + } + let value = &self.data_store; + value.validate(errors); + let value = &self.blob_store; + value.validate(errors); + let value = &self.search_store; + value.validate(errors); + let value = &self.in_memory_store; + value.validate(errors); + let value = &self.directory; + value.validate(errors); + let value = &self.tracer; + value.validate(errors); + let value = &self.dns_server; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Bootstrap { + fn pickle(&self, out: &mut Vec) { + self.server_hostname.pickle(out); + self.default_domain.pickle(out); + self.request_tls_certificate.pickle(out); + self.generate_dkim_keys.pickle(out); + self.data_store.pickle(out); + self.blob_store.pickle(out); + self.search_store.pickle(out); + self.in_memory_store.pickle(out); + self.directory.pickle(out); + self.tracer.pickle(out); + self.dns_server.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.server_hostname = Pickle::unpickle(stream)?; + this.default_domain = Pickle::unpickle(stream)?; + this.request_tls_certificate = Pickle::unpickle(stream)?; + this.generate_dkim_keys = Pickle::unpickle(stream)?; + this.data_store = Pickle::unpickle(stream)?; + this.blob_store = Pickle::unpickle(stream)?; + this.search_store = Pickle::unpickle(stream)?; + this.in_memory_store = Pickle::unpickle(stream)?; + this.directory = Pickle::unpickle(stream)?; + this.tracer = Pickle::unpickle(stream)?; + this.dns_server = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Bootstrap { + fn default() -> Self { + Self { + server_hostname: Default::default(), + default_domain: Default::default(), + request_tls_certificate: true, + generate_dkim_keys: true, + data_store: DataStore::RocksDb(RocksDbStore { + path: "/var/lib/stalwart/".to_string(), + ..Default::default() + }), + blob_store: BlobStore::Default, + search_store: SearchStore::Default, + in_memory_store: InMemoryStore::Default, + directory: DirectoryBootstrap::Internal, + tracer: Tracer::Log(TracerLog { + path: "/var/log/stalwart/".to_string(), + ..Default::default() + }), + dns_server: DnsServerBootstrap::Manual, + } + } +} + +impl IntoValue for Bootstrap { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::ServerHostname, self.server_hostname.into_value()); + map.insert_unchecked(Property::DefaultDomain, self.default_domain.into_value()); + map.insert_unchecked( + Property::RequestTlsCertificate, + self.request_tls_certificate.into_value(), + ); + map.insert_unchecked( + Property::GenerateDkimKeys, + self.generate_dkim_keys.into_value(), + ); + map.insert_unchecked(Property::DataStore, self.data_store.into_value()); + map.insert_unchecked(Property::BlobStore, self.blob_store.into_value()); + map.insert_unchecked(Property::SearchStore, self.search_store.into_value()); + map.insert_unchecked(Property::InMemoryStore, self.in_memory_store.into_value()); + map.insert_unchecked(Property::Directory, self.directory.into_value()); + map.insert_unchecked(Property::Tracer, self.tracer.into_value()); + map.insert_unchecked(Property::DnsServer, self.dns_server.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Bootstrap { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ServerHostname) => self.server_hostname.patch(pointer, value), + Some(Property::DefaultDomain) => self.default_domain.patch(pointer, value), + Some(Property::RequestTlsCertificate) => { + self.request_tls_certificate.patch(pointer, value) + } + Some(Property::GenerateDkimKeys) => self.generate_dkim_keys.patch(pointer, value), + Some(Property::DataStore) => self.data_store.patch(pointer, value), + Some(Property::BlobStore) => self.blob_store.patch(pointer, value), + Some(Property::SearchStore) => self.search_store.patch(pointer, value), + Some(Property::InMemoryStore) => self.in_memory_store.patch(pointer, value), + Some(Property::Directory) => self.directory.patch(pointer, value), + Some(Property::Tracer) => self.tracer.patch(pointer, value), + Some(Property::DnsServer) => self.dns_server.patch(pointer, value), + Some(Property::Username) => pointer.assert_server_set(), + Some(Property::Secret) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Cache { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Cache; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_tokens; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::AccessTokens, 2048)); + } + let value = &self.contacts; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Contacts, 2048)); + } + let value = &self.dns_ipv4; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsIpv4, 2048)); + } + let value = &self.dns_ipv6; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsIpv6, 2048)); + } + let value = &self.dns_mta_sts; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsMtaSts, 2048)); + } + let value = &self.dns_mx; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsMx, 2048)); + } + let value = &self.dns_ptr; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsPtr, 2048)); + } + let value = &self.dns_rbl; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsRbl, 2048)); + } + let value = &self.dns_tlsa; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsTlsa, 2048)); + } + let value = &self.dns_txt; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DnsTxt, 2048)); + } + let value = &self.events; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Events, 2048)); + } + let value = &self.scheduling; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Scheduling, 2048)); + } + let value = &self.files; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Files, 2048)); + } + let value = &self.http_auth; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::HttpAuth, 2048)); + } + let value = &self.messages; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Messages, 2048)); + } + let value = &self.domains; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Domains, 2048)); + } + let value = &self.domain_names; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DomainNames, 2048)); + } + let value = &self.domain_names_negative; + if *value < 2048 { + errors.push(ValidationError::min_value( + Property::DomainNamesNegative, + 2048, + )); + } + let value = &self.email_addresses; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::EmailAddresses, 2048)); + } + let value = &self.email_addresses_negative; + if *value < 2048 { + errors.push(ValidationError::min_value( + Property::EmailAddressesNegative, + 2048, + )); + } + let value = &self.accounts; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Accounts, 2048)); + } + let value = &self.roles; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Roles, 2048)); + } + let value = &self.tenants; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::Tenants, 2048)); + } + let value = &self.mailing_lists; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::MailingLists, 2048)); + } + let value = &self.dkim_signatures; + if *value < 2048 { + errors.push(ValidationError::min_value(Property::DkimSignatures, 2048)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Cache { + fn pickle(&self, out: &mut Vec) { + self.access_tokens.pickle(out); + self.contacts.pickle(out); + self.dns_ipv4.pickle(out); + self.dns_ipv6.pickle(out); + self.dns_mta_sts.pickle(out); + self.dns_mx.pickle(out); + self.dns_ptr.pickle(out); + self.dns_rbl.pickle(out); + self.dns_tlsa.pickle(out); + self.dns_txt.pickle(out); + self.events.pickle(out); + self.scheduling.pickle(out); + self.files.pickle(out); + self.http_auth.pickle(out); + self.messages.pickle(out); + self.domains.pickle(out); + self.domain_names.pickle(out); + self.domain_names_negative.pickle(out); + self.email_addresses.pickle(out); + self.email_addresses_negative.pickle(out); + self.accounts.pickle(out); + self.roles.pickle(out); + self.tenants.pickle(out); + self.mailing_lists.pickle(out); + self.dkim_signatures.pickle(out); + self.negative_ttl.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_tokens = Pickle::unpickle(stream)?; + this.contacts = Pickle::unpickle(stream)?; + this.dns_ipv4 = Pickle::unpickle(stream)?; + this.dns_ipv6 = Pickle::unpickle(stream)?; + this.dns_mta_sts = Pickle::unpickle(stream)?; + this.dns_mx = Pickle::unpickle(stream)?; + this.dns_ptr = Pickle::unpickle(stream)?; + this.dns_rbl = Pickle::unpickle(stream)?; + this.dns_tlsa = Pickle::unpickle(stream)?; + this.dns_txt = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.scheduling = Pickle::unpickle(stream)?; + this.files = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.messages = Pickle::unpickle(stream)?; + this.domains = Pickle::unpickle(stream)?; + this.domain_names = Pickle::unpickle(stream)?; + this.domain_names_negative = Pickle::unpickle(stream)?; + this.email_addresses = Pickle::unpickle(stream)?; + this.email_addresses_negative = Pickle::unpickle(stream)?; + this.accounts = Pickle::unpickle(stream)?; + this.roles = Pickle::unpickle(stream)?; + this.tenants = Pickle::unpickle(stream)?; + this.mailing_lists = Pickle::unpickle(stream)?; + this.dkim_signatures = Pickle::unpickle(stream)?; + this.negative_ttl = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Cache { + fn default() -> Self { + Self { + access_tokens: 10485760, + contacts: 10485760, + dns_ipv4: 5242880, + dns_ipv6: 5242880, + dns_mta_sts: 1048576, + dns_mx: 5242880, + dns_ptr: 1048576, + dns_rbl: 5242880, + dns_tlsa: 1048576, + dns_txt: 5242880, + events: 10485760, + scheduling: 1048576, + files: 10485760, + http_auth: 1048576, + messages: 52428800, + domains: 5242880, + domain_names: 10485760, + domain_names_negative: 1048576, + email_addresses: 10485760, + email_addresses_negative: 2097152, + accounts: 20971520, + roles: 5242880, + tenants: 5242880, + mailing_lists: 2097152, + dkim_signatures: 10485760, + negative_ttl: Duration::from_millis(3600000), + } + } +} + +impl IntoValue for Cache { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(28); + map.insert_unchecked(Property::AccessTokens, self.access_tokens.into_value()); + map.insert_unchecked(Property::Contacts, self.contacts.into_value()); + map.insert_unchecked(Property::DnsIpv4, self.dns_ipv4.into_value()); + map.insert_unchecked(Property::DnsIpv6, self.dns_ipv6.into_value()); + map.insert_unchecked(Property::DnsMtaSts, self.dns_mta_sts.into_value()); + map.insert_unchecked(Property::DnsMx, self.dns_mx.into_value()); + map.insert_unchecked(Property::DnsPtr, self.dns_ptr.into_value()); + map.insert_unchecked(Property::DnsRbl, self.dns_rbl.into_value()); + map.insert_unchecked(Property::DnsTlsa, self.dns_tlsa.into_value()); + map.insert_unchecked(Property::DnsTxt, self.dns_txt.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::Scheduling, self.scheduling.into_value()); + map.insert_unchecked(Property::Files, self.files.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::Messages, self.messages.into_value()); + map.insert_unchecked(Property::Domains, self.domains.into_value()); + map.insert_unchecked(Property::DomainNames, self.domain_names.into_value()); + map.insert_unchecked( + Property::DomainNamesNegative, + self.domain_names_negative.into_value(), + ); + map.insert_unchecked(Property::EmailAddresses, self.email_addresses.into_value()); + map.insert_unchecked( + Property::EmailAddressesNegative, + self.email_addresses_negative.into_value(), + ); + map.insert_unchecked(Property::Accounts, self.accounts.into_value()); + map.insert_unchecked(Property::Roles, self.roles.into_value()); + map.insert_unchecked(Property::Tenants, self.tenants.into_value()); + map.insert_unchecked(Property::MailingLists, self.mailing_lists.into_value()); + map.insert_unchecked(Property::DkimSignatures, self.dkim_signatures.into_value()); + map.insert_unchecked(Property::NegativeTtl, self.negative_ttl.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Cache { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessTokens) => self.access_tokens.patch(pointer, value), + Some(Property::Contacts) => self.contacts.patch(pointer, value), + Some(Property::DnsIpv4) => self.dns_ipv4.patch(pointer, value), + Some(Property::DnsIpv6) => self.dns_ipv6.patch(pointer, value), + Some(Property::DnsMtaSts) => self.dns_mta_sts.patch(pointer, value), + Some(Property::DnsMx) => self.dns_mx.patch(pointer, value), + Some(Property::DnsPtr) => self.dns_ptr.patch(pointer, value), + Some(Property::DnsRbl) => self.dns_rbl.patch(pointer, value), + Some(Property::DnsTlsa) => self.dns_tlsa.patch(pointer, value), + Some(Property::DnsTxt) => self.dns_txt.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::Scheduling) => self.scheduling.patch(pointer, value), + Some(Property::Files) => self.files.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::Messages) => self.messages.patch(pointer, value), + Some(Property::Domains) => self.domains.patch(pointer, value), + Some(Property::DomainNames) => self.domain_names.patch(pointer, value), + Some(Property::DomainNamesNegative) => self.domain_names_negative.patch(pointer, value), + Some(Property::EmailAddresses) => self.email_addresses.patch(pointer, value), + Some(Property::EmailAddressesNegative) => { + self.email_addresses_negative.patch(pointer, value) + } + Some(Property::Accounts) => self.accounts.patch(pointer, value), + Some(Property::Roles) => self.roles.patch(pointer, value), + Some(Property::Tenants) => self.tenants.patch(pointer, value), + Some(Property::MailingLists) => self.mailing_lists.patch(pointer, value), + Some(Property::DkimSignatures) => self.dkim_signatures.patch(pointer, value), + Some(Property::NegativeTtl) => self.negative_ttl.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Calendar { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Calendar; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.default_display_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultDisplayName)); + } + } + if let Some(value) = &self.default_href_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultHrefName)); + } + } + let value = &self.max_attendees; + if *value > 100000 { + errors.push(ValidationError::max_value(Property::MaxAttendees, 100000)); + } + if let Some(value) = &self.max_calendars { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxCalendars, 1)); + } + } + if let Some(value) = &self.max_events { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxEvents, 1)); + } + } + if let Some(value) = &self.max_participant_identities { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::MaxParticipantIdentities, + 1, + )); + } + } + if let Some(value) = &self.max_event_notifications { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::MaxEventNotifications, + 1, + )); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Calendar { + fn pickle(&self, out: &mut Vec) { + self.default_display_name.pickle(out); + self.default_href_name.pickle(out); + self.max_attendees.pickle(out); + self.max_recurrence_expansions.pickle(out); + self.max_i_calendar_size.pickle(out); + self.max_calendars.pickle(out); + self.max_events.pickle(out); + self.max_participant_identities.pickle(out); + self.max_event_notifications.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.default_display_name = Pickle::unpickle(stream)?; + this.default_href_name = Pickle::unpickle(stream)?; + this.max_attendees = Pickle::unpickle(stream)?; + this.max_recurrence_expansions = Pickle::unpickle(stream)?; + this.max_i_calendar_size = Pickle::unpickle(stream)?; + this.max_calendars = Pickle::unpickle(stream)?; + this.max_events = Pickle::unpickle(stream)?; + this.max_participant_identities = Pickle::unpickle(stream)?; + this.max_event_notifications = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Calendar { + fn default() -> Self { + Self { + default_display_name: Some("Stalwart Calendar".to_string()), + default_href_name: Some("default".to_string()), + max_attendees: 20u64, + max_recurrence_expansions: 3000u64, + max_i_calendar_size: 524288, + max_calendars: Some(250u64), + max_events: Default::default(), + max_participant_identities: Some(100u64), + max_event_notifications: Default::default(), + } + } +} + +impl IntoValue for Calendar { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked( + Property::DefaultDisplayName, + self.default_display_name.into_value(), + ); + map.insert_unchecked( + Property::DefaultHrefName, + self.default_href_name.into_value(), + ); + map.insert_unchecked(Property::MaxAttendees, self.max_attendees.into_value()); + map.insert_unchecked( + Property::MaxRecurrenceExpansions, + self.max_recurrence_expansions.into_value(), + ); + map.insert_unchecked( + Property::MaxICalendarSize, + self.max_i_calendar_size.into_value(), + ); + map.insert_unchecked(Property::MaxCalendars, self.max_calendars.into_value()); + map.insert_unchecked(Property::MaxEvents, self.max_events.into_value()); + map.insert_unchecked( + Property::MaxParticipantIdentities, + self.max_participant_identities.into_value(), + ); + map.insert_unchecked( + Property::MaxEventNotifications, + self.max_event_notifications.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Calendar { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DefaultDisplayName) => self + .default_display_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DefaultHrefName) => self + .default_href_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MaxAttendees) => self.max_attendees.patch(pointer, value), + Some(Property::MaxRecurrenceExpansions) => { + self.max_recurrence_expansions.patch(pointer, value) + } + Some(Property::MaxICalendarSize) => self.max_i_calendar_size.patch(pointer, value), + Some(Property::MaxCalendars) => self.max_calendars.patch(pointer, value), + Some(Property::MaxEvents) => self.max_events.patch(pointer, value), + Some(Property::MaxParticipantIdentities) => { + self.max_participant_identities.patch(pointer, value) + } + Some(Property::MaxEventNotifications) => { + self.max_event_notifications.patch(pointer, value) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for CalendarAlarm { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::CalendarAlarm; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.from_email { + if value.is_empty() { + errors.push(ValidationError::required(Property::FromEmail)); + } + } + let value = &self.from_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::FromName)); + } + if let Some(value) = &self.template { + if value.is_empty() { + errors.push(ValidationError::required(Property::Template)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for CalendarAlarm { + fn pickle(&self, out: &mut Vec) { + self.allow_external_rcpts.pickle(out); + self.enable.pickle(out); + self.from_email.pickle(out); + self.from_name.pickle(out); + self.min_trigger_interval.pickle(out); + self.template.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_external_rcpts = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.from_email = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.min_trigger_interval = Pickle::unpickle(stream)?; + this.template = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CalendarAlarm { + fn default() -> Self { + Self { + allow_external_rcpts: false, + enable: true, + from_email: Default::default(), + from_name: "Stalwart Calendar".to_string(), + min_trigger_interval: Duration::from_millis(3600000), + template: Default::default(), + } + } +} + +impl IntoValue for CalendarAlarm { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked( + Property::AllowExternalRcpts, + self.allow_external_rcpts.into_value(), + ); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::FromEmail, self.from_email.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked( + Property::MinTriggerInterval, + self.min_trigger_interval.into_value(), + ); + map.insert_unchecked(Property::Template, self.template.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CalendarAlarm { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowExternalRcpts) => self.allow_external_rcpts.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::FromEmail) => self + .from_email + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::FromName) => self + .from_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MinTriggerInterval) => self.min_trigger_interval.patch(pointer, value), + Some(Property::Template) => self.template.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for CalendarScheduling { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::CalendarScheduling; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.http_rsvp_url { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpRsvpUrl)); + } + } + let value = &self.itip_max_size; + if *value < 100 { + errors.push(ValidationError::min_value(Property::ItipMaxSize, 100)); + } + let value = &self.max_recipients; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRecipients, 1)); + } + if let Some(value) = &self.email_template { + if value.is_empty() { + errors.push(ValidationError::required(Property::EmailTemplate)); + } + } + if let Some(value) = &self.http_rsvp_template { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpRsvpTemplate)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for CalendarScheduling { + fn pickle(&self, out: &mut Vec) { + self.enable.pickle(out); + self.http_rsvp_enable.pickle(out); + self.http_rsvp_link_expiry.pickle(out); + self.http_rsvp_url.pickle(out); + self.auto_add_invitations.pickle(out); + self.itip_max_size.pickle(out); + self.max_recipients.pickle(out); + self.email_template.pickle(out); + self.http_rsvp_template.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable = Pickle::unpickle(stream)?; + this.http_rsvp_enable = Pickle::unpickle(stream)?; + this.http_rsvp_link_expiry = Pickle::unpickle(stream)?; + this.http_rsvp_url = Pickle::unpickle(stream)?; + this.auto_add_invitations = Pickle::unpickle(stream)?; + this.itip_max_size = Pickle::unpickle(stream)?; + this.max_recipients = Pickle::unpickle(stream)?; + this.email_template = Pickle::unpickle(stream)?; + this.http_rsvp_template = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CalendarScheduling { + fn default() -> Self { + Self { + enable: true, + http_rsvp_enable: true, + http_rsvp_link_expiry: Duration::from_millis(7776000000), + http_rsvp_url: Default::default(), + auto_add_invitations: false, + itip_max_size: 524288, + max_recipients: 100u64, + email_template: Default::default(), + http_rsvp_template: Default::default(), + } + } +} + +impl IntoValue for CalendarScheduling { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::HttpRsvpEnable, self.http_rsvp_enable.into_value()); + map.insert_unchecked( + Property::HttpRsvpLinkExpiry, + self.http_rsvp_link_expiry.into_value(), + ); + map.insert_unchecked(Property::HttpRsvpUrl, self.http_rsvp_url.into_value()); + map.insert_unchecked( + Property::AutoAddInvitations, + self.auto_add_invitations.into_value(), + ); + map.insert_unchecked(Property::ItipMaxSize, self.itip_max_size.into_value()); + map.insert_unchecked(Property::MaxRecipients, self.max_recipients.into_value()); + map.insert_unchecked(Property::EmailTemplate, self.email_template.into_value()); + map.insert_unchecked( + Property::HttpRsvpTemplate, + self.http_rsvp_template.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CalendarScheduling { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::HttpRsvpEnable) => self.http_rsvp_enable.patch(pointer, value), + Some(Property::HttpRsvpLinkExpiry) => self.http_rsvp_link_expiry.patch(pointer, value), + Some(Property::HttpRsvpUrl) => self + .http_rsvp_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AutoAddInvitations) => self.auto_add_invitations.patch(pointer, value), + Some(Property::ItipMaxSize) => self.itip_max_size.patch(pointer, value), + Some(Property::MaxRecipients) => self.max_recipients.patch(pointer, value), + Some(Property::EmailTemplate) => self.email_template.patch(pointer, value), + Some(Property::HttpRsvpTemplate) => self.http_rsvp_template.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Certificate { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Certificate; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.certificate; + value.validate(errors); + let value = &self.private_key; + value.validate(errors); + let value = &self.subject_alternative_names; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::SubjectAlternativeNames)); + } + } + let value = &self.not_valid_after; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NotValidAfter, value)); + } + let value = &self.not_valid_before; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NotValidBefore, value)); + } + let value = &self.issuer; + if value.is_empty() { + errors.push(ValidationError::required(Property::Issuer)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + for value in self.subject_alternative_names.iter() { + i.text(Property::SubjectAlternativeNames, value); + } + } +} + +impl Pickle for Certificate { + fn pickle(&self, out: &mut Vec) { + self.certificate.pickle(out); + self.private_key.pickle(out); + self.subject_alternative_names.pickle(out); + self.not_valid_after.pickle(out); + self.not_valid_before.pickle(out); + self.issuer.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.certificate = Pickle::unpickle(stream)?; + this.private_key = Pickle::unpickle(stream)?; + this.subject_alternative_names = Pickle::unpickle(stream)?; + this.not_valid_after = Pickle::unpickle(stream)?; + this.not_valid_before = Pickle::unpickle(stream)?; + this.issuer = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Certificate { + fn default() -> Self { + Self { + certificate: Default::default(), + private_key: Default::default(), + subject_alternative_names: Default::default(), + not_valid_after: Default::default(), + not_valid_before: Default::default(), + issuer: Default::default(), + } + } +} + +impl IntoValue for Certificate { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Certificate, self.certificate.into_value()); + map.insert_unchecked(Property::PrivateKey, self.private_key.into_value()); + map.insert_unchecked( + Property::SubjectAlternativeNames, + self.subject_alternative_names.into_value(), + ); + map.insert_unchecked(Property::NotValidAfter, self.not_valid_after.into_value()); + map.insert_unchecked(Property::NotValidBefore, self.not_valid_before.into_value()); + map.insert_unchecked(Property::Issuer, self.issuer.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Certificate { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Certificate) => self.certificate.patch(pointer, value), + Some(Property::PrivateKey) => self.private_key.patch(pointer, value), + Some(Property::SubjectAlternativeNames) => pointer.assert_server_set(), + Some(Property::NotValidAfter) => pointer.assert_server_set(), + Some(Property::NotValidBefore) => pointer.assert_server_set(), + Some(Property::Issuer) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl CertificateManagement { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + CertificateManagement::Manual => true, + CertificateManagement::Automatic(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + CertificateManagement::Manual => {} + CertificateManagement::Automatic(object) => { + object.index(i); + } + } + } +} + +impl Default for CertificateManagement { + fn default() -> Self { + CertificateManagement::Manual + } +} + +impl Pickle for CertificateManagement { + fn pickle(&self, out: &mut Vec) { + match self { + CertificateManagement::Manual => { + 0u16.pickle(out); + } + CertificateManagement::Automatic(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(CertificateManagement::Manual), + 1 => Pickle::unpickle(stream).map(CertificateManagement::Automatic), + _ => None, + } + } +} + +impl IntoValue for CertificateManagement { + fn into_value(self) -> JmapValue<'static> { + match self { + CertificateManagement::Manual => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Manual".into())); + JmapValue::Object(obj) + } + CertificateManagement::Automatic(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Automatic".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for CertificateManagement { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + CertificateManagementType::Manual => *self = CertificateManagement::Manual, + CertificateManagementType::Automatic => { + *self = CertificateManagement::Automatic(Default::default()) + } + } + } + match self { + CertificateManagement::Manual => pointer.assert_eof(), + CertificateManagement::Automatic(inner) => inner.patch(pointer, value), + } + } +} + +impl CertificateManagement { + pub fn object_type(&self) -> CertificateManagementType { + match self { + CertificateManagement::Manual => CertificateManagementType::Manual, + CertificateManagement::Automatic(_) => CertificateManagementType::Automatic, + } + } +} + +impl CertificateManagementProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.acme_provider_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AcmeProviderId)); + } + let value = &self.subject_alternative_names; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::SubjectAlternativeNames)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::AcmeProvider, self.acme_provider_id.into(), None); + } +} + +impl Pickle for CertificateManagementProperties { + fn pickle(&self, out: &mut Vec) { + self.acme_provider_id.pickle(out); + self.subject_alternative_names.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.acme_provider_id = Pickle::unpickle(stream)?; + this.subject_alternative_names = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CertificateManagementProperties { + fn default() -> Self { + Self { + acme_provider_id: Default::default(), + subject_alternative_names: Default::default(), + } + } +} + +impl IntoValue for CertificateManagementProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::AcmeProviderId, self.acme_provider_id.into_value()); + map.insert_unchecked( + Property::SubjectAlternativeNames, + self.subject_alternative_names.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CertificateManagementProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AcmeProviderId) => self.acme_provider_id.patch(pointer, value), + Some(Property::SubjectAlternativeNames) => self + .subject_alternative_names + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ClusterListenerGroup { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + ClusterListenerGroup::EnableAll => true, + ClusterListenerGroup::DisableAll => true, + ClusterListenerGroup::EnableSome(inner) => inner.validate(errors), + ClusterListenerGroup::DisableSome(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + ClusterListenerGroup::EnableAll => {} + ClusterListenerGroup::DisableAll => {} + ClusterListenerGroup::EnableSome(object) => { + object.index(i); + } + ClusterListenerGroup::DisableSome(object) => { + object.index(i); + } + } + } +} + +impl Default for ClusterListenerGroup { + fn default() -> Self { + ClusterListenerGroup::EnableAll + } +} + +impl Pickle for ClusterListenerGroup { + fn pickle(&self, out: &mut Vec) { + match self { + ClusterListenerGroup::EnableAll => { + 0u16.pickle(out); + } + ClusterListenerGroup::DisableAll => { + 1u16.pickle(out); + } + ClusterListenerGroup::EnableSome(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + ClusterListenerGroup::DisableSome(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(ClusterListenerGroup::EnableAll), + 1 => Some(ClusterListenerGroup::DisableAll), + 2 => Pickle::unpickle(stream).map(ClusterListenerGroup::EnableSome), + 3 => Pickle::unpickle(stream).map(ClusterListenerGroup::DisableSome), + _ => None, + } + } +} + +impl IntoValue for ClusterListenerGroup { + fn into_value(self) -> JmapValue<'static> { + match self { + ClusterListenerGroup::EnableAll => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EnableAll".into())); + JmapValue::Object(obj) + } + ClusterListenerGroup::DisableAll => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DisableAll".into())); + JmapValue::Object(obj) + } + ClusterListenerGroup::EnableSome(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnableSome".into())); + obj + } + ClusterListenerGroup::DisableSome(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DisableSome".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for ClusterListenerGroup { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + ClusterListenerGroupType::EnableAll => *self = ClusterListenerGroup::EnableAll, + ClusterListenerGroupType::DisableAll => *self = ClusterListenerGroup::DisableAll, + ClusterListenerGroupType::EnableSome => { + *self = ClusterListenerGroup::EnableSome(Default::default()) + } + ClusterListenerGroupType::DisableSome => { + *self = ClusterListenerGroup::DisableSome(Default::default()) + } + } + } + match self { + ClusterListenerGroup::EnableAll => pointer.assert_eof(), + ClusterListenerGroup::DisableAll => pointer.assert_eof(), + ClusterListenerGroup::EnableSome(inner) => inner.patch(pointer, value), + ClusterListenerGroup::DisableSome(inner) => inner.patch(pointer, value), + } + } +} + +impl ClusterListenerGroup { + pub fn object_type(&self) -> ClusterListenerGroupType { + match self { + ClusterListenerGroup::EnableAll => ClusterListenerGroupType::EnableAll, + ClusterListenerGroup::DisableAll => ClusterListenerGroupType::DisableAll, + ClusterListenerGroup::EnableSome(_) => ClusterListenerGroupType::EnableSome, + ClusterListenerGroup::DisableSome(_) => ClusterListenerGroupType::DisableSome, + } + } +} + +impl ClusterListenerGroupProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.listener_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::ListenerIds)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + for id in self.listener_ids.iter() { + i.foreign_key(ObjectType::NetworkListener, Some(*id), None); + } + } +} + +impl Pickle for ClusterListenerGroupProperties { + fn pickle(&self, out: &mut Vec) { + self.listener_ids.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.listener_ids = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ClusterListenerGroupProperties { + fn default() -> Self { + Self { + listener_ids: Default::default(), + } + } +} + +impl IntoValue for ClusterListenerGroupProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::ListenerIds, self.listener_ids.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ClusterListenerGroupProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ListenerIds) => self.listener_ids.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ClusterNode { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::ClusterNode; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.hostname; + if value.is_empty() { + errors.push(ValidationError::required(Property::Hostname)); + } + let value = &self.last_renewal; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::LastRenewal, value)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for ClusterNode { + fn pickle(&self, out: &mut Vec) { + self.node_id.pickle(out); + self.hostname.pickle(out); + self.last_renewal.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.node_id = Pickle::unpickle(stream)?; + this.hostname = Pickle::unpickle(stream)?; + this.last_renewal = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ClusterNode { + fn default() -> Self { + Self { + node_id: 1u64, + hostname: Default::default(), + last_renewal: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for ClusterNode { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::NodeId, self.node_id.into_value()); + map.insert_unchecked(Property::Hostname, self.hostname.into_value()); + map.insert_unchecked(Property::LastRenewal, self.last_renewal.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ClusterNode { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::NodeId) => self.node_id.patch(pointer, value), + Some(Property::Hostname) => self.hostname.patch(pointer, value), + Some(Property::LastRenewal) => self.last_renewal.patch(pointer, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ClusterRole { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::ClusterRole; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.tasks; + value.validate(errors); + let value = &self.listeners; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + self.listeners.index(i); + } +} + +impl Pickle for ClusterRole { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.tasks.pickle(out); + self.listeners.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.tasks = Pickle::unpickle(stream)?; + this.listeners = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ClusterRole { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + tasks: Default::default(), + listeners: Default::default(), + } + } +} + +impl IntoValue for ClusterRole { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Tasks, self.tasks.into_value()); + map.insert_unchecked(Property::Listeners, self.listeners.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ClusterRole { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Tasks) => self.tasks.patch(pointer, value), + Some(Property::Listeners) => self.listeners.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ClusterTaskGroup { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + ClusterTaskGroup::EnableAll => true, + ClusterTaskGroup::DisableAll => true, + ClusterTaskGroup::EnableSome(inner) => inner.validate(errors), + ClusterTaskGroup::DisableSome(inner) => inner.validate(errors), + } + } +} + +impl Default for ClusterTaskGroup { + fn default() -> Self { + ClusterTaskGroup::EnableAll + } +} + +impl Pickle for ClusterTaskGroup { + fn pickle(&self, out: &mut Vec) { + match self { + ClusterTaskGroup::EnableAll => { + 0u16.pickle(out); + } + ClusterTaskGroup::DisableAll => { + 1u16.pickle(out); + } + ClusterTaskGroup::EnableSome(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + ClusterTaskGroup::DisableSome(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(ClusterTaskGroup::EnableAll), + 1 => Some(ClusterTaskGroup::DisableAll), + 2 => Pickle::unpickle(stream).map(ClusterTaskGroup::EnableSome), + 3 => Pickle::unpickle(stream).map(ClusterTaskGroup::DisableSome), + _ => None, + } + } +} + +impl IntoValue for ClusterTaskGroup { + fn into_value(self) -> JmapValue<'static> { + match self { + ClusterTaskGroup::EnableAll => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EnableAll".into())); + JmapValue::Object(obj) + } + ClusterTaskGroup::DisableAll => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DisableAll".into())); + JmapValue::Object(obj) + } + ClusterTaskGroup::EnableSome(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnableSome".into())); + obj + } + ClusterTaskGroup::DisableSome(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DisableSome".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for ClusterTaskGroup { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + ClusterTaskGroupType::EnableAll => *self = ClusterTaskGroup::EnableAll, + ClusterTaskGroupType::DisableAll => *self = ClusterTaskGroup::DisableAll, + ClusterTaskGroupType::EnableSome => { + *self = ClusterTaskGroup::EnableSome(Default::default()) + } + ClusterTaskGroupType::DisableSome => { + *self = ClusterTaskGroup::DisableSome(Default::default()) + } + } + } + match self { + ClusterTaskGroup::EnableAll => pointer.assert_eof(), + ClusterTaskGroup::DisableAll => pointer.assert_eof(), + ClusterTaskGroup::EnableSome(inner) => inner.patch(pointer, value), + ClusterTaskGroup::DisableSome(inner) => inner.patch(pointer, value), + } + } +} + +impl ClusterTaskGroup { + pub fn object_type(&self) -> ClusterTaskGroupType { + match self { + ClusterTaskGroup::EnableAll => ClusterTaskGroupType::EnableAll, + ClusterTaskGroup::DisableAll => ClusterTaskGroupType::DisableAll, + ClusterTaskGroup::EnableSome(_) => ClusterTaskGroupType::EnableSome, + ClusterTaskGroup::DisableSome(_) => ClusterTaskGroupType::DisableSome, + } + } +} + +impl ClusterTaskGroupProperties { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for ClusterTaskGroupProperties { + fn pickle(&self, out: &mut Vec) { + self.task_types.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.task_types = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ClusterTaskGroupProperties { + fn default() -> Self { + Self { + task_types: Default::default(), + } + } +} + +impl IntoValue for ClusterTaskGroupProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::TaskTypes, self.task_types.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ClusterTaskGroupProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TaskTypes) => self.task_types.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Coordinator { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Coordinator; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Coordinator::Disabled => true, + Coordinator::Default => true, + Coordinator::Kafka(inner) => inner.validate(errors), + Coordinator::Nats(inner) => inner.validate(errors), + Coordinator::Zenoh(inner) => inner.validate(errors), + Coordinator::Redis(inner) => inner.validate(errors), + Coordinator::RedisCluster(inner) => inner.validate(errors), + Coordinator::RedisSentinel(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for Coordinator { + fn default() -> Self { + Coordinator::Disabled + } +} + +impl Pickle for Coordinator { + fn pickle(&self, out: &mut Vec) { + match self { + Coordinator::Disabled => { + 0u16.pickle(out); + } + Coordinator::Default => { + 1u16.pickle(out); + } + Coordinator::Kafka(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + Coordinator::Nats(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + Coordinator::Zenoh(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + Coordinator::Redis(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + Coordinator::RedisCluster(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + Coordinator::RedisSentinel(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(Coordinator::Disabled), + 1 => Some(Coordinator::Default), + 2 => Pickle::unpickle(stream).map(Coordinator::Kafka), + 3 => Pickle::unpickle(stream).map(Coordinator::Nats), + 4 => Pickle::unpickle(stream).map(Coordinator::Zenoh), + 5 => Pickle::unpickle(stream).map(Coordinator::Redis), + 6 => Pickle::unpickle(stream).map(Coordinator::RedisCluster), + 7 => Pickle::unpickle(stream).map(Coordinator::RedisSentinel), + _ => None, + } + } +} + +impl IntoValue for Coordinator { + fn into_value(self) -> JmapValue<'static> { + match self { + Coordinator::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + Coordinator::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + Coordinator::Kafka(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Kafka".into())); + obj + } + Coordinator::Nats(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Nats".into())); + obj + } + Coordinator::Zenoh(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Zenoh".into())); + obj + } + Coordinator::Redis(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Redis".into())); + obj + } + Coordinator::RedisCluster(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisCluster".into())); + obj + } + Coordinator::RedisSentinel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisSentinel".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Coordinator { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + CoordinatorType::Disabled => *self = Coordinator::Disabled, + CoordinatorType::Default => *self = Coordinator::Default, + CoordinatorType::Kafka => *self = Coordinator::Kafka(Default::default()), + CoordinatorType::Nats => *self = Coordinator::Nats(Default::default()), + CoordinatorType::Zenoh => *self = Coordinator::Zenoh(Default::default()), + CoordinatorType::Redis => *self = Coordinator::Redis(Default::default()), + CoordinatorType::RedisCluster => { + *self = Coordinator::RedisCluster(Default::default()) + } + CoordinatorType::RedisSentinel => { + *self = Coordinator::RedisSentinel(Default::default()) + } + } + } + match self { + Coordinator::Disabled => pointer.assert_eof(), + Coordinator::Default => pointer.assert_eof(), + Coordinator::Kafka(inner) => inner.patch(pointer, value), + Coordinator::Nats(inner) => inner.patch(pointer, value), + Coordinator::Zenoh(inner) => inner.patch(pointer, value), + Coordinator::Redis(inner) => inner.patch(pointer, value), + Coordinator::RedisCluster(inner) => inner.patch(pointer, value), + Coordinator::RedisSentinel(inner) => inner.patch(pointer, value), + } + } +} + +impl Coordinator { + pub fn object_type(&self) -> CoordinatorType { + match self { + Coordinator::Disabled => CoordinatorType::Disabled, + Coordinator::Default => CoordinatorType::Default, + Coordinator::Kafka(_) => CoordinatorType::Kafka, + Coordinator::Nats(_) => CoordinatorType::Nats, + Coordinator::Zenoh(_) => CoordinatorType::Zenoh, + Coordinator::Redis(_) => CoordinatorType::Redis, + Coordinator::RedisCluster(_) => CoordinatorType::RedisCluster, + Coordinator::RedisSentinel(_) => CoordinatorType::RedisSentinel, + } + } +} + +impl Credential { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Credential::Password(inner) => inner.validate(errors), + Credential::AppPassword(inner) => inner.validate(errors), + Credential::ApiKey(inner) => inner.validate(errors), + } + } +} + +impl Default for Credential { + fn default() -> Self { + Credential::Password(Default::default()) + } +} + +impl Pickle for Credential { + fn pickle(&self, out: &mut Vec) { + match self { + Credential::Password(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Credential::AppPassword(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Credential::ApiKey(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Credential::Password), + 1 => Pickle::unpickle(stream).map(Credential::AppPassword), + 2 => Pickle::unpickle(stream).map(Credential::ApiKey), + _ => None, + } + } +} + +impl IntoValue for Credential { + fn into_value(self) -> JmapValue<'static> { + match self { + Credential::Password(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Password".into())); + obj + } + Credential::AppPassword(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("AppPassword".into())); + obj + } + Credential::ApiKey(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ApiKey".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Credential { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + CredentialType::Password => *self = Credential::Password(Default::default()), + CredentialType::AppPassword => *self = Credential::AppPassword(Default::default()), + CredentialType::ApiKey => *self = Credential::ApiKey(Default::default()), + } + } + match self { + Credential::Password(inner) => inner.patch(pointer, value), + Credential::AppPassword(inner) => inner.patch(pointer, value), + Credential::ApiKey(inner) => inner.patch(pointer, value), + } + } +} + +impl Credential { + pub fn object_type(&self) -> CredentialType { + match self { + Credential::Password(_) => CredentialType::Password, + Credential::AppPassword(_) => CredentialType::AppPassword, + Credential::ApiKey(_) => CredentialType::ApiKey, + } + } +} + +impl CredentialPermissions { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + CredentialPermissions::Inherit => true, + CredentialPermissions::Disable(inner) => inner.validate(errors), + CredentialPermissions::Replace(inner) => inner.validate(errors), + } + } +} + +impl Default for CredentialPermissions { + fn default() -> Self { + CredentialPermissions::Inherit + } +} + +impl Pickle for CredentialPermissions { + fn pickle(&self, out: &mut Vec) { + match self { + CredentialPermissions::Inherit => { + 0u16.pickle(out); + } + CredentialPermissions::Disable(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + CredentialPermissions::Replace(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(CredentialPermissions::Inherit), + 1 => Pickle::unpickle(stream).map(CredentialPermissions::Disable), + 2 => Pickle::unpickle(stream).map(CredentialPermissions::Replace), + _ => None, + } + } +} + +impl IntoValue for CredentialPermissions { + fn into_value(self) -> JmapValue<'static> { + match self { + CredentialPermissions::Inherit => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Inherit".into())); + JmapValue::Object(obj) + } + CredentialPermissions::Disable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Disable".into())); + obj + } + CredentialPermissions::Replace(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Replace".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for CredentialPermissions { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + CredentialPermissionsType::Inherit => *self = CredentialPermissions::Inherit, + CredentialPermissionsType::Disable => { + *self = CredentialPermissions::Disable(Default::default()) + } + CredentialPermissionsType::Replace => { + *self = CredentialPermissions::Replace(Default::default()) + } + } + } + match self { + CredentialPermissions::Inherit => pointer.assert_eof(), + CredentialPermissions::Disable(inner) => inner.patch(pointer, value), + CredentialPermissions::Replace(inner) => inner.patch(pointer, value), + } + } +} + +impl CredentialPermissions { + pub fn object_type(&self) -> CredentialPermissionsType { + match self { + CredentialPermissions::Inherit => CredentialPermissionsType::Inherit, + CredentialPermissions::Disable(_) => CredentialPermissionsType::Disable, + CredentialPermissions::Replace(_) => CredentialPermissionsType::Replace, + } + } +} + +impl CredentialPermissionsList { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for CredentialPermissionsList { + fn pickle(&self, out: &mut Vec) { + self.permissions.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.permissions = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CredentialPermissionsList { + fn default() -> Self { + Self { + permissions: Default::default(), + } + } +} + +impl IntoValue for CredentialPermissionsList { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CredentialPermissionsList { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Cron { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Cron::Daily(inner) => inner.validate(errors), + Cron::Weekly(inner) => inner.validate(errors), + Cron::Hourly(inner) => inner.validate(errors), + } + } +} + +impl Default for Cron { + fn default() -> Self { + Cron::Daily(Default::default()) + } +} + +impl Pickle for Cron { + fn pickle(&self, out: &mut Vec) { + match self { + Cron::Daily(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Cron::Weekly(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Cron::Hourly(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Cron::Daily), + 1 => Pickle::unpickle(stream).map(Cron::Weekly), + 2 => Pickle::unpickle(stream).map(Cron::Hourly), + _ => None, + } + } +} + +impl IntoValue for Cron { + fn into_value(self) -> JmapValue<'static> { + match self { + Cron::Daily(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Daily".into())); + obj + } + Cron::Weekly(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Weekly".into())); + obj + } + Cron::Hourly(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hourly".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Cron { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + CronType::Daily => *self = Cron::Daily(Default::default()), + CronType::Weekly => *self = Cron::Weekly(Default::default()), + CronType::Hourly => *self = Cron::Hourly(Default::default()), + } + } + match self { + Cron::Daily(inner) => inner.patch(pointer, value), + Cron::Weekly(inner) => inner.patch(pointer, value), + Cron::Hourly(inner) => inner.patch(pointer, value), + } + } +} + +impl Cron { + pub fn object_type(&self) -> CronType { + match self { + Cron::Daily(_) => CronType::Daily, + Cron::Weekly(_) => CronType::Weekly, + Cron::Hourly(_) => CronType::Hourly, + } + } +} + +impl CronDaily { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.hour; + if *value > 23 { + errors.push(ValidationError::max_value(Property::Hour, 23)); + } + let value = &self.minute; + if *value > 59 { + errors.push(ValidationError::max_value(Property::Minute, 59)); + } + errors.len() == neb + } +} + +impl Pickle for CronDaily { + fn pickle(&self, out: &mut Vec) { + self.hour.pickle(out); + self.minute.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.hour = Pickle::unpickle(stream)?; + this.minute = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CronDaily { + fn default() -> Self { + Self { + hour: 0u64, + minute: 0u64, + } + } +} + +impl IntoValue for CronDaily { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Hour, self.hour.into_value()); + map.insert_unchecked(Property::Minute, self.minute.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CronDaily { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Hour) => self.hour.patch(pointer, value), + Some(Property::Minute) => self.minute.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl CronHourly { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.minute; + if *value > 59 { + errors.push(ValidationError::max_value(Property::Minute, 59)); + } + errors.len() == neb + } +} + +impl Pickle for CronHourly { + fn pickle(&self, out: &mut Vec) { + self.minute.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.minute = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CronHourly { + fn default() -> Self { + Self { minute: 0u64 } + } +} + +impl IntoValue for CronHourly { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Minute, self.minute.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CronHourly { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Minute) => self.minute.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl CronWeekly { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.day; + if *value > 6 { + errors.push(ValidationError::max_value(Property::Day, 6)); + } + let value = &self.hour; + if *value > 23 { + errors.push(ValidationError::max_value(Property::Hour, 23)); + } + let value = &self.minute; + if *value > 59 { + errors.push(ValidationError::max_value(Property::Minute, 59)); + } + errors.len() == neb + } +} + +impl Pickle for CronWeekly { + fn pickle(&self, out: &mut Vec) { + self.day.pickle(out); + self.hour.pickle(out); + self.minute.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.day = Pickle::unpickle(stream)?; + this.hour = Pickle::unpickle(stream)?; + this.minute = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CronWeekly { + fn default() -> Self { + Self { + day: 0u64, + hour: 0u64, + minute: 0u64, + } + } +} + +impl IntoValue for CronWeekly { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Day, self.day.into_value()); + map.insert_unchecked(Property::Hour, self.hour.into_value()); + map.insert_unchecked(Property::Minute, self.minute.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CronWeekly { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Day) => self.day.patch(pointer, value), + Some(Property::Hour) => self.hour.patch(pointer, value), + Some(Property::Minute) => self.minute.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl CustomRoles { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::RoleIds)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + for id in self.role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + } +} + +impl Pickle for CustomRoles { + fn pickle(&self, out: &mut Vec) { + self.role_ids.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.role_ids = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for CustomRoles { + fn default() -> Self { + Self { + role_ids: Default::default(), + } + } +} + +impl IntoValue for CustomRoles { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::RoleIds, self.role_ids.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for CustomRoles { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::RoleIds) => self.role_ids.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DataRetention { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DataRetention; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.expunge_schedule; + value.validate(errors); + let value = &self.data_cleanup_schedule; + value.validate(errors); + let value = &self.blob_cleanup_schedule; + value.validate(errors); + let value = &self.metrics_collection_interval; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for DataRetention { + fn pickle(&self, out: &mut Vec) { + self.expunge_trash_after.pickle(out); + self.expunge_submissions_after.pickle(out); + self.expunge_share_notify_after.pickle(out); + self.expunge_scheduling_inbox_after.pickle(out); + self.expunge_schedule.pickle(out); + self.data_cleanup_schedule.pickle(out); + self.blob_cleanup_schedule.pickle(out); + self.max_changes_history.pickle(out); + self.archive_deleted_items_for.pickle(out); + self.archive_deleted_accounts_for.pickle(out); + self.hold_mta_reports_for.pickle(out); + self.hold_traces_for.pickle(out); + self.hold_metrics_for.pickle(out); + self.metrics_collection_interval.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.expunge_trash_after = Pickle::unpickle(stream)?; + this.expunge_submissions_after = Pickle::unpickle(stream)?; + this.expunge_share_notify_after = Pickle::unpickle(stream)?; + this.expunge_scheduling_inbox_after = Pickle::unpickle(stream)?; + this.expunge_schedule = Pickle::unpickle(stream)?; + this.data_cleanup_schedule = Pickle::unpickle(stream)?; + this.blob_cleanup_schedule = Pickle::unpickle(stream)?; + this.max_changes_history = Pickle::unpickle(stream)?; + this.archive_deleted_items_for = Pickle::unpickle(stream)?; + this.archive_deleted_accounts_for = Pickle::unpickle(stream)?; + this.hold_mta_reports_for = Pickle::unpickle(stream)?; + this.hold_traces_for = Pickle::unpickle(stream)?; + this.hold_metrics_for = Pickle::unpickle(stream)?; + this.metrics_collection_interval = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DataRetention { + fn default() -> Self { + Self { + expunge_trash_after: Some(Duration::from_millis(2592000000)), + expunge_submissions_after: Some(Duration::from_millis(259200000)), + expunge_share_notify_after: Some(Duration::from_millis(2592000000)), + expunge_scheduling_inbox_after: Some(Duration::from_millis(2592000000)), + expunge_schedule: Cron::Daily(CronDaily { + hour: 0u64, + minute: 0u64, + }), + data_cleanup_schedule: Cron::Daily(CronDaily { + hour: 2u64, + minute: 0u64, + }), + blob_cleanup_schedule: Cron::Daily(CronDaily { + hour: 4u64, + minute: 0u64, + }), + max_changes_history: Some(10000u64), + archive_deleted_items_for: Default::default(), + archive_deleted_accounts_for: Default::default(), + hold_mta_reports_for: Some(Duration::from_millis(2592000000)), + hold_traces_for: Some(Duration::from_millis(2592000000)), + hold_metrics_for: Some(Duration::from_millis(7776000000)), + metrics_collection_interval: Cron::Hourly(CronHourly { minute: 0u64 }), + } + } +} + +impl IntoValue for DataRetention { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(16); + map.insert_unchecked( + Property::ExpungeTrashAfter, + self.expunge_trash_after.into_value(), + ); + map.insert_unchecked( + Property::ExpungeSubmissionsAfter, + self.expunge_submissions_after.into_value(), + ); + map.insert_unchecked( + Property::ExpungeShareNotifyAfter, + self.expunge_share_notify_after.into_value(), + ); + map.insert_unchecked( + Property::ExpungeSchedulingInboxAfter, + self.expunge_scheduling_inbox_after.into_value(), + ); + map.insert_unchecked( + Property::ExpungeSchedule, + self.expunge_schedule.into_value(), + ); + map.insert_unchecked( + Property::DataCleanupSchedule, + self.data_cleanup_schedule.into_value(), + ); + map.insert_unchecked( + Property::BlobCleanupSchedule, + self.blob_cleanup_schedule.into_value(), + ); + map.insert_unchecked( + Property::MaxChangesHistory, + self.max_changes_history.into_value(), + ); + map.insert_unchecked( + Property::ArchiveDeletedItemsFor, + self.archive_deleted_items_for.into_value(), + ); + map.insert_unchecked( + Property::ArchiveDeletedAccountsFor, + self.archive_deleted_accounts_for.into_value(), + ); + map.insert_unchecked( + Property::HoldMtaReportsFor, + self.hold_mta_reports_for.into_value(), + ); + map.insert_unchecked(Property::HoldTracesFor, self.hold_traces_for.into_value()); + map.insert_unchecked(Property::HoldMetricsFor, self.hold_metrics_for.into_value()); + map.insert_unchecked( + Property::MetricsCollectionInterval, + self.metrics_collection_interval.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DataRetention { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ExpungeTrashAfter) => self.expunge_trash_after.patch(pointer, value), + Some(Property::ExpungeSubmissionsAfter) => { + self.expunge_submissions_after.patch(pointer, value) + } + Some(Property::ExpungeShareNotifyAfter) => { + self.expunge_share_notify_after.patch(pointer, value) + } + Some(Property::ExpungeSchedulingInboxAfter) => { + self.expunge_scheduling_inbox_after.patch(pointer, value) + } + Some(Property::ExpungeSchedule) => self.expunge_schedule.patch(pointer, value), + Some(Property::DataCleanupSchedule) => self.data_cleanup_schedule.patch(pointer, value), + Some(Property::BlobCleanupSchedule) => self.blob_cleanup_schedule.patch(pointer, value), + Some(Property::MaxChangesHistory) => self.max_changes_history.patch(pointer, value), + Some(Property::ArchiveDeletedItemsFor) => { + self.archive_deleted_items_for.patch(pointer, value) + } + Some(Property::ArchiveDeletedAccountsFor) => { + self.archive_deleted_accounts_for.patch(pointer, value) + } + Some(Property::HoldMtaReportsFor) => self.hold_mta_reports_for.patch(pointer, value), + Some(Property::HoldTracesFor) => self.hold_traces_for.patch(pointer, value), + Some(Property::HoldMetricsFor) => self.hold_metrics_for.patch(pointer, value), + Some(Property::MetricsCollectionInterval) => { + self.metrics_collection_interval.patch(pointer, value) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DataStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::DataStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DataStore::RocksDb(inner) => inner.validate(errors), + DataStore::Sqlite(inner) => inner.validate(errors), + DataStore::FoundationDb(inner) => inner.validate(errors), + DataStore::PostgreSql(inner) => inner.validate(errors), + DataStore::MySql(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for DataStore { + fn default() -> Self { + DataStore::RocksDb(Default::default()) + } +} + +impl Pickle for DataStore { + fn pickle(&self, out: &mut Vec) { + match self { + DataStore::RocksDb(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + DataStore::Sqlite(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DataStore::FoundationDb(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DataStore::PostgreSql(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + DataStore::MySql(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(DataStore::RocksDb), + 1 => Pickle::unpickle(stream).map(DataStore::Sqlite), + 2 => Pickle::unpickle(stream).map(DataStore::FoundationDb), + 3 => Pickle::unpickle(stream).map(DataStore::PostgreSql), + 4 => Pickle::unpickle(stream).map(DataStore::MySql), + _ => None, + } + } +} + +impl IntoValue for DataStore { + fn into_value(self) -> JmapValue<'static> { + match self { + DataStore::RocksDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RocksDb".into())); + obj + } + DataStore::Sqlite(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sqlite".into())); + obj + } + DataStore::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + DataStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + DataStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DataStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DataStoreType::RocksDb => *self = DataStore::RocksDb(Default::default()), + DataStoreType::Sqlite => *self = DataStore::Sqlite(Default::default()), + DataStoreType::FoundationDb => *self = DataStore::FoundationDb(Default::default()), + DataStoreType::PostgreSql => *self = DataStore::PostgreSql(Default::default()), + DataStoreType::MySql => *self = DataStore::MySql(Default::default()), + } + } + match self { + DataStore::RocksDb(inner) => inner.patch(pointer, value), + DataStore::Sqlite(inner) => inner.patch(pointer, value), + DataStore::FoundationDb(inner) => inner.patch(pointer, value), + DataStore::PostgreSql(inner) => inner.patch(pointer, value), + DataStore::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl DataStore { + pub fn object_type(&self) -> DataStoreType { + match self { + DataStore::RocksDb(_) => DataStoreType::RocksDb, + DataStore::Sqlite(_) => DataStoreType::Sqlite, + DataStore::FoundationDb(_) => DataStoreType::FoundationDb, + DataStore::PostgreSql(_) => DataStoreType::PostgreSql, + DataStore::MySql(_) => DataStoreType::MySql, + } + } +} + +impl DeliveryError { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.error_message { + if value.is_empty() { + errors.push(ValidationError::required(Property::ErrorMessage)); + } + } + if let Some(value) = &self.error_command { + if value.is_empty() { + errors.push(ValidationError::required(Property::ErrorCommand)); + } + } + if let Some(value) = &self.response_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseHostname)); + } + } + if let Some(value) = &self.response_code { + if *value < 100 { + errors.push(ValidationError::min_value(Property::ResponseCode, 100)); + } + if *value > 599 { + errors.push(ValidationError::max_value(Property::ResponseCode, 599)); + } + } + if let Some(value) = &self.response_enhanced { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseEnhanced)); + } + } + if let Some(value) = &self.response_message { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseMessage)); + } + } + errors.len() == neb + } +} + +impl Pickle for DeliveryError { + fn pickle(&self, out: &mut Vec) { + self.error_type.pickle(out); + self.error_message.pickle(out); + self.error_command.pickle(out); + self.response_hostname.pickle(out); + self.response_code.pickle(out); + self.response_enhanced.pickle(out); + self.response_message.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.error_type = Pickle::unpickle(stream)?; + this.error_message = Pickle::unpickle(stream)?; + this.error_command = Pickle::unpickle(stream)?; + this.response_hostname = Pickle::unpickle(stream)?; + this.response_code = Pickle::unpickle(stream)?; + this.response_enhanced = Pickle::unpickle(stream)?; + this.response_message = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DeliveryError { + fn default() -> Self { + Self { + error_type: Default::default(), + error_message: Default::default(), + error_command: Default::default(), + response_hostname: Default::default(), + response_code: Default::default(), + response_enhanced: Default::default(), + response_message: Default::default(), + } + } +} + +impl IntoValue for DeliveryError { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::ErrorType, self.error_type.into_value()); + map.insert_unchecked(Property::ErrorMessage, self.error_message.into_value()); + map.insert_unchecked(Property::ErrorCommand, self.error_command.into_value()); + map.insert_unchecked( + Property::ResponseHostname, + self.response_hostname.into_value(), + ); + map.insert_unchecked(Property::ResponseCode, self.response_code.into_value()); + map.insert_unchecked( + Property::ResponseEnhanced, + self.response_enhanced.into_value(), + ); + map.insert_unchecked( + Property::ResponseMessage, + self.response_message.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DeliveryError { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ErrorType) => self.error_type.patch(pointer, value), + Some(Property::ErrorMessage) => self.error_message.patch(pointer, value), + Some(Property::ErrorCommand) => self.error_command.patch(pointer, value), + Some(Property::ResponseHostname) => self.response_hostname.patch(pointer, value), + Some(Property::ResponseCode) => self.response_code.patch(pointer, value), + Some(Property::ResponseEnhanced) => self.response_enhanced.patch(pointer, value), + Some(Property::ResponseMessage) => self.response_message.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Directory { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Directory; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Directory::Ldap(inner) => inner.validate(errors), + Directory::Sql(inner) => inner.validate(errors), + Directory::Oidc(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + Directory::Ldap(object) => { + object.index(i); + } + Directory::Sql(object) => { + object.index(i); + } + Directory::Oidc(object) => { + object.index(i); + } + } + } +} + +impl Default for Directory { + fn default() -> Self { + Directory::Ldap(Default::default()) + } +} + +impl Pickle for Directory { + fn pickle(&self, out: &mut Vec) { + match self { + Directory::Ldap(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Directory::Sql(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Directory::Oidc(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Directory::Ldap), + 1 => Pickle::unpickle(stream).map(Directory::Sql), + 2 => Pickle::unpickle(stream).map(Directory::Oidc), + _ => None, + } + } +} + +impl IntoValue for Directory { + fn into_value(self) -> JmapValue<'static> { + match self { + Directory::Ldap(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ldap".into())); + obj + } + Directory::Sql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sql".into())); + obj + } + Directory::Oidc(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Oidc".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Directory { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DirectoryType::Ldap => *self = Directory::Ldap(Default::default()), + DirectoryType::Sql => *self = Directory::Sql(Default::default()), + DirectoryType::Oidc => *self = Directory::Oidc(Default::default()), + } + } + match self { + Directory::Ldap(inner) => inner.patch(pointer, value), + Directory::Sql(inner) => inner.patch(pointer, value), + Directory::Oidc(inner) => inner.patch(pointer, value), + } + } +} + +impl Directory { + pub fn object_type(&self) -> DirectoryType { + match self { + Directory::Ldap(_) => DirectoryType::Ldap, + Directory::Sql(_) => DirectoryType::Sql, + Directory::Oidc(_) => DirectoryType::Oidc, + } + } +} + +impl DirectoryBootstrap { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DirectoryBootstrap::Internal => true, + DirectoryBootstrap::Ldap(inner) => inner.validate(errors), + DirectoryBootstrap::Sql(inner) => inner.validate(errors), + DirectoryBootstrap::Oidc(inner) => inner.validate(errors), + } + } +} + +impl Default for DirectoryBootstrap { + fn default() -> Self { + DirectoryBootstrap::Internal + } +} + +impl Pickle for DirectoryBootstrap { + fn pickle(&self, out: &mut Vec) { + match self { + DirectoryBootstrap::Internal => { + 0u16.pickle(out); + } + DirectoryBootstrap::Ldap(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DirectoryBootstrap::Sql(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DirectoryBootstrap::Oidc(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(DirectoryBootstrap::Internal), + 1 => Pickle::unpickle(stream).map(DirectoryBootstrap::Ldap), + 2 => Pickle::unpickle(stream).map(DirectoryBootstrap::Sql), + 3 => Pickle::unpickle(stream).map(DirectoryBootstrap::Oidc), + _ => None, + } + } +} + +impl IntoValue for DirectoryBootstrap { + fn into_value(self) -> JmapValue<'static> { + match self { + DirectoryBootstrap::Internal => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Internal".into())); + JmapValue::Object(obj) + } + DirectoryBootstrap::Ldap(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ldap".into())); + obj + } + DirectoryBootstrap::Sql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sql".into())); + obj + } + DirectoryBootstrap::Oidc(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Oidc".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DirectoryBootstrap { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DirectoryBootstrapType::Internal => *self = DirectoryBootstrap::Internal, + DirectoryBootstrapType::Ldap => { + *self = DirectoryBootstrap::Ldap(Default::default()) + } + DirectoryBootstrapType::Sql => *self = DirectoryBootstrap::Sql(Default::default()), + DirectoryBootstrapType::Oidc => { + *self = DirectoryBootstrap::Oidc(Default::default()) + } + } + } + match self { + DirectoryBootstrap::Internal => pointer.assert_eof(), + DirectoryBootstrap::Ldap(inner) => inner.patch(pointer, value), + DirectoryBootstrap::Sql(inner) => inner.patch(pointer, value), + DirectoryBootstrap::Oidc(inner) => inner.patch(pointer, value), + } + } +} + +impl DirectoryBootstrap { + pub fn object_type(&self) -> DirectoryBootstrapType { + match self { + DirectoryBootstrap::Internal => DirectoryBootstrapType::Internal, + DirectoryBootstrap::Ldap(_) => DirectoryBootstrapType::Ldap, + DirectoryBootstrap::Sql(_) => DirectoryBootstrapType::Sql, + DirectoryBootstrap::Oidc(_) => DirectoryBootstrapType::Oidc, + } + } +} + +impl Dkim1Signature { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.auid { + if value.is_empty() { + errors.push(ValidationError::required(Property::Auid)); + } + } + let value = &self.headers; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Headers)); + } + } + let value = &self.private_key; + value.validate(errors); + if let Some(value) = &self.third_party { + if value.is_empty() { + errors.push(ValidationError::required(Property::ThirdParty)); + } + } + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.selector; + if value.is_empty() { + errors.push(ValidationError::required(Property::Selector)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.next_transition_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NextTransitionAt, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + i.search(Property::DomainId, &self.domain_id); + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for Dkim1Signature { + fn pickle(&self, out: &mut Vec) { + self.auid.pickle(out); + self.canonicalization.pickle(out); + self.expire.pickle(out); + self.headers.pickle(out); + self.private_key.pickle(out); + self.report.pickle(out); + self.third_party.pickle(out); + self.third_party_hash.pickle(out); + self.domain_id.pickle(out); + self.member_tenant_id.pickle(out); + self.selector.pickle(out); + self.created_at.pickle(out); + self.next_transition_at.pickle(out); + self.stage.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auid = Pickle::unpickle(stream)?; + this.canonicalization = Pickle::unpickle(stream)?; + this.expire = Pickle::unpickle(stream)?; + this.headers = Pickle::unpickle(stream)?; + this.private_key = Pickle::unpickle(stream)?; + this.report = Pickle::unpickle(stream)?; + this.third_party = Pickle::unpickle(stream)?; + this.third_party_hash = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.selector = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.next_transition_at = Pickle::unpickle(stream)?; + this.stage = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Dkim1Signature { + fn default() -> Self { + Self { + auid: Default::default(), + canonicalization: DkimCanonicalization::RelaxedRelaxed, + expire: Default::default(), + headers: Map::new(vec![ + "From".to_string(), + "To".to_string(), + "Date".to_string(), + "Subject".to_string(), + "Message-ID".to_string(), + ]), + private_key: Default::default(), + report: true, + third_party: Default::default(), + third_party_hash: Default::default(), + domain_id: Default::default(), + member_tenant_id: Default::default(), + selector: Default::default(), + created_at: Default::default(), + next_transition_at: Default::default(), + stage: DkimRotationStage::Active, + } + } +} + +impl IntoValue for Dkim1Signature { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(16); + map.insert_unchecked(Property::Auid, self.auid.into_value()); + map.insert_unchecked( + Property::Canonicalization, + self.canonicalization.into_value(), + ); + map.insert_unchecked(Property::Expire, self.expire.into_value()); + map.insert_unchecked(Property::Headers, self.headers.into_value()); + map.insert_unchecked(Property::PrivateKey, self.private_key.into_value()); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::ThirdParty, self.third_party.into_value()); + map.insert_unchecked(Property::ThirdPartyHash, self.third_party_hash.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Selector, self.selector.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked( + Property::NextTransitionAt, + self.next_transition_at.into_value(), + ); + map.insert_unchecked(Property::Stage, self.stage.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Dkim1Signature { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Auid) => self + .auid + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Canonicalization) => self.canonicalization.patch(pointer, value), + Some(Property::Expire) => self.expire.patch(pointer, value), + Some(Property::Headers) => self + .headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PrivateKey) => self.private_key.patch(pointer, value), + Some(Property::PublicKey) => pointer.assert_server_set(), + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::ThirdParty) => self + .third_party + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ThirdPartyHash) => self.third_party_hash.patch(pointer, value), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Selector) => self + .selector + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::NextTransitionAt) => self.next_transition_at.patch(pointer, value), + Some(Property::Stage) => self.stage.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Dkim2Signature { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.private_key; + value.validate(errors); + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.selector; + if value.is_empty() { + errors.push(ValidationError::required(Property::Selector)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.next_transition_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NextTransitionAt, value)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + i.search(Property::DomainId, &self.domain_id); + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for Dkim2Signature { + fn pickle(&self, out: &mut Vec) { + self.flags.pickle(out); + self.private_key.pickle(out); + self.domain_id.pickle(out); + self.member_tenant_id.pickle(out); + self.selector.pickle(out); + self.created_at.pickle(out); + self.next_transition_at.pickle(out); + self.stage.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.flags = Pickle::unpickle(stream)?; + this.private_key = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.selector = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.next_transition_at = Pickle::unpickle(stream)?; + this.stage = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Dkim2Signature { + fn default() -> Self { + Self { + flags: Default::default(), + private_key: Default::default(), + domain_id: Default::default(), + member_tenant_id: Default::default(), + selector: Default::default(), + created_at: Default::default(), + next_transition_at: Default::default(), + stage: DkimRotationStage::Active, + } + } +} + +impl IntoValue for Dkim2Signature { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Flags, self.flags.into_value()); + map.insert_unchecked(Property::PrivateKey, self.private_key.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Selector, self.selector.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked( + Property::NextTransitionAt, + self.next_transition_at.into_value(), + ); + map.insert_unchecked(Property::Stage, self.stage.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Dkim2Signature { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Flags) => self.flags.patch(pointer, value), + Some(Property::PrivateKey) => self.private_key.patch(pointer, value), + Some(Property::PublicKey) => pointer.assert_server_set(), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Selector) => self + .selector + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::NextTransitionAt) => self.next_transition_at.patch(pointer, value), + Some(Property::Stage) => self.stage.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DkimManagement { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DkimManagement::Automatic(inner) => inner.validate(errors), + DkimManagement::Manual => true, + } + } +} + +impl Default for DkimManagement { + fn default() -> Self { + DkimManagement::Automatic(Default::default()) + } +} + +impl Pickle for DkimManagement { + fn pickle(&self, out: &mut Vec) { + match self { + DkimManagement::Automatic(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + DkimManagement::Manual => { + 1u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(DkimManagement::Automatic), + 1 => Some(DkimManagement::Manual), + _ => None, + } + } +} + +impl IntoValue for DkimManagement { + fn into_value(self) -> JmapValue<'static> { + match self { + DkimManagement::Automatic(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Automatic".into())); + obj + } + DkimManagement::Manual => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Manual".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for DkimManagement { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DkimManagementType::Automatic => { + *self = DkimManagement::Automatic(Default::default()) + } + DkimManagementType::Manual => *self = DkimManagement::Manual, + } + } + match self { + DkimManagement::Automatic(inner) => inner.patch(pointer, value), + DkimManagement::Manual => pointer.assert_eof(), + } + } +} + +impl DkimManagement { + pub fn object_type(&self) -> DkimManagementType { + match self { + DkimManagement::Automatic(_) => DkimManagementType::Automatic, + DkimManagement::Manual => DkimManagementType::Manual, + } + } +} + +impl DkimManagementProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.selector_template; + if value.is_empty() { + errors.push(ValidationError::required(Property::SelectorTemplate)); + } + errors.len() == neb + } +} + +impl Pickle for DkimManagementProperties { + fn pickle(&self, out: &mut Vec) { + self.algorithms.pickle(out); + self.selector_template.pickle(out); + self.rotate_after.pickle(out); + self.retire_after.pickle(out); + self.delete_after.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.algorithms = Pickle::unpickle(stream)?; + this.selector_template = Pickle::unpickle(stream)?; + this.rotate_after = Pickle::unpickle(stream)?; + this.retire_after = Pickle::unpickle(stream)?; + this.delete_after = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DkimManagementProperties { + fn default() -> Self { + Self { + algorithms: Map::new(vec![ + DkimSignatureType::Dkim1Ed25519Sha256, + DkimSignatureType::Dkim1RsaSha256, + ]), + selector_template: "v{version}-{algorithm}-{date-%Y%m%d}".to_string(), + rotate_after: Duration::from_millis(7776000000), + retire_after: Duration::from_millis(604800000), + delete_after: Duration::from_millis(2592000000), + } + } +} + +impl IntoValue for DkimManagementProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Algorithms, self.algorithms.into_value()); + map.insert_unchecked( + Property::SelectorTemplate, + self.selector_template.into_value(), + ); + map.insert_unchecked(Property::RotateAfter, self.rotate_after.into_value()); + map.insert_unchecked(Property::RetireAfter, self.retire_after.into_value()); + map.insert_unchecked(Property::DeleteAfter, self.delete_after.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DkimManagementProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Algorithms) => self.algorithms.patch(pointer, value), + Some(Property::SelectorTemplate) => self + .selector_template + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::RotateAfter) => self.rotate_after.patch(pointer, value), + Some(Property::RetireAfter) => self.retire_after.patch(pointer, value), + Some(Property::DeleteAfter) => self.delete_after.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DkimReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DkimReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from_address; + value.validate(errors); + let value = &self.from_name; + value.validate(errors); + let value = &self.send_frequency; + value.validate(errors); + let value = &self.dkim_sign_domain; + value.validate(errors); + let value = &self.subject; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl DkimReportSettings { + pub fn ctx_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_address, + default: Some(Expression { + else_: "'noreply-dkim@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::FromAddress, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_name, + default: Some(Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }), + property: Property::FromName, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_send_frequency(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.send_frequency, + default: Some(Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }), + property: Property::SendFrequency, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_subject(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.subject, + default: Some(Expression { + else_: "'DKIM Authentication Failure Report'".to_string(), + ..Default::default() + }), + property: Property::Subject, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_from_address(), + self.ctx_from_name(), + self.ctx_send_frequency(), + self.ctx_dkim_sign_domain(), + self.ctx_subject(), + ] + } +} + +impl Pickle for DkimReportSettings { + fn pickle(&self, out: &mut Vec) { + self.from_address.pickle(out); + self.from_name.pickle(out); + self.send_frequency.pickle(out); + self.dkim_sign_domain.pickle(out); + self.subject.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from_address = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.send_frequency = Pickle::unpickle(stream)?; + this.dkim_sign_domain = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DkimReportSettings { + fn default() -> Self { + Self { + from_address: Expression { + else_: "'noreply-dkim@' + system('domain')".to_string(), + ..Default::default() + }, + from_name: Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }, + send_frequency: Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + subject: Expression { + else_: "'DKIM Authentication Failure Report'".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for DkimReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::FromAddress, self.from_address.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked(Property::SendFrequency, self.send_frequency.into_value()); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DkimReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::FromAddress) => self.from_address.patch(pointer, value), + Some(Property::FromName) => self.from_name.patch(pointer, value), + Some(Property::SendFrequency) => self.send_frequency.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DkimSignature { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DkimSignature; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DkimSignature::Dkim1Ed25519Sha256(inner) => inner.validate(errors), + DkimSignature::Dkim1RsaSha256(inner) => inner.validate(errors), + DkimSignature::Dkim2Ed25519Sha256(inner) => inner.validate(errors), + DkimSignature::Dkim2RsaSha256(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + DkimSignature::Dkim1Ed25519Sha256(object) => { + object.index(i); + } + DkimSignature::Dkim1RsaSha256(object) => { + object.index(i); + } + DkimSignature::Dkim2Ed25519Sha256(object) => { + object.index(i); + } + DkimSignature::Dkim2RsaSha256(object) => { + object.index(i); + } + } + } +} + +impl Default for DkimSignature { + fn default() -> Self { + DkimSignature::Dkim1Ed25519Sha256(Default::default()) + } +} + +impl Pickle for DkimSignature { + fn pickle(&self, out: &mut Vec) { + match self { + DkimSignature::Dkim1Ed25519Sha256(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + DkimSignature::Dkim1RsaSha256(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DkimSignature::Dkim2Ed25519Sha256(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DkimSignature::Dkim2RsaSha256(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(DkimSignature::Dkim1Ed25519Sha256), + 1 => Pickle::unpickle(stream).map(DkimSignature::Dkim1RsaSha256), + 2 => Pickle::unpickle(stream).map(DkimSignature::Dkim2Ed25519Sha256), + 3 => Pickle::unpickle(stream).map(DkimSignature::Dkim2RsaSha256), + _ => None, + } + } +} + +impl IntoValue for DkimSignature { + fn into_value(self) -> JmapValue<'static> { + match self { + DkimSignature::Dkim1Ed25519Sha256(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dkim1Ed25519Sha256".into())); + obj + } + DkimSignature::Dkim1RsaSha256(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dkim1RsaSha256".into())); + obj + } + DkimSignature::Dkim2Ed25519Sha256(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dkim2Ed25519Sha256".into())); + obj + } + DkimSignature::Dkim2RsaSha256(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dkim2RsaSha256".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DkimSignature { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DkimSignatureType::Dkim1Ed25519Sha256 => { + *self = DkimSignature::Dkim1Ed25519Sha256(Default::default()) + } + DkimSignatureType::Dkim1RsaSha256 => { + *self = DkimSignature::Dkim1RsaSha256(Default::default()) + } + DkimSignatureType::Dkim2Ed25519Sha256 => { + *self = DkimSignature::Dkim2Ed25519Sha256(Default::default()) + } + DkimSignatureType::Dkim2RsaSha256 => { + *self = DkimSignature::Dkim2RsaSha256(Default::default()) + } + } + } + match self { + DkimSignature::Dkim1Ed25519Sha256(inner) => inner.patch(pointer, value), + DkimSignature::Dkim1RsaSha256(inner) => inner.patch(pointer, value), + DkimSignature::Dkim2Ed25519Sha256(inner) => inner.patch(pointer, value), + DkimSignature::Dkim2RsaSha256(inner) => inner.patch(pointer, value), + } + } +} + +impl DkimSignature { + pub fn object_type(&self) -> DkimSignatureType { + match self { + DkimSignature::Dkim1Ed25519Sha256(_) => DkimSignatureType::Dkim1Ed25519Sha256, + DkimSignature::Dkim1RsaSha256(_) => DkimSignatureType::Dkim1RsaSha256, + DkimSignature::Dkim2Ed25519Sha256(_) => DkimSignatureType::Dkim2Ed25519Sha256, + DkimSignature::Dkim2RsaSha256(_) => DkimSignatureType::Dkim2RsaSha256, + } + } +} + +impl DmarcDkimResult { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::Domain)); + } + let value = &self.selector; + if value.is_empty() { + errors.push(ValidationError::required(Property::Selector)); + } + if let Some(value) = &self.human_result { + if value.is_empty() { + errors.push(ValidationError::required(Property::HumanResult)); + } + } + errors.len() == neb + } +} + +impl Pickle for DmarcDkimResult { + fn pickle(&self, out: &mut Vec) { + self.domain.pickle(out); + self.selector.pickle(out); + self.result.pickle(out); + self.human_result.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.domain = Pickle::unpickle(stream)?; + this.selector = Pickle::unpickle(stream)?; + this.result = Pickle::unpickle(stream)?; + this.human_result = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcDkimResult { + fn default() -> Self { + Self { + domain: Default::default(), + selector: Default::default(), + result: Default::default(), + human_result: Default::default(), + } + } +} + +impl IntoValue for DmarcDkimResult { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Domain, self.domain.into_value()); + map.insert_unchecked(Property::Selector, self.selector.into_value()); + map.insert_unchecked(Property::Result, self.result.into_value()); + map.insert_unchecked(Property::HumanResult, self.human_result.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcDkimResult { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Domain) => self + .domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::Selector) => self.selector.patch(pointer, value), + Some(Property::Result) => self.result.patch(pointer, value), + Some(Property::HumanResult) => self.human_result.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcExtension { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.definition; + if value.is_empty() { + errors.push(ValidationError::required(Property::Definition)); + } + errors.len() == neb + } +} + +impl Pickle for DmarcExtension { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.definition.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.definition = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcExtension { + fn default() -> Self { + Self { + name: Default::default(), + definition: Default::default(), + } + } +} + +impl IntoValue for DmarcExtension { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Definition, self.definition.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcExtension { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer, value), + Some(Property::Definition) => self.definition.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DmarcExternalReport { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::DmarcExternalReport; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.report; + value.validate(errors); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + let value = &self.received_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivedAt, value)); + } + let value = &self.expires_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + } +} + +impl Pickle for DmarcExternalReport { + fn pickle(&self, out: &mut Vec) { + self.report.pickle(out); + self.from.pickle(out); + self.subject.pickle(out); + self.to.pickle(out); + self.received_at.pickle(out); + self.expires_at.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.report = Pickle::unpickle(stream)?; + this.from = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + this.received_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcExternalReport { + fn default() -> Self { + Self { + report: Default::default(), + from: Default::default(), + subject: Default::default(), + to: Default::default(), + received_at: Default::default(), + expires_at: Default::default(), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for DmarcExternalReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + map.insert_unchecked(Property::ReceivedAt, self.received_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcExternalReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::From) => self + .from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::To) => self + .to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::ReceivedAt) => self.received_at.patch(pointer, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DmarcInternalReport { + const FLAGS: u64 = 0; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::DmarcInternalReport; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.rua; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Rua)); + } + } + let value = &self.report; + value.validate(errors); + let value = &self.domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::Domain)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.deliver_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DeliverAt, value)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for DmarcInternalReport { + fn pickle(&self, out: &mut Vec) { + self.rua.pickle(out); + self.policy_identifier.pickle(out); + self.report.pickle(out); + self.domain.pickle(out); + self.created_at.pickle(out); + self.deliver_at.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.rua = Pickle::unpickle(stream)?; + this.policy_identifier = Pickle::unpickle(stream)?; + this.report = Pickle::unpickle(stream)?; + this.domain = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.deliver_at = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcInternalReport { + fn default() -> Self { + Self { + rua: Default::default(), + policy_identifier: 0u64, + report: Default::default(), + domain: Default::default(), + created_at: Default::default(), + deliver_at: Default::default(), + } + } +} + +impl IntoValue for DmarcInternalReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Rua, self.rua.into_value()); + map.insert_unchecked( + Property::PolicyIdentifier, + self.policy_identifier.into_value(), + ); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::Domain, self.domain.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::DeliverAt, self.deliver_at.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcInternalReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Rua) => self + .rua + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::PolicyIdentifier) => self.policy_identifier.patch(pointer, value), + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::Domain) => self + .domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::CreatedAt) => self.created_at.patch(pointer, value), + Some(Property::DeliverAt) => self.deliver_at.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcPolicyOverrideReason { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.comment { + if value.is_empty() { + errors.push(ValidationError::required(Property::Comment)); + } + } + errors.len() == neb + } +} + +impl Pickle for DmarcPolicyOverrideReason { + fn pickle(&self, out: &mut Vec) { + self.override_type.pickle(out); + self.comment.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.override_type = Pickle::unpickle(stream)?; + this.comment = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcPolicyOverrideReason { + fn default() -> Self { + Self { + override_type: Default::default(), + comment: Default::default(), + } + } +} + +impl IntoValue for DmarcPolicyOverrideReason { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::OverrideType, self.override_type.into_value()); + map.insert_unchecked(Property::Comment, self.comment.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcPolicyOverrideReason { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::OverrideType) => self.override_type.patch(pointer, value), + Some(Property::Comment) => self.comment.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcReport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.org_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::OrgName)); + } + let value = &self.email; + if value.is_empty() { + errors.push(ValidationError::required(Property::Email)); + } + if let Some(value) = &self.extra_contact_info { + if value.is_empty() { + errors.push(ValidationError::required(Property::ExtraContactInfo)); + } + } + let value = &self.report_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportId)); + } + let value = &self.date_range_begin; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DateRangeBegin, value)); + } + let value = &self.date_range_end; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DateRangeEnd, value)); + } + let value = &self.errors; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Errors)); + } + } + let value = &self.policy_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::PolicyDomain)); + } + if let Some(value) = &self.policy_version { + if value.is_empty() { + errors.push(ValidationError::required(Property::PolicyVersion)); + } + } + let value = &self.records; + for value in value.values() { + value.validate(errors); + } + let value = &self.extensions; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.generator { + if value.is_empty() { + errors.push(ValidationError::required(Property::Generator)); + } + } + errors.len() == neb + } +} + +impl Pickle for DmarcReport { + fn pickle(&self, out: &mut Vec) { + self.version.pickle(out); + self.org_name.pickle(out); + self.email.pickle(out); + self.extra_contact_info.pickle(out); + self.report_id.pickle(out); + self.date_range_begin.pickle(out); + self.date_range_end.pickle(out); + self.errors.pickle(out); + self.policy_domain.pickle(out); + self.policy_version.pickle(out); + self.policy_adkim.pickle(out); + self.policy_aspf.pickle(out); + self.policy_disposition.pickle(out); + self.policy_subdomain_disposition.pickle(out); + self.policy_testing_mode.pickle(out); + self.policy_failure_reporting_options.pickle(out); + self.records.pickle(out); + self.extensions.pickle(out); + self.generator.pickle(out); + self.policy_np.pickle(out); + self.policy_discovery_method.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.version = Pickle::unpickle(stream)?; + this.org_name = Pickle::unpickle(stream)?; + this.email = Pickle::unpickle(stream)?; + this.extra_contact_info = Pickle::unpickle(stream)?; + this.report_id = Pickle::unpickle(stream)?; + this.date_range_begin = Pickle::unpickle(stream)?; + this.date_range_end = Pickle::unpickle(stream)?; + this.errors = Pickle::unpickle(stream)?; + this.policy_domain = Pickle::unpickle(stream)?; + this.policy_version = Pickle::unpickle(stream)?; + this.policy_adkim = Pickle::unpickle(stream)?; + this.policy_aspf = Pickle::unpickle(stream)?; + this.policy_disposition = Pickle::unpickle(stream)?; + this.policy_subdomain_disposition = Pickle::unpickle(stream)?; + this.policy_testing_mode = Pickle::unpickle(stream)?; + this.policy_failure_reporting_options = Pickle::unpickle(stream)?; + this.records = Pickle::unpickle(stream)?; + this.extensions = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.generator = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.policy_np = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.policy_discovery_method = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for DmarcReport { + fn default() -> Self { + Self { + version: Float::new(1.0f64), + org_name: Default::default(), + email: Default::default(), + extra_contact_info: Default::default(), + report_id: Default::default(), + date_range_begin: Default::default(), + date_range_end: Default::default(), + errors: Default::default(), + policy_domain: Default::default(), + policy_version: Default::default(), + policy_adkim: Default::default(), + policy_aspf: Default::default(), + policy_disposition: Default::default(), + policy_subdomain_disposition: Default::default(), + policy_testing_mode: false, + policy_failure_reporting_options: Default::default(), + records: Default::default(), + extensions: Default::default(), + generator: Default::default(), + policy_np: DmarcDisposition::Unspecified, + policy_discovery_method: DmarcDiscovery::Unspecified, + } + } +} + +impl IntoValue for DmarcReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(23); + map.insert_unchecked(Property::Version, self.version.into_value()); + map.insert_unchecked(Property::OrgName, self.org_name.into_value()); + map.insert_unchecked(Property::Email, self.email.into_value()); + map.insert_unchecked( + Property::ExtraContactInfo, + self.extra_contact_info.into_value(), + ); + map.insert_unchecked(Property::ReportId, self.report_id.into_value()); + map.insert_unchecked(Property::DateRangeBegin, self.date_range_begin.into_value()); + map.insert_unchecked(Property::DateRangeEnd, self.date_range_end.into_value()); + map.insert_unchecked(Property::Errors, self.errors.into_value()); + map.insert_unchecked(Property::PolicyDomain, self.policy_domain.into_value()); + map.insert_unchecked(Property::PolicyVersion, self.policy_version.into_value()); + map.insert_unchecked(Property::PolicyAdkim, self.policy_adkim.into_value()); + map.insert_unchecked(Property::PolicyAspf, self.policy_aspf.into_value()); + map.insert_unchecked( + Property::PolicyDisposition, + self.policy_disposition.into_value(), + ); + map.insert_unchecked( + Property::PolicySubdomainDisposition, + self.policy_subdomain_disposition.into_value(), + ); + map.insert_unchecked( + Property::PolicyTestingMode, + self.policy_testing_mode.into_value(), + ); + map.insert_unchecked( + Property::PolicyFailureReportingOptions, + self.policy_failure_reporting_options.into_value(), + ); + map.insert_unchecked(Property::Records, self.records.into_value()); + map.insert_unchecked(Property::Extensions, self.extensions.into_value()); + map.insert_unchecked(Property::Generator, self.generator.into_value()); + map.insert_unchecked(Property::PolicyNp, self.policy_np.into_value()); + map.insert_unchecked( + Property::PolicyDiscoveryMethod, + self.policy_discovery_method.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Version) => self.version.patch(pointer, value), + Some(Property::OrgName) => self.org_name.patch(pointer, value), + Some(Property::Email) => self + .email + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::ExtraContactInfo) => self.extra_contact_info.patch(pointer, value), + Some(Property::ReportId) => self.report_id.patch(pointer, value), + Some(Property::DateRangeBegin) => self.date_range_begin.patch(pointer, value), + Some(Property::DateRangeEnd) => self.date_range_end.patch(pointer, value), + Some(Property::Errors) => self.errors.patch(pointer, value), + Some(Property::PolicyDomain) => self.policy_domain.patch(pointer, value), + Some(Property::PolicyVersion) => self.policy_version.patch(pointer, value), + Some(Property::PolicyAdkim) => self.policy_adkim.patch(pointer, value), + Some(Property::PolicyAspf) => self.policy_aspf.patch(pointer, value), + Some(Property::PolicyDisposition) => self.policy_disposition.patch(pointer, value), + Some(Property::PolicySubdomainDisposition) => { + self.policy_subdomain_disposition.patch(pointer, value) + } + Some(Property::PolicyTestingMode) => self.policy_testing_mode.patch(pointer, value), + Some(Property::PolicyFailureReportingOptions) => { + self.policy_failure_reporting_options.patch(pointer, value) + } + Some(Property::Records) => self.records.patch(pointer, value), + Some(Property::Extensions) => self.extensions.patch(pointer, value), + Some(Property::Generator) => self.generator.patch(pointer, value), + Some(Property::PolicyNp) => self.policy_np.patch(pointer, value), + Some(Property::PolicyDiscoveryMethod) => { + self.policy_discovery_method.patch(pointer, value) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcReportRecord { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.source_ip { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::SourceIp, value)); + } + } + let value = &self.policy_override_reasons; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.envelope_to { + if value.is_empty() { + errors.push(ValidationError::required(Property::EnvelopeTo)); + } + } + let value = &self.envelope_from; + if value.is_empty() { + errors.push(ValidationError::required(Property::EnvelopeFrom)); + } + let value = &self.header_from; + if value.is_empty() { + errors.push(ValidationError::required(Property::HeaderFrom)); + } + let value = &self.dkim_results; + for value in value.values() { + value.validate(errors); + } + let value = &self.spf_results; + for value in value.values() { + value.validate(errors); + } + let value = &self.extensions; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for DmarcReportRecord { + fn pickle(&self, out: &mut Vec) { + self.source_ip.pickle(out); + self.count.pickle(out); + self.evaluated_disposition.pickle(out); + self.evaluated_dkim.pickle(out); + self.evaluated_spf.pickle(out); + self.policy_override_reasons.pickle(out); + self.envelope_to.pickle(out); + self.envelope_from.pickle(out); + self.header_from.pickle(out); + self.dkim_results.pickle(out); + self.spf_results.pickle(out); + self.extensions.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.source_ip = Pickle::unpickle(stream)?; + this.count = Pickle::unpickle(stream)?; + this.evaluated_disposition = Pickle::unpickle(stream)?; + this.evaluated_dkim = Pickle::unpickle(stream)?; + this.evaluated_spf = Pickle::unpickle(stream)?; + this.policy_override_reasons = Pickle::unpickle(stream)?; + this.envelope_to = Pickle::unpickle(stream)?; + this.envelope_from = Pickle::unpickle(stream)?; + this.header_from = Pickle::unpickle(stream)?; + this.dkim_results = Pickle::unpickle(stream)?; + this.spf_results = Pickle::unpickle(stream)?; + this.extensions = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcReportRecord { + fn default() -> Self { + Self { + source_ip: Default::default(), + count: 0u64, + evaluated_disposition: Default::default(), + evaluated_dkim: Default::default(), + evaluated_spf: Default::default(), + policy_override_reasons: Default::default(), + envelope_to: Default::default(), + envelope_from: Default::default(), + header_from: Default::default(), + dkim_results: Default::default(), + spf_results: Default::default(), + extensions: Default::default(), + } + } +} + +impl IntoValue for DmarcReportRecord { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::SourceIp, self.source_ip.into_value()); + map.insert_unchecked(Property::Count, self.count.into_value()); + map.insert_unchecked( + Property::EvaluatedDisposition, + self.evaluated_disposition.into_value(), + ); + map.insert_unchecked(Property::EvaluatedDkim, self.evaluated_dkim.into_value()); + map.insert_unchecked(Property::EvaluatedSpf, self.evaluated_spf.into_value()); + map.insert_unchecked( + Property::PolicyOverrideReasons, + self.policy_override_reasons.into_value(), + ); + map.insert_unchecked(Property::EnvelopeTo, self.envelope_to.into_value()); + map.insert_unchecked(Property::EnvelopeFrom, self.envelope_from.into_value()); + map.insert_unchecked(Property::HeaderFrom, self.header_from.into_value()); + map.insert_unchecked(Property::DkimResults, self.dkim_results.into_value()); + map.insert_unchecked(Property::SpfResults, self.spf_results.into_value()); + map.insert_unchecked(Property::Extensions, self.extensions.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcReportRecord { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::SourceIp) => self.source_ip.patch(pointer, value), + Some(Property::Count) => self.count.patch(pointer, value), + Some(Property::EvaluatedDisposition) => { + self.evaluated_disposition.patch(pointer, value) + } + Some(Property::EvaluatedDkim) => self.evaluated_dkim.patch(pointer, value), + Some(Property::EvaluatedSpf) => self.evaluated_spf.patch(pointer, value), + Some(Property::PolicyOverrideReasons) => { + self.policy_override_reasons.patch(pointer, value) + } + Some(Property::EnvelopeTo) => self.envelope_to.patch(pointer, value), + Some(Property::EnvelopeFrom) => self.envelope_from.patch(pointer, value), + Some(Property::HeaderFrom) => self.header_from.patch(pointer, value), + Some(Property::DkimResults) => self.dkim_results.patch(pointer, value), + Some(Property::SpfResults) => self.spf_results.patch(pointer, value), + Some(Property::Extensions) => self.extensions.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DmarcReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DmarcReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.aggregate_contact_info; + value.validate(errors); + let value = &self.aggregate_from_address; + value.validate(errors); + let value = &self.aggregate_from_name; + value.validate(errors); + let value = &self.aggregate_max_report_size; + value.validate(errors); + let value = &self.aggregate_org_name; + value.validate(errors); + let value = &self.aggregate_send_frequency; + value.validate(errors); + let value = &self.aggregate_dkim_sign_domain; + value.validate(errors); + let value = &self.aggregate_subject; + value.validate(errors); + let value = &self.failure_from_address; + value.validate(errors); + let value = &self.failure_from_name; + value.validate(errors); + let value = &self.failure_send_frequency; + value.validate(errors); + let value = &self.failure_dkim_sign_domain; + value.validate(errors); + let value = &self.failure_subject; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl DmarcReportSettings { + pub fn ctx_aggregate_contact_info(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_contact_info, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::AggregateContactInfo, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_from_address, + default: Some(Expression { + else_: "'noreply-dmarc@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::AggregateFromAddress, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_from_name, + default: Some(Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }), + property: Property::AggregateFromName, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_max_report_size(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_max_report_size, + default: Some(Expression { + else_: "5242880".to_string(), + ..Default::default() + }), + property: Property::AggregateMaxReportSize, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_org_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_org_name, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::AggregateOrgName, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_send_frequency(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_send_frequency, + default: Some(Expression { + else_: "daily".to_string(), + ..Default::default() + }), + property: Property::AggregateSendFrequency, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: MTA_AGGREGATE_CONSTANT, + } + } + + pub fn ctx_aggregate_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::AggregateDkimSignDomain, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_aggregate_subject(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.aggregate_subject, + default: Some(Expression { + else_: "'DMARC Aggregate Report'".to_string(), + ..Default::default() + }), + property: Property::AggregateSubject, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_failure_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.failure_from_address, + default: Some(Expression { + else_: "'noreply-dmarc@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::FailureFromAddress, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_failure_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.failure_from_name, + default: Some(Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }), + property: Property::FailureFromName, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_failure_send_frequency(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.failure_send_frequency, + default: Some(Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }), + property: Property::FailureSendFrequency, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_failure_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.failure_dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::FailureDkimSignDomain, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_failure_subject(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.failure_subject, + default: Some(Expression { + else_: "'DMARC Authentication Failure Report'".to_string(), + ..Default::default() + }), + property: Property::FailureSubject, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_aggregate_contact_info(), + self.ctx_aggregate_from_address(), + self.ctx_aggregate_from_name(), + self.ctx_aggregate_max_report_size(), + self.ctx_aggregate_org_name(), + self.ctx_aggregate_send_frequency(), + self.ctx_aggregate_dkim_sign_domain(), + self.ctx_aggregate_subject(), + self.ctx_failure_from_address(), + self.ctx_failure_from_name(), + self.ctx_failure_send_frequency(), + self.ctx_failure_dkim_sign_domain(), + self.ctx_failure_subject(), + ] + } +} + +impl Pickle for DmarcReportSettings { + fn pickle(&self, out: &mut Vec) { + self.aggregate_contact_info.pickle(out); + self.aggregate_from_address.pickle(out); + self.aggregate_from_name.pickle(out); + self.aggregate_max_report_size.pickle(out); + self.aggregate_org_name.pickle(out); + self.aggregate_send_frequency.pickle(out); + self.aggregate_dkim_sign_domain.pickle(out); + self.aggregate_subject.pickle(out); + self.failure_from_address.pickle(out); + self.failure_from_name.pickle(out); + self.failure_send_frequency.pickle(out); + self.failure_dkim_sign_domain.pickle(out); + self.failure_subject.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.aggregate_contact_info = Pickle::unpickle(stream)?; + this.aggregate_from_address = Pickle::unpickle(stream)?; + this.aggregate_from_name = Pickle::unpickle(stream)?; + this.aggregate_max_report_size = Pickle::unpickle(stream)?; + this.aggregate_org_name = Pickle::unpickle(stream)?; + this.aggregate_send_frequency = Pickle::unpickle(stream)?; + this.aggregate_dkim_sign_domain = Pickle::unpickle(stream)?; + this.aggregate_subject = Pickle::unpickle(stream)?; + this.failure_from_address = Pickle::unpickle(stream)?; + this.failure_from_name = Pickle::unpickle(stream)?; + this.failure_send_frequency = Pickle::unpickle(stream)?; + this.failure_dkim_sign_domain = Pickle::unpickle(stream)?; + this.failure_subject = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcReportSettings { + fn default() -> Self { + Self { + aggregate_contact_info: Expression { + else_: "false".to_string(), + ..Default::default() + }, + aggregate_from_address: Expression { + else_: "'noreply-dmarc@' + system('domain')".to_string(), + ..Default::default() + }, + aggregate_from_name: Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }, + aggregate_max_report_size: Expression { + else_: "5242880".to_string(), + ..Default::default() + }, + aggregate_org_name: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + aggregate_send_frequency: Expression { + else_: "daily".to_string(), + ..Default::default() + }, + aggregate_dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + aggregate_subject: Expression { + else_: "'DMARC Aggregate Report'".to_string(), + ..Default::default() + }, + failure_from_address: Expression { + else_: "'noreply-dmarc@' + system('domain')".to_string(), + ..Default::default() + }, + failure_from_name: Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }, + failure_send_frequency: Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }, + failure_dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + failure_subject: Expression { + else_: "'DMARC Authentication Failure Report'".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for DmarcReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked( + Property::AggregateContactInfo, + self.aggregate_contact_info.into_value(), + ); + map.insert_unchecked( + Property::AggregateFromAddress, + self.aggregate_from_address.into_value(), + ); + map.insert_unchecked( + Property::AggregateFromName, + self.aggregate_from_name.into_value(), + ); + map.insert_unchecked( + Property::AggregateMaxReportSize, + self.aggregate_max_report_size.into_value(), + ); + map.insert_unchecked( + Property::AggregateOrgName, + self.aggregate_org_name.into_value(), + ); + map.insert_unchecked( + Property::AggregateSendFrequency, + self.aggregate_send_frequency.into_value(), + ); + map.insert_unchecked( + Property::AggregateDkimSignDomain, + self.aggregate_dkim_sign_domain.into_value(), + ); + map.insert_unchecked( + Property::AggregateSubject, + self.aggregate_subject.into_value(), + ); + map.insert_unchecked( + Property::FailureFromAddress, + self.failure_from_address.into_value(), + ); + map.insert_unchecked( + Property::FailureFromName, + self.failure_from_name.into_value(), + ); + map.insert_unchecked( + Property::FailureSendFrequency, + self.failure_send_frequency.into_value(), + ); + map.insert_unchecked( + Property::FailureDkimSignDomain, + self.failure_dkim_sign_domain.into_value(), + ); + map.insert_unchecked(Property::FailureSubject, self.failure_subject.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AggregateContactInfo) => { + self.aggregate_contact_info.patch(pointer, value) + } + Some(Property::AggregateFromAddress) => { + self.aggregate_from_address.patch(pointer, value) + } + Some(Property::AggregateFromName) => self.aggregate_from_name.patch(pointer, value), + Some(Property::AggregateMaxReportSize) => { + self.aggregate_max_report_size.patch(pointer, value) + } + Some(Property::AggregateOrgName) => self.aggregate_org_name.patch(pointer, value), + Some(Property::AggregateSendFrequency) => { + self.aggregate_send_frequency.patch(pointer, value) + } + Some(Property::AggregateDkimSignDomain) => { + self.aggregate_dkim_sign_domain.patch(pointer, value) + } + Some(Property::AggregateSubject) => self.aggregate_subject.patch(pointer, value), + Some(Property::FailureFromAddress) => self.failure_from_address.patch(pointer, value), + Some(Property::FailureFromName) => self.failure_from_name.patch(pointer, value), + Some(Property::FailureSendFrequency) => { + self.failure_send_frequency.patch(pointer, value) + } + Some(Property::FailureDkimSignDomain) => { + self.failure_dkim_sign_domain.patch(pointer, value) + } + Some(Property::FailureSubject) => self.failure_subject.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcSpfResult { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::Domain)); + } + if let Some(value) = &self.human_result { + if value.is_empty() { + errors.push(ValidationError::required(Property::HumanResult)); + } + } + errors.len() == neb + } +} + +impl Pickle for DmarcSpfResult { + fn pickle(&self, out: &mut Vec) { + self.domain.pickle(out); + self.scope.pickle(out); + self.result.pickle(out); + self.human_result.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.domain = Pickle::unpickle(stream)?; + this.scope = Pickle::unpickle(stream)?; + this.result = Pickle::unpickle(stream)?; + this.human_result = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcSpfResult { + fn default() -> Self { + Self { + domain: Default::default(), + scope: Default::default(), + result: Default::default(), + human_result: Default::default(), + } + } +} + +impl IntoValue for DmarcSpfResult { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Domain, self.domain.into_value()); + map.insert_unchecked(Property::Scope, self.scope.into_value()); + map.insert_unchecked(Property::Result, self.result.into_value()); + map.insert_unchecked(Property::HumanResult, self.human_result.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcSpfResult { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Domain) => self + .domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::Scope) => self.scope.patch(pointer, value), + Some(Property::Result) => self.result.patch(pointer, value), + Some(Property::HumanResult) => self.human_result.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcTroubleshoot { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.remote_ip; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::RemoteIp, value)); + } + let value = &self.ehlo_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::EhloDomain)); + } + let value = &self.mail_from; + if value.is_empty() { + errors.push(ValidationError::required(Property::MailFrom)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + if let Some(value) = &self.message { + if value.is_empty() { + errors.push(ValidationError::required(Property::Message)); + } + } + let value = &self.spf_ehlo_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::SpfEhloDomain)); + } + let value = &self.spf_ehlo_result; + value.validate(errors); + let value = &self.spf_mail_from_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::SpfMailFromDomain)); + } + let value = &self.spf_mail_from_result; + value.validate(errors); + let value = &self.ip_rev_result; + value.validate(errors); + let value = &self.ip_rev_ptr; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::IpRevPtr)); + } + } + let value = &self.dkim_results; + for value in value.values() { + value.validate(errors); + } + let value = &self.dkim2_result; + value.validate(errors); + let value = &self.arc_result; + value.validate(errors); + let value = &self.dmarc_result; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for DmarcTroubleshoot { + fn pickle(&self, out: &mut Vec) { + self.remote_ip.pickle(out); + self.ehlo_domain.pickle(out); + self.mail_from.pickle(out); + self.to.pickle(out); + self.message.pickle(out); + self.spf_ehlo_domain.pickle(out); + self.spf_ehlo_result.pickle(out); + self.spf_mail_from_domain.pickle(out); + self.spf_mail_from_result.pickle(out); + self.ip_rev_result.pickle(out); + self.ip_rev_ptr.pickle(out); + self.dkim_results.pickle(out); + self.dkim_pass.pickle(out); + self.dkim2_result.pickle(out); + self.dkim2_pass.pickle(out); + self.arc_result.pickle(out); + self.dmarc_result.pickle(out); + self.dmarc_pass.pickle(out); + self.dmarc_policy.pickle(out); + self.elapsed.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.remote_ip = Pickle::unpickle(stream)?; + this.ehlo_domain = Pickle::unpickle(stream)?; + this.mail_from = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + this.message = Pickle::unpickle(stream)?; + this.spf_ehlo_domain = Pickle::unpickle(stream)?; + this.spf_ehlo_result = Pickle::unpickle(stream)?; + this.spf_mail_from_domain = Pickle::unpickle(stream)?; + this.spf_mail_from_result = Pickle::unpickle(stream)?; + this.ip_rev_result = Pickle::unpickle(stream)?; + this.ip_rev_ptr = Pickle::unpickle(stream)?; + this.dkim_results = Pickle::unpickle(stream)?; + this.dkim_pass = Pickle::unpickle(stream)?; + this.dkim2_result = Pickle::unpickle(stream)?; + this.dkim2_pass = Pickle::unpickle(stream)?; + this.arc_result = Pickle::unpickle(stream)?; + this.dmarc_result = Pickle::unpickle(stream)?; + this.dmarc_pass = Pickle::unpickle(stream)?; + this.dmarc_policy = Pickle::unpickle(stream)?; + this.elapsed = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcTroubleshoot { + fn default() -> Self { + Self { + remote_ip: Default::default(), + ehlo_domain: Default::default(), + mail_from: Default::default(), + to: Default::default(), + message: Default::default(), + spf_ehlo_domain: Default::default(), + spf_ehlo_result: Default::default(), + spf_mail_from_domain: Default::default(), + spf_mail_from_result: Default::default(), + ip_rev_result: Default::default(), + ip_rev_ptr: Default::default(), + dkim_results: Default::default(), + dkim_pass: false, + dkim2_result: Default::default(), + dkim2_pass: false, + arc_result: Default::default(), + dmarc_result: Default::default(), + dmarc_pass: false, + dmarc_policy: Default::default(), + elapsed: Duration::from_millis(0), + } + } +} + +impl IntoValue for DmarcTroubleshoot { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(22); + map.insert_unchecked(Property::RemoteIp, self.remote_ip.into_value()); + map.insert_unchecked(Property::EhloDomain, self.ehlo_domain.into_value()); + map.insert_unchecked(Property::MailFrom, self.mail_from.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + map.insert_unchecked(Property::Message, self.message.into_value()); + map.insert_unchecked(Property::SpfEhloDomain, self.spf_ehlo_domain.into_value()); + map.insert_unchecked(Property::SpfEhloResult, self.spf_ehlo_result.into_value()); + map.insert_unchecked( + Property::SpfMailFromDomain, + self.spf_mail_from_domain.into_value(), + ); + map.insert_unchecked( + Property::SpfMailFromResult, + self.spf_mail_from_result.into_value(), + ); + map.insert_unchecked(Property::IpRevResult, self.ip_rev_result.into_value()); + map.insert_unchecked(Property::IpRevPtr, self.ip_rev_ptr.into_value()); + map.insert_unchecked(Property::DkimResults, self.dkim_results.into_value()); + map.insert_unchecked(Property::DkimPass, self.dkim_pass.into_value()); + map.insert_unchecked(Property::Dkim2Result, self.dkim2_result.into_value()); + map.insert_unchecked(Property::Dkim2Pass, self.dkim2_pass.into_value()); + map.insert_unchecked(Property::ArcResult, self.arc_result.into_value()); + map.insert_unchecked(Property::DmarcResult, self.dmarc_result.into_value()); + map.insert_unchecked(Property::DmarcPass, self.dmarc_pass.into_value()); + map.insert_unchecked(Property::DmarcPolicy, self.dmarc_policy.into_value()); + map.insert_unchecked(Property::Elapsed, self.elapsed.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcTroubleshoot { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::RemoteIp) => self.remote_ip.patch(pointer, value), + Some(Property::EhloDomain) => self.ehlo_domain.patch(pointer, value), + Some(Property::MailFrom) => self + .mail_from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::To) => self + .to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Message) => self.message.patch(pointer, value), + Some(Property::SpfEhloDomain) => self.spf_ehlo_domain.patch(pointer, value), + Some(Property::SpfEhloResult) => pointer.assert_server_set(), + Some(Property::SpfMailFromDomain) => self.spf_mail_from_domain.patch(pointer, value), + Some(Property::SpfMailFromResult) => pointer.assert_server_set(), + Some(Property::IpRevResult) => pointer.assert_server_set(), + Some(Property::IpRevPtr) => pointer.assert_server_set(), + Some(Property::DkimResults) => pointer.assert_server_set(), + Some(Property::DkimPass) => pointer.assert_server_set(), + Some(Property::Dkim2Result) => pointer.assert_server_set(), + Some(Property::Dkim2Pass) => pointer.assert_server_set(), + Some(Property::ArcResult) => pointer.assert_server_set(), + Some(Property::DmarcResult) => pointer.assert_server_set(), + Some(Property::DmarcPass) => pointer.assert_server_set(), + Some(Property::DmarcPolicy) => pointer.assert_server_set(), + Some(Property::Elapsed) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DmarcTroubleshootAuthResult { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DmarcTroubleshootAuthResult::Pass => true, + DmarcTroubleshootAuthResult::Fail(inner) => inner.validate(errors), + DmarcTroubleshootAuthResult::SoftFail(inner) => inner.validate(errors), + DmarcTroubleshootAuthResult::TempError(inner) => inner.validate(errors), + DmarcTroubleshootAuthResult::PermError(inner) => inner.validate(errors), + DmarcTroubleshootAuthResult::Neutral(inner) => inner.validate(errors), + DmarcTroubleshootAuthResult::None => true, + } + } +} + +impl Default for DmarcTroubleshootAuthResult { + fn default() -> Self { + DmarcTroubleshootAuthResult::Pass + } +} + +impl Pickle for DmarcTroubleshootAuthResult { + fn pickle(&self, out: &mut Vec) { + match self { + DmarcTroubleshootAuthResult::Pass => { + 0u16.pickle(out); + } + DmarcTroubleshootAuthResult::Fail(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DmarcTroubleshootAuthResult::SoftFail(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DmarcTroubleshootAuthResult::TempError(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + DmarcTroubleshootAuthResult::PermError(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + DmarcTroubleshootAuthResult::Neutral(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + DmarcTroubleshootAuthResult::None => { + 6u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(DmarcTroubleshootAuthResult::Pass), + 1 => Pickle::unpickle(stream).map(DmarcTroubleshootAuthResult::Fail), + 2 => Pickle::unpickle(stream).map(DmarcTroubleshootAuthResult::SoftFail), + 3 => Pickle::unpickle(stream).map(DmarcTroubleshootAuthResult::TempError), + 4 => Pickle::unpickle(stream).map(DmarcTroubleshootAuthResult::PermError), + 5 => Pickle::unpickle(stream).map(DmarcTroubleshootAuthResult::Neutral), + 6 => Some(DmarcTroubleshootAuthResult::None), + _ => None, + } + } +} + +impl IntoValue for DmarcTroubleshootAuthResult { + fn into_value(self) -> JmapValue<'static> { + match self { + DmarcTroubleshootAuthResult::Pass => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Pass".into())); + JmapValue::Object(obj) + } + DmarcTroubleshootAuthResult::Fail(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Fail".into())); + obj + } + DmarcTroubleshootAuthResult::SoftFail(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("SoftFail".into())); + obj + } + DmarcTroubleshootAuthResult::TempError(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TempError".into())); + obj + } + DmarcTroubleshootAuthResult::PermError(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PermError".into())); + obj + } + DmarcTroubleshootAuthResult::Neutral(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Neutral".into())); + obj + } + DmarcTroubleshootAuthResult::None => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("None".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for DmarcTroubleshootAuthResult { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DmarcTroubleshootAuthResultType::Pass => *self = DmarcTroubleshootAuthResult::Pass, + DmarcTroubleshootAuthResultType::Fail => { + *self = DmarcTroubleshootAuthResult::Fail(Default::default()) + } + DmarcTroubleshootAuthResultType::SoftFail => { + *self = DmarcTroubleshootAuthResult::SoftFail(Default::default()) + } + DmarcTroubleshootAuthResultType::TempError => { + *self = DmarcTroubleshootAuthResult::TempError(Default::default()) + } + DmarcTroubleshootAuthResultType::PermError => { + *self = DmarcTroubleshootAuthResult::PermError(Default::default()) + } + DmarcTroubleshootAuthResultType::Neutral => { + *self = DmarcTroubleshootAuthResult::Neutral(Default::default()) + } + DmarcTroubleshootAuthResultType::None => *self = DmarcTroubleshootAuthResult::None, + } + } + match self { + DmarcTroubleshootAuthResult::Pass => pointer.assert_eof(), + DmarcTroubleshootAuthResult::Fail(inner) => inner.patch(pointer, value), + DmarcTroubleshootAuthResult::SoftFail(inner) => inner.patch(pointer, value), + DmarcTroubleshootAuthResult::TempError(inner) => inner.patch(pointer, value), + DmarcTroubleshootAuthResult::PermError(inner) => inner.patch(pointer, value), + DmarcTroubleshootAuthResult::Neutral(inner) => inner.patch(pointer, value), + DmarcTroubleshootAuthResult::None => pointer.assert_eof(), + } + } +} + +impl DmarcTroubleshootAuthResult { + pub fn object_type(&self) -> DmarcTroubleshootAuthResultType { + match self { + DmarcTroubleshootAuthResult::Pass => DmarcTroubleshootAuthResultType::Pass, + DmarcTroubleshootAuthResult::Fail(_) => DmarcTroubleshootAuthResultType::Fail, + DmarcTroubleshootAuthResult::SoftFail(_) => DmarcTroubleshootAuthResultType::SoftFail, + DmarcTroubleshootAuthResult::TempError(_) => DmarcTroubleshootAuthResultType::TempError, + DmarcTroubleshootAuthResult::PermError(_) => DmarcTroubleshootAuthResultType::PermError, + DmarcTroubleshootAuthResult::Neutral(_) => DmarcTroubleshootAuthResultType::Neutral, + DmarcTroubleshootAuthResult::None => DmarcTroubleshootAuthResultType::None, + } + } +} + +impl DmarcTroubleshootDetails { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.details { + if value.is_empty() { + errors.push(ValidationError::required(Property::Details)); + } + } + errors.len() == neb + } +} + +impl Pickle for DmarcTroubleshootDetails { + fn pickle(&self, out: &mut Vec) { + self.details.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.details = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DmarcTroubleshootDetails { + fn default() -> Self { + Self { + details: Default::default(), + } + } +} + +impl IntoValue for DmarcTroubleshootDetails { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Details, self.details.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DmarcTroubleshootDetails { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Details) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsCustomResolver { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.address; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Address, value)); + } + let value = &self.port; + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + errors.len() == neb + } +} + +impl Pickle for DnsCustomResolver { + fn pickle(&self, out: &mut Vec) { + self.protocol.pickle(out); + self.address.pickle(out); + self.port.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.protocol = Pickle::unpickle(stream)?; + this.address = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsCustomResolver { + fn default() -> Self { + Self { + protocol: DnsResolverProtocol::Udp, + address: IpAddr::from_str("127.0.0.1").unwrap(), + port: 53u64, + } + } +} + +impl IntoValue for DnsCustomResolver { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Protocol, self.protocol.into_value()); + map.insert_unchecked(Property::Address, self.address.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsCustomResolver { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Protocol) => self.protocol.patch(pointer, value), + Some(Property::Address) => self + .address + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsManagement { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DnsManagement::Manual => true, + DnsManagement::Automatic(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + DnsManagement::Manual => {} + DnsManagement::Automatic(object) => { + object.index(i); + } + } + } +} + +impl Default for DnsManagement { + fn default() -> Self { + DnsManagement::Manual + } +} + +impl Pickle for DnsManagement { + fn pickle(&self, out: &mut Vec) { + match self { + DnsManagement::Manual => { + 0u16.pickle(out); + } + DnsManagement::Automatic(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(DnsManagement::Manual), + 1 => Pickle::unpickle(stream).map(DnsManagement::Automatic), + _ => None, + } + } +} + +impl IntoValue for DnsManagement { + fn into_value(self) -> JmapValue<'static> { + match self { + DnsManagement::Manual => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Manual".into())); + JmapValue::Object(obj) + } + DnsManagement::Automatic(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Automatic".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DnsManagement { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DnsManagementType::Manual => *self = DnsManagement::Manual, + DnsManagementType::Automatic => { + *self = DnsManagement::Automatic(Default::default()) + } + } + } + match self { + DnsManagement::Manual => pointer.assert_eof(), + DnsManagement::Automatic(inner) => inner.patch(pointer, value), + } + } +} + +impl DnsManagement { + pub fn object_type(&self) -> DnsManagementType { + match self { + DnsManagement::Manual => DnsManagementType::Manual, + DnsManagement::Automatic(_) => DnsManagementType::Automatic, + } + } +} + +impl DnsManagementProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.dns_server_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DnsServerId)); + } + if let Some(value) = &self.origin { + if value.is_empty() { + errors.push(ValidationError::required(Property::Origin)); + } + } + let value = &self.publish_records; + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::PublishRecords, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::DnsServer, self.dns_server_id.into(), None); + } +} + +impl Pickle for DnsManagementProperties { + fn pickle(&self, out: &mut Vec) { + self.dns_server_id.pickle(out); + self.origin.pickle(out); + self.publish_records.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.dns_server_id = Pickle::unpickle(stream)?; + this.origin = Pickle::unpickle(stream)?; + this.publish_records = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsManagementProperties { + fn default() -> Self { + Self { + dns_server_id: Default::default(), + origin: Default::default(), + publish_records: Map::new(vec![ + DnsRecordType::Dkim, + DnsRecordType::Spf, + DnsRecordType::Mx, + DnsRecordType::Dmarc, + DnsRecordType::Srv, + DnsRecordType::MtaSts, + DnsRecordType::TlsRpt, + DnsRecordType::Caa, + DnsRecordType::AutoConfig, + DnsRecordType::AutoConfigLegacy, + DnsRecordType::AutoDiscover, + ]), + } + } +} + +impl IntoValue for DnsManagementProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::DnsServerId, self.dns_server_id.into_value()); + map.insert_unchecked(Property::Origin, self.origin.into_value()); + map.insert_unchecked(Property::PublishRecords, self.publish_records.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsManagementProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DnsServerId) => self.dns_server_id.patch(pointer, value), + Some(Property::Origin) => self + .origin + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PublishRecords) => self.publish_records.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DnsResolver { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DnsResolver; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DnsResolver::System(inner) => inner.validate(errors), + DnsResolver::Custom(inner) => inner.validate(errors), + DnsResolver::Cloudflare(inner) => inner.validate(errors), + DnsResolver::Quad9(inner) => inner.validate(errors), + DnsResolver::Google(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for DnsResolver { + fn default() -> Self { + DnsResolver::System(Default::default()) + } +} + +impl Pickle for DnsResolver { + fn pickle(&self, out: &mut Vec) { + match self { + DnsResolver::System(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + DnsResolver::Custom(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DnsResolver::Cloudflare(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DnsResolver::Quad9(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + DnsResolver::Google(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(DnsResolver::System), + 1 => Pickle::unpickle(stream).map(DnsResolver::Custom), + 2 => Pickle::unpickle(stream).map(DnsResolver::Cloudflare), + 3 => Pickle::unpickle(stream).map(DnsResolver::Quad9), + 4 => Pickle::unpickle(stream).map(DnsResolver::Google), + _ => None, + } + } +} + +impl IntoValue for DnsResolver { + fn into_value(self) -> JmapValue<'static> { + match self { + DnsResolver::System(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("System".into())); + obj + } + DnsResolver::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + DnsResolver::Cloudflare(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Cloudflare".into())); + obj + } + DnsResolver::Quad9(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Quad9".into())); + obj + } + DnsResolver::Google(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Google".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DnsResolver { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DnsResolverType::System => *self = DnsResolver::System(Default::default()), + DnsResolverType::Custom => *self = DnsResolver::Custom(Default::default()), + DnsResolverType::Cloudflare => *self = DnsResolver::Cloudflare(Default::default()), + DnsResolverType::Quad9 => *self = DnsResolver::Quad9(Default::default()), + DnsResolverType::Google => *self = DnsResolver::Google(Default::default()), + } + } + match self { + DnsResolver::System(inner) => inner.patch(pointer, value), + DnsResolver::Custom(inner) => inner.patch(pointer, value), + DnsResolver::Cloudflare(inner) => inner.patch(pointer, value), + DnsResolver::Quad9(inner) => inner.patch(pointer, value), + DnsResolver::Google(inner) => inner.patch(pointer, value), + } + } +} + +impl DnsResolver { + pub fn object_type(&self) -> DnsResolverType { + match self { + DnsResolver::System(_) => DnsResolverType::System, + DnsResolver::Custom(_) => DnsResolverType::Custom, + DnsResolver::Cloudflare(_) => DnsResolverType::Cloudflare, + DnsResolver::Quad9(_) => DnsResolverType::Quad9, + DnsResolver::Google(_) => DnsResolverType::Google, + } + } +} + +impl DnsResolverCommon { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for DnsResolverCommon { + fn pickle(&self, out: &mut Vec) { + self.attempts.pickle(out); + self.concurrency.pickle(out); + self.enable_edns.pickle(out); + self.preserve_intermediates.pickle(out); + self.timeout.pickle(out); + self.tcp_on_error.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.attempts = Pickle::unpickle(stream)?; + this.concurrency = Pickle::unpickle(stream)?; + this.enable_edns = Pickle::unpickle(stream)?; + this.preserve_intermediates = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.tcp_on_error = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsResolverCommon { + fn default() -> Self { + Self { + attempts: 2u64, + concurrency: 2u64, + enable_edns: true, + preserve_intermediates: true, + timeout: Duration::from_millis(5000), + tcp_on_error: true, + } + } +} + +impl IntoValue for DnsResolverCommon { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Attempts, self.attempts.into_value()); + map.insert_unchecked(Property::Concurrency, self.concurrency.into_value()); + map.insert_unchecked(Property::EnableEdns, self.enable_edns.into_value()); + map.insert_unchecked( + Property::PreserveIntermediates, + self.preserve_intermediates.into_value(), + ); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::TcpOnError, self.tcp_on_error.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsResolverCommon { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Attempts) => self.attempts.patch(pointer, value), + Some(Property::Concurrency) => self.concurrency.patch(pointer, value), + Some(Property::EnableEdns) => self.enable_edns.patch(pointer, value), + Some(Property::PreserveIntermediates) => { + self.preserve_intermediates.patch(pointer, value) + } + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::TcpOnError) => self.tcp_on_error.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsResolverCustom { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.servers; + for value in value.values() { + value.validate(errors); + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Servers, 1)); + } + errors.len() == neb + } +} + +impl Pickle for DnsResolverCustom { + fn pickle(&self, out: &mut Vec) { + self.servers.pickle(out); + self.attempts.pickle(out); + self.concurrency.pickle(out); + self.enable_edns.pickle(out); + self.preserve_intermediates.pickle(out); + self.timeout.pickle(out); + self.tcp_on_error.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.servers = Pickle::unpickle(stream)?; + this.attempts = Pickle::unpickle(stream)?; + this.concurrency = Pickle::unpickle(stream)?; + this.enable_edns = Pickle::unpickle(stream)?; + this.preserve_intermediates = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.tcp_on_error = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsResolverCustom { + fn default() -> Self { + Self { + servers: Default::default(), + attempts: 2u64, + concurrency: 2u64, + enable_edns: true, + preserve_intermediates: true, + timeout: Duration::from_millis(5000), + tcp_on_error: true, + } + } +} + +impl IntoValue for DnsResolverCustom { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Servers, self.servers.into_value()); + map.insert_unchecked(Property::Attempts, self.attempts.into_value()); + map.insert_unchecked(Property::Concurrency, self.concurrency.into_value()); + map.insert_unchecked(Property::EnableEdns, self.enable_edns.into_value()); + map.insert_unchecked( + Property::PreserveIntermediates, + self.preserve_intermediates.into_value(), + ); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::TcpOnError, self.tcp_on_error.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsResolverCustom { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Servers) => self.servers.patch(pointer, value), + Some(Property::Attempts) => self.attempts.patch(pointer, value), + Some(Property::Concurrency) => self.concurrency.patch(pointer, value), + Some(Property::EnableEdns) => self.enable_edns.patch(pointer, value), + Some(Property::PreserveIntermediates) => { + self.preserve_intermediates.patch(pointer, value) + } + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::TcpOnError) => self.tcp_on_error.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsResolverTls { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for DnsResolverTls { + fn pickle(&self, out: &mut Vec) { + self.use_tls.pickle(out); + self.attempts.pickle(out); + self.concurrency.pickle(out); + self.enable_edns.pickle(out); + self.preserve_intermediates.pickle(out); + self.timeout.pickle(out); + self.tcp_on_error.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.use_tls = Pickle::unpickle(stream)?; + this.attempts = Pickle::unpickle(stream)?; + this.concurrency = Pickle::unpickle(stream)?; + this.enable_edns = Pickle::unpickle(stream)?; + this.preserve_intermediates = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.tcp_on_error = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsResolverTls { + fn default() -> Self { + Self { + use_tls: true, + attempts: 2u64, + concurrency: 2u64, + enable_edns: true, + preserve_intermediates: true, + timeout: Duration::from_millis(5000), + tcp_on_error: true, + } + } +} + +impl IntoValue for DnsResolverTls { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked(Property::Attempts, self.attempts.into_value()); + map.insert_unchecked(Property::Concurrency, self.concurrency.into_value()); + map.insert_unchecked(Property::EnableEdns, self.enable_edns.into_value()); + map.insert_unchecked( + Property::PreserveIntermediates, + self.preserve_intermediates.into_value(), + ); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::TcpOnError, self.tcp_on_error.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsResolverTls { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::Attempts) => self.attempts.patch(pointer, value), + Some(Property::Concurrency) => self.concurrency.patch(pointer, value), + Some(Property::EnableEdns) => self.enable_edns.patch(pointer, value), + Some(Property::PreserveIntermediates) => { + self.preserve_intermediates.patch(pointer, value) + } + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::TcpOnError) => self.tcp_on_error.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DnsServer { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::DnsServer; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DnsServer::Tsig(inner) => inner.validate(errors), + DnsServer::Deprecated1 => true, + DnsServer::Cloudflare(inner) => inner.validate(errors), + DnsServer::DigitalOcean(inner) => inner.validate(errors), + DnsServer::DeSEC(inner) => inner.validate(errors), + DnsServer::Ovh(inner) => inner.validate(errors), + DnsServer::Bunny(inner) => inner.validate(errors), + DnsServer::Porkbun(inner) => inner.validate(errors), + DnsServer::Dnsimple(inner) => inner.validate(errors), + DnsServer::Spaceship(inner) => inner.validate(errors), + DnsServer::Route53(inner) => inner.validate(errors), + DnsServer::GoogleCloudDns(inner) => inner.validate(errors), + DnsServer::Alidns(inner) => inner.validate(errors), + DnsServer::ArvanCloud(inner) => inner.validate(errors), + DnsServer::Autodns(inner) => inner.validate(errors), + DnsServer::AzureDns(inner) => inner.validate(errors), + DnsServer::BaiduCloud(inner) => inner.validate(errors), + DnsServer::BluecatV2(inner) => inner.validate(errors), + DnsServer::ClouDns(inner) => inner.validate(errors), + DnsServer::Constellix(inner) => inner.validate(errors), + DnsServer::Cpanel(inner) => inner.validate(errors), + DnsServer::Ddnss(inner) => inner.validate(errors), + DnsServer::DnsMadeEasy(inner) => inner.validate(errors), + DnsServer::Domeneshop(inner) => inner.validate(errors), + DnsServer::Dreamhost(inner) => inner.validate(errors), + DnsServer::DuckDns(inner) => inner.validate(errors), + DnsServer::Dynu(inner) => inner.validate(errors), + DnsServer::EasyDns(inner) => inner.validate(errors), + DnsServer::EdgeDns(inner) => inner.validate(errors), + DnsServer::Exoscale(inner) => inner.validate(errors), + DnsServer::FreeMyIp(inner) => inner.validate(errors), + DnsServer::GandiV5(inner) => inner.validate(errors), + DnsServer::Gcore(inner) => inner.validate(errors), + DnsServer::Glesys(inner) => inner.validate(errors), + DnsServer::Godaddy(inner) => inner.validate(errors), + DnsServer::Hetzner(inner) => inner.validate(errors), + DnsServer::HostingDe(inner) => inner.validate(errors), + DnsServer::Hostinger(inner) => inner.validate(errors), + DnsServer::HuaweiCloud(inner) => inner.validate(errors), + DnsServer::Hurricane(inner) => inner.validate(errors), + DnsServer::IbmCloud(inner) => inner.validate(errors), + DnsServer::Infoblox(inner) => inner.validate(errors), + DnsServer::Infomaniak(inner) => inner.validate(errors), + DnsServer::Inwx(inner) => inner.validate(errors), + DnsServer::Ionos(inner) => inner.validate(errors), + DnsServer::Ipv64(inner) => inner.validate(errors), + DnsServer::Joker(inner) => inner.validate(errors), + DnsServer::Lightsail(inner) => inner.validate(errors), + DnsServer::Linode(inner) => inner.validate(errors), + DnsServer::LuaDns(inner) => inner.validate(errors), + DnsServer::MythicBeasts(inner) => inner.validate(errors), + DnsServer::Namecheap(inner) => inner.validate(errors), + DnsServer::NameDotCom(inner) => inner.validate(errors), + DnsServer::NameSilo(inner) => inner.validate(errors), + DnsServer::Netcup(inner) => inner.validate(errors), + DnsServer::Netlify(inner) => inner.validate(errors), + DnsServer::Nifcloud(inner) => inner.validate(errors), + DnsServer::Ns1(inner) => inner.validate(errors), + DnsServer::OracleCloud(inner) => inner.validate(errors), + DnsServer::Plesk(inner) => inner.validate(errors), + DnsServer::Safedns(inner) => inner.validate(errors), + DnsServer::Scaleway(inner) => inner.validate(errors), + DnsServer::TencentCloud(inner) => inner.validate(errors), + DnsServer::Transip(inner) => inner.validate(errors), + DnsServer::UltraDns(inner) => inner.validate(errors), + DnsServer::Vercel(inner) => inner.validate(errors), + DnsServer::Volcengine(inner) => inner.validate(errors), + DnsServer::Vultr(inner) => inner.validate(errors), + DnsServer::WebSupport(inner) => inner.validate(errors), + DnsServer::YandexCloud(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + DnsServer::Tsig(object) => { + object.index(i); + } + DnsServer::Deprecated1 => {} + DnsServer::Cloudflare(object) => { + object.index(i); + } + DnsServer::DigitalOcean(object) => { + object.index(i); + } + DnsServer::DeSEC(object) => { + object.index(i); + } + DnsServer::Ovh(object) => { + object.index(i); + } + DnsServer::Bunny(object) => { + object.index(i); + } + DnsServer::Porkbun(object) => { + object.index(i); + } + DnsServer::Dnsimple(object) => { + object.index(i); + } + DnsServer::Spaceship(object) => { + object.index(i); + } + DnsServer::Route53(object) => { + object.index(i); + } + DnsServer::GoogleCloudDns(object) => { + object.index(i); + } + DnsServer::Alidns(object) => { + object.index(i); + } + DnsServer::ArvanCloud(object) => { + object.index(i); + } + DnsServer::Autodns(object) => { + object.index(i); + } + DnsServer::AzureDns(object) => { + object.index(i); + } + DnsServer::BaiduCloud(object) => { + object.index(i); + } + DnsServer::BluecatV2(object) => { + object.index(i); + } + DnsServer::ClouDns(object) => { + object.index(i); + } + DnsServer::Constellix(object) => { + object.index(i); + } + DnsServer::Cpanel(object) => { + object.index(i); + } + DnsServer::Ddnss(object) => { + object.index(i); + } + DnsServer::DnsMadeEasy(object) => { + object.index(i); + } + DnsServer::Domeneshop(object) => { + object.index(i); + } + DnsServer::Dreamhost(object) => { + object.index(i); + } + DnsServer::DuckDns(object) => { + object.index(i); + } + DnsServer::Dynu(object) => { + object.index(i); + } + DnsServer::EasyDns(object) => { + object.index(i); + } + DnsServer::EdgeDns(object) => { + object.index(i); + } + DnsServer::Exoscale(object) => { + object.index(i); + } + DnsServer::FreeMyIp(object) => { + object.index(i); + } + DnsServer::GandiV5(object) => { + object.index(i); + } + DnsServer::Gcore(object) => { + object.index(i); + } + DnsServer::Glesys(object) => { + object.index(i); + } + DnsServer::Godaddy(object) => { + object.index(i); + } + DnsServer::Hetzner(object) => { + object.index(i); + } + DnsServer::HostingDe(object) => { + object.index(i); + } + DnsServer::Hostinger(object) => { + object.index(i); + } + DnsServer::HuaweiCloud(object) => { + object.index(i); + } + DnsServer::Hurricane(object) => { + object.index(i); + } + DnsServer::IbmCloud(object) => { + object.index(i); + } + DnsServer::Infoblox(object) => { + object.index(i); + } + DnsServer::Infomaniak(object) => { + object.index(i); + } + DnsServer::Inwx(object) => { + object.index(i); + } + DnsServer::Ionos(object) => { + object.index(i); + } + DnsServer::Ipv64(object) => { + object.index(i); + } + DnsServer::Joker(object) => { + object.index(i); + } + DnsServer::Lightsail(object) => { + object.index(i); + } + DnsServer::Linode(object) => { + object.index(i); + } + DnsServer::LuaDns(object) => { + object.index(i); + } + DnsServer::MythicBeasts(object) => { + object.index(i); + } + DnsServer::Namecheap(object) => { + object.index(i); + } + DnsServer::NameDotCom(object) => { + object.index(i); + } + DnsServer::NameSilo(object) => { + object.index(i); + } + DnsServer::Netcup(object) => { + object.index(i); + } + DnsServer::Netlify(object) => { + object.index(i); + } + DnsServer::Nifcloud(object) => { + object.index(i); + } + DnsServer::Ns1(object) => { + object.index(i); + } + DnsServer::OracleCloud(object) => { + object.index(i); + } + DnsServer::Plesk(object) => { + object.index(i); + } + DnsServer::Safedns(object) => { + object.index(i); + } + DnsServer::Scaleway(object) => { + object.index(i); + } + DnsServer::TencentCloud(object) => { + object.index(i); + } + DnsServer::Transip(object) => { + object.index(i); + } + DnsServer::UltraDns(object) => { + object.index(i); + } + DnsServer::Vercel(object) => { + object.index(i); + } + DnsServer::Volcengine(object) => { + object.index(i); + } + DnsServer::Vultr(object) => { + object.index(i); + } + DnsServer::WebSupport(object) => { + object.index(i); + } + DnsServer::YandexCloud(object) => { + object.index(i); + } + } + } +} + +impl Default for DnsServer { + fn default() -> Self { + DnsServer::Tsig(Default::default()) + } +} + +impl Pickle for DnsServer { + fn pickle(&self, out: &mut Vec) { + match self { + DnsServer::Tsig(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + DnsServer::Deprecated1 => { + 1u16.pickle(out); + } + DnsServer::Cloudflare(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + DnsServer::DigitalOcean(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + DnsServer::DeSEC(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + DnsServer::Ovh(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + DnsServer::Bunny(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + DnsServer::Porkbun(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + DnsServer::Dnsimple(inner) => { + 8u16.pickle(out); + inner.pickle(out); + } + DnsServer::Spaceship(inner) => { + 9u16.pickle(out); + inner.pickle(out); + } + DnsServer::Route53(inner) => { + 10u16.pickle(out); + inner.pickle(out); + } + DnsServer::GoogleCloudDns(inner) => { + 11u16.pickle(out); + inner.pickle(out); + } + DnsServer::Alidns(inner) => { + 12u16.pickle(out); + inner.pickle(out); + } + DnsServer::ArvanCloud(inner) => { + 13u16.pickle(out); + inner.pickle(out); + } + DnsServer::Autodns(inner) => { + 14u16.pickle(out); + inner.pickle(out); + } + DnsServer::AzureDns(inner) => { + 15u16.pickle(out); + inner.pickle(out); + } + DnsServer::BaiduCloud(inner) => { + 16u16.pickle(out); + inner.pickle(out); + } + DnsServer::BluecatV2(inner) => { + 17u16.pickle(out); + inner.pickle(out); + } + DnsServer::ClouDns(inner) => { + 18u16.pickle(out); + inner.pickle(out); + } + DnsServer::Constellix(inner) => { + 19u16.pickle(out); + inner.pickle(out); + } + DnsServer::Cpanel(inner) => { + 20u16.pickle(out); + inner.pickle(out); + } + DnsServer::Ddnss(inner) => { + 21u16.pickle(out); + inner.pickle(out); + } + DnsServer::DnsMadeEasy(inner) => { + 22u16.pickle(out); + inner.pickle(out); + } + DnsServer::Domeneshop(inner) => { + 23u16.pickle(out); + inner.pickle(out); + } + DnsServer::Dreamhost(inner) => { + 24u16.pickle(out); + inner.pickle(out); + } + DnsServer::DuckDns(inner) => { + 25u16.pickle(out); + inner.pickle(out); + } + DnsServer::Dynu(inner) => { + 26u16.pickle(out); + inner.pickle(out); + } + DnsServer::EasyDns(inner) => { + 27u16.pickle(out); + inner.pickle(out); + } + DnsServer::EdgeDns(inner) => { + 28u16.pickle(out); + inner.pickle(out); + } + DnsServer::Exoscale(inner) => { + 29u16.pickle(out); + inner.pickle(out); + } + DnsServer::FreeMyIp(inner) => { + 30u16.pickle(out); + inner.pickle(out); + } + DnsServer::GandiV5(inner) => { + 31u16.pickle(out); + inner.pickle(out); + } + DnsServer::Gcore(inner) => { + 32u16.pickle(out); + inner.pickle(out); + } + DnsServer::Glesys(inner) => { + 33u16.pickle(out); + inner.pickle(out); + } + DnsServer::Godaddy(inner) => { + 34u16.pickle(out); + inner.pickle(out); + } + DnsServer::Hetzner(inner) => { + 35u16.pickle(out); + inner.pickle(out); + } + DnsServer::HostingDe(inner) => { + 36u16.pickle(out); + inner.pickle(out); + } + DnsServer::Hostinger(inner) => { + 37u16.pickle(out); + inner.pickle(out); + } + DnsServer::HuaweiCloud(inner) => { + 38u16.pickle(out); + inner.pickle(out); + } + DnsServer::Hurricane(inner) => { + 39u16.pickle(out); + inner.pickle(out); + } + DnsServer::IbmCloud(inner) => { + 40u16.pickle(out); + inner.pickle(out); + } + DnsServer::Infoblox(inner) => { + 41u16.pickle(out); + inner.pickle(out); + } + DnsServer::Infomaniak(inner) => { + 42u16.pickle(out); + inner.pickle(out); + } + DnsServer::Inwx(inner) => { + 43u16.pickle(out); + inner.pickle(out); + } + DnsServer::Ionos(inner) => { + 44u16.pickle(out); + inner.pickle(out); + } + DnsServer::Ipv64(inner) => { + 45u16.pickle(out); + inner.pickle(out); + } + DnsServer::Joker(inner) => { + 46u16.pickle(out); + inner.pickle(out); + } + DnsServer::Lightsail(inner) => { + 47u16.pickle(out); + inner.pickle(out); + } + DnsServer::Linode(inner) => { + 48u16.pickle(out); + inner.pickle(out); + } + DnsServer::LuaDns(inner) => { + 49u16.pickle(out); + inner.pickle(out); + } + DnsServer::MythicBeasts(inner) => { + 50u16.pickle(out); + inner.pickle(out); + } + DnsServer::Namecheap(inner) => { + 51u16.pickle(out); + inner.pickle(out); + } + DnsServer::NameDotCom(inner) => { + 52u16.pickle(out); + inner.pickle(out); + } + DnsServer::NameSilo(inner) => { + 53u16.pickle(out); + inner.pickle(out); + } + DnsServer::Netcup(inner) => { + 54u16.pickle(out); + inner.pickle(out); + } + DnsServer::Netlify(inner) => { + 55u16.pickle(out); + inner.pickle(out); + } + DnsServer::Nifcloud(inner) => { + 56u16.pickle(out); + inner.pickle(out); + } + DnsServer::Ns1(inner) => { + 57u16.pickle(out); + inner.pickle(out); + } + DnsServer::OracleCloud(inner) => { + 58u16.pickle(out); + inner.pickle(out); + } + DnsServer::Plesk(inner) => { + 59u16.pickle(out); + inner.pickle(out); + } + DnsServer::Safedns(inner) => { + 60u16.pickle(out); + inner.pickle(out); + } + DnsServer::Scaleway(inner) => { + 61u16.pickle(out); + inner.pickle(out); + } + DnsServer::TencentCloud(inner) => { + 62u16.pickle(out); + inner.pickle(out); + } + DnsServer::Transip(inner) => { + 63u16.pickle(out); + inner.pickle(out); + } + DnsServer::UltraDns(inner) => { + 64u16.pickle(out); + inner.pickle(out); + } + DnsServer::Vercel(inner) => { + 65u16.pickle(out); + inner.pickle(out); + } + DnsServer::Volcengine(inner) => { + 66u16.pickle(out); + inner.pickle(out); + } + DnsServer::Vultr(inner) => { + 67u16.pickle(out); + inner.pickle(out); + } + DnsServer::WebSupport(inner) => { + 68u16.pickle(out); + inner.pickle(out); + } + DnsServer::YandexCloud(inner) => { + 69u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(DnsServer::Tsig), + 1 => Some(DnsServer::Deprecated1), + 2 => Pickle::unpickle(stream).map(DnsServer::Cloudflare), + 3 => Pickle::unpickle(stream).map(DnsServer::DigitalOcean), + 4 => Pickle::unpickle(stream).map(DnsServer::DeSEC), + 5 => Pickle::unpickle(stream).map(DnsServer::Ovh), + 6 => Pickle::unpickle(stream).map(DnsServer::Bunny), + 7 => Pickle::unpickle(stream).map(DnsServer::Porkbun), + 8 => Pickle::unpickle(stream).map(DnsServer::Dnsimple), + 9 => Pickle::unpickle(stream).map(DnsServer::Spaceship), + 10 => Pickle::unpickle(stream).map(DnsServer::Route53), + 11 => Pickle::unpickle(stream).map(DnsServer::GoogleCloudDns), + 12 => Pickle::unpickle(stream).map(DnsServer::Alidns), + 13 => Pickle::unpickle(stream).map(DnsServer::ArvanCloud), + 14 => Pickle::unpickle(stream).map(DnsServer::Autodns), + 15 => Pickle::unpickle(stream).map(DnsServer::AzureDns), + 16 => Pickle::unpickle(stream).map(DnsServer::BaiduCloud), + 17 => Pickle::unpickle(stream).map(DnsServer::BluecatV2), + 18 => Pickle::unpickle(stream).map(DnsServer::ClouDns), + 19 => Pickle::unpickle(stream).map(DnsServer::Constellix), + 20 => Pickle::unpickle(stream).map(DnsServer::Cpanel), + 21 => Pickle::unpickle(stream).map(DnsServer::Ddnss), + 22 => Pickle::unpickle(stream).map(DnsServer::DnsMadeEasy), + 23 => Pickle::unpickle(stream).map(DnsServer::Domeneshop), + 24 => Pickle::unpickle(stream).map(DnsServer::Dreamhost), + 25 => Pickle::unpickle(stream).map(DnsServer::DuckDns), + 26 => Pickle::unpickle(stream).map(DnsServer::Dynu), + 27 => Pickle::unpickle(stream).map(DnsServer::EasyDns), + 28 => Pickle::unpickle(stream).map(DnsServer::EdgeDns), + 29 => Pickle::unpickle(stream).map(DnsServer::Exoscale), + 30 => Pickle::unpickle(stream).map(DnsServer::FreeMyIp), + 31 => Pickle::unpickle(stream).map(DnsServer::GandiV5), + 32 => Pickle::unpickle(stream).map(DnsServer::Gcore), + 33 => Pickle::unpickle(stream).map(DnsServer::Glesys), + 34 => Pickle::unpickle(stream).map(DnsServer::Godaddy), + 35 => Pickle::unpickle(stream).map(DnsServer::Hetzner), + 36 => Pickle::unpickle(stream).map(DnsServer::HostingDe), + 37 => Pickle::unpickle(stream).map(DnsServer::Hostinger), + 38 => Pickle::unpickle(stream).map(DnsServer::HuaweiCloud), + 39 => Pickle::unpickle(stream).map(DnsServer::Hurricane), + 40 => Pickle::unpickle(stream).map(DnsServer::IbmCloud), + 41 => Pickle::unpickle(stream).map(DnsServer::Infoblox), + 42 => Pickle::unpickle(stream).map(DnsServer::Infomaniak), + 43 => Pickle::unpickle(stream).map(DnsServer::Inwx), + 44 => Pickle::unpickle(stream).map(DnsServer::Ionos), + 45 => Pickle::unpickle(stream).map(DnsServer::Ipv64), + 46 => Pickle::unpickle(stream).map(DnsServer::Joker), + 47 => Pickle::unpickle(stream).map(DnsServer::Lightsail), + 48 => Pickle::unpickle(stream).map(DnsServer::Linode), + 49 => Pickle::unpickle(stream).map(DnsServer::LuaDns), + 50 => Pickle::unpickle(stream).map(DnsServer::MythicBeasts), + 51 => Pickle::unpickle(stream).map(DnsServer::Namecheap), + 52 => Pickle::unpickle(stream).map(DnsServer::NameDotCom), + 53 => Pickle::unpickle(stream).map(DnsServer::NameSilo), + 54 => Pickle::unpickle(stream).map(DnsServer::Netcup), + 55 => Pickle::unpickle(stream).map(DnsServer::Netlify), + 56 => Pickle::unpickle(stream).map(DnsServer::Nifcloud), + 57 => Pickle::unpickle(stream).map(DnsServer::Ns1), + 58 => Pickle::unpickle(stream).map(DnsServer::OracleCloud), + 59 => Pickle::unpickle(stream).map(DnsServer::Plesk), + 60 => Pickle::unpickle(stream).map(DnsServer::Safedns), + 61 => Pickle::unpickle(stream).map(DnsServer::Scaleway), + 62 => Pickle::unpickle(stream).map(DnsServer::TencentCloud), + 63 => Pickle::unpickle(stream).map(DnsServer::Transip), + 64 => Pickle::unpickle(stream).map(DnsServer::UltraDns), + 65 => Pickle::unpickle(stream).map(DnsServer::Vercel), + 66 => Pickle::unpickle(stream).map(DnsServer::Volcengine), + 67 => Pickle::unpickle(stream).map(DnsServer::Vultr), + 68 => Pickle::unpickle(stream).map(DnsServer::WebSupport), + 69 => Pickle::unpickle(stream).map(DnsServer::YandexCloud), + _ => None, + } + } +} + +impl IntoValue for DnsServer { + fn into_value(self) -> JmapValue<'static> { + match self { + DnsServer::Tsig(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Tsig".into())); + obj + } + DnsServer::Deprecated1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Deprecated1".into())); + JmapValue::Object(obj) + } + DnsServer::Cloudflare(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Cloudflare".into())); + obj + } + DnsServer::DigitalOcean(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DigitalOcean".into())); + obj + } + DnsServer::DeSEC(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DeSEC".into())); + obj + } + DnsServer::Ovh(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ovh".into())); + obj + } + DnsServer::Bunny(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Bunny".into())); + obj + } + DnsServer::Porkbun(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Porkbun".into())); + obj + } + DnsServer::Dnsimple(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dnsimple".into())); + obj + } + DnsServer::Spaceship(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Spaceship".into())); + obj + } + DnsServer::Route53(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Route53".into())); + obj + } + DnsServer::GoogleCloudDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("GoogleCloudDns".into())); + obj + } + DnsServer::Alidns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Alidns".into())); + obj + } + DnsServer::ArvanCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ArvanCloud".into())); + obj + } + DnsServer::Autodns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Autodns".into())); + obj + } + DnsServer::AzureDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("AzureDns".into())); + obj + } + DnsServer::BaiduCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("BaiduCloud".into())); + obj + } + DnsServer::BluecatV2(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("BluecatV2".into())); + obj + } + DnsServer::ClouDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ClouDns".into())); + obj + } + DnsServer::Constellix(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Constellix".into())); + obj + } + DnsServer::Cpanel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Cpanel".into())); + obj + } + DnsServer::Ddnss(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ddnss".into())); + obj + } + DnsServer::DnsMadeEasy(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DnsMadeEasy".into())); + obj + } + DnsServer::Domeneshop(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Domeneshop".into())); + obj + } + DnsServer::Dreamhost(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dreamhost".into())); + obj + } + DnsServer::DuckDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DuckDns".into())); + obj + } + DnsServer::Dynu(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dynu".into())); + obj + } + DnsServer::EasyDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EasyDns".into())); + obj + } + DnsServer::EdgeDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EdgeDns".into())); + obj + } + DnsServer::Exoscale(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Exoscale".into())); + obj + } + DnsServer::FreeMyIp(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FreeMyIp".into())); + obj + } + DnsServer::GandiV5(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("GandiV5".into())); + obj + } + DnsServer::Gcore(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Gcore".into())); + obj + } + DnsServer::Glesys(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Glesys".into())); + obj + } + DnsServer::Godaddy(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Godaddy".into())); + obj + } + DnsServer::Hetzner(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hetzner".into())); + obj + } + DnsServer::HostingDe(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("HostingDe".into())); + obj + } + DnsServer::Hostinger(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hostinger".into())); + obj + } + DnsServer::HuaweiCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("HuaweiCloud".into())); + obj + } + DnsServer::Hurricane(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hurricane".into())); + obj + } + DnsServer::IbmCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("IbmCloud".into())); + obj + } + DnsServer::Infoblox(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Infoblox".into())); + obj + } + DnsServer::Infomaniak(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Infomaniak".into())); + obj + } + DnsServer::Inwx(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Inwx".into())); + obj + } + DnsServer::Ionos(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ionos".into())); + obj + } + DnsServer::Ipv64(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ipv64".into())); + obj + } + DnsServer::Joker(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Joker".into())); + obj + } + DnsServer::Lightsail(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Lightsail".into())); + obj + } + DnsServer::Linode(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Linode".into())); + obj + } + DnsServer::LuaDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("LuaDns".into())); + obj + } + DnsServer::MythicBeasts(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MythicBeasts".into())); + obj + } + DnsServer::Namecheap(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Namecheap".into())); + obj + } + DnsServer::NameDotCom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("NameDotCom".into())); + obj + } + DnsServer::NameSilo(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("NameSilo".into())); + obj + } + DnsServer::Netcup(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Netcup".into())); + obj + } + DnsServer::Netlify(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Netlify".into())); + obj + } + DnsServer::Nifcloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Nifcloud".into())); + obj + } + DnsServer::Ns1(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ns1".into())); + obj + } + DnsServer::OracleCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("OracleCloud".into())); + obj + } + DnsServer::Plesk(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Plesk".into())); + obj + } + DnsServer::Safedns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Safedns".into())); + obj + } + DnsServer::Scaleway(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Scaleway".into())); + obj + } + DnsServer::TencentCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TencentCloud".into())); + obj + } + DnsServer::Transip(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Transip".into())); + obj + } + DnsServer::UltraDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UltraDns".into())); + obj + } + DnsServer::Vercel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Vercel".into())); + obj + } + DnsServer::Volcengine(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Volcengine".into())); + obj + } + DnsServer::Vultr(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Vultr".into())); + obj + } + DnsServer::WebSupport(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("WebSupport".into())); + obj + } + DnsServer::YandexCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DnsServer { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DnsServerType::Tsig => *self = DnsServer::Tsig(Default::default()), + DnsServerType::Deprecated1 => *self = DnsServer::Deprecated1, + DnsServerType::Cloudflare => *self = DnsServer::Cloudflare(Default::default()), + DnsServerType::DigitalOcean => *self = DnsServer::DigitalOcean(Default::default()), + DnsServerType::DeSEC => *self = DnsServer::DeSEC(Default::default()), + DnsServerType::Ovh => *self = DnsServer::Ovh(Default::default()), + DnsServerType::Bunny => *self = DnsServer::Bunny(Default::default()), + DnsServerType::Porkbun => *self = DnsServer::Porkbun(Default::default()), + DnsServerType::Dnsimple => *self = DnsServer::Dnsimple(Default::default()), + DnsServerType::Spaceship => *self = DnsServer::Spaceship(Default::default()), + DnsServerType::Route53 => *self = DnsServer::Route53(Default::default()), + DnsServerType::GoogleCloudDns => { + *self = DnsServer::GoogleCloudDns(Default::default()) + } + DnsServerType::Alidns => *self = DnsServer::Alidns(Default::default()), + DnsServerType::ArvanCloud => *self = DnsServer::ArvanCloud(Default::default()), + DnsServerType::Autodns => *self = DnsServer::Autodns(Default::default()), + DnsServerType::AzureDns => *self = DnsServer::AzureDns(Default::default()), + DnsServerType::BaiduCloud => *self = DnsServer::BaiduCloud(Default::default()), + DnsServerType::BluecatV2 => *self = DnsServer::BluecatV2(Default::default()), + DnsServerType::ClouDns => *self = DnsServer::ClouDns(Default::default()), + DnsServerType::Constellix => *self = DnsServer::Constellix(Default::default()), + DnsServerType::Cpanel => *self = DnsServer::Cpanel(Default::default()), + DnsServerType::Ddnss => *self = DnsServer::Ddnss(Default::default()), + DnsServerType::DnsMadeEasy => *self = DnsServer::DnsMadeEasy(Default::default()), + DnsServerType::Domeneshop => *self = DnsServer::Domeneshop(Default::default()), + DnsServerType::Dreamhost => *self = DnsServer::Dreamhost(Default::default()), + DnsServerType::DuckDns => *self = DnsServer::DuckDns(Default::default()), + DnsServerType::Dynu => *self = DnsServer::Dynu(Default::default()), + DnsServerType::EasyDns => *self = DnsServer::EasyDns(Default::default()), + DnsServerType::EdgeDns => *self = DnsServer::EdgeDns(Default::default()), + DnsServerType::Exoscale => *self = DnsServer::Exoscale(Default::default()), + DnsServerType::FreeMyIp => *self = DnsServer::FreeMyIp(Default::default()), + DnsServerType::GandiV5 => *self = DnsServer::GandiV5(Default::default()), + DnsServerType::Gcore => *self = DnsServer::Gcore(Default::default()), + DnsServerType::Glesys => *self = DnsServer::Glesys(Default::default()), + DnsServerType::Godaddy => *self = DnsServer::Godaddy(Default::default()), + DnsServerType::Hetzner => *self = DnsServer::Hetzner(Default::default()), + DnsServerType::HostingDe => *self = DnsServer::HostingDe(Default::default()), + DnsServerType::Hostinger => *self = DnsServer::Hostinger(Default::default()), + DnsServerType::HuaweiCloud => *self = DnsServer::HuaweiCloud(Default::default()), + DnsServerType::Hurricane => *self = DnsServer::Hurricane(Default::default()), + DnsServerType::IbmCloud => *self = DnsServer::IbmCloud(Default::default()), + DnsServerType::Infoblox => *self = DnsServer::Infoblox(Default::default()), + DnsServerType::Infomaniak => *self = DnsServer::Infomaniak(Default::default()), + DnsServerType::Inwx => *self = DnsServer::Inwx(Default::default()), + DnsServerType::Ionos => *self = DnsServer::Ionos(Default::default()), + DnsServerType::Ipv64 => *self = DnsServer::Ipv64(Default::default()), + DnsServerType::Joker => *self = DnsServer::Joker(Default::default()), + DnsServerType::Lightsail => *self = DnsServer::Lightsail(Default::default()), + DnsServerType::Linode => *self = DnsServer::Linode(Default::default()), + DnsServerType::LuaDns => *self = DnsServer::LuaDns(Default::default()), + DnsServerType::MythicBeasts => *self = DnsServer::MythicBeasts(Default::default()), + DnsServerType::Namecheap => *self = DnsServer::Namecheap(Default::default()), + DnsServerType::NameDotCom => *self = DnsServer::NameDotCom(Default::default()), + DnsServerType::NameSilo => *self = DnsServer::NameSilo(Default::default()), + DnsServerType::Netcup => *self = DnsServer::Netcup(Default::default()), + DnsServerType::Netlify => *self = DnsServer::Netlify(Default::default()), + DnsServerType::Nifcloud => *self = DnsServer::Nifcloud(Default::default()), + DnsServerType::Ns1 => *self = DnsServer::Ns1(Default::default()), + DnsServerType::OracleCloud => *self = DnsServer::OracleCloud(Default::default()), + DnsServerType::Plesk => *self = DnsServer::Plesk(Default::default()), + DnsServerType::Safedns => *self = DnsServer::Safedns(Default::default()), + DnsServerType::Scaleway => *self = DnsServer::Scaleway(Default::default()), + DnsServerType::TencentCloud => *self = DnsServer::TencentCloud(Default::default()), + DnsServerType::Transip => *self = DnsServer::Transip(Default::default()), + DnsServerType::UltraDns => *self = DnsServer::UltraDns(Default::default()), + DnsServerType::Vercel => *self = DnsServer::Vercel(Default::default()), + DnsServerType::Volcengine => *self = DnsServer::Volcengine(Default::default()), + DnsServerType::Vultr => *self = DnsServer::Vultr(Default::default()), + DnsServerType::WebSupport => *self = DnsServer::WebSupport(Default::default()), + DnsServerType::YandexCloud => *self = DnsServer::YandexCloud(Default::default()), + } + } + match self { + DnsServer::Tsig(inner) => inner.patch(pointer, value), + DnsServer::Deprecated1 => pointer.assert_eof(), + DnsServer::Cloudflare(inner) => inner.patch(pointer, value), + DnsServer::DigitalOcean(inner) => inner.patch(pointer, value), + DnsServer::DeSEC(inner) => inner.patch(pointer, value), + DnsServer::Ovh(inner) => inner.patch(pointer, value), + DnsServer::Bunny(inner) => inner.patch(pointer, value), + DnsServer::Porkbun(inner) => inner.patch(pointer, value), + DnsServer::Dnsimple(inner) => inner.patch(pointer, value), + DnsServer::Spaceship(inner) => inner.patch(pointer, value), + DnsServer::Route53(inner) => inner.patch(pointer, value), + DnsServer::GoogleCloudDns(inner) => inner.patch(pointer, value), + DnsServer::Alidns(inner) => inner.patch(pointer, value), + DnsServer::ArvanCloud(inner) => inner.patch(pointer, value), + DnsServer::Autodns(inner) => inner.patch(pointer, value), + DnsServer::AzureDns(inner) => inner.patch(pointer, value), + DnsServer::BaiduCloud(inner) => inner.patch(pointer, value), + DnsServer::BluecatV2(inner) => inner.patch(pointer, value), + DnsServer::ClouDns(inner) => inner.patch(pointer, value), + DnsServer::Constellix(inner) => inner.patch(pointer, value), + DnsServer::Cpanel(inner) => inner.patch(pointer, value), + DnsServer::Ddnss(inner) => inner.patch(pointer, value), + DnsServer::DnsMadeEasy(inner) => inner.patch(pointer, value), + DnsServer::Domeneshop(inner) => inner.patch(pointer, value), + DnsServer::Dreamhost(inner) => inner.patch(pointer, value), + DnsServer::DuckDns(inner) => inner.patch(pointer, value), + DnsServer::Dynu(inner) => inner.patch(pointer, value), + DnsServer::EasyDns(inner) => inner.patch(pointer, value), + DnsServer::EdgeDns(inner) => inner.patch(pointer, value), + DnsServer::Exoscale(inner) => inner.patch(pointer, value), + DnsServer::FreeMyIp(inner) => inner.patch(pointer, value), + DnsServer::GandiV5(inner) => inner.patch(pointer, value), + DnsServer::Gcore(inner) => inner.patch(pointer, value), + DnsServer::Glesys(inner) => inner.patch(pointer, value), + DnsServer::Godaddy(inner) => inner.patch(pointer, value), + DnsServer::Hetzner(inner) => inner.patch(pointer, value), + DnsServer::HostingDe(inner) => inner.patch(pointer, value), + DnsServer::Hostinger(inner) => inner.patch(pointer, value), + DnsServer::HuaweiCloud(inner) => inner.patch(pointer, value), + DnsServer::Hurricane(inner) => inner.patch(pointer, value), + DnsServer::IbmCloud(inner) => inner.patch(pointer, value), + DnsServer::Infoblox(inner) => inner.patch(pointer, value), + DnsServer::Infomaniak(inner) => inner.patch(pointer, value), + DnsServer::Inwx(inner) => inner.patch(pointer, value), + DnsServer::Ionos(inner) => inner.patch(pointer, value), + DnsServer::Ipv64(inner) => inner.patch(pointer, value), + DnsServer::Joker(inner) => inner.patch(pointer, value), + DnsServer::Lightsail(inner) => inner.patch(pointer, value), + DnsServer::Linode(inner) => inner.patch(pointer, value), + DnsServer::LuaDns(inner) => inner.patch(pointer, value), + DnsServer::MythicBeasts(inner) => inner.patch(pointer, value), + DnsServer::Namecheap(inner) => inner.patch(pointer, value), + DnsServer::NameDotCom(inner) => inner.patch(pointer, value), + DnsServer::NameSilo(inner) => inner.patch(pointer, value), + DnsServer::Netcup(inner) => inner.patch(pointer, value), + DnsServer::Netlify(inner) => inner.patch(pointer, value), + DnsServer::Nifcloud(inner) => inner.patch(pointer, value), + DnsServer::Ns1(inner) => inner.patch(pointer, value), + DnsServer::OracleCloud(inner) => inner.patch(pointer, value), + DnsServer::Plesk(inner) => inner.patch(pointer, value), + DnsServer::Safedns(inner) => inner.patch(pointer, value), + DnsServer::Scaleway(inner) => inner.patch(pointer, value), + DnsServer::TencentCloud(inner) => inner.patch(pointer, value), + DnsServer::Transip(inner) => inner.patch(pointer, value), + DnsServer::UltraDns(inner) => inner.patch(pointer, value), + DnsServer::Vercel(inner) => inner.patch(pointer, value), + DnsServer::Volcengine(inner) => inner.patch(pointer, value), + DnsServer::Vultr(inner) => inner.patch(pointer, value), + DnsServer::WebSupport(inner) => inner.patch(pointer, value), + DnsServer::YandexCloud(inner) => inner.patch(pointer, value), + } + } +} + +impl DnsServer { + pub fn object_type(&self) -> DnsServerType { + match self { + DnsServer::Tsig(_) => DnsServerType::Tsig, + DnsServer::Deprecated1 => DnsServerType::Deprecated1, + DnsServer::Cloudflare(_) => DnsServerType::Cloudflare, + DnsServer::DigitalOcean(_) => DnsServerType::DigitalOcean, + DnsServer::DeSEC(_) => DnsServerType::DeSEC, + DnsServer::Ovh(_) => DnsServerType::Ovh, + DnsServer::Bunny(_) => DnsServerType::Bunny, + DnsServer::Porkbun(_) => DnsServerType::Porkbun, + DnsServer::Dnsimple(_) => DnsServerType::Dnsimple, + DnsServer::Spaceship(_) => DnsServerType::Spaceship, + DnsServer::Route53(_) => DnsServerType::Route53, + DnsServer::GoogleCloudDns(_) => DnsServerType::GoogleCloudDns, + DnsServer::Alidns(_) => DnsServerType::Alidns, + DnsServer::ArvanCloud(_) => DnsServerType::ArvanCloud, + DnsServer::Autodns(_) => DnsServerType::Autodns, + DnsServer::AzureDns(_) => DnsServerType::AzureDns, + DnsServer::BaiduCloud(_) => DnsServerType::BaiduCloud, + DnsServer::BluecatV2(_) => DnsServerType::BluecatV2, + DnsServer::ClouDns(_) => DnsServerType::ClouDns, + DnsServer::Constellix(_) => DnsServerType::Constellix, + DnsServer::Cpanel(_) => DnsServerType::Cpanel, + DnsServer::Ddnss(_) => DnsServerType::Ddnss, + DnsServer::DnsMadeEasy(_) => DnsServerType::DnsMadeEasy, + DnsServer::Domeneshop(_) => DnsServerType::Domeneshop, + DnsServer::Dreamhost(_) => DnsServerType::Dreamhost, + DnsServer::DuckDns(_) => DnsServerType::DuckDns, + DnsServer::Dynu(_) => DnsServerType::Dynu, + DnsServer::EasyDns(_) => DnsServerType::EasyDns, + DnsServer::EdgeDns(_) => DnsServerType::EdgeDns, + DnsServer::Exoscale(_) => DnsServerType::Exoscale, + DnsServer::FreeMyIp(_) => DnsServerType::FreeMyIp, + DnsServer::GandiV5(_) => DnsServerType::GandiV5, + DnsServer::Gcore(_) => DnsServerType::Gcore, + DnsServer::Glesys(_) => DnsServerType::Glesys, + DnsServer::Godaddy(_) => DnsServerType::Godaddy, + DnsServer::Hetzner(_) => DnsServerType::Hetzner, + DnsServer::HostingDe(_) => DnsServerType::HostingDe, + DnsServer::Hostinger(_) => DnsServerType::Hostinger, + DnsServer::HuaweiCloud(_) => DnsServerType::HuaweiCloud, + DnsServer::Hurricane(_) => DnsServerType::Hurricane, + DnsServer::IbmCloud(_) => DnsServerType::IbmCloud, + DnsServer::Infoblox(_) => DnsServerType::Infoblox, + DnsServer::Infomaniak(_) => DnsServerType::Infomaniak, + DnsServer::Inwx(_) => DnsServerType::Inwx, + DnsServer::Ionos(_) => DnsServerType::Ionos, + DnsServer::Ipv64(_) => DnsServerType::Ipv64, + DnsServer::Joker(_) => DnsServerType::Joker, + DnsServer::Lightsail(_) => DnsServerType::Lightsail, + DnsServer::Linode(_) => DnsServerType::Linode, + DnsServer::LuaDns(_) => DnsServerType::LuaDns, + DnsServer::MythicBeasts(_) => DnsServerType::MythicBeasts, + DnsServer::Namecheap(_) => DnsServerType::Namecheap, + DnsServer::NameDotCom(_) => DnsServerType::NameDotCom, + DnsServer::NameSilo(_) => DnsServerType::NameSilo, + DnsServer::Netcup(_) => DnsServerType::Netcup, + DnsServer::Netlify(_) => DnsServerType::Netlify, + DnsServer::Nifcloud(_) => DnsServerType::Nifcloud, + DnsServer::Ns1(_) => DnsServerType::Ns1, + DnsServer::OracleCloud(_) => DnsServerType::OracleCloud, + DnsServer::Plesk(_) => DnsServerType::Plesk, + DnsServer::Safedns(_) => DnsServerType::Safedns, + DnsServer::Scaleway(_) => DnsServerType::Scaleway, + DnsServer::TencentCloud(_) => DnsServerType::TencentCloud, + DnsServer::Transip(_) => DnsServerType::Transip, + DnsServer::UltraDns(_) => DnsServerType::UltraDns, + DnsServer::Vercel(_) => DnsServerType::Vercel, + DnsServer::Volcengine(_) => DnsServerType::Volcengine, + DnsServer::Vultr(_) => DnsServerType::Vultr, + DnsServer::WebSupport(_) => DnsServerType::WebSupport, + DnsServer::YandexCloud(_) => DnsServerType::YandexCloud, + } + } +} + +impl DnsServerAlidns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKey)); + } + let value = &self.secret_key; + value.validate(errors); + if let Some(value) = &self.region { + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + } + let value = &self.security_token; + value.validate(errors); + if let Some(value) = &self.line { + if value.is_empty() { + errors.push(ValidationError::required(Property::Line)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerAlidns { + fn pickle(&self, out: &mut Vec) { + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.region.pickle(out); + self.security_token.pickle(out); + self.line.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.security_token = Pickle::unpickle(stream)?; + this.line = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerAlidns { + fn default() -> Self { + Self { + access_key: Default::default(), + secret_key: Default::default(), + region: Default::default(), + security_token: Default::default(), + line: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerAlidns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::SecurityToken, self.security_token.into_value()); + map.insert_unchecked(Property::Line, self.line.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerAlidns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKey) => self + .access_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecurityToken) => self.security_token.patch(pointer, value), + Some(Property::Line) => self + .line + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerAutodns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerAutodns { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.password.pickle(out); + self.context.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.context = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerAutodns { + fn default() -> Self { + Self { + username: Default::default(), + password: Default::default(), + context: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerAutodns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::Context, self.context.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerAutodns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Context) => self.context.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerAzureDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tenant_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::TenantId)); + } + let value = &self.client_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::ClientId)); + } + let value = &self.client_secret; + value.validate(errors); + let value = &self.subscription_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::SubscriptionId)); + } + let value = &self.resource_group; + if value.is_empty() { + errors.push(ValidationError::required(Property::ResourceGroup)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerAzureDns { + fn pickle(&self, out: &mut Vec) { + self.tenant_id.pickle(out); + self.client_id.pickle(out); + self.client_secret.pickle(out); + self.subscription_id.pickle(out); + self.resource_group.pickle(out); + self.environment.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tenant_id = Pickle::unpickle(stream)?; + this.client_id = Pickle::unpickle(stream)?; + this.client_secret = Pickle::unpickle(stream)?; + this.subscription_id = Pickle::unpickle(stream)?; + this.resource_group = Pickle::unpickle(stream)?; + this.environment = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerAzureDns { + fn default() -> Self { + Self { + tenant_id: Default::default(), + client_id: Default::default(), + client_secret: Default::default(), + subscription_id: Default::default(), + resource_group: Default::default(), + environment: AzureEnvironment::Public, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerAzureDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::TenantId, self.tenant_id.into_value()); + map.insert_unchecked(Property::ClientId, self.client_id.into_value()); + map.insert_unchecked(Property::ClientSecret, self.client_secret.into_value()); + map.insert_unchecked(Property::SubscriptionId, self.subscription_id.into_value()); + map.insert_unchecked(Property::ResourceGroup, self.resource_group.into_value()); + map.insert_unchecked(Property::Environment, self.environment.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerAzureDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TenantId) => self + .tenant_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClientId) => self + .client_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClientSecret) => self.client_secret.patch(pointer, value), + Some(Property::SubscriptionId) => self + .subscription_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ResourceGroup) => self + .resource_group + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Environment) => self.environment.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerBaiduCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKey)); + } + let value = &self.secret_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerBaiduCloud { + fn pickle(&self, out: &mut Vec) { + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerBaiduCloud { + fn default() -> Self { + Self { + access_key: Default::default(), + secret_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerBaiduCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerBaiduCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKey) => self + .access_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerBluecatV2 { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.base_url; + if value.is_empty() { + errors.push(ValidationError::required(Property::BaseUrl)); + } + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + let value = &self.config_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::ConfigName)); + } + let value = &self.view_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::ViewName)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerBluecatV2 { + fn pickle(&self, out: &mut Vec) { + self.base_url.pickle(out); + self.username.pickle(out); + self.password.pickle(out); + self.config_name.pickle(out); + self.view_name.pickle(out); + self.skip_deploy.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.base_url = Pickle::unpickle(stream)?; + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.config_name = Pickle::unpickle(stream)?; + this.view_name = Pickle::unpickle(stream)?; + this.skip_deploy = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerBluecatV2 { + fn default() -> Self { + Self { + base_url: Default::default(), + username: Default::default(), + password: Default::default(), + config_name: Default::default(), + view_name: Default::default(), + skip_deploy: false, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerBluecatV2 { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::BaseUrl, self.base_url.into_value()); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::ConfigName, self.config_name.into_value()); + map.insert_unchecked(Property::ViewName, self.view_name.into_value()); + map.insert_unchecked(Property::SkipDeploy, self.skip_deploy.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerBluecatV2 { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BaseUrl) => self + .base_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::ConfigName) => self + .config_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ViewName) => self + .view_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SkipDeploy) => self.skip_deploy.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerBootstrap { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + DnsServerBootstrap::Manual => true, + DnsServerBootstrap::Tsig(inner) => inner.validate(errors), + DnsServerBootstrap::Deprecated1 => true, + DnsServerBootstrap::Cloudflare(inner) => inner.validate(errors), + DnsServerBootstrap::DigitalOcean(inner) => inner.validate(errors), + DnsServerBootstrap::DeSEC(inner) => inner.validate(errors), + DnsServerBootstrap::Ovh(inner) => inner.validate(errors), + DnsServerBootstrap::Bunny(inner) => inner.validate(errors), + DnsServerBootstrap::Porkbun(inner) => inner.validate(errors), + DnsServerBootstrap::Dnsimple(inner) => inner.validate(errors), + DnsServerBootstrap::Spaceship(inner) => inner.validate(errors), + DnsServerBootstrap::Route53(inner) => inner.validate(errors), + DnsServerBootstrap::GoogleCloudDns(inner) => inner.validate(errors), + DnsServerBootstrap::Alidns(inner) => inner.validate(errors), + DnsServerBootstrap::ArvanCloud(inner) => inner.validate(errors), + DnsServerBootstrap::Autodns(inner) => inner.validate(errors), + DnsServerBootstrap::AzureDns(inner) => inner.validate(errors), + DnsServerBootstrap::BaiduCloud(inner) => inner.validate(errors), + DnsServerBootstrap::BluecatV2(inner) => inner.validate(errors), + DnsServerBootstrap::ClouDns(inner) => inner.validate(errors), + DnsServerBootstrap::Constellix(inner) => inner.validate(errors), + DnsServerBootstrap::Cpanel(inner) => inner.validate(errors), + DnsServerBootstrap::Ddnss(inner) => inner.validate(errors), + DnsServerBootstrap::DnsMadeEasy(inner) => inner.validate(errors), + DnsServerBootstrap::Domeneshop(inner) => inner.validate(errors), + DnsServerBootstrap::Dreamhost(inner) => inner.validate(errors), + DnsServerBootstrap::DuckDns(inner) => inner.validate(errors), + DnsServerBootstrap::Dynu(inner) => inner.validate(errors), + DnsServerBootstrap::EasyDns(inner) => inner.validate(errors), + DnsServerBootstrap::EdgeDns(inner) => inner.validate(errors), + DnsServerBootstrap::Exoscale(inner) => inner.validate(errors), + DnsServerBootstrap::FreeMyIp(inner) => inner.validate(errors), + DnsServerBootstrap::GandiV5(inner) => inner.validate(errors), + DnsServerBootstrap::Gcore(inner) => inner.validate(errors), + DnsServerBootstrap::Glesys(inner) => inner.validate(errors), + DnsServerBootstrap::Godaddy(inner) => inner.validate(errors), + DnsServerBootstrap::Hetzner(inner) => inner.validate(errors), + DnsServerBootstrap::HostingDe(inner) => inner.validate(errors), + DnsServerBootstrap::Hostinger(inner) => inner.validate(errors), + DnsServerBootstrap::HuaweiCloud(inner) => inner.validate(errors), + DnsServerBootstrap::Hurricane(inner) => inner.validate(errors), + DnsServerBootstrap::IbmCloud(inner) => inner.validate(errors), + DnsServerBootstrap::Infoblox(inner) => inner.validate(errors), + DnsServerBootstrap::Infomaniak(inner) => inner.validate(errors), + DnsServerBootstrap::Inwx(inner) => inner.validate(errors), + DnsServerBootstrap::Ionos(inner) => inner.validate(errors), + DnsServerBootstrap::Ipv64(inner) => inner.validate(errors), + DnsServerBootstrap::Joker(inner) => inner.validate(errors), + DnsServerBootstrap::Lightsail(inner) => inner.validate(errors), + DnsServerBootstrap::Linode(inner) => inner.validate(errors), + DnsServerBootstrap::LuaDns(inner) => inner.validate(errors), + DnsServerBootstrap::MythicBeasts(inner) => inner.validate(errors), + DnsServerBootstrap::Namecheap(inner) => inner.validate(errors), + DnsServerBootstrap::NameDotCom(inner) => inner.validate(errors), + DnsServerBootstrap::NameSilo(inner) => inner.validate(errors), + DnsServerBootstrap::Netcup(inner) => inner.validate(errors), + DnsServerBootstrap::Netlify(inner) => inner.validate(errors), + DnsServerBootstrap::Nifcloud(inner) => inner.validate(errors), + DnsServerBootstrap::Ns1(inner) => inner.validate(errors), + DnsServerBootstrap::OracleCloud(inner) => inner.validate(errors), + DnsServerBootstrap::Plesk(inner) => inner.validate(errors), + DnsServerBootstrap::Safedns(inner) => inner.validate(errors), + DnsServerBootstrap::Scaleway(inner) => inner.validate(errors), + DnsServerBootstrap::TencentCloud(inner) => inner.validate(errors), + DnsServerBootstrap::Transip(inner) => inner.validate(errors), + DnsServerBootstrap::UltraDns(inner) => inner.validate(errors), + DnsServerBootstrap::Vercel(inner) => inner.validate(errors), + DnsServerBootstrap::Volcengine(inner) => inner.validate(errors), + DnsServerBootstrap::Vultr(inner) => inner.validate(errors), + DnsServerBootstrap::WebSupport(inner) => inner.validate(errors), + DnsServerBootstrap::YandexCloud(inner) => inner.validate(errors), + } + } +} + +impl Default for DnsServerBootstrap { + fn default() -> Self { + DnsServerBootstrap::Manual + } +} + +impl Pickle for DnsServerBootstrap { + fn pickle(&self, out: &mut Vec) { + match self { + DnsServerBootstrap::Manual => { + 0u16.pickle(out); + } + DnsServerBootstrap::Tsig(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Deprecated1 => { + 2u16.pickle(out); + } + DnsServerBootstrap::Cloudflare(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::DigitalOcean(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::DeSEC(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Ovh(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Bunny(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Porkbun(inner) => { + 8u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Dnsimple(inner) => { + 9u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Spaceship(inner) => { + 10u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Route53(inner) => { + 11u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::GoogleCloudDns(inner) => { + 12u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Alidns(inner) => { + 13u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::ArvanCloud(inner) => { + 14u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Autodns(inner) => { + 15u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::AzureDns(inner) => { + 16u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::BaiduCloud(inner) => { + 17u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::BluecatV2(inner) => { + 18u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::ClouDns(inner) => { + 19u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Constellix(inner) => { + 20u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Cpanel(inner) => { + 21u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Ddnss(inner) => { + 22u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::DnsMadeEasy(inner) => { + 23u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Domeneshop(inner) => { + 24u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Dreamhost(inner) => { + 25u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::DuckDns(inner) => { + 26u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Dynu(inner) => { + 27u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::EasyDns(inner) => { + 28u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::EdgeDns(inner) => { + 29u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Exoscale(inner) => { + 30u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::FreeMyIp(inner) => { + 31u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::GandiV5(inner) => { + 32u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Gcore(inner) => { + 33u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Glesys(inner) => { + 34u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Godaddy(inner) => { + 35u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Hetzner(inner) => { + 36u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::HostingDe(inner) => { + 37u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Hostinger(inner) => { + 38u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::HuaweiCloud(inner) => { + 39u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Hurricane(inner) => { + 40u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::IbmCloud(inner) => { + 41u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Infoblox(inner) => { + 42u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Infomaniak(inner) => { + 43u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Inwx(inner) => { + 44u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Ionos(inner) => { + 45u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Ipv64(inner) => { + 46u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Joker(inner) => { + 47u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Lightsail(inner) => { + 48u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Linode(inner) => { + 49u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::LuaDns(inner) => { + 50u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::MythicBeasts(inner) => { + 51u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Namecheap(inner) => { + 52u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::NameDotCom(inner) => { + 53u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::NameSilo(inner) => { + 54u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Netcup(inner) => { + 55u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Netlify(inner) => { + 56u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Nifcloud(inner) => { + 57u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Ns1(inner) => { + 58u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::OracleCloud(inner) => { + 59u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Plesk(inner) => { + 60u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Safedns(inner) => { + 61u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Scaleway(inner) => { + 62u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::TencentCloud(inner) => { + 63u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Transip(inner) => { + 64u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::UltraDns(inner) => { + 65u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Vercel(inner) => { + 66u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Volcengine(inner) => { + 67u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::Vultr(inner) => { + 68u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::WebSupport(inner) => { + 69u16.pickle(out); + inner.pickle(out); + } + DnsServerBootstrap::YandexCloud(inner) => { + 70u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(DnsServerBootstrap::Manual), + 1 => Pickle::unpickle(stream).map(DnsServerBootstrap::Tsig), + 2 => Some(DnsServerBootstrap::Deprecated1), + 3 => Pickle::unpickle(stream).map(DnsServerBootstrap::Cloudflare), + 4 => Pickle::unpickle(stream).map(DnsServerBootstrap::DigitalOcean), + 5 => Pickle::unpickle(stream).map(DnsServerBootstrap::DeSEC), + 6 => Pickle::unpickle(stream).map(DnsServerBootstrap::Ovh), + 7 => Pickle::unpickle(stream).map(DnsServerBootstrap::Bunny), + 8 => Pickle::unpickle(stream).map(DnsServerBootstrap::Porkbun), + 9 => Pickle::unpickle(stream).map(DnsServerBootstrap::Dnsimple), + 10 => Pickle::unpickle(stream).map(DnsServerBootstrap::Spaceship), + 11 => Pickle::unpickle(stream).map(DnsServerBootstrap::Route53), + 12 => Pickle::unpickle(stream).map(DnsServerBootstrap::GoogleCloudDns), + 13 => Pickle::unpickle(stream).map(DnsServerBootstrap::Alidns), + 14 => Pickle::unpickle(stream).map(DnsServerBootstrap::ArvanCloud), + 15 => Pickle::unpickle(stream).map(DnsServerBootstrap::Autodns), + 16 => Pickle::unpickle(stream).map(DnsServerBootstrap::AzureDns), + 17 => Pickle::unpickle(stream).map(DnsServerBootstrap::BaiduCloud), + 18 => Pickle::unpickle(stream).map(DnsServerBootstrap::BluecatV2), + 19 => Pickle::unpickle(stream).map(DnsServerBootstrap::ClouDns), + 20 => Pickle::unpickle(stream).map(DnsServerBootstrap::Constellix), + 21 => Pickle::unpickle(stream).map(DnsServerBootstrap::Cpanel), + 22 => Pickle::unpickle(stream).map(DnsServerBootstrap::Ddnss), + 23 => Pickle::unpickle(stream).map(DnsServerBootstrap::DnsMadeEasy), + 24 => Pickle::unpickle(stream).map(DnsServerBootstrap::Domeneshop), + 25 => Pickle::unpickle(stream).map(DnsServerBootstrap::Dreamhost), + 26 => Pickle::unpickle(stream).map(DnsServerBootstrap::DuckDns), + 27 => Pickle::unpickle(stream).map(DnsServerBootstrap::Dynu), + 28 => Pickle::unpickle(stream).map(DnsServerBootstrap::EasyDns), + 29 => Pickle::unpickle(stream).map(DnsServerBootstrap::EdgeDns), + 30 => Pickle::unpickle(stream).map(DnsServerBootstrap::Exoscale), + 31 => Pickle::unpickle(stream).map(DnsServerBootstrap::FreeMyIp), + 32 => Pickle::unpickle(stream).map(DnsServerBootstrap::GandiV5), + 33 => Pickle::unpickle(stream).map(DnsServerBootstrap::Gcore), + 34 => Pickle::unpickle(stream).map(DnsServerBootstrap::Glesys), + 35 => Pickle::unpickle(stream).map(DnsServerBootstrap::Godaddy), + 36 => Pickle::unpickle(stream).map(DnsServerBootstrap::Hetzner), + 37 => Pickle::unpickle(stream).map(DnsServerBootstrap::HostingDe), + 38 => Pickle::unpickle(stream).map(DnsServerBootstrap::Hostinger), + 39 => Pickle::unpickle(stream).map(DnsServerBootstrap::HuaweiCloud), + 40 => Pickle::unpickle(stream).map(DnsServerBootstrap::Hurricane), + 41 => Pickle::unpickle(stream).map(DnsServerBootstrap::IbmCloud), + 42 => Pickle::unpickle(stream).map(DnsServerBootstrap::Infoblox), + 43 => Pickle::unpickle(stream).map(DnsServerBootstrap::Infomaniak), + 44 => Pickle::unpickle(stream).map(DnsServerBootstrap::Inwx), + 45 => Pickle::unpickle(stream).map(DnsServerBootstrap::Ionos), + 46 => Pickle::unpickle(stream).map(DnsServerBootstrap::Ipv64), + 47 => Pickle::unpickle(stream).map(DnsServerBootstrap::Joker), + 48 => Pickle::unpickle(stream).map(DnsServerBootstrap::Lightsail), + 49 => Pickle::unpickle(stream).map(DnsServerBootstrap::Linode), + 50 => Pickle::unpickle(stream).map(DnsServerBootstrap::LuaDns), + 51 => Pickle::unpickle(stream).map(DnsServerBootstrap::MythicBeasts), + 52 => Pickle::unpickle(stream).map(DnsServerBootstrap::Namecheap), + 53 => Pickle::unpickle(stream).map(DnsServerBootstrap::NameDotCom), + 54 => Pickle::unpickle(stream).map(DnsServerBootstrap::NameSilo), + 55 => Pickle::unpickle(stream).map(DnsServerBootstrap::Netcup), + 56 => Pickle::unpickle(stream).map(DnsServerBootstrap::Netlify), + 57 => Pickle::unpickle(stream).map(DnsServerBootstrap::Nifcloud), + 58 => Pickle::unpickle(stream).map(DnsServerBootstrap::Ns1), + 59 => Pickle::unpickle(stream).map(DnsServerBootstrap::OracleCloud), + 60 => Pickle::unpickle(stream).map(DnsServerBootstrap::Plesk), + 61 => Pickle::unpickle(stream).map(DnsServerBootstrap::Safedns), + 62 => Pickle::unpickle(stream).map(DnsServerBootstrap::Scaleway), + 63 => Pickle::unpickle(stream).map(DnsServerBootstrap::TencentCloud), + 64 => Pickle::unpickle(stream).map(DnsServerBootstrap::Transip), + 65 => Pickle::unpickle(stream).map(DnsServerBootstrap::UltraDns), + 66 => Pickle::unpickle(stream).map(DnsServerBootstrap::Vercel), + 67 => Pickle::unpickle(stream).map(DnsServerBootstrap::Volcengine), + 68 => Pickle::unpickle(stream).map(DnsServerBootstrap::Vultr), + 69 => Pickle::unpickle(stream).map(DnsServerBootstrap::WebSupport), + 70 => Pickle::unpickle(stream).map(DnsServerBootstrap::YandexCloud), + _ => None, + } + } +} + +impl IntoValue for DnsServerBootstrap { + fn into_value(self) -> JmapValue<'static> { + match self { + DnsServerBootstrap::Manual => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Manual".into())); + JmapValue::Object(obj) + } + DnsServerBootstrap::Tsig(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Tsig".into())); + obj + } + DnsServerBootstrap::Deprecated1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Deprecated1".into())); + JmapValue::Object(obj) + } + DnsServerBootstrap::Cloudflare(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Cloudflare".into())); + obj + } + DnsServerBootstrap::DigitalOcean(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DigitalOcean".into())); + obj + } + DnsServerBootstrap::DeSEC(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DeSEC".into())); + obj + } + DnsServerBootstrap::Ovh(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ovh".into())); + obj + } + DnsServerBootstrap::Bunny(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Bunny".into())); + obj + } + DnsServerBootstrap::Porkbun(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Porkbun".into())); + obj + } + DnsServerBootstrap::Dnsimple(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dnsimple".into())); + obj + } + DnsServerBootstrap::Spaceship(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Spaceship".into())); + obj + } + DnsServerBootstrap::Route53(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Route53".into())); + obj + } + DnsServerBootstrap::GoogleCloudDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("GoogleCloudDns".into())); + obj + } + DnsServerBootstrap::Alidns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Alidns".into())); + obj + } + DnsServerBootstrap::ArvanCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ArvanCloud".into())); + obj + } + DnsServerBootstrap::Autodns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Autodns".into())); + obj + } + DnsServerBootstrap::AzureDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("AzureDns".into())); + obj + } + DnsServerBootstrap::BaiduCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("BaiduCloud".into())); + obj + } + DnsServerBootstrap::BluecatV2(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("BluecatV2".into())); + obj + } + DnsServerBootstrap::ClouDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ClouDns".into())); + obj + } + DnsServerBootstrap::Constellix(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Constellix".into())); + obj + } + DnsServerBootstrap::Cpanel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Cpanel".into())); + obj + } + DnsServerBootstrap::Ddnss(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ddnss".into())); + obj + } + DnsServerBootstrap::DnsMadeEasy(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DnsMadeEasy".into())); + obj + } + DnsServerBootstrap::Domeneshop(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Domeneshop".into())); + obj + } + DnsServerBootstrap::Dreamhost(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dreamhost".into())); + obj + } + DnsServerBootstrap::DuckDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DuckDns".into())); + obj + } + DnsServerBootstrap::Dynu(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Dynu".into())); + obj + } + DnsServerBootstrap::EasyDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EasyDns".into())); + obj + } + DnsServerBootstrap::EdgeDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EdgeDns".into())); + obj + } + DnsServerBootstrap::Exoscale(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Exoscale".into())); + obj + } + DnsServerBootstrap::FreeMyIp(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FreeMyIp".into())); + obj + } + DnsServerBootstrap::GandiV5(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("GandiV5".into())); + obj + } + DnsServerBootstrap::Gcore(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Gcore".into())); + obj + } + DnsServerBootstrap::Glesys(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Glesys".into())); + obj + } + DnsServerBootstrap::Godaddy(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Godaddy".into())); + obj + } + DnsServerBootstrap::Hetzner(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hetzner".into())); + obj + } + DnsServerBootstrap::HostingDe(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("HostingDe".into())); + obj + } + DnsServerBootstrap::Hostinger(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hostinger".into())); + obj + } + DnsServerBootstrap::HuaweiCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("HuaweiCloud".into())); + obj + } + DnsServerBootstrap::Hurricane(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Hurricane".into())); + obj + } + DnsServerBootstrap::IbmCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("IbmCloud".into())); + obj + } + DnsServerBootstrap::Infoblox(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Infoblox".into())); + obj + } + DnsServerBootstrap::Infomaniak(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Infomaniak".into())); + obj + } + DnsServerBootstrap::Inwx(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Inwx".into())); + obj + } + DnsServerBootstrap::Ionos(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ionos".into())); + obj + } + DnsServerBootstrap::Ipv64(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ipv64".into())); + obj + } + DnsServerBootstrap::Joker(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Joker".into())); + obj + } + DnsServerBootstrap::Lightsail(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Lightsail".into())); + obj + } + DnsServerBootstrap::Linode(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Linode".into())); + obj + } + DnsServerBootstrap::LuaDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("LuaDns".into())); + obj + } + DnsServerBootstrap::MythicBeasts(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MythicBeasts".into())); + obj + } + DnsServerBootstrap::Namecheap(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Namecheap".into())); + obj + } + DnsServerBootstrap::NameDotCom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("NameDotCom".into())); + obj + } + DnsServerBootstrap::NameSilo(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("NameSilo".into())); + obj + } + DnsServerBootstrap::Netcup(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Netcup".into())); + obj + } + DnsServerBootstrap::Netlify(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Netlify".into())); + obj + } + DnsServerBootstrap::Nifcloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Nifcloud".into())); + obj + } + DnsServerBootstrap::Ns1(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ns1".into())); + obj + } + DnsServerBootstrap::OracleCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("OracleCloud".into())); + obj + } + DnsServerBootstrap::Plesk(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Plesk".into())); + obj + } + DnsServerBootstrap::Safedns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Safedns".into())); + obj + } + DnsServerBootstrap::Scaleway(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Scaleway".into())); + obj + } + DnsServerBootstrap::TencentCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TencentCloud".into())); + obj + } + DnsServerBootstrap::Transip(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Transip".into())); + obj + } + DnsServerBootstrap::UltraDns(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UltraDns".into())); + obj + } + DnsServerBootstrap::Vercel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Vercel".into())); + obj + } + DnsServerBootstrap::Volcengine(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Volcengine".into())); + obj + } + DnsServerBootstrap::Vultr(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Vultr".into())); + obj + } + DnsServerBootstrap::WebSupport(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("WebSupport".into())); + obj + } + DnsServerBootstrap::YandexCloud(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for DnsServerBootstrap { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + DnsServerBootstrapType::Manual => *self = DnsServerBootstrap::Manual, + DnsServerBootstrapType::Tsig => { + *self = DnsServerBootstrap::Tsig(Default::default()) + } + DnsServerBootstrapType::Deprecated1 => *self = DnsServerBootstrap::Deprecated1, + DnsServerBootstrapType::Cloudflare => { + *self = DnsServerBootstrap::Cloudflare(Default::default()) + } + DnsServerBootstrapType::DigitalOcean => { + *self = DnsServerBootstrap::DigitalOcean(Default::default()) + } + DnsServerBootstrapType::DeSEC => { + *self = DnsServerBootstrap::DeSEC(Default::default()) + } + DnsServerBootstrapType::Ovh => *self = DnsServerBootstrap::Ovh(Default::default()), + DnsServerBootstrapType::Bunny => { + *self = DnsServerBootstrap::Bunny(Default::default()) + } + DnsServerBootstrapType::Porkbun => { + *self = DnsServerBootstrap::Porkbun(Default::default()) + } + DnsServerBootstrapType::Dnsimple => { + *self = DnsServerBootstrap::Dnsimple(Default::default()) + } + DnsServerBootstrapType::Spaceship => { + *self = DnsServerBootstrap::Spaceship(Default::default()) + } + DnsServerBootstrapType::Route53 => { + *self = DnsServerBootstrap::Route53(Default::default()) + } + DnsServerBootstrapType::GoogleCloudDns => { + *self = DnsServerBootstrap::GoogleCloudDns(Default::default()) + } + DnsServerBootstrapType::Alidns => { + *self = DnsServerBootstrap::Alidns(Default::default()) + } + DnsServerBootstrapType::ArvanCloud => { + *self = DnsServerBootstrap::ArvanCloud(Default::default()) + } + DnsServerBootstrapType::Autodns => { + *self = DnsServerBootstrap::Autodns(Default::default()) + } + DnsServerBootstrapType::AzureDns => { + *self = DnsServerBootstrap::AzureDns(Default::default()) + } + DnsServerBootstrapType::BaiduCloud => { + *self = DnsServerBootstrap::BaiduCloud(Default::default()) + } + DnsServerBootstrapType::BluecatV2 => { + *self = DnsServerBootstrap::BluecatV2(Default::default()) + } + DnsServerBootstrapType::ClouDns => { + *self = DnsServerBootstrap::ClouDns(Default::default()) + } + DnsServerBootstrapType::Constellix => { + *self = DnsServerBootstrap::Constellix(Default::default()) + } + DnsServerBootstrapType::Cpanel => { + *self = DnsServerBootstrap::Cpanel(Default::default()) + } + DnsServerBootstrapType::Ddnss => { + *self = DnsServerBootstrap::Ddnss(Default::default()) + } + DnsServerBootstrapType::DnsMadeEasy => { + *self = DnsServerBootstrap::DnsMadeEasy(Default::default()) + } + DnsServerBootstrapType::Domeneshop => { + *self = DnsServerBootstrap::Domeneshop(Default::default()) + } + DnsServerBootstrapType::Dreamhost => { + *self = DnsServerBootstrap::Dreamhost(Default::default()) + } + DnsServerBootstrapType::DuckDns => { + *self = DnsServerBootstrap::DuckDns(Default::default()) + } + DnsServerBootstrapType::Dynu => { + *self = DnsServerBootstrap::Dynu(Default::default()) + } + DnsServerBootstrapType::EasyDns => { + *self = DnsServerBootstrap::EasyDns(Default::default()) + } + DnsServerBootstrapType::EdgeDns => { + *self = DnsServerBootstrap::EdgeDns(Default::default()) + } + DnsServerBootstrapType::Exoscale => { + *self = DnsServerBootstrap::Exoscale(Default::default()) + } + DnsServerBootstrapType::FreeMyIp => { + *self = DnsServerBootstrap::FreeMyIp(Default::default()) + } + DnsServerBootstrapType::GandiV5 => { + *self = DnsServerBootstrap::GandiV5(Default::default()) + } + DnsServerBootstrapType::Gcore => { + *self = DnsServerBootstrap::Gcore(Default::default()) + } + DnsServerBootstrapType::Glesys => { + *self = DnsServerBootstrap::Glesys(Default::default()) + } + DnsServerBootstrapType::Godaddy => { + *self = DnsServerBootstrap::Godaddy(Default::default()) + } + DnsServerBootstrapType::Hetzner => { + *self = DnsServerBootstrap::Hetzner(Default::default()) + } + DnsServerBootstrapType::HostingDe => { + *self = DnsServerBootstrap::HostingDe(Default::default()) + } + DnsServerBootstrapType::Hostinger => { + *self = DnsServerBootstrap::Hostinger(Default::default()) + } + DnsServerBootstrapType::HuaweiCloud => { + *self = DnsServerBootstrap::HuaweiCloud(Default::default()) + } + DnsServerBootstrapType::Hurricane => { + *self = DnsServerBootstrap::Hurricane(Default::default()) + } + DnsServerBootstrapType::IbmCloud => { + *self = DnsServerBootstrap::IbmCloud(Default::default()) + } + DnsServerBootstrapType::Infoblox => { + *self = DnsServerBootstrap::Infoblox(Default::default()) + } + DnsServerBootstrapType::Infomaniak => { + *self = DnsServerBootstrap::Infomaniak(Default::default()) + } + DnsServerBootstrapType::Inwx => { + *self = DnsServerBootstrap::Inwx(Default::default()) + } + DnsServerBootstrapType::Ionos => { + *self = DnsServerBootstrap::Ionos(Default::default()) + } + DnsServerBootstrapType::Ipv64 => { + *self = DnsServerBootstrap::Ipv64(Default::default()) + } + DnsServerBootstrapType::Joker => { + *self = DnsServerBootstrap::Joker(Default::default()) + } + DnsServerBootstrapType::Lightsail => { + *self = DnsServerBootstrap::Lightsail(Default::default()) + } + DnsServerBootstrapType::Linode => { + *self = DnsServerBootstrap::Linode(Default::default()) + } + DnsServerBootstrapType::LuaDns => { + *self = DnsServerBootstrap::LuaDns(Default::default()) + } + DnsServerBootstrapType::MythicBeasts => { + *self = DnsServerBootstrap::MythicBeasts(Default::default()) + } + DnsServerBootstrapType::Namecheap => { + *self = DnsServerBootstrap::Namecheap(Default::default()) + } + DnsServerBootstrapType::NameDotCom => { + *self = DnsServerBootstrap::NameDotCom(Default::default()) + } + DnsServerBootstrapType::NameSilo => { + *self = DnsServerBootstrap::NameSilo(Default::default()) + } + DnsServerBootstrapType::Netcup => { + *self = DnsServerBootstrap::Netcup(Default::default()) + } + DnsServerBootstrapType::Netlify => { + *self = DnsServerBootstrap::Netlify(Default::default()) + } + DnsServerBootstrapType::Nifcloud => { + *self = DnsServerBootstrap::Nifcloud(Default::default()) + } + DnsServerBootstrapType::Ns1 => *self = DnsServerBootstrap::Ns1(Default::default()), + DnsServerBootstrapType::OracleCloud => { + *self = DnsServerBootstrap::OracleCloud(Default::default()) + } + DnsServerBootstrapType::Plesk => { + *self = DnsServerBootstrap::Plesk(Default::default()) + } + DnsServerBootstrapType::Safedns => { + *self = DnsServerBootstrap::Safedns(Default::default()) + } + DnsServerBootstrapType::Scaleway => { + *self = DnsServerBootstrap::Scaleway(Default::default()) + } + DnsServerBootstrapType::TencentCloud => { + *self = DnsServerBootstrap::TencentCloud(Default::default()) + } + DnsServerBootstrapType::Transip => { + *self = DnsServerBootstrap::Transip(Default::default()) + } + DnsServerBootstrapType::UltraDns => { + *self = DnsServerBootstrap::UltraDns(Default::default()) + } + DnsServerBootstrapType::Vercel => { + *self = DnsServerBootstrap::Vercel(Default::default()) + } + DnsServerBootstrapType::Volcengine => { + *self = DnsServerBootstrap::Volcengine(Default::default()) + } + DnsServerBootstrapType::Vultr => { + *self = DnsServerBootstrap::Vultr(Default::default()) + } + DnsServerBootstrapType::WebSupport => { + *self = DnsServerBootstrap::WebSupport(Default::default()) + } + DnsServerBootstrapType::YandexCloud => { + *self = DnsServerBootstrap::YandexCloud(Default::default()) + } + } + } + match self { + DnsServerBootstrap::Manual => pointer.assert_eof(), + DnsServerBootstrap::Tsig(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Deprecated1 => pointer.assert_eof(), + DnsServerBootstrap::Cloudflare(inner) => inner.patch(pointer, value), + DnsServerBootstrap::DigitalOcean(inner) => inner.patch(pointer, value), + DnsServerBootstrap::DeSEC(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Ovh(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Bunny(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Porkbun(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Dnsimple(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Spaceship(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Route53(inner) => inner.patch(pointer, value), + DnsServerBootstrap::GoogleCloudDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Alidns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::ArvanCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Autodns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::AzureDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::BaiduCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::BluecatV2(inner) => inner.patch(pointer, value), + DnsServerBootstrap::ClouDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Constellix(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Cpanel(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Ddnss(inner) => inner.patch(pointer, value), + DnsServerBootstrap::DnsMadeEasy(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Domeneshop(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Dreamhost(inner) => inner.patch(pointer, value), + DnsServerBootstrap::DuckDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Dynu(inner) => inner.patch(pointer, value), + DnsServerBootstrap::EasyDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::EdgeDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Exoscale(inner) => inner.patch(pointer, value), + DnsServerBootstrap::FreeMyIp(inner) => inner.patch(pointer, value), + DnsServerBootstrap::GandiV5(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Gcore(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Glesys(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Godaddy(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Hetzner(inner) => inner.patch(pointer, value), + DnsServerBootstrap::HostingDe(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Hostinger(inner) => inner.patch(pointer, value), + DnsServerBootstrap::HuaweiCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Hurricane(inner) => inner.patch(pointer, value), + DnsServerBootstrap::IbmCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Infoblox(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Infomaniak(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Inwx(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Ionos(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Ipv64(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Joker(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Lightsail(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Linode(inner) => inner.patch(pointer, value), + DnsServerBootstrap::LuaDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::MythicBeasts(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Namecheap(inner) => inner.patch(pointer, value), + DnsServerBootstrap::NameDotCom(inner) => inner.patch(pointer, value), + DnsServerBootstrap::NameSilo(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Netcup(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Netlify(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Nifcloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Ns1(inner) => inner.patch(pointer, value), + DnsServerBootstrap::OracleCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Plesk(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Safedns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Scaleway(inner) => inner.patch(pointer, value), + DnsServerBootstrap::TencentCloud(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Transip(inner) => inner.patch(pointer, value), + DnsServerBootstrap::UltraDns(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Vercel(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Volcengine(inner) => inner.patch(pointer, value), + DnsServerBootstrap::Vultr(inner) => inner.patch(pointer, value), + DnsServerBootstrap::WebSupport(inner) => inner.patch(pointer, value), + DnsServerBootstrap::YandexCloud(inner) => inner.patch(pointer, value), + } + } +} + +impl DnsServerBootstrap { + pub fn object_type(&self) -> DnsServerBootstrapType { + match self { + DnsServerBootstrap::Manual => DnsServerBootstrapType::Manual, + DnsServerBootstrap::Tsig(_) => DnsServerBootstrapType::Tsig, + DnsServerBootstrap::Deprecated1 => DnsServerBootstrapType::Deprecated1, + DnsServerBootstrap::Cloudflare(_) => DnsServerBootstrapType::Cloudflare, + DnsServerBootstrap::DigitalOcean(_) => DnsServerBootstrapType::DigitalOcean, + DnsServerBootstrap::DeSEC(_) => DnsServerBootstrapType::DeSEC, + DnsServerBootstrap::Ovh(_) => DnsServerBootstrapType::Ovh, + DnsServerBootstrap::Bunny(_) => DnsServerBootstrapType::Bunny, + DnsServerBootstrap::Porkbun(_) => DnsServerBootstrapType::Porkbun, + DnsServerBootstrap::Dnsimple(_) => DnsServerBootstrapType::Dnsimple, + DnsServerBootstrap::Spaceship(_) => DnsServerBootstrapType::Spaceship, + DnsServerBootstrap::Route53(_) => DnsServerBootstrapType::Route53, + DnsServerBootstrap::GoogleCloudDns(_) => DnsServerBootstrapType::GoogleCloudDns, + DnsServerBootstrap::Alidns(_) => DnsServerBootstrapType::Alidns, + DnsServerBootstrap::ArvanCloud(_) => DnsServerBootstrapType::ArvanCloud, + DnsServerBootstrap::Autodns(_) => DnsServerBootstrapType::Autodns, + DnsServerBootstrap::AzureDns(_) => DnsServerBootstrapType::AzureDns, + DnsServerBootstrap::BaiduCloud(_) => DnsServerBootstrapType::BaiduCloud, + DnsServerBootstrap::BluecatV2(_) => DnsServerBootstrapType::BluecatV2, + DnsServerBootstrap::ClouDns(_) => DnsServerBootstrapType::ClouDns, + DnsServerBootstrap::Constellix(_) => DnsServerBootstrapType::Constellix, + DnsServerBootstrap::Cpanel(_) => DnsServerBootstrapType::Cpanel, + DnsServerBootstrap::Ddnss(_) => DnsServerBootstrapType::Ddnss, + DnsServerBootstrap::DnsMadeEasy(_) => DnsServerBootstrapType::DnsMadeEasy, + DnsServerBootstrap::Domeneshop(_) => DnsServerBootstrapType::Domeneshop, + DnsServerBootstrap::Dreamhost(_) => DnsServerBootstrapType::Dreamhost, + DnsServerBootstrap::DuckDns(_) => DnsServerBootstrapType::DuckDns, + DnsServerBootstrap::Dynu(_) => DnsServerBootstrapType::Dynu, + DnsServerBootstrap::EasyDns(_) => DnsServerBootstrapType::EasyDns, + DnsServerBootstrap::EdgeDns(_) => DnsServerBootstrapType::EdgeDns, + DnsServerBootstrap::Exoscale(_) => DnsServerBootstrapType::Exoscale, + DnsServerBootstrap::FreeMyIp(_) => DnsServerBootstrapType::FreeMyIp, + DnsServerBootstrap::GandiV5(_) => DnsServerBootstrapType::GandiV5, + DnsServerBootstrap::Gcore(_) => DnsServerBootstrapType::Gcore, + DnsServerBootstrap::Glesys(_) => DnsServerBootstrapType::Glesys, + DnsServerBootstrap::Godaddy(_) => DnsServerBootstrapType::Godaddy, + DnsServerBootstrap::Hetzner(_) => DnsServerBootstrapType::Hetzner, + DnsServerBootstrap::HostingDe(_) => DnsServerBootstrapType::HostingDe, + DnsServerBootstrap::Hostinger(_) => DnsServerBootstrapType::Hostinger, + DnsServerBootstrap::HuaweiCloud(_) => DnsServerBootstrapType::HuaweiCloud, + DnsServerBootstrap::Hurricane(_) => DnsServerBootstrapType::Hurricane, + DnsServerBootstrap::IbmCloud(_) => DnsServerBootstrapType::IbmCloud, + DnsServerBootstrap::Infoblox(_) => DnsServerBootstrapType::Infoblox, + DnsServerBootstrap::Infomaniak(_) => DnsServerBootstrapType::Infomaniak, + DnsServerBootstrap::Inwx(_) => DnsServerBootstrapType::Inwx, + DnsServerBootstrap::Ionos(_) => DnsServerBootstrapType::Ionos, + DnsServerBootstrap::Ipv64(_) => DnsServerBootstrapType::Ipv64, + DnsServerBootstrap::Joker(_) => DnsServerBootstrapType::Joker, + DnsServerBootstrap::Lightsail(_) => DnsServerBootstrapType::Lightsail, + DnsServerBootstrap::Linode(_) => DnsServerBootstrapType::Linode, + DnsServerBootstrap::LuaDns(_) => DnsServerBootstrapType::LuaDns, + DnsServerBootstrap::MythicBeasts(_) => DnsServerBootstrapType::MythicBeasts, + DnsServerBootstrap::Namecheap(_) => DnsServerBootstrapType::Namecheap, + DnsServerBootstrap::NameDotCom(_) => DnsServerBootstrapType::NameDotCom, + DnsServerBootstrap::NameSilo(_) => DnsServerBootstrapType::NameSilo, + DnsServerBootstrap::Netcup(_) => DnsServerBootstrapType::Netcup, + DnsServerBootstrap::Netlify(_) => DnsServerBootstrapType::Netlify, + DnsServerBootstrap::Nifcloud(_) => DnsServerBootstrapType::Nifcloud, + DnsServerBootstrap::Ns1(_) => DnsServerBootstrapType::Ns1, + DnsServerBootstrap::OracleCloud(_) => DnsServerBootstrapType::OracleCloud, + DnsServerBootstrap::Plesk(_) => DnsServerBootstrapType::Plesk, + DnsServerBootstrap::Safedns(_) => DnsServerBootstrapType::Safedns, + DnsServerBootstrap::Scaleway(_) => DnsServerBootstrapType::Scaleway, + DnsServerBootstrap::TencentCloud(_) => DnsServerBootstrapType::TencentCloud, + DnsServerBootstrap::Transip(_) => DnsServerBootstrapType::Transip, + DnsServerBootstrap::UltraDns(_) => DnsServerBootstrapType::UltraDns, + DnsServerBootstrap::Vercel(_) => DnsServerBootstrapType::Vercel, + DnsServerBootstrap::Volcengine(_) => DnsServerBootstrapType::Volcengine, + DnsServerBootstrap::Vultr(_) => DnsServerBootstrapType::Vultr, + DnsServerBootstrap::WebSupport(_) => DnsServerBootstrapType::WebSupport, + DnsServerBootstrap::YandexCloud(_) => DnsServerBootstrapType::YandexCloud, + } + } +} + +impl DnsServerClouDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.auth_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthId)); + } + } + if let Some(value) = &self.sub_auth_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::SubAuthId)); + } + } + let value = &self.password; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerClouDns { + fn pickle(&self, out: &mut Vec) { + self.auth_id.pickle(out); + self.sub_auth_id.pickle(out); + self.password.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_id = Pickle::unpickle(stream)?; + this.sub_auth_id = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerClouDns { + fn default() -> Self { + Self { + auth_id: Default::default(), + sub_auth_id: Default::default(), + password: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerClouDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::AuthId, self.auth_id.into_value()); + map.insert_unchecked(Property::SubAuthId, self.sub_auth_id.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerClouDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthId) => self + .auth_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SubAuthId) => self + .sub_auth_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerCloud { + fn pickle(&self, out: &mut Vec) { + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerCloud { + fn default() -> Self { + Self { + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerCloudflare { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerCloudflare { + fn pickle(&self, out: &mut Vec) { + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + if stream.version() < 1 { + let _: Option = Pickle::unpickle(stream)?; + } + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerCloudflare { + fn default() -> Self { + Self { + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerCloudflare { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerCloudflare { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(property @ Property::Email) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerConstellix { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerConstellix { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerConstellix { + fn default() -> Self { + Self { + api_key: Default::default(), + secret_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerConstellix { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerConstellix { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerCpanel { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.base_url; + if value.is_empty() { + errors.push(ValidationError::required(Property::BaseUrl)); + } + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.token; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerCpanel { + fn pickle(&self, out: &mut Vec) { + self.base_url.pickle(out); + self.username.pickle(out); + self.token.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.base_url = Pickle::unpickle(stream)?; + this.username = Pickle::unpickle(stream)?; + this.token = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerCpanel { + fn default() -> Self { + Self { + base_url: Default::default(), + username: Default::default(), + token: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerCpanel { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::BaseUrl, self.base_url.into_value()); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Token, self.token.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerCpanel { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BaseUrl) => self + .base_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Token) => self.token.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerDnsMadeEasy { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerDnsMadeEasy { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerDnsMadeEasy { + fn default() -> Self { + Self { + api_key: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerDnsMadeEasy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerDnsMadeEasy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerDnsimple { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth_token; + value.validate(errors); + let value = &self.account_identifier; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccountIdentifier)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerDnsimple { + fn pickle(&self, out: &mut Vec) { + self.auth_token.pickle(out); + self.account_identifier.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_token = Pickle::unpickle(stream)?; + this.account_identifier = Pickle::unpickle(stream)?; + if stream.version() < 1 { + let _: SecretKey = Pickle::unpickle(stream)?; + } + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerDnsimple { + fn default() -> Self { + Self { + auth_token: Default::default(), + account_identifier: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerDnsimple { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AuthToken, self.auth_token.into_value()); + map.insert_unchecked( + Property::AccountIdentifier, + self.account_identifier.into_value(), + ); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerDnsimple { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthToken) => self.auth_token.patch(pointer, value), + Some(Property::AccountIdentifier) => self + .account_identifier + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(property @ Property::Secret) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerDomeneshop { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth_token; + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthToken)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerDomeneshop { + fn pickle(&self, out: &mut Vec) { + self.auth_token.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_token = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerDomeneshop { + fn default() -> Self { + Self { + auth_token: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerDomeneshop { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AuthToken, self.auth_token.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerDomeneshop { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthToken) => self + .auth_token + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerEasyDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.token; + if value.is_empty() { + errors.push(ValidationError::required(Property::Token)); + } + let value = &self.key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerEasyDns { + fn pickle(&self, out: &mut Vec) { + self.token.pickle(out); + self.key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.token = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerEasyDns { + fn default() -> Self { + Self { + token: Default::default(), + key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerEasyDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Token, self.token.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerEasyDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Token) => self + .token + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerEdgeDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.client_token; + if value.is_empty() { + errors.push(ValidationError::required(Property::ClientToken)); + } + let value = &self.client_secret; + value.validate(errors); + let value = &self.access_token; + value.validate(errors); + if let Some(value) = &self.account_switch_key { + if value.is_empty() { + errors.push(ValidationError::required(Property::AccountSwitchKey)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerEdgeDns { + fn pickle(&self, out: &mut Vec) { + self.host.pickle(out); + self.client_token.pickle(out); + self.client_secret.pickle(out); + self.access_token.pickle(out); + self.account_switch_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.host = Pickle::unpickle(stream)?; + this.client_token = Pickle::unpickle(stream)?; + this.client_secret = Pickle::unpickle(stream)?; + this.access_token = Pickle::unpickle(stream)?; + this.account_switch_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerEdgeDns { + fn default() -> Self { + Self { + host: Default::default(), + client_token: Default::default(), + client_secret: Default::default(), + access_token: Default::default(), + account_switch_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerEdgeDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::ClientToken, self.client_token.into_value()); + map.insert_unchecked(Property::ClientSecret, self.client_secret.into_value()); + map.insert_unchecked(Property::AccessToken, self.access_token.into_value()); + map.insert_unchecked( + Property::AccountSwitchKey, + self.account_switch_key.into_value(), + ); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerEdgeDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClientToken) => self + .client_token + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClientSecret) => self.client_secret.patch(pointer, value), + Some(Property::AccessToken) => self.access_token.patch(pointer, value), + Some(Property::AccountSwitchKey) => self + .account_switch_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerExoscale { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerExoscale { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerExoscale { + fn default() -> Self { + Self { + api_key: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerExoscale { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerExoscale { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerGlesys { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_user; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiUser)); + } + let value = &self.api_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerGlesys { + fn pickle(&self, out: &mut Vec) { + self.api_user.pickle(out); + self.api_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_user = Pickle::unpickle(stream)?; + this.api_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerGlesys { + fn default() -> Self { + Self { + api_user: Default::default(), + api_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerGlesys { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiUser, self.api_user.into_value()); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerGlesys { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiUser) => self + .api_user + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerGodaddy { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerGodaddy { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerGodaddy { + fn default() -> Self { + Self { + api_key: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerGodaddy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerGodaddy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerGoogleCloudDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.service_account_json; + value.validate(errors); + let value = &self.project_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::ProjectId)); + } + if let Some(value) = &self.managed_zone { + if value.is_empty() { + errors.push(ValidationError::required(Property::ManagedZone)); + } + } + if let Some(value) = &self.impersonate_service_account { + if value.is_empty() { + errors.push(ValidationError::required( + Property::ImpersonateServiceAccount, + )); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerGoogleCloudDns { + fn pickle(&self, out: &mut Vec) { + self.service_account_json.pickle(out); + self.project_id.pickle(out); + self.managed_zone.pickle(out); + self.private_zone.pickle(out); + self.impersonate_service_account.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.service_account_json = Pickle::unpickle(stream)?; + this.project_id = Pickle::unpickle(stream)?; + this.managed_zone = Pickle::unpickle(stream)?; + this.private_zone = Pickle::unpickle(stream)?; + this.impersonate_service_account = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerGoogleCloudDns { + fn default() -> Self { + Self { + service_account_json: Default::default(), + project_id: Default::default(), + managed_zone: Default::default(), + private_zone: false, + impersonate_service_account: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerGoogleCloudDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked( + Property::ServiceAccountJson, + self.service_account_json.into_value(), + ); + map.insert_unchecked(Property::ProjectId, self.project_id.into_value()); + map.insert_unchecked(Property::ManagedZone, self.managed_zone.into_value()); + map.insert_unchecked(Property::PrivateZone, self.private_zone.into_value()); + map.insert_unchecked( + Property::ImpersonateServiceAccount, + self.impersonate_service_account.into_value(), + ); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerGoogleCloudDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ServiceAccountJson) => self.service_account_json.patch(pointer, value), + Some(Property::ProjectId) => self + .project_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ManagedZone) => self + .managed_zone + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PrivateZone) => self.private_zone.patch(pointer, value), + Some(Property::ImpersonateServiceAccount) => self + .impersonate_service_account + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerHuaweiCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKey)); + } + let value = &self.secret_key; + value.validate(errors); + let value = &self.region; + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerHuaweiCloud { + fn pickle(&self, out: &mut Vec) { + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.region.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerHuaweiCloud { + fn default() -> Self { + Self { + access_key: Default::default(), + secret_key: Default::default(), + region: "ap-southeast-1".to_string(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerHuaweiCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerHuaweiCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKey) => self + .access_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerHurricane { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.credentials; + for value in value.values() { + value.validate(errors); + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Credentials, 1)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerHurricane { + fn pickle(&self, out: &mut Vec) { + self.credentials.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.credentials = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerHurricane { + fn default() -> Self { + Self { + credentials: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerHurricane { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Credentials, self.credentials.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerHurricane { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Credentials) => self.credentials.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerIbmCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.api_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerIbmCloud { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.api_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.api_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerIbmCloud { + fn default() -> Self { + Self { + username: Default::default(), + api_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerIbmCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerIbmCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerInfoblox { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + if let Some(value) = &self.port { + if value.is_empty() { + errors.push(ValidationError::required(Property::Port)); + } + } + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + if let Some(value) = &self.wapi_version { + if value.is_empty() { + errors.push(ValidationError::required(Property::WapiVersion)); + } + } + if let Some(value) = &self.dns_view { + if value.is_empty() { + errors.push(ValidationError::required(Property::DnsView)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerInfoblox { + fn pickle(&self, out: &mut Vec) { + self.host.pickle(out); + self.port.pickle(out); + self.username.pickle(out); + self.password.pickle(out); + self.wapi_version.pickle(out); + self.dns_view.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.wapi_version = Pickle::unpickle(stream)?; + this.dns_view = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerInfoblox { + fn default() -> Self { + Self { + host: Default::default(), + port: Default::default(), + username: Default::default(), + password: Default::default(), + wapi_version: Default::default(), + dns_view: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerInfoblox { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::WapiVersion, self.wapi_version.into_value()); + map.insert_unchecked(Property::DnsView, self.dns_view.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerInfoblox { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self + .port + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::WapiVersion) => self + .wapi_version + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DnsView) => self + .dns_view + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerInwx { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + let value = &self.shared_secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerInwx { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.password.pickle(out); + self.shared_secret.pickle(out); + self.sandbox.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.shared_secret = Pickle::unpickle(stream)?; + this.sandbox = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerInwx { + fn default() -> Self { + Self { + username: Default::default(), + password: Default::default(), + shared_secret: Default::default(), + sandbox: false, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerInwx { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::SharedSecret, self.shared_secret.into_value()); + map.insert_unchecked(Property::Sandbox, self.sandbox.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerInwx { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::SharedSecret) => self.shared_secret.patch(pointer, value), + Some(Property::Sandbox) => self.sandbox.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerJoker { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerJoker { + fn pickle(&self, out: &mut Vec) { + self.auth.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerJoker { + fn default() -> Self { + Self { + auth: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerJoker { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Auth, self.auth.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerJoker { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Auth) => self.auth.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerLightsail { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKeyId)); + } + let value = &self.secret_access_key; + value.validate(errors); + let value = &self.session_token; + value.validate(errors); + if let Some(value) = &self.region { + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + } + if let Some(value) = &self.domain { + if value.is_empty() { + errors.push(ValidationError::required(Property::Domain)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerLightsail { + fn pickle(&self, out: &mut Vec) { + self.access_key_id.pickle(out); + self.secret_access_key.pickle(out); + self.session_token.pickle(out); + self.region.pickle(out); + self.domain.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key_id = Pickle::unpickle(stream)?; + this.secret_access_key = Pickle::unpickle(stream)?; + this.session_token = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.domain = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerLightsail { + fn default() -> Self { + Self { + access_key_id: Default::default(), + secret_access_key: Default::default(), + session_token: Default::default(), + region: Default::default(), + domain: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerLightsail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::AccessKeyId, self.access_key_id.into_value()); + map.insert_unchecked( + Property::SecretAccessKey, + self.secret_access_key.into_value(), + ); + map.insert_unchecked(Property::SessionToken, self.session_token.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::Domain, self.domain.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerLightsail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKeyId) => self + .access_key_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretAccessKey) => self.secret_access_key.patch(pointer, value), + Some(Property::SessionToken) => self.session_token.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Domain) => self + .domain + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerLuaDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.auth_token; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerLuaDns { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.auth_token.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.auth_token = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerLuaDns { + fn default() -> Self { + Self { + username: Default::default(), + auth_token: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerLuaDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::AuthToken, self.auth_token.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerLuaDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthToken) => self.auth_token.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerMythicBeasts { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerMythicBeasts { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.password.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerMythicBeasts { + fn default() -> Self { + Self { + username: Default::default(), + password: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerMythicBeasts { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerMythicBeasts { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerNameDotCom { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.auth_token; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerNameDotCom { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.auth_token.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.auth_token = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerNameDotCom { + fn default() -> Self { + Self { + username: Default::default(), + auth_token: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerNameDotCom { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::AuthToken, self.auth_token.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerNameDotCom { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthToken) => self.auth_token.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerNamecheap { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_user; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiUser)); + } + let value = &self.api_key; + value.validate(errors); + let value = &self.client_ip; + if value.is_empty() { + errors.push(ValidationError::required(Property::ClientIp)); + } + if let Some(value) = &self.username { + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerNamecheap { + fn pickle(&self, out: &mut Vec) { + self.api_user.pickle(out); + self.api_key.pickle(out); + self.client_ip.pickle(out); + self.username.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_user = Pickle::unpickle(stream)?; + this.api_key = Pickle::unpickle(stream)?; + this.client_ip = Pickle::unpickle(stream)?; + this.username = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerNamecheap { + fn default() -> Self { + Self { + api_user: Default::default(), + api_key: Default::default(), + client_ip: Default::default(), + username: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerNamecheap { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::ApiUser, self.api_user.into_value()); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::ClientIp, self.client_ip.into_value()); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerNamecheap { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiUser) => self + .api_user + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::ClientIp) => self + .client_ip + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerNetcup { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.customer_number; + if value.is_empty() { + errors.push(ValidationError::required(Property::CustomerNumber)); + } + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.password; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerNetcup { + fn pickle(&self, out: &mut Vec) { + self.customer_number.pickle(out); + self.api_key.pickle(out); + self.password.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.customer_number = Pickle::unpickle(stream)?; + this.api_key = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerNetcup { + fn default() -> Self { + Self { + customer_number: Default::default(), + api_key: Default::default(), + password: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerNetcup { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::CustomerNumber, self.customer_number.into_value()); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerNetcup { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CustomerNumber) => self + .customer_number + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerNifcloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKey)); + } + let value = &self.secret_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerNifcloud { + fn pickle(&self, out: &mut Vec) { + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerNifcloud { + fn default() -> Self { + Self { + access_key: Default::default(), + secret_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerNifcloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerNifcloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKey) => self + .access_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerOracleCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tenancy_ocid; + if value.is_empty() { + errors.push(ValidationError::required(Property::TenancyOcid)); + } + let value = &self.user_ocid; + if value.is_empty() { + errors.push(ValidationError::required(Property::UserOcid)); + } + let value = &self.fingerprint; + if value.is_empty() { + errors.push(ValidationError::required(Property::Fingerprint)); + } + let value = &self.private_key_pem; + value.validate(errors); + let value = &self.private_key_password; + value.validate(errors); + let value = &self.region; + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + let value = &self.compartment_ocid; + if value.is_empty() { + errors.push(ValidationError::required(Property::CompartmentOcid)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerOracleCloud { + fn pickle(&self, out: &mut Vec) { + self.tenancy_ocid.pickle(out); + self.user_ocid.pickle(out); + self.fingerprint.pickle(out); + self.private_key_pem.pickle(out); + self.private_key_password.pickle(out); + self.region.pickle(out); + self.compartment_ocid.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tenancy_ocid = Pickle::unpickle(stream)?; + this.user_ocid = Pickle::unpickle(stream)?; + this.fingerprint = Pickle::unpickle(stream)?; + this.private_key_pem = Pickle::unpickle(stream)?; + this.private_key_password = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.compartment_ocid = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerOracleCloud { + fn default() -> Self { + Self { + tenancy_ocid: Default::default(), + user_ocid: Default::default(), + fingerprint: Default::default(), + private_key_pem: Default::default(), + private_key_password: Default::default(), + region: Default::default(), + compartment_ocid: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerOracleCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(16); + map.insert_unchecked(Property::TenancyOcid, self.tenancy_ocid.into_value()); + map.insert_unchecked(Property::UserOcid, self.user_ocid.into_value()); + map.insert_unchecked(Property::Fingerprint, self.fingerprint.into_value()); + map.insert_unchecked(Property::PrivateKeyPem, self.private_key_pem.into_value()); + map.insert_unchecked( + Property::PrivateKeyPassword, + self.private_key_password.into_value(), + ); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked( + Property::CompartmentOcid, + self.compartment_ocid.into_value(), + ); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerOracleCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TenancyOcid) => self + .tenancy_ocid + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::UserOcid) => self + .user_ocid + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Fingerprint) => self + .fingerprint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PrivateKeyPem) => self.private_key_pem.patch(pointer, value), + Some(Property::PrivateKeyPassword) => self.private_key_password.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::CompartmentOcid) => self + .compartment_ocid + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerOvh { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.application_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApplicationKey)); + } + let value = &self.application_secret; + value.validate(errors); + let value = &self.consumer_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerOvh { + fn pickle(&self, out: &mut Vec) { + self.application_key.pickle(out); + self.application_secret.pickle(out); + self.consumer_key.pickle(out); + self.ovh_endpoint.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.application_key = Pickle::unpickle(stream)?; + this.application_secret = Pickle::unpickle(stream)?; + this.consumer_key = Pickle::unpickle(stream)?; + this.ovh_endpoint = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerOvh { + fn default() -> Self { + Self { + application_key: Default::default(), + application_secret: Default::default(), + consumer_key: Default::default(), + ovh_endpoint: OvhEndpoint::OvhEu, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerOvh { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::ApplicationKey, self.application_key.into_value()); + map.insert_unchecked( + Property::ApplicationSecret, + self.application_secret.into_value(), + ); + map.insert_unchecked(Property::ConsumerKey, self.consumer_key.into_value()); + map.insert_unchecked(Property::OvhEndpoint, self.ovh_endpoint.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerOvh { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApplicationKey) => self + .application_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApplicationSecret) => self.application_secret.patch(pointer, value), + Some(Property::ConsumerKey) => self.consumer_key.patch(pointer, value), + Some(Property::OvhEndpoint) => self.ovh_endpoint.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerPlesk { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.base_url; + if value.is_empty() { + errors.push(ValidationError::required(Property::BaseUrl)); + } + let value = &self.api_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerPlesk { + fn pickle(&self, out: &mut Vec) { + self.base_url.pickle(out); + self.api_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.base_url = Pickle::unpickle(stream)?; + this.api_key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerPlesk { + fn default() -> Self { + Self { + base_url: Default::default(), + api_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerPlesk { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::BaseUrl, self.base_url.into_value()); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerPlesk { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BaseUrl) => self + .base_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerPorkbun { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret_api_key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerPorkbun { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret_api_key.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret_api_key = Pickle::unpickle(stream)?; + if stream.version() < 1 { + let _: SecretKey = Pickle::unpickle(stream)?; + } + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerPorkbun { + fn default() -> Self { + Self { + api_key: Default::default(), + secret_api_key: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerPorkbun { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::SecretApiKey, self.secret_api_key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerPorkbun { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretApiKey) => self.secret_api_key.patch(pointer, value), + Some(property @ Property::Secret) => Ok(MaybeUnpatched::Unpatched { property, value }), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerRoute53 { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKeyId)); + } + let value = &self.secret_access_key; + value.validate(errors); + let value = &self.session_token; + value.validate(errors); + let value = &self.region; + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + if let Some(value) = &self.hosted_zone_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::HostedZoneId)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerRoute53 { + fn pickle(&self, out: &mut Vec) { + self.access_key_id.pickle(out); + self.secret_access_key.pickle(out); + self.session_token.pickle(out); + self.region.pickle(out); + self.hosted_zone_id.pickle(out); + self.private_zone_only.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key_id = Pickle::unpickle(stream)?; + this.secret_access_key = Pickle::unpickle(stream)?; + this.session_token = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.hosted_zone_id = Pickle::unpickle(stream)?; + this.private_zone_only = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerRoute53 { + fn default() -> Self { + Self { + access_key_id: Default::default(), + secret_access_key: Default::default(), + session_token: Default::default(), + region: "us-east-1".to_string(), + hosted_zone_id: Default::default(), + private_zone_only: false, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerRoute53 { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::AccessKeyId, self.access_key_id.into_value()); + map.insert_unchecked( + Property::SecretAccessKey, + self.secret_access_key.into_value(), + ); + map.insert_unchecked(Property::SessionToken, self.session_token.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::HostedZoneId, self.hosted_zone_id.into_value()); + map.insert_unchecked( + Property::PrivateZoneOnly, + self.private_zone_only.into_value(), + ); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerRoute53 { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKeyId) => self + .access_key_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretAccessKey) => self.secret_access_key.patch(pointer, value), + Some(Property::SessionToken) => self.session_token.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::HostedZoneId) => self + .hosted_zone_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PrivateZoneOnly) => self.private_zone_only.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerSpaceship { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerSpaceship { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerSpaceship { + fn default() -> Self { + Self { + api_key: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerSpaceship { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerSpaceship { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerTencentCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.secret_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::SecretId)); + } + let value = &self.secret_key; + value.validate(errors); + if let Some(value) = &self.region { + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + } + let value = &self.session_token; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerTencentCloud { + fn pickle(&self, out: &mut Vec) { + self.secret_id.pickle(out); + self.secret_key.pickle(out); + self.region.pickle(out); + self.session_token.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.secret_id = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.session_token = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerTencentCloud { + fn default() -> Self { + Self { + secret_id: Default::default(), + secret_key: Default::default(), + region: Default::default(), + session_token: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerTencentCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::SecretId, self.secret_id.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::SessionToken, self.session_token.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerTencentCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::SecretId) => self + .secret_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SessionToken) => self.session_token.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerTransip { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.private_key_pem; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerTransip { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.private_key_pem.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.private_key_pem = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerTransip { + fn default() -> Self { + Self { + username: Default::default(), + private_key_pem: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerTransip { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::PrivateKeyPem, self.private_key_pem.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerTransip { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PrivateKeyPem) => self.private_key_pem.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerTsig { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.host; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Host, value)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.key_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::KeyName)); + } + let value = &self.key; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerTsig { + fn pickle(&self, out: &mut Vec) { + self.host.pickle(out); + self.port.pickle(out); + self.key_name.pickle(out); + self.key.pickle(out); + self.protocol.pickle(out); + self.tsig_algorithm.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.key_name = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.protocol = Pickle::unpickle(stream)?; + this.tsig_algorithm = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerTsig { + fn default() -> Self { + Self { + host: Default::default(), + port: 53u64, + key_name: Default::default(), + key: Default::default(), + protocol: IpProtocol::Udp, + tsig_algorithm: TsigAlgorithm::HmacSha512, + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerTsig { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::KeyName, self.key_name.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Protocol, self.protocol.into_value()); + map.insert_unchecked(Property::TsigAlgorithm, self.tsig_algorithm.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerTsig { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::KeyName) => self + .key_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Protocol) => self.protocol.patch(pointer, value), + Some(Property::TsigAlgorithm) => self.tsig_algorithm.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerUltraDns { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + if let Some(value) = &self.endpoint { + if value.is_empty() { + errors.push(ValidationError::required(Property::Endpoint)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerUltraDns { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.password.pickle(out); + self.endpoint.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + this.endpoint = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerUltraDns { + fn default() -> Self { + Self { + username: Default::default(), + password: Default::default(), + endpoint: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerUltraDns { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + map.insert_unchecked(Property::Endpoint, self.endpoint.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerUltraDns { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Endpoint) => self + .endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerVercel { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth_token; + value.validate(errors); + if let Some(value) = &self.team_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::TeamId)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerVercel { + fn pickle(&self, out: &mut Vec) { + self.auth_token.pickle(out); + self.team_id.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_token = Pickle::unpickle(stream)?; + this.team_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerVercel { + fn default() -> Self { + Self { + auth_token: Default::default(), + team_id: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerVercel { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AuthToken, self.auth_token.into_value()); + map.insert_unchecked(Property::TeamId, self.team_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerVercel { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthToken) => self.auth_token.patch(pointer, value), + Some(Property::TeamId) => self + .team_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerVolcengine { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.access_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccessKey)); + } + let value = &self.secret_key; + value.validate(errors); + if let Some(value) = &self.region { + if value.is_empty() { + errors.push(ValidationError::required(Property::Region)); + } + } + if let Some(value) = &self.host { + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + } + if let Some(value) = &self.scheme { + if value.is_empty() { + errors.push(ValidationError::required(Property::Scheme)); + } + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerVolcengine { + fn pickle(&self, out: &mut Vec) { + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.region.pickle(out); + self.host.pickle(out); + self.scheme.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.region = Pickle::unpickle(stream)?; + this.host = Pickle::unpickle(stream)?; + this.scheme = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerVolcengine { + fn default() -> Self { + Self { + access_key: Default::default(), + secret_key: Default::default(), + region: Default::default(), + host: Default::default(), + scheme: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerVolcengine { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Scheme, self.scheme.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerVolcengine { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccessKey) => self + .access_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::Region) => self + .region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Scheme) => self + .scheme + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerWebSupport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + if value.is_empty() { + errors.push(ValidationError::required(Property::ApiKey)); + } + let value = &self.secret; + value.validate(errors); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerWebSupport { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.secret.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerWebSupport { + fn default() -> Self { + Self { + api_key: Default::default(), + secret: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerWebSupport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerWebSupport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self + .api_key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl DnsServerYandexCloud { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + value.validate(errors); + let value = &self.folder_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::FolderId)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for DnsServerYandexCloud { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.folder_id.pickle(out); + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.timeout.pickle(out); + self.ttl.pickle(out); + self.polling_interval.pickle(out); + self.propagation_timeout.pickle(out); + self.propagation_delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.folder_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.ttl = Pickle::unpickle(stream)?; + this.polling_interval = Pickle::unpickle(stream)?; + this.propagation_timeout = Pickle::unpickle(stream)?; + this.propagation_delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DnsServerYandexCloud { + fn default() -> Self { + Self { + api_key: Default::default(), + folder_id: Default::default(), + description: Default::default(), + member_tenant_id: Default::default(), + timeout: Duration::from_millis(30000), + ttl: Duration::from_millis(300000), + polling_interval: Duration::from_millis(15000), + propagation_timeout: Duration::from_millis(60000), + propagation_delay: Default::default(), + } + } +} + +impl IntoValue for DnsServerYandexCloud { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::FolderId, self.folder_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Ttl, self.ttl.into_value()); + map.insert_unchecked( + Property::PollingInterval, + self.polling_interval.into_value(), + ); + map.insert_unchecked( + Property::PropagationTimeout, + self.propagation_timeout.into_value(), + ); + map.insert_unchecked( + Property::PropagationDelay, + self.propagation_delay.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DnsServerYandexCloud { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::FolderId) => self + .folder_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Ttl) => self.ttl.patch(pointer, value), + Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value), + Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value), + Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Domain { + const FLAGS: u64 = OBJ_FILTER_TENANT | OBJ_SEQ_ID; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Domain; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.aliases; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Aliases)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + if let Some(value) = &self.logo { + if value.is_empty() { + errors.push(ValidationError::required(Property::Logo)); + } + } + let value = &self.certificate_management; + value.validate(errors); + let value = &self.dkim_management; + value.validate(errors); + let value = &self.dns_management; + value.validate(errors); + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + if let Some(value) = &self.directory_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DirectoryId)); + } + } + if let Some(value) = &self.catch_all_address { + if value.is_empty() { + errors.push(ValidationError::required(Property::CatchAllAddress)); + } + } + let value = &self.sub_addressing; + value.validate(errors); + if let Some(value) = &self.report_address_uri { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportAddressUri)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + i.text(Property::Text, &self.name); + for value in self.aliases.iter() { + i.unique(Property::Aliases, value); + } + for value in self.aliases.iter() { + i.text(Property::Text, value); + } + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + self.certificate_management.index(i); + self.dns_management.index(i); + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + i.foreign_key(ObjectType::Directory, self.directory_id, None); + } +} + +impl Pickle for Domain { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.aliases.pickle(out); + self.is_enabled.pickle(out); + self.created_at.pickle(out); + self.description.pickle(out); + self.logo.pickle(out); + self.certificate_management.pickle(out); + self.dkim_management.pickle(out); + self.dns_management.pickle(out); + self.member_tenant_id.pickle(out); + self.directory_id.pickle(out); + self.catch_all_address.pickle(out); + self.sub_addressing.pickle(out); + self.allow_relaying.pickle(out); + self.report_address_uri.pickle(out); + self.allow_scim_provisioning.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.aliases = Pickle::unpickle(stream)?; + this.is_enabled = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.logo = Pickle::unpickle(stream)?; + this.certificate_management = Pickle::unpickle(stream)?; + this.dkim_management = Pickle::unpickle(stream)?; + this.dns_management = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.directory_id = Pickle::unpickle(stream)?; + this.catch_all_address = Pickle::unpickle(stream)?; + this.sub_addressing = Pickle::unpickle(stream)?; + this.allow_relaying = Pickle::unpickle(stream)?; + this.report_address_uri = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.allow_scim_provisioning = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for Domain { + fn default() -> Self { + Self { + name: Default::default(), + aliases: Default::default(), + is_enabled: true, + created_at: Default::default(), + description: Default::default(), + logo: Default::default(), + certificate_management: Default::default(), + dkim_management: Default::default(), + dns_management: Default::default(), + member_tenant_id: Default::default(), + directory_id: Default::default(), + catch_all_address: Default::default(), + sub_addressing: Default::default(), + allow_relaying: false, + report_address_uri: Some("mailto:postmaster".to_string()), + allow_scim_provisioning: false, + } + } +} + +impl IntoValue for Domain { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(18); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Aliases, self.aliases.into_value()); + map.insert_unchecked(Property::IsEnabled, self.is_enabled.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Logo, self.logo.into_value()); + map.insert_unchecked( + Property::CertificateManagement, + self.certificate_management.into_value(), + ); + map.insert_unchecked(Property::DkimManagement, self.dkim_management.into_value()); + map.insert_unchecked(Property::DnsManagement, self.dns_management.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::DirectoryId, self.directory_id.into_value()); + map.insert_unchecked( + Property::CatchAllAddress, + self.catch_all_address.into_value(), + ); + map.insert_unchecked(Property::SubAddressing, self.sub_addressing.into_value()); + map.insert_unchecked(Property::AllowRelaying, self.allow_relaying.into_value()); + map.insert_unchecked( + Property::ReportAddressUri, + self.report_address_uri.into_value(), + ); + map.insert_unchecked( + Property::AllowScimProvisioning, + self.allow_scim_provisioning.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Domain { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self + .name + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::Aliases) => self + .aliases + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::IsEnabled) => self.is_enabled.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Logo) => self.logo.patch(pointer, value), + Some(Property::CertificateManagement) => { + self.certificate_management.patch(pointer, value) + } + Some(Property::DkimManagement) => self.dkim_management.patch(pointer, value), + Some(Property::DnsManagement) => self.dns_management.patch(pointer, value), + Some(Property::DnsZoneFile) => pointer.assert_server_set(), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::DirectoryId) => self.directory_id.patch(pointer, value), + Some(Property::CatchAllAddress) => self + .catch_all_address + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::SubAddressing) => self.sub_addressing.patch(pointer, value), + Some(Property::AllowRelaying) => self.allow_relaying.patch(pointer, value), + Some(Property::ReportAddressUri) => self + .report_address_uri + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AllowScimProvisioning) => { + self.allow_scim_provisioning.patch(pointer, value) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for DsnReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::DsnReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from_address; + value.validate(errors); + let value = &self.from_name; + value.validate(errors); + let value = &self.dkim_sign_domain; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl DsnReportSettings { + pub fn ctx_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_address, + default: Some(Expression { + else_: "'MAILER-DAEMON@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::FromAddress, + allowed_variables: MTA_QUEUE_SENDER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_name, + default: Some(Expression { + else_: "'Mail Delivery Subsystem'".to_string(), + ..Default::default() + }), + property: Property::FromName, + allowed_variables: MTA_QUEUE_SENDER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_QUEUE_SENDER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_from_address(), + self.ctx_from_name(), + self.ctx_dkim_sign_domain(), + ] + } +} + +impl Pickle for DsnReportSettings { + fn pickle(&self, out: &mut Vec) { + self.from_address.pickle(out); + self.from_name.pickle(out); + self.dkim_sign_domain.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from_address = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.dkim_sign_domain = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for DsnReportSettings { + fn default() -> Self { + Self { + from_address: Expression { + else_: "'MAILER-DAEMON@' + system('domain')".to_string(), + ..Default::default() + }, + from_name: Expression { + else_: "'Mail Delivery Subsystem'".to_string(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for DsnReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::FromAddress, self.from_address.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for DsnReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::FromAddress) => self.from_address.patch(pointer, value), + Some(Property::FromName) => self.from_name.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ElasticSearchStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.num_replicas; + if *value > 2048 { + errors.push(ValidationError::max_value(Property::NumReplicas, 2048)); + } + let value = &self.num_shards; + if *value > 1048576 { + errors.push(ValidationError::max_value(Property::NumShards, 1048576)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::NumShards, 1)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for ElasticSearchStore { + fn pickle(&self, out: &mut Vec) { + self.url.pickle(out); + self.num_replicas.pickle(out); + self.num_shards.pickle(out); + self.include_source.pickle(out); + self.timeout.pickle(out); + self.allow_invalid_certs.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.url = Pickle::unpickle(stream)?; + this.num_replicas = Pickle::unpickle(stream)?; + this.num_shards = Pickle::unpickle(stream)?; + this.include_source = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ElasticSearchStore { + fn default() -> Self { + Self { + url: Default::default(), + num_replicas: 0u64, + num_shards: 3u64, + include_source: false, + timeout: Duration::from_millis(30000), + allow_invalid_certs: false, + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for ElasticSearchStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::NumReplicas, self.num_replicas.into_value()); + map.insert_unchecked(Property::NumShards, self.num_shards.into_value()); + map.insert_unchecked(Property::IncludeSource, self.include_source.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ElasticSearchStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::NumReplicas) => self.num_replicas.patch(pointer, value), + Some(Property::NumShards) => self.num_shards.patch(pointer, value), + Some(Property::IncludeSource) => self.include_source.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Email { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Email; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_attachment_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAttachmentSize, 1)); + } + let value = &self.max_message_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMessageSize, 1)); + } + let value = &self.max_mailbox_depth; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMailboxDepth, 1)); + } + let value = &self.max_mailbox_name_length; + if *value < 1 { + errors.push(ValidationError::min_value( + Property::MaxMailboxNameLength, + 1, + )); + } + let value = &self.default_folders; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.max_messages { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMessages, 1)); + } + } + if let Some(value) = &self.max_submissions { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxSubmissions, 1)); + } + } + if let Some(value) = &self.max_identities { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxIdentities, 1)); + } + } + if let Some(value) = &self.max_mailboxes { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMailboxes, 1)); + } + } + if let Some(value) = &self.max_masked_addresses { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMaskedAddresses, 1)); + } + } + if let Some(value) = &self.max_public_keys { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxPublicKeys, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Email { + fn pickle(&self, out: &mut Vec) { + self.max_attachment_size.pickle(out); + self.max_message_size.pickle(out); + self.max_mailbox_depth.pickle(out); + self.max_mailbox_name_length.pickle(out); + self.encrypt_on_append.pickle(out); + self.encrypt_at_rest.pickle(out); + self.compression_algorithm.pickle(out); + self.default_folders.pickle(out); + self.max_messages.pickle(out); + self.max_submissions.pickle(out); + self.max_identities.pickle(out); + self.max_mailboxes.pickle(out); + self.max_masked_addresses.pickle(out); + self.max_public_keys.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_attachment_size = Pickle::unpickle(stream)?; + this.max_message_size = Pickle::unpickle(stream)?; + this.max_mailbox_depth = Pickle::unpickle(stream)?; + this.max_mailbox_name_length = Pickle::unpickle(stream)?; + this.encrypt_on_append = Pickle::unpickle(stream)?; + this.encrypt_at_rest = Pickle::unpickle(stream)?; + this.compression_algorithm = Pickle::unpickle(stream)?; + this.default_folders = Pickle::unpickle(stream)?; + this.max_messages = Pickle::unpickle(stream)?; + this.max_submissions = Pickle::unpickle(stream)?; + this.max_identities = Pickle::unpickle(stream)?; + this.max_mailboxes = Pickle::unpickle(stream)?; + this.max_masked_addresses = Pickle::unpickle(stream)?; + this.max_public_keys = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Email { + fn default() -> Self { + Self { + max_attachment_size: 50000000u64, + max_message_size: 75000000u64, + max_mailbox_depth: 10u64, + max_mailbox_name_length: 255u64, + encrypt_on_append: false, + encrypt_at_rest: true, + compression_algorithm: CompressionAlgo::Lz4, + default_folders: Default::default(), + max_messages: Default::default(), + max_submissions: Some(500u64), + max_identities: Some(20u64), + max_mailboxes: Some(250u64), + max_masked_addresses: Some(5u64), + max_public_keys: Some(5u64), + } + } +} + +impl IntoValue for Email { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(16); + map.insert_unchecked( + Property::MaxAttachmentSize, + self.max_attachment_size.into_value(), + ); + map.insert_unchecked(Property::MaxMessageSize, self.max_message_size.into_value()); + map.insert_unchecked( + Property::MaxMailboxDepth, + self.max_mailbox_depth.into_value(), + ); + map.insert_unchecked( + Property::MaxMailboxNameLength, + self.max_mailbox_name_length.into_value(), + ); + map.insert_unchecked( + Property::EncryptOnAppend, + self.encrypt_on_append.into_value(), + ); + map.insert_unchecked(Property::EncryptAtRest, self.encrypt_at_rest.into_value()); + map.insert_unchecked( + Property::CompressionAlgorithm, + self.compression_algorithm.into_value(), + ); + map.insert_unchecked(Property::DefaultFolders, self.default_folders.into_value()); + map.insert_unchecked(Property::MaxMessages, self.max_messages.into_value()); + map.insert_unchecked(Property::MaxSubmissions, self.max_submissions.into_value()); + map.insert_unchecked(Property::MaxIdentities, self.max_identities.into_value()); + map.insert_unchecked(Property::MaxMailboxes, self.max_mailboxes.into_value()); + map.insert_unchecked( + Property::MaxMaskedAddresses, + self.max_masked_addresses.into_value(), + ); + map.insert_unchecked(Property::MaxPublicKeys, self.max_public_keys.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Email { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxAttachmentSize) => self.max_attachment_size.patch(pointer, value), + Some(Property::MaxMessageSize) => self.max_message_size.patch(pointer, value), + Some(Property::MaxMailboxDepth) => self.max_mailbox_depth.patch(pointer, value), + Some(Property::MaxMailboxNameLength) => { + self.max_mailbox_name_length.patch(pointer, value) + } + Some(Property::EncryptOnAppend) => self.encrypt_on_append.patch(pointer, value), + Some(Property::EncryptAtRest) => self.encrypt_at_rest.patch(pointer, value), + Some(Property::CompressionAlgorithm) => { + self.compression_algorithm.patch(pointer, value) + } + Some(Property::DefaultFolders) => self.default_folders.patch(pointer, value), + Some(Property::MaxMessages) => self.max_messages.patch(pointer, value), + Some(Property::MaxSubmissions) => self.max_submissions.patch(pointer, value), + Some(Property::MaxIdentities) => self.max_identities.patch(pointer, value), + Some(Property::MaxMailboxes) => self.max_mailboxes.patch(pointer, value), + Some(Property::MaxMaskedAddresses) => self.max_masked_addresses.patch(pointer, value), + Some(Property::MaxPublicKeys) => self.max_public_keys.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl EmailAlias { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Email, &self.name, &self.domain_id); + i.text(Property::Text, &self.name); + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + } +} + +impl Pickle for EmailAlias { + fn pickle(&self, out: &mut Vec) { + self.enabled.pickle(out); + self.name.pickle(out); + self.domain_id.pickle(out); + self.description.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enabled = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for EmailAlias { + fn default() -> Self { + Self { + enabled: true, + name: Default::default(), + domain_id: Default::default(), + description: Default::default(), + } + } +} + +impl IntoValue for EmailAlias { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Enabled, self.enabled.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for EmailAlias { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enabled) => self.enabled.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer.with_validators(&[StringValidator::EmailLocalPart]), + value, + ), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl EmailFolder { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.aliases; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Aliases)); + } + } + errors.len() == neb + } +} + +impl Pickle for EmailFolder { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.create.pickle(out); + self.subscribe.pickle(out); + self.aliases.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.create = Pickle::unpickle(stream)?; + this.subscribe = Pickle::unpickle(stream)?; + this.aliases = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for EmailFolder { + fn default() -> Self { + Self { + name: Default::default(), + create: true, + subscribe: true, + aliases: Default::default(), + } + } +} + +impl IntoValue for EmailFolder { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Create, self.create.into_value()); + map.insert_unchecked(Property::Subscribe, self.subscribe.into_value()); + map.insert_unchecked(Property::Aliases, self.aliases.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for EmailFolder { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self + .name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Create) => self.create.patch(pointer, value), + Some(Property::Subscribe) => self.subscribe.patch(pointer, value), + Some(Property::Aliases) => self + .aliases + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl EncryptionAtRest { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + EncryptionAtRest::Disabled => true, + EncryptionAtRest::Aes128(inner) => inner.validate(errors), + EncryptionAtRest::Aes256(inner) => inner.validate(errors), + EncryptionAtRest::Aes256Gcm(inner) => inner.validate(errors), + EncryptionAtRest::ChaCha20Poly1305(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + EncryptionAtRest::Disabled => {} + EncryptionAtRest::Aes128(object) => { + object.index(i); + } + EncryptionAtRest::Aes256(object) => { + object.index(i); + } + EncryptionAtRest::Aes256Gcm(object) => { + object.index(i); + } + EncryptionAtRest::ChaCha20Poly1305(object) => { + object.index(i); + } + } + } +} + +impl Default for EncryptionAtRest { + fn default() -> Self { + EncryptionAtRest::Disabled + } +} + +impl Pickle for EncryptionAtRest { + fn pickle(&self, out: &mut Vec) { + match self { + EncryptionAtRest::Disabled => { + 0u16.pickle(out); + } + EncryptionAtRest::Aes128(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + EncryptionAtRest::Aes256(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + EncryptionAtRest::Aes256Gcm(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + EncryptionAtRest::ChaCha20Poly1305(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(EncryptionAtRest::Disabled), + 1 => Pickle::unpickle(stream).map(EncryptionAtRest::Aes128), + 2 => Pickle::unpickle(stream).map(EncryptionAtRest::Aes256), + 3 => Pickle::unpickle(stream).map(EncryptionAtRest::Aes256Gcm), + 4 => Pickle::unpickle(stream).map(EncryptionAtRest::ChaCha20Poly1305), + _ => None, + } + } +} + +impl IntoValue for EncryptionAtRest { + fn into_value(self) -> JmapValue<'static> { + match self { + EncryptionAtRest::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + EncryptionAtRest::Aes128(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Aes128".into())); + obj + } + EncryptionAtRest::Aes256(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Aes256".into())); + obj + } + EncryptionAtRest::Aes256Gcm(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Aes256Gcm".into())); + obj + } + EncryptionAtRest::ChaCha20Poly1305(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ChaCha20Poly1305".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for EncryptionAtRest { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + EncryptionAtRestType::Disabled => *self = EncryptionAtRest::Disabled, + EncryptionAtRestType::Aes128 => { + *self = EncryptionAtRest::Aes128(Default::default()) + } + EncryptionAtRestType::Aes256 => { + *self = EncryptionAtRest::Aes256(Default::default()) + } + EncryptionAtRestType::Aes256Gcm => { + *self = EncryptionAtRest::Aes256Gcm(Default::default()) + } + EncryptionAtRestType::ChaCha20Poly1305 => { + *self = EncryptionAtRest::ChaCha20Poly1305(Default::default()) + } + } + } + match self { + EncryptionAtRest::Disabled => pointer.assert_eof(), + EncryptionAtRest::Aes128(inner) => inner.patch(pointer, value), + EncryptionAtRest::Aes256(inner) => inner.patch(pointer, value), + EncryptionAtRest::Aes256Gcm(inner) => inner.patch(pointer, value), + EncryptionAtRest::ChaCha20Poly1305(inner) => inner.patch(pointer, value), + } + } +} + +impl EncryptionAtRest { + pub fn object_type(&self) -> EncryptionAtRestType { + match self { + EncryptionAtRest::Disabled => EncryptionAtRestType::Disabled, + EncryptionAtRest::Aes128(_) => EncryptionAtRestType::Aes128, + EncryptionAtRest::Aes256(_) => EncryptionAtRestType::Aes256, + EncryptionAtRest::Aes256Gcm(_) => EncryptionAtRestType::Aes256Gcm, + EncryptionAtRest::ChaCha20Poly1305(_) => EncryptionAtRestType::ChaCha20Poly1305, + } + } +} + +impl EncryptionSettings { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.public_key; + if !value.is_valid() { + errors.push(ValidationError::required(Property::PublicKey)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::PublicKey, self.public_key.into(), None); + } +} + +impl Pickle for EncryptionSettings { + fn pickle(&self, out: &mut Vec) { + self.public_key.pickle(out); + self.encrypt_on_append.pickle(out); + self.allow_spam_training.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.public_key = Pickle::unpickle(stream)?; + this.encrypt_on_append = Pickle::unpickle(stream)?; + this.allow_spam_training = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for EncryptionSettings { + fn default() -> Self { + Self { + public_key: Default::default(), + encrypt_on_append: false, + allow_spam_training: false, + } + } +} + +impl IntoValue for EncryptionSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::PublicKey, self.public_key.into_value()); + map.insert_unchecked( + Property::EncryptOnAppend, + self.encrypt_on_append.into_value(), + ); + map.insert_unchecked( + Property::AllowSpamTraining, + self.allow_spam_training.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for EncryptionSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::PublicKey) => self.public_key.patch(pointer, value), + Some(Property::EncryptOnAppend) => self.encrypt_on_append.patch(pointer, value), + Some(Property::AllowSpamTraining) => self.allow_spam_training.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Enterprise { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Enterprise; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + value.validate(errors); + let value = &self.license_key; + value.validate(errors); + if let Some(value) = &self.logo_url { + if value.is_empty() { + errors.push(ValidationError::required(Property::LogoUrl)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Enterprise { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + self.license_key.pickle(out); + self.logo_url.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + this.license_key = Pickle::unpickle(stream)?; + this.logo_url = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Enterprise { + fn default() -> Self { + Self { + api_key: Default::default(), + license_key: Default::default(), + logo_url: Default::default(), + } + } +} + +impl IntoValue for Enterprise { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + map.insert_unchecked(Property::LicenseKey, self.license_key.into_value()); + map.insert_unchecked(Property::LogoUrl, self.logo_url.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Enterprise { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::LicenseKey) => self.license_key.patch(pointer, value), + Some(Property::LogoUrl) => self + .logo_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for EventTracingLevel { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::EventTracingLevel; + + fn validate(&self, _: &mut Vec) -> bool { + true + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Event, &self.event); + } +} + +impl Pickle for EventTracingLevel { + fn pickle(&self, out: &mut Vec) { + self.event.pickle(out); + self.level.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.event = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for EventTracingLevel { + fn default() -> Self { + Self { + event: Default::default(), + level: TracingLevelOpt::Info, + } + } +} + +impl IntoValue for EventTracingLevel { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Event, self.event.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for EventTracingLevel { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Event) => self.event.patch(pointer.assert_read_only()?, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Expression { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.match_; + for value in value.values() { + value.validate(errors); + } + let value = &self.else_; + if value.is_empty() { + errors.push(ValidationError::required(Property::Else)); + } + errors.len() == neb + } +} + +impl Pickle for Expression { + fn pickle(&self, out: &mut Vec) { + self.match_.pickle(out); + self.else_.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.match_ = Pickle::unpickle(stream)?; + this.else_ = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Expression { + fn default() -> Self { + Self { + match_: Default::default(), + else_: Default::default(), + } + } +} + +impl IntoValue for Expression { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Match, self.match_.into_value()); + map.insert_unchecked(Property::Else, self.else_.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Expression { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Match) => self.match_.patch(pointer, value), + Some(Property::Else) => self.else_.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ExpressionMatch { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.if_; + if value.is_empty() { + errors.push(ValidationError::required(Property::If)); + } + let value = &self.then; + if value.is_empty() { + errors.push(ValidationError::required(Property::Then)); + } + errors.len() == neb + } +} + +impl Pickle for ExpressionMatch { + fn pickle(&self, out: &mut Vec) { + self.if_.pickle(out); + self.then.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.if_ = Pickle::unpickle(stream)?; + this.then = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ExpressionMatch { + fn default() -> Self { + Self { + if_: Default::default(), + then: Default::default(), + } + } +} + +impl IntoValue for ExpressionMatch { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::If, self.if_.into_value()); + map.insert_unchecked(Property::Then, self.then.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ExpressionMatch { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::If) => self.if_.patch(pointer, value), + Some(Property::Then) => self.then.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for FileStorage { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::FileStorage; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.max_files { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxFiles, 1)); + } + } + if let Some(value) = &self.max_folders { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxFolders, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for FileStorage { + fn pickle(&self, out: &mut Vec) { + self.max_size.pickle(out); + self.max_files.pickle(out); + self.max_folders.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_size = Pickle::unpickle(stream)?; + this.max_files = Pickle::unpickle(stream)?; + this.max_folders = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for FileStorage { + fn default() -> Self { + Self { + max_size: 26214400, + max_files: Default::default(), + max_folders: Default::default(), + } + } +} + +impl IntoValue for FileStorage { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::MaxSize, self.max_size.into_value()); + map.insert_unchecked(Property::MaxFiles, self.max_files.into_value()); + map.insert_unchecked(Property::MaxFolders, self.max_folders.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for FileStorage { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxSize) => self.max_size.patch(pointer, value), + Some(Property::MaxFiles) => self.max_files.patch(pointer, value), + Some(Property::MaxFolders) => self.max_folders.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl FileSystemStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.path; + if value.is_empty() { + errors.push(ValidationError::required(Property::Path)); + } + let value = &self.depth; + if *value > 5 { + errors.push(ValidationError::max_value(Property::Depth, 5)); + } + errors.len() == neb + } +} + +impl Pickle for FileSystemStore { + fn pickle(&self, out: &mut Vec) { + self.path.pickle(out); + self.depth.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.path = Pickle::unpickle(stream)?; + this.depth = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for FileSystemStore { + fn default() -> Self { + Self { + path: Default::default(), + depth: 2u64, + } + } +} + +impl IntoValue for FileSystemStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Path, self.path.into_value()); + map.insert_unchecked(Property::Depth, self.depth.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for FileSystemStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Path) => self + .path + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Depth) => self.depth.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl FoundationDbStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.cluster_file { + if value.is_empty() { + errors.push(ValidationError::required(Property::ClusterFile)); + } + } + if let Some(value) = &self.datacenter_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::DatacenterId)); + } + } + if let Some(value) = &self.machine_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::MachineId)); + } + } + if let Some(value) = &self.transaction_retry_limit { + if *value > 1000 { + errors.push(ValidationError::max_value( + Property::TransactionRetryLimit, + 1000, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value( + Property::TransactionRetryLimit, + 1, + )); + } + } + errors.len() == neb + } +} + +impl Pickle for FoundationDbStore { + fn pickle(&self, out: &mut Vec) { + self.cluster_file.pickle(out); + self.datacenter_id.pickle(out); + self.machine_id.pickle(out); + self.transaction_retry_delay.pickle(out); + self.transaction_retry_limit.pickle(out); + self.transaction_timeout.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.cluster_file = Pickle::unpickle(stream)?; + this.datacenter_id = Pickle::unpickle(stream)?; + this.machine_id = Pickle::unpickle(stream)?; + this.transaction_retry_delay = Pickle::unpickle(stream)?; + this.transaction_retry_limit = Pickle::unpickle(stream)?; + this.transaction_timeout = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for FoundationDbStore { + fn default() -> Self { + Self { + cluster_file: Default::default(), + datacenter_id: Default::default(), + machine_id: Default::default(), + transaction_retry_delay: Default::default(), + transaction_retry_limit: Default::default(), + transaction_timeout: Default::default(), + } + } +} + +impl IntoValue for FoundationDbStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::ClusterFile, self.cluster_file.into_value()); + map.insert_unchecked(Property::DatacenterId, self.datacenter_id.into_value()); + map.insert_unchecked(Property::MachineId, self.machine_id.into_value()); + map.insert_unchecked( + Property::TransactionRetryDelay, + self.transaction_retry_delay.into_value(), + ); + map.insert_unchecked( + Property::TransactionRetryLimit, + self.transaction_retry_limit.into_value(), + ); + map.insert_unchecked( + Property::TransactionTimeout, + self.transaction_timeout.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for FoundationDbStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ClusterFile) => self + .cluster_file + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DatacenterId) => self + .datacenter_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MachineId) => self + .machine_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::TransactionRetryDelay) => { + self.transaction_retry_delay.patch(pointer, value) + } + Some(Property::TransactionRetryLimit) => { + self.transaction_retry_limit.patch(pointer, value) + } + Some(Property::TransactionTimeout) => self.transaction_timeout.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl FtrlParameters { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.alpha; + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::Alpha, 0)); + } + let value = &self.beta; + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::Beta, 0)); + } + let value = &self.l1_ratio; + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::L1Ratio, 0)); + } + let value = &self.l2_ratio; + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::L2Ratio, 0)); + } + errors.len() == neb + } +} + +impl Pickle for FtrlParameters { + fn pickle(&self, out: &mut Vec) { + self.alpha.pickle(out); + self.beta.pickle(out); + self.num_features.pickle(out); + self.l1_ratio.pickle(out); + self.l2_ratio.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.alpha = Pickle::unpickle(stream)?; + this.beta = Pickle::unpickle(stream)?; + this.num_features = Pickle::unpickle(stream)?; + this.l1_ratio = Pickle::unpickle(stream)?; + this.l2_ratio = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for FtrlParameters { + fn default() -> Self { + Self { + alpha: Float::new(2.0f64), + beta: Float::new(1.0f64), + num_features: ModelSize::V20, + l1_ratio: Float::new(0.001f64), + l2_ratio: Float::new(0.0001f64), + } + } +} + +impl IntoValue for FtrlParameters { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Alpha, self.alpha.into_value()); + map.insert_unchecked(Property::Beta, self.beta.into_value()); + map.insert_unchecked(Property::NumFeatures, self.num_features.into_value()); + map.insert_unchecked(Property::L1Ratio, self.l1_ratio.into_value()); + map.insert_unchecked(Property::L2Ratio, self.l2_ratio.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for FtrlParameters { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Alpha) => self.alpha.patch(pointer, value), + Some(Property::Beta) => self.beta.patch(pointer, value), + Some(Property::NumFeatures) => self.num_features.patch(pointer, value), + Some(Property::L1Ratio) => self.l1_ratio.patch(pointer, value), + Some(Property::L2Ratio) => self.l2_ratio.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl GroupAccount { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.roles; + value.validate(errors); + let value = &self.permissions; + value.validate(errors); + let value = &self.aliases; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.external_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::ExternalId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Email, &self.name, &self.domain_id); + i.text(Property::Text, &self.name); + i.search(Property::Name, &self.name); + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + i.search(Property::DomainId, &self.domain_id); + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + self.roles.index(i); + for item in self.aliases.values() { + item.index(i); + } + if let Some(value) = &self.external_id { + i.search(Property::ExternalId, value); + } + } +} + +impl Pickle for GroupAccount { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.domain_id.pickle(out); + self.description.pickle(out); + self.created_at.pickle(out); + self.member_tenant_id.pickle(out); + self.roles.pickle(out); + self.quotas.pickle(out); + self.permissions.pickle(out); + self.aliases.pickle(out); + self.locale.pickle(out); + self.time_zone.pickle(out); + self.external_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.roles = Pickle::unpickle(stream)?; + this.quotas = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.aliases = Pickle::unpickle(stream)?; + this.locale = Pickle::unpickle(stream)?; + this.time_zone = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.external_id = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for GroupAccount { + fn default() -> Self { + Self { + name: Default::default(), + domain_id: Default::default(), + description: Default::default(), + created_at: Default::default(), + member_tenant_id: Default::default(), + roles: Default::default(), + quotas: Default::default(), + permissions: Default::default(), + aliases: Default::default(), + locale: Locale::EnUS, + time_zone: Default::default(), + external_id: Default::default(), + } + } +} + +impl IntoValue for GroupAccount { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Roles, self.roles.into_value()); + map.insert_unchecked(Property::Quotas, self.quotas.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::Aliases, self.aliases.into_value()); + map.insert_unchecked(Property::Locale, self.locale.into_value()); + map.insert_unchecked(Property::TimeZone, self.time_zone.into_value()); + map.insert_unchecked(Property::ExternalId, self.external_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for GroupAccount { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch( + pointer.with_validators(&[StringValidator::EmailLocalPart]), + value, + ), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::EmailAddress) => pointer.assert_server_set(), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Roles) => self.roles.patch(pointer, value), + Some(Property::Quotas) => self.quotas.patch(pointer, value), + Some(Property::UsedDiskQuota) => pointer.assert_server_set(), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::Aliases) => self.aliases.patch(pointer, value), + Some(Property::Locale) => self.locale.patch(pointer, value), + Some(Property::TimeZone) => self.time_zone.patch(pointer, value), + Some(Property::ExternalId) => self.external_id.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Http { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Http; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.rate_limit_authenticated { + value.validate(errors); + } + if let Some(value) = &self.rate_limit_anonymous { + value.validate(errors); + } + let value = &self.allowed_endpoints; + value.validate(errors); + let value = &self.response_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseHeaders)); + } + } + if let Some(value) = &self.redirect_root { + if value.is_empty() { + errors.push(ValidationError::required(Property::RedirectRoot)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Http { + pub fn ctx_allowed_endpoints(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.allowed_endpoints, + default: Some(Expression { + else_: "200".to_string(), + ..Default::default() + }), + property: Property::AllowedEndpoints, + allowed_variables: HTTP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_allowed_endpoints()] + } +} + +impl Pickle for Http { + fn pickle(&self, out: &mut Vec) { + self.rate_limit_authenticated.pickle(out); + self.rate_limit_anonymous.pickle(out); + self.allowed_endpoints.pickle(out); + self.enable_hsts.pickle(out); + self.use_permissive_cors.pickle(out); + self.response_headers.pickle(out); + self.use_x_forwarded.pickle(out); + self.redirect_root.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.rate_limit_authenticated = Pickle::unpickle(stream)?; + this.rate_limit_anonymous = Pickle::unpickle(stream)?; + this.allowed_endpoints = Pickle::unpickle(stream)?; + this.enable_hsts = Pickle::unpickle(stream)?; + this.use_permissive_cors = Pickle::unpickle(stream)?; + this.response_headers = Pickle::unpickle(stream)?; + this.use_x_forwarded = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.redirect_root = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for Http { + fn default() -> Self { + Self { + rate_limit_authenticated: Some(Rate { + count: 1000u64, + period: Duration::from_millis(60000), + }), + rate_limit_anonymous: Some(Rate { + count: 100u64, + period: Duration::from_millis(60000), + }), + allowed_endpoints: Expression { + else_: "200".to_string(), + ..Default::default() + }, + enable_hsts: false, + use_permissive_cors: false, + response_headers: Default::default(), + use_x_forwarded: false, + redirect_root: Some("/account".to_string()), + } + } +} + +impl IntoValue for Http { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked( + Property::RateLimitAuthenticated, + self.rate_limit_authenticated.into_value(), + ); + map.insert_unchecked( + Property::RateLimitAnonymous, + self.rate_limit_anonymous.into_value(), + ); + map.insert_unchecked( + Property::AllowedEndpoints, + self.allowed_endpoints.into_value(), + ); + map.insert_unchecked(Property::EnableHsts, self.enable_hsts.into_value()); + map.insert_unchecked( + Property::UsePermissiveCors, + self.use_permissive_cors.into_value(), + ); + map.insert_unchecked( + Property::ResponseHeaders, + self.response_headers.into_value(), + ); + map.insert_unchecked(Property::UseXForwarded, self.use_x_forwarded.into_value()); + map.insert_unchecked(Property::RedirectRoot, self.redirect_root.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Http { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::RateLimitAuthenticated) => { + self.rate_limit_authenticated.patch(pointer, value) + } + Some(Property::RateLimitAnonymous) => self.rate_limit_anonymous.patch(pointer, value), + Some(Property::AllowedEndpoints) => self.allowed_endpoints.patch(pointer, value), + Some(Property::EnableHsts) => self.enable_hsts.patch(pointer, value), + Some(Property::UsePermissiveCors) => self.use_permissive_cors.patch(pointer, value), + Some(Property::ResponseHeaders) => self + .response_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::UseXForwarded) => self.use_x_forwarded.patch(pointer, value), + Some(Property::RedirectRoot) => self + .redirect_root + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl HttpAuth { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + HttpAuth::Unauthenticated => true, + HttpAuth::Basic(inner) => inner.validate(errors), + HttpAuth::Bearer(inner) => inner.validate(errors), + } + } +} + +impl Default for HttpAuth { + fn default() -> Self { + HttpAuth::Unauthenticated + } +} + +impl Pickle for HttpAuth { + fn pickle(&self, out: &mut Vec) { + match self { + HttpAuth::Unauthenticated => { + 0u16.pickle(out); + } + HttpAuth::Basic(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + HttpAuth::Bearer(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(HttpAuth::Unauthenticated), + 1 => Pickle::unpickle(stream).map(HttpAuth::Basic), + 2 => Pickle::unpickle(stream).map(HttpAuth::Bearer), + _ => None, + } + } +} + +impl IntoValue for HttpAuth { + fn into_value(self) -> JmapValue<'static> { + match self { + HttpAuth::Unauthenticated => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Unauthenticated".into())); + JmapValue::Object(obj) + } + HttpAuth::Basic(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Basic".into())); + obj + } + HttpAuth::Bearer(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Bearer".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for HttpAuth { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + HttpAuthType::Unauthenticated => *self = HttpAuth::Unauthenticated, + HttpAuthType::Basic => *self = HttpAuth::Basic(Default::default()), + HttpAuthType::Bearer => *self = HttpAuth::Bearer(Default::default()), + } + } + match self { + HttpAuth::Unauthenticated => pointer.assert_eof(), + HttpAuth::Basic(inner) => inner.patch(pointer, value), + HttpAuth::Bearer(inner) => inner.patch(pointer, value), + } + } +} + +impl HttpAuth { + pub fn object_type(&self) -> HttpAuthType { + match self { + HttpAuth::Unauthenticated => HttpAuthType::Unauthenticated, + HttpAuth::Basic(_) => HttpAuthType::Basic, + HttpAuth::Bearer(_) => HttpAuthType::Bearer, + } + } +} + +impl HttpAuthBasic { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.secret; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for HttpAuthBasic { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HttpAuthBasic { + fn default() -> Self { + Self { + username: Default::default(), + secret: Default::default(), + } + } +} + +impl IntoValue for HttpAuthBasic { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HttpAuthBasic { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl HttpAuthBearer { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.bearer_token; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for HttpAuthBearer { + fn pickle(&self, out: &mut Vec) { + self.bearer_token.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.bearer_token = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HttpAuthBearer { + fn default() -> Self { + Self { + bearer_token: Default::default(), + } + } +} + +impl IntoValue for HttpAuthBearer { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::BearerToken, self.bearer_token.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HttpAuthBearer { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BearerToken) => self.bearer_token.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for HttpForm { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::HttpForm; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.deliver_to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::DeliverTo)); + } + } + let value = &self.default_from_address; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultFromAddress)); + } + if let Some(value) = &self.field_email { + if value.is_empty() { + errors.push(ValidationError::required(Property::FieldEmail)); + } + } + if let Some(value) = &self.field_honey_pot { + if value.is_empty() { + errors.push(ValidationError::required(Property::FieldHoneyPot)); + } + } + let value = &self.default_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultName)); + } + if let Some(value) = &self.field_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::FieldName)); + } + } + if let Some(value) = &self.rate_limit { + value.validate(errors); + } + let value = &self.default_subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultSubject)); + } + if let Some(value) = &self.field_subject { + if value.is_empty() { + errors.push(ValidationError::required(Property::FieldSubject)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for HttpForm { + fn pickle(&self, out: &mut Vec) { + self.deliver_to.pickle(out); + self.default_from_address.pickle(out); + self.field_email.pickle(out); + self.enable.pickle(out); + self.field_honey_pot.pickle(out); + self.max_size.pickle(out); + self.default_name.pickle(out); + self.field_name.pickle(out); + self.rate_limit.pickle(out); + self.default_subject.pickle(out); + self.field_subject.pickle(out); + self.validate_domain.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.deliver_to = Pickle::unpickle(stream)?; + this.default_from_address = Pickle::unpickle(stream)?; + this.field_email = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.field_honey_pot = Pickle::unpickle(stream)?; + this.max_size = Pickle::unpickle(stream)?; + this.default_name = Pickle::unpickle(stream)?; + this.field_name = Pickle::unpickle(stream)?; + this.rate_limit = Pickle::unpickle(stream)?; + this.default_subject = Pickle::unpickle(stream)?; + this.field_subject = Pickle::unpickle(stream)?; + this.validate_domain = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HttpForm { + fn default() -> Self { + Self { + deliver_to: Default::default(), + default_from_address: "postmaster@localhost".to_string(), + field_email: Default::default(), + enable: false, + field_honey_pot: Default::default(), + max_size: 102400, + default_name: "Anonymous".to_string(), + field_name: Default::default(), + rate_limit: Some(Rate { + count: 5u64, + period: Duration::from_millis(3600000), + }), + default_subject: "Contact form submission".to_string(), + field_subject: Default::default(), + validate_domain: true, + } + } +} + +impl IntoValue for HttpForm { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::DeliverTo, self.deliver_to.into_value()); + map.insert_unchecked( + Property::DefaultFromAddress, + self.default_from_address.into_value(), + ); + map.insert_unchecked(Property::FieldEmail, self.field_email.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::FieldHoneyPot, self.field_honey_pot.into_value()); + map.insert_unchecked(Property::MaxSize, self.max_size.into_value()); + map.insert_unchecked(Property::DefaultName, self.default_name.into_value()); + map.insert_unchecked(Property::FieldName, self.field_name.into_value()); + map.insert_unchecked(Property::RateLimit, self.rate_limit.into_value()); + map.insert_unchecked(Property::DefaultSubject, self.default_subject.into_value()); + map.insert_unchecked(Property::FieldSubject, self.field_subject.into_value()); + map.insert_unchecked(Property::ValidateDomain, self.validate_domain.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HttpForm { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DeliverTo) => self + .deliver_to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::DefaultFromAddress) => self + .default_from_address + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::FieldEmail) => self + .field_email + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::FieldHoneyPot) => self + .field_honey_pot + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MaxSize) => self.max_size.patch(pointer, value), + Some(Property::DefaultName) => self + .default_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::FieldName) => self + .field_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::RateLimit) => self.rate_limit.patch(pointer, value), + Some(Property::DefaultSubject) => self + .default_subject + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::FieldSubject) => self + .field_subject + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ValidateDomain) => self.validate_domain.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for HttpLookup { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::HttpLookup; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.namespace; + if value.is_empty() { + errors.push(ValidationError::required(Property::Namespace)); + } + let value = &self.format; + value.validate(errors); + let value = &self.max_entries; + if *value > 1048576 { + errors.push(ValidationError::max_value(Property::MaxEntries, 1048576)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxEntries, 1)); + } + let value = &self.max_entry_size; + if *value > 1048576 { + errors.push(ValidationError::max_value(Property::MaxEntrySize, 1048576)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxEntrySize, 1)); + } + let value = &self.max_size; + if *value > 1073741824 { + errors.push(ValidationError::max_value(Property::MaxSize, 1073741824)); + } + if *value < 10 { + errors.push(ValidationError::min_value(Property::MaxSize, 10)); + } + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global(Property::Namespace, &self.namespace); + } +} + +impl Pickle for HttpLookup { + fn pickle(&self, out: &mut Vec) { + self.namespace.pickle(out); + self.enable.pickle(out); + self.format.pickle(out); + self.is_gzipped.pickle(out); + self.max_entries.pickle(out); + self.max_entry_size.pickle(out); + self.max_size.pickle(out); + self.refresh.pickle(out); + self.retry.pickle(out); + self.timeout.pickle(out); + self.url.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.namespace = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.format = Pickle::unpickle(stream)?; + this.is_gzipped = Pickle::unpickle(stream)?; + this.max_entries = Pickle::unpickle(stream)?; + this.max_entry_size = Pickle::unpickle(stream)?; + this.max_size = Pickle::unpickle(stream)?; + this.refresh = Pickle::unpickle(stream)?; + this.retry = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HttpLookup { + fn default() -> Self { + Self { + namespace: Default::default(), + enable: true, + format: Default::default(), + is_gzipped: false, + max_entries: 100000u64, + max_entry_size: 512u64, + max_size: 104857600, + refresh: Duration::from_millis(43200000), + retry: Duration::from_millis(3600000), + timeout: Duration::from_millis(30000), + url: Default::default(), + } + } +} + +impl IntoValue for HttpLookup { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::Namespace, self.namespace.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Format, self.format.into_value()); + map.insert_unchecked(Property::IsGzipped, self.is_gzipped.into_value()); + map.insert_unchecked(Property::MaxEntries, self.max_entries.into_value()); + map.insert_unchecked(Property::MaxEntrySize, self.max_entry_size.into_value()); + map.insert_unchecked(Property::MaxSize, self.max_size.into_value()); + map.insert_unchecked(Property::Refresh, self.refresh.into_value()); + map.insert_unchecked(Property::Retry, self.retry.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HttpLookup { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Namespace) => self.namespace.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Trim]), + value, + ), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Format) => self.format.patch(pointer, value), + Some(Property::IsGzipped) => self.is_gzipped.patch(pointer, value), + Some(Property::MaxEntries) => self.max_entries.patch(pointer, value), + Some(Property::MaxEntrySize) => self.max_entry_size.patch(pointer, value), + Some(Property::MaxSize) => self.max_size.patch(pointer, value), + Some(Property::Refresh) => self.refresh.patch(pointer, value), + Some(Property::Retry) => self.retry.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl HttpLookupCsv { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.separator; + if value.is_empty() { + errors.push(ValidationError::required(Property::Separator)); + } + errors.len() == neb + } +} + +impl Pickle for HttpLookupCsv { + fn pickle(&self, out: &mut Vec) { + self.index_key.pickle(out); + self.index_value.pickle(out); + self.separator.pickle(out); + self.skip_first.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.index_key = Pickle::unpickle(stream)?; + this.index_value = Pickle::unpickle(stream)?; + this.separator = Pickle::unpickle(stream)?; + this.skip_first = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HttpLookupCsv { + fn default() -> Self { + Self { + index_key: 0u64, + index_value: Default::default(), + separator: ",".to_string(), + skip_first: false, + } + } +} + +impl IntoValue for HttpLookupCsv { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::IndexKey, self.index_key.into_value()); + map.insert_unchecked(Property::IndexValue, self.index_value.into_value()); + map.insert_unchecked(Property::Separator, self.separator.into_value()); + map.insert_unchecked(Property::SkipFirst, self.skip_first.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HttpLookupCsv { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IndexKey) => self.index_key.patch(pointer, value), + Some(Property::IndexValue) => self.index_value.patch(pointer, value), + Some(Property::Separator) => self + .separator + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SkipFirst) => self.skip_first.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl HttpLookupFormat { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + HttpLookupFormat::Csv(inner) => inner.validate(errors), + HttpLookupFormat::List => true, + } + } +} + +impl Default for HttpLookupFormat { + fn default() -> Self { + HttpLookupFormat::Csv(Default::default()) + } +} + +impl Pickle for HttpLookupFormat { + fn pickle(&self, out: &mut Vec) { + match self { + HttpLookupFormat::Csv(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + HttpLookupFormat::List => { + 1u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(HttpLookupFormat::Csv), + 1 => Some(HttpLookupFormat::List), + _ => None, + } + } +} + +impl IntoValue for HttpLookupFormat { + fn into_value(self) -> JmapValue<'static> { + match self { + HttpLookupFormat::Csv(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Csv".into())); + obj + } + HttpLookupFormat::List => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("List".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for HttpLookupFormat { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + HttpLookupFormatType::Csv => *self = HttpLookupFormat::Csv(Default::default()), + HttpLookupFormatType::List => *self = HttpLookupFormat::List, + } + } + match self { + HttpLookupFormat::Csv(inner) => inner.patch(pointer, value), + HttpLookupFormat::List => pointer.assert_eof(), + } + } +} + +impl HttpLookupFormat { + pub fn object_type(&self) -> HttpLookupFormatType { + match self { + HttpLookupFormat::Csv(_) => HttpLookupFormatType::Csv, + HttpLookupFormat::List => HttpLookupFormatType::List, + } + } +} + +impl HurricaneCredential { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.zone; + if value.is_empty() { + errors.push(ValidationError::required(Property::Zone)); + } + let value = &self.secret; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for HurricaneCredential { + fn pickle(&self, out: &mut Vec) { + self.zone.pickle(out); + self.secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.zone = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for HurricaneCredential { + fn default() -> Self { + Self { + zone: Default::default(), + secret: Default::default(), + } + } +} + +impl IntoValue for HurricaneCredential { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Secret, self.secret.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for HurricaneCredential { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Zone) => self.zone.patch( + pointer.with_validators(&[StringValidator::Domain, StringValidator::Trim]), + value, + ), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Imap { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::Imap; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_auth_failures; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAuthFailures, 1)); + } + if let Some(value) = &self.max_concurrent { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxConcurrent, 1)); + } + } + if let Some(value) = &self.max_request_rate { + value.validate(errors); + } + let value = &self.max_messages_per_command; + if *value < 1000 { + errors.push(ValidationError::min_value( + Property::MaxMessagesPerCommand, + 1000, + )); + } + let value = &self.min_uid_batch_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MinUidBatchSize, 1)); + } + if *value > 500 { + errors.push(ValidationError::max_value(Property::MinUidBatchSize, 500)); + } + let value = &self.max_uid_batches; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxUidBatches, 1)); + } + let value = &self.max_messages_per_save; + if *value < 1000 { + errors.push(ValidationError::min_value( + Property::MaxMessagesPerSave, + 1000, + )); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Imap { + fn pickle(&self, out: &mut Vec) { + self.allow_plain_text_auth.pickle(out); + self.max_auth_failures.pickle(out); + self.max_concurrent.pickle(out); + self.max_request_rate.pickle(out); + self.max_request_size.pickle(out); + self.timeout_anonymous.pickle(out); + self.timeout_authenticated.pickle(out); + self.timeout_idle.pickle(out); + self.max_messages_per_command.pickle(out); + self.min_uid_batch_size.pickle(out); + self.max_uid_batches.pickle(out); + self.max_messages_per_save.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_plain_text_auth = Pickle::unpickle(stream)?; + this.max_auth_failures = Pickle::unpickle(stream)?; + this.max_concurrent = Pickle::unpickle(stream)?; + this.max_request_rate = Pickle::unpickle(stream)?; + this.max_request_size = Pickle::unpickle(stream)?; + this.timeout_anonymous = Pickle::unpickle(stream)?; + this.timeout_authenticated = Pickle::unpickle(stream)?; + this.timeout_idle = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.max_messages_per_command = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.min_uid_batch_size = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.max_uid_batches = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.max_messages_per_save = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for Imap { + fn default() -> Self { + Self { + allow_plain_text_auth: false, + max_auth_failures: 3u64, + max_concurrent: Some(16u64), + max_request_rate: Some(Rate { + count: 2000u64, + period: Duration::from_millis(60000), + }), + max_request_size: 52428800, + timeout_anonymous: Duration::from_millis(60000), + timeout_authenticated: Duration::from_millis(1800000), + timeout_idle: Duration::from_millis(1800000), + max_messages_per_command: 1000000u64, + min_uid_batch_size: 500u64, + max_uid_batches: 10000u64, + max_messages_per_save: 1000000u64, + } + } +} + +impl IntoValue for Imap { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked( + Property::AllowPlainTextAuth, + self.allow_plain_text_auth.into_value(), + ); + map.insert_unchecked( + Property::MaxAuthFailures, + self.max_auth_failures.into_value(), + ); + map.insert_unchecked(Property::MaxConcurrent, self.max_concurrent.into_value()); + map.insert_unchecked(Property::MaxRequestRate, self.max_request_rate.into_value()); + map.insert_unchecked(Property::MaxRequestSize, self.max_request_size.into_value()); + map.insert_unchecked( + Property::TimeoutAnonymous, + self.timeout_anonymous.into_value(), + ); + map.insert_unchecked( + Property::TimeoutAuthenticated, + self.timeout_authenticated.into_value(), + ); + map.insert_unchecked(Property::TimeoutIdle, self.timeout_idle.into_value()); + map.insert_unchecked( + Property::MaxMessagesPerCommand, + self.max_messages_per_command.into_value(), + ); + map.insert_unchecked( + Property::MinUidBatchSize, + self.min_uid_batch_size.into_value(), + ); + map.insert_unchecked(Property::MaxUidBatches, self.max_uid_batches.into_value()); + map.insert_unchecked( + Property::MaxMessagesPerSave, + self.max_messages_per_save.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Imap { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowPlainTextAuth) => self.allow_plain_text_auth.patch(pointer, value), + Some(Property::MaxAuthFailures) => self.max_auth_failures.patch(pointer, value), + Some(Property::MaxConcurrent) => self.max_concurrent.patch(pointer, value), + Some(Property::MaxRequestRate) => self.max_request_rate.patch(pointer, value), + Some(Property::MaxRequestSize) => self.max_request_size.patch(pointer, value), + Some(Property::TimeoutAnonymous) => self.timeout_anonymous.patch(pointer, value), + Some(Property::TimeoutAuthenticated) => { + self.timeout_authenticated.patch(pointer, value) + } + Some(Property::TimeoutIdle) => self.timeout_idle.patch(pointer, value), + Some(Property::MaxMessagesPerCommand) => { + self.max_messages_per_command.patch(pointer, value) + } + Some(Property::MinUidBatchSize) => self.min_uid_batch_size.patch(pointer, value), + Some(Property::MaxUidBatches) => self.max_uid_batches.patch(pointer, value), + Some(Property::MaxMessagesPerSave) => self.max_messages_per_save.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for InMemoryStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::InMemoryStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + InMemoryStore::Default => true, + InMemoryStore::Sharded(inner) => inner.validate(errors), + InMemoryStore::Redis(inner) => inner.validate(errors), + InMemoryStore::RedisCluster(inner) => inner.validate(errors), + InMemoryStore::RedisSentinel(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for InMemoryStore { + fn default() -> Self { + InMemoryStore::Default + } +} + +impl Pickle for InMemoryStore { + fn pickle(&self, out: &mut Vec) { + match self { + InMemoryStore::Default => { + 0u16.pickle(out); + } + InMemoryStore::Sharded(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + InMemoryStore::Redis(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + InMemoryStore::RedisCluster(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + InMemoryStore::RedisSentinel(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(InMemoryStore::Default), + 1 => Pickle::unpickle(stream).map(InMemoryStore::Sharded), + 2 => Pickle::unpickle(stream).map(InMemoryStore::Redis), + 3 => Pickle::unpickle(stream).map(InMemoryStore::RedisCluster), + 4 => Pickle::unpickle(stream).map(InMemoryStore::RedisSentinel), + _ => None, + } + } +} + +impl IntoValue for InMemoryStore { + fn into_value(self) -> JmapValue<'static> { + match self { + InMemoryStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + InMemoryStore::Sharded(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sharded".into())); + obj + } + InMemoryStore::Redis(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Redis".into())); + obj + } + InMemoryStore::RedisCluster(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisCluster".into())); + obj + } + InMemoryStore::RedisSentinel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisSentinel".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for InMemoryStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + InMemoryStoreType::Default => *self = InMemoryStore::Default, + InMemoryStoreType::Sharded => *self = InMemoryStore::Sharded(Default::default()), + InMemoryStoreType::Redis => *self = InMemoryStore::Redis(Default::default()), + InMemoryStoreType::RedisCluster => { + *self = InMemoryStore::RedisCluster(Default::default()) + } + InMemoryStoreType::RedisSentinel => { + *self = InMemoryStore::RedisSentinel(Default::default()) + } + } + } + match self { + InMemoryStore::Default => pointer.assert_eof(), + InMemoryStore::Sharded(inner) => inner.patch(pointer, value), + InMemoryStore::Redis(inner) => inner.patch(pointer, value), + InMemoryStore::RedisCluster(inner) => inner.patch(pointer, value), + InMemoryStore::RedisSentinel(inner) => inner.patch(pointer, value), + } + } +} + +impl InMemoryStore { + pub fn object_type(&self) -> InMemoryStoreType { + match self { + InMemoryStore::Default => InMemoryStoreType::Default, + InMemoryStore::Sharded(_) => InMemoryStoreType::Sharded, + InMemoryStore::Redis(_) => InMemoryStoreType::Redis, + InMemoryStore::RedisCluster(_) => InMemoryStoreType::RedisCluster, + InMemoryStore::RedisSentinel(_) => InMemoryStoreType::RedisSentinel, + } + } +} + +impl InMemoryStoreBase { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + InMemoryStoreBase::Redis(inner) => inner.validate(errors), + InMemoryStoreBase::RedisCluster(inner) => inner.validate(errors), + InMemoryStoreBase::RedisSentinel(inner) => inner.validate(errors), + } + } +} + +impl Default for InMemoryStoreBase { + fn default() -> Self { + InMemoryStoreBase::Redis(Default::default()) + } +} + +impl Pickle for InMemoryStoreBase { + fn pickle(&self, out: &mut Vec) { + match self { + InMemoryStoreBase::Redis(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + InMemoryStoreBase::RedisCluster(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + InMemoryStoreBase::RedisSentinel(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(InMemoryStoreBase::Redis), + 1 => Pickle::unpickle(stream).map(InMemoryStoreBase::RedisCluster), + 2 => Pickle::unpickle(stream).map(InMemoryStoreBase::RedisSentinel), + _ => None, + } + } +} + +impl IntoValue for InMemoryStoreBase { + fn into_value(self) -> JmapValue<'static> { + match self { + InMemoryStoreBase::Redis(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Redis".into())); + obj + } + InMemoryStoreBase::RedisCluster(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisCluster".into())); + obj + } + InMemoryStoreBase::RedisSentinel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisSentinel".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for InMemoryStoreBase { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + InMemoryStoreBaseType::Redis => { + *self = InMemoryStoreBase::Redis(Default::default()) + } + InMemoryStoreBaseType::RedisCluster => { + *self = InMemoryStoreBase::RedisCluster(Default::default()) + } + InMemoryStoreBaseType::RedisSentinel => { + *self = InMemoryStoreBase::RedisSentinel(Default::default()) + } + } + } + match self { + InMemoryStoreBase::Redis(inner) => inner.patch(pointer, value), + InMemoryStoreBase::RedisCluster(inner) => inner.patch(pointer, value), + InMemoryStoreBase::RedisSentinel(inner) => inner.patch(pointer, value), + } + } +} + +impl InMemoryStoreBase { + pub fn object_type(&self) -> InMemoryStoreBaseType { + match self { + InMemoryStoreBase::Redis(_) => InMemoryStoreBaseType::Redis, + InMemoryStoreBase::RedisCluster(_) => InMemoryStoreBaseType::RedisCluster, + InMemoryStoreBase::RedisSentinel(_) => InMemoryStoreBaseType::RedisSentinel, + } + } +} + +impl ObjectImpl for Jmap { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 2; + const OBJECT: ObjectType = ObjectType::Jmap; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.parse_limit_event; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ParseLimitEvent, 1)); + } + let value = &self.parse_limit_contact; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ParseLimitContact, 1)); + } + let value = &self.parse_limit_email; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ParseLimitEmail, 1)); + } + let value = &self.changes_max_results; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ChangesMaxResults, 1)); + } + let value = &self.get_max_results; + if *value < 1 { + errors.push(ValidationError::min_value(Property::GetMaxResults, 1)); + } + let value = &self.query_max_results; + if *value < 1 { + errors.push(ValidationError::min_value(Property::QueryMaxResults, 1)); + } + let value = &self.max_method_calls; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMethodCalls, 1)); + } + if let Some(value) = &self.max_concurrent_requests { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::MaxConcurrentRequests, + 1, + )); + } + } + let value = &self.max_request_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRequestSize, 1)); + } + let value = &self.set_max_objects; + if *value < 1 { + errors.push(ValidationError::min_value(Property::SetMaxObjects, 1)); + } + let value = &self.snippet_max_results; + if *value < 1 { + errors.push(ValidationError::min_value(Property::SnippetMaxResults, 1)); + } + if let Some(value) = &self.max_concurrent_uploads { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::MaxConcurrentUploads, + 1, + )); + } + } + let value = &self.max_upload_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxUploadSize, 1)); + } + let value = &self.max_upload_count; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxUploadCount, 1)); + } + let value = &self.upload_quota; + if *value < 1 { + errors.push(ValidationError::min_value(Property::UploadQuota, 1)); + } + let value = &self.upload_ttl; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::UploadTtl, value)); + } + if *value < Duration::from_millis(1000) { + errors.push(ValidationError::min_value(Property::UploadTtl, 1000)); + } + let value = &self.push_max_attempts; + if *value < 1 { + errors.push(ValidationError::min_value(Property::PushMaxAttempts, 1)); + } + let value = &self.push_shards_total; + if *value < 1 { + errors.push(ValidationError::min_value(Property::PushShardsTotal, 1)); + } + if let Some(value) = &self.max_subscriptions { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxSubscriptions, 1)); + } + } + let value = &self.web_push_key; + value.validate(errors); + if let Some(value) = &self.web_push_contact { + if value.is_empty() { + errors.push(ValidationError::required(Property::WebPushContact)); + } + } + let value = &self.max_push_size; + if *value < 512 { + errors.push(ValidationError::min_value(Property::MaxPushSize, 512)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Jmap { + fn pickle(&self, out: &mut Vec) { + self.parse_limit_event.pickle(out); + self.parse_limit_contact.pickle(out); + self.parse_limit_email.pickle(out); + self.changes_max_results.pickle(out); + self.get_max_results.pickle(out); + self.query_max_results.pickle(out); + self.max_method_calls.pickle(out); + self.max_concurrent_requests.pickle(out); + self.max_request_size.pickle(out); + self.set_max_objects.pickle(out); + self.snippet_max_results.pickle(out); + self.max_concurrent_uploads.pickle(out); + self.max_upload_size.pickle(out); + self.max_upload_count.pickle(out); + self.upload_quota.pickle(out); + self.upload_ttl.pickle(out); + self.event_source_throttle.pickle(out); + self.push_attempt_wait.pickle(out); + self.push_max_attempts.pickle(out); + self.push_retry_wait.pickle(out); + self.push_throttle.pickle(out); + self.push_request_timeout.pickle(out); + self.push_verify_timeout.pickle(out); + self.push_shards_total.pickle(out); + self.websocket_heartbeat.pickle(out); + self.websocket_throttle.pickle(out); + self.websocket_timeout.pickle(out); + self.max_subscriptions.pickle(out); + self.web_push_key.pickle(out); + self.web_push_contact.pickle(out); + self.max_push_size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.parse_limit_event = Pickle::unpickle(stream)?; + this.parse_limit_contact = Pickle::unpickle(stream)?; + this.parse_limit_email = Pickle::unpickle(stream)?; + this.changes_max_results = Pickle::unpickle(stream)?; + this.get_max_results = Pickle::unpickle(stream)?; + this.query_max_results = Pickle::unpickle(stream)?; + this.max_method_calls = Pickle::unpickle(stream)?; + this.max_concurrent_requests = Pickle::unpickle(stream)?; + this.max_request_size = Pickle::unpickle(stream)?; + this.set_max_objects = Pickle::unpickle(stream)?; + this.snippet_max_results = Pickle::unpickle(stream)?; + this.max_concurrent_uploads = Pickle::unpickle(stream)?; + this.max_upload_size = Pickle::unpickle(stream)?; + this.max_upload_count = Pickle::unpickle(stream)?; + this.upload_quota = Pickle::unpickle(stream)?; + this.upload_ttl = Pickle::unpickle(stream)?; + this.event_source_throttle = Pickle::unpickle(stream)?; + this.push_attempt_wait = Pickle::unpickle(stream)?; + this.push_max_attempts = Pickle::unpickle(stream)?; + this.push_retry_wait = Pickle::unpickle(stream)?; + this.push_throttle = Pickle::unpickle(stream)?; + this.push_request_timeout = Pickle::unpickle(stream)?; + this.push_verify_timeout = Pickle::unpickle(stream)?; + this.push_shards_total = Pickle::unpickle(stream)?; + this.websocket_heartbeat = Pickle::unpickle(stream)?; + this.websocket_throttle = Pickle::unpickle(stream)?; + this.websocket_timeout = Pickle::unpickle(stream)?; + this.max_subscriptions = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.web_push_key = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.web_push_contact = Pickle::unpickle(stream)?; + } + if stream.version() >= 2 { + this.max_push_size = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for Jmap { + fn default() -> Self { + Self { + parse_limit_event: 10u64, + parse_limit_contact: 10u64, + parse_limit_email: 10u64, + changes_max_results: 5000u64, + get_max_results: 500u64, + query_max_results: 5000u64, + max_method_calls: 16u64, + max_concurrent_requests: Some(4u64), + max_request_size: 10000000u64, + set_max_objects: 500u64, + snippet_max_results: 100u64, + max_concurrent_uploads: Some(4u64), + max_upload_size: 50000000u64, + max_upload_count: 1000u64, + upload_quota: 50000000u64, + upload_ttl: Duration::from_millis(3600000), + event_source_throttle: Duration::from_millis(1000), + push_attempt_wait: Duration::from_millis(60000), + push_max_attempts: 3u64, + push_retry_wait: Duration::from_millis(1000), + push_throttle: Duration::from_millis(1000), + push_request_timeout: Duration::from_millis(10000), + push_verify_timeout: Duration::from_millis(60000), + push_shards_total: 1u64, + websocket_heartbeat: Duration::from_millis(60000), + websocket_throttle: Duration::from_millis(1000), + websocket_timeout: Duration::from_millis(600000), + max_subscriptions: Some(15u64), + web_push_key: Default::default(), + web_push_contact: Default::default(), + max_push_size: 4096u64, + } + } +} + +impl IntoValue for Jmap { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(33); + map.insert_unchecked( + Property::ParseLimitEvent, + self.parse_limit_event.into_value(), + ); + map.insert_unchecked( + Property::ParseLimitContact, + self.parse_limit_contact.into_value(), + ); + map.insert_unchecked( + Property::ParseLimitEmail, + self.parse_limit_email.into_value(), + ); + map.insert_unchecked( + Property::ChangesMaxResults, + self.changes_max_results.into_value(), + ); + map.insert_unchecked(Property::GetMaxResults, self.get_max_results.into_value()); + map.insert_unchecked( + Property::QueryMaxResults, + self.query_max_results.into_value(), + ); + map.insert_unchecked(Property::MaxMethodCalls, self.max_method_calls.into_value()); + map.insert_unchecked( + Property::MaxConcurrentRequests, + self.max_concurrent_requests.into_value(), + ); + map.insert_unchecked(Property::MaxRequestSize, self.max_request_size.into_value()); + map.insert_unchecked(Property::SetMaxObjects, self.set_max_objects.into_value()); + map.insert_unchecked( + Property::SnippetMaxResults, + self.snippet_max_results.into_value(), + ); + map.insert_unchecked( + Property::MaxConcurrentUploads, + self.max_concurrent_uploads.into_value(), + ); + map.insert_unchecked(Property::MaxUploadSize, self.max_upload_size.into_value()); + map.insert_unchecked(Property::MaxUploadCount, self.max_upload_count.into_value()); + map.insert_unchecked(Property::UploadQuota, self.upload_quota.into_value()); + map.insert_unchecked(Property::UploadTtl, self.upload_ttl.into_value()); + map.insert_unchecked( + Property::EventSourceThrottle, + self.event_source_throttle.into_value(), + ); + map.insert_unchecked( + Property::PushAttemptWait, + self.push_attempt_wait.into_value(), + ); + map.insert_unchecked( + Property::PushMaxAttempts, + self.push_max_attempts.into_value(), + ); + map.insert_unchecked(Property::PushRetryWait, self.push_retry_wait.into_value()); + map.insert_unchecked(Property::PushThrottle, self.push_throttle.into_value()); + map.insert_unchecked( + Property::PushRequestTimeout, + self.push_request_timeout.into_value(), + ); + map.insert_unchecked( + Property::PushVerifyTimeout, + self.push_verify_timeout.into_value(), + ); + map.insert_unchecked( + Property::PushShardsTotal, + self.push_shards_total.into_value(), + ); + map.insert_unchecked( + Property::WebsocketHeartbeat, + self.websocket_heartbeat.into_value(), + ); + map.insert_unchecked( + Property::WebsocketThrottle, + self.websocket_throttle.into_value(), + ); + map.insert_unchecked( + Property::WebsocketTimeout, + self.websocket_timeout.into_value(), + ); + map.insert_unchecked( + Property::MaxSubscriptions, + self.max_subscriptions.into_value(), + ); + map.insert_unchecked(Property::WebPushKey, self.web_push_key.into_value()); + map.insert_unchecked(Property::WebPushContact, self.web_push_contact.into_value()); + map.insert_unchecked(Property::MaxPushSize, self.max_push_size.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Jmap { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ParseLimitEvent) => self.parse_limit_event.patch(pointer, value), + Some(Property::ParseLimitContact) => self.parse_limit_contact.patch(pointer, value), + Some(Property::ParseLimitEmail) => self.parse_limit_email.patch(pointer, value), + Some(Property::ChangesMaxResults) => self.changes_max_results.patch(pointer, value), + Some(Property::GetMaxResults) => self.get_max_results.patch(pointer, value), + Some(Property::QueryMaxResults) => self.query_max_results.patch(pointer, value), + Some(Property::MaxMethodCalls) => self.max_method_calls.patch(pointer, value), + Some(Property::MaxConcurrentRequests) => { + self.max_concurrent_requests.patch(pointer, value) + } + Some(Property::MaxRequestSize) => self.max_request_size.patch(pointer, value), + Some(Property::SetMaxObjects) => self.set_max_objects.patch(pointer, value), + Some(Property::SnippetMaxResults) => self.snippet_max_results.patch(pointer, value), + Some(Property::MaxConcurrentUploads) => { + self.max_concurrent_uploads.patch(pointer, value) + } + Some(Property::MaxUploadSize) => self.max_upload_size.patch(pointer, value), + Some(Property::MaxUploadCount) => self.max_upload_count.patch(pointer, value), + Some(Property::UploadQuota) => self.upload_quota.patch(pointer, value), + Some(Property::UploadTtl) => self.upload_ttl.patch(pointer, value), + Some(Property::EventSourceThrottle) => self.event_source_throttle.patch(pointer, value), + Some(Property::PushAttemptWait) => self.push_attempt_wait.patch(pointer, value), + Some(Property::PushMaxAttempts) => self.push_max_attempts.patch(pointer, value), + Some(Property::PushRetryWait) => self.push_retry_wait.patch(pointer, value), + Some(Property::PushThrottle) => self.push_throttle.patch(pointer, value), + Some(Property::PushRequestTimeout) => self.push_request_timeout.patch(pointer, value), + Some(Property::PushVerifyTimeout) => self.push_verify_timeout.patch(pointer, value), + Some(Property::PushShardsTotal) => self.push_shards_total.patch(pointer, value), + Some(Property::WebsocketHeartbeat) => self.websocket_heartbeat.patch(pointer, value), + Some(Property::WebsocketThrottle) => self.websocket_throttle.patch(pointer, value), + Some(Property::WebsocketTimeout) => self.websocket_timeout.patch(pointer, value), + Some(Property::MaxSubscriptions) => self.max_subscriptions.patch(pointer, value), + Some(Property::WebPushKey) => self.web_push_key.patch(pointer, value), + Some(Property::WebPushContact) => self + .web_push_contact + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MaxPushSize) => self.max_push_size.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl JokerAuth { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + JokerAuth::ApiKey(inner) => inner.validate(errors), + JokerAuth::UsernamePassword(inner) => inner.validate(errors), + } + } +} + +impl Default for JokerAuth { + fn default() -> Self { + JokerAuth::ApiKey(Default::default()) + } +} + +impl Pickle for JokerAuth { + fn pickle(&self, out: &mut Vec) { + match self { + JokerAuth::ApiKey(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + JokerAuth::UsernamePassword(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(JokerAuth::ApiKey), + 1 => Pickle::unpickle(stream).map(JokerAuth::UsernamePassword), + _ => None, + } + } +} + +impl IntoValue for JokerAuth { + fn into_value(self) -> JmapValue<'static> { + match self { + JokerAuth::ApiKey(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ApiKey".into())); + obj + } + JokerAuth::UsernamePassword(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UsernamePassword".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for JokerAuth { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + JokerAuthType::ApiKey => *self = JokerAuth::ApiKey(Default::default()), + JokerAuthType::UsernamePassword => { + *self = JokerAuth::UsernamePassword(Default::default()) + } + } + } + match self { + JokerAuth::ApiKey(inner) => inner.patch(pointer, value), + JokerAuth::UsernamePassword(inner) => inner.patch(pointer, value), + } + } +} + +impl JokerAuth { + pub fn object_type(&self) -> JokerAuthType { + match self { + JokerAuth::ApiKey(_) => JokerAuthType::ApiKey, + JokerAuth::UsernamePassword(_) => JokerAuthType::UsernamePassword, + } + } +} + +impl JokerAuthApiKey { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.api_key; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for JokerAuthApiKey { + fn pickle(&self, out: &mut Vec) { + self.api_key.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.api_key = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for JokerAuthApiKey { + fn default() -> Self { + Self { + api_key: Default::default(), + } + } +} + +impl IntoValue for JokerAuthApiKey { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::ApiKey, self.api_key.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for JokerAuthApiKey { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ApiKey) => self.api_key.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl JokerAuthUsernamePassword { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.username; + if value.is_empty() { + errors.push(ValidationError::required(Property::Username)); + } + let value = &self.password; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for JokerAuthUsernamePassword { + fn pickle(&self, out: &mut Vec) { + self.username.pickle(out); + self.password.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.username = Pickle::unpickle(stream)?; + this.password = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for JokerAuthUsernamePassword { + fn default() -> Self { + Self { + username: Default::default(), + password: Default::default(), + } + } +} + +impl IntoValue for JokerAuthUsernamePassword { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Username, self.username.into_value()); + map.insert_unchecked(Property::Password, self.password.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for JokerAuthUsernamePassword { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Username) => self + .username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Password) => self.password.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl KafkaCoordinator { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.brokers; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Brokers)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Brokers, 1)); + } + let value = &self.group_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::GroupId)); + } + errors.len() == neb + } +} + +impl Pickle for KafkaCoordinator { + fn pickle(&self, out: &mut Vec) { + self.brokers.pickle(out); + self.group_id.pickle(out); + self.timeout_message.pickle(out); + self.timeout_session.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.brokers = Pickle::unpickle(stream)?; + this.group_id = Pickle::unpickle(stream)?; + this.timeout_message = Pickle::unpickle(stream)?; + this.timeout_session = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for KafkaCoordinator { + fn default() -> Self { + Self { + brokers: Default::default(), + group_id: Default::default(), + timeout_message: Duration::from_millis(5000), + timeout_session: Duration::from_millis(5000), + } + } +} + +impl IntoValue for KafkaCoordinator { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Brokers, self.brokers.into_value()); + map.insert_unchecked(Property::GroupId, self.group_id.into_value()); + map.insert_unchecked(Property::TimeoutMessage, self.timeout_message.into_value()); + map.insert_unchecked(Property::TimeoutSession, self.timeout_session.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for KafkaCoordinator { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Brokers) => self.brokers.patch(pointer, value), + Some(Property::GroupId) => self + .group_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::TimeoutMessage) => self.timeout_message.patch(pointer, value), + Some(Property::TimeoutSession) => self.timeout_session.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl LdapDirectory { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.base_dn; + if value.is_empty() { + errors.push(ValidationError::required(Property::BaseDn)); + } + if let Some(value) = &self.bind_dn { + if value.is_empty() { + errors.push(ValidationError::required(Property::BindDn)); + } + } + let value = &self.bind_secret; + value.validate(errors); + let value = &self.filter_login; + if value.is_empty() { + errors.push(ValidationError::required(Property::FilterLogin)); + } + let value = &self.filter_mailbox; + if value.is_empty() { + errors.push(ValidationError::required(Property::FilterMailbox)); + } + if let Some(value) = &self.filter_member_of { + if value.is_empty() { + errors.push(ValidationError::required(Property::FilterMemberOf)); + } + } + let value = &self.attr_class; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrClass)); + } + } + let value = &self.attr_description; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrDescription)); + } + } + let value = &self.attr_email; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrEmail)); + } + } + let value = &self.attr_email_alias; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrEmailAlias)); + } + } + let value = &self.attr_member_of; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrMemberOf)); + } + } + let value = &self.attr_secret; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrSecret)); + } + } + let value = &self.attr_secret_changed; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AttrSecretChanged)); + } + } + let value = &self.group_class; + if value.is_empty() { + errors.push(ValidationError::required(Property::GroupClass)); + } + let value = &self.pool_max_connections; + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for LdapDirectory { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.url.pickle(out); + self.timeout.pickle(out); + self.allow_invalid_certs.pickle(out); + self.use_tls.pickle(out); + self.base_dn.pickle(out); + self.bind_dn.pickle(out); + self.bind_secret.pickle(out); + self.bind_authentication.pickle(out); + self.filter_login.pickle(out); + self.filter_mailbox.pickle(out); + self.filter_member_of.pickle(out); + self.attr_class.pickle(out); + self.attr_description.pickle(out); + self.attr_email.pickle(out); + self.attr_email_alias.pickle(out); + self.attr_member_of.pickle(out); + self.attr_secret.pickle(out); + self.attr_secret_changed.pickle(out); + self.group_class.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_timeout_create.pickle(out); + self.pool_timeout_recycle.pickle(out); + self.pool_timeout_wait.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.base_dn = Pickle::unpickle(stream)?; + this.bind_dn = Pickle::unpickle(stream)?; + this.bind_secret = Pickle::unpickle(stream)?; + this.bind_authentication = Pickle::unpickle(stream)?; + this.filter_login = Pickle::unpickle(stream)?; + this.filter_mailbox = Pickle::unpickle(stream)?; + this.filter_member_of = Pickle::unpickle(stream)?; + this.attr_class = Pickle::unpickle(stream)?; + this.attr_description = Pickle::unpickle(stream)?; + this.attr_email = Pickle::unpickle(stream)?; + this.attr_email_alias = Pickle::unpickle(stream)?; + this.attr_member_of = Pickle::unpickle(stream)?; + this.attr_secret = Pickle::unpickle(stream)?; + this.attr_secret_changed = Pickle::unpickle(stream)?; + this.group_class = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_timeout_create = Pickle::unpickle(stream)?; + this.pool_timeout_recycle = Pickle::unpickle(stream)?; + this.pool_timeout_wait = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for LdapDirectory { + fn default() -> Self { + Self { + description: Default::default(), + url: "ldap://localhost:389".to_string(), + timeout: Duration::from_millis(30000), + allow_invalid_certs: false, + use_tls: false, + base_dn: Default::default(), + bind_dn: Default::default(), + bind_secret: Default::default(), + bind_authentication: true, + filter_login: "(&(objectClass=inetOrgPerson)(mail=?))".to_string(), + filter_mailbox: "(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))".to_string(), + filter_member_of: Some("(&(objectClass=groupOfNames)(member=?))".to_string()), + attr_class: Map::new(vec!["objectClass".to_string()]), + attr_description: Map::new(vec!["description".to_string()]), + attr_email: Map::new(vec!["mail".to_string()]), + attr_email_alias: Map::new(vec!["mailAlias".to_string()]), + attr_member_of: Map::new(vec!["memberOf".to_string()]), + attr_secret: Map::new(vec!["userPassword".to_string()]), + attr_secret_changed: Map::new(vec!["pwdChangeTime".to_string()]), + group_class: "groupOfNames".to_string(), + pool_max_connections: 10u64, + pool_timeout_create: Duration::from_millis(30000), + pool_timeout_recycle: Duration::from_millis(30000), + pool_timeout_wait: Duration::from_millis(30000), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for LdapDirectory { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(27); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked(Property::BaseDn, self.base_dn.into_value()); + map.insert_unchecked(Property::BindDn, self.bind_dn.into_value()); + map.insert_unchecked(Property::BindSecret, self.bind_secret.into_value()); + map.insert_unchecked( + Property::BindAuthentication, + self.bind_authentication.into_value(), + ); + map.insert_unchecked(Property::FilterLogin, self.filter_login.into_value()); + map.insert_unchecked(Property::FilterMailbox, self.filter_mailbox.into_value()); + map.insert_unchecked(Property::FilterMemberOf, self.filter_member_of.into_value()); + map.insert_unchecked(Property::AttrClass, self.attr_class.into_value()); + map.insert_unchecked( + Property::AttrDescription, + self.attr_description.into_value(), + ); + map.insert_unchecked(Property::AttrEmail, self.attr_email.into_value()); + map.insert_unchecked(Property::AttrEmailAlias, self.attr_email_alias.into_value()); + map.insert_unchecked(Property::AttrMemberOf, self.attr_member_of.into_value()); + map.insert_unchecked(Property::AttrSecret, self.attr_secret.into_value()); + map.insert_unchecked( + Property::AttrSecretChanged, + self.attr_secret_changed.into_value(), + ); + map.insert_unchecked(Property::GroupClass, self.group_class.into_value()); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutCreate, + self.pool_timeout_create.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutRecycle, + self.pool_timeout_recycle.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutWait, + self.pool_timeout_wait.into_value(), + ); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for LdapDirectory { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::BaseDn) => self + .base_dn + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::BindDn) => self + .bind_dn + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::BindSecret) => self.bind_secret.patch(pointer, value), + Some(Property::BindAuthentication) => self.bind_authentication.patch(pointer, value), + Some(Property::FilterLogin) => self + .filter_login + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::FilterMailbox) => self + .filter_mailbox + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::FilterMemberOf) => self + .filter_member_of + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrClass) => self + .attr_class + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrDescription) => self + .attr_description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrEmail) => self + .attr_email + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrEmailAlias) => self + .attr_email_alias + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrMemberOf) => self + .attr_member_of + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrSecret) => self + .attr_secret + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AttrSecretChanged) => self + .attr_secret_changed + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::GroupClass) => self + .group_class + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolTimeoutCreate) => self.pool_timeout_create.patch(pointer, value), + Some(Property::PoolTimeoutRecycle) => self.pool_timeout_recycle.patch(pointer, value), + Some(Property::PoolTimeoutWait) => self.pool_timeout_wait.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Log { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Log; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.timestamp; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Timestamp, value)); + } + let value = &self.details; + if value.is_empty() { + errors.push(ValidationError::required(Property::Details)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Log { + fn pickle(&self, out: &mut Vec) { + self.timestamp.pickle(out); + self.level.pickle(out); + self.event.pickle(out); + self.details.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.timestamp = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.event = Pickle::unpickle(stream)?; + this.details = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Log { + fn default() -> Self { + Self { + timestamp: Default::default(), + level: Default::default(), + event: Default::default(), + details: Default::default(), + } + } +} + +impl IntoValue for Log { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Timestamp, self.timestamp.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Event, self.event.into_value()); + map.insert_unchecked(Property::Details, self.details.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Log { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Timestamp) => self.timestamp.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Event) => self.event.patch(pointer, value), + Some(Property::Details) => self.details.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl LookupStore { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + LookupStore::PostgreSql(inner) => inner.validate(errors), + LookupStore::MySql(inner) => inner.validate(errors), + LookupStore::Sqlite(inner) => inner.validate(errors), + LookupStore::Sharded(inner) => inner.validate(errors), + LookupStore::Redis(inner) => inner.validate(errors), + LookupStore::RedisCluster(inner) => inner.validate(errors), + LookupStore::RedisSentinel(inner) => inner.validate(errors), + } + } +} + +impl Default for LookupStore { + fn default() -> Self { + LookupStore::PostgreSql(Default::default()) + } +} + +impl Pickle for LookupStore { + fn pickle(&self, out: &mut Vec) { + match self { + LookupStore::PostgreSql(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + LookupStore::MySql(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + LookupStore::Sqlite(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + LookupStore::Sharded(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + LookupStore::Redis(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + LookupStore::RedisCluster(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + LookupStore::RedisSentinel(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(LookupStore::PostgreSql), + 1 => Pickle::unpickle(stream).map(LookupStore::MySql), + 2 => Pickle::unpickle(stream).map(LookupStore::Sqlite), + 3 => Pickle::unpickle(stream).map(LookupStore::Sharded), + 4 => Pickle::unpickle(stream).map(LookupStore::Redis), + 5 => Pickle::unpickle(stream).map(LookupStore::RedisCluster), + 6 => Pickle::unpickle(stream).map(LookupStore::RedisSentinel), + _ => None, + } + } +} + +impl IntoValue for LookupStore { + fn into_value(self) -> JmapValue<'static> { + match self { + LookupStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + LookupStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + LookupStore::Sqlite(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sqlite".into())); + obj + } + LookupStore::Sharded(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sharded".into())); + obj + } + LookupStore::Redis(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Redis".into())); + obj + } + LookupStore::RedisCluster(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisCluster".into())); + obj + } + LookupStore::RedisSentinel(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RedisSentinel".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for LookupStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + LookupStoreType::PostgreSql => *self = LookupStore::PostgreSql(Default::default()), + LookupStoreType::MySql => *self = LookupStore::MySql(Default::default()), + LookupStoreType::Sqlite => *self = LookupStore::Sqlite(Default::default()), + LookupStoreType::Sharded => *self = LookupStore::Sharded(Default::default()), + LookupStoreType::Redis => *self = LookupStore::Redis(Default::default()), + LookupStoreType::RedisCluster => { + *self = LookupStore::RedisCluster(Default::default()) + } + LookupStoreType::RedisSentinel => { + *self = LookupStore::RedisSentinel(Default::default()) + } + } + } + match self { + LookupStore::PostgreSql(inner) => inner.patch(pointer, value), + LookupStore::MySql(inner) => inner.patch(pointer, value), + LookupStore::Sqlite(inner) => inner.patch(pointer, value), + LookupStore::Sharded(inner) => inner.patch(pointer, value), + LookupStore::Redis(inner) => inner.patch(pointer, value), + LookupStore::RedisCluster(inner) => inner.patch(pointer, value), + LookupStore::RedisSentinel(inner) => inner.patch(pointer, value), + } + } +} + +impl LookupStore { + pub fn object_type(&self) -> LookupStoreType { + match self { + LookupStore::PostgreSql(_) => LookupStoreType::PostgreSql, + LookupStore::MySql(_) => LookupStoreType::MySql, + LookupStore::Sqlite(_) => LookupStoreType::Sqlite, + LookupStore::Sharded(_) => LookupStoreType::Sharded, + LookupStore::Redis(_) => LookupStoreType::Redis, + LookupStore::RedisCluster(_) => LookupStoreType::RedisCluster, + LookupStore::RedisSentinel(_) => LookupStoreType::RedisSentinel, + } + } +} + +impl MailExchanger { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::Hostname)); + } + } + let value = &self.priority; + if *value < 1 { + errors.push(ValidationError::min_value(Property::Priority, 1)); + } + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Priority, 65535)); + } + errors.len() == neb + } +} + +impl Pickle for MailExchanger { + fn pickle(&self, out: &mut Vec) { + self.hostname.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.hostname = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MailExchanger { + fn default() -> Self { + Self { + hostname: Default::default(), + priority: 10u64, + } + } +} + +impl IntoValue for MailExchanger { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Hostname, self.hostname.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MailExchanger { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Hostname) => self + .hostname + .patch(pointer.with_validators(&[StringValidator::Hostname]), value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MailingList { + const FLAGS: u64 = OBJ_FILTER_TENANT | OBJ_SEQ_ID; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MailingList; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.aliases; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.recipients; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Recipients)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Email, &self.name, &self.domain_id); + i.text(Property::Text, &self.name); + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + for item in self.aliases.values() { + item.index(i); + } + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + for value in self.recipients.iter() { + i.text(Property::Text, value); + } + } +} + +impl Pickle for MailingList { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.domain_id.pickle(out); + self.description.pickle(out); + self.aliases.pickle(out); + self.member_tenant_id.pickle(out); + self.recipients.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.aliases = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.recipients = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MailingList { + fn default() -> Self { + Self { + name: Default::default(), + domain_id: Default::default(), + description: Default::default(), + aliases: Default::default(), + member_tenant_id: Default::default(), + recipients: Default::default(), + } + } +} + +impl IntoValue for MailingList { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Aliases, self.aliases.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Recipients, self.recipients.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MailingList { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch( + pointer.with_validators(&[StringValidator::EmailLocalPart]), + value, + ), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::EmailAddress) => pointer.assert_server_set(), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Aliases) => self.aliases.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Recipients) => self + .recipients + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MaskedEmail { + const FLAGS: u64 = OBJ_FILTER_ACCOUNT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MaskedEmail; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.email; + if value.is_empty() { + errors.push(ValidationError::required(Property::Email)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + if let Some(value) = &self.for_domain { + if value.is_empty() { + errors.push(ValidationError::required(Property::ForDomain)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.created_by { + if value.is_empty() { + errors.push(ValidationError::required(Property::CreatedBy)); + } + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + if let Some(value) = &self.url { + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for MaskedEmail { + fn pickle(&self, out: &mut Vec) { + self.enabled.pickle(out); + self.account_id.pickle(out); + self.email.pickle(out); + self.description.pickle(out); + self.for_domain.pickle(out); + self.created_at.pickle(out); + self.created_by.pickle(out); + self.expires_at.pickle(out); + self.url.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enabled = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.email = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.for_domain = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.created_by = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MaskedEmail { + fn default() -> Self { + Self { + enabled: true, + account_id: Default::default(), + email: Default::default(), + description: Default::default(), + for_domain: Default::default(), + created_at: Default::default(), + created_by: Default::default(), + expires_at: Default::default(), + url: Default::default(), + } + } +} + +impl IntoValue for MaskedEmail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Enabled, self.enabled.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::Email, self.email.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::ForDomain, self.for_domain.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::CreatedBy, self.created_by.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MaskedEmail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enabled) => self.enabled.patch(pointer, value), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::Email) => pointer.assert_server_set(), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::ForDomain) => self.for_domain.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::CreatedBy) => self.created_by.patch(pointer, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer.assert_read_only()?, value), + Some(Property::Url) => self.url.patch(pointer, value), + Some(property @ Property::EmailPrefix) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(property @ Property::EmailDomain) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MeilisearchStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.max_retries; + if *value > 1024 { + errors.push(ValidationError::max_value(Property::MaxRetries, 1024)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRetries, 1)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for MeilisearchStore { + fn pickle(&self, out: &mut Vec) { + self.url.pickle(out); + self.poll_interval.pickle(out); + self.max_retries.pickle(out); + self.fail_on_timeout.pickle(out); + self.timeout.pickle(out); + self.allow_invalid_certs.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.url = Pickle::unpickle(stream)?; + this.poll_interval = Pickle::unpickle(stream)?; + this.max_retries = Pickle::unpickle(stream)?; + this.fail_on_timeout = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MeilisearchStore { + fn default() -> Self { + Self { + url: Default::default(), + poll_interval: Duration::from_millis(500), + max_retries: 120u64, + fail_on_timeout: true, + timeout: Duration::from_millis(30000), + allow_invalid_certs: false, + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for MeilisearchStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::PollInterval, self.poll_interval.into_value()); + map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value()); + map.insert_unchecked(Property::FailOnTimeout, self.fail_on_timeout.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MeilisearchStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PollInterval) => self.poll_interval.patch(pointer, value), + Some(Property::MaxRetries) => self.max_retries.patch(pointer, value), + Some(Property::FailOnTimeout) => self.fail_on_timeout.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MemoryLookupKey { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MemoryLookupKey; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.namespace; + if value.is_empty() { + errors.push(ValidationError::required(Property::Namespace)); + } + let value = &self.key; + if value.is_empty() { + errors.push(ValidationError::required(Property::Key)); + } + if value.len() > 255 { + errors.push(ValidationError::max_length(Property::Key, 255)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Namespace, &self.namespace, &self.key); + i.search(Property::Namespace, &self.namespace); + } +} + +impl Pickle for MemoryLookupKey { + fn pickle(&self, out: &mut Vec) { + self.namespace.pickle(out); + self.key.pickle(out); + self.is_glob_pattern.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.namespace = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.is_glob_pattern = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MemoryLookupKey { + fn default() -> Self { + Self { + namespace: Default::default(), + key: Default::default(), + is_glob_pattern: false, + } + } +} + +impl IntoValue for MemoryLookupKey { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Namespace, self.namespace.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::IsGlobPattern, self.is_glob_pattern.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MemoryLookupKey { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Namespace) => self.namespace.patch(pointer, value), + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::IsGlobPattern) => self.is_glob_pattern.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MemoryLookupKeyValue { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MemoryLookupKeyValue; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.namespace; + if value.is_empty() { + errors.push(ValidationError::required(Property::Namespace)); + } + let value = &self.key; + if value.is_empty() { + errors.push(ValidationError::required(Property::Key)); + } + if value.len() > 255 { + errors.push(ValidationError::max_length(Property::Key, 255)); + } + let value = &self.value; + if value.is_empty() { + errors.push(ValidationError::required(Property::Value)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Namespace, &self.namespace, &self.key); + i.search(Property::Namespace, &self.namespace); + } +} + +impl Pickle for MemoryLookupKeyValue { + fn pickle(&self, out: &mut Vec) { + self.namespace.pickle(out); + self.key.pickle(out); + self.value.pickle(out); + self.is_glob_pattern.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.namespace = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.value = Pickle::unpickle(stream)?; + this.is_glob_pattern = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MemoryLookupKeyValue { + fn default() -> Self { + Self { + namespace: Default::default(), + key: Default::default(), + value: Default::default(), + is_glob_pattern: false, + } + } +} + +impl IntoValue for MemoryLookupKeyValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Namespace, self.namespace.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Value, self.value.into_value()); + map.insert_unchecked(Property::IsGlobPattern, self.is_glob_pattern.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MemoryLookupKeyValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Namespace) => self.namespace.patch(pointer, value), + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::IsGlobPattern) => self.is_glob_pattern.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Metric { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Metric; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Metric::Counter(inner) => inner.validate(errors), + Metric::Gauge(inner) => inner.validate(errors), + Metric::Histogram(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for Metric { + fn default() -> Self { + Metric::Counter(Default::default()) + } +} + +impl Pickle for Metric { + fn pickle(&self, out: &mut Vec) { + match self { + Metric::Counter(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Metric::Gauge(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Metric::Histogram(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Metric::Counter), + 1 => Pickle::unpickle(stream).map(Metric::Gauge), + 2 => Pickle::unpickle(stream).map(Metric::Histogram), + _ => None, + } + } +} + +impl IntoValue for Metric { + fn into_value(self) -> JmapValue<'static> { + match self { + Metric::Counter(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Counter".into())); + obj + } + Metric::Gauge(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Gauge".into())); + obj + } + Metric::Histogram(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Histogram".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Metric { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MetricType::Counter => *self = Metric::Counter(Default::default()), + MetricType::Gauge => *self = Metric::Gauge(Default::default()), + MetricType::Histogram => *self = Metric::Histogram(Default::default()), + } + } + match self { + Metric::Counter(inner) => inner.patch(pointer, value), + Metric::Gauge(inner) => inner.patch(pointer, value), + Metric::Histogram(inner) => inner.patch(pointer, value), + } + } +} + +impl Metric { + pub fn object_type(&self) -> MetricType { + match self { + Metric::Counter(_) => MetricType::Counter, + Metric::Gauge(_) => MetricType::Gauge, + Metric::Histogram(_) => MetricType::Histogram, + } + } +} + +impl MetricCount { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for MetricCount { + fn pickle(&self, out: &mut Vec) { + self.count.pickle(out); + self.metric.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.count = Pickle::unpickle(stream)?; + this.metric = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MetricCount { + fn default() -> Self { + Self { + count: 0u64, + metric: Default::default(), + } + } +} + +impl IntoValue for MetricCount { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Count, self.count.into_value()); + map.insert_unchecked(Property::Metric, self.metric.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MetricCount { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Count) => self.count.patch(pointer, value), + Some(Property::Metric) => self.metric.patch(pointer, value), + Some(property @ Property::Timestamp) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MetricSum { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for MetricSum { + fn pickle(&self, out: &mut Vec) { + self.count.pickle(out); + self.sum.pickle(out); + self.metric.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.count = Pickle::unpickle(stream)?; + this.sum = Pickle::unpickle(stream)?; + this.metric = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MetricSum { + fn default() -> Self { + Self { + count: 0u64, + sum: 0u64, + metric: Default::default(), + } + } +} + +impl IntoValue for MetricSum { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Count, self.count.into_value()); + map.insert_unchecked(Property::Sum, self.sum.into_value()); + map.insert_unchecked(Property::Metric, self.metric.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MetricSum { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Count) => self.count.patch(pointer, value), + Some(Property::Sum) => self.sum.patch(pointer, value), + Some(Property::Metric) => self.metric.patch(pointer, value), + Some(property @ Property::Timestamp) => { + Ok(MaybeUnpatched::Unpatched { property, value }) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Metrics { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Metrics; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.open_telemetry; + value.validate(errors); + let value = &self.prometheus; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Metrics { + fn pickle(&self, out: &mut Vec) { + self.open_telemetry.pickle(out); + self.prometheus.pickle(out); + self.metrics.pickle(out); + self.metrics_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.open_telemetry = Pickle::unpickle(stream)?; + this.prometheus = Pickle::unpickle(stream)?; + this.metrics = Pickle::unpickle(stream)?; + this.metrics_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Metrics { + fn default() -> Self { + Self { + open_telemetry: Default::default(), + prometheus: Default::default(), + metrics: Default::default(), + metrics_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for Metrics { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::OpenTelemetry, self.open_telemetry.into_value()); + map.insert_unchecked(Property::Prometheus, self.prometheus.into_value()); + map.insert_unchecked(Property::Metrics, self.metrics.into_value()); + map.insert_unchecked(Property::MetricsPolicy, self.metrics_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Metrics { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::OpenTelemetry) => self.open_telemetry.patch(pointer, value), + Some(Property::Prometheus) => self.prometheus.patch(pointer, value), + Some(Property::Metrics) => self.metrics.patch(pointer, value), + Some(Property::MetricsPolicy) => self.metrics_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MetricsOtel { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MetricsOtel::Disabled => true, + MetricsOtel::Http(inner) => inner.validate(errors), + MetricsOtel::Grpc(inner) => inner.validate(errors), + } + } +} + +impl Default for MetricsOtel { + fn default() -> Self { + MetricsOtel::Disabled + } +} + +impl Pickle for MetricsOtel { + fn pickle(&self, out: &mut Vec) { + match self { + MetricsOtel::Disabled => { + 0u16.pickle(out); + } + MetricsOtel::Http(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + MetricsOtel::Grpc(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(MetricsOtel::Disabled), + 1 => Pickle::unpickle(stream).map(MetricsOtel::Http), + 2 => Pickle::unpickle(stream).map(MetricsOtel::Grpc), + _ => None, + } + } +} + +impl IntoValue for MetricsOtel { + fn into_value(self) -> JmapValue<'static> { + match self { + MetricsOtel::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + MetricsOtel::Http(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Http".into())); + obj + } + MetricsOtel::Grpc(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Grpc".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MetricsOtel { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MetricsOtelType::Disabled => *self = MetricsOtel::Disabled, + MetricsOtelType::Http => *self = MetricsOtel::Http(Default::default()), + MetricsOtelType::Grpc => *self = MetricsOtel::Grpc(Default::default()), + } + } + match self { + MetricsOtel::Disabled => pointer.assert_eof(), + MetricsOtel::Http(inner) => inner.patch(pointer, value), + MetricsOtel::Grpc(inner) => inner.patch(pointer, value), + } + } +} + +impl MetricsOtel { + pub fn object_type(&self) -> MetricsOtelType { + match self { + MetricsOtel::Disabled => MetricsOtelType::Disabled, + MetricsOtel::Http(_) => MetricsOtelType::Http, + MetricsOtel::Grpc(_) => MetricsOtelType::Grpc, + } + } +} + +impl MetricsOtelGrpc { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.endpoint { + if value.is_empty() { + errors.push(ValidationError::required(Property::Endpoint)); + } + } + errors.len() == neb + } +} + +impl Pickle for MetricsOtelGrpc { + fn pickle(&self, out: &mut Vec) { + self.endpoint.pickle(out); + self.interval.pickle(out); + self.timeout.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.endpoint = Pickle::unpickle(stream)?; + this.interval = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MetricsOtelGrpc { + fn default() -> Self { + Self { + endpoint: Default::default(), + interval: Duration::from_millis(60000), + timeout: Duration::from_millis(10000), + } + } +} + +impl IntoValue for MetricsOtelGrpc { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Endpoint, self.endpoint.into_value()); + map.insert_unchecked(Property::Interval, self.interval.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MetricsOtelGrpc { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Endpoint) => self + .endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Interval) => self.interval.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MetricsOtelHttp { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.endpoint; + if value.is_empty() { + errors.push(ValidationError::required(Property::Endpoint)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for MetricsOtelHttp { + fn pickle(&self, out: &mut Vec) { + self.endpoint.pickle(out); + self.interval.pickle(out); + self.timeout.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.endpoint = Pickle::unpickle(stream)?; + this.interval = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MetricsOtelHttp { + fn default() -> Self { + Self { + endpoint: Default::default(), + interval: Duration::from_millis(60000), + timeout: Duration::from_millis(10000), + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for MetricsOtelHttp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Endpoint, self.endpoint.into_value()); + map.insert_unchecked(Property::Interval, self.interval.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MetricsOtelHttp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Endpoint) => self + .endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Interval) => self.interval.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MetricsPrometheus { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MetricsPrometheus::Disabled => true, + MetricsPrometheus::Enabled(inner) => inner.validate(errors), + } + } +} + +impl Default for MetricsPrometheus { + fn default() -> Self { + MetricsPrometheus::Disabled + } +} + +impl Pickle for MetricsPrometheus { + fn pickle(&self, out: &mut Vec) { + match self { + MetricsPrometheus::Disabled => { + 0u16.pickle(out); + } + MetricsPrometheus::Enabled(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(MetricsPrometheus::Disabled), + 1 => Pickle::unpickle(stream).map(MetricsPrometheus::Enabled), + _ => None, + } + } +} + +impl IntoValue for MetricsPrometheus { + fn into_value(self) -> JmapValue<'static> { + match self { + MetricsPrometheus::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + MetricsPrometheus::Enabled(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Enabled".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MetricsPrometheus { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MetricsPrometheusType::Disabled => *self = MetricsPrometheus::Disabled, + MetricsPrometheusType::Enabled => { + *self = MetricsPrometheus::Enabled(Default::default()) + } + } + } + match self { + MetricsPrometheus::Disabled => pointer.assert_eof(), + MetricsPrometheus::Enabled(inner) => inner.patch(pointer, value), + } + } +} + +impl MetricsPrometheus { + pub fn object_type(&self) -> MetricsPrometheusType { + match self { + MetricsPrometheus::Disabled => MetricsPrometheusType::Disabled, + MetricsPrometheus::Enabled(_) => MetricsPrometheusType::Enabled, + } + } +} + +impl MetricsPrometheusProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + errors.len() == neb + } +} + +impl Pickle for MetricsPrometheusProperties { + fn pickle(&self, out: &mut Vec) { + self.auth_secret.pickle(out); + self.auth_username.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_secret = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MetricsPrometheusProperties { + fn default() -> Self { + Self { + auth_secret: Default::default(), + auth_username: Default::default(), + } + } +} + +impl IntoValue for MetricsPrometheusProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MetricsPrometheusProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MetricsStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MetricsStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MetricsStore::Disabled => true, + MetricsStore::Default => true, + MetricsStore::FoundationDb(inner) => inner.validate(errors), + MetricsStore::PostgreSql(inner) => inner.validate(errors), + MetricsStore::MySql(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for MetricsStore { + fn default() -> Self { + MetricsStore::Disabled + } +} + +impl Pickle for MetricsStore { + fn pickle(&self, out: &mut Vec) { + match self { + MetricsStore::Disabled => { + 0u16.pickle(out); + } + MetricsStore::Default => { + 1u16.pickle(out); + } + MetricsStore::FoundationDb(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + MetricsStore::PostgreSql(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + MetricsStore::MySql(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(MetricsStore::Disabled), + 1 => Some(MetricsStore::Default), + 2 => Pickle::unpickle(stream).map(MetricsStore::FoundationDb), + 3 => Pickle::unpickle(stream).map(MetricsStore::PostgreSql), + 4 => Pickle::unpickle(stream).map(MetricsStore::MySql), + _ => None, + } + } +} + +impl IntoValue for MetricsStore { + fn into_value(self) -> JmapValue<'static> { + match self { + MetricsStore::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + MetricsStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + MetricsStore::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + MetricsStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + MetricsStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MetricsStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MetricsStoreType::Disabled => *self = MetricsStore::Disabled, + MetricsStoreType::Default => *self = MetricsStore::Default, + MetricsStoreType::FoundationDb => { + *self = MetricsStore::FoundationDb(Default::default()) + } + MetricsStoreType::PostgreSql => { + *self = MetricsStore::PostgreSql(Default::default()) + } + MetricsStoreType::MySql => *self = MetricsStore::MySql(Default::default()), + } + } + match self { + MetricsStore::Disabled => pointer.assert_eof(), + MetricsStore::Default => pointer.assert_eof(), + MetricsStore::FoundationDb(inner) => inner.patch(pointer, value), + MetricsStore::PostgreSql(inner) => inner.patch(pointer, value), + MetricsStore::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl MetricsStore { + pub fn object_type(&self) -> MetricsStoreType { + match self { + MetricsStore::Disabled => MetricsStoreType::Disabled, + MetricsStore::Default => MetricsStoreType::Default, + MetricsStore::FoundationDb(_) => MetricsStoreType::FoundationDb, + MetricsStore::PostgreSql(_) => MetricsStoreType::PostgreSql, + MetricsStore::MySql(_) => MetricsStoreType::MySql, + } + } +} + +impl MtaConnectionIpHost { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.ehlo_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::EhloHostname)); + } + } + let value = &self.source_ip; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::SourceIp, value)); + } + errors.len() == neb + } +} + +impl Pickle for MtaConnectionIpHost { + fn pickle(&self, out: &mut Vec) { + self.ehlo_hostname.pickle(out); + self.source_ip.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.ehlo_hostname = Pickle::unpickle(stream)?; + this.source_ip = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaConnectionIpHost { + fn default() -> Self { + Self { + ehlo_hostname: Default::default(), + source_ip: Default::default(), + } + } +} + +impl IntoValue for MtaConnectionIpHost { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::EhloHostname, self.ehlo_hostname.into_value()); + map.insert_unchecked(Property::SourceIp, self.source_ip.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaConnectionIpHost { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::EhloHostname) => self + .ehlo_hostname + .patch(pointer.with_validators(&[StringValidator::Hostname]), value), + Some(Property::SourceIp) => self.source_ip.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaConnectionStrategy { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaConnectionStrategy; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + if let Some(value) = &self.ehlo_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::EhloHostname)); + } + } + let value = &self.source_ips; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaConnectionStrategy { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.ehlo_hostname.pickle(out); + self.source_ips.pickle(out); + self.connect_timeout.pickle(out); + self.data_timeout.pickle(out); + self.ehlo_timeout.pickle(out); + self.greeting_timeout.pickle(out); + self.mail_from_timeout.pickle(out); + self.rcpt_to_timeout.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.ehlo_hostname = Pickle::unpickle(stream)?; + this.source_ips = Pickle::unpickle(stream)?; + this.connect_timeout = Pickle::unpickle(stream)?; + this.data_timeout = Pickle::unpickle(stream)?; + this.ehlo_timeout = Pickle::unpickle(stream)?; + this.greeting_timeout = Pickle::unpickle(stream)?; + this.mail_from_timeout = Pickle::unpickle(stream)?; + this.rcpt_to_timeout = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaConnectionStrategy { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + ehlo_hostname: Default::default(), + source_ips: Default::default(), + connect_timeout: Duration::from_millis(300000), + data_timeout: Duration::from_millis(600000), + ehlo_timeout: Duration::from_millis(300000), + greeting_timeout: Duration::from_millis(300000), + mail_from_timeout: Duration::from_millis(300000), + rcpt_to_timeout: Duration::from_millis(300000), + } + } +} + +impl IntoValue for MtaConnectionStrategy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::EhloHostname, self.ehlo_hostname.into_value()); + map.insert_unchecked(Property::SourceIps, self.source_ips.into_value()); + map.insert_unchecked(Property::ConnectTimeout, self.connect_timeout.into_value()); + map.insert_unchecked(Property::DataTimeout, self.data_timeout.into_value()); + map.insert_unchecked(Property::EhloTimeout, self.ehlo_timeout.into_value()); + map.insert_unchecked( + Property::GreetingTimeout, + self.greeting_timeout.into_value(), + ); + map.insert_unchecked( + Property::MailFromTimeout, + self.mail_from_timeout.into_value(), + ); + map.insert_unchecked(Property::RcptToTimeout, self.rcpt_to_timeout.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaConnectionStrategy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::EhloHostname) => self + .ehlo_hostname + .patch(pointer.with_validators(&[StringValidator::Hostname]), value), + Some(Property::SourceIps) => self.source_ips.patch(pointer, value), + Some(Property::ConnectTimeout) => self.connect_timeout.patch(pointer, value), + Some(Property::DataTimeout) => self.data_timeout.patch(pointer, value), + Some(Property::EhloTimeout) => self.ehlo_timeout.patch(pointer, value), + Some(Property::GreetingTimeout) => self.greeting_timeout.patch(pointer, value), + Some(Property::MailFromTimeout) => self.mail_from_timeout.patch(pointer, value), + Some(Property::RcptToTimeout) => self.rcpt_to_timeout.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaDeliveryExpiration { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MtaDeliveryExpiration::Ttl(inner) => inner.validate(errors), + MtaDeliveryExpiration::Attempts(inner) => inner.validate(errors), + } + } +} + +impl Default for MtaDeliveryExpiration { + fn default() -> Self { + MtaDeliveryExpiration::Ttl(Default::default()) + } +} + +impl Pickle for MtaDeliveryExpiration { + fn pickle(&self, out: &mut Vec) { + match self { + MtaDeliveryExpiration::Ttl(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + MtaDeliveryExpiration::Attempts(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(MtaDeliveryExpiration::Ttl), + 1 => Pickle::unpickle(stream).map(MtaDeliveryExpiration::Attempts), + _ => None, + } + } +} + +impl IntoValue for MtaDeliveryExpiration { + fn into_value(self) -> JmapValue<'static> { + match self { + MtaDeliveryExpiration::Ttl(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ttl".into())); + obj + } + MtaDeliveryExpiration::Attempts(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Attempts".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MtaDeliveryExpiration { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MtaDeliveryExpirationType::Ttl => { + *self = MtaDeliveryExpiration::Ttl(Default::default()) + } + MtaDeliveryExpirationType::Attempts => { + *self = MtaDeliveryExpiration::Attempts(Default::default()) + } + } + } + match self { + MtaDeliveryExpiration::Ttl(inner) => inner.patch(pointer, value), + MtaDeliveryExpiration::Attempts(inner) => inner.patch(pointer, value), + } + } +} + +impl MtaDeliveryExpiration { + pub fn object_type(&self) -> MtaDeliveryExpirationType { + match self { + MtaDeliveryExpiration::Ttl(_) => MtaDeliveryExpirationType::Ttl, + MtaDeliveryExpiration::Attempts(_) => MtaDeliveryExpirationType::Attempts, + } + } +} + +impl MtaDeliveryExpirationAttempts { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_attempts; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAttempts, 1)); + } + errors.len() == neb + } +} + +impl Pickle for MtaDeliveryExpirationAttempts { + fn pickle(&self, out: &mut Vec) { + self.max_attempts.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_attempts = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaDeliveryExpirationAttempts { + fn default() -> Self { + Self { max_attempts: 5u64 } + } +} + +impl IntoValue for MtaDeliveryExpirationAttempts { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::MaxAttempts, self.max_attempts.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaDeliveryExpirationAttempts { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxAttempts) => self.max_attempts.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaDeliveryExpirationTtl { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for MtaDeliveryExpirationTtl { + fn pickle(&self, out: &mut Vec) { + self.expire.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.expire = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaDeliveryExpirationTtl { + fn default() -> Self { + Self { + expire: Duration::from_millis(259200000), + } + } +} + +impl IntoValue for MtaDeliveryExpirationTtl { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Expire, self.expire.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaDeliveryExpirationTtl { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Expire) => self.expire.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaDeliverySchedule { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaDeliverySchedule; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.expiry; + value.validate(errors); + let value = &self.notify; + value.validate(errors); + let value = &self.queue_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::QueueId)); + } + let value = &self.retry; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + i.foreign_key(ObjectType::MtaVirtualQueue, self.queue_id.into(), None); + } +} + +impl Pickle for MtaDeliverySchedule { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.expiry.pickle(out); + self.notify.pickle(out); + self.queue_id.pickle(out); + self.retry.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.expiry = Pickle::unpickle(stream)?; + this.notify = Pickle::unpickle(stream)?; + this.queue_id = Pickle::unpickle(stream)?; + this.retry = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaDeliverySchedule { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + expiry: Default::default(), + notify: Default::default(), + queue_id: Default::default(), + retry: Default::default(), + } + } +} + +impl IntoValue for MtaDeliverySchedule { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Expiry, self.expiry.into_value()); + map.insert_unchecked(Property::Notify, self.notify.into_value()); + map.insert_unchecked(Property::QueueId, self.queue_id.into_value()); + map.insert_unchecked(Property::Retry, self.retry.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaDeliverySchedule { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Expiry) => self.expiry.patch(pointer, value), + Some(Property::Notify) => self.notify.patch(pointer, value), + Some(Property::QueueId) => self.queue_id.patch(pointer, value), + Some(Property::Retry) => self.retry.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaDeliveryScheduleInterval { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for MtaDeliveryScheduleInterval { + fn pickle(&self, out: &mut Vec) { + self.duration.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.duration = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaDeliveryScheduleInterval { + fn default() -> Self { + Self { + duration: Duration::from_millis(3600000), + } + } +} + +impl IntoValue for MtaDeliveryScheduleInterval { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Duration, self.duration.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaDeliveryScheduleInterval { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Duration) => self.duration.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaDeliveryScheduleIntervals { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.intervals; + for value in value.values() { + value.validate(errors); + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Intervals, 1)); + } + errors.len() == neb + } +} + +impl Pickle for MtaDeliveryScheduleIntervals { + fn pickle(&self, out: &mut Vec) { + self.intervals.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.intervals = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaDeliveryScheduleIntervals { + fn default() -> Self { + Self { + intervals: Default::default(), + } + } +} + +impl IntoValue for MtaDeliveryScheduleIntervals { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Intervals, self.intervals.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaDeliveryScheduleIntervals { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Intervals) => self.intervals.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaDeliveryScheduleIntervalsOrDefault { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MtaDeliveryScheduleIntervalsOrDefault::Default => true, + MtaDeliveryScheduleIntervalsOrDefault::Custom(inner) => inner.validate(errors), + } + } +} + +impl Default for MtaDeliveryScheduleIntervalsOrDefault { + fn default() -> Self { + MtaDeliveryScheduleIntervalsOrDefault::Default + } +} + +impl Pickle for MtaDeliveryScheduleIntervalsOrDefault { + fn pickle(&self, out: &mut Vec) { + match self { + MtaDeliveryScheduleIntervalsOrDefault::Default => { + 0u16.pickle(out); + } + MtaDeliveryScheduleIntervalsOrDefault::Custom(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(MtaDeliveryScheduleIntervalsOrDefault::Default), + 1 => Pickle::unpickle(stream).map(MtaDeliveryScheduleIntervalsOrDefault::Custom), + _ => None, + } + } +} + +impl IntoValue for MtaDeliveryScheduleIntervalsOrDefault { + fn into_value(self) -> JmapValue<'static> { + match self { + MtaDeliveryScheduleIntervalsOrDefault::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + MtaDeliveryScheduleIntervalsOrDefault::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MtaDeliveryScheduleIntervalsOrDefault { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MtaDeliveryScheduleIntervalsOrDefaultType::Default => { + *self = MtaDeliveryScheduleIntervalsOrDefault::Default + } + MtaDeliveryScheduleIntervalsOrDefaultType::Custom => { + *self = MtaDeliveryScheduleIntervalsOrDefault::Custom(Default::default()) + } + } + } + match self { + MtaDeliveryScheduleIntervalsOrDefault::Default => pointer.assert_eof(), + MtaDeliveryScheduleIntervalsOrDefault::Custom(inner) => inner.patch(pointer, value), + } + } +} + +impl MtaDeliveryScheduleIntervalsOrDefault { + pub fn object_type(&self) -> MtaDeliveryScheduleIntervalsOrDefaultType { + match self { + MtaDeliveryScheduleIntervalsOrDefault::Default => { + MtaDeliveryScheduleIntervalsOrDefaultType::Default + } + MtaDeliveryScheduleIntervalsOrDefault::Custom(_) => { + MtaDeliveryScheduleIntervalsOrDefaultType::Custom + } + } + } +} + +impl ObjectImpl for MtaExtensions { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaExtensions; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.chunking; + value.validate(errors); + let value = &self.deliver_by; + value.validate(errors); + let value = &self.dsn; + value.validate(errors); + let value = &self.expn; + value.validate(errors); + let value = &self.future_release; + value.validate(errors); + let value = &self.mt_priority; + value.validate(errors); + let value = &self.no_soliciting; + value.validate(errors); + let value = &self.pipelining; + value.validate(errors); + let value = &self.require_tls; + value.validate(errors); + let value = &self.vrfy; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaExtensions { + pub fn ctx_chunking(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.chunking, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Chunking, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_deliver_by(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.deliver_by, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "15d".to_string(), + }]), + }), + property: Property::DeliverBy, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dsn(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dsn, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::Dsn, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_expn(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.expn, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::Expn, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_future_release(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.future_release, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "7d".to_string(), + }]), + }), + property: Property::FutureRelease, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_mt_priority(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.mt_priority, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "mixer".to_string(), + }]), + }), + property: Property::MtPriority, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: MTA_PRIORITY_CONSTANT, + } + } + + pub fn ctx_no_soliciting(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.no_soliciting, + default: Some(Expression { + else_: "''".to_string(), + ..Default::default() + }), + property: Property::NoSoliciting, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_pipelining(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.pipelining, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Pipelining, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_require_tls(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.require_tls, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::RequireTls, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_vrfy(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.vrfy, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::Vrfy, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_chunking(), + self.ctx_deliver_by(), + self.ctx_dsn(), + self.ctx_expn(), + self.ctx_future_release(), + self.ctx_mt_priority(), + self.ctx_no_soliciting(), + self.ctx_pipelining(), + self.ctx_require_tls(), + self.ctx_vrfy(), + ] + } +} + +impl Pickle for MtaExtensions { + fn pickle(&self, out: &mut Vec) { + self.chunking.pickle(out); + self.deliver_by.pickle(out); + self.dsn.pickle(out); + self.expn.pickle(out); + self.future_release.pickle(out); + self.mt_priority.pickle(out); + self.no_soliciting.pickle(out); + self.pipelining.pickle(out); + self.require_tls.pickle(out); + self.vrfy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.chunking = Pickle::unpickle(stream)?; + this.deliver_by = Pickle::unpickle(stream)?; + this.dsn = Pickle::unpickle(stream)?; + this.expn = Pickle::unpickle(stream)?; + this.future_release = Pickle::unpickle(stream)?; + this.mt_priority = Pickle::unpickle(stream)?; + this.no_soliciting = Pickle::unpickle(stream)?; + this.pipelining = Pickle::unpickle(stream)?; + this.require_tls = Pickle::unpickle(stream)?; + this.vrfy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaExtensions { + fn default() -> Self { + Self { + chunking: Expression { + else_: "true".to_string(), + ..Default::default() + }, + deliver_by: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "15d".to_string(), + }]), + }, + dsn: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }, + expn: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }, + future_release: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "7d".to_string(), + }]), + }, + mt_priority: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "mixer".to_string(), + }]), + }, + no_soliciting: Expression { + else_: "''".to_string(), + ..Default::default() + }, + pipelining: Expression { + else_: "true".to_string(), + ..Default::default() + }, + require_tls: Expression { + else_: "true".to_string(), + ..Default::default() + }, + vrfy: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".to_string(), + then: "true".to_string(), + }]), + }, + } + } +} + +impl IntoValue for MtaExtensions { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Chunking, self.chunking.into_value()); + map.insert_unchecked(Property::DeliverBy, self.deliver_by.into_value()); + map.insert_unchecked(Property::Dsn, self.dsn.into_value()); + map.insert_unchecked(Property::Expn, self.expn.into_value()); + map.insert_unchecked(Property::FutureRelease, self.future_release.into_value()); + map.insert_unchecked(Property::MtPriority, self.mt_priority.into_value()); + map.insert_unchecked(Property::NoSoliciting, self.no_soliciting.into_value()); + map.insert_unchecked(Property::Pipelining, self.pipelining.into_value()); + map.insert_unchecked(Property::RequireTls, self.require_tls.into_value()); + map.insert_unchecked(Property::Vrfy, self.vrfy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaExtensions { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Chunking) => self.chunking.patch(pointer, value), + Some(Property::DeliverBy) => self.deliver_by.patch(pointer, value), + Some(Property::Dsn) => self.dsn.patch(pointer, value), + Some(Property::Expn) => self.expn.patch(pointer, value), + Some(Property::FutureRelease) => self.future_release.patch(pointer, value), + Some(Property::MtPriority) => self.mt_priority.patch(pointer, value), + Some(Property::NoSoliciting) => self.no_soliciting.patch(pointer, value), + Some(Property::Pipelining) => self.pipelining.patch(pointer, value), + Some(Property::RequireTls) => self.require_tls.patch(pointer, value), + Some(Property::Vrfy) => self.vrfy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaHook { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaHook; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.enable; + value.validate(errors); + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaHook { + pub fn ctx_enable(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.enable, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Enable, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_enable()] + } +} + +impl Pickle for MtaHook { + fn pickle(&self, out: &mut Vec) { + self.allow_invalid_certs.pickle(out); + self.enable.pickle(out); + self.max_response_size.pickle(out); + self.temp_fail_on_error.pickle(out); + self.stages.pickle(out); + self.timeout.pickle(out); + self.url.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.max_response_size = Pickle::unpickle(stream)?; + this.temp_fail_on_error = Pickle::unpickle(stream)?; + this.stages = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaHook { + fn default() -> Self { + Self { + allow_invalid_certs: false, + enable: Expression { + else_: "true".to_string(), + ..Default::default() + }, + max_response_size: 52428800u64, + temp_fail_on_error: true, + stages: Map::new(vec![MtaStage::Data]), + timeout: Duration::from_millis(30000), + url: Default::default(), + http_auth: Default::default(), + http_headers: Default::default(), + } + } +} + +impl IntoValue for MtaHook { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked( + Property::MaxResponseSize, + self.max_response_size.into_value(), + ); + map.insert_unchecked( + Property::TempFailOnError, + self.temp_fail_on_error.into_value(), + ); + map.insert_unchecked(Property::Stages, self.stages.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaHook { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::MaxResponseSize) => self.max_response_size.patch(pointer, value), + Some(Property::TempFailOnError) => self.temp_fail_on_error.patch(pointer, value), + Some(Property::Stages) => self.stages.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaInboundSession { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaInboundSession; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_duration; + value.validate(errors); + let value = &self.timeout; + value.validate(errors); + let value = &self.transfer_limit; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaInboundSession { + pub fn ctx_max_duration(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_duration, + default: Some(Expression { + else_: "10m".to_string(), + ..Default::default() + }), + property: Property::MaxDuration, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_timeout(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.timeout, + default: Some(Expression { + else_: "5m".to_string(), + ..Default::default() + }), + property: Property::Timeout, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_transfer_limit(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.transfer_limit, + default: Some(Expression { + else_: "262144000".to_string(), + ..Default::default() + }), + property: Property::TransferLimit, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_max_duration(), + self.ctx_timeout(), + self.ctx_transfer_limit(), + ] + } +} + +impl Pickle for MtaInboundSession { + fn pickle(&self, out: &mut Vec) { + self.max_duration.pickle(out); + self.timeout.pickle(out); + self.transfer_limit.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_duration = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.transfer_limit = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaInboundSession { + fn default() -> Self { + Self { + max_duration: Expression { + else_: "10m".to_string(), + ..Default::default() + }, + timeout: Expression { + else_: "5m".to_string(), + ..Default::default() + }, + transfer_limit: Expression { + else_: "262144000".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaInboundSession { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::MaxDuration, self.max_duration.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::TransferLimit, self.transfer_limit.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaInboundSession { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxDuration) => self.max_duration.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::TransferLimit) => self.transfer_limit.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaInboundThrottle { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaInboundThrottle; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.match_; + value.validate(errors); + let value = &self.rate; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaInboundThrottle { + pub fn ctx_match_(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.match_, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Match, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_match_()] + } +} + +impl Pickle for MtaInboundThrottle { + fn pickle(&self, out: &mut Vec) { + self.enable.pickle(out); + self.description.pickle(out); + self.key.pickle(out); + self.match_.pickle(out); + self.rate.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.match_ = Pickle::unpickle(stream)?; + this.rate = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaInboundThrottle { + fn default() -> Self { + Self { + enable: true, + description: Default::default(), + key: Default::default(), + match_: Expression { + else_: "true".to_string(), + ..Default::default() + }, + rate: Default::default(), + } + } +} + +impl IntoValue for MtaInboundThrottle { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Match, self.match_.into_value()); + map.insert_unchecked(Property::Rate, self.rate.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaInboundThrottle { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Description) => { + self.description.patch(pointer.assert_read_only()?, value) + } + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Match) => self.match_.patch(pointer, value), + Some(Property::Rate) => self.rate.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaMilter { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaMilter; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.enable; + value.validate(errors); + let value = &self.hostname; + if value.is_empty() { + errors.push(ValidationError::required(Property::Hostname)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.stages; + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Stages, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaMilter { + pub fn ctx_enable(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.enable, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Enable, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_enable()] + } +} + +impl Pickle for MtaMilter { + fn pickle(&self, out: &mut Vec) { + self.allow_invalid_certs.pickle(out); + self.enable.pickle(out); + self.hostname.pickle(out); + self.max_response_size.pickle(out); + self.temp_fail_on_error.pickle(out); + self.protocol_version.pickle(out); + self.port.pickle(out); + self.stages.pickle(out); + self.timeout_command.pickle(out); + self.timeout_connect.pickle(out); + self.timeout_data.pickle(out); + self.use_tls.pickle(out); + self.flags_action.pickle(out); + self.flags_protocol.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.hostname = Pickle::unpickle(stream)?; + this.max_response_size = Pickle::unpickle(stream)?; + this.temp_fail_on_error = Pickle::unpickle(stream)?; + this.protocol_version = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.stages = Pickle::unpickle(stream)?; + this.timeout_command = Pickle::unpickle(stream)?; + this.timeout_connect = Pickle::unpickle(stream)?; + this.timeout_data = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.flags_action = Pickle::unpickle(stream)?; + this.flags_protocol = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaMilter { + fn default() -> Self { + Self { + allow_invalid_certs: false, + enable: Expression { + else_: "true".to_string(), + ..Default::default() + }, + hostname: Default::default(), + max_response_size: 52428800u64, + temp_fail_on_error: true, + protocol_version: MilterVersion::V6, + port: 11332u64, + stages: Map::new(vec![MtaStage::Data]), + timeout_command: Duration::from_millis(30000), + timeout_connect: Duration::from_millis(30000), + timeout_data: Duration::from_millis(60000), + use_tls: false, + flags_action: Default::default(), + flags_protocol: Default::default(), + } + } +} + +impl IntoValue for MtaMilter { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(16); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Hostname, self.hostname.into_value()); + map.insert_unchecked( + Property::MaxResponseSize, + self.max_response_size.into_value(), + ); + map.insert_unchecked( + Property::TempFailOnError, + self.temp_fail_on_error.into_value(), + ); + map.insert_unchecked( + Property::ProtocolVersion, + self.protocol_version.into_value(), + ); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Stages, self.stages.into_value()); + map.insert_unchecked(Property::TimeoutCommand, self.timeout_command.into_value()); + map.insert_unchecked(Property::TimeoutConnect, self.timeout_connect.into_value()); + map.insert_unchecked(Property::TimeoutData, self.timeout_data.into_value()); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked(Property::FlagsAction, self.flags_action.into_value()); + map.insert_unchecked(Property::FlagsProtocol, self.flags_protocol.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaMilter { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Hostname) => self + .hostname + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MaxResponseSize) => self.max_response_size.patch(pointer, value), + Some(Property::TempFailOnError) => self.temp_fail_on_error.patch(pointer, value), + Some(Property::ProtocolVersion) => self.protocol_version.patch(pointer, value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Stages) => self.stages.patch(pointer, value), + Some(Property::TimeoutCommand) => self.timeout_command.patch(pointer, value), + Some(Property::TimeoutConnect) => self.timeout_connect.patch(pointer, value), + Some(Property::TimeoutData) => self.timeout_data.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::FlagsAction) => self.flags_action.patch(pointer, value), + Some(Property::FlagsProtocol) => self.flags_protocol.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaOutboundStrategy { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaOutboundStrategy; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.connection; + value.validate(errors); + let value = &self.route; + value.validate(errors); + let value = &self.schedule; + value.validate(errors); + let value = &self.tls; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaOutboundStrategy { + pub fn ctx_connection(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.connection, + default: Some(Expression { + else_: "'default'".to_string(), + ..Default::default() + }), + property: Property::Connection, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_route(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.route, + default: Some(Expression { + else_: "'mx'".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(rcpt_domain)".to_string(), + then: "'local'".to_string(), + }]), + }), + property: Property::Route, + allowed_variables: MTA_QUEUE_RCPT_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_schedule(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.schedule, + default: Some(Expression { + else_: "'remote'".to_string(), + match_: List::from_iter([ + ExpressionMatch { + if_: "is_local_domain(rcpt_domain)".to_string(), + then: "'local'".to_string(), + }, + ExpressionMatch { + if_: "source == 'dsn'".to_string(), + then: "'dsn'".to_string(), + }, + ExpressionMatch { + if_: "source == 'report'".to_string(), + then: "'report'".to_string(), + }, + ]), + }), + property: Property::Schedule, + allowed_variables: MTA_QUEUE_RCPT_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_tls(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tls, + default: Some(Expression { + else_: "'default'".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "retry_num > 0 && last_error == 'tls'".to_string(), + then: "'invalid-tls'".to_string(), + }]), + }), + property: Property::Tls, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_connection(), + self.ctx_route(), + self.ctx_schedule(), + self.ctx_tls(), + ] + } +} + +impl Pickle for MtaOutboundStrategy { + fn pickle(&self, out: &mut Vec) { + self.connection.pickle(out); + self.route.pickle(out); + self.schedule.pickle(out); + self.tls.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.connection = Pickle::unpickle(stream)?; + this.route = Pickle::unpickle(stream)?; + this.schedule = Pickle::unpickle(stream)?; + this.tls = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaOutboundStrategy { + fn default() -> Self { + Self { + connection: Expression { + else_: "'default'".to_string(), + ..Default::default() + }, + route: Expression { + else_: "'mx'".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(rcpt_domain)".to_string(), + then: "'local'".to_string(), + }]), + }, + schedule: Expression { + else_: "'remote'".to_string(), + match_: List::from_iter([ + ExpressionMatch { + if_: "is_local_domain(rcpt_domain)".to_string(), + then: "'local'".to_string(), + }, + ExpressionMatch { + if_: "source == 'dsn'".to_string(), + then: "'dsn'".to_string(), + }, + ExpressionMatch { + if_: "source == 'report'".to_string(), + then: "'report'".to_string(), + }, + ]), + }, + tls: Expression { + else_: "'default'".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "retry_num > 0 && last_error == 'tls'".to_string(), + then: "'invalid-tls'".to_string(), + }]), + }, + } + } +} + +impl IntoValue for MtaOutboundStrategy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Connection, self.connection.into_value()); + map.insert_unchecked(Property::Route, self.route.into_value()); + map.insert_unchecked(Property::Schedule, self.schedule.into_value()); + map.insert_unchecked(Property::Tls, self.tls.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaOutboundStrategy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Connection) => self.connection.patch(pointer, value), + Some(Property::Route) => self.route.patch(pointer, value), + Some(Property::Schedule) => self.schedule.patch(pointer, value), + Some(Property::Tls) => self.tls.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaOutboundThrottle { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaOutboundThrottle; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.match_; + value.validate(errors); + let value = &self.rate; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaOutboundThrottle { + pub fn ctx_match_(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.match_, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Match, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_match_()] + } +} + +impl Pickle for MtaOutboundThrottle { + fn pickle(&self, out: &mut Vec) { + self.enable.pickle(out); + self.description.pickle(out); + self.key.pickle(out); + self.match_.pickle(out); + self.rate.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.match_ = Pickle::unpickle(stream)?; + this.rate = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaOutboundThrottle { + fn default() -> Self { + Self { + enable: true, + description: Default::default(), + key: Default::default(), + match_: Expression { + else_: "true".to_string(), + ..Default::default() + }, + rate: Default::default(), + } + } +} + +impl IntoValue for MtaOutboundThrottle { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Match, self.match_.into_value()); + map.insert_unchecked(Property::Rate, self.rate.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaOutboundThrottle { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Match) => self.match_.patch(pointer, value), + Some(Property::Rate) => self.rate.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaQueueQuota { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaQueueQuota; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.match_; + if !value.match_.is_empty() || !value.else_.is_empty() { + value.validate(errors); + } + if let Some(value) = &self.messages { + if *value < 1 { + errors.push(ValidationError::min_value(Property::Messages, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaQueueQuota { + pub fn ctx_match_(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.match_, + default: None, + property: Property::Match, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_match_()] + } +} + +impl Pickle for MtaQueueQuota { + fn pickle(&self, out: &mut Vec) { + self.enable.pickle(out); + self.description.pickle(out); + self.key.pickle(out); + self.match_.pickle(out); + self.messages.pickle(out); + self.size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.match_ = Pickle::unpickle(stream)?; + this.messages = Pickle::unpickle(stream)?; + this.size = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaQueueQuota { + fn default() -> Self { + Self { + enable: true, + description: Default::default(), + key: Default::default(), + match_: Default::default(), + messages: Default::default(), + size: Default::default(), + } + } +} + +impl IntoValue for MtaQueueQuota { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Match, self.match_.into_value()); + map.insert_unchecked(Property::Messages, self.messages.into_value()); + map.insert_unchecked(Property::Size, self.size.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaQueueQuota { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Description) => { + self.description.patch(pointer.assert_read_only()?, value) + } + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Match) => self.match_.patch(pointer, value), + Some(Property::Messages) => self.messages.patch(pointer, value), + Some(Property::Size) => self.size.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaRoute { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaRoute; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + MtaRoute::Mx(inner) => inner.validate(errors), + MtaRoute::Relay(inner) => inner.validate(errors), + MtaRoute::Local(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + MtaRoute::Mx(object) => { + object.index(i); + } + MtaRoute::Relay(object) => { + object.index(i); + } + MtaRoute::Local(object) => { + object.index(i); + } + } + } +} + +impl Default for MtaRoute { + fn default() -> Self { + MtaRoute::Mx(Default::default()) + } +} + +impl Pickle for MtaRoute { + fn pickle(&self, out: &mut Vec) { + match self { + MtaRoute::Mx(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + MtaRoute::Relay(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + MtaRoute::Local(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(MtaRoute::Mx), + 1 => Pickle::unpickle(stream).map(MtaRoute::Relay), + 2 => Pickle::unpickle(stream).map(MtaRoute::Local), + _ => None, + } + } +} + +impl IntoValue for MtaRoute { + fn into_value(self) -> JmapValue<'static> { + match self { + MtaRoute::Mx(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Mx".into())); + obj + } + MtaRoute::Relay(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Relay".into())); + obj + } + MtaRoute::Local(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Local".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for MtaRoute { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + MtaRouteType::Mx => *self = MtaRoute::Mx(Default::default()), + MtaRouteType::Relay => *self = MtaRoute::Relay(Default::default()), + MtaRouteType::Local => *self = MtaRoute::Local(Default::default()), + } + } + match self { + MtaRoute::Mx(inner) => inner.patch(pointer, value), + MtaRoute::Relay(inner) => inner.patch(pointer, value), + MtaRoute::Local(inner) => inner.patch(pointer, value), + } + } +} + +impl MtaRoute { + pub fn object_type(&self) -> MtaRouteType { + match self { + MtaRoute::Mx(_) => MtaRouteType::Mx, + MtaRoute::Relay(_) => MtaRouteType::Relay, + MtaRoute::Local(_) => MtaRouteType::Local, + } + } +} + +impl MtaRouteCommon { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaRouteCommon { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaRouteCommon { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + } + } +} + +impl IntoValue for MtaRouteCommon { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaRouteCommon { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaRouteMx { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_multihomed; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMultihomed, 1)); + } + let value = &self.max_mx_hosts; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMxHosts, 1)); + } + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaRouteMx { + fn pickle(&self, out: &mut Vec) { + self.ip_lookup_strategy.pickle(out); + self.max_multihomed.pickle(out); + self.max_mx_hosts.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.ip_lookup_strategy = Pickle::unpickle(stream)?; + this.max_multihomed = Pickle::unpickle(stream)?; + this.max_mx_hosts = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaRouteMx { + fn default() -> Self { + Self { + ip_lookup_strategy: MtaIpStrategy::V4ThenV6, + max_multihomed: 2u64, + max_mx_hosts: 5u64, + name: Default::default(), + description: Default::default(), + } + } +} + +impl IntoValue for MtaRouteMx { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked( + Property::IpLookupStrategy, + self.ip_lookup_strategy.into_value(), + ); + map.insert_unchecked(Property::MaxMultihomed, self.max_multihomed.into_value()); + map.insert_unchecked(Property::MaxMxHosts, self.max_mx_hosts.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaRouteMx { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IpLookupStrategy) => self.ip_lookup_strategy.patch(pointer, value), + Some(Property::MaxMultihomed) => self.max_multihomed.patch(pointer, value), + Some(Property::MaxMxHosts) => self.max_mx_hosts.patch(pointer, value), + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MtaRouteRelay { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.address; + if value.is_empty() { + errors.push(ValidationError::required(Property::Address)); + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaRouteRelay { + fn pickle(&self, out: &mut Vec) { + self.address.pickle(out); + self.auth_secret.pickle(out); + self.auth_username.pickle(out); + self.port.pickle(out); + self.protocol.pickle(out); + self.allow_invalid_certs.pickle(out); + self.implicit_tls.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.address = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.protocol = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.implicit_tls = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaRouteRelay { + fn default() -> Self { + Self { + address: Default::default(), + auth_secret: Default::default(), + auth_username: Default::default(), + port: 25u64, + protocol: MtaProtocol::Smtp, + allow_invalid_certs: false, + implicit_tls: false, + name: Default::default(), + description: Default::default(), + } + } +} + +impl IntoValue for MtaRouteRelay { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Address, self.address.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Protocol, self.protocol.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::ImplicitTls, self.implicit_tls.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaRouteRelay { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Address) => self + .address + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::AuthUsername) => self.auth_username.patch(pointer, value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Protocol) => self.protocol.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::ImplicitTls) => self.implicit_tls.patch(pointer, value), + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageAuth { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageAuth; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_failures; + value.validate(errors); + let value = &self.wait_on_fail; + value.validate(errors); + let value = &self.sasl_mechanisms; + value.validate(errors); + let value = &self.must_match_sender; + value.validate(errors); + let value = &self.require; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageAuth { + pub fn ctx_max_failures(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_failures, + default: Some(Expression { + else_: "3".to_string(), + ..Default::default() + }), + property: Property::MaxFailures, + allowed_variables: MTA_EHLO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_wait_on_fail(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.wait_on_fail, + default: Some(Expression { + else_: "5s".to_string(), + ..Default::default() + }), + property: Property::WaitOnFail, + allowed_variables: MTA_EHLO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_sasl_mechanisms(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.sasl_mechanisms, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ + ExpressionMatch { + if_: "local_port != 25 && is_tls".to_string(), + then: "[plain, login, oauthbearer, xoauth2]".to_string(), + }, + ExpressionMatch { + if_: "local_port != 25".to_string(), + then: "[oauthbearer, xoauth2]".to_string(), + }, + ]), + }), + property: Property::SaslMechanisms, + allowed_variables: MTA_EHLO_VARIABLE, + allowed_constants: MTA_AUTH_TYPE_CONSTANT, + } + } + + pub fn ctx_must_match_sender(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.must_match_sender, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::MustMatchSender, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_require(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.require, + default: Some(Expression { + else_: "local_port != 25".to_string(), + ..Default::default() + }), + property: Property::Require, + allowed_variables: MTA_EHLO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_max_failures(), + self.ctx_wait_on_fail(), + self.ctx_sasl_mechanisms(), + self.ctx_must_match_sender(), + self.ctx_require(), + ] + } +} + +impl Pickle for MtaStageAuth { + fn pickle(&self, out: &mut Vec) { + self.max_failures.pickle(out); + self.wait_on_fail.pickle(out); + self.sasl_mechanisms.pickle(out); + self.must_match_sender.pickle(out); + self.require.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_failures = Pickle::unpickle(stream)?; + this.wait_on_fail = Pickle::unpickle(stream)?; + this.sasl_mechanisms = Pickle::unpickle(stream)?; + this.must_match_sender = Pickle::unpickle(stream)?; + this.require = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageAuth { + fn default() -> Self { + Self { + max_failures: Expression { + else_: "3".to_string(), + ..Default::default() + }, + wait_on_fail: Expression { + else_: "5s".to_string(), + ..Default::default() + }, + sasl_mechanisms: Expression { + else_: "false".to_string(), + match_: List::from_iter([ + ExpressionMatch { + if_: "local_port != 25 && is_tls".to_string(), + then: "[plain, login, oauthbearer, xoauth2]".to_string(), + }, + ExpressionMatch { + if_: "local_port != 25".to_string(), + then: "[oauthbearer, xoauth2]".to_string(), + }, + ]), + }, + must_match_sender: Expression { + else_: "true".to_string(), + ..Default::default() + }, + require: Expression { + else_: "local_port != 25".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageAuth { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::MaxFailures, self.max_failures.into_value()); + map.insert_unchecked(Property::WaitOnFail, self.wait_on_fail.into_value()); + map.insert_unchecked(Property::SaslMechanisms, self.sasl_mechanisms.into_value()); + map.insert_unchecked( + Property::MustMatchSender, + self.must_match_sender.into_value(), + ); + map.insert_unchecked(Property::Require, self.require.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageAuth { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxFailures) => self.max_failures.patch(pointer, value), + Some(Property::WaitOnFail) => self.wait_on_fail.patch(pointer, value), + Some(Property::SaslMechanisms) => self.sasl_mechanisms.patch(pointer, value), + Some(Property::MustMatchSender) => self.must_match_sender.patch(pointer, value), + Some(Property::Require) => self.require.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageConnect { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageConnect; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.smtp_greeting; + value.validate(errors); + let value = &self.hostname; + value.validate(errors); + let value = &self.script; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageConnect { + pub fn ctx_smtp_greeting(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.smtp_greeting, + default: Some(Expression { + else_: "system('hostname') + ' Stalwart ESMTP at your service'".to_string(), + ..Default::default() + }), + property: Property::SmtpGreeting, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_hostname(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.hostname, + default: Some(Expression { + else_: "system('hostname')".to_string(), + ..Default::default() + }), + property: Property::Hostname, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_script(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.script, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Script, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_smtp_greeting(), + self.ctx_hostname(), + self.ctx_script(), + ] + } +} + +impl Pickle for MtaStageConnect { + fn pickle(&self, out: &mut Vec) { + self.smtp_greeting.pickle(out); + self.hostname.pickle(out); + self.script.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.smtp_greeting = Pickle::unpickle(stream)?; + this.hostname = Pickle::unpickle(stream)?; + this.script = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageConnect { + fn default() -> Self { + Self { + smtp_greeting: Expression { + else_: "system('hostname') + ' Stalwart ESMTP at your service'".to_string(), + ..Default::default() + }, + hostname: Expression { + else_: "system('hostname')".to_string(), + ..Default::default() + }, + script: Expression { + else_: "false".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageConnect { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::SmtpGreeting, self.smtp_greeting.into_value()); + map.insert_unchecked(Property::Hostname, self.hostname.into_value()); + map.insert_unchecked(Property::Script, self.script.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageConnect { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::SmtpGreeting) => self.smtp_greeting.patch(pointer, value), + Some(Property::Hostname) => self.hostname.patch(pointer, value), + Some(Property::Script) => self.script.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageData { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageData; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.add_auth_results_header; + value.validate(errors); + let value = &self.add_date_header; + value.validate(errors); + let value = &self.add_message_id_header; + value.validate(errors); + let value = &self.add_received_header; + value.validate(errors); + let value = &self.add_received_spf_header; + value.validate(errors); + let value = &self.add_return_path_header; + value.validate(errors); + let value = &self.max_messages; + value.validate(errors); + let value = &self.max_received_headers; + value.validate(errors); + let value = &self.max_message_size; + value.validate(errors); + let value = &self.script; + value.validate(errors); + let value = &self.enable_spam_filter; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageData { + pub fn ctx_add_auth_results_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_auth_results_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddAuthResultsHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_add_date_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_date_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddDateHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_add_message_id_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_message_id_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddMessageIdHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_add_received_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_received_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddReceivedHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_add_received_spf_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_received_spf_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddReceivedSpfHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_add_return_path_header(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.add_return_path_header, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::AddReturnPathHeader, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_max_messages(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_messages, + default: Some(Expression { + else_: "10".to_string(), + ..Default::default() + }), + property: Property::MaxMessages, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_max_received_headers(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_received_headers, + default: Some(Expression { + else_: "50".to_string(), + ..Default::default() + }), + property: Property::MaxReceivedHeaders, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_max_message_size(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_message_size, + default: Some(Expression { + else_: "104857600".to_string(), + ..Default::default() + }), + property: Property::MaxMessageSize, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_script(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.script, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Script, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_enable_spam_filter(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.enable_spam_filter, + default: Some(Expression { + else_: "is_empty(authenticated_as)".to_string(), + ..Default::default() + }), + property: Property::EnableSpamFilter, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_add_auth_results_header(), + self.ctx_add_date_header(), + self.ctx_add_message_id_header(), + self.ctx_add_received_header(), + self.ctx_add_received_spf_header(), + self.ctx_add_return_path_header(), + self.ctx_max_messages(), + self.ctx_max_received_headers(), + self.ctx_max_message_size(), + self.ctx_script(), + self.ctx_enable_spam_filter(), + ] + } +} + +impl Pickle for MtaStageData { + fn pickle(&self, out: &mut Vec) { + self.add_auth_results_header.pickle(out); + self.add_date_header.pickle(out); + self.add_delivered_to_header.pickle(out); + self.add_message_id_header.pickle(out); + self.add_received_header.pickle(out); + self.add_received_spf_header.pickle(out); + self.add_return_path_header.pickle(out); + self.max_messages.pickle(out); + self.max_received_headers.pickle(out); + self.max_message_size.pickle(out); + self.script.pickle(out); + self.enable_spam_filter.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.add_auth_results_header = Pickle::unpickle(stream)?; + this.add_date_header = Pickle::unpickle(stream)?; + this.add_delivered_to_header = Pickle::unpickle(stream)?; + this.add_message_id_header = Pickle::unpickle(stream)?; + this.add_received_header = Pickle::unpickle(stream)?; + this.add_received_spf_header = Pickle::unpickle(stream)?; + this.add_return_path_header = Pickle::unpickle(stream)?; + this.max_messages = Pickle::unpickle(stream)?; + this.max_received_headers = Pickle::unpickle(stream)?; + this.max_message_size = Pickle::unpickle(stream)?; + this.script = Pickle::unpickle(stream)?; + this.enable_spam_filter = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageData { + fn default() -> Self { + Self { + add_auth_results_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + add_date_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + add_delivered_to_header: true, + add_message_id_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + add_received_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + add_received_spf_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + add_return_path_header: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + max_messages: Expression { + else_: "10".to_string(), + ..Default::default() + }, + max_received_headers: Expression { + else_: "50".to_string(), + ..Default::default() + }, + max_message_size: Expression { + else_: "104857600".to_string(), + ..Default::default() + }, + script: Expression { + else_: "false".to_string(), + ..Default::default() + }, + enable_spam_filter: Expression { + else_: "is_empty(authenticated_as)".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageData { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked( + Property::AddAuthResultsHeader, + self.add_auth_results_header.into_value(), + ); + map.insert_unchecked(Property::AddDateHeader, self.add_date_header.into_value()); + map.insert_unchecked( + Property::AddDeliveredToHeader, + self.add_delivered_to_header.into_value(), + ); + map.insert_unchecked( + Property::AddMessageIdHeader, + self.add_message_id_header.into_value(), + ); + map.insert_unchecked( + Property::AddReceivedHeader, + self.add_received_header.into_value(), + ); + map.insert_unchecked( + Property::AddReceivedSpfHeader, + self.add_received_spf_header.into_value(), + ); + map.insert_unchecked( + Property::AddReturnPathHeader, + self.add_return_path_header.into_value(), + ); + map.insert_unchecked(Property::MaxMessages, self.max_messages.into_value()); + map.insert_unchecked( + Property::MaxReceivedHeaders, + self.max_received_headers.into_value(), + ); + map.insert_unchecked(Property::MaxMessageSize, self.max_message_size.into_value()); + map.insert_unchecked(Property::Script, self.script.into_value()); + map.insert_unchecked( + Property::EnableSpamFilter, + self.enable_spam_filter.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageData { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AddAuthResultsHeader) => { + self.add_auth_results_header.patch(pointer, value) + } + Some(Property::AddDateHeader) => self.add_date_header.patch(pointer, value), + Some(Property::AddDeliveredToHeader) => { + self.add_delivered_to_header.patch(pointer, value) + } + Some(Property::AddMessageIdHeader) => self.add_message_id_header.patch(pointer, value), + Some(Property::AddReceivedHeader) => self.add_received_header.patch(pointer, value), + Some(Property::AddReceivedSpfHeader) => { + self.add_received_spf_header.patch(pointer, value) + } + Some(Property::AddReturnPathHeader) => { + self.add_return_path_header.patch(pointer, value) + } + Some(Property::MaxMessages) => self.max_messages.patch(pointer, value), + Some(Property::MaxReceivedHeaders) => self.max_received_headers.patch(pointer, value), + Some(Property::MaxMessageSize) => self.max_message_size.patch(pointer, value), + Some(Property::Script) => self.script.patch(pointer, value), + Some(Property::EnableSpamFilter) => self.enable_spam_filter.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageEhlo { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageEhlo; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.reject_non_fqdn; + value.validate(errors); + let value = &self.require; + value.validate(errors); + let value = &self.script; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageEhlo { + pub fn ctx_reject_non_fqdn(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.reject_non_fqdn, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }), + property: Property::RejectNonFqdn, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_require(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.require, + default: Some(Expression { + else_: "true".to_string(), + ..Default::default() + }), + property: Property::Require, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_script(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.script, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Script, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_reject_non_fqdn(), + self.ctx_require(), + self.ctx_script(), + ] + } +} + +impl Pickle for MtaStageEhlo { + fn pickle(&self, out: &mut Vec) { + self.reject_non_fqdn.pickle(out); + self.require.pickle(out); + self.script.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.reject_non_fqdn = Pickle::unpickle(stream)?; + this.require = Pickle::unpickle(stream)?; + this.script = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageEhlo { + fn default() -> Self { + Self { + reject_non_fqdn: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "true".to_string(), + }]), + }, + require: Expression { + else_: "true".to_string(), + ..Default::default() + }, + script: Expression { + else_: "false".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageEhlo { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::RejectNonFqdn, self.reject_non_fqdn.into_value()); + map.insert_unchecked(Property::Require, self.require.into_value()); + map.insert_unchecked(Property::Script, self.script.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageEhlo { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::RejectNonFqdn) => self.reject_non_fqdn.patch(pointer, value), + Some(Property::Require) => self.require.patch(pointer, value), + Some(Property::Script) => self.script.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageMail { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageMail; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.is_sender_allowed; + value.validate(errors); + let value = &self.rewrite; + value.validate(errors); + let value = &self.script; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageMail { + pub fn ctx_is_sender_allowed(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.is_sender_allowed, + default: Some(Expression { + else_: "!is_empty(authenticated_as) || !key_exists('spam-block', sender_domain)" + .to_string(), + match_: List::from_iter([]), + }), + property: Property::IsSenderAllowed, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_rewrite(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.rewrite, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Rewrite, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_script(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.script, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Script, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_is_sender_allowed(), + self.ctx_rewrite(), + self.ctx_script(), + ] + } +} + +impl Pickle for MtaStageMail { + fn pickle(&self, out: &mut Vec) { + self.is_sender_allowed.pickle(out); + self.rewrite.pickle(out); + self.script.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.is_sender_allowed = Pickle::unpickle(stream)?; + this.rewrite = Pickle::unpickle(stream)?; + this.script = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageMail { + fn default() -> Self { + Self { + is_sender_allowed: Expression { + else_: "!is_empty(authenticated_as) || !key_exists('spam-block', sender_domain)" + .to_string(), + match_: List::from_iter([]), + }, + rewrite: Expression { + else_: "false".to_string(), + ..Default::default() + }, + script: Expression { + else_: "false".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageMail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked( + Property::IsSenderAllowed, + self.is_sender_allowed.into_value(), + ); + map.insert_unchecked(Property::Rewrite, self.rewrite.into_value()); + map.insert_unchecked(Property::Script, self.script.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageMail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IsSenderAllowed) => self.is_sender_allowed.patch(pointer, value), + Some(Property::Rewrite) => self.rewrite.patch(pointer, value), + Some(Property::Script) => self.script.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaStageRcpt { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaStageRcpt; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_failures; + value.validate(errors); + let value = &self.wait_on_fail; + value.validate(errors); + let value = &self.max_recipients; + value.validate(errors); + let value = &self.allow_relaying; + value.validate(errors); + let value = &self.rewrite; + value.validate(errors); + let value = &self.script; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl MtaStageRcpt { + pub fn ctx_max_failures(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_failures, + default: Some(Expression { + else_: "5".to_string(), + ..Default::default() + }), + property: Property::MaxFailures, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_wait_on_fail(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.wait_on_fail, + default: Some(Expression { + else_: "5s".to_string(), + ..Default::default() + }), + property: Property::WaitOnFail, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_max_recipients(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_recipients, + default: Some(Expression { + else_: "100".to_string(), + ..Default::default() + }), + property: Property::MaxRecipients, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_allow_relaying(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.allow_relaying, + default: Some(Expression { + else_: "!is_empty(authenticated_as)".to_string(), + ..Default::default() + }), + property: Property::AllowRelaying, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_rewrite(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.rewrite, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Rewrite, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_script(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.script, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::Script, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_max_failures(), + self.ctx_wait_on_fail(), + self.ctx_max_recipients(), + self.ctx_allow_relaying(), + self.ctx_rewrite(), + self.ctx_script(), + ] + } +} + +impl Pickle for MtaStageRcpt { + fn pickle(&self, out: &mut Vec) { + self.max_failures.pickle(out); + self.wait_on_fail.pickle(out); + self.max_recipients.pickle(out); + self.allow_relaying.pickle(out); + self.rewrite.pickle(out); + self.script.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_failures = Pickle::unpickle(stream)?; + this.wait_on_fail = Pickle::unpickle(stream)?; + this.max_recipients = Pickle::unpickle(stream)?; + this.allow_relaying = Pickle::unpickle(stream)?; + this.rewrite = Pickle::unpickle(stream)?; + this.script = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaStageRcpt { + fn default() -> Self { + Self { + max_failures: Expression { + else_: "5".to_string(), + ..Default::default() + }, + wait_on_fail: Expression { + else_: "5s".to_string(), + ..Default::default() + }, + max_recipients: Expression { + else_: "100".to_string(), + ..Default::default() + }, + allow_relaying: Expression { + else_: "!is_empty(authenticated_as)".to_string(), + ..Default::default() + }, + rewrite: Expression { + else_: "false".to_string(), + ..Default::default() + }, + script: Expression { + else_: "false".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for MtaStageRcpt { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::MaxFailures, self.max_failures.into_value()); + map.insert_unchecked(Property::WaitOnFail, self.wait_on_fail.into_value()); + map.insert_unchecked(Property::MaxRecipients, self.max_recipients.into_value()); + map.insert_unchecked(Property::AllowRelaying, self.allow_relaying.into_value()); + map.insert_unchecked(Property::Rewrite, self.rewrite.into_value()); + map.insert_unchecked(Property::Script, self.script.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaStageRcpt { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxFailures) => self.max_failures.patch(pointer, value), + Some(Property::WaitOnFail) => self.wait_on_fail.patch(pointer, value), + Some(Property::MaxRecipients) => self.max_recipients.patch(pointer, value), + Some(Property::AllowRelaying) => self.allow_relaying.patch(pointer, value), + Some(Property::Rewrite) => self.rewrite.patch(pointer, value), + Some(Property::Script) => self.script.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaSts { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaSts; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.mx_hosts; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::MxHosts)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for MtaSts { + fn pickle(&self, out: &mut Vec) { + self.max_age.pickle(out); + self.mode.pickle(out); + self.mx_hosts.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_age = Pickle::unpickle(stream)?; + this.mode = Pickle::unpickle(stream)?; + this.mx_hosts = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaSts { + fn default() -> Self { + Self { + max_age: Duration::from_millis(604800000), + mode: PolicyEnforcement::Testing, + mx_hosts: Default::default(), + } + } +} + +impl IntoValue for MtaSts { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::MaxAge, self.max_age.into_value()); + map.insert_unchecked(Property::Mode, self.mode.into_value()); + map.insert_unchecked(Property::MxHosts, self.mx_hosts.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaSts { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxAge) => self.max_age.patch(pointer, value), + Some(Property::Mode) => self.mode.patch(pointer, value), + Some(Property::MxHosts) => self + .mx_hosts + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaTlsStrategy { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaTlsStrategy; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaTlsStrategy { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.allow_invalid_certs.pickle(out); + self.dane.pickle(out); + self.description.pickle(out); + self.mta_sts.pickle(out); + self.start_tls.pickle(out); + self.mta_sts_timeout.pickle(out); + self.tls_timeout.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.dane = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.mta_sts = Pickle::unpickle(stream)?; + this.start_tls = Pickle::unpickle(stream)?; + this.mta_sts_timeout = Pickle::unpickle(stream)?; + this.tls_timeout = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaTlsStrategy { + fn default() -> Self { + Self { + name: Default::default(), + allow_invalid_certs: false, + dane: MtaRequiredOrOptional::Optional, + description: Default::default(), + mta_sts: MtaRequiredOrOptional::Optional, + start_tls: MtaRequiredOrOptional::Optional, + mta_sts_timeout: Duration::from_millis(300000), + tls_timeout: Duration::from_millis(180000), + } + } +} + +impl IntoValue for MtaTlsStrategy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::Dane, self.dane.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MtaSts, self.mta_sts.into_value()); + map.insert_unchecked(Property::StartTls, self.start_tls.into_value()); + map.insert_unchecked(Property::MtaStsTimeout, self.mta_sts_timeout.into_value()); + map.insert_unchecked(Property::TlsTimeout, self.tls_timeout.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaTlsStrategy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::Dane) => self.dane.patch(pointer, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::MtaSts) => self.mta_sts.patch(pointer, value), + Some(Property::StartTls) => self.start_tls.patch(pointer, value), + Some(Property::MtaStsTimeout) => self.mta_sts_timeout.patch(pointer, value), + Some(Property::TlsTimeout) => self.tls_timeout.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for MtaVirtualQueue { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::MtaVirtualQueue; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if value.len() > 8 { + errors.push(ValidationError::max_length(Property::Name, 8)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.threads_per_node; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ThreadsPerNode, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaVirtualQueue { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.threads_per_node.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.threads_per_node = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaVirtualQueue { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + threads_per_node: 25u64, + } + } +} + +impl IntoValue for MtaVirtualQueue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::ThreadsPerNode, self.threads_per_node.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaVirtualQueue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Trim]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::ThreadsPerNode) => self.threads_per_node.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MySqlSettings { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.database; + if value.is_empty() { + errors.push(ValidationError::required(Property::Database)); + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for MySqlSettings { + fn pickle(&self, out: &mut Vec) { + self.host.pickle(out); + self.port.pickle(out); + self.database.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.database = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MySqlSettings { + fn default() -> Self { + Self { + host: Default::default(), + port: 3306u64, + database: "stalwart".to_string(), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + } + } +} + +impl IntoValue for MySqlSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Database, self.database.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MySqlSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Database) => self + .database + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl MySqlStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.max_allowed_packet { + if *value > 1073741824 { + errors.push(ValidationError::max_value( + Property::MaxAllowedPacket, + 1073741824, + )); + } + if *value < 1024 { + errors.push(ValidationError::min_value(Property::MaxAllowedPacket, 1024)); + } + } + if let Some(value) = &self.pool_max_connections { + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + } + if let Some(value) = &self.pool_min_connections { + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMinConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMinConnections, 1)); + } + } + let value = &self.read_replicas; + for value in value.values() { + value.validate(errors); + } + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.database; + if value.is_empty() { + errors.push(ValidationError::required(Property::Database)); + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for MySqlStore { + fn pickle(&self, out: &mut Vec) { + self.timeout.pickle(out); + self.use_tls.pickle(out); + self.allow_invalid_certs.pickle(out); + self.max_allowed_packet.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_min_connections.pickle(out); + self.read_replicas.pickle(out); + self.host.pickle(out); + self.port.pickle(out); + self.database.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.timeout = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.max_allowed_packet = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_min_connections = Pickle::unpickle(stream)?; + this.read_replicas = Pickle::unpickle(stream)?; + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.database = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MySqlStore { + fn default() -> Self { + Self { + timeout: Some(Duration::from_millis(15000)), + use_tls: false, + allow_invalid_certs: false, + max_allowed_packet: Default::default(), + pool_max_connections: Some(10u64), + pool_min_connections: Some(5u64), + read_replicas: Default::default(), + host: Default::default(), + port: 3306u64, + database: "stalwart".to_string(), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + } + } +} + +impl IntoValue for MySqlStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked( + Property::MaxAllowedPacket, + self.max_allowed_packet.into_value(), + ); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolMinConnections, + self.pool_min_connections.into_value(), + ); + map.insert_unchecked(Property::ReadReplicas, self.read_replicas.into_value()); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Database, self.database.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MySqlStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::MaxAllowedPacket) => self.max_allowed_packet.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolMinConnections) => self.pool_min_connections.patch(pointer, value), + Some(Property::ReadReplicas) => self.read_replicas.patch(pointer, value), + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Database) => self + .database + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl NatsCoordinator { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.addresses; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Addresses)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Addresses, 1)); + } + let value = &self.capacity_client; + if *value < 1 { + errors.push(ValidationError::min_value(Property::CapacityClient, 1)); + } + let value = &self.capacity_read_buffer; + if *value < 1 { + errors.push(ValidationError::min_value(Property::CapacityReadBuffer, 1)); + } + let value = &self.capacity_subscription; + if *value < 1 { + errors.push(ValidationError::min_value( + Property::CapacitySubscription, + 1, + )); + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.credentials; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for NatsCoordinator { + fn pickle(&self, out: &mut Vec) { + self.addresses.pickle(out); + self.max_reconnects.pickle(out); + self.timeout_connection.pickle(out); + self.timeout_request.pickle(out); + self.ping_interval.pickle(out); + self.capacity_client.pickle(out); + self.capacity_read_buffer.pickle(out); + self.capacity_subscription.pickle(out); + self.no_echo.pickle(out); + self.use_tls.pickle(out); + self.auth_secret.pickle(out); + self.auth_username.pickle(out); + self.credentials.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.addresses = Pickle::unpickle(stream)?; + this.max_reconnects = Pickle::unpickle(stream)?; + this.timeout_connection = Pickle::unpickle(stream)?; + this.timeout_request = Pickle::unpickle(stream)?; + this.ping_interval = Pickle::unpickle(stream)?; + this.capacity_client = Pickle::unpickle(stream)?; + this.capacity_read_buffer = Pickle::unpickle(stream)?; + this.capacity_subscription = Pickle::unpickle(stream)?; + this.no_echo = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.credentials = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for NatsCoordinator { + fn default() -> Self { + Self { + addresses: Map::new(vec!["127.0.0.1:4444".to_string()]), + max_reconnects: Default::default(), + timeout_connection: Duration::from_millis(5000), + timeout_request: Duration::from_millis(10000), + ping_interval: Duration::from_millis(60000), + capacity_client: 2048u64, + capacity_read_buffer: 65535u64, + capacity_subscription: 65536u64, + no_echo: true, + use_tls: false, + auth_secret: Default::default(), + auth_username: Some("stalwart".to_string()), + credentials: Default::default(), + } + } +} + +impl IntoValue for NatsCoordinator { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::Addresses, self.addresses.into_value()); + map.insert_unchecked(Property::MaxReconnects, self.max_reconnects.into_value()); + map.insert_unchecked( + Property::TimeoutConnection, + self.timeout_connection.into_value(), + ); + map.insert_unchecked(Property::TimeoutRequest, self.timeout_request.into_value()); + map.insert_unchecked(Property::PingInterval, self.ping_interval.into_value()); + map.insert_unchecked(Property::CapacityClient, self.capacity_client.into_value()); + map.insert_unchecked( + Property::CapacityReadBuffer, + self.capacity_read_buffer.into_value(), + ); + map.insert_unchecked( + Property::CapacitySubscription, + self.capacity_subscription.into_value(), + ); + map.insert_unchecked(Property::NoEcho, self.no_echo.into_value()); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::Credentials, self.credentials.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for NatsCoordinator { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Addresses) => self.addresses.patch(pointer, value), + Some(Property::MaxReconnects) => self.max_reconnects.patch(pointer, value), + Some(Property::TimeoutConnection) => self.timeout_connection.patch(pointer, value), + Some(Property::TimeoutRequest) => self.timeout_request.patch(pointer, value), + Some(Property::PingInterval) => self.ping_interval.patch(pointer, value), + Some(Property::CapacityClient) => self.capacity_client.patch(pointer, value), + Some(Property::CapacityReadBuffer) => self.capacity_read_buffer.patch(pointer, value), + Some(Property::CapacitySubscription) => { + self.capacity_subscription.patch(pointer, value) + } + Some(Property::NoEcho) => self.no_echo.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Credentials) => self.credentials.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for NetworkListener { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::NetworkListener; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.bind; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Bind, value)); + } + } + if value.len() < 1 { + errors.push(ValidationError::min_items(Property::Bind, 1)); + } + let value = &self.override_proxy_trusted_networks; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid( + Property::OverrideProxyTrustedNetworks, + value, + )); + } + } + if let Some(value) = &self.socket_backlog { + if *value < 1 { + errors.push(ValidationError::min_value(Property::SocketBacklog, 1)); + } + } + if let Some(value) = &self.socket_receive_buffer_size { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::SocketReceiveBufferSize, + 1, + )); + } + } + if let Some(value) = &self.socket_send_buffer_size { + if *value < 1 { + errors.push(ValidationError::min_value( + Property::SocketSendBufferSize, + 1, + )); + } + } + if let Some(value) = &self.socket_tos_v4 { + if *value < 1 { + errors.push(ValidationError::min_value(Property::SocketTosV4, 1)); + } + } + if let Some(value) = &self.socket_ttl { + if *value < 1 { + errors.push(ValidationError::min_value(Property::SocketTtl, 1)); + } + } + if let Some(value) = &self.max_connections { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxConnections, 1)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for NetworkListener { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.bind.pickle(out); + self.protocol.pickle(out); + self.override_proxy_trusted_networks.pickle(out); + self.socket_backlog.pickle(out); + self.socket_no_delay.pickle(out); + self.socket_receive_buffer_size.pickle(out); + self.socket_reuse_address.pickle(out); + self.socket_reuse_port.pickle(out); + self.socket_send_buffer_size.pickle(out); + self.socket_tos_v4.pickle(out); + self.socket_ttl.pickle(out); + self.use_tls.pickle(out); + self.tls_disable_cipher_suites.pickle(out); + self.tls_disable_protocols.pickle(out); + self.tls_ignore_client_order.pickle(out); + self.tls_implicit.pickle(out); + self.tls_timeout.pickle(out); + self.max_connections.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.bind = Pickle::unpickle(stream)?; + this.protocol = Pickle::unpickle(stream)?; + this.override_proxy_trusted_networks = Pickle::unpickle(stream)?; + this.socket_backlog = Pickle::unpickle(stream)?; + this.socket_no_delay = Pickle::unpickle(stream)?; + this.socket_receive_buffer_size = Pickle::unpickle(stream)?; + this.socket_reuse_address = Pickle::unpickle(stream)?; + this.socket_reuse_port = Pickle::unpickle(stream)?; + this.socket_send_buffer_size = Pickle::unpickle(stream)?; + this.socket_tos_v4 = Pickle::unpickle(stream)?; + this.socket_ttl = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.tls_disable_cipher_suites = Pickle::unpickle(stream)?; + this.tls_disable_protocols = Pickle::unpickle(stream)?; + this.tls_ignore_client_order = Pickle::unpickle(stream)?; + this.tls_implicit = Pickle::unpickle(stream)?; + this.tls_timeout = Pickle::unpickle(stream)?; + this.max_connections = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for NetworkListener { + fn default() -> Self { + Self { + name: Default::default(), + bind: Default::default(), + protocol: NetworkListenerProtocol::Smtp, + override_proxy_trusted_networks: Default::default(), + socket_backlog: Some(1024u64), + socket_no_delay: true, + socket_receive_buffer_size: Default::default(), + socket_reuse_address: true, + socket_reuse_port: true, + socket_send_buffer_size: Default::default(), + socket_tos_v4: Default::default(), + socket_ttl: Default::default(), + use_tls: true, + tls_disable_cipher_suites: Default::default(), + tls_disable_protocols: Default::default(), + tls_ignore_client_order: true, + tls_implicit: false, + tls_timeout: Some(Duration::from_millis(60000)), + max_connections: Some(8192u64), + } + } +} + +impl IntoValue for NetworkListener { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(21); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Bind, self.bind.into_value()); + map.insert_unchecked(Property::Protocol, self.protocol.into_value()); + map.insert_unchecked( + Property::OverrideProxyTrustedNetworks, + self.override_proxy_trusted_networks.into_value(), + ); + map.insert_unchecked(Property::SocketBacklog, self.socket_backlog.into_value()); + map.insert_unchecked(Property::SocketNoDelay, self.socket_no_delay.into_value()); + map.insert_unchecked( + Property::SocketReceiveBufferSize, + self.socket_receive_buffer_size.into_value(), + ); + map.insert_unchecked( + Property::SocketReuseAddress, + self.socket_reuse_address.into_value(), + ); + map.insert_unchecked( + Property::SocketReusePort, + self.socket_reuse_port.into_value(), + ); + map.insert_unchecked( + Property::SocketSendBufferSize, + self.socket_send_buffer_size.into_value(), + ); + map.insert_unchecked(Property::SocketTosV4, self.socket_tos_v4.into_value()); + map.insert_unchecked(Property::SocketTtl, self.socket_ttl.into_value()); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked( + Property::TlsDisableCipherSuites, + self.tls_disable_cipher_suites.into_value(), + ); + map.insert_unchecked( + Property::TlsDisableProtocols, + self.tls_disable_protocols.into_value(), + ); + map.insert_unchecked( + Property::TlsIgnoreClientOrder, + self.tls_ignore_client_order.into_value(), + ); + map.insert_unchecked(Property::TlsImplicit, self.tls_implicit.into_value()); + map.insert_unchecked(Property::TlsTimeout, self.tls_timeout.into_value()); + map.insert_unchecked(Property::MaxConnections, self.max_connections.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for NetworkListener { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Bind) => self + .bind + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Protocol) => self.protocol.patch(pointer, value), + Some(Property::OverrideProxyTrustedNetworks) => self + .override_proxy_trusted_networks + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SocketBacklog) => self.socket_backlog.patch(pointer, value), + Some(Property::SocketNoDelay) => self.socket_no_delay.patch(pointer, value), + Some(Property::SocketReceiveBufferSize) => { + self.socket_receive_buffer_size.patch(pointer, value) + } + Some(Property::SocketReuseAddress) => self.socket_reuse_address.patch(pointer, value), + Some(Property::SocketReusePort) => self.socket_reuse_port.patch(pointer, value), + Some(Property::SocketSendBufferSize) => { + self.socket_send_buffer_size.patch(pointer, value) + } + Some(Property::SocketTosV4) => self.socket_tos_v4.patch(pointer, value), + Some(Property::SocketTtl) => self.socket_ttl.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::TlsDisableCipherSuites) => { + self.tls_disable_cipher_suites.patch(pointer, value) + } + Some(Property::TlsDisableProtocols) => self.tls_disable_protocols.patch(pointer, value), + Some(Property::TlsIgnoreClientOrder) => { + self.tls_ignore_client_order.patch(pointer, value) + } + Some(Property::TlsImplicit) => self.tls_implicit.patch(pointer, value), + Some(Property::TlsTimeout) => self.tls_timeout.patch(pointer, value), + Some(Property::MaxConnections) => self.max_connections.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for OAuthClient { + const FLAGS: u64 = OBJ_FILTER_TENANT | OBJ_SEQ_ID; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::OAuthClient; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.client_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::ClientId)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.contacts; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Contacts)); + } + } + if let Some(value) = &self.secret { + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.redirect_uris; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::RedirectUris)); + } + } + if let Some(value) = &self.logo { + if value.is_empty() { + errors.push(ValidationError::required(Property::Logo)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::ClientId, &self.client_id); + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + for value in self.contacts.iter() { + i.text(Property::Text, value); + } + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for OAuthClient { + fn pickle(&self, out: &mut Vec) { + self.client_id.pickle(out); + self.description.pickle(out); + self.contacts.pickle(out); + self.secret.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + self.member_tenant_id.pickle(out); + self.redirect_uris.pickle(out); + self.logo.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.client_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.contacts = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.redirect_uris = Pickle::unpickle(stream)?; + this.logo = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for OAuthClient { + fn default() -> Self { + Self { + client_id: Default::default(), + description: Default::default(), + contacts: Default::default(), + secret: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + member_tenant_id: Default::default(), + redirect_uris: Default::default(), + logo: Default::default(), + } + } +} + +impl IntoValue for OAuthClient { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::ClientId, self.client_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Contacts, self.contacts.into_value()); + if self.secret.is_some() { + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + } + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::RedirectUris, self.redirect_uris.into_value()); + map.insert_unchecked(Property::Logo, self.logo.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for OAuthClient { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ClientId) => self + .client_id + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Contacts) => self + .contacts + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::RedirectUris) => self.redirect_uris.patch(pointer, value), + Some(Property::Logo) => self.logo.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl OidcDirectory { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.issuer_url; + if value.is_empty() { + errors.push(ValidationError::required(Property::IssuerUrl)); + } + if let Some(value) = &self.require_audience { + if value.is_empty() { + errors.push(ValidationError::required(Property::RequireAudience)); + } + } + let value = &self.require_scopes; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::RequireScopes)); + } + } + let value = &self.claim_username; + if value.is_empty() { + errors.push(ValidationError::required(Property::ClaimUsername)); + } + if let Some(value) = &self.username_domain { + if value.is_empty() { + errors.push(ValidationError::required(Property::UsernameDomain)); + } + } + if let Some(value) = &self.claim_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::ClaimName)); + } + } + if let Some(value) = &self.claim_groups { + if value.is_empty() { + errors.push(ValidationError::required(Property::ClaimGroups)); + } + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for OidcDirectory { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.issuer_url.pickle(out); + self.require_audience.pickle(out); + self.require_scopes.pickle(out); + self.claim_username.pickle(out); + self.username_domain.pickle(out); + self.claim_name.pickle(out); + self.claim_groups.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.issuer_url = Pickle::unpickle(stream)?; + this.require_audience = Pickle::unpickle(stream)?; + this.require_scopes = Pickle::unpickle(stream)?; + this.claim_username = Pickle::unpickle(stream)?; + this.username_domain = Pickle::unpickle(stream)?; + this.claim_name = Pickle::unpickle(stream)?; + this.claim_groups = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for OidcDirectory { + fn default() -> Self { + Self { + description: Default::default(), + issuer_url: Default::default(), + require_audience: Default::default(), + require_scopes: Map::new(vec!["openid".to_string(), "email".to_string()]), + claim_username: "preferred_username".to_string(), + username_domain: Default::default(), + claim_name: Some("name".to_string()), + claim_groups: Default::default(), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for OidcDirectory { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::IssuerUrl, self.issuer_url.into_value()); + map.insert_unchecked( + Property::RequireAudience, + self.require_audience.into_value(), + ); + map.insert_unchecked(Property::RequireScopes, self.require_scopes.into_value()); + map.insert_unchecked(Property::ClaimUsername, self.claim_username.into_value()); + map.insert_unchecked(Property::UsernameDomain, self.username_domain.into_value()); + map.insert_unchecked(Property::ClaimName, self.claim_name.into_value()); + map.insert_unchecked(Property::ClaimGroups, self.claim_groups.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for OidcDirectory { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::IssuerUrl) => self + .issuer_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::RequireAudience) => self + .require_audience + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::RequireScopes) => self + .require_scopes + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClaimUsername) => self + .claim_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::UsernameDomain) => self.username_domain.patch(pointer, value), + Some(Property::ClaimName) => self + .claim_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ClaimGroups) => self + .claim_groups + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for OidcProvider { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::OidcProvider; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.auth_code_max_attempts; + if *value < 1 { + errors.push(ValidationError::min_value(Property::AuthCodeMaxAttempts, 1)); + } + if *value > 1000 { + errors.push(ValidationError::max_value( + Property::AuthCodeMaxAttempts, + 1000, + )); + } + let value = &self.encryption_key; + value.validate(errors); + let value = &self.signature_key; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for OidcProvider { + fn pickle(&self, out: &mut Vec) { + self.auth_code_max_attempts.pickle(out); + self.anonymous_client_registration.pickle(out); + self.require_client_registration.pickle(out); + self.auth_code_expiry.pickle(out); + self.refresh_token_expiry.pickle(out); + self.refresh_token_renewal.pickle(out); + self.access_token_expiry.pickle(out); + self.user_code_expiry.pickle(out); + self.id_token_expiry.pickle(out); + self.encryption_key.pickle(out); + self.signature_algorithm.pickle(out); + self.signature_key.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.auth_code_max_attempts = Pickle::unpickle(stream)?; + this.anonymous_client_registration = Pickle::unpickle(stream)?; + this.require_client_registration = Pickle::unpickle(stream)?; + this.auth_code_expiry = Pickle::unpickle(stream)?; + this.refresh_token_expiry = Pickle::unpickle(stream)?; + this.refresh_token_renewal = Pickle::unpickle(stream)?; + this.access_token_expiry = Pickle::unpickle(stream)?; + this.user_code_expiry = Pickle::unpickle(stream)?; + this.id_token_expiry = Pickle::unpickle(stream)?; + this.encryption_key = Pickle::unpickle(stream)?; + this.signature_algorithm = Pickle::unpickle(stream)?; + this.signature_key = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for OidcProvider { + fn default() -> Self { + Self { + auth_code_max_attempts: 3u64, + anonymous_client_registration: true, + require_client_registration: false, + auth_code_expiry: Duration::from_millis(600000), + refresh_token_expiry: Duration::from_millis(2592000000), + refresh_token_renewal: Duration::from_millis(345600000), + access_token_expiry: Duration::from_millis(3600000), + user_code_expiry: Duration::from_millis(1800000), + id_token_expiry: Duration::from_millis(900000), + encryption_key: Default::default(), + signature_algorithm: JwtSignatureAlgorithm::Hs256, + signature_key: Default::default(), + } + } +} + +impl IntoValue for OidcProvider { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked( + Property::AuthCodeMaxAttempts, + self.auth_code_max_attempts.into_value(), + ); + map.insert_unchecked( + Property::AnonymousClientRegistration, + self.anonymous_client_registration.into_value(), + ); + map.insert_unchecked( + Property::RequireClientRegistration, + self.require_client_registration.into_value(), + ); + map.insert_unchecked(Property::AuthCodeExpiry, self.auth_code_expiry.into_value()); + map.insert_unchecked( + Property::RefreshTokenExpiry, + self.refresh_token_expiry.into_value(), + ); + map.insert_unchecked( + Property::RefreshTokenRenewal, + self.refresh_token_renewal.into_value(), + ); + map.insert_unchecked( + Property::AccessTokenExpiry, + self.access_token_expiry.into_value(), + ); + map.insert_unchecked(Property::UserCodeExpiry, self.user_code_expiry.into_value()); + map.insert_unchecked(Property::IdTokenExpiry, self.id_token_expiry.into_value()); + map.insert_unchecked(Property::EncryptionKey, self.encryption_key.into_value()); + map.insert_unchecked( + Property::SignatureAlgorithm, + self.signature_algorithm.into_value(), + ); + map.insert_unchecked(Property::SignatureKey, self.signature_key.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for OidcProvider { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AuthCodeMaxAttempts) => { + self.auth_code_max_attempts.patch(pointer, value) + } + Some(Property::AnonymousClientRegistration) => { + self.anonymous_client_registration.patch(pointer, value) + } + Some(Property::RequireClientRegistration) => { + self.require_client_registration.patch(pointer, value) + } + Some(Property::AuthCodeExpiry) => self.auth_code_expiry.patch(pointer, value), + Some(Property::RefreshTokenExpiry) => self.refresh_token_expiry.patch(pointer, value), + Some(Property::RefreshTokenRenewal) => self.refresh_token_renewal.patch(pointer, value), + Some(Property::AccessTokenExpiry) => self.access_token_expiry.patch(pointer, value), + Some(Property::UserCodeExpiry) => self.user_code_expiry.patch(pointer, value), + Some(Property::IdTokenExpiry) => self.id_token_expiry.patch(pointer, value), + Some(Property::EncryptionKey) => self.encryption_key.patch(pointer, value), + Some(Property::SignatureAlgorithm) => self.signature_algorithm.patch(pointer, value), + Some(Property::SignatureKey) => self.signature_key.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl OtpAuth { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.otp_code { + if value.is_empty() { + errors.push(ValidationError::required(Property::OtpCode)); + } + } + if let Some(value) = &self.otp_url { + if value.is_empty() { + errors.push(ValidationError::required(Property::OtpUrl)); + } + } + errors.len() == neb + } +} + +impl Pickle for OtpAuth { + fn pickle(&self, out: &mut Vec) { + self.otp_code.pickle(out); + self.otp_url.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.otp_code = Pickle::unpickle(stream)?; + this.otp_url = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for OtpAuth { + fn default() -> Self { + Self { + otp_code: Default::default(), + otp_url: Default::default(), + } + } +} + +impl IntoValue for OtpAuth { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + if self.otp_code.is_some() { + map.insert_unchecked(Property::OtpCode, JmapValue::Str(MASKED_PASSWORD.into())); + } + if self.otp_url.is_some() { + map.insert_unchecked(Property::OtpUrl, JmapValue::Str(MASKED_PASSWORD.into())); + } + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for OtpAuth { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::OtpCode) => self.otp_code.patch(pointer, value), + Some(Property::OtpUrl) => self.otp_url.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl PasswordCredential { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.credential_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CredentialId, value)); + } + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + if let Some(value) = &self.otp_auth { + if value.is_empty() { + errors.push(ValidationError::required(Property::OtpAuth)); + } + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + let value = &self.allowed_ips; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::AllowedIps, value)); + } + } + errors.len() == neb + } +} + +impl Pickle for PasswordCredential { + fn pickle(&self, out: &mut Vec) { + self.credential_id.pickle(out); + self.secret.pickle(out); + self.otp_auth.pickle(out); + self.expires_at.pickle(out); + self.allowed_ips.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.credential_id = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.otp_auth = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.allowed_ips = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PasswordCredential { + fn default() -> Self { + Self { + credential_id: Default::default(), + secret: Default::default(), + otp_auth: Default::default(), + expires_at: Default::default(), + allowed_ips: Default::default(), + } + } +} + +impl IntoValue for PasswordCredential { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::CredentialId, self.credential_id.into_value()); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + if self.otp_auth.is_some() { + map.insert_unchecked(Property::OtpAuth, JmapValue::Str(MASKED_PASSWORD.into())); + } + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::AllowedIps, self.allowed_ips.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PasswordCredential { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CredentialId) => pointer.assert_server_set(), + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::OtpAuth) => self.otp_auth.patch(pointer, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::AllowedIps) => self.allowed_ips.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Permissions { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Permissions::Inherit => true, + Permissions::Merge(inner) => inner.validate(errors), + Permissions::Replace(inner) => inner.validate(errors), + } + } +} + +impl Default for Permissions { + fn default() -> Self { + Permissions::Inherit + } +} + +impl Pickle for Permissions { + fn pickle(&self, out: &mut Vec) { + match self { + Permissions::Inherit => { + 0u16.pickle(out); + } + Permissions::Merge(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Permissions::Replace(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(Permissions::Inherit), + 1 => Pickle::unpickle(stream).map(Permissions::Merge), + 2 => Pickle::unpickle(stream).map(Permissions::Replace), + _ => None, + } + } +} + +impl IntoValue for Permissions { + fn into_value(self) -> JmapValue<'static> { + match self { + Permissions::Inherit => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Inherit".into())); + JmapValue::Object(obj) + } + Permissions::Merge(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Merge".into())); + obj + } + Permissions::Replace(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Replace".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Permissions { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + PermissionsType::Inherit => *self = Permissions::Inherit, + PermissionsType::Merge => *self = Permissions::Merge(Default::default()), + PermissionsType::Replace => *self = Permissions::Replace(Default::default()), + } + } + match self { + Permissions::Inherit => pointer.assert_eof(), + Permissions::Merge(inner) => inner.patch(pointer, value), + Permissions::Replace(inner) => inner.patch(pointer, value), + } + } +} + +impl Permissions { + pub fn object_type(&self) -> PermissionsType { + match self { + Permissions::Inherit => PermissionsType::Inherit, + Permissions::Merge(_) => PermissionsType::Merge, + Permissions::Replace(_) => PermissionsType::Replace, + } + } +} + +impl PermissionsList { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for PermissionsList { + fn pickle(&self, out: &mut Vec) { + self.enabled_permissions.pickle(out); + self.disabled_permissions.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enabled_permissions = Pickle::unpickle(stream)?; + this.disabled_permissions = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PermissionsList { + fn default() -> Self { + Self { + enabled_permissions: Default::default(), + disabled_permissions: Default::default(), + } + } +} + +impl IntoValue for PermissionsList { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked( + Property::EnabledPermissions, + self.enabled_permissions.into_value(), + ); + map.insert_unchecked( + Property::DisabledPermissions, + self.disabled_permissions.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PermissionsList { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::EnabledPermissions) => self.enabled_permissions.patch(pointer, value), + Some(Property::DisabledPermissions) => self.disabled_permissions.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl PostgreSqlSettings { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.database; + if value.is_empty() { + errors.push(ValidationError::required(Property::Database)); + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.options { + if value.is_empty() { + errors.push(ValidationError::required(Property::Options)); + } + } + errors.len() == neb + } +} + +impl Pickle for PostgreSqlSettings { + fn pickle(&self, out: &mut Vec) { + self.host.pickle(out); + self.port.pickle(out); + self.database.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + self.options.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.database = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.options = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PostgreSqlSettings { + fn default() -> Self { + Self { + host: Default::default(), + port: 5432u64, + database: "stalwart".to_string(), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + options: Default::default(), + } + } +} + +impl IntoValue for PostgreSqlSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Database, self.database.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::Options, self.options.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PostgreSqlSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Database) => self + .database + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::Options) => self + .options + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl PostgreSqlStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.pool_max_connections { + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + } + let value = &self.read_replicas; + for value in value.values() { + value.validate(errors); + } + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::Port, 1)); + } + let value = &self.database; + if value.is_empty() { + errors.push(ValidationError::required(Property::Database)); + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.options { + if value.is_empty() { + errors.push(ValidationError::required(Property::Options)); + } + } + errors.len() == neb + } +} + +impl Pickle for PostgreSqlStore { + fn pickle(&self, out: &mut Vec) { + self.timeout.pickle(out); + self.use_tls.pickle(out); + self.allow_invalid_certs.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_recycling_method.pickle(out); + self.read_replicas.pickle(out); + self.host.pickle(out); + self.port.pickle(out); + self.database.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + self.options.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.timeout = Pickle::unpickle(stream)?; + this.use_tls = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_recycling_method = Pickle::unpickle(stream)?; + this.read_replicas = Pickle::unpickle(stream)?; + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.database = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.options = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PostgreSqlStore { + fn default() -> Self { + Self { + timeout: Some(Duration::from_millis(15000)), + use_tls: false, + allow_invalid_certs: false, + pool_max_connections: Some(10u64), + pool_recycling_method: PostgreSqlRecyclingMethod::Fast, + read_replicas: Default::default(), + host: Default::default(), + port: 5432u64, + database: "stalwart".to_string(), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + options: Default::default(), + } + } +} + +impl IntoValue for PostgreSqlStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::UseTls, self.use_tls.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolRecyclingMethod, + self.pool_recycling_method.into_value(), + ); + map.insert_unchecked(Property::ReadReplicas, self.read_replicas.into_value()); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Database, self.database.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::Options, self.options.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PostgreSqlStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::UseTls) => self.use_tls.patch(pointer, value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolRecyclingMethod) => self.pool_recycling_method.patch(pointer, value), + Some(Property::ReadReplicas) => self.read_replicas.patch(pointer, value), + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Database) => self + .database + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::Options) => self + .options + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for PublicKey { + const FLAGS: u64 = OBJ_FILTER_ACCOUNT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::PublicKey; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.key; + if value.is_empty() { + errors.push(ValidationError::required(Property::Key)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + let value = &self.email_addresses; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::EmailAddresses)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.search(Property::AccountId, &self.account_id); + } +} + +impl Pickle for PublicKey { + fn pickle(&self, out: &mut Vec) { + self.account_id.pickle(out); + self.key.pickle(out); + self.description.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + self.email_addresses.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.account_id = Pickle::unpickle(stream)?; + this.key = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.email_addresses = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PublicKey { + fn default() -> Self { + Self { + account_id: Default::default(), + key: Default::default(), + description: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + email_addresses: Default::default(), + } + } +} + +impl IntoValue for PublicKey { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::EmailAddresses, self.email_addresses.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PublicKey { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::Key) => self + .key + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::EmailAddresses) => self + .email_addresses + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl PublicStringOptional { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + PublicStringOptional::None => true, + PublicStringOptional::Value(inner) => inner.validate(errors), + PublicStringOptional::EnvironmentVariable(inner) => inner.validate(errors), + PublicStringOptional::File(inner) => inner.validate(errors), + } + } +} + +impl Default for PublicStringOptional { + fn default() -> Self { + PublicStringOptional::None + } +} + +impl Pickle for PublicStringOptional { + fn pickle(&self, out: &mut Vec) { + match self { + PublicStringOptional::None => { + 0u16.pickle(out); + } + PublicStringOptional::Value(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + PublicStringOptional::EnvironmentVariable(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + PublicStringOptional::File(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(PublicStringOptional::None), + 1 => Pickle::unpickle(stream).map(PublicStringOptional::Value), + 2 => Pickle::unpickle(stream).map(PublicStringOptional::EnvironmentVariable), + 3 => Pickle::unpickle(stream).map(PublicStringOptional::File), + _ => None, + } + } +} + +impl IntoValue for PublicStringOptional { + fn into_value(self) -> JmapValue<'static> { + match self { + PublicStringOptional::None => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("None".into())); + JmapValue::Object(obj) + } + PublicStringOptional::Value(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Value".into())); + obj + } + PublicStringOptional::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + PublicStringOptional::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for PublicStringOptional { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + PublicStringOptionalType::None => *self = PublicStringOptional::None, + PublicStringOptionalType::Value => { + *self = PublicStringOptional::Value(Default::default()) + } + PublicStringOptionalType::EnvironmentVariable => { + *self = PublicStringOptional::EnvironmentVariable(Default::default()) + } + PublicStringOptionalType::File => { + *self = PublicStringOptional::File(Default::default()) + } + } + } + match self { + PublicStringOptional::None => pointer.assert_eof(), + PublicStringOptional::Value(inner) => inner.patch(pointer, value), + PublicStringOptional::EnvironmentVariable(inner) => inner.patch(pointer, value), + PublicStringOptional::File(inner) => inner.patch(pointer, value), + } + } +} + +impl PublicStringOptional { + pub fn object_type(&self) -> PublicStringOptionalType { + match self { + PublicStringOptional::None => PublicStringOptionalType::None, + PublicStringOptional::Value(_) => PublicStringOptionalType::Value, + PublicStringOptional::EnvironmentVariable(_) => { + PublicStringOptionalType::EnvironmentVariable + } + PublicStringOptional::File(_) => PublicStringOptionalType::File, + } + } +} + +impl PublicStringValue { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + if value.is_empty() { + errors.push(ValidationError::required(Property::Value)); + } + errors.len() == neb + } +} + +impl Pickle for PublicStringValue { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PublicStringValue { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for PublicStringValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PublicStringValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self + .value + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl PublicText { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + PublicText::Text(inner) => inner.validate(errors), + PublicText::EnvironmentVariable(inner) => inner.validate(errors), + PublicText::File(inner) => inner.validate(errors), + } + } +} + +impl Default for PublicText { + fn default() -> Self { + PublicText::Text(Default::default()) + } +} + +impl Pickle for PublicText { + fn pickle(&self, out: &mut Vec) { + match self { + PublicText::Text(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + PublicText::EnvironmentVariable(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + PublicText::File(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(PublicText::Text), + 1 => Pickle::unpickle(stream).map(PublicText::EnvironmentVariable), + 2 => Pickle::unpickle(stream).map(PublicText::File), + _ => None, + } + } +} + +impl IntoValue for PublicText { + fn into_value(self) -> JmapValue<'static> { + match self { + PublicText::Text(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Text".into())); + obj + } + PublicText::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + PublicText::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for PublicText { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + PublicTextType::Text => *self = PublicText::Text(Default::default()), + PublicTextType::EnvironmentVariable => { + *self = PublicText::EnvironmentVariable(Default::default()) + } + PublicTextType::File => *self = PublicText::File(Default::default()), + } + } + match self { + PublicText::Text(inner) => inner.patch(pointer, value), + PublicText::EnvironmentVariable(inner) => inner.patch(pointer, value), + PublicText::File(inner) => inner.patch(pointer, value), + } + } +} + +impl PublicText { + pub fn object_type(&self) -> PublicTextType { + match self { + PublicText::Text(_) => PublicTextType::Text, + PublicText::EnvironmentVariable(_) => PublicTextType::EnvironmentVariable, + PublicText::File(_) => PublicTextType::File, + } + } +} + +impl PublicTextValue { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + if value.is_empty() { + errors.push(ValidationError::required(Property::Value)); + } + errors.len() == neb + } +} + +impl Pickle for PublicTextValue { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for PublicTextValue { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for PublicTextValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for PublicTextValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl QueueExpiry { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + QueueExpiry::Ttl(inner) => inner.validate(errors), + QueueExpiry::Attempts(inner) => inner.validate(errors), + } + } +} + +impl Default for QueueExpiry { + fn default() -> Self { + QueueExpiry::Ttl(Default::default()) + } +} + +impl Pickle for QueueExpiry { + fn pickle(&self, out: &mut Vec) { + match self { + QueueExpiry::Ttl(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + QueueExpiry::Attempts(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(QueueExpiry::Ttl), + 1 => Pickle::unpickle(stream).map(QueueExpiry::Attempts), + _ => None, + } + } +} + +impl IntoValue for QueueExpiry { + fn into_value(self) -> JmapValue<'static> { + match self { + QueueExpiry::Ttl(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ttl".into())); + obj + } + QueueExpiry::Attempts(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Attempts".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for QueueExpiry { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + QueueExpiryType::Ttl => *self = QueueExpiry::Ttl(Default::default()), + QueueExpiryType::Attempts => *self = QueueExpiry::Attempts(Default::default()), + } + } + match self { + QueueExpiry::Ttl(inner) => inner.patch(pointer, value), + QueueExpiry::Attempts(inner) => inner.patch(pointer, value), + } + } +} + +impl QueueExpiry { + pub fn object_type(&self) -> QueueExpiryType { + match self { + QueueExpiry::Ttl(_) => QueueExpiryType::Ttl, + QueueExpiry::Attempts(_) => QueueExpiryType::Attempts, + } + } +} + +impl QueueExpiryAttempts { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for QueueExpiryAttempts { + fn pickle(&self, out: &mut Vec) { + self.expires_attempts.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.expires_attempts = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for QueueExpiryAttempts { + fn default() -> Self { + Self { + expires_attempts: 0u64, + } + } +} + +impl IntoValue for QueueExpiryAttempts { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked( + Property::ExpiresAttempts, + self.expires_attempts.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for QueueExpiryAttempts { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ExpiresAttempts) => self.expires_attempts.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl QueueExpiryTtl { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.expires_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + errors.len() == neb + } +} + +impl Pickle for QueueExpiryTtl { + fn pickle(&self, out: &mut Vec) { + self.expires_at.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.expires_at = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for QueueExpiryTtl { + fn default() -> Self { + Self { + expires_at: Default::default(), + } + } +} + +impl IntoValue for QueueExpiryTtl { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for QueueExpiryTtl { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for QueuedMessage { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::QueuedMessage; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.next_retry { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NextRetry, value)); + } + } + if let Some(value) = &self.next_notify { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NextNotify, value)); + } + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + let value = &self.return_path; + if value.is_empty() { + errors.push(ValidationError::required(Property::ReturnPath)); + } + let value = &self.recipients; + for value in value.values() { + value.validate(errors); + } + let value = &self.received_from_ip; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivedFromIp, value)); + } + let value = &self.received_via_port; + if *value < 1 { + errors.push(ValidationError::min_value(Property::ReceivedViaPort, 1)); + } + if *value > 65535 { + errors.push(ValidationError::max_value(Property::ReceivedViaPort, 65535)); + } + if let Some(value) = &self.env_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::EnvId)); + } + } + let value = &self.priority; + if *value < (-100) { + errors.push(ValidationError::min_value(Property::Priority, -100)); + } + if *value > (100) { + errors.push(ValidationError::max_value(Property::Priority, 100)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for QueuedMessage { + fn pickle(&self, out: &mut Vec) { + self.created_at.pickle(out); + self.next_retry.pickle(out); + self.next_notify.pickle(out); + self.blob_id.pickle(out); + self.return_path.pickle(out); + self.recipients.pickle(out); + self.received_from_ip.pickle(out); + self.received_via_port.pickle(out); + self.flags.pickle(out); + self.env_id.pickle(out); + self.priority.pickle(out); + self.size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.created_at = Pickle::unpickle(stream)?; + this.next_retry = Pickle::unpickle(stream)?; + this.next_notify = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + this.return_path = Pickle::unpickle(stream)?; + this.recipients = Pickle::unpickle(stream)?; + this.received_from_ip = Pickle::unpickle(stream)?; + this.received_via_port = Pickle::unpickle(stream)?; + this.flags = Pickle::unpickle(stream)?; + this.env_id = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + this.size = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for QueuedMessage { + fn default() -> Self { + Self { + created_at: Default::default(), + next_retry: Default::default(), + next_notify: Default::default(), + blob_id: Default::default(), + return_path: Default::default(), + recipients: Default::default(), + received_from_ip: Default::default(), + received_via_port: 25u64, + flags: Default::default(), + env_id: Default::default(), + priority: 0i64, + size: 0u64, + } + } +} + +impl IntoValue for QueuedMessage { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::NextRetry, self.next_retry.into_value()); + map.insert_unchecked(Property::NextNotify, self.next_notify.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + map.insert_unchecked(Property::ReturnPath, self.return_path.into_value()); + map.insert_unchecked(Property::Recipients, self.recipients.into_value()); + map.insert_unchecked(Property::ReceivedFromIp, self.received_from_ip.into_value()); + map.insert_unchecked( + Property::ReceivedViaPort, + self.received_via_port.into_value(), + ); + map.insert_unchecked(Property::Flags, self.flags.into_value()); + map.insert_unchecked(Property::EnvId, self.env_id.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + map.insert_unchecked(Property::Size, self.size.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for QueuedMessage { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::NextRetry) => self.next_retry.patch(pointer, value), + Some(Property::NextNotify) => pointer.assert_server_set(), + Some(Property::BlobId) => pointer.assert_server_set(), + Some(Property::ReturnPath) => pointer.assert_server_set(), + Some(Property::Recipients) => self.recipients.patch(pointer, value), + Some(Property::ReceivedFromIp) => pointer.assert_server_set(), + Some(Property::ReceivedViaPort) => pointer.assert_server_set(), + Some(Property::Flags) => pointer.assert_server_set(), + Some(Property::EnvId) => self.env_id.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Size) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl QueuedRecipient { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.retry_due; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::RetryDue, value)); + } + let value = &self.notify_due; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::NotifyDue, value)); + } + let value = &self.expires; + value.validate(errors); + let value = &self.queue_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::QueueName)); + } + if value.len() > 8 { + errors.push(ValidationError::max_length(Property::QueueName, 8)); + } + let value = &self.status; + value.validate(errors); + if let Some(value) = &self.orcpt { + if value.is_empty() { + errors.push(ValidationError::required(Property::Orcpt)); + } + } + errors.len() == neb + } +} + +impl Pickle for QueuedRecipient { + fn pickle(&self, out: &mut Vec) { + self.retry_count.pickle(out); + self.retry_due.pickle(out); + self.notify_count.pickle(out); + self.notify_due.pickle(out); + self.expires.pickle(out); + self.queue_name.pickle(out); + self.status.pickle(out); + self.flags.pickle(out); + self.orcpt.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.retry_count = Pickle::unpickle(stream)?; + this.retry_due = Pickle::unpickle(stream)?; + this.notify_count = Pickle::unpickle(stream)?; + this.notify_due = Pickle::unpickle(stream)?; + this.expires = Pickle::unpickle(stream)?; + this.queue_name = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + this.flags = Pickle::unpickle(stream)?; + this.orcpt = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for QueuedRecipient { + fn default() -> Self { + Self { + retry_count: 0u64, + retry_due: Default::default(), + notify_count: 0u64, + notify_due: Default::default(), + expires: Default::default(), + queue_name: Default::default(), + status: Default::default(), + flags: Default::default(), + orcpt: Default::default(), + } + } +} + +impl IntoValue for QueuedRecipient { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::RetryCount, self.retry_count.into_value()); + map.insert_unchecked(Property::RetryDue, self.retry_due.into_value()); + map.insert_unchecked(Property::NotifyCount, self.notify_count.into_value()); + map.insert_unchecked(Property::NotifyDue, self.notify_due.into_value()); + map.insert_unchecked(Property::Expires, self.expires.into_value()); + map.insert_unchecked(Property::QueueName, self.queue_name.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + map.insert_unchecked(Property::Flags, self.flags.into_value()); + map.insert_unchecked(Property::Orcpt, self.orcpt.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for QueuedRecipient { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::RetryCount) => self.retry_count.patch(pointer, value), + Some(Property::RetryDue) => self.retry_due.patch(pointer, value), + Some(Property::NotifyCount) => self.notify_count.patch(pointer, value), + Some(Property::NotifyDue) => self.notify_due.patch(pointer, value), + Some(Property::Expires) => self.expires.patch(pointer, value), + Some(Property::QueueName) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Flags) => pointer.assert_server_set(), + Some(Property::Orcpt) => self.orcpt.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Rate { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.count; + if *value < 1 { + errors.push(ValidationError::min_value(Property::Count, 1)); + } + if *value > 1000000 { + errors.push(ValidationError::max_value(Property::Count, 1000000)); + } + let value = &self.period; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Period, value)); + } + if *value < Duration::from_millis(1) { + errors.push(ValidationError::min_value(Property::Period, 1)); + } + errors.len() == neb + } +} + +impl Pickle for Rate { + fn pickle(&self, out: &mut Vec) { + self.count.pickle(out); + self.period.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.count = Pickle::unpickle(stream)?; + this.period = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Rate { + fn default() -> Self { + Self { + count: 0u64, + period: Duration::from_millis(0), + } + } +} + +impl IntoValue for Rate { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Count, self.count.into_value()); + map.insert_unchecked(Property::Period, self.period.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Rate { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Count) => self.count.patch(pointer, value), + Some(Property::Period) => self.period.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl RecipientStatus { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + RecipientStatus::Scheduled => true, + RecipientStatus::Completed(inner) => inner.validate(errors), + RecipientStatus::TemporaryFailure(inner) => inner.validate(errors), + RecipientStatus::PermanentFailure(inner) => inner.validate(errors), + } + } +} + +impl Default for RecipientStatus { + fn default() -> Self { + RecipientStatus::Scheduled + } +} + +impl Pickle for RecipientStatus { + fn pickle(&self, out: &mut Vec) { + match self { + RecipientStatus::Scheduled => { + 0u16.pickle(out); + } + RecipientStatus::Completed(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + RecipientStatus::TemporaryFailure(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + RecipientStatus::PermanentFailure(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(RecipientStatus::Scheduled), + 1 => Pickle::unpickle(stream).map(RecipientStatus::Completed), + 2 => Pickle::unpickle(stream).map(RecipientStatus::TemporaryFailure), + 3 => Pickle::unpickle(stream).map(RecipientStatus::PermanentFailure), + _ => None, + } + } +} + +impl IntoValue for RecipientStatus { + fn into_value(self) -> JmapValue<'static> { + match self { + RecipientStatus::Scheduled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Scheduled".into())); + JmapValue::Object(obj) + } + RecipientStatus::Completed(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Completed".into())); + obj + } + RecipientStatus::TemporaryFailure(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TemporaryFailure".into())); + obj + } + RecipientStatus::PermanentFailure(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PermanentFailure".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for RecipientStatus { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + RecipientStatusType::Scheduled => *self = RecipientStatus::Scheduled, + RecipientStatusType::Completed => { + *self = RecipientStatus::Completed(Default::default()) + } + RecipientStatusType::TemporaryFailure => { + *self = RecipientStatus::TemporaryFailure(Default::default()) + } + RecipientStatusType::PermanentFailure => { + *self = RecipientStatus::PermanentFailure(Default::default()) + } + } + } + match self { + RecipientStatus::Scheduled => pointer.assert_eof(), + RecipientStatus::Completed(inner) => inner.patch(pointer, value), + RecipientStatus::TemporaryFailure(inner) => inner.patch(pointer, value), + RecipientStatus::PermanentFailure(inner) => inner.patch(pointer, value), + } + } +} + +impl RecipientStatus { + pub fn object_type(&self) -> RecipientStatusType { + match self { + RecipientStatus::Scheduled => RecipientStatusType::Scheduled, + RecipientStatus::Completed(_) => RecipientStatusType::Completed, + RecipientStatus::TemporaryFailure(_) => RecipientStatusType::TemporaryFailure, + RecipientStatus::PermanentFailure(_) => RecipientStatusType::PermanentFailure, + } + } +} + +impl RedisClusterStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.urls; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Urls)); + } + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.max_retry_wait { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::MaxRetryWait, value)); + } + if *value > Duration::from_millis(1024) { + errors.push(ValidationError::max_value(Property::MaxRetryWait, 1024)); + } + if *value < Duration::from_millis(1) { + errors.push(ValidationError::min_value(Property::MaxRetryWait, 1)); + } + } + if let Some(value) = &self.min_retry_wait { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::MinRetryWait, value)); + } + if *value > Duration::from_millis(1024) { + errors.push(ValidationError::max_value(Property::MinRetryWait, 1024)); + } + if *value < Duration::from_millis(1) { + errors.push(ValidationError::min_value(Property::MinRetryWait, 1)); + } + } + if let Some(value) = &self.max_retries { + if *value > 1024 { + errors.push(ValidationError::max_value(Property::MaxRetries, 1024)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRetries, 1)); + } + } + let value = &self.pool_max_connections; + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + errors.len() == neb + } +} + +impl Pickle for RedisClusterStore { + fn pickle(&self, out: &mut Vec) { + self.urls.pickle(out); + self.timeout.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + self.max_retry_wait.pickle(out); + self.min_retry_wait.pickle(out); + self.max_retries.pickle(out); + self.read_from_replicas.pickle(out); + self.protocol_version.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_timeout_create.pickle(out); + self.pool_timeout_wait.pickle(out); + self.pool_timeout_recycle.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.urls = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.max_retry_wait = Pickle::unpickle(stream)?; + this.min_retry_wait = Pickle::unpickle(stream)?; + this.max_retries = Pickle::unpickle(stream)?; + this.read_from_replicas = Pickle::unpickle(stream)?; + this.protocol_version = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_timeout_create = Pickle::unpickle(stream)?; + this.pool_timeout_wait = Pickle::unpickle(stream)?; + this.pool_timeout_recycle = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for RedisClusterStore { + fn default() -> Self { + Self { + urls: Map::new(vec!["redis://127.0.0.1".to_string()]), + timeout: Duration::from_millis(10000), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + max_retry_wait: Default::default(), + min_retry_wait: Default::default(), + max_retries: Default::default(), + read_from_replicas: true, + protocol_version: RedisProtocol::Resp2, + pool_max_connections: 10u64, + pool_timeout_create: Some(Duration::from_millis(30000)), + pool_timeout_wait: Some(Duration::from_millis(30000)), + pool_timeout_recycle: Some(Duration::from_millis(30000)), + } + } +} + +impl IntoValue for RedisClusterStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked(Property::Urls, self.urls.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked(Property::MaxRetryWait, self.max_retry_wait.into_value()); + map.insert_unchecked(Property::MinRetryWait, self.min_retry_wait.into_value()); + map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value()); + map.insert_unchecked( + Property::ReadFromReplicas, + self.read_from_replicas.into_value(), + ); + map.insert_unchecked( + Property::ProtocolVersion, + self.protocol_version.into_value(), + ); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutCreate, + self.pool_timeout_create.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutWait, + self.pool_timeout_wait.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutRecycle, + self.pool_timeout_recycle.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for RedisClusterStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Urls) => self + .urls + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::MaxRetryWait) => self.max_retry_wait.patch(pointer, value), + Some(Property::MinRetryWait) => self.min_retry_wait.patch(pointer, value), + Some(Property::MaxRetries) => self.max_retries.patch(pointer, value), + Some(Property::ReadFromReplicas) => self.read_from_replicas.patch(pointer, value), + Some(Property::ProtocolVersion) => self.protocol_version.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolTimeoutCreate) => self.pool_timeout_create.patch(pointer, value), + Some(Property::PoolTimeoutWait) => self.pool_timeout_wait.patch(pointer, value), + Some(Property::PoolTimeoutRecycle) => self.pool_timeout_recycle.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl RedisSentinelStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.urls; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Urls)); + } + } + let value = &self.service_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::ServiceName)); + } + if let Some(value) = &self.auth_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthUsername)); + } + } + let value = &self.auth_secret; + value.validate(errors); + if let Some(value) = &self.sentinel_username { + if value.is_empty() { + errors.push(ValidationError::required(Property::SentinelUsername)); + } + } + let value = &self.sentinel_secret; + value.validate(errors); + let value = &self.pool_max_connections; + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + errors.len() == neb + } +} + +impl Pickle for RedisSentinelStore { + fn pickle(&self, out: &mut Vec) { + self.urls.pickle(out); + self.service_name.pickle(out); + self.timeout.pickle(out); + self.auth_username.pickle(out); + self.auth_secret.pickle(out); + self.sentinel_username.pickle(out); + self.sentinel_secret.pickle(out); + self.protocol_version.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_timeout_create.pickle(out); + self.pool_timeout_wait.pickle(out); + self.pool_timeout_recycle.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.urls = Pickle::unpickle(stream)?; + this.service_name = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.auth_username = Pickle::unpickle(stream)?; + this.auth_secret = Pickle::unpickle(stream)?; + this.sentinel_username = Pickle::unpickle(stream)?; + this.sentinel_secret = Pickle::unpickle(stream)?; + this.protocol_version = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_timeout_create = Pickle::unpickle(stream)?; + this.pool_timeout_wait = Pickle::unpickle(stream)?; + this.pool_timeout_recycle = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for RedisSentinelStore { + fn default() -> Self { + Self { + urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]), + service_name: "mymaster".to_string(), + timeout: Duration::from_millis(10000), + auth_username: Some("stalwart".to_string()), + auth_secret: Default::default(), + sentinel_username: Default::default(), + sentinel_secret: Default::default(), + protocol_version: RedisProtocol::Resp2, + pool_max_connections: 10u64, + pool_timeout_create: Some(Duration::from_millis(30000)), + pool_timeout_wait: Some(Duration::from_millis(30000)), + pool_timeout_recycle: Some(Duration::from_millis(30000)), + } + } +} + +impl IntoValue for RedisSentinelStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Urls, self.urls.into_value()); + map.insert_unchecked(Property::ServiceName, self.service_name.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::AuthUsername, self.auth_username.into_value()); + map.insert_unchecked(Property::AuthSecret, self.auth_secret.into_value()); + map.insert_unchecked( + Property::SentinelUsername, + self.sentinel_username.into_value(), + ); + map.insert_unchecked(Property::SentinelSecret, self.sentinel_secret.into_value()); + map.insert_unchecked( + Property::ProtocolVersion, + self.protocol_version.into_value(), + ); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutCreate, + self.pool_timeout_create.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutWait, + self.pool_timeout_wait.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutRecycle, + self.pool_timeout_recycle.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for RedisSentinelStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Urls) => self + .urls + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ServiceName) => self + .service_name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AuthUsername) => self + .auth_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AuthSecret) => self.auth_secret.patch(pointer, value), + Some(Property::SentinelUsername) => self + .sentinel_username + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::SentinelSecret) => self.sentinel_secret.patch(pointer, value), + Some(Property::ProtocolVersion) => self.protocol_version.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolTimeoutCreate) => self.pool_timeout_create.patch(pointer, value), + Some(Property::PoolTimeoutWait) => self.pool_timeout_wait.patch(pointer, value), + Some(Property::PoolTimeoutRecycle) => self.pool_timeout_recycle.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl RedisStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.pool_max_connections; + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + errors.len() == neb + } +} + +impl Pickle for RedisStore { + fn pickle(&self, out: &mut Vec) { + self.url.pickle(out); + self.timeout.pickle(out); + self.pool_max_connections.pickle(out); + self.pool_timeout_create.pickle(out); + self.pool_timeout_wait.pickle(out); + self.pool_timeout_recycle.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.url = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + this.pool_timeout_create = Pickle::unpickle(stream)?; + this.pool_timeout_wait = Pickle::unpickle(stream)?; + this.pool_timeout_recycle = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for RedisStore { + fn default() -> Self { + Self { + url: "redis://127.0.0.1".to_string(), + timeout: Duration::from_millis(10000), + pool_max_connections: 10u64, + pool_timeout_create: Some(Duration::from_millis(30000)), + pool_timeout_wait: Some(Duration::from_millis(30000)), + pool_timeout_recycle: Some(Duration::from_millis(30000)), + } + } +} + +impl IntoValue for RedisStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutCreate, + self.pool_timeout_create.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutWait, + self.pool_timeout_wait.into_value(), + ); + map.insert_unchecked( + Property::PoolTimeoutRecycle, + self.pool_timeout_recycle.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for RedisStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::PoolTimeoutCreate) => self.pool_timeout_create.patch(pointer, value), + Some(Property::PoolTimeoutWait) => self.pool_timeout_wait.patch(pointer, value), + Some(Property::PoolTimeoutRecycle) => self.pool_timeout_recycle.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for ReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::ReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.inbound_report_addresses; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::InboundReportAddresses)); + } + } + if let Some(value) = &self.outbound_report_domain { + if value.is_empty() { + errors.push(ValidationError::required(Property::OutboundReportDomain)); + } + } + let value = &self.outbound_report_submitter; + value.validate(errors); + let value = &self.inbound_report_max_size; + if *value < (1024) { + errors.push(ValidationError::min_value( + Property::InboundReportMaxSize, + 1024, + )); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl ReportSettings { + pub fn ctx_outbound_report_submitter(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.outbound_report_submitter, + default: Some(Expression { + else_: "system('hostname')".to_string(), + ..Default::default() + }), + property: Property::OutboundReportSubmitter, + allowed_variables: MTA_RCPT_DOMAIN_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_outbound_report_submitter()] + } +} + +impl Pickle for ReportSettings { + fn pickle(&self, out: &mut Vec) { + self.inbound_report_addresses.pickle(out); + self.inbound_report_forwarding.pickle(out); + self.outbound_report_domain.pickle(out); + self.outbound_report_submitter.pickle(out); + self.inbound_report_max_size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.inbound_report_addresses = Pickle::unpickle(stream)?; + this.inbound_report_forwarding = Pickle::unpickle(stream)?; + this.outbound_report_domain = Pickle::unpickle(stream)?; + this.outbound_report_submitter = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.inbound_report_max_size = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for ReportSettings { + fn default() -> Self { + Self { + inbound_report_addresses: Map::new(vec!["postmaster@*".to_string()]), + inbound_report_forwarding: true, + outbound_report_domain: Default::default(), + outbound_report_submitter: Expression { + else_: "system('hostname')".to_string(), + ..Default::default() + }, + inbound_report_max_size: 26214400i64, + } + } +} + +impl IntoValue for ReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked( + Property::InboundReportAddresses, + self.inbound_report_addresses.into_value(), + ); + map.insert_unchecked( + Property::InboundReportForwarding, + self.inbound_report_forwarding.into_value(), + ); + map.insert_unchecked( + Property::OutboundReportDomain, + self.outbound_report_domain.into_value(), + ); + map.insert_unchecked( + Property::OutboundReportSubmitter, + self.outbound_report_submitter.into_value(), + ); + map.insert_unchecked( + Property::InboundReportMaxSize, + self.inbound_report_max_size.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::InboundReportAddresses) => self + .inbound_report_addresses + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::InboundReportForwarding) => { + self.inbound_report_forwarding.patch(pointer, value) + } + Some(Property::OutboundReportDomain) => self + .outbound_report_domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::OutboundReportSubmitter) => { + self.outbound_report_submitter.patch(pointer, value) + } + Some(Property::InboundReportMaxSize) => { + self.inbound_report_max_size.patch(pointer, value) + } + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl RocksDbStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.path; + if value.is_empty() { + errors.push(ValidationError::required(Property::Path)); + } + let value = &self.blob_size; + if *value > 1048576 { + errors.push(ValidationError::max_value(Property::BlobSize, 1048576)); + } + if *value < 1024 { + errors.push(ValidationError::min_value(Property::BlobSize, 1024)); + } + let value = &self.buffer_size; + if *value > 4294967296 { + errors.push(ValidationError::max_value(Property::BufferSize, 4294967296)); + } + if *value < 8388608 { + errors.push(ValidationError::min_value(Property::BufferSize, 8388608)); + } + if let Some(value) = &self.pool_workers { + if *value > 64 { + errors.push(ValidationError::max_value(Property::PoolWorkers, 64)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolWorkers, 1)); + } + } + let value = &self.cache_size; + if *value > 17179869184 { + errors.push(ValidationError::max_value(Property::CacheSize, 17179869184)); + } + if *value < 8388608 { + errors.push(ValidationError::min_value(Property::CacheSize, 8388608)); + } + errors.len() == neb + } +} + +impl Pickle for RocksDbStore { + fn pickle(&self, out: &mut Vec) { + self.path.pickle(out); + self.blob_size.pickle(out); + self.buffer_size.pickle(out); + self.pool_workers.pickle(out); + self.cache_size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.path = Pickle::unpickle(stream)?; + this.blob_size = Pickle::unpickle(stream)?; + this.buffer_size = Pickle::unpickle(stream)?; + this.pool_workers = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.cache_size = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for RocksDbStore { + fn default() -> Self { + Self { + path: Default::default(), + blob_size: 16834u64, + buffer_size: 134217728u64, + pool_workers: Default::default(), + cache_size: 134217728u64, + } + } +} + +impl IntoValue for RocksDbStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Path, self.path.into_value()); + map.insert_unchecked(Property::BlobSize, self.blob_size.into_value()); + map.insert_unchecked(Property::BufferSize, self.buffer_size.into_value()); + map.insert_unchecked(Property::PoolWorkers, self.pool_workers.into_value()); + map.insert_unchecked(Property::CacheSize, self.cache_size.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for RocksDbStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Path) => self + .path + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::BlobSize) => self.blob_size.patch(pointer, value), + Some(Property::BufferSize) => self.buffer_size.patch(pointer, value), + Some(Property::PoolWorkers) => self.pool_workers.patch(pointer, value), + Some(Property::CacheSize) => self.cache_size.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Role { + const FLAGS: u64 = OBJ_FILTER_TENANT | OBJ_SEQ_ID; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Role; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.role_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::RoleIds)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.text(Property::Description, &self.description); + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + for id in self.role_ids.iter() { + i.foreign_key(ObjectType::Role, Some(*id), None); + } + } +} + +impl Pickle for Role { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.member_tenant_id.pickle(out); + self.role_ids.pickle(out); + self.enabled_permissions.pickle(out); + self.disabled_permissions.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.role_ids = Pickle::unpickle(stream)?; + this.enabled_permissions = Pickle::unpickle(stream)?; + this.disabled_permissions = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Role { + fn default() -> Self { + Self { + description: Default::default(), + member_tenant_id: Default::default(), + role_ids: Default::default(), + enabled_permissions: Default::default(), + disabled_permissions: Default::default(), + } + } +} + +impl IntoValue for Role { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::RoleIds, self.role_ids.into_value()); + map.insert_unchecked( + Property::EnabledPermissions, + self.enabled_permissions.into_value(), + ); + map.insert_unchecked( + Property::DisabledPermissions, + self.disabled_permissions.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Role { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::RoleIds) => self.role_ids.patch(pointer, value), + Some(Property::EnabledPermissions) => self.enabled_permissions.patch(pointer, value), + Some(Property::DisabledPermissions) => self.disabled_permissions.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Roles { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Roles::Default => true, + Roles::Custom(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + Roles::Default => {} + Roles::Custom(object) => { + object.index(i); + } + } + } +} + +impl Default for Roles { + fn default() -> Self { + Roles::Default + } +} + +impl Pickle for Roles { + fn pickle(&self, out: &mut Vec) { + match self { + Roles::Default => { + 0u16.pickle(out); + } + Roles::Custom(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(Roles::Default), + 1 => Pickle::unpickle(stream).map(Roles::Custom), + _ => None, + } + } +} + +impl IntoValue for Roles { + fn into_value(self) -> JmapValue<'static> { + match self { + Roles::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + Roles::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Roles { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + RolesType::Default => *self = Roles::Default, + RolesType::Custom => *self = Roles::Custom(Default::default()), + } + } + match self { + Roles::Default => pointer.assert_eof(), + Roles::Custom(inner) => inner.patch(pointer, value), + } + } +} + +impl Roles { + pub fn object_type(&self) -> RolesType { + match self { + Roles::Default => RolesType::Default, + Roles::Custom(_) => RolesType::Custom, + } + } +} + +impl S3Store { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.region; + value.validate(errors); + let value = &self.bucket; + if value.is_empty() { + errors.push(ValidationError::required(Property::Bucket)); + } + let value = &self.access_key; + value.validate(errors); + let value = &self.secret_key; + value.validate(errors); + let value = &self.security_token; + value.validate(errors); + let value = &self.session_token; + value.validate(errors); + if let Some(value) = &self.profile { + if value.is_empty() { + errors.push(ValidationError::required(Property::Profile)); + } + } + let value = &self.max_retries; + if *value > 10 { + errors.push(ValidationError::max_value(Property::MaxRetries, 10)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxRetries, 1)); + } + if let Some(value) = &self.key_prefix { + if value.is_empty() { + errors.push(ValidationError::required(Property::KeyPrefix)); + } + } + errors.len() == neb + } +} + +impl Pickle for S3Store { + fn pickle(&self, out: &mut Vec) { + self.region.pickle(out); + self.bucket.pickle(out); + self.access_key.pickle(out); + self.secret_key.pickle(out); + self.security_token.pickle(out); + self.session_token.pickle(out); + self.profile.pickle(out); + self.timeout.pickle(out); + self.max_retries.pickle(out); + self.key_prefix.pickle(out); + self.allow_invalid_certs.pickle(out); + self.verify_after_write.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.region = Pickle::unpickle(stream)?; + this.bucket = Pickle::unpickle(stream)?; + this.access_key = Pickle::unpickle(stream)?; + this.secret_key = Pickle::unpickle(stream)?; + this.security_token = Pickle::unpickle(stream)?; + this.session_token = Pickle::unpickle(stream)?; + this.profile = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.max_retries = Pickle::unpickle(stream)?; + this.key_prefix = Pickle::unpickle(stream)?; + this.allow_invalid_certs = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.verify_after_write = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for S3Store { + fn default() -> Self { + Self { + region: Default::default(), + bucket: Default::default(), + access_key: Default::default(), + secret_key: Default::default(), + security_token: Default::default(), + session_token: Default::default(), + profile: Default::default(), + timeout: Duration::from_millis(30000), + max_retries: 3u64, + key_prefix: Default::default(), + allow_invalid_certs: false, + verify_after_write: true, + } + } +} + +impl IntoValue for S3Store { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Region, self.region.into_value()); + map.insert_unchecked(Property::Bucket, self.bucket.into_value()); + map.insert_unchecked(Property::AccessKey, self.access_key.into_value()); + map.insert_unchecked(Property::SecretKey, self.secret_key.into_value()); + map.insert_unchecked(Property::SecurityToken, self.security_token.into_value()); + map.insert_unchecked(Property::SessionToken, self.session_token.into_value()); + map.insert_unchecked(Property::Profile, self.profile.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::MaxRetries, self.max_retries.into_value()); + map.insert_unchecked(Property::KeyPrefix, self.key_prefix.into_value()); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked( + Property::VerifyAfterWrite, + self.verify_after_write.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for S3Store { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Region) => self.region.patch(pointer, value), + Some(Property::Bucket) => self + .bucket + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AccessKey) => self.access_key.patch(pointer, value), + Some(Property::SecretKey) => self.secret_key.patch(pointer, value), + Some(Property::SecurityToken) => self.security_token.patch(pointer, value), + Some(Property::SessionToken) => self.session_token.patch(pointer, value), + Some(Property::Profile) => self + .profile + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::MaxRetries) => self.max_retries.patch(pointer, value), + Some(Property::KeyPrefix) => self + .key_prefix + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::VerifyAfterWrite) => self.verify_after_write.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl S3StoreCustomRegion { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.custom_endpoint; + if value.is_empty() { + errors.push(ValidationError::required(Property::CustomEndpoint)); + } + let value = &self.custom_region; + if value.is_empty() { + errors.push(ValidationError::required(Property::CustomRegion)); + } + errors.len() == neb + } +} + +impl Pickle for S3StoreCustomRegion { + fn pickle(&self, out: &mut Vec) { + self.custom_endpoint.pickle(out); + self.custom_region.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.custom_endpoint = Pickle::unpickle(stream)?; + this.custom_region = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for S3StoreCustomRegion { + fn default() -> Self { + Self { + custom_endpoint: Default::default(), + custom_region: Default::default(), + } + } +} + +impl IntoValue for S3StoreCustomRegion { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::CustomEndpoint, self.custom_endpoint.into_value()); + map.insert_unchecked(Property::CustomRegion, self.custom_region.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for S3StoreCustomRegion { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CustomEndpoint) => self + .custom_endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::CustomRegion) => self + .custom_region + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl S3StoreRegion { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + S3StoreRegion::UsEast1 => true, + S3StoreRegion::UsEast2 => true, + S3StoreRegion::UsWest1 => true, + S3StoreRegion::UsWest2 => true, + S3StoreRegion::CaCentral1 => true, + S3StoreRegion::AfSouth1 => true, + S3StoreRegion::ApEast1 => true, + S3StoreRegion::ApSouth1 => true, + S3StoreRegion::ApNortheast1 => true, + S3StoreRegion::ApNortheast2 => true, + S3StoreRegion::ApNortheast3 => true, + S3StoreRegion::ApSoutheast1 => true, + S3StoreRegion::ApSoutheast2 => true, + S3StoreRegion::CnNorth1 => true, + S3StoreRegion::CnNorthwest1 => true, + S3StoreRegion::EuNorth1 => true, + S3StoreRegion::EuCentral1 => true, + S3StoreRegion::EuCentral2 => true, + S3StoreRegion::EuWest1 => true, + S3StoreRegion::EuWest2 => true, + S3StoreRegion::EuWest3 => true, + S3StoreRegion::IlCentral1 => true, + S3StoreRegion::MeSouth1 => true, + S3StoreRegion::SaEast1 => true, + S3StoreRegion::DoNyc3 => true, + S3StoreRegion::DoAms3 => true, + S3StoreRegion::DoSgp1 => true, + S3StoreRegion::DoFra1 => true, + S3StoreRegion::Yandex => true, + S3StoreRegion::WaUsEast1 => true, + S3StoreRegion::WaUsEast2 => true, + S3StoreRegion::WaUsCentral1 => true, + S3StoreRegion::WaUsWest1 => true, + S3StoreRegion::WaCaCentral1 => true, + S3StoreRegion::WaEuCentral1 => true, + S3StoreRegion::WaEuCentral2 => true, + S3StoreRegion::WaEuWest1 => true, + S3StoreRegion::WaEuWest2 => true, + S3StoreRegion::WaApNortheast1 => true, + S3StoreRegion::WaApNortheast2 => true, + S3StoreRegion::WaApSoutheast1 => true, + S3StoreRegion::WaApSoutheast2 => true, + S3StoreRegion::Custom(inner) => inner.validate(errors), + } + } +} + +impl Default for S3StoreRegion { + fn default() -> Self { + S3StoreRegion::UsEast1 + } +} + +impl Pickle for S3StoreRegion { + fn pickle(&self, out: &mut Vec) { + match self { + S3StoreRegion::UsEast1 => { + 0u16.pickle(out); + } + S3StoreRegion::UsEast2 => { + 1u16.pickle(out); + } + S3StoreRegion::UsWest1 => { + 2u16.pickle(out); + } + S3StoreRegion::UsWest2 => { + 3u16.pickle(out); + } + S3StoreRegion::CaCentral1 => { + 4u16.pickle(out); + } + S3StoreRegion::AfSouth1 => { + 5u16.pickle(out); + } + S3StoreRegion::ApEast1 => { + 6u16.pickle(out); + } + S3StoreRegion::ApSouth1 => { + 7u16.pickle(out); + } + S3StoreRegion::ApNortheast1 => { + 8u16.pickle(out); + } + S3StoreRegion::ApNortheast2 => { + 9u16.pickle(out); + } + S3StoreRegion::ApNortheast3 => { + 10u16.pickle(out); + } + S3StoreRegion::ApSoutheast1 => { + 11u16.pickle(out); + } + S3StoreRegion::ApSoutheast2 => { + 12u16.pickle(out); + } + S3StoreRegion::CnNorth1 => { + 13u16.pickle(out); + } + S3StoreRegion::CnNorthwest1 => { + 14u16.pickle(out); + } + S3StoreRegion::EuNorth1 => { + 15u16.pickle(out); + } + S3StoreRegion::EuCentral1 => { + 16u16.pickle(out); + } + S3StoreRegion::EuCentral2 => { + 17u16.pickle(out); + } + S3StoreRegion::EuWest1 => { + 18u16.pickle(out); + } + S3StoreRegion::EuWest2 => { + 19u16.pickle(out); + } + S3StoreRegion::EuWest3 => { + 20u16.pickle(out); + } + S3StoreRegion::IlCentral1 => { + 21u16.pickle(out); + } + S3StoreRegion::MeSouth1 => { + 22u16.pickle(out); + } + S3StoreRegion::SaEast1 => { + 23u16.pickle(out); + } + S3StoreRegion::DoNyc3 => { + 24u16.pickle(out); + } + S3StoreRegion::DoAms3 => { + 25u16.pickle(out); + } + S3StoreRegion::DoSgp1 => { + 26u16.pickle(out); + } + S3StoreRegion::DoFra1 => { + 27u16.pickle(out); + } + S3StoreRegion::Yandex => { + 28u16.pickle(out); + } + S3StoreRegion::WaUsEast1 => { + 29u16.pickle(out); + } + S3StoreRegion::WaUsEast2 => { + 30u16.pickle(out); + } + S3StoreRegion::WaUsCentral1 => { + 31u16.pickle(out); + } + S3StoreRegion::WaUsWest1 => { + 32u16.pickle(out); + } + S3StoreRegion::WaCaCentral1 => { + 33u16.pickle(out); + } + S3StoreRegion::WaEuCentral1 => { + 34u16.pickle(out); + } + S3StoreRegion::WaEuCentral2 => { + 35u16.pickle(out); + } + S3StoreRegion::WaEuWest1 => { + 36u16.pickle(out); + } + S3StoreRegion::WaEuWest2 => { + 37u16.pickle(out); + } + S3StoreRegion::WaApNortheast1 => { + 38u16.pickle(out); + } + S3StoreRegion::WaApNortheast2 => { + 39u16.pickle(out); + } + S3StoreRegion::WaApSoutheast1 => { + 40u16.pickle(out); + } + S3StoreRegion::WaApSoutheast2 => { + 41u16.pickle(out); + } + S3StoreRegion::Custom(inner) => { + 42u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(S3StoreRegion::UsEast1), + 1 => Some(S3StoreRegion::UsEast2), + 2 => Some(S3StoreRegion::UsWest1), + 3 => Some(S3StoreRegion::UsWest2), + 4 => Some(S3StoreRegion::CaCentral1), + 5 => Some(S3StoreRegion::AfSouth1), + 6 => Some(S3StoreRegion::ApEast1), + 7 => Some(S3StoreRegion::ApSouth1), + 8 => Some(S3StoreRegion::ApNortheast1), + 9 => Some(S3StoreRegion::ApNortheast2), + 10 => Some(S3StoreRegion::ApNortheast3), + 11 => Some(S3StoreRegion::ApSoutheast1), + 12 => Some(S3StoreRegion::ApSoutheast2), + 13 => Some(S3StoreRegion::CnNorth1), + 14 => Some(S3StoreRegion::CnNorthwest1), + 15 => Some(S3StoreRegion::EuNorth1), + 16 => Some(S3StoreRegion::EuCentral1), + 17 => Some(S3StoreRegion::EuCentral2), + 18 => Some(S3StoreRegion::EuWest1), + 19 => Some(S3StoreRegion::EuWest2), + 20 => Some(S3StoreRegion::EuWest3), + 21 => Some(S3StoreRegion::IlCentral1), + 22 => Some(S3StoreRegion::MeSouth1), + 23 => Some(S3StoreRegion::SaEast1), + 24 => Some(S3StoreRegion::DoNyc3), + 25 => Some(S3StoreRegion::DoAms3), + 26 => Some(S3StoreRegion::DoSgp1), + 27 => Some(S3StoreRegion::DoFra1), + 28 => Some(S3StoreRegion::Yandex), + 29 => Some(S3StoreRegion::WaUsEast1), + 30 => Some(S3StoreRegion::WaUsEast2), + 31 => Some(S3StoreRegion::WaUsCentral1), + 32 => Some(S3StoreRegion::WaUsWest1), + 33 => Some(S3StoreRegion::WaCaCentral1), + 34 => Some(S3StoreRegion::WaEuCentral1), + 35 => Some(S3StoreRegion::WaEuCentral2), + 36 => Some(S3StoreRegion::WaEuWest1), + 37 => Some(S3StoreRegion::WaEuWest2), + 38 => Some(S3StoreRegion::WaApNortheast1), + 39 => Some(S3StoreRegion::WaApNortheast2), + 40 => Some(S3StoreRegion::WaApSoutheast1), + 41 => Some(S3StoreRegion::WaApSoutheast2), + 42 => Pickle::unpickle(stream).map(S3StoreRegion::Custom), + _ => None, + } + } +} + +impl IntoValue for S3StoreRegion { + fn into_value(self) -> JmapValue<'static> { + match self { + S3StoreRegion::UsEast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("UsEast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::UsEast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("UsEast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::UsWest1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("UsWest1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::UsWest2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("UsWest2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::CaCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("CaCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::AfSouth1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("AfSouth1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApEast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApEast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApSouth1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApSouth1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApNortheast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApNortheast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApNortheast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApNortheast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApNortheast3 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApNortheast3".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApSoutheast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApSoutheast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::ApSoutheast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("ApSoutheast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::CnNorth1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("CnNorth1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::CnNorthwest1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("CnNorthwest1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuNorth1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuNorth1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuCentral2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuCentral2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuWest1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuWest1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuWest2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuWest2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::EuWest3 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("EuWest3".into())); + JmapValue::Object(obj) + } + S3StoreRegion::IlCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("IlCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::MeSouth1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("MeSouth1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::SaEast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("SaEast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::DoNyc3 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DoNyc3".into())); + JmapValue::Object(obj) + } + S3StoreRegion::DoAms3 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DoAms3".into())); + JmapValue::Object(obj) + } + S3StoreRegion::DoSgp1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DoSgp1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::DoFra1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("DoFra1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::Yandex => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Yandex".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaUsEast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaUsEast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaUsEast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaUsEast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaUsCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaUsCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaUsWest1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaUsWest1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaCaCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaCaCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaEuCentral1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaEuCentral1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaEuCentral2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaEuCentral2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaEuWest1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaEuWest1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaEuWest2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaEuWest2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaApNortheast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaApNortheast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaApNortheast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaApNortheast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaApSoutheast1 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaApSoutheast1".into())); + JmapValue::Object(obj) + } + S3StoreRegion::WaApSoutheast2 => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("WaApSoutheast2".into())); + JmapValue::Object(obj) + } + S3StoreRegion::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for S3StoreRegion { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + S3StoreRegionType::UsEast1 => *self = S3StoreRegion::UsEast1, + S3StoreRegionType::UsEast2 => *self = S3StoreRegion::UsEast2, + S3StoreRegionType::UsWest1 => *self = S3StoreRegion::UsWest1, + S3StoreRegionType::UsWest2 => *self = S3StoreRegion::UsWest2, + S3StoreRegionType::CaCentral1 => *self = S3StoreRegion::CaCentral1, + S3StoreRegionType::AfSouth1 => *self = S3StoreRegion::AfSouth1, + S3StoreRegionType::ApEast1 => *self = S3StoreRegion::ApEast1, + S3StoreRegionType::ApSouth1 => *self = S3StoreRegion::ApSouth1, + S3StoreRegionType::ApNortheast1 => *self = S3StoreRegion::ApNortheast1, + S3StoreRegionType::ApNortheast2 => *self = S3StoreRegion::ApNortheast2, + S3StoreRegionType::ApNortheast3 => *self = S3StoreRegion::ApNortheast3, + S3StoreRegionType::ApSoutheast1 => *self = S3StoreRegion::ApSoutheast1, + S3StoreRegionType::ApSoutheast2 => *self = S3StoreRegion::ApSoutheast2, + S3StoreRegionType::CnNorth1 => *self = S3StoreRegion::CnNorth1, + S3StoreRegionType::CnNorthwest1 => *self = S3StoreRegion::CnNorthwest1, + S3StoreRegionType::EuNorth1 => *self = S3StoreRegion::EuNorth1, + S3StoreRegionType::EuCentral1 => *self = S3StoreRegion::EuCentral1, + S3StoreRegionType::EuCentral2 => *self = S3StoreRegion::EuCentral2, + S3StoreRegionType::EuWest1 => *self = S3StoreRegion::EuWest1, + S3StoreRegionType::EuWest2 => *self = S3StoreRegion::EuWest2, + S3StoreRegionType::EuWest3 => *self = S3StoreRegion::EuWest3, + S3StoreRegionType::IlCentral1 => *self = S3StoreRegion::IlCentral1, + S3StoreRegionType::MeSouth1 => *self = S3StoreRegion::MeSouth1, + S3StoreRegionType::SaEast1 => *self = S3StoreRegion::SaEast1, + S3StoreRegionType::DoNyc3 => *self = S3StoreRegion::DoNyc3, + S3StoreRegionType::DoAms3 => *self = S3StoreRegion::DoAms3, + S3StoreRegionType::DoSgp1 => *self = S3StoreRegion::DoSgp1, + S3StoreRegionType::DoFra1 => *self = S3StoreRegion::DoFra1, + S3StoreRegionType::Yandex => *self = S3StoreRegion::Yandex, + S3StoreRegionType::WaUsEast1 => *self = S3StoreRegion::WaUsEast1, + S3StoreRegionType::WaUsEast2 => *self = S3StoreRegion::WaUsEast2, + S3StoreRegionType::WaUsCentral1 => *self = S3StoreRegion::WaUsCentral1, + S3StoreRegionType::WaUsWest1 => *self = S3StoreRegion::WaUsWest1, + S3StoreRegionType::WaCaCentral1 => *self = S3StoreRegion::WaCaCentral1, + S3StoreRegionType::WaEuCentral1 => *self = S3StoreRegion::WaEuCentral1, + S3StoreRegionType::WaEuCentral2 => *self = S3StoreRegion::WaEuCentral2, + S3StoreRegionType::WaEuWest1 => *self = S3StoreRegion::WaEuWest1, + S3StoreRegionType::WaEuWest2 => *self = S3StoreRegion::WaEuWest2, + S3StoreRegionType::WaApNortheast1 => *self = S3StoreRegion::WaApNortheast1, + S3StoreRegionType::WaApNortheast2 => *self = S3StoreRegion::WaApNortheast2, + S3StoreRegionType::WaApSoutheast1 => *self = S3StoreRegion::WaApSoutheast1, + S3StoreRegionType::WaApSoutheast2 => *self = S3StoreRegion::WaApSoutheast2, + S3StoreRegionType::Custom => *self = S3StoreRegion::Custom(Default::default()), + } + } + match self { + S3StoreRegion::UsEast1 => pointer.assert_eof(), + S3StoreRegion::UsEast2 => pointer.assert_eof(), + S3StoreRegion::UsWest1 => pointer.assert_eof(), + S3StoreRegion::UsWest2 => pointer.assert_eof(), + S3StoreRegion::CaCentral1 => pointer.assert_eof(), + S3StoreRegion::AfSouth1 => pointer.assert_eof(), + S3StoreRegion::ApEast1 => pointer.assert_eof(), + S3StoreRegion::ApSouth1 => pointer.assert_eof(), + S3StoreRegion::ApNortheast1 => pointer.assert_eof(), + S3StoreRegion::ApNortheast2 => pointer.assert_eof(), + S3StoreRegion::ApNortheast3 => pointer.assert_eof(), + S3StoreRegion::ApSoutheast1 => pointer.assert_eof(), + S3StoreRegion::ApSoutheast2 => pointer.assert_eof(), + S3StoreRegion::CnNorth1 => pointer.assert_eof(), + S3StoreRegion::CnNorthwest1 => pointer.assert_eof(), + S3StoreRegion::EuNorth1 => pointer.assert_eof(), + S3StoreRegion::EuCentral1 => pointer.assert_eof(), + S3StoreRegion::EuCentral2 => pointer.assert_eof(), + S3StoreRegion::EuWest1 => pointer.assert_eof(), + S3StoreRegion::EuWest2 => pointer.assert_eof(), + S3StoreRegion::EuWest3 => pointer.assert_eof(), + S3StoreRegion::IlCentral1 => pointer.assert_eof(), + S3StoreRegion::MeSouth1 => pointer.assert_eof(), + S3StoreRegion::SaEast1 => pointer.assert_eof(), + S3StoreRegion::DoNyc3 => pointer.assert_eof(), + S3StoreRegion::DoAms3 => pointer.assert_eof(), + S3StoreRegion::DoSgp1 => pointer.assert_eof(), + S3StoreRegion::DoFra1 => pointer.assert_eof(), + S3StoreRegion::Yandex => pointer.assert_eof(), + S3StoreRegion::WaUsEast1 => pointer.assert_eof(), + S3StoreRegion::WaUsEast2 => pointer.assert_eof(), + S3StoreRegion::WaUsCentral1 => pointer.assert_eof(), + S3StoreRegion::WaUsWest1 => pointer.assert_eof(), + S3StoreRegion::WaCaCentral1 => pointer.assert_eof(), + S3StoreRegion::WaEuCentral1 => pointer.assert_eof(), + S3StoreRegion::WaEuCentral2 => pointer.assert_eof(), + S3StoreRegion::WaEuWest1 => pointer.assert_eof(), + S3StoreRegion::WaEuWest2 => pointer.assert_eof(), + S3StoreRegion::WaApNortheast1 => pointer.assert_eof(), + S3StoreRegion::WaApNortheast2 => pointer.assert_eof(), + S3StoreRegion::WaApSoutheast1 => pointer.assert_eof(), + S3StoreRegion::WaApSoutheast2 => pointer.assert_eof(), + S3StoreRegion::Custom(inner) => inner.patch(pointer, value), + } + } +} + +impl S3StoreRegion { + pub fn object_type(&self) -> S3StoreRegionType { + match self { + S3StoreRegion::UsEast1 => S3StoreRegionType::UsEast1, + S3StoreRegion::UsEast2 => S3StoreRegionType::UsEast2, + S3StoreRegion::UsWest1 => S3StoreRegionType::UsWest1, + S3StoreRegion::UsWest2 => S3StoreRegionType::UsWest2, + S3StoreRegion::CaCentral1 => S3StoreRegionType::CaCentral1, + S3StoreRegion::AfSouth1 => S3StoreRegionType::AfSouth1, + S3StoreRegion::ApEast1 => S3StoreRegionType::ApEast1, + S3StoreRegion::ApSouth1 => S3StoreRegionType::ApSouth1, + S3StoreRegion::ApNortheast1 => S3StoreRegionType::ApNortheast1, + S3StoreRegion::ApNortheast2 => S3StoreRegionType::ApNortheast2, + S3StoreRegion::ApNortheast3 => S3StoreRegionType::ApNortheast3, + S3StoreRegion::ApSoutheast1 => S3StoreRegionType::ApSoutheast1, + S3StoreRegion::ApSoutheast2 => S3StoreRegionType::ApSoutheast2, + S3StoreRegion::CnNorth1 => S3StoreRegionType::CnNorth1, + S3StoreRegion::CnNorthwest1 => S3StoreRegionType::CnNorthwest1, + S3StoreRegion::EuNorth1 => S3StoreRegionType::EuNorth1, + S3StoreRegion::EuCentral1 => S3StoreRegionType::EuCentral1, + S3StoreRegion::EuCentral2 => S3StoreRegionType::EuCentral2, + S3StoreRegion::EuWest1 => S3StoreRegionType::EuWest1, + S3StoreRegion::EuWest2 => S3StoreRegionType::EuWest2, + S3StoreRegion::EuWest3 => S3StoreRegionType::EuWest3, + S3StoreRegion::IlCentral1 => S3StoreRegionType::IlCentral1, + S3StoreRegion::MeSouth1 => S3StoreRegionType::MeSouth1, + S3StoreRegion::SaEast1 => S3StoreRegionType::SaEast1, + S3StoreRegion::DoNyc3 => S3StoreRegionType::DoNyc3, + S3StoreRegion::DoAms3 => S3StoreRegionType::DoAms3, + S3StoreRegion::DoSgp1 => S3StoreRegionType::DoSgp1, + S3StoreRegion::DoFra1 => S3StoreRegionType::DoFra1, + S3StoreRegion::Yandex => S3StoreRegionType::Yandex, + S3StoreRegion::WaUsEast1 => S3StoreRegionType::WaUsEast1, + S3StoreRegion::WaUsEast2 => S3StoreRegionType::WaUsEast2, + S3StoreRegion::WaUsCentral1 => S3StoreRegionType::WaUsCentral1, + S3StoreRegion::WaUsWest1 => S3StoreRegionType::WaUsWest1, + S3StoreRegion::WaCaCentral1 => S3StoreRegionType::WaCaCentral1, + S3StoreRegion::WaEuCentral1 => S3StoreRegionType::WaEuCentral1, + S3StoreRegion::WaEuCentral2 => S3StoreRegionType::WaEuCentral2, + S3StoreRegion::WaEuWest1 => S3StoreRegionType::WaEuWest1, + S3StoreRegion::WaEuWest2 => S3StoreRegionType::WaEuWest2, + S3StoreRegion::WaApNortheast1 => S3StoreRegionType::WaApNortheast1, + S3StoreRegion::WaApNortheast2 => S3StoreRegionType::WaApNortheast2, + S3StoreRegion::WaApSoutheast1 => S3StoreRegionType::WaApSoutheast1, + S3StoreRegion::WaApSoutheast2 => S3StoreRegionType::WaApSoutheast2, + S3StoreRegion::Custom(_) => S3StoreRegionType::Custom, + } + } +} + +impl ObjectImpl for Search { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Search; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.index_batch_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::IndexBatchSize, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Search { + fn pickle(&self, out: &mut Vec) { + self.index_batch_size.pickle(out); + self.default_language.pickle(out); + self.supported_languages.pickle(out); + self.index_calendar.pickle(out); + self.index_calendar_fields.pickle(out); + self.index_contacts.pickle(out); + self.index_contact_fields.pickle(out); + self.index_email.pickle(out); + self.index_email_fields.pickle(out); + self.index_telemetry.pickle(out); + self.index_tracing_fields.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.index_batch_size = Pickle::unpickle(stream)?; + this.default_language = Pickle::unpickle(stream)?; + this.supported_languages = Pickle::unpickle(stream)?; + this.index_calendar = Pickle::unpickle(stream)?; + this.index_calendar_fields = Pickle::unpickle(stream)?; + this.index_contacts = Pickle::unpickle(stream)?; + this.index_contact_fields = Pickle::unpickle(stream)?; + this.index_email = Pickle::unpickle(stream)?; + this.index_email_fields = Pickle::unpickle(stream)?; + this.index_telemetry = Pickle::unpickle(stream)?; + this.index_tracing_fields = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Search { + fn default() -> Self { + Self { + index_batch_size: 100u64, + default_language: Locale::EnUS, + supported_languages: Map::new(vec![Locale::EnUS]), + index_calendar: true, + index_calendar_fields: Map::new(vec![ + SearchCalendarField::Title, + SearchCalendarField::Description, + SearchCalendarField::Location, + SearchCalendarField::Owner, + SearchCalendarField::Attendee, + SearchCalendarField::Start, + SearchCalendarField::Uid, + ]), + index_contacts: true, + index_contact_fields: Map::new(vec![ + SearchContactField::Member, + SearchContactField::Kind, + SearchContactField::Name, + SearchContactField::Nickname, + SearchContactField::Organization, + SearchContactField::Email, + SearchContactField::Phone, + SearchContactField::OnlineService, + SearchContactField::Address, + SearchContactField::Note, + SearchContactField::Uid, + ]), + index_email: true, + index_email_fields: Map::new(vec![ + SearchEmailField::From, + SearchEmailField::To, + SearchEmailField::Cc, + SearchEmailField::Bcc, + SearchEmailField::Subject, + SearchEmailField::Body, + SearchEmailField::Attachment, + SearchEmailField::ReceivedAt, + SearchEmailField::SentAt, + SearchEmailField::Size, + SearchEmailField::HasAttachment, + ]), + index_telemetry: true, + index_tracing_fields: Map::new(vec![ + SearchTracingField::EventType, + SearchTracingField::QueueId, + SearchTracingField::Keywords, + ]), + } + } +} + +impl IntoValue for Search { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::IndexBatchSize, self.index_batch_size.into_value()); + map.insert_unchecked( + Property::DefaultLanguage, + self.default_language.into_value(), + ); + map.insert_unchecked( + Property::SupportedLanguages, + self.supported_languages.into_value(), + ); + map.insert_unchecked(Property::IndexCalendar, self.index_calendar.into_value()); + map.insert_unchecked( + Property::IndexCalendarFields, + self.index_calendar_fields.into_value(), + ); + map.insert_unchecked(Property::IndexContacts, self.index_contacts.into_value()); + map.insert_unchecked( + Property::IndexContactFields, + self.index_contact_fields.into_value(), + ); + map.insert_unchecked(Property::IndexEmail, self.index_email.into_value()); + map.insert_unchecked( + Property::IndexEmailFields, + self.index_email_fields.into_value(), + ); + map.insert_unchecked(Property::IndexTelemetry, self.index_telemetry.into_value()); + map.insert_unchecked( + Property::IndexTracingFields, + self.index_tracing_fields.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Search { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IndexBatchSize) => self.index_batch_size.patch(pointer, value), + Some(Property::DefaultLanguage) => self.default_language.patch(pointer, value), + Some(Property::SupportedLanguages) => self.supported_languages.patch(pointer, value), + Some(Property::IndexCalendar) => self.index_calendar.patch(pointer, value), + Some(Property::IndexCalendarFields) => self.index_calendar_fields.patch(pointer, value), + Some(Property::IndexContacts) => self.index_contacts.patch(pointer, value), + Some(Property::IndexContactFields) => self.index_contact_fields.patch(pointer, value), + Some(Property::IndexEmail) => self.index_email.patch(pointer, value), + Some(Property::IndexEmailFields) => self.index_email_fields.patch(pointer, value), + Some(Property::IndexTelemetry) => self.index_telemetry.patch(pointer, value), + Some(Property::IndexTracingFields) => self.index_tracing_fields.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SearchStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SearchStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SearchStore::Default => true, + SearchStore::ElasticSearch(inner) => inner.validate(errors), + SearchStore::Meilisearch(inner) => inner.validate(errors), + SearchStore::FoundationDb(inner) => inner.validate(errors), + SearchStore::PostgreSql(inner) => inner.validate(errors), + SearchStore::MySql(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for SearchStore { + fn default() -> Self { + SearchStore::Default + } +} + +impl Pickle for SearchStore { + fn pickle(&self, out: &mut Vec) { + match self { + SearchStore::Default => { + 0u16.pickle(out); + } + SearchStore::ElasticSearch(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SearchStore::Meilisearch(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SearchStore::FoundationDb(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + SearchStore::PostgreSql(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + SearchStore::MySql(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SearchStore::Default), + 1 => Pickle::unpickle(stream).map(SearchStore::ElasticSearch), + 2 => Pickle::unpickle(stream).map(SearchStore::Meilisearch), + 3 => Pickle::unpickle(stream).map(SearchStore::FoundationDb), + 4 => Pickle::unpickle(stream).map(SearchStore::PostgreSql), + 5 => Pickle::unpickle(stream).map(SearchStore::MySql), + _ => None, + } + } +} + +impl IntoValue for SearchStore { + fn into_value(self) -> JmapValue<'static> { + match self { + SearchStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + SearchStore::ElasticSearch(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ElasticSearch".into())); + obj + } + SearchStore::Meilisearch(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Meilisearch".into())); + obj + } + SearchStore::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + SearchStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + SearchStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SearchStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SearchStoreType::Default => *self = SearchStore::Default, + SearchStoreType::ElasticSearch => { + *self = SearchStore::ElasticSearch(Default::default()) + } + SearchStoreType::Meilisearch => { + *self = SearchStore::Meilisearch(Default::default()) + } + SearchStoreType::FoundationDb => { + *self = SearchStore::FoundationDb(Default::default()) + } + SearchStoreType::PostgreSql => *self = SearchStore::PostgreSql(Default::default()), + SearchStoreType::MySql => *self = SearchStore::MySql(Default::default()), + } + } + match self { + SearchStore::Default => pointer.assert_eof(), + SearchStore::ElasticSearch(inner) => inner.patch(pointer, value), + SearchStore::Meilisearch(inner) => inner.patch(pointer, value), + SearchStore::FoundationDb(inner) => inner.patch(pointer, value), + SearchStore::PostgreSql(inner) => inner.patch(pointer, value), + SearchStore::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl SearchStore { + pub fn object_type(&self) -> SearchStoreType { + match self { + SearchStore::Default => SearchStoreType::Default, + SearchStore::ElasticSearch(_) => SearchStoreType::ElasticSearch, + SearchStore::Meilisearch(_) => SearchStoreType::Meilisearch, + SearchStore::FoundationDb(_) => SearchStoreType::FoundationDb, + SearchStore::PostgreSql(_) => SearchStoreType::PostgreSql, + SearchStore::MySql(_) => SearchStoreType::MySql, + } + } +} + +impl SecondaryCredential { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.credential_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CredentialId, value)); + } + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.expires_at { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + } + let value = &self.permissions; + value.validate(errors); + let value = &self.allowed_ips; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::AllowedIps, value)); + } + } + errors.len() == neb + } +} + +impl Pickle for SecondaryCredential { + fn pickle(&self, out: &mut Vec) { + self.credential_id.pickle(out); + self.description.pickle(out); + self.secret.pickle(out); + self.created_at.pickle(out); + self.expires_at.pickle(out); + self.permissions.pickle(out); + self.allowed_ips.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.credential_id = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.secret = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.allowed_ips = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SecondaryCredential { + fn default() -> Self { + Self { + credential_id: Default::default(), + description: Default::default(), + secret: Default::default(), + created_at: Default::default(), + expires_at: Default::default(), + permissions: Default::default(), + allowed_ips: Default::default(), + } + } +} + +impl IntoValue for SecondaryCredential { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::CredentialId, self.credential_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::AllowedIps, self.allowed_ips.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SecondaryCredential { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CredentialId) => pointer.assert_server_set(), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Secret) => pointer.assert_server_set(), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::AllowedIps) => self.allowed_ips.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SecretKey { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SecretKey::Value(inner) => inner.validate(errors), + SecretKey::EnvironmentVariable(inner) => inner.validate(errors), + SecretKey::File(inner) => inner.validate(errors), + } + } +} + +impl Default for SecretKey { + fn default() -> Self { + SecretKey::Value(Default::default()) + } +} + +impl Pickle for SecretKey { + fn pickle(&self, out: &mut Vec) { + match self { + SecretKey::Value(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SecretKey::EnvironmentVariable(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SecretKey::File(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SecretKey::Value), + 1 => Pickle::unpickle(stream).map(SecretKey::EnvironmentVariable), + 2 => Pickle::unpickle(stream).map(SecretKey::File), + _ => None, + } + } +} + +impl IntoValue for SecretKey { + fn into_value(self) -> JmapValue<'static> { + match self { + SecretKey::Value(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Value".into())); + obj + } + SecretKey::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + SecretKey::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SecretKey { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SecretKeyType::Value => *self = SecretKey::Value(Default::default()), + SecretKeyType::EnvironmentVariable => { + *self = SecretKey::EnvironmentVariable(Default::default()) + } + SecretKeyType::File => *self = SecretKey::File(Default::default()), + } + } + match self { + SecretKey::Value(inner) => inner.patch(pointer, value), + SecretKey::EnvironmentVariable(inner) => inner.patch(pointer, value), + SecretKey::File(inner) => inner.patch(pointer, value), + } + } +} + +impl SecretKey { + pub fn object_type(&self) -> SecretKeyType { + match self { + SecretKey::Value(_) => SecretKeyType::Value, + SecretKey::EnvironmentVariable(_) => SecretKeyType::EnvironmentVariable, + SecretKey::File(_) => SecretKeyType::File, + } + } +} + +impl SecretKeyEnvironmentVariable { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.variable_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::VariableName)); + } + errors.len() == neb + } +} + +impl Pickle for SecretKeyEnvironmentVariable { + fn pickle(&self, out: &mut Vec) { + self.variable_name.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.variable_name = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SecretKeyEnvironmentVariable { + fn default() -> Self { + Self { + variable_name: Default::default(), + } + } +} + +impl IntoValue for SecretKeyEnvironmentVariable { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::VariableName, self.variable_name.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SecretKeyEnvironmentVariable { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::VariableName) => self.variable_name.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SecretKeyFile { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.file_path; + if value.is_empty() { + errors.push(ValidationError::required(Property::FilePath)); + } + errors.len() == neb + } +} + +impl Pickle for SecretKeyFile { + fn pickle(&self, out: &mut Vec) { + self.file_path.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.file_path = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SecretKeyFile { + fn default() -> Self { + Self { + file_path: Default::default(), + } + } +} + +impl IntoValue for SecretKeyFile { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::FilePath, self.file_path.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SecretKeyFile { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::FilePath) => self.file_path.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SecretKeyOptional { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SecretKeyOptional::None => true, + SecretKeyOptional::Value(inner) => inner.validate(errors), + SecretKeyOptional::EnvironmentVariable(inner) => inner.validate(errors), + SecretKeyOptional::File(inner) => inner.validate(errors), + } + } +} + +impl Default for SecretKeyOptional { + fn default() -> Self { + SecretKeyOptional::None + } +} + +impl Pickle for SecretKeyOptional { + fn pickle(&self, out: &mut Vec) { + match self { + SecretKeyOptional::None => { + 0u16.pickle(out); + } + SecretKeyOptional::Value(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SecretKeyOptional::EnvironmentVariable(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SecretKeyOptional::File(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SecretKeyOptional::None), + 1 => Pickle::unpickle(stream).map(SecretKeyOptional::Value), + 2 => Pickle::unpickle(stream).map(SecretKeyOptional::EnvironmentVariable), + 3 => Pickle::unpickle(stream).map(SecretKeyOptional::File), + _ => None, + } + } +} + +impl IntoValue for SecretKeyOptional { + fn into_value(self) -> JmapValue<'static> { + match self { + SecretKeyOptional::None => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("None".into())); + JmapValue::Object(obj) + } + SecretKeyOptional::Value(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Value".into())); + obj + } + SecretKeyOptional::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + SecretKeyOptional::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SecretKeyOptional { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SecretKeyOptionalType::None => *self = SecretKeyOptional::None, + SecretKeyOptionalType::Value => { + *self = SecretKeyOptional::Value(Default::default()) + } + SecretKeyOptionalType::EnvironmentVariable => { + *self = SecretKeyOptional::EnvironmentVariable(Default::default()) + } + SecretKeyOptionalType::File => *self = SecretKeyOptional::File(Default::default()), + } + } + match self { + SecretKeyOptional::None => pointer.assert_eof(), + SecretKeyOptional::Value(inner) => inner.patch(pointer, value), + SecretKeyOptional::EnvironmentVariable(inner) => inner.patch(pointer, value), + SecretKeyOptional::File(inner) => inner.patch(pointer, value), + } + } +} + +impl SecretKeyOptional { + pub fn object_type(&self) -> SecretKeyOptionalType { + match self { + SecretKeyOptional::None => SecretKeyOptionalType::None, + SecretKeyOptional::Value(_) => SecretKeyOptionalType::Value, + SecretKeyOptional::EnvironmentVariable(_) => SecretKeyOptionalType::EnvironmentVariable, + SecretKeyOptional::File(_) => SecretKeyOptionalType::File, + } + } +} + +impl SecretKeyValue { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + errors.len() == neb + } +} + +impl Pickle for SecretKeyValue { + fn pickle(&self, out: &mut Vec) { + self.secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SecretKeyValue { + fn default() -> Self { + Self { + secret: Default::default(), + } + } +} + +impl IntoValue for SecretKeyValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SecretKeyValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SecretText { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SecretText::Text(inner) => inner.validate(errors), + SecretText::EnvironmentVariable(inner) => inner.validate(errors), + SecretText::File(inner) => inner.validate(errors), + } + } +} + +impl Default for SecretText { + fn default() -> Self { + SecretText::Text(Default::default()) + } +} + +impl Pickle for SecretText { + fn pickle(&self, out: &mut Vec) { + match self { + SecretText::Text(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SecretText::EnvironmentVariable(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SecretText::File(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SecretText::Text), + 1 => Pickle::unpickle(stream).map(SecretText::EnvironmentVariable), + 2 => Pickle::unpickle(stream).map(SecretText::File), + _ => None, + } + } +} + +impl IntoValue for SecretText { + fn into_value(self) -> JmapValue<'static> { + match self { + SecretText::Text(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Text".into())); + obj + } + SecretText::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + SecretText::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SecretText { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SecretTextType::Text => *self = SecretText::Text(Default::default()), + SecretTextType::EnvironmentVariable => { + *self = SecretText::EnvironmentVariable(Default::default()) + } + SecretTextType::File => *self = SecretText::File(Default::default()), + } + } + match self { + SecretText::Text(inner) => inner.patch(pointer, value), + SecretText::EnvironmentVariable(inner) => inner.patch(pointer, value), + SecretText::File(inner) => inner.patch(pointer, value), + } + } +} + +impl SecretText { + pub fn object_type(&self) -> SecretTextType { + match self { + SecretText::Text(_) => SecretTextType::Text, + SecretText::EnvironmentVariable(_) => SecretTextType::EnvironmentVariable, + SecretText::File(_) => SecretTextType::File, + } + } +} + +impl SecretTextOptional { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SecretTextOptional::None => true, + SecretTextOptional::Text(inner) => inner.validate(errors), + SecretTextOptional::EnvironmentVariable(inner) => inner.validate(errors), + SecretTextOptional::File(inner) => inner.validate(errors), + } + } +} + +impl Default for SecretTextOptional { + fn default() -> Self { + SecretTextOptional::None + } +} + +impl Pickle for SecretTextOptional { + fn pickle(&self, out: &mut Vec) { + match self { + SecretTextOptional::None => { + 0u16.pickle(out); + } + SecretTextOptional::Text(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SecretTextOptional::EnvironmentVariable(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SecretTextOptional::File(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SecretTextOptional::None), + 1 => Pickle::unpickle(stream).map(SecretTextOptional::Text), + 2 => Pickle::unpickle(stream).map(SecretTextOptional::EnvironmentVariable), + 3 => Pickle::unpickle(stream).map(SecretTextOptional::File), + _ => None, + } + } +} + +impl IntoValue for SecretTextOptional { + fn into_value(self) -> JmapValue<'static> { + match self { + SecretTextOptional::None => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("None".into())); + JmapValue::Object(obj) + } + SecretTextOptional::Text(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Text".into())); + obj + } + SecretTextOptional::EnvironmentVariable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("EnvironmentVariable".into())); + obj + } + SecretTextOptional::File(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("File".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SecretTextOptional { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SecretTextOptionalType::None => *self = SecretTextOptional::None, + SecretTextOptionalType::Text => { + *self = SecretTextOptional::Text(Default::default()) + } + SecretTextOptionalType::EnvironmentVariable => { + *self = SecretTextOptional::EnvironmentVariable(Default::default()) + } + SecretTextOptionalType::File => { + *self = SecretTextOptional::File(Default::default()) + } + } + } + match self { + SecretTextOptional::None => pointer.assert_eof(), + SecretTextOptional::Text(inner) => inner.patch(pointer, value), + SecretTextOptional::EnvironmentVariable(inner) => inner.patch(pointer, value), + SecretTextOptional::File(inner) => inner.patch(pointer, value), + } + } +} + +impl SecretTextOptional { + pub fn object_type(&self) -> SecretTextOptionalType { + match self { + SecretTextOptional::None => SecretTextOptionalType::None, + SecretTextOptional::Text(_) => SecretTextOptionalType::Text, + SecretTextOptional::EnvironmentVariable(_) => { + SecretTextOptionalType::EnvironmentVariable + } + SecretTextOptional::File(_) => SecretTextOptionalType::File, + } + } +} + +impl SecretTextValue { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } + errors.len() == neb + } +} + +impl Pickle for SecretTextValue { + fn pickle(&self, out: &mut Vec) { + self.secret.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.secret = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SecretTextValue { + fn default() -> Self { + Self { + secret: Default::default(), + } + } +} + +impl IntoValue for SecretTextValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SecretTextValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Secret) => self.secret.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Security { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Security; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.abuse_ban_rate { + value.validate(errors); + } + if let Some(value) = &self.auth_ban_rate { + value.validate(errors); + } + if let Some(value) = &self.loiter_ban_rate { + value.validate(errors); + } + let value = &self.scan_ban_paths; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ScanBanPaths)); + } + } + if let Some(value) = &self.scan_ban_rate { + value.validate(errors); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Security { + fn pickle(&self, out: &mut Vec) { + self.abuse_ban_rate.pickle(out); + self.abuse_ban_period.pickle(out); + self.auth_ban_rate.pickle(out); + self.auth_ban_period.pickle(out); + self.loiter_ban_rate.pickle(out); + self.loiter_ban_period.pickle(out); + self.scan_ban_paths.pickle(out); + self.scan_ban_rate.pickle(out); + self.scan_ban_period.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.abuse_ban_rate = Pickle::unpickle(stream)?; + this.abuse_ban_period = Pickle::unpickle(stream)?; + this.auth_ban_rate = Pickle::unpickle(stream)?; + this.auth_ban_period = Pickle::unpickle(stream)?; + this.loiter_ban_rate = Pickle::unpickle(stream)?; + this.loiter_ban_period = Pickle::unpickle(stream)?; + this.scan_ban_paths = Pickle::unpickle(stream)?; + this.scan_ban_rate = Pickle::unpickle(stream)?; + this.scan_ban_period = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Security { + fn default() -> Self { + Self { + abuse_ban_rate: Some(Rate { + count: 35u64, + period: Duration::from_millis(86400000), + }), + abuse_ban_period: Default::default(), + auth_ban_rate: Some(Rate { + count: 100u64, + period: Duration::from_millis(86400000), + }), + auth_ban_period: Default::default(), + loiter_ban_rate: Some(Rate { + count: 150u64, + period: Duration::from_millis(86400000), + }), + loiter_ban_period: Default::default(), + scan_ban_paths: Map::new(vec![ + "*.php*".to_string(), + "*.cgi*".to_string(), + "*.asp*".to_string(), + "*/wp-*".to_string(), + "*/php*".to_string(), + "*/cgi-bin*".to_string(), + "*xmlrpc*".to_string(), + "*../*".to_string(), + "*/..*".to_string(), + "*joomla*".to_string(), + "*wordpress*".to_string(), + "*drupal*".to_string(), + ]), + scan_ban_rate: Some(Rate { + count: 30u64, + period: Duration::from_millis(86400000), + }), + scan_ban_period: Default::default(), + } + } +} + +impl IntoValue for Security { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AbuseBanRate, self.abuse_ban_rate.into_value()); + map.insert_unchecked(Property::AbuseBanPeriod, self.abuse_ban_period.into_value()); + map.insert_unchecked(Property::AuthBanRate, self.auth_ban_rate.into_value()); + map.insert_unchecked(Property::AuthBanPeriod, self.auth_ban_period.into_value()); + map.insert_unchecked(Property::LoiterBanRate, self.loiter_ban_rate.into_value()); + map.insert_unchecked( + Property::LoiterBanPeriod, + self.loiter_ban_period.into_value(), + ); + map.insert_unchecked(Property::ScanBanPaths, self.scan_ban_paths.into_value()); + map.insert_unchecked(Property::ScanBanRate, self.scan_ban_rate.into_value()); + map.insert_unchecked(Property::ScanBanPeriod, self.scan_ban_period.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Security { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AbuseBanRate) => self.abuse_ban_rate.patch(pointer, value), + Some(Property::AbuseBanPeriod) => self.abuse_ban_period.patch(pointer, value), + Some(Property::AuthBanRate) => self.auth_ban_rate.patch(pointer, value), + Some(Property::AuthBanPeriod) => self.auth_ban_period.patch(pointer, value), + Some(Property::LoiterBanRate) => self.loiter_ban_rate.patch(pointer, value), + Some(Property::LoiterBanPeriod) => self.loiter_ban_period.patch(pointer, value), + Some(Property::ScanBanPaths) => self + .scan_ban_paths + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ScanBanRate) => self.scan_ban_rate.patch(pointer, value), + Some(Property::ScanBanPeriod) => self.scan_ban_period.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SenderAuth { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SenderAuth; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.dkim_sign_domain; + value.validate(errors); + let value = &self.dkim_verify; + value.validate(errors); + let value = &self.spf_ehlo_verify; + value.validate(errors); + let value = &self.spf_from_verify; + value.validate(errors); + let value = &self.arc_verify; + value.validate(errors); + let value = &self.dmarc_verify; + value.validate(errors); + let value = &self.reverse_ip_verify; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl SenderAuth { + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(sender_domain) && !is_empty(authenticated_as)" + .to_string(), + then: "sender_domain".to_string(), + }]), + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dkim_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_verify, + default: Some(Expression { + else_: "relaxed".to_string(), + match_: List::from_iter([]), + }), + property: Property::DkimVerify, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn ctx_spf_ehlo_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.spf_ehlo_verify, + default: Some(Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }), + property: Property::SpfEhloVerify, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn ctx_spf_from_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.spf_from_verify, + default: Some(Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }), + property: Property::SpfFromVerify, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn ctx_arc_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.arc_verify, + default: Some(Expression { + else_: "disable".to_string(), + match_: List::from_iter([]), + }), + property: Property::ArcVerify, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn ctx_dmarc_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dmarc_verify, + default: Some(Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }), + property: Property::DmarcVerify, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn ctx_reverse_ip_verify(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.reverse_ip_verify, + default: Some(Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }), + property: Property::ReverseIpVerify, + allowed_variables: MTA_CONNECTION_VARIABLE, + allowed_constants: MTA_VERIFY_CONSTANT, + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_dkim_sign_domain(), + self.ctx_dkim_verify(), + self.ctx_spf_ehlo_verify(), + self.ctx_spf_from_verify(), + self.ctx_arc_verify(), + self.ctx_dmarc_verify(), + self.ctx_reverse_ip_verify(), + ] + } +} + +impl Pickle for SenderAuth { + fn pickle(&self, out: &mut Vec) { + self.dkim_sign_domain.pickle(out); + self.dkim_strict.pickle(out); + self.dkim_verify.pickle(out); + self.spf_ehlo_verify.pickle(out); + self.spf_from_verify.pickle(out); + self.arc_verify.pickle(out); + self.dmarc_verify.pickle(out); + self.reverse_ip_verify.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.dkim_sign_domain = Pickle::unpickle(stream)?; + this.dkim_strict = Pickle::unpickle(stream)?; + this.dkim_verify = Pickle::unpickle(stream)?; + this.spf_ehlo_verify = Pickle::unpickle(stream)?; + this.spf_from_verify = Pickle::unpickle(stream)?; + this.arc_verify = Pickle::unpickle(stream)?; + this.dmarc_verify = Pickle::unpickle(stream)?; + this.reverse_ip_verify = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SenderAuth { + fn default() -> Self { + Self { + dkim_sign_domain: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(sender_domain) && !is_empty(authenticated_as)" + .to_string(), + then: "sender_domain".to_string(), + }]), + }, + dkim_strict: true, + dkim_verify: Expression { + else_: "relaxed".to_string(), + match_: List::from_iter([]), + }, + spf_ehlo_verify: Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }, + spf_from_verify: Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }, + arc_verify: Expression { + else_: "disable".to_string(), + match_: List::from_iter([]), + }, + dmarc_verify: Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }, + reverse_ip_verify: Expression { + else_: "disable".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "local_port == 25".to_string(), + then: "relaxed".to_string(), + }]), + }, + } + } +} + +impl IntoValue for SenderAuth { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + map.insert_unchecked(Property::DkimStrict, self.dkim_strict.into_value()); + map.insert_unchecked(Property::DkimVerify, self.dkim_verify.into_value()); + map.insert_unchecked(Property::SpfEhloVerify, self.spf_ehlo_verify.into_value()); + map.insert_unchecked(Property::SpfFromVerify, self.spf_from_verify.into_value()); + map.insert_unchecked(Property::ArcVerify, self.arc_verify.into_value()); + map.insert_unchecked(Property::DmarcVerify, self.dmarc_verify.into_value()); + map.insert_unchecked( + Property::ReverseIpVerify, + self.reverse_ip_verify.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SenderAuth { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::DkimStrict) => self.dkim_strict.patch(pointer, value), + Some(Property::DkimVerify) => self.dkim_verify.patch(pointer, value), + Some(Property::SpfEhloVerify) => self.spf_ehlo_verify.patch(pointer, value), + Some(Property::SpfFromVerify) => self.spf_from_verify.patch(pointer, value), + Some(Property::ArcVerify) => self.arc_verify.patch(pointer, value), + Some(Property::DmarcVerify) => self.dmarc_verify.patch(pointer, value), + Some(Property::ReverseIpVerify) => self.reverse_ip_verify.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ServerResponse { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.response_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseHostname)); + } + } + if let Some(value) = &self.response_code { + if *value < 100 { + errors.push(ValidationError::min_value(Property::ResponseCode, 100)); + } + if *value > 599 { + errors.push(ValidationError::max_value(Property::ResponseCode, 599)); + } + } + if let Some(value) = &self.response_enhanced { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseEnhanced)); + } + } + if let Some(value) = &self.response_message { + if value.is_empty() { + errors.push(ValidationError::required(Property::ResponseMessage)); + } + } + errors.len() == neb + } +} + +impl Pickle for ServerResponse { + fn pickle(&self, out: &mut Vec) { + self.response_hostname.pickle(out); + self.response_code.pickle(out); + self.response_enhanced.pickle(out); + self.response_message.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.response_hostname = Pickle::unpickle(stream)?; + this.response_code = Pickle::unpickle(stream)?; + this.response_enhanced = Pickle::unpickle(stream)?; + this.response_message = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ServerResponse { + fn default() -> Self { + Self { + response_hostname: Default::default(), + response_code: Default::default(), + response_enhanced: Default::default(), + response_message: Default::default(), + } + } +} + +impl IntoValue for ServerResponse { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked( + Property::ResponseHostname, + self.response_hostname.into_value(), + ); + map.insert_unchecked(Property::ResponseCode, self.response_code.into_value()); + map.insert_unchecked( + Property::ResponseEnhanced, + self.response_enhanced.into_value(), + ); + map.insert_unchecked( + Property::ResponseMessage, + self.response_message.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ServerResponse { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ResponseHostname) => self.response_hostname.patch(pointer, value), + Some(Property::ResponseCode) => self.response_code.patch(pointer, value), + Some(Property::ResponseEnhanced) => self.response_enhanced.patch(pointer, value), + Some(Property::ResponseMessage) => self.response_message.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl Service { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::Hostname)); + } + } + errors.len() == neb + } +} + +impl Pickle for Service { + fn pickle(&self, out: &mut Vec) { + self.hostname.pickle(out); + self.cleartext.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.hostname = Pickle::unpickle(stream)?; + this.cleartext = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Service { + fn default() -> Self { + Self { + hostname: Default::default(), + cleartext: false, + } + } +} + +impl IntoValue for Service { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Hostname, self.hostname.into_value()); + map.insert_unchecked(Property::Cleartext, self.cleartext.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Service { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Hostname) => self + .hostname + .patch(pointer.with_validators(&[StringValidator::Hostname]), value), + Some(Property::Cleartext) => self.cleartext.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ShardedBlobStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.stores; + for value in value.values() { + value.validate(errors); + } + if value.len() < 2 { + errors.push(ValidationError::min_items(Property::Stores, 2)); + } + errors.len() == neb + } +} + +impl Pickle for ShardedBlobStore { + fn pickle(&self, out: &mut Vec) { + self.stores.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.stores = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ShardedBlobStore { + fn default() -> Self { + Self { + stores: Default::default(), + } + } +} + +impl IntoValue for ShardedBlobStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Stores, self.stores.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ShardedBlobStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Stores) => self.stores.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ShardedInMemoryStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.stores; + for value in value.values() { + value.validate(errors); + } + if value.len() < 2 { + errors.push(ValidationError::min_items(Property::Stores, 2)); + } + errors.len() == neb + } +} + +impl Pickle for ShardedInMemoryStore { + fn pickle(&self, out: &mut Vec) { + self.stores.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.stores = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ShardedInMemoryStore { + fn default() -> Self { + Self { + stores: Default::default(), + } + } +} + +impl IntoValue for ShardedInMemoryStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Stores, self.stores.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ShardedInMemoryStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Stores) => self.stores.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Sharing { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Sharing; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_shares; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxShares, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Sharing { + fn pickle(&self, out: &mut Vec) { + self.allow_directory_queries.pickle(out); + self.max_shares.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_directory_queries = Pickle::unpickle(stream)?; + this.max_shares = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Sharing { + fn default() -> Self { + Self { + allow_directory_queries: false, + max_shares: 10u64, + } + } +} + +impl IntoValue for Sharing { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked( + Property::AllowDirectoryQueries, + self.allow_directory_queries.into_value(), + ); + map.insert_unchecked(Property::MaxShares, self.max_shares.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Sharing { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowDirectoryQueries) => { + self.allow_directory_queries.patch(pointer, value) + } + Some(Property::MaxShares) => self.max_shares.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SieveSystemInterpreter { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SieveSystemInterpreter; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.default_from_address; + value.validate(errors); + let value = &self.default_from_name; + value.validate(errors); + if let Some(value) = &self.message_id_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::MessageIdHostname)); + } + } + let value = &self.default_return_path; + if !value.match_.is_empty() || !value.else_.is_empty() { + value.validate(errors); + } + let value = &self.dkim_sign_domain; + value.validate(errors); + let value = &self.max_cpu_cycles; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxCpuCycles, 1)); + } + let value = &self.max_nested_includes; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxNestedIncludes, 1)); + } + let value = &self.max_received_headers; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxReceivedHeaders, 1)); + } + let value = &self.max_var_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxVarSize, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl SieveSystemInterpreter { + pub fn ctx_default_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.default_from_address, + default: Some(Expression { + else_: "'MAILER-DAEMON@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::DefaultFromAddress, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_default_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.default_from_name, + default: Some(Expression { + else_: "'Automated Message'".to_string(), + ..Default::default() + }), + property: Property::DefaultFromName, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_default_return_path(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.default_return_path, + default: None, + property: Property::DefaultReturnPath, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_RCPT_TO_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_default_from_address(), + self.ctx_default_from_name(), + self.ctx_default_return_path(), + self.ctx_dkim_sign_domain(), + ] + } +} + +impl Pickle for SieveSystemInterpreter { + fn pickle(&self, out: &mut Vec) { + self.default_from_address.pickle(out); + self.default_from_name.pickle(out); + self.message_id_hostname.pickle(out); + self.duplicate_expiry.pickle(out); + self.no_capability_check.pickle(out); + self.default_return_path.pickle(out); + self.dkim_sign_domain.pickle(out); + self.max_cpu_cycles.pickle(out); + self.max_nested_includes.pickle(out); + self.max_out_messages.pickle(out); + self.max_received_headers.pickle(out); + self.max_redirects.pickle(out); + self.max_var_size.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.default_from_address = Pickle::unpickle(stream)?; + this.default_from_name = Pickle::unpickle(stream)?; + this.message_id_hostname = Pickle::unpickle(stream)?; + this.duplicate_expiry = Pickle::unpickle(stream)?; + this.no_capability_check = Pickle::unpickle(stream)?; + this.default_return_path = Pickle::unpickle(stream)?; + this.dkim_sign_domain = Pickle::unpickle(stream)?; + this.max_cpu_cycles = Pickle::unpickle(stream)?; + this.max_nested_includes = Pickle::unpickle(stream)?; + this.max_out_messages = Pickle::unpickle(stream)?; + this.max_received_headers = Pickle::unpickle(stream)?; + this.max_redirects = Pickle::unpickle(stream)?; + this.max_var_size = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SieveSystemInterpreter { + fn default() -> Self { + Self { + default_from_address: Expression { + else_: "'MAILER-DAEMON@' + system('domain')".to_string(), + ..Default::default() + }, + default_from_name: Expression { + else_: "'Automated Message'".to_string(), + ..Default::default() + }, + message_id_hostname: Default::default(), + duplicate_expiry: Duration::from_millis(604800000), + no_capability_check: true, + default_return_path: Default::default(), + dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + max_cpu_cycles: 1048576u64, + max_nested_includes: 5u64, + max_out_messages: 5u64, + max_received_headers: 50u64, + max_redirects: 3u64, + max_var_size: 52428800u64, + } + } +} + +impl IntoValue for SieveSystemInterpreter { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked( + Property::DefaultFromAddress, + self.default_from_address.into_value(), + ); + map.insert_unchecked( + Property::DefaultFromName, + self.default_from_name.into_value(), + ); + map.insert_unchecked( + Property::MessageIdHostname, + self.message_id_hostname.into_value(), + ); + map.insert_unchecked( + Property::DuplicateExpiry, + self.duplicate_expiry.into_value(), + ); + map.insert_unchecked( + Property::NoCapabilityCheck, + self.no_capability_check.into_value(), + ); + map.insert_unchecked( + Property::DefaultReturnPath, + self.default_return_path.into_value(), + ); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + map.insert_unchecked(Property::MaxCpuCycles, self.max_cpu_cycles.into_value()); + map.insert_unchecked( + Property::MaxNestedIncludes, + self.max_nested_includes.into_value(), + ); + map.insert_unchecked(Property::MaxOutMessages, self.max_out_messages.into_value()); + map.insert_unchecked( + Property::MaxReceivedHeaders, + self.max_received_headers.into_value(), + ); + map.insert_unchecked(Property::MaxRedirects, self.max_redirects.into_value()); + map.insert_unchecked(Property::MaxVarSize, self.max_var_size.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SieveSystemInterpreter { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DefaultFromAddress) => self.default_from_address.patch(pointer, value), + Some(Property::DefaultFromName) => self.default_from_name.patch(pointer, value), + Some(Property::MessageIdHostname) => self + .message_id_hostname + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DuplicateExpiry) => self.duplicate_expiry.patch(pointer, value), + Some(Property::NoCapabilityCheck) => self.no_capability_check.patch(pointer, value), + Some(Property::DefaultReturnPath) => self.default_return_path.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::MaxCpuCycles) => self.max_cpu_cycles.patch(pointer, value), + Some(Property::MaxNestedIncludes) => self.max_nested_includes.patch(pointer, value), + Some(Property::MaxOutMessages) => self.max_out_messages.patch(pointer, value), + Some(Property::MaxReceivedHeaders) => self.max_received_headers.patch(pointer, value), + Some(Property::MaxRedirects) => self.max_redirects.patch(pointer, value), + Some(Property::MaxVarSize) => self.max_var_size.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SieveSystemScript { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SieveSystemScript; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.contents; + if value.is_empty() { + errors.push(ValidationError::required(Property::Contents)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for SieveSystemScript { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.is_active.pickle(out); + self.contents.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.is_active = Pickle::unpickle(stream)?; + this.contents = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SieveSystemScript { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + is_active: false, + contents: Default::default(), + } + } +} + +impl IntoValue for SieveSystemScript { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::IsActive, self.is_active.into_value()); + map.insert_unchecked(Property::Contents, self.contents.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SieveSystemScript { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self + .name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::IsActive) => self.is_active.patch(pointer, value), + Some(Property::Contents) => self + .contents + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SieveUserInterpreter { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 1; + const OBJECT: ObjectType = ObjectType::SieveUserInterpreter; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.allowed_notify_uris; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::AllowedNotifyUris)); + } + } + let value = &self.protected_headers; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ProtectedHeaders)); + } + } + let value = &self.default_subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultSubject)); + } + let value = &self.default_subject_prefix; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultSubjectPrefix)); + } + let value = &self.max_cpu_cycles; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxCpuCycles, 1)); + } + let value = &self.max_header_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxHeaderSize, 1)); + } + let value = &self.max_includes; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxIncludes, 1)); + } + let value = &self.max_local_vars; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxLocalVars, 1)); + } + let value = &self.max_match_vars; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxMatchVars, 1)); + } + let value = &self.max_script_name_length; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxScriptNameLength, 1)); + } + let value = &self.max_nested_blocks; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxNestedBlocks, 1)); + } + let value = &self.max_nested_for_every; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxNestedForEvery, 1)); + } + let value = &self.max_nested_includes; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxNestedIncludes, 1)); + } + let value = &self.max_nested_tests; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxNestedTests, 1)); + } + let value = &self.max_received_headers; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxReceivedHeaders, 1)); + } + let value = &self.max_script_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxScriptSize, 1)); + } + let value = &self.max_string_length; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxStringLength, 1)); + } + let value = &self.max_var_name_length; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxVarNameLength, 1)); + } + let value = &self.max_var_size; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxVarSize, 1)); + } + if let Some(value) = &self.max_scripts { + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxScripts, 1)); + } + } + let value = &self.dkim_sign_domain; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl SieveUserInterpreter { + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(sender_domain)".to_string(), + then: "sender_domain".to_string(), + }]), + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_QUEUE_SENDER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_dkim_sign_domain()] + } +} + +impl Pickle for SieveUserInterpreter { + fn pickle(&self, out: &mut Vec) { + self.default_expiry_duplicate.pickle(out); + self.default_expiry_vacation.pickle(out); + self.disable_capabilities.pickle(out); + self.allowed_notify_uris.pickle(out); + self.protected_headers.pickle(out); + self.default_subject.pickle(out); + self.default_subject_prefix.pickle(out); + self.max_cpu_cycles.pickle(out); + self.max_header_size.pickle(out); + self.max_includes.pickle(out); + self.max_local_vars.pickle(out); + self.max_match_vars.pickle(out); + self.max_script_name_length.pickle(out); + self.max_nested_blocks.pickle(out); + self.max_nested_for_every.pickle(out); + self.max_nested_includes.pickle(out); + self.max_nested_tests.pickle(out); + self.max_out_messages.pickle(out); + self.max_received_headers.pickle(out); + self.max_redirects.pickle(out); + self.max_script_size.pickle(out); + self.max_string_length.pickle(out); + self.max_var_name_length.pickle(out); + self.max_var_size.pickle(out); + self.max_scripts.pickle(out); + self.dkim_sign_domain.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.default_expiry_duplicate = Pickle::unpickle(stream)?; + this.default_expiry_vacation = Pickle::unpickle(stream)?; + this.disable_capabilities = Pickle::unpickle(stream)?; + this.allowed_notify_uris = Pickle::unpickle(stream)?; + this.protected_headers = Pickle::unpickle(stream)?; + this.default_subject = Pickle::unpickle(stream)?; + this.default_subject_prefix = Pickle::unpickle(stream)?; + this.max_cpu_cycles = Pickle::unpickle(stream)?; + this.max_header_size = Pickle::unpickle(stream)?; + this.max_includes = Pickle::unpickle(stream)?; + this.max_local_vars = Pickle::unpickle(stream)?; + this.max_match_vars = Pickle::unpickle(stream)?; + this.max_script_name_length = Pickle::unpickle(stream)?; + this.max_nested_blocks = Pickle::unpickle(stream)?; + this.max_nested_for_every = Pickle::unpickle(stream)?; + this.max_nested_includes = Pickle::unpickle(stream)?; + this.max_nested_tests = Pickle::unpickle(stream)?; + this.max_out_messages = Pickle::unpickle(stream)?; + this.max_received_headers = Pickle::unpickle(stream)?; + this.max_redirects = Pickle::unpickle(stream)?; + this.max_script_size = Pickle::unpickle(stream)?; + this.max_string_length = Pickle::unpickle(stream)?; + this.max_var_name_length = Pickle::unpickle(stream)?; + this.max_var_size = Pickle::unpickle(stream)?; + this.max_scripts = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.dkim_sign_domain = Pickle::unpickle(stream)?; + } + Some(this) + } +} + +impl Default for SieveUserInterpreter { + fn default() -> Self { + Self { + default_expiry_duplicate: Duration::from_millis(604800000), + default_expiry_vacation: Duration::from_millis(2592000000), + disable_capabilities: Default::default(), + allowed_notify_uris: Map::new(vec!["mailto".to_string()]), + protected_headers: Map::new(vec![ + "Original-Subject".to_string(), + "Original-From".to_string(), + "Received".to_string(), + "Auto-Submitted".to_string(), + ]), + default_subject: "Automated reply".to_string(), + default_subject_prefix: "Auto: ".to_string(), + max_cpu_cycles: 5000u64, + max_header_size: 1024u64, + max_includes: 3u64, + max_local_vars: 128u64, + max_match_vars: 30u64, + max_script_name_length: 512u64, + max_nested_blocks: 15u64, + max_nested_for_every: 3u64, + max_nested_includes: 3u64, + max_nested_tests: 15u64, + max_out_messages: 3u64, + max_received_headers: 50u64, + max_redirects: 1u64, + max_script_size: 102400, + max_string_length: 4096u64, + max_var_name_length: 32u64, + max_var_size: 4096u64, + max_scripts: Some(100u64), + dkim_sign_domain: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "is_local_domain(sender_domain)".to_string(), + then: "sender_domain".to_string(), + }]), + }, + } + } +} + +impl IntoValue for SieveUserInterpreter { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(28); + map.insert_unchecked( + Property::DefaultExpiryDuplicate, + self.default_expiry_duplicate.into_value(), + ); + map.insert_unchecked( + Property::DefaultExpiryVacation, + self.default_expiry_vacation.into_value(), + ); + map.insert_unchecked( + Property::DisableCapabilities, + self.disable_capabilities.into_value(), + ); + map.insert_unchecked( + Property::AllowedNotifyUris, + self.allowed_notify_uris.into_value(), + ); + map.insert_unchecked( + Property::ProtectedHeaders, + self.protected_headers.into_value(), + ); + map.insert_unchecked(Property::DefaultSubject, self.default_subject.into_value()); + map.insert_unchecked( + Property::DefaultSubjectPrefix, + self.default_subject_prefix.into_value(), + ); + map.insert_unchecked(Property::MaxCpuCycles, self.max_cpu_cycles.into_value()); + map.insert_unchecked(Property::MaxHeaderSize, self.max_header_size.into_value()); + map.insert_unchecked(Property::MaxIncludes, self.max_includes.into_value()); + map.insert_unchecked(Property::MaxLocalVars, self.max_local_vars.into_value()); + map.insert_unchecked(Property::MaxMatchVars, self.max_match_vars.into_value()); + map.insert_unchecked( + Property::MaxScriptNameLength, + self.max_script_name_length.into_value(), + ); + map.insert_unchecked( + Property::MaxNestedBlocks, + self.max_nested_blocks.into_value(), + ); + map.insert_unchecked( + Property::MaxNestedForEvery, + self.max_nested_for_every.into_value(), + ); + map.insert_unchecked( + Property::MaxNestedIncludes, + self.max_nested_includes.into_value(), + ); + map.insert_unchecked(Property::MaxNestedTests, self.max_nested_tests.into_value()); + map.insert_unchecked(Property::MaxOutMessages, self.max_out_messages.into_value()); + map.insert_unchecked( + Property::MaxReceivedHeaders, + self.max_received_headers.into_value(), + ); + map.insert_unchecked(Property::MaxRedirects, self.max_redirects.into_value()); + map.insert_unchecked(Property::MaxScriptSize, self.max_script_size.into_value()); + map.insert_unchecked( + Property::MaxStringLength, + self.max_string_length.into_value(), + ); + map.insert_unchecked( + Property::MaxVarNameLength, + self.max_var_name_length.into_value(), + ); + map.insert_unchecked(Property::MaxVarSize, self.max_var_size.into_value()); + map.insert_unchecked(Property::MaxScripts, self.max_scripts.into_value()); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SieveUserInterpreter { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DefaultExpiryDuplicate) => { + self.default_expiry_duplicate.patch(pointer, value) + } + Some(Property::DefaultExpiryVacation) => { + self.default_expiry_vacation.patch(pointer, value) + } + Some(Property::DisableCapabilities) => self.disable_capabilities.patch(pointer, value), + Some(Property::AllowedNotifyUris) => self + .allowed_notify_uris + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ProtectedHeaders) => self + .protected_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DefaultSubject) => self + .default_subject + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::DefaultSubjectPrefix) => { + self.default_subject_prefix.patch(pointer, value) + } + Some(Property::MaxCpuCycles) => self.max_cpu_cycles.patch(pointer, value), + Some(Property::MaxHeaderSize) => self.max_header_size.patch(pointer, value), + Some(Property::MaxIncludes) => self.max_includes.patch(pointer, value), + Some(Property::MaxLocalVars) => self.max_local_vars.patch(pointer, value), + Some(Property::MaxMatchVars) => self.max_match_vars.patch(pointer, value), + Some(Property::MaxScriptNameLength) => { + self.max_script_name_length.patch(pointer, value) + } + Some(Property::MaxNestedBlocks) => self.max_nested_blocks.patch(pointer, value), + Some(Property::MaxNestedForEvery) => self.max_nested_for_every.patch(pointer, value), + Some(Property::MaxNestedIncludes) => self.max_nested_includes.patch(pointer, value), + Some(Property::MaxNestedTests) => self.max_nested_tests.patch(pointer, value), + Some(Property::MaxOutMessages) => self.max_out_messages.patch(pointer, value), + Some(Property::MaxReceivedHeaders) => self.max_received_headers.patch(pointer, value), + Some(Property::MaxRedirects) => self.max_redirects.patch(pointer, value), + Some(Property::MaxScriptSize) => self.max_script_size.patch(pointer, value), + Some(Property::MaxStringLength) => self.max_string_length.patch(pointer, value), + Some(Property::MaxVarNameLength) => self.max_var_name_length.patch(pointer, value), + Some(Property::MaxVarSize) => self.max_var_size.patch(pointer, value), + Some(Property::MaxScripts) => self.max_scripts.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SieveUserScript { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SieveUserScript; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.contents; + if value.is_empty() { + errors.push(ValidationError::required(Property::Contents)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for SieveUserScript { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.description.pickle(out); + self.is_active.pickle(out); + self.contents.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.is_active = Pickle::unpickle(stream)?; + this.contents = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SieveUserScript { + fn default() -> Self { + Self { + name: Default::default(), + description: Default::default(), + is_active: false, + contents: Default::default(), + } + } +} + +impl IntoValue for SieveUserScript { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::IsActive, self.is_active.into_value()); + map.insert_unchecked(Property::Contents, self.contents.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SieveUserScript { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self + .name + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::IsActive) => self.is_active.patch(pointer, value), + Some(Property::Contents) => self + .contents + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamClassifier { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamClassifier; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.model; + value.validate(errors); + let value = &self.learn_spam_from_rbl_hits; + if *value > 100 { + errors.push(ValidationError::max_value( + Property::LearnSpamFromRblHits, + 100, + )); + } + let value = &self.min_ham_samples; + if *value > 10000 { + errors.push(ValidationError::max_value(Property::MinHamSamples, 10000)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MinHamSamples, 1)); + } + let value = &self.min_spam_samples; + if *value > 10000 { + errors.push(ValidationError::max_value(Property::MinSpamSamples, 10000)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::MinSpamSamples, 1)); + } + let value = &self.reservoir_capacity; + if *value > 100000 { + errors.push(ValidationError::max_value( + Property::ReservoirCapacity, + 100000, + )); + } + if *value < 100 { + errors.push(ValidationError::min_value(Property::ReservoirCapacity, 100)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for SpamClassifier { + fn pickle(&self, out: &mut Vec) { + self.model.pickle(out); + self.learn_ham_from_card.pickle(out); + self.learn_spam_from_rbl_hits.pickle(out); + self.learn_spam_from_traps.pickle(out); + self.hold_samples_for.pickle(out); + self.min_ham_samples.pickle(out); + self.min_spam_samples.pickle(out); + self.reservoir_capacity.pickle(out); + self.train_frequency.pickle(out); + self.learn_ham_from_reply.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.model = Pickle::unpickle(stream)?; + this.learn_ham_from_card = Pickle::unpickle(stream)?; + this.learn_spam_from_rbl_hits = Pickle::unpickle(stream)?; + this.learn_spam_from_traps = Pickle::unpickle(stream)?; + this.hold_samples_for = Pickle::unpickle(stream)?; + this.min_ham_samples = Pickle::unpickle(stream)?; + this.min_spam_samples = Pickle::unpickle(stream)?; + this.reservoir_capacity = Pickle::unpickle(stream)?; + this.train_frequency = Pickle::unpickle(stream)?; + this.learn_ham_from_reply = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamClassifier { + fn default() -> Self { + Self { + model: Default::default(), + learn_ham_from_card: true, + learn_spam_from_rbl_hits: 2u64, + learn_spam_from_traps: true, + hold_samples_for: Duration::from_millis(15552000000), + min_ham_samples: 100u64, + min_spam_samples: 100u64, + reservoir_capacity: 1024u64, + train_frequency: Some(Duration::from_millis(43200000)), + learn_ham_from_reply: true, + } + } +} + +impl IntoValue for SpamClassifier { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Model, self.model.into_value()); + map.insert_unchecked( + Property::LearnHamFromCard, + self.learn_ham_from_card.into_value(), + ); + map.insert_unchecked( + Property::LearnSpamFromRblHits, + self.learn_spam_from_rbl_hits.into_value(), + ); + map.insert_unchecked( + Property::LearnSpamFromTraps, + self.learn_spam_from_traps.into_value(), + ); + map.insert_unchecked(Property::HoldSamplesFor, self.hold_samples_for.into_value()); + map.insert_unchecked(Property::MinHamSamples, self.min_ham_samples.into_value()); + map.insert_unchecked(Property::MinSpamSamples, self.min_spam_samples.into_value()); + map.insert_unchecked( + Property::ReservoirCapacity, + self.reservoir_capacity.into_value(), + ); + map.insert_unchecked(Property::TrainFrequency, self.train_frequency.into_value()); + map.insert_unchecked( + Property::LearnHamFromReply, + self.learn_ham_from_reply.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamClassifier { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Model) => self.model.patch(pointer, value), + Some(Property::LearnHamFromCard) => self.learn_ham_from_card.patch(pointer, value), + Some(Property::LearnSpamFromRblHits) => { + self.learn_spam_from_rbl_hits.patch(pointer, value) + } + Some(Property::LearnSpamFromTraps) => self.learn_spam_from_traps.patch(pointer, value), + Some(Property::HoldSamplesFor) => self.hold_samples_for.patch(pointer, value), + Some(Property::MinHamSamples) => self.min_ham_samples.patch(pointer, value), + Some(Property::MinSpamSamples) => self.min_spam_samples.patch(pointer, value), + Some(Property::ReservoirCapacity) => self.reservoir_capacity.patch(pointer, value), + Some(Property::TrainFrequency) => self.train_frequency.patch(pointer, value), + Some(Property::LearnHamFromReply) => self.learn_ham_from_reply.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamClassifierFtrlCcfh { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.indicator_parameters; + value.validate(errors); + let value = &self.parameters; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for SpamClassifierFtrlCcfh { + fn pickle(&self, out: &mut Vec) { + self.indicator_parameters.pickle(out); + self.parameters.pickle(out); + self.feature_l2_normalize.pickle(out); + self.feature_log_scale.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.indicator_parameters = Pickle::unpickle(stream)?; + this.parameters = Pickle::unpickle(stream)?; + this.feature_l2_normalize = Pickle::unpickle(stream)?; + this.feature_log_scale = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamClassifierFtrlCcfh { + fn default() -> Self { + Self { + indicator_parameters: FtrlParameters { + num_features: ModelSize::V18, + ..Default::default() + }, + parameters: FtrlParameters { + num_features: ModelSize::V20, + ..Default::default() + }, + feature_l2_normalize: true, + feature_log_scale: true, + } + } +} + +impl IntoValue for SpamClassifierFtrlCcfh { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked( + Property::IndicatorParameters, + self.indicator_parameters.into_value(), + ); + map.insert_unchecked(Property::Parameters, self.parameters.into_value()); + map.insert_unchecked( + Property::FeatureL2Normalize, + self.feature_l2_normalize.into_value(), + ); + map.insert_unchecked( + Property::FeatureLogScale, + self.feature_log_scale.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamClassifierFtrlCcfh { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::IndicatorParameters) => self.indicator_parameters.patch(pointer, value), + Some(Property::Parameters) => self.parameters.patch(pointer, value), + Some(Property::FeatureL2Normalize) => self.feature_l2_normalize.patch(pointer, value), + Some(Property::FeatureLogScale) => self.feature_log_scale.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamClassifierFtrlFh { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.parameters; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for SpamClassifierFtrlFh { + fn pickle(&self, out: &mut Vec) { + self.parameters.pickle(out); + self.feature_l2_normalize.pickle(out); + self.feature_log_scale.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.parameters = Pickle::unpickle(stream)?; + this.feature_l2_normalize = Pickle::unpickle(stream)?; + this.feature_log_scale = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamClassifierFtrlFh { + fn default() -> Self { + Self { + parameters: FtrlParameters { + num_features: ModelSize::V20, + ..Default::default() + }, + feature_l2_normalize: true, + feature_log_scale: true, + } + } +} + +impl IntoValue for SpamClassifierFtrlFh { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Parameters, self.parameters.into_value()); + map.insert_unchecked( + Property::FeatureL2Normalize, + self.feature_l2_normalize.into_value(), + ); + map.insert_unchecked( + Property::FeatureLogScale, + self.feature_log_scale.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamClassifierFtrlFh { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Parameters) => self.parameters.patch(pointer, value), + Some(Property::FeatureL2Normalize) => self.feature_l2_normalize.patch(pointer, value), + Some(Property::FeatureLogScale) => self.feature_log_scale.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamClassifierModel { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SpamClassifierModel::FtrlFh(inner) => inner.validate(errors), + SpamClassifierModel::FtrlCcfh(inner) => inner.validate(errors), + SpamClassifierModel::Disabled => true, + } + } +} + +impl Default for SpamClassifierModel { + fn default() -> Self { + SpamClassifierModel::FtrlFh(Default::default()) + } +} + +impl Pickle for SpamClassifierModel { + fn pickle(&self, out: &mut Vec) { + match self { + SpamClassifierModel::FtrlFh(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SpamClassifierModel::FtrlCcfh(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SpamClassifierModel::Disabled => { + 2u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SpamClassifierModel::FtrlFh), + 1 => Pickle::unpickle(stream).map(SpamClassifierModel::FtrlCcfh), + 2 => Some(SpamClassifierModel::Disabled), + _ => None, + } + } +} + +impl IntoValue for SpamClassifierModel { + fn into_value(self) -> JmapValue<'static> { + match self { + SpamClassifierModel::FtrlFh(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FtrlFh".into())); + obj + } + SpamClassifierModel::FtrlCcfh(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FtrlCcfh".into())); + obj + } + SpamClassifierModel::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for SpamClassifierModel { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SpamClassifierModelType::FtrlFh => { + *self = SpamClassifierModel::FtrlFh(Default::default()) + } + SpamClassifierModelType::FtrlCcfh => { + *self = SpamClassifierModel::FtrlCcfh(Default::default()) + } + SpamClassifierModelType::Disabled => *self = SpamClassifierModel::Disabled, + } + } + match self { + SpamClassifierModel::FtrlFh(inner) => inner.patch(pointer, value), + SpamClassifierModel::FtrlCcfh(inner) => inner.patch(pointer, value), + SpamClassifierModel::Disabled => pointer.assert_eof(), + } + } +} + +impl SpamClassifierModel { + pub fn object_type(&self) -> SpamClassifierModelType { + match self { + SpamClassifierModel::FtrlFh(_) => SpamClassifierModelType::FtrlFh, + SpamClassifierModel::FtrlCcfh(_) => SpamClassifierModelType::FtrlCcfh, + SpamClassifierModel::Disabled => SpamClassifierModelType::Disabled, + } + } +} + +impl SpamClassify { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.message; + if value.is_empty() { + errors.push(ValidationError::required(Property::Message)); + } + let value = &self.remote_ip; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::RemoteIp, value)); + } + let value = &self.ehlo_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::EhloDomain)); + } + if let Some(value) = &self.authenticated_as { + if value.is_empty() { + errors.push(ValidationError::required(Property::AuthenticatedAs)); + } + } + let value = &self.env_from; + if value.is_empty() { + errors.push(ValidationError::required(Property::EnvFrom)); + } + let value = &self.env_rcpt_to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::EnvRcptTo)); + } + } + let value = &self.tags; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for SpamClassify { + fn pickle(&self, out: &mut Vec) { + self.message.pickle(out); + self.remote_ip.pickle(out); + self.ehlo_domain.pickle(out); + self.authenticated_as.pickle(out); + self.is_tls.pickle(out); + self.env_from.pickle(out); + self.env_from_parameters.pickle(out); + self.env_rcpt_to.pickle(out); + self.score.pickle(out); + self.tags.pickle(out); + self.result.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.message = Pickle::unpickle(stream)?; + this.remote_ip = Pickle::unpickle(stream)?; + this.ehlo_domain = Pickle::unpickle(stream)?; + this.authenticated_as = Pickle::unpickle(stream)?; + this.is_tls = Pickle::unpickle(stream)?; + this.env_from = Pickle::unpickle(stream)?; + this.env_from_parameters = Pickle::unpickle(stream)?; + this.env_rcpt_to = Pickle::unpickle(stream)?; + this.score = Pickle::unpickle(stream)?; + this.tags = Pickle::unpickle(stream)?; + this.result = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamClassify { + fn default() -> Self { + Self { + message: Default::default(), + remote_ip: Default::default(), + ehlo_domain: Default::default(), + authenticated_as: Default::default(), + is_tls: true, + env_from: Default::default(), + env_from_parameters: Default::default(), + env_rcpt_to: Default::default(), + score: Float::new(0.0f64), + tags: Default::default(), + result: Default::default(), + } + } +} + +impl IntoValue for SpamClassify { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::Message, self.message.into_value()); + map.insert_unchecked(Property::RemoteIp, self.remote_ip.into_value()); + map.insert_unchecked(Property::EhloDomain, self.ehlo_domain.into_value()); + map.insert_unchecked( + Property::AuthenticatedAs, + self.authenticated_as.into_value(), + ); + map.insert_unchecked(Property::IsTls, self.is_tls.into_value()); + map.insert_unchecked(Property::EnvFrom, self.env_from.into_value()); + map.insert_unchecked( + Property::EnvFromParameters, + self.env_from_parameters.into_value(), + ); + map.insert_unchecked(Property::EnvRcptTo, self.env_rcpt_to.into_value()); + map.insert_unchecked(Property::Score, self.score.into_value()); + map.insert_unchecked(Property::Tags, self.tags.into_value()); + map.insert_unchecked(Property::Result, self.result.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamClassify { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Message) => self.message.patch(pointer, value), + Some(Property::RemoteIp) => self.remote_ip.patch(pointer, value), + Some(Property::EhloDomain) => self.ehlo_domain.patch(pointer, value), + Some(Property::AuthenticatedAs) => self.authenticated_as.patch(pointer, value), + Some(Property::IsTls) => self.is_tls.patch(pointer, value), + Some(Property::EnvFrom) => self + .env_from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::EnvFromParameters) => self.env_from_parameters.patch(pointer, value), + Some(Property::EnvRcptTo) => self + .env_rcpt_to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Score) => pointer.assert_server_set(), + Some(Property::Tags) => pointer.assert_server_set(), + Some(Property::Result) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamClassifyTag { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for SpamClassifyTag { + fn pickle(&self, out: &mut Vec) { + self.score.pickle(out); + self.disposition.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.score = Pickle::unpickle(stream)?; + this.disposition = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamClassifyTag { + fn default() -> Self { + Self { + score: Float::new(0.0f64), + disposition: Default::default(), + } + } +} + +impl IntoValue for SpamClassifyTag { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Score, self.score.into_value()); + map.insert_unchecked(Property::Disposition, self.disposition.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamClassifyTag { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Score) => pointer.assert_server_set(), + Some(Property::Disposition) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamDnsblServer { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamDnsblServer; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SpamDnsblServer::Any(inner) => inner.validate(errors), + SpamDnsblServer::Url(inner) => inner.validate(errors), + SpamDnsblServer::Domain(inner) => inner.validate(errors), + SpamDnsblServer::Email(inner) => inner.validate(errors), + SpamDnsblServer::Ip(inner) => inner.validate(errors), + SpamDnsblServer::Header(inner) => inner.validate(errors), + SpamDnsblServer::Body(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + SpamDnsblServer::Any(object) => { + object.index(i); + } + SpamDnsblServer::Url(object) => { + object.index(i); + } + SpamDnsblServer::Domain(object) => { + object.index(i); + } + SpamDnsblServer::Email(object) => { + object.index(i); + } + SpamDnsblServer::Ip(object) => { + object.index(i); + } + SpamDnsblServer::Header(object) => { + object.index(i); + } + SpamDnsblServer::Body(object) => { + object.index(i); + } + } + } +} + +impl Default for SpamDnsblServer { + fn default() -> Self { + SpamDnsblServer::Any(Default::default()) + } +} + +impl Pickle for SpamDnsblServer { + fn pickle(&self, out: &mut Vec) { + match self { + SpamDnsblServer::Any(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Url(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Domain(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Email(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Ip(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Header(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + SpamDnsblServer::Body(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SpamDnsblServer::Any), + 1 => Pickle::unpickle(stream).map(SpamDnsblServer::Url), + 2 => Pickle::unpickle(stream).map(SpamDnsblServer::Domain), + 3 => Pickle::unpickle(stream).map(SpamDnsblServer::Email), + 4 => Pickle::unpickle(stream).map(SpamDnsblServer::Ip), + 5 => Pickle::unpickle(stream).map(SpamDnsblServer::Header), + 6 => Pickle::unpickle(stream).map(SpamDnsblServer::Body), + _ => None, + } + } +} + +impl IntoValue for SpamDnsblServer { + fn into_value(self) -> JmapValue<'static> { + match self { + SpamDnsblServer::Any(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Any".into())); + obj + } + SpamDnsblServer::Url(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Url".into())); + obj + } + SpamDnsblServer::Domain(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Domain".into())); + obj + } + SpamDnsblServer::Email(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Email".into())); + obj + } + SpamDnsblServer::Ip(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ip".into())); + obj + } + SpamDnsblServer::Header(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Header".into())); + obj + } + SpamDnsblServer::Body(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Body".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SpamDnsblServer { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SpamDnsblServerType::Any => *self = SpamDnsblServer::Any(Default::default()), + SpamDnsblServerType::Url => *self = SpamDnsblServer::Url(Default::default()), + SpamDnsblServerType::Domain => *self = SpamDnsblServer::Domain(Default::default()), + SpamDnsblServerType::Email => *self = SpamDnsblServer::Email(Default::default()), + SpamDnsblServerType::Ip => *self = SpamDnsblServer::Ip(Default::default()), + SpamDnsblServerType::Header => *self = SpamDnsblServer::Header(Default::default()), + SpamDnsblServerType::Body => *self = SpamDnsblServer::Body(Default::default()), + } + } + match self { + SpamDnsblServer::Any(inner) => inner.patch(pointer, value), + SpamDnsblServer::Url(inner) => inner.patch(pointer, value), + SpamDnsblServer::Domain(inner) => inner.patch(pointer, value), + SpamDnsblServer::Email(inner) => inner.patch(pointer, value), + SpamDnsblServer::Ip(inner) => inner.patch(pointer, value), + SpamDnsblServer::Header(inner) => inner.patch(pointer, value), + SpamDnsblServer::Body(inner) => inner.patch(pointer, value), + } + } +} + +impl SpamDnsblServer { + pub fn object_type(&self) -> SpamDnsblServerType { + match self { + SpamDnsblServer::Any(_) => SpamDnsblServerType::Any, + SpamDnsblServer::Url(_) => SpamDnsblServerType::Url, + SpamDnsblServer::Domain(_) => SpamDnsblServerType::Domain, + SpamDnsblServer::Email(_) => SpamDnsblServerType::Email, + SpamDnsblServer::Ip(_) => SpamDnsblServerType::Ip, + SpamDnsblServer::Header(_) => SpamDnsblServerType::Header, + SpamDnsblServer::Body(_) => SpamDnsblServerType::Body, + } + } + + pub fn expression_ctxs(&self) -> Vec> { + match self { + SpamDnsblServer::Any(obj) => obj.expression_ctxs(), + SpamDnsblServer::Url(obj) => obj.expression_ctxs(), + SpamDnsblServer::Domain(obj) => obj.expression_ctxs(), + SpamDnsblServer::Email(obj) => obj.expression_ctxs(), + SpamDnsblServer::Ip(obj) => obj.expression_ctxs(), + SpamDnsblServer::Header(obj) => obj.expression_ctxs(), + SpamDnsblServer::Body(obj) => obj.expression_ctxs(), + } + } +} + +impl SpamDnsblServerAny { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerAny { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerAny { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerAny { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerAny { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerAny { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerBody { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerBody { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerBody { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerBody { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerBody { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerBody { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerDomain { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerDomain { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerDomain { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerDomain { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerDomain { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerDomain { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerEmail { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerEmail { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_EMAIL_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerEmail { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerEmail { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerEmail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerEmail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerHeader { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerHeader { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_HEADER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerHeader { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerHeader { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerHeader { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerHeader { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerIp { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerIp { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerIp { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerIp { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerIp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerIp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamDnsblServerUrl { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + value.validate(errors); + let value = &self.zone; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamDnsblServerUrl { + pub fn ctx_tag(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.tag, + default: None, + property: Property::Tag, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_zone(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.zone, + default: None, + property: Property::Zone, + allowed_variables: SPAM_URL_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_tag(), self.ctx_zone()] + } +} + +impl Pickle for SpamDnsblServerUrl { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.zone.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.zone = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblServerUrl { + fn default() -> Self { + Self { + tag: Default::default(), + zone: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + } + } +} + +impl IntoValue for SpamDnsblServerUrl { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Zone, self.zone.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblServerUrl { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch(pointer, value), + Some(Property::Zone) => self.zone.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamDnsblSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamDnsblSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.domain_limit; + if *value < 1 { + errors.push(ValidationError::min_value(Property::DomainLimit, 1)); + } + let value = &self.email_limit; + if *value < 1 { + errors.push(ValidationError::min_value(Property::EmailLimit, 1)); + } + let value = &self.ip_limit; + if *value < 1 { + errors.push(ValidationError::min_value(Property::IpLimit, 1)); + } + let value = &self.url_limit; + if *value < 1 { + errors.push(ValidationError::min_value(Property::UrlLimit, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for SpamDnsblSettings { + fn pickle(&self, out: &mut Vec) { + self.domain_limit.pickle(out); + self.email_limit.pickle(out); + self.ip_limit.pickle(out); + self.url_limit.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.domain_limit = Pickle::unpickle(stream)?; + this.email_limit = Pickle::unpickle(stream)?; + this.ip_limit = Pickle::unpickle(stream)?; + this.url_limit = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamDnsblSettings { + fn default() -> Self { + Self { + domain_limit: 50u64, + email_limit: 50u64, + ip_limit: 50u64, + url_limit: 50u64, + } + } +} + +impl IntoValue for SpamDnsblSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::DomainLimit, self.domain_limit.into_value()); + map.insert_unchecked(Property::EmailLimit, self.email_limit.into_value()); + map.insert_unchecked(Property::IpLimit, self.ip_limit.into_value()); + map.insert_unchecked(Property::UrlLimit, self.url_limit.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamDnsblSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DomainLimit) => self.domain_limit.patch(pointer, value), + Some(Property::EmailLimit) => self.email_limit.patch(pointer, value), + Some(Property::IpLimit) => self.ip_limit.patch(pointer, value), + Some(Property::UrlLimit) => self.url_limit.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamFileExtension { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamFileExtension; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.extension; + if value.is_empty() { + errors.push(ValidationError::required(Property::Extension)); + } + let value = &self.content_types; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ContentTypes)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Extension, &self.extension); + } +} + +impl Pickle for SpamFileExtension { + fn pickle(&self, out: &mut Vec) { + self.extension.pickle(out); + self.is_archive.pickle(out); + self.is_bad.pickle(out); + self.is_nz.pickle(out); + self.content_types.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.extension = Pickle::unpickle(stream)?; + this.is_archive = Pickle::unpickle(stream)?; + this.is_bad = Pickle::unpickle(stream)?; + this.is_nz = Pickle::unpickle(stream)?; + this.content_types = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamFileExtension { + fn default() -> Self { + Self { + extension: Default::default(), + is_archive: false, + is_bad: false, + is_nz: false, + content_types: Default::default(), + } + } +} + +impl IntoValue for SpamFileExtension { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Extension, self.extension.into_value()); + map.insert_unchecked(Property::IsArchive, self.is_archive.into_value()); + map.insert_unchecked(Property::IsBad, self.is_bad.into_value()); + map.insert_unchecked(Property::IsNz, self.is_nz.into_value()); + map.insert_unchecked(Property::ContentTypes, self.content_types.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamFileExtension { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Extension) => self.extension.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Lowercase]), + value, + ), + Some(Property::IsArchive) => self.is_archive.patch(pointer, value), + Some(Property::IsBad) => self.is_bad.patch(pointer, value), + Some(Property::IsNz) => self.is_nz.patch(pointer, value), + Some(Property::ContentTypes) => self + .content_types + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamLlm { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamLlm; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SpamLlm::Disable => true, + SpamLlm::Enable(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + SpamLlm::Disable => {} + SpamLlm::Enable(object) => { + object.index(i); + } + } + } +} + +impl Default for SpamLlm { + fn default() -> Self { + SpamLlm::Disable + } +} + +impl Pickle for SpamLlm { + fn pickle(&self, out: &mut Vec) { + match self { + SpamLlm::Disable => { + 0u16.pickle(out); + } + SpamLlm::Enable(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SpamLlm::Disable), + 1 => Pickle::unpickle(stream).map(SpamLlm::Enable), + _ => None, + } + } +} + +impl IntoValue for SpamLlm { + fn into_value(self) -> JmapValue<'static> { + match self { + SpamLlm::Disable => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disable".into())); + JmapValue::Object(obj) + } + SpamLlm::Enable(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Enable".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SpamLlm { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SpamLlmType::Disable => *self = SpamLlm::Disable, + SpamLlmType::Enable => *self = SpamLlm::Enable(Default::default()), + } + } + match self { + SpamLlm::Disable => pointer.assert_eof(), + SpamLlm::Enable(inner) => inner.patch(pointer, value), + } + } +} + +impl SpamLlm { + pub fn object_type(&self) -> SpamLlmType { + match self { + SpamLlm::Disable => SpamLlmType::Disable, + SpamLlm::Enable(_) => SpamLlmType::Enable, + } + } +} + +impl SpamLlmProperties { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.categories; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Categories)); + } + } + if value.len() < 2 { + errors.push(ValidationError::min_items(Property::Categories, 2)); + } + let value = &self.confidence; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Confidence)); + } + } + let value = &self.model_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::ModelId)); + } + let value = &self.prompt; + if value.is_empty() { + errors.push(ValidationError::required(Property::Prompt)); + } + let value = &self.separator; + if value.is_empty() { + errors.push(ValidationError::required(Property::Separator)); + } + let value = &self.temperature; + if *value > Float::new(1.0) { + errors.push(ValidationError::max_value(Property::Temperature, 1)); + } + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::Temperature, 0)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::AiModel, self.model_id.into(), None); + } +} + +impl Pickle for SpamLlmProperties { + fn pickle(&self, out: &mut Vec) { + self.categories.pickle(out); + self.confidence.pickle(out); + self.response_pos_category.pickle(out); + self.response_pos_confidence.pickle(out); + self.response_pos_explanation.pickle(out); + self.model_id.pickle(out); + self.prompt.pickle(out); + self.separator.pickle(out); + self.temperature.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.categories = Pickle::unpickle(stream)?; + this.confidence = Pickle::unpickle(stream)?; + this.response_pos_category = Pickle::unpickle(stream)?; + this.response_pos_confidence = Pickle::unpickle(stream)?; + this.response_pos_explanation = Pickle::unpickle(stream)?; + this.model_id = Pickle::unpickle(stream)?; + this.prompt = Pickle::unpickle(stream)?; + this.separator = Pickle::unpickle(stream)?; + this.temperature = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamLlmProperties { + fn default() -> Self { + Self { + categories: Map::new(vec![ + "Unsolicited".to_string(), + "Commercial".to_string(), + "Harmful".to_string(), + "Legitimate".to_string(), + ]), + confidence: Map::new(vec![ + "High".to_string(), + "Medium".to_string(), + "Low".to_string(), + ]), + response_pos_category: 0u64, + response_pos_confidence: Some(1u64), + response_pos_explanation: Some(2u64), + model_id: Default::default(), + prompt: Default::default(), + separator: ",".to_string(), + temperature: Float::new(0.5f64), + } + } +} + +impl IntoValue for SpamLlmProperties { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::Categories, self.categories.into_value()); + map.insert_unchecked(Property::Confidence, self.confidence.into_value()); + map.insert_unchecked( + Property::ResponsePosCategory, + self.response_pos_category.into_value(), + ); + map.insert_unchecked( + Property::ResponsePosConfidence, + self.response_pos_confidence.into_value(), + ); + map.insert_unchecked( + Property::ResponsePosExplanation, + self.response_pos_explanation.into_value(), + ); + map.insert_unchecked(Property::ModelId, self.model_id.into_value()); + map.insert_unchecked(Property::Prompt, self.prompt.into_value()); + map.insert_unchecked(Property::Separator, self.separator.into_value()); + map.insert_unchecked(Property::Temperature, self.temperature.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamLlmProperties { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Categories) => self.categories.patch(pointer, value), + Some(Property::Confidence) => self.confidence.patch(pointer, value), + Some(Property::ResponsePosCategory) => self.response_pos_category.patch(pointer, value), + Some(Property::ResponsePosConfidence) => { + self.response_pos_confidence.patch(pointer, value) + } + Some(Property::ResponsePosExplanation) => { + self.response_pos_explanation.patch(pointer, value) + } + Some(Property::ModelId) => self.model_id.patch(pointer, value), + Some(Property::Prompt) => self.prompt.patch(pointer, value), + Some(Property::Separator) => self + .separator + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Temperature) => self.temperature.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamPyzor { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamPyzor; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.block_count; + if *value > 1000 { + errors.push(ValidationError::max_value(Property::BlockCount, 1000)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::BlockCount, 1)); + } + let value = &self.host; + if value.is_empty() { + errors.push(ValidationError::required(Property::Host)); + } + let value = &self.port; + if *value > 65535 { + errors.push(ValidationError::max_value(Property::Port, 65535)); + } + if *value < 100 { + errors.push(ValidationError::min_value(Property::Port, 100)); + } + let value = &self.ratio; + if *value > Float::new(1.0) { + errors.push(ValidationError::max_value(Property::Ratio, 1)); + } + if *value < Float::new(0.0) { + errors.push(ValidationError::min_value(Property::Ratio, 0)); + } + let value = &self.allow_count; + if *value > 1000 { + errors.push(ValidationError::max_value(Property::AllowCount, 1000)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::AllowCount, 1)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for SpamPyzor { + fn pickle(&self, out: &mut Vec) { + self.block_count.pickle(out); + self.enable.pickle(out); + self.host.pickle(out); + self.port.pickle(out); + self.ratio.pickle(out); + self.timeout.pickle(out); + self.allow_count.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.block_count = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.host = Pickle::unpickle(stream)?; + this.port = Pickle::unpickle(stream)?; + this.ratio = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.allow_count = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamPyzor { + fn default() -> Self { + Self { + block_count: 5u64, + enable: true, + host: "public.pyzor.org".to_string(), + port: 24441u64, + ratio: Float::new(0.2f64), + timeout: Duration::from_millis(5000), + allow_count: 10u64, + } + } +} + +impl IntoValue for SpamPyzor { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::BlockCount, self.block_count.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Host, self.host.into_value()); + map.insert_unchecked(Property::Port, self.port.into_value()); + map.insert_unchecked(Property::Ratio, self.ratio.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::AllowCount, self.allow_count.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamPyzor { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BlockCount) => self.block_count.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Host) => self + .host + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Port) => self.port.patch(pointer, value), + Some(Property::Ratio) => self.ratio.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::AllowCount) => self.allow_count.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamRule { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamRule; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SpamRule::Any(inner) => inner.validate(errors), + SpamRule::Url(inner) => inner.validate(errors), + SpamRule::Domain(inner) => inner.validate(errors), + SpamRule::Email(inner) => inner.validate(errors), + SpamRule::Ip(inner) => inner.validate(errors), + SpamRule::Header(inner) => inner.validate(errors), + SpamRule::Body(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + SpamRule::Any(object) => { + object.index(i); + } + SpamRule::Url(object) => { + object.index(i); + } + SpamRule::Domain(object) => { + object.index(i); + } + SpamRule::Email(object) => { + object.index(i); + } + SpamRule::Ip(object) => { + object.index(i); + } + SpamRule::Header(object) => { + object.index(i); + } + SpamRule::Body(object) => { + object.index(i); + } + } + } +} + +impl Default for SpamRule { + fn default() -> Self { + SpamRule::Any(Default::default()) + } +} + +impl Pickle for SpamRule { + fn pickle(&self, out: &mut Vec) { + match self { + SpamRule::Any(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SpamRule::Url(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SpamRule::Domain(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SpamRule::Email(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + SpamRule::Ip(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + SpamRule::Header(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + SpamRule::Body(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SpamRule::Any), + 1 => Pickle::unpickle(stream).map(SpamRule::Url), + 2 => Pickle::unpickle(stream).map(SpamRule::Domain), + 3 => Pickle::unpickle(stream).map(SpamRule::Email), + 4 => Pickle::unpickle(stream).map(SpamRule::Ip), + 5 => Pickle::unpickle(stream).map(SpamRule::Header), + 6 => Pickle::unpickle(stream).map(SpamRule::Body), + _ => None, + } + } +} + +impl IntoValue for SpamRule { + fn into_value(self) -> JmapValue<'static> { + match self { + SpamRule::Any(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Any".into())); + obj + } + SpamRule::Url(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Url".into())); + obj + } + SpamRule::Domain(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Domain".into())); + obj + } + SpamRule::Email(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Email".into())); + obj + } + SpamRule::Ip(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Ip".into())); + obj + } + SpamRule::Header(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Header".into())); + obj + } + SpamRule::Body(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Body".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SpamRule { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SpamRuleType::Any => *self = SpamRule::Any(Default::default()), + SpamRuleType::Url => *self = SpamRule::Url(Default::default()), + SpamRuleType::Domain => *self = SpamRule::Domain(Default::default()), + SpamRuleType::Email => *self = SpamRule::Email(Default::default()), + SpamRuleType::Ip => *self = SpamRule::Ip(Default::default()), + SpamRuleType::Header => *self = SpamRule::Header(Default::default()), + SpamRuleType::Body => *self = SpamRule::Body(Default::default()), + } + } + match self { + SpamRule::Any(inner) => inner.patch(pointer, value), + SpamRule::Url(inner) => inner.patch(pointer, value), + SpamRule::Domain(inner) => inner.patch(pointer, value), + SpamRule::Email(inner) => inner.patch(pointer, value), + SpamRule::Ip(inner) => inner.patch(pointer, value), + SpamRule::Header(inner) => inner.patch(pointer, value), + SpamRule::Body(inner) => inner.patch(pointer, value), + } + } +} + +impl SpamRule { + pub fn object_type(&self) -> SpamRuleType { + match self { + SpamRule::Any(_) => SpamRuleType::Any, + SpamRule::Url(_) => SpamRuleType::Url, + SpamRule::Domain(_) => SpamRuleType::Domain, + SpamRule::Email(_) => SpamRuleType::Email, + SpamRule::Ip(_) => SpamRuleType::Ip, + SpamRule::Header(_) => SpamRuleType::Header, + SpamRule::Body(_) => SpamRuleType::Body, + } + } + + pub fn expression_ctxs(&self) -> Vec> { + match self { + SpamRule::Any(obj) => obj.expression_ctxs(), + SpamRule::Url(obj) => obj.expression_ctxs(), + SpamRule::Domain(obj) => obj.expression_ctxs(), + SpamRule::Email(obj) => obj.expression_ctxs(), + SpamRule::Ip(obj) => obj.expression_ctxs(), + SpamRule::Header(obj) => obj.expression_ctxs(), + SpamRule::Body(obj) => obj.expression_ctxs(), + } + } +} + +impl SpamRuleAny { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleAny { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleAny { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleAny { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleAny { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleAny { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleBody { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleBody { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleBody { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleBody { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleBody { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleBody { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleDomain { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleDomain { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_GENERIC_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleDomain { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleDomain { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleDomain { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleDomain { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleEmail { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleEmail { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_EMAIL_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleEmail { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleEmail { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleEmail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleEmail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleHeader { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleHeader { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_HEADER_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleHeader { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleHeader { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleHeader { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleHeader { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleIp { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleIp { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_IP_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleIp { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleIp { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleIp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleIp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamRuleUrl { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.condition; + value.validate(errors); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.priority; + if *value > (99999) { + errors.push(ValidationError::max_value(Property::Priority, 99999)); + } + if *value < (-99999) { + errors.push(ValidationError::min_value(Property::Priority, -99999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl SpamRuleUrl { + pub fn ctx_condition(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.condition, + default: None, + property: Property::Condition, + allowed_variables: SPAM_URL_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_condition()] + } +} + +impl Pickle for SpamRuleUrl { + fn pickle(&self, out: &mut Vec) { + self.condition.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + self.enable.pickle(out); + self.priority.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.condition = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.priority = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamRuleUrl { + fn default() -> Self { + Self { + condition: Default::default(), + name: Default::default(), + description: Default::default(), + enable: true, + priority: 500i64, + } + } +} + +impl IntoValue for SpamRuleUrl { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Condition, self.condition.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Priority, self.priority.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamRuleUrl { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Condition) => self.condition.patch(pointer, value), + Some(Property::Name) => self.name.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Uppercase, StringValidator::RemoveSpaces]), + value, + ), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Priority) => self.priority.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.score_discard; + if *value > Float::new(100.0) { + errors.push(ValidationError::max_value(Property::ScoreDiscard, 100)); + } + if *value < Float::new(-100.0) { + errors.push(ValidationError::min_value(Property::ScoreDiscard, -100)); + } + let value = &self.score_reject; + if *value > Float::new(100.0) { + errors.push(ValidationError::max_value(Property::ScoreReject, 100)); + } + if *value < Float::new(-100.0) { + errors.push(ValidationError::min_value(Property::ScoreReject, -100)); + } + let value = &self.score_spam; + if *value > Float::new(100.0) { + errors.push(ValidationError::max_value(Property::ScoreSpam, 100)); + } + if *value < Float::new(-100.0) { + errors.push(ValidationError::min_value(Property::ScoreSpam, -100)); + } + if let Some(value) = &self.spam_filter_rules_url { + if value.is_empty() { + errors.push(ValidationError::required(Property::SpamFilterRulesUrl)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for SpamSettings { + fn pickle(&self, out: &mut Vec) { + self.trust_contacts.pickle(out); + self.enable.pickle(out); + self.greylist_for.pickle(out); + self.score_discard.pickle(out); + self.score_reject.pickle(out); + self.score_spam.pickle(out); + self.trust_replies.pickle(out); + self.spam_filter_rules_url.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.trust_contacts = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.greylist_for = Pickle::unpickle(stream)?; + this.score_discard = Pickle::unpickle(stream)?; + this.score_reject = Pickle::unpickle(stream)?; + this.score_spam = Pickle::unpickle(stream)?; + this.trust_replies = Pickle::unpickle(stream)?; + this.spam_filter_rules_url = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamSettings { + fn default() -> Self { + Self { + trust_contacts: true, + enable: true, + greylist_for: Default::default(), + score_discard: Float::new(0.0f64), + score_reject: Float::new(0.0f64), + score_spam: Float::new(5.0f64), + trust_replies: true, + spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()), + } + } +} + +impl IntoValue for SpamSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::TrustContacts, self.trust_contacts.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::GreylistFor, self.greylist_for.into_value()); + map.insert_unchecked(Property::ScoreDiscard, self.score_discard.into_value()); + map.insert_unchecked(Property::ScoreReject, self.score_reject.into_value()); + map.insert_unchecked(Property::ScoreSpam, self.score_spam.into_value()); + map.insert_unchecked(Property::TrustReplies, self.trust_replies.into_value()); + map.insert_unchecked( + Property::SpamFilterRulesUrl, + self.spam_filter_rules_url.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TrustContacts) => self.trust_contacts.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::GreylistFor) => self.greylist_for.patch(pointer, value), + Some(Property::ScoreDiscard) => self.score_discard.patch(pointer, value), + Some(Property::ScoreReject) => self.score_reject.patch(pointer, value), + Some(Property::ScoreSpam) => self.score_spam.patch(pointer, value), + Some(Property::TrustReplies) => self.trust_replies.patch(pointer, value), + Some(Property::SpamFilterRulesUrl) => self + .spam_filter_rules_url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamTag { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamTag; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SpamTag::Score(inner) => inner.validate(errors), + SpamTag::Discard(inner) => inner.validate(errors), + SpamTag::Reject(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + SpamTag::Score(object) => { + object.index(i); + } + SpamTag::Discard(object) => { + object.index(i); + } + SpamTag::Reject(object) => { + object.index(i); + } + } + } +} + +impl Default for SpamTag { + fn default() -> Self { + SpamTag::Score(Default::default()) + } +} + +impl Pickle for SpamTag { + fn pickle(&self, out: &mut Vec) { + match self { + SpamTag::Score(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + SpamTag::Discard(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SpamTag::Reject(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(SpamTag::Score), + 1 => Pickle::unpickle(stream).map(SpamTag::Discard), + 2 => Pickle::unpickle(stream).map(SpamTag::Reject), + _ => None, + } + } +} + +impl IntoValue for SpamTag { + fn into_value(self) -> JmapValue<'static> { + match self { + SpamTag::Score(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Score".into())); + obj + } + SpamTag::Discard(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Discard".into())); + obj + } + SpamTag::Reject(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Reject".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SpamTag { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SpamTagType::Score => *self = SpamTag::Score(Default::default()), + SpamTagType::Discard => *self = SpamTag::Discard(Default::default()), + SpamTagType::Reject => *self = SpamTag::Reject(Default::default()), + } + } + match self { + SpamTag::Score(inner) => inner.patch(pointer, value), + SpamTag::Discard(inner) => inner.patch(pointer, value), + SpamTag::Reject(inner) => inner.patch(pointer, value), + } + } +} + +impl SpamTag { + pub fn object_type(&self) -> SpamTagType { + match self { + SpamTag::Score(_) => SpamTagType::Score, + SpamTag::Discard(_) => SpamTagType::Discard, + SpamTag::Reject(_) => SpamTagType::Reject, + } + } +} + +impl SpamTagAction { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + if value.is_empty() { + errors.push(ValidationError::required(Property::Tag)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Tag, &self.tag); + } +} + +impl Pickle for SpamTagAction { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamTagAction { + fn default() -> Self { + Self { + tag: Default::default(), + } + } +} + +impl IntoValue for SpamTagAction { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamTagAction { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch( + pointer + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SpamTagScore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tag; + if value.is_empty() { + errors.push(ValidationError::required(Property::Tag)); + } + let value = &self.score; + if *value < Float::new(-999999.0) { + errors.push(ValidationError::min_value(Property::Score, -999999)); + } + if *value > Float::new(999999.0) { + errors.push(ValidationError::max_value(Property::Score, 999999)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Tag, &self.tag); + } +} + +impl Pickle for SpamTagScore { + fn pickle(&self, out: &mut Vec) { + self.tag.pickle(out); + self.score.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tag = Pickle::unpickle(stream)?; + this.score = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamTagScore { + fn default() -> Self { + Self { + tag: Default::default(), + score: Float::new(0.0f64), + } + } +} + +impl IntoValue for SpamTagScore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Tag, self.tag.into_value()); + map.insert_unchecked(Property::Score, self.score.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamTagScore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Tag) => self.tag.patch( + pointer + .with_validators(&[StringValidator::RemoveSpaces, StringValidator::Uppercase]), + value, + ), + Some(Property::Score) => self.score.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpamTrainingSample { + const FLAGS: u64 = OBJ_FILTER_ACCOUNT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpamTrainingSample; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + if let Some(value) = &self.account_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + } + let value = &self.expires_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id, None); + if let Some(value) = &self.account_id { + i.search(Property::AccountId, value); + } + } +} + +impl Pickle for SpamTrainingSample { + fn pickle(&self, out: &mut Vec) { + self.from.pickle(out); + self.subject.pickle(out); + self.blob_id.pickle(out); + self.is_spam.pickle(out); + self.account_id.pickle(out); + self.expires_at.pickle(out); + self.delete_after_use.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.blob_id = Pickle::unpickle(stream)?; + this.is_spam = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.delete_after_use = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpamTrainingSample { + fn default() -> Self { + Self { + from: Default::default(), + subject: Default::default(), + blob_id: Default::default(), + is_spam: false, + account_id: Default::default(), + expires_at: Default::default(), + delete_after_use: false, + } + } +} + +impl IntoValue for SpamTrainingSample { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + map.insert_unchecked(Property::IsSpam, self.is_spam.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::DeleteAfterUse, self.delete_after_use.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpamTrainingSample { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::From) => pointer.assert_server_set(), + Some(Property::Subject) => pointer.assert_server_set(), + Some(Property::BlobId) => self.blob_id.patch(pointer.assert_read_only()?, value), + Some(Property::IsSpam) => self.is_spam.patch(pointer.assert_read_only()?, value), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::ExpiresAt) => pointer.assert_server_set(), + Some(Property::DeleteAfterUse) => self + .delete_after_use + .patch(pointer.assert_read_only()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SpfReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SpfReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from_address; + value.validate(errors); + let value = &self.from_name; + value.validate(errors); + let value = &self.send_frequency; + value.validate(errors); + let value = &self.dkim_sign_domain; + value.validate(errors); + let value = &self.subject; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl SpfReportSettings { + pub fn ctx_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_address, + default: Some(Expression { + else_: "'noreply-spf@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::FromAddress, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_name, + default: Some(Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }), + property: Property::FromName, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_send_frequency(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.send_frequency, + default: Some(Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }), + property: Property::SendFrequency, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_subject(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.subject, + default: Some(Expression { + else_: "'SPF Authentication Failure Report'".to_string(), + ..Default::default() + }), + property: Property::Subject, + allowed_variables: MTA_MAIL_FROM_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_from_address(), + self.ctx_from_name(), + self.ctx_send_frequency(), + self.ctx_dkim_sign_domain(), + self.ctx_subject(), + ] + } +} + +impl Pickle for SpfReportSettings { + fn pickle(&self, out: &mut Vec) { + self.from_address.pickle(out); + self.from_name.pickle(out); + self.send_frequency.pickle(out); + self.dkim_sign_domain.pickle(out); + self.subject.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from_address = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.send_frequency = Pickle::unpickle(stream)?; + this.dkim_sign_domain = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SpfReportSettings { + fn default() -> Self { + Self { + from_address: Expression { + else_: "'noreply-spf@' + system('domain')".to_string(), + ..Default::default() + }, + from_name: Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }, + send_frequency: Expression { + else_: "[1, 1d]".to_string(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + subject: Expression { + else_: "'SPF Authentication Failure Report'".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for SpfReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::FromAddress, self.from_address.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked(Property::SendFrequency, self.send_frequency.into_value()); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SpfReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::FromAddress) => self.from_address.patch(pointer, value), + Some(Property::FromName) => self.from_name.patch(pointer, value), + Some(Property::SendFrequency) => self.send_frequency.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SqlAuthStore { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SqlAuthStore::Default => true, + SqlAuthStore::PostgreSql(inner) => inner.validate(errors), + SqlAuthStore::MySql(inner) => inner.validate(errors), + SqlAuthStore::Sqlite(inner) => inner.validate(errors), + } + } +} + +impl Default for SqlAuthStore { + fn default() -> Self { + SqlAuthStore::Default + } +} + +impl Pickle for SqlAuthStore { + fn pickle(&self, out: &mut Vec) { + match self { + SqlAuthStore::Default => { + 0u16.pickle(out); + } + SqlAuthStore::PostgreSql(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SqlAuthStore::MySql(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + SqlAuthStore::Sqlite(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SqlAuthStore::Default), + 1 => Pickle::unpickle(stream).map(SqlAuthStore::PostgreSql), + 2 => Pickle::unpickle(stream).map(SqlAuthStore::MySql), + 3 => Pickle::unpickle(stream).map(SqlAuthStore::Sqlite), + _ => None, + } + } +} + +impl IntoValue for SqlAuthStore { + fn into_value(self) -> JmapValue<'static> { + match self { + SqlAuthStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + SqlAuthStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + SqlAuthStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + SqlAuthStore::Sqlite(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Sqlite".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for SqlAuthStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SqlAuthStoreType::Default => *self = SqlAuthStore::Default, + SqlAuthStoreType::PostgreSql => { + *self = SqlAuthStore::PostgreSql(Default::default()) + } + SqlAuthStoreType::MySql => *self = SqlAuthStore::MySql(Default::default()), + SqlAuthStoreType::Sqlite => *self = SqlAuthStore::Sqlite(Default::default()), + } + } + match self { + SqlAuthStore::Default => pointer.assert_eof(), + SqlAuthStore::PostgreSql(inner) => inner.patch(pointer, value), + SqlAuthStore::MySql(inner) => inner.patch(pointer, value), + SqlAuthStore::Sqlite(inner) => inner.patch(pointer, value), + } + } +} + +impl SqlAuthStore { + pub fn object_type(&self) -> SqlAuthStoreType { + match self { + SqlAuthStore::Default => SqlAuthStoreType::Default, + SqlAuthStore::PostgreSql(_) => SqlAuthStoreType::PostgreSql, + SqlAuthStore::MySql(_) => SqlAuthStoreType::MySql, + SqlAuthStore::Sqlite(_) => SqlAuthStoreType::Sqlite, + } + } +} + +impl SqlDirectory { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.description; + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + let value = &self.store; + value.validate(errors); + let value = &self.column_email; + if value.is_empty() { + errors.push(ValidationError::required(Property::ColumnEmail)); + } + let value = &self.column_secret; + if value.is_empty() { + errors.push(ValidationError::required(Property::ColumnSecret)); + } + if let Some(value) = &self.column_class { + if value.is_empty() { + errors.push(ValidationError::required(Property::ColumnClass)); + } + } + if let Some(value) = &self.column_description { + if value.is_empty() { + errors.push(ValidationError::required(Property::ColumnDescription)); + } + } + let value = &self.query_login; + if value.is_empty() { + errors.push(ValidationError::required(Property::QueryLogin)); + } + let value = &self.query_recipient; + if value.is_empty() { + errors.push(ValidationError::required(Property::QueryRecipient)); + } + if let Some(value) = &self.query_member_of { + if value.is_empty() { + errors.push(ValidationError::required(Property::QueryMemberOf)); + } + } + if let Some(value) = &self.query_email_aliases { + if value.is_empty() { + errors.push(ValidationError::required(Property::QueryEmailAliases)); + } + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + } +} + +impl Pickle for SqlDirectory { + fn pickle(&self, out: &mut Vec) { + self.description.pickle(out); + self.store.pickle(out); + self.column_email.pickle(out); + self.column_secret.pickle(out); + self.column_class.pickle(out); + self.column_description.pickle(out); + self.query_login.pickle(out); + self.query_recipient.pickle(out); + self.query_member_of.pickle(out); + self.query_email_aliases.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.description = Pickle::unpickle(stream)?; + this.store = Pickle::unpickle(stream)?; + this.column_email = Pickle::unpickle(stream)?; + this.column_secret = Pickle::unpickle(stream)?; + this.column_class = Pickle::unpickle(stream)?; + this.column_description = Pickle::unpickle(stream)?; + this.query_login = Pickle::unpickle(stream)?; + this.query_recipient = Pickle::unpickle(stream)?; + this.query_member_of = Pickle::unpickle(stream)?; + this.query_email_aliases = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SqlDirectory { + fn default() -> Self { + Self { + description: Default::default(), + store: Default::default(), + column_email: "name".to_string(), + column_secret: "secret".to_string(), + column_class: Some("type".to_string()), + column_description: Some("description".to_string()), + query_login: "SELECT name, secret, description, type FROM accounts WHERE name = $1".to_string(), + query_recipient: "SELECT name, secret, description, type FROM accounts WHERE name = $1 AND active = true".to_string(), + query_member_of: Some("SELECT member_of FROM group_members WHERE name = $1".to_string()), + query_email_aliases: Some("SELECT address FROM emails WHERE name = $1".to_string()), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for SqlDirectory { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(13); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Store, self.store.into_value()); + map.insert_unchecked(Property::ColumnEmail, self.column_email.into_value()); + map.insert_unchecked(Property::ColumnSecret, self.column_secret.into_value()); + map.insert_unchecked(Property::ColumnClass, self.column_class.into_value()); + map.insert_unchecked( + Property::ColumnDescription, + self.column_description.into_value(), + ); + map.insert_unchecked(Property::QueryLogin, self.query_login.into_value()); + map.insert_unchecked(Property::QueryRecipient, self.query_recipient.into_value()); + map.insert_unchecked(Property::QueryMemberOf, self.query_member_of.into_value()); + map.insert_unchecked( + Property::QueryEmailAliases, + self.query_email_aliases.into_value(), + ); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SqlDirectory { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Description) => self + .description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Store) => self.store.patch(pointer, value), + Some(Property::ColumnEmail) => self + .column_email + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ColumnSecret) => self + .column_secret + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ColumnClass) => self + .column_class + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::ColumnDescription) => self + .column_description + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::QueryLogin) => self + .query_login + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::QueryRecipient) => self + .query_recipient + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::QueryMemberOf) => self + .query_member_of + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::QueryEmailAliases) => self + .query_email_aliases + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SqliteStore { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.path; + if value.is_empty() { + errors.push(ValidationError::required(Property::Path)); + } + if let Some(value) = &self.pool_workers { + if *value > 64 { + errors.push(ValidationError::max_value(Property::PoolWorkers, 64)); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolWorkers, 1)); + } + } + let value = &self.pool_max_connections; + if *value > 8192 { + errors.push(ValidationError::max_value( + Property::PoolMaxConnections, + 8192, + )); + } + if *value < 1 { + errors.push(ValidationError::min_value(Property::PoolMaxConnections, 1)); + } + errors.len() == neb + } +} + +impl Pickle for SqliteStore { + fn pickle(&self, out: &mut Vec) { + self.path.pickle(out); + self.pool_workers.pickle(out); + self.pool_max_connections.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.path = Pickle::unpickle(stream)?; + this.pool_workers = Pickle::unpickle(stream)?; + this.pool_max_connections = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SqliteStore { + fn default() -> Self { + Self { + path: Default::default(), + pool_workers: Default::default(), + pool_max_connections: 10u64, + } + } +} + +impl IntoValue for SqliteStore { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Path, self.path.into_value()); + map.insert_unchecked(Property::PoolWorkers, self.pool_workers.into_value()); + map.insert_unchecked( + Property::PoolMaxConnections, + self.pool_max_connections.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SqliteStore { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Path) => self + .path + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::PoolWorkers) => self.pool_workers.patch(pointer, value), + Some(Property::PoolMaxConnections) => self.pool_max_connections.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for StoreLookup { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::StoreLookup; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.namespace; + if value.is_empty() { + errors.push(ValidationError::required(Property::Namespace)); + } + let value = &self.store; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global(Property::Namespace, &self.namespace); + } +} + +impl Pickle for StoreLookup { + fn pickle(&self, out: &mut Vec) { + self.namespace.pickle(out); + self.store.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.namespace = Pickle::unpickle(stream)?; + this.store = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for StoreLookup { + fn default() -> Self { + Self { + namespace: Default::default(), + store: Default::default(), + } + } +} + +impl IntoValue for StoreLookup { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Namespace, self.namespace.into_value()); + map.insert_unchecked(Property::Store, self.store.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for StoreLookup { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Namespace) => self.namespace.patch( + pointer + .assert_read_only()? + .with_validators(&[StringValidator::Trim]), + value, + ), + Some(Property::Store) => self.store.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl SubAddressing { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + SubAddressing::Enabled => true, + SubAddressing::Custom(inner) => inner.validate(errors), + SubAddressing::Disabled => true, + } + } +} + +impl Default for SubAddressing { + fn default() -> Self { + SubAddressing::Enabled + } +} + +impl Pickle for SubAddressing { + fn pickle(&self, out: &mut Vec) { + match self { + SubAddressing::Enabled => { + 0u16.pickle(out); + } + SubAddressing::Custom(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + SubAddressing::Disabled => { + 2u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(SubAddressing::Enabled), + 1 => Pickle::unpickle(stream).map(SubAddressing::Custom), + 2 => Some(SubAddressing::Disabled), + _ => None, + } + } +} + +impl IntoValue for SubAddressing { + fn into_value(self) -> JmapValue<'static> { + match self { + SubAddressing::Enabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Enabled".into())); + JmapValue::Object(obj) + } + SubAddressing::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + SubAddressing::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for SubAddressing { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + SubAddressingType::Enabled => *self = SubAddressing::Enabled, + SubAddressingType::Custom => *self = SubAddressing::Custom(Default::default()), + SubAddressingType::Disabled => *self = SubAddressing::Disabled, + } + } + match self { + SubAddressing::Enabled => pointer.assert_eof(), + SubAddressing::Custom(inner) => inner.patch(pointer, value), + SubAddressing::Disabled => pointer.assert_eof(), + } + } +} + +impl SubAddressing { + pub fn object_type(&self) -> SubAddressingType { + match self { + SubAddressing::Enabled => SubAddressingType::Enabled, + SubAddressing::Custom(_) => SubAddressingType::Custom, + SubAddressing::Disabled => SubAddressingType::Disabled, + } + } + + pub fn expression_ctxs(&self) -> Vec> { + match self { + SubAddressing::Custom(obj) => obj.expression_ctxs(), + _ => vec![], + } + } +} + +impl SubAddressingCustom { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.custom_rule; + value.validate(errors); + errors.len() == neb + } +} + +impl SubAddressingCustom { + pub fn ctx_custom_rule(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.custom_rule, + default: None, + property: Property::CustomRule, + allowed_variables: MTA_RCPT_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![self.ctx_custom_rule()] + } +} + +impl Pickle for SubAddressingCustom { + fn pickle(&self, out: &mut Vec) { + self.custom_rule.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.custom_rule = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SubAddressingCustom { + fn default() -> Self { + Self { + custom_rule: Default::default(), + } + } +} + +impl IntoValue for SubAddressingCustom { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::CustomRule, self.custom_rule.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SubAddressingCustom { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CustomRule) => self.custom_rule.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for SystemSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::SystemSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.default_hostname; + if value.is_empty() { + errors.push(ValidationError::required(Property::DefaultHostname)); + } + let value = &self.default_domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultDomainId)); + } + if let Some(value) = &self.default_certificate_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::DefaultCertificateId)); + } + } + if let Some(value) = &self.thread_pool_size { + if *value < 1 { + errors.push(ValidationError::min_value(Property::ThreadPoolSize, 1)); + } + } + let value = &self.max_connections; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxConnections, 1)); + } + let value = &self.proxy_trusted_networks; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::invalid( + Property::ProxyTrustedNetworks, + value, + )); + } + } + let value = &self.mail_exchangers; + for value in value.values() { + value.validate(errors); + } + let value = &self.services; + for value in value.values() { + value.validate(errors); + } + let value = &self.provider_info; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::ProviderInfo)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Domain, self.default_domain_id.into(), None); + i.foreign_key(ObjectType::Certificate, self.default_certificate_id, None); + } +} + +impl Pickle for SystemSettings { + fn pickle(&self, out: &mut Vec) { + self.default_hostname.pickle(out); + self.default_domain_id.pickle(out); + self.default_certificate_id.pickle(out); + self.thread_pool_size.pickle(out); + self.max_connections.pickle(out); + self.proxy_trusted_networks.pickle(out); + self.mail_exchangers.pickle(out); + self.services.pickle(out); + self.provider_info.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.default_hostname = Pickle::unpickle(stream)?; + this.default_domain_id = Pickle::unpickle(stream)?; + this.default_certificate_id = Pickle::unpickle(stream)?; + this.thread_pool_size = Pickle::unpickle(stream)?; + this.max_connections = Pickle::unpickle(stream)?; + this.proxy_trusted_networks = Pickle::unpickle(stream)?; + this.mail_exchangers = Pickle::unpickle(stream)?; + this.services = Pickle::unpickle(stream)?; + this.provider_info = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for SystemSettings { + fn default() -> Self { + Self { + default_hostname: Default::default(), + default_domain_id: Default::default(), + default_certificate_id: Default::default(), + thread_pool_size: Default::default(), + max_connections: 8192u64, + proxy_trusted_networks: Default::default(), + mail_exchangers: List::from_iter([MailExchanger { + priority: 10u64, + ..Default::default() + }]), + services: VecMap::from_iter([ + ( + ServiceProtocol::Caldav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Carddav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Imap, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Jmap, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Managesieve, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Pop3, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Smtp, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Webdav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ]), + provider_info: Default::default(), + } + } +} + +impl IntoValue for SystemSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked( + Property::DefaultHostname, + self.default_hostname.into_value(), + ); + map.insert_unchecked( + Property::DefaultDomainId, + self.default_domain_id.into_value(), + ); + map.insert_unchecked( + Property::DefaultCertificateId, + self.default_certificate_id.into_value(), + ); + map.insert_unchecked(Property::ThreadPoolSize, self.thread_pool_size.into_value()); + map.insert_unchecked(Property::MaxConnections, self.max_connections.into_value()); + map.insert_unchecked( + Property::ProxyTrustedNetworks, + self.proxy_trusted_networks.into_value(), + ); + map.insert_unchecked(Property::MailExchangers, self.mail_exchangers.into_value()); + map.insert_unchecked(Property::Services, self.services.into_value()); + map.insert_unchecked(Property::ProviderInfo, self.provider_info.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for SystemSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DefaultHostname) => self + .default_hostname + .patch(pointer.with_validators(&[StringValidator::Hostname]), value), + Some(Property::DefaultDomainId) => self.default_domain_id.patch(pointer, value), + Some(Property::DefaultCertificateId) => { + self.default_certificate_id.patch(pointer, value) + } + Some(Property::ThreadPoolSize) => self.thread_pool_size.patch(pointer, value), + Some(Property::MaxConnections) => self.max_connections.patch(pointer, value), + Some(Property::ProxyTrustedNetworks) => self + .proxy_trusted_networks + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::MailExchangers) => self.mail_exchangers.patch(pointer, value), + Some(Property::Services) => self.services.patch(pointer, value), + Some(Property::ProviderInfo) => self.provider_info.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Task { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Task; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Task::IndexDocument(inner) => inner.validate(errors), + Task::UnindexDocument(inner) => inner.validate(errors), + Task::IndexTrace(inner) => inner.validate(errors), + Task::CalendarAlarmEmail(inner) => inner.validate(errors), + Task::CalendarAlarmNotification(inner) => inner.validate(errors), + Task::CalendarItipMessage(inner) => inner.validate(errors), + Task::MergeThreads(inner) => inner.validate(errors), + Task::DmarcReport(inner) => inner.validate(errors), + Task::TlsReport(inner) => inner.validate(errors), + Task::RestoreArchivedItem(inner) => inner.validate(errors), + Task::DestroyAccount(inner) => inner.validate(errors), + Task::AccountMaintenance(inner) => inner.validate(errors), + Task::TenantMaintenance(inner) => inner.validate(errors), + Task::StoreMaintenance(inner) => inner.validate(errors), + Task::SpamFilterMaintenance(inner) => inner.validate(errors), + Task::AcmeRenewal(inner) => inner.validate(errors), + Task::DkimManagement(inner) => inner.validate(errors), + Task::DnsManagement(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + Task::IndexDocument(object) => { + object.index(i); + } + Task::UnindexDocument(object) => { + object.index(i); + } + Task::IndexTrace(object) => { + object.index(i); + } + Task::CalendarAlarmEmail(object) => { + object.index(i); + } + Task::CalendarAlarmNotification(object) => { + object.index(i); + } + Task::CalendarItipMessage(object) => { + object.index(i); + } + Task::MergeThreads(object) => { + object.index(i); + } + Task::DmarcReport(object) => { + object.index(i); + } + Task::TlsReport(object) => { + object.index(i); + } + Task::RestoreArchivedItem(object) => { + object.index(i); + } + Task::DestroyAccount(object) => { + object.index(i); + } + Task::AccountMaintenance(object) => { + object.index(i); + } + Task::TenantMaintenance(object) => { + object.index(i); + } + Task::StoreMaintenance(_) => {} + Task::SpamFilterMaintenance(_) => {} + Task::AcmeRenewal(object) => { + object.index(i); + } + Task::DkimManagement(object) => { + object.index(i); + } + Task::DnsManagement(object) => { + object.index(i); + } + } + } +} + +impl Default for Task { + fn default() -> Self { + Task::IndexDocument(Default::default()) + } +} + +impl Pickle for Task { + fn pickle(&self, out: &mut Vec) { + match self { + Task::IndexDocument(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Task::UnindexDocument(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Task::IndexTrace(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + Task::CalendarAlarmEmail(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + Task::CalendarAlarmNotification(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + Task::CalendarItipMessage(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + Task::MergeThreads(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + Task::DmarcReport(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + Task::TlsReport(inner) => { + 8u16.pickle(out); + inner.pickle(out); + } + Task::RestoreArchivedItem(inner) => { + 9u16.pickle(out); + inner.pickle(out); + } + Task::DestroyAccount(inner) => { + 10u16.pickle(out); + inner.pickle(out); + } + Task::AccountMaintenance(inner) => { + 11u16.pickle(out); + inner.pickle(out); + } + Task::TenantMaintenance(inner) => { + 12u16.pickle(out); + inner.pickle(out); + } + Task::StoreMaintenance(inner) => { + 13u16.pickle(out); + inner.pickle(out); + } + Task::SpamFilterMaintenance(inner) => { + 14u16.pickle(out); + inner.pickle(out); + } + Task::AcmeRenewal(inner) => { + 15u16.pickle(out); + inner.pickle(out); + } + Task::DkimManagement(inner) => { + 16u16.pickle(out); + inner.pickle(out); + } + Task::DnsManagement(inner) => { + 17u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Task::IndexDocument), + 1 => Pickle::unpickle(stream).map(Task::UnindexDocument), + 2 => Pickle::unpickle(stream).map(Task::IndexTrace), + 3 => Pickle::unpickle(stream).map(Task::CalendarAlarmEmail), + 4 => Pickle::unpickle(stream).map(Task::CalendarAlarmNotification), + 5 => Pickle::unpickle(stream).map(Task::CalendarItipMessage), + 6 => Pickle::unpickle(stream).map(Task::MergeThreads), + 7 => Pickle::unpickle(stream).map(Task::DmarcReport), + 8 => Pickle::unpickle(stream).map(Task::TlsReport), + 9 => Pickle::unpickle(stream).map(Task::RestoreArchivedItem), + 10 => Pickle::unpickle(stream).map(Task::DestroyAccount), + 11 => Pickle::unpickle(stream).map(Task::AccountMaintenance), + 12 => Pickle::unpickle(stream).map(Task::TenantMaintenance), + 13 => Pickle::unpickle(stream).map(Task::StoreMaintenance), + 14 => Pickle::unpickle(stream).map(Task::SpamFilterMaintenance), + 15 => Pickle::unpickle(stream).map(Task::AcmeRenewal), + 16 => Pickle::unpickle(stream).map(Task::DkimManagement), + 17 => Pickle::unpickle(stream).map(Task::DnsManagement), + _ => None, + } + } +} + +impl IntoValue for Task { + fn into_value(self) -> JmapValue<'static> { + match self { + Task::IndexDocument(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("IndexDocument".into())); + obj + } + Task::UnindexDocument(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UnindexDocument".into())); + obj + } + Task::IndexTrace(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("IndexTrace".into())); + obj + } + Task::CalendarAlarmEmail(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("CalendarAlarmEmail".into())); + obj + } + Task::CalendarAlarmNotification(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut().unwrap().insert_unchecked( + Property::Type, + JmapValue::Str("CalendarAlarmNotification".into()), + ); + obj + } + Task::CalendarItipMessage(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("CalendarItipMessage".into())); + obj + } + Task::MergeThreads(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MergeThreads".into())); + obj + } + Task::DmarcReport(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DmarcReport".into())); + obj + } + Task::TlsReport(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TlsReport".into())); + obj + } + Task::RestoreArchivedItem(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("RestoreArchivedItem".into())); + obj + } + Task::DestroyAccount(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DestroyAccount".into())); + obj + } + Task::AccountMaintenance(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("AccountMaintenance".into())); + obj + } + Task::TenantMaintenance(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("TenantMaintenance".into())); + obj + } + Task::StoreMaintenance(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("StoreMaintenance".into())); + obj + } + Task::SpamFilterMaintenance(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut().unwrap().insert_unchecked( + Property::Type, + JmapValue::Str("SpamFilterMaintenance".into()), + ); + obj + } + Task::AcmeRenewal(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("AcmeRenewal".into())); + obj + } + Task::DkimManagement(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DkimManagement".into())); + obj + } + Task::DnsManagement(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("DnsManagement".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Task { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TaskType::IndexDocument => *self = Task::IndexDocument(Default::default()), + TaskType::UnindexDocument => *self = Task::UnindexDocument(Default::default()), + TaskType::IndexTrace => *self = Task::IndexTrace(Default::default()), + TaskType::CalendarAlarmEmail => { + *self = Task::CalendarAlarmEmail(Default::default()) + } + TaskType::CalendarAlarmNotification => { + *self = Task::CalendarAlarmNotification(Default::default()) + } + TaskType::CalendarItipMessage => { + *self = Task::CalendarItipMessage(Default::default()) + } + TaskType::MergeThreads => *self = Task::MergeThreads(Default::default()), + TaskType::DmarcReport => *self = Task::DmarcReport(Default::default()), + TaskType::TlsReport => *self = Task::TlsReport(Default::default()), + TaskType::RestoreArchivedItem => { + *self = Task::RestoreArchivedItem(Default::default()) + } + TaskType::DestroyAccount => *self = Task::DestroyAccount(Default::default()), + TaskType::AccountMaintenance => { + *self = Task::AccountMaintenance(Default::default()) + } + TaskType::TenantMaintenance => *self = Task::TenantMaintenance(Default::default()), + TaskType::StoreMaintenance => *self = Task::StoreMaintenance(Default::default()), + TaskType::SpamFilterMaintenance => { + *self = Task::SpamFilterMaintenance(Default::default()) + } + TaskType::AcmeRenewal => *self = Task::AcmeRenewal(Default::default()), + TaskType::DkimManagement => *self = Task::DkimManagement(Default::default()), + TaskType::DnsManagement => *self = Task::DnsManagement(Default::default()), + } + } + match self { + Task::IndexDocument(inner) => inner.patch(pointer, value), + Task::UnindexDocument(inner) => inner.patch(pointer, value), + Task::IndexTrace(inner) => inner.patch(pointer, value), + Task::CalendarAlarmEmail(inner) => inner.patch(pointer, value), + Task::CalendarAlarmNotification(inner) => inner.patch(pointer, value), + Task::CalendarItipMessage(inner) => inner.patch(pointer, value), + Task::MergeThreads(inner) => inner.patch(pointer, value), + Task::DmarcReport(inner) => inner.patch(pointer, value), + Task::TlsReport(inner) => inner.patch(pointer, value), + Task::RestoreArchivedItem(inner) => inner.patch(pointer, value), + Task::DestroyAccount(inner) => inner.patch(pointer, value), + Task::AccountMaintenance(inner) => inner.patch(pointer, value), + Task::TenantMaintenance(inner) => inner.patch(pointer, value), + Task::StoreMaintenance(inner) => inner.patch(pointer, value), + Task::SpamFilterMaintenance(inner) => inner.patch(pointer, value), + Task::AcmeRenewal(inner) => inner.patch(pointer, value), + Task::DkimManagement(inner) => inner.patch(pointer, value), + Task::DnsManagement(inner) => inner.patch(pointer, value), + } + } +} + +impl Task { + pub fn object_type(&self) -> TaskType { + match self { + Task::IndexDocument(_) => TaskType::IndexDocument, + Task::UnindexDocument(_) => TaskType::UnindexDocument, + Task::IndexTrace(_) => TaskType::IndexTrace, + Task::CalendarAlarmEmail(_) => TaskType::CalendarAlarmEmail, + Task::CalendarAlarmNotification(_) => TaskType::CalendarAlarmNotification, + Task::CalendarItipMessage(_) => TaskType::CalendarItipMessage, + Task::MergeThreads(_) => TaskType::MergeThreads, + Task::DmarcReport(_) => TaskType::DmarcReport, + Task::TlsReport(_) => TaskType::TlsReport, + Task::RestoreArchivedItem(_) => TaskType::RestoreArchivedItem, + Task::DestroyAccount(_) => TaskType::DestroyAccount, + Task::AccountMaintenance(_) => TaskType::AccountMaintenance, + Task::TenantMaintenance(_) => TaskType::TenantMaintenance, + Task::StoreMaintenance(_) => TaskType::StoreMaintenance, + Task::SpamFilterMaintenance(_) => TaskType::SpamFilterMaintenance, + Task::AcmeRenewal(_) => TaskType::AcmeRenewal, + Task::DkimManagement(_) => TaskType::DkimManagement, + Task::DnsManagement(_) => TaskType::DnsManagement, + } + } +} + +impl TaskAccountMaintenance { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskAccountMaintenance { + fn pickle(&self, out: &mut Vec) { + self.account_id.pickle(out); + self.maintenance_type.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.account_id = Pickle::unpickle(stream)?; + this.maintenance_type = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskAccountMaintenance { + fn default() -> Self { + Self { + account_id: Default::default(), + maintenance_type: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskAccountMaintenance { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked( + Property::MaintenanceType, + self.maintenance_type.into_value(), + ); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskAccountMaintenance { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::MaintenanceType) => self + .maintenance_type + .patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskCalendarAlarmEmail { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.event_start; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::EventStart, value)); + } + let value = &self.event_end; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::EventEnd, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.document_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DocumentId, value)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskCalendarAlarmEmail { + fn pickle(&self, out: &mut Vec) { + self.alarm_id.pickle(out); + self.event_id.pickle(out); + self.event_start.pickle(out); + self.event_end.pickle(out); + self.event_start_tz.pickle(out); + self.event_end_tz.pickle(out); + self.account_id.pickle(out); + self.document_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.alarm_id = Pickle::unpickle(stream)?; + this.event_id = Pickle::unpickle(stream)?; + this.event_start = Pickle::unpickle(stream)?; + this.event_end = Pickle::unpickle(stream)?; + this.event_start_tz = Pickle::unpickle(stream)?; + this.event_end_tz = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.document_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskCalendarAlarmEmail { + fn default() -> Self { + Self { + alarm_id: 0u64, + event_id: 0u64, + event_start: Default::default(), + event_end: Default::default(), + event_start_tz: 0u64, + event_end_tz: 0u64, + account_id: Default::default(), + document_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskCalendarAlarmEmail { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(11); + map.insert_unchecked(Property::AlarmId, self.alarm_id.into_value()); + map.insert_unchecked(Property::EventId, self.event_id.into_value()); + map.insert_unchecked(Property::EventStart, self.event_start.into_value()); + map.insert_unchecked(Property::EventEnd, self.event_end.into_value()); + map.insert_unchecked(Property::EventStartTz, self.event_start_tz.into_value()); + map.insert_unchecked(Property::EventEndTz, self.event_end_tz.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::DocumentId, self.document_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskCalendarAlarmEmail { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AlarmId) => pointer.assert_server_set(), + Some(Property::EventId) => pointer.assert_server_set(), + Some(Property::EventStart) => pointer.assert_server_set(), + Some(Property::EventEnd) => pointer.assert_server_set(), + Some(Property::EventStartTz) => pointer.assert_server_set(), + Some(Property::EventEndTz) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::DocumentId) => { + self.document_id.patch(pointer.assert_read_only()?, value) + } + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskCalendarAlarmNotification { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.document_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DocumentId, value)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskCalendarAlarmNotification { + fn pickle(&self, out: &mut Vec) { + self.alarm_id.pickle(out); + self.event_id.pickle(out); + self.recurrence_id.pickle(out); + self.account_id.pickle(out); + self.document_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.alarm_id = Pickle::unpickle(stream)?; + this.event_id = Pickle::unpickle(stream)?; + this.recurrence_id = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.document_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskCalendarAlarmNotification { + fn default() -> Self { + Self { + alarm_id: 0u64, + event_id: 0u64, + recurrence_id: Default::default(), + account_id: Default::default(), + document_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskCalendarAlarmNotification { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::AlarmId, self.alarm_id.into_value()); + map.insert_unchecked(Property::EventId, self.event_id.into_value()); + map.insert_unchecked(Property::RecurrenceId, self.recurrence_id.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::DocumentId, self.document_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskCalendarAlarmNotification { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AlarmId) => pointer.assert_server_set(), + Some(Property::EventId) => pointer.assert_server_set(), + Some(Property::RecurrenceId) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::DocumentId) => { + self.document_id.patch(pointer.assert_read_only()?, value) + } + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskCalendarItipContents { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + let value = &self.i_calendar_data; + if value.is_empty() { + errors.push(ValidationError::required(Property::ICalendarData)); + } + let value = &self.summary; + if value.is_empty() { + errors.push(ValidationError::required(Property::Summary)); + } + errors.len() == neb + } +} + +impl Pickle for TaskCalendarItipContents { + fn pickle(&self, out: &mut Vec) { + self.from.pickle(out); + self.to.pickle(out); + self.is_from_organizer.pickle(out); + self.i_calendar_data.pickle(out); + self.summary.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.from = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + this.is_from_organizer = Pickle::unpickle(stream)?; + this.i_calendar_data = Pickle::unpickle(stream)?; + this.summary = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskCalendarItipContents { + fn default() -> Self { + Self { + from: Default::default(), + to: Default::default(), + is_from_organizer: false, + i_calendar_data: Default::default(), + summary: Default::default(), + } + } +} + +impl IntoValue for TaskCalendarItipContents { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + map.insert_unchecked( + Property::IsFromOrganizer, + self.is_from_organizer.into_value(), + ); + map.insert_unchecked(Property::ICalendarData, self.i_calendar_data.into_value()); + map.insert_unchecked(Property::Summary, self.summary.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskCalendarItipContents { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::From) => pointer.assert_server_set(), + Some(Property::To) => pointer.assert_server_set(), + Some(Property::IsFromOrganizer) => pointer.assert_server_set(), + Some(Property::ICalendarData) => pointer.assert_server_set(), + Some(Property::Summary) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskCalendarItipMessage { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.messages; + for value in value.values() { + value.validate(errors); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.document_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DocumentId, value)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskCalendarItipMessage { + fn pickle(&self, out: &mut Vec) { + self.messages.pickle(out); + self.account_id.pickle(out); + self.document_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.messages = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.document_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskCalendarItipMessage { + fn default() -> Self { + Self { + messages: Default::default(), + account_id: Default::default(), + document_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskCalendarItipMessage { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Messages, self.messages.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::DocumentId, self.document_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskCalendarItipMessage { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Messages) => pointer.assert_server_set(), + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::DocumentId) => { + self.document_id.patch(pointer.assert_read_only()?, value) + } + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskDestroyAccount { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.account_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::AccountName)); + } + let value = &self.account_domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountDomainId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + i.foreign_key(ObjectType::Domain, self.account_domain_id.into(), None); + } +} + +impl Pickle for TaskDestroyAccount { + fn pickle(&self, out: &mut Vec) { + self.account_id.pickle(out); + self.account_name.pickle(out); + self.account_domain_id.pickle(out); + self.account_type.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.account_id = Pickle::unpickle(stream)?; + this.account_name = Pickle::unpickle(stream)?; + this.account_domain_id = Pickle::unpickle(stream)?; + this.account_type = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskDestroyAccount { + fn default() -> Self { + Self { + account_id: Default::default(), + account_name: Default::default(), + account_domain_id: Default::default(), + account_type: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskDestroyAccount { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::AccountName, self.account_name.into_value()); + map.insert_unchecked( + Property::AccountDomainId, + self.account_domain_id.into_value(), + ); + map.insert_unchecked(Property::AccountType, self.account_type.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskDestroyAccount { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccountId) => pointer.assert_server_set(), + Some(Property::AccountName) => self.account_name.patch(pointer, value), + Some(Property::AccountDomainId) => self.account_domain_id.patch(pointer, value), + Some(Property::AccountType) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskDmarcReport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.report_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::ReportId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::DmarcInternalReport, self.report_id.into(), None); + } +} + +impl Pickle for TaskDmarcReport { + fn pickle(&self, out: &mut Vec) { + self.report_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.report_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskDmarcReport { + fn default() -> Self { + Self { + report_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskDmarcReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::ReportId, self.report_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskDmarcReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ReportId) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskDnsManagement { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + } +} + +impl Pickle for TaskDnsManagement { + fn pickle(&self, out: &mut Vec) { + self.update_records.pickle(out); + self.on_success_renew_certificate.pickle(out); + self.domain_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.update_records = Pickle::unpickle(stream)?; + this.on_success_renew_certificate = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskDnsManagement { + fn default() -> Self { + Self { + update_records: Default::default(), + on_success_renew_certificate: false, + domain_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskDnsManagement { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::UpdateRecords, self.update_records.into_value()); + map.insert_unchecked( + Property::OnSuccessRenewCertificate, + self.on_success_renew_certificate.into_value(), + ); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskDnsManagement { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::UpdateRecords) => self.update_records.patch(pointer, value), + Some(Property::OnSuccessRenewCertificate) => { + self.on_success_renew_certificate.patch(pointer, value) + } + Some(Property::DomainId) => self.domain_id.patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskDomainManagement { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + } +} + +impl Pickle for TaskDomainManagement { + fn pickle(&self, out: &mut Vec) { + self.domain_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.domain_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskDomainManagement { + fn default() -> Self { + Self { + domain_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskDomainManagement { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskDomainManagement { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DomainId) => self.domain_id.patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskIndexDocument { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.document_id; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DocumentId, value)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskIndexDocument { + fn pickle(&self, out: &mut Vec) { + self.document_type.pickle(out); + self.account_id.pickle(out); + self.document_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.document_type = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.document_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskIndexDocument { + fn default() -> Self { + Self { + document_type: Default::default(), + account_id: Default::default(), + document_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskIndexDocument { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::DocumentType, self.document_type.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::DocumentId, self.document_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskIndexDocument { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::DocumentType) => { + self.document_type.patch(pointer.assert_read_only()?, value) + } + Some(Property::AccountId) => self + .account_id + .patch(pointer.assert_read_only()?.assert_can_set_account()?, value), + Some(Property::DocumentId) => { + self.document_id.patch(pointer.assert_read_only()?, value) + } + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskIndexTrace { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.trace_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::TraceId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Trace, self.trace_id.into(), None); + } +} + +impl Pickle for TaskIndexTrace { + fn pickle(&self, out: &mut Vec) { + self.trace_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.trace_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskIndexTrace { + fn default() -> Self { + Self { + trace_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskIndexTrace { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::TraceId, self.trace_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskIndexTrace { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TraceId) => self.trace_id.patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for TaskManager { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::TaskManager; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.max_attempts; + if *value < 1 { + errors.push(ValidationError::min_value(Property::MaxAttempts, 1)); + } + let value = &self.strategy; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for TaskManager { + fn pickle(&self, out: &mut Vec) { + self.max_attempts.pickle(out); + self.strategy.pickle(out); + self.total_deadline.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.max_attempts = Pickle::unpickle(stream)?; + this.strategy = Pickle::unpickle(stream)?; + this.total_deadline = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskManager { + fn default() -> Self { + Self { + max_attempts: 3u64, + strategy: Default::default(), + total_deadline: Duration::from_millis(21600000), + } + } +} + +impl IntoValue for TaskManager { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::MaxAttempts, self.max_attempts.into_value()); + map.insert_unchecked(Property::Strategy, self.strategy.into_value()); + map.insert_unchecked(Property::TotalDeadline, self.total_deadline.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskManager { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaxAttempts) => self.max_attempts.patch(pointer, value), + Some(Property::Strategy) => self.strategy.patch(pointer, value), + Some(Property::TotalDeadline) => self.total_deadline.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskMergeThreads { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.thread_name; + if value.is_empty() { + errors.push(ValidationError::required(Property::ThreadName)); + } + let value = &self.message_ids; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::MessageIds)); + } + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskMergeThreads { + fn pickle(&self, out: &mut Vec) { + self.account_id.pickle(out); + self.thread_name.pickle(out); + self.message_ids.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.account_id = Pickle::unpickle(stream)?; + this.thread_name = Pickle::unpickle(stream)?; + this.message_ids = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskMergeThreads { + fn default() -> Self { + Self { + account_id: Default::default(), + thread_name: Default::default(), + message_ids: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskMergeThreads { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::ThreadName, self.thread_name.into_value()); + map.insert_unchecked(Property::MessageIds, self.message_ids.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskMergeThreads { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AccountId) => pointer.assert_server_set(), + Some(Property::ThreadName) => pointer.assert_server_set(), + Some(Property::MessageIds) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskRestoreArchivedItem { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.blob_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::BlobId)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.archived_until; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ArchivedUntil, value)); + } + let value = &self.account_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::AccountId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Account, self.account_id.into(), None); + } +} + +impl Pickle for TaskRestoreArchivedItem { + fn pickle(&self, out: &mut Vec) { + self.blob_id.pickle(out); + self.archived_item_type.pickle(out); + self.created_at.pickle(out); + self.archived_until.pickle(out); + self.account_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.blob_id = Pickle::unpickle(stream)?; + this.archived_item_type = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.archived_until = Pickle::unpickle(stream)?; + this.account_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskRestoreArchivedItem { + fn default() -> Self { + Self { + blob_id: Default::default(), + archived_item_type: Default::default(), + created_at: Default::default(), + archived_until: Default::default(), + account_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskRestoreArchivedItem { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::BlobId, self.blob_id.into_value()); + map.insert_unchecked( + Property::ArchivedItemType, + self.archived_item_type.into_value(), + ); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::ArchivedUntil, self.archived_until.into_value()); + map.insert_unchecked(Property::AccountId, self.account_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskRestoreArchivedItem { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::BlobId) => pointer.assert_server_set(), + Some(Property::ArchivedItemType) => pointer.assert_server_set(), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::ArchivedUntil) => pointer.assert_server_set(), + Some(Property::AccountId) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskRetryStrategy { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + TaskRetryStrategy::ExponentialBackoff(inner) => inner.validate(errors), + TaskRetryStrategy::FixedDelay(inner) => inner.validate(errors), + } + } +} + +impl Default for TaskRetryStrategy { + fn default() -> Self { + TaskRetryStrategy::ExponentialBackoff(Default::default()) + } +} + +impl Pickle for TaskRetryStrategy { + fn pickle(&self, out: &mut Vec) { + match self { + TaskRetryStrategy::ExponentialBackoff(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + TaskRetryStrategy::FixedDelay(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(TaskRetryStrategy::ExponentialBackoff), + 1 => Pickle::unpickle(stream).map(TaskRetryStrategy::FixedDelay), + _ => None, + } + } +} + +impl IntoValue for TaskRetryStrategy { + fn into_value(self) -> JmapValue<'static> { + match self { + TaskRetryStrategy::ExponentialBackoff(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("ExponentialBackoff".into())); + obj + } + TaskRetryStrategy::FixedDelay(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FixedDelay".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for TaskRetryStrategy { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TaskRetryStrategyType::ExponentialBackoff => { + *self = TaskRetryStrategy::ExponentialBackoff(Default::default()) + } + TaskRetryStrategyType::FixedDelay => { + *self = TaskRetryStrategy::FixedDelay(Default::default()) + } + } + } + match self { + TaskRetryStrategy::ExponentialBackoff(inner) => inner.patch(pointer, value), + TaskRetryStrategy::FixedDelay(inner) => inner.patch(pointer, value), + } + } +} + +impl TaskRetryStrategy { + pub fn object_type(&self) -> TaskRetryStrategyType { + match self { + TaskRetryStrategy::ExponentialBackoff(_) => TaskRetryStrategyType::ExponentialBackoff, + TaskRetryStrategy::FixedDelay(_) => TaskRetryStrategyType::FixedDelay, + } + } +} + +impl TaskRetryStrategyBackoff { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.factor; + if *value < Float::new(1.0) { + errors.push(ValidationError::min_value(Property::Factor, 1)); + } + errors.len() == neb + } +} + +impl Pickle for TaskRetryStrategyBackoff { + fn pickle(&self, out: &mut Vec) { + self.factor.pickle(out); + self.initial_delay.pickle(out); + self.max_delay.pickle(out); + self.jitter.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.factor = Pickle::unpickle(stream)?; + this.initial_delay = Pickle::unpickle(stream)?; + this.max_delay = Pickle::unpickle(stream)?; + this.jitter = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskRetryStrategyBackoff { + fn default() -> Self { + Self { + factor: Float::new(2.0f64), + initial_delay: Duration::from_millis(60000), + max_delay: Duration::from_millis(1800000), + jitter: true, + } + } +} + +impl IntoValue for TaskRetryStrategyBackoff { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::Factor, self.factor.into_value()); + map.insert_unchecked(Property::InitialDelay, self.initial_delay.into_value()); + map.insert_unchecked(Property::MaxDelay, self.max_delay.into_value()); + map.insert_unchecked(Property::Jitter, self.jitter.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskRetryStrategyBackoff { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Factor) => self.factor.patch(pointer, value), + Some(Property::InitialDelay) => self.initial_delay.patch(pointer, value), + Some(Property::MaxDelay) => self.max_delay.patch(pointer, value), + Some(Property::Jitter) => self.jitter.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskRetryStrategyFixed { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TaskRetryStrategyFixed { + fn pickle(&self, out: &mut Vec) { + self.delay.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.delay = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskRetryStrategyFixed { + fn default() -> Self { + Self { + delay: Duration::from_millis(300000), + } + } +} + +impl IntoValue for TaskRetryStrategyFixed { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Delay, self.delay.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskRetryStrategyFixed { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Delay) => self.delay.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskSpamFilterMaintenance { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.status; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for TaskSpamFilterMaintenance { + fn pickle(&self, out: &mut Vec) { + self.maintenance_type.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.maintenance_type = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskSpamFilterMaintenance { + fn default() -> Self { + Self { + maintenance_type: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskSpamFilterMaintenance { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked( + Property::MaintenanceType, + self.maintenance_type.into_value(), + ); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskSpamFilterMaintenance { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaintenanceType) => self + .maintenance_type + .patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskStatus { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + TaskStatus::Pending(inner) => inner.validate(errors), + TaskStatus::Retry(inner) => inner.validate(errors), + TaskStatus::Failed(inner) => inner.validate(errors), + } + } +} + +impl Default for TaskStatus { + fn default() -> Self { + TaskStatus::Pending(Default::default()) + } +} + +impl Pickle for TaskStatus { + fn pickle(&self, out: &mut Vec) { + match self { + TaskStatus::Pending(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + TaskStatus::Retry(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + TaskStatus::Failed(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(TaskStatus::Pending), + 1 => Pickle::unpickle(stream).map(TaskStatus::Retry), + 2 => Pickle::unpickle(stream).map(TaskStatus::Failed), + _ => None, + } + } +} + +impl IntoValue for TaskStatus { + fn into_value(self) -> JmapValue<'static> { + match self { + TaskStatus::Pending(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Pending".into())); + obj + } + TaskStatus::Retry(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Retry".into())); + obj + } + TaskStatus::Failed(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Failed".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for TaskStatus { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TaskStatusType::Pending => *self = TaskStatus::Pending(Default::default()), + TaskStatusType::Retry => *self = TaskStatus::Retry(Default::default()), + TaskStatusType::Failed => *self = TaskStatus::Failed(Default::default()), + } + } + match self { + TaskStatus::Pending(inner) => inner.patch(pointer, value), + TaskStatus::Retry(inner) => inner.patch(pointer, value), + TaskStatus::Failed(inner) => inner.patch(pointer, value), + } + } +} + +impl TaskStatus { + pub fn object_type(&self) -> TaskStatusType { + match self { + TaskStatus::Pending(_) => TaskStatusType::Pending, + TaskStatus::Retry(_) => TaskStatusType::Retry, + TaskStatus::Failed(_) => TaskStatusType::Failed, + } + } +} + +impl TaskStatusFailed { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.failed_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::FailedAt, value)); + } + let value = &self.failure_reason; + if value.is_empty() { + errors.push(ValidationError::required(Property::FailureReason)); + } + errors.len() == neb + } +} + +impl Pickle for TaskStatusFailed { + fn pickle(&self, out: &mut Vec) { + self.created_at.pickle(out); + self.failed_at.pickle(out); + self.failed_attempt_number.pickle(out); + self.failure_reason.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.created_at = Pickle::unpickle(stream)?; + this.failed_at = Pickle::unpickle(stream)?; + this.failed_attempt_number = Pickle::unpickle(stream)?; + this.failure_reason = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskStatusFailed { + fn default() -> Self { + Self { + created_at: Default::default(), + failed_at: Default::default(), + failed_attempt_number: 0u64, + failure_reason: Default::default(), + } + } +} + +impl IntoValue for TaskStatusFailed { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::FailedAt, self.failed_at.into_value()); + map.insert_unchecked( + Property::FailedAttemptNumber, + self.failed_attempt_number.into_value(), + ); + map.insert_unchecked(Property::FailureReason, self.failure_reason.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskStatusFailed { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::FailedAt) => self.failed_at.patch(pointer, value), + Some(Property::FailedAttemptNumber) => self.failed_attempt_number.patch(pointer, value), + Some(Property::FailureReason) => self.failure_reason.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskStatusPending { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.due; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Due, value)); + } + errors.len() == neb + } +} + +impl Pickle for TaskStatusPending { + fn pickle(&self, out: &mut Vec) { + self.created_at.pickle(out); + self.due.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.created_at = Pickle::unpickle(stream)?; + this.due = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskStatusPending { + fn default() -> Self { + Self { + created_at: Default::default(), + due: Default::default(), + } + } +} + +impl IntoValue for TaskStatusPending { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::Due, self.due.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskStatusPending { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::Due) => self.due.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskStatusRetry { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.due; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Due, value)); + } + let value = &self.failure_reason; + if value.is_empty() { + errors.push(ValidationError::required(Property::FailureReason)); + } + errors.len() == neb + } +} + +impl Pickle for TaskStatusRetry { + fn pickle(&self, out: &mut Vec) { + self.created_at.pickle(out); + self.due.pickle(out); + self.attempt_number.pickle(out); + self.failure_reason.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.created_at = Pickle::unpickle(stream)?; + this.due = Pickle::unpickle(stream)?; + this.attempt_number = Pickle::unpickle(stream)?; + this.failure_reason = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskStatusRetry { + fn default() -> Self { + Self { + created_at: Default::default(), + due: Default::default(), + attempt_number: 1u64, + failure_reason: Default::default(), + } + } +} + +impl IntoValue for TaskStatusRetry { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(6); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::Due, self.due.into_value()); + map.insert_unchecked(Property::AttemptNumber, self.attempt_number.into_value()); + map.insert_unchecked(Property::FailureReason, self.failure_reason.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskStatusRetry { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::Due) => self.due.patch(pointer, value), + Some(Property::AttemptNumber) => self.attempt_number.patch(pointer, value), + Some(Property::FailureReason) => self.failure_reason.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskStoreMaintenance { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.status; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for TaskStoreMaintenance { + fn pickle(&self, out: &mut Vec) { + self.maintenance_type.pickle(out); + self.shard_index.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.maintenance_type = Pickle::unpickle(stream)?; + this.shard_index = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskStoreMaintenance { + fn default() -> Self { + Self { + maintenance_type: Default::default(), + shard_index: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskStoreMaintenance { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked( + Property::MaintenanceType, + self.maintenance_type.into_value(), + ); + map.insert_unchecked(Property::ShardIndex, self.shard_index.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskStoreMaintenance { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::MaintenanceType) => self + .maintenance_type + .patch(pointer.assert_read_only()?, value), + Some(Property::ShardIndex) => self.shard_index.patch(pointer, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskTenantMaintenance { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.tenant_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::TenantId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.tenant_id.into(), None); + } +} + +impl Pickle for TaskTenantMaintenance { + fn pickle(&self, out: &mut Vec) { + self.tenant_id.pickle(out); + self.maintenance_type.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.tenant_id = Pickle::unpickle(stream)?; + this.maintenance_type = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskTenantMaintenance { + fn default() -> Self { + Self { + tenant_id: Default::default(), + maintenance_type: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskTenantMaintenance { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::TenantId, self.tenant_id.into_value()); + map.insert_unchecked( + Property::MaintenanceType, + self.maintenance_type.into_value(), + ); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskTenantMaintenance { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::TenantId) => self.tenant_id.patch(pointer.assert_read_only()?, value), + Some(Property::MaintenanceType) => self + .maintenance_type + .patch(pointer.assert_read_only()?, value), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TaskTlsReport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.report_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::ReportId)); + } + let value = &self.status; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::TlsInternalReport, self.report_id.into(), None); + } +} + +impl Pickle for TaskTlsReport { + fn pickle(&self, out: &mut Vec) { + self.report_id.pickle(out); + self.status.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.report_id = Pickle::unpickle(stream)?; + this.status = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TaskTlsReport { + fn default() -> Self { + Self { + report_id: Default::default(), + status: Default::default(), + } + } +} + +impl IntoValue for TaskTlsReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::ReportId, self.report_id.into_value()); + map.insert_unchecked(Property::Status, self.status.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TaskTlsReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ReportId) => pointer.assert_server_set(), + Some(Property::Status) => self.status.patch(pointer, value), + Some(Property::Due) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Tenant { + const FLAGS: u64 = OBJ_SEQ_ID; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Tenant; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + if let Some(value) = &self.logo { + if value.is_empty() { + errors.push(ValidationError::required(Property::Logo)); + } + } + let value = &self.roles; + value.validate(errors); + let value = &self.permissions; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.text(Property::Text, &self.name); + self.roles.index(i); + } +} + +impl Pickle for Tenant { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.created_at.pickle(out); + self.logo.pickle(out); + self.roles.pickle(out); + self.permissions.pickle(out); + self.quotas.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.logo = Pickle::unpickle(stream)?; + this.roles = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.quotas = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Tenant { + fn default() -> Self { + Self { + name: Default::default(), + created_at: Default::default(), + logo: Default::default(), + roles: Default::default(), + permissions: Default::default(), + quotas: Default::default(), + } + } +} + +impl IntoValue for Tenant { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::Logo, self.logo.into_value()); + map.insert_unchecked(Property::Roles, self.roles.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::Quotas, self.quotas.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Tenant { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::Logo) => self.logo.patch(pointer, value), + Some(Property::Roles) => self.roles.patch(pointer, value), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::Quotas) => self.quotas.patch(pointer, value), + Some(Property::UsedDiskQuota) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for TlsExternalReport { + const FLAGS: u64 = OBJ_FILTER_TENANT; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::TlsExternalReport; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.report; + value.validate(errors); + let value = &self.from; + if value.is_empty() { + errors.push(ValidationError::required(Property::From)); + } + let value = &self.subject; + if value.is_empty() { + errors.push(ValidationError::required(Property::Subject)); + } + let value = &self.to; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::To)); + } + } + let value = &self.received_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivedAt, value)); + } + let value = &self.expires_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ExpiresAt, value)); + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + } +} + +impl Pickle for TlsExternalReport { + fn pickle(&self, out: &mut Vec) { + self.report.pickle(out); + self.from.pickle(out); + self.subject.pickle(out); + self.to.pickle(out); + self.received_at.pickle(out); + self.expires_at.pickle(out); + self.member_tenant_id.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.report = Pickle::unpickle(stream)?; + this.from = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + this.to = Pickle::unpickle(stream)?; + this.received_at = Pickle::unpickle(stream)?; + this.expires_at = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsExternalReport { + fn default() -> Self { + Self { + report: Default::default(), + from: Default::default(), + subject: Default::default(), + to: Default::default(), + received_at: Default::default(), + expires_at: Default::default(), + member_tenant_id: Default::default(), + } + } +} + +impl IntoValue for TlsExternalReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::From, self.from.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + map.insert_unchecked(Property::To, self.to.into_value()); + map.insert_unchecked(Property::ReceivedAt, self.received_at.into_value()); + map.insert_unchecked(Property::ExpiresAt, self.expires_at.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsExternalReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::From) => self + .from + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::To) => self + .to + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::ReceivedAt) => self.received_at.patch(pointer, value), + Some(Property::ExpiresAt) => self.expires_at.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TlsFailureDetails { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.sending_mta_ip { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::SendingMtaIp, value)); + } + } + if let Some(value) = &self.receiving_mx_hostname { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReceivingMxHostname)); + } + } + if let Some(value) = &self.receiving_mx_helo { + if value.is_empty() { + errors.push(ValidationError::required(Property::ReceivingMxHelo)); + } + } + if let Some(value) = &self.receiving_ip { + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::ReceivingIp, value)); + } + } + if let Some(value) = &self.additional_information { + if value.is_empty() { + errors.push(ValidationError::required(Property::AdditionalInformation)); + } + } + if let Some(value) = &self.failure_reason_code { + if value.is_empty() { + errors.push(ValidationError::required(Property::FailureReasonCode)); + } + } + errors.len() == neb + } +} + +impl Pickle for TlsFailureDetails { + fn pickle(&self, out: &mut Vec) { + self.result_type.pickle(out); + self.sending_mta_ip.pickle(out); + self.receiving_mx_hostname.pickle(out); + self.receiving_mx_helo.pickle(out); + self.receiving_ip.pickle(out); + self.failed_session_count.pickle(out); + self.additional_information.pickle(out); + self.failure_reason_code.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.result_type = Pickle::unpickle(stream)?; + this.sending_mta_ip = Pickle::unpickle(stream)?; + this.receiving_mx_hostname = Pickle::unpickle(stream)?; + this.receiving_mx_helo = Pickle::unpickle(stream)?; + this.receiving_ip = Pickle::unpickle(stream)?; + this.failed_session_count = Pickle::unpickle(stream)?; + this.additional_information = Pickle::unpickle(stream)?; + this.failure_reason_code = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsFailureDetails { + fn default() -> Self { + Self { + result_type: Default::default(), + sending_mta_ip: Default::default(), + receiving_mx_hostname: Default::default(), + receiving_mx_helo: Default::default(), + receiving_ip: Default::default(), + failed_session_count: 0u64, + additional_information: Default::default(), + failure_reason_code: Default::default(), + } + } +} + +impl IntoValue for TlsFailureDetails { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::ResultType, self.result_type.into_value()); + map.insert_unchecked(Property::SendingMtaIp, self.sending_mta_ip.into_value()); + map.insert_unchecked( + Property::ReceivingMxHostname, + self.receiving_mx_hostname.into_value(), + ); + map.insert_unchecked( + Property::ReceivingMxHelo, + self.receiving_mx_helo.into_value(), + ); + map.insert_unchecked(Property::ReceivingIp, self.receiving_ip.into_value()); + map.insert_unchecked( + Property::FailedSessionCount, + self.failed_session_count.into_value(), + ); + map.insert_unchecked( + Property::AdditionalInformation, + self.additional_information.into_value(), + ); + map.insert_unchecked( + Property::FailureReasonCode, + self.failure_reason_code.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsFailureDetails { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ResultType) => self.result_type.patch(pointer, value), + Some(Property::SendingMtaIp) => self.sending_mta_ip.patch(pointer, value), + Some(Property::ReceivingMxHostname) => self.receiving_mx_hostname.patch(pointer, value), + Some(Property::ReceivingMxHelo) => self.receiving_mx_helo.patch(pointer, value), + Some(Property::ReceivingIp) => self.receiving_ip.patch(pointer, value), + Some(Property::FailedSessionCount) => self.failed_session_count.patch(pointer, value), + Some(Property::AdditionalInformation) => { + self.additional_information.patch(pointer, value) + } + Some(Property::FailureReasonCode) => self.failure_reason_code.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for TlsInternalReport { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::TlsInternalReport; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.mail_rua; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::MailRua)); + } + } + let value = &self.http_rua; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpRua)); + } + } + let value = &self.report; + value.validate(errors); + let value = &self.domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::Domain)); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.deliver_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DeliverAt, value)); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for TlsInternalReport { + fn pickle(&self, out: &mut Vec) { + self.policy_identifiers.pickle(out); + self.mail_rua.pickle(out); + self.http_rua.pickle(out); + self.report.pickle(out); + self.domain.pickle(out); + self.created_at.pickle(out); + self.deliver_at.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.policy_identifiers = Pickle::unpickle(stream)?; + this.mail_rua = Pickle::unpickle(stream)?; + this.http_rua = Pickle::unpickle(stream)?; + this.report = Pickle::unpickle(stream)?; + this.domain = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.deliver_at = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsInternalReport { + fn default() -> Self { + Self { + policy_identifiers: Default::default(), + mail_rua: Default::default(), + http_rua: Default::default(), + report: Default::default(), + domain: Default::default(), + created_at: Default::default(), + deliver_at: Default::default(), + } + } +} + +impl IntoValue for TlsInternalReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked( + Property::PolicyIdentifiers, + self.policy_identifiers.into_value(), + ); + map.insert_unchecked(Property::MailRua, self.mail_rua.into_value()); + map.insert_unchecked(Property::HttpRua, self.http_rua.into_value()); + map.insert_unchecked(Property::Report, self.report.into_value()); + map.insert_unchecked(Property::Domain, self.domain.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::DeliverAt, self.deliver_at.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsInternalReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::PolicyIdentifiers) => self.policy_identifiers.patch(pointer, value), + Some(Property::MailRua) => self + .mail_rua + .patch(pointer.with_validators(&[StringValidator::Email]), value), + Some(Property::HttpRua) => self.http_rua.patch(pointer, value), + Some(Property::Report) => self.report.patch(pointer, value), + Some(Property::Domain) => self + .domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::CreatedAt) => self.created_at.patch(pointer, value), + Some(Property::DeliverAt) => self.deliver_at.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TlsReport { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.organization_name { + if value.is_empty() { + errors.push(ValidationError::required(Property::OrganizationName)); + } + } + if let Some(value) = &self.contact_info { + if value.is_empty() { + errors.push(ValidationError::required(Property::ContactInfo)); + } + } + let value = &self.report_id; + if value.is_empty() { + errors.push(ValidationError::required(Property::ReportId)); + } + let value = &self.date_range_start; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DateRangeStart, value)); + } + let value = &self.date_range_end; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::DateRangeEnd, value)); + } + let value = &self.policies; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for TlsReport { + fn pickle(&self, out: &mut Vec) { + self.organization_name.pickle(out); + self.contact_info.pickle(out); + self.report_id.pickle(out); + self.date_range_start.pickle(out); + self.date_range_end.pickle(out); + self.policies.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.organization_name = Pickle::unpickle(stream)?; + this.contact_info = Pickle::unpickle(stream)?; + this.report_id = Pickle::unpickle(stream)?; + this.date_range_start = Pickle::unpickle(stream)?; + this.date_range_end = Pickle::unpickle(stream)?; + this.policies = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsReport { + fn default() -> Self { + Self { + organization_name: Default::default(), + contact_info: Default::default(), + report_id: Default::default(), + date_range_start: Default::default(), + date_range_end: Default::default(), + policies: Default::default(), + } + } +} + +impl IntoValue for TlsReport { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(8); + map.insert_unchecked( + Property::OrganizationName, + self.organization_name.into_value(), + ); + map.insert_unchecked(Property::ContactInfo, self.contact_info.into_value()); + map.insert_unchecked(Property::ReportId, self.report_id.into_value()); + map.insert_unchecked(Property::DateRangeStart, self.date_range_start.into_value()); + map.insert_unchecked(Property::DateRangeEnd, self.date_range_end.into_value()); + map.insert_unchecked(Property::Policies, self.policies.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsReport { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::OrganizationName) => self.organization_name.patch(pointer, value), + Some(Property::ContactInfo) => self.contact_info.patch(pointer, value), + Some(Property::ReportId) => self.report_id.patch(pointer, value), + Some(Property::DateRangeStart) => self.date_range_start.patch(pointer, value), + Some(Property::DateRangeEnd) => self.date_range_end.patch(pointer, value), + Some(Property::Policies) => self.policies.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TlsReportPolicy { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.policy_strings; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::PolicyStrings)); + } + } + let value = &self.policy_domain; + if value.is_empty() { + errors.push(ValidationError::required(Property::PolicyDomain)); + } + let value = &self.mx_hosts; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::MxHosts)); + } + } + let value = &self.failure_details; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for TlsReportPolicy { + fn pickle(&self, out: &mut Vec) { + self.policy_type.pickle(out); + self.policy_strings.pickle(out); + self.policy_domain.pickle(out); + self.mx_hosts.pickle(out); + self.total_successful_sessions.pickle(out); + self.total_failed_sessions.pickle(out); + self.failure_details.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.policy_type = Pickle::unpickle(stream)?; + this.policy_strings = Pickle::unpickle(stream)?; + this.policy_domain = Pickle::unpickle(stream)?; + this.mx_hosts = Pickle::unpickle(stream)?; + this.total_successful_sessions = Pickle::unpickle(stream)?; + this.total_failed_sessions = Pickle::unpickle(stream)?; + this.failure_details = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsReportPolicy { + fn default() -> Self { + Self { + policy_type: Default::default(), + policy_strings: Default::default(), + policy_domain: Default::default(), + mx_hosts: Default::default(), + total_successful_sessions: 0u64, + total_failed_sessions: 0u64, + failure_details: Default::default(), + } + } +} + +impl IntoValue for TlsReportPolicy { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked(Property::PolicyType, self.policy_type.into_value()); + map.insert_unchecked(Property::PolicyStrings, self.policy_strings.into_value()); + map.insert_unchecked(Property::PolicyDomain, self.policy_domain.into_value()); + map.insert_unchecked(Property::MxHosts, self.mx_hosts.into_value()); + map.insert_unchecked( + Property::TotalSuccessfulSessions, + self.total_successful_sessions.into_value(), + ); + map.insert_unchecked( + Property::TotalFailedSessions, + self.total_failed_sessions.into_value(), + ); + map.insert_unchecked(Property::FailureDetails, self.failure_details.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsReportPolicy { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::PolicyType) => self.policy_type.patch(pointer, value), + Some(Property::PolicyStrings) => self.policy_strings.patch(pointer, value), + Some(Property::PolicyDomain) => self + .policy_domain + .patch(pointer.with_validators(&[StringValidator::Domain]), value), + Some(Property::MxHosts) => self.mx_hosts.patch(pointer, value), + Some(Property::TotalSuccessfulSessions) => { + self.total_successful_sessions.patch(pointer, value) + } + Some(Property::TotalFailedSessions) => self.total_failed_sessions.patch(pointer, value), + Some(Property::FailureDetails) => self.failure_details.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for TlsReportSettings { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::TlsReportSettings; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.contact_info; + value.validate(errors); + let value = &self.from_address; + value.validate(errors); + let value = &self.from_name; + value.validate(errors); + let value = &self.max_report_size; + value.validate(errors); + let value = &self.org_name; + value.validate(errors); + let value = &self.send_frequency; + value.validate(errors); + let value = &self.dkim_sign_domain; + value.validate(errors); + let value = &self.subject; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl TlsReportSettings { + pub fn ctx_contact_info(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.contact_info, + default: Some(Expression { + else_: "false".to_string(), + ..Default::default() + }), + property: Property::ContactInfo, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_from_address(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_address, + default: Some(Expression { + else_: "'noreply-tls@' + system('domain')".to_string(), + ..Default::default() + }), + property: Property::FromAddress, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_from_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.from_name, + default: Some(Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }), + property: Property::FromName, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_max_report_size(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.max_report_size, + default: Some(Expression { + else_: "5242880".to_string(), + ..Default::default() + }), + property: Property::MaxReportSize, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_org_name(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.org_name, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::OrgName, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_send_frequency(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.send_frequency, + default: Some(Expression { + else_: "daily".to_string(), + ..Default::default() + }), + property: Property::SendFrequency, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: MTA_AGGREGATE_CONSTANT, + } + } + + pub fn ctx_dkim_sign_domain(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.dkim_sign_domain, + default: Some(Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }), + property: Property::DkimSignDomain, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn ctx_subject(&self) -> ExpressionContext<'_> { + ExpressionContext { + expr: &self.subject, + default: Some(Expression { + else_: "'TLS Aggregate Report'".to_string(), + ..Default::default() + }), + property: Property::Subject, + allowed_variables: MTA_QUEUE_HOST_VARIABLE, + allowed_constants: &[], + } + } + + pub fn expression_ctxs(&self) -> Vec> { + vec![ + self.ctx_contact_info(), + self.ctx_from_address(), + self.ctx_from_name(), + self.ctx_max_report_size(), + self.ctx_org_name(), + self.ctx_send_frequency(), + self.ctx_dkim_sign_domain(), + self.ctx_subject(), + ] + } +} + +impl Pickle for TlsReportSettings { + fn pickle(&self, out: &mut Vec) { + self.contact_info.pickle(out); + self.from_address.pickle(out); + self.from_name.pickle(out); + self.max_report_size.pickle(out); + self.org_name.pickle(out); + self.send_frequency.pickle(out); + self.dkim_sign_domain.pickle(out); + self.subject.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.contact_info = Pickle::unpickle(stream)?; + this.from_address = Pickle::unpickle(stream)?; + this.from_name = Pickle::unpickle(stream)?; + this.max_report_size = Pickle::unpickle(stream)?; + this.org_name = Pickle::unpickle(stream)?; + this.send_frequency = Pickle::unpickle(stream)?; + this.dkim_sign_domain = Pickle::unpickle(stream)?; + this.subject = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TlsReportSettings { + fn default() -> Self { + Self { + contact_info: Expression { + else_: "false".to_string(), + ..Default::default() + }, + from_address: Expression { + else_: "'noreply-tls@' + system('domain')".to_string(), + ..Default::default() + }, + from_name: Expression { + else_: "'Report Subsystem'".to_string(), + ..Default::default() + }, + max_report_size: Expression { + else_: "5242880".to_string(), + ..Default::default() + }, + org_name: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + send_frequency: Expression { + else_: "daily".to_string(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "system('domain')".to_string(), + ..Default::default() + }, + subject: Expression { + else_: "'TLS Aggregate Report'".to_string(), + ..Default::default() + }, + } + } +} + +impl IntoValue for TlsReportSettings { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::ContactInfo, self.contact_info.into_value()); + map.insert_unchecked(Property::FromAddress, self.from_address.into_value()); + map.insert_unchecked(Property::FromName, self.from_name.into_value()); + map.insert_unchecked(Property::MaxReportSize, self.max_report_size.into_value()); + map.insert_unchecked(Property::OrgName, self.org_name.into_value()); + map.insert_unchecked(Property::SendFrequency, self.send_frequency.into_value()); + map.insert_unchecked(Property::DkimSignDomain, self.dkim_sign_domain.into_value()); + map.insert_unchecked(Property::Subject, self.subject.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TlsReportSettings { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::ContactInfo) => self.contact_info.patch(pointer, value), + Some(Property::FromAddress) => self.from_address.patch(pointer, value), + Some(Property::FromName) => self.from_name.patch(pointer, value), + Some(Property::MaxReportSize) => self.max_report_size.patch(pointer, value), + Some(Property::OrgName) => self.org_name.patch(pointer, value), + Some(Property::SendFrequency) => self.send_frequency.patch(pointer, value), + Some(Property::DkimSignDomain) => self.dkim_sign_domain.patch(pointer, value), + Some(Property::Subject) => self.subject.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Trace { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Trace; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.events; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for Trace { + fn pickle(&self, out: &mut Vec) { + self.events.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.events = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for Trace { + fn default() -> Self { + Self { + events: Default::default(), + } + } +} + +impl IntoValue for Trace { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Events, self.events.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for Trace { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::Timestamp) => pointer.assert_server_set(), + Some(Property::From) => pointer.assert_server_set(), + Some(Property::To) => pointer.assert_server_set(), + Some(Property::Size) => pointer.assert_server_set(), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceEvent { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.timestamp; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Timestamp, value)); + } + let value = &self.key_values; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for TraceEvent { + fn pickle(&self, out: &mut Vec) { + self.event.pickle(out); + self.timestamp.pickle(out); + self.key_values.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.event = Pickle::unpickle(stream)?; + this.timestamp = Pickle::unpickle(stream)?; + this.key_values = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceEvent { + fn default() -> Self { + Self { + event: Default::default(), + timestamp: Default::default(), + key_values: Default::default(), + } + } +} + +impl IntoValue for TraceEvent { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(5); + map.insert_unchecked(Property::Event, self.event.into_value()); + map.insert_unchecked(Property::Timestamp, self.timestamp.into_value()); + map.insert_unchecked(Property::KeyValues, self.key_values.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceEvent { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Event) => self.event.patch(pointer, value), + Some(Property::Timestamp) => self.timestamp.patch(pointer, value), + Some(Property::KeyValues) => self.key_values.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceKeyValue { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + value.validate(errors); + errors.len() == neb + } +} + +impl Pickle for TraceKeyValue { + fn pickle(&self, out: &mut Vec) { + self.key.pickle(out); + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.key = Pickle::unpickle(stream)?; + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceKeyValue { + fn default() -> Self { + Self { + key: Default::default(), + value: Default::default(), + } + } +} + +impl IntoValue for TraceKeyValue { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Key, self.key.into_value()); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceKeyValue { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Key) => self.key.patch(pointer, value), + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValue { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + TraceValue::String(inner) => inner.validate(errors), + TraceValue::UnsignedInt(inner) => inner.validate(errors), + TraceValue::Integer(inner) => inner.validate(errors), + TraceValue::Boolean(inner) => inner.validate(errors), + TraceValue::Float(inner) => inner.validate(errors), + TraceValue::UTCDateTime(inner) => inner.validate(errors), + TraceValue::Duration(inner) => inner.validate(errors), + TraceValue::IpAddr(inner) => inner.validate(errors), + TraceValue::List(inner) => inner.validate(errors), + TraceValue::Event(inner) => inner.validate(errors), + TraceValue::Null => true, + } + } +} + +impl Default for TraceValue { + fn default() -> Self { + TraceValue::String(Default::default()) + } +} + +impl Pickle for TraceValue { + fn pickle(&self, out: &mut Vec) { + match self { + TraceValue::String(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + TraceValue::UnsignedInt(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + TraceValue::Integer(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + TraceValue::Boolean(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + TraceValue::Float(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + TraceValue::UTCDateTime(inner) => { + 5u16.pickle(out); + inner.pickle(out); + } + TraceValue::Duration(inner) => { + 6u16.pickle(out); + inner.pickle(out); + } + TraceValue::IpAddr(inner) => { + 7u16.pickle(out); + inner.pickle(out); + } + TraceValue::List(inner) => { + 8u16.pickle(out); + inner.pickle(out); + } + TraceValue::Event(inner) => { + 9u16.pickle(out); + inner.pickle(out); + } + TraceValue::Null => { + 10u16.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(TraceValue::String), + 1 => Pickle::unpickle(stream).map(TraceValue::UnsignedInt), + 2 => Pickle::unpickle(stream).map(TraceValue::Integer), + 3 => Pickle::unpickle(stream).map(TraceValue::Boolean), + 4 => Pickle::unpickle(stream).map(TraceValue::Float), + 5 => Pickle::unpickle(stream).map(TraceValue::UTCDateTime), + 6 => Pickle::unpickle(stream).map(TraceValue::Duration), + 7 => Pickle::unpickle(stream).map(TraceValue::IpAddr), + 8 => Pickle::unpickle(stream).map(TraceValue::List), + 9 => Pickle::unpickle(stream).map(TraceValue::Event), + 10 => Some(TraceValue::Null), + _ => None, + } + } +} + +impl IntoValue for TraceValue { + fn into_value(self) -> JmapValue<'static> { + match self { + TraceValue::String(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("String".into())); + obj + } + TraceValue::UnsignedInt(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UnsignedInt".into())); + obj + } + TraceValue::Integer(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Integer".into())); + obj + } + TraceValue::Boolean(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Boolean".into())); + obj + } + TraceValue::Float(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Float".into())); + obj + } + TraceValue::UTCDateTime(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("UTCDateTime".into())); + obj + } + TraceValue::Duration(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Duration".into())); + obj + } + TraceValue::IpAddr(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("IpAddr".into())); + obj + } + TraceValue::List(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("List".into())); + obj + } + TraceValue::Event(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Event".into())); + obj + } + TraceValue::Null => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Null".into())); + JmapValue::Object(obj) + } + } + } +} + +impl RegistryJsonPatch for TraceValue { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TraceValueType::String => *self = TraceValue::String(Default::default()), + TraceValueType::UnsignedInt => *self = TraceValue::UnsignedInt(Default::default()), + TraceValueType::Integer => *self = TraceValue::Integer(Default::default()), + TraceValueType::Boolean => *self = TraceValue::Boolean(Default::default()), + TraceValueType::Float => *self = TraceValue::Float(Default::default()), + TraceValueType::UTCDateTime => *self = TraceValue::UTCDateTime(Default::default()), + TraceValueType::Duration => *self = TraceValue::Duration(Default::default()), + TraceValueType::IpAddr => *self = TraceValue::IpAddr(Default::default()), + TraceValueType::List => *self = TraceValue::List(Default::default()), + TraceValueType::Event => *self = TraceValue::Event(Default::default()), + TraceValueType::Null => *self = TraceValue::Null, + } + } + match self { + TraceValue::String(inner) => inner.patch(pointer, value), + TraceValue::UnsignedInt(inner) => inner.patch(pointer, value), + TraceValue::Integer(inner) => inner.patch(pointer, value), + TraceValue::Boolean(inner) => inner.patch(pointer, value), + TraceValue::Float(inner) => inner.patch(pointer, value), + TraceValue::UTCDateTime(inner) => inner.patch(pointer, value), + TraceValue::Duration(inner) => inner.patch(pointer, value), + TraceValue::IpAddr(inner) => inner.patch(pointer, value), + TraceValue::List(inner) => inner.patch(pointer, value), + TraceValue::Event(inner) => inner.patch(pointer, value), + TraceValue::Null => pointer.assert_eof(), + } + } +} + +impl TraceValue { + pub fn object_type(&self) -> TraceValueType { + match self { + TraceValue::String(_) => TraceValueType::String, + TraceValue::UnsignedInt(_) => TraceValueType::UnsignedInt, + TraceValue::Integer(_) => TraceValueType::Integer, + TraceValue::Boolean(_) => TraceValueType::Boolean, + TraceValue::Float(_) => TraceValueType::Float, + TraceValue::UTCDateTime(_) => TraceValueType::UTCDateTime, + TraceValue::Duration(_) => TraceValueType::Duration, + TraceValue::IpAddr(_) => TraceValueType::IpAddr, + TraceValue::List(_) => TraceValueType::List, + TraceValue::Event(_) => TraceValueType::Event, + TraceValue::Null => TraceValueType::Null, + } + } +} + +impl TraceValueBoolean { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TraceValueBoolean { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueBoolean { + fn default() -> Self { + Self { value: false } + } +} + +impl IntoValue for TraceValueBoolean { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueBoolean { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueDuration { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TraceValueDuration { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueDuration { + fn default() -> Self { + Self { value: 0u64 } + } +} + +impl IntoValue for TraceValueDuration { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueDuration { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueEvent { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for TraceValueEvent { + fn pickle(&self, out: &mut Vec) { + self.event.pickle(out); + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.event = Pickle::unpickle(stream)?; + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueEvent { + fn default() -> Self { + Self { + event: Default::default(), + value: Default::default(), + } + } +} + +impl IntoValue for TraceValueEvent { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Event, self.event.into_value()); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueEvent { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Event) => self.event.patch(pointer, value), + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueFloat { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TraceValueFloat { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueFloat { + fn default() -> Self { + Self { + value: Float::new(0.0f64), + } + } +} + +impl IntoValue for TraceValueFloat { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueFloat { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueInteger { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TraceValueInteger { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueInteger { + fn default() -> Self { + Self { value: 0i64 } + } +} + +impl IntoValue for TraceValueInteger { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueInteger { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueIpAddr { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Value, value)); + } + errors.len() == neb + } +} + +impl Pickle for TraceValueIpAddr { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueIpAddr { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for TraceValueIpAddr { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueIpAddr { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueList { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + for value in value.values() { + value.validate(errors); + } + errors.len() == neb + } +} + +impl Pickle for TraceValueList { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueList { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for TraceValueList { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueList { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueString { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + if value.is_empty() { + errors.push(ValidationError::required(Property::Value)); + } + errors.len() == neb + } +} + +impl Pickle for TraceValueString { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueString { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for TraceValueString { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueString { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueUTCDateTime { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.value; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::Value, value)); + } + errors.len() == neb + } +} + +impl Pickle for TraceValueUTCDateTime { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueUTCDateTime { + fn default() -> Self { + Self { + value: Default::default(), + } + } +} + +impl IntoValue for TraceValueUTCDateTime { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueUTCDateTime { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TraceValueUnsignedInt { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TraceValueUnsignedInt { + fn pickle(&self, out: &mut Vec) { + self.value.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.value = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TraceValueUnsignedInt { + fn default() -> Self { + Self { value: 0u64 } + } +} + +impl IntoValue for TraceValueUnsignedInt { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Value, self.value.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TraceValueUnsignedInt { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Value) => self.value.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for Tracer { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::Tracer; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + Tracer::Log(inner) => inner.validate(errors), + Tracer::Stdout(inner) => inner.validate(errors), + Tracer::Journal(inner) => inner.validate(errors), + Tracer::OtelHttp(inner) => inner.validate(errors), + Tracer::OtelGrpc(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for Tracer { + fn default() -> Self { + Tracer::Log(Default::default()) + } +} + +impl Pickle for Tracer { + fn pickle(&self, out: &mut Vec) { + match self { + Tracer::Log(inner) => { + 0u16.pickle(out); + inner.pickle(out); + } + Tracer::Stdout(inner) => { + 1u16.pickle(out); + inner.pickle(out); + } + Tracer::Journal(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + Tracer::OtelHttp(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + Tracer::OtelGrpc(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Pickle::unpickle(stream).map(Tracer::Log), + 1 => Pickle::unpickle(stream).map(Tracer::Stdout), + 2 => Pickle::unpickle(stream).map(Tracer::Journal), + 3 => Pickle::unpickle(stream).map(Tracer::OtelHttp), + 4 => Pickle::unpickle(stream).map(Tracer::OtelGrpc), + _ => None, + } + } +} + +impl IntoValue for Tracer { + fn into_value(self) -> JmapValue<'static> { + match self { + Tracer::Log(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Log".into())); + obj + } + Tracer::Stdout(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Stdout".into())); + obj + } + Tracer::Journal(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Journal".into())); + obj + } + Tracer::OtelHttp(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("OtelHttp".into())); + obj + } + Tracer::OtelGrpc(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("OtelGrpc".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for Tracer { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TracerType::Log => *self = Tracer::Log(Default::default()), + TracerType::Stdout => *self = Tracer::Stdout(Default::default()), + TracerType::Journal => *self = Tracer::Journal(Default::default()), + TracerType::OtelHttp => *self = Tracer::OtelHttp(Default::default()), + TracerType::OtelGrpc => *self = Tracer::OtelGrpc(Default::default()), + } + } + match self { + Tracer::Log(inner) => inner.patch(pointer, value), + Tracer::Stdout(inner) => inner.patch(pointer, value), + Tracer::Journal(inner) => inner.patch(pointer, value), + Tracer::OtelHttp(inner) => inner.patch(pointer, value), + Tracer::OtelGrpc(inner) => inner.patch(pointer, value), + } + } +} + +impl Tracer { + pub fn object_type(&self) -> TracerType { + match self { + Tracer::Log(_) => TracerType::Log, + Tracer::Stdout(_) => TracerType::Stdout, + Tracer::Journal(_) => TracerType::Journal, + Tracer::OtelHttp(_) => TracerType::OtelHttp, + Tracer::OtelGrpc(_) => TracerType::OtelGrpc, + } + } +} + +impl TracerCommon { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TracerCommon { + fn pickle(&self, out: &mut Vec) { + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TracerCommon { + fn default() -> Self { + Self { + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for TracerCommon { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(7); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TracerCommon { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TracerLog { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.path; + if value.is_empty() { + errors.push(ValidationError::required(Property::Path)); + } + let value = &self.prefix; + if value.is_empty() { + errors.push(ValidationError::required(Property::Prefix)); + } + errors.len() == neb + } +} + +impl Pickle for TracerLog { + fn pickle(&self, out: &mut Vec) { + self.path.pickle(out); + self.prefix.pickle(out); + self.rotate.pickle(out); + self.ansi.pickle(out); + self.multiline.pickle(out); + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.path = Pickle::unpickle(stream)?; + this.prefix = Pickle::unpickle(stream)?; + this.rotate = Pickle::unpickle(stream)?; + this.ansi = Pickle::unpickle(stream)?; + this.multiline = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TracerLog { + fn default() -> Self { + Self { + path: Default::default(), + prefix: "stalwart".to_string(), + rotate: LogRotateFrequency::Daily, + ansi: true, + multiline: false, + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for TracerLog { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(12); + map.insert_unchecked(Property::Path, self.path.into_value()); + map.insert_unchecked(Property::Prefix, self.prefix.into_value()); + map.insert_unchecked(Property::Rotate, self.rotate.into_value()); + map.insert_unchecked(Property::Ansi, self.ansi.into_value()); + map.insert_unchecked(Property::Multiline, self.multiline.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TracerLog { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Path) => self + .path + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Prefix) => self + .prefix + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Rotate) => self.rotate.patch(pointer, value), + Some(Property::Ansi) => self.ansi.patch(pointer, value), + Some(Property::Multiline) => self.multiline.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TracerOtelGrpc { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + if let Some(value) = &self.endpoint { + if value.is_empty() { + errors.push(ValidationError::required(Property::Endpoint)); + } + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for TracerOtelGrpc { + fn pickle(&self, out: &mut Vec) { + self.endpoint.pickle(out); + self.enable_log_exporter.pickle(out); + self.enable_span_exporter.pickle(out); + self.throttle.pickle(out); + self.timeout.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.endpoint = Pickle::unpickle(stream)?; + this.enable_log_exporter = Pickle::unpickle(stream)?; + this.enable_span_exporter = Pickle::unpickle(stream)?; + this.throttle = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TracerOtelGrpc { + fn default() -> Self { + Self { + endpoint: Default::default(), + enable_log_exporter: true, + enable_span_exporter: true, + throttle: Duration::from_millis(1000), + timeout: Duration::from_millis(10000), + http_auth: Default::default(), + http_headers: Default::default(), + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for TracerOtelGrpc { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Endpoint, self.endpoint.into_value()); + map.insert_unchecked( + Property::EnableLogExporter, + self.enable_log_exporter.into_value(), + ); + map.insert_unchecked( + Property::EnableSpanExporter, + self.enable_span_exporter.into_value(), + ); + map.insert_unchecked(Property::Throttle, self.throttle.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TracerOtelGrpc { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Endpoint) => self + .endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::EnableLogExporter) => self.enable_log_exporter.patch(pointer, value), + Some(Property::EnableSpanExporter) => self.enable_span_exporter.patch(pointer, value), + Some(Property::Throttle) => self.throttle.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TracerOtelHttp { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.endpoint; + if value.is_empty() { + errors.push(ValidationError::required(Property::Endpoint)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } +} + +impl Pickle for TracerOtelHttp { + fn pickle(&self, out: &mut Vec) { + self.endpoint.pickle(out); + self.enable_log_exporter.pickle(out); + self.enable_span_exporter.pickle(out); + self.throttle.pickle(out); + self.timeout.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.endpoint = Pickle::unpickle(stream)?; + this.enable_log_exporter = Pickle::unpickle(stream)?; + this.enable_span_exporter = Pickle::unpickle(stream)?; + this.throttle = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TracerOtelHttp { + fn default() -> Self { + Self { + endpoint: Default::default(), + enable_log_exporter: true, + enable_span_exporter: true, + throttle: Duration::from_millis(1000), + timeout: Duration::from_millis(10000), + http_auth: Default::default(), + http_headers: Default::default(), + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for TracerOtelHttp { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(14); + map.insert_unchecked(Property::Endpoint, self.endpoint.into_value()); + map.insert_unchecked( + Property::EnableLogExporter, + self.enable_log_exporter.into_value(), + ); + map.insert_unchecked( + Property::EnableSpanExporter, + self.enable_span_exporter.into_value(), + ); + map.insert_unchecked(Property::Throttle, self.throttle.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TracerOtelHttp { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Endpoint) => self + .endpoint + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::EnableLogExporter) => self.enable_log_exporter.patch(pointer, value), + Some(Property::EnableSpanExporter) => self.enable_span_exporter.patch(pointer, value), + Some(Property::Throttle) => self.throttle.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl TracerStdout { + fn validate(&self, _: &mut Vec) -> bool { + true + } +} + +impl Pickle for TracerStdout { + fn pickle(&self, out: &mut Vec) { + self.buffered.pickle(out); + self.ansi.pickle(out); + self.multiline.pickle(out); + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.buffered = Pickle::unpickle(stream)?; + this.ansi = Pickle::unpickle(stream)?; + this.multiline = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for TracerStdout { + fn default() -> Self { + Self { + buffered: true, + ansi: false, + multiline: false, + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for TracerStdout { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(10); + map.insert_unchecked(Property::Buffered, self.buffered.into_value()); + map.insert_unchecked(Property::Ansi, self.ansi.into_value()); + map.insert_unchecked(Property::Multiline, self.multiline.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for TracerStdout { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Buffered) => self.buffered.patch(pointer, value), + Some(Property::Ansi) => self.ansi.patch(pointer, value), + Some(Property::Multiline) => self.multiline.patch(pointer, value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for TracingStore { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::TracingStore; + + fn validate(&self, errors: &mut Vec) -> bool { + match self { + TracingStore::Disabled => true, + TracingStore::Default => true, + TracingStore::FoundationDb(inner) => inner.validate(errors), + TracingStore::PostgreSql(inner) => inner.validate(errors), + TracingStore::MySql(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Default for TracingStore { + fn default() -> Self { + TracingStore::Disabled + } +} + +impl Pickle for TracingStore { + fn pickle(&self, out: &mut Vec) { + match self { + TracingStore::Disabled => { + 0u16.pickle(out); + } + TracingStore::Default => { + 1u16.pickle(out); + } + TracingStore::FoundationDb(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + TracingStore::PostgreSql(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } + TracingStore::MySql(inner) => { + 4u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(TracingStore::Disabled), + 1 => Some(TracingStore::Default), + 2 => Pickle::unpickle(stream).map(TracingStore::FoundationDb), + 3 => Pickle::unpickle(stream).map(TracingStore::PostgreSql), + 4 => Pickle::unpickle(stream).map(TracingStore::MySql), + _ => None, + } + } +} + +impl IntoValue for TracingStore { + fn into_value(self) -> JmapValue<'static> { + match self { + TracingStore::Disabled => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Disabled".into())); + JmapValue::Object(obj) + } + TracingStore::Default => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Default".into())); + JmapValue::Object(obj) + } + TracingStore::FoundationDb(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("FoundationDb".into())); + obj + } + TracingStore::PostgreSql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("PostgreSql".into())); + obj + } + TracingStore::MySql(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("MySql".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for TracingStore { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + TracingStoreType::Disabled => *self = TracingStore::Disabled, + TracingStoreType::Default => *self = TracingStore::Default, + TracingStoreType::FoundationDb => { + *self = TracingStore::FoundationDb(Default::default()) + } + TracingStoreType::PostgreSql => { + *self = TracingStore::PostgreSql(Default::default()) + } + TracingStoreType::MySql => *self = TracingStore::MySql(Default::default()), + } + } + match self { + TracingStore::Disabled => pointer.assert_eof(), + TracingStore::Default => pointer.assert_eof(), + TracingStore::FoundationDb(inner) => inner.patch(pointer, value), + TracingStore::PostgreSql(inner) => inner.patch(pointer, value), + TracingStore::MySql(inner) => inner.patch(pointer, value), + } + } +} + +impl TracingStore { + pub fn object_type(&self) -> TracingStoreType { + match self { + TracingStore::Disabled => TracingStoreType::Disabled, + TracingStore::Default => TracingStoreType::Default, + TracingStore::FoundationDb(_) => TracingStoreType::FoundationDb, + TracingStore::PostgreSql(_) => TracingStoreType::PostgreSql, + TracingStore::MySql(_) => TracingStoreType::MySql, + } + } +} + +impl UserAccount { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + let value = &self.domain_id; + if !value.is_valid() { + errors.push(ValidationError::required(Property::DomainId)); + } + let value = &self.credentials; + for value in value.values() { + value.validate(errors); + } + let value = &self.created_at; + if !value.is_valid() { + errors.push(ValidationError::invalid(Property::CreatedAt, value)); + } + let value = &self.member_group_ids; + for value in value.iter() { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberGroupIds)); + } + } + if let Some(value) = &self.member_tenant_id { + if !value.is_valid() { + errors.push(ValidationError::required(Property::MemberTenantId)); + } + } + let value = &self.roles; + value.validate(errors); + let value = &self.permissions; + value.validate(errors); + let value = &self.aliases; + for value in value.values() { + value.validate(errors); + } + if let Some(value) = &self.external_id { + if value.is_empty() { + errors.push(ValidationError::required(Property::ExternalId)); + } + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + let value = &self.encryption_at_rest; + value.validate(errors); + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique_global_composite(Property::Email, &self.name, &self.domain_id); + i.text(Property::Text, &self.name); + i.search(Property::Name, &self.name); + i.foreign_key(ObjectType::Domain, self.domain_id.into(), None); + i.search(Property::DomainId, &self.domain_id); + for id in self.member_group_ids.iter() { + i.foreign_key( + ObjectType::Account, + Some(*id), + Some(AccountType::Group.to_id()), + ); + } + for value in self.member_group_ids.iter() { + i.search(Property::MemberGroupIds, value); + } + i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None); + if let Some(value) = &self.member_tenant_id { + i.search(Property::MemberTenantId, value); + } + self.roles.index(i); + for item in self.aliases.values() { + item.index(i); + } + if let Some(value) = &self.external_id { + i.search(Property::ExternalId, value); + } + if let Some(value) = &self.description { + i.text(Property::Text, value); + } + self.encryption_at_rest.index(i); + } +} + +impl Pickle for UserAccount { + fn pickle(&self, out: &mut Vec) { + self.name.pickle(out); + self.domain_id.pickle(out); + self.credentials.pickle(out); + self.created_at.pickle(out); + self.member_group_ids.pickle(out); + self.member_tenant_id.pickle(out); + self.roles.pickle(out); + self.permissions.pickle(out); + self.quotas.pickle(out); + self.aliases.pickle(out); + self.external_id.pickle(out); + self.description.pickle(out); + self.locale.pickle(out); + self.time_zone.pickle(out); + self.encryption_at_rest.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.name = Pickle::unpickle(stream)?; + this.domain_id = Pickle::unpickle(stream)?; + this.credentials = Pickle::unpickle(stream)?; + this.created_at = Pickle::unpickle(stream)?; + this.member_group_ids = Pickle::unpickle(stream)?; + this.member_tenant_id = Pickle::unpickle(stream)?; + this.roles = Pickle::unpickle(stream)?; + this.permissions = Pickle::unpickle(stream)?; + this.quotas = Pickle::unpickle(stream)?; + this.aliases = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.external_id = Pickle::unpickle(stream)?; + } + this.description = Pickle::unpickle(stream)?; + this.locale = Pickle::unpickle(stream)?; + this.time_zone = Pickle::unpickle(stream)?; + this.encryption_at_rest = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for UserAccount { + fn default() -> Self { + Self { + name: Default::default(), + domain_id: Default::default(), + credentials: Default::default(), + created_at: Default::default(), + member_group_ids: Default::default(), + member_tenant_id: Default::default(), + roles: Default::default(), + permissions: Default::default(), + quotas: Default::default(), + aliases: Default::default(), + external_id: Default::default(), + description: Default::default(), + locale: Locale::EnUS, + time_zone: Default::default(), + encryption_at_rest: Default::default(), + } + } +} + +impl IntoValue for UserAccount { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(17); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::DomainId, self.domain_id.into_value()); + map.insert_unchecked(Property::Credentials, self.credentials.into_value()); + map.insert_unchecked(Property::CreatedAt, self.created_at.into_value()); + map.insert_unchecked(Property::MemberGroupIds, self.member_group_ids.into_value()); + map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value()); + map.insert_unchecked(Property::Roles, self.roles.into_value()); + map.insert_unchecked(Property::Permissions, self.permissions.into_value()); + map.insert_unchecked(Property::Quotas, self.quotas.into_value()); + map.insert_unchecked(Property::Aliases, self.aliases.into_value()); + map.insert_unchecked(Property::ExternalId, self.external_id.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + map.insert_unchecked(Property::Locale, self.locale.into_value()); + map.insert_unchecked(Property::TimeZone, self.time_zone.into_value()); + map.insert_unchecked( + Property::EncryptionAtRest, + self.encryption_at_rest.into_value(), + ); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for UserAccount { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Name) => self.name.patch( + pointer.with_validators(&[StringValidator::EmailLocalPart]), + value, + ), + Some(Property::DomainId) => self.domain_id.patch(pointer, value), + Some(Property::EmailAddress) => pointer.assert_server_set(), + Some(Property::Credentials) => self.credentials.patch(pointer, value), + Some(Property::CreatedAt) => pointer.assert_server_set(), + Some(Property::MemberGroupIds) => self.member_group_ids.patch(pointer, value), + Some(Property::MemberTenantId) => self + .member_tenant_id + .patch(pointer.assert_can_set_tenant()?, value), + Some(Property::Roles) => self.roles.patch(pointer, value), + Some(Property::Permissions) => self.permissions.patch(pointer, value), + Some(Property::Quotas) => self.quotas.patch(pointer, value), + Some(Property::UsedDiskQuota) => pointer.assert_server_set(), + Some(Property::Aliases) => self.aliases.patch(pointer, value), + Some(Property::ExternalId) => self.external_id.patch(pointer, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Locale) => self.locale.patch(pointer, value), + Some(Property::TimeZone) => self.time_zone.patch(pointer, value), + Some(Property::EncryptionAtRest) => self.encryption_at_rest.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl UserRoles { + fn validate(&self, errors: &mut Vec) -> bool { + match self { + UserRoles::User => true, + UserRoles::Admin => true, + UserRoles::Custom(inner) => inner.validate(errors), + } + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + match self { + UserRoles::User => {} + UserRoles::Admin => {} + UserRoles::Custom(object) => { + object.index(i); + } + } + } +} + +impl Default for UserRoles { + fn default() -> Self { + UserRoles::User + } +} + +impl Pickle for UserRoles { + fn pickle(&self, out: &mut Vec) { + match self { + UserRoles::User => { + 0u16.pickle(out); + } + UserRoles::Admin => { + 1u16.pickle(out); + } + UserRoles::Custom(inner) => { + 2u16.pickle(out); + inner.pickle(out); + } + } + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + match u16::unpickle(stream)? { + 0 => Some(UserRoles::User), + 1 => Some(UserRoles::Admin), + 2 => Pickle::unpickle(stream).map(UserRoles::Custom), + _ => None, + } + } +} + +impl IntoValue for UserRoles { + fn into_value(self) -> JmapValue<'static> { + match self { + UserRoles::User => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("User".into())); + JmapValue::Object(obj) + } + UserRoles::Admin => { + let mut obj = jmap_tools::Map::new(); + obj.insert_unchecked(Property::Type, JmapValue::Str("Admin".into())); + JmapValue::Object(obj) + } + UserRoles::Custom(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("Custom".into())); + obj + } + } + } +} + +impl RegistryJsonPatch for UserRoles { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if !pointer.has_next() { + match object_type(&pointer, &value)? { + UserRolesType::User => *self = UserRoles::User, + UserRolesType::Admin => *self = UserRoles::Admin, + UserRolesType::Custom => *self = UserRoles::Custom(Default::default()), + } + } + match self { + UserRoles::User => pointer.assert_eof(), + UserRoles::Admin => pointer.assert_eof(), + UserRoles::Custom(inner) => inner.patch(pointer, value), + } + } +} + +impl UserRoles { + pub fn object_type(&self) -> UserRolesType { + match self { + UserRoles::User => UserRolesType::User, + UserRoles::Admin => UserRolesType::Admin, + UserRoles::Custom(_) => UserRolesType::Custom, + } + } +} + +impl ObjectImpl for WebDav { + const FLAGS: u64 = OBJ_SINGLETON; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::WebDav; + + fn validate(&self, _: &mut Vec) -> bool { + true + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for WebDav { + fn pickle(&self, out: &mut Vec) { + self.enable_assisted_discovery.pickle(out); + self.max_lock_timeout.pickle(out); + self.max_locks.pickle(out); + self.dead_property_max_size.pickle(out); + self.live_property_max_size.pickle(out); + self.request_max_size.pickle(out); + self.max_results.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.enable_assisted_discovery = Pickle::unpickle(stream)?; + this.max_lock_timeout = Pickle::unpickle(stream)?; + this.max_locks = Pickle::unpickle(stream)?; + this.dead_property_max_size = Pickle::unpickle(stream)?; + this.live_property_max_size = Pickle::unpickle(stream)?; + this.request_max_size = Pickle::unpickle(stream)?; + this.max_results = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for WebDav { + fn default() -> Self { + Self { + enable_assisted_discovery: true, + max_lock_timeout: Duration::from_millis(3600000), + max_locks: 10u64, + dead_property_max_size: Some(1024u64), + live_property_max_size: 250u64, + request_max_size: 26214400, + max_results: 2000u64, + } + } +} + +impl IntoValue for WebDav { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(9); + map.insert_unchecked( + Property::EnableAssistedDiscovery, + self.enable_assisted_discovery.into_value(), + ); + map.insert_unchecked(Property::MaxLockTimeout, self.max_lock_timeout.into_value()); + map.insert_unchecked(Property::MaxLocks, self.max_locks.into_value()); + map.insert_unchecked( + Property::DeadPropertyMaxSize, + self.dead_property_max_size.into_value(), + ); + map.insert_unchecked( + Property::LivePropertyMaxSize, + self.live_property_max_size.into_value(), + ); + map.insert_unchecked(Property::RequestMaxSize, self.request_max_size.into_value()); + map.insert_unchecked(Property::MaxResults, self.max_results.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for WebDav { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::EnableAssistedDiscovery) => { + self.enable_assisted_discovery.patch(pointer, value) + } + Some(Property::MaxLockTimeout) => self.max_lock_timeout.patch(pointer, value), + Some(Property::MaxLocks) => self.max_locks.patch(pointer, value), + Some(Property::DeadPropertyMaxSize) => { + self.dead_property_max_size.patch(pointer, value) + } + Some(Property::LivePropertyMaxSize) => { + self.live_property_max_size.patch(pointer, value) + } + Some(Property::RequestMaxSize) => self.request_max_size.patch(pointer, value), + Some(Property::MaxResults) => self.max_results.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ObjectImpl for WebHook { + const FLAGS: u64 = 0; + const VERSION: u8 = 0; + const OBJECT: ObjectType = ObjectType::WebHook; + + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.signature_key; + value.validate(errors); + let value = &self.url; + if value.is_empty() { + errors.push(ValidationError::required(Property::Url)); + } + let value = &self.http_auth; + value.validate(errors); + let value = &self.http_headers; + for value in value.values() { + if value.is_empty() { + errors.push(ValidationError::required(Property::HttpHeaders)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, _: &mut IndexBuilder<'x>) {} +} + +impl Pickle for WebHook { + fn pickle(&self, out: &mut Vec) { + self.allow_invalid_certs.pickle(out); + self.signature_key.pickle(out); + self.throttle.pickle(out); + self.timeout.pickle(out); + self.discard_after.pickle(out); + self.url.pickle(out); + self.http_auth.pickle(out); + self.http_headers.pickle(out); + self.enable.pickle(out); + self.level.pickle(out); + self.lossy.pickle(out); + self.events.pickle(out); + self.events_policy.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.allow_invalid_certs = Pickle::unpickle(stream)?; + this.signature_key = Pickle::unpickle(stream)?; + this.throttle = Pickle::unpickle(stream)?; + this.timeout = Pickle::unpickle(stream)?; + this.discard_after = Pickle::unpickle(stream)?; + this.url = Pickle::unpickle(stream)?; + this.http_auth = Pickle::unpickle(stream)?; + this.http_headers = Pickle::unpickle(stream)?; + this.enable = Pickle::unpickle(stream)?; + this.level = Pickle::unpickle(stream)?; + this.lossy = Pickle::unpickle(stream)?; + this.events = Pickle::unpickle(stream)?; + this.events_policy = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for WebHook { + fn default() -> Self { + Self { + allow_invalid_certs: false, + signature_key: Default::default(), + throttle: Duration::from_millis(1000), + timeout: Duration::from_millis(30000), + discard_after: Duration::from_millis(300000), + url: Default::default(), + http_auth: Default::default(), + http_headers: Default::default(), + enable: true, + level: TracingLevel::Info, + lossy: false, + events: Default::default(), + events_policy: EventPolicy::Exclude, + } + } +} + +impl IntoValue for WebHook { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(15); + map.insert_unchecked( + Property::AllowInvalidCerts, + self.allow_invalid_certs.into_value(), + ); + map.insert_unchecked(Property::SignatureKey, self.signature_key.into_value()); + map.insert_unchecked(Property::Throttle, self.throttle.into_value()); + map.insert_unchecked(Property::Timeout, self.timeout.into_value()); + map.insert_unchecked(Property::DiscardAfter, self.discard_after.into_value()); + map.insert_unchecked(Property::Url, self.url.into_value()); + map.insert_unchecked(Property::HttpAuth, self.http_auth.into_value()); + map.insert_unchecked(Property::HttpHeaders, self.http_headers.into_value()); + map.insert_unchecked(Property::Enable, self.enable.into_value()); + map.insert_unchecked(Property::Level, self.level.into_value()); + map.insert_unchecked(Property::Lossy, self.lossy.into_value()); + map.insert_unchecked(Property::Events, self.events.into_value()); + map.insert_unchecked(Property::EventsPolicy, self.events_policy.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for WebHook { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::AllowInvalidCerts) => self.allow_invalid_certs.patch(pointer, value), + Some(Property::SignatureKey) => self.signature_key.patch(pointer, value), + Some(Property::Throttle) => self.throttle.patch(pointer, value), + Some(Property::Timeout) => self.timeout.patch(pointer, value), + Some(Property::DiscardAfter) => self.discard_after.patch(pointer, value), + Some(Property::Url) => self + .url + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::HttpAuth) => self.http_auth.patch(pointer, value), + Some(Property::HttpHeaders) => self + .http_headers + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Enable) => self.enable.patch(pointer, value), + Some(Property::Level) => self.level.patch(pointer, value), + Some(Property::Lossy) => self.lossy.patch(pointer, value), + Some(Property::Events) => self.events.patch(pointer, value), + Some(Property::EventsPolicy) => self.events_policy.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + +impl ZenohCoordinator { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.config; + if value.is_empty() { + errors.push(ValidationError::required(Property::Config)); + } + errors.len() == neb + } +} + +impl Pickle for ZenohCoordinator { + fn pickle(&self, out: &mut Vec) { + self.config.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.config = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for ZenohCoordinator { + fn default() -> Self { + Self { + config: Default::default(), + } + } +} + +impl IntoValue for ZenohCoordinator { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(3); + map.insert_unchecked(Property::Config, self.config.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for ZenohCoordinator { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Config) => self.config.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} diff --git a/crates/registry/src/types/datetime.rs b/crates/registry/src/types/datetime.rs new file mode 100644 index 0000000..5df9113 --- /dev/null +++ b/crates/registry/src/types/datetime.rs @@ -0,0 +1,322 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, str::FromStr, time::SystemTime}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct UTCDateTime(i64); + +struct DateTime { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub tz_before_gmt: bool, + pub tz_hour: u8, + pub tz_minute: u8, +} + +impl FromStr for UTCDateTime { + type Err = (); + + fn from_str(s: &str) -> Result { + // 2004 - 06 - 28 T 23 : 43 : 45 . 000 Z + // 1969 - 02 - 13 T 23 : 32 : 00 - 03 : 30 + // 0 1 2 3 4 5 6 7 + + let mut pos = 0; + let mut parts = [0u32; 8]; + let mut parts_sizes = [ + 4u32, // Year (0) + 2u32, // Month (1) + 2u32, // Day (2) + 2u32, // Hour (3) + 2u32, // Minute (4) + 2u32, // Second (5) + 2u32, // TZ Hour (6) + 2u32, // TZ Minute (7) + ]; + let mut skip_digits = false; + let mut is_plus = true; + + for ch in s.as_bytes() { + match ch { + b'0'..=b'9' => { + if !skip_digits { + if parts_sizes[pos] > 0 { + parts_sizes[pos] -= 1; + parts[pos] += (ch - b'0') as u32 * u32::pow(10, parts_sizes[pos]); + } else { + break; + } + } + } + b'-' => { + if pos <= 1 { + pos += 1; + } else if pos == 5 { + pos += 1; + is_plus = false; + skip_digits = false; + } else { + break; + } + } + b'T' if pos == 2 => { + pos += 1; + } + b':' if [3, 4, 6].contains(&pos) => { + pos += 1; + } + b'+' if pos == 5 => { + pos += 1; + skip_digits = false; + } + b'.' if pos == 5 => { + skip_digits = true; + } + b'Z' | b'z' => (), + _ => { + break; + } + } + } + + let dt = DateTime { + year: parts[0] as u16, + month: parts[1] as u8, + day: parts[2] as u8, + hour: parts[3] as u8, + minute: parts[4] as u8, + second: parts[5] as u8, + tz_hour: parts[6] as u8, + tz_minute: parts[7] as u8, + tz_before_gmt: !is_plus, + }; + + if pos >= 5 && dt.is_valid() { + Ok(UTCDateTime(dt.timestamp())) + } else { + Err(()) + } + } +} + +impl UTCDateTime { + pub fn now() -> Self { + UTCDateTime( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64, + ) + } + + pub fn from_timestamp(timestamp: i64) -> Self { + UTCDateTime(timestamp) + } + + #[inline(always)] + pub fn timestamp(&self) -> i64 { + self.0 + } + + #[inline(always)] + pub fn is_valid(&self) -> bool { + self.0 != i64::MAX + } + + pub fn add_seconds(&mut self, seconds: i64) { + if self.is_valid() { + self.0 += seconds; + } + } +} + +impl DateTime { + pub fn from_timestamp(timestamp: i64) -> Self { + // Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days + let (z, seconds) = ((timestamp / 86400) + 719468, timestamp % 86400); + let era: i64 = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe: u64 = (z - era * 146097) as u64; // [0, 146096] + let yoe: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + let y: i64 = (yoe as i64) + era * 400; + let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d: u64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let (h, mn, s) = (seconds / 3600, (seconds / 60) % 60, seconds % 60); + + DateTime { + year: (y + i64::from(m <= 2)) as u16, + month: m as u8, + day: d as u8, + hour: h as u8, + minute: mn as u8, + second: s as u8, + tz_before_gmt: false, + tz_hour: 0, + tz_minute: 0, + } + } + + #[inline(always)] + pub fn is_valid(&self) -> bool { + (0..=23).contains(&self.tz_hour) + && (1970..=3000).contains(&self.year) + && (0..=59).contains(&self.tz_minute) + && (1..=12).contains(&self.month) + && (1..=31).contains(&self.day) + && (0..=23).contains(&self.hour) + && (0..=59).contains(&self.minute) + && (0..=59).contains(&self.second) + } + + pub fn timestamp(&self) -> i64 { + // Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992 + let month = self.month as u32; + let year_base = 4800; /* Before min year, multiple of 400. */ + let m_adj = month.wrapping_sub(3); /* March-based month. */ + let carry = i64::from(m_adj > month); + let adjust = if carry > 0 { 12 } else { 0 }; + let y_adj = self.year as i64 + year_base - carry; + let month_days = ((m_adj.wrapping_add(adjust)) * 62719 + 769) / 2048; + let leap_days = y_adj / 4 - y_adj / 100 + y_adj / 400; + (y_adj * 365 + leap_days + month_days as i64 + (self.day as i64 - 1) - 2472632) * 86400 + + self.hour as i64 * 3600 + + self.minute as i64 * 60 + + self.second as i64 + + ((self.tz_hour as i64 * 3600 + self.tz_minute as i64 * 60) + * if self.tz_before_gmt { 1 } else { -1 }) + } +} + +impl Display for UTCDateTime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let dt = DateTime::from_timestamp(self.0); + + write!( + f, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, + ) + } +} + +impl Default for UTCDateTime { + fn default() -> Self { + UTCDateTime::now() + } +} + +impl serde::Serialize for UTCDateTime { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for UTCDateTime { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + UTCDateTime::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid DateTime")) + } +} + +impl Pickle for UTCDateTime { + fn pickle(&self, out: &mut Vec) { + out.extend_from_slice(&self.0.to_be_bytes()); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + let mut arr = [0u8; 8]; + arr.copy_from_slice(data.read_bytes(8)?); + Some(UTCDateTime(i64::from_be_bytes(arr))) + } +} + +impl From for UTCDateTime { + fn from(value: u64) -> Self { + UTCDateTime(value as i64) + } +} + +impl RegistryJsonPatch for UTCDateTime { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = UTCDateTime::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new( + pointer, + "Failed to parse UTCDateTime from string", + )) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for UTCDateTime, expected a string value", + )), + } + } +} + +impl IntoValue for UTCDateTime { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.to_string().into()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::types::datetime::UTCDateTime; + + #[test] + fn parse_jmap_date() { + for (input, _) in [ + ("1997-11-21T09:55:06-06:00", "1997-11-21T09:55:06-06:00"), + ("1997-11-21T09:55:06+00:00", "1997-11-21T09:55:06Z"), + ("2021-01-01T09:55:06+02:00", "2021-01-01T09:55:06+02:00"), + ("2004-06-28T23:43:45.000Z", "2004-06-28T23:43:45Z"), + ("1997-11-21T09:55:06.123+00:00", "1997-11-21T09:55:06Z"), + ( + "2021-01-01T09:55:06.4567+02:00", + "2021-01-01T09:55:06+02:00", + ), + ] { + let date = UTCDateTime::from_str(input).unwrap(); + //assert_eq!(date.to_string(), expected_result); + + let timestamp = date.timestamp(); + assert_eq!( + UTCDateTime::from_timestamp(timestamp).timestamp(), + timestamp + ); + } + } +} diff --git a/crates/registry/src/types/duration.rs b/crates/registry/src/types/duration.rs new file mode 100644 index 0000000..034d7b4 --- /dev/null +++ b/crates/registry/src/types/duration.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Duration(pub std::time::Duration); + +impl Duration { + pub fn from_millis(millis: u64) -> Self { + Duration(std::time::Duration::from_millis(millis)) + } + + pub fn into_inner(self) -> std::time::Duration { + self.0 + } + + pub fn is_valid(&self) -> bool { + self.0.as_millis() > 0 + } + + #[inline(always)] + pub fn as_secs(&self) -> u64 { + self.0.as_secs() + } + + #[inline(always)] + pub fn as_millis(&self) -> u64 { + self.0.as_millis() as u64 + } +} + +impl Default for Duration { + fn default() -> Self { + Duration(std::time::Duration::from_millis(0)) + } +} + +impl Display for Duration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.as_millis()) + } +} + +impl serde::Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u64(self.0.as_millis() as u64) + } +} + +impl<'de> serde::Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ::deserialize(deserializer) + .map(std::time::Duration::from_millis) + .map(Duration) + .map_err(|_| serde::de::Error::custom("invalid Duration")) + } +} + +impl AsRef for Duration { + fn as_ref(&self) -> &std::time::Duration { + &self.0 + } +} + +impl PartialOrd for Duration { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Duration { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl FromStr for Duration { + type Err = String; + + fn from_str(value: &str) -> Result { + let mut digits = String::new(); + let mut multiplier = String::new(); + + for ch in value.chars() { + if ch.is_ascii_digit() { + if !multiplier.is_empty() { + return Err(format!("Invalid duration value {:?}.", value)); + } + + digits.push(ch); + } else if !ch.is_ascii_whitespace() { + multiplier.push(ch.to_ascii_lowercase()); + } + } + + let multiplier = match multiplier.as_str() { + "d" => 24 * 60 * 60 * 1000, + "h" => 60 * 60 * 1000, + "m" => 60 * 1000, + "s" => 1000, + "ms" | "" => 1, + _ => return Err(format!("Invalid duration value {:?}.", value)), + }; + + digits + .parse::() + .ok() + .and_then(|num| num.checked_mul(multiplier)) + .map(std::time::Duration::from_millis) + .map(Duration) + .ok_or_else(|| format!("Invalid duration value {:?}.", value)) + } +} + +impl Pickle for Duration { + fn pickle(&self, out: &mut Vec) { + (self.0.as_millis() as u64).pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + u64::unpickle(data).map(|timestamp| Duration(std::time::Duration::from_millis(timestamp))) + } +} + +impl RegistryJsonPatch for Duration { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Number(value), None) => { + if let Some(new_value) = value.as_u64().filter(|v| *v > 0) { + *self = Duration::from_millis(new_value); + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new(pointer, "Invalid duration value")) + } + } + _ => Err(PatchError::new(pointer, "Invalid path for Duration")), + } + } +} + +impl IntoValue for Duration { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Number((self.0.as_millis() as u64).into()) + } +} + +impl From for Duration { + fn from(value: std::time::Duration) -> Self { + Duration(value) + } +} + +impl From for Duration { + fn from(value: u64) -> Self { + Duration(std::time::Duration::from_millis(value)) + } +} diff --git a/crates/registry/src/types/error.rs b/crates/registry/src/types/error.rs new file mode 100644 index 0000000..483426e --- /dev/null +++ b/crates/registry/src/types/error.rs @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::JsonPointerPatch, + schema::prelude::Property, + types::{EnumImpl, id::ObjectId}, +}; +use std::{borrow::Cow, fmt::Display}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum ValidationError { + Invalid { property: Property, value: String }, + Required { property: Property }, + MaxLength { property: Property, required: usize }, + MinLength { property: Property, required: usize }, + MaxValue { property: Property, required: i64 }, + MinValue { property: Property, required: i64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + Validation { + object_id: ObjectId, + errors: Vec, + }, + Build { + object_id: ObjectId, + message: String, + }, + Internal { + object_id: Option, + error: trc::Error, + }, + NotFound { + object_id: ObjectId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchError { + pub path: String, + pub message: Cow<'static, str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Warning { + pub object_id: ObjectId, + pub property: Option, + pub message: String, +} + +impl ValidationError { + pub fn required(property: Property) -> Self { + Self::Required { property } + } + + pub fn invalid(property: Property, value: impl Display) -> Self { + Self::Invalid { + property, + value: value.to_string(), + } + } + + pub fn min_items(property: Property, required: usize) -> Self { + Self::MinLength { property, required } + } + + pub fn max_items(property: Property, required: usize) -> Self { + Self::MaxLength { property, required } + } + + pub fn max_length(property: Property, required: usize) -> Self { + Self::MaxLength { property, required } + } + + pub fn min_length(property: Property, required: usize) -> Self { + Self::MinLength { property, required } + } + + pub fn max_value(property: Property, required: i64) -> Self { + Self::MaxValue { property, required } + } + + pub fn min_value(property: Property, required: i64) -> Self { + Self::MinValue { property, required } + } +} + +impl Warning { + pub fn new(object_id: ObjectId, message: impl Display) -> Self { + Self { + object_id, + property: None, + message: message.to_string(), + } + } + + pub fn for_property(object_id: ObjectId, property: Property, message: impl Display) -> Self { + Self { + object_id, + property: Some(property), + message: message.to_string(), + } + } + + pub fn log(&self) { + trc::event!( + Registry(trc::RegistryEvent::BuildWarning), + Source = self.object_id.object().as_str(), + Id = self.object_id.id().id(), + Key = self.property.map(|key| key.as_str()), + Reason = self.message.clone(), + ); + } +} + +impl Error { + pub fn log(&self) { + match self { + Error::Validation { object_id, errors } => { + trc::event!( + Registry(trc::RegistryEvent::ValidationError), + Source = object_id.object().as_str(), + Id = object_id.id().id(), + Reason = errors + .iter() + .map(|err| trc::Value::from(err.to_string())) + .collect::>(), + ); + } + Error::Build { object_id, message } => { + trc::event!( + Registry(trc::RegistryEvent::BuildError), + Source = object_id.object().as_str(), + Id = object_id.id().id(), + Reason = message.clone(), + ); + } + Error::Internal { object_id, error } => { + trc::event!( + Registry(trc::RegistryEvent::ReadError), + Source = object_id.as_ref().map(|id| id.object().as_str()), + Id = object_id.as_ref().map(|id| id.id().id()), + CausedBy = error.clone(), + ); + } + Error::NotFound { object_id } => { + trc::event!( + Registry(trc::RegistryEvent::BuildError), + Source = object_id.object().as_str(), + Id = object_id.id().id(), + Reason = "Object not found", + ); + } + } + } +} + +impl Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::Invalid { property, value } => { + write!(f, "Invalid value '{}' for property '{}'", value, property) + } + ValidationError::Required { property } => { + write!(f, "Property '{}' is required", property) + } + ValidationError::MaxLength { property, required } => { + write!( + f, + "Property '{}' exceeds maximum length of {}", + property, required + ) + } + ValidationError::MinLength { property, required } => { + write!( + f, + "Property '{}' is below minimum length of {}", + property, required + ) + } + ValidationError::MaxValue { property, required } => { + write!( + f, + "Property '{}' exceeds maximum value of {}", + property, required + ) + } + ValidationError::MinValue { property, required } => { + write!( + f, + "Property '{}' is below minimum value of {}", + property, required + ) + } + } + } +} + +impl PatchError { + pub fn new(path: JsonPointerPatch<'_>, message: impl Into>) -> Self { + Self { + path: path.path(), + message: message.into(), + } + } +} diff --git a/crates/registry/src/types/float.rs b/crates/registry/src/types/float.rs new file mode 100644 index 0000000..efa4cdf --- /dev/null +++ b/crates/registry/src/types/float.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{IntoValue, JmapValue, JsonPointerPatch, PatchResult, RegistryJsonPatch}, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(transparent)] +pub struct Float(f64); + +impl Eq for Float {} + +impl PartialOrd for Float { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Float { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.partial_cmp(&other.0).unwrap_or_else(|| { + if self.0.is_nan() && other.0.is_nan() { + std::cmp::Ordering::Equal + } else if self.0.is_nan() { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Less + } + }) + } +} + +impl Float { + pub fn new(value: f64) -> Self { + Float(value) + } + + pub fn into_inner(self) -> f64 { + self.0 + } + + pub fn is_valid(&self) -> bool { + !self.0.is_nan() && self.0.is_finite() + } +} + +impl FromStr for Float { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::().map(Float).map_err(|err| err.to_string()) + } +} + +impl Display for Float { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl serde::Serialize for Float { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_f64(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Float { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + f64::deserialize(deserializer) + .map(Float::new) + .map_err(|_| serde::de::Error::custom("invalid Float")) + } +} + +impl AsRef for Float { + fn as_ref(&self) -> &f64 { + &self.0 + } +} + +impl Default for Float { + fn default() -> Self { + Float(f64::NAN) + } +} + +impl From for Float { + fn from(value: f64) -> Self { + Float(value) + } +} + +impl Pickle for Float { + fn pickle(&self, out: &mut Vec) { + self.0.to_bits().pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + u64::unpickle(data).map(|bits| Float(f64::from_bits(bits))) + } +} + +impl RegistryJsonPatch for Float { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_f64().filter(|v| v.is_finite() && !v.is_nan()) { + *self = Float(new_value); + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + "Invalid value for float property (expected finite number)", + )) + } + } +} + +impl IntoValue for Float { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Number(self.0.into()) + } +} diff --git a/crates/registry/src/types/id.rs b/crates/registry/src/types/id.rs new file mode 100644 index 0000000..57057cb --- /dev/null +++ b/crates/registry/src/types/id.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + RegistryValue, + }, + pickle::{Pickle, PickledStream}, + schema::prelude::ObjectType, + types::{EnumImpl, error::PatchError}, +}; +use std::{fmt::Display, str::FromStr}; +use types::{ + blob::{BlobClass, BlobId}, + blob_hash::{BLOB_HASH_LEN, BlobHash}, + id::Id, +}; + +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ObjectId { + object: ObjectType, + id: Id, +} + +impl ObjectId { + pub fn new(object: ObjectType, id: Id) -> Self { + Self { object, id } + } + + #[inline(always)] + pub fn id(&self) -> Id { + self.id + } + + #[inline(always)] + pub fn object(&self) -> ObjectType { + self.object + } + + #[inline(always)] + pub fn is_valid(&self) -> bool { + self.id.is_valid() + } +} + +impl Display for ObjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} with id {}", self.object.as_str(), self.id) + } +} + +impl ObjectType { + pub fn id(&self, id: Id) -> ObjectId { + ObjectId::new(*self, id) + } + + pub fn singleton(&self) -> ObjectId { + ObjectId::new(*self, Id::singleton()) + } +} + +impl Default for ObjectId { + fn default() -> Self { + ObjectId::new(ObjectType::Account, Id::default()) + } +} + +impl Pickle for Id { + fn pickle(&self, out: &mut Vec) { + self.id().pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + u64::unpickle(data).map(Id::new) + } +} + +impl Pickle for BlobId { + fn pickle(&self, out: &mut Vec) { + out.extend_from_slice(self.hash.as_slice()); + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + stream.read_bytes(BLOB_HASH_LEN).map(|bytes| { + BlobId::new( + BlobHash::try_from_hash_slice(bytes).unwrap(), + BlobClass::default(), + ) + }) + } +} + +impl RegistryJsonPatch for Id { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Element(RegistryValue::Id(value)), None) => { + *self = value; + Ok(MaybeUnpatched::Patched) + } + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = Id::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new(pointer, "Failed to parse Id from string")) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for Id, expected a string value", + )), + } + } +} + +impl RegistryJsonPatch for BlobId { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Element(RegistryValue::BlobId(value)), None) => { + *self = value; + Ok(MaybeUnpatched::Patched) + } + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = BlobId::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new( + pointer, + "Failed to parse BlobId from string", + )) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for BlobId, expected a string value", + )), + } + } +} + +impl IntoValue for Id { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Element(RegistryValue::Id(self)) + } +} + +impl IntoValue for BlobId { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Element(RegistryValue::BlobId(self)) + } +} + +impl Ord for ObjectId { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.object.to_id().cmp(&other.object.to_id()) { + std::cmp::Ordering::Equal => self.id.cmp(&other.id), + ord => ord, + } + } +} + +impl PartialOrd for ObjectId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} diff --git a/crates/registry/src/types/index.rs b/crates/registry/src/types/index.rs new file mode 100644 index 0000000..a179232 --- /dev/null +++ b/crates/registry/src/types/index.rs @@ -0,0 +1,234 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + schema::prelude::{ObjectType, Property}, + types::{id::ObjectId, ipmask::IpAddrOrMask}, +}; +use ahash::AHashSet; +use std::borrow::Cow; +use types::id::Id; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum IndexKey<'x> { + Unique { + property: Property, + value_1: IndexValue<'x>, + value_2: IndexValue<'x>, + global: bool, + }, + Search { + property: Property, + value: IndexValue<'x>, + }, + ForeignKey { + object_id: ObjectId, + type_filter: IndexValue<'x>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObjectFilter<'x> { + pub property: Property, + pub value: IndexValue<'x>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum IndexValue<'x> { + Text(Cow<'x, str>), + Bytes(Vec), + U64(u64), + I64(i64), + U16(u16), + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct IndexSchema { + pub prop: Property, + pub typ: IndexSchemaType, + pub value: IndexSchemaValueType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum IndexSchemaType { + Unique, + Search, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum IndexSchemaValueType { + Keyword, + Text, + Number, + Enum, + Boolean, + Id, + IpMask, +} + +#[derive(Debug, Default)] + +pub struct IndexBuilder<'x> { + pub keys: AHashSet>, +} + +impl<'x> IndexBuilder<'x> { + pub fn typ(&mut self, typ: u16) { + self.keys.insert(IndexKey::Search { + property: Property::Type, + value: IndexValue::U16(typ), + }); + } + + pub fn unique(&mut self, property: Property, value: impl Into>) { + self.keys.insert(IndexKey::Unique { + property, + value_1: value.into(), + value_2: IndexValue::None, + global: false, + }); + } + + pub fn search(&mut self, property: Property, value: impl Into>) { + let value = value.into(); + if value != IndexValue::None { + self.keys.insert(IndexKey::Search { property, value }); + } + } + + pub fn text(&mut self, property: Property, value: &'x str) { + for word in value + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| s.len() > 1) + { + if word + .chars() + .all(|ch| ch.is_lowercase() || !ch.is_alphabetic()) + { + self.keys.insert(IndexKey::Search { + property, + value: IndexValue::Text(Cow::Borrowed(word)), + }); + } else { + self.keys.insert(IndexKey::Search { + property, + value: IndexValue::Text(Cow::Owned(word.to_lowercase())), + }); + } + } + } + + pub fn unique_global(&mut self, property: Property, value: impl Into>) { + self.keys.insert(IndexKey::Unique { + property, + value_1: value.into(), + value_2: IndexValue::None, + global: true, + }); + } + + pub fn unique_global_composite( + &mut self, + property: Property, + value: impl Into>, + composite: impl Into>, + ) { + self.keys.insert(IndexKey::Unique { + property, + value_1: value.into(), + value_2: composite.into(), + global: true, + }); + } + + pub fn foreign_key(&mut self, object: ObjectType, id: Option, type_filter: Option) { + if let Some(id) = id { + self.keys.insert(IndexKey::ForeignKey { + object_id: ObjectId::new(object, id), + type_filter: type_filter.map(IndexValue::U16).unwrap_or(IndexValue::None), + }); + } + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } +} + +impl IndexSchema { + pub const fn new(prop: Property, typ: IndexSchemaType, value: IndexSchemaValueType) -> Self { + Self { prop, typ, value } + } +} + +impl From for IndexValue<'_> { + fn from(value: u64) -> Self { + IndexValue::U64(value) + } +} + +impl From<&u64> for IndexValue<'_> { + fn from(value: &u64) -> Self { + IndexValue::U64(*value) + } +} + +impl From for IndexValue<'_> { + fn from(value: i64) -> Self { + IndexValue::I64(value) + } +} + +impl From<&i64> for IndexValue<'_> { + fn from(value: &i64) -> Self { + IndexValue::I64(*value) + } +} + +impl<'x> From<&'x IpAddrOrMask> for IndexValue<'x> { + fn from(value: &'x IpAddrOrMask) -> Self { + IndexValue::Bytes(value.to_index_key()) + } +} + +impl<'x> From<&'x trc::EventType> for IndexValue<'x> { + fn from(value: &'x trc::EventType) -> Self { + IndexValue::U16(value.to_id()) + } +} + +impl<'x> From<&'x str> for IndexValue<'x> { + fn from(value: &'x str) -> Self { + IndexValue::Text(value.into()) + } +} + +impl<'x> From<&'x String> for IndexValue<'x> { + fn from(value: &'x String) -> Self { + IndexValue::Text(Cow::Borrowed(value.as_str())) + } +} + +impl<'x> From<&'x Id> for IndexValue<'x> { + fn from(value: &'x Id) -> Self { + IndexValue::U64(value.id()) + } +} + +impl<'x, T> From<&'x Option> for IndexValue<'x> +where + IndexValue<'x>: std::convert::From<&'x T>, +{ + fn from(value: &'x Option) -> Self { + match value { + Some(id) => id.into(), + None => IndexValue::None, + } + } +} diff --git a/crates/registry/src/types/ipaddr.rs b/crates/registry/src/types/ipaddr.rs new file mode 100644 index 0000000..efbf1cf --- /dev/null +++ b/crates/registry/src/types/ipaddr.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, net::Ipv4Addr, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub struct IpAddr(pub std::net::IpAddr); + +impl IpAddr { + pub fn into_inner(self) -> std::net::IpAddr { + self.0 + } + + pub fn is_valid(&self) -> bool { + !matches!( + self.0, + std::net::IpAddr::V4(addr) if addr == Ipv4Addr::UNSPECIFIED + ) + } +} + +impl FromStr for IpAddr { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(IpAddr) + .map_err(|err| err.to_string()) + } +} + +impl Display for IpAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl serde::Serialize for IpAddr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for IpAddr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + IpAddr::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid IpAddr")) + } +} + +impl AsRef for IpAddr { + fn as_ref(&self) -> &std::net::IpAddr { + &self.0 + } +} + +impl Default for IpAddr { + fn default() -> Self { + IpAddr(std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED)) + } +} + +impl Pickle for std::net::IpAddr { + fn pickle(&self, out: &mut Vec) { + match self { + std::net::IpAddr::V4(addr) => { + out.push(4); + out.extend_from_slice(&addr.octets()); + } + std::net::IpAddr::V6(addr) => { + out.push(6); + out.extend_from_slice(&addr.octets()); + } + } + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + let kind = data.read()?; + match kind { + 4 => { + let mut arr = [0u8; 4]; + arr.copy_from_slice(data.read_bytes(4)?); + Some(std::net::IpAddr::V4(Ipv4Addr::from(arr))) + } + 6 => { + let mut arr = [0u8; 16]; + arr.copy_from_slice(data.read_bytes(16)?); + Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(arr))) + } + _ => None, + } + } +} + +impl Pickle for IpAddr { + fn pickle(&self, out: &mut Vec) { + self.0.pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + std::net::IpAddr::unpickle(data).map(IpAddr) + } +} + +impl RegistryJsonPatch for IpAddr { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = IpAddr::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new( + pointer, + "Failed to parse IpAddr from string", + )) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for IpAddr, expected a string value", + )), + } + } +} + +impl IntoValue for IpAddr { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.to_string().into()) + } +} diff --git a/crates/registry/src/types/ipmask.rs b/crates/registry/src/types/ipmask.rs new file mode 100644 index 0000000..68ff662 --- /dev/null +++ b/crates/registry/src/types/ipmask.rs @@ -0,0 +1,326 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{ + fmt::{Display, Formatter}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum IpAddrOrMask { + V4 { addr: Ipv4Addr, mask: u32 }, + V6 { addr: Ipv6Addr, mask: u128 }, +} + +impl IpAddrOrMask { + pub fn from_ip(ip: IpAddr) -> Self { + match ip { + IpAddr::V4(addr) => IpAddrOrMask::V4 { + addr, + mask: u32::MAX, + }, + IpAddr::V6(addr) => IpAddrOrMask::V6 { + addr, + mask: u128::MAX, + }, + } + } + + pub fn is_valid(&self) -> bool { + !matches!( + self, + IpAddrOrMask::V4 { addr, mask: _ } if addr == &Ipv4Addr::UNSPECIFIED + ) + } + + pub fn try_to_ip(&self) -> Option { + match self { + IpAddrOrMask::V4 { addr, mask } if *mask == u32::MAX => Some(IpAddr::V4(*addr)), + IpAddrOrMask::V6 { addr, mask } if *mask == u128::MAX => Some(IpAddr::V6(*addr)), + _ => None, + } + } + + pub fn into_inner(self) -> (IpAddr, u128) { + match self { + IpAddrOrMask::V4 { addr, mask } => (IpAddr::V4(addr), mask as u128), + IpAddrOrMask::V6 { addr, mask } => (IpAddr::V6(addr), mask), + } + } + + pub fn matches(&self, remote: &IpAddr) -> bool { + match self { + IpAddrOrMask::V4 { addr, mask } => match *mask { + u32::MAX => match remote { + IpAddr::V4(remote) => addr == remote, + IpAddr::V6(remote) => { + if let Some(remote) = remote.to_ipv4_mapped() { + addr == &remote + } else { + false + } + } + }, + 0 => { + matches!(remote, IpAddr::V4(_)) + } + _ => { + u32::from_be_bytes(match remote { + IpAddr::V4(ip) => ip.octets(), + IpAddr::V6(ip) => { + if let Some(ip) = ip.to_ipv4() { + ip.octets() + } else { + return false; + } + } + }) & mask + == u32::from_be_bytes(addr.octets()) & mask + } + }, + IpAddrOrMask::V6 { addr, mask } => match *mask { + u128::MAX => match remote { + IpAddr::V6(remote) => remote == addr, + IpAddr::V4(remote) => &remote.to_ipv6_mapped() == addr, + }, + 0 => { + matches!(remote, IpAddr::V6(_)) + } + _ => { + u128::from_be_bytes(match remote { + IpAddr::V6(ip) => ip.octets(), + IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(), + }) & mask + == u128::from_be_bytes(addr.octets()) & mask + } + }, + } + } + + pub fn to_index_key(&self) -> Vec { + match self { + IpAddrOrMask::V4 { addr, mask } => { + let mut bytes = Vec::with_capacity(8); + bytes.extend_from_slice(&addr.octets()); + bytes.extend_from_slice(&mask.to_be_bytes()); + bytes + } + IpAddrOrMask::V6 { addr, mask } => { + let mut bytes = Vec::with_capacity(24); + bytes.extend_from_slice(&addr.octets()); + bytes.extend_from_slice(&mask.to_be_bytes()); + bytes + } + } + } +} + +impl FromStr for IpAddrOrMask { + type Err = String; + + fn from_str(value: &str) -> Result { + if let Some((addr, mask)) = value.rsplit_once('/') { + if let (Ok(addr), Ok(mask)) = + (addr.trim().parse::(), mask.trim().parse::()) + { + match addr { + IpAddr::V4(addr) if (8..=32).contains(&mask) => { + return Ok(IpAddrOrMask::V4 { + addr, + mask: u32::MAX << (32 - mask), + }); + } + IpAddr::V6(addr) if (8..=128).contains(&mask) => { + return Ok(IpAddrOrMask::V6 { + addr, + mask: u128::MAX << (128 - mask), + }); + } + _ => (), + } + } + } else { + match value.trim().parse::() { + Ok(IpAddr::V4(addr)) => { + return Ok(IpAddrOrMask::V4 { + addr, + mask: u32::MAX, + }); + } + Ok(IpAddr::V6(addr)) => { + return Ok(IpAddrOrMask::V6 { + addr, + mask: u128::MAX, + }); + } + _ => (), + } + } + + Err(format!("Invalid IP address {:?}", value,)) + } +} + +impl Display for IpAddrOrMask { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + IpAddrOrMask::V4 { addr, mask } => { + if (*mask) == u32::MAX { + write!(f, "{}", addr) + } else { + let prefix = mask.count_ones(); + write!(f, "{}/{}", addr, prefix) + } + } + IpAddrOrMask::V6 { addr, mask } => { + if (*mask) == u128::MAX { + write!(f, "{}", addr) + } else { + let prefix = mask.count_ones(); + write!(f, "{}/{}", addr, prefix) + } + } + } + } +} + +impl serde::Serialize for IpAddrOrMask { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for IpAddrOrMask { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + IpAddrOrMask::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid IpAddrOrMask")) + } +} + +impl Default for IpAddrOrMask { + fn default() -> Self { + IpAddrOrMask::V4 { + addr: Ipv4Addr::UNSPECIFIED, + mask: u32::MAX, + } + } +} + +impl Pickle for IpAddrOrMask { + fn pickle(&self, out: &mut Vec) { + match self { + IpAddrOrMask::V4 { addr, mask } => { + out.push(4); + out.extend_from_slice(&addr.octets()); + out.extend_from_slice(&mask.to_be_bytes()); + } + IpAddrOrMask::V6 { addr, mask } => { + out.push(6); + out.extend_from_slice(&addr.octets()); + out.extend_from_slice(&mask.to_be_bytes()); + } + } + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + match data.read()? { + 4 => { + let mut addr_arr = [0u8; 4]; + addr_arr.copy_from_slice(data.read_bytes(4)?); + let mut mask_arr = [0u8; 4]; + mask_arr.copy_from_slice(data.read_bytes(4)?); + Some(IpAddrOrMask::V4 { + addr: Ipv4Addr::from(addr_arr), + mask: u32::from_be_bytes(mask_arr), + }) + } + 6 => { + let mut addr_arr = [0u8; 16]; + addr_arr.copy_from_slice(data.read_bytes(16)?); + let mut mask_arr = [0u8; 16]; + mask_arr.copy_from_slice(data.read_bytes(16)?); + Some(IpAddrOrMask::V6 { + addr: Ipv6Addr::from(addr_arr), + mask: u128::from_be_bytes(mask_arr), + }) + } + _ => None, + } + } +} + +impl RegistryJsonPatch for IpAddrOrMask { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = IpAddrOrMask::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new( + pointer, + "Failed to parse IpAddrOrMask from string", + )) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for IpAddrOrMask, expected a string value", + )), + } + } +} + +impl IntoValue for IpAddrOrMask { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.to_string().into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ipaddrmask() { + for (mask, ip) in [ + ("10.0.0.0/8", "10.30.20.11"), + ("10.0.0.0/8", "10.0.13.73"), + ("192.168.1.1", "192.168.1.1"), + ] { + let mask = IpAddrOrMask::from_str(mask).unwrap(); + let ip = ip.parse::().unwrap(); + assert!(mask.matches(&ip)); + } + + for (mask, ip) in [ + ("10.0.0.0/8", "11.30.20.11"), + ("192.168.1.1", "193.168.1.1"), + ] { + let mask = IpAddrOrMask::from_str(mask).unwrap(); + let ip = ip.parse::().unwrap(); + assert!(!mask.matches(&ip)); + } + } +} diff --git a/crates/registry/src/types/list.rs b/crates/registry/src/types/list.rs new file mode 100644 index 0000000..ff7f289 --- /dev/null +++ b/crates/registry/src/types/list.rs @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, Visitor}, + ser::SerializeMap, +}; +use std::{ + borrow::Cow, + fmt::{self, Debug}, + marker::PhantomData, +}; +use utils::map::vec_map::{KeyValue, VecMap}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct List(pub VecMap); + +impl List { + pub fn with_capacity(capacity: usize) -> Self { + Self(VecMap::with_capacity(capacity)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.0.values() + } + + pub fn values(&self) -> impl Iterator { + self.0.values() + } + + pub fn values_mut(&mut self) -> impl Iterator { + self.0.values_mut() + } + + pub fn push(&mut self, item: T) { + let next_index = self.0.last().map(|(k, _)| *k + 1).unwrap_or(0); + self.0.append(next_index, item); + } + + pub fn push_unchecked(&mut self, item: T) { + let next_index = self.0.len() as u32; + self.0.append(next_index, item); + } + + pub fn inner_mut(&mut self) -> &mut VecMap { + &mut self.0 + } +} + +impl Pickle for List +where + T: Pickle, +{ + fn pickle(&self, out: &mut Vec) { + (self.0.len() as u32).pickle(out); + for item in self.0.values() { + item.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut vec = Self::with_capacity(len); + for _ in 0..len { + vec.push_unchecked(T::unpickle(stream)?); + } + Some(vec) + } +} + +impl RegistryJsonPatch for List { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (pointer.next(), value) { + (Some(JsonPointerItem::Number(key)), value) => { + let key = *key as u32; + if matches!(value, Value::Null) && !pointer.has_next() { + if self.0.remove(&key).is_some() { + return Ok(MaybeUnpatched::Patched); + } + } else { + let result = self.0.get_mut_or_insert(key).patch(pointer, value); + self.0.sort_unstable_by_key(); + return result; + } + } + (Some(JsonPointerItem::Key(key)), value) => { + if let Ok(key) = key.to_string().parse::() { + if matches!(value, Value::Null) && !pointer.has_next() { + if self.0.remove(&key).is_some() { + return Ok(MaybeUnpatched::Patched); + } + } else { + let result = self.0.get_mut_or_insert(key).patch(pointer, value); + self.0.sort_unstable_by_key(); + return result; + } + } + } + (None, Value::Object(items)) => { + self.0.clear(); + for (key, value) in items.into_vec() { + if let Ok(key) = key.to_string().parse::() { + let mut inner = T::default(); + inner.patch(pointer.clone(), value)?; + self.0.set(key, inner); + } else { + return Err(PatchError::new( + pointer.clone(), + "Invalid key for object property", + )); + } + } + self.0.sort_unstable_by_key(); + return Ok(MaybeUnpatched::Patched); + } + _ => {} + } + + Err(PatchError::new( + pointer, + "Invalid value for object property", + )) + } +} + +impl IntoValue for List { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(self.0.len()); + for (idx, v) in self.0 { + map.insert_unchecked(Key::Owned(idx.to_string()), v.into_value()); + } + + JmapValue::Object(map) + } +} + +impl Serialize for List { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (key, value) in &self.0 { + map.serialize_entry(&key.to_string(), value)?; + } + map.end() + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for List { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ListVisitor(PhantomData); + + impl<'de, T: Deserialize<'de>> Visitor<'de> for ListVisitor { + type Value = List; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of string keys to values") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut items = VecMap::with_capacity(map.size_hint().unwrap_or(0)); + + while let Some(key) = map.next_key::>()? { + let id: u32 = key + .parse() + .map_err(|_| de::Error::custom(format!("invalid integer key: {key}")))?; + let value: T = map.next_value()?; + items.set(id, value); + } + + items.sort_unstable_by_key(); + + Ok(List(items)) + } + } + + deserializer.deserialize_map(ListVisitor(PhantomData)) + } +} + +impl FromIterator for List { + fn from_iter>(iter: I) -> Self { + Self(VecMap::from_iter( + iter.into_iter().enumerate().map(|(i, v)| (i as u32, v)), + )) + } +} + +impl From> for List { + fn from(vec: Vec) -> Self { + Self(VecMap::from_iter( + vec.into_iter().enumerate().map(|(i, v)| (i as u32, v)), + )) + } +} + +impl IntoIterator for List { + type Item = T; + type IntoIter = std::iter::Map>, fn(KeyValue) -> T>; + + fn into_iter(self) -> Self::IntoIter { + self.0.inner.into_iter().map(|kv| kv.value) + } +} diff --git a/crates/registry/src/types/map.rs b/crates/registry/src/types/map.rs new file mode 100644 index 0000000..af953c1 --- /dev/null +++ b/crates/registry/src/types/map.rs @@ -0,0 +1,444 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + schema::prelude::SocketAddr, + types::{EnumImpl, error::PatchError, ipaddr::IpAddr, ipmask::IpAddrOrMask}, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, Visitor}, + ser::SerializeMap, +}; +use std::{ + borrow::Cow, + fmt::{self, Debug}, + marker::PhantomData, + str::FromStr, +}; +use types::id::Id; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Map(Vec); + +impl Map { + pub fn new(items: Vec) -> Self { + Self(items) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self(Vec::with_capacity(capacity)) + } + + #[inline(always)] + pub fn contains(&self, item: &T) -> bool { + self.0.contains(item) + } + + #[inline(always)] + pub fn len(&self) -> usize { + self.0.len() + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[inline(always)] + pub fn into_inner(self) -> Vec { + self.0 + } + + #[inline(always)] + pub fn inner_mut(&mut self) -> &mut Vec { + &mut self.0 + } + + #[inline(always)] + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + #[inline(always)] + pub fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } + + #[inline(always)] + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + #[inline(always)] + pub fn push(&mut self, item: T) { + if !self.0.contains(&item) { + self.0.push(item); + } + } + + #[inline(always)] + pub fn push_unchecked(&mut self, item: T) { + self.0.push(item); + } + + #[inline(always)] + pub fn clear(&mut self) { + self.0.clear(); + } +} + +impl Pickle for Map +where + T: Pickle + MapItem, +{ + fn pickle(&self, out: &mut Vec) { + (self.0.len() as u32).pickle(out); + for item in &self.0 { + item.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut vec = Vec::with_capacity(len); + for _ in 0..len { + vec.push(T::unpickle(stream)?); + } + Some(Self(vec)) + } +} + +impl IntoValue for Map { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(self.0.len()); + for v in self.0 { + let key = match v.into_string() { + Cow::Borrowed(s) => Key::Borrowed(s), + Cow::Owned(s) => Key::Owned(s), + }; + map.insert_unchecked(key, Value::Bool(true)); + } + + JmapValue::Object(map) + } +} + +impl RegistryJsonPatch for Map { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (pointer.next(), value) { + (Some(JsonPointerItem::Number(idx)), Value::Null | Value::Bool(false)) => { + let key = T::try_from_integer(*idx); + + if !pointer.has_next() + && let Some(key) = key + { + self.0.retain(|item| item != &key); + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), Value::Null | Value::Bool(false)) => { + let key = T::try_from_string(key.to_string().as_ref()); + + if !pointer.has_next() + && let Some(key) = key + { + self.0.retain(|item| item != &key); + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), Value::Bool(true)) => { + let key = T::try_from_string(key.to_string().as_ref()); + + if !pointer.has_next() + && let Some(key) = key + { + if !self.0.contains(&key) { + self.0.push(key); + } + + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Number(idx)), Value::Bool(true)) => { + let key = T::try_from_integer(*idx); + if !pointer.has_next() + && let Some(key) = key + { + if !self.0.contains(&key) { + self.0.push(key); + } + + return Ok(MaybeUnpatched::Patched); + } + } + (None, Value::Object(items)) => { + self.0.clear(); + for (key, value) in items.into_vec() { + if let (Some(key), Value::Bool(is_set)) = + (T::try_from_string(key.to_string().as_ref()), value) + { + if is_set && !self.0.contains(&key) { + self.0.push(key); + } + } else { + return Err(PatchError::new( + pointer.clone(), + "Invalid key for object property", + )); + } + } + return Ok(MaybeUnpatched::Patched); + } + _ => {} + } + + Err(PatchError::new( + pointer, + "Invalid value for object property", + )) + } +} + +impl Serialize for Map { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for item in &self.0 { + map.serialize_entry(&item.as_string() as &str, &true)?; + } + map.end() + } +} + +impl<'de, T: MapItem> Deserialize<'de> for Map { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct MapVisitor(PhantomData); + + impl<'de, T: MapItem> Visitor<'de> for MapVisitor { + type Value = Map; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of string keys to booleans or nulls") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut items = Vec::with_capacity(map.size_hint().unwrap_or(0)); + + while let Some(key) = map.next_key::>()? { + let value: Option = map.next_value()?; + + if value == Some(true) { + let item = T::try_from_string(&key) + .ok_or_else(|| de::Error::custom(format!("invalid map key: {key}")))?; + if !items.contains(&item) { + items.push(item); + } + } + } + + Ok(Map(items)) + } + } + + deserializer.deserialize_map(MapVisitor(PhantomData)) + } +} + +pub trait MapItem: Sized + PartialEq + Eq + Debug { + fn try_from_string(value: &str) -> Option; + fn try_from_integer(value: u64) -> Option; + fn into_string(self) -> Cow<'static, str>; + fn as_string(&self) -> Cow<'_, str>; +} + +impl MapItem for String { + fn try_from_string(value: &str) -> Option { + let value = value.trim(); + if !value.is_empty() { + Some(value.to_string()) + } else { + None + } + } + + fn try_from_integer(value: u64) -> Option { + Some(value.to_string()) + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Borrowed(self.as_str()) + } +} + +impl MapItem for Id { + fn try_from_string(value: &str) -> Option { + Id::from_str(value).ok() + } + + fn try_from_integer(value: u64) -> Option { + Id::from_str(&value.to_string()).ok() + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.as_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.as_string()) + } +} + +impl MapItem for T { + fn try_from_string(value: &str) -> Option { + Self::parse(value) + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Borrowed(self.as_str()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Borrowed(self.as_str()) + } +} + +impl MapItem for IpAddr { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for IpAddrOrMask { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for SocketAddr { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for u64 { + fn try_from_string(value: &str) -> Option { + value.parse().ok() + } + + fn try_from_integer(value: u64) -> Option { + Some(value) + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl From> for Map { + fn from(vec: Vec) -> Self { + Self(vec) + } +} + +impl IntoIterator for Map { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{jmap::JsonPointerPatch, schema::prelude::Property}; + use jmap_tools::JsonPointer; + + fn patch_member(ptr_str: &str) -> Map { + let mut map = Map::::default(); + let ptr = JsonPointer::::parse(ptr_str); + let pointer = JsonPointerPatch::new(&ptr); + map.patch(pointer, Value::Bool(true)).expect("patch failed"); + map + } + + #[test] + fn patch_map_id_digit_keys() { + for id in [0u64, 28, 29, 70, 861, 954, 957, 30554] { + let id = Id::new(id); + let key = id.as_string(); + let map = patch_member(&key); + assert_eq!(map.0, vec![id], "id {} via key '{key}'", id.id()); + } + } +} diff --git a/crates/registry/src/types/mod.rs b/crates/registry/src/types/mod.rs new file mode 100644 index 0000000..ddd8528 --- /dev/null +++ b/crates/registry/src/types/mod.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + pickle::{Pickle, maybe_compress_pickle}, + schema::prelude::ObjectType, + types::{error::ValidationError, index::IndexBuilder}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +pub mod datetime; +pub mod duration; +pub mod error; +pub mod float; +pub mod id; +pub mod index; +pub mod ipaddr; +pub mod ipmask; +pub mod list; +pub mod map; +pub mod socketaddr; +pub mod string; + +pub trait EnumImpl: Sized + Debug + PartialEq + Eq { + const COUNT: usize; + + fn parse(s: &str) -> Option; + fn as_str(&self) -> &'static str; + fn from_id(id: u16) -> Option; + fn to_id(&self) -> u16; +} + +pub trait ObjectImpl: + Pickle + Serialize + DeserializeOwned + Default + Clone + Send + Sync +{ + const FLAGS: u64; + const OBJECT: ObjectType; + const VERSION: u8; + + fn validate(&self, errors: &mut Vec) -> bool; + fn index<'x>(&'x self, builder: &mut IndexBuilder<'x>); + fn to_pickled_vec(&self) -> Vec { + let mut out = Vec::with_capacity(256); + out.push(Self::VERSION); + self.pickle(&mut out); + maybe_compress_pickle(out) + } +} diff --git a/crates/registry/src/types/socketaddr.rs b/crates/registry/src/types/socketaddr.rs new file mode 100644 index 0000000..ead5b9a --- /dev/null +++ b/crates/registry/src/types/socketaddr.rs @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, str::FromStr}; + +const UNSET_SOCKET_ADDR: std::net::SocketAddr = std::net::SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::from_octets([255, 255, 255, 255])), + u16::MAX, +); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocketAddr(pub std::net::SocketAddr); + +impl SocketAddr { + pub fn into_inner(self) -> std::net::SocketAddr { + self.0 + } + + pub fn is_valid(&self) -> bool { + self.0 != UNSET_SOCKET_ADDR + } +} + +impl FromStr for SocketAddr { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(SocketAddr) + .map_err(|err| err.to_string()) + } +} + +impl Display for SocketAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl serde::Serialize for SocketAddr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for SocketAddr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SocketAddr::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid SocketAddr")) + } +} + +impl Default for SocketAddr { + fn default() -> Self { + SocketAddr(UNSET_SOCKET_ADDR) + } +} + +impl AsRef for SocketAddr { + fn as_ref(&self) -> &std::net::SocketAddr { + &self.0 + } +} + +impl Pickle for SocketAddr { + fn pickle(&self, out: &mut Vec) { + self.0.ip().pickle(out); + self.0.port().pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + let ip = std::net::IpAddr::unpickle(data)?; + let port = u16::unpickle(data)?; + Some(SocketAddr(std::net::SocketAddr::new(ip, port))) + } +} + +impl RegistryJsonPatch for SocketAddr { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (value, pointer.next()) { + (jmap_tools::Value::Str(value), None) => { + if let Ok(new_value) = SocketAddr::from_str(value.as_ref()) { + *self = new_value; + Ok(MaybeUnpatched::Patched) + } else { + Err(PatchError::new( + pointer, + "Failed to parse SocketAddr from string", + )) + } + } + _ => Err(PatchError::new( + pointer, + "Invalid path for SocketAddr, expected a string value", + )), + } + } +} + +impl IntoValue for SocketAddr { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Str(self.to_string().into()) + } +} diff --git a/crates/registry/src/types/string.rs b/crates/registry/src/types/string.rs new file mode 100644 index 0000000..d9af552 --- /dev/null +++ b/crates/registry/src/types/string.rs @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{net::IpAddr, str::FromStr}; +use utils::{sanitize_domain, sanitize_email, sanitize_email_local}; + +#[derive(Debug, Clone)] +pub enum StringValidator { + Email, + EmailLocalPart, + Domain, + Hostname, + RemoveSpaces, + Lowercase, + Uppercase, + Trim, +} + +pub enum StringValidatorResult { + Valid, + Replace(String), + Invalid(&'static str), +} + +impl StringValidator { + pub fn validate(&self, value: &str) -> StringValidatorResult { + match self { + Self::Email => sanitize_email(value) + .map(StringValidatorResult::Replace) + .unwrap_or(StringValidatorResult::Invalid("Invalid email address")), + Self::EmailLocalPart => sanitize_email_local(value) + .map(StringValidatorResult::Replace) + .unwrap_or(StringValidatorResult::Invalid("Invalid email local part")), + Self::Domain => sanitize_domain(value) + .map(StringValidatorResult::Replace) + .unwrap_or(StringValidatorResult::Invalid("Invalid domain name")), + Self::Hostname => IpAddr::from_str(value) + .ok() + .map(|_| StringValidatorResult::Valid) + .or_else(|| sanitize_domain(value).map(StringValidatorResult::Replace)) + .unwrap_or(StringValidatorResult::Invalid( + "Invalid hostname or IP address", + )), + Self::RemoveSpaces => { + if value.chars().any(|c| c.is_whitespace()) { + StringValidatorResult::Replace( + value.chars().filter(|c| !c.is_whitespace()).collect(), + ) + } else { + StringValidatorResult::Valid + } + } + Self::Lowercase => StringValidatorResult::Replace(value.to_lowercase()), + Self::Uppercase => StringValidatorResult::Replace(value.to_uppercase()), + Self::Trim => { + let trimmed = value.trim(); + if trimmed.len() != value.len() { + if !trimmed.is_empty() { + StringValidatorResult::Replace(trimmed.to_string()) + } else { + StringValidatorResult::Invalid("String cannot be empty") + } + } else { + StringValidatorResult::Valid + } + } + } + } +} diff --git a/crates/registry/src/utils/account.rs b/crates/registry/src/utils/account.rs new file mode 100644 index 0000000..edc4ca7 --- /dev/null +++ b/crates/registry/src/utils/account.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use types::id::Id; + +use crate::schema::prelude::{ + Account, Credential, GroupAccount, PasswordCredential, SecondaryCredential, UserAccount, +}; + +impl Account { + pub fn into_user(self) -> Option { + if let Account::User(user) = self { + Some(user) + } else { + None + } + } + + pub fn into_group(self) -> Option { + if let Account::Group(group) = self { + Some(group) + } else { + None + } + } +} + +impl UserAccount { + pub fn set_password(&mut self, password: String) { + if let Some(credential) = self.credentials.0.values_mut().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) { + credential.secret = password; + } else { + let credential_id = self.next_credential_id().into(); + self.credentials + .push(Credential::Password(PasswordCredential { + credential_id, + secret: password, + ..Default::default() + })); + } + } + + pub fn password_credential(&self) -> Option<&PasswordCredential> { + self.credentials.iter().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn password_credential_mut(&mut self) -> Option<&mut PasswordCredential> { + self.credentials.values_mut().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn password(&self) -> Option<&str> { + self.password_credential() + .map(|credential| credential.secret.as_str()) + } + + pub fn into_password_credential(self) -> Option { + self.credentials.into_iter().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn into_password(self) -> Option { + self.into_password_credential() + .map(|credential| credential.secret) + } + + pub fn next_credential_id(&self) -> u64 { + self.credentials + .0 + .values() + .map(|credential| match credential { + Credential::Password(credential) => credential.credential_id.id() + 1, + Credential::AppPassword(credential_properties) + | Credential::ApiKey(credential_properties) => { + credential_properties.credential_id.id() + 1 + } + }) + .max() + .unwrap_or_default() + } +} + +impl Credential { + pub fn credential_id(&self) -> Id { + match self { + Credential::Password(credential) => credential.credential_id, + Credential::AppPassword(credential_properties) => credential_properties.credential_id, + Credential::ApiKey(credential_properties) => credential_properties.credential_id, + } + } + + pub fn set_credential_id(&mut self, credential_id: Id) { + match self { + Credential::Password(credential) => credential.credential_id = credential_id, + Credential::AppPassword(credential_properties) => { + credential_properties.credential_id = credential_id + } + Credential::ApiKey(credential_properties) => { + credential_properties.credential_id = credential_id + } + } + } + + pub fn into_secondary_credential(self) -> Option { + match self { + Credential::AppPassword(credential_properties) => Some(credential_properties), + Credential::ApiKey(credential_properties) => Some(credential_properties), + Credential::Password(_) => None, + } + } + + pub fn as_secondary_credential(&self) -> Option<&SecondaryCredential> { + match self { + Credential::AppPassword(credential_properties) => Some(credential_properties), + Credential::ApiKey(credential_properties) => Some(credential_properties), + Credential::Password(_) => None, + } + } + + pub fn as_main_credential(&self) -> Option<&PasswordCredential> { + match self { + Credential::Password(credential) => Some(credential), + Credential::AppPassword(_) | Credential::ApiKey(_) => None, + } + } +} diff --git a/crates/registry/src/utils/archived_item.rs b/crates/registry/src/utils/archived_item.rs new file mode 100644 index 0000000..597ff86 --- /dev/null +++ b/crates/registry/src/utils/archived_item.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::{ArchivedItem, UTCDateTime}; +use types::{blob::BlobId, id::Id}; + +impl ArchivedItem { + pub fn account_id(&self) -> Id { + match self { + ArchivedItem::Email(i) => i.account_id, + ArchivedItem::FileNode(i) => i.account_id, + ArchivedItem::CalendarEvent(i) => i.account_id, + ArchivedItem::ContactCard(i) => i.account_id, + ArchivedItem::SieveScript(i) => i.account_id, + } + } + + pub fn blob_id(&self) -> &BlobId { + match self { + ArchivedItem::Email(i) => &i.blob_id, + ArchivedItem::FileNode(i) => &i.blob_id, + ArchivedItem::CalendarEvent(i) => &i.blob_id, + ArchivedItem::ContactCard(i) => &i.blob_id, + ArchivedItem::SieveScript(i) => &i.blob_id, + } + } + + pub fn archived_until(&self) -> UTCDateTime { + match self { + ArchivedItem::Email(i) => i.archived_until, + ArchivedItem::FileNode(i) => i.archived_until, + ArchivedItem::CalendarEvent(i) => i.archived_until, + ArchivedItem::ContactCard(i) => i.archived_until, + ArchivedItem::SieveScript(i) => i.archived_until, + } + } + + pub fn created_at(&self) -> UTCDateTime { + match self { + ArchivedItem::Email(i) => i.received_at, + ArchivedItem::FileNode(i) => i.created_at, + ArchivedItem::CalendarEvent(i) => i.created_at, + ArchivedItem::ContactCard(i) => i.created_at, + ArchivedItem::SieveScript(i) => i.created_at, + } + } + + pub fn set_account_id(&mut self, value: Id) { + match self { + ArchivedItem::Email(i) => i.account_id = value, + ArchivedItem::FileNode(i) => i.account_id = value, + ArchivedItem::CalendarEvent(i) => i.account_id = value, + ArchivedItem::ContactCard(i) => i.account_id = value, + ArchivedItem::SieveScript(i) => i.account_id = value, + } + } + + pub fn set_blob_id(&mut self, value: BlobId) { + match self { + ArchivedItem::Email(i) => i.blob_id = value, + ArchivedItem::FileNode(i) => i.blob_id = value, + ArchivedItem::CalendarEvent(i) => i.blob_id = value, + ArchivedItem::ContactCard(i) => i.blob_id = value, + ArchivedItem::SieveScript(i) => i.blob_id = value, + } + } + + pub fn set_archived_until(&mut self, value: UTCDateTime) { + match self { + ArchivedItem::Email(i) => i.archived_until = value, + ArchivedItem::FileNode(i) => i.archived_until = value, + ArchivedItem::CalendarEvent(i) => i.archived_until = value, + ArchivedItem::ContactCard(i) => i.archived_until = value, + ArchivedItem::SieveScript(i) => i.archived_until = value, + } + } + + pub fn account_id_mut(&mut self) -> &mut Id { + match self { + ArchivedItem::Email(i) => &mut i.account_id, + ArchivedItem::FileNode(i) => &mut i.account_id, + ArchivedItem::CalendarEvent(i) => &mut i.account_id, + ArchivedItem::ContactCard(i) => &mut i.account_id, + ArchivedItem::SieveScript(i) => &mut i.account_id, + } + } + + pub fn blob_id_mut(&mut self) -> &mut BlobId { + match self { + ArchivedItem::Email(i) => &mut i.blob_id, + ArchivedItem::FileNode(i) => &mut i.blob_id, + ArchivedItem::CalendarEvent(i) => &mut i.blob_id, + ArchivedItem::ContactCard(i) => &mut i.blob_id, + ArchivedItem::SieveScript(i) => &mut i.blob_id, + } + } + + pub fn archived_until_mut(&mut self) -> &mut UTCDateTime { + match self { + ArchivedItem::Email(i) => &mut i.archived_until, + ArchivedItem::FileNode(i) => &mut i.archived_until, + ArchivedItem::CalendarEvent(i) => &mut i.archived_until, + ArchivedItem::ContactCard(i) => &mut i.archived_until, + ArchivedItem::SieveScript(i) => &mut i.archived_until, + } + } + + pub fn into_blob_id(self) -> BlobId { + match self { + ArchivedItem::Email(i) => i.blob_id, + ArchivedItem::FileNode(i) => i.blob_id, + ArchivedItem::CalendarEvent(i) => i.blob_id, + ArchivedItem::ContactCard(i) => i.blob_id, + ArchivedItem::SieveScript(i) => i.blob_id, + } + } +} diff --git a/crates/registry/src/utils/cron.rs b/crates/registry/src/utils/cron.rs new file mode 100644 index 0000000..741e248 --- /dev/null +++ b/crates/registry/src/utils/cron.rs @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::Cron; +use utils::cron::SimpleCron; + +impl From for SimpleCron { + fn from(value: Cron) -> Self { + match value { + Cron::Daily(cron) => SimpleCron::Day { + hour: cron.hour as u32, + minute: cron.minute as u32, + }, + Cron::Weekly(cron) => SimpleCron::Week { + day: cron.day as u32, + hour: cron.hour as u32, + minute: cron.minute as u32, + }, + Cron::Hourly(cron) => SimpleCron::Hour { + minute: cron.minute as u32, + }, + } + } +} diff --git a/crates/registry/src/utils/dkim.rs b/crates/registry/src/utils/dkim.rs new file mode 100644 index 0000000..d02704a --- /dev/null +++ b/crates/registry/src/utils/dkim.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + enums::{DkimRotationStage, DkimSignatureType}, + prelude::{DkimSignature, UTCDateTime}, +}; +use types::id::Id; + +impl DkimSignature { + pub fn rotation_due(&self) -> Option { + let (stage, next_transition) = match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => (sign.stage, sign.next_transition_at), + DkimSignature::Dkim1RsaSha256(sign) => (sign.stage, sign.next_transition_at), + DkimSignature::Dkim2Ed25519Sha256(sign) => (sign.stage, sign.next_transition_at), + DkimSignature::Dkim2RsaSha256(sign) => (sign.stage, sign.next_transition_at), + }; + next_transition.and_then(|next_transition| { + if next_transition <= UTCDateTime::now() { + Some(stage) + } else { + None + } + }) + } + + pub fn next_transition(&self) -> Option { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => sign.next_transition_at, + DkimSignature::Dkim1RsaSha256(sign) => sign.next_transition_at, + DkimSignature::Dkim2Ed25519Sha256(sign) => sign.next_transition_at, + DkimSignature::Dkim2RsaSha256(sign) => sign.next_transition_at, + } + } + + pub fn set_next_transition(&mut self, next_transition: UTCDateTime) { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => { + sign.next_transition_at = Some(next_transition) + } + DkimSignature::Dkim1RsaSha256(sign) => sign.next_transition_at = Some(next_transition), + DkimSignature::Dkim2Ed25519Sha256(sign) => { + sign.next_transition_at = Some(next_transition) + } + DkimSignature::Dkim2RsaSha256(sign) => sign.next_transition_at = Some(next_transition), + } + } + + pub fn stage(&self) -> DkimRotationStage { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => sign.stage, + DkimSignature::Dkim1RsaSha256(sign) => sign.stage, + DkimSignature::Dkim2Ed25519Sha256(sign) => sign.stage, + DkimSignature::Dkim2RsaSha256(sign) => sign.stage, + } + } + + pub fn set_stage(&mut self, stage: DkimRotationStage) { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => sign.stage = stage, + DkimSignature::Dkim1RsaSha256(sign) => sign.stage = stage, + DkimSignature::Dkim2Ed25519Sha256(sign) => sign.stage = stage, + DkimSignature::Dkim2RsaSha256(sign) => sign.stage = stage, + } + } + + pub fn is_active(&self) -> bool { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => sign.stage == DkimRotationStage::Active, + DkimSignature::Dkim1RsaSha256(sign) => sign.stage == DkimRotationStage::Active, + DkimSignature::Dkim2Ed25519Sha256(sign) => sign.stage == DkimRotationStage::Active, + DkimSignature::Dkim2RsaSha256(sign) => sign.stage == DkimRotationStage::Active, + } + } + + pub fn is_published(&self) -> bool { + !matches!(self.stage(), DkimRotationStage::Retired) + } + + pub fn selector(&self) -> &str { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => &sign.selector, + DkimSignature::Dkim1RsaSha256(sign) => &sign.selector, + DkimSignature::Dkim2Ed25519Sha256(sign) => &sign.selector, + DkimSignature::Dkim2RsaSha256(sign) => &sign.selector, + } + } + + pub fn domain_id(&self) -> Id { + match self { + DkimSignature::Dkim1Ed25519Sha256(sign) => sign.domain_id, + DkimSignature::Dkim1RsaSha256(sign) => sign.domain_id, + DkimSignature::Dkim2Ed25519Sha256(sign) => sign.domain_id, + DkimSignature::Dkim2RsaSha256(sign) => sign.domain_id, + } + } +} + +impl DkimSignatureType { + pub const fn algorithm(self) -> &'static str { + match self { + Self::Dkim1Ed25519Sha256 | Self::Dkim2Ed25519Sha256 => "ed25519", + Self::Dkim1RsaSha256 | Self::Dkim2RsaSha256 => "rsa", + } + } + + pub const fn hash(self) -> &'static str { + "sha256" + } + + pub const fn version(self) -> &'static str { + match self { + Self::Dkim1Ed25519Sha256 | Self::Dkim1RsaSha256 => "1", + Self::Dkim2Ed25519Sha256 | Self::Dkim2RsaSha256 => "2", + } + } +} diff --git a/crates/registry/src/utils/http.rs b/crates/registry/src/utils/http.rs new file mode 100644 index 0000000..07ae601 --- /dev/null +++ b/crates/registry/src/utils/http.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::{Duration, HttpAuth}; +use utils::{ + Client, HeaderMap, + http::{build_http_client, build_http_headers}, + map::vec_map::VecMap, +}; + +impl HttpAuth { + pub async fn build_headers( + &self, + extra_headers: VecMap, + content_type: Option<&str>, + ) -> Result { + match self { + HttpAuth::Unauthenticated => { + build_http_headers(extra_headers, None, None, None, content_type) + } + HttpAuth::Basic(auth) => build_http_headers( + extra_headers, + auth.username.as_str().into(), + auth.secret.secret().await?.as_ref().into(), + None, + content_type, + ), + HttpAuth::Bearer(auth) => build_http_headers( + extra_headers, + None, + None, + auth.bearer_token.secret().await?.as_ref().into(), + content_type, + ), + } + } + + pub async fn build_http_client( + &self, + extra_headers: VecMap, + content_type: Option<&str>, + timeout: Duration, + allow_invalid_certs: bool, + ) -> Result { + match self { + HttpAuth::Unauthenticated => build_http_client( + extra_headers, + None, + None, + None, + content_type, + timeout.into_inner(), + allow_invalid_certs, + ), + HttpAuth::Basic(auth) => build_http_client( + extra_headers, + auth.username.as_str().into(), + auth.secret.secret().await?.as_ref().into(), + None, + content_type, + timeout.into_inner(), + allow_invalid_certs, + ), + HttpAuth::Bearer(auth) => build_http_client( + extra_headers, + None, + None, + auth.bearer_token.secret().await?.as_ref().into(), + content_type, + timeout.into_inner(), + allow_invalid_certs, + ), + } + } +} diff --git a/crates/registry/src/utils/mod.rs b/crates/registry/src/utils/mod.rs new file mode 100644 index 0000000..94c21c6 --- /dev/null +++ b/crates/registry/src/utils/mod.rs @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::{DkimSignature, Roles, SecretText}; +use types::id::Id; + +pub mod account; +pub mod archived_item; +pub mod cron; +pub mod dkim; +pub mod http; +pub mod report; +pub mod secret; +pub mod task; + +impl Roles { + pub fn role_ids(&self) -> Option<&[Id]> { + match self { + Roles::Default => None, + Roles::Custom(custom_roles) => Some(custom_roles.role_ids.as_slice()), + } + } +} + +impl DkimSignature { + pub fn private_key(&self) -> &SecretText { + match self { + DkimSignature::Dkim1Ed25519Sha256(signature) => &signature.private_key, + DkimSignature::Dkim1RsaSha256(signature) => &signature.private_key, + DkimSignature::Dkim2Ed25519Sha256(signature) => &signature.private_key, + DkimSignature::Dkim2RsaSha256(signature) => &signature.private_key, + } + } + + pub fn private_key_mut(&mut self) -> &mut SecretText { + match self { + DkimSignature::Dkim1Ed25519Sha256(signature) => &mut signature.private_key, + DkimSignature::Dkim1RsaSha256(signature) => &mut signature.private_key, + DkimSignature::Dkim2Ed25519Sha256(signature) => &mut signature.private_key, + DkimSignature::Dkim2RsaSha256(signature) => &mut signature.private_key, + } + } +} diff --git a/crates/registry/src/utils/report.rs b/crates/registry/src/utils/report.rs new file mode 100644 index 0000000..6dca37f --- /dev/null +++ b/crates/registry/src/utils/report.rs @@ -0,0 +1,1013 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + schema::{ + enums, + prelude::UTCDateTime, + structs::{self, DmarcReportRecord, TlsFailureDetails}, + }, + types::{ipaddr::IpAddr, list::List}, +}; +use mail_auth::{ + IprevOutput, IprevResult, SpfOutput, + report::{tlsrpt::*, *}, +}; +use std::borrow::Cow; + +impl From for Alignment { + fn from(value: enums::DmarcAlignment) -> Self { + match value { + enums::DmarcAlignment::Relaxed => Alignment::Relaxed, + enums::DmarcAlignment::Strict => Alignment::Strict, + enums::DmarcAlignment::Unspecified => Alignment::Unspecified, + } + } +} + +impl From for enums::DmarcAlignment { + fn from(value: Alignment) -> Self { + match value { + Alignment::Relaxed => enums::DmarcAlignment::Relaxed, + Alignment::Strict => enums::DmarcAlignment::Strict, + Alignment::Unspecified => enums::DmarcAlignment::Unspecified, + } + } +} + +impl From for Disposition { + fn from(value: enums::DmarcDisposition) -> Self { + match value { + enums::DmarcDisposition::None => Disposition::None, + enums::DmarcDisposition::Quarantine => Disposition::Quarantine, + enums::DmarcDisposition::Reject => Disposition::Reject, + enums::DmarcDisposition::Unspecified => Disposition::Unspecified, + } + } +} + +impl From for enums::DmarcDisposition { + fn from(value: Disposition) -> Self { + match value { + Disposition::None => enums::DmarcDisposition::None, + Disposition::Quarantine => enums::DmarcDisposition::Quarantine, + Disposition::Reject => enums::DmarcDisposition::Reject, + Disposition::Unspecified => enums::DmarcDisposition::Unspecified, + } + } +} + +impl From for Discovery { + fn from(value: enums::DmarcDiscovery) -> Self { + match value { + enums::DmarcDiscovery::Psl => Discovery::Psl, + enums::DmarcDiscovery::Treewalk => Discovery::Treewalk, + enums::DmarcDiscovery::Unspecified => Discovery::Unspecified, + } + } +} + +impl From for enums::DmarcDiscovery { + fn from(value: Discovery) -> Self { + match value { + Discovery::Psl => enums::DmarcDiscovery::Psl, + Discovery::Treewalk => enums::DmarcDiscovery::Treewalk, + Discovery::Unspecified => enums::DmarcDiscovery::Unspecified, + } + } +} + +impl From for ActionDisposition { + fn from(value: enums::DmarcActionDisposition) -> Self { + match value { + enums::DmarcActionDisposition::None => ActionDisposition::None, + enums::DmarcActionDisposition::Pass => ActionDisposition::Pass, + enums::DmarcActionDisposition::Quarantine => ActionDisposition::Quarantine, + enums::DmarcActionDisposition::Reject => ActionDisposition::Reject, + enums::DmarcActionDisposition::Unspecified => ActionDisposition::Unspecified, + } + } +} + +impl From for enums::DmarcActionDisposition { + fn from(value: ActionDisposition) -> Self { + match value { + ActionDisposition::None => enums::DmarcActionDisposition::None, + ActionDisposition::Pass => enums::DmarcActionDisposition::Pass, + ActionDisposition::Quarantine => enums::DmarcActionDisposition::Quarantine, + ActionDisposition::Reject => enums::DmarcActionDisposition::Reject, + ActionDisposition::Unspecified => enums::DmarcActionDisposition::Unspecified, + } + } +} + +impl From for DmarcResult { + fn from(value: enums::DmarcResult) -> Self { + match value { + enums::DmarcResult::Pass => DmarcResult::Pass, + enums::DmarcResult::Fail => DmarcResult::Fail, + enums::DmarcResult::Unspecified => DmarcResult::Unspecified, + } + } +} + +impl From for enums::DmarcResult { + fn from(value: DmarcResult) -> Self { + match value { + DmarcResult::Pass => enums::DmarcResult::Pass, + DmarcResult::Fail => enums::DmarcResult::Fail, + DmarcResult::Unspecified => enums::DmarcResult::Unspecified, + } + } +} + +impl From for PolicyOverride { + fn from(value: enums::DmarcPolicyOverride) -> Self { + match value { + enums::DmarcPolicyOverride::TrustedForwarder => PolicyOverride::TrustedForwarder, + enums::DmarcPolicyOverride::MailingList => PolicyOverride::MailingList, + enums::DmarcPolicyOverride::LocalPolicy => PolicyOverride::LocalPolicy, + enums::DmarcPolicyOverride::PolicyTestMode => PolicyOverride::PolicyTestMode, + // "forwarded" and "sampled_out" were removed in RFC 9990; map any + // legacy stored value to the closest surviving override type. + enums::DmarcPolicyOverride::Forwarded + | enums::DmarcPolicyOverride::SampledOut + | enums::DmarcPolicyOverride::Other => PolicyOverride::Other, + } + } +} + +impl From for enums::DmarcPolicyOverride { + fn from(value: PolicyOverride) -> Self { + match value { + PolicyOverride::TrustedForwarder => enums::DmarcPolicyOverride::TrustedForwarder, + PolicyOverride::MailingList => enums::DmarcPolicyOverride::MailingList, + PolicyOverride::LocalPolicy => enums::DmarcPolicyOverride::LocalPolicy, + PolicyOverride::PolicyTestMode => enums::DmarcPolicyOverride::PolicyTestMode, + PolicyOverride::Other => enums::DmarcPolicyOverride::Other, + } + } +} + +impl From for DkimResult { + fn from(value: enums::DkimAuthResult) -> Self { + match value { + enums::DkimAuthResult::None => DkimResult::None, + enums::DkimAuthResult::Pass => DkimResult::Pass, + enums::DkimAuthResult::Fail => DkimResult::Fail, + enums::DkimAuthResult::Policy => DkimResult::Policy, + enums::DkimAuthResult::Neutral => DkimResult::Neutral, + enums::DkimAuthResult::TempError => DkimResult::TempError, + enums::DkimAuthResult::PermError => DkimResult::PermError, + } + } +} + +impl From for enums::DkimAuthResult { + fn from(value: DkimResult) -> Self { + match value { + DkimResult::None => enums::DkimAuthResult::None, + DkimResult::Pass => enums::DkimAuthResult::Pass, + DkimResult::Fail => enums::DkimAuthResult::Fail, + DkimResult::Policy => enums::DkimAuthResult::Policy, + DkimResult::Neutral => enums::DkimAuthResult::Neutral, + DkimResult::TempError => enums::DkimAuthResult::TempError, + DkimResult::PermError => enums::DkimAuthResult::PermError, + } + } +} + +impl From for SpfResult { + fn from(value: enums::SpfAuthResult) -> Self { + match value { + enums::SpfAuthResult::None => SpfResult::None, + enums::SpfAuthResult::Neutral => SpfResult::Neutral, + enums::SpfAuthResult::Pass => SpfResult::Pass, + enums::SpfAuthResult::Fail => SpfResult::Fail, + enums::SpfAuthResult::SoftFail => SpfResult::SoftFail, + enums::SpfAuthResult::TempError => SpfResult::TempError, + enums::SpfAuthResult::PermError => SpfResult::PermError, + } + } +} + +impl From for enums::SpfAuthResult { + fn from(value: SpfResult) -> Self { + match value { + SpfResult::None => enums::SpfAuthResult::None, + SpfResult::Neutral => enums::SpfAuthResult::Neutral, + SpfResult::Pass => enums::SpfAuthResult::Pass, + SpfResult::Fail => enums::SpfAuthResult::Fail, + SpfResult::SoftFail => enums::SpfAuthResult::SoftFail, + SpfResult::TempError => enums::SpfAuthResult::TempError, + SpfResult::PermError => enums::SpfAuthResult::PermError, + } + } +} + +impl From for SPFDomainScope { + fn from(value: enums::SpfDomainScope) -> Self { + match value { + enums::SpfDomainScope::Helo => SPFDomainScope::Helo, + enums::SpfDomainScope::MailFrom => SPFDomainScope::MailFrom, + enums::SpfDomainScope::Unspecified => SPFDomainScope::Unspecified, + } + } +} + +impl From for enums::SpfDomainScope { + fn from(value: SPFDomainScope) -> Self { + match value { + SPFDomainScope::Helo => enums::SpfDomainScope::Helo, + SPFDomainScope::MailFrom => enums::SpfDomainScope::MailFrom, + SPFDomainScope::Unspecified => enums::SpfDomainScope::Unspecified, + } + } +} + +impl From for PolicyOverrideReason { + fn from(value: structs::DmarcPolicyOverrideReason) -> Self { + PolicyOverrideReason { + type_: value.override_type.into(), + comment: value.comment, + } + } +} + +impl From for structs::DmarcPolicyOverrideReason { + fn from(value: PolicyOverrideReason) -> Self { + structs::DmarcPolicyOverrideReason { + override_type: value.type_.into(), + comment: value.comment, + } + } +} + +impl From for DKIMAuthResult { + fn from(value: structs::DmarcDkimResult) -> Self { + DKIMAuthResult { + domain: value.domain, + selector: value.selector, + result: value.result.into(), + human_result: value.human_result, + } + } +} + +impl From for structs::DmarcDkimResult { + fn from(value: DKIMAuthResult) -> Self { + structs::DmarcDkimResult { + domain: value.domain, + selector: value.selector, + result: value.result.into(), + human_result: value.human_result, + } + } +} + +impl From for SPFAuthResult { + fn from(value: structs::DmarcSpfResult) -> Self { + SPFAuthResult { + domain: value.domain, + scope: value.scope.into(), + result: value.result.into(), + human_result: value.human_result, + } + } +} + +impl From for structs::DmarcSpfResult { + fn from(value: SPFAuthResult) -> Self { + structs::DmarcSpfResult { + domain: value.domain, + scope: value.scope.into(), + result: value.result.into(), + human_result: value.human_result, + } + } +} + +impl From for Extension { + fn from(value: structs::DmarcExtension) -> Self { + Extension { + name: value.name, + definition: value.definition, + } + } +} + +impl From for structs::DmarcExtension { + fn from(value: Extension) -> Self { + structs::DmarcExtension { + name: value.name, + definition: value.definition, + } + } +} + +impl From for Record { + fn from(value: structs::DmarcReportRecord) -> Self { + Record { + row: Row { + source_ip: value.source_ip.map(|ip| ip.into_inner()), + count: value.count as u32, + policy_evaluated: PolicyEvaluated { + disposition: value.evaluated_disposition.into(), + dkim: value.evaluated_dkim.into(), + spf: value.evaluated_spf.into(), + reason: value + .policy_override_reasons + .into_iter() + .map(Into::into) + .collect(), + }, + }, + identifiers: Identifier { + envelope_to: value.envelope_to, + envelope_from: value.envelope_from, + header_from: value.header_from, + }, + auth_results: AuthResult { + dkim: value.dkim_results.into_iter().map(Into::into).collect(), + spf: value.spf_results.into_iter().map(Into::into).collect(), + }, + extensions: value.extensions.into_iter().map(Into::into).collect(), + } + } +} + +impl From for structs::DmarcReportRecord { + fn from(value: Record) -> Self { + structs::DmarcReportRecord { + count: value.row.count as u64, + source_ip: value.row.source_ip.map(IpAddr), + evaluated_disposition: value.row.policy_evaluated.disposition.into(), + evaluated_dkim: value.row.policy_evaluated.dkim.into(), + evaluated_spf: value.row.policy_evaluated.spf.into(), + policy_override_reasons: List::from_iter( + value + .row + .policy_evaluated + .reason + .into_iter() + .map(Into::into), + ), + envelope_to: value.identifiers.envelope_to, + envelope_from: value.identifiers.envelope_from, + header_from: value.identifiers.header_from, + dkim_results: List::from_iter(value.auth_results.dkim.into_iter().map(Into::into)), + spf_results: List::from_iter(value.auth_results.spf.into_iter().map(Into::into)), + extensions: List::from_iter(value.extensions.into_iter().map(Into::into)), + } + } +} + +impl From for Report { + fn from(value: structs::DmarcReport) -> Self { + Report { + version: value.version.into_inner() as f32, + report_metadata: ReportMetadata { + org_name: value.org_name, + email: value.email, + extra_contact_info: value.extra_contact_info, + report_id: value.report_id, + date_range: mail_auth::report::DateRange { + begin: value.date_range_begin.timestamp() as u64, + end: value.date_range_end.timestamp() as u64, + }, + error: value.errors.into_inner(), + generator: value.generator, + }, + policy_published: PolicyPublished { + domain: value.policy_domain, + version_published: value.policy_version.as_deref().and_then(|v| v.parse().ok()), + adkim: value.policy_adkim.into(), + aspf: value.policy_aspf.into(), + p: value.policy_disposition.into(), + sp: value.policy_subdomain_disposition.into(), + np: value.policy_np.into(), + discovery_method: value.policy_discovery_method.into(), + testing: value.policy_testing_mode, + fo: failure_reporting_options_to_fo( + value.policy_failure_reporting_options.as_slice(), + ), + }, + record: value.records.into_iter().map(Into::into).collect(), + extensions: value.extensions.into_iter().map(Into::into).collect(), + } + } +} + +impl From for structs::DmarcReport { + fn from(value: Report) -> Self { + structs::DmarcReport { + version: (value.version as f64).into(), + date_range_begin: UTCDateTime::from_timestamp( + value.report_metadata.date_range.begin as i64, + ), + date_range_end: UTCDateTime::from_timestamp( + value.report_metadata.date_range.end as i64, + ), + email: value.report_metadata.email, + errors: value.report_metadata.error.into(), + extensions: List::from_iter(value.extensions.into_iter().map(Into::into)), + extra_contact_info: value.report_metadata.extra_contact_info, + generator: value.report_metadata.generator, + org_name: value.report_metadata.org_name, + policy_adkim: value.policy_published.adkim.into(), + policy_aspf: value.policy_published.aspf.into(), + policy_discovery_method: value.policy_published.discovery_method.into(), + policy_disposition: value.policy_published.p.into(), + policy_domain: value.policy_published.domain, + policy_failure_reporting_options: fo_to_failure_reporting_options( + &value.policy_published.fo, + ) + .into(), + policy_np: value.policy_published.np.into(), + policy_subdomain_disposition: value.policy_published.sp.into(), + policy_testing_mode: value.policy_published.testing, + policy_version: value + .policy_published + .version_published + .map(|v| v.to_string()), + records: List::from_iter(value.record.into_iter().map(Into::into)), + report_id: value.report_metadata.report_id, + } + } +} + +impl From for AuthFailureType { + fn from(value: enums::ArfAuthFailureType) -> Self { + match value { + enums::ArfAuthFailureType::Adsp => AuthFailureType::Adsp, + enums::ArfAuthFailureType::BodyHash => AuthFailureType::BodyHash, + enums::ArfAuthFailureType::Revoked => AuthFailureType::Revoked, + enums::ArfAuthFailureType::Signature => AuthFailureType::Signature, + enums::ArfAuthFailureType::Spf => AuthFailureType::Spf, + enums::ArfAuthFailureType::Dmarc => AuthFailureType::Dmarc, + enums::ArfAuthFailureType::Unspecified => AuthFailureType::Unspecified, + } + } +} + +impl From for enums::ArfAuthFailureType { + fn from(value: AuthFailureType) -> Self { + match value { + AuthFailureType::Adsp => enums::ArfAuthFailureType::Adsp, + AuthFailureType::BodyHash => enums::ArfAuthFailureType::BodyHash, + AuthFailureType::Revoked => enums::ArfAuthFailureType::Revoked, + AuthFailureType::Signature => enums::ArfAuthFailureType::Signature, + AuthFailureType::Spf => enums::ArfAuthFailureType::Spf, + AuthFailureType::Dmarc => enums::ArfAuthFailureType::Dmarc, + AuthFailureType::Unspecified => enums::ArfAuthFailureType::Unspecified, + } + } +} + +impl From for DeliveryResult { + fn from(value: enums::ArfDeliveryResult) -> Self { + match value { + enums::ArfDeliveryResult::Delivered => DeliveryResult::Delivered, + enums::ArfDeliveryResult::Spam => DeliveryResult::Spam, + enums::ArfDeliveryResult::Policy => DeliveryResult::Policy, + enums::ArfDeliveryResult::Reject => DeliveryResult::Reject, + enums::ArfDeliveryResult::Other => DeliveryResult::Other, + enums::ArfDeliveryResult::Unspecified => DeliveryResult::Unspecified, + } + } +} + +impl From for enums::ArfDeliveryResult { + fn from(value: DeliveryResult) -> Self { + match value { + DeliveryResult::Delivered => enums::ArfDeliveryResult::Delivered, + DeliveryResult::Spam => enums::ArfDeliveryResult::Spam, + DeliveryResult::Policy => enums::ArfDeliveryResult::Policy, + DeliveryResult::Reject => enums::ArfDeliveryResult::Reject, + DeliveryResult::Other => enums::ArfDeliveryResult::Other, + DeliveryResult::Unspecified => enums::ArfDeliveryResult::Unspecified, + } + } +} + +impl From for FeedbackType { + fn from(value: enums::ArfFeedbackType) -> Self { + match value { + enums::ArfFeedbackType::Abuse => FeedbackType::Abuse, + enums::ArfFeedbackType::AuthFailure => FeedbackType::AuthFailure, + enums::ArfFeedbackType::Fraud => FeedbackType::Fraud, + enums::ArfFeedbackType::NotSpam => FeedbackType::NotSpam, + enums::ArfFeedbackType::Virus => FeedbackType::Virus, + enums::ArfFeedbackType::Other => FeedbackType::Other, + } + } +} + +impl From for enums::ArfFeedbackType { + fn from(value: FeedbackType) -> Self { + match value { + FeedbackType::Abuse => enums::ArfFeedbackType::Abuse, + FeedbackType::AuthFailure => enums::ArfFeedbackType::AuthFailure, + FeedbackType::Fraud => enums::ArfFeedbackType::Fraud, + FeedbackType::NotSpam => enums::ArfFeedbackType::NotSpam, + FeedbackType::Virus => enums::ArfFeedbackType::Virus, + FeedbackType::Other => enums::ArfFeedbackType::Other, + } + } +} + +impl From for IdentityAlignment { + fn from(value: enums::ArfIdentityAlignment) -> Self { + match value { + enums::ArfIdentityAlignment::None => IdentityAlignment::None, + enums::ArfIdentityAlignment::Spf => IdentityAlignment::Spf, + enums::ArfIdentityAlignment::Dkim => IdentityAlignment::Dkim, + enums::ArfIdentityAlignment::DkimSpf => IdentityAlignment::DkimSpf, + enums::ArfIdentityAlignment::Unspecified => IdentityAlignment::Unspecified, + } + } +} + +impl From for enums::ArfIdentityAlignment { + fn from(value: IdentityAlignment) -> Self { + match value { + IdentityAlignment::None => enums::ArfIdentityAlignment::None, + IdentityAlignment::Spf => enums::ArfIdentityAlignment::Spf, + IdentityAlignment::Dkim => enums::ArfIdentityAlignment::Dkim, + IdentityAlignment::DkimSpf => enums::ArfIdentityAlignment::DkimSpf, + IdentityAlignment::Unspecified => enums::ArfIdentityAlignment::Unspecified, + } + } +} + +impl From for Feedback<'static> { + fn from(value: structs::ArfFeedbackReport) -> Self { + Feedback { + feedback_type: value.feedback_type.into(), + arrival_date: value.arrival_date.map(|d| d.timestamp()), + authentication_results: value + .authentication_results + .into_inner() + .into_iter() + .map(Cow::Owned) + .collect(), + incidents: value.incidents as u32, + original_envelope_id: value.original_envelope_id.map(Cow::Owned), + original_mail_from: value.original_mail_from.map(Cow::Owned), + original_rcpt_to: value.original_rcpt_to.map(Cow::Owned), + reported_domain: value + .reported_domains + .into_inner() + .into_iter() + .map(Cow::Owned) + .collect(), + reported_uri: value + .reported_uris + .into_inner() + .into_iter() + .map(Cow::Owned) + .collect(), + reporting_mta: value.reporting_mta.map(Cow::Owned), + source_ip: value.source_ip.map(|ip| ip.into_inner()), + user_agent: value.user_agent.map(Cow::Owned), + version: value.version as u32, + source_port: value.source_port.unwrap_or(0) as u32, + auth_failure: value.auth_failure.into(), + delivery_result: value.delivery_result.into(), + dkim_adsp_dns: value.dkim_adsp_dns.map(Cow::Owned), + dkim_canonicalized_body: value.dkim_canonicalized_body.map(Cow::Owned), + dkim_canonicalized_header: value.dkim_canonicalized_header.map(Cow::Owned), + dkim_domain: value.dkim_domain.map(Cow::Owned), + dkim_identity: value.dkim_identity.map(Cow::Owned), + dkim_selector: value.dkim_selector.map(Cow::Owned), + dkim_selector_dns: value.dkim_selector_dns.map(Cow::Owned), + spf_dns: value.spf_dns.map(Cow::Owned), + identity_alignment: value.identity_alignment.into(), + message: value.message.map(Cow::Owned), + headers: value.headers.map(Cow::Owned), + } + } +} + +impl From> for structs::ArfFeedbackReport { + fn from(value: Feedback<'_>) -> Self { + let port = value.source_port; + structs::ArfFeedbackReport { + arrival_date: value.arrival_date.map(UTCDateTime::from_timestamp), + auth_failure: value.auth_failure.into(), + authentication_results: value + .authentication_results + .into_iter() + .map(|s| s.into_owned()) + .collect::>() + .into(), + delivery_result: value.delivery_result.into(), + dkim_adsp_dns: value.dkim_adsp_dns.map(|s| s.into_owned()), + dkim_canonicalized_body: value.dkim_canonicalized_body.map(|s| s.into_owned()), + dkim_canonicalized_header: value.dkim_canonicalized_header.map(|s| s.into_owned()), + dkim_domain: value.dkim_domain.map(|s| s.into_owned()), + dkim_identity: value.dkim_identity.map(|s| s.into_owned()), + dkim_selector: value.dkim_selector.map(|s| s.into_owned()), + dkim_selector_dns: value.dkim_selector_dns.map(|s| s.into_owned()), + feedback_type: value.feedback_type.into(), + headers: value.headers.map(|s| s.into_owned()), + identity_alignment: value.identity_alignment.into(), + incidents: value.incidents as u64, + message: value.message.map(|s| s.into_owned()), + original_envelope_id: value.original_envelope_id.map(|s| s.into_owned()), + original_mail_from: value.original_mail_from.map(|s| s.into_owned()), + original_rcpt_to: value.original_rcpt_to.map(|s| s.into_owned()), + reported_domains: value + .reported_domain + .into_iter() + .map(|s| s.into_owned()) + .collect::>() + .into(), + reported_uris: value + .reported_uri + .into_iter() + .map(|s| s.into_owned()) + .collect::>() + .into(), + reporting_mta: value.reporting_mta.map(|s| s.into_owned()), + source_ip: value.source_ip.map(IpAddr), + source_port: if port == 0 || port > 65535 { + None + } else { + Some(port as u64) + }, + spf_dns: value.spf_dns.map(|s| s.into_owned()), + user_agent: value.user_agent.map(|s| s.into_owned()), + version: value.version as u64, + } + } +} + +impl From for PolicyType { + fn from(value: enums::TlsPolicyType) -> Self { + match value { + enums::TlsPolicyType::Tlsa => PolicyType::Tlsa, + enums::TlsPolicyType::Sts => PolicyType::Sts, + enums::TlsPolicyType::NoPolicyFound => PolicyType::NoPolicyFound, + enums::TlsPolicyType::Other => PolicyType::Other, + } + } +} + +impl From for enums::TlsPolicyType { + fn from(value: PolicyType) -> Self { + match value { + PolicyType::Tlsa => enums::TlsPolicyType::Tlsa, + PolicyType::Sts => enums::TlsPolicyType::Sts, + PolicyType::NoPolicyFound => enums::TlsPolicyType::NoPolicyFound, + PolicyType::Other => enums::TlsPolicyType::Other, + } + } +} + +impl From for ResultType { + fn from(value: enums::TlsResultType) -> Self { + match value { + enums::TlsResultType::StartTlsNotSupported => ResultType::StartTlsNotSupported, + enums::TlsResultType::CertificateHostMismatch => ResultType::CertificateHostMismatch, + enums::TlsResultType::CertificateExpired => ResultType::CertificateExpired, + enums::TlsResultType::CertificateNotTrusted => ResultType::CertificateNotTrusted, + enums::TlsResultType::ValidationFailure => ResultType::ValidationFailure, + enums::TlsResultType::TlsaInvalid => ResultType::TlsaInvalid, + enums::TlsResultType::DnssecInvalid => ResultType::DnssecInvalid, + enums::TlsResultType::DaneRequired => ResultType::DaneRequired, + enums::TlsResultType::StsPolicyFetchError => ResultType::StsPolicyFetchError, + enums::TlsResultType::StsPolicyInvalid => ResultType::StsPolicyInvalid, + enums::TlsResultType::StsWebpkiInvalid => ResultType::StsWebpkiInvalid, + enums::TlsResultType::Other => ResultType::Other, + } + } +} + +impl From for enums::TlsResultType { + fn from(value: ResultType) -> Self { + match value { + ResultType::StartTlsNotSupported => enums::TlsResultType::StartTlsNotSupported, + ResultType::CertificateHostMismatch => enums::TlsResultType::CertificateHostMismatch, + ResultType::CertificateExpired => enums::TlsResultType::CertificateExpired, + ResultType::CertificateNotTrusted => enums::TlsResultType::CertificateNotTrusted, + ResultType::ValidationFailure => enums::TlsResultType::ValidationFailure, + ResultType::TlsaInvalid => enums::TlsResultType::TlsaInvalid, + ResultType::DnssecInvalid => enums::TlsResultType::DnssecInvalid, + ResultType::DaneRequired => enums::TlsResultType::DaneRequired, + ResultType::StsPolicyFetchError => enums::TlsResultType::StsPolicyFetchError, + ResultType::StsPolicyInvalid => enums::TlsResultType::StsPolicyInvalid, + ResultType::StsWebpkiInvalid => enums::TlsResultType::StsWebpkiInvalid, + ResultType::Other => enums::TlsResultType::Other, + } + } +} + +impl From for FailureDetails { + fn from(value: structs::TlsFailureDetails) -> Self { + FailureDetails { + result_type: value.result_type.into(), + sending_mta_ip: value.sending_mta_ip.map(|ip| ip.into_inner()), + receiving_mx_hostname: value.receiving_mx_hostname, + receiving_mx_helo: value.receiving_mx_helo, + receiving_ip: value.receiving_ip.map(|ip| ip.into_inner()), + failed_session_count: value.failed_session_count as u32, + additional_information: value.additional_information, + failure_reason_code: value.failure_reason_code, + } + } +} + +impl From for structs::TlsFailureDetails { + fn from(value: FailureDetails) -> Self { + structs::TlsFailureDetails { + result_type: value.result_type.into(), + sending_mta_ip: value.sending_mta_ip.map(IpAddr), + receiving_mx_hostname: value.receiving_mx_hostname, + receiving_mx_helo: value.receiving_mx_helo, + receiving_ip: value.receiving_ip.map(IpAddr), + failed_session_count: value.failed_session_count as u64, + additional_information: value.additional_information, + failure_reason_code: value.failure_reason_code, + } + } +} + +impl From for Policy { + fn from(value: structs::TlsReportPolicy) -> Self { + Policy { + policy: PolicyDetails { + policy_type: value.policy_type.into(), + policy_string: value.policy_strings.into_inner(), + policy_domain: value.policy_domain, + mx_host: value.mx_hosts.into_inner(), + }, + summary: Summary { + total_success: value.total_successful_sessions as u32, + total_failure: value.total_failed_sessions as u32, + }, + failure_details: value.failure_details.into_iter().map(Into::into).collect(), + } + } +} + +impl From for structs::TlsReportPolicy { + fn from(value: Policy) -> Self { + structs::TlsReportPolicy { + policy_type: value.policy.policy_type.into(), + policy_strings: value.policy.policy_string.into(), + policy_domain: value.policy.policy_domain, + mx_hosts: value.policy.mx_host.into(), + total_successful_sessions: value.summary.total_success as u64, + total_failed_sessions: value.summary.total_failure as u64, + failure_details: List::from_iter(value.failure_details.into_iter().map(Into::into)), + } + } +} + +impl From for TlsReport { + fn from(value: structs::TlsReport) -> Self { + TlsReport { + organization_name: value.organization_name, + date_range: mail_auth::report::tlsrpt::DateRange::from_timestamps( + value.date_range_start.timestamp(), + value.date_range_end.timestamp(), + ), + contact_info: value.contact_info, + report_id: value.report_id, + policies: value.policies.into_iter().map(Into::into).collect(), + } + } +} + +impl From for structs::TlsReport { + fn from(value: TlsReport) -> Self { + structs::TlsReport { + organization_name: value.organization_name, + date_range_start: UTCDateTime::from_timestamp( + value.date_range.start_datetime.to_timestamp(), + ), + date_range_end: UTCDateTime::from_timestamp( + value.date_range.end_datetime.to_timestamp(), + ), + contact_info: value.contact_info, + report_id: value.report_id, + policies: List::from_iter(value.policies.into_iter().map(Into::into)), + } + } +} + +fn failure_reporting_options_to_fo(opts: &[enums::FailureReportingOption]) -> Option { + let opts_len = opts.len(); + if opts_len > 0 { + let mut out = String::with_capacity(opts_len * 2); + for (i, o) in opts.iter().enumerate() { + if i > 0 { + out.push(':'); + } + match o { + enums::FailureReportingOption::All => out.push('0'), + enums::FailureReportingOption::Any => out.push('1'), + enums::FailureReportingOption::DkimFailure => out.push('d'), + enums::FailureReportingOption::SpfFailure => out.push('s'), + } + } + Some(out) + } else { + None + } +} + +fn fo_to_failure_reporting_options(fo: &Option) -> Vec { + match fo { + None => vec![], + Some(s) if s.is_empty() => vec![], + Some(s) => s + .split(':') + .filter_map(|token| match token.trim() { + "0" => Some(enums::FailureReportingOption::All), + "1" => Some(enums::FailureReportingOption::Any), + "d" => Some(enums::FailureReportingOption::DkimFailure), + "s" => Some(enums::FailureReportingOption::SpfFailure), + _ => None, + }) + .collect(), + } +} + +impl From<&SpfOutput> for structs::DmarcTroubleshootAuthResult { + fn from(value: &SpfOutput) -> Self { + match value.result() { + mail_auth::SpfResult::Pass => structs::DmarcTroubleshootAuthResult::Pass, + mail_auth::SpfResult::Fail => { + structs::DmarcTroubleshootAuthResult::Fail(structs::DmarcTroubleshootDetails { + details: value.explanation().map(|e| e.to_string()), + }) + } + mail_auth::SpfResult::SoftFail => { + structs::DmarcTroubleshootAuthResult::SoftFail(structs::DmarcTroubleshootDetails { + details: value.explanation().map(|e| e.to_string()), + }) + } + mail_auth::SpfResult::Neutral => { + structs::DmarcTroubleshootAuthResult::Neutral(structs::DmarcTroubleshootDetails { + details: value.explanation().map(|e| e.to_string()), + }) + } + mail_auth::SpfResult::TempError => { + structs::DmarcTroubleshootAuthResult::TempError(structs::DmarcTroubleshootDetails { + details: value.explanation().map(|e| e.to_string()), + }) + } + mail_auth::SpfResult::PermError => { + structs::DmarcTroubleshootAuthResult::PermError(structs::DmarcTroubleshootDetails { + details: value.explanation().map(|e| e.to_string()), + }) + } + mail_auth::SpfResult::None => structs::DmarcTroubleshootAuthResult::None, + } + } +} + +impl From<&IprevOutput> for structs::DmarcTroubleshootAuthResult { + fn from(value: &IprevOutput) -> Self { + match &value.result { + IprevResult::Pass => structs::DmarcTroubleshootAuthResult::Pass, + IprevResult::Fail(error) => { + structs::DmarcTroubleshootAuthResult::Fail(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + IprevResult::TempError(error) => { + structs::DmarcTroubleshootAuthResult::TempError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + IprevResult::PermError(error) => { + structs::DmarcTroubleshootAuthResult::PermError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + IprevResult::None => structs::DmarcTroubleshootAuthResult::None, + } + } +} + +impl From<&mail_auth::DkimResult> for structs::DmarcTroubleshootAuthResult { + fn from(value: &mail_auth::DkimResult) -> Self { + match value { + mail_auth::DkimResult::Pass => structs::DmarcTroubleshootAuthResult::Pass, + mail_auth::DkimResult::Neutral(error) => { + structs::DmarcTroubleshootAuthResult::Neutral(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DkimResult::Fail(error) => { + structs::DmarcTroubleshootAuthResult::Fail(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DkimResult::PermError(error) => { + structs::DmarcTroubleshootAuthResult::PermError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DkimResult::TempError(error) => { + structs::DmarcTroubleshootAuthResult::TempError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DkimResult::None => structs::DmarcTroubleshootAuthResult::None, + } + } +} + +impl From<&mail_auth::Dkim2Result> for structs::DmarcTroubleshootAuthResult { + fn from(value: &mail_auth::Dkim2Result) -> Self { + match value { + mail_auth::Dkim2Result::Pass => structs::DmarcTroubleshootAuthResult::Pass, + mail_auth::Dkim2Result::Fail(error) => { + structs::DmarcTroubleshootAuthResult::Fail(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::Dkim2Result::PermError(error) => { + structs::DmarcTroubleshootAuthResult::PermError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::Dkim2Result::TempError(error) => { + structs::DmarcTroubleshootAuthResult::TempError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::Dkim2Result::None => structs::DmarcTroubleshootAuthResult::None, + } + } +} + +impl From<&mail_auth::DmarcResult> for structs::DmarcTroubleshootAuthResult { + fn from(value: &mail_auth::DmarcResult) -> Self { + match value { + mail_auth::DmarcResult::Pass => structs::DmarcTroubleshootAuthResult::Pass, + mail_auth::DmarcResult::Fail(error) => { + structs::DmarcTroubleshootAuthResult::Fail(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DmarcResult::TempError(error) => { + structs::DmarcTroubleshootAuthResult::TempError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DmarcResult::PermError(error) => { + structs::DmarcTroubleshootAuthResult::PermError(structs::DmarcTroubleshootDetails { + details: error.to_string().into(), + }) + } + mail_auth::DmarcResult::None => structs::DmarcTroubleshootAuthResult::None, + } + } +} + +impl From<&mail_auth::dmarc::Policy> for enums::DmarcDisposition { + fn from(value: &mail_auth::dmarc::Policy) -> Self { + match value { + mail_auth::dmarc::Policy::None => enums::DmarcDisposition::None, + mail_auth::dmarc::Policy::Quarantine => enums::DmarcDisposition::Quarantine, + mail_auth::dmarc::Policy::Reject => enums::DmarcDisposition::Reject, + mail_auth::dmarc::Policy::Unspecified => enums::DmarcDisposition::Unspecified, + } + } +} + +impl DmarcReportRecord { + pub fn eq_except_count(&self, other: &Self) -> bool { + self.dkim_results == other.dkim_results + && self.envelope_from == other.envelope_from + && self.envelope_to == other.envelope_to + && self.evaluated_disposition == other.evaluated_disposition + && self.evaluated_dkim == other.evaluated_dkim + && self.evaluated_spf == other.evaluated_spf + && self.extensions == other.extensions + && self.header_from == other.header_from + && self.policy_override_reasons == other.policy_override_reasons + && self.source_ip == other.source_ip + && self.spf_results == other.spf_results + } +} + +impl TlsFailureDetails { + pub fn eq_except_count(&self, other: &Self) -> bool { + self.additional_information == other.additional_information + && self.failure_reason_code == other.failure_reason_code + && self.receiving_ip == other.receiving_ip + && self.receiving_mx_helo == other.receiving_mx_helo + && self.receiving_mx_hostname == other.receiving_mx_hostname + && self.result_type == other.result_type + && self.sending_mta_ip == other.sending_mta_ip + } +} diff --git a/crates/registry/src/utils/secret.rs b/crates/registry/src/utils/secret.rs new file mode 100644 index 0000000..6956305 --- /dev/null +++ b/crates/registry/src/utils/secret.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::{ + PublicStringOptional, PublicStringValue, PublicText, SecretKey, SecretKeyEnvironmentVariable, + SecretKeyFile, SecretKeyOptional, SecretKeyValue, SecretText, SecretTextOptional, + SecretTextValue, +}; +use std::borrow::Cow; + +impl SecretKey { + pub async fn secret(&self) -> Result, String> { + match self { + SecretKey::Value(value) => Ok(Cow::Borrowed(value.secret())), + SecretKey::File(file) => file.secret().await.map(Cow::Owned), + SecretKey::EnvironmentVariable(env_var) => env_var.secret().map(Cow::Owned), + } + } +} + +impl SecretText { + pub async fn secret(&self) -> Result, String> { + match self { + SecretText::Text(value) => Ok(Cow::Borrowed(value.secret())), + SecretText::File(file) => file.secret().await.map(Cow::Owned), + SecretText::EnvironmentVariable(env_var) => env_var.secret().map(Cow::Owned), + } + } +} + +impl PublicText { + pub async fn value(&self) -> Result, String> { + match self { + PublicText::Text(value) => Ok(Cow::Borrowed(value.value.as_str())), + PublicText::File(file) => file.secret().await.map(Cow::Owned), + PublicText::EnvironmentVariable(env_var) => env_var.secret().map(Cow::Owned), + } + } +} + +impl SecretKeyOptional { + pub async fn secret(&self) -> Result>, String> { + match self { + SecretKeyOptional::None => Ok(None), + SecretKeyOptional::Value(secret_key_value) => { + Ok(Some(Cow::Borrowed(secret_key_value.secret()))) + } + SecretKeyOptional::EnvironmentVariable(secret_key_environment_variable) => { + secret_key_environment_variable + .secret() + .map(|s| Some(Cow::Owned(s))) + } + SecretKeyOptional::File(secret_key_file) => { + secret_key_file.secret().await.map(|s| Some(Cow::Owned(s))) + } + } + } +} + +impl PublicStringOptional { + pub async fn value(&self) -> Result>, String> { + match self { + PublicStringOptional::None => Ok(None), + PublicStringOptional::Value(public_string_value) => { + Ok(Some(Cow::Borrowed(public_string_value.value()))) + } + PublicStringOptional::EnvironmentVariable(secret_key_environment_variable) => { + secret_key_environment_variable + .secret() + .map(|s| Some(Cow::Owned(s))) + } + PublicStringOptional::File(secret_key_file) => { + secret_key_file.secret().await.map(|s| Some(Cow::Owned(s))) + } + } + } +} + +impl PublicStringValue { + pub fn value(&self) -> &str { + self.value.as_str() + } +} + +impl SecretTextOptional { + pub async fn secret(&self) -> Result>, String> { + match self { + SecretTextOptional::None => Ok(None), + SecretTextOptional::Text(secret_text_value) => { + Ok(Some(Cow::Borrowed(secret_text_value.secret()))) + } + SecretTextOptional::EnvironmentVariable(secret_text_environment_variable) => { + secret_text_environment_variable + .secret() + .map(|s| Some(Cow::Owned(s))) + } + SecretTextOptional::File(secret_text_file) => { + secret_text_file.secret().await.map(|s| Some(Cow::Owned(s))) + } + } + } +} + +impl SecretKeyValue { + pub fn secret(&self) -> &str { + self.secret.as_str() + } +} + +impl SecretTextValue { + pub fn secret(&self) -> &str { + self.secret.as_str() + } +} + +impl SecretKeyFile { + pub async fn secret(&self) -> Result { + let path = self.file_path.trim(); + if !path.is_empty() { + tokio::fs::read_to_string(path) + .await + .map_err(|err| format!("Failed to read secret from file '{}': {}", path, err)) + .and_then(|content| { + let secret = content.trim_end(); + if !secret.is_empty() { + Ok(secret.to_string()) + } else { + Err(format!("Secret in file '{}' is empty", path)) + } + }) + } else { + Err("File path cannot be empty".to_string()) + } + } +} + +impl SecretKeyEnvironmentVariable { + pub fn secret(&self) -> Result { + let var = self.variable_name.trim(); + if !var.is_empty() { + std::env::var(var) + .ok() + .filter(|v| !v.is_empty()) + .ok_or_else(|| format!("Environment variable '{}' not found", var)) + } else { + Err("Variable name cannot be empty".to_string()) + } + } +} diff --git a/crates/registry/src/utils/task.rs b/crates/registry/src/utils/task.rs new file mode 100644 index 0000000..97cbd7c --- /dev/null +++ b/crates/registry/src/utils/task.rs @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::{ + enums::Permission, + prelude::{Action, Task, TaskStatus, TaskStatusPending, UTCDateTime}, +}; + +impl Task { + pub fn set_status(&mut self, status: TaskStatus) { + match self { + Task::IndexDocument(task) => task.status = status, + Task::UnindexDocument(task) => task.status = status, + Task::IndexTrace(task) => task.status = status, + Task::CalendarAlarmEmail(task) => task.status = status, + Task::CalendarAlarmNotification(task) => task.status = status, + Task::CalendarItipMessage(task) => task.status = status, + Task::MergeThreads(task) => task.status = status, + Task::DmarcReport(task) => task.status = status, + Task::TlsReport(task) => task.status = status, + Task::RestoreArchivedItem(task) => task.status = status, + Task::DestroyAccount(task) => task.status = status, + Task::AccountMaintenance(task) => task.status = status, + Task::StoreMaintenance(task) => task.status = status, + Task::SpamFilterMaintenance(task) => task.status = status, + Task::AcmeRenewal(task) => task.status = status, + Task::DkimManagement(task) => task.status = status, + Task::DnsManagement(task) => task.status = status, + Task::TenantMaintenance(task) => task.status = status, + } + } + + pub fn status(&self) -> &TaskStatus { + match self { + Task::IndexDocument(task) => &task.status, + Task::UnindexDocument(task) => &task.status, + Task::IndexTrace(task) => &task.status, + Task::CalendarAlarmEmail(task) => &task.status, + Task::CalendarAlarmNotification(task) => &task.status, + Task::CalendarItipMessage(task) => &task.status, + Task::MergeThreads(task) => &task.status, + Task::DmarcReport(task) => &task.status, + Task::TlsReport(task) => &task.status, + Task::RestoreArchivedItem(task) => &task.status, + Task::DestroyAccount(task) => &task.status, + Task::AccountMaintenance(task) => &task.status, + Task::StoreMaintenance(task) => &task.status, + Task::SpamFilterMaintenance(task) => &task.status, + Task::AcmeRenewal(task) => &task.status, + Task::DkimManagement(task) => &task.status, + Task::DnsManagement(task) => &task.status, + Task::TenantMaintenance(task) => &task.status, + } + } + + pub fn attempt_number(&self) -> u64 { + match self.status() { + TaskStatus::Pending(_) => 0, + TaskStatus::Retry(status) => status.attempt_number, + TaskStatus::Failed(status) => status.failed_attempt_number, + } + } + + pub fn due_timestamp(&self) -> u64 { + match self.status() { + TaskStatus::Pending(status) => status.due.timestamp() as u64, + TaskStatus::Retry(status) => status.due.timestamp() as u64, + TaskStatus::Failed(_) => u64::MAX, + } + } + + pub fn permission(&self) -> Permission { + match self { + Task::IndexDocument(_) => Permission::TaskIndexDocument, + Task::UnindexDocument(_) => Permission::TaskUnindexDocument, + Task::IndexTrace(_) => Permission::TaskIndexTrace, + Task::CalendarAlarmEmail(_) => Permission::TaskCalendarAlarmEmail, + Task::CalendarAlarmNotification(_) => Permission::TaskCalendarAlarmNotification, + Task::CalendarItipMessage(_) => Permission::TaskCalendarItipMessage, + Task::MergeThreads(_) => Permission::TaskMergeThreads, + Task::DmarcReport(_) => Permission::TaskDmarcReport, + Task::TlsReport(_) => Permission::TaskTlsReport, + Task::RestoreArchivedItem(_) => Permission::TaskRestoreArchivedItem, + Task::DestroyAccount(_) => Permission::TaskDestroyAccount, + Task::AccountMaintenance(_) => Permission::TaskAccountMaintenance, + Task::StoreMaintenance(_) => Permission::TaskStoreMaintenance, + Task::SpamFilterMaintenance(_) => Permission::TaskSpamFilterMaintenance, + Task::AcmeRenewal(_) => Permission::TaskAcmeRenewal, + Task::DkimManagement(_) => Permission::TaskDkimManagement, + Task::DnsManagement(_) => Permission::TaskDnsManagement, + Task::TenantMaintenance(_) => Permission::TaskTenantMaintenance, + } + } +} + +impl Action { + pub fn permission(&self) -> Permission { + match self { + Action::ReloadSettings => Permission::ActionReloadSettings, + Action::ReloadTlsCertificates => Permission::ActionReloadTlsCertificates, + Action::ReloadLookupStores => Permission::ActionReloadLookupStores, + Action::ReloadBlockedIps => Permission::ActionReloadBlockedIps, + Action::TroubleshootDmarc(_) => Permission::ActionTroubleshootDmarc, + Action::ClassifySpam(_) => Permission::ActionClassifySpam, + Action::InvalidateCaches => Permission::ActionInvalidateCaches, + Action::InvalidateNegativeCaches => Permission::ActionInvalidateNegativeCaches, + Action::PauseMtaQueue => Permission::ActionPauseMtaQueue, + Action::ResumeMtaQueue => Permission::ActionResumeMtaQueue, + Action::UpdateApps => Permission::ActionUpdateApps, + } + } +} + +impl TaskStatus { + pub fn now() -> Self { + let now = UTCDateTime::now(); + TaskStatus::Pending(TaskStatusPending { + created_at: now, + due: now, + }) + } + + pub fn at(timestamp: i64) -> Self { + TaskStatus::Pending(TaskStatusPending { + due: UTCDateTime::from_timestamp(timestamp), + created_at: UTCDateTime::now(), + }) + } +} diff --git a/crates/scim-proto/Cargo.toml b/crates/scim-proto/Cargo.toml new file mode 100644 index 0000000..42b037c --- /dev/null +++ b/crates/scim-proto/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "scim-proto" +version = "0.16.22" +edition = "2024" + +[dependencies] +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +hashify = "0.2.9" + +[lints] +workspace = true diff --git a/crates/scim-proto/src/lib.rs b/crates/scim-proto/src/lib.rs new file mode 100644 index 0000000..698407d --- /dev/null +++ b/crates/scim-proto/src/lib.rs @@ -0,0 +1,5 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ diff --git a/crates/scim/Cargo.toml b/crates/scim/Cargo.toml new file mode 100644 index 0000000..91cc716 --- /dev/null +++ b/crates/scim/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "scim" +version = "0.16.22" +edition = "2024" + +[dependencies] +scim-proto = { path = "../scim-proto" } +common = { path = "../common" } +jmap = { path = "../jmap" } +store = { path = "../store" } +registry = { path = "../registry" } +directory = { path = "../directory" } +http_proto = { path = "../http-proto" } +jmap_proto = { path = "../jmap-proto" } +types = { path = "../types" } +utils = { path = "../utils" } +trc = { path = "../trc" } +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +xxhash-rust = { version = "0.8.18", features = ["xxh3"] } +icu_locale = "2.3.1" + +[dev-dependencies] + +[features] +test_mode = [] +dev_mode = [] +enterprise = ["common/enterprise", "jmap/enterprise", "store/enterprise", "directory/enterprise", "trc/enterprise"] + +[lints] +workspace = true diff --git a/crates/scim/src/lib.rs b/crates/scim/src/lib.rs new file mode 100644 index 0000000..698407d --- /dev/null +++ b/crates/scim/src/lib.rs @@ -0,0 +1,5 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml new file mode 100644 index 0000000..868380f --- /dev/null +++ b/crates/services/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "services" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +common = { path = "../common" } +utils = { path = "../utils" } +trc = { path = "../trc" } +email = { path = "../email" } +smtp = { path = "../smtp" } +groupware = { path = "../groupware" } +spam-filter = { path = "../spam-filter" } +types = { path = "../types" } +jmap_proto = { path = "../jmap-proto" } +jmap-tools = { version = "0.1" } +registry = { path = "../registry" } +smtp-proto = { version = "0.2", features = ["rkyv", "serde"] } +tokio = { version = "1.53", features = ["rt"] } +mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +mail-builder = { version = "1.0" } +aho-corasick = { version = "1.1" } +calcard = { version = "0.3", features = ["rkyv"] } +serde_json = "1.0" +memory-stats = "1.2.0" +aes-gcm = "0.11.1" +p256 = { version = "0.13", features = ["ecdh"] } +hkdf = "0.13" +sha2 = "0.11" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]} +base64 = "0.23" +dns-update = { version = "0.5" } +psl = "2" + +[dev-dependencies] + +[features] +test_mode = [] +enterprise = [] + +[lints] +workspace = true diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs new file mode 100644 index 0000000..108b413 --- /dev/null +++ b/crates/services/src/broadcast/mod.rs @@ -0,0 +1,287 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::ipc::{ + BroadcastEvent, CacheInvalidation, CalendarAlert, EmailPush, PushNotification, RegistryChange, +}; +use registry::{ + schema::prelude::ObjectType, + types::{EnumImpl, id::ObjectId}, +}; +use std::{borrow::Borrow, io::Write}; +use types::{id::Id, type_state::StateChange}; +use utils::{ + codec::leb128::{Leb128Iterator, Leb128Writer}, + map::bitmap::Bitmap, +}; + +pub mod publisher; +pub mod subscriber; + +#[derive(Debug)] +pub(crate) struct BroadcastBatch { + messages: T, +} + +const MAX_BATCH_SIZE: usize = 100; +pub(crate) const BROADCAST_TOPIC: &str = "stwt.agora"; + +impl BroadcastBatch> { + pub fn init() -> Self { + Self { + messages: Vec::with_capacity(MAX_BATCH_SIZE), + } + } + + pub fn insert(&mut self, message: BroadcastEvent) -> bool { + self.messages.push(message); + self.messages.len() < MAX_BATCH_SIZE + } + + pub fn serialize(&self, node_id: u16) -> Vec { + let mut serialized = + Vec::with_capacity((self.messages.len() * 10) + std::mem::size_of::()); + let _ = serialized.write_leb128(node_id); + for message in &self.messages { + match message { + BroadcastEvent::PushNotification(notification) => match notification { + PushNotification::StateChange(state_change) => { + serialized.push(0u8); + let _ = serialized.write_leb128(state_change.change_id); + let _ = serialized.write_leb128(*state_change.types.as_ref()); + let _ = serialized.write_leb128(state_change.account_id); + } + PushNotification::CalendarAlert(calendar_alert) => { + serialized.push(1u8); + let _ = serialized.write_leb128(calendar_alert.account_id); + let _ = serialized.write_leb128(calendar_alert.event_id); + let _ = serialized + .write_leb128(calendar_alert.recurrence_id.unwrap_or_default() as u64); + let _ = serialized.write_leb128(calendar_alert.uid.len()); + let _ = serialized.write(calendar_alert.uid.as_bytes()); + let _ = serialized.write_leb128(calendar_alert.alert_id.len()); + let _ = serialized.write(calendar_alert.alert_id.as_bytes()); + } + PushNotification::EmailPush(email_push) => { + serialized.push(2u8); + let _ = serialized.write_leb128(email_push.account_id); + let _ = serialized.write_leb128(email_push.email_id); + let _ = serialized.write_leb128(email_push.change_id); + } + }, + BroadcastEvent::PushServerUpdate(account_id) => { + serialized.push(3u8); + let _ = serialized.write_leb128(*account_id); + } + BroadcastEvent::RegistryChange(items) => match items { + RegistryChange::Insert(id) => { + serialized.push(4u8); + let _ = serialized.write_leb128(id.object().to_id()); + let _ = serialized.write_leb128(id.id().id()); + } + RegistryChange::Delete(id) => { + serialized.push(5u8); + let _ = serialized.write_leb128(id.object().to_id()); + let _ = serialized.write_leb128(id.id().id()); + } + RegistryChange::Reload(object) => { + serialized.push(6u8); + let _ = serialized.write_leb128(object.to_id()); + } + }, + BroadcastEvent::CacheInvalidate(items) => { + serialized.push(7u8); + let _ = serialized.write_leb128(items.len()); + for item in items { + let (marker, id) = match item { + CacheInvalidation::AccessToken(id) => (0u8, *id), + CacheInvalidation::DavResources(id) => (1u8, *id), + CacheInvalidation::Domain(id) => (2u8, *id), + CacheInvalidation::Account(id) => (3u8, *id), + CacheInvalidation::DkimSignature(id) => (4u8, *id), + CacheInvalidation::Tenant(id) => (5u8, *id), + CacheInvalidation::Role(id) => (6u8, *id), + CacheInvalidation::List(id) => (7u8, *id), + CacheInvalidation::DomainLogo(id) => (8u8, *id), + CacheInvalidation::TenantLogo(id) => (9u8, *id), + CacheInvalidation::EmailNegative { + domain_id, + local_part_hash, + } => { + serialized.push(10u8); + let _ = serialized.write_leb128(*domain_id); + let _ = serialized.write_leb128(*local_part_hash); + continue; + } + CacheInvalidation::DomainNegative => (11u8, 0), + }; + + serialized.push(marker); + let _ = serialized.write_leb128(id); + } + } + BroadcastEvent::CacheInvalidateAll => { + serialized.push(8u8); + } + BroadcastEvent::CacheInvalidateNegative => { + serialized.push(9u8); + } + BroadcastEvent::MtaQueueStatus { is_running } => { + if *is_running { + serialized.push(10u8); + } else { + serialized.push(11u8); + } + } + BroadcastEvent::QueueRefresh => { + serialized.push(12u8); + } + } + } + serialized + } + + pub fn clear(&mut self) { + self.messages.clear(); + } +} + +impl BroadcastBatch +where + T: Iterator + Leb128Iterator, + I: Borrow, +{ + pub fn node_id(&mut self) -> Option { + self.messages.next_leb128::() + } + + pub fn next_event(&mut self) -> Result, ()> { + if let Some(id) = self.messages.next() { + match id.borrow() { + 0 => Ok(Some(BroadcastEvent::PushNotification( + PushNotification::StateChange(StateChange { + change_id: self.messages.next_leb128().ok_or(())?, + types: Bitmap::from(self.messages.next_leb128::().ok_or(())?), + account_id: self.messages.next_leb128().ok_or(())?, + }), + ))), + + 1 => { + let account_id = self.messages.next_leb128().ok_or(())?; + let event_id = self.messages.next_leb128().ok_or(())?; + let recurrence_id = self.messages.next_leb128::().ok_or(())? as i64; + let uid_len = self.messages.next_leb128::().ok_or(())?; + let mut uid_bytes = vec![0u8; uid_len]; + for byte in uid_bytes.iter_mut() { + *byte = self.messages.next().ok_or(())?.borrow().to_owned(); + } + let uid = String::from_utf8(uid_bytes).map_err(|_| ())?; + let alert_id_len = self.messages.next_leb128::().ok_or(())?; + let mut alert_id_bytes = vec![0u8; alert_id_len]; + for byte in alert_id_bytes.iter_mut() { + *byte = self.messages.next().ok_or(())?.borrow().to_owned(); + } + let alert_id = String::from_utf8(alert_id_bytes).map_err(|_| ())?; + Ok(Some(BroadcastEvent::PushNotification( + PushNotification::CalendarAlert(CalendarAlert { + account_id, + event_id, + recurrence_id: if recurrence_id == 0 { + None + } else { + Some(recurrence_id) + }, + uid, + alert_id, + }), + ))) + } + 2 => Ok(Some(BroadcastEvent::PushNotification( + PushNotification::EmailPush(EmailPush { + account_id: self.messages.next_leb128().ok_or(())?, + email_id: self.messages.next_leb128().ok_or(())?, + change_id: self.messages.next_leb128().ok_or(())?, + }), + ))), + 3 => { + let account_id = self.messages.next_leb128().ok_or(())?; + Ok(Some(BroadcastEvent::PushServerUpdate(account_id))) + } + 4 => { + let object_id = self.messages.next_leb128().ok_or(())?; + let id = self.messages.next_leb128::().ok_or(())?; + Ok(Some(BroadcastEvent::RegistryChange( + RegistryChange::Insert(ObjectId::new( + ObjectType::from_id(object_id).ok_or(())?, + Id::new(id), + )), + ))) + } + 5 => { + let object_id = self.messages.next_leb128().ok_or(())?; + let id = self.messages.next_leb128::().ok_or(())?; + Ok(Some(BroadcastEvent::RegistryChange( + RegistryChange::Delete(ObjectId::new( + ObjectType::from_id(object_id).ok_or(())?, + Id::new(id), + )), + ))) + } + 6 => { + let object_id = self.messages.next_leb128().ok_or(())?; + Ok(Some(BroadcastEvent::RegistryChange( + RegistryChange::Reload(ObjectType::from_id(object_id).ok_or(())?), + ))) + } + 7 => { + let count = self.messages.next_leb128::().ok_or(())?; + let mut items = Vec::with_capacity(count); + for _ in 0..count { + let marker = self.messages.next().ok_or(())?.borrow().to_owned(); + let id = self.messages.next_leb128::().ok_or(())?; + items.push(match marker { + 0 => CacheInvalidation::AccessToken(id), + 1 => CacheInvalidation::DavResources(id), + 2 => CacheInvalidation::Domain(id), + 3 => CacheInvalidation::Account(id), + 4 => CacheInvalidation::DkimSignature(id), + 5 => CacheInvalidation::Tenant(id), + 6 => CacheInvalidation::Role(id), + 7 => CacheInvalidation::List(id), + 8 => CacheInvalidation::DomainLogo(id), + 9 => CacheInvalidation::TenantLogo(id), + 10 => { + let local_part_hash = + self.messages.next_leb128::().ok_or(())?; + CacheInvalidation::EmailNegative { + domain_id: id, + local_part_hash, + } + } + 11 => CacheInvalidation::DomainNegative, + _ => return Err(()), + }); + } + Ok(Some(BroadcastEvent::CacheInvalidate(items))) + } + 8 => Ok(Some(BroadcastEvent::CacheInvalidateAll)), + 9 => Ok(Some(BroadcastEvent::CacheInvalidateNegative)), + 10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })), + 11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })), + 12 => Ok(Some(BroadcastEvent::QueueRefresh)), + _ => Err(()), + } + } else { + Ok(None) + } + } +} + +impl BroadcastBatch { + pub fn new(messages: T) -> Self { + Self { messages } + } +} diff --git a/crates/services/src/broadcast/publisher.rs b/crates/services/src/broadcast/publisher.rs new file mode 100644 index 0000000..cf81f28 --- /dev/null +++ b/crates/services/src/broadcast/publisher.rs @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use common::{Inner, ipc::BroadcastEvent}; +use tokio::sync::mpsc; +use trc::ClusterEvent; + +use super::{BROADCAST_TOPIC, BroadcastBatch}; + +pub fn spawn_broadcast_publisher(inner: Arc, mut event_rx: mpsc::Receiver) { + let (coordinator, this_node_id) = { + let _core = inner.shared_core.load(); + let coordinator = inner.shared_core.load().storage.coordinator.clone(); + if coordinator.is_none() { + return; + } + (coordinator, _core.network.node_id as u16) + }; + + tokio::spawn(async move { + let mut batch = BroadcastBatch::init(); + + trc::event!(Cluster(ClusterEvent::PublisherStart)); + + while let Some(event) = event_rx.recv().await { + batch.insert(event); + + while let Ok(event) = event_rx.try_recv() { + if !batch.insert(event) { + break; + } + } + + match coordinator + .publish(BROADCAST_TOPIC, batch.serialize(this_node_id)) + .await + { + Ok(_) => { + batch.clear(); + } + Err(err) => { + batch.clear(); + trc::event!(Cluster(ClusterEvent::PublisherError), CausedBy = err); + } + } + } + + trc::event!(Cluster(ClusterEvent::PublisherStop)); + }); +} diff --git a/crates/services/src/broadcast/subscriber.rs b/crates/services/src/broadcast/subscriber.rs new file mode 100644 index 0000000..d12f24b --- /dev/null +++ b/crates/services/src/broadcast/subscriber.rs @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::broadcast::{BROADCAST_TOPIC, BroadcastBatch}; +use common::{ + BuildServer, Inner, + ipc::{BroadcastEvent, PushEvent, PushNotification, QueueEvent, RegistryChange}, +}; +use registry::types::EnumImpl; +use std::{sync::Arc, time::Duration}; +use tokio::sync::watch; +use trc::{ClusterEvent, ServerEvent}; + +pub fn spawn_broadcast_subscriber(inner: Arc, mut shutdown_rx: watch::Receiver) { + let this_node_id = { + let _core = inner.shared_core.load(); + if _core.storage.coordinator.is_none() || _core.storage.registry.is_recovery_mode() { + return; + } + _core.network.node_id as u16 + }; + + tokio::spawn(async move { + let mut retry_count = 0; + + trc::event!(Cluster(ClusterEvent::SubscriberStart)); + + 'subscribe: loop { + let coordinator = inner.shared_core.load().storage.coordinator.clone(); + if coordinator.is_none() { + trc::event!( + Cluster(ClusterEvent::SubscriberError), + Details = "Coordinator is no longer configured" + ); + break; + } + + let mut stream = match coordinator.subscribe(BROADCAST_TOPIC).await { + Ok(stream) => { + retry_count = 0; + stream + } + Err(err) => { + trc::event!( + Cluster(ClusterEvent::SubscriberError), + CausedBy = err, + Details = "Failed to subscribe to channel" + ); + + match tokio::time::timeout( + Duration::from_secs(1 << retry_count.max(6)), + shutdown_rx.changed(), + ) + .await + { + Ok(_) => { + break; + } + Err(_) => { + retry_count += 1; + continue; + } + } + } + }; + + loop { + tokio::select! { + message = stream.next() => { + match message { + Some(message) => { + let mut batch = BroadcastBatch::new(message.payload().iter()); + let node_id = match batch.node_id() { + Some(node_id) => { + if node_id != this_node_id { + node_id + } else { + trc::event!( + Cluster(ClusterEvent::MessageSkipped), + Details = message.payload() + ); + continue; + } + } + None => { + trc::event!( + Cluster(ClusterEvent::MessageInvalid), + Details = message.payload() + ); + continue; + } + }; + + loop { + match batch.next_event() { + Ok(Some(event)) => { + trc::event!( + Cluster(ClusterEvent::MessageReceived), + From = node_id, + To = this_node_id, + Details = log_event(&event), + ); + match event { + BroadcastEvent::PushNotification(notification) => { + if inner + .ipc + .push_tx + .send(PushEvent::Publish { + notification, + broadcast: false, + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push notification.", + CausedBy = trc::location!() + ); + } + } + BroadcastEvent::PushServerUpdate(account_id) => { + if inner + .ipc + .push_tx + .send(PushEvent::PushServerUpdate { account_id, broadcast: false }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending reload request.", + CausedBy = trc::location!() + ); + } + } + BroadcastEvent::CacheInvalidate(changes) => { + inner.build_server().invalidate_local_caches(&changes).await; + + } + BroadcastEvent::CacheInvalidateAll => { + inner.build_server().invalidate_all_local_caches(); + } + BroadcastEvent::CacheInvalidateNegative => { + inner.build_server().invalidate_all_local_negative_caches(); + } + BroadcastEvent::MtaQueueStatus { is_running } => { + let _ = inner + .ipc + .queue_tx + .send(QueueEvent::Paused(!is_running)) + .await; + } + BroadcastEvent::QueueRefresh => { + let core = inner.shared_core.load_full(); + if core.network.roles.outbound_mta { + core.storage.data.invalidate_read_snapshot(); + let _ = inner + .ipc + .queue_tx + .send(QueueEvent::Refresh) + .await; + } + } + BroadcastEvent::RegistryChange(change) => { + let server = inner.build_server(); + server.store().invalidate_read_snapshot(); + match Box::pin(server.reload_registry(change)).await { + Ok(result) => { + result.log(); + } + Err(err) => { + trc::error!( + err.details("Failed to reload settings") + .caused_by(trc::location!()) + ); + } + } + } + } + } + Ok(None) => break, + Err(_) => { + trc::event!( + Cluster(ClusterEvent::MessageInvalid), + Details = message.payload() + ); + break; + } + } + } + } + None => { + trc::event!( + Cluster(ClusterEvent::SubscriberDisconnected), + ); + break; + } + } + }, + _ = shutdown_rx.changed() => { + break 'subscribe; + } + }; + } + } + + trc::event!(Cluster(ClusterEvent::SubscriberStop)); + }); +} + +fn log_event(event: &BroadcastEvent) -> trc::Value { + match event { + BroadcastEvent::PushNotification(notification) => match notification { + PushNotification::StateChange(state_change) => trc::Value::Array(vec![ + "StateChange".into(), + state_change.account_id.into(), + state_change.change_id.into(), + (*state_change.types.as_ref()).into(), + ]), + PushNotification::CalendarAlert(calendar_alert) => trc::Value::Array(vec![ + "CalendarAlert".into(), + calendar_alert.account_id.into(), + calendar_alert.event_id.into(), + calendar_alert.recurrence_id.into(), + calendar_alert.uid.clone().into(), + calendar_alert.alert_id.clone().into(), + ]), + PushNotification::EmailPush(email_push) => trc::Value::Array(vec![ + "EmailPush".into(), + email_push.account_id.into(), + email_push.email_id.into(), + email_push.change_id.into(), + ]), + }, + BroadcastEvent::PushServerUpdate(account_id) => { + trc::Value::Array(vec!["PushServerUpdate".into(), (*account_id).into()]) + } + BroadcastEvent::RegistryChange(change) => match change { + RegistryChange::Insert(id) => trc::Value::Array(vec![ + "RegistryInsert".into(), + id.object().as_str().into(), + id.id().id().into(), + ]), + RegistryChange::Delete(id) => trc::Value::Array(vec![ + "RegistryDelete".into(), + id.object().as_str().into(), + id.id().id().into(), + ]), + RegistryChange::Reload(object) => { + trc::Value::Array(vec!["RegistryReload".into(), object.as_str().into()]) + } + }, + BroadcastEvent::CacheInvalidate(items) => { + let mut array = Vec::with_capacity(items.len() + 1); + array.push("CacheInvalidation".into()); + for item in items { + array.push(format!("{:?}", item).into()); + } + trc::Value::Array(array) + } + BroadcastEvent::CacheInvalidateAll => "CacheInvalidateAll".into(), + BroadcastEvent::CacheInvalidateNegative => "CacheInvalidateNegative".into(), + BroadcastEvent::MtaQueueStatus { is_running } => { + if *is_running { + "MtaQueueRunning".into() + } else { + "MtaQueuePaused".into() + } + } + BroadcastEvent::QueueRefresh => "QueueRefresh".into(), + } +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs new file mode 100644 index 0000000..b249b46 --- /dev/null +++ b/crates/services/src/lib.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use broadcast::publisher::spawn_broadcast_publisher; +use common::{ + BuildServer, Inner, + manager::boot::{BootManager, IpcReceivers}, +}; +use state_manager::manager::spawn_push_router; +use std::sync::Arc; + +use crate::task_manager::{manager::spawn_task_manager, scheduler::spawn_task_scheduler}; + +pub mod broadcast; +pub mod state_manager; +pub mod task_manager; + +pub trait StartServices: Sync + Send { + fn start_services(&mut self) -> impl Future + Send; +} + +pub trait SpawnServices { + fn spawn_services(&mut self, inner: Arc); +} + +impl StartServices for BootManager { + async fn start_services(&mut self) { + let server = self.inner.build_server(); + // Unpack webadmin + self.inner + .data + .applications + .unpack_all(&server, false) + .await; + + if !server.registry().is_recovery_mode() { + self.ipc_rxs.spawn_services(self.inner.clone()); + } + } +} + +impl SpawnServices for IpcReceivers { + fn spawn_services(&mut self, inner: Arc) { + if !inner.shared_core.load().storage.registry.is_recovery_mode() { + // Spawn push manager + spawn_push_router(inner.clone(), self.push_rx.take().unwrap()); + + // Spawn broadcast publisher + if let Some(event_rx) = self.broadcast_rx.take() { + // Spawn broadcast publisher + spawn_broadcast_publisher(inner.clone(), event_rx); + } + + // Spawn task manager + spawn_task_manager(inner.clone()); + + // Spawn task scheduler + spawn_task_scheduler(inner); + } + } +} diff --git a/crates/services/src/state_manager/ece.rs b/crates/services/src/state_manager/ece.rs new file mode 100644 index 0000000..dc334b9 --- /dev/null +++ b/crates/services/src/state_manager/ece.rs @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ +use aes_gcm::{Aes128Gcm, Key, Nonce, aead::Aead}; +use hkdf::Hkdf; +use p256::{ + PublicKey, + ecdh::EphemeralSecret, + elliptic_curve::{rand_core::OsRng, sec1::ToEncodedPoint}, +}; +use sha2::Sha256; +use store::rand::RngExt; + +/* + + From https://github.com/mozilla/rust-ece (MPL-2.0 license) + Adapted to use 'aes-gcm' and 'p256' crates instead of 'openssl'. + +*/ + +const ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX: &str = "WebPush: info\0"; +const ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH: usize = 144; +const ECE_WEBPUSH_IKM_LENGTH: usize = 32; +const ECE_WEBPUSH_PUBLIC_KEY_LENGTH: usize = 65; +const ECE_WEBPUSH_DEFAULT_RS: u32 = 4096; +const ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE: usize = 128; + +const ECE_AES128GCM_PAD_SIZE: usize = 1; +const ECE_AES128GCM_KEY_INFO: &str = "Content-Encoding: aes128gcm\0"; +const ECE_AES128GCM_NONCE_INFO: &str = "Content-Encoding: nonce\0"; +const ECE_AES128GCM_HEADER_LENGTH: usize = 21; +const ECE_AES_KEY_LENGTH: usize = 16; + +const ECE_NONCE_LENGTH: usize = 12; +const ECE_TAG_LENGTH: usize = 16; + +pub(crate) const WEBPUSH_MAX_BODY_SIZE: usize = 4096; + +pub(crate) const ECE_WEBPUSH_MAX_PLAINTEXT_SIZE: usize = { + let single_record = ECE_WEBPUSH_DEFAULT_RS as usize - ECE_TAG_LENGTH; + let wire_budget = WEBPUSH_MAX_BODY_SIZE + - (ECE_AES128GCM_HEADER_LENGTH + ECE_WEBPUSH_PUBLIC_KEY_LENGTH) + - ECE_TAG_LENGTH; + let budget = if wire_budget < single_record { + wire_budget + } else { + single_record + }; + + budget / ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE * ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE + - ECE_AES128GCM_PAD_SIZE +}; + +pub fn ece_encrypt( + p256dh: &[u8], + client_auth_secret: &[u8], + mut data: &[u8], +) -> Result, String> { + let salt = store::rand::rng().random::<[u8; 16]>(); + let server_secret = EphemeralSecret::random(&mut OsRng); + let server_public_key = server_secret.public_key(); + let server_public_key_bytes = server_public_key.to_encoded_point(false); + + let client_public_key = PublicKey::from_sec1_bytes(p256dh).map_err(|e| e.to_string())?; + let shared_secret = server_secret.diffie_hellman(&client_public_key); + + let ikm_info = generate_info(p256dh, server_public_key_bytes.as_bytes()); + let ikm = hkdf_sha256( + client_auth_secret, + &shared_secret.raw_secret_bytes()[..], + &ikm_info, + ECE_WEBPUSH_IKM_LENGTH, + )?; + let key = hkdf_sha256( + &salt, + &ikm, + ECE_AES128GCM_KEY_INFO.as_bytes(), + ECE_AES_KEY_LENGTH, + )?; + let nonce = hkdf_sha256( + &salt, + &ikm, + ECE_AES128GCM_NONCE_INFO.as_bytes(), + ECE_NONCE_LENGTH, + )?; + + // Calculate pad length + let mut pad_length = ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE + - (data.len() % ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE); + if pad_length < ECE_AES128GCM_PAD_SIZE { + pad_length += ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE; + } + + // Split into records + let rs = ECE_WEBPUSH_DEFAULT_RS as usize - ECE_TAG_LENGTH; + let mut min_num_records = data.len() / (rs - 1); + if !data.len().is_multiple_of(rs - 1) { + min_num_records += 1; + } + let mut pad_length = std::cmp::max(pad_length, min_num_records); + let total_size = data.len() + pad_length; + let mut num_records = total_size / rs; + let size_of_final_record = total_size % rs; + if size_of_final_record > 0 { + num_records += 1; + } + let data_per_record = data.len() / num_records; + let mut extra_data = data.len() % num_records; + if size_of_final_record > 0 && data_per_record > size_of_final_record - 1 { + extra_data += data_per_record - (size_of_final_record - 1) + } + let mut sequence_number = 0; + let mut plain_text = + Vec::with_capacity(data_per_record + ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE); + + // Write header + let key_id = server_public_key_bytes.as_bytes(); + debug_assert_eq!(key_id.len(), ECE_WEBPUSH_PUBLIC_KEY_LENGTH); + let mut output = Vec::with_capacity( + ECE_AES128GCM_HEADER_LENGTH + key_id.len() + total_size + num_records * ECE_TAG_LENGTH, + ); + output.extend_from_slice(&salt); + output.extend_from_slice(&ECE_WEBPUSH_DEFAULT_RS.to_be_bytes()); + output.push(key_id.len() as u8); + output.extend_from_slice(key_id); + + loop { + let records_remaining = num_records - sequence_number; + if records_remaining == 0 { + break; + } + let mut data_share = data_per_record; + if data_share > data.len() { + data_share = data.len(); + } else if extra_data > 0 { + let mut extra_share = extra_data / (records_remaining - 1); + if !extra_data.is_multiple_of(records_remaining - 1) { + extra_share += 1; + } + data_share += extra_share; + extra_data -= extra_share; + } + + let cur_data = &data[0..data_share]; + data = &data[data_share..]; + let padding = std::cmp::min(pad_length, rs - data_share); + pad_length -= padding; + let cur_sequence_number = sequence_number; + sequence_number += 1; + + let padded_plaintext_len = cur_data.len() + padding; + + plain_text.extend_from_slice(cur_data); + plain_text.push(if sequence_number == num_records { 2 } else { 1 }); + plain_text.resize(padded_plaintext_len, 0); + + output.extend_from_slice(&aes_gcm_128_encrypt( + &key, + &generate_iv(&nonce, cur_sequence_number), + &plain_text, + )?); + plain_text.clear(); + } + + Ok(output) +} + +fn hkdf_sha256(salt: &[u8], secret: &[u8], info: &[u8], len: usize) -> Result, String> { + let (_, hk) = Hkdf::::extract(Some(salt), secret); + let mut okm = vec![0u8; len]; + hk.expand(info, &mut okm).map_err(|e| e.to_string())?; + Ok(okm) +} + +fn aes_gcm_128_encrypt(key: &[u8], nonce: &[u8], data: &[u8]) -> Result, String> { + let key: &Key = key + .try_into() + .map_err(|_| "Invalid AES-GCM key length".to_string())?; + let nonce: &Nonce<_> = nonce + .try_into() + .map_err(|_| "Invalid AES-GCM nonce length".to_string())?; + + ::new(key) + .encrypt(nonce, data) + .map_err(|e| e.to_string()) +} + +fn generate_info( + client_public_key: &[u8], + server_public_key: &[u8], +) -> [u8; ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH] { + let mut info = [0u8; ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH]; + let prefix = ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX.as_bytes(); + let mut offset = prefix.len(); + info[0..offset].copy_from_slice(prefix); + info[offset..offset + ECE_WEBPUSH_PUBLIC_KEY_LENGTH].copy_from_slice(client_public_key); + offset += ECE_WEBPUSH_PUBLIC_KEY_LENGTH; + info[offset..].copy_from_slice(server_public_key); + info +} + +pub fn generate_iv(nonce: &[u8], counter: usize) -> [u8; ECE_NONCE_LENGTH] { + let mut iv = [0u8; ECE_NONCE_LENGTH]; + let offset = ECE_NONCE_LENGTH - 8; + iv[0..offset].copy_from_slice(&nonce[0..offset]); + let mask = u64::from_be_bytes((&nonce[offset..]).try_into().unwrap()); + iv[offset..].copy_from_slice(&(mask ^ (counter as u64)).to_be_bytes()); + iv +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encrypt_len(plaintext_len: usize) -> usize { + let secret = EphemeralSecret::random(&mut OsRng); + let p256dh = secret.public_key().to_encoded_point(false); + + ece_encrypt(p256dh.as_bytes(), &[0u8; 16], &vec![b'a'; plaintext_len]) + .expect("encryption failed") + .len() + } + + #[test] + fn max_plaintext_stays_within_a_single_record() { + let header = ECE_AES128GCM_HEADER_LENGTH + ECE_WEBPUSH_PUBLIC_KEY_LENGTH; + let limit = WEBPUSH_MAX_BODY_SIZE; + + let padded = (ECE_WEBPUSH_MAX_PLAINTEXT_SIZE + ECE_AES128GCM_PAD_SIZE) + .next_multiple_of(ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE); + + let len = encrypt_len(ECE_WEBPUSH_MAX_PLAINTEXT_SIZE); + assert!( + len <= limit, + "{len} exceeds the {limit} octet payload limit" + ); + assert_eq!( + len, + header + padded + ECE_TAG_LENGTH, + "expected exactly one authentication tag, i.e. a single record" + ); + + let over = encrypt_len(ECE_WEBPUSH_MAX_PLAINTEXT_SIZE + 1); + assert!( + over > limit, + "{ECE_WEBPUSH_MAX_PLAINTEXT_SIZE} is not the largest single-record plaintext" + ); + } +} diff --git a/crates/services/src/state_manager/email_push.rs b/crates/services/src/state_manager/email_push.rs new file mode 100644 index 0000000..7930400 --- /dev/null +++ b/crates/services/src/state_manager/email_push.rs @@ -0,0 +1,584 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use aho_corasick::AhoCorasick; +use common::{MessageStoreCache, Server}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::{ + body::ToBodyPart, + headers::{HeaderToValue, IntoForm}, + metadata::{ + ArchivedMessageMetadata, ArchivedMessageMetadataContents, ArchivedMessageMetadataPart, + ArchivedMetadataPartType, MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK, MessageData, + MessageMetadata, MetadataHeaderName, + }, + }, + push::EmailPush, +}; +use jmap_proto::{ + method::query::Filter, + object::{ + email::{EmailFilter, EmailProperty, EmailValue, HeaderForm}, + push_subscription::EmailPushProperty, + }, + types::date::UTCDate, +}; +use jmap_tools::{Map, Property, Value}; +use mail_parser::{HeaderName, HeaderValue}; +use std::iter::Peekable; +use store::{ + ValueKey, + write::{AlignedBytes, Archive}, +}; +use trc::AddContext; +use types::{ + blob::{BlobClass, BlobId}, + blob_hash::BlobHash, + collection::Collection, + field::EmailField, + id::Id, +}; +use utils::chained_bytes::ChainedBytes; + +const BODY_PROPERTIES: &[EmailProperty] = &[ + EmailProperty::PartId, + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::Name, + EmailProperty::Type, + EmailProperty::Charset, + EmailProperty::Disposition, + EmailProperty::Cid, + EmailProperty::Language, + EmailProperty::Location, +]; + +pub async fn build_email_push_object( + server: &Server, + account_id: u32, + document_id: u32, + config: &EmailPush, + max_size: usize, +) -> trc::Result, usize)>> { + let properties = &config.properties; + let Some(data) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + let data = data + .deserialize::() + .caused_by(trc::location!())?; + + let Some(metadata_archive) = server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + let metadata = metadata_archive + .unarchive::() + .caused_by(trc::location!())?; + let (Some(contents), Some(root_part)) = ( + metadata.contents.first(), + metadata.contents.first().and_then(|c| c.parts.first()), + ) else { + return Ok(None); + }; + + let blob_hash = BlobHash::from(&metadata.blob_hash); + let needs_body = properties.iter().any(|property| { + matches!( + property, + EmailPushProperty::TextBody + | EmailPushProperty::HtmlBody + | EmailPushProperty::Attachments + | EmailPushProperty::BodyStructure + ) + }) || config.filter.iter().any(|filter| { + matches!( + filter, + Filter::Property(EmailFilter::Body(_) | EmailFilter::Text(_)) + ) + }); + + let raw_body; + let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()); + if needs_body { + let Some(blob) = server + .blob_store() + .get_blob(blob_hash.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + else { + return Ok(None); + }; + raw_body = blob; + raw_message.append( + raw_body + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + } + + if !config.filter.is_empty() { + let cache = if config.filter.iter().any(|filter| { + matches!( + filter, + Filter::Property( + EmailFilter::AllInThreadHaveKeyword(_) + | EmailFilter::SomeInThreadHaveKeyword(_) + | EmailFilter::NoneInThreadHaveKeyword(_) + ) + ) + }) { + Some( + server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?, + ) + } else { + None + }; + + if !eval_filter_node( + &mut config.filter.iter().peekable(), + &FilterContext { + data: &data, + document_id, + cache: cache.as_deref(), + metadata, + contents, + root_part, + raw_message: &raw_message, + }, + ) + .unwrap_or(true) + { + return Ok(None); + } + } + + let blob_id = BlobId { + hash: blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::Email.into(), + document_id, + }, + section: None, + }; + let blob_body_offset = + metadata.blob_body_offset.to_native() as isize - root_part.offset_body.to_native() as isize; + + let id = Id::from_parts(data.thread_id, document_id); + let mut email = Map::with_capacity(properties.len()); + let mut used = 0; + + for property in properties { + let key = EmailProperty::from(property); + let value: Value<'static, EmailProperty, EmailValue> = match property { + EmailPushProperty::Id => id.into(), + EmailPushProperty::ThreadId => Id::from(id.prefix_id()).into(), + EmailPushProperty::BlobId => blob_id.clone().into(), + EmailPushProperty::MailboxIds => { + let mut mailbox_ids = Map::with_capacity(data.mailboxes.len()); + for mailbox in data.mailboxes.iter() { + mailbox_ids.insert_unchecked( + EmailProperty::IdValue(Id::from(mailbox.mailbox_id)), + true, + ); + } + Value::Object(mailbox_ids) + } + EmailPushProperty::Keywords => { + let mut keywords = Map::with_capacity(2); + for keyword in data.keywords.iter() { + keywords.insert_unchecked(EmailProperty::Keyword(keyword.clone()), true); + } + Value::Object(keywords) + } + EmailPushProperty::Size => data.size.into(), + EmailPushProperty::ReceivedAt => EmailValue::Date(UTCDate::from_timestamp( + (metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64, + )) + .into(), + EmailPushProperty::Preview => { + if metadata.preview.is_empty() { + continue; + } + metadata.preview.to_string().into() + } + EmailPushProperty::HasAttachment => { + ((metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0).into() + } + EmailPushProperty::Subject + | EmailPushProperty::SentAt + | EmailPushProperty::MessageId + | EmailPushProperty::InReplyTo + | EmailPushProperty::References + | EmailPushProperty::Sender + | EmailPushProperty::From + | EmailPushProperty::To + | EmailPushProperty::Cc + | EmailPushProperty::Bcc + | EmailPushProperty::ReplyTo => { + let (header_name, form) = match property { + EmailPushProperty::Subject => (MetadataHeaderName::Subject, HeaderForm::Text), + EmailPushProperty::SentAt => (MetadataHeaderName::Date, HeaderForm::Date), + EmailPushProperty::MessageId => { + (MetadataHeaderName::MessageId, HeaderForm::MessageIds) + } + EmailPushProperty::InReplyTo => { + (MetadataHeaderName::InReplyTo, HeaderForm::MessageIds) + } + EmailPushProperty::References => { + (MetadataHeaderName::References, HeaderForm::MessageIds) + } + EmailPushProperty::Sender => { + (MetadataHeaderName::Sender, HeaderForm::Addresses) + } + EmailPushProperty::From => (MetadataHeaderName::From, HeaderForm::Addresses), + EmailPushProperty::To => (MetadataHeaderName::To, HeaderForm::Addresses), + EmailPushProperty::Cc => (MetadataHeaderName::Cc, HeaderForm::Addresses), + EmailPushProperty::Bcc => (MetadataHeaderName::Bcc, HeaderForm::Addresses), + EmailPushProperty::ReplyTo => { + (MetadataHeaderName::ReplyTo, HeaderForm::Addresses) + } + _ => unreachable!(), + }; + root_part + .header_value(&header_name) + .map(|value| HeaderValue::from(value).into_form(&form)) + .unwrap_or_default() + } + EmailPushProperty::Header(_) => root_part.header_to_value(&key, &raw_message), + EmailPushProperty::Headers => root_part.headers_to_value(&raw_message), + EmailPushProperty::TextBody + | EmailPushProperty::HtmlBody + | EmailPushProperty::Attachments => { + let parts = match property { + EmailPushProperty::TextBody => &contents.text_body, + EmailPushProperty::HtmlBody => &contents.html_body, + EmailPushProperty::Attachments => &contents.attachments, + _ => unreachable!(), + }; + parts + .iter() + .map(|part_id| { + contents.to_body_part( + u16::from(part_id) as u32, + BODY_PROPERTIES, + &raw_message, + &blob_id, + blob_body_offset, + ) + }) + .collect::>() + .into() + } + EmailPushProperty::BodyStructure => { + contents.to_body_part(0, BODY_PROPERTIES, &raw_message, &blob_id, blob_body_offset) + } + EmailPushProperty::BodyValues => Value::Object(Map::with_capacity(0)), + }; + + let entry_size = key.to_cow().len() + estimate_value_size(&value) + 4; + if used + entry_size < max_size { + used += entry_size; + email.insert_unchecked(key, value); + } + } + + Ok(Some((email.into(), used))) +} + +fn estimate_value_size(value: &Value<'_, EmailProperty, EmailValue>) -> usize { + match value { + Value::Null => 4, + Value::Bool(_) => 5, + Value::Number(_) => 12, + Value::Str(text) => text.len() + 2, + Value::Element(_) => 40, + Value::Array(values) => { + 2 + values + .iter() + .map(|value| estimate_value_size(value) + 1) + .sum::() + } + Value::Object(map) => { + 2 + map + .iter() + .map(|(_, value)| estimate_value_size(value) + 24) + .sum::() + } + } +} + +struct FilterContext<'a> { + data: &'a MessageData, + document_id: u32, + cache: Option<&'a MessageStoreCache>, + metadata: &'a ArchivedMessageMetadata, + contents: &'a ArchivedMessageMetadataContents, + root_part: &'a ArchivedMessageMetadataPart, + raw_message: &'a ChainedBytes<'a>, +} + +fn eval_filter_node<'a, I>(tokens: &mut Peekable, context: &FilterContext) -> Option +where + I: Iterator>, +{ + match tokens.next()? { + operator @ (Filter::And | Filter::Or | Filter::Not) => { + let mut all = true; + let mut any = false; + while let Some(token) = tokens.peek() { + if matches!(token, Filter::Close) { + tokens.next(); + break; + } + if let Some(result) = eval_filter_node(tokens, context) { + all &= result; + any |= result; + } + } + Some(match operator { + Filter::And => all, + Filter::Or => any, + Filter::Not => !any, + _ => unreachable!(), + }) + } + Filter::Property(condition) => Some(eval_filter_condition(condition, context)), + Filter::Close => None, + } +} + +fn eval_filter_condition(condition: &EmailFilter, context: &FilterContext) -> bool { + match condition { + EmailFilter::InMailbox(id) => { + let mailbox_id = id.document_id(); + context + .data + .mailboxes + .iter() + .any(|mailbox| mailbox.mailbox_id == mailbox_id) + } + EmailFilter::InMailboxOtherThan(ids) => context + .data + .mailboxes + .iter() + .any(|mailbox| ids.iter().all(|id| id.document_id() != mailbox.mailbox_id)), + EmailFilter::Before(date) => received_at(context) < date.timestamp(), + EmailFilter::After(date) => received_at(context) >= date.timestamp(), + EmailFilter::MinSize(size) => context.data.size >= *size, + EmailFilter::MaxSize(size) => context.data.size < *size, + EmailFilter::HasKeyword(keyword) => { + context.data.keywords.iter().any(|value| value == keyword) + } + EmailFilter::NotKeyword(keyword) => { + !context.data.keywords.iter().any(|value| value == keyword) + } + EmailFilter::AllInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| { + cache + .in_thread(context.data.thread_id) + .all(|message| cache.has_keyword(message, keyword)) + }), + EmailFilter::SomeInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| { + cache + .in_thread(context.data.thread_id) + .any(|message| cache.has_keyword(message, keyword)) + }), + EmailFilter::NoneInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| { + !cache + .in_thread(context.data.thread_id) + .any(|message| cache.has_keyword(message, keyword)) + }), + EmailFilter::HasAttachment(value) => { + ((context.metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0) == *value + } + EmailFilter::From(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::From, + &HeaderForm::Addresses, + &matcher, + ) + }), + EmailFilter::To(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::To, + &HeaderForm::Addresses, + &matcher, + ) + }), + EmailFilter::Cc(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::Cc, + &HeaderForm::Addresses, + &matcher, + ) + }), + EmailFilter::Bcc(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::Bcc, + &HeaderForm::Addresses, + &matcher, + ) + }), + EmailFilter::Subject(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::Subject, + &HeaderForm::Text, + &matcher, + ) + }), + EmailFilter::Body(text) => { + ascii_matcher(text).is_some_and(|matcher| body_matches(context, &matcher)) + } + EmailFilter::Text(text) => ascii_matcher(text).is_some_and(|matcher| { + header_matches( + context, + &MetadataHeaderName::From, + &HeaderForm::Addresses, + &matcher, + ) || header_matches( + context, + &MetadataHeaderName::To, + &HeaderForm::Addresses, + &matcher, + ) || header_matches( + context, + &MetadataHeaderName::Cc, + &HeaderForm::Addresses, + &matcher, + ) || header_matches( + context, + &MetadataHeaderName::Bcc, + &HeaderForm::Addresses, + &matcher, + ) || header_matches( + context, + &MetadataHeaderName::Subject, + &HeaderForm::Text, + &matcher, + ) || body_matches(context, &matcher) + }), + EmailFilter::Header(parts) => { + let Some(name) = parts.first() else { + return false; + }; + let header_name = MetadataHeaderName::from( + HeaderName::parse(name.as_str()) + .unwrap_or_else(|| HeaderName::Other(name.as_str().into())), + ); + match (context.root_part.header_value(&header_name), parts.get(1)) { + (Some(value), Some(expected)) => ascii_matcher(expected).is_some_and(|matcher| { + value_contains( + &HeaderValue::from(value).into_form(&HeaderForm::Raw), + &matcher, + ) + }), + (Some(_), None) => true, + (None, _) => false, + } + } + EmailFilter::SentBefore(date) => { + sent_at(context).is_some_and(|sent_at| sent_at < date.timestamp()) + } + EmailFilter::SentAfter(date) => { + sent_at(context).is_some_and(|sent_at| sent_at >= date.timestamp()) + } + EmailFilter::InThread(id) => context.data.thread_id == id.document_id(), + EmailFilter::Id(ids) => ids.iter().any(|id| id.document_id() == context.document_id), + EmailFilter::_T(_) => false, + } +} + +fn received_at(context: &FilterContext) -> i64 { + (context.metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64 +} + +fn sent_at(context: &FilterContext) -> Option { + match context + .root_part + .header_value(&MetadataHeaderName::Date) + .map(HeaderValue::from) + { + Some(HeaderValue::DateTime(datetime)) => Some(datetime.to_timestamp()), + _ => None, + } +} + +fn ascii_matcher(needle: &str) -> Option { + AhoCorasick::builder() + .ascii_case_insensitive(true) + .build([needle]) + .ok() +} + +fn header_matches( + context: &FilterContext, + name: &MetadataHeaderName, + form: &HeaderForm, + matcher: &AhoCorasick, +) -> bool { + context + .root_part + .header_value(name) + .is_some_and(|value| value_contains(&HeaderValue::from(value).into_form(form), matcher)) +} + +fn value_contains(value: &Value<'_, EmailProperty, EmailValue>, matcher: &AhoCorasick) -> bool { + match value { + Value::Str(text) => matcher.is_match(text.as_ref()), + Value::Array(values) => values.iter().any(|value| value_contains(value, matcher)), + Value::Object(map) => map.iter().any(|(_, value)| value_contains(value, matcher)), + _ => false, + } +} + +fn body_matches(context: &FilterContext, matcher: &AhoCorasick) -> bool { + context + .contents + .text_body + .iter() + .chain(context.contents.html_body.iter()) + .any(|part_id| { + context + .contents + .parts + .as_ref() + .get(u16::from(part_id) as usize) + .is_some_and(|part| { + matches!( + part.body, + ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html + ) && matcher.is_match(part.decode_contents(context.raw_message).as_str()) + }) + }) +} diff --git a/crates/services/src/state_manager/http.rs b/crates/services/src/state_manager/http.rs new file mode 100644 index 0000000..96f01eb --- /dev/null +++ b/crates/services/src/state_manager/http.rs @@ -0,0 +1,427 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Event, + ece::{ECE_WEBPUSH_MAX_PLAINTEXT_SIZE, WEBPUSH_MAX_BODY_SIZE, ece_encrypt}, + email_push::build_email_push_object, +}; +use crate::state_manager::PushRegistration; +use calcard::jscalendar::JSCalendarDateTime; +use common::{Server, ipc::PushNotification, network::webpush::Vapid}; +use email::push::{PushSubscription, Urgency}; +use jmap_proto::{ + object::email::{EmailProperty, EmailValue}, + response::status::PushObject, + types::state::State, +}; +use jmap_tools::Value; +use reqwest::{ + Client, Url, + header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE}, + redirect::Policy, +}; +use std::net::IpAddr; +use std::time::{Duration, Instant}; +use store::write::now; +use tokio::sync::mpsc; +use trc::PushSubscriptionEvent; +use types::{id::Id, type_state::DataType}; +use utils::map::vec_map::VecMap; + +const MAX_ERROR_RESPONSE_LEN: usize = 1024; +const MAX_REDIRECTS: usize = 4; +const PUSH_OBJECT_OVERHEAD: usize = 128; + +#[derive(Default)] +struct EmailPushObject { + emails: Vec>, + change_id: Option, + urgency: Urgency, + used: usize, +} + +impl PushRegistration { + pub fn send( + &mut self, + id: Id, + push_tx: mpsc::Sender, + push_timeout: Duration, + server: Server, + ) { + let subscription = self.server.clone(); + let push_client = self.client.clone(); + let notifications = std::mem::take(&mut self.notifications); + + self.in_flight = true; + self.last_request = Instant::now(); + + tokio::spawn(async move { + let mut changed: VecMap> = VecMap::new(); + let mut email_pushes: VecMap = VecMap::new(); + + let mut failed_state_change = false; + let mut failed_email_pushes = Vec::new(); + let mut failed_calendar_alerts = Vec::new(); + + for notification in ¬ifications { + match notification { + PushNotification::StateChange(state_change) => { + for type_state in state_change.types { + changed + .get_mut_or_insert(state_change.account_id.into()) + .set(type_state, State::Exact(state_change.change_id)); + } + } + PushNotification::CalendarAlert(calendar_alert) => { + let payload = PushObject::CalendarAlert { + account_id: calendar_alert.account_id.into(), + calendar_event_id: calendar_alert.event_id.into(), + uid: calendar_alert.uid.clone(), + recurrence_id: calendar_alert.recurrence_id.map(|timestamp| { + JSCalendarDateTime::new(timestamp, true).to_rfc3339() + }), + alert_id: calendar_alert.alert_id.clone(), + }; + if !http_request( + &push_client, + &subscription, + serde_json::to_string(&payload).unwrap().into_bytes(), + push_timeout, + server.core.jmap.vapid.as_ref(), + Urgency::Normal, + ) + .await + { + failed_calendar_alerts + .push((calendar_alert.account_id, calendar_alert.event_id)); + } + } + PushNotification::EmailPush(email_push) => { + if let Some(config) = subscription + .email_push + .iter() + .find(|config| config.account_id == email_push.account_id) + { + let emails = + email_pushes.get_mut_or_insert(Id::from(email_push.account_id)); + let remaining = server + .core + .jmap + .push_max_size + .min(if subscription.keys.is_some() { + ECE_WEBPUSH_MAX_PLAINTEXT_SIZE + } else { + WEBPUSH_MAX_BODY_SIZE + }) + .saturating_sub(PUSH_OBJECT_OVERHEAD) + .saturating_sub(emails.used); + + match build_email_push_object( + &server, + email_push.account_id, + email_push.email_id, + config, + remaining, + ) + .await + { + Ok(Some((object, used))) => { + emails.urgency = config.urgency; + if emails + .change_id + .is_none_or(|change_id| email_push.change_id > change_id) + { + emails.change_id = Some(email_push.change_id); + } + emails.used += used; + emails.emails.push(object); + } + Ok(None) => {} + Err(err) => { + trc::error!( + err.details( + "Failed to build EmailPush notification object." + ) + ); + failed_email_pushes.push(email_push.account_id); + } + } + } + } + } + } + + if !changed.is_empty() { + failed_state_change = !http_request( + &push_client, + &subscription, + serde_json::to_string(&PushObject::StateChange { changed }) + .unwrap() + .into_bytes(), + push_timeout, + server.core.jmap.vapid.as_ref(), + Urgency::Normal, + ) + .await; + } + + for (account_id, email_push) in email_pushes { + if email_push.emails.is_empty() { + continue; + } + + let payload = PushObject::EmailPush { + account_id, + emails: email_push.emails, + state: email_push.change_id.map(State::Exact), + }; + + if !http_request( + &push_client, + &subscription, + serde_json::to_string(&payload).unwrap().into_bytes(), + push_timeout, + server.core.jmap.vapid.as_ref(), + email_push.urgency, + ) + .await + { + failed_email_pushes.push(account_id.document_id()); + } + } + + let result = if !failed_state_change + && failed_email_pushes.is_empty() + && failed_calendar_alerts.is_empty() + { + Event::DeliverySuccess { id } + } else { + let mut failed_notifications = Vec::with_capacity( + failed_state_change as usize + + failed_email_pushes.len() + + failed_calendar_alerts.len(), + ); + + for notification in notifications { + match ¬ification { + PushNotification::StateChange(_) => { + if failed_state_change { + failed_notifications.push(notification); + } + } + PushNotification::EmailPush(email_push) => { + if failed_email_pushes.contains(&email_push.account_id) { + failed_notifications.push(notification); + } + } + PushNotification::CalendarAlert(calendar_alert) => { + if failed_calendar_alerts + .contains(&(calendar_alert.account_id, calendar_alert.event_id)) + { + failed_notifications.push(notification); + } + } + } + } + + Event::DeliveryFailure { + id, + notifications: failed_notifications, + } + }; + + push_tx.send(result).await.ok(); + }); + } +} + +pub(crate) fn build_push_client() -> Client { + utils::http::http_client_builder(cfg!(feature = "test_mode")) + .redirect(Policy::custom(|attempt| match attempt.previous().last() { + Some(previous) if is_same_organization(previous, attempt.url()) => { + if attempt.previous().len() > MAX_REDIRECTS { + attempt.error("Too many redirects.") + } else { + attempt.follow() + } + } + _ => attempt.stop(), + })) + .build() + .unwrap_or_default() +} + +pub(crate) async fn http_request( + push_client: &Client, + details: &PushSubscription, + mut body: Vec, + push_timeout: Duration, + vapid: Option<&Vapid>, + urgency: Urgency, +) -> bool { + let mut client = push_client + .post(details.url.as_str()) + .timeout(push_timeout) + .header("TTL", "86400") + .header("Urgency", urgency.as_str()); + + if let Some(authorization) = vapid.and_then(|vapid| vapid.authorization(&details.url, now())) { + client = client.header(AUTHORIZATION, authorization); + } + + let mut content_type = "application/json"; + if let Some(keys) = &details.keys { + match ece_encrypt(&keys.p256dh, &keys.auth, &body) { + Ok(body_) => { + body = body_; + content_type = "application/octet-stream"; + client = client.header(CONTENT_ENCODING, "aes128gcm"); + } + Err(err) => { + // Do not reattempt if encryption fails. + + trc::event!( + PushSubscription(PushSubscriptionEvent::Error), + Details = "Failed to encrypt push subscription", + Url = details.url.to_string(), + Reason = err + ); + return true; + } + } + } + + match client + .header(CONTENT_TYPE, content_type) + .body(body) + .send() + .await + { + Ok(response) => { + let status = response.status(); + + if status.is_success() { + trc::event!( + PushSubscription(PushSubscriptionEvent::Success), + Url = details.url.to_string() + ); + + true + } else { + let mut reason = response.text().await.unwrap_or_default(); + reason.truncate(reason.ceil_char_boundary(MAX_ERROR_RESPONSE_LEN)); + + trc::event!( + PushSubscription(PushSubscriptionEvent::Error), + Details = "HTTP POST failed", + Url = details.url.to_string(), + Code = status.as_u16(), + Reason = reason, + ); + + false + } + } + Err(err) => { + trc::event!( + PushSubscription(PushSubscriptionEvent::Error), + Details = "HTTP POST failed", + Url = details.url.to_string(), + Reason = err.to_string() + ); + + false + } + } +} + +fn is_same_organization(previous: &Url, next: &Url) -> bool { + if previous.scheme() == next.scheme() + && let (Some(previous_host), Some(next_host)) = (previous.host_str(), next.host_str()) + { + if is_ip_literal(previous_host) || is_ip_literal(next_host) { + previous_host == next_host + } else { + match (psl::domain_str(previous_host), psl::domain_str(next_host)) { + (Some(previous_domain), Some(next_domain)) => previous_domain == next_domain, + _ => previous_host == next_host, + } + } + } else { + false + } +} + +fn is_ip_literal(host: &str) -> bool { + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .parse::() + .is_ok() +} + +#[cfg(test)] +mod tests { + use super::is_same_organization; + use reqwest::Url; + + #[test] + fn same_organization_redirects() { + for (previous, next, expected) in [ + ( + "https://push.example.org/a", + "https://push.example.org/b", + true, + ), + ( + "https://push.example.org/a", + "https://push2.example.org/b", + true, + ), + ("https://push.example.org/a", "https://example.org/b", true), + ( + "https://push.example.org/a", + "https://push.example.org:8443/b", + true, + ), + ( + "https://push.example.org/a", + "https://push.evil.org/b", + false, + ), + ( + "https://push.example.org/a", + "http://push.example.org/b", + false, + ), + ( + "https://push.example.co.uk/a", + "https://evil.co.uk/b", + false, + ), + ("https://1.2.3.4/a", "https://1.2.3.4/b", true), + ("https://1.2.3.4/a", "https://5.6.7.8/b", false), + ("https://1.2.3.4/a", "https://5.6.3.4/b", false), + ("https://1.2.3.4/a", "https://127.0.0.1/b", false), + ("https://[2606:4700::1111]/a", "https://[::1]/b", false), + ( + "https://[2606:4700::1111]/a", + "https://[2606:4700::1111]/b", + true, + ), + ] { + let previous = Url::parse(previous).unwrap(); + let next = Url::parse(next).unwrap(); + + assert_eq!( + is_same_organization(&previous, &next), + expected, + "{previous} -> {next}" + ); + } + } +} diff --git a/crates/services/src/state_manager/manager.rs b/crates/services/src/state_manager/manager.rs new file mode 100644 index 0000000..121d993 --- /dev/null +++ b/crates/services/src/state_manager/manager.rs @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Event, PURGE_EVERY, SEND_TIMEOUT, push::spawn_push_manager}; +use crate::state_manager::IpcSubscriber; +use common::{ + Inner, + ipc::{BroadcastEvent, PushEvent}, +}; +use std::{sync::Arc, time::Instant}; +use store::ahash::AHashMap; +use tokio::sync::mpsc; +use trc::ServerEvent; + +#[derive(Default)] +struct Subscriber { + ipc: Vec, + is_push: bool, +} + +#[allow(clippy::unwrap_or_default)] +pub fn spawn_push_router(inner: Arc, mut change_rx: mpsc::Receiver) { + let push_tx = spawn_push_manager(inner.clone()); + + tokio::spawn(async move { + let mut subscribers: AHashMap = AHashMap::default(); + let mut last_purge = Instant::now(); + + while let Some(event) = change_rx.recv().await { + let mut purge_needed = last_purge.elapsed() >= PURGE_EVERY; + + match event { + PushEvent::Stop => { + if push_tx.send(Event::Reset).await.is_err() { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push reset.", + CausedBy = trc::location!() + ); + } + break; + } + + PushEvent::Subscribe { + account_ids, + types, + tx, + } => { + for account_id in account_ids { + subscribers + .entry(account_id) + .or_default() + .ipc + .push(IpcSubscriber { + types, + tx: tx.clone(), + }); + } + } + + PushEvent::PushServerRegister { activate, expired } => { + for account_id in activate { + subscribers.entry(account_id).or_default().is_push = true; + } + + for account_id in expired { + let mut remove_account = false; + if let Some(subscriber_list) = subscribers.get_mut(&account_id) { + subscriber_list.is_push = false; + remove_account = subscriber_list.ipc.is_empty(); + } + if remove_account { + subscribers.remove(&account_id); + } + } + } + + PushEvent::Publish { + notification, + broadcast, + } => { + // Publish event to cluster + if broadcast + && let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone() + && broadcast_tx + .send(BroadcastEvent::PushNotification(notification.clone())) + .await + .is_err() + { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending broadcast event.", + CausedBy = trc::location!() + ); + } + + let account_id = notification.account_id(); + if let Some(subscribers) = subscribers.get(&account_id) { + for subscriber in &subscribers.ipc { + if let Some(notification) = notification.filter_types(&subscriber.types) + { + if subscriber.is_valid() { + let subscriber_tx = subscriber.tx.clone(); + + tokio::spawn(async move { + // Timeout after 500ms in case there is a blocked client + if subscriber_tx + .send_timeout(notification, SEND_TIMEOUT) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = + "Error sending state change to subscriber.", + CausedBy = trc::location!() + ); + } + }); + } else { + purge_needed = true; + } + } + } + + if subscribers.is_push + && push_tx.send(Event::Push { notification }).await.is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push updates.", + CausedBy = trc::location!() + ); + } + } + } + + PushEvent::PushServerUpdate { + account_id, + broadcast, + } => { + // Publish event to cluster + if broadcast + && let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone() + && broadcast_tx + .send(BroadcastEvent::PushServerUpdate(account_id)) + .await + .is_err() + { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending broadcast event.", + CausedBy = trc::location!() + ); + } + + // Notify push manager + if push_tx.send(Event::Update { account_id }).await.is_err() { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending push updates.", + CausedBy = trc::location!() + ); + } + } + } + + if purge_needed { + let mut remove_account_ids = Vec::new(); + + for (account_id, subscribers) in &mut subscribers { + subscribers.ipc.retain(|subscriber| subscriber.is_valid()); + + if subscribers.ipc.is_empty() && !subscribers.is_push { + remove_account_ids.push(*account_id); + } + } + + for remove_account_id in remove_account_ids { + subscribers.remove(&remove_account_id); + } + + last_purge = Instant::now(); + } + } + }); +} diff --git a/crates/services/src/state_manager/mod.rs b/crates/services/src/state_manager/mod.rs new file mode 100644 index 0000000..851932c --- /dev/null +++ b/crates/services/src/state_manager/mod.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod ece; +pub mod email_push; +pub mod http; +pub mod manager; +pub mod push; + +use common::ipc::PushNotification; +use email::push::PushSubscription; +use reqwest::Client; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc; +use types::{id::Id, type_state::DataType}; +use utils::map::bitmap::Bitmap; + +const PURGE_EVERY: Duration = Duration::from_secs(3600); +const SEND_TIMEOUT: Duration = Duration::from_millis(500); + +#[derive(Debug)] +struct IpcSubscriber { + types: Bitmap, + tx: mpsc::Sender, +} + +#[derive(Debug)] +pub struct PushRegistration { + server: Arc, + member_account_ids: Vec, + num_attempts: u32, + last_request: Instant, + notifications: Vec, + in_flight: bool, + client: Client, +} + +#[derive(Debug)] +pub enum Event { + Push { + notification: PushNotification, + }, + Update { + account_id: u32, + }, + DeliverySuccess { + id: Id, + }, + DeliveryFailure { + id: Id, + notifications: Vec, + }, + Reset, +} + +impl IpcSubscriber { + fn is_valid(&self) -> bool { + !self.tx.is_closed() + } +} diff --git a/crates/services/src/state_manager/push.rs b/crates/services/src/state_manager/push.rs new file mode 100644 index 0000000..a2a9026 --- /dev/null +++ b/crates/services/src/state_manager/push.rs @@ -0,0 +1,521 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Event, + http::{build_push_client, http_request}, +}; +use crate::state_manager::PushRegistration; +use common::{ + BuildServer, IPC_CHANNEL_BUFFER, Inner, LONG_1Y_SLUMBER, Server, + auth::BuildAccessToken, + ipc::{PushEvent, PushNotification}, +}; +use email::push::{PushSubscription, PushSubscriptions, Urgency}; +use std::{ + collections::hash_map::Entry, + sync::Arc, + time::{Duration, Instant}, +}; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + write::{AlignedBytes, Archive, now}, +}; +use tokio::sync::mpsc; +use trc::{AddContext, PushSubscriptionEvent, ServerEvent}; +use types::{collection::Collection, field::PrincipalField, id::Id}; + +pub fn spawn_push_manager(inner: Arc) -> mpsc::Sender { + let (push_tx_, mut push_rx) = mpsc::channel::(IPC_CHANNEL_BUFFER); + let push_tx = push_tx_.clone(); + + tokio::spawn(async move { + let mut push_servers: AHashMap = AHashMap::default(); + let mut account_push_ids: AHashMap> = AHashMap::default(); + let mut last_verify: AHashMap = AHashMap::default(); + let mut last_retry = Instant::now(); + let mut retry_timeout = LONG_1Y_SLUMBER; + let mut retry_ids = AHashSet::default(); + let push_client = build_push_client(); + + // Load active subscriptions on startup + { + let server = inner.build_server(); + + if server.core.network.roles.push_notifications { + match server + .document_ids( + u32::MAX, + Collection::Principal, + PrincipalField::PushSubscriptions, + ) + .await + { + Ok(account_ids) => { + for account_id in account_ids { + if server.core.jmap.push_total_shards <= 1 + || account_id % server.core.jmap.push_total_shards + == server.registry().cluster_push_shard() + { + // Load push subscriptions for account + let (subscriptions, member_account_ids) = + match load_push_subscriptions(&server, account_id).await { + Ok(subscriptions) => subscriptions, + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + continue; + } + }; + let current_time = now(); + for subscription in subscriptions + .subscriptions + .into_iter() + .filter(|s| s.verified && s.expires > current_time) + { + let id = Id::from_parts(subscription.id, account_id); + let subscription = Arc::new(subscription); + + for account_id in &member_account_ids { + account_push_ids.entry(*account_id).or_default().insert(id); + } + push_servers.insert( + id, + PushRegistration { + member_account_ids: member_account_ids.clone(), + num_attempts: 0, + last_request: Instant::now() + - (server.core.jmap.push_throttle + + Duration::from_millis(1)), + notifications: Vec::new(), + server: subscription.clone(), + in_flight: false, + client: push_client.clone(), + }, + ); + } + } + } + } + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + } + } + + // Subscribe to push events + if !account_push_ids.is_empty() + && server + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::PushServerRegister { + activate: account_push_ids.keys().copied().collect(), + expired: vec![], + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending state change.", + CausedBy = trc::location!() + ); + } + } + } + + loop { + // Wait for the next event or timeout + let event_or_timeout = tokio::time::timeout(retry_timeout, push_rx.recv()).await; + + // Load settings + let server = inner.build_server(); + let push_attempt_interval = server.core.jmap.push_attempt_interval; + let push_attempts_max = server.core.jmap.push_attempts_max; + let push_retry_interval = server.core.jmap.push_retry_interval; + let push_timeout = server.core.jmap.push_timeout; + let push_verify_timeout = server.core.jmap.push_verify_timeout; + let push_throttle = server.core.jmap.push_throttle; + + match event_or_timeout { + Ok(Some(event)) => match event { + Event::Update { account_id } => { + if server.core.jmap.push_total_shards > 1 + && account_id % server.core.jmap.push_total_shards + != server.registry().cluster_push_shard() + { + continue; + } + + // Load push subscriptions for account + let (subscriptions, member_account_ids) = + match load_push_subscriptions(&server, account_id).await { + Ok(subscriptions) => subscriptions, + Err(err) => { + trc::error!(err.caused_by(trc::location!())); + continue; + } + }; + let old_account_push_ids = account_push_ids + .remove(&account_id) + .filter(|v| !v.is_empty()); + + // Process subscriptions + let current_time = now(); + let mut newest_unverified: Option> = None; + for subscription in subscriptions + .subscriptions + .into_iter() + .filter(|s| s.expires > current_time) + { + let id = Id::from_parts(subscription.id, account_id); + let subscription = Arc::new(subscription); + + if subscription.verified { + for account_id in &member_account_ids { + account_push_ids.entry(*account_id).or_default().insert(id); + } + + match push_servers.entry(id) { + Entry::Occupied(mut entry) => { + // Update existing subscription + let entry = entry.get_mut(); + entry.server = subscription.clone(); + entry.member_account_ids = member_account_ids.clone(); + } + Entry::Vacant(entry) => { + entry.insert(PushRegistration { + member_account_ids: member_account_ids.clone(), + num_attempts: 0, + last_request: Instant::now() + - (push_throttle + Duration::from_millis(1)), + notifications: Vec::new(), + server: subscription.clone(), + in_flight: false, + client: push_client.clone(), + }); + } + } + } else { + match &newest_unverified { + Some(existing) if existing.id >= subscription.id => {} + _ => newest_unverified = Some(subscription), + } + } + } + + if let Some(subscription) = newest_unverified { + let current_time = Instant::now(); + + #[cfg(feature = "test_mode")] + if subscription.url.contains("skip_checks") { + last_verify.insert( + account_id, + current_time - (push_verify_timeout + Duration::from_millis(1)), + ); + } + + if last_verify + .get(&account_id) + .map(|last_verify| { + current_time - *last_verify > push_verify_timeout + }) + .unwrap_or(true) + { + let core = server.core.clone(); + let push_client = push_client.clone(); + tokio::spawn(async move { + http_request( + &push_client, + &subscription, + format!( + concat!( + "{{\"@type\":\"PushVerification\",", + "\"pushSubscriptionId\":\"{}\",", + "\"verificationCode\":\"{}\"}}" + ), + Id::from(subscription.id), + subscription.verification_code + ) + .into_bytes(), + push_timeout, + core.jmap.vapid.as_ref(), + Urgency::Normal, + ) + .await; + }); + + last_verify.insert(account_id, current_time); + } else { + trc::event!( + PushSubscription(PushSubscriptionEvent::Error), + Details = "Failed to verify push subscription", + Url = subscription.url.clone(), + AccountId = account_id, + Reason = "Too many requests" + ); + } + } + + // Update subscriptions + let mut remove_push_ids = AHashSet::new(); + let mut active_account_ids = Vec::new(); + let mut inactive_account_ids = Vec::new(); + match (old_account_push_ids, account_push_ids.get(&account_id)) { + (Some(old), Some(current)) if &old != current => { + for id in old.difference(current) { + remove_push_ids.insert(*id); + } + active_account_ids = member_account_ids; + } + (Some(old), None) => { + remove_push_ids = old; + } + (None, Some(_)) => { + active_account_ids = member_account_ids; + } + _ => {} + } + + // Update push server registrations + if !remove_push_ids.is_empty() { + for id in remove_push_ids { + if let Some(subscription) = push_servers.remove(&id) { + for account_id in &subscription.member_account_ids { + if let Some(ids) = account_push_ids.get_mut(account_id) { + ids.remove(&id); + if ids.is_empty() { + account_push_ids.remove(account_id); + inactive_account_ids.push(*account_id); + } + } + } + } + } + } + if (!active_account_ids.is_empty() || !inactive_account_ids.is_empty()) + && server + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::PushServerRegister { + activate: active_account_ids, + expired: inactive_account_ids, + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending state change.", + CausedBy = trc::location!() + ); + } + } + Event::Push { notification } => { + let account_id = notification.account_id(); + if let Some(ids) = account_push_ids.get_mut(&account_id) { + let current_time = now(); + let mut remove_ids = Vec::new(); + + for id in ids.iter() { + if let Some(subscription) = push_servers.get_mut(id) { + if subscription.server.expires > current_time { + if let Some(mut notification) = + notification.filter_types(&subscription.server.types) + { + if let PushNotification::EmailPush(email_push) = + ¬ification + && !subscription + .server + .email_push + .iter() + .any(|ep| ep.account_id == account_id) + { + notification = PushNotification::StateChange( + email_push.to_state_change(), + ); + } + + subscription.notifications.push(notification); + let last_request = subscription.last_request.elapsed(); + + if !subscription.in_flight + && ((subscription.num_attempts == 0 + && last_request > push_throttle) + || ((1..push_attempts_max) + .contains(&subscription.num_attempts) + && last_request > push_attempt_interval)) + { + subscription.send( + *id, + push_tx.clone(), + push_timeout, + server.clone(), + ); + retry_ids.remove(id); + } else { + retry_ids.insert(*id); + } + } + } else { + push_servers.remove(id); + } + } else { + remove_ids.push(*id); + } + } + + if !remove_ids.is_empty() { + for remove_id in remove_ids { + ids.remove(&remove_id); + } + if ids.is_empty() { + account_push_ids.remove(&account_id); + if server + .inner + .ipc + .push_tx + .clone() + .send(PushEvent::PushServerRegister { + activate: vec![], + expired: vec![account_id], + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Details = "Error sending state change.", + CausedBy = trc::location!() + ); + } + } + } + } + } + Event::Reset => { + push_servers.clear(); + account_push_ids.clear(); + } + Event::DeliverySuccess { id } => { + if let Some(subscription) = push_servers.get_mut(&id) { + subscription.num_attempts = 0; + subscription.in_flight = false; + retry_ids.remove(&id); + } + } + Event::DeliveryFailure { id, notifications } => { + if let Some(subscription) = push_servers.get_mut(&id) { + subscription.last_request = Instant::now(); + subscription.num_attempts += 1; + subscription.notifications.extend(notifications); + subscription.in_flight = false; + retry_ids.insert(id); + } + } + }, + Ok(None) => { + break; + } + Err(_) => (), + } + + retry_timeout = if !retry_ids.is_empty() { + let last_retry_elapsed = last_retry.elapsed(); + + if last_retry_elapsed >= push_retry_interval { + let mut remove_ids = Vec::with_capacity(retry_ids.len()); + + for retry_id in &retry_ids { + if let Some(subscription) = push_servers.get_mut(retry_id) { + let last_request = subscription.last_request.elapsed(); + + if !subscription.in_flight + && ((subscription.num_attempts == 0 + && last_request >= push_throttle) + || (subscription.num_attempts > 0 + && last_request >= push_attempt_interval)) + { + if subscription.num_attempts < push_attempts_max { + subscription.send( + *retry_id, + push_tx.clone(), + push_timeout, + server.clone(), + ); + } else { + trc::event!( + PushSubscription(PushSubscriptionEvent::Error), + Details = "Failed to deliver push subscription", + Url = subscription.server.url.clone(), + Reason = "Too many failed attempts" + ); + + subscription.notifications.clear(); + subscription.num_attempts = 0; + } + remove_ids.push(*retry_id); + } + } else { + remove_ids.push(*retry_id); + } + } + + if remove_ids.len() < retry_ids.len() { + for remove_id in remove_ids { + retry_ids.remove(&remove_id); + } + last_retry = Instant::now(); + push_retry_interval + } else { + retry_ids.clear(); + LONG_1Y_SLUMBER + } + } else { + push_retry_interval - last_retry_elapsed + } + } else { + LONG_1Y_SLUMBER + }; + } + }); + + push_tx_ +} + +async fn load_push_subscriptions( + server: &Server, + account_id: u32, +) -> trc::Result<(PushSubscriptions, Vec)> { + let member_of = server + .access_token(account_id) + .await + .caused_by(trc::location!())? + .build() + .member_ids() + .collect::>(); + + if let Some(push_subscriptions) = server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Principal, + 0, + PrincipalField::PushSubscriptions, + )) + .await? + { + push_subscriptions + .deserialize::() + .map(|push_subscriptions| (push_subscriptions, member_of)) + .caused_by(trc::location!()) + } else { + Ok((PushSubscriptions::default(), member_of)) + } +} diff --git a/crates/services/src/task_manager/acme.rs b/crates/services/src/task_manager/acme.rs new file mode 100644 index 0000000..662fc4c --- /dev/null +++ b/crates/services/src/task_manager/acme.rs @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::{TaskFailureType, TaskResult}; +use common::{Server, network::acme::AcmeError}; +use registry::schema::structs::TaskDomainManagement; +use std::time::Duration; +use store::write::now; + +pub(crate) trait AcmeTask: Sync + Send { + fn acme_management( + &self, + task: &TaskDomainManagement, + ) -> impl Future + Send; +} + +impl AcmeTask for Server { + async fn acme_management(&self, task: &TaskDomainManagement) -> TaskResult { + match acme_management(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to run ACME task") + ); + result + } + } + } +} + +#[cfg(not(feature = "test_mode"))] +const MAX_RETRIES: u32 = 3; + +#[cfg(feature = "test_mode")] +const MAX_RETRIES: u32 = 5; + +#[allow(unused_variables)] +async fn acme_management(server: &Server, task: &TaskDomainManagement) -> trc::Result { + let mut last_temporary_error = Ok(TaskResult::temporary("")); + for retry in 0..MAX_RETRIES { + last_temporary_error = match Box::pin(server.acme_renew(task.domain_id)).await { + Ok(tasks) => return Ok(TaskResult::Success(tasks)), + Err(err) => { + if !matches!( + err, + AcmeError::NotDue(_) + | AcmeError::Internal(_) + | AcmeError::AuthInvalid(_) + | AcmeError::OrderInvalid(_) + | AcmeError::AuthTimeout { .. } + | AcmeError::Backoff { .. } + ) { + trc::event!( + Acme(trc::AcmeEvent::Error), + Id = task.domain_id.to_string(), + Total = retry as u64, + Reason = err.to_string(), + ); + } + + match err { + AcmeError::Crypto(_) + | AcmeError::Invalid(_) + | AcmeError::NotDue(_) + | AcmeError::ChallengeNotSupported { .. } => { + return Ok(TaskResult::permanent(err.to_string())); + } + AcmeError::OrderInvalid(_) | AcmeError::Json(_) | AcmeError::Registry(_) => { + return Ok(TaskResult::perpetual(err.to_string())); + } + AcmeError::Http(_) + | AcmeError::HttpStatus(_) + | AcmeError::Dns(_) + | AcmeError::AuthInvalid(_) => Ok(TaskResult::temporary(err.to_string())), + AcmeError::OrderTimeout { max_retries } + | AcmeError::AuthTimeout { max_retries } => Ok(TaskResult::Failure { + typ: TaskFailureType::Temporary, + message: err.to_string(), + max_attempts: (max_retries as u64).into(), + }), + AcmeError::Backoff { max_retries, wait } => { + return if let Some(wait) = wait { + Ok(TaskResult::Failure { + typ: TaskFailureType::Retry(now() + wait.as_secs()), + message: err.to_string(), + max_attempts: (max_retries as u64).into(), + }) + } else { + Ok(TaskResult::Failure { + typ: TaskFailureType::Temporary, + message: err.to_string(), + max_attempts: (max_retries as u64).into(), + }) + }; + } + AcmeError::Internal(error) => return Err(error), + } + } + }; + + #[cfg(not(feature = "test_mode"))] + tokio::time::sleep(Duration::from_secs(1 << (retry + 5))).await; + + #[cfg(feature = "test_mode")] + tokio::time::sleep(Duration::from_secs(1)).await; + } + last_temporary_error +} diff --git a/crates/services/src/task_manager/alarm.rs b/crates/services/src/task_manager/alarm.rs new file mode 100644 index 0000000..bb7fab9 --- /dev/null +++ b/crates/services/src/task_manager/alarm.rs @@ -0,0 +1,669 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::icalendar::{ + ArchivedICalendarParameterName, ArchivedICalendarProperty, ICalendarProperty, +}; +use common::{ + DEFAULT_LOGO_BASE64, Server, + auth::{AccountInfo, BuildAccessToken}, + config::groupware::CalendarTemplateVariable, + ipc::{CalendarAlert, PushNotification}, + network::{ServerInstance, stream::NullIo}, +}; +use groupware::{ + calendar::{ArchivedCalendarEvent, CalendarEvent}, + scheduling::{ + ItipTime, ItipValue, + format::{DateStyle, TextFormatter, hyperlink}, + }, + strip_mailto_scheme, +}; +use mail_builder::{ + MessageBuilder, + headers::{HeaderType, content_type::ContentType}, + mime::{BodyPart, MimePart}, +}; +use mail_parser::decoders::html::html_to_text; +use registry::{ + schema::{ + enums::Permission, + structs::{TaskCalendarAlarmEmail, TaskCalendarAlarmNotification}, + }, + types::EnumImpl, +}; +use smtp::core::{Session, SessionData}; +use smtp_proto::{MailFrom, RcptTo}; +use std::{sync::Arc, time::Duration}; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, now}, +}; +use trc::{AddContext, TaskManagerEvent}; +use types::collection::Collection; +use utils::{sanitize_email, template::Variables}; + +use crate::task_manager::TaskResult; + +pub(crate) trait SendAlarmTask: Sync + Send { + fn send_display_alarm( + &self, + task: &TaskCalendarAlarmNotification, + ) -> impl Future + Send; + + fn send_email_alarm( + &self, + task: &TaskCalendarAlarmEmail, + server_instance: Arc, + ) -> impl Future + Send; +} + +impl SendAlarmTask for Server { + async fn send_display_alarm(&self, task: &TaskCalendarAlarmNotification) -> TaskResult { + match send_display_alarm(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .document_id(task.document_id.document_id()) + .caused_by(trc::location!()) + .details("Failed to process e-mail alarm") + ); + result + } + } + } + + async fn send_email_alarm( + &self, + task: &TaskCalendarAlarmEmail, + server_instance: Arc, + ) -> TaskResult { + match send_email_alarm(self, task, server_instance).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .document_id(task.document_id.document_id()) + .caused_by(trc::location!()) + .details("Failed to process e-mail alarm") + ); + result + } + } + } +} + +async fn send_email_alarm( + server: &Server, + task: &TaskCalendarAlarmEmail, + server_instance: Arc, +) -> trc::Result { + // Obtain access token + let account_id = task.account_id.document_id(); + let document_id = task.document_id.document_id(); + let access_token = server + .access_token(account_id) + .await + .caused_by(trc::location!())? + .build(); + + if !access_token.has_permission(Permission::CalendarAlarmsSend) { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSkipped), + Reason = "Account does not have permission to send calendar alarms", + AccountId = account_id, + DocumentId = document_id, + ); + return Ok(TaskResult::Success(vec![])); + } + let account_info = server + .account_info(account_id) + .await + .caused_by(trc::location!())?; + + if account_info.name().is_empty() { + trc::event!( + Calendar(trc::CalendarEvent::AlarmFailed), + Reason = "Account does not have any email addresses", + AccountId = account_id, + DocumentId = document_id, + ); + return Ok(TaskResult::Success(vec![])); + } + + // Fetch event + let Some(event_) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + trc::event!( + TaskManager(TaskManagerEvent::MetadataNotFound), + Details = "Calendar Event metadata not found", + AccountId = account_id, + DocumentId = document_id, + ); + + return Ok(TaskResult::Success(vec![])); + }; + + // Unarchive event + let event = event_ + .unarchive::() + .caused_by(trc::location!())?; + + // Build message body + let account_main_email = account_info.name(); + let account_main_domain = account_main_email.rsplit('@').next().unwrap_or("localhost"); + let logo_cid = format!("logo.{}@{account_main_domain}", now()); + let Some(tpl) = build_template(server, &account_info, task, event, &logo_cid).await? else { + return Ok(TaskResult::Success(vec![])); + }; + let txt_body = html_to_text(&tpl.body); + + // Obtain logo image + let logo = match server.logo_resource(account_main_domain).await { + Ok(logo) => logo, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to fetch logo image") + ); + None + } + }; + let logo = if let Some(logo) = &logo { + MimePart::new( + ContentType::new(logo.content_type.as_ref()), + BodyPart::Binary(logo.contents.as_slice().into()), + ) + } else { + MimePart::new( + ContentType::new("image/png"), + BodyPart::Binary(DEFAULT_LOGO_BASE64.as_bytes().into()), + ) + .transfer_encoding("base64") + } + .inline() + .cid(&logo_cid); + + // Build message + let mail_from = if let Some(from_email) = &server.core.groupware.alarms_from_email { + from_email.to_string() + } else { + format!("calendar-notification@{account_main_domain}") + }; + let message = MessageBuilder::new() + .from(( + server.core.groupware.alarms_from_name.as_str(), + mail_from.as_str(), + )) + .header("To", HeaderType::Text(tpl.to.as_str().into())) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .header("Reply-To", HeaderType::Text(account_main_email.into())) + .message_id(server.core.network.message_id()) + .subject(tpl.subject) + .body(MimePart::new( + ContentType::new("multipart/related"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("multipart/alternative"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("text/plain"), + BodyPart::Text(txt_body.into()), + ), + MimePart::new( + ContentType::new("text/html"), + BodyPart::Text(tpl.body.into()), + ), + ]), + ), + logo, + ]), + )) + .write_to_vec() + .unwrap_or_default(); + + // Send message + let server_ = server.clone(); + let mail_from = account_main_email.to_string(); + let to = tpl.to; + let result = tokio::spawn(async move { + let mut session = Session::::local( + server_, + server_instance, + SessionData::local(account_info, None, vec![], vec![], 0), + ); + + // MAIL FROM + let _ = session + .handle_mail_from(MailFrom { + address: mail_from.into(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + return Err(format!("Server rejected MAIL-FROM: {}", error.trim())); + } + + // RCPT TO + session.params.rcpt_errors_wait = Duration::from_secs(0); + let _ = session + .handle_rcpt_to(RcptTo { + address: to.into(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + return Err(format!("Server rejected RCPT-TO: {}", error.trim())); + } + + // DATA + session.data.message = message; + let response = session.queue_message().await; + if let smtp::core::State::Accepted(queue_id) = session.state { + Ok(queue_id) + } else { + Err(format!( + "Server rejected DATA: {}", + std::str::from_utf8(&response).unwrap().trim() + )) + } + }) + .await; + + match result { + Ok(Ok(queue_id)) => { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSent), + AccountId = account_id, + DocumentId = document_id, + QueueId = queue_id, + ); + } + Ok(Err(err)) => { + trc::event!( + Calendar(trc::CalendarEvent::AlarmFailed), + AccountId = account_id, + DocumentId = document_id, + Reason = err, + ); + } + Err(_) => { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Join Error", + AccountId = account_id, + DocumentId = document_id, + CausedBy = trc::location!(), + ); + return Ok(TaskResult::temporary("Thread join error")); + } + } + + build_next_alarm(server, account_id, document_id, event) +} + +async fn send_display_alarm( + server: &Server, + task: &TaskCalendarAlarmNotification, +) -> trc::Result { + // Fetch event + let account_id = task.account_id.document_id(); + let document_id = task.document_id.document_id(); + let Some(event_) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .caused_by(trc::location!())? + else { + trc::event!( + TaskManager(TaskManagerEvent::MetadataNotFound), + Details = "Calendar Event metadata not found", + AccountId = account_id, + DocumentId = document_id, + ); + + return Ok(TaskResult::Success(vec![])); + }; + + // Unarchive event + let event = event_ + .unarchive::() + .caused_by(trc::location!())?; + + let recurrence_id = task.recurrence_id; + + let ical = &event.data.event; + server + .broadcast_push_notification(PushNotification::CalendarAlert(CalendarAlert { + account_id, + event_id: document_id, + recurrence_id, + uid: ical.uids().next().unwrap_or_default().to_string(), + alert_id: ical + .components + .get(task.alarm_id as usize) + .and_then(|c| c.property(&ICalendarProperty::Jsid)) + .and_then(|v| v.values.first()) + .and_then(|v| v.as_text()) + .map(|v| v.to_string()) + .unwrap_or_else(|| { + format!( + "k{}", + ical.components + .get(task.event_id as usize) + .and_then(|c| c + .component_ids + .iter() + .position(|id| id.to_native() == task.alarm_id as u32)) + .unwrap_or_default() + + 1 + ) + }), + })) + .await; + + build_next_alarm(server, account_id, document_id, event) +} + +fn build_next_alarm( + server: &Server, + account_id: u32, + document_id: u32, + event: &ArchivedCalendarEvent, +) -> trc::Result { + // Find next alarm time and write to task queue + let now = now() as i64; + if let Some(next_alarm) = + event + .data + .next_alarm(now, Default::default()) + .and_then(|next_alarm| { + // Verify minimum interval + let max_next_alarm = now + server.core.groupware.alarms_minimum_interval; + if next_alarm.alarm_time < max_next_alarm { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSkipped), + Reason = "Next alarm skipped due to minimum interval", + Details = next_alarm.alarm_time - now, + AccountId = account_id, + DocumentId = document_id, + ); + event.data.next_alarm(max_next_alarm, Default::default()) + } else { + Some(next_alarm) + } + }) + { + Ok(TaskResult::Update( + next_alarm.build_write_ops(account_id, document_id), + )) + } else { + Ok(TaskResult::Success(vec![])) + } +} + +struct Details { + to: String, + subject: String, + body: String, +} + +async fn build_template( + server: &Server, + account_info: &AccountInfo, + alarm: &TaskCalendarAlarmEmail, + event: &ArchivedCalendarEvent, + logo_cid: &str, +) -> trc::Result> { + let account_id = alarm.account_id.document_id(); + let document_id = alarm.document_id.document_id(); + let (Some(event_component), Some(alarm_component)) = ( + event.data.event.components.get(alarm.event_id as usize), + event.data.event.components.get(alarm.alarm_id as usize), + ) else { + trc::event!( + TaskManager(TaskManagerEvent::MetadataNotFound), + Details = "Calendar Alarm component not found", + AccountId = account_id, + DocumentId = document_id, + ); + return Ok(None); + }; + + // Build webcal URI + let webcal_uri = match event.webcal_uri(server, account_info).await { + Ok(uri) => uri, + Err(err) => { + trc::error!( + err.account_id(account_id) + .document_id(document_id) + .caused_by(trc::location!()) + .details("Failed to generate webcal URI") + ); + String::from("#") + } + }; + + // Obtain alarm details + let mut summary = None; + let mut description = None; + let mut rcpt_to = None; + let mut location = None; + let mut conference = None; + let mut organizer = None; + let mut guests = vec![]; + + for entry in alarm_component.entries.iter() { + match &entry.name { + ArchivedICalendarProperty::Summary => { + summary = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Description => { + description = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Attendee => { + rcpt_to = entry + .values + .first() + .and_then(|v| v.as_text()) + .map(strip_mailto_scheme) + .and_then(sanitize_email); + } + _ => {} + } + } + + for entry in event_component.entries.iter() { + match &entry.name { + ArchivedICalendarProperty::Summary if summary.is_none() => { + summary = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Description if description.is_none() => { + description = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Location => { + location = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Conference if conference.is_none() => { + conference = entry.values.first().and_then(|v| v.as_text()); + } + ArchivedICalendarProperty::Organizer | ArchivedICalendarProperty::Attendee => { + let email = entry + .values + .first() + .and_then(|v| v.as_text()) + .map(strip_mailto_scheme); + let name = entry.params.iter().find_map(|param| { + if let ArchivedICalendarParameterName::Cn = param.name { + param.value.as_text() + } else { + None + } + }); + + if email.is_some() || name.is_some() { + if matches!(entry.name, ArchivedICalendarProperty::Organizer) { + organizer = Some((email, name)); + } else { + guests.push((email, name)); + } + } + } + _ => {} + } + } + + // Validate recipient + let rcpt_to = if let Some(rcpt_to) = rcpt_to { + if server.core.groupware.alarms_allow_external_recipients + || account_info.addresses().contains(&rcpt_to) + { + rcpt_to + } else { + trc::event!( + Calendar(trc::CalendarEvent::AlarmRecipientOverride), + Reason = "External recipient not allowed for calendar alarms", + Details = rcpt_to, + AccountId = account_id, + DocumentId = document_id, + ); + + account_info.name().to_string() + } + } else { + account_info.name().to_string() + }; + + + #[cfg(not(feature = "enterprise"))] + let template = &server.core.groupware.alarms_template; + let formatter = TextFormatter::new(account_info.locale().as_str())?; + let locale = formatter.locale; + + let start = formatter.field_to_string( + &ItipValue::Time(ItipTime { + start: alarm.event_start.timestamp(), + tz_id: alarm.event_start_tz as u16, + }), + DateStyle::Short, + ); + let end = formatter.field_to_string( + &ItipValue::Time(ItipTime { + start: alarm.event_end.timestamp(), + tz_id: alarm.event_end_tz as u16, + }), + DateStyle::Short, + ); + let subject = format!( + "{}: {} @ {}", + locale.calendar_alarm_subject_prefix, + summary.or(description).unwrap_or("No Subject"), + start + ); + let organizer = organizer + .map(|(email, name)| match (email, name) { + (Some(email), Some(name)) => format!("{} <{}>", name, email), + (Some(email), None) => email.to_string(), + (None, Some(name)) => name.to_string(), + _ => unreachable!(), + }) + .unwrap_or_else(|| account_info.name().to_string()); + let logo_cid = format!("cid:{logo_cid}"); + let mut variables = Variables::new(); + variables.insert_single(CalendarTemplateVariable::PageTitle, subject.as_str()); + variables.insert_single(CalendarTemplateVariable::Lang, locale.name); + variables.insert_single(CalendarTemplateVariable::Dir, locale.direction); + variables.insert_single( + CalendarTemplateVariable::Header, + locale.calendar_alarm_header, + ); + variables.insert_single( + CalendarTemplateVariable::Footer, + locale.calendar_alarm_footer, + ); + variables.insert_single( + CalendarTemplateVariable::ActionName, + locale.calendar_alarm_open, + ); + variables.insert_single(CalendarTemplateVariable::ActionUrl, webcal_uri.as_str()); + variables.insert_single( + CalendarTemplateVariable::AttendeesTitle, + locale.calendar_attendees, + ); + if let Some(summary) = summary.filter(|summary| !summary.is_empty()) { + variables.insert_single(CalendarTemplateVariable::EventTitle, summary); + } + variables.insert_single(CalendarTemplateVariable::LogoCid, logo_cid.as_str()); + if let Some(description) = description { + variables.insert_single(CalendarTemplateVariable::EventDescription, description); + } + variables.insert_block( + CalendarTemplateVariable::EventDetails, + [ + Some(vec![ + (CalendarTemplateVariable::Key, locale.calendar_start), + (CalendarTemplateVariable::Value, start.as_str()), + ]), + Some(vec![ + (CalendarTemplateVariable::Key, locale.calendar_end), + (CalendarTemplateVariable::Value, end.as_str()), + ]), + location.map(|location| { + vec![ + (CalendarTemplateVariable::Key, locale.calendar_location), + (CalendarTemplateVariable::Value, location), + ] + }), + conference.map(|conference| { + let mut detail = vec![ + (CalendarTemplateVariable::Key, locale.calendar_conference), + (CalendarTemplateVariable::Value, conference), + ]; + if let Some(link) = hyperlink(conference) { + detail.push((CalendarTemplateVariable::Link, link)); + } + detail + }), + Some(vec![ + (CalendarTemplateVariable::Key, locale.calendar_organizer), + (CalendarTemplateVariable::Value, organizer.as_str()), + ]), + ] + .into_iter() + .flatten(), + ); + if !guests.is_empty() { + variables.insert_block( + CalendarTemplateVariable::Attendees, + guests.into_iter().map(|(email, name)| { + [ + (CalendarTemplateVariable::Key, name.unwrap_or_default()), + (CalendarTemplateVariable::Value, email.unwrap_or_default()), + ] + }), + ); + } + Ok(Some(Details { + to: rcpt_to, + body: template.eval(&variables), + subject, + })) +} diff --git a/crates/services/src/task_manager/destroy_account.rs b/crates/services/src/task_manager/destroy_account.rs new file mode 100644 index 0000000..4a33f32 --- /dev/null +++ b/crates/services/src/task_manager/destroy_account.rs @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use common::Server; +use email::{message::metadata::MessageMetadata, sieve::SieveScript}; +use groupware::file::FileNode; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{ArchivedItem, TaskDestroyAccount}, + }, + types::EnumImpl, +}; +use store::{ + SerializeInfallible, ValueKey, + registry::RegistryQuery, + search::SearchQuery, + write::{BatchBuilder, BlobLink, BlobOp, RegistryClass, SearchIndex, ValueClass}, +}; +use trc::AddContext; +use types::{ + blob_hash::BlobHash, + collection::Collection, + field::{EmailField, Field}, + id::Id, +}; + +pub(crate) trait DestroyAccountTask: Sync + Send { + fn destroy_account(&self, task: &TaskDestroyAccount) + -> impl Future + Send; +} + +impl DestroyAccountTask for Server { + async fn destroy_account(&self, task: &TaskDestroyAccount) -> TaskResult { + match destroy_account(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .details("Failed to destroy account") + ); + result + } + } + } +} + +async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result { + let account_id = task.account_id.document_id(); + + // Destroy public keys and masked emails + for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] { + let mut batch = BatchBuilder::new(); + let ids = server + .registry() + .query::>(RegistryQuery::new(object).with_account(account_id)) + .await?; + let object_id = object.to_id(); + + for id in ids { + batch + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: id.id(), + })) + .clear(ValueClass::Registry(RegistryClass::IndexId { + object_id, + item_id: id.id(), + })) + .clear(ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId as u16, + object_id, + item_id: id.id(), + key: (account_id as u64).serialize(), + })) + .clear(ValueClass::Registry(RegistryClass::Reference { + to_object_id: ObjectType::Account as u16, + to_item_id: account_id as u64, + from_object_id: object_id, + from_item_id: id.id(), + })); + } + + if !batch.is_empty() { + server.store().write(batch.build_all()).await?; + } + } + + // Remove archived items + let mut batch = BatchBuilder::new(); + let ids = server + .registry() + .query::>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id)) + .await?; + for id in ids { + let object_id = ObjectType::ArchivedItem.to_id(); + let item_id = id.id(); + + if let Some(item) = server + .store() + .get_value::(ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id, + }))) + .await? + { + let until = item.archived_until().timestamp() as u64; + let blob_hash = item.into_blob_id().hash; + + batch + .with_account_id(account_id) + .clear(BlobOp::Link { + hash: blob_hash, + to: BlobLink::Temporary { until }, + }) + .clear(ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (account_id as u64).serialize(), + })) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id, + })); + } + } + if !batch.is_empty() { + server.store().write(batch.build_all()).await?; + } + + // Remove search index + for index in [ + SearchIndex::Email, + SearchIndex::Contacts, + SearchIndex::Calendar, + ] { + server + .search_store() + .unindex(SearchQuery::new(index).with_account_id(account_id)) + .await?; + } + + // Unlink all accounts's blobs + destroy_account_blobs(server, account_id).await?; + + // Destroy account data + server + .store() + .danger_destroy_account(account_id) + .await + .caused_by(trc::location!())?; + + Ok(TaskResult::Success(vec![])) +} + +pub async fn destroy_account_blobs(server: &Server, account_id: u32) -> trc::Result<()> { + let mut delete_keys = Vec::new(); + for (collection, field) in [ + (Collection::Email, u8::from(EmailField::Metadata)), + (Collection::FileNode, u8::from(Field::ARCHIVE)), + (Collection::SieveScript, u8::from(Field::ARCHIVE)), + ] { + server + .all_archives(account_id, collection, field, |document_id, archive| { + match collection { + Collection::Email => { + let message = archive.unarchive::()?; + delete_keys.push(( + collection, + document_id, + BlobHash::from(&message.blob_hash), + )); + } + Collection::FileNode => { + if let Some(file) = archive.unarchive::()?.file.as_ref() { + delete_keys.push(( + collection, + document_id, + BlobHash::from(&file.blob_hash), + )); + } + } + Collection::SieveScript => { + let sieve = archive.unarchive::()?; + delete_keys.push(( + collection, + document_id, + BlobHash::from(&sieve.blob_hash), + )); + } + _ => {} + } + Ok(()) + }) + .await + .caused_by(trc::location!())?; + } + + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + + for (collection, document_id, hash) in delete_keys { + if batch.is_large_batch() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + batch.with_account_id(account_id); + } + batch + .with_collection(collection) + .with_document(document_id) + .clear(ValueClass::Blob(BlobOp::Link { + hash, + to: BlobLink::Document, + })); + } + + if !batch.is_empty() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(()) +} diff --git a/crates/services/src/task_manager/dkim.rs b/crates/services/src/task_manager/dkim.rs new file mode 100644 index 0000000..59ab0f1 --- /dev/null +++ b/crates/services/src/task_manager/dkim.rs @@ -0,0 +1,633 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use common::{ + Server, + cache::invalidate::CacheInvalidationBuilder, + ipc::CacheInvalidation, + network::dkim::{ + generate_dkim_dns_record, generate_dkim_dns_record_name, generate_dkim_private_key, + generate_dkim_selector, + }, +}; +use registry::{ + schema::{ + enums::{DkimRotationStage, DkimSignatureType, DnsRecordType}, + prelude::{Object, ObjectType, Property}, + structs::{ + Dkim1Signature, Dkim2Signature, DkimManagement, DkimSignature, DnsManagement, Domain, + SecretText, SecretTextValue, Task, TaskDomainManagement, TaskStatus, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId}, +}; +use std::fmt::Write; +use store::{ + registry::{ + RegistryObject, RegistryQuery, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::now, +}; +use trc::{DkimEvent, DnsEvent}; +use types::id::Id; + +pub(crate) trait DkimManagementTask: Sync + Send { + fn dkim_management( + &self, + task: &TaskDomainManagement, + ) -> impl Future + Send; +} + +impl DkimManagementTask for Server { + async fn dkim_management(&self, task: &TaskDomainManagement) -> TaskResult { + match dkim_management(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to run DKIM management task") + ); + result + } + } + } +} + +async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::Result { + let Some(domain) = server.registry().object::(task.domain_id).await? else { + return Ok(TaskResult::permanent("Domain not found".to_string())); + }; + let DkimManagement::Automatic(dkim) = domain.dkim_management else { + return Ok(TaskResult::permanent( + "Domain is not set to automatic DKIM management".to_string(), + )); + }; + let mut create_signatures = dkim.algorithms.into_inner(); + if create_signatures.is_empty() { + return Ok(TaskResult::permanent( + "No DKIM algorithms configured for domain".to_string(), + )); + } + + let dns_updater = match domain.dns_management { + DnsManagement::Automatic(props) if props.publish_records.contains(&DnsRecordType::Dkim) => { + match server.build_dns_updater(props.dns_server_id).await? { + Ok(updater) => Some((updater, props.origin.unwrap_or_else(|| domain.name.clone()))), + Err(err) => { + return Ok(TaskResult::permanent(format!( + "Failed to build DNS updater: {}", + err + ))); + } + } + } + _ => None, + }; + + // Fetch existing DKIM keys + let mut publish_signatures = Vec::new(); + let mut retire_signatures = Vec::new(); + let mut retiring_signatures = Vec::new(); + let mut delete_signatures = Vec::new(); + let mut next_transition = None; + + let signature_ids = server + .registry() + .query::>( + RegistryQuery::new(ObjectType::DkimSignature) + .equal(Property::DomainId, task.domain_id.document_id()), + ) + .await?; + + for id in signature_ids { + let id = ObjectId::new(ObjectType::DkimSignature, id); + let Some(key) = server.registry().get(id).await? else { + continue; + }; + let key: RegistryObject = RegistryObject { + id, + revision: key.revision, + object: key.into(), + }; + + let key_algo = key.object.object_type(); + if let Some(current_stage) = key.object.rotation_due() { + match current_stage { + DkimRotationStage::Pending => { + create_signatures.retain(|algo| algo != &key_algo); + publish_signatures.push(key) + } + DkimRotationStage::Active => retiring_signatures.push(key), + DkimRotationStage::Retiring => retire_signatures.push(key), + DkimRotationStage::Retired => delete_signatures.push(key), + } + } else { + if key.object.is_active() { + create_signatures.retain(|algo| algo != &key_algo); + } + + if let Some(transition) = key.object.next_transition() + && next_transition.is_none_or(|next| transition < next) + { + next_transition = Some(transition); + } + } + } + + let now = now(); + let mut do_refresh = false; + + for algorithm in create_signatures { + #[cfg(feature = "test_mode")] + let secret = { + if dkim.selector_template.contains("dummy") { + match algorithm { + DkimSignatureType::Dkim1Ed25519Sha256 + | DkimSignatureType::Dkim2Ed25519Sha256 => TEST_ED25519_KEY.to_string(), + DkimSignatureType::Dkim1RsaSha256 | DkimSignatureType::Dkim2RsaSha256 => { + TEST_RSA_KEY.to_string() + } + } + } else { + generate_dkim_private_key(algorithm).await.unwrap().unwrap() + } + }; + + // Generate new key and selector + #[cfg(not(feature = "test_mode"))] + let secret = match generate_dkim_private_key(algorithm).await? { + Ok(secret) => secret, + Err(err) => { + return Ok(TaskResult::permanent(err.to_string())); + } + }; + let selector = match generate_dkim_selector(&dkim.selector_template, algorithm) { + Ok(selector) => selector, + Err(err) => { + return Ok(TaskResult::permanent(format!( + "Failed to generate DKIM selector: {}", + err + ))); + } + }; + + // Build key + let private_key = SecretText::Text(SecretTextValue { secret }); + let mut signature = match algorithm { + DkimSignatureType::Dkim1Ed25519Sha256 | DkimSignatureType::Dkim1RsaSha256 => { + let signature = Dkim1Signature { + stage: DkimRotationStage::Active, + domain_id: task.domain_id, + member_tenant_id: domain.member_tenant_id, + selector: selector.clone(), + private_key, + ..Default::default() + }; + if algorithm == DkimSignatureType::Dkim1Ed25519Sha256 { + DkimSignature::Dkim1Ed25519Sha256(signature) + } else { + DkimSignature::Dkim1RsaSha256(signature) + } + } + DkimSignatureType::Dkim2Ed25519Sha256 | DkimSignatureType::Dkim2RsaSha256 => { + let signature = Dkim2Signature { + stage: DkimRotationStage::Active, + domain_id: task.domain_id, + member_tenant_id: domain.member_tenant_id, + selector: selector.clone(), + private_key, + ..Default::default() + }; + if algorithm == DkimSignatureType::Dkim2Ed25519Sha256 { + DkimSignature::Dkim2Ed25519Sha256(signature) + } else { + DkimSignature::Dkim2RsaSha256(signature) + } + } + }; + + // Publish key + if let Some((updater, origin)) = &dns_updater { + let record = generate_dkim_dns_record(&signature, &domain.name).await?; + let dns_update::DnsRecord::TXT(txt_value) = &record.record else { + return Ok(TaskResult::permanent( + "DKIM record must be a TXT record".to_string(), + )); + }; + let propagation_target = txt_value.clone(); + let published = updater + .set_rrset( + origin, + &record.name, + dns_update::DnsRecordType::TXT, + vec![record.record.clone()], + ) + .await + .is_ok(); + let signature_transition = if published + && updater + .wait_for_txt_propagation(&record.name, origin, &propagation_target) + .await + { + trc::event!( + Dkim(DkimEvent::SignaturePublished), + Id = selector.clone(), + Details = domain.name.clone() + ); + + do_refresh = true; + UTCDateTime::from_timestamp((now + dkim.rotate_after.as_secs()) as i64) + } else { + // Something went wrong, reschedule. + signature.set_stage(DkimRotationStage::Pending); + UTCDateTime::from_timestamp((now + 60) as i64) // Retry after 1 minute + }; + + if next_transition.is_none_or(|next| signature_transition < next) { + next_transition = Some(signature_transition); + } + + signature.set_next_transition(signature_transition); + } + + // Write key + match server + .registry() + .write(RegistryWrite::insert(&signature.into())) + .await? + { + RegistryWriteResult::Success(_) => { + trc::event!( + Dkim(DkimEvent::SignatureCreated), + Id = selector, + Details = domain.name.clone() + ); + } + err => { + return Ok(TaskResult::permanent(format!( + "Failed to write DKIM signature: {err}" + ))); + } + } + } + + // Publish signatures + let mut temporary_errors = String::new(); + for signature in publish_signatures { + let record = generate_dkim_dns_record(&signature.object, &domain.name).await?; + if let Some((updater, origin)) = &dns_updater { + let dns_update::DnsRecord::TXT(txt_value) = &record.record else { + return Ok(TaskResult::permanent( + "DKIM record must be a TXT record".to_string(), + )); + }; + let propagation_target = txt_value.clone(); + let publish_result = updater + .set_rrset( + origin, + &record.name, + dns_update::DnsRecordType::TXT, + vec![record.record.clone()], + ) + .await; + let propagation_result = match &publish_result { + Ok(_) => Ok(updater + .wait_for_txt_propagation(&record.name, origin, &propagation_target) + .await), + Err(err) => Err(err.clone()), + }; + match propagation_result { + Ok(true) => { + let signature_transition = + UTCDateTime::from_timestamp((now + dkim.rotate_after.as_secs()) as i64); + + if next_transition.is_none_or(|next| signature_transition < next) { + next_transition = Some(signature_transition); + } + + let mut new_signature = signature.object.clone(); + + new_signature.set_next_transition(signature_transition); + new_signature.set_stage(DkimRotationStage::Active); + + trc::event!( + Dkim(DkimEvent::SignaturePublished), + Id = new_signature.selector().to_string(), + Details = domain.name.clone() + ); + + // Write key + if let Some(task_result) = update_signature( + server, + signature, + new_signature, + &record.name, + &mut temporary_errors, + ) + .await? + { + return Ok(task_result); + } + do_refresh = true; + } + Ok(false) => { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + &mut temporary_errors, + "DKIM record {} did not propagate, will retry.", + record.name + ); + } + Err(err) => { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + &mut temporary_errors, + "Failed to publish DKIM record {}: {err}.", + record.name + ); + } + } + } else { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + &mut temporary_errors, + "No DNS server configured, cannot publish DKIM record {}.", + record.name + ); + } + } + + // Retiring signatures + for signature in retiring_signatures { + let record = generate_dkim_dns_record_name(&signature.object, &domain.name); + let signature_transition = + UTCDateTime::from_timestamp((now + dkim.retire_after.as_secs()) as i64); + + if next_transition.is_none_or(|next| signature_transition < next) { + next_transition = Some(signature_transition); + } + + let mut new_signature = signature.object.clone(); + + new_signature.set_next_transition(signature_transition); + new_signature.set_stage(DkimRotationStage::Retiring); + + trc::event!( + Dkim(DkimEvent::SignatureRetiring), + Id = new_signature.selector().to_string(), + Details = domain.name.clone() + ); + + // Write key + if let Some(task_result) = update_signature( + server, + signature, + new_signature, + &record, + &mut temporary_errors, + ) + .await? + { + return Ok(task_result); + } + do_refresh = true; + } + + // Retire signatures + for signature in retire_signatures { + let record = generate_dkim_dns_record_name(&signature.object, &domain.name); + if let Some((updater, origin)) = &dns_updater { + match updater + .set_rrset(origin, &record, dns_update::DnsRecordType::TXT, Vec::new()) + .await + { + Ok(_) => { + let signature_transition = + UTCDateTime::from_timestamp((now + dkim.delete_after.as_secs()) as i64); + + if next_transition.is_none_or(|next| signature_transition < next) { + next_transition = Some(signature_transition); + } + + let mut new_signature = signature.object.clone(); + + new_signature.set_next_transition(signature_transition); + new_signature.set_stage(DkimRotationStage::Retired); + + trc::event!( + Dkim(DkimEvent::SignatureRetired), + Id = new_signature.selector().to_string(), + Details = domain.name.clone() + ); + + // Write key + if let Some(task_result) = update_signature( + server, + signature, + new_signature, + &record, + &mut temporary_errors, + ) + .await? + { + return Ok(task_result); + } + + do_refresh = true; + } + Err(err) => { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + &mut temporary_errors, + "Failed to remove DKIM record {}: {err}.", + record + ); + } + } + } else { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + &mut temporary_errors, + "No DNS server configured, cannot retire DKIM record {}.", + record + ); + } + } + + // Delete signatures + for signature in delete_signatures { + let record = generate_dkim_dns_record_name(&signature.object, &domain.name); + + if let Some((updater, origin)) = &dns_updater + && let Err(err) = updater + .set_rrset(origin, &record, dns_update::DnsRecordType::TXT, Vec::new()) + .await + { + trc::event!( + Dns(DnsEvent::RecordDeletionFailed), + Hostname = record.clone(), + Details = origin.clone(), + Type = "TXT", + Reason = err.to_string(), + ); + } + + trc::event!( + Dkim(DkimEvent::SignatureDeleted), + Id = signature.object.selector().to_string(), + Details = domain.name.clone() + ); + + match server + .registry() + .write(RegistryWrite::delete_object( + signature.id, + &Object { + inner: signature.object.into(), + revision: signature.revision, + }, + )) + .await + { + Ok(RegistryWriteResult::Success(_)) => {} + Ok(err) => { + return Ok(TaskResult::permanent(format!( + "Failed to delete DKIM signature for record {record}: {err}" + ))); + } + Err(err) => { + if err.is_assertion_failure() { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + temporary_errors, + "Failed to delete DKIM signature for record {record} due to concurrent modification, will retry.", + ); + } else { + return Err(err); + } + } + } + } + + if do_refresh + && let Err(err) = server + .invalidate_caches(CacheInvalidationBuilder::default().with_invalidation( + CacheInvalidation::DkimSignature(task.domain_id.document_id()), + )) + .await + { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to invalidate caches after DKIM management task") + ); + } + + if !temporary_errors.is_empty() { + Ok(TaskResult::temporary(temporary_errors)) + } else { + let tasks = if let Some(next_transition) = next_transition { + vec![Task::DkimManagement(TaskDomainManagement { + domain_id: task.domain_id, + status: TaskStatus::at(next_transition.timestamp()), + })] + } else { + vec![] + }; + + Ok(TaskResult::Success(tasks)) + } +} + +async fn update_signature( + server: &Server, + signature: RegistryObject, + new_signature: DkimSignature, + name: &str, + temporary_errors: &mut String, +) -> trc::Result> { + match server + .registry() + .write(RegistryWrite::update( + signature.id.id(), + &new_signature.into(), + &Object { + inner: signature.object.into(), + revision: signature.revision, + }, + )) + .await + { + Ok(RegistryWriteResult::Success(_)) => Ok(None), + Ok(err) => Ok(Some(TaskResult::permanent(format!( + "Failed to write DKIM signature for record {name}: {err}" + )))), + Err(err) => { + if err.is_assertion_failure() { + if !temporary_errors.is_empty() { + temporary_errors.push_str("; "); + } + let _ = write!( + temporary_errors, + "Failed to write DKIM signature for record {name} due to concurrent modification, will retry.", + ); + Ok(None) + } else { + Err(err) + } + } + } +} + +#[cfg(feature = "test_mode")] +const TEST_RSA_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAv9XYXG3uK95115mB4nJ37nGeNe2CrARm1agrbcnSk5oIaEfM +ZLUR/X8gPzoiNHZcfMZEVR6bAytxUhc5EvZIZrjSuEEeny+fFd/cTvcm3cOUUbIa +UmSACj0dL2/KwW0LyUaza9z9zor7I5XdIl1M53qVd5GI62XBB76FH+Q0bWPZNkT4 +NclzTLspD/MTpNCCPhySM4Kdg5CuDczTH4aNzyS0TqgXdtw6A4Sdsp97VXT9fkPW +9rso3lrkpsl/9EQ1mR/DWK6PBmRfIuSFuqnLKY6v/z2hXHxF7IoojfZLa2kZr9Ae +d4l9WheQOTA19k5r2BmlRw/W9CrgCBo0Sdj+KQIDAQABAoIBAFPChEi/OvnulReB +ECQWhOUYuNKlFKQU++2YEvZJ4+bMn5UgnE7wfJ1pj2Pr9xlfALz+OMHNrjMxGbaV +KzdrT2uCkYcf78XjnhuH9gKIiXDUv4L4N+P3u6w8yOx4bFgOS9IjS53yDOPM7SC5 +g6dIg5aigHaHlffqIuFFv4yQMI/+Ai+zBKxS7wRhxK/7nnAuo28fe5MEdp57ho9/ +AGlDNsdg9zCgjwhokwFE3+AaD+bkUFm4gQ1XjkUFrlmnQn8vDQ0i9toEWhCj+UPY +iOKL63MJnr90MXTXWLHoFj99wBp//mYygbF9Lj8fa28/oa8LWp3Jhb7QeMgH46iv +3aLHbTECgYEA5M2dAw+nyMw9vYlkMejhwObKYP8Mr/6zcGMLCalYvRJM5iUAM0JI +H6sM6pV9/nv167cbKocj3xYPdtE7FPOn4132MLM8Ne1f8nPE64Qrcbj5WBXvLnU8 +hpWbwe2Z8h7UUMKx6q4F1/TXYkc3ScxYwfjM4mP/pLsAOgVzRSEEgrUCgYEA1qNQ +xaQHNWZ1O8WuTnqWd5JSsic6iURAmUcLeFDZY2PWhVoaQ8L/xMQhDYs1FIbLWArW +4Qq3Ibu8AbSejAKuaJz7Uf26PX+PYVUwAOO0qamCJ8d/qd6So7qWMDyAY2yXI39Y +1nMqRjr7bkEsggAZao7BKqA7ZtmogjOusBT38iUCgYEA06agJ8TDoKvOMRZ26PRU +YO0dKLzGL8eclcoI29cbj0rud7aiiMg3j5PbTuUat95TjsjDCIQaWrM9etvxm2AJ +Xfn9Uu96MyhyKQWOk46f4YMKpMElkARDCPw8KRhx39dE77AqhLyWCz8iPndCXbH6 +KPTOEl4OjYOuof2Is9nnIkECgYBh948RdsnXhNlzm8nwhiGRmBbou+EK8D0v+O5y +Tyy6IcKzgSnFzgZh8EdJ4EUtBk1f9SqY8wQdgIvSl3daXorusuA/TzkngsaV3YUY +ktZOLlF7CKLrjOyPkMWmZKcROmpNyH1q/IvKHHfQnizLdXIkYd4nL5WNX0F7lE1i +j1+QhQKBgB2lviBK7rJFwlFYdQUP1NAN2dKxMZk8uJS8JglHrM0+8nRI83HbTdEQ +vB0ManEKBkbS4T5n+gRtdEqKSDmWDTXDlrBfcdCHNQLwYtBpOotCqQn/AmfjcPBl +byAbwh4+HiZ5JISoRZpiZqy67aJNVoXmdtb/E9mi7ozzytpxMNql +-----END RSA PRIVATE KEY----- +"#; + +#[cfg(feature = "test_mode")] +const TEST_ED25519_KEY: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIAO3hAf144lTAVjTkht3ZwBTK0CMCCd1bI0alggneN3B +-----END PRIVATE KEY----- +"#; diff --git a/crates/services/src/task_manager/dns.rs b/crates/services/src/task_manager/dns.rs new file mode 100644 index 0000000..ab65047 --- /dev/null +++ b/crates/services/src/task_manager/dns.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use common::Server; +use dns_update::{CAARecord, DnsRecord, DnsRecordType, Error as DnsUpdateError, KeyValue}; +use registry::schema::structs::{ + DnsManagement, Domain, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus, +}; +use std::fmt::Write; +use store::ahash::AHashMap; +use trc::DnsEvent; + +pub(crate) trait DnsManagementTask: Sync + Send { + fn dns_management(&self, task: &TaskDnsManagement) -> impl Future + Send; +} + +impl DnsManagementTask for Server { + async fn dns_management(&self, task: &TaskDnsManagement) -> TaskResult { + match dns_management(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to run DNS management task") + ); + result + } + } + } +} + +async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Result { + if task.update_records.is_empty() { + return Ok(TaskResult::permanent( + "No DNS records to update".to_string(), + )); + } + let Some(domain) = server.registry().object::(task.domain_id).await? else { + return Ok(TaskResult::permanent("Domain not found".to_string())); + }; + let DnsManagement::Automatic(props) = &domain.dns_management else { + return Ok(TaskResult::permanent( + "Domain is not set to automatic DNS management".to_string(), + )); + }; + let dns_updater = match server.build_dns_updater(props.dns_server_id).await? { + Ok(updater) => updater, + Err(err) => { + return Ok(TaskResult::permanent(format!( + "Failed to build DNS updater: {}", + err + ))); + } + }; + let origin = props.origin.as_deref().unwrap_or(&domain.name); + let records = server + .build_dns_records(task.domain_id, &domain, task.update_records.as_slice()) + .await?; + + // Group records by (name, type) so each RRSet is published in one call. + let mut by_owner: AHashMap<(String, DnsRecordType), Vec> = AHashMap::new(); + for record in records { + by_owner + .entry((record.name, record.record.as_type())) + .or_default() + .push(record.record); + } + + let mut errors = String::new(); + for ((name, record_type), mut recs) in by_owner { + let preserve_unrelated = match record_type { + DnsRecordType::TXT => !is_owned_txt_name(&name), + DnsRecordType::CAA => true, + _ => false, + }; + if preserve_unrelated { + match dns_updater.list_rrset(origin, &name, record_type).await { + Ok(existing) => { + for existing_rec in existing { + if !recs.iter().any(|new| same_rrset_family(new, &existing_rec)) { + recs.push(existing_rec); + } + } + } + Err(DnsUpdateError::Unsupported(reason)) => { + trc::event!( + Dns(DnsEvent::RecordLookupFailed), + Hostname = name.clone(), + Details = origin.to_string(), + Type = record_type.as_str(), + Reason = format!( + "DNS provider cannot list RRSet, unrelated records at this name may be overwritten: {reason}" + ), + ); + } + Err(err) => { + trc::event!( + Dns(DnsEvent::RecordLookupFailed), + Hostname = name.clone(), + Details = origin.to_string(), + Type = record_type.as_str(), + Reason = format!("DNS provider failed to list RRSet: {err}"), + ); + } + } + } + + if let Err(err) = dns_updater + .set_rrset(origin, &name, record_type, recs) + .await + { + if !errors.is_empty() { + errors.push_str("; "); + } + let _ = write!( + &mut errors, + "Failed to set DNS RRSet for {}/{}: {}", + name, + record_type.as_str(), + err + ); + } + } + + if errors.is_empty() { + if task.on_success_renew_certificate { + Ok(TaskResult::Success(vec![Task::AcmeRenewal( + TaskDomainManagement { + domain_id: task.domain_id, + status: TaskStatus::now(), + }, + )])) + } else { + Ok(TaskResult::Success(vec![])) + } + } else { + Ok(TaskResult::permanent(errors)) + } +} + +fn same_rrset_family(a: &DnsRecord, b: &DnsRecord) -> bool { + match (a, b) { + (DnsRecord::TXT(_), DnsRecord::TXT(_)) => same_txt_family(a, b), + (DnsRecord::CAA(_), DnsRecord::CAA(_)) => same_caa_family(a, b), + _ => false, + } +} + +fn same_txt_family(a: &DnsRecord, b: &DnsRecord) -> bool { + match (a, b) { + (DnsRecord::TXT(va), DnsRecord::TXT(vb)) => match (txt_family(va), txt_family(vb)) { + (Some(fa), Some(fb)) => fa.eq_ignore_ascii_case(fb), + _ => false, + }, + _ => false, + } +} + +fn same_caa_family(a: &DnsRecord, b: &DnsRecord) -> bool { + match (a, b) { + (DnsRecord::CAA(ca), DnsRecord::CAA(cb)) => match (ca, cb) { + (CAARecord::Issue { options: oa, .. }, CAARecord::Issue { options: ob, .. }) + | ( + CAARecord::IssueWild { options: oa, .. }, + CAARecord::IssueWild { options: ob, .. }, + ) => match (caa_account_uri(oa), caa_account_uri(ob)) { + (Some(ua), Some(ub)) => ua.eq_ignore_ascii_case(ub), + _ => false, + }, + (CAARecord::Iodef { url: ua, .. }, CAARecord::Iodef { url: ub, .. }) => { + ua.eq_ignore_ascii_case(ub) + } + _ => false, + }, + _ => false, + } +} + +fn caa_account_uri(options: &[KeyValue]) -> Option<&str> { + options + .iter() + .find(|kv| kv.key.eq_ignore_ascii_case("accounturi")) + .map(|kv| kv.value.as_str()) +} + +fn txt_family(value: &str) -> Option<&str> { + value.trim_start().strip_prefix("v=").map(|rest| { + rest.split_once([';', ' ']) + .map_or(rest, |(family, _)| family) + }) +} + +fn is_owned_txt_name(name: &str) -> bool { + name.contains("_dmarc.") + || name.contains("_smtp._tls.") + || name.contains("_mta-sts.") + || name.contains("_ua-auto-config.") + || name.contains("_validation-persist.") + || name.contains("._domainkey.") +} diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs new file mode 100644 index 0000000..fdd82fb --- /dev/null +++ b/crates/services/src/task_manager/imip.rs @@ -0,0 +1,612 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use calcard::icalendar::{ICalendarParticipationStatus, ICalendarProperty}; +use common::{ + DEFAULT_LOGO_BASE64, Server, + auth::AccountInfo, + config::groupware::CalendarTemplateVariable, + network::{ServerInstance, stream::NullIo}, +}; +use groupware::{ + calendar::itip::ItipIngest, + scheduling::{ + ItipSummary, ItipValue, + format::{DateStyle, TextFormatter, hyperlink}, + }, +}; +use mail_builder::{ + MessageBuilder, + headers::{HeaderType, content_type::ContentType}, + mime::{BodyPart, MimePart}, +}; +use mail_parser::decoders::html::html_to_text; +use registry::{schema::structs::TaskCalendarItipMessage, types::EnumImpl}; +use smtp::core::{Session, SessionData}; +use smtp_proto::{MailFrom, RcptTo}; +use std::{sync::Arc, time::Duration}; +use store::{ahash::AHashMap, write::now}; +use trc::AddContext; +use utils::template::{Variable, Variables}; + +pub(crate) trait SendImipTask: Sync + Send { + fn send_imip( + &self, + task: &TaskCalendarItipMessage, + server_instance: Arc, + ) -> impl Future + Send; +} + +impl SendImipTask for Server { + async fn send_imip( + &self, + task: &TaskCalendarItipMessage, + server_instance: Arc, + ) -> TaskResult { + match send_imip(self, task, server_instance).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .document_id(task.document_id.document_id()) + .caused_by(trc::location!()) + .details("Failed to send iMIP message") + ); + result + } + } + } +} + +async fn send_imip( + server: &Server, + imip: &TaskCalendarItipMessage, + server_instance: Arc, +) -> trc::Result { + // Obtain iMIP payload + let account_id = imip.account_id.document_id(); + let document_id = imip.document_id.document_id(); + + let sender_domain = imip + .messages + .iter() + .next() + .and_then(|msg| msg.from.rsplit('@').next()) + .unwrap_or("localhost"); + + // Obtain logo image + let logo = match server.logo_resource(sender_domain).await { + Ok(logo) => logo, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to fetch logo image") + ); + None + } + }; + let logo_cid = format!("logo.{}@{sender_domain}", now()); + let logo = if let Some(logo) = &logo { + MimePart::new( + ContentType::new(logo.content_type.as_ref()), + BodyPart::Binary(logo.contents.as_slice().into()), + ) + } else { + MimePart::new( + ContentType::new("image/png"), + BodyPart::Binary(DEFAULT_LOGO_BASE64.as_bytes().into()), + ) + .transfer_encoding("base64") + } + .inline() + .cid(&logo_cid); + + let account_info = server + .account_info(account_id) + .await + .caused_by(trc::location!())?; + + for itip_message in imip.messages.iter() { + let Ok(summary) = serde_json::from_str::(&itip_message.summary) else { + return Ok(TaskResult::permanent( + "Failed to parse iMIP message summary.", + )); + }; + + let organizer_info = match server + .account_id_from_email(itip_message.from.as_str(), true) + .await + { + Ok(Some(sender_id)) if sender_id != account_id => { + match server.account_info(sender_id).await { + Ok(info) => Some(info), + Err(err) => { + trc::error!( + err.account_id(account_id) + .document_id(document_id) + .caused_by(trc::location!()) + .details("Failed to load organizer account for iMIP sender") + ); + None + } + } + } + Ok(_) => None, + Err(err) => { + trc::error!( + err.account_id(account_id) + .document_id(document_id) + .caused_by(trc::location!()) + .details("Failed to resolve organizer account for iMIP sender") + ); + None + } + }; + let sender_info = organizer_info.as_ref().unwrap_or(&account_info); + + for recipient in itip_message.to.iter() { + // Build template + let tpl = build_itip_template( + server, + &account_info, + account_id, + document_id, + itip_message.from.as_str(), + recipient.as_str(), + &summary, + &logo_cid, + ) + .await?; + let txt_body = html_to_text(&tpl.body); + + // Build message + let message = MessageBuilder::new() + .from(( + sender_info.description().unwrap_or(sender_info.name()), + itip_message.from.as_str(), + )) + .to(recipient.as_str()) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .header( + "Reply-To", + HeaderType::Text(itip_message.from.as_str().into()), + ) + .message_id(server.core.network.message_id()) + .subject(&tpl.subject) + .body(MimePart::new( + ContentType::new("multipart/mixed"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("multipart/related"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("multipart/alternative"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("text/plain"), + BodyPart::Text(txt_body.into()), + ), + MimePart::new( + ContentType::new("text/html"), + BodyPart::Text(tpl.body.as_str().into()), + ), + MimePart::new( + ContentType::new("text/calendar") + .attribute("method", summary.method()) + .attribute("charset", "utf-8"), + BodyPart::Text( + itip_message.i_calendar_data.as_str().into(), + ), + ), + ]), + ), + logo.clone(), + ]), + ), + MimePart::new( + ContentType::new("application/ics").attribute("name", "event.ics"), + BodyPart::Text(itip_message.i_calendar_data.as_str().into()), + ) + .attachment("event.ics"), + ]), + )) + .write_to_vec() + .unwrap_or_default(); + + // Send message + let server_ = server.clone(); + let server_instance = server_instance.clone(); + let sender_info = sender_info.clone(); + let from = itip_message.from.to_string(); + let to = recipient.to_string(); + tokio::spawn(async move { + let mut session = Session::::local( + server_, + server_instance, + SessionData::local(sender_info, None, vec![], vec![], 0), + ); + + // MAIL FROM + let _ = session + .handle_mail_from(MailFrom { + address: from.as_str().into(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + From = from, + To = to, + Reason = format!("Server rejected MAIL-FROM: {}", error.trim()), + ); + return; + } + + // RCPT TO + session.params.rcpt_errors_wait = Duration::from_secs(0); + let _ = session + .handle_rcpt_to(RcptTo { + address: to.as_str().into(), + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + AccountId = account_id, + DocumentId = document_id, + From = from, + To = to, + Reason = format!("Server rejected RCPT-TO: {}", error.trim()), + ); + return; + } + + // DATA + session.data.message = message; + let response = session.queue_message().await; + if let smtp::core::State::Accepted(queue_id) = session.state { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageSent), + From = from, + To = to, + AccountId = account_id, + DocumentId = document_id, + QueueId = queue_id, + ); + } else { + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageError), + From = from, + To = to, + AccountId = account_id, + DocumentId = document_id, + Reason = format!( + "Server rejected DATA: {}", + std::str::from_utf8(&response).unwrap().trim() + ), + ); + } + }) + .await + .map_err(|_| { + trc::Error::new(trc::EventType::Server(trc::ServerEvent::ThreadError)) + .caused_by(trc::location!()) + })?; + } + } + + Ok(TaskResult::Success(vec![])) +} + +pub struct Details { + pub subject: String, + pub body: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn build_itip_template( + server: &Server, + account_info: &AccountInfo, + account_id: u32, + document_id: u32, + from: &str, + to: &str, + summary: &ItipSummary, + logo_cid: &str, +) -> trc::Result
{ + #[cfg(not(feature = "enterprise"))] + let template = &server.core.groupware.itip_template; + let formatter = TextFormatter::new(account_info.locale().as_str())?; + let locale = formatter.locale; + + let mut variables = Variables::new(); + let mut subject; + let (fields, old_fields) = match summary { + ItipSummary::Invite(fields) => { + subject = format!("{}: ", locale.calendar_invitation); + + (fields, None) + } + ItipSummary::Update { + current, previous, .. + } => { + subject = format!("{}: ", locale.calendar_updated_invitation); + variables.insert_single( + CalendarTemplateVariable::Header, + locale.calendar_event_updated.to_string(), + ); + variables.insert_single(CalendarTemplateVariable::Color, "info".to_string()); + (current, Some(previous)) + } + ItipSummary::Cancel(fields) => { + subject = format!("{}: ", locale.calendar_cancelled); + variables.insert_single( + CalendarTemplateVariable::Header, + locale.calendar_event_cancelled.to_string(), + ); + variables.insert_single(CalendarTemplateVariable::Color, "danger".to_string()); + (fields, None) + } + ItipSummary::Rsvp { part_stat, current } => { + let (color, value) = match part_stat { + ICalendarParticipationStatus::Accepted => { + subject = format!("{}: ", locale.calendar_accepted); + + ( + "info", + locale.calendar_participant_accepted.replace("$name", from), + ) + } + ICalendarParticipationStatus::Declined => { + subject = format!("{}: ", locale.calendar_declined); + ( + "danger", + locale.calendar_participant_declined.replace("$name", from), + ) + } + ICalendarParticipationStatus::Tentative => { + subject = format!("{}: ", locale.calendar_tentative); + ( + "warning", + locale.calendar_participant_tentative.replace("$name", from), + ) + } + ICalendarParticipationStatus::Delegated => { + subject = format!("{}: ", locale.calendar_delegated); + ( + "warning", + locale.calendar_participant_delegated.replace("$name", from), + ) + } + _ => { + subject = format!("{}: ", locale.calendar_reply); + ( + "info", + locale.calendar_participant_reply.replace("$name", from), + ) + } + }; + + variables.insert_single(CalendarTemplateVariable::Header, value); + variables.insert_single(CalendarTemplateVariable::Color, color.to_string()); + + (current, None) + } + }; + + let mut when_detail: Option<(usize, &ItipValue)> = None; + let mut details: Vec> = Vec::with_capacity(4); + for field in [ + ICalendarProperty::Summary, + ICalendarProperty::Description, + ICalendarProperty::Dtstart, + ICalendarProperty::Rrule, + ICalendarProperty::Location, + ICalendarProperty::Conference, + ] { + let mut old_entries = old_fields.into_iter().flatten().filter(|e| e.name == field); + + for entry in fields.iter().filter(|e| e.name == field) { + let field_name = match &field { + ICalendarProperty::Summary => locale.calendar_summary, + ICalendarProperty::Description => locale.calendar_description, + ICalendarProperty::Dtstart | ICalendarProperty::Rrule => locale.calendar_when, + ICalendarProperty::Location => locale.calendar_location, + ICalendarProperty::Conference => locale.calendar_conference, + _ => continue, + }; + let value = formatter.field_to_string(&entry.value, DateStyle::Long); + + let old_entry = old_entries.next(); + + match &field { + ICalendarProperty::Summary => { + subject.push_str(&value); + } + ICalendarProperty::Dtstart => { + subject.push_str(" @ "); + subject.push_str(&value); + } + ICalendarProperty::Rrule if when_detail.is_none() => { + subject.push_str(" @ "); + subject.push_str(&value); + } + _ => (), + } + + if let ICalendarProperty::Summary | ICalendarProperty::Description = &field { + let variable = if matches!(field, ICalendarProperty::Summary) { + CalendarTemplateVariable::EventTitle + } else { + CalendarTemplateVariable::EventDescription + }; + + if old_entry.is_none() { + variables.insert_single(variable, value); + continue; + } + variables.insert_single(variable, value.clone()); + } + + if matches!(field, ICalendarProperty::Rrule) + && let Some((index, start_value)) = when_detail + && let Some(detail) = details.get_mut(index) + { + if let Some(when_value) = detail.get_mut(&CalendarTemplateVariable::Value) { + when_value.push_str(", "); + when_value.push_str(&value); + } + + if let Some(old_entry) = old_entry { + detail.insert( + CalendarTemplateVariable::Changed, + locale.calendar_changed.to_string(), + ); + let old_value = detail + .entry(CalendarTemplateVariable::OldValue) + .or_insert_with(|| { + formatter.field_to_string(start_value, DateStyle::Short) + }); + old_value.push_str(", "); + old_value + .push_str(&formatter.field_to_string(&old_entry.value, DateStyle::Short)); + } + + continue; + } + + let mut detail = AHashMap::with_capacity(4); + detail.insert(CalendarTemplateVariable::Key, field_name.to_string()); + if matches!(field, ICalendarProperty::Conference) + && let Some(link) = hyperlink(&value) + { + detail.insert(CalendarTemplateVariable::Link, link.to_string()); + } + detail.insert(CalendarTemplateVariable::Value, value); + if let Some(old_entry) = old_entry { + detail.insert( + CalendarTemplateVariable::Changed, + locale.calendar_changed.to_string(), + ); + detail.insert( + CalendarTemplateVariable::OldValue, + formatter.field_to_string(&old_entry.value, DateStyle::Short), + ); + } + if matches!(field, ICalendarProperty::Dtstart) && when_detail.is_none() { + when_detail = Some((details.len(), &entry.value)); + } + details.push(detail); + } + } + if !details.is_empty() { + variables.items.insert( + CalendarTemplateVariable::EventDetails, + Variable::Block(details), + ); + } + variables.insert_single(CalendarTemplateVariable::PageTitle, subject.clone()); + variables.insert_single(CalendarTemplateVariable::Lang, locale.name.to_string()); + variables.insert_single(CalendarTemplateVariable::Dir, locale.direction.to_string()); + variables.insert_single(CalendarTemplateVariable::LogoCid, format!("cid:{logo_cid}")); + + if let Some(guests) = fields + .iter() + .find(|e| e.name == ICalendarProperty::Attendee) + && let ItipValue::Participants(guests) = &guests.value + { + variables.insert_single( + CalendarTemplateVariable::AttendeesTitle, + locale.calendar_attendees.to_string(), + ); + variables.insert_block( + CalendarTemplateVariable::Attendees, + guests.iter().map(|guest| { + [ + ( + CalendarTemplateVariable::Key, + if guest.is_organizer { + if let Some(name) = guest.name.as_ref() { + format!("{name} - {}", locale.calendar_organizer) + } else { + locale.calendar_organizer.to_string() + } + } else { + guest.name.as_deref().unwrap_or_default().to_string() + }, + ), + (CalendarTemplateVariable::Value, guest.email.to_string()), + ] + }), + ); + } + + // Add RSVP buttons + if matches!(summary, ItipSummary::Invite(_) | ItipSummary::Update { .. }) + && let Some(rsvp_url) = server + .http_rsvp_url(account_id, account_info.name(), document_id, to) + .await + { + variables.insert_single( + CalendarTemplateVariable::Rsvp, + locale.calendar_reply_as.replace("$name", to), + ); + variables.insert_block( + CalendarTemplateVariable::Actions, + [ + ( + ICalendarParticipationStatus::Accepted, + locale.calendar_yes.to_string(), + "info", + ), + ( + ICalendarParticipationStatus::Declined, + locale.calendar_no.to_string(), + "danger", + ), + ( + ICalendarParticipationStatus::Tentative, + locale.calendar_maybe.to_string(), + "warning", + ), + ] + .into_iter() + .map(|(status, title, color)| { + [ + (CalendarTemplateVariable::ActionName, title.to_string()), + (CalendarTemplateVariable::ActionUrl, rsvp_url.url(&status)), + (CalendarTemplateVariable::Color, color.to_string()), + ] + }), + ); + } + + // Add footer + variables.insert_block( + CalendarTemplateVariable::Footer, + [ + [( + CalendarTemplateVariable::Key, + locale.calendar_imip_footer_1.to_string(), + )], + [( + CalendarTemplateVariable::Key, + locale.calendar_imip_footer_2.to_string(), + )], + ], + ); + + Ok(Details { + subject, + body: template.eval(&variables), + }) +} diff --git a/crates/services/src/task_manager/index.rs b/crates/services/src/task_manager/index.rs new file mode 100644 index 0000000..f45cba1 --- /dev/null +++ b/crates/services/src/task_manager/index.rs @@ -0,0 +1,610 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult}; +use common::Server; +use email::{cache::MessageCacheFetch, message::metadata::MessageMetadata}; +use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard}; +use registry::{ + schema::{ + enums::IndexDocumentType, + prelude::{ObjectType, Property}, + structs::{TaskIndexDocument, TaskIndexTrace, TaskStatus}, + }, + types::EnumImpl, +}; +use std::cmp::Ordering; +use store::{ + IterateParams, ValueKey, + ahash::AHashMap, + rand::{self, RngExt}, + search::{IndexDocument, SearchField, SearchFilter, SearchQuery}, + write::{ + AlignedBytes, Archive, BatchBuilder, SearchIndex, TelemetryClass, ValueClass, + key::DeserializeBigEndian, now, + }, +}; +use trc::{AddContext, TaskManagerEvent}; +use types::{ + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, + field::EmailField, +}; + +pub(crate) trait SearchIndexTask: Sync + Send { + fn index(&self, tasks: &[TaskDetails]) -> impl Future> + Send; +} + +const NUM_INDEXES: usize = 5; +const MISSING_DOCUMENT_MAX_ATTEMPTS: u64 = 3; +const MISSING_DOCUMENT_RETRY_DELAY: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TaskType { + Insert, + Delete, +} + +enum BuildResult { + Document(IndexDocument), + NotIndexed, + NotFound, +} + +#[derive(Debug)] +pub(crate) struct IndexTaskResult { + index: IndexDocumentType, + task_type: TaskType, + pub result: TaskResult, +} + +impl SearchIndexTask for Server { + async fn index(&self, tasks: &[TaskDetails]) -> Vec { + let mut results: Vec = Vec::with_capacity(tasks.len()); + let mut batch = BatchBuilder::new(); + let mut document_insertions = Vec::new(); + let mut document_deletions: [AHashMap>; NUM_INDEXES] = + std::array::from_fn(|_| AHashMap::new()); + + for task in tasks { + match &task.task { + Task::IndexDocument(task) => { + let account_id = task.account_id.document_id(); + let document_id = task.document_id.document_id(); + + let document = match task.document_type { + IndexDocumentType::Email => { + build_email_document(self, account_id, document_id).await + } + IndexDocumentType::Calendar => { + build_calendar_document(self, account_id, document_id).await + } + IndexDocumentType::Contacts => { + build_contact_document(self, account_id, document_id).await + } + IndexDocumentType::File => { + // File indexing not implemented yet + continue; + } + }; + + // Retry non found errors in case they are due to SQL read replication lag + let result = match document { + Ok(BuildResult::Document(doc)) if !doc.is_empty() => { + document_insertions.push(doc); + TaskResult::Success(vec![]) + } + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(account_id) + .document_id(document_id) + .caused_by(trc::location!()) + .ctx(trc::Key::Collection, task.document_type.as_str()) + .details("Failed to build document for indexing") + ); + result + } + Ok(BuildResult::NotFound) + if attempt_number(&task.status) < MISSING_DOCUMENT_MAX_ATTEMPTS => + { + TaskResult::Failure { + typ: TaskFailureType::Retry( + now().saturating_add(MISSING_DOCUMENT_RETRY_DELAY), + ), + message: "Document not found in data store".into(), + max_attempts: Some(MISSING_DOCUMENT_MAX_ATTEMPTS), + } + } + Ok(BuildResult::NotFound) => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Collection = task.document_type.as_str(), + Reason = "Document no longer exists", + AccountId = account_id, + DocumentId = document_id, + ); + TaskResult::Ignored + } + _ => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Collection = task.document_type.as_str(), + Reason = "Nothing to index", + AccountId = account_id, + DocumentId = document_id, + ); + TaskResult::Ignored + } + }; + + results.push(IndexTaskResult { + task_type: TaskType::Insert, + index: task.document_type, + result, + }); + } + Task::IndexTrace(task) => { + let result = match build_tracing_span_document(self, task.trace_id.id()).await { + Ok(Some(doc)) if !doc.is_empty() => { + document_insertions.push(doc); + TaskResult::Success(vec![]) + } + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.id(task.trace_id.id()) + .caused_by(trc::location!()) + .details("Failed to build document for indexing") + ); + result + } + _ => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Reason = "Nothing to index", + Id = task.trace_id.id(), + ); + TaskResult::Ignored + } + }; + + results.push(IndexTaskResult { + task_type: TaskType::Insert, + index: IndexDocumentType::File, // use File index for tracing spans to avoid creating a new index type + result, + }); + } + Task::UnindexDocument(task) => { + let account_id = task.account_id.document_id(); + let document_id = task.document_id.document_id(); + let idx = match task.document_type { + IndexDocumentType::Email => { + if let Err(err) = + delete_email_metadata(self, &mut batch, account_id, document_id) + .await + { + trc::error!( + err.account_id(account_id) + .document_id(document_id) + .caused_by(trc::location!()) + .details("Failed to delete email metadata from index") + ); + results.push(IndexTaskResult { + task_type: TaskType::Delete, + index: task.document_type, + result: TaskResult::temporary( + "Failed to delete email metadata from index", + ), + }); + continue; + } + 0 + } + IndexDocumentType::Calendar => 1, + IndexDocumentType::Contacts => 2, + IndexDocumentType::File => 3, + }; + + document_deletions[idx] + .entry(account_id) + .or_default() + .push(document_id); + + results.push(IndexTaskResult { + task_type: TaskType::Delete, + index: task.document_type, + result: TaskResult::Success(vec![]), + }); + } + _ => unreachable!(), + } + } + + // Commit deletion batch to data store + if !batch.is_empty() + && let Err(err) = self.store().write(batch.build_all()).await + { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to commit index deletions to data store") + ); + for r in results.iter_mut() { + if r.task_type == TaskType::Delete + && r.result.is_success() + && r.index == IndexDocumentType::Email + { + r.result = + TaskResult::temporary("Failed to commit index deletions to data store"); + } + } + return results; + } + + // Index documents + if !document_insertions.is_empty() + && let Err(err) = self.search_store().index(document_insertions).await + { + let retry_at = deferred_retry_time(&err); + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to index documents") + ); + for r in results.iter_mut() { + if r.task_type == TaskType::Insert && r.result.is_success() { + r.result = search_store_failure(retry_at, "Failed to index documents"); + } + } + return results; + } + + // Delete documents + for (accounts, index) in document_deletions.into_iter().zip([ + SearchIndex::Email, + SearchIndex::Calendar, + SearchIndex::Contacts, + ]) { + let multi_account = match accounts.len().cmp(&1) { + Ordering::Greater => true, + Ordering::Equal => false, + Ordering::Less => continue, + }; + + let mut query = SearchQuery::new(index); + if multi_account { + query.add_filter(SearchFilter::Or); + } + + for (account_id, document_ids) in accounts { + let multi_document = document_ids.len() > 1; + query + .add_filter(SearchFilter::And) + .add_filter(SearchFilter::eq(SearchField::AccountId, account_id)); + + if multi_document { + query.add_filter(SearchFilter::Or); + } + + for document_id in document_ids { + query.add_filter(SearchFilter::eq(SearchField::DocumentId, document_id)); + } + + if multi_document { + query.add_filter(SearchFilter::End); + } + query.add_filter(SearchFilter::End); + } + + if multi_account { + query.add_filter(SearchFilter::End); + } + + if let Err(err) = self.search_store().unindex(query).await { + let retry_at = deferred_retry_time(&err); + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to delete documents from index") + .ctx(trc::Key::Collection, index.name()) + ); + for r in results.iter_mut() { + if r.task_type == TaskType::Delete && r.result.is_success() { + r.result = + search_store_failure(retry_at, "Failed to delete documents from index"); + } + } + return results; + } + } + + results + } +} + +pub(crate) async fn reindex_telemetry(server: &Server) -> trc::Result<()> { + let mut spans = Vec::new(); + server + .tracing_store() + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(0))), + ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(u64::MAX))), + ) + .no_values(), + |key, _| { + spans.push(key.deserialize_be_u64(0)?); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + let mut batch = BatchBuilder::new(); + let now = now() as i64; + for span_id in spans { + batch.schedule_task(Task::IndexTrace(TaskIndexTrace { + trace_id: span_id.into(), + status: TaskStatus::at(now + rand::rng().random_range(0..=300)), + })); + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + batch = BatchBuilder::new(); + } + } + + if !batch.is_empty() { + server.core.storage.data.write(batch.build_all()).await?; + } + Ok(()) +} + +pub(crate) async fn reindex_account(server: &Server, account_id: u32) -> trc::Result<()> { + let now = now() as i64; + + let mut batch = BatchBuilder::new(); + + for document_id in server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())? + .emails + .items + .iter() + .map(|v| v.document_id) + { + batch.schedule_task(Task::IndexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: document_id.into(), + document_type: IndexDocumentType::Email, + status: TaskStatus::at(now + rand::rng().random_range(0..=300)), + })); + + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + batch = BatchBuilder::new(); + } + } + + for document_type in [IndexDocumentType::Calendar, IndexDocumentType::Contacts] { + let cache = server + .fetch_dav_resources( + account_id, + account_id, + if document_type == IndexDocumentType::Calendar { + SyncCollection::Calendar + } else { + SyncCollection::AddressBook + }, + ) + .await + .caused_by(trc::location!())?; + + for document_id in cache.document_ids(false) { + batch.schedule_task(Task::IndexDocument(TaskIndexDocument { + account_id: account_id.into(), + document_id: document_id.into(), + document_type, + status: TaskStatus::at(now + rand::rng().random_range(0..=300)), + })); + + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + batch = BatchBuilder::new(); + } + } + } + + if !batch.is_empty() { + server.core.storage.data.write(batch.build_all()).await?; + } + + // Request indexing + server.notify_task_queue(); + + Ok(()) +} + +fn deferred_retry_time(err: &trc::Error) -> Option { + err.value(trc::Key::NextRetry) + .and_then(|value| value.to_uint()) +} + +fn search_store_failure(retry_at: Option, message: &'static str) -> TaskResult { + match retry_at { + Some(retry_at) => TaskResult::Failure { + typ: TaskFailureType::Retry(retry_at), + message: message.into(), + max_attempts: None, + }, + None => TaskResult::temporary(message), + } +} + +fn attempt_number(status: &TaskStatus) -> u64 { + match status { + TaskStatus::Pending(_) => 0, + TaskStatus::Retry(status) => status.attempt_number, + TaskStatus::Failed(status) => status.failed_attempt_number, + } +} + +async fn build_email_document( + server: &Server, + account_id: u32, + document_id: u32, +) -> trc::Result { + let Some(index_fields) = server.core.email.index_fields.get(&SearchIndex::Email) else { + return Ok(BuildResult::NotIndexed); + }; + + match server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await? + { + Some(metadata_) => { + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + + let raw_message = server + .blob_store() + .get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .details("Blob not found") + })?; + + Ok(BuildResult::Document(metadata.index_document( + account_id, + document_id, + &raw_message, + index_fields, + server.core.email.default_language, + ))) + } + None => Ok(BuildResult::NotFound), + } +} + +async fn build_calendar_document( + server: &Server, + account_id: u32, + document_id: u32, +) -> trc::Result { + let Some(index_fields) = server.core.email.index_fields.get(&SearchIndex::Calendar) else { + return Ok(BuildResult::NotIndexed); + }; + + match server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await? + { + Some(metadata_) => Ok(BuildResult::Document( + metadata_ + .unarchive::() + .caused_by(trc::location!())? + .index_document( + account_id, + document_id, + index_fields, + server.core.email.default_language, + ), + )), + None => Ok(BuildResult::NotFound), + } +} + +async fn build_contact_document( + server: &Server, + account_id: u32, + document_id: u32, +) -> trc::Result { + let Some(index_fields) = server.core.email.index_fields.get(&SearchIndex::Contacts) else { + return Ok(BuildResult::NotIndexed); + }; + + match server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::ContactCard, + document_id, + )) + .await? + { + Some(metadata_) => Ok(BuildResult::Document( + metadata_ + .unarchive::() + .caused_by(trc::location!())? + .index_document( + account_id, + document_id, + index_fields, + server.core.email.default_language, + ), + )), + None => Ok(BuildResult::NotFound), + } +} + + +#[cfg(not(feature = "enterprise"))] +async fn build_tracing_span_document(_: &Server, _: u64) -> trc::Result> { + Ok(None) +} + +async fn delete_email_metadata( + server: &Server, + batch: &mut BatchBuilder, + account_id: u32, + document_id: u32, +) -> trc::Result<()> { + match server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await? + { + Some(metadata_) => { + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(document_id); + let metadata = metadata_ + .unarchive::() + .caused_by(trc::location!())?; + metadata.unindex(batch); + + } + None => { + trc::event!( + TaskManager(TaskManagerEvent::MetadataNotFound), + Details = "E-mail metadata not found", + AccountId = account_id, + DocumentId = document_id, + ); + } + } + + Ok(()) +} diff --git a/crates/services/src/task_manager/lock.rs b/crates/services/src/task_manager/lock.rs new file mode 100644 index 0000000..cb11a2e --- /dev/null +++ b/crates/services/src/task_manager/lock.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::*; + +pub trait TaskLockManager: Sync + Send { + fn try_lock_task(&self, task: u64) -> impl Future + Send; + fn remove_index_lock(&self, id: u64) -> impl Future + Send; +} + +impl TaskLockManager for Server { + async fn try_lock_task(&self, id: u64) -> bool { + match self + .in_memory_store() + .try_lock(KV_LOCK_TASK, &id.to_be_bytes(), DEFAULT_LOCK_EXPIRY) + .await + { + Ok(result) => { + if !result { + trc::event!( + TaskManager(TaskManagerEvent::TaskLocked), + Id = id, + Details = "Task details not available", + ); + } + result + } + Err(err) => { + trc::error!(err.id(id).details("Failed to lock task")); + + false + } + } + } + + async fn remove_index_lock(&self, id: u64) { + if let Err(err) = self + .in_memory_store() + .remove_lock(KV_LOCK_TASK, &id.to_be_bytes()) + .await + { + trc::error!( + err.details("Failed to unlock task") + .ctx(trc::Key::Id, id) + .caused_by(trc::location!()) + ); + } + } +} diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs new file mode 100644 index 0000000..3c5ec41 --- /dev/null +++ b/crates/services/src/task_manager/maintenance.rs @@ -0,0 +1,542 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use crate::task_manager::{ + TaskResult, + index::{reindex_account, reindex_telemetry}, +}; +use common::{ + KV_ACME, KV_GREYLIST, KV_LOCK_DAV, KV_LOCK_QUEUE_MESSAGE, KV_LOCK_TASK, KV_OAUTH, + KV_QUOTA_BLOB, KV_RATE_LIMIT_AUTH, KV_RATE_LIMIT_CONTACT, KV_RATE_LIMIT_HTTP_ANONYMOUS, + KV_RATE_LIMIT_HTTP_AUTHENTICATED, KV_RATE_LIMIT_IMAP, KV_RATE_LIMIT_LOITER, KV_RATE_LIMIT_RCPT, + KV_RATE_LIMIT_SCAN, KV_RATE_LIMIT_SMTP, KV_SIEVE_ID, Server, + storage::index::ObjectIndexBuilder, +}; +use email::{ + cache::MessageCacheFetch, + message::{delete::EmailDeletion, ingest::EmailIngest, metadata::MessageData}, + sieve::SieveScript, +}; +use groupware::{ + calendar::{Calendar, CalendarEvent, CalendarEventNotification}, + contact::{AddressBook, ContactCard}, + file::FileNode, +}; +use registry::{ + schema::{ + enums::{TaskAccountMaintenanceType, TaskStoreMaintenanceType, TaskTenantMaintenanceType}, + prelude::{Object, ObjectInner, ObjectType, Property}, + structs::{ + Task, TaskAccountMaintenance, TaskStatus, TaskStoreMaintenance, TaskTenantMaintenance, + }, + }, + types::EnumImpl, +}; +use smtp::reporting::index::ExternalReportIndex; +use store::{ + Serialize, ValueKey, + rand::{self}, + registry::{RegistryFilter, RegistryQuery}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, Archiver, BatchBuilder, RegistryClass, ValueClass, now}, +}; +use trc::{AddContext, StoreEvent}; +use types::{ + collection::Collection, + field::{EmailField, MailboxField}, + id::Id, +}; + +pub(crate) trait MaintenanceTask: Sync + Send { + fn store_maintenance( + &self, + task: &TaskStoreMaintenance, + ) -> impl Future + Send; + fn account_maintenance( + &self, + task: &TaskAccountMaintenance, + ) -> impl Future + Send; + fn tenant_maintenance( + &self, + task: &TaskTenantMaintenance, + ) -> impl Future + Send; +} + +impl MaintenanceTask for Server { + async fn store_maintenance(&self, task: &TaskStoreMaintenance) -> TaskResult { + match store_maintenance(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!(err.details("Failed to perform store maintenance task")); + result + } + } + } + + async fn account_maintenance(&self, task: &TaskAccountMaintenance) -> TaskResult { + match account_maintenance(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .details("Failed to perform account maintenance task") + ); + result + } + } + } + + async fn tenant_maintenance(&self, task: &TaskTenantMaintenance) -> TaskResult { + match tenant_maintenance(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!(err.details("Failed to perform tenant maintenance task")); + result + } + } + } +} + +async fn store_maintenance( + server: &Server, + task: &TaskStoreMaintenance, +) -> trc::Result { + match task.maintenance_type { + TaskStoreMaintenanceType::ReindexAccounts + | TaskStoreMaintenanceType::PurgeAccounts + | TaskStoreMaintenanceType::ResetUserQuotas => { + let mut batch = BatchBuilder::new(); + let now = now() as i64; + let maintenance_type = match task.maintenance_type { + TaskStoreMaintenanceType::ReindexAccounts => TaskAccountMaintenanceType::Reindex, + TaskStoreMaintenanceType::PurgeAccounts => TaskAccountMaintenanceType::Purge, + TaskStoreMaintenanceType::ResetUserQuotas => { + TaskAccountMaintenanceType::RecalculateQuota + } + _ => unreachable!(), + }; + for account_id in server + .registry() + .query::(RegistryQuery::new(ObjectType::Account)) + .await? + { + #[cfg(feature = "test_mode")] + let status = TaskStatus::at(now); + + #[cfg(not(feature = "test_mode"))] + let status = + TaskStatus::at(now + rand::RngExt::random_range(&mut rand::rng(), 0..=300)); + + batch.schedule_task(Task::AccountMaintenance(TaskAccountMaintenance { + account_id: account_id.into(), + maintenance_type, + status, + })); + + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + server.notify_task_queue(); + batch = BatchBuilder::new(); + } + } + + if !batch.is_empty() { + server.core.storage.data.write(batch.build_all()).await?; + server.notify_task_queue(); + } + } + TaskStoreMaintenanceType::ReindexTelemetry => { + reindex_telemetry(server).await?; + } + TaskStoreMaintenanceType::PurgeData => { + // Delete expired external reports + let now = now(); + let mut batch = BatchBuilder::new(); + for object in [ + ObjectType::DmarcExternalReport, + ObjectType::TlsExternalReport, + ObjectType::ArfExternalReport, + ] { + let ids = server + .registry() + .query::>(RegistryQuery::new(object).filter(RegistryFilter::less_than( + Property::ExpiresAt, + now, + false, + ))) + .await?; + let object_id = object.to_id(); + for id in ids { + let item_id = id.id(); + if let Some(report) = server + .store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::Item { object_id, item_id }, + ))) + .await? + { + match &report.inner { + ObjectInner::DmarcExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::TlsExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + ObjectInner::ArfExternalReport(report) => { + report.write_ops(&mut batch, item_id, false); + } + _ => {} + } + + if batch.is_large_batch() { + server.store().write(batch.build_all()).await?; + batch = BatchBuilder::new(); + } + } + } + } + if !batch.is_empty() { + server.store().write(batch.build_all()).await?; + } + + let started = Instant::now(); + + server + .store() + .purge_store() + .await + .caused_by(trc::location!())?; + + server + .in_memory_store() + .purge_in_memory_store() + .await + .caused_by(trc::location!())?; + + server + .registry() + .purge_dead_nodes() + .await + .caused_by(trc::location!())?; + + + trc::event!( + Store(StoreEvent::DataStorePurged), + Elapsed = started.elapsed() + ); + } + TaskStoreMaintenanceType::PurgeBlob => { + if let Some(shard_index) = task.shard_index { + server + .store() + .purge_blobs(server.blob_store().clone(), shard_index as u8) + .await + .caused_by(trc::location!())?; + } else { + let mut batch = BatchBuilder::new(); + let now = now() as i64; + for shard_index in 0..=u8::MAX { + batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeBlob, + shard_index: Some(shard_index as u64), + status: TaskStatus::at(now), + })); + + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + server.notify_task_queue(); + batch = BatchBuilder::new(); + } + } + + if !batch.is_empty() { + server.core.storage.data.write(batch.build_all()).await?; + server.notify_task_queue(); + } + } + } + TaskStoreMaintenanceType::RemoveGreylist + | TaskStoreMaintenanceType::RemoveLockQueueMessage + | TaskStoreMaintenanceType::RemoveLockTask + | TaskStoreMaintenanceType::RemoveLockDav + | TaskStoreMaintenanceType::RemoveSieveId + | TaskStoreMaintenanceType::ResetRateLimiters + | TaskStoreMaintenanceType::ResetBlobQuotas + | TaskStoreMaintenanceType::RemoveAuthTokens => { + #[cfg(feature = "test_mode")] + if let Some(test_var) = task.shard_index { + use crate::task_manager::TaskFailureType; + + // Simulate success for testing purposes + match test_var { + 0 => { + return Ok(TaskResult::Success(vec![])); + } + 1 => { + return Ok(TaskResult::temporary( + "Simulated temporary failure".to_string(), + )); + } + 2 => { + return Ok(TaskResult::permanent("Simulated permanent failure")); + } + + retry => { + return Ok(TaskResult::Failure { + typ: TaskFailureType::Retry(retry), + message: "Simulated retry failure".to_string(), + max_attempts: None, + }); + } + } + } + + let prefixes = match task.maintenance_type { + TaskStoreMaintenanceType::RemoveGreylist => &[KV_GREYLIST][..], + TaskStoreMaintenanceType::RemoveLockQueueMessage => &[KV_LOCK_QUEUE_MESSAGE][..], + TaskStoreMaintenanceType::RemoveLockTask => &[KV_LOCK_TASK][..], + TaskStoreMaintenanceType::RemoveLockDav => &[KV_LOCK_DAV][..], + TaskStoreMaintenanceType::RemoveSieveId => &[KV_SIEVE_ID][..], + TaskStoreMaintenanceType::ResetRateLimiters => &[ + KV_RATE_LIMIT_RCPT, + KV_RATE_LIMIT_SCAN, + KV_RATE_LIMIT_LOITER, + KV_RATE_LIMIT_AUTH, + KV_RATE_LIMIT_SMTP, + KV_RATE_LIMIT_CONTACT, + KV_RATE_LIMIT_HTTP_AUTHENTICATED, + KV_RATE_LIMIT_HTTP_ANONYMOUS, + KV_RATE_LIMIT_IMAP, + ][..], + TaskStoreMaintenanceType::ResetBlobQuotas => &[KV_QUOTA_BLOB][..], + TaskStoreMaintenanceType::RemoveAuthTokens => &[KV_ACME, KV_OAUTH][..], + _ => unreachable!(), + }; + + for &prefix in prefixes { + server + .in_memory_store() + .key_delete_prefix(&[prefix]) + .await?; + } + } + TaskStoreMaintenanceType::ResetTenantQuotas => { + } + } + + Ok(TaskResult::Success(vec![])) +} + +async fn account_maintenance( + server: &Server, + task: &TaskAccountMaintenance, +) -> trc::Result { + match task.maintenance_type { + TaskAccountMaintenanceType::Purge => { + server.purge_account(task.account_id.document_id()).await?; + } + TaskAccountMaintenanceType::Reindex => { + reindex_account(server, task.account_id.document_id()).await?; + } + TaskAccountMaintenanceType::RecalculateImapUid => { + reset_imap_uids(server, task.account_id.document_id()).await?; + } + TaskAccountMaintenanceType::RecalculateQuota => { + recalculate_quota(server, task.account_id.document_id()).await?; + } + } + + Ok(TaskResult::Success(vec![])) +} + +async fn tenant_maintenance( + server: &Server, + task: &TaskTenantMaintenance, +) -> trc::Result { + match task.maintenance_type { + TaskTenantMaintenanceType::RecalculateQuota => { + recalculate_tenant_quota(server, task.tenant_id.document_id()).await?; + } + } + + Ok(TaskResult::Success(vec![])) +} + +async fn recalculate_quota(server: &Server, account_id: u32) -> trc::Result<()> { + let mut quota = 0; + + for collection in [ + Collection::Email, + Collection::Calendar, + Collection::CalendarEvent, + Collection::CalendarEventNotification, + Collection::AddressBook, + Collection::ContactCard, + Collection::FileNode, + Collection::SieveScript, + ] { + server + .archives(account_id, collection, &(), |_, archive| { + match collection { + Collection::Email => { + quota += archive.unarchive::()?.size.to_native() as i64; + } + Collection::Calendar => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::CalendarEvent => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::CalendarEventNotification => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::AddressBook => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::ContactCard => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::FileNode => { + quota += archive.unarchive::()?.size() as i64; + } + Collection::SieveScript => { + quota += u32::from(archive.unarchive::()?.size) as i64; + } + _ => {} + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + } + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .clear(ValueClass::Quota) + .add(ValueClass::Quota, quota); + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + + +#[cfg(not(feature = "enterprise"))] +async fn recalculate_tenant_quota(_server: &Server, _tenant_id: u32) -> trc::Result<()> { + Ok(()) +} + +async fn reset_imap_uids(server: &Server, account_id: u32) -> trc::Result<(u32, u32)> { + let mut mailbox_count = 0; + let mut email_count = 0; + + let cache = server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + + for &mailbox_id in cache.mailboxes.index.keys() { + let mailbox = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Mailbox, + mailbox_id, + )) + .await + .caused_by(trc::location!())? + .ok_or_else(|| trc::ImapEvent::Error.into_err().caused_by(trc::location!()))? + .into_deserialized::() + .caused_by(trc::location!())?; + let mut new_mailbox = mailbox.inner.clone(); + new_mailbox.uid_validity = rand::random::(); + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox) + .with_document(mailbox_id) + .custom( + ObjectIndexBuilder::new() + .with_current(mailbox) + .with_changes(new_mailbox), + ) + .caused_by(trc::location!())? + .clear(MailboxField::UidCounter); + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + mailbox_count += 1; + } + + // Reset all UIDs + for message_id in cache.emails.items.iter().map(|i| i.document_id) { + let data = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + message_id, + )) + .await + .caused_by(trc::location!())?; + let data_ = if let Some(data) = data { + data + } else { + continue; + }; + let data = data_ + .to_unarchived::() + .caused_by(trc::location!())?; + let mut new_data = data + .deserialize::() + .caused_by(trc::location!())?; + + let ids = server + .assign_email_ids( + account_id, + new_data.mailboxes.iter().map(|m| m.mailbox_id), + false, + ) + .await + .caused_by(trc::location!())?; + + for (uid_mailbox, uid) in new_data.mailboxes.iter_mut().zip(ids) { + uid_mailbox.uid = uid; + } + + // Prepare write batch + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Email) + .with_document(message_id) + .assert_value(ValueClass::Property(EmailField::Archive.into()), &data) + .set( + EmailField::Archive, + Archiver::new(new_data) + .serialize() + .caused_by(trc::location!())?, + ); + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + email_count += 1; + } + + Ok((mailbox_count, email_count)) +} diff --git a/crates/services/src/task_manager/manager.rs b/crates/services/src/task_manager/manager.rs new file mode 100644 index 0000000..23cb05b --- /dev/null +++ b/crates/services/src/task_manager/manager.rs @@ -0,0 +1,687 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::acme::AcmeTask; +use crate::task_manager::alarm::SendAlarmTask; +use crate::task_manager::destroy_account::DestroyAccountTask; +use crate::task_manager::dkim::DkimManagementTask; +use crate::task_manager::dns::DnsManagementTask; +use crate::task_manager::imip::SendImipTask; +use crate::task_manager::index::SearchIndexTask; +use crate::task_manager::lock::TaskLockManager; +use crate::task_manager::maintenance::MaintenanceTask; +use crate::task_manager::merge_threads::MergeThreadsTask; +use crate::task_manager::report::{self, SubmitReportTask}; +use crate::task_manager::restore_item::RestoreItemTask; +use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask; +use crate::task_manager::{ + DEFAULT_LOCK_EXPIRY, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo, + TaskJob, TaskManagerIpc, TaskResult, +}; +use common::BuildServer; +use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol}; +use common::network::limiter::ConcurrencyLimiter; +use common::network::{ServerInstance, TcpAcceptor}; +use common::{Inner, Server}; +use registry::schema::enums::TaskType; +use registry::schema::structs::{ + Task, TaskManager, TaskRetryStrategy, TaskStatus, TaskStatusFailed, TaskStatusRetry, +}; +use registry::types::datetime::UTCDateTime; +use registry::types::{EnumImpl, ObjectImpl}; +use std::collections::hash_map::Entry; +use std::future::Future; +use std::time::Duration; +use std::{sync::Arc, time::Instant}; +use store::rand::seq::SliceRandom; +use store::write::key::DeserializeBigEndian; +use store::{ + IterateParams, ValueKey, + write::{BatchBuilder, TaskQueueClass, ValueClass, assert::AssertValue, now}, +}; +use store::{SerializeInfallible, U64_LEN, rand}; +use tokio::sync::{mpsc, watch}; +use trc::TaskManagerEvent; +use utils::snowflake::SnowflakeIdGenerator; + +const TASK_QUEUE_BUFFER: usize = 10; +const PERPETUAL_RETRY_MIN_DELAY: u64 = 3600; +const PERPETUAL_RETRY_MAX_DELAY: u64 = 21600; + +pub fn spawn_task_manager(inner: Arc) { + let is_clustered = { + let server = inner.build_server(); + let roles = &server.core.network.roles; + + if !roles.account_maintenance + && !roles.store_maintenance + && !roles.search_indexing + && !roles.spam_training + && !roles.task_manager + { + return; + } + + server.core.storage.coordinator.is_enabled() + }; + + trc::event!(TaskManager(TaskManagerEvent::ManagerStarted)); + + // Create dummy server instance for alarms + let server_instance = Arc::new(ServerInstance { + id: "_local".to_string(), + protocol: ServerProtocol::Smtp, + acceptor: TcpAcceptor::Plain, + limiter: ConcurrencyLimiter::new(100), + tls_timeout: DEFAULT_TLS_TIMEOUT, + shutdown_rx: watch::channel(false).1, + proxy_networks: vec![], + span_id_gen: Arc::new(SnowflakeIdGenerator::new()), + }); + + // Spawn workers for each task type + let mut txs = Vec::with_capacity(TaskType::COUNT); + for idx in 0..TaskType::COUNT { + let task_type = TaskType::from_id(idx as u16).unwrap(); + let channel_capacity = match task_type { + TaskType::IndexDocument | TaskType::UnindexDocument | TaskType::IndexTrace => { + std::cmp::max( + inner.build_server().core.email.index_batch_size, + TASK_QUEUE_BUFFER, + ) + } + TaskType::DestroyAccount + | TaskType::AccountMaintenance + | TaskType::TenantMaintenance + | TaskType::StoreMaintenance => 1, + TaskType::SpamFilterMaintenance => 2, + TaskType::CalendarAlarmEmail + | TaskType::CalendarAlarmNotification + | TaskType::CalendarItipMessage + | TaskType::MergeThreads + | TaskType::DmarcReport + | TaskType::TlsReport + | TaskType::RestoreArchivedItem + | TaskType::AcmeRenewal + | TaskType::DkimManagement + | TaskType::DnsManagement => TASK_QUEUE_BUFFER, + }; + + let (tx, mut rx) = mpsc::channel::(channel_capacity); + txs.push(tx); + let inner = inner.clone(); + let server_instance = server_instance.clone(); + + if matches!( + task_type, + TaskType::IndexDocument | TaskType::UnindexDocument | TaskType::IndexTrace, + ) { + tokio::spawn(async move { + while let Some(job) = rx.recv().await { + let server = inner.build_server(); + let batch_size = server.core.email.index_batch_size; + let mut batch = Vec::with_capacity(batch_size); + match server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: job.id }, + ))) + .await + { + Ok(Some(task)) => { + batch.push(TaskDetails { task, info: job }); + } + Ok(None) => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Id = job.id, + Reason = "Task not found in store, likely already processed.", + ); + } + Err(err) => { + trc::error!( + err.id(job.id) + .details("Failed to retrieve task details.") + .caused_by(trc::location!()) + ); + } + } + + while batch.len() < batch_size { + match rx.try_recv() { + Ok(job) => { + match server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: job.id }, + ))) + .await + { + Ok(Some(task)) => { + batch.push(TaskDetails { task, info: job }); + } + Ok(None) => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Id = job.id, + Reason = "Task not found in store, likely already processed.", + ); + } + Err(err) => { + trc::error!( + err.id(job.id) + .details("Failed to retrieve task details.") + .caused_by(trc::location!()) + ); + } + } + } + Err(_) => break, + } + } + + // Dispatch + let mut refresh_queue = false; + let results = server.index(&batch).await.into_iter().map(|r| { + refresh_queue |= r.result.is_retry(); + r.result + }); + update_tasks(&server, &mut batch, results).await; + + if refresh_queue || rx.is_empty() { + server.notify_task_queue(); + } + } + }); + } else { + let server_instance = server_instance.clone(); + tokio::spawn(async move { + while let Some(job) = rx.recv().await { + let server = inner.build_server(); + let mut refresh_queue = false; + + match server + .store() + .get_value::(ValueKey::from(ValueClass::TaskQueue( + TaskQueueClass::Task { id: job.id }, + ))) + .await + { + Ok(Some(task)) => { + let result = match &task { + Task::CalendarAlarmEmail(task) => { + server.send_email_alarm(task, server_instance.clone()).await + } + Task::CalendarAlarmNotification(task) => { + server.send_display_alarm(task).await + } + Task::CalendarItipMessage(task) => { + server.send_imip(task, server_instance.clone()).await + } + Task::MergeThreads(task) => server.merge_threads(task).await, + Task::DmarcReport(task) => { + server + .submit_report(report::ReportId::Dmarc(task.report_id.id())) + .await + } + Task::TlsReport(task) => { + server + .submit_report(report::ReportId::Tls(task.report_id.id())) + .await + } + Task::RestoreArchivedItem(task) => server.restore_item(task).await, + Task::DestroyAccount(task) => server.destroy_account(task).await, + Task::AccountMaintenance(task) => { + server.account_maintenance(task).await + } + Task::TenantMaintenance(task) => { + server.tenant_maintenance(task).await + } + Task::StoreMaintenance(task) => { + server.store_maintenance(task).await + } + Task::SpamFilterMaintenance(task) => { + Box::pin(server.spam_filter_maintenance(task)).await + } + Task::AcmeRenewal(task) => server.acme_management(task).await, + Task::DkimManagement(task_dkim_rotation) => { + server.dkim_management(task_dkim_rotation).await + } + Task::DnsManagement(task_dns_management) => { + server.dns_management(task_dns_management).await + } + Task::IndexDocument(_) + | Task::UnindexDocument(_) + | Task::IndexTrace(_) => unreachable!(), + }; + + refresh_queue = result.is_retry(); + + update_tasks( + &server, + &mut [TaskDetails { task, info: job }], + vec![result], + ) + .await; + } + Ok(None) => { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Id = job.id, + Reason = "Task not found in store, likely already processed.", + ); + } + Err(err) => { + trc::error!( + err.id(job.id) + .details("Failed to retrieve task details.") + .caused_by(trc::location!()) + ); + } + } + + if refresh_queue || rx.is_empty() { + server.notify_task_queue(); + } + } + }); + } + } + + const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + tokio::spawn(async move { + let mut ipc = TaskManagerIpc { + txs: txs.try_into().expect("Incorrect number of task channels"), + locked: Default::default(), + revision: 0, + }; + let rx = inner.ipc.task_tx.clone(); + loop { + // Index any queued tasks + let mut sleep_for = inner.build_server().process_tasks(&mut ipc).await; + if is_clustered && sleep_for > REFRESH_INTERVAL { + sleep_for = REFRESH_INTERVAL; + } + + // Wait for a signal or sleep until the next task is due + let _ = tokio::time::timeout(sleep_for, rx.notified()).await; + } + }); +} + +pub(crate) trait TaskQueueManager: Sync + Send { + fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> impl Future + Send; +} + +impl TaskQueueManager for Server { + async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration { + let now_timestamp = now(); + let from_key = ValueKey:: { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 1 }), + }; + let to_key = ValueKey:: { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::TaskQueue(TaskQueueClass::Due { + id: u64::MAX, + due: now_timestamp + QUEUE_REFRESH_INTERVAL, + }), + }; + + // Retrieve tasks pending to be processed + let mut tasks = Vec::new(); + let now = Instant::now(); + let mut next_event = None; + let roles = &self.core.network.roles; + ipc.revision += 1; + let _ = self + .store() + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + if key.len() == U64_LEN * 2 { + let task_due = key.deserialize_be_u64(0)?; + let task_id = key.deserialize_be_u64(U64_LEN)?; + + if task_due <= now_timestamp { + let task_type_idx = value.deserialize_be_u16(0)?; + let task_type = TaskType::from_id(task_type_idx).ok_or_else(|| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Value, value) + })?; + let enabled = match task_type { + TaskType::IndexDocument + | TaskType::UnindexDocument + | TaskType::IndexTrace => roles.search_indexing, + TaskType::AccountMaintenance + | TaskType::TenantMaintenance + | TaskType::DestroyAccount => roles.account_maintenance, + TaskType::StoreMaintenance => roles.store_maintenance, + TaskType::SpamFilterMaintenance => roles.spam_training, + TaskType::CalendarAlarmEmail + | TaskType::CalendarAlarmNotification + | TaskType::CalendarItipMessage + | TaskType::MergeThreads + | TaskType::DmarcReport + | TaskType::TlsReport + | TaskType::RestoreArchivedItem + | TaskType::AcmeRenewal + | TaskType::DkimManagement + | TaskType::DnsManagement => true, + }; + + if !enabled { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Id = task_id, + Details = task_type.as_str(), + Reason = "Task type is disabled by cluster roles.", + ); + return Ok(true); + } + + match ipc.locked.entry(task_id) { + Entry::Occupied(mut entry) => { + let locked = entry.get_mut(); + if locked.expires <= now || locked.due < task_due { + locked.expires = Instant::now() + + std::time::Duration::from_secs( + DEFAULT_LOCK_EXPIRY + 1, + ); + locked.due = task_due; + tasks.push(( + TaskJob { + id: task_id, + due: task_due, + typ: task_type, + }, + task_type_idx, + )); + } + locked.revision = ipc.revision; + } + Entry::Vacant(entry) => { + entry.insert(Locked { + expires: Instant::now() + + std::time::Duration::from_secs( + DEFAULT_LOCK_EXPIRY + 1, + ), + due: task_due, + revision: ipc.revision, + }); + tasks.push(( + TaskJob { + id: task_id, + due: task_due, + typ: task_type, + }, + task_type_idx, + )); + } + } + + Ok(true) + } else { + next_event = Some(task_due); + Ok(false) + } + } else { + Ok(true) + } + }, + ) + .await + .map_err(|err| { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to iterate over task queue.") + ); + }); + + if !tasks.is_empty() { + trc::event!( + TaskManager(TaskManagerEvent::TaskAcquired), + Total = tasks.len(), + Details = ipc.locked.len(), + ); + } + + // Shuffle tasks + if tasks.len() > 1 { + tasks.shuffle(&mut rand::rng()); + } + + // Dispatch tasks + for (task_job, task_type_idx) in tasks { + let tx = &ipc.txs[task_type_idx as usize]; + + if tx.capacity() > 0 { + if self.try_lock_task(task_job.id).await && tx.send(task_job).await.is_err() { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending task.", + CausedBy = trc::location!() + ); + } + } else { + // If the channel is full, release the lock so it can be picked up in the next iteration + ipc.locked.remove(&task_job.id); + } + } + + // Delete expired locks + let now = Instant::now(); + ipc.locked + .retain(|_, locked| locked.expires > now && locked.revision == ipc.revision); + Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| { + timestamp.saturating_sub(store::write::now()) + })) + } +} + +async fn update_tasks( + server: &Server, + tasks: &mut [TaskDetails], + results: impl IntoIterator, +) { + let mut batch = BatchBuilder::new(); + + for (task, result) in tasks.iter_mut().zip(results) { + let id = task.info.id; + batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id, + due: task.info.due, + })); + match result { + TaskResult::Success(tasks) => { + for task in tasks { + batch.schedule_task(task); + } + batch.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id })); + } + TaskResult::Ignored => { + batch.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id })); + } + TaskResult::Update(ops) => { + for op in ops { + batch.any_op(op); + } + } + TaskResult::Failure { + typ, + message, + max_attempts, + } => { + let (attempt_number, retry_since) = match task.task.status() { + TaskStatus::Pending(_) => (0, UTCDateTime::now()), + TaskStatus::Retry(status) => (status.attempt_number, status.created_at), + TaskStatus::Failed(status) => (status.failed_attempt_number, status.failed_at), + }; + let retry_at = match typ { + TaskFailureType::Retry(retry_at) => (attempt_number + < max_attempts.unwrap_or(server.core.network.task_manager.max_attempts) + && retry_at + <= (retry_since.timestamp() as u64).saturating_add( + server.core.network.task_manager.total_deadline.as_secs(), + )) + .then_some(retry_at) + .or_else(|| perpetual_retry_time(task.info.typ, attempt_number)), + TaskFailureType::Temporary => next_retry_time( + &server.core.network.task_manager, + max_attempts, + retry_since.timestamp() as u64, + attempt_number, + now(), + ) + .or_else(|| perpetual_retry_time(task.info.typ, attempt_number)), + TaskFailureType::Perpetual => { + perpetual_retry_time(task.info.typ, attempt_number) + } + TaskFailureType::Permanent => None, + }; + + let due = if let Some(retry_at) = retry_at { + trc::event!( + TaskManager(TaskManagerEvent::TaskRetry), + Id = id, + Details = task.task.name(), + Reason = message.to_string(), + NextRetry = trc::Value::Timestamp(retry_at), + ); + + task.task.set_status(TaskStatus::Retry(TaskStatusRetry { + due: UTCDateTime::from_timestamp(retry_at as i64), + attempt_number: attempt_number + 1, + failure_reason: message, + created_at: retry_since, + })); + + retry_at + } else { + trc::event!( + TaskManager(TaskManagerEvent::TaskFailed), + Id = id, + Details = task.task.name(), + Reason = message.to_string(), + ); + + task.task.set_status(TaskStatus::Failed(TaskStatusFailed { + failed_at: UTCDateTime::now(), + failed_attempt_number: attempt_number, + failure_reason: message, + created_at: retry_since, + })); + u64::MAX + }; + batch + .assert_value( + ValueClass::TaskQueue(TaskQueueClass::Task { id }), + AssertValue::Some, + ) + .set( + ValueClass::TaskQueue(TaskQueueClass::Due { id, due }), + task.info.typ.to_id().serialize(), + ) + .set( + ValueClass::TaskQueue(TaskQueueClass::Task { id }), + task.task.to_pickled_vec(), + ); + } + } + } + + if let Err(err) = server.store().write(batch.build_all()).await { + if err.matches(trc::EventType::Store(trc::StoreEvent::AssertValueFailed)) { + trc::event!( + TaskManager(TaskManagerEvent::TaskIgnored), + Reason = "Task was deleted while being processed; skipping update.", + ); + } else { + trc::error!(err.details("Failed to remove task(s) from queue.")); + } + } + + for task in tasks { + server.remove_index_lock(task.info.id).await; + } +} + +pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option { + matches!( + typ, + TaskType::AcmeRenewal + | TaskType::DkimManagement + | TaskType::IndexDocument + | TaskType::UnindexDocument + ) + .then(|| { + now().saturating_add( + PERPETUAL_RETRY_MIN_DELAY + .saturating_mul(1u64 << attempt.min(4)) + .min(PERPETUAL_RETRY_MAX_DELAY), + ) + }) +} + +pub fn next_retry_time( + manager: &TaskManager, + max_attempts_override: Option, + retry_since: u64, + attempt: u64, + now: u64, +) -> Option { + if attempt >= max_attempts_override.unwrap_or(manager.max_attempts) { + return None; + } + + let delay_secs: u64 = match &manager.strategy { + TaskRetryStrategy::FixedDelay(fixed) => fixed.delay.as_secs(), + TaskRetryStrategy::ExponentialBackoff(backoff) => { + let delay = (backoff.initial_delay.as_secs() as f64 + * backoff.factor.into_inner().powi(attempt as i32)) + .min(backoff.max_delay.as_secs() as f64) as u64; + + if backoff.jitter { + let jitter_factor = rand::random::() + 0.5; + ((delay as f64 * jitter_factor) as u64).min(backoff.max_delay.as_secs()) + } else { + delay + } + } + }; + + let next_time = now.saturating_add(delay_secs); + let deadline = retry_since.saturating_add(manager.total_deadline.as_secs()); + if next_time > deadline { + return None; + } + + Some(next_time) +} + +impl TaskResult { + pub fn is_success(&self) -> bool { + matches!(self, TaskResult::Success(_)) + } + + pub fn is_retry(&self) -> bool { + matches!( + self, + TaskResult::Update(_) + | TaskResult::Failure { + typ: TaskFailureType::Temporary + | TaskFailureType::Retry(_) + | TaskFailureType::Perpetual, + .. + } + ) + } +} diff --git a/crates/services/src/task_manager/merge_threads.rs b/crates/services/src/task_manager/merge_threads.rs new file mode 100644 index 0000000..2a8a4c0 --- /dev/null +++ b/crates/services/src/task_manager/merge_threads.rs @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use common::{Server, storage::index::ObjectIndexBuilder}; +use email::message::{ + ingest::{ThreadMerge, has_message_id}, + metadata::MessageData, +}; +use registry::schema::structs::TaskMergeThreads; +use std::{str::FromStr, time::Duration}; +use store::{ + IterateParams, Key, U32_LEN, ValueKey, + ahash::AHashMap, + rand::RngExt, + write::{ + AlignedBytes, Archive, BatchBuilder, IndexPropertyClass, MergeResult, Params, ValueClass, + key::DeserializeBigEndian, + }, +}; +use trc::AddContext; +use types::{ + collection::{Collection, SyncCollection}, + field::EmailField, +}; +use utils::cheeky_hash::CheekyHash; + +const MAX_RETRIES: usize = 5; + +pub(crate) trait MergeThreadsTask: Sync + Send { + fn merge_threads(&self, threads: &TaskMergeThreads) -> impl Future + Send; +} + +impl MergeThreadsTask for Server { + async fn merge_threads(&self, threads: &TaskMergeThreads) -> TaskResult { + match merge_threads(self, threads).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(threads.account_id.document_id()) + .details("Failed to merge threads") + ); + result + } + } + } +} + +async fn merge_threads( + server: &Server, + task_merge_threads: &TaskMergeThreads, +) -> trc::Result { + let Ok(thread_hash) = CheekyHash::from_str(&task_merge_threads.thread_name) else { + return Ok(TaskResult::permanent("Invalid thread hash")); + }; + let Ok(mut message_ids) = task_merge_threads + .message_ids + .iter() + .map(|id| CheekyHash::from_str(id)) + .collect::, _>>() + else { + return Ok(TaskResult::permanent("Invalid message ids")); + }; + message_ids.sort_unstable(); + + let account_id = task_merge_threads.account_id.document_id(); + let mut try_count = 0; + + let from_key = ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: 0, + class: ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: thread_hash, + }), + }; + let to_key = ValueKey { + account_id, + collection: Collection::Email.into(), + document_id: u32::MAX, + class: ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: thread_hash, + }), + }; + let mut prefix = from_key.serialize(0); + let key_len = prefix.len(); + let document_id_pos = key_len - U32_LEN; + prefix.truncate(document_id_pos); + + 'retry: loop { + // Merge threads + let mut thread_merge = ThreadMerge::new(); + let mut same_subject_messages: AHashMap> = AHashMap::new(); + + // Find thread ids + server + .store() + .iterate( + IterateParams::new(from_key.clone(), to_key.clone()).ascending(), + |key, value| { + if key.len() == key_len && key.starts_with(&prefix) { + // Find matching references + let references = value.get(U32_LEN..).unwrap_or_default(); + let thread_id = value.deserialize_be_u32(0)?; + let document_id = key.deserialize_be_u32(document_id_pos)?; + + if has_message_id(&message_ids, references) { + thread_merge.add(thread_id, document_id); + } else { + // Keep track of messages with the same subject for potential future merges + same_subject_messages + .entry(thread_id) + .or_default() + .push(document_id); + } + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if thread_merge.num_thread_ids() < 2 { + // Another process merged the threads already? + return Ok(TaskResult::Success(vec![])); + } + + // Add other messages with the same subject to the merge if they share a + // thread id with a message that has a matching message id + for thread_id in thread_merge.thread_ids().copied().collect::>() { + if let Some(document_ids) = same_subject_messages.get(&thread_id) { + for &document_id in document_ids { + thread_merge.add(thread_id, document_id); + } + } + } + + let thread_id = thread_merge.merge_thread_id(); + + // Delete all but the most common threadId + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Thread); + + for &delete_thread_id in thread_merge.thread_ids() { + if delete_thread_id != thread_id { + batch + .with_document(delete_thread_id) + .log_container_delete(SyncCollection::Thread); + } + } + + // Move messages to the new threadId + batch.with_collection(Collection::Email); + + for (&group_thread_id, document_ids) in thread_merge.thread_groups() { + if thread_id != group_thread_id { + for &document_id in document_ids { + if let Some(data_) = server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::Email, + document_id, + )) + .await + .caused_by(trc::location!())? + { + let data = data_ + .to_unarchived::() + .caused_by(trc::location!())?; + if data.inner.thread_id != group_thread_id { + try_count += 1; + continue 'retry; + } + + // Update thread id + let mut new_data = data + .deserialize::() + .caused_by(trc::location!())?; + new_data.thread_id = thread_id; + batch + .with_document(document_id) + .custom( + ObjectIndexBuilder::new() + .with_current(data) + .with_changes(new_data), + ) + .caused_by(trc::location!())?; + + // Update thread index property + batch.merge_fnc( + ValueClass::IndexProperty(IndexPropertyClass::Hash { + property: EmailField::Threading.into(), + hash: thread_hash, + }), + Params::with_capacity(3) + .with_u64(thread_id as u64) + .with_u64(group_thread_id as u64), + |params, _, bytes| { + let new_thread_id = params.u64(0) as u32; + let old_thread_id = params.u64(1) as u32; + + let mut thread_index = bytes + .filter(|v| v.len() > U32_LEN) + .ok_or_else(|| { + trc::StoreEvent::AssertValueFailed + .into_err() + .details("Message no longer exists.") + .caused_by(trc::location!()) + })? + .to_vec(); + + if thread_index.as_slice().deserialize_be_u32(0)? != old_thread_id { + return Err( + trc::StoreEvent::AssertValueFailed + .into_err() + .details("Thread id mismatch, likely due to concurrent modification.") + .caused_by(trc::location!()) + ); + } + + thread_index[0..U32_LEN].copy_from_slice(&new_thread_id.to_be_bytes()); + + Ok(MergeResult::Update(thread_index)) + }, + ); + } + } + } + } + + match server.commit_batch(batch).await { + Ok(_) => return Ok(TaskResult::Success(vec![])), + Err(err) if err.is_assertion_failure() && try_count < MAX_RETRIES => { + let backoff = store::rand::rng().random_range(50..=300); + tokio::time::sleep(Duration::from_millis(backoff)).await; + try_count += 1; + } + Err(err) => { + return Err(err.caused_by(trc::location!())); + } + } + } +} diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs new file mode 100644 index 0000000..b7d2a00 --- /dev/null +++ b/crates/services/src/task_manager/mod.rs @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{KV_LOCK_TASK, Server}; +use registry::schema::enums::TaskType; +use registry::schema::structs::Task; +use registry::types::EnumImpl; +use std::future::Future; +use std::time::Instant; +use store::ahash::AHashMap; +use store::write::Operation; +use tokio::sync::mpsc; +use trc::TaskManagerEvent; + +pub mod acme; +pub mod alarm; +pub mod destroy_account; +pub mod dkim; +pub mod dns; +pub mod imip; +pub mod index; +pub mod lock; +pub mod maintenance; +pub mod manager; +pub mod merge_threads; +pub mod report; +pub mod restore_item; +pub mod scheduler; +pub mod spam_classifier; + +const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes +const DEFAULT_LOCK_EXPIRY: u64 = 60 * 60; // 1 hour + +pub(crate) struct TaskManagerIpc { + txs: [mpsc::Sender; TaskType::COUNT], + locked: AHashMap, + revision: u64, +} + +#[derive(Debug)] +pub(crate) struct Locked { + expires: Instant, + due: u64, + revision: u64, +} + +#[derive(Debug)] +pub(crate) struct TaskDetails { + task: Task, + info: TaskJob, +} + +#[derive(Debug)] +pub(crate) struct TaskJob { + id: u64, + due: u64, + typ: TaskType, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TaskResult { + Success(Vec), + Update([Operation; 2]), + Failure { + typ: TaskFailureType, + message: String, + max_attempts: Option, + }, + Ignored, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub(crate) enum TaskFailureType { + Retry(u64), + Temporary, + Perpetual, + Permanent, +} + +pub(crate) trait TaskInfo { + fn name(&self) -> &'static str; +} + +impl TaskInfo for Task { + fn name(&self) -> &'static str { + match self { + Task::IndexDocument(_) => "IndexDocument", + Task::UnindexDocument(_) => "UnindexDocument", + Task::IndexTrace(_) => "IndexTrace", + Task::CalendarAlarmEmail(_) => "CalendarAlarmEmail", + Task::CalendarAlarmNotification(_) => "CalendarAlarmNotification", + Task::CalendarItipMessage(_) => "CalendarItipMessage", + Task::MergeThreads(_) => "MergeThreads", + Task::DmarcReport(_) => "DmarcReport", + Task::TlsReport(_) => "TlsReport", + Task::RestoreArchivedItem(_) => "RestoreArchivedItem", + Task::DestroyAccount(_) => "DestroyAccount", + Task::AccountMaintenance(_) => "AccountMaintenance", + Task::StoreMaintenance(_) => "StoreMaintenance", + Task::SpamFilterMaintenance(_) => "SpamFilterMaintenance", + Task::AcmeRenewal(_) => "AcmeRenewal", + Task::DkimManagement(_) => "DkimManagement", + Task::DnsManagement(_) => "DnsManagement", + Task::TenantMaintenance(_) => "TenantMaintenance", + } + } +} + +impl TaskResult { + pub fn permanent(message: impl Into) -> Self { + TaskResult::Failure { + typ: TaskFailureType::Permanent, + message: message.into(), + max_attempts: None, + } + } + + pub fn temporary(message: impl Into) -> Self { + TaskResult::Failure { + typ: TaskFailureType::Temporary, + message: message.into(), + max_attempts: None, + } + } + + pub fn perpetual(message: impl Into) -> Self { + TaskResult::Failure { + typ: TaskFailureType::Perpetual, + message: message.into(), + max_attempts: None, + } + } +} diff --git a/crates/services/src/task_manager/report.rs b/crates/services/src/task_manager/report.rs new file mode 100644 index 0000000..44bcaf6 --- /dev/null +++ b/crates/services/src/task_manager/report.rs @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::TaskResult; +use common::Server; +use smtp::reporting::{dmarc::DmarcReporting, tls::TlsReporting}; + +pub enum ReportId { + Dmarc(u64), + Tls(u64), +} + +pub(crate) trait SubmitReportTask: Sync + Send { + fn submit_report(&self, report_id: ReportId) -> impl Future + Send; +} + +impl SubmitReportTask for Server { + async fn submit_report(&self, report_id: ReportId) -> TaskResult { + match submit_report(self, report_id).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!(err.details("Failed to submit report")); + result + } + } + } +} + +async fn submit_report(server: &Server, report_id: ReportId) -> trc::Result { + match report_id { + ReportId::Dmarc(item_id) => server + .send_dmarc_aggregate_report(item_id) + .await + .map(|_| TaskResult::Success(vec![])), + ReportId::Tls(item_id) => server + .send_tls_aggregate_report(item_id) + .await + .map(|_| TaskResult::Success(vec![])), + } +} diff --git a/crates/services/src/task_manager/restore_item.rs b/crates/services/src/task_manager/restore_item.rs new file mode 100644 index 0000000..82dada2 --- /dev/null +++ b/crates/services/src/task_manager/restore_item.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{Server, auth::BuildAccessToken}; +use email::{ + mailbox::INBOX_ID, + message::ingest::{EmailIngest, IngestEmail, IngestSource}, +}; +use mail_parser::MessageParser; +use registry::schema::{enums::ArchivedItemType, structs::TaskRestoreArchivedItem}; +use store::write::{BatchBuilder, BlobLink, BlobOp}; +use trc::AddContext; + +use crate::task_manager::TaskResult; + +pub(crate) trait RestoreItemTask: Sync + Send { + fn restore_item( + &self, + task: &TaskRestoreArchivedItem, + ) -> impl Future + Send; +} + +impl RestoreItemTask for Server { + async fn restore_item(&self, task: &TaskRestoreArchivedItem) -> TaskResult { + match restore_item(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!( + err.account_id(task.account_id.document_id()) + .details("Failed to restore item") + ); + result + } + } + } +} + +async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::Result { + match task.archived_item_type { + ArchivedItemType::Email => { + let account_id = task.account_id.document_id(); + let access_token = server + .access_token(account_id) + .await + .caused_by(trc::location!())?; + + let Some(bytes) = server + .blob_store() + .get_blob(task.blob_id.hash.as_slice(), 0..usize::MAX) + .await? + else { + return Ok(TaskResult::permanent("Blob not found")); + }; + + match server + .email_ingest(IngestEmail { + raw_message: &bytes, + message: MessageParser::new().parse(&bytes), + blob_hash: Some(&task.blob_id.hash), + access_token: &access_token.build(), + mailbox_ids: vec![INBOX_ID], + keywords: vec![], + received_at: (task.created_at.timestamp() as u64).into(), + source: IngestSource::Restore, + session_id: 0, + }) + .await + { + Ok(_) => { + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id).clear(BlobOp::Link { + hash: task.blob_id.hash.clone(), + to: BlobLink::Temporary { + until: task.archived_until.timestamp() as u64, + }, + }); + server.store().write(batch.build_all()).await?; + + Ok(TaskResult::Success(vec![])) + } + Err(mut err) + if err.matches(trc::EventType::MessageIngest( + trc::MessageIngestEvent::Error, + )) => + { + Ok(TaskResult::permanent( + err.take_value(trc::Key::Reason) + .and_then(|v| v.into_string()) + .unwrap() + .to_string(), + )) + } + Err(err) => Err(err.caused_by(trc::location!())), + } + } + ArchivedItemType::FileNode + | ArchivedItemType::CalendarEvent + | ArchivedItemType::ContactCard + | ArchivedItemType::SieveScript => Ok(TaskResult::permanent("Not implemented")), + } +} diff --git a/crates/services/src/task_manager/scheduler.rs b/crates/services/src/task_manager/scheduler.rs new file mode 100644 index 0000000..2f4495c --- /dev/null +++ b/crates/services/src/task_manager/scheduler.rs @@ -0,0 +1,399 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; +use std::{ + collections::BinaryHeap, + sync::Arc, + time::{Instant, SystemTime}, +}; + +use common::{ + BuildServer, Inner, LONG_1D_SLUMBER, + config::{mailstore::spamfilter, telemetry::OtelMetrics}, +}; +use registry::{ + schema::{ + enums::{TaskSpamFilterMaintenanceType, TaskStoreMaintenanceType, TaskType}, + structs::{Task, TaskSpamFilterMaintenance, TaskStatus, TaskStoreMaintenance}, + }, + types::EnumImpl, +}; +use store::write::{BatchBuilder, now}; +use trc::{ClusterEvent, Collector, MetricType, TaskManagerEvent, TelemetryEvent}; + +#[derive(PartialEq, Eq)] +struct Action { + due: Instant, + event: Event, +} + +#[derive(PartialEq, Eq, Debug)] +enum Event { + PurgeAccount, + PurgeDataStore, + PurgeBlobStore, + OtelMetrics, + CalculateMetrics, + TrainSpamClassifier, + RenewNodeIdLease, +} + +#[derive(Default)] +struct Queue { + heap: BinaryHeap, +} + + +pub fn spawn_task_scheduler(inner: Arc) { + tokio::spawn(async move { + trc::event!(TaskManager(TaskManagerEvent::SchedulerStarted)); + let start_time = SystemTime::now(); + + // Add all events to queue + let mut queue = Queue::default(); + { + let server = inner.build_server(); + + // Account purge + queue.schedule( + Instant::now() + server.core.email.account_purge_frequency.time_to_next(), + Event::PurgeAccount, + ); + queue.schedule( + Instant::now() + server.core.email.data_purge_frequency.time_to_next(), + Event::PurgeDataStore, + ); + queue.schedule( + Instant::now() + server.core.email.blob_purge_frequency.time_to_next(), + Event::PurgeBlobStore, + ); + + // Node ID lease renewal + if server.core.storage.coordinator.is_enabled() { + queue.schedule( + Instant::now() + server.registry().refresh_node_id_interval(), + Event::RenewNodeIdLease, + ); + } + + // Spam classifier training + if let Some(train_frequency) = server + .core + .spam + .classifier + .as_ref() + .and_then(|c| c.train_frequency) + { + let next_train = match server.inner.data.spam_classifier.load().as_ref() { + spamfilter::SpamClassifier::FhClassifier { + last_trained_at, .. + } + | spamfilter::SpamClassifier::CcfhClassifier { + last_trained_at, .. + } => now().saturating_sub(*last_trained_at).min(train_frequency), + spamfilter::SpamClassifier::Disabled => train_frequency, + }; + + queue.schedule( + Instant::now() + Duration::from_secs(next_train), + Event::TrainSpamClassifier, + ); + } + + // OTEL Push Metrics + if let Some(otel) = &server.core.metrics.otel { + OtelMetrics::enable_errors(); + queue.schedule(Instant::now() + otel.interval, Event::OtelMetrics); + } + + // Calculate expensive metrics + queue.schedule(Instant::now(), Event::CalculateMetrics); + + } + + + let mut next_metric_update = Instant::now(); + + loop { + tokio::time::sleep(queue.wake_up_time()).await; + + let server = inner.build_server(); + let roles = &server.core.network.roles; + let mut batch = (roles.task_scheduler).then(BatchBuilder::new); + + while let Some(event) = queue.pop() { + match event.event { + Event::PurgeAccount => { + queue.schedule( + Instant::now() + + server.core.email.account_purge_frequency.time_to_next(), + Event::PurgeAccount, + ); + + if let Some(batch) = batch.as_mut() { + trc::event!( + TaskManager(TaskManagerEvent::TaskQueued), + Type = TaskStoreMaintenanceType::PurgeAccounts.as_str() + ); + + batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeAccounts, + status: TaskStatus::now(), + shard_index: None, + })); + } + } + Event::PurgeDataStore => { + queue.schedule( + Instant::now() + server.core.email.data_purge_frequency.time_to_next(), + Event::PurgeDataStore, + ); + + if let Some(batch) = batch.as_mut() { + trc::event!( + TaskManager(TaskManagerEvent::TaskQueued), + Type = TaskStoreMaintenanceType::PurgeData.as_str() + ); + + batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeData, + status: TaskStatus::now(), + shard_index: None, + })); + } + } + Event::PurgeBlobStore => { + queue.schedule( + Instant::now() + server.core.email.blob_purge_frequency.time_to_next(), + Event::PurgeBlobStore, + ); + + if let Some(batch) = batch.as_mut() { + trc::event!( + TaskManager(TaskManagerEvent::TaskQueued), + Type = TaskStoreMaintenanceType::PurgeBlob.as_str() + ); + + batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeBlob, + status: TaskStatus::now(), + shard_index: None, + })); + } + } + Event::RenewNodeIdLease => { + queue.schedule( + Instant::now() + server.registry().refresh_node_id_interval(), + Event::RenewNodeIdLease, + ); + + trc::event!( + Cluster(ClusterEvent::NodeIdRenewed), + Id = server.registry().node_id() + ); + + let server = server.clone(); + tokio::spawn(async move { + if let Err(err) = server.registry().refresh_node_id_lease().await { + trc::error!(err.details("Failed to renew node ID lease")); + } + }); + } + Event::OtelMetrics => { + if let Some(otel) = &server.core.metrics.otel { + queue.schedule(Instant::now() + otel.interval, Event::OtelMetrics); + + if roles.metrics_push { + let otel = otel.clone(); + + + #[cfg(not(feature = "enterprise"))] + let is_enterprise = false; + + tokio::spawn(async move { + let elapsed = Instant::now(); + otel.push_metrics(is_enterprise, start_time).await; + + trc::event!( + Telemetry(TelemetryEvent::MetricsPushed), + Elapsed = elapsed.elapsed() + ); + }); + } + } + } + Event::CalculateMetrics => { + // Calculate expensive metrics every 5 minutes + queue.schedule( + Instant::now() + Duration::from_secs(5 * 60), + Event::CalculateMetrics, + ); + + let update_other_metrics = if Instant::now() >= next_metric_update { + next_metric_update = Instant::now() + Duration::from_secs(86400); + true + } else { + false + }; + + let server = server.clone(); + tokio::spawn(async move { + let elapsed = Instant::now(); + if server.core.network.roles.metrics_calculate { + + if update_other_metrics { + match server.total_accounts().await { + Ok(total) => { + Collector::update_gauge( + MetricType::UserCount, + total as u64, + ); + } + Err(err) => { + trc::error!( + err.details("Failed to obtain account count") + ); + } + } + + match server.total_domains().await { + Ok(total) => { + Collector::update_gauge( + MetricType::DomainCount, + total as u64, + ); + } + Err(err) => { + trc::error!( + err.details("Failed to obtain domain count") + ); + } + } + } + } + + match tokio::task::spawn_blocking(memory_stats::memory_stats).await { + Ok(Some(stats)) => { + Collector::update_gauge( + MetricType::ServerMemory, + stats.physical_mem as u64, + ); + } + Ok(None) => {} + Err(err) => { + trc::error!( + trc::EventType::Server(trc::ServerEvent::ThreadError,) + .reason(err) + .caused_by(trc::location!()) + .details("Join Error") + ); + } + } + + trc::event!( + Telemetry(TelemetryEvent::MetricsCollected), + Elapsed = elapsed.elapsed() + ); + }); + } + Event::TrainSpamClassifier => { + if let Some(train_frequency) = server + .core + .spam + .classifier + .as_ref() + .and_then(|c| c.train_frequency) + { + // Schedule next training + queue.schedule( + Instant::now() + Duration::from_secs(train_frequency), + Event::TrainSpamClassifier, + ); + + if let Some(batch) = batch.as_mut() { + trc::event!( + TaskManager(TaskManagerEvent::TaskQueued), + Type = TaskType::SpamFilterMaintenance.as_str() + ); + + batch.schedule_task(Task::SpamFilterMaintenance( + TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::Train, + status: TaskStatus::now(), + }, + )); + } + } + } + + } + } + + if let Some(mut batch) = batch + && !batch.is_empty() + && let Err(err) = server.store().write(batch.build_all()).await + { + trc::error!(err.details("Failed to write scheduled tasks")); + } + } + }); +} + +impl Queue { + pub fn schedule(&mut self, due: Instant, event: Event) { + trc::event!( + TaskManager(TaskManagerEvent::TaskScheduled), + Due = trc::Value::Timestamp( + now() + due.saturating_duration_since(Instant::now()).as_secs() + ), + Id = event.name() + ); + + self.heap.push(Action { due, event }); + } + + pub fn wake_up_time(&self) -> Duration { + self.heap + .peek() + .map(|e| e.due.saturating_duration_since(Instant::now())) + .unwrap_or(LONG_1D_SLUMBER) + } + + pub fn pop(&mut self) -> Option { + if self.heap.peek()?.due <= Instant::now() { + self.heap.pop() + } else { + None + } + } +} + +impl Ord for Action { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.due.cmp(&other.due).reverse() + } +} + +impl PartialOrd for Action { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Event { + fn name(&self) -> &'static str { + match self { + Event::PurgeAccount => "purgeAccount", + Event::PurgeDataStore => "purgeDataStore", + Event::PurgeBlobStore => "purgeBlobStore", + Event::OtelMetrics => "otelMetrics", + Event::CalculateMetrics => "calculateMetrics", + Event::TrainSpamClassifier => "trainSpamClassifier", + Event::RenewNodeIdLease => "renewNodeIdLease", + } + } +} diff --git a/crates/services/src/task_manager/spam_classifier.rs b/crates/services/src/task_manager/spam_classifier.rs new file mode 100644 index 0000000..502fb9b --- /dev/null +++ b/crates/services/src/task_manager/spam_classifier.rs @@ -0,0 +1,402 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::task_manager::{TaskFailureType, TaskResult}; +use common::{ + Server, + ipc::{BroadcastEvent, RegistryChange}, + manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource}, +}; +use registry::{ + schema::{ + enums::TaskSpamFilterMaintenanceType, + prelude::ObjectType, + structs::{ + HttpLookup, MemoryLookupKey, SpamDnsblServer, SpamFileExtension, SpamRule, SpamTag, + TaskSpamFilterMaintenance, + }, + }, + types::EnumImpl, +}; +use spam_filter::modules::classifier::SpamClassifier; +use std::time::{Duration, Instant}; +use store::{ + ahash::AHashMap, + registry::write::{RegistryWrite, RegistryWriteResult}, +}; +use trc::{SpamEvent, Value}; + +pub(crate) trait SpamFilterMaintenanceTask: Sync + Send { + fn spam_filter_maintenance( + &self, + task: &TaskSpamFilterMaintenance, + ) -> impl Future + Send; +} + +impl SpamFilterMaintenanceTask for Server { + async fn spam_filter_maintenance(&self, task: &TaskSpamFilterMaintenance) -> TaskResult { + match spam_filter_maintenance(self, task).await { + Ok(result) => result, + Err(err) => { + let result = TaskResult::temporary(err.to_string()); + trc::error!(err.details("Failed to perform spam filter maintenance task")); + result + } + } + } +} + +async fn spam_filter_maintenance( + server: &Server, + task: &TaskSpamFilterMaintenance, +) -> trc::Result { + match task.maintenance_type { + TaskSpamFilterMaintenanceType::Train => { + if !server.inner.ipc.train_task_controller.is_running() { + Box::pin(server.spam_train(false)).await?; + } + } + TaskSpamFilterMaintenanceType::Retrain => { + if !server.inner.ipc.train_task_controller.is_running() { + Box::pin(server.spam_train(true)).await?; + } + } + TaskSpamFilterMaintenanceType::Reset => { + for key in [SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY] { + server.blob_store().delete_blob(key).await?; + } + } + TaskSpamFilterMaintenanceType::Abort => { + if server.inner.ipc.train_task_controller.is_running() { + server.inner.ipc.train_task_controller.stop(); + } + } + TaskSpamFilterMaintenanceType::UpdateRules => { + return update_spam_rules(server).await; + } + } + + Ok(TaskResult::Success(vec![])) +} + +struct RuleUpdateError { + typ: TaskFailureType, + reason: String, +} + +#[derive(Default)] +struct Rules { + rules: Vec, + dnsbls: Vec, + tags: Vec, + http_lookups: Vec, + key_lookups: Vec, + file_exts: Vec, +} + +#[derive(Default)] +struct RuleUpdateResult { + success: usize, + already_exists: usize, + failed: usize, +} + +async fn update_spam_rules(server: &Server) -> trc::Result { + let started = Instant::now(); + let rules = match fetch_spam_rules(server).await { + Ok(rules) => rules, + Err(err) => { + return Ok(TaskResult::Failure { + typ: err.typ, + message: err.reason, + max_attempts: None, + }); + } + }; + + let registry = server.registry(); + let mut stats: AHashMap = AHashMap::new(); + + let mut reload_settings = false; + let mut reload_lookups = false; + + for rule in rules.rules { + match registry.write(RegistryWrite::insert(&rule.into())).await? { + RegistryWriteResult::Success(_) => { + stats.entry(ObjectType::SpamRule).or_default().success += 1; + reload_settings = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats + .entry(ObjectType::SpamRule) + .or_default() + .already_exists += 1; + } + _ => { + stats.entry(ObjectType::SpamRule).or_default().failed += 1; + } + } + } + + for dnsbl in rules.dnsbls { + match registry.write(RegistryWrite::insert(&dnsbl.into())).await? { + RegistryWriteResult::Success(_) => { + stats + .entry(ObjectType::SpamDnsblServer) + .or_default() + .success += 1; + reload_settings = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats + .entry(ObjectType::SpamDnsblServer) + .or_default() + .already_exists += 1; + } + _ => { + stats.entry(ObjectType::SpamDnsblServer).or_default().failed += 1; + } + } + } + + for tag in rules.tags { + match registry.write(RegistryWrite::insert(&tag.into())).await? { + RegistryWriteResult::Success(_) => { + stats.entry(ObjectType::SpamTag).or_default().success += 1; + reload_settings = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats.entry(ObjectType::SpamTag).or_default().already_exists += 1; + } + _ => { + stats.entry(ObjectType::SpamTag).or_default().failed += 1; + } + } + } + + for lookup in rules.http_lookups { + match registry + .write(RegistryWrite::insert(&lookup.into())) + .await? + { + RegistryWriteResult::Success(_) => { + stats.entry(ObjectType::HttpLookup).or_default().success += 1; + reload_lookups = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats + .entry(ObjectType::HttpLookup) + .or_default() + .already_exists += 1; + } + _ => { + stats.entry(ObjectType::HttpLookup).or_default().failed += 1; + } + } + } + + for key_lookup in rules.key_lookups { + match registry + .write(RegistryWrite::insert(&key_lookup.into())) + .await? + { + RegistryWriteResult::Success(_) => { + stats + .entry(ObjectType::MemoryLookupKey) + .or_default() + .success += 1; + reload_lookups = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats + .entry(ObjectType::MemoryLookupKey) + .or_default() + .already_exists += 1; + } + _ => { + stats.entry(ObjectType::MemoryLookupKey).or_default().failed += 1; + } + } + } + + for ext in rules.file_exts { + match registry.write(RegistryWrite::insert(&ext.into())).await? { + RegistryWriteResult::Success(_) => { + stats + .entry(ObjectType::SpamFileExtension) + .or_default() + .success += 1; + reload_settings = true; + } + RegistryWriteResult::PrimaryKeyConflict { .. } => { + stats + .entry(ObjectType::SpamFileExtension) + .or_default() + .already_exists += 1; + } + _ => { + stats + .entry(ObjectType::SpamFileExtension) + .or_default() + .failed += 1; + } + } + } + + if reload_settings { + if let Err(err) = + Box::pin(server.reload_registry(RegistryChange::Reload(ObjectType::SpamRule))).await + { + trc::error!(err.details("Failed to reload registry after updating spam rules")); + } + server + .cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload( + ObjectType::SpamRule, + ))) + .await; + } + + if reload_lookups { + if let Err(err) = + Box::pin(server.reload_registry(RegistryChange::Reload(ObjectType::MemoryLookupKey))) + .await + { + trc::error!(err.details("Failed to reload registry after updating spam rules")); + } + server + .cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload( + ObjectType::MemoryLookupKey, + ))) + .await; + } + + trc::event!( + Spam(SpamEvent::RulesUpdated), + Details = stats + .into_iter() + .map(|(object_type, result)| { + Value::Array(vec![ + Value::String(object_type.as_str().into()), + Value::from(result.success), + Value::from(result.already_exists), + Value::from(result.failed), + ]) + }) + .collect::>(), + Elapsed = started.elapsed(), + ); + + Ok(TaskResult::Success(vec![])) +} + +async fn fetch_spam_rules(server: &Server) -> Result { + let Some(rules_url) = server.core.spam.spam_rules_url.as_ref() else { + return Err(RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: "Spam rules resource URL not configured".to_string(), + }); + }; + let rules_json: AHashMap> = + fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500) + .await + .map_err(|reason| RuleUpdateError { + typ: TaskFailureType::Temporary, + reason, + }) + .and_then(|bytes| { + serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse spam rules JSON: {err}"), + }) + })?; + + let mut rules = Rules::default(); + for (object_type, values) in rules_json { + let Some(object_type) = ObjectType::parse(&object_type) else { + return Err(RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Invalid object type in spam rules JSON: {object_type}"), + }); + }; + + match object_type { + ObjectType::SpamRule => { + rules.rules = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse spam rule: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + ObjectType::SpamDnsblServer => { + rules.dnsbls = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse DNSBL server: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + ObjectType::SpamTag => { + rules.tags = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse spam tag: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + ObjectType::HttpLookup => { + rules.http_lookups = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse HTTP lookup: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + ObjectType::MemoryLookupKey => { + rules.key_lookups = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse memory lookup key: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + ObjectType::SpamFileExtension => { + rules.file_exts = values + .into_iter() + .map(|value| { + serde_json::from_value(value).map_err(|err| RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Failed to parse spam file extension: {err}"), + }) + }) + .collect::, RuleUpdateError>>()?; + } + _ => { + return Err(RuleUpdateError { + typ: TaskFailureType::Permanent, + reason: format!("Unsupported object type in spam rules: {object_type:?}"), + }); + } + } + } + + Ok(rules) +} diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml new file mode 100644 index 0000000..2bdd6b7 --- /dev/null +++ b/crates/smtp/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "smtp" +description = "Stalwart SMTP Server" +authors = [ "Stalwart Labs LLC "] +repository = "https://github.com/stalwartlabs/smtp-server" +homepage = "https://stalw.art/smtp" +keywords = ["smtp", "email", "mail", "server"] +categories = ["email"] +license = "AGPL-3.0-only OR LicenseRef-SEL" +version = "0.16.22" +edition = "2024" + +[dependencies] +store = { path = "../store" } +types = { path = "../types" } +utils = { path = "../utils" } +directory = { path = "../directory" } +common = { path = "../common" } +email = { path = "../email" } +registry = { path = "../registry" } +spam-filter = { path = "../spam-filter" } +trc = { path = "../trc" } +mail-auth = { version = "0.13", features = ["rkyv"] } +mail-parser = { version = "0.11", features = ["full_encoding"] } +mail-builder = { version = "1.0" } +smtp-proto = { version = "0.2.3", features = ["rkyv", "serde"] } +sieve-rs = { version = "0.7", features = ["rkyv"] } +ahash = { version = "0.8" } +rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +rustls-pki-types = { version = "1", features = ["std"] } +rustls-webpki = { version = "0.103", default-features = false, features = ["std", "aws-lc-rs"] } +tokio = { version = "1.53", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +sha2 = "0.11" +parking_lot = "0.12" +blake3 = "1.8" +rand = "0.10.2" +x509-parser = { version = "0.18", features = ["verify-aws"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] } +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = "0.10.0" +hashify = { version = "0.2" } +base64 = "0.23" + +[features] +test_mode = ["mail-auth/test"] +enterprise = [] + +#[[bench]] +#name = "hash" +#harness = false + +[lints] +workspace = true diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs new file mode 100644 index 0000000..63b301a --- /dev/null +++ b/crates/smtp/src/core/mod.rs @@ -0,0 +1,315 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{inbound::auth::SaslToken, queue::QueueId}; +use common::{ + Inner, Server, + auth::AccountInfo, + config::smtp::auth::VerifyStrategy, + network::{ServerInstance, asn::AsnGeoLookupResult}, +}; +use mail_auth::{IprevOutput, SpfOutput}; +use smtp_proto::request::receiver::{ + BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver, +}; +use std::{ + hash::Hash, + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncRead, AsyncWrite}; +use utils::DomainPart; + +pub mod params; +pub mod throttle; + +#[derive(Clone)] +pub struct SmtpSessionManager { + pub inner: Arc, +} + +impl SmtpSessionManager { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +pub enum State { + Request(RequestReceiver), + Bdat(BdatReceiver), + Data(DataReceiver), + Sasl(LineReceiver), + SkipData(DummyDataReceiver, &'static [u8]), + RequestTooLarge(DummyLineReceiver), + Accepted(QueueId), + None, +} + +pub struct Session { + pub hostname: String, + pub state: State, + pub instance: Arc, + pub server: Server, + pub stream: T, + pub data: SessionData, + pub params: SessionParameters, +} + +pub struct SessionData { + pub session_id: u64, + pub local_ip: IpAddr, + pub local_ip_str: String, + pub local_port: u16, + pub remote_ip: IpAddr, + pub remote_ip_str: String, + pub remote_port: u16, + pub asn_geo_data: AsnGeoLookupResult, + pub helo_domain: String, + + pub mail_from: Option, + pub rcpt_to: Vec, + pub rcpt_errors: usize, + pub rcpt_oks: usize, + pub message: Vec, + + pub authenticated_as: Option, + pub auth_errors: usize, + + pub priority: i16, + pub delivery_by: i64, + pub future_release: u64, + + pub valid_until: Instant, + pub bytes_left: usize, + pub messages_sent: usize, + + pub iprev: Option, + pub spf_ehlo: Option, + pub spf_mail_from: Option, + pub dnsbl_error: Option>, +} + +#[derive(Clone, Debug)] +pub struct SessionAddress { + pub address: String, + pub address_lcase: String, + pub domain: String, + pub flags: u64, + pub dsn_info: Option, +} + +#[derive(Debug, Default)] +pub struct SessionParameters { + // Global parameters + pub timeout: Duration, + + // Ehlo parameters + pub ehlo_require: bool, + pub ehlo_reject_non_fqdn: bool, + + // Auth parameters + pub auth_require: bool, + pub auth_errors_max: usize, + pub auth_errors_wait: Duration, + + // Rcpt parameters + pub rcpt_errors_max: usize, + pub rcpt_errors_wait: Duration, + pub rcpt_max: usize, + pub rcpt_dsn: bool, + pub can_expn: bool, + pub can_vrfy: bool, + pub max_message_size: usize, + + // Mail authentication parameters + pub iprev: VerifyStrategy, + pub spf_ehlo: VerifyStrategy, + pub spf_mail_from: VerifyStrategy, +} + +impl SessionData { + pub fn new( + local_ip: IpAddr, + local_port: u16, + remote_ip: IpAddr, + remote_port: u16, + asn_geo_data: AsnGeoLookupResult, + session_id: u64, + ) -> Self { + SessionData { + session_id, + local_ip, + local_port, + remote_ip, + local_ip_str: local_ip.to_string(), + remote_ip_str: remote_ip.to_string(), + remote_port, + asn_geo_data, + helo_domain: String::new(), + mail_from: None, + rcpt_to: Vec::new(), + authenticated_as: None, + priority: 0, + valid_until: Instant::now(), + rcpt_errors: 0, + rcpt_oks: 0, + message: Vec::with_capacity(0), + auth_errors: 0, + messages_sent: 0, + bytes_left: 0, + delivery_by: 0, + future_release: 0, + iprev: None, + spf_ehlo: None, + spf_mail_from: None, + dnsbl_error: None, + } + } +} + +impl Default for State { + fn default() -> Self { + State::Request(RequestReceiver::default()) + } +} + +impl PartialEq for SessionAddress { + fn eq(&self, other: &Self) -> bool { + self.address_lcase == other.address_lcase + } +} + +impl Eq for SessionAddress {} + +impl Hash for SessionAddress { + fn hash(&self, state: &mut H) { + self.address_lcase.hash(state); + } +} + +impl Ord for SessionAddress { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.domain.cmp(&other.domain) { + std::cmp::Ordering::Equal => self.address_lcase.cmp(&other.address_lcase), + order => order, + } + } +} + +impl PartialOrd for SessionAddress { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Session { + pub fn local( + server: Server, + instance: std::sync::Arc, + data: SessionData, + ) -> Self { + Session { + hostname: "localhost".into(), + state: State::None, + instance, + server, + stream: common::network::stream::NullIo::default(), + data, + params: SessionParameters { + timeout: Default::default(), + ehlo_require: Default::default(), + ehlo_reject_non_fqdn: Default::default(), + auth_require: Default::default(), + auth_errors_max: Default::default(), + auth_errors_wait: Default::default(), + rcpt_errors_max: Default::default(), + rcpt_errors_wait: Default::default(), + rcpt_max: Default::default(), + rcpt_dsn: Default::default(), + max_message_size: Default::default(), + iprev: VerifyStrategy::Disable, + spf_ehlo: VerifyStrategy::Disable, + spf_mail_from: VerifyStrategy::Disable, + can_expn: false, + can_vrfy: false, + }, + } + } + + pub fn has_failed(&mut self) -> Option { + if self.stream.tx_buf.first().is_none_or(|&c| c == b'2') { + self.stream.tx_buf.clear(); + None + } else { + let response = std::str::from_utf8(&self.stream.tx_buf) + .unwrap() + .trim() + .into(); + self.stream.tx_buf.clear(); + Some(response) + } + } +} + +impl SessionData { + pub fn local( + authenticated_as: AccountInfo, + mail_from: Option, + rcpt_to: Vec, + message: Vec, + session_id: u64, + ) -> Self { + SessionData { + local_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)), + remote_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)), + local_ip_str: "127.0.0.1".into(), + remote_ip_str: "127.0.0.1".into(), + remote_port: 0, + local_port: 0, + session_id, + asn_geo_data: AsnGeoLookupResult::default(), + helo_domain: "localhost".into(), + mail_from, + rcpt_to, + rcpt_errors: 0, + rcpt_oks: 0, + message, + authenticated_as: Some(authenticated_as), + auth_errors: 0, + priority: 0, + delivery_by: 0, + future_release: 0, + valid_until: Instant::now(), + bytes_left: 0, + messages_sent: 0, + iprev: None, + spf_ehlo: None, + spf_mail_from: None, + dnsbl_error: None, + } + } +} + +impl SessionAddress { + pub fn new(address: String) -> Self { + let address_lcase = address.to_lowercase(); + SessionAddress { + domain: address_lcase.domain_part().into(), + address_lcase, + address, + flags: 0, + dsn_info: None, + } + } + + pub fn report_address(&self) -> &str { + self.dsn_info + .as_ref() + .and_then(|v| v.strip_prefix("rfc822;")) + .unwrap_or(&self.address_lcase) + } +} diff --git a/crates/smtp/src/core/params.rs b/crates/smtp/src/core/params.rs new file mode 100644 index 0000000..5e15f3f --- /dev/null +++ b/crates/smtp/src/core/params.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Session; +use common::{config::smtp::auth::VerifyStrategy, network::SessionStream}; +use std::time::Duration; + +impl Session { + pub async fn eval_session_params(&mut self) { + let c = &self.server.core.smtp.session; + self.data.bytes_left = self + .server + .eval_if(&c.transfer_limit, self, self.data.session_id) + .await + .unwrap_or(250 * 1024 * 1024); + self.data.valid_until += self + .server + .eval_if(&c.duration, self, self.data.session_id) + .await + .unwrap_or_else(|| Duration::from_secs(15 * 60)); + + self.params.timeout = self + .server + .eval_if(&c.timeout, self, self.data.session_id) + .await + .unwrap_or_else(|| Duration::from_secs(5 * 60)); + self.params.spf_ehlo = self + .server + .eval_if( + &self.server.core.smtp.mail_auth.spf.verify_ehlo, + self, + self.data.session_id, + ) + .await + .unwrap_or(VerifyStrategy::Relaxed); + self.params.spf_mail_from = self + .server + .eval_if( + &self.server.core.smtp.mail_auth.spf.verify_mail_from, + self, + self.data.session_id, + ) + .await + .unwrap_or(VerifyStrategy::Relaxed); + self.params.iprev = self + .server + .eval_if( + &self.server.core.smtp.mail_auth.iprev.verify, + self, + self.data.session_id, + ) + .await + .unwrap_or(VerifyStrategy::Relaxed); + + // Ehlo parameters + let ec = &self.server.core.smtp.session.ehlo; + self.params.ehlo_require = self + .server + .eval_if(&ec.require, self, self.data.session_id) + .await + .unwrap_or(true); + self.params.ehlo_reject_non_fqdn = self + .server + .eval_if(&ec.reject_non_fqdn, self, self.data.session_id) + .await + .unwrap_or(true); + + // Auth parameters + let ac = &self.server.core.smtp.session.auth; + self.params.auth_require = self + .server + .eval_if(&ac.require, self, self.data.session_id) + .await + .unwrap_or(false); + self.params.auth_errors_max = self + .server + .eval_if(&ac.errors_max, self, self.data.session_id) + .await + .unwrap_or(3); + self.params.auth_errors_wait = self + .server + .eval_if(&ac.errors_wait, self, self.data.session_id) + .await + .unwrap_or_else(|| Duration::from_secs(30)); + + // VRFY/EXPN parameters + let ec = &self.server.core.smtp.session.extensions; + self.params.can_expn = self + .server + .eval_if(&ec.expn, self, self.data.session_id) + .await + .unwrap_or(false); + self.params.can_vrfy = self + .server + .eval_if(&ec.vrfy, self, self.data.session_id) + .await + .unwrap_or(false); + } + + pub async fn eval_post_auth_params(&mut self) { + // Refresh VRFY/EXPN parameters + let ec = &self.server.core.smtp.session.extensions; + self.params.can_expn = self + .server + .eval_if(&ec.expn, self, self.data.session_id) + .await + .unwrap_or(false); + self.params.can_vrfy = self + .server + .eval_if(&ec.vrfy, self, self.data.session_id) + .await + .unwrap_or(false); + } + + pub async fn eval_rcpt_params(&mut self) { + let rc = &self.server.core.smtp.session.rcpt; + self.params.rcpt_errors_max = self + .server + .eval_if(&rc.errors_max, self, self.data.session_id) + .await + .unwrap_or(10); + self.params.rcpt_errors_wait = self + .server + .eval_if(&rc.errors_wait, self, self.data.session_id) + .await + .unwrap_or_else(|| Duration::from_secs(30)); + self.params.rcpt_max = self + .server + .eval_if(&rc.max_recipients, self, self.data.session_id) + .await + .unwrap_or(100); + self.params.rcpt_dsn = self + .server + .eval_if( + &self.server.core.smtp.session.extensions.dsn, + self, + self.data.session_id, + ) + .await + .unwrap_or(true); + + self.params.max_message_size = match self + .server + .eval_if::( + &self.server.core.smtp.session.data.max_message_size, + self, + self.data.session_id, + ) + .await + { + Some(0) => usize::MAX, + Some(max_message_size) => max_message_size, + None => 25 * 1024 * 1024, + }; + } +} diff --git a/crates/smtp/src/core/throttle.rs b/crates/smtp/src/core/throttle.rs new file mode 100644 index 0000000..549eccd --- /dev/null +++ b/crates/smtp/src/core/throttle.rs @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Session; +use common::{ + KV_RATE_LIMIT_SMTP, ThrottleKey, config::smtp::*, expr::functions::ResolveVariable, + network::SessionStream, +}; +use queue::QueueQuota; +use registry::schema::{enums::ExpressionVariable, structs::Rate}; +use trc::SmtpEvent; + +pub trait NewKey: Sized { + fn new_key(&self, e: &impl ResolveVariable, context: &str) -> ThrottleKey; +} + +impl NewKey for QueueQuota { + fn new_key(&self, e: &impl ResolveVariable, _: &str) -> ThrottleKey { + let mut hasher = blake3::Hasher::new(); + + if (self.keys & THROTTLE_RCPT) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::Rcpt) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::RcptDomain) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_SENDER) != 0 { + let sender = e.resolve_variable(ExpressionVariable::Sender).into_string(); + hasher.update( + if !sender.is_empty() { + sender.as_ref() + } else { + "<>" + } + .as_bytes(), + ); + } + if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 { + let sender_domain = e + .resolve_variable(ExpressionVariable::SenderDomain) + .into_string(); + hasher.update( + if !sender_domain.is_empty() { + sender_domain.as_ref() + } else { + "<>" + } + .as_bytes(), + ); + } + + if let Some(messages) = &self.messages { + hasher.update(&messages.to_ne_bytes()[..]); + } + + if let Some(size) = &self.size { + hasher.update(&size.to_ne_bytes()[..]); + } + + ThrottleKey { + hash: hasher.finalize().into(), + } + } +} + +impl NewKey for QueueRateLimiter { + fn new_key(&self, e: &impl ResolveVariable, context: &str) -> ThrottleKey { + let mut hasher = blake3::Hasher::new(); + + if (self.keys & THROTTLE_RCPT) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::Rcpt) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::RcptDomain) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_SENDER) != 0 { + let sender = e.resolve_variable(ExpressionVariable::Sender).into_string(); + hasher.update( + if !sender.is_empty() { + sender.as_ref() + } else { + "<>" + } + .as_bytes(), + ); + } + if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 { + let sender_domain = e + .resolve_variable(ExpressionVariable::SenderDomain) + .into_string(); + hasher.update( + if !sender_domain.is_empty() { + sender_domain.as_ref() + } else { + "<>" + } + .as_bytes(), + ); + } + if (self.keys & THROTTLE_HELO_DOMAIN) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::HeloDomain) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_AUTH_AS) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::AuthenticatedAs) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_LISTENER) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::Listener) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_MX) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::Mx) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_REMOTE_IP) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::RemoteIp) + .to_string() + .as_bytes(), + ); + } + if (self.keys & THROTTLE_LOCAL_IP) != 0 { + hasher.update( + e.resolve_variable(ExpressionVariable::LocalIp) + .to_string() + .as_bytes(), + ); + } + hasher.update(&self.rate.period.as_secs().to_be_bytes()[..]); + hasher.update(&self.rate.count.to_be_bytes()[..]); + hasher.update(context.as_bytes()); + + ThrottleKey { + hash: hasher.finalize().into(), + } + } +} + +impl Session { + pub async fn is_allowed(&mut self) -> bool { + let throttles = if !self.data.rcpt_to.is_empty() { + &self.server.core.smtp.queue.inbound_limiters.rcpt + } else if self.data.mail_from.is_some() { + &self.server.core.smtp.queue.inbound_limiters.sender + } else { + &self.server.core.smtp.queue.inbound_limiters.remote + }; + + for t in throttles { + if t.expr.is_empty() + || self + .server + .eval_if(&t.expr, self, self.data.session_id) + .await + .unwrap_or(false) + { + if (t.keys & THROTTLE_RCPT_DOMAIN) != 0 { + let d = self + .data + .rcpt_to + .last() + .map(|r| r.domain.as_str()) + .unwrap_or_default(); + + if self.data.rcpt_to.iter().filter(|p| p.domain == d).count() > 1 { + continue; + } + } + + // Build throttle key + let key = t.new_key(self, "inbound"); + + // Check rate + match self + .server + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_SMTP, key.hash.as_slice(), &t.rate, false) + .await + { + Ok(Some(_)) => { + trc::event!( + Smtp(SmtpEvent::RateLimitExceeded), + SpanId = self.data.session_id, + Id = t.id.to_string(), + Limit = vec![ + trc::Value::from(t.rate.count), + trc::Value::from(t.rate.period.into_inner()) + ], + ); + + return false; + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + ); + } + _ => (), + } + } + } + + true + } + + pub async fn throttle_rcpt(&self, rcpt: &str, rate: &Rate, ctx: &str) -> bool { + let mut hasher = blake3::Hasher::new(); + hasher.update(rcpt.as_bytes()); + hasher.update(ctx.as_bytes()); + hasher.update(&rate.period.as_secs().to_ne_bytes()[..]); + hasher.update(&rate.count.to_ne_bytes()[..]); + + match self + .server + .in_memory_store() + .is_rate_allowed( + KV_RATE_LIMIT_SMTP, + hasher.finalize().as_bytes(), + rate, + false, + ) + .await + { + Ok(None) => true, + Ok(Some(_)) => false, + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + ); + true + } + } + } +} diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs new file mode 100644 index 0000000..044cf3f --- /dev/null +++ b/crates/smtp/src/inbound/auth.rs @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::{auth::AuthRequest, network::SessionStream}; +use directory::Credentials; +use mail_parser::decoders::base64::base64_decode; +use registry::schema::enums::Permission; +use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString}; +use trc::AuthEvent; + +pub struct SaslToken { + mechanism: u64, + credentials: Credentials, +} + +impl SaslToken { + pub fn from_mechanism(mechanism: u64) -> Option { + match mechanism { + AUTH_PLAIN | AUTH_LOGIN => SaslToken { + mechanism, + credentials: Credentials::Basic { + username: String::new(), + secret: String::new(), + mfa_token: None, + }, + } + .into(), + AUTH_OAUTHBEARER | AUTH_XOAUTH2 => SaslToken { + mechanism, + credentials: Credentials::Bearer { + username: None, + token: String::new(), + }, + } + .into(), + _ => None, + } + } +} + +impl Session { + pub async fn handle_sasl_response( + &mut self, + token: &mut SaslToken, + response: &[u8], + ) -> Result { + if response.is_empty() { + match (token.mechanism, &token.credentials) { + (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER, _) => { + self.write(b"334 \r\n").await?; + return Ok(true); + } + ( + AUTH_LOGIN, + Credentials::Basic { + username, secret, .. + }, + ) if username.is_empty() && secret.is_empty() => { + self.write(b"334 VXNlcm5hbWU6\r\n").await?; + return Ok(true); + } + _ => (), + } + } else if let Some(response) = base64_decode(response) { + match (token.mechanism, &mut token.credentials) { + (AUTH_PLAIN, _) => { + if let Some(credentials) = Credentials::decode_sasl_challenge_plain(&response) { + return self.authenticate(credentials).await; + } + } + ( + AUTH_LOGIN, + Credentials::Basic { + username, secret, .. + }, + ) => { + return if username.is_empty() { + *username = response.into_string(); + self.write(b"334 UGFzc3dvcmQ6\r\n").await?; + Ok(true) + } else { + *secret = response.into_string(); + self.authenticate(std::mem::replace( + &mut token.credentials, + Credentials::Basic { + username: String::new(), + secret: String::new(), + mfa_token: None, + }, + )) + .await + }; + } + (AUTH_OAUTHBEARER | AUTH_XOAUTH2, _) => { + if let Some(credentials) = Credentials::decode_sasl_challenge_oauth(&response) { + return self.authenticate(credentials).await; + } + } + _ => (), + } + } + + self.auth_error(b"500 5.5.6 Invalid challenge.\r\n").await + } + + pub async fn authenticate(&mut self, credentials: Credentials) -> Result { + // Authenticate + let result = self + .server + .authenticate(&AuthRequest::from_credentials( + credentials, + self.data.session_id, + self.data.remote_ip, + )) + .await + .and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend)); + + let result = match result { + Ok(access_token) => self.server.account_info(access_token.account_id()).await, + Err(err) => Err(err), + }; + + match result { + Ok(account_info) => { + self.data.authenticated_as = account_info.into(); + self.eval_post_auth_params().await; + self.write(b"235 2.7.0 Authentication succeeded.\r\n") + .await?; + return Ok(false); + } + Err(err) => { + let reason = *err.as_ref(); + + trc::error!(err.span_id(self.data.session_id)); + + match reason { + trc::EventType::Auth(trc::AuthEvent::Failed) => { + return self + .auth_error(b"535 5.7.8 Authentication credentials invalid.\r\n") + .await; + } + trc::EventType::Auth(trc::AuthEvent::TokenExpired) => { + return self.auth_error(b"535 5.7.8 OAuth token expired.\r\n").await; + } + trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { + return self + .auth_error( + concat!( + "334 5.7.8 This account requires multi-factor authentication. ", + "Alternatively, you can use an app password if your account has one.\r\n" + ) + .as_bytes(), + ) + .await; + } + trc::EventType::Security(trc::SecurityEvent::Unauthorized) => { + self.write( + concat!( + "550 5.7.1 Your account is not authorized ", + "to use this service.\r\n" + ) + .as_bytes(), + ) + .await?; + return Ok(false); + } + trc::EventType::Security(_) => { + return Err(()); + } + _ => (), + } + } + } + + self.write(b"454 4.7.0 Temporary authentication failure\r\n") + .await?; + + Ok(false) + } + + pub async fn auth_error(&mut self, response: &[u8]) -> Result { + tokio::time::sleep(self.params.auth_errors_wait).await; + self.data.auth_errors += 1; + self.write(response).await?; + if self.data.auth_errors < self.params.auth_errors_max { + Ok(false) + } else { + trc::event!( + Auth(AuthEvent::TooManyAttempts), + SpanId = self.data.session_id, + ); + + self.write(b"455 4.3.0 Too many authentication errors, disconnecting.\r\n") + .await?; + Err(()) + } + } + + pub fn authenticated_as(&self) -> Option<&str> { + self.data + .authenticated_as + .as_ref() + .map(|authenticated_as| authenticated_as.name()) + } + + pub fn is_authenticated(&self) -> bool { + self.data.authenticated_as.is_some() + } + + pub fn authenticated_emails(&self) -> &[String] { + self.data.authenticated_as.as_ref().unwrap().addresses() + } +} diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs new file mode 100644 index 0000000..d9198c3 --- /dev/null +++ b/crates/smtp/src/inbound/data.rs @@ -0,0 +1,1111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::AuthResult; +use crate::{ + core::{Session, SessionAddress, State}, + inbound::{dkim::DkimSign, milter::Modification}, + queue::{ + self, Message, MessageSource, MessageWrapper, QueueEnvelope, RCPT_SPAM_MASK, + quota::HasQueueQuota, rcpt_spam_flag, spool::QueueParams, + }, + reporting::analysis::{AnalyzeReport, ReportData}, + scripts::ScriptResult, +}; +use common::{ + config::{ + mailstore::spamfilter::{SpamFilterAction, spam_status}, + smtp::{ + auth::VerifyStrategy, + queue::{QueueExpiry, QueueName}, + session::Stage, + }, + }, + network::SessionStream, + scripts::ScriptModification, +}; +use mail_auth::{ + AuthenticatedMessage, AuthenticationResults, Dkim2Result, DkimResult, DmarcResult, ReceivedSpf, + SpfOutput, SpfResult, + common::{ + crypto::Algorithm, + headers::{Header, HeaderWriter}, + verify::VerifySignature, + }, + dkim::DkimError, + dkim2::{Dkim2Dsn, Dkim2DsnFailure, Envelope as Dkim2Envelope}, + dmarc::{self, verify::DmarcParameters}, +}; +use mail_builder::headers::{date::Date, message_id::generate_message_id_header}; +use mail_parser::{MessageParser, MimeHeaders, parsers::fields::thread::thread_name}; +use registry::schema::structs::Rate; +use sieve::runtime::Variable; +use smtp_proto::{ + MAIL_BY_RETURN, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, +}; +use std::{ + borrow::Cow, + time::{Instant, SystemTime}, +}; +use trc::{SmtpEvent, SpamEvent}; +use utils::DomainPart; + +impl Session { + pub async fn queue_message(&mut self) -> Cow<'static, [u8]> { + // Parse message + let raw_message = std::mem::take(&mut self.data.message); + let parsed_message = match MessageParser::new() + .parse(&raw_message) + .filter(|p| p.headers().iter().any(|h| !h.name.is_other())) + { + Some(parsed_message) => parsed_message, + None => { + trc::event!( + Smtp(SmtpEvent::MessageParseFailed), + SpanId = self.data.session_id, + ); + + return (&b"550 5.7.7 Failed to parse message.\r\n"[..]).into(); + } + }; + + // Authenticate message + let mut auth_message = AuthenticatedMessage::from_parsed( + &parsed_message, + &raw_message, + self.server.core.smtp.mail_auth.dkim.strict, + ); + let has_date_header = auth_message.has_date_header(); + let has_message_id_header = auth_message.has_message_id_header(); + + // Loop detection + let dc = &self.server.core.smtp.session.data; + let ac = &self.server.core.smtp.mail_auth; + let rc = &self.server.core.smtp.report; + if auth_message.received_headers_count() + > self + .server + .eval_if(&dc.max_received_headers, self, self.data.session_id) + .await + .unwrap_or(50) + { + trc::event!( + Smtp(SmtpEvent::LoopDetected), + SpanId = self.data.session_id, + Total = auth_message.received_headers_count(), + ); + + return (&b"450 4.4.6 Too many Received headers. Possible loop detected.\r\n"[..]) + .into(); + } + + // Verify DKIM + let dkim = self + .server + .eval_if(&ac.dkim.verify, self, self.data.session_id) + .await + .unwrap_or(VerifyStrategy::Relaxed); + let dmarc = self + .server + .eval_if(&ac.dmarc.verify, self, self.data.session_id) + .await + .unwrap_or(VerifyStrategy::Relaxed); + let dkim_output = if dkim.verify() || dmarc.verify() { + // Remove insecure DKIM signatures before verification + auth_message.dkim_headers.retain(|header| { + let signature = &header.header; + if signature.algorithm() == Algorithm::RsaSha1 + || (signature.algorithm() == Algorithm::RsaSha256 && signature.b.len() < 128) + { + auth_message.errors.push(Header { + name: header.name, + value: header.value, + header: mail_auth::Error::Dkim(DkimError::UnsupportedAlgorithm), + }); + auth_message.has_dkim_errors = true; + false + } else { + true + } + }); + + let time = Instant::now(); + let dkim_output = self + .server + .core + .smtp + .resolvers + .dns + .verify_dkim(self.server.inner.cache.build_auth_parameters(&auth_message)) + .await; + let pass = dkim_output + .iter() + .any(|d| matches!(d.result(), DkimResult::Pass)); + let strict = dkim.is_strict(); + let rejected = strict && !pass; + + // Send reports for failed signatures + if let Some(rate) = self + .server + .eval_if::(&rc.dkim.send, self, self.data.session_id) + .await + { + for output in &dkim_output { + if let Some(rcpt) = output.failure_report_addr() { + self.send_dkim_report(rcpt, &auth_message, &rate, rejected, output) + .await; + } + } + } + + trc::event!( + Smtp(if pass { + SmtpEvent::DkimPass + } else { + SmtpEvent::DkimFail + }), + SpanId = self.data.session_id, + Strict = strict, + Result = dkim_output.iter().map(trc::Error::from).collect::>(), + Elapsed = time.elapsed(), + ); + + if rejected { + // 'Strict' mode violates the advice of Section 6.1 of RFC6376 + return if dkim_output + .iter() + .any(|d| matches!(d.result(), DkimResult::TempError(_))) + { + (&b"451 4.7.20 No passing DKIM signatures found.\r\n"[..]).into() + } else { + (&b"550 5.7.20 No passing DKIM signatures found.\r\n"[..]).into() + }; + } + + dkim_output + } else { + vec![] + }; + + // Verify ARC + let arc = self + .server + .eval_if(&ac.arc.verify, self, self.data.session_id) + .await + .unwrap_or(VerifyStrategy::Relaxed); + let arc_output = if arc.verify() { + let time = Instant::now(); + let arc_output = self + .server + .core + .smtp + .resolvers + .dns + .verify_arc(self.server.inner.cache.build_auth_parameters(&auth_message)) + .await; + + let strict = arc.is_strict(); + let pass = matches!(arc_output.result(), DkimResult::Pass | DkimResult::None); + + trc::event!( + Smtp(if pass { + SmtpEvent::ArcPass + } else { + SmtpEvent::ArcFail + }), + SpanId = self.data.session_id, + Strict = strict, + Result = trc::Error::from(arc_output.result()), + Elapsed = time.elapsed(), + ); + + if strict && !pass { + return if matches!(arc_output.result(), DkimResult::TempError(_)) { + (&b"451 4.7.29 ARC validation failed.\r\n"[..]).into() + } else { + (&b"550 5.7.29 ARC validation failed.\r\n"[..]).into() + }; + } + + arc_output.into() + } else { + None + }; + + // Verify DKIM2 + let mail_from = self.data.mail_from.as_ref().unwrap(); + let dkim2_output = if (dkim.verify() || dmarc.verify()) + && (!auth_message.dkim2_signatures.is_empty() || auth_message.has_dkim2_errors) + { + // Discard forged DKIM2-signed delivery status notifications + if dkim.verify() + && !auth_message.dkim2_signatures.is_empty() + && let Some(dsn) = + parse_dkim2_dsn(&parsed_message, &auth_message, raw_message.as_slice()) + && let Err(failure) = self + .server + .core + .smtp + .resolvers + .dns + .verify_dkim2_dsn( + self.server.inner.cache.build_auth_parameters(&dsn), + Dkim2Envelope { + mail_from: &mail_from.address, + rcpt_to: self.data.rcpt_to.iter().map(|r| r.address.as_str()), + }, + ) + .await + && matches!( + failure, + Dkim2DsnFailure::DsnChainFailed + | Dkim2DsnFailure::ReturnedChainFailed + | Dkim2DsnFailure::NotAligned + ) + { + trc::event!( + Smtp(SmtpEvent::Dkim2DsnDiscarded), + SpanId = self.data.session_id, + From = mail_from.address.to_string(), + To = self + .data + .rcpt_to + .iter() + .map(|rcpt| trc::Value::from(rcpt.address.to_string())) + .collect::>(), + Reason = failure.to_string(), + ); + + self.data.messages_sent += 1; + return (b"550 5.7.1 DSN rejected due to DKIM2 verification failure.\r\n"[..]) + .into(); + } + + let time = Instant::now(); + let output = self + .server + .core + .smtp + .resolvers + .dns + .verify_dkim2( + self.server.inner.cache.build_auth_parameters(&auth_message), + Dkim2Envelope { + mail_from: &mail_from.address, + rcpt_to: self.data.rcpt_to.iter().map(|r| r.address.as_str()), + }, + ) + .await; + + if !matches!(output.result(), Dkim2Result::None) { + trc::event!( + Smtp(if matches!(output.result(), Dkim2Result::Pass) { + SmtpEvent::Dkim2Pass + } else { + SmtpEvent::Dkim2Fail + }), + SpanId = self.data.session_id, + Result = trc::Error::from(&output), + Elapsed = time.elapsed(), + ); + } + + Some(output) + } else { + None + }; + + // Build authentication results header + let mut auth_results = AuthenticationResults::new(&self.hostname); + if !dkim_output.is_empty() { + auth_results = auth_results.with_dkim_results(&dkim_output, auth_message.from()) + } + if let Some(dkim2_output) = &dkim2_output + && (!matches!(dkim2_output.result(), Dkim2Result::None) + || !dkim2_output.chain().is_empty()) + { + auth_results = auth_results.with_dkim2_result(dkim2_output); + } + if let Some(spf_ehlo) = &self.data.spf_ehlo { + auth_results = auth_results.with_spf_ehlo_result( + spf_ehlo, + self.data.remote_ip, + &self.data.helo_domain, + ); + } + if let Some(spf_mail_from) = &self.data.spf_mail_from { + auth_results = auth_results.with_spf_mailfrom_result( + spf_mail_from, + self.data.remote_ip, + &mail_from.address, + &self.data.helo_domain, + ); + } + if let Some(iprev) = &self.data.iprev { + auth_results = auth_results.with_iprev_result(iprev, self.data.remote_ip); + } + + // Verify DMARC + let is_report = !self.is_authenticated() && self.is_report(); + let (dmarc_result, dmarc_policy) = if dmarc.verify() { + { + let synthetic_spf; + let spf_output = match &self.data.spf_mail_from { + Some(spf_output) => spf_output, + None => { + synthetic_spf = SpfOutput::new(String::new()).with_result(SpfResult::None); + &synthetic_spf + } + }; + let time = Instant::now(); + let dmarc_output = + self.server + .core + .smtp + .resolvers + .dns + .verify_dmarc(self.server.inner.cache.build_auth_parameters( + DmarcParameters { + message: &auth_message, + dkim_output: &dkim_output, + dkim2_output: dkim2_output.as_ref(), + rfc5321_mail_from_domain: if !mail_from.domain.is_empty() { + &mail_from.domain + } else { + &self.data.helo_domain + }, + spf_output, + }, + )) + .await; + + let dmarc_result = dmarc_output.result(); + let pass = dmarc_result == DmarcResult::Pass; + let strict = dmarc.is_strict(); + let is_temp_fail = matches!(dmarc_result, DmarcResult::TempError(_)); + let rejected = strict + && dmarc_output.policy() == dmarc::Policy::Reject + && (is_temp_fail || matches!(dmarc_result, DmarcResult::Fail(_))); + + // Add to DMARC output to the Authentication-Results header + auth_results = auth_results.with_dmarc_result(&dmarc_output); + let dmarc_policy = dmarc_output.policy(); + + trc::event!( + Smtp(if pass { + SmtpEvent::DmarcPass + } else { + SmtpEvent::DmarcFail + }), + SpanId = self.data.session_id, + Strict = strict, + Domain = dmarc_output.domain().to_string(), + Policy = dmarc_policy.to_string(), + Result = trc::Error::from(&dmarc_result), + Elapsed = time.elapsed(), + ); + + // Send DMARC report + if dmarc_output.requested_reports() && !is_report && !(rejected && is_temp_fail) { + self.send_dmarc_report( + &auth_message, + &auth_results, + rejected, + dmarc_output, + &dkim_output, + dkim2_output.as_ref(), + &arc_output, + ) + .await; + } + + if rejected { + return if is_temp_fail { + (&b"451 4.7.1 Email temporarily rejected per DMARC policy.\r\n"[..]).into() + } else { + (&b"550 5.7.1 Email rejected per DMARC policy.\r\n"[..]).into() + }; + } + + (dmarc_result.into(), dmarc_policy.into()) + } + } else { + (None, None) + }; + + // Analyze reports + if is_report && ReportData::is_present(&parsed_message) { + if !rc.analysis.forward { + self.data + .rcpt_to + .retain(|rcpt| !rc.analysis.is_report_address(rcpt.report_address())); + } + + if self.data.rcpt_to.is_empty() { + self.server.analyze_report( + mail_parser::Message { + html_body: parsed_message.html_body, + text_body: parsed_message.text_body, + attachments: parsed_message.attachments, + parts: parsed_message + .parts + .into_iter() + .map(|p| p.into_owned()) + .collect(), + raw_message: b"".into(), + }, + self.data.session_id, + ); + self.data.messages_sent += 1; + return (b"250 2.0.0 Message queued for delivery.\r\n"[..]).into(); + } else { + self.server.analyze_report( + mail_parser::Message { + html_body: parsed_message.html_body.clone(), + text_body: parsed_message.text_body.clone(), + attachments: parsed_message.attachments.clone(), + parts: parsed_message + .parts + .iter() + .map(|p| p.clone().into_owned()) + .collect(), + raw_message: b"".into(), + }, + self.data.session_id, + ); + } + } + + // Add Received header + let message_id = self.server.inner.data.queue_id_gen.generate(); + let mut headers = Vec::with_capacity(64); + if self + .server + .eval_if(&dc.add_received, self, self.data.session_id) + .await + .unwrap_or(true) + { + self.write_received(&mut headers, message_id) + } + + // Add authentication results header + if self + .server + .eval_if(&dc.add_auth_results, self, self.data.session_id) + .await + .unwrap_or(true) + { + auth_results.write_header(&mut headers); + } + + // Add Received-SPF header + if let Some(spf_output) = &self.data.spf_mail_from + && self + .server + .eval_if(&dc.add_received_spf, self, self.data.session_id) + .await + .unwrap_or(true) + { + ReceivedSpf::new( + spf_output, + self.data.remote_ip, + &self.data.helo_domain, + &mail_from.address_lcase, + &self.hostname, + ) + .write_header(&mut headers); + } + + // Run SPAM filter + let mut train_spam = None; + let mut spam_result = None; + if self.server.core.spam.enabled + && self + .server + .eval_if(&dc.spam_filter, self, self.data.session_id) + .await + .unwrap_or(true) + { + match self + .spam_classify( + &parsed_message, + &dkim_output, + dkim2_output.as_ref(), + (&arc_output).into(), + dmarc_result.as_ref(), + dmarc_policy.as_ref(), + ) + .await + { + SpamFilterAction::Allow(score) => { + // Add headers + headers.extend_from_slice(score.headers.as_bytes()); + train_spam = score.train_spam.map(|is_spam| { + ( + is_spam, + thread_name(parsed_message.subject().unwrap_or_default()).to_string(), + ) + }); + let scores = &self.server.core.spam.scores; + spam_result = Some((score.score, scores.spam_percentage(score.score))); + + // Add scores for local recipients + for (user_score, recipient) in + score.results.into_iter().zip(self.data.rcpt_to.iter_mut()) + { + recipient.flags = (recipient.flags & !RCPT_SPAM_MASK) + | rcpt_spam_flag(scores.spam_percentage(user_score)); + } + } + SpamFilterAction::Discard => { + trc::event!( + Spam(SpamEvent::Classify), + SpanId = self.data.session_id, + QueueId = message_id, + Result = "discard", + Reason = "Message discarded due to excessive spam score.", + ); + + self.data.messages_sent += 1; + return (b"250 2.0.0 Message queued for delivery.\r\n"[..]).into(); + } + SpamFilterAction::Reject => { + trc::event!( + Spam(SpamEvent::Classify), + SpanId = self.data.session_id, + QueueId = message_id, + Result = "reject", + Reason = "Message rejected due to excessive spam score.", + ); + + self.data.messages_sent += 1; + return (b"550 5.7.1 Message rejected due to excessive spam score.\r\n"[..]) + .into(); + } + SpamFilterAction::Disabled => {} + } + } + + // Run Milter filters + let mut modifications = Vec::new(); + match self + .run_milters(Stage::Data, (&auth_message).into(), message_id.into()) + .await + { + Ok(modifications_) => { + if !modifications_.is_empty() { + modifications = modifications_; + } + } + Err(response) => { + return response.into_bytes(); + } + }; + + // Run MTA Hooks + match self + .run_mta_hooks(Stage::Data, (&auth_message).into(), message_id.into()) + .await + { + Ok(modifications_) => { + if !modifications_.is_empty() { + modifications.retain(|m| !matches!(m, Modification::ReplaceBody { .. })); + modifications.extend(modifications_); + } + } + Err(response) => { + return response.into_bytes(); + } + }; + + // Apply modifications + let mut edited_message = if !modifications.is_empty() { + self.data + .apply_milter_modifications(modifications, &auth_message) + } else { + None + }; + + // Sieve filtering + if let Some((script, script_id)) = self + .server + .eval_if::(&dc.script, self, self.data.session_id) + .await + .and_then(|name| { + self.server + .get_trusted_sieve_script(&name, self.data.session_id) + .map(|s| (s, name)) + }) + { + let mut params = self + .build_script_parameters("data") + .with_auth_headers(&headers); + if let Some((score, percentage)) = spam_result { + params = params + .with_spam_status(spam_status(Some(percentage))) + .set_variable("spam.score", score as f64) + .set_variable("spam.is_spam", self.server.core.spam.scores.is_spam(score)); + } + let params = params + .set_variable( + "arc.result", + arc_output + .as_ref() + .map(|a| a.result().as_str()) + .unwrap_or_default(), + ) + .set_variable( + "dkim.result", + dkim_output + .iter() + .find(|r| matches!(r.result(), DkimResult::Pass)) + .or_else(|| dkim_output.first()) + .map(|r| r.result().as_str()) + .unwrap_or_default(), + ) + .set_variable( + "dkim.domains", + dkim_output + .iter() + .filter_map(|r| { + if matches!(r.result(), DkimResult::Pass) { + r.signature() + .map(|s| Variable::from(s.domain().to_lowercase())) + } else { + None + } + }) + .collect::>(), + ) + .set_variable( + "dmarc.result", + dmarc_result + .as_ref() + .map(|a| a.as_str()) + .unwrap_or_default(), + ) + .set_variable( + "dmarc.policy", + dmarc_policy + .as_ref() + .map(|a| a.as_str()) + .unwrap_or_default(), + ) + .with_message(parsed_message); + + let modifications = match self.run_script(script_id, script.clone(), params).await { + ScriptResult::Accept { modifications } => modifications, + ScriptResult::Replace { + message, + modifications, + } => { + edited_message = message.into(); + modifications + } + ScriptResult::Reject(message) => { + return message.as_bytes().to_vec().into(); + } + ScriptResult::Discard => { + return (b"250 2.0.0 Message queued for delivery.\r\n"[..]).into(); + } + }; + + // Apply modifications + for modification in modifications { + match modification { + ScriptModification::AddHeader { name, value } => { + headers.extend_from_slice(name.as_bytes()); + headers.extend_from_slice(b": "); + headers.extend_from_slice(value.as_bytes()); + if !value.ends_with('\n') { + headers.extend_from_slice(b"\r\n"); + } + } + ScriptModification::SetEnvelope { name, value } => { + self.data.apply_envelope_modification(name, value); + } + } + } + } + + // Build message + let mail_from = self.data.mail_from.clone().unwrap(); + let rcpt_to = std::mem::take(&mut self.data.rcpt_to); + let source = if !self.is_authenticated() { + let dmarc_pass = dmarc_result.is_some_and(|result| result == DmarcResult::Pass); + + #[cfg(feature = "test_mode")] + { + MessageSource::Unauthenticated { + dmarc_pass: dmarc_pass || mail_from.address.starts_with("dmarc-"), + } + } + + #[cfg(not(feature = "test_mode"))] + { + MessageSource::Unauthenticated { dmarc_pass } + } + } else { + MessageSource::Authenticated + }; + let mut message = self + .build_message(mail_from, rcpt_to, source, message_id, self.data.session_id) + .await; + + // Add Return-Path + if self + .server + .eval_if(&dc.add_return_path, self, self.data.session_id) + .await + .unwrap_or(true) + { + headers.extend_from_slice(b"Return-Path: <"); + headers.extend_from_slice(message.message.return_path.as_bytes()); + headers.extend_from_slice(b">\r\n"); + } + + // Add any missing headers + if !has_date_header + && self + .server + .eval_if(&dc.add_date, self, self.data.session_id) + .await + .unwrap_or(true) + { + headers.extend_from_slice(b"Date: "); + headers.extend_from_slice(Date::now().to_rfc822().as_bytes()); + headers.extend_from_slice(b"\r\n"); + } + if !has_message_id_header + && self + .server + .eval_if(&dc.add_message_id, self, self.data.session_id) + .await + .unwrap_or(true) + { + headers.extend_from_slice(b"Message-ID: "); + generate_message_id_header(&mut headers, &self.hostname); + headers.extend_from_slice(b"\r\n"); + } + + // Update size + let original_message = raw_message.as_slice(); + let raw_message = edited_message.as_deref().unwrap_or(raw_message.as_slice()); + message.message.size = (raw_message.len() + headers.len()) as u64; + + // Verify queue quota + if let Some(metadata) = self.server.has_quota(&mut message).await { + // Queue message + let queue_id = message.queue_id; + let dkim_signers = self + .server + .eval_signers(&ac.dkim.sign, self, self.data.session_id) + .await; + if message + .queue( + QueueParams::new(raw_message, self.data.session_id, &self.server) + .with_train_spam(train_spam) + .with_raw_headers(&headers) + .with_dkim_signers(dkim_signers) + .with_original_raw_message(original_message) + .with_original_authenticated_message(auth_message) + .with_metadata(metadata), + ) + .await + { + self.state = State::Accepted(queue_id); + self.data.messages_sent += 1; + format!("250 2.0.0 Message queued with id {queue_id:x}.\r\n") + .into_bytes() + .into() + } else { + (b"451 4.3.5 Unable to accept message at this time.\r\n"[..]).into() + } + } else { + (b"452 4.3.1 Mail system full, try again later.\r\n"[..]).into() + } + } + + pub async fn build_message( + &self, + mail_from: SessionAddress, + mut rcpt_to: Vec, + source: MessageSource, + queue_id: u64, + span_id: u64, + ) -> MessageWrapper { + // Build message + let created = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let mut message = Message { + created, + return_path: mail_from + .address + .to_lowercase_address(false) + .into_boxed_str(), + recipients: Vec::with_capacity(rcpt_to.len()), + flags: mail_from.flags | source.flags(), + priority: self.data.priority, + size: 0, + env_id: mail_from.dsn_info.map(|i| i.into_boxed_str()), + blob_hash: Default::default(), + metadata: Default::default(), + received_from_ip: self.data.remote_ip, + received_via_port: self.data.local_port, + }; + + // Add recipients + let future_release = self.data.future_release; + rcpt_to.sort_unstable(); + for rcpt in rcpt_to { + message.recipients.push( + queue::Recipient::new(rcpt.address) + .with_flags( + if rcpt.flags + & (RCPT_NOTIFY_DELAY + | RCPT_NOTIFY_FAILURE + | RCPT_NOTIFY_SUCCESS + | RCPT_NOTIFY_NEVER) + != 0 + { + rcpt.flags + } else { + rcpt.flags | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_FAILURE + }, + ) + .with_orcpt(rcpt.dsn_info.map(|v| v.into_boxed_str())), + ); + + let envelope = QueueEnvelope::new(&message, message.recipients.last().unwrap()); + + // Set next retry time + let retry = if self.data.future_release == 0 { + queue::Schedule::now() + } else { + queue::Schedule::later(future_release) + }; + + // Resolve queue + let queue = self.server.get_queue_or_default( + &self + .server + .eval_if::( + &self.server.core.smtp.queue.queue, + &envelope, + self.data.session_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + self.data.session_id, + ); + + // Set expiration and notification times + let num_intervals = std::cmp::max(queue.notify.len(), 1); + let next_notify = queue.notify.first().copied().unwrap_or(86400); + let (notify, expires) = if self.data.delivery_by == 0 { + ( + queue::Schedule::later(future_release + next_notify), + match queue.expiry { + QueueExpiry::Ttl(time) => QueueExpiry::Ttl(future_release + time), + QueueExpiry::Attempts(count) => QueueExpiry::Attempts(count), + }, + ) + } else if (message.flags & MAIL_BY_RETURN) != 0 { + ( + queue::Schedule::later(future_release + next_notify), + QueueExpiry::Ttl(self.data.delivery_by as u64), + ) + } else { + let (notify, expires) = match queue.expiry { + QueueExpiry::Ttl(expire_secs) => ( + (if self.data.delivery_by.is_positive() { + let notify_at = self.data.delivery_by as u64; + if expire_secs > notify_at { + notify_at + } else { + next_notify + } + } else { + let notify_at = -self.data.delivery_by as u64; + if expire_secs > notify_at { + expire_secs - notify_at + } else { + next_notify + } + }), + QueueExpiry::Ttl(expire_secs), + ), + QueueExpiry::Attempts(_) => ( + next_notify, + QueueExpiry::Ttl(self.data.delivery_by.unsigned_abs()), + ), + }; + + let mut notify = queue::Schedule::later(future_release + notify); + notify.inner = (num_intervals - 1) as u32; // Disable further notification attempts + + (notify, expires) + }; + + // Update recipient + let recipient = message.recipients.last_mut().unwrap(); + recipient.retry = retry; + recipient.notify = notify; + recipient.expires = expires; + recipient.queue = queue.virtual_queue; + } + + MessageWrapper { + queue_id, + queue_name: QueueName::default(), + is_multi_queue: false, + span_id, + message, + } + } + + pub async fn can_send_data(&mut self) -> Option<&'static [u8]> { + if self.data.mail_from.is_none() { + trc::event!( + Smtp(SmtpEvent::MailFromMissing), + SpanId = self.data.session_id, + ); + + Some(b"503 5.5.1 MAIL is required first.\r\n") + } else if self.data.rcpt_to.is_empty() { + trc::event!( + Smtp(SmtpEvent::RcptToMissing), + SpanId = self.data.session_id, + ); + + Some(b"503 5.5.1 RCPT is required first.\r\n") + } else if self.data.messages_sent + < self + .server + .eval_if( + &self.server.core.smtp.session.data.max_messages, + self, + self.data.session_id, + ) + .await + .unwrap_or(10) + { + None + } else { + trc::event!( + Smtp(SmtpEvent::TooManyMessages), + SpanId = self.data.session_id, + Limit = self.data.messages_sent + ); + + Some(b"452 4.4.5 Maximum number of messages per session exceeded.\r\n") + } + } + + fn write_received(&self, headers: &mut Vec, id: u64) { + headers.extend_from_slice(b"Received: from "); + headers.extend_from_slice(self.data.helo_domain.as_bytes()); + headers.extend_from_slice(b" ("); + headers.extend_from_slice( + self.data + .iprev + .as_ref() + .and_then(|ir| ir.ptr.as_ref()) + .and_then(|ptr| ptr.first().map(|s| s.strip_suffix('.').unwrap_or(s))) + .unwrap_or("unknown") + .as_bytes(), + ); + headers.extend_from_slice(b" ["); + headers.extend_from_slice(self.data.remote_ip.to_string().as_bytes()); + headers.extend_from_slice(b"]"); + if self.data.asn_geo_data.asn.is_some() || self.data.asn_geo_data.country.is_some() { + headers.extend_from_slice(b" ("); + if let Some(asn) = &self.data.asn_geo_data.asn { + headers.extend_from_slice(b"AS"); + headers.extend_from_slice(asn.id.to_string().as_bytes()); + if let Some(name) = &asn.name { + headers.extend_from_slice(b" "); + headers.extend_from_slice(name.as_bytes()); + } + } + if let Some(country) = &self.data.asn_geo_data.country { + if self.data.asn_geo_data.asn.is_some() { + headers.extend_from_slice(b", "); + } + headers.extend_from_slice(country.as_bytes()); + } + headers.extend_from_slice(b")"); + } + headers.extend_from_slice(b")\r\n\t"); + if self.stream.is_tls() { + let (version, cipher) = self.stream.tls_version_and_cipher(); + headers.extend_from_slice(b"(using "); + headers.extend_from_slice(version.as_bytes()); + headers.extend_from_slice(b" with cipher "); + headers.extend_from_slice(cipher.as_bytes()); + headers.extend_from_slice(b")\r\n\t"); + } + headers.extend_from_slice(b"by "); + headers.extend_from_slice(self.hostname.as_bytes()); + headers.extend_from_slice(b" (Stalwart SMTP) with "); + headers.extend_from_slice(match (self.stream.is_tls(), !self.is_authenticated()) { + (true, true) => b"ESMTPS", + (true, false) => b"ESMTPSA", + (false, true) => b"ESMTP", + (false, false) => b"ESMTPA", + }); + headers.extend_from_slice(b" id "); + headers.extend_from_slice(format!("{id:X}").as_bytes()); + headers.extend_from_slice(b";\r\n\t"); + headers.extend_from_slice(Date::now().to_rfc822().as_bytes()); + headers.extend_from_slice(b"\r\n"); + } +} + +fn parse_dkim2_dsn<'x, 'r>( + parsed_message: &mail_parser::Message<'x>, + raw: &'r AuthenticatedMessage<'x>, + raw_message: &'x [u8], +) -> Option, AuthenticatedMessage<'x>>> { + if !parsed_message.content_type().is_some_and(|ct| { + ct.ctype().eq_ignore_ascii_case("multipart") + && ct + .subtype() + .is_some_and(|subtype| subtype.eq_ignore_ascii_case("report")) + && ct + .attribute("report-type") + .is_some_and(|report_type| report_type.eq_ignore_ascii_case("delivery-status")) + }) { + return None; + } + let mail_parser::PartType::Multipart(children) = &parsed_message.root_part().body else { + return None; + }; + + let mut returned = &b""[..]; + let mut returned_full = false; + for child in children { + let part = parsed_message.parts.get(*child as usize)?; + if part.is_content_type("message", "rfc822") { + returned = raw_message.get(part.offset_body as usize..part.offset_end as usize)?; + returned_full = true; + } else if part.is_content_type("text", "rfc822-headers") { + returned = raw_message.get(part.offset_body as usize..part.offset_end as usize)?; + returned_full = false; + } + } + + if !returned.is_empty() { + Some(Dkim2Dsn::new( + raw, + AuthenticatedMessage::parse(returned)?, + returned_full, + )) + } else { + None + } +} diff --git a/crates/smtp/src/inbound/dkim.rs b/crates/smtp/src/inbound/dkim.rs new file mode 100644 index 0000000..a13afcd --- /dev/null +++ b/crates/smtp/src/inbound/dkim.rs @@ -0,0 +1,418 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::queue::{MessageWrapper, Metadata, spool::QueueParams}; +use common::{ + Server, + config::smtp::auth::{Dkim1Signer, DkimSigners}, + expr::{functions::ResolveVariable, if_block::IfBlock}, +}; +use mail_auth::{ + AuthenticatedMessage, + common::headers::HeaderWriter, + dkim2::{Hop, MessageInstance}, +}; +use mail_parser::{Address, parsers::MessageStream}; +use std::{collections::HashSet, sync::Arc}; +use utils::sanitize_email; + +pub(crate) trait DkimSign: Sync + Send { + fn sign_message( + &self, + message: &mut MessageWrapper, + params: &mut QueueParams<'_, '_>, + ) -> impl Future>> + Send; + + fn eval_signers( + &self, + if_block: &IfBlock, + resolver: &impl ResolveVariable, + session_id: u64, + ) -> impl Future>> + Send; +} + +impl DkimSign for Server { + async fn sign_message( + &self, + message: &mut MessageWrapper, + params: &mut QueueParams<'_, '_>, + ) -> Option> { + let signers = params.dkim_signers.as_ref().unwrap(); + let raw_message = params.raw_message; + + // DKIM1 signing + let mut headers = Vec::with_capacity(64); + for signer in &signers.dkim1 { + let result = match (signer, params.raw_headers) { + (Dkim1Signer::RsaSha256(signer), None) => signer.sign(raw_message), + (Dkim1Signer::Ed25519Sha256(signer), None) => signer.sign(raw_message), + (Dkim1Signer::RsaSha256(signer), Some(headers)) => { + signer.sign_chained([headers, raw_message].iter().copied()) + } + (Dkim1Signer::Ed25519Sha256(signer), Some(headers)) => { + signer.sign_chained([headers, raw_message].iter().copied()) + } + }; + + match result { + Ok(signature) => { + signature.write_header(&mut headers); + } + Err(err) => { + trc::error!( + trc::Error::from(err) + .span_id(params.session_id) + .details("Failed to sign message") + .caused_by(trc::location!()) + ); + } + } + } + + // DKIM2 signing + if let Some(signer) = &signers.dkim2 + && let Some(modified) = + AuthenticatedMessage::parse_with_opts(raw_message, params.raw_headers, true) + { + // Generate message instance + let original = params.original_authenticated_message.take().or_else(|| { + params + .original_raw_message + .and_then(AuthenticatedMessage::parse) + }); + let instance = MessageInstance::from_message(&modified, original.as_ref()); + if let Some(instance) = &instance { + instance.write_header(&mut headers); + } + + // Obtain disclosed and undisclosed recipients + let envelopes = message.undisclosed_recipients(&modified); + + // Generate DKIM2 signature for disclosed recipients + if !envelopes.disclosed_recipients.is_empty() { + match signer.sign_with_message_instance( + &modified, + instance.as_ref(), + Hop::real( + message.message.return_path.as_ref(), + envelopes.disclosed_recipients, + ), + ) { + Ok(signature) => { + if envelopes.undisclosed_recipients.is_empty() { + // Happy path: no undisclosed recipients, serialize signature straight to blob + signature.write_header(&mut headers); + } else { + // Undisclosed recipients present, serialize signature to metadata + let mut header = Vec::with_capacity(64); + signature.write_header(&mut header); + params.metadata.push(Metadata::Headers { + value: header.into_boxed_slice(), + id: u64::MAX, + }); + } + } + Err(err) => { + trc::error!( + trc::Error::from(err) + .span_id(params.session_id) + .details("Failed to DKIM2 sign message") + ); + } + } + } + + // Generate DKIM2 signature for undisclosed recipients + for (pos, rcpt) in envelopes.undisclosed_recipients { + match signer.sign_with_message_instance( + &modified, + instance.as_ref(), + Hop::real(message.message.return_path.as_ref(), [rcpt]), + ) { + Ok(signature) => { + // Serialize signature to metadata + let mut header = Vec::with_capacity(64); + signature.write_header(&mut header); + params.metadata.push(Metadata::Headers { + value: header.into_boxed_slice(), + id: pos as u64, + }); + } + Err(err) => { + trc::error!( + trc::Error::from(err) + .span_id(params.session_id) + .details("Failed to DKIM2 sign message") + ); + } + } + } + } + + (!headers.is_empty()).then_some(headers) + } + + async fn eval_signers( + &self, + if_block: &IfBlock, + resolver: &impl ResolveVariable, + session_id: u64, + ) -> Option> { + let sign_with_domain = self + .eval_if::(if_block, resolver, session_id) + .await?; + match self.dkim_signers(&sign_with_domain).await { + Ok(signers) => signers, + Err(err) => { + trc::error!( + err.span_id(session_id) + .details("Failed to retrieve DKIM signers") + ); + None + } + } + } +} + +struct Dkim2Envelopes<'x> { + undisclosed_recipients: Vec<(usize, &'x str)>, + disclosed_recipients: Vec<&'x str>, +} + +impl MessageWrapper { + fn undisclosed_recipients<'x>( + &'x self, + message: &AuthenticatedMessage<'_>, + ) -> Dkim2Envelopes<'x> { + if self.message.recipients.len() == 1 { + return Dkim2Envelopes { + undisclosed_recipients: Vec::new(), + disclosed_recipients: vec![self.message.recipients[0].address.as_ref()], + }; + } + + let mut recipients = HashSet::with_capacity(self.message.recipients.len()); + + for addr in message.headers.iter().filter_map(|(name, value)| { + let name = name.trim_ascii(); + if name.len() == 2 + && (name.eq_ignore_ascii_case(b"to") || name.eq_ignore_ascii_case(b"cc")) + { + MessageStream::new(value).parse_address().into_address() + } else { + None + } + }) { + match addr { + Address::List(addrs) => { + recipients.extend( + addrs + .iter() + .filter_map(|a| a.address()) + .map(sanitize_or_lower), + ); + } + Address::Group(groups) => { + for group in groups { + recipients.extend( + group + .addresses + .iter() + .filter_map(|a| a.address()) + .map(sanitize_or_lower), + ); + } + } + } + } + + let mut undisclosed_recipients = Vec::new(); + let mut disclosed_recipients = Vec::new(); + for (i, rcpt) in self.message.recipients.iter().enumerate() { + if !recipients.contains(rcpt.address.as_ref()) + && !recipients.contains(&sanitize_or_lower(&rcpt.address)) + { + undisclosed_recipients.push((i, rcpt.address.as_ref())); + } else { + disclosed_recipients.push(rcpt.address.as_ref()); + } + } + + Dkim2Envelopes { + undisclosed_recipients, + disclosed_recipients, + } + } +} + +fn sanitize_or_lower(rcpt: &str) -> String { + sanitize_email(rcpt).unwrap_or_else(|| rcpt.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::queue::{Message, QueueId, Recipient}; + use common::config::smtp::queue::QueueName; + use std::net::{IpAddr, Ipv4Addr}; + + fn wrapper(recipients: &[&str]) -> MessageWrapper { + MessageWrapper { + queue_id: 0 as QueueId, + queue_name: QueueName::default(), + is_multi_queue: false, + span_id: 0, + message: Message { + created: 0, + blob_hash: Default::default(), + return_path: "sender@example.com".into(), + recipients: recipients.iter().map(Recipient::new).collect(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + flags: 0, + env_id: None, + priority: 0, + size: 0, + metadata: Default::default(), + }, + } + } + + fn split(headers: &str, recipients: &[&str]) -> (Vec, Vec) { + let raw = format!("{headers}\r\nSubject: test\r\n\r\nbody\r\n"); + let auth = AuthenticatedMessage::parse(raw.as_bytes()).expect("parse message"); + let message = wrapper(recipients); + let envelopes = message.undisclosed_recipients(&auth); + let mut disclosed = envelopes + .disclosed_recipients + .iter() + .map(|s| s.to_string()) + .collect::>(); + let mut undisclosed = envelopes + .undisclosed_recipients + .iter() + .map(|(_, s)| s.to_string()) + .collect::>(); + disclosed.sort(); + undisclosed.sort(); + (disclosed, undisclosed) + } + + #[test] + fn single_recipient_is_always_disclosed() { + let (disclosed, undisclosed) = split("To: someone-else@example.com", &["bcc@example.org"]); + assert_eq!(disclosed, vec!["bcc@example.org".to_string()]); + assert!(undisclosed.is_empty()); + } + + #[test] + fn all_recipients_disclosed() { + let (disclosed, undisclosed) = split( + "To: alice@example.com, bob@example.com\r\nCc: carol@example.com", + &["alice@example.com", "bob@example.com", "carol@example.com"], + ); + assert_eq!( + disclosed, + vec![ + "alice@example.com".to_string(), + "bob@example.com".to_string(), + "carol@example.com".to_string(), + ] + ); + assert!(undisclosed.is_empty()); + } + + #[test] + fn mixed_disclosed_and_undisclosed() { + let (disclosed, undisclosed) = split( + "To: alice@example.com\r\nCc: bob@example.com", + &[ + "alice@example.com", + "bob@example.com", + "eve@secret.example.org", + "mallory@secret.example.org", + ], + ); + assert_eq!( + disclosed, + vec![ + "alice@example.com".to_string(), + "bob@example.com".to_string() + ] + ); + assert_eq!( + undisclosed, + vec![ + "eve@secret.example.org".to_string(), + "mallory@secret.example.org".to_string(), + ] + ); + } + + #[test] + fn no_to_or_cc_header_all_undisclosed() { + let (disclosed, undisclosed) = split( + "From: sender@example.com", + &["eve@example.org", "mallory@example.org"], + ); + assert!(disclosed.is_empty()); + assert_eq!( + undisclosed, + vec![ + "eve@example.org".to_string(), + "mallory@example.org".to_string() + ] + ); + } + + #[test] + fn group_addresses_are_disclosed() { + let (disclosed, undisclosed) = split( + "To: Team:alice@example.com,bob@example.com;", + &["alice@example.com", "bob@example.com", "eve@example.org"], + ); + assert_eq!( + disclosed, + vec![ + "alice@example.com".to_string(), + "bob@example.com".to_string() + ] + ); + assert_eq!(undisclosed, vec!["eve@example.org".to_string()]); + } + + #[test] + fn header_address_casing_is_ignored() { + let (disclosed, undisclosed) = split( + "To: Alice@Example.COM, BOB@EXAMPLE.com", + &["alice@example.com", "bob@example.com", "eve@example.org"], + ); + assert_eq!( + disclosed, + vec![ + "alice@example.com".to_string(), + "bob@example.com".to_string() + ] + ); + assert_eq!(undisclosed, vec!["eve@example.org".to_string()]); + } + + #[test] + fn display_names_and_brackets_are_ignored() { + let (disclosed, undisclosed) = split( + "To: \"Alice Doe\" \r\nCc: Bob ", + &["alice@example.com", "bob@example.com", "eve@example.org"], + ); + assert_eq!( + disclosed, + vec![ + "alice@example.com".to_string(), + "bob@example.com".to_string() + ] + ); + assert_eq!(undisclosed, vec!["eve@example.org".to_string()]); + } +} diff --git a/crates/smtp/src/inbound/ehlo.rs b/crates/smtp/src/inbound/ehlo.rs new file mode 100644 index 0000000..d82d46b --- /dev/null +++ b/crates/smtp/src/inbound/ehlo.rs @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{core::Session, scripts::ScriptResult}; +use common::{ + config::smtp::session::{Mechanism, Stage}, + network::SessionStream, +}; +use mail_auth::{ + SpfResult, + spf::verify::{HasValidLabels, SpfParameters}, +}; +use smtp_proto::*; +use std::{ + borrow::Cow, + time::{Duration, Instant, SystemTime}, +}; +use trc::SmtpEvent; + +impl Session { + pub async fn handle_ehlo(&mut self, domain: Cow<'_, str>, is_extended: bool) -> Result<(), ()> { + // Set EHLO domain + + if domain != self.data.helo_domain { + // Reject non-FQDN EHLO domains - simply checks that the hostname has at least one dot + if self.params.ehlo_reject_non_fqdn && !domain.as_ref().has_valid_labels() { + trc::event!( + Smtp(SmtpEvent::InvalidEhlo), + SpanId = self.data.session_id, + Domain = domain.as_ref().to_string(), + ); + + return self.write(b"550 5.5.0 Invalid EHLO domain.\r\n").await; + } + + trc::event!( + Smtp(SmtpEvent::Ehlo), + SpanId = self.data.session_id, + Domain = domain.as_ref().to_string(), + ); + + // SPF check + let prev_helo_domain = + std::mem::replace(&mut self.data.helo_domain, domain.into_owned()); + if self.params.spf_ehlo.verify() { + let time = Instant::now(); + let spf_output = self + .server + .core + .smtp + .resolvers + .dns + .verify_spf(self.server.inner.cache.build_auth_parameters( + SpfParameters::verify_ehlo( + self.data.remote_ip, + &self.data.helo_domain, + &self.hostname, + ), + )) + .await; + + trc::event!( + Smtp(if matches!(spf_output.result(), SpfResult::Pass) { + SmtpEvent::SpfEhloPass + } else { + SmtpEvent::SpfEhloFail + }), + SpanId = self.data.session_id, + Domain = self.data.helo_domain.clone(), + Result = trc::Error::from(&spf_output), + Elapsed = time.elapsed(), + ); + + if self + .handle_spf(&spf_output, self.params.spf_ehlo.is_strict()) + .await? + { + self.data.spf_ehlo = spf_output.into(); + } else { + self.data.mail_from = None; + self.data.helo_domain = prev_helo_domain; + return Ok(()); + } + } + + // Sieve filtering + if let Some((script, script_id)) = self + .server + .eval_if::( + &self.server.core.smtp.session.ehlo.script, + self, + self.data.session_id, + ) + .await + .and_then(|name| { + self.server + .get_trusted_sieve_script(&name, self.data.session_id) + .map(|s| (s, name)) + }) + && let ScriptResult::Reject(message) = self + .run_script( + script_id, + script.clone(), + self.build_script_parameters("ehlo"), + ) + .await + { + self.data.mail_from = None; + self.data.helo_domain = prev_helo_domain; + self.data.spf_ehlo = None; + return self.write(message.as_bytes()).await; + } + + // Milter filtering + if let Err(message) = self.run_milters(Stage::Ehlo, None, None).await { + self.data.mail_from = None; + self.data.helo_domain = prev_helo_domain; + self.data.spf_ehlo = None; + return self.write(message.message.as_bytes()).await; + } + + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Ehlo, None, None).await { + self.data.mail_from = None; + self.data.helo_domain = prev_helo_domain; + self.data.spf_ehlo = None; + return self.write(message.message.as_bytes()).await; + } + } + + // Reset + if self.data.mail_from.is_some() { + self.reset(); + } + + if !is_extended { + return self + .write(format!("250 {} you had me at HELO\r\n", self.hostname).as_bytes()) + .await; + } + + let mut response = EhloResponse::new(self.hostname.as_str()); + response.capabilities = + EXT_ENHANCED_STATUS_CODES | EXT_8BIT_MIME | EXT_BINARY_MIME | EXT_SMTP_UTF8; + if !self.stream.is_tls() && self.instance.acceptor.is_tls() { + response.capabilities |= EXT_START_TLS; + } + let ec = &self.server.core.smtp.session.extensions; + let ac = &self.server.core.smtp.session.auth; + let dc = &self.server.core.smtp.session.data; + + // Pipelining + if self + .server + .eval_if(&ec.pipelining, self, self.data.session_id) + .await + .unwrap_or(true) + { + response.capabilities |= EXT_PIPELINING; + } + + // Chunking + if self + .server + .eval_if(&ec.chunking, self, self.data.session_id) + .await + .unwrap_or(true) + { + response.capabilities |= EXT_CHUNKING; + } + + // Address Expansion + if self + .server + .eval_if(&ec.expn, self, self.data.session_id) + .await + .unwrap_or(false) + { + response.capabilities |= EXT_EXPN; + } + + // Recipient Verification + if self + .server + .eval_if(&ec.vrfy, self, self.data.session_id) + .await + .unwrap_or(false) + { + response.capabilities |= EXT_VRFY; + } + + // Require TLS + if self + .server + .eval_if(&ec.requiretls, self, self.data.session_id) + .await + .unwrap_or(true) + { + response.capabilities |= EXT_REQUIRE_TLS; + } + + // DSN + if self + .server + .eval_if(&ec.dsn, self, self.data.session_id) + .await + .unwrap_or(false) + { + response.capabilities |= EXT_DSN; + } + + // Authentication + if !self.is_authenticated() { + response.auth_mechanisms = self + .server + .eval_if::(&ac.mechanisms, self, self.data.session_id) + .await + .unwrap_or_default() + .into(); + if response.auth_mechanisms != 0 { + response.capabilities |= EXT_AUTH; + } + } + + // Future release + if let Some(value) = self + .server + .eval_if::(&ec.future_release, self, self.data.session_id) + .await + { + response.capabilities |= EXT_FUTURE_RELEASE; + response.future_release_interval = value.as_secs(); + response.future_release_datetime = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + + value.as_secs(); + } + + // Deliver By + if let Some(value) = self + .server + .eval_if::(&ec.deliver_by, self, self.data.session_id) + .await + { + response.capabilities |= EXT_DELIVER_BY; + response.deliver_by = value.as_secs(); + } + + // Priority + if let Some(value) = self + .server + .eval_if::(&ec.mt_priority, self, self.data.session_id) + .await + { + response.capabilities |= EXT_MT_PRIORITY; + response.mt_priority = value; + } + + // Size + response.size = self + .server + .eval_if(&dc.max_message_size, self, self.data.session_id) + .await + .unwrap_or(25 * 1024 * 1024); + if response.size > 0 { + response.capabilities |= EXT_SIZE; + } + + // No soliciting + if let Some(value) = self + .server + .eval_if::(&ec.no_soliciting, self, self.data.session_id) + .await + { + response.capabilities |= EXT_NO_SOLICITING; + response.no_soliciting = if !value.is_empty() { + value.to_string().into() + } else { + None + }; + } + + // Generate response + let mut buf = Vec::with_capacity(64); + response.write(&mut buf).ok(); + self.write(&buf).await + } +} diff --git a/crates/smtp/src/inbound/hooks/client.rs b/crates/smtp/src/inbound/hooks/client.rs new file mode 100644 index 0000000..252f1cf --- /dev/null +++ b/crates/smtp/src/inbound/hooks/client.rs @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::session::MTAHook; +use utils::HttpLimitResponse; + +use super::{Request, Response}; + +pub(super) async fn send_mta_hook_request( + mta_hook: &MTAHook, + request: Request, +) -> Result { + let response = mta_hook + .client + .post(&mta_hook.url) + .timeout(mta_hook.timeout) + .headers(mta_hook.headers.clone()) + .body( + serde_json::to_string(&request) + .map_err(|err| format!("Failed to serialize Hook request: {}", err))?, + ) + .send() + .await + .map_err(|err| format!("Hook request failed: {err}"))?; + + if response.status().is_success() { + serde_json::from_slice( + response + .bytes_with_limit(mta_hook.max_response_size) + .await + .map_err(|err| format!("Failed to parse Hook response: {}", err))? + .ok_or_else(|| "Hook response too large".to_string())? + .as_ref(), + ) + .map_err(|err| format!("Failed to parse Hook response: {}", err)) + } else { + Err(format!( + "Hook request failed with code {}: {}", + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("Unknown") + )) + } +} diff --git a/crates/smtp/src/inbound/hooks/message.rs b/crates/smtp/src/inbound/hooks/message.rs new file mode 100644 index 0000000..31bff92 --- /dev/null +++ b/crates/smtp/src/inbound/hooks/message.rs @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Action, Queue, Response, client::send_mta_hook_request}; +use crate::{ + core::Session, + inbound::{ + FilterResponse, + hooks::{ + Address, Client, Context, Envelope, Message, Protocol, Request, Sasl, Server, Tls, + }, + milter::Modification, + }, + queue::QueueId, +}; +use ahash::AHashMap; +use common::{ + DAEMON_NAME, + config::smtp::session::{MTAHook, Stage}, + network::SessionStream, +}; +use mail_auth::AuthenticatedMessage; +use std::time::Instant; +use trc::MtaHookEvent; + +impl Session { + pub async fn run_mta_hooks( + &self, + stage: Stage, + message: Option<&AuthenticatedMessage<'_>>, + queue_id: Option, + ) -> Result, FilterResponse> { + let mta_hooks = &self.server.core.smtp.session.hooks; + if mta_hooks.is_empty() { + return Ok(Vec::new()); + } + + let mut modifications = Vec::new(); + for mta_hook in mta_hooks { + if !mta_hook.run_on_stage.contains(&stage) + || !self + .server + .eval_if(&mta_hook.enable, self, self.data.session_id) + .await + .unwrap_or(false) + { + continue; + } + + let time = Instant::now(); + match self.run_mta_hook(stage, mta_hook, message, queue_id).await { + Ok(response) => { + trc::event!( + MtaHook(match response.action { + Action::Accept => MtaHookEvent::ActionAccept, + Action::Discard => MtaHookEvent::ActionDiscard, + Action::Reject => MtaHookEvent::ActionReject, + Action::Quarantine => MtaHookEvent::ActionQuarantine, + }), + SpanId = self.data.session_id, + QueueId = queue_id, + Id = mta_hook.id.to_string(), + Elapsed = time.elapsed(), + ); + + let mut new_modifications = Vec::with_capacity(response.modifications.len()); + for modification in response.modifications { + new_modifications.push(match modification { + super::Modification::ChangeFrom { value, parameters } => { + Modification::ChangeFrom { + sender: value, + args: flatten_parameters(parameters), + } + } + super::Modification::AddRecipient { value, parameters } => { + Modification::AddRcpt { + recipient: value, + args: flatten_parameters(parameters), + } + } + super::Modification::DeleteRecipient { value } => { + Modification::DeleteRcpt { recipient: value } + } + super::Modification::ReplaceContents { value } => { + Modification::ReplaceBody { + value: value.as_bytes().to_vec(), + } + } + super::Modification::AddHeader { name, value } => { + Modification::AddHeader { name, value } + } + super::Modification::InsertHeader { index, name, value } => { + Modification::InsertHeader { index, name, value } + } + super::Modification::ChangeHeader { index, name, value } => { + Modification::ChangeHeader { index, name, value } + } + super::Modification::DeleteHeader { index, name } => { + Modification::ChangeHeader { + index, + name, + value: String::new(), + } + } + }); + } + + if !modifications.is_empty() { + // The message body can only be replaced once, so we need to remove + // any previous replacements. + if new_modifications + .iter() + .any(|m| matches!(m, Modification::ReplaceBody { .. })) + { + modifications + .retain(|m| !matches!(m, Modification::ReplaceBody { .. })); + } + modifications.extend(new_modifications); + } else { + modifications = new_modifications; + } + + let mut message = match response.action { + Action::Accept => continue, + Action::Discard => FilterResponse::accept(), + Action::Reject => FilterResponse::reject(), + Action::Quarantine => { + modifications.push(Modification::AddHeader { + name: "X-Quarantine".into(), + value: "true".into(), + }); + FilterResponse::accept() + } + }; + + if let Some(response) = response.response { + if let (Some(status), Some(text)) = (response.status, response.message) { + if let Some(enhanced) = response.enhanced_status { + message.message = format!("{status} {enhanced} {text}\r\n").into(); + } else { + message.message = format!("{status} {text}\r\n").into(); + } + } + message.disconnect = response.disconnect; + } + + return Err(message); + } + Err(err) => { + trc::event!( + MtaHook(MtaHookEvent::Error), + SpanId = self.data.session_id, + Id = mta_hook.id.to_string(), + Reason = err, + Elapsed = time.elapsed(), + ); + + if mta_hook.tempfail_on_error { + return Err(FilterResponse::server_failure()); + } + } + } + } + + Ok(modifications) + } + + pub async fn run_mta_hook( + &self, + stage: Stage, + mta_hook: &MTAHook, + message: Option<&AuthenticatedMessage<'_>>, + queue_id: Option, + ) -> Result { + // Build request + let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher(); + let request = Request { + context: Context { + stage: stage.into(), + client: Client { + ip: self.data.remote_ip.to_string(), + port: self.data.remote_port, + ptr: self + .data + .iprev + .as_ref() + .and_then(|ip_rev| ip_rev.ptr.as_ref()) + .and_then(|ptrs| ptrs.first()) + .map(|ip| ip.to_string()), + helo: (!self.data.helo_domain.is_empty()) + .then(|| self.data.helo_domain.clone()), + active_connections: 1, + }, + sasl: self.authenticated_as().map(|name| Sasl { + login: name.into(), + method: None, + }), + tls: (!tls_version.is_empty()).then(|| Tls { + version: tls_version.as_ref().into(), + cipher: tls_cipher.as_ref().into(), + bits: None, + issuer: None, + subject: None, + }), + server: Server { + name: Some(DAEMON_NAME.into()), + port: self.data.local_port, + ip: self.data.local_ip.to_string().into(), + }, + queue: queue_id.map(|id| Queue { + id: format!("{:x}", id), + }), + protocol: Protocol { version: 1 }, + }, + envelope: self.data.mail_from.as_ref().map(|from| Envelope { + from: Address { + address: from.address_lcase.clone(), + parameters: None, + }, + to: self + .data + .rcpt_to + .iter() + .map(|to| Address { + address: to.address_lcase.clone(), + parameters: None, + }) + .collect(), + }), + message: message.map(|message| Message { + headers: message + .raw_parsed_headers() + .iter() + .map(|(k, v)| { + ( + String::from_utf8_lossy(k).into_owned(), + String::from_utf8_lossy(v).into_owned(), + ) + }) + .collect(), + server_headers: vec![], + contents: String::from_utf8_lossy(message.raw_body()).into_owned(), + size: message.raw_message().len(), + }), + }; + + send_mta_hook_request(mta_hook, request).await + } +} + +fn flatten_parameters(parameters: AHashMap>) -> String { + let mut arguments = String::new(); + for (key, value) in parameters { + if !arguments.is_empty() { + arguments.push(' '); + } + arguments.push_str(key.as_str()); + if let Some(value) = value { + arguments.push('='); + arguments.push_str(value.as_str()); + } + } + + arguments +} diff --git a/crates/smtp/src/inbound/hooks/mod.rs b/crates/smtp/src/inbound/hooks/mod.rs new file mode 100644 index 0000000..bc78a31 --- /dev/null +++ b/crates/smtp/src/inbound/hooks/mod.rs @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod client; +pub mod message; + +use ahash::AHashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Request { + pub context: Context, + #[serde(skip_serializing_if = "Option::is_none")] + pub envelope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Context { + pub stage: Stage, + pub client: Client, + #[serde(skip_serializing_if = "Option::is_none")] + pub sasl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, + pub server: Server, + #[serde(skip_serializing_if = "Option::is_none")] + pub queue: Option, + pub protocol: Protocol, +} + +#[derive(Serialize, Deserialize)] +pub struct Sasl { + pub login: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Client { + pub ip: String, + pub port: u16, + pub ptr: Option, + pub helo: Option, + #[serde(rename = "activeConnections")] + pub active_connections: u32, +} + +#[derive(Serialize, Deserialize)] +pub struct Tls { + pub version: String, + pub cipher: String, + #[serde(rename = "cipherBits")] + #[serde(skip_serializing_if = "Option::is_none")] + pub bits: Option, + #[serde(rename = "certIssuer")] + #[serde(skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(rename = "certSubject")] + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Server { + pub name: Option, + pub port: u16, + pub ip: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Queue { + pub id: String, +} + +#[derive(Serialize, Deserialize)] +pub struct Protocol { + pub version: u32, +} + +#[derive(Serialize, Deserialize)] +pub enum Stage { + #[serde(rename = "connect")] + Connect, + #[serde(rename = "ehlo")] + Ehlo, + #[serde(rename = "auth")] + Auth, + #[serde(rename = "mail")] + Mail, + #[serde(rename = "rcpt")] + Rcpt, + #[serde(rename = "data")] + Data, +} + +#[derive(Serialize, Deserialize)] +pub struct Address { + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct Envelope { + pub from: Address, + pub to: Vec
, +} + +#[derive(Serialize, Deserialize)] +pub struct Message { + pub headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "serverHeaders")] + #[serde(default)] + pub server_headers: Vec<(String, String)>, + pub contents: String, + pub size: usize, +} + +#[derive(Serialize, Deserialize)] +pub struct Response { + pub action: Action, + #[serde(default)] + pub response: Option, + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize)] +pub enum Action { + #[serde(rename = "accept")] + Accept, + #[serde(rename = "discard")] + Discard, + #[serde(rename = "reject")] + Reject, + #[serde(rename = "quarantine")] + Quarantine, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct SmtpResponse { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub enhanced_status: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub disconnect: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +pub enum Modification { + #[serde(rename = "changeFrom")] + ChangeFrom { + value: String, + #[serde(default)] + parameters: AHashMap>, + }, + #[serde(rename = "addRecipient")] + AddRecipient { + value: String, + #[serde(default)] + parameters: AHashMap>, + }, + #[serde(rename = "deleteRecipient")] + DeleteRecipient { value: String }, + #[serde(rename = "replaceContents")] + ReplaceContents { value: String }, + #[serde(rename = "addHeader")] + AddHeader { name: String, value: String }, + #[serde(rename = "insertHeader")] + InsertHeader { + index: u32, + name: String, + value: String, + }, + #[serde(rename = "changeHeader")] + ChangeHeader { + index: u32, + name: String, + value: String, + }, + #[serde(rename = "deleteHeader")] + DeleteHeader { index: u32, name: String }, +} + +impl From for Stage { + fn from(value: common::config::smtp::session::Stage) -> Self { + match value { + common::config::smtp::session::Stage::Connect => Stage::Connect, + common::config::smtp::session::Stage::Ehlo => Stage::Ehlo, + common::config::smtp::session::Stage::Auth => Stage::Auth, + common::config::smtp::session::Stage::Mail => Stage::Mail, + common::config::smtp::session::Stage::Rcpt => Stage::Rcpt, + common::config::smtp::session::Stage::Data => Stage::Data, + } + } +} diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs new file mode 100644 index 0000000..482f191 --- /dev/null +++ b/crates/smtp/src/inbound/mail.rs @@ -0,0 +1,607 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionAddress}, + scripts::ScriptResult, +}; +use common::{config::smtp::session::Stage, network::SessionStream, scripts::ScriptModification}; +use mail_auth::{IprevOutput, IprevResult, SpfOutput, SpfResult, spf::verify::SpfParameters}; +use mail_parser::DateTime; +use registry::schema::structs::Rate; +use smtp_proto::{MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_REQUIRETLS, MailFrom, MtPriority}; +use std::{ + borrow::Cow, + time::{Duration, Instant, SystemTime}, +}; +use trc::SmtpEvent; +use utils::DomainPart; + +impl Session { + pub async fn handle_mail_from(&mut self, from: MailFrom>) -> Result<(), ()> { + if self.data.helo_domain.is_empty() + && (self.params.ehlo_require + || self.params.spf_ehlo.verify() + || self.params.spf_mail_from.verify()) + { + trc::event!( + Smtp(SmtpEvent::DidNotSayEhlo), + SpanId = self.data.session_id, + ); + + return self + .write(b"503 5.5.1 Polite people say EHLO first.\r\n") + .await; + } else if self.data.mail_from.is_some() { + trc::event!( + Smtp(SmtpEvent::MultipleMailFrom), + SpanId = self.data.session_id, + ); + + return self + .write(b"503 5.5.1 Multiple MAIL commands not allowed.\r\n") + .await; + } else if self.params.auth_require && !self.is_authenticated() { + trc::event!( + Smtp(SmtpEvent::MailFromUnauthenticated), + SpanId = self.data.session_id, + ); + + return self + .write(b"503 5.5.1 You must authenticate first.\r\n") + .await; + } else if self.data.iprev.is_none() && self.params.iprev.verify() { + let time = Instant::now(); + let iprev = self + .server + .core + .smtp + .resolvers + .dns + .verify_iprev( + self.server + .inner + .cache + .build_auth_parameters(self.data.remote_ip), + ) + .await; + + trc::event!( + Smtp(if matches!(iprev.result(), IprevResult::Pass) { + SmtpEvent::IprevPass + } else { + SmtpEvent::IprevFail + }), + SpanId = self.data.session_id, + Domain = self.data.helo_domain.clone(), + Result = trc::Error::from(&iprev), + Elapsed = time.elapsed(), + ); + + self.data.iprev = iprev.into(); + } + + // In strict mode reject messages from hosts that fail the reverse DNS lookup check + if self.params.iprev.is_strict() + && !matches!( + &self.data.iprev, + Some(IprevOutput { + result: IprevResult::Pass, + .. + }) + ) + { + let message = if matches!( + &self.data.iprev, + Some(IprevOutput { + result: IprevResult::TempError(_), + .. + }) + ) { + &b"451 4.7.25 Temporary error validating reverse DNS.\r\n"[..] + } else { + &b"550 5.7.25 Reverse DNS validation failed.\r\n"[..] + }; + + return self.write(message).await; + } + + let (address, address_lcase, domain) = if !from.address.is_empty() { + let address_lcase = from.address.to_lowercase_address(true); + let domain = address_lcase.domain_part().into(); + (from.address.into_owned(), address_lcase, domain) + } else { + (String::new(), String::new(), String::new()) + }; + + let has_dsn = from.env_id.is_some(); + self.data.mail_from = SessionAddress { + address, + address_lcase, + domain, + flags: from.flags, + dsn_info: from.env_id.map(|e| e.into_owned()), + } + .into(); + + // Check whether the address is allowed + if !self + .server + .eval_if::( + &self.server.core.smtp.session.mail.is_allowed, + self, + self.data.session_id, + ) + .await + .unwrap_or(true) + { + let mail_from = self.data.mail_from.take().unwrap(); + trc::event!( + Smtp(SmtpEvent::MailFromNotAllowed), + From = mail_from.address_lcase, + SpanId = self.data.session_id, + ); + return self + .write(b"550 5.7.1 Sender address not allowed.\r\n") + .await; + } + + // Sieve filtering + if let Some((script, script_id)) = self + .server + .eval_if::( + &self.server.core.smtp.session.mail.script, + self, + self.data.session_id, + ) + .await + .and_then(|name| { + self.server + .get_trusted_sieve_script(&name, self.data.session_id) + .map(|s| (s, name)) + }) + { + match self + .run_script( + script_id, + script.clone(), + self.build_script_parameters("mail"), + ) + .await + { + ScriptResult::Accept { modifications } if !modifications.is_empty() => { + for modification in modifications { + if let ScriptModification::SetEnvelope { name, value } = modification { + self.data.apply_envelope_modification(name, value); + } + } + } + ScriptResult::Reject(message) => { + self.data.mail_from = None; + return self.write(message.as_bytes()).await; + } + _ => (), + } + } + + // Milter filtering + if let Err(message) = self.run_milters(Stage::Mail, None, None).await { + self.data.mail_from = None; + return self.write(message.message.as_bytes()).await; + } + + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Mail, None, None).await { + self.data.mail_from = None; + return self.write(message.message.as_bytes()).await; + } + + // Address rewriting + if let Some(new_address) = self + .server + .eval_if::( + &self.server.core.smtp.session.mail.rewrite, + self, + self.data.session_id, + ) + .await + { + let mail_from = self.data.mail_from.as_mut().unwrap(); + + trc::event!( + Smtp(SmtpEvent::MailFromRewritten), + SpanId = self.data.session_id, + Details = mail_from.address_lcase.clone(), + From = new_address.clone(), + ); + + if new_address.contains('@') { + mail_from.address_lcase = new_address.to_lowercase_address(true); + mail_from.domain = mail_from.address_lcase.domain_part().into(); + mail_from.address = new_address; + } else if new_address.is_empty() { + mail_from.address_lcase.clear(); + mail_from.domain.clear(); + mail_from.address.clear(); + } + } + + // Make sure that the authenticated user is allowed to send from this address + match self.authenticated_as() { + Some(authenticated_as) + if self + .server + .eval_if( + &self.server.core.smtp.session.auth.must_match_sender, + self, + self.data.session_id, + ) + .await + .unwrap_or(true) => + { + let address_lcase = self.data.mail_from.as_ref().unwrap().address_lcase.as_str(); + if authenticated_as != address_lcase + && !self + .authenticated_emails() + .iter() + .any(|e| e == address_lcase) + { + trc::event!( + Smtp(SmtpEvent::MailFromUnauthorized), + SpanId = self.data.session_id, + From = address_lcase.to_string(), + Details = [trc::Value::String(authenticated_as.into())] + .into_iter() + .chain( + self.authenticated_emails() + .iter() + .map(|e| trc::Value::String(e.into())) + ) + .collect::>() + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 You are not allowed to send from this address.\r\n") + .await; + } + } + _ => (), + } + + // Validate parameters + let config = &self.server.core.smtp.session.extensions; + let config_data = &self.server.core.smtp.session.data; + if (from.flags & MAIL_REQUIRETLS) != 0 + && !self + .server + .eval_if(&config.requiretls, self, self.data.session_id) + .await + .unwrap_or(false) + { + trc::event!( + Smtp(SmtpEvent::RequireTlsDisabled), + SpanId = self.data.session_id, + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 REQUIRETLS has been disabled.\r\n") + .await; + } + if (from.flags & (MAIL_BY_NOTIFY | MAIL_BY_RETURN)) != 0 { + if let Some(duration) = self + .server + .eval_if::(&config.deliver_by, self, self.data.session_id) + .await + { + if from.by.checked_abs().unwrap_or(0) as u64 <= duration.as_secs() + && (from.by.is_positive() || (from.flags & MAIL_BY_NOTIFY) != 0) + { + self.data.delivery_by = from.by; + } else { + self.data.mail_from = None; + + trc::event!( + Smtp(SmtpEvent::DeliverByInvalid), + SpanId = self.data.session_id, + Details = from.by, + ); + + return self + .write( + format!( + "501 5.5.4 BY parameter exceeds maximum of {} seconds.\r\n", + duration.as_secs() + ) + .as_bytes(), + ) + .await; + } + } else { + trc::event!( + Smtp(SmtpEvent::DeliverByDisabled), + SpanId = self.data.session_id, + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 DELIVERBY extension has been disabled.\r\n") + .await; + } + } + if from.mt_priority != 0 { + if self + .server + .eval_if::(&config.mt_priority, self, self.data.session_id) + .await + .is_some() + { + if (-6..6).contains(&from.mt_priority) { + self.data.priority = from.mt_priority as i16; + } else { + trc::event!( + Smtp(SmtpEvent::MtPriorityInvalid), + SpanId = self.data.session_id, + Details = from.mt_priority, + ); + self.data.mail_from = None; + return self.write(b"501 5.5.4 Invalid priority value.\r\n").await; + } + } else { + trc::event!( + Smtp(SmtpEvent::MtPriorityDisabled), + SpanId = self.data.session_id, + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 MT-PRIORITY extension has been disabled.\r\n") + .await; + } + } + if from.size > 0 { + let max_message_size = self + .server + .eval_if::(&config_data.max_message_size, self, self.data.session_id) + .await + .unwrap_or(25 * 1024 * 1024); + + if max_message_size > 0 && from.size > max_message_size { + trc::event!( + Smtp(SmtpEvent::MessageTooLarge), + SpanId = self.data.session_id, + Size = from.size, + Limit = max_message_size, + ); + + self.data.mail_from = None; + return self + .write(b"552 5.3.4 Message too big for system.\r\n") + .await; + } + } + + if from.hold_for != 0 || from.hold_until != 0 { + if from.hold_for != 0 && from.hold_until != 0 { + trc::event!( + Smtp(SmtpEvent::FutureReleaseInvalid), + SpanId = self.data.session_id, + Details = "Both HOLDFOR and HOLDUNTIL were specified", + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 Only one of HOLDFOR or HOLDUNTIL may be specified.\r\n") + .await; + } + if let Some(max_hold) = self + .server + .eval_if::(&config.future_release, self, self.data.session_id) + .await + { + let max_hold = max_hold.as_secs(); + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let hold_for = if from.hold_for != 0 { + from.hold_for + } else if from.hold_until > now { + from.hold_until - now + } else { + trc::event!( + Smtp(SmtpEvent::FutureReleaseInvalid), + SpanId = self.data.session_id, + Details = from.hold_until, + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 HOLDUNTIL must be a date and time in the future.\r\n") + .await; + }; + if hold_for <= max_hold { + self.data.future_release = hold_for; + } else { + trc::event!( + Smtp(SmtpEvent::FutureReleaseInvalid), + SpanId = self.data.session_id, + Details = hold_for, + ); + self.data.mail_from = None; + let response = if from.hold_for != 0 { + format!( + "501 5.5.4 Requested hold time exceeds maximum of {max_hold} seconds.\r\n" + ) + } else { + format!( + "501 5.5.4 Requested release time exceeds maximum of {}.\r\n", + DateTime::from_timestamp((now + max_hold) as i64).to_rfc3339() + ) + }; + return self.write(response.as_bytes()).await; + } + } else { + trc::event!( + Smtp(SmtpEvent::FutureReleaseDisabled), + SpanId = self.data.session_id, + ); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 FUTURERELEASE extension has been disabled.\r\n") + .await; + } + } + if has_dsn + && !self + .server + .eval_if(&config.dsn, self, self.data.session_id) + .await + .unwrap_or(false) + { + trc::event!(Smtp(SmtpEvent::DsnDisabled), SpanId = self.data.session_id,); + self.data.mail_from = None; + return self + .write(b"501 5.5.4 DSN extension has been disabled.\r\n") + .await; + } + + if self.is_allowed().await { + // Verify SPF + if self.params.spf_mail_from.verify() { + let time = Instant::now(); + let mail_from = self.data.mail_from.as_ref().unwrap(); + let spf_output = if !mail_from.address.is_empty() { + self.server + .core + .smtp + .resolvers + .dns + .check_host(self.server.inner.cache.build_auth_parameters( + SpfParameters::new( + self.data.remote_ip, + &mail_from.domain, + &self.data.helo_domain, + &self.hostname, + &mail_from.address_lcase, + ), + )) + .await + } else { + self.server + .core + .smtp + .resolvers + .dns + .check_host(self.server.inner.cache.build_auth_parameters( + SpfParameters::new( + self.data.remote_ip, + &self.data.helo_domain, + &self.data.helo_domain, + &self.hostname, + &format!("postmaster@{}", self.data.helo_domain), + ), + )) + .await + }; + + trc::event!( + Smtp(if matches!(spf_output.result(), SpfResult::Pass) { + SmtpEvent::SpfFromPass + } else { + SmtpEvent::SpfFromFail + }), + SpanId = self.data.session_id, + Domain = self.data.helo_domain.clone(), + From = if !mail_from.address.is_empty() { + mail_from.address.as_str() + } else { + "<>" + } + .to_string(), + Result = trc::Error::from(&spf_output), + Elapsed = time.elapsed(), + ); + + if self + .handle_spf(&spf_output, self.params.spf_mail_from.is_strict()) + .await? + { + self.data.spf_mail_from = spf_output.into(); + } else { + self.data.mail_from = None; + return Ok(()); + } + } + + trc::event!( + Smtp(SmtpEvent::MailFrom), + SpanId = self.data.session_id, + From = self.data.mail_from.as_ref().unwrap().address_lcase.clone(), + ); + + self.eval_rcpt_params().await; + self.write(b"250 2.1.0 OK\r\n").await + } else { + trc::event!( + Smtp(SmtpEvent::RateLimitExceeded), + SpanId = self.data.session_id, + From = self.data.mail_from.as_ref().unwrap().address_lcase.clone(), + ); + + self.data.mail_from = None; + self.write(b"452 4.4.5 Rate limit exceeded, try again later.\r\n") + .await + } + } + + pub async fn handle_spf(&mut self, spf_output: &SpfOutput, strict: bool) -> Result { + let result = match spf_output.result() { + SpfResult::Pass => true, + SpfResult::TempError if strict => { + self.write(b"451 4.7.24 Temporary SPF validation error.\r\n") + .await?; + false + } + result => { + if strict { + self.write( + format!("550 5.7.23 SPF validation failed, status: {result}.\r\n") + .as_bytes(), + ) + .await?; + false + } else { + true + } + } + }; + + // Send report + if let (Some(recipient), Some(rate)) = ( + spf_output.report_address(), + self.server + .eval_if::( + &self.server.core.smtp.report.spf.send, + self, + self.data.session_id, + ) + .await, + ) { + // Do not send SPF auth failures to local domains, as they are likely relay attempts (which are blocked later on) + match self.server.domain(recipient.domain_part()).await { + Ok(Some(_)) => return Ok(result), + Ok(None) => (), + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .span_id(self.data.session_id) + .details("Failed to lookup local domain") + ); + } + } + + self.send_spf_report(recipient, &rate, !result, spf_output) + .await; + } + + Ok(result) + } +} diff --git a/crates/smtp/src/inbound/milter/client.rs b/crates/smtp/src/inbound/milter/client.rs new file mode 100644 index 0000000..fb86cda --- /dev/null +++ b/crates/smtp/src/inbound/milter/client.rs @@ -0,0 +1,368 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::session::Milter; +use rustls_pki_types::ServerName; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::TcpStream, +}; +use tokio_rustls::{TlsConnector, client::TlsStream}; +use trc::MilterEvent; + +use super::{ + protocol::{SMFIC_CONNECT, SMFIC_HELO, SMFIC_MAIL, SMFIC_RCPT}, + receiver::{FrameResult, Receiver}, + *, +}; + +const MILTER_CHUNK_SIZE: usize = 65535; + +impl MilterClient { + pub async fn connect(config: &Milter, session_id: u64) -> Result { + tokio::time::timeout(config.timeout_command, async { + let mut last_err = Error::Disconnected; + for addr in &config.addrs { + match TcpStream::connect(addr).await { + Ok(stream) => { + return Ok(MilterClient { + stream, + timeout_cmd: config.timeout_command, + timeout_data: config.timeout_data, + buf: vec![0u8; 8192], + bytes_read: 0, + receiver: Receiver::with_max_frame_len(config.max_frame_len), + options: 0, + version: config.protocol_version, + session_id, + flags_actions: config.flags_actions.unwrap_or( + SMFIF_ADDHDRS + | SMFIF_CHGBODY + | SMFIF_ADDRCPT + | SMFIF_DELRCPT + | SMFIF_CHGHDRS + | SMFIF_QUARANTINE + | SMFIF_CHGFROM + | SMFIF_ADDRCPT_PAR, + ), + flags_protocol: config.flags_protocol.unwrap_or(0x42), + id: config.id, + }); + } + Err(err) => { + last_err = Error::Io(err); + } + } + } + Err(last_err) + }) + .await + .map_err(|_| Error::Timeout)? + } + + pub async fn into_tls( + self, + tls_connector: &TlsConnector, + tls_hostname: &str, + ) -> Result>> { + tokio::time::timeout(self.timeout_cmd, async { + Ok(MilterClient { + stream: tls_connector + .connect( + ServerName::try_from(tls_hostname) + .map_err(|_| Error::TLSInvalidName)? + .to_owned(), + self.stream, + ) + .await?, + buf: self.buf, + timeout_cmd: self.timeout_cmd, + timeout_data: self.timeout_data, + receiver: self.receiver, + bytes_read: self.bytes_read, + options: self.options, + version: self.version, + session_id: self.session_id, + flags_actions: self.flags_actions, + flags_protocol: self.flags_protocol, + id: self.id, + }) + }) + .await + .map_err(|_| Error::Timeout)? + } +} + +impl MilterClient { + pub async fn init(&mut self) -> super::Result { + self.write(Command::OptionNegotiation(Options { + version: match self.version { + MilterVersion::V2 => 2, + MilterVersion::V6 => 6, + }, + actions: self.flags_actions, + protocol: self.flags_protocol, + })) + .await?; + match self.read().await? { + Response::OptionNegotiation(options) => { + self.options = options.protocol; + Ok(options) + } + response => Err(Error::Unexpected(response)), + } + } + + pub async fn connection( + &mut self, + hostname: impl AsRef<[u8]>, + remote_ip: IpAddr, + remote_port: u16, + macros: Macros<'_>, + ) -> super::Result { + if !self.has_option(SMFIP_NOCONNECT) { + self.write(Command::Macro { + macros: macros.with_cmd_code(SMFIC_CONNECT), + }) + .await?; + self.write(Command::Connect { + hostname: hostname.as_ref(), + port: remote_port, + address: remote_ip, + }) + .await?; + if !self.has_option(SMFIP_NR_CONN) { + return self.read().await?.into_action(); + } + } + + Ok(Action::Accept) + } + + pub async fn helo( + &mut self, + hostname: impl AsRef<[u8]>, + macros: Macros<'_>, + ) -> super::Result { + if !self.has_option(SMFIP_NOHELO) { + self.write(Command::Macro { + macros: macros.with_cmd_code(SMFIC_HELO), + }) + .await?; + self.write(Command::Helo { + hostname: hostname.as_ref(), + }) + .await?; + if !self.has_option(SMFIP_NR_HELO) { + return self.read().await?.into_action(); + } + } + Ok(Action::Accept) + } + + pub async fn mail_from( + &mut self, + addr: A, + params: Option<&[V]>, + macros: Macros<'_>, + ) -> super::Result + where + A: AsRef<[u8]>, + V: AsRef<[u8]>, + { + if !self.has_option(SMFIP_NOMAIL) { + self.write(Command::Macro { + macros: macros.with_cmd_code(SMFIC_MAIL), + }) + .await?; + self.write(Command::MailFrom { + sender: addr.as_ref(), + args: params.map(|params| params.iter().map(|value| value.as_ref()).collect()), + }) + .await?; + if !self.has_option(SMFIP_NR_MAIL) { + return self.read().await?.into_action(); + } + } + Ok(Action::Accept) + } + + pub async fn rcpt_to( + &mut self, + addr: A, + params: Option<&[V]>, + macros: Macros<'_>, + ) -> super::Result + where + A: AsRef<[u8]>, + V: AsRef<[u8]>, + { + if !self.has_option(SMFIP_NORCPT) { + self.write(Command::Macro { + macros: macros.with_cmd_code(SMFIC_RCPT), + }) + .await?; + self.write(Command::Rcpt { + recipient: addr.as_ref(), + args: params.map(|params| params.iter().map(|value| value.as_ref()).collect()), + }) + .await?; + if !self.has_option(SMFIP_NR_RCPT) { + return self.read().await?.into_action(); + } + } + Ok(Action::Accept) + } + + pub async fn headers(&mut self, headers: I) -> super::Result + where + I: Iterator, + H: AsRef, + V: AsRef, + { + if !self.has_option(SMFIP_NOHDRS) { + for (name, value) in headers { + self.write(Command::Header { + name: name.as_ref().trim().as_bytes(), + value: value.as_ref().trim().as_bytes(), + }) + .await?; + if !self.has_option(SMFIP_NR_HDR) { + match self.read().await? { + Response::Action(Action::Accept | Action::Continue) => (), + Response::Action(action) => return Ok(action), + response => return Err(Error::Unexpected(response)), + } + } + } + + // Write EndOfHeaders + self.write(Command::EndOfHeader).await?; + if !self.has_option(SMFIP_NR_EOH) { + return self.read().await?.into_action(); + } + } + Ok(Action::Accept) + } + + pub async fn data(&mut self) -> super::Result { + if matches!(self.version, MilterVersion::V6) && !self.has_option(SMFIP_NODATA) { + self.write(Command::Data).await?; + if !self.has_option(SMFIP_NR_DATA) { + return self.read().await?.into_action(); + } + } + Ok(Action::Accept) + } + + pub async fn body(&mut self, body: &[u8]) -> super::Result<(Action, Vec)> { + if !self.has_option(SMFIP_NOBODY) { + // Write body chunks + for value in body.chunks(MILTER_CHUNK_SIZE) { + self.write(Command::Body { value }).await?; + if !self.has_option(SMFIP_NR_BODY) { + match self.read().await? { + Response::Action(Action::Accept | Action::Continue) + | Response::Progress => (), + Response::Skip => break, + Response::Action(reject) => { + return Ok((reject, Vec::new())); + } + response => return Err(Error::Unexpected(response)), + } + } + } + + // Write EndOfBody + self.write(Command::EndOfBody).await?; + + // Collect responses + let mut modifications = Vec::new(); + loop { + match self.read().await? { + Response::Action(action) => { + return Ok((action, modifications)); + } + Response::Modification(modification) => { + modifications.push(modification); + } + Response::Progress => (), + unexpected => { + return Err(Error::Unexpected(unexpected)); + } + } + } + } else { + Ok((Action::Accept, vec![])) + } + } + + pub async fn abort(&mut self) -> super::Result<()> { + self.write(Command::Abort).await + } + + pub async fn quit(&mut self) -> super::Result<()> { + self.write(Command::Quit).await + } + + async fn write(&mut self, action: Command<'_>) -> super::Result<()> { + trc::event!( + Milter(MilterEvent::Write), + SpanId = self.session_id, + Id = self.id.to_string(), + Contents = action.to_string(), + ); + + tokio::time::timeout(self.timeout_cmd, async { + self.stream.write_all(action.serialize().as_ref()).await?; + self.stream.flush().await.map_err(Error::Io) + }) + .await + .map_err(|_| Error::Timeout)? + } + + async fn read(&mut self) -> super::Result { + loop { + match self.receiver.read_frame(&self.buf[..self.bytes_read]) { + FrameResult::Frame(frame) => { + if let Some(response) = Response::deserialize(&frame) { + trc::event!( + Milter(MilterEvent::Read), + SpanId = self.session_id, + Id = self.id.to_string(), + Contents = response.to_string(), + ); + + return Ok(response); + } else { + return Err(Error::FrameInvalid(frame.into_owned())); + } + } + FrameResult::Incomplete => { + self.bytes_read = tokio::time::timeout(self.timeout_data, async { + self.stream.read(&mut self.buf).await.map_err(Error::Io) + }) + .await + .map_err(|_| Error::Timeout)??; + if self.bytes_read == 0 { + return Err(Error::Disconnected); + } + } + FrameResult::TooLarge(size) => return Err(Error::FrameTooLarge(size)), + } + } + } + + #[inline(always)] + fn has_option(&self, opt: u32) -> bool { + self.options & opt == opt + } + + pub fn with_version(mut self, version: MilterVersion) -> Self { + self.version = version; + self + } +} diff --git a/crates/smtp/src/inbound/milter/macros.rs b/crates/smtp/src/inbound/milter/macros.rs new file mode 100644 index 0000000..4d5d90b --- /dev/null +++ b/crates/smtp/src/inbound/milter/macros.rs @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, net::IpAddr}; + +use super::{Macro, Macros}; + +pub trait IntoMacroValue<'x> { + fn into_macro_value(self) -> Cow<'x, [u8]>; +} + +impl<'x> Macros<'x> { + pub fn new() -> Self { + Macros::default() + } + + pub fn with_cmd_code(mut self, cmd_code: u8) -> Self { + self.cmdcode = cmd_code; + self + } + + pub fn with_macro(mut self, name: &'static [u8], value: impl IntoMacroValue<'x>) -> Self { + self.macros.push(Macro { + name, + value: value.into_macro_value(), + }); + self + } + + pub fn with_queue_id(self, queue_id: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"i", queue_id) + } + + pub fn with_local_hostname(self, my_hostname: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"j", my_hostname) + } + + pub fn with_validated_client_name(self, client_name: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"_", client_name) + } + + pub fn with_sasl_login_name(self, sasl_login_name: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{auth_authen}", sasl_login_name) + } + + pub fn with_sasl_sender(self, sasl_sender: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{auth_author}", sasl_sender) + } + + pub fn with_sasl_method(self, sasl_method: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{auth_type}", sasl_method) + } + + pub fn with_client_address(self, client_address: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{client_addr}", client_address) + } + + pub fn with_client_connections(self, client_connections: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{client_connections}", client_connections) + } + + pub fn with_client_name(self, client_name: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{client_name}", client_name) + } + + pub fn with_client_port(self, client_port: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{client_port}", client_port) + } + + pub fn with_client_ptr(self, client_ptr: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{client_ptr}", client_ptr) + } + + pub fn with_cert_issuer(self, cert_issuer: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{cert_issuer}", cert_issuer) + } + + pub fn with_cert_subject(self, cert_subject: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{cert_subject}", cert_subject) + } + + pub fn with_cipher_bits(self, cipher_bits: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{cipher_bits}", cipher_bits) + } + + pub fn with_cipher(self, cipher: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{cipher}", cipher) + } + + pub fn with_daemon_address(self, daemon_address: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{daemon_addr}", daemon_address) + } + + pub fn with_daemon_name(self, daemon_name: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{daemon_name}", daemon_name) + } + + pub fn with_daemon_port(self, daemon_port: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{daemon_port}", daemon_port) + } + + pub fn with_mail_address(self, mail_address: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{mail_addr}", mail_address) + } + + pub fn with_mail_host(self, mail_host_address: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{mail_host}", mail_host_address) + } + + pub fn with_mail_mailer(self, mail_mailer: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{mail_mailer}", mail_mailer) + } + + pub fn with_rcpt_address(self, rcpt_address: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{rcpt_addr}", rcpt_address) + } + + pub fn with_rcpt_host(self, rcpt_host: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{rcpt_host}", rcpt_host) + } + + pub fn with_rcpt_mailer(self, rcpt_mailer: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{rcpt_mailer}", rcpt_mailer) + } + + pub fn with_tls_version(self, tls_version: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{tls_version}", tls_version) + } + + pub fn with_version(self, version: impl IntoMacroValue<'x>) -> Self { + self.with_macro(b"{v}", version) + } +} + +impl<'x> IntoMacroValue<'x> for IpAddr { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Owned(self.to_string().into_bytes()) + } +} + +impl<'x> IntoMacroValue<'x> for u16 { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Owned(self.to_string().into_bytes()) + } +} + +impl<'x> IntoMacroValue<'x> for &'x [u8] { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Borrowed(self) + } +} + +impl<'x> IntoMacroValue<'x> for &'x str { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Borrowed(self.as_bytes()) + } +} + +impl<'x> IntoMacroValue<'x> for &'x String { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Borrowed(self.as_bytes()) + } +} + +impl<'x> IntoMacroValue<'x> for String { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Owned(self.into_bytes()) + } +} + +impl<'x> IntoMacroValue<'x> for Vec { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Owned(self) + } +} + +impl<'x> IntoMacroValue<'x> for &'x Vec { + fn into_macro_value(self) -> Cow<'x, [u8]> { + Cow::Borrowed(self) + } +} diff --git a/crates/smtp/src/inbound/milter/message.rs b/crates/smtp/src/inbound/milter/message.rs new file mode 100644 index 0000000..e910c2b --- /dev/null +++ b/crates/smtp/src/inbound/milter/message.rs @@ -0,0 +1,550 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Action, Error, Macros, Modification}; +use crate::{ + core::{Session, SessionAddress, SessionData}, + inbound::{FilterResponse, milter::MilterClient}, + queue::QueueId, +}; +use common::{ + DAEMON_NAME, + config::smtp::session::{Milter, Stage}, + network::SessionStream, +}; +use mail_auth::AuthenticatedMessage; +use smtp_proto::{IntoString, request::parser::Rfc5321Parser}; +use std::{borrow::Cow, time::Instant}; +use tokio::io::{AsyncRead, AsyncWrite}; +use trc::MilterEvent; +use utils::DomainPart; + +enum Rejection { + Action(Action), + Error(Error), +} + +impl Session { + pub async fn run_milters( + &self, + stage: Stage, + message: Option<&AuthenticatedMessage<'_>>, + queue_id: Option, + ) -> Result, FilterResponse> { + let milters = &self.server.core.smtp.session.milters; + if milters.is_empty() { + return Ok(Vec::new()); + } + + let mut modifications = Vec::new(); + for milter in milters { + if !milter.run_on_stage.contains(&stage) + || !self + .server + .eval_if(&milter.enable, self, self.data.session_id) + .await + .unwrap_or(false) + { + continue; + } + + let time = Instant::now(); + match self.connect_and_run(milter, message).await { + Ok(new_modifications) => { + trc::event!( + Milter(MilterEvent::ActionAccept), + SpanId = self.data.session_id, + Id = milter.id.to_string(), + Elapsed = time.elapsed(), + ); + + if !modifications.is_empty() { + // The message body can only be replaced once, so we need to remove + // any previous replacements. + if new_modifications + .iter() + .any(|m| matches!(m, Modification::ReplaceBody { .. })) + { + modifications + .retain(|m| !matches!(m, Modification::ReplaceBody { .. })); + } + modifications.extend(new_modifications); + } else { + modifications = new_modifications; + } + } + Err(Rejection::Action(action)) => { + trc::event!( + Milter(match &action { + Action::Discard => MilterEvent::ActionDiscard, + Action::Reject => MilterEvent::ActionReject, + Action::TempFail => MilterEvent::ActionTempFail, + Action::ReplyCode { .. } => { + MilterEvent::ActionReplyCode + } + Action::Shutdown => MilterEvent::ActionShutdown, + Action::ConnectionFailure => MilterEvent::ActionConnectionFailure, + Action::Accept | Action::Continue => unreachable!(), + }), + SpanId = self.data.session_id, + QueueId = queue_id, + Id = milter.id.to_string(), + Elapsed = time.elapsed(), + ); + + return Err(match action { + Action::Discard => FilterResponse::accept(), + Action::Reject => FilterResponse::reject(), + Action::TempFail => FilterResponse::temp_fail(), + Action::ReplyCode { code, text } => { + let mut response = Vec::with_capacity(text.len() + 6); + response.extend_from_slice(code.as_slice()); + response.push(b' '); + response.extend_from_slice(text.as_bytes()); + if !text.ends_with('\n') { + response.extend_from_slice(b"\r\n"); + } + FilterResponse { + message: response.into_string().into(), + disconnect: false, + } + } + Action::Shutdown => FilterResponse::shutdown(), + Action::ConnectionFailure => FilterResponse::default().disconnect(), + Action::Accept | Action::Continue => unreachable!(), + }); + } + Err(Rejection::Error(err)) => { + let (code, details) = match err { + Error::Io(details) => { + (MilterEvent::IoError, trc::Value::from(details.to_string())) + } + Error::FrameTooLarge(size) => { + (MilterEvent::FrameTooLarge, trc::Value::from(size)) + } + Error::FrameInvalid(bytes) => { + (MilterEvent::FrameInvalid, trc::Value::from(bytes)) + } + Error::Unexpected(response) => ( + MilterEvent::UnexpectedResponse, + trc::Value::from(response.to_string()), + ), + Error::Timeout => (MilterEvent::Timeout, trc::Value::None), + Error::TLSInvalidName => (MilterEvent::TlsInvalidName, trc::Value::None), + Error::Disconnected => (MilterEvent::Disconnected, trc::Value::None), + }; + + trc::event!( + Milter(code), + SpanId = self.data.session_id, + Id = milter.id.to_string(), + Details = details, + Elapsed = time.elapsed(), + ); + + if milter.tempfail_on_error { + return Err(FilterResponse::server_failure()); + } + } + } + } + + Ok(modifications) + } + + async fn connect_and_run( + &self, + milter: &Milter, + message: Option<&AuthenticatedMessage<'_>>, + ) -> Result, Rejection> { + // Build client + let client = MilterClient::connect(milter, self.data.session_id).await?; + if !milter.tls { + self.run(client, message).await + } else { + self.run( + client + .into_tls( + if !milter.tls_allow_invalid_certs { + &self.server.inner.data.smtp_connectors.pki_verify + } else { + &self.server.inner.data.smtp_connectors.dummy_verify + }, + &milter.hostname, + ) + .await?, + message, + ) + .await + } + } + + async fn run( + &self, + mut client: MilterClient, + message: Option<&AuthenticatedMessage<'_>>, + ) -> Result, Rejection> { + // Option negotiation + client.init().await?; + + // Connect stage + let client_ptr = self + .data + .iprev + .as_ref() + .and_then(|ip_rev| ip_rev.ptr.as_ref()) + .and_then(|ptrs| ptrs.first()) + .map(|s| s.as_ref()); + client + .connection( + client_ptr.unwrap_or(self.data.helo_domain.as_str()), + self.data.remote_ip, + self.data.remote_port, + Macros::new() + .with_daemon_name(DAEMON_NAME) + .with_local_hostname(&self.hostname) + .with_client_address(self.data.remote_ip) + .with_client_port(self.data.remote_port) + .with_client_ptr(client_ptr.unwrap_or("unknown")), + ) + .await? + .assert_continue()?; + + // EHLO/HELO + let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher(); + client + .helo( + &self.data.helo_domain, + Macros::new() + .with_cipher(tls_cipher.as_ref()) + .with_tls_version(tls_version.as_ref()), + ) + .await? + .assert_continue()?; + + // Mail from + if let Some(mail_from) = &self.data.mail_from { + let addr = &mail_from.address_lcase; + client + .mail_from( + &format!("<{addr}>"), + None::<&[&str]>, + if let Some(name) = self.authenticated_as() { + Macros::new() + .with_mail_address(addr) + .with_sasl_login_name(name) + } else { + Macros::new().with_mail_address(addr) + }, + ) + .await? + .assert_continue()?; + + // Rcpt to + for rcpt in &self.data.rcpt_to { + client + .rcpt_to( + &format!("<{}>", rcpt.address_lcase), + None::<&[&str]>, + Macros::new().with_rcpt_address(&rcpt.address_lcase), + ) + .await? + .assert_continue()?; + } + } + + if let Some(message) = message { + // Data + client.data().await?.assert_continue()?; + + // Headers + client + .headers(message.raw_parsed_headers().iter().map(|(k, v)| { + ( + std::str::from_utf8(k).unwrap_or_default(), + std::str::from_utf8(v).unwrap_or_default(), + ) + })) + .await? + .assert_continue()?; + + // Message body + let (action, modifications) = client.body(message.raw_message()).await?; + action.assert_continue()?; + + // Quit + let _ = client.quit().await; + + // Return modifications + Ok(modifications) + } else { + // Quit + let _ = client.quit().await; + + Ok(Vec::new()) + } + } +} + +impl SessionData { + pub fn apply_milter_modifications( + &mut self, + modifications: Vec, + message: &AuthenticatedMessage<'_>, + ) -> Option> { + let mut body = Vec::new(); + let mut header_changes = Vec::new(); + let mut needs_rewrite = false; + + for modification in modifications { + match modification { + Modification::ChangeFrom { sender, mut args } => { + // Change sender + let sender = strip_brackets(&sender); + let address_lcase = sender.to_lowercase(); + let mut mail_from = SessionAddress { + domain: address_lcase.domain_part().into(), + address_lcase, + address: sender, + flags: 0, + dsn_info: None, + }; + if !args.is_empty() { + args.push('\n'); + match Rfc5321Parser::new(&mut args.as_bytes().iter()) + .mail_from_parameters(Cow::Borrowed("")) + { + Ok(addr) => { + mail_from.flags = addr.flags; + mail_from.dsn_info = addr.env_id.map(|e| e.into_owned()); + } + Err(err) => { + trc::event!( + Milter(MilterEvent::ParseError), + SpanId = self.session_id, + Details = "Failed to parse milter mailFrom parameters", + Reason = err.to_string(), + ); + } + } + } + self.mail_from = Some(mail_from); + } + Modification::AddRcpt { + recipient, + mut args, + } => { + // Add recipient + let recipient = strip_brackets(&recipient); + if recipient.contains('@') { + let address_lcase = recipient.to_lowercase(); + let mut rcpt = SessionAddress { + domain: address_lcase.domain_part().into(), + address_lcase, + address: recipient, + flags: 0, + dsn_info: None, + }; + if !args.is_empty() { + args.push('\n'); + match Rfc5321Parser::new(&mut args.as_bytes().iter()) + .rcpt_to_parameters(Cow::Borrowed("")) + { + Ok(addr) => { + rcpt.flags = addr.flags; + rcpt.dsn_info = addr.orcpt.map(|e| e.into_owned()); + } + Err(err) => { + trc::event!( + Milter(MilterEvent::ParseError), + SpanId = self.session_id, + Details = "Failed to parse milter rcptTo parameters", + Reason = err.to_string(), + ); + } + } + } + + if !self.rcpt_to.contains(&rcpt) { + self.rcpt_to.push(rcpt); + } + } + } + Modification::DeleteRcpt { recipient } => { + let recipient = strip_brackets(&recipient); + self.rcpt_to.retain(|r| r.address_lcase != recipient); + } + Modification::ReplaceBody { value } => { + body.extend(value); + } + Modification::AddHeader { name, value } => { + header_changes.push((0, name, value, false)); + } + Modification::InsertHeader { index, name, value } => { + header_changes.push((index, name, value, false)); + needs_rewrite = true; + } + Modification::ChangeHeader { index, name, value } => { + if value.is_empty() + || message + .raw_parsed_headers() + .iter() + .any(|(n, _)| n.eq_ignore_ascii_case(name.as_bytes())) + { + header_changes.push((index, name, value, true)); + needs_rewrite = true; + } else { + header_changes.push((0, name, value, false)); + } + } + Modification::Quarantine { reason } => { + header_changes.push((0, "X-Quarantine".into(), reason, false)); + } + } + } + + // If there are no header changes return + if header_changes.is_empty() { + return if !body.is_empty() { + let mut new_message = Vec::with_capacity(body.len() + message.raw_headers().len()); + new_message.extend_from_slice(message.raw_headers()); + new_message.extend(body); + Some(new_message) + } else { + None + }; + } + + let new_body = if !body.is_empty() { + &body[..] + } else { + message.raw_body() + }; + + if needs_rewrite { + let mut headers = message + .raw_parsed_headers() + .iter() + .map(|(h, v)| (Cow::from(*h), Cow::from(*v))) + .collect::>(); + + // Perform changes + for (index, header_name, header_value, is_change) in header_changes { + if is_change { + let mut header_count = 0; + for (pos, (name, value)) in headers.iter_mut().enumerate() { + if name.eq_ignore_ascii_case(header_name.as_bytes()) { + header_count += 1; + if header_count == index { + if !header_value.is_empty() { + *value = Cow::from(header_value.as_bytes().to_vec()); + } else { + headers.remove(pos); + } + break; + } + } + } + } else { + let mut header_pos = 0; + if index > 0 { + let mut header_count = 0; + for (pos, (name, _)) in headers.iter().enumerate() { + if name.eq_ignore_ascii_case(header_name.as_bytes()) { + header_pos = pos; + header_count += 1; + if header_count == index { + break; + } + } + } + } + + headers.insert( + header_pos, + ( + Cow::from(header_name.as_bytes().to_vec()), + Cow::from(header_value.as_bytes().to_vec()), + ), + ); + } + } + + // Write new headers + let mut new_message = Vec::with_capacity( + new_body.len() + + message.raw_headers().len() + + headers + .iter() + .map(|(h, v)| h.len() + v.len() + 4) + .sum::(), + ); + for (header, value) in headers { + new_message.extend_from_slice(header.as_ref()); + if value.first().is_some_and(|c| c.is_ascii_whitespace()) { + new_message.extend_from_slice(b":"); + } else { + new_message.extend_from_slice(b": "); + } + new_message.extend_from_slice(value.as_ref()); + if value.last().is_none_or(|c| *c != b'\n') { + new_message.extend_from_slice(b"\r\n"); + } + } + new_message.extend_from_slice(b"\r\n"); + new_message.extend(new_body); + Some(new_message) + } else { + let mut new_message = Vec::with_capacity( + new_body.len() + + message.raw_headers().len() + + header_changes + .iter() + .map(|(_, h, v, _)| h.len() + v.len() + 4) + .sum::(), + ); + for (_, header, value, _) in header_changes { + new_message.extend_from_slice(header.as_bytes()); + new_message.extend_from_slice(b": "); + new_message.extend_from_slice(value.as_bytes()); + if !value.ends_with('\n') { + new_message.extend_from_slice(b"\r\n"); + } + } + new_message.extend_from_slice(message.raw_headers()); + new_message.extend(new_body); + Some(new_message) + } + } +} + +impl Action { + fn assert_continue(self) -> Result<(), Rejection> { + match self { + Action::Continue | Action::Accept => Ok(()), + action => Err(Rejection::Action(action)), + } + } +} + +impl From for Rejection { + fn from(err: Error) -> Self { + Rejection::Error(err) + } +} + +fn strip_brackets(addr: &str) -> String { + let addr = addr.trim(); + if let Some(addr) = addr.strip_prefix('<') { + if let Some((addr, _)) = addr.rsplit_once('>') { + addr.trim().into() + } else { + addr.trim().into() + } + } else { + addr.into() + } +} diff --git a/crates/smtp/src/inbound/milter/mod.rs b/crates/smtp/src/inbound/milter/mod.rs new file mode 100644 index 0000000..342fb81 --- /dev/null +++ b/crates/smtp/src/inbound/milter/mod.rs @@ -0,0 +1,488 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use self::receiver::Receiver; +use common::config::smtp::session::MilterVersion; +use registry::types::id::ObjectId; +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, fmt::Display, net::IpAddr, time::Duration}; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub mod client; +pub mod macros; +pub mod message; +pub mod protocol; +pub mod receiver; + +pub struct MilterClient { + stream: T, + buf: Vec, + bytes_read: usize, + timeout_cmd: Duration, + timeout_data: Duration, + receiver: Receiver, + version: MilterVersion, + options: u32, + flags_actions: u32, + flags_protocol: u32, + id: ObjectId, + session_id: u64, +} + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + FrameTooLarge(usize), + FrameInvalid(Vec), + Unexpected(Response), + Timeout, + TLSInvalidName, + Disconnected, +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(err) + } +} + +pub enum Command<'x> { + Abort, + Body { + value: &'x [u8], + }, + EndOfBody, + Data, + Connect { + hostname: &'x [u8], + port: u16, + address: IpAddr, + }, + Macro { + macros: Macros<'x>, + }, + Header { + name: &'x [u8], + value: &'x [u8], + }, + EndOfHeader, + Helo { + hostname: &'x [u8], + }, + MailFrom { + sender: &'x [u8], + args: Option>, + }, + Rcpt { + recipient: &'x [u8], + args: Option>, + }, + OptionNegotiation(Options), + Quit, + QuitNewConnection, +} + +#[derive(Debug)] +pub enum Response { + Action(Action), + Modification(Modification), + Progress, + Skip, + SetSymbols, + OptionNegotiation(Options), +} + +#[derive(Debug)] +pub enum Action { + Accept, + Continue, + Discard, + Reject, + TempFail, + ReplyCode { code: [u8; 3], text: String }, + Shutdown, + ConnectionFailure, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Modification { + ChangeFrom { + sender: String, + args: String, + }, + AddRcpt { + recipient: String, + args: String, + }, + DeleteRcpt { + recipient: String, + }, + ReplaceBody { + value: Vec, + }, + AddHeader { + name: String, + value: String, + }, + InsertHeader { + index: u32, + name: String, + value: String, + }, + ChangeHeader { + index: u32, + name: String, + value: String, + }, + Quarantine { + reason: String, + }, +} + +#[derive(Debug)] +pub struct Options { + pub version: u32, + pub actions: u32, + pub protocol: u32, +} + +#[derive(Default)] +pub struct Macros<'x> { + cmdcode: u8, + macros: Vec>, +} + +pub struct Macro<'x> { + name: &'x [u8], + value: Cow<'x, [u8]>, +} + +pub const SMFIF_NONE: u32 = 0x00000000; /* no flags */ +pub const SMFIF_ADDHDRS: u32 = 0x00000001; /* filter may add headers */ +pub const SMFIF_CHGBODY: u32 = 0x00000002; /* filter may replace body */ +pub const SMFIF_MODBODY: u32 = SMFIF_CHGBODY; /* backwards compatible */ +pub const SMFIF_ADDRCPT: u32 = 0x00000004; /* filter may add recipients */ +pub const SMFIF_DELRCPT: u32 = 0x00000008; /* filter may delete recipients */ +pub const SMFIF_CHGHDRS: u32 = 0x00000010; /* filter may change/delete headers */ +pub const SMFIF_QUARANTINE: u32 = 0x00000020; /* filter may quarantine envelope */ +pub const SMFIF_CHGFROM: u32 = 0x00000040; /* filter may change "from" (envelope sender) */ +pub const SMFIF_ADDRCPT_PAR: u32 = 0x00000080; /* add recipients incl. args */ +pub const SMFIF_SETSYMLIST: u32 = 0x00000100; /* filter can send set of symbols (macros) that it wants */ + +pub const SMFIP_NOCONNECT: u32 = 0x00000001; /* MTA should not send connect info */ +pub const SMFIP_NOHELO: u32 = 0x00000002; /* MTA should not send HELO info */ +pub const SMFIP_NOMAIL: u32 = 0x00000004; /* MTA should not send MAIL info */ +pub const SMFIP_NORCPT: u32 = 0x00000008; /* MTA should not send RCPT info */ +pub const SMFIP_NOBODY: u32 = 0x00000010; /* MTA should not send body */ +pub const SMFIP_NOHDRS: u32 = 0x00000020; /* MTA should not send headers */ +pub const SMFIP_NOEOH: u32 = 0x00000040; /* MTA should not send EOH */ +pub const SMFIP_NR_HDR: u32 = 0x00000080; /* No reply for headers */ +pub const SMFIP_NOHREPL: u32 = SMFIP_NR_HDR; /* No reply for headers */ +pub const SMFIP_NOUNKNOWN: u32 = 0x00000100; /* MTA should not send unknown commands */ +pub const SMFIP_NODATA: u32 = 0x00000200; /* MTA should not send DATA */ +pub const SMFIP_SKIP: u32 = 0x00000400; /* MTA understands SMFIS_SKIP */ +pub const SMFIP_RCPT_REJ: u32 = 0x00000800; /* MTA should also send rejected RCPTs */ +pub const SMFIP_NR_CONN: u32 = 0x00001000; /* No reply for connect */ +pub const SMFIP_NR_HELO: u32 = 0x00002000; /* No reply for HELO */ +pub const SMFIP_NR_MAIL: u32 = 0x00004000; /* No reply for MAIL */ +pub const SMFIP_NR_RCPT: u32 = 0x00008000; /* No reply for RCPT */ +pub const SMFIP_NR_DATA: u32 = 0x00010000; /* No reply for DATA */ +pub const SMFIP_NR_UNKN: u32 = 0x00020000; /* No reply for UNKN */ +pub const SMFIP_NR_EOH: u32 = 0x00040000; /* No reply for eoh */ +pub const SMFIP_NR_BODY: u32 = 0x00080000; /* No reply for body chunk */ +pub const SMFIP_HDR_LEADSPC: u32 = 0x00100000; /* header value leading space */ +pub const SMFIP_MDS_256K: u32 = 0x10000000; /* MILTER_MAX_DATA_SIZE=256K */ +pub const SMFIP_MDS_1M: u32 = 0x20000000; /* MILTER_MAX_DATA_SIZE=1M */ + +pub type Result = std::result::Result; + +impl Display for Command<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::Abort => write!(f, "ABORT"), + Command::Body { value } => write!(f, "BODY [{} bytes]", value.len()), + Command::EndOfBody => write!(f, "EOB"), + Command::Connect { + hostname, + port, + address, + } => write!( + f, + "CONNECT (host: {:?}, port: {}, address: {})", + std::str::from_utf8(hostname).unwrap_or_default(), + port, + address + ), + Command::Macro { macros } => { + write!(f, "MACRO (code: {}, params: ", macros.cmdcode)?; + for macro_ in ¯os.macros { + write!( + f, + "({:?}, {:?})", + std::str::from_utf8(macro_.name).unwrap_or_default(), + std::str::from_utf8(macro_.value.as_ref()).unwrap_or_default() + )?; + } + write!(f, ")") + } + Command::Header { name, value } => { + write!( + f, + "HEADER ({}: {:?})", + std::str::from_utf8(name).unwrap_or_default(), + std::str::from_utf8(value).unwrap_or_default() + ) + } + Command::EndOfHeader => write!(f, "EOH"), + Command::Helo { hostname } => write!( + f, + "HELO {:?}", + std::str::from_utf8(hostname).unwrap_or_default() + ), + Command::MailFrom { sender, args } => { + write!( + f, + "MAIL (from: {}, params: ", + std::str::from_utf8(sender).unwrap_or_default() + )?; + if let Some(args) = args { + for arg in args { + write!(f, " {}", std::str::from_utf8(arg).unwrap_or_default())?; + } + } + write!(f, ")") + } + Command::Rcpt { recipient, args } => { + write!( + f, + "RCPT (to: {}, params: ", + std::str::from_utf8(recipient).unwrap_or_default() + )?; + if let Some(args) = args { + for arg in args { + write!(f, " {}", std::str::from_utf8(arg).unwrap_or_default())?; + } + } + write!(f, ")") + } + Command::OptionNegotiation(opt) => write!(f, "OPTNEG ({})", opt), + Command::Quit => write!(f, "QUIT"), + Command::Data => write!(f, "DATA"), + Command::QuitNewConnection => write!(f, "QUIT_NC"), + } + } +} + +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Response::Action(action) => write!(f, "ACTION ({})", action), + Response::Modification(modification) => write!(f, "MODIFICATION ({})", modification), + Response::Progress => write!(f, "PROGRESS"), + Response::OptionNegotiation(opt) => write!(f, "OPTNEG ({})", opt), + Response::Skip => write!(f, "SKIP"), + Response::SetSymbols => write!(f, "SET_SYMBOLS"), + } + } +} + +impl Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Action::Accept => write!(f, "ACCEPT"), + Action::Continue => write!(f, "CONTINUE"), + Action::Discard => write!(f, "DISCARD"), + Action::Reject => write!(f, "REJECT"), + Action::TempFail => write!(f, "TEMPFAIL"), + Action::ReplyCode { code, text } => { + write!(f, "REPLYCODE (code: {:?}, text: {})", code, text) + } + Action::Shutdown => write!(f, "SHUTDOWN"), + Action::ConnectionFailure => write!(f, "CONN_FAIL"), + } + } +} + +impl Display for Modification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Modification::AddRcpt { recipient, args } => { + write!(f, "ADD_RCPT (recipient: {}, args: {})", recipient, args) + } + Modification::DeleteRcpt { recipient } => { + write!(f, "DEL_RCPT (recipient: {})", recipient) + } + Modification::ReplaceBody { value } => { + write!(f, "REPLACE_BODY ({} bytes)", value.len()) + } + Modification::AddHeader { name, value } => { + write!(f, "ADD_HEADER ({}: {})", name, value) + } + Modification::ChangeHeader { index, name, value } => { + write!(f, "CHANGE_HEADER (index: {}, {}: {})", index, name, value) + } + Modification::Quarantine { reason } => write!(f, "QUARANTINE ({})", reason), + Modification::ChangeFrom { sender, args } => { + write!(f, "CHANGE_FROM (<{}> {})", sender, args) + } + Modification::InsertHeader { index, name, value } => { + write!(f, "INSERT_HEADER (index: {}, {}: {})", index, name, value) + } + } + } +} + +impl Display for Options { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "version: {}, actions: [", self.version,)?; + + if self.actions & SMFIF_ADDHDRS != 0 { + write!(f, "ADDHDRS ")?; + } + if self.actions & SMFIF_CHGBODY != 0 { + write!(f, "CHGBODY ")?; + } + if self.actions & SMFIF_CHGHDRS != 0 { + write!(f, "CHGHDRS ")?; + } + if self.actions & SMFIF_ADDRCPT != 0 { + write!(f, "ADDRCPT ")?; + } + if self.actions & SMFIF_DELRCPT != 0 { + write!(f, "DELRCPT ")?; + } + if self.actions & SMFIF_CHGFROM != 0 { + write!(f, "CHGFROM ")?; + } + if self.actions & SMFIF_QUARANTINE != 0 { + write!(f, "QUARANTINE ")?; + } + if self.actions & SMFIF_CHGFROM != 0 { + write!(f, "CHGFROM ")?; + } + if self.actions & SMFIF_ADDRCPT_PAR != 0 { + write!(f, "ADDRCPT_PAR ")?; + } + if self.actions & SMFIF_SETSYMLIST != 0 { + write!(f, "SETSYMLIST ")?; + } + write!(f, "], options: [",)?; + + if self.protocol & SMFIP_NOCONNECT != 0 { + write!(f, "NOCONNECT ")?; + } + + if self.protocol & SMFIP_NOHELO != 0 { + write!(f, "NOHELO ")?; + } + + if self.protocol & SMFIP_NOMAIL != 0 { + write!(f, "NOMAIL ")?; + } + + if self.protocol & SMFIP_NORCPT != 0 { + write!(f, "NORCPT ")?; + } + + if self.protocol & SMFIP_NOBODY != 0 { + write!(f, "NOBODY ")?; + } + + if self.protocol & SMFIP_NOHDRS != 0 { + write!(f, "NOHDRS ")?; + } + + if self.protocol & SMFIP_NOEOH != 0 { + write!(f, "NOEOH ")?; + } + + if self.protocol & SMFIP_NR_HDR != 0 { + write!(f, "NR_HDR ")?; + } + + if self.protocol & SMFIP_NOUNKNOWN != 0 { + write!(f, "NOUNKNOWN ")?; + } + + if self.protocol & SMFIP_NODATA != 0 { + write!(f, "NODATA ")?; + } + + if self.protocol & SMFIP_SKIP != 0 { + write!(f, "SKIP ")?; + } + + if self.protocol & SMFIP_RCPT_REJ != 0 { + write!(f, "RCPT_REJ ")?; + } + + if self.protocol & SMFIP_NR_CONN != 0 { + write!(f, "NR_CONN ")?; + } + + if self.protocol & SMFIP_NR_HELO != 0 { + write!(f, "NR_HELO ")?; + } + + if self.protocol & SMFIP_NR_MAIL != 0 { + write!(f, "NR_MAIL ")?; + } + + if self.protocol & SMFIP_NR_RCPT != 0 { + write!(f, "NR_RCPT ")?; + } + + if self.protocol & SMFIP_NR_DATA != 0 { + write!(f, "NR_DATA ")?; + } + + if self.protocol & SMFIP_NR_UNKN != 0 { + write!(f, "NR_UNKN ")?; + } + + if self.protocol & SMFIP_NR_EOH != 0 { + write!(f, "NR_EOH ")?; + } + + if self.protocol & SMFIP_NR_BODY != 0 { + write!(f, "NR_BODY ")?; + } + + if self.protocol & SMFIP_HDR_LEADSPC != 0 { + write!(f, "HDR_LEADSPC ")?; + } + + if self.protocol & SMFIP_MDS_256K != 0 { + write!(f, "MDS_256K ")?; + } + + if self.protocol & SMFIP_MDS_1M != 0 { + write!(f, "MDS_1M ")?; + } + + write!(f, "]") + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(err) => write!(f, "IO error: {}", err), + Error::FrameTooLarge(size) => { + write!(f, "Milter response of {} bytes is too large.", size) + } + Error::FrameInvalid(frame) => write!( + f, + "Invalid milter response: {:?}", + frame.get(0..100).unwrap_or(frame.as_ref()) + ), + Error::Unexpected(response) => write!(f, "Unexpected response: {}", response), + Error::Timeout => write!(f, "Connection timed out"), + Error::TLSInvalidName => write!(f, "Invalid TLS name"), + Error::Disconnected => write!(f, "Disconnected unexpectedly"), + } + } +} diff --git a/crates/smtp/src/inbound/milter/protocol.rs b/crates/smtp/src/inbound/milter/protocol.rs new file mode 100644 index 0000000..556fc6b --- /dev/null +++ b/crates/smtp/src/inbound/milter/protocol.rs @@ -0,0 +1,532 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::IpAddr; + +use crate::inbound::milter::Action; + +use super::{Command, Error, Modification, Options, Response}; + +pub const SMFIR_ADDRCPT: u8 = b'+'; /* add recipient */ +pub const SMFIR_DELRCPT: u8 = b'-'; /* remove recipient */ +pub const SMFIR_ADDRCPT_PAR: u8 = b'2'; /* add recipient (incl. ESMTP args) */ +pub const SMFIR_SHUTDOWN: u8 = b'4'; /* 421: shutdown (internal to MTA) */ +pub const SMFIR_ACCEPT: u8 = b'a'; /* accept */ +pub const SMFIR_REPLBODY: u8 = b'b'; /* replace body (chunk) */ +pub const SMFIR_CONTINUE: u8 = b'c'; /* continue */ +pub const SMFIR_DISCARD: u8 = b'd'; /* discard */ +pub const SMFIR_CHGFROM: u8 = b'e'; /* change envelope sender (from) */ +pub const SMFIR_CONN_FAIL: u8 = b'f'; /* cause a connection failure */ +pub const SMFIR_ADDHEADER: u8 = b'h'; /* add header */ +pub const SMFIR_INSHEADER: u8 = b'i'; /* insert header */ +pub const SMFIR_SETSYMLIST: u8 = b'l'; /* set list of symbols (macros) */ +pub const SMFIR_CHGHEADER: u8 = b'm'; /* change header */ +pub const SMFIR_PROGRESS: u8 = b'p'; /* progress */ +pub const SMFIR_QUARANTINE: u8 = b'q'; /* quarantine */ +pub const SMFIR_REJECT: u8 = b'r'; /* reject */ +pub const SMFIR_SKIP: u8 = b's'; /* skip */ +pub const SMFIR_TEMPFAIL: u8 = b't'; /* tempfail */ +pub const SMFIR_REPLYCODE: u8 = b'y'; /* reply code etc */ + +pub const SMFIC_ABORT: u8 = b'A'; /* Abort */ +pub const SMFIC_BODY: u8 = b'B'; /* Body chunk */ +pub const SMFIC_CONNECT: u8 = b'C'; /* Connection information */ +pub const SMFIC_MACRO: u8 = b'D'; /* Define macro */ +pub const SMFIC_BODYEOB: u8 = b'E'; /* final body chunk (End) */ +pub const SMFIC_HELO: u8 = b'H'; /* HELO/EHLO */ +pub const SMFIC_QUIT_NC: u8 = b'K'; /* QUIT but new connection follows */ +pub const SMFIC_HEADER: u8 = b'L'; /* Header */ +pub const SMFIC_MAIL: u8 = b'M'; /* MAIL from */ +pub const SMFIC_EOH: u8 = b'N'; /* EOH */ +pub const SMFIC_OPTNEG: u8 = b'O'; /* Option negotiation */ +pub const SMFIC_QUIT: u8 = b'Q'; /* QUIT */ +pub const SMFIC_RCPT: u8 = b'R'; /* RCPT to */ +pub const SMFIC_DATA: u8 = b'T'; /* DATA */ +pub const SMFIC_UNKNOWN: u8 = b'U'; /* Any unknown command */ + +impl Command<'_> { + fn build(command: u8, len: u32) -> Vec { + let mut buf = Vec::with_capacity(len as usize + 1 + std::mem::size_of::()); + buf.extend_from_slice((len + 1).to_be_bytes().as_slice()); + buf.push(command); + buf + } + + pub fn serialize(self) -> Vec { + match self { + Command::Abort => Command::build(SMFIC_ABORT, 0), + Command::Body { value } => { + let mut buf = Command::build(SMFIC_BODY, value.len() as u32); + buf.extend(value); + buf + } + Command::EndOfBody => Command::build(SMFIC_BODYEOB, 0), + Command::Connect { + hostname, + port, + address, + } => { + /* + + char hostname[] Hostname, NUL terminated + char family Protocol family (see below) + uint16 port Port number (SMFIA_INET or SMFIA_INET6 only) + char address[] IP address (ASCII) or unix socket path, NUL terminated + + */ + + let (address, family) = match address { + IpAddr::V4(address) => (address.to_string(), b'4'), + IpAddr::V6(address) => (address.to_string(), b'6'), + }; + + let mut buf = Command::build( + SMFIC_CONNECT, + hostname.len() as u32 // hostname + + 1 // NUL + + 1 // family + + std::mem::size_of::() as u32 // port + + address.len() as u32 // address + + 1, // NUL + ); + buf.extend(hostname); + buf.push(0x00); + buf.push(family); + buf.extend(port.to_be_bytes().as_slice()); + buf.extend(address.as_bytes()); + buf.push(0x00); + buf + } + Command::Macro { macros } => { + let mut buf = Command::build( + SMFIC_MACRO, + macros.macros.iter().fold(1, |acc, macro_| { + acc + macro_.name.len() as u32 + 1 + macro_.value.len() as u32 + 1 + }), + ); + buf.push(macros.cmdcode); + for macro_ in macros.macros { + buf.extend(macro_.name); + buf.push(0x00); + buf.extend(macro_.value.as_ref()); + buf.push(0x00); + } + buf + } + Command::Header { name, value } => { + let mut buf = + Command::build(SMFIC_HEADER, name.len() as u32 + 1 + value.len() as u32 + 1); + buf.extend(name); + buf.push(0x00); + buf.extend(value); + buf.push(0x00); + buf + } + Command::EndOfHeader => Command::build(SMFIC_EOH, 0), + Command::Helo { hostname } => { + let mut buf = Command::build(SMFIC_HELO, hostname.len() as u32 + 1); + buf.extend(hostname); + buf.push(0x00); + buf + } + Command::MailFrom { sender, args } => { + let mut buf = Command::build( + SMFIC_MAIL, + sender.len() as u32 // sender + + 1 // NUL + + args.as_ref().map_or(0, |args| args.iter().fold(0, |acc, arg| acc + arg.len() as u32) + 1), // args + ); + buf.extend(sender); + buf.push(0x00); + if let Some(args) = args { + for arg in args { + buf.extend(arg); + buf.push(0x00); + } + } + buf + } + Command::Rcpt { recipient, args } => { + let mut buf = Command::build( + SMFIC_RCPT, + recipient.len() as u32 // recipient + + 1 // NUL + + args.as_ref().map_or(0, |args| args.iter().fold(0, |acc, arg| acc + arg.len() as u32) + 1), // args + ); + buf.extend(recipient); + buf.push(0x00); + if let Some(args) = args { + for arg in args { + buf.extend(arg); + buf.push(0x00); + } + } + buf + } + Command::OptionNegotiation(opt) => { + let mut buf = Command::build(SMFIC_OPTNEG, 3 * std::mem::size_of::() as u32); + buf.extend(opt.version.to_be_bytes().as_slice()); + buf.extend(opt.actions.to_be_bytes().as_slice()); + buf.extend(opt.protocol.to_be_bytes().as_slice()); + buf + } + Command::Quit => Command::build(SMFIC_QUIT, 0), + + // Version 6 + Command::Data => Command::build(SMFIC_DATA, 0), + Command::QuitNewConnection => Command::build(SMFIC_QUIT_NC, 0), + } + } + + #[cfg(feature = "test_mode")] + pub fn deserialize(bytes: &[u8]) -> Command<'_> { + let mut reader = PacketReader::new(bytes); + match reader.byte() { + SMFIC_ABORT => Command::Abort, + SMFIC_BODY => Command::Body { value: &bytes[1..] }, + SMFIC_BODYEOB => Command::EndOfBody, + SMFIC_CONNECT => { + let hostname = reader.read_nul_terminated().unwrap(); + let family = reader.byte(); + let port = reader.read_u16(); + let address = std::str::from_utf8(reader.read_nul_terminated().unwrap()).unwrap(); + Command::Connect { + hostname, + port, + address: match family { + b'4' => IpAddr::V4(address.parse().unwrap()), + b'6' => IpAddr::V6(address.parse().unwrap()), + _ => unreachable!(), + }, + } + } + SMFIC_MACRO => { + let cmdcode = reader.byte(); + let mut macros = Vec::new(); + while let Some(name) = reader.read_nul_terminated() { + let value = reader.read_nul_terminated().unwrap(); + macros.push(super::Macro { + name, + value: value.into(), + }); + } + Command::Macro { + macros: super::Macros { cmdcode, macros }, + } + } + SMFIC_HEADER => { + let name = reader.read_nul_terminated().unwrap(); + let value = reader.read_nul_terminated().unwrap(); + Command::Header { name, value } + } + SMFIC_EOH => Command::EndOfHeader, + SMFIC_HELO => { + let hostname = reader.read_nul_terminated().unwrap(); + Command::Helo { hostname } + } + SMFIC_MAIL => { + let sender = reader.read_nul_terminated().unwrap(); + let mut args = Vec::new(); + while let Some(arg) = reader.read_nul_terminated() { + args.push(arg); + } + Command::MailFrom { + sender, + args: Some(args), + } + } + SMFIC_RCPT => { + let recipient = reader.read_nul_terminated().unwrap(); + let mut args = Vec::new(); + while let Some(arg) = reader.read_nul_terminated() { + args.push(arg); + } + Command::Rcpt { + recipient, + args: Some(args), + } + } + SMFIC_OPTNEG => Command::OptionNegotiation(super::Options { + version: reader.read_u32(), + actions: reader.read_u32(), + protocol: reader.read_u32(), + }), + SMFIC_QUIT => Command::Quit, + SMFIC_DATA => Command::Data, + SMFIC_QUIT_NC => Command::QuitNewConnection, + c => panic!("Unknown command: {}", char::from(c)), + } + } +} + +impl Response { + pub fn deserialize(bytes: &[u8]) -> Option { + let frame_len = bytes.len().saturating_sub(1); + let mut bytes = bytes.iter(); + match *bytes.next()? { + SMFIR_ADDRCPT => Response::Modification(Modification::AddRcpt { + recipient: read_nul_terminated(&mut bytes, frame_len)?, + args: String::new(), + }), + SMFIR_DELRCPT => Response::Modification(Modification::DeleteRcpt { + recipient: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_ACCEPT => Response::Action(Action::Accept), + SMFIR_REPLBODY => { + let mut body = Vec::with_capacity(frame_len); + body.extend(bytes); + Response::Modification(Modification::ReplaceBody { value: body }) + } + SMFIR_CONTINUE => Response::Action(Action::Continue), + SMFIR_DISCARD => Response::Action(Action::Discard), + SMFIR_ADDHEADER => Response::Modification(Modification::AddHeader { + name: read_nul_terminated(&mut bytes, 16)?, + value: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_CHGHEADER => Response::Modification(Modification::ChangeHeader { + index: read_u32(&mut bytes)?, + name: read_nul_terminated(&mut bytes, 16)?, + value: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_PROGRESS => Response::Progress, + SMFIR_QUARANTINE => Response::Modification(Modification::Quarantine { + reason: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_REJECT => Response::Action(Action::Reject), + SMFIR_TEMPFAIL => Response::Action(Action::TempFail), + SMFIR_REPLYCODE => { + let code = [*bytes.next()?, *bytes.next()?, *bytes.next()?]; + bytes.next()?; // Space + Response::Action(Action::ReplyCode { + code, + text: read_nul_terminated(&mut bytes, frame_len)?, + }) + } + SMFIC_OPTNEG => Response::OptionNegotiation(Options { + version: read_u32(&mut bytes)?, + actions: read_u32(&mut bytes)?, + protocol: read_u32(&mut bytes)?, + }), + + // V6 + SMFIR_ADDRCPT_PAR => Response::Modification(Modification::AddRcpt { + recipient: read_nul_terminated(&mut bytes, frame_len)?, + args: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_CHGFROM => Response::Modification(Modification::ChangeFrom { + sender: read_nul_terminated(&mut bytes, frame_len)?, + args: read_nul_terminated(&mut bytes, frame_len)?, + }), + SMFIR_SKIP => Response::Skip, + SMFIR_SETSYMLIST => Response::SetSymbols, + SMFIR_SHUTDOWN => Response::Action(Action::Shutdown), + SMFIR_CONN_FAIL => Response::Action(Action::ConnectionFailure), + SMFIR_INSHEADER => Response::Modification(Modification::InsertHeader { + index: read_u32(&mut bytes)?, + name: read_nul_terminated(&mut bytes, 16)?, + value: read_nul_terminated(&mut bytes, frame_len)?, + }), + _ => return None, + } + .into() + } + + pub fn can_continue(&self) -> bool { + matches!( + self, + Response::Progress | Response::Action(Action::Accept | Action::Continue) + ) + } + + pub fn into_action(self) -> super::Result { + match self { + Response::Action(action) => Ok(action), + response => Err(Error::Unexpected(response)), + } + } + + #[cfg(feature = "test_mode")] + pub fn serialize(&self) -> Vec { + match self { + Response::Action(action) => match action { + Action::Accept => Command::build(SMFIR_ACCEPT, 0), + Action::Continue => Command::build(SMFIR_CONTINUE, 0), + Action::Discard => Command::build(SMFIR_DISCARD, 0), + Action::Reject => Command::build(SMFIR_REJECT, 0), + Action::TempFail => Command::build(SMFIR_TEMPFAIL, 0), + Action::ReplyCode { code, text } => { + let mut buf = Command::build(SMFIR_REPLYCODE, text.len() as u32 + 4 + 1); + buf.extend(code); + buf.push(b' '); + buf.extend(text.as_bytes()); + buf.push(0x00); + buf + } + Action::Shutdown => Command::build(SMFIR_SHUTDOWN, 0), + Action::ConnectionFailure => Command::build(SMFIR_CONN_FAIL, 0), + }, + Response::Modification(modif) => match modif { + Modification::ChangeFrom { sender, args } => { + let mut buf = + Command::build(SMFIR_CHGFROM, sender.len() as u32 + args.len() as u32 + 2); + buf.extend(sender.as_bytes()); + buf.push(0x00); + buf.extend(args.as_bytes()); + buf.push(0x00); + buf + } + Modification::AddRcpt { recipient, args } => { + let mut buf = Command::build( + SMFIR_ADDRCPT_PAR, + recipient.len() as u32 + args.len() as u32 + 2, + ); + buf.extend(recipient.as_bytes()); + buf.push(0x00); + buf.extend(args.as_bytes()); + buf.push(0x00); + buf + } + Modification::DeleteRcpt { recipient } => { + let mut buf = Command::build(SMFIR_DELRCPT, recipient.len() as u32 + 1); + buf.extend(recipient.as_bytes()); + buf.push(0x00); + buf + } + Modification::ReplaceBody { value } => { + let mut buf = Command::build(SMFIR_REPLBODY, value.len() as u32); + buf.extend(value); + buf + } + Modification::AddHeader { name, value } => { + let mut buf = + Command::build(SMFIR_ADDHEADER, name.len() as u32 + value.len() as u32 + 2); + buf.extend(name.as_bytes()); + buf.push(0x00); + buf.extend(value.as_bytes()); + buf.push(0x00); + buf + } + Modification::InsertHeader { index, name, value } => { + let mut buf = Command::build( + SMFIR_INSHEADER, + name.len() as u32 + + value.len() as u32 + + std::mem::size_of::() as u32 + + 2, + ); + buf.extend(index.to_be_bytes().as_slice()); + buf.extend(name.as_bytes()); + buf.push(0x00); + buf.extend(value.as_bytes()); + buf.push(0x00); + buf + } + Modification::ChangeHeader { index, name, value } => { + let mut buf = Command::build( + SMFIR_CHGHEADER, + name.len() as u32 + + value.len() as u32 + + std::mem::size_of::() as u32 + + 2, + ); + buf.extend(index.to_be_bytes().as_slice()); + buf.extend(name.as_bytes()); + buf.push(0x00); + buf.extend(value.as_bytes()); + buf.push(0x00); + buf + } + Modification::Quarantine { reason } => { + let mut buf = Command::build(SMFIR_QUARANTINE, reason.len() as u32 + 1); + buf.extend(reason.as_bytes()); + buf.push(0x00); + buf + } + }, + Response::Progress => Command::build(SMFIR_PROGRESS, 0), + Response::Skip => Command::build(SMFIR_SKIP, 0), + Response::SetSymbols => Command::build(SMFIR_SETSYMLIST, 0), + Response::OptionNegotiation(opt) => { + let mut buf = Command::build(SMFIC_OPTNEG, 3 * std::mem::size_of::() as u32); + buf.extend(opt.version.to_be_bytes().as_slice()); + buf.extend(opt.actions.to_be_bytes().as_slice()); + buf.extend(opt.protocol.to_be_bytes().as_slice()); + buf + } + } + } +} + +fn read_nul_terminated(bytes: &mut std::slice::Iter, expected_len: usize) -> Option { + let mut buf = Vec::with_capacity(expected_len); + loop { + match bytes.next()? { + 0x00 => break, + byte => buf.push(*byte), + } + } + String::from_utf8(buf).ok() +} + +fn read_u32(bytes: &mut std::slice::Iter) -> Option { + let mut buf = [0u8; 4]; + for byte in buf.iter_mut() { + *byte = *bytes.next()?; + } + Some(u32::from_be_bytes(buf)) +} + +#[cfg(feature = "test_mode")] +pub struct PacketReader<'x> { + bytes: &'x [u8], + iter: std::iter::Enumerate>, +} + +#[cfg(feature = "test_mode")] +impl<'x> PacketReader<'x> { + pub fn new(bytes: &'x [u8]) -> PacketReader<'x> { + Self { + bytes, + iter: bytes.iter().enumerate(), + } + } + + pub fn byte(&mut self) -> u8 { + *self.iter.next().unwrap().1 + } + + pub fn read_nul_terminated(&mut self) -> Option<&'x [u8]> { + let (start_pos, ch) = self.iter.next()?; + let mut end_pos = start_pos; + + if *ch != 0x00 { + loop { + match self.iter.next().unwrap().1 { + 0x00 => break, + _ => end_pos += 1, + } + } + } + + Some(&self.bytes[start_pos..end_pos + 1]) + } + + pub fn read_u32(&mut self) -> u32 { + let mut buf = [0u8; 4]; + for byte in buf.iter_mut() { + *byte = self.byte(); + } + u32::from_be_bytes(buf) + } + + pub fn read_u16(&mut self) -> u16 { + let mut buf = [0u8; 2]; + for byte in buf.iter_mut() { + *byte = self.byte(); + } + u16::from_be_bytes(buf) + } +} diff --git a/crates/smtp/src/inbound/milter/receiver.rs b/crates/smtp/src/inbound/milter/receiver.rs new file mode 100644 index 0000000..a50c367 --- /dev/null +++ b/crates/smtp/src/inbound/milter/receiver.rs @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +enum State { + Len { + buf: [u8; std::mem::size_of::()], + bytes_read: usize, + }, + Frame { + buf: Vec, + frame_len: usize, + }, +} + +pub struct Receiver { + packet_pos: usize, + state: State, + max_frame_len: usize, +} + +pub enum FrameResult<'x> { + Frame(Cow<'x, [u8]>), + Incomplete, + TooLarge(usize), +} + +impl Default for State { + fn default() -> Self { + State::Len { + buf: [0; std::mem::size_of::()], + bytes_read: 0, + } + } +} + +impl Receiver { + pub fn with_max_frame_len(max_frame_len: usize) -> Self { + Receiver { + packet_pos: 0, + state: State::default(), + max_frame_len, + } + } + + pub fn read_frame<'x>(&mut self, packet: &'x [u8]) -> FrameResult<'x> { + if !packet.is_empty() { + match &mut self.state { + State::Len { buf, bytes_read } => { + while *bytes_read < std::mem::size_of::() { + if let Some(byte) = packet.get(self.packet_pos) { + buf[*bytes_read] = *byte; + *bytes_read += 1; + self.packet_pos += 1; + } else { + self.packet_pos = 0; + return FrameResult::Incomplete; + } + } + let length = u32::from_be_bytes(*buf) as usize; + if length <= self.max_frame_len { + if let Some(frame) = packet.get(self.packet_pos..self.packet_pos + length) { + self.packet_pos += length; + self.state = State::default(); + FrameResult::Frame(frame.into()) + } else { + let mut buf = Vec::with_capacity(length); + if let Some(bytes_available) = packet.get(self.packet_pos..) { + buf.extend(bytes_available); + } + self.state = State::Frame { + buf, + frame_len: length, + }; + self.packet_pos = 0; + FrameResult::Incomplete + } + } else { + FrameResult::TooLarge(length) + } + } + State::Frame { buf, frame_len } => { + let bytes_pending = *frame_len - buf.len(); + if let Some(bytes) = + packet.get(self.packet_pos..self.packet_pos + bytes_pending) + { + let mut buf = std::mem::take(buf); + buf.extend(bytes); + self.packet_pos += bytes_pending; + self.state = State::default(); + FrameResult::Frame(buf.into()) + } else if let Some(bytes_available) = packet.get(self.packet_pos..) { + buf.extend(bytes_available); + self.packet_pos = 0; + FrameResult::Incomplete + } else { + self.packet_pos = 0; + FrameResult::Incomplete + } + } + } + } else { + FrameResult::Incomplete + } + } +} diff --git a/crates/smtp/src/inbound/mod.rs b/crates/smtp/src/inbound/mod.rs new file mode 100644 index 0000000..99585af --- /dev/null +++ b/crates/smtp/src/inbound/mod.rs @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_auth::{DkimResult, DmarcResult, IprevResult, SpfResult, dmarc::Policy}; +use std::borrow::Cow; + +pub mod auth; +pub mod data; +pub mod dkim; +pub mod ehlo; +pub mod hooks; +pub mod mail; +pub mod milter; +pub mod rcpt; +pub mod session; +pub mod spam; +pub mod spawn; +pub mod vrfy; + +#[derive(Debug, Default)] +pub struct FilterResponse { + pub message: Cow<'static, str>, + pub disconnect: bool, +} + +pub trait AuthResult { + fn as_str(&self) -> &'static str; +} + +impl AuthResult for SpfResult { + fn as_str(&self) -> &'static str { + match self { + SpfResult::Pass => "pass", + SpfResult::Fail => "fail", + SpfResult::SoftFail => "softfail", + SpfResult::Neutral => "neutral", + SpfResult::None => "none", + SpfResult::TempError => "temperror", + SpfResult::PermError => "permerror", + } + } +} + +impl AuthResult for IprevResult { + fn as_str(&self) -> &'static str { + match self { + IprevResult::Pass => "pass", + IprevResult::Fail(_) => "fail", + IprevResult::TempError(_) => "temperror", + IprevResult::PermError(_) => "permerror", + IprevResult::None => "none", + } + } +} + +impl AuthResult for DkimResult { + fn as_str(&self) -> &'static str { + match self { + DkimResult::Pass => "pass", + DkimResult::None => "none", + DkimResult::Neutral(_) => "neutral", + DkimResult::Fail(_) => "fail", + DkimResult::PermError(_) => "permerror", + DkimResult::TempError(_) => "temperror", + } + } +} + +impl AuthResult for DmarcResult { + fn as_str(&self) -> &'static str { + match self { + DmarcResult::Pass => "pass", + DmarcResult::Fail(_) => "fail", + DmarcResult::TempError(_) => "temperror", + DmarcResult::PermError(_) => "permerror", + DmarcResult::None => "none", + } + } +} + +impl AuthResult for Policy { + fn as_str(&self) -> &'static str { + match self { + Policy::Reject => "reject", + Policy::Quarantine => "quarantine", + Policy::None | Policy::Unspecified => "none", + } + } +} + +impl FilterResponse { + pub fn accept() -> Self { + Self { + message: Cow::Borrowed("250 2.0.0 Message queued for delivery.\r\n"), + disconnect: false, + } + } + + pub fn reject() -> Self { + Self { + message: Cow::Borrowed("503 5.5.3 Message rejected.\r\n"), + disconnect: false, + } + } + + pub fn temp_fail() -> Self { + Self { + message: Cow::Borrowed("451 4.3.5 Unable to accept message at this time.\r\n"), + disconnect: false, + } + } + + pub fn shutdown() -> Self { + Self { + message: Cow::Borrowed("421 4.3.0 Server shutting down.\r\n"), + disconnect: true, + } + } + + pub fn server_failure() -> Self { + Self { + message: Cow::Borrowed("451 4.3.5 Unable to accept message at this time.\r\n"), + disconnect: false, + } + } + + pub fn disconnect(self) -> Self { + Self { + disconnect: true, + ..self + } + } + + pub fn into_bytes(self) -> Cow<'static, [u8]> { + match self.message { + Cow::Borrowed(s) => Cow::Borrowed(s.as_bytes()), + Cow::Owned(s) => Cow::Owned(s.into_bytes()), + } + } +} diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs new file mode 100644 index 0000000..32a290b --- /dev/null +++ b/crates/smtp/src/inbound/rcpt.rs @@ -0,0 +1,458 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionAddress}, + scripts::ScriptResult, +}; +use common::{ + KV_GREYLIST, + config::smtp::session::Stage, + network::{RcptResolution, SessionStream}, + scripts::ScriptModification, +}; +use smtp_proto::{ + RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, RcptTo, +}; +use std::borrow::Cow; +use store::dispatch::lookup::KeyValue; +use trc::{SecurityEvent, SmtpEvent}; +use utils::DomainPart; + +impl Session { + pub async fn handle_rcpt_to(&mut self, to: RcptTo>) -> Result<(), ()> { + #[cfg(feature = "test_mode")] + if self.instance.id.ends_with("-debug") { + if to.address.contains("fail@") { + return self.write(b"503 5.5.1 Invalid recipient.\r\n").await; + } else if (to.address.contains("delay-random@") && rand::random()) + || to.address.contains("delay@") + { + return self.write(b"451 4.5.3 Try again later.\r\n").await; + } else if to.address.contains("slow@") { + tokio::time::sleep(std::time::Duration::from_secs( + rand::random::() % 5 + 5, + )) + .await; + } + } + + if self.data.mail_from.is_none() { + trc::event!( + Smtp(SmtpEvent::MailFromMissing), + SpanId = self.data.session_id, + ); + return self.write(b"503 5.5.1 MAIL is required first.\r\n").await; + } else if std::cmp::min(self.data.rcpt_to.len(), self.data.rcpt_oks) >= self.params.rcpt_max + { + trc::event!( + Smtp(SmtpEvent::TooManyRecipients), + SpanId = self.data.session_id, + Limit = self.params.rcpt_max, + ); + return self.write(b"455 4.5.3 Too many recipients.\r\n").await; + } + + // Verify parameters + if ((to.flags + & (RCPT_NOTIFY_DELAY | RCPT_NOTIFY_NEVER | RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE) + != 0) + || to.orcpt.is_some()) + && !self.params.rcpt_dsn + { + trc::event!(Smtp(SmtpEvent::DsnDisabled), SpanId = self.data.session_id,); + return self + .write(b"501 5.5.4 DSN extension has been disabled.\r\n") + .await; + } + + // Build RCPT + let address_lcase = to.address.to_lowercase_address(true); + let rcpt = SessionAddress { + domain: address_lcase.domain_part().into(), + address_lcase, + address: to.address.into_owned(), + flags: to.flags, + dsn_info: to.orcpt.map(|e| e.into_owned()), + }; + + if self.data.rcpt_to.contains(&rcpt) { + trc::event!( + Smtp(SmtpEvent::RcptToDuplicate), + SpanId = self.data.session_id, + To = rcpt.address_lcase, + ); + self.data.rcpt_oks += 1; + return self.write(b"250 2.1.5 OK\r\n").await; + } + self.data.rcpt_to.push(rcpt); + + // Address rewriting and Sieve filtering + let rcpt_config = &self.server.core.smtp.session.rcpt; + let rcpt_script = self + .server + .eval_if::(&rcpt_config.script, self, self.data.session_id) + .await + .and_then(|name| { + self.server + .get_trusted_sieve_script(&name, self.data.session_id) + .map(|s| (s.clone(), name)) + }); + + let session_config = &self.server.core.smtp.session; + if rcpt_script.is_some() + || !rcpt_config.rewrite.is_empty() + || session_config + .milters + .iter() + .any(|m| m.run_on_stage.contains(&Stage::Rcpt)) + || session_config + .hooks + .iter() + .any(|h| h.run_on_stage.contains(&Stage::Rcpt)) + { + // Sieve filtering + if let Some((script, script_id)) = rcpt_script { + match self + .run_script( + script_id, + script.clone(), + self.build_script_parameters("rcpt"), + ) + .await + { + ScriptResult::Accept { modifications } if !modifications.is_empty() => { + for modification in modifications { + if let ScriptModification::SetEnvelope { name, value } = modification { + self.data.apply_envelope_modification(name, value); + } + } + } + ScriptResult::Reject(message) => { + self.data.rcpt_to.pop(); + return self.write(message.as_bytes()).await; + } + _ => (), + } + } + + // Milter filtering + if let Err(message) = self.run_milters(Stage::Rcpt, None, None).await { + self.data.rcpt_to.pop(); + return self.write(message.message.as_bytes()).await; + } + + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Rcpt, None, None).await { + self.data.rcpt_to.pop(); + return self.write(message.message.as_bytes()).await; + } + + // Address rewriting + if let Some(new_address) = self + .server + .eval_if::(&rcpt_config.rewrite, self, self.data.session_id) + .await + { + let rcpt = self.data.rcpt_to.last_mut().unwrap(); + + trc::event!( + Smtp(SmtpEvent::RcptToRewritten), + SpanId = self.data.session_id, + Details = rcpt.address_lcase.clone(), + To = new_address.clone(), + ); + + if new_address.contains('@') { + rcpt.address_lcase = new_address.to_lowercase_address(true); + rcpt.domain = rcpt.address_lcase.domain_part().into(); + rcpt.address = new_address; + } + } + + // Check for duplicates + let rcpt = self.data.rcpt_to.last().unwrap(); + if self.data.rcpt_to.iter().filter(|r| r == &rcpt).count() > 1 { + trc::event!( + Smtp(SmtpEvent::RcptToDuplicate), + SpanId = self.data.session_id, + To = rcpt.address_lcase.clone(), + ); + self.data.rcpt_to.pop(); + self.data.rcpt_oks += 1; + return self.write(b"250 2.1.5 OK\r\n").await; + } + } + + // Verify address + let rcpt = self.data.rcpt_to.last().unwrap(); + let mut rcpt_members = None; + + match self + .server + .rcpt_resolve(&rcpt.address_lcase, true, self.data.session_id) + .await + { + Ok(RcptResolution::Accept) => {} + Ok(RcptResolution::Rewrite(address)) => { + let orig_addr = self.data.rcpt_to.pop().unwrap(); + let mut new_addr = SessionAddress::new(address); + + if !self.data.rcpt_to.contains(&new_addr) { + new_addr.dsn_info = format!("rfc822;{}", orig_addr.address_lcase).into(); + new_addr.flags = orig_addr.flags; + self.data.rcpt_to.push(new_addr); + } else { + trc::event!( + Smtp(SmtpEvent::RcptToDuplicate), + SpanId = self.data.session_id, + To = new_addr.address_lcase.clone(), + ); + self.data.rcpt_oks += 1; + return self.write(b"250 2.1.5 OK\r\n").await; + } + } + Ok(RcptResolution::Expand(members)) => { + rcpt_members = Some(members); + } + Ok(RcptResolution::UnknownRecipient) => { + trc::event!( + Smtp(SmtpEvent::MailboxDoesNotExist), + SpanId = self.data.session_id, + To = rcpt.address_lcase.clone(), + ); + + let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase; + return self + .rcpt_error(b"550 5.1.2 Mailbox does not exist.\r\n", rcpt_to) + .await; + } + Ok(RcptResolution::UnknownDomain) => { + if !self + .server + .eval_if(&rcpt_config.relay, self, self.data.session_id) + .await + .unwrap_or(false) + { + trc::event!( + Smtp(SmtpEvent::RelayNotAllowed), + SpanId = self.data.session_id, + To = rcpt.address_lcase.clone(), + ); + + let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase; + return self + .rcpt_error(b"550 5.1.2 Relay not allowed.\r\n", rcpt_to) + .await; + } + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to verify address.") + ); + + self.data.rcpt_to.pop(); + return self + .write(b"451 4.4.3 Unable to verify address at this time.\r\n") + .await; + } + } + + if self.is_allowed().await { + // Greylist + if let Some(greylist_duration) = self + .server + .core + .spam + .grey_list_expiry + .filter(|_| self.data.authenticated_as.is_none()) + { + let from_addr = self + .data + .mail_from + .as_ref() + .unwrap() + .address_lcase + .as_bytes(); + let to_addr = self.data.rcpt_to.last().unwrap().address_lcase.as_bytes(); + let mut key = Vec::with_capacity(from_addr.len() + to_addr.len() + 1); + key.push(KV_GREYLIST); + key.extend_from_slice(from_addr); + key.extend_from_slice(to_addr); + + match self.server.in_memory_store().key_exists(key.clone()).await { + Ok(true) => (), + Ok(false) => { + match self + .server + .in_memory_store() + .key_set(KeyValue::new(key, vec![]).expires(greylist_duration)) + .await + { + Ok(_) => { + let rcpt = self.data.rcpt_to.pop().unwrap(); + + trc::event!( + Smtp(SmtpEvent::RcptToGreylisted), + SpanId = self.data.session_id, + To = rcpt.address_lcase, + ); + + return self + .write( + concat!( + "452 4.2.2 Greylisted, please try ", + "again in a few moments.\r\n" + ) + .as_bytes(), + ) + .await; + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to set greylist.") + ); + } + } + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to check greylist.") + ); + } + } + } + + trc::event!( + Smtp(SmtpEvent::RcptTo), + SpanId = self.data.session_id, + To = self.data.rcpt_to.last().unwrap().address_lcase.clone(), + ); + } else { + trc::event!( + Smtp(SmtpEvent::RateLimitExceeded), + SpanId = self.data.session_id, + To = self.data.rcpt_to.last().unwrap().address_lcase.clone(), + ); + + self.data.rcpt_to.pop(); + return self + .write(b"452 4.4.5 Rate limit exceeded, try again later.\r\n") + .await; + } + + // Expand list + if let Some(members) = rcpt_members { + let list_addr = self.data.rcpt_to.pop().unwrap(); + let orcpt = format!("rfc822;{}", list_addr.address_lcase); + for member in members.as_ref() { + let member_lcase = member.to_lowercase(); + let is_local = match self + .server + .account_id_from_email(&member_lcase, false) + .await + { + Ok(account_id) => account_id.is_some(), + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to look up mailing list member.") + .ctx(trc::Key::To, member.to_string()) + ); + false + } + }; + + let address = if is_local { + member.to_string() + } else { + match self + .server + .rcpt_resolve(&member_lcase, false, self.data.session_id) + .await + { + Ok(RcptResolution::Rewrite(address)) => address, + Ok(_) => member.to_string(), + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to resolve mailing list member.") + .ctx(trc::Key::To, member.to_string()) + ); + member.to_string() + } + } + }; + + let mut member_addr = SessionAddress::new(address); + if !self.data.rcpt_to.contains(&member_addr) + && member_addr.address_lcase != list_addr.address_lcase + { + member_addr.dsn_info = orcpt.clone().into(); + member_addr.flags = list_addr.flags; + self.data.rcpt_to.push(member_addr); + } + } + } + + self.data.rcpt_oks += 1; + self.write(b"250 2.1.5 OK\r\n").await + } + + async fn rcpt_error(&mut self, response: &[u8], rcpt: String) -> Result<(), ()> { + tokio::time::sleep(self.params.rcpt_errors_wait).await; + self.data.rcpt_errors += 1; + let has_too_many_errors = self.data.rcpt_errors >= self.params.rcpt_errors_max; + + match self + .server + .is_rcpt_fail2banned(self.data.remote_ip, &rcpt) + .await + { + Ok(true) => { + trc::event!( + Security(SecurityEvent::AbuseBan), + SpanId = self.data.session_id, + RemoteIp = self.data.remote_ip, + To = rcpt, + ); + } + Ok(false) => { + if has_too_many_errors { + trc::event!( + Smtp(SmtpEvent::TooManyInvalidRcpt), + SpanId = self.data.session_id, + Limit = self.params.rcpt_errors_max, + To = rcpt, + ); + } + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to check if IP should be banned.") + ); + } + } + + if !has_too_many_errors { + self.write(response).await + } else { + self.write(b"451 4.3.0 Too many errors, disconnecting.\r\n") + .await?; + Err(()) + } + } +} diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs new file mode 100644 index 0000000..73b7737 --- /dev/null +++ b/crates/smtp/src/inbound/session.rs @@ -0,0 +1,639 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + config::{server::ServerProtocol, smtp::session::Mechanism}, + expr::{self, functions::ResolveVariable, *}, + network::SessionStream, +}; + +use compact_str::ToCompactString; +use registry::schema::enums::ExpressionVariable; +use smtp_proto::{ + request::receiver::{ + BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, + MAX_LINE_LENGTH, + }, + *, +}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use trc::{NetworkEvent, SecurityEvent, SmtpEvent}; + +use crate::core::{Session, State}; + +use super::auth::SaslToken; + +impl Session { + pub async fn ingest(&mut self, bytes: &[u8]) -> Result { + let mut iter = bytes.iter(); + let mut state = std::mem::replace(&mut self.state, State::None); + + 'outer: loop { + match &mut state { + State::Request(receiver) => loop { + match receiver.ingest(&mut iter) { + Ok(request) => match request { + Request::Rcpt { to } => { + self.handle_rcpt_to(to).await?; + } + Request::Mail { from } => { + self.handle_mail_from(from).await?; + } + Request::Ehlo { host } => { + if self.instance.protocol == ServerProtocol::Smtp { + self.handle_ehlo(host, true).await?; + } else { + trc::event!( + Smtp(SmtpEvent::LhloExpected), + SpanId = self.data.session_id, + ); + + self.write(b"500 5.5.1 Invalid command.\r\n").await?; + } + } + Request::Data => { + if let Some(response) = self.can_send_data().await { + self.write(response).await?; + } else { + self.write(b"354 Start mail input; end with .\r\n") + .await?; + self.data.message = Vec::with_capacity(1024); + state = State::Data(DataReceiver::new()); + continue 'outer; + } + } + Request::Bdat { + chunk_size, + is_last, + } => { + state = if let Some(response) = self.can_send_data().await { + State::SkipData( + DummyDataReceiver::new_bdat(chunk_size), + response, + ) + } else if chunk_size.saturating_add(self.data.message.len()) + < self.params.max_message_size + { + if self.data.message.is_empty() { + self.data.message = Vec::with_capacity(chunk_size); + } else { + self.data.message.reserve(chunk_size); + } + State::Bdat(BdatReceiver::new(chunk_size, is_last)) + } else { + trc::event!( + Smtp(SmtpEvent::MessageTooLarge), + SpanId = self.data.session_id, + Size = chunk_size.saturating_add(self.data.message.len()), + Limit = self.params.max_message_size, + ); + + State::SkipData( + DummyDataReceiver::new_bdat(chunk_size), + b"552 5.3.4 Message too big for system.\r\n", + ) + }; + continue 'outer; + } + Request::Auth { + mechanism, + initial_response, + } => { + let auth: u64 = self + .server + .eval_if::( + &self.server.core.smtp.session.auth.mechanisms, + self, + self.data.session_id, + ) + .await + .unwrap_or_default() + .into(); + if auth == 0 { + trc::event!( + Smtp(SmtpEvent::AuthNotAllowed), + SpanId = self.data.session_id, + ); + + self.write(b"503 5.5.1 AUTH not allowed.\r\n").await?; + } else if let Some(authenticated_as) = self.authenticated_as() { + trc::event!( + Smtp(SmtpEvent::AlreadyAuthenticated), + SpanId = self.data.session_id, + AccountName = authenticated_as.to_string(), + ); + + self.write(b"503 5.5.1 Already authenticated.\r\n").await?; + } else if let Some(mut token) = + SaslToken::from_mechanism(mechanism & auth) + { + if self + .handle_sasl_response( + &mut token, + initial_response.as_bytes(), + ) + .await? + { + state = State::Sasl(LineReceiver::new(token)); + continue 'outer; + } + } else { + trc::event!( + Smtp(SmtpEvent::AuthMechanismNotSupported), + SpanId = self.data.session_id, + ); + + self.write( + b"554 5.7.8 Authentication mechanism not supported.\r\n", + ) + .await?; + } + } + Request::Noop { .. } => { + trc::event!(Smtp(SmtpEvent::Noop), SpanId = self.data.session_id,); + + self.write(b"250 2.0.0 OK\r\n").await?; + } + Request::Vrfy { value } => { + self.handle_vrfy(value).await?; + } + Request::Expn { value } => { + self.handle_expn(value).await?; + } + Request::StartTls => { + if !self.stream.is_tls() { + if self.instance.acceptor.is_tls() { + trc::event!( + Smtp(SmtpEvent::StartTls), + SpanId = self.data.session_id, + ); + + self.write(b"220 2.0.0 Ready to start TLS.\r\n").await?; + #[cfg(any(test, feature = "test_mode"))] + if self.data.helo_domain.contains("badtls") { + return Err(()); + } + self.state = State::default(); + self.reset_tls(); + return Ok(false); + } else { + trc::event!( + Smtp(SmtpEvent::StartTlsUnavailable), + SpanId = self.data.session_id, + ); + + self.write(b"502 5.7.0 TLS not available.\r\n").await?; + } + } else { + trc::event!( + Smtp(SmtpEvent::StartTlsAlready), + SpanId = self.data.session_id, + ); + + self.write(b"504 5.7.4 Already in TLS mode.\r\n").await?; + } + } + Request::Rset => { + trc::event!(Smtp(SmtpEvent::Rset), SpanId = self.data.session_id,); + + self.reset(); + self.write(b"250 2.0.0 OK\r\n").await?; + } + Request::Quit => { + trc::event!(Smtp(SmtpEvent::Quit), SpanId = self.data.session_id,); + + self.write(b"221 2.0.0 Bye.\r\n").await?; + return Err(()); + } + Request::Help { .. } => { + trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,); + + self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n") + .await?; + } + Request::Helo { host } => { + if self.instance.protocol == ServerProtocol::Smtp { + self.handle_ehlo(host, false).await?; + } else { + trc::event!( + Smtp(SmtpEvent::LhloExpected), + SpanId = self.data.session_id, + ); + + self.write(b"500 5.5.1 Invalid command: LHLO expected.\r\n") + .await?; + } + } + Request::Lhlo { host } => { + if self.instance.protocol == ServerProtocol::Lmtp { + self.handle_ehlo(host, true).await?; + } else { + trc::event!( + Smtp(SmtpEvent::EhloExpected), + SpanId = self.data.session_id, + ); + + self.write(b"502 5.5.1 Invalid command: EHLO expected.\r\n") + .await?; + } + } + cmd @ (Request::Etrn { .. } + | Request::Atrn { .. } + | Request::Burl { .. }) => { + trc::event!( + Smtp(SmtpEvent::CommandNotImplemented), + SpanId = self.data.session_id, + Details = format!("{cmd:?}"), + ); + + self.write(b"502 5.5.1 Command not implemented.\r\n") + .await?; + } + }, + Err(err) => match err { + Error::NeedsMoreData { .. } => break 'outer, + Error::UnknownCommand | Error::InvalidResponse { .. } => { + // Check for port scanners + if !self.is_authenticated() { + match self + .server + .is_scanner_fail2banned(self.data.remote_ip) + .await + { + Ok(true) => { + trc::event!( + Security(SecurityEvent::ScanBan), + SpanId = self.data.session_id, + RemoteIp = self.data.remote_ip, + Reason = "Invalid SMTP command", + ); + + return Err(()); + } + Ok(false) => {} + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .details("Failed to check for fail2ban") + ); + } + } + } + + trc::event!( + Smtp(SmtpEvent::InvalidCommand), + SpanId = self.data.session_id, + ); + + self.write(b"500 5.5.1 Invalid command.\r\n").await?; + } + Error::InvalidSenderAddress => { + trc::event!( + Smtp(SmtpEvent::InvalidSenderAddress), + SpanId = self.data.session_id, + ); + + self.write(b"501 5.1.8 Bad sender's system address.\r\n") + .await?; + } + Error::InvalidRecipientAddress => { + trc::event!( + Smtp(SmtpEvent::InvalidRecipientAddress), + SpanId = self.data.session_id, + ); + + self.write( + b"501 5.1.3 Bad destination mailbox address syntax.\r\n", + ) + .await?; + } + Error::SyntaxError { syntax } => { + trc::event!( + Smtp(SmtpEvent::SyntaxError), + SpanId = self.data.session_id, + Details = syntax + ); + + if !self.params.ehlo_reject_non_fqdn && syntax.starts_with("EHLO ") + { + self.handle_ehlo("null".into(), true).await? + } else { + self.write( + format!("501 5.5.2 Syntax error, expected: {syntax}\r\n") + .as_bytes(), + ) + .await?; + } + } + Error::InvalidParameter { param } => { + trc::event!( + Smtp(SmtpEvent::InvalidParameter), + SpanId = self.data.session_id, + Details = param + ); + + self.write( + format!("501 5.5.4 Invalid parameter {param:?}.\r\n") + .as_bytes(), + ) + .await?; + } + Error::UnsupportedParameter { param } => { + trc::event!( + Smtp(SmtpEvent::UnsupportedParameter), + SpanId = self.data.session_id, + Details = param.clone() + ); + + self.write( + format!("504 5.5.4 Unsupported parameter {param:?}.\r\n") + .as_bytes(), + ) + .await?; + } + Error::ResponseTooLong => { + state = State::RequestTooLarge(DummyLineReceiver::default()); + continue 'outer; + } + }, + } + }, + State::Data(receiver) => { + if self.data.message.len() + bytes.len() < self.params.max_message_size { + if receiver.ingest(&mut iter, &mut self.data.message) { + let message = self.queue_message().await; + let num_responses = if self.instance.protocol == ServerProtocol::Smtp { + 1 + } else { + self.data.rcpt_oks + }; + if !message.is_empty() { + for _ in 0..num_responses { + self.write(message.as_ref()).await?; + } + self.reset(); + state = State::default(); + } else { + // Disconnect requested + return Err(()); + } + } else { + break 'outer; + } + } else { + trc::event!( + Smtp(SmtpEvent::MessageTooLarge), + SpanId = self.data.session_id, + Size = self.data.message.len() + bytes.len(), + Limit = self.params.max_message_size, + ); + + state = State::SkipData( + DummyDataReceiver::new_data(receiver), + b"552 5.3.4 Message too big for system.\r\n", + ); + } + } + State::Bdat(receiver) => { + if receiver.ingest(&mut iter, &mut self.data.message) { + if receiver.is_last { + let message = self.queue_message().await; + if !message.is_empty() { + let num_responses = + if self.instance.protocol == ServerProtocol::Smtp { + 1 + } else { + self.data.rcpt_oks + }; + for _ in 0..num_responses { + self.write(message.as_ref()).await?; + } + self.reset(); + } else { + // Disconnect requested + return Err(()); + } + } else { + self.write(b"250 2.6.0 Chunk accepted.\r\n").await?; + } + state = State::default(); + } else { + break 'outer; + } + } + State::Sasl(receiver) => { + if receiver.ingest(&mut iter) { + if receiver.buf.len() < MAX_LINE_LENGTH { + if self + .handle_sasl_response(&mut receiver.state, &receiver.buf) + .await? + { + receiver.buf.clear(); + continue 'outer; + } + } else { + trc::event!( + Smtp(SmtpEvent::AuthExchangeTooLong), + SpanId = self.data.session_id, + Limit = MAX_LINE_LENGTH, + ); + + self.auth_error( + b"500 5.5.6 Authentication Exchange line is too long.\r\n", + ) + .await?; + } + state = State::default(); + } else { + break 'outer; + } + } + State::SkipData(receiver, response) => { + if receiver.ingest(&mut iter) { + self.data.message = Vec::with_capacity(0); + self.write(response).await?; + state = State::default(); + } else { + break 'outer; + } + } + State::RequestTooLarge(receiver) => { + if receiver.ingest(&mut iter) { + trc::event!( + Smtp(SmtpEvent::RequestTooLarge), + SpanId = self.data.session_id, + ); + + self.write(b"554 5.3.4 Line is too long.\r\n").await?; + state = State::default(); + } else { + break 'outer; + } + } + State::None | State::Accepted(_) => unreachable!(), + } + } + self.state = state; + + Ok(true) + } +} + +impl Session { + pub fn reset(&mut self) { + self.data.mail_from = None; + self.data.spf_mail_from = None; + self.data.rcpt_to.clear(); + self.data.message = Vec::with_capacity(0); + self.data.priority = 0; + self.data.delivery_by = 0; + self.data.future_release = 0; + self.data.rcpt_oks = 0; + } + + pub fn reset_tls(&mut self) { + self.reset(); + self.data.helo_domain.clear(); + self.data.spf_ehlo = None; + self.data.authenticated_as = None; + } + + #[inline(always)] + pub async fn write(&mut self, bytes: &[u8]) -> Result<(), ()> { + match self.stream.write_all(bytes).await { + Ok(_) => match self.stream.flush().await { + Ok(_) => { + trc::event!( + Smtp(SmtpEvent::RawOutput), + SpanId = self.data.session_id, + Size = bytes.len(), + Contents = trc::Value::from_maybe_string(bytes), + ); + + Ok(()) + } + Err(err) => { + trc::event!( + Network(NetworkEvent::FlushError), + SpanId = self.data.session_id, + Reason = err.to_string(), + ); + Err(()) + } + }, + Err(err) => { + trc::event!( + Network(NetworkEvent::WriteError), + SpanId = self.data.session_id, + Reason = err.to_string(), + ); + + Err(()) + } + } + } + + #[inline(always)] + pub async fn read(&mut self, bytes: &mut [u8]) -> Result { + match self.stream.read(bytes).await { + Ok(len) => { + trc::event!( + Smtp(SmtpEvent::RawInput), + SpanId = self.data.session_id, + Size = len, + Contents = + String::from_utf8_lossy(bytes.get(0..len).unwrap_or_default()).into_owned(), + ); + + Ok(len) + } + Err(err) => { + trc::event!( + Network(NetworkEvent::ReadError), + SpanId = self.data.session_id, + Reason = err.to_string(), + ); + + Err(()) + } + } + } +} + +impl ResolveVariable for Session { + fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> { + match variable { + ExpressionVariable::Rcpt => self + .data + .rcpt_to + .last() + .map(|r| r.address_lcase.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::RcptDomain => self + .data + .rcpt_to + .last() + .map(|r| r.domain.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::Recipients => self + .data + .rcpt_to + .iter() + .map(|r| Variable::from(r.address_lcase.as_str())) + .collect::>() + .into(), + ExpressionVariable::Sender => self + .data + .mail_from + .as_ref() + .map(|m| m.address_lcase.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::SenderDomain => self + .data + .mail_from + .as_ref() + .map(|m| m.domain.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::HeloDomain => self.data.helo_domain.as_str().into(), + ExpressionVariable::AuthenticatedAs => { + self.authenticated_as().unwrap_or_default().into() + } + ExpressionVariable::Listener => self.instance.id.as_str().into(), + ExpressionVariable::RemoteIp => self.data.remote_ip_str.as_str().into(), + ExpressionVariable::RemotePort => self.data.remote_port.into(), + ExpressionVariable::LocalIp => self.data.local_ip_str.as_str().into(), + ExpressionVariable::LocalPort => self.data.local_port.into(), + ExpressionVariable::IsTls => self.stream.is_tls().into(), + ExpressionVariable::Priority => self.data.priority.to_compact_string().into(), + ExpressionVariable::Protocol => self.instance.protocol.as_str().into(), + ExpressionVariable::Asn => self + .data + .asn_geo_data + .asn + .as_ref() + .map(|a| a.id) + .unwrap_or_default() + .into(), + ExpressionVariable::Country => self + .data + .asn_geo_data + .country + .as_ref() + .map(|c| c.as_str()) + .unwrap_or_default() + .into(), + _ => expr::Variable::default(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} diff --git a/crates/smtp/src/inbound/spam.rs b/crates/smtp/src/inbound/spam.rs new file mode 100644 index 0000000..0ebb670 --- /dev/null +++ b/crates/smtp/src/inbound/spam.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::{config::mailstore::spamfilter::SpamFilterAction, network::SessionStream}; +use mail_auth::{ArcOutput, DkimOutput, DmarcResult, dkim2::Dkim2Output, dmarc::Policy}; +use mail_parser::Message; +use spam_filter::{ + SpamFilterInput, + analysis::{ + init::SpamFilterInit, + score::{SpamFilterAnalyzeScore, SpamFilterScore}, + }, +}; + +impl Session { + pub async fn spam_classify<'x>( + &'x self, + message: &'x Message<'x>, + dkim_result: &'x [DkimOutput<'x>], + dkim2_result: Option<&'x Dkim2Output<'x>>, + arc_result: Option<&'x ArcOutput<'x>>, + dmarc_result: Option<&'x DmarcResult>, + dmarc_policy: Option<&'x Policy>, + ) -> SpamFilterAction { + let server = &self.server; + let mut ctx = server.spam_filter_init(self.build_spam_input( + message, + dkim_result, + dkim2_result, + arc_result, + dmarc_result, + dmarc_policy, + )); + + if !self.is_authenticated() { + // Spam classification + server.spam_filter_classify(&mut ctx).await + } else { + // Do not classify authenticated sessions + SpamFilterAction::Disabled + } + } + + pub fn build_spam_input<'x>( + &'x self, + message: &'x Message<'x>, + dkim_result: &'x [DkimOutput<'x>], + dkim2_result: Option<&'x Dkim2Output<'x>>, + arc_result: Option<&'x ArcOutput>, + dmarc_result: Option<&'x DmarcResult>, + dmarc_policy: Option<&'x Policy>, + ) -> SpamFilterInput<'x> { + SpamFilterInput { + message, + span_id: self.data.session_id, + arc_result, + spf_ehlo_result: self.data.spf_ehlo.as_ref(), + spf_mail_from_result: self.data.spf_mail_from.as_ref(), + dkim_result, + dkim2_result, + dmarc_result, + dmarc_policy, + iprev_result: self.data.iprev.as_ref(), + remote_ip: self.data.remote_ip, + ehlo_domain: self.data.helo_domain.as_str().into(), + authenticated_as: self.data.authenticated_as.as_ref().map(|a| a.name()), + asn: self.data.asn_geo_data.asn.as_ref().map(|a| a.id), + country: self.data.asn_geo_data.country.as_ref().map(|c| c.as_str()), + is_tls: self.stream.is_tls(), + env_from: self + .data + .mail_from + .as_ref() + .map(|m| m.address_lcase.as_str()) + .unwrap_or_default(), + env_from_flags: self + .data + .mail_from + .as_ref() + .map(|m| m.flags) + .unwrap_or_default(), + env_rcpt_rewritten_to: self + .data + .rcpt_to + .iter() + .map(|r| r.address_lcase.as_str()) + .collect(), + env_rcpt_orig_to: self + .data + .rcpt_to + .iter() + .map(|r| { + r.dsn_info + .as_deref() + .and_then(|info| info.strip_prefix("rfc822;")) + .unwrap_or(r.address_lcase.as_str()) + }) + .collect(), + is_test: false, + is_train: false, + } + } +} diff --git a/crates/smtp/src/inbound/spawn.rs b/crates/smtp/src/inbound/spawn.rs new file mode 100644 index 0000000..7849224 --- /dev/null +++ b/crates/smtp/src/inbound/spawn.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + core::{Session, SessionData, SessionParameters, SmtpSessionManager, State}, + scripts::ScriptResult, +}; +use common::{ + BuildServer, + config::smtp::session::Stage, + network::{self, SessionManager, SessionStream}, +}; +use std::time::Instant; +use tokio_rustls::server::TlsStream; +use trc::{SecurityEvent, SmtpEvent}; + +impl SessionManager for SmtpSessionManager { + async fn handle(self, session: network::SessionData) { + // Build server and create session + let server = self.inner.build_server(); + let _in_flight = session.in_flight; + let mut session = Session { + data: SessionData::new( + session.local_ip, + session.local_port, + session.remote_ip, + session.remote_port, + server.lookup_asn_country(session.remote_ip).await, + session.session_id, + ), + hostname: "".into(), + server, + instance: session.instance, + state: State::default(), + stream: session.stream, + params: SessionParameters::default(), + }; + + // Enforce throttle + if session.is_allowed().await + && session.init_conn().await + && session.handle_conn().await + && session.instance.acceptor.is_tls() + && let Ok(mut session) = session.into_tls().await + { + session.handle_conn().await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async { + let _ = self + .inner + .ipc + .queue_tx + .send(common::ipc::QueueEvent::Stop) + .await; + let _ = self + .inner + .ipc + .report_tx + .send(common::ipc::ReportingEvent::Stop) + .await; + } + } +} + +impl Session { + pub async fn init_conn(&mut self) -> bool { + self.eval_session_params().await; + + let config = &self.server.core.smtp.session.connect; + + // Sieve filtering + if let Some((script, script_id)) = self + .server + .eval_if::(&config.script, self, self.data.session_id) + .await + .and_then(|name| { + self.server + .get_trusted_sieve_script(&name, self.data.session_id) + .map(|s| (s, name)) + }) + && let ScriptResult::Reject(message) = self + .run_script( + script_id, + script.clone(), + self.build_script_parameters("connect"), + ) + .await + { + let _ = self.write(message.as_bytes()).await; + return false; + } + + // Milter filtering + if let Err(message) = self.run_milters(Stage::Connect, None, None).await { + let _ = self.write(message.message.as_bytes()).await; + return false; + } + + // MTAHook filtering + if let Err(message) = self.run_mta_hooks(Stage::Connect, None, None).await { + let _ = self.write(message.message.as_bytes()).await; + return false; + } + + // Obtain hostname + self.hostname = self + .server + .eval_if::(&config.hostname, self, self.data.session_id) + .await + .unwrap_or_default(); + if self.hostname.is_empty() { + trc::event!( + Smtp(SmtpEvent::MissingLocalHostname), + SpanId = self.data.session_id, + ); + self.hostname = "localhost".into(); + } + + // Obtain greeting + let greeting = self + .server + .eval_if::(&config.greeting, self, self.data.session_id) + .await + .filter(|g| !g.is_empty()) + .map(|g| format!("220 {}\r\n", g)) + .unwrap_or_else(|| "220 Stalwart ESMTP at your service.\r\n".to_string()); + + if self.write(greeting.as_bytes()).await.is_err() { + return false; + } + + true + } + + pub async fn handle_conn(&mut self) -> bool { + let mut buf = vec![0; 8192]; + let mut shutdown_rx = self.instance.shutdown_rx.clone(); + + loop { + tokio::select! { + result = tokio::time::timeout( + self.params.timeout, + self.read(&mut buf)) => { + match result { + Ok(Ok(bytes_read)) => { + if bytes_read > 0 { + if Instant::now() < self.data.valid_until && bytes_read <= self.data.bytes_left { + self.data.bytes_left -= bytes_read; + match Box::pin(self.ingest(&buf[..bytes_read])).await { + Ok(true) => (), + Ok(false) => { + return true; + } + Err(_) => { + break; + } + } + } else if bytes_read > self.data.bytes_left { + self + .write(format!("452 4.7.28 {} Session exceeded transfer quota.\r\n", self.hostname).as_bytes()) + .await + .ok(); + + trc::event!( + Smtp(SmtpEvent::TransferLimitExceeded), + SpanId = self.data.session_id, + ); + + break; + } else { + self + .write(format!("421 4.3.2 {} Session open for too long.\r\n", self.hostname).as_bytes()) + .await + .ok(); + + match self.server.is_loiter_fail2banned(self.data.remote_ip) + .await + { + Ok(true) => { + trc::event!( + Security(SecurityEvent::LoiterBan), + SpanId = self.data.session_id, + RemoteIp = self.data.remote_ip, + ); + } + Ok(false) => { + trc::event!( + Smtp(SmtpEvent::TimeLimitExceeded), + SpanId = self.data.session_id, + ); + } + Err(err) => { + trc::error!(err + .span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to check if IP should be banned.")); + } + } + + break; + } + } else { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.data.session_id, + CausedBy = trc::location!() + ); + + break; + } + } + Ok(Err(_)) => { + break; + } + Err(_) => { + trc::event!( + Network(trc::NetworkEvent::Timeout), + SpanId = self.data.session_id, + CausedBy = trc::location!() + ); + + self + .write(format!("221 2.0.0 {} Disconnecting inactive client.\r\n", self.hostname).as_bytes()) + .await + .ok(); + break; + } + } + }, + _ = shutdown_rx.changed() => { + trc::event!( + Network(trc::NetworkEvent::Closed), + SpanId = self.data.session_id, + Reason = "Server shutting down", + CausedBy = trc::location!() + ); + self.write(format!("421 4.3.0 {} Server shutting down.\r\n", self.hostname).as_bytes()).await.ok(); + break; + } + }; + } + + false + } + + pub async fn into_tls(self) -> Result>, ()> { + Ok(Session { + hostname: self.hostname, + stream: self + .instance + .tls_accept(self.stream, self.data.session_id) + .await?, + state: self.state, + data: self.data, + instance: self.instance, + server: self.server, + params: self.params, + }) + } +} diff --git a/crates/smtp/src/inbound/vrfy.rs b/crates/smtp/src/inbound/vrfy.rs new file mode 100644 index 0000000..0a4e3a3 --- /dev/null +++ b/crates/smtp/src/inbound/vrfy.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use common::network::{RcptResolution, SessionStream}; +use std::{borrow::Cow, fmt::Write}; +use trc::SmtpEvent; +use utils::DomainPart; + +impl Session { + pub async fn handle_vrfy(&mut self, address: Cow<'_, str>) -> Result<(), ()> { + if self.params.can_vrfy { + match self + .server + .rcpt_resolve( + &address.to_lowercase_address(true), + true, + self.data.session_id, + ) + .await + { + Ok(RcptResolution::Accept | RcptResolution::Rewrite(_)) => { + trc::event!( + Smtp(SmtpEvent::Vrfy), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(format!("250 {}\r\n", address.as_ref()).as_bytes()) + .await + } + Ok( + RcptResolution::UnknownRecipient + | RcptResolution::UnknownDomain + | RcptResolution::Expand(_), + ) => { + trc::event!( + Smtp(SmtpEvent::VrfyNotFound), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(b"550 5.1.2 Address not found.\r\n").await + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to verify address.") + ); + + self.write(b"252 2.4.3 Unable to verify address at this time.\r\n") + .await + } + } + } else { + trc::event!( + Smtp(SmtpEvent::VrfyDisabled), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(b"252 2.5.1 VRFY is disabled.\r\n").await + } + } + + pub async fn handle_expn(&mut self, address: Cow<'_, str>) -> Result<(), ()> { + if self.params.can_expn { + match self + .server + .rcpt_resolve( + &address.to_lowercase_address(true), + true, + self.data.session_id, + ) + .await + { + Ok(RcptResolution::Expand(addresses)) => { + let mut result = String::with_capacity(32); + for (pos, value) in addresses.iter().enumerate() { + let _ = write!( + result, + "250{}{}\r\n", + if pos == addresses.len() - 1 { " " } else { "-" }, + value + ); + } + + trc::event!( + Smtp(SmtpEvent::Expn), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(result.as_bytes()).await + } + Ok(_) => { + trc::event!( + Smtp(SmtpEvent::ExpnNotFound), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(b"550 5.1.2 Mailing list not found.\r\n").await + } + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to verify address.") + ); + + self.write(b"252 2.4.3 Unable to expand mailing list at this time.\r\n") + .await + } + } + } else { + trc::event!( + Smtp(SmtpEvent::ExpnDisabled), + SpanId = self.data.session_id, + To = address.as_ref().to_string(), + ); + + self.write(b"252 2.5.1 EXPN is disabled.\r\n").await + } + } +} diff --git a/crates/smtp/src/lib.rs b/crates/smtp/src/lib.rs new file mode 100644 index 0000000..bf1cb5b --- /dev/null +++ b/crates/smtp/src/lib.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +use common::{ + Inner, + manager::boot::{BootManager, IpcReceivers}, +}; +use queue::manager::SpawnQueue; +use reporting::scheduler::SpawnReport; +use std::sync::Arc; + +pub mod core; +pub mod inbound; +pub mod outbound; +pub mod queue; +pub mod reporting; +pub mod scripts; + +pub trait StartQueueManager { + fn start_queue_manager(&mut self); +} + +pub trait SpawnQueueManager { + fn spawn_queue_manager(&mut self, inner: Arc); +} + +impl StartQueueManager for BootManager { + fn start_queue_manager(&mut self) { + self.ipc_rxs.spawn_queue_manager(self.inner.clone()); + } +} + +impl SpawnQueueManager for IpcReceivers { + fn spawn_queue_manager(&mut self, inner: Arc) { + let core = inner.shared_core.load(); + if !core.storage.registry.is_recovery_mode() && core.network.roles.outbound_mta { + // Spawn queue manager + self.queue_rx.take().unwrap().spawn(inner.clone()); + + // Spawn report manager + self.report_rx.take().unwrap().spawn(inner); + } + } +} diff --git a/crates/smtp/src/outbound/client.rs b/crates/smtp/src/outbound/client.rs new file mode 100644 index 0000000..eb0c61e --- /dev/null +++ b/crates/smtp/src/outbound/client.rs @@ -0,0 +1,773 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::session::SessionParams; +use crate::{ + outbound::error::{AssertReply, ClientError, ClientResult}, + queue::{Error, ErrorDetails, HostResponse, MessageWrapper, Status}, +}; +use base64::{Engine, engine::general_purpose}; +use directory::Credentials; +use rustls::ClientConnection; +use rustls_pki_types::ServerName; +use smtp_proto::{ + AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, EXT_START_TLS, EhloResponse, Response, + response::{ + generate::BitToString, + parser::{MAX_RESPONSE_LENGTH, ResponseReceiver}, + }, +}; +use std::{ + net::{IpAddr, SocketAddr}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpSocket, TcpStream}, +}; +use tokio_rustls::{TlsConnector, client::TlsStream}; +use trc::DeliveryEvent; + +pub struct SmtpClient { + pub stream: T, + pub timeout: Duration, + pub session_id: u64, +} + +impl SmtpClient { + pub async fn authenticate( + &mut self, + credentials: &Credentials, + capabilities: impl AsRef>, + ) -> ClientResult<&mut Self> { + let capabilities = capabilities.as_ref(); + let mut available_mechanisms = match &credentials { + Credentials::Basic { .. } => AUTH_LOGIN | AUTH_PLAIN, + Credentials::Bearer { .. } => AUTH_OAUTHBEARER | AUTH_XOAUTH2, + } & capabilities.auth_mechanisms; + + // Try authenticating from most secure to least secure + let mut has_err = None; + let mut has_failed = false; + + while available_mechanisms != 0 && !has_failed { + let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64); + available_mechanisms ^= mechanism; + match self.auth(mechanism, credentials).await { + Ok(_) => { + return Ok(self); + } + Err(err) => match err { + ClientError::UnexpectedReply(reply) => { + has_failed = reply.code() == 535; + has_err = reply.into(); + } + ClientError::UnsupportedAuthMechanism => (), + _ => return Err(err), + }, + } + } + + if let Some(has_err) = has_err { + Err(ClientError::AuthenticationFailed(has_err)) + } else { + Err(ClientError::UnsupportedAuthMechanism) + } + } + + pub(crate) async fn auth( + &mut self, + mechanism: u64, + credentials: &Credentials, + ) -> ClientResult<()> { + let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 { + self.cmd( + format!( + "AUTH {} {}\r\n", + mechanism.to_mechanism(), + encode_credentials(credentials, mechanism, "")?, + ) + .as_bytes(), + ) + .await? + } else { + self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes()) + .await? + }; + + for _ in 0..3 { + match reply.code() { + 334 => { + reply = self + .cmd( + format!( + "{}\r\n", + encode_credentials(credentials, mechanism, reply.message())? + ) + .as_bytes(), + ) + .await?; + } + 235 => { + return Ok(()); + } + _ => { + return Err(ClientError::UnexpectedReply(Box::new(reply))); + } + } + } + + Err(ClientError::UnexpectedReply(Box::new(reply))) + } + + pub async fn read_greeting( + &mut self, + hostname: &str, + ) -> Result<(), Status>, ErrorDetails>> { + tokio::time::timeout(self.timeout, self.read()) + .await + .map_err(|_| Status::timeout(hostname, "reading greeting"))? + .and_then(|r| r.assert_code(220)) + .map_err(|err| Status::from_smtp_error(hostname, "", err)) + } + + pub async fn read_smtp_data_response( + &mut self, + hostname: &str, + bdat_cmd: &Option, + ) -> Result, Status>, ErrorDetails>> { + tokio::time::timeout(self.timeout, self.read()) + .await + .map_err(|_| Status::timeout(hostname, "reading SMTP DATA response"))? + .map_err(|err| { + Status::from_smtp_error(hostname, bdat_cmd.as_deref().unwrap_or("DATA"), err) + }) + } + + pub async fn read_lmtp_data_response( + &mut self, + hostname: &str, + num_responses: usize, + ) -> Result>>, Status>, ErrorDetails>> { + tokio::time::timeout(self.timeout, async { self.read_many(num_responses).await }) + .await + .map_err(|_| Status::timeout(hostname, "reading LMTP DATA responses"))? + .map_err(|err| Status::from_smtp_error(hostname, "", err)) + } + + pub async fn write_chunks(&mut self, chunks: &[&[u8]]) -> Result<(), ClientError> { + for chunk in chunks { + self.stream + .write_all(chunk) + .await + .map_err(ClientError::from)?; + } + self.stream.flush().await.map_err(ClientError::from) + } + + pub async fn send_message( + &mut self, + message: &MessageWrapper, + rcpt_headers: Option<&[u8]>, + bdat_cmd: &mut Option, + params: &SessionParams<'_>, + ) -> Result<(), Status>, ErrorDetails>> { + match params + .server + .blob_store() + .get_blob(message.message.blob_hash.as_slice(), 0..usize::MAX) + .await + { + Ok(Some(raw_message)) => { + tokio::time::timeout(params.conn_strategy.timeout_data, async { + if let Some(bdat_cmd) = bdat_cmd { + *bdat_cmd = format!( + "BDAT {} LAST\r\n", + raw_message.len() + rcpt_headers.map(|h| h.len()).unwrap_or(0) + ); + + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = bdat_cmd.clone(), + Size = bdat_cmd.len() + ); + + let chunks = if let Some(rcpt_headers) = rcpt_headers { + &[bdat_cmd.as_bytes(), rcpt_headers, &raw_message][..] + } else { + &[bdat_cmd.as_bytes(), &raw_message][..] + }; + + self.write_chunks(chunks).await + } else { + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = "DATA\r\n", + Size = 6 + ); + + self.write_chunks(&[b"DATA\r\n"]).await?; + self.read().await?.assert_code(354)?; + if let Some(rcpt_headers) = rcpt_headers + && let Err(err) = self.write_chunks(&[rcpt_headers]).await + { + Err(err) + } else { + self.write_message(&raw_message) + .await + .map_err(ClientError::from) + } + } + }) + .await + .map_err(|_| Status::timeout(params.hostname, "sending message"))? + .map_err(|err| { + Status::from_smtp_error( + params.hostname, + bdat_cmd.as_deref().unwrap_or("DATA"), + err, + ) + }) + } + Ok(None) => { + trc::event!( + Queue(trc::QueueEvent::BlobNotFound), + SpanId = message.span_id, + BlobId = message.message.blob_hash.to_hex(), + CausedBy = trc::location!() + ); + Err(Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::Io("Queue system error.".into()), + })) + } + Err(err) => { + trc::error!( + err.span_id(message.span_id) + .details("Failed to fetch blobId") + .caused_by(trc::location!()) + ); + + Err(Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::Io("Queue system error.".into()), + })) + } + } + } + + pub async fn say_helo( + &mut self, + params: &SessionParams<'_>, + ) -> Result, Status>, ErrorDetails>> { + let cmd = if params.is_smtp { + format!("EHLO {}\r\n", params.local_hostname) + } else { + format!("LHLO {}\r\n", params.local_hostname) + }; + + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = cmd.clone(), + Size = cmd.len() + ); + + tokio::time::timeout(params.conn_strategy.timeout_ehlo, async { + self.stream.write_all(cmd.as_bytes()).await?; + self.stream.flush().await?; + self.read_ehlo().await + }) + .await + .map_err(|_| Status::timeout(params.hostname, "reading EHLO response"))? + .map_err(|err| Status::from_smtp_error(params.hostname, &cmd, err)) + } + + pub async fn quit(mut self: SmtpClient) { + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = "QUIT\r\n", + Size = 6 + ); + + let _ = tokio::time::timeout(Duration::from_secs(10), async { + if self.stream.write_all(b"QUIT\r\n").await.is_ok() && self.stream.flush().await.is_ok() + { + let mut buf = [0u8; 128]; + let _ = self.stream.read(&mut buf).await; + } + }) + .await; + } + + pub async fn read_ehlo(&mut self) -> ClientResult> { + let mut buf = vec![0u8; 8192]; + let mut buf_concat = Vec::with_capacity(0); + + loop { + let br = self.stream.read(&mut buf).await?; + + if br == 0 { + return Err(ClientError::UnparseableReply); + } + + trc::event!( + Delivery(DeliveryEvent::RawInput), + SpanId = self.session_id, + Contents = trc::Value::from_maybe_string(&buf[..br]), + Size = br, + ); + + let mut iter = if buf_concat.is_empty() { + buf[..br].iter() + } else if br + buf_concat.len() < MAX_RESPONSE_LENGTH { + buf_concat.extend_from_slice(&buf[..br]); + buf_concat.iter() + } else { + return Err(ClientError::UnparseableReply); + }; + + match EhloResponse::parse(&mut iter) { + Ok(reply) => return Ok(reply), + Err(err) => match err { + smtp_proto::Error::NeedsMoreData { .. } => { + if buf_concat.is_empty() { + buf_concat = buf[..br].to_vec(); + } + } + smtp_proto::Error::InvalidResponse { code } => { + match ResponseReceiver::from_code(code).parse(&mut iter) { + Ok(response) => { + return Err(ClientError::UnexpectedReply(Box::new(response))); + } + Err(smtp_proto::Error::NeedsMoreData { .. }) => { + if buf_concat.is_empty() { + buf_concat = buf[..br].to_vec(); + } + } + Err(_) => return Err(ClientError::UnparseableReply), + } + } + _ => { + return Err(ClientError::UnparseableReply); + } + }, + } + } + } + + pub async fn read(&mut self) -> ClientResult> { + let mut buf = vec![0u8; 8192]; + let mut parser = ResponseReceiver::default(); + + loop { + let br = self.stream.read(&mut buf).await?; + + if br > 0 { + trc::event!( + Delivery(DeliveryEvent::RawInput), + SpanId = self.session_id, + Contents = trc::Value::from_maybe_string(&buf[..br]), + Size = br + ); + + match parser.parse(&mut buf[..br].iter()) { + Ok(reply) => return Ok(reply), + Err(err) => match err { + smtp_proto::Error::NeedsMoreData { .. } => (), + _ => { + return Err(ClientError::UnparseableReply); + } + }, + } + } else { + return Err(ClientError::UnparseableReply); + } + } + } + + pub async fn read_many(&mut self, num: usize) -> ClientResult>>> { + let mut buf = vec![0u8; 1024]; + let mut response = Vec::with_capacity(num); + let mut parser = ResponseReceiver::default(); + + 'outer: loop { + let br = self.stream.read(&mut buf).await?; + + if br > 0 { + let mut iter = buf[..br].iter(); + + trc::event!( + Delivery(DeliveryEvent::RawInput), + SpanId = self.session_id, + Contents = trc::Value::from_maybe_string(&buf[..br]), + Size = br + ); + + loop { + match parser.parse(&mut iter) { + Ok(reply) => { + response.push(reply.into_box()); + if response.len() != num { + parser.reset(); + } else { + break 'outer; + } + } + Err(err) => match err { + smtp_proto::Error::NeedsMoreData { .. } => break, + _ => { + return Err(ClientError::UnparseableReply); + } + }, + } + } + } else { + return Err(ClientError::UnparseableReply); + } + } + + Ok(response) + } + + /// Sends a command to the SMTP server and waits for a reply. + pub async fn cmd(&mut self, cmd: impl AsRef<[u8]>) -> ClientResult> { + tokio::time::timeout(self.timeout, async { + let cmd = cmd.as_ref(); + + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = trc::Value::from_maybe_string(cmd), + Size = cmd.len() + ); + + self.stream.write_all(cmd).await?; + self.stream.flush().await?; + self.read().await + }) + .await + .map_err(|_| ClientError::Timeout)? + } + + pub async fn write_message(&mut self, message: &[u8]) -> tokio::io::Result<()> { + // Transparency procedure + let mut is_cr_or_lf = false; + + // As per RFC 5322bis, section 2.3: + // CR and LF MUST only occur together as CRLF; they MUST NOT appear + // independently in the body. + // For this reason, we apply the transparency procedure when there is + // a CR or LF followed by a dot. + + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = "[message]", + Size = message.len() + 5 + ); + + let mut last_pos = 0; + for (pos, byte) in message.iter().enumerate() { + if *byte == b'.' && is_cr_or_lf { + if let Some(bytes) = message.get(last_pos..pos) { + self.stream.write_all(bytes).await?; + self.stream.write_all(b".").await?; + last_pos = pos; + } + is_cr_or_lf = false; + } else { + is_cr_or_lf = *byte == b'\n' || *byte == b'\r'; + } + } + if let Some(bytes) = message.get(last_pos..) { + self.stream.write_all(bytes).await?; + } + self.stream.write_all("\r\n.\r\n".as_bytes()).await?; + self.stream.flush().await + } +} + +impl SmtpClient { + /// Upgrade the connection to TLS. + pub async fn start_tls( + mut self, + tls_connector: &TlsConnector, + hostname: &str, + ) -> ClientResult>> { + // Send STARTTLS command + self.cmd(b"STARTTLS\r\n") + .await? + .assert_positive_completion()?; + + self.into_tls(tls_connector, hostname).await + } + + pub async fn into_tls( + self, + tls_connector: &TlsConnector, + hostname: &str, + ) -> ClientResult>> { + tokio::time::timeout(self.timeout, async { + Ok(SmtpClient { + stream: tls_connector + .connect( + ServerName::try_from(hostname) + .map_err(|_| ClientError::InvalidTLSName)? + .to_owned(), + self.stream, + ) + .await + .map_err(|err| { + let kind = err.kind(); + if let Some(inner) = err.into_inner() { + match inner.downcast::() { + Ok(error) => ClientError::Tls(error), + Err(error) => ClientError::Io(std::io::Error::new(kind, error)), + } + } else { + ClientError::Io(std::io::Error::new(kind, "Unspecified")) + } + })?, + timeout: self.timeout, + session_id: self.session_id, + }) + }) + .await + .map_err(|_| ClientError::Timeout)? + } +} + +impl SmtpClient { + /// Connects to a remote host address + pub async fn connect( + remote_addr: SocketAddr, + timeout: Duration, + session_id: u64, + ) -> ClientResult { + tokio::time::timeout(timeout, async { + Ok(SmtpClient { + stream: TcpStream::connect(remote_addr).await?, + timeout, + session_id, + }) + }) + .await + .map_err(|_| ClientError::Timeout)? + } + + /// Connects to a remote host address using the provided local IP + pub async fn connect_using( + local_ip: IpAddr, + remote_addr: SocketAddr, + timeout: Duration, + session_id: u64, + ) -> ClientResult { + tokio::time::timeout(timeout, async { + let socket = if local_ip.is_ipv4() { + TcpSocket::new_v4()? + } else { + TcpSocket::new_v6()? + }; + socket.bind(SocketAddr::new(local_ip, 0))?; + + Ok(SmtpClient { + stream: socket.connect(remote_addr).await?, + timeout, + session_id, + }) + }) + .await + .map_err(|_| ClientError::Timeout)? + } + + pub async fn try_start_tls( + mut self, + tls_connector: &TlsConnector, + hostname: &str, + capabilities: &EhloResponse, + ) -> StartTlsResult { + if capabilities.has_capability(EXT_START_TLS) { + match self.cmd("STARTTLS\r\n").await { + Ok(response) => { + if response.code() == 220 { + match self.into_tls(tls_connector, hostname).await { + Ok(smtp_client) => StartTlsResult::Success { smtp_client }, + Err(error) => StartTlsResult::Error { error }, + } + } else { + StartTlsResult::Unavailable { + response: response.into_box().into(), + smtp_client: self, + } + } + } + Err(error) => StartTlsResult::Error { error }, + } + } else { + StartTlsResult::Unavailable { + smtp_client: self, + response: None, + } + } + } +} + +fn encode_credentials( + credentials: &Credentials, + mechanism: u64, + challenge: &str, +) -> ClientResult { + Ok(general_purpose::STANDARD.encode( + match (mechanism, credentials) { + ( + AUTH_PLAIN, + Credentials::Basic { + username, secret, .. + }, + ) => { + format!("\u{0}{}\u{0}{}", username, secret) + } + ( + AUTH_LOGIN, + Credentials::Basic { + username, secret, .. + }, + ) => { + let challenge = general_purpose::STANDARD.decode(challenge)?; + + if b"user name" + .eq_ignore_ascii_case(challenge.get(0..9).ok_or(ClientError::InvalidChallenge)?) + || b"username".eq_ignore_ascii_case( + // Because Google makes its own standards + challenge.get(0..8).ok_or(ClientError::InvalidChallenge)?, + ) + { + &username + } else if b"password" + .eq_ignore_ascii_case(challenge.get(0..8).ok_or(ClientError::InvalidChallenge)?) + { + &secret + } else { + return Err(ClientError::InvalidChallenge); + } + .to_string() + } + + (AUTH_XOAUTH2, Credentials::Bearer { token, username }) => format!( + "user={}\x01auth=Bearer {}\x01\x01", + username.as_deref().unwrap_or_default(), + token + ), + (AUTH_OAUTHBEARER, Credentials::Bearer { token, .. }) => token.to_string(), + _ => return Err(ClientError::UnsupportedAuthMechanism), + } + .as_bytes(), + )) +} + +impl SmtpClient> { + pub fn tls_connection(&self) -> &ClientConnection { + self.stream.get_ref().1 + } +} + +#[allow(clippy::large_enum_variant)] +pub enum StartTlsResult { + Success { + smtp_client: SmtpClient>, + }, + Error { + error: ClientError, + }, + Unavailable { + response: Option>>, + smtp_client: SmtpClient, + }, +} + +pub(crate) trait BoxResponse { + fn into_box(self) -> Response>; +} + +impl BoxResponse for Response { + fn into_box(self) -> Response> { + Response { + code: self.code, + esc: self.esc, + message: self.message.into_boxed_str(), + } + } +} + +pub(crate) fn from_mail_send_error(error: &ClientError) -> trc::Error { + let event = trc::EventType::Smtp(trc::SmtpEvent::Error).into_err(); + match error { + ClientError::Io(err) => event.details("I/O Error").reason(err), + ClientError::Tls(err) => event.details("TLS Error").reason(err), + ClientError::Base64(err) => event.details("Base64 Error").reason(err), + ClientError::InvalidChallenge => event + .details("SMTP Authentication Error") + .reason("Invalid Challenge"), + ClientError::UnparseableReply => event.details("Unparseable SMTP Reply"), + ClientError::UnexpectedReply(reply) => event + .details("Unexpected SMTP Response") + .ctx(trc::Key::Code, reply.code) + .ctx(trc::Key::Reason, reply.message.clone()), + ClientError::AuthenticationFailed(reply) => event + .details("SMTP Authentication Failed") + .ctx(trc::Key::Code, reply.code) + .ctx(trc::Key::Reason, reply.message.clone()), + ClientError::InvalidTLSName => event.details("Invalid TLS Name"), + ClientError::MissingCredentials => event.details("Missing Authentication Credentials"), + ClientError::MissingMailFrom => event.details("Missing Message Sender"), + ClientError::MissingRcptTo => event.details("Missing Message Recipients"), + ClientError::UnsupportedAuthMechanism => { + event.details("Unsupported Authentication Mechanism") + } + ClientError::Timeout => event.details("Connection Timeout"), + ClientError::MissingStartTls => event.details("STARTTLS not available"), + } +} + +pub(crate) fn from_error_status(err: &Status>, ErrorDetails>) -> trc::Error { + match err { + Status::Scheduled | Status::Completed(_) => { + trc::EventType::Smtp(trc::SmtpEvent::Error).into_err() + } + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + from_error_details(&err.details) + } + } +} + +pub(crate) fn from_error_details(err: &Error) -> trc::Error { + let event = trc::EventType::Smtp(trc::SmtpEvent::Error).into_err(); + match err { + Error::DnsError(err) => event.details("DNS Error").reason(err), + Error::UnexpectedResponse(reply) => event + .details("Unexpected SMTP Response") + .ctx(trc::Key::Code, reply.response.code) + .ctx(trc::Key::Details, reply.command.clone()) + .ctx(trc::Key::Reason, reply.response.message.clone()), + Error::ConnectionError(err) => event + .details("Connection Error") + .ctx(trc::Key::Reason, err.clone()), + Error::TlsError(err) => event + .details("TLS Error") + .ctx(trc::Key::Reason, err.clone()), + Error::DaneError(err) => event + .details("DANE Error") + .ctx(trc::Key::Reason, err.clone()), + Error::MtaStsError(err) => event.details("MTA-STS Error").reason(err), + Error::RateLimited => event.details("Rate Limited"), + Error::ConcurrencyLimited => event.details("Concurrency Limited"), + Error::Io(err) => event.details("I/O Error").reason(err), + } +} diff --git a/crates/smtp/src/outbound/dane/dnssec.rs b/crates/smtp/src/outbound/dane/dnssec.rs new file mode 100644 index 0000000..d2010d8 --- /dev/null +++ b/crates/smtp/src/outbound/dane/dnssec.rs @@ -0,0 +1,627 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}, +}; +pub use mail_auth::DnssecStatus; +use mail_auth::{ + MX, RecordSet, + common::resolver::ToFqdn, + hickory_resolver::{ + net::{DnsError, NetError}, + proto::{ + dnssec::Proof, + op::ResponseCode, + rr::{ + Name, RData, Record, RecordType, + rdata::tlsa::{CertUsage, Matching, Selector}, + }, + }, + }, +}; +use std::{ + future::Future, + net::{Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::{Duration, Instant}, +}; + +pub trait TlsaLookup: Sync + Send { + fn mx_lookup( + &self, + key: impl ToFqdn + Sync + Send, + ) -> impl Future>> + Send; + + fn tlsa_lookup( + &self, + key: impl ToFqdn + Sync + Send, + ) -> impl Future> + Send; + + fn ipv4_lookup_dnssec( + &self, + key: impl ToFqdn + Sync + Send, + ) -> impl Future>> + Send; + + fn ipv6_lookup_dnssec( + &self, + key: impl ToFqdn + Sync + Send, + ) -> impl Future>> + Send; +} + +pub enum TlsaResult { + Secure(Arc), + Bogus, + Missing, +} + +impl TlsaLookup for Server { + async fn mx_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result> { + if !self.core.smtp.resolvers.dnssec_available { + return self + .core + .smtp + .resolvers + .dns + .mx_lookup(key, Some(&self.inner.cache.dns_mx)) + .await; + } + + let key = key.to_fqdn().into_owned().into_boxed_str(); + if let Some(value) = self.inner.cache.dns_mx.get::(key.as_ref()) + && value.dnssec_status != DnssecStatus::Indeterminate + { + return Ok(value); + } + + #[cfg(any(test, feature = "test_mode"))] + if true { + return mail_auth::common::resolver::mock_resolve(key.as_ref()); + } + + let mx_lookup = match self + .core + .smtp + .resolvers + .dnssec + .resolver + .mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?) + .await + { + Ok(mx_lookup) => mx_lookup, + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) + && denial.response_code == ResponseCode::NoError + { + let records = RecordSet { + rrset: Arc::new([]), + dnssec_status: denial.dnssec_status, + }; + if let Some(valid_until) = denial.valid_until { + self.inner.cache.dns_mx.insert_with_expiry( + key, + records.clone(), + valid_until, + ); + } + return Ok(records); + } + return Err(err.into()); + } + }; + let mx_records = mx_lookup.answers(); + let mut dnssec_status: Option = None; + let mut records: Vec<(u16, Vec>)> = Vec::with_capacity(mx_records.len()); + for mx_record in mx_records { + if let RData::MX(mx) = &mx_record.data { + dnssec_status = Some(match dnssec_status { + Some(status) => least_secure(status, proof_to_dnssec_status(mx_record.proof)), + None => proof_to_dnssec_status(mx_record.proof), + }); + + let preference = mx.preference; + let exchange = mx.exchange.to_lowercase().to_ascii().into_boxed_str(); + + if let Some(record) = records.iter_mut().find(|r| r.0 == preference) { + record.1.push(exchange); + } else { + records.push((preference, vec![exchange])); + } + } + } + + records.sort_unstable_by_key(|a| a.0); + let rrset: Arc<[MX]> = records + .into_iter() + .map(|(preference, exchanges)| MX { + preference, + exchanges: exchanges.into_boxed_slice(), + }) + .collect::>(); + let records = RecordSet { + rrset, + dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate), + }; + + self.inner + .cache + .dns_mx + .insert_with_expiry(key, records.clone(), mx_lookup.valid_until()); + + Ok(records) + } + + async fn tlsa_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result { + let key = key.to_fqdn().into_owned().into_boxed_str(); + if let Some(value) = self.inner.cache.dns_tlsa.get(key.as_ref()) { + return Ok(TlsaResult::Secure(value)); + } + + #[cfg(any(test, feature = "test_mode"))] + if true { + if key.as_ref().contains("_dnssec_bogus.") { + return Ok(TlsaResult::Bogus); + } + return mail_auth::common::resolver::mock_resolve(key.as_ref()); + } + + let tlsa_lookup = match self + .core + .smtp + .resolvers + .dnssec + .resolver + .tlsa_lookup(Name::from_str_relaxed(key.as_ref())?) + .await + { + Ok(tlsa_lookup) => tlsa_lookup, + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) { + return Ok(if denial.dnssec_status == DnssecStatus::Bogus { + TlsaResult::Bogus + } else { + TlsaResult::Missing + }); + } + return Err(err.into()); + } + }; + + let mut entries = Vec::new(); + let mut has_end_entities = false; + let mut has_intermediates = false; + let mut dnssec_status: Option = None; + + for record in tlsa_lookup.answers() { + if let RData::TLSA(tlsa) = &record.data { + dnssec_status = Some(match dnssec_status { + Some(status) => least_secure(status, proof_to_dnssec_status(record.proof)), + None => proof_to_dnssec_status(record.proof), + }); + + if !record.proof.is_secure() { + continue; + } + + let is_end_entity = match tlsa.cert_usage { + CertUsage::DaneEe => true, + CertUsage::DaneTa => false, + _ => continue, + }; + let matching = match tlsa.matching { + Matching::Raw => TlsaMatching::Full, + Matching::Sha256 => TlsaMatching::Sha256, + Matching::Sha512 => TlsaMatching::Sha512, + _ => continue, + }; + let is_spki = match tlsa.selector { + Selector::Spki => true, + Selector::Full => false, + _ => continue, + }; + if is_end_entity { + has_end_entities = true; + } else { + has_intermediates = true; + } + entries.push(TlsaEntry { + is_end_entity, + is_spki, + matching, + data: tlsa.cert_data.clone(), + }); + } + } + + match dnssec_status { + Some(DnssecStatus::Bogus) => Ok(TlsaResult::Bogus), + Some(DnssecStatus::Secure) => { + let tlsa = Arc::new(Tlsa { + entries, + has_end_entities, + has_intermediates, + }); + + self.inner.cache.dns_tlsa.insert_with_expiry( + key, + tlsa.clone(), + tlsa_lookup.valid_until(), + ); + + Ok(TlsaResult::Secure(tlsa)) + } + _ => Ok(TlsaResult::Missing), + } + } + + async fn ipv4_lookup_dnssec( + &self, + key: impl ToFqdn + Sync + Send, + ) -> mail_auth::Result> { + if !self.core.smtp.resolvers.dnssec_available { + return self + .core + .smtp + .resolvers + .dns + .ipv4_lookup(key, Some(&self.inner.cache.dns_ipv4)) + .await; + } + + let key = key.to_fqdn().into_owned().into_boxed_str(); + if let Some(value) = self.inner.cache.dns_ipv4.get::(key.as_ref()) + && value.dnssec_status != DnssecStatus::Indeterminate + { + return Ok(value); + } + + #[cfg(any(test, feature = "test_mode"))] + if true { + return mail_auth::common::resolver::mock_resolve(key.as_ref()); + } + + let name = Name::from_str_relaxed::<&str>(key.as_ref())?; + let lookup = match self + .core + .smtp + .resolvers + .dnssec + .resolver + .ipv4_lookup(name.clone()) + .await + { + Ok(lookup) => lookup, + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) + && denial.response_code == ResponseCode::NoError + { + let records = RecordSet { + rrset: Arc::new([]), + dnssec_status: denial.dnssec_status, + }; + if let Some(valid_until) = denial.valid_until { + self.inner.cache.dns_ipv4.insert_with_expiry( + key, + records.clone(), + valid_until, + ); + } + return Ok(records); + } + return Err(err.into()); + } + }; + + let answers = lookup.answers(); + let records = RecordSet { + rrset: answers + .iter() + .filter_map(|record| match &record.data { + RData::A(addr) => Some(addr.0), + _ => None, + }) + .collect::>(), + dnssec_status: tlsa_base_status(&name, answers, RecordType::A), + }; + + self.inner + .cache + .dns_ipv4 + .insert_with_expiry(key, records.clone(), lookup.valid_until()); + + Ok(records) + } + + async fn ipv6_lookup_dnssec( + &self, + key: impl ToFqdn + Sync + Send, + ) -> mail_auth::Result> { + if !self.core.smtp.resolvers.dnssec_available { + return self + .core + .smtp + .resolvers + .dns + .ipv6_lookup(key, Some(&self.inner.cache.dns_ipv6)) + .await; + } + + let key = key.to_fqdn().into_owned().into_boxed_str(); + if let Some(value) = self.inner.cache.dns_ipv6.get::(key.as_ref()) + && value.dnssec_status != DnssecStatus::Indeterminate + { + return Ok(value); + } + + #[cfg(any(test, feature = "test_mode"))] + if true { + return mail_auth::common::resolver::mock_resolve(key.as_ref()); + } + + let name = Name::from_str_relaxed::<&str>(key.as_ref())?; + let lookup = match self + .core + .smtp + .resolvers + .dnssec + .resolver + .ipv6_lookup(name.clone()) + .await + { + Ok(lookup) => lookup, + Err(err) => { + if let Some(denial) = NegativeAnswer::from_error(&err) + && denial.response_code == ResponseCode::NoError + { + let records = RecordSet { + rrset: Arc::new([]), + dnssec_status: denial.dnssec_status, + }; + if let Some(valid_until) = denial.valid_until { + self.inner.cache.dns_ipv6.insert_with_expiry( + key, + records.clone(), + valid_until, + ); + } + return Ok(records); + } + return Err(err.into()); + } + }; + + let answers = lookup.answers(); + let records = RecordSet { + rrset: answers + .iter() + .filter_map(|record| match &record.data { + RData::AAAA(addr) => Some(addr.0), + _ => None, + }) + .collect::>(), + dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA), + }; + + self.inner + .cache + .dns_ipv6 + .insert_with_expiry(key, records.clone(), lookup.valid_until()); + + Ok(records) + } +} + +struct NegativeAnswer { + response_code: ResponseCode, + dnssec_status: DnssecStatus, + valid_until: Option, +} + +impl NegativeAnswer { + fn from_error(err: &NetError) -> Option { + let NetError::Dns(dns_error) = err else { + return None; + }; + + match dns_error { + DnsError::NoRecordsFound(no_records) => Some(NegativeAnswer { + response_code: no_records.response_code, + dnssec_status: no_records + .authorities + .as_deref() + .map(denial_dnssec_status) + .unwrap_or(DnssecStatus::Indeterminate), + valid_until: no_records + .negative_ttl + .map(|ttl| Instant::now() + Duration::from_secs(ttl as u64)), + }), + DnsError::Nsec { + response, proof, .. + } => Some(NegativeAnswer { + response_code: response.response_code, + dnssec_status: proof_to_dnssec_status(*proof), + valid_until: None, + }), + _ => None, + } + } +} + +fn denial_dnssec_status(authorities: &[Record]) -> DnssecStatus { + authorities + .iter() + .filter(|record| matches!(record.record_type(), RecordType::NSEC | RecordType::NSEC3)) + .map(|record| proof_to_dnssec_status(record.proof)) + .reduce(least_secure) + .unwrap_or(DnssecStatus::Indeterminate) +} + +fn proof_to_dnssec_status(proof: Proof) -> DnssecStatus { + match proof { + Proof::Secure => DnssecStatus::Secure, + Proof::Insecure => DnssecStatus::Insecure, + Proof::Bogus => DnssecStatus::Bogus, + Proof::Indeterminate => DnssecStatus::Indeterminate, + } +} + +fn tlsa_base_status(query: &Name, answers: &[Record], address_type: RecordType) -> DnssecStatus { + let mut addresses: Option = None; + let mut alias: Option = None; + + for record in answers { + let status = proof_to_dnssec_status(record.proof); + if record.record_type() == address_type { + addresses = Some(match addresses { + Some(current) => least_secure(current, status), + None => status, + }); + } else if record.record_type() == RecordType::CNAME && &record.name == query { + alias = Some(match alias { + Some(current) => least_secure(current, status), + None => status, + }); + } + } + + match (addresses, alias) { + (Some(DnssecStatus::Insecure), Some(DnssecStatus::Secure)) => DnssecStatus::Secure, + (Some(status), _) => status, + (None, _) => DnssecStatus::Indeterminate, + } +} + +pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus { + fn rank(status: DnssecStatus) -> u8 { + match status { + DnssecStatus::Bogus => 0, + DnssecStatus::Indeterminate => 1, + DnssecStatus::Insecure => 2, + DnssecStatus::Secure => 3, + } + } + + if rank(a) <= rank(b) { a } else { b } +} + +#[cfg(test)] +mod tests { + use super::*; + use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME}; + use std::net::Ipv4Addr; + + fn name(value: &str) -> Name { + Name::from_ascii(value).unwrap() + } + + fn address(owner: &str, proof: Proof) -> Record { + let mut record = + Record::from_rdata(name(owner), 3600, RData::A(A(Ipv4Addr::new(192, 0, 2, 1)))); + record.proof = proof; + record + } + + fn alias(owner: &str, target: &str, proof: Proof) -> Record { + let mut record = Record::from_rdata(name(owner), 3600, RData::CNAME(CNAME(name(target)))); + record.proof = proof; + record + } + + #[test] + fn tlsa_base_status_follows_address_records() { + let query = name("mx.example.org."); + + for (proof, expected) in [ + (Proof::Secure, DnssecStatus::Secure), + (Proof::Insecure, DnssecStatus::Insecure), + (Proof::Bogus, DnssecStatus::Bogus), + (Proof::Indeterminate, DnssecStatus::Indeterminate), + ] { + assert_eq!( + tlsa_base_status(&query, &[address("mx.example.org.", proof)], RecordType::A), + expected, + "proof {proof}" + ); + } + } + + #[test] + fn tlsa_base_status_is_indeterminate_without_addresses() { + assert_eq!( + tlsa_base_status(&name("mx.example.org."), &[], RecordType::A), + DnssecStatus::Indeterminate + ); + } + + #[test] + fn tlsa_base_status_takes_least_secure_address() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + address("mx.example.org.", Proof::Secure), + address("mx.example.org.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } + + #[test] + fn tlsa_base_status_keeps_secure_alias_to_insecure_zone() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.example.org.", "mx.provider.net.", Proof::Secure), + address("mx.provider.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Secure + ); + } + + #[test] + fn tlsa_base_status_skips_insecure_alias() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.example.org.", "mx.provider.net.", Proof::Insecure), + address("mx.provider.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } + + #[test] + fn tlsa_base_status_ignores_alias_below_query_name() { + let query = name("mx.example.org."); + + assert_eq!( + tlsa_base_status( + &query, + &[ + alias("mx.provider.net.", "mx.other.net.", Proof::Secure), + address("mx.other.net.", Proof::Insecure), + ], + RecordType::A + ), + DnssecStatus::Insecure + ); + } +} diff --git a/crates/smtp/src/outbound/dane/mod.rs b/crates/smtp/src/outbound/dane/mod.rs new file mode 100644 index 0000000..958f00f --- /dev/null +++ b/crates/smtp/src/outbound/dane/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod dnssec; +pub mod verify; diff --git a/crates/smtp/src/outbound/dane/verify.rs b/crates/smtp/src/outbound/dane/verify.rs new file mode 100644 index 0000000..b464874 --- /dev/null +++ b/crates/smtp/src/outbound/dane/verify.rs @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::queue::{Error, ErrorDetails, HostResponse, Status}; +use common::config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}; +use rustls_pki_types::{CertificateDer, Der, ServerName, TrustAnchor, UnixTime}; +use sha2::{Digest, Sha256, Sha512}; +use trc::DaneEvent; +use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert}; +use x509_parser::asn1_rs::Any; +use x509_parser::prelude::{FromDer, X509Certificate}; + +pub trait TlsaVerify { + fn verify( + &self, + session_id: u64, + hostname: &str, + reference_ids: &[&str], + certificates: Option<&[CertificateDer<'_>]>, + ) -> Result<(), Status>, ErrorDetails>>; +} + +impl TlsaVerify for Tlsa { + fn verify( + &self, + session_id: u64, + hostname: &str, + reference_ids: &[&str], + certificates: Option<&[CertificateDer<'_>]>, + ) -> Result<(), Status>, ErrorDetails>> { + let certificates = match certificates { + Some(certificates) if !certificates.is_empty() => certificates, + _ => { + trc::event!( + Dane(DaneEvent::NoCertificatesFound), + SpanId = session_id, + Hostname = hostname.to_string(), + ); + + return Err(Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::DaneError("No certificates were provided by host".into()), + })); + } + }; + + let mut parsed = Vec::with_capacity(certificates.len()); + for der_certificate in certificates { + match X509Certificate::from_der(der_certificate.as_ref()) { + Ok((_, cert)) => parsed.push(cert), + Err(err) => { + trc::event!( + Dane(DaneEvent::CertificateParseError), + SpanId = session_id, + Hostname = hostname.to_string(), + Reason = err.to_string(), + ); + + return Err(Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::DaneError("Failed to parse X.509 certificate".into()), + })); + } + } + } + + if verify_end_entity(self, session_id, hostname, certificates, &parsed) + || verify_trust_anchor( + self, + session_id, + hostname, + reference_ids, + certificates, + &parsed, + ) + { + trc::event!( + Dane(DaneEvent::AuthenticationSuccess), + SpanId = session_id, + Hostname = hostname.to_string(), + ); + + Ok(()) + } else { + trc::event!( + Dane(DaneEvent::AuthenticationFailure), + SpanId = session_id, + Hostname = hostname.to_string(), + ); + + Err(Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::DaneError("No matching certificates found in TLSA records".into()), + })) + } + } +} + +fn verify_end_entity( + tlsa: &Tlsa, + session_id: u64, + hostname: &str, + certificates: &[CertificateDer<'_>], + parsed: &[X509Certificate<'_>], +) -> bool { + if tlsa.has_end_entities { + for record in tlsa.entries.iter().filter(|record| record.is_end_entity) { + if record_matches(record, &parsed[0], certificates[0].as_ref()) { + trc::event!( + Dane(DaneEvent::TlsaRecordMatch), + SpanId = session_id, + Hostname = hostname.to_string(), + Type = "end-entity", + ); + return true; + } + } + } + + false +} + +fn verify_trust_anchor( + tlsa: &Tlsa, + session_id: u64, + hostname: &str, + reference_ids: &[&str], + certificates: &[CertificateDer<'_>], + parsed: &[X509Certificate<'_>], +) -> bool { + if !tlsa.has_intermediates { + return false; + } + + let end_entity = match EndEntityCert::try_from(&certificates[0]) { + Ok(end_entity) => end_entity, + Err(_) => return false, + }; + + let mut anchors: Vec> = Vec::new(); + + for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) { + match (record.is_spki, record.matching) { + (false, TlsaMatching::Full) => { + let der = CertificateDer::from(record.data.clone()); + if let Ok(anchor) = anchor_from_trusted_cert(&der) { + anchors.push(anchor.to_owned()); + } + } + (true, TlsaMatching::Full) => { + if let Some(depth) = (1..certificates.len()) + .find(|&depth| parsed[depth].public_key().raw == record.data.as_slice()) + { + if let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) { + anchors.push(anchor.to_owned()); + } + } else if let Some(spki) = der_value(&record.data) { + for depth in 1..certificates.len() { + if is_chain_top(parsed, depth) + && let Some(subject) = der_value(parsed[depth].issuer().as_raw()) + { + anchors.push(TrustAnchor { + subject: Der::from(subject.to_vec()), + subject_public_key_info: Der::from(spki.to_vec()), + name_constraints: None, + }); + } + } + } + } + _ => { + for depth in 1..certificates.len() { + if record_matches(record, &parsed[depth], certificates[depth].as_ref()) + && let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) + { + anchors.push(anchor.to_owned()); + } + } + } + } + } + + if anchors.is_empty() + || end_entity + .verify_for_usage( + ALL_VERIFICATION_ALGS, + &anchors, + &certificates[1..], + UnixTime::now(), + KeyUsage::server_auth(), + None, + None, + ) + .is_err() + || !reference_ids.iter().any(|reference| { + ServerName::try_from(*reference) + .map(|name| end_entity.verify_is_valid_for_subject_name(&name).is_ok()) + .unwrap_or(false) + }) + { + false + } else { + trc::event!( + Dane(DaneEvent::TlsaRecordMatch), + SpanId = session_id, + Hostname = hostname.to_string(), + Type = "trust-anchor", + ); + + true + } +} + +fn is_chain_top(parsed: &[X509Certificate<'_>], depth: usize) -> bool { + let issuer = parsed[depth].issuer().as_raw(); + !parsed + .iter() + .enumerate() + .any(|(other, cert)| other != depth && cert.subject().as_raw() == issuer) +} + +fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) -> bool { + let selected: &[u8] = if record.is_spki { + cert.public_key().raw + } else { + raw + }; + + match record.matching { + TlsaMatching::Full => selected == record.data.as_slice(), + TlsaMatching::Sha256 => Sha256::digest(selected).as_slice() == record.data.as_slice(), + TlsaMatching::Sha512 => Sha512::digest(selected).as_slice() == record.data.as_slice(), + } +} + +#[inline(always)] +fn der_value(der: &[u8]) -> Option<&[u8]> { + Any::from_der(der).ok().map(|(_, any)| any.data) +} diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs new file mode 100644 index 0000000..cd83494 --- /dev/null +++ b/crates/smtp/src/outbound/delivery.rs @@ -0,0 +1,1629 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{NextHop, lookup::ToNextHop, mta_sts, session::SessionParams}; +use crate::outbound::DeliveryResult; +use crate::outbound::client::{ + SmtpClient, from_error_details, from_error_status, from_mail_send_error, +}; +use crate::outbound::dane::dnssec::{DnssecStatus, TlsaLookup, TlsaResult}; +use crate::outbound::error::ClientError; +use crate::outbound::lookup::{DnsLookup, SourceIp}; +use crate::outbound::mta_sts::lookup::MtaStsLookup; +use crate::outbound::mta_sts::verify::VerifyPolicy; +use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify}; +use crate::queue::dsn::SendDsn; +use crate::queue::spool::SmtpSpool; +use crate::queue::throttle::IsAllowed; +use crate::queue::{ + Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage, + Status, +}; +use crate::reporting::send::MtaReportSend; +use crate::{queue::ErrorDetails, reporting::tls::TlsRptOptions}; +use ahash::AHashMap; +use common::Server; +use common::config::smtp::queue::RoutingStrategy; +use common::config::{server::ServerProtocol, smtp::report::AggregateFrequency}; +use common::ipc::{PolicyType, QueueEvent, QueueEventStatus, TlsEvent}; +use compact_str::ToCompactString; +use mail_auth::RecordSet; +use mail_auth::{ + mta_sts::TlsRpt, + report::tlsrpt::{FailureDetails, ResultType}, +}; +use smtp_proto::MAIL_REQUIRETLS; +use std::sync::Arc; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Instant, +}; +use store::write::{BatchBuilder, QueueClass, ValueClass, now}; +use trc::{DaneEvent, DeliveryEvent, MtaStsEvent, ServerEvent, TlsRptEvent}; + +impl QueuedMessage { + pub fn try_deliver(self, server: Server) { + tokio::spawn(async move { + // Lock queue event + let queue_id = self.queue_id; + let status = if server.try_lock_event(queue_id, self.queue_name).await { + if let Some(mut message) = server.read_message(queue_id, self.queue_name).await { + // Generate span id + message.span_id = server.inner.data.span_id_gen.generate(); + let span_id = message.span_id; + + trc::event!( + Delivery(DeliveryEvent::AttemptStart), + SpanId = message.span_id, + QueueId = message.queue_id, + QueueName = message.queue_name.to_string(), + From = if !message.message.return_path.is_empty() { + trc::Value::String(message.message.return_path.as_ref().into()) + } else { + trc::Value::String("<>".into()) + }, + To = message + .message + .recipients + .iter() + .filter_map(|r| { + if matches!( + r.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && r.queue == message.queue_name + { + Some(trc::Value::String(r.address().into())) + } else { + None + } + }) + .collect::>(), + Size = message.message.size, + Total = message.message.recipients.len(), + ); + + // Attempt delivery + let start_time = Instant::now(); + let queue_event = Box::pin(self.deliver_task(server.clone(), message)).await; + + trc::event!( + Delivery(DeliveryEvent::AttemptEnd), + SpanId = span_id, + Elapsed = start_time.elapsed(), + ); + + // Unlock event + server.unlock_event(queue_id, self.queue_name).await; + + queue_event + } else { + // Message no longer exists, delete queue event. + let mut batch = BatchBuilder::new(); + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: self.due, + queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), + }, + ))); + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to delete queue event.") + .caused_by(trc::location!()) + ); + } + + // Unlock event + server.unlock_event(queue_id, self.queue_name).await; + + QueueEventStatus::Completed + } + } else { + QueueEventStatus::Locked + }; + + // Notify queue manager + if server + .inner + .ipc + .queue_tx + .send(QueueEvent::WorkerDone { + queue_id, + queue_name: self.queue_name, + status, + }) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Reason = "Channel closed.", + CausedBy = trc::location!(), + ); + } + }); + } + + #[allow(clippy::type_complexity)] + async fn deliver_task(self, server: Server, mut message: MessageWrapper) -> QueueEventStatus { + // Check that the message still has recipients to be delivered + let has_pending_delivery = message.has_pending_delivery(); + let span_id = message.span_id; + + // Send any due Delivery Status Notifications + server.send_dsn(&mut message).await; + + match has_pending_delivery { + PendingDelivery::Yes(true) + if message + .message + .next_delivery_event(self.queue_name.into()) + .is_some_and(|due| due <= now()) => {} + PendingDelivery::No => { + trc::event!( + Delivery(DeliveryEvent::Completed), + SpanId = span_id, + Elapsed = trc::Value::Duration((now() - message.message.created) * 1000) + ); + + // All message recipients expired, do not re-queue. (DSN has been already sent) + message.remove(&server, self.due.into()).await; + + return QueueEventStatus::Completed; + } + _ => { + // Re-queue the message if its not yet due for delivery + message.save_changes(&server, self.due.into()).await; + return QueueEventStatus::Deferred; + } + } + + // Throttle sender + for throttle in &server.core.smtp.queue.outbound_limiters.sender { + if let Err(retry_at) = server.is_allowed(throttle, &message, message.span_id).await { + trc::event!( + Delivery(DeliveryEvent::RateLimitExceeded), + Id = throttle.id.to_string(), + SpanId = span_id, + NextRetry = trc::Value::Timestamp(retry_at) + ); + + let now = now(); + for rcpt in message.message.recipients.iter_mut() { + if matches!( + &rcpt.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && rcpt.retry.due <= now + && rcpt.queue == message.queue_name + { + rcpt.retry.due = retry_at; + rcpt.status = Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::RateLimited, + }); + } + } + + message.save_changes(&server, self.due.into()).await; + + return QueueEventStatus::Deferred; + } + } + + // Group recipients by route + let queue_config = &server.core.smtp.queue; + let now_ = now(); + let mut routes: AHashMap<(&str, &RoutingStrategy, Option<&[u8]>), Vec> = + AHashMap::new(); + let mut has_rcpt_headers = false; + let mut default_rcpt_header = None; + for metadata in message.message.metadata.iter() { + if let Metadata::Headers { value, id } = metadata { + has_rcpt_headers = true; + if *id == u64::MAX { + default_rcpt_header = Some(value.as_ref()); + break; + } + } + } + for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { + if matches!( + &rcpt.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && rcpt.retry.due <= now_ + && rcpt.queue == message.queue_name + { + let envelope = QueueEnvelope::new(&message.message, rcpt); + let route = server.get_route_or_default( + &server + .eval_if::(&queue_config.route, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Map RCPT headers + let mut rcpt_headers = default_rcpt_header; + if has_rcpt_headers { + for metadata in message.message.metadata.iter() { + if let Metadata::Headers { value, id } = metadata + && *id == rcpt_idx as u64 + { + rcpt_headers = Some(value.as_ref()); + break; + } + } + } + + routes + .entry((rcpt.domain_part(), route, rcpt_headers)) + .or_default() + .push(rcpt_idx); + } + } + + let no_ip = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)); + let mut delivery_results: Vec = Vec::new(); + 'next_route: for ((domain, route, rcpt_headers), rcpt_idxs) in routes { + trc::event!( + Delivery(DeliveryEvent::DomainDeliveryStart), + SpanId = message.span_id, + Domain = domain.to_string(), + ); + + // Build envelope + let mut envelope = + QueueEnvelope::new(&message.message, &message.message.recipients[rcpt_idxs[0]]); + + // Throttle recipient domain + for throttle in &queue_config.outbound_limiters.rcpt { + if let Err(retry_at) = server + .is_allowed(throttle, &envelope, message.span_id) + .await + { + trc::event!( + Delivery(DeliveryEvent::RateLimitExceeded), + Id = throttle.id.to_string(), + SpanId = span_id, + Domain = domain.to_string(), + ); + + delivery_results.push(DeliveryResult::rate_limited(rcpt_idxs, retry_at)); + continue 'next_route; + } + } + + // Obtain next hop + let (mut remote_hosts, mx_config, is_smtp) = match route { + RoutingStrategy::Local => { + // Deliver message locally + message + .deliver_local(&rcpt_idxs, &mut delivery_results, &server) + .await; + continue 'next_route; + } + RoutingStrategy::Mx(mx_config) => (Vec::with_capacity(0), Some(mx_config), true), + RoutingStrategy::Relay(relay_config) => ( + vec![NextHop::Relay(relay_config)], + None, + relay_config.protocol == ServerProtocol::Smtp, + ), + }; + + // Prepare TLS strategy + let mut tls_strategy = server.get_tls_or_default( + &server + .eval_if::(&queue_config.tls, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Obtain TLS reporting + let tls_report = if is_smtp + && mx_config.is_some() + && (message.message.flags & FROM_REPORT == 0) + { + match server + .eval_if( + &server.core.smtp.report.tls.send, + &envelope, + message.span_id, + ) + .await + .unwrap_or(AggregateFrequency::Never) + { + interval @ (AggregateFrequency::Hourly + | AggregateFrequency::Daily + | AggregateFrequency::Weekly) => { + let time = Instant::now(); + match server + .core + .smtp + .resolvers + .dns + .txt_lookup::( + format!("_smtp._tls.{domain}."), + Some(&server.inner.cache.dns_txt), + ) + .await + { + Ok(record) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordFetch), + SpanId = message.span_id, + Domain = domain.to_string(), + Details = record + .rua + .iter() + .map(|uri| trc::Value::from(match uri { + mail_auth::mta_sts::ReportUri::Mail(uri) + | mail_auth::mta_sts::ReportUri::Http(uri) => + uri.to_string(), + })) + .collect::>(), + Elapsed = time.elapsed(), + ); + + TlsRptOptions { record, interval }.into() + } + Err(mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordNotFound), + SpanId = message.span_id, + Domain = domain.to_string(), + Elapsed = time.elapsed(), + ); + None + } + Err(err) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + CausedBy = trc::Error::from(err), + Elapsed = time.elapsed(), + ); + None + } + } + } + _ => None, + } + } else { + None + }; + + // Obtain MTA-STS policy for domain + let mta_sts_policy = if mx_config.is_some() && tls_strategy.try_mta_sts() && is_smtp { + let time = Instant::now(); + match server + .lookup_mta_sts_policy(domain, tls_strategy.timeout_mta_sts) + .await + { + Ok(mta_sts_policy) => { + trc::event!( + MtaSts(MtaStsEvent::PolicyFetch), + SpanId = message.span_id, + Domain = domain.to_string(), + Strict = mta_sts_policy.enforce(), + Details = mta_sts_policy + .mx + .iter() + .map(|mx| trc::Value::String(mx.to_compact_string())) + .collect::>(), + Elapsed = time.elapsed(), + ); + + mta_sts_policy.into() + } + Err(err) => { + // Report MTA-STS error + let strict = tls_strategy.is_mta_sts_required(); + if let Some(tls_report) = &tls_report { + match &err { + mta_sts::Error::Dns(mail_auth::Error::Dns( + mail_auth::DnsError::RecordNotFound(_), + )) => { + if strict { + server.schedule_report(TlsEvent { + policy: PolicyType::Sts(None), + domain: domain.to_string(), + failure: FailureDetails::new(ResultType::Other) + .with_failure_reason_code( + "MTA-STS is required and no policy was found.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + } + mta_sts::Error::Dns(mail_auth::Error::Dns( + mail_auth::DnsError::Resolver(_), + )) => (), + _ => { + server + .schedule_report(TlsEvent { + policy: PolicyType::Sts(None), + domain: domain.to_string(), + failure: FailureDetails::new(&err) + .with_failure_reason_code(err.to_string()) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + } + } + + match &err { + mta_sts::Error::Dns(mail_auth::Error::Dns( + mail_auth::DnsError::RecordNotFound(_), + )) => { + trc::event!( + MtaSts(MtaStsEvent::PolicyNotFound), + SpanId = message.span_id, + Domain = domain.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + } + mta_sts::Error::Dns(err) => { + trc::event!( + MtaSts(MtaStsEvent::PolicyFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + CausedBy = trc::Error::from(err.clone()), + Strict = strict, + Elapsed = time.elapsed(), + ); + } + mta_sts::Error::Http(err) => { + trc::event!( + MtaSts(MtaStsEvent::PolicyFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + Reason = err.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + } + mta_sts::Error::InvalidPolicy(reason) => { + trc::event!( + MtaSts(MtaStsEvent::InvalidPolicy), + SpanId = message.span_id, + Domain = domain.to_string(), + Reason = reason.clone(), + Strict = strict, + Elapsed = time.elapsed(), + ); + } + } + + if strict { + delivery_results.push(DeliveryResult::domain( + Status::from_mta_sts_error(domain, err), + rcpt_idxs, + )); + continue 'next_route; + } + + None + } + } + } else { + None + }; + + // Obtain remote hosts list + let mx_list; + if let Some(mx_config) = mx_config { + // Lookup MX + let time = Instant::now(); + mx_list = match server.mx_lookup(domain).await { + Ok(mx) => mx, + Err(mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => { + trc::event!( + Delivery(DeliveryEvent::MxLookupFailed), + SpanId = message.span_id, + Domain = domain.to_string(), + Details = "No MX records were found, attempting implicit MX.", + Elapsed = time.elapsed(), + ); + + RecordSet { + rrset: Arc::new([]), + dnssec_status: DnssecStatus::Indeterminate, + } + } + Err(err) => { + trc::event!( + Delivery(DeliveryEvent::MxLookupFailed), + SpanId = message.span_id, + Domain = domain.to_string(), + CausedBy = trc::Error::from(err.clone()), + Elapsed = time.elapsed(), + ); + + delivery_results.push(DeliveryResult::domain( + Status::from_mail_auth_error(domain, err), + rcpt_idxs, + )); + continue 'next_route; + } + }; + + if let Some(remote_hosts_) = mx_list.to_remote_hosts(domain, mx_config) { + trc::event!( + Delivery(DeliveryEvent::MxLookup), + SpanId = message.span_id, + Domain = domain.to_string(), + Details = remote_hosts_ + .iter() + .map(|h| trc::Value::String(h.hostname().into())) + .collect::>(), + Elapsed = time.elapsed(), + ); + remote_hosts = remote_hosts_; + } else { + trc::event!( + Delivery(DeliveryEvent::NullMx), + SpanId = message.span_id, + Domain = domain.to_string(), + Elapsed = time.elapsed(), + ); + + delivery_results.push(DeliveryResult::domain( + Status::PermanentFailure(ErrorDetails { + entity: domain.into(), + details: Error::DnsError( + "Domain does not accept messages (null MX)".into(), + ), + }), + rcpt_idxs, + )); + continue 'next_route; + } + } + + // Try delivering message + let mut last_status: Status>, ErrorDetails> = Status::Scheduled; + 'next_host: for remote_host in &remote_hosts { + // Validate MTA-STS + envelope.mx = remote_host.hostname(); + if let Some(mta_sts_policy) = &mta_sts_policy { + let strict = mta_sts_policy.enforce(); + if !mta_sts_policy.verify(envelope.mx) { + // Report MTA-STS failed verification + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: mta_sts_policy.into(), + domain: domain.to_string(), + failure: FailureDetails::new(ResultType::ValidationFailure) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code("MX not authorized by policy.") + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + trc::event!( + MtaSts(MtaStsEvent::NotAuthorized), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = mta_sts_policy + .mx + .iter() + .map(|mx| trc::Value::String(mx.to_compact_string())) + .collect::>(), + Strict = strict, + ); + + if strict { + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::MtaStsError( + format!("MX {:?} not authorized by policy.", envelope.mx) + .into_boxed_str(), + ), + }); + continue 'next_host; + } + } else { + trc::event!( + MtaSts(MtaStsEvent::Authorized), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = mta_sts_policy + .mx + .iter() + .map(|mx| trc::Value::String(mx.to_compact_string())) + .collect::>(), + Strict = strict, + ); + } + } + + // Update TLS strategy + tls_strategy = server.get_tls_or_default( + &server + .eval_if::(&queue_config.tls, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Obtain source and remote IPs + let time = Instant::now(); + let validate_addresses = server.core.smtp.resolvers.dnssec_available + && tls_strategy.try_dane() + && is_smtp + && remote_host.dnssec_status() == DnssecStatus::Secure; + let (remote_ips, addresses_dnssec_status) = match server + .resolve_host(remote_host, &envelope, validate_addresses) + .await + { + Ok(resolved) => { + trc::event!( + Delivery(DeliveryEvent::IpLookup), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = resolved + .ips + .iter() + .map(|ip| trc::Value::from(*ip)) + .collect::>(), + Limit = remote_host.max_multi_homed(), + Elapsed = time.elapsed(), + ); + + (resolved.ips, resolved.dnssec_status) + } + Err(status) => { + trc::event!( + Delivery(DeliveryEvent::IpLookupFailed), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = status.to_string(), + Elapsed = time.elapsed(), + ); + + last_status = status; + continue 'next_host; + } + }; + + // Lookup DANE policy + let mut dane_requires_encryption = false; + let dane_policy = if tls_strategy.try_dane() && is_smtp { + let time = Instant::now(); + let strict = tls_strategy.is_dane_required(); + + let (dnssec_status, dnssec_entity) = match remote_host.dnssec_status() { + DnssecStatus::Secure => match addresses_dnssec_status { + status @ (DnssecStatus::Insecure | DnssecStatus::Bogus) => { + (status, "A/AAAA") + } + _ => (DnssecStatus::Secure, "MX"), + }, + status => (status, "MX"), + }; + + match dnssec_status { + DnssecStatus::Secure => { + match server + .tlsa_lookup(format!("_25._tcp.{}.", envelope.mx)) + .await + { + Ok(TlsaResult::Secure(tlsa)) => { + if tlsa.has_end_entities || tlsa.has_intermediates { + trc::event!( + Dane(DaneEvent::TlsaRecordFetch), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = format!("{tlsa:?}"), + Strict = strict, + Elapsed = time.elapsed(), + ); + + tlsa.into() + } else { + trc::event!( + Dane(DaneEvent::TlsaRecordInvalid), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = format!("{tlsa:?}"), + Strict = strict, + Elapsed = time.elapsed(), + ); + + // Report invalid TLSA record + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: tlsa.into(), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::TlsaInvalid, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "Invalid TLSA record.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + if strict { + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No valid TLSA records were found".into(), + ), + }); + continue 'next_host; + } + + dane_requires_encryption = true; + None + } + } + Ok(TlsaResult::Bogus) => { + trc::event!( + Dane(DaneEvent::BogusDnssecRecord), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = "TLSA", + Strict = strict, + Elapsed = time.elapsed(), + ); + + // Report bogus TLSA record + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::DnssecInvalid, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "Bogus TLSA records were found.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "Bogus TLSA records were found".into(), + ), + }); + continue 'next_host; + } + Ok(TlsaResult::Missing) => { + trc::event!( + Dane(DaneEvent::TlsaRecordNotDnssecSigned), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + + if strict { + // Report DANE required + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::DaneRequired, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "No TLSA DNSSEC records found.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No TLSA DNSSEC records found".into(), + ), + }); + continue 'next_host; + } + None + } + Err(err) => { + let not_found = matches!( + &err, + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound( + _ + )) + ); + + if not_found { + trc::event!( + Dane(DaneEvent::TlsaRecordNotFound), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + + if strict { + // Report DANE required + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::DaneRequired, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "No TLSA records found for MX.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No TLSA records found".into(), + ), + }); + continue 'next_host; + } + None + } else { + trc::event!( + Dane(DaneEvent::TlsaRecordFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + CausedBy = trc::Error::from(err.clone()), + Strict = strict, + Elapsed = time.elapsed(), + ); + + last_status = + Status::from_mail_auth_error(envelope.mx, err); + continue 'next_host; + } + } + } + } + DnssecStatus::Bogus => { + trc::event!( + Dane(DaneEvent::BogusDnssecRecord), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = dnssec_entity, + Strict = strict, + Elapsed = time.elapsed(), + ); + + // Report bogus DNS record + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new(ResultType::DnssecInvalid) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code(format!( + "Bogus {dnssec_entity} records were found." + )) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + format!("Bogus {dnssec_entity} records were found").into(), + ), + }); + continue 'next_host; + } + _ => { + trc::event!( + Dane(DaneEvent::TlsaRecordNotDnssecSigned), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Strict = strict, + Elapsed = time.elapsed(), + ); + + if strict { + // Report DANE required + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: PolicyType::Tlsa(None), + domain: domain.to_string(), + failure: FailureDetails::new(ResultType::DaneRequired) + .with_receiving_mx_hostname(envelope.mx) + .with_failure_reason_code( + "MX host is not in a DNSSEC signed zone.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = Status::TemporaryFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No TLSA DNSSEC records found".into(), + ), + }); + continue 'next_host; + } + None + } + } + } else { + None + }; + + // Try each IP address + 'next_ip: for remote_ip in remote_ips { + // Throttle remote host + envelope.remote_ip = remote_ip; + for throttle in &queue_config.outbound_limiters.remote { + if let Err(retry_at) = server + .is_allowed(throttle, &envelope, message.span_id) + .await + { + trc::event!( + Delivery(DeliveryEvent::RateLimitExceeded), + SpanId = message.span_id, + Id = throttle.id.to_string(), + RemoteIp = remote_ip, + ); + delivery_results + .push(DeliveryResult::rate_limited(rcpt_idxs, retry_at)); + continue 'next_route; + } + } + + // Obtain connection parameters + let conn_strategy = server.get_connection_or_default( + &server + .eval_if::( + &queue_config.connection, + &envelope, + message.span_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Set source IP, if any + let ip_host = conn_strategy.source_ip(remote_ip.is_ipv4()); + + // Connect + let time = Instant::now(); + let mut smtp_client = match if let Some(ip_host) = ip_host { + envelope.local_ip = ip_host.ip; + SmtpClient::connect_using( + ip_host.ip, + SocketAddr::new(remote_ip, remote_host.port()), + conn_strategy.timeout_connect, + span_id, + ) + .await + } else { + envelope.local_ip = no_ip; + SmtpClient::connect( + SocketAddr::new(remote_ip, remote_host.port()), + conn_strategy.timeout_connect, + span_id, + ) + .await + } { + Ok(smtp_client) => { + trc::event!( + Delivery(DeliveryEvent::Connect), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + LocalIp = envelope.local_ip, + RemoteIp = remote_ip, + RemotePort = remote_host.port(), + Elapsed = time.elapsed(), + ); + + smtp_client + } + Err(err) => { + trc::event!( + Delivery(DeliveryEvent::ConnectError), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + LocalIp = envelope.local_ip, + RemoteIp = remote_ip, + RemotePort = remote_host.port(), + CausedBy = from_mail_send_error(&err), + Elapsed = time.elapsed(), + ); + + last_status = Status::from_smtp_error(envelope.mx, "", err); + continue 'next_ip; + } + }; + + // Obtain session parameters + let local_hostname = ip_host + .and_then(|ip| ip.host.as_deref()) + .or(conn_strategy.ehlo_hostname.as_deref()) + .unwrap_or(server.core.network.server_name.as_str()); + let mut params = SessionParams { + session_id: message.span_id, + server: &server, + credentials: remote_host.credentials(), + is_smtp: remote_host.is_smtp(), + hostname: envelope.mx, + local_hostname, + conn_strategy, + capabilities: None, + }; + + // Prepare TLS connector + let is_mta_sts_enforced = mta_sts_policy + .as_ref() + .is_some_and(|policy| policy.enforce()); + let is_strict_tls = tls_strategy.is_tls_required() + || (message.message.flags & MAIL_REQUIRETLS) != 0 + || is_mta_sts_enforced + || dane_policy.is_some() + || dane_requires_encryption; + let tls_connector = if dane_policy.is_some() + || dane_requires_encryption + || remote_host.allow_invalid_certs() + || (tls_strategy.allow_invalid_certs && !is_mta_sts_enforced) + { + &server.inner.data.smtp_connectors.dummy_verify + } else { + &server.inner.data.smtp_connectors.pki_verify + }; + + if !remote_host.implicit_tls() { + // Read greeting + smtp_client.timeout = conn_strategy.timeout_greeting; + if let Err(status) = smtp_client.read_greeting(envelope.mx).await { + trc::event!( + Delivery(DeliveryEvent::GreetingFailed), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = status.to_string(), + ); + + last_status = status; + continue 'next_host; + } + + // Say EHLO + let time = Instant::now(); + let capabilities = match smtp_client.say_helo(¶ms).await { + Ok(capabilities) => { + trc::event!( + Delivery(DeliveryEvent::Ehlo), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = capabilities.capabilities(), + Elapsed = time.elapsed(), + ); + + capabilities + } + Err(status) => { + trc::event!( + Delivery(DeliveryEvent::EhloRejected), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = status.to_string(), + Elapsed = time.elapsed(), + ); + + last_status = status; + continue 'next_host; + } + }; + + // Try starting TLS + if tls_strategy.try_start_tls() { + let time = Instant::now(); + smtp_client.timeout = tls_strategy.timeout_tls; + match smtp_client + .try_start_tls(tls_connector, envelope.mx, &capabilities) + .await + { + StartTlsResult::Success { smtp_client } => { + trc::event!( + Delivery(DeliveryEvent::StartTls), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Version = format!( + "{:?}", + smtp_client + .tls_connection() + .protocol_version() + .unwrap() + ), + Details = format!( + "{:?}", + smtp_client + .tls_connection() + .negotiated_cipher_suite() + .unwrap() + ), + Elapsed = time.elapsed(), + ); + + // Verify DANE + if let Some(dane_policy) = &dane_policy + && let Err(status) = dane_policy.verify( + message.span_id, + envelope.mx, + &[envelope.mx, domain], + smtp_client.tls_connection().peer_certificates(), + ) + { + // Report DANE verification failure + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: dane_policy.into(), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::ValidationFailure, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_receiving_ip(remote_ip) + .with_failure_reason_code( + "No matching certificates found.", + ) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = status; + continue 'next_host; + } + + // Report TLS success + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: (&mta_sts_policy, &dane_policy).into(), + domain: domain.to_string(), + failure: None, + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + // Deliver message over TLS + message + .deliver( + smtp_client, + rcpt_idxs, + rcpt_headers, + &mut delivery_results, + params, + ) + .await + } + StartTlsResult::Unavailable { + response, + smtp_client, + } => { + // Report unavailable STARTTLS + let reason = + response.as_ref().map(|r| r.to_string()).unwrap_or_else( + || "STARTTLS was not advertised by host".to_string(), + ); + + trc::event!( + Delivery(DeliveryEvent::StartTlsUnavailable), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Code = response.as_ref().map(|r| r.code()), + Details = response + .as_ref() + .map(|r| r.message().as_ref()) + .unwrap_or("STARTTLS was not advertised by host") + .to_string(), + Elapsed = time.elapsed(), + ); + + if let Some(tls_report) = &tls_report { + server + .schedule_report(TlsEvent { + policy: (&mta_sts_policy, &dane_policy).into(), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::StartTlsNotSupported, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_receiving_ip(remote_ip) + .with_failure_reason_code(reason) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + if is_strict_tls { + last_status = + Status::from_starttls_error(envelope.mx, response); + continue 'next_host; + } else { + // TLS is not required, proceed in plain-text + params.capabilities = Some(capabilities); + message + .deliver( + smtp_client, + rcpt_idxs, + rcpt_headers, + &mut delivery_results, + params, + ) + .await + } + } + StartTlsResult::Error { error } => { + trc::event!( + Delivery(DeliveryEvent::StartTlsError), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Reason = from_mail_send_error(&error), + Elapsed = time.elapsed(), + ); + + // Report TLS failure + if let (Some(tls_report), ClientError::Tls(error)) = + (&tls_report, &error) + { + server + .schedule_report(TlsEvent { + policy: (&mta_sts_policy, &dane_policy).into(), + domain: domain.to_string(), + failure: FailureDetails::new( + ResultType::CertificateNotTrusted, + ) + .with_receiving_mx_hostname(envelope.mx) + .with_receiving_ip(remote_ip) + .with_failure_reason_code(error.to_string()) + .into(), + tls_record: tls_report.record.clone(), + interval: tls_report.interval, + span_id: message.span_id, + }) + .await; + } + + last_status = if is_strict_tls { + Status::from_tls_error(envelope.mx, error) + } else { + Status::from_tls_error(envelope.mx, error).into_temporary() + }; + continue 'next_host; + } + } + } else { + // TLS has been disabled + trc::event!( + Delivery(DeliveryEvent::StartTlsDisabled), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + ); + + message + .deliver( + smtp_client, + rcpt_idxs, + rcpt_headers, + &mut delivery_results, + params, + ) + .await + } + } else { + // Start TLS + smtp_client.timeout = tls_strategy.timeout_tls; + let mut smtp_client = + match smtp_client.into_tls(tls_connector, envelope.mx).await { + Ok(smtp_client) => smtp_client, + Err(error) => { + trc::event!( + Delivery(DeliveryEvent::ImplicitTlsError), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Reason = from_mail_send_error(&error), + ); + + last_status = Status::from_tls_error(envelope.mx, error); + continue 'next_host; + } + }; + + // Read greeting + smtp_client.timeout = conn_strategy.timeout_greeting; + if let Err(status) = smtp_client.read_greeting(envelope.mx).await { + trc::event!( + Delivery(DeliveryEvent::GreetingFailed), + SpanId = message.span_id, + Domain = domain.to_string(), + Hostname = envelope.mx.to_string(), + Details = from_error_status(&status), + ); + + last_status = status; + continue 'next_host; + } + + // Deliver message + message + .deliver( + smtp_client, + rcpt_idxs, + rcpt_headers, + &mut delivery_results, + params, + ) + .await + } + + // Continue with the next domain/route + continue 'next_route; + } + } + + // Update status + delivery_results.push(DeliveryResult::domain(last_status, rcpt_idxs)); + } + + // Apply status changes + for delivery_result in delivery_results { + match delivery_result { + DeliveryResult::Domain { status, rcpt_idxs } => { + for rcpt_idx in rcpt_idxs { + message + .set_rcpt_status(status.clone(), rcpt_idx, &server) + .await; + } + } + DeliveryResult::Account { status, rcpt_idx } => { + message.set_rcpt_status(status, rcpt_idx, &server).await; + } + DeliveryResult::RateLimited { + rcpt_idxs, + retry_at, + } => { + for rcpt_idx in rcpt_idxs { + message.set_rcpt_rate_limit(rcpt_idx, retry_at); + } + } + } + } + + // Send Delivery Status Notifications + server.send_dsn(&mut message).await; + + // Notify queue manager + if message.message.next_event(None).is_some() { + trc::event!( + Queue(trc::QueueEvent::Rescheduled), + SpanId = span_id, + NextRetry = message + .message + .next_delivery_event(None) + .map(trc::Value::Timestamp), + NextDsn = message.message.next_dsn(None).map(trc::Value::Timestamp), + Expires = message.message.expires(None).map(trc::Value::Timestamp), + ); + + // Save changes to disk + message.save_changes(&server, self.due.into()).await; + + QueueEventStatus::Deferred + } else { + trc::event!( + Delivery(DeliveryEvent::Completed), + SpanId = span_id, + Elapsed = trc::Value::Duration((now() - message.message.created) * 1000) + ); + + // Delete message from queue + message.remove(&server, self.due.into()).await; + + QueueEventStatus::Completed + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingDelivery { + Yes(bool), + No, +} + +impl MessageWrapper { + /// Marks as failed all domains that reached their expiration time + pub fn has_pending_delivery(&mut self) -> PendingDelivery { + let now = now(); + let mut has_pending_delivery = false; + let mut matches_queue = false; + + for rcpt in self.message.recipients.iter_mut() { + match &rcpt.status { + Status::TemporaryFailure(err) if rcpt.is_expired(self.message.created, now) => { + trc::event!( + Delivery(DeliveryEvent::Failed), + SpanId = self.span_id, + QueueId = self.queue_id, + QueueName = self.queue_name.as_str().to_string(), + To = rcpt.address().to_string(), + Reason = from_error_details(&err.details), + Details = trc::Value::Timestamp(now), + Expires = rcpt + .expiration_time(self.message.created) + .map(trc::Value::Timestamp), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + NextDsn = trc::Value::Timestamp(rcpt.notify.due), + ); + + rcpt.status = + std::mem::replace(&mut rcpt.status, Status::Scheduled).into_permanent(); + } + Status::Scheduled if rcpt.is_expired(self.message.created, now) => { + trc::event!( + Delivery(DeliveryEvent::Failed), + SpanId = self.span_id, + QueueId = self.queue_id, + QueueName = self.queue_name.as_str().to_string(), + To = rcpt.address().to_string(), + Reason = "Message expired without any delivery attempts made.", + Details = trc::Value::Timestamp(now), + Expires = rcpt + .expiration_time(self.message.created) + .map(trc::Value::Timestamp), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + NextDsn = trc::Value::Timestamp(rcpt.notify.due), + ); + + rcpt.status = Status::PermanentFailure(ErrorDetails { + entity: rcpt.domain_part().into(), + details: Error::Io( + "Message expired without any delivery attempts made.".into(), + ), + }); + } + Status::Completed(_) | Status::PermanentFailure(_) => (), + _ => { + has_pending_delivery = true; + matches_queue = matches_queue || rcpt.queue == self.queue_name; + } + } + } + + if has_pending_delivery { + PendingDelivery::Yes(matches_queue) + } else { + PendingDelivery::No + } + } + + pub async fn set_rcpt_status( + &mut self, + status: Status>, ErrorDetails>, + rcpt_idx: usize, + server: &Server, + ) { + let needs_retry = matches!(&status, Status::TemporaryFailure(_) | Status::Scheduled); + self.message.recipients[rcpt_idx].status = status; + + if needs_retry { + let envelope = QueueEnvelope::new(&self.message, &self.message.recipients[rcpt_idx]); + let queue = server.get_queue_or_default( + &server + .eval_if::(&server.core.smtp.queue.queue, &envelope, self.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + self.span_id, + ); + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.retry.due = now() + + queue.retry[std::cmp::min(rcpt.retry.inner as usize, queue.retry.len() - 1)]; + rcpt.retry.inner += 1; + rcpt.expires = queue.expiry; + rcpt.queue = queue.virtual_queue; + } + } + + pub fn set_rcpt_rate_limit(&mut self, rcpt_idx: usize, retry_at: u64) { + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.retry.due = retry_at; + rcpt.status = Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::RateLimited, + }); + } +} diff --git a/crates/smtp/src/outbound/error.rs b/crates/smtp/src/outbound/error.rs new file mode 100644 index 0000000..ef62ea3 --- /dev/null +++ b/crates/smtp/src/outbound/error.rs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::Display; + +use smtp_proto::{Response, Severity}; + +#[derive(Debug)] +pub enum ClientError { + /// I/O error + Io(std::io::Error), + + /// TLS error + Tls(Box), + + /// Base64 decode error + Base64(base64::DecodeError), + + // SMTP authentication error. + InvalidChallenge, + + /// Failure parsing SMTP reply + UnparseableReply, + + /// Unexpected SMTP reply. + UnexpectedReply(Box>), + + /// SMTP authentication failure. + AuthenticationFailed(Box>), + + /// Invalid TLS name provided. + InvalidTLSName, + + /// Missing authentication credentials. + MissingCredentials, + + /// Missing message sender. + MissingMailFrom, + + /// Missing message recipients. + MissingRcptTo, + + /// The server does no support any of the available authentication methods. + UnsupportedAuthMechanism, + + /// Connection timeout. + Timeout, + + /// STARTTLS not available + MissingStartTls, +} + +pub trait AssertReply: Sized { + fn is_positive_completion(&self) -> bool; + fn assert_positive_completion(self) -> ClientResult<()>; + fn assert_severity(self, severity: Severity) -> ClientResult<()>; + fn assert_code(self, code: u16) -> ClientResult<()>; +} + +impl AssertReply for Response { + /// Returns `true` if the reply is a positive completion. + #[inline(always)] + fn is_positive_completion(&self) -> bool { + (200..=299).contains(&self.code) + } + + /// Returns Ok if the reply has the specified severity. + #[inline(always)] + fn assert_severity(self, severity: Severity) -> ClientResult<()> { + if self.severity() == severity { + Ok(()) + } else { + Err(ClientError::UnexpectedReply(Box::new(self))) + } + } + + /// Returns Ok if the reply returned a 2xx code. + #[inline(always)] + fn assert_positive_completion(self) -> ClientResult<()> { + if (200..=299).contains(&self.code) { + Ok(()) + } else { + Err(ClientError::UnexpectedReply(Box::new(self))) + } + } + + /// Returns Ok if the reply has the specified status code. + #[inline(always)] + fn assert_code(self, code: u16) -> ClientResult<()> { + if self.code() == code { + Ok(()) + } else { + Err(ClientError::UnexpectedReply(Box::new(self))) + } + } +} + +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Io(err) => err.source(), + ClientError::Tls(err) => err.source(), + ClientError::Base64(err) => err.source(), + _ => None, + } + } +} + +pub type ClientResult = std::result::Result; + +impl Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Io(e) => write!(f, "I/O error: {e}"), + ClientError::Tls(e) => write!(f, "TLS error: {e}"), + ClientError::Base64(e) => write!(f, "Base64 decode error: {e}"), + ClientError::InvalidChallenge => { + write!(f, "SMTP authentication error: Invalid challenge") + } + ClientError::UnparseableReply => write!(f, "Unparseable SMTP reply"), + ClientError::UnexpectedReply(e) => write!(f, "Unexpected reply: {e}"), + ClientError::AuthenticationFailed(e) => write!(f, "Authentication failed: {e}"), + ClientError::InvalidTLSName => write!(f, "Invalid TLS name provided"), + ClientError::MissingCredentials => write!(f, "Missing authentication credentials"), + ClientError::MissingMailFrom => write!(f, "Missing message sender"), + ClientError::MissingRcptTo => write!(f, "Missing message recipients"), + ClientError::UnsupportedAuthMechanism => write!( + f, + "The server does no support any of the available authentication methods" + ), + ClientError::Timeout => write!(f, "Connection timeout"), + ClientError::MissingStartTls => write!(f, "STARTTLS extension unavailable"), + } + } +} + +impl From for ClientError { + fn from(err: std::io::Error) -> Self { + ClientError::Io(err) + } +} + +impl From for ClientError { + fn from(err: base64::DecodeError) -> Self { + ClientError::Base64(err) + } +} diff --git a/crates/smtp/src/outbound/local.rs b/crates/smtp/src/outbound/local.rs new file mode 100644 index 0000000..8a89092 --- /dev/null +++ b/crates/smtp/src/outbound/local.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + inbound::dkim::DkimSign, + outbound::DeliveryResult, + queue::{ + Error, ErrorDetails, FROM_AUTHENTICATED, FROM_UNAUTHENTICATED_DMARC, HostResponse, + MessageSource, MessageWrapper, Status, UnexpectedResponse, + quota::HasQueueQuota, + rcpt_spam_percentage, + spool::{QueueParams, SmtpSpool}, + }, +}; +use common::Server; +use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use smtp_proto::Response; +use trc::SieveEvent; + +impl MessageWrapper { + pub(super) async fn deliver_local( + &self, + rcpt_idxs: &[usize], + statuses: &mut Vec, + server: &Server, + ) { + // Prepare recipients list + let mut pending_recipients = Vec::new(); + let mut recipients = Vec::new(); + for &rcpt_idx in rcpt_idxs { + let rcpt = &self.message.recipients[rcpt_idx]; + let rcpt_addr = rcpt.address(); + recipients.push(IngestRecipient { + address: rcpt_addr.to_lowercase(), + orcpt: rcpt.orcpt.as_ref().map(|orcpt| orcpt.to_string()), + spam_percentage: rcpt_spam_percentage(rcpt.flags), + }); + pending_recipients.push((rcpt_idx, rcpt_addr)); + } + + // Deliver message + let delivery_result = server + .deliver_message(IngestMessage { + sender_address: self.message.return_path.to_string(), + sender_authenticated: self.message.flags + & (FROM_UNAUTHENTICATED_DMARC | FROM_AUTHENTICATED) + != 0, + recipients, + message_blob: self.message.blob_hash.clone(), + message_size: self.message.size, + session_id: self.span_id, + }) + .await; + + // Process delivery results + for ((rcpt_idx, rcpt_addr), result) in + pending_recipients.into_iter().zip(delivery_result.status) + { + let status = match result { + LocalDeliveryStatus::Success => Status::Completed(HostResponse { + hostname: "localhost".into(), + response: Response { + code: 250, + esc: [2, 1, 5], + message: "OK".into(), + }, + }), + LocalDeliveryStatus::TemporaryFailure { reason } => { + Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: format!("RCPT TO:<{rcpt_addr}>").into_boxed_str(), + response: Response { + code: 451, + esc: [4, 3, 0], + message: reason.into(), + }, + }), + }) + } + LocalDeliveryStatus::PermanentFailure { code, reason } => { + Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: format!("RCPT TO:<{rcpt_addr}>").into_boxed_str(), + response: Response { + code: 550, + esc: code, + message: reason.into(), + }, + }), + }) + } + }; + statuses.push(DeliveryResult::account(status, rcpt_idx)); + } + + // Process autogenerated messages + for autogenerated in delivery_result.autogenerated { + let mut message = server.new_message( + autogenerated.sender_address, + MessageSource::Autogenerated, + self.span_id, + ); + for rcpt in autogenerated.recipients { + message.expand_and_add_recipient(rcpt, server).await; + } + + // Queue Message + message.message.size = autogenerated.message.len() as u64; + if let Some(metadata) = server.has_quota(&mut message).await { + let dkim_signers = server + .eval_signers( + &server.core.sieve.untrusted_sign, + &message.message, + self.span_id, + ) + .await; + + message + .queue( + QueueParams::new(&autogenerated.message, self.span_id, server) + .with_dkim_signers(dkim_signers) + .with_metadata(metadata), + ) + .await; + } else { + trc::event!( + Sieve(SieveEvent::QuotaExceeded), + SpanId = self.span_id, + From = message.message.return_path, + To = message + .message + .recipients + .into_iter() + .map(|r| trc::Value::from(r.address().to_string())) + .collect::>(), + ); + } + } + } +} diff --git a/crates/smtp/src/outbound/lookup.rs b/crates/smtp/src/outbound/lookup.rs new file mode 100644 index 0000000..3292e7c --- /dev/null +++ b/crates/smtp/src/outbound/lookup.rs @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::NextHop; +use super::dane::dnssec::{TlsaLookup, least_secure}; +use crate::queue::{Error, ErrorDetails, HostResponse, Status}; +use common::{ + Server, + config::smtp::queue::{ConnectionStrategy, HostOrIp, IpAndHost, MxConfig}, + expr::functions::ResolveVariable, +}; +use mail_auth::{DnssecStatus, IpLookupStrategy, MX, RecordSet}; +use rand::{RngExt, seq::SliceRandom}; +use registry::schema::enums::ExpressionVariable; +use std::{future::Future, net::IpAddr, sync::Arc}; + +pub struct ResolvedHost { + pub ips: Vec, + pub dnssec_status: DnssecStatus, +} + +pub trait DnsLookup: Sync + Send { + fn ip_lookup( + &self, + key: &str, + strategy: IpLookupStrategy, + max_results: usize, + dnssec: bool, + ) -> impl Future, DnssecStatus)>> + Send; + + fn resolve_host( + &self, + remote_host: &NextHop<'_>, + envelope: &impl ResolveVariable, + dnssec: bool, + ) -> impl Future>, ErrorDetails>>> + Send; +} + +impl DnsLookup for Server { + async fn ip_lookup( + &self, + key: &str, + strategy: IpLookupStrategy, + max_results: usize, + dnssec: bool, + ) -> mail_auth::Result<(Vec, DnssecStatus)> { + let (has_ipv4, has_ipv6, v4_first) = match strategy { + IpLookupStrategy::Ipv4Only => (true, false, false), + IpLookupStrategy::Ipv6Only => (false, true, false), + IpLookupStrategy::Ipv4thenIpv6 => (true, true, true), + IpLookupStrategy::Ipv6thenIpv4 => (true, true, false), + }; + let mut dnssec_status: Option = None; + + let ipv4_addrs = if has_ipv4 { + let result = if dnssec { + self.ipv4_lookup_dnssec(key).await + } else { + self.core + .smtp + .resolvers + .dns + .ipv4_lookup(key, Some(&self.inner.cache.dns_ipv4)) + .await + }; + + match result { + Ok(addrs) => { + if !addrs.rrset.is_empty() { + dnssec_status = Some(addrs.dnssec_status); + } + addrs.rrset + } + Err(_) if has_ipv6 => Arc::new([]), + Err(err) => return Err(err), + } + } else { + Arc::new([]) + }; + + let ipv6_addrs = if has_ipv6 { + let result = if dnssec { + self.ipv6_lookup_dnssec(key).await + } else { + self.core + .smtp + .resolvers + .dns + .ipv6_lookup(key, Some(&self.inner.cache.dns_ipv6)) + .await + }; + + match result { + Ok(addrs) => { + if !addrs.rrset.is_empty() { + dnssec_status = Some(match dnssec_status { + Some(status) => least_secure(status, addrs.dnssec_status), + None => addrs.dnssec_status, + }); + } + addrs.rrset + } + Err(_) if !ipv4_addrs.is_empty() => Arc::new([]), + Err(err) => return Err(err), + } + } else { + Arc::new([]) + }; + + let remote_ips = if v4_first { + ipv4_addrs + .iter() + .copied() + .map(IpAddr::from) + .chain(ipv6_addrs.iter().copied().map(IpAddr::from)) + .take(max_results) + .collect() + } else { + ipv6_addrs + .iter() + .copied() + .map(IpAddr::from) + .chain(ipv4_addrs.iter().copied().map(IpAddr::from)) + .take(max_results) + .collect() + }; + + Ok(( + remote_ips, + dnssec_status.unwrap_or(DnssecStatus::Indeterminate), + )) + } + + async fn resolve_host( + &self, + remote_host: &NextHop<'_>, + envelope: &impl ResolveVariable, + dnssec: bool, + ) -> Result>, ErrorDetails>> { + let (mut remote_ips, dnssec_status) = match remote_host.fqdn_hostname() { + HostOrIp::Host(hostname) => self + .ip_lookup( + hostname.as_ref(), + remote_host.ip_lookup_strategy(), + remote_host.max_multi_homed(), + dnssec, + ) + .await + .map_err(|err| { + if let mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_)) = &err { + if matches!( + remote_host, + NextHop::MX { + is_implicit: true, + .. + } + ) { + Status::PermanentFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::DnsError("no MX record found.".into()), + }) + } else { + Status::PermanentFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::ConnectionError("record not found for MX".into()), + }) + } + } else { + Status::TemporaryFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::ConnectionError( + format!("lookup error: {err}").into_boxed_str(), + ), + }) + } + })?, + HostOrIp::Ip(ip) => (vec![ip], DnssecStatus::Indeterminate), + }; + + if !remote_ips.is_empty() { + if !remote_host.allow_loopback() && remote_ips.iter().any(|ip| ip.is_loopback()) { + remote_ips.retain(|ip| !ip.is_loopback()); + if remote_ips.is_empty() { + return Err(Status::PermanentFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::ConnectionError("host resolves loopback address".into()), + })); + } + } + + Ok(ResolvedHost { + ips: remote_ips, + dnssec_status, + }) + } else { + Err(Status::TemporaryFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::DnsError( + format!( + "No IP addresses found for {:?}.", + envelope + .resolve_variable(ExpressionVariable::Mx) + .to_string() + ) + .into_boxed_str(), + ), + })) + } + } +} + +pub trait SourceIp { + fn source_ip(&self, is_v4: bool) -> Option<&IpAndHost>; +} + +impl SourceIp for ConnectionStrategy { + fn source_ip(&self, is_v4: bool) -> Option<&IpAndHost> { + let ips = if is_v4 { + &self.source_ipv4 + } else { + &self.source_ipv6 + }; + match ips.len().cmp(&1) { + std::cmp::Ordering::Equal => ips.first(), + std::cmp::Ordering::Greater => Some(&ips[rand::rng().random_range(0..ips.len())]), + std::cmp::Ordering::Less => None, + } + } +} + +pub trait ToNextHop { + fn to_remote_hosts<'x, 'y: 'x>( + &'x self, + domain: &'y str, + config: &'x MxConfig, + ) -> Option>>; +} + +impl ToNextHop for RecordSet { + fn to_remote_hosts<'x, 'y: 'x>( + &'x self, + domain: &'y str, + config: &'x MxConfig, + ) -> Option>> { + if !self.rrset.is_empty() { + // Obtain max number of MX hosts to process + let mut remote_hosts = Vec::with_capacity(config.max_mx); + + 'outer: for mx in self.rrset.iter() { + if mx.exchanges.len() > 1 { + let mut slice = mx.exchanges.iter().collect::>(); + slice.shuffle(&mut rand::rng()); + for remote_host in slice { + remote_hosts.push(NextHop::MX { + host: remote_host.as_ref(), + is_implicit: false, + dnssec_status: self.dnssec_status, + config, + }); + if remote_hosts.len() == config.max_mx { + break 'outer; + } + } + } else if let Some(remote_host) = mx.exchanges.first() { + // Check for Null MX + if mx.preference == 0 && remote_host.as_ref() == "." { + return None; + } + remote_hosts.push(NextHop::MX { + host: remote_host.as_ref(), + is_implicit: false, + dnssec_status: self.dnssec_status, + config, + }); + if remote_hosts.len() == config.max_mx { + break; + } + } + } + remote_hosts.into() + } else { + // If an empty list of MXs is returned, the address is treated as if it was + // associated with an implicit MX RR with a preference of 0, pointing to that host. + vec![NextHop::MX { + host: domain, + is_implicit: true, + dnssec_status: self.dnssec_status, + config, + }] + .into() + } + } +} diff --git a/crates/smtp/src/outbound/mod.rs b/crates/smtp/src/outbound/mod.rs new file mode 100644 index 0000000..d5e5d95 --- /dev/null +++ b/crates/smtp/src/outbound/mod.rs @@ -0,0 +1,379 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + outbound::{client::BoxResponse, error::ClientError}, + queue::{Error, ErrorDetails, HostResponse, Status, UnexpectedResponse}, +}; +use common::config::{ + server::ServerProtocol, + smtp::queue::{HostOrIp, MxConfig, RelayConfig}, +}; +use directory::Credentials; +use mail_auth::{DnssecStatus, IpLookupStrategy}; +use smtp_proto::{Response, Severity}; +use std::{borrow::Cow, net::IpAddr}; + +pub mod client; +pub mod dane; +pub mod delivery; +pub mod error; +pub mod local; +pub mod lookup; +pub mod mta_sts; +pub mod session; + +pub(super) enum DeliveryResult { + Domain { + status: Status>, ErrorDetails>, + rcpt_idxs: Vec, + }, + Account { + status: Status>, ErrorDetails>, + rcpt_idx: usize, + }, + RateLimited { + rcpt_idxs: Vec, + retry_at: u64, + }, +} + +impl Status>, ErrorDetails> { + pub fn from_smtp_error(hostname: &str, command: &str, err: ClientError) -> Self { + match err { + ClientError::Io(_) + | ClientError::Tls(_) + | ClientError::Base64(_) + | ClientError::UnparseableReply + | ClientError::AuthenticationFailed(_) + | ClientError::MissingCredentials + | ClientError::MissingMailFrom + | ClientError::MissingRcptTo + | ClientError::Timeout => Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::ConnectionError(err.to_string().into_boxed_str()), + }), + + ClientError::UnexpectedReply(response) => { + if response.severity() == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: command.trim().into(), + response: response.into_box(), + }), + }) + } else { + Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: command.trim().into(), + response: response.into_box(), + }), + }) + } + } + + ClientError::InvalidChallenge + | ClientError::UnsupportedAuthMechanism + | ClientError::InvalidTLSName + | ClientError::MissingStartTls => Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::ConnectionError(err.to_string().into_boxed_str()), + }), + } + } + + pub fn from_starttls_error(hostname: &str, response: Option>>) -> Self { + let entity = hostname.into(); + if let Some(response) = response { + if response.severity() == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(ErrorDetails { + entity, + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "STARTTLS".into(), + response, + }), + }) + } else { + Status::TemporaryFailure(ErrorDetails { + entity, + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "STARTTLS".into(), + response, + }), + }) + } + } else { + Status::PermanentFailure(ErrorDetails { + entity, + details: Error::TlsError("STARTTLS not advertised by host.".into()), + }) + } + } + + pub fn from_tls_error(hostname: &str, err: ClientError) -> Self { + match err { + ClientError::InvalidTLSName => Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError("Invalid hostname".into()), + }), + ClientError::Timeout => Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError("TLS handshake timed out".into()), + }), + ClientError::Tls(err) => Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError(format!("Handshake failed: {err}").into_boxed_str()), + }), + ClientError::Io(err) => Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError(format!("I/O error: {err}").into_boxed_str()), + }), + _ => Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError("Other TLS error".into()), + }), + } + } + + pub fn timeout(hostname: &str, stage: &str) -> Self { + Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::ConnectionError(format!("Timeout while {stage}").into_boxed_str()), + }) + } + + pub fn local_error() -> Self { + Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConnectionError("Could not deliver message locally.".into()), + }) + } + + pub fn from_mail_auth_error(entity: &str, err: mail_auth::Error) -> Self { + match &err { + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(code)) => { + Status::PermanentFailure(ErrorDetails { + entity: entity.into(), + details: Error::DnsError( + format!("Domain not found: {code:?}").into_boxed_str(), + ), + }) + } + _ => Status::TemporaryFailure(ErrorDetails { + entity: entity.into(), + details: Error::DnsError(err.to_string().into_boxed_str()), + }), + } + } + + pub fn from_mta_sts_error(entity: &str, err: mta_sts::Error) -> Self { + match &err { + mta_sts::Error::Dns(err) => match err { + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(code)) => { + Status::PermanentFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError( + format!("Record not found: {code:?}").into_boxed_str(), + ), + }) + } + mail_auth::Error::Dns(mail_auth::DnsError::InvalidRecordType) => { + Status::PermanentFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError("Failed to parse MTA-STS DNS record.".into()), + }) + } + _ => Status::TemporaryFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError( + format!("DNS lookup error: {err}").into_boxed_str(), + ), + }), + }, + mta_sts::Error::Http(err) => { + if err.is_timeout() { + Status::TemporaryFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError("Timeout fetching policy.".into()), + }) + } else if err.is_connect() { + Status::TemporaryFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError("Could not reach policy host.".into()), + }) + } else if err.is_status() + & err + .status() + .is_some_and(|s| s == reqwest::StatusCode::NOT_FOUND) + { + Status::PermanentFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError("Policy not found.".into()), + }) + } else { + Status::TemporaryFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError("Failed to fetch policy.".into()), + }) + } + } + mta_sts::Error::InvalidPolicy(err) => Status::PermanentFailure(ErrorDetails { + entity: entity.into(), + details: Error::MtaStsError( + format!("Failed to parse policy: {err}").into_boxed_str(), + ), + }), + } + } +} + +#[derive(Debug)] +pub enum NextHop<'x> { + Relay(&'x RelayConfig), + MX { + is_implicit: bool, + host: &'x str, + config: &'x MxConfig, + dnssec_status: DnssecStatus, + }, +} + +impl NextHop<'_> { + #[inline(always)] + pub fn hostname(&self) -> &str { + match self { + NextHop::MX { host, .. } => { + if let Some(host) = host.strip_suffix('.') { + host + } else { + host + } + } + NextHop::Relay(host) => match &host.address { + HostOrIp::Host(host) => host.as_ref(), + HostOrIp::Ip(ip) => ip.ip_str.as_ref(), + }, + } + } + + #[inline(always)] + pub fn fqdn_hostname(&self) -> HostOrIp, IpAddr> { + match self { + NextHop::MX { host, .. } => { + if !host.ends_with('.') { + HostOrIp::Host(format!("{host}.").into()) + } else { + HostOrIp::Host((*host).into()) + } + } + NextHop::Relay(host) => match &host.address { + HostOrIp::Host(host) => HostOrIp::Host(host.as_ref().into()), + HostOrIp::Ip(ip) => HostOrIp::Ip(ip.ip), + }, + } + } + + #[inline(always)] + pub fn max_multi_homed(&self) -> usize { + match self { + NextHop::MX { config, .. } => config.max_multi_homed, + NextHop::Relay(_) => 10, + } + } + + #[inline(always)] + pub fn ip_lookup_strategy(&self) -> IpLookupStrategy { + match self { + NextHop::MX { config, .. } => config.ip_lookup_strategy, + NextHop::Relay(_) => IpLookupStrategy::Ipv4thenIpv6, + } + } + + #[inline(always)] + fn port(&self) -> u16 { + match self { + #[cfg(feature = "test_mode")] + NextHop::MX { .. } => 9925, + #[cfg(not(feature = "test_mode"))] + NextHop::MX { .. } => 25, + NextHop::Relay(host) => host.port, + } + } + + #[inline(always)] + fn allow_loopback(&self) -> bool { + match self { + NextHop::MX { .. } => cfg!(feature = "test_mode"), + NextHop::Relay(_) => true, + } + } + + #[inline(always)] + fn credentials(&self) -> Option<&Credentials> { + match self { + NextHop::MX { .. } => None, + NextHop::Relay(host) => host.auth.as_ref(), + } + } + + #[inline(always)] + fn allow_invalid_certs(&self) -> bool { + #[cfg(feature = "test_mode")] + { + true + } + #[cfg(not(feature = "test_mode"))] + match self { + NextHop::MX { .. } => false, + NextHop::Relay(host) => host.tls_allow_invalid_certs, + } + } + + #[inline(always)] + fn implicit_tls(&self) -> bool { + match self { + NextHop::MX { .. } => false, + NextHop::Relay(host) => host.tls_implicit, + } + } + + #[inline(always)] + fn is_smtp(&self) -> bool { + match self { + NextHop::MX { .. } => true, + NextHop::Relay(host) => host.protocol == ServerProtocol::Smtp, + } + } + + fn dnssec_status(&self) -> DnssecStatus { + match self { + NextHop::MX { dnssec_status, .. } => *dnssec_status, + NextHop::Relay(_) => DnssecStatus::Indeterminate, + } + } +} + +impl DeliveryResult { + pub fn domain( + status: Status>, ErrorDetails>, + rcpt_idxs: Vec, + ) -> Self { + DeliveryResult::Domain { status, rcpt_idxs } + } + + pub fn rate_limited(rcpt_idxs: Vec, retry_at: u64) -> Self { + DeliveryResult::RateLimited { + rcpt_idxs, + retry_at, + } + } + + pub fn account(status: Status>, ErrorDetails>, rcpt_idx: usize) -> Self { + DeliveryResult::Account { status, rcpt_idx } + } +} diff --git a/crates/smtp/src/outbound/mta_sts/lookup.rs b/crates/smtp/src/outbound/mta_sts/lookup.rs new file mode 100644 index 0000000..73503d6 --- /dev/null +++ b/crates/smtp/src/outbound/mta_sts/lookup.rs @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{fmt::Display, sync::Arc, time::Duration}; + +#[cfg(feature = "test_mode")] +pub static STS_TEST_POLICY: parking_lot::Mutex> = parking_lot::Mutex::new(Vec::new()); + +use common::{Server, config::smtp::resolver::Policy}; +use mail_auth::{mta_sts::MtaSts, report::tlsrpt::ResultType}; + +use super::{Error, parse::ParsePolicy}; + +#[cfg(not(feature = "test_mode"))] +use utils::HttpLimitResponse; + +#[cfg(not(feature = "test_mode"))] +const MAX_POLICY_SIZE: usize = 1024 * 1024; + +pub trait MtaStsLookup: Sync + Send { + fn lookup_mta_sts_policy( + &self, + domain: &str, + timeout: Duration, + ) -> impl std::future::Future, Error>> + Send; +} + +#[allow(unused_variables)] +impl MtaStsLookup for Server { + async fn lookup_mta_sts_policy( + &self, + domain: &str, + timeout: Duration, + ) -> Result, Error> { + // Lookup MTA-STS TXT record + let record = match self + .core + .smtp + .resolvers + .dns + .txt_lookup::( + format!("_mta-sts.{domain}."), + Some(&self.inner.cache.dns_txt), + ) + .await + { + Ok(record) => record, + Err(err) => { + // Return the cached policy in case of failure + return if let Some(value) = self.inner.cache.dns_mta_sts.get(domain) { + Ok(value) + } else { + Err(err.into()) + }; + } + }; + + // Check if the policy has been cached + if let Some(value) = self.inner.cache.dns_mta_sts.get(domain) + && value.id == record.id + { + return Ok(value); + } + + // Fetch policy + #[cfg(not(feature = "test_mode"))] + let bytes = self + .core + .smtp + .mta_sts_client + .get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt")) + .timeout(timeout) + .send() + .await? + .bytes_with_limit(MAX_POLICY_SIZE) + .await? + .ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?; + #[cfg(feature = "test_mode")] + let bytes = STS_TEST_POLICY.lock().clone(); + + // Parse policy + let policy = Arc::new(Policy::parse( + std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?, + record.id.clone(), + )?); + + self.inner.cache.dns_mta_sts.insert( + domain.into(), + policy.clone(), + Duration::from_secs(if (3600..31557600).contains(&policy.max_age) { + policy.max_age + } else { + 86400 + }), + ); + + Ok(policy) + } +} + +impl From<&Error> for ResultType { + fn from(err: &Error) -> Self { + match &err { + Error::InvalidPolicy(_) => ResultType::StsPolicyInvalid, + _ => ResultType::StsPolicyFetchError, + } + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Dns(err) => match err { + mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(code)) => { + write!(f, "Record not found: {code:?}") + } + mail_auth::Error::Dns(mail_auth::DnsError::InvalidRecordType) => { + f.write_str("Failed to parse MTA-STS DNS record.") + } + _ => write!(f, "DNS lookup error: {err}"), + }, + Error::Http(err) => { + if err.is_timeout() { + f.write_str("Timeout fetching policy.") + } else if err.is_connect() { + f.write_str("Could not reach policy host.") + } else if err.is_status() && (err.status() == Some(reqwest::StatusCode::NOT_FOUND)) + { + f.write_str("Policy not found.") + } else { + f.write_str("Failed to fetch policy.") + } + } + Error::InvalidPolicy(err) => write!(f, "Failed to parse policy: {err}"), + } + } +} + +impl From for Error { + fn from(value: mail_auth::Error) -> Self { + Error::Dns(value) + } +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + Error::Http(value) + } +} + +impl From for Error { + fn from(value: String) -> Self { + Error::InvalidPolicy(value) + } +} diff --git a/crates/smtp/src/outbound/mta_sts/mod.rs b/crates/smtp/src/outbound/mta_sts/mod.rs new file mode 100644 index 0000000..68a6af6 --- /dev/null +++ b/crates/smtp/src/outbound/mta_sts/mod.rs @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod lookup; +pub mod parse; +pub mod verify; + +#[derive(Debug)] +pub enum Error { + Dns(mail_auth::Error), + Http(reqwest::Error), + InvalidPolicy(String), +} diff --git a/crates/smtp/src/outbound/mta_sts/parse.rs b/crates/smtp/src/outbound/mta_sts/parse.rs new file mode 100644 index 0000000..5030686 --- /dev/null +++ b/crates/smtp/src/outbound/mta_sts/parse.rs @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::resolver::{Mode, MxPattern, Policy}; +use utils::DomainPart; + +fn to_a_label(domain: &str) -> String { + domain + .to_ascii_domain() + .map(|domain| domain.to_lowercase()) + .unwrap_or_else(|| domain.to_lowercase()) +} + +pub trait ParsePolicy { + fn parse(data: &str, id: String) -> Result + where + Self: Sized; +} + +impl ParsePolicy for Policy { + fn parse(mut data: &str, id: String) -> Result { + let mut mode = Mode::None; + let mut max_age: u64 = 86400; + let mut mx = Vec::new(); + + while !data.is_empty() { + if let Some((key, next_data)) = data.split_once(':') { + let value = if let Some((value, next_data)) = next_data.split_once('\n') { + data = next_data; + value.trim() + } else { + data = ""; + next_data.trim() + }; + hashify::fnc_map!(key.trim().as_bytes(), + b"mx" => { + if let Some(suffix) = value.strip_prefix("*.") { + if !suffix.is_empty() { + mx.push(MxPattern::StartsWith(to_a_label(suffix))); + } + } else if !value.is_empty() { + mx.push(MxPattern::Equals(to_a_label(value))); + } + }, + b"max_age" => { + if let Ok(value) = value.parse() { + max_age = value; + } + }, + b"mode" => { + mode = match value { + "enforce" => Mode::Enforce, + "testing" => Mode::Testing, + "none" => Mode::None, + _ => return Err(format!("Unsupported mode {value:?}.")), + }; + }, + b"version" => { + if !value.eq_ignore_ascii_case("STSv1") { + return Err(format!("Unsupported version {value:?}.")); + } + }, + _ => {} + ); + } else { + break; + } + } + + if !mx.is_empty() { + Ok(Policy { + id, + mode, + mx: mx.into_boxed_slice(), + max_age, + }) + } else { + Err("No 'mx' entries found.".to_string()) + } + } +} + +#[cfg(test)] +mod test { + use super::ParsePolicy; + use crate::outbound::mta_sts::verify::VerifyPolicy; + use common::config::smtp::resolver::Policy; + + #[test] + fn mx_patterns_are_a_labels() { + let policy = Policy::parse( + concat!( + "version: STSv1\n", + "mode: enforce\n", + "mx: *.\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\n", + "mx: MAIL.\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\n", + "max_age: 604800\n" + ), + "test".to_string(), + ) + .unwrap(); + + assert!(policy.verify("mx.xn--eebajf.xn--9dbq2a")); + assert!(policy.verify("mail.xn--eebajf.xn--9dbq2a")); + assert!(!policy.verify("mx.example.org")); + } +} diff --git a/crates/smtp/src/outbound/mta_sts/verify.rs b/crates/smtp/src/outbound/mta_sts/verify.rs new file mode 100644 index 0000000..757679b --- /dev/null +++ b/crates/smtp/src/outbound/mta_sts/verify.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::resolver::{Mode, MxPattern, Policy}; + +pub trait VerifyPolicy { + fn verify(&self, mx_host: &str) -> bool; + fn enforce(&self) -> bool; +} + +impl VerifyPolicy for Policy { + fn verify(&self, mx_host: &str) -> bool { + if self.mode != Mode::None { + for mx_pattern in &self.mx { + match mx_pattern { + MxPattern::Equals(host) => { + if host == mx_host { + return true; + } + } + MxPattern::StartsWith(domain) => { + if let Some((_, suffix)) = mx_host.split_once('.') + && suffix == domain + { + return true; + } + } + } + } + + false + } else { + true + } + } + + fn enforce(&self) -> bool { + self.mode == Mode::Enforce + } +} diff --git a/crates/smtp/src/outbound/session.rs b/crates/smtp/src/outbound/session.rs new file mode 100644 index 0000000..7f0d5c7 --- /dev/null +++ b/crates/smtp/src/outbound/session.rs @@ -0,0 +1,481 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::client::SmtpClient; +use crate::outbound::DeliveryResult; +use crate::outbound::client::{BoxResponse, from_error_status, from_mail_send_error}; +use crate::outbound::error::ClientError; +use crate::queue::{Error, MessageWrapper, Recipient, Status}; +use crate::queue::{ErrorDetails, HostResponse, UnexpectedResponse}; +use common::Server; +use common::config::smtp::queue::ConnectionStrategy; +use directory::Credentials; +use smtp_proto::{ + EXT_CHUNKING, EXT_DSN, EXT_REQUIRE_TLS, EXT_SIZE, EXT_SMTP_UTF8, EhloResponse, MAIL_REQUIRETLS, + MAIL_RET_FULL, MAIL_RET_HDRS, MAIL_SMTPUTF8, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, + RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Severity, +}; +use std::{fmt::Write, time::Instant}; +use tokio::io::{AsyncRead, AsyncWrite}; +use trc::DeliveryEvent; + +pub struct SessionParams<'x> { + pub server: &'x Server, + pub hostname: &'x str, + pub credentials: Option<&'x Credentials>, + pub capabilities: Option>, + pub is_smtp: bool, + pub local_hostname: &'x str, + pub conn_strategy: &'x ConnectionStrategy, + pub session_id: u64, +} + +impl MessageWrapper { + pub(super) async fn deliver( + &self, + mut smtp_client: SmtpClient, + rcpt_idxs: Vec, + rcpt_headers: Option<&[u8]>, + statuses: &mut Vec, + mut params: SessionParams<'_>, + ) { + // Obtain capabilities + let time = Instant::now(); + let capabilities = if let Some(capabilities) = params.capabilities.take() { + capabilities + } else { + match smtp_client.say_helo(¶ms).await { + Ok(capabilities) => { + trc::event!( + Delivery(DeliveryEvent::Ehlo), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + Details = capabilities.capabilities(), + Elapsed = time.elapsed(), + ); + + capabilities + } + Err(status) => { + trc::event!( + Delivery(DeliveryEvent::EhloRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_error_status(&status), + Elapsed = time.elapsed(), + ); + smtp_client.quit().await; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; + } + } + }; + + // Authenticate + if let Some(credentials) = params.credentials { + let time = Instant::now(); + if let Err(err) = smtp_client.authenticate(credentials, &capabilities).await { + trc::event!( + Delivery(DeliveryEvent::AuthFailed), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_mail_send_error(&err), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, "AUTH ...", err), + rcpt_idxs, + )); + return; + } + + trc::event!( + Delivery(DeliveryEvent::Auth), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + Elapsed = time.elapsed(), + ); + + // Refresh capabilities + // Disabled as some SMTP servers deauthenticate after EHLO + /*capabilities = match say_helo(&mut smtp_client, ¶ms).await { + Ok(capabilities) => capabilities, + Err(status) => { + trc::event!( + + context = "ehlo", + event = "rejected", + mx = ¶ms.hostname, + reason = %status, + ); + smtp_client.quit().await; + return status; + } + };*/ + } + + // MAIL FROM + let time = Instant::now(); + smtp_client.timeout = params.conn_strategy.timeout_mail; + let cmd = self.build_mail_from(&capabilities); + match smtp_client.cmd(cmd.as_bytes()).await.and_then(|r| { + if r.is_positive_completion() { + Ok(r) + } else { + Err(ClientError::UnexpectedReply(Box::new(r))) + } + }) { + Ok(response) => { + trc::event!( + Delivery(DeliveryEvent::MailFrom), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + From = self.message.return_path.to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + } + Err(err) => { + trc::event!( + Delivery(DeliveryEvent::MailFromRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_mail_send_error(&err), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, &cmd, err), + rcpt_idxs, + )); + return; + } + } + + // RCPT TO + let mut accepted_rcpts = Vec::new(); + smtp_client.timeout = params.conn_strategy.timeout_rcpt; + for rcpt_idx in &rcpt_idxs { + let time = Instant::now(); + let rcpt = &self.message.recipients[*rcpt_idx]; + if matches!( + &rcpt.status, + Status::Completed(_) | Status::PermanentFailure(_) + ) { + continue; + } + + let cmd = self.build_rcpt_to(rcpt, &capabilities); + match smtp_client.cmd(cmd.as_bytes()).await { + Ok(response) => match response.severity() { + Severity::PositiveCompletion => { + trc::event!( + Delivery(DeliveryEvent::RcptTo), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + accepted_rcpts.push(( + rcpt, + rcpt_idx, + Status::Completed(HostResponse { + hostname: params.hostname.into(), + response: response.into_box(), + }), + )); + } + severity => { + trc::event!( + Delivery(DeliveryEvent::RcptToRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + let response = ErrorDetails { + entity: params.hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: cmd.trim().into(), + response: response.into_box(), + }), + }; + statuses.push(DeliveryResult::account( + if severity == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(response) + } else { + Status::TemporaryFailure(response) + }, + *rcpt_idx, + )); + } + }, + Err(err) => { + trc::event!( + Delivery(DeliveryEvent::RcptToFailed), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + CausedBy = from_mail_send_error(&err), + Elapsed = time.elapsed(), + ); + + // Something went wrong, abort. + smtp_client.quit().await; + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, "", err), + rcpt_idxs, + )); + return; + } + } + } + + // Send message + if !accepted_rcpts.is_empty() { + let time = Instant::now(); + let mut bdat_cmd = capabilities.has_capability(EXT_CHUNKING).then(String::new); + + if let Err(status) = smtp_client + .send_message(self, rcpt_headers, &mut bdat_cmd, ¶ms) + .await + { + trc::event!( + Delivery(DeliveryEvent::MessageRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_error_status(&status), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; + } + + if params.is_smtp { + // Handle SMTP response + match smtp_client + .read_smtp_data_response(params.hostname, &bdat_cmd) + .await + { + Ok(response) => { + // Mark recipients as delivered + if response.code() == 250 { + for (rcpt, rcpt_idx, status) in accepted_rcpts { + trc::event!( + Delivery(DeliveryEvent::Delivered), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + statuses.push(DeliveryResult::account(status, *rcpt_idx)); + } + } else { + trc::event!( + Delivery(DeliveryEvent::MessageRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain( + Status::from_smtp_error( + params.hostname, + bdat_cmd.as_deref().unwrap_or("DATA"), + ClientError::UnexpectedReply(Box::new(response)), + ), + rcpt_idxs, + )); + return; + } + } + Err(status) => { + trc::event!( + Delivery(DeliveryEvent::MessageRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_error_status(&status), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; + } + } + } else { + // Handle LMTP responses + match smtp_client + .read_lmtp_data_response(params.hostname, accepted_rcpts.len()) + .await + { + Ok(responses) => { + for ((rcpt, rcpt_idx, _), response) in + accepted_rcpts.into_iter().zip(responses) + { + let status: Status>, ErrorDetails> = + match response.severity() { + Severity::PositiveCompletion => { + trc::event!( + Delivery(DeliveryEvent::Delivered), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + Status::Completed(HostResponse { + hostname: params.hostname.into(), + response, + }) + } + severity => { + trc::event!( + Delivery(DeliveryEvent::RcptToRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + To = rcpt.address().to_string(), + Code = response.code, + Details = response.message.to_string(), + Elapsed = time.elapsed(), + ); + + let response = ErrorDetails { + entity: params.hostname.into(), + details: Error::UnexpectedResponse( + UnexpectedResponse { + command: bdat_cmd + .as_deref() + .unwrap_or("DATA") + .into(), + response, + }, + ), + }; + if severity == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(response) + } else { + Status::TemporaryFailure(response) + } + } + }; + + statuses.push(DeliveryResult::account(status, *rcpt_idx)); + } + } + Err(status) => { + trc::event!( + Delivery(DeliveryEvent::MessageRejected), + SpanId = params.session_id, + Hostname = params.hostname.to_string(), + CausedBy = from_error_status(&status), + Elapsed = time.elapsed(), + ); + + smtp_client.quit().await; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; + } + } + } + } + + smtp_client.quit().await; + } + + fn build_mail_from(&self, capabilities: &EhloResponse) -> String { + let mut mail_from = String::with_capacity(self.message.return_path.len() + 60); + let _ = write!(mail_from, "MAIL FROM:<{}>", self.message.return_path); + if capabilities.has_capability(EXT_SIZE) { + let _ = write!(mail_from, " SIZE={}", self.message.size); + } + if self.has_flag(MAIL_REQUIRETLS) & capabilities.has_capability(EXT_REQUIRE_TLS) { + mail_from.push_str(" REQUIRETLS"); + } + if self.has_flag(MAIL_SMTPUTF8) & capabilities.has_capability(EXT_SMTP_UTF8) { + mail_from.push_str(" SMTPUTF8"); + } + if capabilities.has_capability(EXT_DSN) { + if self.has_flag(MAIL_RET_FULL) { + mail_from.push_str(" RET=FULL"); + } else if self.has_flag(MAIL_RET_HDRS) { + mail_from.push_str(" RET=HDRS"); + } + if let Some(env_id) = &self.message.env_id { + let _ = write!(mail_from, " ENVID={env_id}"); + } + } + + mail_from.push_str("\r\n"); + mail_from + } + + fn build_rcpt_to(&self, rcpt: &Recipient, capabilities: &EhloResponse) -> String { + let mut rcpt_to = String::with_capacity(rcpt.address().len() + 60); + let _ = write!(rcpt_to, "RCPT TO:<{}>", rcpt.address()); + if capabilities.has_capability(EXT_DSN) { + if rcpt.has_flag(RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY) { + rcpt_to.push_str(" NOTIFY="); + let mut add_comma = if rcpt.has_flag(RCPT_NOTIFY_SUCCESS) { + rcpt_to.push_str("SUCCESS"); + true + } else { + false + }; + if rcpt.has_flag(RCPT_NOTIFY_DELAY) { + if add_comma { + rcpt_to.push(','); + } else { + add_comma = true; + } + rcpt_to.push_str("DELAY"); + } + if rcpt.has_flag(RCPT_NOTIFY_FAILURE) { + if add_comma { + rcpt_to.push(','); + } + rcpt_to.push_str("FAILURE"); + } + } else if rcpt.has_flag(RCPT_NOTIFY_NEVER) { + rcpt_to.push_str(" NOTIFY=NEVER"); + } + } + rcpt_to.push_str("\r\n"); + rcpt_to + } + + #[inline(always)] + pub fn has_flag(&self, flag: u64) -> bool { + (self.message.flags & flag) != 0 + } +} + +impl Recipient { + #[inline(always)] + pub fn has_flag(&self, flag: u64) -> bool { + (self.flags & flag) != 0 + } +} diff --git a/crates/smtp/src/queue/dsn.rs b/crates/smtp/src/queue/dsn.rs new file mode 100644 index 0000000..f83c8c6 --- /dev/null +++ b/crates/smtp/src/queue/dsn.rs @@ -0,0 +1,669 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::spool::SmtpSpool; +use super::{ + Error, ErrorDetails, HostResponse, Message, MessageSource, QueueEnvelope, RCPT_DSN_SENT, + Recipient, Status, +}; +use crate::inbound::dkim::DkimSign; +use crate::queue::spool::QueueParams; +use crate::queue::{MessageWrapper, UnexpectedResponse}; +use common::Server; +use mail_builder::MessageBuilder; +use mail_builder::headers::HeaderType; +use mail_builder::headers::content_type::ContentType; +use mail_builder::mime::{BodyPart, MimePart, make_boundary}; +use mail_parser::DateTime; +use smtp_proto::{ + RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response, +}; +use std::fmt::Write; +use std::future::Future; +use store::write::now; + +pub trait SendDsn: Sync + Send { + fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future + Send; + fn log_dsn(&self, message: &MessageWrapper) -> impl Future + Send; +} + +impl SendDsn for Server { + async fn send_dsn(&self, message: &mut MessageWrapper) { + // Send DSN events + self.log_dsn(message).await; + + if !message.message.return_path.is_empty() { + // Build DSN + if let Some(dsn) = message.build_dsn(self).await { + let mut dsn_message = self.new_message("", MessageSource::Dsn, message.span_id); + dsn_message + .expand_and_add_recipient(message.message.return_path.as_ref(), self) + .await; + + // Queue DSN + let dkim_signers = self + .eval_signers( + &self.core.smtp.queue.dsn.sign, + &message.message, + message.span_id, + ) + .await; + dsn_message + .queue( + QueueParams::new(&dsn, message.span_id, self) + .with_dkim_signers(dkim_signers), + ) + .await; + } + } else { + // Handle double bounce + message.handle_double_bounce(); + } + + // Update next DSN notify times + message.update_next_dsn(self).await; + } + + async fn log_dsn(&self, message: &MessageWrapper) { + let now = now(); + + for rcpt in &message.message.recipients { + if rcpt.has_flag(RCPT_DSN_SENT) { + continue; + } + + match &rcpt.status { + Status::Completed(response) => { + trc::event!( + Delivery(trc::DeliveryEvent::DsnSuccess), + SpanId = message.span_id, + To = rcpt.address.clone(), + Hostname = response.hostname.clone(), + Code = response.response.code, + Details = response.response.message.to_string(), + ); + } + Status::TemporaryFailure(response) if rcpt.notify.due <= now => { + trc::event!( + Delivery(trc::DeliveryEvent::DsnTempFail), + SpanId = message.span_id, + To = rcpt.address.clone(), + Hostname = response.entity.clone(), + Details = response.details.to_string(), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + Expires = rcpt + .expiration_time(message.message.created) + .map(trc::Value::Timestamp), + Total = rcpt.retry.inner, + ); + } + Status::PermanentFailure(response) => { + trc::event!( + Delivery(trc::DeliveryEvent::DsnPermFail), + SpanId = message.span_id, + To = rcpt.address.clone(), + Hostname = response.entity.clone(), + Details = response.details.to_string(), + Total = rcpt.retry.inner, + ); + } + Status::Scheduled if rcpt.notify.due <= now => { + trc::event!( + Delivery(trc::DeliveryEvent::DsnTempFail), + SpanId = message.span_id, + To = rcpt.address.clone(), + Details = "Concurrency limited", + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + Expires = rcpt + .expiration_time(message.message.created) + .map(trc::Value::Timestamp), + Total = rcpt.retry.inner, + ); + } + _ => continue, + } + } + } +} + +const MAX_HEADER_SIZE: usize = 4096; + +impl MessageWrapper { + pub async fn build_dsn(&mut self, server: &Server) -> Option> { + let config = &server.core.smtp.queue; + let now = now(); + + let mut txt_success = String::new(); + let mut txt_delay = String::new(); + let mut txt_failed = String::new(); + let mut dsn = String::new(); + + for rcpt in &mut self.message.recipients { + if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) { + continue; + } + match &rcpt.status { + Status::Completed(response) => { + rcpt.flags |= RCPT_DSN_SENT; + if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) { + continue; + } + rcpt.write_dsn(&mut dsn); + rcpt.status.write_dsn(&mut dsn); + response.write_dsn_text(&rcpt.address, &mut txt_success); + } + Status::TemporaryFailure(response) + if rcpt.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => + { + rcpt.write_dsn(&mut dsn); + rcpt.status.write_dsn(&mut dsn); + rcpt.write_dsn_will_retry_until(self.message.created, &mut dsn); + response.write_dsn_text(&rcpt.address, &mut txt_delay); + } + Status::PermanentFailure(response) => { + rcpt.flags |= RCPT_DSN_SENT; + if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) { + continue; + } + rcpt.write_dsn(&mut dsn); + rcpt.status.write_dsn(&mut dsn); + response.write_dsn_text(&rcpt.address, &mut txt_failed); + } + Status::Scheduled if rcpt.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => { + // This case should not happen under normal circumstances + rcpt.write_dsn(&mut dsn); + rcpt.status.write_dsn(&mut dsn); + rcpt.write_dsn_will_retry_until(self.message.created, &mut dsn); + ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + } + .write_dsn_text(&rcpt.address, &mut txt_delay); + } + _ => continue, + } + + dsn.push_str("\r\n"); + } + + let txt_len = txt_success.len() + txt_delay.len() + txt_failed.len(); + if txt_len == 0 { + return None; + } + + let has_success = !txt_success.is_empty(); + let has_delay = !txt_delay.is_empty(); + let has_failure = !txt_failed.is_empty(); + + let mut txt = String::with_capacity(txt_len + 128); + let (subject, is_mixed) = if has_success && !has_delay && !has_failure { + txt.push_str( + "Your message has been successfully delivered to the following recipients:\r\n\r\n", + ); + ("Successfully delivered message", false) + } else if has_delay && !has_success && !has_failure { + txt.push_str("There was a temporary problem delivering your message to the following recipients:\r\n\r\n"); + ("Warning: Delay in message delivery", false) + } else if has_failure && !has_success && !has_delay { + txt.push_str( + "Your message could not be delivered to the following recipients:\r\n\r\n", + ); + ("Failed to deliver message", false) + } else if has_success { + txt.push_str("Your message has been partially delivered:\r\n\r\n"); + ("Partially delivered message", true) + } else { + txt.push_str("Your message could not be delivered to some recipients:\r\n\r\n"); + ( + "Warning: Temporary and permanent failures during message delivery", + true, + ) + }; + + if has_success { + if is_mixed { + txt.push_str( + " ----- Delivery to the following addresses was successful -----\r\n", + ); + } + + txt.push_str(&txt_success); + txt.push_str("\r\n"); + } + + if has_delay { + if is_mixed { + txt.push_str( + " ----- There was a temporary problem delivering to these addresses -----\r\n", + ); + } + txt.push_str(&txt_delay); + txt.push_str("\r\n"); + } + + if has_failure { + if is_mixed { + txt.push_str(" ----- Delivery to the following addresses failed -----\r\n"); + } + txt.push_str(&txt_failed); + txt.push_str("\r\n"); + } + + // Obtain hostname and sender addresses + let from_name = server + .eval_if(&config.dsn.name, &self.message, self.span_id) + .await + .unwrap_or_else(|| String::from("Mail Delivery Subsystem")); + let from_addr = server + .eval_if(&config.dsn.address, &self.message, self.span_id) + .await + .unwrap_or_else(|| String::from("MAILER-DAEMON@localhost")); + let reporting_mta = server + .eval_if( + &server.core.smtp.report.submitter, + &self.message, + self.span_id, + ) + .await + .unwrap_or_else(|| String::from("localhost")); + + // Prepare DSN + let mut dsn_header = String::with_capacity(dsn.len() + 128); + self.message + .write_dsn_headers(&mut dsn_header, &reporting_mta); + let dsn = dsn_header + dsn.as_str(); + + // Fetch up to MAX_HEADER_SIZE bytes of message headers + let headers = match server + .blob_store() + .get_blob(self.message.blob_hash.as_slice(), 0..MAX_HEADER_SIZE) + .await + { + Ok(Some(mut buf)) => { + let mut prev_ch = 0; + let mut last_lf = buf.len(); + for (pos, &ch) in buf.iter().enumerate() { + match ch { + b'\n' => { + last_lf = pos + 1; + if prev_ch != b'\n' { + prev_ch = ch; + } else { + break; + } + } + b'\r' => (), + 0 => break, + _ => { + prev_ch = ch; + } + } + } + if last_lf < MAX_HEADER_SIZE { + buf.truncate(last_lf); + } + String::from_utf8(buf).unwrap_or_default() + } + Ok(None) => { + trc::event!( + Queue(trc::QueueEvent::BlobNotFound), + SpanId = self.span_id, + BlobId = self.message.blob_hash.to_hex(), + CausedBy = trc::location!() + ); + + String::new() + } + Err(err) => { + trc::error!( + err.span_id(self.span_id) + .details("Failed to fetch blobId") + .caused_by(trc::location!()) + ); + + String::new() + } + }; + + // Build message + MessageBuilder::new() + .from((from_name.as_str(), from_addr.as_str())) + .header( + "To", + HeaderType::Text(self.message.return_path.as_ref().into()), + ) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .message_id(format!("{}@{}", make_boundary("."), reporting_mta)) + .subject(subject) + .body(MimePart::new( + ContentType::new("multipart/report").attribute("report-type", "delivery-status"), + BodyPart::Multipart(vec![ + MimePart::new(ContentType::new("text/plain"), BodyPart::Text(txt.into())), + MimePart::new( + ContentType::new("message/delivery-status"), + BodyPart::Text(dsn.into()), + ), + MimePart::new( + ContentType::new("message/rfc822"), + BodyPart::Text(headers.into()), + ), + ]), + )) + .write_to_vec() + .unwrap_or_default() + .into() + } + + pub async fn update_next_dsn(&mut self, server: &Server) { + let now = now(); + let mut notify_changes = Vec::new(); + for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() { + if matches!( + &rcpt.status, + Status::TemporaryFailure(_) | Status::Scheduled + ) && rcpt.notify.due <= now + { + let envelope = QueueEnvelope::new(&self.message, rcpt); + + let queue_id = server + .eval_if::(&server.core.smtp.queue.queue, &envelope, self.span_id) + .await + .unwrap_or_else(|| "default".to_string()); + let queue = server.get_queue_or_default(&queue_id, self.span_id); + + if let Some(next_notify) = + queue.notify.get((rcpt.notify.inner + 1) as usize).copied() + { + notify_changes.push((rcpt_idx, 1, now + next_notify)); + } else { + notify_changes.push((rcpt_idx, 0, u64::MAX)); + } + } + } + + for (rcpt_idx, inner, due) in notify_changes { + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.notify.inner += inner; + rcpt.notify.due = due; + } + } + + fn handle_double_bounce(&mut self) { + let mut is_double_bounce = Vec::with_capacity(0); + let now = now(); + + for rcpt in &mut self.message.recipients { + if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) + && let Status::PermanentFailure(err) = &rcpt.status + { + rcpt.flags |= RCPT_DSN_SENT; + let mut dsn = String::new(); + err.write_dsn_text(&rcpt.address, &mut dsn); + is_double_bounce.push(dsn); + } + + if rcpt.notify.due <= now { + rcpt.notify.due = rcpt + .expiration_time(self.message.created) + .map(|d| d + 10) + .unwrap_or(u64::MAX); + } + } + + if !is_double_bounce.is_empty() { + trc::event!( + Delivery(trc::DeliveryEvent::DoubleBounce), + SpanId = self.span_id, + To = is_double_bounce + ); + } + } +} + +impl HostResponse> { + fn write_dsn_text(&self, addr: &str, dsn: &mut String) { + let _ = write!( + dsn, + "<{}> (delivered to '{}' with code {} ({}.{}.{}) '", + addr, + self.hostname, + self.response.code, + self.response.esc[0], + self.response.esc[1], + self.response.esc[2] + ); + self.response.write_response(dsn); + dsn.push_str("')\r\n"); + } +} + +impl UnexpectedResponse { + fn write_dsn_text(&self, host: &str, addr: &str, dsn: &mut String) { + let _ = write!(dsn, "<{addr}> (host '{host}' rejected "); + + if !self.command.is_empty() { + let _ = write!(dsn, "command '{}'", self.command); + } else { + dsn.push_str("transaction"); + } + + let _ = write!( + dsn, + " with code {} ({}.{}.{}) '", + self.response.code, self.response.esc[0], self.response.esc[1], self.response.esc[2] + ); + self.response.write_response(dsn); + dsn.push_str("')\r\n"); + } +} + +impl ErrorDetails { + fn write_dsn_text(&self, addr: &str, dsn: &mut String) { + let entity = self.entity.as_ref(); + match &self.details { + Error::UnexpectedResponse(response) => { + response.write_dsn_text(entity, addr, dsn); + } + Error::DnsError(err) => { + let _ = write!(dsn, "<{addr}> (failed to lookup '{entity}': {err})\r\n",); + } + Error::ConnectionError(details) => { + let _ = write!( + dsn, + "<{addr}> (connection to '{entity}' failed: {details})\r\n", + ); + } + Error::TlsError(details) => { + let _ = write!(dsn, "<{addr}> (TLS error from '{entity}': {details})\r\n",); + } + Error::DaneError(details) => { + let _ = write!( + dsn, + "<{addr}> (DANE failed to authenticate '{entity}': {details})\r\n", + ); + } + Error::MtaStsError(details) => { + let _ = write!( + dsn, + "<{addr}> (MTA-STS failed to authenticate '{entity}': {details})\r\n", + ); + } + Error::RateLimited => { + let _ = write!(dsn, "<{addr}> (rate limited)\r\n"); + } + Error::ConcurrencyLimited => { + let _ = write!( + dsn, + "<{addr}> (too many concurrent connections to remote server)\r\n", + ); + } + Error::Io(err) => { + let _ = write!(dsn, "<{addr}> (queue error: {err})\r\n"); + } + } + } +} + +impl Message { + fn write_dsn_headers(&self, dsn: &mut String, reporting_mta: &str) { + let _ = write!(dsn, "Reporting-MTA: dns;{reporting_mta}\r\n"); + dsn.push_str("Arrival-Date: "); + dsn.push_str(&DateTime::from_timestamp(self.created as i64).to_rfc822()); + dsn.push_str("\r\n"); + if let Some(env_id) = &self.env_id { + let _ = write!(dsn, "Original-Envelope-Id: {env_id}\r\n"); + } + dsn.push_str("\r\n"); + } +} + +impl Recipient { + fn write_dsn(&self, dsn: &mut String) { + if let Some(orcpt) = &self.orcpt { + let _ = write!(dsn, "Original-Recipient: rfc822;{orcpt}\r\n"); + } + let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address); + } + + fn write_dsn_will_retry_until(&self, created: u64, dsn: &mut String) { + if let Some(expires) = self.expiration_time(created) + && expires > now() + { + dsn.push_str("Will-Retry-Until: "); + dsn.push_str(&DateTime::from_timestamp(expires as i64).to_rfc822()); + dsn.push_str("\r\n"); + } + } +} + +impl Status { + pub fn into_permanent(self) -> Self { + match self { + Status::TemporaryFailure(v) => Status::PermanentFailure(v), + v => v, + } + } + + pub fn into_temporary(self) -> Self { + match self { + Status::PermanentFailure(err) => Status::TemporaryFailure(err), + other => other, + } + } + + pub fn is_permanent(&self) -> bool { + matches!(self, Status::PermanentFailure(_)) + } + + fn write_dsn_action(&self, dsn: &mut String) { + dsn.push_str("Action: "); + dsn.push_str(match self { + Status::Completed(_) => "delivered", + Status::PermanentFailure(_) => "failed", + Status::TemporaryFailure(_) | Status::Scheduled => "delayed", + }); + dsn.push_str("\r\n"); + } +} + +impl Status>, ErrorDetails> { + fn write_dsn(&self, dsn: &mut String) { + self.write_dsn_action(dsn); + self.write_dsn_status(dsn); + self.write_dsn_diagnostic(dsn); + self.write_dsn_remote_mta(dsn); + } + + fn write_dsn_status(&self, dsn: &mut String) { + dsn.push_str("Status: "); + match self { + Status::Completed(response) => { + response.response.write_dsn_status(dsn); + } + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + if let Error::UnexpectedResponse(response) = &err.details { + response.response.write_dsn_status(dsn); + } else { + dsn.push_str(if matches!(self, Status::PermanentFailure(_)) { + "5.0.0" + } else { + "4.0.0" + }); + } + } + Status::Scheduled => { + dsn.push_str("4.0.0"); + } + } + dsn.push_str("\r\n"); + } + + fn write_dsn_remote_mta(&self, dsn: &mut String) { + match self { + Status::Completed(response) => { + dsn.push_str("Remote-MTA: dns;"); + dsn.push_str(&response.hostname); + dsn.push_str("\r\n"); + } + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => match &err.details { + Error::UnexpectedResponse(_) + | Error::ConnectionError(_) + | Error::TlsError(_) + | Error::DaneError(_) => { + dsn.push_str("Remote-MTA: dns;"); + dsn.push_str(&err.entity); + dsn.push_str("\r\n"); + } + _ => (), + }, + Status::Scheduled => (), + } + } + + fn write_dsn_diagnostic(&self, dsn: &mut String) { + if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self + && let Error::UnexpectedResponse(response) = &err.details + { + response.response.write_dsn_diagnostic(dsn); + } + } +} + +impl WriteDsn for Response> { + fn write_dsn_status(&self, dsn: &mut String) { + if self.esc[0] > 0 { + let _ = write!(dsn, "{}.{}.{}", self.esc[0], self.esc[1], self.esc[2]); + } else { + let _ = write!( + dsn, + "{}.{}.{}", + self.code / 100, + (self.code / 10) % 10, + self.code % 10 + ); + } + } + + fn write_dsn_diagnostic(&self, dsn: &mut String) { + let _ = write!(dsn, "Diagnostic-Code: smtp;{} ", self.code); + self.write_response(dsn); + dsn.push_str("\r\n"); + } + + fn write_response(&self, dsn: &mut String) { + for ch in self.message.chars() { + if ch != '\n' && ch != '\r' { + dsn.push(ch); + } + } + } +} + +trait WriteDsn { + fn write_dsn_status(&self, dsn: &mut String); + fn write_dsn_diagnostic(&self, dsn: &mut String); + fn write_response(&self, dsn: &mut String); +} diff --git a/crates/smtp/src/queue/manager.rs b/crates/smtp/src/queue/manager.rs new file mode 100644 index 0000000..0719360 --- /dev/null +++ b/crates/smtp/src/queue/manager.rs @@ -0,0 +1,501 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Message, QueueId, Status, spool::SmtpSpool}; +use crate::queue::{ + Recipient, + spool::{INFINITE_LOCK, LOCK_EXPIRY, QUEUE_REFRESH}, +}; +use ahash::AHashMap; +use common::{ + BuildServer, Inner, + config::smtp::queue::{QueueExpiry, QueueName}, + ipc::{QueueEvent, QueueEventStatus}, +}; +use rand::{RngExt, seq::SliceRandom}; +use std::{ + collections::hash_map::Entry, + sync::{Arc, atomic::Ordering}, + time::{Duration, Instant}, +}; +use store::write::now; +use tokio::sync::mpsc; + +pub struct Queue { + pub core: Arc, + pub locked: AHashMap<(QueueId, QueueName), LockedMessage>, + pub locked_revision: u64, + pub stats: AHashMap, + pub next_refresh: Instant, + pub rx: mpsc::Receiver, + pub is_paused: bool, + pub scan_from: u64, + pub scan_ceiling: u64, + pub has_pending_work: bool, + pub pending_refresh: bool, + pub urgent_refresh: bool, + pub last_scan: Instant, + pub last_full_scan: Instant, +} + +#[derive(Debug)] +pub struct QueueStats { + pub in_flight: usize, + pub max_in_flight: usize, + pub budget: usize, + pub last_warning: Instant, +} + +#[derive(Debug)] +pub struct LockedMessage { + pub expires: u64, + pub revision: u64, + pub due: u64, +} + +impl SpawnQueue for mpsc::Receiver { + fn spawn(self, core: Arc) { + tokio::spawn(async move { + Queue::new(core, self).start().await; + }); + } +} + +const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60); +const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100); +const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2); + +impl Queue { + pub fn new(core: Arc, rx: mpsc::Receiver) -> Self { + let now = Instant::now(); + + Queue { + core, + locked: AHashMap::with_capacity(128), + locked_revision: 0, + stats: AHashMap::new(), + next_refresh: now + Duration::from_secs(1), + is_paused: false, + rx, + scan_from: 0, + scan_ceiling: u64::MAX, + has_pending_work: false, + pending_refresh: false, + urgent_refresh: false, + last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now), + last_full_scan: now, + } + } + + pub async fn start(&mut self) { + trc::event!(Queue(trc::QueueEvent::Started)); + + loop { + let mut refresh_queue; + + match tokio::time::timeout( + self.next_refresh.duration_since(Instant::now()), + self.rx.recv(), + ) + .await + { + Ok(Some(event)) => { + refresh_queue = self.handle_event(event).await; + + while let Ok(event) = self.rx.try_recv() { + refresh_queue = self.handle_event(event).await || refresh_queue; + } + } + Err(_) => { + refresh_queue = true; + self.urgent_refresh = true; + } + Ok(None) => { + break; + } + }; + + if self.is_paused { + self.next_refresh = Instant::now() + Duration::from_secs(86400); + continue; + } + + self.pending_refresh |= refresh_queue; + if !self.pending_refresh && self.next_refresh > Instant::now() { + continue; + } + + // Coalesce bursts of worker notifications into a single scan + let scan_at = self.last_scan + MIN_SCAN_INTERVAL; + if !self.urgent_refresh && scan_at > Instant::now() { + self.next_refresh = scan_at; + continue; + } + + if self.scan_from != 0 && self.last_full_scan.elapsed() >= FULL_SCAN_INTERVAL { + self.scan_from = 0; + } + if self.scan_from == 0 { + self.last_full_scan = Instant::now(); + } + let scan_floor = self.scan_from; + self.pending_refresh = false; + self.urgent_refresh = false; + + // Process queue events + let server = self.core.build_server(); + let mut queue_events = server.next_event(self).await; + self.last_scan = Instant::now(); + + if queue_events.messages.len() > 3 { + queue_events.messages.shuffle(&mut rand::rng()); + } + + // A truncated scan left events behind + let now = now(); + self.has_pending_work = self.scan_ceiling != u64::MAX; + + for queue_event in &queue_events.messages { + // A message may hold more than one event key, dispatch it only once + if self + .locked + .get(&(queue_event.queue_id, queue_event.queue_name)) + .is_some_and(|locked| locked.expires > now) + { + continue; + } + + // Fetch queue stats + let stats = match self.stats.get_mut(&queue_event.queue_name) { + Some(stats) => stats, + None => { + let queue_config = + server.get_virtual_queue_or_default(&queue_event.queue_name); + self.stats.insert( + queue_event.queue_name, + QueueStats::new(queue_config.threads), + ); + self.stats.get_mut(&queue_event.queue_name).unwrap() + } + }; + + // Enforce concurrency limits + if stats.has_capacity() { + // Deliver message + stats.in_flight += 1; + self.locked.insert( + (queue_event.queue_id, queue_event.queue_name), + LockedMessage { + expires: now + INFINITE_LOCK, + revision: self.locked_revision, + due: queue_event.due, + }, + ); + queue_event.try_deliver(server.clone()); + } else { + if stats.last_warning.elapsed() >= BACK_PRESSURE_WARN_INTERVAL { + stats.last_warning = Instant::now(); + trc::event!( + Queue(trc::QueueEvent::BackPressure), + Reason = "Processing capacity for this queue exceeded.", + QueueName = queue_event.queue_name.to_string(), + Limit = stats.max_in_flight, + ); + } + self.has_pending_work = true; + if queue_event.due < self.scan_from { + self.scan_from = queue_event.due; + } + } + } + + // Remove expired locks, revisiting any event they were holding back + let scan_ceiling = self.scan_ceiling; + let mut dropped_due = u64::MAX; + self.locked.retain(|_, locked| { + let keep = locked.expires > now + && (locked.revision == self.locked_revision + || locked.due < scan_floor + || locked.due >= scan_ceiling); + if !keep && locked.due < dropped_due { + dropped_due = locked.due; + } + keep + }); + + // Do not wait for the next scheduled event while there is work left over + let mut next_refresh = queue_events.next_refresh.saturating_sub(now); + if self.has_pending_work { + next_refresh = std::cmp::min(next_refresh, FULL_SCAN_INTERVAL.as_secs()); + } + let mut next_refresh = Instant::now() + Duration::from_secs(next_refresh); + + // A released lock uncovered an event below the floor that no scan can see + if dropped_due < self.scan_from { + self.scan_from = dropped_due; + self.has_pending_work = true; + self.pending_refresh = true; + + let scan_at = self.last_scan + MIN_SCAN_INTERVAL; + if scan_at < next_refresh { + next_refresh = scan_at; + } + } + + self.next_refresh = next_refresh; + } + } + + async fn handle_event(&mut self, event: QueueEvent) -> bool { + match event { + QueueEvent::WorkerDone { + queue_id, + queue_name, + status, + } => { + let has_capacity = match self.stats.get_mut(&queue_name) { + Some(queue_stats) => { + queue_stats.in_flight = queue_stats.in_flight.saturating_sub(1); + queue_stats.has_capacity() + } + None => true, + }; + + match status { + QueueEventStatus::Completed => { + self.core.ipc.task_tx.notify_one(); + self.locked.remove(&(queue_id, queue_name)); + !self.locked.is_empty() || !has_capacity || self.has_pending_work + } + QueueEventStatus::Locked => { + let expires = LOCK_EXPIRY + rand::rng().random_range(5..10); + let due_in = Instant::now() + Duration::from_secs(expires); + if due_in < self.next_refresh { + self.next_refresh = due_in; + } + + // The event was not delivered, so it has to be visited again + // once the remote lock expires. + let expires = now() + expires; + let due = match self.locked.entry((queue_id, queue_name)) { + Entry::Occupied(mut entry) => { + let locked = entry.get_mut(); + locked.expires = expires; + locked.revision = self.locked_revision; + locked.due + } + Entry::Vacant(entry) => { + entry.insert(LockedMessage { + expires, + revision: self.locked_revision, + due: 0, + }); + 0 + } + }; + if due < self.scan_from { + self.scan_from = due; + } + self.locked.len() > 1 || !has_capacity || self.has_pending_work + } + QueueEventStatus::Deferred => { + self.locked.remove(&(queue_id, queue_name)); + self.scan_from = 0; + true + } + } + } + QueueEvent::Refresh => { + self.scan_from = 0; + self.urgent_refresh = true; + true + } + QueueEvent::Paused(paused) => { + self.core + .data + .queue_status + .store(!paused, Ordering::Relaxed); + self.is_paused = paused; + self.scan_from = 0; + self.urgent_refresh = !paused; + !paused + } + QueueEvent::ReloadSettings => { + let server = self.core.build_server(); + let virtual_queues = &server.core.smtp.queue.virtual_queues; + for (name, settings) in virtual_queues { + if let Some(stats) = self.stats.get_mut(name) { + stats.max_in_flight = settings.threads; + } else { + self.stats.insert(*name, QueueStats::new(settings.threads)); + } + } + self.stats + .retain(|name, stats| stats.in_flight > 0 || virtual_queues.contains_key(name)); + self.scan_from = 0; + false + } + QueueEvent::Stop => { + self.rx.close(); + self.is_paused = true; + false + } + } + } +} + +impl Message { + pub fn next_event(&self, queue: Option) -> Option { + let mut next_event = None; + + for rcpt in &self.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + { + let mut earlier_event = std::cmp::min(rcpt.retry.due, rcpt.notify.due); + + if let Some(expires) = rcpt.expiration_time(self.created) { + earlier_event = std::cmp::min(earlier_event, expires); + } + + if let Some(next_event) = &mut next_event { + if earlier_event < *next_event { + *next_event = earlier_event; + } + } else { + next_event = Some(earlier_event); + } + } + } + + next_event + } + + pub fn next_delivery_event(&self, queue: Option) -> Option { + let mut next_delivery = None; + + for rcpt in self.recipients.iter().filter(|rcpt| { + matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + }) { + if let Some(next_delivery) = &mut next_delivery { + if rcpt.retry.due < *next_delivery { + *next_delivery = rcpt.retry.due; + } + } else { + next_delivery = Some(rcpt.retry.due); + } + } + + next_delivery + } + + pub fn next_dsn(&self, queue: Option) -> Option { + let mut next_dsn = None; + + for rcpt in self.recipients.iter().filter(|rcpt| { + matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + }) { + if let Some(next_dsn) = &mut next_dsn { + if rcpt.notify.due < *next_dsn { + *next_dsn = rcpt.notify.due; + } + } else { + next_dsn = Some(rcpt.notify.due); + } + } + + next_dsn + } + + pub fn expires(&self, queue: Option) -> Option { + let mut expires = None; + + for rcpt in self.recipients.iter().filter(|d| { + matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| d.queue == q) + }) { + if let Some(rcpt_expires) = rcpt.expiration_time(self.created) { + if let Some(expires) = &mut expires { + if rcpt_expires > *expires { + *expires = rcpt_expires; + } + } else { + expires = Some(rcpt_expires) + } + } + } + + expires + } + + pub fn next_events(&self) -> AHashMap { + let mut next_events = AHashMap::new(); + + for rcpt in &self.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) { + let mut earlier_event = std::cmp::min(rcpt.retry.due, rcpt.notify.due); + + if let Some(expires) = rcpt.expiration_time(self.created) { + earlier_event = std::cmp::min(earlier_event, expires); + } + + match next_events.entry(rcpt.queue) { + Entry::Occupied(mut entry) => { + let entry = entry.get_mut(); + if earlier_event < *entry { + *entry = earlier_event; + } + } + Entry::Vacant(entry) => { + entry.insert(earlier_event); + } + } + } + } + + next_events + } +} + +impl Recipient { + pub fn expiration_time(&self, created: u64) -> Option { + match self.expires { + QueueExpiry::Ttl(time) => Some(created + time), + QueueExpiry::Attempts(_) => None, + } + } + + pub fn is_expired(&self, created: u64, now: u64) -> bool { + match self.expires { + QueueExpiry::Ttl(time) => created + time <= now, + QueueExpiry::Attempts(count) => self.retry.inner >= count, + } + } +} + +pub trait SpawnQueue { + fn spawn(self, core: Arc); +} + +impl QueueStats { + pub(crate) fn new(max_in_flight: usize) -> Self { + QueueStats { + in_flight: 0, + max_in_flight, + budget: 0, + last_warning: Instant::now() + .checked_sub(BACK_PRESSURE_WARN_INTERVAL) + .unwrap_or_else(Instant::now), + } + } + + #[inline] + pub fn has_capacity(&self) -> bool { + self.in_flight < self.max_in_flight + } +} diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs new file mode 100644 index 0000000..bacc6c9 --- /dev/null +++ b/crates/smtp/src/queue/mod.rs @@ -0,0 +1,666 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + config::smtp::queue::{QueueExpiry, QueueName}, + expr::{self, functions::ResolveVariable, *}, +}; +use compact_str::ToCompactString; +use registry::schema::enums::ExpressionVariable; +use smtp_proto::Response; +use std::{ + fmt::Display, + net::{IpAddr, Ipv4Addr}, + time::{Duration, Instant, SystemTime}, +}; +use store::write::now; +use types::blob_hash::BlobHash; +use utils::DomainPart; + +pub mod dsn; +pub mod manager; +pub mod quota; +pub mod spool; +pub mod throttle; + +pub type QueueId = u64; + +#[derive(Debug, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Deserialize)] +pub struct Schedule { + pub due: u64, + pub inner: T, +} + +#[derive(Debug, Clone, Copy)] +pub struct QueuedMessage { + pub due: u64, + pub queue_id: QueueId, + pub queue_name: QueueName, +} + +#[derive(Debug, Clone, Copy)] +pub enum MessageSource { + Authenticated, + Unauthenticated { dmarc_pass: bool }, + Dsn, + Report, + Autogenerated, +} + +impl MessageSource { + pub fn flags(&self) -> u64 { + match self { + MessageSource::Authenticated => FROM_AUTHENTICATED, + MessageSource::Unauthenticated { dmarc_pass: true } => FROM_UNAUTHENTICATED_DMARC, + MessageSource::Unauthenticated { dmarc_pass: false } => FROM_UNAUTHENTICATED, + MessageSource::Dsn => FROM_DSN, + MessageSource::Report => FROM_REPORT, + MessageSource::Autogenerated => FROM_AUTOGENERATED, + } + } +} + +#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub created: u64, + pub blob_hash: BlobHash, + + pub return_path: Box, + pub recipients: Vec, + + pub received_from_ip: IpAddr, + pub received_via_port: u16, + + pub flags: u64, + pub env_id: Option>, + pub priority: i16, + + pub size: u64, + pub metadata: Box<[Metadata]>, +} + +impl Message { + pub fn queued_event(&self) -> trc::QueueEvent { + if (self.flags & FROM_AUTHENTICATED) != 0 { + trc::QueueEvent::AuthenticatedMessageQueued + } else if (self.flags & FROM_DSN) != 0 { + trc::QueueEvent::DsnQueued + } else if (self.flags & FROM_REPORT) != 0 { + trc::QueueEvent::ReportQueued + } else if (self.flags & FROM_AUTOGENERATED) != 0 { + trc::QueueEvent::AutogeneratedQueued + } else { + trc::QueueEvent::MessageQueued + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MessageWrapper { + pub queue_id: QueueId, + pub queue_name: QueueName, + pub is_multi_queue: bool, + pub span_id: u64, + pub message: Message, +} + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Debug, + Clone, + PartialEq, + Eq, + serde::Deserialize, +)] +pub enum Metadata { + QueueSize { key: Box<[u8]>, id: u64 }, + QueueCount { key: Box<[u8]>, id: u64 }, + Headers { value: Box<[u8]>, id: u64 }, +} + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Debug, + Clone, + PartialEq, + Eq, + serde::Deserialize, +)] +pub struct Recipient { + pub address: Box, + + pub retry: Schedule, + pub notify: Schedule, + pub expires: QueueExpiry, + + pub queue: QueueName, + pub status: Status>, ErrorDetails>, + pub flags: u64, + pub orcpt: Option>, +} + +pub const FROM_AUTHENTICATED: u64 = 1 << 32; +pub const FROM_UNAUTHENTICATED: u64 = 1 << 33; +pub const FROM_UNAUTHENTICATED_DMARC: u64 = 1 << 34; +pub const FROM_DSN: u64 = 1 << 35; +pub const FROM_REPORT: u64 = 1 << 36; +pub const FROM_AUTOGENERATED: u64 = 1 << 37; + +pub const RCPT_DSN_SENT: u64 = 1 << 32; + +pub const RCPT_SPAM_SHIFT: u64 = 56; +pub const RCPT_SPAM_MASK: u64 = 0xff << RCPT_SPAM_SHIFT; + +pub const fn rcpt_spam_flag(percentage: u8) -> u64 { + (percentage as u64 + 1) << RCPT_SPAM_SHIFT +} + +pub const fn rcpt_spam_percentage(flags: u64) -> Option { + match (flags >> RCPT_SPAM_SHIFT) as u8 { + 0 => None, + percentage => Some(percentage - 1), + } +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Serialize, + serde::Deserialize, +)] +pub enum Status { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "completed")] + Completed(T), + #[serde(rename = "temp_fail")] + TemporaryFailure(E), + #[serde(rename = "perm_fail")] + PermanentFailure(E), +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, +)] +pub struct HostResponse { + pub hostname: T, + pub response: Response>, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, + Default, +)] +pub enum Error { + DnsError(Box), + UnexpectedResponse(UnexpectedResponse), + ConnectionError(Box), + TlsError(Box), + DaneError(Box), + MtaStsError(Box), + RateLimited, + #[default] + ConcurrencyLimited, + Io(Box), +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, +)] +pub struct UnexpectedResponse { + pub command: Box, + pub response: Response>, +} + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Default, + serde::Deserialize, +)] +pub struct ErrorDetails { + pub entity: Box, + pub details: Error, +} + +impl Ord for Schedule { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + other.due.cmp(&self.due) + } +} + +impl PartialOrd for Schedule { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Schedule { + fn eq(&self, other: &Self) -> bool { + self.due == other.due + } +} + +impl Eq for Schedule {} + +impl Schedule { + pub fn now() -> Self { + Schedule { + due: now(), + inner: T::default(), + } + } + + pub fn later(duration: u64) -> Self { + Schedule { + due: now() + duration, + inner: T::default(), + } + } +} + +pub struct QueueEnvelope<'x> { + pub message: &'x Message, + pub domain: &'x str, + pub mx: &'x str, + pub rcpt: &'x Recipient, + pub remote_ip: IpAddr, + pub local_ip: IpAddr, +} + +impl<'x> QueueEnvelope<'x> { + pub fn new(message: &'x Message, rcpt: &'x Recipient) -> Self { + Self { + message, + domain: rcpt.address.domain_part(), + rcpt, + mx: "", + remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + } + } +} + +impl<'x> ResolveVariable for QueueEnvelope<'x> { + fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'x> { + match variable { + ExpressionVariable::Sender => self.message.return_path.as_ref().into(), + ExpressionVariable::SenderDomain => self.message.return_path.domain_part().into(), + ExpressionVariable::RcptDomain => self.domain.into(), + ExpressionVariable::Rcpt => self.rcpt.address.as_ref().into(), + ExpressionVariable::Recipients => self + .message + .recipients + .iter() + .map(|r| Variable::from(r.address.as_ref())) + .collect::>() + .into(), + ExpressionVariable::RetryNum => self.rcpt.retry.inner.into(), + ExpressionVariable::NotifyNum => self.rcpt.notify.inner.into(), + ExpressionVariable::ExpiresIn => match &self.rcpt.expires { + QueueExpiry::Ttl(time) => (*time + self.message.created).saturating_sub(now()), + QueueExpiry::Attempts(count) => { + (count.saturating_sub(self.rcpt.retry.inner)) as u64 + } + } + .into(), + ExpressionVariable::LastStatus => self.rcpt.status.to_compact_string().into(), + ExpressionVariable::LastError => match &self.rcpt.status { + Status::Scheduled | Status::Completed(_) => "none", + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + match &err.details { + Error::DnsError(_) => "dns", + Error::UnexpectedResponse(_) => "unexpected-reply", + Error::ConnectionError(_) => "connection", + Error::TlsError(_) => "tls", + Error::DaneError(_) => "dane", + Error::MtaStsError(_) => "mta-sts", + Error::RateLimited => "rate", + Error::ConcurrencyLimited => "concurrency", + Error::Io(_) => "io", + } + } + } + .into(), + ExpressionVariable::QueueName => self.rcpt.queue.as_str().into(), + ExpressionVariable::QueueAge => now().saturating_sub(self.message.created).into(), + ExpressionVariable::Source => if (self.message.flags & FROM_AUTHENTICATED) != 0 { + "authenticated" + } else if (self.message.flags & FROM_UNAUTHENTICATED_DMARC) != 0 { + "dmarc_pass" + } else if (self.message.flags & FROM_UNAUTHENTICATED) != 0 { + "unauthenticated" + } else if (self.message.flags & FROM_DSN) != 0 { + "dsn" + } else if (self.message.flags & FROM_REPORT) != 0 { + "report" + } else if (self.message.flags & FROM_AUTOGENERATED) != 0 { + "autogenerated" + } else { + "unknown" + } + .into(), + ExpressionVariable::Mx => self.mx.into(), + ExpressionVariable::Priority => self.message.priority.into(), + ExpressionVariable::RemoteIp => self.remote_ip.to_compact_string().into(), + ExpressionVariable::LocalIp => self.local_ip.to_compact_string().into(), + ExpressionVariable::ReceivedFromIp => { + self.message.received_from_ip.to_compact_string().into() + } + ExpressionVariable::ReceivedViaPort => self.message.received_via_port.into(), + ExpressionVariable::Size => self.message.size.into(), + _ => "".into(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl ResolveVariable for Message { + fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> { + match variable { + ExpressionVariable::Sender => self.return_path.as_ref().into(), + ExpressionVariable::SenderDomain => self.return_path.domain_part().into(), + ExpressionVariable::Recipients => self + .recipients + .iter() + .map(|r| Variable::from(r.address.as_ref())) + .collect::>() + .into(), + ExpressionVariable::Priority => self.priority.into(), + _ => "".into(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl ResolveVariable for MessageWrapper { + fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> { + match variable { + ExpressionVariable::Sender => self.message.return_path.as_ref().into(), + ExpressionVariable::SenderDomain => self.message.return_path.domain_part().into(), + ExpressionVariable::Recipients => self + .message + .recipients + .iter() + .map(|r| Variable::from(r.address.as_ref())) + .collect::>() + .into(), + ExpressionVariable::Priority => self.message.priority.into(), + ExpressionVariable::QueueName => self.queue_name.as_str().into(), + ExpressionVariable::QueueAge => now().saturating_sub(self.message.created).into(), + ExpressionVariable::Source => if (self.message.flags & FROM_AUTHENTICATED) != 0 { + "authenticated" + } else if (self.message.flags & FROM_UNAUTHENTICATED_DMARC) != 0 { + "dmarc_pass" + } else if (self.message.flags & FROM_UNAUTHENTICATED) != 0 { + "unauthenticated" + } else if (self.message.flags & FROM_DSN) != 0 { + "dsn" + } else if (self.message.flags & FROM_REPORT) != 0 { + "report" + } else if (self.message.flags & FROM_AUTOGENERATED) != 0 { + "autogenerated" + } else { + "unknown" + } + .into(), + ExpressionVariable::ReceivedFromIp => { + self.message.received_from_ip.to_compact_string().into() + } + ExpressionVariable::ReceivedViaPort => self.message.received_via_port.into(), + ExpressionVariable::Size => self.message.size.into(), + _ => "".into(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +pub struct RecipientDomain<'x>(&'x str); + +impl<'x> RecipientDomain<'x> { + pub fn new(domain: &'x str) -> Self { + Self(domain) + } +} + +impl<'x> ResolveVariable for RecipientDomain<'x> { + fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'x> { + match variable { + ExpressionVariable::RcptDomain => self.0.into(), + _ => "".into(), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +#[inline(always)] +pub fn instant_to_timestamp(now: Instant, time: Instant) -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + + time.checked_duration_since(now).map_or(0, |d| d.as_secs()) +} + +impl Recipient { + pub fn new(address: impl AsRef) -> Self { + Recipient { + address: address.to_lowercase_address(false).into_boxed_str(), + status: Status::Scheduled, + flags: 0, + orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Attempts(0), + queue: QueueName::default(), + } + } + + pub fn with_flags(mut self, flags: u64) -> Self { + self.flags = flags; + self + } + + pub fn with_orcpt(mut self, orcpt: Option>) -> Self { + self.orcpt = orcpt; + self + } + + pub fn address(&self) -> &str { + &self.address + } + + pub fn domain_part(&self) -> &str { + self.address.domain_part() + } +} + +impl ArchivedRecipient { + pub fn address(&self) -> &str { + self.address.as_ref() + } + + pub fn domain_part(&self) -> &str { + self.address.domain_part() + } +} + +pub trait InstantFromTimestamp { + fn to_instant(&self) -> Instant; +} + +impl InstantFromTimestamp for u64 { + fn to_instant(&self) -> Instant { + let timestamp = *self; + let current_timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + if timestamp > current_timestamp { + Instant::now() + Duration::from_secs(timestamp - current_timestamp) + } else { + Instant::now() + } + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::UnexpectedResponse(response) => { + write!( + f, + "Unexpected response for {}: {}", + response.command, response.response + ) + } + Error::DnsError(err) => { + write!(f, "DNS lookup failed: {err}") + } + Error::ConnectionError(details) => { + write!(f, "Connection failed: {details}",) + } + Error::TlsError(details) => { + write!(f, "TLS error: {details}",) + } + Error::DaneError(details) => { + write!(f, "DANE authentication failure: {details}",) + } + Error::MtaStsError(details) => { + write!(f, "MTA-STS auth failed: {details}") + } + Error::RateLimited => { + write!(f, "Rate limited") + } + Error::ConcurrencyLimited => { + write!(f, "Too many concurrent connections to remote server") + } + Error::Io(err) => { + write!(f, "Queue error: {err}") + } + } + } +} + +impl Display for ArchivedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArchivedError::UnexpectedResponse(response) => { + write!( + f, + "Unexpected response for {}: {}", + response.command, response.response + ) + } + ArchivedError::DnsError(err) => { + write!(f, "DNS lookup failed: {err}") + } + ArchivedError::ConnectionError(details) => { + write!(f, "Connection failed: {details}",) + } + ArchivedError::TlsError(details) => { + write!(f, "TLS error: {details}",) + } + ArchivedError::DaneError(details) => { + write!(f, "DANE authentication failure: {details}",) + } + ArchivedError::MtaStsError(details) => { + write!(f, "MTA-STS auth failed: {details}") + } + ArchivedError::RateLimited => { + write!(f, "Rate limited") + } + ArchivedError::ConcurrencyLimited => { + write!(f, "Too many concurrent connections to remote server") + } + ArchivedError::Io(err) => { + write!(f, "Queue error: {err}") + } + } + } +} + +impl Display for Status>, ErrorDetails> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Scheduled => write!(f, "Scheduled"), + Status::Completed(response) => write!(f, "Delivered: {}", response.response), + Status::TemporaryFailure(err) => { + write!(f, "Temporary Failure for {}: {}", err.entity, err.details) + } + Status::PermanentFailure(err) => { + write!(f, "Permanent Failure for {}: {}", err.entity, err.details) + } + } + } +} + +impl Display for ArchivedErrorDetails { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error for {}: {}", self.entity, self.details) + } +} + +/* + +pub trait DisplayArchivedResponse { + fn to_string(&self) -> String; +} + +impl DisplayArchivedResponse for ArchivedResponse> { + fn to_string(&self) -> String { + format!( + "Code: {}, Enhanced code: {}.{}.{}, Message: {}", + self.code, self.esc[0], self.esc[1], self.esc[2], self.message, + ) + } +} +*/ diff --git a/crates/smtp/src/queue/quota.rs b/crates/smtp/src/queue/quota.rs new file mode 100644 index 0000000..7305899 --- /dev/null +++ b/crates/smtp/src/queue/quota.rs @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Metadata, QueueEnvelope, Status}; +use crate::{core::throttle::NewKey, queue::MessageWrapper}; +use ahash::AHashSet; +use common::{Server, config::smtp::queue::QueueQuota, expr::functions::ResolveVariable}; +use std::future::Future; +use store::{ + ValueKey, + write::{BatchBuilder, QueueClass, ValueClass}, +}; +use trc::QueueEvent; +use utils::DomainPart; + +pub trait HasQueueQuota: Sync + Send { + fn has_quota( + &self, + message: &mut MessageWrapper, + ) -> impl Future>> + Send; + fn check_quota<'x>( + &'x self, + quota: &'x QueueQuota, + envelope: &impl ResolveVariable, + size: u64, + id: u64, + refs: &mut Vec, + session_id: u64, + ) -> impl Future + Send; +} + +impl HasQueueQuota for Server { + async fn has_quota(&self, message: &mut MessageWrapper) -> Option> { + let mut quota_keys = Vec::new(); + + if !self.core.smtp.queue.quota.sender.is_empty() { + for quota in &self.core.smtp.queue.quota.sender { + if !self + .check_quota( + quota, + &message.message, + message.message.size, + 0, + &mut quota_keys, + message.span_id, + ) + .await + { + trc::event!( + Queue(QueueEvent::QuotaExceeded), + SpanId = message.span_id, + Id = quota.id.to_string(), + Type = "Sender" + ); + + return None; + } + } + } + + if !self.core.smtp.queue.quota.rcpt_domain.is_empty() { + let mut seen_domains = AHashSet::new(); + for quota in &self.core.smtp.queue.quota.rcpt_domain { + for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { + if seen_domains.insert(rcpt.address.domain_part()) + && !self + .check_quota( + quota, + &QueueEnvelope::new(&message.message, rcpt), + message.message.size, + ((rcpt_idx + 1) << 32) as u64, + &mut quota_keys, + message.span_id, + ) + .await + { + trc::event!( + Queue(QueueEvent::QuotaExceeded), + SpanId = message.span_id, + Id = quota.id.to_string(), + Type = "Domain" + ); + + return None; + } + } + } + } + + for quota in &self.core.smtp.queue.quota.rcpt { + for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { + if !self + .check_quota( + quota, + &QueueEnvelope::new(&message.message, rcpt), + message.message.size, + (rcpt_idx + 1) as u64, + &mut quota_keys, + message.span_id, + ) + .await + { + trc::event!( + Queue(QueueEvent::QuotaExceeded), + SpanId = message.span_id, + Id = quota.id.to_string(), + Type = "Recipient" + ); + + return None; + } + } + } + + Some(quota_keys) + } + + async fn check_quota<'x>( + &'x self, + quota: &'x QueueQuota, + envelope: &impl ResolveVariable, + size: u64, + id: u64, + refs: &mut Vec, + session_id: u64, + ) -> bool { + if !quota.expr.is_empty() + && self + .eval_if("a.expr, envelope, session_id) + .await + .unwrap_or(false) + { + let key = quota.new_key(envelope, ""); + if let Some(max_size) = quota.size { + let used_size = self + .core + .storage + .data + .get_counter(ValueKey::from(ValueClass::Queue(QueueClass::QuotaSize( + key.as_ref().to_vec(), + )))) + .await + .unwrap_or(0) as u64; + if used_size + size > max_size { + return false; + } else { + refs.push(Metadata::QueueSize { + key: key.as_ref().into(), + id, + }); + } + } + + if let Some(max_messages) = quota.messages { + let total_messages = self + .core + .storage + .data + .get_counter(ValueKey::from(ValueClass::Queue(QueueClass::QuotaCount( + key.as_ref().to_vec(), + )))) + .await + .unwrap_or(0) as u64; + if total_messages + 1 > max_messages { + return false; + } else { + refs.push(Metadata::QueueCount { + key: key.as_ref().into(), + id, + }); + } + } + } + true + } +} + +impl MessageWrapper { + pub fn release_quota(&mut self, batch: &mut BatchBuilder) { + if !self.message.metadata.iter().any(|metadata| { + matches!( + metadata, + Metadata::QueueSize { .. } | Metadata::QueueCount { .. } + ) + }) { + return; + } + let mut quota_ids = Vec::with_capacity(self.message.recipients.len()); + + let mut seen_domains = AHashSet::new(); + for (pos, rcpt) in self.message.recipients.iter().enumerate() { + if matches!( + &rcpt.status, + Status::Completed(_) | Status::PermanentFailure(_) + ) { + if seen_domains.insert(rcpt.address.domain_part()) { + quota_ids.push(((pos + 1) as u64) << 32); + } + quota_ids.push((pos + 1) as u64); + } + } + + if !quota_ids.is_empty() { + let mut metadata = Vec::new(); + for entry in std::mem::take(&mut self.message.metadata) { + match entry { + Metadata::QueueCount { id, key } if quota_ids.contains(&id) => { + batch.add( + ValueClass::Queue(QueueClass::QuotaCount(key.into_vec())), + -1, + ); + } + Metadata::QueueSize { id, key } if quota_ids.contains(&id) => { + batch.add( + ValueClass::Queue(QueueClass::QuotaSize(key.into_vec())), + -(self.message.size as i64), + ); + } + _ => { + metadata.push(entry); + } + } + } + self.message.metadata = metadata.into_boxed_slice(); + } + } +} diff --git a/crates/smtp/src/queue/spool.rs b/crates/smtp/src/queue/spool.rs new file mode 100644 index 0000000..14a756f --- /dev/null +++ b/crates/smtp/src/queue/spool.rs @@ -0,0 +1,1152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + ArchivedMessage, ArchivedStatus, Message, MessageSource, Metadata, QueueEnvelope, QueueId, + QueuedMessage, Recipient, Schedule, Status, +}; +use crate::inbound::dkim::DkimSign; +use crate::queue::MessageWrapper; +use crate::queue::manager::{Queue, QueueStats}; +use ahash::{AHashMap, AHashSet}; +use common::config::smtp::auth::DkimSigners; +use common::config::smtp::queue::{ArchivedQueueExpiry, QueueName}; +use common::ipc::{BroadcastEvent, QueueEvent}; +use common::network::RcptResolution; +use common::{KV_LOCK_QUEUE_MESSAGE, Server}; +use mail_auth::AuthenticatedMessage; +use registry::schema::prelude::{ObjectType, Property}; +use registry::schema::structs::SpamTrainingSample; +use registry::types::datetime::UTCDateTime; +use registry::types::id::ObjectId; +use registry::types::{EnumImpl, ObjectImpl}; +use std::borrow::Cow; +use std::collections::hash_map::Entry; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::time::SystemTime; +use store::write::key::DeserializeBigEndian; +use store::write::serialize::rkyv_deserialize; +use store::write::{ + AlignedBytes, Archive, Archiver, BatchBuilder, BlobLink, BlobOp, MergeResult, Params, + QueueClass, RegistryClass, ValueClass, now, +}; +use store::{ + Deserialize, IterateParams, Serialize, SerializeInfallible, U32_LEN, U64_LEN, ValueKey, +}; +use trc::{AddContext, ServerEvent, SpamEvent}; +use types::blob::BlobId; +use types::blob_hash::BlobHash; +use utils::DomainPart; + +pub const LOCK_EXPIRY: u64 = 10 * 60; // 10 minutes +pub const QUEUE_REFRESH: u64 = 5 * 60; // 5 minutes +pub(crate) const INFINITE_LOCK: u64 = 60 * 60 * 24 * 365; // 1 year +const CANDIDATE_OVERSCAN: usize = 4; +const MAX_PREALLOCATED_CANDIDATES: usize = 1024; + +pub struct QueuedMessages { + pub messages: Vec, + pub next_refresh: u64, +} + +pub trait SmtpSpool: Sync + Send { + fn new_message( + &self, + return_path: impl AsRef, + source: MessageSource, + span_id: u64, + ) -> MessageWrapper; + + fn next_event(&self, queue: &mut Queue) -> impl Future + Send; + + fn try_lock_event( + &self, + queue_id: QueueId, + queue_name: QueueName, + ) -> impl Future + Send; + + fn unlock_event( + &self, + queue_id: QueueId, + queue_name: QueueName, + ) -> impl Future + Send; + + fn read_message( + &self, + id: QueueId, + queue_name: QueueName, + ) -> impl Future> + Send; + + fn read_message_archive( + &self, + id: QueueId, + ) -> impl Future>>> + Send; +} + +impl SmtpSpool for Server { + fn new_message( + &self, + return_path: impl AsRef, + source: MessageSource, + span_id: u64, + ) -> MessageWrapper { + let created = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + + MessageWrapper { + queue_id: self.inner.data.queue_id_gen.generate(), + queue_name: QueueName::default(), + is_multi_queue: false, + span_id, + message: Message { + created, + return_path: return_path.to_lowercase_address(false).into_boxed_str(), + recipients: Vec::with_capacity(1), + flags: source.flags(), + env_id: None, + priority: 0, + size: 0, + blob_hash: Default::default(), + metadata: Default::default(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, + } + } + + async fn next_event(&self, queue: &mut Queue) -> QueuedMessages { + let now = now(); + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: queue.scan_from, + queue_id: 0, + queue_name: [0; 8], + }, + ))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: now + QUEUE_REFRESH, + queue_id: u64::MAX, + queue_name: [u8::MAX; 8], + }, + ))); + + // Over-sample the free delivery slots so the shuffle still has a pool to pick from + let mut total_budget: usize = 0; + for stats in queue.stats.values_mut() { + stats.budget = stats + .max_in_flight + .saturating_sub(stats.in_flight) + .saturating_mul(CANDIDATE_OVERSCAN); + total_budget = total_budget.saturating_add(stats.budget); + } + + let mut events = QueuedMessages { + messages: Vec::with_capacity(std::cmp::min(total_budget, MAX_PREALLOCATED_CANDIDATES)), + next_refresh: now + QUEUE_REFRESH, + }; + let mut next_scan_from = u64::MAX; + let mut scan_ceiling = u64::MAX; + + queue.locked_revision += 1; + let result = self + .store() + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + let due = key.deserialize_be_u64(0)?; + + if due > now { + if due < events.next_refresh { + events.next_refresh = due; + } + if due < next_scan_from { + next_scan_from = due; + } + return Ok(false); + } + + let queue_id = key.deserialize_be_u64(U64_LEN)?; + let queue_name = key + .get(U64_LEN + U64_LEN..) + .and_then(QueueName::from_bytes) + .ok_or_else(|| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Key, key) + })?; + + // Refreshed even when not dispatched, or a stale revision would evict it + let is_locked = match queue.locked.get_mut(&(queue_id, queue_name)) { + Some(locked) => { + locked.revision = queue.locked_revision; + locked.due = due; + if locked.expires > now { + if locked.expires < events.next_refresh { + events.next_refresh = locked.expires; + } + true + } else { + false + } + } + None => false, + }; + + if !is_locked { + let stats = match queue.stats.entry(queue_name) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let threads = + self.get_virtual_queue_or_default(&queue_name).threads; + let mut stats = QueueStats::new(threads); + stats.budget = threads.saturating_mul(CANDIDATE_OVERSCAN); + total_budget = total_budget.saturating_add(stats.budget); + entry.insert(stats) + } + }; + + if stats.budget > 0 { + stats.budget -= 1; + total_budget -= 1; + events.messages.push(QueuedMessage { + due, + queue_id, + queue_name, + }); + } else if due < next_scan_from { + next_scan_from = due; + } + + // Everything from here on is left for the next scan + if total_budget == 0 { + scan_ceiling = due; + if due < next_scan_from { + next_scan_from = due; + } + return Ok(false); + } + } + + Ok(true) + }, + ) + .await; + + if let Err(err) = result { + trc::error!( + err.details("Failed to read queue.") + .caused_by(trc::location!()) + ); + + queue.scan_from = 0; + queue.scan_ceiling = 0; + } else { + queue.scan_from = std::cmp::min(next_scan_from, now); + queue.scan_ceiling = scan_ceiling; + } + + events + } + + async fn try_lock_event(&self, queue_id: QueueId, queue_name: QueueName) -> bool { + match self + .in_memory_store() + .try_lock( + KV_LOCK_QUEUE_MESSAGE, + &lock_id(queue_id, queue_name), + LOCK_EXPIRY, + ) + .await + { + Ok(result) => { + if !result { + trc::event!( + Queue(trc::QueueEvent::Locked), + QueueId = queue_id, + QueueName = queue_name.to_string() + ); + } + result + } + Err(err) => { + trc::error!( + err.details("Failed to lock event.") + .caused_by(trc::location!()) + ); + false + } + } + } + + async fn unlock_event(&self, queue_id: QueueId, queue_name: QueueName) { + if let Err(err) = self + .in_memory_store() + .remove_lock(KV_LOCK_QUEUE_MESSAGE, &lock_id(queue_id, queue_name)) + .await + { + trc::error!( + err.details("Failed to unlock event.") + .caused_by(trc::location!()) + ); + } + } + + async fn read_message( + &self, + queue_id: QueueId, + queue_name: QueueName, + ) -> Option { + match self + .read_message_archive(queue_id) + .await + .and_then(|a| match a { + Some(a) => a.deserialize::().map(Some), + None => Ok(None), + }) { + Ok(Some(message)) => Some(MessageWrapper::new(message, queue_id, queue_name)), + Ok(None) => None, + Err(err) => { + trc::error!( + err.details("Failed to read message.") + .caused_by(trc::location!()) + ); + + None + } + } + } + + async fn read_message_archive( + &self, + id: QueueId, + ) -> trc::Result>> { + self.store() + .get_value::>(ValueKey::from(ValueClass::Queue( + QueueClass::Message(id), + ))) + .await + } +} + +impl MessageWrapper { + pub fn new(message: Message, queue_id: QueueId, queue_name: QueueName) -> Self { + MessageWrapper { + is_multi_queue: message.recipients.iter().any(|rcpt| { + matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && rcpt.queue != queue_name + }), + queue_id, + queue_name, + span_id: 0, + message, + } + } +} + +fn lock_id(queue_id: QueueId, queue_name: QueueName) -> [u8; 16] { + let mut id = [0; 16]; + id[..8].copy_from_slice(&queue_id.to_be_bytes()); + id[8..].copy_from_slice(queue_name.as_ref()); + id +} + +pub(crate) struct QueueParams<'x, 'y> { + pub raw_message: &'x [u8], + pub raw_headers: Option<&'x [u8]>, + pub metadata: Vec, + pub original_raw_message: Option<&'x [u8]>, + pub original_authenticated_message: Option>, + pub dkim_signers: Option>, + pub session_id: u64, + pub server: &'y Server, + pub train_spam: Option<(bool, String)>, +} + +impl MessageWrapper { + pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool { + // Add DKIM signatures + let dkim_headers = if params.dkim_signers.is_some() { + params.server.sign_message(&mut self, &mut params).await + } else { + None + }; + + // Fetch params + let QueueParams { + raw_message, + raw_headers, + session_id, + server, + train_spam, + metadata, + .. + } = params; + let event = self.message.queued_event(); + + // Write blob + let raw_headers = raw_headers.unwrap_or_default(); + let dkim_headers = dkim_headers.as_deref().unwrap_or_default(); + let message = if !raw_headers.is_empty() || !dkim_headers.is_empty() { + let mut message = + Vec::with_capacity(raw_headers.len() + dkim_headers.len() + raw_message.len()); + message.extend_from_slice(dkim_headers); + message.extend_from_slice(raw_headers); + message.extend_from_slice(raw_message); + Cow::Owned(message) + } else { + raw_message.into() + }; + self.message.blob_hash = BlobHash::generate(message.as_ref()); + + // Update size + if self.message.size == 0 { + self.message.size = message.len() as u64; + } + self.message.metadata = metadata.into_boxed_slice(); + + // Reserve and write blob + let mut batch = BatchBuilder::new(); + let now = now(); + let reserve_until = now + 120; + batch.set( + BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Temporary { + until: reserve_until, + }, + }, + vec![], + ); + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to write to store.") + .span_id(session_id) + .caused_by(trc::location!()) + ); + + return false; + } + if let Err(err) = server + .blob_store() + .put_blob( + self.message.blob_hash.as_slice(), + message.as_ref(), + server.core.email.compression, + ) + .await + { + trc::error!( + err.details("Failed to write blob.") + .span_id(session_id) + .caused_by(trc::location!()) + ); + + return false; + } + + trc::event!( + Queue(event), + SpanId = session_id, + QueueId = self.queue_id, + From = if !self.message.return_path.is_empty() { + trc::Value::String(self.message.return_path.as_ref().into()) + } else { + trc::Value::String("<>".into()) + }, + To = self + .message + .recipients + .iter() + .map(|r| trc::Value::String(r.address.as_ref().into())) + .collect::>(), + Size = self.message.size, + NextRetry = self + .message + .next_delivery_event(None) + .map(trc::Value::Timestamp), + NextDsn = self.message.next_dsn(None).map(trc::Value::Timestamp), + Expires = self.message.expires(None).map(trc::Value::Timestamp), + ); + + // Write message to queue + let mut batch = BatchBuilder::new(); + + // Reserve quotas + for metadata in &self.message.metadata { + match metadata { + Metadata::QueueCount { key, .. } => { + batch.add(ValueClass::Queue(QueueClass::QuotaCount(key.to_vec())), 1); + } + Metadata::QueueSize { key, .. } => { + batch.add( + ValueClass::Queue(QueueClass::QuotaSize(key.to_vec())), + self.message.size as i64, + ); + } + Metadata::Headers { .. } => {} + } + } + + for (queue_name, due) in self.message.next_events() { + batch.set( + ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + })), + Vec::new(), + ); + } + + if let Some((is_spam, subject)) = train_spam + && let Some(config) = &server.core.spam.classifier + { + let hold_period = now + config.hold_samples_for; + let sample = SpamTrainingSample { + account_id: None, + blob_id: BlobId::new(self.message.blob_hash.clone(), Default::default()), + delete_after_use: false, + expires_at: UTCDateTime::from_timestamp(hold_period as i64), + from: self.message.return_path.to_string(), + is_spam, + subject, + } + .to_pickled_vec(); + + let object_id = ObjectType::SpamTrainingSample.to_id(); + let item_id = server.inner.data.registry_id_gen.generate(); + batch + .set( + BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Temporary { until: hold_period }, + }, + ObjectId::new(ObjectType::SpamTrainingSample, item_id.into()).serialize(), + ) + .set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + sample, + ) + .set( + ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (u32::MAX as u64).serialize(), + }), + vec![], + ); + + trc::event!( + Spam(SpamEvent::TrainSampleAdded), + Details = if is_spam { "spam" } else { "ham" }, + Expires = trc::Value::Timestamp(hold_period), + SpanId = self.span_id, + ); + } + + batch + .clear(BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Temporary { + until: reserve_until, + }, + }) + .set( + BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Id { id: self.queue_id }, + }, + vec![], + ) + .set( + BlobOp::Commit { + hash: self.message.blob_hash.clone(), + }, + vec![], + ) + .set( + ValueClass::Queue(QueueClass::Message(self.queue_id)), + match Archiver::new(self.message).serialize() { + Ok(data) => data, + Err(err) => { + trc::error!( + err.details("Failed to serialize message.") + .span_id(session_id) + .caused_by(trc::location!()) + ); + return false; + } + }, + ); + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to write to store.") + .span_id(session_id) + .caused_by(trc::location!()) + ); + + return false; + } + + // Queue the message + if server + .inner + .ipc + .queue_tx + .send(QueueEvent::Refresh) + .await + .is_err() + { + trc::event!( + Server(ServerEvent::ThreadError), + Reason = "Channel closed.", + CausedBy = trc::location!(), + SpanId = session_id, + ); + } + + server.cluster_broadcast(BroadcastEvent::QueueRefresh).await; + + true + } + + pub async fn expand_and_add_recipient(&mut self, rcpt: impl AsRef, server: &Server) { + let rcpt = rcpt.as_ref(); + match server + .rcpt_resolve(&rcpt.to_lowercase(), true, self.span_id) + .await + { + Ok(RcptResolution::Rewrite(rewritten)) => { + self.add_expanded_recipient(&rewritten, server).await; + } + Ok(RcptResolution::Expand(addrs)) => { + for addr in addrs.as_ref() { + self.add_expanded_recipient(addr, server).await; + } + } + Ok(_) => { + self.add_expanded_recipient(rcpt, server).await; + } + Err(err) => { + trc::error!( + err.span_id(self.span_id) + .caused_by(trc::location!()) + .details("Failed to resolve recipient.") + .ctx(trc::Key::To, rcpt.to_string()) + ); + self.add_expanded_recipient(rcpt, server).await; + } + } + } + + pub async fn add_expanded_recipient(&mut self, rcpt: impl AsRef, server: &Server) { + self.message.recipients.push(Recipient::new(rcpt.as_ref())); + let queue = server.get_queue_or_default( + &server + .eval_if::( + &server.core.smtp.queue.queue, + &QueueEnvelope::new(&self.message, self.message.recipients.last().unwrap()), + self.span_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + self.span_id, + ); + + // Update expiration + let recipient = self.message.recipients.last_mut().unwrap(); + recipient.notify = Schedule::later(queue.notify.first().copied().unwrap_or(86400)); + recipient.expires = queue.expiry; + recipient.queue = queue.virtual_queue; + } + + pub async fn save_changes(mut self, server: &Server, prev_event: Option) -> bool { + // Release quota for completed deliveries + let mut batch = BatchBuilder::new(); + self.release_quota(&mut batch); + + // Update message queue + if let Some(prev_event) = prev_event { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: prev_event, + queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), + }, + ))); + } + for (queue_name, due) in self.message.next_events() { + batch.set( + ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + })), + Vec::new(), + ); + } + + let message_bytes = match Archiver::new(self.message).serialize() { + Ok(data) => data, + Err(err) => { + trc::error!( + err.details("Failed to serialize message.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + return false; + } + }; + if self.is_multi_queue { + batch.merge_fnc( + ValueClass::Queue(QueueClass::Message(self.queue_id)), + Params::with_capacity(3) + .with_u64(self.queue_id) + .with_bytes(self.queue_name.into_inner().to_vec()) + .with_bytes(message_bytes), + |params, _, bytes| { + let mut cur_message = as Deserialize>::deserialize( + bytes.ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .details("Message no longer exists.") + .caused_by(trc::location!()) + .ctx(trc::Key::QueueId, params.u64(0)) + })?, + ) + .and_then(|archive| archive.deserialize::()) + .caused_by(trc::location!())?; + + let new_message_ = + as Deserialize>::deserialize(params.bytes(2)) + .caused_by(trc::location!())?; + let new_message = new_message_ + .unarchive::() + .caused_by(trc::location!())?; + + if cur_message.blob_hash.as_slice() == new_message.blob_hash.0.as_slice() + && cur_message.recipients.len() == new_message.recipients.len() + { + let queue_name = params.bytes(1); + for (rcpt_idx, rcpt) in new_message + .recipients + .iter() + .enumerate() + .filter(|(_, rcpt)| rcpt.queue.as_slice() == queue_name) + { + cur_message.recipients[rcpt_idx] = + rkyv_deserialize(rcpt).caused_by(trc::location!())?; + } + + Archiver::new(cur_message) + .serialize() + .caused_by(trc::location!()) + .map(MergeResult::Update) + } else { + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Message blob hash or recipient count mismatch.") + .caused_by(trc::location!()) + .ctx(trc::Key::QueueId, params.u64(0))) + } + }, + ); + } else { + batch.set( + ValueClass::Queue(QueueClass::Message(self.queue_id)), + message_bytes, + ); + } + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to save changes.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + false + } else { + true + } + } + + pub async fn remove(self, server: &Server, prev_event: Option) -> bool { + let mut batch = BatchBuilder::new(); + + if let Some(prev_event) = prev_event { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: prev_event, + queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), + }, + ))); + } else { + for (queue_name, due) in self.message.next_events() { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + }, + ))); + } + } + + // Release all quotas + for metadata in self.message.metadata { + match metadata { + Metadata::QueueCount { key, .. } => { + batch.add(ValueClass::Queue(QueueClass::QuotaCount(key.to_vec())), -1); + } + Metadata::QueueSize { key, .. } => { + batch.add( + ValueClass::Queue(QueueClass::QuotaSize(key.to_vec())), + -(self.message.size as i64), + ); + } + Metadata::Headers { .. } => {} + } + } + + batch + .clear(BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Id { id: self.queue_id }, + }) + .clear(ValueClass::Queue(QueueClass::Message(self.queue_id))); + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to write to update queue.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + false + } else { + true + } + } + + pub async fn save_registry_changes( + mut self, + server: &Server, + prev_events: AHashMap, + modified_rcpts: AHashSet, + ) -> bool { + let mut batch = BatchBuilder::new(); + self.release_quota(&mut batch); + + for (queue_name, due) in prev_events { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + }, + ))); + } + for (queue_name, due) in self.message.next_events() { + batch.set( + ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + })), + Vec::new(), + ); + } + + let message_bytes = match Archiver::new(self.message).serialize() { + Ok(data) => data, + Err(err) => { + trc::error!( + err.details("Failed to serialize message.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + return false; + } + }; + + let mut modified_bytes = Vec::with_capacity(modified_rcpts.len() * U32_LEN); + for idx in modified_rcpts { + modified_bytes.extend_from_slice(&(idx as u32).to_be_bytes()); + } + + batch.merge_fnc( + ValueClass::Queue(QueueClass::Message(self.queue_id)), + Params::with_capacity(3) + .with_u64(self.queue_id) + .with_bytes(modified_bytes) + .with_bytes(message_bytes), + |params, _, bytes| { + let mut cur_message = as Deserialize>::deserialize( + bytes.ok_or_else(|| { + trc::StoreEvent::NotFound + .into_err() + .details("Message no longer exists.") + .caused_by(trc::location!()) + .ctx(trc::Key::QueueId, params.u64(0)) + })?, + ) + .and_then(|archive| archive.deserialize::()) + .caused_by(trc::location!())?; + + let new_message_ = + as Deserialize>::deserialize(params.bytes(2)) + .caused_by(trc::location!())?; + let new_message = new_message_ + .unarchive::() + .caused_by(trc::location!())?; + + if cur_message.blob_hash.as_slice() == new_message.blob_hash.0.as_slice() + && cur_message.recipients.len() == new_message.recipients.len() + { + cur_message.priority = new_message.priority.to_native(); + cur_message.env_id = new_message.env_id.as_ref().map(|v| v.as_ref().into()); + + for idx in params.bytes(1).as_chunks::().0 { + let rcpt_idx = u32::from_be_bytes(*idx) as usize; + if let Some(rcpt) = new_message.recipients.get(rcpt_idx) { + cur_message.recipients[rcpt_idx] = + rkyv_deserialize(rcpt).caused_by(trc::location!())?; + } + } + + Archiver::new(cur_message) + .serialize() + .caused_by(trc::location!()) + .map(MergeResult::Update) + } else { + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Message blob hash or recipient count mismatch.") + .caused_by(trc::location!()) + .ctx(trc::Key::QueueId, params.u64(0))) + } + }, + ); + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to save changes.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + false + } else { + true + } + } + + pub async fn remove_registry( + self, + server: &Server, + prev_events: AHashMap, + ) -> bool { + let mut batch = BatchBuilder::new(); + + for (queue_name, due) in prev_events { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + }, + ))); + } + + for metadata in self.message.metadata { + match metadata { + Metadata::QueueCount { key, .. } => { + batch.add(ValueClass::Queue(QueueClass::QuotaCount(key.to_vec())), -1); + } + Metadata::QueueSize { key, .. } => { + batch.add( + ValueClass::Queue(QueueClass::QuotaSize(key.to_vec())), + -(self.message.size as i64), + ); + } + Metadata::Headers { .. } => {} + } + } + + batch + .clear(BlobOp::Link { + hash: self.message.blob_hash.clone(), + to: BlobLink::Id { id: self.queue_id }, + }) + .clear(ValueClass::Queue(QueueClass::Message(self.queue_id))); + + if let Err(err) = server.store().write(batch.build_all()).await { + trc::error!( + err.details("Failed to write to update queue.") + .span_id(self.span_id) + .caused_by(trc::location!()) + ); + false + } else { + true + } + } + + pub fn has_domain(&self, domains: &[String]) -> bool { + self.message.recipients.iter().any(|r| { + let domain = r.address.domain_part(); + domains.iter().any(|dd| dd == domain) + }) || self + .message + .return_path + .rsplit_once('@') + .is_some_and(|(_, domain)| domains.iter().any(|dd| dd == domain)) + } +} + +impl ArchivedMessage { + pub fn has_domain(&self, domains: &AHashSet) -> bool { + self.recipients.iter().any(|r| { + let domain = r.address.domain_part(); + domains.contains(domain) + }) || self + .return_path + .rsplit_once('@') + .is_some_and(|(_, domain)| domains.contains(domain)) + } + + pub fn next_delivery_event(&self, queue: Option) -> Option { + let mut next_delivery = None; + + for rcpt in self.recipients.iter().filter(|d| { + matches!( + d.status, + ArchivedStatus::Scheduled | ArchivedStatus::TemporaryFailure(_) + ) && queue.is_none_or(|q| d.queue == q) + }) { + let retry_due = rcpt.retry.due.to_native(); + if let Some(next_delivery) = &mut next_delivery { + if retry_due < *next_delivery { + *next_delivery = retry_due; + } + } else { + next_delivery = Some(retry_due); + } + } + + next_delivery + } + + pub fn next_event(&self, queue: Option) -> Option { + let created = self.created.to_native(); + let mut next_event = None; + + for rcpt in self.recipients.iter().filter(|d| { + matches!( + d.status, + ArchivedStatus::Scheduled | ArchivedStatus::TemporaryFailure(_) + ) && queue.is_none_or(|q| d.queue == q) + }) { + let mut earlier_event = + std::cmp::min(rcpt.retry.due.to_native(), rcpt.notify.due.to_native()); + + if let ArchivedQueueExpiry::Ttl(ttl) = &rcpt.expires { + earlier_event = std::cmp::min(earlier_event, created + ttl.to_native()); + } + + if let Some(next_event) = &mut next_event { + if earlier_event < *next_event { + *next_event = earlier_event; + } + } else { + next_event = Some(earlier_event); + } + } + + next_event + } + + pub fn next_notify_event(&self, queue: Option) -> Option { + let mut next_notify = None; + + for rcpt in self.recipients.iter().filter(|d| { + matches!( + d.status, + ArchivedStatus::Scheduled | ArchivedStatus::TemporaryFailure(_) + ) && queue.is_none_or(|q| d.queue == q) + }) { + let notify_due = rcpt.notify.due.to_native(); + if let Some(next_notify) = &mut next_notify { + if notify_due < *next_notify { + *next_notify = notify_due; + } + } else { + next_notify = Some(notify_due); + } + } + + next_notify + } +} + +impl<'x, 'y> QueueParams<'x, 'y> { + pub fn new(raw_message: &'x [u8], session_id: u64, server: &'y Server) -> Self { + QueueParams { + raw_message, + dkim_signers: None, + raw_headers: None, + session_id, + server, + train_spam: None, + original_raw_message: None, + original_authenticated_message: None, + metadata: Vec::new(), + } + } + + pub fn with_train_spam(mut self, train_spam: Option<(bool, String)>) -> Self { + self.train_spam = train_spam; + self + } + + pub fn with_original_authenticated_message( + mut self, + authenticated_message: AuthenticatedMessage<'x>, + ) -> Self { + self.original_authenticated_message = Some(authenticated_message); + self + } + + pub fn with_original_raw_message(mut self, raw_message: &'x [u8]) -> Self { + self.original_raw_message = Some(raw_message); + self + } + + pub fn with_dkim_signers(mut self, dkim_signers: Option>) -> Self { + self.dkim_signers = dkim_signers; + self + } + + pub fn with_raw_headers(mut self, raw_headers: &'x [u8]) -> Self { + self.raw_headers = Some(raw_headers); + self + } + + pub fn with_raw_headers_opt(mut self, raw_headers: Option<&'x [u8]>) -> Self { + self.raw_headers = raw_headers; + self + } + + pub fn with_metadata(mut self, metadata: Vec) -> Self { + self.metadata = metadata; + self + } +} diff --git a/crates/smtp/src/queue/throttle.rs b/crates/smtp/src/queue/throttle.rs new file mode 100644 index 0000000..3e23f90 --- /dev/null +++ b/crates/smtp/src/queue/throttle.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::throttle::NewKey; +use common::{ + KV_RATE_LIMIT_SMTP, Server, config::smtp::QueueRateLimiter, expr::functions::ResolveVariable, +}; +use std::future::Future; +use store::write::now; + +pub trait IsAllowed: Sync + Send { + fn is_allowed<'x>( + &'x self, + throttle: &'x QueueRateLimiter, + envelope: &impl ResolveVariable, + session_id: u64, + ) -> impl Future> + Send; +} + +impl IsAllowed for Server { + async fn is_allowed<'x>( + &'x self, + throttle: &'x QueueRateLimiter, + envelope: &impl ResolveVariable, + session_id: u64, + ) -> Result<(), u64> { + if throttle.expr.is_empty() + || self + .eval_if(&throttle.expr, envelope, session_id) + .await + .unwrap_or(false) + { + let key = throttle.new_key(envelope, "outbound"); + + match self + .in_memory_store() + .is_rate_allowed(KV_RATE_LIMIT_SMTP, key.as_ref(), &throttle.rate, false) + .await + { + Ok(Some(next_refill)) => { + trc::event!( + Queue(trc::QueueEvent::RateLimitExceeded), + SpanId = session_id, + Id = throttle.id.to_string(), + Limit = vec![ + trc::Value::from(throttle.rate.count), + trc::Value::from(throttle.rate.period.into_inner()) + ], + ); + + return Err(now() + next_refill); + } + Err(err) => { + trc::error!(err.span_id(session_id).caused_by(trc::location!())); + } + _ => (), + } + } + + Ok(()) + } +} diff --git a/crates/smtp/src/reporting/analysis.rs b/crates/smtp/src/reporting/analysis.rs new file mode 100644 index 0000000..db41102 --- /dev/null +++ b/crates/smtp/src/reporting/analysis.rs @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashSet; +use common::{Server, psl}; +use mail_auth::{ + flate2::read::GzDecoder, + report::{Feedback, Report, tlsrpt::TlsReport}, + zip, +}; +use mail_parser::{Message, MessagePart, MimeHeaders, PartType}; +use registry::{ + schema::structs::{ArfExternalReport, DmarcExternalReport, TlsExternalReport}, + types::datetime::UTCDateTime, +}; +use std::{ + borrow::Cow, + io::{Cursor, Read}, +}; +use store::write::{BatchBuilder, now}; +use trc::IncomingReportEvent; +use types::id::Id; + +use crate::reporting::{inbound::LogReport, index::ExternalReportIndex}; + +enum Compression { + None, + Gzip, + Zip, +} + +enum Format { + Dmarc(D), + Tls(T), + Arf(A), +} + +pub(crate) struct ReportData<'x> { + compression: Compression, + format: Format<(), (), ()>, + data: &'x [u8], +} + +impl<'x> ReportData<'x> { + fn from_part(part: &'x MessagePart<'x>) -> Option { + match &part.body { + PartType::Text(report) => { + if part + .content_type() + .and_then(|ct| ct.subtype()) + .is_some_and(|t| t.eq_ignore_ascii_case("xml")) + || part + .attachment_name() + .and_then(|n| n.rsplit_once('.')) + .is_some_and(|(_, e)| e.eq_ignore_ascii_case("xml")) + { + Some(ReportData { + compression: Compression::None, + format: Format::Dmarc(()), + data: report.as_bytes(), + }) + } else if part.is_content_type("message", "feedback-report") { + Some(ReportData { + compression: Compression::None, + format: Format::Arf(()), + data: report.as_bytes(), + }) + } else { + None + } + } + PartType::Binary(report) | PartType::InlineBinary(report) => { + if part.is_content_type("message", "feedback-report") { + return Some(ReportData { + compression: Compression::None, + format: Format::Arf(()), + data: report.as_ref(), + }); + } + + let subtype = part + .content_type() + .and_then(|ct| ct.subtype()) + .unwrap_or(""); + let attachment_name = part.attachment_name(); + let ext = attachment_name + .and_then(|f| f.rsplit_once('.')) + .map_or("", |(_, e)| e); + let tls_parts = subtype.rsplit_once('+'); + let compression = match (tls_parts.map(|(_, c)| c).unwrap_or(subtype), ext) { + ("gzip", _) => Compression::Gzip, + ("zip", _) => Compression::Zip, + (_, "gz") => Compression::Gzip, + (_, "zip") => Compression::Zip, + _ => Compression::None, + }; + let format = match (tls_parts.map(|(c, _)| c).unwrap_or(subtype), ext) { + ("xml", _) => Format::Dmarc(()), + ("tlsrpt", _) | (_, "json") => Format::Tls(()), + _ => { + if attachment_name.is_some_and(|n| n.contains(".xml") || n.contains('!')) { + Format::Dmarc(()) + } else { + return None; + } + } + }; + + Some(ReportData { + compression, + format, + data: report.as_ref(), + }) + } + _ => None, + } + } + + fn extract(message: &'x Message<'x>) -> Vec { + message.parts.iter().filter_map(Self::from_part).collect() + } + + pub(crate) fn is_present(message: &Message<'_>) -> bool { + message + .parts + .iter() + .any(|part| ReportData::from_part(part).is_some()) + } +} + +pub trait AnalyzeReport: Sync + Send { + fn analyze_report(&self, message: Message<'static>, session_id: u64); +} + +impl AnalyzeReport for Server { + fn analyze_report(&self, message: Message<'static>, session_id: u64) { + let core = self.clone(); + tokio::spawn(async move { + let from: String = message + .from() + .and_then(|a| a.last()) + .and_then(|a| a.address()) + .unwrap_or_default() + .into(); + let to: Vec = message.to().map_or_else(Vec::new, |a| { + a.iter() + .filter_map(|a| a.address()) + .map(|a| a.into()) + .collect() + }); + let subject: String = message.subject().unwrap_or_default().into(); + let reports = ReportData::extract(&message); + let max_size = core.core.smtp.report.analysis.max_size; + + for report in reports { + let data = match report.compression { + Compression::None => Cow::Borrowed(report.data), + Compression::Gzip => { + match read_capped(GzDecoder::new(report.data), 0, max_size) { + Ok(buf) => Cow::Owned(buf), + Err(err) => { + trc::event!( + IncomingReport(IncomingReportEvent::DecompressError), + SpanId = session_id, + From = from.to_string(), + Reason = err.to_string(), + CausedBy = trc::location!() + ); + + continue; + } + } + } + Compression::Zip => { + let data = report.data.to_vec(); + let result = tokio::task::spawn_blocking( + move || -> Result, std::io::Error> { + let mut archive = zip::ZipArchive::new(Cursor::new(data)) + .map_err(std::io::Error::other)?; + if archive.is_empty() { + return Ok(Vec::new()); + } + let mut file = + archive.by_index(0).map_err(std::io::Error::other)?; + let size_hint = file.size(); + read_capped(&mut file, size_hint, max_size) + }, + ) + .await; + match result { + Ok(Ok(buf)) => Cow::Owned(buf), + Ok(Err(err)) => { + trc::event!( + IncomingReport(IncomingReportEvent::DecompressError), + SpanId = session_id, + From = from.to_string(), + Reason = err.to_string(), + CausedBy = trc::location!() + ); + continue; + } + Err(err) => { + trc::event!( + IncomingReport(IncomingReportEvent::DecompressError), + SpanId = session_id, + From = from.to_string(), + Reason = err.to_string(), + CausedBy = trc::location!() + ); + continue; + } + } + } + }; + + let report = match report.format { + Format::Dmarc(_) => match Report::parse_xml(&data) { + Ok(report) => { + // Log + report.log(); + Format::Dmarc(report) + } + Err(err) => { + trc::event!( + IncomingReport(IncomingReportEvent::DmarcParseFailed), + SpanId = session_id, + From = from.to_string(), + Reason = err, + CausedBy = trc::location!() + ); + + continue; + } + }, + Format::Tls(_) => match TlsReport::parse_json(&data) { + Ok(report) => { + // Log + report.log(); + Format::Tls(report) + } + Err(err) => { + trc::event!( + IncomingReport(IncomingReportEvent::TlsRpcParseFailed), + SpanId = session_id, + From = from.to_string(), + Reason = format!("{err:?}"), + CausedBy = trc::location!() + ); + + continue; + } + }, + Format::Arf(_) => match Feedback::parse_arf(&data) { + Some(report) => { + // Log + report.log(); + Format::Arf(report.into_owned()) + } + None => { + trc::event!( + IncomingReport(IncomingReportEvent::ArfParseFailed), + SpanId = session_id, + From = from.to_string(), + CausedBy = trc::location!() + ); + + continue; + } + }, + }; + + // Store report + if let Some(expires_in) = &core.core.smtp.report.analysis.store { + let expires = now() + expires_in.as_secs(); + let item_id = core.inner.data.queue_id_gen.generate(); + let mut batch = BatchBuilder::new(); + + match report { + Format::Dmarc(report) => { + let mut report = DmarcExternalReport { + from, + to: to.into(), + subject, + member_tenant_id: None, + expires_at: UTCDateTime::from_timestamp(expires as i64), + received_at: UTCDateTime::now(), + report: report.into(), + }; + report.member_tenant_id = tenant_ids( + &core, + report + .domains() + .filter_map(psl::domain_str) + .collect::>(), + ) + .await; + report.write_ops(&mut batch, item_id, true); + } + Format::Tls(report) => { + let mut report = TlsExternalReport { + from, + to: to.into(), + subject, + member_tenant_id: None, + expires_at: UTCDateTime::from_timestamp(expires as i64), + received_at: UTCDateTime::now(), + report: report.into(), + }; + report.member_tenant_id = tenant_ids( + &core, + report + .domains() + .filter_map(psl::domain_str) + .collect::>(), + ) + .await; + report.write_ops(&mut batch, item_id, true); + } + Format::Arf(report) => { + let mut report = ArfExternalReport { + from, + to: to.into(), + subject, + member_tenant_id: None, + expires_at: UTCDateTime::from_timestamp(expires as i64), + received_at: UTCDateTime::now(), + report: report.into(), + }; + report.member_tenant_id = tenant_ids( + &core, + report + .domains() + .filter_map(psl::domain_str) + .collect::>(), + ) + .await; + report.write_ops(&mut batch, item_id, true); + } + } + + if let Err(err) = core.core.storage.data.write(batch.build_all()).await + && !err.is_assertion_failure() + { + trc::error!( + err.span_id(session_id) + .caused_by(trc::location!()) + .details("Failed to write report") + ); + } + } + return; + } + }); + } +} + +async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Option { + let mut tenant_ids = Vec::with_capacity(domains.len()); + for domain in domains { + if let Some(tenant_id) = server + .domain(domain) + .await + .map_err(|err| { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to lookup domain") + ); + }) + .unwrap_or_default() + .and_then(|domain| domain.id_tenant) + .map(Id::from) + && !tenant_ids.contains(&tenant_id) + { + tenant_ids.push(tenant_id); + } + } + + if tenant_ids.len() == 1 { + tenant_ids.into_iter().next() + } else { + None + } +} + +fn read_capped( + reader: impl Read, + size_hint: u64, + max_size: usize, +) -> Result, std::io::Error> { + let max_size = max_size as u64; + if size_hint > max_size { + return Err(std::io::Error::other(format!( + "Report is larger than the {max_size} byte limit" + ))); + } + + let mut buf = Vec::with_capacity(size_hint.min(64 * 1024) as usize); + reader + .take(max_size.saturating_add(1)) + .read_to_end(&mut buf)?; + + if buf.len() as u64 > max_size { + return Err(std::io::Error::other(format!( + "Report is larger than the {max_size} byte limit" + ))); + } + + Ok(buf) +} diff --git a/crates/smtp/src/reporting/dkim.rs b/crates/smtp/src/reporting/dkim.rs new file mode 100644 index 0000000..c425c8c --- /dev/null +++ b/crates/smtp/src/reporting/dkim.rs @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{core::Session, reporting::send::MtaReportSend}; +use common::network::SessionStream; +use mail_auth::{ + AuthenticatedMessage, AuthenticationResults, DkimOutput, common::verify::VerifySignature, +}; +use registry::schema::structs::Rate; +use trc::OutgoingReportEvent; + +impl Session { + pub async fn send_dkim_report( + &self, + rcpt: &str, + message: &AuthenticatedMessage<'_>, + rate: &Rate, + rejected: bool, + output: &DkimOutput<'_>, + ) { + // Generate report + let signature = if let Some(signature) = output.signature() { + signature + } else { + return; + }; + + if self + .server + .is_local_report_domain(signature.domain(), self.data.session_id) + .await + { + return; + } + + // Throttle recipient + if !self.throttle_rcpt(rcpt, rate, "dkim").await { + trc::event!( + OutgoingReport(OutgoingReportEvent::DkimRateLimited), + SpanId = self.data.session_id, + To = rcpt.to_string(), + Limit = vec![ + trc::Value::from(rate.count), + trc::Value::from(rate.period.into_inner()) + ], + ); + + return; + } + + let config = &self.server.core.smtp.report.dkim; + let from_addr = self + .server + .eval_if(&config.address, self, self.data.session_id) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()); + let mut report = Vec::with_capacity(128); + self.new_auth_failure(output.result().into(), rejected) + .with_authentication_results( + AuthenticationResults::new(&self.hostname) + .with_dkim_result(output, message.from()) + .to_string(), + ) + .with_dkim_domain(signature.domain()) + .with_dkim_selector(signature.selector()) + .with_dkim_identity(signature.identity()) + .with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default()) + .write_rfc5322( + ( + self.server + .eval_if(&config.name, self, self.data.session_id) + .await + .unwrap_or_else(|| "Mail Delivery Subsystem".to_string()) + .as_str(), + from_addr.as_str(), + ), + rcpt, + &self + .server + .eval_if(&config.subject, self, self.data.session_id) + .await + .unwrap_or_else(|| "DKIM Report".to_string()), + &mut report, + ) + .ok(); + + trc::event!( + OutgoingReport(OutgoingReportEvent::DkimReport), + SpanId = self.data.session_id, + From = from_addr.to_string(), + To = rcpt.to_string(), + ); + + // Send report + self.server + .send_report( + &from_addr, + [rcpt].into_iter(), + report, + &config.sign, + true, + self.data.session_id, + ) + .await; + } +} diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs new file mode 100644 index 0000000..bee93b2 --- /dev/null +++ b/crates/smtp/src/reporting/dmarc.rs @@ -0,0 +1,695 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::AggregateTimestamp; +use crate::{ + core::Session, + queue::RecipientDomain, + reporting::{index::InternalReportIndex, send::MtaReportSend}, +}; +use common::{ + Server, + config::smtp::report::AggregateFrequency, + ipc::{DmarcEvent, ToHash}, + network::SessionStream, +}; +use compact_str::ToCompactString; +use mail_auth::{ + ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, + DmarcResult, SpfResult, + common::verify::VerifySignature, + dkim2::Dkim2Output, + dmarc::{self}, + report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope}, +}; +use registry::{ + schema::{ + enums::FailureReportingOption, + prelude::{ObjectType, Property}, + structs::{DmarcInternalReport, DmarcReport, DmarcReportRecord, Rate}, + }, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, map::Map}, +}; +use std::{borrow::Cow, future::Future}; +use store::{ + SerializeInfallible, U64_LEN, ValueKey, + registry::ObjectIdVersioned, + write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue, key::KeySerializer}, +}; +use trc::{AddContext, OutgoingReportEvent}; +use utils::DomainPart; + +impl Session { + #[allow(clippy::too_many_arguments)] + pub async fn send_dmarc_report( + &self, + message: &AuthenticatedMessage<'_>, + auth_results: &AuthenticationResults<'_>, + rejected: bool, + dmarc_output: DmarcOutput, + dkim_output: &[DkimOutput<'_>], + dkim2_output: Option<&Dkim2Output<'_>>, + arc_output: &Option>, + ) { + let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap(); + let config = &self.server.core.smtp.report.dmarc; + + if self + .server + .is_local_report_domain(dmarc_output.domain(), self.data.session_id) + .await + { + return; + } + + // Send failure report. RFC 9991 Section 2: report generators MUST NOT + // honor "ruf" for policy records published with "psd=y". + if !matches!(dmarc_record.psd, dmarc::Psd::Yes) + && let (Some(failure_rate), Some(report_options)) = ( + self.server + .eval_if::(&config.send, self, self.data.session_id) + .await, + dmarc_output.failure_report(), + ) + { + // Verify that any external reporting addresses are authorized + let rcpts = match self + .server + .core + .smtp + .resolvers + .dns + .verify_dmarc_report_address( + dmarc_output.domain(), + dmarc_record.ruf(), + Some(&self.server.inner.cache.dns_txt), + ) + .await + { + Some(rcpts) => { + if !rcpts.is_empty() { + let mut new_rcpts = Vec::with_capacity(rcpts.len()); + + for rcpt in rcpts { + if self.throttle_rcpt(rcpt.uri(), &failure_rate, "dmarc").await { + new_rcpts.push(rcpt.uri()); + } + } + + new_rcpts + } else { + if !dmarc_record.ruf().is_empty() { + trc::event!( + OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress), + SpanId = self.data.session_id, + Url = dmarc_record + .ruf() + .iter() + .map(|u| trc::Value::String(u.uri().to_compact_string())) + .collect::>(), + ); + } + vec![] + } + } + None => { + trc::event!( + OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError), + SpanId = self.data.session_id, + Url = dmarc_record + .ruf() + .iter() + .map(|u| trc::Value::String(u.uri().to_compact_string())) + .collect::>(), + ); + + vec![] + } + }; + + // Throttle recipient + if !rcpts.is_empty() { + let mut report = Vec::with_capacity(128); + let from_addr = self + .server + .eval_if(&config.address, self, self.data.session_id) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string()); + let mut auth_failure = self + .new_auth_failure(AuthFailureType::Dmarc, rejected) + .with_authentication_results(auth_results.to_string()) + .with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default()); + + let dkim_aligned = matches!(dmarc_output.dkim_result(), DmarcResult::Pass); + let spf_aligned = matches!(dmarc_output.spf_result(), DmarcResult::Pass); + + // Report the first failed signature + if let ( + dmarc::Report::Dkim + | dmarc::Report::DkimSpf + | dmarc::Report::All + | dmarc::Report::Any, + Some(signature), + ) = ( + &report_options, + if !dkim_aligned { + dkim_output + .iter() + .find_map(|o| { + let s = o.signature()?; + if !matches!(o.result(), DkimResult::Pass) { + Some(s) + } else { + None + } + }) + .or_else(|| dkim_output.iter().find_map(|o| o.signature())) + } else { + None + }, + ) { + auth_failure = auth_failure + .with_dkim_domain(signature.domain()) + .with_dkim_selector(signature.selector()) + .with_dkim_identity(signature.identity()); + } + + // Report SPF failure + if let ( + dmarc::Report::Spf + | dmarc::Report::DkimSpf + | dmarc::Report::All + | dmarc::Report::Any, + Some(output), + ) = ( + &report_options, + if !spf_aligned { + self.data + .spf_ehlo + .as_ref() + .and_then(|s| { + if s.result() != SpfResult::Pass { + s.into() + } else { + None + } + }) + .or_else(|| { + self.data.spf_mail_from.as_ref().and_then(|s| { + if s.result() != SpfResult::Pass { + s.into() + } else { + None + } + }) + }) + .or(self.data.spf_mail_from.as_ref()) + } else { + None + }, + ) { + auth_failure = + auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain())); + // TODO use DNS record + } + + auth_failure + .with_identity_alignment(match (dkim_aligned, spf_aligned) { + (false, false) => IdentityAlignment::DkimSpf, + (false, true) => IdentityAlignment::Dkim, + (true, false) => IdentityAlignment::Spf, + (true, true) => IdentityAlignment::None, + }) + .write_rfc5322( + ( + self.server + .eval_if(&config.name, self, self.data.session_id) + .await + .unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string()) + .as_str(), + from_addr.as_str(), + ), + &rcpts.join(", "), + &self + .server + .eval_if(&config.subject, self, self.data.session_id) + .await + .unwrap_or_else(|| "DMARC Report".to_compact_string()), + &mut report, + ) + .ok(); + + trc::event!( + OutgoingReport(OutgoingReportEvent::DmarcReport), + SpanId = self.data.session_id, + From = from_addr.to_string(), + To = rcpts + .iter() + .map(|a| trc::Value::String(a.to_compact_string())) + .collect::>(), + ); + + // Send report + self.server + .send_report( + &from_addr, + rcpts.into_iter(), + report, + &config.sign, + true, + self.data.session_id, + ) + .await; + } else { + trc::event!( + OutgoingReport(OutgoingReportEvent::DmarcRateLimited), + SpanId = self.data.session_id, + Limit = vec![ + trc::Value::from(failure_rate.count), + trc::Value::from(failure_rate.period.into_inner()) + ], + ); + } + } + + // Send aggregate reports + let interval = self + .server + .eval_if( + &self.server.core.smtp.report.dmarc_aggregate.send, + self, + self.data.session_id, + ) + .await + .unwrap_or(AggregateFrequency::Never); + + if matches!(interval, AggregateFrequency::Never) || dmarc_record.rua().is_empty() { + return; + } + + // Report the same identifier forms that were used for alignment + let message_from = message.from(); + let header_from = message_from.domain_part(); + let header_from = header_from + .to_ascii_domain() + .unwrap_or(Cow::Borrowed(header_from)); + let envelope_from = self + .data + .mail_from + .as_ref() + .map(|mf| mf.domain.as_str()) + .unwrap_or_else(|| self.data.helo_domain.as_str()); + let envelope_from = envelope_from + .to_ascii_domain() + .unwrap_or(Cow::Borrowed(envelope_from)); + + // Create DMARC report record + let mut report_record = Record::new() + .with_dmarc_output(&dmarc_output) + .with_dkim_output(dkim_output) + .with_source_ip(self.data.remote_ip) + .with_header_from(header_from.as_ref()) + .with_envelope_from(envelope_from.as_ref()); + if let Some(dkim2_output) = dkim2_output { + report_record = report_record.with_dkim2_output(dkim2_output); + } + if let Some(spf_ehlo) = &self.data.spf_ehlo { + report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo); + } + if let Some(spf_mail_from) = &self.data.spf_mail_from { + report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom); + } + if let Some(arc_output) = arc_output { + report_record = report_record.with_arc_output(arc_output); + } + + // Submit DMARC report event + self.server + .schedule_report(DmarcEvent { + domain: dmarc_output.into_domain(), + report_record, + dmarc_record, + interval, + span_id: self.data.session_id, + }) + .await; + } +} + +pub trait DmarcReporting: Sync + Send { + fn send_dmarc_aggregate_report( + &self, + report_id: u64, + ) -> impl Future> + Send; + fn schedule_dmarc(&self, event: Box) -> impl Future + Send; +} + +impl DmarcReporting for Server { + async fn send_dmarc_aggregate_report(&self, item_id: u64) -> trc::Result<()> { + let object_id = ObjectType::DmarcInternalReport.to_id(); + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + + let Some(report) = self + .store() + .get_value::(ValueKey::from(key.clone())) + .await + .caused_by(trc::location!())? + else { + return Ok(()); + }; + + // Delete report + let mut batch = BatchBuilder::new(); + batch.clear(key).clear(RegistryClass::PrimaryKey { + object_id: object_id.into(), + index_id: Property::Domain.to_id(), + key: KeySerializer::new(report.domain.len() + U64_LEN) + .write(&report.domain) + .write(report.policy_identifier) + .finalize(), + }); + self.store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + let span_id = self.inner.data.span_id_gen.generate(); + let event_from = report.report.date_range_begin.timestamp() as u64; + let event_to = report.report.date_range_end.timestamp() as u64; + + trc::event!( + OutgoingReport(OutgoingReportEvent::DmarcAggregateReport), + SpanId = span_id, + ReportId = event_from, + Domain = report.domain.clone(), + RangeFrom = trc::Value::Timestamp(event_from), + RangeTo = trc::Value::Timestamp(event_to), + ); + + // Verify external reporting addresses + let rua = match self + .core + .smtp + .resolvers + .dns + .verify_dmarc_report_address( + &report.domain, + report.rua.as_slice(), + Some(&self.inner.cache.dns_txt), + ) + .await + { + Some(rcpts) => { + if !rcpts.is_empty() { + rcpts + } else { + trc::event!( + OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress), + SpanId = span_id, + Url = report + .rua + .into_iter() + .map(|u| trc::Value::String(u.into())) + .collect::>(), + ); + + return Ok(()); + } + } + None => { + trc::event!( + OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError), + SpanId = span_id, + Url = report + .rua + .into_iter() + .map(|u| trc::Value::String(u.into())) + .collect::>(), + ); + + return Ok(()); + } + }; + + // Serialize report + let config = &self.core.smtp.report.dmarc_aggregate; + let from_addr = self + .eval_if( + &config.address, + &RecipientDomain::new(report.domain.as_str()), + span_id, + ) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string()); + let mut message = Vec::with_capacity(2048); + let _ = mail_auth::report::Report::from(report.report).write_rfc5322( + &self + .eval_if( + &self.core.smtp.report.submitter, + &RecipientDomain::new(report.domain.as_str()), + span_id, + ) + .await + .unwrap_or_else(|| "localhost".to_compact_string()), + ( + self.eval_if( + &config.name, + &RecipientDomain::new(report.domain.as_str()), + span_id, + ) + .await + .unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string()) + .as_str(), + from_addr.as_str(), + ), + rua.iter().map(|a| a.as_str()), + &mut message, + ); + + // Send report + self.send_report( + &from_addr, + rua.iter(), + message, + &config.sign, + false, + span_id, + ) + .await; + + Ok(()) + } + + async fn schedule_dmarc(&self, event: Box) { + let object_id = ObjectType::DmarcInternalReport.to_id(); + let policy_hash = event.dmarc_record.to_hash(); + let pk = ValueClass::Registry(RegistryClass::PrimaryKey { + object_id: object_id.into(), + index_id: Property::Domain.to_id(), + key: KeySerializer::new(event.domain.len() + U64_LEN) + .write(&event.domain) + .write(policy_hash) + .finalize(), + }); + let mut rety_count = 0; + + loop { + // Find the report by domain name + let mut batch = BatchBuilder::new(); + let report = match self + .store() + .get_value::(ValueKey::from(pk.clone())) + .await + { + Ok(Some(object_id_v)) => { + match self + .store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::Item { + object_id, + item_id: object_id_v.object_id.id().id(), + }, + ))) + .await + { + Ok(Some(report)) => Some((object_id_v, report)), + Ok(None) => { + trc::event!( + OutgoingReport(OutgoingReportEvent::NotFound), + Id = object_id_v.object_id.id().id(), + CausedBy = trc::location!(), + Details = "Failed to find DMARC report for domain" + ); + + return; + } + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to query registry for DMARC report") + ); + return; + } + } + } + Ok(None) => None, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to query registry for DMARC report") + ); + return; + } + }; + + // Create report if missing + let config = &self.core.smtp.report.dmarc_aggregate; + let (item_id, mut report) = if let Some((mut object_id_v, report)) = report { + batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version)); + object_id_v.version += 1; + batch.set(pk.clone(), object_id_v.serialize()); + + (object_id_v.object_id.id().id(), report) + } else { + let item_id = self.inner.data.queue_id_gen.generate(); + let date_range_begin = UTCDateTime::now(); + let date_range_end = UTCDateTime::from_timestamp( + date_range_begin.timestamp() + event.interval.as_secs() as i64, + ); + let policy = + PolicyPublished::from_record(event.domain.clone(), &event.dmarc_record); + + let report = DmarcInternalReport { + created_at: date_range_begin, + deliver_at: date_range_end, + domain: event.domain.clone(), + report: DmarcReport { + report_id: format!("{}_{policy_hash}", date_range_begin.timestamp()), + date_range_begin, + date_range_end, + email: self + .eval_if( + &config.address, + &RecipientDomain::new(event.domain.as_str()), + event.span_id, + ) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()), + extra_contact_info: self + .eval_if::( + &config.contact_info, + &RecipientDomain::new(event.domain.as_str()), + event.span_id, + ) + .await, + org_name: self + .eval_if::( + &config.org_name, + &RecipientDomain::new(event.domain.as_str()), + event.span_id, + ) + .await + .unwrap_or_default(), + policy_adkim: policy.adkim.into(), + policy_aspf: policy.aspf.into(), + policy_disposition: policy.p.into(), + policy_domain: policy.domain, + policy_failure_reporting_options: match event.dmarc_record.fo { + dmarc::Report::All => vec![FailureReportingOption::All], + dmarc::Report::Any => vec![FailureReportingOption::Any], + dmarc::Report::Dkim => vec![FailureReportingOption::DkimFailure], + dmarc::Report::Spf => vec![FailureReportingOption::SpfFailure], + dmarc::Report::DkimSpf => vec![ + FailureReportingOption::DkimFailure, + FailureReportingOption::SpfFailure, + ], + } + .into(), + policy_subdomain_disposition: policy.sp.into(), + policy_np: policy.np.into(), + policy_discovery_method: policy.discovery_method.into(), + policy_testing_mode: policy.testing, + policy_version: None, + version: 1.0.into(), + ..Default::default() + }, + policy_identifier: policy_hash, + rua: Map::new( + event + .dmarc_record + .rua() + .iter() + .map(|u| u.uri.clone()) + .collect(), + ), + }; + + report.write_ops(&mut batch, item_id, true); + + (item_id, report) + }; + + // Add record + let mut record = DmarcReportRecord::from(event.report_record.clone()); + if let Some(idx) = report + .report + .records + .0 + .inner + .iter() + .position(|d| d.value.eq_except_count(&record)) + { + report.report.records.0.inner[idx].value.count += 1; + } else { + record.count = 1; + report.report.records.push(record); + } + + // Write entry + let report_bytes = report.to_pickled_vec(); + let max_report_size = self + .eval_if( + &config.max_size, + &RecipientDomain::new(&event.domain), + event.span_id, + ) + .await + .unwrap_or(5 * 1024 * 1024); + if max_report_size != 0 && report_bytes.len() > max_report_size { + trc::event!( + OutgoingReport(OutgoingReportEvent::MaxSizeExceeded), + SpanId = event.span_id, + Domain = event.domain.clone(), + Details = report_bytes.len(), + Limit = max_report_size, + ); + return; + } + + batch.set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + report_bytes, + ); + + match self.core.storage.data.write(batch.build_all()).await { + Ok(_) => { + break; + } + Err(err) => { + if err.is_assertion_failure() && rety_count < 3 { + rety_count += 1; + continue; + } + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to write DMARC report") + ); + break; + } + } + } + } +} diff --git a/crates/smtp/src/reporting/inbound.rs b/crates/smtp/src/reporting/inbound.rs new file mode 100644 index 0000000..bba4f0e --- /dev/null +++ b/crates/smtp/src/reporting/inbound.rs @@ -0,0 +1,208 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::core::Session; +use ahash::AHashMap; +use common::USER_AGENT; +use mail_auth::report::{ + ActionDisposition, AuthFailureType, DeliveryResult, DmarcResult, Feedback, FeedbackType, + Report, tlsrpt::TlsReport, +}; +use std::{collections::hash_map::Entry, time::SystemTime}; +use store::write::now; +use tokio::io::{AsyncRead, AsyncWrite}; +use trc::IncomingReportEvent; + +impl Session { + pub fn new_auth_failure(&self, ft: AuthFailureType, rejected: bool) -> Feedback<'_> { + Feedback::new(FeedbackType::AuthFailure) + .with_auth_failure(ft) + .with_arrival_date( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64, + ) + .with_source_ip(self.data.remote_ip) + .with_reporting_mta(&self.hostname) + .with_user_agent(USER_AGENT) + .with_delivery_result(if rejected { + DeliveryResult::Reject + } else { + DeliveryResult::Unspecified + }) + } + + pub fn is_report(&self) -> bool { + let analysis = &self.server.core.smtp.report.analysis; + + self.data + .rcpt_to + .iter() + .any(|addr| analysis.is_report_address(addr.report_address())) + } +} + +pub(crate) trait LogReport { + fn log(&self); +} + +impl LogReport for Report { + fn log(&self) { + let mut dmarc_pass = 0; + let mut dmarc_quarantine = 0; + let mut dmarc_reject = 0; + let mut dmarc_none = 0; + let mut dkim_pass = 0; + let mut dkim_fail = 0; + let mut dkim_none = 0; + let mut spf_pass = 0; + let mut spf_fail = 0; + let mut spf_none = 0; + + for record in self.records() { + let count = std::cmp::min(record.count(), 1); + + match record.action_disposition() { + ActionDisposition::Pass => { + dmarc_pass += count; + } + ActionDisposition::Quarantine => { + dmarc_quarantine += count; + } + ActionDisposition::Reject => { + dmarc_reject += count; + } + ActionDisposition::None | ActionDisposition::Unspecified => { + dmarc_none += count; + } + } + match record.dmarc_dkim_result() { + DmarcResult::Pass => { + dkim_pass += count; + } + DmarcResult::Fail => { + dkim_fail += count; + } + DmarcResult::Unspecified => { + dkim_none += count; + } + } + match record.dmarc_spf_result() { + DmarcResult::Pass => { + spf_pass += count; + } + DmarcResult::Fail => { + spf_fail += count; + } + DmarcResult::Unspecified => { + spf_none += count; + } + } + } + + trc::event!( + IncomingReport( + if (dmarc_reject + dmarc_quarantine + dkim_fail + spf_fail) > 0 { + IncomingReportEvent::DmarcReportWithWarnings + } else { + IncomingReportEvent::DmarcReport + } + ), + RangeFrom = trc::Value::Timestamp(self.date_range_begin()), + RangeTo = trc::Value::Timestamp(self.date_range_end()), + Domain = self.domain().to_string(), + From = self.email().to_string(), + Id = self.report_id().to_string(), + DmarcPass = dmarc_pass, + DmarcQuarantine = dmarc_quarantine, + DmarcReject = dmarc_reject, + DmarcNone = dmarc_none, + DkimPass = dkim_pass, + DkimFail = dkim_fail, + DkimNone = dkim_none, + SpfPass = spf_pass, + SpfFail = spf_fail, + SpfNone = spf_none, + ); + } +} + +impl LogReport for TlsReport { + fn log(&self) { + for policy in self.policies.iter().take(5) { + let mut details = AHashMap::with_capacity(policy.failure_details.len()); + for failure in &policy.failure_details { + let num_failures = std::cmp::min(1, failure.failed_session_count); + match details.entry(failure.result_type) { + Entry::Occupied(mut e) => { + *e.get_mut() += num_failures; + } + Entry::Vacant(e) => { + e.insert(num_failures); + } + } + } + + trc::event!( + IncomingReport(if policy.summary.total_failure > 0 { + IncomingReportEvent::TlsReportWithWarnings + } else { + IncomingReportEvent::TlsReport + }), + RangeFrom = + trc::Value::Timestamp(self.date_range.start_datetime.to_timestamp() as u64), + RangeTo = trc::Value::Timestamp(self.date_range.end_datetime.to_timestamp() as u64), + Domain = policy.policy.policy_domain.clone(), + From = self.contact_info.as_deref().unwrap_or_default().to_string(), + Id = self.report_id.clone(), + Policy = format!("{:?}", policy.policy.policy_type), + TotalSuccesses = policy.summary.total_success, + TotalFailures = policy.summary.total_failure, + Details = format!("{details:?}"), + ); + } + } +} + +impl LogReport for Feedback<'_> { + fn log(&self) { + trc::event!( + IncomingReport(match self.feedback_type() { + mail_auth::report::FeedbackType::Abuse => IncomingReportEvent::AbuseReport, + mail_auth::report::FeedbackType::AuthFailure => + IncomingReportEvent::AuthFailureReport, + mail_auth::report::FeedbackType::Fraud => IncomingReportEvent::FraudReport, + mail_auth::report::FeedbackType::NotSpam => IncomingReportEvent::NotSpamReport, + mail_auth::report::FeedbackType::Other => IncomingReportEvent::OtherReport, + mail_auth::report::FeedbackType::Virus => IncomingReportEvent::VirusReport, + }), + RangeFrom = trc::Value::Timestamp( + self.arrival_date() + .map(|d| d as u64) + .unwrap_or_else(|| { now() }) + ), + Domain = self + .reported_domain() + .iter() + .map(|d| trc::Value::String(d.as_ref().into())) + .collect::>(), + Hostname = self.reporting_mta().map(|d| trc::Value::String(d.into())), + Url = self + .reported_uri() + .iter() + .map(|d| trc::Value::String(d.as_ref().into())) + .collect::>(), + RemoteIp = self.source_ip(), + Total = self.incidents(), + Result = format!("{:?}", self.delivery_result()), + Details = self + .authentication_results() + .iter() + .map(|d| trc::Value::String(d.as_ref().into())) + .collect::>(), + ); + } +} diff --git a/crates/smtp/src/reporting/index.rs b/crates/smtp/src/reporting/index.rs new file mode 100644 index 0000000..83cc993 --- /dev/null +++ b/crates/smtp/src/reporting/index.rs @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use registry::{ + schema::{ + enums::DmarcActionDisposition, + prelude::{ObjectType, Property}, + structs::{ + ArfExternalReport, DmarcExternalReport, DmarcInternalReport, Task, TaskDmarcReport, + TaskStatus, TaskTlsReport, TlsExternalReport, TlsInternalReport, + }, + }, + types::{ + EnumImpl, ObjectImpl, + datetime::UTCDateTime, + id::ObjectId, + index::{IndexBuilder, IndexValue}, + }, +}; +use store::{ + SerializeInfallible, U64_LEN, + registry::ObjectIdVersioned, + write::{ + BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, assert::AssertValue, + key::KeySerializer, + }, + xxhash_rust::xxh3::Xxh3, +}; +use types::id::Id; + +pub trait InternalReportIndex: ObjectImpl { + fn deliver_at(&self) -> UTCDateTime; + + fn set_deliver_at(&mut self, at: UTCDateTime); + + fn task(&self, item_id: u64) -> Task; + + fn primary_key(&self) -> ValueClass; + + fn reschedule_ops( + &mut self, + batch: &mut BatchBuilder, + item_id: u64, + revision: u64, + at: UTCDateTime, + ) { + let current_deliver_at = self.deliver_at(); + + if current_deliver_at != at { + let object = Self::OBJECT; + let object_id = object.to_id(); + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + + self.set_deliver_at(at); + + batch + .assert_value(key.clone(), AssertValue::Hash(revision)) + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: item_id, + due: current_deliver_at.timestamp() as u64, + })) + .set( + ValueClass::TaskQueue(TaskQueueClass::Due { + id: item_id, + due: at.timestamp() as u64, + }), + object_id.serialize(), + ) + .set(key, self.to_pickled_vec()); + } + } + + fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) { + let object = Self::OBJECT; + let object_id = object.to_id(); + let pk = self.primary_key(); + + if is_set { + batch + .assert_value(pk.clone(), ()) + .set( + pk, + ObjectIdVersioned { + object_id: ObjectId::new(object, item_id.into()), + version: 0, + } + .serialize(), + ) + .schedule_task_with_id(item_id, self.task(item_id)); + } else { + batch + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id, + })) + .clear(pk) + .clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: item_id })) + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: item_id, + due: self.deliver_at().timestamp() as u64, + })); + } + } +} + +pub trait ExternalReportIndex: ObjectImpl { + fn text(&self) -> impl Iterator; + + fn tenant_id(&self) -> Option; + + fn expires_at(&self) -> u64; + + fn domains(&self) -> impl Iterator; + + fn success_fail_count(&self) -> (u64, u64); + + fn unique_key(&self) -> Option<[u8; 16]>; + + fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) { + let object_id = Self::OBJECT.to_id(); + let mut index_builder = IndexBuilder::default(); + for text in self.text() { + index_builder.text(Property::Text, text); + } + + if let Some(tenant_id) = self.tenant_id() { + index_builder.search(Property::MemberTenantId, tenant_id.id()); + } + + let (success_count, fail_count) = self.success_fail_count(); + index_builder.search(Property::TotalSuccessfulSessions, success_count); + index_builder.search(Property::TotalFailedSessions, fail_count); + + index_builder.search(Property::ExpiresAt, self.expires_at()); + + if let Some(unique_key) = self.unique_key() { + index_builder.unique(Property::ReportId, IndexValue::Bytes(unique_key.to_vec())); + } + + batch.registry_index(object_id, item_id, index_builder.keys.iter(), is_set); + + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + if is_set { + batch.set(key, self.to_pickled_vec()); + } else { + batch.clear(key); + } + } +} + +impl InternalReportIndex for DmarcInternalReport { + fn deliver_at(&self) -> UTCDateTime { + self.deliver_at + } + + fn set_deliver_at(&mut self, at: UTCDateTime) { + self.deliver_at = at; + } + + fn task(&self, item_id: u64) -> Task { + Task::DmarcReport(TaskDmarcReport { + report_id: item_id.into(), + status: TaskStatus::at(self.deliver_at.timestamp()), + }) + } + + fn primary_key(&self) -> ValueClass { + ValueClass::Registry(RegistryClass::PrimaryKey { + object_id: ObjectType::DmarcInternalReport.to_id().into(), + index_id: Property::Domain.to_id(), + key: KeySerializer::new(self.domain.len() + U64_LEN) + .write(self.domain.as_str()) + .write(self.policy_identifier) + .finalize(), + }) + } +} + +impl InternalReportIndex for TlsInternalReport { + fn deliver_at(&self) -> UTCDateTime { + self.deliver_at + } + + fn set_deliver_at(&mut self, at: UTCDateTime) { + self.deliver_at = at; + } + + fn task(&self, item_id: u64) -> Task { + Task::TlsReport(TaskTlsReport { + report_id: item_id.into(), + status: TaskStatus::at(self.deliver_at.timestamp()), + }) + } + + fn primary_key(&self) -> ValueClass { + ValueClass::Registry(RegistryClass::PrimaryKey { + object_id: ObjectType::TlsInternalReport.to_id().into(), + index_id: Property::Domain.to_id(), + key: self.domain.as_bytes().to_vec(), + }) + } +} + +impl ExternalReportIndex for ArfExternalReport { + fn domains(&self) -> impl Iterator { + let report = &self.report; + + report + .reported_domains + .iter() + .filter_map(|s| non_empty(s)) + .chain( + [report.dkim_domain.as_deref()] + .into_iter() + .flatten() + .filter_map(non_empty), + ) + } + + fn text(&self) -> impl Iterator { + let report = &self.report; + + report + .reported_domains + .iter() + .filter_map(|s| non_empty(s)) + .chain( + [ + report.dkim_domain.as_deref(), + report.reporting_mta.as_deref(), + report.original_mail_from.as_deref(), + report.original_rcpt_to.as_deref(), + ] + .into_iter() + .flatten() + .filter_map(non_empty), + ) + .chain(non_empty(&self.from)) + } + + fn tenant_id(&self) -> Option { + self.member_tenant_id + } + + fn expires_at(&self) -> u64 { + self.expires_at.timestamp() as u64 + } + + fn success_fail_count(&self) -> (u64, u64) { + (self.report.incidents, 0) + } + + fn unique_key(&self) -> Option<[u8; 16]> { + None + } +} + +impl ExternalReportIndex for DmarcExternalReport { + fn domains(&self) -> impl Iterator { + let report = &self.report; + + non_empty(&report.policy_domain) + .into_iter() + .filter_map(non_empty) + } + + fn text(&self) -> impl Iterator { + let report = &self.report; + + non_empty(&report.email) + .into_iter() + .filter_map(non_empty) + .chain(non_empty(&report.policy_domain)) + .chain(report.records.iter().flat_map(|r| { + r.envelope_to + .as_deref() + .into_iter() + .filter_map(non_empty) + .chain(non_empty(&r.envelope_from)) + .chain(non_empty(&r.header_from)) + .chain(r.dkim_results.iter().filter_map(|d| non_empty(&d.domain))) + .chain(r.spf_results.iter().filter_map(|s| non_empty(&s.domain))) + })) + .chain(non_empty(&self.from)) + } + + fn tenant_id(&self) -> Option { + self.member_tenant_id + } + + fn expires_at(&self) -> u64 { + self.expires_at.timestamp() as u64 + } + + fn success_fail_count(&self) -> (u64, u64) { + let mut success_count = 0; + let mut fail_count = 0; + + for record in self.report.records.iter() { + if record.evaluated_disposition == DmarcActionDisposition::Pass { + success_count += std::cmp::min(record.count, 1); + } else { + fail_count += std::cmp::min(record.count, 1); + } + } + + (success_count, fail_count) + } + + fn unique_key(&self) -> Option<[u8; 16]> { + let report = &self.report; + + Some(report_key( + [ + report.org_name.as_str(), + report.policy_domain.as_str(), + report.report_id.as_str(), + ], + report.date_range_begin, + report.date_range_end, + )) + } +} + +impl ExternalReportIndex for TlsExternalReport { + fn domains(&self) -> impl Iterator { + let report = &self.report; + + report + .policies + .iter() + .flat_map(|p| non_empty(&p.policy_domain).into_iter()) + } + + fn text(&self) -> impl Iterator { + let report = &self.report; + + report + .policies + .iter() + .flat_map(|p| { + non_empty(&p.policy_domain) + .into_iter() + .chain(p.mx_hosts.iter().filter_map(|s| non_empty(s))) + .chain(p.failure_details.iter().flat_map(|fd| { + non_empty_opt(&fd.receiving_mx_hostname) + .into_iter() + .chain(non_empty_opt(&fd.receiving_mx_helo)) + })) + }) + .chain(non_empty(&self.from)) + } + + fn tenant_id(&self) -> Option { + self.member_tenant_id + } + + fn expires_at(&self) -> u64 { + self.expires_at.timestamp() as u64 + } + + fn success_fail_count(&self) -> (u64, u64) { + let mut success_count = 0; + let mut fail_count = 0; + + for policy in self.report.policies.iter() { + success_count += std::cmp::min(policy.total_successful_sessions, 1); + fail_count += std::cmp::min(policy.total_failed_sessions, 1); + } + + (success_count, fail_count) + } + + fn unique_key(&self) -> Option<[u8; 16]> { + let report = &self.report; + + Some(report_key( + [ + report.organization_name.as_deref().unwrap_or_default(), + report.report_id.as_str(), + ], + report.date_range_start, + report.date_range_end, + )) + } +} + +fn report_key(fields: [&str; N], from: UTCDateTime, to: UTCDateTime) -> [u8; 16] { + let mut hasher = Xxh3::new(); + + for field in fields { + hasher.update(field.as_bytes()); + hasher.update(&[0u8]); + } + hasher.update(&(from.timestamp() as u64).to_be_bytes()); + hasher.update(&(to.timestamp() as u64).to_be_bytes()); + + hasher.digest128().to_be_bytes() +} + +#[inline(always)] +fn non_empty(s: &str) -> Option<&str> { + if s.is_empty() { None } else { Some(s) } +} + +#[inline(always)] +fn non_empty_opt(s: &Option) -> Option<&str> { + s.as_deref().filter(|s| !s.is_empty()) +} diff --git a/crates/smtp/src/reporting/mod.rs b/crates/smtp/src/reporting/mod.rs new file mode 100644 index 0000000..add57eb --- /dev/null +++ b/crates/smtp/src/reporting/mod.rs @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::report::AggregateFrequency; +use mail_parser::DateTime; +use std::time::SystemTime; + +pub mod analysis; +pub mod dkim; +pub mod dmarc; +pub mod inbound; +pub mod index; +pub mod scheduler; +pub mod send; +pub mod spf; +pub mod tls; + +pub trait AggregateTimestamp { + fn to_timestamp(&self) -> u64; + fn to_timestamp_(&self, dt: DateTime) -> u64; + fn as_secs(&self) -> u64; + fn due(&self) -> u64; +} + +impl AggregateTimestamp for AggregateFrequency { + fn to_timestamp(&self) -> u64 { + self.to_timestamp_(DateTime::from_timestamp( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64, + )) + } + + fn to_timestamp_(&self, mut dt: DateTime) -> u64 { + (match self { + AggregateFrequency::Hourly => { + dt.minute = 0; + dt.second = 0; + dt.to_timestamp() + } + AggregateFrequency::Daily => { + dt.hour = 0; + dt.minute = 0; + dt.second = 0; + dt.to_timestamp() + } + AggregateFrequency::Weekly => { + let dow = dt.day_of_week(); + dt.hour = 0; + dt.minute = 0; + dt.second = 0; + dt.to_timestamp() - (86400 * dow as i64) + } + AggregateFrequency::Never => dt.to_timestamp(), + }) as u64 + } + + fn as_secs(&self) -> u64 { + match self { + AggregateFrequency::Hourly => 3600, + AggregateFrequency::Daily => 86400, + AggregateFrequency::Weekly => 7 * 86400, + AggregateFrequency::Never => 0, + } + } + + fn due(&self) -> u64 { + self.to_timestamp() + self.as_secs() + } +} diff --git a/crates/smtp/src/reporting/scheduler.rs b/crates/smtp/src/reporting/scheduler.rs new file mode 100644 index 0000000..b28b7c2 --- /dev/null +++ b/crates/smtp/src/reporting/scheduler.rs @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{dmarc::DmarcReporting, tls::TlsReporting}; +use common::{BuildServer, Inner, ipc::ReportingEvent}; +use std::sync::Arc; +use tokio::sync::mpsc; + +pub trait SpawnReport { + fn spawn(self, core: Arc); +} + +impl SpawnReport for mpsc::Receiver { + fn spawn(mut self, inner: Arc) { + tokio::spawn(async move { + while let Some(event) = self.recv().await { + let server = inner.build_server(); + match event { + ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await, + ReportingEvent::Tls(event) => server.schedule_tls(event).await, + ReportingEvent::Stop => break, + } + } + }); + } +} diff --git a/crates/smtp/src/reporting/send.rs b/crates/smtp/src/reporting/send.rs new file mode 100644 index 0000000..ad2de84 --- /dev/null +++ b/crates/smtp/src/reporting/send.rs @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + inbound::dkim::DkimSign, + queue::{ + MessageSource, + spool::{QueueParams, SmtpSpool}, + }, +}; +use common::{Server, expr::if_block::IfBlock, ipc::ReportingEvent}; + +pub trait MtaReportSend: Sync + Send { + fn is_local_report_domain( + &self, + domain: &str, + session_id: u64, + ) -> impl Future + Send; + + fn send_report( + &self, + from_addr: &str, + rcpts: impl Iterator + Sync + Send> + Sync + Send, + report: Vec, + sign_config: &IfBlock, + deliver_now: bool, + parent_session_id: u64, + ) -> impl Future + Send; + + fn send_autogenerated( + &self, + from_addr: impl AsRef + Sync + Send, + rcpts: impl Iterator + Sync + Send> + Sync + Send, + raw_message: Vec, + sign_config: Option<&IfBlock>, + parent_session_id: u64, + ) -> impl Future + Send; + + fn schedule_report( + &self, + report: impl Into + Sync + Send, + ) -> impl Future + Send; +} + +impl MtaReportSend for Server { + async fn is_local_report_domain(&self, domain: &str, session_id: u64) -> bool { + match self.domain(domain).await { + Ok(domain) => domain.is_some(), + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .span_id(session_id) + .details("Failed to lookup local domain") + ); + + false + } + } + } + + async fn send_report( + &self, + from_addr: &str, + rcpts: impl Iterator + Sync + Send> + Sync + Send, + report: Vec, + sign_config: &IfBlock, + deliver_now: bool, + parent_session_id: u64, + ) { + // Build message + let mut message = self.new_message(from_addr, MessageSource::Report, parent_session_id); + for rcpt_ in rcpts { + message.add_expanded_recipient(rcpt_.as_ref(), self).await; + } + + // Schedule delivery at a random time between now and the next 3 hours + if !deliver_now { + #[cfg(not(feature = "test_mode"))] + { + use common::config::smtp::queue::QueueExpiry; + use rand::RngExt; + + let delivery_time = rand::rng().random_range(0u64..10800u64); + for rcpt in &mut message.message.recipients { + rcpt.retry.due += delivery_time; + rcpt.notify.due += delivery_time; + if let QueueExpiry::Ttl(expires) = &mut rcpt.expires { + *expires += delivery_time; + } + } + } + } + + // Queue message + let dkim_signers = self + .eval_signers(sign_config, &message.message, parent_session_id) + .await; + message + .queue( + QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers), + ) + .await; + } + + async fn send_autogenerated( + &self, + from_addr: impl AsRef + Sync + Send, + rcpts: impl Iterator + Sync + Send> + Sync + Send, + raw_message: Vec, + sign_config: Option<&IfBlock>, + parent_session_id: u64, + ) { + // Build message + let mut message = self.new_message( + from_addr.as_ref(), + MessageSource::Autogenerated, + parent_session_id, + ); + for rcpt in rcpts { + message.add_expanded_recipient(rcpt, self).await; + } + + // Queue message + let dkim_signers = if let Some(sign_config) = sign_config { + self.eval_signers(sign_config, &message.message, parent_session_id) + .await + } else { + None + }; + message + .queue( + QueueParams::new(&raw_message, parent_session_id, self) + .with_dkim_signers(dkim_signers), + ) + .await; + } + + async fn schedule_report(&self, report: impl Into + Sync + Send) { + if self.inner.ipc.report_tx.send(report.into()).await.is_err() { + trc::event!( + Server(trc::ServerEvent::ThreadError), + CausedBy = trc::location!(), + Details = "Failed to send event to ReportScheduler" + ); + } + } +} diff --git a/crates/smtp/src/reporting/spf.rs b/crates/smtp/src/reporting/spf.rs new file mode 100644 index 0000000..234a5c8 --- /dev/null +++ b/crates/smtp/src/reporting/spf.rs @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{core::Session, reporting::send::MtaReportSend}; +use common::network::SessionStream; +use mail_auth::{AuthenticationResults, SpfOutput, report::AuthFailureType}; +use registry::schema::structs::Rate; +use trc::OutgoingReportEvent; + +impl Session { + pub async fn send_spf_report( + &self, + rcpt: &str, + rate: &Rate, + rejected: bool, + output: &SpfOutput, + ) { + // Throttle recipient + if !self.throttle_rcpt(rcpt, rate, "spf").await { + trc::event!( + OutgoingReport(OutgoingReportEvent::SpfRateLimited), + SpanId = self.data.session_id, + To = rcpt.to_string(), + Limit = vec![ + trc::Value::from(rate.count), + trc::Value::from(rate.period.into_inner()) + ], + ); + + return; + } + + // Generate report + let config = &self.server.core.smtp.report.spf; + let from_addr = self + .server + .eval_if(&config.address, self, self.data.session_id) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()); + let mut report = Vec::with_capacity(128); + self.new_auth_failure(AuthFailureType::Spf, rejected) + .with_authentication_results( + if let Some(mail_from) = &self.data.mail_from { + AuthenticationResults::new(&self.hostname).with_spf_mailfrom_result( + output, + self.data.remote_ip, + &mail_from.address, + &self.data.helo_domain, + ) + } else { + AuthenticationResults::new(&self.hostname).with_spf_ehlo_result( + output, + self.data.remote_ip, + &self.data.helo_domain, + ) + } + .to_string(), + ) + .with_spf_dns(format!("txt : {} : v=SPF1", output.domain())) // TODO use DNS record + .write_rfc5322( + ( + self.server + .eval_if(&config.name, self, self.data.session_id) + .await + .unwrap_or_else(|| "Mailer Daemon".to_string()) + .as_str(), + from_addr.as_str(), + ), + rcpt, + &self + .server + .eval_if(&config.subject, self, self.data.session_id) + .await + .unwrap_or_else(|| "SPF Report".to_string()), + &mut report, + ) + .ok(); + + trc::event!( + OutgoingReport(OutgoingReportEvent::SpfReport), + SpanId = self.data.session_id, + To = rcpt.to_string(), + From = from_addr.to_string(), + ); + + // Send report + self.server + .send_report( + &from_addr, + [rcpt].into_iter(), + report, + &config.sign, + true, + self.data.session_id, + ) + .await; + } +} diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs new file mode 100644 index 0000000..3842407 --- /dev/null +++ b/crates/smtp/src/reporting/tls.rs @@ -0,0 +1,493 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::AggregateTimestamp; +use crate::{ + queue::RecipientDomain, + reporting::{index::InternalReportIndex, send::MtaReportSend}, +}; +use common::{ + Server, USER_AGENT, + config::smtp::{ + report::AggregateFrequency, + resolver::{Mode, MxPattern, TlsaMatching}, + }, + ipc::{TlsEvent, ToHash}, +}; +use mail_auth::{ + flate2::{Compression, write::GzEncoder}, + mta_sts::{ReportUri, TlsRpt}, + report::tlsrpt::{FailureDetails, PolicyDetails}, +}; +use registry::{ + schema::{ + enums::TlsPolicyType, + prelude::{ObjectType, Property}, + structs::{TlsFailureDetails, TlsInternalReport, TlsReport, TlsReportPolicy}, + }, + types::{EnumImpl, ObjectImpl, datetime::UTCDateTime}, +}; +use reqwest::header::CONTENT_TYPE; +use std::fmt::Write; +use std::{future::Future, sync::Arc, time::Duration}; +use store::{ + SerializeInfallible, ValueKey, + registry::ObjectIdVersioned, + write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue}, +}; +use trc::{AddContext, OutgoingReportEvent}; + +#[derive(Debug, Clone)] +pub struct TlsRptOptions { + pub record: Arc, + pub interval: AggregateFrequency, +} + +#[derive(Debug, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Serialize)] +pub struct TlsFormat { + pub rua: Vec, + pub policy: PolicyDetails, + pub records: Vec>, +} + +#[cfg(feature = "test_mode")] +pub static TLS_HTTP_REPORT: parking_lot::Mutex> = parking_lot::Mutex::new(Vec::new()); + +pub trait TlsReporting: Sync + Send { + fn send_tls_aggregate_report( + &self, + report_id: u64, + ) -> impl Future> + Send; + + fn schedule_tls(&self, event: Box) -> impl Future + Send; +} + +impl TlsReporting for Server { + async fn send_tls_aggregate_report(&self, item_id: u64) -> trc::Result<()> { + let object_id = ObjectType::TlsInternalReport.to_id(); + let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id }); + + let Some(report) = self + .store() + .get_value::(ValueKey::from(key.clone())) + .await + .caused_by(trc::location!())? + else { + return Ok(()); + }; + + // Delete report + let mut batch = BatchBuilder::new(); + batch.clear(key).clear(RegistryClass::PrimaryKey { + object_id: object_id.into(), + index_id: Property::Domain.to_id(), + key: report.domain.as_bytes().to_vec(), + }); + self.core + .storage + .data + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + let domain_name = report.domain.as_str(); + let event_from = report.report.date_range_start.timestamp() as u64; + let event_to = report.report.date_range_end.timestamp() as u64; + let span_id = self.inner.data.span_id_gen.generate(); + + trc::event!( + OutgoingReport(OutgoingReportEvent::TlsAggregate), + SpanId = span_id, + ReportId = event_from, + Domain = domain_name.to_string(), + RangeFrom = trc::Value::Timestamp(event_from), + RangeTo = trc::Value::Timestamp(event_to), + ); + + // Generate report + let exported_report = mail_auth::report::tlsrpt::TlsReport::from(report.report); + let json = exported_report.to_json(); + let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default()); + let json = match std::io::Write::write_all(&mut e, json.as_bytes()).and_then(|_| e.finish()) + { + Ok(report) => report, + Err(err) => { + trc::event!( + OutgoingReport(OutgoingReportEvent::SubmissionError), + SpanId = span_id, + Reason = err.to_string(), + Details = "Failed to compress report" + ); + + return Ok(()); + } + }; + + // Try delivering report over HTTP + for uri in report.http_rua.as_slice() { + { + #[cfg(feature = "test_mode")] + if uri == "https://127.0.0.1/tls" { + TLS_HTTP_REPORT.lock().extend_from_slice(&json); + + return Ok(()); + } + + match self + .core + .smtp + .tls_report_client + .post(uri) + .timeout(Duration::from_secs(2 * 60)) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .header(CONTENT_TYPE, "application/tlsrpt+gzip") + .body(json.to_vec()) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + trc::event!( + OutgoingReport(OutgoingReportEvent::HttpSubmission), + SpanId = span_id, + Url = uri.to_string(), + Code = response.status().as_u16(), + ); + + return Ok(()); + } else { + trc::event!( + OutgoingReport(OutgoingReportEvent::SubmissionError), + SpanId = span_id, + Url = uri.to_string(), + Code = response.status().as_u16(), + Details = "Invalid HTTP response" + ); + } + } + Err(err) => { + trc::event!( + OutgoingReport(OutgoingReportEvent::SubmissionError), + SpanId = span_id, + Url = uri.to_string(), + Reason = err.to_string(), + Details = "HTTP submission error" + ); + } + } + } + } + + // Deliver report over SMTP + if !report.mail_rua.is_empty() { + let config = &self.core.smtp.report.tls; + let from_addr = self + .eval_if(&config.address, &RecipientDomain::new(domain_name), span_id) + .await + .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()); + let mut message = Vec::with_capacity(2048); + let _ = exported_report.write_rfc5322_from_bytes( + domain_name, + &self + .eval_if( + &self.core.smtp.report.submitter, + &RecipientDomain::new(domain_name), + span_id, + ) + .await + .unwrap_or_else(|| "localhost".to_string()), + ( + self.eval_if(&config.name, &RecipientDomain::new(domain_name), span_id) + .await + .unwrap_or_else(|| "Mail Delivery Subsystem".to_string()) + .as_str(), + from_addr.as_str(), + ), + report.mail_rua.iter().map(|v| v.as_str()), + &json, + &mut message, + ); + + // Send report + self.send_report( + &from_addr, + report.mail_rua.iter().map(|v| v.as_str()), + message, + &config.sign, + false, + span_id, + ) + .await; + } else { + trc::event!( + OutgoingReport(OutgoingReportEvent::NoRecipientsFound), + SpanId = span_id, + ); + } + + Ok(()) + } + + async fn schedule_tls(&self, event: Box) { + let object_id = ObjectType::TlsInternalReport.to_id(); + let pk = ValueClass::Registry(RegistryClass::PrimaryKey { + object_id: object_id.into(), + index_id: Property::Domain.to_id(), + key: event.domain.as_bytes().to_vec(), + }); + let mut rety_count = 0; + let policy_hash = event.policy.to_hash(); + + loop { + // Find the report by domain name + let mut batch = BatchBuilder::new(); + let report = match self + .store() + .get_value::(ValueKey::from(pk.clone())) + .await + { + Ok(Some(object_id_v)) => { + match self + .store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::Item { + object_id, + item_id: object_id_v.object_id.id().id(), + }, + ))) + .await + { + Ok(Some(report)) => Some((object_id_v, report)), + Ok(None) => { + trc::event!( + OutgoingReport(OutgoingReportEvent::NotFound), + Id = object_id_v.object_id.id().id(), + CausedBy = trc::location!(), + Details = "Failed to find TLS report for domain" + ); + + return; + } + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to query registry for TLS report") + ); + return; + } + } + } + Ok(None) => None, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to query registry for TLS report") + ); + return; + } + }; + + // Create report if missing + let config = &self.core.smtp.report.tls; + let (item_id, mut report) = if let Some((mut object_id_v, report)) = report { + batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version)); + object_id_v.version += 1; + batch.set(pk.clone(), object_id_v.serialize()); + + (object_id_v.object_id.id().id(), report) + } else { + let item_id = self.inner.data.queue_id_gen.generate(); + let date_range_start = UTCDateTime::now(); + let date_range_end = UTCDateTime::from_timestamp( + date_range_start.timestamp() + event.interval.as_secs() as i64, + ); + + let report = TlsInternalReport { + created_at: date_range_start, + deliver_at: date_range_end, + domain: event.domain.clone(), + report: TlsReport { + report_id: format!("{}_{policy_hash}", date_range_start.timestamp()), + organization_name: self + .eval_if::( + &config.org_name, + &RecipientDomain::new(&event.domain), + event.span_id, + ) + .await + .clone(), + contact_info: self + .eval_if::( + &config.contact_info, + &RecipientDomain::new(&event.domain), + event.span_id, + ) + .await + .clone(), + date_range_end, + date_range_start, + policies: Default::default(), + }, + ..Default::default() + }; + + report.write_ops(&mut batch, item_id, true); + + (item_id, report) + }; + + let policy = if let Some(policy) = report + .policy_identifiers + .as_slice() + .iter() + .position(|id| *id == policy_hash) + .and_then(|idx| report.report.policies.0.inner.get_mut(idx)) + { + &mut policy.value + } else { + // Create policy + let mut policy = TlsReportPolicy { + policy_type: TlsPolicyType::NoPolicyFound, + policy_domain: report.domain.clone(), + ..Default::default() + }; + + match &event.policy { + common::ipc::PolicyType::Tlsa(tlsa) => { + policy.policy_type = TlsPolicyType::Tlsa; + if let Some(tlsa) = tlsa { + for entry in &tlsa.entries { + policy.policy_strings.push(format!( + "{} {} {} {}", + if entry.is_end_entity { 3 } else { 2 }, + i32::from(entry.is_spki), + match entry.matching { + TlsaMatching::Full => 0, + TlsaMatching::Sha256 => 1, + TlsaMatching::Sha512 => 2, + }, + entry.data.iter().fold( + String::with_capacity(64), + |mut s, b| { + write!(s, "{b:02X}").ok(); + s + } + ) + )); + } + } + } + common::ipc::PolicyType::Sts(sts) => { + policy.policy_type = TlsPolicyType::Sts; + if let Some(sts) = sts { + policy.policy_strings.push("version: STSv1".to_string()); + policy.policy_strings.push(format!( + "mode: {}", + match sts.mode { + Mode::Enforce => "enforce", + Mode::Testing => "testing", + Mode::None => "none", + } + )); + policy + .policy_strings + .push(format!("max_age: {}", sts.max_age)); + for mx in &sts.mx { + let mx = match mx { + MxPattern::Equals(mx) => mx.to_string(), + MxPattern::StartsWith(mx) => format!("*.{mx}"), + }; + policy.policy_strings.push(format!("mx: {mx}")); + policy.mx_hosts.push(mx); + } + } + } + _ => (), + } + + for rua in &event.tls_record.rua { + match rua { + ReportUri::Mail(mail) => { + report.mail_rua.push(mail.clone()); + } + ReportUri::Http(uri) => { + report.http_rua.push(uri.clone()); + } + } + } + + report.policy_identifiers.push(policy_hash); + report.report.policies.push(policy); + &mut report.report.policies.0.inner.last_mut().unwrap().value + }; + + // Add failure details + if let Some(mut failure) = event.failure.clone().map(TlsFailureDetails::from) { + if let Some(idx) = policy + .failure_details + .0 + .inner + .iter() + .position(|d| d.value.eq_except_count(&failure)) + { + policy.failure_details.0.inner[idx] + .value + .failed_session_count += 1; + } else { + failure.failed_session_count = 1; + policy.failure_details.push(failure); + } + + policy.total_failed_sessions += 1; + } else { + policy.total_successful_sessions += 1; + } + + // Write entry + let report_bytes = report.to_pickled_vec(); + let max_report_size = self + .eval_if( + &config.max_size, + &RecipientDomain::new(&event.domain), + event.span_id, + ) + .await + .unwrap_or(5 * 1024 * 1024); + if max_report_size != 0 && report_bytes.len() > max_report_size { + trc::event!( + OutgoingReport(OutgoingReportEvent::MaxSizeExceeded), + SpanId = event.span_id, + Domain = event.domain.clone(), + Details = report_bytes.len(), + Limit = max_report_size, + ); + return; + } + + batch.set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + report_bytes, + ); + + match self.core.storage.data.write(batch.build_all()).await { + Ok(_) => { + break; + } + Err(err) => { + if err.is_assertion_failure() && rety_count < 3 { + rety_count += 1; + continue; + } + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to write TLS report") + ); + break; + } + } + } + } +} diff --git a/crates/smtp/src/scripts/envelope.rs b/crates/smtp/src/scripts/envelope.rs new file mode 100644 index 0000000..d6e9bbd --- /dev/null +++ b/crates/smtp/src/scripts/envelope.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sieve::Envelope; +use smtp_proto::{ + MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_BY_TRACE, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, + RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, +}; +use utils::DomainPart; + +use crate::core::{SessionAddress, SessionData}; + +impl SessionData { + pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) { + match envelope { + Envelope::From => { + let (address, address_lcase, domain) = if value.contains('@') { + let address_lcase = value.to_lowercase(); + let domain = address_lcase.domain_part().into(); + (value, address_lcase, domain) + } else if value.is_empty() { + (String::new(), String::new(), String::new()) + } else { + return; + }; + if let Some(mail_from) = &mut self.mail_from { + mail_from.address = address; + mail_from.address_lcase = address_lcase; + mail_from.domain = domain; + } else { + self.mail_from = SessionAddress { + address, + address_lcase, + domain, + flags: 0, + dsn_info: None, + } + .into(); + } + } + Envelope::To => { + if value.contains('@') { + let address_lcase = value.to_lowercase(); + let domain = address_lcase.domain_part().into(); + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.address = value; + rcpt_to.address_lcase = address_lcase; + rcpt_to.domain = domain; + } else { + self.rcpt_to.push(SessionAddress { + address: value, + address_lcase, + domain, + flags: 0, + dsn_info: None, + }); + } + } + } + Envelope::ByMode => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.flags &= !(MAIL_BY_NOTIFY | MAIL_BY_RETURN); + if value == "N" { + mail_from.flags |= MAIL_BY_NOTIFY; + } else if value == "R" { + mail_from.flags |= MAIL_BY_RETURN; + } + } + } + Envelope::ByTrace => { + if let Some(mail_from) = &mut self.mail_from { + if value == "T" { + mail_from.flags |= MAIL_BY_TRACE; + } else { + mail_from.flags &= !MAIL_BY_TRACE; + } + } + } + Envelope::Notify => { + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.flags &= !(RCPT_NOTIFY_DELAY + | RCPT_NOTIFY_FAILURE + | RCPT_NOTIFY_SUCCESS + | RCPT_NOTIFY_NEVER); + if value == "NEVER" { + rcpt_to.flags |= RCPT_NOTIFY_NEVER; + } else { + for value in value.split(',') { + match value.trim() { + "SUCCESS" => rcpt_to.flags |= RCPT_NOTIFY_SUCCESS, + "FAILURE" => rcpt_to.flags |= RCPT_NOTIFY_FAILURE, + "DELAY" => rcpt_to.flags |= RCPT_NOTIFY_DELAY, + _ => (), + } + } + } + } + } + Envelope::Ret => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.flags &= !(MAIL_RET_FULL | MAIL_RET_HDRS); + if value == "FULL" { + mail_from.flags |= MAIL_RET_FULL; + } else if value == "HDRS" { + mail_from.flags |= MAIL_RET_HDRS; + } + } + } + Envelope::Orcpt => { + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.dsn_info = value.into(); + } + } + Envelope::Envid => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.dsn_info = value.into(); + } + } + Envelope::ByTimeAbsolute | Envelope::ByTimeRelative => (), + } + } +} diff --git a/crates/smtp/src/scripts/event_loop.rs b/crates/smtp/src/scripts/event_loop.rs new file mode 100644 index 0000000..25295b4 --- /dev/null +++ b/crates/smtp/src/scripts/event_loop.rs @@ -0,0 +1,435 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::queue::{ + MessageSource, + quota::HasQueueQuota, + spool::{QueueParams, SmtpSpool}, +}; +use common::{Server, config::smtp::queue::QueueExpiry, scripts::plugins::PluginContext}; +use mail_parser::{Encoding, Message, MessagePart, PartType}; +use sieve::{ + Event, Input, MatchAs, Recipient, Sieve, + compiler::grammar::actions::action_redirect::{ByMode, ByTime, Notify, NotifyItem, Ret}, +}; +use smtp_proto::{ + MAIL_BY_TRACE, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, + RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, +}; +use std::{future::Future, sync::Arc, time::Instant}; +use trc::SieveEvent; + +use super::{ScriptModification, ScriptParameters, ScriptResult}; + +pub trait RunScript: Sync + Send { + fn run_script( + &self, + script_id: String, + script: Arc, + params: ScriptParameters<'_>, + ) -> impl Future + Send; +} + +impl RunScript for Server { + async fn run_script( + &self, + script_id: String, + script: Arc, + params: ScriptParameters<'_>, + ) -> ScriptResult { + // Create filter instance + let time = Instant::now(); + let mut instance = self + .core + .sieve + .trusted_runtime + .filter_parsed(params.message.unwrap_or_else(|| Message { + parts: vec![MessagePart { + headers: vec![], + is_encoding_problem: false, + body: PartType::Text("".into()), + encoding: Encoding::None, + offset_header: 0, + offset_body: 0, + offset_end: 0, + }], + raw_message: b""[..].into(), + ..Default::default() + })) + .with_vars_env(params.variables) + .with_envelope_list(params.envelope) + .with_user_address(¶ms.from_addr) + .with_user_full_name(¶ms.from_name); + if let Some(spam_status) = params.spam_status { + instance.set_spam_status(spam_status); + } + let mut input = Input::script("__script", script); + let mut messages: Vec> = Vec::new(); + let session_id = params.session_id; + + let mut reject_reason = None; + let mut modifications = vec![]; + let mut keep_id = usize::MAX; + + // Start event loop + while let Some(result) = instance.run(input) { + match result { + Ok(event) => match event { + Event::IncludeScript { name, optional } => { + if let Some(script) = self.core.sieve.trusted_script(name.as_str()) { + input = Input::script(name, script.clone()); + } else if optional { + input = false.into(); + } else { + trc::event!( + Sieve(SieveEvent::ScriptNotFound), + Id = script_id.clone(), + SpanId = session_id, + Details = name.as_str().to_string(), + ); + break; + } + } + Event::ListContains { + lists, + values, + match_as, + } => { + input = false.into(); + 'outer: for list in lists { + if let Some(store) = self.get_lookup_store(&list) { + for value in &values { + if let Ok(true) = store + .key_exists(if !matches!(match_as, MatchAs::Lowercase) { + value.clone() + } else { + value.to_lowercase() + }) + .await + { + input = true.into(); + break 'outer; + } + } + } else { + trc::event!( + Sieve(SieveEvent::ListNotFound), + Id = script_id.clone(), + SpanId = session_id, + Details = list, + ); + } + } + } + Event::Function { id, arguments } => { + input = self + .core + .run_plugin( + id, + PluginContext { + session_id, + server: self, + message: instance.message(), + modifications: &mut modifications, + access_token: params.access_token, + arguments, + }, + ) + .await; + } + Event::Keep { message_id, .. } => { + keep_id = message_id; + input = true.into(); + } + Event::Discard => { + keep_id = usize::MAX - 1; + input = true.into(); + } + Event::Reject { reason, .. } => { + reject_reason = reason.into(); + input = true.into(); + } + Event::SendMessage { + recipient, + notify, + return_of_content, + by_time, + message_id, + } => { + // Build message + let mut message = self.new_message( + params.return_path.as_str(), + MessageSource::Autogenerated, + session_id, + ); + match recipient { + Recipient::Address(rcpt) => { + message.expand_and_add_recipient(rcpt, self).await; + } + Recipient::Group(rcpt_list) => { + for rcpt in rcpt_list { + message.expand_and_add_recipient(rcpt, self).await; + } + } + Recipient::List(list) => { + trc::event!( + Sieve(SieveEvent::NotSupported), + Id = script_id.clone(), + SpanId = session_id, + Details = list, + Reason = "Sending to lists is not supported.", + ); + } + } + + // Set notify flags + let mut flags = 0; + match notify { + Notify::Never => { + flags = RCPT_NOTIFY_NEVER; + } + Notify::Items(items) => { + for item in items { + flags |= match item { + NotifyItem::Success => RCPT_NOTIFY_SUCCESS, + NotifyItem::Failure => RCPT_NOTIFY_FAILURE, + NotifyItem::Delay => RCPT_NOTIFY_DELAY, + }; + } + } + Notify::Default => (), + } + if flags > 0 { + for rcpt in &mut message.message.recipients { + rcpt.flags |= flags; + } + } + + // Set ByTime flags + match by_time { + ByTime::Relative { + rlimit, + mode, + trace, + } => { + if trace { + message.message.flags |= MAIL_BY_TRACE; + } + match mode { + ByMode::Notify => { + for domain in &mut message.message.recipients { + domain.notify.due += rlimit; + } + } + ByMode::Return => { + for domain in &mut message.message.recipients { + domain.notify.due += rlimit; + } + } + ByMode::Default => (), + } + } + ByTime::Absolute { + alimit, + mode, + trace, + } => { + if trace { + message.message.flags |= MAIL_BY_TRACE; + } + match mode { + ByMode::Notify => { + for domain in &mut message.message.recipients { + domain.notify.due = alimit as u64; + } + } + ByMode::Return => { + let expires = + (alimit as u64).saturating_sub(message.message.created); + if expires > 0 { + for domain in &mut message.message.recipients { + domain.expires = QueueExpiry::Ttl(expires); + } + } + } + ByMode::Default => (), + } + } + ByTime::None => (), + }; + + // Set ret + match return_of_content { + Ret::Full => { + message.message.flags |= MAIL_RET_FULL; + } + Ret::Hdrs => { + message.message.flags |= MAIL_RET_HDRS; + } + Ret::Default => (), + } + + // Queue message + let is_forward = message_id == 0; + let raw_message = if !is_forward { + messages.get(message_id - 1).map(|m| m.as_slice()) + } else { + instance.message().raw_message().into() + }; + if let Some(raw_message) = raw_message.filter(|m| !m.is_empty()) { + if let Some(metadata) = self.has_quota(&mut message).await { + let dkim_signers = if let Some(sign_domain) = ¶ms.sign_domain { + match self.dkim_signers(sign_domain).await { + Ok(signers) => signers, + Err(err) => { + trc::error!( + err.details("Failed to obtain DKIM signers") + .caused_by(trc::location!()) + ); + + None + } + } + } else { + None + }; + + message + .queue( + QueueParams::new(raw_message, session_id, self) + .with_dkim_signers(dkim_signers) + .with_raw_headers_opt( + params.headers.filter(|_| is_forward), + ) + .with_original_raw_message( + instance.message().raw_message(), + ) + .with_metadata(metadata), + ) + .await; + } else { + trc::event!( + Sieve(SieveEvent::QuotaExceeded), + SpanId = session_id, + Id = script_id.clone(), + From = message.message.return_path, + To = message + .message + .recipients + .into_iter() + .map(|r| trc::Value::from(r.address().to_string())) + .collect::>(), + ); + } + } + + input = true.into(); + } + Event::CreatedMessage { message, .. } => { + messages.push(message); + input = true.into(); + } + Event::SetEnvelope { envelope, value } => { + modifications.push(ScriptModification::SetEnvelope { + name: envelope, + value, + }); + input = true.into(); + } + unsupported => { + trc::event!( + Sieve(SieveEvent::NotSupported), + Id = script_id.clone(), + SpanId = session_id, + Reason = "Unsupported event", + Details = format!("{unsupported:?}"), + ); + break; + } + }, + Err(err) => { + trc::event!( + Sieve(SieveEvent::RuntimeError), + Id = script_id.clone(), + SpanId = session_id, + Reason = err.to_string(), + ); + break; + } + } + } + + // Keep id + // 0 = use original message + // MAX = implicit keep + // MAX - 1 = discard message + + if keep_id == 0 { + trc::event!( + Sieve(SieveEvent::ActionAccept), + SpanId = session_id, + Id = script_id, + Elapsed = time.elapsed(), + ); + + ScriptResult::Accept { modifications } + } else if let Some(mut reject_reason) = reject_reason { + trc::event!( + Sieve(SieveEvent::ActionReject), + Id = script_id, + SpanId = session_id, + Details = reject_reason.clone(), + Elapsed = time.elapsed(), + ); + + if !reject_reason.ends_with('\n') { + reject_reason.push_str("\r\n"); + } + let mut reject_bytes = reject_reason.as_bytes().iter(); + if matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit()) + && matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit()) + && matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit()) + && matches!(reject_bytes.next(), Some(ch) if ch == &b' ' ) + { + ScriptResult::Reject(reject_reason) + } else { + ScriptResult::Reject(format!("503 5.5.3 {reject_reason}")) + } + } else if keep_id != usize::MAX - 1 { + if let Some(message) = messages.into_iter().nth(keep_id - 1) { + trc::event!( + Sieve(SieveEvent::ActionAccept), + SpanId = session_id, + Id = script_id, + Elapsed = time.elapsed(), + ); + + ScriptResult::Replace { + message, + modifications, + } + } else { + trc::event!( + Sieve(SieveEvent::ActionAcceptReplace), + SpanId = session_id, + Id = script_id, + Elapsed = time.elapsed(), + ); + + ScriptResult::Accept { modifications } + } + } else { + trc::event!( + Sieve(SieveEvent::ActionDiscard), + SpanId = session_id, + Id = script_id, + Elapsed = time.elapsed() + ); + + ScriptResult::Discard + } + } +} diff --git a/crates/smtp/src/scripts/exec.rs b/crates/smtp/src/scripts/exec.rs new file mode 100644 index 0000000..2900eef --- /dev/null +++ b/crates/smtp/src/scripts/exec.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{sync::Arc, time::SystemTime}; + +use common::network::SessionStream; + +use mail_auth::common::resolver::ToReverseName; +use sieve::{Envelope, Sieve, runtime::Variable}; +use smtp_proto::*; + +use crate::{core::Session, inbound::AuthResult}; + +use super::{ScriptParameters, ScriptResult, event_loop::RunScript}; + +impl Session { + pub fn build_script_parameters(&self, stage: &'static str) -> ScriptParameters<'_> { + let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher(); + let mut params = ScriptParameters::new() + .set_variable("remote_ip", self.data.remote_ip.to_string()) + .set_variable("remote_ip.reverse", self.data.remote_ip.to_reverse_name()) + .set_variable("helo_domain", self.data.helo_domain.as_str().to_lowercase()) + .set_variable( + "authenticated_as", + self.authenticated_as().unwrap_or_default().to_string(), + ) + .set_variable( + "now", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) + .set_variable( + "asn", + self.data + .asn_geo_data + .asn + .as_ref() + .map(|r| r.id) + .unwrap_or_default(), + ) + .set_variable( + "country", + self.data + .asn_geo_data + .country + .as_ref() + .map(|r| r.as_str()) + .unwrap_or_default(), + ) + .set_variable( + "spf.result", + self.data + .spf_mail_from + .as_ref() + .map(|r| r.result().as_str()) + .unwrap_or_default(), + ) + .set_variable( + "spf_ehlo.result", + self.data + .spf_ehlo + .as_ref() + .map(|r| r.result().as_str()) + .unwrap_or_default(), + ) + .set_variable("tls.version", tls_version) + .set_variable("tls.cipher", tls_cipher) + .set_variable("stage", stage); + if let Some(ip_rev) = &self.data.iprev { + params = params.set_variable("iprev.result", ip_rev.result().as_str()); + if let Some(ptr) = ip_rev.ptr.as_ref().and_then(|addrs| addrs.first()) { + params = params.set_variable( + "iprev.ptr", + ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase(), + ); + } + } + + if let Some(mail_from) = &self.data.mail_from { + params + .envelope + .push((Envelope::From, mail_from.address_lcase.to_string().into())); + if let Some(env_id) = &mail_from.dsn_info { + params + .envelope + .push((Envelope::Envid, env_id.as_str().to_lowercase().into())); + } + + if stage != "data" { + if let Some(rcpt) = self.data.rcpt_to.last() { + params + .envelope + .push((Envelope::To, rcpt.address_lcase.to_string().into())); + if let Some(orcpt) = &rcpt.dsn_info { + params + .envelope + .push((Envelope::Orcpt, orcpt.as_str().to_lowercase().into())); + } + } + } else { + // Build recipients list + let mut recipients = Vec::with_capacity(self.data.rcpt_to.len()); + let mut orcpts = Vec::with_capacity(self.data.rcpt_to.len()); + let mut has_orcpts = false; + + for rcpt in &self.data.rcpt_to { + recipients.push(Variable::from(rcpt.address_lcase.to_string())); + orcpts.push(match &rcpt.dsn_info { + Some(orcpt) => { + has_orcpts = true; + Variable::from(orcpt.as_str().to_lowercase()) + } + None => Variable::default(), + }); + } + + params.envelope.push((Envelope::To, recipients.into())); + if has_orcpts { + params.envelope.push((Envelope::Orcpt, orcpts.into())); + } + } + + if (mail_from.flags & MAIL_RET_FULL) != 0 { + params.envelope.push((Envelope::Ret, "FULL".into())); + } else if (mail_from.flags & MAIL_RET_HDRS) != 0 { + params.envelope.push((Envelope::Ret, "HDRS".into())); + } + if (mail_from.flags & MAIL_BY_NOTIFY) != 0 { + params.envelope.push((Envelope::ByMode, "N".into())); + } else if (mail_from.flags & MAIL_BY_RETURN) != 0 { + params.envelope.push((Envelope::ByMode, "R".into())); + } + + if (mail_from.flags & MAIL_BODY_7BIT) != 0 { + params = params.set_variable("param.body", "7bit"); + } else if (mail_from.flags & MAIL_BODY_8BITMIME) != 0 { + params = params.set_variable("param.body", "8bitmime"); + } else if (mail_from.flags & MAIL_BODY_BINARYMIME) != 0 { + params = params.set_variable("param.body", "binarymime"); + } + + if (mail_from.flags & MAIL_SMTPUTF8) != 0 { + params = params.set_variable("param.smtputf8", Variable::Integer(1)); + } + if (mail_from.flags & MAIL_REQUIRETLS) != 0 { + params = params.set_variable("param.requiretls", Variable::Integer(1)); + } + } + + params + } + + pub async fn run_script( + &self, + script_id: String, + script: Arc, + params: ScriptParameters<'_>, + ) -> ScriptResult { + Box::pin( + self.server.run_script( + script_id, + script, + params + .with_session_id(self.data.session_id) + .with_envelope(&self.server, self, self.data.session_id) + .await, + ), + ) + .await + } +} diff --git a/crates/smtp/src/scripts/mod.rs b/crates/smtp/src/scripts/mod.rs new file mode 100644 index 0000000..ff8d946 --- /dev/null +++ b/crates/smtp/src/scripts/mod.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +use ahash::AHashMap; +use common::{ + Server, auth::AccessToken, expr::functions::ResolveVariable, scripts::ScriptModification, +}; + +use mail_parser::Message; +use sieve::{Envelope, SpamStatus, runtime::Variable}; + +pub mod envelope; +pub mod event_loop; +pub mod exec; + +#[derive(Debug, serde::Serialize)] +pub enum ScriptResult { + Accept { + modifications: Vec, + }, + Replace { + message: Vec, + modifications: Vec, + }, + Reject(String), + Discard, +} + +pub struct ScriptParameters<'x> { + message: Option>, + headers: Option<&'x [u8]>, + variables: AHashMap, Variable>, + envelope: Vec<(Envelope, Variable)>, + from_addr: String, + from_name: String, + return_path: String, + sign_domain: Option, + access_token: Option<&'x AccessToken>, + spam_status: Option, + session_id: u64, +} + +impl<'x> ScriptParameters<'x> { + pub fn new() -> Self { + ScriptParameters { + variables: AHashMap::with_capacity(10), + envelope: Vec::with_capacity(6), + message: None, + headers: None, + from_addr: Default::default(), + from_name: Default::default(), + return_path: Default::default(), + sign_domain: Default::default(), + access_token: None, + spam_status: None, + session_id: Default::default(), + } + } + + pub async fn with_envelope( + mut self, + server: &Server, + vars: &impl ResolveVariable, + session_id: u64, + ) -> Self { + for (variable, expr) in [ + (&mut self.from_addr, &server.core.sieve.from_addr), + (&mut self.from_name, &server.core.sieve.from_name), + (&mut self.return_path, &server.core.sieve.return_path), + ] { + if let Some(value) = server.eval_if(expr, vars, session_id).await { + *variable = value; + } + } + self.sign_domain = server + .eval_if(&server.core.sieve.sign, vars, session_id) + .await; + self + } + + pub fn with_message(self, message: Message<'x>) -> Self { + Self { + message: message.into(), + ..self + } + } + + pub fn with_auth_headers(self, headers: &'x [u8]) -> Self { + Self { + headers: headers.into(), + ..self + } + } + + pub fn with_spam_status(self, status: SpamStatus) -> Self { + Self { + spam_status: status.into(), + ..self + } + } + + pub fn set_variable( + mut self, + name: impl Into>, + value: impl Into, + ) -> Self { + self.variables.insert(name.into(), value.into()); + self + } + + pub fn set_envelope(mut self, envelope: Envelope, value: impl Into) -> Self { + self.envelope.push((envelope, value.into())); + self + } + + pub fn with_access_token(mut self, access_token: &'x AccessToken) -> Self { + self.access_token = Some(access_token); + self + } + + pub fn with_session_id(mut self, session_id: u64) -> Self { + self.session_id = session_id; + self + } +} + +impl Default for ScriptParameters<'_> { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/spam-filter/Cargo.toml b/crates/spam-filter/Cargo.toml new file mode 100644 index 0000000..561c73e --- /dev/null +++ b/crates/spam-filter/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "spam-filter" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +types = { path = "../types" } +nlp = { path = "../nlp" } +store = { path = "../store" } +trc = { path = "../trc" } +common = { path = "../common" } +registry = { path = "../registry" } +smtp-proto = { version = "0.2", features = ["rkyv"] } +mail-parser = { version = "0.11", features = ["full_encoding"] } +mail-auth = { version = "0.13" } +tokio = { version = "1.53", features = ["net", "macros"] } +psl = "2" +hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +idna = "1.1" +decancer = "3.3.3" +unicode-security = "0.1.2" +infer = "0.22" +hashify = "0.2" +sha1 = "0.11" +compact_str = "0.10.0" +rkyv = { version = "0.8.18", features = ["little_endian"] } +serde = { version = "1.0", features = ["derive"]} +unicode-general-category = "1.1.0" +unicode-normalization = "0.1.25" + +[features] +test_mode = [] +enterprise = [] + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/spam-filter/src/analysis/classifier.rs b/crates/spam-filter/src/analysis/classifier.rs new file mode 100644 index 0000000..b2262bf --- /dev/null +++ b/crates/spam-filter/src/analysis/classifier.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{SpamFilterContext, modules::classifier::SpamClassifier}; +use common::Server; +use std::future::Future; + +pub trait SpamFilterAnalyzeClassify: Sync + Send { + fn spam_filter_analyze_classify( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; + + fn spam_filter_analyze_spam_trap( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeClassify for Server { + async fn spam_filter_analyze_classify(&self, ctx: &mut SpamFilterContext<'_>) { + if self.core.spam.classifier.is_some() + && !ctx.result.has_tag("SPAM_TRAP") + && let Err(err) = self.spam_classify(ctx).await + { + trc::error!(err.span_id(ctx.input.span_id).caused_by(trc::location!())); + } + } + + async fn spam_filter_analyze_spam_trap(&self, ctx: &mut SpamFilterContext<'_>) -> bool { + if let Some(store) = self.get_lookup_store("spam-traps") { + for addr in &ctx.output.env_to_orig_addr { + match store.key_exists(addr.address.as_str()).await { + Ok(true) => { + ctx.result.add_tag("SPAM_TRAP"); + return true; + } + Ok(false) => (), + Err(err) => { + trc::error!(err.span_id(ctx.input.span_id).caused_by(trc::location!())); + } + } + } + } + + false + } +} diff --git a/crates/spam-filter/src/analysis/date.rs b/crates/spam-filter/src/analysis/date.rs new file mode 100644 index 0000000..849082e --- /dev/null +++ b/crates/spam-filter/src/analysis/date.rs @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::HeaderName; +use store::write::now; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeDate: Sync + Send { + fn spam_filter_analyze_date( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeDate for Server { + async fn spam_filter_analyze_date(&self, ctx: &mut SpamFilterContext<'_>) { + match ctx + .input + .message + .header(HeaderName::Date) + .map(|h| h.as_datetime()) + { + Some(Some(date)) => { + let date = date.to_timestamp(); + if date != 0 { + let date_diff = now() as i64 - date; + + if date_diff > 86400 { + // Older than a day + ctx.result.add_tag("DATE_IN_PAST"); + } else if -date_diff > 7200 { + //# More than 2 hours in the future + ctx.result.add_tag("DATE_IN_FUTURE"); + } + } else { + ctx.result.add_tag("INVALID_DATE"); + } + } + Some(None) => { + ctx.result.add_tag("INVALID_DATE"); + } + + None => { + ctx.result.add_tag("MISSING_DATE"); + } + } + } +} diff --git a/crates/spam-filter/src/analysis/dmarc.rs b/crates/spam-filter/src/analysis/dmarc.rs new file mode 100644 index 0000000..9b4903a --- /dev/null +++ b/crates/spam-filter/src/analysis/dmarc.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_auth::{Dkim2Result, DkimResult, DmarcResult, SpfResult, dmarc::Policy}; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeDmarc: Sync + Send { + fn spam_filter_analyze_dmarc( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeDmarc for Server { + async fn spam_filter_analyze_dmarc(&self, ctx: &mut SpamFilterContext<'_>) { + ctx.result.add_tag( + ctx.input + .spf_mail_from_result + .map_or("SPF_NA", |r| match r.result() { + SpfResult::Pass => "SPF_ALLOW", + SpfResult::Fail => "SPF_FAIL", + SpfResult::SoftFail => "SPF_SOFTFAIL", + SpfResult::Neutral => "SPF_NEUTRAL", + SpfResult::TempError => "SPF_DNSFAIL", + SpfResult::PermError => "SPF_PERMFAIL", + SpfResult::None => "SPF_NA", + }), + ); + + ctx.result.add_tag( + match ctx + .input + .dkim_result + .iter() + .find(|r| matches!(r.result(), DkimResult::Pass)) + .or_else(|| ctx.input.dkim_result.first()) + .map(|r| r.result()) + .unwrap_or(&DkimResult::None) + { + DkimResult::Pass => "DKIM_ALLOW", + DkimResult::Fail(_) => "DKIM_REJECT", + DkimResult::PermError(_) => "DKIM_PERMFAIL", + DkimResult::TempError(_) => "DKIM_TEMPFAIL", + DkimResult::Neutral(_) | DkimResult::None => "DKIM_NA", + }, + ); + + ctx.result.add_tag( + ctx.input + .dkim2_result + .map_or("DKIM2_NA", |r| match r.result() { + Dkim2Result::Pass => "DKIM2_ALLOW", + Dkim2Result::Fail(_) => "DKIM2_REJECT", + Dkim2Result::PermError(_) => "DKIM2_PERMFAIL", + Dkim2Result::TempError(_) => "DKIM2_TEMPFAIL", + Dkim2Result::None => "DKIM2_NA", + }), + ); + + ctx.result + .add_tag(ctx.input.arc_result.map_or("ARC_NA", |r| match r.result() { + DkimResult::Pass => "ARC_ALLOW", + DkimResult::Fail(_) => "ARC_REJECT", + DkimResult::PermError(_) => "ARC_INVALID", + DkimResult::TempError(_) => "ARC_DNSFAIL", + DkimResult::Neutral(_) | DkimResult::None => "ARC_NA", + })); + + ctx.result + .add_tag(ctx.input.dmarc_result.map_or("DMARC_NA", |r| match r { + DmarcResult::Pass => "DMARC_POLICY_ALLOW", + DmarcResult::TempError(_) => "DMARC_DNSFAIL", + DmarcResult::PermError(_) => "DMARC_BAD_POLICY", + DmarcResult::None => "DMARC_NA", + DmarcResult::Fail(_) => ctx.input.dmarc_policy.map_or( + "DMARC_POLICY_SOFTFAIL", + |p| match p { + Policy::Quarantine => "DMARC_POLICY_QUARANTINE", + Policy::Reject => "DMARC_POLICY_REJECT", + Policy::Unspecified | Policy::None => "DMARC_POLICY_SOFTFAIL", + }, + ), + })); + } +} diff --git a/crates/spam-filter/src/analysis/domain.rs b/crates/spam-filter/src/analysis/domain.rs new file mode 100644 index 0000000..ea3ed86 --- /dev/null +++ b/crates/spam-filter/src/analysis/domain.rs @@ -0,0 +1,260 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ElementLocation, is_trusted_domain}; +use crate::{ + Email, Hostname, Recipient, SpamFilterContext, TextPart, + modules::{ + dnsbl::check_dnsbl, + expression::StringResolver, + html::{A, HREF, HtmlToken}, + }, +}; +use common::{ + Server, + config::mailstore::spamfilter::{Element, Location}, +}; +use mail_auth::DkimResult; +use mail_parser::{HeaderName, HeaderValue, Host, parsers::MessageStream}; +use nlp::tokenizers::types::TokenType; +use std::{collections::HashSet, future::Future}; + +pub trait SpamFilterAnalyzeDomain: Sync + Send { + fn spam_filter_analyze_domain( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeDomain for Server { + async fn spam_filter_analyze_domain(&self, ctx: &mut SpamFilterContext<'_>) { + // Obtain email addresses and domains + let mut domains: HashSet> = HashSet::new(); + let mut emails: HashSet> = HashSet::new(); + + // Add DKIM domains + for dkim in ctx.input.dkim_result { + if dkim.result() == &DkimResult::Pass + && let Some(domain) = dkim.signature().map(|s| &s.d) + { + domains.insert(ElementLocation::new( + domain.to_lowercase(), + Location::HeaderDkimPass, + )); + } + } + + // Add Received headers + for header in ctx.input.message.headers() { + match (&header.name, &header.value) { + (HeaderName::Received, HeaderValue::Received(received)) => { + for host in [&received.from, &received.helo, &received.by] + .into_iter() + .flatten() + { + if let Host::Name(name) = host { + let host = Hostname::new(name.as_ref()); + + if host.sld.is_some() { + domains.insert(ElementLocation::new( + host.fqdn, + Location::HeaderReceived, + )); + } + } + } + } + (HeaderName::MessageId, value) => { + if let Some(mid_domain) = value + .as_text() + .and_then(|s| s.rsplit_once('@')) + .and_then(|(_, d)| { + let host = Hostname::new(d); + if host.sld.is_some() { Some(host) } else { None } + }) + { + domains.insert(ElementLocation::new(mid_domain.fqdn, Location::HeaderMid)); + } + } + (HeaderName::DispositionNotificationTo, _) => { + if let Some(address) = MessageStream::new( + ctx.input + .message + .raw_message + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(), + ) + .parse_address() + .as_address() + { + for addr in address.iter() { + if let Some(email) = addr.address() { + emails.insert(ElementLocation::new( + Recipient { + email: Email::new(email), + name: None, + }, + Location::HeaderDnt, + )); + } + } + } + } + _ => (), + } + } + + // Add EHLO domain + if ctx.output.ehlo_host.sld.is_some() { + domains.insert(ElementLocation::new( + ctx.output.ehlo_host.fqdn.clone(), + Location::Ehlo, + )); + } + + // Add PTR + if let Some(ptr) = &ctx.output.iprev_ptr { + domains.insert(ElementLocation::new(ptr.clone(), Location::Tcp)); + } + + // Add From, Envelope From and Reply-To + emails.insert(ElementLocation::new( + ctx.output.from.clone(), + Location::HeaderFrom, + )); + if let Some(reply_to) = &ctx.output.reply_to { + emails.insert(ElementLocation::new( + reply_to.clone(), + Location::HeaderReplyTo, + )); + } + emails.insert(ElementLocation::new( + Recipient { + email: ctx.output.env_from_addr.clone(), + name: None, + }, + Location::EnvelopeFrom, + )); + + // Add emails found in the message + for (part_id, part) in ctx.output.text_parts.iter().enumerate() { + let part_id = part_id as u32; + let is_body = ctx.input.message.text_body.contains(&part_id) + || ctx.input.message.html_body.contains(&part_id); + let tokens = match part { + TextPart::Plain { tokens, .. } => tokens, + TextPart::Html { + tokens, + html_tokens, + .. + } => { + emails.extend(html_tokens.iter().filter_map(|token| { + if let HtmlToken::StartTag { + name: A, + attributes, + .. + } = token + { + attributes.iter().find_map(|(attr, value)| { + if *attr == HREF { + let value = value.as_deref()?.strip_prefix("mailto:")?; + let email = + Email::new(value.split_once('?').map_or(value, |(e, _)| e)); + + if email.is_valid() { + return Some(ElementLocation::new( + Recipient { email, name: None }, + if is_body { + Location::BodyHtml + } else { + Location::Attachment + }, + )); + } + } + None + }) + } else { + None + } + })); + tokens + } + TextPart::None => continue, + }; + + for token in tokens { + if let TokenType::Email(email) = token { + if !ctx.input.is_train && is_body && !ctx.result.has_tag("RCPT_IN_BODY") { + for rcpt in ctx.output.all_recipients() { + if &rcpt.email == email { + ctx.result.add_tag("RCPT_IN_BODY"); + break; + } + } + } + + if email.is_valid() { + emails.insert(ElementLocation::new( + Recipient { + email: email.clone(), + name: None, + }, + if is_body { + Location::BodyText + } else { + Location::Attachment + }, + )); + } + } + } + } + + if !ctx.input.is_train { + // Validate email + for email in &emails { + // Skip trusted domains + if !email.element.email.is_valid() + || is_trusted_domain( + self, + &email.element.email.domain_part.fqdn, + ctx.input.span_id, + ) + .await + { + continue; + } + + // Check Email DNSBL + check_dnsbl(self, ctx, &email.element, Element::Email, email.location).await; + + domains.insert(ElementLocation::new( + email.element.email.domain_part.fqdn.clone(), + email.location, + )); + } + + // Validate domains + for domain in &domains { + // Skip trusted domains + if !is_trusted_domain(self, &domain.element, ctx.input.span_id).await { + // Check Domain DNSBL + check_dnsbl( + self, + ctx, + &StringResolver(domain.element.as_str()), + Element::Domain, + domain.location, + ) + .await; + } + } + } + ctx.output.emails = emails; + ctx.output.domains = domains; + } +} diff --git a/crates/spam-filter/src/analysis/ehlo.rs b/crates/spam-filter/src/analysis/ehlo.rs new file mode 100644 index 0000000..2c2de14 --- /dev/null +++ b/crates/spam-filter/src/analysis/ehlo.rs @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeEhlo: Sync + Send { + fn spam_filter_analyze_ehlo( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeEhlo for Server { + async fn spam_filter_analyze_ehlo(&self, ctx: &mut SpamFilterContext<'_>) { + if let Some(ehlo_ip) = ctx.output.ehlo_host.ip { + // Helo host is bare ip + ctx.result.add_tag("HELO_BAREIP"); + + if ehlo_ip != ctx.input.remote_ip { + // Helo A IP != hostname IP + ctx.result.add_tag("HELO_IP_A"); + } + } else if ctx.output.ehlo_host.sld.is_some() { + if ctx + .output + .iprev_ptr + .as_ref() + .is_some_and(|ptr| *ptr != ctx.output.ehlo_host.fqdn) + { + // Helo does not match reverse IP + ctx.result.add_tag("HELO_IPREV_MISMATCH"); + } + + if matches!( + ( + self.dns_exists_ip(&ctx.output.ehlo_host.fqdn).await, + self.dns_exists_mx(&ctx.output.ehlo_host.fqdn).await + ), + (Ok(false), Ok(false)) + ) { + // Helo no resolve to A or MX + ctx.result.add_tag("HELO_NORES_A_OR_MX"); + } + } else { + if ctx.output.ehlo_host.fqdn.contains("user") { + // Helo host contains 'user' + ctx.result.add_tag("RCVD_HELO_USER"); + } + + // Helo not FQDN + ctx.result.add_tag("HELO_NOT_FQDN"); + } + } +} diff --git a/crates/spam-filter/src/analysis/from.rs b/crates/spam-filter/src/analysis/from.rs new file mode 100644 index 0000000..69a3389 --- /dev/null +++ b/crates/spam-filter/src/analysis/from.rs @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Email, SpamFilterContext}; +use common::Server; +use mail_parser::HeaderName; +use nlp::tokenizers::types::{TokenType, TypesTokenizer}; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; +use std::future::Future; + +pub trait SpamFilterAnalyzeFrom: Sync + Send { + fn spam_filter_analyze_from( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeFrom for Server { + async fn spam_filter_analyze_from(&self, ctx: &mut SpamFilterContext<'_>) { + let mut from_count = 0; + let mut from_raw = b"".as_slice(); + let mut crt = None; + let mut dnt = None; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::From => { + from_count += 1; + from_raw = ctx + .input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(); + } + HeaderName::DispositionNotificationTo => { + dnt = ctx + .input + .header_as_address(header) + .map(|s| Email::new(s.as_ref())); + } + HeaderName::Other(name) if name.eq_ignore_ascii_case("X-Confirm-Reading-To") => { + crt = ctx + .input + .header_as_address(header) + .map(|s| Email::new(s.as_ref())); + } + _ => {} + } + } + + match from_count { + 0 => { + ctx.result.add_tag("MISSING_FROM"); + } + 1 => {} + _ => { + ctx.result.add_tag("MULTIPLE_FROM"); + } + } + + let env_from_empty = ctx.output.env_from_addr.address.is_empty(); + let from_addr = &ctx.output.from.email; + let from_name = ctx.output.from.name.as_deref().unwrap_or_default(); + if from_count > 0 { + // Validate address + let from_addr_is_valid = from_addr.is_valid(); + if !from_addr_is_valid { + ctx.result.add_tag("FROM_INVALID"); + } + + // Validate from name + let from_name_trimmed = from_name.trim(); + if from_name_trimmed.is_empty() { + ctx.result.add_tag("FROM_NO_DN"); + } else if from_name_trimmed == from_addr.address { + ctx.result.add_tag("FROM_DN_EQ_ADDR"); + } else { + if from_addr_is_valid { + ctx.result.add_tag("FROM_HAS_DN"); + } + + if from_name_trimmed.contains('@') + && let Some(from_name_addr) = TypesTokenizer::new(from_name_trimmed) + .tokenize_numbers(false) + .tokenize_urls(false) + .tokenize_urls_without_scheme(false) + .tokenize_emails(true) + .filter_map(|t| match t.word { + TokenType::Email(email) => { + let email = Email::new(email); + email.is_valid().then_some(email) + } + _ => None, + }) + .next() + { + if (from_addr_is_valid + && from_name_addr.domain_part.sld != from_addr.domain_part.sld) + || (!env_from_empty + && ctx.output.env_from_addr.domain_part.sld + != from_name_addr.domain_part.sld) + || (env_from_empty + && ctx.output.ehlo_host.sld != from_name_addr.domain_part.sld) + { + ctx.result.add_tag("SPOOF_DISPLAY_NAME"); + } else { + ctx.result.add_tag("FROM_NEQ_DISPLAY_NAME"); + } + } + } + + // Check sender + if ctx.output.env_from_postmaster { + ctx.result.add_tag("FROM_BOUNCE"); + } + + if !env_from_empty && ctx.output.env_from_addr == *from_addr { + ctx.result.add_tag("FROM_EQ_ENV_FROM"); + } else if from_addr_is_valid { + if from_addr.domain_part.sld == ctx.output.ehlo_host.sld { + ctx.result.add_tag("FROMTLD_EQ_ENV_FROMTLD"); + } else if !ctx.output.env_from_postmaster { + ctx.result.add_tag("FORGED_SENDER"); + ctx.result.add_tag("FROM_NEQ_ENV_FROM"); + } + } + + // Validate FROM/TO relationship + if ctx.output.recipients_to.len() + ctx.output.recipients_cc.len() == 1 { + let rcpt = ctx + .output + .recipients_to + .first() + .or_else(|| ctx.output.recipients_cc.first()) + .unwrap(); + if rcpt.email == *from_addr { + ctx.result.add_tag("TO_EQ_FROM"); + } else if rcpt.email.domain_part.fqdn == from_addr.domain_part.fqdn { + ctx.result.add_tag("TO_DOM_EQ_FROM_DOM"); + } + } + + // Validate encoding + let from_raw_utf8 = std::str::from_utf8(from_raw); + if !from_raw.is_ascii() { + if (ctx.input.env_from_flags + & (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME)) + == 0 + { + ctx.result.add_tag("FROM_NEEDS_ENCODING"); + } + + if from_raw_utf8.is_err() { + ctx.result.add_tag("INVALID_FROM_8BIT"); + } + } + + // Validate unnecessary encoding + let from_raw_utf8 = from_raw_utf8.unwrap_or_default(); + if from_name.is_ascii() + && from_addr.address.is_ascii() + && from_raw_utf8.contains("=?") + && from_raw_utf8.contains("?=") + { + if from_raw_utf8.contains("?q?") || from_raw_utf8.contains("?Q?") { + // From header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("FROM_EXCESS_QP"); + } else if from_raw_utf8.contains("?b?") || from_raw_utf8.contains("?B?") { + // From header is unnecessarily encoded in base64 + ctx.result.add_tag("FROM_EXCESS_BASE64"); + } + } + + // Validate space in FROM + if !from_name.is_empty() + && !from_addr.address.is_empty() + && from_raw_utf8 + .as_bytes() + .iter() + .position(|&b| b == b'<') + .and_then(|v| from_raw_utf8.as_bytes().get(v - 1)) + .is_none_or(|v| !v.is_ascii_whitespace()) + { + ctx.result.add_tag("NO_SPACE_IN_FROM"); + } + + // Check whether read confirmation address is different to from address + if let Some(crt) = &crt + && crt != from_addr + { + ctx.result.add_tag("HEADER_RCONFIRM_MISMATCH"); + } + } + + if !env_from_empty { + // Validate envelope address + if ctx.output.env_from_addr.is_valid() { + // Mail from no resolve to A or MX + if matches!( + ( + self.dns_exists_ip(&ctx.output.env_from_addr.domain_part.fqdn) + .await, + self.dns_exists_mx(&ctx.output.env_from_addr.domain_part.fqdn) + .await + ), + (Ok(false), Ok(false)) + ) { + // Helo no resolve to A or MX + ctx.result.add_tag("FROMHOST_NORES_A_OR_MX"); + } + } else { + ctx.result.add_tag("ENV_FROM_INVALID"); + } + + // Check whether disposition notification address is different to return path + if let Some(dnt) = &dnt + && *dnt != ctx.output.env_from_addr + { + ctx.result.add_tag("HEADER_FORGED_MDN"); + } + } + } +} diff --git a/crates/spam-filter/src/analysis/headers.rs b/crates/spam-filter/src/analysis/headers.rs new file mode 100644 index 0000000..a0a4ef0 --- /dev/null +++ b/crates/spam-filter/src/analysis/headers.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::HeaderName; +use store::ahash::AHashSet; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeHeaders: Sync + Send { + fn spam_filter_analyze_headers( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeHeaders for Server { + async fn spam_filter_analyze_headers(&self, ctx: &mut SpamFilterContext<'_>) { + let mut list_score = 0.0; + let mut unique_headers = AHashSet::new(); + let raw_message = ctx.input.message.raw_message(); + + for header in ctx.input.message.headers() { + // Add header exists tag + let hdr_name = header.name(); + let mut tag: String = String::with_capacity(hdr_name.len() + 5); + tag.push_str("X_HDR_"); + for ch in hdr_name.chars() { + if ch.is_ascii_alphanumeric() { + tag.push(ch.to_ascii_uppercase()); + } else if ch == '-' { + tag.push('_'); + } else { + tag.push(' '); + } + } + ctx.result.add_tag(tag); + + match &header.name { + HeaderName::ContentType + | HeaderName::ContentTransferEncoding + | HeaderName::Date + | HeaderName::From + | HeaderName::Sender + | HeaderName::To + | HeaderName::Cc + | HeaderName::Bcc + | HeaderName::ReplyTo + | HeaderName::Subject + | HeaderName::MessageId + | HeaderName::References + | HeaderName::InReplyTo => { + if !unique_headers.insert(header.name.clone()) { + ctx.result.add_tag("MULTIPLE_UNIQUE_HEADERS"); + } + + let mut value = raw_message + .get(header.offset_start as usize..) + .unwrap_or_default() + .iter(); + loop { + match value.next() { + Some(b' ' | b'\t') => { + break; + } + Some(b'\r' | b'\n') => {} + _ => { + ctx.result.add_tag("HEADER_EMPTY_DELIMITER"); + break; + } + } + } + } + HeaderName::ListArchive + | HeaderName::ListOwner + | HeaderName::ListHelp + | HeaderName::ListPost => { + list_score += 0.125; + } + HeaderName::ListId => { + list_score += 0.5125; + } + HeaderName::ListSubscribe => { + list_score += 0.25; + } + HeaderName::ListUnsubscribe => { + list_score += 0.25; + ctx.result.add_tag("HAS_LIST_UNSUB"); + } + HeaderName::Other(name) => { + let value = header + .value() + .as_text() + .unwrap_or_default() + .trim() + .to_lowercase(); + + if name.eq_ignore_ascii_case("Precedence") { + if value == "bulk" { + list_score += 0.25; + ctx.result.add_tag("PRECEDENCE_BULK"); + } else if value == "list" { + list_score += 0.25; + } + } else if name.eq_ignore_ascii_case("X-Loop") { + list_score += 0.125; + } else if name.eq_ignore_ascii_case("X-Priority") { + match value.parse::().unwrap_or(i32::MAX) { + 0 => { + ctx.result.add_tag("HAS_X_PRIO_ZERO"); + } + 1 => { + ctx.result.add_tag("HAS_X_PRIO_ONE"); + } + 2 => { + ctx.result.add_tag("HAS_X_PRIO_TWO"); + } + 3 | 4 => { + ctx.result.add_tag("HAS_X_PRIO_THREE"); + } + 4..=10000 => { + ctx.result.add_tag("HAS_X_PRIO_FIVE"); + } + _ => {} + } + } + } + _ => {} + } + } + + if list_score >= 1.0 { + ctx.result.add_tag("MAILLIST"); + } + + if unique_headers.is_empty() { + ctx.result.add_tag("MISSING_ESSENTIAL_HEADERS"); + } + } +} diff --git a/crates/spam-filter/src/analysis/html.rs b/crates/spam-filter/src/analysis/html.rs new file mode 100644 index 0000000..c967817 --- /dev/null +++ b/crates/spam-filter/src/analysis/html.rs @@ -0,0 +1,339 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use hyper::Uri; +use mail_parser::MimeHeaders; +use nlp::tokenizers::types::{TokenType, TypesTokenizer}; + +use crate::{Hostname, SpamFilterContext, TextPart, modules::html::*}; + +pub trait SpamFilterAnalyzeHtml: Sync + Send { + fn spam_filter_analyze_html( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +#[derive(Debug)] +struct Href { + url_parsed: Option, + host: Option, +} + +impl SpamFilterAnalyzeHtml for Server { + async fn spam_filter_analyze_html(&self, ctx: &mut SpamFilterContext<'_>) { + // Message only has text/html MIME parts + if ctx.input.message.content_type().is_some_and(|ct| { + ct.ctype().eq_ignore_ascii_case("text") + && ct + .subtype() + .unwrap_or_default() + .eq_ignore_ascii_case("html") + }) { + ctx.result.add_tag("MIME_HTML_ONLY"); + } + + for (part_id, part) in ctx.output.text_parts.iter().enumerate() { + let part_id = part_id as u32; + let is_body_part = ctx.input.message.text_body.contains(&part_id) + || ctx.input.message.html_body.contains(&part_id); + + let (html_tokens, tokens) = if let TextPart::Html { + html_tokens, + tokens, + .. + } = part + { + (html_tokens, tokens) + } else { + continue; + }; + + let mut has_link_to_img = false; + let mut last_href: Option = None; + let mut html_img_words = 0; + let mut in_head: i32 = 0; + let mut in_body: i32 = 0; + + for token in html_tokens { + match token { + HtmlToken::StartTag { + name, + attributes, + is_self_closing, + } => match *name { + A => { + if let Some(attr) = attributes.iter().find_map(|(attr, value)| { + if *attr == HREF { + value.as_deref() + } else { + None + } + }) { + let url = attr.trim().to_lowercase(); + let url_parsed = url.parse::().ok(); + let href = Href { + host: url_parsed + .as_ref() + .and_then(|uri| uri.host().map(Hostname::new)), + url_parsed, + }; + + if is_body_part + && attr.starts_with("data:") + && attr.contains(";base64,") + { + // Has Data URI encoding + ctx.result.add_tag("HAS_DATA_URI"); + if attr.contains("text/") { + // Uses Data URI encoding to obfuscate plain or HTML in base64 + ctx.result.add_tag("DATA_URI_OBFU"); + } + } else if href.host.as_ref().is_some_and(|h| h.ip.is_some()) { + // HTML anchor points to an IP address + ctx.result.add_tag("HTTP_TO_IP"); + } + + if !*is_self_closing { + last_href = Some(href); + } + } + } + IMG if is_body_part => { + let mut img_width = 800; + let mut img_height = 600; + + for (attr, value) in attributes { + if let Some(value) = + value.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty()) + { + let dimension = match *attr { + WIDTH => &mut img_width, + HEIGHT => &mut img_height, + SRC => { + let src = value.to_ascii_lowercase(); + if src.starts_with("data:") && src.contains(";base64,") + { + // Has Data URI encoding + ctx.result.add_tag("HAS_DATA_URI"); + } else if src.starts_with("https://") + || src.starts_with("http://") + { + // Has external image + ctx.result.add_tag("HAS_EXTERNAL_IMG"); + } + continue; + } + _ => { + continue; + } + }; + if let Some(pct) = value.strip_suffix('%') { + if let Ok(pct) = pct.trim().parse::() { + *dimension = (*dimension * pct) / 100; + } + } else if let Ok(value) = value.parse::() { + *dimension = value; + } + } + } + let dimensions = img_width + img_height; + + if last_href.is_some() { + if dimensions >= 210 { + ctx.result.add_tag("HAS_LINK_TO_LARGE_IMG"); + has_link_to_img = true; + } else { + ctx.result.add_tag("HAS_LINK_TO_IMG"); + } + } + + if dimensions > 100 { + // We assume that a single picture 100x200 contains approx 3 words of text + html_img_words += dimensions / 100; + } + } + META => { + let mut has_equiv_refresh = false; + let mut has_content_url = false; + + for (attr, value) in attributes { + if let Some(value) = + value.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty()) + { + if *attr == HTTP_EQUIV { + if value.eq_ignore_ascii_case("refresh") { + has_equiv_refresh = true; + } + } else if *attr == CONTENT + && value.to_ascii_lowercase().contains("url=") + { + has_content_url = true; + } + } + } + + if has_equiv_refresh && has_content_url { + // HTML meta refresh tag + ctx.result.add_tag("HTML_META_REFRESH_URL"); + } + } + LINK if is_body_part => { + let mut has_rel_style = false; + let mut has_href_css = false; + + for (attr, value) in attributes { + if let Some(value) = + value.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty()) + { + if *attr == REL { + if value.to_ascii_lowercase().contains("stylesheet") { + has_rel_style = true; + } + } else if *attr == HREF + && value.to_ascii_lowercase().contains(".css") + { + has_href_css = true; + } + } + } + + if has_rel_style || has_href_css { + // Has external CSS + ctx.result.add_tag("EXT_CSS"); + } + } + HEAD if !*is_self_closing => { + in_head += 1; + } + BODY if !*is_self_closing => { + in_body += 1; + } + _ => {} + }, + HtmlToken::EndTag { name } => match *name { + A => { + last_href = None; + } + HEAD => { + in_head -= 1; + } + BODY => { + in_body -= 1; + } + _ => (), + }, + HtmlToken::Text { text } if in_head == 0 => { + if let Some((href_url, href_host)) = last_href + .as_ref() + .and_then(|href| Some((href.url_parsed.as_ref()?, href.host.as_ref()?))) + { + for token in TypesTokenizer::new(text.as_ref()) + .tokenize_numbers(false) + .tokenize_urls(true) + .tokenize_urls_without_scheme(true) + .tokenize_emails(true) + { + let text_url = match token.word { + TokenType::Url(url) => url.to_lowercase(), + TokenType::UrlNoScheme(url) => { + format!("http://{}", url.to_lowercase()) + } + _ => continue, + }; + let text_url_parsed = + if let Ok(text_url_parsed) = text_url.parse::() { + text_url_parsed + } else { + continue; + }; + + if href_url.scheme().map(|s| s.as_str()).unwrap_or_default() + == "http" + && text_url_parsed + .scheme() + .map(|s| s.as_str()) + .unwrap_or_default() + == "https" + { + // The anchor text contains a distinct scheme compared to the target URL + ctx.result.add_tag("HTTP_TO_HTTPS"); + } + + if let Some(text_url_host) = text_url_parsed.host() { + let text_url_host = Hostname::new(text_url_host); + + if text_url_host.sld_or_default() != href_host.sld_or_default() + { + // The anchor text contains a different domain than the target URL + ctx.result.add_tag("PHISHING"); + } + } + } + } + } + _ => (), + } + } + + if is_body_part { + if in_head != 0 || in_body != 0 { + // HTML tags are not properly closed + ctx.result.add_tag("HTML_UNBALANCED_TAG"); + } + + let mut html_words = 0; + let mut html_uris = 0; + let mut html_text_chars = 0; + + for token in tokens { + match token { + TokenType::Alphabetic(s) | TokenType::Alphanumeric(s) => { + html_words += 1; + html_text_chars += s.len(); + } + TokenType::Email(s) => { + html_words += 1; + html_text_chars += s.address.len(); + } + TokenType::Url(_) | TokenType::UrlNoScheme(_) => { + html_uris += 1; + } + _ => (), + } + } + + match html_text_chars { + 0..1024 => { + ctx.result.add_tag("HTML_SHORT_1"); + } + 1024..1536 => { + ctx.result.add_tag("HTML_SHORT_2"); + } + 1536..2048 => { + ctx.result.add_tag("HTML_SHORT_3"); + } + _ => (), + } + + if (!has_link_to_img || html_text_chars >= 2048) + && (html_img_words as f64 / (html_words as f64 + html_img_words as f64) > 0.5) + { + // Message contains more images than text + ctx.result.add_tag("HTML_TEXT_IMG_RATIO"); + } + + if html_uris > 0 && html_words == 0 { + // Message only contains URIs in HTML + ctx.result.add_tag("BODY_URI_ONLY"); + } + } + } + } +} diff --git a/crates/spam-filter/src/analysis/init.rs b/crates/spam-filter/src/analysis/init.rs new file mode 100644 index 0000000..0bdfe8d --- /dev/null +++ b/crates/spam-filter/src/analysis/init.rs @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::Server; + +use mail_auth::DmarcResult; +use mail_parser::{HeaderName, PartType, parsers::fields::thread::thread_name}; +use nlp::tokenizers::types::{TokenType, TypesTokenizer}; + +use crate::{ + Email, Hostname, IpParts, Recipient, SpamFilterContext, SpamFilterInput, SpamFilterOutput, + SpamFilterResult, TextPart, + modules::html::{HEAD, HtmlToken, html_to_tokens}, +}; + +use super::url::UrlParts; + +pub trait SpamFilterInit { + fn spam_filter_init<'x>(&self, input: SpamFilterInput<'x>) -> SpamFilterContext<'x>; +} + +const POSTMASTER_ADDRESSES: [&str; 3] = ["postmaster", "mailer-daemon", "root"]; + +impl SpamFilterInit for Server { + fn spam_filter_init<'x>(&self, mut input: SpamFilterInput<'x>) -> SpamFilterContext<'x> { + let mut subject = ""; + let mut from = None; + let mut reply_to = None; + let mut recipients_to = Vec::new(); + let mut recipients_cc = Vec::new(); + let mut recipients_bcc = Vec::new(); + let mut found_spam_status = false; + + for header in input.message.headers() { + match &header.name { + HeaderName::To | HeaderName::Cc | HeaderName::Bcc => { + if let Some(addrs) = header.value().as_address() { + for addr in addrs.iter() { + let rcpt = Recipient { + email: Email::new(addr.address().unwrap_or_default()), + name: addr.name().and_then(|s| { + let s = s.trim(); + if !s.is_empty() { + Some(s.to_lowercase()) + } else { + None + } + }), + }; + if header.name == HeaderName::To { + recipients_to.push(rcpt); + } else if header.name == HeaderName::Cc { + recipients_cc.push(rcpt); + } else { + recipients_bcc.push(rcpt); + } + } + } + } + HeaderName::ReplyTo => { + reply_to = header + .value() + .as_address() + .and_then(|addrs| addrs.first()) + .and_then(|addr| { + Some(Recipient { + email: Email::new(addr.address()?), + name: addr.name().and_then(|s| { + let s = s.trim(); + if !s.is_empty() { + Some(s.to_lowercase()) + } else { + None + } + }), + }) + }); + } + HeaderName::Subject => { + subject = header.value().as_text().unwrap_or_default(); + } + HeaderName::From => { + from = header.value().as_address().and_then(|addrs| addrs.first()); + } + HeaderName::Other(name) + if input.is_train && !found_spam_status && name.eq("X-Spam-Result") => + { + for token in header + .value() + .as_text() + .unwrap_or_default() + .split_ascii_whitespace() + { + if let Some(dmarc) = token.strip_prefix("DMARC_") { + input.dmarc_result = if dmarc == "POLICY_ALLOW" { + Some(&DmarcResult::Pass) + } else { + Some(&DmarcResult::None) + }; + } else if let Some(asn) = token + .strip_prefix("SOURCE_ASN_") + .and_then(|v| v.parse().ok()) + { + input.asn = Some(asn); + } + } + + found_spam_status = true; + } + _ => {} + } + } + + // Tokenize subject + let subject_tokens = TypesTokenizer::new(subject) + .tokenize_numbers(false) + .tokenize_urls(true) + .tokenize_urls_without_scheme(true) + .tokenize_emails(true) + .map(|t| match t.word { + TokenType::Alphabetic(s) => TokenType::Alphabetic(s.into()), + TokenType::Alphanumeric(s) => TokenType::Alphanumeric(s.into()), + TokenType::Integer(s) => TokenType::Integer(s.into()), + TokenType::Other(s) => TokenType::Other(s), + TokenType::Punctuation(s) => TokenType::Punctuation(s), + TokenType::Space => TokenType::Space, + TokenType::Url(url) => TokenType::Url(UrlParts::new(url)), + TokenType::UrlNoHost(s) => TokenType::UrlNoHost(s.into()), + TokenType::UrlNoScheme(s) => TokenType::UrlNoScheme(UrlParts::no_scheme(s)), + TokenType::IpAddr(i) => TokenType::IpAddr(IpParts::new(i)), + TokenType::Email(e) => TokenType::Email(Email::new(e)), + TokenType::Float(s) => TokenType::Float(s.into()), + }) + .collect::>(); + + // Tokenize and convert text parts + let mut text_parts = Vec::new(); + let mut text_parts_nested = Vec::new(); + let mut message_stack = Vec::new(); + let mut message_iter = input.message.parts.iter(); + + loop { + while let Some(part) = message_iter.next() { + let is_main_message = message_stack.is_empty(); + let text_part = match &part.body { + PartType::Text(text) => TextPart::Plain { + text_body: text.as_ref(), + tokens: TypesTokenizer::new(text.as_ref()) + .tokenize_numbers(false) + .tokenize_urls(true) + .tokenize_urls_without_scheme(true) + .tokenize_emails(true) + .map(|t| match t.word { + TokenType::Alphabetic(s) => TokenType::Alphabetic(s.into()), + TokenType::Alphanumeric(s) => TokenType::Alphanumeric(s.into()), + TokenType::Integer(s) => TokenType::Integer(s.into()), + TokenType::Other(s) => TokenType::Other(s), + TokenType::Punctuation(s) => TokenType::Punctuation(s), + TokenType::Space => TokenType::Space, + TokenType::Url(url) => TokenType::Url(UrlParts::new(url)), + TokenType::UrlNoHost(s) => TokenType::UrlNoHost(s.into()), + TokenType::UrlNoScheme(s) => { + TokenType::UrlNoScheme(UrlParts::no_scheme(s)) + } + TokenType::IpAddr(i) => TokenType::IpAddr(IpParts::new(i)), + TokenType::Email(e) => TokenType::Email(Email::new(e)), + TokenType::Float(s) => TokenType::Float(s.into()), + }) + .collect::>(), + }, + PartType::Html(html) => { + let html_tokens = html_to_tokens(html); + let text_body_len = html_tokens + .iter() + .filter_map(|t| match t { + HtmlToken::Text { text } => text.len().into(), + _ => None, + }) + .sum(); + let mut text_body = String::with_capacity(text_body_len); + let mut in_head = false; + for token in &html_tokens { + match token { + HtmlToken::StartTag { name: HEAD, .. } => { + in_head = true; + } + HtmlToken::EndTag { name: HEAD } => { + in_head = false; + } + HtmlToken::Text { text } if !in_head => { + if !text_body.is_empty() + && !text_body.ends_with(' ') + && !text.starts_with(' ') + { + text_body.push(' '); + } + text_body.push_str(text) + } + _ => {} + } + } + + TextPart::Html { + tokens: TypesTokenizer::new(&text_body) + .tokenize_numbers(false) + .tokenize_urls(true) + .tokenize_urls_without_scheme(true) + .tokenize_emails(true) + .map(|t| match t.word { + TokenType::Alphabetic(s) => { + TokenType::Alphabetic(s.to_string().into()) + } + TokenType::Alphanumeric(s) => { + TokenType::Alphanumeric(s.to_string().into()) + } + TokenType::Integer(s) => { + TokenType::Integer(s.to_string().into()) + } + TokenType::Other(s) => TokenType::Other(s), + TokenType::Punctuation(s) => TokenType::Punctuation(s), + TokenType::Space => TokenType::Space, + TokenType::Url(url) => { + TokenType::Url(UrlParts::new(url.to_string())) + } + TokenType::UrlNoHost(s) => { + TokenType::UrlNoHost(s.to_string().into()) + } + TokenType::UrlNoScheme(s) => { + TokenType::UrlNoScheme(UrlParts::no_scheme(s.to_string())) + } + TokenType::IpAddr(i) => TokenType::IpAddr(IpParts::new(i)), + TokenType::Email(e) => TokenType::Email(Email::new(e)), + TokenType::Float(s) => TokenType::Float(s.to_string().into()), + }) + .collect::>(), + html_tokens, + text_body, + } + } + PartType::Message(message) => { + message_stack.push(message_iter); + message_iter = message.parts.iter(); + TextPart::None + } + _ => TextPart::None, + }; + + if is_main_message { + text_parts.push(text_part); + } else if !matches!(text_part, TextPart::None) { + text_parts_nested.push(text_part); + } + } + + if let Some(iter) = message_stack.pop() { + message_iter = iter; + } else { + break; + } + } + text_parts.extend(text_parts_nested); + + let subject_thread = thread_name(subject).to_string(); + let env_from_addr = Email::new(input.env_from); + SpamFilterContext { + output: SpamFilterOutput { + ehlo_host: Hostname::new(input.ehlo_domain.unwrap_or("unknown")), + iprev_ptr: input.iprev_result.and_then(|r| { + r.ptr + .as_ref() + .and_then(|ptr| ptr.first()) + .map(|ptr| (ptr.strip_suffix('.').unwrap_or(ptr)).to_lowercase()) + }), + env_from_postmaster: env_from_addr.address.is_empty() + || POSTMASTER_ADDRESSES.contains(&env_from_addr.local_part.as_str()), + env_from_addr, + env_to_orig_addr: input + .env_rcpt_orig_to + .iter() + .map(|rcpt| Email::new(rcpt)) + .collect(), + env_to_rewritten_addr: input + .env_rcpt_rewritten_to + .iter() + .map(|rcpt| Email::new(rcpt)) + .collect(), + from: Recipient { + email: Email::new(from.and_then(|f| f.address()).unwrap_or_default()), + name: from.and_then(|f| f.name()).map(|name| name.to_lowercase()), + }, + reply_to, + subject_thread_lc: subject_thread.trim().to_lowercase(), + subject_thread, + subject_lc: subject.trim().to_lowercase(), + subject: subject.to_string(), + subject_tokens, + recipients_to, + recipients_cc, + recipients_bcc, + text_parts, + ips: Default::default(), + emails: Default::default(), + urls: Default::default(), + domains: Default::default(), + }, + input, + result: SpamFilterResult::default(), + } + } +} diff --git a/crates/spam-filter/src/analysis/ip.rs b/crates/spam-filter/src/analysis/ip.rs new file mode 100644 index 0000000..1ae312c --- /dev/null +++ b/crates/spam-filter/src/analysis/ip.rs @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::ElementLocation; +use crate::{IpParts, SpamFilterContext, TextPart, modules::dnsbl::check_dnsbl}; +use common::{ + Server, + config::mailstore::spamfilter::{Element, IpResolver, Location}, +}; +use mail_auth::IprevResult; +use mail_parser::{HeaderName, HeaderValue, Host}; +use nlp::tokenizers::types::TokenType; +use std::future::Future; +use store::ahash::AHashSet; + +pub trait SpamFilterAnalyzeIp: Sync + Send { + fn spam_filter_analyze_ip( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeIp for Server { + async fn spam_filter_analyze_ip(&self, ctx: &mut SpamFilterContext<'_>) { + // IP Address RBL + let mut ips = AHashSet::new(); + + ips.insert(ElementLocation::new(ctx.input.remote_ip, Location::Tcp)); + + // Obtain IP addresses from Received headers + for header in ctx.input.message.headers() { + if let (HeaderName::Received, HeaderValue::Received(received)) = + (&header.name, &header.value) + { + if let Some(ip) = received.from_ip() + && !ip.is_loopback() + && !self.is_ip_allowed(ip) + { + ips.insert(ElementLocation::new(ip, Location::HeaderReceived)); + } + for host in [&received.from, &received.helo, &received.by] + .into_iter() + .flatten() + { + if let Host::IpAddr(ip) = host + && !ip.is_loopback() + && !self.is_ip_allowed(*ip) + { + ips.insert(ElementLocation::new(*ip, Location::HeaderReceived)); + } + } + } + } + + // Obtain IP addresses from the message body + for (part_id, part) in ctx.output.text_parts.iter().enumerate() { + let part_id = part_id as u32; + let is_body = ctx.input.message.text_body.contains(&part_id) + || ctx.input.message.html_body.contains(&part_id); + match part { + TextPart::Plain { tokens, .. } | TextPart::Html { tokens, .. } => { + ips.extend(tokens.iter().filter_map(|t| { + if let TokenType::IpAddr(ip) = t { + ip.ip.map(|ip| { + ElementLocation::new( + ip, + if is_body { + Location::BodyText + } else { + Location::Attachment + }, + ) + }) + } else { + None + } + })) + } + + TextPart::None => (), + } + } + + // Validate IP addresses + for ip in &ips { + if ip.element.is_loopback() + || ip.element.is_multicast() + || ip.element.is_unspecified() + || self.is_ip_allowed(ip.element) + { + continue; + } else if self.is_ip_blocked(ip.element) { + ctx.result.add_tag("IP_BLOCKED"); + continue; + } + + check_dnsbl( + self, + ctx, + &IpResolver::new(ip.element), + Element::Ip, + ip.location, + ) + .await; + } + ctx.output.ips = ips; + + // Reverse DNS validation + if let Some(iprev) = ctx.input.iprev_result { + match &iprev.result { + IprevResult::TempError(_) => ctx.result.add_tag("RDNS_DNSFAIL"), + IprevResult::Fail(_) | IprevResult::PermError(_) => ctx.result.add_tag("RDNS_NONE"), + IprevResult::Pass | IprevResult::None => (), + } + } + + // Add ASN + if let Some(asn_id) = &ctx.input.asn { + ctx.result.add_tag(format!("SOURCE_ASN_{asn_id}")); + } + } +} + +impl IpParts { + pub fn new(text: &str) -> IpParts { + IpParts { + ip: text.parse().ok(), + } + } +} diff --git a/crates/spam-filter/src/analysis/messageid.rs b/crates/spam-filter/src/analysis/messageid.rs new file mode 100644 index 0000000..6e7a5d9 --- /dev/null +++ b/crates/spam-filter/src/analysis/messageid.rs @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::HeaderName; + +use crate::{Hostname, SpamFilterContext}; + +pub trait SpamFilterAnalyzeMid: Sync + Send { + fn spam_filter_analyze_message_id( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeMid for Server { + async fn spam_filter_analyze_message_id(&self, ctx: &mut SpamFilterContext<'_>) { + let mut mid = ""; + let mut mid_raw = ""; + + for header in ctx.input.message.headers() { + if let (HeaderName::MessageId, value) = (&header.name, &header.value) { + mid = value.as_text().unwrap_or_default(); + mid_raw = std::str::from_utf8( + &ctx.input.message.raw_message() + [header.offset_start as usize..header.offset_end as usize], + ) + .unwrap_or_default() + .trim(); + break; + } + } + + if !mid.is_empty() { + let mid = mid.to_lowercase(); + if let Some(mid_host) = mid.rsplit_once('@').map(|(_, host)| Hostname::new(host)) { + if mid_host.ip.is_some() { + if mid_host.fqdn.starts_with('[') { + ctx.result.add_tag("MID_RHS_IP_LITERAL"); + } else { + ctx.result.add_tag("MID_BARE_IP"); + } + } else if !mid_host.fqdn.contains('.') { + ctx.result.add_tag("MID_RHS_NOT_FQDN"); + } else if mid_host.fqdn.starts_with("www.") { + ctx.result.add_tag("MID_RHS_WWW"); + } + + if !mid_raw.is_ascii() || mid_raw.contains('(') || mid.starts_with('@') { + ctx.result.add_tag("INVALID_MSGID"); + } + + if mid_host.fqdn.len() > 255 { + ctx.result.add_tag("MID_RHS_TOO_LONG"); + } + + // From address present in Message-ID checks + for (part, sender) in [ + ("FROM", &ctx.output.from.email), + ("ENV_FROM", &ctx.output.env_from_addr), + ] { + if !sender.address.is_empty() { + if mid.contains(sender.address.as_str()) { + ctx.result.add_tag(format!("MID_CONTAINS_{part}")); + } else if mid_host.fqdn == sender.domain_part.fqdn { + ctx.result.add_tag(format!("MID_RHS_MATCH_{part}")); + } else if matches!((&mid_host.sld, &sender.domain_part.sld), (Some(mid_sld), Some(sender_sld)) if mid_sld == sender_sld) + { + ctx.result.add_tag(format!("MID_RHS_MATCH_{part}TLD")); + } + } + } + + // To/Cc addresses present in Message-ID checks + for rcpt in ctx.output.all_recipients() { + if mid.contains(rcpt.email.address.as_str()) { + ctx.result.add_tag("MID_CONTAINS_TO"); + } else if mid_host.fqdn == rcpt.email.domain_part.fqdn { + ctx.result.add_tag("MID_RHS_MATCH_TO"); + } + } + } else { + ctx.result.add_tag("INVALID_MSGID"); + } + + if !mid_raw.starts_with('<') || !mid_raw.contains('>') { + ctx.result.add_tag("MID_MISSING_BRACKETS"); + } + } else { + ctx.result.add_tag("MISSING_MID"); + } + } +} diff --git a/crates/spam-filter/src/analysis/mime.rs b/crates/spam-filter/src/analysis/mime.rs new file mode 100644 index 0000000..f6dc0d6 --- /dev/null +++ b/crates/spam-filter/src/analysis/mime.rs @@ -0,0 +1,442 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{collections::HashSet, future::Future, vec}; + +use common::{ + Server, + scripts::{ + IsMixedCharset, + functions::{array::cosine_similarity, unicode::CharUtils}, + }, +}; +use mail_parser::{HeaderName, MimeHeaders, PartType}; +use nlp::tokenizers::types::TokenType; + +use super::mime_types::{MimeMatch, mime_match}; +use crate::{SpamFilterContext, TextPart}; + +pub trait SpamFilterAnalyzeMime: Sync + Send { + fn spam_filter_analyze_mime( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeMime for Server { + async fn spam_filter_analyze_mime(&self, ctx: &mut SpamFilterContext<'_>) { + let mut has_mime_version = false; + let mut has_ct = false; + let mut has_cte = false; + let mut had_cd = false; + let mut is_plain_text = false; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::MimeVersion => { + if ctx + .input + .message + .raw_message() + .get(header.offset_field as usize..header.offset_start as usize - 1) + != Some(b"MIME-Version") + { + ctx.result.add_tag("MV_CASE"); + } + has_mime_version = true; + } + HeaderName::ContentType => { + has_ct = true; + + if let Some(ct) = header.value().as_content_type() { + if ct.ctype().eq_ignore_ascii_case("multipart") + && ct + .subtype() + .is_some_and(|s| s.eq_ignore_ascii_case("report")) + && ct.attribute("report-type").is_some_and(|a| { + a.eq_ignore_ascii_case("delivery-status") + || a.eq_ignore_ascii_case("disposition-notification") + }) + { + // Message is a DSN + ctx.result.add_tag("IS_DSN"); + } + + is_plain_text = ct.ctype().eq_ignore_ascii_case("text") + && ct + .subtype() + .unwrap_or_default() + .eq_ignore_ascii_case("plain"); + } + } + HeaderName::ContentTransferEncoding => { + has_cte = true; + } + HeaderName::ContentDisposition => { + had_cd = true; + } + _ => (), + } + } + + if !has_mime_version && (has_ct || has_cte) { + ctx.result.add_tag("MISSING_MIME_VERSION"); + } + if has_ct && !is_plain_text && !has_cte && !had_cd && !has_mime_version { + // Only Content-Type header without other MIME headers + ctx.result.add_tag("MIME_HEADER_CTYPE_ONLY"); + } + let raw_message = ctx.input.message.raw_message(); + + let mut has_text_part = false; + let mut is_encrypted = false; + let mut is_encrypted_smime = false; + let mut is_encrypted_pgp = false; + + let mut num_parts = 0; + let mut num_parts_size = 0; + + for (part_id, part) in ctx.input.message.parts.iter().enumerate() { + let part_id = part_id as u32; + let mut ct = None; + let mut cd = None; + let mut ct_type = String::new(); + let mut ct_subtype = String::new(); + let mut cte = String::new(); + let mut is_attachment = ctx.input.message.attachments.contains(&part_id); + let mut has_content_id = false; + + for header in part.headers() { + match &header.name { + HeaderName::ContentType => { + if let Some(ct_) = header.value().as_content_type() { + ct_type = ct_.ctype().to_ascii_lowercase(); + ct_subtype = ct_.subtype().unwrap_or_default().to_ascii_lowercase(); + ct = Some(ct_); + } + + if ct_type.is_empty() { + // Content-Type header can't be parsed + ctx.result.add_tag("BROKEN_CONTENT_TYPE"); + } else if (ct_type == "message" && ct_subtype == "rfc822") + || (ct_type == "text" && ct_subtype == "rfc822-headers") + { + // Message has parts + ctx.result.add_tag("HAS_MESSAGE_PARTS"); + } + + if raw_message + .get(header.offset_start as usize..header.offset_end as usize) + .and_then(|s| s.trim_ascii_end().last()) + == Some(&b';') + { + // Content-Type header ends with a semi-colon + ctx.result.add_tag("CT_EXTRA_SEMI"); + } + } + HeaderName::ContentTransferEncoding => { + let cte_ = header.value().as_text().unwrap_or_default(); + cte = cte_.to_ascii_lowercase(); + + if cte != cte_ { + ctx.result.add_tag("CTE_CASE"); + } + } + HeaderName::ContentDisposition => { + cd = header.value().as_content_type(); + } + HeaderName::ContentId => { + has_content_id = true; + } + _ => (), + } + } + + match ct_type.as_str() { + "multipart" => { + let part_ids = match &part.body { + PartType::Multipart(parts) => parts.as_slice(), + _ => &[], + }; + + match ct_subtype.as_str() { + "alternative" => { + let mut has_plain_part = false; + let mut has_html_part = false; + + let mut text_part_words = vec![]; + let mut text_part_uris = 0; + + let mut html_part_words = vec![]; + let mut html_part_uris = 0; + + for text_part in part_ids + .iter() + .map(|id| &ctx.output.text_parts[*id as usize]) + { + let (tokens, words, uri_count) = match text_part { + TextPart::Plain { tokens, .. } if !has_plain_part => { + has_plain_part = true; + (tokens, &mut text_part_words, &mut text_part_uris) + } + TextPart::Html { tokens, .. } if !has_html_part => { + has_html_part = true; + (tokens, &mut html_part_words, &mut html_part_uris) + } + _ => continue, + }; + + let mut uris = HashSet::new(); + for token in tokens { + match token { + TokenType::Alphabetic(v) | TokenType::Alphanumeric(v) => { + words.push(v.as_ref()); + } + TokenType::Url(v) => { + if let Some(host) = + v.url_parsed.as_ref().map(|uri| &uri.host) + { + uris.insert(host.sld_or_default()); + } + } + _ => (), + } + } + + *uri_count = uris.len(); + } + + // Multipart message mostly text/html MIME + if has_html_part { + if !has_plain_part { + ctx.result.add_tag("MIME_MA_MISSING_TEXT"); + } + } else if has_plain_part { + ctx.result.add_tag("MIME_MA_MISSING_HTML"); + } + + // HTML and text parts are different + if has_plain_part + && has_html_part + && (!text_part_words.is_empty() || !html_part_words.is_empty()) + && cosine_similarity(&text_part_words, &html_part_words) < 0.95 + { + ctx.result.add_tag("PARTS_DIFFER"); + } + + // Odd URI count between parts + if text_part_uris != html_part_uris { + ctx.result.add_tag("URI_COUNT_ODD"); + } + } + "mixed" => { + let mut num_text_parts = 0; + let mut has_other_parts = false; + + for (sub_part_id, sub_part) in part_ids + .iter() + .map(|id| (*id, &ctx.input.message.parts[*id as usize])) + { + let ctype = sub_part + .content_type() + .map(|ct| ct.ctype()) + .unwrap_or_default(); + + if ctype.eq_ignore_ascii_case("text") + && !ctx.input.message.attachments.contains(&sub_part_id) + { + num_text_parts += 1; + } else if !ctype.eq_ignore_ascii_case("multipart") { + has_other_parts = true; + } + } + + // Found multipart/mixed without non-textual part + if !has_other_parts && num_text_parts < 3 { + ctx.result.add_tag("CTYPE_MIXED_BOGUS"); + } + } + "encrypted" => { + is_encrypted = true; + } + _ => (), + } + + continue; + } + "text" => { + let mut is_7bit = false; + match cte.as_str() { + "" | "7bit" => { + if raw_message + .get( + part.raw_body_offset() as usize..part.raw_end_offset() as usize, + ) + .is_some_and(|bytes| !bytes.is_ascii()) + { + // MIME text part claims to be ASCII but isn't + ctx.result.add_tag("BAD_CTE_7BIT"); + } + is_7bit = true; + } + "base64" => { + if part.contents().is_ascii() { + // Has text part encoded in base64 that does not contain any 8bit characters + ctx.result.add_tag("MIME_BASE64_TEXT_BOGUS"); + } else { + // Has text part encoded in base64 + ctx.result.add_tag("MIME_BASE64_TEXT"); + } + } + _ => (), + } + + if !is_7bit + && ct_subtype == "plain" + && ct + .and_then(|ct| ct.attribute("charset")) + .is_none_or(|c| c.is_empty()) + { + // Charset header is missing + ctx.result.add_tag("MISSING_CHARSET"); + } + + if ctx + .output + .text_parts + .get(part_id as usize) + .filter(|_| { + ctx.input.message.text_body.contains(&part_id) + || ctx.input.message.html_body.contains(&part_id) + }) + .is_some_and(|p| match p { + TextPart::Plain { text_body, .. } => text_body.is_mixed_charset(), + TextPart::Html { text_body, .. } => text_body.is_mixed_charset(), + TextPart::None => false, + }) + { + // Text part contains multiple scripts + ctx.result.add_tag("MIXED_CHARSET"); + } + + has_text_part = true; + } + "application" => match ct_subtype.as_str() { + "pkcs7-mime" => { + ctx.result.add_tag("ENCRYPTED_SMIME"); + is_attachment = false; + is_encrypted_smime = true; + } + "pkcs7-signature" => { + ctx.result.add_tag("SIGNED_SMIME"); + is_attachment = false; + } + "pgp-encrypted" => { + ctx.result.add_tag("ENCRYPTED_PGP"); + is_attachment = false; + is_encrypted_pgp = true; + } + "pgp-signature" => { + ctx.result.add_tag("SIGNED_PGP"); + is_attachment = false; + } + "octet-stream" + if !is_encrypted + && !has_content_id + && cd.is_none_or(|cd| { + !cd.c_type.eq_ignore_ascii_case("attachment") + && !cd.has_attribute("filename") + }) => + { + ctx.result.add_tag("CTYPE_MISSING_DISPOSITION"); + } + _ => (), + }, + _ => (), + } + + num_parts += 1; + num_parts_size += part.len(); + + let ct_full = format!("{ct_type}/{ct_subtype}"); + + if is_attachment { + // Has a MIME attachment + ctx.result.add_tag("HAS_ATTACHMENT"); + if ct_full != "application/octet-stream" + && let Some(t) = infer::get(part.contents()) + { + match mime_match(t.mime_type(), &ct_full) { + MimeMatch::Equal => { + // Known content-type + ctx.result.add_tag("MIME_GOOD"); + } + MimeMatch::Mismatch => { + // Known bad content-type + ctx.result.add_tag("MIME_BAD"); + } + MimeMatch::Compatible => (), + } + } + } + + // Analyze attachment name + if let Some(attach_name) = part.attachment_name() { + if attach_name.chars().any(|c| c.is_obscured()) { + // Attachment name contains zero-width space + ctx.result.add_tag("MIME_BAD_UNICODE"); + } + let attach_name = attach_name.trim().to_lowercase(); + if let Some((name, ext)) = attach_name.rsplit_once('.').and_then(|(name, ext)| { + Some((name, self.core.spam.lists.file_extensions.get(ext)?)) + }) { + let sub_ext = name + .rsplit_once('.') + .and_then(|(_, ext)| self.core.spam.lists.file_extensions.get(ext)); + + if ext.is_bad { + // Attachment has a bad extension + if sub_ext.is_some_and(|e| e.is_bad) { + ctx.result.add_tag("MIME_DOUBLE_BAD_EXTENSION"); + } else { + ctx.result.add_tag("MIME_BAD_EXTENSION"); + } + } + + if ext.is_archive && sub_ext.is_some_and(|e| e.is_archive) { + // Archive in archive + ctx.result.add_tag("MIME_ARCHIVE_IN_ARCHIVE"); + } + + if !ext.known_types.is_empty() + && ct_full != "application/octet-stream" + && !ext.known_types.contains(&ct_full) + { + // Invalid attachment mime type + ctx.result.add_tag("MIME_BAD_ATTACHMENT"); + } + } + } + } + + match num_parts_size { + 0 => { + // Message contains no parts + ctx.result.add_tag("COMPLETELY_EMPTY"); + } + 1..64 if num_parts == 1 => { + // Message contains only one short part + ctx.result.add_tag("SINGLE_SHORT_PART"); + } + _ => (), + } + + if has_text_part && (is_encrypted_pgp || is_encrypted_smime) { + // Message contains both text and encrypted parts + ctx.result.add_tag("BOGUS_ENCRYPTED_AND_TEXT"); + } + } +} diff --git a/crates/spam-filter/src/analysis/mime_types.rs b/crates/spam-filter/src/analysis/mime_types.rs new file mode 100644 index 0000000..a6cf4c0 --- /dev/null +++ b/crates/spam-filter/src/analysis/mime_types.rs @@ -0,0 +1,660 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub(super) enum MimeMatch { + Equal, + Compatible, + Mismatch, +} + +struct MimeType { + alias_of: u16, + container: u64, + contained_in: u64, +} + +pub(super) fn mime_match(detected: &str, declared: &str) -> MimeMatch { + if detected == declared { + return MimeMatch::Equal; + } + + match (lookup(detected), lookup(declared)) { + (Some(detected), Some(declared)) => { + if detected.alias_of != 0 && detected.alias_of == declared.alias_of { + MimeMatch::Equal + } else if detected.container & declared.contained_in != 0 { + MimeMatch::Compatible + } else { + MimeMatch::Mismatch + } + } + _ => MimeMatch::Mismatch, + } +} + +fn lookup(mime: &str) -> Option<&'static MimeType> { + hashify::map!( + mime.as_bytes(), + MimeType, + "application/acad" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/acrobat" => MimeType { alias_of: 5, container: 0x0, contained_in: 0x0 }, + "application/appinstaller" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/appx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/appxbundle" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/atom+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/autocad_dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/bizagi-modeler" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/bzip2" => MimeType { alias_of: 17, container: 0x0, contained_in: 0x2000 }, + "application/dash+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/dif+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/dita+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/doc" => MimeType { alias_of: 4, container: 0x0, contained_in: 0x40000 }, + "application/docbook+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/epub+zip" => MimeType { alias_of: 0, container: 0x1, contained_in: 0x800000 }, + "application/font-woff" => MimeType { alias_of: 1, container: 0x0, contained_in: 0x0 }, + "application/futuresplash" => MimeType { alias_of: 19, container: 0x0, contained_in: 0x0 }, + "application/gml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/gpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/gpx+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/gzip" => MimeType { alias_of: 2, container: 0x2, contained_in: 0x0 }, + "application/gzip-compressed" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "application/gzipped" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "application/hta" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x200000000 }, + "application/hwp+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/ico" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "application/illustrator" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8 }, + "application/illustrator+ps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x10 }, + "application/its+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/java" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/java-archive" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/java-byte-code" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/java-vm" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/kate" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "application/mathml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/metalink+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/metalink4+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/microsoftpatch" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/microsoftupdate" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40 }, + "application/msexcel" => MimeType { alias_of: 9, container: 0x0, contained_in: 0x40000 }, + "application/msix" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/msixbundle" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/mspowerpoint" => MimeType { alias_of: 10, container: 0x0, contained_in: 0x40000 }, + "application/msword" => MimeType { alias_of: 4, container: 0x4, contained_in: 0x40000 }, + "application/msword-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40004 }, + "application/nappdf" => MimeType { alias_of: 5, container: 0x0, contained_in: 0x0 }, + "application/ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "application/onix-message+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/onix-message-short+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/ovf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x100000 }, + "application/owl+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/oxps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/pdf" => MimeType { alias_of: 5, container: 0x8, contained_in: 0x0 }, + "application/photoshop" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "application/postscript" => MimeType { alias_of: 0, container: 0x10, contained_in: 0x0 }, + "application/powerpoint" => MimeType { alias_of: 10, container: 0x0, contained_in: 0x40000 }, + "application/rdf+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/rss+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/rtf" => MimeType { alias_of: 6, container: 0x0, contained_in: 0x0 }, + "application/sldworks" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/smil" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/smil+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/sparql-results+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/ttml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.adobe.flash.movie" => MimeType { alias_of: 19, container: 0x0, contained_in: 0x0 }, + "application/vnd.adobe.illustrator" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8 }, + "application/vnd.adobe.indesign-idml-package" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.adobe.xdp+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.adobe.xfdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.amazon.mobi8-ebook" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000 }, + "application/vnd.android.app-bundle" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.android.package-archive" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.iwork" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.keynote" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.numbers" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.pages" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.pkpass" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.pkpasses" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.apple.unknown.13" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.bzip3" => MimeType { alias_of: 7, container: 0x20, contained_in: 0x0 }, + "application/vnd.comicbook+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.comicbook-rar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400 }, + "application/vnd.cyclonedx+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.debian.binary-package" => MimeType { alias_of: 8, container: 0x0, contained_in: 0x200000 }, + "application/vnd.etsi.asic-e+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.etsi.asic-s+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.google-earth.kml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.google-earth.kmz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.hancom.hwpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.iptc.g2.newsmessage+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.microsoft.windows.thumbnail-cache" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.mindjet.mindmanager" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.mozilla.xul+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.ms-3mfdocument" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-asf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "application/vnd.ms-cab-compressed" => MimeType { alias_of: 0, container: 0x40, contained_in: 0x0 }, + "application/vnd.ms-excel" => MimeType { alias_of: 9, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-excel.addin.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800100 }, + "application/vnd.ms-excel.sheet.binary.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800100 }, + "application/vnd.ms-excel.sheet.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800100 }, + "application/vnd.ms-excel.template.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-officetheme" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-outlook" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-powerpoint" => MimeType { alias_of: 10, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-powerpoint.addin.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-powerpoint.presentation.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800080 }, + "application/vnd.ms-powerpoint.slide.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-powerpoint.slideshow.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-powerpoint.template.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-project" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-publisher" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-spreadsheetml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.ms-visio" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-visio.drawing" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.drawing.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.drawing.macroenabled.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.drawing.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.stencil" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.stencil.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.stencil.macroenabled.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.stencil.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.template.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.template.macroenabled.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-visio.template.main+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-word" => MimeType { alias_of: 4, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-word.document.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800200 }, + "application/vnd.ms-word.template.macroenabled.12" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.ms-word2006ml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.ms-wordml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.ms-works" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.ms-xpsdocument" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.docbook+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.base" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.chart" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.chart-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.database" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.flat.presentation" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.flat.spreadsheet" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.flat.text" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.formula" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.formula-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.graphics" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.graphics-flat-xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.graphics-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.image" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.image-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.presentation" => MimeType { alias_of: 11, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.presentation-flat-xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.presentation-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.spreadsheet" => MimeType { alias_of: 12, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.spreadsheet-flat-xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.spreadsheet-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.text" => MimeType { alias_of: 13, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.text-flat-xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.oasis.opendocument.text-master" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.text-master-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.text-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.text-web" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.oasis.opendocument.tika.flat.document" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/vnd.openofficeorg.autotext" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openofficeorg.extension" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => MimeType { alias_of: 0, container: 0x80, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.presentationml.slide" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.presentationml.slideshow" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.presentationml.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => MimeType { alias_of: 0, container: 0x100, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => MimeType { alias_of: 0, container: 0x200, contained_in: 0x800000 }, + "application/vnd.openxmlformats-officedocument.wordprocessingml.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.rar" => MimeType { alias_of: 14, container: 0x400, contained_in: 0x0 }, + "application/vnd.sqlite3" => MimeType { alias_of: 15, container: 0x800, contained_in: 0x0 }, + "application/vnd.stardivision.calc" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.chart" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.draw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.impress" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.mail" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.math" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.writer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.stardivision.writer-global" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.sun.xml.base" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.calc" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.calc.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.draw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.draw.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.impress" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.impress.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.math" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.writer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.writer.global" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.sun.xml.writer.template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/vnd.visio" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/vnd.youtube.yt" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/wwf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8 }, + "application/x-7z" => MimeType { alias_of: 16, container: 0x0, contained_in: 0x0 }, + "application/x-7z-compressed" => MimeType { alias_of: 16, container: 0x1000, contained_in: 0x0 }, + "application/x-abiword" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-acad" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/x-adobe-indesign-interchange" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-alpine-package-keeper-package" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-amf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-apple-systemprofiler+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-archive" => MimeType { alias_of: 21, container: 0x0, contained_in: 0x0 }, + "application/x-autocad" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/x-bentley-besqlite" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-bentley-localization" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-bzdvi" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-bzip" => MimeType { alias_of: 17, container: 0x0, contained_in: 0x2000 }, + "application/x-bzip-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-bzip2" => MimeType { alias_of: 17, container: 0x2000, contained_in: 0x0 }, + "application/x-bzip2-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-bzip3" => MimeType { alias_of: 7, container: 0x0, contained_in: 0x0 }, + "application/x-bzip3-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20 }, + "application/x-bzpdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-bzpostscript" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-cb7" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x1000 }, + "application/x-cbr" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400 }, + "application/x-cbt" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x100000 }, + "application/x-cbz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-compress" => MimeType { alias_of: 0, container: 0x4000, contained_in: 0x0 }, + "application/x-compressed" => MimeType { alias_of: 23, container: 0x0, contained_in: 0x0 }, + "application/x-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-corelpresentations" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-cpio-compressed" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-csh" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400000000 }, + "application/x-deb" => MimeType { alias_of: 8, container: 0x0, contained_in: 0x200000 }, + "application/x-debian-package" => MimeType { alias_of: 8, container: 0x0, contained_in: 0x200000 }, + "application/x-designer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-dia-diagram" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-dia-shape" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-docbook+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "application/x-esri-layer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-esri-spatially-enabled-db" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-fictionbook" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-fictionbook+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-flash-video" => MimeType { alias_of: 52, container: 0x0, contained_in: 0x0 }, + "application/x-font-ttx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-font-type1" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x10 }, + "application/x-fossil-checkout" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-fossil-global-conf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-fossil-repository" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-freedesktop-appstream-component" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-freedesktop-appstream-releases" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-geopackage" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-glade" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-gpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-gpx+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-gtar" => MimeType { alias_of: 20, container: 0x0, contained_in: 0x100000 }, + "application/x-gtk-builder" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-gunzip" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "application/x-gz-font-linux-psf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-gzdvi" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-gzip" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "application/x-gzip-compressed" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "application/x-gzpdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-gzpostscript" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-hwp+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-hwp-v5" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-hwpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-ibooks+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800001 }, + "application/x-itunes-ipa" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-iwork-keynote-sffkey" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-iwork-numbers-sffnumbers" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-iwork-pages-sffpages" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-jar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-java" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/x-java-archive" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-java-class" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/x-java-jnlp-file" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-java-vm" => MimeType { alias_of: 3, container: 0x0, contained_in: 0x0 }, + "application/x-kexiproject-sqlite" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-kexiproject-sqlite3" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-linguist" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-lz4" => MimeType { alias_of: 0, container: 0x8000, contained_in: 0x0 }, + "application/x-lz4-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000 }, + "application/x-lzip" => MimeType { alias_of: 0, container: 0x10000, contained_in: 0x0 }, + "application/x-lzip-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x10000 }, + "application/x-lzpdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x10000 }, + "application/x-mbtiles" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-midi" => MimeType { alias_of: 27, container: 0x0, contained_in: 0x0 }, + "application/x-mobi8-ebook" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000 }, + "application/x-mobipocket-ebook" => MimeType { alias_of: 0, container: 0x20000, contained_in: 0x0 }, + "application/x-modrinth-modpack+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-monotone-source-repo" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-mozilla-bookmarks" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x200000000 }, + "application/x-ms-asx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-ms-emz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-ms-installer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-ms-wmz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-msexcel" => MimeType { alias_of: 9, container: 0x0, contained_in: 0x40000 }, + "application/x-msi" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-mspowerpoint" => MimeType { alias_of: 10, container: 0x0, contained_in: 0x40000 }, + "application/x-mspublisher" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-msword" => MimeType { alias_of: 4, container: 0x0, contained_in: 0x40000 }, + "application/x-netscape-bookmarks" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x200000000 }, + "application/x-netshow-channel" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "application/x-nzb" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "application/x-ole-storage" => MimeType { alias_of: 0, container: 0x40000, contained_in: 0x0 }, + "application/x-pagemaker" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-pdf" => MimeType { alias_of: 5, container: 0x0, contained_in: 0x0 }, + "application/x-photoshop" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "application/x-plist" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-pyspread-bz-spreadsheet" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "application/x-qbrew" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-quattro-pro" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-quicktime-media-link" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000000 }, + "application/x-quicktimeplayer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000000 }, + "application/x-rar" => MimeType { alias_of: 14, container: 0x0, contained_in: 0x0 }, + "application/x-rar-compressed" => MimeType { alias_of: 14, container: 0x0, contained_in: 0x0 }, + "application/x-raw-disk-image-xz-compressed" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400000 }, + "application/x-redhat-package-manager" => MimeType { alias_of: 18, container: 0x0, contained_in: 0x0 }, + "application/x-rpm" => MimeType { alias_of: 18, container: 0x80000, contained_in: 0x0 }, + "application/x-shellscript" => MimeType { alias_of: 47, container: 0x0, contained_in: 0x0 }, + "application/x-shockwave-flash" => MimeType { alias_of: 19, container: 0x0, contained_in: 0x0 }, + "application/x-source-rpm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000 }, + "application/x-speex" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "application/x-sqlite3" => MimeType { alias_of: 15, container: 0x0, contained_in: 0x0 }, + "application/x-starcalc" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-starchart" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-stardraw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-starimpress" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-starmath" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-staroffice-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-starwriter" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-starwriter-global" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-tar" => MimeType { alias_of: 20, container: 0x100000, contained_in: 0x0 }, + "application/x-tarz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x4000 }, + "application/x-texnicard" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-tiled-tmx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-tiled-tsx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-tmx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-ufraw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-unix-archive" => MimeType { alias_of: 21, container: 0x200000, contained_in: 0x0 }, + "application/x-virtualbox-ova" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x100000 }, + "application/x-vnd.datapackage+gz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "application/x-vnd.datapackage+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.kde.kexi" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800 }, + "application/x-vnd.oasis.opendocument.chart" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.chart-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.formula" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.formula-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.graphics" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.graphics-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.image" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.image-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.presentation" => MimeType { alias_of: 11, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.presentation-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.spreadsheet" => MimeType { alias_of: 12, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.spreadsheet-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.text" => MimeType { alias_of: 13, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.text-master" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.text-template" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.oasis.opendocument.text-web" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-vnd.sun.xml.writer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-wacz" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-windows-installer" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "application/x-windows-themepack" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40 }, + "application/x-wwf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8 }, + "application/x-x509-ca-cert" => MimeType { alias_of: 22, container: 0x0, contained_in: 0x0 }, + "application/x-x509-cert" => MimeType { alias_of: 22, container: 0x0, contained_in: 0x0 }, + "application/x-x509-user-cert" => MimeType { alias_of: 22, container: 0x0, contained_in: 0x0 }, + "application/x-xbel" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-xliff" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-xliff+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-xliff+zip" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-xmind" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-xml" => MimeType { alias_of: 48, container: 0x0, contained_in: 0x0 }, + "application/x-xpinstall" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-xspf+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/x-xz" => MimeType { alias_of: 0, container: 0x400000, contained_in: 0x0 }, + "application/x-xz-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400000 }, + "application/x-xzpdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400000 }, + "application/x-zip" => MimeType { alias_of: 23, container: 0x0, contained_in: 0x0 }, + "application/x-zip-compressed" => MimeType { alias_of: 23, container: 0x0, contained_in: 0x0 }, + "application/x-zip-compressed-fb2" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/x-zstd-compressed-tar" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x1000000 }, + "application/xhtml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/xliff+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/xml" => MimeType { alias_of: 48, container: 0x0, contained_in: 0x0 }, + "application/xml-external-parsed-entity" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/xps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "application/xslfo+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/xslt+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/xspf+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "application/zip" => MimeType { alias_of: 23, container: 0x800000, contained_in: 0x0 }, + "application/zstd" => MimeType { alias_of: 0, container: 0x1000000, contained_in: 0x0 }, + "audio/3gpp" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/3gpp-encrypted" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/aac" => MimeType { alias_of: 24, container: 0x0, contained_in: 0x0 }, + "audio/aiff" => MimeType { alias_of: 30, container: 0x0, contained_in: 0x0 }, + "audio/amr" => MimeType { alias_of: 25, container: 0x2000000, contained_in: 0x0 }, + "audio/amr-encrypted" => MimeType { alias_of: 25, container: 0x0, contained_in: 0x0 }, + "audio/amr-wb" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000 }, + "audio/amr-wb+" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000 }, + "audio/amr-wb-encrypted" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000 }, + "audio/dsd" => MimeType { alias_of: 31, container: 0x0, contained_in: 0x0 }, + "audio/dsf" => MimeType { alias_of: 31, container: 0x0, contained_in: 0x0 }, + "audio/flac" => MimeType { alias_of: 32, container: 0x0, contained_in: 0x0 }, + "audio/m4a" => MimeType { alias_of: 26, container: 0x4000000, contained_in: 0x13000000000 }, + "audio/mid" => MimeType { alias_of: 27, container: 0x0, contained_in: 0x0 }, + "audio/midi" => MimeType { alias_of: 27, container: 0x0, contained_in: 0x0 }, + "audio/mp3" => MimeType { alias_of: 28, container: 0x0, contained_in: 0x0 }, + "audio/mp4" => MimeType { alias_of: 26, container: 0x0, contained_in: 0x13000000000 }, + "audio/mpeg" => MimeType { alias_of: 28, container: 0x0, contained_in: 0x0 }, + "audio/ogg" => MimeType { alias_of: 29, container: 0x8000000, contained_in: 0x0 }, + "audio/opus" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/speex" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/vnd.wave" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "audio/vorbis" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/wav" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "audio/wave" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "audio/webm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x4000000000 }, + "audio/wma" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "audio/x-aac" => MimeType { alias_of: 24, container: 0x0, contained_in: 0x0 }, + "audio/x-aiff" => MimeType { alias_of: 30, container: 0x0, contained_in: 0x0 }, + "audio/x-dsd" => MimeType { alias_of: 31, container: 0x0, contained_in: 0x0 }, + "audio/x-dsf" => MimeType { alias_of: 31, container: 0x0, contained_in: 0x0 }, + "audio/x-flac" => MimeType { alias_of: 32, container: 0x0, contained_in: 0x0 }, + "audio/x-flac+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-hx-aac-adts" => MimeType { alias_of: 24, container: 0x0, contained_in: 0x0 }, + "audio/x-m4a" => MimeType { alias_of: 26, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-m4b" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13004000000 }, + "audio/x-m4r" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-mid" => MimeType { alias_of: 27, container: 0x0, contained_in: 0x0 }, + "audio/x-midi" => MimeType { alias_of: 27, container: 0x0, contained_in: 0x0 }, + "audio/x-mp3" => MimeType { alias_of: 28, container: 0x0, contained_in: 0x0 }, + "audio/x-mp4a" => MimeType { alias_of: 26, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-mpeg" => MimeType { alias_of: 28, container: 0x0, contained_in: 0x0 }, + "audio/x-mpg" => MimeType { alias_of: 28, container: 0x0, contained_in: 0x0 }, + "audio/x-ms-asx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "audio/x-ms-wma" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "audio/x-ogg" => MimeType { alias_of: 29, container: 0x0, contained_in: 0x8000000 }, + "audio/x-ogg-flac" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-ogg-pcm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-oggflac" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-oggpcm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-opus+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-pn-wav" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "audio/x-rn-3gpp-amr" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-rn-3gpp-amr-encrypted" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-rn-3gpp-amr-wb" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-rn-3gpp-amr-wb-encrypted" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "audio/x-speex+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-vorbis" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-vorbis+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "audio/x-wav" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "audio/x-wave" => MimeType { alias_of: 33, container: 0x0, contained_in: 0x0 }, + "drawing/dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "flv-application/octet-stream" => MimeType { alias_of: 52, container: 0x0, contained_in: 0x0 }, + "font/woff" => MimeType { alias_of: 1, container: 0x0, contained_in: 0x0 }, + "gzip/document" => MimeType { alias_of: 2, container: 0x0, contained_in: 0x0 }, + "image/apng" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000000 }, + "image/avif" => MimeType { alias_of: 34, container: 0x0, contained_in: 0x0 }, + "image/avif-sequence" => MimeType { alias_of: 34, container: 0x0, contained_in: 0x0 }, + "image/bmp" => MimeType { alias_of: 35, container: 0x0, contained_in: 0x0 }, + "image/gif" => MimeType { alias_of: 36, container: 0x0, contained_in: 0x0 }, + "image/heic" => MimeType { alias_of: 37, container: 0x0, contained_in: 0x2010000000 }, + "image/heic-sequence" => MimeType { alias_of: 37, container: 0x0, contained_in: 0x2010000000 }, + "image/heif" => MimeType { alias_of: 37, container: 0x10000000, contained_in: 0x2000000000 }, + "image/heif-sequence" => MimeType { alias_of: 37, container: 0x0, contained_in: 0x2010000000 }, + "image/hevc" => MimeType { alias_of: 37, container: 0x0, contained_in: 0x2010000000 }, + "image/hevc-sequence" => MimeType { alias_of: 37, container: 0x0, contained_in: 0x2010000000 }, + "image/ico" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "image/icon" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "image/jp2" => MimeType { alias_of: 38, container: 0x0, contained_in: 0x0 }, + "image/jpe" => MimeType { alias_of: 39, container: 0x0, contained_in: 0x0 }, + "image/jpeg" => MimeType { alias_of: 39, container: 0x20000000, contained_in: 0x0 }, + "image/jpeg2000" => MimeType { alias_of: 38, container: 0x0, contained_in: 0x0 }, + "image/jpeg2000-image" => MimeType { alias_of: 38, container: 0x0, contained_in: 0x0 }, + "image/jpg" => MimeType { alias_of: 39, container: 0x0, contained_in: 0x0 }, + "image/jxr" => MimeType { alias_of: 46, container: 0x0, contained_in: 0x0 }, + "image/openraster" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "image/pdf" => MimeType { alias_of: 5, container: 0x0, contained_in: 0x0 }, + "image/photoshop" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "image/pjpeg" => MimeType { alias_of: 39, container: 0x0, contained_in: 0x0 }, + "image/png" => MimeType { alias_of: 40, container: 0x40000000, contained_in: 0x0 }, + "image/psd" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "image/svg+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "image/svg+xml-compressed" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "image/tif" => MimeType { alias_of: 41, container: 0x0, contained_in: 0x0 }, + "image/tiff" => MimeType { alias_of: 41, container: 0x80000000, contained_in: 0x0 }, + "image/vnd.adobe.photoshop" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "image/vnd.adobe.premiere" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "image/vnd.djvu" => MimeType { alias_of: 43, container: 0x100000000, contained_in: 0x0 }, + "image/vnd.djvu+multipage" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x100000000 }, + "image/vnd.dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "image/vnd.fpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "image/vnd.microsoft.icon" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "image/vnd.mozilla.apng" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000000 }, + "image/vnd.ms-photo" => MimeType { alias_of: 46, container: 0x0, contained_in: 0x0 }, + "image/x-adobe-dng" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-bmp" => MimeType { alias_of: 35, container: 0x0, contained_in: 0x0 }, + "image/x-bzeps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000 }, + "image/x-canon-cr2" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-canon-cr3" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000000 }, + "image/x-djvu" => MimeType { alias_of: 43, container: 0x0, contained_in: 0x0 }, + "image/x-dwg" => MimeType { alias_of: 44, container: 0x0, contained_in: 0x0 }, + "image/x-emf-compressed" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "image/x-eps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x10 }, + "image/x-epson-erf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-fpx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x40000 }, + "image/x-gif" => MimeType { alias_of: 36, container: 0x0, contained_in: 0x0 }, + "image/x-gzeps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2 }, + "image/x-hasselblad-3fr" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-hasselblad-fff" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-ico" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "image/x-icon" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "image/x-jpeg2000-image" => MimeType { alias_of: 38, container: 0x0, contained_in: 0x0 }, + "image/x-jpg" => MimeType { alias_of: 39, container: 0x0, contained_in: 0x0 }, + "image/x-kodak-dcr" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-kodak-k25" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-kodak-kdc" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-leaf-mos" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-mamiya-mef" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-ms-bmp" => MimeType { alias_of: 35, container: 0x0, contained_in: 0x0 }, + "image/x-nikon-nef" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-nikon-nrw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-pentax-pef" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-phaseone-iiq" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-photoshop" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "image/x-png" => MimeType { alias_of: 40, container: 0x0, contained_in: 0x0 }, + "image/x-psd" => MimeType { alias_of: 42, container: 0x0, contained_in: 0x0 }, + "image/x-raw-adobe" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-raw-nikon" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-raw-pentax" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-raw-sony" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-samsung-srw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-sinar-sti" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-sony-arw" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-sony-sr2" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-sony-srf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x-tif" => MimeType { alias_of: 41, container: 0x0, contained_in: 0x0 }, + "image/x-tiff-multipage" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x80000000 }, + "image/x.djvu" => MimeType { alias_of: 43, container: 0x0, contained_in: 0x0 }, + "model/3mf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "model/vnd.dwfx+xps" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "multipart/x-zip" => MimeType { alias_of: 23, container: 0x0, contained_in: 0x0 }, + "text/html" => MimeType { alias_of: 0, container: 0x200000000, contained_in: 0x0 }, + "text/ico" => MimeType { alias_of: 45, container: 0x0, contained_in: 0x0 }, + "text/iso19139+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/mathml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/rdf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/rss" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/rtf" => MimeType { alias_of: 6, container: 0x0, contained_in: 0x0 }, + "text/scriptlet" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/vnd.qt.linguist" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/vnd.trolltech.linguist" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/vnd.wap.wml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-component" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-csh" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x400000000 }, + "text/x-maven+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-mrml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-opml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-opml+xml" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-sh" => MimeType { alias_of: 47, container: 0x0, contained_in: 0x0 }, + "text/x-shellscript" => MimeType { alias_of: 47, container: 0x400000000, contained_in: 0x0 }, + "text/x-xmi" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/x-xslfo" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/xml" => MimeType { alias_of: 48, container: 0x800000000, contained_in: 0x0 }, + "text/xml-external-parsed-entity" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "text/xsl" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "video/3gp" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "video/3gpp" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "video/3gpp-encrypted" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x13000000000 }, + "video/avi" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/daala" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/divx" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/flv" => MimeType { alias_of: 52, container: 0x0, contained_in: 0x0 }, + "video/iso.segment" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x2000000000 }, + "video/matroska" => MimeType { alias_of: 53, container: 0x0, contained_in: 0x0 }, + "video/mov" => MimeType { alias_of: 51, container: 0x0, contained_in: 0x0 }, + "video/mp4" => MimeType { alias_of: 49, container: 0x1000000000, contained_in: 0x12000000000 }, + "video/mp4v-es" => MimeType { alias_of: 49, container: 0x0, contained_in: 0x13000000000 }, + "video/mpeg" => MimeType { alias_of: 50, container: 0x0, contained_in: 0x0 }, + "video/mpeg-system" => MimeType { alias_of: 50, container: 0x0, contained_in: 0x0 }, + "video/msvideo" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/quicktime" => MimeType { alias_of: 51, container: 0x2000000000, contained_in: 0x0 }, + "video/theora" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/vnd.avi" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/vnd.divx" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/vnd.youtube.yt" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000 }, + "video/webm" => MimeType { alias_of: 0, container: 0x4000000000, contained_in: 0x0 }, + "video/x-avi" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/x-daala" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-dirac" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-flv" => MimeType { alias_of: 52, container: 0x8000000000, contained_in: 0x0 }, + "video/x-javafx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000000 }, + "video/x-m4v" => MimeType { alias_of: 49, container: 0x10000000000, contained_in: 0x3000000000 }, + "video/x-matroska" => MimeType { alias_of: 53, container: 0x0, contained_in: 0x0 }, + "video/x-mjpeg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000 }, + "video/x-mov" => MimeType { alias_of: 51, container: 0x0, contained_in: 0x0 }, + "video/x-mpeg" => MimeType { alias_of: 50, container: 0x0, contained_in: 0x0 }, + "video/x-mpeg-system" => MimeType { alias_of: 50, container: 0x0, contained_in: 0x0 }, + "video/x-mpeg2" => MimeType { alias_of: 50, container: 0x0, contained_in: 0x0 }, + "video/x-ms-asf" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "video/x-ms-asf-plugin" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "video/x-ms-wax" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "video/x-ms-wm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x20000000000 }, + "video/x-ms-wmv" => MimeType { alias_of: 0, container: 0x20000000000, contained_in: 0x0 }, + "video/x-ms-wmx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "video/x-ms-wvx" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x800000000 }, + "video/x-msvideo" => MimeType { alias_of: 54, container: 0x0, contained_in: 0x0 }, + "video/x-ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogg-rgb" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogg-uvs" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogg-yuv" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-oggrgb" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogguvs" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-oggyuv" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogm" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-ogm+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-theora" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + "video/x-theora+ogg" => MimeType { alias_of: 0, container: 0x0, contained_in: 0x8000000 }, + ) +} diff --git a/crates/spam-filter/src/analysis/mod.rs b/crates/spam-filter/src/analysis/mod.rs new file mode 100644 index 0000000..a9c0993 --- /dev/null +++ b/crates/spam-filter/src/analysis/mod.rs @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Recipient, SpamFilterContext, SpamFilterInput, SpamFilterOutput, SpamFilterResult, TextPart, +}; +use common::{Server, config::mailstore::spamfilter::Location}; +use mail_parser::{Header, parsers::MessageStream}; +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, +}; + +pub mod classifier; +pub mod date; +pub mod dmarc; +pub mod domain; +pub mod ehlo; +pub mod from; +pub mod headers; +pub mod html; +pub mod init; +pub mod ip; +pub mod messageid; +pub mod mime; +mod mime_types; +pub mod pyzor; +pub mod received; +pub mod recipient; +pub mod replyto; +pub mod rules; +pub mod score; +pub mod subject; +pub mod url; + + +impl SpamFilterInput<'_> { + pub fn header_as_address(&self, header: &Header<'_>) -> Option> { + self.message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .map(|bytes| MessageStream::new(bytes).parse_address()) + .and_then(|addr| addr.into_address()) + .and_then(|addr| addr.into_list().into_iter().next()) + .and_then(|addr| addr.address) + } +} + +impl SpamFilterOutput<'_> { + pub fn all_recipients(&self) -> impl Iterator { + self.recipients_to + .iter() + .chain(self.recipients_cc.iter()) + .chain(self.recipients_bcc.iter()) + } +} + +impl SpamFilterContext<'_> { + pub fn text_body(&self) -> Option<&str> { + self.input + .message + .text_body + .first() + .or_else(|| self.input.message.html_body.first()) + .and_then(|idx| self.output.text_parts.get(*idx as usize)) + .and_then(|part| match part { + TextPart::Plain { text_body, .. } => Some(*text_body), + TextPart::Html { text_body, .. } => Some(text_body.as_str()), + TextPart::None => None, + }) + } +} + +impl SpamFilterResult { + pub fn add_tag(&mut self, tag: impl Into) { + self.tags.insert(tag.into()); + } + + pub fn has_tag(&self, tag: impl AsRef) -> bool { + self.tags.contains(tag.as_ref()) + } +} + +#[derive(Debug)] +pub struct ElementLocation { + pub element: T, + pub location: Location, +} + +impl Hash for ElementLocation { + fn hash(&self, state: &mut H) { + self.element.hash(state); + } +} + +impl PartialEq for ElementLocation { + fn eq(&self, other: &Self) -> bool { + self.element.eq(&other.element) + } +} + +impl Eq for ElementLocation {} + +impl ElementLocation { + pub fn new(element: T, location: impl Into) -> Self { + Self { + element, + location: location.into(), + } + } +} + +pub(crate) async fn is_trusted_domain(server: &Server, domain: &str, span_id: u64) -> bool { + if let Some(store) = server.get_lookup_store("trusted-domains") { + match store.key_exists(domain).await { + Ok(true) => return true, + Ok(false) => (), + Err(err) => { + trc::error!(err.span_id(span_id).caused_by(trc::location!())); + } + } + } + + match server.domain(domain).await { + Ok(result) => result.is_some(), + Err(err) => { + trc::error!(err.span_id(span_id).caused_by(trc::location!())); + false + } + } +} + +pub(crate) async fn is_url_redirector(server: &Server, url: &str, span_id: u64) -> bool { + if let Some(store) = server.get_lookup_store("url-redirectors") { + match store.key_exists(url).await { + Ok(result) => result, + Err(err) => { + trc::error!(err.span_id(span_id).caused_by(trc::location!())); + false + } + } + } else { + false + } +} diff --git a/crates/spam-filter/src/analysis/pyzor.rs b/crates/spam-filter/src/analysis/pyzor.rs new file mode 100644 index 0000000..1f80fd8 --- /dev/null +++ b/crates/spam-filter/src/analysis/pyzor.rs @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{future::Future, time::Instant}; + +use common::Server; + +use crate::{SpamFilterContext, modules::pyzor::pyzor_check}; + +pub trait SpamFilterAnalyzePyzor: Sync + Send { + fn spam_filter_analyze_pyzor( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzePyzor for Server { + async fn spam_filter_analyze_pyzor(&self, ctx: &mut SpamFilterContext<'_>) { + if let Some(config) = &self.core.spam.pyzor { + let time = Instant::now(); + match pyzor_check(ctx.input.message, config).await { + Ok(Some(result)) => { + let is_spam = result.code == 200 + && result.count > config.min_count + && (result.wl_count < config.min_wl_count + || (result.wl_count as f64 / result.count as f64) < config.ratio); + if is_spam { + ctx.result.add_tag("PYZOR"); + } + trc::event!( + Spam(trc::SpamEvent::Pyzor), + Result = is_spam, + Details = vec![ + trc::Value::from(result.code), + trc::Value::from(result.count), + trc::Value::from(result.wl_count) + ], + SpanId = ctx.input.span_id, + Elapsed = time.elapsed() + ); + } + Ok(None) => {} + Err(err) => { + trc::error!( + err.span_id(ctx.input.span_id) + .ctx(trc::Key::Elapsed, time.elapsed()) + ); + } + } + } + } +} diff --git a/crates/spam-filter/src/analysis/received.rs b/crates/spam-filter/src/analysis/received.rs new file mode 100644 index 0000000..2ba4cb0 --- /dev/null +++ b/crates/spam-filter/src/analysis/received.rs @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::{HeaderName, Host}; +use smtp_proto::MAIL_SMTPUTF8; + +use crate::{Email, SpamFilterContext}; + +pub trait SpamFilterAnalyzeReceived: Sync + Send { + fn spam_filter_analyze_received( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeReceived for Server { + async fn spam_filter_analyze_received(&self, ctx: &mut SpamFilterContext<'_>) { + let mut rcvd_count = 0; + let mut rcvd_from_ip = 0; + let mut tls_count = 0; + + let is_smtputf8 = (ctx.input.env_from_flags & MAIL_SMTPUTF8) != 0; + + for header in ctx.input.message.headers() { + if let HeaderName::Received = &header.name { + if !is_smtputf8 + && !ctx + .input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default() + .is_ascii() + { + // Received headers have non-ASCII characters + ctx.result.add_tag("RCVD_ILLEGAL_CHARS"); + } + + if let Some(received) = header.value().as_received() { + let helo_domain = received.from().or_else(|| received.helo()); + let ip_rev = received.from_iprev(); + + if matches!(&helo_domain, Some(Host::Name(hostname)) if hostname.eq_ignore_ascii_case("user")) + { + // HELO domain is "user" + ctx.result.add_tag("RCVD_HELO_USER"); + } else if let (Some(Host::Name(helo_domain)), Some(ip_rev)) = + (helo_domain, ip_rev) + && helo_domain.to_lowercase() != ip_rev.to_lowercase() + { + // HELO domain does not match PTR record + ctx.result.add_tag("FORGED_RCVD_TRAIL"); + } + + if let Some(delivered_for) = received.for_().map(Email::new) + && ctx + .output + .all_recipients() + .any(|r| r.email == delivered_for) + { + // Recipient appears on Received trail + ctx.result.add_tag("PREVIOUSLY_DELIVERED"); + } + + if matches!(received.from, Some(Host::IpAddr(_))) { + // Received from an IP address rather than a FQDN + rcvd_from_ip += 1; + } + + if received.tls_version().is_some() { + // Received with TLS + tls_count += 1; + } + } else { + // Received header is not RFC 5322 compliant + ctx.result.add_tag("RCVD_UNPARSABLE"); + } + + rcvd_count += 1; + } + } + + if rcvd_from_ip >= 2 || (rcvd_from_ip == 1 && ctx.output.ehlo_host.ip.is_some()) { + // Has two or more Received headers containing bare IP addresses + ctx.result.add_tag("RCVD_DOUBLE_IP_SPAM"); + } + + // Received from an authenticated user + if ctx.input.authenticated_as.is_some() { + ctx.result.add_tag("RCVD_VIA_SMTP_AUTH"); + } + + // Received with TLS checks + if rcvd_count > 0 && rcvd_count == tls_count && ctx.input.is_tls { + ctx.result.add_tag("RCVD_TLS_ALL"); + } else if ctx.input.is_tls { + ctx.result.add_tag("RCVD_TLS_LAST"); + } else { + ctx.result.add_tag("RCVD_NO_TLS_LAST"); + } + + match rcvd_count { + 0 => { + ctx.result.add_tag("RCVD_COUNT_ZERO"); + } + 1 => { + ctx.result.add_tag("RCVD_COUNT_ONE"); + } + 2 => { + ctx.result.add_tag("RCVD_COUNT_TWO"); + } + 3 => { + ctx.result.add_tag("RCVD_COUNT_THREE"); + } + 4 | 5 => { + ctx.result.add_tag("RCVD_COUNT_FIVE"); + } + 6 | 7 => { + ctx.result.add_tag("RCVD_COUNT_SEVEN"); + } + 8..=12 => { + ctx.result.add_tag("RCVD_COUNT_TWELVE"); + } + _ => {} + } + } +} diff --git a/crates/spam-filter/src/analysis/recipient.rs b/crates/spam-filter/src/analysis/recipient.rs new file mode 100644 index 0000000..b04ec5d --- /dev/null +++ b/crates/spam-filter/src/analysis/recipient.rs @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::{Server, scripts::functions::text::levenshtein_distance}; +use mail_parser::HeaderName; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; +use store::ahash::HashSet; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeRecipient: Sync + Send { + fn spam_filter_analyze_recipient( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeRecipient for Server { + async fn spam_filter_analyze_recipient(&self, ctx: &mut SpamFilterContext<'_>) { + let mut to_raw = b"".as_slice(); + let mut cc_raw = b"".as_slice(); + let mut bcc_raw = b"".as_slice(); + let mut has_list_unsubscribe = false; + let mut has_list_id = false; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::To | HeaderName::Cc | HeaderName::Bcc => { + let raw = ctx + .input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(); + match header.name { + HeaderName::To => to_raw = raw, + HeaderName::Cc => cc_raw = raw, + HeaderName::Bcc => bcc_raw = raw, + _ => unreachable!(), + } + } + HeaderName::ListUnsubscribe => { + has_list_unsubscribe = true; + } + HeaderName::ListId => { + has_list_id = true; + } + _ => {} + } + } + + if to_raw.is_empty() { + ctx.result.add_tag("MISSING_TO"); + } + + let to_raw_utf8 = std::str::from_utf8(to_raw); + let cc_raw_utf8 = std::str::from_utf8(cc_raw); + let bcc_raw_utf8 = std::str::from_utf8(bcc_raw); + + for (raw, raw_utf8, recipients) in [ + (to_raw, &to_raw_utf8, &ctx.output.recipients_to), + (cc_raw, &cc_raw_utf8, &ctx.output.recipients_cc), + (bcc_raw, &bcc_raw_utf8, &ctx.output.recipients_bcc), + ] { + if !raw.is_empty() { + // Validate non-ASCII characters in recipient headers + if !raw.is_ascii() { + if (ctx.input.env_from_flags + & (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME)) + == 0 + { + ctx.result.add_tag("TO_NEEDS_ENCODING"); + } + + if raw_utf8.is_err() { + ctx.result.add_tag("INVALID_TO_8BIT"); + } + } + + // Validate unnecessary encoding in recipient headers + let raw_utf8 = raw_utf8.unwrap_or_default(); + if recipients.iter().all(|rcpt| { + rcpt.name.as_ref().is_none_or(|name| name.is_ascii()) + && rcpt.email.address.is_ascii() + }) && raw_utf8.contains("=?") + && raw_utf8.contains("?=") + { + if raw_utf8.contains("?q?") || raw_utf8.contains("?Q?") { + // To header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("TO_EXCESS_QP"); + } else if raw_utf8.contains("?b?") || raw_utf8.contains("?B?") { + // To header is unnecessarily encoded in base64 + ctx.result.add_tag("TO_EXCESS_BASE64"); + } + } + + // Check for spaces in recipient addresses + for token in raw_utf8.split('<') { + if let Some((addr, _)) = token.split_once('>') + && (addr.starts_with(' ') || addr.ends_with(' ')) + { + ctx.result.add_tag("TO_WRAPPED_IN_SPACES"); + break; + } + } + } + } + + let unique_recipients = ctx + .output + .all_recipients() + .filter(|rcpt| !rcpt.email.address.is_empty()) + .collect::>(); + let rcpt_count = unique_recipients.len(); + + match unique_recipients.len() { + 0 => { + ctx.result.add_tag("RCPT_COUNT_ZERO"); + return; + } + 1 => { + ctx.result.add_tag("RCPT_COUNT_ONE"); + } + 2 => { + ctx.result.add_tag("RCPT_COUNT_TWO"); + } + 3 => { + ctx.result.add_tag("RCPT_COUNT_THREE"); + } + 4 | 5 => { + ctx.result.add_tag("RCPT_COUNT_FIVE"); + } + 6 | 7 => { + ctx.result.add_tag("RCPT_COUNT_SEVEN"); + } + 8..=12 => { + ctx.result.add_tag("RCPT_COUNT_TWELVE"); + } + 13.. => { + ctx.result.add_tag("RCPT_COUNT_GT_50"); + } + } + + let mut to_dn_eq_addr_count = 0; + let mut to_dn_count = 0; + let mut to_match_envrcpt = 0; + + for rcpt in &unique_recipients { + // Validate name + if let Some(rcpt_name) = &rcpt.name { + if *rcpt_name == rcpt.email.address { + to_dn_eq_addr_count += 1; + } else { + to_dn_count += 1; + } + } + + // Recipient is present in envelope + if ctx.output.env_to_orig_addr.contains(&rcpt.email) { + to_match_envrcpt += 1; + } + + // Check if the local part is present in the subject + if !rcpt.email.local_part.is_empty() { + if ctx.output.subject_lc.contains(rcpt.email.address.as_str()) { + ctx.result.add_tag("RCPT_IN_SUBJECT"); + } else if rcpt.email.local_part.len() > 3 + && ctx + .output + .subject_lc + .contains(rcpt.email.local_part.as_str()) + { + ctx.result.add_tag("RCPT_LOCAL_IN_SUBJECT"); + } + } + } + + if to_dn_count == 0 && to_dn_eq_addr_count == 0 { + ctx.result.add_tag("TO_DN_NONE"); + } else if to_dn_count == rcpt_count { + ctx.result.add_tag("TO_DN_ALL"); + } else if to_dn_count > 0 { + ctx.result.add_tag("TO_DN_SOME"); + } + + if to_dn_eq_addr_count == rcpt_count { + ctx.result.add_tag("TO_DN_EQ_ADDR_ALL"); + } else if to_dn_eq_addr_count > 0 { + ctx.result.add_tag("TO_DN_EQ_ADDR_SOME"); + } + + if to_match_envrcpt == rcpt_count { + ctx.result.add_tag("TO_MATCH_ENVRCPT_ALL"); + } else { + if to_match_envrcpt > 0 { + ctx.result.add_tag("TO_MATCH_ENVRCPT_SOME"); + } + + if !has_list_id && !has_list_unsubscribe { + for env_rcpt in &ctx.output.env_to_orig_addr { + if !unique_recipients.iter().any(|rcpt| rcpt.email == *env_rcpt) + && env_rcpt != &ctx.output.env_from_addr + { + ctx.result.add_tag("FORGED_RECIPIENTS"); + break; + } + } + } + } + + // Message from bounce and over 1 recipient + if rcpt_count > 1 && ctx.output.env_from_postmaster { + ctx.result.add_tag("RCPT_BOUNCEMOREONE"); + } + + let rcpts = ctx + .output + .recipients_to + .iter() + .chain(ctx.output.recipients_cc.iter()) + .collect::>(); + + let mut is_sorted = false; + if rcpts.len() >= 6 { + // Check if the recipients list is sorted + let mut sorted = true; + for i in 1..rcpts.len() { + if rcpts[i - 1].email.address > rcpts[i].email.address { + sorted = false; + break; + } + } + if sorted { + ctx.result.add_tag("SORTED_RECIPS"); + is_sorted = true; + } + } + + if !is_sorted && rcpt_count >= 5 { + // Look for similar recipients + let mut hits = 0; + let mut combinations = 0; + for i in 0..rcpts.len() { + for j in i + 1..rcpts.len() { + let a = &rcpts[i].email; + let b = &rcpts[j].email; + + if levenshtein_distance(&a.local_part, &b.local_part) < 3 + || (a.domain_part.fqdn != b.domain_part.fqdn + && levenshtein_distance(&a.domain_part.fqdn, &b.domain_part.fqdn) < 4) + { + hits += 1; + } + combinations += 1; + } + } + + if hits as f64 / combinations as f64 > 0.65 { + ctx.result.add_tag("SUSPICIOUS_RECIPS"); + } + } + } +} diff --git a/crates/spam-filter/src/analysis/replyto.rs b/crates/spam-filter/src/analysis/replyto.rs new file mode 100644 index 0000000..62db3aa --- /dev/null +++ b/crates/spam-filter/src/analysis/replyto.rs @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::HeaderName; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeReplyTo: Sync + Send { + fn spam_filter_analyze_reply_to( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeReplyTo for Server { + async fn spam_filter_analyze_reply_to(&self, ctx: &mut SpamFilterContext<'_>) { + let mut reply_to_raw = b"".as_slice(); + let mut is_from_list = false; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::ReplyTo => { + reply_to_raw = ctx + .input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(); + } + HeaderName::ListUnsubscribe | HeaderName::ListId => { + is_from_list = true; + } + + HeaderName::AutoSubmitted => { + is_from_list = true; + } + HeaderName::Other(name) if !is_from_list => { + is_from_list = name.eq_ignore_ascii_case("X-To-Get-Off-This-List") + || name.eq_ignore_ascii_case("X-List"); + } + _ => {} + } + } + + if reply_to_raw.is_empty() { + return; + } + + if let Some(reply_to) = &ctx.output.reply_to { + let reply_to_name = reply_to.name.as_deref().unwrap_or_default(); + ctx.result.add_tag("HAS_REPLYTO"); + + if reply_to.email == ctx.output.from.email { + ctx.result.add_tag("REPLYTO_EQ_FROM"); + } else { + if reply_to.email.domain_part.sld == ctx.output.from.email.domain_part.sld { + ctx.result.add_tag("REPLYTO_DOM_EQ_FROM_DOM"); + } else { + if !is_from_list + && ctx + .output + .all_recipients() + .any(|r| r.email == reply_to.email) + { + ctx.result.add_tag("REPLYTO_EQ_TO_ADDR"); + } else { + ctx.result.add_tag("REPLYTO_DOM_NEQ_FROM_DOM"); + } + + if !(is_from_list + || ctx + .output + .recipients_to + .iter() + .any(|r| r.email == ctx.output.from.email) + || ctx + .output + .env_to_orig_addr + .iter() + .any(|r| r.domain_part.sld == ctx.output.from.email.domain_part.sld) + || ctx.output.env_to_orig_addr.len() == 1 + && ctx.output.env_to_orig_addr.contains(&ctx.output.from.email)) + { + ctx.result.add_tag("SPOOF_REPLYTO"); + } + } + + if !reply_to_name.is_empty() + && reply_to_name == ctx.output.from.name.as_deref().unwrap_or_default() + { + ctx.result.add_tag("REPLYTO_DN_EQ_FROM_DN"); + } + } + + if reply_to.email == ctx.output.env_from_addr { + ctx.result.add_tag("REPLYTO_ADDR_EQ_FROM"); + } + + // Validate unnecessary encoding + let reply_to_raw_utf8 = std::str::from_utf8(reply_to_raw).unwrap_or_default(); + if reply_to.email.address.is_ascii() + && reply_to_name.is_ascii() + && reply_to_raw_utf8.contains("=?") + && reply_to_raw_utf8.contains("?=") + { + if reply_to_raw_utf8.contains("?q?") || reply_to_raw_utf8.contains("?Q?") { + // Reply-To header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("REPLYTO_EXCESS_QP"); + } else if reply_to_raw_utf8.contains("?b?") || reply_to_raw_utf8.contains("?B?") { + // Reply-To header is unnecessarily encoded in base64 + ctx.result.add_tag("REPLYTO_EXCESS_BASE64"); + } + } + } else { + ctx.result.add_tag("REPLYTO_UNPARSABLE"); + } + } +} diff --git a/crates/spam-filter/src/analysis/rules.rs b/crates/spam-filter/src/analysis/rules.rs new file mode 100644 index 0000000..5dc005a --- /dev/null +++ b/crates/spam-filter/src/analysis/rules.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::{ + Server, + config::mailstore::spamfilter::{IpResolver, Location}, +}; + +use crate::{ + SpamFilterContext, TextPart, + modules::expression::{EmailHeader, SpamFilterResolver, StringResolver}, +}; + +pub trait SpamFilterAnalyzeRules: Sync + Send { + fn spam_filter_analyze_rules( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeRules for Server { + async fn spam_filter_analyze_rules(&self, ctx: &mut SpamFilterContext<'_>) { + if !self.core.spam.rules.url.is_empty() { + for url in &ctx.output.urls { + for rule in &self.core.spam.rules.url { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &url.element, url.location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + + if !self.core.spam.rules.domain.is_empty() { + for domain in &ctx.output.domains { + let resolver = StringResolver(domain.element.as_str()); + + for rule in &self.core.spam.rules.domain { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &resolver, domain.location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + + if !self.core.spam.rules.email.is_empty() { + for email in &ctx.output.emails { + for rule in &self.core.spam.rules.email { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &email.element, email.location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + + for (rcpt, location) in [ + (&ctx.output.recipients_to, Location::HeaderTo), + (&ctx.output.recipients_cc, Location::HeaderCc), + (&ctx.output.recipients_bcc, Location::HeaderBcc), + ] { + for email in rcpt { + for rule in &self.core.spam.rules.email { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, email, location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + } + + if !self.core.spam.rules.ip.is_empty() { + for ip in &ctx.output.ips { + let ip_resolver = IpResolver::new(ip.element); + + for rule in &self.core.spam.rules.ip { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &ip_resolver, ip.location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + + if !self.core.spam.rules.header.is_empty() { + for header in ctx.input.message.headers() { + let raw = String::from_utf8_lossy( + ctx.input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(), + ); + let header_resolver = EmailHeader { + header, + raw: raw.as_ref(), + }; + + for rule in &self.core.spam.rules.header { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &header_resolver, Location::BodyText), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + + if !self.core.spam.rules.body.is_empty() { + for (idx, part) in ctx.output.text_parts.iter().enumerate() { + let text = match part { + TextPart::Plain { text_body, .. } => *text_body, + TextPart::Html { text_body, .. } => text_body.as_str(), + TextPart::None => continue, + }; + let idx = idx as u32; + let location = if ctx.input.message.text_body.contains(&idx) { + Location::BodyText + } else if ctx.input.message.html_body.contains(&idx) { + Location::BodyHtml + } else { + Location::Attachment + }; + let string_resolver = StringResolver(text); + + for rule in &self.core.spam.rules.body { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &string_resolver, location), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } + + if !self.core.spam.rules.any.is_empty() { + let dummy_resolver = StringResolver(""); + for rule in &self.core.spam.rules.any { + if let Some(tag) = self + .eval_if::( + rule, + &SpamFilterResolver::new(ctx, &dummy_resolver, Location::BodyText), + ctx.input.span_id, + ) + .await + { + ctx.result.tags.insert(tag); + } + } + } + } +} diff --git a/crates/spam-filter/src/analysis/score.rs b/crates/spam-filter/src/analysis/score.rs new file mode 100644 index 0000000..62d59e9 --- /dev/null +++ b/crates/spam-filter/src/analysis/score.rs @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + SpamFilterContext, + analysis::{ + classifier::SpamFilterAnalyzeClassify, date::SpamFilterAnalyzeDate, + dmarc::SpamFilterAnalyzeDmarc, domain::SpamFilterAnalyzeDomain, + ehlo::SpamFilterAnalyzeEhlo, from::SpamFilterAnalyzeFrom, + headers::SpamFilterAnalyzeHeaders, html::SpamFilterAnalyzeHtml, ip::SpamFilterAnalyzeIp, + messageid::SpamFilterAnalyzeMid, mime::SpamFilterAnalyzeMime, + pyzor::SpamFilterAnalyzePyzor, received::SpamFilterAnalyzeReceived, + recipient::SpamFilterAnalyzeRecipient, replyto::SpamFilterAnalyzeReplyTo, + rules::SpamFilterAnalyzeRules, subject::SpamFilterAnalyzeSubject, + url::SpamFilterAnalyzeUrl, + }, +}; +use common::{Server, config::mailstore::spamfilter::SpamFilterAction}; +use std::{fmt::Write, future::Future, vec}; + + +pub trait SpamFilterAnalyzeScore: Sync + Send { + fn spam_filter_finalize( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future> + Send; + + fn spam_filter_classify( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future> + Send; +} + +#[derive(Debug, Default)] +pub struct SpamFilterScore { + pub results: Vec, + pub headers: String, + pub train_spam: Option, + pub score: f32, + pub is_spam: bool, +} + +impl SpamFilterAnalyzeScore for Server { + async fn spam_filter_finalize( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> SpamFilterAction { + // Calculate final score + let mut results = vec![]; + let mut header_len = 60; + let mut is_spam_trap = false; + let mut rbl_count = 0; + + for tag in &ctx.result.tags { + let score = match self.core.spam.lists.scores.get(tag) { + Some(SpamFilterAction::Allow(score)) => *score, + Some(SpamFilterAction::Discard) => { + return SpamFilterAction::Discard; + } + Some(SpamFilterAction::Reject) => { + return SpamFilterAction::Reject; + } + None | Some(SpamFilterAction::Disabled) => 0.0, + }; + if tag == "SPAM_TRAP" { + is_spam_trap = true; + } else if score > 1.0 && tag.starts_with("RBL_") { + rbl_count += 1; + } + ctx.result.score += score; + header_len += tag.len() + 10; + if score != 0.0 || !tag.starts_with("X_") { + results.push((tag.as_str(), score)); + } + } + + let mut final_score = ctx.result.score; + let mut avg_confidence: f32 = 0.0; + let mut total_results = 0; + let mut user_results = vec![ctx.result.score; ctx.input.env_rcpt_rewritten_to.len()]; + if !ctx.result.classifier_confidence.is_empty() { + for (idx, &confidence) in ctx.result.classifier_confidence.iter().enumerate() { + if let Some(confidence) = confidence { + avg_confidence += confidence; + total_results += 1; + + let user_score = self + .core + .spam + .lists + .scores + .get(confidence.spam_tag()) + .and_then(|v| v.as_score()) + .copied() + .unwrap_or_default(); + + user_results[idx] = ctx.result.score + user_score; + } + } + + if total_results > 0 { + avg_confidence /= total_results as f32; + + let tag = avg_confidence.spam_tag(); + let score = self + .core + .spam + .lists + .scores + .get(tag) + .and_then(|v| v.as_score()) + .copied() + .unwrap_or_default(); + results.push((tag, score)); + final_score += score; + } + } + + if self.core.spam.scores.reject_threshold > 0.0 + && final_score >= self.core.spam.scores.reject_threshold + { + SpamFilterAction::Reject + } else if self.core.spam.scores.discard_threshold > 0.0 + && final_score >= self.core.spam.scores.discard_threshold + { + SpamFilterAction::Discard + } else { + let mut headers = String::with_capacity(header_len + 40); + results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(b.0))); + headers.push_str("X-Spam-Result: "); + for (idx, (tag, score)) in results.into_iter().enumerate() { + if idx > 0 { + headers.push_str(",\r\n\t"); + } + let _ = write!(&mut headers, "{} ({:.2})", tag, score); + } + headers.push_str("\r\n"); + + if let Some((category, explanation)) = &ctx.result.llm_result { + let _ = write!(&mut headers, "X-Spam-LLM: {category} ({explanation})\r\n",); + } + + let is_spam = final_score >= self.core.spam.scores.spam_threshold; + let class = if is_spam { "spam" } else { "ham" }; + + if avg_confidence != 0.0 { + let _ = write!( + &mut headers, + "X-Spam-Score: {class}, score={final_score:.2}, avg_confidence={avg_confidence:.2}\r\n", + ); + } else { + let _ = write!( + &mut headers, + "X-Spam-Score: {class}, score={final_score:.2}\r\n", + ); + } + + // Autolearn SPAM + let mut train_spam = None; + if is_spam + && self.core.spam.classifier.as_ref().is_some_and(|c| { + (c.auto_learn_spam_trap && is_spam_trap) + || (c.auto_learn_spam_rbl_count > 0 + && rbl_count >= c.auto_learn_spam_rbl_count) + }) + { + train_spam = Some(true); + } + + SpamFilterAction::Allow(SpamFilterScore { + results: user_results, + headers, + train_spam, + score: final_score, + is_spam, + }) + } + } + + async fn spam_filter_classify( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> SpamFilterAction { + // IP address analysis + self.spam_filter_analyze_ip(ctx).await; + + // DMARC/SPF/DKIM/ARC analysis + self.spam_filter_analyze_dmarc(ctx).await; + + // EHLO hostname analysis + self.spam_filter_analyze_ehlo(ctx).await; + + // Generic header analysis + self.spam_filter_analyze_headers(ctx).await; + + // Received headers analysis + self.spam_filter_analyze_received(ctx).await; + + // Message-ID analysis + self.spam_filter_analyze_message_id(ctx).await; + + // Date header analysis + self.spam_filter_analyze_date(ctx).await; + + // Subject analysis + self.spam_filter_analyze_subject(ctx).await; + + // From and Envelope From analysis + self.spam_filter_analyze_from(ctx).await; + + // Reply-To analysis + self.spam_filter_analyze_reply_to(ctx).await; + + // Recipient analysis + self.spam_filter_analyze_recipient(ctx).await; + + // E-mail and domain analysis + self.spam_filter_analyze_domain(ctx).await; + + // URL analysis + self.spam_filter_analyze_url(ctx).await; + + // MIME part analysis + self.spam_filter_analyze_mime(ctx).await; + + // HTML content analysis + self.spam_filter_analyze_html(ctx).await; + + + // Spam trap + self.spam_filter_analyze_spam_trap(ctx).await; + + // Pyzor checks + self.spam_filter_analyze_pyzor(ctx).await; + + // Model classification + self.spam_filter_analyze_classify(ctx).await; + + // User-defined rules + self.spam_filter_analyze_rules(ctx).await; + + // Final score calculation + self.spam_filter_finalize(ctx).await + } +} + +pub trait ConfidenceStore { + fn spam_tag(&self) -> &'static str; +} + +impl ConfidenceStore for f32 { + fn spam_tag(&self) -> &'static str { + match *self { + p if p < 0.15 => "PROB_HAM_HIGH", + p if p < 0.25 => "PROB_HAM_MEDIUM", + p if p < 0.40 => "PROB_HAM_LOW", + p if p < 0.60 => "PROB_SPAM_UNCERTAIN", + p if p < 0.75 => "PROB_SPAM_LOW", + p if p < 0.85 => "PROB_SPAM_MEDIUM", + p => { + if p.is_finite() { + "PROB_SPAM_HIGH" + } else { + "PROB_SPAM_UNCERTAIN" + } + } + } + } +} diff --git a/crates/spam-filter/src/analysis/subject.rs b/crates/spam-filter/src/analysis/subject.rs new file mode 100644 index 0000000..afeacf0 --- /dev/null +++ b/crates/spam-filter/src/analysis/subject.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::Server; +use mail_parser::HeaderName; +use nlp::tokenizers::types::TokenType; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeSubject: Sync + Send { + fn spam_filter_analyze_subject( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeSubject for Server { + async fn spam_filter_analyze_subject(&self, ctx: &mut SpamFilterContext<'_>) { + let mut subject_raw = b"".as_slice(); + + for header in ctx.input.message.headers() { + if header.name == HeaderName::Subject { + subject_raw = ctx + .input + .message + .raw_message() + .get(header.offset_start as usize..header.offset_end as usize) + .unwrap_or_default(); + break; + } + } + + if subject_raw.is_empty() { + // Missing subject header + ctx.result.add_tag("MISSING_SUBJECT"); + return; + } + + let mut word_count = 0; + let mut upper_count = 0; + let mut lower_count = 0; + + let mut last_ch = ' '; + let mut is_ascii = true; + + for ch in ctx.output.subject_thread.chars() { + if !ch.is_whitespace() { + if last_ch.is_whitespace() { + word_count += 1; + } + + match ch { + '$' | '€' | '£' | '¥' | '₹' | '₽' | '₿' => { + ctx.result.add_tag("SUBJECT_HAS_CURRENCY"); + } + _ => { + if ch.is_alphabetic() { + if ch.is_uppercase() { + upper_count += 1; + } else { + lower_count += 1; + } + } + } + } + } + + if !ch.is_ascii() { + is_ascii = false; + } + + last_ch = ch; + } + + if ctx.output.subject_lc.is_empty() { + // Subject is empty + ctx.result.add_tag("EMPTY_SUBJECT"); + } else if ctx.output.subject.ends_with(' ') { + // Subject ends with whitespace + ctx.result.add_tag("SUBJECT_ENDS_SPACES"); + } else if ctx.output.subject + == "XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X" + { + ctx.result.add_tag("GTUBE_TEST"); + } + + if ctx.output.subject_thread.len() >= 10 + && word_count > 1 + && upper_count > 2 + && lower_count == 0 + { + // Subject contains mostly capital letters + ctx.result.add_tag("SUBJ_ALL_CAPS"); + } + + for token in &ctx.output.subject_tokens { + match token { + TokenType::Url(url) => { + // Subject contains URL + ctx.result.add_tag("URL_IN_SUBJECT"); + + if let Some(url_parsed) = &url.url_parsed { + let host = url_parsed.host.sld_or_default(); + for rcpt in ctx.output.all_recipients() { + if rcpt.email.domain_part.sld_or_default() == host { + ctx.result.add_tag("RCPT_DOMAIN_IN_SUBJECT"); + break; + } + } + } + } + TokenType::UrlNoScheme(url) => { + if let Some(url_parsed) = &url.url_parsed { + let host = url_parsed.host.sld_or_default(); + for rcpt in ctx.output.all_recipients() { + if rcpt.email.domain_part.sld_or_default() == host { + ctx.result.add_tag("RCPT_DOMAIN_IN_SUBJECT"); + break; + } + } + } + } + TokenType::Email(email) => { + // Subject contains recipient + if ctx.output.env_to_orig_addr.contains(email) + || ctx.output.all_recipients().any(|r| &r.email == email) + { + ctx.result.add_tag("RCPT_IN_SUBJECT"); + } else { + let host = email.domain_part.sld_or_default(); + for rcpt in ctx.output.all_recipients() { + if &rcpt.email == email { + ctx.result.add_tag("RCPT_IN_SUBJECT"); + break; + } else if rcpt.email.domain_part.sld_or_default() == host { + ctx.result.add_tag("RCPT_DOMAIN_IN_SUBJECT"); + break; + } + } + } + } + _ => {} + } + } + + // Validate encoding + let subject_raw_utf8 = std::str::from_utf8(subject_raw); + if !subject_raw.is_ascii() { + if (ctx.input.env_from_flags + & (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME)) + == 0 + { + ctx.result.add_tag("SUBJECT_NEEDS_ENCODING"); + } + + if subject_raw_utf8.is_err() { + ctx.result.add_tag("INVALID_SUBJECT_8BIT"); + } + } + + // Validate unnecessary encoding + let subject_raw_utf8 = subject_raw_utf8.unwrap_or_default(); + if is_ascii && subject_raw_utf8.contains("=?") && subject_raw_utf8.contains("?=") { + if subject_raw_utf8.contains("?q?") || subject_raw_utf8.contains("?Q?") { + // Subject header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("SUBJ_EXCESS_QP"); + } else if subject_raw_utf8.contains("?b?") || subject_raw_utf8.contains("?B?") { + // Subject header is unnecessarily encoded in base64 + ctx.result.add_tag("SUBJ_EXCESS_BASE64"); + } + } + } +} diff --git a/crates/spam-filter/src/analysis/url.rs b/crates/spam-filter/src/analysis/url.rs new file mode 100644 index 0000000..c3b8f60 --- /dev/null +++ b/crates/spam-filter/src/analysis/url.rs @@ -0,0 +1,507 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ElementLocation, is_trusted_domain, is_url_redirector}; +use crate::modules::dnsbl::check_dnsbl; +use crate::modules::expression::StringResolver; +use crate::modules::html::SRC; +use crate::{ + Hostname, SpamFilterContext, TextPart, + modules::html::{A, HREF, HtmlToken}, +}; +use common::Server; +use common::config::mailstore::spamfilter::{Element, IpResolver, Location}; +use common::scripts::IsMixedCharset; +use common::scripts::functions::unicode::CharUtils; +use hyper::{Uri, header::LOCATION}; +use nlp::tokenizers::types::TokenType; +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; +use std::{borrow::Cow, future::Future, time::Duration}; + +const HTTPS_SCHEME: &str = "https://"; + +pub trait SpamFilterAnalyzeUrl: Sync + Send { + fn spam_filter_analyze_url( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +#[derive(Clone, Debug)] +pub struct UrlParts<'x> { + pub url: String, + pub url_original: Cow<'x, str>, + pub url_parsed: Option, + pub has_scheme: bool, +} + +#[derive(Clone, Debug)] +pub struct UrlParsed { + pub parts: Uri, + pub host: Hostname, +} + +impl SpamFilterAnalyzeUrl for Server { + async fn spam_filter_analyze_url(&self, ctx: &mut SpamFilterContext<'_>) { + // Extract URLs + let mut urls: HashSet>> = HashSet::new(); + let mut inferred_urls: HashSet>> = HashSet::new(); + for token in &ctx.output.subject_tokens { + if let TokenType::Url(url) | TokenType::UrlNoScheme(url) = token { + collect_url( + &mut urls, + &mut inferred_urls, + url.to_owned(), + Location::HeaderSubject, + ); + } + } + for (part_id, part) in ctx.output.text_parts.iter().enumerate() { + let part_id = part_id as u32; + let is_body = ctx.input.message.text_body.contains(&part_id) + || ctx.input.message.html_body.contains(&part_id); + let text_location = if is_body { + Location::BodyText + } else { + Location::Attachment + }; + + let tokens = match part { + TextPart::Plain { tokens, .. } => tokens, + TextPart::Html { + html_tokens, + tokens, + .. + } => { + for token in html_tokens { + if let HtmlToken::StartTag { attributes, .. } = token { + for (attr, value) in attributes { + match value { + Some(value) if [HREF, SRC].contains(attr) => { + collect_url( + &mut urls, + &mut inferred_urls, + UrlParts::new(value.trim().to_string()), + if is_body { + Location::BodyHtml + } else { + Location::Attachment + }, + ); + } + _ => {} + } + } + } + } + tokens + } + TextPart::None => &[][..], + }; + + for token in tokens { + match token { + TokenType::Url(url) | TokenType::UrlNoScheme(url) => { + if !ctx.input.is_train + && is_body + && !ctx.result.has_tag("RCPT_DOMAIN_IN_BODY") + && let Some(url_parsed) = &url.url_parsed + { + let host = url_parsed.host.sld_or_default(); + for rcpt in ctx.output.all_recipients() { + if rcpt.email.domain_part.sld_or_default() == host { + ctx.result.add_tag("RCPT_DOMAIN_IN_BODY"); + break; + } + } + } + + collect_url(&mut urls, &mut inferred_urls, url.to_owned(), text_location); + } + _ => {} + } + } + + if is_body && !ctx.input.is_train { + let is_single = match part { + TextPart::Plain { tokens, .. } => is_single_url(tokens), + TextPart::Html { + html_tokens, + tokens, + .. + } => is_single_html_url(html_tokens, tokens), + TextPart::None => false, + }; + + if is_single { + ctx.result.add_tag("URL_ONLY"); + } + } + } + + urls.extend(inferred_urls); + + if !ctx.input.is_train { + let mut redirected_urls = HashSet::new(); + let mut trusted_domains: HashSet = HashSet::new(); + + { + let mut checked_domains: HashSet<&str> = HashSet::new(); + for url in &urls { + if let Some(url_parsed) = &url.element.url_parsed { + let host_sld = url_parsed.host.sld_or_default(); + if checked_domains.insert(host_sld) + && is_trusted_domain(self, host_sld, ctx.input.span_id).await + { + trusted_domains.insert(host_sld.into()); + } + } + } + } + + for url in &urls { + for ch in url.element.url.chars() { + if ch.is_zwsp() { + ctx.result.add_tag("ZERO_WIDTH_SPACE_URL"); + } + + if ch.is_obscured() { + ctx.result.add_tag("SUSPICIOUS_URL"); + } + } + + // Skip non-URLs such as 'data:' and 'mailto:' + if !url.element.url.contains("://") { + continue; + } + + // Obtain parse url + let url_parsed = if let Some(url_parsed) = &url.element.url_parsed { + url_parsed + } else { + // URL could not be parsed + ctx.result.add_tag("UNPARSABLE_URL"); + continue; + }; + let host_sld = url_parsed.host.sld_or_default(); + + // Skip local and trusted domains + if trusted_domains.contains(host_sld) { + continue; + } + + if let Some(ip) = url_parsed.host.ip { + // Check IP DNSBL + check_dnsbl(self, ctx, &IpResolver::new(ip), Element::Ip, url.location).await; + } else if is_url_redirector(self, host_sld, ctx.input.span_id).await { + // Check for redirectors + ctx.result.add_tag("REDIRECTOR_URL"); + + if !ctx.result.has_tag("URL_REDIRECTOR_NESTED") { + let mut redirect_count = 1; + let mut url_redirect = Cow::Borrowed(url.element.url.as_str()); + + while redirect_count <= 3 { + match http_get_header( + self, + url_redirect.as_ref(), + LOCATION, + Duration::from_secs(5), + ) + .await + { + Ok(Some(location)) => { + let location = UrlParts::new(location); + if let Some(location_parsed) = &location.url_parsed { + if is_url_redirector( + self, + location_parsed.host.sld_or_default(), + ctx.input.span_id, + ) + .await + { + url_redirect = Cow::Owned(location.url); + redirect_count += 1; + continue; + } else { + if is_trusted_domain( + self, + location_parsed.host.sld_or_default(), + ctx.input.span_id, + ) + .await + { + trusted_domains.insert( + location_parsed.host.sld_or_default().into(), + ); + } + + redirected_urls.insert(ElementLocation::new( + location, + url.location, + )); + } + } + } + Ok(None) => {} + Err(err) => { + trc::error!(err.span_id(ctx.input.span_id)); + } + } + break; + } + + if redirect_count > 3 { + ctx.result.add_tag("URL_REDIRECTOR_NESTED"); + } + } + } + } + + urls.extend(redirected_urls); + + for (el, url_parsed) in urls.iter().filter_map(|el| { + el.element + .url_parsed + .as_ref() + .map(|url_parsed| (el, url_parsed)) + }) { + let host = &url_parsed.host; + let is_explicit_link = el.element.is_explicit_link(); + + if host.ip.is_none() { + if !host.fqdn.is_ascii() { + if let Ok(cured_host) = + decancer::cure(&host.fqdn, decancer::Options::default()) + { + let cured_host = cured_host.to_string(); + if cured_host != host.fqdn + && matches!(self.dns_exists_ip(&cured_host).await, Ok(true)) + { + ctx.result.add_tag("HOMOGRAPH_URL"); + } + } + + if host.fqdn.is_mixed_charset() { + ctx.result.add_tag("MIXED_CHARSET_URL"); + } + } + + // Check Domain DNSBL + if is_explicit_link + && let Some(sld) = &host.sld + && !trusted_domains.contains(sld.as_str()) + { + check_dnsbl( + self, + ctx, + &StringResolver(sld), + Element::Domain, + el.location, + ) + .await; + } + } else { + // URL is an ip address + ctx.result.add_tag("SUSPICIOUS_URL"); + } + + // Check URL DNSBL + if is_explicit_link { + check_dnsbl(self, ctx, &el.element, Element::Url, el.location).await; + } + } + } + + // Update context + ctx.output.urls = urls; + } +} + +#[allow(unreachable_code)] +#[allow(unused_variables)] +async fn http_get_header( + server: &Server, + url: &str, + header: hyper::header::HeaderName, + timeout: Duration, +) -> trc::Result> { + #[cfg(feature = "test_mode")] + { + return if url.contains("redirect.") { + Ok(url.split_once("/?").unwrap().1.to_string().into()) + } else { + Ok(None) + }; + } + server + .core + .spam + .url_client + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + trc::SieveEvent::RuntimeError + .into_err() + .reason(err) + .details("Failed to send request") + }) + .map(|response| { + response + .headers() + .get(header) + .and_then(|h| h.to_str().ok()) + .map(|h| h.to_string()) + }) +} + +fn collect_url( + urls: &mut HashSet>>, + inferred_urls: &mut HashSet>>, + url: UrlParts<'static>, + location: Location, +) { + if url.is_explicit_link() { + urls.insert(ElementLocation::new(url, location)); + } else { + inferred_urls.insert(ElementLocation::new(url, location)); + } +} + +fn is_single_url(tokens: &[TokenType]) -> bool { + let mut url_count = 0; + let mut word_count = 0; + + for token in tokens { + match token { + TokenType::Alphabetic(_) + | TokenType::Alphanumeric(_) + | TokenType::Integer(_) + | TokenType::Email(_) + | TokenType::Float(_) => { + word_count += 1; + } + TokenType::Url(_) | TokenType::UrlNoScheme(_) => { + url_count += 1; + } + _ => {} + } + } + + url_count == 1 && word_count <= 1 +} + +fn is_single_html_url( + html_tokens: &[HtmlToken], + tokens: &[TokenType], +) -> bool { + let mut url_count = 0; + let mut word_count = 0; + + for token in tokens { + match token { + TokenType::Alphabetic(_) + | TokenType::Alphanumeric(_) + | TokenType::Integer(_) + | TokenType::Email(_) + | TokenType::Float(_) => { + word_count += 1; + } + TokenType::Url(_) | TokenType::UrlNoScheme(_) => { + url_count += 1; + } + _ => {} + } + } + + if word_count > 1 || url_count != 1 { + return false; + } + + url_count = 0; + + for token in html_tokens { + if matches!(token, HtmlToken::StartTag { name, attributes, .. } if *name == A && attributes.iter().any(|(k, _)| *k == HREF)) + { + url_count += 1; + } + } + + url_count == 1 +} + +impl PartialEq for UrlParts<'_> { + fn eq(&self, other: &Self) -> bool { + self.url == other.url + } +} + +impl Eq for UrlParts<'_> {} + +impl Hash for UrlParts<'_> { + fn hash(&self, state: &mut H) { + self.url.hash(state); + } +} + +impl<'x> UrlParts<'x> { + pub fn new(url: impl Into>) -> Self { + let url_original = url.into(); + let url = url_original.trim().to_lowercase(); + + Self { + url_parsed: Self::parse(&url), + url, + url_original, + has_scheme: true, + } + } + + pub fn no_scheme(url: impl Into>) -> Self { + let url_original = url.into(); + let host = url_original.trim().to_lowercase(); + let mut url = String::with_capacity(HTTPS_SCHEME.len() + host.len()); + url.push_str(HTTPS_SCHEME); + url.push_str(&host); + + Self { + url_parsed: Self::parse(&url), + url, + url_original, + has_scheme: false, + } + } + + pub fn is_explicit_link(&self) -> bool { + self.has_scheme + || self.url_original.contains(['/', '?']) + || self + .url_parsed + .as_ref() + .is_some_and(|url| url.host.fqdn.starts_with("www.")) + } + + fn parse(url: &str) -> Option { + url.parse::().ok().and_then(|parts| { + parts + .host() + .map(Hostname::new) + .map(|host| UrlParsed { host, parts }) + }) + } + + pub fn to_owned(&self) -> UrlParts<'static> { + UrlParts { + url: self.url.clone(), + url_original: Cow::Owned(self.url_original.clone().into_owned()), + url_parsed: self.url_parsed.clone(), + has_scheme: self.has_scheme, + } + } +} diff --git a/crates/spam-filter/src/lib.rs b/crates/spam-filter/src/lib.rs new file mode 100644 index 0000000..1f773f3 --- /dev/null +++ b/crates/spam-filter/src/lib.rs @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod analysis; +pub mod modules; + +use analysis::ElementLocation; +use analysis::url::UrlParts; +use mail_auth::{ + ArcOutput, DkimOutput, DmarcResult, IprevOutput, SpfOutput, dkim2::Dkim2Output, dmarc::Policy, +}; +use mail_parser::Message; +use modules::html::HtmlToken; +use nlp::tokenizers::types::TokenType; +use std::borrow::Cow; +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; +use std::net::{IpAddr, Ipv4Addr}; +use store::ahash::AHashSet; + +pub struct SpamFilterInput<'x> { + pub message: &'x Message<'x>, + pub span_id: u64, + + // Sender authentication + pub arc_result: Option<&'x ArcOutput<'x>>, + pub spf_ehlo_result: Option<&'x SpfOutput>, + pub spf_mail_from_result: Option<&'x SpfOutput>, + pub dkim_result: &'x [DkimOutput<'x>], + pub dkim2_result: Option<&'x Dkim2Output<'x>>, + pub dmarc_result: Option<&'x DmarcResult>, + pub dmarc_policy: Option<&'x Policy>, + pub iprev_result: Option<&'x IprevOutput>, + + // Session details + pub remote_ip: IpAddr, + pub ehlo_domain: Option<&'x str>, + pub authenticated_as: Option<&'x str>, + pub asn: Option, + pub country: Option<&'x str>, + + // TLS + pub is_tls: bool, + + // Envelope + pub env_from: &'x str, + pub env_from_flags: u64, + pub env_rcpt_orig_to: Vec<&'x str>, + pub env_rcpt_rewritten_to: Vec<&'x str>, + + pub is_train: bool, + pub is_test: bool, +} + +pub struct SpamFilterOutput<'x> { + pub ehlo_host: Hostname, + pub iprev_ptr: Option, + + pub env_from_addr: Email, + pub env_from_postmaster: bool, + pub env_to_orig_addr: HashSet, + pub env_to_rewritten_addr: HashSet, + pub from: Recipient, + pub recipients_to: Vec, + pub recipients_cc: Vec, + pub recipients_bcc: Vec, + pub reply_to: Option, + + pub subject: String, + pub subject_lc: String, + pub subject_thread: String, + pub subject_thread_lc: String, + pub subject_tokens: Vec, Email, UrlParts<'x>, IpParts>>, + + pub ips: AHashSet>, + pub urls: HashSet>>, + pub emails: HashSet>, + pub domains: HashSet>, + + pub text_parts: Vec>, +} + +#[derive(Debug)] +pub struct IpParts { + ip: Option, +} + +pub enum TextPart<'x> { + Plain { + text_body: &'x str, + tokens: Vec, Email, UrlParts<'x>, IpParts>>, + }, + Html { + html_tokens: Vec, + text_body: String, + tokens: Vec, Email, UrlParts<'x>, IpParts>>, + }, + None, +} + +#[derive(Debug, Default)] +pub struct SpamFilterResult { + pub tags: AHashSet, + pub classifier_confidence: Vec>, + pub score: f32, + pub rbl_ip_checks: usize, + pub rbl_domain_checks: usize, + pub rbl_url_checks: usize, + pub rbl_email_checks: usize, + pub llm_result: Option<(String, String)>, +} + +pub struct SpamFilterContext<'x> { + pub input: SpamFilterInput<'x>, + pub output: SpamFilterOutput<'x>, + pub result: SpamFilterResult, +} + +#[derive(Debug, Clone)] +pub struct Hostname { + pub fqdn: String, + pub ip: Option, + pub sld: Option, +} + +#[derive(Debug, Clone)] +pub struct Email { + pub address: String, + pub local_part: String, + pub domain_part: Hostname, +} + +#[derive(Debug, Clone)] +pub struct Recipient { + pub email: Email, + pub name: Option, +} + +impl<'x> SpamFilterInput<'x> { + pub fn from_message(message: &'x Message<'x>, span_id: u64) -> Self { + Self { + message, + span_id, + arc_result: None, + spf_ehlo_result: None, + spf_mail_from_result: None, + dkim_result: &[], + dkim2_result: None, + dmarc_result: None, + dmarc_policy: None, + iprev_result: None, + remote_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + ehlo_domain: None, + authenticated_as: None, + asn: None, + country: None, + is_tls: true, + env_from: "", + env_from_flags: 0, + env_rcpt_rewritten_to: vec![], + env_rcpt_orig_to: vec![], + is_test: false, + is_train: false, + } + } + + pub fn train_mode(mut self) -> Self { + self.is_train = true; + self + } +} + +impl PartialEq for Hostname { + fn eq(&self, other: &Self) -> bool { + self.fqdn.eq(&other.fqdn) + } +} + +impl Eq for Hostname {} + +impl PartialEq for Email { + fn eq(&self, other: &Self) -> bool { + self.local_part.eq(&other.local_part) && self.domain_part.eq(&other.domain_part) + } +} + +impl Eq for Email {} + +impl Hash for Hostname { + fn hash(&self, state: &mut H) { + self.fqdn.hash(state) + } +} + +impl Hash for Email { + fn hash(&self, state: &mut H) { + self.local_part.hash(state); + self.domain_part.hash(state); + } +} + +impl Email { + pub fn classifier_parts(&self) -> Option<(&str, &str)> { + // Returns (local@, @domain) + if self.is_valid() { + let at_pos = self.address.find('@')?; + Some((&self.address[..=at_pos], &self.address[at_pos..])) + } else { + None + } + } + + pub fn is_valid(&self) -> bool { + self.domain_part.sld.is_some() && !self.local_part.is_empty() + } +} + +impl PartialEq for Recipient { + fn eq(&self, other: &Self) -> bool { + self.email.eq(&other.email) + } +} + +impl Eq for Recipient {} + +impl Hash for Recipient { + fn hash(&self, state: &mut H) { + self.email.hash(state) + } +} + +impl PartialOrd for Email { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialOrd for Recipient { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Email { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.local_part + .cmp(&other.local_part) + .then_with(|| self.domain_part.fqdn.cmp(&other.domain_part.fqdn)) + } +} + +impl Ord for Recipient { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.email.cmp(&other.email) + } +} diff --git a/crates/spam-filter/src/modules/classifier.rs b/crates/spam-filter/src/modules/classifier.rs new file mode 100644 index 0000000..4637970 --- /dev/null +++ b/crates/spam-filter/src/modules/classifier.rs @@ -0,0 +1,1495 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::analysis::domain::SpamFilterAnalyzeDomain; +use crate::analysis::init::SpamFilterInit; +use crate::analysis::is_trusted_domain; +use crate::analysis::url::SpamFilterAnalyzeUrl; +use crate::modules::html::{A, ALT, HREF, HtmlToken, IMG, SRC, TITLE}; +use crate::{Email, SpamFilterContext, TextPart}; +use crate::{Hostname, SpamFilterInput}; +use common::config::mailstore::spamfilter; +use common::manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY}; +use common::{Server, config::mailstore::spamfilter::Location, ipc::BroadcastEvent}; +use mail_auth::DmarcResult; +use mail_parser::{MessageParser, MimeHeaders}; +use nlp::classifier::feature::{ + CcfhFeature, CcfhFeatureBuilder, FeatureBuilder, FhFeature, FhFeatureBuilder, Sample, + UnprocessedFeature, +}; +use nlp::classifier::ftrl::Ftrl; +use nlp::classifier::reservoir::SampleReservoir; +use nlp::classifier::train::{CcfhTrainer, FhTrainer}; +use nlp::tokenizers::types::TypesTokenizer; +use nlp::tokenizers::{stream::WordStemTokenizer, types::TokenType}; +use registry::schema::prelude::{ObjectType, Property}; +use registry::schema::structs::SpamTrainingSample; +use registry::types::EnumImpl; +use std::time::Instant; +use std::{ + borrow::Cow, + collections::{HashMap, hash_map::Entry}, + hash::{Hash, RandomState}, + sync::Arc, +}; +use store::ahash::AHashSet; +use store::rand::seq::SliceRandom; +use store::write::{BlobLink, RegistryClass, now}; +use store::{ + Deserialize, IterateParams, Serialize, ValueKey, + write::{ + AlignedBytes, Archive, Archiver, BatchBuilder, BlobOp, ValueClass, + key::DeserializeBigEndian, + }, +}; +use store::{SerializeInfallible, U16_LEN}; +use tokio::sync::{mpsc, oneshot}; +use trc::{AddContext, SpamEvent}; +use types::blob_hash::BlobHash; +use unicode_general_category::{GeneralCategory, get_general_category}; +use unicode_normalization::UnicodeNormalization; +use unicode_security::mixed_script::AugmentedScriptSet; + +pub trait SpamClassifier { + fn spam_train(&self, retrain: bool) -> impl Future> + Send; + + fn spam_classify( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future> + Send; + + fn spam_build_tokens<'x>( + &self, + ctx: &'x SpamFilterContext<'_>, + ) -> impl Future> + Send; +} + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Clone, + PartialEq, + Eq, + Debug, + PartialOrd, + Ord, + Hash, +)] +pub struct TrainingSample { + hash: BlobHash, + account_id: u32, +} + +#[derive(Debug)] +struct TrainingTask { + id: u64, + sample: TrainingSample, + is_spam: bool, + is_replay: bool, + remove: Option, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)] +pub struct SpamTrainer { + pub trainer: SpamTrainerClass, + pub reservoir: SampleReservoir, + pub last_id: u64, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug)] +pub enum SpamTrainerClass { + FtrlFh(Box>), + FtrlCfh(Box>), +} + +impl SpamClassifier for Server { + async fn spam_train(&self, retrain: bool) -> trc::Result<()> { + let Some(config) = &self.core.spam.classifier else { + return Ok(()); + }; + + let _permit = self + .inner + .ipc + .train_task_controller + .try_run() + .ok_or_else(|| { + trc::EventType::Spam(SpamEvent::TrainCompleted) + .reason("Spam training task is already running") + .caused_by(trc::location!()) + })?; + + let started = Instant::now(); + trc::event!(Spam(SpamEvent::TrainStarted)); + + // Fetch or build trainer + let mut trainer = if !retrain + && let Some(trainer) = self + .blob_store() + .get_blob(SPAM_TRAINER_KEY, 0..usize::MAX) + .await + .and_then(|archive| match archive { + Some(archive) => as Deserialize>::deserialize(&archive) + .and_then(|archive| archive.deserialize_untrusted::()) + .map(Some), + None => Ok(None), + }) + .caused_by(trc::location!())? + { + trainer + } else { + SpamTrainer { + trainer: match &config.i_params { + Some(i_params) => SpamTrainerClass::FtrlCfh(Box::new(CcfhTrainer::new( + Ftrl::new(config.w_params.feature_hash_size), + Ftrl::new(i_params.feature_hash_size).with_initial_weights(0.5), + ))), + None => SpamTrainerClass::FtrlFh(Box::new(FhTrainer::new(Ftrl::new( + config.w_params.feature_hash_size, + )))), + }, + reservoir: SampleReservoir::default(), + last_id: 0, + } + }; + + // Update hyperparameters + match (&mut trainer.trainer, &config.i_params) { + (SpamTrainerClass::FtrlFh(trainer), None) => { + trainer.optimizer_mut().set_hyperparams( + config.w_params.alpha, + config.w_params.beta, + config.w_params.l1_ratio, + config.w_params.l2_ratio, + ); + } + (SpamTrainerClass::FtrlCfh(trainer), Some(i_params)) => { + trainer.w_optimizer_mut().set_hyperparams( + config.w_params.alpha, + config.w_params.beta, + config.w_params.l1_ratio, + config.w_params.l2_ratio, + ); + trainer.i_optimizer_mut().set_hyperparams( + i_params.alpha, + i_params.beta, + i_params.l1_ratio, + i_params.l2_ratio, + ); + } + _ => {} + } + + // Fetch blob hashes for samples + let mut samples = Vec::new(); + let mut duplicate_samples = Vec::new(); + let mut remove_entries = false; + let object_id = ObjectType::SpamTrainingSample.to_id(); + let from_key = ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: trainer.last_id + 1, + })); + let to_key = ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: u64::MAX, + })); + let mut seen_samples = AHashSet::new(); + let mut spam_count = 0; + let mut ham_count = 0; + self.store() + .iterate( + IterateParams::new(from_key, to_key).descending(), + |key, value| { + let id = key.deserialize_be_u64(U16_LEN)?; + let sample = SpamTrainingSample::deserialize(value)?; + + let until = sample.expires_at.timestamp() as u64; + let do_remove = sample.delete_after_use; + let is_spam = sample.is_spam; + let sample = TrainingSample { + hash: sample.blob_id.hash, + account_id: sample + .account_id + .map(|a| a.document_id()) + .unwrap_or(u32::MAX), + }; + + if seen_samples.insert(sample.clone()) { + // Add to reservoir + if !do_remove { + trainer.reservoir.update_reservoir( + &sample, + is_spam, + config.reservoir_capacity, + ); + } else { + trainer.reservoir.update_counts(is_spam); + } + + samples.push(TrainingTask { + id, + sample, + is_spam, + is_replay: false, + remove: do_remove.then_some(until), + }); + + remove_entries |= do_remove; + + // Update trainer stats + if is_spam { + spam_count += 1; + } else { + ham_count += 1; + } + } else { + duplicate_samples.push(TrainingTask { + id, + sample, + is_spam, + is_replay: false, + remove: Some(until), + }); + remove_entries = true; + } + + if trainer.last_id == 0 { + trainer.last_id = id; + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if samples.is_empty() { + trc::event!( + Spam(SpamEvent::TrainCompleted), + Total = 0, + Elapsed = started.elapsed() + ); + + return if duplicate_samples.is_empty() { + Ok(()) + } else { + delete_samples(self, samples, duplicate_samples).await + }; + } else if (trainer.reservoir.ham.total_seen < config.min_ham_samples) + || (trainer.reservoir.spam.total_seen < config.min_spam_samples) + { + trc::event!( + Spam(SpamEvent::ModelNotReady), + Reason = "Not enough samples for training", + Details = vec![ + trc::Value::from(trainer.reservoir.ham.total_seen), + trc::Value::from(trainer.reservoir.spam.total_seen) + ], + Limit = vec![ + trc::Value::from(config.min_ham_samples), + trc::Value::from(config.min_spam_samples) + ], + Elapsed = started.elapsed() + ); + + return if duplicate_samples.is_empty() { + Ok(()) + } else { + delete_samples(self, samples, duplicate_samples).await + }; + } + + // Balance classes if needed + if spam_count > ham_count { + // We have too much spam this time. We need to replay old HAM. + samples.extend( + trainer + .reservoir + .replay_samples((spam_count - ham_count) as usize, false) + .map(|sample| TrainingTask { + id: 0, + sample: sample.clone(), + is_spam: false, + is_replay: true, + remove: None, + }), + ); + } else if ham_count > spam_count { + // We have too much ham this time. We need to replay old SPAM. + samples.extend( + trainer + .reservoir + .replay_samples((ham_count - spam_count) as usize, true) + .map(|sample| TrainingTask { + id: 0, + sample: sample.clone(), + is_spam: true, + is_replay: true, + remove: None, + }), + ); + } + + let num_samples = samples.len(); + samples.shuffle(&mut store::rand::rng()); + + // Spawn training task + let epochs = match trainer + .reservoir + .ham + .total_seen + .min(trainer.reservoir.spam.total_seen) + { + 0..=50 => 3, // Bootstrap + 51..=200 => 2, // Refinement + _ => 1, // Full online training + }; + let task = trainer.trainer.spawn(epochs)?; + let is_fh = matches!(task, TrainTask::Fh { .. }); + + // Train + for chunk in samples.chunks(128) { + let mut fh_samples = if is_fh { + Vec::with_capacity(chunk.len()) + } else { + Vec::new() + }; + let mut ccfh_samples = if !is_fh { + Vec::with_capacity(chunk.len()) + } else { + Vec::new() + }; + + for sample in chunk { + let account_id = if sample.sample.account_id != u32::MAX { + Some(sample.sample.account_id) + } else { + None + }; + let Some(raw_message) = self + .blob_store() + .get_blob(sample.sample.hash.as_slice(), 0..usize::MAX) + .await + .caused_by(trc::location!())? + else { + if sample.is_replay { + trainer + .reservoir + .remove_sample(&sample.sample, sample.is_spam); + } else { + trc::event!( + Spam(SpamEvent::TrainSampleNotFound), + Reason = "Blob not found", + AccountId = account_id, + BlobId = sample.sample.hash.to_hex(), + ); + } + continue; + }; + + // Build features + let Some(message) = MessageParser::new().parse(&raw_message) else { + if sample.is_replay { + trainer + .reservoir + .remove_sample(&sample.sample, sample.is_spam); + } + trc::event!( + Spam(SpamEvent::TrainSampleNotFound), + Reason = "Failed to parse message", + AccountId = account_id, + BlobId = sample.sample.hash.to_hex(), + ); + continue; + }; + let mut ctx = + self.spam_filter_init(SpamFilterInput::from_message(&message, 0).train_mode()); + self.spam_filter_analyze_domain(&mut ctx).await; + self.spam_filter_analyze_url(&mut ctx).await; + let mut tokens = self.spam_build_tokens(&ctx).await.0; + + match &task { + TrainTask::Fh { builder, .. } => { + if config.log_scale { + builder.scale(&mut tokens); + } + fh_samples.push(Sample::new( + builder.build(&tokens, account_id, config.l2_normalize), + sample.is_spam, + )); + } + TrainTask::Ccfh { builder, .. } => { + if config.log_scale { + builder.scale(&mut tokens); + } + ccfh_samples.push(Sample::new( + builder.build(&tokens, account_id, config.l2_normalize), + sample.is_spam, + )); + } + } + + // Look for stop requests + if self.inner.ipc.train_task_controller.should_stop() { + trc::event!( + Spam(SpamEvent::TrainCompleted), + Reason = "Training task was stopped", + Total = fh_samples.len() + ccfh_samples.len(), + Elapsed = started.elapsed() + ); + return Ok(()); + } + } + + // Send batch for training + let (done_tx, done_rx) = oneshot::channel::<()>(); + match &task { + TrainTask::Fh { batch_tx, .. } => { + batch_tx + .send(FhTrainJob { + samples: fh_samples, + done: done_tx, + }) + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Spam train task failed") + .caused_by(trc::location!()) + })?; + } + TrainTask::Ccfh { batch_tx, .. } => { + batch_tx + .send(CcfhTrainJob { + samples: ccfh_samples, + done: done_tx, + }) + .await + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Spam train task failed") + .caused_by(trc::location!()) + })?; + } + } + + done_rx.await.map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Spam train task failed") + .caused_by(trc::location!()) + })?; + } + + // Take ownership of trainer + trainer.trainer = match task { + TrainTask::Fh { + batch_tx, + trainer_rx, + .. + } => { + drop(batch_tx); + SpamTrainerClass::FtrlFh(trainer_rx.await.map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Spam train task failed") + .caused_by(trc::location!()) + })?) + } + TrainTask::Ccfh { + batch_tx, + trainer_rx, + .. + } => { + drop(batch_tx); + SpamTrainerClass::FtrlCfh(trainer_rx.await.map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Spam train task failed") + .caused_by(trc::location!()) + })?) + } + }; + + // Store updated trainer and classifier + let ham_count = trainer.reservoir.ham.total_seen; + let spam_count = trainer.reservoir.spam.total_seen; + let classifier = Archiver::new(match &trainer.trainer { + SpamTrainerClass::FtrlFh(fh_trainer) => spamfilter::SpamClassifier::FhClassifier { + classifier: fh_trainer.build_classifier(), + last_trained_at: now(), + }, + SpamTrainerClass::FtrlCfh(ccfh_trainer) => spamfilter::SpamClassifier::CcfhClassifier { + classifier: ccfh_trainer.build_classifier(), + last_trained_at: now(), + }, + }); + self.blob_store() + .put_blob( + SPAM_TRAINER_KEY, + &Archiver::new(trainer) + .serialize() + .caused_by(trc::location!())?, + self.core.email.compression, + ) + .await + .caused_by(trc::location!())?; + self.blob_store() + .put_blob( + SPAM_CLASSIFIER_KEY, + &classifier.serialize().caused_by(trc::location!())?, + self.core.email.compression, + ) + .await + .caused_by(trc::location!())?; + + self.inner + .data + .spam_classifier + .store(Arc::new(classifier.inner)); + self.cluster_broadcast(BroadcastEvent::reload(ObjectType::SpamClassifier)) + .await; + + trc::event!( + Spam(SpamEvent::TrainCompleted), + Total = num_samples, + Details = vec![trc::Value::from(ham_count), trc::Value::from(spam_count)], + Elapsed = started.elapsed() + ); + + // Remove samples marked for deletion + if remove_entries { + delete_samples(self, samples, duplicate_samples).await + } else { + Ok(()) + } + } + + async fn spam_classify(&self, ctx: &mut SpamFilterContext<'_>) -> trc::Result<()> { + let classifier = self.inner.data.spam_classifier.load_full(); + let Some(config) = &self.core.spam.classifier else { + return Ok(()); + }; + + let started = Instant::now(); + match classifier.as_ref() { + spamfilter::SpamClassifier::FhClassifier { classifier, .. } => { + let mut classifier_confidence = + Vec::with_capacity(ctx.input.env_rcpt_rewritten_to.len()); + let mut has_prediction = false; + let mut tokens = self.spam_build_tokens(ctx).await.0; + let feature_builder = classifier.feature_builder(); + if config.log_scale { + feature_builder.scale(&mut tokens); + } + + for rcpt in &ctx.input.env_rcpt_rewritten_to { + let prediction = if let Some(account_id) = self + .account_id_from_email(rcpt, true) + .await + .caused_by(trc::location!())? + { + has_prediction = true; + classifier + .predict_proba_sample(&feature_builder.build( + &tokens, + account_id.into(), + config.l2_normalize, + )) + .into() + } else { + None + }; + classifier_confidence.push(prediction); + } + + if has_prediction { + ctx.result.classifier_confidence = classifier_confidence; + } else { + // None of the recipients are local, default to global model prediction + let prediction = classifier.predict_proba_sample(&feature_builder.build( + &tokens, + None, + config.l2_normalize, + )); + ctx.result.classifier_confidence = + vec![prediction.into(); ctx.input.env_rcpt_rewritten_to.len()]; + } + } + spamfilter::SpamClassifier::CcfhClassifier { classifier, .. } => { + let mut classifier_confidence = + Vec::with_capacity(ctx.input.env_rcpt_rewritten_to.len()); + let mut has_prediction = false; + let mut tokens = self.spam_build_tokens(ctx).await.0; + let feature_builder = classifier.feature_builder(); + if config.log_scale { + feature_builder.scale(&mut tokens); + } + + for rcpt in &ctx.input.env_rcpt_rewritten_to { + let prediction = if let Some(account_id) = self + .account_id_from_email(rcpt, true) + .await + .caused_by(trc::location!())? + { + has_prediction = true; + classifier + .predict_proba_sample(&feature_builder.build( + &tokens, + account_id.into(), + config.l2_normalize, + )) + .into() + } else { + None + }; + classifier_confidence.push(prediction); + } + + if has_prediction { + ctx.result.classifier_confidence = classifier_confidence; + } else { + // None of the recipients are local, default to global model prediction + let prediction = classifier.predict_proba_sample(&feature_builder.build( + &tokens, + None, + config.l2_normalize, + )); + ctx.result.classifier_confidence = + vec![prediction.into(); ctx.input.env_rcpt_rewritten_to.len()]; + } + } + spamfilter::SpamClassifier::Disabled => { + return Ok(()); + } + } + + trc::event!( + Spam(SpamEvent::Classify), + Result = ctx + .result + .classifier_confidence + .iter() + .zip(ctx.input.env_rcpt_rewritten_to.iter()) + .map(|(v, rcpt)| trc::Value::Array(vec![ + trc::Value::from(rcpt.to_string()), + trc::Value::from(*v) + ])) + .collect::>(), + SpanId = ctx.input.span_id, + Elapsed = started.elapsed() + ); + + Ok(()) + } + + async fn spam_build_tokens<'x>(&self, ctx: &'x SpamFilterContext<'_>) -> Tokens<'x> { + let mut tokens = Tokens::default(); + + // Add From addresses + if ctx + .input + .dmarc_result + .as_ref() + .is_some_and(|result| **result != DmarcResult::Pass) + { + tokens.insert(Token::Sender { value: "!".into() }); + } + for email in [&ctx.output.env_from_addr, &ctx.output.from.email] { + tokens.insert_email(email, true); + } + + // Add Email addresses + for email in &ctx.output.emails { + let is_sender = match &email.location { + Location::HeaderReplyTo | Location::HeaderDnt => true, + Location::BodyText + | Location::BodyHtml + | Location::Attachment + | Location::HeaderSubject => false, + _ => continue, + }; + + if is_sender + || !is_trusted_domain( + self, + email.element.email.domain_part.sld_or_default(), + ctx.input.span_id, + ) + .await + { + tokens.insert_email(&email.element.email, is_sender); + } + } + + // Add URLs + for url in &ctx.output.urls { + if let Some(url) = &url.element.url_parsed + && !is_trusted_domain(self, url.host.sld_or_default(), ctx.input.span_id).await + { + if let Some(host) = &url.host.sld { + tokens.insert(Token::Url { value: host.into() }); + if host != &url.host.fqdn { + tokens.insert(Token::Url { + value: url.host.fqdn.as_str().into(), + }); + } + } else { + tokens.insert(Token::Url { + value: url.host.fqdn.as_str().into(), + }); + } + for token in url + .parts + .path() + .split(['/', '.', '_']) + .filter(|v| v.chars().all(|ch| ch.is_alphabetic())) + { + if token.len() > 2 { + let token = truncate_word(token, MAX_TOKEN_LENGTH); + tokens.insert(Token::Url { + value: format!("_{token}").into(), + }); + } + } + } + } + + // Add hostnames + for domain in &ctx.output.domains { + if matches!( + domain.location, + Location::HeaderReceived | Location::HeaderMid | Location::Ehlo | Location::Tcp + ) { + let host = Hostname::new(&domain.element); + let host_sld = host.sld_or_default(); + + if !is_trusted_domain(self, host_sld, ctx.input.span_id).await { + if !host_sld.is_empty() && host_sld != host.fqdn { + tokens.insert(Token::Hostname { + value: host_sld.to_string().into(), + }); + } + + tokens.insert(Token::Hostname { + value: host.fqdn.into(), + }); + } + } + } + + // Add ASN + if let Some(asn) = ctx.input.asn { + tokens.insert(Token::Asn { + number: asn.to_be_bytes(), + }); + } + + // Add MIME and attachment indicators + for part in &ctx.input.message.parts { + if let Some(name) = part.attachment_name() + && let Some((name, ext)) = name.rsplit_once('.') + { + if !ext.is_empty() { + tokens.insert(Token::Attachment { + value: lower_prefix("!", truncate_word(ext, MAX_TOKEN_LENGTH)).into(), + }); + } + let name = name.to_lowercase(); + let word_tokenizer = WordStemTokenizer::new(&name); + for token in TypesTokenizer::new(&name) { + if let TokenType::Alphabetic(word) = token.word { + word_tokenizer.tokenize(word, |token| { + tokens.insert(Token::Attachment { + value: format!( + "_{}", + truncate_word(token.as_ref(), MAX_TOKEN_LENGTH) + ) + .into(), + }); + }); + } + } + } + + if let Some(ct) = part.content_type() { + let mut ct_lower = String::with_capacity( + ct.c_type.len() + ct.c_subtype.as_ref().map_or(0, |s| s.len()), + ); + for ch in ct.c_type.chars() { + ct_lower.push(ch.to_ascii_lowercase()); + } + if let Some(st) = &ct.c_subtype { + ct_lower.push('/'); + for ch in st.chars() { + ct_lower.push(ch.to_ascii_lowercase()); + } + } + + tokens.insert(Token::MimeType { value: ct_lower }); + } + } + + // Tokenize the subject + for token in &ctx.output.subject_tokens { + tokens.insert_type( + &WordStemTokenizer::new(&ctx.output.subject_thread_lc), + token, + false, + ); + } + + // Tokenize the text parts + let body_idx = ctx + .input + .message + .html_body + .first() + .or_else(|| ctx.input.message.text_body.first()) + .map(|idx| *idx as usize); + let mut alt_tokens = Tokens::default(); + for (idx, part) in ctx.output.text_parts.iter().enumerate() { + let is_body = Some(idx) == body_idx; + if is_body + || (!ctx.input.message.text_body.contains(&(idx as u32)) + && !ctx.input.message.html_body.contains(&(idx as u32))) + { + tokens.insert_text_part(part, is_body); + } else { + alt_tokens.insert_text_part(part, false); + } + } + if !alt_tokens.0.is_empty() { + for (token, count) in alt_tokens.0.into_iter() { + if let Entry::Vacant(entry) = tokens.0.entry(token) { + entry.insert(count); + } + } + } + + tokens + } +} + +async fn delete_samples( + server: &Server, + samples: Vec, + duplicate_samples: Vec, +) -> trc::Result<()> { + let object_id = ObjectType::SpamTrainingSample.to_id(); + let mut batch = BatchBuilder::new(); + for sample in samples.into_iter().chain(duplicate_samples) { + if let Some(until) = sample.remove { + batch + .with_account_id(sample.sample.account_id) + .clear(BlobOp::Link { + hash: sample.sample.hash, + to: BlobLink::Temporary { until }, + }) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: sample.id, + })) + .clear(ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id: sample.id, + key: (sample.sample.account_id as u64).serialize(), + })); + + if batch.is_large_batch() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + batch.with_account_id(sample.sample.account_id); + } + } + } + if !batch.is_empty() { + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + Ok(()) +} + +struct FhTrainJob { + samples: Vec>, + done: oneshot::Sender<()>, +} + +struct CcfhTrainJob { + samples: Vec>, + done: oneshot::Sender<()>, +} + +enum TrainTask { + Fh { + batch_tx: mpsc::Sender, + trainer_rx: oneshot::Receiver>>, + builder: FhFeatureBuilder, + }, + Ccfh { + batch_tx: mpsc::Sender, + trainer_rx: oneshot::Receiver>>, + builder: CcfhFeatureBuilder, + }, +} + +impl SpamTrainerClass { + fn spawn(self, num_epochs: usize) -> trc::Result { + match self { + SpamTrainerClass::FtrlFh(mut trainer) => { + let builder = trainer.feature_builder(); + let (batch_tx, mut batch_rx) = mpsc::channel::(1); + let (trainer_tx, trainer_rx) = oneshot::channel(); + + std::thread::Builder::new() + .name("FTRL Train Task".into()) + .spawn(move || { + while let Some(mut job) = batch_rx.blocking_recv() { + trainer.fit(&mut job.samples, num_epochs); + let _ = job.done.send(()); + } + // Send trainer back when done + let _ = trainer_tx.send(trainer); + }) + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Failed to spawn spam train task") + .caused_by(trc::location!()) + })?; + + Ok(TrainTask::Fh { + batch_tx, + trainer_rx, + builder, + }) + } + SpamTrainerClass::FtrlCfh(mut trainer) => { + let builder = trainer.feature_builder(); + let (batch_tx, mut batch_rx) = mpsc::channel::(1); + let (trainer_tx, trainer_rx) = oneshot::channel(); + + std::thread::Builder::new() + .name("FTRL Train Task".into()) + .spawn(move || { + while let Some(mut job) = batch_rx.blocking_recv() { + trainer.fit(&mut job.samples, num_epochs); + let _ = job.done.send(()); + } + // Send trainer back when done + let _ = trainer_tx.send(trainer); + }) + .map_err(|err| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .reason(err) + .details("Failed to spawn spam train task") + .caused_by(trc::location!()) + })?; + + Ok(TrainTask::Ccfh { + batch_tx, + trainer_rx, + builder, + }) + } + } + } +} + +const MAX_TOKEN_LENGTH: usize = 16; + +#[derive( + Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, PartialOrd, Ord, +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Token<'x> { + Word { value: Cow<'x, str> }, + Number { code: [u8; 2] }, + Alphanumeric { code: [u8; 4] }, + UnicodeCategory { value: &'x str }, + Sender { value: Cow<'x, str> }, + Asn { number: [u8; 4] }, + Url { value: Cow<'x, str> }, + Email { value: Cow<'x, str> }, + Hostname { value: Cow<'x, str> }, + Attachment { value: Cow<'x, str> }, + MimeType { value: String }, + HtmlImage { src: &'x str }, + HtmlAnchor { href: &'x str }, +} + +#[derive(Debug)] +pub struct Tokens<'x>(pub HashMap, f32, RandomState>); + +impl<'x> Tokens<'x> { + fn insert_text_part(&mut self, part: &'x TextPart<'x>, is_body: bool) { + match part { + TextPart::Plain { text_body, tokens } => { + let word_tokenizer = WordStemTokenizer::new(text_body); + + for token in tokens { + self.insert_type(&word_tokenizer, token, is_body); + } + + if is_body + && (tokens.is_empty() + || !tokens.iter().any(|t| matches!(t, TokenType::Alphabetic(_)))) + { + self.insert(Token::Word { + value: "_null".into(), + }); + } + } + TextPart::Html { + text_body, + tokens, + html_tokens, + } => { + let word_tokenizer = WordStemTokenizer::new(text_body); + + for token in tokens { + self.insert_type(&word_tokenizer, token, is_body); + } + + if is_body { + if tokens.is_empty() + || !tokens.iter().any(|t| matches!(t, TokenType::Alphabetic(_))) + { + self.insert(Token::Word { + value: "_null".into(), + }); + } + + for token in html_tokens { + if let HtmlToken::StartTag { + name: A | IMG, + attributes, + .. + } = token + { + for (name, value) in attributes { + match (*name, value) { + (ALT | TITLE, Some(value)) => { + for token in TypesTokenizer::new(value) { + self.insert_type(&word_tokenizer, &token.word, is_body); + } + } + (SRC, Some(value)) => { + self.insert(Token::HtmlImage { + src: value.split_once(':').unwrap_or_default().0, + }); + } + (HREF, Some(value)) => { + self.insert(Token::HtmlAnchor { + href: value.split_once(':').unwrap_or_default().0, + }); + } + _ => {} + } + } + } + } + } + } + TextPart::None => (), + } + } + + fn insert_type, E, U, I>( + &mut self, + word_tokenizer: &WordStemTokenizer, + token: &TokenType, + is_body: bool, + ) { + match token { + TokenType::Alphabetic(word) => { + let word = word.as_ref(); + let mut set: Option = None; + let mut has_confusables = false; + let mut upper_count = 0; + for ch in word.chars() { + if ch.is_uppercase() { + upper_count += 1; + } + + has_confusables |= + !ch.is_ascii() && !std::iter::once(ch).nfc().eq(std::iter::once(ch).nfkc()); + set.get_or_insert_default().intersect_with(ch.into()); + } + let is_mixed_script = set.is_some_and(|set| set.is_empty()); + + if (is_mixed_script || has_confusables) + && let Ok(cured_word) = decancer::cure(word, decancer::Options::default()) + { + if word.len() > MAX_TOKEN_LENGTH { + self.insert(Token::Word { + value: truncate_word(cured_word.as_str(), MAX_TOKEN_LENGTH) + .to_string() + .into(), + }); + } else { + self.insert(Token::Word { + value: String::from(cured_word).into(), + }); + } + } else { + let word = word.to_lowercase(); + word_tokenizer.tokenize(&word, |token| { + self.insert(Token::Word { + value: truncate_word(token.as_ref(), MAX_TOKEN_LENGTH) + .to_string() + .into(), + }); + }); + } + + if is_body && word.len() == upper_count && word.len() > 3 { + self.insert(Token::Word { + value: "_allcaps".into(), + }); + } + } + TokenType::Alphanumeric(word) => { + self.insert(Token::from_alphanumeric(word.as_ref())); + } + TokenType::UrlNoHost(url) => { + for token in url + .as_ref() + .to_lowercase() + .split(['/', '.', '_']) + .filter(|v| v.chars().all(|ch| ch.is_alphabetic())) + { + if token.len() > 2 { + let token = truncate_word(token, MAX_TOKEN_LENGTH); + self.insert(Token::Url { + value: format!("_{token}").into(), + }); + } + } + } + TokenType::Other(ch) | TokenType::Punctuation(ch) => { + let category = get_general_category(*ch); + if !matches!( + category, + GeneralCategory::ClosePunctuation + | GeneralCategory::ConnectorPunctuation + | GeneralCategory::DashPunctuation + | GeneralCategory::FinalPunctuation + | GeneralCategory::InitialPunctuation + | GeneralCategory::OpenPunctuation + | GeneralCategory::OtherPunctuation + | GeneralCategory::SpaceSeparator + ) { + self.insert(Token::UnicodeCategory { + value: category.abbreviation(), + }); + } + } + TokenType::Integer(word) => { + self.insert(Token::from_number(false, word.as_ref())); + } + TokenType::Float(word) => { + self.insert(Token::from_number(true, word.as_ref())); + } + TokenType::IpAddr(_) => { + self.insert(Token::Url { + value: "!ip".into(), + }); + } + TokenType::Email(_) + | TokenType::Url(_) + | TokenType::UrlNoScheme(_) + | TokenType::Space => {} + } + } + + fn insert(&mut self, token: Token<'x>) { + *self.0.entry(token).or_insert(0.0) += 1.0; + } + + fn insert_if_missing(&mut self, token: Token<'x>) { + self.0.entry(token).or_insert(1.0); + } + + fn insert_email(&mut self, email: &'x Email, is_sender: bool) { + if !email.address.is_empty() { + if is_sender { + self.insert_if_missing(Token::Sender { + value: email.address.as_str().into(), + }); + self.insert_if_missing(Token::Sender { + value: email.domain_part.fqdn.as_str().into(), + }); + if let Some(sld) = &email.domain_part.sld + && sld != &email.domain_part.fqdn + { + self.insert_if_missing(Token::Sender { value: sld.into() }); + } + } else { + self.insert_if_missing(Token::Email { + value: email.address.as_str().into(), + }); + self.insert_if_missing(Token::Email { + value: email.domain_part.fqdn.as_str().into(), + }); + if let Some(sld) = &email.domain_part.sld + && !sld.is_empty() + && sld != &email.domain_part.fqdn + { + self.insert_if_missing(Token::Email { value: sld.into() }); + } + } + } + } +} + +impl Token<'static> { + fn from_alphanumeric(s: &str) -> Self { + let mut is_hex = true; + let mut is_ascii = true; + let mut digit_count = 0; + + for ch in s.chars() { + match ch { + 'a'..='f' | 'A'..='F' => {} + '0'..='9' => { + digit_count += 1; + } + _ => { + is_ascii &= ch.is_ascii(); + is_hex = false; + } + } + } + + if is_hex { + Token::Number { + code: [b'X', s.len().min(u8::MAX as usize) as u8], + } + } else if !is_ascii { + let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) { + cured + .as_str() + .chars() + .filter(|ch| ch.is_alphabetic()) + .take(MAX_TOKEN_LENGTH) + .collect() + } else { + s.chars() + .filter(|ch| ch.is_alphabetic()) + .flat_map(|ch| ch.to_lowercase()) + .take(MAX_TOKEN_LENGTH) + .collect() + }; + + Token::Word { value: word.into() } + } else if s.len() > 3 && digit_count == 1 { + let word: String = s + .chars() + .filter(|ch| ch.is_alphabetic()) + .flat_map(|ch| ch.to_lowercase()) + .take(MAX_TOKEN_LENGTH) + .collect(); + Token::Word { value: word.into() } + } else { + // Character class counts + let mut upper = 0u32; + let mut lower = 0u32; + let mut digit = 0u32; + let mut len = 0; + let mut char_types = Vec::with_capacity(len); + for c in s.chars() { + let char_type = CharType::from_char(c); + char_types.push(char_type); + match char_type { + CharType::Upper => upper += 1, + CharType::Lower => lower += 1, + CharType::Digit => digit += 1, + CharType::Other => (), + } + len += 1; + } + + // Determine dominant composition + let composition = match (upper > 0, lower > 0, digit > 0) { + (true, false, false) => b'U', // UPPERCASE only + (false, true, false) => b'L', // lowercase only + (false, false, true) => b'D', // digits only + (true, true, false) => b'A', // Alphabetic mixed case + (true, false, true) => b'H', // Upper + digits (common in codes) + (false, true, true) => b'M', // lower + digits (common in identifiers) + (true, true, true) => b'X', // eXtreme mix - all three + (false, false, false) => b'E', // empty/invalid + }; + + // Length bucket (log-ish scale) + let len_code = match len { + 1 => b'1', + 2 => b'2', + 3 => b'3', + 4 => b'4', + 5..=6 => b'5', + 7..=8 => b'6', + 9..=12 => b'7', + 13..=16 => b'8', + 17..=32 => b'9', + _ => b'Z', + }; + + // Ratio encoding (which class dominates) + let max_count = upper.max(lower).max(digit); + let dominance = (max_count * 100) / len.min(1) as u32; + let ratio = match dominance { + 0..=50 => b'B', // Balanced + 51..=75 => b'P', // Partial dominance + 76..=99 => b'D', // Dominant + _ => b'O', // One class only (100%) + }; + + // Run code + let mut run_count = 0; + if len > 1 { + let mut prev_type = char_types[0]; + for ¤t_type in char_types.iter().skip(1) { + if current_type != prev_type { + run_count += 1; + prev_type = current_type; + } + } + } + let run_ratio = (run_count as f64) / ((len - 1) as f64); + let run_code = match run_ratio { + r if r <= 0.1 => b'0', // Very long runs (e.g., AAAABBBB) + r if r <= 0.3 => b'1', // Moderate runs + r if r <= 0.5 => b'2', // Balanced runs/alternation + r if r <= 0.7 => b'3', // High alternation + _ => b'4', // Near maximum alternation (e.g., A1A1A1) + }; + + Token::Alphanumeric { + code: [composition, len_code, ratio, run_code], + } + } + } + + fn from_number(is_float: bool, num: &str) -> Self { + Token::Number { + code: [ + if num.starts_with("-") { + if is_float { b'F' } else { b'I' } + } else if is_float { + b'f' + } else { + b'i' + }, + num.as_bytes() + .iter() + .filter(|c| c.is_ascii_digit()) + .count() + .min(u8::MAX as usize) as u8, + ], + } + } +} + +fn lower_prefix(prefix: &str, value: &str) -> String { + let mut result = String::with_capacity(prefix.len() + value.len()); + result.push_str(prefix); + for ch in value.chars() { + for lower_ch in ch.to_lowercase() { + result.push(lower_ch); + } + } + result +} + +fn truncate_word(word: &str, max_len: usize) -> &str { + if word.len() <= max_len { + word + } else { + let mut pos = 0; + for (count, (idx, _)) in word.char_indices().enumerate() { + pos = idx; + if count == max_len { + break; + } + } + &word[..pos] + } +} + +impl UnprocessedFeature for Token<'_> { + fn prefix(&self) -> u16 { + match self { + Token::Word { .. } => 0, + Token::Number { .. } => 1, + Token::Alphanumeric { .. } => 2, + Token::UnicodeCategory { .. } => 3, + Token::Sender { .. } => 4, + Token::Asn { .. } => 5, + Token::Url { .. } => 6, + Token::Email { .. } => 7, + Token::Hostname { .. } => 8, + Token::Attachment { .. } => 9, + Token::MimeType { .. } => 10, + Token::HtmlImage { .. } => 11, + Token::HtmlAnchor { .. } => 12, + } + } + + fn value(&self) -> &[u8] { + match self { + Token::Word { value } => value.as_bytes(), + Token::Number { code } => code, + Token::Alphanumeric { code } => code, + Token::UnicodeCategory { value } => value.as_bytes(), + Token::Sender { value } => value.as_bytes(), + Token::Asn { number } => number, + Token::Url { value } => value.as_bytes(), + Token::Email { value } => value.as_bytes(), + Token::Hostname { value } => value.as_bytes(), + Token::Attachment { value } => value.as_bytes(), + Token::MimeType { value } => value.as_bytes(), + Token::HtmlImage { src } => src.as_bytes(), + Token::HtmlAnchor { href } => href.as_bytes(), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum CharType { + Upper, + Lower, + Digit, + Other, +} + +impl CharType { + fn from_char(c: char) -> CharType { + match c { + 'A'..='Z' => CharType::Upper, + 'a'..='z' => CharType::Lower, + '0'..='9' => CharType::Digit, + _ => CharType::Other, + } + } +} + +impl<'x> Default for Tokens<'x> { + fn default() -> Self { + Tokens(HashMap::with_capacity(128)) + } +} diff --git a/crates/spam-filter/src/modules/dnsbl.rs b/crates/spam-filter/src/modules/dnsbl.rs new file mode 100644 index 0000000..e0e4f14 --- /dev/null +++ b/crates/spam-filter/src/modules/dnsbl.rs @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::expression::SpamFilterResolver; +use crate::SpamFilterContext; +use common::{ + Server, + config::mailstore::spamfilter::{DnsBlServer, Element, IpResolver, Location}, + expr::functions::ResolveVariable, +}; +use mail_auth::{Error, common::resolver::ToFqdn}; +use std::{ + net::Ipv4Addr, + sync::Arc, + time::{Duration, Instant}, +}; +use trc::SpamEvent; + +pub(crate) async fn check_dnsbl( + server: &Server, + ctx: &mut SpamFilterContext<'_>, + resolver: &impl ResolveVariable, + scope: Element, + location: Location, +) { + let (mut checks, max_checks) = match scope { + Element::Email => ( + ctx.result.rbl_email_checks, + server.core.spam.dnsbl.max_email_checks, + ), + Element::Ip => ( + ctx.result.rbl_ip_checks, + server.core.spam.dnsbl.max_ip_checks, + ), + Element::Url => ( + ctx.result.rbl_url_checks, + server.core.spam.dnsbl.max_url_checks, + ), + Element::Domain => ( + ctx.result.rbl_domain_checks, + server.core.spam.dnsbl.max_domain_checks, + ), + Element::Header | Element::Body | Element::Any => unreachable!(), + }; + + for dnsbl in &server.core.spam.dnsbl.servers { + if dnsbl.scope == scope + && checks < max_checks + && let Some(tag) = is_dnsbl( + server, + dnsbl, + SpamFilterResolver::new(ctx, resolver, location), + scope, + &mut checks, + ) + .await + { + ctx.result.add_tag(tag); + } + } + + match scope { + Element::Email => ctx.result.rbl_email_checks = checks, + Element::Ip => ctx.result.rbl_ip_checks = checks, + Element::Url => ctx.result.rbl_url_checks = checks, + Element::Domain => ctx.result.rbl_domain_checks = checks, + Element::Header | Element::Body | Element::Any => unreachable!(), + } +} + +async fn is_dnsbl( + server: &Server, + config: &DnsBlServer, + resolver: SpamFilterResolver<'_, impl ResolveVariable>, + element: Element, + checks: &mut usize, +) -> Option { + let time = Instant::now(); + let zone = server + .eval_if::(&config.zone, &resolver, resolver.ctx.input.span_id) + .await?; + + #[cfg(feature = "test_mode")] + { + if zone.contains(".11.20.") { + let parts = zone.split('.').collect::>(); + + return if config.tags.if_then.iter().any(|i| i.expr.items.len() == 3) && parts[0] != "2" + { + None + } else { + server + .eval_if( + &config.tags, + &SpamFilterResolver::new( + resolver.ctx, + &IpResolver::new( + format!("127.0.{}.{}", parts[1], parts[0]).parse().unwrap(), + ), + resolver.location, + ), + resolver.ctx.input.span_id, + ) + .await + }; + } + } + + let result = match server.inner.cache.dns_rbl.get(zone.as_str()) { + Some(Some(result)) => result, + Some(None) => return None, + None => { + *checks += 1; + + match server + .core + .smtp + .resolvers + .dns + .ipv4_lookup_raw(zone.to_fqdn().as_ref()) + .await + { + Ok(result) => { + trc::event!( + Spam(SpamEvent::Dnsbl), + Hostname = zone.clone(), + Result = result + .entry + .iter() + .map(|ip| trc::Value::from(ip.to_string())) + .collect::>(), + Details = element.as_str(), + Elapsed = time.elapsed() + ); + + let entry = Arc::new(IpResolver::new( + result + .entry + .iter() + .copied() + .next() + .unwrap_or(Ipv4Addr::BROADCAST) + .into(), + )); + + server.inner.cache.dns_rbl.insert_with_expiry( + zone.into(), + Some(entry.clone()), + result.expires, + ); + + entry + } + Err(Error::Dns(mail_auth::DnsError::RecordNotFound(_))) => { + trc::event!( + Spam(SpamEvent::Dnsbl), + Hostname = zone.clone(), + Result = trc::Value::None, + Details = element.as_str(), + Elapsed = time.elapsed() + ); + + server.inner.cache.dns_rbl.insert( + zone.into(), + None, + Duration::from_secs(86400), + ); + + return None; + } + Err(err) => { + trc::event!( + Spam(SpamEvent::DnsblError), + Hostname = zone, + Elapsed = time.elapsed(), + Details = element.as_str(), + CausedBy = err.to_string() + ); + + return None; + } + } + } + }; + + server + .eval_if( + &config.tags, + &SpamFilterResolver::new(resolver.ctx, result.as_ref(), resolver.location), + resolver.ctx.input.span_id, + ) + .await +} diff --git a/crates/spam-filter/src/modules/expression.rs b/crates/spam-filter/src/modules/expression.rs new file mode 100644 index 0000000..c430a42 --- /dev/null +++ b/crates/spam-filter/src/modules/expression.rs @@ -0,0 +1,504 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + config::mailstore::spamfilter::*, + expr::{StringCow, Variable, functions::ResolveVariable}, +}; +use compact_str::{CompactString, ToCompactString, format_compact}; +use mail_parser::{Header, HeaderValue}; +use nlp::tokenizers::types::TokenType; +use registry::schema::enums::ExpressionVariable; + +use crate::{Recipient, SpamFilterContext, TextPart, analysis::url::UrlParts}; + +pub(crate) struct SpamFilterResolver<'x, T: ResolveVariable> { + pub ctx: &'x SpamFilterContext<'x>, + pub item: &'x T, + pub location: Location, +} + +impl<'x, T: ResolveVariable> SpamFilterResolver<'x, T> { + pub fn new(ctx: &'x SpamFilterContext<'x>, item: &'x T, location: Location) -> Self { + Self { + ctx, + item, + location, + } + } +} + +impl ResolveVariable for SpamFilterResolver<'_, T> { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::RemoteIp => self.ctx.input.remote_ip.to_compact_string().into(), + ExpressionVariable::RemoteIpPtr => self + .ctx + .output + .iprev_ptr + .as_deref() + .unwrap_or_default() + .into(), + ExpressionVariable::HeloDomain => self.ctx.output.ehlo_host.fqdn.as_str().into(), + ExpressionVariable::AuthenticatedAs => { + self.ctx.input.authenticated_as.unwrap_or_default().into() + } + ExpressionVariable::Asn => self.ctx.input.asn.unwrap_or_default().into(), + ExpressionVariable::Country => self.ctx.input.country.unwrap_or_default().into(), + ExpressionVariable::IsTls => self.ctx.input.is_tls.into(), + ExpressionVariable::EnvFrom => self.ctx.output.env_from_addr.address.as_str().into(), + ExpressionVariable::EnvFromLocal => { + self.ctx.output.env_from_addr.local_part.as_str().into() + } + ExpressionVariable::EnvFromDomain => self + .ctx + .output + .env_from_addr + .domain_part + .fqdn + .as_str() + .into(), + ExpressionVariable::EnvTo => self + .ctx + .output + .env_to_orig_addr + .iter() + .map(|e| Variable::from(e.address.as_str())) + .collect::>() + .into(), + ExpressionVariable::From => self.ctx.output.from.email.address.as_str().into(), + ExpressionVariable::FromName => self + .ctx + .output + .from + .name + .as_deref() + .unwrap_or_default() + .into(), + ExpressionVariable::FromLocal => self.ctx.output.from.email.local_part.as_str().into(), + ExpressionVariable::FromDomain => { + self.ctx.output.from.email.domain_part.fqdn.as_str().into() + } + ExpressionVariable::ReplyTo => self + .ctx + .output + .reply_to + .as_ref() + .map(|r| r.email.address.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::ReplyToName => self + .ctx + .output + .reply_to + .as_ref() + .and_then(|r| r.name.as_deref()) + .unwrap_or_default() + .into(), + ExpressionVariable::ReplyToLocal => self + .ctx + .output + .reply_to + .as_ref() + .map(|r| r.email.local_part.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::ReplyToDomain => self + .ctx + .output + .reply_to + .as_ref() + .map(|r| r.email.domain_part.fqdn.as_str()) + .unwrap_or_default() + .into(), + ExpressionVariable::To => self + .ctx + .output + .recipients_to + .iter() + .map(|r| Variable::from(r.email.address.as_str())) + .collect::>() + .into(), + ExpressionVariable::ToName => self + .ctx + .output + .recipients_to + .iter() + .filter_map(|r| Variable::from(r.name.as_deref()?).into()) + .collect::>() + .into(), + ExpressionVariable::ToLocal => self + .ctx + .output + .recipients_to + .iter() + .map(|r| Variable::from(r.email.local_part.as_str())) + .collect::>() + .into(), + ExpressionVariable::ToDomain => self + .ctx + .output + .recipients_to + .iter() + .map(|r| Variable::from(r.email.domain_part.fqdn.as_str())) + .collect::>() + .into(), + ExpressionVariable::Cc => self + .ctx + .output + .recipients_cc + .iter() + .map(|r| Variable::from(r.email.address.as_str())) + .collect::>() + .into(), + ExpressionVariable::CcName => self + .ctx + .output + .recipients_cc + .iter() + .filter_map(|r| Variable::from(r.name.as_deref()?).into()) + .collect::>() + .into(), + ExpressionVariable::CcLocal => self + .ctx + .output + .recipients_cc + .iter() + .map(|r| Variable::from(r.email.local_part.as_str())) + .collect::>() + .into(), + ExpressionVariable::CcDomain => self + .ctx + .output + .recipients_cc + .iter() + .map(|r| Variable::from(r.email.domain_part.fqdn.as_str())) + .collect::>() + .into(), + ExpressionVariable::Bcc => self + .ctx + .output + .recipients_bcc + .iter() + .map(|r| Variable::from(r.email.address.as_str())) + .collect::>() + .into(), + ExpressionVariable::BccName => self + .ctx + .output + .recipients_bcc + .iter() + .filter_map(|r| Variable::from(r.name.as_deref()?).into()) + .collect::>() + .into(), + ExpressionVariable::BccLocal => self + .ctx + .output + .recipients_bcc + .iter() + .map(|r| Variable::from(r.email.local_part.as_str())) + .collect::>() + .into(), + ExpressionVariable::BccDomain => self + .ctx + .output + .recipients_bcc + .iter() + .map(|r| Variable::from(r.email.domain_part.fqdn.as_str())) + .collect::>() + .into(), + ExpressionVariable::Body | ExpressionVariable::BodyText => { + self.ctx.text_body().unwrap_or_default().into() + } + ExpressionVariable::BodyHtml => self + .ctx + .input + .message + .html_body + .first() + .and_then(|idx| self.ctx.output.text_parts.get(*idx as usize)) + .map(|part| { + if let TextPart::Html { text_body, .. } = part { + text_body.as_str() + } else { + "" + } + }) + .unwrap_or_default() + .into(), + ExpressionVariable::BodyRaw => Variable::from(CompactString::from_utf8_lossy( + self.ctx.input.message.raw_message(), + )), + ExpressionVariable::Subject => self.ctx.output.subject_lc.as_str().into(), + ExpressionVariable::SubjectThread => self.ctx.output.subject_thread_lc.as_str().into(), + ExpressionVariable::Location => self.location.as_str().into(), + ExpressionVariable::SubjectWords => self + .ctx + .output + .subject_tokens + .iter() + .filter_map(|w| match w { + TokenType::Alphabetic(w) + | TokenType::Alphanumeric(w) + | TokenType::Integer(w) + | TokenType::Float(w) => Some(Variable::from(w.as_ref())), + _ => None, + }) + .collect::>() + .into(), + ExpressionVariable::BodyWords => self + .ctx + .input + .message + .html_body + .first() + .and_then(|idx| self.ctx.output.text_parts.get(*idx as usize)) + .map(|part| match part { + TextPart::Plain { tokens, .. } | TextPart::Html { tokens, .. } => tokens + .iter() + .filter_map(|w| match w { + TokenType::Alphabetic(w) + | TokenType::Alphanumeric(w) + | TokenType::Integer(w) + | TokenType::Float(w) => Some(Variable::from(w.as_ref())), + _ => None, + }) + .collect::>(), + TextPart::None => vec![], + }) + .unwrap_or_default() + .into(), + variable => self.item.resolve_variable(variable), + } + } + + fn resolve_global(&self, variable: &str) -> Variable<'_> { + Variable::Integer(self.ctx.result.tags.contains(variable).into()) + } +} + +pub(crate) struct EmailHeader<'x> { + pub header: &'x Header<'x>, + pub raw: &'x str, +} + +impl ResolveVariable for EmailHeader<'_> { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::Name => self.header.name().into(), + ExpressionVariable::NameLower => { + CompactString::from_str_to_lowercase(self.header.name()).into() + } + ExpressionVariable::Value + | ExpressionVariable::ValueLower + | ExpressionVariable::Attributes => match &self.header.value { + HeaderValue::Text(text) => { + if variable == ExpressionVariable::ValueLower { + CompactString::from_str_to_lowercase(text).into() + } else { + text.as_ref().into() + } + } + HeaderValue::TextList(list) => Variable::Array( + list.iter() + .map(|text| { + Variable::String(if variable == ExpressionVariable::ValueLower { + StringCow::Owned(CompactString::from_str_to_lowercase(text)) + } else { + StringCow::Borrowed(text.as_ref()) + }) + }) + .collect(), + ), + HeaderValue::Address(address) => { + Variable::Array(if matches!(variable, ExpressionVariable::ValueLower) { + address + .iter() + .filter_map(|a| { + a.address.as_ref().map(|text| { + Variable::String( + if variable == ExpressionVariable::ValueLower { + StringCow::Owned(CompactString::from_str_to_lowercase( + text, + )) + } else { + StringCow::Borrowed(text.as_ref()) + }, + ) + }) + }) + .collect() + } else { + address + .iter() + .filter_map(|a| { + a.name.as_ref().map(|text| { + Variable::String( + if variable == ExpressionVariable::ValueLower { + StringCow::Owned(CompactString::from_str_to_lowercase( + text, + )) + } else { + StringCow::Borrowed(text.as_ref()) + }, + ) + }) + }) + .collect() + }) + } + HeaderValue::DateTime(date_time) => { + CompactString::new(date_time.to_rfc3339()).into() + } + HeaderValue::ContentType(ct) => { + if variable != ExpressionVariable::Attributes { + if let Some(st) = ct.subtype() { + format_compact!("{}/{}", ct.ctype(), st).into() + } else { + ct.ctype().into() + } + } else { + Variable::Array( + ct.attributes() + .map(|attr| { + attr.iter() + .map(|attr| { + Variable::from(format_compact!( + "{}={}", attr.name, attr.value + )) + }) + .collect::>() + }) + .unwrap_or_default(), + ) + } + } + HeaderValue::Received(_) => { + if variable == ExpressionVariable::ValueLower { + CompactString::from_str_to_lowercase(self.raw.trim()).into() + } else { + self.raw.trim().into() + } + } + HeaderValue::Empty => "".into(), + }, + ExpressionVariable::Raw => self.raw.into(), + ExpressionVariable::RawLower => CompactString::from_str_to_lowercase(self.raw).into(), + _ => Variable::Integer(0), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl ResolveVariable for Recipient { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::Email | ExpressionVariable::Value => { + Variable::from(self.email.address.as_str()) + } + ExpressionVariable::Name => Variable::from(self.name.as_deref().unwrap_or_default()), + ExpressionVariable::Local => Variable::from(self.email.local_part.as_str()), + ExpressionVariable::Domain => Variable::from(self.email.domain_part.fqdn.as_str()), + ExpressionVariable::Sld => Variable::from(self.email.domain_part.sld_or_default()), + _ => Variable::Integer(0), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +impl ResolveVariable for UrlParts<'_> { + fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> { + match variable { + ExpressionVariable::Url | ExpressionVariable::Value => { + Variable::from(self.url.as_str()) + } + ExpressionVariable::UrlOriginal => Variable::from(self.url_original.as_ref()), + ExpressionVariable::PathQuery => Variable::from( + self.url_parsed + .as_ref() + .and_then(|p| p.parts.path_and_query().map(|p| p.as_str())) + .unwrap_or_default(), + ), + ExpressionVariable::Path => Variable::from( + self.url_parsed + .as_ref() + .map(|p| p.parts.path()) + .unwrap_or_default(), + ), + ExpressionVariable::Query => Variable::from( + self.url_parsed + .as_ref() + .and_then(|p| p.parts.query()) + .unwrap_or_default(), + ), + ExpressionVariable::Scheme => Variable::from( + self.url_parsed + .as_ref() + .and_then(|p| p.parts.scheme_str()) + .unwrap_or_default(), + ), + ExpressionVariable::Authority => Variable::from( + self.url_parsed + .as_ref() + .and_then(|p| p.parts.authority().map(|a| a.as_str())) + .unwrap_or_default(), + ), + ExpressionVariable::Host => Variable::from( + self.url_parsed + .as_ref() + .map(|p| p.host.fqdn.as_str()) + .unwrap_or_default(), + ), + ExpressionVariable::Sld => Variable::from( + self.url_parsed + .as_ref() + .map(|p| p.host.sld_or_default()) + .unwrap_or_default(), + ), + ExpressionVariable::Port => Variable::Integer( + self.url_parsed + .as_ref() + .and_then(|p| p.parts.port_u16()) + .unwrap_or(0) as _, + ), + _ => Variable::Integer(0), + } + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +pub struct StringResolver<'x>(pub &'x str); + +impl ResolveVariable for StringResolver<'_> { + fn resolve_variable(&self, _: ExpressionVariable) -> Variable<'_> { + Variable::from(self.0) + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} + +pub struct StringListResolver<'x>(pub &'x [String]); + +impl ResolveVariable for StringListResolver<'_> { + fn resolve_variable(&self, _: ExpressionVariable) -> Variable<'_> { + Variable::Array(self.0.iter().map(|v| Variable::from(v.as_str())).collect()) + } + + fn resolve_global(&self, _: &str) -> Variable<'_> { + Variable::Integer(0) + } +} diff --git a/crates/spam-filter/src/modules/html.rs b/crates/spam-filter/src/modules/html.rs new file mode 100644 index 0000000..3e9431c --- /dev/null +++ b/crates/spam-filter/src/modules/html.rs @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_parser::decoders::html::add_html_token; + +#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum HtmlToken { + StartTag { + name: u64, + attributes: Vec<(u64, Option)>, + is_self_closing: bool, + }, + EndTag { + name: u64, + }, + Comment { + text: String, + }, + Text { + text: String, + }, +} + +pub(crate) const A: u64 = b'a' as u64; +pub(crate) const IMG: u64 = (b'i' as u64) | ((b'm' as u64) << 8) | ((b'g' as u64) << 16); +pub(crate) const HEAD: u64 = + (b'h' as u64) | ((b'e' as u64) << 8) | ((b'a' as u64) << 16) | ((b'd' as u64) << 24); +pub(crate) const BODY: u64 = + (b'b' as u64) | ((b'o' as u64) << 8) | ((b'd' as u64) << 16) | ((b'y' as u64) << 24); +pub(crate) const META: u64 = + (b'm' as u64) | ((b'e' as u64) << 8) | ((b't' as u64) << 16) | ((b'a' as u64) << 24); +pub(crate) const LINK: u64 = + (b'l' as u64) | ((b'i' as u64) << 8) | ((b'n' as u64) << 16) | ((b'k' as u64) << 24); +pub(crate) const ALT: u64 = (b'a' as u64) | ((b'l' as u64) << 8) | ((b't' as u64) << 16); +pub(crate) const TITLE: u64 = (b't' as u64) + | ((b'i' as u64) << 8) + | ((b't' as u64) << 16) + | ((b'l' as u64) << 24) + | ((b'e' as u64) << 32); + +pub(crate) const HREF: u64 = + (b'h' as u64) | ((b'r' as u64) << 8) | ((b'e' as u64) << 16) | ((b'f' as u64) << 24); +pub(crate) const SRC: u64 = (b's' as u64) | ((b'r' as u64) << 8) | ((b'c' as u64) << 16); +pub(crate) const WIDTH: u64 = (b'w' as u64) + | ((b'i' as u64) << 8) + | ((b'd' as u64) << 16) + | ((b't' as u64) << 24) + | ((b'h' as u64) << 32); +pub(crate) const HEIGHT: u64 = (b'h' as u64) + | ((b'e' as u64) << 8) + | ((b'i' as u64) << 16) + | ((b'g' as u64) << 24) + | ((b'h' as u64) << 32) + | ((b't' as u64) << 40); +pub(crate) const REL: u64 = (b'r' as u64) | ((b'e' as u64) << 8) | ((b'l' as u64) << 16); +pub(crate) const CONTENT: u64 = (b'c' as u64) + | ((b'o' as u64) << 8) + | ((b'n' as u64) << 16) + | ((b't' as u64) << 24) + | ((b'e' as u64) << 32) + | ((b'n' as u64) << 40) + | ((b't' as u64) << 48); +pub(crate) const HTTP_EQUIV: u64 = (b'h' as u64) + | ((b't' as u64) << 8) + | ((b't' as u64) << 16) + | ((b'p' as u64) << 24) + | ((b'-' as u64) << 32) + | ((b'e' as u64) << 40) + | ((b'q' as u64) << 48) + | ((b'u' as u64) << 56); + +pub fn html_to_tokens(input: &str) -> Vec { + let input = input.as_bytes(); + let mut iter = input.iter().enumerate().peekable(); + let mut tags = vec![]; + + let mut is_token_start = true; + let mut is_after_space = false; + let mut is_new_line = true; + + let mut token_start = 0; + let mut token_end = 0; + + let mut text = String::with_capacity(16); + + while let Some((mut pos, &ch)) = iter.next() { + match ch { + b'<' => { + if !is_token_start { + add_html_token( + &mut text, + &input[token_start..token_end + 1], + is_after_space, + ); + is_after_space = false; + is_token_start = true; + } + if !text.is_empty() { + tags.push(HtmlToken::Text { + text: text.as_str().into(), + }); + text.clear(); + } + + while matches!(iter.peek(), Some(&(_, &ch)) if ch.is_ascii_whitespace()) { + pos += 1; + iter.next(); + } + + if matches!(input.get(pos + 1..pos + 4), Some(b"!--")) { + let mut comment = Vec::new(); + let mut last_ch: u8 = 0; + for (_, &ch) in iter.by_ref() { + match ch { + b'>' if comment.len() > 2 + && matches!(comment.last(), Some(b'-')) + && matches!(comment.get(comment.len() - 2), Some(b'-')) => + { + break; + } + b' ' | b'\t' | b'\r' | b'\n' => { + if last_ch != b' ' { + comment.push(b' '); + } else { + last_ch = b' '; + } + continue; + } + _ => { + comment.push(ch); + } + } + last_ch = ch; + } + tags.push(HtmlToken::Comment { + text: String::from_utf8(comment).unwrap_or_default(), + }); + } else { + let mut is_end_tag = false; + loop { + match iter.peek() { + Some(&(_, &b'/')) => { + is_end_tag = true; + //pos += 1; + iter.next(); + } + Some((_, ch)) if ch.is_ascii_whitespace() => { + //pos += 1; + iter.next(); + } + _ => break, + } + } + + let mut in_quote = false; + let mut is_self_closing = false; + + let mut key: u64 = 0; + let mut shift = 0; + + let mut tag = 0; + let mut attributes: Vec<(u64, Option)> = vec![]; + + 'outer: while let Some((_, &ch)) = iter.next() { + match ch { + b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' if shift < 64 => { + key |= (ch as u64) << shift; + shift += 8; + } + b'A'..=b'Z' if shift < 64 => { + key |= ((ch - b'A' + b'a') as u64) << shift; + shift += 8; + } + b'/' if !in_quote => { + is_self_closing = true; + } + b'>' if !in_quote => { + if shift != 0 { + if tag == 0 { + tag = key; + } else { + attributes.push((key, None)); + } + } + break; + } + b'"' => { + in_quote = !in_quote; + } + b'=' if !in_quote => { + while matches!(iter.peek(), Some(&(_, &ch)) if ch.is_ascii_whitespace()) + { + iter.next(); + } + + if shift != 0 { + attributes.push((key, None)); + key = 0; + shift = 0; + } + + let mut value = vec![]; + + for (_, &ch) in iter.by_ref() { + match ch { + b'>' if !in_quote => { + if !value.is_empty() { + let value = + String::from_utf8(value).unwrap_or_default(); + if let Some((_, v)) = attributes.last_mut() { + *v = value.into(); + } else { + // Broken attribute + attributes.push((0, Some(value))); + } + } + break 'outer; + } + b'"' => { + if in_quote { + in_quote = false; + break; + } else { + in_quote = true; + } + } + b' ' | b'\t' | b'\r' | b'\n' if !in_quote => { + break; + } + _ => { + value.push(ch); + } + } + } + + if !value.is_empty() { + let value = String::from_utf8(value).unwrap_or_default(); + if let Some((_, v)) = attributes.last_mut() { + *v = value.into(); + } else { + // Broken attribute + attributes.push((0, Some(value))); + } + } + } + b' ' | b'\t' | b'\r' | b'\n' if shift != 0 => { + if tag == 0 { + tag = key; + } else { + attributes.push((key, None)); + } + key = 0; + shift = 0; + } + _ => {} + } + } + + if tag != 0 { + if is_end_tag { + tags.push(HtmlToken::EndTag { name: tag }); + } else { + tags.push(HtmlToken::StartTag { + name: tag, + attributes, + is_self_closing, + }); + } + } + } + continue; + } + b' ' | b'\t' | b'\r' | b'\n' => { + if !is_token_start { + add_html_token( + &mut text, + &input[token_start..token_end + 1], + is_after_space && !is_new_line, + ); + is_new_line = false; + } + is_after_space = true; + is_token_start = true; + continue; + } + b'&' if !is_token_start => { + add_html_token( + &mut text, + &input[token_start..token_end + 1], + is_after_space && !is_new_line, + ); + is_new_line = false; + is_token_start = true; + is_after_space = false; + } + b';' if !is_token_start => { + add_html_token( + &mut text, + &input[token_start..pos + 1], + is_after_space && !is_new_line, + ); + is_token_start = true; + is_after_space = false; + is_new_line = false; + continue; + } + _ => (), + } + + if is_token_start { + token_start = pos; + is_token_start = false; + } + token_end = pos; + } + + if !is_token_start { + add_html_token( + &mut text, + &input[token_start..token_end + 1], + is_after_space && !is_new_line, + ); + } + if !text.is_empty() { + tags.push(HtmlToken::Text { + text: text.as_str().into(), + }); + } + + tags +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_html_to_tokens_text() { + let input = "Hello, world!"; + let tokens = html_to_tokens(input); + assert_eq!( + tokens, + vec![HtmlToken::Text { + text: "Hello, world!".into() + }] + ); + } + + #[test] + fn test_html_to_tokens_start_tag() { + let input = "
"; + let tokens = html_to_tokens(input); + assert_eq!( + tokens, + vec![HtmlToken::StartTag { + name: 7760228, + attributes: vec![], + is_self_closing: false + }] + ); + } + + #[test] + fn test_html_to_tokens_end_tag() { + let input = "
"; + let tokens = html_to_tokens(input); + assert_eq!(tokens, vec![HtmlToken::EndTag { name: 7760228 }]); + } + + #[test] + fn test_html_to_tokens_comment() { + let input = ""; + let tokens = html_to_tokens(input); + assert_eq!( + tokens, + vec![HtmlToken::Comment { + text: "!-- This is a comment --".into() + }] + ); + } + + #[test] + fn test_html_to_tokens_mixed() { + let input = "
Hello, " world " !
"; + let tokens = html_to_tokens(input); + assert_eq!( + tokens, + vec![ + HtmlToken::StartTag { + name: 7760228, + attributes: vec![], + is_self_closing: false + }, + HtmlToken::Text { + text: "Hello,".into() + }, + HtmlToken::StartTag { + name: 1851879539, + attributes: vec![], + is_self_closing: false + }, + HtmlToken::Text { + text: " \" world \"".into() + }, + HtmlToken::EndTag { name: 1851879539 }, + HtmlToken::Text { text: " !".into() }, + HtmlToken::EndTag { name: 7760228 } + ] + ); + } + + #[test] + fn test_html_to_tokens_with_attributes() { + let input = r#""#; + let tokens = html_to_tokens(input); + assert_eq!( + tokens, + vec![ + HtmlToken::StartTag { + name: 500186508905, + attributes: vec![ + (1701869940, Some("text".into())), + (435761734006, Some("test".into())) + ], + is_self_closing: false + }, + HtmlToken::StartTag { + name: 111516266162547, + attributes: vec![], + is_self_closing: true + }, + HtmlToken::StartTag { + name: 6647407, + attributes: vec![(1920234593, None)], + is_self_closing: true + }, + HtmlToken::StartTag { + name: 97, + attributes: vec![(98, Some("1".into())), (98, None), (99, Some("123".into()))], + is_self_closing: false + } + ] + ); + } +} diff --git a/crates/spam-filter/src/modules/mod.rs b/crates/spam-filter/src/modules/mod.rs new file mode 100644 index 0000000..8b0e17b --- /dev/null +++ b/crates/spam-filter/src/modules/mod.rs @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod classifier; +pub mod dnsbl; +pub mod expression; +pub mod html; +pub mod pyzor; +pub mod sanitize; diff --git a/crates/spam-filter/src/modules/pyzor.rs b/crates/spam-filter/src/modules/pyzor.rs new file mode 100644 index 0000000..d24f0cd --- /dev/null +++ b/crates/spam-filter/src/modules/pyzor.rs @@ -0,0 +1,789 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::mailstore::spamfilter::PyzorConfig; +use mail_parser::{Message, PartType, decoders::html::add_html_token}; +use nlp::tokenizers::types::{TokenType, TypesTokenizer}; +use sha1::{Digest, Sha1}; +use std::{ + borrow::Cow, + net::SocketAddr, + time::{Duration, SystemTime}, +}; +use tokio::net::UdpSocket; +use utils::HexEncode; + +const MIN_LINE_LENGTH: usize = 8; +const ATOMIC_NUM_LINES: usize = 4; +const DIGEST_SPEC: &[(usize, usize)] = &[(20, 3), (60, 3)]; + +#[derive(Default, Debug, PartialEq, Eq)] +pub(crate) struct PyzorResponse { + pub code: u32, + pub count: u64, + pub wl_count: u64, +} + +pub(crate) async fn pyzor_check( + message: &Message<'_>, + config: &PyzorConfig, +) -> trc::Result> { + // Make sure there is at least one text part + if !message + .parts + .iter() + .any(|p| matches!(p.body, PartType::Text(_) | PartType::Html(_))) + { + return Ok(None); + } + + // Hash message + let request = message.pyzor_check_message(); + + #[cfg(feature = "test_mode")] + { + if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2") { + return Ok(PyzorResponse { + code: 200, + count: 1000, + wl_count: 0, + } + .into()); + } else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") { + return Ok(PyzorResponse { + code: 200, + count: 60, + wl_count: 10, + } + .into()); + } else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") { + return Ok(PyzorResponse { + code: 200, + count: 50, + wl_count: 20, + } + .into()); + } + } + + // Send message to address + pyzor_send_message(config.address, config.timeout, &request) + .await + .map(Into::into) + .map_err(|err| { + trc::SpamEvent::PyzorError + .into_err() + .ctx(trc::Key::Url, config.address.to_string()) + .reason(err) + .details("Pyzor failed") + }) +} + +async fn pyzor_send_message( + addr: SocketAddr, + timeout: Duration, + message: &str, +) -> std::io::Result { + let socket = UdpSocket::bind("0.0.0.0:0").await?; + tokio::time::timeout(timeout, socket.send_to(message.as_bytes(), addr)).await??; + + let mut buffer = vec![0u8; 1024]; + let (size, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buffer)).await??; + + let raw_response = std::str::from_utf8(&buffer[..size]) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let mut response = PyzorResponse { + code: u32::MAX, + count: u64::MAX, + wl_count: u64::MAX, + }; + + for line in raw_response.lines() { + if let Some((k, v)) = line.split_once(':') { + if k.eq_ignore_ascii_case("code") { + response.code = v.trim().parse().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid line: {raw_response}"), + ) + })?; + } else if k.eq_ignore_ascii_case("count") { + response.count = v.trim().parse().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid line: {raw_response}"), + ) + })?; + } else if k.eq_ignore_ascii_case("wl-count") { + response.wl_count = v.trim().parse().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid line: {raw_response}"), + ) + })?; + } + } + } + + if response.code != u32::MAX && response.count != u64::MAX && response.wl_count != u64::MAX { + Ok(response) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid response: {raw_response}"), + )) + } +} + +trait PyzorWrite { + fn write_all(&mut self, data: &[u8]); +} + +impl PyzorWrite for Vec { + fn write_all(&mut self, data: &[u8]) { + self.extend_from_slice(data); + } +} + +impl PyzorWrite for Sha1 { + fn write_all(&mut self, data: &[u8]) { + self.update(data); + } +} + +trait PyzorDigest { + fn pyzor_digest(&self, writer: W) -> W; +} + +pub trait PyzorCheck { + fn pyzor_check_message(&self) -> String; +} + +impl PyzorDigest for Message<'_> { + fn pyzor_digest(&self, writer: W) -> W { + let parts = self + .parts + .iter() + .filter_map(|part| match &part.body { + PartType::Text(text) => Some(text.as_ref().into()), + PartType::Html(html) => Some(html_to_text(html.as_ref()).into()), + _ => None, + }) + .collect::>>(); + + pyzor_digest(writer, parts.iter().flat_map(|text| text.lines())) + } +} + +impl PyzorCheck for Message<'_> { + fn pyzor_check_message(&self) -> String { + let time = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + + pyzor_create_message( + self, + time, + (time & 0xFFFF) as u16 ^ ((time >> 16) & 0xFFFF) as u16, + ) + } +} + +fn pyzor_create_message(message: &Message<'_>, time: u64, thread: u16) -> String { + // Hash message + let hash = message.pyzor_digest(Sha1::new()).finalize().hex_encode(); + // Hash key + let mut hash_key = Sha1::new(); + hash_key.update("anonymous:".as_bytes()); + let hash_key = hash_key.finalize().hex_encode(); + + // Hash message + let message = format!( + "Op: check\nOp-Digest: {hash}\nThread: {thread}\nPV: 2.1\nUser: anonymous\nTime: {time}" + ); + let mut msg_hash = Sha1::new(); + msg_hash.update(message.as_bytes()); + let msg_hash = msg_hash.finalize(); + + // Sign + let mut sig = Sha1::new(); + sig.update(msg_hash); + sig.update(format!(":{time}:{hash_key}")); + let sig = sig.finalize().hex_encode(); + + format!("{message}\nSig: {sig}\n") +} + +fn pyzor_digest<'x, I, W>(mut writer: W, lines: I) -> W +where + I: Iterator, + W: PyzorWrite, +{ + let mut result = Vec::with_capacity(16); + + for line in lines { + let mut clean_line = String::with_capacity(line.len()); + let mut token_start = usize::MAX; + let mut token_end = usize::MAX; + + let add_line = |line: &mut String, span: &str| { + if !span.contains(char::from(0)) { + if span.len() < 10 { + line.push_str(span); + } + } else { + let span = span.replace(char::from(0), ""); + if span.len() < 10 { + line.push_str(&span); + } + } + }; + + for token in TypesTokenizer::new(line) { + match token.word { + TokenType::Alphabetic(_) + | TokenType::Alphanumeric(_) + | TokenType::Integer(_) + | TokenType::Float(_) + | TokenType::Other(_) + | TokenType::Punctuation(_) => { + if token_start == usize::MAX { + token_start = token.from; + } + token_end = token.to; + } + TokenType::Space + | TokenType::Url(_) + | TokenType::UrlNoScheme(_) + | TokenType::UrlNoHost(_) + | TokenType::IpAddr(_) + | TokenType::Email(_) => { + if token_start != usize::MAX { + add_line(&mut clean_line, &line[token_start..token_end]); + token_start = usize::MAX; + token_end = usize::MAX; + } + } + } + } + + if token_start != usize::MAX { + add_line(&mut clean_line, &line[token_start..token_end]); + } + + if clean_line.len() >= MIN_LINE_LENGTH { + result.push(clean_line); + } + } + + if result.len() > ATOMIC_NUM_LINES { + for (offset, length) in DIGEST_SPEC { + for i in 0..*length { + if let Some(line) = result.get((*offset * result.len() / 100) + i) { + writer.write_all(line.as_bytes()); + } + } + } + } else { + for line in result { + writer.write_all(line.as_bytes()); + } + } + + writer +} + +fn html_to_text(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let input = input.as_bytes(); + + let mut in_tag = false; + let mut in_comment = false; + let mut in_style = false; + let mut in_script = false; + + let mut is_token_start = true; + let mut is_after_space = false; + let mut is_tag_close = false; + + let mut token_start = 0; + let mut token_end = 0; + + let mut tag_token_pos = 0; + let mut comment_pos = 0; + + for (pos, ch) in input.iter().enumerate() { + if !in_comment { + match ch { + b'<' => { + if !(in_tag || in_style || in_script || is_token_start) { + add_html_token( + &mut result, + &input[token_start..token_end + 1], + is_after_space, + ); + is_after_space = false; + } + + tag_token_pos = 0; + in_tag = true; + is_token_start = true; + is_tag_close = false; + continue; + } + b'>' if in_tag => { + if tag_token_pos == 1 + && let Some(tag) = input.get(token_start..token_end + 1) + { + if tag.eq_ignore_ascii_case(b"style") { + in_style = !is_tag_close; + } else if tag.eq_ignore_ascii_case(b"script") { + in_script = !is_tag_close; + } + } + + in_tag = false; + is_token_start = true; + is_after_space = !result.is_empty(); + + continue; + } + b'/' if in_tag => { + if tag_token_pos == 0 { + is_tag_close = true; + } + continue; + } + b'!' if in_tag && tag_token_pos == 0 => { + if let Some(b"--") = input.get(pos + 1..pos + 3) { + in_comment = true; + continue; + } + } + b' ' | b'\t' | b'\r' | b'\n' => { + if !(in_tag || in_style || in_script) { + if !is_token_start { + add_html_token( + &mut result, + &input[token_start..token_end + 1], + is_after_space, + ); + } + is_after_space = true; + } + + is_token_start = true; + continue; + } + b'&' if !(in_tag || is_token_start || in_style || in_script) => { + add_html_token( + &mut result, + &input[token_start..token_end + 1], + is_after_space, + ); + is_token_start = true; + is_after_space = false; + } + b';' if !(in_tag || is_token_start || in_style || in_script) => { + add_html_token(&mut result, &input[token_start..pos + 1], is_after_space); + is_token_start = true; + is_after_space = false; + continue; + } + _ => (), + } + if is_token_start { + token_start = pos; + is_token_start = false; + if in_tag { + tag_token_pos += 1; + } + } + token_end = pos; + } else { + match ch { + b'-' => comment_pos += 1, + b'>' if comment_pos == 2 => { + comment_pos = 0; + in_comment = false; + in_tag = false; + is_token_start = true; + } + _ => comment_pos = 0, + } + } + } + + if !(in_tag || is_token_start || in_style || in_script) { + add_html_token( + &mut result, + &input[token_start..token_end + 1], + is_after_space, + ); + } + + result.shrink_to_fit(); + result +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use mail_parser::MessageParser; + use sha1::Digest; + use sha1::Sha1; + use utils::HexEncode; + + use super::pyzor_create_message; + use super::pyzor_send_message; + use super::{PyzorDigest, html_to_text, pyzor_digest}; + + use super::PyzorResponse; + + #[ignore] + #[tokio::test] + async fn send_message() { + assert_eq!( + pyzor_send_message( + "public.pyzor.org:24441".parse().unwrap(), + Duration::from_secs(10), + concat!( + "Op: check\n", + "Op-Digest: b2c27325a034c581df0c9ef37e4a0d63208a3e7e\n", + "Thread: 49005\n", + "PV: 2.1\n", + "User: anonymous\n", + "Time: 1697468672\n", + "Sig: 9cf4571b85d3887fdd0d4f444fd0c164e0290722\n" + ), + ) + .await + .unwrap(), + PyzorResponse { + code: 200, + count: 0, + wl_count: 0 + } + ); + } + + #[test] + fn message_pyzor() { + let message = pyzor_create_message( + &MessageParser::new().parse(HTML_TEXT_STYLE_SCRIPT).unwrap(), + 1697468672, + 49005, + ); + + assert_eq!( + message, + concat!( + "Op: check\n", + "Op-Digest: b2c27325a034c581df0c9ef37e4a0d63208a3e7e\n", + "Thread: 49005\n", + "PV: 2.1\n", + "User: anonymous\n", + "Time: 1697468672\n", + "Sig: 9cf4571b85d3887fdd0d4f444fd0c164e0290722\n" + ) + ); + } + + #[test] + fn digest_pyzor() { + // HTML stripping + assert_eq!(html_to_text(HTML_RAW), HTML_RAW_STRIPED); + + // Token stripping + for strip_me in [ + "t@abc.com", + "t1@abc.com", + "t+a@abc.com", + "t.a@abc.com", + "0A2D3f%a#S", + "3sddkf9jdkd9", + "@@#@@@@@@@@@", + "http://spammer.com/special-offers?buy=now", + ] { + assert_eq!( + String::from_utf8(pyzor_digest( + Vec::new(), + format!("Test {strip_me} Test2").lines(), + )) + .unwrap(), + "TestTest2" + ); + } + + // Test short lines + assert_eq!( + String::from_utf8(pyzor_digest( + Vec::new(), + concat!("This line is included\n", "not this\n", "This also").lines(), + )) + .unwrap(), + "ThislineisincludedThisalso" + ); + + // Test atomic + assert_eq!( + String::from_utf8(pyzor_digest( + Vec::new(), + "All this message\nShould be included\nIn the digest".lines(), + )) + .unwrap(), + "AllthismessageShouldbeincludedInthedigest" + ); + + // Test spec + let mut text = String::new(); + for i in 0..100 { + text += format!("Line{i} test test test\n").as_str(); + } + let mut expected = String::new(); + for i in [20, 21, 22, 60, 61, 62] { + expected += format!("Line{i}testtesttest").as_str(); + } + assert_eq!( + String::from_utf8(pyzor_digest(Vec::new(), text.lines(),)).unwrap(), + expected + ); + + // Test email parsing + for (input, expected) in [ + ( + HTML_TEXT, + concat!( + "Emailspam,alsoknownasjunkemailorbulkemail,isasubset", + "ofspaminvolvingnearlyidenticalmessagessenttonumerous", + "byemail.Clickingonlinksinspamemailmaysendusersto", + "byemail.Clickingonlinksinspamemailmaysendusersto", + "phishingwebsitesorsitesthatarehostingmalware.", + "Emailspam.Emailspam,alsoknownasjunkemailorbulkemail,", + "isasubsetofspaminvolvingnearlyidenticalmessage", + "ssenttonumerousbyemail.Clickingonlinksinspamemailmaysenduse", + "rstophishingwebsitesorsitesthatarehostingmalware." + ), + ), + (HTML_TEXT_STYLE_SCRIPT, "Thisisatest.Thisisatest."), + (TEXT_ATTACHMENT, "Thisisatestmailing"), + (TEXT_ATTACHMENT_W_NULL, "Thisisatestmailing"), + (TEXT_ATTACHMENT_W_MULTIPLE_NULLS, "Thisisatestmailing"), + (TEXT_ATTACHMENT_W_SUBJECT_NULL, "Thisisatestmailing"), + (TEXT_ATTACHMENT_W_CONTENTTYPE_NULL, "Thisisatestmailing"), + ] { + assert_eq!( + String::from_utf8( + MessageParser::new() + .parse(input) + .unwrap() + .pyzor_digest(Vec::new(),) + ) + .unwrap(), + expected, + "failed for {input}" + ) + } + + // Test SHA hash + assert_eq!( + MessageParser::new() + .parse(HTML_TEXT_STYLE_SCRIPT) + .unwrap() + .pyzor_digest(Sha1::new(),) + .finalize() + .hex_encode(), + "b2c27325a034c581df0c9ef37e4a0d63208a3e7e", + ) + } + + const HTML_TEXT: &str = r#"MIME-Version: 1.0 +Sender: chirila@gapps.spamexperts.com +Received: by 10.216.157.70 with HTTP; Thu, 16 Jan 2014 00:43:31 -0800 (PST) +Date: Thu, 16 Jan 2014 10:43:31 +0200 +Delivered-To: chirila@gapps.spamexperts.com +X-Google-Sender-Auth: ybCmONS9U9D6ZUfjx-9_tY-hF2Q +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/alternative; boundary=001a11c25ff293069304f0126bfd + +--001a11c25ff293069304f0126bfd +Content-Type: text/plain; charset=ISO-8859-1 + +Email spam. + +Email spam, also known as junk email or unsolicited bulk email, is a subset +of electronic spam involving nearly identical messages sent to numerous +recipients by email. Clicking on links in spam email may send users to +phishing web sites or sites that are hosting malware. + +--001a11c25ff293069304f0126bfd +Content-Type: text/html; charset=ISO-8859-1 +Content-Transfer-Encoding: quoted-printable + +
Email spam.

Email spam, also= + known as junk email or unsolicited bulk email, is a subset of electronic s= +pam involving nearly identical messages sent to numerous recipients by emai= +l. Clicking on links in spam email may send users to phishing web sites or = +sites that are hosting malware.
+
+ +--001a11c25ff293069304f0126bfd-- +"#; + + const HTML_TEXT_STYLE_SCRIPT: &str = r#"MIME-Version: 1.0 +Sender: chirila@gapps.spamexperts.com +Received: by 10.216.157.70 with HTTP; Thu, 16 Jan 2014 00:43:31 -0800 (PST) +Date: Thu, 16 Jan 2014 10:43:31 +0200 +Delivered-To: chirila@gapps.spamexperts.com +X-Google-Sender-Auth: ybCmONS9U9D6ZUfjx-9_tY-hF2Q +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/alternative; boundary=001a11c25ff293069304f0126bfd + +--001a11c25ff293069304f0126bfd +Content-Type: text/plain; charset=ISO-8859-1 + +This is a test. + +--001a11c25ff293069304f0126bfd +Content-Type: text/html; charset=ISO-8859-1 +Content-Transfer-Encoding: quoted-printable + +
+ + +
This is a test.
+
+ +--001a11c25ff293069304f0126bfd-- +"#; + + const TEXT_ATTACHMENT: &str = r#"MIME-Version: 1.0 +Received: by 10.76.127.40 with HTTP; Fri, 17 Jan 2014 02:21:43 -0800 (PST) +Date: Fri, 17 Jan 2014 12:21:43 +0200 +Delivered-To: chirila.s.alexandru@gmail.com +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/mixed; boundary=f46d040a62c49bb1c804f027e8cc + +--f46d040a62c49bb1c804f027e8cc +Content-Type: multipart/alternative; boundary=f46d040a62c49bb1c404f027e8ca + +--f46d040a62c49bb1c404f027e8ca +Content-Type: text/plain; charset=ISO-8859-1 + +This is a test mailing + +--f46d040a62c49bb1c404f027e8ca-- +--f46d040a62c49bb1c804f027e8cc +Content-Type: image/png; name="tar.png" +Content-Disposition: attachment; filename="tar.png" +Content-Transfer-Encoding: base64 +X-Attachment-Id: f_hqjas5ad0 + +iVBORw0KGgoAAAANSUhEUgAAAskAAADlCAAAAACErzVVAAAACXBIWXMAAAsTAAALEwEAmpwYAAAD +QmCC +--f46d040a62c49bb1c804f027e8cc--"#; + + const TEXT_ATTACHMENT_W_NULL: &str = "MIME-Version: 1.0 +Received: by 10.76.127.40 with HTTP; Fri, 17 Jan 2014 02:21:43 -0800 (PST) +Date: Fri, 17 Jan 2014 12:21:43 +0200 +Delivered-To: chirila.s.alexandru@gmail.com +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/mixed; boundary=f46d040a62c49bb1c804f027e8cc + +--f46d040a62c49bb1c804f027e8cc +Content-Type: multipart/alternative; boundary=f46d040a62c49bb1c404f027e8ca + +--f46d040a62c49bb1c404f027e8ca +Content-Type: text/plain; charset=ISO-8859-1 + +This is a test ma\0iling +--f46d040a62c49bb1c804f027e8cc--"; + + const TEXT_ATTACHMENT_W_MULTIPLE_NULLS: &str = "MIME-Version: 1.0 +Received: by 10.76.127.40 with HTTP; Fri, 17 Jan 2014 02:21:43 -0800 (PST) +Date: Fri, 17 Jan 2014 12:21:43 +0200 +Delivered-To: chirila.s.alexandru@gmail.com +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/mixed; boundary=f46d040a62c49bb1c804f027e8cc + +--f46d040a62c49bb1c804f027e8cc +Content-Type: multipart/alternative; boundary=f46d040a62c49bb1c404f027e8ca + +--f46d040a62c49bb1c404f027e8ca +Content-Type: text/plain; charset=ISO-8859-1 + +This is a test ma\0\0\0iling +--f46d040a62c49bb1c804f027e8cc--"; + + const TEXT_ATTACHMENT_W_SUBJECT_NULL: &str = "MIME-Version: 1.0 +Received: by 10.76.127.40 with HTTP; Fri, 17 Jan 2014 02:21:43 -0800 (PST) +Date: Fri, 17 Jan 2014 12:21:43 +0200 +Delivered-To: chirila.s.alexandru@gmail.com +Message-ID: +Subject: Te\0\0\0st +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/mixed; boundary=f46d040a62c49bb1c804f027e8cc + +--f46d040a62c49bb1c804f027e8cc +Content-Type: multipart/alternative; boundary=f46d040a62c49bb1c404f027e8ca + +--f46d040a62c49bb1c404f027e8ca +Content-Type: text/plain; charset=ISO-8859-1 + +This is a test mailing +--f46d040a62c49bb1c804f027e8cc--"; + + const TEXT_ATTACHMENT_W_CONTENTTYPE_NULL: &str = "MIME-Version: 1.0 +Received: by 10.76.127.40 with HTTP; Fri, 17 Jan 2014 02:21:43 -0800 (PST) +Date: Fri, 17 Jan 2014 12:21:43 +0200 +Delivered-To: chirila.s.alexandru@gmail.com +Message-ID: +Subject: Test +From: Alexandru Chirila +To: Alexandru Chirila +Content-Type: multipart/mixed; boundary=f46d040a62c49bb1c804f027e8cc + +--f46d040a62c49bb1c804f027e8cc +Content-Type: multipart/alternative; boundary=f46d040a62c49bb1c404f027e8ca + +--f46d040a62c49bb1c404f027e8ca +Content-Type: text/plain; charset=\"iso-8859-1\0\0\0\" + +This is a test mailing +--f46d040a62c49bb1c804f027e8cc--"; + + const HTML_RAW: &str = r#"Email spam +

Email spam, also known as junk email +or unsolicited bulk email (UBE), is a subset of +electronic spam +involving nearly identical messages sent to numerous recipients by +email. Clicking on +links in spam email may send users to phishing +web sites or sites that are hosting malware."#; + + const HTML_RAW_STRIPED: &str = concat!( + "Email spam Email spam , also known as junk email or unsolicited bulk email ( UBE ),", + " is a subset of electronic spam involving nearly identical messages sent to numerous recipients by email", + " . Clicking on links in spam email may send users to phishing web sites or sites that are hosting malware ." + ); +} diff --git a/crates/spam-filter/src/modules/sanitize.rs b/crates/spam-filter/src/modules/sanitize.rs new file mode 100644 index 0000000..f01f618 --- /dev/null +++ b/crates/spam-filter/src/modules/sanitize.rs @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::IpAddr; + +use crate::{Email, Hostname}; + +impl Hostname { + pub fn new(host: &str) -> Self { + let mut fqdn = host.trim_end_matches('.').to_lowercase(); + + // Decode punycode + if fqdn.contains("xn--") { + let mut decoded = String::with_capacity(fqdn.len()); + + for part in fqdn.split('.') { + if !decoded.is_empty() { + decoded.push('.'); + } + + if let Some(puny) = part + .strip_prefix("xn--") + .and_then(idna::punycode::decode_to_string) + .filter(|puny| { + idna::domain_to_ascii(puny).is_ok_and(|reencoded| reencoded == part) + }) + { + decoded.push_str(&puny); + } else { + decoded.push_str(part); + } + } + + fqdn = decoded; + } + + let ip = fqdn + .strip_prefix('[') + .and_then(|ip| ip.strip_suffix(']')) + .unwrap_or(&fqdn) + .parse::() + .ok(); + + Hostname { + sld: if ip.is_none() { + psl::domain(fqdn.as_bytes()).and_then(|domain| { + if domain.suffix().typ().is_some() { + std::str::from_utf8(domain.as_bytes()).ok().map(Into::into) + } else { + None + } + }) + } else { + None + }, + ip, + fqdn, + } + } +} + +impl Email { + pub fn new(address: &str) -> Self { + let address = address.to_lowercase(); + let (local_part, domain) = address.rsplit_once('@').unwrap_or((address.as_str(), "")); + + Email { + local_part: local_part.into(), + domain_part: Hostname::new(domain), + address, + } + } +} + +impl Hostname { + pub fn sld_or_default(&self) -> &str { + self.sld.as_deref().unwrap_or(self.fqdn.as_str()) + } +} + +#[cfg(test)] +mod test { + use crate::{Email, Hostname}; + + #[test] + fn hostname_punycode_round_trip() { + for (host, fqdn, sld) in [ + ("mail.example.com", "mail.example.com", Some("example.com")), + ( + "MAIL.Example.CO.UK.", + "mail.example.co.uk", + Some("example.co.uk"), + ), + ( + "mail.xn--eebajf.xn--9dbq2a", + "mail.\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}", + Some("\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}"), + ), + ("xn--gmail-.com", "xn--gmail-.com", Some("xn--gmail-.com")), + ( + "xn--example-.org", + "xn--example-.org", + Some("xn--example-.org"), + ), + ("xn--.com", "xn--.com", Some("xn--.com")), + ("127.0.0.1", "127.0.0.1", None), + ] { + let parsed = Hostname::new(host); + assert_eq!(parsed.fqdn, fqdn, "fqdn of {host:?}"); + assert_eq!(parsed.sld.as_deref(), sld, "sld of {host:?}"); + } + } + + #[test] + fn email_a_label_and_u_label_are_equal() { + assert_eq!( + Email::new("bill@xn--eebajf.xn--9dbq2a"), + Email::new("bill@\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}") + ); + assert_ne!( + Email::new("bill@example.com"), + Email::new("bob@example.com") + ); + assert_ne!(Email::new("postmaster"), Email::new("mailer-daemon")); + assert_ne!( + Email::new("victim@xn--gmail-.com"), + Email::new("victim@gmail.com") + ); + } +} diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml new file mode 100644 index 0000000..e22c3a8 --- /dev/null +++ b/crates/store/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "store" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +types = { path = "../types" } +nlp = { path = "../nlp" } +trc = { path = "../trc" } +registry = { path = "../registry" } +rocksdb = { version = "0.24", optional = true, features = ["multi-threaded-cf"] } +foundationdb = { version = "0.11", features = ["embedded-fdb-include", "fdb-7_4"], optional = true } +rusqlite = { version = "0.40", features = ["bundled"], optional = true } +rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"], optional = true } +reqwest_s3 = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"], optional = true } +azure_core = { version = "0.21.0", optional = true } +azure_storage = { version = "0.21.0", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"], optional = true } +azure_storage_blobs = { version = "0.21.0", default-features = false, features = ["enable_reqwest_rustls", "hmac_rust"], optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "stream"]} +tokio = { version = "1.53", features = ["sync", "fs", "io-util"] } +r2d2 = { version = "0.8.10", optional = true } +futures = { version = "0.3", optional = true } +rand = "0.10.2" +roaring = "0.11" +rayon = { version = "1.12", optional = true } +serde = { version = "1.0", features = ["derive"]} +ahash = { version = "0.8.12", features = ["serde"] } +xxhash-rust = { version = "0.8.18", features = ["xxh3"] } +parking_lot = "0.12" +lru-cache = { version = "0.1.2", optional = true } +num_cpus = { version = "1.17", optional = true } +blake3 = "1.8" +lz4_flex = { version = "0.14", features = ["alloc"], default-features = false } +deadpool-postgres = { version = "0.14", optional = true } +tokio-postgres = { version = "0.7.18", features = ["with-serde_json-1"], optional = true } +tokio-rustls = { version = "0.26", optional = true, default-features = false, features = ["aws_lc_rs", "tls12"] } +rustls = { version = "0.23.43", optional = true, default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +rustls-pki-types = { version = "1", optional = true } +aws-lc-rs = { version = "1", optional = true } +x509-parser = { version = "0.18", optional = true } +bytes = { version = "1.12", optional = true } +mysql_async = { version = "0.37", default-features = false, features = ["default-rustls", "minimal"], optional = true } +serde_json = { version = "1.0.151" } +flate2 = "1.1" +redis = { version = "1.6", features = [ "tokio-comp", "tokio-rustls-comp", "tls-rustls-insecure", "tls-rustls", "cluster-async", "sentinel"], optional = true } +deadpool = { version = "0.13", features = ["managed"], optional = true } +arc-swap = "1.9.2" +bitpacking = "0.9.3" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = "0.10.0" +gethostname = "1.1.0" +radsort = "0.1.1" + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[features] +# Data Stores +rocks = ["rocksdb", "rayon", "num_cpus"] +sqlite = ["rusqlite", "rayon", "r2d2", "num_cpus", "lru-cache"] +postgres = ["tokio-postgres", "deadpool", "deadpool-postgres", "tokio-rustls", "rustls", "aws-lc-rs", "rustls-pki-types", "x509-parser", "futures", "bytes"] +mysql = ["mysql_async", "futures"] +foundation = ["foundationdb", "futures"] +fdb-chunked-bm = [] + +# Blob stores +s3 = ["rust-s3", "dep:reqwest_s3"] +azure = ["azure_core", "azure_storage", "azure_storage_blobs", "futures"] + +# In-memory stores +redis = ["dep:redis", "deadpool", "deadpool/rt_tokio_1", "futures"] + +enterprise = [] +test_mode = [] + +[lints] +workspace = true diff --git a/crates/store/src/backend/azure/mod.rs b/crates/store/src/backend/azure/mod.rs new file mode 100644 index 0000000..9360d5e --- /dev/null +++ b/crates/store/src/backend/azure/mod.rs @@ -0,0 +1,169 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use azure_core::error::ErrorKind; +use azure_core::{ExponentialRetryOptions, RetryOptions, StatusCode}; +use azure_storage::StorageCredentials; +use azure_storage_blobs::prelude::{ClientBuilder, ContainerClient}; +use futures::stream::StreamExt; +use registry::schema::structs::{self}; +use std::sync::Arc; +use std::{fmt::Display, io::Write, ops::Range}; +use utils::codec::base32_custom::Base32Writer; + +use crate::BlobStore; + +pub struct AzureStore { + client: ContainerClient, + prefix: Option, +} + +impl AzureStore { + pub async fn open(config: structs::AzureStore) -> Result { + let credentials = match ( + config.access_key.secret().await?.map(|v| v.into_owned()), + config.sas_token.secret().await?.map(|v| v.into_owned()), + ) { + (Some(access_key), None) => { + StorageCredentials::access_key(config.storage_account.clone(), access_key) + } + (None, Some(sas_token)) => match StorageCredentials::sas_token(sas_token) { + Ok(cred) => cred, + Err(err) => { + return Err(format!("Failed to create credentials: {err:?}")); + } + }, + _ => { + return Err(concat!( + "Failed to create credentials: exactly one of ", + "'azure-access-key' and 'sas-token' must be specified" + ) + .to_string()); + } + }; + + Ok(BlobStore::Azure(Arc::new(AzureStore { + client: ClientBuilder::new(config.storage_account, credentials) + .retry(RetryOptions::exponential( + ExponentialRetryOptions::default().max_retries(config.max_retries as u32 * 2), + )) + .container_client(config.container), + prefix: config.key_prefix, + }))) + } + + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let blob_client = self.client.blob_client(self.build_key(key)); + + let mut stream = blob_client.get(); + let mut buf = if range.end == usize::MAX { + // Let's turn this into a proper RangeFrom. + stream = stream.range(range.start..); + // We don't know how big to expect the result to be. + Vec::new() + } else { + stream = stream.range(range.clone()); + Vec::with_capacity(range.end - range.start) + }; + let mut stream = stream.into_stream(); + + while let Some(response) = stream.next().await { + let err = match response { + Ok(chunks) => { + let mut chunks = chunks.data; + let mut err = None; + while let Some(chunk) = chunks.next().await { + match chunk { + Ok(ref data) => { + buf.extend(data); + } + Err(e) => { + err = Some(e); + break; + } + } + } + err + } + Err(e) => Some(e), + }; + + if let Some(e) = err { + return if matches!( + e.kind(), + ErrorKind::HttpResponse { + status: StatusCode::NotFound, + .. + } + ) { + Ok(None) + } else { + Err(trc::StoreEvent::AzureError.reason(e)) + }; + } + } + + Ok(Some(buf)) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let blob_client = self.client.blob_client(self.build_key(key)); + + // We unfortunately have to make a copy of `data`. This is because the Azure SDK wants to + // coerce the body into a value of type azure_core::Body, which doesn't have a lifetime + // parameter and so cannot hold any non-static references (directly or indirectly). + let data = data.to_vec(); + + blob_client + .put_block_blob(data) + .into_future() + .await + .map_err(into_error)?; + + Ok(()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let blob_client = self.client.blob_client(self.build_key(key)); + + if let Err(e) = blob_client.delete().into_future().await { + if matches!( + e.kind(), + ErrorKind::HttpResponse { + status: StatusCode::NotFound, + .. + } + ) { + Ok(false) + } else { + Err(trc::StoreEvent::AzureError.reason(e)) + } + } else { + Ok(true) + } + } + + fn build_key(&self, key: &[u8]) -> String { + if let Some(prefix) = &self.prefix { + let mut writer = + Base32Writer::with_raw_capacity(prefix.len() + (key.len().div_ceil(4) * 5)); + writer.push_string(prefix); + writer.write_all(key).unwrap(); + writer.finalize() + } else { + Base32Writer::from_bytes(key).finalize() + } + } +} + +#[inline(always)] +fn into_error(err: impl Display) -> trc::Error { + trc::StoreEvent::AzureError.reason(err) +} diff --git a/crates/store/src/backend/elastic/main.rs b/crates/store/src/backend/elastic/main.rs new file mode 100644 index 0000000..bf55282 --- /dev/null +++ b/crates/store/src/backend/elastic/main.rs @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use crate::{ + SearchStore, + backend::elastic::ElasticSearchStore, + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, SearchableField, + TracingSearchField, + }, +}; +use registry::schema::structs; +use reqwest::{Error, Response, Url}; +use serde_json::{Value, json}; + +impl ElasticSearchStore { + pub async fn open(config: structs::ElasticSearchStore) -> Result { + Url::parse(&config.url).map_err(|e| format!("Invalid URL: {e}",))?; + + Ok(SearchStore::ElasticSearch(Arc::new(Self { + client: config + .http_auth + .build_http_client( + config.http_headers, + "application/json".into(), + config.timeout, + config.allow_invalid_certs, + ) + .await?, + url: config.url, + num_replicas: config.num_replicas as usize, + num_shards: config.num_shards as usize, + include_source: config.include_source, + }))) + } + + pub async fn create_indexes(&self) -> trc::Result<()> { + self.create_index::().await?; + self.create_index::().await?; + self.create_index::().await?; + self.create_index::().await?; + Ok(()) + } + + async fn create_index(&self) -> trc::Result<()> { + let mut mappings = serde_json::Map::new(); + mappings.insert( + "properties".to_string(), + Value::Object( + T::primary_keys() + .iter() + .chain(T::all_fields()) + .map(|field| (field.field_name().to_string(), field.es_schema())) + .collect::>(), + ), + ); + if !self.include_source { + mappings.insert("_source".to_string(), json!({ "enabled": false })); + } + let body = json!({ + "mappings": mappings, + "settings": { + "index.number_of_shards": self.num_shards, + "index.number_of_replicas": self.num_replicas, + "analysis": { + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "stemmer"] + } + } + } + } + }); + + let response = self + .client + .put(format!("{}/{}", self.url, T::index().index_name())) + .body(body.to_string()) + .send() + .await + .map_err(|err| { + trc::StoreEvent::ElasticsearchError + .reason(err) + .details("Failed to create index") + })?; + + match response.status().as_u16() { + 200..300 => Ok(()), + status @ (400..500) => { + let text = response.text().await.unwrap_or_default(); + if text.contains("resource_already_exists_exception") { + // Index already exists, ignore + Ok(()) + } else { + Err(trc::StoreEvent::ElasticsearchError + .reason(text) + .ctx(trc::Key::Code, status)) + } + } + status => { + let text = response.text().await.unwrap_or_default(); + Err(trc::StoreEvent::ElasticsearchError + .reason(text) + .ctx(trc::Key::Code, status)) + } + } + } + + #[cfg(feature = "test_mode")] + pub async fn drop_indexes(&self) -> trc::Result<()> { + use crate::write::SearchIndex; + + for index in &[ + SearchIndex::Email, + SearchIndex::Calendar, + SearchIndex::Contacts, + SearchIndex::Tracing, + ] { + assert_success( + self.client + .delete(format!("{}/{}", self.url, index.index_name())) + .send() + .await, + ) + .await + .map(|_| ())?; + } + + Ok(()) + } +} + +pub(crate) async fn assert_success(response: Result) -> trc::Result { + match response { + Ok(response) => { + let status = response.status(); + if status.is_success() { + Ok(response) + } else { + Err(trc::StoreEvent::ElasticsearchError + .reason(response.text().await.unwrap_or_default()) + .ctx(trc::Key::Code, status.as_u16())) + } + } + Err(err) => Err(trc::StoreEvent::ElasticsearchError.reason(err)), + } +} diff --git a/crates/store/src/backend/elastic/mod.rs b/crates/store/src/backend/elastic/mod.rs new file mode 100644 index 0000000..d0936f9 --- /dev/null +++ b/crates/store/src/backend/elastic/mod.rs @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::search::*; +use reqwest::Client; +use serde::{Deserialize, Deserializer}; +use serde_json::{Value, json}; + +pub mod main; +pub mod search; + +pub struct ElasticSearchStore { + client: Client, + url: String, + num_shards: usize, + num_replicas: usize, + include_source: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SearchResponse { + pub hits: Hits, +} + +#[derive(Debug, Deserialize)] +pub struct Hits { + pub total: Total, + pub hits: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Total { + pub value: u64, +} + +#[derive(Debug, Deserialize)] +pub struct Hit { + #[serde(rename = "_id", deserialize_with = "deserialize_string_to_u64")] + pub id: u64, + pub sort: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteByQueryResponse { + pub deleted: u64, +} + +impl SearchField { + pub fn es_schema(&self) -> Value { + match self { + SearchField::AccountId + | SearchField::DocumentId + | SearchField::Email(EmailSearchField::Size) => json!({ + "type": "integer" + }), + SearchField::Id + | SearchField::Email(EmailSearchField::SentAt | EmailSearchField::ReceivedAt) + | SearchField::Calendar(CalendarSearchField::Start) + | SearchField::Tracing(TracingSearchField::QueueId | TracingSearchField::EventType) => { + json!({ + "type": "long" + }) + } + SearchField::Email(EmailSearchField::HasAttachment) => json!({ + "type": "boolean" + }), + SearchField::Calendar(CalendarSearchField::Uid) + | SearchField::Contact(ContactSearchField::Uid) => json!({ + "type": "keyword", + }), + SearchField::Email( + EmailSearchField::From | EmailSearchField::To | EmailSearchField::Subject, + ) => json!({ + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }), + SearchField::Email(EmailSearchField::Headers) => { + json!({ + "type": "object", + "enabled": true + }) + } + #[cfg(feature = "test_mode")] + SearchField::Email(EmailSearchField::Bcc | EmailSearchField::Cc) => { + json!({ + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }) + } + #[cfg(not(feature = "test_mode"))] + SearchField::Email(EmailSearchField::Bcc | EmailSearchField::Cc) => { + json!({ + "type": "text" + }) + } + SearchField::Email(EmailSearchField::Body | EmailSearchField::Attachment) + | SearchField::Calendar( + CalendarSearchField::Title + | CalendarSearchField::Description + | CalendarSearchField::Location + | CalendarSearchField::Owner + | CalendarSearchField::Attendee, + ) + | SearchField::Contact( + ContactSearchField::Member + | ContactSearchField::Kind + | ContactSearchField::Name + | ContactSearchField::Nickname + | ContactSearchField::Organization + | ContactSearchField::Email + | ContactSearchField::Phone + | ContactSearchField::OnlineService + | ContactSearchField::Address + | ContactSearchField::Note, + ) + | SearchField::File(FileSearchField::Name | FileSearchField::Content) + | SearchField::Tracing(TracingSearchField::Keywords) => json!({ + "type": "text" + }), + } + } +} + +fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + <&str>::deserialize(deserializer)? + .parse::() + .map_err(serde::de::Error::custom) +} diff --git a/crates/store/src/backend/elastic/search.rs b/crates/store/src/backend/elastic/search.rs new file mode 100644 index 0000000..b741ec2 --- /dev/null +++ b/crates/store/src/backend/elastic/search.rs @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + backend::elastic::{ + DeleteByQueryResponse, ElasticSearchStore, SearchResponse, main::assert_success, + }, + search::{ + IndexDocument, SearchComparator, SearchDocumentId, SearchField, SearchFilter, + SearchOperator, SearchQuery, SearchValue, + }, + write::SearchIndex, +}; +use serde_json::{Map, Value, json}; +use std::fmt::Write; + +impl ElasticSearchStore { + pub async fn index(&self, documents: Vec) -> trc::Result<()> { + let mut request = String::with_capacity(512); + + for document in documents { + let id = if let (Some(SearchValue::Uint(account_id)), Some(SearchValue::Uint(doc_id))) = ( + document.fields.get(&SearchField::AccountId), + document.fields.get(&SearchField::DocumentId), + ) { + *account_id << 32 | *doc_id + } else if let Some(SearchValue::Uint(id)) = document.fields.get(&SearchField::Id) { + *id + } else { + debug_assert!(false, "Document is missing required ID fields"); + continue; + }; + + let _ = writeln!( + &mut request, + "{{\"index\":{{\"_index\":\"{}\",\"_id\":{id}}}}}", + document.index.index_name() + ); + json_serialize(&mut request, &document); + request.push('\n'); + } + + assert_success( + self.client + .post(format!("{}/_bulk", self.url)) + .body(request) + .send() + .await, + ) + .await + .map(|_| ()) + } + + pub async fn query( + &self, + index: SearchIndex, + filters: &[SearchFilter], + sort: &[SearchComparator], + ) -> trc::Result> { + let mut search_after: Option = None; + let mut results = Vec::new(); + let mut has_more = true; + + while has_more { + let query = Map::from_iter( + [ + Some(("query".to_string(), build_query(filters))), + Some(("size".to_string(), Value::from(10_000))), + Some(("_source".to_string(), Value::from(false))), + Some(( + "sort".to_string(), + build_sort(sort, R::field().field_name()), + )), + search_after + .take() + .map(|sa| ("search_after".to_string(), sa)), + ] + .into_iter() + .flatten(), + ); + + let response = assert_success( + self.client + .post(format!("{}/{}/_search", self.url, index.index_name())) + .body(serde_json::to_string(&query).unwrap_or_default()) + .send() + .await, + ) + .await?; + + let text = response + .text() + .await + .map_err(|err| trc::StoreEvent::ElasticsearchError.reason(err))?; + + let response = serde_json::from_str::(&text).map_err(|err| { + trc::StoreEvent::ElasticsearchError + .reason(err) + .details(text) + })?; + + has_more = response.hits.hits.len() == 10_000 + && response.hits.hits.last().unwrap().sort.is_some(); + + for hit in response.hits.hits { + search_after = hit.sort; + results.push(R::from_u64(hit.id)); + } + } + + Ok(results) + } + + pub async fn unindex(&self, filter: SearchQuery) -> trc::Result { + if filter.filters.is_empty() { + return Err(trc::StoreEvent::ElasticsearchError + .reason("Unindex operation requires at least one filter")); + } + + let query = json!({ + "query": build_query(&filter.filters), + }); + + let response = assert_success( + self.client + .post(format!( + "{}/{}/_delete_by_query", + self.url, + filter.index.index_name() + )) + .body(serde_json::to_string(&query).unwrap_or_default()) + .send() + .await, + ) + .await?; + + let response_body = response + .text() + .await + .map_err(|err| trc::StoreEvent::ElasticsearchError.reason(err))?; + + serde_json::from_str::(&response_body) + .map(|delete_response| delete_response.deleted) + .map_err(|err| trc::StoreEvent::ElasticsearchError.reason(err)) + } + + pub async fn refresh_index(&self, index: SearchIndex) -> trc::Result<()> { + let url = format!("{}/{}/_refresh", self.url, index.index_name()); + + assert_success(self.client.post(url).send().await) + .await + .map(|_| ()) + } +} + +fn build_query(filters: &[SearchFilter]) -> Value { + if filters.is_empty() { + return json!({ "match_all": {} }); + } + + let mut stack = Vec::new(); + let mut conditions = Vec::new(); + let mut logical_op = &SearchFilter::And; + + for filter in filters { + match filter { + SearchFilter::Operator { field, op, value } => { + if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains) + { + let SearchValue::Text { value, .. } = value else { + debug_assert!(false, "Invalid value type for text field"); + continue; + }; + + if op != &SearchOperator::Equal { + conditions.push(json!({ + "match": { field.field_name(): { + "query": value, + "operator": "and" + } } + })); + } else { + conditions.push(json!({ + "match_phrase": { field.field_name(): value } + })); + } + } else { + let value = match value { + SearchValue::Text { value, .. } => json!(value), + SearchValue::Int(value) => json!(value), + SearchValue::Uint(value) => json!(value), + SearchValue::Boolean(value) => json!(value), + SearchValue::KeyValues(kv) => { + let (key, value) = kv.iter().next().unwrap(); + + let cond = if !value.is_empty() { + if op == &SearchOperator::Equal { + json!({ + "term": { + format!("{}.{}.keyword", field.field_name(), key): value + } + }) + } else { + json!({ + "match": { + format!("{}.{}", field.field_name(), key): value + } + }) + } + } else { + json!({ + "exists": { "field": format!("{}.{}", field.field_name(), key) } + }) + }; + + conditions.push(cond); + continue; + } + }; + + let cond = match op { + SearchOperator::Equal | SearchOperator::Contains => json!({ + "term": { field.field_name(): value } + }), + op => { + let op = match op { + SearchOperator::LowerThan => "lt", + SearchOperator::LowerEqualThan => "lte", + SearchOperator::GreaterThan => "gt", + SearchOperator::GreaterEqualThan => "gte", + _ => unreachable!(), + }; + + json!({ + "range": { field.field_name(): { op: value } } + }) + } + }; + + conditions.push(cond); + } + } + + SearchFilter::And | SearchFilter::Or | SearchFilter::Not => { + stack.push((logical_op, conditions)); + logical_op = filter; + conditions = Vec::new(); + } + SearchFilter::End => { + if let Some((prev_logical_op, mut prev_conditions)) = stack.pop() { + if !conditions.is_empty() { + match logical_op { + SearchFilter::And => { + prev_conditions.push(json!({ "bool": { "must": conditions } })); + } + SearchFilter::Or => { + prev_conditions.push(json!({ "bool": { "should": conditions } })); + } + SearchFilter::Not => { + prev_conditions.push(json!({ "bool": { "must_not": conditions } })); + } + _ => unreachable!(), + } + } + logical_op = prev_logical_op; + conditions = prev_conditions; + } + } + SearchFilter::DocumentSet(_) => { + debug_assert!( + false, + "DocumentSet filters are not supported in this backend" + ); + continue; + } + } + } + + debug_assert!( + !conditions.is_empty(), + "No conditions were built for the query" + ); + + if conditions.len() == 1 { + conditions.pop().unwrap() + } else { + json!({ "bool": { "must": conditions } }) + } +} + +fn build_sort(sort: &[SearchComparator], tie_breaker: &str) -> Value { + Value::Array( + sort.iter() + .filter_map(|comp| match comp { + SearchComparator::Field { field, ascending } => { + let field = if field.is_text() { + format!("{}.keyword", field.field_name()) + } else { + field.field_name().to_string() + }; + + Some(json!({ + field: if *ascending { "asc" } else { "desc" } + })) + } + _ => None, + }) + .chain([json!({ + tie_breaker: "asc" + })]) + .collect(), + ) +} + +fn json_serialize(request: &mut String, document: &IndexDocument) { + request.push('{'); + for (idx, (k, v)) in document.fields.iter().enumerate() { + if idx > 0 { + request.push(','); + } + + let _ = write!(request, "{:?}:", k.field_name()); + match v { + SearchValue::Text { value, .. } => { + json_serialize_str(request, value); + } + SearchValue::KeyValues(map) => { + request.push('{'); + for (i, (key, value)) in map.iter().enumerate() { + if i > 0 { + request.push(','); + } + json_serialize_str(request, key); + request.push(':'); + json_serialize_str(request, value); + } + request.push('}'); + } + SearchValue::Int(v) => { + let _ = write!(request, "{}", v); + } + SearchValue::Uint(v) => { + let _ = write!(request, "{}", v); + } + SearchValue::Boolean(v) => { + let _ = write!(request, "{}", v); + } + } + } + request.push('}'); +} + +fn json_serialize_str(request: &mut String, value: &str) { + request.push('"'); + for c in value.chars() { + match c { + '"' => request.push_str("\\\""), + '\\' => request.push_str("\\\\"), + '\n' => request.push_str("\\n"), + '\r' => request.push_str("\\r"), + '\t' => request.push_str("\\t"), + '\u{0008}' => request.push_str("\\b"), // backspace + '\u{000C}' => request.push_str("\\f"), // form feed + _ => { + if !c.is_control() { + request.push(c); + } else { + let _ = write!(request, "\\u{:04x}", c as u32); + } + } + } + } + request.push('"'); +} diff --git a/crates/store/src/backend/ephemeral/blob.rs b/crates/store/src/backend/ephemeral/blob.rs new file mode 100644 index 0000000..3ea62af --- /dev/null +++ b/crates/store/src/backend/ephemeral/blob.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::EphemeralStore; +use crate::SUBSPACE_BLOBS; +use std::ops::Range; + +impl EphemeralStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let state = self.state.read(); + Ok(state + .subspaces + .get(&SUBSPACE_BLOBS) + .and_then(|m| m.get(key)) + .map(|bytes| { + if range.start == 0 && range.end == usize::MAX { + bytes.clone() + } else { + bytes + .get(range.start..std::cmp::min(bytes.len(), range.end)) + .unwrap_or_default() + .to_vec() + } + })) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let mut state = self.state.write(); + state + .subspaces + .entry(SUBSPACE_BLOBS) + .or_default() + .insert(key.to_vec(), data.to_vec()); + Ok(()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let mut state = self.state.write(); + if let Some(map) = state.subspaces.get_mut(&SUBSPACE_BLOBS) { + map.remove(key); + } + Ok(true) + } +} diff --git a/crates/store/src/backend/ephemeral/main.rs b/crates/store/src/backend/ephemeral/main.rs new file mode 100644 index 0000000..665a5cd --- /dev/null +++ b/crates/store/src/backend/ephemeral/main.rs @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{EphemeralState, EphemeralStore}; +use crate::Store; +use ahash::AHashMap; +use parking_lot::RwLock; +use std::sync::Arc; + +impl EphemeralStore { + pub fn open() -> Store { + Store::Ephemeral(Arc::new(EphemeralStore { + state: RwLock::new(EphemeralState { + subspaces: AHashMap::new(), + }), + })) + } +} diff --git a/crates/store/src/backend/ephemeral/mod.rs b/crates/store/src/backend/ephemeral/mod.rs new file mode 100644 index 0000000..f5945be --- /dev/null +++ b/crates/store/src/backend/ephemeral/mod.rs @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod blob; +pub mod main; +pub mod read; +pub mod write; + +use ahash::AHashMap; +use parking_lot::RwLock; +use std::collections::BTreeMap; + +pub struct EphemeralStore { + pub(crate) state: RwLock, +} + +pub(crate) struct EphemeralState { + pub(crate) subspaces: AHashMap, Vec>>, +} diff --git a/crates/store/src/backend/ephemeral/read.rs b/crates/store/src/backend/ephemeral/read.rs new file mode 100644 index 0000000..41036ea --- /dev/null +++ b/crates/store/src/backend/ephemeral/read.rs @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::EphemeralStore; +use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass}; + +impl EphemeralStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + let subspace = key.subspace(); + let key_bytes = key.serialize(0); + let state = self.state.read(); + match state + .subspaces + .get(&subspace) + .and_then(|m| m.get(&key_bytes)) + { + Some(value) => U::deserialize_with_key(&key_bytes, value).map(Some), + None => Ok(None), + } + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let subspace = key.subspace(); + let key_bytes = key.serialize(0); + let state = self.state.read(); + Ok(state + .subspaces + .get(&subspace) + .is_some_and(|m| m.contains_key(&key_bytes))) + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let subspace = params.begin.subspace(); + let begin = params.begin.serialize(0); + let end = params.end.serialize(0); + let state = self.state.read(); + let Some(map) = state.subspaces.get(&subspace) else { + return Ok(()); + }; + + if params.ascending { + for (k, v) in map.range(begin..=end) { + if !cb(k.as_slice(), v.as_slice())? || params.first { + break; + } + } + } else { + for (k, v) in map.range(begin..=end).rev() { + if !cb(k.as_slice(), v.as_slice())? || params.first { + break; + } + } + } + Ok(()) + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into(); + let subspace = key.subspace(); + let key_bytes = key.serialize(0); + let state = self.state.read(); + match state + .subspaces + .get(&subspace) + .and_then(|m| m.get(&key_bytes)) + { + Some(bytes) => Ok(i64::from_le_bytes(bytes[..].try_into().map_err(|_| { + trc::Error::corrupted_key(&key_bytes, Some(bytes.as_slice()), trc::location!()) + })?)), + None => Ok(0), + } + } +} diff --git a/crates/store/src/backend/ephemeral/write.rs b/crates/store/src/backend/ephemeral/write.rs new file mode 100644 index 0000000..23b8740 --- /dev/null +++ b/crates/store/src/backend/ephemeral/write.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::EphemeralStore; +use crate::{ + IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_INDEXES, + SUBSPACE_LOGS, SUBSPACE_QUOTA, + backend::deserialize_i64_le, + write::{AssignedIds, Batch, MergeResult, Operation, ValueClass, ValueOp}, +}; + +impl EphemeralStore { + pub(crate) async fn write(&self, batch: Batch<'_>) -> trc::Result { + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let mut result = AssignedIds::default(); + let has_changes = !batch.changes.is_empty(); + + let mut state = self.state.write(); + + if has_changes { + let map = state.subspaces.entry(SUBSPACE_COUNTER).or_default(); + for &account_id in batch.changes.keys() { + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, 0); + let next = match map.get(&key) { + Some(bytes) => deserialize_i64_le(&key, bytes)? + 1, + None => 1, + }; + map.insert(key, next.to_le_bytes().to_vec()); + result.push_change_id(account_id, next as u64); + } + } + + for op in batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let subspace = class.subspace(collection); + let key = class.serialize(account_id, collection, document_id, 0); + let map = state.subspaces.entry(subspace).or_default(); + + match op { + ValueOp::Set(value) => { + map.insert(key, std::mem::take(value)); + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + map.insert(key, value); + } + ValueOp::MergeFnc(merge_op) => { + let merge_result = (merge_op.fnc)( + &merge_op.params, + &result, + map.get(&key).map(|v| v.as_slice()), + )?; + + match merge_result { + MergeResult::Update(value) => { + map.insert(key, value); + } + MergeResult::Delete => { + map.remove(&key); + } + MergeResult::Skip => (), + } + } + ValueOp::AtomicAdd(by) => { + let current = match map.get(&key) { + Some(bytes) => deserialize_i64_le(&key, bytes)?, + None => 0, + }; + let next = current + *by; + map.insert(key, next.to_le_bytes().to_vec()); + } + ValueOp::AddAndGet(by) => { + let current = match map.get(&key) { + Some(bytes) => deserialize_i64_le(&key, bytes)?, + None => 0, + }; + let next = current + *by; + map.insert(key, next.to_le_bytes().to_vec()); + result.push_counter_id(next); + } + ValueOp::Clear => { + map.remove(&key); + } + } + } + Operation::Index { field, key, set } => { + let index_key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: key.as_slice(), + } + .serialize(0); + let map = state.subspaces.entry(SUBSPACE_INDEXES).or_default(); + if *set { + map.insert(index_key, Vec::new()); + } else { + map.remove(&index_key); + } + } + Operation::Log { collection, set } => { + let log_key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(0); + let map = state.subspaces.entry(SUBSPACE_LOGS).or_default(); + map.insert(log_key, std::mem::take(set)); + } + Operation::AssertValue { + class, + assert_value, + } => { + let subspace = class.subspace(collection); + let key = class.serialize(account_id, collection, document_id, 0); + let matches = state + .subspaces + .get(&subspace) + .and_then(|m| m.get(&key)) + .map(|v| assert_value.matches(v.as_slice())) + .unwrap_or_else(|| assert_value.is_none()); + + if !matches { + return Err(trc::StoreEvent::AssertValueFailed.into()); + } + } + } + } + + Ok(result) + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let subspace = from.subspace(); + let from_key = from.serialize(0); + let to_key = to.serialize(0); + let mut state = self.state.write(); + if let Some(map) = state.subspaces.get_mut(&subspace) { + let keys: Vec> = map + .range(from_key..to_key) + .map(|(k, _)| k.clone()) + .collect(); + for k in keys { + map.remove(&k); + } + } + Ok(()) + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + let mut state = self.state.write(); + for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { + if let Some(map) = state.subspaces.get_mut(&subspace) { + let keys: Vec> = map + .iter() + .filter_map(|(k, v)| { + if v.len() == std::mem::size_of::() + && i64::from_le_bytes(v[..].try_into().unwrap()) == 0 + { + Some(k.clone()) + } else { + None + } + }) + .collect(); + for k in keys { + map.remove(&k); + } + } + } + Ok(()) + } +} diff --git a/crates/store/src/backend/foundationdb/blob.rs b/crates/store/src/backend/foundationdb/blob.rs new file mode 100644 index 0000000..459d24e --- /dev/null +++ b/crates/store/src/backend/foundationdb/blob.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{FdbStore, MAX_VALUE_SIZE}; +use crate::{ + IterateParams, SUBSPACE_BLOBS, + backend::foundationdb::into_error, + write::{AnyKey, key::KeySerializer}, +}; +use std::ops::Range; +use trc::AddContext; +use types::blob_hash::BLOB_HASH_LEN; + +impl FdbStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let block_start = range.start / MAX_VALUE_SIZE; + let bytes_start = range.start % MAX_VALUE_SIZE; + let block_end = (range.end / MAX_VALUE_SIZE) + 1; + + let begin = KeySerializer::new(key.len() + 2) + .write(key) + .write(block_start as u16) + .finalize(); + let end = KeySerializer::new(key.len() + 2) + .write(key) + .write(block_end as u16) + .finalize(); + let key_len = begin.len(); + + let mut blob_data: Option> = None; + let blob_range = range.end - range.start; + + self.iterate( + IterateParams::new( + AnyKey { + subspace: SUBSPACE_BLOBS, + key: begin, + }, + AnyKey { + subspace: SUBSPACE_BLOBS, + key: end, + }, + ), + |key, value| { + if key.len() == key_len { + if let Some(blob_data) = &mut blob_data { + blob_data.extend_from_slice( + value + .get( + ..std::cmp::min( + blob_range.saturating_sub(blob_data.len()), + value.len(), + ), + ) + .unwrap_or(&[]), + ); + if blob_data.len() == blob_range { + return Ok(false); + } + } else { + let blob_size = if blob_range <= (5 * (1 << 20)) { + blob_range + } else if value.len() == MAX_VALUE_SIZE { + MAX_VALUE_SIZE * 2 + } else { + value.len() + }; + let mut blob_data_ = Vec::with_capacity(blob_size); + blob_data_.extend_from_slice( + value + .get( + bytes_start + ..std::cmp::min(bytes_start + blob_range, value.len()), + ) + .unwrap_or(&[]), + ); + let is_done = blob_data_.len() == blob_range; + blob_data = blob_data_.into(); + if is_done { + return Ok(false); + } + } + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(blob_data) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + const N_CHUNKS: usize = (1 << 5) - 1; + let last_chunk = std::cmp::max( + (data.len() / MAX_VALUE_SIZE) + + if !data.len().is_multiple_of(MAX_VALUE_SIZE) { + 1 + } else { + 0 + }, + 1, + ) - 1; + let mut trx = self.db.create_trx().map_err(into_error)?; + + for (chunk_pos, chunk_bytes) in data.chunks(MAX_VALUE_SIZE).enumerate() { + trx.set( + &KeySerializer::new(key.len() + 3) + .write(SUBSPACE_BLOBS) + .write(key) + .write(chunk_pos as u16) + .finalize(), + chunk_bytes, + ); + if chunk_pos == last_chunk || (chunk_pos > 0 && chunk_pos % N_CHUNKS == 0) { + self.commit(trx, false).await?; + if chunk_pos < last_chunk { + trx = self.db.create_trx().map_err(into_error)?; + } else { + break; + } + } + } + + Ok(()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + if key.len() < BLOB_HASH_LEN { + return Ok(false); + } + + let trx = self.db.create_trx().map_err(into_error)?; + trx.clear_range( + &KeySerializer::new(key.len() + 3) + .write(SUBSPACE_BLOBS) + .write(key) + .write(0u16) + .finalize(), + &KeySerializer::new(key.len() + 3) + .write(SUBSPACE_BLOBS) + .write(key) + .write(u16::MAX) + .finalize(), + ); + + self.commit(trx, false).await + } +} diff --git a/crates/store/src/backend/foundationdb/main.rs b/crates/store/src/backend/foundationdb/main.rs new file mode 100644 index 0000000..1a044fe --- /dev/null +++ b/crates/store/src/backend/foundationdb/main.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::FdbStore; +use crate::Store; +use foundationdb::{Database, api, api::NetworkAutoStop, options::DatabaseOption}; +use parking_lot::Mutex; +use registry::schema::structs; +use std::sync::Arc; + +static FDB_NETWORK: Mutex> = Mutex::new(None); + +impl FdbStore { + pub async fn open(config: structs::FoundationDbStore) -> Result { + { + let mut guard = FDB_NETWORK.lock(); + if guard.is_none() { + let network = unsafe { + api::FdbApiBuilder::default() + .build() + .map_err(|err| format!("Failed to boot FoundationDB: {err:?}"))? + .boot() + .map_err(|err| format!("Failed to boot FoundationDB: {err:?}"))? + }; + *guard = Some(network); + } + } + + let db = Database::new(config.cluster_file.as_deref()) + .map_err(|err| format!("Failed to create FoundationDB database: {err:?}"))?; + + if let Some(value) = config.transaction_timeout { + db.set_option(DatabaseOption::TransactionTimeout( + value.into_inner().as_millis() as i32, + )) + .map_err(|err| format!("Failed to set option: {err:?}"))?; + } + if let Some(value) = config.transaction_retry_limit { + db.set_option(DatabaseOption::TransactionRetryLimit(value as i32)) + .map_err(|err| format!("Failed to set option: {err:?}"))?; + } + if let Some(value) = config.transaction_retry_delay { + db.set_option(DatabaseOption::TransactionMaxRetryDelay( + value.into_inner().as_millis() as i32, + )) + .map_err(|err| format!("Failed to set option: {err:?}"))?; + } + if let Some(value) = config.machine_id { + db.set_option(DatabaseOption::MachineId(value)) + .map_err(|err| format!("Failed to set option: {err:?}"))?; + } + if let Some(value) = config.datacenter_id { + db.set_option(DatabaseOption::DatacenterId(value)) + .map_err(|err| format!("Failed to set option: {err:?}"))?; + } + + Ok(Store::FoundationDb(Arc::new(Self { + db, + version: Default::default(), + }))) + } +} diff --git a/crates/store/src/backend/foundationdb/mod.rs b/crates/store/src/backend/foundationdb/mod.rs new file mode 100644 index 0000000..09035df --- /dev/null +++ b/crates/store/src/backend/foundationdb/mod.rs @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use foundationdb::{Database, FdbError}; +use std::{ + sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}, + time::{Duration, Instant}, +}; + +pub mod blob; +pub mod main; +pub mod read; +pub mod write; + +const MAX_VALUE_SIZE: usize = 100000; + +const REFRESH_READ_VERSION_AFTER: Duration = Duration::from_secs(1); +const MAX_READ_VERSION_AGE: Duration = Duration::from_secs(4); + +pub struct FdbStore { + db: Database, + version: ReadVersion, +} + +pub(crate) struct ReadVersion { + base: Instant, + version: AtomicI64, + obtained: AtomicU64, + refreshing: AtomicBool, +} + +impl ReadVersion { + fn now(&self) -> u64 { + self.base.elapsed().as_nanos() as u64 + } + + fn current(&self) -> i64 { + self.version.load(Ordering::Acquire) + } + + fn age(&self) -> u64 { + self.now() + .saturating_sub(self.obtained.load(Ordering::Acquire)) + } + + fn store_max(&self, version: i64) { + let mut current = self.version.load(Ordering::Relaxed); + while version > current { + match self.version.compare_exchange_weak( + current, + version, + Ordering::Release, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(actual) => current = actual, + } + } + } + + fn refreshed(&self, version: i64) { + self.store_max(version); + self.obtained.store(self.now(), Ordering::Release); + } + + fn raise_floor(&self, version: i64) { + self.store_max(version); + } + + fn expire(&self) { + self.obtained.store(0, Ordering::Release); + } + + fn try_begin_refresh(&self) -> Option> { + if self + .refreshing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + Some(RefreshGuard(&self.refreshing)) + } else { + None + } + } +} + +impl Default for ReadVersion { + fn default() -> Self { + Self { + base: Instant::now(), + version: AtomicI64::new(0), + obtained: AtomicU64::new(0), + refreshing: AtomicBool::new(false), + } + } +} + +pub(crate) struct RefreshGuard<'a>(&'a AtomicBool); + +impl Drop for RefreshGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[inline(always)] +fn into_error(error: FdbError) -> trc::Error { + trc::StoreEvent::FoundationdbError + .reason(error.message()) + .ctx(trc::Key::Code, error.code()) +} diff --git a/crates/store/src/backend/foundationdb/read.rs b/crates/store/src/backend/foundationdb/read.rs new file mode 100644 index 0000000..7694fe7 --- /dev/null +++ b/crates/store/src/backend/foundationdb/read.rs @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + FdbStore, MAX_READ_VERSION_AGE, MAX_VALUE_SIZE, REFRESH_READ_VERSION_AFTER, into_error, +}; +use crate::{ + Deserialize, IterateParams, Key, ValueKey, WITH_SUBSPACE, + backend::deserialize_i64_le, + write::{MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME, ValueClass, key::KeySerializer}, +}; +use foundationdb::{ + FdbError, KeySelector, RangeOption, Transaction, + future::FdbSlice, + options::{self}, +}; +use futures::TryStreamExt; +use std::time::Instant; + +#[allow(dead_code)] +pub(crate) enum ChunkedValue { + Single(FdbSlice), + Chunked { n_chunks: u8, bytes: Vec }, + None, +} + +struct ChunkedValueCollector { + key: Vec, + bytes: Vec, +} + +impl FdbStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize, + { + let key = key.serialize(WITH_SUBSPACE); + let mut retry_count = 0; + let start = Instant::now(); + + loop { + let trx = self.read_trx().await?; + + match read_chunked_value(&key, &trx, true).await { + Ok(ChunkedValue::Single(bytes)) => { + return U::deserialize_with_key(key.get(1..).unwrap_or_default(), &bytes) + .map(Some); + } + Ok(ChunkedValue::Chunked { bytes, .. }) => { + return U::deserialize_owned_with_key(key.get(1..).unwrap_or_default(), bytes) + .map(Some); + } + Ok(ChunkedValue::None) => return Ok(None), + Err(err) => { + self.on_read_error(trx, err, &mut retry_count, start) + .await?; + } + } + } + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let key = key.serialize(WITH_SUBSPACE); + let mut retry_count = 0; + let start = Instant::now(); + + loop { + let trx = self.read_trx().await?; + + match read_chunked_value(&key, &trx, true).await { + Ok(ChunkedValue::Single(_) | ChunkedValue::Chunked { .. }) => return Ok(true), + Ok(ChunkedValue::None) => return Ok(false), + Err(err) => { + self.on_read_error(trx, err, &mut retry_count, start) + .await?; + } + } + } + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let begin = params.begin.serialize(WITH_SUBSPACE); + let end = params.end.serialize(WITH_SUBSPACE); + let mut retry_count = 0; + let start = Instant::now(); + + if !params.first { + let mut last_key = vec![]; + let mut chunked_key: Option = None; + + 'outer: loop { + let begin_selector = if last_key.is_empty() { + KeySelector::first_greater_or_equal(&begin) + } else { + KeySelector::first_greater_than(&last_key) + }; + + let trx = self.read_trx().await?; + let mut values = trx.get_ranges( + RangeOption { + begin: begin_selector, + end: KeySelector::first_greater_than(&end), + mode: options::StreamingMode::WantAll, + reverse: !params.ascending, + ..Default::default() + }, + true, + ); + + let mut last_key_ = vec![]; + loop { + match values.try_next().await { + Ok(Some(values)) => { + let mut key = &[] as &[u8]; + for value in values.iter() { + key = value.key(); + + // Check whether we are collecting a chunked value + let cb_key = key.get(1..).unwrap_or_default(); + let cb_value = value.value(); + + if let Some(chunk) = &mut chunked_key { + if chunk.key.len() + 1 == cb_key.len() + && cb_key[..chunk.key.len()] == chunk.key[..] + { + // This is a chunk of the current value + if params.values { + chunk.bytes.extend_from_slice(cb_value); + } + continue; + } else { + // Return collected chunked value + if !cb(&chunk.key, &chunk.bytes)? { + return Ok(()); + } + + // Reset collector + chunked_key = None; + } + } + + if cb_value.len() < MAX_VALUE_SIZE { + if !cb(cb_key, cb_value)? { + return Ok(()); + } + } else { + // Start collecting chunked value + chunked_key = Some(ChunkedValueCollector { + key: cb_key.to_vec(), + bytes: if params.values { + cb_value.to_vec() + } else { + Vec::new() + }, + }); + } + } + if values.more() { + last_key_ = key.to_vec(); + } + } + Ok(None) => { + // Return any chunked value collected + if let Some(chunked_key) = chunked_key.take() { + cb(&chunked_key.key, &chunked_key.bytes)?; + } + + break 'outer; + } + Err(e) => { + drop(values); + if e.code() == 1007 && !last_key_.is_empty() { + // Transaction is too old to perform reads or be committed + last_key = last_key_; + continue 'outer; + } else if e.is_retryable() + && retry_count < MAX_COMMIT_ATTEMPTS + && start.elapsed() < MAX_COMMIT_TIME + { + // Transient error such as a cached read version ahead of lagging + // storage servers (code 1009); resume from the last key read, + // refresh the read version and back off before retrying. + if !last_key_.is_empty() { + last_key = last_key_; + } + self.version.expire(); + trx.on_error(e).await.map_err(into_error)?; + retry_count += 1; + continue 'outer; + } else { + return Err(into_error(e)); + } + } + } + } + } + } else { + loop { + let trx = self.read_trx().await?; + let mut values = trx.get_ranges_keyvalues( + RangeOption { + begin: KeySelector::first_greater_or_equal(&begin), + end: KeySelector::first_greater_than(&end), + mode: options::StreamingMode::Small, + reverse: !params.ascending, + ..Default::default() + }, + true, + ); + + match values.try_next().await { + Ok(Some(value)) => { + cb(value.key().get(1..).unwrap_or_default(), value.value())?; + break; + } + Ok(None) => break, + Err(e) => { + drop(values); + self.on_read_error(trx, e, &mut retry_count, start).await?; + } + } + } + } + + Ok(()) + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into().serialize(WITH_SUBSPACE); + let mut retry_count = 0; + let start = Instant::now(); + + loop { + let trx = self.read_trx().await?; + match trx.get(&key, true).await { + Ok(Some(bytes)) => return deserialize_i64_le(&key, &bytes), + Ok(None) => return Ok(0), + Err(e) => { + self.on_read_error(trx, e, &mut retry_count, start).await?; + } + } + } + } + + async fn on_read_error( + &self, + trx: Transaction, + err: FdbError, + retry_count: &mut u32, + start: Instant, + ) -> trc::Result<()> { + if err.is_retryable() + && *retry_count < MAX_COMMIT_ATTEMPTS + && start.elapsed() < MAX_COMMIT_TIME + { + // The cached read version may be ahead of lagging storage servers under heavy write + // load (code 1009); expire it so the retry obtains a fresh read version, then let + // FoundationDB back off before retrying. + self.version.expire(); + trx.on_error(err).await.map_err(into_error)?; + *retry_count += 1; + Ok(()) + } else { + Err(into_error(err)) + } + } + + pub(crate) async fn read_trx(&self) -> trc::Result { + let trx = self.db.create_trx().map_err(into_error)?; + let version = self.version.current(); + let age = self.version.age(); + + if version != 0 && age < MAX_READ_VERSION_AGE.as_nanos() as u64 { + if age >= REFRESH_READ_VERSION_AFTER.as_nanos() as u64 + && let Some(_guard) = self.version.try_begin_refresh() + { + let read_version = trx.get_read_version().await.map_err(into_error)?; + self.version.refreshed(read_version); + } else { + trx.set_read_version(version); + } + } else { + let read_version = trx.get_read_version().await.map_err(into_error)?; + self.version.refreshed(read_version); + } + + Ok(trx) + } + + pub(crate) fn invalidate_read_snapshot(&self) { + self.version.expire(); + } +} + +pub(crate) async fn read_chunked_value( + key: &[u8], + trx: &Transaction, + snapshot: bool, +) -> Result { + if let Some(bytes) = trx.get(key, snapshot).await? { + if bytes.len() < MAX_VALUE_SIZE { + Ok(ChunkedValue::Single(bytes)) + } else { + let mut value = Vec::with_capacity(bytes.len() * 2); + value.extend_from_slice(&bytes); + let mut key = KeySerializer::new(key.len() + 1) + .write(key) + .write(0u8) + .finalize(); + + while let Some(bytes) = trx.get(&key, snapshot).await? { + value.extend_from_slice(&bytes); + *key.last_mut().unwrap() += 1; + } + + Ok(ChunkedValue::Chunked { + bytes: value, + n_chunks: *key.last().unwrap(), + }) + } + } else { + Ok(ChunkedValue::None) + } +} diff --git a/crates/store/src/backend/foundationdb/write.rs b/crates/store/src/backend/foundationdb/write.rs new file mode 100644 index 0000000..06c7907 --- /dev/null +++ b/crates/store/src/backend/foundationdb/write.rs @@ -0,0 +1,436 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + FdbStore, MAX_VALUE_SIZE, into_error, + read::{ChunkedValue, read_chunked_value}, +}; +use crate::{ + backend::deserialize_i64_le, + write::{ + AssignedIds, Batch, IndexPropertyClass, MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME, MergeResult, + Operation, QueueClass, RegistryClass, SearchIndexType, TaskQueueClass, TelemetryClass, + ValueClass, ValueOp, key::KeySerializer, + }, + *, +}; +use foundationdb::{ + FdbError, KeySelector, RangeOption, Transaction, + options::{self, MutationType}, +}; +use futures::TryStreamExt; +use rand::RngExt; +use std::{ + borrow::Cow, + cmp::Ordering, + time::{Duration, Instant}, +}; +use trc::AddContext; + +impl FdbStore { + pub(crate) async fn write(&self, batch: Batch<'_>) -> trc::Result { + let start = Instant::now(); + let mut retry_count = 0; + let has_changes = !batch.changes.is_empty(); + + loop { + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let mut result = AssignedIds::default(); + + let trx = self.db.create_trx().map_err(into_error)?; + + if has_changes { + for &account_id in batch.changes.keys() { + debug_assert!(account_id != u32::MAX); + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, WITH_SUBSPACE); + let change_id = + if let Some(bytes) = trx.get(&key, false).await.map_err(into_error)? { + deserialize_i64_le(&key, &bytes)? + 1 + } else { + 1 + }; + trx.set(&key, &change_id.to_le_bytes()[..]); + result.push_change_id(account_id, change_id as u64); + } + } + + for op in batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let mut key = + class.serialize(account_id, collection, document_id, WITH_SUBSPACE); + + match op { + ValueOp::Set(value) => { + if !chunk_value(&trx, &mut key, value, class, None).await { + trx.cancel(); + return Err(trc::StoreEvent::FoundationdbError + .ctx(trc::Key::Reason, "Value is too large")); + } + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + if !chunk_value(&trx, &mut key, &value, class, None).await { + trx.cancel(); + return Err(trc::StoreEvent::FoundationdbError + .ctx(trc::Key::Reason, "Value is too large")); + } + } + ValueOp::MergeFnc(merge_op) => { + let (merge_result, prev_num_chunks) = + match read_chunked_value(&key, &trx, false) + .await + .map_err(into_error) + .caused_by(trc::location!())? + { + ChunkedValue::Single(slice) => ( + (merge_op.fnc)( + &merge_op.params, + &result, + Some(slice.as_ref()), + )?, + 1, + ), + ChunkedValue::Chunked { bytes, n_chunks } => ( + (merge_op.fnc)( + &merge_op.params, + &result, + Some(bytes.as_ref()), + )?, + n_chunks as usize + 1, + ), + ChunkedValue::None => { + ((merge_op.fnc)(&merge_op.params, &result, None)?, 0) + } + }; + + match merge_result { + MergeResult::Update(value) => { + if !chunk_value( + &trx, + &mut key, + &value, + class, + Some(prev_num_chunks), + ) + .await + { + trx.cancel(); + return Err(trc::StoreEvent::FoundationdbError + .ctx(trc::Key::Reason, "Value is too large")); + } + } + MergeResult::Delete => { + if prev_num_chunks > 1 { + clear_chunks(&trx, &key, None).await; + } else { + trx.clear(&key); + } + } + MergeResult::Skip => (), + } + } + ValueOp::AtomicAdd(by) => { + trx.atomic_op(&key, &by.to_le_bytes()[..], MutationType::Add); + } + ValueOp::AddAndGet(by) => { + let num = if let Some(bytes) = + trx.get(&key, false).await.map_err(into_error)? + { + deserialize_i64_le(&key, &bytes)? + *by + } else { + *by + }; + trx.set(&key, &num.to_le_bytes()[..]); + result.push_counter_id(num); + } + ValueOp::Clear => { + if is_chunked_value(key[0], class) { + clear_chunks(&trx, &key, None).await; + } else { + trx.clear(&key); + } + } + } + } + Operation::Index { field, key, set } => { + let key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: &*key, + } + .serialize(WITH_SUBSPACE); + + if *set { + trx.set(&key, &[]); + } else { + trx.clear(&key); + } + } + Operation::Log { collection, set } => { + let key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(WITH_SUBSPACE); + + trx.set(&key, set); + } + Operation::AssertValue { + class, + assert_value, + } => { + let key = + class.serialize(account_id, collection, document_id, WITH_SUBSPACE); + + let matches = match read_chunked_value(&key, &trx, false).await { + Ok(ChunkedValue::Single(bytes)) => assert_value.matches(bytes.as_ref()), + Ok(ChunkedValue::Chunked { bytes, .. }) => { + assert_value.matches(bytes.as_ref()) + } + Ok(ChunkedValue::None) => assert_value.is_none(), + Err(_) => false, + }; + + if !matches { + trx.cancel(); + return Err(trc::StoreEvent::AssertValueFailed.into()); + } + } + } + } + + if self + .commit( + trx, + retry_count < MAX_COMMIT_ATTEMPTS && start.elapsed() < MAX_COMMIT_TIME, + ) + .await? + { + return Ok(result); + } else { + let backoff = rand::rng().random_range(50..=100); + tokio::time::sleep(Duration::from_millis(backoff)).await; + retry_count += 1; + } + } + } + + pub(crate) async fn commit(&self, trx: Transaction, will_retry: bool) -> trc::Result { + match trx.commit().await { + Ok(result) => { + let commit_version = result.committed_version().map_err(into_error)?; + self.version.raise_floor(commit_version); + Ok(true) + } + Err(err) => { + if will_retry { + err.on_error().await.map_err(into_error)?; + Ok(false) + } else { + Err(into_error(FdbError::from(err))) + } + } + } + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + // Obtain all zero counters + let mut delete_keys = Vec::new(); + for subspace in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] { + let trx = self.db.create_trx().map_err(into_error)?; + let from_key = [subspace, 0u8]; + let to_key = [subspace, u8::MAX, u8::MAX, u8::MAX, u8::MAX, u8::MAX]; + + let mut values = trx.get_ranges_keyvalues( + RangeOption { + begin: KeySelector::first_greater_or_equal(&from_key[..]), + end: KeySelector::first_greater_or_equal(&to_key[..]), + mode: options::StreamingMode::WantAll, + reverse: false, + ..Default::default() + }, + true, + ); + + while let Some(value) = values.try_next().await.map_err(into_error)? { + if value.value().iter().all(|byte| *byte == 0) { + delete_keys.push(value.key().to_vec()); + } + } + } + + if delete_keys.is_empty() { + return Ok(()); + } + + // Delete keys + let integer = 0i64.to_le_bytes(); + for chunk in delete_keys.chunks(1024) { + let mut retry_count = 0; + loop { + let trx = self.db.create_trx().map_err(into_error)?; + for key in chunk { + trx.atomic_op(key, &integer, MutationType::CompareAndClear); + } + + if self.commit(trx, retry_count < MAX_COMMIT_ATTEMPTS).await? { + break; + } else { + retry_count += 1; + } + } + } + + Ok(()) + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let from = from.serialize(WITH_SUBSPACE); + let to = to.serialize(WITH_SUBSPACE); + + let trx = self.db.create_trx().map_err(into_error)?; + trx.clear_range(&from, &to); + self.commit(trx, false).await.map(|_| ()) + } +} + +fn is_chunked_subspace(subspace: u8) -> bool { + matches!( + subspace, + crate::SUBSPACE_PROPERTY + | crate::SUBSPACE_SEARCH_INDEX + | crate::SUBSPACE_QUEUE_MESSAGE + | crate::SUBSPACE_TASK_QUEUE + | crate::SUBSPACE_DIRECTORY + | crate::SUBSPACE_REGISTRY + | crate::SUBSPACE_DELETED_ITEMS + | crate::SUBSPACE_SPAM_SAMPLES + | crate::SUBSPACE_REPORT_IN + | crate::SUBSPACE_REPORT_OUT + | crate::SUBSPACE_TELEMETRY_SPAN + ) +} + +fn is_chunked_value(subspace: u8, class: &ValueClass) -> bool { + is_chunked_subspace(subspace) + && match class { + ValueClass::Property(_) + | ValueClass::IndexProperty(IndexPropertyClass::Hash { .. }) + | ValueClass::Registry(RegistryClass::Item { .. }) + | ValueClass::Queue(QueueClass::Message(_)) + | ValueClass::TaskQueue(TaskQueueClass::Task { .. }) + | ValueClass::Telemetry(TelemetryClass::Span(_)) => true, + ValueClass::SearchIndex(index) => matches!(index.typ, SearchIndexType::Document), + _ => false, + } +} + +async fn clear_chunks(trx: &Transaction, key: &[u8], from_chunk: Option) { + let to = KeySerializer::new(key.len() + 1) + .write(key) + .write(u8::MAX) + .finalize(); + let from = match from_chunk { + Some(from_chunk) => Cow::Owned( + KeySerializer::new(key.len() + 1) + .write(key) + .write(from_chunk) + .finalize(), + ), + None => Cow::Borrowed(key), + }; + + #[cfg(debug_assertions)] + { + let mut chunks = trx.get_ranges_keyvalues( + RangeOption { + begin: KeySelector::first_greater_or_equal(from.as_ref()), + end: KeySelector::first_greater_or_equal(to.as_slice()), + mode: options::StreamingMode::WantAll, + ..Default::default() + }, + true, + ); + + while let Ok(Some(chunk)) = chunks.try_next().await { + let found = chunk.key(); + debug_assert!( + found.len() == key.len() + 1 || found == key, + "chunk range of {key:?} holds foreign key {found:?}, clearing it would destroy data" + ); + } + } + + trx.clear_range(from.as_ref(), &to); +} + +async fn chunk_value( + trx: &Transaction, + key: &mut Vec, + value: &[u8], + class: &ValueClass, + prev_num_chunks: Option, +) -> bool { + let num_chunks = if value.len() > MAX_VALUE_SIZE { + value.len().div_ceil(MAX_VALUE_SIZE) + } else { + 1 + }; + + if num_chunks > u8::MAX as usize { + return false; + } + + if is_chunked_value(key[0], class) + && prev_num_chunks.is_none_or(|prev_num_chunks| prev_num_chunks > num_chunks) + { + clear_chunks(trx, key, Some((num_chunks - 1) as u8)).await; + } + + if value.len() > MAX_VALUE_SIZE { + for (pos, chunk) in value.chunks(MAX_VALUE_SIZE).enumerate() { + match pos.cmp(&1) { + Ordering::Less => {} + Ordering::Equal => { + key.push(0); + } + Ordering::Greater => { + *key.last_mut().unwrap() += 1; + } + } + trx.set(key, chunk); + } + } else { + trx.set(key, value); + } + + true +} diff --git a/crates/store/src/backend/fs/mod.rs b/crates/store/src/backend/fs/mod.rs new file mode 100644 index 0000000..d9a4b5d --- /dev/null +++ b/crates/store/src/backend/fs/mod.rs @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::BlobStore; +use registry::schema::structs; +use std::{io::SeekFrom, ops::Range, path::PathBuf, sync::Arc}; +use tokio::{ + fs::{self, File}, + io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, +}; +use utils::codec::base32_custom::Base32Writer; + +pub struct FsStore { + path: PathBuf, + hash_levels: usize, +} + +impl FsStore { + pub async fn open(config: structs::FileSystemStore) -> Result { + let path = PathBuf::from(&config.path); + if !path.exists() { + fs::create_dir_all(&path) + .await + .map_err(|e| format!("Failed to create directory: {e}"))?; + } + + Ok(BlobStore::Fs(Arc::new(FsStore { + path, + hash_levels: std::cmp::min(config.depth as usize, 5), + }))) + } + + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let blob_path = self.build_path(key); + let blob_size = match fs::metadata(&blob_path).await { + Ok(m) => m.len() as usize, + Err(_) => return Ok(None), + }; + let mut blob = File::open(&blob_path).await.map_err(into_error)?; + + Ok(Some(if range.start != 0 || range.end != usize::MAX { + let from_offset = if range.start < blob_size { + range.start + } else { + 0 + }; + let mut buf = vec![0; (std::cmp::min(range.end, blob_size) - from_offset) as usize]; + + if from_offset > 0 { + blob.seek(SeekFrom::Start(from_offset as u64)) + .await + .map_err(into_error)?; + } + blob.read_exact(&mut buf).await.map_err(into_error)?; + buf + } else { + let mut buf = Vec::with_capacity(blob_size as usize); + blob.read_to_end(&mut buf).await.map_err(into_error)?; + buf + })) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let blob_path = self.build_path(key); + + if fs::metadata(&blob_path) + .await + .map_or(true, |m| m.len() as usize != data.len()) + { + fs::create_dir_all(blob_path.parent().unwrap()) + .await + .map_err(into_error)?; + let mut blob_file = File::create(&blob_path).await.map_err(into_error)?; + blob_file.write_all(data).await.map_err(into_error)?; + blob_file.flush().await.map_err(into_error)?; + } + + Ok(()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let blob_path = self.build_path(key); + if fs::metadata(&blob_path).await.is_ok() { + fs::remove_file(&blob_path).await.map_err(into_error)?; + Ok(true) + } else { + Ok(false) + } + } + + fn build_path(&self, key: &[u8]) -> PathBuf { + let mut path = self.path.clone(); + + for byte in key.iter().take(self.hash_levels) { + path.push(format!("{:x}", byte)); + } + path.push(Base32Writer::from_bytes(key).finalize()); + path + } +} + +fn into_error(err: std::io::Error) -> trc::Error { + trc::StoreEvent::FilesystemError.reason(err) +} diff --git a/crates/store/src/backend/http/config.rs b/crates/store/src/backend/http/config.rs new file mode 100644 index 0000000..8279d4a --- /dev/null +++ b/crates/store/src/backend/http/config.rs @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{HttpStore, HttpStoreConfig, HttpStoreFormat}; +use crate::{InMemoryStore, LookupStores, registry::bootstrap::Bootstrap}; +use ahash::AHashMap; +use arc_swap::ArcSwap; +use registry::schema::structs::{self, HttpLookupFormat}; +use std::{ + collections::hash_map::Entry, + sync::atomic::{AtomicBool, AtomicU64}, +}; + +impl LookupStores { + pub async fn parse_http(&mut self, bp: &mut Bootstrap) { + // Parse remote lists + for http in bp.list_infallible::().await { + let id = http.id; + let http = http.object; + if !http.enable { + continue; + } + + let http_config = HttpStoreConfig { + url: http.url, + retry: http.retry.as_secs(), + refresh: http.refresh.as_secs(), + timeout: http.timeout.into_inner(), + gzipped: http.is_gzipped, + max_size: http.max_size as usize, + max_entries: http.max_entries as usize, + max_entry_size: http.max_entry_size as usize, + format: match http.format { + HttpLookupFormat::List => HttpStoreFormat::List, + HttpLookupFormat::Csv(csv) => HttpStoreFormat::Csv { + index_key: csv.index_key as u32, + index_value: csv.index_value.map(|v| v as u32), + separator: csv.separator.chars().next().unwrap_or(','), + skip_first: csv.skip_first, + }, + }, + id: http.namespace, + }; + + match self.stores.entry(http_config.id.as_str().into()) { + Entry::Vacant(entry) => { + let store = HttpStore { + entries: ArcSwap::from_pointee(AHashMap::new()), + expires: AtomicU64::new(0), + in_flight: AtomicBool::new(false), + config: http_config, + client: utils::http::unpooled_http_client(false), + }; + + entry.insert(InMemoryStore::Http(store.into())); + } + Entry::Occupied(_) => { + bp.build_error( + id, + format!( + "An lookup store with the {} namespace already exists", + http_config.id + ), + ); + } + } + } + } +} diff --git a/crates/store/src/backend/http/lookup.rs b/crates/store/src/backend/http/lookup.rs new file mode 100644 index 0000000..9d9c4ec --- /dev/null +++ b/crates/store/src/backend/http/lookup.rs @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + io::{BufRead, BufReader}, + sync::{Arc, atomic::Ordering}, + time::Instant, +}; + +use ahash::AHashMap; +use compact_str::ToCompactString; +use rand::seq::IndexedRandom; +use utils::HttpLimitResponse; + +use crate::{Value, backend::http::HttpStoreFormat, write::now}; + +use super::HttpStore; + +const BROWSER_USER_AGENTS: [&str; 5] = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", +]; + +pub(crate) trait HttpStoreGet { + fn get(&self, key: &str) -> Option>; + fn contains(&self, key: &str) -> bool; + fn refresh(&self); +} + +impl HttpStoreGet for Arc { + fn get(&self, key: &str) -> Option> { + self.refresh(); + self.entries.load().get(key).cloned() + } + + fn contains(&self, key: &str) -> bool { + #[cfg(feature = "test_mode")] + { + if self.config.url.contains("phishtank.com") + || self.config.url.contains("openphish.com") + { + return (self.config.url.contains("open") && key.contains("open")) + || (self.config.url.contains("tank") && key.contains("tank")); + } else if self.config.url.contains("disposable.github.io") { + return key.ends_with("guerrillamail.com") || key.ends_with("disposable.org"); + } else if self.config.url.contains("free_email_provider_domains.txt") { + return key.ends_with("gmail.com") + || key.ends_with("googlemail.com") + || key.ends_with("yahoomail.com") + || key.ends_with("outlook.com") + || key.ends_with("freemail.org"); + } + } + + self.refresh(); + self.entries.load().contains_key(key) + } + + fn refresh(&self) { + if self.expires.load(Ordering::Relaxed) <= now() { + let in_flight = self.in_flight.swap(true, Ordering::Relaxed); + if !in_flight { + let this = self.clone(); + tokio::spawn(async move { + let expires = match this.try_refresh().await { + Ok(list) => { + this.entries.store(list.into()); + this.config.refresh + } + Err(err) => { + trc::error!(err); + this.config.retry + } + }; + + this.expires.store(now() + expires, Ordering::Relaxed); + this.in_flight.store(false, Ordering::Relaxed); + }); + } + } + } +} + +impl HttpStore { + async fn try_refresh(&self) -> trc::Result>> { + let time = Instant::now(); + let agent = BROWSER_USER_AGENTS.choose(&mut rand::rng()).unwrap(); + let response = self + .client + .get(&self.config.url) + .timeout(self.config.timeout) + .header(reqwest::header::USER_AGENT, *agent) + .send() + .await + .map_err(|err| { + trc::StoreEvent::HttpStoreError + .into_err() + .reason(err) + .ctx(trc::Key::Url, self.config.url.to_compact_string()) + .details("Failed to build request") + })?; + + if !response.status().is_success() { + trc::bail!( + trc::StoreEvent::HttpStoreError + .into_err() + .ctx(trc::Key::Code, response.status().as_u16()) + .ctx(trc::Key::Url, self.config.url.to_compact_string()) + .ctx(trc::Key::Elapsed, time.elapsed()) + .details("Failed to fetch HTTP list") + ); + } + + let bytes = response + .bytes_with_limit(self.config.max_size) + .await + .map_err(|err| { + trc::StoreEvent::HttpStoreError + .into_err() + .reason(err) + .ctx(trc::Key::Url, self.config.url.to_compact_string()) + .ctx(trc::Key::Elapsed, time.elapsed()) + .details("Failed to fetch resource") + })? + .ok_or_else(|| { + trc::StoreEvent::HttpStoreError + .into_err() + .ctx(trc::Key::Url, self.config.url.to_compact_string()) + .ctx(trc::Key::Elapsed, time.elapsed()) + .details("Resource is too large") + })?; + + let reader: Box = if self.config.gzipped { + Box::new(flate2::read::GzDecoder::new(&bytes[..])) + } else { + Box::new(&bytes[..]) + }; + + let mut entries = AHashMap::new(); + for (pos, line) in BufReader::new(reader).lines().enumerate() { + let line_ = line.map_err(|err| { + trc::StoreEvent::HttpStoreError + .into_err() + .reason(err) + .ctx(trc::Key::Url, self.config.url.to_compact_string()) + .ctx(trc::Key::Elapsed, time.elapsed()) + .details("Failed to read line") + })?; + + match &self.config.format { + HttpStoreFormat::List => { + let line = line_.trim(); + if !line.is_empty() { + entries.insert(line.to_string(), Value::Integer(1)); + } + } + HttpStoreFormat::Csv { + index_key, + index_value, + separator, + skip_first, + } if pos > 0 || !*skip_first => { + let mut in_quote = false; + let mut col_num = 0; + let mut last_ch = ' '; + + let mut entry_key: String = String::new(); + let mut entry_value: String = String::new(); + + for ch in line_.chars() { + match ch { + '"' if last_ch != '\\' => { + in_quote = !in_quote; + } + '\\' if last_ch != '\\' => (), + _ => { + if ch == *separator && !in_quote { + if col_num == *index_key && index_value.is_none() { + break; + } else { + col_num += 1; + } + } else if col_num == *index_key { + entry_key.push(ch); + if entry_key.len() > self.config.max_entry_size { + break; + } + } else if index_value.is_some_and(|v| col_num == v) { + entry_value.push(ch); + if entry_value.len() > self.config.max_entry_size { + break; + } + } + } + } + + last_ch = ch; + } + + if !entry_key.is_empty() { + let entry_value = if !entry_value.is_empty() { + Value::Text(entry_value.into()) + } else { + Value::Integer(1) + }; + entries.insert(entry_key, entry_value); + } + } + _ => (), + } + + if entries.len() == self.config.max_entries { + break; + } + } + + trc::event!( + Store(trc::StoreEvent::HttpStoreFetch), + Url = self.config.url.to_compact_string(), + Total = entries.len(), + Elapsed = time.elapsed(), + ); + + Ok(entries) + } +} diff --git a/crates/store/src/backend/http/mod.rs b/crates/store/src/backend/http/mod.rs new file mode 100644 index 0000000..42836ec --- /dev/null +++ b/crates/store/src/backend/http/mod.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod config; +pub mod lookup; + +use std::{ + sync::atomic::{AtomicBool, AtomicU64}, + time::Duration, +}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; + +use crate::Value; + +#[derive(Debug, Clone)] +pub struct HttpStoreConfig { + pub id: String, + pub url: String, + pub retry: u64, + pub refresh: u64, + pub timeout: Duration, + pub gzipped: bool, + pub max_size: usize, + pub max_entries: usize, + pub max_entry_size: usize, + pub format: HttpStoreFormat, +} + +#[derive(Debug, Clone)] +pub enum HttpStoreFormat { + List, + Csv { + index_key: u32, + index_value: Option, + separator: char, + skip_first: bool, + }, +} + +#[derive(Debug)] +pub struct HttpStore { + pub entries: ArcSwap>>, + pub expires: AtomicU64, + pub in_flight: AtomicBool, + pub config: HttpStoreConfig, + pub client: reqwest::Client, +} diff --git a/crates/store/src/backend/meili/main.rs b/crates/store/src/backend/meili/main.rs new file mode 100644 index 0000000..e6cb29f --- /dev/null +++ b/crates/store/src/backend/meili/main.rs @@ -0,0 +1,332 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + SearchStore, + backend::meili::{MeiliSearchStore, Task, TaskStatus, TaskUid}, + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, SearchField, SearchableField, + TracingSearchField, + }, + write::now, +}; +use registry::schema::structs; +use reqwest::{Error, Response, Url}; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; + +const UNCONFIRMED_TASK_RECHECK_DELAY: u64 = 600; +pub(crate) const MAX_TOTAL_HITS: u64 = 100_000; + +impl MeiliSearchStore { + pub async fn open(config: structs::MeilisearchStore) -> Result { + let client = config + .http_auth + .build_http_client( + config.http_headers, + "application/json".into(), + config.timeout, + config.allow_invalid_certs, + ) + .await?; + + Url::parse(&config.url).map_err(|e| format!("Invalid URL: {e}",))?; + + let ms = Self { + client, + url: config.url, + task_poll_interval: Duration::from_millis(500), + task_poll_retries: 120, + task_fail_on_timeout: true, + }; + + if let Err(err) = ms.create_indexes().await { + return Err(format!("Failed to create indexes: {err}")); + } + + Ok(SearchStore::MeiliSearch(Arc::new(MeiliSearchStore { + client: ms.client, + url: ms.url, + task_poll_interval: config.poll_interval.into_inner(), + task_poll_retries: config.max_retries as usize, + task_fail_on_timeout: config.fail_on_timeout, + }))) + } + + pub async fn create_indexes(&self) -> trc::Result<()> { + self.create_index::().await?; + self.create_index::().await?; + self.create_index::().await?; + self.create_index::().await?; + Ok(()) + } + + async fn index_exists(&self, index_uid: &str) -> trc::Result { + let response = self + .client + .get(format!("{}/indexes/{}", self.url, index_uid)) + .send() + .await + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))?; + + match response.status().as_u16() { + 200..=299 => Ok(true), + 404 => Ok(false), + status => { + let text = response.text().await.unwrap_or_default(); + Err(trc::StoreEvent::MeilisearchError + .reason(text) + .ctx(trc::Key::Code, status)) + } + } + } + + async fn create_index(&self) -> trc::Result<()> { + let index_name = T::index().index_name(); + + if self.index_exists(index_name).await? { + return Ok(()); + } + + let response = assert_success( + self.client + .post(format!("{}/indexes", self.url)) + .body( + json!({ + "uid": index_name, + "primaryKey": "id", + }) + .to_string(), + ) + .send() + .await, + ) + .await?; + + if !self.wait_for_task(response).await? { + // Index already exists + return Ok(()); + } + + let mut searchable = Vec::new(); + let mut filterable = Vec::new(); + let mut sortable = Vec::new(); + + for field in T::all_fields() { + if field.is_indexed() { + sortable.push(Value::String(field.field_name().to_string())); + } + if field.is_text() { + searchable.push(Value::String(field.field_name().to_string())); + } else { + filterable.push(Value::String(field.field_name().to_string())); + } + } + + for key in T::primary_keys() { + filterable.push(Value::String(key.field_name().to_string())); + if matches!(key, SearchField::Id) { + sortable.push(Value::String(key.field_name().to_string())); + } + } + + #[cfg(feature = "test_mode")] + filterable.push(Value::String("bcc".into())); + + if !searchable.is_empty() { + self.update_index_settings( + index_name, + "searchable-attributes", + Value::Array(searchable), + ) + .await?; + } + + if !filterable.is_empty() { + self.update_index_settings( + index_name, + "filterable-attributes", + Value::Array(filterable), + ) + .await?; + } + + if !sortable.is_empty() { + self.update_index_settings(index_name, "sortable-attributes", Value::Array(sortable)) + .await?; + } + + self.update_index_pagination(index_name).await?; + + Ok(()) + } + + async fn update_index_pagination(&self, index_uid: &str) -> trc::Result { + let response = assert_success( + self.client + .patch(format!( + "{}/indexes/{}/settings/pagination", + self.url, index_uid + )) + .body(json!({ "maxTotalHits": MAX_TOTAL_HITS }).to_string()) + .send() + .await, + ) + .await?; + + self.wait_for_task(response).await + } + + async fn update_index_settings( + &self, + index_uid: &str, + setting: &str, + value: Value, + ) -> trc::Result { + let response = assert_success( + self.client + .put(format!( + "{}/indexes/{}/settings/{}", + self.url, index_uid, setting + )) + .body(value.to_string()) + .send() + .await, + ) + .await?; + self.wait_for_task(response).await + } + + #[cfg(feature = "test_mode")] + pub async fn drop_indexes(&self) -> trc::Result<()> { + use crate::write::SearchIndex; + + for index in &[ + SearchIndex::Email, + SearchIndex::Calendar, + SearchIndex::Contacts, + SearchIndex::Tracing, + ] { + let response = self + .client + .delete(format!("{}/indexes/{}", self.url, index.index_name())) + .send() + .await + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))?; + + match response.status().as_u16() { + 200..=299 => { + self.wait_for_task(response).await?; + } + 400..=499 => { + // Index does not exist + return Ok(()); + } + _ => { + let status = response.status(); + let msg = response.text().await.unwrap_or_default(); + return Err(trc::StoreEvent::MeilisearchError + .reason(msg) + .ctx(trc::Key::Code, status.as_u16())); + } + } + } + + Ok(()) + } + + pub(crate) async fn wait_for_task(&self, response: Response) -> trc::Result { + let response_body = response.text().await.map_err(|err| { + trc::StoreEvent::MeilisearchError + .reason(err) + .details("Request failed") + })?; + let task_uid = serde_json::from_str::(&response_body) + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))? + .task_uid; + + let mut loop_count = 0; + let url = format!("{}/tasks/{}", self.url, task_uid); + + while loop_count < self.task_poll_retries { + let resp = assert_success(self.client.get(&url).send().await).await?; + + let text = resp + .text() + .await + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))?; + + let task = serde_json::from_str::(&text).map_err(|err| { + trc::StoreEvent::MeilisearchError + .reason(err) + .details(text.clone()) + })?; + + match task.status { + TaskStatus::Succeeded => return Ok(true), + TaskStatus::Failed => { + let (code, message) = task + .error + .map(|e| (e.code, Some(e.message))) + .unwrap_or((None, None)); + return if matches!(code.as_deref(), Some("index_already_exists")) { + Ok(false) + } else { + Err(trc::StoreEvent::MeilisearchError + .reason("Meilisearch task failed.") + .id(task_uid) + .code(code) + .details(message)) + }; + } + TaskStatus::Canceled => { + return Err(trc::StoreEvent::MeilisearchError + .reason("Meilisearch task was canceled") + .id(task_uid)); + } + TaskStatus::Enqueued | TaskStatus::Processing => { + loop_count += 1; + tokio::time::sleep(self.task_poll_interval).await; + } + TaskStatus::Unknown => { + return Err(trc::StoreEvent::MeilisearchError + .reason("Meilisearch task returned an unknown status") + .id(task_uid) + .details(text)); + } + } + } + + let err = trc::StoreEvent::MeilisearchError + .reason("Timed out waiting for Meilisearch task") + .id(task_uid); + + Err(if self.task_fail_on_timeout { + err + } else { + err.ctx( + trc::Key::NextRetry, + now().saturating_add(UNCONFIRMED_TASK_RECHECK_DELAY), + ) + }) + } +} + +pub(crate) async fn assert_success(response: Result) -> trc::Result { + match response { + Ok(response) => { + let status = response.status(); + if status.is_success() { + Ok(response) + } else { + Err(trc::StoreEvent::MeilisearchError + .reason(response.text().await.unwrap_or_default()) + .ctx(trc::Key::Code, status.as_u16())) + } + } + Err(err) => Err(trc::StoreEvent::MeilisearchError.reason(err)), + } +} diff --git a/crates/store/src/backend/meili/mod.rs b/crates/store/src/backend/meili/mod.rs new file mode 100644 index 0000000..e905552 --- /dev/null +++ b/crates/store/src/backend/meili/mod.rs @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use reqwest::Client; +use serde::Deserialize; +use std::time::Duration; + +pub mod main; +pub mod search; + +pub struct MeiliSearchStore { + client: Client, + url: String, + task_poll_interval: Duration, + task_poll_retries: usize, + task_fail_on_timeout: bool, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TaskUid { + #[serde(rename = "taskUid")] + pub task_uid: u64, +} + +#[derive(Debug, Deserialize)] +struct TaskError { + message: String, + #[serde(default)] + code: Option, +} + +#[derive(Debug, Deserialize)] +struct Task { + //#[serde(rename = "uid")] + //uid: u64, + status: TaskStatus, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum TaskStatus { + Enqueued, + Processing, + Succeeded, + Failed, + Canceled, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +struct MeiliSearchResponse { + hits: Vec, +} + +#[derive(Debug, Deserialize)] +struct MeiliDocumentsResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct MeiliHit { + id: u64, +} diff --git a/crates/store/src/backend/meili/search.rs b/crates/store/src/backend/meili/search.rs new file mode 100644 index 0000000..4c08ebc --- /dev/null +++ b/crates/store/src/backend/meili/search.rs @@ -0,0 +1,561 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + backend::meili::{ + MeiliDocumentsResponse, MeiliSearchResponse, MeiliSearchStore, + main::{MAX_TOTAL_HITS, assert_success}, + }, + search::*, + write::SearchIndex, +}; +use ahash::AHashSet; +use serde_json::{Map, Value, json}; +use std::fmt::{Display, Write}; + +const MAX_SEARCH_RESULTS: usize = 10_000; + +impl MeiliSearchStore { + pub async fn index(&self, documents: Vec) -> trc::Result<()> { + let mut index_documents: [String; 5] = [ + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ]; + + for document in documents { + let request = &mut index_documents[document.index.array_pos()]; + if !request.is_empty() { + request.push(','); + } else { + request.reserve(1024); + request.push('['); + } + + json_serialize(request, &document); + } + + for (mut payload, index) in index_documents.into_iter().zip([ + SearchIndex::Email, + SearchIndex::Calendar, + SearchIndex::Contacts, + SearchIndex::Tracing, + SearchIndex::File, + ]) { + if payload.is_empty() { + continue; + } + + payload.push(']'); + + let response = assert_success( + self.client + .put(format!( + "{}/indexes/{}/documents", + self.url, + index.index_name() + )) + .body(payload) + .send() + .await, + ) + .await?; + self.wait_for_task(response).await?; + } + + Ok(()) + } + + pub async fn query( + &self, + index: SearchIndex, + filters: &[SearchFilter], + sort: &[SearchComparator], + ) -> trc::Result> { + let filter_group = build_query(filters); + + if filter_group.q.is_empty() && sort.is_empty() { + return self.fetch_documents(index, &filter_group.filter).await; + } + + let mut body = Map::new(); + body.insert("limit".to_string(), Value::from(MAX_SEARCH_RESULTS)); + body.insert( + "attributesToRetrieve".to_string(), + Value::Array(vec![Value::String("id".to_string())]), + ); + + if !filter_group.filter.is_empty() { + body.insert("filter".to_string(), Value::String(filter_group.filter)); + } + + if !filter_group.q.is_empty() { + body.insert("q".to_string(), Value::String(filter_group.q)); + body.insert( + "matchingStrategy".to_string(), + Value::String("all".to_string()), + ); + + if !filter_group.search_on.is_empty() { + body.insert( + "attributesToSearchOn".to_string(), + Value::Array( + filter_group + .search_on + .into_iter() + .map(|field| Value::String(field.to_string())) + .collect(), + ), + ); + } + } + + if !sort.is_empty() { + let sort_arr: Vec = sort + .iter() + .filter_map(|comp| match comp { + SearchComparator::Field { field, ascending } => Some(Value::String(format!( + "{}:{}", + field.field_name(), + if *ascending { "asc" } else { "desc" } + ))), + _ => None, + }) + .collect(); + if !sort_arr.is_empty() { + body.insert("sort".to_string(), Value::Array(sort_arr)); + } + } + + let url = format!("{}/indexes/{}/search", self.url, index.index_name()); + let mut results = Vec::new(); + let mut offset = 0; + + loop { + body.insert("offset".to_string(), Value::from(offset)); + + let resp = assert_success( + self.client + .post(&url) + .body(Value::Object(body.clone()).to_string()) + .send() + .await, + ) + .await?; + + let text = resp + .text() + .await + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))?; + + let hits = serde_json::from_str::(&text) + .map_err(|err| { + trc::StoreEvent::MeilisearchError + .reason(err) + .details(text.clone()) + })? + .hits; + + let total = hits.len(); + results.extend(hits.into_iter().map(|hit| R::from_u64(hit.id))); + + if total < MAX_SEARCH_RESULTS { + break; + } + + offset += total; + + if offset >= MAX_TOTAL_HITS as usize { + trc::event!( + Store(trc::StoreEvent::MeilisearchError), + Reason = "Search results were truncated", + Collection = index.index_name(), + Total = offset, + ); + break; + } + } + + Ok(results) + } + + async fn fetch_documents( + &self, + index: SearchIndex, + filter: &str, + ) -> trc::Result> { + let url = format!( + "{}/indexes/{}/documents/fetch", + self.url, + index.index_name() + ); + let mut results = Vec::new(); + let mut offset = 0; + + loop { + let mut body = Map::new(); + body.insert("limit".to_string(), Value::from(MAX_SEARCH_RESULTS)); + body.insert("offset".to_string(), Value::from(offset)); + body.insert( + "fields".to_string(), + Value::Array(vec![Value::String("id".to_string())]), + ); + + if !filter.is_empty() { + body.insert("filter".to_string(), Value::String(filter.to_string())); + } + + let resp = assert_success( + self.client + .post(&url) + .body(Value::Object(body).to_string()) + .send() + .await, + ) + .await?; + + let text = resp + .text() + .await + .map_err(|err| trc::StoreEvent::MeilisearchError.reason(err))?; + + let documents = serde_json::from_str::(&text) + .map_err(|err| { + trc::StoreEvent::MeilisearchError + .reason(err) + .details(text.clone()) + })? + .results; + + let total = documents.len(); + results.extend(documents.into_iter().map(|hit| R::from_u64(hit.id))); + + if total < MAX_SEARCH_RESULTS { + break; + } + + offset += total; + } + + Ok(results) + } + + pub async fn unindex(&self, filter: SearchQuery) -> trc::Result { + let filter_group = build_query(&filter.filters); + + if filter_group.filter.is_empty() { + return Err(trc::StoreEvent::MeilisearchError.reason( + "Meilisearch delete-by-filter requires structured (non-text) filters only", + )); + } + + let url = format!( + "{}/indexes/{}/documents/delete", + self.url, + filter.index.index_name() + ); + + let response = assert_success( + self.client + .post(url) + .body(json!({ "filter": filter_group.filter }).to_string()) + .send() + .await, + ) + .await?; + + self.wait_for_task(response).await?; + + Ok(0) + } +} + +#[derive(Default, Debug)] +struct FilterGroup { + q: String, + filter: String, + search_on: AHashSet<&'static str>, +} + +fn build_query(filters: &[SearchFilter]) -> FilterGroup { + if filters.is_empty() { + return FilterGroup::default(); + } + let mut operator_stack = Vec::new(); + let mut operator = &SearchFilter::And; + let mut is_first = true; + let mut filter = String::new(); + let mut queries = AHashSet::new(); + let mut search_on = AHashSet::new(); + + for f in filters { + match f { + SearchFilter::Operator { field, op, value } => { + if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains) + { + let value = match value { + SearchValue::Text { value, .. } => value, + _ => { + debug_assert!( + false, + "Text field search with non-text value is not supported" + ); + "" + } + }; + + search_on.insert(field.field_name()); + + if matches!(op, SearchOperator::Equal) { + queries.insert(format!("{value:?}")); + } else { + for token in value.split_whitespace() { + queries.insert(token.to_string()); + } + } + } else { + if !filter.is_empty() && !filter.ends_with('(') { + match operator { + SearchFilter::And => filter.push_str(" AND "), + SearchFilter::Or => filter.push_str(" OR "), + _ => (), + } + } + + match value { + SearchValue::Text { value, .. } => { + filter.push_str(field.field_name()); + filter.push(' '); + op.write_meli_op(&mut filter, format!("{value:?}")); + } + SearchValue::KeyValues(kv) => { + let (key, value) = kv.iter().next().unwrap(); + filter.push_str(field.field_name()); + filter.push('.'); + filter.push_str(key); + filter.push(' '); + op.write_meli_op(&mut filter, format!("{value:?}")); + } + SearchValue::Int(v) => { + filter.push_str(field.field_name()); + filter.push(' '); + op.write_meli_op(&mut filter, v); + } + SearchValue::Uint(v) => { + filter.push_str(field.field_name()); + filter.push(' '); + op.write_meli_op(&mut filter, v); + } + SearchValue::Boolean(v) => { + filter.push_str(field.field_name()); + filter.push(' '); + op.write_meli_op(&mut filter, v); + } + } + } + } + SearchFilter::And | SearchFilter::Or => { + if !filter.is_empty() && !filter.ends_with('(') { + match operator { + SearchFilter::And => filter.push_str(" AND "), + SearchFilter::Or => filter.push_str(" OR "), + _ => (), + } + } + + operator_stack.push((operator, is_first)); + operator = f; + is_first = true; + filter.push('('); + } + SearchFilter::Not => { + if !filter.is_empty() && !filter.ends_with('(') { + match operator { + SearchFilter::And => filter.push_str(" AND "), + SearchFilter::Or => filter.push_str(" OR "), + _ => (), + } + } + + operator_stack.push((operator, is_first)); + operator = &SearchFilter::And; + is_first = true; + filter.push_str("NOT ("); + } + SearchFilter::End => { + let p = operator_stack.pop().unwrap_or((&SearchFilter::And, true)); + operator = p.0; + is_first = p.1; + + if !filter.ends_with('(') { + filter.push(')'); + } else { + filter.pop(); + if filter.ends_with("NOT ") { + let len = filter.len(); + filter.truncate(len - 4); + } + if filter.ends_with(" AND ") { + let len = filter.len(); + filter.truncate(len - 5); + is_first = true; + } else if filter.ends_with(" OR ") { + let len = filter.len(); + filter.truncate(len - 4); + is_first = true; + } + } + } + SearchFilter::DocumentSet(_) => { + debug_assert!(false, "DocumentSet filters are not supported") + } + } + } + + let mut q = String::new(); + if !queries.is_empty() { + for (idx, term) in queries.into_iter().enumerate() { + if idx > 0 { + q.push(' '); + } + q.push_str(&term); + } + } + + FilterGroup { + q, + filter, + search_on, + } +} + +impl SearchOperator { + fn write_meli_op(&self, query: &mut String, value: impl Display) { + match self { + SearchOperator::LowerThan => { + let _ = write!(query, "< {value}"); + } + SearchOperator::LowerEqualThan => { + let _ = write!(query, "<= {value}"); + } + SearchOperator::GreaterThan => { + let _ = write!(query, "> {value}"); + } + SearchOperator::GreaterEqualThan => { + let _ = write!(query, ">= {value}"); + } + SearchOperator::Equal | SearchOperator::Contains => { + let _ = write!(query, "= {value}"); + } + } + } +} + +fn json_serialize(request: &mut String, document: &IndexDocument) { + let mut id = 0u64; + let mut is_first = true; + request.push('{'); + for (k, v) in document.fields.iter() { + match k { + SearchField::AccountId => { + if let SearchValue::Uint(account_id) = v { + id |= account_id << 32; + } + } + SearchField::DocumentId => { + if let SearchValue::Uint(doc_id) = v { + id |= doc_id; + } + } + SearchField::Id => { + if let SearchValue::Uint(doc_id) = v { + id = *doc_id; + } + continue; + } + _ => {} + } + + if !is_first { + request.push(','); + } else { + is_first = false; + } + + let _ = write!(request, "{:?}:", k.field_name()); + match v { + SearchValue::Text { value, .. } => { + json_serialize_str(request, value); + } + SearchValue::KeyValues(map) => { + request.push('{'); + for (i, (key, value)) in map.iter().enumerate() { + if i > 0 { + request.push(','); + } + json_serialize_str(request, key); + request.push(':'); + json_serialize_str(request, value); + } + request.push('}'); + } + SearchValue::Int(v) => { + let _ = write!(request, "{}", v); + } + SearchValue::Uint(v) => { + let _ = write!(request, "{}", v); + } + SearchValue::Boolean(v) => { + let _ = write!(request, "{}", v); + } + } + } + + /*if id == 0 { + debug_assert!(false, "Document is missing required ID fields"); + }*/ + + let _ = write!(request, ",\"id\":{id}}}"); +} + +fn json_serialize_str(request: &mut String, value: &str) { + request.push('"'); + for c in value.chars() { + match c { + '"' => request.push_str("\\\""), + '\\' => request.push_str("\\\\"), + '\n' => request.push_str("\\n"), + '\r' => request.push_str("\\r"), + '\t' => request.push_str("\\t"), + '\u{0008}' => request.push_str("\\b"), // backspace + '\u{000C}' => request.push_str("\\f"), // form feed + _ => { + if !c.is_control() { + request.push(c); + } else { + let _ = write!(request, "\\u{:04x}", c as u32); + } + } + } + } + request.push('"'); +} + +impl SearchIndex { + #[inline(always)] + fn array_pos(&self) -> usize { + match self { + SearchIndex::Email => 0, + SearchIndex::Calendar => 1, + SearchIndex::Contacts => 2, + SearchIndex::Tracing => 3, + SearchIndex::File => 4, + SearchIndex::InMemory => unreachable!(), + } + } +} diff --git a/crates/store/src/backend/memory/mod.rs b/crates/store/src/backend/memory/mod.rs new file mode 100644 index 0000000..5b831e7 --- /dev/null +++ b/crates/store/src/backend/memory/mod.rs @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{InMemoryStore, LookupStores, Value, registry::bootstrap::Bootstrap}; +use ahash::AHashMap; +use registry::schema::structs; +use utils::glob::{GlobMap, GlobSet}; + +#[derive(Debug)] +pub enum StaticMemoryStore { + Map(GlobMap>), + Set(GlobSet), +} + +impl LookupStores { + pub async fn parse_static(&mut self, bp: &mut Bootstrap) { + let mut lookups = AHashMap::new(); + + for lookup in bp.list_infallible::().await { + if let StaticMemoryStore::Map(map) = lookups + .entry(lookup.object.namespace) + .or_insert_with(|| StaticMemoryStore::Map(Default::default())) + { + if lookup.object.is_glob_pattern { + map.insert_pattern(&lookup.object.key, Value::from(lookup.object.value)); + } else { + map.insert_entry(lookup.object.key, Value::from(lookup.object.value)); + } + } else { + bp.build_warning( + lookup.id, + "Memory lookup has mixed types (key-value and set)", + ); + } + } + + for lookup in bp.list_infallible::().await { + if let StaticMemoryStore::Set(set) = lookups + .entry(lookup.object.namespace) + .or_insert_with(|| StaticMemoryStore::Set(Default::default())) + { + if lookup.object.is_glob_pattern { + set.insert_pattern(&lookup.object.key); + } else { + set.insert_entry(lookup.object.key); + } + } else { + bp.build_warning( + lookup.id, + "Memory lookup has mixed types (key-value and set)", + ); + } + } + + for (namespace, store) in lookups { + self.stores.insert( + namespace.into_boxed_str(), + InMemoryStore::Static(store.into()), + ); + } + } +} diff --git a/crates/store/src/backend/mod.rs b/crates/store/src/backend/mod.rs new file mode 100644 index 0000000..b3508a3 --- /dev/null +++ b/crates/store/src/backend/mod.rs @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[cfg(feature = "azure")] +pub mod azure; +pub mod elastic; +pub mod ephemeral; +#[cfg(feature = "foundation")] +pub mod foundationdb; +pub mod fs; +pub mod http; +pub mod meili; +pub mod memory; +#[cfg(feature = "mysql")] +pub mod mysql; +#[cfg(feature = "postgres")] +pub mod postgres; +#[cfg(feature = "redis")] +pub mod redis; +#[cfg(feature = "rocks")] +pub mod rocksdb; +#[cfg(feature = "s3")] +pub mod s3; +#[cfg(feature = "sqlite")] +pub mod sqlite; + + +pub const MAX_TOKEN_LENGTH: usize = (u8::MAX >> 1) as usize; +pub const MAX_TOKEN_MASK: usize = MAX_TOKEN_LENGTH - 1; + +#[allow(dead_code)] +fn deserialize_i64_le(key: &[u8], bytes: &[u8]) -> trc::Result { + Ok(i64::from_le_bytes(bytes[..].try_into().map_err(|_| { + trc::Error::corrupted_key(key, bytes.into(), trc::location!()) + })?)) +} diff --git a/crates/store/src/backend/mysql/blob.rs b/crates/store/src/backend/mysql/blob.rs new file mode 100644 index 0000000..983a358 --- /dev/null +++ b/crates/store/src/backend/mysql/blob.rs @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::ops::Range; + +use mysql_async::prelude::Queryable; + +use super::{MysqlStore, into_error}; + +impl MysqlStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep("SELECT v FROM t WHERE k = ?") + .await + .map_err(into_error)?; + conn.exec_first::, _, _>(&s, (key,)) + .await + .map(|bytes| { + if range.start == 0 && range.end == usize::MAX { + bytes + } else { + bytes.map(|bytes| { + bytes + .get(range.start..std::cmp::min(bytes.len(), range.end)) + .unwrap_or_default() + .to_vec() + }) + } + }) + .map_err(into_error) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)") + .await + .map_err(into_error)?; + conn.exec_drop(&s, (key, data)) + .await + .map_err(into_error) + .map(|_| ()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep("DELETE FROM t WHERE k = ?") + .await + .map_err(into_error)?; + conn.exec_iter(&s, (key,)) + .await + .map_err(into_error) + .map(|hits| hits.affected_rows() > 0) + } +} diff --git a/crates/store/src/backend/mysql/lookup.rs b/crates/store/src/backend/mysql/lookup.rs new file mode 100644 index 0000000..321bc1e --- /dev/null +++ b/crates/store/src/backend/mysql/lookup.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mysql_async::{Params, Row, prelude::Queryable}; + +use crate::{IntoRows, QueryResult, QueryType, Value}; + +use super::{MysqlStore, into_error}; + +impl MysqlStore { + pub(crate) async fn sql_query( + &self, + query: &str, + params: &[Value<'_>], + ) -> trc::Result { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn.prep(query).await.map_err(into_error)?; + let params = Params::Positional(params.iter().map(Into::into).collect()); + + match T::query_type() { + QueryType::Execute => conn.exec_drop(s, params).await.map_or_else( + |e| Err(into_error(e)), + |_| Ok(T::from_exec(conn.affected_rows() as usize)), + ), + QueryType::Exists => conn + .exec_first::(s, params) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))), + QueryType::QueryOne => conn + .exec_first::(s, params) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))), + QueryType::QueryAll => conn + .exec::(s, params) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))), + } + } +} + +impl From> for mysql_async::Value { + fn from(value: crate::Value) -> Self { + match value { + crate::Value::Integer(i) => mysql_async::Value::Int(i), + crate::Value::Bool(b) => mysql_async::Value::Int(b as i64), + crate::Value::Float(f) => mysql_async::Value::Double(f), + crate::Value::Text(t) => mysql_async::Value::Bytes(t.into_owned().into_bytes()), + crate::Value::Blob(b) => mysql_async::Value::Bytes(b.into_owned()), + crate::Value::Null => mysql_async::Value::NULL, + } + } +} + +impl From for crate::Value<'static> { + fn from(value: mysql_async::Value) -> Self { + match value { + mysql_async::Value::Int(i) => Self::Integer(i), + mysql_async::Value::UInt(i) => Self::Integer(i as i64), + mysql_async::Value::Double(f) => Self::Float(f), + mysql_async::Value::Bytes(b) => String::from_utf8(b).map_or_else( + |e| Self::Blob(e.into_bytes().into()), + |s| Self::Text(s.into()), + ), + mysql_async::Value::NULL => Self::Null, + mysql_async::Value::Float(f) => Self::Float(f as f64), + mysql_async::Value::Date(_, _, _, _, _, _, _) + | mysql_async::Value::Time(_, _, _, _, _, _) => Self::Text(value.as_sql(true).into()), + } + } +} + +impl IntoRows for Vec { + fn into_rows(self) -> crate::Rows { + crate::Rows { + rows: self + .into_iter() + .map(|r| crate::Row { + values: r + .unwrap_raw() + .into_iter() + .flatten() + .map(Into::into) + .collect(), + }) + .collect(), + } + } + + fn into_named_rows(self) -> crate::NamedRows { + crate::NamedRows { + names: self + .first() + .map(|r| r.columns().iter().map(|c| c.name_str().into()).collect()) + .unwrap_or_default(), + rows: self + .into_iter() + .map(|r| crate::Row { + values: r + .unwrap_raw() + .into_iter() + .flatten() + .map(Into::into) + .collect(), + }) + .collect(), + } + } + + fn into_row(self) -> Option { + unreachable!() + } +} + +impl IntoRows for Option { + fn into_row(self) -> Option { + self.map(|row| crate::Row { + values: row + .unwrap_raw() + .into_iter() + .flatten() + .map(Into::into) + .collect(), + }) + } + + fn into_rows(self) -> crate::Rows { + unreachable!() + } + + fn into_named_rows(self) -> crate::NamedRows { + unreachable!() + } +} diff --git a/crates/store/src/backend/mysql/main.rs b/crates/store/src/backend/mysql/main.rs new file mode 100644 index 0000000..d7ab0d3 --- /dev/null +++ b/crates/store/src/backend/mysql/main.rs @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{MysqlStore, into_error}; +use crate::{ + backend::mysql::MysqlSearchField, + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, SearchableField, + TracingSearchField, + }, + *, +}; +use ::registry::schema::structs; +use mysql_async::{ + Conn, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts, prelude::Queryable, +}; + +impl MysqlStore { + pub async fn open(config: structs::MySqlStore) -> Result { + let mut opts = OptsBuilder::default() + .ip_or_hostname(config.host) + .user(config.auth_username) + .pass(config.auth_secret.secret().await?.map(|v| v.into_owned())) + .db_name(Some(config.database)) + .max_allowed_packet(config.max_allowed_packet.map(|v| v as usize)) + .wait_timeout(config.timeout.map(|t| t.as_secs() as usize)) + .client_found_rows(true) + .tcp_port(config.port as u16); + + if config.use_tls { + opts = opts.ssl_opts(Some( + SslOpts::default() + .with_danger_accept_invalid_certs(config.allow_invalid_certs) + .with_danger_skip_domain_validation(config.allow_invalid_certs), + )); + } + + // Configure connection pool + let mut pool_min = PoolConstraints::default().min(); + let mut pool_max = PoolConstraints::default().max(); + if let Some(n_size) = config.pool_min_connections { + pool_min = n_size as usize; + } + if let Some(n_size) = config.pool_max_connections { + pool_max = n_size as usize; + } + opts = opts.pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new(pool_min, pool_max).unwrap()), + ); + + let mut replicas = vec![]; + for replica in config.read_replicas { + replicas.push(Store::MySQL(Arc::new(MysqlStore { + conn_pool: Pool::new( + opts.clone() + .ip_or_hostname(replica.host) + .user(replica.auth_username) + .pass(replica.auth_secret.secret().await?.map(|v| v.into_owned())) + .db_name(Some(replica.database)) + .tcp_port(replica.port as u16), + ), + }))) + } + + let primary = Store::MySQL(Arc::new(MysqlStore { + conn_pool: Pool::new(opts), + })); + + + Ok(primary) + } + + pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + + for table in [ + SUBSPACE_ACL, + SUBSPACE_TASK_QUEUE, + SUBSPACE_DELETED_ITEMS, + SUBSPACE_SPAM_SAMPLES, + SUBSPACE_BLOB_LINK, + SUBSPACE_IN_MEMORY_VALUE, + SUBSPACE_PROPERTY, + SUBSPACE_REGISTRY, + SUBSPACE_REGISTRY_PK, + SUBSPACE_DIRECTORY, + SUBSPACE_QUEUE_MESSAGE, + SUBSPACE_QUEUE_EVENT, + SUBSPACE_REPORT_OUT, + SUBSPACE_REPORT_IN, + SUBSPACE_LOGS, + SUBSPACE_TELEMETRY_SPAN, + SUBSPACE_TELEMETRY_METRIC, + ] { + let table = char::from(table); + conn.query_drop(format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k VARBINARY(255) NOT NULL, + v MEDIUMBLOB NOT NULL, + PRIMARY KEY (k) + ) ENGINE=InnoDB" + )) + .await + .map_err(into_error)?; + } + + conn.query_drop(format!( + "CREATE TABLE IF NOT EXISTS {} ( + k VARBINARY(255) NOT NULL, + v LONGBLOB NOT NULL, + PRIMARY KEY (k) + ) ENGINE=InnoDB", + char::from(SUBSPACE_BLOBS), + )) + .await + .map_err(into_error)?; + + for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] { + let table = char::from(table); + conn.query_drop(format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k BLOB, + PRIMARY KEY (k(400)) + ) ENGINE=InnoDB" + )) + .await + .map_err(into_error)?; + } + + for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] { + conn.query_drop(format!( + "CREATE TABLE IF NOT EXISTS {} ( + k VARBINARY(255) NOT NULL, + v BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (k) + ) ENGINE=InnoDB", + char::from(table) + )) + .await + .map_err(into_error)?; + } + + Ok(()) + } + + pub(crate) async fn create_search_tables(&self) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + + create_search_tables::(&mut conn).await?; + create_search_tables::(&mut conn).await?; + create_search_tables::(&mut conn).await?; + //create_search_tables::(&mut conn).await?; + create_search_tables::(&mut conn).await?; + + Ok(()) + } +} + +async fn create_search_tables( + conn: &mut Conn, +) -> trc::Result<()> { + let table_name = T::index().mysql_table(); + let mut query = format!("CREATE TABLE IF NOT EXISTS {} (", table_name); + + // Add primary key columns + let pkeys = T::primary_keys(); + for pkey in pkeys { + query.push_str(&format!("{} {}, ", pkey.column(), pkey.column_type())); + } + + // Add other columns + for field in T::all_fields() { + query.push_str(&format!("{} {}, ", field.column(), field.column_type())); + } + + // Add primary key constraint + query.push_str("PRIMARY KEY ("); + for (i, pkey) in pkeys.iter().enumerate() { + if i > 0 { + query.push_str(", "); + } + query.push_str(pkey.column()); + } + query.push_str(")) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + + conn.query_drop(&query).await.map_err(into_error)?; + + // Create indexes + for field in T::all_fields() { + if field.is_text() { + let column_name = field.column(); + let create_index_query = format!( + "CREATE FULLTEXT INDEX fts_{table_name}_{column_name} ON {table_name}({column_name})", + ); + + let _ = conn.query_drop(&create_index_query).await; + } + + if field.is_indexed() { + let column_name = field.column(); + let create_index_query = format!( + "CREATE INDEX idx_{table_name}_{column_name} ON {table_name}({column_name})", + ); + let _ = conn.query_drop(&create_index_query).await; + } + } + + Ok(()) +} diff --git a/crates/store/src/backend/mysql/mod.rs b/crates/store/src/backend/mysql/mod.rs new file mode 100644 index 0000000..9cf087b --- /dev/null +++ b/crates/store/src/backend/mysql/mod.rs @@ -0,0 +1,208 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField, + TracingSearchField, + }, + write::SearchIndex, +}; +use mysql_async::Pool; +use std::fmt::Display; + +pub mod blob; +pub mod lookup; +pub mod main; +pub mod read; +pub mod search; +pub mod write; + +pub struct MysqlStore { + pub(crate) conn_pool: Pool, +} + +#[inline(always)] +fn into_error(err: impl Display) -> trc::Error { + trc::StoreEvent::MysqlError.reason(err) +} + +const ER_LOCK_WAIT_TIMEOUT: u16 = 1205; +const ER_STATEMENT_TIMEOUT: u16 = 1969; +const ER_QUERY_TIMEOUT: u16 = 3024; + +pub(crate) const DELETE_CHUNK_SIZE: usize = 1000; +pub(crate) const MIN_DELETE_CHUNK_SIZE: usize = 10; + +#[inline(always)] +pub(crate) fn is_timeout_error(err: &mysql_async::Error) -> bool { + matches!(err, mysql_async::Error::Server(err) + if matches!( + err.code, + ER_LOCK_WAIT_TIMEOUT | ER_STATEMENT_TIMEOUT | ER_QUERY_TIMEOUT + ) + ) +} + +impl SearchIndex { + pub fn mysql_table(&self) -> &'static str { + match self { + SearchIndex::Email => "s_email", + SearchIndex::Calendar => "s_cal", + SearchIndex::Contacts => "s_card", + SearchIndex::File => "s_file", + SearchIndex::Tracing => "s_trace", + SearchIndex::InMemory => "", + } + } +} + +trait MysqlSearchField { + fn column(&self) -> &'static str; + fn column_type(&self) -> &'static str; +} + +impl MysqlSearchField for EmailSearchField { + fn column(&self) -> &'static str { + match self { + EmailSearchField::From => "fadr", + EmailSearchField::To => "tadr", + EmailSearchField::Cc => "cc", + EmailSearchField::Bcc => "bcc", + EmailSearchField::Subject => "subj", + EmailSearchField::Body => "body", + EmailSearchField::Attachment => "atta", + EmailSearchField::ReceivedAt => "rcvd", + EmailSearchField::SentAt => "sent", + EmailSearchField::Size => "size", + EmailSearchField::HasAttachment => "hatt", + EmailSearchField::Headers => "hdrs", + } + } + + fn column_type(&self) -> &'static str { + match self { + EmailSearchField::ReceivedAt | EmailSearchField::SentAt => "BIGINT", + EmailSearchField::Size => "INT", + EmailSearchField::HasAttachment => "BOOLEAN", + EmailSearchField::Headers => "JSON", + EmailSearchField::From => "TEXT", + EmailSearchField::To => "TEXT", + EmailSearchField::Cc => "TEXT", + EmailSearchField::Bcc => "TEXT", + EmailSearchField::Subject => "TEXT", + EmailSearchField::Body => "MEDIUMTEXT", + EmailSearchField::Attachment => "MEDIUMTEXT", + } + } +} + +impl MysqlSearchField for CalendarSearchField { + fn column(&self) -> &'static str { + match self { + CalendarSearchField::Title => "titl", + CalendarSearchField::Description => "dscd", + CalendarSearchField::Location => "locn", + CalendarSearchField::Owner => "ownr", + CalendarSearchField::Attendee => "atnd", + CalendarSearchField::Start => "strt", + CalendarSearchField::Uid => "uid", + } + } + + fn column_type(&self) -> &'static str { + match self { + CalendarSearchField::Start => "BIGINT NOT NULL", + _ => "TEXT", + } + } +} + +impl MysqlSearchField for ContactSearchField { + fn column(&self) -> &'static str { + match self { + ContactSearchField::Member => "mmbr", + ContactSearchField::Name => "name", + ContactSearchField::Nickname => "nick", + ContactSearchField::Organization => "orgn", + ContactSearchField::Email => "eml", + ContactSearchField::Phone => "phon", + ContactSearchField::OnlineService => "olsv", + ContactSearchField::Address => "addr", + ContactSearchField::Note => "note", + ContactSearchField::Kind => "kind", + ContactSearchField::Uid => "uid", + } + } + + fn column_type(&self) -> &'static str { + match self { + ContactSearchField::Kind | ContactSearchField::Uid => "TEXT", + _ => "TEXT", + } + } +} + +impl MysqlSearchField for FileSearchField { + fn column(&self) -> &'static str { + match self { + FileSearchField::Name => "name", + FileSearchField::Content => "body", + } + } + + fn column_type(&self) -> &'static str { + match self { + FileSearchField::Name => "TEXT", + FileSearchField::Content => "MEDIUMTEXT", + } + } +} +impl MysqlSearchField for TracingSearchField { + fn column(&self) -> &'static str { + match self { + TracingSearchField::QueueId => "qid", + TracingSearchField::EventType => "etyp", + TracingSearchField::Keywords => "kwds", + } + } + + fn column_type(&self) -> &'static str { + match self { + TracingSearchField::EventType => "BIGINT", + TracingSearchField::QueueId => "BIGINT", + TracingSearchField::Keywords => "TEXT", + } + } +} + +impl MysqlSearchField for SearchField { + fn column(&self) -> &'static str { + match self { + SearchField::AccountId => "accid", + SearchField::DocumentId => "docid", + SearchField::Id => "id", + SearchField::Email(field) => field.column(), + SearchField::Calendar(field) => field.column(), + SearchField::Contact(field) => field.column(), + SearchField::File(field) => field.column(), + SearchField::Tracing(field) => field.column(), + } + } + + fn column_type(&self) -> &'static str { + match self { + SearchField::AccountId => "INT NOT NULL", + SearchField::DocumentId => "INT NOT NULL", + SearchField::Id => "BIGINT NOT NULL", + SearchField::Email(field) => field.column_type(), + SearchField::Calendar(field) => field.column_type(), + SearchField::Contact(field) => field.column_type(), + SearchField::File(field) => field.column_type(), + SearchField::Tracing(field) => field.column_type(), + } + } +} diff --git a/crates/store/src/backend/mysql/read.rs b/crates/store/src/backend/mysql/read.rs new file mode 100644 index 0000000..3fc387e --- /dev/null +++ b/crates/store/src/backend/mysql/read.rs @@ -0,0 +1,169 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{MysqlStore, into_error, is_timeout_error}; +use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass}; +use futures::TryStreamExt; +use mysql_async::{Row, prelude::Queryable}; + +impl MysqlStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep(format!( + "SELECT v FROM {} WHERE k = ?", + char::from(key.subspace()) + )) + .await + .map_err(into_error)?; + let key = key.serialize(0); + conn.exec_first::, _, _>(&s, (&key,)) + .await + .map_err(into_error) + .and_then(|r| { + if let Some(r) = r { + Ok(Some(U::deserialize_owned_with_key(&key, r)?)) + } else { + Ok(None) + } + }) + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep(format!( + "SELECT 1 FROM {} WHERE k = ?", + char::from(key.subspace()) + )) + .await + .map_err(into_error)?; + let key = key.serialize(0); + conn.exec_first::(&s, (&key,)) + .await + .map_err(into_error) + .map(|r| r.is_some()) + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let table = char::from(params.begin.subspace()); + let begin = params.begin.serialize(0); + let end = params.end.serialize(0); + let keys = if params.values { "k, v" } else { "k" }; + + let s = conn + .prep(&match (params.first, params.ascending) { + (true, true) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1" + ) + } + (true, false) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1" + ) + } + (false, true) => { + format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC") + } + (false, false) => { + format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC") + } + }) + .await + .map_err(into_error)?; + let mut from = begin; + let mut to = end; + let mut resume_key = None; + + loop { + let mut last_key = None; + let mut timed_out = false; + + { + let mut rows = conn + .exec_stream::(&s, (from.clone(), to.clone())) + .await + .map_err(into_error)?; + + loop { + match rows.try_next().await { + Ok(Some(mut row)) => { + let value = if params.values { + row.take_opt::, _>(1) + .unwrap_or_else(|| Ok(vec![])) + .map_err(into_error)? + } else { + vec![] + }; + let key = row + .take_opt::, _>(0) + .unwrap_or_else(|| Ok(vec![])) + .map_err(into_error)?; + + if resume_key.take().is_some_and(|resumed| resumed == key) { + continue; + } + + if !cb(&key, &value)? { + return Ok(()); + } + + last_key = Some(key); + } + Ok(None) => break, + Err(err) => { + if params.first || last_key.is_none() || !is_timeout_error(&err) { + return Err(into_error(err)); + } + timed_out = true; + break; + } + } + } + } + + match last_key { + Some(last_key) if timed_out => { + if params.ascending { + from.clone_from(&last_key); + } else { + to.clone_from(&last_key); + } + resume_key = Some(last_key); + } + _ => return Ok(()), + } + } + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into(); + let table = char::from(key.subspace()); + let key = key.serialize(0); + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn + .prep(format!("SELECT v FROM {table} WHERE k = ?")) + .await + .map_err(into_error)?; + match conn.exec_first::(&s, (key,)).await { + Ok(Some(num)) => Ok(num), + Ok(None) => Ok(0), + Err(e) => Err(into_error(e)), + } + } +} diff --git a/crates/store/src/backend/mysql/search.rs b/crates/store/src/backend/mysql/search.rs new file mode 100644 index 0000000..c5c3188 --- /dev/null +++ b/crates/store/src/backend/mysql/search.rs @@ -0,0 +1,353 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + backend::{ + MAX_TOKEN_LENGTH, + mysql::{ + DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlSearchField, MysqlStore, into_error, + is_timeout_error, + }, + }, + search::{ + IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator, + SearchQuery, SearchValue, + }, + write::SearchIndex, +}; +use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable}; +use nlp::tokenizers::word::WordTokenizer; +use std::fmt::Write; + +impl MysqlStore { + pub async fn index(&self, documents: Vec) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let mut tx_opts = TxOpts::default(); + tx_opts + .with_consistent_snapshot(false) + .with_isolation_level(IsolationLevel::ReadCommitted); + let mut trx = conn.start_transaction(tx_opts).await.map_err(into_error)?; + + for document in documents { + let index = document.index; + let primary_keys = index.primary_keys(); + let all_fields = index.all_fields(); + let mut fields = document.fields; + let mut values = Vec::with_capacity(fields.len() + 2); + let mut query = format!("INSERT INTO {} (", index.mysql_table()); + + for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { + if i > 0 { + query.push(','); + } + query.push_str(field.column()); + } + + query.push_str(") VALUES ("); + + for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { + if i > 0 { + query.push(','); + } + + if let Some(value) = fields.remove(field) { + query.push('?'); + values.push(value); + } else { + query.push_str("NULL"); + } + } + + query.push_str(") ON DUPLICATE KEY UPDATE "); + for (i, field) in all_fields.iter().enumerate() { + if i > 0 { + query.push(','); + } + let column = field.column(); + let _ = write!(&mut query, "{column} = VALUES({column})"); + } + + let s = trx.prep(&query).await.map_err(into_error)?; + + trx.exec_drop(&s, values).await.map_err(into_error)?; + } + + trx.commit().await.map_err(into_error) + } + + pub async fn query( + &self, + index: SearchIndex, + filters: &[SearchFilter], + sort: &[SearchComparator], + ) -> trc::Result> { + let mut query = format!( + "SELECT {} FROM {}", + R::field().column(), + index.mysql_table() + ); + let params = build_filter(&mut query, filters); + if !sort.is_empty() { + build_sort(&mut query, sort); + } + + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn.prep(query).await.map_err(into_error)?; + + conn.exec::(s, params) + .await + .map(|r| r.into_iter().map(|r| R::from_u64(r as u64)).collect()) + .map_err(into_error) + } + + pub async fn unindex(&self, filter: SearchQuery) -> trc::Result { + let table = filter.index.mysql_table(); + let mut query = format!("DELETE FROM {table} "); + let params = build_filter(&mut query, &filter.filters); + + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let s = conn.prep(&query).await.map_err(into_error)?; + + match conn.exec_drop(s, params.clone()).await { + Ok(_) => return Ok(conn.affected_rows()), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let mut chunk_size = DELETE_CHUNK_SIZE; + let mut deleted = 0; + + loop { + let s = conn + .prep(format!("{query} LIMIT {chunk_size}")) + .await + .map_err(into_error)?; + + loop { + match conn.exec_drop(&s, params.clone()).await { + Ok(_) => { + let affected = conn.affected_rows(); + if affected == 0 { + return Ok(deleted); + } + deleted += affected; + } + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + } + } + } +} + +fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec { + if filters.is_empty() { + return Vec::new(); + } + query.push_str(" WHERE "); + let mut operator_stack = Vec::new(); + let mut operator = &SearchFilter::And; + let mut is_first = true; + let mut values: Vec = Vec::new(); + + for filter in filters { + match filter { + SearchFilter::Operator { field, op, value } => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + + if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains) + { + let (value, mode) = match (value, op) { + (SearchValue::Text { value, .. }, SearchOperator::Equal) => { + (Value::Bytes(format!("{value:?}").into_bytes()), "BOOLEAN") + } + (SearchValue::Text { value, .. }, ..) => { + let mut text_query = String::with_capacity(value.len() + 1); + + for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) { + if !text_query.is_empty() { + text_query.push(' '); + } + text_query.push('+'); + text_query.push_str(&item.word); + } + + (Value::Bytes(text_query.into_bytes()), "BOOLEAN") + } + _ => { + debug_assert!(false, "Invalid search value for text field"); + continue; + } + }; + let _ = write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column()); + values.push(value); + } else if let SearchValue::KeyValues(kv) = value { + let (key, value) = kv.iter().next().unwrap(); + + values.push(Value::Bytes(format!("$.{key:?}").into_bytes())); + + if !value.is_empty() { + if op == &SearchOperator::Equal { + let _ = write!(query, "JSON_EXTRACT({}, ?) = ?", field.column()); + values.push(Value::Bytes(value.as_bytes().to_vec())); + } else { + let _ = write!(query, "JSON_EXTRACT({}, ?) LIKE ?", field.column(),); + values.push(Value::Bytes(format!("%{value}%").into_bytes())); + } + } else { + let _ = write!(query, "JSON_CONTAINS_PATH({}, 'one', ?)", field.column(),); + } + } else { + query.push_str(field.column()); + query.push(' '); + op.write_mysql(query); + values.push(to_mysql(value)); + } + } + SearchFilter::And | SearchFilter::Or => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + + operator_stack.push((operator, is_first)); + operator = filter; + is_first = true; + query.push('('); + } + SearchFilter::Not => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + + operator_stack.push((operator, is_first)); + operator = &SearchFilter::And; + is_first = true; + query.push_str("NOT ("); + } + SearchFilter::End => { + let p = operator_stack.pop().unwrap_or((&SearchFilter::And, true)); + operator = p.0; + is_first = p.1; + query.push(')'); + } + SearchFilter::DocumentSet(_) => { + debug_assert!( + false, + "DocumentSet filters are not supported in Postgres backend" + ) + } + } + } + + values +} + +fn build_sort(query: &mut String, sort: &[SearchComparator]) { + query.push_str(" ORDER BY "); + for (i, comparator) in sort.iter().enumerate() { + if i > 0 { + query.push_str(", "); + } + match comparator { + SearchComparator::Field { field, ascending } => { + query.push_str(field.column()); + if *ascending { + query.push_str(" ASC"); + } else { + query.push_str(" DESC"); + } + } + SearchComparator::DocumentSet { .. } | SearchComparator::SortedSet { .. } => { + debug_assert!( + false, + "DocumentSet and SortedSet comparators are not supported " + ); + } + } + } +} + +impl SearchOperator { + fn write_mysql(&self, query: &mut String) { + match self { + SearchOperator::LowerThan => { + let _ = write!(query, "< ?"); + } + SearchOperator::LowerEqualThan => { + let _ = write!(query, "<= ?"); + } + SearchOperator::GreaterThan => { + let _ = write!(query, "> ?"); + } + SearchOperator::GreaterEqualThan => { + let _ = write!(query, ">= ?"); + } + SearchOperator::Equal => { + let _ = write!(query, "= ?"); + } + SearchOperator::Contains => { + let _ = write!(query, "LIKE '%' CONCAT('%', ?, '%')"); + } + } + } +} + +impl From for Value { + fn from(value: SearchValue) -> Self { + match value { + SearchValue::Text { mut value, .. } => { + // Truncate values larger than 16MB to avoid MySQL errors + if value.len() > 16_777_214 { + let pos = value.floor_char_boundary(16_777_214); + value.truncate(pos); + } + + Value::Bytes(value.into_bytes()) + } + SearchValue::KeyValues(vec_map) => serde_json::to_string(&vec_map) + .map(|v| Value::Bytes(v.into_bytes())) + .unwrap_or(Value::NULL), + SearchValue::Int(i) => Value::Int(i), + SearchValue::Uint(i) => Value::Int(i as i64), + SearchValue::Boolean(b) => Value::Int(b as i64), + } + } +} + +fn to_mysql(value: &SearchValue) -> Value { + match value { + SearchValue::Text { value, .. } => Value::Bytes(value.as_bytes().to_vec()), + SearchValue::KeyValues(vec_map) => serde_json::to_string(&vec_map) + .map(|v| Value::Bytes(v.into_bytes())) + .unwrap_or(Value::NULL), + SearchValue::Int(i) => Value::Int(*i), + SearchValue::Uint(i) => Value::Int(*i as i64), + SearchValue::Boolean(b) => Value::Int(*b as i64), + } +} diff --git a/crates/store/src/backend/mysql/write.rs b/crates/store/src/backend/mysql/write.rs new file mode 100644 index 0000000..d5b183a --- /dev/null +++ b/crates/store/src/backend/mysql/write.rs @@ -0,0 +1,529 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, into_error, is_timeout_error}; +use crate::{ + IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA, + SUBSPACE_REGISTRY_IDX, + write::{ + AssignedIds, Batch, MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME, MergeResult, Operation, + ValueClass, ValueOp, + }, +}; +use ahash::AHashMap; +use mysql_async::{Conn, Error, IsolationLevel, TxOpts, params, prelude::Queryable}; +use rand::RngExt; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +enum CommitError { + Mysql(mysql_async::Error), + Internal(trc::Error), + //Retry, +} + +impl MysqlStore { + pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result { + let start = Instant::now(); + let mut retry_count = 0; + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + + loop { + let err = match self.write_trx(&mut conn, &mut batch).await { + Ok(result) => { + return Ok(result); + } + Err(err) => err, + }; + + let _ = conn.query_drop("ROLLBACK;").await; + + match err { + CommitError::Mysql(Error::Server(err)) + if [1062, 1213].contains(&err.code) + && retry_count < MAX_COMMIT_ATTEMPTS + && start.elapsed() < MAX_COMMIT_TIME => {} + /*CommitError::Retry => { + if retry_count > MAX_COMMIT_ATTEMPTS || start.elapsed() > MAX_COMMIT_TIME { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!())); + } + }*/ + CommitError::Mysql(err) => { + return Err(into_error(err)); + } + CommitError::Internal(err) => { + return Err(err); + } + } + + let backoff = rand::rng().random_range(50..=300); + tokio::time::sleep(Duration::from_millis(backoff)).await; + retry_count += 1; + } + } + + async fn write_trx( + &self, + conn: &mut Conn, + batch: &mut Batch<'_>, + ) -> Result { + let has_changes = !batch.changes.is_empty(); + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let mut asserted_values = AHashMap::new(); + let mut tx_opts = TxOpts::default(); + tx_opts + .with_consistent_snapshot(false) + .with_isolation_level(IsolationLevel::ReadCommitted); + let mut trx = conn.start_transaction(tx_opts).await?; + let mut result = AssignedIds::default(); + + if has_changes { + for &account_id in batch.changes.keys() { + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, 0); + let s = trx + .prep(concat!( + "INSERT INTO n (k, v) VALUES (:k, LAST_INSERT_ID(1)) ", + "ON DUPLICATE KEY UPDATE v = LAST_INSERT_ID(v + 1)" + )) + .await?; + trx.exec_drop(&s, params! {"k" => key}).await?; + let s = trx.prep("SELECT LAST_INSERT_ID()").await?; + let change_id = trx.exec_first::(&s, ()).await?.ok_or_else(|| { + mysql_async::Error::Io(mysql_async::IoError::Io(std::io::Error::other( + "LAST_INSERT_ID() did not return a value", + ))) + })?; + result.push_change_id(account_id, change_id as u64); + } + } + + for op in batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let key = class.serialize(account_id, collection, document_id, 0); + let subspace = class.subspace(collection); + let table = char::from(subspace); + + match op { + ValueOp::Set(value) => { + if subspace != SUBSPACE_REGISTRY_IDX { + let exists = asserted_values.get(&key); + let s = if let Some(exists) = exists { + if *exists { + trx.prep(format!( + "UPDATE {} SET v = :v WHERE k = :k", + table + )) + .await? + } else { + trx.prep(format!( + "INSERT INTO {} (k, v) VALUES (:k, :v)", + table + )) + .await? + } + } else { + trx + .prep( + format!("INSERT INTO {} (k, v) VALUES (:k, :v) ON DUPLICATE KEY UPDATE v = VALUES(v)", table), + ) + .await? + }; + + match trx + .exec_drop(&s, params! {"k" => key, "v" => &*value}) + .await + { + Ok(_) => { + if trx.affected_rows() == 0 { + trx.rollback().await?; + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + } + Err(err) => { + trx.rollback().await?; + return Err(err.into()); + } + } + } else { + let s = trx.prep("INSERT IGNORE INTO b (k) VALUES (?)").await?; + trx.exec_drop(&s, (key,)).await?; + } + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + let exists = asserted_values.get(&key); + let s = if let Some(exists) = exists { + if *exists { + trx.prep(format!("UPDATE {} SET v = :v WHERE k = :k", table)) + .await? + } else { + trx.prep(format!( + "INSERT INTO {} (k, v) VALUES (:k, :v)", + table + )) + .await? + } + } else { + trx + .prep( + format!("INSERT INTO {} (k, v) VALUES (:k, :v) ON DUPLICATE KEY UPDATE v = VALUES(v)", table), + ) + .await? + }; + + match trx.exec_drop(&s, params! {"k" => key, "v" => &value}).await { + Ok(_) => { + if trx.affected_rows() == 0 { + trx.rollback().await?; + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + } + Err(err) => { + trx.rollback().await?; + return Err(err.into()); + } + } + } + ValueOp::MergeFnc(merge_op) => { + let s = trx + .prep(format!("SELECT v FROM {} WHERE k = ? FOR UPDATE", table)) + .await?; + let (exists, merge_result) = trx + .exec_first::, _, _>(&s, (&key,)) + .await? + .map(|bytes| { + (merge_op.fnc)(&merge_op.params, &result, Some(bytes.as_ref())) + .map(|v| (true, v)) + .map_err(CommitError::from) + }) + .unwrap_or_else(|| { + (merge_op.fnc)(&merge_op.params, &result, None) + .map(|v| (false, v)) + .map_err(CommitError::from) + })?; + + let s = if exists { + trx.prep(format!("UPDATE {} SET v = :v WHERE k = :k", table)) + .await? + } else { + trx.prep(format!("INSERT INTO {} (k, v) VALUES (:k, :v)", table)) + .await? + }; + + match merge_result { + MergeResult::Update(value) => { + if let Err(err) = + trx.exec_drop(&s, params! {"k" => key, "v" => &value}).await + { + trx.rollback().await?; + return Err(err.into()); + } + } + MergeResult::Delete if exists => { + // Update asserted value + if let Some(exists) = asserted_values.get_mut(&key) { + *exists = false; + } + + let s = trx + .prep(format!("DELETE FROM {} WHERE k = ?", table)) + .await?; + trx.exec_drop(&s, (key,)).await?; + } + _ => (), + } + } + ValueOp::AtomicAdd(by) => { + if *by >= 0 { + let s = trx + .prep(format!( + concat!( + "INSERT INTO {} (k, v) VALUES (?, ?) ", + "ON DUPLICATE KEY UPDATE v = v + VALUES(v)" + ), + table + )) + .await?; + trx.exec_drop(&s, (key, &*by)).await?; + } else { + let s = trx + .prep(format!("UPDATE {table} SET v = v + ? WHERE k = ?")) + .await?; + trx.exec_drop(&s, (&*by, key)).await?; + } + } + ValueOp::AddAndGet(by) => { + let s = trx + .prep(format!( + concat!( + "INSERT INTO {} (k, v) VALUES (:k, LAST_INSERT_ID(:v)) ", + "ON DUPLICATE KEY UPDATE v = LAST_INSERT_ID(v + :v)" + ), + table + )) + .await?; + trx.exec_drop(&s, params! {"k" => key, "v" => &*by}).await?; + let s = trx.prep("SELECT LAST_INSERT_ID()").await?; + result.push_counter_id( + trx.exec_first::(&s, ()).await?.ok_or_else(|| { + mysql_async::Error::Io(mysql_async::IoError::Io( + std::io::Error::other( + "LAST_INSERT_ID() did not return a value", + ), + )) + })?, + ); + } + ValueOp::Clear => { + // Update asserted value + if let Some(exists) = asserted_values.get_mut(&key) { + *exists = false; + } + + let s = trx + .prep(format!("DELETE FROM {} WHERE k = ?", table)) + .await?; + trx.exec_drop(&s, (key,)).await?; + } + } + } + Operation::Index { field, key, set } => { + let key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: &*key, + } + .serialize(0); + + let s = if *set { + trx.prep("INSERT IGNORE INTO i (k) VALUES (?)").await? + } else { + trx.prep("DELETE FROM i WHERE k = ?").await? + }; + trx.exec_drop(&s, (key,)).await?; + } + Operation::Log { collection, set } => { + let key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(0); + + let s = trx + .prep("INSERT INTO l (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)") + .await?; + + trx.exec_drop(&s, (key, &*set)).await?; + } + Operation::AssertValue { + class, + assert_value, + } => { + let key = class.serialize(account_id, collection, document_id, 0); + let table = char::from(class.subspace(collection)); + + let s = trx + .prep(format!("SELECT v FROM {} WHERE k = ? FOR UPDATE", table)) + .await?; + let (exists, matches) = trx + .exec_first::, _, _>(&s, (&key,)) + .await? + .map(|bytes| (true, assert_value.matches(&bytes))) + .unwrap_or_else(|| (false, assert_value.is_none())); + if !matches { + trx.rollback().await?; + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + asserted_values.insert(key, exists); + } + } + } + + trx.commit().await.map(|_| result).map_err(Into::into) + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { + purge_table(&mut conn, char::from(subspace)).await?; + } + + Ok(()) + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?; + let table = char::from(from.subspace()); + let mut from = from.serialize(0); + let to = to.serialize(0); + + let delete = conn + .prep(format!("DELETE FROM {table} WHERE k >= ? AND k < ?")) + .await + .map_err(into_error)?; + + match conn.exec_drop(&delete, (&from, &to)).await { + Ok(_) => return Ok(()), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let mut chunk_size = DELETE_CHUNK_SIZE; + + loop { + let boundary = conn + .prep(format!( + "SELECT k FROM {table} WHERE k >= ? AND k < ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}" + )) + .await + .map_err(into_error)?; + + loop { + let next = match conn + .exec_first::, _, _>(&boundary, (&from, &to)) + .await + { + Ok(next) => next, + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + }; + + match conn + .exec_drop(&delete, (&from, next.as_ref().unwrap_or(&to))) + .await + { + Ok(_) => (), + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + + match next { + Some(next) => from = next, + None => return Ok(()), + } + } + } + } +} + +async fn purge_table(conn: &mut Conn, table: char) -> trc::Result<()> { + let s = conn + .prep(format!("DELETE FROM {table} WHERE v = 0")) + .await + .map_err(into_error)?; + + match conn.exec_drop(&s, ()).await { + Ok(_) => return Ok(()), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let purge = conn + .prep(format!( + "DELETE FROM {table} WHERE v = 0 AND k >= ? AND k < ?" + )) + .await + .map_err(into_error)?; + let purge_last = conn + .prep(format!("DELETE FROM {table} WHERE v = 0 AND k >= ?")) + .await + .map_err(into_error)?; + let mut chunk_size = DELETE_CHUNK_SIZE; + let mut from = Vec::new(); + + loop { + let boundary = conn + .prep(format!( + "SELECT k FROM {table} WHERE k >= ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}" + )) + .await + .map_err(into_error)?; + + loop { + let next = match conn.exec_first::, _, _>(&boundary, (&from,)).await { + Ok(next) => next, + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + }; + + let result = match &next { + Some(next) => conn.exec_drop(&purge, (&from, next)).await, + None => conn.exec_drop(&purge_last, (&from,)).await, + }; + + match result { + Ok(_) => (), + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + + match next { + Some(next) => from = next, + None => return Ok(()), + } + } + } +} + +impl From for CommitError { + fn from(err: trc::Error) -> Self { + CommitError::Internal(err) + } +} + +impl From for CommitError { + fn from(err: mysql_async::Error) -> Self { + CommitError::Mysql(err) + } +} diff --git a/crates/store/src/backend/postgres/blob.rs b/crates/store/src/backend/postgres/blob.rs new file mode 100644 index 0000000..66d437c --- /dev/null +++ b/crates/store/src/backend/postgres/blob.rs @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::ops::Range; + +use crate::backend::postgres::into_pool_error; + +use super::{PostgresStore, into_error}; + +impl PostgresStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached("SELECT v FROM t WHERE k = $1") + .await + .map_err(into_error)?; + conn.query_opt(&s, &[&key]) + .await + .and_then(|row| { + if let Some(row) = row { + Ok(Some(if range.start == 0 && range.end == usize::MAX { + row.try_get::<_, Vec>(0)? + } else { + let bytes = row.try_get::<_, &[u8]>(0)?; + bytes + .get(range.start..std::cmp::min(bytes.len(), range.end)) + .unwrap_or_default() + .to_vec() + })) + } else { + Ok(None) + } + }) + .map_err(into_error) + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached( + "INSERT INTO t (k, v) VALUES ($1, $2) ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v", + ) + .await + .map_err(into_error)?; + conn.execute(&s, &[&key, &data]) + .await + .map_err(into_error) + .map(|_| ()) + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached("DELETE FROM t WHERE k = $1") + .await + .map_err(into_error)?; + conn.execute(&s, &[&key]) + .await + .map_err(into_error) + .map(|hits| hits > 0) + } +} diff --git a/crates/store/src/backend/postgres/lookup.rs b/crates/store/src/backend/postgres/lookup.rs new file mode 100644 index 0000000..30b052f --- /dev/null +++ b/crates/store/src/backend/postgres/lookup.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{QueryResult, QueryType, backend::postgres::into_pool_error}; + +use bytes::BytesMut; +use futures::{TryStreamExt, pin_mut}; +use tokio_postgres::types::{FromSql, ToSql, Type}; + +use crate::IntoRows; + +use super::{PostgresStore, into_error}; + +impl PostgresStore { + pub(crate) async fn sql_query( + &self, + query: &str, + params_: &[crate::Value<'_>], + ) -> trc::Result { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn.prepare_cached(query).await.map_err(into_error)?; + let params = params_ + .iter() + .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect::>(); + + match T::query_type() { + QueryType::Execute => conn + .execute(&s, params.as_slice()) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r as usize))), + QueryType::Exists => { + let rows = conn.query_raw(&s, params).await.map_err(into_error)?; + pin_mut!(rows); + rows.try_next() + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))) + } + QueryType::QueryOne => conn + .query_opt(&s, params.as_slice()) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))), + QueryType::QueryAll => conn + .query(&s, params.as_slice()) + .await + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))), + } + } +} + +impl ToSql for crate::Value<'_> { + fn to_sql( + &self, + ty: &tokio_postgres::types::Type, + out: &mut BytesMut, + ) -> Result> + where + Self: Sized, + { + match self { + crate::Value::Integer(v) => match *ty { + Type::CHAR => (*v as i8).to_sql(ty, out), + Type::INT2 => (*v as i16).to_sql(ty, out), + Type::INT4 => (*v as i32).to_sql(ty, out), + _ => v.to_sql(ty, out), + }, + crate::Value::Bool(v) => v.to_sql(ty, out), + crate::Value::Float(v) => { + if matches!(ty, &Type::FLOAT4) { + (*v as f32).to_sql(ty, out) + } else { + v.to_sql(ty, out) + } + } + crate::Value::Text(v) => v.to_sql(ty, out), + crate::Value::Blob(v) => v.to_sql(ty, out), + crate::Value::Null => None::.to_sql(ty, out), + } + } + + fn accepts(_: &tokio_postgres::types::Type) -> bool + where + Self: Sized, + { + true + } + + fn to_sql_checked( + &self, + ty: &tokio_postgres::types::Type, + out: &mut BytesMut, + ) -> Result> { + match self { + crate::Value::Integer(v) => match *ty { + Type::CHAR => (*v as i8).to_sql_checked(ty, out), + Type::INT2 => (*v as i16).to_sql_checked(ty, out), + Type::INT4 => (*v as i32).to_sql_checked(ty, out), + _ => v.to_sql_checked(ty, out), + }, + crate::Value::Bool(v) => v.to_sql_checked(ty, out), + crate::Value::Float(v) => { + if matches!(ty, &Type::FLOAT4) { + (*v as f32).to_sql_checked(ty, out) + } else { + v.to_sql_checked(ty, out) + } + } + crate::Value::Text(v) => v.to_sql_checked(ty, out), + crate::Value::Blob(v) => v.to_sql_checked(ty, out), + crate::Value::Null => None::.to_sql_checked(ty, out), + } + } +} + +impl IntoRows for Vec { + fn into_rows(self) -> crate::Rows { + crate::Rows { + rows: self + .into_iter() + .map(|r| crate::Row { + values: (0..r.len()) + .map(|idx| r.try_get(idx).unwrap_or(crate::Value::Null)) + .collect(), + }) + .collect(), + } + } + + fn into_named_rows(self) -> crate::NamedRows { + crate::NamedRows { + names: self + .first() + .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect()) + .unwrap_or_default(), + rows: self + .into_iter() + .map(|r| crate::Row { + values: (0..r.len()) + .map(|idx| r.try_get(idx).unwrap_or(crate::Value::Null)) + .collect(), + }) + .collect(), + } + } + + fn into_row(self) -> Option { + unreachable!() + } +} + +impl IntoRows for Option { + fn into_row(self) -> Option { + self.map(|row| crate::Row { + values: (0..row.len()) + .map(|idx| row.try_get(idx).unwrap_or(crate::Value::Null)) + .collect(), + }) + } + + fn into_rows(self) -> crate::Rows { + unreachable!() + } + + fn into_named_rows(self) -> crate::NamedRows { + unreachable!() + } +} + +impl FromSql<'_> for crate::Value<'static> { + fn from_sql( + ty: &tokio_postgres::types::Type, + raw: &'_ [u8], + ) -> Result> { + match ty { + &Type::VARCHAR | &Type::TEXT | &Type::BPCHAR | &Type::NAME | &Type::UNKNOWN => { + String::from_sql(ty, raw).map(|s| crate::Value::Text(s.into())) + } + &Type::BOOL => bool::from_sql(ty, raw).map(crate::Value::Bool), + &Type::CHAR => i8::from_sql(ty, raw).map(|v| crate::Value::Integer(v as i64)), + &Type::INT2 => i16::from_sql(ty, raw).map(|v| crate::Value::Integer(v as i64)), + &Type::INT4 => i32::from_sql(ty, raw).map(|v| crate::Value::Integer(v as i64)), + &Type::INT8 | &Type::OID => i64::from_sql(ty, raw).map(crate::Value::Integer), + &Type::FLOAT4 | &Type::FLOAT8 => f64::from_sql(ty, raw).map(crate::Value::Float), + ty if (ty.name() == "citext" + || ty.name() == "ltree" + || ty.name() == "lquery" + || ty.name() == "ltxtquery") => + { + String::from_sql(ty, raw).map(|s| crate::Value::Text(s.into())) + } + _ => Vec::::from_sql(ty, raw).map(|b| crate::Value::Blob(b.into())), + } + } + + fn accepts(_: &tokio_postgres::types::Type) -> bool { + true + } +} diff --git a/crates/store/src/backend/postgres/main.rs b/crates/store/src/backend/postgres/main.rs new file mode 100644 index 0000000..c33f978 --- /dev/null +++ b/crates/store/src/backend/postgres/main.rs @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{PostgresStore, into_error}; +use crate::{ + backend::postgres::{ + PsqlSearchField, into_pool_error, + search::{PG_FALLBACK_LANG, PG_LANGS, PG_UNSTEMMED_LANG}, + tls::MakeRustlsConnect, + }, + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, SearchableField, + TracingSearchField, + }, + *, +}; +use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs}; +use ahash::AHashSet; +use deadpool_postgres::{ + Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime, +}; +use tokio_postgres::NoTls; +use utils::tls::rustls_client_config; + +impl PostgresStore { + pub async fn open(config: structs::PostgreSqlStore) -> Result { + let mut cfg = Config::new(); + cfg.dbname = config.database.into(); + cfg.host = config.host.into(); + cfg.user = config.auth_username; + cfg.password = config.auth_secret.secret().await?.map(|v| v.into_owned()); + cfg.port = (config.port as u16).into(); + cfg.connect_timeout = config.timeout.map(|t| t.into_inner()); + cfg.options = config.options; + cfg.manager = Some(ManagerConfig { + recycling_method: match config.pool_recycling_method { + PostgreSqlRecyclingMethod::Fast => RecyclingMethod::Fast, + PostgreSqlRecyclingMethod::Verified => RecyclingMethod::Verified, + PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean, + }, + }); + if let Some(max_conn) = config.pool_max_connections { + cfg.pool = PoolConfig::new(max_conn as usize).into(); + } + + let primary_pool = if config.use_tls { + cfg.create_pool( + Some(Runtime::Tokio1), + MakeRustlsConnect::new(rustls_client_config(config.allow_invalid_certs)?), + ) + } else { + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + } + .map_err(|e| format!("Failed to create connection pool: {e}"))?; + let ts_configs = discover_ts_configs(&primary_pool).await; + + let mut replicas = vec![]; + for replica in config.read_replicas { + let mut cfg = cfg.clone(); + cfg.dbname = replica.database.into(); + cfg.host = replica.host.into(); + cfg.user = replica.auth_username; + cfg.password = replica.auth_secret.secret().await?.map(|v| v.into_owned()); + cfg.port = (replica.port as u16).into(); + cfg.options = replica.options; + replicas.push(Store::PostgreSQL(Arc::new(PostgresStore { + conn_pool: if config.use_tls { + cfg.create_pool( + Some(Runtime::Tokio1), + MakeRustlsConnect::new(rustls_client_config(config.allow_invalid_certs)?), + ) + } else { + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + } + .map_err(|e| format!("Failed to create connection pool: {e}"))?, + ts_configs: ts_configs.clone(), + }))); + } + + let primary = Store::PostgreSQL(Arc::new(PostgresStore { + conn_pool: primary_pool, + ts_configs, + })); + + + Ok(primary) + } + + pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + + for table in [ + SUBSPACE_ACL, + SUBSPACE_TASK_QUEUE, + SUBSPACE_DELETED_ITEMS, + SUBSPACE_SPAM_SAMPLES, + SUBSPACE_BLOB_LINK, + SUBSPACE_IN_MEMORY_VALUE, + SUBSPACE_PROPERTY, + SUBSPACE_REGISTRY, + SUBSPACE_REGISTRY_PK, + SUBSPACE_QUEUE_MESSAGE, + SUBSPACE_QUEUE_EVENT, + SUBSPACE_REPORT_OUT, + SUBSPACE_REPORT_IN, + SUBSPACE_LOGS, + SUBSPACE_BLOBS, + SUBSPACE_DIRECTORY, + SUBSPACE_TELEMETRY_SPAN, + SUBSPACE_TELEMETRY_METRIC, + ] { + let table = char::from(table); + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k BYTEA PRIMARY KEY, + v BYTEA NOT NULL + )" + ), + &[], + ) + .await + .map_err(into_error)?; + } + + for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] { + let table = char::from(table); + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k BYTEA PRIMARY KEY + )" + ), + &[], + ) + .await + .map_err(into_error)?; + } + + for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] { + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {} ( + k BYTEA PRIMARY KEY, + v BIGINT NOT NULL DEFAULT 0 + )", + char::from(table) + ), + &[], + ) + .await + .map_err(into_error)?; + } + + Ok(()) + } + + pub(crate) async fn create_search_tables(&self) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + + create_search_tables::(&conn).await?; + create_search_tables::(&conn).await?; + create_search_tables::(&conn).await?; + //create_search_tables::(&conn).await?; + create_search_tables::(&conn).await?; + + Ok(()) + } +} + +async fn create_search_tables( + conn: &Object, +) -> trc::Result<()> { + let table_name = T::index().psql_table(); + let mut query = format!("CREATE TABLE IF NOT EXISTS {} (", table_name); + + // Add primary key columns + let pkeys = T::primary_keys(); + for pkey in pkeys { + query.push_str(&format!("{} {}, ", pkey.column(), pkey.column_type())); + } + + // Add other columns + for field in T::all_fields() { + query.push_str(&format!("{} {}", field.column(), field.column_type())); + if let Some(sort_type) = field.sort_column_type() { + query.push_str(&format!(", {} {}", field.sort_column().unwrap(), sort_type)); + } + query.push_str(", "); + } + + // Add primary key constraint + query.push_str("PRIMARY KEY ("); + for (i, pkey) in pkeys.iter().enumerate() { + if i > 0 { + query.push_str(", "); + } + query.push_str(pkey.column()); + } + query.push_str("))"); + + conn.execute(&query, &[]).await.map_err(into_error)?; + + // Create indexes + for field in T::all_fields() { + if field.is_text() || field.is_json() { + let column_name = field.column(); + let create_index_query = format!( + "CREATE INDEX IF NOT EXISTS gin_{table_name}_{column_name} ON {table_name} USING GIN({column_name})", + ); + conn.execute(&create_index_query, &[]) + .await + .map_err(into_error)?; + } + + if field.is_indexed() { + let column_name = field.sort_column().unwrap_or(field.column()); + let create_index_query = format!( + "CREATE INDEX IF NOT EXISTS idx_{table_name}_{column_name} ON {table_name}({column_name})", + ); + conn.execute(&create_index_query, &[]) + .await + .map_err(into_error)?; + } + } + + Ok(()) +} + +async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> { + let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]); + + match probe_ts_configs(pool).await { + Ok(available) => { + for name in available { + if let Some(config) = PG_LANGS.iter().copied().find(|config| *config == name) { + ts_configs.insert(config); + } + } + } + Err(err) => { + trc::event!( + Store(trc::StoreEvent::PostgresqlError), + Details = "Failed to query pg_ts_config, assuming english only", + Reason = err.to_string(), + ); + } + } + + ts_configs +} + +async fn probe_ts_configs(pool: &Pool) -> trc::Result> { + let conn = pool.get().await.map_err(into_pool_error)?; + + conn.query("SELECT cfgname::text FROM pg_ts_config", &[]) + .await + .map_err(into_error)? + .into_iter() + .map(|row| row.try_get::<_, String>(0).map_err(into_error)) + .collect() +} diff --git a/crates/store/src/backend/postgres/mod.rs b/crates/store/src/backend/postgres/mod.rs new file mode 100644 index 0000000..39d5040 --- /dev/null +++ b/crates/store/src/backend/postgres/mod.rs @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + search::{ + CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField, + TracingSearchField, + }, + write::SearchIndex, +}; +use ahash::AHashSet; +use deadpool_postgres::Pool; +use tokio_postgres::error::SqlState; + +pub mod blob; +pub mod lookup; +pub mod main; +pub mod read; +pub mod search; +pub mod tls; +pub mod write; + +pub struct PostgresStore { + pub(crate) conn_pool: Pool, + pub(crate) ts_configs: AHashSet<&'static str>, +} + +#[inline(always)] +fn into_error(err: tokio_postgres::error::Error) -> trc::Error { + let mut local_err = trc::StoreEvent::PostgresqlError.reason(error_chain(&err)); + if let Some(db_err) = err.as_db_error() { + local_err = local_err.code(db_err.code().code().to_string()); + if let Some(detail) = db_err.detail() { + local_err = local_err.details(detail.to_string()); + } + + if let Some(hint) = db_err.hint() { + local_err = local_err.caused_by(hint.to_string()); + } + } + local_err +} + +fn error_chain(err: &(dyn std::error::Error + 'static)) -> String { + let mut message = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + let cause_message = cause.to_string(); + if !cause_message.is_empty() && !message.ends_with(&cause_message) { + message.push_str(": "); + message.push_str(&cause_message); + } + source = cause.source(); + } + message +} + +pub(crate) const DELETE_CHUNK_SIZE: usize = 1000; +pub(crate) const MIN_DELETE_CHUNK_SIZE: usize = 10; + +#[inline(always)] +pub(crate) fn is_timeout_error(err: &tokio_postgres::Error) -> bool { + err.code().is_some_and(|code| { + *code == SqlState::QUERY_CANCELED + || *code == SqlState::IDLE_IN_TRANSACTION_SESSION_TIMEOUT + || *code == SqlState::LOCK_NOT_AVAILABLE + }) +} + +#[inline(always)] +fn into_pool_error(err: deadpool_postgres::PoolError) -> trc::Error { + match err { + deadpool_postgres::PoolError::Backend(err) => into_error(err), + err => trc::StoreEvent::PostgresqlError.reason(error_chain(&err)), + } +} + +impl SearchIndex { + pub fn psql_table(&self) -> &'static str { + match self { + SearchIndex::Email => "s_email", + SearchIndex::Calendar => "s_cal", + SearchIndex::Contacts => "s_card", + SearchIndex::File => "s_file", + SearchIndex::Tracing => "s_trace", + SearchIndex::InMemory => "", + } + } +} + +trait PsqlSearchField { + fn column(&self) -> &'static str; + fn column_type(&self) -> &'static str; + fn sort_column_type(&self) -> Option<&'static str>; + fn sort_column(&self) -> Option<&'static str>; +} + +impl PsqlSearchField for EmailSearchField { + fn column(&self) -> &'static str { + match self { + EmailSearchField::From => "fadr", + EmailSearchField::To => "tadr", + EmailSearchField::Cc => "cc", + EmailSearchField::Bcc => "bcc", + EmailSearchField::Subject => "subj", + EmailSearchField::Body => "body", + EmailSearchField::Attachment => "atta", + EmailSearchField::ReceivedAt => "rcvd", + EmailSearchField::SentAt => "sent", + EmailSearchField::Size => "size", + EmailSearchField::HasAttachment => "hatt", + EmailSearchField::Headers => "hdrs", + } + } + + fn column_type(&self) -> &'static str { + match self { + EmailSearchField::ReceivedAt | EmailSearchField::SentAt => "BIGINT", + EmailSearchField::Size => "INTEGER", + EmailSearchField::HasAttachment => "BOOLEAN", + EmailSearchField::Headers => "JSONB", + _ => "TSVECTOR", + } + } + + fn sort_column_type(&self) -> Option<&'static str> { + match self { + EmailSearchField::From | EmailSearchField::To | EmailSearchField::Subject => { + Some("TEXT") + } + #[cfg(feature = "test_mode")] + EmailSearchField::Cc | EmailSearchField::Bcc => Some("TEXT"), + _ => None, + } + } + + fn sort_column(&self) -> Option<&'static str> { + match self { + EmailSearchField::From => Some("s_fr"), + EmailSearchField::To => Some("s_to"), + EmailSearchField::Subject => Some("s_sj"), + #[cfg(feature = "test_mode")] + EmailSearchField::Bcc => Some("s_bc"), + #[cfg(feature = "test_mode")] + EmailSearchField::Cc => Some("s_cc"), + _ => None, + } + } +} + +impl PsqlSearchField for CalendarSearchField { + fn column(&self) -> &'static str { + match self { + CalendarSearchField::Title => "titl", + CalendarSearchField::Description => "dscd", + CalendarSearchField::Location => "locn", + CalendarSearchField::Owner => "ownr", + CalendarSearchField::Attendee => "atnd", + CalendarSearchField::Start => "strt", + CalendarSearchField::Uid => "uid", + } + } + + fn column_type(&self) -> &'static str { + match self { + CalendarSearchField::Start => "BIGINT", + CalendarSearchField::Uid => "TEXT", + _ => "TSVECTOR", + } + } + + fn sort_column_type(&self) -> Option<&'static str> { + None + } + + fn sort_column(&self) -> Option<&'static str> { + None + } +} + +impl PsqlSearchField for ContactSearchField { + fn column(&self) -> &'static str { + match self { + ContactSearchField::Member => "mmbr", + ContactSearchField::Name => "name", + ContactSearchField::Nickname => "nick", + ContactSearchField::Organization => "orgn", + ContactSearchField::Email => "eml", + ContactSearchField::Phone => "phon", + ContactSearchField::OnlineService => "olsv", + ContactSearchField::Address => "addr", + ContactSearchField::Note => "note", + ContactSearchField::Kind => "kind", + ContactSearchField::Uid => "uid", + } + } + + fn column_type(&self) -> &'static str { + match self { + ContactSearchField::Kind | ContactSearchField::Uid => "TEXT", + _ => "TSVECTOR", + } + } + + fn sort_column_type(&self) -> Option<&'static str> { + None + } + + fn sort_column(&self) -> Option<&'static str> { + None + } +} + +impl PsqlSearchField for FileSearchField { + fn column(&self) -> &'static str { + match self { + FileSearchField::Name => "name", + FileSearchField::Content => "body", + } + } + + fn column_type(&self) -> &'static str { + "TSVECTOR" + } + + fn sort_column_type(&self) -> Option<&'static str> { + None + } + + fn sort_column(&self) -> Option<&'static str> { + None + } +} +impl PsqlSearchField for TracingSearchField { + fn column(&self) -> &'static str { + match self { + TracingSearchField::QueueId => "qid", + TracingSearchField::EventType => "etyp", + TracingSearchField::Keywords => "kwds", + } + } + + fn column_type(&self) -> &'static str { + match self { + TracingSearchField::EventType => "BIGINT", + TracingSearchField::QueueId => "BIGINT", + TracingSearchField::Keywords => "TSVECTOR", + } + } + + fn sort_column_type(&self) -> Option<&'static str> { + None + } + + fn sort_column(&self) -> Option<&'static str> { + None + } +} + +impl PsqlSearchField for SearchField { + fn column(&self) -> &'static str { + match self { + SearchField::AccountId => "accid", + SearchField::DocumentId => "docid", + SearchField::Id => "id", + SearchField::Email(field) => field.column(), + SearchField::Calendar(field) => field.column(), + SearchField::Contact(field) => field.column(), + SearchField::File(field) => field.column(), + SearchField::Tracing(field) => field.column(), + } + } + + fn column_type(&self) -> &'static str { + match self { + SearchField::AccountId => "INTEGER NOT NULL", + SearchField::DocumentId => "INTEGER NOT NULL", + SearchField::Id => "BIGINT NOT NULL", + SearchField::Email(field) => field.column_type(), + SearchField::Calendar(field) => field.column_type(), + SearchField::Contact(field) => field.column_type(), + SearchField::File(field) => field.column_type(), + SearchField::Tracing(field) => field.column_type(), + } + } + + fn sort_column_type(&self) -> Option<&'static str> { + match self { + SearchField::Email(field) => field.sort_column_type(), + SearchField::Calendar(field) => field.sort_column_type(), + SearchField::Contact(field) => field.sort_column_type(), + SearchField::File(field) => field.sort_column_type(), + SearchField::Tracing(field) => field.sort_column_type(), + SearchField::AccountId | SearchField::DocumentId | SearchField::Id => None, + } + } + + fn sort_column(&self) -> Option<&'static str> { + match self { + SearchField::Email(field) => field.sort_column(), + SearchField::Calendar(field) => field.sort_column(), + SearchField::Contact(field) => field.sort_column(), + SearchField::File(field) => field.sort_column(), + SearchField::Tracing(field) => field.sort_column(), + SearchField::AccountId | SearchField::DocumentId | SearchField::Id => None, + } + } +} diff --git a/crates/store/src/backend/postgres/read.rs b/crates/store/src/backend/postgres/read.rs new file mode 100644 index 0000000..813d853 --- /dev/null +++ b/crates/store/src/backend/postgres/read.rs @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{PostgresStore, into_error, is_timeout_error}; +use crate::{ + Deserialize, IterateParams, Key, ValueKey, backend::postgres::into_pool_error, + write::ValueClass, +}; +use futures::{TryStreamExt, pin_mut}; + +impl PostgresStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached(&format!( + "SELECT v FROM {} WHERE k = $1", + char::from(key.subspace()) + )) + .await + .map_err(into_error)?; + let key = key.serialize(0); + conn.query_opt(&s, &[&key]) + .await + .map_err(into_error) + .and_then(|r| { + if let Some(r) = r { + Ok(Some(U::deserialize_with_key(&key, r.get(0))?)) + } else { + Ok(None) + } + }) + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached(&format!( + "SELECT 1 FROM {} WHERE k = $1", + char::from(key.subspace()) + )) + .await + .map_err(into_error)?; + let key = key.serialize(0); + conn.query_opt(&s, &[&key]) + .await + .map_err(into_error) + .map(|r| r.is_some()) + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let table = char::from(params.begin.subspace()); + let begin = params.begin.serialize(0); + let end = params.end.serialize(0); + let keys = if params.values { "k, v" } else { "k" }; + + let s = conn + .prepare_cached(&match (params.first, params.ascending) { + (true, true) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC LIMIT 1" + ) + } + (true, false) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC LIMIT 1" + ) + } + (false, true) => { + format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC") + } + (false, false) => { + format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC") + } + }) + .await.map_err(into_error)?; + let mut from = begin; + let mut to = end; + let mut resume_key: Option> = None; + + loop { + let mut last_key = None; + let mut timed_out = false; + + { + let rows = conn + .query_raw(&s, &[&from, &to]) + .await + .map_err(into_error)?; + + pin_mut!(rows); + + loop { + match rows.try_next().await { + Ok(Some(row)) => { + let key = row.try_get::<_, &[u8]>(0).map_err(into_error)?; + let value = if params.values { + row.try_get::<_, &[u8]>(1).map_err(into_error)? + } else { + b"".as_slice() + }; + + if resume_key.take().is_some_and(|resumed| resumed == key) { + continue; + } + + if !cb(key, value)? { + return Ok(()); + } + + last_key = Some(key.to_vec()); + } + Ok(None) => break, + Err(err) => { + if params.first || last_key.is_none() || !is_timeout_error(&err) { + return Err(into_error(err)); + } + timed_out = true; + break; + } + } + } + } + + match last_key { + Some(last_key) if timed_out => { + if params.ascending { + from.clone_from(&last_key); + } else { + to.clone_from(&last_key); + } + resume_key = Some(last_key); + } + _ => return Ok(()), + } + } + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into(); + let table = char::from(key.subspace()); + let key = key.serialize(0); + + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached(&format!("SELECT v FROM {table} WHERE k = $1")) + .await + .map_err(into_error)?; + match conn.query_opt(&s, &[&key]).await { + Ok(Some(row)) => row.try_get(0).map_err(into_error), + Ok(None) => Ok(0), + Err(e) => Err(into_error(e)), + } + } +} diff --git a/crates/store/src/backend/postgres/search.rs b/crates/store/src/backend/postgres/search.rs new file mode 100644 index 0000000..ca083e4 --- /dev/null +++ b/crates/store/src/backend/postgres/search.rs @@ -0,0 +1,571 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + backend::postgres::{ + DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, into_error, + into_pool_error, is_timeout_error, + }, + search::{ + IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator, + SearchQuery, SearchValue, + }, + write::SearchIndex, +}; +use nlp::language::Language; +use std::fmt::Write; +use tokio_postgres::{ + IsolationLevel, + types::{FromSql, ToSql, Type, WrongType}, +}; + +impl PostgresStore { + fn ts_config(&self, language: &Language) -> &'static str { + pg_lang(language) + .filter(|config| self.ts_configs.contains(config)) + .unwrap_or(PG_UNSTEMMED_LANG) + } + + pub async fn index(&self, documents: Vec) -> trc::Result<()> { + let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let trx = conn + .build_transaction() + .isolation_level(IsolationLevel::ReadCommitted) + .start() + .await + .map_err(into_error)?; + + for document in documents { + let index = document.index; + let primary_keys = index.primary_keys(); + let all_fields = index.all_fields(); + let fields = document.fields; + let mut values = Vec::with_capacity(fields.len() + 2); + let mut query = format!("INSERT INTO {} (", index.psql_table()); + + for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { + if i > 0 { + query.push(','); + } + query.push_str(field.column()); + + if let Some(sort_column) = field.sort_column() { + query.push(','); + query.push_str(sort_column); + } + } + + query.push_str(") VALUES ("); + + for (i, field) in primary_keys.iter().chain(all_fields).enumerate() { + if i > 0 { + query.push(','); + } + + if let Some(value) = fields.get(field) { + let value_ref = format!("${}", values.len() + 1); + let (text_len, language) = if let SearchValue::Text { value, language } = value + { + (value.len(), self.ts_config(language)) + } else { + (0, PG_UNSTEMMED_LANG) + }; + + if field.is_text() { + let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})"); + } else if text_len > 512 { + query.push_str("left("); + query.push_str(&value_ref); + query.push_str(",512)"); + } else { + query.push_str(&value_ref); + } + + if field.sort_column().is_some() { + if text_len > 255 { + query.push_str(",left("); + query.push_str(&value_ref); + query.push_str(",255)"); + } else { + query.push(','); + query.push_str(&value_ref); + } + } + + values.push(value as &(dyn ToSql + Sync)); + } else { + query.push_str("NULL"); + if field.sort_column().is_some() { + query.push_str(",NULL"); + } + } + } + + query.push_str(") ON CONFLICT ("); + for (i, pkey) in primary_keys.iter().enumerate() { + if i > 0 { + query.push(','); + } + query.push_str(pkey.column()); + } + query.push_str(") DO UPDATE SET "); + for (i, field) in all_fields.iter().enumerate() { + if i > 0 { + query.push(','); + } + let column = field.column(); + let _ = write!(&mut query, "{column} = EXCLUDED.{column}"); + } + + trx.execute(&query, &values).await.map_err(into_error)?; + } + + trx.commit().await.map_err(into_error) + } + + pub async fn query( + &self, + index: SearchIndex, + filters: &[SearchFilter], + sort: &[SearchComparator], + ) -> trc::Result> { + let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table()); + let params = self.build_filter(&mut query, filters); + if !sort.is_empty() { + build_sort(&mut query, sort); + } + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn.prepare_cached(&query).await.map_err(into_error)?; + + conn.query(&s, params.as_slice()) + .await + .and_then(|rows| { + rows.into_iter() + .map(|row| row.try_get::<_, DocId>(0).map(|v| R::from_u64(v.0))) + .collect::, _>>() + }) + .map_err(into_error) + } + + pub async fn unindex(&self, filter: SearchQuery) -> trc::Result { + debug_assert!(!filter.filters.is_empty()); + let table = filter.index.psql_table(); + let mut where_clause = String::new(); + let params = self.build_filter(&mut where_clause, &filter.filters); + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let s = conn + .prepare_cached(&format!("DELETE FROM {table}{where_clause}")) + .await + .map_err(into_error)?; + + match conn.execute(&s, params.as_slice()).await { + Ok(deleted) => return Ok(deleted), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let mut chunk_size = DELETE_CHUNK_SIZE; + let mut deleted = 0; + + loop { + let s = conn + .prepare_cached(&format!( + "DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table}{where_clause} LIMIT {chunk_size})" + )) + .await + .map_err(into_error)?; + + loop { + match conn.execute(&s, params.as_slice()).await { + Ok(0) => return Ok(deleted), + Ok(affected) => deleted += affected, + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + } + } + } + + fn build_filter<'x>( + &self, + query: &mut String, + filters: &'x [SearchFilter], + ) -> Vec<&'x (dyn ToSql + Sync)> { + if filters.is_empty() { + return Vec::new(); + } + query.push_str(" WHERE "); + let mut operator_stack = Vec::new(); + let mut operator = &SearchFilter::And; + let mut is_first = true; + let mut values = Vec::new(); + + for filter in filters { + match filter { + SearchFilter::Operator { field, op, value } => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + let value_pos = values.len() + 1; + if field.is_text() + && matches!(op, SearchOperator::Equal | SearchOperator::Contains) + { + query.push_str(field.column()); + query.push(' '); + + let language = match &value { + SearchValue::Text { language, .. } => *language, + _ => Language::None, + }; + let config = self.ts_config(&language); + let method = match op { + SearchOperator::Equal => "phraseto_tsquery", + _ => "plainto_tsquery", + }; + + if matches!(language, Language::None) { + let _ = write!(query, "@@ {method}('{config}', ${value_pos})"); + } else { + let _ = write!(query, "@@ ({method}('{config}', ${value_pos})"); + for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] { + if fallback != config && self.ts_configs.contains(fallback) { + let _ = + write!(query, " || {method}('{fallback}', ${value_pos})"); + } + } + query.push(')'); + } + values.push(value as &(dyn ToSql + Sync)); + } else if let SearchValue::KeyValues(kv) = value { + query.push_str(field.column()); + query.push(' '); + + let (key, value) = kv.iter().next().unwrap(); + values.push(key as &(dyn ToSql + Sync)); + + if !value.is_empty() { + let _ = write!(query, "->> ${value_pos} "); + op.write_pqsql(query, values.len() + 1); + values.push(value as &(dyn ToSql + Sync)); + } else { + let _ = write!(query, " ? ${value_pos}"); + } + } else { + query.push_str(field.sort_column().unwrap_or(field.column())); + query.push(' '); + + op.write_pqsql(query, value_pos); + values.push(value as &(dyn ToSql + Sync)); + } + } + SearchFilter::And | SearchFilter::Or => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + + operator_stack.push((operator, is_first)); + operator = filter; + is_first = true; + query.push('('); + } + SearchFilter::Not => { + if !is_first { + match operator { + SearchFilter::And => query.push_str(" AND "), + SearchFilter::Or => query.push_str(" OR "), + _ => (), + } + } else { + is_first = false; + } + + operator_stack.push((operator, is_first)); + operator = &SearchFilter::And; + is_first = true; + query.push_str("NOT ("); + } + SearchFilter::End => { + let p = operator_stack.pop().unwrap_or((&SearchFilter::And, true)); + operator = p.0; + is_first = p.1; + query.push(')'); + } + SearchFilter::DocumentSet(_) => { + debug_assert!( + false, + "DocumentSet filters are not supported in Postgres backend" + ) + } + } + } + + values + } +} + +fn build_sort(query: &mut String, sort: &[SearchComparator]) { + query.push_str(" ORDER BY "); + for (i, comparator) in sort.iter().enumerate() { + if i > 0 { + query.push_str(", "); + } + match comparator { + SearchComparator::Field { field, ascending } => { + query.push_str(field.sort_column().unwrap_or(field.column())); + if *ascending { + query.push_str(" ASC"); + } else { + query.push_str(" DESC"); + } + } + SearchComparator::DocumentSet { .. } | SearchComparator::SortedSet { .. } => { + debug_assert!( + false, + "DocumentSet and SortedSet comparators are not supported " + ); + } + } + } +} + +impl ToSql for SearchValue { + fn to_sql( + &self, + ty: &tokio_postgres::types::Type, + out: &mut bytes::BytesMut, + ) -> Result> + where + Self: Sized, + { + match self { + SearchValue::Text { value, .. } => { + // Truncate large text fields to avoid Postgres errors (see https://www.postgresql.org/docs/current/textsearch-limitations.html) + + if value.len() > 650_000 { + (&value[..value.floor_char_boundary(650_000)]).to_sql(ty, out) + } else { + value.to_sql(ty, out) + } + } + SearchValue::Int(v) => match *ty { + Type::INT4 => (*v as i32).to_sql(ty, out), + _ => v.to_sql(ty, out), + }, + SearchValue::Uint(v) => match *ty { + Type::INT4 => (*v as i32).to_sql(ty, out), + _ => (*v as i64).to_sql(ty, out), + }, + SearchValue::Boolean(v) => v.to_sql(ty, out), + SearchValue::KeyValues(kv) => { + serde_json::to_value(kv).unwrap_or_default().to_sql(ty, out) + } + } + } + + fn accepts(_: &tokio_postgres::types::Type) -> bool + where + Self: Sized, + { + true + } + + fn to_sql_checked( + &self, + ty: &tokio_postgres::types::Type, + out: &mut bytes::BytesMut, + ) -> Result> { + match self { + SearchValue::Text { value, .. } => { + // Truncate large text fields to avoid Postgres errors (see https://www.postgresql.org/docs/current/textsearch-limitations.html) + + if value.len() > 650_000 { + (&value[..value.floor_char_boundary(650_000)]).to_sql_checked(ty, out) + } else { + value.to_sql_checked(ty, out) + } + } + SearchValue::Int(v) => match *ty { + Type::INT4 => (*v as i32).to_sql_checked(ty, out), + _ => v.to_sql_checked(ty, out), + }, + SearchValue::Uint(v) => match *ty { + Type::INT4 => (*v as i32).to_sql_checked(ty, out), + _ => (*v as i64).to_sql_checked(ty, out), + }, + SearchValue::Boolean(v) => v.to_sql_checked(ty, out), + SearchValue::KeyValues(kv) => serde_json::to_value(kv) + .unwrap_or_default() + .to_sql_checked(ty, out), + } + } +} + +struct DocId(u64); + +impl FromSql<'_> for DocId { + fn from_sql( + ty: &tokio_postgres::types::Type, + raw: &'_ [u8], + ) -> Result> { + match ty { + &Type::INT4 => i32::from_sql(ty, raw).map(|v| DocId(v as u64)), + &Type::INT8 | &Type::OID => i64::from_sql(ty, raw).map(|v| DocId(v as u64)), + _ => Err(Box::new(WrongType::new::(ty.clone()))), + } + } + + fn accepts(typ: &Type) -> bool { + matches!(typ, &Type::INT4 | &Type::INT8 | &Type::OID) + } +} + +impl SearchOperator { + fn write_pqsql(&self, query: &mut String, value_pos: usize) { + match self { + SearchOperator::LowerThan => { + let _ = write!(query, "< ${value_pos}"); + } + SearchOperator::LowerEqualThan => { + let _ = write!(query, "<= ${value_pos}"); + } + SearchOperator::GreaterThan => { + let _ = write!(query, "> ${value_pos}"); + } + SearchOperator::GreaterEqualThan => { + let _ = write!(query, ">= ${value_pos}"); + } + SearchOperator::Equal => { + let _ = write!(query, "= ${value_pos}"); + } + SearchOperator::Contains => { + let _ = write!(query, "LIKE '%' || ${value_pos} || '%'"); + } + } + } +} + +pub(super) const PG_FALLBACK_LANG: &str = "english"; +pub(super) const PG_UNSTEMMED_LANG: &str = "simple"; + +pub(super) const PG_LANGS: &[&str] = &[ + "arabic", + "armenian", + "catalan", + "danish", + "dutch", + "english", + "finnish", + "french", + "german", + "greek", + "hindi", + "hungarian", + "indonesian", + "italian", + "lithuanian", + "nepali", + "norwegian", + "portuguese", + "romanian", + "russian", + "serbian", + "spanish", + "swedish", + "tamil", + "turkish", + "yiddish", +]; + +#[inline(always)] +fn pg_lang(lang: &Language) -> Option<&'static str> { + match lang { + Language::Esperanto => None, + Language::English => Some("english"), + Language::Russian => Some("russian"), + Language::Mandarin => None, + Language::Spanish => Some("spanish"), + Language::Portuguese => Some("portuguese"), + Language::Italian => Some("italian"), + Language::Bengali => None, + Language::French => Some("french"), + Language::German => Some("german"), + Language::Ukrainian => None, + Language::Georgian => None, + Language::Arabic => Some("arabic"), + Language::Hindi => Some("hindi"), + Language::Japanese => None, + Language::Hebrew => None, + Language::Yiddish => Some("yiddish"), + Language::Polish => None, + Language::Amharic => None, + Language::Javanese => None, + Language::Korean => None, + Language::Bokmal => Some("norwegian"), // Norwegian covers Bokmål + Language::Danish => Some("danish"), + Language::Swedish => Some("swedish"), + Language::Finnish => Some("finnish"), + Language::Turkish => Some("turkish"), + Language::Dutch => Some("dutch"), + Language::Hungarian => Some("hungarian"), + Language::Czech => None, + Language::Greek => Some("greek"), + Language::Bulgarian => None, + Language::Belarusian => None, + Language::Marathi => None, + Language::Kannada => None, + Language::Romanian => Some("romanian"), + Language::Slovene => None, + Language::Croatian => None, + Language::Serbian => Some("serbian"), + Language::Macedonian => None, + Language::Lithuanian => Some("lithuanian"), + Language::Latvian => None, + Language::Estonian => None, + Language::Tamil => Some("tamil"), + Language::Vietnamese => None, + Language::Urdu => None, + Language::Thai => None, + Language::Gujarati => None, + Language::Uzbek => None, + Language::Punjabi => None, + Language::Azerbaijani => None, + Language::Indonesian => Some("indonesian"), + Language::Telugu => None, + Language::Persian => None, + Language::Malayalam => None, + Language::Oriya => None, + Language::Burmese => None, + Language::Nepali => Some("nepali"), + Language::Sinhalese => None, + Language::Khmer => None, + Language::Turkmen => None, + Language::Akan => None, + Language::Zulu => None, + Language::Shona => None, + Language::Afrikaans => None, + Language::Latin => None, + Language::Slovak => None, + Language::Catalan => Some("catalan"), + Language::Tagalog => None, + Language::Armenian => Some("armenian"), + Language::Unknown | Language::None => None, + } +} diff --git a/crates/store/src/backend/postgres/tls.rs b/crates/store/src/backend/postgres/tls.rs new file mode 100644 index 0000000..4ae056e --- /dev/null +++ b/crates/store/src/backend/postgres/tls.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// Credits: https://github.com/jbg/tokio-postgres-rustls + +use std::{ + convert::TryFrom, + future::Future, + io, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use aws_lc_rs::digest; +use futures::future::{FutureExt, TryFutureExt}; +use rustls::ClientConfig; +use rustls_pki_types::ServerName; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_postgres::tls::{ChannelBinding, MakeTlsConnect, TlsConnect}; +use tokio_rustls::{TlsConnector, client::TlsStream}; +use x509_parser::{ + asn1_rs::oid, + oid_registry::{ + OID_HASH_SHA1, OID_MD5_WITH_RSA, OID_NIST_HASH_SHA256, OID_NIST_HASH_SHA384, + OID_NIST_HASH_SHA512, OID_PKCS1_MD5WITHRSAENC, OID_PKCS1_RSASSAPSS, OID_PKCS1_SHA1WITHRSA, + OID_PKCS1_SHA224WITHRSA, OID_PKCS1_SHA256WITHRSA, OID_PKCS1_SHA384WITHRSA, + OID_PKCS1_SHA512WITHRSA, OID_SHA1_WITH_RSA, OID_SIG_DSA_WITH_SHA1, + OID_SIG_ECDSA_WITH_SHA224, OID_SIG_ECDSA_WITH_SHA256, OID_SIG_ECDSA_WITH_SHA384, + OID_SIG_ECDSA_WITH_SHA512, + }, + parse_x509_certificate, + prelude::X509Certificate, + signature_algorithm::RsaSsaPssParams, +}; + +#[derive(Clone)] +pub struct MakeRustlsConnect { + config: Arc, +} + +impl MakeRustlsConnect { + pub fn new(config: ClientConfig) -> Self { + Self { + config: Arc::new(config), + } + } +} + +impl MakeTlsConnect for MakeRustlsConnect +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + type Stream = RustlsStream; + type TlsConnect = RustlsConnect; + type Error = io::Error; + + fn make_tls_connect(&mut self, hostname: &str) -> io::Result { + ServerName::try_from(hostname.to_string()) + .map(|dns_name| { + RustlsConnect(Some(RustlsConnectData { + hostname: dns_name, + connector: Arc::clone(&self.config).into(), + })) + }) + .or(Ok(RustlsConnect(None))) + } +} + +pub struct RustlsConnect(Option); + +struct RustlsConnectData { + hostname: ServerName<'static>, + connector: TlsConnector, +} + +impl TlsConnect for RustlsConnect +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + type Stream = RustlsStream; + type Error = io::Error; + type Future = Pin>> + Send>>; + + fn connect(self, stream: S) -> Self::Future { + match self.0 { + None => Box::pin(core::future::ready(Err(io::ErrorKind::InvalidInput.into()))), + Some(c) => c + .connector + .connect(c.hostname, stream) + .map_ok(|s| RustlsStream(Box::pin(s))) + .boxed(), + } + } +} + +pub struct RustlsStream(Pin>>); + +fn cb_digest_for_cert(cert: &X509Certificate<'_>) -> Option<&'static digest::Algorithm> { + let sig_alg = cert.signature_algorithm.oid(); + // Signature algorithms that use a digest should use the same digest for channel binding: + if sig_alg == &OID_PKCS1_SHA512WITHRSA || sig_alg == &OID_SIG_ECDSA_WITH_SHA512 { + Some(&digest::SHA512) + } else if sig_alg == &OID_PKCS1_SHA384WITHRSA || sig_alg == &OID_SIG_ECDSA_WITH_SHA384 { + Some(&digest::SHA384) + } else if sig_alg == &OID_PKCS1_MD5WITHRSAENC + || sig_alg == &OID_MD5_WITH_RSA + || sig_alg == &OID_PKCS1_SHA1WITHRSA + || sig_alg == &OID_SHA1_WITH_RSA + || sig_alg == &OID_SIG_DSA_WITH_SHA1 + || sig_alg == &OID_PKCS1_SHA256WITHRSA + || sig_alg == &OID_SIG_ECDSA_WITH_SHA256 + { + // ...apart from MD5 or SHA1, which use SHA256 for channel binding, as per RFC 5929 section 4.1: + Some(&digest::SHA256) + } else if sig_alg == &OID_PKCS1_SHA224WITHRSA || sig_alg == &OID_SIG_ECDSA_WITH_SHA224 { + Some(&digest::SHA224) + } else if sig_alg == &OID_PKCS1_RSASSAPSS { + // For RSASSA-PSS, the hash algorithm is specified in the parameters of the signature algorithm: + let params_any = cert.signature_algorithm.parameters()?; + let pss = RsaSsaPssParams::try_from(params_any).ok()?; + let alg = pss.hash_algorithm_oid(); + if alg == &OID_NIST_HASH_SHA512 { + Some(&digest::SHA512) + } else if alg == &OID_NIST_HASH_SHA384 { + Some(&digest::SHA384) + } else if alg == &OID_NIST_HASH_SHA256 || alg == &OID_HASH_SHA1 { + Some(&digest::SHA256) + } else if alg == &oid!(2.16.840.1.101.3.4.2.4) { + // id-sha224 from RFC 4055 ^ + Some(&digest::SHA224) + } else { + None + } + } else { + None + } +} + +impl tokio_postgres::tls::TlsStream for RustlsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn channel_binding(&self) -> ChannelBinding { + let (_, session) = self.0.get_ref(); + match session.peer_certificates() { + Some(certs) if !certs.is_empty() => match parse_x509_certificate(certs[0].as_ref()) { + Ok((_, cert)) => { + if let Some(digest_alg) = cb_digest_for_cert(&cert) { + let dgst = digest::digest(digest_alg, certs[0].as_ref()); + ChannelBinding::tls_server_end_point(dgst.as_ref().into()) + } else { + ChannelBinding::none() + } + } + Err(_) => ChannelBinding::none(), + }, + _ => ChannelBinding::none(), + } + } +} + +impl AsyncRead for RustlsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.0.as_mut().poll_read(cx, buf) + } +} + +impl AsyncWrite for RustlsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context, + buf: &[u8], + ) -> Poll> { + self.0.as_mut().poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { + self.0.as_mut().poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { + self.0.as_mut().poll_shutdown(cx) + } +} diff --git a/crates/store/src/backend/postgres/write.rs b/crates/store/src/backend/postgres/write.rs new file mode 100644 index 0000000..d91e7f6 --- /dev/null +++ b/crates/store/src/backend/postgres/write.rs @@ -0,0 +1,543 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{PostgresStore, into_error, is_timeout_error}; +use crate::{ + IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA, + SUBSPACE_REGISTRY_IDX, + backend::postgres::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, into_pool_error}, + write::{ + AssignedIds, Batch, MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME, MergeResult, Operation, + ValueClass, ValueOp, + }, +}; +use ahash::AHashMap; +use deadpool_postgres::Object; +use rand::RngExt; +use std::time::{Duration, Instant}; +use tokio_postgres::{IsolationLevel, error::SqlState}; + +#[derive(Debug)] +enum CommitError { + Postgres(tokio_postgres::Error), + Internal(trc::Error), + //Retry, +} + +impl PostgresStore { + pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result { + let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let start = Instant::now(); + let mut retry_count = 0; + + loop { + match self.write_trx(&mut conn, &mut batch).await { + Ok(result) => { + return Ok(result); + } + Err(err) => { + match err { + CommitError::Postgres(err) => match err.code() { + Some( + &SqlState::T_R_SERIALIZATION_FAILURE + | &SqlState::T_R_DEADLOCK_DETECTED, + ) if retry_count < MAX_COMMIT_ATTEMPTS + && start.elapsed() < MAX_COMMIT_TIME => {} + Some(&SqlState::UNIQUE_VIOLATION) => { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .reason("Unique violation") + .caused_by(trc::location!())); + } + _ => return Err(into_error(err)), + }, + CommitError::Internal(err) => return Err(err), + /*CommitError::Retry => { + if retry_count > MAX_COMMIT_ATTEMPTS + || start.elapsed() > MAX_COMMIT_TIME + { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!())); + } + }*/ + } + + let backoff = rand::rng().random_range(50..=300); + tokio::time::sleep(Duration::from_millis(backoff)).await; + retry_count += 1; + } + } + } + } + + async fn write_trx( + &self, + conn: &mut Object, + batch: &mut Batch<'_>, + ) -> Result { + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let mut asserted_values = AHashMap::new(); + let trx = conn + .build_transaction() + .isolation_level(IsolationLevel::ReadCommitted) + .start() + .await?; + let mut result = AssignedIds::default(); + let has_changes = !batch.changes.is_empty(); + + if has_changes { + for &account_id in batch.changes.keys() { + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, 0); + let s = trx + .prepare_cached(concat!( + "INSERT INTO n (k, v) VALUES ($1, 1) ", + "ON CONFLICT(k) DO UPDATE SET v = n.v + 1 RETURNING v" + )) + .await?; + let change_id = trx + .query_one(&s, &[&key]) + .await + .and_then(|row| row.try_get::<_, i64>(0))?; + result.push_change_id(account_id, change_id as u64); + } + } + + for op in batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let key = class.serialize(account_id, collection, document_id, 0); + let subspace = class.subspace(collection); + let table = char::from(subspace); + + match op { + ValueOp::Set(value) => { + if subspace != SUBSPACE_REGISTRY_IDX { + let s = if let Some(exists) = asserted_values.get(&key) { + if *exists { + trx.prepare_cached(&format!( + "UPDATE {} SET v = $2 WHERE k = $1", + table + )) + .await? + } else { + trx.prepare_cached(&format!( + "INSERT INTO {} (k, v) VALUES ($1, $2)", + table + )) + .await? + } + } else { + trx.prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES ($1, $2) ", + "ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v" + ), + table + )) + .await? + }; + + if trx.execute(&s, &[&key, &(*value)]).await? == 0 { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + } else { + let s = trx + .prepare_cached( + "INSERT INTO b (k) VALUES ($1) ON CONFLICT (k) DO NOTHING", + ) + .await?; + trx.execute(&s, &[&key]).await?; + } + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + + let s = if let Some(exists) = asserted_values.get(&key) { + if *exists { + trx.prepare_cached(&format!( + "UPDATE {} SET v = $2 WHERE k = $1", + table + )) + .await? + } else { + trx.prepare_cached(&format!( + "INSERT INTO {} (k, v) VALUES ($1, $2)", + table + )) + .await? + } + } else { + trx.prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES ($1, $2) ", + "ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v" + ), + table + )) + .await? + }; + + if trx.execute(&s, &[&key, &value]).await? == 0 { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + } + ValueOp::MergeFnc(merge_op) => { + let s = trx + .prepare_cached(&format!( + "SELECT v FROM {} WHERE k = $1 FOR UPDATE", + table + )) + .await?; + let (exists, merge_result) = trx + .query_opt(&s, &[&key]) + .await? + .map(|row| { + row.try_get::<_, &[u8]>(0) + .map_err(CommitError::from) + .and_then(|v| { + (merge_op.fnc)(&merge_op.params, &result, Some(v)) + .map(|v| (true, v)) + .map_err(CommitError::from) + }) + }) + .unwrap_or_else(|| { + (merge_op.fnc)(&merge_op.params, &result, None) + .map(|v| (false, v)) + .map_err(CommitError::from) + })?; + + match merge_result { + MergeResult::Update(value) => { + let s = if exists { + trx.prepare_cached(&format!( + "UPDATE {} SET v = $2 WHERE k = $1", + table + )) + .await? + } else { + trx.prepare_cached(&format!( + "INSERT INTO {} (k, v) VALUES ($1, $2)", + table + )) + .await? + }; + + trx.execute(&s, &[&key, &value]).await?; + } + MergeResult::Delete if exists => { + let s = trx + .prepare_cached(&format!( + "DELETE FROM {} WHERE k = $1", + table + )) + .await?; + trx.execute(&s, &[&key]).await?; + + // Update asserted value + if let Some(exists) = asserted_values.get_mut(&key) { + *exists = false; + } + } + _ => (), + } + } + ValueOp::AtomicAdd(by) => { + if *by >= 0 { + let s = trx + .prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES ($1, $2) ", + "ON CONFLICT(k) DO UPDATE SET v = {}.v + EXCLUDED.v" + ), + table, table + )) + .await?; + trx.execute(&s, &[&key, &*by]).await?; + } else { + let s = trx + .prepare_cached(&format!( + "UPDATE {table} SET v = v + $1 WHERE k = $2" + )) + .await?; + trx.execute(&s, &[&*by, &key]).await?; + } + } + ValueOp::AddAndGet(by) => { + let s = trx + .prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES ($1, $2) ", + "ON CONFLICT(k) DO UPDATE SET v = {}.v + EXCLUDED.v RETURNING v" + ), + table, table + )) + .await?; + result.push_counter_id( + trx.query_one(&s, &[&key, &*by]) + .await + .and_then(|row| row.try_get::<_, i64>(0))?, + ); + } + ValueOp::Clear => { + let s = trx + .prepare_cached(&format!("DELETE FROM {} WHERE k = $1", table)) + .await?; + trx.execute(&s, &[&key]).await?; + + // Update asserted value + if let Some(exists) = asserted_values.get_mut(&key) { + *exists = false; + } + } + } + } + Operation::Index { field, key, set } => { + let key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: &*key, + } + .serialize(0); + + let s = if *set { + trx.prepare_cached( + "INSERT INTO i (k) VALUES ($1) ON CONFLICT (k) DO NOTHING", + ) + .await? + } else { + trx.prepare_cached("DELETE FROM i WHERE k = $1").await? + }; + trx.execute(&s, &[&key]).await?; + } + Operation::Log { collection, set } => { + let key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(0); + + let s = trx + .prepare_cached(concat!( + "INSERT INTO l (k, v) VALUES ($1, $2) ", + "ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v" + )) + .await?; + + trx.execute(&s, &[&key, &*set]).await?; + } + Operation::AssertValue { + class, + assert_value, + } => { + let key = class.serialize(account_id, collection, document_id, 0); + let table = char::from(class.subspace(collection)); + + let s = trx + .prepare_cached(&format!("SELECT v FROM {} WHERE k = $1 FOR UPDATE", table)) + .await?; + let (exists, matches) = trx + .query_opt(&s, &[&key]) + .await? + .map(|row| { + row.try_get::<_, &[u8]>(0) + .map_or((true, false), |v| (true, assert_value.matches(v))) + }) + .unwrap_or_else(|| (false, assert_value.is_none())); + if !matches { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!()) + .into()); + } + asserted_values.insert(key, exists); + } + } + } + + trx.commit().await.map(|_| result).map_err(Into::into) + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + + for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { + purge_table(&conn, char::from(subspace)).await?; + } + + Ok(()) + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let conn = self.conn_pool.get().await.map_err(into_pool_error)?; + let table = char::from(from.subspace()); + let mut from = from.serialize(0); + let to = to.serialize(0); + + let delete = conn + .prepare_cached(&format!("DELETE FROM {table} WHERE k >= $1 AND k < $2")) + .await + .map_err(into_error)?; + + match conn.execute(&delete, &[&from, &to]).await { + Ok(_) => return Ok(()), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let mut chunk_size = DELETE_CHUNK_SIZE; + + loop { + let boundary = conn + .prepare_cached(&format!( + "SELECT k FROM {table} WHERE k >= $1 AND k < $2 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}" + )) + .await + .map_err(into_error)?; + + loop { + let next = match conn.query_opt(&boundary, &[&from, &to]).await { + Ok(next) => match next { + Some(row) => Some(row.try_get::<_, Vec>(0).map_err(into_error)?), + None => None, + }, + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + }; + + match conn + .execute(&delete, &[&from, next.as_ref().unwrap_or(&to)]) + .await + { + Ok(_) => (), + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + + match next { + Some(next) => from = next, + None => return Ok(()), + } + } + } + } +} + +async fn purge_table(conn: &Object, table: char) -> trc::Result<()> { + let s = conn + .prepare_cached(&format!("DELETE FROM {table} WHERE v = 0")) + .await + .map_err(into_error)?; + + match conn.execute(&s, &[]).await { + Ok(_) => return Ok(()), + Err(err) if is_timeout_error(&err) => (), + Err(err) => return Err(into_error(err)), + } + + let purge = conn + .prepare_cached(&format!( + "DELETE FROM {table} WHERE v = 0 AND k >= $1 AND k < $2" + )) + .await + .map_err(into_error)?; + let purge_last = conn + .prepare_cached(&format!("DELETE FROM {table} WHERE v = 0 AND k >= $1")) + .await + .map_err(into_error)?; + let mut chunk_size = DELETE_CHUNK_SIZE; + let mut from = Vec::new(); + + loop { + let boundary = conn + .prepare_cached(&format!( + "SELECT k FROM {table} WHERE k >= $1 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}" + )) + .await + .map_err(into_error)?; + + loop { + let next = match conn.query_opt(&boundary, &[&from]).await { + Ok(next) => match next { + Some(row) => Some(row.try_get::<_, Vec>(0).map_err(into_error)?), + None => None, + }, + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + }; + + let result = match &next { + Some(next) => conn.execute(&purge, &[&from, next]).await, + None => conn.execute(&purge_last, &[&from]).await, + }; + + match result { + Ok(_) => (), + Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => { + chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE); + break; + } + Err(err) => return Err(into_error(err)), + } + + match next { + Some(next) => from = next, + None => return Ok(()), + } + } + } +} + +impl From for CommitError { + fn from(err: trc::Error) -> Self { + CommitError::Internal(err) + } +} + +impl From for CommitError { + fn from(err: tokio_postgres::Error) -> Self { + CommitError::Postgres(err) + } +} diff --git a/crates/store/src/backend/redis/lookup.rs b/crates/store/src/backend/redis/lookup.rs new file mode 100644 index 0000000..9f681d9 --- /dev/null +++ b/crates/store/src/backend/redis/lookup.rs @@ -0,0 +1,301 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{RedisPool, RedisStore, into_error}; +use crate::{Deserialize, write::now}; +use redis::AsyncCommands; + +impl RedisStore { + pub async fn key_set(&self, key: &[u8], value: &[u8], expires: Option) -> trc::Result<()> { + match &self.pool { + RedisPool::Single(pool) => { + self.key_set_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + RedisPool::Cluster(pool) => { + self.key_set_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + RedisPool::Sentinel(pool) => { + self.key_set_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + } + } + + pub async fn key_incr(&self, key: &[u8], value: i64, expires: Option) -> trc::Result { + match &self.pool { + RedisPool::Single(pool) => { + self.key_incr_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + RedisPool::Cluster(pool) => { + self.key_incr_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + RedisPool::Sentinel(pool) => { + self.key_incr_( + pool.get().await.map_err(into_error)?.as_mut(), + key, + value, + expires, + ) + .await + } + } + } + + pub async fn try_lock(&self, key: &[u8], expires: u64) -> trc::Result { + match &self.pool { + RedisPool::Single(pool) => { + self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) + .await + } + RedisPool::Cluster(pool) => { + self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) + .await + } + RedisPool::Sentinel(pool) => { + self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) + .await + } + } + } + + pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> { + match &self.pool { + RedisPool::Single(pool) => { + self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Cluster(pool) => { + self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Sentinel(pool) => { + self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + } + } + + pub async fn key_delete_prefix(&self, prefix: &[u8]) -> trc::Result<()> { + match &self.pool { + RedisPool::Single(pool) => { + self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) + .await + } + RedisPool::Cluster(pool) => { + self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) + .await + } + RedisPool::Sentinel(pool) => { + self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) + .await + } + } + } + + pub async fn key_get( + &self, + key: &[u8], + ) -> trc::Result> { + match &self.pool { + RedisPool::Single(pool) => { + self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Cluster(pool) => { + self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Sentinel(pool) => { + self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + } + } + + pub async fn counter_get(&self, key: &[u8]) -> trc::Result { + match &self.pool { + RedisPool::Single(pool) => { + self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Cluster(pool) => { + self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Sentinel(pool) => { + self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + } + } + + pub async fn key_exists(&self, key: &[u8]) -> trc::Result { + match &self.pool { + RedisPool::Single(pool) => { + self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Cluster(pool) => { + self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + RedisPool::Sentinel(pool) => { + self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) + .await + } + } + } + + async fn key_get_( + &self, + conn: &mut impl AsyncCommands, + key: &[u8], + ) -> trc::Result> { + if let Some(value) = redis::cmd("GET") + .arg(key) + .query_async::>>(conn) + .await + .map_err(into_error)? + { + T::deserialize_owned(value).map(Some) + } else { + Ok(None) + } + } + + async fn counter_get_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result { + redis::cmd("GET") + .arg(key) + .query_async::>(conn) + .await + .map(|x| x.unwrap_or(0)) + .map_err(into_error) + } + + async fn key_exists_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result { + conn.exists(key).await.map_err(into_error) + } + + async fn key_set_( + &self, + conn: &mut impl AsyncCommands, + key: &[u8], + value: &[u8], + expires: Option, + ) -> trc::Result<()> { + if let Some(expires) = expires { + conn.set_ex(key, value, expires).await.map_err(into_error) + } else { + conn.set(key, value).await.map_err(into_error) + } + } + + async fn key_incr_( + &self, + conn: &mut impl AsyncCommands, + key: &[u8], + value: i64, + expires: Option, + ) -> trc::Result { + if let Some(expires) = expires { + redis::pipe() + .atomic() + .incr(key, value) + .expire(key, expires as i64) + .ignore() + .query_async::>(conn) + .await + .map_err(into_error) + .map(|v| v.first().copied().unwrap_or(0)) + } else { + conn.incr(key, value).await.map_err(into_error) + } + } + + async fn try_lock_( + &self, + conn: &mut impl AsyncCommands, + key: &[u8], + expires: u64, + ) -> trc::Result { + redis::cmd("SET") + .arg(key) + .arg(now() + expires) + .arg("NX") + .arg("EX") + .arg(expires as i64) + .query_async::>(conn) + .await + .map(|reply| reply.is_some()) + .map_err(into_error) + } + + async fn key_delete_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result<()> { + conn.del(key).await.map_err(into_error) + } + + async fn key_delete_prefix_( + &self, + conn: &mut impl AsyncCommands, + prefix: &[u8], + ) -> trc::Result<()> { + let mut pattern = Vec::with_capacity(prefix.len() + 1); + pattern.extend_from_slice(prefix); + pattern.push(b'*'); + + let mut cursor = 0; + loop { + let (new_cursor, keys): (u64, Vec>) = redis::cmd("SCAN") + .cursor_arg(cursor) + .arg("MATCH") + .arg(&pattern) + .arg("COUNT") + .arg(100) + .query_async(conn) + .await + .map_err(into_error)?; + + if !keys.is_empty() { + conn.del::<_, ()>(&keys).await.map_err(into_error)?; + } + + if new_cursor != 0 { + cursor = new_cursor; + } else { + return Ok(()); + } + } + } +} diff --git a/crates/store/src/backend/redis/mod.rs b/crates/store/src/backend/redis/mod.rs new file mode 100644 index 0000000..ea72fda --- /dev/null +++ b/crates/store/src/backend/redis/mod.rs @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::InMemoryStore; +use deadpool::{ + Runtime, + managed::{Manager, Pool}, +}; +use redis::{ + Client, ConnectionAddr, IntoConnectionInfo, ProtocolVersion, TlsMode, + cluster::{ClusterClient, ClusterClientBuilder}, + cluster_read_routing::RandomReplicaStrategy, + sentinel::{SentinelClient, SentinelClientBuilder, SentinelServerType}, +}; +use registry::{ + schema::{enums::RedisProtocol, structs}, + types::duration::Duration, +}; +use std::{fmt::Display, sync::Arc}; + +pub mod lookup; +pub mod pool; + +#[derive(Debug)] +pub struct RedisStore { + pub pool: RedisPool, +} + +pub struct RedisConnectionManager { + pub client: Client, + timeout: std::time::Duration, +} + +pub struct RedisClusterConnectionManager { + pub client: ClusterClient, + timeout: std::time::Duration, +} + +pub struct RedisSentinelConnectionManager { + pub client: tokio::sync::Mutex, + timeout: std::time::Duration, +} + +pub enum RedisPool { + Single(Pool), + Cluster(Pool), + Sentinel(Pool), +} + +impl RedisStore { + pub async fn open_single(config: structs::RedisStore) -> Result { + Ok(InMemoryStore::Redis(Arc::new(RedisStore { + pool: RedisPool::Single(build_pool( + RedisConnectionManager { + client: Client::open(config.url) + .map_err(|err| format!("Failed to open Redis client: {err:?}"))?, + timeout: config.timeout.into_inner(), + }, + config.pool_max_connections, + config.pool_timeout_create, + config.pool_timeout_wait, + config.pool_timeout_recycle, + )?), + }))) + } + + pub async fn open_cluster(config: structs::RedisClusterStore) -> Result { + let mut builder = ClusterClientBuilder::new(config.urls); + if let Some(value) = config.auth_username { + builder = builder.username(value); + } + if let Some(value) = config.auth_secret.secret().await?.map(|v| v.into_owned()) { + builder = builder.password(value); + } + if let Some(value) = config.max_retries { + builder = builder.retries(value as u32); + } + if let Some(value) = config.max_retry_wait { + builder = builder.max_retry_wait(value.as_millis()); + } + if let Some(value) = config.min_retry_wait { + builder = builder.min_retry_wait(value.as_millis()); + } + if config.read_from_replicas { + builder = builder.read_routing_strategy(RandomReplicaStrategy); + } + if matches!(config.protocol_version, RedisProtocol::Resp3) { + builder = builder.use_protocol(ProtocolVersion::RESP3); + } + + let client = builder + .build() + .map_err(|err| format!("Failed to open Redis client: {err:?}"))?; + + Ok(InMemoryStore::Redis(Arc::new(RedisStore { + pool: RedisPool::Cluster(build_pool( + RedisClusterConnectionManager { + client, + timeout: config.timeout.into_inner(), + }, + config.pool_max_connections, + config.pool_timeout_create, + config.pool_timeout_wait, + config.pool_timeout_recycle, + )?), + }))) + } + + pub async fn open_sentinel( + config: structs::RedisSentinelStore, + ) -> Result { + let mut sentinels = Vec::with_capacity(config.urls.len()); + let mut tls_mode = None; + for url in config.urls { + let info = url + .into_connection_info() + .map_err(|err| format!("Invalid Redis Sentinel URL: {err}"))?; + let url_tls_mode = match info.addr() { + ConnectionAddr::TcpTls { insecure: true, .. } => Some(TlsMode::Insecure), + ConnectionAddr::TcpTls { + insecure: false, .. + } => Some(TlsMode::Secure), + _ => None, + }; + if sentinels.is_empty() { + tls_mode = url_tls_mode; + } else if tls_mode != url_tls_mode { + return Err( + "All Redis Sentinel URLs must use the same scheme and TLS settings".to_string(), + ); + } + sentinels.push(info.addr().clone()); + } + + let mut builder = + SentinelClientBuilder::new(sentinels, config.service_name, SentinelServerType::Master) + .map_err(|err| format!("Failed to create Redis Sentinel client: {err:?}"))?; + + if let Some(value) = config.auth_username { + builder = builder.set_client_to_redis_username(value); + } + if let Some(value) = config.auth_secret.secret().await?.map(|v| v.into_owned()) { + builder = builder.set_client_to_redis_password(value); + } + if let Some(value) = config.sentinel_username { + builder = builder.set_client_to_sentinel_username(value); + } + if let Some(value) = config + .sentinel_secret + .secret() + .await? + .map(|v| v.into_owned()) + { + builder = builder.set_client_to_sentinel_password(value); + } + if matches!(config.protocol_version, RedisProtocol::Resp3) { + builder = builder.set_client_to_redis_protocol(ProtocolVersion::RESP3); + } + if let Some(tls_mode) = tls_mode { + builder = builder.set_client_to_redis_tls_mode(tls_mode); + } + + let client = builder + .build() + .map_err(|err| format!("Failed to open Redis Sentinel client: {err:?}"))?; + + Ok(InMemoryStore::Redis(Arc::new(RedisStore { + pool: RedisPool::Sentinel(build_pool( + RedisSentinelConnectionManager { + client: tokio::sync::Mutex::new(client), + timeout: config.timeout.into_inner(), + }, + config.pool_max_connections, + config.pool_timeout_create, + config.pool_timeout_wait, + config.pool_timeout_recycle, + )?), + }))) + } +} + +fn build_pool( + manager: M, + max_size: u64, + create_timeout: Option, + wait_timeout: Option, + recycle_timeout: Option, +) -> Result, String> { + Pool::builder(manager) + .runtime(Runtime::Tokio1) + .max_size(max_size as usize) + .create_timeout(create_timeout.map(|v| v.into_inner())) + .wait_timeout(wait_timeout.map(|v| v.into_inner())) + .recycle_timeout(recycle_timeout.map(|v| v.into_inner())) + .build() + .map_err(|err| format!("Failed to build pool: {err}")) +} + +#[inline(always)] +fn into_error(err: impl Display) -> trc::Error { + trc::StoreEvent::RedisError.reason(err) +} + +impl std::fmt::Debug for RedisPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Single(_) => f.debug_tuple("Single").finish(), + Self::Cluster(_) => f.debug_tuple("Cluster").finish(), + Self::Sentinel(_) => f.debug_tuple("Sentinel").finish(), + } + } +} diff --git a/crates/store/src/backend/redis/pool.rs b/crates/store/src/backend/redis/pool.rs new file mode 100644 index 0000000..341b630 --- /dev/null +++ b/crates/store/src/backend/redis/pool.rs @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + RedisClusterConnectionManager, RedisConnectionManager, RedisSentinelConnectionManager, + into_error, +}; +use deadpool::managed; +use redis::{ + aio::{ConnectionLike, MultiplexedConnection}, + cluster_async::ClusterConnection, +}; + +impl managed::Manager for RedisConnectionManager { + type Type = MultiplexedConnection; + type Error = trc::Error; + + async fn create(&self) -> Result { + match tokio::time::timeout(self.timeout, self.client.get_multiplexed_async_connection()) + .await + { + Ok(conn) => conn.map_err(into_error), + Err(_) => Err(trc::StoreEvent::RedisError.ctx(trc::Key::Details, "Connection Timeout")), + } + } + + async fn recycle( + &self, + conn: &mut MultiplexedConnection, + _: &managed::Metrics, + ) -> managed::RecycleResult { + conn.req_packed_command(&redis::cmd("PING")) + .await + .map(|_| ()) + .map_err(|err| managed::RecycleError::Backend(into_error(err))) + } +} + +impl managed::Manager for RedisClusterConnectionManager { + type Type = ClusterConnection; + type Error = trc::Error; + + async fn create(&self) -> Result { + match tokio::time::timeout(self.timeout, self.client.get_async_connection()).await { + Ok(conn) => conn.map_err(into_error), + Err(_) => Err(trc::StoreEvent::RedisError.ctx(trc::Key::Details, "Connection Timeout")), + } + } + + async fn recycle( + &self, + conn: &mut ClusterConnection, + _: &managed::Metrics, + ) -> managed::RecycleResult { + conn.req_packed_command(&redis::cmd("PING")) + .await + .map(|_| ()) + .map_err(|err| managed::RecycleError::Backend(into_error(err))) + } +} + +impl managed::Manager for RedisSentinelConnectionManager { + type Type = MultiplexedConnection; + type Error = trc::Error; + + async fn create(&self) -> Result { + let mut client = self.client.lock().await; + match tokio::time::timeout(self.timeout, client.get_async_connection()).await { + Ok(conn) => conn.map_err(into_error), + Err(_) => Err(trc::StoreEvent::RedisError.ctx(trc::Key::Details, "Connection Timeout")), + } + } + + async fn recycle( + &self, + conn: &mut MultiplexedConnection, + _: &managed::Metrics, + ) -> managed::RecycleResult { + conn.req_packed_command(&redis::cmd("PING")) + .await + .map(|_| ()) + .map_err(|err| managed::RecycleError::Backend(into_error(err))) + } +} diff --git a/crates/store/src/backend/rocksdb/blob.rs b/crates/store/src/backend/rocksdb/blob.rs new file mode 100644 index 0000000..5a7f872 --- /dev/null +++ b/crates/store/src/backend/rocksdb/blob.rs @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::ops::Range; + +use super::{CF_BLOBS, RocksDbStore, into_error}; + +impl RocksDbStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let db = self.db.clone(); + self.spawn_worker(move || { + db.get_pinned_cf(&db.cf_handle(CF_BLOBS).unwrap(), key) + .map(|obj| { + obj.map(|bytes| { + if range.start == 0 && range.end == usize::MAX { + bytes.to_vec() + } else { + bytes + .get(range.start..std::cmp::min(bytes.len(), range.end)) + .unwrap_or_default() + .to_vec() + } + }) + }) + .map_err(into_error) + }) + .await + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let db = self.db.clone(); + self.spawn_worker(move || { + db.put_cf(&db.cf_handle(CF_BLOBS).unwrap(), key, data) + .map_err(into_error) + }) + .await + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let db = self.db.clone(); + self.spawn_worker(move || { + db.delete_cf(&db.cf_handle(CF_BLOBS).unwrap(), key) + .map_err(into_error) + .map(|_| true) + }) + .await + } +} diff --git a/crates/store/src/backend/rocksdb/main.rs b/crates/store/src/backend/rocksdb/main.rs new file mode 100644 index 0000000..cfe726b --- /dev/null +++ b/crates/store/src/backend/rocksdb/main.rs @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CF_BLOBS, RocksDbStore}; +use crate::*; +use ::registry::schema::structs; +use rocksdb::{ + BlockBasedOptions, Cache, ColumnFamilyDescriptor, DBCompressionType, MergeOperands, + OptimisticTransactionDB, Options, +}; +use std::path::PathBuf; +use tokio::sync::oneshot; + +const MIN_WRITE_BUFFER_SIZE: usize = 4 * 1024 * 1024; +const MAX_WRITE_BUFFER_SIZE: usize = 64 * 1024 * 1024; +const MIN_DB_WRITE_BUFFER_SIZE: usize = 32 * 1024 * 1024; +const BLOOM_BITS_PER_KEY: f64 = 10.0; +const SCAN_BLOCK_SIZE: usize = 16 * 1024; +const CHURN_TARGET_FILE_SIZE: u64 = 16 * 1024 * 1024; +const CHURN_DELETION_WINDOW: usize = 4096; +const CHURN_DELETION_TRIGGER: usize = 1024; +const CHURN_DELETION_RATIO: f64 = 0.5; +const BYTES_PER_SYNC: u64 = 1024 * 1024; + +#[derive(Clone, Copy)] +enum CfProfile { + /// Read through `get_value` / `key_exists`, so a whole key bloom filter pays off. + PointLookup, + /// Read only through `iterate`, which never consults a whole key bloom filter. + Scan, + /// Point read and point deleted at a high rate. + Churn, + /// Scanned from the oldest key and point deleted once consumed, with empty values. + Queue, + /// Counters updated through the merge operator. + Counter, + /// Blob values held in RocksDB blob files. + Blob, +} + +impl RocksDbStore { + pub async fn open(config: structs::RocksDbStore) -> Result { + // Create the database directory if it doesn't exist + let idx_path: PathBuf = PathBuf::from(config.path); + std::fs::create_dir_all(&idx_path).map_err(|err| { + format!( + "Failed to create database directory {}: {:?}", + idx_path.display(), + err + ) + })?; + + let cache = Cache::new_lru_cache(config.cache_size as usize); + let write_buffer_size = + ((config.buffer_size as usize) / 4).clamp(MIN_WRITE_BUFFER_SIZE, MAX_WRITE_BUFFER_SIZE); + let mut cfs = Vec::new(); + + // Counters + for subspace in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] { + cfs.push(ColumnFamilyDescriptor::new( + std::str::from_utf8(&[subspace]).unwrap(), + cf_options(CfProfile::Counter, &cache, write_buffer_size), + )); + } + + // Blobs + let mut cf_opts = cf_options(CfProfile::Blob, &cache, write_buffer_size); + cf_opts.set_enable_blob_files(true); + cf_opts.set_min_blob_size(config.blob_size); + cf_opts.set_enable_blob_gc(true); + cf_opts.set_blob_gc_age_cutoff(1.0); + cf_opts.set_blob_gc_force_threshold(0.5); + cfs.push(ColumnFamilyDescriptor::new(CF_BLOBS, cf_opts)); + + // Other cfs + for (subspace, profile) in [ + (SUBSPACE_INDEXES, CfProfile::Scan), + (SUBSPACE_ACL, CfProfile::Scan), + (SUBSPACE_TASK_QUEUE, CfProfile::Churn), + (SUBSPACE_DELETED_ITEMS, CfProfile::Churn), + (SUBSPACE_BLOB_LINK, CfProfile::Churn), + (SUBSPACE_IN_MEMORY_VALUE, CfProfile::Churn), + (SUBSPACE_PROPERTY, CfProfile::PointLookup), + (SUBSPACE_REGISTRY, CfProfile::PointLookup), + (SUBSPACE_QUEUE_MESSAGE, CfProfile::Churn), + (SUBSPACE_QUEUE_EVENT, CfProfile::Queue), + (SUBSPACE_REPORT_OUT, CfProfile::Churn), + (SUBSPACE_REPORT_IN, CfProfile::Churn), + (SUBSPACE_LOGS, CfProfile::Scan), + (SUBSPACE_TELEMETRY_SPAN, CfProfile::PointLookup), + (SUBSPACE_TELEMETRY_METRIC, CfProfile::Scan), + (SUBSPACE_SEARCH_INDEX, CfProfile::Scan), + (SUBSPACE_SPAM_SAMPLES, CfProfile::Churn), + (SUBSPACE_REGISTRY_IDX, CfProfile::Scan), + (SUBSPACE_REGISTRY_PK, CfProfile::PointLookup), + (SUBSPACE_DIRECTORY, CfProfile::PointLookup), + (LEGACY_SUBSPACE_BITMAP_TEXT, CfProfile::Scan), + (LEGACY_SUBSPACE_BITMAP_TAG, CfProfile::Scan), + ] { + cfs.push(ColumnFamilyDescriptor::new( + std::str::from_utf8(&[subspace]).unwrap(), + cf_options(profile, &cache, write_buffer_size), + )); + } + + let mut db_opts = Options::default(); + db_opts.create_missing_column_families(true); + db_opts.create_if_missing(true); + db_opts.set_max_background_jobs(std::cmp::max(num_cpus::get() as i32, 3)); + db_opts.increase_parallelism(std::cmp::max(num_cpus::get() as i32, 3)); + db_opts + .set_db_write_buffer_size((config.buffer_size as usize).max(MIN_DB_WRITE_BUFFER_SIZE)); + db_opts.set_bytes_per_sync(BYTES_PER_SYNC); + db_opts.set_wal_bytes_per_sync(BYTES_PER_SYNC); + + Ok(Store::RocksDb(Arc::new(RocksDbStore { + db: OptimisticTransactionDB::open_cf_descriptors(&db_opts, idx_path, cfs) + .map_err(|err| format!("Failed to open database: {:?}", err))? + .into(), + worker_pool: rayon::ThreadPoolBuilder::new() + .num_threads(std::cmp::max( + config + .pool_workers + .filter(|v| *v > 0) + .map(|v| v as usize) + .unwrap_or_else(num_cpus::get), + 4, + )) + .build() + .map_err(|err| format!("Failed to build worker pool: {:?}", err))?, + }))) + } + + pub async fn spawn_worker(&self, mut f: U) -> trc::Result + where + U: FnMut() -> trc::Result + Send, + V: Sync + Send + 'static, + { + let (tx, rx) = oneshot::channel(); + + self.worker_pool.scope(|s| { + s.spawn(|_| { + tx.send(f()).ok(); + }); + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError).reason(err)), + } + } +} + +pub fn numeric_value_merge( + _key: &[u8], + value: Option<&[u8]>, + operands: &MergeOperands, +) -> Option> { + let mut value = if let Some(value) = value { + i64::from_le_bytes(value.try_into().ok()?) + } else { + 0 + }; + + for op in operands.iter() { + value += i64::from_le_bytes(op.try_into().ok()?); + } + + let mut bytes = Vec::with_capacity(std::mem::size_of::()); + bytes.extend_from_slice(&value.to_le_bytes()); + Some(bytes) +} + +fn cf_options(profile: CfProfile, cache: &Cache, write_buffer_size: usize) -> Options { + let mut block_opts = BlockBasedOptions::default(); + block_opts.set_block_cache(cache); + block_opts.set_cache_index_and_filter_blocks(true); + block_opts.set_pin_l0_filter_and_index_blocks_in_cache(true); + + let mut opts = Options::default(); + opts.set_write_buffer_size(write_buffer_size); + opts.set_max_write_buffer_number(4); + + match profile { + CfProfile::PointLookup => { + block_opts.set_bloom_filter(BLOOM_BITS_PER_KEY, false); + opts.set_compression_type(DBCompressionType::Lz4); + } + CfProfile::Scan => { + block_opts.set_block_size(SCAN_BLOCK_SIZE); + opts.set_compression_type(DBCompressionType::Lz4); + } + CfProfile::Churn => { + block_opts.set_bloom_filter(BLOOM_BITS_PER_KEY, false); + opts.set_compression_type(DBCompressionType::Lz4); + opts.set_target_file_size_base(CHURN_TARGET_FILE_SIZE); + opts.add_compact_on_deletion_collector_factory( + CHURN_DELETION_WINDOW, + CHURN_DELETION_TRIGGER, + CHURN_DELETION_RATIO, + ); + } + CfProfile::Queue => { + block_opts.set_block_size(SCAN_BLOCK_SIZE); + opts.set_compression_type(DBCompressionType::None); + opts.set_target_file_size_base(CHURN_TARGET_FILE_SIZE); + opts.add_compact_on_deletion_collector_factory( + CHURN_DELETION_WINDOW, + CHURN_DELETION_TRIGGER, + CHURN_DELETION_RATIO, + ); + } + CfProfile::Counter => { + block_opts.set_bloom_filter(BLOOM_BITS_PER_KEY, false); + opts.set_compression_type(DBCompressionType::None); + opts.set_merge_operator_associative("merge", numeric_value_merge); + } + CfProfile::Blob => { + block_opts.set_bloom_filter(BLOOM_BITS_PER_KEY, false); + opts.set_compression_type(DBCompressionType::None); + } + } + + opts.set_block_based_table_factory(&block_opts); + + opts +} diff --git a/crates/store/src/backend/rocksdb/mod.rs b/crates/store/src/backend/rocksdb/mod.rs new file mode 100644 index 0000000..ed40981 --- /dev/null +++ b/crates/store/src/backend/rocksdb/mod.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use rocksdb::{BoundColumnFamily, MultiThreaded, OptimisticTransactionDB}; + +use crate::{SUBSPACE_BLOBS, SUBSPACE_INDEXES, SUBSPACE_LOGS}; + +pub mod blob; +pub mod main; +pub mod read; +pub mod write; + +static CF_LOGS: &str = unsafe { std::str::from_utf8_unchecked(&[SUBSPACE_LOGS]) }; +static CF_INDEXES: &str = unsafe { std::str::from_utf8_unchecked(&[SUBSPACE_INDEXES]) }; +static CF_BLOBS: &str = unsafe { std::str::from_utf8_unchecked(&[SUBSPACE_BLOBS]) }; + +pub(crate) trait CfHandle { + fn subspace_handle(&self, subspace: u8) -> Arc>; +} + +impl CfHandle for OptimisticTransactionDB { + #[inline(always)] + fn subspace_handle(&self, subspace: u8) -> Arc> { + let subspace = &[subspace]; + self.cf_handle(unsafe { std::str::from_utf8_unchecked(subspace) }) + .unwrap() + } +} + +pub struct RocksDbStore { + db: Arc>, + worker_pool: rayon::ThreadPool, +} + +#[inline(always)] +fn into_error(err: rocksdb::Error) -> trc::Error { + trc::StoreEvent::RocksdbError.reason(err) +} diff --git a/crates/store/src/backend/rocksdb/read.rs b/crates/store/src/backend/rocksdb/read.rs new file mode 100644 index 0000000..4f1c1fe --- /dev/null +++ b/crates/store/src/backend/rocksdb/read.rs @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{RocksDbStore, into_error}; +use crate::{ + Deserialize, IterateParams, Key, ValueKey, backend::rocksdb::CfHandle, write::ValueClass, +}; +use rocksdb::ReadOptions; + +impl RocksDbStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + let db = self.db.clone(); + self.spawn_worker(move || { + let subspace = &[key.subspace()]; + let key = key.serialize(0); + db.get_pinned_cf( + &db.cf_handle(unsafe { std::str::from_utf8_unchecked(subspace.as_slice()) }) + .unwrap(), + &key, + ) + .map_err(into_error) + .and_then(|value| { + if let Some(value) = value { + U::deserialize_with_key(&key, &value).map(Some) + } else { + Ok(None) + } + }) + }) + .await + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let db = self.db.clone(); + self.spawn_worker(move || { + let subspace = &[key.subspace()]; + let key = key.serialize(0); + db.get_pinned_cf( + &db.cf_handle(unsafe { std::str::from_utf8_unchecked(subspace.as_slice()) }) + .unwrap(), + &key, + ) + .map_err(into_error) + .map(|value| value.is_some()) + }) + .await + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let db = self.db.clone(); + + self.spawn_worker(move || { + let cf = db.subspace_handle(params.begin.subspace()); + let begin = params.begin.serialize(0); + let end = params.end.serialize(0); + let mut upper_bound = Vec::with_capacity(end.len() + 1); + + upper_bound.extend_from_slice(&end); + upper_bound.push(0u8); + + let mut read_opts = ReadOptions::default(); + read_opts.set_iterate_lower_bound(begin.as_slice()); + read_opts.set_iterate_upper_bound(upper_bound); + + let mut it = db.raw_iterator_cf_opt(&cf, read_opts); + if params.ascending { + it.seek(&begin); + } else { + it.seek_for_prev(&end); + } + + while it.valid() { + let Some(key) = it.key() else { + break; + }; + let value = if params.values { + it.value().unwrap_or_default() + } else { + &[][..] + }; + + if !cb(key, value)? || params.first { + return Ok(()); + } + + if params.ascending { + it.next(); + } else { + it.prev(); + } + } + + it.status().map_err(into_error) + }) + .await + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into(); + let db = self.db.clone(); + self.spawn_worker(move || { + let cf = self.db.subspace_handle(key.subspace()); + let key = key.serialize(0); + + db.get_pinned_cf(&cf, &key) + .map_err(into_error) + .and_then(|bytes| { + Ok(if let Some(bytes) = bytes { + i64::from_le_bytes(bytes[..].try_into().map_err(|_| { + trc::Error::corrupted_key(&key, (&bytes[..]).into(), trc::location!()) + })?) + } else { + 0 + }) + }) + }) + .await + } +} diff --git a/crates/store/src/backend/rocksdb/write.rs b/crates/store/src/backend/rocksdb/write.rs new file mode 100644 index 0000000..fbe77ad --- /dev/null +++ b/crates/store/src/backend/rocksdb/write.rs @@ -0,0 +1,306 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CF_INDEXES, CF_LOGS, CfHandle, RocksDbStore, into_error}; +use crate::{ + Deserialize, IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, + SUBSPACE_QUOTA, + backend::deserialize_i64_le, + write::{ + AssignedIds, Batch, MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME, MergeResult, Operation, + ValueClass, ValueOp, + }, +}; +use rand::RngExt; +use rocksdb::{ + BoundColumnFamily, ErrorKind, IteratorMode, OptimisticTransactionDB, + OptimisticTransactionOptions, WriteOptions, +}; +use std::{ + sync::Arc, + thread::sleep, + time::{Duration, Instant}, +}; + +impl RocksDbStore { + pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result { + let db = self.db.clone(); + + self.spawn_worker(move || { + let mut txn = RocksDBTransaction { + db: &db, + cf_indexes: db.cf_handle(CF_INDEXES).unwrap(), + cf_logs: db.cf_handle(CF_LOGS).unwrap(), + txn_opts: OptimisticTransactionOptions::default(), + batch: &mut batch, + }; + txn.txn_opts.set_snapshot(true); + + // Begin write + let mut retry_count = 0; + let start = Instant::now(); + loop { + match txn.commit() { + Ok(result) => { + return Ok(result); + } + Err(CommitError::Internal(err)) => return Err(err), + Err(CommitError::RocksDB(err)) => match err.kind() { + ErrorKind::Busy | ErrorKind::MergeInProgress | ErrorKind::TryAgain + if retry_count < MAX_COMMIT_ATTEMPTS + && start.elapsed() < MAX_COMMIT_TIME => + { + let backoff = rand::rng().random_range(50..=300); + sleep(Duration::from_millis(backoff)); + retry_count += 1; + } + _ => return Err(into_error(err)), + }, + } + } + }) + .await + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let db = self.db.clone(); + self.spawn_worker(move || { + db.delete_range_cf( + &db.cf_handle(std::str::from_utf8(&[from.subspace()]).unwrap()) + .unwrap(), + from.serialize(0), + to.serialize(0), + ) + .map_err(into_error) + }) + .await + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + let db = self.db.clone(); + self.spawn_worker(move || { + for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { + let cf = db + .cf_handle(std::str::from_utf8(&[subspace]).unwrap()) + .unwrap(); + + let mut delete_keys = Vec::new(); + + for row in db.iterator_cf(&cf, IteratorMode::Start) { + let (key, value) = row.map_err(into_error)?; + + if i64::deserialize(&value)? == 0 { + delete_keys.push(key); + } + } + + let txn_opts = OptimisticTransactionOptions::default(); + for key in delete_keys { + let txn = db.transaction_opt(&WriteOptions::default(), &txn_opts); + if txn + .get_pinned_for_update_cf(&cf, &key, true) + .map_err(into_error)? + .map(|value| i64::deserialize(&value).map(|v| v == 0).unwrap_or(false)) + .unwrap_or(false) + { + txn.delete_cf(&cf, key).map_err(into_error)?; + txn.commit().map_err(into_error)?; + } else { + txn.rollback().map_err(into_error)?; + } + } + } + + Ok(()) + }) + .await + } +} + +struct RocksDBTransaction<'x, 'y> { + db: &'x OptimisticTransactionDB, + cf_indexes: Arc>, + cf_logs: Arc>, + txn_opts: OptimisticTransactionOptions, + batch: &'x mut Batch<'y>, +} + +enum CommitError { + Internal(trc::Error), + RocksDB(rocksdb::Error), +} + +impl RocksDBTransaction<'_, '_> { + fn commit(&mut self) -> Result { + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let mut result = AssignedIds::default(); + let has_changes = !self.batch.changes.is_empty(); + + let txn = self + .db + .transaction_opt(&WriteOptions::default(), &self.txn_opts); + + if has_changes { + let cf = self.db.cf_handle("n").unwrap(); + for &account_id in self.batch.changes.keys() { + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, 0); + let change_id = txn + .get_pinned_for_update_cf(&cf, &key, true) + .map_err(CommitError::from) + .and_then(|bytes| { + if let Some(bytes) = bytes { + deserialize_i64_le(&key, &bytes) + .map(|v| v + 1) + .map_err(CommitError::from) + } else { + Ok(1) + } + })?; + txn.put_cf(&cf, &key, &change_id.to_le_bytes()[..])?; + result.push_change_id(account_id, change_id as u64); + } + } + + for op in self.batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let key = class.serialize(account_id, collection, document_id, 0); + let cf = self.db.subspace_handle(class.subspace(collection)); + + match op { + ValueOp::Set(value) => { + txn.put_cf(&cf, &key, value)?; + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + + txn.put_cf(&cf, &key, value)?; + } + ValueOp::MergeFnc(merge_op) => { + let merge_result = (merge_op.fnc)( + &merge_op.params, + &result, + txn.get_pinned_for_update_cf(&cf, &key, true)?.as_deref(), + )?; + + match merge_result { + MergeResult::Update(value) => { + txn.put_cf(&cf, &key, value)?; + } + MergeResult::Delete => { + txn.delete_cf(&cf, &key)?; + } + MergeResult::Skip => (), + } + } + ValueOp::AtomicAdd(by) => { + txn.merge_cf(&cf, &key, &by.to_le_bytes()[..])?; + } + ValueOp::AddAndGet(by) => { + let num = txn + .get_pinned_for_update_cf(&cf, &key, true) + .map_err(CommitError::from) + .and_then(|bytes| { + if let Some(bytes) = bytes { + deserialize_i64_le(&key, &bytes) + .map(|v| v + *by) + .map_err(CommitError::from) + } else { + Ok(*by) + } + })?; + txn.put_cf(&cf, &key, &num.to_le_bytes()[..])?; + result.push_counter_id(num); + } + ValueOp::Clear => { + txn.delete_cf(&cf, &key)?; + } + } + } + Operation::Index { field, key, set } => { + let key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: &*key, + } + .serialize(0); + + if *set { + txn.put_cf(&self.cf_indexes, &key, [])?; + } else { + txn.delete_cf(&self.cf_indexes, &key)?; + } + } + Operation::Log { collection, set } => { + let key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(0); + + txn.put_cf(&self.cf_logs, &key, set)?; + } + Operation::AssertValue { + class, + assert_value, + } => { + let key = class.serialize(account_id, collection, document_id, 0); + let cf = self.db.subspace_handle(class.subspace(collection)); + + let matches = txn + .get_pinned_for_update_cf(&cf, &key, true)? + .map(|value| assert_value.matches(&value)) + .unwrap_or_else(|| assert_value.is_none()); + + if !matches { + txn.rollback()?; + return Err(CommitError::Internal( + trc::StoreEvent::AssertValueFailed.into(), + )); + } + } + } + } + + txn.commit().map(|_| result).map_err(Into::into) + } +} + +impl From for CommitError { + fn from(err: rocksdb::Error) -> Self { + CommitError::RocksDB(err) + } +} + +impl From for CommitError { + fn from(err: trc::Error) -> Self { + CommitError::Internal(err) + } +} diff --git a/crates/store/src/backend/s3/mod.rs b/crates/store/src/backend/s3/mod.rs new file mode 100644 index 0000000..d46949b --- /dev/null +++ b/crates/store/src/backend/s3/mod.rs @@ -0,0 +1,259 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::BlobStore; +use registry::schema::structs; +use s3::{Bucket, Region, creds::Credentials}; +use std::{io::Write, ops::Range, sync::Arc, time::Duration}; +use utils::codec::base32_custom::Base32Writer; + +pub struct S3Store { + bucket: Box, + prefix: Option, + max_retries: u32, + verify_after_write: bool, +} + +impl S3Store { + pub async fn open(config: structs::S3Store) -> Result { + // Obtain region and endpoint from config + let region = match config.region { + structs::S3StoreRegion::UsEast1 => Region::UsEast1, + structs::S3StoreRegion::UsEast2 => Region::UsEast2, + structs::S3StoreRegion::UsWest1 => Region::UsWest1, + structs::S3StoreRegion::UsWest2 => Region::UsWest2, + structs::S3StoreRegion::CaCentral1 => Region::CaCentral1, + structs::S3StoreRegion::AfSouth1 => Region::Custom { + region: "af-south-1".into(), + endpoint: "s3.af-south-1.amazonaws.com".into(), + }, + structs::S3StoreRegion::ApEast1 => Region::ApEast1, + structs::S3StoreRegion::ApSouth1 => Region::ApSouth1, + structs::S3StoreRegion::ApNortheast1 => Region::ApNortheast1, + structs::S3StoreRegion::ApNortheast2 => Region::ApNortheast2, + structs::S3StoreRegion::ApNortheast3 => Region::ApNortheast3, + structs::S3StoreRegion::ApSoutheast1 => Region::ApSoutheast1, + structs::S3StoreRegion::ApSoutheast2 => Region::ApSoutheast2, + structs::S3StoreRegion::CnNorth1 => Region::CnNorth1, + structs::S3StoreRegion::CnNorthwest1 => Region::CnNorthwest1, + structs::S3StoreRegion::EuNorth1 => Region::EuNorth1, + structs::S3StoreRegion::EuCentral1 => Region::EuCentral1, + structs::S3StoreRegion::EuCentral2 => Region::EuCentral2, + structs::S3StoreRegion::EuWest1 => Region::EuWest1, + structs::S3StoreRegion::EuWest2 => Region::EuWest2, + structs::S3StoreRegion::EuWest3 => Region::EuWest3, + structs::S3StoreRegion::IlCentral1 => Region::IlCentral1, + structs::S3StoreRegion::MeSouth1 => Region::MeSouth1, + structs::S3StoreRegion::SaEast1 => Region::SaEast1, + structs::S3StoreRegion::DoNyc3 => Region::DoNyc3, + structs::S3StoreRegion::DoAms3 => Region::DoAms3, + structs::S3StoreRegion::DoSgp1 => Region::DoSgp1, + structs::S3StoreRegion::DoFra1 => Region::DoFra1, + structs::S3StoreRegion::Yandex => Region::Yandex, + structs::S3StoreRegion::WaUsEast1 => Region::WaUsEast1, + structs::S3StoreRegion::WaUsEast2 => Region::WaUsEast2, + structs::S3StoreRegion::WaUsCentral1 => Region::WaUsCentral1, + structs::S3StoreRegion::WaUsWest1 => Region::WaUsWest1, + structs::S3StoreRegion::WaCaCentral1 => Region::WaCaCentral1, + structs::S3StoreRegion::WaEuCentral1 => Region::WaEuCentral1, + structs::S3StoreRegion::WaEuCentral2 => Region::WaEuCentral2, + structs::S3StoreRegion::WaEuWest1 => Region::WaEuWest1, + structs::S3StoreRegion::WaEuWest2 => Region::WaEuWest2, + structs::S3StoreRegion::WaApNortheast1 => Region::WaApNortheast1, + structs::S3StoreRegion::WaApNortheast2 => Region::WaApNortheast2, + structs::S3StoreRegion::WaApSoutheast1 => Region::WaApSoutheast1, + structs::S3StoreRegion::WaApSoutheast2 => Region::WaApSoutheast2, + structs::S3StoreRegion::Custom(custom) => Region::Custom { + region: custom.custom_region, + endpoint: custom.custom_endpoint, + }, + }; + let credentials = Credentials::new( + config.access_key.value().await?.as_deref(), + config.secret_key.secret().await?.as_deref(), + config.security_token.secret().await?.as_deref(), + config.session_token.secret().await?.as_deref(), + config.profile.as_deref(), + ) + .map_err(|err| format!("Failed to create credentials: {err:?}"))?; + + Ok(BlobStore::S3(Arc::new(S3Store { + bucket: Bucket::new(&config.bucket, region, credentials) + .map_err(|err| format!("Failed to create bucket: {err:?}"))? + .with_path_style() + .set_dangerous_config(config.allow_invalid_certs, config.allow_invalid_certs) + .map_err(|err| format!("Failed to create bucket: {err:?}"))? + .with_request_timeout(config.timeout.into_inner()) + .map_err(|err| format!("Failed to create bucket: {err:?}"))?, + max_retries: config.max_retries as u32, + prefix: config.key_prefix, + verify_after_write: config.verify_after_write, + }))) + } + + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let path = self.build_key(key); + let mut retries_left = self.max_retries; + + loop { + let response = if range.start != 0 || range.end != usize::MAX { + self.bucket + .get_object_range( + &path, + range.start as u64, + Some(range.end.saturating_sub(1) as u64), + ) + .await + } else { + self.bucket.get_object(&path).await + } + .map_err(into_error)?; + + match response.status_code() { + 200..=299 => return Ok(Some(response.to_vec())), + 404 => return Ok(None), + 500..=599 if retries_left > 0 => { + // wait backoff + tokio::time::sleep(Duration::from_secs( + 1 << (self.max_retries - retries_left).min(6), + )) + .await; + + retries_left -= 1; + } + code => { + return Err(trc::StoreEvent::S3Error + .reason(String::from_utf8_lossy(response.as_slice())) + .ctx(trc::Key::Code, code)); + } + } + } + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let path = self.build_key(key); + let mut retries_left = self.max_retries; + + loop { + let response = self + .bucket + .put_object(&path, data) + .await + .map_err(into_error)?; + + match response.status_code() { + 200..=299 => { + if !self.verify_after_write { + return Ok(()); + } + + // Some S3-compatible backends acknowledge a PUT before the + // write is durable. HEAD the object to confirm it is visible + // to the read path before reporting success. + let (_, head_status) = + self.bucket.head_object(&path).await.map_err(into_error)?; + + match head_status { + 200..=299 => return Ok(()), + 404 | 500..=599 if retries_left > 0 => { + tokio::time::sleep(Duration::from_secs( + 1 << (self.max_retries - retries_left).min(6), + )) + .await; + + retries_left -= 1; + } + 404 => { + return Err(trc::StoreEvent::S3Error + .reason(concat!( + "PUT acknowledged with 2xx but object not visible", + "to read path; backend may be silently losing writes" + )) + .ctx(trc::Key::Code, head_status)); + } + code => { + return Err(trc::StoreEvent::S3Error + .reason("HEAD verification failed after PUT") + .ctx(trc::Key::Code, code)); + } + } + } + 500..=599 if retries_left > 0 => { + // wait backoff + tokio::time::sleep(Duration::from_secs( + 1 << (self.max_retries - retries_left).min(6), + )) + .await; + + retries_left -= 1; + } + code => { + return Err(trc::StoreEvent::S3Error + .reason(String::from_utf8_lossy(response.as_slice())) + .ctx(trc::Key::Code, code)); + } + } + } + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let mut retries_left = self.max_retries; + + loop { + let response = self + .bucket + .delete_object(self.build_key(key)) + .await + .map_err(into_error)?; + + match response.status_code() { + 200..=299 => return Ok(true), + 404 => return Ok(false), + 500..=599 if retries_left > 0 => { + // wait backoff + tokio::time::sleep(Duration::from_secs( + 1 << (self.max_retries - retries_left).min(6), + )) + .await; + + retries_left -= 1; + } + code => { + return Err(trc::StoreEvent::S3Error + .reason(String::from_utf8_lossy(response.as_slice())) + .ctx(trc::Key::Code, code)); + } + } + } + } + + fn build_key(&self, key: &[u8]) -> String { + if let Some(prefix) = &self.prefix { + let mut writer = + Base32Writer::with_raw_capacity(prefix.len() + (key.len().div_ceil(4) * 5)); + writer.push_string(prefix); + writer.write_all(key).unwrap(); + writer.finalize() + } else { + Base32Writer::from_bytes(key).finalize() + } + } +} + +fn into_error(err: impl std::error::Error) -> trc::Error { + let mut reason = err.to_string(); + let mut source = err.source(); + while let Some(err) = source { + reason.push_str(": "); + reason.push_str(&err.to_string()); + source = err.source(); + } + trc::StoreEvent::S3Error.reason(reason) +} diff --git a/crates/store/src/backend/sqlite/blob.rs b/crates/store/src/backend/sqlite/blob.rs new file mode 100644 index 0000000..29f0578 --- /dev/null +++ b/crates/store/src/backend/sqlite/blob.rs @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::ops::Range; + +use rusqlite::OptionalExtension; + +use super::{SqliteStore, into_error}; + +impl SqliteStore { + pub(crate) async fn get_blob( + &self, + key: &[u8], + range: Range, + ) -> trc::Result>> { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + let mut result = conn + .prepare_cached("SELECT v FROM t WHERE k = ?") + .map_err(into_error)?; + result + .query_row([&key], |row| { + Ok({ + let bytes = row.get_ref(0)?.as_bytes()?; + if range.start == 0 && range.end == usize::MAX { + bytes.to_vec() + } else { + bytes + .get(range.start..std::cmp::min(bytes.len(), range.end)) + .unwrap_or_default() + .to_vec() + } + }) + }) + .optional() + .map_err(into_error) + }) + .await + } + + pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + conn.prepare_cached("INSERT OR REPLACE INTO t (k, v) VALUES (?, ?)") + .map_err(into_error)? + .execute([key, data]) + .map_err(into_error) + .map(|_| ()) + }) + .await + } + + pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + conn.prepare_cached("DELETE FROM t WHERE k = ?") + .map_err(into_error)? + .execute([key]) + .map_err(into_error) + .map(|_| true) + }) + .await + } +} diff --git a/crates/store/src/backend/sqlite/lookup.rs b/crates/store/src/backend/sqlite/lookup.rs new file mode 100644 index 0000000..8dfbbd2 --- /dev/null +++ b/crates/store/src/backend/sqlite/lookup.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use rusqlite::{Row, Rows, ToSql, types::FromSql}; + +use crate::{IntoRows, QueryResult, QueryType, Value}; + +use super::{SqliteStore, into_error}; + +impl SqliteStore { + pub(crate) async fn sql_query( + &self, + query: &str, + params_: &[Value<'_>], + ) -> trc::Result { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + let mut s = conn.prepare_cached(query).map_err(into_error)?; + let params = params_ + .iter() + .map(|v| v as &dyn rusqlite::types::ToSql) + .collect::>(); + + match T::query_type() { + QueryType::Execute => s + .execute(params.as_slice()) + .map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r))), + QueryType::Exists => s + .exists(params.as_slice()) + .map(T::from_exists) + .map_err(into_error), + QueryType::QueryOne => s + .query(params.as_slice()) + .and_then(|mut rows| Ok(T::from_query_one(rows.next()?))) + .map_err(into_error), + QueryType::QueryAll => Ok(T::from_query_all( + s.query(params.as_slice()).map_err(into_error)?, + )), + } + }) + .await + } +} + +impl ToSql for Value<'_> { + fn to_sql(&self) -> rusqlite::Result> { + match self { + Value::Integer(value) => value.to_sql(), + Value::Bool(value) => value.to_sql(), + Value::Float(value) => value.to_sql(), + Value::Text(value) => value.to_sql(), + Value::Blob(value) => value.to_sql(), + Value::Null => Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Null, + )), + } + } +} + +impl FromSql for Value<'static> { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + Ok(match value { + rusqlite::types::ValueRef::Null => Value::Null, + rusqlite::types::ValueRef::Integer(v) => Value::Integer(v), + rusqlite::types::ValueRef::Real(v) => Value::Float(v), + rusqlite::types::ValueRef::Text(v) => { + Value::Text(String::from_utf8_lossy(v).into_owned().into()) + } + rusqlite::types::ValueRef::Blob(v) => Value::Blob(v.to_vec().into()), + }) + } +} + +impl IntoRows for Rows<'_> { + fn into_rows(mut self) -> crate::Rows { + let column_count = self.as_ref().map(|s| s.column_count()).unwrap_or_default(); + let mut rows = crate::Rows { rows: Vec::new() }; + + while let Ok(Some(row)) = self.next() { + rows.rows.push(crate::Row { + values: (0..column_count) + .map(|idx| row.get::<_, Value>(idx).unwrap_or(Value::Null)) + .collect(), + }); + } + + rows + } + + fn into_named_rows(mut self) -> crate::NamedRows { + let (column_count, names) = self + .as_ref() + .map(|s| { + ( + s.column_count(), + s.column_names() + .into_iter() + .map(String::from) + .collect::>(), + ) + }) + .unwrap_or((0, Vec::new())); + + let mut rows = crate::NamedRows { + names, + rows: Vec::new(), + }; + + while let Ok(Some(row)) = self.next() { + rows.rows.push(crate::Row { + values: (0..column_count) + .map(|idx| row.get::<_, Value>(idx).unwrap_or(Value::Null)) + .collect(), + }); + } + + rows + } + + fn into_row(self) -> Option { + unreachable!() + } +} + +impl IntoRows for Option<&Row<'_>> { + fn into_row(self) -> Option { + self.map(|row| crate::Row { + values: (0..row.as_ref().column_count()) + .map(|idx| row.get::<_, Value>(idx).unwrap_or(Value::Null)) + .collect(), + }) + } + + fn into_rows(self) -> crate::Rows { + unreachable!() + } + + fn into_named_rows(self) -> crate::NamedRows { + unreachable!() + } +} diff --git a/crates/store/src/backend/sqlite/main.rs b/crates/store/src/backend/sqlite/main.rs new file mode 100644 index 0000000..4631e77 --- /dev/null +++ b/crates/store/src/backend/sqlite/main.rs @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{SqliteStore, into_error, pool::SqliteConnectionManager}; +use crate::*; +use ::registry::schema::structs; +use r2d2::Pool; +use tokio::sync::oneshot; + +impl SqliteStore { + pub fn open(config: structs::SqliteStore) -> Result { + Ok(Store::SQLite(Arc::new(SqliteStore { + conn_pool: Pool::builder() + .max_size(config.pool_max_connections as u32) + .build(SqliteConnectionManager::file(&config.path).with_init(|c| { + c.execute_batch(concat!( + "PRAGMA journal_mode = WAL; ", + "PRAGMA synchronous = NORMAL; ", + "PRAGMA temp_store = memory;", + "PRAGMA busy_timeout = 30000;" + )) + })) + .map_err(|err| format!("Failed to build connection pool: {err}"))?, + worker_pool: rayon::ThreadPoolBuilder::new() + .num_threads(std::cmp::max( + config + .pool_workers + .filter(|v| *v > 0) + .map(|v| v as usize) + .unwrap_or_else(num_cpus::get), + 4, + )) + .build() + .map_err(|err| format!("Failed to build worker pool: {err}"))?, + }))) + } + + #[cfg(feature = "test_mode")] + pub fn open_memory() -> trc::Result { + use super::into_error; + + let db = Self { + conn_pool: Pool::builder() + .max_size(1) + .build(SqliteConnectionManager::memory()) + .map_err(into_error)?, + worker_pool: rayon::ThreadPoolBuilder::new() + .num_threads(num_cpus::get()) + .build() + .map_err(|err| { + into_error(err).ctx(trc::Key::Reason, "Failed to build worker pool") + })?, + }; + db.create_tables()?; + Ok(db) + } + + pub(crate) fn create_tables(&self) -> trc::Result<()> { + let conn = self.conn_pool.get().map_err(into_error)?; + + for table in [ + SUBSPACE_ACL, + SUBSPACE_TASK_QUEUE, + SUBSPACE_DELETED_ITEMS, + SUBSPACE_SPAM_SAMPLES, + SUBSPACE_BLOB_LINK, + SUBSPACE_IN_MEMORY_VALUE, + SUBSPACE_PROPERTY, + SUBSPACE_REGISTRY, + SUBSPACE_REGISTRY_PK, + SUBSPACE_QUEUE_MESSAGE, + SUBSPACE_QUEUE_EVENT, + SUBSPACE_REPORT_OUT, + SUBSPACE_REPORT_IN, + SUBSPACE_LOGS, + SUBSPACE_BLOBS, + SUBSPACE_TELEMETRY_SPAN, + SUBSPACE_TELEMETRY_METRIC, + SUBSPACE_SEARCH_INDEX, + SUBSPACE_DIRECTORY, + ] { + let table = char::from(table); + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k BLOB PRIMARY KEY, + v BLOB NOT NULL + )" + ), + [], + ) + .map_err(into_error)?; + } + + for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] { + let table = char::from(table); + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {table} ( + k BLOB PRIMARY KEY + )" + ), + [], + ) + .map_err(into_error)?; + } + + for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] { + conn.execute( + &format!( + "CREATE TABLE IF NOT EXISTS {} ( + k BLOB PRIMARY KEY, + v INTEGER NOT NULL DEFAULT 0 + )", + char::from(table) + ), + [], + ) + .map_err(into_error)?; + } + + Ok(()) + } + + pub async fn spawn_worker(&self, mut f: U) -> trc::Result + where + U: FnMut() -> trc::Result + Send, + V: Sync + Send + 'static, + { + let (tx, rx) = oneshot::channel(); + + self.worker_pool.scope(|s| { + s.spawn(|_| { + tx.send(f()).ok(); + }); + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError).reason(err)), + } + } +} diff --git a/crates/store/src/backend/sqlite/mod.rs b/crates/store/src/backend/sqlite/mod.rs new file mode 100644 index 0000000..4346f68 --- /dev/null +++ b/crates/store/src/backend/sqlite/mod.rs @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use self::pool::SqliteConnectionManager; +use r2d2::Pool; +use std::fmt::Display; + +pub mod blob; +pub mod lookup; +pub mod main; +pub mod pool; +pub mod read; +pub mod write; + +pub struct SqliteStore { + pub(crate) conn_pool: Pool, + pub(crate) worker_pool: rayon::ThreadPool, +} + +#[inline(always)] +fn into_error(err: impl Display) -> trc::Error { + trc::StoreEvent::SqliteError.reason(err) +} diff --git a/crates/store/src/backend/sqlite/pool.rs b/crates/store/src/backend/sqlite/pool.rs new file mode 100644 index 0000000..23082f1 --- /dev/null +++ b/crates/store/src/backend/sqlite/pool.rs @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use rusqlite::{Connection, Error, OpenFlags}; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +enum Source { + File(PathBuf), + Memory, +} + +type InitFn = dyn Fn(&mut Connection) -> Result<(), rusqlite::Error> + Send + Sync + 'static; + +/// An `r2d2::ManageConnection` for `rusqlite::Connection`s. +pub struct SqliteConnectionManager { + source: Source, + flags: OpenFlags, + init: Option>, +} + +impl fmt::Debug for SqliteConnectionManager { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut builder = f.debug_struct("SqliteConnectionManager"); + let _ = builder.field("source", &self.source); + let _ = builder.field("flags", &self.source); + let _ = builder.field("init", &self.init.as_ref().map(|_| "InitFn")); + builder.finish() + } +} + +impl SqliteConnectionManager { + /// Creates a new `SqliteConnectionManager` from file. + /// + /// See `rusqlite::Connection::open` + pub fn file>(path: P) -> Self { + Self { + source: Source::File(path.as_ref().to_path_buf()), + flags: OpenFlags::default(), + init: None, + } + } + + /// Creates a new `SqliteConnectionManager` from memory. + pub fn memory() -> Self { + Self { + source: Source::Memory, + flags: OpenFlags::default(), + init: None, + } + } + + /// Converts `SqliteConnectionManager` into one that sets OpenFlags upon + /// connection creation. + /// + /// See `rustqlite::OpenFlags` for a list of available flags. + pub fn with_flags(self, flags: OpenFlags) -> Self { + Self { flags, ..self } + } + + /// Converts `SqliteConnectionManager` into one that calls an initialization + /// function upon connection creation. Could be used to set PRAGMAs, for + /// example. + /// + /// ### Example + /// + /// Make a `SqliteConnectionManager` that sets the `foreign_keys` pragma to + /// true for every connection. + /// + /// ```rust,no_run + /// # use r2d2_sqlite::{SqliteConnectionManager}; + /// let manager = SqliteConnectionManager::file("app.db") + /// .with_init(|c| c.execute_batch("PRAGMA foreign_keys=1;")); + /// ``` + pub fn with_init(self, init: F) -> Self + where + F: Fn(&mut Connection) -> Result<(), rusqlite::Error> + Send + Sync + 'static, + { + let init: Option> = Some(Box::new(init)); + Self { init, ..self } + } +} + +fn sleeper(_: i32) -> bool { + std::thread::sleep(std::time::Duration::from_millis(200)); + true +} + +impl r2d2::ManageConnection for SqliteConnectionManager { + type Connection = Connection; + type Error = rusqlite::Error; + + fn connect(&self) -> Result { + match self.source { + Source::File(ref path) => Connection::open_with_flags(path, self.flags), + Source::Memory => Connection::open_in_memory_with_flags(self.flags), + } + .and_then(|mut c| { + c.busy_handler(Some(sleeper))?; + match self.init { + None => Ok(c), + Some(ref init) => init(&mut c).map(|_| c), + } + }) + } + + fn is_valid(&self, conn: &mut Connection) -> Result<(), Error> { + conn.execute_batch("") + } + + fn has_broken(&self, _: &mut Connection) -> bool { + false + } +} diff --git a/crates/store/src/backend/sqlite/read.rs b/crates/store/src/backend/sqlite/read.rs new file mode 100644 index 0000000..c4fe10d --- /dev/null +++ b/crates/store/src/backend/sqlite/read.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{SqliteStore, into_error}; +use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass}; +use rusqlite::OptionalExtension; + +impl SqliteStore { + pub(crate) async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + let mut result = conn + .prepare_cached(&format!( + "SELECT v FROM {} WHERE k = ?", + char::from(key.subspace()) + )) + .map_err(into_error)?; + let key = key.serialize(0); + result + .query_row([&key], |row| { + U::deserialize_with_key(&key, row.get_ref(0)?.as_bytes()?) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(err.into())) + }) + .optional() + .map_err(into_error) + }) + .await + } + + pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + let mut result = conn + .prepare_cached(&format!( + "SELECT 1 FROM {} WHERE k = ?", + char::from(key.subspace()) + )) + .map_err(into_error)?; + let key = key.serialize(0); + result + .query_row([&key], |_| Ok(())) + .optional() + .map(|opt| opt.is_some()) + .map_err(into_error) + }) + .await + } + + pub(crate) async fn iterate( + &self, + params: IterateParams, + mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + let table = char::from(params.begin.subspace()); + let begin = params.begin.serialize(0); + let end = params.end.serialize(0); + let keys = if params.values { "k, v" } else { "k" }; + + let mut query = conn + .prepare_cached(&match (params.first, params.ascending) { + (true, true) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1" + ) + } + (true, false) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1" + ) + } + (false, true) => { + format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC") + } + (false, false) => { + format!( + "SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC" + ) + } + }) + .map_err(into_error)?; + let mut rows = query.query([&begin, &end]).map_err(into_error)?; + + if params.values { + while let Some(row) = rows.next().map_err(into_error)? { + let key = row + .get_ref(0) + .map_err(into_error)? + .as_bytes() + .map_err(into_error)?; + let value = row + .get_ref(1) + .map_err(into_error)? + .as_bytes() + .map_err(into_error)?; + + if !cb(key, value)? { + break; + } + } + } else { + while let Some(row) = rows.next().map_err(into_error)? { + if !cb( + row.get_ref(0) + .map_err(into_error)? + .as_bytes() + .map_err(into_error)?, + b"", + )? { + break; + } + } + } + + Ok(()) + }) + .await + } + + pub(crate) async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + let key = key.into(); + let table = char::from(key.subspace()); + let key = key.serialize(0); + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + match conn + .prepare_cached(&format!("SELECT v FROM {table} WHERE k = ?")) + .map_err(into_error)? + .query_row([&key], |row| row.get::<_, i64>(0)) + { + Ok(value) => Ok(value), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0), + Err(e) => Err(into_error(e)), + } + }) + .await + } +} diff --git a/crates/store/src/backend/sqlite/write.rs b/crates/store/src/backend/sqlite/write.rs new file mode 100644 index 0000000..eb9860e --- /dev/null +++ b/crates/store/src/backend/sqlite/write.rs @@ -0,0 +1,319 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{SqliteStore, into_error}; +use crate::{ + IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA, + SUBSPACE_REGISTRY_IDX, + write::{AssignedIds, Batch, MergeResult, Operation, ValueClass, ValueOp}, +}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use trc::AddContext; + +impl SqliteStore { + pub(crate) async fn write(&self, batch: Batch<'_>) -> trc::Result { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let mut conn = manager.get().map_err(into_error)?; + + let mut account_id = u32::MAX; + let mut collection = u8::MAX; + let mut document_id = u32::MAX; + let mut change_id = 0u64; + let trx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(into_error) + .caused_by(trc::location!())?; + let mut result = AssignedIds::default(); + let has_changes = !batch.changes.is_empty(); + + if has_changes { + for &account_id in batch.changes.keys() { + let key = ValueClass::ChangeId.serialize(account_id, 0, 0, 0); + let change_id = trx + .prepare_cached(concat!( + "INSERT INTO n (k, v) VALUES (?, ?) ", + "ON CONFLICT(k) DO UPDATE SET v = v + ", + "excluded.v RETURNING v" + )) + .map_err(into_error) + .caused_by(trc::location!())? + .query_row(params![&key, &1i64], |row| row.get::<_, i64>(0)) + .map_err(into_error) + .caused_by(trc::location!())?; + result.push_change_id(account_id, change_id as u64); + } + } + + for op in batch.ops.iter_mut() { + match op { + Operation::AccountId { + account_id: account_id_, + } => { + account_id = *account_id_; + if has_changes { + change_id = result.set_current_change_id(account_id)?; + } + } + Operation::Collection { + collection: collection_, + } => { + collection = u8::from(*collection_); + } + Operation::DocumentId { + document_id: document_id_, + } => { + document_id = *document_id_; + } + Operation::Value { class, op } => { + let key = class.serialize(account_id, collection, document_id, 0); + let subspace = class.subspace(collection); + let table = char::from(subspace); + + match op { + ValueOp::Set(value) => { + if subspace != SUBSPACE_REGISTRY_IDX { + trx.prepare_cached(&format!( + "INSERT OR REPLACE INTO {} (k, v) VALUES (?, ?)", + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key, value]) + .map_err(into_error) + .caused_by(trc::location!())?; + } else { + trx.prepare_cached("INSERT OR IGNORE INTO b (k) VALUES (?)") + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + } + ValueOp::SetFnc(set_op) => { + let value = (set_op.fnc)(&set_op.params, &result)?; + trx.prepare_cached(&format!( + "INSERT OR REPLACE INTO {} (k, v) VALUES (?, ?)", + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key, &value]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + ValueOp::MergeFnc(merge_op) => { + let merge_result = trx + .prepare_cached(&format!("SELECT v FROM {} WHERE k = ?", table)) + .map_err(into_error) + .caused_by(trc::location!())? + .query_row([&key], |row| { + Ok((merge_op.fnc)( + &merge_op.params, + &result, + Some(row.get_ref(0)?.as_bytes()?), + )) + }) + .optional() + .map_err(into_error) + .caused_by(trc::location!())? + .unwrap_or_else(|| { + (merge_op.fnc)(&merge_op.params, &result, None) + })?; + + match merge_result { + MergeResult::Update(value) => { + trx.prepare_cached(&format!( + "INSERT OR REPLACE INTO {} (k, v) VALUES (?, ?)", + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key, &value]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + MergeResult::Delete => { + trx.prepare_cached(&format!( + "DELETE FROM {} WHERE k = ?", + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + MergeResult::Skip => (), + } + } + ValueOp::AtomicAdd(by) => { + if *by >= 0 { + trx.prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES (?, ?) ", + "ON CONFLICT(k) DO UPDATE SET v = v + excluded.v" + ), + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute(params![&key, *by]) + .map_err(into_error) + .caused_by(trc::location!())?; + } else { + trx.prepare_cached(&format!( + "UPDATE {table} SET v = v + ? WHERE k = ?" + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute(params![*by, &key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + } + ValueOp::AddAndGet(by) => { + result.push_counter_id( + trx.prepare_cached(&format!( + concat!( + "INSERT INTO {} (k, v) VALUES (?, ?) ", + "ON CONFLICT(k) DO UPDATE SET v = v + ", + "excluded.v RETURNING v" + ), + table + )) + .map_err(into_error) + .caused_by(trc::location!())? + .query_row(params![&key, &*by], |row| row.get::<_, i64>(0)) + .map_err(into_error) + .caused_by(trc::location!())?, + ); + } + ValueOp::Clear => { + trx.prepare_cached(&format!("DELETE FROM {} WHERE k = ?", table)) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + } + } + Operation::Index { field, key, set } => { + let key = IndexKey { + account_id, + collection, + document_id, + field: *field, + key: &*key, + } + .serialize(0); + + if *set { + trx.prepare_cached("INSERT OR IGNORE INTO i (k) VALUES (?)") + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } else { + trx.prepare_cached("DELETE FROM i WHERE k = ?") + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + } + Operation::Log { collection, set } => { + let key = LogKey { + account_id, + collection: u8::from(*collection), + change_id, + } + .serialize(0); + + trx.prepare_cached("INSERT OR REPLACE INTO l (k, v) VALUES (?, ?)") + .map_err(into_error) + .caused_by(trc::location!())? + .execute([&key, set]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + Operation::AssertValue { + class, + assert_value, + } => { + let key = class.serialize(account_id, collection, document_id, 0); + let table = char::from(class.subspace(collection)); + + let matches = trx + .prepare_cached(&format!("SELECT v FROM {} WHERE k = ?", table)) + .map_err(into_error) + .caused_by(trc::location!())? + .query_row([&key], |row| { + Ok(assert_value.matches(row.get_ref(0)?.as_bytes()?)) + }) + .optional() + .map_err(into_error) + .caused_by(trc::location!())? + .unwrap_or_else(|| assert_value.is_none()); + if !matches { + trx.rollback() + .map_err(into_error) + .caused_by(trc::location!())?; + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .caused_by(trc::location!())); + } + } + } + } + + trx.commit().map(|_| result).map_err(into_error) + }) + .await + } + + pub(crate) async fn purge_store(&self) -> trc::Result<()> { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] { + conn.prepare_cached(&format!("DELETE FROM {} WHERE v = 0", char::from(subspace),)) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([]) + .map_err(into_error) + .caused_by(trc::location!())?; + } + + Ok(()) + }) + .await + } + + pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + let manager = self.conn_pool.clone(); + self.spawn_worker(move || { + let conn = manager.get().map_err(into_error)?; + + conn.prepare_cached(&format!( + "DELETE FROM {} WHERE k >= ? AND k < ?", + char::from(from.subspace()), + )) + .map_err(into_error) + .caused_by(trc::location!())? + .execute([from.serialize(0), to.serialize(0)]) + .map_err(into_error) + .caused_by(trc::location!())?; + + Ok(()) + }) + .await + } +} diff --git a/crates/store/src/build/blob.rs b/crates/store/src/build/blob.rs new file mode 100644 index 0000000..bf6c15e --- /dev/null +++ b/crates/store/src/build/blob.rs @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{BlobStore, backend::fs::FsStore, registry::bootstrap::Bootstrap}; +use registry::schema::{prelude::ObjectType, structs}; + +#[allow(unreachable_patterns)] +impl BlobStore { + pub async fn build(bp: &mut Bootstrap) -> Option { + let result = match bp.setting_infallible::().await { + structs::BlobStore::Default => return Some(BlobStore::Store(bp.data_store.clone())), + #[cfg(feature = "foundation")] + structs::BlobStore::FoundationDb(foundation_db_store) => { + crate::backend::foundationdb::FdbStore::open(foundation_db_store) + .await + .map(BlobStore::Store) + } + #[cfg(feature = "postgres")] + structs::BlobStore::PostgreSql(postgre_sql_store) => { + crate::backend::postgres::PostgresStore::open(postgre_sql_store) + .await + .map(BlobStore::Store) + } + #[cfg(feature = "mysql")] + structs::BlobStore::MySql(my_sql_store) => { + crate::backend::mysql::MysqlStore::open(my_sql_store) + .await + .map(BlobStore::Store) + } + #[cfg(feature = "s3")] + structs::BlobStore::S3(s3_store) => crate::backend::s3::S3Store::open(s3_store).await, + #[cfg(feature = "azure")] + structs::BlobStore::Azure(azure_store) => { + crate::backend::azure::AzureStore::open(azure_store).await + } + structs::BlobStore::FileSystem(file_system_store) => { + FsStore::open(file_system_store).await + } + _ => Err("Binary was not compiled with the selected blob store backend".to_string()), + }; + + match result { + Ok(store) => Some(store), + Err(err) => { + bp.build_error(ObjectType::BlobStore.singleton(), err); + None + } + } + } + +} diff --git a/crates/store/src/build/cluster.rs b/crates/store/src/build/cluster.rs new file mode 100644 index 0000000..ad328a7 --- /dev/null +++ b/crates/store/src/build/cluster.rs @@ -0,0 +1,316 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, RegistryStore, RegistryStoreInner, Store, U16_LEN, U32_LEN, U64_LEN, ValueKey, + write::{ + BatchBuilder, ValueClass, + assert::AssertValue, + key::{DeserializeBigEndian, KeySerializer}, + now, + }, +}; +use registry::{ + schema::{enums::ClusterNodeStatus, structs::ClusterNode}, + types::datetime::UTCDateTime, +}; +use std::time::Duration; +use trc::AddContext; +use utils::snowflake::MAX_NODE_ID; + +const STALE_NODE_TIMEOUT: u64 = 60 * 60; // 1 hour +const DEAD_NODE_TIMEOUT: u64 = 60 * 60 * 24; // 24 hours +const MAX_LEASE_RETRIES: u32 = 5; + +struct NodeSlot { + node_id: u16, + hostname: String, + last_renewal: u64, + elapsed: u64, + hash: u64, +} + +struct NodeClaim { + node_id: u16, + assert: AssertValue, +} + +impl RegistryStoreInner { + pub(super) async fn acquire_node_id(&mut self) -> Result<(), String> { + let mut retry_count = 0; + let slots = loop { + let now = now(); + let slots = NodeSlot::list(&self.store, now) + .await + .map_err(|err| format!("Failed to iterate store: {err}"))?; + let claim = NodeSlot::claim(&slots, &self.env_hostname)?; + let mut batch = BatchBuilder::new(); + + batch + .assert_value(ValueClass::NodeId(claim.node_id), claim.assert) + .set( + ValueClass::NodeId(claim.node_id), + KeySerializer::new(self.env_hostname.len() + U64_LEN) + .write(now) + .write(&self.env_hostname) + .finalize(), + ); + + match self.store.write(batch.build_all()).await { + Ok(_) => { + self.node_id = claim.node_id; + break slots; + } + Err(err) => { + if err.is_assertion_failure() && retry_count < MAX_LEASE_RETRIES { + retry_count += 1; + continue; + } else { + return Err(format!("Failed to write node id to store: {err}")); + } + } + } + }; + + if let Err(err) = NodeSlot::release( + &self.store, + slots + .iter() + .filter(|slot| slot.node_id != self.node_id && slot.is_dead()), + ) + .await + { + trc::error!(err.details("Failed to release expired node id leases")); + } + + Ok(()) + } +} + +impl RegistryStore { + pub fn node_id(&self) -> u16 { + self.0.node_id + } + + pub fn refresh_node_id_interval(&self) -> Duration { + Duration::from_secs(STALE_NODE_TIMEOUT / 2) + } + + pub async fn cluster_node_list(&self) -> trc::Result> { + NodeSlot::list(&self.0.store, now()) + .await + .map(|slots| slots.into_iter().map(ClusterNode::from).collect()) + } + + pub async fn refresh_node_id_lease(&self) -> trc::Result<()> { + let node_id = self.0.node_id; + let assert = match NodeSlot::list(&self.0.store, now()) + .await + .caused_by(trc::location!())? + .into_iter() + .find(|slot| slot.node_id == node_id) + { + Some(slot) if slot.is_owned_by(&self.0.env_hostname) => AssertValue::Hash(slot.hash), + Some(slot) => { + return Err(trc::StoreEvent::AssertValueFailed + .into_err() + .details("Node id lease is held by another host") + .ctx(trc::Key::Id, node_id) + .ctx(trc::Key::Hostname, slot.hostname)); + } + None => AssertValue::None, + }; + + let mut batch = BatchBuilder::new(); + batch.assert_value(ValueClass::NodeId(node_id), assert).set( + ValueClass::NodeId(node_id), + KeySerializer::new(self.0.env_hostname.len() + U64_LEN) + .write(now()) + .write(&self.0.env_hostname) + .finalize(), + ); + + self.0 + .store + .write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) + } + + pub async fn purge_dead_nodes(&self) -> trc::Result<()> { + let node_id = self.0.node_id; + let slots = NodeSlot::list(&self.0.store, now()) + .await + .caused_by(trc::location!())?; + + if !slots.iter().any(|slot| { + slot.node_id == node_id && slot.is_owned_by(&self.0.env_hostname) && !slot.is_stale() + }) { + Ok(()) + } else { + NodeSlot::release( + &self.0.store, + slots + .iter() + .filter(|slot| slot.node_id != node_id && slot.is_dead()), + ) + .await + } + } +} + +impl NodeSlot { + async fn list(store: &Store, now: u64) -> trc::Result> { + let mut slots = Vec::new(); + + store + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::NodeId(0)), + ValueKey::from(ValueClass::NodeId(u16::MAX)), + ) + .ascending(), + |key, value| { + if key.len() == U16_LEN * 3 { + let node_id = key.deserialize_be_u16(U32_LEN)?; + + match ( + value.deserialize_be_u64(0), + value + .get(U64_LEN..) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .filter(|text| !text.is_empty()), + ) { + (Ok(last_renewal), Some(hostname)) => { + slots.push(NodeSlot { + node_id, + hostname: hostname.to_string(), + last_renewal, + elapsed: now.saturating_sub(last_renewal), + hash: xxhash_rust::xxh3::xxh3_64(value), + }); + } + _ => { + trc::error!( + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid node id lease") + .ctx(trc::Key::Id, node_id) + ); + } + } + } + Ok(true) + }, + ) + .await + .map(|_| slots) + } + + fn claim(slots: &[NodeSlot], hostname: &str) -> Result { + if let Some(slot) = slots + .iter() + .find(|slot| slot.is_owned_by(hostname) && slot.is_assignable()) + .or_else(|| { + slots + .iter() + .find(|slot| slot.is_stale() && slot.is_assignable()) + }) + { + return Ok(NodeClaim { + node_id: slot.node_id, + assert: AssertValue::Hash(slot.hash), + }); + } + + let mut leased = slots + .iter() + .filter(|slot| !slot.is_stale()) + .map(|slot| slot.node_id) + .collect::>(); + leased.sort_unstable(); + + let mut node_id = 0; + for leased_id in leased { + if leased_id > node_id { + break; + } + node_id = leased_id.saturating_add(1); + if node_id > MAX_NODE_ID { + return Err(format!( + "Failed to obtain a node id: all {} ids are leased by active nodes", + MAX_NODE_ID as u32 + 1 + )); + } + } + + Ok(NodeClaim { + node_id, + assert: AssertValue::None, + }) + } + + async fn release<'x>( + store: &Store, + slots: impl Iterator, + ) -> trc::Result<()> { + for slot in slots { + let mut batch = BatchBuilder::new(); + batch + .assert_value( + ValueClass::NodeId(slot.node_id), + AssertValue::Hash(slot.hash), + ) + .clear(ValueClass::NodeId(slot.node_id)); + + if let Err(err) = store.write(batch.build_all()).await + && !err.is_assertion_failure() + { + return Err(err.caused_by(trc::location!())); + } + } + + Ok(()) + } + + fn is_owned_by(&self, hostname: &str) -> bool { + self.hostname == hostname + } + + fn is_stale(&self) -> bool { + self.elapsed > STALE_NODE_TIMEOUT + } + + fn is_dead(&self) -> bool { + self.elapsed > DEAD_NODE_TIMEOUT + } + + fn is_assignable(&self) -> bool { + self.node_id <= MAX_NODE_ID + } + + fn status(&self) -> ClusterNodeStatus { + if self.is_dead() { + ClusterNodeStatus::Inactive + } else if self.is_stale() { + ClusterNodeStatus::Stale + } else { + ClusterNodeStatus::Active + } + } +} + +impl From for ClusterNode { + fn from(slot: NodeSlot) -> Self { + ClusterNode { + status: slot.status(), + last_renewal: UTCDateTime::from_timestamp(slot.last_renewal.cast_signed()), + node_id: slot.node_id as u64, + hostname: slot.hostname, + } + } +} diff --git a/crates/store/src/build/data.rs b/crates/store/src/build/data.rs new file mode 100644 index 0000000..591e8a1 --- /dev/null +++ b/crates/store/src/build/data.rs @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Store, registry::bootstrap::Bootstrap}; +use registry::schema::{ + prelude::ObjectType, + structs::{DataStore, MetricsStore, TracingStore}, +}; + +#[allow(unreachable_patterns)] +impl Store { + pub async fn build(config: DataStore) -> Result { + #[allow(unreachable_patterns)] + match config { + #[cfg(feature = "rocks")] + DataStore::RocksDb(store) => crate::backend::rocksdb::RocksDbStore::open(store).await, + #[cfg(feature = "foundation")] + DataStore::FoundationDb(store) => { + crate::backend::foundationdb::FdbStore::open(store).await + } + #[cfg(feature = "postgres")] + DataStore::PostgreSql(store) => { + crate::backend::postgres::PostgresStore::open(store).await + } + #[cfg(feature = "mysql")] + DataStore::MySql(store) => crate::backend::mysql::MysqlStore::open(store).await, + #[cfg(feature = "sqlite")] + DataStore::Sqlite(store) => crate::backend::sqlite::SqliteStore::open(store), + _ => Err("Binary was not compiled with the selected data store backend".to_string()), + } + } + + pub async fn build_tracing(bp: &mut Bootstrap) -> Option { + let result = match bp.setting_infallible::().await { + TracingStore::Disabled => Ok(None), + TracingStore::Default => Ok(Some(bp.data_store.clone())), + #[cfg(feature = "foundation")] + TracingStore::FoundationDb(store) => { + crate::backend::foundationdb::FdbStore::open(store) + .await + .map(Some) + } + #[cfg(feature = "postgres")] + TracingStore::PostgreSql(store) => crate::backend::postgres::PostgresStore::open(store) + .await + .map(Some), + #[cfg(feature = "mysql")] + TracingStore::MySql(store) => crate::backend::mysql::MysqlStore::open(store) + .await + .map(Some), + _ => Err("Binary was not compiled with the selected tracing store backend".to_string()), + }; + + match result { + Ok(store) => store, + Err(err) => { + bp.build_warning(ObjectType::TracingStore.singleton(), err); + None + } + } + } + + pub async fn build_metrics(bp: &mut Bootstrap) -> Option { + let result = match bp.setting_infallible::().await { + MetricsStore::Disabled => Ok(None), + MetricsStore::Default => Ok(Some(bp.data_store.clone())), + #[cfg(feature = "foundation")] + MetricsStore::FoundationDb(store) => { + crate::backend::foundationdb::FdbStore::open(store) + .await + .map(Some) + } + #[cfg(feature = "postgres")] + MetricsStore::PostgreSql(store) => crate::backend::postgres::PostgresStore::open(store) + .await + .map(Some), + #[cfg(feature = "mysql")] + MetricsStore::MySql(store) => crate::backend::mysql::MysqlStore::open(store) + .await + .map(Some), + _ => Err("Binary was not compiled with the selected metrics store backend".to_string()), + }; + + match result { + Ok(store) => store, + Err(err) => { + bp.build_warning(ObjectType::MetricsStore.singleton(), err); + None + } + } + } +} diff --git a/crates/store/src/build/lookup.rs b/crates/store/src/build/lookup.rs new file mode 100644 index 0000000..495f5d4 --- /dev/null +++ b/crates/store/src/build/lookup.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{LookupStores, registry::bootstrap::Bootstrap}; +use registry::schema::structs::{LookupStore, StoreLookup}; +use std::collections::hash_map::Entry; + +impl LookupStores { + pub async fn build(bp: &mut Bootstrap) -> Self { + let mut stores = LookupStores::default(); + stores.parse_stores(bp).await; + stores.parse_static(bp).await; + stores.parse_http(bp).await; + stores + } + + #[allow(unreachable_patterns)] + pub async fn parse_stores(&mut self, bp: &mut Bootstrap) { + for store in bp.list_infallible::().await { + let id = store.id; + let store = store.object; + + let result = match store.store { + #[cfg(feature = "postgres")] + LookupStore::PostgreSql(postgre_sql_store) => { + crate::backend::postgres::PostgresStore::open(postgre_sql_store) + .await + .map(crate::InMemoryStore::Store) + } + #[cfg(feature = "mysql")] + LookupStore::MySql(my_sql_store) => { + crate::backend::mysql::MysqlStore::open(my_sql_store) + .await + .map(crate::InMemoryStore::Store) + } + #[cfg(feature = "sqlite")] + LookupStore::Sqlite(sqlite_store) => { + crate::backend::sqlite::SqliteStore::open(sqlite_store) + .map(crate::InMemoryStore::Store) + } + #[cfg(feature = "redis")] + LookupStore::Redis(redis_store) => { + crate::backend::redis::RedisStore::open_single(redis_store).await + } + #[cfg(feature = "redis")] + LookupStore::RedisCluster(redis_cluster_store) => { + crate::backend::redis::RedisStore::open_cluster(redis_cluster_store).await + } + _ => Err( + "Binary was not compiled with the selected lookup store backend".to_string(), + ), + }; + + match result { + Ok(lookup) => match self.stores.entry(store.namespace.as_str().into()) { + Entry::Vacant(entry) => { + entry.insert(lookup); + } + Entry::Occupied(_) => { + bp.build_error( + id, + format!( + "A lookup store with the {} namespace already exists", + store.namespace + ), + ); + } + }, + Err(err) => { + bp.build_error(id, err); + } + } + } + } +} diff --git a/crates/store/src/build/memory.rs b/crates/store/src/build/memory.rs new file mode 100644 index 0000000..754b635 --- /dev/null +++ b/crates/store/src/build/memory.rs @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{InMemoryStore, registry::bootstrap::Bootstrap}; +use registry::schema::{prelude::ObjectType, structs}; + +#[allow(unreachable_patterns)] +impl InMemoryStore { + pub async fn build(bp: &mut Bootstrap) -> Option { + let result = match bp.setting_infallible::().await { + structs::InMemoryStore::Default => { + return Some(InMemoryStore::Store(bp.data_store.clone())); + } + #[cfg(feature = "redis")] + structs::InMemoryStore::Redis(redis_store) => { + crate::backend::redis::RedisStore::open_single(redis_store).await + } + #[cfg(feature = "redis")] + structs::InMemoryStore::RedisCluster(redis_cluster_store) => { + crate::backend::redis::RedisStore::open_cluster(redis_cluster_store).await + } + #[cfg(feature = "redis")] + structs::InMemoryStore::RedisSentinel(redis_sentinel_store) => { + crate::backend::redis::RedisStore::open_sentinel(redis_sentinel_store).await + } + _ => Err("Binary was not compiled with the selected in-memory backend".to_string()), + }; + + match result { + Ok(store) => Some(store), + Err(err) => { + bp.build_error(ObjectType::InMemoryStore.singleton(), err); + None + } + } + } + +} diff --git a/crates/store/src/build/mod.rs b/crates/store/src/build/mod.rs new file mode 100644 index 0000000..0a14086 --- /dev/null +++ b/crates/store/src/build/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod blob; +pub mod cluster; +pub mod data; +pub mod lookup; +pub mod memory; +pub mod registry; +pub mod search; diff --git a/crates/store/src/build/registry.rs b/crates/store/src/build/registry.rs new file mode 100644 index 0000000..ac622a4 --- /dev/null +++ b/crates/store/src/build/registry.rs @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + RegistryStore, RegistryStoreInner, Store, backend::ephemeral::EphemeralStore, + registry::local::RegistryInit, +}; +use rand::{RngExt, distr::Alphanumeric, rng}; +use std::path::PathBuf; + +impl RegistryStore { + pub async fn init(local: PathBuf, acquire_node_id: bool) -> Result { + // Create inner store + let mut inner = RegistryStoreInner::new(local); + + // Build store + inner.store = match inner.read_data_store().await { + RegistryInit::Ok(data_store) => Store::build(data_store).await?, + RegistryInit::Err(err) => return Err(err), + RegistryInit::Bootstrap => { + inner.env_recovery_mode = true; + + if inner.env_recovery_admin.is_none() { + let password = rng() + .sample_iter(Alphanumeric) + .take(16) + .map(char::from) + .collect::(); + eprintln!(); + eprintln!("════════════════════════════════════════════════════════════"); + eprintln!("🔑 Stalwart bootstrap mode - temporary administrator account"); + eprintln!(); + eprintln!(" username: admin"); + eprintln!(" password: {password}"); + eprintln!(); + eprintln!("Use these credentials to complete the initial setup at the"); + eprintln!("/admin web UI. Once setup is done, Stalwart will provision a"); + eprintln!("permanent administrator and this temporary account will no"); + eprintln!("longer apply."); + eprintln!(); + eprintln!("This password is shown only once. To pin a credential"); + eprintln!("instead, set STALWART_RECOVERY_ADMIN=admin: in the"); + eprintln!("env file."); + eprintln!("════════════════════════════════════════════════════════════"); + eprintln!(); + inner.env_recovery_admin = Some(("admin".to_string(), password)); + } + + EphemeralStore::open() + } + }; + + Self::from_inner(inner, acquire_node_id).await + } + + pub fn from_inner_bootstrapped(inner: RegistryStoreInner) -> Self { + Self(inner.into()) + } + + pub async fn from_inner( + mut inner: RegistryStoreInner, + acquire_node_id: bool, + ) -> Result { + // Create tables (SQL only) + inner + .store + .create_tables() + .await + .map_err(|err| format!("Failed to create tables: {err}"))?; + + if acquire_node_id { + inner.acquire_node_id().await?; + } + + Ok(Self(inner.into())) + } + + #[inline(always)] + pub fn recovery_admin(&self) -> Option<&(String, String)> { + self.0.env_recovery_admin.as_ref() + } + + #[inline(always)] + pub fn cluster_role(&self) -> Option<&str> { + self.0.env_cluster_role.as_deref() + } + + #[inline(always)] + pub fn cluster_push_shard(&self) -> u32 { + self.0.env_push_shard_id + } + + #[inline(always)] + pub fn local_hostname(&self) -> &str { + &self.0.env_hostname + } + + #[inline(always)] + pub fn public_url(&self) -> Option<&str> { + self.0.env_public_url.as_deref() + } + + #[inline(always)] + pub fn is_recovery_mode(&self) -> bool { + self.0.env_recovery_mode + } + + #[inline(always)] + pub fn is_bootstrap_mode(&self) -> bool { + self.0.store.is_ephemeral() + } + + #[inline(always)] + pub fn path(&self) -> &PathBuf { + &self.0.local_path + } + + #[inline(always)] + pub fn store(&self) -> &Store { + &self.0.store + } + + pub fn initialize_inner(&self, store: Store) -> RegistryStoreInner { + let mut inner = self.0.as_ref().clone(); + inner.store = store; + inner + } + + #[cfg(feature = "test_mode")] + pub fn clone_with_public_url(&self, url: String) -> Self { + let mut inner = self.0.as_ref().clone(); + inner.env_public_url = Some(url); + Self(inner.into()) + } + + #[cfg(feature = "test_mode")] + pub async fn new( + path: &str, + store: Store, + hostname: String, + push_shard_id: u32, + cluster_role: Option, + ) -> Self { + Self::from_inner( + RegistryStoreInner { + local_path: PathBuf::from(path), + store, + node_id: 0, + env_recovery_mode: false, + env_recovery_admin: Some(("admin".to_string(), "popolna_zapora".to_string())), + env_cluster_role: cluster_role, + env_push_shard_id: push_shard_id, + env_hostname: hostname, + env_public_url: None, + id_generator: utils::snowflake::SnowflakeIdGenerator::new(), + }, + true, + ) + .await + .unwrap() + } +} diff --git a/crates/store/src/build/search.rs b/crates/store/src/build/search.rs new file mode 100644 index 0000000..608b958 --- /dev/null +++ b/crates/store/src/build/search.rs @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + SearchStore, + backend::{elastic::ElasticSearchStore, meili::MeiliSearchStore}, + registry::bootstrap::Bootstrap, +}; +use registry::schema::{prelude::ObjectType, structs}; + +#[allow(unreachable_patterns)] +impl SearchStore { + pub async fn build(bp: &mut Bootstrap) -> Option { + let result = match bp.setting_infallible::().await { + structs::SearchStore::Default => { + return Some(SearchStore::Store(bp.data_store.clone())); + } + structs::SearchStore::ElasticSearch(elastic_search_store) => { + ElasticSearchStore::open(elastic_search_store).await + } + structs::SearchStore::Meilisearch(meilisearch_store) => { + MeiliSearchStore::open(meilisearch_store).await + } + #[cfg(feature = "foundation")] + structs::SearchStore::FoundationDb(foundation_db_store) => { + crate::backend::foundationdb::FdbStore::open(foundation_db_store) + .await + .map(SearchStore::Store) + } + #[cfg(feature = "postgres")] + structs::SearchStore::PostgreSql(postgre_sql_store) => { + crate::backend::postgres::PostgresStore::open(postgre_sql_store) + .await + .map(SearchStore::Store) + } + #[cfg(feature = "mysql")] + structs::SearchStore::MySql(my_sql_store) => { + crate::backend::mysql::MysqlStore::open(my_sql_store) + .await + .map(SearchStore::Store) + } + _ => Err("Binary was not compiled with the selected search store backend".to_string()), + }; + + match result { + Ok(store) => Some(store), + Err(err) => { + bp.build_error(ObjectType::SearchStore.singleton(), err); + None + } + } + } +} diff --git a/crates/store/src/dispatch/blob.rs b/crates/store/src/dispatch/blob.rs new file mode 100644 index 0000000..8e01ac8 --- /dev/null +++ b/crates/store/src/dispatch/blob.rs @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{BlobStore, CompressionAlgo, Store, U32_LEN}; +use std::{ops::Range, time::Instant}; +use trc::{AddContext, StoreEvent}; + +const MAGIC_MARKER: u8 = 0xa0; +const LZ4_MARKER: u8 = MAGIC_MARKER | 0x01; +//const ZSTD_MARKER: u8 = MAGIC_MARKER | 0x02; +const NONE_MARKER: u8 = 0x00; + +impl BlobStore { + pub async fn get_blob(&self, key: &[u8], range: Range) -> trc::Result>> { + let start_time = Instant::now(); + let result = match &self { + BlobStore::Store(store) => match store { + #[cfg(feature = "sqlite")] + Store::SQLite(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "foundation")] + Store::FoundationDb(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "rocks")] + Store::RocksDb(store) => store.get_blob(key, 0..usize::MAX).await, + Store::Ephemeral(store) => store.get_blob(key, 0..usize::MAX).await, + Store::None => Err(trc::StoreEvent::NotConfigured.into()), + }, + BlobStore::Fs(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "s3")] + BlobStore::S3(store) => store.get_blob(key, 0..usize::MAX).await, + #[cfg(feature = "azure")] + BlobStore::Azure(store) => store.get_blob(key, 0..usize::MAX).await, + } + .caused_by(trc::location!())?; + + trc::event!( + Store(StoreEvent::BlobRead), + Key = key, + Elapsed = start_time.elapsed(), + Size = result.as_ref().map_or(0, |data| data.len()), + ); + + let Some(mut data) = result else { + return Ok(None); + }; + + let mut data = match data.last().copied() { + Some(LZ4_MARKER) => { + lz4_flex::decompress_size_prepended(data.get(..data.len() - 1).unwrap_or_default()) + .map_err(|err| { + trc::StoreEvent::DecompressError + .reason(err) + .ctx(trc::Key::Key, key) + .ctx(trc::Key::CausedBy, trc::location!()) + })? + } + Some(NONE_MARKER) => { + if !data.is_empty() { + data.truncate(data.len() - 1); + } + data + } + Some(_) => { + trc::event!(Store(StoreEvent::BlobMissingMarker), Key = key); + + data + } + None => { + return Ok(Some(data)); + } + }; + + if range.start == 0 { + if range.end > data.len() { + Ok(Some(data)) + } else { + data.truncate(range.end); + Ok(Some(data)) + } + } else { + Ok(Some( + data.get(range.start..range.end) + .unwrap_or_default() + .to_vec(), + )) + } + } + + pub async fn put_blob( + &self, + key: &[u8], + data: &[u8], + compression: CompressionAlgo, + ) -> trc::Result<()> { + let data = match compression { + CompressionAlgo::None => { + let mut uncompressed = Vec::with_capacity(data.len() + 1); + uncompressed.extend_from_slice(data); + uncompressed.push(NONE_MARKER); + uncompressed + } + CompressionAlgo::Lz4 => { + let mut compressed = + vec![ + LZ4_MARKER; + lz4_flex::block::get_maximum_output_size(data.len()) + U32_LEN + 1 + ]; + + // Compress the data + let compressed_len = + lz4_flex::compress_into(data, &mut compressed[U32_LEN..]).unwrap(); + + // Prepend the length of the uncompressed data + compressed[..U32_LEN].copy_from_slice(&(data.len() as u32).to_le_bytes()); + + // Truncate to the actual size + compressed.truncate(compressed_len + U32_LEN + 1); + compressed + } + }; + + let start_time = Instant::now(); + let result = match &self { + BlobStore::Store(store) => match store { + #[cfg(feature = "sqlite")] + Store::SQLite(store) => store.put_blob(key, &data).await, + #[cfg(feature = "foundation")] + Store::FoundationDb(store) => store.put_blob(key, &data).await, + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.put_blob(key, &data).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.put_blob(key, &data).await, + #[cfg(feature = "rocks")] + Store::RocksDb(store) => store.put_blob(key, &data).await, + Store::Ephemeral(store) => store.put_blob(key, &data).await, + Store::None => Err(trc::StoreEvent::NotConfigured.into()), + }, + BlobStore::Fs(store) => store.put_blob(key, &data).await, + #[cfg(feature = "s3")] + BlobStore::S3(store) => store.put_blob(key, &data).await, + #[cfg(feature = "azure")] + BlobStore::Azure(store) => store.put_blob(key, &data).await, + } + .caused_by(trc::location!()); + + trc::event!( + Store(StoreEvent::BlobWrite), + Key = key, + Elapsed = start_time.elapsed(), + Size = data.len(), + ); + + result + } + + pub async fn delete_blob(&self, key: &[u8]) -> trc::Result { + let start_time = Instant::now(); + let result = match &self { + BlobStore::Store(store) => match store { + #[cfg(feature = "sqlite")] + Store::SQLite(store) => store.delete_blob(key).await, + #[cfg(feature = "foundation")] + Store::FoundationDb(store) => store.delete_blob(key).await, + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.delete_blob(key).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.delete_blob(key).await, + #[cfg(feature = "rocks")] + Store::RocksDb(store) => store.delete_blob(key).await, + Store::Ephemeral(store) => store.delete_blob(key).await, + Store::None => Err(trc::StoreEvent::NotConfigured.into()), + }, + BlobStore::Fs(store) => store.delete_blob(key).await, + #[cfg(feature = "s3")] + BlobStore::S3(store) => store.delete_blob(key).await, + #[cfg(feature = "azure")] + BlobStore::Azure(store) => store.delete_blob(key).await, + } + .caused_by(trc::location!()); + + trc::event!( + Store(StoreEvent::BlobWrite), + Key = key, + Elapsed = start_time.elapsed(), + ); + + result + } +} diff --git a/crates/store/src/dispatch/lookup.rs b/crates/store/src/dispatch/lookup.rs new file mode 100644 index 0000000..8f2f957 --- /dev/null +++ b/crates/store/src/dispatch/lookup.rs @@ -0,0 +1,625 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use registry::schema::structs::Rate; +use std::borrow::Cow; +use trc::AddContext; + +#[allow(unused_imports)] +use crate::{ + Deserialize, InMemoryStore, IterateParams, QueryResult, Store, U64_LEN, Value, ValueKey, + write::{ + BatchBuilder, Operation, ValueClass, ValueOp, + key::{DeserializeBigEndian, KeySerializer}, + now, + }, +}; +use crate::{ + SerializeInfallible, + backend::{http::lookup::HttpStoreGet, memory::StaticMemoryStore}, + write::{InMemoryClass, assert::AssertValue}, +}; + +pub struct KeyValue { + pub key: Vec, + pub value: T, + pub expires: Option, +} + +impl InMemoryStore { + pub async fn key_set(&self, kv: KeyValue>) -> trc::Result<()> { + match self { + InMemoryStore::Store(store) => { + let mut batch = BatchBuilder::new(); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(kv.key)), + op: ValueOp::Set( + KeySerializer::new(kv.value.len() + U64_LEN) + .write(kv.expires.map_or(u64::MAX, |expires| now() + expires)) + .write(kv.value.as_slice()) + .finalize(), + ), + }); + store.write(batch.build_all()).await.map(|_| ()) + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_set(&kv.key, &kv.value, kv.expires).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn counter_incr(&self, kv: KeyValue, return_value: bool) -> trc::Result { + match self { + InMemoryStore::Store(store) => { + let mut batch = BatchBuilder::new(); + + if let Some(expires) = kv.expires { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(kv.key.clone())), + op: ValueOp::Set( + KeySerializer::new(U64_LEN * 2) + .write(0u64) + .write(now() + expires) + .finalize(), + ), + }); + } + + if return_value { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Counter(kv.key)), + op: ValueOp::AddAndGet(kv.value), + }); + + store + .write(batch.build_all()) + .await + .and_then(|r| r.last_counter_id()) + } else { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Counter(kv.key)), + op: ValueOp::AtomicAdd(kv.value), + }); + + store.write(batch.build_all()).await.map(|_| 0) + } + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_incr(&kv.key, kv.value, kv.expires).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn key_delete(&self, key: impl Into>) -> trc::Result<()> { + match self { + InMemoryStore::Store(store) => { + let mut batch = BatchBuilder::new(); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key.into().into_bytes())), + op: ValueOp::Clear, + }); + store.write(batch.build_all()).await.map(|_| ()) + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_delete(key.into().as_bytes()).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn counter_delete(&self, key: impl Into>) -> trc::Result<()> { + match self { + InMemoryStore::Store(store) => { + let mut batch = BatchBuilder::new(); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Counter(key.into().into_bytes())), + op: ValueOp::Clear, + }); + store.write(batch.build_all()).await.map(|_| ()) + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_delete(key.into().as_bytes()).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn key_delete_prefix(&self, prefix: &[u8]) -> trc::Result<()> { + match self { + InMemoryStore::Store(store) => { + if prefix.is_empty() { + return Ok(()); + } + + let from_range = prefix.to_vec(); + let mut to_range = Vec::with_capacity(prefix.len() + 3); + to_range.extend_from_slice(prefix); + to_range.extend_from_slice([u8::MAX, u8::MAX, u8::MAX].as_ref()); + + store + .delete_range( + ValueKey::from(ValueClass::InMemory(InMemoryClass::Counter( + from_range.clone(), + ))), + ValueKey::from(ValueClass::InMemory(InMemoryClass::Counter( + to_range.clone(), + ))), + ) + .await?; + + store + .delete_range( + ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(from_range))), + ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(to_range))), + ) + .await + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_delete_prefix(prefix).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn key_get> + std::fmt::Debug + 'static>( + &self, + key: impl Into>, + ) -> trc::Result> { + match self { + InMemoryStore::Store(store) => store + .get_value::>(ValueKey::from(ValueClass::InMemory( + InMemoryClass::Key(key.into().into_bytes()), + ))) + .await + .map(|value| value.and_then(|v| v.into())), + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_get(key.into().as_bytes()).await, + InMemoryStore::Static(store) => Ok(match store.as_ref() { + StaticMemoryStore::Map(map) => map + .get(key.into().as_str()) + .map(|value| T::from(value.clone())), + StaticMemoryStore::Set(set) => { + if set.contains(key.into().as_str()) { + Some(T::from(Value::Bool(true))) + } else { + None + } + } + }), + InMemoryStore::Http(store) => { + Ok(store.get(key.into().as_str()).map(|value| T::from(value))) + } + } + .caused_by(trc::location!()) + } + + pub async fn counter_get(&self, key: impl Into>) -> trc::Result { + match self { + InMemoryStore::Store(store) => { + store + .get_counter(ValueKey::from(ValueClass::InMemory( + InMemoryClass::Counter(key.into().into_bytes()), + ))) + .await + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.counter_get(key.into().as_bytes()).await, + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + .caused_by(trc::location!()) + } + + pub async fn key_exists(&self, key: impl Into>) -> trc::Result { + match self { + InMemoryStore::Store(store) => store + .get_value::>(ValueKey::from(ValueClass::InMemory( + InMemoryClass::Key(key.into().into_bytes()), + ))) + .await + .map(|value| matches!(value, Some(LookupValue::Value(Empty)))), + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => store.key_exists(key.into().as_bytes()).await, + InMemoryStore::Static(store) => Ok(match store.as_ref() { + StaticMemoryStore::Map(map) => map.get(key.into().as_str()).is_some(), + StaticMemoryStore::Set(set) => set.contains(key.into().as_str()), + }), + InMemoryStore::Http(store) => Ok(store.contains(key.into().as_str())), + } + .caused_by(trc::location!()) + } + + pub async fn is_rate_allowed( + &self, + prefix: u8, + key: &[u8], + rate: &Rate, + soft_check: bool, + ) -> trc::Result> { + let now = now(); + let period = rate.period.as_secs().max(1); + let range_start = now / period; + let range_end = (range_start * period) + period; + let expires_in = range_end - now; + + let mut bucket = Vec::with_capacity(key.len() + U64_LEN + 1); + bucket.push(prefix); + bucket.extend_from_slice(key); + bucket.extend_from_slice(range_start.to_be_bytes().as_slice()); + + let requests = if !soft_check { + self.counter_incr(KeyValue::new(bucket, 1).expires(expires_in), true) + .await + .caused_by(trc::location!())? + } else { + self.counter_get(bucket).await.caused_by(trc::location!())? + 1 + }; + + if requests <= rate.count as i64 { + Ok(None) + } else { + Ok(Some(expires_in)) + } + } + + pub async fn try_lock(&self, prefix: u8, key: &[u8], duration: u64) -> trc::Result { + match self { + InMemoryStore::Store(store) => { + let key = KeyValue::<()>::build_key(prefix, key); + let lock_expiry = match store + .get_value::(ValueKey::from(ValueClass::InMemory(InMemoryClass::Key( + key.clone(), + )))) + .await + { + Ok(lock_expiry) => lock_expiry, + Err(err) + if err.matches(trc::EventType::Store(trc::StoreEvent::DataCorruption)) => + { + // TODO remove in 1.0 + let mut batch = BatchBuilder::new(); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key.clone())), + op: ValueOp::Clear, + }); + store + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + None + } + Err(err) => { + return Err(err + .details("Failed to read lock.") + .caused_by(trc::location!())); + } + }; + + let now = now(); + if lock_expiry.is_some_and(|expiry| expiry > now) { + return Ok(false); + } + + let key: ValueClass = ValueClass::InMemory(InMemoryClass::Key(key)); + let mut batch = BatchBuilder::new(); + batch.assert_value( + key.clone(), + match lock_expiry { + Some(value) => AssertValue::U64(value), + None => AssertValue::None, + }, + ); + batch.set(key.clone(), (now + duration).serialize()); + match store.write(batch.build_all()).await { + Ok(_) => Ok(true), + Err(err) if err.is_assertion_failure() => Ok(false), + Err(err) => Err(err + .details("Failed to lock event.") + .caused_by(trc::location!())), + } + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(store) => { + store + .try_lock(&KeyValue::<()>::build_key(prefix, key), duration) + .await + } + InMemoryStore::Static(_) | InMemoryStore::Http(_) => { + Err(trc::StoreEvent::NotSupported.into_err()) + } + } + } + + pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> { + self.key_delete(KeyValue::<()>::build_key(prefix, key)) + .await + } + + pub async fn purge_in_memory_store(&self) -> trc::Result<()> { + match self { + InMemoryStore::Store(store) => { + // Delete expired keys and counters + let from_key = ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(vec![0u8]))); + let to_key = + ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(vec![u8::MAX; 10]))); + + let current_time = now(); + let mut expired_keys = Vec::new(); + let mut expired_counters = Vec::new(); + store + .iterate(IterateParams::new(from_key, to_key), |key, value| { + let expiry = value.deserialize_be_u64(0).caused_by(trc::location!())?; + if expiry == 0 { + if value + .deserialize_be_u64(U64_LEN) + .caused_by(trc::location!())? + <= current_time + { + expired_counters.push(key.to_vec()); + } + } else if expiry <= current_time { + expired_keys.push(key.to_vec()); + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + + if !expired_keys.is_empty() { + let mut batch = BatchBuilder::new(); + for key in expired_keys { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key)), + op: ValueOp::Clear, + }); + if batch.is_large_batch() { + store + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + } + if !batch.is_empty() { + store + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } + + if !expired_counters.is_empty() { + let mut batch = BatchBuilder::new(); + for key in expired_counters { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Counter(key.clone())), + op: ValueOp::Clear, + }); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key)), + op: ValueOp::Clear, + }); + if batch.is_large_batch() { + store + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + } + if !batch.is_empty() { + store + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } + } + #[cfg(feature = "redis")] + InMemoryStore::Redis(_) => {} + InMemoryStore::Static(_) | InMemoryStore::Http(_) => {} + } + + Ok(()) + } + + pub fn is_sql(&self) -> bool { + match self { + InMemoryStore::Store(store) => store.is_sql(), + _ => false, + } + } + + pub fn is_redis(&self) -> bool { + match self { + #[cfg(feature = "redis")] + InMemoryStore::Redis(_) => true, + InMemoryStore::Static(_) => false, + _ => false, + } + } + + pub fn into_store(self) -> Option { + match self { + InMemoryStore::Store(store) => Some(store), + _ => None, + } + } +} + +pub enum LookupKey<'x> { + String(String), + StringRef(&'x str), + Bytes(Vec), + BytesRef(&'x [u8]), +} + +impl<'x> From<&'x str> for LookupKey<'x> { + fn from(key: &'x str) -> Self { + LookupKey::StringRef(key) + } +} + +impl<'x> From<&'x String> for LookupKey<'x> { + fn from(key: &'x String) -> Self { + LookupKey::StringRef(key.as_str()) + } +} + +impl<'x> From<&'x [u8]> for LookupKey<'x> { + fn from(key: &'x [u8]) -> Self { + LookupKey::BytesRef(key) + } +} + +impl<'x> From> for LookupKey<'x> { + fn from(key: Cow<'x, str>) -> Self { + match key { + Cow::Borrowed(key) => LookupKey::StringRef(key), + Cow::Owned(key) => LookupKey::String(key), + } + } +} + +impl From for LookupKey<'static> { + fn from(key: String) -> Self { + LookupKey::String(key) + } +} + +impl From> for LookupKey<'static> { + fn from(key: Vec) -> Self { + LookupKey::Bytes(key) + } +} + +impl LookupKey<'_> { + pub fn as_str(&self) -> &str { + match self { + LookupKey::String(string) => string, + LookupKey::StringRef(string) => string, + LookupKey::Bytes(bytes) => std::str::from_utf8(bytes).unwrap_or_default(), + LookupKey::BytesRef(bytes) => std::str::from_utf8(bytes).unwrap_or_default(), + } + } + + pub fn into_bytes(self) -> Vec { + match self { + LookupKey::String(string) => string.into_bytes(), + LookupKey::StringRef(string) => string.as_bytes().to_vec(), + LookupKey::Bytes(bytes) => bytes, + LookupKey::BytesRef(bytes) => bytes.to_vec(), + } + } + + pub fn as_bytes(&self) -> &[u8] { + match self { + LookupKey::String(string) => string.as_bytes(), + LookupKey::StringRef(string) => string.as_bytes(), + LookupKey::Bytes(bytes) => bytes.as_slice(), + LookupKey::BytesRef(bytes) => bytes, + } + } +} + +impl KeyValue { + pub fn build_key(prefix: u8, key: impl AsRef<[u8]>) -> Vec { + let key_ = key.as_ref(); + let mut key = Vec::with_capacity(key_.len() + 1); + key.push(prefix); + key.extend_from_slice(key_); + key + } + + pub fn with_prefix(prefix: u8, key: impl AsRef<[u8]>, value: T) -> Self { + Self { + key: Self::build_key(prefix, key), + value, + expires: None, + } + } + + pub fn new(key: impl Into>, value: T) -> Self { + Self { + key: key.into(), + value, + expires: None, + } + } + + pub fn expires(mut self, expires: u64) -> Self { + self.expires = expires.into(); + self + } + + pub fn expires_opt(mut self, expires: Option) -> Self { + self.expires = expires; + self + } +} + +struct Empty; + +enum LookupValue { + Value(T), + None, +} + +impl Deserialize for LookupValue { + fn deserialize(bytes: &[u8]) -> trc::Result { + bytes.deserialize_be_u64(0).and_then(|expires| { + Ok(if expires > now() { + LookupValue::Value( + T::deserialize(bytes.get(U64_LEN..).unwrap_or_default()) + .caused_by(trc::location!())?, + ) + } else { + LookupValue::None + }) + }) + } +} + +impl Deserialize for Empty { + fn deserialize(_bytes: &[u8]) -> trc::Result { + Ok(Empty) + } +} + +impl From> for Option { + fn from(value: LookupValue) -> Self { + match value { + LookupValue::Value(value) => Some(value), + LookupValue::None => None, + } + } +} + +impl From> for String { + fn from(value: Value<'static>) -> Self { + match value { + Value::Text(string) => string.into_owned(), + Value::Blob(bytes) => String::from_utf8_lossy(bytes.as_ref()).into_owned(), + Value::Bool(boolean) => boolean.to_string(), + Value::Null => String::new(), + Value::Integer(num) => num.to_string(), + Value::Float(num) => num.to_string(), + } + } +} diff --git a/crates/store/src/dispatch/mod.rs b/crates/store/src/dispatch/mod.rs new file mode 100644 index 0000000..205affd --- /dev/null +++ b/crates/store/src/dispatch/mod.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Store; +use roaring::RoaringBitmap; + +pub mod blob; +pub mod lookup; +pub mod search; +pub mod store; + +impl Store { + pub fn id(&self) -> &'static str { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(_) => "sqlite", + #[cfg(feature = "foundation")] + Self::FoundationDb(_) => "foundationdb", + #[cfg(feature = "postgres")] + Self::PostgreSQL(_) => "postgresql", + #[cfg(feature = "mysql")] + Self::MySQL(_) => "mysql", + #[cfg(feature = "rocks")] + Self::RocksDb(_) => "rocksdb", + Self::Ephemeral(_) => "ephemeral", + Self::None => "none", + } + } +} + +#[allow(clippy::len_without_is_empty)] +pub trait DocumentSet: Sync + Send { + fn min(&self) -> u32; + fn max(&self) -> u32; + fn contains(&self, id: u32) -> bool; + fn len(&self) -> usize; + fn iterate(&self) -> impl Iterator; +} + +impl DocumentSet for RoaringBitmap { + fn min(&self) -> u32 { + self.min().unwrap_or(0) + } + + fn max(&self) -> u32 { + self.max().map(|m| m + 1).unwrap_or(0) + } + + fn contains(&self, id: u32) -> bool { + self.contains(id) + } + + fn len(&self) -> usize { + self.len() as usize + } + + fn iterate(&self) -> impl Iterator { + self.iter() + } +} + +impl DocumentSet for Vec { + fn contains(&self, id: u32) -> bool { + self.binary_search(&id).is_ok() + } + + fn min(&self) -> u32 { + self.first().copied().unwrap_or(0) + } + + fn max(&self) -> u32 { + self.last().copied().map(|m| m + 1).unwrap_or(0) + } + + fn len(&self) -> usize { + self.len() + } + + fn iterate(&self) -> impl Iterator { + self.iter().copied() + } +} + +impl DocumentSet for () { + fn min(&self) -> u32 { + 0 + } + + fn max(&self) -> u32 { + u32::MAX + } + + fn contains(&self, _: u32) -> bool { + true + } + + fn len(&self) -> usize { + 0 + } + + fn iterate(&self) -> impl Iterator { + std::iter::empty() + } +} diff --git a/crates/store/src/dispatch/search.rs b/crates/store/src/dispatch/search.rs new file mode 100644 index 0000000..5e01844 --- /dev/null +++ b/crates/store/src/dispatch/search.rs @@ -0,0 +1,337 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + SearchStore, Store, + search::{ + IndexDocument, SearchComparator, SearchField, SearchFilter, SearchOperator, SearchQuery, + SearchValue, + split::{SplitFilter, split_filters}, + }, + write::SearchIndex, +}; +use std::cmp::Ordering; +use trc::AddContext; + +impl SearchStore { + pub async fn query_account(&self, query: SearchQuery) -> trc::Result> { + // Pre-filter by mask + if query.mask.is_empty() { + return Ok(vec![]); + } + + // If the store does not support FTS, use the internal FTS store + if let Some(store) = self.internal_fts() { + return store.query_account(query).await; + } + + // If all filters and comparators are external, delegate to the underlying store + let mut account_id = u32::MAX; + let mut has_local_filters = false; + let mut has_external_filters = false; + for filter in &query.filters { + match filter { + SearchFilter::Operator { + field: SearchField::AccountId, + op: SearchOperator::Equal, + value: SearchValue::Uint(id), + } => { + account_id = *id as u32; + } + SearchFilter::DocumentSet(_) => { + has_local_filters = true; + } + SearchFilter::Operator { .. } => { + has_external_filters = true; + } + _ => (), + } + } + + if account_id == u32::MAX { + return Err(trc::StoreEvent::UnexpectedError + .reason("Account ID filter is required for account queries") + .caused_by(trc::location!())); + } + + if !has_local_filters && !has_external_filters && query.comparators.is_empty() { + return Ok(query.mask.iter().collect()); + } + + if !has_local_filters && query.comparators.iter().all(|c| c.is_external()) { + return self + .sub_query(query.index, &query.filters, &query.comparators) + .await + .map(|results| { + if !results.is_empty() || has_external_filters { + results + .into_iter() + .filter(|id| query.mask.contains(*id)) + .collect() + } else { + // Database sort is broken, return masked results + query.mask.iter().collect() + } + }) + .caused_by(trc::location!()); + } + + let filters = if has_external_filters { + // Split filters + let split_filters = split_filters(query.filters).ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .reason("Invalid filter query") + .caused_by(trc::location!()) + })?; + + let mut filters = Vec::with_capacity(split_filters.len()); + for split_filter in split_filters { + match split_filter { + SplitFilter::External(external) => { + // Execute sub-query + filters.push(SearchFilter::DocumentSet( + self.sub_query(query.index, &external, &[]) + .await? + .into_iter() + .collect(), + )); + } + SplitFilter::Internal(filter) => { + filters.push(filter); + } + } + } + + filters + } else { + query.filters + }; + + // Merge results locally + let results = SearchQuery::new(query.index) + .with_filters(filters) + .with_mask(query.mask) + .filter(); + + let total_results = results.results().len(); + match total_results.cmp(&1) { + Ordering::Equal => Ok(vec![results.results().min().unwrap()]), + Ordering::Less => Ok(vec![]), + Ordering::Greater => { + if !query.comparators.is_empty() { + let mut local = Vec::with_capacity(query.comparators.len()); + let mut external = Vec::with_capacity(query.comparators.len()); + let mut external_first = false; + for (pos, comparator) in query.comparators.into_iter().enumerate() { + if comparator.is_external() { + external.push(comparator); + if pos == 0 { + external_first = true; + } + } else { + local.push(comparator); + } + } + + if !external.is_empty() { + let mut results = results.results().clone(); + let filters = vec![ + SearchFilter::Operator { + field: SearchField::AccountId, + op: SearchOperator::Equal, + value: SearchValue::Uint(account_id as u64), + }, + SearchFilter::Operator { + field: SearchField::DocumentId, + op: SearchOperator::GreaterEqualThan, + value: SearchValue::Uint(results.min().unwrap() as u64), + }, + SearchFilter::Operator { + field: SearchField::DocumentId, + op: SearchOperator::LowerEqualThan, + value: SearchValue::Uint(results.max().unwrap() as u64), + }, + ]; + + let mut ordered_results = Vec::with_capacity(total_results as usize); + for ordered_result in + self.sub_query(query.index, &filters, &external).await? + { + if results.remove(ordered_result) { + ordered_results.push(ordered_result); + } + } + // Add any remaining results not yet in the index + ordered_results.extend(results); + + if local.is_empty() { + return Ok(ordered_results); + } + + let comparator = SearchComparator::SortedSet { + set: ordered_results + .into_iter() + .enumerate() + .map(|(pos, id)| (id, pos as u32)) + .collect(), + ascending: true, + }; + + if external_first { + local.insert(0, comparator); + } else { + local.push(comparator); + } + } + + Ok(results.with_comparators(local).into_sorted()) + } else { + Ok(results.results().iter().collect()) + } + } + } + } + + async fn sub_query( + &self, + index: SearchIndex, + filters: &[SearchFilter], + sort: &[SearchComparator], + ) -> trc::Result> { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.query(index, filters, sort).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.query(index, filters, sort).await, + _ => unreachable!(), + }, + SearchStore::ElasticSearch(store) => store.query(index, filters, sort).await, + SearchStore::MeiliSearch(store) => store.query(index, filters, sort).await, + } + } + + pub async fn query_global(&self, query: SearchQuery) -> trc::Result> { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => { + store + .query(query.index, &query.filters, &query.comparators) + .await + } + #[cfg(feature = "mysql")] + Store::MySQL(store) => { + store + .query(query.index, &query.filters, &query.comparators) + .await + } + store => store.query_global(query).await, + }, + SearchStore::ElasticSearch(store) => { + store + .query(query.index, &query.filters, &query.comparators) + .await + } + SearchStore::MeiliSearch(store) => { + store + .query(query.index, &query.filters, &query.comparators) + .await + } + } + } + + pub async fn index(&self, documents: Vec) -> trc::Result<()> { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.index(documents).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.index(documents).await, + store => store.index(documents).await, + }, + SearchStore::ElasticSearch(store) => store.index(documents).await, + SearchStore::MeiliSearch(store) => store.index(documents).await, + } + } + + pub async fn unindex(&self, query: SearchQuery) -> trc::Result { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.unindex(query).await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.unindex(query).await, + store => store.unindex(query).await.map(|_| 0), + }, + SearchStore::ElasticSearch(store) => store.unindex(query).await, + SearchStore::MeiliSearch(store) => store.unindex(query).await, + } + } + + pub fn internal_fts(&self) -> Option<&Store> { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(_) => None, + #[cfg(feature = "mysql")] + Store::MySQL(_) => None, + store => Some(store), + }, + _ => None, + } + } + + pub fn is_mysql(&self) -> bool { + match self { + #[cfg(feature = "mysql")] + SearchStore::Store(Store::MySQL(_)) => true, + _ => false, + } + } + + pub fn is_postgres(&self) -> bool { + match self { + #[cfg(feature = "postgres")] + SearchStore::Store(Store::PostgreSQL(_)) => true, + _ => false, + } + } + + pub fn is_elasticsearch(&self) -> bool { + matches!(self, SearchStore::ElasticSearch(_)) + } + + pub fn is_meilisearch(&self) -> bool { + matches!(self, SearchStore::MeiliSearch(_)) + } + + pub async fn create_indexes(&self) -> trc::Result<()> { + match self { + SearchStore::Store(store) => match store { + #[cfg(feature = "postgres")] + Store::PostgreSQL(store) => store.create_search_tables().await, + #[cfg(feature = "mysql")] + Store::MySQL(store) => store.create_search_tables().await, + _ => Ok(()), + }, + SearchStore::ElasticSearch(store) => store.create_indexes().await, + SearchStore::MeiliSearch(store) => store.create_indexes().await, + } + } +} + +impl SearchFilter { + pub fn is_external(&self) -> bool { + matches!(self, SearchFilter::Operator { .. }) + } +} + +impl SearchComparator { + pub fn is_external(&self) -> bool { + matches!(self, SearchComparator::Field { .. }) + } +} diff --git a/crates/store/src/dispatch/store.rs b/crates/store/src/dispatch/store.rs new file mode 100644 index 0000000..af693bf --- /dev/null +++ b/crates/store/src/dispatch/store.rs @@ -0,0 +1,373 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::DocumentSet; +use crate::{ + Deserialize, IterateParams, Key, QueryResult, SUBSPACE_COUNTER, SUBSPACE_INDEXES, + SUBSPACE_LOGS, Store, U32_LEN, Value, ValueKey, + write::{ + AnyClass, AnyKey, AssignedIds, Batch, BatchBuilder, Operation, ValueClass, ValueOp, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use compact_str::ToCompactString; +use std::time::Instant; +use trc::{AddContext, StoreEvent}; +use types::collection::Collection; + +impl Store { + pub async fn get_value(&self, key: impl Key) -> trc::Result> + where + U: Deserialize + 'static, + { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.get_value(key).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.get_value(key).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.get_value(key).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.get_value(key).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.get_value(key).await, + Self::Ephemeral(store) => store.get_value(key).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()) + } + + pub async fn key_exists(&self, key: impl Key) -> trc::Result { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.key_exists(key).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.key_exists(key).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.key_exists(key).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.key_exists(key).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.key_exists(key).await, + Self::Ephemeral(store) => store.key_exists(key).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()) + } + + pub async fn iterate( + &self, + params: IterateParams, + cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result + Sync + Send, + ) -> trc::Result<()> { + let start_time = Instant::now(); + let result = match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.iterate(params, cb).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.iterate(params, cb).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.iterate(params, cb).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.iterate(params, cb).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.iterate(params, cb).await, + Self::Ephemeral(store) => store.iterate(params, cb).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()); + + trc::event!( + Store(StoreEvent::DataIterate), + Elapsed = start_time.elapsed(), + ); + + result + } + + pub async fn get_counter( + &self, + key: impl Into> + Sync + Send, + ) -> trc::Result { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.get_counter(key).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.get_counter(key).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.get_counter(key).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.get_counter(key).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.get_counter(key).await, + Self::Ephemeral(store) => store.get_counter(key).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()) + } + + #[allow(unreachable_patterns)] + #[allow(unused_variables)] + pub async fn sql_query( + &self, + query: &str, + params: Vec>, + ) -> trc::Result { + let result = match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.sql_query(query, ¶ms).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.sql_query(query, ¶ms).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.sql_query(query, ¶ms).await, + _ => Err(trc::StoreEvent::NotSupported.into_err()), + }; + + trc::event!( + Store(trc::StoreEvent::SqlQuery), + Details = query.to_compact_string(), + Value = params.as_slice(), + Result = &result, + ); + + result.caused_by(trc::location!()) + } + + pub async fn write(&self, batch: Batch<'_>) -> trc::Result { + let start_time = Instant::now(); + let ops = batch.ops.len(); + + let result = match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.write(batch).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.write(batch).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.write(batch).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.write(batch).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.write(batch).await, + Self::Ephemeral(store) => store.write(batch).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + }; + + trc::event!( + Store(StoreEvent::DataWrite), + Elapsed = start_time.elapsed(), + Total = ops, + ); + + result + } + + pub async fn assign_document_ids( + &self, + account_id: u32, + collection: Collection, + num_ids: u64, + ) -> trc::Result { + // Increment UID next + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(collection) + .add_and_get(ValueClass::DocumentId, num_ids as i64); + self.write(batch.build_all()).await.and_then(|v| { + v.last_counter_id().map(|id| { + debug_assert!(id >= num_ids as i64, "{} < {}", id, num_ids); + id as u32 + }) + }) + } + + pub async fn purge_store(&self) -> trc::Result<()> { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.purge_store().await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.purge_store().await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.purge_store().await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.purge_store().await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.purge_store().await, + Self::Ephemeral(store) => store.purge_store().await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()) + } + + pub async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.delete_range(from, to).await, + #[cfg(feature = "foundation")] + Self::FoundationDb(store) => store.delete_range(from, to).await, + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.delete_range(from, to).await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.delete_range(from, to).await, + #[cfg(feature = "rocks")] + Self::RocksDb(store) => store.delete_range(from, to).await, + Self::Ephemeral(store) => store.delete_range(from, to).await, + Self::None => Err(trc::StoreEvent::NotConfigured.into()), + } + .caused_by(trc::location!()) + } + + pub async fn delete_documents( + &self, + subspace: u8, + account_id: u32, + collection: u8, + collection_offset: Option, + document_ids: &impl DocumentSet, + ) -> trc::Result<()> { + // Serialize keys + let (from_key, to_key) = if collection_offset.is_some() { + ( + KeySerializer::new(U32_LEN + 2) + .write(account_id) + .write(collection), + KeySerializer::new(U32_LEN + 2) + .write(account_id) + .write(collection + 1), + ) + } else { + ( + KeySerializer::new(U32_LEN).write(account_id), + KeySerializer::new(U32_LEN).write(account_id + 1), + ) + }; + + // Find keys to delete + let mut delete_keys = Vec::new(); + self.iterate( + IterateParams::new( + AnyKey { + subspace, + key: from_key.finalize(), + }, + AnyKey { + subspace, + key: to_key.finalize(), + }, + ) + .no_values(), + |key, _| { + if collection_offset.is_none_or(|offset| { + key.get(key.len() - U32_LEN - offset).copied() == Some(collection) + }) { + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + if document_ids.contains(document_id) { + delete_keys.push(key.to_vec()); + } + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + // Remove keys + let mut batch = BatchBuilder::new(); + + for key in delete_keys { + if batch.is_large_batch() { + self.write(std::mem::take(&mut batch).build_all()) + .await + .caused_by(trc::location!())?; + } + batch.any_op(Operation::Value { + class: ValueClass::Any(AnyClass { subspace, key }), + op: ValueOp::Clear, + }); + } + + if !batch.is_empty() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(()) + } + + pub async fn danger_destroy_account(&self, account_id: u32) -> trc::Result<()> { + for subspace in [SUBSPACE_LOGS, SUBSPACE_INDEXES, SUBSPACE_COUNTER] { + self.delete_range( + AnyKey { + subspace, + key: KeySerializer::new(U32_LEN).write(account_id).finalize(), + }, + AnyKey { + subspace, + key: KeySerializer::new(U32_LEN).write(account_id + 1).finalize(), + }, + ) + .await + .caused_by(trc::location!())?; + } + + self.delete_range( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Acl(account_id), + }, + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Acl(account_id + 1), + }, + ) + .await + .caused_by(trc::location!())?; + + self.delete_range( + ValueKey { + account_id, + collection: 0, + document_id: 0, + class: ValueClass::Property(0), + }, + ValueKey { + account_id: account_id + 1, + collection: 0, + document_id: 0, + class: ValueClass::Property(0), + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(()) + } + + pub async fn create_tables(&self) -> trc::Result<()> { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(store) => store.create_tables(), + #[cfg(feature = "postgres")] + Self::PostgreSQL(store) => store.create_storage_tables().await, + #[cfg(feature = "mysql")] + Self::MySQL(store) => store.create_storage_tables().await, + _ => Ok(()), + } + } + + pub fn invalidate_read_snapshot(&self) { + #[cfg(feature = "foundation")] + if let Self::FoundationDb(store) = self { + store.invalidate_read_snapshot(); + } + } +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs new file mode 100644 index 0000000..ae776ba --- /dev/null +++ b/crates/store/src/lib.rs @@ -0,0 +1,737 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod backend; +pub mod build; +pub mod dispatch; +pub mod query; +pub mod registry; +pub mod search; +pub mod write; + +use ::registry::schema::enums::CompressionAlgo; +pub use ahash; +pub use blake3; +pub use parking_lot; +pub use rand; +pub use rkyv; +pub use roaring; +use utils::snowflake::SnowflakeIdGenerator; +pub use xxhash_rust; + +use crate::backend::{elastic::ElasticSearchStore, meili::MeiliSearchStore}; +use ahash::AHashMap; +use backend::{ephemeral::EphemeralStore, fs::FsStore, http::HttpStore, memory::StaticMemoryStore}; +use std::{borrow::Cow, path::PathBuf, sync::Arc}; +use write::ValueClass; + +pub trait Deserialize: Sized + Sync + Send { + fn deserialize(bytes: &[u8]) -> trc::Result; + + #[inline(always)] + fn deserialize_owned(bytes: Vec) -> trc::Result { + Self::deserialize(&bytes) + } + + #[inline(always)] + fn deserialize_with_key(_: &[u8], bytes: &[u8]) -> trc::Result { + Self::deserialize(bytes) + } + + #[inline(always)] + fn deserialize_owned_with_key(key: &[u8], bytes: Vec) -> trc::Result { + Self::deserialize_with_key(key, &bytes) + } +} + +pub trait Serialize { + fn serialize(&self) -> trc::Result>; +} + +pub trait SerializeInfallible { + fn serialize(&self) -> Vec; +} + +// Key serialization flags +pub(crate) const WITH_SUBSPACE: u32 = 1; + +pub trait Key: Sync + Send + Clone { + fn serialize(&self, flags: u32) -> Vec; + fn subspace(&self) -> u8; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct IndexKey> { + pub account_id: u32, + pub collection: u8, + pub document_id: u32, + pub field: u8, + pub key: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct IndexKeyPrefix { + pub account_id: u32, + pub collection: u8, + pub field: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ValueKey> { + pub account_id: u32, + pub collection: u8, + pub document_id: u32, + pub class: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LogKey { + pub account_id: u32, + pub collection: u8, + pub change_id: u64, +} + +pub const U64_LEN: usize = std::mem::size_of::(); +pub const U32_LEN: usize = std::mem::size_of::(); +pub const U16_LEN: usize = std::mem::size_of::(); + +pub const SUBSPACE_ACL: u8 = b'a'; +pub const SUBSPACE_TASK_QUEUE: u8 = b'f'; +pub const SUBSPACE_INDEXES: u8 = b'i'; +pub const SUBSPACE_BLOB_LINK: u8 = b'k'; +pub const SUBSPACE_BLOBS: u8 = b't'; +pub const SUBSPACE_LOGS: u8 = b'l'; +pub const SUBSPACE_COUNTER: u8 = b'n'; +pub const SUBSPACE_IN_MEMORY_VALUE: u8 = b'm'; +pub const SUBSPACE_IN_MEMORY_COUNTER: u8 = b'y'; +pub const SUBSPACE_PROPERTY: u8 = b'p'; +pub const SUBSPACE_REGISTRY: u8 = b's'; +pub const SUBSPACE_REGISTRY_IDX: u8 = b'b'; +pub const SUBSPACE_REGISTRY_PK: u8 = b'g'; +pub const SUBSPACE_DIRECTORY: u8 = b'd'; +pub const SUBSPACE_QUEUE_MESSAGE: u8 = b'e'; +pub const SUBSPACE_QUEUE_EVENT: u8 = b'q'; +pub const SUBSPACE_QUOTA: u8 = b'u'; +pub const SUBSPACE_REPORT_OUT: u8 = b'h'; +pub const SUBSPACE_REPORT_IN: u8 = b'r'; +pub const SUBSPACE_TELEMETRY_SPAN: u8 = b'o'; +pub const SUBSPACE_TELEMETRY_METRIC: u8 = b'x'; +pub const SUBSPACE_SEARCH_INDEX: u8 = b'z'; +pub const SUBSPACE_DELETED_ITEMS: u8 = b'j'; +pub const SUBSPACE_SPAM_SAMPLES: u8 = b'w'; + +// TODO: Remove in v1.0 +pub const LEGACY_SUBSPACE_BITMAP_TEXT: u8 = b'v'; +pub const LEGACY_SUBSPACE_BITMAP_TAG: u8 = b'c'; + +#[derive(Clone)] +pub struct IterateParams { + begin: T, + end: T, + first: bool, + ascending: bool, + values: bool, +} + +#[derive(Clone, Default)] +pub struct LookupStores { + pub stores: AHashMap, InMemoryStore>, +} + +#[derive(Clone, Default)] +pub enum Store { + #[cfg(feature = "sqlite")] + SQLite(Arc), + #[cfg(feature = "foundation")] + FoundationDb(Arc), + #[cfg(feature = "postgres")] + PostgreSQL(Arc), + #[cfg(feature = "mysql")] + MySQL(Arc), + #[cfg(feature = "rocks")] + RocksDb(Arc), + Ephemeral(Arc), + #[default] + None, +} + +#[derive(Clone)] +pub enum BlobStore { + Store(Store), + Fs(Arc), + #[cfg(feature = "s3")] + S3(Arc), + #[cfg(feature = "azure")] + Azure(Arc), +} + +#[derive(Clone)] +pub enum SearchStore { + Store(Store), + ElasticSearch(Arc), + MeiliSearch(Arc), +} + +#[derive(Clone, Debug)] +pub enum InMemoryStore { + Store(Store), + #[cfg(feature = "redis")] + Redis(Arc), + Http(Arc), + Static(Arc), +} + +#[derive(Clone)] +pub struct RegistryStore(pub(crate) Arc); + +#[derive(Clone)] +pub struct RegistryStoreInner { + pub(crate) local_path: PathBuf, + pub(crate) store: Store, + pub(crate) node_id: u16, + pub(crate) env_recovery_mode: bool, + pub(crate) env_recovery_admin: Option<(String, String)>, + pub(crate) env_cluster_role: Option, + pub(crate) env_push_shard_id: u32, + pub(crate) env_hostname: String, + pub(crate) env_public_url: Option, + pub(crate) id_generator: SnowflakeIdGenerator, +} + +#[cfg(feature = "sqlite")] +impl From for Store { + fn from(store: backend::sqlite::SqliteStore) -> Self { + Self::SQLite(Arc::new(store)) + } +} + +#[cfg(feature = "foundation")] +impl From for Store { + fn from(store: backend::foundationdb::FdbStore) -> Self { + Self::FoundationDb(Arc::new(store)) + } +} + +#[cfg(feature = "postgres")] +impl From for Store { + fn from(store: backend::postgres::PostgresStore) -> Self { + Self::PostgreSQL(Arc::new(store)) + } +} + +#[cfg(feature = "mysql")] +impl From for Store { + fn from(store: backend::mysql::MysqlStore) -> Self { + Self::MySQL(Arc::new(store)) + } +} + +#[cfg(feature = "rocks")] +impl From for Store { + fn from(store: backend::rocksdb::RocksDbStore) -> Self { + Self::RocksDb(Arc::new(store)) + } +} + +impl From for Store { + fn from(store: EphemeralStore) -> Self { + Self::Ephemeral(Arc::new(store)) + } +} + +impl From for SearchStore { + fn from(store: ElasticSearchStore) -> Self { + Self::ElasticSearch(Arc::new(store)) + } +} + +impl From for SearchStore { + fn from(store: MeiliSearchStore) -> Self { + Self::MeiliSearch(Arc::new(store)) + } +} + +#[cfg(feature = "redis")] +impl From for InMemoryStore { + fn from(store: backend::redis::RedisStore) -> Self { + Self::Redis(Arc::new(store)) + } +} + +impl From for SearchStore { + fn from(store: Store) -> Self { + Self::Store(store) + } +} + +impl From for InMemoryStore { + fn from(store: Store) -> Self { + Self::Store(store) + } +} + +impl From for BlobStore { + fn from(store: Store) -> Self { + Self::Store(store) + } +} + +impl Default for BlobStore { + fn default() -> Self { + Self::Store(Store::None) + } +} + +impl Default for InMemoryStore { + fn default() -> Self { + Self::Store(Store::None) + } +} + +impl Default for SearchStore { + fn default() -> Self { + Self::Store(Store::None) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Value<'x> { + Integer(i64), + Bool(bool), + Float(f64), + Text(Cow<'x, str>), + Blob(Cow<'x, [u8]>), + Null, +} + +impl Eq for Value<'_> {} + +impl<'x> Value<'x> { + pub fn to_str<'y: 'x>(&'y self) -> Cow<'x, str> { + match self { + Value::Text(s) => s.as_ref().into(), + Value::Integer(i) => Cow::Owned(i.to_string()), + Value::Bool(b) => Cow::Owned(b.to_string()), + Value::Float(f) => Cow::Owned(f.to_string()), + Value::Blob(b) => String::from_utf8_lossy(b.as_ref()), + Value::Null => Cow::Borrowed(""), + } + } +} + +#[derive(Clone, Debug)] +pub struct Row { + pub values: Vec>, +} + +#[derive(Clone, Debug)] +pub struct Rows { + pub rows: Vec, +} + +#[derive(Clone, Debug)] +pub struct NamedRows { + pub names: Vec, + pub rows: Vec, +} + +#[derive(Clone, Copy)] +pub enum QueryType { + Execute, + Exists, + QueryAll, + QueryOne, +} + +pub trait QueryResult: Sync + Send + 'static { + fn from_exec(items: usize) -> Self; + fn from_exists(exists: bool) -> Self; + fn from_query_one(items: impl IntoRows) -> Self; + fn from_query_all(items: impl IntoRows) -> Self; + + fn query_type() -> QueryType; +} + +pub trait IntoRows { + fn into_row(self) -> Option; + fn into_rows(self) -> Rows; + fn into_named_rows(self) -> NamedRows; +} + +impl QueryResult for Option { + fn query_type() -> QueryType { + QueryType::QueryOne + } + + fn from_exec(_: usize) -> Self { + unreachable!() + } + + fn from_exists(_: bool) -> Self { + unreachable!() + } + + fn from_query_all(_: impl IntoRows) -> Self { + unreachable!() + } + + fn from_query_one(items: impl IntoRows) -> Self { + items.into_row() + } +} + +impl QueryResult for Rows { + fn query_type() -> QueryType { + QueryType::QueryAll + } + + fn from_exec(_: usize) -> Self { + unreachable!() + } + + fn from_exists(_: bool) -> Self { + unreachable!() + } + + fn from_query_all(items: impl IntoRows) -> Self { + items.into_rows() + } + + fn from_query_one(_: impl IntoRows) -> Self { + unreachable!() + } +} + +impl QueryResult for NamedRows { + fn query_type() -> QueryType { + QueryType::QueryAll + } + + fn from_exec(_: usize) -> Self { + unreachable!() + } + + fn from_exists(_: bool) -> Self { + unreachable!() + } + + fn from_query_all(items: impl IntoRows) -> Self { + items.into_named_rows() + } + + fn from_query_one(_: impl IntoRows) -> Self { + unreachable!() + } +} + +impl QueryResult for bool { + fn query_type() -> QueryType { + QueryType::Exists + } + + fn from_exec(_: usize) -> Self { + unreachable!() + } + + fn from_exists(exists: bool) -> Self { + exists + } + + fn from_query_all(_: impl IntoRows) -> Self { + unreachable!() + } + + fn from_query_one(_: impl IntoRows) -> Self { + unreachable!() + } +} + +impl QueryResult for usize { + fn query_type() -> QueryType { + QueryType::Execute + } + + fn from_exec(items: usize) -> Self { + items + } + + fn from_exists(_: bool) -> Self { + unreachable!() + } + + fn from_query_all(_: impl IntoRows) -> Self { + unreachable!() + } + + fn from_query_one(_: impl IntoRows) -> Self { + unreachable!() + } +} + +impl<'x> From<&'x str> for Value<'x> { + fn from(value: &'x str) -> Self { + Self::Text(value.into()) + } +} + +impl From for Value<'_> { + fn from(value: String) -> Self { + Self::Text(value.into()) + } +} + +impl<'x> From<&'x String> for Value<'x> { + fn from(value: &'x String) -> Self { + Self::Text(value.into()) + } +} + +impl<'x> From> for Value<'x> { + fn from(value: Cow<'x, str>) -> Self { + Self::Text(value) + } +} + +impl From for Value<'_> { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for Value<'_> { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From> for i64 { + fn from(value: Value<'static>) -> Self { + if let Value::Integer(value) = value { + value + } else { + 0 + } + } +} + +impl From for Value<'_> { + fn from(value: u64) -> Self { + Self::Integer(value as i64) + } +} + +impl From for Value<'_> { + fn from(value: u32) -> Self { + Self::Integer(value as i64) + } +} + +impl From for Value<'_> { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl<'x> From<&'x [u8]> for Value<'x> { + fn from(value: &'x [u8]) -> Self { + Self::Blob(value.into()) + } +} + +impl From> for Value<'_> { + fn from(value: Vec) -> Self { + Self::Blob(value.into()) + } +} + +impl Value<'_> { + pub fn into_string(self) -> String { + match self { + Value::Text(s) => s.into_owned(), + Value::Integer(i) => i.to_string(), + Value::Bool(b) => b.to_string(), + Value::Float(f) => f.to_string(), + Value::Blob(b) => String::from_utf8_lossy(b.as_ref()).into_owned(), + Value::Null => "".into(), + } + } + + pub fn into_lower_string(self) -> String { + match self { + Value::Text(s) => s.as_ref().to_lowercase(), + Value::Integer(i) => i.to_string(), + Value::Bool(b) => b.to_string(), + Value::Float(f) => f.to_string(), + Value::Blob(b) => String::from_utf8_lossy(b.as_ref()).to_lowercase(), + Value::Null => "".into(), + } + } +} + +impl From for Vec { + fn from(value: Row) -> Self { + value.values.into_iter().map(|v| v.into_string()).collect() + } +} + +impl From for Vec { + fn from(value: Row) -> Self { + value + .values + .into_iter() + .filter_map(|v| { + if let Value::Integer(v) = v { + Some(v as u32) + } else { + None + } + }) + .collect() + } +} + +impl From for Vec { + fn from(value: Rows) -> Self { + value + .rows + .into_iter() + .flat_map(|v| v.values.into_iter().map(|v| v.into_string())) + .collect() + } +} + +impl From for Vec { + fn from(value: Rows) -> Self { + value + .rows + .into_iter() + .flat_map(|v| { + v.values.into_iter().filter_map(|v| { + if let Value::Integer(v) = v { + Some(v as u32) + } else { + None + } + }) + }) + .collect() + } +} + +impl Store { + #[inline(always)] + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + #[inline(always)] + pub fn is_active(&self) -> bool { + !matches!(self, Self::None) + } + + pub fn is_same(&self, other: &Store) -> bool { + match (self, other) { + #[cfg(feature = "sqlite")] + (Store::SQLite(a), Store::SQLite(b)) => Arc::ptr_eq(a, b), + #[cfg(feature = "foundation")] + (Store::FoundationDb(a), Store::FoundationDb(b)) => Arc::ptr_eq(a, b), + #[cfg(feature = "postgres")] + (Store::PostgreSQL(a), Store::PostgreSQL(b)) => Arc::ptr_eq(a, b), + #[cfg(feature = "mysql")] + (Store::MySQL(a), Store::MySQL(b)) => Arc::ptr_eq(a, b), + #[cfg(feature = "rocks")] + (Store::RocksDb(a), Store::RocksDb(b)) => Arc::ptr_eq(a, b), + (Store::Ephemeral(a), Store::Ephemeral(b)) => Arc::ptr_eq(a, b), + #[cfg(all(feature = "enterprise", any(feature = "postgres", feature = "mysql")))] + (Store::SQLReadReplica(a), Store::SQLReadReplica(b)) => Arc::ptr_eq(a, b), + (Store::None, Store::None) => true, + _ => false, + } + } + + #[inline(always)] + pub fn is_sql(&self) -> bool { + match self { + #[cfg(feature = "sqlite")] + Store::SQLite(_) => true, + #[cfg(feature = "postgres")] + Store::PostgreSQL(_) => true, + #[cfg(feature = "mysql")] + Store::MySQL(_) => true, + _ => false, + } + } + + #[inline(always)] + pub fn is_pg_or_mysql(&self) -> bool { + match self { + #[cfg(feature = "mysql")] + Store::MySQL(_) => true, + #[cfg(feature = "postgres")] + Store::PostgreSQL(_) => true, + _ => false, + } + } + + #[inline(always)] + pub fn is_foundationdb(&self) -> bool { + match self { + #[cfg(feature = "foundation")] + Store::FoundationDb(_) => true, + _ => false, + } + } + + #[inline(always)] + pub fn is_ephemeral(&self) -> bool { + matches!(self, Self::Ephemeral(_)) + } + +} + +impl std::fmt::Debug for Store { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #[cfg(feature = "sqlite")] + Self::SQLite(_) => f.debug_tuple("SQLite").finish(), + #[cfg(feature = "foundation")] + Self::FoundationDb(_) => f.debug_tuple("FoundationDb").finish(), + #[cfg(feature = "postgres")] + Self::PostgreSQL(_) => f.debug_tuple("PostgreSQL").finish(), + #[cfg(feature = "mysql")] + Self::MySQL(_) => f.debug_tuple("MySQL").finish(), + #[cfg(feature = "rocks")] + Self::RocksDb(_) => f.debug_tuple("RocksDb").finish(), + Self::Ephemeral(_) => f.debug_tuple("Ephemeral").finish(), + + Self::None => f.debug_tuple("None").finish(), + } + } +} + +impl From> for trc::Value { + fn from(value: Value) -> Self { + match value { + Value::Integer(v) => trc::Value::Int(v), + Value::Bool(v) => trc::Value::Bool(v), + Value::Float(v) => trc::Value::Float(v), + Value::Text(v) => trc::Value::String(match v { + Cow::Borrowed(v) => v.into(), + Cow::Owned(v) => v.into(), + }), + Value::Blob(v) => trc::Value::Bytes(v.into_owned()), + Value::Null => trc::Value::None, + } + } +} + +impl From> for () { + fn from(_: Value<'static>) -> Self { + unreachable!() + } +} diff --git a/crates/store/src/query/acl.rs b/crates/store/src/query/acl.rs new file mode 100644 index 0000000..9b74aa8 --- /dev/null +++ b/crates/store/src/query/acl.rs @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashSet; +use trc::AddContext; +use types::collection::Collection; + +use crate::{ + Deserialize, IterateParams, Store, U32_LEN, ValueKey, + write::{BatchBuilder, ValueClass, key::DeserializeBigEndian}, +}; + +pub enum AclQuery { + SharedWith { + grant_account_id: u32, + to_account_id: u32, + to_collection: u8, + }, + HasAccess { + grant_account_id: u32, + }, +} + +#[derive(Debug)] +pub struct AclItem { + pub to_account_id: u32, + pub to_collection: Collection, + pub to_document_id: u32, + pub permissions: u64, +} + +impl Store { + pub async fn acl_query(&self, query: AclQuery) -> trc::Result> { + let mut results = Vec::new(); + let (from_key, to_key) = match query { + AclQuery::SharedWith { + grant_account_id, + to_account_id, + to_collection, + } => { + let from_key = ValueKey { + account_id: to_account_id, + collection: to_collection, + document_id: 0, + class: ValueClass::Acl(grant_account_id), + }; + let mut to_key = from_key.clone(); + to_key.document_id = u32::MAX; + + (from_key, to_key) + } + AclQuery::HasAccess { grant_account_id } => ( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Acl(grant_account_id), + }, + ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Acl(grant_account_id), + }, + ), + }; + + self.iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + results.push(AclItem::deserialize(key)?.with_permissions(u64::deserialize(value)?)); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| results) + } + + pub async fn acl_revoke_all(&self, account_id: u32) -> trc::Result> { + let from_key = ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Acl(0), + }; + let to_key = ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Acl(u32::MAX), + }; + + let mut delete_keys = Vec::new(); + let mut revoked_accounts = AHashSet::new(); + self.iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + if account_id == key.deserialize_be_u32(U32_LEN)? { + let owner_account_id = key.deserialize_be_u32(0)?; + revoked_accounts.insert(owner_account_id); + delete_keys.push((owner_account_id, AclItem::deserialize(key)?)); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + // Remove permissions + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + let mut last_collection = Collection::None; + for (revoke_account_id, acl_item) in delete_keys.into_iter() { + if batch.is_large_batch() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + batch.with_account_id(account_id); + last_collection = Collection::None; + } + if acl_item.to_collection != last_collection { + batch.with_collection(acl_item.to_collection); + last_collection = acl_item.to_collection; + } + batch + .with_document(acl_item.to_document_id) + .acl_revoke(revoke_account_id); + } + if !batch.is_empty() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(revoked_accounts) + } +} + +impl Deserialize for AclItem { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(AclItem { + to_account_id: bytes.deserialize_be_u32(U32_LEN)?, + to_collection: bytes + .get(U32_LEN * 2) + .map(|b| Collection::from(*b)) + .ok_or_else(|| trc::StoreEvent::DataCorruption.caused_by(trc::location!()))?, + to_document_id: bytes.deserialize_be_u32((U32_LEN * 2) + 1)?, + permissions: 0, + }) + } +} + +impl AclItem { + fn with_permissions(mut self, permissions: u64) -> Self { + self.permissions = permissions; + self + } +} diff --git a/crates/store/src/query/log.rs b/crates/store/src/query/log.rs new file mode 100644 index 0000000..8c15043 --- /dev/null +++ b/crates/store/src/query/log.rs @@ -0,0 +1,487 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use trc::AddContext; +use types::collection::{SyncCollection, VanishedCollection}; +use utils::codec::leb128::Leb128Iterator; + +use crate::{ + IterateParams, LogKey, Store, U32_LEN, U64_LEN, + write::{LogCollection, key::DeserializeBigEndian}, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Change { + InsertContainer(u64), + UpdateContainer(u64), + UpdateContainerProperty(u64), + DeleteContainer(u64), + InsertItem(u64), + UpdateItem(u64), + DeleteItem(u64), +} + +#[derive(Debug)] +pub struct Changes { + pub changes: Vec, + pub from_change_id: u64, + pub to_change_id: u64, + pub container_change_id: Option, + pub item_change_id: Option, + pub is_truncated: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum Query { + All, + Since(u64), + SinceInclusive(u64), + RangeInclusive(u64, u64), +} + +pub trait DeserializeVanished: Sized + Sync + Send { + fn deserialize_vanished<'x>(bytes: &mut impl Iterator) -> Option; +} + +impl Default for Changes { + fn default() -> Self { + Self { + changes: Vec::with_capacity(10), + from_change_id: 0, + to_change_id: 0, + container_change_id: None, + item_change_id: None, + is_truncated: false, + } + } +} + +impl Store { + pub async fn changes( + &self, + account_id: u32, + collection_: LogCollection, + query: Query, + ) -> trc::Result { + let is_share_log = matches!( + collection_, + LogCollection::Sync(SyncCollection::ShareNotification) + ); + let collection = u8::from(collection_); + + let (is_inclusive, from_change_id, to_change_id) = match query { + Query::All => (true, 0, u64::MAX), + Query::Since(change_id) => (false, change_id, u64::MAX), + Query::SinceInclusive(change_id) => (true, change_id, u64::MAX), + Query::RangeInclusive(from_change_id, to_change_id) => { + (true, from_change_id, to_change_id) + } + }; + let from_key = LogKey { + account_id, + collection, + change_id: from_change_id, + }; + let to_key = LogKey { + account_id, + collection, + change_id: to_change_id, + }; + + let mut changelog = Changes::default(); + + self.iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?; + if is_inclusive || change_id != from_change_id { + if value.is_empty() { + changelog.is_truncated = true; + return Ok(true); + } + if changelog.changes.is_empty() { + changelog.from_change_id = change_id; + } + changelog.to_change_id = change_id; + if !is_share_log { + let (has_container_changes, has_item_changes) = + changelog.deserialize(value).ok_or_else(|| { + trc::Error::corrupted_key(key, value.into(), trc::location!()) + })?; + if has_container_changes { + changelog.container_change_id = Some(change_id); + } + if has_item_changes { + changelog.item_change_id = Some(change_id); + } + } else { + changelog.changes.push(Change::InsertItem(change_id)); + } + } else { + changelog.from_change_id = change_id; + changelog.to_change_id = change_id; + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(changelog) + } + + pub async fn vanished( + &self, + account_id: u32, + collection: LogCollection, + query: Query, + ) -> trc::Result> { + let collection = u8::from(collection); + let (is_inclusive, from_change_id, to_change_id) = match query { + Query::All => (true, 0, u64::MAX), + Query::Since(change_id) => (false, change_id, u64::MAX), + Query::SinceInclusive(change_id) => (true, change_id, u64::MAX), + Query::RangeInclusive(from_change_id, to_change_id) => { + (true, from_change_id, to_change_id) + } + }; + let from_key = LogKey { + account_id, + collection, + change_id: from_change_id, + }; + let to_key = LogKey { + account_id, + collection, + change_id: to_change_id, + }; + + let mut vanished = Vec::default(); + + self.iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let change_id = key.deserialize_be_u64(key.len() - U64_LEN)?; + if is_inclusive || change_id != from_change_id { + let mut iter = value.iter().peekable(); + + while iter.peek().is_some() { + if let Some(item) = T::deserialize_vanished(&mut iter) { + vanished.push(item); + } else { + return Err(trc::Error::corrupted_key( + key, + value.into(), + trc::location!(), + )); + } + } + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(vanished) + } + + pub async fn get_last_change_id( + &self, + account_id: u32, + collection: LogCollection, + ) -> trc::Result> { + let collection = u8::from(collection); + let from_key = LogKey { + account_id, + collection, + change_id: 0, + }; + let to_key = LogKey { + account_id, + collection, + change_id: u64::MAX, + }; + + let mut last_change_id = None; + + self.iterate( + IterateParams::new(from_key, to_key) + .descending() + .no_values() + .only_first(), + |key, _| { + last_change_id = key.deserialize_be_u64(key.len() - U64_LEN)?.into(); + Ok(false) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(last_change_id) + } +} + +impl From for LogCollection { + fn from(value: VanishedCollection) -> Self { + LogCollection::Vanished(value) + } +} + +impl From for LogCollection { + fn from(value: SyncCollection) -> Self { + LogCollection::Sync(value) + } +} + +impl Changes { + pub fn deserialize(&mut self, bytes: &[u8]) -> Option<(bool, bool)> { + let mut bytes_it = bytes.iter(); + + let container_inserts: usize = bytes_it.next_leb128()?; + let container_updates: usize = bytes_it.next_leb128()?; + let container_property_changes: usize = bytes_it.next_leb128()?; + let container_deletes: usize = bytes_it.next_leb128()?; + + let item_inserts: usize = bytes_it.next_leb128()?; + let item_updates: usize = bytes_it.next_leb128()?; + let item_deletes: usize = bytes_it.next_leb128()?; + + let has_container_changes = + container_inserts + container_updates + container_property_changes + container_deletes + > 0; + let has_item_changes = item_inserts + item_updates + item_deletes > 0; + + if container_inserts > 0 { + for _ in 0..container_inserts { + self.changes + .push(Change::InsertContainer(bytes_it.next_leb128()?)); + } + } + + if container_updates > 0 || container_property_changes > 0 { + 'update_outer: for change_pos in 0..(container_updates + container_property_changes) { + let id = bytes_it.next_leb128()?; + let mut is_property_change = change_pos >= container_updates; + + for (idx, change) in self.changes.iter().enumerate() { + match change { + Change::InsertContainer(insert_id) if *insert_id == id => { + // Item updated after inserted, no need to count this change. + continue 'update_outer; + } + Change::UpdateContainer(update_id) if *update_id == id => { + // Move update to the front + is_property_change = false; + self.changes.remove(idx); + break; + } + Change::UpdateContainerProperty(update_id) if *update_id == id => { + // Move update to the front + self.changes.remove(idx); + break; + } + _ => (), + } + } + + self.changes.push(if !is_property_change { + Change::UpdateContainer(id) + } else { + Change::UpdateContainerProperty(id) + }); + } + } + + if container_deletes > 0 { + 'delete_outer: for _ in 0..container_deletes { + let id = bytes_it.next_leb128()?; + + 'delete_inner: for (idx, change) in self.changes.iter().enumerate() { + match change { + Change::InsertContainer(insert_id) if *insert_id == id => { + self.changes.remove(idx); + continue 'delete_outer; + } + Change::UpdateContainer(update_id) if *update_id == id => { + self.changes.remove(idx); + break 'delete_inner; + } + _ => (), + } + } + + self.changes.push(Change::DeleteContainer(id)); + } + } + + // Item changes + if item_inserts > 0 { + for _ in 0..item_inserts { + self.changes + .push(Change::InsertItem(bytes_it.next_leb128()?)); + } + } + + if item_updates > 0 { + 'update_outer: for _ in 0..item_updates { + let id = bytes_it.next_leb128()?; + + for (idx, change) in self.changes.iter().enumerate() { + match change { + Change::InsertItem(insert_id) if *insert_id == id => { + // Item updated after inserted, no need to count this change. + continue 'update_outer; + } + Change::UpdateItem(update_id) if *update_id == id => { + // Move update to the front + self.changes.remove(idx); + break; + } + _ => (), + } + } + + self.changes.push(Change::UpdateItem(id)); + } + } + + if item_deletes > 0 { + 'delete_outer: for _ in 0..item_deletes { + let id = bytes_it.next_leb128()?; + + 'delete_inner: for (idx, change) in self.changes.iter().enumerate() { + match change { + Change::InsertItem(insert_id) if *insert_id == id => { + self.changes.remove(idx); + continue 'delete_outer; + } + Change::UpdateItem(update_id) if *update_id == id => { + self.changes.remove(idx); + break 'delete_inner; + } + _ => (), + } + } + + self.changes.push(Change::DeleteItem(id)); + } + } + + Some((has_container_changes, has_item_changes)) + } +} + +impl Changes { + pub fn total_container_changes(&self) -> usize { + self.changes + .iter() + .filter(|change| change.is_container_change()) + .count() + } + + pub fn total_item_changes(&self) -> usize { + self.changes + .iter() + .filter(|change| change.is_item_change()) + .count() + } +} + +impl Change { + pub fn item_id(&self) -> Option { + match self { + Change::InsertItem(id) => Some(*id), + Change::UpdateItem(id) => Some(*id), + Change::DeleteItem(id) => Some(*id), + _ => None, + } + } + + pub fn container_id(&self) -> Option { + match self { + Change::InsertContainer(id) => Some(*id), + Change::UpdateContainer(id) => Some(*id), + Change::UpdateContainerProperty(id) => Some(*id), + Change::DeleteContainer(id) => Some(*id), + _ => None, + } + } + + pub fn try_unwrap_item_id(self) -> Option { + match self { + Change::InsertItem(id) => Some(id), + Change::UpdateItem(id) => Some(id), + Change::DeleteItem(id) => Some(id), + _ => None, + } + } + + pub fn try_unwrap_container_id(self) -> Option { + match self { + Change::InsertContainer(id) => Some(id), + Change::UpdateContainer(id) => Some(id), + Change::UpdateContainerProperty(id) => Some(id), + Change::DeleteContainer(id) => Some(id), + _ => None, + } + } + + pub fn is_container_change(&self) -> bool { + matches!( + self, + Change::InsertContainer(_) + | Change::UpdateContainer(_) + | Change::UpdateContainerProperty(_) + | Change::DeleteContainer(_) + ) + } + + pub fn is_item_change(&self) -> bool { + matches!( + self, + Change::InsertItem(_) | Change::UpdateItem(_) | Change::DeleteItem(_) + ) + } +} + +impl DeserializeVanished for u64 { + fn deserialize_vanished<'x>(bytes: &mut impl Iterator) -> Option { + let mut num = [0u8; U64_LEN]; + for i in num.iter_mut() { + *i = *bytes.next()?; + } + Some(u64::from_be_bytes(num)) + } +} + +impl DeserializeVanished for (u32, u32) { + fn deserialize_vanished<'x>(bytes: &mut impl Iterator) -> Option { + let mut num1 = [0u8; U32_LEN]; + let mut num2 = [0u8; U32_LEN]; + for i in num1.iter_mut().chain(num2.iter_mut()) { + *i = *bytes.next()?; + } + Some((u32::from_be_bytes(num1), u32::from_be_bytes(num2))) + } +} + +impl DeserializeVanished for String { + fn deserialize_vanished<'x>(bytes: &mut impl Iterator) -> Option { + let mut name = Vec::with_capacity(16); + + loop { + let byte = bytes.next()?; + if *byte != 0 { + name.push(*byte); + } else { + break; + } + } + + String::from_utf8(name).ok() + } +} diff --git a/crates/store/src/query/mod.rs b/crates/store/src/query/mod.rs new file mode 100644 index 0000000..4e4fd4e --- /dev/null +++ b/crates/store/src/query/mod.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod log; + +use crate::{IterateParams, Key}; + +impl IterateParams { + pub fn new(begin: T, end: T) -> Self { + IterateParams { + begin, + end, + first: false, + ascending: true, + values: true, + } + } + + pub fn set_ascending(mut self, ascending: bool) -> Self { + self.ascending = ascending; + self + } + + pub fn set_values(mut self, values: bool) -> Self { + self.values = values; + self + } + + pub fn ascending(mut self) -> Self { + self.ascending = true; + self + } + + pub fn descending(mut self) -> Self { + self.ascending = false; + self + } + + pub fn only_first(mut self) -> Self { + self.first = true; + self + } + + pub fn no_values(mut self) -> Self { + self.values = false; + self + } +} diff --git a/crates/store/src/registry/bootstrap.rs b/crates/store/src/registry/bootstrap.rs new file mode 100644 index 0000000..87494bd --- /dev/null +++ b/crates/store/src/registry/bootstrap.rs @@ -0,0 +1,206 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{RegistryStore, Store, registry::RegistryObject}; +use registry::{ + schema::{ + prelude::{Object, ObjectType, Property}, + structs::ClusterRole, + }, + types::{ + ObjectImpl, + error::{Error, ValidationError, Warning}, + id::ObjectId, + }, +}; +use types::id::Id; + +pub struct Bootstrap { + pub registry: RegistryStore, + pub data_store: Store, + pub errors: Vec, + pub warnings: Vec, + pub has_fatal_errors: bool, + pub role: Option, +} + +impl Bootstrap { + pub async fn new(registry: RegistryStore) -> Self { + let mut bp = Self::new_uninitialized(registry); + + let Some(role_name) = bp.registry.cluster_role().map(|r| r.to_string()) else { + return bp; + }; + + for role in bp.list_infallible::().await { + if role.object.name == role_name { + bp.role = Some(role.object); + return bp; + } + } + + bp.build_error( + ObjectType::ClusterRole.singleton(), + format!("Cluster role \"{role_name}\" not found in registry"), + ); + + bp + } + + pub fn new_uninitialized(registry: RegistryStore) -> Self { + Self { + data_store: registry.0.store.clone(), + registry, + errors: Vec::new(), + warnings: Vec::new(), + has_fatal_errors: false, + role: None, + } + } + + pub fn with_data_store(mut self, data_store: Store) -> Self { + self.data_store = data_store; + self + } + + pub async fn setting>(&mut self) -> trc::Result { + let object_id = T::OBJECT.singleton(); + + if let Some(setting) = self.registry.object::(object_id.id()).await? { + let mut errors = Vec::new(); + if setting.validate(&mut errors) { + return Ok(setting); + } + self.errors.push(Error::Validation { object_id, errors }); + } + + Ok(T::default()) + } + + pub async fn setting_infallible>(&mut self) -> T { + match self.setting::().await { + Ok(setting) => setting, + Err(err) => { + if !self.has_fatal_errors { + self.errors.push(Error::Internal { + object_id: Some(T::OBJECT.singleton()), + error: err, + }); + self.has_fatal_errors = true; + } + T::default() + } + } + } + + pub async fn get_infallible>(&mut self, id: Id) -> Option { + match self.registry.object::(id).await { + Ok(Some(setting)) => { + let mut errors = Vec::new(); + if setting.validate(&mut errors) { + Some(setting) + } else { + self.errors.push(Error::Validation { + object_id: ObjectId::new(T::OBJECT, id), + errors, + }); + None + } + } + Ok(None) => { + self.errors.push(Error::NotFound { + object_id: ObjectId::new(T::OBJECT, id), + }); + None + } + Err(err) => { + if !self.has_fatal_errors { + self.errors.push(Error::Internal { + object_id: Some(ObjectId::new(T::OBJECT, id)), + error: err, + }); + self.has_fatal_errors = true; + } + None + } + } + } + + pub async fn list_infallible>( + &mut self, + ) -> Vec> { + match self.registry.list::().await { + Ok(objects) => objects + .into_iter() + .filter(|object| self.validate(object.id, &object.object)) + .collect(), + Err(err) => { + if !self.has_fatal_errors { + self.errors.push(Error::Internal { + object_id: None, + error: err, + }); + self.has_fatal_errors = true; + } + Vec::new() + } + } + } + + pub fn build_error(&mut self, id: ObjectId, message: impl Into) { + self.errors.push(Error::Build { + object_id: id, + message: message.into(), + }); + } + + pub fn build_warning(&mut self, id: ObjectId, message: impl Into) { + self.warnings.push(Warning { + object_id: id, + property: None, + message: message.into(), + }); + } + + pub fn invalid_property(&mut self, id: ObjectId, property: Property, value: impl Into) { + self.errors.push(Error::Validation { + object_id: id, + errors: vec![ValidationError::Invalid { + property, + value: value.into(), + }], + }); + } + + pub fn validate(&mut self, id: ObjectId, object: &impl ObjectImpl) -> bool { + let mut errors = Vec::new(); + if object.validate(&mut errors) { + true + } else { + self.errors.push(Error::Validation { + object_id: id, + errors, + }); + false + } + } + + pub fn node_id(&self) -> u16 { + self.registry.0.node_id + } + + pub fn log_errors(&self) { + for error in &self.errors { + error.log(); + } + } + + pub fn log_warnings(&self) { + for warning in &self.warnings { + warning.log(); + } + } +} diff --git a/crates/store/src/registry/get.rs b/crates/store/src/registry/get.rs new file mode 100644 index 0000000..45c2874 --- /dev/null +++ b/crates/store/src/registry/get.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, RegistryStore, SUBSPACE_REGISTRY, U16_LEN, U64_LEN, ValueKey, + registry::{RegistryObject, local::RegistryInit}, + write::{ + AnyClass, RegistryClass, ValueClass, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use registry::{ + pickle::PickledStream, + schema::prelude::{Object, ObjectType}, + types::{EnumImpl, ObjectImpl, id::ObjectId}, +}; +use trc::AddContext; +use types::id::Id; + +impl RegistryStore { + pub async fn get(&self, object_id: ObjectId) -> trc::Result> { + if object_id.object() != ObjectType::DataStore { + self.0 + .store + .get_value::(ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id: object_id.object().to_id(), + item_id: object_id.id().id(), + }))) + .await + } else { + match self.0.read_data_store().await { + RegistryInit::Ok(data_store) => Ok(Some(Object { + inner: data_store.into(), + revision: 0, + })), + RegistryInit::Err(err) => { + Err(trc::EventType::Registry(trc::RegistryEvent::LocalReadError) + .into_err() + .caused_by(trc::location!()) + .reason(err)) + } + RegistryInit::Bootstrap => Ok(None), + } + } + } + + pub async fn object>(&self, id: Id) -> trc::Result> { + self.get(ObjectId::new(T::OBJECT, id)) + .await + .map(|v| v.map(T::from)) + } + + pub async fn list>(&self) -> trc::Result>> { + let object_type = T::OBJECT; + + let mut results = Vec::new(); + self.0 + .store + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY, + key: KeySerializer::new(U16_LEN) + .write(object_type.to_id()) + .finalize(), + })), + ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY, + key: KeySerializer::new(U16_LEN + U64_LEN) + .write(object_type.to_id()) + .write(u64::MAX) + .finalize(), + })), + ), + |key, value| { + let id = key.deserialize_be_u64(U16_LEN)?; + let object = PickledStream::new(value) + .and_then(|mut stream| T::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .id(id) + .details(object_type.as_str()) + .ctx(trc::Key::Value, value) + })?; + + results.push(RegistryObject { + id: ObjectId::new(object_type, Id::new(id)), + object, + revision: xxhash_rust::xxh3::xxh3_64(value), + }); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(results) + } +} diff --git a/crates/store/src/registry/local.rs b/crates/store/src/registry/local.rs new file mode 100644 index 0000000..85d091a --- /dev/null +++ b/crates/store/src/registry/local.rs @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{RegistryStore, RegistryStoreInner, Store}; +use registry::schema::structs::DataStore; +use std::{net::IpAddr, path::PathBuf}; +use utils::snowflake::SnowflakeIdGenerator; + +pub(crate) enum RegistryInit { + Ok(DataStore), + Err(String), + Bootstrap, +} + +impl RegistryStoreInner { + pub(crate) fn new(local_path: PathBuf) -> Self { + let env_hostname = std::env::var("STALWART_HOSTNAME") + .ok() + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| { + let host = gethostname::gethostname(); + let host = host.to_string_lossy(); + if host.parse::().is_err() { + host.to_lowercase() + } else { + "localhost".to_string() + } + }); + + Self { + local_path, + store: Store::None, + id_generator: SnowflakeIdGenerator::new(), + node_id: 0, + env_recovery_mode: std::env::var("STALWART_RECOVERY_MODE") + .ok() + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false), + env_recovery_admin: std::env::var("STALWART_RECOVERY_ADMIN") + .ok() + .and_then(|v| { + v.split_once(':') + .map(|(a, p)| (a.trim().to_string(), p.trim().to_string())) + }) + .filter(|(a, p)| !a.is_empty() && !p.is_empty()), + env_cluster_role: std::env::var("STALWART_ROLE") + .ok() + .filter(|r| !r.is_empty()), + env_push_shard_id: std::env::var("STALWART_PUSH_SHARD") + .ok() + .and_then(|id| id.parse::().ok().and_then(|v| v.checked_sub(1))) + .unwrap_or(0), + env_public_url: std::env::var("STALWART_PUBLIC_URL") + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|u| !u.is_empty()) + .or_else(|| { + std::env::var("STALWART_HTTPS_PORT").ok().and_then(|p| { + p.parse::() + .ok() + .map(|port| format!("https://{}:{}", env_hostname, port)) + }) + }), + env_hostname, + } + } + + pub(crate) async fn read_data_store(&self) -> RegistryInit { + match tokio::fs::read_to_string(&self.local_path).await { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(data_store) => RegistryInit::Ok(data_store), + Err(err) => RegistryInit::Err(format!( + "Failed to parse data store settings at {}: {}", + self.local_path.display(), + err + )), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => RegistryInit::Bootstrap, + Err(err) => RegistryInit::Err(format!( + "Failed to read data store settings at {}: {}", + self.local_path.display(), + err + )), + } + } +} + +impl RegistryStore { + pub async fn write_data_store(&self, data_store: &DataStore) -> trc::Result<()> { + let json_text = serde_json::to_string(data_store).map_err(|err| { + trc::EventType::Registry(trc::RegistryEvent::LocalWriteError) + .into_err() + .caused_by(trc::location!()) + .reason(err) + })?; + tokio::fs::write(&self.0.local_path, json_text) + .await + .map_err(|err| { + trc::EventType::Registry(trc::RegistryEvent::LocalWriteError) + .into_err() + .caused_by(trc::location!()) + .reason(err) + }) + } +} diff --git a/crates/store/src/registry/mod.rs b/crates/store/src/registry/mod.rs new file mode 100644 index 0000000..3550f6f --- /dev/null +++ b/crates/store/src/registry/mod.rs @@ -0,0 +1,238 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod bootstrap; +pub mod get; +pub mod local; +pub mod query; +pub mod write; + +use crate::{ + Deserialize, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN, + write::key::{DeserializeBigEndian, KeySerializer}, +}; +use registry::{ + pickle::{Pickle, PickledStream}, + schema::{ + prelude::{Object, ObjectInner, ObjectType, Property}, + structs::{ + ArchivedItem, DmarcInternalReport, Metric, SpamTrainingSample, Task, TlsInternalReport, + Trace, + }, + }, + types::{EnumImpl, ObjectImpl, id::ObjectId}, +}; +use types::id::Id; + +pub struct RegistryObject { + pub id: ObjectId, + pub object: T, + pub revision: u64, +} + +#[derive(Debug)] +pub struct RegistryQuery { + pub(crate) object_type: ObjectType, + pub filters: Vec, + pub(crate) start: RegistryQueryStart, + pub(crate) limit: Option, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct RegistryObjectCounter(pub usize); + +#[derive(Debug, Clone, Copy)] +pub(crate) enum RegistryQueryStart { + Index(u64), + Anchor(u64), + None, +} + +#[derive(Debug)] +pub struct RegistryFilter { + pub property: Property, + pub op: RegistryFilterOp, + pub value: RegistryFilterValue, + pub is_pk: bool, +} + +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)] +pub struct ObjectIdVersioned { + pub object_id: ObjectId, + pub version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryFilterOp { + Equal, + GreaterThan, + GreaterEqualThan, + LowerThan, + LowerEqualThan, + TextMatch, +} + +#[derive(Debug)] +pub enum RegistryFilterValue { + String(String), + Bytes(Vec), + U64(u64), + U16(u16), + Boolean(bool), +} + +impl Deserialize for Object { + fn deserialize_with_key(key: &[u8], bytes: &[u8]) -> trc::Result { + let revision = xxhash_rust::xxh3::xxh3_64(bytes); + ObjectType::from_id(key.deserialize_be_u16(0)?) + .and_then(|object_id| ObjectInner::unpickle(object_id, &mut PickledStream::new(bytes)?)) + .map(|inner| Object { revision, inner }) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } + + fn deserialize(_: &[u8]) -> trc::Result { + unreachable!("Object deserialization requires the object type from the key") + } +} + +impl Deserialize for Task { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl Deserialize for SpamTrainingSample { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl Deserialize for ArchivedItem { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl Deserialize for TlsInternalReport { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl Deserialize for DmarcInternalReport { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl SerializeInfallible for ObjectId { + fn serialize(&self) -> Vec { + KeySerializer::new(U16_LEN + U64_LEN) + .write(self.object().to_id()) + .write(self.id().id()) + .finalize() + } +} + +impl Deserialize for ObjectId { + fn deserialize(bytes: &[u8]) -> trc::Result { + let object_id = bytes.deserialize_be_u16(0)?; + let item_id = bytes.deserialize_be_u64(U16_LEN)?; + Ok(ObjectId::new( + ObjectType::from_id(object_id).ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + })?, + Id::new(item_id), + )) + } +} + +impl SerializeInfallible for ObjectIdVersioned { + fn serialize(&self) -> Vec { + KeySerializer::new(U16_LEN + U64_LEN + U32_LEN) + .write(self.object_id.object().to_id()) + .write(self.object_id.id().id()) + .write(self.version) + .finalize() + } +} + +impl Deserialize for ObjectIdVersioned { + fn deserialize(bytes: &[u8]) -> trc::Result { + let object_id = ObjectId::deserialize(bytes)?; + let version = bytes.deserialize_be_u32(U16_LEN + U64_LEN)?; + Ok(Self { object_id, version }) + } +} + +impl Deserialize for Trace { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} + +impl Deserialize for Metric { + fn deserialize(bytes: &[u8]) -> trc::Result { + PickledStream::new(bytes) + .and_then(|mut stream| Self::unpickle(&mut stream)) + .ok_or_else(|| { + trc::EventType::Registry(trc::RegistryEvent::DeserializationError) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Value, bytes) + }) + } +} diff --git a/crates/store/src/registry/query.rs b/crates/store/src/registry/query.rs new file mode 100644 index 0000000..d7898b7 --- /dev/null +++ b/crates/store/src/registry/query.rs @@ -0,0 +1,943 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, RegistryStore, SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_PK, Store, U16_LEN, + U64_LEN, ValueKey, + registry::{ + RegistryFilter, RegistryFilterOp, RegistryFilterValue, RegistryObjectCounter, + RegistryQuery, RegistryQueryStart, + }, + write::{ + AnyClass, RegistryClass, ValueClass, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use ahash::AHashSet; +use registry::{ + schema::prelude::{OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, ObjectType, Property}, + types::{EnumImpl, id::ObjectId}, +}; +use roaring::RoaringBitmap; +use std::{borrow::Cow, ops::BitAndAssign}; +use trc::AddContext; +use types::id::Id; + +impl RegistryStore { + pub async fn query(&self, query: RegistryQuery) -> trc::Result { + if query.filters.is_empty() { + return all_ids::(&self.0.store, query).await; + } + + let mut u64_buffer; + let mut u16_buffer; + let mut bool_buffer = [0u8; 1]; + + let mut results = ResultsPagination::::new(&query); + for filter in &query.filters { + if filter.op == RegistryFilterOp::TextMatch { + if let RegistryFilterValue::String(text) = &filter.value { + let mut matches = ResultsPagination::::new(&query); + + for word in text + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| s.len() > 1) + { + let word = if word + .chars() + .all(|ch| ch.is_lowercase() || !ch.is_alphabetic()) + { + Cow::Borrowed(word) + } else { + Cow::Owned(word.to_lowercase()) + }; + + let mut result = ResultsPagination::::new(&query); + + index_range( + &self.0.store, + query.object_type, + filter.property.to_id(), + word.as_bytes(), + RegistryFilterOp::Equal, + &mut result, + ) + .await?; + + if !matches.list.has_items() { + matches = result; + } else { + matches.list.intersect(&result.list); + if !matches.list.has_items() { + break; + } + } + } + + if !results.list.has_items() { + results = matches; + } else { + results.list.intersect(&matches.list); + } + } else { + return Err(trc::EventType::Registry(trc::RegistryEvent::NotSupported) + .into_err() + .details("TextMatch operator only supports string values")); + } + } else { + let value = match &filter.value { + RegistryFilterValue::String(v) => v.as_bytes(), + RegistryFilterValue::Bytes(v) => v.as_slice(), + RegistryFilterValue::U64(v) => { + u64_buffer = v.to_be_bytes(); + &u64_buffer + } + RegistryFilterValue::U16(v) => { + u16_buffer = v.to_be_bytes(); + &u16_buffer + } + RegistryFilterValue::Boolean(v) => { + bool_buffer[0] = *v as u8; + &bool_buffer + } + }; + + let mut result = ResultsPagination::::new(&query); + if !filter.is_pk { + index_range( + &self.0.store, + query.object_type, + filter.property.to_id(), + value, + filter.op, + &mut result, + ) + .await? + } else { + pk_range( + &self.0.store, + query.object_type, + filter.property.to_id(), + value, + filter.op, + &mut result, + ) + .await? + }; + + if !results.list.has_items() { + results = result; + } else { + results.list.intersect(&result.list); + } + } + + if !results.list.has_items() { + break; + } + } + + Ok(results.finalize()) + } + + pub async fn count_object(&self, object_type: ObjectType) -> trc::Result { + if object_type.flags() & OBJ_SINGLETON == 0 { + self.query::(RegistryQuery::new(object_type)) + .await + .map(|r| r.0) + } else { + self.store() + .key_exists(ValueKey::from(RegistryClass::Item { + object_id: object_type.to_id(), + item_id: Id::singleton().id(), + })) + .await + .caused_by(trc::location!()) + .map(|exists| if exists { 1 } else { 0 }) + } + } + + pub async fn primary_key( + &self, + object_type: Option, + property: Property, + key: Vec, + ) -> trc::Result> { + self.store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::PrimaryKey { + object_id: object_type.map(|obj| obj.to_id()), + index_id: property.to_id(), + key, + }, + ))) + .await + } + + pub async fn sort_by_index( + &self, + object: ObjectType, + property: Property, + ids: Option>, + ascending: bool, + ) -> trc::Result> { + let mut ids = ids.map(|ids| ids.into_iter().collect::>()); + let mut ids_sorted = Vec::with_capacity(ids.as_ref().map_or(0, |ids| ids.len())); + + let object_id = object.to_id(); + let index_id = property.to_id(); + let begin = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_IDX, + key: KeySerializer::new(U16_LEN * 2) + .write(object_id) + .write(index_id) + .finalize(), + })); + let end = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_IDX, + key: KeySerializer::new((U16_LEN * 2) + U64_LEN) + .write(object_id) + .write(index_id) + .write(u64::MAX) + .finalize(), + })); + + self.0 + .store + .iterate( + IterateParams::new(begin, end) + .no_values() + .set_ascending(ascending), + |key, _| { + let id = Id::from(key.deserialize_be_u64(key.len() - U64_LEN)?); + if let Some(ids) = ids.as_mut() { + if ids.remove(&id) { + ids_sorted.push(id); + } + Ok(!ids.is_empty()) + } else { + ids_sorted.push(id); + Ok(true) + } + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| { + if let Some(mut ids) = ids + && !ids.is_empty() + { + ids_sorted.extend(ids.drain()); + } + + ids_sorted + }) + } + + pub async fn sort_by_pk( + &self, + object: ObjectType, + property: Property, + ids: Option>, + ascending: bool, + ) -> trc::Result> { + let mut ids = ids.map(|ids| ids.into_iter().collect::>()); + let mut ids_sorted = Vec::with_capacity(ids.as_ref().map_or(0, |ids| ids.len())); + + let object_id = object.to_id(); + let index_id = property.to_id(); + let begin = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_PK, + key: KeySerializer::new(U16_LEN * 2) + .write(object_id) + .write(index_id) + .finalize(), + })); + let end = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_PK, + key: KeySerializer::new((U16_LEN * 2) + U64_LEN) + .write(object_id) + .write(index_id) + .write(u64::MAX) + .finalize(), + })); + + self.0 + .store + .iterate( + IterateParams::new(begin, end).set_ascending(ascending), + |_, value| { + let id = Id::from(value.deserialize_be_u64(U16_LEN)?); + + if let Some(ids) = ids.as_mut() { + if ids.remove(&id) { + ids_sorted.push(id); + } + Ok(!ids.is_empty()) + } else { + ids_sorted.push(id); + Ok(true) + } + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| { + if let Some(mut ids) = ids + && !ids.is_empty() + { + ids_sorted.extend(ids.drain()); + } + + ids_sorted + }) + } +} + +async fn all_ids(store: &Store, query: RegistryQuery) -> trc::Result { + let mut bm = T::default(); + let object_id = query.object_type.to_id(); + + let (item_id, mut offset) = match query.start { + RegistryQueryStart::Index(index) => (0, index), + RegistryQueryStart::Anchor(anchor) => (anchor + 1, 0), + RegistryQueryStart::None => (0, 0), + }; + + store + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::Registry(RegistryClass::IndexId { + object_id, + item_id, + })), + ValueKey::from(ValueClass::Registry(RegistryClass::IndexId { + object_id, + item_id: u64::MAX, + })), + ) + .no_values() + .ascending(), + |key, _| { + if offset == 0 { + bm.push(key.deserialize_be_u64(U16_LEN * 2)?); + Ok(query.limit.is_none_or(|limit| bm.count() < limit)) + } else { + offset -= 1; + Ok(true) + } + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| bm) +} + +async fn index_range( + store: &Store, + object: ObjectType, + index_id: u16, + match_value: &[u8], + op: RegistryFilterOp, + results: &mut ResultsPagination, +) -> trc::Result<()> { + let ((from_value, from_doc_id, from_index_id), (end_value, end_doc_id, end_index_id)) = match op + { + RegistryFilterOp::LowerThan => ((&[][..], 0, index_id), (match_value, 0, index_id)), + RegistryFilterOp::LowerEqualThan => { + ((&[][..], 0, index_id), (match_value, u64::MAX, index_id)) + } + RegistryFilterOp::GreaterThan => ( + (match_value, u64::MAX, index_id), + (&[][..], u64::MAX, index_id + 1), + ), + RegistryFilterOp::GreaterEqualThan => ( + (match_value, 0, index_id), + (&[][..], u64::MAX, index_id + 1), + ), + RegistryFilterOp::Equal | RegistryFilterOp::TextMatch => ( + (match_value, 0, index_id), + (match_value, u64::MAX, index_id), + ), + }; + + let object_id = object.to_id(); + let begin = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_IDX, + key: KeySerializer::new((U16_LEN * 2) + U64_LEN + from_value.len()) + .write(object_id) + .write(from_index_id) + .write(from_value) + .write(from_doc_id) + .finalize(), + })); + let end = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_IDX, + key: KeySerializer::new((U16_LEN * 2) + U64_LEN + end_value.len()) + .write(object_id) + .write(end_index_id) + .write(end_value) + .write(end_doc_id) + .finalize(), + })); + + let prefix = KeySerializer::new(U16_LEN * 2) + .write(object_id) + .write(index_id) + .finalize(); + + store + .iterate( + IterateParams::new(begin, end).no_values().ascending(), + |key, _| { + if !key.starts_with(&prefix) { + return Ok(false); + } + + let id_pos = key.len() - U64_LEN; + let value = key + .get(U16_LEN * 2..id_pos) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + + let matches = match op { + RegistryFilterOp::LowerThan => value < match_value, + RegistryFilterOp::LowerEqualThan => value <= match_value, + RegistryFilterOp::GreaterThan => value > match_value, + RegistryFilterOp::GreaterEqualThan => value >= match_value, + RegistryFilterOp::Equal | RegistryFilterOp::TextMatch => value == match_value, + }; + + if matches { + Ok(results.push(key.deserialize_be_u64(id_pos)?)) + } else { + Ok(true) + } + }, + ) + .await + .caused_by(trc::location!()) + .inspect(|_| results.list.sort()) +} + +async fn pk_range( + store: &Store, + object: ObjectType, + index_id: u16, + match_value: &[u8], + op: RegistryFilterOp, + results: &mut ResultsPagination, +) -> trc::Result<()> { + let ((from_value, from_index_id), (end_value, end_index_id)) = match op { + RegistryFilterOp::LowerThan => ((&[][..], index_id), (match_value, index_id)), + RegistryFilterOp::LowerEqualThan => ((&[][..], index_id), (match_value, index_id)), + RegistryFilterOp::GreaterThan => ((match_value, index_id), (&[][..], index_id + 1)), + RegistryFilterOp::GreaterEqualThan => ((match_value, index_id), (&[][..], index_id + 1)), + RegistryFilterOp::Equal | RegistryFilterOp::TextMatch => { + ((match_value, index_id), (match_value, index_id)) + } + }; + + let object_id = object.to_id(); + let begin = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_PK, + key: KeySerializer::new((U16_LEN * 2) + from_value.len()) + .write(object_id) + .write(from_index_id) + .write(from_value) + .finalize(), + })); + let end = ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_REGISTRY_PK, + key: KeySerializer::new((U16_LEN * 2) + end_value.len()) + .write(object_id) + .write(end_index_id) + .write(end_value) + .finalize(), + })); + + let prefix = KeySerializer::new(U16_LEN * 2) + .write(object_id) + .write(index_id) + .finalize(); + + store + .iterate(IterateParams::new(begin, end).ascending(), |key, value| { + if !key.starts_with(&prefix) { + return Ok(false); + } + + let key = key + .get(U16_LEN * 2..) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + + let matches = match op { + RegistryFilterOp::LowerThan => key < match_value, + RegistryFilterOp::LowerEqualThan => key <= match_value, + RegistryFilterOp::GreaterThan => key > match_value, + RegistryFilterOp::GreaterEqualThan => key >= match_value, + RegistryFilterOp::Equal | RegistryFilterOp::TextMatch => key == match_value, + }; + + if matches { + Ok(results.push(value.deserialize_be_u64(U16_LEN)?)) + } else { + Ok(true) + } + }) + .await + .caused_by(trc::location!()) + .inspect(|_| results.list.sort()) +} + +pub trait RegistryQueryResults: Default + Sized + Sync + Send { + fn push(&mut self, id: u64); + fn has_items(&self) -> bool; + fn intersect(&mut self, other: &Self); + fn count(&self) -> usize; + fn sort(&mut self); + fn into_list(self) -> impl Iterator; +} + +impl RegistryQueryResults for Vec { + fn push(&mut self, id: u64) { + self.push(Id::new(id)); + } + + fn has_items(&self) -> bool { + !self.is_empty() + } + + fn intersect(&mut self, other: &Self) { + let a = self; + let b = other; + let mut i = 0; + let mut j = 0; + let mut write = 0; + + while i < a.len() && j < b.len() { + if a[i] < b[j] { + let target = b[j]; + let remain = &a[i..]; + i += remain.partition_point(|&x| x < target); + } else if a[i] > b[j] { + let target = a[i]; + let remain = &b[j..]; + j += remain.partition_point(|&x| x < target); + } else { + a[write] = a[i]; + write += 1; + i += 1; + j += 1; + } + } + a.truncate(write); + } + + fn count(&self) -> usize { + self.len() + } + + fn sort(&mut self) { + match self.len() { + 0 | 1 => {} + ..3000 => self.sort_unstable(), + _ => radsort::sort_by_key(self, |id| id.id()), + } + } + + fn into_list(self) -> impl Iterator { + self.into_iter().map(|id| id.id()) + } +} + +impl RegistryQueryResults for RoaringBitmap { + fn push(&mut self, id: u64) { + self.insert(id as u32); + } + + fn has_items(&self) -> bool { + !self.is_empty() + } + + fn intersect(&mut self, other: &Self) { + self.bitand_assign(other); + } + + fn count(&self) -> usize { + self.len() as usize + } + + fn sort(&mut self) {} + + fn into_list(self) -> impl Iterator { + self.into_iter().map(|id| id as u64) + } +} + +impl RegistryQueryResults for RegistryObjectCounter { + fn push(&mut self, _: u64) { + self.0 += 1; + } + + fn has_items(&self) -> bool { + self.0 > 0 + } + + fn intersect(&mut self, _: &Self) { + unimplemented!() + } + + fn count(&self) -> usize { + self.0 + } + + fn sort(&mut self) {} + + fn into_list(self) -> impl Iterator { + Vec::new().into_iter() + } +} + +struct ResultsPagination { + list: T, + offset: usize, + anchor: Option, + limit: Option, + deferred_pagination: bool, +} + +impl ResultsPagination { + fn new(query: &RegistryQuery) -> Self { + let (anchor, offset) = match query.start { + RegistryQueryStart::Index(index) => (None, index), + RegistryQueryStart::Anchor(anchor) => (Some(anchor), 0), + RegistryQueryStart::None => (None, 0), + }; + + Self { + list: T::default(), + offset: offset as usize, + anchor, + limit: query.limit, + deferred_pagination: query.filters.len() > 1 + || query.filters.first().is_some_and(|f| { + if let (RegistryFilterOp::TextMatch, RegistryFilterValue::String(value)) = + (&f.op, &f.value) + { + value.chars().any(|c| !c.is_alphanumeric()) && value.len() > 1 + } else { + false + } + }), + } + } + + fn push(&mut self, id: u64) -> bool { + if !self.deferred_pagination { + if self.offset > 0 { + self.offset -= 1; + true + } else if let Some(anchor) = self.anchor { + if id == anchor { + self.anchor = None; + } + true + } else { + self.list.push(id); + self.limit.is_none_or(|limit| self.list.count() < limit) + } + } else { + self.list.push(id); + true + } + } + + fn finalize(mut self) -> T { + if self.deferred_pagination + && self.list.has_items() + && (self.limit.is_some() || self.anchor.is_some() || self.offset > 0) + { + let list = std::mem::take(&mut self.list); + self.deferred_pagination = false; + + for item in list.into_list() { + if !self.push(item) { + break; + } + } + } + self.list + } +} + +impl RegistryQuery { + pub fn new(object_type: ObjectType) -> Self { + Self { + object_type, + filters: Vec::new(), + start: RegistryQueryStart::None, + limit: None, + } + } + + pub fn with_anchor(mut self, anchor: u64) -> Self { + self.start = RegistryQueryStart::Anchor(anchor); + self + } + + pub fn with_index_start(mut self, index: u64) -> Self { + self.start = RegistryQueryStart::Index(index); + self + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + pub fn with_account(mut self, account_id: u32) -> Self { + if self.object_type.flags() & OBJ_FILTER_ACCOUNT != 0 { + let filter = RegistryFilter::equal(Property::AccountId, account_id, false); + if self.filters.is_empty() { + self.filters.push(filter); + } else { + self.filters.insert(0, filter); + } + } + self + } + + pub fn with_account_opt(self, account_id: Option) -> Self { + if let Some(account_id) = account_id { + self.with_account(account_id) + } else { + self + } + } + + pub fn with_tenant(mut self, tenant_id: Option) -> Self { + if let Some(tenant_id) = tenant_id + && self.object_type.flags() & OBJ_FILTER_TENANT != 0 + { + let filter = RegistryFilter::equal(Property::MemberTenantId, tenant_id, false); + if self.filters.is_empty() { + self.filters.push(filter); + } else { + self.filters.insert(0, filter); + } + } + self + } + + pub fn filter(mut self, filter: RegistryFilter) -> Self { + self.filters.push(filter); + self + } + + pub fn equal(mut self, property: Property, value: impl Into) -> Self { + self.filters + .push(RegistryFilter::equal(property, value, false)); + self + } + + pub fn equal_pk( + mut self, + property: Property, + value: impl Into, + is_pk: bool, + ) -> Self { + self.filters + .push(RegistryFilter::equal(property, value, is_pk)); + self + } + + pub fn push_equal_pk( + &mut self, + property: Property, + value: impl Into, + is_pk: bool, + ) { + self.filters + .push(RegistryFilter::equal(property, value, is_pk)); + } + + pub fn equal_opt( + mut self, + property: Property, + value: Option>, + ) -> Self { + if let Some(value) = value { + self.filters + .push(RegistryFilter::equal(property, value, false)); + } + self + } + + pub fn greater_than( + mut self, + property: Property, + value: impl Into, + ) -> Self { + self.filters + .push(RegistryFilter::greater_than(property, value, false)); + self + } + + pub fn less_than(mut self, property: Property, value: impl Into) -> Self { + self.filters + .push(RegistryFilter::less_than(property, value, false)); + self + } + + pub fn greater_than_or_equal( + mut self, + property: Property, + value: impl Into, + ) -> Self { + self.filters.push(RegistryFilter::greater_than_or_equal( + property, value, false, + )); + self + } + + pub fn less_than_or_equal( + mut self, + property: Property, + value: impl Into, + ) -> Self { + self.filters + .push(RegistryFilter::less_than_or_equal(property, value, false)); + self + } + + pub fn text(mut self, property: Property, value: impl Into) -> Self { + self.filters.push(RegistryFilter::text(property, value)); + self + } + + pub fn text_opt(mut self, property: Property, value: Option>) -> Self { + if let Some(value) = value { + self.filters.push(RegistryFilter::text(property, value)); + } + self + } + + pub fn push_text(&mut self, property: Property, value: impl Into) { + self.filters.push(RegistryFilter::text(property, value)); + } + + pub fn has_filters(&self) -> bool { + !self.filters.is_empty() + } +} + +impl RegistryFilter { + pub fn text(property: Property, value: impl Into) -> Self { + Self { + property, + op: RegistryFilterOp::TextMatch, + value: RegistryFilterValue::String(value.into()), + is_pk: false, + } + } + + pub fn equal(property: Property, value: impl Into, is_pk: bool) -> Self { + Self { + property, + op: RegistryFilterOp::Equal, + value: value.into(), + is_pk, + } + } + + pub fn greater_than( + property: Property, + value: impl Into, + is_pk: bool, + ) -> Self { + Self { + property, + op: RegistryFilterOp::GreaterThan, + value: value.into(), + is_pk, + } + } + + pub fn less_than( + property: Property, + value: impl Into, + is_pk: bool, + ) -> Self { + Self { + property, + op: RegistryFilterOp::LowerThan, + value: value.into(), + is_pk, + } + } + + pub fn greater_than_or_equal( + property: Property, + value: impl Into, + is_pk: bool, + ) -> Self { + Self { + property, + op: RegistryFilterOp::GreaterEqualThan, + value: value.into(), + is_pk, + } + } + + pub fn less_than_or_equal( + property: Property, + value: impl Into, + is_pk: bool, + ) -> Self { + Self { + property, + op: RegistryFilterOp::LowerEqualThan, + value: value.into(), + is_pk, + } + } +} + +impl From for RegistryFilterValue { + fn from(value: String) -> Self { + RegistryFilterValue::String(value) + } +} + +impl From<&str> for RegistryFilterValue { + fn from(value: &str) -> Self { + RegistryFilterValue::String(value.to_string()) + } +} + +impl From for RegistryFilterValue { + fn from(value: u64) -> Self { + RegistryFilterValue::U64(value) + } +} + +impl From for RegistryFilterValue { + fn from(value: u32) -> Self { + RegistryFilterValue::U64(value as u64) + } +} + +impl From for RegistryFilterValue { + fn from(value: u16) -> Self { + RegistryFilterValue::U16(value) + } +} + +impl From for RegistryFilterValue { + fn from(value: bool) -> Self { + RegistryFilterValue::Boolean(value) + } +} diff --git a/crates/store/src/registry/write.rs b/crates/store/src/registry/write.rs new file mode 100644 index 0000000..61f2c63 --- /dev/null +++ b/crates/store/src/registry/write.rs @@ -0,0 +1,632 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, RegistryStore, SerializeInfallible, U16_LEN, U64_LEN, ValueKey, + write::{ + BatchBuilder, RegistryClass, ValueClass, + assert::AssertValue, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use registry::{ + schema::prelude::{ + OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SEQ_ID, OBJ_SINGLETON, Object, ObjectInner, + ObjectType, Property, + }, + types::{ + EnumImpl, + error::ValidationError, + id::ObjectId, + index::{IndexBuilder, IndexKey, IndexValue}, + }, +}; +use std::{borrow::Cow, fmt::Display}; +use trc::AddContext; +use types::id::Id; + +const MAX_OBJECT_PAYLOAD_SIZE: usize = 200_000; + +#[derive(Debug, PartialEq, Eq)] +pub enum RegistryWriteResult { + Success(Id), + CannotDeleteLinked { + object_id: ObjectId, + linked_objects: Vec, + }, + InvalidSingletonId, + CannotDeleteSingleton, + NotFound { + object_id: ObjectId, + }, + InvalidForeignKey { + object_id: ObjectId, + }, + PrimaryKeyConflict { + property: Property, + existing_id: ObjectId, + }, + ValidationError { + errors: Vec, + }, + NotSupported, +} + +pub enum RegistryWrite<'x> { + Insert { + object: &'x Object, + id: Option, + }, + Update { + object: &'x Object, + id: Id, + old_object: &'x Object, + }, + Delete { + object_id: ObjectId, + object: Option<&'x Object>, + allowed_orphan_types: &'x [ObjectType], + }, +} + +impl RegistryStore { + pub async fn write(&self, write: RegistryWrite<'_>) -> trc::Result { + let mut set_index = IndexBuilder::default(); + let mut clear_index = IndexBuilder::default(); + + let object; + let object_type; + let object_flags; + let object_id; + let mut item_id; + + let mut batch = BatchBuilder::new(); + let mut write_id = true; + let mut generate_id = false; + + match write { + RegistryWrite::Insert { + object: insert_object, + id, + } => { + object = insert_object; + object_flags = object.flags(); + object_type = object.object_type(); + object_id = object_type.to_id(); + object.index(&mut set_index); + + item_id = if let Some(id) = id { + id.id() + } else if object_flags & OBJ_SINGLETON != 0 { + write_id = false; + Id::singleton().id() + } else if object_flags & OBJ_SEQ_ID != 0 { + generate_id = true; + u64::MAX + } else { + self.0.id_generator.generate() + }; + } + RegistryWrite::Update { + object: update_object, + id, + old_object, + } => { + object = update_object; + object_flags = object.flags(); + object_type = object.object_type(); + object_id = object_type.to_id(); + object.index(&mut set_index); + + // Obtain changes + let mut old_index = IndexBuilder::default(); + old_object.index(&mut old_index); + for key in &old_index.keys { + if !set_index.keys.contains(key) { + clear_index.keys.insert(key.clone()); + } + } + set_index.keys.retain(|key| !old_index.keys.contains(key)); + + // Validate singleton + if object_flags & OBJ_SINGLETON != 0 && !id.is_singleton() { + return Ok(RegistryWriteResult::InvalidSingletonId); + } + + // Assert value + item_id = id.id(); + batch.assert_value( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + AssertValue::Hash(old_object.revision), + ); + } + RegistryWrite::Delete { + object_id, + object, + allowed_orphan_types, + } => { + return if object_id.object().flags() & OBJ_SINGLETON == 0 { + self.delete(object_id, object, allowed_orphan_types).await + } else { + Ok(RegistryWriteResult::CannotDeleteSingleton) + }; + } + } + + // Validate object + let mut errors = Vec::new(); + object.validate(&mut errors); + if !errors.is_empty() { + return Ok(RegistryWriteResult::ValidationError { errors }); + } + + // Write to local registry + if let ObjectInner::DataStore(data_store) = &object.inner { + if generate_id { + return Ok(RegistryWriteResult::NotSupported); + } + + return self + .write_data_store(data_store) + .await + .map(|_| RegistryWriteResult::Success(Id::singleton())); + } + + // Validate foreign keys + let tenant_id = object.inner.member_tenant_id().map(|id| id.id()); + let account_id = object + .inner + .account_id() + .map(|id| id.id()) + .or_else(|| (object_type == ObjectType::Account).then_some(item_id)); + + #[cfg(not(feature = "test_mode"))] + let set_keys = &set_index.keys; + + #[cfg(feature = "test_mode")] + let set_keys = set_index + .keys + .iter() + .collect::>(); + + for key in set_keys { + match key { + IndexKey::ForeignKey { + object_id: foreign_id, + type_filter, + } => { + // Verify that the referenced object exists + let item_id = foreign_id.id().id(); + let object_id = foreign_id.object().to_id(); + let object_flags = foreign_id.object().flags(); + let key = if type_filter != &IndexValue::None { + RegistryClass::Index { + index_id: Property::Type.to_id(), + object_id, + item_id, + key: type_filter.serialize(), + } + } else { + RegistryClass::IndexId { object_id, item_id } + }; + + if !self + .0 + .store + .key_exists(ValueKey::from(ValueClass::Registry(key))) + .await + .caused_by(trc::location!())? + { + return Ok(RegistryWriteResult::InvalidForeignKey { + object_id: *foreign_id, + }); + } else if let Some(tenant_id) = tenant_id + && (object_flags & OBJ_FILTER_TENANT) != 0 + && !self + .0 + .store + .key_exists(ValueKey::from(ValueClass::Registry( + RegistryClass::Index { + index_id: Property::MemberTenantId.to_id(), + object_id, + item_id, + key: IndexValue::U64(tenant_id).serialize(), + }, + ))) + .await + .caused_by(trc::location!())? + { + return Ok(RegistryWriteResult::InvalidForeignKey { + object_id: *foreign_id, + }); + } else if (object_flags & OBJ_FILTER_ACCOUNT) != 0 + && let Some(account_id) = account_id + && !self + .0 + .store + .key_exists(ValueKey::from(ValueClass::Registry( + RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: IndexValue::U64(account_id).serialize(), + }, + ))) + .await + .caused_by(trc::location!())? + { + return Ok(RegistryWriteResult::InvalidForeignKey { + object_id: *foreign_id, + }); + } + } + IndexKey::Unique { + property, + value_1, + value_2, + global, + } => { + let key = ValueKey::from(ValueClass::Registry(RegistryClass::PrimaryKey { + object_id: (!*global).then_some(object_id), + index_id: property.to_id(), + key: serialize_composite_key(value_1, value_2), + })); + if let Some(existing_id) = self + .0 + .store + .get_value::(key) + .await + .caused_by(trc::location!())? + && existing_id != ObjectId::new(object_type, Id::new(item_id)) + { + return Ok(RegistryWriteResult::PrimaryKeyConflict { + property: *property, + existing_id, + }); + } + } + IndexKey::Search { .. } => {} + } + } + + // Assign id + if generate_id { + let mut id_batch = BatchBuilder::new(); + id_batch.add_and_get( + ValueClass::Registry(RegistryClass::IdCounter { object_id }), + 1, + ); + item_id = self + .0 + .store + .write(id_batch.build_all()) + .await + .and_then(|v| v.last_counter_id())? as u64; + } + + // It's pickle time! + let out = object.inner.to_pickled_vec(); + if out.len() > MAX_OBJECT_PAYLOAD_SIZE { + return Ok(RegistryWriteResult::ValidationError { + errors: vec![ValidationError::Invalid { + property: Property::Id, + value: format!( + "Object size {} exceeds maximum of {}", + out.len(), + MAX_OBJECT_PAYLOAD_SIZE + ), + }], + }); + } + + // Build batch + if write_id { + batch.set( + ValueClass::Registry(RegistryClass::IndexId { object_id, item_id }), + vec![], + ); + } + + batch + .registry_index(object_id, item_id, set_index.keys.iter(), true) + .registry_index(object_id, item_id, clear_index.keys.iter(), false) + .set( + ValueClass::Registry(RegistryClass::Item { object_id, item_id }), + out, + ); + + self.store() + .write(batch.build_all()) + .await + .map(|_| RegistryWriteResult::Success(Id::new(item_id))) + } + + async fn delete( + &self, + object_id: ObjectId, + object: Option<&Object>, + allowed_orphan_types: &[ObjectType], + ) -> trc::Result { + let object_type = object_id.object(); + let object_type_id = object_type.to_id(); + let id = object_id.id(); + let item_id = id.id(); + + // Fetch object + let object = if let Some(object) = object { + Cow::Borrowed(object) + } else if let Some(object) = self.get(object_id).await? { + Cow::Owned(object) + } else { + return Ok(RegistryWriteResult::NotFound { + object_id: ObjectId::new(object_type, id), + }); + }; + + // Validate tenant and account changes + let mut clear_index = IndexBuilder::default(); + object.index(&mut clear_index); + + // Validate relationships + let mut linked = self.linked_objects(object_id).await?; + if !linked.is_empty() { + if !allowed_orphan_types.is_empty() { + linked.retain(|object_id| !allowed_orphan_types.contains(&object_id.object())); + } + + if !linked.is_empty() { + return Ok(RegistryWriteResult::CannotDeleteLinked { + object_id: ObjectId::new(object_type, id), + linked_objects: linked, + }); + } + } + + // Build deletion batch + let mut batch = BatchBuilder::new(); + batch + .assert_value( + ValueClass::Registry(RegistryClass::Item { + object_id: object_type_id, + item_id, + }), + AssertValue::Hash(object.revision), + ) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id: object_type_id, + item_id, + })) + .clear(ValueClass::Registry(RegistryClass::IndexId { + object_id: object_type_id, + item_id, + })) + .registry_index(object_type_id, item_id, clear_index.keys.iter(), false); + + self.0 + .store + .write(batch.build_all()) + .await + .map(|_| RegistryWriteResult::Success(Id::from(item_id))) + .caused_by(trc::location!()) + } + + pub async fn linked_objects(&self, object_id: ObjectId) -> trc::Result> { + let object_type_id = object_id.object().to_id(); + let item_id = object_id.id().id(); + let mut linked = Vec::new(); + let from_key = ValueKey::from(ValueClass::Registry(RegistryClass::Reference { + to_object_id: object_type_id, + to_item_id: item_id, + from_object_id: 0, + from_item_id: 0, + })); + let to_key = ValueKey::from(ValueClass::Registry(RegistryClass::Reference { + to_object_id: object_type_id, + to_item_id: item_id, + from_object_id: u16::MAX, + from_item_id: u64::MAX, + })); + + self.0 + .store + .iterate( + IterateParams::new(from_key, to_key).no_values().ascending(), + |key, _| { + if key.len() == (U16_LEN * 2) + (U64_LEN * 2) { + let object = + ObjectType::from_id(key.deserialize_be_u16(U64_LEN + U16_LEN)?) + .ok_or_else(|| { + trc::EventType::Registry( + trc::RegistryEvent::DeserializationError, + ) + .into_err() + .caused_by(trc::location!()) + .ctx(trc::Key::Key, key) + })?; + let id = key.deserialize_be_u64(U64_LEN + U16_LEN + U16_LEN)?; + linked.push(ObjectId::new(object, Id::new(id))); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!()) + .map(|_| linked) + } + + #[inline(always)] + pub fn assign_id(&self) -> u64 { + self.0.id_generator.generate() + } +} + +impl BatchBuilder { + pub fn registry_index<'x>( + &mut self, + object_id: u16, + item_id: u64, + index_keys: impl Iterator>, + is_set: bool, + ) -> &mut Self { + for key in index_keys { + let (key, value) = match key { + IndexKey::Search { property, value } => ( + RegistryClass::Index { + index_id: property.to_id(), + object_id, + item_id, + key: value.serialize(), + }, + vec![], + ), + IndexKey::Unique { + property, + value_1, + value_2, + global, + } => ( + RegistryClass::PrimaryKey { + object_id: (!*global).then_some(object_id), + index_id: property.to_id(), + key: serialize_composite_key(value_1, value_2), + }, + KeySerializer::new(U16_LEN + U64_LEN) + .write(object_id) + .write(item_id) + .finalize(), + ), + IndexKey::ForeignKey { + object_id: to_object_id, + .. + } => ( + RegistryClass::Reference { + to_object_id: to_object_id.object().to_id(), + to_item_id: to_object_id.id().id(), + from_item_id: item_id, + from_object_id: object_id, + }, + vec![], + ), + }; + if is_set { + if !value.is_empty() { + self.assert_value(ValueClass::Registry(key.clone()), ()); + } + self.set(ValueClass::Registry(key), value); + } else { + self.clear(ValueClass::Registry(key)); + } + } + self + } +} + +fn serialize_composite_key(value_1: &IndexValue<'_>, value_2: &IndexValue<'_>) -> Vec { + let mut key = value_1.serialize(); + match value_2 { + IndexValue::Text(text) => key.extend_from_slice(text.as_bytes()), + IndexValue::Bytes(bytes) => key.extend_from_slice(bytes), + IndexValue::U64(num) => key.extend_from_slice(&num.to_be_bytes()), + IndexValue::I64(num) => key.extend_from_slice(&num.to_be_bytes()), + IndexValue::U16(num) => key.extend_from_slice(&num.to_be_bytes()), + IndexValue::None => {} + } + key +} + +impl SerializeInfallible for IndexValue<'_> { + fn serialize(&self) -> Vec { + match self { + IndexValue::Text(text) => text.as_bytes().to_vec(), + IndexValue::Bytes(bytes) => bytes.clone(), + IndexValue::U64(num) => num.to_be_bytes().to_vec(), + IndexValue::I64(num) => num.to_be_bytes().to_vec(), + IndexValue::U16(num) => num.to_be_bytes().to_vec(), + IndexValue::None => vec![], + } + } +} + +impl<'x> RegistryWrite<'x> { + pub fn insert(object: &'x Object) -> Self { + RegistryWrite::Insert { object, id: None } + } + + pub fn insert_with_id(id: Id, object: &'x Object) -> Self { + RegistryWrite::Insert { + object, + id: Some(id), + } + } + + pub fn update(id: Id, object: &'x Object, old_object: &'x Object) -> Self { + RegistryWrite::Update { + object, + id, + old_object, + } + } + + pub fn delete(object_id: ObjectId) -> Self { + RegistryWrite::Delete { + object_id, + object: None, + allowed_orphan_types: &[], + } + } + + pub fn delete_object(object_id: ObjectId, object: &'x Object) -> Self { + RegistryWrite::Delete { + object_id, + object: Some(object), + allowed_orphan_types: &[], + } + } +} + +impl Display for RegistryWriteResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RegistryWriteResult::Success(id) => write!(f, "Success: {}", id), + RegistryWriteResult::CannotDeleteLinked { + object_id, + linked_objects, + } => { + write!(f, "Cannot delete {} because it is linked to: ", object_id)?; + for linked in linked_objects { + write!(f, "{}, ", linked)?; + } + Ok(()) + } + RegistryWriteResult::InvalidSingletonId => write!(f, "Invalid singleton id"), + RegistryWriteResult::CannotDeleteSingleton => write!(f, "Cannot delete singleton"), + RegistryWriteResult::NotFound { object_id } => write!(f, "Not found: {}", object_id), + RegistryWriteResult::InvalidForeignKey { object_id } => { + write!(f, "Invalid foreign key: {}", object_id) + } + RegistryWriteResult::PrimaryKeyConflict { + property, + existing_id, + } => { + write!( + f, + "Primary key conflict on property {:?} with existing object {}", + property.as_str(), + existing_id + ) + } + RegistryWriteResult::ValidationError { errors } => { + write!(f, "Validation error: ")?; + for error in errors { + write!(f, "{}, ", error)?; + } + Ok(()) + } + RegistryWriteResult::NotSupported => write!(f, "Operation not supported"), + } + } +} diff --git a/crates/store/src/search/bm_u32.rs b/crates/store/src/search/bm_u32.rs new file mode 100644 index 0000000..32040fe --- /dev/null +++ b/crates/store/src/search/bm_u32.rs @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, Store, U32_LEN, ValueKey, + search::*, + write::{ + SEARCH_INDEX_MAX_FIELD_LEN, SearchIndex, SearchIndexClass, SearchIndexField, SearchIndexId, + SearchIndexType, ValueClass, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use ahash::AHashMap; +use roaring::RoaringBitmap; +use std::{ + collections::hash_map::Entry, + ops::{BitAndAssign, BitOrAssign}, +}; +use trc::AddContext; +use utils::cheeky_hash::CheekyHash; + +#[derive(Default)] +pub(super) struct BitmapCache { + cache: AHashMap<(CheekyHash, u8), Option>, +} + +impl BitmapCache { + pub async fn merge_bitmaps( + &mut self, + store: &Store, + index: SearchIndex, + account_id: u32, + hashes: impl Iterator, + field: u8, + is_union: bool, + ) -> trc::Result> { + let mut result = RoaringBitmap::new(); + for (idx, hash) in hashes.enumerate() { + match self.cache.entry((hash, field)) { + Entry::Occupied(entry) => { + if let Some(bm) = entry.get() { + if is_union { + result.bitor_assign(bm); + } else if idx == 0 { + result = bm.clone(); + } else { + result.bitand_assign(bm); + if result.is_empty() { + return Ok(None); + } + } + } else if !is_union { + return Ok(None); + } + } + Entry::Vacant(entry) => { + let from_key = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: 0, + }, + typ: SearchIndexType::Term { hash, field }, + })); + let to_key = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: u32::MAX, + }, + typ: SearchIndexType::Term { hash, field }, + })); + let key_len = (U32_LEN * 2) + hash.len() + 2; + let mut documents = RoaringBitmap::new(); + store + .iterate( + IterateParams::new(from_key, to_key).no_values().ascending(), + |key, _| { + if key.len() == key_len { + documents.insert(key.deserialize_be_u32(key.len() - U32_LEN)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if !documents.is_empty() { + if is_union { + result.bitor_assign(&documents); + } else if idx == 0 { + result = documents.clone(); + } else { + result.bitand_assign(&documents); + if result.is_empty() { + entry.insert(Some(documents)); + return Ok(None); + } + } + entry.insert(Some(documents)); + } else if !is_union { + entry.insert(None); + return Ok(None); + } + } + } + } + + if !result.is_empty() { + Ok(Some(result)) + } else { + Ok(None) + } + } +} + +pub(crate) async fn range_to_bitmap( + store: &Store, + index: SearchIndex, + account_id: u32, + field_id: u8, + match_value: &[u8], + op: SearchOperator, +) -> trc::Result> { + let ((from_value, from_doc_id, from_field), (end_value, end_doc_id, end_field)) = match op { + SearchOperator::LowerThan => ((&[][..], 0, field_id), (match_value, 0, field_id)), + SearchOperator::LowerEqualThan => { + ((&[][..], 0, field_id), (match_value, u32::MAX, field_id)) + } + SearchOperator::GreaterThan => ( + (match_value, u32::MAX, field_id), + (&[][..], u32::MAX, field_id + 1), + ), + SearchOperator::GreaterEqualThan => ( + (match_value, 0, field_id), + (&[][..], u32::MAX, field_id + 1), + ), + SearchOperator::Equal | SearchOperator::Contains => ( + (match_value, 0, field_id), + (match_value, u32::MAX, field_id), + ), + }; + + let begin = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: from_doc_id, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: from_field, + data: from_value.to_vec(), + }, + }, + })); + + let end = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: end_doc_id, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: end_field, + data: end_value.to_vec(), + }, + }, + })); + + let mut bm = RoaringBitmap::new(); + let prefix = KeySerializer::new(U32_LEN + 2) + .write(index.as_u8() | 1 << 6) + .write(account_id) + .write(field_id) + .finalize(); + let prefix_len = prefix.len(); + + store + .iterate( + IterateParams::new(begin, end).no_values().ascending(), + |key, _| { + if !key.starts_with(&prefix) { + return Ok(false); + } + + let id_pos = key.len() - U32_LEN; + let value = key + .get(prefix_len..id_pos) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + + let matches = match op { + SearchOperator::LowerThan => value < match_value, + SearchOperator::LowerEqualThan => value <= match_value, + SearchOperator::GreaterThan => value > match_value, + SearchOperator::GreaterEqualThan => value >= match_value, + SearchOperator::Equal | SearchOperator::Contains => value == match_value, + }; + + if matches { + bm.insert(key.deserialize_be_u32(id_pos)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if !bm.is_empty() { + Ok(Some(bm)) + } else { + Ok(None) + } +} + +pub(crate) async fn sort_order( + store: &Store, + index: SearchIndex, + account_id: u32, + field_id: u8, +) -> trc::Result> { + let begin = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: 0, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id, + data: vec![0u8], + }, + }, + })); + let end = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: u32::MAX, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id, + data: vec![u8::MAX; SEARCH_INDEX_MAX_FIELD_LEN], + }, + }, + })); + + let mut last_value = Vec::new(); + let mut results = AHashMap::new(); + let mut pos = 0; + store + .iterate( + IterateParams::new(begin, end).no_values().ascending(), + |key, _| { + let value = key + .get(U32_LEN + 2..key.len() - U32_LEN) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + if value != last_value { + pos += 1; + last_value = value.to_vec(); + } + + results.insert(key.deserialize_be_u32(key.len() - U32_LEN)?, pos); + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + Ok(results) +} diff --git a/crates/store/src/search/bm_u64.rs b/crates/store/src/search/bm_u64.rs new file mode 100644 index 0000000..3fda59a --- /dev/null +++ b/crates/store/src/search/bm_u64.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + IterateParams, Store, U64_LEN, ValueKey, + search::*, + write::{ + SearchIndex, SearchIndexClass, SearchIndexField, SearchIndexId, SearchIndexType, + ValueClass, + key::{DeserializeBigEndian, KeySerializer}, + }, +}; +use ahash::AHashMap; +use roaring::RoaringTreemap; +use std::{ + collections::hash_map::Entry, + ops::{BitAndAssign, BitOrAssign}, +}; +use trc::AddContext; +use utils::cheeky_hash::CheekyHash; + +#[derive(Default)] +pub(super) struct TreemapCache { + cache: AHashMap<(CheekyHash, u8), Option>, +} + +impl TreemapCache { + pub async fn merge_treemaps( + &mut self, + store: &Store, + index: SearchIndex, + hashes: impl Iterator, + field: u8, + is_union: bool, + ) -> trc::Result> { + let mut result = RoaringTreemap::new(); + for (idx, hash) in hashes.enumerate() { + match self.cache.entry((hash, field)) { + Entry::Occupied(entry) => { + if let Some(bm) = entry.get() { + if is_union { + result.bitor_assign(bm); + } else if idx == 0 { + result = bm.clone(); + } else { + result.bitand_assign(bm); + if result.is_empty() { + return Ok(None); + } + } + } else if !is_union { + return Ok(None); + } + } + Entry::Vacant(entry) => { + let from_key = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: 0 }, + typ: SearchIndexType::Term { hash, field }, + })); + let to_key = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: u64::MAX }, + typ: SearchIndexType::Term { hash, field }, + })); + let key_len = U64_LEN + hash.len() + 2; + let mut documents = RoaringTreemap::new(); + store + .iterate( + IterateParams::new(from_key, to_key).no_values().ascending(), + |key, _| { + if key.len() == key_len { + documents.insert(key.deserialize_be_u64(key.len() - U64_LEN)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if !documents.is_empty() { + if is_union { + result.bitor_assign(&documents); + } else if idx == 0 { + result = documents.clone(); + } else { + result.bitand_assign(&documents); + if result.is_empty() { + entry.insert(Some(documents)); + return Ok(None); + } + } + entry.insert(Some(documents)); + } else if !is_union { + entry.insert(None); + return Ok(None); + } + } + } + } + + if !result.is_empty() { + Ok(Some(result)) + } else { + Ok(None) + } + } +} + +pub(crate) async fn range_to_treemap( + store: &Store, + index: SearchIndex, + field_id: u8, + match_value: &[u8], + op: SearchOperator, +) -> trc::Result> { + let ((from_value, from_id, from_field), (end_value, end_id, end_field)) = match op { + SearchOperator::LowerThan => ((&[][..], 0, field_id), (match_value, 0, field_id)), + SearchOperator::LowerEqualThan => { + ((&[][..], 0, field_id), (match_value, u64::MAX, field_id)) + } + SearchOperator::GreaterThan => ( + (match_value, u64::MAX, field_id), + (&[][..], u64::MAX, field_id + 1), + ), + SearchOperator::GreaterEqualThan => ( + (match_value, 0, field_id), + (&[][..], u64::MAX, field_id + 1), + ), + SearchOperator::Equal | SearchOperator::Contains => ( + (match_value, 0, field_id), + (match_value, u64::MAX, field_id), + ), + }; + + let begin = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: from_id }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: from_field, + data: from_value.to_vec(), + }, + }, + })); + + let end = ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: end_id }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: end_field, + data: end_value.to_vec(), + }, + }, + })); + + let mut bm = RoaringTreemap::new(); + let prefix = KeySerializer::new(U64_LEN + 2) + .write(index.as_u8() | 1 << 6) + .write(field_id) + .finalize(); + let prefix_len = prefix.len(); + + store + .iterate( + IterateParams::new(begin, end).no_values().ascending(), + |key, _| { + if !key.starts_with(&prefix) { + return Ok(false); + } + + let id_pos = key.len() - U64_LEN; + let value = key + .get(prefix_len..id_pos) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + + let matches = match op { + SearchOperator::LowerThan => value < match_value, + SearchOperator::LowerEqualThan => value <= match_value, + SearchOperator::GreaterThan => value > match_value, + SearchOperator::GreaterEqualThan => value >= match_value, + SearchOperator::Equal | SearchOperator::Contains => value == match_value, + }; + + if matches { + bm.insert(key.deserialize_be_u64(id_pos)?); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + if !bm.is_empty() { + Ok(Some(bm)) + } else { + Ok(None) + } +} diff --git a/crates/store/src/search/document.rs b/crates/store/src/search/document.rs new file mode 100644 index 0000000..974648d --- /dev/null +++ b/crates/store/src/search/document.rs @@ -0,0 +1,315 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::search::*; + +impl IndexDocument { + pub fn new(index: SearchIndex) -> Self { + Self { + fields: Default::default(), + index, + } + } + + pub fn with_account_id(mut self, account_id: u32) -> Self { + self.fields + .insert(SearchField::AccountId, SearchValue::Uint(account_id as u64)); + self + } + + pub fn with_document_id(mut self, document_id: u32) -> Self { + self.fields.insert( + SearchField::DocumentId, + SearchValue::Uint(document_id as u64), + ); + self + } + + pub fn with_id(mut self, id: u64) -> Self { + self.fields.insert(SearchField::Id, SearchValue::Uint(id)); + self + } + + pub fn index_text(&mut self, field: impl Into, value: &str, language: Language) { + match self.fields.entry(field.into()) { + Entry::Occupied(mut entry) => { + if let SearchValue::Text { + value: existing_value, + .. + } = entry.get_mut() + { + existing_value.push(' '); + sanitize_text_to_buf(existing_value, value); + } + } + Entry::Vacant(entry) => { + entry.insert(SearchValue::Text { + value: sanitize_text(value), + language, + }); + } + } + } + + pub fn index_bool(&mut self, field: impl Into, value: bool) { + self.fields + .insert(field.into(), SearchValue::Boolean(value)); + } + + pub fn index_integer>(&mut self, field: impl Into, value: N) { + self.fields + .insert(field.into(), SearchValue::Int(value.into())); + } + + pub fn index_unsigned>(&mut self, field: impl Into, value: N) { + self.fields + .insert(field.into(), SearchValue::Uint(value.into())); + } + + pub fn index_keyword(&mut self, field: impl Into, value: impl AsRef) { + self.fields.insert( + field.into(), + SearchValue::Text { + value: sanitize_text(value.as_ref()), + language: Language::None, + }, + ); + } + + pub fn insert_key_value( + &mut self, + field: impl Into, + key: impl AsRef, + value: impl AsRef, + ) { + let search_field = field.into(); + let key = key + .as_ref() + .chars() + .filter(|ch| !ch.is_control()) + .map(|ch| ch.to_ascii_lowercase()) + .collect::(); + let value = value.as_ref(); + + match self.fields.entry(search_field) { + Entry::Occupied(mut entry) => { + if let SearchValue::KeyValues(existing_key_values) = entry.get_mut() { + if let Some(existing_value) = existing_key_values.get_mut(&key) { + existing_value.push(' '); + sanitize_text_to_buf(existing_value, value); + } else { + existing_key_values.append(key, sanitize_text(value)); + } + } + } + Entry::Vacant(entry) => { + let mut new_key_values = VecMap::new(); + new_key_values.append(key, sanitize_text(value)); + entry.insert(SearchValue::KeyValues(new_key_values)); + } + } + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + pub fn has_field(&self, field: &SearchField) -> bool { + self.fields.contains_key(field) + } + + pub fn fields(&self) -> impl Iterator { + self.fields.iter() + } + + pub fn set_unknown_language(&mut self, lang: Language) { + for value in self.fields.values_mut() { + if let SearchValue::Text { language, .. } = value + && language.is_unknown() + { + *language = lang; + } + } + } +} + +impl SearchFilter { + pub fn cond( + field: impl Into, + op: SearchOperator, + value: impl Into, + ) -> Self { + SearchFilter::Operator { + field: field.into(), + op, + value: value.into(), + } + } + + pub fn eq(field: impl Into, value: impl Into) -> Self { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::Equal, + value: value.into(), + } + } + + pub fn lt(field: impl Into, value: impl Into) -> Self { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::LowerThan, + value: value.into(), + } + } + + pub fn le(field: impl Into, value: impl Into) -> Self { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::LowerEqualThan, + value: value.into(), + } + } + + pub fn gt(field: impl Into, value: impl Into) -> Self { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::GreaterThan, + value: value.into(), + } + } + + pub fn ge(field: impl Into, value: impl Into) -> Self { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::GreaterEqualThan, + value: value.into(), + } + } + + pub fn has_text_detect( + field: impl Into, + text: impl Into, + default_language: Language, + ) -> Self { + let (text, language) = Language::detect(text.into(), default_language); + Self::has_text(field, text, language) + } + + pub fn has_text( + field: impl Into, + text: impl Into, + language: Language, + ) -> Self { + let text = text.into(); + let (is_exact, text) = if let Some(text) = text + .strip_prefix('"') + .and_then(|t| t.strip_suffix('"')) + .or_else(|| text.strip_prefix('\'').and_then(|t| t.strip_suffix('\''))) + { + (true, text.to_string()) + } else { + (false, text) + }; + + if !matches!(language, Language::None) && is_exact { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::Equal, + value: SearchValue::Text { + value: text, + language, + }, + } + } else { + SearchFilter::Operator { + field: field.into(), + op: SearchOperator::Contains, + value: SearchValue::Text { + value: text, + language, + }, + } + } + } + + #[inline(always)] + pub fn has_english_text(field: impl Into, text: impl Into) -> Self { + Self::has_text(field, text, Language::English) + } + + #[inline(always)] + pub fn has_keyword(field: impl Into, text: impl Into) -> Self { + Self::has_text(field, text, Language::None) + } + + pub fn is_in_set(set: RoaringBitmap) -> Self { + SearchFilter::DocumentSet(set) + } +} + +impl SearchComparator { + pub fn field(field: impl Into, ascending: bool) -> Self { + Self::Field { + field: field.into(), + ascending, + } + } + + pub fn set(set: RoaringBitmap, ascending: bool) -> Self { + Self::DocumentSet { set, ascending } + } + + pub fn sorted_set(set: AHashMap, ascending: bool) -> Self { + Self::SortedSet { set, ascending } + } + + pub fn ascending(field: impl Into) -> Self { + Self::Field { + field: field.into(), + ascending: true, + } + } + + pub fn descending(field: impl Into) -> Self { + Self::Field { + field: field.into(), + ascending: false, + } + } +} + +#[inline(always)] +fn write_sanitized(out: &mut String, text: &str) { + let mut last_is_space = true; + for ch in text.chars() { + match ch { + ' ' | '\x09'..='\x0d' => { + if !last_is_space { + out.push(' '); + last_is_space = true; + } + } + '\0'..='\x1f' | '\x7f'..='\u{9f}' => {} + ch => { + out.push(ch); + last_is_space = false; + } + } + } +} + +#[inline(always)] +fn sanitize_text_to_buf(out: &mut String, text: &str) { + out.reserve_exact(text.len()); + write_sanitized(out, text); +} + +#[inline(always)] +fn sanitize_text(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + write_sanitized(&mut out, text); + out +} diff --git a/crates/store/src/search/fields.rs b/crates/store/src/search/fields.rs new file mode 100644 index 0000000..7cf7e26 --- /dev/null +++ b/crates/store/src/search/fields.rs @@ -0,0 +1,249 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::search::*; + +impl SearchableField for EmailSearchField { + fn index() -> SearchIndex { + SearchIndex::Email + } + + fn primary_keys() -> &'static [SearchField] { + &[SearchField::AccountId, SearchField::DocumentId] + } + + fn all_fields() -> &'static [SearchField] { + &[ + SearchField::Email(EmailSearchField::From), + SearchField::Email(EmailSearchField::To), + SearchField::Email(EmailSearchField::Cc), + SearchField::Email(EmailSearchField::Bcc), + SearchField::Email(EmailSearchField::Subject), + SearchField::Email(EmailSearchField::Body), + SearchField::Email(EmailSearchField::Attachment), + SearchField::Email(EmailSearchField::ReceivedAt), + SearchField::Email(EmailSearchField::SentAt), + SearchField::Email(EmailSearchField::Size), + SearchField::Email(EmailSearchField::HasAttachment), + SearchField::Email(EmailSearchField::Headers), + ] + } + + fn is_indexed(&self) -> bool { + #[cfg(not(feature = "test_mode"))] + { + matches!( + self, + EmailSearchField::From + | EmailSearchField::To + | EmailSearchField::Subject + | EmailSearchField::ReceivedAt + | EmailSearchField::SentAt + | EmailSearchField::Size + | EmailSearchField::HasAttachment, + ) + } + + #[cfg(feature = "test_mode")] + { + matches!( + self, + EmailSearchField::From + | EmailSearchField::To + | EmailSearchField::Subject + | EmailSearchField::ReceivedAt + | EmailSearchField::SentAt + | EmailSearchField::Size + | EmailSearchField::HasAttachment + | EmailSearchField::Bcc + | EmailSearchField::Cc + ) + } + } + + fn is_text(&self) -> bool { + matches!( + self, + EmailSearchField::From + | EmailSearchField::To + | EmailSearchField::Cc + | EmailSearchField::Bcc + | EmailSearchField::Subject + | EmailSearchField::Body + | EmailSearchField::Attachment, + ) + } +} + +impl SearchableField for CalendarSearchField { + fn index() -> SearchIndex { + SearchIndex::Calendar + } + + fn primary_keys() -> &'static [SearchField] { + &[SearchField::AccountId, SearchField::DocumentId] + } + + fn all_fields() -> &'static [SearchField] { + &[ + SearchField::Calendar(CalendarSearchField::Title), + SearchField::Calendar(CalendarSearchField::Description), + SearchField::Calendar(CalendarSearchField::Location), + SearchField::Calendar(CalendarSearchField::Owner), + SearchField::Calendar(CalendarSearchField::Attendee), + SearchField::Calendar(CalendarSearchField::Start), + SearchField::Calendar(CalendarSearchField::Uid), + ] + } + + fn is_indexed(&self) -> bool { + matches!(self, CalendarSearchField::Start | CalendarSearchField::Uid) + } + + fn is_text(&self) -> bool { + !self.is_indexed() + } +} + +impl SearchableField for ContactSearchField { + fn index() -> SearchIndex { + SearchIndex::Contacts + } + + fn primary_keys() -> &'static [SearchField] { + &[SearchField::AccountId, SearchField::DocumentId] + } + + fn all_fields() -> &'static [SearchField] { + &[ + SearchField::Contact(ContactSearchField::Member), + SearchField::Contact(ContactSearchField::Kind), + SearchField::Contact(ContactSearchField::Name), + SearchField::Contact(ContactSearchField::Nickname), + SearchField::Contact(ContactSearchField::Organization), + SearchField::Contact(ContactSearchField::Email), + SearchField::Contact(ContactSearchField::Phone), + SearchField::Contact(ContactSearchField::OnlineService), + SearchField::Contact(ContactSearchField::Address), + SearchField::Contact(ContactSearchField::Note), + SearchField::Contact(ContactSearchField::Uid), + ] + } + + fn is_indexed(&self) -> bool { + matches!(self, ContactSearchField::Uid | ContactSearchField::Kind) + } + + fn is_text(&self) -> bool { + !self.is_indexed() + } +} + +impl SearchableField for FileSearchField { + fn index() -> SearchIndex { + SearchIndex::File + } + + fn primary_keys() -> &'static [SearchField] { + &[SearchField::AccountId, SearchField::DocumentId] + } + + fn all_fields() -> &'static [SearchField] { + &[ + SearchField::File(FileSearchField::Name), + SearchField::File(FileSearchField::Content), + ] + } + + fn is_indexed(&self) -> bool { + false + } + + fn is_text(&self) -> bool { + true + } +} + +impl SearchableField for TracingSearchField { + fn index() -> SearchIndex { + SearchIndex::Tracing + } + + fn primary_keys() -> &'static [SearchField] { + &[SearchField::Id] + } + + fn all_fields() -> &'static [SearchField] { + &[ + SearchField::Tracing(TracingSearchField::EventType), + SearchField::Tracing(TracingSearchField::QueueId), + SearchField::Tracing(TracingSearchField::Keywords), + ] + } + + fn is_indexed(&self) -> bool { + matches!( + self, + TracingSearchField::QueueId | TracingSearchField::EventType + ) + } + + fn is_text(&self) -> bool { + matches!(self, TracingSearchField::Keywords) + } +} + +impl SearchField { + pub(crate) fn is_indexed(&self) -> bool { + match self { + SearchField::Email(field) => field.is_indexed(), + SearchField::Calendar(field) => field.is_indexed(), + SearchField::Contact(field) => field.is_indexed(), + SearchField::File(field) => field.is_indexed(), + SearchField::Tracing(field) => field.is_indexed(), + SearchField::AccountId | SearchField::DocumentId | SearchField::Id => false, + } + } + + pub(crate) fn is_text(&self) -> bool { + match self { + SearchField::Email(field) => field.is_text(), + SearchField::Calendar(field) => field.is_text(), + SearchField::Contact(field) => field.is_text(), + SearchField::File(field) => field.is_text(), + SearchField::Tracing(field) => field.is_text(), + SearchField::AccountId | SearchField::DocumentId | SearchField::Id => false, + } + } + + pub(crate) fn is_json(&self) -> bool { + matches!(self, SearchField::Email(EmailSearchField::Headers)) + } +} + +impl SearchIndex { + pub fn all_fields(&self) -> &[SearchField] { + match self { + SearchIndex::Email => EmailSearchField::all_fields(), + SearchIndex::Calendar => CalendarSearchField::all_fields(), + SearchIndex::Contacts => ContactSearchField::all_fields(), + SearchIndex::File => FileSearchField::all_fields(), + SearchIndex::Tracing => TracingSearchField::all_fields(), + SearchIndex::InMemory => unreachable!(), + } + } + + pub fn primary_keys(&self) -> &'static [SearchField] { + match self { + SearchIndex::Email => EmailSearchField::primary_keys(), + SearchIndex::Calendar => CalendarSearchField::primary_keys(), + SearchIndex::Contacts => ContactSearchField::primary_keys(), + SearchIndex::File => FileSearchField::primary_keys(), + SearchIndex::Tracing => TracingSearchField::primary_keys(), + SearchIndex::InMemory => unreachable!(), + } + } +} diff --git a/crates/store/src/search/index.rs b/crates/store/src/search/index.rs new file mode 100644 index 0000000..a45bf46 --- /dev/null +++ b/crates/store/src/search/index.rs @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Deserialize, IterateParams, Store, U64_LEN, ValueKey, + search::{ + IndexDocument, SearchField, SearchFilter, SearchOperator, SearchQuery, SearchValue, + term::{TermIndex, TermIndexBuilder}, + }, + write::{ + AlignedBytes, Archive, BatchBuilder, SEARCH_INDEX_MAX_FIELD_LEN, SearchIndex, + SearchIndexClass, SearchIndexField, SearchIndexId, SearchIndexType, ValueClass, + key::DeserializeBigEndian, + }, +}; +use ahash::AHashMap; +use trc::AddContext; +use utils::cheeky_hash::CheekyHash; + +impl Store { + pub(crate) async fn index(&self, documents: Vec) -> trc::Result<()> { + let truncate_at = if self.is_foundationdb() { 1_048_576 } else { 0 }; + + for document in documents { + let mut batch = BatchBuilder::new(); + let index = document.index; + let mut old_term_index = None; + + if matches!(index, SearchIndex::Calendar | SearchIndex::Contacts) { + let mut account_id = None; + let mut document_id = None; + for (field, value) in &document.fields { + if let SearchValue::Uint(id) = value { + match field { + SearchField::AccountId => { + account_id = Some(*id as u32); + } + SearchField::DocumentId => { + document_id = Some(*id as u32); + } + _ => {} + } + } + } + + if let (Some(account_id), Some(document_id)) = (account_id, document_id) + && let Some(archive) = self + .get_value::>(ValueKey::from( + ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id, + }, + typ: SearchIndexType::Document, + }), + )) + .await + .caused_by(trc::location!())? + { + old_term_index = Some(archive); + } + } + + let term_index_builder = TermIndexBuilder::build(document, truncate_at); + if let Some(old_term_index) = old_term_index { + let old_term_index = old_term_index + .unarchive::() + .caused_by(trc::location!())?; + term_index_builder + .index + .merge_index(&mut batch, index, term_index_builder.id, old_term_index) + .caused_by(trc::location!())?; + } else { + term_index_builder + .index + .write_index(&mut batch, index, term_index_builder.id) + .caused_by(trc::location!())?; + } + + let mut commit_points = batch.commit_points(); + for commit_point in commit_points.iter() { + let batch = batch.build_one(commit_point); + self.write(batch).await.caused_by(trc::location!())?; + } + } + Ok(()) + } + + pub(crate) async fn unindex(&self, query: SearchQuery) -> trc::Result<()> { + let index = query.index; + let mut account_documents: AHashMap> = AHashMap::new(); + let mut ids = vec![]; + let mut to_id = None; + let mut last_account_id = None; + + for filter in query.filters { + match filter { + SearchFilter::Operator { field, op, value } => match (field, value) { + (SearchField::AccountId, SearchValue::Uint(id)) + if op == SearchOperator::Equal => + { + last_account_id = Some(id as u32); + account_documents.entry(id as u32).or_default(); + } + (SearchField::DocumentId, SearchValue::Uint(id)) + if op == SearchOperator::Equal && last_account_id.is_some() => + { + account_documents + .get_mut(&last_account_id.unwrap()) + .unwrap() + .push(id as u32); + } + (SearchField::Id, SearchValue::Uint(id)) => match op { + SearchOperator::LowerThan => { + to_id = Some(id.saturating_sub(1)); + } + SearchOperator::LowerEqualThan => { + to_id = Some(id); + } + SearchOperator::Equal => { + ids.push(id); + } + _ => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .reason("Unsupported operator for Id field")); + } + }, + filter => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details(format!("Unsupported unindex filter {filter:?}"))); + } + }, + SearchFilter::And | SearchFilter::Or | SearchFilter::End => {} + SearchFilter::Not | SearchFilter::DocumentSet(_) => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details(format!("Unsupported unindex filter {filter:?}"))); + } + } + } + + // Delete by account and document ids + for (account_id, document_ids) in account_documents { + if !document_ids.is_empty() { + for document_id in document_ids { + let Some(archive) = self + .get_value::>(ValueKey::from( + ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id, + }, + typ: SearchIndexType::Document, + }), + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let term_index = archive + .unarchive::() + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + term_index.delete_index( + &mut batch, + index, + SearchIndexId::Account { + account_id, + document_id, + }, + ); + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } else { + // Delete all documents for the account + self.delete_range( + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: 0, + }, + typ: SearchIndexType::Document, + })), + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: u32::MAX, + }, + typ: SearchIndexType::Document, + })), + ) + .await + .caused_by(trc::location!())?; + + self.delete_range( + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: 0, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: 0, + data: vec![0u8], + }, + }, + })), + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: u32::MAX, + }, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: u8::MAX, + data: vec![u8::MAX; SEARCH_INDEX_MAX_FIELD_LEN], + }, + }, + })), + ) + .await + .caused_by(trc::location!())?; + + self.delete_range( + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: 0, + }, + typ: SearchIndexType::Term { + hash: CheekyHash::NULL, + field: 0, + }, + })), + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Account { + account_id, + document_id: u32::MAX, + }, + typ: SearchIndexType::Term { + hash: CheekyHash::FULL, + field: u8::MAX, + }, + })), + ) + .await + .caused_by(trc::location!())?; + } + } + + // Delete by global ids + for id in ids { + let Some(archive) = self + .get_value::>(ValueKey::from(ValueClass::SearchIndex( + SearchIndexClass { + index, + id: SearchIndexId::Global { id }, + typ: SearchIndexType::Document, + }, + ))) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let term_index = archive + .unarchive::() + .caused_by(trc::location!())?; + let mut batch = BatchBuilder::new(); + term_index.delete_index(&mut batch, index, SearchIndexId::Global { id }); + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + // Delete ranges + if let Some(to_id) = to_id { + let mut batches = Vec::new(); + self.iterate( + IterateParams::new( + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: 0 }, + typ: SearchIndexType::Document, + })), + ValueKey::from(ValueClass::SearchIndex(SearchIndexClass { + index, + id: SearchIndexId::Global { id: to_id }, + typ: SearchIndexType::Document, + })), + ), + |key, value| { + let archive = as Deserialize>::deserialize(value)?; + let term_index = archive.unarchive::()?; + let mut batch = BatchBuilder::new(); + term_index.delete_index( + &mut batch, + index, + SearchIndexId::Global { + id: key.deserialize_be_u64(key.len() - U64_LEN)?, + }, + ); + batches.push(batch); + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + for mut batch in batches { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + } + + Ok(()) + } +} diff --git a/crates/store/src/search/local.rs b/crates/store/src/search/local.rs new file mode 100644 index 0000000..ab312a0 --- /dev/null +++ b/crates/store/src/search/local.rs @@ -0,0 +1,228 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::search::*; +use roaring::RoaringBitmap; + +struct State { + pub op: SearchFilter, + pub bm: Option, +} + +impl SearchQuery { + pub fn new(index: SearchIndex) -> Self { + Self { + index, + filters: Vec::new(), + comparators: Vec::new(), + mask: RoaringBitmap::new(), + } + } + + pub fn with_filters(mut self, filters: Vec) -> Self { + if self.filters.is_empty() { + self.filters = filters; + } else { + self.filters.extend(filters); + } + self + } + + pub fn with_comparators(mut self, comparators: Vec) -> Self { + if self.comparators.is_empty() { + self.comparators = comparators; + } else { + self.comparators.extend(comparators); + } + self + } + + pub fn with_filter(mut self, filter: SearchFilter) -> Self { + self.filters.push(filter); + self + } + + pub fn add_filter(&mut self, filter: SearchFilter) -> &mut Self { + self.filters.push(filter); + self + } + + pub fn with_comparator(mut self, comparator: SearchComparator) -> Self { + self.comparators.push(comparator); + self + } + + pub fn with_mask(mut self, mask: RoaringBitmap) -> Self { + self.mask = mask; + self + } + + pub fn with_account_id(mut self, account_id: u32) -> Self { + self.filters.push(SearchFilter::cond( + SearchField::AccountId, + SearchOperator::Equal, + SearchValue::Uint(account_id as u64), + )); + self + } + + pub fn filter(self) -> QueryResults { + if self.filters.is_empty() { + return QueryResults { + results: self.mask, + comparators: self.comparators, + }; + } + let mut state: State = State { + op: SearchFilter::And, + bm: None, + }; + let mut stack = Vec::new(); + let mut filters = self.filters.into_iter().peekable(); + let mask = self.mask; + + while let Some(filter) = filters.next() { + let mut result = match filter { + SearchFilter::DocumentSet(set) => Some(set), + op @ (SearchFilter::And | SearchFilter::Or | SearchFilter::Not) => { + stack.push(state); + state = State { op, bm: None }; + continue; + } + SearchFilter::End => { + if let Some(prev_state) = stack.pop() { + let bm = state.bm; + state = prev_state; + bm + } else { + break; + } + } + SearchFilter::Operator { .. } => { + continue; + } + }; + + // Apply logical operation + if let Some(dest) = &mut state.bm { + match state.op { + SearchFilter::And => { + if let Some(result) = result { + dest.bitand_assign(result); + } else { + dest.clear(); + } + } + SearchFilter::Or => { + if let Some(result) = result { + dest.bitor_assign(result); + } + } + SearchFilter::Not => { + if let Some(mut result) = result { + result.bitxor_assign(&mask); + dest.bitand_assign(result); + } + } + _ => unreachable!(), + } + } else if let Some(ref mut result_) = result { + if let SearchFilter::Not = state.op { + result_.bitxor_assign(&mask); + } + state.bm = result; + } else if let SearchFilter::Not = state.op { + state.bm = Some(mask.clone()); + } else { + state.bm = Some(RoaringBitmap::new()); + } + + // And short-circuit + if matches!(state.op, SearchFilter::And) && state.bm.as_ref().unwrap().is_empty() { + while let Some(filter) = filters.peek() { + if matches!(filter, SearchFilter::End) { + break; + } else { + filters.next(); + } + } + } + } + + // AND with mask + let mut results = state.bm.unwrap_or_default(); + results.bitand_assign(&mask); + QueryResults { + results, + comparators: self.comparators, + } + } +} + +impl QueryResults { + pub fn new(results: RoaringBitmap, comparators: Vec) -> Self { + Self { + results, + comparators, + } + } + + pub fn with_comparators(mut self, comparators: Vec) -> Self { + if self.comparators.is_empty() { + self.comparators = comparators; + } else { + self.comparators.extend(comparators); + } + self + } + + pub fn results(&self) -> &RoaringBitmap { + &self.results + } + + pub fn update_results(&mut self, results: RoaringBitmap) { + self.results = results; + } + + pub fn into_bitmap(self) -> RoaringBitmap { + self.results + } + + pub fn into_sorted(self) -> Vec { + let comparators = self.comparators; + let mut results = self.results.into_iter().collect::>(); + + if !results.is_empty() && !comparators.is_empty() { + results.sort_by(|a, b| { + for comparator in &comparators { + let (a, b, is_ascending) = match comparator { + SearchComparator::DocumentSet { set, ascending } => { + (set.contains(*a) as u32, set.contains(*b) as u32, *ascending) + } + SearchComparator::SortedSet { set, ascending } => { + let missing = if *ascending { u32::MAX } else { 0 }; + ( + *set.get(a).unwrap_or(&missing), + *set.get(b).unwrap_or(&missing), + *ascending, + ) + } + SearchComparator::Field { .. } => continue, + }; + + let ordering = if is_ascending { a.cmp(&b) } else { b.cmp(&a) }; + + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal + }); + } + + results + } +} diff --git a/crates/store/src/search/mod.rs b/crates/store/src/search/mod.rs new file mode 100644 index 0000000..f23fe6c --- /dev/null +++ b/crates/store/src/search/mod.rs @@ -0,0 +1,340 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod bm_u32; +pub mod bm_u64; +pub mod document; +pub mod fields; +pub mod index; +pub mod local; +pub mod query; +pub mod split; +pub mod term; + +use crate::write::SearchIndex; +use ahash::AHashMap; +use nlp::language::Language; +use roaring::RoaringBitmap; +use std::cmp::Ordering; +use std::collections::hash_map::Entry; +use std::fmt::Display; +use std::ops::{BitAndAssign, BitOrAssign, BitXorAssign}; +use utils::map::vec_map::VecMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchOperator { + LowerThan, + LowerEqualThan, + GreaterThan, + GreaterEqualThan, + Equal, + Contains, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SearchField { + AccountId, + DocumentId, + Id, + Email(EmailSearchField), + Calendar(CalendarSearchField), + Contact(ContactSearchField), + File(FileSearchField), + Tracing(TracingSearchField), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum EmailSearchField { + From, + To, + Cc, + Bcc, + Subject, + Body, + Attachment, + ReceivedAt, + SentAt, + Size, + HasAttachment, + Headers, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CalendarSearchField { + Title, + Description, + Location, + Owner, + Attendee, + Start, + Uid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContactSearchField { + Member, + Kind, + Name, + Nickname, + Organization, + Email, + Phone, + OnlineService, + Address, + Note, + Uid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FileSearchField { + Name, + Content, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TracingSearchField { + EventType, + QueueId, + Keywords, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SearchValue { + Text { value: String, language: Language }, + KeyValues(VecMap), + Int(i64), + Uint(u64), + Boolean(bool), +} + +pub trait SearchDocumentId: Sized + Copy + Display { + fn from_u64(id: u64) -> Self; + fn field() -> SearchField; +} + +#[derive(Debug)] +pub struct SearchQuery { + pub(crate) index: SearchIndex, + pub(crate) filters: Vec, + pub(crate) comparators: Vec, + pub(crate) mask: RoaringBitmap, +} + +#[derive(Debug, PartialEq, Clone, Default)] +pub enum SearchFilter { + Operator { + field: SearchField, + op: SearchOperator, + value: SearchValue, + }, + DocumentSet(RoaringBitmap), + And, + Or, + Not, + #[default] + End, +} + +#[derive(Debug)] +pub enum SearchComparator { + Field { + field: SearchField, + ascending: bool, + }, + DocumentSet { + set: RoaringBitmap, + ascending: bool, + }, + SortedSet { + set: AHashMap, + ascending: bool, + }, +} + +#[derive(Debug)] +pub struct IndexDocument { + pub(crate) index: SearchIndex, + pub(crate) fields: AHashMap, +} + +#[derive(Debug)] +pub struct QueryResults { + results: RoaringBitmap, + comparators: Vec, +} + +impl From for SearchField { + fn from(field: EmailSearchField) -> Self { + SearchField::Email(field) + } +} + +impl From for SearchField { + fn from(field: CalendarSearchField) -> Self { + SearchField::Calendar(field) + } +} + +impl From for SearchField { + fn from(field: ContactSearchField) -> Self { + SearchField::Contact(field) + } +} + +impl From for SearchField { + fn from(field: FileSearchField) -> Self { + SearchField::File(field) + } +} + +impl From for SearchField { + fn from(field: TracingSearchField) -> Self { + SearchField::Tracing(field) + } +} + +impl From for SearchValue { + fn from(value: u64) -> Self { + SearchValue::Uint(value) + } +} + +impl From for SearchValue { + fn from(value: i64) -> Self { + SearchValue::Int(value) + } +} + +impl From for SearchValue { + fn from(value: u32) -> Self { + SearchValue::Uint(value as u64) + } +} + +impl From for SearchValue { + fn from(value: i32) -> Self { + SearchValue::Int(value as i64) + } +} + +impl From for SearchValue { + fn from(value: usize) -> Self { + SearchValue::Uint(value as u64) + } +} + +impl From for SearchValue { + fn from(value: bool) -> Self { + SearchValue::Boolean(value) + } +} + +impl From for SearchValue { + fn from(value: String) -> Self { + SearchValue::Text { + value, + language: Language::None, + } + } +} + +impl SearchDocumentId for u32 { + fn from_u64(id: u64) -> Self { + id as u32 + } + + fn field() -> SearchField { + SearchField::DocumentId + } +} + +impl SearchDocumentId for u64 { + fn from_u64(id: u64) -> Self { + id + } + + fn field() -> SearchField { + SearchField::Id + } +} + +pub trait SearchableField: Sized { + fn index() -> SearchIndex; + fn primary_keys() -> &'static [SearchField]; + fn all_fields() -> &'static [SearchField]; + fn is_indexed(&self) -> bool; + fn is_text(&self) -> bool; +} + +impl Eq for SearchFilter {} + +impl SearchIndex { + pub fn index_name(&self) -> &'static str { + match self { + SearchIndex::Email => "st_email", + SearchIndex::Calendar => "st_calendar", + SearchIndex::Contacts => "st_contact", + SearchIndex::File => "st_file", + SearchIndex::Tracing => "st_tracing", + SearchIndex::InMemory => unreachable!(), + } + } +} + +impl SearchField { + pub fn field_name(&self) -> &'static str { + match self { + SearchField::AccountId => "acc_id", + SearchField::DocumentId => "doc_id", + SearchField::Id => "id", + SearchField::Email(field) => match field { + EmailSearchField::From => "from", + EmailSearchField::To => "to", + EmailSearchField::Cc => "cc", + EmailSearchField::Bcc => "bcc", + EmailSearchField::Subject => "subj", + EmailSearchField::Body => "body", + EmailSearchField::Attachment => "attach", + EmailSearchField::ReceivedAt => "rcvd", + EmailSearchField::SentAt => "sent", + EmailSearchField::Size => "size", + EmailSearchField::HasAttachment => "has_att", + EmailSearchField::Headers => "headers", + }, + SearchField::Calendar(field) => match field { + CalendarSearchField::Title => "title", + CalendarSearchField::Description => "desc", + CalendarSearchField::Location => "loc", + CalendarSearchField::Owner => "owner", + CalendarSearchField::Attendee => "attendee", + CalendarSearchField::Start => "start", + CalendarSearchField::Uid => "uid", + }, + SearchField::Contact(field) => match field { + ContactSearchField::Member => "member", + ContactSearchField::Kind => "kind", + ContactSearchField::Name => "name", + ContactSearchField::Nickname => "nick", + ContactSearchField::Organization => "org", + ContactSearchField::Email => "email", + ContactSearchField::Phone => "phone", + ContactSearchField::OnlineService => "online", + ContactSearchField::Address => "addr", + ContactSearchField::Note => "note", + ContactSearchField::Uid => "uid", + }, + SearchField::File(field) => match field { + FileSearchField::Name => "name", + FileSearchField::Content => "content", + }, + SearchField::Tracing(field) => match field { + TracingSearchField::EventType => "ev_type", + TracingSearchField::QueueId => "queue_id", + TracingSearchField::Keywords => "keywords", + }, + } + } +} diff --git a/crates/store/src/search/query.rs b/crates/store/src/search/query.rs new file mode 100644 index 0000000..171ca4a --- /dev/null +++ b/crates/store/src/search/query.rs @@ -0,0 +1,423 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Store, + backend::MAX_TOKEN_LENGTH, + search::{ + QueryResults, SearchComparator, SearchField, SearchFilter, SearchOperator, SearchQuery, + SearchValue, + bm_u32::{BitmapCache, range_to_bitmap, sort_order}, + bm_u64::{TreemapCache, range_to_treemap}, + }, + write::SEARCH_INDEX_MAX_FIELD_LEN, +}; +use nlp::{language::stemmer::Stemmer, tokenizers::space::SpaceTokenizer}; +use roaring::{RoaringBitmap, RoaringTreemap}; +use std::ops::{BitAndAssign, BitOrAssign, BitXorAssign}; +use utils::cheeky_hash::CheekyHash; + +impl Store { + pub(crate) async fn query_account(&self, query: SearchQuery) -> trc::Result> { + struct State { + pub op: SearchFilter, + pub bm: Option, + } + let mut state: State = State { + op: SearchFilter::And, + bm: None, + }; + let mut stack = Vec::new(); + let mask = query.mask; + let mut bitmaps = BitmapCache::default(); + let mut account_id = u32::MAX; + + for filter in &query.filters { + if let SearchFilter::Operator { + field: SearchField::AccountId, + value: SearchValue::Uint(id), + .. + } = filter + { + account_id = *id as u32; + break; + } + } + + if account_id == u32::MAX { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Account ID must be specified before other filters")); + } + + let mut results; + if query.filters.len() > 1 { + let mut filters = query.filters.into_iter().peekable(); + while let Some(filter) = filters.next() { + let mut result = match filter { + SearchFilter::Operator { field, op, value } => { + if matches!(field, SearchField::AccountId) { + continue; + } + + if field.is_text() + && matches!(op, SearchOperator::Contains | SearchOperator::Equal) + { + let (value, language) = match value { + SearchValue::Text { value, language } => (value, language), + _ => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Expected text value for text field")); + } + }; + + if op == SearchOperator::Equal { + bitmaps + .merge_bitmaps( + self, + query.index, + account_id, + language + .tokenize_text(&value, MAX_TOKEN_LENGTH) + .map(|token| CheekyHash::new(token.word.as_bytes())), + field.u8_id(), + false, + ) + .await? + } else { + let mut result = RoaringBitmap::new(); + for token in Stemmer::new(&value, language, MAX_TOKEN_LENGTH) { + let mut tokens = Vec::with_capacity(3); + tokens.push(CheekyHash::new(token.word.as_bytes())); + tokens.push(CheekyHash::new( + format!("{}*", token.word).as_bytes(), + )); + if let Some(stemmed_word) = token.stemmed_word { + tokens.push(CheekyHash::new( + format!("{stemmed_word}*").as_bytes(), + )); + } + let union = bitmaps + .merge_bitmaps( + self, + query.index, + account_id, + tokens.into_iter(), + field.u8_id(), + true, + ) + .await?; + if let Some(union) = union { + if result.is_empty() { + result = union; + } else { + result.bitand_assign(&union); + if result.is_empty() { + break; + } + } + } else { + result.clear(); + break; + } + } + if !result.is_empty() { + Some(result) + } else { + None + } + } + } else if field.is_json() { + let (key, value) = match value { + SearchValue::KeyValues(kv) => kv.into_iter().next().unwrap(), + _ => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Expected text value for text field")); + } + }; + + if !value.is_empty() { + bitmaps + .merge_bitmaps( + self, + query.index, + account_id, + SpaceTokenizer::new(value.as_str(), MAX_TOKEN_LENGTH).map( + |value| { + CheekyHash::new(format!("{key} {value}").as_bytes()) + }, + ), + field.u8_id(), + true, + ) + .await? + } else { + bitmaps + .merge_bitmaps( + self, + query.index, + account_id, + [CheekyHash::new(key.as_bytes())].into_iter(), + field.u8_id(), + false, + ) + .await? + } + } else if field.is_indexed() { + let value = match value { + SearchValue::Text { value, .. } => { + let mut value = value.into_bytes(); + value.truncate(SEARCH_INDEX_MAX_FIELD_LEN); + value + } + SearchValue::Int(v) => (v as u64).to_be_bytes().to_vec(), + SearchValue::Uint(v) => v.to_be_bytes().to_vec(), + SearchValue::Boolean(v) => vec![v as u8], + SearchValue::KeyValues(_) => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Expected non key-value for non-text field")); + } + }; + + range_to_bitmap( + self, + query.index, + account_id, + field.u8_id(), + &value, + op, + ) + .await? + } else { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details(format!("Field {field:?} is not indexed"))); + } + } + SearchFilter::DocumentSet(bitmap) => Some(bitmap), + op @ (SearchFilter::And | SearchFilter::Or | SearchFilter::Not) => { + stack.push(state); + state = State { op, bm: None }; + continue; + } + SearchFilter::End => { + if let Some(prev_state) = stack.pop() { + let bm = state.bm; + state = prev_state; + bm + } else { + break; + } + } + }; + + // Apply logical operation + if let Some(dest) = &mut state.bm { + match state.op { + SearchFilter::And => { + if let Some(result) = result { + dest.bitand_assign(result); + } else { + dest.clear(); + } + } + SearchFilter::Or => { + if let Some(result) = result { + dest.bitor_assign(result); + } + } + SearchFilter::Not => { + if let Some(mut result) = result { + result.bitxor_assign(&mask); + dest.bitand_assign(result); + } + } + _ => unreachable!(), + } + } else if let Some(result_) = &mut result { + if let SearchFilter::Not = state.op { + result_.bitxor_assign(&mask); + } + state.bm = result; + } else if let SearchFilter::Not = state.op { + state.bm = Some(mask.clone()); + } else { + state.bm = Some(RoaringBitmap::new()); + } + + // And short circuit + if matches!(state.op, SearchFilter::And) && state.bm.as_ref().unwrap().is_empty() { + while let Some(filter) = filters.peek() { + if matches!(filter, SearchFilter::End) { + break; + } else { + filters.next(); + } + } + } + } + + results = state.bm.unwrap_or_default(); + results.bitand_assign(&mask); + } else { + results = mask; + } + + if results.len() > 1 && !query.comparators.is_empty() { + let mut comparators = Vec::with_capacity(query.comparators.len()); + for comparator in query.comparators { + let comparator = match comparator { + SearchComparator::Field { field, ascending } => SearchComparator::SortedSet { + set: sort_order(self, query.index, account_id, field.u8_id()).await?, + ascending, + }, + _ => comparator, + }; + + comparators.push(comparator); + } + + Ok(QueryResults::new(results, comparators).into_sorted()) + } else { + Ok(results.into_iter().collect::>()) + } + } + + pub(crate) async fn query_global(&self, query: SearchQuery) -> trc::Result> { + struct State { + pub op: SearchFilter, + pub bm: Option, + } + let mut state: State = State { + op: SearchFilter::And, + bm: None, + }; + let mut stack = Vec::new(); + let mut filters = query.filters.into_iter().peekable(); + let mut bitmaps = TreemapCache::default(); + + while let Some(filter) = filters.next() { + let result = match filter { + SearchFilter::Operator { field, op, value } => { + if field.is_text() { + let value = match value { + SearchValue::Text { value, .. } => value, + _ => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Expected text value for text field")); + } + }; + + bitmaps + .merge_treemaps( + self, + query.index, + SpaceTokenizer::new(value.as_str(), MAX_TOKEN_LENGTH) + .map(|word| CheekyHash::new(word.as_bytes())), + field.u8_id(), + false, + ) + .await? + } else if field.is_indexed() || matches!(field, SearchField::Id) { + let value = match value { + SearchValue::Text { value, .. } => value.into_bytes(), + SearchValue::Int(v) => (v as u64).to_be_bytes().to_vec(), + SearchValue::Uint(v) => v.to_be_bytes().to_vec(), + SearchValue::Boolean(v) => vec![v as u8], + SearchValue::KeyValues(_) => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Expected non key-value for non-text field")); + } + }; + + range_to_treemap(self, query.index, field.u8_id(), &value, op).await? + } else { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details(format!("Field {field:?} is not indexed"))); + } + } + SearchFilter::DocumentSet(_) | SearchFilter::Not => { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Unsupported filter in global search")); + } + op @ (SearchFilter::And | SearchFilter::Or) => { + stack.push(state); + state = State { op, bm: None }; + continue; + } + SearchFilter::End => { + if let Some(prev_state) = stack.pop() { + let bm = state.bm; + state = prev_state; + bm + } else { + break; + } + } + }; + + // Apply logical operation + if let Some(dest) = &mut state.bm { + match state.op { + SearchFilter::And => { + if let Some(result) = result { + dest.bitand_assign(result); + } else { + dest.clear(); + } + } + SearchFilter::Or => { + if let Some(result) = result { + dest.bitor_assign(result); + } + } + _ => unreachable!(), + } + } else if result.is_some() { + state.bm = result; + } else { + state.bm = Some(RoaringTreemap::new()); + } + + // And short circuit + if matches!(state.op, SearchFilter::And) && state.bm.as_ref().unwrap().is_empty() { + while let Some(filter) = filters.peek() { + if matches!(filter, SearchFilter::End) { + break; + } else { + filters.next(); + } + } + } + } + + if query.comparators.iter().all(|c| { + matches!( + c, + SearchComparator::Field { + field: SearchField::Id, + ascending: false + } + ) + }) { + Ok(state + .bm + .unwrap_or_default() + .into_iter() + .rev() + .collect::>()) + } else { + Ok(state.bm.unwrap_or_default().into_iter().collect::>()) + } + } +} diff --git a/crates/store/src/search/split.rs b/crates/store/src/search/split.rs new file mode 100644 index 0000000..4a5a767 --- /dev/null +++ b/crates/store/src/search/split.rs @@ -0,0 +1,708 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::search::*; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SplitFilter { + Internal(SearchFilter), + External(Vec), +} + +pub(crate) fn split_filters(filters_in: Vec) -> Option> { + let mut account_id = u64::MAX; + let mut filters: Vec = Vec::with_capacity(filters_in.len()); + let mut op_stack = Vec::new(); + let mut document_sets: AHashMap = AHashMap::new(); + let mut operators: AHashMap> = AHashMap::new(); + + for filter in filters_in { + match filter { + op @ (SearchFilter::And | SearchFilter::Or | SearchFilter::Not) => { + op_stack.push(op.clone()); + filters.push(op); + } + SearchFilter::End => { + if let Some(ops) = operators.remove(&op_stack.len()) { + filters.extend(ops); + } + if let Some(docs) = document_sets.remove(&op_stack.len()) { + filters.push(SearchFilter::DocumentSet(docs)); + } + filters.push(SearchFilter::End); + op_stack.pop()?; + } + SearchFilter::Operator { + field: SearchField::AccountId, + value: SearchValue::Uint(id), + .. + } => { + account_id = id; + } + SearchFilter::Operator { .. } => { + operators.entry(op_stack.len()).or_default().push(filter); + } + SearchFilter::DocumentSet(docs) => match document_sets.entry(op_stack.len()) { + Entry::Occupied(mut entry) => { + if matches!(op_stack.last(), Some(SearchFilter::Or)) { + entry.get_mut().bitor_assign(&docs); + } else { + entry.get_mut().bitand_assign(&docs); + } + } + Entry::Vacant(entry) => { + entry.insert(docs); + } + }, + } + } + + if let Some(ops) = operators.remove(&0) { + filters.extend(ops); + } + if let Some(docs) = document_sets.remove(&0) { + filters.push(SearchFilter::DocumentSet(docs)); + } + + if account_id == u64::MAX { + return None; + } + + let mut split: Vec = Vec::new(); + let mut i = 0; + + 'outer: while i < filters.len() { + let mut j = i; + let mut depth = 0; + + while j < filters.len() { + match &filters[j] { + SearchFilter::And | SearchFilter::Or | SearchFilter::Not => { + depth += 1; + } + SearchFilter::End => { + depth -= 1; + if depth < 0 { + if j > i { + break; + } else { + split.push(SplitFilter::Internal(SearchFilter::End)); + i += 1; + continue 'outer; + } + } + } + SearchFilter::Operator { .. } => {} + SearchFilter::DocumentSet(_) => { + if depth == 0 && j > i { + break; + } else { + split.push(SplitFilter::Internal(std::mem::take(&mut filters[i]))); + i += 1; + continue 'outer; + } + } + } + j += 1; + } + + let mut external_filters = vec![SearchFilter::Operator { + field: SearchField::AccountId, + op: SearchOperator::Equal, + value: SearchValue::Uint(account_id), + }]; + let add_or = + matches!(split.last(), Some(SplitFilter::Internal(SearchFilter::Or))) && j > i + 1; + if add_or { + external_filters.push(SearchFilter::Or); + } + external_filters.extend(&mut filters[i..j].iter_mut().map(std::mem::take)); + if add_or { + external_filters.push(SearchFilter::End); + } + split.push(SplitFilter::External(external_filters)); + + i = j; + } + + Some(split) +} + +// Test cases +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_filters_exhaustive() { + let test_cases: Vec<(&str, Vec, Vec)> = vec![ + // Test 1: Operator followed by document set at depth 0 + ( + "Operator then document set at depth 0", + vec![account_id(42), other_op("test"), doc_set(&[1, 2, 3])], + vec![ + SplitFilter::External(vec![account_id(42), other_op("test")]), + SplitFilter::Internal(doc_set(&[1, 2, 3])), + ], + ), + // Test 2: Document set followed by operator at depth 0 + ( + "Document set then operator at depth 0", + vec![account_id(42), doc_set(&[1, 2, 3]), other_op("test")], + vec![ + SplitFilter::External(vec![account_id(42), other_op("test")]), + SplitFilter::Internal(doc_set(&[1, 2, 3])), + ], + ), + // Test 3: Multiple document sets with operator in between + ( + "Multiple document sets at depth 0 with operator", + vec![ + account_id(42), + doc_set(&[1, 2]), + other_op("middle"), + doc_set(&[2, 4]), + ], + vec![ + SplitFilter::External(vec![account_id(42), other_op("middle")]), + SplitFilter::Internal(doc_set(&[2])), + ], + ), + // Test 4: Document set at depth 0, then AND group + ( + "Document set then AND group", + vec![ + account_id(42), + doc_set(&[1, 2]), + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 5: AND group followed by document set at depth 0 + ( + "AND group then document set", + vec![ + account_id(42), + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + doc_set(&[1, 2]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 6: Operator at depth 0, then OR group, then document set + ( + "Operator, OR group, then document set", + vec![ + account_id(42), + other_op("pre"), + SearchFilter::Or, + other_op("a"), + other_op("b"), + SearchFilter::End, + doc_set(&[1, 2, 3]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + other_op("a"), + other_op("b"), + SearchFilter::End, + other_op("pre"), + ]), + SplitFilter::Internal(doc_set(&[1, 2, 3])), + ], + ), + // Test 7: Document set, OR group, operator + ( + "Document set, OR group, operator", + vec![ + account_id(42), + doc_set(&[1, 2]), + SearchFilter::Or, + other_op("a"), + other_op("b"), + SearchFilter::End, + other_op("post"), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + other_op("a"), + other_op("b"), + SearchFilter::End, + other_op("post"), + ]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 8: Multiple OR branches with document sets between + ( + "Multiple OR branches with document sets between", + vec![ + account_id(42), + SearchFilter::Or, + other_op("a"), + SearchFilter::End, + doc_set(&[1, 2]), + SearchFilter::Or, + other_op("b"), + SearchFilter::End, + doc_set(&[1, 2, 5, 6]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + other_op("a"), + SearchFilter::End, + SearchFilter::Or, + other_op("b"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 9: Document sets at different depths - depth 0 and inside AND + ( + "Document sets at different depths in AND", + vec![ + account_id(42), + doc_set(&[1, 2]), + SearchFilter::And, + other_op("a"), + doc_set(&[2, 3]), + SearchFilter::End, + ], + vec![ + SplitFilter::Internal(SearchFilter::And), + SplitFilter::External(vec![account_id(42), other_op("a")]), + SplitFilter::Internal(doc_set(&[2, 3])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 10: Operator, AND group with doc set inside, operator + ( + "Operator, AND(operator, doc_set), operator", + vec![ + account_id(42), + other_op("pre"), + SearchFilter::And, + other_op("a"), + doc_set(&[1, 2, 3]), + SearchFilter::End, + other_op("post"), + ], + vec![ + SplitFilter::Internal(SearchFilter::And), + SplitFilter::External(vec![account_id(42), other_op("a")]), + SplitFilter::Internal(doc_set(&[1, 2, 3])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("pre"), other_op("post")]), + ], + ), + // Test 11: Document set, nested groups, document set + ( + "Doc set, AND(OR(a,b)), doc set", + vec![ + account_id(42), + SearchFilter::Or, + doc_set(&[1, 2]), + other_op("c"), + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + doc_set(&[3, 4]), + SearchFilter::End, + ], + vec![ + SplitFilter::Internal(SearchFilter::Or), + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + SearchFilter::And, + other_op("a"), + other_op("b"), + SearchFilter::End, + other_op("c"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[1, 2, 3, 4])), + SplitFilter::Internal(SearchFilter::End), + ], + ), + // Test 12: OR with nested AND containing document sets, followed by operator + ( + "OR(AND(doc_set, doc_set), operator) followed by operator", + vec![ + account_id(42), + SearchFilter::Or, + SearchFilter::And, + doc_set(&[1, 2]), + doc_set(&[2, 3]), + SearchFilter::End, + other_op("b"), + SearchFilter::End, + other_op("post"), + ], + vec![ + SplitFilter::Internal(SearchFilter::Or), + SplitFilter::Internal(SearchFilter::And), + SplitFilter::Internal(doc_set(&[2])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("b")]), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("post")]), + ], + ), + // Test 13: Complex: doc set, AND group, doc set, OR group, doc set + ( + "Complex: doc, AND, doc, OR, doc", + vec![ + account_id(42), + doc_set(&[1, 2, 3]), + SearchFilter::And, + other_op("a"), + SearchFilter::End, + doc_set(&[1, 2, 3, 5]), + SearchFilter::Or, + other_op("b"), + SearchFilter::End, + doc_set(&[1, 2, 3, 6]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::And, + other_op("a"), + SearchFilter::End, + SearchFilter::Or, + other_op("b"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[1, 2, 3])), + ], + ), + // Test 14: Operator, NOT group, document set + ( + "Operator, NOT(operator), document set", + vec![ + account_id(42), + other_op("pre"), + SearchFilter::Not, + other_op("a"), + SearchFilter::End, + doc_set(&[1, 2]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Not, + other_op("a"), + SearchFilter::End, + other_op("pre"), + ]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 15: Document set, NOT group, operator + ( + "Document set, NOT(operator), operator", + vec![ + account_id(42), + doc_set(&[1, 2]), + SearchFilter::Not, + other_op("a"), + doc_set(&[3, 4]), + SearchFilter::End, + other_op("post"), + ], + vec![ + SplitFilter::Internal(SearchFilter::Not), + SplitFilter::External(vec![account_id(42), other_op("a")]), + SplitFilter::Internal(doc_set(&[3, 4])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("post")]), + SplitFilter::Internal(doc_set(&[1, 2])), + ], + ), + // Test 16: Alternating doc sets and operators + ( + "Alternating: doc, op, doc, op, doc", + vec![ + account_id(42), + doc_set(&[1]), + other_op("a"), + doc_set(&[1, 2]), + other_op("b"), + doc_set(&[1, 3]), + ], + vec![ + SplitFilter::External(vec![account_id(42), other_op("a"), other_op("b")]), + SplitFilter::Internal(doc_set(&[1])), + ], + ), + // Test 17: Multiple operators, then OR group with doc set inside, then doc set + ( + "Multiple ops, OR(op, doc_set), doc", + vec![ + account_id(42), + other_op("a"), + SearchFilter::Or, + other_op("c"), + doc_set(&[1, 2]), + SearchFilter::End, + other_op("b"), + doc_set(&[3, 4]), + ], + vec![ + SplitFilter::Internal(SearchFilter::Or), + SplitFilter::External(vec![account_id(42), other_op("c")]), + SplitFilter::Internal(doc_set(&[1, 2])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("a"), other_op("b")]), + SplitFilter::Internal(doc_set(&[3, 4])), + ], + ), + // Test 18: Doc set before and after nested OR(AND(op)) + ( + "Doc, OR(AND(op)), doc", + vec![ + account_id(42), + doc_set(&[1]), + SearchFilter::Or, + SearchFilter::And, + other_op("a"), + other_op("c"), + SearchFilter::End, + other_op("b"), + SearchFilter::End, + doc_set(&[2]), + ], + vec![ + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + SearchFilter::And, + other_op("a"), + other_op("c"), + SearchFilter::End, + other_op("b"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[])), + ], + ), + // Test 19: AND group with doc set, operator between, OR group with doc set + ( + "AND(op, doc), op, OR(op, doc)", + vec![ + account_id(42), + SearchFilter::And, + other_op("a"), + doc_set(&[1, 2]), + SearchFilter::End, + other_op("middle"), + SearchFilter::Or, + other_op("b"), + other_op("c"), + doc_set(&[3, 4]), + SearchFilter::End, + ], + vec![ + SplitFilter::Internal(SearchFilter::And), + SplitFilter::External(vec![account_id(42), other_op("a")]), + SplitFilter::Internal(doc_set(&[1, 2])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::Internal(SearchFilter::Or), + SplitFilter::External(vec![ + account_id(42), + SearchFilter::Or, + other_op("b"), + other_op("c"), + SearchFilter::End, + ]), + SplitFilter::Internal(doc_set(&[3, 4])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::External(vec![account_id(42), other_op("middle")]), + ], + ), + // Test 20: Deep nesting with document sets at multiple levels + ( + "Deep nesting: doc, AND(doc, OR(doc, AND(op, doc)))", + vec![ + account_id(42), + doc_set(&[1]), + SearchFilter::And, + doc_set(&[2]), + SearchFilter::Or, + doc_set(&[3]), + SearchFilter::And, + other_op("a"), + doc_set(&[4]), + SearchFilter::End, + SearchFilter::End, + SearchFilter::End, + ], + vec![ + SplitFilter::Internal(SearchFilter::And), + SplitFilter::Internal(SearchFilter::Or), + SplitFilter::Internal(SearchFilter::And), + SplitFilter::External(vec![account_id(42), other_op("a")]), + SplitFilter::Internal(doc_set(&[4])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::Internal(doc_set(&[3])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::Internal(doc_set(&[2])), + SplitFilter::Internal(SearchFilter::End), + SplitFilter::Internal(doc_set(&[1])), + ], + ), + ]; + + for (description, input, expected) in test_cases { + println!("------ Running test: {} ------", description); + let result = split_filters(input.clone()); + assert!(result.is_some(), "Test '{}' returned None", description); + + let result = result.unwrap(); + if result != expected { + print_split_filter_code(&result); + } + assert_eq!(result, expected, "Test '{description}' failed",); + } + } + + fn account_id(id: u64) -> SearchFilter { + SearchFilter::Operator { + field: SearchField::AccountId, + op: SearchOperator::Equal, + value: SearchValue::Uint(id), + } + } + + fn other_op(value: &str) -> SearchFilter { + SearchFilter::Operator { + field: SearchField::DocumentId, + op: SearchOperator::Equal, + value: SearchValue::Text { + value: value.to_string(), + language: Language::None, + }, + } + } + + fn doc_set(ids: &[u32]) -> SearchFilter { + let mut bitmap = RoaringBitmap::new(); + for id in ids { + bitmap.insert(*id); + } + SearchFilter::DocumentSet(bitmap) + } + + fn print_split_filter_code(splits: &[SplitFilter]) { + println!("vec!["); + for split in splits { + match split { + SplitFilter::Internal(filter) => { + print!(" SplitFilter::Internal("); + print_search_filter_code(filter, 0); + println!("),"); + } + SplitFilter::External(filters) => { + println!(" SplitFilter::External(vec!["); + for filter in filters { + print!(" "); + print_search_filter_code(filter, 2); + println!(","); + } + println!(" ]),"); + } + } + } + println!("]"); + } + + fn print_search_filter_code(filter: &SearchFilter, indent_level: usize) { + let indent = " ".repeat(indent_level); + match filter { + SearchFilter::Operator { field, op, value } => match (field, op, value) { + (SearchField::AccountId, SearchOperator::Equal, SearchValue::Uint(id)) => { + print!("account_id({})", id); + } + ( + SearchField::DocumentId, + SearchOperator::Equal, + SearchValue::Text { value, .. }, + ) => { + print!("other_op(\"{}\")", value); + } + _ => { + println!("SearchFilter::Operator {{"); + println!("{} field: {:?},", indent, field); + println!("{} op: {:?},", indent, op); + println!("{} value: {:?},", indent, value); + print!("{}}}", indent); + } + }, + SearchFilter::DocumentSet(bitmap) => { + let ids: Vec = bitmap.iter().collect(); + if ids.is_empty() { + print!("doc_set(&[])"); + } else if ids.len() <= 5 { + print!("doc_set(&["); + for (i, id) in ids.iter().enumerate() { + if i > 0 { + print!(", "); + } + print!("{}", id); + } + print!("])"); + } else { + // For large bitmaps, create inline + println!("{{"); + println!("{} let mut bitmap = RoaringBitmap::new();", indent); + for id in ids { + println!("{} bitmap.insert({});", indent, id); + } + print!("{} doc_set_bitmap(bitmap)", indent); + println!(); + print!("{}}}", indent); + } + } + SearchFilter::And => print!("SearchFilter::And"), + SearchFilter::Or => print!("SearchFilter::Or"), + SearchFilter::Not => print!("SearchFilter::Not"), + SearchFilter::End => print!("SearchFilter::End"), + } + } +} diff --git a/crates/store/src/search/term.rs b/crates/store/src/search/term.rs new file mode 100644 index 0000000..f56a800 --- /dev/null +++ b/crates/store/src/search/term.rs @@ -0,0 +1,451 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Serialize, + backend::MAX_TOKEN_LENGTH, + search::*, + write::{ + Archiver, BatchBuilder, SEARCH_INDEX_MAX_FIELD_LEN, SearchIndexClass, SearchIndexField, + SearchIndexId, SearchIndexType, ValueClass, + }, +}; +use ahash::AHashSet; +use nlp::{ + language::stemmer::Stemmer, + tokenizers::{space::SpaceTokenizer, word::WordTokenizer}, +}; +use utils::{ + cheeky_hash::{CheekyBTreeMap, CheekyHash}, + map::bitmap::BitPop, +}; + +#[derive(Debug, PartialEq, Eq, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +pub(crate) struct TermIndex { + terms: Vec, + fields: Vec, +} + +#[derive(Debug, PartialEq, Eq, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +pub(crate) struct Term { + hash: CheekyHash, + fields: u32, +} + +pub(crate) struct TermIndexBuilder { + pub(crate) index: TermIndex, + pub(crate) id: SearchIndexId, +} + +impl TermIndexBuilder { + pub fn build(document: IndexDocument, truncate_at: usize) -> Self { + let mut terms: CheekyBTreeMap = CheekyBTreeMap::new(); + let mut fields: Vec = Vec::new(); + let mut account_id = None; + let mut document_id = None; + let mut id = None; + + for (field, value) in document.fields { + match field { + SearchField::Id => { + if let SearchValue::Uint(v) = value { + fields.push(SearchIndexField { + field_id: field.u8_id(), + data: v.to_be_bytes().to_vec(), + }); + id = Some(v); + } + + continue; + } + SearchField::AccountId => { + if let SearchValue::Uint(v) = value { + account_id = Some(v); + } + continue; + } + SearchField::DocumentId => { + if let SearchValue::Uint(v) = value { + document_id = Some(v); + } + continue; + } + _ => {} + } + + let field = match value { + SearchValue::Text { value, language } => { + if field.is_text() { + let value = if truncate_at > 0 && value.len() > truncate_at { + let pos = value.floor_char_boundary(truncate_at); + &value[..pos] + } else { + &value + }; + + match language { + Language::Unknown => { + for token in WordTokenizer::new(value, MAX_TOKEN_LENGTH) { + terms + .entry(CheekyHash::new(token.word.as_bytes())) + .or_default() + .bit_push(field.u8_id()); + } + } + Language::None => { + for token in SpaceTokenizer::new(value, MAX_TOKEN_LENGTH) { + terms + .entry(CheekyHash::new(token.as_bytes())) + .or_default() + .bit_push(field.u8_id()); + } + } + _ => { + for token in Stemmer::new(value, language, MAX_TOKEN_LENGTH) { + terms + .entry(CheekyHash::new(token.word.as_bytes())) + .or_default() + .bit_push(field.u8_id()); + + if let Some(stemmed_word) = token.stemmed_word { + terms + .entry(CheekyHash::new( + format!("{}*", stemmed_word).as_bytes(), + )) + .or_default() + .bit_push(field.u8_id()); + } + } + } + } + } + + if field.is_indexed() { + let mut data = value.into_bytes(); + data.truncate(SEARCH_INDEX_MAX_FIELD_LEN); + + SearchIndexField { + field_id: field.u8_id(), + data, + } + } else { + continue; + } + } + SearchValue::KeyValues(map) => { + for (key, value) in map { + terms + .entry(CheekyHash::new(key.as_bytes())) + .or_default() + .bit_push(field.u8_id()); + for token in SpaceTokenizer::new(value.as_str(), MAX_TOKEN_LENGTH) { + terms + .entry(CheekyHash::new(format!("{key} {token}").as_bytes())) + .or_default() + .bit_push(field.u8_id()); + } + } + + continue; + } + SearchValue::Int(v) => SearchIndexField { + field_id: field.u8_id(), + data: (v as u64).to_be_bytes().to_vec(), + }, + SearchValue::Uint(v) => SearchIndexField { + field_id: field.u8_id(), + data: v.to_be_bytes().to_vec(), + }, + SearchValue::Boolean(v) => SearchIndexField { + field_id: field.u8_id(), + data: vec![v as u8], + }, + }; + + fields.push(field); + } + + TermIndexBuilder { + index: TermIndex { + terms: terms + .into_iter() + .map(|(k, v)| Term { hash: k, fields: v }) + .collect(), + fields, + }, + id: match (account_id, document_id, id) { + (Some(account_id), Some(document_id), None) => SearchIndexId::Account { + account_id: account_id as u32, + document_id: document_id as u32, + }, + (None, None, Some(id)) => SearchIndexId::Global { id }, + _ => { + debug_assert!( + false, + "Invalid combination of AccountId {account_id:?}, DocumentId {document_id:?} and Id {id:?} fields" + ); + SearchIndexId::Global { id: 0 } + } + }, + } + } +} + +impl TermIndex { + pub fn write_index( + self, + batch: &mut BatchBuilder, + index: SearchIndex, + id: SearchIndexId, + ) -> trc::Result<()> { + let archive = Archiver::new(self); + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Document, + }), + archive.serialize()?, + ) + .commit_point(); + + for term in archive.inner.terms { + let mut fields = term.fields; + while let Some(field) = fields.bit_pop() { + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Term { + hash: term.hash, + field, + }, + }), + vec![], + ) + .commit_point(); + } + } + + for field in archive.inner.fields { + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Index { field }, + }), + vec![], + ) + .commit_point(); + } + + Ok(()) + } + + pub fn merge_index( + self, + batch: &mut BatchBuilder, + index: SearchIndex, + id: SearchIndexId, + old_term: &ArchivedTermIndex, + ) -> trc::Result<()> { + let archive = Archiver::new(self); + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Document, + }), + archive.serialize()?, + ) + .commit_point(); + + let mut old_terms = AHashSet::with_capacity(old_term.terms.len()); + let mut old_fields = AHashSet::with_capacity(old_term.fields.len()); + for term in old_term.terms.iter() { + let mut fields = term.fields.to_native(); + while let Some(field) = fields.bit_pop() { + old_terms.insert(SearchIndexType::Term { + hash: term.hash.to_native(), + field, + }); + } + } + for field in old_term.fields.iter() { + old_fields.insert(SearchIndexField { + field_id: field.field_id, + data: field.data.to_vec(), + }); + } + + for term in archive.inner.terms { + let mut fields = term.fields; + while let Some(field) = fields.bit_pop() { + let typ = SearchIndexType::Term { + hash: term.hash, + field, + }; + + if !old_terms.remove(&typ) { + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { index, id, typ }), + vec![], + ) + .commit_point(); + } + } + } + + for field in archive.inner.fields { + if !old_fields.remove(&field) { + batch + .set( + ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Index { field }, + }), + vec![], + ) + .commit_point(); + } + } + + for typ in old_terms { + batch + .clear(ValueClass::SearchIndex(SearchIndexClass { index, id, typ })) + .commit_point(); + } + + for field in old_fields { + batch + .clear(ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Index { field }, + })) + .commit_point(); + } + + Ok(()) + } +} + +impl ArchivedTermIndex { + pub fn delete_index(&self, batch: &mut BatchBuilder, index: SearchIndex, id: SearchIndexId) { + batch + .clear(ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Document, + })) + .commit_point(); + + for term in self.terms.iter() { + let mut fields = term.fields.to_native(); + while let Some(field) = fields.bit_pop() { + batch + .clear(ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Term { + hash: term.hash.to_native(), + field, + }, + })) + .commit_point(); + } + } + + for field in self.fields.iter() { + batch + .clear(ValueClass::SearchIndex(SearchIndexClass { + index, + id, + typ: SearchIndexType::Index { + field: SearchIndexField { + field_id: field.field_id, + data: field.data.to_vec(), + }, + }, + })) + .commit_point(); + } + } +} + +impl SearchIndex { + pub(crate) fn as_u8(&self) -> u8 { + match self { + SearchIndex::Email => 0, + SearchIndex::Calendar => 1, + SearchIndex::Contacts => 2, + SearchIndex::File => 3, + SearchIndex::Tracing => 4, + SearchIndex::InMemory => unreachable!(), + } + } +} + +impl SearchField { + pub(crate) fn u8_id(&self) -> u8 { + match self { + SearchField::AccountId => 0, + SearchField::DocumentId => 1, + SearchField::Id => 2, + SearchField::Email(field) => match field { + EmailSearchField::From => 3, + EmailSearchField::To => 4, + EmailSearchField::Cc => 5, + EmailSearchField::Bcc => 6, + EmailSearchField::Subject => 7, + EmailSearchField::Body => 8, + EmailSearchField::Attachment => 9, + EmailSearchField::ReceivedAt => 10, + EmailSearchField::SentAt => 11, + EmailSearchField::Size => 12, + EmailSearchField::HasAttachment => 13, + EmailSearchField::Headers => 14, + }, + SearchField::Calendar(field) => match field { + CalendarSearchField::Title => 3, + CalendarSearchField::Description => 4, + CalendarSearchField::Location => 5, + CalendarSearchField::Owner => 6, + CalendarSearchField::Attendee => 7, + CalendarSearchField::Start => 8, + CalendarSearchField::Uid => 9, + }, + SearchField::Contact(field) => match field { + ContactSearchField::Member => 3, + ContactSearchField::Kind => 4, + ContactSearchField::Name => 5, + ContactSearchField::Nickname => 6, + ContactSearchField::Organization => 7, + ContactSearchField::Email => 8, + ContactSearchField::Phone => 9, + ContactSearchField::OnlineService => 10, + ContactSearchField::Address => 11, + ContactSearchField::Note => 12, + ContactSearchField::Uid => 13, + }, + SearchField::File(field) => match field { + FileSearchField::Name => 3, + FileSearchField::Content => 4, + }, + SearchField::Tracing(field) => match field { + TracingSearchField::EventType => 3, + TracingSearchField::QueueId => 4, + TracingSearchField::Keywords => 5, + }, + } + } +} diff --git a/crates/store/src/write/assert.rs b/crates/store/src/write/assert.rs new file mode 100644 index 0000000..f16f6f3 --- /dev/null +++ b/crates/store/src/write/assert.rs @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Archive, ArchiveVersion}; +use crate::{U32_LEN, U64_LEN}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AssertValue { + U32(u32), + U64(u64), + Hash(u64), + Archive(ArchiveVersion), + Some, + None, +} + +pub trait ToAssertValue { + fn to_assert_value(&self) -> AssertValue; +} + +impl ToAssertValue for AssertValue { + fn to_assert_value(&self) -> AssertValue { + *self + } +} + +impl ToAssertValue for () { + fn to_assert_value(&self) -> AssertValue { + AssertValue::None + } +} + +impl ToAssertValue for u64 { + fn to_assert_value(&self) -> AssertValue { + AssertValue::U64(*self) + } +} + +impl ToAssertValue for u32 { + fn to_assert_value(&self) -> AssertValue { + AssertValue::U32(*self) + } +} + +impl ToAssertValue for Archive { + fn to_assert_value(&self) -> AssertValue { + AssertValue::Archive(self.version) + } +} + +impl ToAssertValue for &Archive { + fn to_assert_value(&self) -> AssertValue { + AssertValue::Archive(self.version) + } +} + +impl AssertValue { + pub fn matches(&self, bytes: &[u8]) -> bool { + match self { + AssertValue::U32(v) => bytes + .get(bytes.len() - U32_LEN..) + .is_some_and(|b| b == v.to_be_bytes()), + + AssertValue::U64(v) => bytes + .get(bytes.len() - U64_LEN..) + .is_some_and(|b| b == v.to_be_bytes()), + AssertValue::Hash(v) => xxhash_rust::xxh3::xxh3_64(bytes) == *v, + AssertValue::Archive(v) => match v { + ArchiveVersion::Versioned { hash, .. } => bytes + .get(bytes.len() - U32_LEN - U64_LEN - 1..bytes.len() - U64_LEN - 1) + .is_some_and(|b| b == hash.to_be_bytes()), + ArchiveVersion::Hashed { hash } => bytes + .get(bytes.len() - U32_LEN - 1..bytes.len() - 1) + .is_some_and(|b| b == hash.to_be_bytes()), + ArchiveVersion::Unversioned => false, + }, + AssertValue::None => false, + AssertValue::Some => true, + } + } + + pub fn is_none(&self) -> bool { + matches!(self, AssertValue::None) + } +} diff --git a/crates/store/src/write/batch.rs b/crates/store/src/write/batch.rs new file mode 100644 index 0000000..74fa986 --- /dev/null +++ b/crates/store/src/write/batch.rs @@ -0,0 +1,556 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + Batch, BatchBuilder, ChangedCollection, IntoOperations, Operation, ValueClass, ValueOp, + assert::ToAssertValue, log::VanishedItem, +}; +use crate::{ + SerializeInfallible, U32_LEN, + write::{ + LogCollection, MergeFnc, MergeOperation, Params, SetFnc, SetOperation, TaskQueueClass, + }, +}; +use registry::{ + schema::structs::Task, + types::{EnumImpl, ObjectImpl}, +}; +use types::{ + collection::{Collection, SyncCollection, VanishedCollection}, + field::FieldType, +}; +use utils::{map::vec_map::VecMap, snowflake::SnowflakeIdGenerator}; + +impl BatchBuilder { + pub fn new() -> Self { + Self { + ops: Vec::with_capacity(32), + current_account_id: None, + current_collection: None, + current_document_id: None, + changes: Default::default(), + changed_collections: Default::default(), + batch_size: 0, + batch_ops: 0, + has_assertions: false, + commit_points: Vec::new(), + } + } + + pub fn with_account_id(&mut self, account_id: u32) -> &mut Self { + if self + .current_account_id + .is_none_or(|current_account_id| current_account_id != account_id) + { + self.current_account_id = account_id.into(); + self.ops.push(Operation::AccountId { account_id }); + } + self + } + + pub fn with_collection(&mut self, collection: Collection) -> &mut Self { + let collection_ = Some(collection); + if collection_ != self.current_collection { + self.current_collection = collection_; + self.ops.push(Operation::Collection { collection }); + } + self + } + + pub fn with_document(&mut self, document_id: u32) -> &mut Self { + self.ops.push(Operation::DocumentId { document_id }); + self.current_document_id = Some(document_id); + self.has_assertions = false; + self + } + + pub fn assert_value( + &mut self, + class: impl Into, + value: impl ToAssertValue, + ) -> &mut Self { + self.ops.push(Operation::AssertValue { + class: class.into(), + assert_value: value.to_assert_value(), + }); + self.batch_ops += 1; + self.has_assertions = true; + self + } + + pub fn index(&mut self, field: impl FieldType, value: impl Into>) -> &mut Self { + let field = field.into(); + let value = value.into(); + let value_len = value.len(); + + self.ops.push(Operation::Index { + field, + key: value, + set: true, + }); + self.batch_size += (U32_LEN * 3) + value_len; + self.batch_ops += 1; + self + } + + pub fn unindex(&mut self, field: impl FieldType, value: impl Into>) -> &mut Self { + let field = field.into(); + let value = value.into(); + let value_len = value.len(); + + self.ops.push(Operation::Index { + field, + key: value, + set: false, + }); + self.batch_size += (U32_LEN * 3) + value_len; + self.batch_ops += 1; + self + } + + #[inline(always)] + pub fn tag(&mut self, field: impl FieldType) -> &mut Self { + self.index(field, vec![]) + } + + #[inline(always)] + pub fn untag(&mut self, field: impl FieldType) -> &mut Self { + self.unindex(field, vec![]) + } + + pub fn add(&mut self, class: impl Into, value: i64) -> &mut Self { + let class = class.into(); + self.batch_size += class.serialized_size() + std::mem::size_of::(); + self.ops.push(Operation::Value { + class, + op: ValueOp::AtomicAdd(value), + }); + self.batch_ops += 1; + self + } + + pub fn add_and_get(&mut self, class: impl Into, value: i64) -> &mut Self { + let class = class.into(); + self.batch_size += class.serialized_size() + (std::mem::size_of::() * 2); + self.ops.push(Operation::Value { + class, + op: ValueOp::AddAndGet(value), + }); + self.batch_ops += 1; + self + } + + pub fn set(&mut self, class: impl Into, value: impl Into>) -> &mut Self { + let class = class.into(); + let value = value.into(); + self.batch_size += class.serialized_size() + value.len(); + self.ops.push(Operation::Value { + class, + op: ValueOp::Set(value), + }); + self.batch_ops += 1; + self + } + + pub fn set_fnc( + &mut self, + class: impl Into, + params: Params, + fnc: SetFnc, + ) -> &mut Self { + self.ops.push(Operation::Value { + class: class.into(), + op: ValueOp::SetFnc(SetOperation { fnc, params }), + }); + self + } + + pub fn merge_fnc( + &mut self, + class: impl Into, + params: Params, + fnc: MergeFnc, + ) -> &mut Self { + self.ops.push(Operation::Value { + class: class.into(), + op: ValueOp::MergeFnc(MergeOperation { fnc, params }), + }); + self + } + + pub fn clear(&mut self, class: impl Into) -> &mut Self { + let class = class.into(); + self.batch_size += class.serialized_size(); + self.ops.push(Operation::Value { + class, + op: ValueOp::Clear, + }); + self.batch_ops += 1; + self + } + + pub fn acl_grant(&mut self, grant_account_id: u32, op: Vec) -> &mut Self { + self.batch_size += (U32_LEN * 3) + op.len(); + self.ops.push(Operation::Value { + class: ValueClass::Acl(grant_account_id), + op: ValueOp::Set(op), + }); + self.batch_ops += 1; + self + } + + pub fn acl_revoke(&mut self, grant_account_id: u32) -> &mut Self { + self.batch_size += U32_LEN * 3; + self.ops.push(Operation::Value { + class: ValueClass::Acl(grant_account_id), + op: ValueOp::Clear, + }); + self.batch_ops += 1; + self + } + + pub fn log_item_insert( + &mut self, + collection: SyncCollection, + prefix: Option, + ) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes.get_mut_or_insert(account_id).log_item_insert( + collection, + prefix, + document_id, + ); + } + self + } + + pub fn log_item_update( + &mut self, + collection: SyncCollection, + prefix: Option, + ) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes.get_mut_or_insert(account_id).log_item_update( + collection, + prefix, + document_id, + ); + } + self + } + + pub fn log_item_delete( + &mut self, + collection: SyncCollection, + prefix: Option, + ) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes.get_mut_or_insert(account_id).log_item_delete( + collection, + prefix, + document_id, + ); + } + self + } + + pub fn log_container_insert(&mut self, collection: SyncCollection) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes + .get_mut_or_insert(account_id) + .log_container_insert(collection, document_id); + } + self + } + + pub fn log_container_update(&mut self, collection: SyncCollection) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes + .get_mut_or_insert(account_id) + .log_container_update(collection, document_id); + } + self + } + + pub fn log_container_delete(&mut self, collection: SyncCollection) -> &mut Self { + if let (Some(account_id), Some(document_id)) = + (self.current_account_id, self.current_document_id) + { + self.changes + .get_mut_or_insert(account_id) + .log_container_delete(collection, document_id); + } + self + } + + pub fn log_container_property_change( + &mut self, + collection: SyncCollection, + document_id: u32, + ) -> &mut Self { + if let Some(account_id) = self.current_account_id { + self.changes + .get_mut_or_insert(account_id) + .log_container_property_update(collection, document_id); + } + self + } + + pub fn log_vanished_item( + &mut self, + collection: VanishedCollection, + item: impl Into, + ) -> &mut Self { + if let Some(account_id) = self.current_account_id { + let item = item.into(); + self.batch_size += item.serialized_size(); + self.changes + .get_mut_or_insert(account_id) + .log_vanished_item(collection, item); + } + self + } + + pub fn log_share_notification( + &mut self, + notification_id: u64, + notify_account_id: u32, + value: impl SerializeInfallible, + ) -> &mut Self { + self.changed_collections + .get_mut_or_insert(notify_account_id) + .share_notification_id = Some(notification_id); + self.set( + ValueClass::ShareNotification { + notification_id, + notify_account_id, + }, + value.serialize(), + ) + } + + fn serialize_changes(&mut self) { + if !self.changes.is_empty() { + for (account_id, changelog) in std::mem::take(&mut self.changes) { + self.with_account_id(account_id); + + // Serialize changes + for (collection, changes) in changelog.changes.into_iter() { + let cc = self.changed_collections.get_mut_or_insert(account_id); + if changes.has_container_changes() { + cc.changed_containers.insert(collection); + } + if changes.has_item_changes() { + cc.changed_items.insert(collection); + } + + self.ops.push(Operation::Log { + collection: LogCollection::Sync(collection), + set: changes.serialize(), + }); + } + + // Serialize vanished items + for (collection, vanished) in changelog.vanished.into_iter() { + self.ops.push(Operation::Log { + collection: LogCollection::Vanished(collection), + set: vanished.serialize(), + }); + } + } + } + } + + pub fn commit_point(&mut self) -> &mut Self { + if self.is_large_batch() { + self.serialize_changes(); + self.commit_points.push(self.ops.len()); + self.batch_ops = 0; + self.batch_size = 0; + if let Some(account_id) = self.current_account_id { + self.ops.push(Operation::AccountId { account_id }); + } + if let Some(collection) = self.current_collection { + self.ops.push(Operation::Collection { collection }); + } + } + self + } + + #[inline] + pub fn is_large_batch(&self) -> bool { + self.batch_size > 5_000_000 || self.batch_ops > 1000 + } + + pub fn any_op(&mut self, op: Operation) -> &mut Self { + if let Operation::Value { class, op } = &op { + self.batch_size += class.serialized_size(); + if let ValueOp::Set(value) = op { + self.batch_size += value.len(); + } + } + + self.ops.push(op); + self.batch_ops += 1; + self + } + + pub fn custom(&mut self, value: impl IntoOperations) -> trc::Result<&mut Self> { + value.build(self)?; + Ok(self) + } + + pub fn last_account_id(&self) -> Option { + self.current_account_id + } + + pub fn last_collection(&self) -> Option { + self.current_collection + } + + pub fn last_document_id(&self) -> Option { + self.current_document_id + } + + pub fn commit_points(&mut self) -> CommitPointIterator { + self.serialize_changes(); + CommitPointIterator { + commit_points: std::mem::take(&mut self.commit_points), + commit_point_last: self.ops.len(), + offset_start: 0, + } + } + + pub fn build_one(&mut self, commit_point: CommitPoint) -> Batch<'_> { + Batch { + changes: &self.changed_collections, + ops: &mut self.ops[commit_point.offset_start..commit_point.offset_end], + } + } + + pub fn build_all(&mut self) -> Batch<'_> { + self.serialize_changes(); + Batch { + changes: &self.changed_collections, + ops: self.ops.as_mut_slice(), + } + } + + pub fn changes(self) -> Option> { + if self.has_changes() { + Some(self.changed_collections) + } else { + None + } + } + + pub fn has_changes(&self) -> bool { + !self.changed_collections.is_empty() + } + + pub fn ops(&self) -> &[Operation] { + self.ops.as_slice() + } + + pub fn len(&self) -> usize { + self.batch_size + } + + pub fn is_empty(&self) -> bool { + self.batch_ops == 0 + } + + pub fn schedule_task(&mut self, task: Task) -> &mut Self { + let due = task.due_timestamp(); + let class = task.object_type().to_id(); + let task = task.to_pickled_vec(); + let id = SnowflakeIdGenerator::global_id().unwrap_or_default(); + + self.set(ValueClass::TaskQueue(TaskQueueClass::Task { id }), task) + .set( + ValueClass::TaskQueue(TaskQueueClass::Due { id, due }), + class.serialize(), + ) + } + + pub fn schedule_task_with_id(&mut self, id: u64, task: Task) -> &mut Self { + let due = task.due_timestamp(); + let class = task.object_type().to_id(); + let task = task.to_pickled_vec(); + + self.set(ValueClass::TaskQueue(TaskQueueClass::Task { id }), task) + .set( + ValueClass::TaskQueue(TaskQueueClass::Due { id, due }), + class.serialize(), + ) + } +} + +pub struct CommitPointIterator { + commit_points: Vec, + commit_point_last: usize, + offset_start: usize, +} + +pub struct CommitPoint { + pub offset_start: usize, + pub offset_end: usize, +} + +impl CommitPointIterator { + pub fn iter(&mut self) -> impl Iterator { + self.commit_points + .iter() + .copied() + .chain([self.commit_point_last]) + .map(|offset_end| { + let point = CommitPoint { + offset_start: self.offset_start, + offset_end, + }; + self.offset_start = offset_end; + point + }) + } +} + +impl Batch<'_> { + pub fn is_atomic(&self) -> bool { + !self.ops.iter().any(|op| { + matches!( + op, + Operation::AssertValue { .. } + | Operation::Value { + op: ValueOp::AddAndGet(_), + .. + } + ) + }) + } + + pub fn first_account_id(&self) -> Option { + self.ops.iter().find_map(|op| match op { + Operation::AccountId { account_id } => Some(*account_id), + _ => None, + }) + } +} + +impl Default for BatchBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/store/src/write/bitpack.rs b/crates/store/src/write/bitpack.rs new file mode 100644 index 0000000..92695ed --- /dev/null +++ b/crates/store/src/write/bitpack.rs @@ -0,0 +1,382 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use bitpacking::{BitPacker, BitPacker1x, BitPacker4x, BitPacker8x}; +use utils::codec::leb128::Leb128Reader; + +use super::key::KeySerializer; + +#[derive(Default)] +pub struct BitpackIterator<'x> { + pub(crate) bytes: &'x [u8], + pub(crate) bytes_offset: usize, + pub(crate) chunk: Vec, + pub(crate) chunk_offset: usize, + pub items_left: u32, +} + +#[derive(Clone, Copy)] +pub(crate) struct BitBlockPacker { + bitpacker_1: BitPacker1x, + bitpacker_4: BitPacker4x, + bitpacker_8: BitPacker8x, + block_len: usize, +} + +impl KeySerializer { + pub fn bitpack_sorted(self, items: &[u32]) -> Self { + let mut serializer = self; + let mut bitpacker = BitBlockPacker::new(); + let mut compressed = vec![0u8; 4 * BitPacker8x::BLOCK_LEN]; + + let mut pos = 0; + let len = items.len(); + let mut initial_value = None; + + serializer = serializer.write_leb128(len as u32); + + while pos < len { + let block_len = match len - pos { + 0..=31 => { + for val in &items[pos..] { + serializer = serializer.write_leb128(*val); + } + break; + } + 32..=127 => BitPacker1x::BLOCK_LEN, + 128..=255 => BitPacker4x::BLOCK_LEN, + _ => BitPacker8x::BLOCK_LEN, + }; + + let chunk = &items[pos..pos + block_len]; + bitpacker.block_len(block_len); + let num_bits: u8 = bitpacker.num_bits_strictly_sorted(initial_value, chunk); + let compressed_len = bitpacker.compress_strictly_sorted( + initial_value, + chunk, + &mut compressed[..], + num_bits, + ); + serializer = serializer + .write(num_bits) + .write(&compressed[..compressed_len]); + initial_value = chunk[chunk.len() - 1].into(); + + pos += block_len; + } + serializer + } +} + +impl<'x> BitpackIterator<'x> { + pub fn from_bytes_and_offset(bytes: &'x [u8], bytes_offset: usize, items_left: u32) -> Self { + BitpackIterator { + bytes, + bytes_offset, + items_left, + ..Default::default() + } + } + + pub fn new(bytes: &'x [u8]) -> Option { + bytes + .read_leb128::() + .map(|(items_left, bytes_offset)| BitpackIterator { + bytes, + bytes_offset, + items_left, + ..Default::default() + }) + } +} + +impl Iterator for BitpackIterator<'_> { + type Item = u32; + + fn next(&mut self) -> Option { + if let Some(item) = self.chunk.get(self.chunk_offset) { + self.chunk_offset += 1; + return Some(*item); + } + let block_len = match self.items_left { + 0 => return None, + 1..=31 => { + self.items_left -= 1; + let (item, bytes_read) = self.bytes.get(self.bytes_offset..)?.read_leb128()?; + self.bytes_offset += bytes_read; + return Some(item); + } + 32..=127 => BitPacker1x::BLOCK_LEN, + 128..=255 => BitPacker4x::BLOCK_LEN, + _ => BitPacker8x::BLOCK_LEN, + }; + + let bitpacker = BitBlockPacker::with_block_len(block_len); + let num_bits = *self.bytes.get(self.bytes_offset)?; + let bytes_read = ((num_bits as usize) * block_len / 8) + 1; + let initial_value = self.chunk.last().copied(); + + self.chunk = vec![0u32; block_len]; + self.chunk_offset = 1; + + bitpacker.decompress_strictly_sorted( + initial_value, + self.bytes + .get(self.bytes_offset + 1..self.bytes_offset + bytes_read)?, + &mut self.chunk[..], + num_bits, + ); + + self.bytes_offset += bytes_read; + self.items_left -= block_len as u32; + self.chunk.first().copied() + } +} + +impl BitBlockPacker { + pub fn with_block_len(block_len: usize) -> Self { + BitBlockPacker { + bitpacker_1: BitPacker1x::new(), + bitpacker_4: BitPacker4x::new(), + bitpacker_8: BitPacker8x::new(), + block_len, + } + } + + pub fn block_len(&mut self, num: usize) { + self.block_len = num; + } +} + +impl BitPacker for BitBlockPacker { + const BLOCK_LEN: usize = 0; + + fn new() -> Self { + BitBlockPacker { + bitpacker_1: BitPacker1x::new(), + bitpacker_4: BitPacker4x::new(), + bitpacker_8: BitPacker8x::new(), + block_len: 1, + } + } + + fn compress(&self, decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => self + .bitpacker_8 + .compress(decompressed, compressed, num_bits), + BitPacker4x::BLOCK_LEN => self + .bitpacker_4 + .compress(decompressed, compressed, num_bits), + _ => self + .bitpacker_1 + .compress(decompressed, compressed, num_bits), + } + } + + fn compress_sorted( + &self, + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => { + self.bitpacker_8 + .compress_sorted(initial, decompressed, compressed, num_bits) + } + BitPacker4x::BLOCK_LEN => { + self.bitpacker_4 + .compress_sorted(initial, decompressed, compressed, num_bits) + } + _ => self + .bitpacker_1 + .compress_sorted(initial, decompressed, compressed, num_bits), + } + } + + fn decompress(&self, compressed: &[u8], decompressed: &mut [u32], num_bits: u8) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => { + self.bitpacker_8 + .decompress(compressed, decompressed, num_bits) + } + BitPacker4x::BLOCK_LEN => { + self.bitpacker_4 + .decompress(compressed, decompressed, num_bits) + } + _ => self + .bitpacker_1 + .decompress(compressed, decompressed, num_bits), + } + } + + fn decompress_sorted( + &self, + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => { + self.bitpacker_8 + .decompress_sorted(initial, compressed, decompressed, num_bits) + } + BitPacker4x::BLOCK_LEN => { + self.bitpacker_4 + .decompress_sorted(initial, compressed, decompressed, num_bits) + } + _ => self + .bitpacker_1 + .decompress_sorted(initial, compressed, decompressed, num_bits), + } + } + + fn num_bits(&self, decompressed: &[u32]) -> u8 { + match self.block_len { + BitPacker8x::BLOCK_LEN => self.bitpacker_8.num_bits(decompressed), + BitPacker4x::BLOCK_LEN => self.bitpacker_4.num_bits(decompressed), + _ => self.bitpacker_1.num_bits(decompressed), + } + } + + fn num_bits_sorted(&self, initial: u32, decompressed: &[u32]) -> u8 { + match self.block_len { + BitPacker8x::BLOCK_LEN => self.bitpacker_8.num_bits_sorted(initial, decompressed), + BitPacker4x::BLOCK_LEN => self.bitpacker_4.num_bits_sorted(initial, decompressed), + _ => self.bitpacker_1.num_bits_sorted(initial, decompressed), + } + } + + fn compress_strictly_sorted( + &self, + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => self.bitpacker_8.compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + BitPacker4x::BLOCK_LEN => self.bitpacker_4.compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + _ => self.bitpacker_1.compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + } + } + + fn decompress_strictly_sorted( + &self, + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + match self.block_len { + BitPacker8x::BLOCK_LEN => self.bitpacker_8.decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + BitPacker4x::BLOCK_LEN => self.bitpacker_4.decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + _ => self.bitpacker_1.decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + } + } + + fn num_bits_strictly_sorted(&self, initial: Option, decompressed: &[u32]) -> u8 { + match self.block_len { + BitPacker8x::BLOCK_LEN => self + .bitpacker_8 + .num_bits_strictly_sorted(initial, decompressed), + BitPacker4x::BLOCK_LEN => self + .bitpacker_4 + .num_bits_strictly_sorted(initial, decompressed), + _ => self + .bitpacker_1 + .num_bits_strictly_sorted(initial, decompressed), + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn bitpack_roundtrip() { + for num_positions in [ + 1, + 10, + BitPacker1x::BLOCK_LEN, + BitPacker4x::BLOCK_LEN, + BitPacker8x::BLOCK_LEN, + BitPacker8x::BLOCK_LEN + BitPacker4x::BLOCK_LEN + BitPacker1x::BLOCK_LEN, + BitPacker8x::BLOCK_LEN + BitPacker4x::BLOCK_LEN + BitPacker1x::BLOCK_LEN + 1, + (BitPacker8x::BLOCK_LEN * 3) + + (BitPacker4x::BLOCK_LEN * 3) + + (BitPacker1x::BLOCK_LEN * 3) + + 1, + (BitPacker8x::BLOCK_LEN * 32) + 1, + ] { + let serialized = KeySerializer::new(num_positions * std::mem::size_of::()) + .bitpack_sorted( + &(0..num_positions) + .map(|i| (i * i) as u32) + .collect::>(), + ) + .finalize(); + + println!( + "Testing block {num_positions} with {} size...", + serialized.len() + ); + + let mut iter = BitpackIterator::new(&serialized).unwrap(); + + assert_eq!( + iter.items_left, num_positions as u32, + "failed for num_positions: {}", + num_positions + ); + + for i in 0..num_positions { + assert_eq!( + iter.next(), + Some((i * i) as u32), + "failed for position: {}", + i + ); + } + assert_eq!(iter.next(), None, "expected end of iterator"); + } + } +} diff --git a/crates/store/src/write/blob.rs b/crates/store/src/write/blob.rs new file mode 100644 index 0000000..fd64d19 --- /dev/null +++ b/crates/store/src/write/blob.rs @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{BlobOp, Operation, ValueClass, ValueOp, key::DeserializeBigEndian, now}; +use crate::{ + BlobStore, Deserialize, IterateParams, SerializeInfallible, Store, U16_LEN, U32_LEN, U64_LEN, + ValueKey, + write::{BatchBuilder, BlobLink, RegistryClass}, +}; +use registry::{ + schema::prelude::Property, + types::{EnumImpl, id::ObjectId}, +}; +use std::time::Instant; +use trc::{AddContext, StoreEvent}; +use types::{ + blob::BlobClass, + blob_hash::{BLOB_HASH_LEN, BlobHash}, +}; + +#[derive(Debug, PartialEq, Eq)] +pub struct BlobQuota { + pub bytes: usize, + pub count: usize, +} + +impl Store { + pub async fn blob_exists(&self, hash: impl AsRef + Sync + Send) -> trc::Result { + self.key_exists(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Commit { + hash: hash.as_ref().clone(), + }), + }) + .await + .caused_by(trc::location!()) + } + + pub async fn blob_has_access( + &self, + hash: impl AsRef + Sync + Send, + class: impl AsRef + Sync + Send, + ) -> trc::Result { + let key = match class.as_ref() { + BlobClass::Reserved { + account_id, + expires, + } if *expires > now() => ValueKey { + account_id: *account_id, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Link { + hash: hash.as_ref().clone(), + to: BlobLink::Temporary { until: *expires }, + }), + }, + BlobClass::Linked { + account_id, + collection, + document_id, + } => ValueKey { + account_id: *account_id, + collection: *collection, + document_id: *document_id, + class: ValueClass::Blob(BlobOp::Link { + hash: hash.as_ref().clone(), + to: BlobLink::Document, + }), + }, + _ => return Ok(false), + }; + + self.key_exists(key).await + } + + pub async fn purge_blobs_all_shards(&self, blob_store: BlobStore) -> trc::Result<()> { + for shard_index in 0u8..=255 { + self.purge_blobs(blob_store.clone(), shard_index).await?; + } + Ok(()) + } + + pub async fn purge_blobs(&self, blob_store: BlobStore, shard_index: u8) -> trc::Result<()> { + let mut total_active = 0; + let mut total_deleted = 0; + let started = Instant::now(); + + // Validate linked blobs + let mut from_hash = BlobHash::default(); + let mut to_hash = BlobHash::new_max(); + from_hash.0[0] = shard_index; + to_hash.0[0] = shard_index; + let from_key = ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Commit { hash: from_hash }), + }; + let to_key = ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Blob(BlobOp::Link { + hash: to_hash, + to: BlobLink::Document, + }), + }; + + let mut state = BlobPurgeState::new(); + self.iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let hash = + BlobHash::try_from_hash_slice(key.get(0..BLOB_HASH_LEN).ok_or_else(|| { + trc::Error::corrupted_key(key, value.into(), trc::location!()) + })?) + .unwrap(); + + state.update_hash(hash); + state.process_key(key, value)?; + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + state.finalize(BlobHash::default()); + + // Delete expired or unlinked blobs + for (_, op) in &state.delete_keys { + if let BlobOp::Commit { hash } = op { + blob_store + .delete_blob(hash.as_ref()) + .await + .caused_by(trc::location!())?; + } + } + + // Delete hashes + let mut batch = BatchBuilder::new(); + for (account_id, op) in state.delete_keys { + if batch.is_large_batch() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + + if let Some(account_id) = account_id { + batch.with_account_id(account_id); + } + + batch.any_op(Operation::Value { + class: ValueClass::Blob(op), + op: ValueOp::Clear, + }); + } + for (account_id, object_id) in state.delete_registry { + if batch.is_large_batch() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + batch = BatchBuilder::new(); + } + + let item_id = object_id.id().id(); + let object_id = object_id.object().to_id(); + + batch + .clear(ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id, + item_id, + key: (account_id as u64).serialize(), + })) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id, + })); + } + if !batch.is_empty() { + self.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + total_active += state.total_active - 1; // Exclude default hash + total_deleted += state.total_deleted; + + trc::event!( + Store(StoreEvent::BlobStorePurged), + Id = shard_index as u16, + Expires = total_deleted, + Total = total_active, + Elapsed = started.elapsed() + ); + + Ok(()) + } +} + +struct BlobPurgeState { + last_hash: BlobHash, + last_hash_is_linked: bool, + delete_keys: Vec<(Option, BlobOp)>, + delete_registry: Vec<(u32, ObjectId)>, + now: u64, + total_deleted: u64, + total_active: u64, +} + +impl BlobPurgeState { + fn new() -> Self { + Self { + last_hash: BlobHash::default(), + last_hash_is_linked: true, // Avoid deleting non-existing last_hash on first iteration + delete_keys: Vec::new(), + delete_registry: Vec::new(), + now: now(), + total_deleted: 0, + total_active: 0, + } + } + + pub fn update_hash(&mut self, hash: BlobHash) { + if self.last_hash != hash { + self.finalize(hash); + self.last_hash_is_linked = false; + } + } + + pub fn finalize(&mut self, new_hash: BlobHash) { + if !self.last_hash_is_linked { + self.total_deleted += 1; + self.delete_keys.push(( + None, + BlobOp::Commit { + hash: std::mem::replace(&mut self.last_hash, new_hash), + }, + )); + } else { + self.total_active += 1; + self.last_hash = new_hash; + } + } + + pub fn process_key(&mut self, key: &[u8], value: &[u8]) -> trc::Result<()> { + const TEMP_LINK: usize = BLOB_HASH_LEN + U32_LEN + U64_LEN; + const DOC_LINK: usize = BLOB_HASH_LEN + U64_LEN + 1; + const ID_LINK: usize = BLOB_HASH_LEN + U64_LEN; + + match key.len() { + BLOB_HASH_LEN => { + // Main blob entry + Ok(()) + } + TEMP_LINK => { + // Temporary link + let until = key.deserialize_be_u64(BLOB_HASH_LEN + U32_LEN)?; + if until <= self.now { + let account_id = key.deserialize_be_u32(BLOB_HASH_LEN)?; + self.delete_keys.push(( + Some(account_id), + BlobOp::Link { + hash: self.last_hash.clone(), + to: BlobLink::Temporary { until }, + }, + )); + if value.len() == U16_LEN + U64_LEN { + self.delete_registry + .push((account_id, ObjectId::deserialize(value)?)); + } + } else { + self.last_hash_is_linked = true; + } + Ok(()) + } + DOC_LINK | ID_LINK => { + // Document/Id link + self.last_hash_is_linked = true; + Ok(()) + } + _ => Err(trc::Error::corrupted_key( + key, + value.into(), + trc::location!(), + )), + } + } +} diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs new file mode 100644 index 0000000..e6f6ddd --- /dev/null +++ b/crates/store/src/write/key.rs @@ -0,0 +1,685 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + AnyKey, BlobOp, InMemoryClass, QueueClass, TaskQueueClass, TelemetryClass, ValueClass, +}; +use crate::{ + IndexKey, IndexKeyPrefix, Key, LogKey, SUBSPACE_ACL, SUBSPACE_BLOB_LINK, SUBSPACE_COUNTER, + SUBSPACE_DELETED_ITEMS, SUBSPACE_DIRECTORY, SUBSPACE_IN_MEMORY_COUNTER, + SUBSPACE_IN_MEMORY_VALUE, SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_PROPERTY, + SUBSPACE_QUEUE_EVENT, SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUOTA, SUBSPACE_REGISTRY, + SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_PK, SUBSPACE_REPORT_IN, SUBSPACE_REPORT_OUT, + SUBSPACE_SEARCH_INDEX, SUBSPACE_SPAM_SAMPLES, SUBSPACE_TASK_QUEUE, SUBSPACE_TELEMETRY_METRIC, + SUBSPACE_TELEMETRY_SPAN, U16_LEN, U32_LEN, U64_LEN, ValueKey, WITH_SUBSPACE, + write::{ + BlobLink, IndexPropertyClass, RegistryClass, SearchIndex, SearchIndexId, SearchIndexType, + }, +}; +use registry::schema::prelude::ObjectType; +use std::convert::TryInto; +use types::{ + blob_hash::BLOB_HASH_LEN, + collection::{Collection, SyncCollection}, + field::{Field, MailboxField}, +}; +use utils::codec::leb128::Leb128_; + +pub struct KeySerializer { + pub buf: Vec, +} + +pub trait KeySerialize { + fn serialize(&self, buf: &mut Vec); +} + +pub trait DeserializeBigEndian { + fn deserialize_be_u16(&self, index: usize) -> trc::Result; + fn deserialize_be_u32(&self, index: usize) -> trc::Result; + fn deserialize_be_u64(&self, index: usize) -> trc::Result; +} + +impl KeySerializer { + pub fn new(capacity: usize) -> Self { + Self { + buf: Vec::with_capacity(capacity), + } + } + + pub fn write(mut self, value: T) -> Self { + value.serialize(&mut self.buf); + self + } + + pub fn write_leb128(mut self, value: T) -> Self { + T::to_leb128_bytes(value, &mut self.buf); + self + } + + pub fn finalize(self) -> Vec { + self.buf + } +} + +impl KeySerialize for u8 { + fn serialize(&self, buf: &mut Vec) { + buf.push(*self); + } +} + +impl KeySerialize for &str { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(self.as_bytes()); + } +} + +impl KeySerialize for &String { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(self.as_bytes()); + } +} + +impl KeySerialize for &[u8] { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(self); + } +} + +impl KeySerialize for u32 { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.to_be_bytes()); + } +} + +impl KeySerialize for u16 { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.to_be_bytes()); + } +} + +impl KeySerialize for u64 { + fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.to_be_bytes()); + } +} + +impl DeserializeBigEndian for &[u8] { + fn deserialize_be_u16(&self, index: usize) -> trc::Result { + self.get(index..index + U16_LEN) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Value, *self) + }) + .map(u16::from_be_bytes) + } + + fn deserialize_be_u32(&self, index: usize) -> trc::Result { + self.get(index..index + U32_LEN) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Value, *self) + }) + .map(u32::from_be_bytes) + } + + fn deserialize_be_u64(&self, index: usize) -> trc::Result { + self.get(index..index + U64_LEN) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Value, *self) + }) + .map(u64::from_be_bytes) + } +} + +impl> ValueKey { + pub fn with_document_id(self, document_id: u32) -> Self { + Self { + document_id, + ..self + } + } +} + +impl ValueKey { + pub fn property( + account_id: u32, + collection: impl Into, + document_id: u32, + field: impl Into, + ) -> ValueKey { + ValueKey { + account_id, + collection: collection.into(), + document_id, + class: ValueClass::Property(field.into()), + } + } + + pub fn archive( + account_id: u32, + collection: impl Into, + document_id: u32, + ) -> ValueKey { + ValueKey { + account_id, + collection: collection.into(), + document_id, + class: ValueClass::Property(Field::ARCHIVE.into()), + } + } +} + +impl Key for IndexKeyPrefix { + fn serialize(&self, flags: u32) -> Vec { + { + if (flags & WITH_SUBSPACE) != 0 { + KeySerializer::new(std::mem::size_of::() + 1) + .write(crate::SUBSPACE_INDEXES) + } else { + KeySerializer::new(std::mem::size_of::()) + } + } + .write(self.account_id) + .write(self.collection) + .write(self.field) + .finalize() + } + + fn subspace(&self) -> u8 { + SUBSPACE_INDEXES + } +} + +impl IndexKeyPrefix { + pub fn len() -> usize { + U32_LEN + 2 + } +} + +impl Key for LogKey { + fn subspace(&self) -> u8 { + SUBSPACE_LOGS + } + + fn serialize(&self, flags: u32) -> Vec { + { + if (flags & WITH_SUBSPACE) != 0 { + KeySerializer::new(std::mem::size_of::() + 1).write(crate::SUBSPACE_LOGS) + } else { + KeySerializer::new(std::mem::size_of::()) + } + } + .write(self.account_id) + .write(self.collection) + .write(self.change_id) + .finalize() + } +} + +impl + Sync + Send + Clone> Key for ValueKey { + fn subspace(&self) -> u8 { + self.class.as_ref().subspace(self.collection) + } + + fn serialize(&self, flags: u32) -> Vec { + self.class + .as_ref() + .serialize(self.account_id, self.collection, self.document_id, flags) + } +} + +impl ValueClass { + pub fn serialize( + &self, + account_id: u32, + collection: u8, + document_id: u32, + flags: u32, + ) -> Vec { + let serializer = if (flags & WITH_SUBSPACE) != 0 { + KeySerializer::new(self.serialized_size() + 2).write(self.subspace(collection)) + } else { + KeySerializer::new(self.serialized_size() + 1) + }; + + match self { + ValueClass::Property(property) => serializer + .write(account_id) + .write(collection) + .write(*property) + .write(document_id), + ValueClass::IndexProperty(property) => match property { + IndexPropertyClass::Hash { property, hash } => serializer + .write(account_id) + .write(collection) + .write(*property) + .write(hash.as_bytes()) + .write(document_id), + IndexPropertyClass::Integer { property, value } => serializer + .write(account_id) + .write(collection) + .write(*property) + .write(*value) + .write(document_id), + }, + ValueClass::Acl(grant_account_id) => serializer + .write(*grant_account_id) + .write(account_id) + .write(collection) + .write(document_id), + ValueClass::TaskQueue(task) => match task { + TaskQueueClass::Task { id } => serializer.write(0u64).write(*id), + TaskQueueClass::Due { id, due } => serializer.write(*due).write(*id), + }, + ValueClass::Blob(op) => match op { + BlobOp::Commit { hash } => serializer.write::<&[u8]>(hash.as_ref()), + BlobOp::Link { hash, to } => match to { + BlobLink::Id { id } => serializer.write::<&[u8]>(hash.as_ref()).write(*id), + BlobLink::Document => serializer + .write::<&[u8]>(hash.as_ref()) + .write(account_id) + .write(collection) + .write(document_id), + BlobLink::Temporary { until } => serializer + .write::<&[u8]>(hash.as_ref()) + .write(account_id) + .write(*until), + }, + }, + ValueClass::InMemory(lookup) => match lookup { + InMemoryClass::Key(key) => serializer.write(key.as_slice()), + InMemoryClass::Counter(key) => serializer.write(key.as_slice()), + }, + ValueClass::Registry(registry) => match registry { + RegistryClass::Item { object_id, item_id } => { + serializer.write(*object_id).write(*item_id) + } + RegistryClass::IndexId { object_id, item_id } => { + serializer.write(u16::MAX).write(*object_id).write(*item_id) + } + RegistryClass::Index { + index_id, + object_id, + item_id, + key, + } => serializer + .write(*object_id) + .write(*index_id) + .write(key.as_slice()) + .write(*item_id), + RegistryClass::Reference { + to_object_id, + to_item_id, + from_object_id, + from_item_id, + } => serializer + .write(*to_object_id) + .write(*to_item_id) + .write(*from_object_id) + .write(*from_item_id), + RegistryClass::PrimaryKey { + object_id, + index_id, + key, + } => serializer + .write((*object_id).unwrap_or(u16::MAX)) + .write(*index_id) + .write(key.as_slice()), + RegistryClass::IdCounter { object_id } => serializer.write(*object_id), + }, + ValueClass::Queue(queue) => match queue { + QueueClass::Message(queue_id) => serializer.write(*queue_id), + QueueClass::MessageEvent(event) => serializer + .write(event.due) + .write(event.queue_id) + .write(event.queue_name.as_slice()), + QueueClass::QuotaCount(key) => serializer.write(0u8).write(key.as_slice()), + QueueClass::QuotaSize(key) => serializer.write(1u8).write(key.as_slice()), + }, + ValueClass::Telemetry(telemetry) => match telemetry { + TelemetryClass::Span(span_id) => serializer.write(*span_id), + TelemetryClass::Metric(metric_id) => serializer.write(*metric_id), + }, + ValueClass::DocumentId => serializer.write(account_id).write(collection), + ValueClass::ChangeId => serializer.write(account_id), + ValueClass::Quota => serializer.write(account_id).write(u8::MAX), + ValueClass::TenantQuota(tenant_id) => serializer.write(*tenant_id).write(u8::MAX - 1), + ValueClass::NodeId(node_id) => serializer.write(u32::MAX).write(*node_id), + ValueClass::ShareNotification { + notification_id, + notify_account_id, + } => serializer + .write(*notify_account_id) + .write(u8::from(SyncCollection::ShareNotification)) + .write(*notification_id), + ValueClass::SearchIndex(index) => match &index.typ { + SearchIndexType::Term { field, hash } => { + let class = index.index.as_u8(); + match &index.id { + SearchIndexId::Account { + account_id, + document_id, + } => serializer + .write(class) + .write(*account_id) + .write(hash.payload()) + .write(hash.payload_len()) + .write(*field) + .write(*document_id), + SearchIndexId::Global { id } => serializer + .write(class) + .write(hash.payload()) + .write(hash.payload_len()) + .write(*field) + .write(*id), + } + } + SearchIndexType::Index { field } => { + let class = index.index.as_u8() | 1 << 6; + match &index.id { + SearchIndexId::Account { + account_id, + document_id, + } => serializer + .write(class) + .write(*account_id) + .write(field.field_id) + .write(field.data.as_slice()) + .write(*document_id), + SearchIndexId::Global { id } => serializer + .write(class) + .write(field.field_id) + .write(field.data.as_slice()) + .write(*id), + } + } + SearchIndexType::Document => { + let class = index.index.as_u8() | 2 << 6; + match &index.id { + SearchIndexId::Account { + account_id, + document_id, + } => serializer + .write(class) + .write(*account_id) + .write(*document_id), + SearchIndexId::Global { id } => serializer.write(class).write(*id), + } + } + }, + ValueClass::Any(any) => serializer.write(any.key.as_slice()), + } + .finalize() + } +} + +impl + Sync + Send + Clone> Key for IndexKey { + fn subspace(&self) -> u8 { + SUBSPACE_INDEXES + } + + fn serialize(&self, flags: u32) -> Vec { + let key = self.key.as_ref(); + { + if (flags & WITH_SUBSPACE) != 0 { + KeySerializer::new(std::mem::size_of::>() + key.len() + 1) + .write(crate::SUBSPACE_INDEXES) + } else { + KeySerializer::new(std::mem::size_of::>() + key.len()) + } + } + .write(self.account_id) + .write(self.collection) + .write(self.field) + .write(key) + .write(self.document_id) + .finalize() + } +} + +impl + Sync + Send + Clone> Key for AnyKey { + fn serialize(&self, flags: u32) -> Vec { + let key = self.key.as_ref(); + if (flags & WITH_SUBSPACE) != 0 { + KeySerializer::new(key.len() + 1).write(self.subspace) + } else { + KeySerializer::new(key.len()) + } + .write(key) + .finalize() + } + + fn subspace(&self) -> u8 { + self.subspace + } +} + +const MAILBOX_COLLECTION: u8 = Collection::Mailbox as u8; +const MAILBOX_COUNTER_FIELD: u8 = MailboxField::UidCounter as u8; +const REG_ARCHIVED_ITEM: u16 = ObjectType::ArchivedItem as u16; +const REG_SPAM_SAMPLE: u16 = ObjectType::SpamTrainingSample as u16; +const REG_ACCOUNT: u16 = ObjectType::Account as u16; +const REG_DOMAIN: u16 = ObjectType::Domain as u16; +const REG_TENANT: u16 = ObjectType::Tenant as u16; +const REG_ROLE: u16 = ObjectType::Role as u16; +const REG_OAUTH_CLIENT: u16 = ObjectType::OAuthClient as u16; +const REG_MAILING_LIST: u16 = ObjectType::MailingList as u16; +const REG_MASKED_EMAIL: u16 = ObjectType::MaskedEmail as u16; +const REG_PUBLIC_KEY: u16 = ObjectType::PublicKey as u16; +const REG_TRACE: u16 = ObjectType::Trace as u16; +const REG_METRIC: u16 = ObjectType::Metric as u16; +const REPORT_EXTERNAL_ARF: u16 = ObjectType::ArfExternalReport as u16; +const REPORT_EXTERNAL_DMARC: u16 = ObjectType::DmarcExternalReport as u16; +const REPORT_EXTERNAL_TLS: u16 = ObjectType::TlsExternalReport as u16; +const REPORT_INTERNAL_DMARC: u16 = ObjectType::DmarcInternalReport as u16; +const REPORT_INTERNAL_TLS: u16 = ObjectType::TlsInternalReport as u16; + +impl ValueClass { + pub fn serialized_size(&self) -> usize { + match self { + ValueClass::Property(_) => U32_LEN * 2 + 3, + ValueClass::IndexProperty(p) => match p { + IndexPropertyClass::Hash { hash, .. } => U32_LEN * 2 + 3 + hash.len(), + IndexPropertyClass::Integer { .. } => U32_LEN * 2 + 3 + U64_LEN, + }, + ValueClass::Acl(_) => U32_LEN * 3 + 2, + ValueClass::InMemory(InMemoryClass::Counter(v) | InMemoryClass::Key(v)) => v.len(), + ValueClass::Registry(registry) => match registry { + RegistryClass::Item { .. } => U16_LEN + U64_LEN + 1, + RegistryClass::Reference { .. } => ((U16_LEN + U64_LEN) * 2) + 1, + RegistryClass::Index { key, .. } => (U16_LEN * 2) + U64_LEN + key.len() + 1, + RegistryClass::PrimaryKey { key, .. } => (U16_LEN * 2) + key.len() + 1, + RegistryClass::IndexId { .. } => U16_LEN + U64_LEN + 1, + RegistryClass::IdCounter { .. } => U16_LEN + 1, + }, + ValueClass::Blob(op) => match op { + BlobOp::Commit { .. } => BLOB_HASH_LEN, + BlobOp::Link { to, .. } => { + BLOB_HASH_LEN + + match to { + BlobLink::Id { .. } => U64_LEN, + BlobLink::Document => U32_LEN * 2 + 1, + BlobLink::Temporary { .. } => U32_LEN + U64_LEN, + } + } + }, + ValueClass::TaskQueue(_) => (U64_LEN * 2) + 1, + ValueClass::Queue(q) => match q { + QueueClass::Message(_) => U64_LEN, + QueueClass::MessageEvent(_) => U64_LEN * 3, + QueueClass::QuotaCount(v) | QueueClass::QuotaSize(v) => v.len(), + }, + ValueClass::Telemetry(telemetry) => match telemetry { + TelemetryClass::Span(_) | TelemetryClass::Metric(_) => U64_LEN + 1, + }, + ValueClass::DocumentId | ValueClass::Quota | ValueClass::TenantQuota(_) => U32_LEN + 1, + ValueClass::ChangeId => U32_LEN, + ValueClass::ShareNotification { .. } => U32_LEN + U64_LEN + 1, + ValueClass::NodeId(_) => (U16_LEN * 3) + 1, + ValueClass::SearchIndex(v) => match &v.typ { + SearchIndexType::Term { hash, .. } => U64_LEN + hash.len() + 2, + SearchIndexType::Index { field, .. } => 1 + field.data.len() + U64_LEN, + SearchIndexType::Document => match &v.id { + SearchIndexId::Account { .. } => 1 + U32_LEN * 2, + SearchIndexId::Global { .. } => 1 + U64_LEN, + }, + }, + ValueClass::Any(v) => v.key.len(), + } + } + + pub fn subspace(&self, collection: u8) -> u8 { + match self { + ValueClass::Property(field) => { + if collection == MAILBOX_COLLECTION && *field == MAILBOX_COUNTER_FIELD { + SUBSPACE_COUNTER + } else { + SUBSPACE_PROPERTY + } + } + ValueClass::IndexProperty { .. } => SUBSPACE_PROPERTY, + ValueClass::Acl(_) => SUBSPACE_ACL, + ValueClass::TaskQueue { .. } => SUBSPACE_TASK_QUEUE, + ValueClass::Blob(op) => match op { + BlobOp::Commit { .. } | BlobOp::Link { .. } => SUBSPACE_BLOB_LINK, + }, + ValueClass::Registry(registry) => match registry { + RegistryClass::Item { object_id, .. } => match *object_id { + REG_ACCOUNT | REG_DOMAIN | REG_TENANT | REG_ROLE | REG_OAUTH_CLIENT + | REG_MAILING_LIST | REG_MASKED_EMAIL | REG_PUBLIC_KEY => SUBSPACE_DIRECTORY, + REG_ARCHIVED_ITEM => SUBSPACE_DELETED_ITEMS, + REG_SPAM_SAMPLE => SUBSPACE_SPAM_SAMPLES, + REG_TRACE => SUBSPACE_TELEMETRY_SPAN, + REG_METRIC => SUBSPACE_TELEMETRY_METRIC, + REPORT_EXTERNAL_ARF | REPORT_EXTERNAL_DMARC | REPORT_EXTERNAL_TLS => { + SUBSPACE_REPORT_IN + } + REPORT_INTERNAL_DMARC | REPORT_INTERNAL_TLS => SUBSPACE_REPORT_OUT, + _ => SUBSPACE_REGISTRY, + }, + RegistryClass::IndexId { .. } | RegistryClass::Index { .. } => { + SUBSPACE_REGISTRY_IDX + } + RegistryClass::Reference { .. } | RegistryClass::PrimaryKey { .. } => { + SUBSPACE_REGISTRY_PK + } + RegistryClass::IdCounter { .. } => SUBSPACE_COUNTER, + }, + ValueClass::NodeId(_) => SUBSPACE_REGISTRY_PK, + ValueClass::InMemory(lookup) => match lookup { + InMemoryClass::Key(_) => SUBSPACE_IN_MEMORY_VALUE, + InMemoryClass::Counter(_) => SUBSPACE_IN_MEMORY_COUNTER, + }, + ValueClass::Queue(queue) => match queue { + QueueClass::Message(_) => SUBSPACE_QUEUE_MESSAGE, + QueueClass::MessageEvent(_) => SUBSPACE_QUEUE_EVENT, + QueueClass::QuotaCount(_) | QueueClass::QuotaSize(_) => SUBSPACE_QUOTA, + }, + ValueClass::Telemetry(telemetry) => match telemetry { + TelemetryClass::Span { .. } => SUBSPACE_TELEMETRY_SPAN, + TelemetryClass::Metric { .. } => SUBSPACE_TELEMETRY_METRIC, + }, + ValueClass::DocumentId + | ValueClass::ChangeId + | ValueClass::Quota + | ValueClass::TenantQuota(_) => SUBSPACE_COUNTER, + ValueClass::ShareNotification { .. } => SUBSPACE_LOGS, + ValueClass::SearchIndex(_) => SUBSPACE_SEARCH_INDEX, + ValueClass::Any(any) => any.subspace, + } + } +} + +pub fn is_node_id_key(key: &[u8]) -> bool { + key.len() == U32_LEN + U16_LEN && key.starts_with(&u32::MAX.to_be_bytes()) +} + +impl From for ValueKey { + fn from(class: ValueClass) -> Self { + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class, + } + } +} + +impl From for ValueKey { + fn from(value: RegistryClass) -> Self { + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(value), + } + } +} + +impl From for ValueClass { + fn from(value: RegistryClass) -> Self { + ValueClass::Registry(value) + } +} + +impl From for ValueClass { + fn from(value: BlobOp) -> Self { + ValueClass::Blob(value) + } +} + +impl SearchIndex { + pub fn to_u8(&self) -> u8 { + match self { + SearchIndex::Email => 0, + SearchIndex::Calendar => 1, + SearchIndex::Contacts => 2, + SearchIndex::File => 3, + SearchIndex::Tracing => 4, + SearchIndex::InMemory => unreachable!(), + } + } + + pub fn try_from_u8(value: u8) -> Option { + match value { + 0 => Some(SearchIndex::Email), + 1 => Some(SearchIndex::Calendar), + 2 => Some(SearchIndex::Contacts), + 3 => Some(SearchIndex::File), + 4 => Some(SearchIndex::Tracing), + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + SearchIndex::Email => "email", + SearchIndex::Calendar => "calendar", + SearchIndex::Contacts => "contacts", + SearchIndex::File => "file", + SearchIndex::Tracing => "tracing", + SearchIndex::InMemory => "in_memory", + } + } + + pub fn try_from_str(value: &str) -> Option { + match value { + "email" => Some(SearchIndex::Email), + "calendar" => Some(SearchIndex::Calendar), + "contacts" => Some(SearchIndex::Contacts), + "file" => Some(SearchIndex::File), + "tracing" => Some(SearchIndex::Tracing), + _ => None, + } + } +} diff --git a/crates/store/src/write/log.rs b/crates/store/src/write/log.rs new file mode 100644 index 0000000..d15e10a --- /dev/null +++ b/crates/store/src/write/log.rs @@ -0,0 +1,234 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{SerializeInfallible, U64_LEN}; +use ahash::AHashSet; +use types::collection::{SyncCollection, VanishedCollection}; +use utils::{codec::leb128::Leb128Vec, map::vec_map::VecMap}; + +use super::key::KeySerializer; + +#[derive(Default, Debug)] +pub(crate) struct ChangeLogBuilder { + pub changes: VecMap, + pub vanished: VecMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum VanishedItem { + Name(String), + Id(u64), + IdPair(u32, u32), +} + +#[derive(Default, Debug)] +pub(crate) struct VanishedItems(Vec); + +#[derive(Default, Debug)] +pub struct Changes { + pub item_inserts: AHashSet, + pub item_updates: AHashSet, + pub item_deletes: AHashSet, + + pub container_inserts: AHashSet, + pub container_updates: AHashSet, + pub container_deletes: AHashSet, + pub container_property_changes: AHashSet, +} + +impl ChangeLogBuilder { + pub fn log_container_insert(&mut self, collection: SyncCollection, document_id: u32) { + let changes = self.changes.get_mut_or_insert(collection); + if changes.container_deletes.remove(&document_id) { + changes.container_updates.insert(document_id); + } else { + changes.container_inserts.insert(document_id); + } + } + + pub fn log_item_insert( + &mut self, + collection: SyncCollection, + prefix: Option, + document_id: u32, + ) { + let id = build_id(prefix, document_id); + let changes = self.changes.get_mut_or_insert(collection); + if changes.item_deletes.remove(&id) { + changes.item_updates.insert(id); + } else { + changes.item_inserts.insert(id); + } + } + + pub fn log_container_update(&mut self, collection: SyncCollection, document_id: u32) { + self.changes + .get_mut_or_insert(collection) + .container_updates + .insert(document_id); + } + + pub fn log_container_property_update(&mut self, collection: SyncCollection, document_id: u32) { + self.changes + .get_mut_or_insert(collection) + .container_property_changes + .insert(document_id); + } + + pub fn log_item_update( + &mut self, + collection: SyncCollection, + prefix: Option, + document_id: u32, + ) { + self.changes + .get_mut_or_insert(collection) + .item_updates + .insert(build_id(prefix, document_id)); + } + + pub fn log_container_delete(&mut self, collection: SyncCollection, document_id: u32) { + let changes = self.changes.get_mut_or_insert(collection); + let id = document_id; + changes.container_updates.remove(&id); + changes.container_property_changes.remove(&id); + changes.container_deletes.insert(id); + } + + pub fn log_item_delete( + &mut self, + collection: SyncCollection, + prefix: Option, + document_id: u32, + ) { + let changes = self.changes.get_mut_or_insert(collection); + let id = build_id(prefix, document_id); + changes.item_updates.remove(&id); + changes.item_deletes.insert(id); + } + + pub fn log_vanished_item( + &mut self, + collection: VanishedCollection, + item: impl Into, + ) { + self.vanished + .get_mut_or_insert(collection) + .0 + .push(item.into()); + } +} + +#[inline(always)] +fn build_id(prefix: Option, document_id: u32) -> u64 { + if let Some(prefix) = prefix { + ((prefix as u64) << 32) | document_id as u64 + } else { + document_id as u64 + } +} + +impl Changes { + pub fn has_container_changes(&self) -> bool { + !self.container_inserts.is_empty() + || !self.container_updates.is_empty() + || !self.container_property_changes.is_empty() + || !self.container_deletes.is_empty() + } + + pub fn has_item_changes(&self) -> bool { + !self.item_inserts.is_empty() + || !self.item_updates.is_empty() + || !self.item_deletes.is_empty() + } +} + +impl SerializeInfallible for Changes { + fn serialize(&self) -> Vec { + let mut buf = Vec::with_capacity( + 1 + (self.item_inserts.len() + + self.item_updates.len() + + self.item_deletes.len() + + self.container_inserts.len() + + self.container_updates.len() + + self.container_property_changes.len() + + self.container_deletes.len() + + 4) + * std::mem::size_of::(), + ); + + buf.push_leb128(self.container_inserts.len()); + buf.push_leb128(self.container_updates.len()); + buf.push_leb128(self.container_property_changes.len()); + buf.push_leb128(self.container_deletes.len()); + buf.push_leb128(self.item_inserts.len()); + buf.push_leb128(self.item_updates.len()); + buf.push_leb128(self.item_deletes.len()); + + for list in [ + &self.container_inserts, + &self.container_updates, + &self.container_property_changes, + &self.container_deletes, + ] { + for id in list { + buf.push_leb128(*id); + } + } + for list in [&self.item_inserts, &self.item_updates, &self.item_deletes] { + for id in list { + buf.push_leb128(*id); + } + } + + buf + } +} + +impl From for VanishedItem { + fn from(value: String) -> Self { + VanishedItem::Name(value) + } +} + +impl From for VanishedItem { + fn from(value: u64) -> Self { + VanishedItem::Id(value) + } +} + +impl From<(u32, u32)> for VanishedItem { + fn from(value: (u32, u32)) -> Self { + VanishedItem::Id((value.0 as u64) << 32 | value.1 as u64) + } +} + +impl VanishedItem { + pub fn serialized_size(&self) -> usize { + match self { + VanishedItem::Name(name) => name.len() + 1, + VanishedItem::Id(_) | VanishedItem::IdPair(..) => U64_LEN, + } + } +} + +impl SerializeInfallible for VanishedItems { + fn serialize(&self) -> Vec { + let mut buf = KeySerializer::new(64); + + for item in &self.0 { + buf = match item { + VanishedItem::Name(name) => buf.write(name.as_bytes()).write(0u8), + VanishedItem::Id(id) => buf.write(id.to_be_bytes().as_slice()), + VanishedItem::IdPair(a, b) => buf + .write(a.to_be_bytes().as_slice()) + .write(b.to_be_bytes().as_slice()), + }; + } + + buf.finalize() + } +} diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs new file mode 100644 index 0000000..d52fda2 --- /dev/null +++ b/crates/store/src/write/mod.rs @@ -0,0 +1,700 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use self::assert::AssertValue; +use crate::backend::MAX_TOKEN_LENGTH; +use log::ChangeLogBuilder; +use nlp::tokenizers::word::WordTokenizer; +use rkyv::util::AlignedVec; +use std::{collections::HashSet, hash::Hash, time::SystemTime}; +use types::{ + blob_hash::BlobHash, + collection::{Collection, SyncCollection, VanishedCollection}, + field::{ + CalendarEventField, CalendarNotificationField, ContactField, EmailField, + EmailSubmissionField, Field, MailboxField, PrincipalField, SieveField, + }, +}; +use utils::{ + cheeky_hash::CheekyHash, + map::{bitmap::Bitmap, vec_map::VecMap}, +}; + +pub mod assert; +pub mod batch; +pub mod bitpack; +pub mod blob; +pub mod key; +pub mod log; +pub mod serialize; + +pub(crate) const ARCHIVE_ALIGNMENT: usize = 16; + +#[derive(Debug, Clone)] +pub struct Archive { + pub inner: T, + pub version: ArchiveVersion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArchiveVersion { + Versioned { change_id: u64, hash: u32 }, + Hashed { hash: u32 }, + Unversioned, +} + +#[derive(Debug, Clone)] +pub enum AlignedBytes { + Aligned(AlignedVec), + Vec(Vec), +} + +pub struct Archiver +where + T: rkyv::Archive + + for<'a> rkyv::Serialize< + rkyv::api::high::HighSerializer< + rkyv::util::AlignedVec, + rkyv::ser::allocator::ArenaHandle<'a>, + rkyv::rancor::Error, + >, + >, +{ + pub inner: T, + pub flags: u8, +} + +#[derive(Debug, Default)] +pub struct AssignedIds { + pub ids: Vec, + current_change_id: Option, +} + +#[derive(Debug)] +pub enum AssignedId { + Counter(i64), + ChangeId(ChangeId), +} + +#[derive(Debug, Clone, Copy)] +pub struct ChangeId { + pub account_id: u32, + pub change_id: u64, +} + +#[cfg(any( + feature = "rocks", + feature = "postgres", + feature = "mysql", + feature = "foundation" +))] +pub(crate) use commit_limits::{MAX_COMMIT_ATTEMPTS, MAX_COMMIT_TIME}; + +#[cfg(any( + feature = "rocks", + feature = "postgres", + feature = "mysql", + feature = "foundation" +))] +mod commit_limits { + use std::time::Duration; + + #[cfg(not(feature = "test_mode"))] + pub(crate) const MAX_COMMIT_ATTEMPTS: u32 = 10; + #[cfg(not(feature = "test_mode"))] + pub(crate) const MAX_COMMIT_TIME: Duration = Duration::from_secs(10); + + #[cfg(feature = "test_mode")] + pub(crate) const MAX_COMMIT_ATTEMPTS: u32 = 1000; + #[cfg(feature = "test_mode")] + pub(crate) const MAX_COMMIT_TIME: Duration = Duration::from_secs(3600); +} + +#[derive(Debug)] +pub struct Batch<'x> { + pub(crate) changes: &'x VecMap, + pub(crate) ops: &'x mut [Operation], +} + +#[derive(Debug)] +pub struct BatchBuilder { + current_account_id: Option, + current_collection: Option, + current_document_id: Option, + changes: VecMap, + changed_collections: VecMap, + has_assertions: bool, + batch_size: usize, + batch_ops: usize, + commit_points: Vec, + ops: Vec, +} + +#[derive(Debug, Default)] +pub struct ChangedCollection { + pub changed_containers: Bitmap, + pub changed_items: Bitmap, + pub share_notification_id: Option, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum Operation { + AccountId { + account_id: u32, + }, + Collection { + collection: Collection, + }, + DocumentId { + document_id: u32, + }, + AssertValue { + class: ValueClass, + assert_value: AssertValue, + }, + Value { + class: ValueClass, + op: ValueOp, + }, + Index { + field: u8, + key: Vec, + set: bool, + }, + Log { + collection: LogCollection, + set: Vec, + }, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum LogCollection { + Sync(SyncCollection), + Vanished(VanishedCollection), +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum ValueClass { + Property(u8), + IndexProperty(IndexPropertyClass), + Acl(u32), + InMemory(InMemoryClass), + TaskQueue(TaskQueueClass), + Blob(BlobOp), + Registry(RegistryClass), + Queue(QueueClass), + Telemetry(TelemetryClass), + SearchIndex(SearchIndexClass), + Any(AnyClass), + ShareNotification { + notification_id: u64, + notify_account_id: u32, + }, + DocumentId, + ChangeId, + Quota, + TenantQuota(u32), + NodeId(u16), +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum IndexPropertyClass { + Hash { property: u8, hash: CheekyHash }, + Integer { property: u8, value: u64 }, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub struct SearchIndexClass { + pub index: SearchIndex, + pub id: SearchIndexId, + pub typ: SearchIndexType, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum SearchIndexType { + Term { field: u8, hash: CheekyHash }, + Index { field: SearchIndexField }, + Document, +} + +pub(crate) const SEARCH_INDEX_MAX_FIELD_LEN: usize = 128; + +#[derive(Debug, PartialEq, Eq, Clone, Hash, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)] +pub struct SearchIndexField { + pub(crate) field_id: u8, + pub(crate) data: Vec, +} + +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)] +pub enum SearchIndexId { + Account { account_id: u32, document_id: u32 }, + Global { id: u64 }, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum TaskQueueClass { + Task { id: u64 }, + Due { id: u64, due: u64 }, +} + +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)] +pub enum SearchIndex { + Email, + Calendar, + Contacts, + File, + Tracing, + InMemory, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub struct AnyClass { + pub subspace: u8, + pub key: Vec, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum InMemoryClass { + Key(Vec), + Counter(Vec), +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum RegistryClass { + Item { + object_id: u16, + item_id: u64, + }, + Reference { + to_object_id: u16, + to_item_id: u64, + from_object_id: u16, + from_item_id: u64, + }, + Index { + index_id: u16, + object_id: u16, + item_id: u64, + key: Vec, + }, + IndexId { + object_id: u16, + item_id: u64, + }, + PrimaryKey { + object_id: Option, + index_id: u16, + key: Vec, + }, + IdCounter { + object_id: u16, + }, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum QueueClass { + Message(u64), + MessageEvent(QueueEvent), + QuotaCount(Vec), + QuotaSize(Vec), +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum TelemetryClass { + Span(u64), + Metric(u64), +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub struct QueueEvent { + pub due: u64, + pub queue_id: u64, + pub queue_name: [u8; 8], +} + +#[derive(Debug, PartialEq, Eq, Hash, Default)] +pub enum ValueOp { + Set(Vec), + SetFnc(SetOperation), + MergeFnc(MergeOperation), + AtomicAdd(i64), + AddAndGet(i64), + #[default] + Clear, +} + +pub enum MergeResult { + Update(Vec), + Skip, + Delete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Param { + I64(i64), + U64(u64), + String(String), + Bytes(Vec), + Bool(bool), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct Params(Vec); + +pub type SetFnc = fn(&Params, &AssignedIds) -> trc::Result>; +pub type MergeFnc = fn(&Params, &AssignedIds, Option<&[u8]>) -> trc::Result; + +#[derive(Debug, Clone)] +pub struct MergeOperation { + pub(crate) fnc: MergeFnc, + pub(crate) params: Params, +} + +#[derive(Debug, Clone)] +pub struct SetOperation { + pub(crate) fnc: SetFnc, + pub(crate) params: Params, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum BlobOp { + Commit { hash: BlobHash }, + Link { hash: BlobHash, to: BlobLink }, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub enum BlobLink { + Id { id: u64 }, + Document, + Temporary { until: u64 }, +} + +#[derive(Debug, PartialEq, Clone, Eq, Hash)] +pub struct AnyKey> { + pub subspace: u8, + pub key: T, +} + +pub trait TokenizeText { + fn tokenize_into(&self, tokens: &mut HashSet); + fn to_tokens(&self) -> HashSet; +} + +impl TokenizeText for &str { + fn tokenize_into(&self, tokens: &mut HashSet) { + for token in WordTokenizer::new(self, MAX_TOKEN_LENGTH) { + tokens.insert(token.word.into_owned()); + } + } + + fn to_tokens(&self) -> HashSet { + let mut tokens = HashSet::new(); + self.tokenize_into(&mut tokens); + tokens + } +} + +pub trait IntoOperations { + fn build(self, batch: &mut BatchBuilder) -> trc::Result<()>; +} + +#[inline(always)] +pub fn now() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +impl AsRef for ValueClass { + fn as_ref(&self) -> &ValueClass { + self + } +} + +impl AssignedIds { + pub fn push_counter_id(&mut self, id: i64) { + self.ids.push(AssignedId::Counter(id)); + } + + pub fn push_change_id(&mut self, account_id: u32, change_id: u64) { + self.ids.push(AssignedId::ChangeId(ChangeId { + account_id, + change_id, + })); + } + + pub fn last_change_id(&self, account_id: u32) -> trc::Result { + self.ids + .iter() + .filter_map(|id| match id { + AssignedId::ChangeId(change_id) if change_id.account_id == account_id => { + Some(change_id.change_id) + } + _ => None, + }) + .next_back() + .ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .ctx(trc::Key::Reason, "No change ids were created") + }) + } + + pub fn current_change_id(&self) -> trc::Result { + self.current_change_id.ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .ctx(trc::Key::Reason, "No current change id is set") + }) + } + + pub(crate) fn set_current_change_id(&mut self, account_id: u32) -> trc::Result { + let change_id = self.last_change_id(account_id)?; + self.current_change_id = Some(change_id); + Ok(change_id) + } + + pub fn last_counter_id(&self) -> trc::Result { + self.ids + .iter() + .filter_map(|id| match id { + AssignedId::Counter(counter_id) => Some(*counter_id), + _ => None, + }) + .next_back() + .ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .ctx(trc::Key::Reason, "No counter ids were created") + }) + } +} + +impl> AsRef<[u8]> for Archive { + fn as_ref(&self) -> &[u8] { + self.inner.as_ref() + } +} + +impl ArchiveVersion { + pub fn hash(&self) -> Option { + match self { + ArchiveVersion::Versioned { hash, .. } => Some(*hash), + ArchiveVersion::Hashed { hash } => Some(*hash), + ArchiveVersion::Unversioned => None, + } + } + + pub fn change_id(&self) -> Option { + match self { + ArchiveVersion::Versioned { change_id, .. } => Some(*change_id), + _ => None, + } + } +} + +impl From for u8 { + fn from(value: LogCollection) -> Self { + match value { + LogCollection::Sync(col) => col as u8, + LogCollection::Vanished(col) => col as u8, + } + } +} + +impl From for ValueClass { + fn from(value: ContactField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: CalendarEventField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: CalendarNotificationField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: EmailField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: MailboxField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: PrincipalField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: SieveField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: EmailSubmissionField) -> Self { + ValueClass::Property(value.into()) + } +} + +impl From for ValueClass { + fn from(value: Field) -> Self { + ValueClass::Property(value.into()) + } +} + +impl PartialEq for MergeOperation { + fn eq(&self, other: &Self) -> bool { + self.params == other.params + } +} + +impl Eq for MergeOperation {} + +impl PartialEq for SetOperation { + fn eq(&self, other: &Self) -> bool { + self.params == other.params + } +} + +impl Eq for SetOperation {} + +impl Hash for MergeOperation { + fn hash(&self, state: &mut H) { + self.params.hash(state); + } +} + +impl Hash for SetOperation { + fn hash(&self, state: &mut H) { + self.params.hash(state); + } +} + +impl SetOperation { + pub fn params(&self) -> &Params { + &self.params + } +} + +impl MergeOperation { + pub fn params(&self) -> &Params { + &self.params + } +} + +impl Params { + pub fn with_capacity(capacity: usize) -> Self { + Self(Vec::with_capacity(capacity)) + } + + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn with_i64(mut self, value: i64) -> Self { + self.0.push(Param::I64(value)); + self + } + + pub fn with_u64(mut self, value: u64) -> Self { + self.0.push(Param::U64(value)); + self + } + + pub fn with_string(mut self, value: String) -> Self { + self.0.push(Param::String(value)); + self + } + + pub fn with_str(mut self, value: &str) -> Self { + self.0.push(Param::String(value.to_string())); + self + } + + pub fn with_bytes(mut self, value: Vec) -> Self { + self.0.push(Param::Bytes(value)); + self + } + + pub fn with_bool(mut self, value: bool) -> Self { + self.0.push(Param::Bool(value)); + self + } + + pub fn i64(&self, idx: usize) -> i64 { + match &self.0[idx] { + Param::I64(v) => *v, + _ => panic!("Param at index {} is not an i64", idx), + } + } + + pub fn u64(&self, idx: usize) -> u64 { + match &self.0[idx] { + Param::U64(v) => *v, + _ => panic!("Param at index {} is not a u64", idx), + } + } + + pub fn string(&self, idx: usize) -> &str { + match &self.0[idx] { + Param::String(v) => v.as_str(), + _ => panic!("Param at index {} is not a String", idx), + } + } + + pub fn bytes(&self, idx: usize) -> &[u8] { + match &self.0[idx] { + Param::Bytes(v) => v.as_slice(), + _ => panic!("Param at index {} is not Bytes", idx), + } + } + + pub fn bool(&self, idx: usize) -> bool { + match &self.0[idx] { + Param::Bool(v) => *v, + _ => panic!("Param at index {} is not a bool", idx), + } + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn as_slice(&self) -> &[Param] { + &self.0 + } +} + +impl Default for Params { + fn default() -> Self { + Self::new() + } +} + +impl AsRef<[Param]> for Params { + fn as_ref(&self) -> &[Param] { + &self.0 + } +} diff --git a/crates/store/src/write/serialize.rs b/crates/store/src/write/serialize.rs new file mode 100644 index 0000000..0f0aea1 --- /dev/null +++ b/crates/store/src/write/serialize.rs @@ -0,0 +1,625 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ARCHIVE_ALIGNMENT, AlignedBytes, Archive, ArchiveVersion, Archiver}; +use crate::{Deserialize, Serialize, SerializeInfallible, U32_LEN, U64_LEN, Value}; +use compact_str::format_compact; +use rkyv::util::AlignedVec; +use roaring::{RoaringBitmap, RoaringTreemap}; + +const MAGIC_MARKER: u8 = 1 << 7; +const VERSIONED: u8 = 1 << 6; +const HASHED: u8 = 1 << 5; +const LZ4_COMPRESSED: u8 = 1 << 4; + +const COMPRESS_WATERMARK: usize = 8192; + +fn validate_marker_and_contents(bytes: &[u8]) -> Option<(bool, &[u8], ArchiveVersion)> { + let (marker, contents) = bytes + .split_last() + .filter(|(marker, _)| (**marker & MAGIC_MARKER) != 0)?; + let is_uncompressed = (marker & LZ4_COMPRESSED) == 0; + if marker & VERSIONED != 0 { + let (contents, change_id) = contents + .split_at_checked(contents.len() - U64_LEN) + .and_then(|(contents, change_id)| { + change_id + .try_into() + .ok() + .map(|change_id| (contents, u64::from_be_bytes(change_id))) + })?; + contents + .split_at_checked(contents.len() - U32_LEN) + .and_then(|(contents, archive_hash)| { + let hash = xxhash_rust::xxh3::xxh3_64(contents) as u32; + if hash.to_be_bytes().as_slice() == archive_hash { + Some(( + is_uncompressed, + contents, + ArchiveVersion::Versioned { change_id, hash }, + )) + } else { + None + } + }) + } else if marker & HASHED != 0 { + contents + .split_at_checked(contents.len() - U32_LEN) + .and_then(|(contents, archive_hash)| { + let hash = xxhash_rust::xxh3::xxh3_64(contents) as u32; + if hash.to_be_bytes().as_slice() == archive_hash { + Some((is_uncompressed, contents, ArchiveVersion::Hashed { hash })) + } else { + None + } + }) + } else { + Some((is_uncompressed, contents, ArchiveVersion::Unversioned)) + } +} + +impl Deserialize for Archive { + fn deserialize(bytes: &[u8]) -> trc::Result { + let (is_uncompressed, contents, version) = + validate_marker_and_contents(bytes).ok_or_else(|| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Archive integrity compromised") + .ctx(trc::Key::Value, bytes) + .caused_by(trc::location!()) + })?; + + if is_uncompressed { + let mut bytes = AlignedVec::with_capacity(contents.len()); + bytes.extend_from_slice(contents); + Ok(Archive { + version, + inner: AlignedBytes::Aligned(bytes), + }) + } else { + aligned_lz4_deflate(contents).map(|inner| Archive { version, inner }) + } + } + + fn deserialize_owned(mut bytes: Vec) -> trc::Result { + let (is_uncompressed, contents, version) = validate_marker_and_contents(&bytes) + .ok_or_else(|| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Archive integrity compromised") + .ctx(trc::Key::Value, bytes.as_slice()) + .caused_by(trc::location!()) + })?; + + if is_uncompressed { + bytes.truncate(contents.len()); + if bytes.as_ptr().addr() & (ARCHIVE_ALIGNMENT - 1) == 0 { + Ok(Archive { + version, + inner: AlignedBytes::Vec(bytes), + }) + } else { + let mut aligned = AlignedVec::with_capacity(bytes.len()); + aligned.extend_from_slice(&bytes); + Ok(Archive { + version, + inner: AlignedBytes::Aligned(aligned), + }) + } + } else { + aligned_lz4_deflate(contents).map(|inner| Archive { version, inner }) + } + } +} + +#[inline] +fn aligned_lz4_deflate(archive: &[u8]) -> trc::Result { + lz4_flex::block::uncompressed_size(archive) + .and_then(|(uncompressed_size, archive)| { + let mut bytes = AlignedVec::with_capacity(uncompressed_size); + unsafe { + // SAFETY: `new_len` is equal to `capacity` and vector is initialized by lz4_flex. + bytes.set_len(uncompressed_size); + } + lz4_flex::decompress_into(archive, &mut bytes)?; + Ok(AlignedBytes::Aligned(bytes)) + }) + .map_err(|err| { + trc::StoreEvent::DecompressError + .ctx(trc::Key::Value, archive) + .caused_by(trc::location!()) + .reason(err) + }) +} + +impl Serialize for Archiver +where + T: rkyv::Archive + + for<'a> rkyv::Serialize< + rkyv::api::high::HighSerializer< + rkyv::util::AlignedVec, + rkyv::ser::allocator::ArenaHandle<'a>, + rkyv::rancor::Error, + >, + >, +{ + fn serialize(&self) -> trc::Result> { + rkyv::to_bytes::(&self.inner) + .map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) + .map(|input| { + let input = input.as_ref(); + let input_len = input.len(); + let version_offset = ((self.flags & VERSIONED != 0) as usize) * U64_LEN; + let mut bytes = if input_len > COMPRESS_WATERMARK { + let mut bytes = vec![ + self.flags | LZ4_COMPRESSED; + lz4_flex::block::get_maximum_output_size(input_len) + + (U32_LEN * 2) + + version_offset + + 1 + ]; + + // Compress the data + let compressed_len = + lz4_flex::compress_into(input, &mut bytes[U32_LEN..]).unwrap(); + + if compressed_len < input_len { + // Prepend the length of the uncompressed data + bytes[..U32_LEN].copy_from_slice(&(input_len as u32).to_le_bytes()); + + if self.flags & HASHED != 0 { + // Hash the compressed data including the length + let hash = + xxhash_rust::xxh3::xxh3_64(&bytes[..compressed_len + U32_LEN]) + as u32; + + // Add the hash + bytes[compressed_len + U32_LEN..compressed_len + (U32_LEN * 2)] + .copy_from_slice(&hash.to_be_bytes()); + + // Truncate to the actual size + bytes.truncate(compressed_len + (U32_LEN * 2) + version_offset + 1); + } else { + // Truncate to the actual size + bytes.truncate(compressed_len + U32_LEN + 1); + } + + return bytes; + } + bytes.clear(); + bytes + } else { + Vec::with_capacity(input_len + U32_LEN + version_offset + 1) + }; + + bytes.extend_from_slice(input); + if self.flags & HASHED != 0 { + bytes.extend_from_slice( + &(xxhash_rust::xxh3::xxh3_64(input) as u32).to_be_bytes(), + ); + } + if version_offset != 0 { + bytes.extend_from_slice(0u64.to_be_bytes().as_slice()); + } + bytes.push(self.flags); + bytes + }) + } +} + +impl Archive { + #[inline] + pub fn as_bytes(&self) -> &[u8] { + match &self.inner { + AlignedBytes::Vec(bytes) => bytes.as_slice(), + AlignedBytes::Aligned(bytes) => bytes.as_slice(), + } + } + + pub fn unarchive(&self) -> trc::Result<&::Archived> + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + let bytes = self.as_bytes(); + if self.version != ArchiveVersion::Unversioned { + if bytes.len() >= std::mem::size_of::() { + // SAFETY: Trusted input with integrity hash + Ok(unsafe { rkyv::access_unchecked::(bytes) }) + } else { + Err(trc::StoreEvent::DataCorruption + .into_err() + .details(format_compact!( + "Archive size mismatch, expected {} bytes but got {} bytes.", + std::mem::size_of::(), + bytes.len() + )) + .ctx(trc::Key::Value, bytes) + .caused_by(trc::location!())) + } + } else { + rkyv::access::(bytes).map_err(|err| { + trc::StoreEvent::DeserializeError + .ctx(trc::Key::Value, self.as_bytes()) + .details("Archive access failed") + .caused_by(trc::location!()) + .reason(err) + }) + } + } + + pub fn unarchive_untrusted(&self) -> trc::Result<&::Archived> + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + let bytes = self.as_bytes(); + if bytes.len() >= std::mem::size_of::() { + rkyv::access::(bytes).map_err(|err| { + trc::StoreEvent::DeserializeError + .ctx(trc::Key::Value, self.as_bytes()) + .details("Archive access failed") + .caused_by(trc::location!()) + .reason(err) + }) + } else { + Err(trc::StoreEvent::DataCorruption + .into_err() + .details(format_compact!( + "Archive size mismatch, expected {} bytes but got {} bytes.", + std::mem::size_of::(), + bytes.len() + )) + .ctx(trc::Key::Value, bytes) + .caused_by(trc::location!())) + } + } + + pub fn deserialize(&self) -> trc::Result + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + self.unarchive::().and_then(|input| { + rkyv::deserialize(input).map_err(|err| { + trc::StoreEvent::DeserializeError + .ctx(trc::Key::Value, self.as_bytes()) + .caused_by(trc::location!()) + .reason(err) + }) + }) + } + + pub fn deserialize_untrusted(&self) -> trc::Result + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + self.unarchive_untrusted::().and_then(|input| { + rkyv::deserialize(input).map_err(|err| { + trc::StoreEvent::DeserializeError + .ctx(trc::Key::Value, self.as_bytes()) + .caused_by(trc::location!()) + .reason(err) + }) + }) + } + + pub fn to_unarchived(&self) -> trc::Result::Archived>> + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + self.unarchive::().map(|inner| Archive { + version: self.version, + inner, + }) + } + + pub fn into_deserialized(&self) -> trc::Result> + where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + > + rkyv::Deserialize>, + { + self.deserialize::().map(|inner| Archive { + version: self.version, + inner, + }) + } + + pub fn into_inner(self) -> Vec { + let mut bytes = match self.inner { + AlignedBytes::Vec(bytes) => bytes, + AlignedBytes::Aligned(bytes) => bytes.to_vec(), + }; + match self.version { + ArchiveVersion::Versioned { change_id, hash } => { + bytes.extend_from_slice(&hash.to_be_bytes()); + bytes.extend_from_slice(&change_id.to_be_bytes()); + bytes.push(MAGIC_MARKER | VERSIONED | HASHED); + } + ArchiveVersion::Hashed { hash } => { + bytes.extend_from_slice(&hash.to_be_bytes()); + bytes.push(MAGIC_MARKER | HASHED); + } + ArchiveVersion::Unversioned => { + bytes.push(MAGIC_MARKER); + } + } + bytes + } + + pub fn extract_hash(bytes: &[u8]) -> Option { + let marker = *bytes.last()?; + if marker & VERSIONED != 0 { + bytes + .get(bytes.len() - U32_LEN - U64_LEN - 1..bytes.len() - U64_LEN - 1) + .and_then(|slice| slice.try_into().ok().map(u32::from_be_bytes)) + } else if marker & HASHED != 0 { + bytes + .get(bytes.len() - U32_LEN - 1..bytes.len() - 1) + .and_then(|slice| slice.try_into().ok().map(u32::from_be_bytes)) + } else { + None + } + } +} + +impl Archiver +where + T: rkyv::Archive + + for<'a> rkyv::Serialize< + rkyv::api::high::HighSerializer< + rkyv::util::AlignedVec, + rkyv::ser::allocator::ArenaHandle<'a>, + rkyv::rancor::Error, + >, + >, +{ + pub fn new(inner: T) -> Self { + Self { + inner, + flags: MAGIC_MARKER | HASHED, + } + } + + pub fn into_inner(self) -> T { + self.inner + } + + pub fn with_version(self) -> Self { + Self { + inner: self.inner, + flags: self.flags | VERSIONED, + } + } + + pub fn untrusted(self) -> Self { + Self { + inner: self.inner, + flags: MAGIC_MARKER, + } + } + + pub fn serialize_versioned(self) -> trc::Result<(u64, Vec)> { + self.with_version() + .serialize() + .map(|bytes| ((bytes.len() - U64_LEN - 1) as u64, bytes)) + } +} + +impl Archive<&T> +where + T: rkyv::Portable + + for<'a> rkyv::bytecheck::CheckBytes> + + Sync + + Send, +{ + pub fn to_deserialized(&self) -> trc::Result> + where + T: rkyv::Deserialize>, + { + rkyv::deserialize::(self.inner) + .map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) + .map(|inner| Archive { + version: self.version, + inner, + }) + } + + pub fn deserialize(&self) -> trc::Result + where + T: rkyv::Deserialize>, + { + rkyv::deserialize::(self.inner).map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +#[inline] +pub fn rkyv_deserialize(input: &T) -> trc::Result +where + T: rkyv::Portable + + for<'a> rkyv::bytecheck::CheckBytes> + + Sync + + Send + + rkyv::Deserialize>, +{ + rkyv::deserialize::(input).map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) +} + +pub fn rkyv_unarchive(input: &[u8]) -> trc::Result<&::Archived> +where + T: rkyv::Archive, + T::Archived: for<'a> rkyv::bytecheck::CheckBytes> + + rkyv::Deserialize>, +{ + rkyv::access::(input).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .ctx(trc::Key::Value, input) + .reason(err) + }) +} + +impl SerializeInfallible for u32 { + fn serialize(&self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl SerializeInfallible for u64 { + fn serialize(&self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl SerializeInfallible for i64 { + fn serialize(&self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl SerializeInfallible for u16 { + fn serialize(&self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl SerializeInfallible for f64 { + fn serialize(&self) -> Vec { + self.to_be_bytes().to_vec() + } +} + +impl SerializeInfallible for &str { + fn serialize(&self) -> Vec { + self.as_bytes().to_vec() + } +} + +impl Deserialize for String { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(String::from_utf8_lossy(bytes).into_owned()) + } + + fn deserialize_owned(bytes: Vec) -> trc::Result { + Ok(String::from_utf8(bytes) + .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())) + } +} + +impl Deserialize for u64 { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(u64::from_be_bytes(bytes.try_into().map_err(|_| { + trc::StoreEvent::DataCorruption.caused_by(trc::location!()) + })?)) + } +} + +impl Deserialize for i64 { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(i64::from_be_bytes(bytes.try_into().map_err(|_| { + trc::StoreEvent::DataCorruption.caused_by(trc::location!()) + })?)) + } +} + +impl Deserialize for u32 { + fn deserialize(bytes: &[u8]) -> trc::Result { + Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| { + trc::StoreEvent::DataCorruption.caused_by(trc::location!()) + })?)) + } +} + +impl From> for Archive { + fn from(_: Value<'static>) -> Self { + unimplemented!() + } +} + +impl Default for Archive { + fn default() -> Self { + Archive { + version: ArchiveVersion::Unversioned, + inner: AlignedBytes::Aligned(AlignedVec::new()), + } + } +} + +impl Serialize for RoaringBitmap { + fn serialize(&self) -> trc::Result> { + let mut bytes = Vec::with_capacity(self.serialized_size()); + self.serialize_into(&mut bytes) + .map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + }) + .map(|_| bytes) + } +} + +impl Deserialize for RoaringBitmap { + fn deserialize(bytes: &[u8]) -> trc::Result { + RoaringBitmap::deserialize_from(bytes).map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +impl Serialize for RoaringTreemap { + fn serialize(&self) -> trc::Result> { + let mut bytes = Vec::with_capacity(self.serialized_size()); + self.serialize_into(&mut bytes) + .map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + }) + .map(|_| bytes) + } +} + +impl Deserialize for RoaringTreemap { + fn deserialize(bytes: &[u8]) -> trc::Result { + RoaringTreemap::deserialize_from(bytes).map_err(|err| { + trc::StoreEvent::DeserializeError + .caused_by(trc::location!()) + .reason(err) + }) + } +} diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml new file mode 100644 index 0000000..d6f5c30 --- /dev/null +++ b/crates/trc/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "trc" +version = "0.16.22" +edition = "2024" + +[dependencies] +event_macro = { path = "./event-macro" } +mail-auth = { version = "0.13", features = ["arc"] } +mail-parser = { version = "0.11", features = ["full_encoding"] } +base64 = "0.23.1" +serde = "1.0" +serde_json = "1.0.151" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]} +rtrb = "0.4.0" +parking_lot = "0.12.5" +tokio = { version = "1.53", features = ["net", "macros"] } +ahash = "0.8.12" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = "0.10.0" +hashify = "0.2.9" + +[features] +test_mode = [] +dev_mode = [] +enterprise = [] + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/trc/event-macro/Cargo.toml b/crates/trc/event-macro/Cargo.toml new file mode 100644 index 0000000..98ac0ea --- /dev/null +++ b/crates/trc/event-macro/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "event_macro" +version = "0.16.22" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "3.0", features = ["full"] } +quote = "1.0" diff --git a/crates/trc/event-macro/src/lib.rs b/crates/trc/event-macro/src/lib.rs new file mode 100644 index 0000000..2f032d7 --- /dev/null +++ b/crates/trc/event-macro/src/lib.rs @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Expr, ExprPath, Ident, Token, parse::Parse, parse_macro_input}; + +static mut GLOBAL_ID_COUNTER: usize = 0; + +#[proc_macro_attribute] +pub fn key_names(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = &input.ident; + + let enum_variants = match &input.data { + Data::Enum(data_enum) => &data_enum.variants, + _ => panic!("This macro only works with enums"), + }; + + let mut variant_names = Vec::new(); + let mut camel_case_names = Vec::new(); + let mut snake_case_names = Vec::new(); + + for variant in enum_variants.iter() { + let variant_name = &variant.ident; + variant_names.push(variant_name); + snake_case_names.push(to_snake_case(&variant_name.to_string())); + camel_case_names.push( + variant_name + .to_string() + .char_indices() + .map(|(i, c)| if i == 0 { c.to_ascii_lowercase() } else { c }) + .collect::(), + ); + } + + let id_fn = quote! { + pub fn id(&self) -> &'static str { + match self { + #(Self::#variant_names => #snake_case_names,)* + } + } + }; + + let name_fn = quote! { + pub fn name(&self) -> &'static str { + match self { + #(Self::#variant_names => #camel_case_names,)* + } + } + }; + + let parse_fn = quote! { + pub fn try_parse(name: &str) -> Option { + match name { + #(#camel_case_names => Some(Self::#variant_names),)* + _ => None, + } + } + }; + + let expanded = quote! { + #input + + impl #name { + #name_fn + #id_fn + #parse_fn + } + }; + + TokenStream::from(expanded) +} + +#[proc_macro] +pub fn total_event_count(_item: TokenStream) -> TokenStream { + let count = unsafe { GLOBAL_ID_COUNTER }; + let expanded = quote! { + #count + }; + TokenStream::from(expanded) +} + +fn to_snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for (idx, ch) in name.char_indices() { + if ch.is_ascii_uppercase() { + if idx > 0 { + out.push('-'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +struct EventMacroInput { + event: Ident, + param: Expr, + key_values: Vec<(Ident, Expr)>, +} + +impl Parse for EventMacroInput { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let event: Ident = input.parse()?; + let content; + syn::parenthesized!(content in input); + let param: Expr = content.parse()?; + + let mut key_values = Vec::new(); + while !input.is_empty() { + input.parse::()?; + if input.is_empty() { + break; + } + let key: Ident = input.parse()?; + input.parse::()?; + let value: Expr = input.parse()?; + key_values.push((key, value)); + } + + Ok(EventMacroInput { + event, + param, + key_values, + }) + } +} + +#[proc_macro] +pub fn event(input: TokenStream) -> TokenStream { + let EventMacroInput { + event, + param, + key_values, + } = parse_macro_input!(input as EventMacroInput); + + let key_value_tokens = key_values.iter().map(|(key, value)| { + quote! { + (trc::Key::#key, trc::Value::from(#value)) + } + }); + // This avoids having to evaluate expensive values when we know we are not interested in the event + let key_value_metric_tokens = key_values.iter().filter_map(|(key, value)| { + if key.is_metric_key() { + Some(quote! { + (trc::Key::#key, trc::Value::from(#value)) + }) + } else { + None + } + }); + + let expanded = if matches!(¶m, Expr::Path(ExprPath { path, .. }) if path.segments.len() > 1 && path.segments.last().unwrap().arguments.is_empty() ) + { + quote! {{ + const ET: trc::EventType = trc::EventType::#event(#param); + const ET_ID: usize = ET.to_id() as usize; + if trc::Collector::has_interest(ET_ID) { + let keys = vec![#(#key_value_tokens),*]; + if trc::Collector::is_metric(ET_ID) { + trc::Collector::record_metric(ET, ET_ID, &keys); + } + trc::Event::with_keys(ET, keys).send(); + } else if trc::Collector::is_metric(ET_ID) { + trc::Collector::record_metric(ET, ET_ID, &[#(#key_value_metric_tokens),*]); + } + }} + } else { + quote! {{ + let et = trc::EventType::#event(#param); + let et_id = et.to_id() as usize; + if trc::Collector::has_interest(et_id) { + let keys = vec![#(#key_value_tokens),*]; + if trc::Collector::is_metric(et_id) { + trc::Collector::record_metric(et, et_id, &keys); + } + trc::Event::with_keys(et, keys).send(); + } else if trc::Collector::is_metric(et_id) { + trc::Collector::record_metric(et, et_id, &[#(#key_value_metric_tokens),*]); + } + }} + }; + + TokenStream::from(expanded) +} + +trait IsMetricKey { + fn is_metric_key(&self) -> bool; +} + +impl IsMetricKey for Ident { + fn is_metric_key(&self) -> bool { + matches!( + self.to_string().as_ref(), + "Total" + | "Elapsed" + | "Size" + | "TotalSuccesses" + | "TotalFailures" + | "DmarcPass" + | "DmarcQuarantine" + | "DmarcReject" + | "DmarcNone" + | "DkimPass" + | "DkimFail" + | "DkimNone" + | "SpfPass" + | "SpfFail" + | "SpfNone" + | "Protocol" + | "Code" + ) + } +} diff --git a/crates/trc/src/atomics/array.rs b/crates/trc/src/atomics/array.rs new file mode 100644 index 0000000..e81f8ed --- /dev/null +++ b/crates/trc/src/atomics/array.rs @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; + +pub struct AtomicU32Array([AtomicU32; N]); +pub struct AtomicU64Array([AtomicU64; N]); + +impl AtomicU32Array { + #[allow(clippy::new_without_default)] + #[allow(clippy::declare_interior_mutable_const)] + pub const fn new() -> Self { + Self({ + const INIT: AtomicU32 = AtomicU32::new(0); + let mut array = [INIT; N]; + let mut i = 0; + while i < N { + array[i] = AtomicU32::new(0); + i += 1; + } + array + }) + } + + #[inline(always)] + pub fn get(&self, index: usize) -> u32 { + self.0[index].load(Ordering::Relaxed) + } + + #[inline(always)] + pub fn set(&self, index: usize, value: u32) { + self.0[index].store(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn add(&self, index: usize, value: u32) { + self.0[index].fetch_add(value, Ordering::Relaxed); + } + + pub fn inner(&self) -> &[AtomicU32; N] { + &self.0 + } +} + +impl AtomicU64Array { + #[allow(clippy::new_without_default)] + #[allow(clippy::declare_interior_mutable_const)] + pub const fn new() -> Self { + Self({ + const INIT: AtomicU64 = AtomicU64::new(0); + let mut array = [INIT; N]; + let mut i = 0; + while i < N { + array[i] = AtomicU64::new(0); + i += 1; + } + array + }) + } + + #[inline(always)] + pub fn get(&self, index: usize) -> u64 { + self.0[index].load(Ordering::Relaxed) + } + + #[inline(always)] + pub fn set(&self, index: usize, value: u64) { + self.0[index].store(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn add(&self, index: usize, value: u64) { + self.0[index].fetch_add(value, Ordering::Relaxed); + } + + pub fn inner(&self) -> &[AtomicU64; N] { + &self.0 + } +} diff --git a/crates/trc/src/atomics/bitset.rs b/crates/trc/src/atomics/bitset.rs new file mode 100644 index 0000000..7934e8f --- /dev/null +++ b/crates/trc/src/atomics/bitset.rs @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::ipc::{USIZE_BITS, USIZE_BITS_MASK, bitset::Bitset}; + +pub struct AtomicBitset([AtomicUsize; N]); + +impl AtomicBitset { + #[allow(clippy::new_without_default)] + #[allow(clippy::declare_interior_mutable_const)] + pub const fn new() -> Self { + Self({ + const INIT: AtomicUsize = AtomicUsize::new(0); + let mut array = [INIT; N]; + let mut i = 0; + while i < N { + array[i] = AtomicUsize::new(0); + i += 1; + } + array + }) + } + + #[inline(always)] + pub fn set(&self, index: impl Into) { + let index = index.into(); + self.0[index / USIZE_BITS].fetch_or(1 << (index & USIZE_BITS_MASK), Ordering::Relaxed); + } + + #[inline(always)] + pub fn clear(&self, index: impl Into) { + let index = index.into(); + self.0[index / USIZE_BITS].fetch_and(!(1 << (index & USIZE_BITS_MASK)), Ordering::Relaxed); + } + + #[inline(always)] + pub fn get(&self, index: impl Into) -> bool { + let index = index.into(); + self.0[index / USIZE_BITS].load(Ordering::Relaxed) & (1 << (index & USIZE_BITS_MASK)) != 0 + } + + pub fn update(&self, bitset: impl AsRef>) { + let bitset = bitset.as_ref(); + for i in 0..N { + self.0[i].store(bitset.0[i], Ordering::Relaxed); + } + } + + pub fn union(&self, bitset: impl AsRef>) { + let bitset = bitset.as_ref(); + for i in 0..N { + self.0[i].fetch_or(bitset.0[i], Ordering::Relaxed); + } + } + + pub fn clear_all(&self) { + for i in 0..N { + self.0[i].store(0, Ordering::Relaxed); + } + } + + pub fn is_empty(&self) -> bool { + for i in 0..N { + if self.0[i].load(Ordering::Relaxed) != 0 { + return false; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SIZE: usize = 1000; + type TestBitset = AtomicBitset<{ TEST_SIZE.div_ceil(USIZE_BITS) }>; + static BITSET: TestBitset = TestBitset::new(); + + #[test] + fn test_atomic_bitset() { + for i in 0..TEST_SIZE { + assert!(!BITSET.get(i), "Bit {} should be unset in new BITSET", i); + } + + for i in 0..TEST_SIZE { + assert!(!BITSET.get(i), "Bit {} should be initially unset", i); + BITSET.set(i); + assert!(BITSET.get(i), "Bit {} should be set after setting", i); + } + + BITSET.clear_all(); + + for i in 0..TEST_SIZE { + BITSET.set(i); + assert!(BITSET.get(i), "Bit {} should be set before clearing", i); + BITSET.clear(i); + assert!(!BITSET.get(i), "Bit {} should be unset after clearing", i); + } + + BITSET.clear_all(); + + // Set even bits + for i in (0..TEST_SIZE).step_by(2) { + BITSET.set(i); + } + + // Check all bits + for i in 0..TEST_SIZE { + if i % 2 == 0 { + assert!(BITSET.get(i), "Even bit {} should be set", i); + } else { + assert!(!BITSET.get(i), "Odd bit {} should be unset", i); + } + } + + // Clear even bits and set odd bits + for i in 0..TEST_SIZE { + if i % 2 == 0 { + BITSET.clear(i); + } else { + BITSET.set(i); + } + } + + // Check all bits again + for i in 0..TEST_SIZE { + if i % 2 == 0 { + assert!(!BITSET.get(i), "Even bit {} should now be unset", i); + } else { + assert!(BITSET.get(i), "Odd bit {} should now be set", i); + } + } + } +} diff --git a/crates/trc/src/atomics/counter.rs b/crates/trc/src/atomics/counter.rs new file mode 100644 index 0000000..641e166 --- /dev/null +++ b/crates/trc/src/atomics/counter.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicU64, Ordering}; + +pub struct AtomicCounter { + id: &'static str, + description: &'static str, + unit: &'static str, + value: AtomicU64, +} + +impl AtomicCounter { + pub const fn new(id: &'static str, description: &'static str, unit: &'static str) -> Self { + Self { + id, + description, + unit, + value: AtomicU64::new(0), + } + } + + #[inline(always)] + pub fn increment(&self) { + self.value.fetch_add(1, Ordering::Relaxed); + } + + #[inline(always)] + pub fn increment_by(&self, value: u64) { + self.value.fetch_add(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn decrement(&self) { + self.value.fetch_sub(1, Ordering::Relaxed); + } + + #[inline(always)] + pub fn decrement_by(&self, value: u64) { + self.value.fetch_sub(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn get(&self) -> u64 { + self.value.load(Ordering::Relaxed) + } + + pub fn id(&self) -> &'static str { + self.id + } + + pub fn description(&self) -> &'static str { + self.description + } + + pub fn unit(&self) -> &'static str { + self.unit + } + + pub fn is_active(&self) -> bool { + self.value.load(Ordering::Relaxed) > 0 + } +} diff --git a/crates/trc/src/atomics/gauge.rs b/crates/trc/src/atomics/gauge.rs new file mode 100644 index 0000000..5a92e22 --- /dev/null +++ b/crates/trc/src/atomics/gauge.rs @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::MetricType; + +pub struct AtomicGauge { + id: MetricType, + value: AtomicU64, +} + +impl AtomicGauge { + pub const fn new(id: MetricType) -> Self { + Self { + id, + value: AtomicU64::new(0), + } + } + + #[inline(always)] + pub fn increment(&self) { + self.value.fetch_add(1, Ordering::Relaxed); + } + + #[inline(always)] + pub fn set(&self, value: u64) { + self.value.store(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn decrement(&self) { + self.value.fetch_sub(1, Ordering::Relaxed); + } + + #[inline(always)] + pub fn get(&self) -> u64 { + self.value.load(Ordering::Relaxed) + } + + #[inline(always)] + pub fn add(&self, value: u64) { + self.value.fetch_add(value, Ordering::Relaxed); + } + + #[inline(always)] + pub fn subtract(&self, value: u64) { + self.value.fetch_sub(value, Ordering::Relaxed); + } + + pub fn id(&self) -> MetricType { + self.id + } +} diff --git a/crates/trc/src/atomics/histogram.rs b/crates/trc/src/atomics/histogram.rs new file mode 100644 index 0000000..b6bc00b --- /dev/null +++ b/crates/trc/src/atomics/histogram.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::MetricType; + +use super::array::AtomicU32Array; + +pub struct AtomicHistogram { + id: MetricType, + buckets: AtomicU32Array, + upper_bounds: [u64; N], + sum: AtomicU64, + count: AtomicU64, + min: AtomicU64, + max: AtomicU64, +} + +impl AtomicHistogram { + pub const fn new(id: MetricType, upper_bounds: [u64; N]) -> Self { + Self { + buckets: AtomicU32Array::new(), + upper_bounds, + sum: AtomicU64::new(0), + count: AtomicU64::new(0), + min: AtomicU64::new(u64::MAX), + max: AtomicU64::new(0), + id, + } + } + + pub fn observe(&self, value: u64) { + self.sum.fetch_add(value, Ordering::Relaxed); + self.count.fetch_add(1, Ordering::Relaxed); + self.min.fetch_min(value, Ordering::Relaxed); + self.max.fetch_max(value, Ordering::Relaxed); + + for (idx, upper_bound) in self.upper_bounds.iter().enumerate() { + if value < *upper_bound { + self.buckets.add(idx, 1); + return; + } + } + + unreachable!() + } + + pub fn id(&self) -> MetricType { + self.id + } + + pub fn sum(&self) -> u64 { + self.sum.load(Ordering::Relaxed) + } + + pub fn count(&self) -> u64 { + self.count.load(Ordering::Relaxed) + } + + pub fn average(&self) -> f64 { + let sum = self.sum(); + let count = self.count(); + if count > 0 { + sum as f64 / count as f64 + } else { + 0.0 + } + } + + pub fn min(&self) -> Option { + let min = self.min.load(Ordering::Relaxed); + if min != u64::MAX { Some(min) } else { None } + } + + pub fn max(&self) -> Option { + let max = self.max.load(Ordering::Relaxed); + if max != 0 { Some(max) } else { None } + } + + pub fn buckets_iter(&self) -> impl IntoIterator + '_ { + self.buckets + .inner() + .iter() + .map(|bucket| bucket.load(Ordering::Relaxed) as u64) + } + + pub fn buckets_vec(&self) -> Vec { + let mut vec = Vec::with_capacity(N); + for bucket in self.buckets.inner().iter() { + vec.push(bucket.load(Ordering::Relaxed) as u64); + } + vec + } + + pub fn buckets_len(&self) -> usize { + N + } + + pub fn upper_bounds_iter(&self) -> impl IntoIterator + '_ { + self.upper_bounds.iter().copied() + } + + pub fn upper_bounds_vec(&self) -> Vec { + let mut vec = Vec::with_capacity(N - 1); + for upper_bound in self.upper_bounds.iter().take(N - 1) { + vec.push(*upper_bound as f64); + } + vec + } + + pub fn is_active(&self) -> bool { + self.count.load(Ordering::Relaxed) > 0 + } + + pub const fn new_message_sizes(id: MetricType) -> AtomicHistogram<12> { + AtomicHistogram::new( + id, + [ + 500, // 500 bytes + 1_000, // 1 KB + 10_000, // 10 KB + 100_000, // 100 KB + 1_000_000, // 1 MB + 5_000_000, // 5 MB + 10_000_000, // 10 MB + 25_000_000, // 25 MB + 50_000_000, // 50 MB + 100_000_000, // 100 MB + 500_000_000, // 500 MB + u64::MAX, // Catch-all for any larger sizes + ], + ) + } + + pub const fn new_short_durations(id: MetricType) -> AtomicHistogram<12> { + AtomicHistogram::new( + id, + [ + 5, // 5 milliseconds + 10, // 10 milliseconds + 50, // 50 milliseconds + 100, // 100 milliseconds + 500, // 0.5 seconds + 1_000, // 1 second + 2_000, // 2 seconds + 5_000, // 5 seconds + 10_000, // 10 seconds + 30_000, // 30 seconds + 60_000, // 1 minute + u64::MAX, // Catch-all for any longer durations + ], + ) + } + + pub const fn new_medium_durations(id: MetricType) -> AtomicHistogram<12> { + AtomicHistogram::new( + id, + [ + 250, + 500, + 1_000, + 5_000, + 10_000, // For quick connections (seconds) + 60_000, + (60 * 5) * 1_000, + (60 * 10) * 1_000, + (60 * 30) * 1_000, // For medium-length connections (minutes) + (60 * 60) * 1_000, + (60 * 60 * 5) * 1_000, + u64::MAX, // For extreme cases (8 hours and 1 day) + ], + ) + } + + pub const fn new_long_durations(id: MetricType) -> AtomicHistogram<12> { + AtomicHistogram::new( + id, + [ + 1_000, // 1 second + 30_000, // 30 seconds + 300_000, // 5 minutes + 600_000, // 10 minutes + 1_800_000, // 30 minutes + 3_600_000, // 1 hour + 14_400_000, // 5 hours + 28_800_000, // 8 hours + 43_200_000, // 12 hours + 86_400_000, // 1 day + 604_800_000, // 1 week + u64::MAX, // Catch-all for any longer durations + ], + ) + } +} diff --git a/crates/trc/src/atomics/mod.rs b/crates/trc/src/atomics/mod.rs new file mode 100644 index 0000000..bf8b87f --- /dev/null +++ b/crates/trc/src/atomics/mod.rs @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod array; +pub mod bitset; +pub mod counter; +pub mod gauge; +pub mod histogram; diff --git a/crates/trc/src/event/conv.rs b/crates/trc/src/event/conv.rs new file mode 100644 index 0000000..adffa8c --- /dev/null +++ b/crates/trc/src/event/conv.rs @@ -0,0 +1,598 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, fmt::Debug, str::FromStr, time::Duration}; + +use compact_str::{CompactString, ToCompactString, format_compact}; +use mail_auth::common::verify::VerifySignature; + +use crate::*; + +impl AsRef for Error { + fn as_ref(&self) -> &EventType { + &self.0.inner + } +} + +impl From<&'static str> for Value { + fn from(value: &'static str) -> Self { + Self::String(CompactString::const_new(value)) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::String(CompactString::from_string_buffer(value)) + } +} + +impl From for Value { + fn from(value: CompactString) -> Self { + Self::String(value) + } +} + +impl From> for Value { + fn from(value: Box) -> Self { + Self::String(CompactString::from(value)) + } +} + +impl From for Value { + fn from(value: u64) -> Self { + Self::UInt(value) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Float(value.into()) + } +} + +impl From for Value { + fn from(value: u16) -> Self { + Self::UInt(value.into()) + } +} + +impl From for Value { + fn from(value: i32) -> Self { + Self::Int(value.into()) + } +} + +impl From for Value { + fn from(value: u32) -> Self { + Self::UInt(value.into()) + } +} + +impl From for Value { + fn from(value: usize) -> Self { + Self::UInt(value as u64) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for Value { + fn from(value: IpAddr) -> Self { + match value { + IpAddr::V4(ip) => Value::Ipv4(ip), + IpAddr::V6(ip) => Value::Ipv6(ip), + } + } +} + +impl> From> for Value { + fn from(value: Option) -> Self { + match value { + Some(value) => value.into(), + None => Self::None, + } + } +} + +impl From for Value { + fn from(value: Duration) -> Self { + Self::Duration(value.as_millis() as u64) + } +} + +impl From for Value { + fn from(value: Error) -> Self { + Self::Event(value) + } +} + +impl From for Error { + fn from(value: EventType) -> Self { + Error::new(value) + } +} + +impl From for Error { + fn from(value: StoreEvent) -> Self { + Error::new(EventType::Store(value)) + } +} + +impl From for Error { + fn from(value: AuthEvent) -> Self { + Error::new(EventType::Auth(value)) + } +} + +impl From> for Value { + fn from(value: Vec) -> Self { + Self::Bytes(value) + } +} + +impl From<&[u8]> for Value { + fn from(value: &[u8]) -> Self { + Self::Bytes(value.to_vec()) + } +} + +impl From> for Value { + fn from(value: Cow<'static, str>) -> Self { + match value { + Cow::Borrowed(value) => Self::String(CompactString::const_new(value)), + Cow::Owned(value) => Self::String(value.into()), + } + } +} + +impl From<&crate::Result> for Value +where + T: Debug, +{ + fn from(value: &crate::Result) -> Self { + match value { + Ok(value) => format_compact!("{:?}", value).into(), + Err(err) => Value::Event(err.clone()), + } + } +} + +impl From> for Value +where + T: Into, +{ + fn from(value: Vec) -> Self { + Self::Array(value.into_iter().map(Into::into).collect()) + } +} + +impl From<&[T]> for Value +where + T: Into + Clone, +{ + fn from(value: &[T]) -> Self { + Self::Array(value.iter().map(|v| v.clone().into()).collect()) + } +} + +impl EventType { + pub fn from_io_error(self, err: std::io::Error) -> Error { + self.reason(err).details("I/O error") + } + + pub fn from_json_error(self, err: serde_json::Error) -> Error { + self.reason(err).details("JSON deserialization failed") + } + + pub fn from_base64_error(self, err: base64::DecodeError) -> Error { + self.reason(err).details("Base64 decoding failed") + } + + pub fn from_http_error(self, err: reqwest::Error) -> Error { + self.into_err() + .ctx_opt( + Key::Url, + err.url().map(|url| url.as_ref().to_compact_string()), + ) + .ctx_opt(Key::Code, err.status().map(|status| status.as_u16())) + .reason(err) + } + + pub fn from_http_str_error(self, err: reqwest::header::ToStrError) -> Error { + self.reason(err) + .details("Failed to convert header to string") + } +} + +impl From for Error { + fn from(err: mail_auth::Error) -> Self { + match err { + mail_auth::Error::ParseError => { + EventType::MailAuth(MailAuthEvent::ParseError).into_err() + } + mail_auth::Error::MissingParameters => { + EventType::MailAuth(MailAuthEvent::MissingParameters).into_err() + } + mail_auth::Error::NoHeadersFound => { + EventType::MailAuth(MailAuthEvent::NoHeadersFound).into_err() + } + mail_auth::Error::Io(details) => EventType::MailAuth(MailAuthEvent::Io) + .into_err() + .details(CompactString::from(details)), + mail_auth::Error::Base64 => EventType::MailAuth(MailAuthEvent::Base64).into_err(), + mail_auth::Error::NotAligned => { + EventType::MailAuth(MailAuthEvent::PolicyNotAligned).into_err() + } + mail_auth::Error::Crypto(err) => match err { + mail_auth::common::crypto::CryptoError::Library(details) => { + EventType::MailAuth(MailAuthEvent::Crypto) + .into_err() + .details(CompactString::from(details)) + } + mail_auth::common::crypto::CryptoError::FailedVerification => { + EventType::Dkim(DkimEvent::FailedVerification).into_err() + } + mail_auth::common::crypto::CryptoError::IncompatibleAlgorithms => { + EventType::Dkim(DkimEvent::IncompatibleAlgorithms).into_err() + } + }, + mail_auth::Error::Dns(err) => match err { + mail_auth::DnsError::Resolver(details) => { + EventType::MailAuth(MailAuthEvent::DnsError) + .into_err() + .details(CompactString::from(details)) + } + mail_auth::DnsError::RecordNotFound(code) => { + EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) + .into_err() + .code(code.to_str()) + } + mail_auth::DnsError::InvalidRecordType => { + EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType).into_err() + } + }, + mail_auth::Error::Dkim(err) => match err { + mail_auth::dkim::DkimError::UnsupportedVersion => { + EventType::Dkim(DkimEvent::UnsupportedVersion).into_err() + } + mail_auth::dkim::DkimError::UnsupportedAlgorithm => { + EventType::Dkim(DkimEvent::UnsupportedAlgorithm).into_err() + } + mail_auth::dkim::DkimError::UnsupportedCanonicalization => { + EventType::Dkim(DkimEvent::UnsupportedCanonicalization).into_err() + } + mail_auth::dkim::DkimError::UnsupportedKeyType => { + EventType::Dkim(DkimEvent::UnsupportedKeyType).into_err() + } + mail_auth::dkim::DkimError::FailedBodyHashMatch => { + EventType::Dkim(DkimEvent::FailedBodyHashMatch).into_err() + } + mail_auth::dkim::DkimError::FailedAuidMatch => { + EventType::Dkim(DkimEvent::FailedAuidMatch).into_err() + } + mail_auth::dkim::DkimError::RevokedPublicKey => { + EventType::Dkim(DkimEvent::RevokedPublicKey).into_err() + } + mail_auth::dkim::DkimError::SignatureExpired => { + EventType::Dkim(DkimEvent::SignatureExpired).into_err() + } + mail_auth::dkim::DkimError::SignatureLength => { + EventType::Dkim(DkimEvent::SignatureLength).into_err() + } + }, + mail_auth::Error::Arc(err) => match err { + mail_auth::arc::ArcError::ChainTooLong => { + EventType::Arc(ArcEvent::ChainTooLong).into_err() + } + mail_auth::arc::ArcError::InvalidInstance(instance) => { + EventType::Arc(ArcEvent::InvalidInstance).ctx(Key::Id, instance) + } + mail_auth::arc::ArcError::InvalidCV => { + EventType::Arc(ArcEvent::InvalidCv).into_err() + } + mail_auth::arc::ArcError::HasHeaderTag => { + EventType::Arc(ArcEvent::HasHeaderTag).into_err() + } + mail_auth::arc::ArcError::BrokenChain => { + EventType::Arc(ArcEvent::BrokenChain).into_err() + } + mail_auth::arc::ArcError::FailedBodyHashMatch => { + EventType::Dkim(DkimEvent::FailedBodyHashMatch).into_err() + } + mail_auth::arc::ArcError::SignatureExpired => { + EventType::Dkim(DkimEvent::SignatureExpired).into_err() + } + mail_auth::arc::ArcError::SignatureLength => { + EventType::Dkim(DkimEvent::SignatureLength).into_err() + } + }, + mail_auth::Error::Dkim2(err) => match err { + mail_auth::dkim2::Dkim2Error::InstanceMissing(m) => { + EventType::Dkim(DkimEvent::InstanceMissing).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::InstanceSyntax(m) => { + EventType::Dkim(DkimEvent::InstanceSyntax).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::InstanceTagMissing { m, tag } => { + EventType::Dkim(DkimEvent::InstanceTagMissing) + .ctx(Key::Id, m) + .details(tag) + } + mail_auth::dkim2::Dkim2Error::InstanceNotSigned(m) => { + EventType::Dkim(DkimEvent::InstanceNotSigned).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::InstanceAboveSignature(m) => { + EventType::Dkim(DkimEvent::InstanceAboveSignature).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::SignatureMissing(i) => { + EventType::Dkim(DkimEvent::SignatureMissing).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::SignatureSyntax(i) => { + EventType::Dkim(DkimEvent::SignatureSyntax).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::SignatureTagMissing { i, tag } => { + EventType::Dkim(DkimEvent::SignatureTagMissing) + .ctx(Key::Id, i) + .details(tag) + } + mail_auth::dkim2::Dkim2Error::SignatureTagUnexpected { i, tag } => { + EventType::Dkim(DkimEvent::SignatureTagUnexpected) + .ctx(Key::Id, i) + .details(tag) + } + mail_auth::dkim2::Dkim2Error::SequenceGap => { + EventType::Dkim(DkimEvent::SequenceGap).into_err() + } + mail_auth::dkim2::Dkim2Error::SequenceOverflow => { + EventType::Dkim(DkimEvent::SequenceOverflow).into_err() + } + mail_auth::dkim2::Dkim2Error::ChainTooLong => { + EventType::Dkim(DkimEvent::ChainTooLong).into_err() + } + mail_auth::dkim2::Dkim2Error::SignatureExpired(i) => { + EventType::Dkim(DkimEvent::SignatureExpired).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::MailFromMismatch(i) => { + EventType::Dkim(DkimEvent::MailFromMismatch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::RcptToMismatch(i) => { + EventType::Dkim(DkimEvent::RcptToMismatch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::MailFromDomainMismatch(i) => { + EventType::Dkim(DkimEvent::MailFromDomainMismatch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::NextDomainMismatch(i) => { + EventType::Dkim(DkimEvent::NextDomainMismatch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::CustodyBreak(i) => { + EventType::Dkim(DkimEvent::CustodyBreak).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeyFetch(i) => { + EventType::Dkim(DkimEvent::PublicKeyFetch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeyMissing(i) => { + EventType::Dkim(DkimEvent::PublicKeyMissing).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeyMultiple(i) => { + EventType::Dkim(DkimEvent::PublicKeyMultiple).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeySyntax(i) => { + EventType::Dkim(DkimEvent::PublicKeySyntax).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeyAlgorithmMismatch(i) => { + EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::PublicKeyRevoked(i) => { + EventType::Dkim(DkimEvent::RevokedPublicKey).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::IncorrectSignature(i) => { + EventType::Dkim(DkimEvent::FailedVerification).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::NoValidAlgorithm(i) => { + EventType::Dkim(DkimEvent::NoValidAlgorithm).ctx(Key::Id, i) + } + mail_auth::dkim2::Dkim2Error::HeaderHashMismatch(m) => { + EventType::Dkim(DkimEvent::HeaderHashMismatch).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::BodyHashMismatch(m) => { + EventType::Dkim(DkimEvent::FailedBodyHashMatch).ctx(Key::Id, m) + } + mail_auth::dkim2::Dkim2Error::Modified => { + EventType::Dkim(DkimEvent::Modified).into_err() + } + mail_auth::dkim2::Dkim2Error::Exploded => { + EventType::Dkim(DkimEvent::Exploded).into_err() + } + }, + } + } +} + +impl From<&mail_auth::DkimResult> for Error { + fn from(value: &mail_auth::DkimResult) -> Self { + match value.clone() { + mail_auth::DkimResult::Pass => Error::new(EventType::Dkim(DkimEvent::Pass)), + mail_auth::DkimResult::Neutral(err) => { + Error::new(EventType::Dkim(DkimEvent::Neutral)).caused_by(Error::from(err)) + } + mail_auth::DkimResult::Fail(err) => { + Error::new(EventType::Dkim(DkimEvent::Fail)).caused_by(Error::from(err)) + } + mail_auth::DkimResult::PermError(err) => { + Error::new(EventType::Dkim(DkimEvent::PermError)).caused_by(Error::from(err)) + } + mail_auth::DkimResult::TempError(err) => { + Error::new(EventType::Dkim(DkimEvent::TempError)).caused_by(Error::from(err)) + } + mail_auth::DkimResult::None => Error::new(EventType::Dkim(DkimEvent::None)), + } + } +} + +impl From<&mail_auth::Dkim2Result> for Error { + fn from(value: &mail_auth::Dkim2Result) -> Self { + match value.clone() { + mail_auth::Dkim2Result::Pass => Error::new(EventType::Dkim(DkimEvent::Pass)), + mail_auth::Dkim2Result::Fail(err) => { + Error::new(EventType::Dkim(DkimEvent::Fail)).caused_by(Error::from(err)) + } + mail_auth::Dkim2Result::PermError(err) => { + Error::new(EventType::Dkim(DkimEvent::PermError)).caused_by(Error::from(err)) + } + mail_auth::Dkim2Result::TempError(err) => { + Error::new(EventType::Dkim(DkimEvent::TempError)).caused_by(Error::from(err)) + } + mail_auth::Dkim2Result::None => Error::new(EventType::Dkim(DkimEvent::None)), + } + } +} + +impl From<&mail_auth::dkim2::Dkim2Output<'_>> for Error { + fn from(value: &mail_auth::dkim2::Dkim2Output<'_>) -> Self { + Error::from(value.result()).ctx_opt( + Key::Domain, + value + .chain() + .first() + .map(|link| link.signature.d.to_compact_string()), + ) + } +} + +impl From<&mail_auth::DmarcResult> for Error { + fn from(value: &mail_auth::DmarcResult) -> Self { + match value.clone() { + mail_auth::DmarcResult::Pass => Error::new(EventType::Dmarc(DmarcEvent::Pass)), + mail_auth::DmarcResult::Fail(err) => { + Error::new(EventType::Dmarc(DmarcEvent::Fail)).caused_by(Error::from(err)) + } + mail_auth::DmarcResult::PermError(err) => { + Error::new(EventType::Dmarc(DmarcEvent::PermError)).caused_by(Error::from(err)) + } + mail_auth::DmarcResult::TempError(err) => { + Error::new(EventType::Dmarc(DmarcEvent::TempError)).caused_by(Error::from(err)) + } + mail_auth::DmarcResult::None => Error::new(EventType::Dmarc(DmarcEvent::None)), + } + } +} + +impl From<&mail_auth::DkimOutput<'_>> for Error { + fn from(value: &mail_auth::DkimOutput<'_>) -> Self { + Error::from(value.result()).ctx_opt( + Key::Domain, + value.signature().map(|s| s.domain().to_compact_string()), + ) + } +} + +impl From<&mail_auth::IprevOutput> for Error { + fn from(value: &mail_auth::IprevOutput) -> Self { + match value.result().clone() { + mail_auth::IprevResult::Pass => Error::new(EventType::Iprev(IprevEvent::Pass)), + mail_auth::IprevResult::Fail(err) => { + Error::new(EventType::Iprev(IprevEvent::Fail)).caused_by(Error::from(err)) + } + mail_auth::IprevResult::PermError(err) => { + Error::new(EventType::Iprev(IprevEvent::PermError)).caused_by(Error::from(err)) + } + mail_auth::IprevResult::TempError(err) => { + Error::new(EventType::Iprev(IprevEvent::TempError)).caused_by(Error::from(err)) + } + mail_auth::IprevResult::None => Error::new(EventType::Iprev(IprevEvent::None)), + } + .ctx_opt( + Key::Details, + value.ptr.as_ref().map(|s| { + s.iter() + .map(|v| Value::String(v.as_ref().into())) + .collect::>() + }), + ) + } +} + +impl From<&mail_auth::SpfOutput> for Error { + fn from(value: &mail_auth::SpfOutput) -> Self { + Error::new(EventType::Spf(match value.result() { + mail_auth::SpfResult::Pass => SpfEvent::Pass, + mail_auth::SpfResult::Fail => SpfEvent::Fail, + mail_auth::SpfResult::SoftFail => SpfEvent::SoftFail, + mail_auth::SpfResult::Neutral => SpfEvent::Neutral, + mail_auth::SpfResult::PermError => SpfEvent::PermError, + mail_auth::SpfResult::TempError => SpfEvent::TempError, + mail_auth::SpfResult::None => SpfEvent::None, + })) + .ctx_opt( + Key::Details, + value.explanation().map(|s| s.to_compact_string()), + ) + } +} + +impl From for Error { + fn from(value: rkyv::rancor::Error) -> Self { + Error::new(EventType::Store(StoreEvent::DeserializeError)) + .reason(value) + .details("Rkyv de/serialization failed") + } +} + +pub trait AssertSuccess +where + Self: Sized, +{ + fn assert_success( + self, + cause: EventType, + ) -> impl std::future::Future> + Send; +} + +impl AssertSuccess for reqwest::Response { + async fn assert_success(self, cause: EventType) -> crate::Result { + let status = self.status(); + if status.is_success() { + Ok(self) + } else { + Err(cause + .ctx(Key::Code, status.as_u16()) + .details("HTTP request failed") + .ctx_opt(Key::Reason, self.text().await.map(CompactString::from).ok())) + } + } +} + +impl FromStr for EventType { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + EventType::parse(s).ok_or(()) + } +} + +impl FromStr for Key { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + Key::parse(s).ok_or(()) + } +} diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs new file mode 100644 index 0000000..cad2503 --- /dev/null +++ b/crates/trc/src/event/enums.rs @@ -0,0 +1,1355 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +pub const TOTAL_EVENT_COUNT: usize = 637; +pub const TOTAL_METRIC_COUNT: usize = 369; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EventType { + Acme(AcmeEvent), + Ai(AiEvent), + Arc(ArcEvent), + Auth(AuthEvent), + Calendar(CalendarEvent), + Cluster(ClusterEvent), + Dane(DaneEvent), + Delivery(DeliveryEvent), + Dkim(DkimEvent), + Dmarc(DmarcEvent), + Dns(DnsEvent), + Eval(EvalEvent), + Http(HttpEvent), + Imap(ImapEvent), + IncomingReport(IncomingReportEvent), + Iprev(IprevEvent), + Jmap(JmapEvent), + Limit(LimitEvent), + MailAuth(MailAuthEvent), + ManageSieve(ManageSieveEvent), + MessageIngest(MessageIngestEvent), + Milter(MilterEvent), + MtaHook(MtaHookEvent), + MtaSts(MtaStsEvent), + Network(NetworkEvent), + OutgoingReport(OutgoingReportEvent), + Pop3(Pop3Event), + PushSubscription(PushSubscriptionEvent), + Queue(QueueEvent), + Registry(RegistryEvent), + Resource(ResourceEvent), + Security(SecurityEvent), + Server(ServerEvent), + Sieve(SieveEvent), + Smtp(SmtpEvent), + Spam(SpamEvent), + Spf(SpfEvent), + Store(StoreEvent), + TaskManager(TaskManagerEvent), + Telemetry(TelemetryEvent), + Tls(TlsEvent), + TlsRpt(TlsRptEvent), + WebDav(WebDavEvent), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AcmeEvent { + AuthStart = 3, + AuthPending = 2, + AuthValid = 5, + AuthCompleted = 0, + AuthError = 1, + AuthTooManyAttempts = 4, + ProcessCert = 22, + OrderStart = 20, + OrderProcessing = 18, + OrderCompleted = 16, + OrderReady = 19, + OrderValid = 21, + OrderInvalid = 17, + RenewBackoff = 23, + ClientSuppliedSni = 7, + ClientMissingSni = 6, + TlsAlpnReceived = 25, + TlsAlpnError = 24, + TokenNotFound = 26, + Error = 15, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AiEvent { + LlmResponse = 556, + ApiError = 557, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ArcEvent { + ChainTooLong = 28, + InvalidInstance = 31, + InvalidCv = 30, + HasHeaderTag = 29, + BrokenChain = 27, + SealerNotFound = 32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum AuthEvent { + Success = 37, + Failed = 35, + TokenExpired = 554, + MfaRequired = 36, + TooManyAttempts = 38, + ClientRegistration = 555, + Error = 34, + Warning = 595, + CredentialExpired = 276, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum CalendarEvent { + RuleExpansionError = 576, + AlarmSent = 579, + AlarmSkipped = 580, + AlarmRecipientOverride = 581, + AlarmFailed = 582, + ItipMessageSent = 583, + ItipMessageReceived = 584, + ItipMessageError = 585, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ClusterEvent { + Startup = 278, + SubscriberStart = 39, + SubscriberStop = 40, + SubscriberError = 41, + SubscriberDisconnected = 42, + PublisherStart = 43, + PublisherStop = 44, + PublisherError = 45, + MessageReceived = 46, + MessageSkipped = 47, + MessageInvalid = 49, + NodeIdRenewed = 275, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DaneEvent { + AuthenticationSuccess = 67, + AuthenticationFailure = 66, + NoCertificatesFound = 69, + CertificateParseError = 68, + TlsaRecordMatch = 73, + TlsaRecordFetch = 70, + TlsaRecordFetchError = 71, + TlsaRecordNotFound = 75, + TlsaRecordNotDnssecSigned = 74, + TlsaRecordInvalid = 72, + BogusDnssecRecord = 605, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DeliveryEvent { + AttemptStart = 77, + AttemptEnd = 76, + Completed = 80, + Failed = 92, + DomainDeliveryStart = 85, + MxLookup = 101, + MxLookupFailed = 102, + IpLookup = 95, + IpLookupFailed = 96, + NullMx = 103, + Connect = 82, + ConnectError = 83, + MissingOutboundHostname = 100, + GreetingFailed = 93, + Ehlo = 90, + EhloRejected = 91, + Auth = 78, + AuthFailed = 79, + MailFrom = 97, + MailFromRejected = 98, + Delivered = 84, + RcptTo = 107, + RcptToRejected = 109, + RcptToFailed = 108, + MessageRejected = 99, + StartTls = 110, + StartTlsUnavailable = 113, + StartTlsError = 112, + StartTlsDisabled = 111, + ImplicitTlsError = 94, + ConcurrencyLimitExceeded = 81, + RateLimitExceeded = 104, + DoubleBounce = 86, + DsnSuccess = 88, + DsnTempFail = 89, + DsnPermFail = 87, + RawInput = 105, + RawOutput = 106, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DkimEvent { + Pass = 121, + Neutral = 119, + Fail = 114, + PermError = 122, + TempError = 127, + None = 120, + UnsupportedVersion = 131, + UnsupportedAlgorithm = 128, + UnsupportedCanonicalization = 129, + UnsupportedKeyType = 130, + FailedBodyHashMatch = 116, + FailedVerification = 117, + FailedAuidMatch = 115, + RevokedPublicKey = 123, + IncompatibleAlgorithms = 118, + SignatureExpired = 124, + SignatureLength = 125, + SignerNotFound = 126, + BuildError = 592, + SignatureCreated = 596, + SignaturePublished = 597, + SignatureRetiring = 598, + SignatureRetired = 599, + SignatureDeleted = 600, + InstanceMissing = 606, + InstanceSyntax = 607, + InstanceTagMissing = 608, + InstanceNotSigned = 609, + InstanceAboveSignature = 610, + SignatureMissing = 611, + SignatureSyntax = 612, + SignatureTagMissing = 613, + SignatureTagUnexpected = 614, + SequenceGap = 615, + SequenceOverflow = 616, + ChainTooLong = 635, + MailFromMismatch = 617, + RcptToMismatch = 618, + MailFromDomainMismatch = 619, + NextDomainMismatch = 620, + CustodyBreak = 636, + PublicKeyFetch = 621, + PublicKeyMissing = 622, + PublicKeyMultiple = 623, + PublicKeySyntax = 624, + PublicKeyAlgorithmMismatch = 625, + NoValidAlgorithm = 626, + HeaderHashMismatch = 627, + Modified = 628, + Exploded = 629, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DmarcEvent { + Pass = 134, + Fail = 132, + PermError = 135, + TempError = 136, + None = 133, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum DnsEvent { + RecordCreated = 8, + RecordCreationFailed = 9, + RecordDeletionFailed = 10, + RecordNotPropagated = 12, + RecordLookupFailed = 11, + RecordPropagated = 13, + RecordPropagationTimeout = 14, + BuildError = 591, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum EvalEvent { + Result = 139, + Error = 138, + DirectoryNotFound = 137, + StoreNotFound = 140, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum HttpEvent { + ConnectionStart = 153, + ConnectionEnd = 152, + Error = 154, + RequestUrl = 156, + RequestBody = 155, + ResponseBody = 157, + XForwardedMissing = 158, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ImapEvent { + ConnectionStart = 163, + ConnectionEnd = 162, + GetAcl = 171, + SetAcl = 188, + MyRights = 180, + ListRights = 176, + Append = 159, + Capabilities = 160, + Id = 172, + Close = 161, + Copy = 164, + Move = 179, + CreateMailbox = 165, + DeleteMailbox = 166, + RenameMailbox = 185, + Enable = 167, + Expunge = 169, + Fetch = 170, + IdleStart = 173, + IdleStop = 174, + List = 175, + Lsub = 178, + Logout = 177, + Namespace = 181, + Noop = 182, + Search = 186, + Sort = 189, + Select = 187, + Status = 190, + Store = 191, + Subscribe = 192, + Unsubscribe = 194, + Thread = 193, + UidBatches = 634, + GetQuota = 57, + Error = 168, + RawInput = 183, + RawOutput = 184, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum IncomingReportEvent { + DmarcReport = 200, + DmarcReportWithWarnings = 201, + TlsReport = 206, + TlsReportWithWarnings = 207, + AbuseReport = 195, + AuthFailureReport = 197, + FraudReport = 202, + NotSpamReport = 204, + VirusReport = 209, + OtherReport = 205, + MessageParseFailed = 203, + DmarcParseFailed = 199, + TlsRpcParseFailed = 208, + ArfParseFailed = 196, + DecompressError = 198, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum IprevEvent { + Pass = 212, + Fail = 210, + PermError = 213, + TempError = 214, + None = 211, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum JmapEvent { + MethodCall = 223, + InvalidArguments = 221, + RequestTooLarge = 227, + StateMismatch = 228, + AnchorNotFound = 218, + UnsupportedFilter = 232, + UnsupportedSort = 233, + UnknownMethod = 231, + InvalidResultReference = 222, + Forbidden = 220, + AccountNotFound = 215, + AccountNotSupportedByMethod = 216, + AccountReadOnly = 217, + NotFound = 224, + CannotCalculateChanges = 219, + UnknownDataType = 230, + UnknownCapability = 229, + NotJson = 225, + NotRequest = 226, + WebsocketStart = 235, + WebsocketStop = 236, + WebsocketError = 234, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum LimitEvent { + SizeRequest = 243, + SizeUpload = 244, + CallsIn = 238, + ConcurrentRequest = 240, + ConcurrentUpload = 241, + ConcurrentConnection = 239, + Quota = 242, + BlobQuota = 237, + TenantQuota = 553, + TooManyRequests = 245, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MailAuthEvent { + ParseError = 254, + MissingParameters = 252, + NoHeadersFound = 253, + Crypto = 247, + Io = 251, + Base64 = 246, + DnsError = 248, + DnsRecordNotFound = 250, + DnsInvalidRecordType = 249, + PolicyNotAligned = 255, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ManageSieveEvent { + ConnectionStart = 259, + ConnectionEnd = 258, + CreateScript = 260, + UpdateScript = 274, + GetScript = 263, + DeleteScript = 261, + RenameScript = 270, + CheckScript = 257, + HaveSpace = 264, + ListScripts = 265, + SetActive = 271, + Capabilities = 256, + StartTls = 272, + Unauthenticate = 273, + Logout = 266, + Noop = 267, + Error = 262, + RawInput = 268, + RawOutput = 269, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MessageIngestEvent { + Ham = 283, + Spam = 286, + ImapAppend = 284, + JmapAppend = 285, + Duplicate = 281, + Error = 282, + SearchIndex = 142, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MilterEvent { + Read = 299, + Write = 303, + ActionAccept = 287, + ActionDiscard = 289, + ActionReject = 290, + ActionTempFail = 293, + ActionReplyCode = 291, + ActionConnectionFailure = 288, + ActionShutdown = 292, + IoError = 297, + FrameTooLarge = 296, + FrameInvalid = 295, + UnexpectedResponse = 302, + Timeout = 300, + TlsInvalidName = 301, + Disconnected = 294, + ParseError = 298, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaHookEvent { + ActionAccept = 304, + ActionDiscard = 305, + ActionReject = 307, + ActionQuarantine = 306, + Error = 308, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MtaStsEvent { + Authorized = 309, + NotAuthorized = 311, + PolicyFetch = 312, + PolicyNotFound = 314, + PolicyFetchError = 313, + InvalidPolicy = 310, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum NetworkEvent { + ListenStart = 321, + ListenStop = 322, + ListenError = 320, + BindError = 316, + ReadError = 324, + WriteError = 328, + FlushError = 319, + AcceptError = 315, + SplitError = 326, + Timeout = 327, + Closed = 317, + ProxyError = 323, + SetOptError = 325, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum OutgoingReportEvent { + SpfReport = 342, + SpfRateLimited = 341, + DkimReport = 330, + DkimRateLimited = 329, + DmarcReport = 333, + DmarcRateLimited = 332, + DmarcAggregateReport = 331, + TlsAggregate = 344, + HttpSubmission = 334, + UnauthorizedReportingAddress = 345, + ReportingAddressValidationError = 340, + NotFound = 339, + SubmissionError = 343, + NoRecipientsFound = 338, + Locked = 337, + MaxSizeExceeded = 59, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Pop3Event { + ConnectionStart = 348, + ConnectionEnd = 347, + Delete = 349, + Reset = 358, + Quit = 355, + Fetch = 351, + List = 352, + ListMessage = 353, + Uidl = 361, + UidlMessage = 362, + Stat = 360, + Noop = 354, + Capabilities = 346, + StartTls = 359, + Utf8 = 363, + Error = 350, + RawInput = 356, + RawOutput = 357, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum PushSubscriptionEvent { + Success = 373, + Error = 371, + NotFound = 372, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum QueueEvent { + Started = 390, + MessageQueued = 380, + AuthenticatedMessageQueued = 381, + ReportQueued = 382, + DsnQueued = 379, + AutogeneratedQueued = 378, + Rescheduled = 385, + Locked = 377, + BlobNotFound = 374, + RateLimitExceeded = 384, + ConcurrencyLimitExceeded = 375, + QuotaExceeded = 383, + BackPressure = 48, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum RegistryEvent { + LocalReadError = 62, + LocalWriteError = 54, + LocalParseError = 60, + ReadError = 65, + WriteError = 58, + DeserializationError = 56, + BuildError = 61, + BuildWarning = 55, + NotSupported = 64, + ValidationError = 63, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ResourceEvent { + NotFound = 389, + BadParameters = 386, + Error = 388, + DownloadExternal = 387, + ApplicationUpdated = 601, + ApplicationUnpacked = 602, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SecurityEvent { + AuthenticationBan = 33, + AbuseBan = 549, + ScanBan = 558, + LoiterBan = 550, + IpBlocked = 318, + IpBlockExpired = 593, + IpAllowExpired = 594, + IpUnauthorized = 279, + Unauthorized = 552, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ServerEvent { + Startup = 393, + Shutdown = 392, + StartupError = 394, + ThreadError = 395, + Licensing = 391, + RecoveryMode = 603, + BootstrapMode = 604, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SieveEvent { + ActionAccept = 396, + ActionAcceptReplace = 397, + ActionDiscard = 398, + ActionReject = 399, + SendMessage = 406, + MessageTooLarge = 401, + ScriptNotFound = 405, + ListNotFound = 400, + RuntimeError = 404, + UnexpectedError = 407, + NotSupported = 402, + QuotaExceeded = 403, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SmtpEvent { + ConnectionStart = 417, + ConnectionEnd = 416, + Error = 428, + IdNotFound = 469, + ConcurrencyLimitExceeded = 415, + TransferLimitExceeded = 485, + RateLimitExceeded = 461, + TimeLimitExceeded = 481, + MissingAuthDirectory = 452, + MessageParseFailed = 450, + MessageTooLarge = 451, + LoopDetected = 443, + DkimPass = 422, + DkimFail = 421, + Dkim2Pass = 630, + Dkim2Fail = 631, + Dkim2DsnDiscarded = 632, + ArcPass = 410, + ArcFail = 409, + SpfEhloPass = 474, + SpfEhloFail = 473, + SpfFromPass = 476, + SpfFromFail = 475, + DmarcPass = 424, + DmarcFail = 423, + IprevPass = 441, + IprevFail = 440, + TooManyMessages = 483, + Ehlo = 426, + InvalidEhlo = 436, + DidNotSayEhlo = 420, + EhloExpected = 427, + LhloExpected = 442, + MailFromUnauthenticated = 447, + MailFromUnauthorized = 448, + MailFromNotAllowed = 551, + MailFromRewritten = 446, + MailFromMissing = 445, + MailFrom = 444, + MultipleMailFrom = 456, + MailboxDoesNotExist = 449, + RelayNotAllowed = 468, + RcptTo = 464, + RcptToDuplicate = 465, + RcptToRewritten = 467, + RcptToMissing = 466, + RcptToGreylisted = 561, + TooManyRecipients = 484, + TooManyInvalidRcpt = 482, + RawInput = 462, + RawOutput = 463, + MissingLocalHostname = 453, + Vrfy = 487, + VrfyNotFound = 489, + VrfyDisabled = 488, + Expn = 429, + ExpnNotFound = 431, + ExpnDisabled = 430, + RequireTlsDisabled = 471, + DeliverByDisabled = 418, + DeliverByInvalid = 419, + FutureReleaseDisabled = 432, + FutureReleaseInvalid = 433, + MtPriorityDisabled = 454, + MtPriorityInvalid = 455, + DsnDisabled = 425, + AuthNotAllowed = 413, + AuthMechanismNotSupported = 412, + AuthExchangeTooLong = 411, + AlreadyAuthenticated = 408, + Noop = 457, + StartTls = 477, + StartTlsUnavailable = 479, + StartTlsAlready = 478, + Rset = 472, + Quit = 460, + Help = 434, + CommandNotImplemented = 414, + InvalidCommand = 435, + InvalidSenderAddress = 439, + InvalidRecipientAddress = 438, + InvalidParameter = 437, + UnsupportedParameter = 486, + SyntaxError = 480, + RequestTooLarge = 470, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpamEvent { + Pyzor = 564, + PyzorError = 494, + Dnsbl = 562, + DnsblError = 563, + TrainStarted = 588, + TrainCompleted = 495, + TrainSampleAdded = 143, + TrainSampleNotFound = 491, + Classify = 490, + ModelLoaded = 589, + ModelNotReady = 496, + ModelNotFound = 497, + RulesUpdated = 280, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum SpfEvent { + Pass = 501, + Fail = 498, + SoftFail = 503, + Neutral = 499, + TempError = 504, + PermError = 502, + None = 500, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum StoreEvent { + AssertValueFailed = 505, + FoundationdbError = 518, + MysqlError = 522, + PostgresqlError = 527, + RocksdbError = 529, + SqliteError = 532, + LdapError = 520, + ElasticsearchError = 516, + MeilisearchError = 590, + RedisError = 528, + S3Error = 530, + AzureError = 559, + FilesystemError = 517, + PoolError = 526, + DataCorruption = 511, + DecompressError = 514, + DeserializeError = 515, + NotFound = 524, + NotConfigured = 523, + NotSupported = 525, + UnexpectedError = 533, + CryptoError = 510, + HttpStoreError = 493, + CacheMiss = 50, + CacheHit = 51, + CacheStale = 52, + CacheUpdate = 577, + CacheEntryTooLarge = 633, + BlobMissingMarker = 507, + DataWrite = 513, + DataIterate = 512, + BlobRead = 508, + BlobWrite = 509, + BlobDelete = 506, + SqlQuery = 531, + LdapQuery = 521, + LdapWarning = 519, + HttpStoreFetch = 492, + AutoExpunge = 364, + BlobStorePurged = 369, + DataStorePurged = 368, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TaskManagerEvent { + TaskAcquired = 578, + TaskQueued = 149, + TaskScheduled = 370, + TaskLocked = 144, + TaskIgnored = 586, + TaskFailed = 587, + TaskRetry = 53, + BlobNotFound = 141, + MetadataNotFound = 145, + SchedulerStarted = 150, + ManagerStarted = 367, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TelemetryEvent { + AlertEvent = 548, + AlertMessage = 365, + LogError = 535, + WebhookError = 539, + OtelExporterError = 536, + OtelMetricsExporterError = 537, + PrometheusExporterError = 538, + JournalError = 534, + MetricsCollected = 151, + MetricsStored = 366, + MetricsPushed = 146, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsEvent { + Handshake = 543, + HandshakeError = 544, + NotConfigured = 547, + CertificateNotFound = 542, + NoCertificatesAvailable = 546, + MultipleCertificatesAvailable = 545, + ExpiredCertificateRemoved = 277, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum TlsRptEvent { + RecordFetch = 540, + RecordFetchError = 541, + RecordNotFound = 560, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum WebDavEvent { + Propfind = 147, + Proppatch = 148, + Get = 335, + Head = 574, + Report = 336, + Mkcol = 376, + Mkcalendar = 575, + Delete = 458, + Put = 459, + Post = 565, + Patch = 566, + Copy = 567, + Move = 568, + Lock = 569, + Unlock = 570, + Acl = 571, + Options = 573, + Error = 572, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum MetricType { + AcmeAuthError = 27, + AcmeAuthTooManyAttempts = 28, + AcmeOrderCompleted = 29, + AcmeOrderInvalid = 30, + AcmeClientMissingSni = 31, + AcmeTlsAlpnError = 32, + AcmeTokenNotFound = 33, + AcmeError = 34, + ArcChainTooLong = 39, + ArcInvalidInstance = 40, + ArcInvalidCv = 41, + ArcHasHeaderTag = 42, + ArcBrokenChain = 43, + AuthSuccess = 44, + AuthFailed = 45, + AuthTooManyAttempts = 46, + AuthError = 47, + CalendarAlarmSent = 48, + CalendarAlarmFailed = 49, + CalendarItipMessageSent = 50, + CalendarItipMessageReceived = 51, + CalendarItipMessageError = 52, + ClusterSubscriberError = 53, + ClusterSubscriberDisconnected = 54, + ClusterPublisherError = 55, + DaneAuthenticationSuccess = 56, + DaneAuthenticationFailure = 57, + DaneNoCertificatesFound = 58, + DaneCertificateParseError = 59, + DaneTlsaRecordFetchError = 60, + DaneTlsaRecordNotFound = 61, + DaneTlsaRecordNotDnssecSigned = 62, + DaneTlsaRecordInvalid = 63, + DaneBogusDnssecRecord = 339, + DeliveryTotalTime = 2, + DeliveryAttemptTime = 3, + DeliveryActiveConnections = 22, + DeliveryAttemptStart = 64, + DeliveryAttemptEnd = 65, + DeliveryCompleted = 66, + DeliveryMxLookupFailed = 67, + DeliveryIpLookupFailed = 68, + DeliveryNullMx = 69, + DeliveryGreetingFailed = 70, + DeliveryEhloRejected = 71, + DeliveryAuthFailed = 72, + DeliveryMailFromRejected = 73, + DeliveryDelivered = 74, + DeliveryRcptToRejected = 75, + DeliveryRcptToFailed = 76, + DeliveryMessageRejected = 77, + DeliveryStartTlsUnavailable = 78, + DeliveryStartTlsError = 79, + DeliveryStartTlsDisabled = 80, + DeliveryImplicitTlsError = 81, + DeliveryConcurrencyLimitExceeded = 82, + DeliveryRateLimitExceeded = 83, + DeliveryDoubleBounce = 84, + DeliveryDsnSuccess = 85, + DeliveryDsnTempFail = 86, + DeliveryDsnPermFail = 87, + DkimPass = 88, + DkimNeutral = 89, + DkimFail = 90, + DkimPermError = 91, + DkimTempError = 92, + DkimNone = 93, + DkimUnsupportedVersion = 94, + DkimUnsupportedAlgorithm = 95, + DkimUnsupportedCanonicalization = 96, + DkimUnsupportedKeyType = 97, + DkimFailedBodyHashMatch = 98, + DkimFailedVerification = 99, + DkimFailedAuidMatch = 100, + DkimRevokedPublicKey = 101, + DkimIncompatibleAlgorithms = 102, + DkimSignatureExpired = 103, + DkimSignatureLength = 104, + DkimSignerNotFound = 105, + DkimInstanceMissing = 340, + DkimInstanceSyntax = 341, + DkimInstanceTagMissing = 342, + DkimInstanceNotSigned = 343, + DkimInstanceAboveSignature = 344, + DkimSignatureMissing = 345, + DkimSignatureSyntax = 346, + DkimSignatureTagMissing = 347, + DkimSignatureTagUnexpected = 348, + DkimSequenceGap = 349, + DkimSequenceOverflow = 350, + DkimChainTooLong = 367, + DkimMailFromMismatch = 351, + DkimRcptToMismatch = 352, + DkimMailFromDomainMismatch = 353, + DkimNextDomainMismatch = 354, + DkimCustodyBreak = 368, + DkimPublicKeyFetch = 355, + DkimPublicKeyMissing = 356, + DkimPublicKeyMultiple = 357, + DkimPublicKeySyntax = 358, + DkimPublicKeyAlgorithmMismatch = 359, + DkimNoValidAlgorithm = 360, + DkimHeaderHashMismatch = 361, + DkimModified = 362, + DkimExploded = 363, + DmarcPass = 106, + DmarcFail = 107, + DmarcPermError = 108, + DmarcTempError = 109, + DmarcNone = 110, + DnsLookupTime = 11, + DnsRecordCreationFailed = 35, + DnsRecordDeletionFailed = 36, + DnsRecordLookupFailed = 37, + DnsRecordPropagationTimeout = 38, + DomainCount = 26, + EvalError = 111, + EvalDirectoryNotFound = 112, + EvalStoreNotFound = 113, + HttpRequestTime = 12, + HttpActiveConnections = 17, + HttpConnectionStart = 337, + HttpError = 114, + HttpRequestBody = 115, + HttpResponseBody = 116, + HttpXForwardedMissing = 117, + ImapRequestTime = 13, + ImapActiveConnections = 18, + ImapConnectionStart = 118, + ImapConnectionEnd = 119, + IncomingReportDmarcReport = 120, + IncomingReportDmarcReportWithWarnings = 121, + IncomingReportTlsReport = 122, + IncomingReportTlsReportWithWarnings = 123, + IncomingReportAbuseReport = 124, + IncomingReportAuthFailureReport = 125, + IncomingReportFraudReport = 126, + IncomingReportNotSpamReport = 127, + IncomingReportVirusReport = 128, + IncomingReportOtherReport = 129, + IncomingReportMessageParseFailed = 130, + IncomingReportDmarcParseFailed = 131, + IncomingReportTlsRpcParseFailed = 132, + IncomingReportArfParseFailed = 133, + IncomingReportDecompressError = 134, + IprevPass = 135, + IprevFail = 136, + IprevPermError = 137, + IprevTempError = 138, + IprevNone = 139, + JmapMethodCall = 140, + JmapInvalidArguments = 141, + JmapRequestTooLarge = 142, + JmapUnsupportedFilter = 143, + JmapUnsupportedSort = 144, + JmapUnknownMethod = 145, + JmapForbidden = 146, + JmapNotJson = 147, + JmapNotRequest = 148, + JmapWebsocketStart = 149, + JmapWebsocketError = 150, + LimitSizeRequest = 151, + LimitSizeUpload = 152, + LimitCallsIn = 153, + LimitConcurrentRequest = 154, + LimitConcurrentUpload = 155, + LimitConcurrentConnection = 156, + LimitQuota = 157, + LimitBlobQuota = 158, + LimitTenantQuota = 159, + LimitTooManyRequests = 160, + MailAuthParseError = 161, + MailAuthMissingParameters = 162, + MailAuthNoHeadersFound = 163, + MailAuthCrypto = 164, + MailAuthIo = 165, + MailAuthBase64 = 166, + MailAuthDnsError = 167, + MailAuthDnsRecordNotFound = 168, + MailAuthDnsInvalidRecordType = 169, + MailAuthPolicyNotAligned = 170, + ManageSieveConnectionStart = 171, + ManageSieveConnectionEnd = 172, + MessageSize = 4, + MessageAuthenticatedSize = 5, + MessageIngestTime = 0, + MessageIngestIndexTime = 1, + MessageIngestHam = 173, + MessageIngestSpam = 174, + MessageIngestImapAppend = 175, + MessageIngestJmapAppend = 176, + MessageIngestDuplicate = 177, + MessageIngestError = 178, + MessageIngestSearchIndex = 179, + MilterActionAccept = 180, + MilterActionDiscard = 181, + MilterActionReject = 182, + MilterActionTempFail = 183, + MilterActionReplyCode = 184, + MilterActionConnectionFailure = 185, + MilterActionShutdown = 186, + MtaHookActionAccept = 187, + MtaHookActionDiscard = 188, + MtaHookActionReject = 189, + MtaHookActionQuarantine = 190, + MtaHookError = 191, + MtaStsAuthorized = 192, + MtaStsNotAuthorized = 193, + MtaStsInvalidPolicy = 194, + NetworkTimeout = 195, + OutgoingReportSize = 6, + OutgoingReportSpfReport = 196, + OutgoingReportSpfRateLimited = 197, + OutgoingReportDkimReport = 198, + OutgoingReportDkimRateLimited = 199, + OutgoingReportDmarcReport = 200, + OutgoingReportDmarcRateLimited = 201, + OutgoingReportDmarcAggregateReport = 202, + OutgoingReportTlsAggregate = 203, + OutgoingReportHttpSubmission = 204, + OutgoingReportUnauthorizedReportingAddress = 205, + OutgoingReportReportingAddressValidationError = 206, + OutgoingReportNotFound = 207, + OutgoingReportSubmissionError = 208, + OutgoingReportNoRecipientsFound = 209, + Pop3RequestTime = 14, + Pop3ActiveConnections = 19, + Pop3ConnectionStart = 210, + Pop3ConnectionEnd = 211, + PushSubscriptionSuccess = 213, + PushSubscriptionError = 214, + PushSubscriptionNotFound = 215, + QueueCount = 24, + QueueMessageQueued = 216, + QueueAuthenticatedMessageQueued = 217, + QueueReportQueued = 218, + QueueDsnQueued = 219, + QueueAutogeneratedQueued = 220, + QueueRescheduled = 221, + QueueBlobNotFound = 222, + QueueRateLimitExceeded = 223, + QueueConcurrencyLimitExceeded = 224, + QueueQuotaExceeded = 225, + ResourceNotFound = 226, + ResourceBadParameters = 227, + ResourceError = 228, + SecurityAuthenticationBan = 229, + SecurityAbuseBan = 230, + SecurityScanBan = 231, + SecurityLoiterBan = 232, + SecurityIpBlocked = 233, + SecurityUnauthorized = 234, + ServerMemory = 23, + ServerThreadError = 235, + SieveRequestTime = 16, + SieveActiveConnections = 21, + SieveActionAccept = 236, + SieveActionAcceptReplace = 237, + SieveActionDiscard = 238, + SieveActionReject = 239, + SieveSendMessage = 240, + SieveMessageTooLarge = 241, + SieveRuntimeError = 242, + SieveUnexpectedError = 243, + SieveNotSupported = 244, + SieveQuotaExceeded = 245, + SmtpRequestTime = 15, + SmtpActiveConnections = 20, + SmtpConnectionStart = 246, + SmtpConnectionEnd = 247, + SmtpError = 248, + SmtpConcurrencyLimitExceeded = 249, + SmtpTransferLimitExceeded = 250, + SmtpRateLimitExceeded = 251, + SmtpTimeLimitExceeded = 252, + SmtpMessageParseFailed = 253, + SmtpMessageTooLarge = 254, + SmtpLoopDetected = 255, + SmtpDkimPass = 256, + SmtpDkimFail = 257, + SmtpDkim2Pass = 364, + SmtpDkim2Fail = 365, + SmtpDkim2DsnDiscarded = 366, + SmtpArcPass = 258, + SmtpArcFail = 259, + SmtpSpfEhloPass = 260, + SmtpSpfEhloFail = 261, + SmtpSpfFromPass = 262, + SmtpSpfFromFail = 263, + SmtpDmarcPass = 264, + SmtpDmarcFail = 265, + SmtpIprevPass = 266, + SmtpIprevFail = 267, + SmtpTooManyMessages = 268, + SmtpInvalidEhlo = 269, + SmtpDidNotSayEhlo = 270, + SmtpMailFromUnauthenticated = 271, + SmtpMailFromUnauthorized = 272, + SmtpMailFromMissing = 273, + SmtpMultipleMailFrom = 274, + SmtpMailboxDoesNotExist = 275, + SmtpRelayNotAllowed = 276, + SmtpRcptToDuplicate = 277, + SmtpRcptToMissing = 278, + SmtpTooManyRecipients = 279, + SmtpTooManyInvalidRcpt = 280, + SmtpAuthMechanismNotSupported = 281, + SmtpAuthExchangeTooLong = 282, + SmtpCommandNotImplemented = 283, + SmtpInvalidCommand = 284, + SmtpSyntaxError = 285, + SmtpRequestTooLarge = 286, + SpamPyzorError = 287, + SpamDnsblError = 288, + SpamTrainCompleted = 289, + SpamTrainSampleAdded = 290, + SpamClassify = 291, + SpamModelNotReady = 292, + SpfPass = 293, + SpfFail = 294, + SpfSoftFail = 295, + SpfNeutral = 296, + SpfTempError = 297, + SpfPermError = 298, + SpfNone = 299, + StoreDataReadTime = 7, + StoreDataWriteTime = 8, + StoreBlobReadTime = 9, + StoreBlobWriteTime = 10, + StoreAssertValueFailed = 300, + StoreFoundationdbError = 301, + StoreMysqlError = 302, + StorePostgresqlError = 303, + StoreRocksdbError = 304, + StoreSqliteError = 305, + StoreLdapError = 306, + StoreElasticsearchError = 307, + StoreRedisError = 308, + StoreS3Error = 309, + StoreAzureError = 310, + StoreFilesystemError = 311, + StorePoolError = 312, + StoreDataCorruption = 313, + StoreDecompressError = 314, + StoreDeserializeError = 315, + StoreNotFound = 316, + StoreNotConfigured = 317, + StoreNotSupported = 318, + StoreUnexpectedError = 319, + StoreCryptoError = 320, + StoreHttpStoreError = 321, + StoreBlobMissingMarker = 322, + StoreDataWrite = 323, + StoreDataIterate = 324, + StoreBlobRead = 325, + StoreBlobWrite = 326, + StoreBlobDelete = 327, + TaskManagerBlobNotFound = 328, + TaskManagerMetadataNotFound = 329, + TelemetryAlertEvent = 338, + TelemetryAlertMessage = 212, + TelemetryLogError = 330, + TelemetryWebhookError = 331, + TelemetryOtelExporterError = 332, + TelemetryOtelMetricsExporterError = 333, + TelemetryPrometheusExporterError = 334, + TelemetryJournalError = 335, + TlsHandshakeError = 336, + UserCount = 25, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum Key { + #[default] + AccountName = 0, + AccountId = 1, + BlobId = 2, + CausedBy = 3, + ChangeId = 4, + Code = 5, + Collection = 6, + Contents = 7, + Details = 8, + DkimFail = 9, + DkimNone = 10, + DkimPass = 11, + DmarcNone = 12, + DmarcPass = 13, + DmarcQuarantine = 14, + DmarcReject = 15, + DocumentId = 16, + Domain = 17, + Due = 18, + Elapsed = 19, + Expires = 20, + From = 21, + Hostname = 22, + Id = 23, + Key = 24, + Limit = 25, + ListenerId = 26, + LocalIp = 27, + LocalPort = 28, + MailboxName = 29, + MailboxId = 30, + MessageId = 31, + NextDsn = 32, + NextRetry = 33, + Path = 34, + Policy = 35, + QueueId = 36, + RangeFrom = 37, + RangeTo = 38, + Reason = 39, + RemoteIp = 40, + RemotePort = 41, + ReportId = 42, + Result = 43, + Size = 44, + Source = 45, + SpanId = 46, + SpfFail = 47, + SpfNone = 48, + SpfPass = 49, + Strict = 50, + Tls = 51, + To = 52, + Total = 53, + TotalFailures = 54, + TotalSuccesses = 55, + Type = 56, + Uid = 57, + UidNext = 58, + UidValidity = 59, + Url = 60, + ValidFrom = 61, + ValidTo = 62, + Value = 63, + Version = 64, + QueueName = 65, +} diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs new file mode 100644 index 0000000..e84c5ab --- /dev/null +++ b/crates/trc/src/event/enums_impl.rs @@ -0,0 +1,8063 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +// This file is auto-generated. Do not edit directly. + +use crate::{Level, event::enums::*}; +use std::borrow::Cow; + +impl EventType { + pub fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + EventType, + b"acme.auth-start" => EventType::Acme(AcmeEvent::AuthStart), + b"acme.auth-pending" => EventType::Acme(AcmeEvent::AuthPending), + b"acme.auth-valid" => EventType::Acme(AcmeEvent::AuthValid), + b"acme.auth-completed" => EventType::Acme(AcmeEvent::AuthCompleted), + b"acme.auth-error" => EventType::Acme(AcmeEvent::AuthError), + b"acme.auth-too-many-attempts" => EventType::Acme(AcmeEvent::AuthTooManyAttempts), + b"acme.process-cert" => EventType::Acme(AcmeEvent::ProcessCert), + b"acme.order-start" => EventType::Acme(AcmeEvent::OrderStart), + b"acme.order-processing" => EventType::Acme(AcmeEvent::OrderProcessing), + b"acme.order-completed" => EventType::Acme(AcmeEvent::OrderCompleted), + b"acme.order-ready" => EventType::Acme(AcmeEvent::OrderReady), + b"acme.order-valid" => EventType::Acme(AcmeEvent::OrderValid), + b"acme.order-invalid" => EventType::Acme(AcmeEvent::OrderInvalid), + b"acme.renew-backoff" => EventType::Acme(AcmeEvent::RenewBackoff), + b"acme.client-supplied-sni" => EventType::Acme(AcmeEvent::ClientSuppliedSni), + b"acme.client-missing-sni" => EventType::Acme(AcmeEvent::ClientMissingSni), + b"acme.tls-alpn-received" => EventType::Acme(AcmeEvent::TlsAlpnReceived), + b"acme.tls-alpn-error" => EventType::Acme(AcmeEvent::TlsAlpnError), + b"acme.token-not-found" => EventType::Acme(AcmeEvent::TokenNotFound), + b"acme.error" => EventType::Acme(AcmeEvent::Error), + b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse), + b"ai.api-error" => EventType::Ai(AiEvent::ApiError), + b"arc.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong), + b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance), + b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv), + b"arc.has-header-tag" => EventType::Arc(ArcEvent::HasHeaderTag), + b"arc.broken-chain" => EventType::Arc(ArcEvent::BrokenChain), + b"arc.sealer-not-found" => EventType::Arc(ArcEvent::SealerNotFound), + b"auth.success" => EventType::Auth(AuthEvent::Success), + b"auth.failed" => EventType::Auth(AuthEvent::Failed), + b"auth.token-expired" => EventType::Auth(AuthEvent::TokenExpired), + b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired), + b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), + b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), + b"auth.error" => EventType::Auth(AuthEvent::Error), + b"auth.warning" => EventType::Auth(AuthEvent::Warning), + b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired), + b"calendar.rule-expansion-error" => EventType::Calendar(CalendarEvent::RuleExpansionError), + b"calendar.alarm-sent" => EventType::Calendar(CalendarEvent::AlarmSent), + b"calendar.alarm-skipped" => EventType::Calendar(CalendarEvent::AlarmSkipped), + b"calendar.alarm-recipient-override" => EventType::Calendar(CalendarEvent::AlarmRecipientOverride), + b"calendar.alarm-failed" => EventType::Calendar(CalendarEvent::AlarmFailed), + b"calendar.itip-message-sent" => EventType::Calendar(CalendarEvent::ItipMessageSent), + b"calendar.itip-message-received" => EventType::Calendar(CalendarEvent::ItipMessageReceived), + b"calendar.itip-message-error" => EventType::Calendar(CalendarEvent::ItipMessageError), + b"cluster.startup" => EventType::Cluster(ClusterEvent::Startup), + b"cluster.subscriber-start" => EventType::Cluster(ClusterEvent::SubscriberStart), + b"cluster.subscriber-stop" => EventType::Cluster(ClusterEvent::SubscriberStop), + b"cluster.subscriber-error" => EventType::Cluster(ClusterEvent::SubscriberError), + b"cluster.subscriber-disconnected" => EventType::Cluster(ClusterEvent::SubscriberDisconnected), + b"cluster.publisher-start" => EventType::Cluster(ClusterEvent::PublisherStart), + b"cluster.publisher-stop" => EventType::Cluster(ClusterEvent::PublisherStop), + b"cluster.publisher-error" => EventType::Cluster(ClusterEvent::PublisherError), + b"cluster.message-received" => EventType::Cluster(ClusterEvent::MessageReceived), + b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped), + b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid), + b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed), + b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess), + b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure), + b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound), + b"dane.certificate-parse-error" => EventType::Dane(DaneEvent::CertificateParseError), + b"dane.tlsa-record-match" => EventType::Dane(DaneEvent::TlsaRecordMatch), + b"dane.tlsa-record-fetch" => EventType::Dane(DaneEvent::TlsaRecordFetch), + b"dane.tlsa-record-fetch-error" => EventType::Dane(DaneEvent::TlsaRecordFetchError), + b"dane.tlsa-record-not-found" => EventType::Dane(DaneEvent::TlsaRecordNotFound), + b"dane.tlsa-record-not-dnssec-signed" => EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned), + b"dane.tlsa-record-invalid" => EventType::Dane(DaneEvent::TlsaRecordInvalid), + b"dane.bogus-dnssec-record" => EventType::Dane(DaneEvent::BogusDnssecRecord), + b"delivery.attempt-start" => EventType::Delivery(DeliveryEvent::AttemptStart), + b"delivery.attempt-end" => EventType::Delivery(DeliveryEvent::AttemptEnd), + b"delivery.completed" => EventType::Delivery(DeliveryEvent::Completed), + b"delivery.failed" => EventType::Delivery(DeliveryEvent::Failed), + b"delivery.domain-delivery-start" => EventType::Delivery(DeliveryEvent::DomainDeliveryStart), + b"delivery.mx-lookup" => EventType::Delivery(DeliveryEvent::MxLookup), + b"delivery.mx-lookup-failed" => EventType::Delivery(DeliveryEvent::MxLookupFailed), + b"delivery.ip-lookup" => EventType::Delivery(DeliveryEvent::IpLookup), + b"delivery.ip-lookup-failed" => EventType::Delivery(DeliveryEvent::IpLookupFailed), + b"delivery.null-mx" => EventType::Delivery(DeliveryEvent::NullMx), + b"delivery.connect" => EventType::Delivery(DeliveryEvent::Connect), + b"delivery.connect-error" => EventType::Delivery(DeliveryEvent::ConnectError), + b"delivery.missing-outbound-hostname" => EventType::Delivery(DeliveryEvent::MissingOutboundHostname), + b"delivery.greeting-failed" => EventType::Delivery(DeliveryEvent::GreetingFailed), + b"delivery.ehlo" => EventType::Delivery(DeliveryEvent::Ehlo), + b"delivery.ehlo-rejected" => EventType::Delivery(DeliveryEvent::EhloRejected), + b"delivery.auth" => EventType::Delivery(DeliveryEvent::Auth), + b"delivery.auth-failed" => EventType::Delivery(DeliveryEvent::AuthFailed), + b"delivery.mail-from" => EventType::Delivery(DeliveryEvent::MailFrom), + b"delivery.mail-from-rejected" => EventType::Delivery(DeliveryEvent::MailFromRejected), + b"delivery.delivered" => EventType::Delivery(DeliveryEvent::Delivered), + b"delivery.rcpt-to" => EventType::Delivery(DeliveryEvent::RcptTo), + b"delivery.rcpt-to-rejected" => EventType::Delivery(DeliveryEvent::RcptToRejected), + b"delivery.rcpt-to-failed" => EventType::Delivery(DeliveryEvent::RcptToFailed), + b"delivery.message-rejected" => EventType::Delivery(DeliveryEvent::MessageRejected), + b"delivery.start-tls" => EventType::Delivery(DeliveryEvent::StartTls), + b"delivery.start-tls-unavailable" => EventType::Delivery(DeliveryEvent::StartTlsUnavailable), + b"delivery.start-tls-error" => EventType::Delivery(DeliveryEvent::StartTlsError), + b"delivery.start-tls-disabled" => EventType::Delivery(DeliveryEvent::StartTlsDisabled), + b"delivery.implicit-tls-error" => EventType::Delivery(DeliveryEvent::ImplicitTlsError), + b"delivery.concurrency-limit-exceeded" => EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded), + b"delivery.rate-limit-exceeded" => EventType::Delivery(DeliveryEvent::RateLimitExceeded), + b"delivery.double-bounce" => EventType::Delivery(DeliveryEvent::DoubleBounce), + b"delivery.dsn-success" => EventType::Delivery(DeliveryEvent::DsnSuccess), + b"delivery.dsn-temp-fail" => EventType::Delivery(DeliveryEvent::DsnTempFail), + b"delivery.dsn-perm-fail" => EventType::Delivery(DeliveryEvent::DsnPermFail), + b"delivery.raw-input" => EventType::Delivery(DeliveryEvent::RawInput), + b"delivery.raw-output" => EventType::Delivery(DeliveryEvent::RawOutput), + b"dkim.pass" => EventType::Dkim(DkimEvent::Pass), + b"dkim.neutral" => EventType::Dkim(DkimEvent::Neutral), + b"dkim.fail" => EventType::Dkim(DkimEvent::Fail), + b"dkim.perm-error" => EventType::Dkim(DkimEvent::PermError), + b"dkim.temp-error" => EventType::Dkim(DkimEvent::TempError), + b"dkim.none" => EventType::Dkim(DkimEvent::None), + b"dkim.unsupported-version" => EventType::Dkim(DkimEvent::UnsupportedVersion), + b"dkim.unsupported-algorithm" => EventType::Dkim(DkimEvent::UnsupportedAlgorithm), + b"dkim.unsupported-canonicalization" => EventType::Dkim(DkimEvent::UnsupportedCanonicalization), + b"dkim.unsupported-key-type" => EventType::Dkim(DkimEvent::UnsupportedKeyType), + b"dkim.failed-body-hash-match" => EventType::Dkim(DkimEvent::FailedBodyHashMatch), + b"dkim.failed-verification" => EventType::Dkim(DkimEvent::FailedVerification), + b"dkim.failed-auid-match" => EventType::Dkim(DkimEvent::FailedAuidMatch), + b"dkim.revoked-public-key" => EventType::Dkim(DkimEvent::RevokedPublicKey), + b"dkim.incompatible-algorithms" => EventType::Dkim(DkimEvent::IncompatibleAlgorithms), + b"dkim.signature-expired" => EventType::Dkim(DkimEvent::SignatureExpired), + b"dkim.signature-length" => EventType::Dkim(DkimEvent::SignatureLength), + b"dkim.signer-not-found" => EventType::Dkim(DkimEvent::SignerNotFound), + b"dkim.build-error" => EventType::Dkim(DkimEvent::BuildError), + b"dkim.signature-created" => EventType::Dkim(DkimEvent::SignatureCreated), + b"dkim.signature-published" => EventType::Dkim(DkimEvent::SignaturePublished), + b"dkim.signature-retiring" => EventType::Dkim(DkimEvent::SignatureRetiring), + b"dkim.signature-retired" => EventType::Dkim(DkimEvent::SignatureRetired), + b"dkim.signature-deleted" => EventType::Dkim(DkimEvent::SignatureDeleted), + b"dkim.instance-missing" => EventType::Dkim(DkimEvent::InstanceMissing), + b"dkim.instance-syntax" => EventType::Dkim(DkimEvent::InstanceSyntax), + b"dkim.instance-tag-missing" => EventType::Dkim(DkimEvent::InstanceTagMissing), + b"dkim.instance-not-signed" => EventType::Dkim(DkimEvent::InstanceNotSigned), + b"dkim.instance-above-signature" => EventType::Dkim(DkimEvent::InstanceAboveSignature), + b"dkim.signature-missing" => EventType::Dkim(DkimEvent::SignatureMissing), + b"dkim.signature-syntax" => EventType::Dkim(DkimEvent::SignatureSyntax), + b"dkim.signature-tag-missing" => EventType::Dkim(DkimEvent::SignatureTagMissing), + b"dkim.signature-tag-unexpected" => EventType::Dkim(DkimEvent::SignatureTagUnexpected), + b"dkim.sequence-gap" => EventType::Dkim(DkimEvent::SequenceGap), + b"dkim.sequence-overflow" => EventType::Dkim(DkimEvent::SequenceOverflow), + b"dkim.chain-too-long" => EventType::Dkim(DkimEvent::ChainTooLong), + b"dkim.mail-from-mismatch" => EventType::Dkim(DkimEvent::MailFromMismatch), + b"dkim.rcpt-to-mismatch" => EventType::Dkim(DkimEvent::RcptToMismatch), + b"dkim.mail-from-domain-mismatch" => EventType::Dkim(DkimEvent::MailFromDomainMismatch), + b"dkim.next-domain-mismatch" => EventType::Dkim(DkimEvent::NextDomainMismatch), + b"dkim.custody-break" => EventType::Dkim(DkimEvent::CustodyBreak), + b"dkim.public-key-fetch" => EventType::Dkim(DkimEvent::PublicKeyFetch), + b"dkim.public-key-missing" => EventType::Dkim(DkimEvent::PublicKeyMissing), + b"dkim.public-key-multiple" => EventType::Dkim(DkimEvent::PublicKeyMultiple), + b"dkim.public-key-syntax" => EventType::Dkim(DkimEvent::PublicKeySyntax), + b"dkim.public-key-algorithm-mismatch" => EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch), + b"dkim.no-valid-algorithm" => EventType::Dkim(DkimEvent::NoValidAlgorithm), + b"dkim.header-hash-mismatch" => EventType::Dkim(DkimEvent::HeaderHashMismatch), + b"dkim.modified" => EventType::Dkim(DkimEvent::Modified), + b"dkim.exploded" => EventType::Dkim(DkimEvent::Exploded), + b"dmarc.pass" => EventType::Dmarc(DmarcEvent::Pass), + b"dmarc.fail" => EventType::Dmarc(DmarcEvent::Fail), + b"dmarc.perm-error" => EventType::Dmarc(DmarcEvent::PermError), + b"dmarc.temp-error" => EventType::Dmarc(DmarcEvent::TempError), + b"dmarc.none" => EventType::Dmarc(DmarcEvent::None), + b"dns.record-created" => EventType::Dns(DnsEvent::RecordCreated), + b"dns.record-creation-failed" => EventType::Dns(DnsEvent::RecordCreationFailed), + b"dns.record-deletion-failed" => EventType::Dns(DnsEvent::RecordDeletionFailed), + b"dns.record-not-propagated" => EventType::Dns(DnsEvent::RecordNotPropagated), + b"dns.record-lookup-failed" => EventType::Dns(DnsEvent::RecordLookupFailed), + b"dns.record-propagated" => EventType::Dns(DnsEvent::RecordPropagated), + b"dns.record-propagation-timeout" => EventType::Dns(DnsEvent::RecordPropagationTimeout), + b"dns.build-error" => EventType::Dns(DnsEvent::BuildError), + b"eval.result" => EventType::Eval(EvalEvent::Result), + b"eval.error" => EventType::Eval(EvalEvent::Error), + b"eval.directory-not-found" => EventType::Eval(EvalEvent::DirectoryNotFound), + b"eval.store-not-found" => EventType::Eval(EvalEvent::StoreNotFound), + b"http.connection-start" => EventType::Http(HttpEvent::ConnectionStart), + b"http.connection-end" => EventType::Http(HttpEvent::ConnectionEnd), + b"http.error" => EventType::Http(HttpEvent::Error), + b"http.request-url" => EventType::Http(HttpEvent::RequestUrl), + b"http.request-body" => EventType::Http(HttpEvent::RequestBody), + b"http.response-body" => EventType::Http(HttpEvent::ResponseBody), + b"http.x-forwarded-missing" => EventType::Http(HttpEvent::XForwardedMissing), + b"imap.connection-start" => EventType::Imap(ImapEvent::ConnectionStart), + b"imap.connection-end" => EventType::Imap(ImapEvent::ConnectionEnd), + b"imap.get-acl" => EventType::Imap(ImapEvent::GetAcl), + b"imap.set-acl" => EventType::Imap(ImapEvent::SetAcl), + b"imap.my-rights" => EventType::Imap(ImapEvent::MyRights), + b"imap.list-rights" => EventType::Imap(ImapEvent::ListRights), + b"imap.append" => EventType::Imap(ImapEvent::Append), + b"imap.capabilities" => EventType::Imap(ImapEvent::Capabilities), + b"imap.id" => EventType::Imap(ImapEvent::Id), + b"imap.close" => EventType::Imap(ImapEvent::Close), + b"imap.copy" => EventType::Imap(ImapEvent::Copy), + b"imap.move" => EventType::Imap(ImapEvent::Move), + b"imap.create-mailbox" => EventType::Imap(ImapEvent::CreateMailbox), + b"imap.delete-mailbox" => EventType::Imap(ImapEvent::DeleteMailbox), + b"imap.rename-mailbox" => EventType::Imap(ImapEvent::RenameMailbox), + b"imap.enable" => EventType::Imap(ImapEvent::Enable), + b"imap.expunge" => EventType::Imap(ImapEvent::Expunge), + b"imap.fetch" => EventType::Imap(ImapEvent::Fetch), + b"imap.idle-start" => EventType::Imap(ImapEvent::IdleStart), + b"imap.idle-stop" => EventType::Imap(ImapEvent::IdleStop), + b"imap.list" => EventType::Imap(ImapEvent::List), + b"imap.lsub" => EventType::Imap(ImapEvent::Lsub), + b"imap.logout" => EventType::Imap(ImapEvent::Logout), + b"imap.namespace" => EventType::Imap(ImapEvent::Namespace), + b"imap.noop" => EventType::Imap(ImapEvent::Noop), + b"imap.search" => EventType::Imap(ImapEvent::Search), + b"imap.sort" => EventType::Imap(ImapEvent::Sort), + b"imap.select" => EventType::Imap(ImapEvent::Select), + b"imap.status" => EventType::Imap(ImapEvent::Status), + b"imap.store" => EventType::Imap(ImapEvent::Store), + b"imap.subscribe" => EventType::Imap(ImapEvent::Subscribe), + b"imap.unsubscribe" => EventType::Imap(ImapEvent::Unsubscribe), + b"imap.thread" => EventType::Imap(ImapEvent::Thread), + b"imap.uid-batches" => EventType::Imap(ImapEvent::UidBatches), + b"imap.get-quota" => EventType::Imap(ImapEvent::GetQuota), + b"imap.error" => EventType::Imap(ImapEvent::Error), + b"imap.raw-input" => EventType::Imap(ImapEvent::RawInput), + b"imap.raw-output" => EventType::Imap(ImapEvent::RawOutput), + b"incoming-report.dmarc-report" => EventType::IncomingReport(IncomingReportEvent::DmarcReport), + b"incoming-report.dmarc-report-with-warnings" => EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), + b"incoming-report.tls-report" => EventType::IncomingReport(IncomingReportEvent::TlsReport), + b"incoming-report.tls-report-with-warnings" => EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings), + b"incoming-report.abuse-report" => EventType::IncomingReport(IncomingReportEvent::AbuseReport), + b"incoming-report.auth-failure-report" => EventType::IncomingReport(IncomingReportEvent::AuthFailureReport), + b"incoming-report.fraud-report" => EventType::IncomingReport(IncomingReportEvent::FraudReport), + b"incoming-report.not-spam-report" => EventType::IncomingReport(IncomingReportEvent::NotSpamReport), + b"incoming-report.virus-report" => EventType::IncomingReport(IncomingReportEvent::VirusReport), + b"incoming-report.other-report" => EventType::IncomingReport(IncomingReportEvent::OtherReport), + b"incoming-report.message-parse-failed" => EventType::IncomingReport(IncomingReportEvent::MessageParseFailed), + b"incoming-report.dmarc-parse-failed" => EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed), + b"incoming-report.tls-rpc-parse-failed" => EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed), + b"incoming-report.arf-parse-failed" => EventType::IncomingReport(IncomingReportEvent::ArfParseFailed), + b"incoming-report.decompress-error" => EventType::IncomingReport(IncomingReportEvent::DecompressError), + b"iprev.pass" => EventType::Iprev(IprevEvent::Pass), + b"iprev.fail" => EventType::Iprev(IprevEvent::Fail), + b"iprev.perm-error" => EventType::Iprev(IprevEvent::PermError), + b"iprev.temp-error" => EventType::Iprev(IprevEvent::TempError), + b"iprev.none" => EventType::Iprev(IprevEvent::None), + b"jmap.method-call" => EventType::Jmap(JmapEvent::MethodCall), + b"jmap.invalid-arguments" => EventType::Jmap(JmapEvent::InvalidArguments), + b"jmap.request-too-large" => EventType::Jmap(JmapEvent::RequestTooLarge), + b"jmap.state-mismatch" => EventType::Jmap(JmapEvent::StateMismatch), + b"jmap.anchor-not-found" => EventType::Jmap(JmapEvent::AnchorNotFound), + b"jmap.unsupported-filter" => EventType::Jmap(JmapEvent::UnsupportedFilter), + b"jmap.unsupported-sort" => EventType::Jmap(JmapEvent::UnsupportedSort), + b"jmap.unknown-method" => EventType::Jmap(JmapEvent::UnknownMethod), + b"jmap.invalid-result-reference" => EventType::Jmap(JmapEvent::InvalidResultReference), + b"jmap.forbidden" => EventType::Jmap(JmapEvent::Forbidden), + b"jmap.account-not-found" => EventType::Jmap(JmapEvent::AccountNotFound), + b"jmap.account-not-supported-by-method" => EventType::Jmap(JmapEvent::AccountNotSupportedByMethod), + b"jmap.account-read-only" => EventType::Jmap(JmapEvent::AccountReadOnly), + b"jmap.not-found" => EventType::Jmap(JmapEvent::NotFound), + b"jmap.cannot-calculate-changes" => EventType::Jmap(JmapEvent::CannotCalculateChanges), + b"jmap.unknown-data-type" => EventType::Jmap(JmapEvent::UnknownDataType), + b"jmap.unknown-capability" => EventType::Jmap(JmapEvent::UnknownCapability), + b"jmap.not-json" => EventType::Jmap(JmapEvent::NotJson), + b"jmap.not-request" => EventType::Jmap(JmapEvent::NotRequest), + b"jmap.websocket-start" => EventType::Jmap(JmapEvent::WebsocketStart), + b"jmap.websocket-stop" => EventType::Jmap(JmapEvent::WebsocketStop), + b"jmap.websocket-error" => EventType::Jmap(JmapEvent::WebsocketError), + b"limit.size-request" => EventType::Limit(LimitEvent::SizeRequest), + b"limit.size-upload" => EventType::Limit(LimitEvent::SizeUpload), + b"limit.calls-in" => EventType::Limit(LimitEvent::CallsIn), + b"limit.concurrent-request" => EventType::Limit(LimitEvent::ConcurrentRequest), + b"limit.concurrent-upload" => EventType::Limit(LimitEvent::ConcurrentUpload), + b"limit.concurrent-connection" => EventType::Limit(LimitEvent::ConcurrentConnection), + b"limit.quota" => EventType::Limit(LimitEvent::Quota), + b"limit.blob-quota" => EventType::Limit(LimitEvent::BlobQuota), + b"limit.tenant-quota" => EventType::Limit(LimitEvent::TenantQuota), + b"limit.too-many-requests" => EventType::Limit(LimitEvent::TooManyRequests), + b"mail-auth.parse-error" => EventType::MailAuth(MailAuthEvent::ParseError), + b"mail-auth.missing-parameters" => EventType::MailAuth(MailAuthEvent::MissingParameters), + b"mail-auth.no-headers-found" => EventType::MailAuth(MailAuthEvent::NoHeadersFound), + b"mail-auth.crypto" => EventType::MailAuth(MailAuthEvent::Crypto), + b"mail-auth.io" => EventType::MailAuth(MailAuthEvent::Io), + b"mail-auth.base64" => EventType::MailAuth(MailAuthEvent::Base64), + b"mail-auth.dns-error" => EventType::MailAuth(MailAuthEvent::DnsError), + b"mail-auth.dns-record-not-found" => EventType::MailAuth(MailAuthEvent::DnsRecordNotFound), + b"mail-auth.dns-invalid-record-type" => EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType), + b"mail-auth.policy-not-aligned" => EventType::MailAuth(MailAuthEvent::PolicyNotAligned), + b"manage-sieve.connection-start" => EventType::ManageSieve(ManageSieveEvent::ConnectionStart), + b"manage-sieve.connection-end" => EventType::ManageSieve(ManageSieveEvent::ConnectionEnd), + b"manage-sieve.create-script" => EventType::ManageSieve(ManageSieveEvent::CreateScript), + b"manage-sieve.update-script" => EventType::ManageSieve(ManageSieveEvent::UpdateScript), + b"manage-sieve.get-script" => EventType::ManageSieve(ManageSieveEvent::GetScript), + b"manage-sieve.delete-script" => EventType::ManageSieve(ManageSieveEvent::DeleteScript), + b"manage-sieve.rename-script" => EventType::ManageSieve(ManageSieveEvent::RenameScript), + b"manage-sieve.check-script" => EventType::ManageSieve(ManageSieveEvent::CheckScript), + b"manage-sieve.have-space" => EventType::ManageSieve(ManageSieveEvent::HaveSpace), + b"manage-sieve.list-scripts" => EventType::ManageSieve(ManageSieveEvent::ListScripts), + b"manage-sieve.set-active" => EventType::ManageSieve(ManageSieveEvent::SetActive), + b"manage-sieve.capabilities" => EventType::ManageSieve(ManageSieveEvent::Capabilities), + b"manage-sieve.start-tls" => EventType::ManageSieve(ManageSieveEvent::StartTls), + b"manage-sieve.unauthenticate" => EventType::ManageSieve(ManageSieveEvent::Unauthenticate), + b"manage-sieve.logout" => EventType::ManageSieve(ManageSieveEvent::Logout), + b"manage-sieve.noop" => EventType::ManageSieve(ManageSieveEvent::Noop), + b"manage-sieve.error" => EventType::ManageSieve(ManageSieveEvent::Error), + b"manage-sieve.raw-input" => EventType::ManageSieve(ManageSieveEvent::RawInput), + b"manage-sieve.raw-output" => EventType::ManageSieve(ManageSieveEvent::RawOutput), + b"message-ingest.ham" => EventType::MessageIngest(MessageIngestEvent::Ham), + b"message-ingest.spam" => EventType::MessageIngest(MessageIngestEvent::Spam), + b"message-ingest.imap-append" => EventType::MessageIngest(MessageIngestEvent::ImapAppend), + b"message-ingest.jmap-append" => EventType::MessageIngest(MessageIngestEvent::JmapAppend), + b"message-ingest.duplicate" => EventType::MessageIngest(MessageIngestEvent::Duplicate), + b"message-ingest.error" => EventType::MessageIngest(MessageIngestEvent::Error), + b"message-ingest.search-index" => EventType::MessageIngest(MessageIngestEvent::SearchIndex), + b"milter.read" => EventType::Milter(MilterEvent::Read), + b"milter.write" => EventType::Milter(MilterEvent::Write), + b"milter.action-accept" => EventType::Milter(MilterEvent::ActionAccept), + b"milter.action-discard" => EventType::Milter(MilterEvent::ActionDiscard), + b"milter.action-reject" => EventType::Milter(MilterEvent::ActionReject), + b"milter.action-temp-fail" => EventType::Milter(MilterEvent::ActionTempFail), + b"milter.action-reply-code" => EventType::Milter(MilterEvent::ActionReplyCode), + b"milter.action-connection-failure" => EventType::Milter(MilterEvent::ActionConnectionFailure), + b"milter.action-shutdown" => EventType::Milter(MilterEvent::ActionShutdown), + b"milter.io-error" => EventType::Milter(MilterEvent::IoError), + b"milter.frame-too-large" => EventType::Milter(MilterEvent::FrameTooLarge), + b"milter.frame-invalid" => EventType::Milter(MilterEvent::FrameInvalid), + b"milter.unexpected-response" => EventType::Milter(MilterEvent::UnexpectedResponse), + b"milter.timeout" => EventType::Milter(MilterEvent::Timeout), + b"milter.tls-invalid-name" => EventType::Milter(MilterEvent::TlsInvalidName), + b"milter.disconnected" => EventType::Milter(MilterEvent::Disconnected), + b"milter.parse-error" => EventType::Milter(MilterEvent::ParseError), + b"mta-hook.action-accept" => EventType::MtaHook(MtaHookEvent::ActionAccept), + b"mta-hook.action-discard" => EventType::MtaHook(MtaHookEvent::ActionDiscard), + b"mta-hook.action-reject" => EventType::MtaHook(MtaHookEvent::ActionReject), + b"mta-hook.action-quarantine" => EventType::MtaHook(MtaHookEvent::ActionQuarantine), + b"mta-hook.error" => EventType::MtaHook(MtaHookEvent::Error), + b"mta-sts.authorized" => EventType::MtaSts(MtaStsEvent::Authorized), + b"mta-sts.not-authorized" => EventType::MtaSts(MtaStsEvent::NotAuthorized), + b"mta-sts.policy-fetch" => EventType::MtaSts(MtaStsEvent::PolicyFetch), + b"mta-sts.policy-not-found" => EventType::MtaSts(MtaStsEvent::PolicyNotFound), + b"mta-sts.policy-fetch-error" => EventType::MtaSts(MtaStsEvent::PolicyFetchError), + b"mta-sts.invalid-policy" => EventType::MtaSts(MtaStsEvent::InvalidPolicy), + b"network.listen-start" => EventType::Network(NetworkEvent::ListenStart), + b"network.listen-stop" => EventType::Network(NetworkEvent::ListenStop), + b"network.listen-error" => EventType::Network(NetworkEvent::ListenError), + b"network.bind-error" => EventType::Network(NetworkEvent::BindError), + b"network.read-error" => EventType::Network(NetworkEvent::ReadError), + b"network.write-error" => EventType::Network(NetworkEvent::WriteError), + b"network.flush-error" => EventType::Network(NetworkEvent::FlushError), + b"network.accept-error" => EventType::Network(NetworkEvent::AcceptError), + b"network.split-error" => EventType::Network(NetworkEvent::SplitError), + b"network.timeout" => EventType::Network(NetworkEvent::Timeout), + b"network.closed" => EventType::Network(NetworkEvent::Closed), + b"network.proxy-error" => EventType::Network(NetworkEvent::ProxyError), + b"network.set-opt-error" => EventType::Network(NetworkEvent::SetOptError), + b"outgoing-report.spf-report" => EventType::OutgoingReport(OutgoingReportEvent::SpfReport), + b"outgoing-report.spf-rate-limited" => EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited), + b"outgoing-report.dkim-report" => EventType::OutgoingReport(OutgoingReportEvent::DkimReport), + b"outgoing-report.dkim-rate-limited" => EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited), + b"outgoing-report.dmarc-report" => EventType::OutgoingReport(OutgoingReportEvent::DmarcReport), + b"outgoing-report.dmarc-rate-limited" => EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited), + b"outgoing-report.dmarc-aggregate-report" => EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport), + b"outgoing-report.tls-aggregate" => EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate), + b"outgoing-report.http-submission" => EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission), + b"outgoing-report.unauthorized-reporting-address" => EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress), + b"outgoing-report.reporting-address-validation-error" => EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError), + b"outgoing-report.not-found" => EventType::OutgoingReport(OutgoingReportEvent::NotFound), + b"outgoing-report.submission-error" => EventType::OutgoingReport(OutgoingReportEvent::SubmissionError), + b"outgoing-report.no-recipients-found" => EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound), + b"outgoing-report.locked" => EventType::OutgoingReport(OutgoingReportEvent::Locked), + b"outgoing-report.max-size-exceeded" => EventType::OutgoingReport(OutgoingReportEvent::MaxSizeExceeded), + b"pop3.connection-start" => EventType::Pop3(Pop3Event::ConnectionStart), + b"pop3.connection-end" => EventType::Pop3(Pop3Event::ConnectionEnd), + b"pop3.delete" => EventType::Pop3(Pop3Event::Delete), + b"pop3.reset" => EventType::Pop3(Pop3Event::Reset), + b"pop3.quit" => EventType::Pop3(Pop3Event::Quit), + b"pop3.fetch" => EventType::Pop3(Pop3Event::Fetch), + b"pop3.list" => EventType::Pop3(Pop3Event::List), + b"pop3.list-message" => EventType::Pop3(Pop3Event::ListMessage), + b"pop3.uidl" => EventType::Pop3(Pop3Event::Uidl), + b"pop3.uidl-message" => EventType::Pop3(Pop3Event::UidlMessage), + b"pop3.stat" => EventType::Pop3(Pop3Event::Stat), + b"pop3.noop" => EventType::Pop3(Pop3Event::Noop), + b"pop3.capabilities" => EventType::Pop3(Pop3Event::Capabilities), + b"pop3.start-tls" => EventType::Pop3(Pop3Event::StartTls), + b"pop3.utf8" => EventType::Pop3(Pop3Event::Utf8), + b"pop3.error" => EventType::Pop3(Pop3Event::Error), + b"pop3.raw-input" => EventType::Pop3(Pop3Event::RawInput), + b"pop3.raw-output" => EventType::Pop3(Pop3Event::RawOutput), + b"push-subscription.success" => EventType::PushSubscription(PushSubscriptionEvent::Success), + b"push-subscription.error" => EventType::PushSubscription(PushSubscriptionEvent::Error), + b"push-subscription.not-found" => EventType::PushSubscription(PushSubscriptionEvent::NotFound), + b"queue.started" => EventType::Queue(QueueEvent::Started), + b"queue.message-queued" => EventType::Queue(QueueEvent::MessageQueued), + b"queue.authenticated-message-queued" => EventType::Queue(QueueEvent::AuthenticatedMessageQueued), + b"queue.report-queued" => EventType::Queue(QueueEvent::ReportQueued), + b"queue.dsn-queued" => EventType::Queue(QueueEvent::DsnQueued), + b"queue.autogenerated-queued" => EventType::Queue(QueueEvent::AutogeneratedQueued), + b"queue.rescheduled" => EventType::Queue(QueueEvent::Rescheduled), + b"queue.locked" => EventType::Queue(QueueEvent::Locked), + b"queue.blob-not-found" => EventType::Queue(QueueEvent::BlobNotFound), + b"queue.rate-limit-exceeded" => EventType::Queue(QueueEvent::RateLimitExceeded), + b"queue.concurrency-limit-exceeded" => EventType::Queue(QueueEvent::ConcurrencyLimitExceeded), + b"queue.quota-exceeded" => EventType::Queue(QueueEvent::QuotaExceeded), + b"queue.back-pressure" => EventType::Queue(QueueEvent::BackPressure), + b"registry.local-read-error" => EventType::Registry(RegistryEvent::LocalReadError), + b"registry.local-write-error" => EventType::Registry(RegistryEvent::LocalWriteError), + b"registry.local-parse-error" => EventType::Registry(RegistryEvent::LocalParseError), + b"registry.read-error" => EventType::Registry(RegistryEvent::ReadError), + b"registry.write-error" => EventType::Registry(RegistryEvent::WriteError), + b"registry.deserialization-error" => EventType::Registry(RegistryEvent::DeserializationError), + b"registry.build-error" => EventType::Registry(RegistryEvent::BuildError), + b"registry.build-warning" => EventType::Registry(RegistryEvent::BuildWarning), + b"registry.not-supported" => EventType::Registry(RegistryEvent::NotSupported), + b"registry.validation-error" => EventType::Registry(RegistryEvent::ValidationError), + b"resource.not-found" => EventType::Resource(ResourceEvent::NotFound), + b"resource.bad-parameters" => EventType::Resource(ResourceEvent::BadParameters), + b"resource.error" => EventType::Resource(ResourceEvent::Error), + b"resource.download-external" => EventType::Resource(ResourceEvent::DownloadExternal), + b"resource.application-updated" => EventType::Resource(ResourceEvent::ApplicationUpdated), + b"resource.application-unpacked" => EventType::Resource(ResourceEvent::ApplicationUnpacked), + b"security.authentication-ban" => EventType::Security(SecurityEvent::AuthenticationBan), + b"security.abuse-ban" => EventType::Security(SecurityEvent::AbuseBan), + b"security.scan-ban" => EventType::Security(SecurityEvent::ScanBan), + b"security.loiter-ban" => EventType::Security(SecurityEvent::LoiterBan), + b"security.ip-blocked" => EventType::Security(SecurityEvent::IpBlocked), + b"security.ip-block-expired" => EventType::Security(SecurityEvent::IpBlockExpired), + b"security.ip-allow-expired" => EventType::Security(SecurityEvent::IpAllowExpired), + b"security.ip-unauthorized" => EventType::Security(SecurityEvent::IpUnauthorized), + b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized), + b"server.startup" => EventType::Server(ServerEvent::Startup), + b"server.shutdown" => EventType::Server(ServerEvent::Shutdown), + b"server.startup-error" => EventType::Server(ServerEvent::StartupError), + b"server.thread-error" => EventType::Server(ServerEvent::ThreadError), + b"server.licensing" => EventType::Server(ServerEvent::Licensing), + b"server.recovery-mode" => EventType::Server(ServerEvent::RecoveryMode), + b"server.bootstrap-mode" => EventType::Server(ServerEvent::BootstrapMode), + b"sieve.action-accept" => EventType::Sieve(SieveEvent::ActionAccept), + b"sieve.action-accept-replace" => EventType::Sieve(SieveEvent::ActionAcceptReplace), + b"sieve.action-discard" => EventType::Sieve(SieveEvent::ActionDiscard), + b"sieve.action-reject" => EventType::Sieve(SieveEvent::ActionReject), + b"sieve.send-message" => EventType::Sieve(SieveEvent::SendMessage), + b"sieve.message-too-large" => EventType::Sieve(SieveEvent::MessageTooLarge), + b"sieve.script-not-found" => EventType::Sieve(SieveEvent::ScriptNotFound), + b"sieve.list-not-found" => EventType::Sieve(SieveEvent::ListNotFound), + b"sieve.runtime-error" => EventType::Sieve(SieveEvent::RuntimeError), + b"sieve.unexpected-error" => EventType::Sieve(SieveEvent::UnexpectedError), + b"sieve.not-supported" => EventType::Sieve(SieveEvent::NotSupported), + b"sieve.quota-exceeded" => EventType::Sieve(SieveEvent::QuotaExceeded), + b"smtp.connection-start" => EventType::Smtp(SmtpEvent::ConnectionStart), + b"smtp.connection-end" => EventType::Smtp(SmtpEvent::ConnectionEnd), + b"smtp.error" => EventType::Smtp(SmtpEvent::Error), + b"smtp.id-not-found" => EventType::Smtp(SmtpEvent::IdNotFound), + b"smtp.concurrency-limit-exceeded" => EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded), + b"smtp.transfer-limit-exceeded" => EventType::Smtp(SmtpEvent::TransferLimitExceeded), + b"smtp.rate-limit-exceeded" => EventType::Smtp(SmtpEvent::RateLimitExceeded), + b"smtp.time-limit-exceeded" => EventType::Smtp(SmtpEvent::TimeLimitExceeded), + b"smtp.missing-auth-directory" => EventType::Smtp(SmtpEvent::MissingAuthDirectory), + b"smtp.message-parse-failed" => EventType::Smtp(SmtpEvent::MessageParseFailed), + b"smtp.message-too-large" => EventType::Smtp(SmtpEvent::MessageTooLarge), + b"smtp.loop-detected" => EventType::Smtp(SmtpEvent::LoopDetected), + b"smtp.dkim-pass" => EventType::Smtp(SmtpEvent::DkimPass), + b"smtp.dkim-fail" => EventType::Smtp(SmtpEvent::DkimFail), + b"smtp.dkim2-pass" => EventType::Smtp(SmtpEvent::Dkim2Pass), + b"smtp.dkim2-fail" => EventType::Smtp(SmtpEvent::Dkim2Fail), + b"smtp.dkim2-dsn-discarded" => EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded), + b"smtp.arc-pass" => EventType::Smtp(SmtpEvent::ArcPass), + b"smtp.arc-fail" => EventType::Smtp(SmtpEvent::ArcFail), + b"smtp.spf-ehlo-pass" => EventType::Smtp(SmtpEvent::SpfEhloPass), + b"smtp.spf-ehlo-fail" => EventType::Smtp(SmtpEvent::SpfEhloFail), + b"smtp.spf-from-pass" => EventType::Smtp(SmtpEvent::SpfFromPass), + b"smtp.spf-from-fail" => EventType::Smtp(SmtpEvent::SpfFromFail), + b"smtp.dmarc-pass" => EventType::Smtp(SmtpEvent::DmarcPass), + b"smtp.dmarc-fail" => EventType::Smtp(SmtpEvent::DmarcFail), + b"smtp.iprev-pass" => EventType::Smtp(SmtpEvent::IprevPass), + b"smtp.iprev-fail" => EventType::Smtp(SmtpEvent::IprevFail), + b"smtp.too-many-messages" => EventType::Smtp(SmtpEvent::TooManyMessages), + b"smtp.ehlo" => EventType::Smtp(SmtpEvent::Ehlo), + b"smtp.invalid-ehlo" => EventType::Smtp(SmtpEvent::InvalidEhlo), + b"smtp.did-not-say-ehlo" => EventType::Smtp(SmtpEvent::DidNotSayEhlo), + b"smtp.ehlo-expected" => EventType::Smtp(SmtpEvent::EhloExpected), + b"smtp.lhlo-expected" => EventType::Smtp(SmtpEvent::LhloExpected), + b"smtp.mail-from-unauthenticated" => EventType::Smtp(SmtpEvent::MailFromUnauthenticated), + b"smtp.mail-from-unauthorized" => EventType::Smtp(SmtpEvent::MailFromUnauthorized), + b"smtp.mail-from-not-allowed" => EventType::Smtp(SmtpEvent::MailFromNotAllowed), + b"smtp.mail-from-rewritten" => EventType::Smtp(SmtpEvent::MailFromRewritten), + b"smtp.mail-from-missing" => EventType::Smtp(SmtpEvent::MailFromMissing), + b"smtp.mail-from" => EventType::Smtp(SmtpEvent::MailFrom), + b"smtp.multiple-mail-from" => EventType::Smtp(SmtpEvent::MultipleMailFrom), + b"smtp.mailbox-does-not-exist" => EventType::Smtp(SmtpEvent::MailboxDoesNotExist), + b"smtp.relay-not-allowed" => EventType::Smtp(SmtpEvent::RelayNotAllowed), + b"smtp.rcpt-to" => EventType::Smtp(SmtpEvent::RcptTo), + b"smtp.rcpt-to-duplicate" => EventType::Smtp(SmtpEvent::RcptToDuplicate), + b"smtp.rcpt-to-rewritten" => EventType::Smtp(SmtpEvent::RcptToRewritten), + b"smtp.rcpt-to-missing" => EventType::Smtp(SmtpEvent::RcptToMissing), + b"smtp.rcpt-to-greylisted" => EventType::Smtp(SmtpEvent::RcptToGreylisted), + b"smtp.too-many-recipients" => EventType::Smtp(SmtpEvent::TooManyRecipients), + b"smtp.too-many-invalid-rcpt" => EventType::Smtp(SmtpEvent::TooManyInvalidRcpt), + b"smtp.raw-input" => EventType::Smtp(SmtpEvent::RawInput), + b"smtp.raw-output" => EventType::Smtp(SmtpEvent::RawOutput), + b"smtp.missing-local-hostname" => EventType::Smtp(SmtpEvent::MissingLocalHostname), + b"smtp.vrfy" => EventType::Smtp(SmtpEvent::Vrfy), + b"smtp.vrfy-not-found" => EventType::Smtp(SmtpEvent::VrfyNotFound), + b"smtp.vrfy-disabled" => EventType::Smtp(SmtpEvent::VrfyDisabled), + b"smtp.expn" => EventType::Smtp(SmtpEvent::Expn), + b"smtp.expn-not-found" => EventType::Smtp(SmtpEvent::ExpnNotFound), + b"smtp.expn-disabled" => EventType::Smtp(SmtpEvent::ExpnDisabled), + b"smtp.require-tls-disabled" => EventType::Smtp(SmtpEvent::RequireTlsDisabled), + b"smtp.deliver-by-disabled" => EventType::Smtp(SmtpEvent::DeliverByDisabled), + b"smtp.deliver-by-invalid" => EventType::Smtp(SmtpEvent::DeliverByInvalid), + b"smtp.future-release-disabled" => EventType::Smtp(SmtpEvent::FutureReleaseDisabled), + b"smtp.future-release-invalid" => EventType::Smtp(SmtpEvent::FutureReleaseInvalid), + b"smtp.mt-priority-disabled" => EventType::Smtp(SmtpEvent::MtPriorityDisabled), + b"smtp.mt-priority-invalid" => EventType::Smtp(SmtpEvent::MtPriorityInvalid), + b"smtp.dsn-disabled" => EventType::Smtp(SmtpEvent::DsnDisabled), + b"smtp.auth-not-allowed" => EventType::Smtp(SmtpEvent::AuthNotAllowed), + b"smtp.auth-mechanism-not-supported" => EventType::Smtp(SmtpEvent::AuthMechanismNotSupported), + b"smtp.auth-exchange-too-long" => EventType::Smtp(SmtpEvent::AuthExchangeTooLong), + b"smtp.already-authenticated" => EventType::Smtp(SmtpEvent::AlreadyAuthenticated), + b"smtp.noop" => EventType::Smtp(SmtpEvent::Noop), + b"smtp.start-tls" => EventType::Smtp(SmtpEvent::StartTls), + b"smtp.start-tls-unavailable" => EventType::Smtp(SmtpEvent::StartTlsUnavailable), + b"smtp.start-tls-already" => EventType::Smtp(SmtpEvent::StartTlsAlready), + b"smtp.rset" => EventType::Smtp(SmtpEvent::Rset), + b"smtp.quit" => EventType::Smtp(SmtpEvent::Quit), + b"smtp.help" => EventType::Smtp(SmtpEvent::Help), + b"smtp.command-not-implemented" => EventType::Smtp(SmtpEvent::CommandNotImplemented), + b"smtp.invalid-command" => EventType::Smtp(SmtpEvent::InvalidCommand), + b"smtp.invalid-sender-address" => EventType::Smtp(SmtpEvent::InvalidSenderAddress), + b"smtp.invalid-recipient-address" => EventType::Smtp(SmtpEvent::InvalidRecipientAddress), + b"smtp.invalid-parameter" => EventType::Smtp(SmtpEvent::InvalidParameter), + b"smtp.unsupported-parameter" => EventType::Smtp(SmtpEvent::UnsupportedParameter), + b"smtp.syntax-error" => EventType::Smtp(SmtpEvent::SyntaxError), + b"smtp.request-too-large" => EventType::Smtp(SmtpEvent::RequestTooLarge), + b"spam.pyzor" => EventType::Spam(SpamEvent::Pyzor), + b"spam.pyzor-error" => EventType::Spam(SpamEvent::PyzorError), + b"spam.dnsbl" => EventType::Spam(SpamEvent::Dnsbl), + b"spam.dnsbl-error" => EventType::Spam(SpamEvent::DnsblError), + b"spam.train-started" => EventType::Spam(SpamEvent::TrainStarted), + b"spam.train-completed" => EventType::Spam(SpamEvent::TrainCompleted), + b"spam.train-sample-added" => EventType::Spam(SpamEvent::TrainSampleAdded), + b"spam.train-sample-not-found" => EventType::Spam(SpamEvent::TrainSampleNotFound), + b"spam.classify" => EventType::Spam(SpamEvent::Classify), + b"spam.model-loaded" => EventType::Spam(SpamEvent::ModelLoaded), + b"spam.model-not-ready" => EventType::Spam(SpamEvent::ModelNotReady), + b"spam.model-not-found" => EventType::Spam(SpamEvent::ModelNotFound), + b"spam.rules-updated" => EventType::Spam(SpamEvent::RulesUpdated), + b"spf.pass" => EventType::Spf(SpfEvent::Pass), + b"spf.fail" => EventType::Spf(SpfEvent::Fail), + b"spf.soft-fail" => EventType::Spf(SpfEvent::SoftFail), + b"spf.neutral" => EventType::Spf(SpfEvent::Neutral), + b"spf.temp-error" => EventType::Spf(SpfEvent::TempError), + b"spf.perm-error" => EventType::Spf(SpfEvent::PermError), + b"spf.none" => EventType::Spf(SpfEvent::None), + b"store.assert-value-failed" => EventType::Store(StoreEvent::AssertValueFailed), + b"store.foundationdb-error" => EventType::Store(StoreEvent::FoundationdbError), + b"store.mysql-error" => EventType::Store(StoreEvent::MysqlError), + b"store.postgresql-error" => EventType::Store(StoreEvent::PostgresqlError), + b"store.rocksdb-error" => EventType::Store(StoreEvent::RocksdbError), + b"store.sqlite-error" => EventType::Store(StoreEvent::SqliteError), + b"store.ldap-error" => EventType::Store(StoreEvent::LdapError), + b"store.elasticsearch-error" => EventType::Store(StoreEvent::ElasticsearchError), + b"store.meilisearch-error" => EventType::Store(StoreEvent::MeilisearchError), + b"store.redis-error" => EventType::Store(StoreEvent::RedisError), + b"store.s3-error" => EventType::Store(StoreEvent::S3Error), + b"store.azure-error" => EventType::Store(StoreEvent::AzureError), + b"store.filesystem-error" => EventType::Store(StoreEvent::FilesystemError), + b"store.pool-error" => EventType::Store(StoreEvent::PoolError), + b"store.data-corruption" => EventType::Store(StoreEvent::DataCorruption), + b"store.decompress-error" => EventType::Store(StoreEvent::DecompressError), + b"store.deserialize-error" => EventType::Store(StoreEvent::DeserializeError), + b"store.not-found" => EventType::Store(StoreEvent::NotFound), + b"store.not-configured" => EventType::Store(StoreEvent::NotConfigured), + b"store.not-supported" => EventType::Store(StoreEvent::NotSupported), + b"store.unexpected-error" => EventType::Store(StoreEvent::UnexpectedError), + b"store.crypto-error" => EventType::Store(StoreEvent::CryptoError), + b"store.http-store-error" => EventType::Store(StoreEvent::HttpStoreError), + b"store.cache-miss" => EventType::Store(StoreEvent::CacheMiss), + b"store.cache-hit" => EventType::Store(StoreEvent::CacheHit), + b"store.cache-stale" => EventType::Store(StoreEvent::CacheStale), + b"store.cache-update" => EventType::Store(StoreEvent::CacheUpdate), + b"store.cache-entry-too-large" => EventType::Store(StoreEvent::CacheEntryTooLarge), + b"store.blob-missing-marker" => EventType::Store(StoreEvent::BlobMissingMarker), + b"store.data-write" => EventType::Store(StoreEvent::DataWrite), + b"store.data-iterate" => EventType::Store(StoreEvent::DataIterate), + b"store.blob-read" => EventType::Store(StoreEvent::BlobRead), + b"store.blob-write" => EventType::Store(StoreEvent::BlobWrite), + b"store.blob-delete" => EventType::Store(StoreEvent::BlobDelete), + b"store.sql-query" => EventType::Store(StoreEvent::SqlQuery), + b"store.ldap-query" => EventType::Store(StoreEvent::LdapQuery), + b"store.ldap-warning" => EventType::Store(StoreEvent::LdapWarning), + b"store.http-store-fetch" => EventType::Store(StoreEvent::HttpStoreFetch), + b"store.auto-expunge" => EventType::Store(StoreEvent::AutoExpunge), + b"store.blob-store-purged" => EventType::Store(StoreEvent::BlobStorePurged), + b"store.data-store-purged" => EventType::Store(StoreEvent::DataStorePurged), + b"task-manager.task-acquired" => EventType::TaskManager(TaskManagerEvent::TaskAcquired), + b"task-manager.task-queued" => EventType::TaskManager(TaskManagerEvent::TaskQueued), + b"task-manager.task-scheduled" => EventType::TaskManager(TaskManagerEvent::TaskScheduled), + b"task-manager.task-locked" => EventType::TaskManager(TaskManagerEvent::TaskLocked), + b"task-manager.task-ignored" => EventType::TaskManager(TaskManagerEvent::TaskIgnored), + b"task-manager.task-failed" => EventType::TaskManager(TaskManagerEvent::TaskFailed), + b"task-manager.task-retry" => EventType::TaskManager(TaskManagerEvent::TaskRetry), + b"task-manager.blob-not-found" => EventType::TaskManager(TaskManagerEvent::BlobNotFound), + b"task-manager.metadata-not-found" => EventType::TaskManager(TaskManagerEvent::MetadataNotFound), + b"task-manager.scheduler-started" => EventType::TaskManager(TaskManagerEvent::SchedulerStarted), + b"task-manager.manager-started" => EventType::TaskManager(TaskManagerEvent::ManagerStarted), + b"telemetry.alert-event" => EventType::Telemetry(TelemetryEvent::AlertEvent), + b"telemetry.alert-message" => EventType::Telemetry(TelemetryEvent::AlertMessage), + b"telemetry.log-error" => EventType::Telemetry(TelemetryEvent::LogError), + b"telemetry.webhook-error" => EventType::Telemetry(TelemetryEvent::WebhookError), + b"telemetry.otel-exporter-error" => EventType::Telemetry(TelemetryEvent::OtelExporterError), + b"telemetry.otel-metrics-exporter-error" => EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError), + b"telemetry.prometheus-exporter-error" => EventType::Telemetry(TelemetryEvent::PrometheusExporterError), + b"telemetry.journal-error" => EventType::Telemetry(TelemetryEvent::JournalError), + b"telemetry.metrics-collected" => EventType::Telemetry(TelemetryEvent::MetricsCollected), + b"telemetry.metrics-stored" => EventType::Telemetry(TelemetryEvent::MetricsStored), + b"telemetry.metrics-pushed" => EventType::Telemetry(TelemetryEvent::MetricsPushed), + b"tls.handshake" => EventType::Tls(TlsEvent::Handshake), + b"tls.handshake-error" => EventType::Tls(TlsEvent::HandshakeError), + b"tls.not-configured" => EventType::Tls(TlsEvent::NotConfigured), + b"tls.certificate-not-found" => EventType::Tls(TlsEvent::CertificateNotFound), + b"tls.no-certificates-available" => EventType::Tls(TlsEvent::NoCertificatesAvailable), + b"tls.multiple-certificates-available" => EventType::Tls(TlsEvent::MultipleCertificatesAvailable), + b"tls.expired-certificate-removed" => EventType::Tls(TlsEvent::ExpiredCertificateRemoved), + b"tls-rpt.record-fetch" => EventType::TlsRpt(TlsRptEvent::RecordFetch), + b"tls-rpt.record-fetch-error" => EventType::TlsRpt(TlsRptEvent::RecordFetchError), + b"tls-rpt.record-not-found" => EventType::TlsRpt(TlsRptEvent::RecordNotFound), + b"web-dav.propfind" => EventType::WebDav(WebDavEvent::Propfind), + b"web-dav.proppatch" => EventType::WebDav(WebDavEvent::Proppatch), + b"web-dav.get" => EventType::WebDav(WebDavEvent::Get), + b"web-dav.head" => EventType::WebDav(WebDavEvent::Head), + b"web-dav.report" => EventType::WebDav(WebDavEvent::Report), + b"web-dav.mkcol" => EventType::WebDav(WebDavEvent::Mkcol), + b"web-dav.mkcalendar" => EventType::WebDav(WebDavEvent::Mkcalendar), + b"web-dav.delete" => EventType::WebDav(WebDavEvent::Delete), + b"web-dav.put" => EventType::WebDav(WebDavEvent::Put), + b"web-dav.post" => EventType::WebDav(WebDavEvent::Post), + b"web-dav.patch" => EventType::WebDav(WebDavEvent::Patch), + b"web-dav.copy" => EventType::WebDav(WebDavEvent::Copy), + b"web-dav.move" => EventType::WebDav(WebDavEvent::Move), + b"web-dav.lock" => EventType::WebDav(WebDavEvent::Lock), + b"web-dav.unlock" => EventType::WebDav(WebDavEvent::Unlock), + b"web-dav.acl" => EventType::WebDav(WebDavEvent::Acl), + b"web-dav.options" => EventType::WebDav(WebDavEvent::Options), + b"web-dav.error" => EventType::WebDav(WebDavEvent::Error), + } + .copied() + } + + pub fn as_str(&self) -> &'static str { + match self { + EventType::Acme(AcmeEvent::AuthStart) => "acme.auth-start", + EventType::Acme(AcmeEvent::AuthPending) => "acme.auth-pending", + EventType::Acme(AcmeEvent::AuthValid) => "acme.auth-valid", + EventType::Acme(AcmeEvent::AuthCompleted) => "acme.auth-completed", + EventType::Acme(AcmeEvent::AuthError) => "acme.auth-error", + EventType::Acme(AcmeEvent::AuthTooManyAttempts) => "acme.auth-too-many-attempts", + EventType::Acme(AcmeEvent::ProcessCert) => "acme.process-cert", + EventType::Acme(AcmeEvent::OrderStart) => "acme.order-start", + EventType::Acme(AcmeEvent::OrderProcessing) => "acme.order-processing", + EventType::Acme(AcmeEvent::OrderCompleted) => "acme.order-completed", + EventType::Acme(AcmeEvent::OrderReady) => "acme.order-ready", + EventType::Acme(AcmeEvent::OrderValid) => "acme.order-valid", + EventType::Acme(AcmeEvent::OrderInvalid) => "acme.order-invalid", + EventType::Acme(AcmeEvent::RenewBackoff) => "acme.renew-backoff", + EventType::Acme(AcmeEvent::ClientSuppliedSni) => "acme.client-supplied-sni", + EventType::Acme(AcmeEvent::ClientMissingSni) => "acme.client-missing-sni", + EventType::Acme(AcmeEvent::TlsAlpnReceived) => "acme.tls-alpn-received", + EventType::Acme(AcmeEvent::TlsAlpnError) => "acme.tls-alpn-error", + EventType::Acme(AcmeEvent::TokenNotFound) => "acme.token-not-found", + EventType::Acme(AcmeEvent::Error) => "acme.error", + EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response", + EventType::Ai(AiEvent::ApiError) => "ai.api-error", + EventType::Arc(ArcEvent::ChainTooLong) => "arc.chain-too-long", + EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance", + EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv", + EventType::Arc(ArcEvent::HasHeaderTag) => "arc.has-header-tag", + EventType::Arc(ArcEvent::BrokenChain) => "arc.broken-chain", + EventType::Arc(ArcEvent::SealerNotFound) => "arc.sealer-not-found", + EventType::Auth(AuthEvent::Success) => "auth.success", + EventType::Auth(AuthEvent::Failed) => "auth.failed", + EventType::Auth(AuthEvent::TokenExpired) => "auth.token-expired", + EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required", + EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", + EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", + EventType::Auth(AuthEvent::Error) => "auth.error", + EventType::Auth(AuthEvent::Warning) => "auth.warning", + EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired", + EventType::Calendar(CalendarEvent::RuleExpansionError) => { + "calendar.rule-expansion-error" + } + EventType::Calendar(CalendarEvent::AlarmSent) => "calendar.alarm-sent", + EventType::Calendar(CalendarEvent::AlarmSkipped) => "calendar.alarm-skipped", + EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => { + "calendar.alarm-recipient-override" + } + EventType::Calendar(CalendarEvent::AlarmFailed) => "calendar.alarm-failed", + EventType::Calendar(CalendarEvent::ItipMessageSent) => "calendar.itip-message-sent", + EventType::Calendar(CalendarEvent::ItipMessageReceived) => { + "calendar.itip-message-received" + } + EventType::Calendar(CalendarEvent::ItipMessageError) => "calendar.itip-message-error", + EventType::Cluster(ClusterEvent::Startup) => "cluster.startup", + EventType::Cluster(ClusterEvent::SubscriberStart) => "cluster.subscriber-start", + EventType::Cluster(ClusterEvent::SubscriberStop) => "cluster.subscriber-stop", + EventType::Cluster(ClusterEvent::SubscriberError) => "cluster.subscriber-error", + EventType::Cluster(ClusterEvent::SubscriberDisconnected) => { + "cluster.subscriber-disconnected" + } + EventType::Cluster(ClusterEvent::PublisherStart) => "cluster.publisher-start", + EventType::Cluster(ClusterEvent::PublisherStop) => "cluster.publisher-stop", + EventType::Cluster(ClusterEvent::PublisherError) => "cluster.publisher-error", + EventType::Cluster(ClusterEvent::MessageReceived) => "cluster.message-received", + EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped", + EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid", + EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed", + EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success", + EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure", + EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found", + EventType::Dane(DaneEvent::CertificateParseError) => "dane.certificate-parse-error", + EventType::Dane(DaneEvent::TlsaRecordMatch) => "dane.tlsa-record-match", + EventType::Dane(DaneEvent::TlsaRecordFetch) => "dane.tlsa-record-fetch", + EventType::Dane(DaneEvent::TlsaRecordFetchError) => "dane.tlsa-record-fetch-error", + EventType::Dane(DaneEvent::TlsaRecordNotFound) => "dane.tlsa-record-not-found", + EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned) => { + "dane.tlsa-record-not-dnssec-signed" + } + EventType::Dane(DaneEvent::TlsaRecordInvalid) => "dane.tlsa-record-invalid", + EventType::Dane(DaneEvent::BogusDnssecRecord) => "dane.bogus-dnssec-record", + EventType::Delivery(DeliveryEvent::AttemptStart) => "delivery.attempt-start", + EventType::Delivery(DeliveryEvent::AttemptEnd) => "delivery.attempt-end", + EventType::Delivery(DeliveryEvent::Completed) => "delivery.completed", + EventType::Delivery(DeliveryEvent::Failed) => "delivery.failed", + EventType::Delivery(DeliveryEvent::DomainDeliveryStart) => { + "delivery.domain-delivery-start" + } + EventType::Delivery(DeliveryEvent::MxLookup) => "delivery.mx-lookup", + EventType::Delivery(DeliveryEvent::MxLookupFailed) => "delivery.mx-lookup-failed", + EventType::Delivery(DeliveryEvent::IpLookup) => "delivery.ip-lookup", + EventType::Delivery(DeliveryEvent::IpLookupFailed) => "delivery.ip-lookup-failed", + EventType::Delivery(DeliveryEvent::NullMx) => "delivery.null-mx", + EventType::Delivery(DeliveryEvent::Connect) => "delivery.connect", + EventType::Delivery(DeliveryEvent::ConnectError) => "delivery.connect-error", + EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => { + "delivery.missing-outbound-hostname" + } + EventType::Delivery(DeliveryEvent::GreetingFailed) => "delivery.greeting-failed", + EventType::Delivery(DeliveryEvent::Ehlo) => "delivery.ehlo", + EventType::Delivery(DeliveryEvent::EhloRejected) => "delivery.ehlo-rejected", + EventType::Delivery(DeliveryEvent::Auth) => "delivery.auth", + EventType::Delivery(DeliveryEvent::AuthFailed) => "delivery.auth-failed", + EventType::Delivery(DeliveryEvent::MailFrom) => "delivery.mail-from", + EventType::Delivery(DeliveryEvent::MailFromRejected) => "delivery.mail-from-rejected", + EventType::Delivery(DeliveryEvent::Delivered) => "delivery.delivered", + EventType::Delivery(DeliveryEvent::RcptTo) => "delivery.rcpt-to", + EventType::Delivery(DeliveryEvent::RcptToRejected) => "delivery.rcpt-to-rejected", + EventType::Delivery(DeliveryEvent::RcptToFailed) => "delivery.rcpt-to-failed", + EventType::Delivery(DeliveryEvent::MessageRejected) => "delivery.message-rejected", + EventType::Delivery(DeliveryEvent::StartTls) => "delivery.start-tls", + EventType::Delivery(DeliveryEvent::StartTlsUnavailable) => { + "delivery.start-tls-unavailable" + } + EventType::Delivery(DeliveryEvent::StartTlsError) => "delivery.start-tls-error", + EventType::Delivery(DeliveryEvent::StartTlsDisabled) => "delivery.start-tls-disabled", + EventType::Delivery(DeliveryEvent::ImplicitTlsError) => "delivery.implicit-tls-error", + EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => { + "delivery.concurrency-limit-exceeded" + } + EventType::Delivery(DeliveryEvent::RateLimitExceeded) => "delivery.rate-limit-exceeded", + EventType::Delivery(DeliveryEvent::DoubleBounce) => "delivery.double-bounce", + EventType::Delivery(DeliveryEvent::DsnSuccess) => "delivery.dsn-success", + EventType::Delivery(DeliveryEvent::DsnTempFail) => "delivery.dsn-temp-fail", + EventType::Delivery(DeliveryEvent::DsnPermFail) => "delivery.dsn-perm-fail", + EventType::Delivery(DeliveryEvent::RawInput) => "delivery.raw-input", + EventType::Delivery(DeliveryEvent::RawOutput) => "delivery.raw-output", + EventType::Dkim(DkimEvent::Pass) => "dkim.pass", + EventType::Dkim(DkimEvent::Neutral) => "dkim.neutral", + EventType::Dkim(DkimEvent::Fail) => "dkim.fail", + EventType::Dkim(DkimEvent::PermError) => "dkim.perm-error", + EventType::Dkim(DkimEvent::TempError) => "dkim.temp-error", + EventType::Dkim(DkimEvent::None) => "dkim.none", + EventType::Dkim(DkimEvent::UnsupportedVersion) => "dkim.unsupported-version", + EventType::Dkim(DkimEvent::UnsupportedAlgorithm) => "dkim.unsupported-algorithm", + EventType::Dkim(DkimEvent::UnsupportedCanonicalization) => { + "dkim.unsupported-canonicalization" + } + EventType::Dkim(DkimEvent::UnsupportedKeyType) => "dkim.unsupported-key-type", + EventType::Dkim(DkimEvent::FailedBodyHashMatch) => "dkim.failed-body-hash-match", + EventType::Dkim(DkimEvent::FailedVerification) => "dkim.failed-verification", + EventType::Dkim(DkimEvent::FailedAuidMatch) => "dkim.failed-auid-match", + EventType::Dkim(DkimEvent::RevokedPublicKey) => "dkim.revoked-public-key", + EventType::Dkim(DkimEvent::IncompatibleAlgorithms) => "dkim.incompatible-algorithms", + EventType::Dkim(DkimEvent::SignatureExpired) => "dkim.signature-expired", + EventType::Dkim(DkimEvent::SignatureLength) => "dkim.signature-length", + EventType::Dkim(DkimEvent::SignerNotFound) => "dkim.signer-not-found", + EventType::Dkim(DkimEvent::BuildError) => "dkim.build-error", + EventType::Dkim(DkimEvent::SignatureCreated) => "dkim.signature-created", + EventType::Dkim(DkimEvent::SignaturePublished) => "dkim.signature-published", + EventType::Dkim(DkimEvent::SignatureRetiring) => "dkim.signature-retiring", + EventType::Dkim(DkimEvent::SignatureRetired) => "dkim.signature-retired", + EventType::Dkim(DkimEvent::SignatureDeleted) => "dkim.signature-deleted", + EventType::Dkim(DkimEvent::InstanceMissing) => "dkim.instance-missing", + EventType::Dkim(DkimEvent::InstanceSyntax) => "dkim.instance-syntax", + EventType::Dkim(DkimEvent::InstanceTagMissing) => "dkim.instance-tag-missing", + EventType::Dkim(DkimEvent::InstanceNotSigned) => "dkim.instance-not-signed", + EventType::Dkim(DkimEvent::InstanceAboveSignature) => "dkim.instance-above-signature", + EventType::Dkim(DkimEvent::SignatureMissing) => "dkim.signature-missing", + EventType::Dkim(DkimEvent::SignatureSyntax) => "dkim.signature-syntax", + EventType::Dkim(DkimEvent::SignatureTagMissing) => "dkim.signature-tag-missing", + EventType::Dkim(DkimEvent::SignatureTagUnexpected) => "dkim.signature-tag-unexpected", + EventType::Dkim(DkimEvent::SequenceGap) => "dkim.sequence-gap", + EventType::Dkim(DkimEvent::SequenceOverflow) => "dkim.sequence-overflow", + EventType::Dkim(DkimEvent::ChainTooLong) => "dkim.chain-too-long", + EventType::Dkim(DkimEvent::MailFromMismatch) => "dkim.mail-from-mismatch", + EventType::Dkim(DkimEvent::RcptToMismatch) => "dkim.rcpt-to-mismatch", + EventType::Dkim(DkimEvent::MailFromDomainMismatch) => "dkim.mail-from-domain-mismatch", + EventType::Dkim(DkimEvent::NextDomainMismatch) => "dkim.next-domain-mismatch", + EventType::Dkim(DkimEvent::CustodyBreak) => "dkim.custody-break", + EventType::Dkim(DkimEvent::PublicKeyFetch) => "dkim.public-key-fetch", + EventType::Dkim(DkimEvent::PublicKeyMissing) => "dkim.public-key-missing", + EventType::Dkim(DkimEvent::PublicKeyMultiple) => "dkim.public-key-multiple", + EventType::Dkim(DkimEvent::PublicKeySyntax) => "dkim.public-key-syntax", + EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch) => { + "dkim.public-key-algorithm-mismatch" + } + EventType::Dkim(DkimEvent::NoValidAlgorithm) => "dkim.no-valid-algorithm", + EventType::Dkim(DkimEvent::HeaderHashMismatch) => "dkim.header-hash-mismatch", + EventType::Dkim(DkimEvent::Modified) => "dkim.modified", + EventType::Dkim(DkimEvent::Exploded) => "dkim.exploded", + EventType::Dmarc(DmarcEvent::Pass) => "dmarc.pass", + EventType::Dmarc(DmarcEvent::Fail) => "dmarc.fail", + EventType::Dmarc(DmarcEvent::PermError) => "dmarc.perm-error", + EventType::Dmarc(DmarcEvent::TempError) => "dmarc.temp-error", + EventType::Dmarc(DmarcEvent::None) => "dmarc.none", + EventType::Dns(DnsEvent::RecordCreated) => "dns.record-created", + EventType::Dns(DnsEvent::RecordCreationFailed) => "dns.record-creation-failed", + EventType::Dns(DnsEvent::RecordDeletionFailed) => "dns.record-deletion-failed", + EventType::Dns(DnsEvent::RecordNotPropagated) => "dns.record-not-propagated", + EventType::Dns(DnsEvent::RecordLookupFailed) => "dns.record-lookup-failed", + EventType::Dns(DnsEvent::RecordPropagated) => "dns.record-propagated", + EventType::Dns(DnsEvent::RecordPropagationTimeout) => "dns.record-propagation-timeout", + EventType::Dns(DnsEvent::BuildError) => "dns.build-error", + EventType::Eval(EvalEvent::Result) => "eval.result", + EventType::Eval(EvalEvent::Error) => "eval.error", + EventType::Eval(EvalEvent::DirectoryNotFound) => "eval.directory-not-found", + EventType::Eval(EvalEvent::StoreNotFound) => "eval.store-not-found", + EventType::Http(HttpEvent::ConnectionStart) => "http.connection-start", + EventType::Http(HttpEvent::ConnectionEnd) => "http.connection-end", + EventType::Http(HttpEvent::Error) => "http.error", + EventType::Http(HttpEvent::RequestUrl) => "http.request-url", + EventType::Http(HttpEvent::RequestBody) => "http.request-body", + EventType::Http(HttpEvent::ResponseBody) => "http.response-body", + EventType::Http(HttpEvent::XForwardedMissing) => "http.x-forwarded-missing", + EventType::Imap(ImapEvent::ConnectionStart) => "imap.connection-start", + EventType::Imap(ImapEvent::ConnectionEnd) => "imap.connection-end", + EventType::Imap(ImapEvent::GetAcl) => "imap.get-acl", + EventType::Imap(ImapEvent::SetAcl) => "imap.set-acl", + EventType::Imap(ImapEvent::MyRights) => "imap.my-rights", + EventType::Imap(ImapEvent::ListRights) => "imap.list-rights", + EventType::Imap(ImapEvent::Append) => "imap.append", + EventType::Imap(ImapEvent::Capabilities) => "imap.capabilities", + EventType::Imap(ImapEvent::Id) => "imap.id", + EventType::Imap(ImapEvent::Close) => "imap.close", + EventType::Imap(ImapEvent::Copy) => "imap.copy", + EventType::Imap(ImapEvent::Move) => "imap.move", + EventType::Imap(ImapEvent::CreateMailbox) => "imap.create-mailbox", + EventType::Imap(ImapEvent::DeleteMailbox) => "imap.delete-mailbox", + EventType::Imap(ImapEvent::RenameMailbox) => "imap.rename-mailbox", + EventType::Imap(ImapEvent::Enable) => "imap.enable", + EventType::Imap(ImapEvent::Expunge) => "imap.expunge", + EventType::Imap(ImapEvent::Fetch) => "imap.fetch", + EventType::Imap(ImapEvent::IdleStart) => "imap.idle-start", + EventType::Imap(ImapEvent::IdleStop) => "imap.idle-stop", + EventType::Imap(ImapEvent::List) => "imap.list", + EventType::Imap(ImapEvent::Lsub) => "imap.lsub", + EventType::Imap(ImapEvent::Logout) => "imap.logout", + EventType::Imap(ImapEvent::Namespace) => "imap.namespace", + EventType::Imap(ImapEvent::Noop) => "imap.noop", + EventType::Imap(ImapEvent::Search) => "imap.search", + EventType::Imap(ImapEvent::Sort) => "imap.sort", + EventType::Imap(ImapEvent::Select) => "imap.select", + EventType::Imap(ImapEvent::Status) => "imap.status", + EventType::Imap(ImapEvent::Store) => "imap.store", + EventType::Imap(ImapEvent::Subscribe) => "imap.subscribe", + EventType::Imap(ImapEvent::Unsubscribe) => "imap.unsubscribe", + EventType::Imap(ImapEvent::Thread) => "imap.thread", + EventType::Imap(ImapEvent::UidBatches) => "imap.uid-batches", + EventType::Imap(ImapEvent::GetQuota) => "imap.get-quota", + EventType::Imap(ImapEvent::Error) => "imap.error", + EventType::Imap(ImapEvent::RawInput) => "imap.raw-input", + EventType::Imap(ImapEvent::RawOutput) => "imap.raw-output", + EventType::IncomingReport(IncomingReportEvent::DmarcReport) => { + "incoming-report.dmarc-report" + } + EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings) => { + "incoming-report.dmarc-report-with-warnings" + } + EventType::IncomingReport(IncomingReportEvent::TlsReport) => { + "incoming-report.tls-report" + } + EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings) => { + "incoming-report.tls-report-with-warnings" + } + EventType::IncomingReport(IncomingReportEvent::AbuseReport) => { + "incoming-report.abuse-report" + } + EventType::IncomingReport(IncomingReportEvent::AuthFailureReport) => { + "incoming-report.auth-failure-report" + } + EventType::IncomingReport(IncomingReportEvent::FraudReport) => { + "incoming-report.fraud-report" + } + EventType::IncomingReport(IncomingReportEvent::NotSpamReport) => { + "incoming-report.not-spam-report" + } + EventType::IncomingReport(IncomingReportEvent::VirusReport) => { + "incoming-report.virus-report" + } + EventType::IncomingReport(IncomingReportEvent::OtherReport) => { + "incoming-report.other-report" + } + EventType::IncomingReport(IncomingReportEvent::MessageParseFailed) => { + "incoming-report.message-parse-failed" + } + EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed) => { + "incoming-report.dmarc-parse-failed" + } + EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed) => { + "incoming-report.tls-rpc-parse-failed" + } + EventType::IncomingReport(IncomingReportEvent::ArfParseFailed) => { + "incoming-report.arf-parse-failed" + } + EventType::IncomingReport(IncomingReportEvent::DecompressError) => { + "incoming-report.decompress-error" + } + EventType::Iprev(IprevEvent::Pass) => "iprev.pass", + EventType::Iprev(IprevEvent::Fail) => "iprev.fail", + EventType::Iprev(IprevEvent::PermError) => "iprev.perm-error", + EventType::Iprev(IprevEvent::TempError) => "iprev.temp-error", + EventType::Iprev(IprevEvent::None) => "iprev.none", + EventType::Jmap(JmapEvent::MethodCall) => "jmap.method-call", + EventType::Jmap(JmapEvent::InvalidArguments) => "jmap.invalid-arguments", + EventType::Jmap(JmapEvent::RequestTooLarge) => "jmap.request-too-large", + EventType::Jmap(JmapEvent::StateMismatch) => "jmap.state-mismatch", + EventType::Jmap(JmapEvent::AnchorNotFound) => "jmap.anchor-not-found", + EventType::Jmap(JmapEvent::UnsupportedFilter) => "jmap.unsupported-filter", + EventType::Jmap(JmapEvent::UnsupportedSort) => "jmap.unsupported-sort", + EventType::Jmap(JmapEvent::UnknownMethod) => "jmap.unknown-method", + EventType::Jmap(JmapEvent::InvalidResultReference) => "jmap.invalid-result-reference", + EventType::Jmap(JmapEvent::Forbidden) => "jmap.forbidden", + EventType::Jmap(JmapEvent::AccountNotFound) => "jmap.account-not-found", + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => { + "jmap.account-not-supported-by-method" + } + EventType::Jmap(JmapEvent::AccountReadOnly) => "jmap.account-read-only", + EventType::Jmap(JmapEvent::NotFound) => "jmap.not-found", + EventType::Jmap(JmapEvent::CannotCalculateChanges) => "jmap.cannot-calculate-changes", + EventType::Jmap(JmapEvent::UnknownDataType) => "jmap.unknown-data-type", + EventType::Jmap(JmapEvent::UnknownCapability) => "jmap.unknown-capability", + EventType::Jmap(JmapEvent::NotJson) => "jmap.not-json", + EventType::Jmap(JmapEvent::NotRequest) => "jmap.not-request", + EventType::Jmap(JmapEvent::WebsocketStart) => "jmap.websocket-start", + EventType::Jmap(JmapEvent::WebsocketStop) => "jmap.websocket-stop", + EventType::Jmap(JmapEvent::WebsocketError) => "jmap.websocket-error", + EventType::Limit(LimitEvent::SizeRequest) => "limit.size-request", + EventType::Limit(LimitEvent::SizeUpload) => "limit.size-upload", + EventType::Limit(LimitEvent::CallsIn) => "limit.calls-in", + EventType::Limit(LimitEvent::ConcurrentRequest) => "limit.concurrent-request", + EventType::Limit(LimitEvent::ConcurrentUpload) => "limit.concurrent-upload", + EventType::Limit(LimitEvent::ConcurrentConnection) => "limit.concurrent-connection", + EventType::Limit(LimitEvent::Quota) => "limit.quota", + EventType::Limit(LimitEvent::BlobQuota) => "limit.blob-quota", + EventType::Limit(LimitEvent::TenantQuota) => "limit.tenant-quota", + EventType::Limit(LimitEvent::TooManyRequests) => "limit.too-many-requests", + EventType::MailAuth(MailAuthEvent::ParseError) => "mail-auth.parse-error", + EventType::MailAuth(MailAuthEvent::MissingParameters) => "mail-auth.missing-parameters", + EventType::MailAuth(MailAuthEvent::NoHeadersFound) => "mail-auth.no-headers-found", + EventType::MailAuth(MailAuthEvent::Crypto) => "mail-auth.crypto", + EventType::MailAuth(MailAuthEvent::Io) => "mail-auth.io", + EventType::MailAuth(MailAuthEvent::Base64) => "mail-auth.base64", + EventType::MailAuth(MailAuthEvent::DnsError) => "mail-auth.dns-error", + EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) => { + "mail-auth.dns-record-not-found" + } + EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType) => { + "mail-auth.dns-invalid-record-type" + } + EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => "mail-auth.policy-not-aligned", + EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { + "manage-sieve.connection-start" + } + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => { + "manage-sieve.connection-end" + } + EventType::ManageSieve(ManageSieveEvent::CreateScript) => "manage-sieve.create-script", + EventType::ManageSieve(ManageSieveEvent::UpdateScript) => "manage-sieve.update-script", + EventType::ManageSieve(ManageSieveEvent::GetScript) => "manage-sieve.get-script", + EventType::ManageSieve(ManageSieveEvent::DeleteScript) => "manage-sieve.delete-script", + EventType::ManageSieve(ManageSieveEvent::RenameScript) => "manage-sieve.rename-script", + EventType::ManageSieve(ManageSieveEvent::CheckScript) => "manage-sieve.check-script", + EventType::ManageSieve(ManageSieveEvent::HaveSpace) => "manage-sieve.have-space", + EventType::ManageSieve(ManageSieveEvent::ListScripts) => "manage-sieve.list-scripts", + EventType::ManageSieve(ManageSieveEvent::SetActive) => "manage-sieve.set-active", + EventType::ManageSieve(ManageSieveEvent::Capabilities) => "manage-sieve.capabilities", + EventType::ManageSieve(ManageSieveEvent::StartTls) => "manage-sieve.start-tls", + EventType::ManageSieve(ManageSieveEvent::Unauthenticate) => { + "manage-sieve.unauthenticate" + } + EventType::ManageSieve(ManageSieveEvent::Logout) => "manage-sieve.logout", + EventType::ManageSieve(ManageSieveEvent::Noop) => "manage-sieve.noop", + EventType::ManageSieve(ManageSieveEvent::Error) => "manage-sieve.error", + EventType::ManageSieve(ManageSieveEvent::RawInput) => "manage-sieve.raw-input", + EventType::ManageSieve(ManageSieveEvent::RawOutput) => "manage-sieve.raw-output", + EventType::MessageIngest(MessageIngestEvent::Ham) => "message-ingest.ham", + EventType::MessageIngest(MessageIngestEvent::Spam) => "message-ingest.spam", + EventType::MessageIngest(MessageIngestEvent::ImapAppend) => { + "message-ingest.imap-append" + } + EventType::MessageIngest(MessageIngestEvent::JmapAppend) => { + "message-ingest.jmap-append" + } + EventType::MessageIngest(MessageIngestEvent::Duplicate) => "message-ingest.duplicate", + EventType::MessageIngest(MessageIngestEvent::Error) => "message-ingest.error", + EventType::MessageIngest(MessageIngestEvent::SearchIndex) => { + "message-ingest.search-index" + } + EventType::Milter(MilterEvent::Read) => "milter.read", + EventType::Milter(MilterEvent::Write) => "milter.write", + EventType::Milter(MilterEvent::ActionAccept) => "milter.action-accept", + EventType::Milter(MilterEvent::ActionDiscard) => "milter.action-discard", + EventType::Milter(MilterEvent::ActionReject) => "milter.action-reject", + EventType::Milter(MilterEvent::ActionTempFail) => "milter.action-temp-fail", + EventType::Milter(MilterEvent::ActionReplyCode) => "milter.action-reply-code", + EventType::Milter(MilterEvent::ActionConnectionFailure) => { + "milter.action-connection-failure" + } + EventType::Milter(MilterEvent::ActionShutdown) => "milter.action-shutdown", + EventType::Milter(MilterEvent::IoError) => "milter.io-error", + EventType::Milter(MilterEvent::FrameTooLarge) => "milter.frame-too-large", + EventType::Milter(MilterEvent::FrameInvalid) => "milter.frame-invalid", + EventType::Milter(MilterEvent::UnexpectedResponse) => "milter.unexpected-response", + EventType::Milter(MilterEvent::Timeout) => "milter.timeout", + EventType::Milter(MilterEvent::TlsInvalidName) => "milter.tls-invalid-name", + EventType::Milter(MilterEvent::Disconnected) => "milter.disconnected", + EventType::Milter(MilterEvent::ParseError) => "milter.parse-error", + EventType::MtaHook(MtaHookEvent::ActionAccept) => "mta-hook.action-accept", + EventType::MtaHook(MtaHookEvent::ActionDiscard) => "mta-hook.action-discard", + EventType::MtaHook(MtaHookEvent::ActionReject) => "mta-hook.action-reject", + EventType::MtaHook(MtaHookEvent::ActionQuarantine) => "mta-hook.action-quarantine", + EventType::MtaHook(MtaHookEvent::Error) => "mta-hook.error", + EventType::MtaSts(MtaStsEvent::Authorized) => "mta-sts.authorized", + EventType::MtaSts(MtaStsEvent::NotAuthorized) => "mta-sts.not-authorized", + EventType::MtaSts(MtaStsEvent::PolicyFetch) => "mta-sts.policy-fetch", + EventType::MtaSts(MtaStsEvent::PolicyNotFound) => "mta-sts.policy-not-found", + EventType::MtaSts(MtaStsEvent::PolicyFetchError) => "mta-sts.policy-fetch-error", + EventType::MtaSts(MtaStsEvent::InvalidPolicy) => "mta-sts.invalid-policy", + EventType::Network(NetworkEvent::ListenStart) => "network.listen-start", + EventType::Network(NetworkEvent::ListenStop) => "network.listen-stop", + EventType::Network(NetworkEvent::ListenError) => "network.listen-error", + EventType::Network(NetworkEvent::BindError) => "network.bind-error", + EventType::Network(NetworkEvent::ReadError) => "network.read-error", + EventType::Network(NetworkEvent::WriteError) => "network.write-error", + EventType::Network(NetworkEvent::FlushError) => "network.flush-error", + EventType::Network(NetworkEvent::AcceptError) => "network.accept-error", + EventType::Network(NetworkEvent::SplitError) => "network.split-error", + EventType::Network(NetworkEvent::Timeout) => "network.timeout", + EventType::Network(NetworkEvent::Closed) => "network.closed", + EventType::Network(NetworkEvent::ProxyError) => "network.proxy-error", + EventType::Network(NetworkEvent::SetOptError) => "network.set-opt-error", + EventType::OutgoingReport(OutgoingReportEvent::SpfReport) => { + "outgoing-report.spf-report" + } + EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited) => { + "outgoing-report.spf-rate-limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DkimReport) => { + "outgoing-report.dkim-report" + } + EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited) => { + "outgoing-report.dkim-rate-limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DmarcReport) => { + "outgoing-report.dmarc-report" + } + EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited) => { + "outgoing-report.dmarc-rate-limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport) => { + "outgoing-report.dmarc-aggregate-report" + } + EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate) => { + "outgoing-report.tls-aggregate" + } + EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission) => { + "outgoing-report.http-submission" + } + EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress) => { + "outgoing-report.unauthorized-reporting-address" + } + EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError) => { + "outgoing-report.reporting-address-validation-error" + } + EventType::OutgoingReport(OutgoingReportEvent::NotFound) => "outgoing-report.not-found", + EventType::OutgoingReport(OutgoingReportEvent::SubmissionError) => { + "outgoing-report.submission-error" + } + EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound) => { + "outgoing-report.no-recipients-found" + } + EventType::OutgoingReport(OutgoingReportEvent::Locked) => "outgoing-report.locked", + EventType::OutgoingReport(OutgoingReportEvent::MaxSizeExceeded) => { + "outgoing-report.max-size-exceeded" + } + EventType::Pop3(Pop3Event::ConnectionStart) => "pop3.connection-start", + EventType::Pop3(Pop3Event::ConnectionEnd) => "pop3.connection-end", + EventType::Pop3(Pop3Event::Delete) => "pop3.delete", + EventType::Pop3(Pop3Event::Reset) => "pop3.reset", + EventType::Pop3(Pop3Event::Quit) => "pop3.quit", + EventType::Pop3(Pop3Event::Fetch) => "pop3.fetch", + EventType::Pop3(Pop3Event::List) => "pop3.list", + EventType::Pop3(Pop3Event::ListMessage) => "pop3.list-message", + EventType::Pop3(Pop3Event::Uidl) => "pop3.uidl", + EventType::Pop3(Pop3Event::UidlMessage) => "pop3.uidl-message", + EventType::Pop3(Pop3Event::Stat) => "pop3.stat", + EventType::Pop3(Pop3Event::Noop) => "pop3.noop", + EventType::Pop3(Pop3Event::Capabilities) => "pop3.capabilities", + EventType::Pop3(Pop3Event::StartTls) => "pop3.start-tls", + EventType::Pop3(Pop3Event::Utf8) => "pop3.utf8", + EventType::Pop3(Pop3Event::Error) => "pop3.error", + EventType::Pop3(Pop3Event::RawInput) => "pop3.raw-input", + EventType::Pop3(Pop3Event::RawOutput) => "pop3.raw-output", + EventType::PushSubscription(PushSubscriptionEvent::Success) => { + "push-subscription.success" + } + EventType::PushSubscription(PushSubscriptionEvent::Error) => "push-subscription.error", + EventType::PushSubscription(PushSubscriptionEvent::NotFound) => { + "push-subscription.not-found" + } + EventType::Queue(QueueEvent::Started) => "queue.started", + EventType::Queue(QueueEvent::MessageQueued) => "queue.message-queued", + EventType::Queue(QueueEvent::AuthenticatedMessageQueued) => { + "queue.authenticated-message-queued" + } + EventType::Queue(QueueEvent::ReportQueued) => "queue.report-queued", + EventType::Queue(QueueEvent::DsnQueued) => "queue.dsn-queued", + EventType::Queue(QueueEvent::AutogeneratedQueued) => "queue.autogenerated-queued", + EventType::Queue(QueueEvent::Rescheduled) => "queue.rescheduled", + EventType::Queue(QueueEvent::Locked) => "queue.locked", + EventType::Queue(QueueEvent::BlobNotFound) => "queue.blob-not-found", + EventType::Queue(QueueEvent::RateLimitExceeded) => "queue.rate-limit-exceeded", + EventType::Queue(QueueEvent::ConcurrencyLimitExceeded) => { + "queue.concurrency-limit-exceeded" + } + EventType::Queue(QueueEvent::QuotaExceeded) => "queue.quota-exceeded", + EventType::Queue(QueueEvent::BackPressure) => "queue.back-pressure", + EventType::Registry(RegistryEvent::LocalReadError) => "registry.local-read-error", + EventType::Registry(RegistryEvent::LocalWriteError) => "registry.local-write-error", + EventType::Registry(RegistryEvent::LocalParseError) => "registry.local-parse-error", + EventType::Registry(RegistryEvent::ReadError) => "registry.read-error", + EventType::Registry(RegistryEvent::WriteError) => "registry.write-error", + EventType::Registry(RegistryEvent::DeserializationError) => { + "registry.deserialization-error" + } + EventType::Registry(RegistryEvent::BuildError) => "registry.build-error", + EventType::Registry(RegistryEvent::BuildWarning) => "registry.build-warning", + EventType::Registry(RegistryEvent::NotSupported) => "registry.not-supported", + EventType::Registry(RegistryEvent::ValidationError) => "registry.validation-error", + EventType::Resource(ResourceEvent::NotFound) => "resource.not-found", + EventType::Resource(ResourceEvent::BadParameters) => "resource.bad-parameters", + EventType::Resource(ResourceEvent::Error) => "resource.error", + EventType::Resource(ResourceEvent::DownloadExternal) => "resource.download-external", + EventType::Resource(ResourceEvent::ApplicationUpdated) => { + "resource.application-updated" + } + EventType::Resource(ResourceEvent::ApplicationUnpacked) => { + "resource.application-unpacked" + } + EventType::Security(SecurityEvent::AuthenticationBan) => "security.authentication-ban", + EventType::Security(SecurityEvent::AbuseBan) => "security.abuse-ban", + EventType::Security(SecurityEvent::ScanBan) => "security.scan-ban", + EventType::Security(SecurityEvent::LoiterBan) => "security.loiter-ban", + EventType::Security(SecurityEvent::IpBlocked) => "security.ip-blocked", + EventType::Security(SecurityEvent::IpBlockExpired) => "security.ip-block-expired", + EventType::Security(SecurityEvent::IpAllowExpired) => "security.ip-allow-expired", + EventType::Security(SecurityEvent::IpUnauthorized) => "security.ip-unauthorized", + EventType::Security(SecurityEvent::Unauthorized) => "security.unauthorized", + EventType::Server(ServerEvent::Startup) => "server.startup", + EventType::Server(ServerEvent::Shutdown) => "server.shutdown", + EventType::Server(ServerEvent::StartupError) => "server.startup-error", + EventType::Server(ServerEvent::ThreadError) => "server.thread-error", + EventType::Server(ServerEvent::Licensing) => "server.licensing", + EventType::Server(ServerEvent::RecoveryMode) => "server.recovery-mode", + EventType::Server(ServerEvent::BootstrapMode) => "server.bootstrap-mode", + EventType::Sieve(SieveEvent::ActionAccept) => "sieve.action-accept", + EventType::Sieve(SieveEvent::ActionAcceptReplace) => "sieve.action-accept-replace", + EventType::Sieve(SieveEvent::ActionDiscard) => "sieve.action-discard", + EventType::Sieve(SieveEvent::ActionReject) => "sieve.action-reject", + EventType::Sieve(SieveEvent::SendMessage) => "sieve.send-message", + EventType::Sieve(SieveEvent::MessageTooLarge) => "sieve.message-too-large", + EventType::Sieve(SieveEvent::ScriptNotFound) => "sieve.script-not-found", + EventType::Sieve(SieveEvent::ListNotFound) => "sieve.list-not-found", + EventType::Sieve(SieveEvent::RuntimeError) => "sieve.runtime-error", + EventType::Sieve(SieveEvent::UnexpectedError) => "sieve.unexpected-error", + EventType::Sieve(SieveEvent::NotSupported) => "sieve.not-supported", + EventType::Sieve(SieveEvent::QuotaExceeded) => "sieve.quota-exceeded", + EventType::Smtp(SmtpEvent::ConnectionStart) => "smtp.connection-start", + EventType::Smtp(SmtpEvent::ConnectionEnd) => "smtp.connection-end", + EventType::Smtp(SmtpEvent::Error) => "smtp.error", + EventType::Smtp(SmtpEvent::IdNotFound) => "smtp.id-not-found", + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded) => { + "smtp.concurrency-limit-exceeded" + } + EventType::Smtp(SmtpEvent::TransferLimitExceeded) => "smtp.transfer-limit-exceeded", + EventType::Smtp(SmtpEvent::RateLimitExceeded) => "smtp.rate-limit-exceeded", + EventType::Smtp(SmtpEvent::TimeLimitExceeded) => "smtp.time-limit-exceeded", + EventType::Smtp(SmtpEvent::MissingAuthDirectory) => "smtp.missing-auth-directory", + EventType::Smtp(SmtpEvent::MessageParseFailed) => "smtp.message-parse-failed", + EventType::Smtp(SmtpEvent::MessageTooLarge) => "smtp.message-too-large", + EventType::Smtp(SmtpEvent::LoopDetected) => "smtp.loop-detected", + EventType::Smtp(SmtpEvent::DkimPass) => "smtp.dkim-pass", + EventType::Smtp(SmtpEvent::DkimFail) => "smtp.dkim-fail", + EventType::Smtp(SmtpEvent::Dkim2Pass) => "smtp.dkim2-pass", + EventType::Smtp(SmtpEvent::Dkim2Fail) => "smtp.dkim2-fail", + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded) => "smtp.dkim2-dsn-discarded", + EventType::Smtp(SmtpEvent::ArcPass) => "smtp.arc-pass", + EventType::Smtp(SmtpEvent::ArcFail) => "smtp.arc-fail", + EventType::Smtp(SmtpEvent::SpfEhloPass) => "smtp.spf-ehlo-pass", + EventType::Smtp(SmtpEvent::SpfEhloFail) => "smtp.spf-ehlo-fail", + EventType::Smtp(SmtpEvent::SpfFromPass) => "smtp.spf-from-pass", + EventType::Smtp(SmtpEvent::SpfFromFail) => "smtp.spf-from-fail", + EventType::Smtp(SmtpEvent::DmarcPass) => "smtp.dmarc-pass", + EventType::Smtp(SmtpEvent::DmarcFail) => "smtp.dmarc-fail", + EventType::Smtp(SmtpEvent::IprevPass) => "smtp.iprev-pass", + EventType::Smtp(SmtpEvent::IprevFail) => "smtp.iprev-fail", + EventType::Smtp(SmtpEvent::TooManyMessages) => "smtp.too-many-messages", + EventType::Smtp(SmtpEvent::Ehlo) => "smtp.ehlo", + EventType::Smtp(SmtpEvent::InvalidEhlo) => "smtp.invalid-ehlo", + EventType::Smtp(SmtpEvent::DidNotSayEhlo) => "smtp.did-not-say-ehlo", + EventType::Smtp(SmtpEvent::EhloExpected) => "smtp.ehlo-expected", + EventType::Smtp(SmtpEvent::LhloExpected) => "smtp.lhlo-expected", + EventType::Smtp(SmtpEvent::MailFromUnauthenticated) => "smtp.mail-from-unauthenticated", + EventType::Smtp(SmtpEvent::MailFromUnauthorized) => "smtp.mail-from-unauthorized", + EventType::Smtp(SmtpEvent::MailFromNotAllowed) => "smtp.mail-from-not-allowed", + EventType::Smtp(SmtpEvent::MailFromRewritten) => "smtp.mail-from-rewritten", + EventType::Smtp(SmtpEvent::MailFromMissing) => "smtp.mail-from-missing", + EventType::Smtp(SmtpEvent::MailFrom) => "smtp.mail-from", + EventType::Smtp(SmtpEvent::MultipleMailFrom) => "smtp.multiple-mail-from", + EventType::Smtp(SmtpEvent::MailboxDoesNotExist) => "smtp.mailbox-does-not-exist", + EventType::Smtp(SmtpEvent::RelayNotAllowed) => "smtp.relay-not-allowed", + EventType::Smtp(SmtpEvent::RcptTo) => "smtp.rcpt-to", + EventType::Smtp(SmtpEvent::RcptToDuplicate) => "smtp.rcpt-to-duplicate", + EventType::Smtp(SmtpEvent::RcptToRewritten) => "smtp.rcpt-to-rewritten", + EventType::Smtp(SmtpEvent::RcptToMissing) => "smtp.rcpt-to-missing", + EventType::Smtp(SmtpEvent::RcptToGreylisted) => "smtp.rcpt-to-greylisted", + EventType::Smtp(SmtpEvent::TooManyRecipients) => "smtp.too-many-recipients", + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt) => "smtp.too-many-invalid-rcpt", + EventType::Smtp(SmtpEvent::RawInput) => "smtp.raw-input", + EventType::Smtp(SmtpEvent::RawOutput) => "smtp.raw-output", + EventType::Smtp(SmtpEvent::MissingLocalHostname) => "smtp.missing-local-hostname", + EventType::Smtp(SmtpEvent::Vrfy) => "smtp.vrfy", + EventType::Smtp(SmtpEvent::VrfyNotFound) => "smtp.vrfy-not-found", + EventType::Smtp(SmtpEvent::VrfyDisabled) => "smtp.vrfy-disabled", + EventType::Smtp(SmtpEvent::Expn) => "smtp.expn", + EventType::Smtp(SmtpEvent::ExpnNotFound) => "smtp.expn-not-found", + EventType::Smtp(SmtpEvent::ExpnDisabled) => "smtp.expn-disabled", + EventType::Smtp(SmtpEvent::RequireTlsDisabled) => "smtp.require-tls-disabled", + EventType::Smtp(SmtpEvent::DeliverByDisabled) => "smtp.deliver-by-disabled", + EventType::Smtp(SmtpEvent::DeliverByInvalid) => "smtp.deliver-by-invalid", + EventType::Smtp(SmtpEvent::FutureReleaseDisabled) => "smtp.future-release-disabled", + EventType::Smtp(SmtpEvent::FutureReleaseInvalid) => "smtp.future-release-invalid", + EventType::Smtp(SmtpEvent::MtPriorityDisabled) => "smtp.mt-priority-disabled", + EventType::Smtp(SmtpEvent::MtPriorityInvalid) => "smtp.mt-priority-invalid", + EventType::Smtp(SmtpEvent::DsnDisabled) => "smtp.dsn-disabled", + EventType::Smtp(SmtpEvent::AuthNotAllowed) => "smtp.auth-not-allowed", + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported) => { + "smtp.auth-mechanism-not-supported" + } + EventType::Smtp(SmtpEvent::AuthExchangeTooLong) => "smtp.auth-exchange-too-long", + EventType::Smtp(SmtpEvent::AlreadyAuthenticated) => "smtp.already-authenticated", + EventType::Smtp(SmtpEvent::Noop) => "smtp.noop", + EventType::Smtp(SmtpEvent::StartTls) => "smtp.start-tls", + EventType::Smtp(SmtpEvent::StartTlsUnavailable) => "smtp.start-tls-unavailable", + EventType::Smtp(SmtpEvent::StartTlsAlready) => "smtp.start-tls-already", + EventType::Smtp(SmtpEvent::Rset) => "smtp.rset", + EventType::Smtp(SmtpEvent::Quit) => "smtp.quit", + EventType::Smtp(SmtpEvent::Help) => "smtp.help", + EventType::Smtp(SmtpEvent::CommandNotImplemented) => "smtp.command-not-implemented", + EventType::Smtp(SmtpEvent::InvalidCommand) => "smtp.invalid-command", + EventType::Smtp(SmtpEvent::InvalidSenderAddress) => "smtp.invalid-sender-address", + EventType::Smtp(SmtpEvent::InvalidRecipientAddress) => "smtp.invalid-recipient-address", + EventType::Smtp(SmtpEvent::InvalidParameter) => "smtp.invalid-parameter", + EventType::Smtp(SmtpEvent::UnsupportedParameter) => "smtp.unsupported-parameter", + EventType::Smtp(SmtpEvent::SyntaxError) => "smtp.syntax-error", + EventType::Smtp(SmtpEvent::RequestTooLarge) => "smtp.request-too-large", + EventType::Spam(SpamEvent::Pyzor) => "spam.pyzor", + EventType::Spam(SpamEvent::PyzorError) => "spam.pyzor-error", + EventType::Spam(SpamEvent::Dnsbl) => "spam.dnsbl", + EventType::Spam(SpamEvent::DnsblError) => "spam.dnsbl-error", + EventType::Spam(SpamEvent::TrainStarted) => "spam.train-started", + EventType::Spam(SpamEvent::TrainCompleted) => "spam.train-completed", + EventType::Spam(SpamEvent::TrainSampleAdded) => "spam.train-sample-added", + EventType::Spam(SpamEvent::TrainSampleNotFound) => "spam.train-sample-not-found", + EventType::Spam(SpamEvent::Classify) => "spam.classify", + EventType::Spam(SpamEvent::ModelLoaded) => "spam.model-loaded", + EventType::Spam(SpamEvent::ModelNotReady) => "spam.model-not-ready", + EventType::Spam(SpamEvent::ModelNotFound) => "spam.model-not-found", + EventType::Spam(SpamEvent::RulesUpdated) => "spam.rules-updated", + EventType::Spf(SpfEvent::Pass) => "spf.pass", + EventType::Spf(SpfEvent::Fail) => "spf.fail", + EventType::Spf(SpfEvent::SoftFail) => "spf.soft-fail", + EventType::Spf(SpfEvent::Neutral) => "spf.neutral", + EventType::Spf(SpfEvent::TempError) => "spf.temp-error", + EventType::Spf(SpfEvent::PermError) => "spf.perm-error", + EventType::Spf(SpfEvent::None) => "spf.none", + EventType::Store(StoreEvent::AssertValueFailed) => "store.assert-value-failed", + EventType::Store(StoreEvent::FoundationdbError) => "store.foundationdb-error", + EventType::Store(StoreEvent::MysqlError) => "store.mysql-error", + EventType::Store(StoreEvent::PostgresqlError) => "store.postgresql-error", + EventType::Store(StoreEvent::RocksdbError) => "store.rocksdb-error", + EventType::Store(StoreEvent::SqliteError) => "store.sqlite-error", + EventType::Store(StoreEvent::LdapError) => "store.ldap-error", + EventType::Store(StoreEvent::ElasticsearchError) => "store.elasticsearch-error", + EventType::Store(StoreEvent::MeilisearchError) => "store.meilisearch-error", + EventType::Store(StoreEvent::RedisError) => "store.redis-error", + EventType::Store(StoreEvent::S3Error) => "store.s3-error", + EventType::Store(StoreEvent::AzureError) => "store.azure-error", + EventType::Store(StoreEvent::FilesystemError) => "store.filesystem-error", + EventType::Store(StoreEvent::PoolError) => "store.pool-error", + EventType::Store(StoreEvent::DataCorruption) => "store.data-corruption", + EventType::Store(StoreEvent::DecompressError) => "store.decompress-error", + EventType::Store(StoreEvent::DeserializeError) => "store.deserialize-error", + EventType::Store(StoreEvent::NotFound) => "store.not-found", + EventType::Store(StoreEvent::NotConfigured) => "store.not-configured", + EventType::Store(StoreEvent::NotSupported) => "store.not-supported", + EventType::Store(StoreEvent::UnexpectedError) => "store.unexpected-error", + EventType::Store(StoreEvent::CryptoError) => "store.crypto-error", + EventType::Store(StoreEvent::HttpStoreError) => "store.http-store-error", + EventType::Store(StoreEvent::CacheMiss) => "store.cache-miss", + EventType::Store(StoreEvent::CacheHit) => "store.cache-hit", + EventType::Store(StoreEvent::CacheStale) => "store.cache-stale", + EventType::Store(StoreEvent::CacheUpdate) => "store.cache-update", + EventType::Store(StoreEvent::CacheEntryTooLarge) => "store.cache-entry-too-large", + EventType::Store(StoreEvent::BlobMissingMarker) => "store.blob-missing-marker", + EventType::Store(StoreEvent::DataWrite) => "store.data-write", + EventType::Store(StoreEvent::DataIterate) => "store.data-iterate", + EventType::Store(StoreEvent::BlobRead) => "store.blob-read", + EventType::Store(StoreEvent::BlobWrite) => "store.blob-write", + EventType::Store(StoreEvent::BlobDelete) => "store.blob-delete", + EventType::Store(StoreEvent::SqlQuery) => "store.sql-query", + EventType::Store(StoreEvent::LdapQuery) => "store.ldap-query", + EventType::Store(StoreEvent::LdapWarning) => "store.ldap-warning", + EventType::Store(StoreEvent::HttpStoreFetch) => "store.http-store-fetch", + EventType::Store(StoreEvent::AutoExpunge) => "store.auto-expunge", + EventType::Store(StoreEvent::BlobStorePurged) => "store.blob-store-purged", + EventType::Store(StoreEvent::DataStorePurged) => "store.data-store-purged", + EventType::TaskManager(TaskManagerEvent::TaskAcquired) => "task-manager.task-acquired", + EventType::TaskManager(TaskManagerEvent::TaskQueued) => "task-manager.task-queued", + EventType::TaskManager(TaskManagerEvent::TaskScheduled) => { + "task-manager.task-scheduled" + } + EventType::TaskManager(TaskManagerEvent::TaskLocked) => "task-manager.task-locked", + EventType::TaskManager(TaskManagerEvent::TaskIgnored) => "task-manager.task-ignored", + EventType::TaskManager(TaskManagerEvent::TaskFailed) => "task-manager.task-failed", + EventType::TaskManager(TaskManagerEvent::TaskRetry) => "task-manager.task-retry", + EventType::TaskManager(TaskManagerEvent::BlobNotFound) => "task-manager.blob-not-found", + EventType::TaskManager(TaskManagerEvent::MetadataNotFound) => { + "task-manager.metadata-not-found" + } + EventType::TaskManager(TaskManagerEvent::SchedulerStarted) => { + "task-manager.scheduler-started" + } + EventType::TaskManager(TaskManagerEvent::ManagerStarted) => { + "task-manager.manager-started" + } + EventType::Telemetry(TelemetryEvent::AlertEvent) => "telemetry.alert-event", + EventType::Telemetry(TelemetryEvent::AlertMessage) => "telemetry.alert-message", + EventType::Telemetry(TelemetryEvent::LogError) => "telemetry.log-error", + EventType::Telemetry(TelemetryEvent::WebhookError) => "telemetry.webhook-error", + EventType::Telemetry(TelemetryEvent::OtelExporterError) => { + "telemetry.otel-exporter-error" + } + EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError) => { + "telemetry.otel-metrics-exporter-error" + } + EventType::Telemetry(TelemetryEvent::PrometheusExporterError) => { + "telemetry.prometheus-exporter-error" + } + EventType::Telemetry(TelemetryEvent::JournalError) => "telemetry.journal-error", + EventType::Telemetry(TelemetryEvent::MetricsCollected) => "telemetry.metrics-collected", + EventType::Telemetry(TelemetryEvent::MetricsStored) => "telemetry.metrics-stored", + EventType::Telemetry(TelemetryEvent::MetricsPushed) => "telemetry.metrics-pushed", + EventType::Tls(TlsEvent::Handshake) => "tls.handshake", + EventType::Tls(TlsEvent::HandshakeError) => "tls.handshake-error", + EventType::Tls(TlsEvent::NotConfigured) => "tls.not-configured", + EventType::Tls(TlsEvent::CertificateNotFound) => "tls.certificate-not-found", + EventType::Tls(TlsEvent::NoCertificatesAvailable) => "tls.no-certificates-available", + EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => { + "tls.multiple-certificates-available" + } + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => { + "tls.expired-certificate-removed" + } + EventType::TlsRpt(TlsRptEvent::RecordFetch) => "tls-rpt.record-fetch", + EventType::TlsRpt(TlsRptEvent::RecordFetchError) => "tls-rpt.record-fetch-error", + EventType::TlsRpt(TlsRptEvent::RecordNotFound) => "tls-rpt.record-not-found", + EventType::WebDav(WebDavEvent::Propfind) => "web-dav.propfind", + EventType::WebDav(WebDavEvent::Proppatch) => "web-dav.proppatch", + EventType::WebDav(WebDavEvent::Get) => "web-dav.get", + EventType::WebDav(WebDavEvent::Head) => "web-dav.head", + EventType::WebDav(WebDavEvent::Report) => "web-dav.report", + EventType::WebDav(WebDavEvent::Mkcol) => "web-dav.mkcol", + EventType::WebDav(WebDavEvent::Mkcalendar) => "web-dav.mkcalendar", + EventType::WebDav(WebDavEvent::Delete) => "web-dav.delete", + EventType::WebDav(WebDavEvent::Put) => "web-dav.put", + EventType::WebDav(WebDavEvent::Post) => "web-dav.post", + EventType::WebDav(WebDavEvent::Patch) => "web-dav.patch", + EventType::WebDav(WebDavEvent::Copy) => "web-dav.copy", + EventType::WebDav(WebDavEvent::Move) => "web-dav.move", + EventType::WebDav(WebDavEvent::Lock) => "web-dav.lock", + EventType::WebDav(WebDavEvent::Unlock) => "web-dav.unlock", + EventType::WebDav(WebDavEvent::Acl) => "web-dav.acl", + EventType::WebDav(WebDavEvent::Options) => "web-dav.options", + EventType::WebDav(WebDavEvent::Error) => "web-dav.error", + } + } + + pub const fn to_id(&self) -> u16 { + match self { + EventType::Acme(AcmeEvent::AuthStart) => 3, + EventType::Acme(AcmeEvent::AuthPending) => 2, + EventType::Acme(AcmeEvent::AuthValid) => 5, + EventType::Acme(AcmeEvent::AuthCompleted) => 0, + EventType::Acme(AcmeEvent::AuthError) => 1, + EventType::Acme(AcmeEvent::AuthTooManyAttempts) => 4, + EventType::Acme(AcmeEvent::ProcessCert) => 22, + EventType::Acme(AcmeEvent::OrderStart) => 20, + EventType::Acme(AcmeEvent::OrderProcessing) => 18, + EventType::Acme(AcmeEvent::OrderCompleted) => 16, + EventType::Acme(AcmeEvent::OrderReady) => 19, + EventType::Acme(AcmeEvent::OrderValid) => 21, + EventType::Acme(AcmeEvent::OrderInvalid) => 17, + EventType::Acme(AcmeEvent::RenewBackoff) => 23, + EventType::Acme(AcmeEvent::ClientSuppliedSni) => 7, + EventType::Acme(AcmeEvent::ClientMissingSni) => 6, + EventType::Acme(AcmeEvent::TlsAlpnReceived) => 25, + EventType::Acme(AcmeEvent::TlsAlpnError) => 24, + EventType::Acme(AcmeEvent::TokenNotFound) => 26, + EventType::Acme(AcmeEvent::Error) => 15, + EventType::Ai(AiEvent::LlmResponse) => 556, + EventType::Ai(AiEvent::ApiError) => 557, + EventType::Arc(ArcEvent::ChainTooLong) => 28, + EventType::Arc(ArcEvent::InvalidInstance) => 31, + EventType::Arc(ArcEvent::InvalidCv) => 30, + EventType::Arc(ArcEvent::HasHeaderTag) => 29, + EventType::Arc(ArcEvent::BrokenChain) => 27, + EventType::Arc(ArcEvent::SealerNotFound) => 32, + EventType::Auth(AuthEvent::Success) => 37, + EventType::Auth(AuthEvent::Failed) => 35, + EventType::Auth(AuthEvent::TokenExpired) => 554, + EventType::Auth(AuthEvent::MfaRequired) => 36, + EventType::Auth(AuthEvent::TooManyAttempts) => 38, + EventType::Auth(AuthEvent::ClientRegistration) => 555, + EventType::Auth(AuthEvent::Error) => 34, + EventType::Auth(AuthEvent::Warning) => 595, + EventType::Auth(AuthEvent::CredentialExpired) => 276, + EventType::Calendar(CalendarEvent::RuleExpansionError) => 576, + EventType::Calendar(CalendarEvent::AlarmSent) => 579, + EventType::Calendar(CalendarEvent::AlarmSkipped) => 580, + EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => 581, + EventType::Calendar(CalendarEvent::AlarmFailed) => 582, + EventType::Calendar(CalendarEvent::ItipMessageSent) => 583, + EventType::Calendar(CalendarEvent::ItipMessageReceived) => 584, + EventType::Calendar(CalendarEvent::ItipMessageError) => 585, + EventType::Cluster(ClusterEvent::Startup) => 278, + EventType::Cluster(ClusterEvent::SubscriberStart) => 39, + EventType::Cluster(ClusterEvent::SubscriberStop) => 40, + EventType::Cluster(ClusterEvent::SubscriberError) => 41, + EventType::Cluster(ClusterEvent::SubscriberDisconnected) => 42, + EventType::Cluster(ClusterEvent::PublisherStart) => 43, + EventType::Cluster(ClusterEvent::PublisherStop) => 44, + EventType::Cluster(ClusterEvent::PublisherError) => 45, + EventType::Cluster(ClusterEvent::MessageReceived) => 46, + EventType::Cluster(ClusterEvent::MessageSkipped) => 47, + EventType::Cluster(ClusterEvent::MessageInvalid) => 49, + EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275, + EventType::Dane(DaneEvent::AuthenticationSuccess) => 67, + EventType::Dane(DaneEvent::AuthenticationFailure) => 66, + EventType::Dane(DaneEvent::NoCertificatesFound) => 69, + EventType::Dane(DaneEvent::CertificateParseError) => 68, + EventType::Dane(DaneEvent::TlsaRecordMatch) => 73, + EventType::Dane(DaneEvent::TlsaRecordFetch) => 70, + EventType::Dane(DaneEvent::TlsaRecordFetchError) => 71, + EventType::Dane(DaneEvent::TlsaRecordNotFound) => 75, + EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned) => 74, + EventType::Dane(DaneEvent::TlsaRecordInvalid) => 72, + EventType::Dane(DaneEvent::BogusDnssecRecord) => 605, + EventType::Delivery(DeliveryEvent::AttemptStart) => 77, + EventType::Delivery(DeliveryEvent::AttemptEnd) => 76, + EventType::Delivery(DeliveryEvent::Completed) => 80, + EventType::Delivery(DeliveryEvent::Failed) => 92, + EventType::Delivery(DeliveryEvent::DomainDeliveryStart) => 85, + EventType::Delivery(DeliveryEvent::MxLookup) => 101, + EventType::Delivery(DeliveryEvent::MxLookupFailed) => 102, + EventType::Delivery(DeliveryEvent::IpLookup) => 95, + EventType::Delivery(DeliveryEvent::IpLookupFailed) => 96, + EventType::Delivery(DeliveryEvent::NullMx) => 103, + EventType::Delivery(DeliveryEvent::Connect) => 82, + EventType::Delivery(DeliveryEvent::ConnectError) => 83, + EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => 100, + EventType::Delivery(DeliveryEvent::GreetingFailed) => 93, + EventType::Delivery(DeliveryEvent::Ehlo) => 90, + EventType::Delivery(DeliveryEvent::EhloRejected) => 91, + EventType::Delivery(DeliveryEvent::Auth) => 78, + EventType::Delivery(DeliveryEvent::AuthFailed) => 79, + EventType::Delivery(DeliveryEvent::MailFrom) => 97, + EventType::Delivery(DeliveryEvent::MailFromRejected) => 98, + EventType::Delivery(DeliveryEvent::Delivered) => 84, + EventType::Delivery(DeliveryEvent::RcptTo) => 107, + EventType::Delivery(DeliveryEvent::RcptToRejected) => 109, + EventType::Delivery(DeliveryEvent::RcptToFailed) => 108, + EventType::Delivery(DeliveryEvent::MessageRejected) => 99, + EventType::Delivery(DeliveryEvent::StartTls) => 110, + EventType::Delivery(DeliveryEvent::StartTlsUnavailable) => 113, + EventType::Delivery(DeliveryEvent::StartTlsError) => 112, + EventType::Delivery(DeliveryEvent::StartTlsDisabled) => 111, + EventType::Delivery(DeliveryEvent::ImplicitTlsError) => 94, + EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => 81, + EventType::Delivery(DeliveryEvent::RateLimitExceeded) => 104, + EventType::Delivery(DeliveryEvent::DoubleBounce) => 86, + EventType::Delivery(DeliveryEvent::DsnSuccess) => 88, + EventType::Delivery(DeliveryEvent::DsnTempFail) => 89, + EventType::Delivery(DeliveryEvent::DsnPermFail) => 87, + EventType::Delivery(DeliveryEvent::RawInput) => 105, + EventType::Delivery(DeliveryEvent::RawOutput) => 106, + EventType::Dkim(DkimEvent::Pass) => 121, + EventType::Dkim(DkimEvent::Neutral) => 119, + EventType::Dkim(DkimEvent::Fail) => 114, + EventType::Dkim(DkimEvent::PermError) => 122, + EventType::Dkim(DkimEvent::TempError) => 127, + EventType::Dkim(DkimEvent::None) => 120, + EventType::Dkim(DkimEvent::UnsupportedVersion) => 131, + EventType::Dkim(DkimEvent::UnsupportedAlgorithm) => 128, + EventType::Dkim(DkimEvent::UnsupportedCanonicalization) => 129, + EventType::Dkim(DkimEvent::UnsupportedKeyType) => 130, + EventType::Dkim(DkimEvent::FailedBodyHashMatch) => 116, + EventType::Dkim(DkimEvent::FailedVerification) => 117, + EventType::Dkim(DkimEvent::FailedAuidMatch) => 115, + EventType::Dkim(DkimEvent::RevokedPublicKey) => 123, + EventType::Dkim(DkimEvent::IncompatibleAlgorithms) => 118, + EventType::Dkim(DkimEvent::SignatureExpired) => 124, + EventType::Dkim(DkimEvent::SignatureLength) => 125, + EventType::Dkim(DkimEvent::SignerNotFound) => 126, + EventType::Dkim(DkimEvent::BuildError) => 592, + EventType::Dkim(DkimEvent::SignatureCreated) => 596, + EventType::Dkim(DkimEvent::SignaturePublished) => 597, + EventType::Dkim(DkimEvent::SignatureRetiring) => 598, + EventType::Dkim(DkimEvent::SignatureRetired) => 599, + EventType::Dkim(DkimEvent::SignatureDeleted) => 600, + EventType::Dkim(DkimEvent::InstanceMissing) => 606, + EventType::Dkim(DkimEvent::InstanceSyntax) => 607, + EventType::Dkim(DkimEvent::InstanceTagMissing) => 608, + EventType::Dkim(DkimEvent::InstanceNotSigned) => 609, + EventType::Dkim(DkimEvent::InstanceAboveSignature) => 610, + EventType::Dkim(DkimEvent::SignatureMissing) => 611, + EventType::Dkim(DkimEvent::SignatureSyntax) => 612, + EventType::Dkim(DkimEvent::SignatureTagMissing) => 613, + EventType::Dkim(DkimEvent::SignatureTagUnexpected) => 614, + EventType::Dkim(DkimEvent::SequenceGap) => 615, + EventType::Dkim(DkimEvent::SequenceOverflow) => 616, + EventType::Dkim(DkimEvent::ChainTooLong) => 635, + EventType::Dkim(DkimEvent::MailFromMismatch) => 617, + EventType::Dkim(DkimEvent::RcptToMismatch) => 618, + EventType::Dkim(DkimEvent::MailFromDomainMismatch) => 619, + EventType::Dkim(DkimEvent::NextDomainMismatch) => 620, + EventType::Dkim(DkimEvent::CustodyBreak) => 636, + EventType::Dkim(DkimEvent::PublicKeyFetch) => 621, + EventType::Dkim(DkimEvent::PublicKeyMissing) => 622, + EventType::Dkim(DkimEvent::PublicKeyMultiple) => 623, + EventType::Dkim(DkimEvent::PublicKeySyntax) => 624, + EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch) => 625, + EventType::Dkim(DkimEvent::NoValidAlgorithm) => 626, + EventType::Dkim(DkimEvent::HeaderHashMismatch) => 627, + EventType::Dkim(DkimEvent::Modified) => 628, + EventType::Dkim(DkimEvent::Exploded) => 629, + EventType::Dmarc(DmarcEvent::Pass) => 134, + EventType::Dmarc(DmarcEvent::Fail) => 132, + EventType::Dmarc(DmarcEvent::PermError) => 135, + EventType::Dmarc(DmarcEvent::TempError) => 136, + EventType::Dmarc(DmarcEvent::None) => 133, + EventType::Dns(DnsEvent::RecordCreated) => 8, + EventType::Dns(DnsEvent::RecordCreationFailed) => 9, + EventType::Dns(DnsEvent::RecordDeletionFailed) => 10, + EventType::Dns(DnsEvent::RecordNotPropagated) => 12, + EventType::Dns(DnsEvent::RecordLookupFailed) => 11, + EventType::Dns(DnsEvent::RecordPropagated) => 13, + EventType::Dns(DnsEvent::RecordPropagationTimeout) => 14, + EventType::Dns(DnsEvent::BuildError) => 591, + EventType::Eval(EvalEvent::Result) => 139, + EventType::Eval(EvalEvent::Error) => 138, + EventType::Eval(EvalEvent::DirectoryNotFound) => 137, + EventType::Eval(EvalEvent::StoreNotFound) => 140, + EventType::Http(HttpEvent::ConnectionStart) => 153, + EventType::Http(HttpEvent::ConnectionEnd) => 152, + EventType::Http(HttpEvent::Error) => 154, + EventType::Http(HttpEvent::RequestUrl) => 156, + EventType::Http(HttpEvent::RequestBody) => 155, + EventType::Http(HttpEvent::ResponseBody) => 157, + EventType::Http(HttpEvent::XForwardedMissing) => 158, + EventType::Imap(ImapEvent::ConnectionStart) => 163, + EventType::Imap(ImapEvent::ConnectionEnd) => 162, + EventType::Imap(ImapEvent::GetAcl) => 171, + EventType::Imap(ImapEvent::SetAcl) => 188, + EventType::Imap(ImapEvent::MyRights) => 180, + EventType::Imap(ImapEvent::ListRights) => 176, + EventType::Imap(ImapEvent::Append) => 159, + EventType::Imap(ImapEvent::Capabilities) => 160, + EventType::Imap(ImapEvent::Id) => 172, + EventType::Imap(ImapEvent::Close) => 161, + EventType::Imap(ImapEvent::Copy) => 164, + EventType::Imap(ImapEvent::Move) => 179, + EventType::Imap(ImapEvent::CreateMailbox) => 165, + EventType::Imap(ImapEvent::DeleteMailbox) => 166, + EventType::Imap(ImapEvent::RenameMailbox) => 185, + EventType::Imap(ImapEvent::Enable) => 167, + EventType::Imap(ImapEvent::Expunge) => 169, + EventType::Imap(ImapEvent::Fetch) => 170, + EventType::Imap(ImapEvent::IdleStart) => 173, + EventType::Imap(ImapEvent::IdleStop) => 174, + EventType::Imap(ImapEvent::List) => 175, + EventType::Imap(ImapEvent::Lsub) => 178, + EventType::Imap(ImapEvent::Logout) => 177, + EventType::Imap(ImapEvent::Namespace) => 181, + EventType::Imap(ImapEvent::Noop) => 182, + EventType::Imap(ImapEvent::Search) => 186, + EventType::Imap(ImapEvent::Sort) => 189, + EventType::Imap(ImapEvent::Select) => 187, + EventType::Imap(ImapEvent::Status) => 190, + EventType::Imap(ImapEvent::Store) => 191, + EventType::Imap(ImapEvent::Subscribe) => 192, + EventType::Imap(ImapEvent::Unsubscribe) => 194, + EventType::Imap(ImapEvent::Thread) => 193, + EventType::Imap(ImapEvent::UidBatches) => 634, + EventType::Imap(ImapEvent::GetQuota) => 57, + EventType::Imap(ImapEvent::Error) => 168, + EventType::Imap(ImapEvent::RawInput) => 183, + EventType::Imap(ImapEvent::RawOutput) => 184, + EventType::IncomingReport(IncomingReportEvent::DmarcReport) => 200, + EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings) => 201, + EventType::IncomingReport(IncomingReportEvent::TlsReport) => 206, + EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings) => 207, + EventType::IncomingReport(IncomingReportEvent::AbuseReport) => 195, + EventType::IncomingReport(IncomingReportEvent::AuthFailureReport) => 197, + EventType::IncomingReport(IncomingReportEvent::FraudReport) => 202, + EventType::IncomingReport(IncomingReportEvent::NotSpamReport) => 204, + EventType::IncomingReport(IncomingReportEvent::VirusReport) => 209, + EventType::IncomingReport(IncomingReportEvent::OtherReport) => 205, + EventType::IncomingReport(IncomingReportEvent::MessageParseFailed) => 203, + EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed) => 199, + EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed) => 208, + EventType::IncomingReport(IncomingReportEvent::ArfParseFailed) => 196, + EventType::IncomingReport(IncomingReportEvent::DecompressError) => 198, + EventType::Iprev(IprevEvent::Pass) => 212, + EventType::Iprev(IprevEvent::Fail) => 210, + EventType::Iprev(IprevEvent::PermError) => 213, + EventType::Iprev(IprevEvent::TempError) => 214, + EventType::Iprev(IprevEvent::None) => 211, + EventType::Jmap(JmapEvent::MethodCall) => 223, + EventType::Jmap(JmapEvent::InvalidArguments) => 221, + EventType::Jmap(JmapEvent::RequestTooLarge) => 227, + EventType::Jmap(JmapEvent::StateMismatch) => 228, + EventType::Jmap(JmapEvent::AnchorNotFound) => 218, + EventType::Jmap(JmapEvent::UnsupportedFilter) => 232, + EventType::Jmap(JmapEvent::UnsupportedSort) => 233, + EventType::Jmap(JmapEvent::UnknownMethod) => 231, + EventType::Jmap(JmapEvent::InvalidResultReference) => 222, + EventType::Jmap(JmapEvent::Forbidden) => 220, + EventType::Jmap(JmapEvent::AccountNotFound) => 215, + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => 216, + EventType::Jmap(JmapEvent::AccountReadOnly) => 217, + EventType::Jmap(JmapEvent::NotFound) => 224, + EventType::Jmap(JmapEvent::CannotCalculateChanges) => 219, + EventType::Jmap(JmapEvent::UnknownDataType) => 230, + EventType::Jmap(JmapEvent::UnknownCapability) => 229, + EventType::Jmap(JmapEvent::NotJson) => 225, + EventType::Jmap(JmapEvent::NotRequest) => 226, + EventType::Jmap(JmapEvent::WebsocketStart) => 235, + EventType::Jmap(JmapEvent::WebsocketStop) => 236, + EventType::Jmap(JmapEvent::WebsocketError) => 234, + EventType::Limit(LimitEvent::SizeRequest) => 243, + EventType::Limit(LimitEvent::SizeUpload) => 244, + EventType::Limit(LimitEvent::CallsIn) => 238, + EventType::Limit(LimitEvent::ConcurrentRequest) => 240, + EventType::Limit(LimitEvent::ConcurrentUpload) => 241, + EventType::Limit(LimitEvent::ConcurrentConnection) => 239, + EventType::Limit(LimitEvent::Quota) => 242, + EventType::Limit(LimitEvent::BlobQuota) => 237, + EventType::Limit(LimitEvent::TenantQuota) => 553, + EventType::Limit(LimitEvent::TooManyRequests) => 245, + EventType::MailAuth(MailAuthEvent::ParseError) => 254, + EventType::MailAuth(MailAuthEvent::MissingParameters) => 252, + EventType::MailAuth(MailAuthEvent::NoHeadersFound) => 253, + EventType::MailAuth(MailAuthEvent::Crypto) => 247, + EventType::MailAuth(MailAuthEvent::Io) => 251, + EventType::MailAuth(MailAuthEvent::Base64) => 246, + EventType::MailAuth(MailAuthEvent::DnsError) => 248, + EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) => 250, + EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType) => 249, + EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => 255, + EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => 259, + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => 258, + EventType::ManageSieve(ManageSieveEvent::CreateScript) => 260, + EventType::ManageSieve(ManageSieveEvent::UpdateScript) => 274, + EventType::ManageSieve(ManageSieveEvent::GetScript) => 263, + EventType::ManageSieve(ManageSieveEvent::DeleteScript) => 261, + EventType::ManageSieve(ManageSieveEvent::RenameScript) => 270, + EventType::ManageSieve(ManageSieveEvent::CheckScript) => 257, + EventType::ManageSieve(ManageSieveEvent::HaveSpace) => 264, + EventType::ManageSieve(ManageSieveEvent::ListScripts) => 265, + EventType::ManageSieve(ManageSieveEvent::SetActive) => 271, + EventType::ManageSieve(ManageSieveEvent::Capabilities) => 256, + EventType::ManageSieve(ManageSieveEvent::StartTls) => 272, + EventType::ManageSieve(ManageSieveEvent::Unauthenticate) => 273, + EventType::ManageSieve(ManageSieveEvent::Logout) => 266, + EventType::ManageSieve(ManageSieveEvent::Noop) => 267, + EventType::ManageSieve(ManageSieveEvent::Error) => 262, + EventType::ManageSieve(ManageSieveEvent::RawInput) => 268, + EventType::ManageSieve(ManageSieveEvent::RawOutput) => 269, + EventType::MessageIngest(MessageIngestEvent::Ham) => 283, + EventType::MessageIngest(MessageIngestEvent::Spam) => 286, + EventType::MessageIngest(MessageIngestEvent::ImapAppend) => 284, + EventType::MessageIngest(MessageIngestEvent::JmapAppend) => 285, + EventType::MessageIngest(MessageIngestEvent::Duplicate) => 281, + EventType::MessageIngest(MessageIngestEvent::Error) => 282, + EventType::MessageIngest(MessageIngestEvent::SearchIndex) => 142, + EventType::Milter(MilterEvent::Read) => 299, + EventType::Milter(MilterEvent::Write) => 303, + EventType::Milter(MilterEvent::ActionAccept) => 287, + EventType::Milter(MilterEvent::ActionDiscard) => 289, + EventType::Milter(MilterEvent::ActionReject) => 290, + EventType::Milter(MilterEvent::ActionTempFail) => 293, + EventType::Milter(MilterEvent::ActionReplyCode) => 291, + EventType::Milter(MilterEvent::ActionConnectionFailure) => 288, + EventType::Milter(MilterEvent::ActionShutdown) => 292, + EventType::Milter(MilterEvent::IoError) => 297, + EventType::Milter(MilterEvent::FrameTooLarge) => 296, + EventType::Milter(MilterEvent::FrameInvalid) => 295, + EventType::Milter(MilterEvent::UnexpectedResponse) => 302, + EventType::Milter(MilterEvent::Timeout) => 300, + EventType::Milter(MilterEvent::TlsInvalidName) => 301, + EventType::Milter(MilterEvent::Disconnected) => 294, + EventType::Milter(MilterEvent::ParseError) => 298, + EventType::MtaHook(MtaHookEvent::ActionAccept) => 304, + EventType::MtaHook(MtaHookEvent::ActionDiscard) => 305, + EventType::MtaHook(MtaHookEvent::ActionReject) => 307, + EventType::MtaHook(MtaHookEvent::ActionQuarantine) => 306, + EventType::MtaHook(MtaHookEvent::Error) => 308, + EventType::MtaSts(MtaStsEvent::Authorized) => 309, + EventType::MtaSts(MtaStsEvent::NotAuthorized) => 311, + EventType::MtaSts(MtaStsEvent::PolicyFetch) => 312, + EventType::MtaSts(MtaStsEvent::PolicyNotFound) => 314, + EventType::MtaSts(MtaStsEvent::PolicyFetchError) => 313, + EventType::MtaSts(MtaStsEvent::InvalidPolicy) => 310, + EventType::Network(NetworkEvent::ListenStart) => 321, + EventType::Network(NetworkEvent::ListenStop) => 322, + EventType::Network(NetworkEvent::ListenError) => 320, + EventType::Network(NetworkEvent::BindError) => 316, + EventType::Network(NetworkEvent::ReadError) => 324, + EventType::Network(NetworkEvent::WriteError) => 328, + EventType::Network(NetworkEvent::FlushError) => 319, + EventType::Network(NetworkEvent::AcceptError) => 315, + EventType::Network(NetworkEvent::SplitError) => 326, + EventType::Network(NetworkEvent::Timeout) => 327, + EventType::Network(NetworkEvent::Closed) => 317, + EventType::Network(NetworkEvent::ProxyError) => 323, + EventType::Network(NetworkEvent::SetOptError) => 325, + EventType::OutgoingReport(OutgoingReportEvent::SpfReport) => 342, + EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited) => 341, + EventType::OutgoingReport(OutgoingReportEvent::DkimReport) => 330, + EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited) => 329, + EventType::OutgoingReport(OutgoingReportEvent::DmarcReport) => 333, + EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited) => 332, + EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport) => 331, + EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate) => 344, + EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission) => 334, + EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress) => 345, + EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError) => 340, + EventType::OutgoingReport(OutgoingReportEvent::NotFound) => 339, + EventType::OutgoingReport(OutgoingReportEvent::SubmissionError) => 343, + EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound) => 338, + EventType::OutgoingReport(OutgoingReportEvent::Locked) => 337, + EventType::OutgoingReport(OutgoingReportEvent::MaxSizeExceeded) => 59, + EventType::Pop3(Pop3Event::ConnectionStart) => 348, + EventType::Pop3(Pop3Event::ConnectionEnd) => 347, + EventType::Pop3(Pop3Event::Delete) => 349, + EventType::Pop3(Pop3Event::Reset) => 358, + EventType::Pop3(Pop3Event::Quit) => 355, + EventType::Pop3(Pop3Event::Fetch) => 351, + EventType::Pop3(Pop3Event::List) => 352, + EventType::Pop3(Pop3Event::ListMessage) => 353, + EventType::Pop3(Pop3Event::Uidl) => 361, + EventType::Pop3(Pop3Event::UidlMessage) => 362, + EventType::Pop3(Pop3Event::Stat) => 360, + EventType::Pop3(Pop3Event::Noop) => 354, + EventType::Pop3(Pop3Event::Capabilities) => 346, + EventType::Pop3(Pop3Event::StartTls) => 359, + EventType::Pop3(Pop3Event::Utf8) => 363, + EventType::Pop3(Pop3Event::Error) => 350, + EventType::Pop3(Pop3Event::RawInput) => 356, + EventType::Pop3(Pop3Event::RawOutput) => 357, + EventType::PushSubscription(PushSubscriptionEvent::Success) => 373, + EventType::PushSubscription(PushSubscriptionEvent::Error) => 371, + EventType::PushSubscription(PushSubscriptionEvent::NotFound) => 372, + EventType::Queue(QueueEvent::Started) => 390, + EventType::Queue(QueueEvent::MessageQueued) => 380, + EventType::Queue(QueueEvent::AuthenticatedMessageQueued) => 381, + EventType::Queue(QueueEvent::ReportQueued) => 382, + EventType::Queue(QueueEvent::DsnQueued) => 379, + EventType::Queue(QueueEvent::AutogeneratedQueued) => 378, + EventType::Queue(QueueEvent::Rescheduled) => 385, + EventType::Queue(QueueEvent::Locked) => 377, + EventType::Queue(QueueEvent::BlobNotFound) => 374, + EventType::Queue(QueueEvent::RateLimitExceeded) => 384, + EventType::Queue(QueueEvent::ConcurrencyLimitExceeded) => 375, + EventType::Queue(QueueEvent::QuotaExceeded) => 383, + EventType::Queue(QueueEvent::BackPressure) => 48, + EventType::Registry(RegistryEvent::LocalReadError) => 62, + EventType::Registry(RegistryEvent::LocalWriteError) => 54, + EventType::Registry(RegistryEvent::LocalParseError) => 60, + EventType::Registry(RegistryEvent::ReadError) => 65, + EventType::Registry(RegistryEvent::WriteError) => 58, + EventType::Registry(RegistryEvent::DeserializationError) => 56, + EventType::Registry(RegistryEvent::BuildError) => 61, + EventType::Registry(RegistryEvent::BuildWarning) => 55, + EventType::Registry(RegistryEvent::NotSupported) => 64, + EventType::Registry(RegistryEvent::ValidationError) => 63, + EventType::Resource(ResourceEvent::NotFound) => 389, + EventType::Resource(ResourceEvent::BadParameters) => 386, + EventType::Resource(ResourceEvent::Error) => 388, + EventType::Resource(ResourceEvent::DownloadExternal) => 387, + EventType::Resource(ResourceEvent::ApplicationUpdated) => 601, + EventType::Resource(ResourceEvent::ApplicationUnpacked) => 602, + EventType::Security(SecurityEvent::AuthenticationBan) => 33, + EventType::Security(SecurityEvent::AbuseBan) => 549, + EventType::Security(SecurityEvent::ScanBan) => 558, + EventType::Security(SecurityEvent::LoiterBan) => 550, + EventType::Security(SecurityEvent::IpBlocked) => 318, + EventType::Security(SecurityEvent::IpBlockExpired) => 593, + EventType::Security(SecurityEvent::IpAllowExpired) => 594, + EventType::Security(SecurityEvent::IpUnauthorized) => 279, + EventType::Security(SecurityEvent::Unauthorized) => 552, + EventType::Server(ServerEvent::Startup) => 393, + EventType::Server(ServerEvent::Shutdown) => 392, + EventType::Server(ServerEvent::StartupError) => 394, + EventType::Server(ServerEvent::ThreadError) => 395, + EventType::Server(ServerEvent::Licensing) => 391, + EventType::Server(ServerEvent::RecoveryMode) => 603, + EventType::Server(ServerEvent::BootstrapMode) => 604, + EventType::Sieve(SieveEvent::ActionAccept) => 396, + EventType::Sieve(SieveEvent::ActionAcceptReplace) => 397, + EventType::Sieve(SieveEvent::ActionDiscard) => 398, + EventType::Sieve(SieveEvent::ActionReject) => 399, + EventType::Sieve(SieveEvent::SendMessage) => 406, + EventType::Sieve(SieveEvent::MessageTooLarge) => 401, + EventType::Sieve(SieveEvent::ScriptNotFound) => 405, + EventType::Sieve(SieveEvent::ListNotFound) => 400, + EventType::Sieve(SieveEvent::RuntimeError) => 404, + EventType::Sieve(SieveEvent::UnexpectedError) => 407, + EventType::Sieve(SieveEvent::NotSupported) => 402, + EventType::Sieve(SieveEvent::QuotaExceeded) => 403, + EventType::Smtp(SmtpEvent::ConnectionStart) => 417, + EventType::Smtp(SmtpEvent::ConnectionEnd) => 416, + EventType::Smtp(SmtpEvent::Error) => 428, + EventType::Smtp(SmtpEvent::IdNotFound) => 469, + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded) => 415, + EventType::Smtp(SmtpEvent::TransferLimitExceeded) => 485, + EventType::Smtp(SmtpEvent::RateLimitExceeded) => 461, + EventType::Smtp(SmtpEvent::TimeLimitExceeded) => 481, + EventType::Smtp(SmtpEvent::MissingAuthDirectory) => 452, + EventType::Smtp(SmtpEvent::MessageParseFailed) => 450, + EventType::Smtp(SmtpEvent::MessageTooLarge) => 451, + EventType::Smtp(SmtpEvent::LoopDetected) => 443, + EventType::Smtp(SmtpEvent::DkimPass) => 422, + EventType::Smtp(SmtpEvent::DkimFail) => 421, + EventType::Smtp(SmtpEvent::Dkim2Pass) => 630, + EventType::Smtp(SmtpEvent::Dkim2Fail) => 631, + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded) => 632, + EventType::Smtp(SmtpEvent::ArcPass) => 410, + EventType::Smtp(SmtpEvent::ArcFail) => 409, + EventType::Smtp(SmtpEvent::SpfEhloPass) => 474, + EventType::Smtp(SmtpEvent::SpfEhloFail) => 473, + EventType::Smtp(SmtpEvent::SpfFromPass) => 476, + EventType::Smtp(SmtpEvent::SpfFromFail) => 475, + EventType::Smtp(SmtpEvent::DmarcPass) => 424, + EventType::Smtp(SmtpEvent::DmarcFail) => 423, + EventType::Smtp(SmtpEvent::IprevPass) => 441, + EventType::Smtp(SmtpEvent::IprevFail) => 440, + EventType::Smtp(SmtpEvent::TooManyMessages) => 483, + EventType::Smtp(SmtpEvent::Ehlo) => 426, + EventType::Smtp(SmtpEvent::InvalidEhlo) => 436, + EventType::Smtp(SmtpEvent::DidNotSayEhlo) => 420, + EventType::Smtp(SmtpEvent::EhloExpected) => 427, + EventType::Smtp(SmtpEvent::LhloExpected) => 442, + EventType::Smtp(SmtpEvent::MailFromUnauthenticated) => 447, + EventType::Smtp(SmtpEvent::MailFromUnauthorized) => 448, + EventType::Smtp(SmtpEvent::MailFromNotAllowed) => 551, + EventType::Smtp(SmtpEvent::MailFromRewritten) => 446, + EventType::Smtp(SmtpEvent::MailFromMissing) => 445, + EventType::Smtp(SmtpEvent::MailFrom) => 444, + EventType::Smtp(SmtpEvent::MultipleMailFrom) => 456, + EventType::Smtp(SmtpEvent::MailboxDoesNotExist) => 449, + EventType::Smtp(SmtpEvent::RelayNotAllowed) => 468, + EventType::Smtp(SmtpEvent::RcptTo) => 464, + EventType::Smtp(SmtpEvent::RcptToDuplicate) => 465, + EventType::Smtp(SmtpEvent::RcptToRewritten) => 467, + EventType::Smtp(SmtpEvent::RcptToMissing) => 466, + EventType::Smtp(SmtpEvent::RcptToGreylisted) => 561, + EventType::Smtp(SmtpEvent::TooManyRecipients) => 484, + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt) => 482, + EventType::Smtp(SmtpEvent::RawInput) => 462, + EventType::Smtp(SmtpEvent::RawOutput) => 463, + EventType::Smtp(SmtpEvent::MissingLocalHostname) => 453, + EventType::Smtp(SmtpEvent::Vrfy) => 487, + EventType::Smtp(SmtpEvent::VrfyNotFound) => 489, + EventType::Smtp(SmtpEvent::VrfyDisabled) => 488, + EventType::Smtp(SmtpEvent::Expn) => 429, + EventType::Smtp(SmtpEvent::ExpnNotFound) => 431, + EventType::Smtp(SmtpEvent::ExpnDisabled) => 430, + EventType::Smtp(SmtpEvent::RequireTlsDisabled) => 471, + EventType::Smtp(SmtpEvent::DeliverByDisabled) => 418, + EventType::Smtp(SmtpEvent::DeliverByInvalid) => 419, + EventType::Smtp(SmtpEvent::FutureReleaseDisabled) => 432, + EventType::Smtp(SmtpEvent::FutureReleaseInvalid) => 433, + EventType::Smtp(SmtpEvent::MtPriorityDisabled) => 454, + EventType::Smtp(SmtpEvent::MtPriorityInvalid) => 455, + EventType::Smtp(SmtpEvent::DsnDisabled) => 425, + EventType::Smtp(SmtpEvent::AuthNotAllowed) => 413, + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported) => 412, + EventType::Smtp(SmtpEvent::AuthExchangeTooLong) => 411, + EventType::Smtp(SmtpEvent::AlreadyAuthenticated) => 408, + EventType::Smtp(SmtpEvent::Noop) => 457, + EventType::Smtp(SmtpEvent::StartTls) => 477, + EventType::Smtp(SmtpEvent::StartTlsUnavailable) => 479, + EventType::Smtp(SmtpEvent::StartTlsAlready) => 478, + EventType::Smtp(SmtpEvent::Rset) => 472, + EventType::Smtp(SmtpEvent::Quit) => 460, + EventType::Smtp(SmtpEvent::Help) => 434, + EventType::Smtp(SmtpEvent::CommandNotImplemented) => 414, + EventType::Smtp(SmtpEvent::InvalidCommand) => 435, + EventType::Smtp(SmtpEvent::InvalidSenderAddress) => 439, + EventType::Smtp(SmtpEvent::InvalidRecipientAddress) => 438, + EventType::Smtp(SmtpEvent::InvalidParameter) => 437, + EventType::Smtp(SmtpEvent::UnsupportedParameter) => 486, + EventType::Smtp(SmtpEvent::SyntaxError) => 480, + EventType::Smtp(SmtpEvent::RequestTooLarge) => 470, + EventType::Spam(SpamEvent::Pyzor) => 564, + EventType::Spam(SpamEvent::PyzorError) => 494, + EventType::Spam(SpamEvent::Dnsbl) => 562, + EventType::Spam(SpamEvent::DnsblError) => 563, + EventType::Spam(SpamEvent::TrainStarted) => 588, + EventType::Spam(SpamEvent::TrainCompleted) => 495, + EventType::Spam(SpamEvent::TrainSampleAdded) => 143, + EventType::Spam(SpamEvent::TrainSampleNotFound) => 491, + EventType::Spam(SpamEvent::Classify) => 490, + EventType::Spam(SpamEvent::ModelLoaded) => 589, + EventType::Spam(SpamEvent::ModelNotReady) => 496, + EventType::Spam(SpamEvent::ModelNotFound) => 497, + EventType::Spam(SpamEvent::RulesUpdated) => 280, + EventType::Spf(SpfEvent::Pass) => 501, + EventType::Spf(SpfEvent::Fail) => 498, + EventType::Spf(SpfEvent::SoftFail) => 503, + EventType::Spf(SpfEvent::Neutral) => 499, + EventType::Spf(SpfEvent::TempError) => 504, + EventType::Spf(SpfEvent::PermError) => 502, + EventType::Spf(SpfEvent::None) => 500, + EventType::Store(StoreEvent::AssertValueFailed) => 505, + EventType::Store(StoreEvent::FoundationdbError) => 518, + EventType::Store(StoreEvent::MysqlError) => 522, + EventType::Store(StoreEvent::PostgresqlError) => 527, + EventType::Store(StoreEvent::RocksdbError) => 529, + EventType::Store(StoreEvent::SqliteError) => 532, + EventType::Store(StoreEvent::LdapError) => 520, + EventType::Store(StoreEvent::ElasticsearchError) => 516, + EventType::Store(StoreEvent::MeilisearchError) => 590, + EventType::Store(StoreEvent::RedisError) => 528, + EventType::Store(StoreEvent::S3Error) => 530, + EventType::Store(StoreEvent::AzureError) => 559, + EventType::Store(StoreEvent::FilesystemError) => 517, + EventType::Store(StoreEvent::PoolError) => 526, + EventType::Store(StoreEvent::DataCorruption) => 511, + EventType::Store(StoreEvent::DecompressError) => 514, + EventType::Store(StoreEvent::DeserializeError) => 515, + EventType::Store(StoreEvent::NotFound) => 524, + EventType::Store(StoreEvent::NotConfigured) => 523, + EventType::Store(StoreEvent::NotSupported) => 525, + EventType::Store(StoreEvent::UnexpectedError) => 533, + EventType::Store(StoreEvent::CryptoError) => 510, + EventType::Store(StoreEvent::HttpStoreError) => 493, + EventType::Store(StoreEvent::CacheMiss) => 50, + EventType::Store(StoreEvent::CacheHit) => 51, + EventType::Store(StoreEvent::CacheStale) => 52, + EventType::Store(StoreEvent::CacheUpdate) => 577, + EventType::Store(StoreEvent::CacheEntryTooLarge) => 633, + EventType::Store(StoreEvent::BlobMissingMarker) => 507, + EventType::Store(StoreEvent::DataWrite) => 513, + EventType::Store(StoreEvent::DataIterate) => 512, + EventType::Store(StoreEvent::BlobRead) => 508, + EventType::Store(StoreEvent::BlobWrite) => 509, + EventType::Store(StoreEvent::BlobDelete) => 506, + EventType::Store(StoreEvent::SqlQuery) => 531, + EventType::Store(StoreEvent::LdapQuery) => 521, + EventType::Store(StoreEvent::LdapWarning) => 519, + EventType::Store(StoreEvent::HttpStoreFetch) => 492, + EventType::Store(StoreEvent::AutoExpunge) => 364, + EventType::Store(StoreEvent::BlobStorePurged) => 369, + EventType::Store(StoreEvent::DataStorePurged) => 368, + EventType::TaskManager(TaskManagerEvent::TaskAcquired) => 578, + EventType::TaskManager(TaskManagerEvent::TaskQueued) => 149, + EventType::TaskManager(TaskManagerEvent::TaskScheduled) => 370, + EventType::TaskManager(TaskManagerEvent::TaskLocked) => 144, + EventType::TaskManager(TaskManagerEvent::TaskIgnored) => 586, + EventType::TaskManager(TaskManagerEvent::TaskFailed) => 587, + EventType::TaskManager(TaskManagerEvent::TaskRetry) => 53, + EventType::TaskManager(TaskManagerEvent::BlobNotFound) => 141, + EventType::TaskManager(TaskManagerEvent::MetadataNotFound) => 145, + EventType::TaskManager(TaskManagerEvent::SchedulerStarted) => 150, + EventType::TaskManager(TaskManagerEvent::ManagerStarted) => 367, + EventType::Telemetry(TelemetryEvent::AlertEvent) => 548, + EventType::Telemetry(TelemetryEvent::AlertMessage) => 365, + EventType::Telemetry(TelemetryEvent::LogError) => 535, + EventType::Telemetry(TelemetryEvent::WebhookError) => 539, + EventType::Telemetry(TelemetryEvent::OtelExporterError) => 536, + EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError) => 537, + EventType::Telemetry(TelemetryEvent::PrometheusExporterError) => 538, + EventType::Telemetry(TelemetryEvent::JournalError) => 534, + EventType::Telemetry(TelemetryEvent::MetricsCollected) => 151, + EventType::Telemetry(TelemetryEvent::MetricsStored) => 366, + EventType::Telemetry(TelemetryEvent::MetricsPushed) => 146, + EventType::Tls(TlsEvent::Handshake) => 543, + EventType::Tls(TlsEvent::HandshakeError) => 544, + EventType::Tls(TlsEvent::NotConfigured) => 547, + EventType::Tls(TlsEvent::CertificateNotFound) => 542, + EventType::Tls(TlsEvent::NoCertificatesAvailable) => 546, + EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => 545, + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => 277, + EventType::TlsRpt(TlsRptEvent::RecordFetch) => 540, + EventType::TlsRpt(TlsRptEvent::RecordFetchError) => 541, + EventType::TlsRpt(TlsRptEvent::RecordNotFound) => 560, + EventType::WebDav(WebDavEvent::Propfind) => 147, + EventType::WebDav(WebDavEvent::Proppatch) => 148, + EventType::WebDav(WebDavEvent::Get) => 335, + EventType::WebDav(WebDavEvent::Head) => 574, + EventType::WebDav(WebDavEvent::Report) => 336, + EventType::WebDav(WebDavEvent::Mkcol) => 376, + EventType::WebDav(WebDavEvent::Mkcalendar) => 575, + EventType::WebDav(WebDavEvent::Delete) => 458, + EventType::WebDav(WebDavEvent::Put) => 459, + EventType::WebDav(WebDavEvent::Post) => 565, + EventType::WebDav(WebDavEvent::Patch) => 566, + EventType::WebDav(WebDavEvent::Copy) => 567, + EventType::WebDav(WebDavEvent::Move) => 568, + EventType::WebDav(WebDavEvent::Lock) => 569, + EventType::WebDav(WebDavEvent::Unlock) => 570, + EventType::WebDav(WebDavEvent::Acl) => 571, + EventType::WebDav(WebDavEvent::Options) => 573, + EventType::WebDav(WebDavEvent::Error) => 572, + } + } + + pub fn from_id(id: u16) -> Option { + match id { + 3 => Some(EventType::Acme(AcmeEvent::AuthStart)), + 2 => Some(EventType::Acme(AcmeEvent::AuthPending)), + 5 => Some(EventType::Acme(AcmeEvent::AuthValid)), + 0 => Some(EventType::Acme(AcmeEvent::AuthCompleted)), + 1 => Some(EventType::Acme(AcmeEvent::AuthError)), + 4 => Some(EventType::Acme(AcmeEvent::AuthTooManyAttempts)), + 22 => Some(EventType::Acme(AcmeEvent::ProcessCert)), + 20 => Some(EventType::Acme(AcmeEvent::OrderStart)), + 18 => Some(EventType::Acme(AcmeEvent::OrderProcessing)), + 16 => Some(EventType::Acme(AcmeEvent::OrderCompleted)), + 19 => Some(EventType::Acme(AcmeEvent::OrderReady)), + 21 => Some(EventType::Acme(AcmeEvent::OrderValid)), + 17 => Some(EventType::Acme(AcmeEvent::OrderInvalid)), + 23 => Some(EventType::Acme(AcmeEvent::RenewBackoff)), + 7 => Some(EventType::Acme(AcmeEvent::ClientSuppliedSni)), + 6 => Some(EventType::Acme(AcmeEvent::ClientMissingSni)), + 25 => Some(EventType::Acme(AcmeEvent::TlsAlpnReceived)), + 24 => Some(EventType::Acme(AcmeEvent::TlsAlpnError)), + 26 => Some(EventType::Acme(AcmeEvent::TokenNotFound)), + 15 => Some(EventType::Acme(AcmeEvent::Error)), + 556 => Some(EventType::Ai(AiEvent::LlmResponse)), + 557 => Some(EventType::Ai(AiEvent::ApiError)), + 28 => Some(EventType::Arc(ArcEvent::ChainTooLong)), + 31 => Some(EventType::Arc(ArcEvent::InvalidInstance)), + 30 => Some(EventType::Arc(ArcEvent::InvalidCv)), + 29 => Some(EventType::Arc(ArcEvent::HasHeaderTag)), + 27 => Some(EventType::Arc(ArcEvent::BrokenChain)), + 32 => Some(EventType::Arc(ArcEvent::SealerNotFound)), + 37 => Some(EventType::Auth(AuthEvent::Success)), + 35 => Some(EventType::Auth(AuthEvent::Failed)), + 554 => Some(EventType::Auth(AuthEvent::TokenExpired)), + 36 => Some(EventType::Auth(AuthEvent::MfaRequired)), + 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), + 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), + 34 => Some(EventType::Auth(AuthEvent::Error)), + 595 => Some(EventType::Auth(AuthEvent::Warning)), + 276 => Some(EventType::Auth(AuthEvent::CredentialExpired)), + 576 => Some(EventType::Calendar(CalendarEvent::RuleExpansionError)), + 579 => Some(EventType::Calendar(CalendarEvent::AlarmSent)), + 580 => Some(EventType::Calendar(CalendarEvent::AlarmSkipped)), + 581 => Some(EventType::Calendar(CalendarEvent::AlarmRecipientOverride)), + 582 => Some(EventType::Calendar(CalendarEvent::AlarmFailed)), + 583 => Some(EventType::Calendar(CalendarEvent::ItipMessageSent)), + 584 => Some(EventType::Calendar(CalendarEvent::ItipMessageReceived)), + 585 => Some(EventType::Calendar(CalendarEvent::ItipMessageError)), + 278 => Some(EventType::Cluster(ClusterEvent::Startup)), + 39 => Some(EventType::Cluster(ClusterEvent::SubscriberStart)), + 40 => Some(EventType::Cluster(ClusterEvent::SubscriberStop)), + 41 => Some(EventType::Cluster(ClusterEvent::SubscriberError)), + 42 => Some(EventType::Cluster(ClusterEvent::SubscriberDisconnected)), + 43 => Some(EventType::Cluster(ClusterEvent::PublisherStart)), + 44 => Some(EventType::Cluster(ClusterEvent::PublisherStop)), + 45 => Some(EventType::Cluster(ClusterEvent::PublisherError)), + 46 => Some(EventType::Cluster(ClusterEvent::MessageReceived)), + 47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)), + 49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)), + 275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)), + 67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)), + 66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)), + 69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)), + 68 => Some(EventType::Dane(DaneEvent::CertificateParseError)), + 73 => Some(EventType::Dane(DaneEvent::TlsaRecordMatch)), + 70 => Some(EventType::Dane(DaneEvent::TlsaRecordFetch)), + 71 => Some(EventType::Dane(DaneEvent::TlsaRecordFetchError)), + 75 => Some(EventType::Dane(DaneEvent::TlsaRecordNotFound)), + 74 => Some(EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned)), + 72 => Some(EventType::Dane(DaneEvent::TlsaRecordInvalid)), + 605 => Some(EventType::Dane(DaneEvent::BogusDnssecRecord)), + 77 => Some(EventType::Delivery(DeliveryEvent::AttemptStart)), + 76 => Some(EventType::Delivery(DeliveryEvent::AttemptEnd)), + 80 => Some(EventType::Delivery(DeliveryEvent::Completed)), + 92 => Some(EventType::Delivery(DeliveryEvent::Failed)), + 85 => Some(EventType::Delivery(DeliveryEvent::DomainDeliveryStart)), + 101 => Some(EventType::Delivery(DeliveryEvent::MxLookup)), + 102 => Some(EventType::Delivery(DeliveryEvent::MxLookupFailed)), + 95 => Some(EventType::Delivery(DeliveryEvent::IpLookup)), + 96 => Some(EventType::Delivery(DeliveryEvent::IpLookupFailed)), + 103 => Some(EventType::Delivery(DeliveryEvent::NullMx)), + 82 => Some(EventType::Delivery(DeliveryEvent::Connect)), + 83 => Some(EventType::Delivery(DeliveryEvent::ConnectError)), + 100 => Some(EventType::Delivery(DeliveryEvent::MissingOutboundHostname)), + 93 => Some(EventType::Delivery(DeliveryEvent::GreetingFailed)), + 90 => Some(EventType::Delivery(DeliveryEvent::Ehlo)), + 91 => Some(EventType::Delivery(DeliveryEvent::EhloRejected)), + 78 => Some(EventType::Delivery(DeliveryEvent::Auth)), + 79 => Some(EventType::Delivery(DeliveryEvent::AuthFailed)), + 97 => Some(EventType::Delivery(DeliveryEvent::MailFrom)), + 98 => Some(EventType::Delivery(DeliveryEvent::MailFromRejected)), + 84 => Some(EventType::Delivery(DeliveryEvent::Delivered)), + 107 => Some(EventType::Delivery(DeliveryEvent::RcptTo)), + 109 => Some(EventType::Delivery(DeliveryEvent::RcptToRejected)), + 108 => Some(EventType::Delivery(DeliveryEvent::RcptToFailed)), + 99 => Some(EventType::Delivery(DeliveryEvent::MessageRejected)), + 110 => Some(EventType::Delivery(DeliveryEvent::StartTls)), + 113 => Some(EventType::Delivery(DeliveryEvent::StartTlsUnavailable)), + 112 => Some(EventType::Delivery(DeliveryEvent::StartTlsError)), + 111 => Some(EventType::Delivery(DeliveryEvent::StartTlsDisabled)), + 94 => Some(EventType::Delivery(DeliveryEvent::ImplicitTlsError)), + 81 => Some(EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded)), + 104 => Some(EventType::Delivery(DeliveryEvent::RateLimitExceeded)), + 86 => Some(EventType::Delivery(DeliveryEvent::DoubleBounce)), + 88 => Some(EventType::Delivery(DeliveryEvent::DsnSuccess)), + 89 => Some(EventType::Delivery(DeliveryEvent::DsnTempFail)), + 87 => Some(EventType::Delivery(DeliveryEvent::DsnPermFail)), + 105 => Some(EventType::Delivery(DeliveryEvent::RawInput)), + 106 => Some(EventType::Delivery(DeliveryEvent::RawOutput)), + 121 => Some(EventType::Dkim(DkimEvent::Pass)), + 119 => Some(EventType::Dkim(DkimEvent::Neutral)), + 114 => Some(EventType::Dkim(DkimEvent::Fail)), + 122 => Some(EventType::Dkim(DkimEvent::PermError)), + 127 => Some(EventType::Dkim(DkimEvent::TempError)), + 120 => Some(EventType::Dkim(DkimEvent::None)), + 131 => Some(EventType::Dkim(DkimEvent::UnsupportedVersion)), + 128 => Some(EventType::Dkim(DkimEvent::UnsupportedAlgorithm)), + 129 => Some(EventType::Dkim(DkimEvent::UnsupportedCanonicalization)), + 130 => Some(EventType::Dkim(DkimEvent::UnsupportedKeyType)), + 116 => Some(EventType::Dkim(DkimEvent::FailedBodyHashMatch)), + 117 => Some(EventType::Dkim(DkimEvent::FailedVerification)), + 115 => Some(EventType::Dkim(DkimEvent::FailedAuidMatch)), + 123 => Some(EventType::Dkim(DkimEvent::RevokedPublicKey)), + 118 => Some(EventType::Dkim(DkimEvent::IncompatibleAlgorithms)), + 124 => Some(EventType::Dkim(DkimEvent::SignatureExpired)), + 125 => Some(EventType::Dkim(DkimEvent::SignatureLength)), + 126 => Some(EventType::Dkim(DkimEvent::SignerNotFound)), + 592 => Some(EventType::Dkim(DkimEvent::BuildError)), + 596 => Some(EventType::Dkim(DkimEvent::SignatureCreated)), + 597 => Some(EventType::Dkim(DkimEvent::SignaturePublished)), + 598 => Some(EventType::Dkim(DkimEvent::SignatureRetiring)), + 599 => Some(EventType::Dkim(DkimEvent::SignatureRetired)), + 600 => Some(EventType::Dkim(DkimEvent::SignatureDeleted)), + 606 => Some(EventType::Dkim(DkimEvent::InstanceMissing)), + 607 => Some(EventType::Dkim(DkimEvent::InstanceSyntax)), + 608 => Some(EventType::Dkim(DkimEvent::InstanceTagMissing)), + 609 => Some(EventType::Dkim(DkimEvent::InstanceNotSigned)), + 610 => Some(EventType::Dkim(DkimEvent::InstanceAboveSignature)), + 611 => Some(EventType::Dkim(DkimEvent::SignatureMissing)), + 612 => Some(EventType::Dkim(DkimEvent::SignatureSyntax)), + 613 => Some(EventType::Dkim(DkimEvent::SignatureTagMissing)), + 614 => Some(EventType::Dkim(DkimEvent::SignatureTagUnexpected)), + 615 => Some(EventType::Dkim(DkimEvent::SequenceGap)), + 616 => Some(EventType::Dkim(DkimEvent::SequenceOverflow)), + 635 => Some(EventType::Dkim(DkimEvent::ChainTooLong)), + 617 => Some(EventType::Dkim(DkimEvent::MailFromMismatch)), + 618 => Some(EventType::Dkim(DkimEvent::RcptToMismatch)), + 619 => Some(EventType::Dkim(DkimEvent::MailFromDomainMismatch)), + 620 => Some(EventType::Dkim(DkimEvent::NextDomainMismatch)), + 636 => Some(EventType::Dkim(DkimEvent::CustodyBreak)), + 621 => Some(EventType::Dkim(DkimEvent::PublicKeyFetch)), + 622 => Some(EventType::Dkim(DkimEvent::PublicKeyMissing)), + 623 => Some(EventType::Dkim(DkimEvent::PublicKeyMultiple)), + 624 => Some(EventType::Dkim(DkimEvent::PublicKeySyntax)), + 625 => Some(EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch)), + 626 => Some(EventType::Dkim(DkimEvent::NoValidAlgorithm)), + 627 => Some(EventType::Dkim(DkimEvent::HeaderHashMismatch)), + 628 => Some(EventType::Dkim(DkimEvent::Modified)), + 629 => Some(EventType::Dkim(DkimEvent::Exploded)), + 134 => Some(EventType::Dmarc(DmarcEvent::Pass)), + 132 => Some(EventType::Dmarc(DmarcEvent::Fail)), + 135 => Some(EventType::Dmarc(DmarcEvent::PermError)), + 136 => Some(EventType::Dmarc(DmarcEvent::TempError)), + 133 => Some(EventType::Dmarc(DmarcEvent::None)), + 8 => Some(EventType::Dns(DnsEvent::RecordCreated)), + 9 => Some(EventType::Dns(DnsEvent::RecordCreationFailed)), + 10 => Some(EventType::Dns(DnsEvent::RecordDeletionFailed)), + 12 => Some(EventType::Dns(DnsEvent::RecordNotPropagated)), + 11 => Some(EventType::Dns(DnsEvent::RecordLookupFailed)), + 13 => Some(EventType::Dns(DnsEvent::RecordPropagated)), + 14 => Some(EventType::Dns(DnsEvent::RecordPropagationTimeout)), + 591 => Some(EventType::Dns(DnsEvent::BuildError)), + 139 => Some(EventType::Eval(EvalEvent::Result)), + 138 => Some(EventType::Eval(EvalEvent::Error)), + 137 => Some(EventType::Eval(EvalEvent::DirectoryNotFound)), + 140 => Some(EventType::Eval(EvalEvent::StoreNotFound)), + 153 => Some(EventType::Http(HttpEvent::ConnectionStart)), + 152 => Some(EventType::Http(HttpEvent::ConnectionEnd)), + 154 => Some(EventType::Http(HttpEvent::Error)), + 156 => Some(EventType::Http(HttpEvent::RequestUrl)), + 155 => Some(EventType::Http(HttpEvent::RequestBody)), + 157 => Some(EventType::Http(HttpEvent::ResponseBody)), + 158 => Some(EventType::Http(HttpEvent::XForwardedMissing)), + 163 => Some(EventType::Imap(ImapEvent::ConnectionStart)), + 162 => Some(EventType::Imap(ImapEvent::ConnectionEnd)), + 171 => Some(EventType::Imap(ImapEvent::GetAcl)), + 188 => Some(EventType::Imap(ImapEvent::SetAcl)), + 180 => Some(EventType::Imap(ImapEvent::MyRights)), + 176 => Some(EventType::Imap(ImapEvent::ListRights)), + 159 => Some(EventType::Imap(ImapEvent::Append)), + 160 => Some(EventType::Imap(ImapEvent::Capabilities)), + 172 => Some(EventType::Imap(ImapEvent::Id)), + 161 => Some(EventType::Imap(ImapEvent::Close)), + 164 => Some(EventType::Imap(ImapEvent::Copy)), + 179 => Some(EventType::Imap(ImapEvent::Move)), + 165 => Some(EventType::Imap(ImapEvent::CreateMailbox)), + 166 => Some(EventType::Imap(ImapEvent::DeleteMailbox)), + 185 => Some(EventType::Imap(ImapEvent::RenameMailbox)), + 167 => Some(EventType::Imap(ImapEvent::Enable)), + 169 => Some(EventType::Imap(ImapEvent::Expunge)), + 170 => Some(EventType::Imap(ImapEvent::Fetch)), + 173 => Some(EventType::Imap(ImapEvent::IdleStart)), + 174 => Some(EventType::Imap(ImapEvent::IdleStop)), + 175 => Some(EventType::Imap(ImapEvent::List)), + 178 => Some(EventType::Imap(ImapEvent::Lsub)), + 177 => Some(EventType::Imap(ImapEvent::Logout)), + 181 => Some(EventType::Imap(ImapEvent::Namespace)), + 182 => Some(EventType::Imap(ImapEvent::Noop)), + 186 => Some(EventType::Imap(ImapEvent::Search)), + 189 => Some(EventType::Imap(ImapEvent::Sort)), + 187 => Some(EventType::Imap(ImapEvent::Select)), + 190 => Some(EventType::Imap(ImapEvent::Status)), + 191 => Some(EventType::Imap(ImapEvent::Store)), + 192 => Some(EventType::Imap(ImapEvent::Subscribe)), + 194 => Some(EventType::Imap(ImapEvent::Unsubscribe)), + 193 => Some(EventType::Imap(ImapEvent::Thread)), + 634 => Some(EventType::Imap(ImapEvent::UidBatches)), + 57 => Some(EventType::Imap(ImapEvent::GetQuota)), + 168 => Some(EventType::Imap(ImapEvent::Error)), + 183 => Some(EventType::Imap(ImapEvent::RawInput)), + 184 => Some(EventType::Imap(ImapEvent::RawOutput)), + 200 => Some(EventType::IncomingReport(IncomingReportEvent::DmarcReport)), + 201 => Some(EventType::IncomingReport( + IncomingReportEvent::DmarcReportWithWarnings, + )), + 206 => Some(EventType::IncomingReport(IncomingReportEvent::TlsReport)), + 207 => Some(EventType::IncomingReport( + IncomingReportEvent::TlsReportWithWarnings, + )), + 195 => Some(EventType::IncomingReport(IncomingReportEvent::AbuseReport)), + 197 => Some(EventType::IncomingReport( + IncomingReportEvent::AuthFailureReport, + )), + 202 => Some(EventType::IncomingReport(IncomingReportEvent::FraudReport)), + 204 => Some(EventType::IncomingReport( + IncomingReportEvent::NotSpamReport, + )), + 209 => Some(EventType::IncomingReport(IncomingReportEvent::VirusReport)), + 205 => Some(EventType::IncomingReport(IncomingReportEvent::OtherReport)), + 203 => Some(EventType::IncomingReport( + IncomingReportEvent::MessageParseFailed, + )), + 199 => Some(EventType::IncomingReport( + IncomingReportEvent::DmarcParseFailed, + )), + 208 => Some(EventType::IncomingReport( + IncomingReportEvent::TlsRpcParseFailed, + )), + 196 => Some(EventType::IncomingReport( + IncomingReportEvent::ArfParseFailed, + )), + 198 => Some(EventType::IncomingReport( + IncomingReportEvent::DecompressError, + )), + 212 => Some(EventType::Iprev(IprevEvent::Pass)), + 210 => Some(EventType::Iprev(IprevEvent::Fail)), + 213 => Some(EventType::Iprev(IprevEvent::PermError)), + 214 => Some(EventType::Iprev(IprevEvent::TempError)), + 211 => Some(EventType::Iprev(IprevEvent::None)), + 223 => Some(EventType::Jmap(JmapEvent::MethodCall)), + 221 => Some(EventType::Jmap(JmapEvent::InvalidArguments)), + 227 => Some(EventType::Jmap(JmapEvent::RequestTooLarge)), + 228 => Some(EventType::Jmap(JmapEvent::StateMismatch)), + 218 => Some(EventType::Jmap(JmapEvent::AnchorNotFound)), + 232 => Some(EventType::Jmap(JmapEvent::UnsupportedFilter)), + 233 => Some(EventType::Jmap(JmapEvent::UnsupportedSort)), + 231 => Some(EventType::Jmap(JmapEvent::UnknownMethod)), + 222 => Some(EventType::Jmap(JmapEvent::InvalidResultReference)), + 220 => Some(EventType::Jmap(JmapEvent::Forbidden)), + 215 => Some(EventType::Jmap(JmapEvent::AccountNotFound)), + 216 => Some(EventType::Jmap(JmapEvent::AccountNotSupportedByMethod)), + 217 => Some(EventType::Jmap(JmapEvent::AccountReadOnly)), + 224 => Some(EventType::Jmap(JmapEvent::NotFound)), + 219 => Some(EventType::Jmap(JmapEvent::CannotCalculateChanges)), + 230 => Some(EventType::Jmap(JmapEvent::UnknownDataType)), + 229 => Some(EventType::Jmap(JmapEvent::UnknownCapability)), + 225 => Some(EventType::Jmap(JmapEvent::NotJson)), + 226 => Some(EventType::Jmap(JmapEvent::NotRequest)), + 235 => Some(EventType::Jmap(JmapEvent::WebsocketStart)), + 236 => Some(EventType::Jmap(JmapEvent::WebsocketStop)), + 234 => Some(EventType::Jmap(JmapEvent::WebsocketError)), + 243 => Some(EventType::Limit(LimitEvent::SizeRequest)), + 244 => Some(EventType::Limit(LimitEvent::SizeUpload)), + 238 => Some(EventType::Limit(LimitEvent::CallsIn)), + 240 => Some(EventType::Limit(LimitEvent::ConcurrentRequest)), + 241 => Some(EventType::Limit(LimitEvent::ConcurrentUpload)), + 239 => Some(EventType::Limit(LimitEvent::ConcurrentConnection)), + 242 => Some(EventType::Limit(LimitEvent::Quota)), + 237 => Some(EventType::Limit(LimitEvent::BlobQuota)), + 553 => Some(EventType::Limit(LimitEvent::TenantQuota)), + 245 => Some(EventType::Limit(LimitEvent::TooManyRequests)), + 254 => Some(EventType::MailAuth(MailAuthEvent::ParseError)), + 252 => Some(EventType::MailAuth(MailAuthEvent::MissingParameters)), + 253 => Some(EventType::MailAuth(MailAuthEvent::NoHeadersFound)), + 247 => Some(EventType::MailAuth(MailAuthEvent::Crypto)), + 251 => Some(EventType::MailAuth(MailAuthEvent::Io)), + 246 => Some(EventType::MailAuth(MailAuthEvent::Base64)), + 248 => Some(EventType::MailAuth(MailAuthEvent::DnsError)), + 250 => Some(EventType::MailAuth(MailAuthEvent::DnsRecordNotFound)), + 249 => Some(EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType)), + 255 => Some(EventType::MailAuth(MailAuthEvent::PolicyNotAligned)), + 259 => Some(EventType::ManageSieve(ManageSieveEvent::ConnectionStart)), + 258 => Some(EventType::ManageSieve(ManageSieveEvent::ConnectionEnd)), + 260 => Some(EventType::ManageSieve(ManageSieveEvent::CreateScript)), + 274 => Some(EventType::ManageSieve(ManageSieveEvent::UpdateScript)), + 263 => Some(EventType::ManageSieve(ManageSieveEvent::GetScript)), + 261 => Some(EventType::ManageSieve(ManageSieveEvent::DeleteScript)), + 270 => Some(EventType::ManageSieve(ManageSieveEvent::RenameScript)), + 257 => Some(EventType::ManageSieve(ManageSieveEvent::CheckScript)), + 264 => Some(EventType::ManageSieve(ManageSieveEvent::HaveSpace)), + 265 => Some(EventType::ManageSieve(ManageSieveEvent::ListScripts)), + 271 => Some(EventType::ManageSieve(ManageSieveEvent::SetActive)), + 256 => Some(EventType::ManageSieve(ManageSieveEvent::Capabilities)), + 272 => Some(EventType::ManageSieve(ManageSieveEvent::StartTls)), + 273 => Some(EventType::ManageSieve(ManageSieveEvent::Unauthenticate)), + 266 => Some(EventType::ManageSieve(ManageSieveEvent::Logout)), + 267 => Some(EventType::ManageSieve(ManageSieveEvent::Noop)), + 262 => Some(EventType::ManageSieve(ManageSieveEvent::Error)), + 268 => Some(EventType::ManageSieve(ManageSieveEvent::RawInput)), + 269 => Some(EventType::ManageSieve(ManageSieveEvent::RawOutput)), + 283 => Some(EventType::MessageIngest(MessageIngestEvent::Ham)), + 286 => Some(EventType::MessageIngest(MessageIngestEvent::Spam)), + 284 => Some(EventType::MessageIngest(MessageIngestEvent::ImapAppend)), + 285 => Some(EventType::MessageIngest(MessageIngestEvent::JmapAppend)), + 281 => Some(EventType::MessageIngest(MessageIngestEvent::Duplicate)), + 282 => Some(EventType::MessageIngest(MessageIngestEvent::Error)), + 142 => Some(EventType::MessageIngest(MessageIngestEvent::SearchIndex)), + 299 => Some(EventType::Milter(MilterEvent::Read)), + 303 => Some(EventType::Milter(MilterEvent::Write)), + 287 => Some(EventType::Milter(MilterEvent::ActionAccept)), + 289 => Some(EventType::Milter(MilterEvent::ActionDiscard)), + 290 => Some(EventType::Milter(MilterEvent::ActionReject)), + 293 => Some(EventType::Milter(MilterEvent::ActionTempFail)), + 291 => Some(EventType::Milter(MilterEvent::ActionReplyCode)), + 288 => Some(EventType::Milter(MilterEvent::ActionConnectionFailure)), + 292 => Some(EventType::Milter(MilterEvent::ActionShutdown)), + 297 => Some(EventType::Milter(MilterEvent::IoError)), + 296 => Some(EventType::Milter(MilterEvent::FrameTooLarge)), + 295 => Some(EventType::Milter(MilterEvent::FrameInvalid)), + 302 => Some(EventType::Milter(MilterEvent::UnexpectedResponse)), + 300 => Some(EventType::Milter(MilterEvent::Timeout)), + 301 => Some(EventType::Milter(MilterEvent::TlsInvalidName)), + 294 => Some(EventType::Milter(MilterEvent::Disconnected)), + 298 => Some(EventType::Milter(MilterEvent::ParseError)), + 304 => Some(EventType::MtaHook(MtaHookEvent::ActionAccept)), + 305 => Some(EventType::MtaHook(MtaHookEvent::ActionDiscard)), + 307 => Some(EventType::MtaHook(MtaHookEvent::ActionReject)), + 306 => Some(EventType::MtaHook(MtaHookEvent::ActionQuarantine)), + 308 => Some(EventType::MtaHook(MtaHookEvent::Error)), + 309 => Some(EventType::MtaSts(MtaStsEvent::Authorized)), + 311 => Some(EventType::MtaSts(MtaStsEvent::NotAuthorized)), + 312 => Some(EventType::MtaSts(MtaStsEvent::PolicyFetch)), + 314 => Some(EventType::MtaSts(MtaStsEvent::PolicyNotFound)), + 313 => Some(EventType::MtaSts(MtaStsEvent::PolicyFetchError)), + 310 => Some(EventType::MtaSts(MtaStsEvent::InvalidPolicy)), + 321 => Some(EventType::Network(NetworkEvent::ListenStart)), + 322 => Some(EventType::Network(NetworkEvent::ListenStop)), + 320 => Some(EventType::Network(NetworkEvent::ListenError)), + 316 => Some(EventType::Network(NetworkEvent::BindError)), + 324 => Some(EventType::Network(NetworkEvent::ReadError)), + 328 => Some(EventType::Network(NetworkEvent::WriteError)), + 319 => Some(EventType::Network(NetworkEvent::FlushError)), + 315 => Some(EventType::Network(NetworkEvent::AcceptError)), + 326 => Some(EventType::Network(NetworkEvent::SplitError)), + 327 => Some(EventType::Network(NetworkEvent::Timeout)), + 317 => Some(EventType::Network(NetworkEvent::Closed)), + 323 => Some(EventType::Network(NetworkEvent::ProxyError)), + 325 => Some(EventType::Network(NetworkEvent::SetOptError)), + 342 => Some(EventType::OutgoingReport(OutgoingReportEvent::SpfReport)), + 341 => Some(EventType::OutgoingReport( + OutgoingReportEvent::SpfRateLimited, + )), + 330 => Some(EventType::OutgoingReport(OutgoingReportEvent::DkimReport)), + 329 => Some(EventType::OutgoingReport( + OutgoingReportEvent::DkimRateLimited, + )), + 333 => Some(EventType::OutgoingReport(OutgoingReportEvent::DmarcReport)), + 332 => Some(EventType::OutgoingReport( + OutgoingReportEvent::DmarcRateLimited, + )), + 331 => Some(EventType::OutgoingReport( + OutgoingReportEvent::DmarcAggregateReport, + )), + 344 => Some(EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate)), + 334 => Some(EventType::OutgoingReport( + OutgoingReportEvent::HttpSubmission, + )), + 345 => Some(EventType::OutgoingReport( + OutgoingReportEvent::UnauthorizedReportingAddress, + )), + 340 => Some(EventType::OutgoingReport( + OutgoingReportEvent::ReportingAddressValidationError, + )), + 339 => Some(EventType::OutgoingReport(OutgoingReportEvent::NotFound)), + 343 => Some(EventType::OutgoingReport( + OutgoingReportEvent::SubmissionError, + )), + 338 => Some(EventType::OutgoingReport( + OutgoingReportEvent::NoRecipientsFound, + )), + 337 => Some(EventType::OutgoingReport(OutgoingReportEvent::Locked)), + 59 => Some(EventType::OutgoingReport( + OutgoingReportEvent::MaxSizeExceeded, + )), + 348 => Some(EventType::Pop3(Pop3Event::ConnectionStart)), + 347 => Some(EventType::Pop3(Pop3Event::ConnectionEnd)), + 349 => Some(EventType::Pop3(Pop3Event::Delete)), + 358 => Some(EventType::Pop3(Pop3Event::Reset)), + 355 => Some(EventType::Pop3(Pop3Event::Quit)), + 351 => Some(EventType::Pop3(Pop3Event::Fetch)), + 352 => Some(EventType::Pop3(Pop3Event::List)), + 353 => Some(EventType::Pop3(Pop3Event::ListMessage)), + 361 => Some(EventType::Pop3(Pop3Event::Uidl)), + 362 => Some(EventType::Pop3(Pop3Event::UidlMessage)), + 360 => Some(EventType::Pop3(Pop3Event::Stat)), + 354 => Some(EventType::Pop3(Pop3Event::Noop)), + 346 => Some(EventType::Pop3(Pop3Event::Capabilities)), + 359 => Some(EventType::Pop3(Pop3Event::StartTls)), + 363 => Some(EventType::Pop3(Pop3Event::Utf8)), + 350 => Some(EventType::Pop3(Pop3Event::Error)), + 356 => Some(EventType::Pop3(Pop3Event::RawInput)), + 357 => Some(EventType::Pop3(Pop3Event::RawOutput)), + 373 => Some(EventType::PushSubscription(PushSubscriptionEvent::Success)), + 371 => Some(EventType::PushSubscription(PushSubscriptionEvent::Error)), + 372 => Some(EventType::PushSubscription(PushSubscriptionEvent::NotFound)), + 390 => Some(EventType::Queue(QueueEvent::Started)), + 380 => Some(EventType::Queue(QueueEvent::MessageQueued)), + 381 => Some(EventType::Queue(QueueEvent::AuthenticatedMessageQueued)), + 382 => Some(EventType::Queue(QueueEvent::ReportQueued)), + 379 => Some(EventType::Queue(QueueEvent::DsnQueued)), + 378 => Some(EventType::Queue(QueueEvent::AutogeneratedQueued)), + 385 => Some(EventType::Queue(QueueEvent::Rescheduled)), + 377 => Some(EventType::Queue(QueueEvent::Locked)), + 374 => Some(EventType::Queue(QueueEvent::BlobNotFound)), + 384 => Some(EventType::Queue(QueueEvent::RateLimitExceeded)), + 375 => Some(EventType::Queue(QueueEvent::ConcurrencyLimitExceeded)), + 383 => Some(EventType::Queue(QueueEvent::QuotaExceeded)), + 48 => Some(EventType::Queue(QueueEvent::BackPressure)), + 62 => Some(EventType::Registry(RegistryEvent::LocalReadError)), + 54 => Some(EventType::Registry(RegistryEvent::LocalWriteError)), + 60 => Some(EventType::Registry(RegistryEvent::LocalParseError)), + 65 => Some(EventType::Registry(RegistryEvent::ReadError)), + 58 => Some(EventType::Registry(RegistryEvent::WriteError)), + 56 => Some(EventType::Registry(RegistryEvent::DeserializationError)), + 61 => Some(EventType::Registry(RegistryEvent::BuildError)), + 55 => Some(EventType::Registry(RegistryEvent::BuildWarning)), + 64 => Some(EventType::Registry(RegistryEvent::NotSupported)), + 63 => Some(EventType::Registry(RegistryEvent::ValidationError)), + 389 => Some(EventType::Resource(ResourceEvent::NotFound)), + 386 => Some(EventType::Resource(ResourceEvent::BadParameters)), + 388 => Some(EventType::Resource(ResourceEvent::Error)), + 387 => Some(EventType::Resource(ResourceEvent::DownloadExternal)), + 601 => Some(EventType::Resource(ResourceEvent::ApplicationUpdated)), + 602 => Some(EventType::Resource(ResourceEvent::ApplicationUnpacked)), + 33 => Some(EventType::Security(SecurityEvent::AuthenticationBan)), + 549 => Some(EventType::Security(SecurityEvent::AbuseBan)), + 558 => Some(EventType::Security(SecurityEvent::ScanBan)), + 550 => Some(EventType::Security(SecurityEvent::LoiterBan)), + 318 => Some(EventType::Security(SecurityEvent::IpBlocked)), + 593 => Some(EventType::Security(SecurityEvent::IpBlockExpired)), + 594 => Some(EventType::Security(SecurityEvent::IpAllowExpired)), + 279 => Some(EventType::Security(SecurityEvent::IpUnauthorized)), + 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), + 393 => Some(EventType::Server(ServerEvent::Startup)), + 392 => Some(EventType::Server(ServerEvent::Shutdown)), + 394 => Some(EventType::Server(ServerEvent::StartupError)), + 395 => Some(EventType::Server(ServerEvent::ThreadError)), + 391 => Some(EventType::Server(ServerEvent::Licensing)), + 603 => Some(EventType::Server(ServerEvent::RecoveryMode)), + 604 => Some(EventType::Server(ServerEvent::BootstrapMode)), + 396 => Some(EventType::Sieve(SieveEvent::ActionAccept)), + 397 => Some(EventType::Sieve(SieveEvent::ActionAcceptReplace)), + 398 => Some(EventType::Sieve(SieveEvent::ActionDiscard)), + 399 => Some(EventType::Sieve(SieveEvent::ActionReject)), + 406 => Some(EventType::Sieve(SieveEvent::SendMessage)), + 401 => Some(EventType::Sieve(SieveEvent::MessageTooLarge)), + 405 => Some(EventType::Sieve(SieveEvent::ScriptNotFound)), + 400 => Some(EventType::Sieve(SieveEvent::ListNotFound)), + 404 => Some(EventType::Sieve(SieveEvent::RuntimeError)), + 407 => Some(EventType::Sieve(SieveEvent::UnexpectedError)), + 402 => Some(EventType::Sieve(SieveEvent::NotSupported)), + 403 => Some(EventType::Sieve(SieveEvent::QuotaExceeded)), + 417 => Some(EventType::Smtp(SmtpEvent::ConnectionStart)), + 416 => Some(EventType::Smtp(SmtpEvent::ConnectionEnd)), + 428 => Some(EventType::Smtp(SmtpEvent::Error)), + 469 => Some(EventType::Smtp(SmtpEvent::IdNotFound)), + 415 => Some(EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded)), + 485 => Some(EventType::Smtp(SmtpEvent::TransferLimitExceeded)), + 461 => Some(EventType::Smtp(SmtpEvent::RateLimitExceeded)), + 481 => Some(EventType::Smtp(SmtpEvent::TimeLimitExceeded)), + 452 => Some(EventType::Smtp(SmtpEvent::MissingAuthDirectory)), + 450 => Some(EventType::Smtp(SmtpEvent::MessageParseFailed)), + 451 => Some(EventType::Smtp(SmtpEvent::MessageTooLarge)), + 443 => Some(EventType::Smtp(SmtpEvent::LoopDetected)), + 422 => Some(EventType::Smtp(SmtpEvent::DkimPass)), + 421 => Some(EventType::Smtp(SmtpEvent::DkimFail)), + 630 => Some(EventType::Smtp(SmtpEvent::Dkim2Pass)), + 631 => Some(EventType::Smtp(SmtpEvent::Dkim2Fail)), + 632 => Some(EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded)), + 410 => Some(EventType::Smtp(SmtpEvent::ArcPass)), + 409 => Some(EventType::Smtp(SmtpEvent::ArcFail)), + 474 => Some(EventType::Smtp(SmtpEvent::SpfEhloPass)), + 473 => Some(EventType::Smtp(SmtpEvent::SpfEhloFail)), + 476 => Some(EventType::Smtp(SmtpEvent::SpfFromPass)), + 475 => Some(EventType::Smtp(SmtpEvent::SpfFromFail)), + 424 => Some(EventType::Smtp(SmtpEvent::DmarcPass)), + 423 => Some(EventType::Smtp(SmtpEvent::DmarcFail)), + 441 => Some(EventType::Smtp(SmtpEvent::IprevPass)), + 440 => Some(EventType::Smtp(SmtpEvent::IprevFail)), + 483 => Some(EventType::Smtp(SmtpEvent::TooManyMessages)), + 426 => Some(EventType::Smtp(SmtpEvent::Ehlo)), + 436 => Some(EventType::Smtp(SmtpEvent::InvalidEhlo)), + 420 => Some(EventType::Smtp(SmtpEvent::DidNotSayEhlo)), + 427 => Some(EventType::Smtp(SmtpEvent::EhloExpected)), + 442 => Some(EventType::Smtp(SmtpEvent::LhloExpected)), + 447 => Some(EventType::Smtp(SmtpEvent::MailFromUnauthenticated)), + 448 => Some(EventType::Smtp(SmtpEvent::MailFromUnauthorized)), + 551 => Some(EventType::Smtp(SmtpEvent::MailFromNotAllowed)), + 446 => Some(EventType::Smtp(SmtpEvent::MailFromRewritten)), + 445 => Some(EventType::Smtp(SmtpEvent::MailFromMissing)), + 444 => Some(EventType::Smtp(SmtpEvent::MailFrom)), + 456 => Some(EventType::Smtp(SmtpEvent::MultipleMailFrom)), + 449 => Some(EventType::Smtp(SmtpEvent::MailboxDoesNotExist)), + 468 => Some(EventType::Smtp(SmtpEvent::RelayNotAllowed)), + 464 => Some(EventType::Smtp(SmtpEvent::RcptTo)), + 465 => Some(EventType::Smtp(SmtpEvent::RcptToDuplicate)), + 467 => Some(EventType::Smtp(SmtpEvent::RcptToRewritten)), + 466 => Some(EventType::Smtp(SmtpEvent::RcptToMissing)), + 561 => Some(EventType::Smtp(SmtpEvent::RcptToGreylisted)), + 484 => Some(EventType::Smtp(SmtpEvent::TooManyRecipients)), + 482 => Some(EventType::Smtp(SmtpEvent::TooManyInvalidRcpt)), + 462 => Some(EventType::Smtp(SmtpEvent::RawInput)), + 463 => Some(EventType::Smtp(SmtpEvent::RawOutput)), + 453 => Some(EventType::Smtp(SmtpEvent::MissingLocalHostname)), + 487 => Some(EventType::Smtp(SmtpEvent::Vrfy)), + 489 => Some(EventType::Smtp(SmtpEvent::VrfyNotFound)), + 488 => Some(EventType::Smtp(SmtpEvent::VrfyDisabled)), + 429 => Some(EventType::Smtp(SmtpEvent::Expn)), + 431 => Some(EventType::Smtp(SmtpEvent::ExpnNotFound)), + 430 => Some(EventType::Smtp(SmtpEvent::ExpnDisabled)), + 471 => Some(EventType::Smtp(SmtpEvent::RequireTlsDisabled)), + 418 => Some(EventType::Smtp(SmtpEvent::DeliverByDisabled)), + 419 => Some(EventType::Smtp(SmtpEvent::DeliverByInvalid)), + 432 => Some(EventType::Smtp(SmtpEvent::FutureReleaseDisabled)), + 433 => Some(EventType::Smtp(SmtpEvent::FutureReleaseInvalid)), + 454 => Some(EventType::Smtp(SmtpEvent::MtPriorityDisabled)), + 455 => Some(EventType::Smtp(SmtpEvent::MtPriorityInvalid)), + 425 => Some(EventType::Smtp(SmtpEvent::DsnDisabled)), + 413 => Some(EventType::Smtp(SmtpEvent::AuthNotAllowed)), + 412 => Some(EventType::Smtp(SmtpEvent::AuthMechanismNotSupported)), + 411 => Some(EventType::Smtp(SmtpEvent::AuthExchangeTooLong)), + 408 => Some(EventType::Smtp(SmtpEvent::AlreadyAuthenticated)), + 457 => Some(EventType::Smtp(SmtpEvent::Noop)), + 477 => Some(EventType::Smtp(SmtpEvent::StartTls)), + 479 => Some(EventType::Smtp(SmtpEvent::StartTlsUnavailable)), + 478 => Some(EventType::Smtp(SmtpEvent::StartTlsAlready)), + 472 => Some(EventType::Smtp(SmtpEvent::Rset)), + 460 => Some(EventType::Smtp(SmtpEvent::Quit)), + 434 => Some(EventType::Smtp(SmtpEvent::Help)), + 414 => Some(EventType::Smtp(SmtpEvent::CommandNotImplemented)), + 435 => Some(EventType::Smtp(SmtpEvent::InvalidCommand)), + 439 => Some(EventType::Smtp(SmtpEvent::InvalidSenderAddress)), + 438 => Some(EventType::Smtp(SmtpEvent::InvalidRecipientAddress)), + 437 => Some(EventType::Smtp(SmtpEvent::InvalidParameter)), + 486 => Some(EventType::Smtp(SmtpEvent::UnsupportedParameter)), + 480 => Some(EventType::Smtp(SmtpEvent::SyntaxError)), + 470 => Some(EventType::Smtp(SmtpEvent::RequestTooLarge)), + 564 => Some(EventType::Spam(SpamEvent::Pyzor)), + 494 => Some(EventType::Spam(SpamEvent::PyzorError)), + 562 => Some(EventType::Spam(SpamEvent::Dnsbl)), + 563 => Some(EventType::Spam(SpamEvent::DnsblError)), + 588 => Some(EventType::Spam(SpamEvent::TrainStarted)), + 495 => Some(EventType::Spam(SpamEvent::TrainCompleted)), + 143 => Some(EventType::Spam(SpamEvent::TrainSampleAdded)), + 491 => Some(EventType::Spam(SpamEvent::TrainSampleNotFound)), + 490 => Some(EventType::Spam(SpamEvent::Classify)), + 589 => Some(EventType::Spam(SpamEvent::ModelLoaded)), + 496 => Some(EventType::Spam(SpamEvent::ModelNotReady)), + 497 => Some(EventType::Spam(SpamEvent::ModelNotFound)), + 280 => Some(EventType::Spam(SpamEvent::RulesUpdated)), + 501 => Some(EventType::Spf(SpfEvent::Pass)), + 498 => Some(EventType::Spf(SpfEvent::Fail)), + 503 => Some(EventType::Spf(SpfEvent::SoftFail)), + 499 => Some(EventType::Spf(SpfEvent::Neutral)), + 504 => Some(EventType::Spf(SpfEvent::TempError)), + 502 => Some(EventType::Spf(SpfEvent::PermError)), + 500 => Some(EventType::Spf(SpfEvent::None)), + 505 => Some(EventType::Store(StoreEvent::AssertValueFailed)), + 518 => Some(EventType::Store(StoreEvent::FoundationdbError)), + 522 => Some(EventType::Store(StoreEvent::MysqlError)), + 527 => Some(EventType::Store(StoreEvent::PostgresqlError)), + 529 => Some(EventType::Store(StoreEvent::RocksdbError)), + 532 => Some(EventType::Store(StoreEvent::SqliteError)), + 520 => Some(EventType::Store(StoreEvent::LdapError)), + 516 => Some(EventType::Store(StoreEvent::ElasticsearchError)), + 590 => Some(EventType::Store(StoreEvent::MeilisearchError)), + 528 => Some(EventType::Store(StoreEvent::RedisError)), + 530 => Some(EventType::Store(StoreEvent::S3Error)), + 559 => Some(EventType::Store(StoreEvent::AzureError)), + 517 => Some(EventType::Store(StoreEvent::FilesystemError)), + 526 => Some(EventType::Store(StoreEvent::PoolError)), + 511 => Some(EventType::Store(StoreEvent::DataCorruption)), + 514 => Some(EventType::Store(StoreEvent::DecompressError)), + 515 => Some(EventType::Store(StoreEvent::DeserializeError)), + 524 => Some(EventType::Store(StoreEvent::NotFound)), + 523 => Some(EventType::Store(StoreEvent::NotConfigured)), + 525 => Some(EventType::Store(StoreEvent::NotSupported)), + 533 => Some(EventType::Store(StoreEvent::UnexpectedError)), + 510 => Some(EventType::Store(StoreEvent::CryptoError)), + 493 => Some(EventType::Store(StoreEvent::HttpStoreError)), + 50 => Some(EventType::Store(StoreEvent::CacheMiss)), + 51 => Some(EventType::Store(StoreEvent::CacheHit)), + 52 => Some(EventType::Store(StoreEvent::CacheStale)), + 577 => Some(EventType::Store(StoreEvent::CacheUpdate)), + 633 => Some(EventType::Store(StoreEvent::CacheEntryTooLarge)), + 507 => Some(EventType::Store(StoreEvent::BlobMissingMarker)), + 513 => Some(EventType::Store(StoreEvent::DataWrite)), + 512 => Some(EventType::Store(StoreEvent::DataIterate)), + 508 => Some(EventType::Store(StoreEvent::BlobRead)), + 509 => Some(EventType::Store(StoreEvent::BlobWrite)), + 506 => Some(EventType::Store(StoreEvent::BlobDelete)), + 531 => Some(EventType::Store(StoreEvent::SqlQuery)), + 521 => Some(EventType::Store(StoreEvent::LdapQuery)), + 519 => Some(EventType::Store(StoreEvent::LdapWarning)), + 492 => Some(EventType::Store(StoreEvent::HttpStoreFetch)), + 364 => Some(EventType::Store(StoreEvent::AutoExpunge)), + 369 => Some(EventType::Store(StoreEvent::BlobStorePurged)), + 368 => Some(EventType::Store(StoreEvent::DataStorePurged)), + 578 => Some(EventType::TaskManager(TaskManagerEvent::TaskAcquired)), + 149 => Some(EventType::TaskManager(TaskManagerEvent::TaskQueued)), + 370 => Some(EventType::TaskManager(TaskManagerEvent::TaskScheduled)), + 144 => Some(EventType::TaskManager(TaskManagerEvent::TaskLocked)), + 586 => Some(EventType::TaskManager(TaskManagerEvent::TaskIgnored)), + 587 => Some(EventType::TaskManager(TaskManagerEvent::TaskFailed)), + 53 => Some(EventType::TaskManager(TaskManagerEvent::TaskRetry)), + 141 => Some(EventType::TaskManager(TaskManagerEvent::BlobNotFound)), + 145 => Some(EventType::TaskManager(TaskManagerEvent::MetadataNotFound)), + 150 => Some(EventType::TaskManager(TaskManagerEvent::SchedulerStarted)), + 367 => Some(EventType::TaskManager(TaskManagerEvent::ManagerStarted)), + 548 => Some(EventType::Telemetry(TelemetryEvent::AlertEvent)), + 365 => Some(EventType::Telemetry(TelemetryEvent::AlertMessage)), + 535 => Some(EventType::Telemetry(TelemetryEvent::LogError)), + 539 => Some(EventType::Telemetry(TelemetryEvent::WebhookError)), + 536 => Some(EventType::Telemetry(TelemetryEvent::OtelExporterError)), + 537 => Some(EventType::Telemetry( + TelemetryEvent::OtelMetricsExporterError, + )), + 538 => Some(EventType::Telemetry( + TelemetryEvent::PrometheusExporterError, + )), + 534 => Some(EventType::Telemetry(TelemetryEvent::JournalError)), + 151 => Some(EventType::Telemetry(TelemetryEvent::MetricsCollected)), + 366 => Some(EventType::Telemetry(TelemetryEvent::MetricsStored)), + 146 => Some(EventType::Telemetry(TelemetryEvent::MetricsPushed)), + 543 => Some(EventType::Tls(TlsEvent::Handshake)), + 544 => Some(EventType::Tls(TlsEvent::HandshakeError)), + 547 => Some(EventType::Tls(TlsEvent::NotConfigured)), + 542 => Some(EventType::Tls(TlsEvent::CertificateNotFound)), + 546 => Some(EventType::Tls(TlsEvent::NoCertificatesAvailable)), + 545 => Some(EventType::Tls(TlsEvent::MultipleCertificatesAvailable)), + 277 => Some(EventType::Tls(TlsEvent::ExpiredCertificateRemoved)), + 540 => Some(EventType::TlsRpt(TlsRptEvent::RecordFetch)), + 541 => Some(EventType::TlsRpt(TlsRptEvent::RecordFetchError)), + 560 => Some(EventType::TlsRpt(TlsRptEvent::RecordNotFound)), + 147 => Some(EventType::WebDav(WebDavEvent::Propfind)), + 148 => Some(EventType::WebDav(WebDavEvent::Proppatch)), + 335 => Some(EventType::WebDav(WebDavEvent::Get)), + 574 => Some(EventType::WebDav(WebDavEvent::Head)), + 336 => Some(EventType::WebDav(WebDavEvent::Report)), + 376 => Some(EventType::WebDav(WebDavEvent::Mkcol)), + 575 => Some(EventType::WebDav(WebDavEvent::Mkcalendar)), + 458 => Some(EventType::WebDav(WebDavEvent::Delete)), + 459 => Some(EventType::WebDav(WebDavEvent::Put)), + 565 => Some(EventType::WebDav(WebDavEvent::Post)), + 566 => Some(EventType::WebDav(WebDavEvent::Patch)), + 567 => Some(EventType::WebDav(WebDavEvent::Copy)), + 568 => Some(EventType::WebDav(WebDavEvent::Move)), + 569 => Some(EventType::WebDav(WebDavEvent::Lock)), + 570 => Some(EventType::WebDav(WebDavEvent::Unlock)), + 571 => Some(EventType::WebDav(WebDavEvent::Acl)), + 573 => Some(EventType::WebDav(WebDavEvent::Options)), + 572 => Some(EventType::WebDav(WebDavEvent::Error)), + _ => None, + } + } + + pub fn level(&self) -> Level { + match self { + EventType::Acme(AcmeEvent::Error) => Level::Error, + EventType::Auth(AuthEvent::Error) => Level::Error, + EventType::Cluster(ClusterEvent::SubscriberError) => Level::Error, + EventType::Cluster(ClusterEvent::PublisherError) => Level::Error, + EventType::Cluster(ClusterEvent::MessageInvalid) => Level::Error, + EventType::Dkim(DkimEvent::BuildError) => Level::Error, + EventType::Dns(DnsEvent::BuildError) => Level::Error, + EventType::MessageIngest(MessageIngestEvent::Error) => Level::Error, + EventType::Network(NetworkEvent::ListenError) => Level::Error, + EventType::Network(NetworkEvent::BindError) => Level::Error, + EventType::Network(NetworkEvent::SplitError) => Level::Error, + EventType::Network(NetworkEvent::SetOptError) => Level::Error, + EventType::Registry(RegistryEvent::LocalReadError) => Level::Error, + EventType::Registry(RegistryEvent::LocalWriteError) => Level::Error, + EventType::Registry(RegistryEvent::LocalParseError) => Level::Error, + EventType::Registry(RegistryEvent::ReadError) => Level::Error, + EventType::Registry(RegistryEvent::WriteError) => Level::Error, + EventType::Registry(RegistryEvent::DeserializationError) => Level::Error, + EventType::Registry(RegistryEvent::BuildError) => Level::Error, + EventType::Registry(RegistryEvent::ValidationError) => Level::Error, + EventType::Resource(ResourceEvent::BadParameters) => Level::Error, + EventType::Resource(ResourceEvent::Error) => Level::Error, + EventType::Server(ServerEvent::StartupError) => Level::Error, + EventType::Server(ServerEvent::ThreadError) => Level::Error, + EventType::Sieve(SieveEvent::UnexpectedError) => Level::Error, + EventType::Store(StoreEvent::AssertValueFailed) => Level::Error, + EventType::Store(StoreEvent::FoundationdbError) => Level::Error, + EventType::Store(StoreEvent::MysqlError) => Level::Error, + EventType::Store(StoreEvent::PostgresqlError) => Level::Error, + EventType::Store(StoreEvent::RocksdbError) => Level::Error, + EventType::Store(StoreEvent::SqliteError) => Level::Error, + EventType::Store(StoreEvent::LdapError) => Level::Error, + EventType::Store(StoreEvent::ElasticsearchError) => Level::Error, + EventType::Store(StoreEvent::MeilisearchError) => Level::Error, + EventType::Store(StoreEvent::RedisError) => Level::Error, + EventType::Store(StoreEvent::S3Error) => Level::Error, + EventType::Store(StoreEvent::AzureError) => Level::Error, + EventType::Store(StoreEvent::FilesystemError) => Level::Error, + EventType::Store(StoreEvent::PoolError) => Level::Error, + EventType::Store(StoreEvent::DataCorruption) => Level::Error, + EventType::Store(StoreEvent::DecompressError) => Level::Error, + EventType::Store(StoreEvent::DeserializeError) => Level::Error, + EventType::Store(StoreEvent::NotConfigured) => Level::Error, + EventType::Store(StoreEvent::NotSupported) => Level::Error, + EventType::Store(StoreEvent::UnexpectedError) => Level::Error, + EventType::Store(StoreEvent::CryptoError) => Level::Error, + EventType::Tls(TlsEvent::NotConfigured) => Level::Error, + EventType::Acme(AcmeEvent::AuthStart) => Level::Info, + EventType::Acme(AcmeEvent::AuthPending) => Level::Info, + EventType::Acme(AcmeEvent::AuthValid) => Level::Info, + EventType::Acme(AcmeEvent::AuthCompleted) => Level::Info, + EventType::Acme(AcmeEvent::ProcessCert) => Level::Info, + EventType::Acme(AcmeEvent::OrderStart) => Level::Info, + EventType::Acme(AcmeEvent::OrderProcessing) => Level::Info, + EventType::Acme(AcmeEvent::OrderCompleted) => Level::Info, + EventType::Acme(AcmeEvent::OrderReady) => Level::Info, + EventType::Acme(AcmeEvent::OrderValid) => Level::Info, + EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info, + EventType::Auth(AuthEvent::Success) => Level::Info, + EventType::Auth(AuthEvent::ClientRegistration) => Level::Info, + EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info, + EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info, + EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info, + EventType::Cluster(ClusterEvent::Startup) => Level::Info, + EventType::Cluster(ClusterEvent::SubscriberStart) => Level::Info, + EventType::Cluster(ClusterEvent::SubscriberStop) => Level::Info, + EventType::Cluster(ClusterEvent::PublisherStart) => Level::Info, + EventType::Cluster(ClusterEvent::PublisherStop) => Level::Info, + EventType::Dane(DaneEvent::AuthenticationSuccess) => Level::Info, + EventType::Dane(DaneEvent::AuthenticationFailure) => Level::Info, + EventType::Dane(DaneEvent::NoCertificatesFound) => Level::Info, + EventType::Dane(DaneEvent::CertificateParseError) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordMatch) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordFetch) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordFetchError) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordNotFound) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned) => Level::Info, + EventType::Dane(DaneEvent::TlsaRecordInvalid) => Level::Info, + EventType::Dane(DaneEvent::BogusDnssecRecord) => Level::Info, + EventType::Delivery(DeliveryEvent::AttemptStart) => Level::Info, + EventType::Delivery(DeliveryEvent::AttemptEnd) => Level::Info, + EventType::Delivery(DeliveryEvent::Completed) => Level::Info, + EventType::Delivery(DeliveryEvent::Failed) => Level::Info, + EventType::Delivery(DeliveryEvent::DomainDeliveryStart) => Level::Info, + EventType::Delivery(DeliveryEvent::MxLookupFailed) => Level::Info, + EventType::Delivery(DeliveryEvent::IpLookupFailed) => Level::Info, + EventType::Delivery(DeliveryEvent::NullMx) => Level::Info, + EventType::Delivery(DeliveryEvent::Connect) => Level::Info, + EventType::Delivery(DeliveryEvent::ConnectError) => Level::Info, + EventType::Delivery(DeliveryEvent::GreetingFailed) => Level::Info, + EventType::Delivery(DeliveryEvent::EhloRejected) => Level::Info, + EventType::Delivery(DeliveryEvent::AuthFailed) => Level::Info, + EventType::Delivery(DeliveryEvent::MailFromRejected) => Level::Info, + EventType::Delivery(DeliveryEvent::Delivered) => Level::Info, + EventType::Delivery(DeliveryEvent::RcptToRejected) => Level::Info, + EventType::Delivery(DeliveryEvent::RcptToFailed) => Level::Info, + EventType::Delivery(DeliveryEvent::MessageRejected) => Level::Info, + EventType::Delivery(DeliveryEvent::StartTls) => Level::Info, + EventType::Delivery(DeliveryEvent::StartTlsUnavailable) => Level::Info, + EventType::Delivery(DeliveryEvent::StartTlsError) => Level::Info, + EventType::Delivery(DeliveryEvent::StartTlsDisabled) => Level::Info, + EventType::Delivery(DeliveryEvent::ImplicitTlsError) => Level::Info, + EventType::Delivery(DeliveryEvent::DoubleBounce) => Level::Info, + EventType::Delivery(DeliveryEvent::DsnSuccess) => Level::Info, + EventType::Delivery(DeliveryEvent::DsnTempFail) => Level::Info, + EventType::Delivery(DeliveryEvent::DsnPermFail) => Level::Info, + EventType::Dkim(DkimEvent::SignatureCreated) => Level::Info, + EventType::Dkim(DkimEvent::SignaturePublished) => Level::Info, + EventType::Dkim(DkimEvent::SignatureRetiring) => Level::Info, + EventType::Dkim(DkimEvent::SignatureRetired) => Level::Info, + EventType::Dkim(DkimEvent::SignatureDeleted) => Level::Info, + EventType::Dns(DnsEvent::RecordCreated) => Level::Info, + EventType::Dns(DnsEvent::RecordPropagated) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::DmarcReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::TlsReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::AbuseReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::AuthFailureReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::FraudReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::NotSpamReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::VirusReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::OtherReport) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::MessageParseFailed) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::ArfParseFailed) => Level::Info, + EventType::IncomingReport(IncomingReportEvent::DecompressError) => Level::Info, + EventType::Limit(LimitEvent::TenantQuota) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::Ham) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::Spam) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::ImapAppend) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::JmapAppend) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::Duplicate) => Level::Info, + EventType::MessageIngest(MessageIngestEvent::SearchIndex) => Level::Info, + EventType::Milter(MilterEvent::ActionAccept) => Level::Info, + EventType::Milter(MilterEvent::ActionDiscard) => Level::Info, + EventType::Milter(MilterEvent::ActionReject) => Level::Info, + EventType::Milter(MilterEvent::ActionTempFail) => Level::Info, + EventType::Milter(MilterEvent::ActionReplyCode) => Level::Info, + EventType::Milter(MilterEvent::ActionConnectionFailure) => Level::Info, + EventType::Milter(MilterEvent::ActionShutdown) => Level::Info, + EventType::MtaHook(MtaHookEvent::ActionAccept) => Level::Info, + EventType::MtaHook(MtaHookEvent::ActionDiscard) => Level::Info, + EventType::MtaHook(MtaHookEvent::ActionReject) => Level::Info, + EventType::MtaHook(MtaHookEvent::ActionQuarantine) => Level::Info, + EventType::MtaSts(MtaStsEvent::Authorized) => Level::Info, + EventType::MtaSts(MtaStsEvent::NotAuthorized) => Level::Info, + EventType::MtaSts(MtaStsEvent::PolicyFetch) => Level::Info, + EventType::MtaSts(MtaStsEvent::PolicyNotFound) => Level::Info, + EventType::MtaSts(MtaStsEvent::PolicyFetchError) => Level::Info, + EventType::MtaSts(MtaStsEvent::InvalidPolicy) => Level::Info, + EventType::Network(NetworkEvent::ListenStart) => Level::Info, + EventType::Network(NetworkEvent::ListenStop) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::SpfReport) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::DkimReport) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::DmarcReport) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress) => { + Level::Info + } + EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError) => { + Level::Info + } + EventType::OutgoingReport(OutgoingReportEvent::NotFound) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::SubmissionError) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound) => Level::Info, + EventType::OutgoingReport(OutgoingReportEvent::Locked) => Level::Info, + EventType::Queue(QueueEvent::Started) => Level::Info, + EventType::Queue(QueueEvent::MessageQueued) => Level::Info, + EventType::Queue(QueueEvent::AuthenticatedMessageQueued) => Level::Info, + EventType::Queue(QueueEvent::ReportQueued) => Level::Info, + EventType::Queue(QueueEvent::DsnQueued) => Level::Info, + EventType::Queue(QueueEvent::AutogeneratedQueued) => Level::Info, + EventType::Queue(QueueEvent::Rescheduled) => Level::Info, + EventType::Queue(QueueEvent::RateLimitExceeded) => Level::Info, + EventType::Queue(QueueEvent::ConcurrencyLimitExceeded) => Level::Info, + EventType::Queue(QueueEvent::QuotaExceeded) => Level::Info, + EventType::Resource(ResourceEvent::DownloadExternal) => Level::Info, + EventType::Resource(ResourceEvent::ApplicationUpdated) => Level::Info, + EventType::Security(SecurityEvent::AuthenticationBan) => Level::Info, + EventType::Security(SecurityEvent::AbuseBan) => Level::Info, + EventType::Security(SecurityEvent::ScanBan) => Level::Info, + EventType::Security(SecurityEvent::LoiterBan) => Level::Info, + EventType::Security(SecurityEvent::IpBlocked) => Level::Info, + EventType::Security(SecurityEvent::IpBlockExpired) => Level::Info, + EventType::Security(SecurityEvent::IpAllowExpired) => Level::Info, + EventType::Security(SecurityEvent::IpUnauthorized) => Level::Info, + EventType::Security(SecurityEvent::Unauthorized) => Level::Info, + EventType::Server(ServerEvent::Startup) => Level::Info, + EventType::Server(ServerEvent::Shutdown) => Level::Info, + EventType::Server(ServerEvent::Licensing) => Level::Info, + EventType::Sieve(SieveEvent::SendMessage) => Level::Info, + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded) => Level::Info, + EventType::Smtp(SmtpEvent::TransferLimitExceeded) => Level::Info, + EventType::Smtp(SmtpEvent::RateLimitExceeded) => Level::Info, + EventType::Smtp(SmtpEvent::TimeLimitExceeded) => Level::Info, + EventType::Smtp(SmtpEvent::MissingAuthDirectory) => Level::Info, + EventType::Smtp(SmtpEvent::MessageParseFailed) => Level::Info, + EventType::Smtp(SmtpEvent::MessageTooLarge) => Level::Info, + EventType::Smtp(SmtpEvent::LoopDetected) => Level::Info, + EventType::Smtp(SmtpEvent::DkimPass) => Level::Info, + EventType::Smtp(SmtpEvent::DkimFail) => Level::Info, + EventType::Smtp(SmtpEvent::Dkim2Pass) => Level::Info, + EventType::Smtp(SmtpEvent::Dkim2Fail) => Level::Info, + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded) => Level::Info, + EventType::Smtp(SmtpEvent::ArcPass) => Level::Info, + EventType::Smtp(SmtpEvent::ArcFail) => Level::Info, + EventType::Smtp(SmtpEvent::SpfEhloPass) => Level::Info, + EventType::Smtp(SmtpEvent::SpfEhloFail) => Level::Info, + EventType::Smtp(SmtpEvent::SpfFromPass) => Level::Info, + EventType::Smtp(SmtpEvent::SpfFromFail) => Level::Info, + EventType::Smtp(SmtpEvent::DmarcPass) => Level::Info, + EventType::Smtp(SmtpEvent::DmarcFail) => Level::Info, + EventType::Smtp(SmtpEvent::IprevPass) => Level::Info, + EventType::Smtp(SmtpEvent::IprevFail) => Level::Info, + EventType::Smtp(SmtpEvent::TooManyMessages) => Level::Info, + EventType::Smtp(SmtpEvent::Ehlo) => Level::Info, + EventType::Smtp(SmtpEvent::InvalidEhlo) => Level::Info, + EventType::Smtp(SmtpEvent::MailFrom) => Level::Info, + EventType::Smtp(SmtpEvent::MailboxDoesNotExist) => Level::Info, + EventType::Smtp(SmtpEvent::RelayNotAllowed) => Level::Info, + EventType::Smtp(SmtpEvent::RcptTo) => Level::Info, + EventType::Smtp(SmtpEvent::RcptToGreylisted) => Level::Info, + EventType::Smtp(SmtpEvent::TooManyRecipients) => Level::Info, + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt) => Level::Info, + EventType::Smtp(SmtpEvent::Vrfy) => Level::Info, + EventType::Smtp(SmtpEvent::VrfyNotFound) => Level::Info, + EventType::Smtp(SmtpEvent::VrfyDisabled) => Level::Info, + EventType::Smtp(SmtpEvent::Expn) => Level::Info, + EventType::Smtp(SmtpEvent::ExpnNotFound) => Level::Info, + EventType::Smtp(SmtpEvent::ExpnDisabled) => Level::Info, + EventType::Smtp(SmtpEvent::AuthNotAllowed) => Level::Info, + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported) => Level::Info, + EventType::Smtp(SmtpEvent::RequestTooLarge) => Level::Info, + EventType::Spam(SpamEvent::TrainStarted) => Level::Info, + EventType::Spam(SpamEvent::TrainCompleted) => Level::Info, + EventType::Spam(SpamEvent::ModelLoaded) => Level::Info, + EventType::Spam(SpamEvent::ModelNotReady) => Level::Info, + EventType::Spam(SpamEvent::ModelNotFound) => Level::Info, + EventType::Spam(SpamEvent::RulesUpdated) => Level::Info, + EventType::Store(StoreEvent::BlobStorePurged) => Level::Info, + EventType::Store(StoreEvent::DataStorePurged) => Level::Info, + EventType::TaskManager(TaskManagerEvent::TaskQueued) => Level::Info, + EventType::TaskManager(TaskManagerEvent::TaskScheduled) => Level::Info, + EventType::TaskManager(TaskManagerEvent::SchedulerStarted) => Level::Info, + EventType::TaskManager(TaskManagerEvent::ManagerStarted) => Level::Info, + EventType::Telemetry(TelemetryEvent::AlertMessage) => Level::Info, + EventType::Telemetry(TelemetryEvent::MetricsCollected) => Level::Info, + EventType::Tls(TlsEvent::Handshake) => Level::Info, + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => Level::Info, + EventType::TlsRpt(TlsRptEvent::RecordFetch) => Level::Info, + EventType::TlsRpt(TlsRptEvent::RecordFetchError) => Level::Info, + EventType::TlsRpt(TlsRptEvent::RecordNotFound) => Level::Info, + EventType::Ai(AiEvent::LlmResponse) => Level::Trace, + EventType::Auth(AuthEvent::MfaRequired) => Level::Trace, + EventType::Cluster(ClusterEvent::MessageReceived) => Level::Trace, + EventType::Cluster(ClusterEvent::MessageSkipped) => Level::Trace, + EventType::Delivery(DeliveryEvent::RawInput) => Level::Trace, + EventType::Delivery(DeliveryEvent::RawOutput) => Level::Trace, + EventType::Eval(EvalEvent::Result) => Level::Trace, + EventType::Http(HttpEvent::RequestBody) => Level::Trace, + EventType::Http(HttpEvent::ResponseBody) => Level::Trace, + EventType::Imap(ImapEvent::RawInput) => Level::Trace, + EventType::Imap(ImapEvent::RawOutput) => Level::Trace, + EventType::ManageSieve(ManageSieveEvent::RawInput) => Level::Trace, + EventType::ManageSieve(ManageSieveEvent::RawOutput) => Level::Trace, + EventType::Milter(MilterEvent::Read) => Level::Trace, + EventType::Milter(MilterEvent::Write) => Level::Trace, + EventType::Network(NetworkEvent::ReadError) => Level::Trace, + EventType::Network(NetworkEvent::WriteError) => Level::Trace, + EventType::Network(NetworkEvent::FlushError) => Level::Trace, + EventType::Network(NetworkEvent::Closed) => Level::Trace, + EventType::Pop3(Pop3Event::RawInput) => Level::Trace, + EventType::Pop3(Pop3Event::RawOutput) => Level::Trace, + EventType::PushSubscription(PushSubscriptionEvent::Success) => Level::Trace, + EventType::Smtp(SmtpEvent::RawInput) => Level::Trace, + EventType::Smtp(SmtpEvent::RawOutput) => Level::Trace, + EventType::Store(StoreEvent::DataWrite) => Level::Trace, + EventType::Store(StoreEvent::DataIterate) => Level::Trace, + EventType::Store(StoreEvent::BlobRead) => Level::Trace, + EventType::Store(StoreEvent::BlobWrite) => Level::Trace, + EventType::Store(StoreEvent::BlobDelete) => Level::Trace, + EventType::Store(StoreEvent::SqlQuery) => Level::Trace, + EventType::Store(StoreEvent::LdapQuery) => Level::Trace, + EventType::Acme(AcmeEvent::AuthError) => Level::Warn, + EventType::Acme(AcmeEvent::AuthTooManyAttempts) => Level::Warn, + EventType::Acme(AcmeEvent::OrderInvalid) => Level::Warn, + EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn, + EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn, + EventType::Ai(AiEvent::ApiError) => Level::Warn, + EventType::Arc(ArcEvent::SealerNotFound) => Level::Warn, + EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn, + EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn, + EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn, + EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn, + EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn, + EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn, + EventType::Dkim(DkimEvent::SignerNotFound) => Level::Warn, + EventType::Dns(DnsEvent::RecordCreationFailed) => Level::Warn, + EventType::Dns(DnsEvent::RecordPropagationTimeout) => Level::Warn, + EventType::Eval(EvalEvent::DirectoryNotFound) => Level::Warn, + EventType::Http(HttpEvent::XForwardedMissing) => Level::Warn, + EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings) => Level::Warn, + EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings) => Level::Warn, + EventType::Limit(LimitEvent::ConcurrentConnection) => Level::Warn, + EventType::Limit(LimitEvent::TooManyRequests) => Level::Warn, + EventType::Milter(MilterEvent::IoError) => Level::Warn, + EventType::Milter(MilterEvent::FrameTooLarge) => Level::Warn, + EventType::Milter(MilterEvent::FrameInvalid) => Level::Warn, + EventType::Milter(MilterEvent::UnexpectedResponse) => Level::Warn, + EventType::Milter(MilterEvent::Timeout) => Level::Warn, + EventType::Milter(MilterEvent::TlsInvalidName) => Level::Warn, + EventType::Milter(MilterEvent::Disconnected) => Level::Warn, + EventType::Milter(MilterEvent::ParseError) => Level::Warn, + EventType::MtaHook(MtaHookEvent::Error) => Level::Warn, + EventType::Network(NetworkEvent::ProxyError) => Level::Warn, + EventType::Queue(QueueEvent::BackPressure) => Level::Warn, + EventType::Registry(RegistryEvent::BuildWarning) => Level::Warn, + EventType::Server(ServerEvent::RecoveryMode) => Level::Warn, + EventType::Server(ServerEvent::BootstrapMode) => Level::Warn, + EventType::Sieve(SieveEvent::MessageTooLarge) => Level::Warn, + EventType::Sieve(SieveEvent::ScriptNotFound) => Level::Warn, + EventType::Sieve(SieveEvent::ListNotFound) => Level::Warn, + EventType::Sieve(SieveEvent::NotSupported) => Level::Warn, + EventType::Sieve(SieveEvent::QuotaExceeded) => Level::Warn, + EventType::Smtp(SmtpEvent::IdNotFound) => Level::Warn, + EventType::Smtp(SmtpEvent::MissingLocalHostname) => Level::Warn, + EventType::Spam(SpamEvent::TrainSampleNotFound) => Level::Warn, + EventType::Store(StoreEvent::HttpStoreError) => Level::Warn, + EventType::Store(StoreEvent::CacheEntryTooLarge) => Level::Warn, + EventType::Store(StoreEvent::BlobMissingMarker) => Level::Warn, + EventType::TaskManager(TaskManagerEvent::TaskFailed) => Level::Warn, + EventType::Telemetry(TelemetryEvent::AlertEvent) => Level::Warn, + EventType::Telemetry(TelemetryEvent::LogError) => Level::Warn, + EventType::Telemetry(TelemetryEvent::WebhookError) => Level::Warn, + EventType::Telemetry(TelemetryEvent::OtelExporterError) => Level::Warn, + EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError) => Level::Warn, + EventType::Telemetry(TelemetryEvent::PrometheusExporterError) => Level::Warn, + EventType::Telemetry(TelemetryEvent::JournalError) => Level::Warn, + EventType::Tls(TlsEvent::NoCertificatesAvailable) => Level::Warn, + EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => Level::Warn, + _ => Level::Debug, + } + } + + pub fn description(&self) -> &'static str { + match self { + EventType::Acme(AcmeEvent::AuthStart) => "ACME authentication started", + EventType::Acme(AcmeEvent::AuthPending) => "ACME authentication pending", + EventType::Acme(AcmeEvent::AuthValid) => "ACME authentication valid", + EventType::Acme(AcmeEvent::AuthCompleted) => "ACME authentication completed", + EventType::Acme(AcmeEvent::AuthError) => "ACME authentication error", + EventType::Acme(AcmeEvent::AuthTooManyAttempts) => { + "Too many ACME authentication attempts" + } + EventType::Acme(AcmeEvent::ProcessCert) => "Processing ACME certificate", + EventType::Acme(AcmeEvent::OrderStart) => "ACME order started", + EventType::Acme(AcmeEvent::OrderProcessing) => "ACME order processing", + EventType::Acme(AcmeEvent::OrderCompleted) => "ACME order completed", + EventType::Acme(AcmeEvent::OrderReady) => "ACME order ready", + EventType::Acme(AcmeEvent::OrderValid) => "ACME order valid", + EventType::Acme(AcmeEvent::OrderInvalid) => "ACME order invalid", + EventType::Acme(AcmeEvent::RenewBackoff) => "ACME renew backoff", + EventType::Acme(AcmeEvent::ClientSuppliedSni) => "ACME client supplied SNI", + EventType::Acme(AcmeEvent::ClientMissingSni) => "ACME client missing SNI", + EventType::Acme(AcmeEvent::TlsAlpnReceived) => "ACME TLS ALPN received", + EventType::Acme(AcmeEvent::TlsAlpnError) => "ACME TLS ALPN error", + EventType::Acme(AcmeEvent::TokenNotFound) => "ACME token not found", + EventType::Acme(AcmeEvent::Error) => "ACME error", + EventType::Ai(AiEvent::LlmResponse) => "LLM response", + EventType::Ai(AiEvent::ApiError) => "AI API error", + EventType::Arc(ArcEvent::ChainTooLong) => "ARC chain too long", + EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance", + EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV", + EventType::Arc(ArcEvent::HasHeaderTag) => "ARC has header tag", + EventType::Arc(ArcEvent::BrokenChain) => "Broken ARC chain", + EventType::Arc(ArcEvent::SealerNotFound) => "ARC sealer not found", + EventType::Auth(AuthEvent::Success) => "Authentication successful", + EventType::Auth(AuthEvent::Failed) => "Authentication failed", + EventType::Auth(AuthEvent::TokenExpired) => "OAuth token expired", + EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication", + EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", + EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", + EventType::Auth(AuthEvent::Error) => "Authentication error", + EventType::Auth(AuthEvent::Warning) => "Authentication warning", + EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", + EventType::Calendar(CalendarEvent::RuleExpansionError) => { + "Calendar rule expansion error" + } + EventType::Calendar(CalendarEvent::AlarmSent) => "Calendar alarm sent", + EventType::Calendar(CalendarEvent::AlarmSkipped) => "Calendar alarm skipped", + EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => { + "Calendar alarm recipient overriden" + } + EventType::Calendar(CalendarEvent::AlarmFailed) => "Calendar alarm could not be sent", + EventType::Calendar(CalendarEvent::ItipMessageSent) => "Calendar iTIP message sent", + EventType::Calendar(CalendarEvent::ItipMessageReceived) => { + "Calendar iTIP message received" + } + EventType::Calendar(CalendarEvent::ItipMessageError) => "iTIP message error", + EventType::Cluster(ClusterEvent::Startup) => "Clustering enabled", + EventType::Cluster(ClusterEvent::SubscriberStart) => "PubSub subscriber started", + EventType::Cluster(ClusterEvent::SubscriberStop) => "PubSub subscriber stopped", + EventType::Cluster(ClusterEvent::SubscriberError) => "PubSub subscriber error", + EventType::Cluster(ClusterEvent::SubscriberDisconnected) => { + "PubSub subscriber disconnected" + } + EventType::Cluster(ClusterEvent::PublisherStart) => "PubSub publisher started", + EventType::Cluster(ClusterEvent::PublisherStop) => "PubSub publisher stopped", + EventType::Cluster(ClusterEvent::PublisherError) => "PubSub publisher error", + EventType::Cluster(ClusterEvent::MessageReceived) => "PubSub message received", + EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped", + EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message", + EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed", + EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful", + EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed", + EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE", + EventType::Dane(DaneEvent::CertificateParseError) => { + "Error parsing certificate for DANE" + } + EventType::Dane(DaneEvent::TlsaRecordMatch) => "TLSA record match found", + EventType::Dane(DaneEvent::TlsaRecordFetch) => "Fetching TLSA record", + EventType::Dane(DaneEvent::TlsaRecordFetchError) => "Error fetching TLSA record", + EventType::Dane(DaneEvent::TlsaRecordNotFound) => "TLSA record not found", + EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned) => { + "TLSA record not DNSSEC signed" + } + EventType::Dane(DaneEvent::TlsaRecordInvalid) => "Invalid TLSA record", + EventType::Dane(DaneEvent::BogusDnssecRecord) => "Bogus DNSSEC record", + EventType::Delivery(DeliveryEvent::AttemptStart) => "Delivery attempt started", + EventType::Delivery(DeliveryEvent::AttemptEnd) => "Delivery attempt ended", + EventType::Delivery(DeliveryEvent::Completed) => "Delivery completed", + EventType::Delivery(DeliveryEvent::Failed) => "Delivery failed", + EventType::Delivery(DeliveryEvent::DomainDeliveryStart) => { + "New delivery attempt for domain" + } + EventType::Delivery(DeliveryEvent::MxLookup) => "MX record lookup", + EventType::Delivery(DeliveryEvent::MxLookupFailed) => "MX record lookup failed", + EventType::Delivery(DeliveryEvent::IpLookup) => "IP address lookup", + EventType::Delivery(DeliveryEvent::IpLookupFailed) => "IP address lookup failed", + EventType::Delivery(DeliveryEvent::NullMx) => "Null MX record found", + EventType::Delivery(DeliveryEvent::Connect) => "Connecting to remote server", + EventType::Delivery(DeliveryEvent::ConnectError) => "Connection error", + EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => { + "Missing outbound hostname in configuration" + } + EventType::Delivery(DeliveryEvent::GreetingFailed) => "SMTP greeting failed", + EventType::Delivery(DeliveryEvent::Ehlo) => "SMTP EHLO command", + EventType::Delivery(DeliveryEvent::EhloRejected) => "SMTP EHLO rejected", + EventType::Delivery(DeliveryEvent::Auth) => "SMTP authentication", + EventType::Delivery(DeliveryEvent::AuthFailed) => "SMTP authentication failed", + EventType::Delivery(DeliveryEvent::MailFrom) => "SMTP MAIL FROM command", + EventType::Delivery(DeliveryEvent::MailFromRejected) => "SMTP MAIL FROM rejected", + EventType::Delivery(DeliveryEvent::Delivered) => "Message delivered", + EventType::Delivery(DeliveryEvent::RcptTo) => "SMTP RCPT TO command", + EventType::Delivery(DeliveryEvent::RcptToRejected) => "SMTP RCPT TO rejected", + EventType::Delivery(DeliveryEvent::RcptToFailed) => "SMTP RCPT TO failed", + EventType::Delivery(DeliveryEvent::MessageRejected) => { + "Message rejected by remote server" + } + EventType::Delivery(DeliveryEvent::StartTls) => "SMTP STARTTLS command", + EventType::Delivery(DeliveryEvent::StartTlsUnavailable) => "STARTTLS unavailable", + EventType::Delivery(DeliveryEvent::StartTlsError) => "STARTTLS error", + EventType::Delivery(DeliveryEvent::StartTlsDisabled) => "STARTTLS disabled", + EventType::Delivery(DeliveryEvent::ImplicitTlsError) => "Implicit TLS error", + EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => { + "Concurrency limit exceeded" + } + EventType::Delivery(DeliveryEvent::RateLimitExceeded) => "Rate limit exceeded", + EventType::Delivery(DeliveryEvent::DoubleBounce) => { + "Discarding message after double bounce" + } + EventType::Delivery(DeliveryEvent::DsnSuccess) => "DSN success notification", + EventType::Delivery(DeliveryEvent::DsnTempFail) => "DSN temporary failure notification", + EventType::Delivery(DeliveryEvent::DsnPermFail) => "DSN permanent failure notification", + EventType::Delivery(DeliveryEvent::RawInput) => "Raw SMTP input received", + EventType::Delivery(DeliveryEvent::RawOutput) => "Raw SMTP output sent", + EventType::Dkim(DkimEvent::Pass) => "DKIM verification passed", + EventType::Dkim(DkimEvent::Neutral) => "DKIM verification neutral", + EventType::Dkim(DkimEvent::Fail) => "DKIM verification failed", + EventType::Dkim(DkimEvent::PermError) => "DKIM permanent error", + EventType::Dkim(DkimEvent::TempError) => "DKIM temporary error", + EventType::Dkim(DkimEvent::None) => "No DKIM signature", + EventType::Dkim(DkimEvent::UnsupportedVersion) => "Unsupported DKIM version", + EventType::Dkim(DkimEvent::UnsupportedAlgorithm) => "Unsupported DKIM algorithm", + EventType::Dkim(DkimEvent::UnsupportedCanonicalization) => { + "Unsupported DKIM canonicalization" + } + EventType::Dkim(DkimEvent::UnsupportedKeyType) => "Unsupported DKIM key type", + EventType::Dkim(DkimEvent::FailedBodyHashMatch) => "DKIM body hash mismatch", + EventType::Dkim(DkimEvent::FailedVerification) => "DKIM verification failed", + EventType::Dkim(DkimEvent::FailedAuidMatch) => "DKIM AUID mismatch", + EventType::Dkim(DkimEvent::RevokedPublicKey) => "DKIM public key revoked", + EventType::Dkim(DkimEvent::IncompatibleAlgorithms) => "Incompatible DKIM algorithms", + EventType::Dkim(DkimEvent::SignatureExpired) => "DKIM signature expired", + EventType::Dkim(DkimEvent::SignatureLength) => "DKIM signature length issue", + EventType::Dkim(DkimEvent::SignerNotFound) => "DKIM signer not found", + EventType::Dkim(DkimEvent::BuildError) => "DKIM build error", + EventType::Dkim(DkimEvent::SignatureCreated) => "DKIM signature created", + EventType::Dkim(DkimEvent::SignaturePublished) => "DKIM signature published", + EventType::Dkim(DkimEvent::SignatureRetiring) => "DKIM signature retiring", + EventType::Dkim(DkimEvent::SignatureRetired) => "DKIM signature retired", + EventType::Dkim(DkimEvent::SignatureDeleted) => "DKIM signature deleted", + EventType::Dkim(DkimEvent::InstanceMissing) => "DKIM2 message-instance missing", + EventType::Dkim(DkimEvent::InstanceSyntax) => "DKIM2 message-instance syntax error", + EventType::Dkim(DkimEvent::InstanceTagMissing) => "DKIM2 message-instance tag missing", + EventType::Dkim(DkimEvent::InstanceNotSigned) => "DKIM2 message-instance not signed", + EventType::Dkim(DkimEvent::InstanceAboveSignature) => { + "DKIM2 message-instance above signature" + } + EventType::Dkim(DkimEvent::SignatureMissing) => "DKIM2 signature missing", + EventType::Dkim(DkimEvent::SignatureSyntax) => "DKIM2 signature syntax error", + EventType::Dkim(DkimEvent::SignatureTagMissing) => "DKIM2 signature tag missing", + EventType::Dkim(DkimEvent::SignatureTagUnexpected) => "DKIM2 signature unexpected tag", + EventType::Dkim(DkimEvent::SequenceGap) => "DKIM2 sequence gap", + EventType::Dkim(DkimEvent::SequenceOverflow) => "DKIM2 sequence overflow", + EventType::Dkim(DkimEvent::ChainTooLong) => "DKIM2 chain too long", + EventType::Dkim(DkimEvent::MailFromMismatch) => "DKIM2 MAIL FROM mismatch", + EventType::Dkim(DkimEvent::RcptToMismatch) => "DKIM2 RCPT TO mismatch", + EventType::Dkim(DkimEvent::MailFromDomainMismatch) => "DKIM2 MAIL FROM domain mismatch", + EventType::Dkim(DkimEvent::NextDomainMismatch) => "DKIM2 next domain mismatch", + EventType::Dkim(DkimEvent::CustodyBreak) => "DKIM2 custody break", + EventType::Dkim(DkimEvent::PublicKeyFetch) => "DKIM2 public key fetch error", + EventType::Dkim(DkimEvent::PublicKeyMissing) => "DKIM2 public key missing", + EventType::Dkim(DkimEvent::PublicKeyMultiple) => "DKIM2 multiple public keys", + EventType::Dkim(DkimEvent::PublicKeySyntax) => "DKIM2 public key syntax error", + EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch) => { + "DKIM2 public key algorithm mismatch" + } + EventType::Dkim(DkimEvent::NoValidAlgorithm) => "DKIM2 no valid algorithm", + EventType::Dkim(DkimEvent::HeaderHashMismatch) => "DKIM2 header hash mismatch", + EventType::Dkim(DkimEvent::Modified) => "DKIM2 message modified", + EventType::Dkim(DkimEvent::Exploded) => "DKIM2 message exploded", + EventType::Dmarc(DmarcEvent::Pass) => "DMARC check passed", + EventType::Dmarc(DmarcEvent::Fail) => "DMARC check failed", + EventType::Dmarc(DmarcEvent::PermError) => "DMARC permanent error", + EventType::Dmarc(DmarcEvent::TempError) => "DMARC temporary error", + EventType::Dmarc(DmarcEvent::None) => "No DMARC record", + EventType::Dns(DnsEvent::RecordCreated) => "DNS record created", + EventType::Dns(DnsEvent::RecordCreationFailed) => "DNS record creation failed", + EventType::Dns(DnsEvent::RecordDeletionFailed) => "DNS record deletion failed", + EventType::Dns(DnsEvent::RecordNotPropagated) => "DNS record not propagated", + EventType::Dns(DnsEvent::RecordLookupFailed) => "DNS record lookup failed", + EventType::Dns(DnsEvent::RecordPropagated) => "DNS record propagated", + EventType::Dns(DnsEvent::RecordPropagationTimeout) => "DNS record propagation timeout", + EventType::Dns(DnsEvent::BuildError) => "DNS updater build error", + EventType::Eval(EvalEvent::Result) => "Expression evaluation result", + EventType::Eval(EvalEvent::Error) => "Expression evaluation error", + EventType::Eval(EvalEvent::DirectoryNotFound) => { + "Directory not found while evaluating expression" + } + EventType::Eval(EvalEvent::StoreNotFound) => { + "Store not found while evaluating expression" + } + EventType::Http(HttpEvent::ConnectionStart) => "HTTP connection started", + EventType::Http(HttpEvent::ConnectionEnd) => "HTTP connection ended", + EventType::Http(HttpEvent::Error) => "HTTP error occurred", + EventType::Http(HttpEvent::RequestUrl) => "HTTP request URL", + EventType::Http(HttpEvent::RequestBody) => "HTTP request body", + EventType::Http(HttpEvent::ResponseBody) => "HTTP response body", + EventType::Http(HttpEvent::XForwardedMissing) => "X-Forwarded-For header is missing", + EventType::Imap(ImapEvent::ConnectionStart) => "IMAP connection started", + EventType::Imap(ImapEvent::ConnectionEnd) => "IMAP connection ended", + EventType::Imap(ImapEvent::GetAcl) => "IMAP GET ACL command", + EventType::Imap(ImapEvent::SetAcl) => "IMAP SET ACL command", + EventType::Imap(ImapEvent::MyRights) => "IMAP MYRIGHTS command", + EventType::Imap(ImapEvent::ListRights) => "IMAP LISTRIGHTS command", + EventType::Imap(ImapEvent::Append) => "IMAP APPEND command", + EventType::Imap(ImapEvent::Capabilities) => "IMAP CAPABILITIES command", + EventType::Imap(ImapEvent::Id) => "IMAP ID command", + EventType::Imap(ImapEvent::Close) => "IMAP CLOSE command", + EventType::Imap(ImapEvent::Copy) => "IMAP COPY command", + EventType::Imap(ImapEvent::Move) => "IMAP MOVE command", + EventType::Imap(ImapEvent::CreateMailbox) => "IMAP CREATE mailbox command", + EventType::Imap(ImapEvent::DeleteMailbox) => "IMAP DELETE mailbox command", + EventType::Imap(ImapEvent::RenameMailbox) => "IMAP RENAME mailbox command", + EventType::Imap(ImapEvent::Enable) => "IMAP ENABLE command", + EventType::Imap(ImapEvent::Expunge) => "IMAP EXPUNGE command", + EventType::Imap(ImapEvent::Fetch) => "IMAP FETCH command", + EventType::Imap(ImapEvent::IdleStart) => "IMAP IDLE start", + EventType::Imap(ImapEvent::IdleStop) => "IMAP IDLE stop", + EventType::Imap(ImapEvent::List) => "IMAP LIST command", + EventType::Imap(ImapEvent::Lsub) => "IMAP LSUB command", + EventType::Imap(ImapEvent::Logout) => "IMAP LOGOUT command", + EventType::Imap(ImapEvent::Namespace) => "IMAP NAMESPACE command", + EventType::Imap(ImapEvent::Noop) => "IMAP NOOP command", + EventType::Imap(ImapEvent::Search) => "IMAP SEARCH command", + EventType::Imap(ImapEvent::Sort) => "IMAP SORT command", + EventType::Imap(ImapEvent::Select) => "IMAP SELECT command", + EventType::Imap(ImapEvent::Status) => "IMAP STATUS command", + EventType::Imap(ImapEvent::Store) => "IMAP STORE command", + EventType::Imap(ImapEvent::Subscribe) => "IMAP SUBSCRIBE command", + EventType::Imap(ImapEvent::Unsubscribe) => "IMAP UNSUBSCRIBE command", + EventType::Imap(ImapEvent::Thread) => "IMAP THREAD command", + EventType::Imap(ImapEvent::UidBatches) => "IMAP UIDBATCHES command", + EventType::Imap(ImapEvent::GetQuota) => "IMAP GETQUOTA command", + EventType::Imap(ImapEvent::Error) => "IMAP error occurred", + EventType::Imap(ImapEvent::RawInput) => "Raw IMAP input received", + EventType::Imap(ImapEvent::RawOutput) => "Raw IMAP output sent", + EventType::IncomingReport(IncomingReportEvent::DmarcReport) => "DMARC report received", + EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings) => { + "DMARC report received with warnings" + } + EventType::IncomingReport(IncomingReportEvent::TlsReport) => "TLS report received", + EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings) => { + "TLS report received with warnings" + } + EventType::IncomingReport(IncomingReportEvent::AbuseReport) => "Abuse report received", + EventType::IncomingReport(IncomingReportEvent::AuthFailureReport) => { + "Authentication failure report received" + } + EventType::IncomingReport(IncomingReportEvent::FraudReport) => "Fraud report received", + EventType::IncomingReport(IncomingReportEvent::NotSpamReport) => { + "Not spam report received" + } + EventType::IncomingReport(IncomingReportEvent::VirusReport) => "Virus report received", + EventType::IncomingReport(IncomingReportEvent::OtherReport) => { + "Other type of report received" + } + EventType::IncomingReport(IncomingReportEvent::MessageParseFailed) => { + "Failed to parse incoming report message" + } + EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed) => { + "Failed to parse DMARC report" + } + EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed) => { + "Failed to parse TLS RPC report" + } + EventType::IncomingReport(IncomingReportEvent::ArfParseFailed) => { + "Failed to parse ARF report" + } + EventType::IncomingReport(IncomingReportEvent::DecompressError) => { + "Error decompressing report" + } + EventType::Iprev(IprevEvent::Pass) => "IPREV check passed", + EventType::Iprev(IprevEvent::Fail) => "IPREV check failed", + EventType::Iprev(IprevEvent::PermError) => "IPREV permanent error", + EventType::Iprev(IprevEvent::TempError) => "IPREV temporary error", + EventType::Iprev(IprevEvent::None) => "No IPREV record", + EventType::Jmap(JmapEvent::MethodCall) => "JMAP method call", + EventType::Jmap(JmapEvent::InvalidArguments) => "Invalid JMAP arguments", + EventType::Jmap(JmapEvent::RequestTooLarge) => "JMAP request too large", + EventType::Jmap(JmapEvent::StateMismatch) => "JMAP state mismatch", + EventType::Jmap(JmapEvent::AnchorNotFound) => "JMAP anchor not found", + EventType::Jmap(JmapEvent::UnsupportedFilter) => "Unsupported JMAP filter", + EventType::Jmap(JmapEvent::UnsupportedSort) => "Unsupported JMAP sort", + EventType::Jmap(JmapEvent::UnknownMethod) => "Unknown JMAP method", + EventType::Jmap(JmapEvent::InvalidResultReference) => "Invalid JMAP result reference", + EventType::Jmap(JmapEvent::Forbidden) => "JMAP operation forbidden", + EventType::Jmap(JmapEvent::AccountNotFound) => "JMAP account not found", + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => { + "JMAP account not supported by method" + } + EventType::Jmap(JmapEvent::AccountReadOnly) => "JMAP account is read-only", + EventType::Jmap(JmapEvent::NotFound) => "JMAP resource not found", + EventType::Jmap(JmapEvent::CannotCalculateChanges) => "Cannot calculate JMAP changes", + EventType::Jmap(JmapEvent::UnknownDataType) => "Unknown JMAP data type", + EventType::Jmap(JmapEvent::UnknownCapability) => "Unknown JMAP capability", + EventType::Jmap(JmapEvent::NotJson) => "JMAP request is not JSON", + EventType::Jmap(JmapEvent::NotRequest) => "JMAP input is not a request", + EventType::Jmap(JmapEvent::WebsocketStart) => "JMAP WebSocket connection started", + EventType::Jmap(JmapEvent::WebsocketStop) => "JMAP WebSocket connection stopped", + EventType::Jmap(JmapEvent::WebsocketError) => "JMAP WebSocket error", + EventType::Limit(LimitEvent::SizeRequest) => "Request size limit reached", + EventType::Limit(LimitEvent::SizeUpload) => "Upload size limit reached", + EventType::Limit(LimitEvent::CallsIn) => "Incoming calls limit reached", + EventType::Limit(LimitEvent::ConcurrentRequest) => "Concurrent request limit reached", + EventType::Limit(LimitEvent::ConcurrentUpload) => "Concurrent upload limit reached", + EventType::Limit(LimitEvent::ConcurrentConnection) => { + "Concurrent connection limit reached" + } + EventType::Limit(LimitEvent::Quota) => "Quota limit reached", + EventType::Limit(LimitEvent::BlobQuota) => "Blob quota limit reached", + EventType::Limit(LimitEvent::TenantQuota) => "Tenant quota limit reached", + EventType::Limit(LimitEvent::TooManyRequests) => "Too many requests", + EventType::MailAuth(MailAuthEvent::ParseError) => "Mail authentication parse error", + EventType::MailAuth(MailAuthEvent::MissingParameters) => { + "Missing mail authentication parameters" + } + EventType::MailAuth(MailAuthEvent::NoHeadersFound) => "No headers found in message", + EventType::MailAuth(MailAuthEvent::Crypto) => "Crypto error during mail authentication", + EventType::MailAuth(MailAuthEvent::Io) => "I/O error during mail authentication", + EventType::MailAuth(MailAuthEvent::Base64) => "Base64 error during mail authentication", + EventType::MailAuth(MailAuthEvent::DnsError) => "DNS error", + EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) => "DNS record not found", + EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType) => "Invalid DNS record type", + EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => "Policy not aligned", + EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { + "ManageSieve connection started" + } + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => { + "ManageSieve connection ended" + } + EventType::ManageSieve(ManageSieveEvent::CreateScript) => { + "ManageSieve CREATE script command" + } + EventType::ManageSieve(ManageSieveEvent::UpdateScript) => { + "ManageSieve UPDATE script command" + } + EventType::ManageSieve(ManageSieveEvent::GetScript) => "ManageSieve GET script command", + EventType::ManageSieve(ManageSieveEvent::DeleteScript) => { + "ManageSieve DELETE script command" + } + EventType::ManageSieve(ManageSieveEvent::RenameScript) => { + "ManageSieve RENAME script command" + } + EventType::ManageSieve(ManageSieveEvent::CheckScript) => { + "ManageSieve CHECK script command" + } + EventType::ManageSieve(ManageSieveEvent::HaveSpace) => "ManageSieve HAVESPACE command", + EventType::ManageSieve(ManageSieveEvent::ListScripts) => { + "ManageSieve LIST scripts command" + } + EventType::ManageSieve(ManageSieveEvent::SetActive) => "ManageSieve SET ACTIVE command", + EventType::ManageSieve(ManageSieveEvent::Capabilities) => { + "ManageSieve CAPABILITIES command" + } + EventType::ManageSieve(ManageSieveEvent::StartTls) => "ManageSieve STARTTLS command", + EventType::ManageSieve(ManageSieveEvent::Unauthenticate) => { + "ManageSieve UNAUTHENTICATE command" + } + EventType::ManageSieve(ManageSieveEvent::Logout) => "ManageSieve LOGOUT command", + EventType::ManageSieve(ManageSieveEvent::Noop) => "ManageSieve NOOP command", + EventType::ManageSieve(ManageSieveEvent::Error) => "ManageSieve error occurred", + EventType::ManageSieve(ManageSieveEvent::RawInput) => "Raw ManageSieve input received", + EventType::ManageSieve(ManageSieveEvent::RawOutput) => "Raw ManageSieve output sent", + EventType::MessageIngest(MessageIngestEvent::Ham) => "Message ingested", + EventType::MessageIngest(MessageIngestEvent::Spam) => "Possible spam message ingested", + EventType::MessageIngest(MessageIngestEvent::ImapAppend) => "Message appended via IMAP", + EventType::MessageIngest(MessageIngestEvent::JmapAppend) => "Message appended via JMAP", + EventType::MessageIngest(MessageIngestEvent::Duplicate) => "Skipping duplicate message", + EventType::MessageIngest(MessageIngestEvent::Error) => "Message ingestion error", + EventType::MessageIngest(MessageIngestEvent::SearchIndex) => "Search index updated", + EventType::Milter(MilterEvent::Read) => "Reading from Milter", + EventType::Milter(MilterEvent::Write) => "Writing to Milter", + EventType::Milter(MilterEvent::ActionAccept) => "Milter action: Accept", + EventType::Milter(MilterEvent::ActionDiscard) => "Milter action: Discard", + EventType::Milter(MilterEvent::ActionReject) => "Milter action: Reject", + EventType::Milter(MilterEvent::ActionTempFail) => "Milter action: Temporary failure", + EventType::Milter(MilterEvent::ActionReplyCode) => "Milter action: Reply code", + EventType::Milter(MilterEvent::ActionConnectionFailure) => { + "Milter action: Connection failure" + } + EventType::Milter(MilterEvent::ActionShutdown) => "Milter action: Shutdown", + EventType::Milter(MilterEvent::IoError) => "Milter I/O error", + EventType::Milter(MilterEvent::FrameTooLarge) => "Milter frame too large", + EventType::Milter(MilterEvent::FrameInvalid) => "Invalid Milter frame", + EventType::Milter(MilterEvent::UnexpectedResponse) => "Unexpected Milter response", + EventType::Milter(MilterEvent::Timeout) => "Milter timeout", + EventType::Milter(MilterEvent::TlsInvalidName) => "Invalid TLS name for Milter", + EventType::Milter(MilterEvent::Disconnected) => "Milter disconnected", + EventType::Milter(MilterEvent::ParseError) => "Milter parse error", + EventType::MtaHook(MtaHookEvent::ActionAccept) => "MTA hook action: Accept", + EventType::MtaHook(MtaHookEvent::ActionDiscard) => "MTA hook action: Discard", + EventType::MtaHook(MtaHookEvent::ActionReject) => "MTA hook action: Reject", + EventType::MtaHook(MtaHookEvent::ActionQuarantine) => "MTA hook action: Quarantine", + EventType::MtaHook(MtaHookEvent::Error) => "MTA hook error", + EventType::MtaSts(MtaStsEvent::Authorized) => "Host authorized by MTA-STS policy", + EventType::MtaSts(MtaStsEvent::NotAuthorized) => { + "Host not authorized by MTA-STS policy" + } + EventType::MtaSts(MtaStsEvent::PolicyFetch) => "Fetched MTA-STS policy", + EventType::MtaSts(MtaStsEvent::PolicyNotFound) => "MTA-STS policy not found", + EventType::MtaSts(MtaStsEvent::PolicyFetchError) => "Error fetching MTA-STS policy", + EventType::MtaSts(MtaStsEvent::InvalidPolicy) => "Invalid MTA-STS policy", + EventType::Network(NetworkEvent::ListenStart) => "Network listener started", + EventType::Network(NetworkEvent::ListenStop) => "Network listener stopped", + EventType::Network(NetworkEvent::ListenError) => "Network listener error", + EventType::Network(NetworkEvent::BindError) => "Network bind error", + EventType::Network(NetworkEvent::ReadError) => "Network read error", + EventType::Network(NetworkEvent::WriteError) => "Network write error", + EventType::Network(NetworkEvent::FlushError) => "Network flush error", + EventType::Network(NetworkEvent::AcceptError) => "Network accept error", + EventType::Network(NetworkEvent::SplitError) => "Network split error", + EventType::Network(NetworkEvent::Timeout) => "Network timeout", + EventType::Network(NetworkEvent::Closed) => "Network connection closed", + EventType::Network(NetworkEvent::ProxyError) => "Proxy protocol error", + EventType::Network(NetworkEvent::SetOptError) => "Network set option error", + EventType::OutgoingReport(OutgoingReportEvent::SpfReport) => "SPF report sent", + EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited) => { + "SPF report rate limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DkimReport) => "DKIM report sent", + EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited) => { + "DKIM report rate limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DmarcReport) => "DMARC report sent", + EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited) => { + "DMARC report rate limited" + } + EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport) => { + "DMARC aggregate is being prepared" + } + EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate) => { + "TLS aggregate report is being prepared" + } + EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission) => { + "Report submitted via HTTP" + } + EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress) => { + "Unauthorized reporting address" + } + EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError) => { + "Error validating reporting address" + } + EventType::OutgoingReport(OutgoingReportEvent::NotFound) => "Report not found", + EventType::OutgoingReport(OutgoingReportEvent::SubmissionError) => { + "Error submitting report" + } + EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound) => { + "No recipients found for report" + } + EventType::OutgoingReport(OutgoingReportEvent::Locked) => { + "Report is locked by another process" + } + EventType::OutgoingReport(OutgoingReportEvent::MaxSizeExceeded) => { + "Report size exceeds maximum" + } + EventType::Pop3(Pop3Event::ConnectionStart) => "POP3 connection started", + EventType::Pop3(Pop3Event::ConnectionEnd) => "POP3 connection ended", + EventType::Pop3(Pop3Event::Delete) => "POP3 DELETE command", + EventType::Pop3(Pop3Event::Reset) => "POP3 RESET command", + EventType::Pop3(Pop3Event::Quit) => "POP3 QUIT command", + EventType::Pop3(Pop3Event::Fetch) => "POP3 FETCH command", + EventType::Pop3(Pop3Event::List) => "POP3 LIST command", + EventType::Pop3(Pop3Event::ListMessage) => "POP3 LIST specific message command", + EventType::Pop3(Pop3Event::Uidl) => "POP3 UIDL command", + EventType::Pop3(Pop3Event::UidlMessage) => "POP3 UIDL specific message command", + EventType::Pop3(Pop3Event::Stat) => "POP3 STAT command", + EventType::Pop3(Pop3Event::Noop) => "POP3 NOOP command", + EventType::Pop3(Pop3Event::Capabilities) => "POP3 CAPABILITIES command", + EventType::Pop3(Pop3Event::StartTls) => "POP3 STARTTLS command", + EventType::Pop3(Pop3Event::Utf8) => "POP3 UTF8 command", + EventType::Pop3(Pop3Event::Error) => "POP3 error occurred", + EventType::Pop3(Pop3Event::RawInput) => "Raw POP3 input received", + EventType::Pop3(Pop3Event::RawOutput) => "Raw POP3 output sent", + EventType::PushSubscription(PushSubscriptionEvent::Success) => { + "Push subscription successful" + } + EventType::PushSubscription(PushSubscriptionEvent::Error) => "Push subscription error", + EventType::PushSubscription(PushSubscriptionEvent::NotFound) => { + "Push subscription not found" + } + EventType::Queue(QueueEvent::Started) => "MTA queue started", + EventType::Queue(QueueEvent::MessageQueued) => "Queued message for delivery", + EventType::Queue(QueueEvent::AuthenticatedMessageQueued) => { + "Queued message submission for delivery" + } + EventType::Queue(QueueEvent::ReportQueued) => "Queued report for delivery", + EventType::Queue(QueueEvent::DsnQueued) => "Queued DSN for delivery", + EventType::Queue(QueueEvent::AutogeneratedQueued) => { + "Queued autogenerated message for delivery" + } + EventType::Queue(QueueEvent::Rescheduled) => "Message rescheduled for delivery", + EventType::Queue(QueueEvent::Locked) => "Queue event is locked by another process", + EventType::Queue(QueueEvent::BlobNotFound) => "Message blob not found", + EventType::Queue(QueueEvent::RateLimitExceeded) => "Rate limit exceeded", + EventType::Queue(QueueEvent::ConcurrencyLimitExceeded) => "Concurrency limit exceeded", + EventType::Queue(QueueEvent::QuotaExceeded) => "Quota exceeded", + EventType::Queue(QueueEvent::BackPressure) => "Queue backpressure detected", + EventType::Registry(RegistryEvent::LocalReadError) => "Local registry read error", + EventType::Registry(RegistryEvent::LocalWriteError) => "Local registry write error", + EventType::Registry(RegistryEvent::LocalParseError) => "Local registry parse error", + EventType::Registry(RegistryEvent::ReadError) => "Registry read error", + EventType::Registry(RegistryEvent::WriteError) => "Registry write error", + EventType::Registry(RegistryEvent::DeserializationError) => { + "Registry deserialization error" + } + EventType::Registry(RegistryEvent::BuildError) => "Configuration build error", + EventType::Registry(RegistryEvent::BuildWarning) => "Configuration build warning", + EventType::Registry(RegistryEvent::NotSupported) => { + "Operation not supported by local registry" + } + EventType::Registry(RegistryEvent::ValidationError) => "Object validation error", + EventType::Resource(ResourceEvent::NotFound) => "Resource not found", + EventType::Resource(ResourceEvent::BadParameters) => "Bad resource parameters", + EventType::Resource(ResourceEvent::Error) => "Resource error", + EventType::Resource(ResourceEvent::DownloadExternal) => "Downloading external resource", + EventType::Resource(ResourceEvent::ApplicationUpdated) => { + "Application resource updated" + } + EventType::Resource(ResourceEvent::ApplicationUnpacked) => { + "Application resource unpacked" + } + EventType::Security(SecurityEvent::AuthenticationBan) => { + "Banned due to authentication errors" + } + EventType::Security(SecurityEvent::AbuseBan) => "Banned due to abuse", + EventType::Security(SecurityEvent::ScanBan) => "Banned due to scan", + EventType::Security(SecurityEvent::LoiterBan) => "Banned due to loitering", + EventType::Security(SecurityEvent::IpBlocked) => "Blocked IP address", + EventType::Security(SecurityEvent::IpBlockExpired) => "IP block expired", + EventType::Security(SecurityEvent::IpAllowExpired) => "IP allow expired", + EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", + EventType::Security(SecurityEvent::Unauthorized) => "Unauthorized access", + EventType::Server(ServerEvent::Startup) => "Starting Stalwart Server", + EventType::Server(ServerEvent::Shutdown) => "Shutting down Stalwart Server", + EventType::Server(ServerEvent::StartupError) => "Server startup error", + EventType::Server(ServerEvent::ThreadError) => "Server thread error", + EventType::Server(ServerEvent::Licensing) => "Server licensing event", + EventType::Server(ServerEvent::RecoveryMode) => "Server started in recovery mode", + EventType::Server(ServerEvent::BootstrapMode) => "Server started in bootstrap mode", + EventType::Sieve(SieveEvent::ActionAccept) => "Sieve action: Accept", + EventType::Sieve(SieveEvent::ActionAcceptReplace) => "Sieve action: Accept and replace", + EventType::Sieve(SieveEvent::ActionDiscard) => "Sieve action: Discard", + EventType::Sieve(SieveEvent::ActionReject) => "Sieve action: Reject", + EventType::Sieve(SieveEvent::SendMessage) => "Sieve sending message", + EventType::Sieve(SieveEvent::MessageTooLarge) => "Sieve message too large", + EventType::Sieve(SieveEvent::ScriptNotFound) => "Sieve script not found", + EventType::Sieve(SieveEvent::ListNotFound) => "Sieve list not found", + EventType::Sieve(SieveEvent::RuntimeError) => "Sieve runtime error", + EventType::Sieve(SieveEvent::UnexpectedError) => "Unexpected Sieve error", + EventType::Sieve(SieveEvent::NotSupported) => "Sieve action not supported", + EventType::Sieve(SieveEvent::QuotaExceeded) => "Sieve quota exceeded", + EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP connection started", + EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP connection ended", + EventType::Smtp(SmtpEvent::Error) => "SMTP error occurred", + EventType::Smtp(SmtpEvent::IdNotFound) => "Strategy not found", + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded) => "Concurrency limit exceeded", + EventType::Smtp(SmtpEvent::TransferLimitExceeded) => "Transfer limit exceeded", + EventType::Smtp(SmtpEvent::RateLimitExceeded) => "Rate limit exceeded", + EventType::Smtp(SmtpEvent::TimeLimitExceeded) => "Time limit exceeded", + EventType::Smtp(SmtpEvent::MissingAuthDirectory) => "Missing auth directory", + EventType::Smtp(SmtpEvent::MessageParseFailed) => "Message parsing failed", + EventType::Smtp(SmtpEvent::MessageTooLarge) => "Message too large", + EventType::Smtp(SmtpEvent::LoopDetected) => "Mail loop detected", + EventType::Smtp(SmtpEvent::DkimPass) => "DKIM verification passed", + EventType::Smtp(SmtpEvent::DkimFail) => "DKIM verification failed", + EventType::Smtp(SmtpEvent::Dkim2Pass) => "DKIM2 verification passed", + EventType::Smtp(SmtpEvent::Dkim2Fail) => "DKIM2 verification failed", + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded) => "DKIM2 DSN discarded", + EventType::Smtp(SmtpEvent::ArcPass) => "ARC verification passed", + EventType::Smtp(SmtpEvent::ArcFail) => "ARC verification failed", + EventType::Smtp(SmtpEvent::SpfEhloPass) => "SPF EHLO check passed", + EventType::Smtp(SmtpEvent::SpfEhloFail) => "SPF EHLO check failed", + EventType::Smtp(SmtpEvent::SpfFromPass) => "SPF From check passed", + EventType::Smtp(SmtpEvent::SpfFromFail) => "SPF From check failed", + EventType::Smtp(SmtpEvent::DmarcPass) => "DMARC check passed", + EventType::Smtp(SmtpEvent::DmarcFail) => "DMARC check failed", + EventType::Smtp(SmtpEvent::IprevPass) => "IPREV check passed", + EventType::Smtp(SmtpEvent::IprevFail) => "IPREV check failed", + EventType::Smtp(SmtpEvent::TooManyMessages) => "Too many messages", + EventType::Smtp(SmtpEvent::Ehlo) => "SMTP EHLO command", + EventType::Smtp(SmtpEvent::InvalidEhlo) => "Invalid EHLO command", + EventType::Smtp(SmtpEvent::DidNotSayEhlo) => "Client did not say EHLO", + EventType::Smtp(SmtpEvent::EhloExpected) => "EHLO command expected", + EventType::Smtp(SmtpEvent::LhloExpected) => "LHLO command expected", + EventType::Smtp(SmtpEvent::MailFromUnauthenticated) => { + "MAIL FROM without authentication" + } + EventType::Smtp(SmtpEvent::MailFromUnauthorized) => "MAIL FROM unauthorized", + EventType::Smtp(SmtpEvent::MailFromNotAllowed) => "MAIL FROM not allowed", + EventType::Smtp(SmtpEvent::MailFromRewritten) => "MAIL FROM address rewritten", + EventType::Smtp(SmtpEvent::MailFromMissing) => "MAIL FROM address missing", + EventType::Smtp(SmtpEvent::MailFrom) => "SMTP MAIL FROM command", + EventType::Smtp(SmtpEvent::MultipleMailFrom) => "Multiple MAIL FROM commands", + EventType::Smtp(SmtpEvent::MailboxDoesNotExist) => "Mailbox does not exist", + EventType::Smtp(SmtpEvent::RelayNotAllowed) => "Relay not allowed", + EventType::Smtp(SmtpEvent::RcptTo) => "SMTP RCPT TO command", + EventType::Smtp(SmtpEvent::RcptToDuplicate) => "Duplicate RCPT TO", + EventType::Smtp(SmtpEvent::RcptToRewritten) => "RCPT TO address rewritten", + EventType::Smtp(SmtpEvent::RcptToMissing) => "RCPT TO address missing", + EventType::Smtp(SmtpEvent::RcptToGreylisted) => "RCPT TO greylisted", + EventType::Smtp(SmtpEvent::TooManyRecipients) => "Too many recipients", + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt) => "Too many invalid recipients", + EventType::Smtp(SmtpEvent::RawInput) => "Raw SMTP input received", + EventType::Smtp(SmtpEvent::RawOutput) => "Raw SMTP output sent", + EventType::Smtp(SmtpEvent::MissingLocalHostname) => "Missing local hostname", + EventType::Smtp(SmtpEvent::Vrfy) => "SMTP VRFY command", + EventType::Smtp(SmtpEvent::VrfyNotFound) => "VRFY address not found", + EventType::Smtp(SmtpEvent::VrfyDisabled) => "VRFY command disabled", + EventType::Smtp(SmtpEvent::Expn) => "SMTP EXPN command", + EventType::Smtp(SmtpEvent::ExpnNotFound) => "EXPN address not found", + EventType::Smtp(SmtpEvent::ExpnDisabled) => "EXPN command disabled", + EventType::Smtp(SmtpEvent::RequireTlsDisabled) => "REQUIRETLS extension disabled", + EventType::Smtp(SmtpEvent::DeliverByDisabled) => "DELIVERBY extension disabled", + EventType::Smtp(SmtpEvent::DeliverByInvalid) => "Invalid DELIVERBY parameter", + EventType::Smtp(SmtpEvent::FutureReleaseDisabled) => { + "FUTURE RELEASE extension disabled" + } + EventType::Smtp(SmtpEvent::FutureReleaseInvalid) => "Invalid FUTURE RELEASE parameter", + EventType::Smtp(SmtpEvent::MtPriorityDisabled) => "MT-PRIORITY extension disabled", + EventType::Smtp(SmtpEvent::MtPriorityInvalid) => "Invalid MT-PRIORITY parameter", + EventType::Smtp(SmtpEvent::DsnDisabled) => "DSN extension disabled", + EventType::Smtp(SmtpEvent::AuthNotAllowed) => "Authentication not allowed", + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported) => "Auth mechanism not supported", + EventType::Smtp(SmtpEvent::AuthExchangeTooLong) => "Auth exchange too long", + EventType::Smtp(SmtpEvent::AlreadyAuthenticated) => "Already authenticated", + EventType::Smtp(SmtpEvent::Noop) => "SMTP NOOP command", + EventType::Smtp(SmtpEvent::StartTls) => "SMTP STARTTLS command", + EventType::Smtp(SmtpEvent::StartTlsUnavailable) => "STARTTLS unavailable", + EventType::Smtp(SmtpEvent::StartTlsAlready) => "TLS already active", + EventType::Smtp(SmtpEvent::Rset) => "SMTP RSET command", + EventType::Smtp(SmtpEvent::Quit) => "SMTP QUIT command", + EventType::Smtp(SmtpEvent::Help) => "SMTP HELP command", + EventType::Smtp(SmtpEvent::CommandNotImplemented) => "Command not implemented", + EventType::Smtp(SmtpEvent::InvalidCommand) => "Invalid command", + EventType::Smtp(SmtpEvent::InvalidSenderAddress) => "Invalid sender address", + EventType::Smtp(SmtpEvent::InvalidRecipientAddress) => "Invalid recipient address", + EventType::Smtp(SmtpEvent::InvalidParameter) => "Invalid parameter", + EventType::Smtp(SmtpEvent::UnsupportedParameter) => "Unsupported parameter", + EventType::Smtp(SmtpEvent::SyntaxError) => "Syntax error", + EventType::Smtp(SmtpEvent::RequestTooLarge) => "Request too large", + EventType::Spam(SpamEvent::Pyzor) => "Pyzor success", + EventType::Spam(SpamEvent::PyzorError) => "Pyzor error", + EventType::Spam(SpamEvent::Dnsbl) => "DNSBL query", + EventType::Spam(SpamEvent::DnsblError) => "Error querying DNSBL", + EventType::Spam(SpamEvent::TrainStarted) => "Spam classifier training started", + EventType::Spam(SpamEvent::TrainCompleted) => "Spam classifier training completed", + EventType::Spam(SpamEvent::TrainSampleAdded) => "New training sample added", + EventType::Spam(SpamEvent::TrainSampleNotFound) => "Training sample not found", + EventType::Spam(SpamEvent::Classify) => "Classifying message for spam", + EventType::Spam(SpamEvent::ModelLoaded) => "Spam classifier model loaded", + EventType::Spam(SpamEvent::ModelNotReady) => "Spam classifier model not ready", + EventType::Spam(SpamEvent::ModelNotFound) => "Spam classifier model not found", + EventType::Spam(SpamEvent::RulesUpdated) => "Spam filter rules updated", + EventType::Spf(SpfEvent::Pass) => "SPF check passed", + EventType::Spf(SpfEvent::Fail) => "SPF check failed", + EventType::Spf(SpfEvent::SoftFail) => "SPF soft fail", + EventType::Spf(SpfEvent::Neutral) => "SPF neutral result", + EventType::Spf(SpfEvent::TempError) => "SPF temporary error", + EventType::Spf(SpfEvent::PermError) => "SPF permanent error", + EventType::Spf(SpfEvent::None) => "No SPF record", + EventType::Store(StoreEvent::AssertValueFailed) => { + "Another process modified the record" + } + EventType::Store(StoreEvent::FoundationdbError) => "FoundationDB error", + EventType::Store(StoreEvent::MysqlError) => "MySQL error", + EventType::Store(StoreEvent::PostgresqlError) => "PostgreSQL error", + EventType::Store(StoreEvent::RocksdbError) => "RocksDB error", + EventType::Store(StoreEvent::SqliteError) => "SQLite error", + EventType::Store(StoreEvent::LdapError) => "LDAP error", + EventType::Store(StoreEvent::ElasticsearchError) => "ElasticSearch error", + EventType::Store(StoreEvent::MeilisearchError) => "Meilisearch error", + EventType::Store(StoreEvent::RedisError) => "Redis error", + EventType::Store(StoreEvent::S3Error) => "S3 error", + EventType::Store(StoreEvent::AzureError) => "Azure error", + EventType::Store(StoreEvent::FilesystemError) => "Filesystem error", + EventType::Store(StoreEvent::PoolError) => "Connection pool error", + EventType::Store(StoreEvent::DataCorruption) => "Data corruption detected", + EventType::Store(StoreEvent::DecompressError) => "Decompression error", + EventType::Store(StoreEvent::DeserializeError) => "Deserialization error", + EventType::Store(StoreEvent::NotFound) => "Record not found in database", + EventType::Store(StoreEvent::NotConfigured) => "Store not configured", + EventType::Store(StoreEvent::NotSupported) => "Operation not supported by store", + EventType::Store(StoreEvent::UnexpectedError) => "Unexpected store error", + EventType::Store(StoreEvent::CryptoError) => "Store crypto error", + EventType::Store(StoreEvent::HttpStoreError) => "Error updating HTTP store", + EventType::Store(StoreEvent::CacheMiss) => "Cache miss", + EventType::Store(StoreEvent::CacheHit) => "Cache hit", + EventType::Store(StoreEvent::CacheStale) => "Cache is stale", + EventType::Store(StoreEvent::CacheUpdate) => "Cache update", + EventType::Store(StoreEvent::CacheEntryTooLarge) => "Cache entry too large", + EventType::Store(StoreEvent::BlobMissingMarker) => "Blob missing marker", + EventType::Store(StoreEvent::DataWrite) => "Write batch operation", + EventType::Store(StoreEvent::DataIterate) => "Data store iteration operation", + EventType::Store(StoreEvent::BlobRead) => "Blob read operation", + EventType::Store(StoreEvent::BlobWrite) => "Blob write operation", + EventType::Store(StoreEvent::BlobDelete) => "Blob delete operation", + EventType::Store(StoreEvent::SqlQuery) => "SQL query executed", + EventType::Store(StoreEvent::LdapQuery) => "LDAP query executed", + EventType::Store(StoreEvent::LdapWarning) => "LDAP authentication warning", + EventType::Store(StoreEvent::HttpStoreFetch) => "HTTP store updated", + EventType::Store(StoreEvent::AutoExpunge) => "Auto-expunge executed", + EventType::Store(StoreEvent::BlobStorePurged) => "Blob store purge completed", + EventType::Store(StoreEvent::DataStorePurged) => "Data store purge completed", + EventType::TaskManager(TaskManagerEvent::TaskAcquired) => "Task acquired from queue", + EventType::TaskManager(TaskManagerEvent::TaskQueued) => "Task queued for processing", + EventType::TaskManager(TaskManagerEvent::TaskScheduled) => { + "Task scheduled for future execution" + } + EventType::TaskManager(TaskManagerEvent::TaskLocked) => { + "Task is locked by another process" + } + EventType::TaskManager(TaskManagerEvent::TaskIgnored) => { + "Task ignored based on current server roles" + } + EventType::TaskManager(TaskManagerEvent::TaskFailed) => "Task failed during processing", + EventType::TaskManager(TaskManagerEvent::TaskRetry) => "Task will be retried", + EventType::TaskManager(TaskManagerEvent::BlobNotFound) => "Blob not found for task", + EventType::TaskManager(TaskManagerEvent::MetadataNotFound) => { + "Metadata not found for task" + } + EventType::TaskManager(TaskManagerEvent::SchedulerStarted) => "Task scheduler started", + EventType::TaskManager(TaskManagerEvent::ManagerStarted) => "Task manager started", + EventType::Telemetry(TelemetryEvent::AlertEvent) => "Alert event triggered", + EventType::Telemetry(TelemetryEvent::AlertMessage) => "Alert message sent", + EventType::Telemetry(TelemetryEvent::LogError) => "Log collector error", + EventType::Telemetry(TelemetryEvent::WebhookError) => "Webhook collector error", + EventType::Telemetry(TelemetryEvent::OtelExporterError) => { + "OpenTelemetry exporter error" + } + EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError) => { + "OpenTelemetry metrics exporter error" + } + EventType::Telemetry(TelemetryEvent::PrometheusExporterError) => { + "Prometheus exporter error" + } + EventType::Telemetry(TelemetryEvent::JournalError) => "Journal collector error", + EventType::Telemetry(TelemetryEvent::MetricsCollected) => "Metrics collected", + EventType::Telemetry(TelemetryEvent::MetricsStored) => "Metric store", + EventType::Telemetry(TelemetryEvent::MetricsPushed) => "Metrics pushed", + EventType::Tls(TlsEvent::Handshake) => "TLS handshake", + EventType::Tls(TlsEvent::HandshakeError) => "TLS handshake error", + EventType::Tls(TlsEvent::NotConfigured) => "TLS not configured", + EventType::Tls(TlsEvent::CertificateNotFound) => "TLS certificate not found", + EventType::Tls(TlsEvent::NoCertificatesAvailable) => "No TLS certificates available", + EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => { + "Multiple TLS certificates available" + } + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => { + "Certificate expired and removed" + } + EventType::TlsRpt(TlsRptEvent::RecordFetch) => "Fetched TLS-RPT record", + EventType::TlsRpt(TlsRptEvent::RecordFetchError) => "Error fetching TLS-RPT record", + EventType::TlsRpt(TlsRptEvent::RecordNotFound) => "TLS-RPT record not found", + EventType::WebDav(WebDavEvent::Propfind) => "WebDAV PROPFIND request", + EventType::WebDav(WebDavEvent::Proppatch) => "WebDAV PROPPATCH request", + EventType::WebDav(WebDavEvent::Get) => "WebDAV GET request", + EventType::WebDav(WebDavEvent::Head) => "WebDAV HEAD request", + EventType::WebDav(WebDavEvent::Report) => "WebDAV REPORT request", + EventType::WebDav(WebDavEvent::Mkcol) => "WebDAV MKCOL request", + EventType::WebDav(WebDavEvent::Mkcalendar) => "WebDAV MKCALENDAR request", + EventType::WebDav(WebDavEvent::Delete) => "WebDAV DELETE request", + EventType::WebDav(WebDavEvent::Put) => "WebDAV PUT request", + EventType::WebDav(WebDavEvent::Post) => "WebDAV POST request", + EventType::WebDav(WebDavEvent::Patch) => "WebDAV PATCH request", + EventType::WebDav(WebDavEvent::Copy) => "WebDAV COPY request", + EventType::WebDav(WebDavEvent::Move) => "WebDAV MOVE request", + EventType::WebDav(WebDavEvent::Lock) => "WebDAV LOCK request", + EventType::WebDav(WebDavEvent::Unlock) => "WebDAV UNLOCK request", + EventType::WebDav(WebDavEvent::Acl) => "WebDAV ACL request", + EventType::WebDav(WebDavEvent::Options) => "WebDAV OPTIONS request", + EventType::WebDav(WebDavEvent::Error) => "WebDAV error", + } + } + + pub fn message(&self) -> &'static str { + match self { + EventType::Auth(AuthEvent::Success) => "Authentication error", + EventType::Auth(AuthEvent::Failed) => "Authentication failed", + EventType::Auth(AuthEvent::TokenExpired) => "Authentication error", + EventType::Auth(AuthEvent::MfaRequired) => { + "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one." + } + EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", + EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", + EventType::Auth(AuthEvent::Error) => "Authentication error", + EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", + EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", + EventType::Imap(ImapEvent::ConnectionEnd) => "IMAP error", + EventType::Imap(ImapEvent::GetAcl) => "IMAP error", + EventType::Imap(ImapEvent::SetAcl) => "IMAP error", + EventType::Imap(ImapEvent::MyRights) => "IMAP error", + EventType::Imap(ImapEvent::ListRights) => "IMAP error", + EventType::Imap(ImapEvent::Append) => "IMAP error", + EventType::Imap(ImapEvent::Capabilities) => "IMAP error", + EventType::Imap(ImapEvent::Id) => "IMAP error", + EventType::Imap(ImapEvent::Close) => "IMAP error", + EventType::Imap(ImapEvent::Copy) => "IMAP error", + EventType::Imap(ImapEvent::Move) => "IMAP error", + EventType::Imap(ImapEvent::CreateMailbox) => "IMAP error", + EventType::Imap(ImapEvent::DeleteMailbox) => "IMAP error", + EventType::Imap(ImapEvent::RenameMailbox) => "IMAP error", + EventType::Imap(ImapEvent::Enable) => "IMAP error", + EventType::Imap(ImapEvent::Expunge) => "IMAP error", + EventType::Imap(ImapEvent::Fetch) => "IMAP error", + EventType::Imap(ImapEvent::IdleStart) => "IMAP error", + EventType::Imap(ImapEvent::IdleStop) => "IMAP error", + EventType::Imap(ImapEvent::List) => "IMAP error", + EventType::Imap(ImapEvent::Lsub) => "IMAP error", + EventType::Imap(ImapEvent::Logout) => "IMAP error", + EventType::Imap(ImapEvent::Namespace) => "IMAP error", + EventType::Imap(ImapEvent::Noop) => "IMAP error", + EventType::Imap(ImapEvent::Search) => "IMAP error", + EventType::Imap(ImapEvent::Sort) => "IMAP error", + EventType::Imap(ImapEvent::Select) => "IMAP error", + EventType::Imap(ImapEvent::Status) => "IMAP error", + EventType::Imap(ImapEvent::Store) => "IMAP error", + EventType::Imap(ImapEvent::Subscribe) => "IMAP error", + EventType::Imap(ImapEvent::Unsubscribe) => "IMAP error", + EventType::Imap(ImapEvent::Thread) => "IMAP error", + EventType::Imap(ImapEvent::UidBatches) => "IMAP error", + EventType::Imap(ImapEvent::GetQuota) => "IMAP error", + EventType::Imap(ImapEvent::Error) => "IMAP error", + EventType::Imap(ImapEvent::RawInput) => "IMAP error", + EventType::Imap(ImapEvent::RawOutput) => "IMAP error", + EventType::Jmap(JmapEvent::MethodCall) => "Other message", + EventType::Jmap(JmapEvent::InvalidArguments) => "Invalid arguments", + EventType::Jmap(JmapEvent::RequestTooLarge) => "Request too large", + EventType::Jmap(JmapEvent::StateMismatch) => "State mismatch", + EventType::Jmap(JmapEvent::AnchorNotFound) => "Anchor not found", + EventType::Jmap(JmapEvent::UnsupportedFilter) => "Unsupported filter", + EventType::Jmap(JmapEvent::UnsupportedSort) => "Unsupported sort", + EventType::Jmap(JmapEvent::UnknownMethod) => "Unknown method", + EventType::Jmap(JmapEvent::InvalidResultReference) => "Invalid result reference", + EventType::Jmap(JmapEvent::Forbidden) => "Forbidden", + EventType::Jmap(JmapEvent::AccountNotFound) => "Account not found", + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => { + "Account not supported by method" + } + EventType::Jmap(JmapEvent::AccountReadOnly) => "Account read-only", + EventType::Jmap(JmapEvent::NotFound) => "Not found", + EventType::Jmap(JmapEvent::CannotCalculateChanges) => "Cannot calculate changes", + EventType::Jmap(JmapEvent::UnknownDataType) => "Unknown data type", + EventType::Jmap(JmapEvent::UnknownCapability) => "Unknown capability", + EventType::Jmap(JmapEvent::NotJson) => "Not JSON", + EventType::Jmap(JmapEvent::NotRequest) => "Not a request", + EventType::Jmap(JmapEvent::WebsocketStart) => "Other message", + EventType::Jmap(JmapEvent::WebsocketStop) => "Other message", + EventType::Jmap(JmapEvent::WebsocketError) => "Other message", + EventType::Limit(LimitEvent::SizeRequest) => "Request too large", + EventType::Limit(LimitEvent::SizeUpload) => "Upload too large", + EventType::Limit(LimitEvent::CallsIn) => "Too many calls in", + EventType::Limit(LimitEvent::ConcurrentRequest) => "Too many concurrent requests", + EventType::Limit(LimitEvent::ConcurrentUpload) => "Too many concurrent uploads", + EventType::Limit(LimitEvent::ConcurrentConnection) => "Too many concurrent connections", + EventType::Limit(LimitEvent::Quota) => "Quota exceeded", + EventType::Limit(LimitEvent::BlobQuota) => "Blob quota exceeded", + EventType::Limit(LimitEvent::TenantQuota) => "Tenant quota exceeded", + EventType::Limit(LimitEvent::TooManyRequests) => "Too many requests", + EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::CreateScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::UpdateScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::GetScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::DeleteScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::RenameScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::CheckScript) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::HaveSpace) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::ListScripts) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::SetActive) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::Capabilities) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::StartTls) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::Unauthenticate) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::Logout) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::Noop) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::Error) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::RawInput) => "ManageSieve error", + EventType::ManageSieve(ManageSieveEvent::RawOutput) => "ManageSieve error", + EventType::Network(NetworkEvent::ListenStart) => "Network error", + EventType::Network(NetworkEvent::ListenStop) => "Network error", + EventType::Network(NetworkEvent::ListenError) => "Network error", + EventType::Network(NetworkEvent::BindError) => "Network error", + EventType::Network(NetworkEvent::ReadError) => "Network error", + EventType::Network(NetworkEvent::WriteError) => "Network error", + EventType::Network(NetworkEvent::FlushError) => "Network error", + EventType::Network(NetworkEvent::AcceptError) => "Network error", + EventType::Network(NetworkEvent::SplitError) => "Network error", + EventType::Network(NetworkEvent::Timeout) => "Network error", + EventType::Network(NetworkEvent::Closed) => "Network error", + EventType::Network(NetworkEvent::ProxyError) => "Network error", + EventType::Network(NetworkEvent::SetOptError) => "Network error", + EventType::Pop3(Pop3Event::ConnectionStart) => "POP3 error", + EventType::Pop3(Pop3Event::ConnectionEnd) => "POP3 error", + EventType::Pop3(Pop3Event::Delete) => "POP3 error", + EventType::Pop3(Pop3Event::Reset) => "POP3 error", + EventType::Pop3(Pop3Event::Quit) => "POP3 error", + EventType::Pop3(Pop3Event::Fetch) => "POP3 error", + EventType::Pop3(Pop3Event::List) => "POP3 error", + EventType::Pop3(Pop3Event::ListMessage) => "POP3 error", + EventType::Pop3(Pop3Event::Uidl) => "POP3 error", + EventType::Pop3(Pop3Event::UidlMessage) => "POP3 error", + EventType::Pop3(Pop3Event::Stat) => "POP3 error", + EventType::Pop3(Pop3Event::Noop) => "POP3 error", + EventType::Pop3(Pop3Event::Capabilities) => "POP3 error", + EventType::Pop3(Pop3Event::StartTls) => "POP3 error", + EventType::Pop3(Pop3Event::Utf8) => "POP3 error", + EventType::Pop3(Pop3Event::Error) => "POP3 error", + EventType::Pop3(Pop3Event::RawInput) => "POP3 error", + EventType::Pop3(Pop3Event::RawOutput) => "POP3 error", + EventType::Resource(ResourceEvent::NotFound) => "Not found", + EventType::Resource(ResourceEvent::BadParameters) => "Bad parameters", + EventType::Resource(ResourceEvent::Error) => "Resource error", + EventType::Resource(ResourceEvent::DownloadExternal) => "Other status", + EventType::Security(SecurityEvent::AuthenticationBan) => "Insufficient permissions", + EventType::Security(SecurityEvent::AbuseBan) => "Insufficient permissions", + EventType::Security(SecurityEvent::ScanBan) => "Insufficient permissions", + EventType::Security(SecurityEvent::LoiterBan) => "Insufficient permissions", + EventType::Security(SecurityEvent::IpBlocked) => "Insufficient permissions", + EventType::Security(SecurityEvent::IpBlockExpired) => "Insufficient permissions", + EventType::Security(SecurityEvent::IpAllowExpired) => "Insufficient permissions", + EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", + EventType::Security(SecurityEvent::Unauthorized) => "Insufficient permissions", + EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error", + EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error", + EventType::Smtp(SmtpEvent::Error) => "SMTP error", + EventType::Smtp(SmtpEvent::IdNotFound) => "SMTP error", + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded) => "SMTP error", + EventType::Smtp(SmtpEvent::TransferLimitExceeded) => "SMTP error", + EventType::Smtp(SmtpEvent::RateLimitExceeded) => "SMTP error", + EventType::Smtp(SmtpEvent::TimeLimitExceeded) => "SMTP error", + EventType::Smtp(SmtpEvent::MissingAuthDirectory) => "SMTP error", + EventType::Smtp(SmtpEvent::MessageParseFailed) => "SMTP error", + EventType::Smtp(SmtpEvent::MessageTooLarge) => "SMTP error", + EventType::Smtp(SmtpEvent::LoopDetected) => "SMTP error", + EventType::Smtp(SmtpEvent::DkimPass) => "SMTP error", + EventType::Smtp(SmtpEvent::DkimFail) => "SMTP error", + EventType::Smtp(SmtpEvent::Dkim2Pass) => "SMTP error", + EventType::Smtp(SmtpEvent::Dkim2Fail) => "SMTP error", + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded) => "SMTP error", + EventType::Smtp(SmtpEvent::ArcPass) => "SMTP error", + EventType::Smtp(SmtpEvent::ArcFail) => "SMTP error", + EventType::Smtp(SmtpEvent::SpfEhloPass) => "SMTP error", + EventType::Smtp(SmtpEvent::SpfEhloFail) => "SMTP error", + EventType::Smtp(SmtpEvent::SpfFromPass) => "SMTP error", + EventType::Smtp(SmtpEvent::SpfFromFail) => "SMTP error", + EventType::Smtp(SmtpEvent::DmarcPass) => "SMTP error", + EventType::Smtp(SmtpEvent::DmarcFail) => "SMTP error", + EventType::Smtp(SmtpEvent::IprevPass) => "SMTP error", + EventType::Smtp(SmtpEvent::IprevFail) => "SMTP error", + EventType::Smtp(SmtpEvent::TooManyMessages) => "SMTP error", + EventType::Smtp(SmtpEvent::Ehlo) => "SMTP error", + EventType::Smtp(SmtpEvent::InvalidEhlo) => "SMTP error", + EventType::Smtp(SmtpEvent::DidNotSayEhlo) => "SMTP error", + EventType::Smtp(SmtpEvent::EhloExpected) => "SMTP error", + EventType::Smtp(SmtpEvent::LhloExpected) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFromUnauthenticated) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFromUnauthorized) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFromNotAllowed) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFromRewritten) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFromMissing) => "SMTP error", + EventType::Smtp(SmtpEvent::MailFrom) => "SMTP error", + EventType::Smtp(SmtpEvent::MultipleMailFrom) => "SMTP error", + EventType::Smtp(SmtpEvent::MailboxDoesNotExist) => "SMTP error", + EventType::Smtp(SmtpEvent::RelayNotAllowed) => "SMTP error", + EventType::Smtp(SmtpEvent::RcptTo) => "SMTP error", + EventType::Smtp(SmtpEvent::RcptToDuplicate) => "SMTP error", + EventType::Smtp(SmtpEvent::RcptToRewritten) => "SMTP error", + EventType::Smtp(SmtpEvent::RcptToMissing) => "SMTP error", + EventType::Smtp(SmtpEvent::RcptToGreylisted) => "SMTP error", + EventType::Smtp(SmtpEvent::TooManyRecipients) => "SMTP error", + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt) => "SMTP error", + EventType::Smtp(SmtpEvent::RawInput) => "SMTP error", + EventType::Smtp(SmtpEvent::RawOutput) => "SMTP error", + EventType::Smtp(SmtpEvent::MissingLocalHostname) => "SMTP error", + EventType::Smtp(SmtpEvent::Vrfy) => "SMTP error", + EventType::Smtp(SmtpEvent::VrfyNotFound) => "SMTP error", + EventType::Smtp(SmtpEvent::VrfyDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::Expn) => "SMTP error", + EventType::Smtp(SmtpEvent::ExpnNotFound) => "SMTP error", + EventType::Smtp(SmtpEvent::ExpnDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::RequireTlsDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::DeliverByDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::DeliverByInvalid) => "SMTP error", + EventType::Smtp(SmtpEvent::FutureReleaseDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::FutureReleaseInvalid) => "SMTP error", + EventType::Smtp(SmtpEvent::MtPriorityDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::MtPriorityInvalid) => "SMTP error", + EventType::Smtp(SmtpEvent::DsnDisabled) => "SMTP error", + EventType::Smtp(SmtpEvent::AuthNotAllowed) => "SMTP error", + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported) => "SMTP error", + EventType::Smtp(SmtpEvent::AuthExchangeTooLong) => "SMTP error", + EventType::Smtp(SmtpEvent::AlreadyAuthenticated) => "SMTP error", + EventType::Smtp(SmtpEvent::Noop) => "SMTP error", + EventType::Smtp(SmtpEvent::StartTls) => "SMTP error", + EventType::Smtp(SmtpEvent::StartTlsUnavailable) => "SMTP error", + EventType::Smtp(SmtpEvent::StartTlsAlready) => "SMTP error", + EventType::Smtp(SmtpEvent::Rset) => "SMTP error", + EventType::Smtp(SmtpEvent::Quit) => "SMTP error", + EventType::Smtp(SmtpEvent::Help) => "SMTP error", + EventType::Smtp(SmtpEvent::CommandNotImplemented) => "SMTP error", + EventType::Smtp(SmtpEvent::InvalidCommand) => "SMTP error", + EventType::Smtp(SmtpEvent::InvalidSenderAddress) => "SMTP error", + EventType::Smtp(SmtpEvent::InvalidRecipientAddress) => "SMTP error", + EventType::Smtp(SmtpEvent::InvalidParameter) => "SMTP error", + EventType::Smtp(SmtpEvent::UnsupportedParameter) => "SMTP error", + EventType::Smtp(SmtpEvent::SyntaxError) => "SMTP error", + EventType::Smtp(SmtpEvent::RequestTooLarge) => "SMTP error", + EventType::Store(StoreEvent::AssertValueFailed) => { + "Another process has modified the value" + } + EventType::Store(StoreEvent::FoundationdbError) => "FoundationDB error", + EventType::Store(StoreEvent::MysqlError) => "MySQL error", + EventType::Store(StoreEvent::PostgresqlError) => "PostgreSQL error", + EventType::Store(StoreEvent::RocksdbError) => "RocksDB error", + EventType::Store(StoreEvent::SqliteError) => "SQLite error", + EventType::Store(StoreEvent::LdapError) => "LDAP error", + EventType::Store(StoreEvent::ElasticsearchError) => "ElasticSearch error", + EventType::Store(StoreEvent::MeilisearchError) => "Store error", + EventType::Store(StoreEvent::RedisError) => "Redis error", + EventType::Store(StoreEvent::S3Error) => "S3 error", + EventType::Store(StoreEvent::AzureError) => "Azure error", + EventType::Store(StoreEvent::FilesystemError) => "Filesystem error", + EventType::Store(StoreEvent::PoolError) => "Connection pool error", + EventType::Store(StoreEvent::DataCorruption) => "Data corruption", + EventType::Store(StoreEvent::DecompressError) => "Decompression error", + EventType::Store(StoreEvent::DeserializeError) => "Deserialization error", + EventType::Store(StoreEvent::NotFound) => "Not found", + EventType::Store(StoreEvent::NotConfigured) => "Not configured", + EventType::Store(StoreEvent::NotSupported) => "Operation not supported", + EventType::Store(StoreEvent::UnexpectedError) => "Unexpected error", + EventType::Store(StoreEvent::CryptoError) => "Crypto error", + EventType::Store(StoreEvent::HttpStoreError) => "Store error", + EventType::Store(StoreEvent::CacheMiss) => "Store error", + EventType::Store(StoreEvent::CacheHit) => "Store error", + EventType::Store(StoreEvent::CacheStale) => "Store error", + EventType::Store(StoreEvent::CacheUpdate) => "Store error", + EventType::Store(StoreEvent::CacheEntryTooLarge) => "Cache entry too large to store", + EventType::Store(StoreEvent::BlobMissingMarker) => "Blob is missing marker", + EventType::Store(StoreEvent::DataWrite) => "Store error", + EventType::Store(StoreEvent::DataIterate) => "Store error", + EventType::Store(StoreEvent::BlobRead) => "Store error", + EventType::Store(StoreEvent::BlobWrite) => "Store error", + EventType::Store(StoreEvent::BlobDelete) => "Store error", + EventType::Store(StoreEvent::SqlQuery) => "Store error", + EventType::Store(StoreEvent::LdapQuery) => "Store error", + EventType::Store(StoreEvent::LdapWarning) => "Store error", + EventType::Store(StoreEvent::HttpStoreFetch) => "Store error", + _ => "Internal Server Error", + } + } + + pub const fn variants() -> &'static [Self] { + &[ + EventType::Acme(AcmeEvent::AuthStart), + EventType::Acme(AcmeEvent::AuthPending), + EventType::Acme(AcmeEvent::AuthValid), + EventType::Acme(AcmeEvent::AuthCompleted), + EventType::Acme(AcmeEvent::AuthError), + EventType::Acme(AcmeEvent::AuthTooManyAttempts), + EventType::Acme(AcmeEvent::ProcessCert), + EventType::Acme(AcmeEvent::OrderStart), + EventType::Acme(AcmeEvent::OrderProcessing), + EventType::Acme(AcmeEvent::OrderCompleted), + EventType::Acme(AcmeEvent::OrderReady), + EventType::Acme(AcmeEvent::OrderValid), + EventType::Acme(AcmeEvent::OrderInvalid), + EventType::Acme(AcmeEvent::RenewBackoff), + EventType::Acme(AcmeEvent::ClientSuppliedSni), + EventType::Acme(AcmeEvent::ClientMissingSni), + EventType::Acme(AcmeEvent::TlsAlpnReceived), + EventType::Acme(AcmeEvent::TlsAlpnError), + EventType::Acme(AcmeEvent::TokenNotFound), + EventType::Acme(AcmeEvent::Error), + EventType::Ai(AiEvent::LlmResponse), + EventType::Ai(AiEvent::ApiError), + EventType::Arc(ArcEvent::ChainTooLong), + EventType::Arc(ArcEvent::InvalidInstance), + EventType::Arc(ArcEvent::InvalidCv), + EventType::Arc(ArcEvent::HasHeaderTag), + EventType::Arc(ArcEvent::BrokenChain), + EventType::Arc(ArcEvent::SealerNotFound), + EventType::Auth(AuthEvent::Success), + EventType::Auth(AuthEvent::Failed), + EventType::Auth(AuthEvent::TokenExpired), + EventType::Auth(AuthEvent::MfaRequired), + EventType::Auth(AuthEvent::TooManyAttempts), + EventType::Auth(AuthEvent::ClientRegistration), + EventType::Auth(AuthEvent::Error), + EventType::Auth(AuthEvent::Warning), + EventType::Auth(AuthEvent::CredentialExpired), + EventType::Calendar(CalendarEvent::RuleExpansionError), + EventType::Calendar(CalendarEvent::AlarmSent), + EventType::Calendar(CalendarEvent::AlarmSkipped), + EventType::Calendar(CalendarEvent::AlarmRecipientOverride), + EventType::Calendar(CalendarEvent::AlarmFailed), + EventType::Calendar(CalendarEvent::ItipMessageSent), + EventType::Calendar(CalendarEvent::ItipMessageReceived), + EventType::Calendar(CalendarEvent::ItipMessageError), + EventType::Cluster(ClusterEvent::Startup), + EventType::Cluster(ClusterEvent::SubscriberStart), + EventType::Cluster(ClusterEvent::SubscriberStop), + EventType::Cluster(ClusterEvent::SubscriberError), + EventType::Cluster(ClusterEvent::SubscriberDisconnected), + EventType::Cluster(ClusterEvent::PublisherStart), + EventType::Cluster(ClusterEvent::PublisherStop), + EventType::Cluster(ClusterEvent::PublisherError), + EventType::Cluster(ClusterEvent::MessageReceived), + EventType::Cluster(ClusterEvent::MessageSkipped), + EventType::Cluster(ClusterEvent::MessageInvalid), + EventType::Cluster(ClusterEvent::NodeIdRenewed), + EventType::Dane(DaneEvent::AuthenticationSuccess), + EventType::Dane(DaneEvent::AuthenticationFailure), + EventType::Dane(DaneEvent::NoCertificatesFound), + EventType::Dane(DaneEvent::CertificateParseError), + EventType::Dane(DaneEvent::TlsaRecordMatch), + EventType::Dane(DaneEvent::TlsaRecordFetch), + EventType::Dane(DaneEvent::TlsaRecordFetchError), + EventType::Dane(DaneEvent::TlsaRecordNotFound), + EventType::Dane(DaneEvent::TlsaRecordNotDnssecSigned), + EventType::Dane(DaneEvent::TlsaRecordInvalid), + EventType::Dane(DaneEvent::BogusDnssecRecord), + EventType::Delivery(DeliveryEvent::AttemptStart), + EventType::Delivery(DeliveryEvent::AttemptEnd), + EventType::Delivery(DeliveryEvent::Completed), + EventType::Delivery(DeliveryEvent::Failed), + EventType::Delivery(DeliveryEvent::DomainDeliveryStart), + EventType::Delivery(DeliveryEvent::MxLookup), + EventType::Delivery(DeliveryEvent::MxLookupFailed), + EventType::Delivery(DeliveryEvent::IpLookup), + EventType::Delivery(DeliveryEvent::IpLookupFailed), + EventType::Delivery(DeliveryEvent::NullMx), + EventType::Delivery(DeliveryEvent::Connect), + EventType::Delivery(DeliveryEvent::ConnectError), + EventType::Delivery(DeliveryEvent::MissingOutboundHostname), + EventType::Delivery(DeliveryEvent::GreetingFailed), + EventType::Delivery(DeliveryEvent::Ehlo), + EventType::Delivery(DeliveryEvent::EhloRejected), + EventType::Delivery(DeliveryEvent::Auth), + EventType::Delivery(DeliveryEvent::AuthFailed), + EventType::Delivery(DeliveryEvent::MailFrom), + EventType::Delivery(DeliveryEvent::MailFromRejected), + EventType::Delivery(DeliveryEvent::Delivered), + EventType::Delivery(DeliveryEvent::RcptTo), + EventType::Delivery(DeliveryEvent::RcptToRejected), + EventType::Delivery(DeliveryEvent::RcptToFailed), + EventType::Delivery(DeliveryEvent::MessageRejected), + EventType::Delivery(DeliveryEvent::StartTls), + EventType::Delivery(DeliveryEvent::StartTlsUnavailable), + EventType::Delivery(DeliveryEvent::StartTlsError), + EventType::Delivery(DeliveryEvent::StartTlsDisabled), + EventType::Delivery(DeliveryEvent::ImplicitTlsError), + EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded), + EventType::Delivery(DeliveryEvent::RateLimitExceeded), + EventType::Delivery(DeliveryEvent::DoubleBounce), + EventType::Delivery(DeliveryEvent::DsnSuccess), + EventType::Delivery(DeliveryEvent::DsnTempFail), + EventType::Delivery(DeliveryEvent::DsnPermFail), + EventType::Delivery(DeliveryEvent::RawInput), + EventType::Delivery(DeliveryEvent::RawOutput), + EventType::Dkim(DkimEvent::Pass), + EventType::Dkim(DkimEvent::Neutral), + EventType::Dkim(DkimEvent::Fail), + EventType::Dkim(DkimEvent::PermError), + EventType::Dkim(DkimEvent::TempError), + EventType::Dkim(DkimEvent::None), + EventType::Dkim(DkimEvent::UnsupportedVersion), + EventType::Dkim(DkimEvent::UnsupportedAlgorithm), + EventType::Dkim(DkimEvent::UnsupportedCanonicalization), + EventType::Dkim(DkimEvent::UnsupportedKeyType), + EventType::Dkim(DkimEvent::FailedBodyHashMatch), + EventType::Dkim(DkimEvent::FailedVerification), + EventType::Dkim(DkimEvent::FailedAuidMatch), + EventType::Dkim(DkimEvent::RevokedPublicKey), + EventType::Dkim(DkimEvent::IncompatibleAlgorithms), + EventType::Dkim(DkimEvent::SignatureExpired), + EventType::Dkim(DkimEvent::SignatureLength), + EventType::Dkim(DkimEvent::SignerNotFound), + EventType::Dkim(DkimEvent::BuildError), + EventType::Dkim(DkimEvent::SignatureCreated), + EventType::Dkim(DkimEvent::SignaturePublished), + EventType::Dkim(DkimEvent::SignatureRetiring), + EventType::Dkim(DkimEvent::SignatureRetired), + EventType::Dkim(DkimEvent::SignatureDeleted), + EventType::Dkim(DkimEvent::InstanceMissing), + EventType::Dkim(DkimEvent::InstanceSyntax), + EventType::Dkim(DkimEvent::InstanceTagMissing), + EventType::Dkim(DkimEvent::InstanceNotSigned), + EventType::Dkim(DkimEvent::InstanceAboveSignature), + EventType::Dkim(DkimEvent::SignatureMissing), + EventType::Dkim(DkimEvent::SignatureSyntax), + EventType::Dkim(DkimEvent::SignatureTagMissing), + EventType::Dkim(DkimEvent::SignatureTagUnexpected), + EventType::Dkim(DkimEvent::SequenceGap), + EventType::Dkim(DkimEvent::SequenceOverflow), + EventType::Dkim(DkimEvent::ChainTooLong), + EventType::Dkim(DkimEvent::MailFromMismatch), + EventType::Dkim(DkimEvent::RcptToMismatch), + EventType::Dkim(DkimEvent::MailFromDomainMismatch), + EventType::Dkim(DkimEvent::NextDomainMismatch), + EventType::Dkim(DkimEvent::CustodyBreak), + EventType::Dkim(DkimEvent::PublicKeyFetch), + EventType::Dkim(DkimEvent::PublicKeyMissing), + EventType::Dkim(DkimEvent::PublicKeyMultiple), + EventType::Dkim(DkimEvent::PublicKeySyntax), + EventType::Dkim(DkimEvent::PublicKeyAlgorithmMismatch), + EventType::Dkim(DkimEvent::NoValidAlgorithm), + EventType::Dkim(DkimEvent::HeaderHashMismatch), + EventType::Dkim(DkimEvent::Modified), + EventType::Dkim(DkimEvent::Exploded), + EventType::Dmarc(DmarcEvent::Pass), + EventType::Dmarc(DmarcEvent::Fail), + EventType::Dmarc(DmarcEvent::PermError), + EventType::Dmarc(DmarcEvent::TempError), + EventType::Dmarc(DmarcEvent::None), + EventType::Dns(DnsEvent::RecordCreated), + EventType::Dns(DnsEvent::RecordCreationFailed), + EventType::Dns(DnsEvent::RecordDeletionFailed), + EventType::Dns(DnsEvent::RecordNotPropagated), + EventType::Dns(DnsEvent::RecordLookupFailed), + EventType::Dns(DnsEvent::RecordPropagated), + EventType::Dns(DnsEvent::RecordPropagationTimeout), + EventType::Dns(DnsEvent::BuildError), + EventType::Eval(EvalEvent::Result), + EventType::Eval(EvalEvent::Error), + EventType::Eval(EvalEvent::DirectoryNotFound), + EventType::Eval(EvalEvent::StoreNotFound), + EventType::Http(HttpEvent::ConnectionStart), + EventType::Http(HttpEvent::ConnectionEnd), + EventType::Http(HttpEvent::Error), + EventType::Http(HttpEvent::RequestUrl), + EventType::Http(HttpEvent::RequestBody), + EventType::Http(HttpEvent::ResponseBody), + EventType::Http(HttpEvent::XForwardedMissing), + EventType::Imap(ImapEvent::ConnectionStart), + EventType::Imap(ImapEvent::ConnectionEnd), + EventType::Imap(ImapEvent::GetAcl), + EventType::Imap(ImapEvent::SetAcl), + EventType::Imap(ImapEvent::MyRights), + EventType::Imap(ImapEvent::ListRights), + EventType::Imap(ImapEvent::Append), + EventType::Imap(ImapEvent::Capabilities), + EventType::Imap(ImapEvent::Id), + EventType::Imap(ImapEvent::Close), + EventType::Imap(ImapEvent::Copy), + EventType::Imap(ImapEvent::Move), + EventType::Imap(ImapEvent::CreateMailbox), + EventType::Imap(ImapEvent::DeleteMailbox), + EventType::Imap(ImapEvent::RenameMailbox), + EventType::Imap(ImapEvent::Enable), + EventType::Imap(ImapEvent::Expunge), + EventType::Imap(ImapEvent::Fetch), + EventType::Imap(ImapEvent::IdleStart), + EventType::Imap(ImapEvent::IdleStop), + EventType::Imap(ImapEvent::List), + EventType::Imap(ImapEvent::Lsub), + EventType::Imap(ImapEvent::Logout), + EventType::Imap(ImapEvent::Namespace), + EventType::Imap(ImapEvent::Noop), + EventType::Imap(ImapEvent::Search), + EventType::Imap(ImapEvent::Sort), + EventType::Imap(ImapEvent::Select), + EventType::Imap(ImapEvent::Status), + EventType::Imap(ImapEvent::Store), + EventType::Imap(ImapEvent::Subscribe), + EventType::Imap(ImapEvent::Unsubscribe), + EventType::Imap(ImapEvent::Thread), + EventType::Imap(ImapEvent::UidBatches), + EventType::Imap(ImapEvent::GetQuota), + EventType::Imap(ImapEvent::Error), + EventType::Imap(ImapEvent::RawInput), + EventType::Imap(ImapEvent::RawOutput), + EventType::IncomingReport(IncomingReportEvent::DmarcReport), + EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), + EventType::IncomingReport(IncomingReportEvent::TlsReport), + EventType::IncomingReport(IncomingReportEvent::TlsReportWithWarnings), + EventType::IncomingReport(IncomingReportEvent::AbuseReport), + EventType::IncomingReport(IncomingReportEvent::AuthFailureReport), + EventType::IncomingReport(IncomingReportEvent::FraudReport), + EventType::IncomingReport(IncomingReportEvent::NotSpamReport), + EventType::IncomingReport(IncomingReportEvent::VirusReport), + EventType::IncomingReport(IncomingReportEvent::OtherReport), + EventType::IncomingReport(IncomingReportEvent::MessageParseFailed), + EventType::IncomingReport(IncomingReportEvent::DmarcParseFailed), + EventType::IncomingReport(IncomingReportEvent::TlsRpcParseFailed), + EventType::IncomingReport(IncomingReportEvent::ArfParseFailed), + EventType::IncomingReport(IncomingReportEvent::DecompressError), + EventType::Iprev(IprevEvent::Pass), + EventType::Iprev(IprevEvent::Fail), + EventType::Iprev(IprevEvent::PermError), + EventType::Iprev(IprevEvent::TempError), + EventType::Iprev(IprevEvent::None), + EventType::Jmap(JmapEvent::MethodCall), + EventType::Jmap(JmapEvent::InvalidArguments), + EventType::Jmap(JmapEvent::RequestTooLarge), + EventType::Jmap(JmapEvent::StateMismatch), + EventType::Jmap(JmapEvent::AnchorNotFound), + EventType::Jmap(JmapEvent::UnsupportedFilter), + EventType::Jmap(JmapEvent::UnsupportedSort), + EventType::Jmap(JmapEvent::UnknownMethod), + EventType::Jmap(JmapEvent::InvalidResultReference), + EventType::Jmap(JmapEvent::Forbidden), + EventType::Jmap(JmapEvent::AccountNotFound), + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod), + EventType::Jmap(JmapEvent::AccountReadOnly), + EventType::Jmap(JmapEvent::NotFound), + EventType::Jmap(JmapEvent::CannotCalculateChanges), + EventType::Jmap(JmapEvent::UnknownDataType), + EventType::Jmap(JmapEvent::UnknownCapability), + EventType::Jmap(JmapEvent::NotJson), + EventType::Jmap(JmapEvent::NotRequest), + EventType::Jmap(JmapEvent::WebsocketStart), + EventType::Jmap(JmapEvent::WebsocketStop), + EventType::Jmap(JmapEvent::WebsocketError), + EventType::Limit(LimitEvent::SizeRequest), + EventType::Limit(LimitEvent::SizeUpload), + EventType::Limit(LimitEvent::CallsIn), + EventType::Limit(LimitEvent::ConcurrentRequest), + EventType::Limit(LimitEvent::ConcurrentUpload), + EventType::Limit(LimitEvent::ConcurrentConnection), + EventType::Limit(LimitEvent::Quota), + EventType::Limit(LimitEvent::BlobQuota), + EventType::Limit(LimitEvent::TenantQuota), + EventType::Limit(LimitEvent::TooManyRequests), + EventType::MailAuth(MailAuthEvent::ParseError), + EventType::MailAuth(MailAuthEvent::MissingParameters), + EventType::MailAuth(MailAuthEvent::NoHeadersFound), + EventType::MailAuth(MailAuthEvent::Crypto), + EventType::MailAuth(MailAuthEvent::Io), + EventType::MailAuth(MailAuthEvent::Base64), + EventType::MailAuth(MailAuthEvent::DnsError), + EventType::MailAuth(MailAuthEvent::DnsRecordNotFound), + EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType), + EventType::MailAuth(MailAuthEvent::PolicyNotAligned), + EventType::ManageSieve(ManageSieveEvent::ConnectionStart), + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd), + EventType::ManageSieve(ManageSieveEvent::CreateScript), + EventType::ManageSieve(ManageSieveEvent::UpdateScript), + EventType::ManageSieve(ManageSieveEvent::GetScript), + EventType::ManageSieve(ManageSieveEvent::DeleteScript), + EventType::ManageSieve(ManageSieveEvent::RenameScript), + EventType::ManageSieve(ManageSieveEvent::CheckScript), + EventType::ManageSieve(ManageSieveEvent::HaveSpace), + EventType::ManageSieve(ManageSieveEvent::ListScripts), + EventType::ManageSieve(ManageSieveEvent::SetActive), + EventType::ManageSieve(ManageSieveEvent::Capabilities), + EventType::ManageSieve(ManageSieveEvent::StartTls), + EventType::ManageSieve(ManageSieveEvent::Unauthenticate), + EventType::ManageSieve(ManageSieveEvent::Logout), + EventType::ManageSieve(ManageSieveEvent::Noop), + EventType::ManageSieve(ManageSieveEvent::Error), + EventType::ManageSieve(ManageSieveEvent::RawInput), + EventType::ManageSieve(ManageSieveEvent::RawOutput), + EventType::MessageIngest(MessageIngestEvent::Ham), + EventType::MessageIngest(MessageIngestEvent::Spam), + EventType::MessageIngest(MessageIngestEvent::ImapAppend), + EventType::MessageIngest(MessageIngestEvent::JmapAppend), + EventType::MessageIngest(MessageIngestEvent::Duplicate), + EventType::MessageIngest(MessageIngestEvent::Error), + EventType::MessageIngest(MessageIngestEvent::SearchIndex), + EventType::Milter(MilterEvent::Read), + EventType::Milter(MilterEvent::Write), + EventType::Milter(MilterEvent::ActionAccept), + EventType::Milter(MilterEvent::ActionDiscard), + EventType::Milter(MilterEvent::ActionReject), + EventType::Milter(MilterEvent::ActionTempFail), + EventType::Milter(MilterEvent::ActionReplyCode), + EventType::Milter(MilterEvent::ActionConnectionFailure), + EventType::Milter(MilterEvent::ActionShutdown), + EventType::Milter(MilterEvent::IoError), + EventType::Milter(MilterEvent::FrameTooLarge), + EventType::Milter(MilterEvent::FrameInvalid), + EventType::Milter(MilterEvent::UnexpectedResponse), + EventType::Milter(MilterEvent::Timeout), + EventType::Milter(MilterEvent::TlsInvalidName), + EventType::Milter(MilterEvent::Disconnected), + EventType::Milter(MilterEvent::ParseError), + EventType::MtaHook(MtaHookEvent::ActionAccept), + EventType::MtaHook(MtaHookEvent::ActionDiscard), + EventType::MtaHook(MtaHookEvent::ActionReject), + EventType::MtaHook(MtaHookEvent::ActionQuarantine), + EventType::MtaHook(MtaHookEvent::Error), + EventType::MtaSts(MtaStsEvent::Authorized), + EventType::MtaSts(MtaStsEvent::NotAuthorized), + EventType::MtaSts(MtaStsEvent::PolicyFetch), + EventType::MtaSts(MtaStsEvent::PolicyNotFound), + EventType::MtaSts(MtaStsEvent::PolicyFetchError), + EventType::MtaSts(MtaStsEvent::InvalidPolicy), + EventType::Network(NetworkEvent::ListenStart), + EventType::Network(NetworkEvent::ListenStop), + EventType::Network(NetworkEvent::ListenError), + EventType::Network(NetworkEvent::BindError), + EventType::Network(NetworkEvent::ReadError), + EventType::Network(NetworkEvent::WriteError), + EventType::Network(NetworkEvent::FlushError), + EventType::Network(NetworkEvent::AcceptError), + EventType::Network(NetworkEvent::SplitError), + EventType::Network(NetworkEvent::Timeout), + EventType::Network(NetworkEvent::Closed), + EventType::Network(NetworkEvent::ProxyError), + EventType::Network(NetworkEvent::SetOptError), + EventType::OutgoingReport(OutgoingReportEvent::SpfReport), + EventType::OutgoingReport(OutgoingReportEvent::SpfRateLimited), + EventType::OutgoingReport(OutgoingReportEvent::DkimReport), + EventType::OutgoingReport(OutgoingReportEvent::DkimRateLimited), + EventType::OutgoingReport(OutgoingReportEvent::DmarcReport), + EventType::OutgoingReport(OutgoingReportEvent::DmarcRateLimited), + EventType::OutgoingReport(OutgoingReportEvent::DmarcAggregateReport), + EventType::OutgoingReport(OutgoingReportEvent::TlsAggregate), + EventType::OutgoingReport(OutgoingReportEvent::HttpSubmission), + EventType::OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress), + EventType::OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError), + EventType::OutgoingReport(OutgoingReportEvent::NotFound), + EventType::OutgoingReport(OutgoingReportEvent::SubmissionError), + EventType::OutgoingReport(OutgoingReportEvent::NoRecipientsFound), + EventType::OutgoingReport(OutgoingReportEvent::Locked), + EventType::OutgoingReport(OutgoingReportEvent::MaxSizeExceeded), + EventType::Pop3(Pop3Event::ConnectionStart), + EventType::Pop3(Pop3Event::ConnectionEnd), + EventType::Pop3(Pop3Event::Delete), + EventType::Pop3(Pop3Event::Reset), + EventType::Pop3(Pop3Event::Quit), + EventType::Pop3(Pop3Event::Fetch), + EventType::Pop3(Pop3Event::List), + EventType::Pop3(Pop3Event::ListMessage), + EventType::Pop3(Pop3Event::Uidl), + EventType::Pop3(Pop3Event::UidlMessage), + EventType::Pop3(Pop3Event::Stat), + EventType::Pop3(Pop3Event::Noop), + EventType::Pop3(Pop3Event::Capabilities), + EventType::Pop3(Pop3Event::StartTls), + EventType::Pop3(Pop3Event::Utf8), + EventType::Pop3(Pop3Event::Error), + EventType::Pop3(Pop3Event::RawInput), + EventType::Pop3(Pop3Event::RawOutput), + EventType::PushSubscription(PushSubscriptionEvent::Success), + EventType::PushSubscription(PushSubscriptionEvent::Error), + EventType::PushSubscription(PushSubscriptionEvent::NotFound), + EventType::Queue(QueueEvent::Started), + EventType::Queue(QueueEvent::MessageQueued), + EventType::Queue(QueueEvent::AuthenticatedMessageQueued), + EventType::Queue(QueueEvent::ReportQueued), + EventType::Queue(QueueEvent::DsnQueued), + EventType::Queue(QueueEvent::AutogeneratedQueued), + EventType::Queue(QueueEvent::Rescheduled), + EventType::Queue(QueueEvent::Locked), + EventType::Queue(QueueEvent::BlobNotFound), + EventType::Queue(QueueEvent::RateLimitExceeded), + EventType::Queue(QueueEvent::ConcurrencyLimitExceeded), + EventType::Queue(QueueEvent::QuotaExceeded), + EventType::Queue(QueueEvent::BackPressure), + EventType::Registry(RegistryEvent::LocalReadError), + EventType::Registry(RegistryEvent::LocalWriteError), + EventType::Registry(RegistryEvent::LocalParseError), + EventType::Registry(RegistryEvent::ReadError), + EventType::Registry(RegistryEvent::WriteError), + EventType::Registry(RegistryEvent::DeserializationError), + EventType::Registry(RegistryEvent::BuildError), + EventType::Registry(RegistryEvent::BuildWarning), + EventType::Registry(RegistryEvent::NotSupported), + EventType::Registry(RegistryEvent::ValidationError), + EventType::Resource(ResourceEvent::NotFound), + EventType::Resource(ResourceEvent::BadParameters), + EventType::Resource(ResourceEvent::Error), + EventType::Resource(ResourceEvent::DownloadExternal), + EventType::Resource(ResourceEvent::ApplicationUpdated), + EventType::Resource(ResourceEvent::ApplicationUnpacked), + EventType::Security(SecurityEvent::AuthenticationBan), + EventType::Security(SecurityEvent::AbuseBan), + EventType::Security(SecurityEvent::ScanBan), + EventType::Security(SecurityEvent::LoiterBan), + EventType::Security(SecurityEvent::IpBlocked), + EventType::Security(SecurityEvent::IpBlockExpired), + EventType::Security(SecurityEvent::IpAllowExpired), + EventType::Security(SecurityEvent::IpUnauthorized), + EventType::Security(SecurityEvent::Unauthorized), + EventType::Server(ServerEvent::Startup), + EventType::Server(ServerEvent::Shutdown), + EventType::Server(ServerEvent::StartupError), + EventType::Server(ServerEvent::ThreadError), + EventType::Server(ServerEvent::Licensing), + EventType::Server(ServerEvent::RecoveryMode), + EventType::Server(ServerEvent::BootstrapMode), + EventType::Sieve(SieveEvent::ActionAccept), + EventType::Sieve(SieveEvent::ActionAcceptReplace), + EventType::Sieve(SieveEvent::ActionDiscard), + EventType::Sieve(SieveEvent::ActionReject), + EventType::Sieve(SieveEvent::SendMessage), + EventType::Sieve(SieveEvent::MessageTooLarge), + EventType::Sieve(SieveEvent::ScriptNotFound), + EventType::Sieve(SieveEvent::ListNotFound), + EventType::Sieve(SieveEvent::RuntimeError), + EventType::Sieve(SieveEvent::UnexpectedError), + EventType::Sieve(SieveEvent::NotSupported), + EventType::Sieve(SieveEvent::QuotaExceeded), + EventType::Smtp(SmtpEvent::ConnectionStart), + EventType::Smtp(SmtpEvent::ConnectionEnd), + EventType::Smtp(SmtpEvent::Error), + EventType::Smtp(SmtpEvent::IdNotFound), + EventType::Smtp(SmtpEvent::ConcurrencyLimitExceeded), + EventType::Smtp(SmtpEvent::TransferLimitExceeded), + EventType::Smtp(SmtpEvent::RateLimitExceeded), + EventType::Smtp(SmtpEvent::TimeLimitExceeded), + EventType::Smtp(SmtpEvent::MissingAuthDirectory), + EventType::Smtp(SmtpEvent::MessageParseFailed), + EventType::Smtp(SmtpEvent::MessageTooLarge), + EventType::Smtp(SmtpEvent::LoopDetected), + EventType::Smtp(SmtpEvent::DkimPass), + EventType::Smtp(SmtpEvent::DkimFail), + EventType::Smtp(SmtpEvent::Dkim2Pass), + EventType::Smtp(SmtpEvent::Dkim2Fail), + EventType::Smtp(SmtpEvent::Dkim2DsnDiscarded), + EventType::Smtp(SmtpEvent::ArcPass), + EventType::Smtp(SmtpEvent::ArcFail), + EventType::Smtp(SmtpEvent::SpfEhloPass), + EventType::Smtp(SmtpEvent::SpfEhloFail), + EventType::Smtp(SmtpEvent::SpfFromPass), + EventType::Smtp(SmtpEvent::SpfFromFail), + EventType::Smtp(SmtpEvent::DmarcPass), + EventType::Smtp(SmtpEvent::DmarcFail), + EventType::Smtp(SmtpEvent::IprevPass), + EventType::Smtp(SmtpEvent::IprevFail), + EventType::Smtp(SmtpEvent::TooManyMessages), + EventType::Smtp(SmtpEvent::Ehlo), + EventType::Smtp(SmtpEvent::InvalidEhlo), + EventType::Smtp(SmtpEvent::DidNotSayEhlo), + EventType::Smtp(SmtpEvent::EhloExpected), + EventType::Smtp(SmtpEvent::LhloExpected), + EventType::Smtp(SmtpEvent::MailFromUnauthenticated), + EventType::Smtp(SmtpEvent::MailFromUnauthorized), + EventType::Smtp(SmtpEvent::MailFromNotAllowed), + EventType::Smtp(SmtpEvent::MailFromRewritten), + EventType::Smtp(SmtpEvent::MailFromMissing), + EventType::Smtp(SmtpEvent::MailFrom), + EventType::Smtp(SmtpEvent::MultipleMailFrom), + EventType::Smtp(SmtpEvent::MailboxDoesNotExist), + EventType::Smtp(SmtpEvent::RelayNotAllowed), + EventType::Smtp(SmtpEvent::RcptTo), + EventType::Smtp(SmtpEvent::RcptToDuplicate), + EventType::Smtp(SmtpEvent::RcptToRewritten), + EventType::Smtp(SmtpEvent::RcptToMissing), + EventType::Smtp(SmtpEvent::RcptToGreylisted), + EventType::Smtp(SmtpEvent::TooManyRecipients), + EventType::Smtp(SmtpEvent::TooManyInvalidRcpt), + EventType::Smtp(SmtpEvent::RawInput), + EventType::Smtp(SmtpEvent::RawOutput), + EventType::Smtp(SmtpEvent::MissingLocalHostname), + EventType::Smtp(SmtpEvent::Vrfy), + EventType::Smtp(SmtpEvent::VrfyNotFound), + EventType::Smtp(SmtpEvent::VrfyDisabled), + EventType::Smtp(SmtpEvent::Expn), + EventType::Smtp(SmtpEvent::ExpnNotFound), + EventType::Smtp(SmtpEvent::ExpnDisabled), + EventType::Smtp(SmtpEvent::RequireTlsDisabled), + EventType::Smtp(SmtpEvent::DeliverByDisabled), + EventType::Smtp(SmtpEvent::DeliverByInvalid), + EventType::Smtp(SmtpEvent::FutureReleaseDisabled), + EventType::Smtp(SmtpEvent::FutureReleaseInvalid), + EventType::Smtp(SmtpEvent::MtPriorityDisabled), + EventType::Smtp(SmtpEvent::MtPriorityInvalid), + EventType::Smtp(SmtpEvent::DsnDisabled), + EventType::Smtp(SmtpEvent::AuthNotAllowed), + EventType::Smtp(SmtpEvent::AuthMechanismNotSupported), + EventType::Smtp(SmtpEvent::AuthExchangeTooLong), + EventType::Smtp(SmtpEvent::AlreadyAuthenticated), + EventType::Smtp(SmtpEvent::Noop), + EventType::Smtp(SmtpEvent::StartTls), + EventType::Smtp(SmtpEvent::StartTlsUnavailable), + EventType::Smtp(SmtpEvent::StartTlsAlready), + EventType::Smtp(SmtpEvent::Rset), + EventType::Smtp(SmtpEvent::Quit), + EventType::Smtp(SmtpEvent::Help), + EventType::Smtp(SmtpEvent::CommandNotImplemented), + EventType::Smtp(SmtpEvent::InvalidCommand), + EventType::Smtp(SmtpEvent::InvalidSenderAddress), + EventType::Smtp(SmtpEvent::InvalidRecipientAddress), + EventType::Smtp(SmtpEvent::InvalidParameter), + EventType::Smtp(SmtpEvent::UnsupportedParameter), + EventType::Smtp(SmtpEvent::SyntaxError), + EventType::Smtp(SmtpEvent::RequestTooLarge), + EventType::Spam(SpamEvent::Pyzor), + EventType::Spam(SpamEvent::PyzorError), + EventType::Spam(SpamEvent::Dnsbl), + EventType::Spam(SpamEvent::DnsblError), + EventType::Spam(SpamEvent::TrainStarted), + EventType::Spam(SpamEvent::TrainCompleted), + EventType::Spam(SpamEvent::TrainSampleAdded), + EventType::Spam(SpamEvent::TrainSampleNotFound), + EventType::Spam(SpamEvent::Classify), + EventType::Spam(SpamEvent::ModelLoaded), + EventType::Spam(SpamEvent::ModelNotReady), + EventType::Spam(SpamEvent::ModelNotFound), + EventType::Spam(SpamEvent::RulesUpdated), + EventType::Spf(SpfEvent::Pass), + EventType::Spf(SpfEvent::Fail), + EventType::Spf(SpfEvent::SoftFail), + EventType::Spf(SpfEvent::Neutral), + EventType::Spf(SpfEvent::TempError), + EventType::Spf(SpfEvent::PermError), + EventType::Spf(SpfEvent::None), + EventType::Store(StoreEvent::AssertValueFailed), + EventType::Store(StoreEvent::FoundationdbError), + EventType::Store(StoreEvent::MysqlError), + EventType::Store(StoreEvent::PostgresqlError), + EventType::Store(StoreEvent::RocksdbError), + EventType::Store(StoreEvent::SqliteError), + EventType::Store(StoreEvent::LdapError), + EventType::Store(StoreEvent::ElasticsearchError), + EventType::Store(StoreEvent::MeilisearchError), + EventType::Store(StoreEvent::RedisError), + EventType::Store(StoreEvent::S3Error), + EventType::Store(StoreEvent::AzureError), + EventType::Store(StoreEvent::FilesystemError), + EventType::Store(StoreEvent::PoolError), + EventType::Store(StoreEvent::DataCorruption), + EventType::Store(StoreEvent::DecompressError), + EventType::Store(StoreEvent::DeserializeError), + EventType::Store(StoreEvent::NotFound), + EventType::Store(StoreEvent::NotConfigured), + EventType::Store(StoreEvent::NotSupported), + EventType::Store(StoreEvent::UnexpectedError), + EventType::Store(StoreEvent::CryptoError), + EventType::Store(StoreEvent::HttpStoreError), + EventType::Store(StoreEvent::CacheMiss), + EventType::Store(StoreEvent::CacheHit), + EventType::Store(StoreEvent::CacheStale), + EventType::Store(StoreEvent::CacheUpdate), + EventType::Store(StoreEvent::CacheEntryTooLarge), + EventType::Store(StoreEvent::BlobMissingMarker), + EventType::Store(StoreEvent::DataWrite), + EventType::Store(StoreEvent::DataIterate), + EventType::Store(StoreEvent::BlobRead), + EventType::Store(StoreEvent::BlobWrite), + EventType::Store(StoreEvent::BlobDelete), + EventType::Store(StoreEvent::SqlQuery), + EventType::Store(StoreEvent::LdapQuery), + EventType::Store(StoreEvent::LdapWarning), + EventType::Store(StoreEvent::HttpStoreFetch), + EventType::Store(StoreEvent::AutoExpunge), + EventType::Store(StoreEvent::BlobStorePurged), + EventType::Store(StoreEvent::DataStorePurged), + EventType::TaskManager(TaskManagerEvent::TaskAcquired), + EventType::TaskManager(TaskManagerEvent::TaskQueued), + EventType::TaskManager(TaskManagerEvent::TaskScheduled), + EventType::TaskManager(TaskManagerEvent::TaskLocked), + EventType::TaskManager(TaskManagerEvent::TaskIgnored), + EventType::TaskManager(TaskManagerEvent::TaskFailed), + EventType::TaskManager(TaskManagerEvent::TaskRetry), + EventType::TaskManager(TaskManagerEvent::BlobNotFound), + EventType::TaskManager(TaskManagerEvent::MetadataNotFound), + EventType::TaskManager(TaskManagerEvent::SchedulerStarted), + EventType::TaskManager(TaskManagerEvent::ManagerStarted), + EventType::Telemetry(TelemetryEvent::AlertEvent), + EventType::Telemetry(TelemetryEvent::AlertMessage), + EventType::Telemetry(TelemetryEvent::LogError), + EventType::Telemetry(TelemetryEvent::WebhookError), + EventType::Telemetry(TelemetryEvent::OtelExporterError), + EventType::Telemetry(TelemetryEvent::OtelMetricsExporterError), + EventType::Telemetry(TelemetryEvent::PrometheusExporterError), + EventType::Telemetry(TelemetryEvent::JournalError), + EventType::Telemetry(TelemetryEvent::MetricsCollected), + EventType::Telemetry(TelemetryEvent::MetricsStored), + EventType::Telemetry(TelemetryEvent::MetricsPushed), + EventType::Tls(TlsEvent::Handshake), + EventType::Tls(TlsEvent::HandshakeError), + EventType::Tls(TlsEvent::NotConfigured), + EventType::Tls(TlsEvent::CertificateNotFound), + EventType::Tls(TlsEvent::NoCertificatesAvailable), + EventType::Tls(TlsEvent::MultipleCertificatesAvailable), + EventType::Tls(TlsEvent::ExpiredCertificateRemoved), + EventType::TlsRpt(TlsRptEvent::RecordFetch), + EventType::TlsRpt(TlsRptEvent::RecordFetchError), + EventType::TlsRpt(TlsRptEvent::RecordNotFound), + EventType::WebDav(WebDavEvent::Propfind), + EventType::WebDav(WebDavEvent::Proppatch), + EventType::WebDav(WebDavEvent::Get), + EventType::WebDav(WebDavEvent::Head), + EventType::WebDav(WebDavEvent::Report), + EventType::WebDav(WebDavEvent::Mkcol), + EventType::WebDav(WebDavEvent::Mkcalendar), + EventType::WebDav(WebDavEvent::Delete), + EventType::WebDav(WebDavEvent::Put), + EventType::WebDav(WebDavEvent::Post), + EventType::WebDav(WebDavEvent::Patch), + EventType::WebDav(WebDavEvent::Copy), + EventType::WebDav(WebDavEvent::Move), + EventType::WebDav(WebDavEvent::Lock), + EventType::WebDav(WebDavEvent::Unlock), + EventType::WebDav(WebDavEvent::Acl), + EventType::WebDav(WebDavEvent::Options), + EventType::WebDav(WebDavEvent::Error), + ] + } +} + +impl MetricType { + pub fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + MetricType, + b"acme.auth-error" => MetricType::AcmeAuthError, + b"acme.auth-too-many-attempts" => MetricType::AcmeAuthTooManyAttempts, + b"acme.order-completed" => MetricType::AcmeOrderCompleted, + b"acme.order-invalid" => MetricType::AcmeOrderInvalid, + b"acme.client-missing-sni" => MetricType::AcmeClientMissingSni, + b"acme.tls-alpn-error" => MetricType::AcmeTlsAlpnError, + b"acme.token-not-found" => MetricType::AcmeTokenNotFound, + b"acme.error" => MetricType::AcmeError, + b"arc.chain-too-long" => MetricType::ArcChainTooLong, + b"arc.invalid-instance" => MetricType::ArcInvalidInstance, + b"arc.invalid-cv" => MetricType::ArcInvalidCv, + b"arc.has-header-tag" => MetricType::ArcHasHeaderTag, + b"arc.broken-chain" => MetricType::ArcBrokenChain, + b"auth.success" => MetricType::AuthSuccess, + b"auth.failed" => MetricType::AuthFailed, + b"auth.too-many-attempts" => MetricType::AuthTooManyAttempts, + b"auth.error" => MetricType::AuthError, + b"calendar.alarm-sent" => MetricType::CalendarAlarmSent, + b"calendar.alarm-failed" => MetricType::CalendarAlarmFailed, + b"calendar.itip-message-sent" => MetricType::CalendarItipMessageSent, + b"calendar.itip-message-received" => MetricType::CalendarItipMessageReceived, + b"calendar.itip-message-error" => MetricType::CalendarItipMessageError, + b"cluster.subscriber-error" => MetricType::ClusterSubscriberError, + b"cluster.subscriber-disconnected" => MetricType::ClusterSubscriberDisconnected, + b"cluster.publisher-error" => MetricType::ClusterPublisherError, + b"dane.authentication-success" => MetricType::DaneAuthenticationSuccess, + b"dane.authentication-failure" => MetricType::DaneAuthenticationFailure, + b"dane.no-certificates-found" => MetricType::DaneNoCertificatesFound, + b"dane.certificate-parse-error" => MetricType::DaneCertificateParseError, + b"dane.tlsa-record-fetch-error" => MetricType::DaneTlsaRecordFetchError, + b"dane.tlsa-record-not-found" => MetricType::DaneTlsaRecordNotFound, + b"dane.tlsa-record-not-dnssec-signed" => MetricType::DaneTlsaRecordNotDnssecSigned, + b"dane.tlsa-record-invalid" => MetricType::DaneTlsaRecordInvalid, + b"dane.bogus-dnssec-record" => MetricType::DaneBogusDnssecRecord, + b"delivery.total-time" => MetricType::DeliveryTotalTime, + b"delivery.attempt-time" => MetricType::DeliveryAttemptTime, + b"delivery.active-connections" => MetricType::DeliveryActiveConnections, + b"delivery.attempt-start" => MetricType::DeliveryAttemptStart, + b"delivery.attempt-end" => MetricType::DeliveryAttemptEnd, + b"delivery.completed" => MetricType::DeliveryCompleted, + b"delivery.mx-lookup-failed" => MetricType::DeliveryMxLookupFailed, + b"delivery.ip-lookup-failed" => MetricType::DeliveryIpLookupFailed, + b"delivery.null-mx" => MetricType::DeliveryNullMx, + b"delivery.greeting-failed" => MetricType::DeliveryGreetingFailed, + b"delivery.ehlo-rejected" => MetricType::DeliveryEhloRejected, + b"delivery.auth-failed" => MetricType::DeliveryAuthFailed, + b"delivery.mail-from-rejected" => MetricType::DeliveryMailFromRejected, + b"delivery.delivered" => MetricType::DeliveryDelivered, + b"delivery.rcpt-to-rejected" => MetricType::DeliveryRcptToRejected, + b"delivery.rcpt-to-failed" => MetricType::DeliveryRcptToFailed, + b"delivery.message-rejected" => MetricType::DeliveryMessageRejected, + b"delivery.start-tls-unavailable" => MetricType::DeliveryStartTlsUnavailable, + b"delivery.start-tls-error" => MetricType::DeliveryStartTlsError, + b"delivery.start-tls-disabled" => MetricType::DeliveryStartTlsDisabled, + b"delivery.implicit-tls-error" => MetricType::DeliveryImplicitTlsError, + b"delivery.concurrency-limit-exceeded" => MetricType::DeliveryConcurrencyLimitExceeded, + b"delivery.rate-limit-exceeded" => MetricType::DeliveryRateLimitExceeded, + b"delivery.double-bounce" => MetricType::DeliveryDoubleBounce, + b"delivery.dsn-success" => MetricType::DeliveryDsnSuccess, + b"delivery.dsn-temp-fail" => MetricType::DeliveryDsnTempFail, + b"delivery.dsn-perm-fail" => MetricType::DeliveryDsnPermFail, + b"dkim.pass" => MetricType::DkimPass, + b"dkim.neutral" => MetricType::DkimNeutral, + b"dkim.fail" => MetricType::DkimFail, + b"dkim.perm-error" => MetricType::DkimPermError, + b"dkim.temp-error" => MetricType::DkimTempError, + b"dkim.none" => MetricType::DkimNone, + b"dkim.unsupported-version" => MetricType::DkimUnsupportedVersion, + b"dkim.unsupported-algorithm" => MetricType::DkimUnsupportedAlgorithm, + b"dkim.unsupported-canonicalization" => MetricType::DkimUnsupportedCanonicalization, + b"dkim.unsupported-key-type" => MetricType::DkimUnsupportedKeyType, + b"dkim.failed-body-hash-match" => MetricType::DkimFailedBodyHashMatch, + b"dkim.failed-verification" => MetricType::DkimFailedVerification, + b"dkim.failed-auid-match" => MetricType::DkimFailedAuidMatch, + b"dkim.revoked-public-key" => MetricType::DkimRevokedPublicKey, + b"dkim.incompatible-algorithms" => MetricType::DkimIncompatibleAlgorithms, + b"dkim.signature-expired" => MetricType::DkimSignatureExpired, + b"dkim.signature-length" => MetricType::DkimSignatureLength, + b"dkim.signer-not-found" => MetricType::DkimSignerNotFound, + b"dkim.instance-missing" => MetricType::DkimInstanceMissing, + b"dkim.instance-syntax" => MetricType::DkimInstanceSyntax, + b"dkim.instance-tag-missing" => MetricType::DkimInstanceTagMissing, + b"dkim.instance-not-signed" => MetricType::DkimInstanceNotSigned, + b"dkim.instance-above-signature" => MetricType::DkimInstanceAboveSignature, + b"dkim.signature-missing" => MetricType::DkimSignatureMissing, + b"dkim.signature-syntax" => MetricType::DkimSignatureSyntax, + b"dkim.signature-tag-missing" => MetricType::DkimSignatureTagMissing, + b"dkim.signature-tag-unexpected" => MetricType::DkimSignatureTagUnexpected, + b"dkim.sequence-gap" => MetricType::DkimSequenceGap, + b"dkim.sequence-overflow" => MetricType::DkimSequenceOverflow, + b"dkim.chain-too-long" => MetricType::DkimChainTooLong, + b"dkim.mail-from-mismatch" => MetricType::DkimMailFromMismatch, + b"dkim.rcpt-to-mismatch" => MetricType::DkimRcptToMismatch, + b"dkim.mail-from-domain-mismatch" => MetricType::DkimMailFromDomainMismatch, + b"dkim.next-domain-mismatch" => MetricType::DkimNextDomainMismatch, + b"dkim.custody-break" => MetricType::DkimCustodyBreak, + b"dkim.public-key-fetch" => MetricType::DkimPublicKeyFetch, + b"dkim.public-key-missing" => MetricType::DkimPublicKeyMissing, + b"dkim.public-key-multiple" => MetricType::DkimPublicKeyMultiple, + b"dkim.public-key-syntax" => MetricType::DkimPublicKeySyntax, + b"dkim.public-key-algorithm-mismatch" => MetricType::DkimPublicKeyAlgorithmMismatch, + b"dkim.no-valid-algorithm" => MetricType::DkimNoValidAlgorithm, + b"dkim.header-hash-mismatch" => MetricType::DkimHeaderHashMismatch, + b"dkim.modified" => MetricType::DkimModified, + b"dkim.exploded" => MetricType::DkimExploded, + b"dmarc.pass" => MetricType::DmarcPass, + b"dmarc.fail" => MetricType::DmarcFail, + b"dmarc.perm-error" => MetricType::DmarcPermError, + b"dmarc.temp-error" => MetricType::DmarcTempError, + b"dmarc.none" => MetricType::DmarcNone, + b"dns.lookup-time" => MetricType::DnsLookupTime, + b"dns.record-creation-failed" => MetricType::DnsRecordCreationFailed, + b"dns.record-deletion-failed" => MetricType::DnsRecordDeletionFailed, + b"dns.record-lookup-failed" => MetricType::DnsRecordLookupFailed, + b"dns.record-propagation-timeout" => MetricType::DnsRecordPropagationTimeout, + b"domain.count" => MetricType::DomainCount, + b"eval.error" => MetricType::EvalError, + b"eval.directory-not-found" => MetricType::EvalDirectoryNotFound, + b"eval.store-not-found" => MetricType::EvalStoreNotFound, + b"http.request-time" => MetricType::HttpRequestTime, + b"http.active-connections" => MetricType::HttpActiveConnections, + b"http.connection-start" => MetricType::HttpConnectionStart, + b"http.error" => MetricType::HttpError, + b"http.request-body" => MetricType::HttpRequestBody, + b"http.response-body" => MetricType::HttpResponseBody, + b"http.x-forwarded-missing" => MetricType::HttpXForwardedMissing, + b"imap.request-time" => MetricType::ImapRequestTime, + b"imap.active-connections" => MetricType::ImapActiveConnections, + b"imap.connection-start" => MetricType::ImapConnectionStart, + b"imap.connection-end" => MetricType::ImapConnectionEnd, + b"incoming-report.dmarc-report" => MetricType::IncomingReportDmarcReport, + b"incoming-report.dmarc-report-with-warnings" => MetricType::IncomingReportDmarcReportWithWarnings, + b"incoming-report.tls-report" => MetricType::IncomingReportTlsReport, + b"incoming-report.tls-report-with-warnings" => MetricType::IncomingReportTlsReportWithWarnings, + b"incoming-report.abuse-report" => MetricType::IncomingReportAbuseReport, + b"incoming-report.auth-failure-report" => MetricType::IncomingReportAuthFailureReport, + b"incoming-report.fraud-report" => MetricType::IncomingReportFraudReport, + b"incoming-report.not-spam-report" => MetricType::IncomingReportNotSpamReport, + b"incoming-report.virus-report" => MetricType::IncomingReportVirusReport, + b"incoming-report.other-report" => MetricType::IncomingReportOtherReport, + b"incoming-report.message-parse-failed" => MetricType::IncomingReportMessageParseFailed, + b"incoming-report.dmarc-parse-failed" => MetricType::IncomingReportDmarcParseFailed, + b"incoming-report.tls-rpc-parse-failed" => MetricType::IncomingReportTlsRpcParseFailed, + b"incoming-report.arf-parse-failed" => MetricType::IncomingReportArfParseFailed, + b"incoming-report.decompress-error" => MetricType::IncomingReportDecompressError, + b"iprev.pass" => MetricType::IprevPass, + b"iprev.fail" => MetricType::IprevFail, + b"iprev.perm-error" => MetricType::IprevPermError, + b"iprev.temp-error" => MetricType::IprevTempError, + b"iprev.none" => MetricType::IprevNone, + b"jmap.method-call" => MetricType::JmapMethodCall, + b"jmap.invalid-arguments" => MetricType::JmapInvalidArguments, + b"jmap.request-too-large" => MetricType::JmapRequestTooLarge, + b"jmap.unsupported-filter" => MetricType::JmapUnsupportedFilter, + b"jmap.unsupported-sort" => MetricType::JmapUnsupportedSort, + b"jmap.unknown-method" => MetricType::JmapUnknownMethod, + b"jmap.forbidden" => MetricType::JmapForbidden, + b"jmap.not-json" => MetricType::JmapNotJson, + b"jmap.not-request" => MetricType::JmapNotRequest, + b"jmap.websocket-start" => MetricType::JmapWebsocketStart, + b"jmap.websocket-error" => MetricType::JmapWebsocketError, + b"limit.size-request" => MetricType::LimitSizeRequest, + b"limit.size-upload" => MetricType::LimitSizeUpload, + b"limit.calls-in" => MetricType::LimitCallsIn, + b"limit.concurrent-request" => MetricType::LimitConcurrentRequest, + b"limit.concurrent-upload" => MetricType::LimitConcurrentUpload, + b"limit.concurrent-connection" => MetricType::LimitConcurrentConnection, + b"limit.quota" => MetricType::LimitQuota, + b"limit.blob-quota" => MetricType::LimitBlobQuota, + b"limit.tenant-quota" => MetricType::LimitTenantQuota, + b"limit.too-many-requests" => MetricType::LimitTooManyRequests, + b"mail-auth.parse-error" => MetricType::MailAuthParseError, + b"mail-auth.missing-parameters" => MetricType::MailAuthMissingParameters, + b"mail-auth.no-headers-found" => MetricType::MailAuthNoHeadersFound, + b"mail-auth.crypto" => MetricType::MailAuthCrypto, + b"mail-auth.io" => MetricType::MailAuthIo, + b"mail-auth.base64" => MetricType::MailAuthBase64, + b"mail-auth.dns-error" => MetricType::MailAuthDnsError, + b"mail-auth.dns-record-not-found" => MetricType::MailAuthDnsRecordNotFound, + b"mail-auth.dns-invalid-record-type" => MetricType::MailAuthDnsInvalidRecordType, + b"mail-auth.policy-not-aligned" => MetricType::MailAuthPolicyNotAligned, + b"manage-sieve.connection-start" => MetricType::ManageSieveConnectionStart, + b"manage-sieve.connection-end" => MetricType::ManageSieveConnectionEnd, + b"message.size" => MetricType::MessageSize, + b"message.authenticated-size" => MetricType::MessageAuthenticatedSize, + b"message-ingest.time" => MetricType::MessageIngestTime, + b"message-ingest.index-time" => MetricType::MessageIngestIndexTime, + b"message-ingest.ham" => MetricType::MessageIngestHam, + b"message-ingest.spam" => MetricType::MessageIngestSpam, + b"message-ingest.imap-append" => MetricType::MessageIngestImapAppend, + b"message-ingest.jmap-append" => MetricType::MessageIngestJmapAppend, + b"message-ingest.duplicate" => MetricType::MessageIngestDuplicate, + b"message-ingest.error" => MetricType::MessageIngestError, + b"message-ingest.search-index" => MetricType::MessageIngestSearchIndex, + b"milter.action-accept" => MetricType::MilterActionAccept, + b"milter.action-discard" => MetricType::MilterActionDiscard, + b"milter.action-reject" => MetricType::MilterActionReject, + b"milter.action-temp-fail" => MetricType::MilterActionTempFail, + b"milter.action-reply-code" => MetricType::MilterActionReplyCode, + b"milter.action-connection-failure" => MetricType::MilterActionConnectionFailure, + b"milter.action-shutdown" => MetricType::MilterActionShutdown, + b"mta-hook.action-accept" => MetricType::MtaHookActionAccept, + b"mta-hook.action-discard" => MetricType::MtaHookActionDiscard, + b"mta-hook.action-reject" => MetricType::MtaHookActionReject, + b"mta-hook.action-quarantine" => MetricType::MtaHookActionQuarantine, + b"mta-hook.error" => MetricType::MtaHookError, + b"mta-sts.authorized" => MetricType::MtaStsAuthorized, + b"mta-sts.not-authorized" => MetricType::MtaStsNotAuthorized, + b"mta-sts.invalid-policy" => MetricType::MtaStsInvalidPolicy, + b"network.timeout" => MetricType::NetworkTimeout, + b"outgoing-report.size" => MetricType::OutgoingReportSize, + b"outgoing-report.spf-report" => MetricType::OutgoingReportSpfReport, + b"outgoing-report.spf-rate-limited" => MetricType::OutgoingReportSpfRateLimited, + b"outgoing-report.dkim-report" => MetricType::OutgoingReportDkimReport, + b"outgoing-report.dkim-rate-limited" => MetricType::OutgoingReportDkimRateLimited, + b"outgoing-report.dmarc-report" => MetricType::OutgoingReportDmarcReport, + b"outgoing-report.dmarc-rate-limited" => MetricType::OutgoingReportDmarcRateLimited, + b"outgoing-report.dmarc-aggregate-report" => MetricType::OutgoingReportDmarcAggregateReport, + b"outgoing-report.tls-aggregate" => MetricType::OutgoingReportTlsAggregate, + b"outgoing-report.http-submission" => MetricType::OutgoingReportHttpSubmission, + b"outgoing-report.unauthorized-reporting-address" => MetricType::OutgoingReportUnauthorizedReportingAddress, + b"outgoing-report.reporting-address-validation-error" => MetricType::OutgoingReportReportingAddressValidationError, + b"outgoing-report.not-found" => MetricType::OutgoingReportNotFound, + b"outgoing-report.submission-error" => MetricType::OutgoingReportSubmissionError, + b"outgoing-report.no-recipients-found" => MetricType::OutgoingReportNoRecipientsFound, + b"pop3.request-time" => MetricType::Pop3RequestTime, + b"pop3.active-connections" => MetricType::Pop3ActiveConnections, + b"pop3.connection-start" => MetricType::Pop3ConnectionStart, + b"pop3.connection-end" => MetricType::Pop3ConnectionEnd, + b"push-subscription.success" => MetricType::PushSubscriptionSuccess, + b"push-subscription.error" => MetricType::PushSubscriptionError, + b"push-subscription.not-found" => MetricType::PushSubscriptionNotFound, + b"queue.count" => MetricType::QueueCount, + b"queue.message-queued" => MetricType::QueueMessageQueued, + b"queue.authenticated-message-queued" => MetricType::QueueAuthenticatedMessageQueued, + b"queue.report-queued" => MetricType::QueueReportQueued, + b"queue.dsn-queued" => MetricType::QueueDsnQueued, + b"queue.autogenerated-queued" => MetricType::QueueAutogeneratedQueued, + b"queue.rescheduled" => MetricType::QueueRescheduled, + b"queue.blob-not-found" => MetricType::QueueBlobNotFound, + b"queue.rate-limit-exceeded" => MetricType::QueueRateLimitExceeded, + b"queue.concurrency-limit-exceeded" => MetricType::QueueConcurrencyLimitExceeded, + b"queue.quota-exceeded" => MetricType::QueueQuotaExceeded, + b"resource.not-found" => MetricType::ResourceNotFound, + b"resource.bad-parameters" => MetricType::ResourceBadParameters, + b"resource.error" => MetricType::ResourceError, + b"security.authentication-ban" => MetricType::SecurityAuthenticationBan, + b"security.abuse-ban" => MetricType::SecurityAbuseBan, + b"security.scan-ban" => MetricType::SecurityScanBan, + b"security.loiter-ban" => MetricType::SecurityLoiterBan, + b"security.ip-blocked" => MetricType::SecurityIpBlocked, + b"security.unauthorized" => MetricType::SecurityUnauthorized, + b"server.memory" => MetricType::ServerMemory, + b"server.thread-error" => MetricType::ServerThreadError, + b"sieve.request-time" => MetricType::SieveRequestTime, + b"sieve.active-connections" => MetricType::SieveActiveConnections, + b"sieve.action-accept" => MetricType::SieveActionAccept, + b"sieve.action-accept-replace" => MetricType::SieveActionAcceptReplace, + b"sieve.action-discard" => MetricType::SieveActionDiscard, + b"sieve.action-reject" => MetricType::SieveActionReject, + b"sieve.send-message" => MetricType::SieveSendMessage, + b"sieve.message-too-large" => MetricType::SieveMessageTooLarge, + b"sieve.runtime-error" => MetricType::SieveRuntimeError, + b"sieve.unexpected-error" => MetricType::SieveUnexpectedError, + b"sieve.not-supported" => MetricType::SieveNotSupported, + b"sieve.quota-exceeded" => MetricType::SieveQuotaExceeded, + b"smtp.request-time" => MetricType::SmtpRequestTime, + b"smtp.active-connections" => MetricType::SmtpActiveConnections, + b"smtp.connection-start" => MetricType::SmtpConnectionStart, + b"smtp.connection-end" => MetricType::SmtpConnectionEnd, + b"smtp.error" => MetricType::SmtpError, + b"smtp.concurrency-limit-exceeded" => MetricType::SmtpConcurrencyLimitExceeded, + b"smtp.transfer-limit-exceeded" => MetricType::SmtpTransferLimitExceeded, + b"smtp.rate-limit-exceeded" => MetricType::SmtpRateLimitExceeded, + b"smtp.time-limit-exceeded" => MetricType::SmtpTimeLimitExceeded, + b"smtp.message-parse-failed" => MetricType::SmtpMessageParseFailed, + b"smtp.message-too-large" => MetricType::SmtpMessageTooLarge, + b"smtp.loop-detected" => MetricType::SmtpLoopDetected, + b"smtp.dkim-pass" => MetricType::SmtpDkimPass, + b"smtp.dkim-fail" => MetricType::SmtpDkimFail, + b"smtp.dkim2-pass" => MetricType::SmtpDkim2Pass, + b"smtp.dkim2-fail" => MetricType::SmtpDkim2Fail, + b"smtp.dkim2-dsn-discarded" => MetricType::SmtpDkim2DsnDiscarded, + b"smtp.arc-pass" => MetricType::SmtpArcPass, + b"smtp.arc-fail" => MetricType::SmtpArcFail, + b"smtp.spf-ehlo-pass" => MetricType::SmtpSpfEhloPass, + b"smtp.spf-ehlo-fail" => MetricType::SmtpSpfEhloFail, + b"smtp.spf-from-pass" => MetricType::SmtpSpfFromPass, + b"smtp.spf-from-fail" => MetricType::SmtpSpfFromFail, + b"smtp.dmarc-pass" => MetricType::SmtpDmarcPass, + b"smtp.dmarc-fail" => MetricType::SmtpDmarcFail, + b"smtp.iprev-pass" => MetricType::SmtpIprevPass, + b"smtp.iprev-fail" => MetricType::SmtpIprevFail, + b"smtp.too-many-messages" => MetricType::SmtpTooManyMessages, + b"smtp.invalid-ehlo" => MetricType::SmtpInvalidEhlo, + b"smtp.did-not-say-ehlo" => MetricType::SmtpDidNotSayEhlo, + b"smtp.mail-from-unauthenticated" => MetricType::SmtpMailFromUnauthenticated, + b"smtp.mail-from-unauthorized" => MetricType::SmtpMailFromUnauthorized, + b"smtp.mail-from-missing" => MetricType::SmtpMailFromMissing, + b"smtp.multiple-mail-from" => MetricType::SmtpMultipleMailFrom, + b"smtp.mailbox-does-not-exist" => MetricType::SmtpMailboxDoesNotExist, + b"smtp.relay-not-allowed" => MetricType::SmtpRelayNotAllowed, + b"smtp.rcpt-to-duplicate" => MetricType::SmtpRcptToDuplicate, + b"smtp.rcpt-to-missing" => MetricType::SmtpRcptToMissing, + b"smtp.too-many-recipients" => MetricType::SmtpTooManyRecipients, + b"smtp.too-many-invalid-rcpt" => MetricType::SmtpTooManyInvalidRcpt, + b"smtp.auth-mechanism-not-supported" => MetricType::SmtpAuthMechanismNotSupported, + b"smtp.auth-exchange-too-long" => MetricType::SmtpAuthExchangeTooLong, + b"smtp.command-not-implemented" => MetricType::SmtpCommandNotImplemented, + b"smtp.invalid-command" => MetricType::SmtpInvalidCommand, + b"smtp.syntax-error" => MetricType::SmtpSyntaxError, + b"smtp.request-too-large" => MetricType::SmtpRequestTooLarge, + b"spam.pyzor-error" => MetricType::SpamPyzorError, + b"spam.dnsbl-error" => MetricType::SpamDnsblError, + b"spam.train-completed" => MetricType::SpamTrainCompleted, + b"spam.train-sample-added" => MetricType::SpamTrainSampleAdded, + b"spam.classify" => MetricType::SpamClassify, + b"spam.model-not-ready" => MetricType::SpamModelNotReady, + b"spf.pass" => MetricType::SpfPass, + b"spf.fail" => MetricType::SpfFail, + b"spf.soft-fail" => MetricType::SpfSoftFail, + b"spf.neutral" => MetricType::SpfNeutral, + b"spf.temp-error" => MetricType::SpfTempError, + b"spf.perm-error" => MetricType::SpfPermError, + b"spf.none" => MetricType::SpfNone, + b"store.data-read-time" => MetricType::StoreDataReadTime, + b"store.data-write-time" => MetricType::StoreDataWriteTime, + b"store.blob-read-time" => MetricType::StoreBlobReadTime, + b"store.blob-write-time" => MetricType::StoreBlobWriteTime, + b"store.assert-value-failed" => MetricType::StoreAssertValueFailed, + b"store.foundationdb-error" => MetricType::StoreFoundationdbError, + b"store.mysql-error" => MetricType::StoreMysqlError, + b"store.postgresql-error" => MetricType::StorePostgresqlError, + b"store.rocksdb-error" => MetricType::StoreRocksdbError, + b"store.sqlite-error" => MetricType::StoreSqliteError, + b"store.ldap-error" => MetricType::StoreLdapError, + b"store.elasticsearch-error" => MetricType::StoreElasticsearchError, + b"store.redis-error" => MetricType::StoreRedisError, + b"store.s3-error" => MetricType::StoreS3Error, + b"store.azure-error" => MetricType::StoreAzureError, + b"store.filesystem-error" => MetricType::StoreFilesystemError, + b"store.pool-error" => MetricType::StorePoolError, + b"store.data-corruption" => MetricType::StoreDataCorruption, + b"store.decompress-error" => MetricType::StoreDecompressError, + b"store.deserialize-error" => MetricType::StoreDeserializeError, + b"store.not-found" => MetricType::StoreNotFound, + b"store.not-configured" => MetricType::StoreNotConfigured, + b"store.not-supported" => MetricType::StoreNotSupported, + b"store.unexpected-error" => MetricType::StoreUnexpectedError, + b"store.crypto-error" => MetricType::StoreCryptoError, + b"store.http-store-error" => MetricType::StoreHttpStoreError, + b"store.blob-missing-marker" => MetricType::StoreBlobMissingMarker, + b"store.data-write" => MetricType::StoreDataWrite, + b"store.data-iterate" => MetricType::StoreDataIterate, + b"store.blob-read" => MetricType::StoreBlobRead, + b"store.blob-write" => MetricType::StoreBlobWrite, + b"store.blob-delete" => MetricType::StoreBlobDelete, + b"task-manager.blob-not-found" => MetricType::TaskManagerBlobNotFound, + b"task-manager.metadata-not-found" => MetricType::TaskManagerMetadataNotFound, + b"telemetry.alert-event" => MetricType::TelemetryAlertEvent, + b"telemetry.alert-message" => MetricType::TelemetryAlertMessage, + b"telemetry.log-error" => MetricType::TelemetryLogError, + b"telemetry.webhook-error" => MetricType::TelemetryWebhookError, + b"telemetry.otel-exporter-error" => MetricType::TelemetryOtelExporterError, + b"telemetry.otel-metrics-exporter-error" => MetricType::TelemetryOtelMetricsExporterError, + b"telemetry.prometheus-exporter-error" => MetricType::TelemetryPrometheusExporterError, + b"telemetry.journal-error" => MetricType::TelemetryJournalError, + b"tls.handshake-error" => MetricType::TlsHandshakeError, + b"user.count" => MetricType::UserCount, + } + .copied() + } + + pub fn as_str(&self) -> &'static str { + match self { + MetricType::AcmeAuthError => "acme.auth-error", + MetricType::AcmeAuthTooManyAttempts => "acme.auth-too-many-attempts", + MetricType::AcmeOrderCompleted => "acme.order-completed", + MetricType::AcmeOrderInvalid => "acme.order-invalid", + MetricType::AcmeClientMissingSni => "acme.client-missing-sni", + MetricType::AcmeTlsAlpnError => "acme.tls-alpn-error", + MetricType::AcmeTokenNotFound => "acme.token-not-found", + MetricType::AcmeError => "acme.error", + MetricType::ArcChainTooLong => "arc.chain-too-long", + MetricType::ArcInvalidInstance => "arc.invalid-instance", + MetricType::ArcInvalidCv => "arc.invalid-cv", + MetricType::ArcHasHeaderTag => "arc.has-header-tag", + MetricType::ArcBrokenChain => "arc.broken-chain", + MetricType::AuthSuccess => "auth.success", + MetricType::AuthFailed => "auth.failed", + MetricType::AuthTooManyAttempts => "auth.too-many-attempts", + MetricType::AuthError => "auth.error", + MetricType::CalendarAlarmSent => "calendar.alarm-sent", + MetricType::CalendarAlarmFailed => "calendar.alarm-failed", + MetricType::CalendarItipMessageSent => "calendar.itip-message-sent", + MetricType::CalendarItipMessageReceived => "calendar.itip-message-received", + MetricType::CalendarItipMessageError => "calendar.itip-message-error", + MetricType::ClusterSubscriberError => "cluster.subscriber-error", + MetricType::ClusterSubscriberDisconnected => "cluster.subscriber-disconnected", + MetricType::ClusterPublisherError => "cluster.publisher-error", + MetricType::DaneAuthenticationSuccess => "dane.authentication-success", + MetricType::DaneAuthenticationFailure => "dane.authentication-failure", + MetricType::DaneNoCertificatesFound => "dane.no-certificates-found", + MetricType::DaneCertificateParseError => "dane.certificate-parse-error", + MetricType::DaneTlsaRecordFetchError => "dane.tlsa-record-fetch-error", + MetricType::DaneTlsaRecordNotFound => "dane.tlsa-record-not-found", + MetricType::DaneTlsaRecordNotDnssecSigned => "dane.tlsa-record-not-dnssec-signed", + MetricType::DaneTlsaRecordInvalid => "dane.tlsa-record-invalid", + MetricType::DaneBogusDnssecRecord => "dane.bogus-dnssec-record", + MetricType::DeliveryTotalTime => "delivery.total-time", + MetricType::DeliveryAttemptTime => "delivery.attempt-time", + MetricType::DeliveryActiveConnections => "delivery.active-connections", + MetricType::DeliveryAttemptStart => "delivery.attempt-start", + MetricType::DeliveryAttemptEnd => "delivery.attempt-end", + MetricType::DeliveryCompleted => "delivery.completed", + MetricType::DeliveryMxLookupFailed => "delivery.mx-lookup-failed", + MetricType::DeliveryIpLookupFailed => "delivery.ip-lookup-failed", + MetricType::DeliveryNullMx => "delivery.null-mx", + MetricType::DeliveryGreetingFailed => "delivery.greeting-failed", + MetricType::DeliveryEhloRejected => "delivery.ehlo-rejected", + MetricType::DeliveryAuthFailed => "delivery.auth-failed", + MetricType::DeliveryMailFromRejected => "delivery.mail-from-rejected", + MetricType::DeliveryDelivered => "delivery.delivered", + MetricType::DeliveryRcptToRejected => "delivery.rcpt-to-rejected", + MetricType::DeliveryRcptToFailed => "delivery.rcpt-to-failed", + MetricType::DeliveryMessageRejected => "delivery.message-rejected", + MetricType::DeliveryStartTlsUnavailable => "delivery.start-tls-unavailable", + MetricType::DeliveryStartTlsError => "delivery.start-tls-error", + MetricType::DeliveryStartTlsDisabled => "delivery.start-tls-disabled", + MetricType::DeliveryImplicitTlsError => "delivery.implicit-tls-error", + MetricType::DeliveryConcurrencyLimitExceeded => "delivery.concurrency-limit-exceeded", + MetricType::DeliveryRateLimitExceeded => "delivery.rate-limit-exceeded", + MetricType::DeliveryDoubleBounce => "delivery.double-bounce", + MetricType::DeliveryDsnSuccess => "delivery.dsn-success", + MetricType::DeliveryDsnTempFail => "delivery.dsn-temp-fail", + MetricType::DeliveryDsnPermFail => "delivery.dsn-perm-fail", + MetricType::DkimPass => "dkim.pass", + MetricType::DkimNeutral => "dkim.neutral", + MetricType::DkimFail => "dkim.fail", + MetricType::DkimPermError => "dkim.perm-error", + MetricType::DkimTempError => "dkim.temp-error", + MetricType::DkimNone => "dkim.none", + MetricType::DkimUnsupportedVersion => "dkim.unsupported-version", + MetricType::DkimUnsupportedAlgorithm => "dkim.unsupported-algorithm", + MetricType::DkimUnsupportedCanonicalization => "dkim.unsupported-canonicalization", + MetricType::DkimUnsupportedKeyType => "dkim.unsupported-key-type", + MetricType::DkimFailedBodyHashMatch => "dkim.failed-body-hash-match", + MetricType::DkimFailedVerification => "dkim.failed-verification", + MetricType::DkimFailedAuidMatch => "dkim.failed-auid-match", + MetricType::DkimRevokedPublicKey => "dkim.revoked-public-key", + MetricType::DkimIncompatibleAlgorithms => "dkim.incompatible-algorithms", + MetricType::DkimSignatureExpired => "dkim.signature-expired", + MetricType::DkimSignatureLength => "dkim.signature-length", + MetricType::DkimSignerNotFound => "dkim.signer-not-found", + MetricType::DkimInstanceMissing => "dkim.instance-missing", + MetricType::DkimInstanceSyntax => "dkim.instance-syntax", + MetricType::DkimInstanceTagMissing => "dkim.instance-tag-missing", + MetricType::DkimInstanceNotSigned => "dkim.instance-not-signed", + MetricType::DkimInstanceAboveSignature => "dkim.instance-above-signature", + MetricType::DkimSignatureMissing => "dkim.signature-missing", + MetricType::DkimSignatureSyntax => "dkim.signature-syntax", + MetricType::DkimSignatureTagMissing => "dkim.signature-tag-missing", + MetricType::DkimSignatureTagUnexpected => "dkim.signature-tag-unexpected", + MetricType::DkimSequenceGap => "dkim.sequence-gap", + MetricType::DkimSequenceOverflow => "dkim.sequence-overflow", + MetricType::DkimChainTooLong => "dkim.chain-too-long", + MetricType::DkimMailFromMismatch => "dkim.mail-from-mismatch", + MetricType::DkimRcptToMismatch => "dkim.rcpt-to-mismatch", + MetricType::DkimMailFromDomainMismatch => "dkim.mail-from-domain-mismatch", + MetricType::DkimNextDomainMismatch => "dkim.next-domain-mismatch", + MetricType::DkimCustodyBreak => "dkim.custody-break", + MetricType::DkimPublicKeyFetch => "dkim.public-key-fetch", + MetricType::DkimPublicKeyMissing => "dkim.public-key-missing", + MetricType::DkimPublicKeyMultiple => "dkim.public-key-multiple", + MetricType::DkimPublicKeySyntax => "dkim.public-key-syntax", + MetricType::DkimPublicKeyAlgorithmMismatch => "dkim.public-key-algorithm-mismatch", + MetricType::DkimNoValidAlgorithm => "dkim.no-valid-algorithm", + MetricType::DkimHeaderHashMismatch => "dkim.header-hash-mismatch", + MetricType::DkimModified => "dkim.modified", + MetricType::DkimExploded => "dkim.exploded", + MetricType::DmarcPass => "dmarc.pass", + MetricType::DmarcFail => "dmarc.fail", + MetricType::DmarcPermError => "dmarc.perm-error", + MetricType::DmarcTempError => "dmarc.temp-error", + MetricType::DmarcNone => "dmarc.none", + MetricType::DnsLookupTime => "dns.lookup-time", + MetricType::DnsRecordCreationFailed => "dns.record-creation-failed", + MetricType::DnsRecordDeletionFailed => "dns.record-deletion-failed", + MetricType::DnsRecordLookupFailed => "dns.record-lookup-failed", + MetricType::DnsRecordPropagationTimeout => "dns.record-propagation-timeout", + MetricType::DomainCount => "domain.count", + MetricType::EvalError => "eval.error", + MetricType::EvalDirectoryNotFound => "eval.directory-not-found", + MetricType::EvalStoreNotFound => "eval.store-not-found", + MetricType::HttpRequestTime => "http.request-time", + MetricType::HttpActiveConnections => "http.active-connections", + MetricType::HttpConnectionStart => "http.connection-start", + MetricType::HttpError => "http.error", + MetricType::HttpRequestBody => "http.request-body", + MetricType::HttpResponseBody => "http.response-body", + MetricType::HttpXForwardedMissing => "http.x-forwarded-missing", + MetricType::ImapRequestTime => "imap.request-time", + MetricType::ImapActiveConnections => "imap.active-connections", + MetricType::ImapConnectionStart => "imap.connection-start", + MetricType::ImapConnectionEnd => "imap.connection-end", + MetricType::IncomingReportDmarcReport => "incoming-report.dmarc-report", + MetricType::IncomingReportDmarcReportWithWarnings => { + "incoming-report.dmarc-report-with-warnings" + } + MetricType::IncomingReportTlsReport => "incoming-report.tls-report", + MetricType::IncomingReportTlsReportWithWarnings => { + "incoming-report.tls-report-with-warnings" + } + MetricType::IncomingReportAbuseReport => "incoming-report.abuse-report", + MetricType::IncomingReportAuthFailureReport => "incoming-report.auth-failure-report", + MetricType::IncomingReportFraudReport => "incoming-report.fraud-report", + MetricType::IncomingReportNotSpamReport => "incoming-report.not-spam-report", + MetricType::IncomingReportVirusReport => "incoming-report.virus-report", + MetricType::IncomingReportOtherReport => "incoming-report.other-report", + MetricType::IncomingReportMessageParseFailed => "incoming-report.message-parse-failed", + MetricType::IncomingReportDmarcParseFailed => "incoming-report.dmarc-parse-failed", + MetricType::IncomingReportTlsRpcParseFailed => "incoming-report.tls-rpc-parse-failed", + MetricType::IncomingReportArfParseFailed => "incoming-report.arf-parse-failed", + MetricType::IncomingReportDecompressError => "incoming-report.decompress-error", + MetricType::IprevPass => "iprev.pass", + MetricType::IprevFail => "iprev.fail", + MetricType::IprevPermError => "iprev.perm-error", + MetricType::IprevTempError => "iprev.temp-error", + MetricType::IprevNone => "iprev.none", + MetricType::JmapMethodCall => "jmap.method-call", + MetricType::JmapInvalidArguments => "jmap.invalid-arguments", + MetricType::JmapRequestTooLarge => "jmap.request-too-large", + MetricType::JmapUnsupportedFilter => "jmap.unsupported-filter", + MetricType::JmapUnsupportedSort => "jmap.unsupported-sort", + MetricType::JmapUnknownMethod => "jmap.unknown-method", + MetricType::JmapForbidden => "jmap.forbidden", + MetricType::JmapNotJson => "jmap.not-json", + MetricType::JmapNotRequest => "jmap.not-request", + MetricType::JmapWebsocketStart => "jmap.websocket-start", + MetricType::JmapWebsocketError => "jmap.websocket-error", + MetricType::LimitSizeRequest => "limit.size-request", + MetricType::LimitSizeUpload => "limit.size-upload", + MetricType::LimitCallsIn => "limit.calls-in", + MetricType::LimitConcurrentRequest => "limit.concurrent-request", + MetricType::LimitConcurrentUpload => "limit.concurrent-upload", + MetricType::LimitConcurrentConnection => "limit.concurrent-connection", + MetricType::LimitQuota => "limit.quota", + MetricType::LimitBlobQuota => "limit.blob-quota", + MetricType::LimitTenantQuota => "limit.tenant-quota", + MetricType::LimitTooManyRequests => "limit.too-many-requests", + MetricType::MailAuthParseError => "mail-auth.parse-error", + MetricType::MailAuthMissingParameters => "mail-auth.missing-parameters", + MetricType::MailAuthNoHeadersFound => "mail-auth.no-headers-found", + MetricType::MailAuthCrypto => "mail-auth.crypto", + MetricType::MailAuthIo => "mail-auth.io", + MetricType::MailAuthBase64 => "mail-auth.base64", + MetricType::MailAuthDnsError => "mail-auth.dns-error", + MetricType::MailAuthDnsRecordNotFound => "mail-auth.dns-record-not-found", + MetricType::MailAuthDnsInvalidRecordType => "mail-auth.dns-invalid-record-type", + MetricType::MailAuthPolicyNotAligned => "mail-auth.policy-not-aligned", + MetricType::ManageSieveConnectionStart => "manage-sieve.connection-start", + MetricType::ManageSieveConnectionEnd => "manage-sieve.connection-end", + MetricType::MessageSize => "message.size", + MetricType::MessageAuthenticatedSize => "message.authenticated-size", + MetricType::MessageIngestTime => "message-ingest.time", + MetricType::MessageIngestIndexTime => "message-ingest.index-time", + MetricType::MessageIngestHam => "message-ingest.ham", + MetricType::MessageIngestSpam => "message-ingest.spam", + MetricType::MessageIngestImapAppend => "message-ingest.imap-append", + MetricType::MessageIngestJmapAppend => "message-ingest.jmap-append", + MetricType::MessageIngestDuplicate => "message-ingest.duplicate", + MetricType::MessageIngestError => "message-ingest.error", + MetricType::MessageIngestSearchIndex => "message-ingest.search-index", + MetricType::MilterActionAccept => "milter.action-accept", + MetricType::MilterActionDiscard => "milter.action-discard", + MetricType::MilterActionReject => "milter.action-reject", + MetricType::MilterActionTempFail => "milter.action-temp-fail", + MetricType::MilterActionReplyCode => "milter.action-reply-code", + MetricType::MilterActionConnectionFailure => "milter.action-connection-failure", + MetricType::MilterActionShutdown => "milter.action-shutdown", + MetricType::MtaHookActionAccept => "mta-hook.action-accept", + MetricType::MtaHookActionDiscard => "mta-hook.action-discard", + MetricType::MtaHookActionReject => "mta-hook.action-reject", + MetricType::MtaHookActionQuarantine => "mta-hook.action-quarantine", + MetricType::MtaHookError => "mta-hook.error", + MetricType::MtaStsAuthorized => "mta-sts.authorized", + MetricType::MtaStsNotAuthorized => "mta-sts.not-authorized", + MetricType::MtaStsInvalidPolicy => "mta-sts.invalid-policy", + MetricType::NetworkTimeout => "network.timeout", + MetricType::OutgoingReportSize => "outgoing-report.size", + MetricType::OutgoingReportSpfReport => "outgoing-report.spf-report", + MetricType::OutgoingReportSpfRateLimited => "outgoing-report.spf-rate-limited", + MetricType::OutgoingReportDkimReport => "outgoing-report.dkim-report", + MetricType::OutgoingReportDkimRateLimited => "outgoing-report.dkim-rate-limited", + MetricType::OutgoingReportDmarcReport => "outgoing-report.dmarc-report", + MetricType::OutgoingReportDmarcRateLimited => "outgoing-report.dmarc-rate-limited", + MetricType::OutgoingReportDmarcAggregateReport => { + "outgoing-report.dmarc-aggregate-report" + } + MetricType::OutgoingReportTlsAggregate => "outgoing-report.tls-aggregate", + MetricType::OutgoingReportHttpSubmission => "outgoing-report.http-submission", + MetricType::OutgoingReportUnauthorizedReportingAddress => { + "outgoing-report.unauthorized-reporting-address" + } + MetricType::OutgoingReportReportingAddressValidationError => { + "outgoing-report.reporting-address-validation-error" + } + MetricType::OutgoingReportNotFound => "outgoing-report.not-found", + MetricType::OutgoingReportSubmissionError => "outgoing-report.submission-error", + MetricType::OutgoingReportNoRecipientsFound => "outgoing-report.no-recipients-found", + MetricType::Pop3RequestTime => "pop3.request-time", + MetricType::Pop3ActiveConnections => "pop3.active-connections", + MetricType::Pop3ConnectionStart => "pop3.connection-start", + MetricType::Pop3ConnectionEnd => "pop3.connection-end", + MetricType::PushSubscriptionSuccess => "push-subscription.success", + MetricType::PushSubscriptionError => "push-subscription.error", + MetricType::PushSubscriptionNotFound => "push-subscription.not-found", + MetricType::QueueCount => "queue.count", + MetricType::QueueMessageQueued => "queue.message-queued", + MetricType::QueueAuthenticatedMessageQueued => "queue.authenticated-message-queued", + MetricType::QueueReportQueued => "queue.report-queued", + MetricType::QueueDsnQueued => "queue.dsn-queued", + MetricType::QueueAutogeneratedQueued => "queue.autogenerated-queued", + MetricType::QueueRescheduled => "queue.rescheduled", + MetricType::QueueBlobNotFound => "queue.blob-not-found", + MetricType::QueueRateLimitExceeded => "queue.rate-limit-exceeded", + MetricType::QueueConcurrencyLimitExceeded => "queue.concurrency-limit-exceeded", + MetricType::QueueQuotaExceeded => "queue.quota-exceeded", + MetricType::ResourceNotFound => "resource.not-found", + MetricType::ResourceBadParameters => "resource.bad-parameters", + MetricType::ResourceError => "resource.error", + MetricType::SecurityAuthenticationBan => "security.authentication-ban", + MetricType::SecurityAbuseBan => "security.abuse-ban", + MetricType::SecurityScanBan => "security.scan-ban", + MetricType::SecurityLoiterBan => "security.loiter-ban", + MetricType::SecurityIpBlocked => "security.ip-blocked", + MetricType::SecurityUnauthorized => "security.unauthorized", + MetricType::ServerMemory => "server.memory", + MetricType::ServerThreadError => "server.thread-error", + MetricType::SieveRequestTime => "sieve.request-time", + MetricType::SieveActiveConnections => "sieve.active-connections", + MetricType::SieveActionAccept => "sieve.action-accept", + MetricType::SieveActionAcceptReplace => "sieve.action-accept-replace", + MetricType::SieveActionDiscard => "sieve.action-discard", + MetricType::SieveActionReject => "sieve.action-reject", + MetricType::SieveSendMessage => "sieve.send-message", + MetricType::SieveMessageTooLarge => "sieve.message-too-large", + MetricType::SieveRuntimeError => "sieve.runtime-error", + MetricType::SieveUnexpectedError => "sieve.unexpected-error", + MetricType::SieveNotSupported => "sieve.not-supported", + MetricType::SieveQuotaExceeded => "sieve.quota-exceeded", + MetricType::SmtpRequestTime => "smtp.request-time", + MetricType::SmtpActiveConnections => "smtp.active-connections", + MetricType::SmtpConnectionStart => "smtp.connection-start", + MetricType::SmtpConnectionEnd => "smtp.connection-end", + MetricType::SmtpError => "smtp.error", + MetricType::SmtpConcurrencyLimitExceeded => "smtp.concurrency-limit-exceeded", + MetricType::SmtpTransferLimitExceeded => "smtp.transfer-limit-exceeded", + MetricType::SmtpRateLimitExceeded => "smtp.rate-limit-exceeded", + MetricType::SmtpTimeLimitExceeded => "smtp.time-limit-exceeded", + MetricType::SmtpMessageParseFailed => "smtp.message-parse-failed", + MetricType::SmtpMessageTooLarge => "smtp.message-too-large", + MetricType::SmtpLoopDetected => "smtp.loop-detected", + MetricType::SmtpDkimPass => "smtp.dkim-pass", + MetricType::SmtpDkimFail => "smtp.dkim-fail", + MetricType::SmtpDkim2Pass => "smtp.dkim2-pass", + MetricType::SmtpDkim2Fail => "smtp.dkim2-fail", + MetricType::SmtpDkim2DsnDiscarded => "smtp.dkim2-dsn-discarded", + MetricType::SmtpArcPass => "smtp.arc-pass", + MetricType::SmtpArcFail => "smtp.arc-fail", + MetricType::SmtpSpfEhloPass => "smtp.spf-ehlo-pass", + MetricType::SmtpSpfEhloFail => "smtp.spf-ehlo-fail", + MetricType::SmtpSpfFromPass => "smtp.spf-from-pass", + MetricType::SmtpSpfFromFail => "smtp.spf-from-fail", + MetricType::SmtpDmarcPass => "smtp.dmarc-pass", + MetricType::SmtpDmarcFail => "smtp.dmarc-fail", + MetricType::SmtpIprevPass => "smtp.iprev-pass", + MetricType::SmtpIprevFail => "smtp.iprev-fail", + MetricType::SmtpTooManyMessages => "smtp.too-many-messages", + MetricType::SmtpInvalidEhlo => "smtp.invalid-ehlo", + MetricType::SmtpDidNotSayEhlo => "smtp.did-not-say-ehlo", + MetricType::SmtpMailFromUnauthenticated => "smtp.mail-from-unauthenticated", + MetricType::SmtpMailFromUnauthorized => "smtp.mail-from-unauthorized", + MetricType::SmtpMailFromMissing => "smtp.mail-from-missing", + MetricType::SmtpMultipleMailFrom => "smtp.multiple-mail-from", + MetricType::SmtpMailboxDoesNotExist => "smtp.mailbox-does-not-exist", + MetricType::SmtpRelayNotAllowed => "smtp.relay-not-allowed", + MetricType::SmtpRcptToDuplicate => "smtp.rcpt-to-duplicate", + MetricType::SmtpRcptToMissing => "smtp.rcpt-to-missing", + MetricType::SmtpTooManyRecipients => "smtp.too-many-recipients", + MetricType::SmtpTooManyInvalidRcpt => "smtp.too-many-invalid-rcpt", + MetricType::SmtpAuthMechanismNotSupported => "smtp.auth-mechanism-not-supported", + MetricType::SmtpAuthExchangeTooLong => "smtp.auth-exchange-too-long", + MetricType::SmtpCommandNotImplemented => "smtp.command-not-implemented", + MetricType::SmtpInvalidCommand => "smtp.invalid-command", + MetricType::SmtpSyntaxError => "smtp.syntax-error", + MetricType::SmtpRequestTooLarge => "smtp.request-too-large", + MetricType::SpamPyzorError => "spam.pyzor-error", + MetricType::SpamDnsblError => "spam.dnsbl-error", + MetricType::SpamTrainCompleted => "spam.train-completed", + MetricType::SpamTrainSampleAdded => "spam.train-sample-added", + MetricType::SpamClassify => "spam.classify", + MetricType::SpamModelNotReady => "spam.model-not-ready", + MetricType::SpfPass => "spf.pass", + MetricType::SpfFail => "spf.fail", + MetricType::SpfSoftFail => "spf.soft-fail", + MetricType::SpfNeutral => "spf.neutral", + MetricType::SpfTempError => "spf.temp-error", + MetricType::SpfPermError => "spf.perm-error", + MetricType::SpfNone => "spf.none", + MetricType::StoreDataReadTime => "store.data-read-time", + MetricType::StoreDataWriteTime => "store.data-write-time", + MetricType::StoreBlobReadTime => "store.blob-read-time", + MetricType::StoreBlobWriteTime => "store.blob-write-time", + MetricType::StoreAssertValueFailed => "store.assert-value-failed", + MetricType::StoreFoundationdbError => "store.foundationdb-error", + MetricType::StoreMysqlError => "store.mysql-error", + MetricType::StorePostgresqlError => "store.postgresql-error", + MetricType::StoreRocksdbError => "store.rocksdb-error", + MetricType::StoreSqliteError => "store.sqlite-error", + MetricType::StoreLdapError => "store.ldap-error", + MetricType::StoreElasticsearchError => "store.elasticsearch-error", + MetricType::StoreRedisError => "store.redis-error", + MetricType::StoreS3Error => "store.s3-error", + MetricType::StoreAzureError => "store.azure-error", + MetricType::StoreFilesystemError => "store.filesystem-error", + MetricType::StorePoolError => "store.pool-error", + MetricType::StoreDataCorruption => "store.data-corruption", + MetricType::StoreDecompressError => "store.decompress-error", + MetricType::StoreDeserializeError => "store.deserialize-error", + MetricType::StoreNotFound => "store.not-found", + MetricType::StoreNotConfigured => "store.not-configured", + MetricType::StoreNotSupported => "store.not-supported", + MetricType::StoreUnexpectedError => "store.unexpected-error", + MetricType::StoreCryptoError => "store.crypto-error", + MetricType::StoreHttpStoreError => "store.http-store-error", + MetricType::StoreBlobMissingMarker => "store.blob-missing-marker", + MetricType::StoreDataWrite => "store.data-write", + MetricType::StoreDataIterate => "store.data-iterate", + MetricType::StoreBlobRead => "store.blob-read", + MetricType::StoreBlobWrite => "store.blob-write", + MetricType::StoreBlobDelete => "store.blob-delete", + MetricType::TaskManagerBlobNotFound => "task-manager.blob-not-found", + MetricType::TaskManagerMetadataNotFound => "task-manager.metadata-not-found", + MetricType::TelemetryAlertEvent => "telemetry.alert-event", + MetricType::TelemetryAlertMessage => "telemetry.alert-message", + MetricType::TelemetryLogError => "telemetry.log-error", + MetricType::TelemetryWebhookError => "telemetry.webhook-error", + MetricType::TelemetryOtelExporterError => "telemetry.otel-exporter-error", + MetricType::TelemetryOtelMetricsExporterError => { + "telemetry.otel-metrics-exporter-error" + } + MetricType::TelemetryPrometheusExporterError => "telemetry.prometheus-exporter-error", + MetricType::TelemetryJournalError => "telemetry.journal-error", + MetricType::TlsHandshakeError => "tls.handshake-error", + MetricType::UserCount => "user.count", + } + } + + pub fn to_id(&self) -> u16 { + match self { + MetricType::AcmeAuthError => 27, + MetricType::AcmeAuthTooManyAttempts => 28, + MetricType::AcmeOrderCompleted => 29, + MetricType::AcmeOrderInvalid => 30, + MetricType::AcmeClientMissingSni => 31, + MetricType::AcmeTlsAlpnError => 32, + MetricType::AcmeTokenNotFound => 33, + MetricType::AcmeError => 34, + MetricType::ArcChainTooLong => 39, + MetricType::ArcInvalidInstance => 40, + MetricType::ArcInvalidCv => 41, + MetricType::ArcHasHeaderTag => 42, + MetricType::ArcBrokenChain => 43, + MetricType::AuthSuccess => 44, + MetricType::AuthFailed => 45, + MetricType::AuthTooManyAttempts => 46, + MetricType::AuthError => 47, + MetricType::CalendarAlarmSent => 48, + MetricType::CalendarAlarmFailed => 49, + MetricType::CalendarItipMessageSent => 50, + MetricType::CalendarItipMessageReceived => 51, + MetricType::CalendarItipMessageError => 52, + MetricType::ClusterSubscriberError => 53, + MetricType::ClusterSubscriberDisconnected => 54, + MetricType::ClusterPublisherError => 55, + MetricType::DaneAuthenticationSuccess => 56, + MetricType::DaneAuthenticationFailure => 57, + MetricType::DaneNoCertificatesFound => 58, + MetricType::DaneCertificateParseError => 59, + MetricType::DaneTlsaRecordFetchError => 60, + MetricType::DaneTlsaRecordNotFound => 61, + MetricType::DaneTlsaRecordNotDnssecSigned => 62, + MetricType::DaneTlsaRecordInvalid => 63, + MetricType::DaneBogusDnssecRecord => 339, + MetricType::DeliveryTotalTime => 2, + MetricType::DeliveryAttemptTime => 3, + MetricType::DeliveryActiveConnections => 22, + MetricType::DeliveryAttemptStart => 64, + MetricType::DeliveryAttemptEnd => 65, + MetricType::DeliveryCompleted => 66, + MetricType::DeliveryMxLookupFailed => 67, + MetricType::DeliveryIpLookupFailed => 68, + MetricType::DeliveryNullMx => 69, + MetricType::DeliveryGreetingFailed => 70, + MetricType::DeliveryEhloRejected => 71, + MetricType::DeliveryAuthFailed => 72, + MetricType::DeliveryMailFromRejected => 73, + MetricType::DeliveryDelivered => 74, + MetricType::DeliveryRcptToRejected => 75, + MetricType::DeliveryRcptToFailed => 76, + MetricType::DeliveryMessageRejected => 77, + MetricType::DeliveryStartTlsUnavailable => 78, + MetricType::DeliveryStartTlsError => 79, + MetricType::DeliveryStartTlsDisabled => 80, + MetricType::DeliveryImplicitTlsError => 81, + MetricType::DeliveryConcurrencyLimitExceeded => 82, + MetricType::DeliveryRateLimitExceeded => 83, + MetricType::DeliveryDoubleBounce => 84, + MetricType::DeliveryDsnSuccess => 85, + MetricType::DeliveryDsnTempFail => 86, + MetricType::DeliveryDsnPermFail => 87, + MetricType::DkimPass => 88, + MetricType::DkimNeutral => 89, + MetricType::DkimFail => 90, + MetricType::DkimPermError => 91, + MetricType::DkimTempError => 92, + MetricType::DkimNone => 93, + MetricType::DkimUnsupportedVersion => 94, + MetricType::DkimUnsupportedAlgorithm => 95, + MetricType::DkimUnsupportedCanonicalization => 96, + MetricType::DkimUnsupportedKeyType => 97, + MetricType::DkimFailedBodyHashMatch => 98, + MetricType::DkimFailedVerification => 99, + MetricType::DkimFailedAuidMatch => 100, + MetricType::DkimRevokedPublicKey => 101, + MetricType::DkimIncompatibleAlgorithms => 102, + MetricType::DkimSignatureExpired => 103, + MetricType::DkimSignatureLength => 104, + MetricType::DkimSignerNotFound => 105, + MetricType::DkimInstanceMissing => 340, + MetricType::DkimInstanceSyntax => 341, + MetricType::DkimInstanceTagMissing => 342, + MetricType::DkimInstanceNotSigned => 343, + MetricType::DkimInstanceAboveSignature => 344, + MetricType::DkimSignatureMissing => 345, + MetricType::DkimSignatureSyntax => 346, + MetricType::DkimSignatureTagMissing => 347, + MetricType::DkimSignatureTagUnexpected => 348, + MetricType::DkimSequenceGap => 349, + MetricType::DkimSequenceOverflow => 350, + MetricType::DkimChainTooLong => 367, + MetricType::DkimMailFromMismatch => 351, + MetricType::DkimRcptToMismatch => 352, + MetricType::DkimMailFromDomainMismatch => 353, + MetricType::DkimNextDomainMismatch => 354, + MetricType::DkimCustodyBreak => 368, + MetricType::DkimPublicKeyFetch => 355, + MetricType::DkimPublicKeyMissing => 356, + MetricType::DkimPublicKeyMultiple => 357, + MetricType::DkimPublicKeySyntax => 358, + MetricType::DkimPublicKeyAlgorithmMismatch => 359, + MetricType::DkimNoValidAlgorithm => 360, + MetricType::DkimHeaderHashMismatch => 361, + MetricType::DkimModified => 362, + MetricType::DkimExploded => 363, + MetricType::DmarcPass => 106, + MetricType::DmarcFail => 107, + MetricType::DmarcPermError => 108, + MetricType::DmarcTempError => 109, + MetricType::DmarcNone => 110, + MetricType::DnsLookupTime => 11, + MetricType::DnsRecordCreationFailed => 35, + MetricType::DnsRecordDeletionFailed => 36, + MetricType::DnsRecordLookupFailed => 37, + MetricType::DnsRecordPropagationTimeout => 38, + MetricType::DomainCount => 26, + MetricType::EvalError => 111, + MetricType::EvalDirectoryNotFound => 112, + MetricType::EvalStoreNotFound => 113, + MetricType::HttpRequestTime => 12, + MetricType::HttpActiveConnections => 17, + MetricType::HttpConnectionStart => 337, + MetricType::HttpError => 114, + MetricType::HttpRequestBody => 115, + MetricType::HttpResponseBody => 116, + MetricType::HttpXForwardedMissing => 117, + MetricType::ImapRequestTime => 13, + MetricType::ImapActiveConnections => 18, + MetricType::ImapConnectionStart => 118, + MetricType::ImapConnectionEnd => 119, + MetricType::IncomingReportDmarcReport => 120, + MetricType::IncomingReportDmarcReportWithWarnings => 121, + MetricType::IncomingReportTlsReport => 122, + MetricType::IncomingReportTlsReportWithWarnings => 123, + MetricType::IncomingReportAbuseReport => 124, + MetricType::IncomingReportAuthFailureReport => 125, + MetricType::IncomingReportFraudReport => 126, + MetricType::IncomingReportNotSpamReport => 127, + MetricType::IncomingReportVirusReport => 128, + MetricType::IncomingReportOtherReport => 129, + MetricType::IncomingReportMessageParseFailed => 130, + MetricType::IncomingReportDmarcParseFailed => 131, + MetricType::IncomingReportTlsRpcParseFailed => 132, + MetricType::IncomingReportArfParseFailed => 133, + MetricType::IncomingReportDecompressError => 134, + MetricType::IprevPass => 135, + MetricType::IprevFail => 136, + MetricType::IprevPermError => 137, + MetricType::IprevTempError => 138, + MetricType::IprevNone => 139, + MetricType::JmapMethodCall => 140, + MetricType::JmapInvalidArguments => 141, + MetricType::JmapRequestTooLarge => 142, + MetricType::JmapUnsupportedFilter => 143, + MetricType::JmapUnsupportedSort => 144, + MetricType::JmapUnknownMethod => 145, + MetricType::JmapForbidden => 146, + MetricType::JmapNotJson => 147, + MetricType::JmapNotRequest => 148, + MetricType::JmapWebsocketStart => 149, + MetricType::JmapWebsocketError => 150, + MetricType::LimitSizeRequest => 151, + MetricType::LimitSizeUpload => 152, + MetricType::LimitCallsIn => 153, + MetricType::LimitConcurrentRequest => 154, + MetricType::LimitConcurrentUpload => 155, + MetricType::LimitConcurrentConnection => 156, + MetricType::LimitQuota => 157, + MetricType::LimitBlobQuota => 158, + MetricType::LimitTenantQuota => 159, + MetricType::LimitTooManyRequests => 160, + MetricType::MailAuthParseError => 161, + MetricType::MailAuthMissingParameters => 162, + MetricType::MailAuthNoHeadersFound => 163, + MetricType::MailAuthCrypto => 164, + MetricType::MailAuthIo => 165, + MetricType::MailAuthBase64 => 166, + MetricType::MailAuthDnsError => 167, + MetricType::MailAuthDnsRecordNotFound => 168, + MetricType::MailAuthDnsInvalidRecordType => 169, + MetricType::MailAuthPolicyNotAligned => 170, + MetricType::ManageSieveConnectionStart => 171, + MetricType::ManageSieveConnectionEnd => 172, + MetricType::MessageSize => 4, + MetricType::MessageAuthenticatedSize => 5, + MetricType::MessageIngestTime => 0, + MetricType::MessageIngestIndexTime => 1, + MetricType::MessageIngestHam => 173, + MetricType::MessageIngestSpam => 174, + MetricType::MessageIngestImapAppend => 175, + MetricType::MessageIngestJmapAppend => 176, + MetricType::MessageIngestDuplicate => 177, + MetricType::MessageIngestError => 178, + MetricType::MessageIngestSearchIndex => 179, + MetricType::MilterActionAccept => 180, + MetricType::MilterActionDiscard => 181, + MetricType::MilterActionReject => 182, + MetricType::MilterActionTempFail => 183, + MetricType::MilterActionReplyCode => 184, + MetricType::MilterActionConnectionFailure => 185, + MetricType::MilterActionShutdown => 186, + MetricType::MtaHookActionAccept => 187, + MetricType::MtaHookActionDiscard => 188, + MetricType::MtaHookActionReject => 189, + MetricType::MtaHookActionQuarantine => 190, + MetricType::MtaHookError => 191, + MetricType::MtaStsAuthorized => 192, + MetricType::MtaStsNotAuthorized => 193, + MetricType::MtaStsInvalidPolicy => 194, + MetricType::NetworkTimeout => 195, + MetricType::OutgoingReportSize => 6, + MetricType::OutgoingReportSpfReport => 196, + MetricType::OutgoingReportSpfRateLimited => 197, + MetricType::OutgoingReportDkimReport => 198, + MetricType::OutgoingReportDkimRateLimited => 199, + MetricType::OutgoingReportDmarcReport => 200, + MetricType::OutgoingReportDmarcRateLimited => 201, + MetricType::OutgoingReportDmarcAggregateReport => 202, + MetricType::OutgoingReportTlsAggregate => 203, + MetricType::OutgoingReportHttpSubmission => 204, + MetricType::OutgoingReportUnauthorizedReportingAddress => 205, + MetricType::OutgoingReportReportingAddressValidationError => 206, + MetricType::OutgoingReportNotFound => 207, + MetricType::OutgoingReportSubmissionError => 208, + MetricType::OutgoingReportNoRecipientsFound => 209, + MetricType::Pop3RequestTime => 14, + MetricType::Pop3ActiveConnections => 19, + MetricType::Pop3ConnectionStart => 210, + MetricType::Pop3ConnectionEnd => 211, + MetricType::PushSubscriptionSuccess => 213, + MetricType::PushSubscriptionError => 214, + MetricType::PushSubscriptionNotFound => 215, + MetricType::QueueCount => 24, + MetricType::QueueMessageQueued => 216, + MetricType::QueueAuthenticatedMessageQueued => 217, + MetricType::QueueReportQueued => 218, + MetricType::QueueDsnQueued => 219, + MetricType::QueueAutogeneratedQueued => 220, + MetricType::QueueRescheduled => 221, + MetricType::QueueBlobNotFound => 222, + MetricType::QueueRateLimitExceeded => 223, + MetricType::QueueConcurrencyLimitExceeded => 224, + MetricType::QueueQuotaExceeded => 225, + MetricType::ResourceNotFound => 226, + MetricType::ResourceBadParameters => 227, + MetricType::ResourceError => 228, + MetricType::SecurityAuthenticationBan => 229, + MetricType::SecurityAbuseBan => 230, + MetricType::SecurityScanBan => 231, + MetricType::SecurityLoiterBan => 232, + MetricType::SecurityIpBlocked => 233, + MetricType::SecurityUnauthorized => 234, + MetricType::ServerMemory => 23, + MetricType::ServerThreadError => 235, + MetricType::SieveRequestTime => 16, + MetricType::SieveActiveConnections => 21, + MetricType::SieveActionAccept => 236, + MetricType::SieveActionAcceptReplace => 237, + MetricType::SieveActionDiscard => 238, + MetricType::SieveActionReject => 239, + MetricType::SieveSendMessage => 240, + MetricType::SieveMessageTooLarge => 241, + MetricType::SieveRuntimeError => 242, + MetricType::SieveUnexpectedError => 243, + MetricType::SieveNotSupported => 244, + MetricType::SieveQuotaExceeded => 245, + MetricType::SmtpRequestTime => 15, + MetricType::SmtpActiveConnections => 20, + MetricType::SmtpConnectionStart => 246, + MetricType::SmtpConnectionEnd => 247, + MetricType::SmtpError => 248, + MetricType::SmtpConcurrencyLimitExceeded => 249, + MetricType::SmtpTransferLimitExceeded => 250, + MetricType::SmtpRateLimitExceeded => 251, + MetricType::SmtpTimeLimitExceeded => 252, + MetricType::SmtpMessageParseFailed => 253, + MetricType::SmtpMessageTooLarge => 254, + MetricType::SmtpLoopDetected => 255, + MetricType::SmtpDkimPass => 256, + MetricType::SmtpDkimFail => 257, + MetricType::SmtpDkim2Pass => 364, + MetricType::SmtpDkim2Fail => 365, + MetricType::SmtpDkim2DsnDiscarded => 366, + MetricType::SmtpArcPass => 258, + MetricType::SmtpArcFail => 259, + MetricType::SmtpSpfEhloPass => 260, + MetricType::SmtpSpfEhloFail => 261, + MetricType::SmtpSpfFromPass => 262, + MetricType::SmtpSpfFromFail => 263, + MetricType::SmtpDmarcPass => 264, + MetricType::SmtpDmarcFail => 265, + MetricType::SmtpIprevPass => 266, + MetricType::SmtpIprevFail => 267, + MetricType::SmtpTooManyMessages => 268, + MetricType::SmtpInvalidEhlo => 269, + MetricType::SmtpDidNotSayEhlo => 270, + MetricType::SmtpMailFromUnauthenticated => 271, + MetricType::SmtpMailFromUnauthorized => 272, + MetricType::SmtpMailFromMissing => 273, + MetricType::SmtpMultipleMailFrom => 274, + MetricType::SmtpMailboxDoesNotExist => 275, + MetricType::SmtpRelayNotAllowed => 276, + MetricType::SmtpRcptToDuplicate => 277, + MetricType::SmtpRcptToMissing => 278, + MetricType::SmtpTooManyRecipients => 279, + MetricType::SmtpTooManyInvalidRcpt => 280, + MetricType::SmtpAuthMechanismNotSupported => 281, + MetricType::SmtpAuthExchangeTooLong => 282, + MetricType::SmtpCommandNotImplemented => 283, + MetricType::SmtpInvalidCommand => 284, + MetricType::SmtpSyntaxError => 285, + MetricType::SmtpRequestTooLarge => 286, + MetricType::SpamPyzorError => 287, + MetricType::SpamDnsblError => 288, + MetricType::SpamTrainCompleted => 289, + MetricType::SpamTrainSampleAdded => 290, + MetricType::SpamClassify => 291, + MetricType::SpamModelNotReady => 292, + MetricType::SpfPass => 293, + MetricType::SpfFail => 294, + MetricType::SpfSoftFail => 295, + MetricType::SpfNeutral => 296, + MetricType::SpfTempError => 297, + MetricType::SpfPermError => 298, + MetricType::SpfNone => 299, + MetricType::StoreDataReadTime => 7, + MetricType::StoreDataWriteTime => 8, + MetricType::StoreBlobReadTime => 9, + MetricType::StoreBlobWriteTime => 10, + MetricType::StoreAssertValueFailed => 300, + MetricType::StoreFoundationdbError => 301, + MetricType::StoreMysqlError => 302, + MetricType::StorePostgresqlError => 303, + MetricType::StoreRocksdbError => 304, + MetricType::StoreSqliteError => 305, + MetricType::StoreLdapError => 306, + MetricType::StoreElasticsearchError => 307, + MetricType::StoreRedisError => 308, + MetricType::StoreS3Error => 309, + MetricType::StoreAzureError => 310, + MetricType::StoreFilesystemError => 311, + MetricType::StorePoolError => 312, + MetricType::StoreDataCorruption => 313, + MetricType::StoreDecompressError => 314, + MetricType::StoreDeserializeError => 315, + MetricType::StoreNotFound => 316, + MetricType::StoreNotConfigured => 317, + MetricType::StoreNotSupported => 318, + MetricType::StoreUnexpectedError => 319, + MetricType::StoreCryptoError => 320, + MetricType::StoreHttpStoreError => 321, + MetricType::StoreBlobMissingMarker => 322, + MetricType::StoreDataWrite => 323, + MetricType::StoreDataIterate => 324, + MetricType::StoreBlobRead => 325, + MetricType::StoreBlobWrite => 326, + MetricType::StoreBlobDelete => 327, + MetricType::TaskManagerBlobNotFound => 328, + MetricType::TaskManagerMetadataNotFound => 329, + MetricType::TelemetryAlertEvent => 338, + MetricType::TelemetryAlertMessage => 212, + MetricType::TelemetryLogError => 330, + MetricType::TelemetryWebhookError => 331, + MetricType::TelemetryOtelExporterError => 332, + MetricType::TelemetryOtelMetricsExporterError => 333, + MetricType::TelemetryPrometheusExporterError => 334, + MetricType::TelemetryJournalError => 335, + MetricType::TlsHandshakeError => 336, + MetricType::UserCount => 25, + } + } + + pub fn from_id(id: u16) -> Option { + match id { + 27 => Some(MetricType::AcmeAuthError), + 28 => Some(MetricType::AcmeAuthTooManyAttempts), + 29 => Some(MetricType::AcmeOrderCompleted), + 30 => Some(MetricType::AcmeOrderInvalid), + 31 => Some(MetricType::AcmeClientMissingSni), + 32 => Some(MetricType::AcmeTlsAlpnError), + 33 => Some(MetricType::AcmeTokenNotFound), + 34 => Some(MetricType::AcmeError), + 39 => Some(MetricType::ArcChainTooLong), + 40 => Some(MetricType::ArcInvalidInstance), + 41 => Some(MetricType::ArcInvalidCv), + 42 => Some(MetricType::ArcHasHeaderTag), + 43 => Some(MetricType::ArcBrokenChain), + 44 => Some(MetricType::AuthSuccess), + 45 => Some(MetricType::AuthFailed), + 46 => Some(MetricType::AuthTooManyAttempts), + 47 => Some(MetricType::AuthError), + 48 => Some(MetricType::CalendarAlarmSent), + 49 => Some(MetricType::CalendarAlarmFailed), + 50 => Some(MetricType::CalendarItipMessageSent), + 51 => Some(MetricType::CalendarItipMessageReceived), + 52 => Some(MetricType::CalendarItipMessageError), + 53 => Some(MetricType::ClusterSubscriberError), + 54 => Some(MetricType::ClusterSubscriberDisconnected), + 55 => Some(MetricType::ClusterPublisherError), + 56 => Some(MetricType::DaneAuthenticationSuccess), + 57 => Some(MetricType::DaneAuthenticationFailure), + 58 => Some(MetricType::DaneNoCertificatesFound), + 59 => Some(MetricType::DaneCertificateParseError), + 60 => Some(MetricType::DaneTlsaRecordFetchError), + 61 => Some(MetricType::DaneTlsaRecordNotFound), + 62 => Some(MetricType::DaneTlsaRecordNotDnssecSigned), + 63 => Some(MetricType::DaneTlsaRecordInvalid), + 339 => Some(MetricType::DaneBogusDnssecRecord), + 2 => Some(MetricType::DeliveryTotalTime), + 3 => Some(MetricType::DeliveryAttemptTime), + 22 => Some(MetricType::DeliveryActiveConnections), + 64 => Some(MetricType::DeliveryAttemptStart), + 65 => Some(MetricType::DeliveryAttemptEnd), + 66 => Some(MetricType::DeliveryCompleted), + 67 => Some(MetricType::DeliveryMxLookupFailed), + 68 => Some(MetricType::DeliveryIpLookupFailed), + 69 => Some(MetricType::DeliveryNullMx), + 70 => Some(MetricType::DeliveryGreetingFailed), + 71 => Some(MetricType::DeliveryEhloRejected), + 72 => Some(MetricType::DeliveryAuthFailed), + 73 => Some(MetricType::DeliveryMailFromRejected), + 74 => Some(MetricType::DeliveryDelivered), + 75 => Some(MetricType::DeliveryRcptToRejected), + 76 => Some(MetricType::DeliveryRcptToFailed), + 77 => Some(MetricType::DeliveryMessageRejected), + 78 => Some(MetricType::DeliveryStartTlsUnavailable), + 79 => Some(MetricType::DeliveryStartTlsError), + 80 => Some(MetricType::DeliveryStartTlsDisabled), + 81 => Some(MetricType::DeliveryImplicitTlsError), + 82 => Some(MetricType::DeliveryConcurrencyLimitExceeded), + 83 => Some(MetricType::DeliveryRateLimitExceeded), + 84 => Some(MetricType::DeliveryDoubleBounce), + 85 => Some(MetricType::DeliveryDsnSuccess), + 86 => Some(MetricType::DeliveryDsnTempFail), + 87 => Some(MetricType::DeliveryDsnPermFail), + 88 => Some(MetricType::DkimPass), + 89 => Some(MetricType::DkimNeutral), + 90 => Some(MetricType::DkimFail), + 91 => Some(MetricType::DkimPermError), + 92 => Some(MetricType::DkimTempError), + 93 => Some(MetricType::DkimNone), + 94 => Some(MetricType::DkimUnsupportedVersion), + 95 => Some(MetricType::DkimUnsupportedAlgorithm), + 96 => Some(MetricType::DkimUnsupportedCanonicalization), + 97 => Some(MetricType::DkimUnsupportedKeyType), + 98 => Some(MetricType::DkimFailedBodyHashMatch), + 99 => Some(MetricType::DkimFailedVerification), + 100 => Some(MetricType::DkimFailedAuidMatch), + 101 => Some(MetricType::DkimRevokedPublicKey), + 102 => Some(MetricType::DkimIncompatibleAlgorithms), + 103 => Some(MetricType::DkimSignatureExpired), + 104 => Some(MetricType::DkimSignatureLength), + 105 => Some(MetricType::DkimSignerNotFound), + 340 => Some(MetricType::DkimInstanceMissing), + 341 => Some(MetricType::DkimInstanceSyntax), + 342 => Some(MetricType::DkimInstanceTagMissing), + 343 => Some(MetricType::DkimInstanceNotSigned), + 344 => Some(MetricType::DkimInstanceAboveSignature), + 345 => Some(MetricType::DkimSignatureMissing), + 346 => Some(MetricType::DkimSignatureSyntax), + 347 => Some(MetricType::DkimSignatureTagMissing), + 348 => Some(MetricType::DkimSignatureTagUnexpected), + 349 => Some(MetricType::DkimSequenceGap), + 350 => Some(MetricType::DkimSequenceOverflow), + 367 => Some(MetricType::DkimChainTooLong), + 351 => Some(MetricType::DkimMailFromMismatch), + 352 => Some(MetricType::DkimRcptToMismatch), + 353 => Some(MetricType::DkimMailFromDomainMismatch), + 354 => Some(MetricType::DkimNextDomainMismatch), + 368 => Some(MetricType::DkimCustodyBreak), + 355 => Some(MetricType::DkimPublicKeyFetch), + 356 => Some(MetricType::DkimPublicKeyMissing), + 357 => Some(MetricType::DkimPublicKeyMultiple), + 358 => Some(MetricType::DkimPublicKeySyntax), + 359 => Some(MetricType::DkimPublicKeyAlgorithmMismatch), + 360 => Some(MetricType::DkimNoValidAlgorithm), + 361 => Some(MetricType::DkimHeaderHashMismatch), + 362 => Some(MetricType::DkimModified), + 363 => Some(MetricType::DkimExploded), + 106 => Some(MetricType::DmarcPass), + 107 => Some(MetricType::DmarcFail), + 108 => Some(MetricType::DmarcPermError), + 109 => Some(MetricType::DmarcTempError), + 110 => Some(MetricType::DmarcNone), + 11 => Some(MetricType::DnsLookupTime), + 35 => Some(MetricType::DnsRecordCreationFailed), + 36 => Some(MetricType::DnsRecordDeletionFailed), + 37 => Some(MetricType::DnsRecordLookupFailed), + 38 => Some(MetricType::DnsRecordPropagationTimeout), + 26 => Some(MetricType::DomainCount), + 111 => Some(MetricType::EvalError), + 112 => Some(MetricType::EvalDirectoryNotFound), + 113 => Some(MetricType::EvalStoreNotFound), + 12 => Some(MetricType::HttpRequestTime), + 17 => Some(MetricType::HttpActiveConnections), + 337 => Some(MetricType::HttpConnectionStart), + 114 => Some(MetricType::HttpError), + 115 => Some(MetricType::HttpRequestBody), + 116 => Some(MetricType::HttpResponseBody), + 117 => Some(MetricType::HttpXForwardedMissing), + 13 => Some(MetricType::ImapRequestTime), + 18 => Some(MetricType::ImapActiveConnections), + 118 => Some(MetricType::ImapConnectionStart), + 119 => Some(MetricType::ImapConnectionEnd), + 120 => Some(MetricType::IncomingReportDmarcReport), + 121 => Some(MetricType::IncomingReportDmarcReportWithWarnings), + 122 => Some(MetricType::IncomingReportTlsReport), + 123 => Some(MetricType::IncomingReportTlsReportWithWarnings), + 124 => Some(MetricType::IncomingReportAbuseReport), + 125 => Some(MetricType::IncomingReportAuthFailureReport), + 126 => Some(MetricType::IncomingReportFraudReport), + 127 => Some(MetricType::IncomingReportNotSpamReport), + 128 => Some(MetricType::IncomingReportVirusReport), + 129 => Some(MetricType::IncomingReportOtherReport), + 130 => Some(MetricType::IncomingReportMessageParseFailed), + 131 => Some(MetricType::IncomingReportDmarcParseFailed), + 132 => Some(MetricType::IncomingReportTlsRpcParseFailed), + 133 => Some(MetricType::IncomingReportArfParseFailed), + 134 => Some(MetricType::IncomingReportDecompressError), + 135 => Some(MetricType::IprevPass), + 136 => Some(MetricType::IprevFail), + 137 => Some(MetricType::IprevPermError), + 138 => Some(MetricType::IprevTempError), + 139 => Some(MetricType::IprevNone), + 140 => Some(MetricType::JmapMethodCall), + 141 => Some(MetricType::JmapInvalidArguments), + 142 => Some(MetricType::JmapRequestTooLarge), + 143 => Some(MetricType::JmapUnsupportedFilter), + 144 => Some(MetricType::JmapUnsupportedSort), + 145 => Some(MetricType::JmapUnknownMethod), + 146 => Some(MetricType::JmapForbidden), + 147 => Some(MetricType::JmapNotJson), + 148 => Some(MetricType::JmapNotRequest), + 149 => Some(MetricType::JmapWebsocketStart), + 150 => Some(MetricType::JmapWebsocketError), + 151 => Some(MetricType::LimitSizeRequest), + 152 => Some(MetricType::LimitSizeUpload), + 153 => Some(MetricType::LimitCallsIn), + 154 => Some(MetricType::LimitConcurrentRequest), + 155 => Some(MetricType::LimitConcurrentUpload), + 156 => Some(MetricType::LimitConcurrentConnection), + 157 => Some(MetricType::LimitQuota), + 158 => Some(MetricType::LimitBlobQuota), + 159 => Some(MetricType::LimitTenantQuota), + 160 => Some(MetricType::LimitTooManyRequests), + 161 => Some(MetricType::MailAuthParseError), + 162 => Some(MetricType::MailAuthMissingParameters), + 163 => Some(MetricType::MailAuthNoHeadersFound), + 164 => Some(MetricType::MailAuthCrypto), + 165 => Some(MetricType::MailAuthIo), + 166 => Some(MetricType::MailAuthBase64), + 167 => Some(MetricType::MailAuthDnsError), + 168 => Some(MetricType::MailAuthDnsRecordNotFound), + 169 => Some(MetricType::MailAuthDnsInvalidRecordType), + 170 => Some(MetricType::MailAuthPolicyNotAligned), + 171 => Some(MetricType::ManageSieveConnectionStart), + 172 => Some(MetricType::ManageSieveConnectionEnd), + 4 => Some(MetricType::MessageSize), + 5 => Some(MetricType::MessageAuthenticatedSize), + 0 => Some(MetricType::MessageIngestTime), + 1 => Some(MetricType::MessageIngestIndexTime), + 173 => Some(MetricType::MessageIngestHam), + 174 => Some(MetricType::MessageIngestSpam), + 175 => Some(MetricType::MessageIngestImapAppend), + 176 => Some(MetricType::MessageIngestJmapAppend), + 177 => Some(MetricType::MessageIngestDuplicate), + 178 => Some(MetricType::MessageIngestError), + 179 => Some(MetricType::MessageIngestSearchIndex), + 180 => Some(MetricType::MilterActionAccept), + 181 => Some(MetricType::MilterActionDiscard), + 182 => Some(MetricType::MilterActionReject), + 183 => Some(MetricType::MilterActionTempFail), + 184 => Some(MetricType::MilterActionReplyCode), + 185 => Some(MetricType::MilterActionConnectionFailure), + 186 => Some(MetricType::MilterActionShutdown), + 187 => Some(MetricType::MtaHookActionAccept), + 188 => Some(MetricType::MtaHookActionDiscard), + 189 => Some(MetricType::MtaHookActionReject), + 190 => Some(MetricType::MtaHookActionQuarantine), + 191 => Some(MetricType::MtaHookError), + 192 => Some(MetricType::MtaStsAuthorized), + 193 => Some(MetricType::MtaStsNotAuthorized), + 194 => Some(MetricType::MtaStsInvalidPolicy), + 195 => Some(MetricType::NetworkTimeout), + 6 => Some(MetricType::OutgoingReportSize), + 196 => Some(MetricType::OutgoingReportSpfReport), + 197 => Some(MetricType::OutgoingReportSpfRateLimited), + 198 => Some(MetricType::OutgoingReportDkimReport), + 199 => Some(MetricType::OutgoingReportDkimRateLimited), + 200 => Some(MetricType::OutgoingReportDmarcReport), + 201 => Some(MetricType::OutgoingReportDmarcRateLimited), + 202 => Some(MetricType::OutgoingReportDmarcAggregateReport), + 203 => Some(MetricType::OutgoingReportTlsAggregate), + 204 => Some(MetricType::OutgoingReportHttpSubmission), + 205 => Some(MetricType::OutgoingReportUnauthorizedReportingAddress), + 206 => Some(MetricType::OutgoingReportReportingAddressValidationError), + 207 => Some(MetricType::OutgoingReportNotFound), + 208 => Some(MetricType::OutgoingReportSubmissionError), + 209 => Some(MetricType::OutgoingReportNoRecipientsFound), + 14 => Some(MetricType::Pop3RequestTime), + 19 => Some(MetricType::Pop3ActiveConnections), + 210 => Some(MetricType::Pop3ConnectionStart), + 211 => Some(MetricType::Pop3ConnectionEnd), + 213 => Some(MetricType::PushSubscriptionSuccess), + 214 => Some(MetricType::PushSubscriptionError), + 215 => Some(MetricType::PushSubscriptionNotFound), + 24 => Some(MetricType::QueueCount), + 216 => Some(MetricType::QueueMessageQueued), + 217 => Some(MetricType::QueueAuthenticatedMessageQueued), + 218 => Some(MetricType::QueueReportQueued), + 219 => Some(MetricType::QueueDsnQueued), + 220 => Some(MetricType::QueueAutogeneratedQueued), + 221 => Some(MetricType::QueueRescheduled), + 222 => Some(MetricType::QueueBlobNotFound), + 223 => Some(MetricType::QueueRateLimitExceeded), + 224 => Some(MetricType::QueueConcurrencyLimitExceeded), + 225 => Some(MetricType::QueueQuotaExceeded), + 226 => Some(MetricType::ResourceNotFound), + 227 => Some(MetricType::ResourceBadParameters), + 228 => Some(MetricType::ResourceError), + 229 => Some(MetricType::SecurityAuthenticationBan), + 230 => Some(MetricType::SecurityAbuseBan), + 231 => Some(MetricType::SecurityScanBan), + 232 => Some(MetricType::SecurityLoiterBan), + 233 => Some(MetricType::SecurityIpBlocked), + 234 => Some(MetricType::SecurityUnauthorized), + 23 => Some(MetricType::ServerMemory), + 235 => Some(MetricType::ServerThreadError), + 16 => Some(MetricType::SieveRequestTime), + 21 => Some(MetricType::SieveActiveConnections), + 236 => Some(MetricType::SieveActionAccept), + 237 => Some(MetricType::SieveActionAcceptReplace), + 238 => Some(MetricType::SieveActionDiscard), + 239 => Some(MetricType::SieveActionReject), + 240 => Some(MetricType::SieveSendMessage), + 241 => Some(MetricType::SieveMessageTooLarge), + 242 => Some(MetricType::SieveRuntimeError), + 243 => Some(MetricType::SieveUnexpectedError), + 244 => Some(MetricType::SieveNotSupported), + 245 => Some(MetricType::SieveQuotaExceeded), + 15 => Some(MetricType::SmtpRequestTime), + 20 => Some(MetricType::SmtpActiveConnections), + 246 => Some(MetricType::SmtpConnectionStart), + 247 => Some(MetricType::SmtpConnectionEnd), + 248 => Some(MetricType::SmtpError), + 249 => Some(MetricType::SmtpConcurrencyLimitExceeded), + 250 => Some(MetricType::SmtpTransferLimitExceeded), + 251 => Some(MetricType::SmtpRateLimitExceeded), + 252 => Some(MetricType::SmtpTimeLimitExceeded), + 253 => Some(MetricType::SmtpMessageParseFailed), + 254 => Some(MetricType::SmtpMessageTooLarge), + 255 => Some(MetricType::SmtpLoopDetected), + 256 => Some(MetricType::SmtpDkimPass), + 257 => Some(MetricType::SmtpDkimFail), + 364 => Some(MetricType::SmtpDkim2Pass), + 365 => Some(MetricType::SmtpDkim2Fail), + 366 => Some(MetricType::SmtpDkim2DsnDiscarded), + 258 => Some(MetricType::SmtpArcPass), + 259 => Some(MetricType::SmtpArcFail), + 260 => Some(MetricType::SmtpSpfEhloPass), + 261 => Some(MetricType::SmtpSpfEhloFail), + 262 => Some(MetricType::SmtpSpfFromPass), + 263 => Some(MetricType::SmtpSpfFromFail), + 264 => Some(MetricType::SmtpDmarcPass), + 265 => Some(MetricType::SmtpDmarcFail), + 266 => Some(MetricType::SmtpIprevPass), + 267 => Some(MetricType::SmtpIprevFail), + 268 => Some(MetricType::SmtpTooManyMessages), + 269 => Some(MetricType::SmtpInvalidEhlo), + 270 => Some(MetricType::SmtpDidNotSayEhlo), + 271 => Some(MetricType::SmtpMailFromUnauthenticated), + 272 => Some(MetricType::SmtpMailFromUnauthorized), + 273 => Some(MetricType::SmtpMailFromMissing), + 274 => Some(MetricType::SmtpMultipleMailFrom), + 275 => Some(MetricType::SmtpMailboxDoesNotExist), + 276 => Some(MetricType::SmtpRelayNotAllowed), + 277 => Some(MetricType::SmtpRcptToDuplicate), + 278 => Some(MetricType::SmtpRcptToMissing), + 279 => Some(MetricType::SmtpTooManyRecipients), + 280 => Some(MetricType::SmtpTooManyInvalidRcpt), + 281 => Some(MetricType::SmtpAuthMechanismNotSupported), + 282 => Some(MetricType::SmtpAuthExchangeTooLong), + 283 => Some(MetricType::SmtpCommandNotImplemented), + 284 => Some(MetricType::SmtpInvalidCommand), + 285 => Some(MetricType::SmtpSyntaxError), + 286 => Some(MetricType::SmtpRequestTooLarge), + 287 => Some(MetricType::SpamPyzorError), + 288 => Some(MetricType::SpamDnsblError), + 289 => Some(MetricType::SpamTrainCompleted), + 290 => Some(MetricType::SpamTrainSampleAdded), + 291 => Some(MetricType::SpamClassify), + 292 => Some(MetricType::SpamModelNotReady), + 293 => Some(MetricType::SpfPass), + 294 => Some(MetricType::SpfFail), + 295 => Some(MetricType::SpfSoftFail), + 296 => Some(MetricType::SpfNeutral), + 297 => Some(MetricType::SpfTempError), + 298 => Some(MetricType::SpfPermError), + 299 => Some(MetricType::SpfNone), + 7 => Some(MetricType::StoreDataReadTime), + 8 => Some(MetricType::StoreDataWriteTime), + 9 => Some(MetricType::StoreBlobReadTime), + 10 => Some(MetricType::StoreBlobWriteTime), + 300 => Some(MetricType::StoreAssertValueFailed), + 301 => Some(MetricType::StoreFoundationdbError), + 302 => Some(MetricType::StoreMysqlError), + 303 => Some(MetricType::StorePostgresqlError), + 304 => Some(MetricType::StoreRocksdbError), + 305 => Some(MetricType::StoreSqliteError), + 306 => Some(MetricType::StoreLdapError), + 307 => Some(MetricType::StoreElasticsearchError), + 308 => Some(MetricType::StoreRedisError), + 309 => Some(MetricType::StoreS3Error), + 310 => Some(MetricType::StoreAzureError), + 311 => Some(MetricType::StoreFilesystemError), + 312 => Some(MetricType::StorePoolError), + 313 => Some(MetricType::StoreDataCorruption), + 314 => Some(MetricType::StoreDecompressError), + 315 => Some(MetricType::StoreDeserializeError), + 316 => Some(MetricType::StoreNotFound), + 317 => Some(MetricType::StoreNotConfigured), + 318 => Some(MetricType::StoreNotSupported), + 319 => Some(MetricType::StoreUnexpectedError), + 320 => Some(MetricType::StoreCryptoError), + 321 => Some(MetricType::StoreHttpStoreError), + 322 => Some(MetricType::StoreBlobMissingMarker), + 323 => Some(MetricType::StoreDataWrite), + 324 => Some(MetricType::StoreDataIterate), + 325 => Some(MetricType::StoreBlobRead), + 326 => Some(MetricType::StoreBlobWrite), + 327 => Some(MetricType::StoreBlobDelete), + 328 => Some(MetricType::TaskManagerBlobNotFound), + 329 => Some(MetricType::TaskManagerMetadataNotFound), + 338 => Some(MetricType::TelemetryAlertEvent), + 212 => Some(MetricType::TelemetryAlertMessage), + 330 => Some(MetricType::TelemetryLogError), + 331 => Some(MetricType::TelemetryWebhookError), + 332 => Some(MetricType::TelemetryOtelExporterError), + 333 => Some(MetricType::TelemetryOtelMetricsExporterError), + 334 => Some(MetricType::TelemetryPrometheusExporterError), + 335 => Some(MetricType::TelemetryJournalError), + 336 => Some(MetricType::TlsHandshakeError), + 25 => Some(MetricType::UserCount), + _ => None, + } + } + + pub fn event_id(&self) -> usize { + match self { + MetricType::AcmeAuthError => 1, + MetricType::AcmeAuthTooManyAttempts => 4, + MetricType::AcmeOrderCompleted => 16, + MetricType::AcmeOrderInvalid => 17, + MetricType::AcmeClientMissingSni => 6, + MetricType::AcmeTlsAlpnError => 24, + MetricType::AcmeTokenNotFound => 26, + MetricType::AcmeError => 15, + MetricType::ArcChainTooLong => 28, + MetricType::ArcInvalidInstance => 31, + MetricType::ArcInvalidCv => 30, + MetricType::ArcHasHeaderTag => 29, + MetricType::ArcBrokenChain => 27, + MetricType::AuthSuccess => 37, + MetricType::AuthFailed => 35, + MetricType::AuthTooManyAttempts => 38, + MetricType::AuthError => 34, + MetricType::CalendarAlarmSent => 579, + MetricType::CalendarAlarmFailed => 582, + MetricType::CalendarItipMessageSent => 583, + MetricType::CalendarItipMessageReceived => 584, + MetricType::CalendarItipMessageError => 585, + MetricType::ClusterSubscriberError => 41, + MetricType::ClusterSubscriberDisconnected => 42, + MetricType::ClusterPublisherError => 45, + MetricType::DaneAuthenticationSuccess => 67, + MetricType::DaneAuthenticationFailure => 66, + MetricType::DaneNoCertificatesFound => 69, + MetricType::DaneCertificateParseError => 68, + MetricType::DaneTlsaRecordFetchError => 71, + MetricType::DaneTlsaRecordNotFound => 75, + MetricType::DaneTlsaRecordNotDnssecSigned => 74, + MetricType::DaneTlsaRecordInvalid => 72, + MetricType::DaneBogusDnssecRecord => 605, + MetricType::DeliveryAttemptStart => 77, + MetricType::DeliveryAttemptEnd => 76, + MetricType::DeliveryCompleted => 80, + MetricType::DeliveryMxLookupFailed => 102, + MetricType::DeliveryIpLookupFailed => 96, + MetricType::DeliveryNullMx => 103, + MetricType::DeliveryGreetingFailed => 93, + MetricType::DeliveryEhloRejected => 91, + MetricType::DeliveryAuthFailed => 79, + MetricType::DeliveryMailFromRejected => 98, + MetricType::DeliveryDelivered => 84, + MetricType::DeliveryRcptToRejected => 109, + MetricType::DeliveryRcptToFailed => 108, + MetricType::DeliveryMessageRejected => 99, + MetricType::DeliveryStartTlsUnavailable => 113, + MetricType::DeliveryStartTlsError => 112, + MetricType::DeliveryStartTlsDisabled => 111, + MetricType::DeliveryImplicitTlsError => 94, + MetricType::DeliveryConcurrencyLimitExceeded => 81, + MetricType::DeliveryRateLimitExceeded => 104, + MetricType::DeliveryDoubleBounce => 86, + MetricType::DeliveryDsnSuccess => 88, + MetricType::DeliveryDsnTempFail => 89, + MetricType::DeliveryDsnPermFail => 87, + MetricType::DkimPass => 121, + MetricType::DkimNeutral => 119, + MetricType::DkimFail => 114, + MetricType::DkimPermError => 122, + MetricType::DkimTempError => 127, + MetricType::DkimNone => 120, + MetricType::DkimUnsupportedVersion => 131, + MetricType::DkimUnsupportedAlgorithm => 128, + MetricType::DkimUnsupportedCanonicalization => 129, + MetricType::DkimUnsupportedKeyType => 130, + MetricType::DkimFailedBodyHashMatch => 116, + MetricType::DkimFailedVerification => 117, + MetricType::DkimFailedAuidMatch => 115, + MetricType::DkimRevokedPublicKey => 123, + MetricType::DkimIncompatibleAlgorithms => 118, + MetricType::DkimSignatureExpired => 124, + MetricType::DkimSignatureLength => 125, + MetricType::DkimSignerNotFound => 126, + MetricType::DkimInstanceMissing => 606, + MetricType::DkimInstanceSyntax => 607, + MetricType::DkimInstanceTagMissing => 608, + MetricType::DkimInstanceNotSigned => 609, + MetricType::DkimInstanceAboveSignature => 610, + MetricType::DkimSignatureMissing => 611, + MetricType::DkimSignatureSyntax => 612, + MetricType::DkimSignatureTagMissing => 613, + MetricType::DkimSignatureTagUnexpected => 614, + MetricType::DkimSequenceGap => 615, + MetricType::DkimSequenceOverflow => 616, + MetricType::DkimChainTooLong => 635, + MetricType::DkimMailFromMismatch => 617, + MetricType::DkimRcptToMismatch => 618, + MetricType::DkimMailFromDomainMismatch => 619, + MetricType::DkimNextDomainMismatch => 620, + MetricType::DkimCustodyBreak => 636, + MetricType::DkimPublicKeyFetch => 621, + MetricType::DkimPublicKeyMissing => 622, + MetricType::DkimPublicKeyMultiple => 623, + MetricType::DkimPublicKeySyntax => 624, + MetricType::DkimPublicKeyAlgorithmMismatch => 625, + MetricType::DkimNoValidAlgorithm => 626, + MetricType::DkimHeaderHashMismatch => 627, + MetricType::DkimModified => 628, + MetricType::DkimExploded => 629, + MetricType::DmarcPass => 134, + MetricType::DmarcFail => 132, + MetricType::DmarcPermError => 135, + MetricType::DmarcTempError => 136, + MetricType::DmarcNone => 133, + MetricType::DnsRecordCreationFailed => 9, + MetricType::DnsRecordDeletionFailed => 10, + MetricType::DnsRecordLookupFailed => 11, + MetricType::DnsRecordPropagationTimeout => 14, + MetricType::EvalError => 138, + MetricType::EvalDirectoryNotFound => 137, + MetricType::EvalStoreNotFound => 140, + MetricType::HttpConnectionStart => 153, + MetricType::HttpError => 154, + MetricType::HttpRequestBody => 155, + MetricType::HttpResponseBody => 157, + MetricType::HttpXForwardedMissing => 158, + MetricType::ImapConnectionStart => 163, + MetricType::ImapConnectionEnd => 162, + MetricType::IncomingReportDmarcReport => 200, + MetricType::IncomingReportDmarcReportWithWarnings => 201, + MetricType::IncomingReportTlsReport => 206, + MetricType::IncomingReportTlsReportWithWarnings => 207, + MetricType::IncomingReportAbuseReport => 195, + MetricType::IncomingReportAuthFailureReport => 197, + MetricType::IncomingReportFraudReport => 202, + MetricType::IncomingReportNotSpamReport => 204, + MetricType::IncomingReportVirusReport => 209, + MetricType::IncomingReportOtherReport => 205, + MetricType::IncomingReportMessageParseFailed => 203, + MetricType::IncomingReportDmarcParseFailed => 199, + MetricType::IncomingReportTlsRpcParseFailed => 208, + MetricType::IncomingReportArfParseFailed => 196, + MetricType::IncomingReportDecompressError => 198, + MetricType::IprevPass => 212, + MetricType::IprevFail => 210, + MetricType::IprevPermError => 213, + MetricType::IprevTempError => 214, + MetricType::IprevNone => 211, + MetricType::JmapMethodCall => 223, + MetricType::JmapInvalidArguments => 221, + MetricType::JmapRequestTooLarge => 227, + MetricType::JmapUnsupportedFilter => 232, + MetricType::JmapUnsupportedSort => 233, + MetricType::JmapUnknownMethod => 231, + MetricType::JmapForbidden => 220, + MetricType::JmapNotJson => 225, + MetricType::JmapNotRequest => 226, + MetricType::JmapWebsocketStart => 235, + MetricType::JmapWebsocketError => 234, + MetricType::LimitSizeRequest => 243, + MetricType::LimitSizeUpload => 244, + MetricType::LimitCallsIn => 238, + MetricType::LimitConcurrentRequest => 240, + MetricType::LimitConcurrentUpload => 241, + MetricType::LimitConcurrentConnection => 239, + MetricType::LimitQuota => 242, + MetricType::LimitBlobQuota => 237, + MetricType::LimitTenantQuota => 553, + MetricType::LimitTooManyRequests => 245, + MetricType::MailAuthParseError => 254, + MetricType::MailAuthMissingParameters => 252, + MetricType::MailAuthNoHeadersFound => 253, + MetricType::MailAuthCrypto => 247, + MetricType::MailAuthIo => 251, + MetricType::MailAuthBase64 => 246, + MetricType::MailAuthDnsError => 248, + MetricType::MailAuthDnsRecordNotFound => 250, + MetricType::MailAuthDnsInvalidRecordType => 249, + MetricType::MailAuthPolicyNotAligned => 255, + MetricType::ManageSieveConnectionStart => 259, + MetricType::ManageSieveConnectionEnd => 258, + MetricType::MessageIngestHam => 283, + MetricType::MessageIngestSpam => 286, + MetricType::MessageIngestImapAppend => 284, + MetricType::MessageIngestJmapAppend => 285, + MetricType::MessageIngestDuplicate => 281, + MetricType::MessageIngestError => 282, + MetricType::MessageIngestSearchIndex => 142, + MetricType::MilterActionAccept => 287, + MetricType::MilterActionDiscard => 289, + MetricType::MilterActionReject => 290, + MetricType::MilterActionTempFail => 293, + MetricType::MilterActionReplyCode => 291, + MetricType::MilterActionConnectionFailure => 288, + MetricType::MilterActionShutdown => 292, + MetricType::MtaHookActionAccept => 304, + MetricType::MtaHookActionDiscard => 305, + MetricType::MtaHookActionReject => 307, + MetricType::MtaHookActionQuarantine => 306, + MetricType::MtaHookError => 308, + MetricType::MtaStsAuthorized => 309, + MetricType::MtaStsNotAuthorized => 311, + MetricType::MtaStsInvalidPolicy => 310, + MetricType::NetworkTimeout => 327, + MetricType::OutgoingReportSpfReport => 342, + MetricType::OutgoingReportSpfRateLimited => 341, + MetricType::OutgoingReportDkimReport => 330, + MetricType::OutgoingReportDkimRateLimited => 329, + MetricType::OutgoingReportDmarcReport => 333, + MetricType::OutgoingReportDmarcRateLimited => 332, + MetricType::OutgoingReportDmarcAggregateReport => 331, + MetricType::OutgoingReportTlsAggregate => 344, + MetricType::OutgoingReportHttpSubmission => 334, + MetricType::OutgoingReportUnauthorizedReportingAddress => 345, + MetricType::OutgoingReportReportingAddressValidationError => 340, + MetricType::OutgoingReportNotFound => 339, + MetricType::OutgoingReportSubmissionError => 343, + MetricType::OutgoingReportNoRecipientsFound => 338, + MetricType::Pop3ConnectionStart => 348, + MetricType::Pop3ConnectionEnd => 347, + MetricType::PushSubscriptionSuccess => 373, + MetricType::PushSubscriptionError => 371, + MetricType::PushSubscriptionNotFound => 372, + MetricType::QueueMessageQueued => 380, + MetricType::QueueAuthenticatedMessageQueued => 381, + MetricType::QueueReportQueued => 382, + MetricType::QueueDsnQueued => 379, + MetricType::QueueAutogeneratedQueued => 378, + MetricType::QueueRescheduled => 385, + MetricType::QueueBlobNotFound => 374, + MetricType::QueueRateLimitExceeded => 384, + MetricType::QueueConcurrencyLimitExceeded => 375, + MetricType::QueueQuotaExceeded => 383, + MetricType::ResourceNotFound => 389, + MetricType::ResourceBadParameters => 386, + MetricType::ResourceError => 388, + MetricType::SecurityAuthenticationBan => 33, + MetricType::SecurityAbuseBan => 549, + MetricType::SecurityScanBan => 558, + MetricType::SecurityLoiterBan => 550, + MetricType::SecurityIpBlocked => 318, + MetricType::SecurityUnauthorized => 552, + MetricType::ServerThreadError => 395, + MetricType::SieveActionAccept => 396, + MetricType::SieveActionAcceptReplace => 397, + MetricType::SieveActionDiscard => 398, + MetricType::SieveActionReject => 399, + MetricType::SieveSendMessage => 406, + MetricType::SieveMessageTooLarge => 401, + MetricType::SieveRuntimeError => 404, + MetricType::SieveUnexpectedError => 407, + MetricType::SieveNotSupported => 402, + MetricType::SieveQuotaExceeded => 403, + MetricType::SmtpConnectionStart => 417, + MetricType::SmtpConnectionEnd => 416, + MetricType::SmtpError => 428, + MetricType::SmtpConcurrencyLimitExceeded => 415, + MetricType::SmtpTransferLimitExceeded => 485, + MetricType::SmtpRateLimitExceeded => 461, + MetricType::SmtpTimeLimitExceeded => 481, + MetricType::SmtpMessageParseFailed => 450, + MetricType::SmtpMessageTooLarge => 451, + MetricType::SmtpLoopDetected => 443, + MetricType::SmtpDkimPass => 422, + MetricType::SmtpDkimFail => 421, + MetricType::SmtpDkim2Pass => 630, + MetricType::SmtpDkim2Fail => 631, + MetricType::SmtpDkim2DsnDiscarded => 632, + MetricType::SmtpArcPass => 410, + MetricType::SmtpArcFail => 409, + MetricType::SmtpSpfEhloPass => 474, + MetricType::SmtpSpfEhloFail => 473, + MetricType::SmtpSpfFromPass => 476, + MetricType::SmtpSpfFromFail => 475, + MetricType::SmtpDmarcPass => 424, + MetricType::SmtpDmarcFail => 423, + MetricType::SmtpIprevPass => 441, + MetricType::SmtpIprevFail => 440, + MetricType::SmtpTooManyMessages => 483, + MetricType::SmtpInvalidEhlo => 436, + MetricType::SmtpDidNotSayEhlo => 420, + MetricType::SmtpMailFromUnauthenticated => 447, + MetricType::SmtpMailFromUnauthorized => 448, + MetricType::SmtpMailFromMissing => 445, + MetricType::SmtpMultipleMailFrom => 456, + MetricType::SmtpMailboxDoesNotExist => 449, + MetricType::SmtpRelayNotAllowed => 468, + MetricType::SmtpRcptToDuplicate => 465, + MetricType::SmtpRcptToMissing => 466, + MetricType::SmtpTooManyRecipients => 484, + MetricType::SmtpTooManyInvalidRcpt => 482, + MetricType::SmtpAuthMechanismNotSupported => 412, + MetricType::SmtpAuthExchangeTooLong => 411, + MetricType::SmtpCommandNotImplemented => 414, + MetricType::SmtpInvalidCommand => 435, + MetricType::SmtpSyntaxError => 480, + MetricType::SmtpRequestTooLarge => 470, + MetricType::SpamPyzorError => 494, + MetricType::SpamDnsblError => 563, + MetricType::SpamTrainCompleted => 495, + MetricType::SpamTrainSampleAdded => 143, + MetricType::SpamClassify => 490, + MetricType::SpamModelNotReady => 496, + MetricType::SpfPass => 501, + MetricType::SpfFail => 498, + MetricType::SpfSoftFail => 503, + MetricType::SpfNeutral => 499, + MetricType::SpfTempError => 504, + MetricType::SpfPermError => 502, + MetricType::SpfNone => 500, + MetricType::StoreAssertValueFailed => 505, + MetricType::StoreFoundationdbError => 518, + MetricType::StoreMysqlError => 522, + MetricType::StorePostgresqlError => 527, + MetricType::StoreRocksdbError => 529, + MetricType::StoreSqliteError => 532, + MetricType::StoreLdapError => 520, + MetricType::StoreElasticsearchError => 516, + MetricType::StoreRedisError => 528, + MetricType::StoreS3Error => 530, + MetricType::StoreAzureError => 559, + MetricType::StoreFilesystemError => 517, + MetricType::StorePoolError => 526, + MetricType::StoreDataCorruption => 511, + MetricType::StoreDecompressError => 514, + MetricType::StoreDeserializeError => 515, + MetricType::StoreNotFound => 524, + MetricType::StoreNotConfigured => 523, + MetricType::StoreNotSupported => 525, + MetricType::StoreUnexpectedError => 533, + MetricType::StoreCryptoError => 510, + MetricType::StoreHttpStoreError => 493, + MetricType::StoreBlobMissingMarker => 507, + MetricType::StoreDataWrite => 513, + MetricType::StoreDataIterate => 512, + MetricType::StoreBlobRead => 508, + MetricType::StoreBlobWrite => 509, + MetricType::StoreBlobDelete => 506, + MetricType::TaskManagerBlobNotFound => 141, + MetricType::TaskManagerMetadataNotFound => 145, + MetricType::TelemetryAlertEvent => 548, + MetricType::TelemetryAlertMessage => 365, + MetricType::TelemetryLogError => 535, + MetricType::TelemetryWebhookError => 539, + MetricType::TelemetryOtelExporterError => 536, + MetricType::TelemetryOtelMetricsExporterError => 537, + MetricType::TelemetryPrometheusExporterError => 538, + MetricType::TelemetryJournalError => 534, + MetricType::TlsHandshakeError => 544, + _ => usize::MAX, + } + } + + pub fn description(&self) -> &'static str { + match self { + MetricType::AcmeAuthError => "ACME authentication error", + MetricType::AcmeAuthTooManyAttempts => "Too many ACME authentication attempts", + MetricType::AcmeOrderCompleted => "ACME order completed", + MetricType::AcmeOrderInvalid => "ACME order invalid", + MetricType::AcmeClientMissingSni => "ACME client missing SNI", + MetricType::AcmeTlsAlpnError => "ACME TLS ALPN error", + MetricType::AcmeTokenNotFound => "ACME token not found", + MetricType::AcmeError => "ACME error", + MetricType::ArcChainTooLong => "ARC chain too long", + MetricType::ArcInvalidInstance => "Invalid ARC instance", + MetricType::ArcInvalidCv => "Invalid ARC CV", + MetricType::ArcHasHeaderTag => "ARC has header tag", + MetricType::ArcBrokenChain => "Broken ARC chain", + MetricType::AuthSuccess => "Authentication successful", + MetricType::AuthFailed => "Authentication failed", + MetricType::AuthTooManyAttempts => "Too many authentication attempts", + MetricType::AuthError => "Authentication error", + MetricType::CalendarAlarmSent => "Calendar alarm sent", + MetricType::CalendarAlarmFailed => "Calendar alarm could not be sent", + MetricType::CalendarItipMessageSent => "Calendar iTIP message sent", + MetricType::CalendarItipMessageReceived => "Calendar iTIP message received", + MetricType::CalendarItipMessageError => "iTIP message error", + MetricType::ClusterSubscriberError => "PubSub subscriber error", + MetricType::ClusterSubscriberDisconnected => "PubSub subscriber disconnected", + MetricType::ClusterPublisherError => "PubSub publisher error", + MetricType::DaneAuthenticationSuccess => "DANE authentication successful", + MetricType::DaneAuthenticationFailure => "DANE authentication failed", + MetricType::DaneNoCertificatesFound => "No certificates found for DANE", + MetricType::DaneCertificateParseError => "Error parsing certificate for DANE", + MetricType::DaneTlsaRecordFetchError => "Error fetching TLSA record", + MetricType::DaneTlsaRecordNotFound => "TLSA record not found", + MetricType::DaneTlsaRecordNotDnssecSigned => "TLSA record not DNSSEC signed", + MetricType::DaneTlsaRecordInvalid => "Invalid TLSA record", + MetricType::DaneBogusDnssecRecord => "Bogus DNSSEC record", + MetricType::DeliveryTotalTime => { + "Total message delivery time from submission to delivery" + } + MetricType::DeliveryAttemptTime => "Message delivery time", + MetricType::DeliveryActiveConnections => "Active delivery connections", + MetricType::DeliveryAttemptStart => "Delivery attempt started", + MetricType::DeliveryAttemptEnd => "Delivery attempt ended", + MetricType::DeliveryCompleted => "Delivery completed", + MetricType::DeliveryMxLookupFailed => "MX record lookup failed", + MetricType::DeliveryIpLookupFailed => "IP address lookup failed", + MetricType::DeliveryNullMx => "Null MX record found", + MetricType::DeliveryGreetingFailed => "SMTP greeting failed", + MetricType::DeliveryEhloRejected => "SMTP EHLO rejected", + MetricType::DeliveryAuthFailed => "SMTP authentication failed", + MetricType::DeliveryMailFromRejected => "SMTP MAIL FROM rejected", + MetricType::DeliveryDelivered => "Message delivered", + MetricType::DeliveryRcptToRejected => "SMTP RCPT TO rejected", + MetricType::DeliveryRcptToFailed => "SMTP RCPT TO failed", + MetricType::DeliveryMessageRejected => "Message rejected by remote server", + MetricType::DeliveryStartTlsUnavailable => "STARTTLS unavailable", + MetricType::DeliveryStartTlsError => "STARTTLS error", + MetricType::DeliveryStartTlsDisabled => "STARTTLS disabled", + MetricType::DeliveryImplicitTlsError => "Implicit TLS error", + MetricType::DeliveryConcurrencyLimitExceeded => "Concurrency limit exceeded", + MetricType::DeliveryRateLimitExceeded => "Rate limit exceeded", + MetricType::DeliveryDoubleBounce => "Discarding message after double bounce", + MetricType::DeliveryDsnSuccess => "DSN success notification", + MetricType::DeliveryDsnTempFail => "DSN temporary failure notification", + MetricType::DeliveryDsnPermFail => "DSN permanent failure notification", + MetricType::DkimPass => "DKIM verification passed", + MetricType::DkimNeutral => "DKIM verification neutral", + MetricType::DkimFail => "DKIM verification failed", + MetricType::DkimPermError => "DKIM permanent error", + MetricType::DkimTempError => "DKIM temporary error", + MetricType::DkimNone => "No DKIM signature", + MetricType::DkimUnsupportedVersion => "Unsupported DKIM version", + MetricType::DkimUnsupportedAlgorithm => "Unsupported DKIM algorithm", + MetricType::DkimUnsupportedCanonicalization => "Unsupported DKIM canonicalization", + MetricType::DkimUnsupportedKeyType => "Unsupported DKIM key type", + MetricType::DkimFailedBodyHashMatch => "DKIM body hash mismatch", + MetricType::DkimFailedVerification => "DKIM verification failed", + MetricType::DkimFailedAuidMatch => "DKIM AUID mismatch", + MetricType::DkimRevokedPublicKey => "DKIM public key revoked", + MetricType::DkimIncompatibleAlgorithms => "Incompatible DKIM algorithms", + MetricType::DkimSignatureExpired => "DKIM signature expired", + MetricType::DkimSignatureLength => "DKIM signature length issue", + MetricType::DkimSignerNotFound => "DKIM signer not found", + MetricType::DkimInstanceMissing => "DKIM2 message-instance missing", + MetricType::DkimInstanceSyntax => "DKIM2 message-instance syntax error", + MetricType::DkimInstanceTagMissing => "DKIM2 message-instance tag missing", + MetricType::DkimInstanceNotSigned => "DKIM2 message-instance not signed", + MetricType::DkimInstanceAboveSignature => "DKIM2 message-instance above signature", + MetricType::DkimSignatureMissing => "DKIM2 signature missing", + MetricType::DkimSignatureSyntax => "DKIM2 signature syntax error", + MetricType::DkimSignatureTagMissing => "DKIM2 signature tag missing", + MetricType::DkimSignatureTagUnexpected => "DKIM2 signature unexpected tag", + MetricType::DkimSequenceGap => "DKIM2 sequence gap", + MetricType::DkimSequenceOverflow => "DKIM2 sequence overflow", + MetricType::DkimChainTooLong => "DKIM2 chain too long", + MetricType::DkimMailFromMismatch => "DKIM2 MAIL FROM mismatch", + MetricType::DkimRcptToMismatch => "DKIM2 RCPT TO mismatch", + MetricType::DkimMailFromDomainMismatch => "DKIM2 MAIL FROM domain mismatch", + MetricType::DkimNextDomainMismatch => "DKIM2 next domain mismatch", + MetricType::DkimCustodyBreak => "DKIM2 custody break", + MetricType::DkimPublicKeyFetch => "DKIM2 public key fetch error", + MetricType::DkimPublicKeyMissing => "DKIM2 public key missing", + MetricType::DkimPublicKeyMultiple => "DKIM2 multiple public keys", + MetricType::DkimPublicKeySyntax => "DKIM2 public key syntax error", + MetricType::DkimPublicKeyAlgorithmMismatch => "DKIM2 public key algorithm mismatch", + MetricType::DkimNoValidAlgorithm => "DKIM2 no valid algorithm", + MetricType::DkimHeaderHashMismatch => "DKIM2 header hash mismatch", + MetricType::DkimModified => "DKIM2 message modified", + MetricType::DkimExploded => "DKIM2 message exploded", + MetricType::DmarcPass => "DMARC check passed", + MetricType::DmarcFail => "DMARC check failed", + MetricType::DmarcPermError => "DMARC permanent error", + MetricType::DmarcTempError => "DMARC temporary error", + MetricType::DmarcNone => "No DMARC record", + MetricType::DnsLookupTime => "DNS lookup time", + MetricType::DnsRecordCreationFailed => "DNS record creation failed", + MetricType::DnsRecordDeletionFailed => "DNS record deletion failed", + MetricType::DnsRecordLookupFailed => "DNS record lookup failed", + MetricType::DnsRecordPropagationTimeout => "DNS record propagation timeout", + MetricType::DomainCount => "Total number of domains", + MetricType::EvalError => "Expression evaluation error", + MetricType::EvalDirectoryNotFound => "Directory not found while evaluating expression", + MetricType::EvalStoreNotFound => "Store not found while evaluating expression", + MetricType::HttpRequestTime => "HTTP request duration", + MetricType::HttpActiveConnections => "Active HTTP connections", + MetricType::HttpConnectionStart => "HTTP connection started", + MetricType::HttpError => "HTTP error occurred", + MetricType::HttpRequestBody => "HTTP request body", + MetricType::HttpResponseBody => "HTTP response body", + MetricType::HttpXForwardedMissing => "X-Forwarded-For header is missing", + MetricType::ImapRequestTime => "IMAP request duration", + MetricType::ImapActiveConnections => "Active IMAP connections", + MetricType::ImapConnectionStart => "IMAP connection started", + MetricType::ImapConnectionEnd => "IMAP connection ended", + MetricType::IncomingReportDmarcReport => "DMARC report received", + MetricType::IncomingReportDmarcReportWithWarnings => { + "DMARC report received with warnings" + } + MetricType::IncomingReportTlsReport => "TLS report received", + MetricType::IncomingReportTlsReportWithWarnings => "TLS report received with warnings", + MetricType::IncomingReportAbuseReport => "Abuse report received", + MetricType::IncomingReportAuthFailureReport => "Authentication failure report received", + MetricType::IncomingReportFraudReport => "Fraud report received", + MetricType::IncomingReportNotSpamReport => "Not spam report received", + MetricType::IncomingReportVirusReport => "Virus report received", + MetricType::IncomingReportOtherReport => "Other type of report received", + MetricType::IncomingReportMessageParseFailed => { + "Failed to parse incoming report message" + } + MetricType::IncomingReportDmarcParseFailed => "Failed to parse DMARC report", + MetricType::IncomingReportTlsRpcParseFailed => "Failed to parse TLS RPC report", + MetricType::IncomingReportArfParseFailed => "Failed to parse ARF report", + MetricType::IncomingReportDecompressError => "Error decompressing report", + MetricType::IprevPass => "IPREV check passed", + MetricType::IprevFail => "IPREV check failed", + MetricType::IprevPermError => "IPREV permanent error", + MetricType::IprevTempError => "IPREV temporary error", + MetricType::IprevNone => "No IPREV record", + MetricType::JmapMethodCall => "JMAP method call", + MetricType::JmapInvalidArguments => "Invalid JMAP arguments", + MetricType::JmapRequestTooLarge => "JMAP request too large", + MetricType::JmapUnsupportedFilter => "Unsupported JMAP filter", + MetricType::JmapUnsupportedSort => "Unsupported JMAP sort", + MetricType::JmapUnknownMethod => "Unknown JMAP method", + MetricType::JmapForbidden => "JMAP operation forbidden", + MetricType::JmapNotJson => "JMAP request is not JSON", + MetricType::JmapNotRequest => "JMAP input is not a request", + MetricType::JmapWebsocketStart => "JMAP WebSocket connection started", + MetricType::JmapWebsocketError => "JMAP WebSocket error", + MetricType::LimitSizeRequest => "Request size limit reached", + MetricType::LimitSizeUpload => "Upload size limit reached", + MetricType::LimitCallsIn => "Incoming calls limit reached", + MetricType::LimitConcurrentRequest => "Concurrent request limit reached", + MetricType::LimitConcurrentUpload => "Concurrent upload limit reached", + MetricType::LimitConcurrentConnection => "Concurrent connection limit reached", + MetricType::LimitQuota => "Quota limit reached", + MetricType::LimitBlobQuota => "Blob quota limit reached", + MetricType::LimitTenantQuota => "Tenant quota limit reached", + MetricType::LimitTooManyRequests => "Too many requests", + MetricType::MailAuthParseError => "Mail authentication parse error", + MetricType::MailAuthMissingParameters => "Missing mail authentication parameters", + MetricType::MailAuthNoHeadersFound => "No headers found in message", + MetricType::MailAuthCrypto => "Crypto error during mail authentication", + MetricType::MailAuthIo => "I/O error during mail authentication", + MetricType::MailAuthBase64 => "Base64 error during mail authentication", + MetricType::MailAuthDnsError => "DNS error", + MetricType::MailAuthDnsRecordNotFound => "DNS record not found", + MetricType::MailAuthDnsInvalidRecordType => "Invalid DNS record type", + MetricType::MailAuthPolicyNotAligned => "Policy not aligned", + MetricType::ManageSieveConnectionStart => "ManageSieve connection started", + MetricType::ManageSieveConnectionEnd => "ManageSieve connection ended", + MetricType::MessageSize => "Received message size", + MetricType::MessageAuthenticatedSize => { + "Received message size from authenticated users" + } + MetricType::MessageIngestTime => "Message ingestion time", + MetricType::MessageIngestIndexTime => "Message full-text indexing time", + MetricType::MessageIngestHam => "Message ingested", + MetricType::MessageIngestSpam => "Possible spam message ingested", + MetricType::MessageIngestImapAppend => "Message appended via IMAP", + MetricType::MessageIngestJmapAppend => "Message appended via JMAP", + MetricType::MessageIngestDuplicate => "Skipping duplicate message", + MetricType::MessageIngestError => "Message ingestion error", + MetricType::MessageIngestSearchIndex => "Search index updated", + MetricType::MilterActionAccept => "Milter action: Accept", + MetricType::MilterActionDiscard => "Milter action: Discard", + MetricType::MilterActionReject => "Milter action: Reject", + MetricType::MilterActionTempFail => "Milter action: Temporary failure", + MetricType::MilterActionReplyCode => "Milter action: Reply code", + MetricType::MilterActionConnectionFailure => "Milter action: Connection failure", + MetricType::MilterActionShutdown => "Milter action: Shutdown", + MetricType::MtaHookActionAccept => "MTA hook action: Accept", + MetricType::MtaHookActionDiscard => "MTA hook action: Discard", + MetricType::MtaHookActionReject => "MTA hook action: Reject", + MetricType::MtaHookActionQuarantine => "MTA hook action: Quarantine", + MetricType::MtaHookError => "MTA hook error", + MetricType::MtaStsAuthorized => "Host authorized by MTA-STS policy", + MetricType::MtaStsNotAuthorized => "Host not authorized by MTA-STS policy", + MetricType::MtaStsInvalidPolicy => "Invalid MTA-STS policy", + MetricType::NetworkTimeout => "Network timeout", + MetricType::OutgoingReportSize => "Outgoing report size", + MetricType::OutgoingReportSpfReport => "SPF report sent", + MetricType::OutgoingReportSpfRateLimited => "SPF report rate limited", + MetricType::OutgoingReportDkimReport => "DKIM report sent", + MetricType::OutgoingReportDkimRateLimited => "DKIM report rate limited", + MetricType::OutgoingReportDmarcReport => "DMARC report sent", + MetricType::OutgoingReportDmarcRateLimited => "DMARC report rate limited", + MetricType::OutgoingReportDmarcAggregateReport => "DMARC aggregate is being prepared", + MetricType::OutgoingReportTlsAggregate => "TLS aggregate report is being prepared", + MetricType::OutgoingReportHttpSubmission => "Report submitted via HTTP", + MetricType::OutgoingReportUnauthorizedReportingAddress => { + "Unauthorized reporting address" + } + MetricType::OutgoingReportReportingAddressValidationError => { + "Error validating reporting address" + } + MetricType::OutgoingReportNotFound => "Report not found", + MetricType::OutgoingReportSubmissionError => "Error submitting report", + MetricType::OutgoingReportNoRecipientsFound => "No recipients found for report", + MetricType::Pop3RequestTime => "POP3 request duration", + MetricType::Pop3ActiveConnections => "Active POP3 connections", + MetricType::Pop3ConnectionStart => "POP3 connection started", + MetricType::Pop3ConnectionEnd => "POP3 connection ended", + MetricType::PushSubscriptionSuccess => "Push subscription successful", + MetricType::PushSubscriptionError => "Push subscription error", + MetricType::PushSubscriptionNotFound => "Push subscription not found", + MetricType::QueueCount => "Total number of messages in the queue", + MetricType::QueueMessageQueued => "Queued message for delivery", + MetricType::QueueAuthenticatedMessageQueued => "Queued message submission for delivery", + MetricType::QueueReportQueued => "Queued report for delivery", + MetricType::QueueDsnQueued => "Queued DSN for delivery", + MetricType::QueueAutogeneratedQueued => "Queued autogenerated message for delivery", + MetricType::QueueRescheduled => "Message rescheduled for delivery", + MetricType::QueueBlobNotFound => "Message blob not found", + MetricType::QueueRateLimitExceeded => "Rate limit exceeded", + MetricType::QueueConcurrencyLimitExceeded => "Concurrency limit exceeded", + MetricType::QueueQuotaExceeded => "Quota exceeded", + MetricType::ResourceNotFound => "Resource not found", + MetricType::ResourceBadParameters => "Bad resource parameters", + MetricType::ResourceError => "Resource error", + MetricType::SecurityAuthenticationBan => "Banned due to authentication errors", + MetricType::SecurityAbuseBan => "Banned due to abuse", + MetricType::SecurityScanBan => "Banned due to scan", + MetricType::SecurityLoiterBan => "Banned due to loitering", + MetricType::SecurityIpBlocked => "Blocked IP address", + MetricType::SecurityUnauthorized => "Unauthorized access", + MetricType::ServerMemory => "Server memory usage", + MetricType::ServerThreadError => "Server thread error", + MetricType::SieveRequestTime => "ManageSieve request duration", + MetricType::SieveActiveConnections => "Active ManageSieve connections", + MetricType::SieveActionAccept => "Sieve action: Accept", + MetricType::SieveActionAcceptReplace => "Sieve action: Accept and replace", + MetricType::SieveActionDiscard => "Sieve action: Discard", + MetricType::SieveActionReject => "Sieve action: Reject", + MetricType::SieveSendMessage => "Sieve sending message", + MetricType::SieveMessageTooLarge => "Sieve message too large", + MetricType::SieveRuntimeError => "Sieve runtime error", + MetricType::SieveUnexpectedError => "Unexpected Sieve error", + MetricType::SieveNotSupported => "Sieve action not supported", + MetricType::SieveQuotaExceeded => "Sieve quota exceeded", + MetricType::SmtpRequestTime => "SMTP request duration", + MetricType::SmtpActiveConnections => "Active SMTP connections", + MetricType::SmtpConnectionStart => "SMTP connection started", + MetricType::SmtpConnectionEnd => "SMTP connection ended", + MetricType::SmtpError => "SMTP error occurred", + MetricType::SmtpConcurrencyLimitExceeded => "Concurrency limit exceeded", + MetricType::SmtpTransferLimitExceeded => "Transfer limit exceeded", + MetricType::SmtpRateLimitExceeded => "Rate limit exceeded", + MetricType::SmtpTimeLimitExceeded => "Time limit exceeded", + MetricType::SmtpMessageParseFailed => "Message parsing failed", + MetricType::SmtpMessageTooLarge => "Message too large", + MetricType::SmtpLoopDetected => "Mail loop detected", + MetricType::SmtpDkimPass => "DKIM verification passed", + MetricType::SmtpDkimFail => "DKIM verification failed", + MetricType::SmtpDkim2Pass => "DKIM2 verification passed", + MetricType::SmtpDkim2Fail => "DKIM2 verification failed", + MetricType::SmtpDkim2DsnDiscarded => "DKIM2 DSN discarded", + MetricType::SmtpArcPass => "ARC verification passed", + MetricType::SmtpArcFail => "ARC verification failed", + MetricType::SmtpSpfEhloPass => "SPF EHLO check passed", + MetricType::SmtpSpfEhloFail => "SPF EHLO check failed", + MetricType::SmtpSpfFromPass => "SPF From check passed", + MetricType::SmtpSpfFromFail => "SPF From check failed", + MetricType::SmtpDmarcPass => "DMARC check passed", + MetricType::SmtpDmarcFail => "DMARC check failed", + MetricType::SmtpIprevPass => "IPREV check passed", + MetricType::SmtpIprevFail => "IPREV check failed", + MetricType::SmtpTooManyMessages => "Too many messages", + MetricType::SmtpInvalidEhlo => "Invalid EHLO command", + MetricType::SmtpDidNotSayEhlo => "Client did not say EHLO", + MetricType::SmtpMailFromUnauthenticated => "MAIL FROM without authentication", + MetricType::SmtpMailFromUnauthorized => "MAIL FROM unauthorized", + MetricType::SmtpMailFromMissing => "MAIL FROM address missing", + MetricType::SmtpMultipleMailFrom => "Multiple MAIL FROM commands", + MetricType::SmtpMailboxDoesNotExist => "Mailbox does not exist", + MetricType::SmtpRelayNotAllowed => "Relay not allowed", + MetricType::SmtpRcptToDuplicate => "Duplicate RCPT TO", + MetricType::SmtpRcptToMissing => "RCPT TO address missing", + MetricType::SmtpTooManyRecipients => "Too many recipients", + MetricType::SmtpTooManyInvalidRcpt => "Too many invalid recipients", + MetricType::SmtpAuthMechanismNotSupported => "Auth mechanism not supported", + MetricType::SmtpAuthExchangeTooLong => "Auth exchange too long", + MetricType::SmtpCommandNotImplemented => "Command not implemented", + MetricType::SmtpInvalidCommand => "Invalid command", + MetricType::SmtpSyntaxError => "Syntax error", + MetricType::SmtpRequestTooLarge => "Request too large", + MetricType::SpamPyzorError => "Pyzor error", + MetricType::SpamDnsblError => "Error querying DNSBL", + MetricType::SpamTrainCompleted => "Spam classifier training completed", + MetricType::SpamTrainSampleAdded => "New training sample added", + MetricType::SpamClassify => "Classifying message for spam", + MetricType::SpamModelNotReady => "Spam classifier model not ready", + MetricType::SpfPass => "SPF check passed", + MetricType::SpfFail => "SPF check failed", + MetricType::SpfSoftFail => "SPF soft fail", + MetricType::SpfNeutral => "SPF neutral result", + MetricType::SpfTempError => "SPF temporary error", + MetricType::SpfPermError => "SPF permanent error", + MetricType::SpfNone => "No SPF record", + MetricType::StoreDataReadTime => "Data store read time", + MetricType::StoreDataWriteTime => "Data store write time", + MetricType::StoreBlobReadTime => "Blob store read time", + MetricType::StoreBlobWriteTime => "Blob store write time", + MetricType::StoreAssertValueFailed => "Another process modified the record", + MetricType::StoreFoundationdbError => "FoundationDB error", + MetricType::StoreMysqlError => "MySQL error", + MetricType::StorePostgresqlError => "PostgreSQL error", + MetricType::StoreRocksdbError => "RocksDB error", + MetricType::StoreSqliteError => "SQLite error", + MetricType::StoreLdapError => "LDAP error", + MetricType::StoreElasticsearchError => "ElasticSearch error", + MetricType::StoreRedisError => "Redis error", + MetricType::StoreS3Error => "S3 error", + MetricType::StoreAzureError => "Azure error", + MetricType::StoreFilesystemError => "Filesystem error", + MetricType::StorePoolError => "Connection pool error", + MetricType::StoreDataCorruption => "Data corruption detected", + MetricType::StoreDecompressError => "Decompression error", + MetricType::StoreDeserializeError => "Deserialization error", + MetricType::StoreNotFound => "Record not found in database", + MetricType::StoreNotConfigured => "Store not configured", + MetricType::StoreNotSupported => "Operation not supported by store", + MetricType::StoreUnexpectedError => "Unexpected store error", + MetricType::StoreCryptoError => "Store crypto error", + MetricType::StoreHttpStoreError => "Error updating HTTP store", + MetricType::StoreBlobMissingMarker => "Blob missing marker", + MetricType::StoreDataWrite => "Write batch operation", + MetricType::StoreDataIterate => "Data store iteration operation", + MetricType::StoreBlobRead => "Blob read operation", + MetricType::StoreBlobWrite => "Blob write operation", + MetricType::StoreBlobDelete => "Blob delete operation", + MetricType::TaskManagerBlobNotFound => "Blob not found for task", + MetricType::TaskManagerMetadataNotFound => "Metadata not found for task", + MetricType::TelemetryAlertEvent => "Alert event triggered", + MetricType::TelemetryAlertMessage => "Alert message sent", + MetricType::TelemetryLogError => "Log collector error", + MetricType::TelemetryWebhookError => "Webhook collector error", + MetricType::TelemetryOtelExporterError => "OpenTelemetry exporter error", + MetricType::TelemetryOtelMetricsExporterError => "OpenTelemetry metrics exporter error", + MetricType::TelemetryPrometheusExporterError => "Prometheus exporter error", + MetricType::TelemetryJournalError => "Journal collector error", + MetricType::TlsHandshakeError => "TLS handshake error", + MetricType::UserCount => "Total number of users", + } + } + + pub fn unit(&self) -> &'static str { + match self { + MetricType::MessageSize + | MetricType::MessageAuthenticatedSize + | MetricType::OutgoingReportSize + | MetricType::ServerMemory => "bytes", + MetricType::DeliveryActiveConnections + | MetricType::HttpActiveConnections + | MetricType::ImapActiveConnections + | MetricType::Pop3ActiveConnections + | MetricType::SieveActiveConnections + | MetricType::SmtpActiveConnections => "connections", + MetricType::AcmeAuthError + | MetricType::AcmeAuthTooManyAttempts + | MetricType::AcmeOrderCompleted + | MetricType::AcmeOrderInvalid + | MetricType::AcmeClientMissingSni + | MetricType::AcmeTlsAlpnError + | MetricType::AcmeTokenNotFound + | MetricType::AcmeError + | MetricType::ArcChainTooLong + | MetricType::ArcInvalidInstance + | MetricType::ArcInvalidCv + | MetricType::ArcHasHeaderTag + | MetricType::ArcBrokenChain + | MetricType::AuthSuccess + | MetricType::AuthFailed + | MetricType::AuthTooManyAttempts + | MetricType::AuthError + | MetricType::CalendarAlarmSent + | MetricType::CalendarAlarmFailed + | MetricType::CalendarItipMessageSent + | MetricType::CalendarItipMessageReceived + | MetricType::CalendarItipMessageError + | MetricType::ClusterSubscriberError + | MetricType::ClusterSubscriberDisconnected + | MetricType::ClusterPublisherError + | MetricType::DaneAuthenticationSuccess + | MetricType::DaneAuthenticationFailure + | MetricType::DaneNoCertificatesFound + | MetricType::DaneCertificateParseError + | MetricType::DaneTlsaRecordFetchError + | MetricType::DaneTlsaRecordNotFound + | MetricType::DaneTlsaRecordNotDnssecSigned + | MetricType::DaneTlsaRecordInvalid + | MetricType::DaneBogusDnssecRecord + | MetricType::DeliveryAttemptStart + | MetricType::DeliveryAttemptEnd + | MetricType::DeliveryCompleted + | MetricType::DeliveryMxLookupFailed + | MetricType::DeliveryIpLookupFailed + | MetricType::DeliveryNullMx + | MetricType::DeliveryGreetingFailed + | MetricType::DeliveryEhloRejected + | MetricType::DeliveryAuthFailed + | MetricType::DeliveryMailFromRejected + | MetricType::DeliveryDelivered + | MetricType::DeliveryRcptToRejected + | MetricType::DeliveryRcptToFailed + | MetricType::DeliveryMessageRejected + | MetricType::DeliveryStartTlsUnavailable + | MetricType::DeliveryStartTlsError + | MetricType::DeliveryStartTlsDisabled + | MetricType::DeliveryImplicitTlsError + | MetricType::DeliveryConcurrencyLimitExceeded + | MetricType::DeliveryRateLimitExceeded + | MetricType::DeliveryDoubleBounce + | MetricType::DeliveryDsnSuccess + | MetricType::DeliveryDsnTempFail + | MetricType::DeliveryDsnPermFail + | MetricType::DkimPass + | MetricType::DkimNeutral + | MetricType::DkimFail + | MetricType::DkimPermError + | MetricType::DkimTempError + | MetricType::DkimNone + | MetricType::DkimUnsupportedVersion + | MetricType::DkimUnsupportedAlgorithm + | MetricType::DkimUnsupportedCanonicalization + | MetricType::DkimUnsupportedKeyType + | MetricType::DkimFailedBodyHashMatch + | MetricType::DkimFailedVerification + | MetricType::DkimFailedAuidMatch + | MetricType::DkimRevokedPublicKey + | MetricType::DkimIncompatibleAlgorithms + | MetricType::DkimSignatureExpired + | MetricType::DkimSignatureLength + | MetricType::DkimSignerNotFound + | MetricType::DkimInstanceMissing + | MetricType::DkimInstanceSyntax + | MetricType::DkimInstanceTagMissing + | MetricType::DkimInstanceNotSigned + | MetricType::DkimInstanceAboveSignature + | MetricType::DkimSignatureMissing + | MetricType::DkimSignatureSyntax + | MetricType::DkimSignatureTagMissing + | MetricType::DkimSignatureTagUnexpected + | MetricType::DkimSequenceGap + | MetricType::DkimSequenceOverflow + | MetricType::DkimChainTooLong + | MetricType::DkimMailFromMismatch + | MetricType::DkimRcptToMismatch + | MetricType::DkimMailFromDomainMismatch + | MetricType::DkimNextDomainMismatch + | MetricType::DkimCustodyBreak + | MetricType::DkimPublicKeyFetch + | MetricType::DkimPublicKeyMissing + | MetricType::DkimPublicKeyMultiple + | MetricType::DkimPublicKeySyntax + | MetricType::DkimPublicKeyAlgorithmMismatch + | MetricType::DkimNoValidAlgorithm + | MetricType::DkimHeaderHashMismatch + | MetricType::DkimModified + | MetricType::DkimExploded + | MetricType::DmarcPass + | MetricType::DmarcFail + | MetricType::DmarcPermError + | MetricType::DmarcTempError + | MetricType::DmarcNone + | MetricType::DnsRecordCreationFailed + | MetricType::DnsRecordDeletionFailed + | MetricType::DnsRecordLookupFailed + | MetricType::DnsRecordPropagationTimeout + | MetricType::EvalError + | MetricType::EvalDirectoryNotFound + | MetricType::EvalStoreNotFound + | MetricType::HttpConnectionStart + | MetricType::HttpError + | MetricType::HttpRequestBody + | MetricType::HttpResponseBody + | MetricType::HttpXForwardedMissing + | MetricType::ImapConnectionStart + | MetricType::ImapConnectionEnd + | MetricType::IncomingReportDmarcReport + | MetricType::IncomingReportDmarcReportWithWarnings + | MetricType::IncomingReportTlsReport + | MetricType::IncomingReportTlsReportWithWarnings + | MetricType::IncomingReportAbuseReport + | MetricType::IncomingReportAuthFailureReport + | MetricType::IncomingReportFraudReport + | MetricType::IncomingReportNotSpamReport + | MetricType::IncomingReportVirusReport + | MetricType::IncomingReportOtherReport + | MetricType::IncomingReportMessageParseFailed + | MetricType::IncomingReportDmarcParseFailed + | MetricType::IncomingReportTlsRpcParseFailed + | MetricType::IncomingReportArfParseFailed + | MetricType::IncomingReportDecompressError + | MetricType::IprevPass + | MetricType::IprevFail + | MetricType::IprevPermError + | MetricType::IprevTempError + | MetricType::IprevNone + | MetricType::JmapMethodCall + | MetricType::JmapInvalidArguments + | MetricType::JmapRequestTooLarge + | MetricType::JmapUnsupportedFilter + | MetricType::JmapUnsupportedSort + | MetricType::JmapUnknownMethod + | MetricType::JmapForbidden + | MetricType::JmapNotJson + | MetricType::JmapNotRequest + | MetricType::JmapWebsocketStart + | MetricType::JmapWebsocketError + | MetricType::LimitSizeRequest + | MetricType::LimitSizeUpload + | MetricType::LimitCallsIn + | MetricType::LimitConcurrentRequest + | MetricType::LimitConcurrentUpload + | MetricType::LimitConcurrentConnection + | MetricType::LimitQuota + | MetricType::LimitBlobQuota + | MetricType::LimitTenantQuota + | MetricType::LimitTooManyRequests + | MetricType::MailAuthParseError + | MetricType::MailAuthMissingParameters + | MetricType::MailAuthNoHeadersFound + | MetricType::MailAuthCrypto + | MetricType::MailAuthIo + | MetricType::MailAuthBase64 + | MetricType::MailAuthDnsError + | MetricType::MailAuthDnsRecordNotFound + | MetricType::MailAuthDnsInvalidRecordType + | MetricType::MailAuthPolicyNotAligned + | MetricType::ManageSieveConnectionStart + | MetricType::ManageSieveConnectionEnd + | MetricType::MessageIngestHam + | MetricType::MessageIngestSpam + | MetricType::MessageIngestImapAppend + | MetricType::MessageIngestJmapAppend + | MetricType::MessageIngestDuplicate + | MetricType::MessageIngestError + | MetricType::MessageIngestSearchIndex + | MetricType::MilterActionAccept + | MetricType::MilterActionDiscard + | MetricType::MilterActionReject + | MetricType::MilterActionTempFail + | MetricType::MilterActionReplyCode + | MetricType::MilterActionConnectionFailure + | MetricType::MilterActionShutdown + | MetricType::MtaHookActionAccept + | MetricType::MtaHookActionDiscard + | MetricType::MtaHookActionReject + | MetricType::MtaHookActionQuarantine + | MetricType::MtaHookError + | MetricType::MtaStsAuthorized + | MetricType::MtaStsNotAuthorized + | MetricType::MtaStsInvalidPolicy + | MetricType::NetworkTimeout + | MetricType::OutgoingReportSpfReport + | MetricType::OutgoingReportSpfRateLimited + | MetricType::OutgoingReportDkimReport + | MetricType::OutgoingReportDkimRateLimited + | MetricType::OutgoingReportDmarcReport + | MetricType::OutgoingReportDmarcRateLimited + | MetricType::OutgoingReportDmarcAggregateReport + | MetricType::OutgoingReportTlsAggregate + | MetricType::OutgoingReportHttpSubmission + | MetricType::OutgoingReportUnauthorizedReportingAddress + | MetricType::OutgoingReportReportingAddressValidationError + | MetricType::OutgoingReportNotFound + | MetricType::OutgoingReportSubmissionError + | MetricType::OutgoingReportNoRecipientsFound + | MetricType::Pop3ConnectionStart + | MetricType::Pop3ConnectionEnd + | MetricType::PushSubscriptionSuccess + | MetricType::PushSubscriptionError + | MetricType::PushSubscriptionNotFound + | MetricType::QueueMessageQueued + | MetricType::QueueAuthenticatedMessageQueued + | MetricType::QueueReportQueued + | MetricType::QueueDsnQueued + | MetricType::QueueAutogeneratedQueued + | MetricType::QueueRescheduled + | MetricType::QueueBlobNotFound + | MetricType::QueueRateLimitExceeded + | MetricType::QueueConcurrencyLimitExceeded + | MetricType::QueueQuotaExceeded + | MetricType::ResourceNotFound + | MetricType::ResourceBadParameters + | MetricType::ResourceError + | MetricType::SecurityAuthenticationBan + | MetricType::SecurityAbuseBan + | MetricType::SecurityScanBan + | MetricType::SecurityLoiterBan + | MetricType::SecurityIpBlocked + | MetricType::SecurityUnauthorized + | MetricType::ServerThreadError + | MetricType::SieveActionAccept + | MetricType::SieveActionAcceptReplace + | MetricType::SieveActionDiscard + | MetricType::SieveActionReject + | MetricType::SieveSendMessage + | MetricType::SieveMessageTooLarge + | MetricType::SieveRuntimeError + | MetricType::SieveUnexpectedError + | MetricType::SieveNotSupported + | MetricType::SieveQuotaExceeded + | MetricType::SmtpConnectionStart + | MetricType::SmtpConnectionEnd + | MetricType::SmtpError + | MetricType::SmtpConcurrencyLimitExceeded + | MetricType::SmtpTransferLimitExceeded + | MetricType::SmtpRateLimitExceeded + | MetricType::SmtpTimeLimitExceeded + | MetricType::SmtpMessageParseFailed + | MetricType::SmtpMessageTooLarge + | MetricType::SmtpLoopDetected + | MetricType::SmtpDkimPass + | MetricType::SmtpDkimFail + | MetricType::SmtpDkim2Pass + | MetricType::SmtpDkim2Fail + | MetricType::SmtpDkim2DsnDiscarded + | MetricType::SmtpArcPass + | MetricType::SmtpArcFail + | MetricType::SmtpSpfEhloPass + | MetricType::SmtpSpfEhloFail + | MetricType::SmtpSpfFromPass + | MetricType::SmtpSpfFromFail + | MetricType::SmtpDmarcPass + | MetricType::SmtpDmarcFail + | MetricType::SmtpIprevPass + | MetricType::SmtpIprevFail + | MetricType::SmtpTooManyMessages + | MetricType::SmtpInvalidEhlo + | MetricType::SmtpDidNotSayEhlo + | MetricType::SmtpMailFromUnauthenticated + | MetricType::SmtpMailFromUnauthorized + | MetricType::SmtpMailFromMissing + | MetricType::SmtpMultipleMailFrom + | MetricType::SmtpMailboxDoesNotExist + | MetricType::SmtpRelayNotAllowed + | MetricType::SmtpRcptToDuplicate + | MetricType::SmtpRcptToMissing + | MetricType::SmtpTooManyRecipients + | MetricType::SmtpTooManyInvalidRcpt + | MetricType::SmtpAuthMechanismNotSupported + | MetricType::SmtpAuthExchangeTooLong + | MetricType::SmtpCommandNotImplemented + | MetricType::SmtpInvalidCommand + | MetricType::SmtpSyntaxError + | MetricType::SmtpRequestTooLarge + | MetricType::SpamPyzorError + | MetricType::SpamDnsblError + | MetricType::SpamTrainCompleted + | MetricType::SpamTrainSampleAdded + | MetricType::SpamClassify + | MetricType::SpamModelNotReady + | MetricType::SpfPass + | MetricType::SpfFail + | MetricType::SpfSoftFail + | MetricType::SpfNeutral + | MetricType::SpfTempError + | MetricType::SpfPermError + | MetricType::SpfNone + | MetricType::StoreAssertValueFailed + | MetricType::StoreFoundationdbError + | MetricType::StoreMysqlError + | MetricType::StorePostgresqlError + | MetricType::StoreRocksdbError + | MetricType::StoreSqliteError + | MetricType::StoreLdapError + | MetricType::StoreElasticsearchError + | MetricType::StoreRedisError + | MetricType::StoreS3Error + | MetricType::StoreAzureError + | MetricType::StoreFilesystemError + | MetricType::StorePoolError + | MetricType::StoreDataCorruption + | MetricType::StoreDecompressError + | MetricType::StoreDeserializeError + | MetricType::StoreNotFound + | MetricType::StoreNotConfigured + | MetricType::StoreNotSupported + | MetricType::StoreUnexpectedError + | MetricType::StoreCryptoError + | MetricType::StoreHttpStoreError + | MetricType::StoreBlobMissingMarker + | MetricType::StoreDataWrite + | MetricType::StoreDataIterate + | MetricType::StoreBlobRead + | MetricType::StoreBlobWrite + | MetricType::StoreBlobDelete + | MetricType::TaskManagerBlobNotFound + | MetricType::TaskManagerMetadataNotFound + | MetricType::TelemetryAlertEvent + | MetricType::TelemetryAlertMessage + | MetricType::TelemetryLogError + | MetricType::TelemetryWebhookError + | MetricType::TelemetryOtelExporterError + | MetricType::TelemetryOtelMetricsExporterError + | MetricType::TelemetryPrometheusExporterError + | MetricType::TelemetryJournalError + | MetricType::TlsHandshakeError => "count", + MetricType::DomainCount => "domains", + MetricType::QueueCount => "messages", + MetricType::DeliveryTotalTime + | MetricType::DeliveryAttemptTime + | MetricType::DnsLookupTime + | MetricType::HttpRequestTime + | MetricType::ImapRequestTime + | MetricType::MessageIngestTime + | MetricType::MessageIngestIndexTime + | MetricType::Pop3RequestTime + | MetricType::SieveRequestTime + | MetricType::SmtpRequestTime + | MetricType::StoreDataReadTime + | MetricType::StoreDataWriteTime + | MetricType::StoreBlobReadTime + | MetricType::StoreBlobWriteTime => "milliseconds", + MetricType::UserCount => "users", + } + } + + pub const fn variants() -> &'static [Self] { + &[ + MetricType::AcmeAuthError, + MetricType::AcmeAuthTooManyAttempts, + MetricType::AcmeOrderCompleted, + MetricType::AcmeOrderInvalid, + MetricType::AcmeClientMissingSni, + MetricType::AcmeTlsAlpnError, + MetricType::AcmeTokenNotFound, + MetricType::AcmeError, + MetricType::ArcChainTooLong, + MetricType::ArcInvalidInstance, + MetricType::ArcInvalidCv, + MetricType::ArcHasHeaderTag, + MetricType::ArcBrokenChain, + MetricType::AuthSuccess, + MetricType::AuthFailed, + MetricType::AuthTooManyAttempts, + MetricType::AuthError, + MetricType::CalendarAlarmSent, + MetricType::CalendarAlarmFailed, + MetricType::CalendarItipMessageSent, + MetricType::CalendarItipMessageReceived, + MetricType::CalendarItipMessageError, + MetricType::ClusterSubscriberError, + MetricType::ClusterSubscriberDisconnected, + MetricType::ClusterPublisherError, + MetricType::DaneAuthenticationSuccess, + MetricType::DaneAuthenticationFailure, + MetricType::DaneNoCertificatesFound, + MetricType::DaneCertificateParseError, + MetricType::DaneTlsaRecordFetchError, + MetricType::DaneTlsaRecordNotFound, + MetricType::DaneTlsaRecordNotDnssecSigned, + MetricType::DaneTlsaRecordInvalid, + MetricType::DaneBogusDnssecRecord, + MetricType::DeliveryTotalTime, + MetricType::DeliveryAttemptTime, + MetricType::DeliveryActiveConnections, + MetricType::DeliveryAttemptStart, + MetricType::DeliveryAttemptEnd, + MetricType::DeliveryCompleted, + MetricType::DeliveryMxLookupFailed, + MetricType::DeliveryIpLookupFailed, + MetricType::DeliveryNullMx, + MetricType::DeliveryGreetingFailed, + MetricType::DeliveryEhloRejected, + MetricType::DeliveryAuthFailed, + MetricType::DeliveryMailFromRejected, + MetricType::DeliveryDelivered, + MetricType::DeliveryRcptToRejected, + MetricType::DeliveryRcptToFailed, + MetricType::DeliveryMessageRejected, + MetricType::DeliveryStartTlsUnavailable, + MetricType::DeliveryStartTlsError, + MetricType::DeliveryStartTlsDisabled, + MetricType::DeliveryImplicitTlsError, + MetricType::DeliveryConcurrencyLimitExceeded, + MetricType::DeliveryRateLimitExceeded, + MetricType::DeliveryDoubleBounce, + MetricType::DeliveryDsnSuccess, + MetricType::DeliveryDsnTempFail, + MetricType::DeliveryDsnPermFail, + MetricType::DkimPass, + MetricType::DkimNeutral, + MetricType::DkimFail, + MetricType::DkimPermError, + MetricType::DkimTempError, + MetricType::DkimNone, + MetricType::DkimUnsupportedVersion, + MetricType::DkimUnsupportedAlgorithm, + MetricType::DkimUnsupportedCanonicalization, + MetricType::DkimUnsupportedKeyType, + MetricType::DkimFailedBodyHashMatch, + MetricType::DkimFailedVerification, + MetricType::DkimFailedAuidMatch, + MetricType::DkimRevokedPublicKey, + MetricType::DkimIncompatibleAlgorithms, + MetricType::DkimSignatureExpired, + MetricType::DkimSignatureLength, + MetricType::DkimSignerNotFound, + MetricType::DkimInstanceMissing, + MetricType::DkimInstanceSyntax, + MetricType::DkimInstanceTagMissing, + MetricType::DkimInstanceNotSigned, + MetricType::DkimInstanceAboveSignature, + MetricType::DkimSignatureMissing, + MetricType::DkimSignatureSyntax, + MetricType::DkimSignatureTagMissing, + MetricType::DkimSignatureTagUnexpected, + MetricType::DkimSequenceGap, + MetricType::DkimSequenceOverflow, + MetricType::DkimChainTooLong, + MetricType::DkimMailFromMismatch, + MetricType::DkimRcptToMismatch, + MetricType::DkimMailFromDomainMismatch, + MetricType::DkimNextDomainMismatch, + MetricType::DkimCustodyBreak, + MetricType::DkimPublicKeyFetch, + MetricType::DkimPublicKeyMissing, + MetricType::DkimPublicKeyMultiple, + MetricType::DkimPublicKeySyntax, + MetricType::DkimPublicKeyAlgorithmMismatch, + MetricType::DkimNoValidAlgorithm, + MetricType::DkimHeaderHashMismatch, + MetricType::DkimModified, + MetricType::DkimExploded, + MetricType::DmarcPass, + MetricType::DmarcFail, + MetricType::DmarcPermError, + MetricType::DmarcTempError, + MetricType::DmarcNone, + MetricType::DnsLookupTime, + MetricType::DnsRecordCreationFailed, + MetricType::DnsRecordDeletionFailed, + MetricType::DnsRecordLookupFailed, + MetricType::DnsRecordPropagationTimeout, + MetricType::DomainCount, + MetricType::EvalError, + MetricType::EvalDirectoryNotFound, + MetricType::EvalStoreNotFound, + MetricType::HttpRequestTime, + MetricType::HttpActiveConnections, + MetricType::HttpConnectionStart, + MetricType::HttpError, + MetricType::HttpRequestBody, + MetricType::HttpResponseBody, + MetricType::HttpXForwardedMissing, + MetricType::ImapRequestTime, + MetricType::ImapActiveConnections, + MetricType::ImapConnectionStart, + MetricType::ImapConnectionEnd, + MetricType::IncomingReportDmarcReport, + MetricType::IncomingReportDmarcReportWithWarnings, + MetricType::IncomingReportTlsReport, + MetricType::IncomingReportTlsReportWithWarnings, + MetricType::IncomingReportAbuseReport, + MetricType::IncomingReportAuthFailureReport, + MetricType::IncomingReportFraudReport, + MetricType::IncomingReportNotSpamReport, + MetricType::IncomingReportVirusReport, + MetricType::IncomingReportOtherReport, + MetricType::IncomingReportMessageParseFailed, + MetricType::IncomingReportDmarcParseFailed, + MetricType::IncomingReportTlsRpcParseFailed, + MetricType::IncomingReportArfParseFailed, + MetricType::IncomingReportDecompressError, + MetricType::IprevPass, + MetricType::IprevFail, + MetricType::IprevPermError, + MetricType::IprevTempError, + MetricType::IprevNone, + MetricType::JmapMethodCall, + MetricType::JmapInvalidArguments, + MetricType::JmapRequestTooLarge, + MetricType::JmapUnsupportedFilter, + MetricType::JmapUnsupportedSort, + MetricType::JmapUnknownMethod, + MetricType::JmapForbidden, + MetricType::JmapNotJson, + MetricType::JmapNotRequest, + MetricType::JmapWebsocketStart, + MetricType::JmapWebsocketError, + MetricType::LimitSizeRequest, + MetricType::LimitSizeUpload, + MetricType::LimitCallsIn, + MetricType::LimitConcurrentRequest, + MetricType::LimitConcurrentUpload, + MetricType::LimitConcurrentConnection, + MetricType::LimitQuota, + MetricType::LimitBlobQuota, + MetricType::LimitTenantQuota, + MetricType::LimitTooManyRequests, + MetricType::MailAuthParseError, + MetricType::MailAuthMissingParameters, + MetricType::MailAuthNoHeadersFound, + MetricType::MailAuthCrypto, + MetricType::MailAuthIo, + MetricType::MailAuthBase64, + MetricType::MailAuthDnsError, + MetricType::MailAuthDnsRecordNotFound, + MetricType::MailAuthDnsInvalidRecordType, + MetricType::MailAuthPolicyNotAligned, + MetricType::ManageSieveConnectionStart, + MetricType::ManageSieveConnectionEnd, + MetricType::MessageSize, + MetricType::MessageAuthenticatedSize, + MetricType::MessageIngestTime, + MetricType::MessageIngestIndexTime, + MetricType::MessageIngestHam, + MetricType::MessageIngestSpam, + MetricType::MessageIngestImapAppend, + MetricType::MessageIngestJmapAppend, + MetricType::MessageIngestDuplicate, + MetricType::MessageIngestError, + MetricType::MessageIngestSearchIndex, + MetricType::MilterActionAccept, + MetricType::MilterActionDiscard, + MetricType::MilterActionReject, + MetricType::MilterActionTempFail, + MetricType::MilterActionReplyCode, + MetricType::MilterActionConnectionFailure, + MetricType::MilterActionShutdown, + MetricType::MtaHookActionAccept, + MetricType::MtaHookActionDiscard, + MetricType::MtaHookActionReject, + MetricType::MtaHookActionQuarantine, + MetricType::MtaHookError, + MetricType::MtaStsAuthorized, + MetricType::MtaStsNotAuthorized, + MetricType::MtaStsInvalidPolicy, + MetricType::NetworkTimeout, + MetricType::OutgoingReportSize, + MetricType::OutgoingReportSpfReport, + MetricType::OutgoingReportSpfRateLimited, + MetricType::OutgoingReportDkimReport, + MetricType::OutgoingReportDkimRateLimited, + MetricType::OutgoingReportDmarcReport, + MetricType::OutgoingReportDmarcRateLimited, + MetricType::OutgoingReportDmarcAggregateReport, + MetricType::OutgoingReportTlsAggregate, + MetricType::OutgoingReportHttpSubmission, + MetricType::OutgoingReportUnauthorizedReportingAddress, + MetricType::OutgoingReportReportingAddressValidationError, + MetricType::OutgoingReportNotFound, + MetricType::OutgoingReportSubmissionError, + MetricType::OutgoingReportNoRecipientsFound, + MetricType::Pop3RequestTime, + MetricType::Pop3ActiveConnections, + MetricType::Pop3ConnectionStart, + MetricType::Pop3ConnectionEnd, + MetricType::PushSubscriptionSuccess, + MetricType::PushSubscriptionError, + MetricType::PushSubscriptionNotFound, + MetricType::QueueCount, + MetricType::QueueMessageQueued, + MetricType::QueueAuthenticatedMessageQueued, + MetricType::QueueReportQueued, + MetricType::QueueDsnQueued, + MetricType::QueueAutogeneratedQueued, + MetricType::QueueRescheduled, + MetricType::QueueBlobNotFound, + MetricType::QueueRateLimitExceeded, + MetricType::QueueConcurrencyLimitExceeded, + MetricType::QueueQuotaExceeded, + MetricType::ResourceNotFound, + MetricType::ResourceBadParameters, + MetricType::ResourceError, + MetricType::SecurityAuthenticationBan, + MetricType::SecurityAbuseBan, + MetricType::SecurityScanBan, + MetricType::SecurityLoiterBan, + MetricType::SecurityIpBlocked, + MetricType::SecurityUnauthorized, + MetricType::ServerMemory, + MetricType::ServerThreadError, + MetricType::SieveRequestTime, + MetricType::SieveActiveConnections, + MetricType::SieveActionAccept, + MetricType::SieveActionAcceptReplace, + MetricType::SieveActionDiscard, + MetricType::SieveActionReject, + MetricType::SieveSendMessage, + MetricType::SieveMessageTooLarge, + MetricType::SieveRuntimeError, + MetricType::SieveUnexpectedError, + MetricType::SieveNotSupported, + MetricType::SieveQuotaExceeded, + MetricType::SmtpRequestTime, + MetricType::SmtpActiveConnections, + MetricType::SmtpConnectionStart, + MetricType::SmtpConnectionEnd, + MetricType::SmtpError, + MetricType::SmtpConcurrencyLimitExceeded, + MetricType::SmtpTransferLimitExceeded, + MetricType::SmtpRateLimitExceeded, + MetricType::SmtpTimeLimitExceeded, + MetricType::SmtpMessageParseFailed, + MetricType::SmtpMessageTooLarge, + MetricType::SmtpLoopDetected, + MetricType::SmtpDkimPass, + MetricType::SmtpDkimFail, + MetricType::SmtpDkim2Pass, + MetricType::SmtpDkim2Fail, + MetricType::SmtpDkim2DsnDiscarded, + MetricType::SmtpArcPass, + MetricType::SmtpArcFail, + MetricType::SmtpSpfEhloPass, + MetricType::SmtpSpfEhloFail, + MetricType::SmtpSpfFromPass, + MetricType::SmtpSpfFromFail, + MetricType::SmtpDmarcPass, + MetricType::SmtpDmarcFail, + MetricType::SmtpIprevPass, + MetricType::SmtpIprevFail, + MetricType::SmtpTooManyMessages, + MetricType::SmtpInvalidEhlo, + MetricType::SmtpDidNotSayEhlo, + MetricType::SmtpMailFromUnauthenticated, + MetricType::SmtpMailFromUnauthorized, + MetricType::SmtpMailFromMissing, + MetricType::SmtpMultipleMailFrom, + MetricType::SmtpMailboxDoesNotExist, + MetricType::SmtpRelayNotAllowed, + MetricType::SmtpRcptToDuplicate, + MetricType::SmtpRcptToMissing, + MetricType::SmtpTooManyRecipients, + MetricType::SmtpTooManyInvalidRcpt, + MetricType::SmtpAuthMechanismNotSupported, + MetricType::SmtpAuthExchangeTooLong, + MetricType::SmtpCommandNotImplemented, + MetricType::SmtpInvalidCommand, + MetricType::SmtpSyntaxError, + MetricType::SmtpRequestTooLarge, + MetricType::SpamPyzorError, + MetricType::SpamDnsblError, + MetricType::SpamTrainCompleted, + MetricType::SpamTrainSampleAdded, + MetricType::SpamClassify, + MetricType::SpamModelNotReady, + MetricType::SpfPass, + MetricType::SpfFail, + MetricType::SpfSoftFail, + MetricType::SpfNeutral, + MetricType::SpfTempError, + MetricType::SpfPermError, + MetricType::SpfNone, + MetricType::StoreDataReadTime, + MetricType::StoreDataWriteTime, + MetricType::StoreBlobReadTime, + MetricType::StoreBlobWriteTime, + MetricType::StoreAssertValueFailed, + MetricType::StoreFoundationdbError, + MetricType::StoreMysqlError, + MetricType::StorePostgresqlError, + MetricType::StoreRocksdbError, + MetricType::StoreSqliteError, + MetricType::StoreLdapError, + MetricType::StoreElasticsearchError, + MetricType::StoreRedisError, + MetricType::StoreS3Error, + MetricType::StoreAzureError, + MetricType::StoreFilesystemError, + MetricType::StorePoolError, + MetricType::StoreDataCorruption, + MetricType::StoreDecompressError, + MetricType::StoreDeserializeError, + MetricType::StoreNotFound, + MetricType::StoreNotConfigured, + MetricType::StoreNotSupported, + MetricType::StoreUnexpectedError, + MetricType::StoreCryptoError, + MetricType::StoreHttpStoreError, + MetricType::StoreBlobMissingMarker, + MetricType::StoreDataWrite, + MetricType::StoreDataIterate, + MetricType::StoreBlobRead, + MetricType::StoreBlobWrite, + MetricType::StoreBlobDelete, + MetricType::TaskManagerBlobNotFound, + MetricType::TaskManagerMetadataNotFound, + MetricType::TelemetryAlertEvent, + MetricType::TelemetryAlertMessage, + MetricType::TelemetryLogError, + MetricType::TelemetryWebhookError, + MetricType::TelemetryOtelExporterError, + MetricType::TelemetryOtelMetricsExporterError, + MetricType::TelemetryPrometheusExporterError, + MetricType::TelemetryJournalError, + MetricType::TlsHandshakeError, + MetricType::UserCount, + ] + } +} + +impl Key { + pub fn parse(value: &str) -> Option { + hashify::map! { + value.as_bytes(), + Key, + b"accountName" => Key::AccountName, + b"accountId" => Key::AccountId, + b"blobId" => Key::BlobId, + b"causedBy" => Key::CausedBy, + b"changeId" => Key::ChangeId, + b"code" => Key::Code, + b"collection" => Key::Collection, + b"contents" => Key::Contents, + b"details" => Key::Details, + b"dkimFail" => Key::DkimFail, + b"dkimNone" => Key::DkimNone, + b"dkimPass" => Key::DkimPass, + b"dmarcNone" => Key::DmarcNone, + b"dmarcPass" => Key::DmarcPass, + b"dmarcQuarantine" => Key::DmarcQuarantine, + b"dmarcReject" => Key::DmarcReject, + b"documentId" => Key::DocumentId, + b"domain" => Key::Domain, + b"due" => Key::Due, + b"elapsed" => Key::Elapsed, + b"expires" => Key::Expires, + b"from" => Key::From, + b"hostname" => Key::Hostname, + b"id" => Key::Id, + b"key" => Key::Key, + b"limit" => Key::Limit, + b"listenerId" => Key::ListenerId, + b"localIp" => Key::LocalIp, + b"localPort" => Key::LocalPort, + b"mailboxName" => Key::MailboxName, + b"mailboxId" => Key::MailboxId, + b"messageId" => Key::MessageId, + b"nextDsn" => Key::NextDsn, + b"nextRetry" => Key::NextRetry, + b"path" => Key::Path, + b"policy" => Key::Policy, + b"queueId" => Key::QueueId, + b"rangeFrom" => Key::RangeFrom, + b"rangeTo" => Key::RangeTo, + b"reason" => Key::Reason, + b"remoteIp" => Key::RemoteIp, + b"remotePort" => Key::RemotePort, + b"reportId" => Key::ReportId, + b"result" => Key::Result, + b"size" => Key::Size, + b"source" => Key::Source, + b"spanId" => Key::SpanId, + b"spfFail" => Key::SpfFail, + b"spfNone" => Key::SpfNone, + b"spfPass" => Key::SpfPass, + b"strict" => Key::Strict, + b"tls" => Key::Tls, + b"to" => Key::To, + b"total" => Key::Total, + b"totalFailures" => Key::TotalFailures, + b"totalSuccesses" => Key::TotalSuccesses, + b"type" => Key::Type, + b"uid" => Key::Uid, + b"uidNext" => Key::UidNext, + b"uidValidity" => Key::UidValidity, + b"url" => Key::Url, + b"validFrom" => Key::ValidFrom, + b"validTo" => Key::ValidTo, + b"value" => Key::Value, + b"version" => Key::Version, + b"queueName" => Key::QueueName, + } + .copied() + } + + pub fn as_str(&self) -> &'static str { + match self { + Key::AccountName => "accountName", + Key::AccountId => "accountId", + Key::BlobId => "blobId", + Key::CausedBy => "causedBy", + Key::ChangeId => "changeId", + Key::Code => "code", + Key::Collection => "collection", + Key::Contents => "contents", + Key::Details => "details", + Key::DkimFail => "dkimFail", + Key::DkimNone => "dkimNone", + Key::DkimPass => "dkimPass", + Key::DmarcNone => "dmarcNone", + Key::DmarcPass => "dmarcPass", + Key::DmarcQuarantine => "dmarcQuarantine", + Key::DmarcReject => "dmarcReject", + Key::DocumentId => "documentId", + Key::Domain => "domain", + Key::Due => "due", + Key::Elapsed => "elapsed", + Key::Expires => "expires", + Key::From => "from", + Key::Hostname => "hostname", + Key::Id => "id", + Key::Key => "key", + Key::Limit => "limit", + Key::ListenerId => "listenerId", + Key::LocalIp => "localIp", + Key::LocalPort => "localPort", + Key::MailboxName => "mailboxName", + Key::MailboxId => "mailboxId", + Key::MessageId => "messageId", + Key::NextDsn => "nextDsn", + Key::NextRetry => "nextRetry", + Key::Path => "path", + Key::Policy => "policy", + Key::QueueId => "queueId", + Key::RangeFrom => "rangeFrom", + Key::RangeTo => "rangeTo", + Key::Reason => "reason", + Key::RemoteIp => "remoteIp", + Key::RemotePort => "remotePort", + Key::ReportId => "reportId", + Key::Result => "result", + Key::Size => "size", + Key::Source => "source", + Key::SpanId => "spanId", + Key::SpfFail => "spfFail", + Key::SpfNone => "spfNone", + Key::SpfPass => "spfPass", + Key::Strict => "strict", + Key::Tls => "tls", + Key::To => "to", + Key::Total => "total", + Key::TotalFailures => "totalFailures", + Key::TotalSuccesses => "totalSuccesses", + Key::Type => "type", + Key::Uid => "uid", + Key::UidNext => "uidNext", + Key::UidValidity => "uidValidity", + Key::Url => "url", + Key::ValidFrom => "validFrom", + Key::ValidTo => "validTo", + Key::Value => "value", + Key::Version => "version", + Key::QueueName => "queueName", + } + } + + pub fn to_id(&self) -> u16 { + *self as u16 + } + + pub fn from_id(id: u16) -> Option { + match id { + 0 => Some(Key::AccountName), + 1 => Some(Key::AccountId), + 2 => Some(Key::BlobId), + 3 => Some(Key::CausedBy), + 4 => Some(Key::ChangeId), + 5 => Some(Key::Code), + 6 => Some(Key::Collection), + 7 => Some(Key::Contents), + 8 => Some(Key::Details), + 9 => Some(Key::DkimFail), + 10 => Some(Key::DkimNone), + 11 => Some(Key::DkimPass), + 12 => Some(Key::DmarcNone), + 13 => Some(Key::DmarcPass), + 14 => Some(Key::DmarcQuarantine), + 15 => Some(Key::DmarcReject), + 16 => Some(Key::DocumentId), + 17 => Some(Key::Domain), + 18 => Some(Key::Due), + 19 => Some(Key::Elapsed), + 20 => Some(Key::Expires), + 21 => Some(Key::From), + 22 => Some(Key::Hostname), + 23 => Some(Key::Id), + 24 => Some(Key::Key), + 25 => Some(Key::Limit), + 26 => Some(Key::ListenerId), + 27 => Some(Key::LocalIp), + 28 => Some(Key::LocalPort), + 29 => Some(Key::MailboxName), + 30 => Some(Key::MailboxId), + 31 => Some(Key::MessageId), + 32 => Some(Key::NextDsn), + 33 => Some(Key::NextRetry), + 34 => Some(Key::Path), + 35 => Some(Key::Policy), + 36 => Some(Key::QueueId), + 37 => Some(Key::RangeFrom), + 38 => Some(Key::RangeTo), + 39 => Some(Key::Reason), + 40 => Some(Key::RemoteIp), + 41 => Some(Key::RemotePort), + 42 => Some(Key::ReportId), + 43 => Some(Key::Result), + 44 => Some(Key::Size), + 45 => Some(Key::Source), + 46 => Some(Key::SpanId), + 47 => Some(Key::SpfFail), + 48 => Some(Key::SpfNone), + 49 => Some(Key::SpfPass), + 50 => Some(Key::Strict), + 51 => Some(Key::Tls), + 52 => Some(Key::To), + 53 => Some(Key::Total), + 54 => Some(Key::TotalFailures), + 55 => Some(Key::TotalSuccesses), + 56 => Some(Key::Type), + 57 => Some(Key::Uid), + 58 => Some(Key::UidNext), + 59 => Some(Key::UidValidity), + 60 => Some(Key::Url), + 61 => Some(Key::ValidFrom), + 62 => Some(Key::ValidTo), + 63 => Some(Key::Value), + 64 => Some(Key::Version), + 65 => Some(Key::QueueName), + _ => None, + } + } + + pub const COUNT: usize = 66; +} + +impl serde::Serialize for Key { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Key { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs new file mode 100644 index 0000000..92c32cc --- /dev/null +++ b/crates/trc/src/event/level.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::Level; +use std::{cmp::Ordering, fmt::Display, str::FromStr}; + +impl PartialOrd for Level { + #[inline(always)] + fn partial_cmp(&self, other: &Level) -> Option { + Some(self.cmp(other)) + } + + #[inline(always)] + fn lt(&self, other: &Level) -> bool { + (*other as usize) < (*self as usize) + } + + #[inline(always)] + fn le(&self, other: &Level) -> bool { + (*other as usize) <= (*self as usize) + } + + #[inline(always)] + fn gt(&self, other: &Level) -> bool { + (*other as usize) > (*self as usize) + } + + #[inline(always)] + fn ge(&self, other: &Level) -> bool { + (*other as usize) >= (*self as usize) + } +} + +impl Ord for Level { + #[inline(always)] + fn cmp(&self, other: &Self) -> Ordering { + (*other as usize).cmp(&(*self as usize)) + } +} + +impl FromStr for Level { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "disable" => Ok(Self::Disable), + "trace" => Ok(Self::Trace), + "debug" => Ok(Self::Debug), + "info" => Ok(Self::Info), + "warn" => Ok(Self::Warn), + "error" => Ok(Self::Error), + _ => Err(s.to_string()), + } + } +} + +impl Level { + pub fn as_str(&self) -> &'static str { + match self { + Self::Disable => "DISABLE", + Self::Trace => "TRACE", + Self::Debug => "DEBUG", + Self::Info => "INFO", + Self::Warn => "WARN", + Self::Error => "ERROR", + } + } + + pub fn is_contained(&self, other: Self) -> bool { + *self >= other && other != Level::Disable && *self != Level::Disable + } +} + +impl Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} diff --git a/crates/trc/src/event/mod.rs b/crates/trc/src/event/mod.rs new file mode 100644 index 0000000..2c05356 --- /dev/null +++ b/crates/trc/src/event/mod.rs @@ -0,0 +1,683 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod conv; +pub mod level; + +pub mod enums; +#[allow(clippy::match_like_matches_macro)] +pub mod enums_impl; + +use compact_str::ToCompactString; +use std::fmt::Display; + +use crate::*; + +impl Event { + pub fn with_capacity(inner: T, capacity: usize) -> Self { + Self { + inner, + keys: Vec::with_capacity(capacity), + } + } + + pub fn with_keys(inner: T, keys: Vec<(Key, Value)>) -> Self { + Self { inner, keys } + } + + pub fn new(inner: T) -> Self { + Self { + inner, + keys: Vec::with_capacity(5), + } + } + + pub fn value(&self, key: Key) -> Option<&Value> { + self.keys + .iter() + .find_map(|(k, v)| if *k == key { Some(v) } else { None }) + } + + pub fn value_as_str(&self, key: Key) -> Option<&str> { + self.value(key).and_then(|v| v.as_str()) + } + + pub fn value_as_uint(&self, key: Key) -> Option { + self.value(key).and_then(|v| v.to_uint()) + } + + pub fn take_value(&mut self, key: Key) -> Option { + self.keys.iter_mut().find_map(|(k, v)| { + if *k == key { + Some(std::mem::take(v)) + } else { + None + } + }) + } + + pub fn into_boxed(self) -> Box { + Box::new(self) + } +} + +impl Error { + #[inline(always)] + pub fn new(inner: EventType) -> Self { + Error(Box::new(Event::new(inner))) + } + + #[inline(always)] + pub fn set_ctx(&mut self, key: Key, value: impl Into) { + self.0.keys.push((key, value.into())); + } + + #[inline(always)] + pub fn ctx(mut self, key: Key, value: impl Into) -> Self { + self.0.keys.push((key, value.into())); + self + } + + #[inline(always)] + pub fn ctx_unique(mut self, key: Key, value: impl Into) -> Self { + if self.0.keys.iter().all(|(k, _)| *k != key) { + self.0.keys.push((key, value.into())); + } + self + } + + #[inline(always)] + pub fn ctx_opt(self, key: Key, value: Option>) -> Self { + match value { + Some(value) => self.ctx(key, value), + None => self, + } + } + + #[inline(always)] + pub fn matches(&self, inner: EventType) -> bool { + self.0.inner == inner + } + + #[inline(always)] + pub fn event_type(&self) -> EventType { + self.0.inner + } + + #[inline(always)] + pub fn span_id(self, session_id: u64) -> Self { + self.ctx(Key::SpanId, session_id) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Self { + self.ctx(Key::CausedBy, error) + } + + #[inline(always)] + pub fn details(self, error: impl Into) -> Self { + self.ctx(Key::Details, error) + } + + #[inline(always)] + pub fn code(self, error: impl Into) -> Self { + self.ctx(Key::Code, error) + } + + #[inline(always)] + pub fn id(self, error: impl Into) -> Self { + self.ctx(Key::Id, error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Self { + self.ctx(Key::Reason, error.to_compact_string()) + } + + #[inline(always)] + pub fn document_id(self, id: u32) -> Self { + self.ctx(Key::DocumentId, id) + } + + #[inline(always)] + pub fn account_id(self, id: u32) -> Self { + self.ctx(Key::AccountId, id) + } + + #[inline(always)] + pub fn collection(self, id: impl Into) -> Self { + self.ctx(Key::Collection, id.into() as u64) + } + + #[inline(always)] + pub fn wrap(self, cause: EventType) -> Self { + Error::new(cause).caused_by(self) + } + + #[inline(always)] + pub fn keys(&self) -> &[(Key, Value)] { + &self.0.keys + } + + #[inline(always)] + pub fn value(&self, key: Key) -> Option<&Value> { + self.0.value(key) + } + + #[inline(always)] + pub fn value_as_str(&self, key: Key) -> Option<&str> { + self.0.value_as_str(key) + } + + #[inline(always)] + pub fn value_as_uint(&self, key: Key) -> Option { + self.0.value_as_uint(key) + } + + #[inline(always)] + pub fn take_value(&mut self, key: Key) -> Option { + self.0.take_value(key) + } + + #[inline(always)] + pub fn is_assertion_failure(&self) -> bool { + self.0.inner == EventType::Store(StoreEvent::AssertValueFailed) + } + + pub fn key(&self, key: Key) -> Option<&Value> { + self.0 + .keys + .iter() + .find_map(|(k, v)| if *k == key { Some(v) } else { None }) + } + + #[inline(always)] + pub fn is_jmap_method_error(&self) -> bool { + !matches!( + self.0.inner, + EventType::Jmap( + JmapEvent::UnknownCapability | JmapEvent::NotJson | JmapEvent::NotRequest + ) + ) + } + + #[inline(always)] + pub fn must_disconnect(&self) -> bool { + matches!( + self.0.inner, + EventType::Network(_) + | EventType::Auth(AuthEvent::TooManyAttempts) + | EventType::Limit(LimitEvent::ConcurrentRequest | LimitEvent::TooManyRequests) + | EventType::Security(_) + ) + } + + #[inline(always)] + pub fn should_write_err(&self) -> bool { + !matches!(self.0.inner, EventType::Network(_) | EventType::Security(_)) + } + + pub fn corrupted_key(key: &[u8], value: Option<&[u8]>, caused_by: &'static str) -> Error { + EventType::Store(StoreEvent::DataCorruption) + .ctx(Key::Key, key) + .ctx_opt(Key::Value, value) + .ctx(Key::CausedBy, caused_by) + } +} + +impl Event { + pub fn span_id(&self) -> Option { + for (key, value) in &self.keys { + match (key, value) { + (Key::SpanId, Value::UInt(value)) => return Some(*value), + (Key::SpanId, Value::Int(value)) => return Some(*value as u64), + _ => {} + } + } + + None + } +} + +impl EventType { + #[inline(always)] + pub fn is_span_start(&self) -> bool { + matches!( + self, + EventType::Smtp(SmtpEvent::ConnectionStart) + | EventType::Imap(ImapEvent::ConnectionStart) + | EventType::ManageSieve(ManageSieveEvent::ConnectionStart) + | EventType::Pop3(Pop3Event::ConnectionStart) + | EventType::Http(HttpEvent::ConnectionStart) + | EventType::Delivery(DeliveryEvent::AttemptStart) + ) + } + + #[inline(always)] + pub fn is_span_end(&self) -> bool { + matches!( + self, + EventType::Smtp(SmtpEvent::ConnectionEnd) + | EventType::Imap(ImapEvent::ConnectionEnd) + | EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) + | EventType::Pop3(Pop3Event::ConnectionEnd) + | EventType::Http(HttpEvent::ConnectionEnd) + | EventType::Delivery(DeliveryEvent::AttemptEnd) + ) + } + + pub fn is_raw_io(&self) -> bool { + matches!( + self, + EventType::Imap(ImapEvent::RawInput | ImapEvent::RawOutput) + | EventType::Smtp(SmtpEvent::RawInput | SmtpEvent::RawOutput) + | EventType::Pop3(Pop3Event::RawInput | Pop3Event::RawOutput) + | EventType::ManageSieve(ManageSieveEvent::RawInput | ManageSieveEvent::RawOutput) + | EventType::Delivery(DeliveryEvent::RawInput | DeliveryEvent::RawOutput) + | EventType::Milter(MilterEvent::Read | MilterEvent::Write) + ) + } + + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(self) + } +} + +impl StoreEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Store(self)) + } +} + +impl DnsEvent { + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Dns(self)) + } +} + +impl AcmeEvent { + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Acme(self)) + } +} + +impl DkimEvent { + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Dkim(self)) + } +} + +impl SecurityEvent { + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Security(self)) + } +} + +impl AuthEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Auth(self)) + } +} + +impl JmapEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Jmap(self)) + } +} + +impl LimitEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Limit(self)) + } +} + +impl ResourceEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Resource(self)) + } +} + +impl SmtpEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Smtp(self)) + } +} + +impl SieveEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Sieve(self)) + } +} + +impl SpamEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Spam(self)) + } +} + +impl ImapEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Imap(self)) + } + + #[inline(always)] + pub fn caused_by(self, error: impl Into) -> Error { + self.into_err().caused_by(error) + } + + #[inline(always)] + pub fn reason(self, error: impl Display) -> Error { + self.into_err().reason(error) + } +} + +impl Pop3Event { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Pop3(self)) + } +} + +impl ManageSieveEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::ManageSieve(self)) + } +} + +impl NetworkEvent { + #[inline(always)] + pub fn ctx(self, key: Key, value: impl Into) -> Error { + self.into_err().ctx(key, value) + } + + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Network(self)) + } +} + +impl Value { + pub fn from_maybe_string(value: &[u8]) -> Self { + if let Ok(value) = std::str::from_utf8(value) { + Self::String(value.into()) + } else { + Self::Bytes(value.to_vec()) + } + } + + pub fn to_uint(&self) -> Option { + match self { + Self::UInt(value) => Some(*value), + Self::Int(value) => Some(*value as u64), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value.as_str()), + _ => None, + } + } + + pub fn into_string(self) -> Option { + match self { + Self::String(value) => Some(value), + _ => None, + } + } +} + +impl AddContext for Result { + #[inline(always)] + fn caused_by(self, location: &'static str) -> Result { + match self { + Ok(value) => Ok(value), + Err(mut err) => { + err.set_ctx(Key::CausedBy, location); + Err(err) + } + } + } + + #[inline(always)] + fn add_context(self, f: F) -> Result + where + F: FnOnce(Error) -> Error, + { + match self { + Ok(value) => Ok(value), + Err(err) => Err(f(err)), + } + } +} + +impl std::error::Error for Error {} +impl Eq for Error {} +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + if self.0.inner == other.0.inner && self.0.keys.len() == other.0.keys.len() { + for kv in self.0.keys.iter() { + if !other.0.keys.iter().any(|okv| kv == okv) { + return false; + } + } + + true + } else { + false + } + } +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::String(l0), Self::String(r0)) => l0 == r0, + (Self::UInt(l0), Self::UInt(r0)) => l0 == r0, + (Self::Int(l0), Self::Int(r0)) => l0 == r0, + (Self::Float(l0), Self::Float(r0)) => l0 == r0, + (Self::Bytes(l0), Self::Bytes(r0)) => l0 == r0, + (Self::Bool(l0), Self::Bool(r0)) => l0 == r0, + (Self::Ipv4(l0), Self::Ipv4(r0)) => l0 == r0, + (Self::Ipv6(l0), Self::Ipv6(r0)) => l0 == r0, + (Self::Event(l0), Self::Event(r0)) => l0 == r0, + (Self::Array(l0), Self::Array(r0)) => l0 == r0, + _ => false, + } + } +} + +impl Eq for Value {} + +impl From for usize { + fn from(value: EventType) -> Self { + value.to_id() as usize + } +} + +impl AsRef> for Event { + fn as_ref(&self) -> &Event { + self + } +} diff --git a/crates/trc/src/ipc/bitset.rs b/crates/trc/src/ipc/bitset.rs new file mode 100644 index 0000000..716ccd0 --- /dev/null +++ b/crates/trc/src/ipc/bitset.rs @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{USIZE_BITS, USIZE_BITS_MASK}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Bitset(pub(crate) [usize; N]); + +impl Bitset { + #[allow(clippy::new_without_default)] + pub const fn new() -> Self { + Self([0; N]) + } + + pub const fn all() -> Self { + Self([usize::MAX; N]) + } + + #[inline(always)] + pub fn set(&mut self, index: impl Into) { + let index = index.into(); + self.0[index / USIZE_BITS] |= 1 << (index & USIZE_BITS_MASK); + } + + #[inline(always)] + pub fn clear(&mut self, index: impl Into) { + let index = index.into(); + self.0[index / USIZE_BITS] &= !(1 << (index & USIZE_BITS_MASK)); + } + + #[inline(always)] + pub fn get(&self, index: impl Into) -> bool { + let index = index.into(); + self.0[index / USIZE_BITS] & (1 << (index & USIZE_BITS_MASK)) != 0 + } + + pub fn union(&mut self, other: &Self) { + for i in 0..N { + self.0[i] |= other.0[i]; + } + } + + pub fn intersection(&mut self, other: &Self) { + for i in 0..N { + self.0[i] &= other.0[i]; + } + } + + pub fn difference(&mut self, other: &Self) { + for i in 0..N { + self.0[i] &= !other.0[i]; + } + } + + pub fn clear_many(&mut self, other: &Self) { + for i in 0..N { + self.0[i] &= !other.0[i]; + } + } + + pub fn clear_all(&mut self) { + for i in 0..N { + self.0[i] = 0; + } + } + + pub fn is_empty(&self) -> bool { + for i in 0..N { + if self.0[i] != 0 { + return false; + } + } + true + } + + pub fn inner(&self) -> &[usize; N] { + &self.0 + } +} + +impl Default for Bitset { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/trc/src/ipc/channel.rs b/crates/trc/src/ipc/channel.rs new file mode 100644 index 0000000..9e85a8b --- /dev/null +++ b/crates/trc/src/ipc/channel.rs @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + cell::UnsafeCell, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use rtrb::{Consumer, Producer, PushError, RingBuffer}; + +use crate::{ + Error, Event, EventType, + ipc::collector::{COLLECTOR_THREAD, COLLECTOR_UPDATES, Update}, +}; + +use super::collector::{Collector, CollectorThread}; + +pub(crate) static CHANNEL_FLAGS: AtomicU64 = AtomicU64::new(0); +pub(crate) const CHANNEL_SIZE: usize = 10240; +pub(crate) const CHANNEL_UPDATE_MARKER: u64 = 1 << 63; + +thread_local! { + static EVENT_TX: UnsafeCell = { + // Create channel. + let (tx, rx) = RingBuffer::new(CHANNEL_SIZE); + + // Register receiver with collector. + COLLECTOR_UPDATES.lock().push(Update::RegisterReceiver { receiver: Receiver { rx } }); + + // Spawn collector thread. + let collector = COLLECTOR_THREAD.clone(); + CHANNEL_FLAGS.fetch_or(CHANNEL_UPDATE_MARKER, Ordering::Relaxed); + collector.thread().unpark(); + + // Return sender. + UnsafeCell::new(Sender { + tx, + collector, + overflow: Vec::with_capacity(0), + }) + }; +} + +pub struct Sender { + tx: Producer>, + collector: Arc, + overflow: Vec>, +} + +pub struct Receiver { + rx: Consumer>, +} + +#[derive(Debug)] +pub struct ChannelError; + +impl Sender { + pub fn send(&mut self, event: Event) -> Result<(), ChannelError> { + while let Some(event) = self.overflow.pop() { + if let Err(PushError::Full(event)) = self.tx.push(event) { + self.overflow.push(event); + break; + } + } + + if let Err(PushError::Full(event)) = self.tx.push(event) { + if self.overflow.len() <= CHANNEL_SIZE * 2 { + self.overflow.push(event); + } else { + return Err(ChannelError); + } + } + + Ok(()) + } +} + +impl Receiver { + pub fn try_recv(&mut self) -> Result>, ChannelError> { + match self.rx.pop() { + Ok(event) => Ok(Some(event)), + Err(_) => { + if !self.rx.is_abandoned() { + Ok(None) + } else { + Err(ChannelError) + } + } + } + } +} + +impl Event { + pub fn send(self) { + // SAFETY: EVENT_TX is thread-local. + let _ = EVENT_TX.try_with(|tx| unsafe { + let tx = &mut *tx.get(); + if tx.send(self).is_ok() { + CHANNEL_FLAGS.fetch_add(1, Ordering::Relaxed); + tx.collector.thread().unpark(); + } + }); + } + + pub fn send_with_metrics(self) { + Collector::record_metric(self.inner, self.inner.to_id() as usize, &self.keys); + self.send(); + } +} + +impl Error { + pub fn send(self) { + self.0.send(); + } +} diff --git a/crates/trc/src/ipc/collector.rs b/crates/trc/src/ipc/collector.rs new file mode 100644 index 0000000..a031d00 --- /dev/null +++ b/crates/trc/src/ipc/collector.rs @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + sync::{Arc, LazyLock, atomic::Ordering}, + thread::{Builder, JoinHandle, park}, + time::SystemTime, +}; + +use ahash::AHashMap; +use atomics::bitset::AtomicBitset; +use ipc::{ + USIZE_BITS, + channel::{CHANNEL_FLAGS, CHANNEL_UPDATE_MARKER, Receiver}, + subscriber::{Interests, Subscriber}, +}; +use parking_lot::Mutex; + +use crate::*; + +pub(crate) type GlobalInterests = AtomicBitset<{ TOTAL_EVENT_COUNT.div_ceil(USIZE_BITS) }>; + +pub(crate) static TRACE_INTERESTS: GlobalInterests = GlobalInterests::new(); +pub(crate) type CollectorThread = JoinHandle<()>; +pub(crate) static ACTIVE_SUBSCRIBERS: Mutex> = Mutex::new(Vec::new()); +pub(crate) static COLLECTOR_UPDATES: Mutex> = Mutex::new(Vec::new()); + +pub(crate) static EVENT_TYPES: &[EventType] = EventType::variants(); + +#[allow(clippy::enum_variant_names)] +pub(crate) enum Update { + RegisterReceiver { + receiver: Receiver, + }, + RegisterSubscriber { + subscriber: Subscriber, + }, + UnregisterSubscriber { + id: String, + }, + UpdateSubscriber { + id: String, + interests: Interests, + lossy: bool, + }, + UpdateLevels { + levels: AHashMap, + }, + Shutdown, +} + +pub struct Collector { + receivers: Vec, + subscribers: Vec, + levels: [Level; TOTAL_EVENT_COUNT], + active_spans: AHashMap>>, +} + +const HTTP_CONN_START: usize = EventType::Http(HttpEvent::ConnectionStart).to_id() as usize; +const HTTP_CONN_END: usize = EventType::Http(HttpEvent::ConnectionEnd).to_id() as usize; +const IMAP_CONN_START: usize = EventType::Imap(ImapEvent::ConnectionStart).to_id() as usize; +const IMAP_CONN_END: usize = EventType::Imap(ImapEvent::ConnectionEnd).to_id() as usize; +const POP3_CONN_START: usize = EventType::Pop3(Pop3Event::ConnectionStart).to_id() as usize; +const POP3_CONN_END: usize = EventType::Pop3(Pop3Event::ConnectionEnd).to_id() as usize; +const SMTP_CONN_START: usize = EventType::Smtp(SmtpEvent::ConnectionStart).to_id() as usize; +const SMTP_CONN_END: usize = EventType::Smtp(SmtpEvent::ConnectionEnd).to_id() as usize; +const MANAGE_SIEVE_CONN_START: usize = + EventType::ManageSieve(ManageSieveEvent::ConnectionStart).to_id() as usize; +const MANAGE_SIEVE_CONN_END: usize = + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd).to_id() as usize; +const EV_ATTEMPT_START: usize = EventType::Delivery(DeliveryEvent::AttemptStart).to_id() as usize; +const EV_ATTEMPT_END: usize = EventType::Delivery(DeliveryEvent::AttemptEnd).to_id() as usize; + +const STALE_SPAN_CHECK_WATERMARK: usize = 8000; +const SPAN_MAX_HOLD: u64 = 60 * 60 * 24; // 1 day + +pub(crate) static COLLECTOR_THREAD: LazyLock> = LazyLock::new(|| { + Arc::new( + Builder::new() + .name("stalwart-collector".to_string()) + .spawn(move || { + Collector::default().collect(); + }) + .expect("Failed to start event collector"), + ) +}); + +impl Collector { + fn collect(&mut self) { + let mut do_continue = true; + + // Update + self.update(); + + while do_continue { + match CHANNEL_FLAGS.swap(0, Ordering::Relaxed) { + 0 => { + park(); + } + CHANNEL_UPDATE_MARKER..=u64::MAX => { + do_continue = self.update(); + } + _ => {} + } + + // Collect all events + let mut closed_rxs = Vec::new(); + for (rx_idx, rx) in self.receivers.iter_mut().enumerate() { + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + + loop { + match rx.try_recv() { + Ok(Some(event)) => { + // Build event + let event_id = event.inner.to_id() as usize; + let mut event = Event { + inner: EventDetails { + level: self.levels[event_id], + typ: event.inner, + timestamp, + span: None, + }, + keys: event.keys, + }; + + // Track spans + let event = match event_id { + HTTP_CONN_START + | IMAP_CONN_START + | POP3_CONN_START + | SMTP_CONN_START + | MANAGE_SIEVE_CONN_START + | EV_ATTEMPT_START => { + let event = Arc::new(event); + self.active_spans.insert( + event.span_id().unwrap_or_else(|| { + panic!("Missing span ID: {event:?}") + }), + event.clone(), + ); + + if self.active_spans.len() > STALE_SPAN_CHECK_WATERMARK { + self.active_spans.retain(|_, span| { + timestamp.saturating_sub(span.inner.timestamp) + < SPAN_MAX_HOLD + }); + } + event + } + + HTTP_CONN_END + | IMAP_CONN_END + | POP3_CONN_END + | SMTP_CONN_END + | MANAGE_SIEVE_CONN_END + | EV_ATTEMPT_END => { + if let Some(span) = self + .active_spans + .remove(&event.span_id().expect("Missing span ID")) + { + event.inner.span = Some(span.clone()); + } else { + #[cfg(any(feature = "dev_mode", feature = "test_mode"))] + { + if event.span_id().unwrap() != 0 { + eprintln!("Unregistered span ID: {event:?}"); + } + } + } + Arc::new(event) + } + _ => { + if let Some(span_id) = event.span_id() { + if let Some(span) = self.active_spans.get(&span_id) { + event.inner.span = Some(span.clone()); + } else { + #[cfg(any( + feature = "dev_mode", + feature = "test_mode" + ))] + { + if span_id != 0 { + eprintln!("Unregistered span ID: {event:?}"); + } + } + } + } + + Arc::new(event) + } + }; + + // Send to subscribers + for subscriber in self.subscribers.iter_mut() { + subscriber.push_event(event_id, event.clone()); + } + } + Ok(None) => { + break; + } + Err(_) => { + closed_rxs.push(rx_idx); // Channel is closed, remove. + break; + } + } + } + } + + if do_continue { + // Remove closed receivers (should be rare in Tokio) + if !closed_rxs.is_empty() { + let mut receivers = Vec::with_capacity(self.receivers.len() - closed_rxs.len()); + for (rx_idx, rx) in self.receivers.drain(..).enumerate() { + if !closed_rxs.contains(&rx_idx) { + receivers.push(rx); + } + } + self.receivers = receivers; + } + + // Send batched events + if !self.subscribers.is_empty() { + self.subscribers + .retain_mut(|subscriber| subscriber.send_batch().is_ok()); + } + } + } + + // Send remaining events + for mut subscriber in self.subscribers.drain(..) { + let _ = subscriber.send_batch(); + } + } + + fn update(&mut self) -> bool { + for update in COLLECTOR_UPDATES.lock().drain(..) { + match update { + Update::RegisterReceiver { receiver } => { + self.receivers.push(receiver); + } + Update::RegisterSubscriber { subscriber } => { + ACTIVE_SUBSCRIBERS.lock().push(subscriber.id.clone()); + self.subscribers.push(subscriber); + } + Update::UnregisterSubscriber { id } => { + ACTIVE_SUBSCRIBERS.lock().retain(|s| s != &id); + self.subscribers.retain(|s| s.id != id); + } + Update::UpdateSubscriber { + id, + interests, + lossy, + } => { + for subscriber in self.subscribers.iter_mut() { + if subscriber.id == id { + subscriber.interests = interests; + subscriber.lossy = lossy; + break; + } + } + } + Update::UpdateLevels { levels } => { + for event in EVENT_TYPES.iter() { + let event_id = event.to_id() as usize; + if let Some(level) = levels.get(event) { + self.levels[event_id] = *level; + } else { + self.levels[event_id] = event.level(); + } + } + } + Update::Shutdown => return false, + } + } + + true + } + + pub fn set_interests(mut interests: Interests) { + if !interests.is_empty() { + for event_type in EVENT_TYPES.iter() { + if event_type.is_span_start() || event_type.is_span_end() { + interests.set(*event_type); + } + } + } + + TRACE_INTERESTS.update(interests); + } + + pub fn union_interests(interests: Interests) { + TRACE_INTERESTS.union(interests); + } + + #[inline(always)] + pub fn has_interest(event: impl Into) -> bool { + TRACE_INTERESTS.get(event) + } + + pub fn get_subscribers() -> Vec { + ACTIVE_SUBSCRIBERS.lock().clone() + } + + pub fn update_custom_levels(levels: AHashMap) { + COLLECTOR_UPDATES + .lock() + .push(Update::UpdateLevels { levels }); + } + + pub fn update_subscriber(id: String, interests: Interests, lossy: bool) { + COLLECTOR_UPDATES.lock().push(Update::UpdateSubscriber { + id, + interests, + lossy, + }); + } + + pub fn remove_subscriber(id: String) { + COLLECTOR_UPDATES + .lock() + .push(Update::UnregisterSubscriber { id }); + } + + pub fn shutdown() { + COLLECTOR_UPDATES.lock().push(Update::Shutdown); + Collector::reload(); + } + + pub fn is_enabled() -> bool { + !TRACE_INTERESTS.is_empty() + } + + pub fn reload() { + CHANNEL_FLAGS.fetch_or(CHANNEL_UPDATE_MARKER, Ordering::Relaxed); + COLLECTOR_THREAD.thread().unpark(); + } +} + +impl Default for Collector { + fn default() -> Self { + let mut c = Collector { + subscribers: Vec::new(), + levels: [Level::Disable; TOTAL_EVENT_COUNT], + active_spans: AHashMap::new(), + receivers: Vec::new(), + }; + + for event in EVENT_TYPES.iter() { + let event_id = event.to_id() as usize; + c.levels[event_id] = event.level(); + } + + c + } +} diff --git a/crates/trc/src/ipc/metrics.rs b/crates/trc/src/ipc/metrics.rs new file mode 100644 index 0000000..bef5302 --- /dev/null +++ b/crates/trc/src/ipc/metrics.rs @@ -0,0 +1,415 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::Ordering; + +use atomics::{array::AtomicU32Array, gauge::AtomicGauge, histogram::AtomicHistogram}; +use ipc::{ + collector::{Collector, GlobalInterests}, + subscriber::Interests, +}; + +use crate::*; + +pub(crate) static METRIC_INTERESTS: GlobalInterests = GlobalInterests::new(); + +static EVENT_COUNTERS: AtomicU32Array = AtomicU32Array::new(); +static CONNECTION_METRICS: [ConnectionMetrics; TOTAL_CONN_TYPES] = init_conn_metrics(); + +static MESSAGE_INGESTION_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::MessageIngestTime); +static MESSAGE_INDEX_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::MessageIngestIndexTime); +static MESSAGE_DELIVERY_TIME: AtomicHistogram<12> = + AtomicHistogram::<18>::new_long_durations(MetricType::DeliveryTotalTime); + +static MESSAGE_INCOMING_SIZE: AtomicHistogram<12> = + AtomicHistogram::<12>::new_message_sizes(MetricType::MessageSize); +static MESSAGE_SUBMISSION_SIZE: AtomicHistogram<12> = + AtomicHistogram::<12>::new_message_sizes(MetricType::MessageAuthenticatedSize); +static MESSAGE_OUT_REPORT_SIZE: AtomicHistogram<12> = + AtomicHistogram::<12>::new_message_sizes(MetricType::OutgoingReportSize); + +static STORE_DATA_READ_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::StoreDataReadTime); +static STORE_DATA_WRITE_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::StoreDataWriteTime); +static STORE_BLOB_READ_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::StoreBlobReadTime); +static STORE_BLOB_WRITE_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::StoreBlobWriteTime); + +static DNS_LOOKUP_TIME: AtomicHistogram<12> = + AtomicHistogram::<10>::new_short_durations(MetricType::DnsLookupTime); + +static SERVER_MEMORY: AtomicGauge = AtomicGauge::new(MetricType::ServerMemory); +static QUEUE_COUNT: AtomicGauge = AtomicGauge::new(MetricType::QueueCount); +static USER_COUNT: AtomicGauge = AtomicGauge::new(MetricType::UserCount); +static DOMAIN_COUNT: AtomicGauge = AtomicGauge::new(MetricType::DomainCount); + +const CONN_SMTP_IN: usize = 0; +const CONN_SMTP_OUT: usize = 1; +const CONN_IMAP: usize = 2; +const CONN_POP3: usize = 3; +const CONN_HTTP: usize = 4; +const CONN_SIEVE: usize = 5; +const TOTAL_CONN_TYPES: usize = 6; + +pub struct ConnectionMetrics { + pub active_connections: AtomicGauge, + pub elapsed: AtomicHistogram<12>, +} + +pub struct EventCounter { + id: EventType, + value: u32, +} + +impl Collector { + pub fn record_metric(event: EventType, event_id: usize, keys: &[(Key, Value)]) { + // Increment the event counter + if !event.is_span_end() && !event.is_raw_io() { + EVENT_COUNTERS.add(event_id, 1); + } + + // Extract variables + let mut elapsed = 0; + let mut size = 0; + for (key, value) in keys { + match (key, value) { + (Key::Elapsed, Value::Duration(d)) => elapsed = *d, + (Key::Size, Value::UInt(s)) => size = *s, + _ => {} + } + } + + match event { + EventType::Smtp(SmtpEvent::ConnectionStart) => { + let conn = &CONNECTION_METRICS[CONN_SMTP_IN]; + conn.active_connections.increment(); + } + EventType::Smtp(SmtpEvent::ConnectionEnd) => { + let conn = &CONNECTION_METRICS[CONN_SMTP_IN]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::Imap(ImapEvent::ConnectionStart) => { + let conn = &CONNECTION_METRICS[CONN_IMAP]; + conn.active_connections.increment(); + } + EventType::Imap(ImapEvent::ConnectionEnd) => { + let conn = &CONNECTION_METRICS[CONN_IMAP]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::Pop3(Pop3Event::ConnectionStart) => { + let conn = &CONNECTION_METRICS[CONN_POP3]; + conn.active_connections.increment(); + } + EventType::Pop3(Pop3Event::ConnectionEnd) => { + let conn = &CONNECTION_METRICS[CONN_POP3]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::Http(HttpEvent::ConnectionStart) => { + let conn = &CONNECTION_METRICS[CONN_HTTP]; + conn.active_connections.increment(); + } + EventType::Http(HttpEvent::ConnectionEnd) => { + let conn = &CONNECTION_METRICS[CONN_HTTP]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { + let conn = &CONNECTION_METRICS[CONN_SIEVE]; + conn.active_connections.increment(); + } + EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => { + let conn = &CONNECTION_METRICS[CONN_SIEVE]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::Delivery(DeliveryEvent::AttemptStart) => { + let conn = &CONNECTION_METRICS[CONN_SMTP_OUT]; + conn.active_connections.increment(); + } + EventType::Delivery(DeliveryEvent::AttemptEnd) => { + let conn = &CONNECTION_METRICS[CONN_SMTP_OUT]; + conn.active_connections.decrement(); + conn.elapsed.observe(elapsed); + } + EventType::Delivery(DeliveryEvent::Completed) => { + QUEUE_COUNT.decrement(); + MESSAGE_DELIVERY_TIME.observe(elapsed); + } + EventType::Delivery( + DeliveryEvent::MxLookup | DeliveryEvent::IpLookup | DeliveryEvent::NullMx, + ) + | EventType::TlsRpt(_) + | EventType::MtaSts(_) + | EventType::Dane(_) + if elapsed > 0 => + { + DNS_LOOKUP_TIME.observe(elapsed); + } + EventType::MessageIngest( + MessageIngestEvent::Ham + | MessageIngestEvent::Spam + | MessageIngestEvent::ImapAppend + | MessageIngestEvent::JmapAppend, + ) => { + MESSAGE_INGESTION_TIME.observe(elapsed); + } + EventType::Queue(QueueEvent::MessageQueued) => { + MESSAGE_INCOMING_SIZE.observe(size); + QUEUE_COUNT.increment(); + } + EventType::Queue(QueueEvent::AuthenticatedMessageQueued) => { + MESSAGE_SUBMISSION_SIZE.observe(size); + QUEUE_COUNT.increment(); + } + EventType::Queue(QueueEvent::ReportQueued) => { + MESSAGE_OUT_REPORT_SIZE.observe(size); + QUEUE_COUNT.increment(); + } + EventType::Queue(QueueEvent::AutogeneratedQueued | QueueEvent::DsnQueued) => { + QUEUE_COUNT.increment(); + } + EventType::MessageIngest(MessageIngestEvent::SearchIndex) => { + MESSAGE_INDEX_TIME.observe(elapsed); + } + EventType::Store(StoreEvent::BlobWrite) => { + STORE_BLOB_WRITE_TIME.observe(elapsed); + } + EventType::Store(StoreEvent::BlobRead) => { + STORE_BLOB_READ_TIME.observe(elapsed); + } + EventType::Store(StoreEvent::DataWrite) => { + STORE_DATA_WRITE_TIME.observe(elapsed); + } + EventType::Store(StoreEvent::DataIterate) => { + STORE_DATA_READ_TIME.observe(elapsed); + } + + _ => {} + } + } + + #[inline(always)] + pub fn is_metric(event: impl Into) -> bool { + METRIC_INTERESTS.get(event) + } + + pub fn set_metrics(interests: Interests) { + METRIC_INTERESTS.update(interests); + } + + pub fn collect_counters(_is_enterprise: bool) -> impl Iterator { + EVENT_COUNTERS + .inner() + .iter() + .enumerate() + .filter_map(|(event_id, value)| { + let value = value.load(Ordering::Relaxed); + if value > 0 { + Some(EventCounter { + id: EventType::from_id(event_id as u16)?, + value, + }) + } else { + None + } + }) + } + + pub fn collect_gauges(is_enterprise: bool) -> impl Iterator { + static E_GAUGES: &[&AtomicGauge] = + &[&SERVER_MEMORY, &QUEUE_COUNT, &USER_COUNT, &DOMAIN_COUNT]; + static C_GAUGES: &[&AtomicGauge] = &[&SERVER_MEMORY, &USER_COUNT, &DOMAIN_COUNT]; + + if is_enterprise { E_GAUGES } else { C_GAUGES } + .iter() + .copied() + .chain(CONNECTION_METRICS.iter().map(|m| &m.active_connections)) + } + + pub fn collect_histograms( + is_enterprise: bool, + ) -> impl Iterator> { + static E_HISTOGRAMS: &[&AtomicHistogram<12>] = &[ + &MESSAGE_INGESTION_TIME, + &MESSAGE_INDEX_TIME, + &MESSAGE_DELIVERY_TIME, + &MESSAGE_INCOMING_SIZE, + &MESSAGE_SUBMISSION_SIZE, + &MESSAGE_OUT_REPORT_SIZE, + &STORE_DATA_READ_TIME, + &STORE_DATA_WRITE_TIME, + &STORE_BLOB_READ_TIME, + &STORE_BLOB_WRITE_TIME, + &DNS_LOOKUP_TIME, + ]; + static C_HISTOGRAMS: &[&AtomicHistogram<12>] = &[ + &MESSAGE_DELIVERY_TIME, + &MESSAGE_INCOMING_SIZE, + &MESSAGE_SUBMISSION_SIZE, + ]; + + if is_enterprise { + E_HISTOGRAMS + } else { + C_HISTOGRAMS + } + .iter() + .copied() + .chain(CONNECTION_METRICS.iter().map(|m| &m.elapsed)) + .filter(|h| h.is_active()) + } + + #[inline(always)] + pub fn read_metric_counter(metric_id: usize) -> u32 { + EVENT_COUNTERS.get(metric_id) + } + + pub fn read_metric(metric_type: MetricType) -> f64 { + match metric_type { + MetricType::ServerMemory => SERVER_MEMORY.get() as f64, + MetricType::MessageIngestTime => MESSAGE_INGESTION_TIME.average(), + MetricType::MessageIngestIndexTime => MESSAGE_INDEX_TIME.average(), + MetricType::MessageSize => MESSAGE_INCOMING_SIZE.average(), + MetricType::MessageAuthenticatedSize => MESSAGE_SUBMISSION_SIZE.average(), + MetricType::DeliveryTotalTime => MESSAGE_DELIVERY_TIME.average(), + MetricType::DeliveryAttemptTime => CONNECTION_METRICS[CONN_SMTP_OUT].elapsed.average(), + MetricType::DeliveryActiveConnections => { + CONNECTION_METRICS[CONN_SMTP_OUT].active_connections.get() as f64 + } + MetricType::QueueCount => QUEUE_COUNT.get() as f64, + MetricType::OutgoingReportSize => MESSAGE_OUT_REPORT_SIZE.average(), + MetricType::StoreDataReadTime => STORE_DATA_READ_TIME.average(), + MetricType::StoreDataWriteTime => STORE_DATA_WRITE_TIME.average(), + MetricType::StoreBlobReadTime => STORE_BLOB_READ_TIME.average(), + MetricType::StoreBlobWriteTime => STORE_BLOB_WRITE_TIME.average(), + MetricType::DnsLookupTime => DNS_LOOKUP_TIME.average(), + MetricType::HttpActiveConnections => { + CONNECTION_METRICS[CONN_HTTP].active_connections.get() as f64 + } + MetricType::HttpRequestTime => CONNECTION_METRICS[CONN_HTTP].elapsed.average(), + MetricType::ImapActiveConnections => { + CONNECTION_METRICS[CONN_IMAP].active_connections.get() as f64 + } + MetricType::ImapRequestTime => CONNECTION_METRICS[CONN_IMAP].elapsed.average(), + MetricType::Pop3ActiveConnections => { + CONNECTION_METRICS[CONN_POP3].active_connections.get() as f64 + } + MetricType::Pop3RequestTime => CONNECTION_METRICS[CONN_POP3].elapsed.average(), + MetricType::SmtpActiveConnections => { + CONNECTION_METRICS[CONN_SMTP_IN].active_connections.get() as f64 + } + MetricType::SmtpRequestTime => CONNECTION_METRICS[CONN_SMTP_IN].elapsed.average(), + MetricType::SieveActiveConnections => { + CONNECTION_METRICS[CONN_SIEVE].active_connections.get() as f64 + } + MetricType::SieveRequestTime => CONNECTION_METRICS[CONN_SIEVE].elapsed.average(), + MetricType::UserCount => USER_COUNT.get() as f64, + MetricType::DomainCount => DOMAIN_COUNT.get() as f64, + _ => EVENT_COUNTERS.get(metric_type.event_id()) as f64, + } + } + + pub fn update_gauge(metric_type: MetricType, value: u64) { + match metric_type { + MetricType::ServerMemory => SERVER_MEMORY.set(value), + MetricType::QueueCount => QUEUE_COUNT.set(value), + MetricType::UserCount => USER_COUNT.set(value), + MetricType::DomainCount => DOMAIN_COUNT.set(value), + _ => {} + } + } + + pub fn update_event_counter(event_type: EventType, value: u32) { + EVENT_COUNTERS.add(event_type.into(), value); + } + + pub fn update_histogram(metric_type: MetricType, value: u64) { + match metric_type { + MetricType::MessageIngestTime => MESSAGE_INGESTION_TIME.observe(value), + MetricType::MessageIngestIndexTime => MESSAGE_INDEX_TIME.observe(value), + MetricType::DeliveryTotalTime => MESSAGE_DELIVERY_TIME.observe(value), + MetricType::DeliveryAttemptTime => { + CONNECTION_METRICS[CONN_SMTP_OUT].elapsed.observe(value) + } + MetricType::DnsLookupTime => DNS_LOOKUP_TIME.observe(value), + MetricType::StoreDataReadTime => STORE_DATA_READ_TIME.observe(value), + MetricType::StoreDataWriteTime => STORE_DATA_WRITE_TIME.observe(value), + MetricType::StoreBlobReadTime => STORE_BLOB_READ_TIME.observe(value), + MetricType::StoreBlobWriteTime => STORE_BLOB_WRITE_TIME.observe(value), + _ => {} + } + } +} + +impl EventCounter { + pub fn id(&self) -> EventType { + self.id + } + + pub fn value(&self) -> u64 { + self.value as u64 + } +} + +impl ConnectionMetrics { + #[allow(clippy::new_without_default)] + pub const fn new() -> Self { + Self { + active_connections: AtomicGauge::new(MetricType::StoreBlobReadTime), + elapsed: AtomicHistogram::<18>::new_medium_durations(MetricType::StoreBlobReadTime), + } + } +} + +#[allow(clippy::declare_interior_mutable_const)] +const fn init_conn_metrics() -> [ConnectionMetrics; TOTAL_CONN_TYPES] { + const INIT: ConnectionMetrics = ConnectionMetrics::new(); + let mut array = [INIT; TOTAL_CONN_TYPES]; + let mut i = 0; + while i < TOTAL_CONN_TYPES { + let metric = match i { + CONN_HTTP => &[ + MetricType::HttpRequestTime, + MetricType::HttpActiveConnections, + ], + CONN_IMAP => &[ + MetricType::ImapRequestTime, + MetricType::ImapActiveConnections, + ], + CONN_POP3 => &[ + MetricType::Pop3RequestTime, + MetricType::Pop3ActiveConnections, + ], + CONN_SMTP_IN => &[ + MetricType::SmtpRequestTime, + MetricType::SmtpActiveConnections, + ], + CONN_SMTP_OUT => &[ + MetricType::DeliveryAttemptTime, + MetricType::DeliveryActiveConnections, + ], + CONN_SIEVE => &[ + MetricType::SieveRequestTime, + MetricType::SieveActiveConnections, + ], + _ => &[MetricType::StoreBlobReadTime, MetricType::StoreBlobReadTime], + }; + + array[i] = ConnectionMetrics { + elapsed: AtomicHistogram::<18>::new_medium_durations(metric[0]), + active_connections: AtomicGauge::new(metric[1]), + }; + i += 1; + } + array +} diff --git a/crates/trc/src/ipc/mod.rs b/crates/trc/src/ipc/mod.rs new file mode 100644 index 0000000..400f721 --- /dev/null +++ b/crates/trc/src/ipc/mod.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod bitset; +pub mod channel; +pub mod collector; +pub mod metrics; +pub mod subscriber; + +pub(crate) const USIZE_BITS: usize = std::mem::size_of::() * 8; +pub(crate) const USIZE_BITS_MASK: usize = USIZE_BITS - 1; diff --git a/crates/trc/src/ipc/subscriber.rs b/crates/trc/src/ipc/subscriber.rs new file mode 100644 index 0000000..7a9c588 --- /dev/null +++ b/crates/trc/src/ipc/subscriber.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use tokio::sync::mpsc::{self, error::TrySendError}; + +use crate::{Event, EventDetails, EventType, Level, TOTAL_EVENT_COUNT}; + +use super::{ + USIZE_BITS, + bitset::Bitset, + channel::ChannelError, + collector::{COLLECTOR_UPDATES, Collector, Update}, +}; + +const MAX_BATCH_SIZE: usize = 32768; + +pub type Interests = Box>; +pub type EventBatch = Vec>>; + +#[derive(Debug)] +pub(crate) struct Subscriber { + pub id: String, + pub interests: Interests, + pub tx: mpsc::Sender, + pub lossy: bool, + pub batch: EventBatch, +} + +pub struct SubscriberBuilder { + pub id: String, + pub interests: Interests, + pub lossy: bool, +} + +impl Subscriber { + #[inline(always)] + pub fn push_event(&mut self, event_id: usize, trace: Arc>) { + if self.interests.get(event_id) { + self.batch.push(trace); + } + } + + pub fn send_batch(&mut self) -> Result<(), ChannelError> { + if !self.batch.is_empty() { + match self + .tx + .try_send(std::mem::replace(&mut self.batch, Vec::with_capacity(128))) + { + Ok(_) => Ok(()), + Err(TrySendError::Full(mut events)) => { + if self.lossy && events.len() > MAX_BATCH_SIZE { + events.retain(|e| e.inner.level == Level::Error); + if events.len() > MAX_BATCH_SIZE { + events.truncate(MAX_BATCH_SIZE); + } + } + self.batch = events; + Ok(()) + } + Err(TrySendError::Closed(_)) => Err(ChannelError), + } + } else { + Ok(()) + } + } +} + +impl SubscriberBuilder { + pub fn new(id: String) -> Self { + Self { + id, + interests: Default::default(), + lossy: true, + } + } + + pub fn with_default_interests(mut self, level: Level) -> Self { + for event in EventType::variants() { + if event.level() >= level { + self.interests.set(*event); + } + } + self + } + + pub fn with_interests(mut self, interests: Interests) -> Self { + self.interests = interests; + self + } + + pub fn set_interests(mut self, interest: impl IntoIterator>) -> Self { + for level in interest { + self.interests.set(level); + } + self + } + + pub fn with_lossy(mut self, lossy: bool) -> Self { + self.lossy = lossy; + self + } + + pub fn register(self) -> (mpsc::Sender, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(8192); + + COLLECTOR_UPDATES.lock().push(Update::RegisterSubscriber { + subscriber: Subscriber { + id: self.id, + interests: self.interests, + tx: tx.clone(), + lossy: self.lossy, + batch: Vec::new(), + }, + }); + + // Notify collector + Collector::reload(); + + (tx, rx) + } +} diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs new file mode 100644 index 0000000..cefa91b --- /dev/null +++ b/crates/trc/src/lib.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod atomics; +pub mod event; +pub mod ipc; +pub mod macros; +pub mod serializers; + +pub use crate::event::enums::*; +pub use crate::ipc::collector::Collector; +use compact_str::CompactString; +pub use event_macro::event; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, +}; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +#[repr(transparent)] +pub struct Error(Box>); + +#[derive(Debug, Clone)] +pub struct Event { + pub inner: T, + pub keys: Vec<(Key, Value)>, +} + +#[derive(Debug, Clone)] +pub struct EventDetails { + pub typ: EventType, + pub timestamp: u64, + pub level: Level, + pub span: Option>>, +} + +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +#[repr(usize)] +pub enum Level { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, + Disable = 5, +} + +#[derive(Debug, Default, Clone)] +pub enum Value { + String(CompactString), + UInt(u64), + Int(i64), + Float(f64), + Timestamp(u64), + Duration(u64), + Bytes(Vec), + Bool(bool), + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), + Event(Error), + Array(Vec), + #[default] + None, +} + +pub trait AddContext { + fn caused_by(self, location: &'static str) -> Result; + fn add_context(self, f: F) -> Result + where + F: FnOnce(Error) -> Error; +} + +#[allow(clippy::derivable_impls)] +impl Default for MetricType { + fn default() -> Self { + MetricType::UserCount + } +} + +impl Default for EventType { + fn default() -> Self { + EventType::Store(StoreEvent::UnexpectedError) + } +} diff --git a/crates/trc/src/macros.rs b/crates/trc/src/macros.rs new file mode 100644 index 0000000..ecf99cc --- /dev/null +++ b/crates/trc/src/macros.rs @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[macro_export] +macro_rules! location { + () => {{ concat!(file!(), ":", line!()) }}; +} + +#[macro_export] +macro_rules! bail { + ($err:expr $(,)?) => { + return Err($err); + }; +} + +#[macro_export] +macro_rules! error { + ($err:expr $(,)?) => { + let err = $err; + let event_id = err.as_ref().to_id() as usize; + + if $crate::Collector::is_metric(event_id) { + $crate::Collector::record_metric(*err.as_ref(), event_id, err.keys()); + } + if $crate::Collector::has_interest(event_id) { + err.send(); + } + }; +} diff --git a/crates/trc/src/serializers/json.rs b/crates/trc/src/serializers/json.rs new file mode 100644 index 0000000..96f79f4 --- /dev/null +++ b/crates/trc/src/serializers/json.rs @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{Error, Event, EventDetails, EventType, Key, MetricType, Value}; +use ahash::AHashSet; +use base64::{Engine, engine::general_purpose::STANDARD}; +use mail_parser::DateTime; +use serde::{ + Serialize, Serializer, + ser::{SerializeMap, SerializeSeq}, +}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static EVENT_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct Keys<'x> { + keys: &'x [(Key, Value)], + span_keys: &'x [(Key, Value)], +} + +pub struct JsonEventSerializer { + inner: T, + with_id: bool, + with_spans: bool, + with_description: bool, +} + +impl JsonEventSerializer { + pub fn new(inner: T) -> Self { + Self { + inner, + with_id: false, + with_spans: false, + with_description: false, + } + } + + pub fn with_id(mut self) -> Self { + self.with_id = true; + self + } + + pub fn with_spans(mut self) -> Self { + self.with_spans = true; + self + } + + pub fn with_description(mut self) -> Self { + self.with_description = true; + self + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +impl>> Serialize for JsonEventSerializer> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.inner.len()))?; + for event in &self.inner { + seq.serialize_element(&JsonEventSerializer { + inner: event, + with_id: self.with_id, + with_spans: self.with_spans, + with_description: self.with_description, + })?; + } + seq.end() + } +} + +impl>> Serialize for JsonEventSerializer { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let event = self.inner.as_ref(); + let mut map = serializer.serialize_map(None)?; + if self.with_id { + map.serialize_entry( + "id", + &format!( + "{}{}{}", + event.inner.timestamp, + EVENT_ID_COUNTER.fetch_add(1, Ordering::Relaxed), + event.inner.typ.to_id() + ), + )?; + } + if self.with_description { + map.serialize_entry("text", event.inner.typ.description())?; + } + map.serialize_entry( + "createdAt", + &DateTime::from_timestamp(event.inner.timestamp as i64).to_rfc3339(), + )?; + map.serialize_entry("type", event.inner.typ.as_str())?; + map.serialize_entry( + "data", + &JsonEventSerializer { + inner: Keys { + keys: event.keys.as_slice(), + span_keys: event + .inner + .span + .as_ref() + .map(|s| &s.keys[..]) + .unwrap_or(&[]), + }, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + }, + )?; + map.end() + } +} + +impl Serialize for JsonEventSerializer> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let keys_len = self.inner.keys.len() + self.inner.span_keys.len(); + let mut seen_keys = AHashSet::with_capacity(keys_len); + let mut keys = serializer.serialize_map(Some(keys_len))?; + for (key, value) in self.inner.keys.iter().chain(self.inner.span_keys.iter()) { + if !matches!(value, Value::None) + && (self.with_spans || !matches!(key, Key::SpanId)) + && seen_keys.insert(*key) + { + keys.serialize_entry( + key.as_str(), + &JsonEventSerializer { + inner: value, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + }, + )?; + } + } + keys.end() + } +} + +impl Serialize for JsonEventSerializer<&Error> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("type", self.inner.0.inner.as_str())?; + if self.with_description { + map.serialize_entry("text", self.inner.0.inner.description())?; + } + map.serialize_entry( + "data", + &JsonEventSerializer { + inner: Keys { + keys: self.inner.0.keys.as_slice(), + span_keys: &[], + }, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + }, + )?; + map.end() + } +} + +impl Serialize for JsonEventSerializer<&Value> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.inner { + Value::String(value) => value.serialize(serializer), + Value::UInt(value) => value.serialize(serializer), + Value::Int(value) => value.serialize(serializer), + Value::Float(value) => value.serialize(serializer), + Value::Timestamp(value) => DateTime::from_timestamp(*value as i64) + .to_rfc3339() + .serialize(serializer), + Value::Duration(value) => value.serialize(serializer), + Value::Bytes(value) => STANDARD.encode(value).serialize(serializer), + Value::Bool(value) => value.serialize(serializer), + Value::Ipv4(value) => value.serialize(serializer), + Value::Ipv6(value) => value.serialize(serializer), + Value::Event(value) => JsonEventSerializer { + inner: value, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + } + .serialize(serializer), + Value::Array(value) => JsonEventSerializer { + inner: value, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + } + .serialize(serializer), + Value::None => unreachable!(), + } + } +} + +impl Serialize for JsonEventSerializer<&Vec> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.inner.len()))?; + for value in self.inner { + seq.serialize_element(&JsonEventSerializer { + inner: value, + with_spans: self.with_spans, + with_description: self.with_description, + with_id: self.with_id, + })?; + } + seq.end() + } +} + +impl serde::Serialize for EventType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for EventType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = <&str>::deserialize(deserializer)?; + Self::parse(s).ok_or_else(|| serde::de::Error::unknown_variant(s, &[])) + } +} + +impl serde::Serialize for MetricType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for MetricType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = <&str>::deserialize(deserializer)?; + Self::parse(s).ok_or_else(|| serde::de::Error::unknown_variant(s, &[])) + } +} diff --git a/crates/trc/src/serializers/mod.rs b/crates/trc/src/serializers/mod.rs new file mode 100644 index 0000000..6c744fd --- /dev/null +++ b/crates/trc/src/serializers/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod json; +pub mod text; + diff --git a/crates/trc/src/serializers/text.rs b/crates/trc/src/serializers/text.rs new file mode 100644 index 0000000..1e86b8b --- /dev/null +++ b/crates/trc/src/serializers/text.rs @@ -0,0 +1,374 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::Display; + +use mail_parser::DateTime; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use crate::{Error, Event, EventDetails, Key, Level, Value}; +use base64::{Engine, engine::general_purpose::STANDARD}; + +pub struct FmtWriter { + writer: T, + ansi: bool, + multiline: bool, +} + +#[allow(dead_code)] +enum Color { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, +} + +impl FmtWriter { + pub fn new(writer: T) -> Self { + Self { + writer, + ansi: false, + multiline: false, + } + } + + pub fn with_ansi(self, ansi: bool) -> Self { + Self { ansi, ..self } + } + + pub fn with_multiline(self, multiline: bool) -> Self { + Self { multiline, ..self } + } + + pub async fn write(&mut self, event: &Event) -> std::io::Result<()> { + // Write timestamp + if self.ansi { + self.writer + .write_all(Color::White.as_code().as_bytes()) + .await?; + } + self.writer + .write_all( + DateTime::from_timestamp(event.inner.timestamp as i64) + .to_rfc3339() + .as_bytes(), + ) + .await?; + if self.ansi { + self.writer.write_all(Color::reset().as_bytes()).await?; + } + self.writer.write_all(" ".as_bytes()).await?; + + // Write level + if self.ansi { + self.writer + .write_all( + match event.inner.level { + Level::Error => Color::Red, + Level::Warn => Color::Yellow, + Level::Info => Color::Green, + Level::Debug => Color::Blue, + Level::Trace => Color::Magenta, + Level::Disable => return Ok(()), + } + .as_code_bold() + .as_bytes(), + ) + .await?; + } + self.writer + .write_all(event.inner.level.as_str().as_bytes()) + .await?; + if self.ansi { + self.writer.write_all(Color::reset().as_bytes()).await?; + } + self.writer.write_all(" ".as_bytes()).await?; + + // Write message + if self.ansi { + self.writer + .write_all(Color::White.as_code_bold().as_bytes()) + .await?; + } + self.writer + .write_all(event.inner.typ.description().as_bytes()) + .await?; + if self.ansi { + self.writer.write_all(Color::reset().as_bytes()).await?; + } + self.writer.write_all(" (".as_bytes()).await?; + self.writer + .write_all(event.inner.typ.as_str().as_bytes()) + .await?; + + self.writer + .write_all(if self.multiline { ")\n" } else { ") " }.as_bytes()) + .await?; + + // Write keys + if let Some(parent_event) = &event.inner.span { + self.write_keys(&parent_event.keys, &event.keys, 1).await?; + } else { + self.write_keys(&[], &event.keys, 1).await?; + } + + if !self.multiline { + self.writer.write_all("\n".as_bytes()).await?; + } + + Ok(()) + } + + async fn write_keys( + &mut self, + span_keys: &[(Key, Value)], + keys: &[(Key, Value)], + indent: usize, + ) -> std::io::Result<()> { + Box::pin(async move { + let mut is_first = true; + for (key, value) in span_keys.iter().chain(keys.iter()) { + if matches!(key, Key::SpanId) { + continue; + } else if is_first { + is_first = false; + } else if !self.multiline { + self.writer.write_all(", ".as_bytes()).await?; + } + + // Write key + if self.multiline { + for _ in 0..indent { + self.writer.write_all("\t".as_bytes()).await?; + } + } + if self.ansi { + self.writer + .write_all(Color::Cyan.as_code().as_bytes()) + .await?; + } + self.writer.write_all(key.as_str().as_bytes()).await?; + if self.ansi { + self.writer.write_all(Color::reset().as_bytes()).await?; + } + + // Write value + self.writer.write_all(" = ".as_bytes()).await?; + self.write_value(value, indent).await?; + + if self.multiline && !matches!(value, Value::Event(_)) { + self.writer.write_all("\n".as_bytes()).await?; + } + } + + Ok(()) + }) + .await + } + + async fn write_value(&mut self, value: &Value, indent: usize) -> std::io::Result<()> { + Box::pin(async move { + match value { + Value::String(v) => { + self.writer.write_all("\"".as_bytes()).await?; + for ch in v.as_bytes() { + match ch { + b'\r' => { + self.writer.write_all("\\r".as_bytes()).await?; + } + b'\n' => { + self.writer.write_all("\\n".as_bytes()).await?; + } + b'\t' => { + self.writer.write_all("\\t".as_bytes()).await?; + } + b'\\' => { + self.writer.write_all("\\\\".as_bytes()).await?; + } + _ => { + self.writer.write_all(&[*ch]).await?; + } + } + } + self.writer.write_all("\"".as_bytes()).await?; + } + Value::UInt(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + } + Value::Int(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + } + Value::Float(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + } + Value::Timestamp(v) => { + self.writer + .write_all(DateTime::from_timestamp(*v as i64).to_rfc3339().as_bytes()) + .await?; + } + Value::Duration(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + self.writer.write_all("ms".as_bytes()).await?; + } + Value::Bytes(bytes) => { + self.writer.write_all("base64:".as_bytes()).await?; + self.writer + .write_all(STANDARD.encode(bytes).as_bytes()) + .await?; + } + Value::Bool(true) => { + self.writer.write_all("true".as_bytes()).await?; + } + Value::Bool(false) => { + self.writer.write_all("false".as_bytes()).await?; + } + Value::Ipv4(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + } + Value::Ipv6(v) => { + self.writer.write_all(v.to_string().as_bytes()).await?; + } + Value::Event(e) => { + self.writer + .write_all(e.0.inner.description().as_bytes()) + .await?; + self.writer.write_all(" (".as_bytes()).await?; + self.writer.write_all(e.0.inner.as_str().as_bytes()).await?; + self.writer.write_all(")".as_bytes()).await?; + if !e.0.keys.is_empty() { + self.writer + .write_all(if self.multiline { "\n" } else { " { " }.as_bytes()) + .await?; + + self.write_keys(&e.0.keys, &[], indent + 1).await?; + + if !self.multiline { + self.writer.write_all(" }".as_bytes()).await?; + } + } else if self.multiline { + self.writer.write_all("\n".as_bytes()).await?; + } + } + Value::Array(arr) => { + self.writer.write_all("[".as_bytes()).await?; + for (pos, value) in arr.iter().enumerate() { + if pos > 0 { + self.writer.write_all(", ".as_bytes()).await?; + } + self.write_value(value, indent).await?; + } + self.writer.write_all("]".as_bytes()).await?; + } + Value::None => { + self.writer.write_all("(null)".as_bytes()).await?; + } + } + + Ok(()) + }) + .await + } + + pub async fn flush(&mut self) -> std::io::Result<()> { + self.writer.flush().await + } + + pub fn update_writer(&mut self, writer: T) { + self.writer = writer; + } +} + +impl Color { + pub fn as_code(&self) -> &'static str { + match self { + Color::Black => "\x1b[30m", + Color::Red => "\x1b[31m", + Color::Green => "\x1b[32m", + Color::Yellow => "\x1b[33m", + Color::Blue => "\x1b[34m", + Color::Magenta => "\x1b[35m", + Color::Cyan => "\x1b[36m", + Color::White => "\x1b[37m", + } + } + + pub fn as_code_bold(&self) -> &'static str { + match self { + Color::Black => "\x1b[30;1m", + Color::Red => "\x1b[31;1m", + Color::Green => "\x1b[32;1m", + Color::Yellow => "\x1b[33;1m", + Color::Blue => "\x1b[34;1m", + Color::Magenta => "\x1b[35;1m", + Color::Cyan => "\x1b[36;1m", + Color::White => "\x1b[37;1m", + } + } + + pub fn reset() -> &'static str { + "\x1b[0m" + } +} + +impl Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Value::String(value) => value.fmt(f), + Value::UInt(value) => value.fmt(f), + Value::Int(value) => value.fmt(f), + Value::Float(value) => value.fmt(f), + Value::Timestamp(value) => value.fmt(f), + Value::Duration(value) => value.fmt(f), + Value::Bytes(value) => STANDARD.encode(value).fmt(f), + Value::Bool(value) => value.fmt(f), + Value::Ipv4(value) => value.fmt(f), + Value::Ipv6(value) => value.fmt(f), + Value::Event(value) => { + "{".fmt(f)?; + value.fmt(f)?; + "}".fmt(f) + } + Value::Array(value) => { + f.write_str("[")?; + for (i, value) in value.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + value.fmt(f)?; + } + f.write_str("]") + } + Value::None => "(null)".fmt(f), + } + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.inner.description().fmt(f)?; + " (".fmt(f)?; + self.0.inner.as_str().fmt(f)?; + ")".fmt(f)?; + + if !self.0.keys.is_empty() { + f.write_str(": ")?; + for (i, (key, value)) in self.0.keys.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + key.as_str().fmt(f)?; + f.write_str(" = ")?; + value.fmt(f)?; + } + } + + Ok(()) + } +} diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml new file mode 100644 index 0000000..1f64650 --- /dev/null +++ b/crates/types/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "types" +version = "0.16.22" +edition = "2024" + +[dependencies] +utils = { path = "../utils" } +trc = { path = "../trc" } +jmap-tools = { version = "0.1" } +hashify = "0.2" +serde = { version = "1.0", features = ["derive"]} +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = { version = "0.10.0", features = ["rkyv", "serde"] } +blake3 = "1.8.7" + + +[features] +test_mode = [] + + +[lints] +workspace = true diff --git a/crates/types/src/acl.rs b/crates/types/src/acl.rs new file mode 100644 index 0000000..a73bbde --- /dev/null +++ b/crates/types/src/acl.rs @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::{self, Display}; +use utils::map::bitmap::{Bitmap, BitmapItem}; + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Copy, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +#[repr(u8)] +pub enum Acl { + Read = 0, + Modify = 1, + Delete = 2, + ReadItems = 3, + AddItems = 4, + ModifyItems = 5, + RemoveItems = 6, + CreateChild = 7, + Share = 8, + Submit = 9, + SchedulingReadFreeBusy = 10, + SchedulingInvite = 11, + SchedulingReply = 12, + ModifyItemsOwn = 13, + ModifyPrivateProperties = 14, + ModifyRSVP = 15, + None = 16, +} + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + Default, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub struct AclGrant { + pub account_id: u32, + pub grants: Bitmap, +} + +impl Acl { + fn as_str(&self) -> &'static str { + match self { + Acl::Read => "read", + Acl::Modify => "modify", + Acl::Delete => "delete", + Acl::ReadItems => "readItems", + Acl::AddItems => "addItems", + Acl::ModifyItems => "modifyItems", + Acl::RemoveItems => "removeItems", + Acl::CreateChild => "createChild", + Acl::Share => "share", + Acl::Submit => "submit", + Acl::ModifyItemsOwn => "modifyItemsOwn", + Acl::ModifyPrivateProperties => "modifyPrivateProperties", + Acl::None => "", + Acl::SchedulingReadFreeBusy => "schedulingReadFreeBusy", + Acl::SchedulingInvite => "schedulingInvite", + Acl::SchedulingReply => "schedulingReply", + Acl::ModifyRSVP => "modifyRSVP", + } + } +} + +impl Display for Acl { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl serde::Serialize for Acl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl BitmapItem for Acl { + fn max() -> u64 { + Acl::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, Acl::None) + } +} + +impl From for u64 { + fn from(value: Acl) -> Self { + value as u64 + } +} + +impl From for Acl { + fn from(value: u64) -> Self { + match value { + 0 => Acl::Read, + 1 => Acl::Modify, + 2 => Acl::Delete, + 3 => Acl::ReadItems, + 4 => Acl::AddItems, + 5 => Acl::ModifyItems, + 6 => Acl::RemoveItems, + 7 => Acl::CreateChild, + 8 => Acl::Share, + 9 => Acl::Submit, + 10 => Acl::SchedulingReadFreeBusy, + 11 => Acl::SchedulingInvite, + 12 => Acl::SchedulingReply, + 13 => Acl::ModifyItemsOwn, + 14 => Acl::ModifyPrivateProperties, + 15 => Acl::ModifyRSVP, + _ => Acl::None, + } + } +} + +impl From<&ArchivedAclGrant> for AclGrant { + fn from(value: &ArchivedAclGrant) -> Self { + Self { + account_id: u32::from(value.account_id), + grants: (&value.grants).into(), + } + } +} diff --git a/crates/types/src/blob.rs b/crates/types/src/blob.rs new file mode 100644 index 0000000..16619fa --- /dev/null +++ b/crates/types/src/blob.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Property, Value}; +use std::{borrow::Borrow, str::FromStr, time::SystemTime}; +use utils::codec::{ + base32_custom::{Base32Reader, Base32Writer}, + leb128::{Leb128Iterator, Leb128Writer}, +}; + +use crate::blob_hash::BlobHash; + +const B_LINKED: u8 = 0x10; +const B_RESERVED: u8 = 0x20; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum BlobClass { + Reserved { + account_id: u32, + expires: u64, + }, + Linked { + account_id: u32, + collection: u8, + document_id: u32, + }, +} + +impl Default for BlobClass { + fn default() -> Self { + BlobClass::Reserved { + account_id: u32::MAX, + expires: u64::MAX, + } + } +} + +impl AsRef for BlobClass { + fn as_ref(&self) -> &BlobClass { + self + } +} + +impl BlobClass { + pub fn account_id(&self) -> u32 { + match self { + BlobClass::Reserved { account_id, .. } | BlobClass::Linked { account_id, .. } => { + *account_id + } + } + } + + pub fn is_valid(&self) -> bool { + match self { + BlobClass::Reserved { expires, .. } => { + *expires + > SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + } + BlobClass::Linked { .. } => true, + } + } + + pub fn is_superuser(&self) -> bool { + matches!(self, BlobClass::Reserved { account_id, expires } if *account_id == u32::MAX && *expires == u64::MAX) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BlobId { + pub hash: BlobHash, + pub class: BlobClass, + pub section: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BlobSection { + pub offset_start: usize, + pub size: usize, + pub encoding: u8, +} + +impl FromStr for BlobId { + type Err = (); + + fn from_str(s: &str) -> Result { + BlobId::from_base32(s).ok_or(()) + } +} + +impl BlobId { + pub fn new(hash: BlobHash, class: BlobClass) -> Self { + BlobId { + hash, + class, + section: None, + } + } + + pub fn new_section( + hash: BlobHash, + class: BlobClass, + offset_start: usize, + offset_end: usize, + encoding: impl Into, + ) -> Self { + BlobId { + hash, + class, + section: BlobSection { + offset_start, + size: offset_end - offset_start, + encoding: encoding.into(), + } + .into(), + } + } + + pub fn with_section_size(mut self, size: usize) -> Self { + self.section.get_or_insert_with(Default::default).size = size; + self + } + + #[inline] + pub fn from_base32(value: impl AsRef<[u8]>) -> Option { + BlobId::from_iter(&mut Base32Reader::new(value.as_ref())) + } + + #[allow(clippy::should_implement_trait)] + pub fn from_iter(it: &mut T) -> Option + where + T: Iterator + Leb128Iterator, + U: Borrow, + { + let class = *it.next()?.borrow(); + let encoding = class & 0x0F; + + let mut hash = BlobHash::default(); + for byte in hash.as_mut().iter_mut() { + *byte = *it.next()?.borrow(); + } + + let account_id: u32 = it.next_leb128()?; + + BlobId { + hash, + class: if (class & B_LINKED) != 0 { + BlobClass::Linked { + account_id, + collection: *it.next()?.borrow(), + document_id: it.next_leb128()?, + } + } else { + BlobClass::Reserved { + account_id, + expires: it.next_leb128()?, + } + }, + section: if encoding != 0 { + BlobSection { + offset_start: it.next_leb128()?, + size: it.next_leb128()?, + encoding: encoding - 1, + } + .into() + } else { + None + }, + } + .into() + } + + fn serialize_as(&self, writer: &mut impl Leb128Writer) { + let marker = self + .section + .as_ref() + .map_or(0, |section| section.encoding + 1) + | if matches!( + self, + BlobId { + class: BlobClass::Linked { .. }, + .. + } + ) { + B_LINKED + } else { + B_RESERVED + }; + + let _ = writer.write(&[marker]); + let _ = writer.write(self.hash.as_ref()); + + match &self.class { + BlobClass::Reserved { + account_id, + expires, + } => { + let _ = writer.write_leb128(*account_id); + let _ = writer.write_leb128(*expires); + } + BlobClass::Linked { + account_id, + collection, + document_id, + } => { + let _ = writer.write_leb128(*account_id); + let _ = writer.write(&[*collection]); + let _ = writer.write_leb128(*document_id); + } + } + + if let Some(section) = &self.section { + let _ = writer.write_leb128(section.offset_start); + let _ = writer.write_leb128(section.size); + } + } + + pub fn start_offset(&self) -> usize { + if let Some(section) = &self.section { + section.offset_start + } else { + 0 + } + } + + pub fn is_empty(&self) -> bool { + self.hash.is_empty() + } +} + +impl serde::Serialize for BlobId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for BlobId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + BlobId::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid BlobId")) + } +} + +impl std::fmt::Display for BlobId { + #[allow(clippy::unused_io_amount)] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut writer = Base32Writer::with_capacity(std::mem::size_of::() * 2); + self.serialize_as(&mut writer); + f.write_str(&writer.finalize()) + } +} + +impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { + fn from(id: BlobId) -> Self { + Value::Element(E::from(id)) + } +} diff --git a/crates/types/src/blob_hash.rs b/crates/types/src/blob_hash.rs new file mode 100644 index 0000000..c756e88 --- /dev/null +++ b/crates/types/src/blob_hash.rs @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub const BLOB_HASH_LEN: usize = 32; + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Clone, + Debug, + Default, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +#[rkyv(derive(Debug))] +#[repr(transparent)] +pub struct BlobHash(pub [u8; BLOB_HASH_LEN]); + +impl BlobHash { + pub fn new_max() -> Self { + BlobHash([u8::MAX; BLOB_HASH_LEN]) + } + + pub fn generate(value: impl AsRef<[u8]>) -> Self { + BlobHash(blake3::hash(value.as_ref()).into()) + } + + pub fn try_from_hash_slice(value: &[u8]) -> Result { + value.try_into().map(BlobHash) + } + + pub fn as_slice(&self) -> &[u8] { + self.0.as_ref() + } + + pub fn to_hex(&self) -> String { + let mut hex = String::with_capacity(BLOB_HASH_LEN * 2); + for byte in self.0.iter() { + hex.push_str(&format!("{:02x}", byte)); + } + hex + } + + pub fn is_empty(&self) -> bool { + self.0 == [0; BLOB_HASH_LEN] + } +} + +impl From<&ArchivedBlobHash> for BlobHash { + fn from(value: &ArchivedBlobHash) -> Self { + BlobHash(value.0) + } +} + +impl AsRef for BlobHash { + fn as_ref(&self) -> &BlobHash { + self + } +} + +impl From for Vec { + fn from(value: BlobHash) -> Self { + value.0.to_vec() + } +} + +impl AsRef<[u8]> for BlobHash { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl AsMut<[u8]> for BlobHash { + fn as_mut(&mut self) -> &mut [u8] { + self.0.as_mut() + } +} diff --git a/crates/types/src/collection.rs b/crates/types/src/collection.rs new file mode 100644 index 0000000..d40416f --- /dev/null +++ b/crates/types/src/collection.rs @@ -0,0 +1,447 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::type_state::DataType; +use compact_str::CompactString; +use std::{ + fmt::{self, Display, Formatter}, + str::FromStr, +}; +use utils::map::bitmap::BitmapItem; + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, +)] +#[repr(u8)] +pub enum Collection { + Email = 0, + Mailbox = 1, + Thread = 2, + Identity = 3, + EmailSubmission = 4, + SieveScript = 5, + PushSubscription = 6, + Principal = 7, + Calendar = 8, + CalendarEvent = 9, + AddressBook = 10, + ContactCard = 11, + FileNode = 12, + CalendarEventNotification = 13, + #[default] + None = 14, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum SyncCollection { + Email = 0, + Thread = 1, + Calendar = 2, + AddressBook = 3, + FileNode = 4, + Identity = 5, + EmailSubmission = 6, + SieveScript = 7, + CalendarEventNotification = 8, + ShareNotification = 9, + #[default] + None = 10, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[repr(u8)] +pub enum VanishedCollection { + Email = 251, + Calendar = 252, + AddressBook = 253, + FileNode = 254, +} + +impl Collection { + pub const MAX: usize = Collection::None as usize; + + pub fn main_collection(&self) -> Collection { + match self { + Collection::Email => Collection::Mailbox, + Collection::CalendarEvent => Collection::Calendar, + Collection::ContactCard => Collection::AddressBook, + _ => *self, + } + } + + pub fn parent_collection(&self) -> Option { + match self { + Collection::Email => Some(Collection::Mailbox), + Collection::CalendarEvent => Some(Collection::Calendar), + Collection::ContactCard => Some(Collection::AddressBook), + Collection::FileNode => Some(Collection::FileNode), + Collection::CalendarEventNotification => Some(Collection::CalendarEventNotification), + _ => None, + } + } + + pub fn child_collection(&self) -> Option { + match self { + Collection::Mailbox => Some(Collection::Email), + Collection::Calendar => Some(Collection::CalendarEvent), + Collection::AddressBook => Some(Collection::ContactCard), + Collection::FileNode => Some(Collection::FileNode), + Collection::CalendarEventNotification => Some(Collection::CalendarEventNotification), + _ => None, + } + } +} + +impl SyncCollection { + pub fn collection(&self, is_container: bool) -> Collection { + match self { + SyncCollection::Email => { + if is_container { + Collection::Mailbox + } else { + Collection::Email + } + } + SyncCollection::Thread => Collection::Thread, + SyncCollection::Calendar => { + if is_container { + Collection::Calendar + } else { + Collection::CalendarEvent + } + } + SyncCollection::AddressBook => { + if is_container { + Collection::AddressBook + } else { + Collection::ContactCard + } + } + SyncCollection::FileNode => Collection::FileNode, + SyncCollection::Identity => Collection::Identity, + SyncCollection::EmailSubmission => Collection::EmailSubmission, + SyncCollection::SieveScript => Collection::SieveScript, + SyncCollection::CalendarEventNotification => Collection::CalendarEventNotification, + SyncCollection::ShareNotification | SyncCollection::None => Collection::None, + } + } + + pub fn vanished_collection(&self) -> Option { + match self { + SyncCollection::Email => Some(VanishedCollection::Email), + SyncCollection::Calendar => Some(VanishedCollection::Calendar), + SyncCollection::AddressBook => Some(VanishedCollection::AddressBook), + SyncCollection::FileNode => Some(VanishedCollection::FileNode), + _ => None, + } + } +} + +impl From for SyncCollection { + fn from(v: Collection) -> Self { + match v { + Collection::Email => SyncCollection::Email, + Collection::Mailbox => SyncCollection::Email, + Collection::Thread => SyncCollection::Thread, + Collection::Identity => SyncCollection::Identity, + Collection::EmailSubmission => SyncCollection::EmailSubmission, + Collection::SieveScript => SyncCollection::SieveScript, + Collection::PushSubscription => SyncCollection::None, + Collection::Principal => SyncCollection::None, + Collection::Calendar => SyncCollection::Calendar, + Collection::CalendarEvent => SyncCollection::Calendar, + Collection::CalendarEventNotification => SyncCollection::CalendarEventNotification, + Collection::AddressBook => SyncCollection::AddressBook, + Collection::ContactCard => SyncCollection::AddressBook, + Collection::FileNode => SyncCollection::FileNode, + _ => SyncCollection::None, + } + } +} + +impl From for Collection { + fn from(v: u8) -> Self { + match v { + 0 => Collection::Email, + 1 => Collection::Mailbox, + 2 => Collection::Thread, + 3 => Collection::Identity, + 4 => Collection::EmailSubmission, + 5 => Collection::SieveScript, + 6 => Collection::PushSubscription, + 7 => Collection::Principal, + 8 => Collection::Calendar, + 9 => Collection::CalendarEvent, + 10 => Collection::AddressBook, + 11 => Collection::ContactCard, + 12 => Collection::FileNode, + 13 => Collection::CalendarEventNotification, + _ => Collection::None, + } + } +} + +impl From for SyncCollection { + fn from(v: u8) -> Self { + match v { + 0 => SyncCollection::Email, + 1 => SyncCollection::Thread, + 2 => SyncCollection::Calendar, + 3 => SyncCollection::AddressBook, + 4 => SyncCollection::FileNode, + 5 => SyncCollection::Identity, + 6 => SyncCollection::EmailSubmission, + 7 => SyncCollection::SieveScript, + 8 => SyncCollection::CalendarEventNotification, + 9 => SyncCollection::ShareNotification, + _ => SyncCollection::None, + } + } +} + +impl From for SyncCollection { + fn from(v: u64) -> Self { + match v { + 0 => SyncCollection::Email, + 1 => SyncCollection::Thread, + 2 => SyncCollection::Calendar, + 3 => SyncCollection::AddressBook, + 4 => SyncCollection::FileNode, + 5 => SyncCollection::Identity, + 6 => SyncCollection::EmailSubmission, + 7 => SyncCollection::SieveScript, + 8 => SyncCollection::CalendarEventNotification, + 9 => SyncCollection::ShareNotification, + _ => SyncCollection::None, + } + } +} + +impl From for Collection { + fn from(v: u64) -> Self { + match v { + 0 => Collection::Email, + 1 => Collection::Mailbox, + 2 => Collection::Thread, + 3 => Collection::Identity, + 4 => Collection::EmailSubmission, + 5 => Collection::SieveScript, + 6 => Collection::PushSubscription, + 7 => Collection::Principal, + 8 => Collection::Calendar, + 9 => Collection::CalendarEvent, + 10 => Collection::AddressBook, + 11 => Collection::ContactCard, + 12 => Collection::FileNode, + 13 => Collection::CalendarEventNotification, + _ => Collection::None, + } + } +} + +impl From for u8 { + fn from(v: Collection) -> Self { + v as u8 + } +} + +impl From for u8 { + fn from(v: SyncCollection) -> Self { + v as u8 + } +} + +impl From for u64 { + fn from(v: SyncCollection) -> Self { + v as u64 + } +} + +impl From for u8 { + fn from(v: VanishedCollection) -> Self { + v as u8 + } +} + +impl From for u64 { + fn from(collection: Collection) -> u64 { + collection as u64 + } +} + +impl TryFrom for DataType { + type Error = (); + + fn try_from(value: Collection) -> Result { + match value { + Collection::Email => Ok(DataType::Email), + Collection::Mailbox => Ok(DataType::Mailbox), + Collection::Thread => Ok(DataType::Thread), + Collection::Identity => Ok(DataType::Identity), + Collection::EmailSubmission => Ok(DataType::EmailSubmission), + Collection::SieveScript => Ok(DataType::SieveScript), + Collection::PushSubscription => Ok(DataType::PushSubscription), + Collection::Principal => Ok(DataType::Principal), + Collection::Calendar => Ok(DataType::Calendar), + Collection::CalendarEvent => Ok(DataType::CalendarEvent), + Collection::AddressBook => Ok(DataType::AddressBook), + Collection::ContactCard => Ok(DataType::ContactCard), + Collection::FileNode => Ok(DataType::FileNode), + Collection::CalendarEventNotification => Ok(DataType::CalendarEventNotification), + _ => Err(()), + } + } +} + +impl TryFrom for Collection { + type Error = (); + + fn try_from(value: DataType) -> Result { + match value { + DataType::Email => Ok(Collection::Email), + DataType::Mailbox => Ok(Collection::Mailbox), + DataType::Thread => Ok(Collection::Thread), + DataType::Identity => Ok(Collection::Identity), + DataType::EmailSubmission => Ok(Collection::EmailSubmission), + DataType::SieveScript => Ok(Collection::SieveScript), + DataType::PushSubscription => Ok(Collection::PushSubscription), + DataType::Principal => Ok(Collection::Principal), + DataType::Calendar => Ok(Collection::Calendar), + DataType::CalendarEvent => Ok(Collection::CalendarEvent), + DataType::AddressBook => Ok(Collection::AddressBook), + DataType::ContactCard => Ok(Collection::ContactCard), + DataType::FileNode => Ok(Collection::FileNode), + DataType::CalendarEventNotification => Ok(Collection::CalendarEventNotification), + _ => Err(()), + } + } +} + +impl Display for Collection { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } +} + +impl Collection { + pub fn as_str(&self) -> &'static str { + match self { + Collection::PushSubscription => "pushSubscription", + Collection::Email => "email", + Collection::Mailbox => "mailbox", + Collection::Thread => "thread", + Collection::Identity => "identity", + Collection::EmailSubmission => "emailSubmission", + Collection::SieveScript => "sieveScript", + Collection::Principal => "principal", + Collection::Calendar => "calendar", + Collection::CalendarEvent => "calendarEvent", + Collection::AddressBook => "addressBook", + Collection::ContactCard => "contactCard", + Collection::FileNode => "fileNode", + Collection::CalendarEventNotification => "calendarEventNotification", + Collection::None => "", + } + } + + pub fn as_config_case(&self) -> &'static str { + match self { + Collection::PushSubscription => "push-subscription", + Collection::Email => "email", + Collection::Mailbox => "mailbox", + Collection::Thread => "thread", + Collection::Identity => "identity", + Collection::EmailSubmission => "email-submission", + Collection::SieveScript => "sieve-script", + Collection::Principal => "principal", + Collection::Calendar => "calendar", + Collection::CalendarEvent => "calendar-event", + Collection::AddressBook => "address-book", + Collection::ContactCard => "contact-card", + Collection::FileNode => "file-node", + Collection::CalendarEventNotification => "calendar-event-notification", + Collection::None => "", + } + } +} + +impl FromStr for Collection { + type Err = (); + + fn from_str(s: &str) -> Result { + hashify::tiny_map!(s.as_bytes(), + "pushSubscription" => Collection::PushSubscription, + "email" => Collection::Email, + "mailbox" => Collection::Mailbox, + "thread" => Collection::Thread, + "identity" => Collection::Identity, + "emailSubmission" => Collection::EmailSubmission, + "sieveScript" => Collection::SieveScript, + "principal" => Collection::Principal, + "calendar" => Collection::Calendar, + "calendarEvent" => Collection::CalendarEvent, + "addressBook" => Collection::AddressBook, + "contactCard" => Collection::ContactCard, + "fileNode" => Collection::FileNode, + "calendarEventNotification" => Collection::CalendarEventNotification, + ) + .ok_or(()) + } +} + +impl From for trc::Value { + fn from(value: Collection) -> Self { + trc::Value::String(CompactString::const_new(value.as_str())) + } +} + +impl BitmapItem for Collection { + fn max() -> u64 { + Collection::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, Collection::None) + } +} + +impl BitmapItem for SyncCollection { + fn max() -> u64 { + SyncCollection::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, SyncCollection::None) + } +} + +impl SyncCollection { + pub fn as_str(&self) -> &'static str { + match self { + SyncCollection::Email => "email", + SyncCollection::Thread => "thread", + SyncCollection::Calendar => "calendar", + SyncCollection::AddressBook => "addressBook", + SyncCollection::FileNode => "fileNode", + SyncCollection::Identity => "identity", + SyncCollection::EmailSubmission => "emailSubmission", + SyncCollection::SieveScript => "sieveScript", + SyncCollection::CalendarEventNotification => "calendarEventNotification", + SyncCollection::ShareNotification => "shareNotification", + SyncCollection::None => "", + } + } +} diff --git a/crates/types/src/dead_property.rs b/crates/types/src/dead_property.rs new file mode 100644 index 0000000..73aaf4e --- /dev/null +++ b/crates/types/src/dead_property.rs @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[cfg_attr(feature = "test_mode", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "test_mode", serde(tag = "type", content = "data"))] +#[rkyv(derive(Debug))] +pub enum DeadPropertyTag { + ElementStart(DeadElementTag), + ElementEnd, + Text(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[cfg_attr(feature = "test_mode", derive(serde::Serialize, serde::Deserialize))] +#[rkyv(derive(Debug))] +pub struct DeadElementTag { + pub name: String, + pub attrs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +#[cfg_attr(feature = "test_mode", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "test_mode", serde(transparent))] +#[rkyv(derive(Debug))] +#[repr(transparent)] +pub struct DeadProperty(pub Vec); + +impl From<&ArchivedDeadProperty> for DeadProperty { + fn from(value: &ArchivedDeadProperty) -> Self { + DeadProperty(value.0.iter().map(|tag| tag.into()).collect::>()) + } +} + +impl From<&ArchivedDeadPropertyTag> for DeadPropertyTag { + fn from(tag: &ArchivedDeadPropertyTag) -> Self { + match tag { + ArchivedDeadPropertyTag::ElementStart(tag) => DeadPropertyTag::ElementStart(tag.into()), + ArchivedDeadPropertyTag::ElementEnd => DeadPropertyTag::ElementEnd, + ArchivedDeadPropertyTag::Text(tag) => DeadPropertyTag::Text(tag.to_string()), + } + } +} + +impl From<&ArchivedDeadElementTag> for DeadElementTag { + fn from(tag: &ArchivedDeadElementTag) -> Self { + DeadElementTag { + name: tag.name.to_string(), + attrs: tag.attrs.as_ref().map(|s| s.to_string()), + } + } +} + +impl ArchivedDeadProperty { + pub fn find_tag(&self, needle: &str) -> Option { + let mut depth: u32 = 0; + let mut tags = Vec::new(); + let mut found_tag = false; + + for tag in self.0.iter() { + match tag { + ArchivedDeadPropertyTag::ElementStart(start) => { + if depth == 0 && start.name == needle { + found_tag = true; + } else if found_tag { + tags.push(tag.into()); + } + + depth += 1; + } + ArchivedDeadPropertyTag::ElementEnd => { + if found_tag { + if depth == 1 { + break; + } else { + tags.push(tag.into()); + } + } + depth = depth.saturating_sub(1); + } + ArchivedDeadPropertyTag::Text(_) => { + if found_tag { + tags.push(tag.into()); + } + } + } + } + + if found_tag { + Some(DeadProperty(tags)) + } else { + None + } + } +} + +impl DeadProperty { + pub fn remove_element(&mut self, element: &DeadElementTag) { + let mut depth = 0; + let mut remove = false; + self.0.retain(|item| match item { + DeadPropertyTag::ElementStart(tag) => { + if depth == 0 && !remove && tag.name == element.name { + remove = true; + } + depth += 1; + + !remove + } + DeadPropertyTag::ElementEnd => { + depth -= 1; + if remove && depth == 0 { + remove = false; + false + } else { + !remove + } + } + _ => !remove, + }); + } + + pub fn add_element(&mut self, element: DeadElementTag, values: Vec) { + self.0.push(DeadPropertyTag::ElementStart(element)); + self.0.extend(values); + self.0.push(DeadPropertyTag::ElementEnd); + } + + pub fn size(&self) -> usize { + let mut size = 0; + for item in &self.0 { + match item { + DeadPropertyTag::ElementStart(tag) => { + size += tag.size(); + } + DeadPropertyTag::ElementEnd => { + size += 1; + } + DeadPropertyTag::Text(text) => { + size += text.len(); + } + } + } + size + } +} + +impl ArchivedDeadProperty { + pub fn size(&self) -> usize { + let mut size = 0; + for item in self.0.iter() { + match item { + ArchivedDeadPropertyTag::ElementStart(tag) => { + size += tag.size(); + } + ArchivedDeadPropertyTag::ElementEnd => { + size += 1; + } + ArchivedDeadPropertyTag::Text(text) => { + size += text.len(); + } + } + } + size + } +} + +impl DeadElementTag { + pub fn new(name: String, attrs: Option) -> Self { + DeadElementTag { name, attrs } + } + + pub fn size(&self) -> usize { + self.name.len() + self.attrs.as_ref().map_or(0, |attrs| attrs.len()) + } +} + +impl ArchivedDeadElementTag { + pub fn size(&self) -> usize { + self.name.len() + self.attrs.as_ref().map_or(0, |attrs| attrs.len()) + } +} + +impl Default for DeadProperty { + fn default() -> Self { + DeadProperty(Vec::with_capacity(4)) + } +} diff --git a/crates/types/src/field.rs b/crates/types/src/field.rs new file mode 100644 index 0000000..7cb59d5 --- /dev/null +++ b/crates/types/src/field.rs @@ -0,0 +1,260 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +const ARCHIVE_FIELD: u8 = 50; + +pub trait FieldType: Into + Copy + std::fmt::Debug + PartialEq + Eq {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(transparent)] +pub struct Field(u8); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ContactField { + Uid, + Email, + Archive, + CreatedToUpdated, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum CalendarEventField { + Uid, + Archive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum CalendarNotificationField { + CreatedToId, + Archive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum EmailField { + Archive, + Metadata, + Threading, + DeletedAt, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum MailboxField { + UidCounter = 84, + Archive = ARCHIVE_FIELD, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum SieveField { + Name, + Ids, + Archive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum EmailSubmissionField { + Archive, + Metadata, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum IdentityField { + Archive, + DocumentId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum PrincipalField { + Archive = ARCHIVE_FIELD, + ParticipantIdentities = 45, + IdentityAddresses = 110, + DefaultCalendarId = 47, + DefaultAddressBookId = 48, + ActiveScriptId = 49, + PushSubscriptions = 44, +} + +impl From for u8 { + fn from(value: ContactField) -> Self { + match value { + ContactField::Uid => 0, + ContactField::Email => 1, + ContactField::CreatedToUpdated => 2, + ContactField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: CalendarEventField) -> Self { + match value { + CalendarEventField::Uid => 0, + CalendarEventField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: CalendarNotificationField) -> Self { + match value { + CalendarNotificationField::CreatedToId => 0, + CalendarNotificationField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: EmailField) -> Self { + match value { + EmailField::Metadata => 71, + EmailField::Threading => 90, + EmailField::DeletedAt => 91, + EmailField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: MailboxField) -> Self { + match value { + MailboxField::UidCounter => 84, + MailboxField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: SieveField) -> Self { + match value { + SieveField::Name => 13, + SieveField::Ids => 84, + SieveField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: EmailSubmissionField) -> Self { + match value { + EmailSubmissionField::Metadata => 49, + EmailSubmissionField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: PrincipalField) -> Self { + match value { + PrincipalField::ParticipantIdentities => 45, + PrincipalField::IdentityAddresses => 110, + PrincipalField::DefaultCalendarId => 47, + PrincipalField::DefaultAddressBookId => 48, + PrincipalField::ActiveScriptId => 49, + PrincipalField::PushSubscriptions => 44, + PrincipalField::Archive => ARCHIVE_FIELD, + } + } +} + +impl From for u8 { + fn from(value: IdentityField) -> Self { + match value { + IdentityField::Archive => ARCHIVE_FIELD, + IdentityField::DocumentId => 51, + } + } +} + +impl From for u8 { + fn from(value: Field) -> Self { + value.0 + } +} + +impl From for Field { + fn from(value: ContactField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: CalendarEventField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: CalendarNotificationField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: EmailField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: MailboxField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: PrincipalField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: SieveField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: EmailSubmissionField) -> Self { + Field(u8::from(value)) + } +} + +impl From for Field { + fn from(value: IdentityField) -> Self { + Field(u8::from(value)) + } +} + +impl Field { + pub const ARCHIVE: Field = Field(ARCHIVE_FIELD); + + pub fn new(value: u8) -> Self { + Field(value) + } + + pub fn inner(&self) -> u8 { + self.0 + } +} + +impl FieldType for Field {} +impl FieldType for ContactField {} +impl FieldType for CalendarEventField {} +impl FieldType for CalendarNotificationField {} +impl FieldType for EmailField {} +impl FieldType for MailboxField {} +impl FieldType for PrincipalField {} +impl FieldType for SieveField {} +impl FieldType for EmailSubmissionField {} +impl FieldType for IdentityField {} diff --git a/crates/types/src/id.rs b/crates/types/src/id.rs new file mode 100644 index 0000000..2befedc --- /dev/null +++ b/crates/types/src/id.rs @@ -0,0 +1,264 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::DocumentId; +use jmap_tools::{Element, Property, Value}; +use std::{ops::Deref, str::FromStr}; +use utils::codec::base32_custom::{BASE32_ALPHABET, BASE32_INVERSE}; + +#[derive( + rkyv::Archive, + rkyv::Serialize, + rkyv::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + Hash, + Copy, + PartialOrd, + Ord, +)] +#[rkyv(derive(Debug), compare(PartialEq))] +#[repr(transparent)] +pub struct Id(u64); + +impl Default for Id { + fn default() -> Self { + Id(u64::MAX) + } +} + +impl FromStr for Id { + type Err = (); + + fn from_str(s: &str) -> Result { + let mut id = 0; + + for &ch in s.as_bytes() { + let i = BASE32_INVERSE[ch as usize]; + if i != u8::MAX { + id = (id << 5) | i as u64; + } else { + return Err(()); + } + } + + Ok(Id(id)) + } +} + +impl From<&ArchivedId> for Id { + fn from(value: &ArchivedId) -> Self { + Id(value.0.to_native()) + } +} + +impl Id { + pub fn new(id: u64) -> Self { + Self(id) + } + + pub fn singleton() -> Self { + Self::new(20080258862541) + } + + // From https://github.com/archer884/crockford by J/A + // License: MIT/Apache 2.0 + pub fn as_string(&self) -> String { + match self.0 { + 0 => "a".to_string(), + mut n => { + // Used for the initial shift. + const QUAD_SHIFT: usize = 60; + const QUAD_RESET: usize = 4; + + // Used for all subsequent shifts. + const FIVE_SHIFT: usize = 59; + const FIVE_RESET: usize = 5; + + // After we clear the four most significant bits, the four least significant bits will be + // replaced with 0001. We can then know to stop once the four most significant bits are, + // likewise, 0001. + const STOP_BIT: u64 = 1 << QUAD_SHIFT; + + let mut buf = String::with_capacity(7); + + // Start by getting the most significant four bits. We get four here because these would be + // leftovers when starting from the least significant bits. In either case, tag the four least + // significant bits with our stop bit. + match (n >> QUAD_SHIFT) as usize { + // Eat leading zero-bits. This should not be done if the first four bits were non-zero. + // Additionally, we *must* do this in increments of five bits. + 0 => { + n <<= QUAD_RESET; + n |= 1; + n <<= n.leading_zeros() / 5 * 5; + } + + // Write value of first four bytes. + i => { + n <<= QUAD_RESET; + n |= 1; + buf.push(char::from(BASE32_ALPHABET[i])); + } + } + + // From now until we reach the stop bit, take the five most significant bits and then shift + // left by five bits. + while n != STOP_BIT { + buf.push(char::from(BASE32_ALPHABET[(n >> FIVE_SHIFT) as usize])); + n <<= FIVE_RESET; + } + + buf + } + } + } + + #[inline(always)] + pub fn from_parts(prefix_id: DocumentId, doc_id: DocumentId) -> Id { + Id(((prefix_id as u64) << 32) | doc_id as u64) + } + + #[inline(always)] + pub fn id(&self) -> u64 { + self.0 + } + + #[inline(always)] + pub fn document_id(&self) -> DocumentId { + self.0 as DocumentId + } + + #[inline(always)] + pub fn prefix_id(&self) -> DocumentId { + (self.0 >> 32) as DocumentId + } + + #[inline(always)] + pub fn is_singleton(&self) -> bool { + self.0 == 20080258862541 + } + + #[inline(always)] + pub fn is_valid(&self) -> bool { + self.0 != u64::MAX + } +} + +impl From for Id { + fn from(id: u64) -> Self { + Id(id) + } +} + +impl From for Id { + fn from(id: u32) -> Self { + Id(id as u64) + } +} + +impl From for u64 { + fn from(id: Id) -> Self { + id.0 + } +} + +impl From<&Id> for u64 { + fn from(id: &Id) -> Self { + id.0 + } +} + +impl From<(u32, u32)> for Id { + fn from(id: (u32, u32)) -> Self { + Id::from_parts(id.0, id.1) + } +} + +impl Deref for Id { + type Target = u64; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for Id { + fn as_ref(&self) -> &u64 { + &self.0 + } +} + +impl From for u32 { + fn from(id: Id) -> Self { + id.document_id() + } +} + +impl From for String { + fn from(id: Id) -> Self { + id.as_string() + } +} + +impl serde::Serialize for Id { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_string().as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Id { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Id::from_str(<&str>::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("invalid JMAP ID")) + } +} + +impl std::fmt::Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.as_string()) + } +} + +impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { + fn from(id: Id) -> Self { + Value::Element(E::from(id)) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::id::Id; + + #[test] + fn parse_jmap_id() { + for number in [ + 0, + 1, + 10, + 1000, + Id::singleton().id(), + u64::MAX / 2, + u64::MAX - 1, + u64::MAX, + ] { + let id = Id::from(number); + assert_eq!(Id::from_str(&id.to_string()).unwrap(), id); + } + + Id::from_str("p333333333333p333333333333").unwrap(); + } +} diff --git a/crates/types/src/keyword.rs b/crates/types/src/keyword.rs new file mode 100644 index 0000000..88427e9 --- /dev/null +++ b/crates/types/src/keyword.rs @@ -0,0 +1,538 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Property, Value}; +use std::{fmt::Display, str::FromStr}; + +pub const SEEN: usize = 0; +pub const DRAFT: usize = 1; +pub const FLAGGED: usize = 2; +pub const ANSWERED: usize = 3; +pub const RECENT: usize = 4; +pub const IMPORTANT: usize = 5; +pub const PHISHING: usize = 6; +pub const JUNK: usize = 7; +pub const NOTJUNK: usize = 8; +pub const DELETED: usize = 9; +pub const FORWARDED: usize = 10; +pub const MDN_SENT: usize = 11; +pub const AUTOSENT: usize = 12; +pub const CANUNSUBSCRIBE: usize = 13; +pub const FOLLOWED: usize = 14; +pub const HASATTACHMENT: usize = 15; +pub const HASMEMO: usize = 16; +pub const HASNOATTACHMENT: usize = 17; +pub const IMPORTED: usize = 18; +pub const ISTRUSTED: usize = 19; +pub const MAILFLAGBIT0: usize = 20; +pub const MAILFLAGBIT1: usize = 21; +pub const MAILFLAGBIT2: usize = 22; +pub const MASKEDEMAIL: usize = 23; +pub const MEMO: usize = 24; +pub const MUTED: usize = 25; +pub const NEW: usize = 26; +pub const NOTIFY: usize = 27; +pub const UNSUBSCRIBED: usize = 28; +pub const OTHER: usize = 29; + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Debug, + Clone, + PartialEq, + Eq, + Hash, + Default, + PartialOrd, + Ord, + serde::Serialize, +)] +#[serde(untagged)] +#[rkyv(derive(PartialEq), compare(PartialEq))] +pub enum Keyword { + Other(Box), + #[serde(rename(serialize = "$seen"))] + Seen, + #[serde(rename(serialize = "$draft"))] + Draft, + #[serde(rename(serialize = "$flagged"))] + Flagged, + #[serde(rename(serialize = "$answered"))] + Answered, + #[default] + #[serde(rename(serialize = "$recent"))] + Recent, + #[serde(rename(serialize = "$important"))] + Important, + #[serde(rename(serialize = "$phishing"))] + Phishing, + #[serde(rename(serialize = "$junk"))] + Junk, + #[serde(rename(serialize = "$notjunk"))] + NotJunk, + #[serde(rename(serialize = "$deleted"))] + Deleted, + #[serde(rename(serialize = "$forwarded"))] + Forwarded, + #[serde(rename(serialize = "$mdnsent"))] + MdnSent, + #[serde(rename(serialize = "$autosent"))] + Autosent, + #[serde(rename(serialize = "$canunsubscribe"))] + CanUnsubscribe, + #[serde(rename(serialize = "$followed"))] + Followed, + #[serde(rename(serialize = "$hasattachment"))] + HasAttachment, + #[serde(rename(serialize = "$hasmemo"))] + HasMemo, + #[serde(rename(serialize = "$hasnoattachment"))] + HasNoAttachment, + #[serde(rename(serialize = "$imported"))] + Imported, + #[serde(rename(serialize = "$istrusted"))] + IsTrusted, + #[serde(rename(serialize = "$MailFlagBit0"))] + MailFlagBit0, + #[serde(rename(serialize = "$MailFlagBit1"))] + MailFlagBit1, + #[serde(rename(serialize = "$MailFlagBit2"))] + MailFlagBit2, + #[serde(rename(serialize = "$maskedemail"))] + MaskedEmail, + #[serde(rename(serialize = "$memo"))] + Memo, + #[serde(rename(serialize = "$muted"))] + Muted, + #[serde(rename(serialize = "$new"))] + New, + #[serde(rename(serialize = "$notify"))] + Notify, + #[serde(rename(serialize = "$unsubscribed"))] + Unsubscribed, +} + +impl Keyword { + pub const MAX_LENGTH: usize = 128; + + pub fn parse(value: &str) -> Self { + Self::try_parse(value) + .unwrap_or_else(|| Keyword::Other(value.chars().take(Keyword::MAX_LENGTH).collect())) + } + + pub fn from_other(value: String) -> Self { + if value.len() <= Keyword::MAX_LENGTH { + Keyword::Other(value.into_boxed_str()) + } else { + Keyword::Other(value.chars().take(Keyword::MAX_LENGTH).collect()) + } + } + + pub fn from_boxed_other(value: Box) -> Self { + if value.len() <= Keyword::MAX_LENGTH { + Keyword::Other(value) + } else { + Keyword::Other(value.chars().take(Keyword::MAX_LENGTH).collect()) + } + } + + pub fn try_parse(value: &str) -> Option { + value + .split_at_checked(1) + .filter(|(prefix, _)| matches!(*prefix, "$" | "\\")) + .and_then(|(_, rest)| { + hashify::tiny_map_ignore_case!(rest.as_bytes(), + "seen" => Keyword::Seen, + "draft" => Keyword::Draft, + "flagged" => Keyword::Flagged, + "answered" => Keyword::Answered, + "recent" => Keyword::Recent, + "important" => Keyword::Important, + "phishing" => Keyword::Phishing, + "junk" => Keyword::Junk, + "notjunk" => Keyword::NotJunk, + "deleted" => Keyword::Deleted, + "forwarded" => Keyword::Forwarded, + "mdnsent" => Keyword::MdnSent, + "autosent" => Keyword::Autosent, + "canunsubscribe" => Keyword::CanUnsubscribe, + "followed" => Keyword::Followed, + "hasattachment" => Keyword::HasAttachment, + "hasmemo" => Keyword::HasMemo, + "hasnoattachment" => Keyword::HasNoAttachment, + "imported" => Keyword::Imported, + "istrusted" => Keyword::IsTrusted, + "mailflagbit0" => Keyword::MailFlagBit0, + "mailflagbit1" => Keyword::MailFlagBit1, + "mailflagbit2" => Keyword::MailFlagBit2, + "maskedemail" => Keyword::MaskedEmail, + "memo" => Keyword::Memo, + "muted" => Keyword::Muted, + "new" => Keyword::New, + "notify" => Keyword::Notify, + "unsubscribed" => Keyword::Unsubscribed, + ) + }) + } + + pub fn id(&self) -> Result { + match self { + Keyword::Seen => Ok(SEEN as u32), + Keyword::Draft => Ok(DRAFT as u32), + Keyword::Flagged => Ok(FLAGGED as u32), + Keyword::Answered => Ok(ANSWERED as u32), + Keyword::Recent => Ok(RECENT as u32), + Keyword::Important => Ok(IMPORTANT as u32), + Keyword::Phishing => Ok(PHISHING as u32), + Keyword::Junk => Ok(JUNK as u32), + Keyword::NotJunk => Ok(NOTJUNK as u32), + Keyword::Deleted => Ok(DELETED as u32), + Keyword::Forwarded => Ok(FORWARDED as u32), + Keyword::MdnSent => Ok(MDN_SENT as u32), + Keyword::Autosent => Ok(AUTOSENT as u32), + Keyword::CanUnsubscribe => Ok(CANUNSUBSCRIBE as u32), + Keyword::Followed => Ok(FOLLOWED as u32), + Keyword::HasAttachment => Ok(HASATTACHMENT as u32), + Keyword::HasMemo => Ok(HASMEMO as u32), + Keyword::HasNoAttachment => Ok(HASNOATTACHMENT as u32), + Keyword::Imported => Ok(IMPORTED as u32), + Keyword::IsTrusted => Ok(ISTRUSTED as u32), + Keyword::MailFlagBit0 => Ok(MAILFLAGBIT0 as u32), + Keyword::MailFlagBit1 => Ok(MAILFLAGBIT1 as u32), + Keyword::MailFlagBit2 => Ok(MAILFLAGBIT2 as u32), + Keyword::MaskedEmail => Ok(MASKEDEMAIL as u32), + Keyword::Memo => Ok(MEMO as u32), + Keyword::Muted => Ok(MUTED as u32), + Keyword::New => Ok(NEW as u32), + Keyword::Notify => Ok(NOTIFY as u32), + Keyword::Unsubscribed => Ok(UNSUBSCRIBED as u32), + Keyword::Other(string) => Err(string.as_ref()), + } + } + + pub fn into_id(self) -> Result> { + match self { + Keyword::Seen => Ok(SEEN as u32), + Keyword::Draft => Ok(DRAFT as u32), + Keyword::Flagged => Ok(FLAGGED as u32), + Keyword::Answered => Ok(ANSWERED as u32), + Keyword::Recent => Ok(RECENT as u32), + Keyword::Important => Ok(IMPORTANT as u32), + Keyword::Phishing => Ok(PHISHING as u32), + Keyword::Junk => Ok(JUNK as u32), + Keyword::NotJunk => Ok(NOTJUNK as u32), + Keyword::Deleted => Ok(DELETED as u32), + Keyword::Forwarded => Ok(FORWARDED as u32), + Keyword::MdnSent => Ok(MDN_SENT as u32), + Keyword::Autosent => Ok(AUTOSENT as u32), + Keyword::CanUnsubscribe => Ok(CANUNSUBSCRIBE as u32), + Keyword::Followed => Ok(FOLLOWED as u32), + Keyword::HasAttachment => Ok(HASATTACHMENT as u32), + Keyword::HasMemo => Ok(HASMEMO as u32), + Keyword::HasNoAttachment => Ok(HASNOATTACHMENT as u32), + Keyword::Imported => Ok(IMPORTED as u32), + Keyword::IsTrusted => Ok(ISTRUSTED as u32), + Keyword::MailFlagBit0 => Ok(MAILFLAGBIT0 as u32), + Keyword::MailFlagBit1 => Ok(MAILFLAGBIT1 as u32), + Keyword::MailFlagBit2 => Ok(MAILFLAGBIT2 as u32), + Keyword::MaskedEmail => Ok(MASKEDEMAIL as u32), + Keyword::Memo => Ok(MEMO as u32), + Keyword::Muted => Ok(MUTED as u32), + Keyword::New => Ok(NEW as u32), + Keyword::Notify => Ok(NOTIFY as u32), + Keyword::Unsubscribed => Ok(UNSUBSCRIBED as u32), + Keyword::Other(string) => Err(string), + } + } + + pub fn try_from_id(id: usize) -> Result { + match id { + SEEN => Ok(Keyword::Seen), + DRAFT => Ok(Keyword::Draft), + FLAGGED => Ok(Keyword::Flagged), + ANSWERED => Ok(Keyword::Answered), + RECENT => Ok(Keyword::Recent), + IMPORTANT => Ok(Keyword::Important), + PHISHING => Ok(Keyword::Phishing), + JUNK => Ok(Keyword::Junk), + NOTJUNK => Ok(Keyword::NotJunk), + DELETED => Ok(Keyword::Deleted), + FORWARDED => Ok(Keyword::Forwarded), + MDN_SENT => Ok(Keyword::MdnSent), + AUTOSENT => Ok(Keyword::Autosent), + CANUNSUBSCRIBE => Ok(Keyword::CanUnsubscribe), + FOLLOWED => Ok(Keyword::Followed), + HASATTACHMENT => Ok(Keyword::HasAttachment), + HASMEMO => Ok(Keyword::HasMemo), + HASNOATTACHMENT => Ok(Keyword::HasNoAttachment), + IMPORTED => Ok(Keyword::Imported), + ISTRUSTED => Ok(Keyword::IsTrusted), + MAILFLAGBIT0 => Ok(Keyword::MailFlagBit0), + MAILFLAGBIT1 => Ok(Keyword::MailFlagBit1), + MAILFLAGBIT2 => Ok(Keyword::MailFlagBit2), + MASKEDEMAIL => Ok(Keyword::MaskedEmail), + MEMO => Ok(Keyword::Memo), + MUTED => Ok(Keyword::Muted), + NEW => Ok(Keyword::New), + NOTIFY => Ok(Keyword::Notify), + UNSUBSCRIBED => Ok(Keyword::Unsubscribed), + _ => Err(id), + } + } +} + +impl From for Keyword { + fn from(value: String) -> Self { + Keyword::try_parse(&value).unwrap_or_else(|| Keyword::from_other(value)) + } +} + +impl Display for Keyword { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Keyword::Seen => write!(f, "$seen"), + Keyword::Draft => write!(f, "$draft"), + Keyword::Flagged => write!(f, "$flagged"), + Keyword::Answered => write!(f, "$answered"), + Keyword::Recent => write!(f, "$recent"), + Keyword::Important => write!(f, "$important"), + Keyword::Phishing => write!(f, "$phishing"), + Keyword::Junk => write!(f, "$junk"), + Keyword::NotJunk => write!(f, "$notjunk"), + Keyword::Deleted => write!(f, "$deleted"), + Keyword::Forwarded => write!(f, "$forwarded"), + Keyword::MdnSent => write!(f, "$mdnsent"), + Keyword::Autosent => write!(f, "$autosent"), + Keyword::CanUnsubscribe => write!(f, "$canunsubscribe"), + Keyword::Followed => write!(f, "$followed"), + Keyword::HasAttachment => write!(f, "$hasattachment"), + Keyword::HasMemo => write!(f, "$hasmemo"), + Keyword::HasNoAttachment => write!(f, "$hasnoattachment"), + Keyword::Imported => write!(f, "$imported"), + Keyword::IsTrusted => write!(f, "$istrusted"), + Keyword::MailFlagBit0 => write!(f, "$MailFlagBit0"), + Keyword::MailFlagBit1 => write!(f, "$MailFlagBit1"), + Keyword::MailFlagBit2 => write!(f, "$MailFlagBit2"), + Keyword::MaskedEmail => write!(f, "$maskedemail"), + Keyword::Memo => write!(f, "$memo"), + Keyword::Muted => write!(f, "$muted"), + Keyword::New => write!(f, "$new"), + Keyword::Notify => write!(f, "$notify"), + Keyword::Unsubscribed => write!(f, "$unsubscribed"), + Keyword::Other(s) => write!(f, "{}", s), + } + } +} + +impl Display for ArchivedKeyword { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArchivedKeyword::Seen => write!(f, "$seen"), + ArchivedKeyword::Draft => write!(f, "$draft"), + ArchivedKeyword::Flagged => write!(f, "$flagged"), + ArchivedKeyword::Answered => write!(f, "$answered"), + ArchivedKeyword::Recent => write!(f, "$recent"), + ArchivedKeyword::Important => write!(f, "$important"), + ArchivedKeyword::Phishing => write!(f, "$phishing"), + ArchivedKeyword::Junk => write!(f, "$junk"), + ArchivedKeyword::NotJunk => write!(f, "$notjunk"), + ArchivedKeyword::Deleted => write!(f, "$deleted"), + ArchivedKeyword::Forwarded => write!(f, "$forwarded"), + ArchivedKeyword::MdnSent => write!(f, "$mdnsent"), + ArchivedKeyword::Autosent => write!(f, "$autosent"), + ArchivedKeyword::CanUnsubscribe => write!(f, "$canunsubscribe"), + ArchivedKeyword::Followed => write!(f, "$followed"), + ArchivedKeyword::HasAttachment => write!(f, "$hasattachment"), + ArchivedKeyword::HasMemo => write!(f, "$hasmemo"), + ArchivedKeyword::HasNoAttachment => write!(f, "$hasnoattachment"), + ArchivedKeyword::Imported => write!(f, "$imported"), + ArchivedKeyword::IsTrusted => write!(f, "$istrusted"), + ArchivedKeyword::MailFlagBit0 => write!(f, "$MailFlagBit0"), + ArchivedKeyword::MailFlagBit1 => write!(f, "$MailFlagBit1"), + ArchivedKeyword::MailFlagBit2 => write!(f, "$MailFlagBit2"), + ArchivedKeyword::MaskedEmail => write!(f, "$maskedemail"), + ArchivedKeyword::Memo => write!(f, "$memo"), + ArchivedKeyword::Muted => write!(f, "$muted"), + ArchivedKeyword::New => write!(f, "$new"), + ArchivedKeyword::Notify => write!(f, "$notify"), + ArchivedKeyword::Unsubscribed => write!(f, "$unsubscribed"), + ArchivedKeyword::Other(s) => write!(f, "{}", s), + } + } +} + +impl From for Vec { + fn from(keyword: Keyword) -> Self { + match keyword { + Keyword::Seen => vec![SEEN as u8], + Keyword::Draft => vec![DRAFT as u8], + Keyword::Flagged => vec![FLAGGED as u8], + Keyword::Answered => vec![ANSWERED as u8], + Keyword::Recent => vec![RECENT as u8], + Keyword::Important => vec![IMPORTANT as u8], + Keyword::Phishing => vec![PHISHING as u8], + Keyword::Junk => vec![JUNK as u8], + Keyword::NotJunk => vec![NOTJUNK as u8], + Keyword::Deleted => vec![DELETED as u8], + Keyword::Forwarded => vec![FORWARDED as u8], + Keyword::MdnSent => vec![MDN_SENT as u8], + Keyword::Autosent => vec![AUTOSENT as u8], + Keyword::CanUnsubscribe => vec![CANUNSUBSCRIBE as u8], + Keyword::Followed => vec![FOLLOWED as u8], + Keyword::HasAttachment => vec![HASATTACHMENT as u8], + Keyword::HasMemo => vec![HASMEMO as u8], + Keyword::HasNoAttachment => vec![HASNOATTACHMENT as u8], + Keyword::Imported => vec![IMPORTED as u8], + Keyword::IsTrusted => vec![ISTRUSTED as u8], + Keyword::MailFlagBit0 => vec![MAILFLAGBIT0 as u8], + Keyword::MailFlagBit1 => vec![MAILFLAGBIT1 as u8], + Keyword::MailFlagBit2 => vec![MAILFLAGBIT2 as u8], + Keyword::MaskedEmail => vec![MASKEDEMAIL as u8], + Keyword::Memo => vec![MEMO as u8], + Keyword::Muted => vec![MUTED as u8], + Keyword::New => vec![NEW as u8], + Keyword::Notify => vec![NOTIFY as u8], + Keyword::Unsubscribed => vec![UNSUBSCRIBED as u8], + Keyword::Other(string) => string.as_bytes().to_vec(), + } + } +} + +impl FromStr for Keyword { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(Keyword::parse(s)) + } +} + +impl ArchivedKeyword { + pub fn id(&self) -> Result { + match self { + ArchivedKeyword::Seen => Ok(SEEN as u32), + ArchivedKeyword::Draft => Ok(DRAFT as u32), + ArchivedKeyword::Flagged => Ok(FLAGGED as u32), + ArchivedKeyword::Answered => Ok(ANSWERED as u32), + ArchivedKeyword::Recent => Ok(RECENT as u32), + ArchivedKeyword::Important => Ok(IMPORTANT as u32), + ArchivedKeyword::Phishing => Ok(PHISHING as u32), + ArchivedKeyword::Junk => Ok(JUNK as u32), + ArchivedKeyword::NotJunk => Ok(NOTJUNK as u32), + ArchivedKeyword::Deleted => Ok(DELETED as u32), + ArchivedKeyword::Forwarded => Ok(FORWARDED as u32), + ArchivedKeyword::MdnSent => Ok(MDN_SENT as u32), + ArchivedKeyword::Autosent => Ok(AUTOSENT as u32), + ArchivedKeyword::CanUnsubscribe => Ok(CANUNSUBSCRIBE as u32), + ArchivedKeyword::Followed => Ok(FOLLOWED as u32), + ArchivedKeyword::HasAttachment => Ok(HASATTACHMENT as u32), + ArchivedKeyword::HasMemo => Ok(HASMEMO as u32), + ArchivedKeyword::HasNoAttachment => Ok(HASNOATTACHMENT as u32), + ArchivedKeyword::Imported => Ok(IMPORTED as u32), + ArchivedKeyword::IsTrusted => Ok(ISTRUSTED as u32), + ArchivedKeyword::MailFlagBit0 => Ok(MAILFLAGBIT0 as u32), + ArchivedKeyword::MailFlagBit1 => Ok(MAILFLAGBIT1 as u32), + ArchivedKeyword::MailFlagBit2 => Ok(MAILFLAGBIT2 as u32), + ArchivedKeyword::MaskedEmail => Ok(MASKEDEMAIL as u32), + ArchivedKeyword::Memo => Ok(MEMO as u32), + ArchivedKeyword::Muted => Ok(MUTED as u32), + ArchivedKeyword::New => Ok(NEW as u32), + ArchivedKeyword::Notify => Ok(NOTIFY as u32), + ArchivedKeyword::Unsubscribed => Ok(UNSUBSCRIBED as u32), + ArchivedKeyword::Other(string) => Err(string.as_ref()), + } + } + + pub fn to_native(&self) -> Keyword { + match self { + ArchivedKeyword::Seen => Keyword::Seen, + ArchivedKeyword::Draft => Keyword::Draft, + ArchivedKeyword::Flagged => Keyword::Flagged, + ArchivedKeyword::Answered => Keyword::Answered, + ArchivedKeyword::Recent => Keyword::Recent, + ArchivedKeyword::Important => Keyword::Important, + ArchivedKeyword::Phishing => Keyword::Phishing, + ArchivedKeyword::Junk => Keyword::Junk, + ArchivedKeyword::NotJunk => Keyword::NotJunk, + ArchivedKeyword::Deleted => Keyword::Deleted, + ArchivedKeyword::Forwarded => Keyword::Forwarded, + ArchivedKeyword::MdnSent => Keyword::MdnSent, + ArchivedKeyword::Autosent => Keyword::Autosent, + ArchivedKeyword::CanUnsubscribe => Keyword::CanUnsubscribe, + ArchivedKeyword::Followed => Keyword::Followed, + ArchivedKeyword::HasAttachment => Keyword::HasAttachment, + ArchivedKeyword::HasMemo => Keyword::HasMemo, + ArchivedKeyword::HasNoAttachment => Keyword::HasNoAttachment, + ArchivedKeyword::Imported => Keyword::Imported, + ArchivedKeyword::IsTrusted => Keyword::IsTrusted, + ArchivedKeyword::MailFlagBit0 => Keyword::MailFlagBit0, + ArchivedKeyword::MailFlagBit1 => Keyword::MailFlagBit1, + ArchivedKeyword::MailFlagBit2 => Keyword::MailFlagBit2, + ArchivedKeyword::MaskedEmail => Keyword::MaskedEmail, + ArchivedKeyword::Memo => Keyword::Memo, + ArchivedKeyword::Muted => Keyword::Muted, + ArchivedKeyword::New => Keyword::New, + ArchivedKeyword::Notify => Keyword::Notify, + ArchivedKeyword::Unsubscribed => Keyword::Unsubscribed, + ArchivedKeyword::Other(other) => Keyword::Other(other.as_ref().into()), + } + } +} + +impl From<&ArchivedKeyword> for Keyword { + fn from(value: &ArchivedKeyword) -> Self { + match value { + ArchivedKeyword::Seen => Keyword::Seen, + ArchivedKeyword::Draft => Keyword::Draft, + ArchivedKeyword::Flagged => Keyword::Flagged, + ArchivedKeyword::Answered => Keyword::Answered, + ArchivedKeyword::Recent => Keyword::Recent, + ArchivedKeyword::Important => Keyword::Important, + ArchivedKeyword::Phishing => Keyword::Phishing, + ArchivedKeyword::Junk => Keyword::Junk, + ArchivedKeyword::NotJunk => Keyword::NotJunk, + ArchivedKeyword::Deleted => Keyword::Deleted, + ArchivedKeyword::Forwarded => Keyword::Forwarded, + ArchivedKeyword::MdnSent => Keyword::MdnSent, + ArchivedKeyword::Autosent => Keyword::Autosent, + ArchivedKeyword::CanUnsubscribe => Keyword::CanUnsubscribe, + ArchivedKeyword::Followed => Keyword::Followed, + ArchivedKeyword::HasAttachment => Keyword::HasAttachment, + ArchivedKeyword::HasMemo => Keyword::HasMemo, + ArchivedKeyword::HasNoAttachment => Keyword::HasNoAttachment, + ArchivedKeyword::Imported => Keyword::Imported, + ArchivedKeyword::IsTrusted => Keyword::IsTrusted, + ArchivedKeyword::MailFlagBit0 => Keyword::MailFlagBit0, + ArchivedKeyword::MailFlagBit1 => Keyword::MailFlagBit1, + ArchivedKeyword::MailFlagBit2 => Keyword::MailFlagBit2, + ArchivedKeyword::MaskedEmail => Keyword::MaskedEmail, + ArchivedKeyword::Memo => Keyword::Memo, + ArchivedKeyword::Muted => Keyword::Muted, + ArchivedKeyword::New => Keyword::New, + ArchivedKeyword::Notify => Keyword::Notify, + ArchivedKeyword::Unsubscribed => Keyword::Unsubscribed, + ArchivedKeyword::Other(string) => Keyword::Other(string.as_ref().into()), + } + } +} + +impl<'de> serde::Deserialize<'de> for Keyword { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(Keyword::parse( + >::deserialize(deserializer)?.as_ref(), + )) + } +} + +impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { + fn from(id: Keyword) -> Self { + Value::Element(E::from(id)) + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs new file mode 100644 index 0000000..422c41a --- /dev/null +++ b/crates/types/src/lib.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod blob; +pub mod blob_hash; +pub mod collection; +pub mod dead_property; +pub mod field; +pub mod id; +pub mod keyword; +pub mod semver; +pub mod special_use; +pub mod type_state; + +pub type DocumentId = u32; +pub type ChangeId = u64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "test_mode", derive(serde::Serialize, serde::Deserialize))] +pub struct TimeRange { + pub start: i64, + pub end: i64, +} + +impl TimeRange { + pub fn new(start: i64, end: i64) -> Self { + Self { start, end } + } + + pub fn is_in_range(&self, match_overlap: bool, start: i64, end: i64) -> bool { + if !match_overlap { + // RFC4791#9.9: (start < DTEND AND end > DTSTART) + self.start < end && self.end > start + } else { + // RFC4791#9.9: ((start < DUE) OR (start <= DTSTART)) AND ((end > DTSTART) OR (end >= DUE)) + ((start < self.end) || (start <= self.start)) && (end > self.start || end >= self.end) + } + } +} + +impl Default for TimeRange { + fn default() -> Self { + Self { + start: i64::MIN, + end: i64::MAX, + } + } +} diff --git a/crates/types/src/semver.rs b/crates/types/src/semver.rs new file mode 100644 index 0000000..e266f4a --- /dev/null +++ b/crates/types/src/semver.rs @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::Display; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct Semver(u64); + +impl Semver { + pub fn current() -> Self { + env!("CARGO_PKG_VERSION").try_into().unwrap() + } + + pub fn new(major: u16, minor: u16, patch: u16) -> Self { + let mut version: u64 = 0; + version |= (major as u64) << 32; + version |= (minor as u64) << 16; + version |= patch as u64; + Semver(version) + } + + pub fn unpack(&self) -> (u16, u16, u16) { + let version = self.0; + let major = ((version >> 32) & 0xFFFF) as u16; + let minor = ((version >> 16) & 0xFFFF) as u16; + let patch = (version & 0xFFFF) as u16; + (major, minor, patch) + } + + pub fn major(&self) -> u16 { + (self.0 >> 32) as u16 + } + + pub fn minor(&self) -> u16 { + (self.0 >> 16) as u16 + } + + pub fn patch(&self) -> u16 { + self.0 as u16 + } + + pub fn is_valid(&self) -> bool { + self.0 > 0 + } +} + +impl AsRef for Semver { + fn as_ref(&self) -> &u64 { + &self.0 + } +} + +impl From for Semver { + fn from(value: u64) -> Self { + Semver(value) + } +} + +impl TryFrom<&str> for Semver { + type Error = (); + + fn try_from(value: &str) -> Result { + let mut parts = value.splitn(3, '.'); + let major = parts.next().ok_or(())?.parse().map_err(|_| ())?; + let minor = parts.next().ok_or(())?.parse().map_err(|_| ())?; + let patch = parts.next().ok_or(())?.parse().map_err(|_| ())?; + Ok(Semver::new(major, minor, patch)) + } +} + +impl Display for Semver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (major, minor, patch) = self.unpack(); + write!(f, "{major}.{minor}.{patch}") + } +} diff --git a/crates/types/src/special_use.rs b/crates/types/src/special_use.rs new file mode 100644 index 0000000..4e9cda0 --- /dev/null +++ b/crates/types/src/special_use.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_tools::{Element, Property, Value}; + +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Debug, + PartialOrd, + Ord, +)] +#[rkyv(derive(Debug))] +pub enum SpecialUse { + Inbox, + Trash, + Junk, + Drafts, + Archive, + Sent, + Shared, + Important, + None, + Memos, + Scheduled, + Snoozed, +} + +impl SpecialUse { + pub fn parse(s: &str) -> Option { + hashify::tiny_map_ignore_case!(s.as_bytes(), + b"inbox" => SpecialUse::Inbox, + b"trash" => SpecialUse::Trash, + b"junk" => SpecialUse::Junk, + b"drafts" => SpecialUse::Drafts, + b"archive" => SpecialUse::Archive, + b"sent" => SpecialUse::Sent, + b"shared" => SpecialUse::Shared, + b"important" => SpecialUse::Important, + b"memos" => SpecialUse::Memos, + b"scheduled" => SpecialUse::Scheduled, + b"snoozed" => SpecialUse::Snoozed, + ) + } + + #[inline(always)] + pub fn parse_use_attr(s: &str) -> Option { + Self::parse(s.strip_prefix('\\').unwrap_or(s)) + } + + pub fn as_str(&self) -> Option<&'static str> { + match self { + SpecialUse::Inbox => Some("inbox"), + SpecialUse::Trash => Some("trash"), + SpecialUse::Junk => Some("junk"), + SpecialUse::Drafts => Some("drafts"), + SpecialUse::Archive => Some("archive"), + SpecialUse::Sent => Some("sent"), + SpecialUse::Shared => Some("shared"), + SpecialUse::Important => Some("important"), + SpecialUse::Memos => Some("memos"), + SpecialUse::Scheduled => Some("scheduled"), + SpecialUse::Snoozed => Some("snoozed"), + SpecialUse::None => None, + } + } +} + +impl ArchivedSpecialUse { + pub fn as_str(&self) -> Option<&'static str> { + match self { + ArchivedSpecialUse::Inbox => Some("inbox"), + ArchivedSpecialUse::Trash => Some("trash"), + ArchivedSpecialUse::Junk => Some("junk"), + ArchivedSpecialUse::Drafts => Some("drafts"), + ArchivedSpecialUse::Archive => Some("archive"), + ArchivedSpecialUse::Sent => Some("sent"), + ArchivedSpecialUse::Shared => Some("shared"), + ArchivedSpecialUse::Important => Some("important"), + ArchivedSpecialUse::Memos => Some("memos"), + ArchivedSpecialUse::Scheduled => Some("scheduled"), + ArchivedSpecialUse::Snoozed => Some("snoozed"), + ArchivedSpecialUse::None => None, + } + } +} + +impl From<&ArchivedSpecialUse> for SpecialUse { + fn from(value: &ArchivedSpecialUse) -> Self { + match value { + ArchivedSpecialUse::Inbox => SpecialUse::Inbox, + ArchivedSpecialUse::Trash => SpecialUse::Trash, + ArchivedSpecialUse::Junk => SpecialUse::Junk, + ArchivedSpecialUse::Drafts => SpecialUse::Drafts, + ArchivedSpecialUse::Archive => SpecialUse::Archive, + ArchivedSpecialUse::Sent => SpecialUse::Sent, + ArchivedSpecialUse::Shared => SpecialUse::Shared, + ArchivedSpecialUse::Important => SpecialUse::Important, + ArchivedSpecialUse::Memos => SpecialUse::Memos, + ArchivedSpecialUse::Scheduled => SpecialUse::Scheduled, + ArchivedSpecialUse::Snoozed => SpecialUse::Snoozed, + ArchivedSpecialUse::None => SpecialUse::None, + } + } +} + +impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { + fn from(id: SpecialUse) -> Self { + Value::Element(E::from(id)) + } +} diff --git a/crates/types/src/type_state.rs b/crates/types/src/type_state.rs new file mode 100644 index 0000000..b4f8b70 --- /dev/null +++ b/crates/types/src/type_state.rs @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::collection::SyncCollection; +use jmap_tools::{Element, Property, Value}; +use serde::Serialize; +use std::{fmt::Display, str::FromStr}; +use utils::map::bitmap::{Bitmap, BitmapItem}; + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, PartialOrd, Ord)] +#[repr(u8)] +pub enum DataType { + #[serde(rename = "Email")] + Email = 0, + #[serde(rename = "EmailDelivery")] + EmailDelivery = 1, + #[serde(rename = "EmailSubmission")] + EmailSubmission = 2, + #[serde(rename = "Mailbox")] + Mailbox = 3, + #[serde(rename = "Thread")] + Thread = 4, + #[serde(rename = "Identity")] + Identity = 5, + #[serde(rename = "Core")] + Core = 6, + #[serde(rename = "PushSubscription")] + PushSubscription = 7, + #[serde(rename = "SearchSnippet")] + SearchSnippet = 8, + #[serde(rename = "VacationResponse")] + VacationResponse = 9, + #[serde(rename = "MDN")] + Mdn = 10, + #[serde(rename = "Quota")] + Quota = 11, + #[serde(rename = "SieveScript")] + SieveScript = 12, + #[serde(rename = "Calendar")] + Calendar = 13, + #[serde(rename = "CalendarEvent")] + CalendarEvent = 14, + #[serde(rename = "CalendarEventNotification")] + CalendarEventNotification = 15, + #[serde(rename = "AddressBook")] + AddressBook = 16, + #[serde(rename = "ContactCard")] + ContactCard = 17, + #[serde(rename = "FileNode")] + FileNode = 18, + #[serde(rename = "Principal")] + Principal = 19, + #[serde(rename = "ShareNotification")] + ShareNotification = 20, + #[serde(rename = "ParticipantIdentity")] + ParticipantIdentity = 21, + #[serde(rename = "CalendarAlert")] + CalendarAlert = 22, + None = 23, +} + +#[derive(Debug, Clone, Copy)] +pub struct StateChange { + pub account_id: u32, + pub change_id: u64, + pub types: Bitmap, +} + +impl StateChange { + pub fn new(account_id: u32) -> Self { + Self { + account_id, + change_id: 0, + types: Default::default(), + } + } + + pub fn set_change(&mut self, type_state: DataType) { + self.types.insert(type_state); + } + + pub fn with_change(mut self, type_state: DataType) -> Self { + self.set_change(type_state); + self + } + + pub fn with_change_id(mut self, change_id: u64) -> Self { + self.change_id = change_id; + self + } + + pub fn has_changes(&self) -> bool { + !self.types.is_empty() + } +} + +impl BitmapItem for DataType { + fn max() -> u64 { + DataType::None as u64 + } + + fn is_valid(&self) -> bool { + !matches!(self, DataType::None) + } +} + +impl From for DataType { + fn from(value: u64) -> Self { + match value { + 0 => DataType::Email, + 1 => DataType::EmailDelivery, + 2 => DataType::EmailSubmission, + 3 => DataType::Mailbox, + 4 => DataType::Thread, + 5 => DataType::Identity, + 6 => DataType::Core, + 7 => DataType::PushSubscription, + 8 => DataType::SearchSnippet, + 9 => DataType::VacationResponse, + 10 => DataType::Mdn, + 11 => DataType::Quota, + 12 => DataType::SieveScript, + 13 => DataType::Calendar, + 14 => DataType::CalendarEvent, + 15 => DataType::CalendarEventNotification, + 16 => DataType::AddressBook, + 17 => DataType::ContactCard, + 18 => DataType::FileNode, + 19 => DataType::Principal, + 20 => DataType::ShareNotification, + 21 => DataType::ParticipantIdentity, + 22 => DataType::CalendarAlert, + _ => { + debug_assert!(false, "Invalid type_state value: {}", value); + DataType::None + } + } + } +} + +impl From for u64 { + fn from(type_state: DataType) -> u64 { + type_state as u64 + } +} + +impl DataType { + pub fn try_from_sync(value: SyncCollection, is_container: bool) -> Option { + match (value, is_container) { + (SyncCollection::Email, false) => DataType::Email.into(), + (SyncCollection::Email, true) => DataType::Mailbox.into(), + (SyncCollection::Thread, _) => DataType::Thread.into(), + (SyncCollection::Calendar, true) => DataType::Calendar.into(), + (SyncCollection::Calendar, false) => DataType::CalendarEvent.into(), + (SyncCollection::AddressBook, true) => DataType::AddressBook.into(), + (SyncCollection::AddressBook, false) => DataType::ContactCard.into(), + (SyncCollection::FileNode, _) => DataType::FileNode.into(), + (SyncCollection::Identity, _) => DataType::Identity.into(), + (SyncCollection::EmailSubmission, _) => DataType::EmailSubmission.into(), + (SyncCollection::SieveScript, _) => DataType::SieveScript.into(), + _ => None, + } + } +} + +impl DataType { + pub fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"Email" => DataType::Email, + b"EmailDelivery" => DataType::EmailDelivery, + b"EmailSubmission" => DataType::EmailSubmission, + b"Mailbox" => DataType::Mailbox, + b"Thread" => DataType::Thread, + b"Identity" => DataType::Identity, + b"Core" => DataType::Core, + b"PushSubscription" => DataType::PushSubscription, + b"SearchSnippet" => DataType::SearchSnippet, + b"VacationResponse" => DataType::VacationResponse, + b"MDN" => DataType::Mdn, + b"Quota" => DataType::Quota, + b"SieveScript" => DataType::SieveScript, + b"Calendar" => DataType::Calendar, + b"CalendarEvent" => DataType::CalendarEvent, + b"CalendarEventNotification" => DataType::CalendarEventNotification, + b"AddressBook" => DataType::AddressBook, + b"ContactCard" => DataType::ContactCard, + b"FileNode" => DataType::FileNode, + b"Principal" => DataType::Principal, + b"ShareNotification" => DataType::ShareNotification, + b"ParticipantIdentity" => DataType::ParticipantIdentity, + b"CalendarAlert" => DataType::CalendarAlert, + ) + } + + pub fn as_str(&self) -> &'static str { + match self { + DataType::Email => "Email", + DataType::EmailDelivery => "EmailDelivery", + DataType::EmailSubmission => "EmailSubmission", + DataType::Mailbox => "Mailbox", + DataType::Thread => "Thread", + DataType::Identity => "Identity", + DataType::Core => "Core", + DataType::PushSubscription => "PushSubscription", + DataType::SearchSnippet => "SearchSnippet", + DataType::VacationResponse => "VacationResponse", + DataType::Mdn => "MDN", + DataType::Quota => "Quota", + DataType::SieveScript => "SieveScript", + DataType::Calendar => "Calendar", + DataType::CalendarEvent => "CalendarEvent", + DataType::CalendarEventNotification => "CalendarEventNotification", + DataType::AddressBook => "AddressBook", + DataType::ContactCard => "ContactCard", + DataType::FileNode => "FileNode", + DataType::Principal => "Principal", + DataType::ShareNotification => "ShareNotification", + DataType::ParticipantIdentity => "ParticipantIdentity", + DataType::CalendarAlert => "CalendarAlert", + DataType::None => "", + } + } +} + +impl FromStr for DataType { + type Err = (); + + fn from_str(s: &str) -> Result { + DataType::parse(s).ok_or(()) + } +} + +impl Display for DataType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DataType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + DataType::parse(<&str>::deserialize(deserializer)?) + .ok_or_else(|| serde::de::Error::custom("invalid JMAP data type")) + } +} + +impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { + fn from(id: DataType) -> Self { + Value::Element(E::from(id)) + } +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml new file mode 100644 index 0000000..2d4d1e0 --- /dev/null +++ b/crates/utils/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "utils" +version = "0.16.22" +edition = "2024" + +[dependencies] +trc = { path = "../trc" } +rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +rustls-pki-types = { version = "1" } +rustls-platform-verifier = "0.7" +tokio = { version = "1.53", features = ["net", "macros", "signal"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +serde = { version = "1.0", features = ["derive"]} +mail-auth = { version = "0.13" } +ahash = { version = "0.8", features = ["serde"] } +chrono = "0.4" +aws-lc-rs = { version = "1" } +base64 = "0.23" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "stream"]} +futures = "0.3" +form_urlencoded = "1.2.2" +psl = "2" +idna = "1.1" +quick_cache = "0.7" +rkyv = { version = "0.8.18", features = ["little_endian"] } +compact_str = "0.10.0" +xxhash-rust = { version = "0.8.18", features = ["xxh3"] } +farmhash = "1.1.5" +nohash-hasher = "0.2.0" +arcstr = "1.2.0" + +[target.'cfg(unix)'.dependencies] +privdrop = "0.5.6" + +[features] +test_mode = [] + +[dev-dependencies] +tokio = { version = "1.53", features = ["full"] } + +[lints] +workspace = true diff --git a/crates/utils/proc-macros/Cargo.toml b/crates/utils/proc-macros/Cargo.toml new file mode 100644 index 0000000..56df256 --- /dev/null +++ b/crates/utils/proc-macros/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "proc_macros" +version = "0.16.22" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "3.0", features = ["full"] } +quote = "1.0" diff --git a/crates/utils/proc-macros/src/lib.rs b/crates/utils/proc-macros/src/lib.rs new file mode 100644 index 0000000..64ea8f1 --- /dev/null +++ b/crates/utils/proc-macros/src/lib.rs @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, parse_macro_input}; + +#[proc_macro_derive(EnumMethods)] +pub fn enum_id(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + let variants = match input.data { + Data::Enum(ref data) => &data.variants, + _ => panic!("EnumMethods only works on enums"), + }; + + let variant_count = variants.len(); + let variant_names: Vec<_> = variants.iter().map(|v| &v.ident).collect(); + let variant_ids: Vec = (0..(variant_count as u32)).collect(); + let snake_case_names: Vec = variant_names + .iter() + .map(|name| to_snake_case(&name.to_string())) + .collect(); + + let expanded = quote! { + impl #name { + pub const COUNT: usize = #variant_count; + + pub const fn id(&self) -> u32 { + match self { + #(#name::#variant_names => #variant_ids,)* + } + } + + pub fn from_id(id: u32) -> Option { + match id { + #(#variant_ids => Some(#name::#variant_names),)* + _ => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + #(#name::#variant_names => #snake_case_names,)* + } + } + + pub fn from_name(name: &str) -> Option { + match name { + #(#snake_case_names => Some(#name::#variant_names),)* + _ => None, + } + } + } + }; + + TokenStream::from(expanded) +} + +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, ch) in s.char_indices() { + if ch.is_uppercase() { + if i > 0 { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } + result +} diff --git a/crates/utils/src/bimap.rs b/crates/utils/src/bimap.rs new file mode 100644 index 0000000..54bbe24 --- /dev/null +++ b/crates/utils/src/bimap.rs @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Borrow, hash::Hash, rc::Rc}; + +use ahash::AHashMap; + +#[derive(Debug)] +#[repr(transparent)] +struct StringRef(Rc); + +#[derive(Debug)] +#[repr(transparent)] +struct IdRef(Rc); + +#[derive(Debug, Default)] +pub struct IdBimap { + id_to_name: AHashMap, Rc>, + name_to_id: AHashMap, Rc>, +} + +impl IdBimap { + pub fn with_capacity(capacity: usize) -> Self { + Self { + id_to_name: AHashMap::with_capacity(capacity), + name_to_id: AHashMap::with_capacity(capacity), + } + } + + pub fn insert(&mut self, item: T) { + let item = Rc::new(item); + self.id_to_name.insert(IdRef(item.clone()), item.clone()); + self.name_to_id.insert(StringRef(item.clone()), item); + } + + pub fn by_name(&self, name: &str) -> Option<&T> { + self.name_to_id.get(name).map(|v| v.as_ref()) + } + + pub fn by_id(&self, id: u32) -> Option<&T> { + self.id_to_name.get(&id).map(|v| v.as_ref()) + } + + pub fn iter(&self) -> impl Iterator { + self.name_to_id.values().map(|v| v.as_ref()) + } + + pub fn is_empty(&self) -> bool { + self.name_to_id.is_empty() + } +} + +// SAFETY: Safe because Rc<> are never returned from the struct +unsafe impl Send for IdBimap {} +unsafe impl Sync for IdBimap {} + +pub trait IdBimapItem: std::fmt::Debug { + fn id(&self) -> &u32; + fn name(&self) -> &str; +} + +impl Borrow for StringRef { + fn borrow(&self) -> &str { + self.0.name() + } +} + +impl Borrow for IdRef { + fn borrow(&self) -> &u32 { + self.0.id() + } +} + +impl PartialEq for StringRef { + fn eq(&self, other: &Self) -> bool { + self.0.name() == other.0.name() + } +} + +impl Eq for StringRef {} + +impl PartialEq for IdRef { + fn eq(&self, other: &Self) -> bool { + self.0.id() == other.0.id() + } +} + +impl Eq for IdRef {} + +impl Hash for StringRef { + fn hash(&self, state: &mut H) { + self.0.name().hash(state) + } +} + +impl Hash for IdRef { + fn hash(&self, state: &mut H) { + self.0.id().hash(state) + } +} diff --git a/crates/utils/src/cache.rs b/crates/utils/src/cache.rs new file mode 100644 index 0000000..d4a7cca --- /dev/null +++ b/crates/utils/src/cache.rs @@ -0,0 +1,420 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use arcstr::ArcStr; +use mail_auth::{DnssecStatus, MX, RecordSet, ResolverCache, Txt}; +use quick_cache::{ + Equivalent, Options, OptionsBuilder, Weighter, + sync::{DefaultLifecycle, PlaceholderGuard}, +}; +use std::{ + borrow::Borrow, + hash::Hash, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, + time::{Duration, Instant}, +}; + +pub struct Cache( + quick_cache::sync::Cache, +); + +pub struct CacheWithTtl( + quick_cache::sync::Cache, CacheItemWeighter, ahash::RandomState>, +); + +#[derive(Clone)] +pub struct TtlEntry { + value: V, + expires: Instant, +} + +impl Cache { + pub fn new(weight: u64, estimated_weight: u64) -> Self { + Self::new_estimated(weight as usize / estimated_weight as usize, weight) + } + + pub fn new_estimated(estimated_items_capacity: usize, weight_capacity: u64) -> Self { + Self(quick_cache::sync::Cache::with_options( + cache_options(estimated_items_capacity, weight_capacity, None), + CacheItemWeighter, + ahash::RandomState::default(), + DefaultLifecycle::default(), + )) + } + + pub fn new_single_shard(weight: u64, estimated_weight: u64) -> Self { + Self(quick_cache::sync::Cache::with_options( + cache_options(weight as usize / estimated_weight as usize, weight, Some(1)), + CacheItemWeighter, + ahash::RandomState::default(), + DefaultLifecycle::default(), + )) + } + + #[inline(always)] + pub fn get(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.0.get(key) + } + + #[inline(always)] + pub fn peek(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.0.peek(key) + } + + #[inline(always)] + pub async fn get_value_or_guard_async<'a, Q>( + &'a self, + key: &Q, + ) -> Result< + V, + PlaceholderGuard<'a, K, V, CacheItemWeighter, ahash::RandomState, DefaultLifecycle>, + > + where + Q: Hash + Equivalent + ToOwned + ?Sized, + { + self.0.get_value_or_guard_async(key).await + } + + #[inline(always)] + pub fn insert(&self, key: K, value: V) { + self.0.insert(key, value); + } + + #[inline(always)] + pub fn update(&self, key: K, value: V) { + if let Err((key, value)) = self.0.replace(key, value, true) { + self.0.insert(key, value); + } + } + + #[inline(always)] + pub fn remove(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.0.remove(key).map(|(_, v)| v) + } + + #[inline(always)] + pub fn clear(&self) { + self.0.clear(); + } + + #[inline(always)] + pub fn inner(&self) -> &quick_cache::sync::Cache { + &self.0 + } + + #[inline(always)] + pub fn weight_capacity(&self) -> u64 { + self.0.capacity() + } +} + +impl CacheWithTtl { + pub fn new(weight: u64, estimated_weight: u64) -> Self { + Self::new_estimated(weight as usize / estimated_weight as usize, weight) + } + + pub fn new_estimated(estimated_items_capacity: usize, weight_capacity: u64) -> Self { + Self(quick_cache::sync::Cache::with_options( + cache_options(estimated_items_capacity, weight_capacity, None), + CacheItemWeighter, + ahash::RandomState::default(), + DefaultLifecycle::default(), + )) + } + + #[inline(always)] + pub fn get(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.0.get(key).and_then(|v| { + if v.expires > Instant::now() { + Some(v.value) + } else { + self.0.remove(key); + None + } + }) + } + + #[inline(always)] + pub async fn get_value_or_guard_async<'a, Q>( + &'a self, + key: &Q, + ) -> Result< + V, + PlaceholderGuard< + 'a, + K, + TtlEntry, + CacheItemWeighter, + ahash::RandomState, + DefaultLifecycle>, + >, + > + where + Q: Hash + Equivalent + ToOwned + ?Sized, + { + match self.0.get_value_or_guard_async(key).await { + Ok(value) => { + if value.expires > Instant::now() { + Ok(value.value) + } else { + self.0.remove(key); + self.0.get_value_or_guard_async(key).await.map(|v| v.value) + } + } + Err(err) => Err(err), + } + } + + #[inline(always)] + pub fn insert(&self, key: K, value: V, expires: Duration) { + self.0.insert(key, TtlEntry::new(value, expires)); + } + + #[inline(always)] + pub fn insert_with_expiry(&self, key: K, value: V, expires: Instant) { + self.0.insert(key, TtlEntry::with_expiry(value, expires)); + } + + #[inline(always)] + pub fn remove(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.0.remove(key).map(|(_, v)| v.value) + } + + #[inline(always)] + pub fn retain(&self, f: impl Fn(&K) -> bool) { + self.0.retain(|key, _| f(key)); + } + + #[inline(always)] + pub fn clear(&self) { + self.0.clear(); + } +} + +fn cache_options( + estimated_items_capacity: usize, + weight_capacity: u64, + shards: Option, +) -> Options { + let mut builder = OptionsBuilder::new(); + builder + .estimated_items_capacity(estimated_items_capacity.max(1)) + .weight_capacity(weight_capacity); + if let Some(shards) = shards { + builder.shards(shards.max(1)); + } + builder.build().unwrap() +} + +#[derive(Clone)] +pub struct CacheItemWeighter; + +impl Weighter for CacheItemWeighter { + fn weight(&self, key: &K, val: &V) -> u64 { + key.weight() + val.weight() + } +} + +pub trait CacheItemWeight { + fn weight(&self) -> u64; +} + +impl CacheItemWeight for TtlEntry { + fn weight(&self) -> u64 { + self.value.weight() + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Option { + fn weight(&self) -> u64 { + match self { + Some(v) => v.weight(), + None => std::mem::size_of::() as u64, + } + } +} + +impl CacheItemWeight for Arc { + fn weight(&self) -> u64 { + self.as_ref().weight() + } +} + +impl CacheItemWeight for u64 { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for String { + fn weight(&self) -> u64 { + self.len() as u64 + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Box { + fn weight(&self) -> u64 { + self.len() as u64 + std::mem::size_of::>() as u64 + } +} + +impl CacheItemWeight for Box<[T]> { + fn weight(&self) -> u64 { + std::mem::size_of::>() as u64 + self.iter().map(|item| item.weight()).sum::() + } +} + +impl CacheItemWeight for Arc<[T]> { + fn weight(&self) -> u64 { + std::mem::size_of::>() as u64 + self.iter().map(|item| item.weight()).sum::() + } +} + +impl CacheItemWeight for RecordSet { + fn weight(&self) -> u64 { + self.rrset.weight() + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for u32 { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for IpAddr { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Ipv4Addr { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Ipv6Addr { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for MX { + fn weight(&self) -> u64 { + self.exchanges + .iter() + .map(|e| e.len() as u64 + std::mem::size_of::>() as u64) + .sum::() + + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for Txt { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for bool { + fn weight(&self) -> u64 { + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for ArcStr { + fn weight(&self) -> u64 { + self.len() as u64 + std::mem::size_of::() as u64 + } +} + +impl CacheItemWeight for () { + fn weight(&self) -> u64 { + 0 + } +} + +impl TtlEntry { + pub fn new(value: T, expires: Duration) -> Self { + Self { + value, + expires: Instant::now() + expires, + } + } + + pub fn with_expiry(value: T, expires: Instant) -> Self { + Self { value, expires } + } +} + +impl ResolverCache + for CacheWithTtl +{ + fn get(&self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + CacheWithTtl::get(self, key) + } + + fn remove(&self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + CacheWithTtl::remove(self, key) + } + + fn insert(&self, key: K, value: V, expires: Instant) { + self.0.insert(key, TtlEntry::with_expiry(value, expires)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_shard_retains_large_entry() { + let capacity = 10_000_000u64; + let cache = Cache::::new_single_shard(capacity, 1000); + assert_eq!(cache.inner().num_shards(), 1); + + let value = "x".repeat(9_000_000); + cache.insert(0, value.clone()); + assert_eq!(cache.get(&0), Some(value)); + } + + #[test] + fn sharded_cache_drops_entry_larger_than_a_shard() { + let capacity = 10_000_000u64; + let cache = Cache::::new_estimated(10_000, capacity); + + let value = "x".repeat((capacity / 2) as usize); + cache.insert(0, value); + + if cache.inner().num_shards() > 1 { + assert_eq!(cache.get(&0), None); + } + } +} diff --git a/crates/utils/src/chained_bytes.rs b/crates/utils/src/chained_bytes.rs new file mode 100644 index 0000000..0942f49 --- /dev/null +++ b/crates/utils/src/chained_bytes.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, ops::Range}; + +#[derive(Debug, Clone)] +pub struct ChainedBytes<'x> { + first: &'x [u8], + last: &'x [u8], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SliceRange<'x> { + Single(&'x [u8]), + Split(&'x [u8], &'x [u8]), + None, +} + +impl<'x> ChainedBytes<'x> { + pub fn new(first: &'x [u8]) -> Self { + Self { first, last: &[] } + } + + pub fn append(&mut self, bytes: &'x [u8]) { + self.last = bytes; + } + + pub fn with_last(mut self, bytes: &'x [u8]) -> Self { + self.last = bytes; + self + } + + pub fn get(&self, index: Range) -> Option> { + let start = index.start; + let end = index.end; + + if let Some(bytes) = self.first.get(start..end) { + Some(Cow::Borrowed(bytes)) + } else if start >= self.first.len() { + self.last + .get(start - self.first.len()..end - self.first.len()) + .map(Cow::Borrowed) + } else if let (Some(first), Some(last)) = ( + self.first.get(start..), + self.last.get(..end - self.first.len()), + ) { + let mut vec = vec![0u8; first.len() + last.len()]; + vec[..first.len()].copy_from_slice(first); + vec[first.len()..].copy_from_slice(last); + Some(Cow::Owned(vec)) + } else { + None + } + } + + pub fn get_slice_range(&self, index: Range) -> SliceRange<'x> { + let start = index.start; + let end = index.end; + + if let Some(bytes) = self.first.get(start..end) { + SliceRange::Single(bytes) + } else if start >= self.first.len() { + self.last + .get(start - self.first.len()..end - self.first.len()) + .map(SliceRange::Single) + .unwrap_or(SliceRange::None) + } else if let (Some(first), Some(last)) = ( + self.first.get(start..), + self.last.get(..end - self.first.len()), + ) { + SliceRange::Split(first, last) + } else { + SliceRange::None + } + } + + pub fn get_full_range(&self) -> SliceRange<'x> { + if self.last.is_empty() { + SliceRange::Single(self.first) + } else { + SliceRange::Split(self.first, self.last) + } + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = vec![0u8; self.first.len() + self.last.len()]; + bytes[..self.first.len()].copy_from_slice(self.first); + bytes[self.first.len()..].copy_from_slice(self.last); + bytes + } + + pub fn len(&self) -> usize { + self.first.len() + self.last.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl<'x> SliceRange<'x> { + pub fn len(&self) -> usize { + match self { + SliceRange::Single(bytes) => bytes.len(), + SliceRange::Split(first, last) => first.len() + last.len(), + SliceRange::None => 0, + } + } + + pub fn try_into_bytes(self) -> Option> { + match self { + SliceRange::Single(bytes) => Some(Cow::Borrowed(bytes)), + SliceRange::Split(first, last) => { + let mut vec = vec![0u8; first.len() + last.len()]; + vec[..first.len()].copy_from_slice(first); + vec[first.len()..].copy_from_slice(last); + Some(Cow::Owned(vec)) + } + SliceRange::None => None, + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn into_pairs(self) -> (&'x [u8], &'x [u8]) { + match self { + SliceRange::Single(bytes) => (bytes, &[][..]), + SliceRange::Split(first, last) => (first, last), + SliceRange::None => (&[][..], &[][..]), + } + } + + pub fn is_none(&self) -> bool { + matches!(self, SliceRange::None) + } + + pub fn is_some(&self) -> bool { + !self.is_none() + } +} + +impl<'x> IntoIterator for SliceRange<'x> { + type Item = &'x u8; + type IntoIter = std::iter::Chain, std::slice::Iter<'x, u8>>; + + fn into_iter(self) -> Self::IntoIter { + let (first, last) = self.into_pairs(); + + first.iter().chain(last.iter()) + } +} diff --git a/crates/utils/src/cheeky_hash.rs b/crates/utils/src/cheeky_hash.rs new file mode 100644 index 0000000..bac9611 --- /dev/null +++ b/crates/utils/src/cheeky_hash.rs @@ -0,0 +1,319 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use nohash_hasher::IsEnabled; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fmt::Debug, + hash::Hash, + str::FromStr, +}; + +// A hash that can cheekily store small inputs directly without hashing them. +#[derive( + Copy, Clone, PartialEq, Eq, PartialOrd, Ord, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, +)] +#[repr(transparent)] +pub struct CheekyHash([u8; HASH_SIZE]); + +const HASH_SIZE: usize = std::mem::size_of::() * 2; +const HASH_PAYLOAD: usize = HASH_SIZE - 1; + +pub type CheekyHashSet = HashSet>; +pub type CheekyHashMap = HashMap>; +pub type CheekyBTreeMap = BTreeMap; + +impl CheekyHash { + pub const HASH_SIZE: usize = HASH_SIZE; + pub const NULL: CheekyHash = CheekyHash([0u8; HASH_SIZE]); + pub const FULL: CheekyHash = CheekyHash([u8::MAX; HASH_SIZE]); + + pub fn new(bytes: impl AsRef<[u8]>) -> Self { + let mut hash = [0u8; HASH_SIZE]; + let bytes = bytes.as_ref(); + + if bytes.len() <= HASH_PAYLOAD { + hash[0] = bytes.len() as u8; + hash[1..1 + bytes.len()].copy_from_slice(bytes); + } else { + let h1 = xxhash_rust::xxh3::xxh3_64(bytes).to_be_bytes(); + let h2 = farmhash::fingerprint64(bytes).to_be_bytes(); + hash[0] = bytes.len().min(u8::MAX as usize) as u8; + hash[1..1 + std::mem::size_of::()].copy_from_slice(&h1); + hash[1 + std::mem::size_of::()..] + .copy_from_slice(&h2[..std::mem::size_of::() - 1]); + } + + CheekyHash(hash) + } + + pub fn deserialize(bytes: &[u8]) -> Option { + let len = *bytes.first()?; + let mut hash = [0u8; HASH_SIZE]; + let hash_len = 1 + (len as usize).min(HASH_PAYLOAD); + + hash[0] = len; + hash[1..hash_len].copy_from_slice(bytes.get(1..hash_len)?); + Some(CheekyHash(hash)) + } + + #[allow(clippy::len_without_is_empty)] + #[inline(always)] + pub fn len(&self) -> usize { + (self.0[0] as usize).min(HASH_PAYLOAD) + 1 + } + + #[inline(always)] + pub fn as_bytes(&self) -> &[u8] { + &self.0[..self.len()] + } + + #[inline(always)] + pub fn as_raw_bytes(&self) -> &[u8; HASH_SIZE] { + &self.0 + } + + pub fn into_inner(self) -> [u8; HASH_SIZE] { + self.0 + } + + pub fn payload(&self) -> &[u8] { + let len = self.0[0] as usize; + if len <= HASH_PAYLOAD { + &self.0[1..1 + len] + } else { + &self.0[1..] + } + } + + pub fn payload_len(&self) -> u8 { + self.0[0] + } + + fn as_u128(&self) -> u128 { + u128::from_be_bytes(self.0) + } +} + +impl AsRef<[u8]> for CheekyHash { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +impl FromStr for CheekyHash { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { + u128::from_str_radix(s, 16).map(|n| CheekyHash(n.to_be_bytes())) + } +} + +impl std::fmt::Display for CheekyHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:032x}", self.as_u128()) + } +} + +impl Hash for CheekyHash { + fn hash(&self, state: &mut H) { + let len = self.0[0] as usize; + if len <= HASH_PAYLOAD { + state.write_u64(xxhash_rust::xxh3::xxh3_64(&self.0[1..1 + len])); + } else { + state.write_u64(u64::from_be_bytes( + self.0[1..1 + std::mem::size_of::()] + .try_into() + .unwrap(), + )); + } + } +} + +impl IsEnabled for CheekyHash {} + +impl ArchivedCheekyHash { + #[inline(always)] + pub fn as_raw_bytes(&self) -> &[u8; HASH_SIZE] { + &self.0 + } + + #[inline(always)] + pub fn as_bytes(&self) -> &[u8] { + let len = self.0[0] as usize; + &self.0[..1 + len.min(HASH_PAYLOAD)] + } + + #[inline(always)] + pub fn to_native(&self) -> CheekyHash { + CheekyHash(self.0) + } +} + +impl Debug for CheekyHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let len = self.payload_len(); + let payload = self.payload(); + let payload_str = if len <= HASH_PAYLOAD as u8 { + std::str::from_utf8(payload).unwrap_or("") + } else { + "" + }; + + f.debug_struct("CheekyHash") + .field("length", &len) + .field("bytes", &payload_str) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cheeky_hash_all() { + // Test 1: Empty input + let hash_empty = CheekyHash::new([]); + assert_eq!( + hash_empty.as_bytes()[0], + 0, + "Empty input should have length 0" + ); + assert_eq!( + hash_empty.as_bytes().len(), + 1, + "Empty input should only have length byte" + ); + + // Test 2: Single byte input + let hash_single = CheekyHash::new([42]); + assert_eq!( + hash_single.as_bytes()[0], + 1, + "Single byte should have length 1" + ); + assert_eq!( + hash_single.as_bytes()[1], + 42, + "Single byte value should be preserved" + ); + assert_eq!(hash_single.as_bytes().len(), 2); + + // Test 3: Small input (less than HASH_LEN) + let small_data = b"hello"; + let hash_small = CheekyHash::new(small_data); + assert_eq!(hash_small.as_bytes()[0], 5, "Length should be 5"); + assert_eq!( + &hash_small.as_bytes()[1..6], + small_data, + "Small data should be stored directly" + ); + assert_eq!(hash_small.as_bytes().len(), 6); + + // Test 4: Input exactly at HASH_PAYLOAD boundary + let boundary_data = vec![1u8; HASH_PAYLOAD - 1]; + let hash_boundary = CheekyHash::new(&boundary_data); + assert_eq!( + hash_boundary.as_bytes()[0], + (HASH_PAYLOAD - 1) as u8, + "Length should be HASH_LEN" + ); + assert_eq!( + &hash_boundary.as_bytes()[1..], + &boundary_data[..], + "Boundary data should be stored directly" + ); + + // Test 5: Large input (greater than HASH_LEN) - uses hashing + let large_data = vec![7u8; HASH_SIZE]; + let hash_large = CheekyHash::new(&large_data); + assert_eq!( + hash_large.as_bytes()[0], + HASH_SIZE as u8, + "Large data should have length byte set to HASH_LEN" + ); + assert_eq!( + hash_large.as_bytes().len(), + HASH_SIZE, + "Large data hash should be full length" + ); + // Verify it's actually hashed (not raw data) + assert_ne!( + &hash_large.as_bytes()[1..], + &large_data[..HASH_PAYLOAD], + "Large data should be hashed, not stored directly" + ); + + // Test 6: AsRef<[u8]> trait + let hash = CheekyHash::new(b"test"); + let bytes_ref: &[u8] = hash.as_ref(); + assert_eq!(bytes_ref, hash.as_bytes(), "AsRef should match as_bytes"); + + // Test 7: Copy, Clone, PartialEq traits + let hash1 = CheekyHash::new(b"identical"); + let hash2 = hash1; // Copy + assert_eq!(hash1, hash2, "Copied hashes should be equal"); + + // Test 8: Different inputs produce different hashes + let hash_a = CheekyHash::new(b"abc"); + let hash_b = CheekyHash::new(b"def"); + assert_ne!( + hash_a, hash_b, + "Different inputs should produce different hashes" + ); + + // Test 9: Same input produces same hash (deterministic) + let hash_x1 = CheekyHash::new(b"deterministic"); + let hash_x2 = CheekyHash::new(b"deterministic"); + assert_eq!( + hash_x1, hash_x2, + "Same input should produce identical hashes" + ); + + // Test 10: Large inputs with different content produce different hashes + let large1 = vec![1u8; 100]; + let large2 = vec![2u8; 100]; + let hash_large1 = CheekyHash::new(&large1); + let hash_large2 = CheekyHash::new(&large2); + assert_ne!( + hash_large1, hash_large2, + "Different large inputs should produce different hashes" + ); + + // Test 11: Hash trait (can be used in HashMap/HashSet) + use std::collections::HashMap; + let mut map = HashMap::new(); + let key = CheekyHash::new(b"key"); + map.insert(key, "value"); + assert_eq!( + map.get(&key), + Some(&"value"), + "CheekyHash should work as HashMap key" + ); + + // Test 12: Debug trait + let hash = CheekyHash::new(b"debug"); + let debug_str = format!("{:?}", hash); + assert!( + debug_str.contains("CheekyHash"), + "Debug output should contain type name" + ); + + // Test 13: CheekyHashSet and CheekyHashMap + let mut cheeky_set: CheekyHashSet = CheekyHashSet::default(); + cheeky_set.insert(CheekyHash::new(b"set_item")); + assert!(cheeky_set.contains(&CheekyHash::new(b"set_item"))); + let mut cheeky_map: CheekyHashMap<&str> = CheekyHashMap::default(); + cheeky_map.insert(CheekyHash::new(b"map_key"), "map_value"); + assert_eq!( + cheeky_map.get(&CheekyHash::new(b"map_key")), + Some(&"map_value") + ); + + println!("All CheekyHash tests passed!"); + } +} diff --git a/crates/utils/src/codec/base32_custom.rs b/crates/utils/src/codec/base32_custom.rs new file mode 100644 index 0000000..ce19d36 --- /dev/null +++ b/crates/utils/src/codec/base32_custom.rs @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::leb128::{Leb128Iterator, Leb128Writer}; +use std::{io::Write, slice::Iter}; + +pub static BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz792013"; +pub static BASE32_INVERSE: [u8; 256] = [ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 29, 30, 28, 31, 255, 255, 255, 26, 255, 27, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 10, 11, + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 0, 1, 2, + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, +]; + +pub struct Base32Writer { + last_byte: u8, + pos: usize, + result: String, +} + +impl Base32Writer { + pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Self { + let bytes = bytes.as_ref(); + let mut writer = Base32Writer::with_capacity(bytes.len()); + writer.write_all(bytes).unwrap(); + writer + } + + pub fn with_capacity(capacity: usize) -> Self { + Self::with_raw_capacity(capacity.div_ceil(4) * 5) + } + + pub fn with_raw_capacity(capacity: usize) -> Self { + Base32Writer { + result: String::with_capacity(capacity), + last_byte: 0, + pos: 0, + } + } + + pub fn push_char(&mut self, ch: char) { + self.result.push(ch); + } + + pub fn push_string(&mut self, string: &str) { + self.result.push_str(string); + } + + fn push_byte(&mut self, byte: u8, is_remainder: bool) { + let (ch1, ch2) = match self.pos % 5 { + 0 => ((byte & 0xF8) >> 3, u8::MAX), + 1 => ( + (((self.last_byte & 0x07) << 2) | ((byte & 0xC0) >> 6)), + ((byte & 0x3E) >> 1), + ), + 2 => ( + (((self.last_byte & 0x01) << 4) | ((byte & 0xF0) >> 4)), + u8::MAX, + ), + 3 => ( + (((self.last_byte & 0x0F) << 1) | (byte >> 7)), + ((byte & 0x7C) >> 2), + ), + 4 => ( + (((self.last_byte & 0x03) << 3) | ((byte & 0xE0) >> 5)), + (byte & 0x1F), + ), + _ => unreachable!(), + }; + + self.result.push(char::from(BASE32_ALPHABET[ch1 as usize])); + if !is_remainder { + if ch2 != u8::MAX { + self.result.push(char::from(BASE32_ALPHABET[ch2 as usize])); + } + self.last_byte = byte; + self.pos += 1; + } + } + + pub fn finalize(mut self) -> String { + if !self.pos.is_multiple_of(5) { + self.push_byte(0, true); + } + + self.result + } +} + +impl std::io::Write for Base32Writer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let start_pos = self.pos; + + for &byte in bytes { + self.push_byte(byte, false); + } + + Ok(self.pos - start_pos) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +pub struct Base32Reader<'x> { + bytes: Iter<'x, u8>, + last_byte: u8, + pos: usize, +} + +impl<'x> Base32Reader<'x> { + pub fn new(bytes: &'x [u8]) -> Self { + Base32Reader { + bytes: bytes.iter(), + pos: 0, + last_byte: 0, + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_iter(bytes: Iter<'x, u8>) -> Self { + Base32Reader { + bytes, + pos: 0, + last_byte: 0, + } + } + + #[inline(always)] + fn map_byte(&mut self) -> Option { + match self.bytes.next() { + Some(&byte) => match BASE32_INVERSE[byte as usize] { + byte if byte != u8::MAX => { + self.last_byte = byte; + Some(byte) + } + _ => None, + }, + _ => None, + } + } +} + +impl Iterator for Base32Reader<'_> { + type Item = u8; + fn next(&mut self) -> Option { + let pos = self.pos % 5; + let last_byte = self.last_byte; + let byte = self.map_byte()?; + self.pos += 1; + + match pos { + 0 => ((byte << 3) | (self.map_byte().unwrap_or(0) >> 2)).into(), + 1 => ((last_byte << 6) | (byte << 1) | (self.map_byte().unwrap_or(0) >> 4)).into(), + 2 => ((last_byte << 4) | (byte >> 1)).into(), + 3 => ((last_byte << 7) | (byte << 2) | (self.map_byte().unwrap_or(0) >> 3)).into(), + 4 => ((last_byte << 5) | byte).into(), + _ => None, + } + } +} + +impl Leb128Iterator for Base32Reader<'_> {} +impl Leb128Writer for Base32Writer {} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use crate::codec::base32_custom::{Base32Reader, Base32Writer}; + + #[test] + fn base32_roundtrip() { + let mut bytes = Vec::with_capacity(100); + for byte in 0..100 { + bytes.push((100 - byte) as u8); + let mut writer = Base32Writer::with_capacity(10); + writer.write_all(&bytes).unwrap(); + let result = writer.finalize(); + + let mut bytes_result = Vec::new(); + for byte in Base32Reader::new(result.as_bytes()) { + bytes_result.push(byte); + } + + assert_eq!(bytes, bytes_result); + } + + for bytes in [ + vec![0], + vec![32, 43, 55, 99, 43, 55], + vec![84, 4, 43, 77, 62, 55, 92], + vec![84, 4, 43, 77, 62, 55, 92], + ] { + let mut writer = Base32Writer::with_capacity(10); + writer.write_all(&bytes).unwrap(); + let result = writer.finalize(); + + let mut bytes_result = Vec::new(); + for byte in Base32Reader::new(result.as_bytes()) { + bytes_result.push(byte); + } + + assert_eq!(bytes, bytes_result); + } + } +} diff --git a/crates/utils/src/codec/leb128.rs b/crates/utils/src/codec/leb128.rs new file mode 100644 index 0000000..078563d --- /dev/null +++ b/crates/utils/src/codec/leb128.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![allow(dead_code)] + +use std::{borrow::Borrow, io::Write}; + +pub trait Leb128_ { + fn to_leb128_writer(self, out: &mut impl Write) -> std::io::Result; + fn to_leb128_bytes(self, out: &mut Vec); + fn from_leb128_bytes_pos(slice: &[u8]) -> Option<(Self, usize)> + where + Self: std::marker::Sized; + fn from_leb128_bytes(slice: &[u8]) -> Option + where + Self: std::marker::Sized; + fn from_leb128_it(it: T) -> Option + where + Self: std::marker::Sized, + T: Iterator, + I: Borrow; +} + +pub trait Leb128Vec { + fn push_leb128(&mut self, value: T); +} + +pub trait Leb128Writer: Write + Sized { + #[inline(always)] + fn write_leb128(&mut self, value: T) -> std::io::Result { + T::to_leb128_writer(value, self) + } +} + +impl Leb128Vec for Vec { + #[inline(always)] + fn push_leb128(&mut self, value: T) { + T::to_leb128_bytes(value, self); + } +} + +pub trait Leb128Iterator: Iterator +where + I: Borrow, +{ + #[inline(always)] + fn next_leb128(&mut self) -> Option { + T::from_leb128_it(self) + } + + #[inline(always)] + fn skip_leb128(&mut self) -> Option<()> { + for byte in self { + if (byte.borrow() & 0x80) == 0 { + return Some(()); + } + } + None + } +} + +pub trait Leb128Reader: AsRef<[u8]> { + #[inline(always)] + fn read_leb128(&self) -> Option<(T, usize)> { + T::from_leb128_bytes_pos(self.as_ref()) + } + + #[inline(always)] + fn skip_leb128(&self) -> Option { + for (pos, byte) in self.as_ref().iter().enumerate() { + if (byte & 0x80) == 0 { + return (pos + 1).into(); + } + } + None + } +} + +impl Leb128Reader for &[u8] {} +impl Leb128Reader for Vec {} +impl Leb128Reader for Box<[u8]> {} +impl<'x> Leb128Iterator<&'x u8> for std::slice::Iter<'x, u8> {} + +// Based on leb128.rs from rustc +macro_rules! impl_unsigned_leb128 { + ($int_ty:ident, $shifts:expr) => { + impl Leb128_ for $int_ty { + #[inline(always)] + fn to_leb128_writer(self, out: &mut impl Write) -> std::io::Result { + let mut value = self; + let mut bytes_written = 0; + loop { + if value < 0x80 { + bytes_written += out.write(&[value as u8])?; + break; + } else { + bytes_written += out.write(&[((value & 0x7f) | 0x80) as u8])?; + value >>= 7; + } + } + Ok(bytes_written) + } + + #[inline(always)] + fn to_leb128_bytes(self, out: &mut Vec) { + let mut value = self; + loop { + if value < 0x80 { + out.push(value as u8); + break; + } else { + out.push(((value & 0x7f) | 0x80) as u8); + value >>= 7; + } + } + } + + #[inline(always)] + fn from_leb128_bytes_pos(slice: &[u8]) -> Option<($int_ty, usize)> { + let mut result = 0; + + for (shift, (pos, &byte)) in $shifts.into_iter().zip(slice.iter().enumerate()) { + if (byte & 0x80) == 0 { + result |= (byte as $int_ty) << shift; + return Some((result, pos + 1)); + } else { + result |= ((byte & 0x7F) as $int_ty) << shift; + } + } + + None + } + + #[inline(always)] + fn from_leb128_bytes(slice: &[u8]) -> Option<$int_ty> { + let mut result = 0; + + for (shift, &byte) in $shifts.into_iter().zip(slice.iter()) { + if (byte & 0x80) == 0 { + result |= (byte as $int_ty) << shift; + return Some(result); + } else { + result |= ((byte & 0x7F) as $int_ty) << shift; + } + } + + None + } + + #[inline(always)] + fn from_leb128_it(it: T) -> Option<$int_ty> + where + T: Iterator, + I: Borrow, + { + let mut result = 0; + + for (shift, byte_) in $shifts.into_iter().zip(it) { + let byte = byte_.borrow(); + + if (byte & 0x80) == 0 { + result |= (*byte as $int_ty) << shift; + return Some(result); + } else { + result |= ((byte & 0x7F) as $int_ty) << shift; + } + } + + None + } + } + }; +} + +impl_unsigned_leb128!(u8, [0]); +impl_unsigned_leb128!(u16, [0, 7, 14]); +impl_unsigned_leb128!(u32, [0, 7, 14, 21, 28]); +impl_unsigned_leb128!(u64, [0, 7, 14, 21, 28, 35, 42, 49, 56, 63]); +impl_unsigned_leb128!(usize, [0, 7, 14, 21, 28, 35, 42, 49, 56, 63]); + +impl Leb128Writer for Vec { + #[inline(always)] + fn write_leb128(&mut self, value: T) -> std::io::Result { + T::to_leb128_writer(value, self) + } +} diff --git a/crates/utils/src/codec/mod.rs b/crates/utils/src/codec/mod.rs new file mode 100644 index 0000000..b693447 --- /dev/null +++ b/crates/utils/src/codec/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod base32_custom; +pub mod leb128; diff --git a/crates/utils/src/cron.rs b/crates/utils/src/cron.rs new file mode 100644 index 0000000..b9fc94e --- /dev/null +++ b/crates/utils/src/cron.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use chrono::{Datelike, Local, TimeDelta, TimeZone, Timelike}; +use std::{str::FromStr, time::Duration}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimpleCron { + Day { hour: u32, minute: u32 }, + Week { day: u32, hour: u32, minute: u32 }, + Hour { minute: u32 }, +} + +impl SimpleCron { + pub fn time_to_next(&self) -> Duration { + let now = Local::now(); + let next = match self { + SimpleCron::Day { hour, minute } => { + let next = Local + .with_ymd_and_hms(now.year(), now.month(), now.day(), *hour, *minute, 0) + .earliest() + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); + if next <= now { + next + TimeDelta::try_days(1).unwrap_or_default() + } else { + next + } + } + SimpleCron::Week { day, hour, minute } => { + let next = Local + .with_ymd_and_hms(now.year(), now.month(), now.day(), *hour, *minute, 0) + .earliest() + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); + if next <= now { + next + TimeDelta::try_days( + (7 - now.weekday().number_from_monday() + *day).into(), + ) + .unwrap_or_default() + } else { + next + } + } + SimpleCron::Hour { minute } => { + let next = Local + .with_ymd_and_hms(now.year(), now.month(), now.day(), now.hour(), *minute, 0) + .earliest() + .unwrap_or_else(|| now - TimeDelta::try_seconds(1).unwrap_or_default()); + if next <= now { + next + TimeDelta::try_hours(1).unwrap_or_default() + } else { + next + } + } + }; + + (next - now).to_std().unwrap_or_else(|_| self.as_duration()) + } + + pub fn as_duration(&self) -> Duration { + match self { + SimpleCron::Day { .. } => Duration::from_secs(24 * 60 * 60), + SimpleCron::Week { .. } => Duration::from_secs(7 * 24 * 60 * 60), + SimpleCron::Hour { .. } => Duration::from_secs(60 * 60), + } + } +} + +impl FromStr for SimpleCron { + type Err = String; + + fn from_str(value: &str) -> Result { + let mut hour = 0; + let mut minute = 0; + + for (pos, value) in value.split(' ').enumerate() { + if pos == 0 { + minute = value + .parse::() + .map_err(|_| "Invalid cron key: failed to parse cron minute".to_string())?; + if !(0..=59).contains(&minute) { + return Err(format!( + "Invalid cron key: failed to parse minute, invalid value: {minute}" + )); + } + } else if pos == 1 { + if value + .as_bytes() + .first() + .ok_or_else(|| "Invalid cron key: failed to parse cron hour".to_string())? + == &b'*' + { + return Ok(SimpleCron::Hour { minute }); + } else { + hour = value + .parse::() + .map_err(|_| "Invalid cron key: failed to parse cron hour".to_string())?; + if !(0..=23).contains(&hour) { + return Err(format!( + "Invalid cron key: failed to parse hour, invalid value: {hour}" + )); + } + } + } else if pos == 2 { + if value + .as_bytes() + .first() + .ok_or_else(|| "Invalid cron key: failed to parse cron weekday".to_string())? + == &b'*' + { + return Ok(SimpleCron::Day { hour, minute }); + } else { + let day = value.parse::().map_err(|_| { + "Invalid cron key: failed to parse cron weekday".to_string() + })?; + if !(1..=7).contains(&hour) { + return Err(format!( + "Invalid cron key: failed to parse weekday, invalid value: {}, range is 1 (Monday) to 7 (Sunday).", + hour, + )); + } + + return Ok(SimpleCron::Week { day, hour, minute }); + } + } + } + + Err("Invalid cron key: parse cron expression.".to_string()) + } +} + +impl Default for SimpleCron { + fn default() -> Self { + SimpleCron::Hour { minute: 0 } + } +} diff --git a/crates/utils/src/glob.rs b/crates/utils/src/glob.rs new file mode 100644 index 0000000..43beb9d --- /dev/null +++ b/crates/utils/src/glob.rs @@ -0,0 +1,264 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::{AHashMap, AHashSet}; +use serde::Deserialize; +use std::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchType { + Equal(String), + StartsWith(String), + EndsWith(String), + Matches(GlobPattern), + All, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlobPattern { + pattern: Vec, + to_lower: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PatternChar { + WildcardMany { num: usize, match_pos: usize }, + WildcardSingle { match_pos: usize }, + Char { char: char, match_pos: usize }, +} + +impl GlobPattern { + pub fn compile(pattern: &str, to_lower: bool) -> Self { + let mut chars = Vec::new(); + let mut is_escaped = false; + let mut str = pattern.chars().peekable(); + + while let Some(char) = str.next() { + match char { + '*' if !is_escaped => { + let mut num = 1; + while let Some('*') = str.peek() { + num += 1; + str.next(); + } + chars.push(PatternChar::WildcardMany { num, match_pos: 0 }); + } + '?' if !is_escaped => { + chars.push(PatternChar::WildcardSingle { match_pos: 0 }); + } + '\\' if !is_escaped => { + is_escaped = true; + continue; + } + _ => { + if is_escaped { + is_escaped = false; + } + if to_lower && char.is_uppercase() { + for char in char.to_lowercase() { + chars.push(PatternChar::Char { char, match_pos: 0 }); + } + } else { + chars.push(PatternChar::Char { char, match_pos: 0 }); + } + } + } + } + + GlobPattern { + pattern: chars, + to_lower, + } + } + + pub fn try_compile(pattern: &str, to_lower: bool) -> Result { + // Detect if the key is a glob pattern + let mut last_ch = '\0'; + let mut has_escape = false; + let mut is_glob = false; + for ch in pattern.chars() { + match ch { + '\\' => { + has_escape = true; + } + '*' | '?' if last_ch != '\\' => { + is_glob = true; + } + _ => {} + } + + last_ch = ch; + } + + if is_glob { + Ok(GlobPattern::compile(pattern, to_lower)) + } else { + Err(if has_escape { + pattern.replace('\\', "") + } else { + pattern.to_string() + }) + } + } + + // Credits: Algorithm ported from https://research.swtch.com/glob + pub fn matches(&self, value: &str) -> bool { + let value = if self.to_lower { + value.to_lowercase().chars().collect::>() + } else { + value.chars().collect::>() + }; + + let mut px = 0; + let mut nx = 0; + let mut next_px = 0; + let mut next_nx = 0; + + while px < self.pattern.len() || nx < value.len() { + match self.pattern.get(px) { + Some(PatternChar::Char { char, .. }) => { + if matches!(value.get(nx), Some(nc) if nc == char ) { + px += 1; + nx += 1; + continue; + } + } + Some(PatternChar::WildcardSingle { .. }) if nx < value.len() => { + px += 1; + nx += 1; + continue; + } + Some(PatternChar::WildcardMany { .. }) => { + next_px = px; + next_nx = nx + 1; + px += 1; + continue; + } + _ => (), + } + if 0 < next_nx && next_nx <= value.len() { + px = next_px; + nx = next_nx; + continue; + } + return false; + } + true + } +} + +#[derive(Debug, Clone, Default)] +pub struct GlobSet { + entries: AHashSet, + patterns: Vec, +} + +#[derive(Debug, Clone)] +pub struct GlobMap { + entries: AHashMap, + patterns: Vec<(GlobPattern, V)>, +} + +impl GlobSet { + pub fn new() -> Self { + GlobSet::default() + } + + pub fn insert_pattern(&mut self, pattern: &str) { + match GlobPattern::try_compile(pattern, false) { + Ok(glob) => { + self.patterns.push(glob); + } + Err(entry) => { + self.entries.insert(entry); + } + } + } + + pub fn insert_entry(&mut self, entry: String) { + self.entries.insert(entry); + } + + pub fn contains(&self, key: &str) -> bool { + self.entries.contains(key) || self.patterns.iter().any(|pattern| pattern.matches(key)) + } +} + +impl GlobMap { + pub fn new() -> Self { + GlobMap { + entries: AHashMap::new(), + patterns: Vec::new(), + } + } + + pub fn insert_pattern(&mut self, pattern: &str, value: V) { + match GlobPattern::try_compile(pattern, false) { + Ok(glob) => { + self.patterns.push((glob, value)); + } + Err(entry) => { + self.entries.insert(entry, value); + } + } + } + + pub fn insert_entry(&mut self, entry: String, value: V) { + self.entries.insert(entry, value); + } + + pub fn get(&self, key: &str) -> Option<&V> { + self.entries.get(key).or_else(|| { + self.patterns + .iter() + .find_map(|(pattern, value)| pattern.matches(key).then_some(value)) + }) + } +} + +impl Default for GlobMap { + fn default() -> Self { + GlobMap::new() + } +} + +impl MatchType { + pub fn parse(value: &str) -> Self { + if value == "*" { + MatchType::All + } else if let Some(value) = value.strip_suffix('*') { + MatchType::StartsWith(value.to_string()) + } else if let Some(value) = value.strip_prefix('*') { + MatchType::EndsWith(value.to_string()) + } else if value.contains('*') { + MatchType::Matches(GlobPattern::compile(value, false)) + } else { + MatchType::Equal(value.to_string()) + } + } + + pub fn matches(&self, value: &str) -> bool { + match self { + MatchType::Equal(pattern) => value == pattern, + MatchType::StartsWith(pattern) => value.starts_with(pattern), + MatchType::EndsWith(pattern) => value.ends_with(pattern), + MatchType::Matches(pattern) => pattern.matches(value), + MatchType::All => true, + } + } +} + +impl<'de> Deserialize<'de> for GlobPattern { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(GlobPattern::compile( + >::deserialize(deserializer)?.as_ref(), + true, + )) + } +} diff --git a/crates/utils/src/http.rs b/crates/utils/src/http.rs new file mode 100644 index 0000000..6f78478 --- /dev/null +++ b/crates/utils/src/http.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use reqwest::{ + Client, ClientBuilder, + header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT}, +}; +use rustls::{ + ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + crypto::{CryptoProvider, aws_lc_rs}, +}; +use rustls_pki_types::{CertificateDer, ServerName, UnixTime}; +use std::{ + str::FromStr, + sync::{Arc, LazyLock}, + time::Duration, +}; + +struct SharedTlsConfigs { + strict: ClientConfig, + strict_http1: ClientConfig, + insecure: ClientConfig, + insecure_http1: ClientConfig, +} + +#[derive(Debug)] +struct NoCertificateVerification(Arc); + +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +static SHARED_TLS_CONFIGS: LazyLock = LazyLock::new(|| { + let provider = Arc::new(aws_lc_rs::default_provider()); + + let verifier = rustls_platform_verifier::Verifier::new(provider.clone()) + .expect("Failed to load the platform certificate verifier"); + + let mut strict = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .expect("Failed to build the TLS client configuration") + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + strict.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + + let mut insecure = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .expect("Failed to build the TLS client configuration") + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification(provider))) + .with_no_client_auth(); + insecure.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + + let mut strict_http1 = strict.clone(); + strict_http1.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let mut insecure_http1 = insecure.clone(); + insecure_http1.alpn_protocols = vec![b"http/1.1".to_vec()]; + + SharedTlsConfigs { + strict, + strict_http1, + insecure, + insecure_http1, + } +}); + +pub fn init_shared_tls_configs() { + LazyLock::force(&SHARED_TLS_CONFIGS); +} + +pub fn shared_tls_config(allow_invalid_certs: bool) -> ClientConfig { + if allow_invalid_certs { + SHARED_TLS_CONFIGS.insecure.clone() + } else { + SHARED_TLS_CONFIGS.strict.clone() + } +} + +pub fn http_client_builder(allow_invalid_certs: bool) -> ClientBuilder { + Client::builder().use_preconfigured_tls(shared_tls_config(allow_invalid_certs)) +} + +pub fn http1_client_builder(allow_invalid_certs: bool) -> ClientBuilder { + let tls = if allow_invalid_certs { + SHARED_TLS_CONFIGS.insecure_http1.clone() + } else { + SHARED_TLS_CONFIGS.strict_http1.clone() + }; + + Client::builder().http1_only().use_preconfigured_tls(tls) +} + +pub fn unpooled_http_client(allow_invalid_certs: bool) -> Client { + http_client_builder(allow_invalid_certs) + .pool_max_idle_per_host(0) + .build() + .unwrap_or_default() +} + +pub fn build_http_client( + raw_headers: impl IntoIterator, + username: Option<&str>, + password: Option<&str>, + token: Option<&str>, + content_type: Option<&str>, + timeout: Duration, + allow_invalid_certs: bool, +) -> Result { + let mut headers = build_http_headers(raw_headers, username, password, token, content_type)?; + headers.insert(USER_AGENT, "Stalwart/1.0.0".parse().unwrap()); + + match http_client_builder(allow_invalid_certs) + .connect_timeout(timeout) + .default_headers(headers) + .build() + { + Ok(client) => Ok(client), + Err(err) => Err(format!("Failed to build HTTP client: {}", err)), + } +} + +pub fn build_http_headers( + raw_headers: impl IntoIterator, + username: Option<&str>, + password: Option<&str>, + token: Option<&str>, + content_type: Option<&str>, +) -> Result { + let mut headers = HeaderMap::new(); + + if let Some(content_type) = content_type { + headers.insert(CONTENT_TYPE, HeaderValue::from_str(content_type).unwrap()); + } + + for (header, value) in raw_headers + .into_iter() + .map(|(k, v)| { + Ok(( + HeaderName::from_str(k.trim()) + .map_err(|err| format!("Invalid header {k:?}: {err}",))?, + HeaderValue::from_str(v.trim()) + .map_err(|err| format!("Invalid value {v:?}: {err}",))?, + )) + }) + .collect::, String>>()? + { + headers.insert(header, value); + } + + if let (Some(name), Some(secret)) = (username, password) { + headers.insert( + AUTHORIZATION, + format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", name, secret)) + ) + .parse() + .unwrap(), + ); + } else if let Some(token) = token { + headers.insert(AUTHORIZATION, format!("Bearer {}", token).parse().unwrap()); + } + + Ok(headers) +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs new file mode 100644 index 0000000..4629c3c --- /dev/null +++ b/crates/utils/src/lib.rs @@ -0,0 +1,536 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#![warn(clippy::large_futures)] + +pub mod bimap; +pub mod cache; +pub mod chained_bytes; +pub mod cheeky_hash; +pub mod codec; +pub mod cron; +pub mod glob; +pub mod http; +pub mod map; +pub mod snowflake; +pub mod template; +pub mod tls; +pub mod topological; +pub mod url_params; + +use compact_str::ToCompactString; +use futures::StreamExt; +pub use reqwest::Client; +use reqwest::Response; +pub use reqwest::header::HeaderMap; +use std::borrow::Cow; +use std::fmt::Write; + +pub trait HttpLimitResponse: Sync + Send { + fn bytes_with_limit( + self, + limit: usize, + ) -> impl std::future::Future>>> + Send; +} + +impl HttpLimitResponse for Response { + async fn bytes_with_limit(self, limit: usize) -> reqwest::Result>> { + if self + .content_length() + .is_some_and(|len| len as usize > limit) + { + return Ok(None); + } + + let mut bytes = Vec::with_capacity(std::cmp::min(limit, 1024)); + let mut stream = self.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if bytes.len() + chunk.len() > limit { + return Ok(None); + } + bytes.extend_from_slice(&chunk); + } + + Ok(Some(bytes)) + } +} + +pub trait UnwrapFailure { + fn failed(self, action: &str) -> T; +} + +impl UnwrapFailure for Option { + fn failed(self, message: &str) -> T { + match self { + Some(result) => result, + None => { + trc::event!( + Server(trc::ServerEvent::StartupError), + Details = message.to_compact_string() + ); + eprintln!("{message}"); + std::process::exit(1); + } + } + } +} + +impl UnwrapFailure for Result { + fn failed(self, message: &str) -> T { + match self { + Ok(result) => result, + Err(err) => { + trc::event!( + Server(trc::ServerEvent::StartupError), + Details = message.to_compact_string(), + Reason = err.to_compact_string() + ); + + #[cfg(feature = "test_mode")] + panic!("{message}: {err}"); + + #[cfg(not(feature = "test_mode"))] + { + eprintln!("{message}: {err}"); + std::process::exit(1); + } + } + } + } +} + +pub fn failed(message: &str) -> ! { + trc::event!( + Server(trc::ServerEvent::StartupError), + Details = message.to_compact_string(), + ); + eprintln!("{message}"); + std::process::exit(1); +} + +pub async fn wait_for_shutdown() { + #[cfg(not(target_env = "msvc"))] + let signal = { + use tokio::signal::unix::{SignalKind, signal}; + + let mut h_term = signal(SignalKind::terminate()).failed("start signal handler"); + let mut h_int = signal(SignalKind::interrupt()).failed("start signal handler"); + + tokio::select! { + _ = h_term.recv() => "SIGTERM", + _ = h_int.recv() => "SIGINT", + } + }; + + #[cfg(target_env = "msvc")] + let signal = { + match tokio::signal::ctrl_c().await { + Ok(()) => "SIGINT", + Err(err) => { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Unable to listen for shutdown signal", + Reason = err.to_string(), + ); + "Error" + } + } + }; + + trc::event!(Server(trc::ServerEvent::Shutdown), CausedBy = signal); +} + +pub trait DomainPart { + fn to_lowercase_address(&self, lower_local: bool) -> String; + fn to_canonical_address(&self) -> Cow<'_, str>; + fn domain_part(&self) -> &str; + fn try_domain_part(&self) -> Option<&str>; + fn try_local_part(&self) -> Option<&str>; + fn to_ascii_domain(&self) -> Option>; +} + +impl> DomainPart for T { + fn to_lowercase_address(&self, lower_local: bool) -> String { + let address = self.as_ref(); + if let Some((local, domain)) = address.rsplit_once('@') { + let mut address = String::with_capacity(address.len()); + if lower_local { + for ch in local.chars() { + for ch in ch.to_lowercase() { + address.push(ch); + } + } + } else { + address.push_str(local); + } + address.push('@'); + if domain.is_ascii() { + for ch in domain.chars() { + for ch in ch.to_lowercase() { + address.push(ch); + } + } + } else { + let domain = + idna::domain_to_ascii(domain).unwrap_or_else(|_| domain.to_lowercase()); + address.push_str(&domain); + } + address + } else { + address.to_lowercase() + } + } + + fn to_canonical_address(&self) -> Cow<'_, str> { + let address = self.as_ref(); + + if address + .bytes() + .any(|ch| !ch.is_ascii() || ch.is_ascii_uppercase()) + { + Cow::Owned(address.to_lowercase_address(true)) + } else { + Cow::Borrowed(address) + } + } + + #[inline(always)] + fn try_domain_part(&self) -> Option<&str> { + self.as_ref().rsplit_once('@').map(|(_, d)| d) + } + + #[inline(always)] + fn try_local_part(&self) -> Option<&str> { + self.as_ref().rsplit_once('@').map(|(l, _)| l) + } + + #[inline(always)] + fn domain_part(&self) -> &str { + self.as_ref() + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or_default() + } + + #[inline(always)] + fn to_ascii_domain(&self) -> Option> { + let domain = self.as_ref(); + + if !domain.is_ascii() { + idna::domain_to_ascii(domain).ok().map(Cow::Owned) + } else if domain.bytes().any(|ch| ch.is_ascii_uppercase()) { + Some(Cow::Owned(domain.to_ascii_lowercase())) + } else { + Some(Cow::Borrowed(domain)) + } + } +} + +pub trait HexEncode { + fn hex_encode(&self) -> String; +} + +impl> HexEncode for T { + fn hex_encode(&self) -> String { + let bytes = self.as_ref(); + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + let _ = write!(&mut s, "{b:02x}"); + } + s + } +} + +static NIL_CHAR: char = char::from_u32(0).unwrap(); + +// Basic email sanitizer +pub fn sanitize_email(email: &str) -> Option { + let mut result = String::with_capacity(email.len()); + let mut last_ch = NIL_CHAR; + let mut chars = email.chars(); + + for ch in chars.by_ref() { + match ch { + '.' => { + if last_ch == NIL_CHAR || last_ch == '.' { + return None; + } + result.push('.'); + } + '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_' + | '`' | '{' | '|' | '}' | '~' => { + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + '@' => { + if result.is_empty() || last_ch == '.' { + return None; + } + last_ch = ch; + result.push(ch); + break; + } + _ => { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); + } else { + return None; + } + } + } + + last_ch = ch; + } + + if last_ch != '@' { + return None; + } + + last_ch = NIL_CHAR; + let domain_start = result.len(); + let mut domain_is_ascii = true; + + for ch in chars { + match ch { + '.' => { + if !last_ch.is_alphanumeric() { + return None; + } + result.push('.'); + } + '-' | '_' => { + if last_ch == NIL_CHAR || last_ch == '.' { + return None; + } + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + _ => { + if !ch.is_ascii() { + domain_is_ascii = false; + } + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); + } else { + return None; + } + } + } + + last_ch = ch; + } + + if !last_ch.is_alphanumeric() { + return None; + } + + if domain_is_ascii { + is_valid_domain(&result[domain_start..]).then_some(result) + } else { + let domain = idna::domain_to_ascii(&result[domain_start..]).ok()?; + if !is_valid_domain(&domain) { + return None; + } + result.truncate(domain_start); + result.push_str(&domain); + Some(result) + } +} + +pub fn sanitize_email_local(local: &str) -> Option { + let mut result = String::with_capacity(local.len()); + let mut last_ch = NIL_CHAR; + + for ch in local.chars() { + match ch { + '.' => { + if last_ch == NIL_CHAR || last_ch == '.' { + return None; + } + result.push('.'); + } + '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_' + | '`' | '{' | '|' | '}' | '~' => { + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + _ => { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); + } else { + return None; + } + } + } + + last_ch = ch; + } + + if !result.is_empty() && last_ch != '.' { + Some(result) + } else { + None + } +} + +pub fn sanitize_domain(domain: &str) -> Option { + let mut result = String::with_capacity(domain.len()); + let mut found_dot = false; + let mut last_ch = char::from(0); + let mut is_ascii = true; + + for ch in domain.chars() { + if !ch.is_whitespace() { + if ch == '.' { + found_dot = true; + if !(last_ch.is_alphanumeric() || last_ch == '-' || last_ch == '_') { + return None; + } + } else if !ch.is_ascii() { + is_ascii = false; + } + last_ch = ch; + for ch in ch.to_lowercase() { + result.push(ch); + } + } + } + + if !(found_dot && last_ch != '.') { + return None; + } + + if is_ascii { + is_valid_domain(&result).then_some(result) + } else { + let domain = idna::domain_to_ascii(&result).ok()?; + is_valid_domain(&domain).then_some(domain) + } +} + +pub fn is_valid_domain(domain: &str) -> bool { + const RESERVED_TLDS: &[&str] = &[ + "test", + "localhost", + "local", + "internal", + "lan", + "home", + "corp", + "intranet", + "private", + "localdomain", + ]; + (domain.contains('.') && psl::suffix(domain.as_bytes()).is_some_and(|s| s.typ().is_some())) + || RESERVED_TLDS.contains(&domain) + || domain + .rsplit_once('.') + .is_some_and(|(_, tld)| RESERVED_TLDS.contains(&tld)) +} + +#[cfg(test)] +mod tests { + use crate::DomainPart; + + use super::{sanitize_domain, sanitize_email}; + + #[test] + fn idn_domains_canonicalize_to_a_label() { + assert_eq!( + sanitize_domain("straß6.de").as_deref(), + Some("xn--stra6-oqa.de") + ); + assert_eq!( + sanitize_domain("STRASS.straß6.DE").as_deref(), + Some("strass.xn--stra6-oqa.de") + ); + assert_eq!( + sanitize_domain("münchen.de").as_deref(), + Some("xn--mnchen-3ya.de") + ); + } + + #[test] + fn a_label_and_ascii_domains_are_idempotent() { + assert_eq!( + sanitize_domain("xn--stra6-oqa.de").as_deref(), + Some("xn--stra6-oqa.de") + ); + assert_eq!( + sanitize_domain(&sanitize_domain("straß6.de").unwrap()).as_deref(), + Some("xn--stra6-oqa.de") + ); + assert_eq!( + sanitize_domain("Example.COM").as_deref(), + Some("example.com") + ); + } + + #[test] + fn email_domain_part_canonicalizes_local_part_preserved() { + assert_eq!( + sanitize_email("cornelius_strauss@straß6.de").as_deref(), + Some("cornelius_strauss@xn--stra6-oqa.de") + ); + assert_eq!( + sanitize_email("Foo.Bar@münchen.de").as_deref(), + Some("foo.bar@xn--mnchen-3ya.de") + ); + assert_eq!( + sanitize_email("user@example.com").as_deref(), + Some("user@example.com") + ); + } + + #[test] + fn bare_public_suffix_domains_are_accepted() { + assert_eq!( + sanitize_email("user@gov.in").as_deref(), + Some("user@gov.in") + ); + assert_eq!(sanitize_email("user@co.uk").as_deref(), Some("user@co.uk")); + assert_eq!(sanitize_email("user@com"), None); + assert_eq!(sanitize_email("user@example.invalidtld"), None); + } + + #[test] + fn a_label_email_domains_are_accepted_and_idempotent() { + assert_eq!( + sanitize_email("user@xn--fsqu00a.com").as_deref(), + Some("user@xn--fsqu00a.com") + ); + assert_eq!( + sanitize_email("User@例子.com").as_deref(), + sanitize_email("user@xn--fsqu00a.com").as_deref() + ); + } + + #[test] + fn to_ascii_domain_borrows_ascii_owns_idn() { + assert!(matches!( + "example.com".to_ascii_domain(), + Some(std::borrow::Cow::Borrowed(_)) + )); + assert!(matches!( + "straß6.de".to_ascii_domain(), + Some(std::borrow::Cow::Owned(_)) + )); + } +} diff --git a/crates/utils/src/map/bitmap.rs b/crates/utils/src/map/bitmap.rs new file mode 100644 index 0000000..ced6e20 --- /dev/null +++ b/crates/utils/src/map/bitmap.rs @@ -0,0 +1,316 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::ops::Deref; + +#[derive( + Debug, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + serde::Serialize, + serde::Deserialize, + Clone, + Copy, + PartialOrd, + Ord, + PartialEq, + Eq, + Hash, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +#[repr(transparent)] +pub struct Bitmap { + pub bitmap: u64, + #[serde(skip)] + #[rkyv(omit_bounds)] + _state: std::marker::PhantomData, +} + +pub trait BitmapItem: From + Into + Sized + Copy { + fn max() -> u64; + fn is_valid(&self) -> bool; +} + +pub trait BitPop { + fn bit_push(&mut self, item: u8); + fn bit_pop(&mut self) -> Option; +} + +impl Bitmap { + pub fn new() -> Self { + Self::default() + } + + #[inline(always)] + pub fn all() -> Self { + Self { + bitmap: u64::MAX >> (64 - T::max()), + _state: std::marker::PhantomData, + } + } + + #[inline(always)] + pub fn union(&mut self, items: &Bitmap) { + self.bitmap |= items.bitmap; + } + + #[inline(always)] + pub fn union_raw(&mut self, items: impl Into) { + self.bitmap |= items.into(); + } + + #[inline(always)] + pub fn intersection(&mut self, items: &Bitmap) { + self.bitmap &= items.bitmap; + } + + #[inline(always)] + pub fn insert(&mut self, item: T) { + debug_assert!(item.is_valid()); + self.bitmap |= 1 << item.into(); + } + + pub fn insert_many(&mut self, items: impl IntoIterator) { + for item in items.into_iter() { + self.insert(item); + } + } + + pub fn remove_many(&mut self, items: impl IntoIterator) { + for item in items.into_iter() { + debug_assert!(item.is_valid()); + self.bitmap &= !(1 << item.into()); + } + } + + #[inline(always)] + pub fn with_item(mut self, item: T) -> Self { + self.insert(item); + self + } + + #[inline(always)] + pub fn remove(&mut self, item: T) { + debug_assert!(item.is_valid()); + self.bitmap ^= 1 << item.into(); + } + + #[inline(always)] + pub fn pop(&mut self) -> Option { + if self.bitmap != 0 { + let item = 63 - self.bitmap.leading_zeros(); + self.bitmap ^= 1 << item; + Some((item as u64).into()) + } else { + None + } + } + + #[inline(always)] + pub fn contains(&self, item: T) -> bool { + self.bitmap & (1 << item.into()) != 0 + } + + #[inline(always)] + pub fn contains_any(&self, items: impl Iterator) -> bool { + for item in items { + if self.bitmap & (1 << item.into()) != 0 { + return true; + } + } + false + } + + #[inline(always)] + pub fn contains_all(&self, items: impl Iterator) -> bool { + if !self.is_empty() { + for item in items { + if self.bitmap & (1 << item.into()) == 0 { + return false; + } + } + true + } else { + false + } + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.bitmap == 0 + } + + #[inline(always)] + pub fn clear(&mut self) -> Self { + let bitmap = self.bitmap; + self.bitmap = 0; + Bitmap { + bitmap, + _state: std::marker::PhantomData, + } + } + + pub fn into_inner(self) -> u64 { + self.bitmap + } +} + +impl BitPop for u32 { + fn bit_push(&mut self, item: u8) { + *self |= 1 << item; + } + + fn bit_pop(&mut self) -> Option { + if *self != 0 { + let item = 31 - self.leading_zeros(); + *self ^= 1 << item; + Some(item as u8) + } else { + None + } + } +} + +impl BitPop for u64 { + fn bit_push(&mut self, item: u8) { + *self |= 1 << item; + } + + fn bit_pop(&mut self) -> Option { + if *self != 0 { + let item = 63 - self.leading_zeros(); + *self ^= 1 << item; + Some(item as u8) + } else { + None + } + } +} + +impl From> for Bitmap { + fn from(value: ArchivedBitmap) -> Self { + Self { + bitmap: value.bitmap.into(), + _state: std::marker::PhantomData, + } + } +} + +impl From<&ArchivedBitmap> for Bitmap { + fn from(value: &ArchivedBitmap) -> Self { + Self { + bitmap: value.bitmap.into(), + _state: std::marker::PhantomData, + } + } +} + +impl From for Bitmap { + fn from(value: u64) -> Self { + Self { + bitmap: value, + _state: std::marker::PhantomData, + } + } +} + +impl AsRef for Bitmap { + fn as_ref(&self) -> &u64 { + &self.bitmap + } +} + +impl Deref for Bitmap { + type Target = u64; + + fn deref(&self) -> &Self::Target { + &self.bitmap + } +} + +impl From> for u64 { + fn from(value: Bitmap) -> Self { + value.bitmap + } +} + +impl Iterator for Bitmap { + type Item = T; + + fn next(&mut self) -> Option { + if self.bitmap != 0 { + let item = 63 - self.bitmap.leading_zeros(); + self.bitmap ^= 1 << item; + Some((item as u64).into()) + } else { + None + } + } +} + +impl From> for Bitmap { + fn from(values: Vec) -> Self { + let mut bitmap = Bitmap::default(); + for value in values { + if value.is_valid() { + bitmap.insert(value); + } + } + bitmap + } +} + +impl FromIterator for Bitmap { + fn from_iter>(iter: U) -> Self { + let mut bitmap = Bitmap::new(); + for value in iter { + if value.is_valid() { + bitmap.insert(value); + } + } + bitmap + } +} + +impl From<&Vec> for Bitmap { + fn from(values: &Vec) -> Self { + let mut bitmap = Bitmap::default(); + for value in values { + if value.is_valid() { + bitmap.insert(*value); + } + } + bitmap + } +} + +impl From for Bitmap { + fn from(value: T) -> Self { + let mut bitmap = Bitmap::default(); + bitmap.insert(value); + bitmap + } +} + +impl From> for Vec { + fn from(values: Bitmap) -> Self { + let mut list = Vec::new(); + for item in values { + list.push(item); + } + list + } +} + +impl Default for Bitmap { + fn default() -> Self { + Bitmap { + bitmap: 0, + _state: std::marker::PhantomData, + } + } +} diff --git a/crates/utils/src/map/mod.rs b/crates/utils/src/map/mod.rs new file mode 100644 index 0000000..dbbfe93 --- /dev/null +++ b/crates/utils/src/map/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod bitmap; +pub mod mutex_map; +pub mod vec_map; diff --git a/crates/utils/src/map/mutex_map.rs b/crates/utils/src/map/mutex_map.rs new file mode 100644 index 0000000..02f8deb --- /dev/null +++ b/crates/utils/src/map/mutex_map.rs @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use core::hash::Hash; +use std::hash::Hasher; + +use ahash::AHasher; +use tokio::sync::{Mutex, MutexGuard}; + +pub struct MutexMap { + map: Box<[Mutex]>, + mask: u64, + hasher: AHasher, +} + +pub struct MutexMapLockError; +pub type Result = std::result::Result; + +#[allow(clippy::mutex_atomic)] +impl MutexMap { + pub fn with_capacity(size: usize) -> MutexMap { + let size = size.next_power_of_two(); + MutexMap { + map: (0..size) + .map(|_| T::default().into()) + .collect::>>() + .into_boxed_slice(), + mask: (size - 1) as u64, + hasher: AHasher::default(), + } + } + + pub async fn lock(&self, key: U) -> MutexGuard<'_, T> + where + U: Into + Copy, + { + let hash = key.into() & self.mask; + self.map[hash as usize].lock().await + } + + /*pub async fn try_lock(&self, key: U, timeout: Duration) -> Option> + where + U: Into + Copy, + { + let hash = key.into() & self.mask; + self.map[hash as usize].try_lock(timeout).await + }*/ + + pub async fn lock_hash(&self, key: U) -> MutexGuard<'_, T> + where + U: Hash, + { + let mut hasher = self.hasher.clone(); + key.hash(&mut hasher); + let hash = hasher.finish() & self.mask; + self.map[hash as usize].lock().await + } + + /*pub async fn try_lock_hash(&self, key: U, timeout: Duration) -> Option> + where + U: Hash, + { + let mut hasher = self.hasher.clone(); + key.hash(&mut hasher); + let hash = hasher.finish() & self.mask; + self.map[hash as usize].try_lock_for(timeout).await + }*/ +} diff --git a/crates/utils/src/map/vec_map.rs b/crates/utils/src/map/vec_map.rs new file mode 100644 index 0000000..67b42b4 --- /dev/null +++ b/crates/utils/src/map/vec_map.rs @@ -0,0 +1,396 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use rkyv::Archive; +use serde::{Deserialize, Serialize, ser::SerializeMap}; +use std::{borrow::Borrow, cmp::Ordering, fmt, hash::Hash}; + +// A map implemented using vectors +// used for small datasets of less than 20 items +// and when deserializing from JSON + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +pub struct VecMap { + pub inner: Vec>, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct KeyValue { + pub key: K, + pub value: V, +} + +impl Default for VecMap { + fn default() -> Self { + VecMap { inner: Vec::new() } + } +} + +impl VecMap { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: Vec::with_capacity(capacity), + } + } + + #[inline(always)] + pub fn set(&mut self, key: impl Into, value: V) -> bool { + let key = key.into(); + if let Some(kv) = self.inner.iter_mut().find(|kv| kv.key == key) { + kv.value = value; + false + } else { + self.inner.push(KeyValue { key, value }); + true + } + } + + #[inline(always)] + pub fn append(&mut self, key: impl Into, value: V) { + self.inner.push(KeyValue { + key: key.into(), + value, + }); + } + + #[inline(always)] + pub fn with_append(mut self, key: impl Into, value: V) -> Self { + self.append(key, value); + self + } + + #[inline(always)] + pub fn insert(&mut self, idx: usize, key: impl Into, value: V) { + self.inner.insert( + idx, + KeyValue { + key: key.into(), + value, + }, + ); + } + + #[inline(always)] + pub fn get(&self, key: &Q) -> Option<&V> + where + K: Borrow + PartialEq, + { + self.inner.iter().find_map(|kv| { + if &kv.key == key { + Some(&kv.value) + } else { + None + } + }) + } + + #[inline(always)] + pub fn get_mut(&mut self, key: &K) -> Option<&mut V> { + self.inner.iter_mut().find_map(|kv| { + if &kv.key == key { + Some(&mut kv.value) + } else { + None + } + }) + } + + #[inline(always)] + pub fn contains_key(&self, key: &K) -> bool { + self.inner.iter().any(|kv| kv.key == *key) + } + + #[inline(always)] + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow + PartialEq, + { + self.inner + .iter() + .position(|kv| kv.key == *key) + .map(|pos| self.inner.remove(pos).value) + } + + #[inline(always)] + pub fn remove_all(&mut self, key: &K) { + self.inner.retain(|kv| kv.key != *key); + } + + #[inline(always)] + pub fn remove_entry(&mut self, key: &K) -> Option<(K, V)> { + self.inner.iter().position(|k| &k.key == key).map(|pos| { + let kv = self.inner.remove(pos); + (kv.key, kv.value) + }) + } + + #[inline(always)] + pub fn swap_remove(&mut self, index: usize) -> V { + self.inner.swap_remove(index).value + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + #[inline(always)] + pub fn len(&self) -> usize { + self.inner.len() + } + + #[inline(always)] + pub fn clear(&mut self) { + self.inner.clear(); + } + + #[inline(always)] + pub fn iter(&self) -> impl Iterator { + self.inner.iter().map(|kv| (&kv.key, &kv.value)) + } + + #[inline(always)] + pub fn iter_by_key<'x, 'y: 'x>(&'x self, key: &'y K) -> impl Iterator + 'x { + self.inner.iter().filter_map(move |kv| { + if &kv.key == key { + Some(&kv.value) + } else { + None + } + }) + } + + #[inline(always)] + pub fn iter_mut(&mut self) -> impl Iterator { + self.inner.iter_mut().map(|kv| (&mut kv.key, &mut kv.value)) + } + + #[inline(always)] + pub fn iter_mut_by_key<'x, 'y: 'x>( + &'x mut self, + key: &'y K, + ) -> impl Iterator + 'x { + self.inner.iter_mut().filter_map(move |kv| { + if &kv.key == key { + Some(&mut kv.value) + } else { + None + } + }) + } + + #[inline(always)] + pub fn keys(&self) -> impl Iterator { + self.inner.iter().map(|kv| &kv.key) + } + + #[inline(always)] + pub fn values(&self) -> impl Iterator { + self.inner.iter().map(|kv| &kv.value) + } + + #[inline(always)] + pub fn last(&self) -> Option<(&K, &V)> { + self.inner.last().map(|kv| (&kv.key, &kv.value)) + } + + #[inline(always)] + pub fn values_mut(&mut self) -> impl Iterator { + self.inner.iter_mut().map(|kv| &mut kv.value) + } + + pub fn get_mut_or_insert_with(&mut self, key: K, fnc: impl FnOnce() -> V) -> &mut V { + if let Some(pos) = self.inner.iter().position(|kv| kv.key == key) { + &mut self.inner[pos].value + } else { + self.inner.push(KeyValue { key, value: fnc() }); + &mut self.inner.last_mut().unwrap().value + } + } + + pub fn with_key_value(mut self, key: K, value: V) -> Self { + self.append(key, value); + self + } + + pub fn sort_unstable(&mut self) + where + K: Ord, + V: Ord, + { + self.inner.sort_unstable_by(|a, b| match a.key.cmp(&b.key) { + Ordering::Equal => a.value.cmp(&b.value), + cmp => cmp, + }); + } + + pub fn sort_unstable_by_key(&mut self) + where + K: Ord, + { + self.inner.sort_unstable_by(|a, b| a.key.cmp(&b.key)); + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + for (k, v) in iter { + self.append(k, v); + } + } + + pub fn drain(&mut self) -> impl Iterator + '_ { + self.inner.drain(..).map(|kv| (kv.key, kv.value)) + } + + pub fn into_values(self) -> impl Iterator { + self.inner.into_iter().map(|kv| kv.value) + } + + pub fn into_keys(self) -> impl Iterator { + self.inner.into_iter().map(|kv| kv.key) + } +} + +impl VecMap { + pub fn get_mut_or_insert(&mut self, key: K) -> &mut V { + if let Some(pos) = self.inner.iter().position(|kv| kv.key == key) { + &mut self.inner[pos].value + } else { + self.inner.push(KeyValue { + key, + value: V::default(), + }); + &mut self.inner.last_mut().unwrap().value + } + } +} + +impl ArchivedVecMap { + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + #[inline(always)] + pub fn iter( + &self, + ) -> impl Iterator< + Item = ( + &::Archived, + &::Archived, + ), + > { + self.inner.iter().map(|kv| (&kv.key, &kv.value)) + } +} + +impl IntoIterator for VecMap { + type Item = (K, V); + + type IntoIter = + std::iter::Map>, fn(KeyValue) -> (K, V)>; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter().map(|kv| (kv.key, kv.value)) + } +} + +impl<'x, K: Eq + PartialEq, V> IntoIterator for &'x VecMap { + type Item = (&'x K, &'x V); + + type IntoIter = std::iter::Map< + std::slice::Iter<'x, KeyValue>, + fn(&'x KeyValue) -> (&'x K, &'x V), + >; + + fn into_iter(self) -> Self::IntoIter { + self.inner.iter().map(|kv| (&kv.key, &kv.value)) + } +} + +impl Hash for VecMap +where + K: Eq + PartialEq + Hash, + V: Hash, +{ + fn hash(&self, state: &mut H) { + self.inner.hash(state); + } +} + +impl FromIterator<(K, V)> for VecMap { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + let iter = iter.into_iter(); + let mut map = VecMap::with_capacity(iter.size_hint().0); + for (k, v) in iter { + map.append(k, v); + } + map + } +} + +struct VecMapVisitor { + phantom: std::marker::PhantomData<(K, V)>, +} + +impl<'de, K: Eq + PartialEq + Deserialize<'de>, V: Deserialize<'de>> serde::de::Visitor<'de> + for VecMapVisitor +{ + type Value = VecMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid map") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + // Duplicates are not checked during deserialization + let mut vec_map = VecMap::new(); + while let Some(key) = map.next_key::()? { + vec_map.append(key, map.next_value()?); + } + Ok(vec_map) + } +} + +impl<'de, K: Eq + PartialEq + Deserialize<'de>, V: Deserialize<'de>> Deserialize<'de> + for VecMap +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(VecMapVisitor { + phantom: std::marker::PhantomData, + }) + } +} + +impl Serialize for VecMap { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(self.len().into())?; + + for (key, value) in self { + map.serialize_entry(key, value)? + } + + map.end() + } +} diff --git a/crates/utils/src/snowflake.rs b/crates/utils/src/snowflake.rs new file mode 100644 index 0000000..b5e7538 --- /dev/null +++ b/crates/utils/src/snowflake.rs @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime}, +}; + +#[derive(Debug)] +pub struct SnowflakeIdGenerator { + epoch: SystemTime, + sequence: AtomicU64, +} + +const SEQUENCE_LEN: u64 = 12; +const NODE_ID_LEN: u64 = 9; + +const SEQUENCE_MASK: u64 = (1 << SEQUENCE_LEN) - 1; +const NODE_ID_MASK: u64 = (1 << NODE_ID_LEN) - 1; + +pub const MAX_NODE_ID: u16 = NODE_ID_MASK as u16; + +const DEFAULT_EPOCH: u64 = 1632280000; // 52 years after UNIX_EPOCH + +static mut NODE_ID: u64 = 1; +static SEQUENCE_ID: AtomicU64 = AtomicU64::new(0); + +/* + +ID characteristics: + +- 43 bits for milliseconds since January 1st, 2022: 2^43 / (1000 * 60 * 60 * 24 * 365) = 278.92 years (from year 2022 until 2300) +- 9 bits for a node id: 2^9 = 512 nodes +- 12 bits for a sequence number: 2^12 = 4096 ids per millisecond + +*/ + +#[inline(always)] +fn node_id() -> u64 { + unsafe { std::ptr::read_volatile(&raw const NODE_ID) } +} + +impl SnowflakeIdGenerator { + pub fn new() -> Self { + Self { + epoch: SystemTime::UNIX_EPOCH + Duration::from_secs(DEFAULT_EPOCH), // 52 years after UNIX_EPOCH + sequence: 0.into(), + } + } + + pub fn set_node_id(set_node_id: u64) { + let set_node_id = set_node_id & NODE_ID_MASK; + + if set_node_id != node_id() { + unsafe { + NODE_ID = set_node_id; + } + } + } + + pub fn from_duration(period: Duration) -> Option { + (SystemTime::UNIX_EPOCH + Duration::from_secs(DEFAULT_EPOCH)) + .elapsed() + .ok() + .map(|elapsed| { + (elapsed.saturating_sub(period).as_millis() as u64) << (SEQUENCE_LEN + NODE_ID_LEN) + }) + } + + pub fn from_timestamp(timestamp: u64) -> Option { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .and_then(|now| now.as_secs().checked_sub(timestamp)) + .and_then(|diff| Self::from_duration(Duration::from_secs(diff))) + } + + pub fn global_id_from_timestamp(timestamp: u64) -> Option { + let sequence = SEQUENCE_ID.fetch_add(1, Ordering::Relaxed) & SEQUENCE_MASK; + Self::from_timestamp(timestamp).map(|id| id | (sequence << NODE_ID_LEN) | node_id()) + } + + pub fn global_id() -> Option { + let sequence = SEQUENCE_ID.fetch_add(1, Ordering::Relaxed) & SEQUENCE_MASK; + + (SystemTime::UNIX_EPOCH + Duration::from_secs(DEFAULT_EPOCH)) + .elapsed() + .ok() + .map(|elapsed| { + ((elapsed.as_millis() as u64) << (SEQUENCE_LEN + NODE_ID_LEN)) + | (sequence << NODE_ID_LEN) + | node_id() + }) + } + + pub fn to_timestamp(id: u64) -> u64 { + (id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH + } + + #[inline(always)] + pub fn past_id(&self, period: Duration) -> Option { + self.epoch.elapsed().ok().map(|elapsed| { + (elapsed.saturating_sub(period).as_millis() as u64) << (SEQUENCE_LEN + NODE_ID_LEN) + }) + } + + pub fn is_valid(&self) -> bool { + self.epoch.elapsed().is_ok() + } + + #[inline(always)] + pub fn generate(&self) -> u64 { + let elapsed = self + .epoch + .elapsed() + .map(|e| e.as_millis()) + .unwrap_or_default() as u64; + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) & SEQUENCE_MASK; + + (elapsed << (SEQUENCE_LEN + NODE_ID_LEN)) | (sequence << NODE_ID_LEN) | node_id() + } +} + +impl Default for SnowflakeIdGenerator { + fn default() -> Self { + Self::new() + } +} + +impl Clone for SnowflakeIdGenerator { + fn clone(&self) -> Self { + Self { + epoch: self.epoch, + sequence: 0.into(), + } + } +} diff --git a/crates/utils/src/template.rs b/crates/utils/src/template.rs new file mode 100644 index 0000000..ba28bb1 --- /dev/null +++ b/crates/utils/src/template.rs @@ -0,0 +1,618 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use std::{hash::Hash, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Template { + pub items: Vec>, + pub size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateItem { + Static(String), + Variable { name: T, escape: bool }, + If { variable: T, block_end: usize }, + ForEach { variable: T, block_end: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Variable> { + Single(V), + Block(Vec>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Variables> { + pub items: AHashMap>, +} + +impl Template { + pub fn parse(mut template: &str) -> Result { + let mut items = Vec::new(); + let mut block_stack = vec![]; + let mut size = 0; + + loop { + if let Some((start, end)) = template.split_once("{{") { + if !start.is_empty() { + items.push(TemplateItem::Static(start.to_string())); + size += start.len(); + } + let (var, rest) = end.split_once("}}").ok_or("Unmatched {{")?; + template = rest; + let var = var.trim(); + if let Some(var_name) = var.strip_prefix("#").map(|v| v.trim()) { + let (is_each, var_name) = if let Some(each) = var_name.strip_prefix("each ") { + (true, each) + } else if let Some(if_cond) = var_name.strip_prefix("if ") { + (false, if_cond) + } else { + return Err(format!("Invalid block start: {}", var_name)); + }; + let var = T::from_str(var_name) + .map_err(|_| format!("Invalid variable: {}", var_name))?; + + block_stack.push((var_name, items.len())); + + if is_each { + items.push(TemplateItem::ForEach { + variable: var, + block_end: 0, + }); + } else { + items.push(TemplateItem::If { + variable: var, + block_end: 0, + }); + } + } else if let Some(var_name) = var.strip_prefix("/").map(|v| v.trim()) { + let (is_each, var_name) = if let Some(each) = var_name.strip_prefix("each ") { + (true, each) + } else if let Some(if_cond) = var_name.strip_prefix("if ") { + (false, if_cond) + } else { + return Err(format!("Invalid block end: {}", var_name)); + }; + + if let Some((expected_name, if_pos)) = block_stack.pop() { + if expected_name != var_name { + return Err(format!( + "Block end does not match start: expected {}, got {}", + expected_name, var_name + )); + } + let block_end_idx = items.len(); + match &mut items[if_pos] { + TemplateItem::If { block_end, .. } if !is_each => { + *block_end = block_end_idx; + } + TemplateItem::ForEach { block_end, .. } if is_each => { + *block_end = block_end_idx; + } + _ => { + return Err(format!( + "Block end does not match start type for {}", + var_name + )); + } + } + } + } else { + let (name, escape) = var.strip_prefix("!").map_or((var, true), |v| (v, false)); + let name = + T::from_str(name).map_err(|_| format!("Invalid variable: {}", name))?; + items.push(TemplateItem::Variable { name, escape }); + } + } else { + if !template.is_empty() { + items.push(TemplateItem::Static(template.to_string())); + size += template.len(); + } + break; + } + } + + if block_stack.is_empty() { + Ok(Template { items, size }) + } else { + Err(format!("Unmatched {{: {}", block_stack.last().unwrap().0)) + } + } + + pub fn eval(&self, variables: &Variables) -> String + where + V: AsRef, + { + let mut result = String::with_capacity(self.size); + let mut items = self.items.iter().enumerate(); + let mut base_offset = 0; + + while let Some((idx, item)) = items.next() { + let idx = idx + base_offset; + match item { + TemplateItem::Static(s) => result.push_str(s), + TemplateItem::Variable { name, escape } => { + if let Some(Variable::Single(variable)) = variables.items.get(name) { + if *escape { + html_escape(&mut result, variable.as_ref()) + } else { + result.push_str(variable.as_ref()); + } + } + } + TemplateItem::If { + variable, + block_end, + } => { + if !variables.items.contains_key(variable) { + items = self.items[*block_end..].iter().enumerate(); + base_offset = *block_end; + } + } + TemplateItem::ForEach { + variable, + block_end, + } => { + if let Some(Variable::Block(entries)) = variables.items.get(variable) { + let slice = &self.items[idx + 1..*block_end]; + for entry in entries { + let mut slice = slice.iter(); + while let Some(sub_item) = slice.next() { + match sub_item { + TemplateItem::Static(s) => result.push_str(s), + TemplateItem::Variable { name, escape } => { + if let Some(variable) = entry.get(name) { + if *escape { + html_escape(&mut result, variable.as_ref()) + } else { + result.push_str(variable.as_ref()); + } + } + } + TemplateItem::If { + variable, + block_end: start_pos, + } if !entry.contains_key(variable) => { + slice = self.items[*start_pos..*block_end].iter(); + } + _ => {} + } + } + } + } + items = self.items[*block_end..].iter().enumerate(); + base_offset = *block_end; + } + } + } + + result + } +} + +fn html_escape(result: &mut String, input: &str) { + for c in input.chars() { + match c { + '&' => result.push_str("&"), + '<' => result.push_str("<"), + '>' => result.push_str(">"), + '"' => result.push_str("""), + '\'' => result.push_str("'"), + _ => result.push(c), + } + } +} + +impl> Variables { + pub fn new() -> Self { + Self { + items: AHashMap::new(), + } + } + + pub fn insert_single(&mut self, key: T, value: V) { + self.items.insert(key, Variable::Single(value)); + } + + pub fn insert_block(&mut self, key: T, value: V1) + where + V1: IntoIterator, + V2: IntoIterator, + { + self.items.insert( + key, + Variable::Block(value.into_iter().map(AHashMap::from_iter).collect()), + ); + } +} + +impl> Default for Variables { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_variable_substitution() { + let template = Template::parse("Hello {{name}}!").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "World".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_multiple_variables() { + let template = Template::parse("{{greeting}} {{name}}, today is {{day}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("greeting".to_string(), "Hello".to_string()); + vars.insert_single("name".to_string(), "Alice".to_string()); + vars.insert_single("day".to_string(), "Monday".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello Alice, today is Monday"); + } + + #[test] + fn test_missing_variable() { + let template = Template::parse("Hello {{name}}!").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, "Hello !"); + } + + #[test] + fn test_static_text_only() { + let template = Template::parse("This is just static text").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, "This is just static text"); + } + + #[test] + fn test_empty_template() { + let template = Template::parse("").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_if_block_with_existing_variable() { + let template = + Template::parse("{{#if show_message}}Hello World!{{/if show_message}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("show_message".to_string(), "true".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_if_block_with_missing_variable() { + let template = + Template::parse("{{#if show_message}}Hello World!{{/if show_message}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_if_block_with_content_and_variables() { + let template = Template::parse( + "{{#if notifications}}You have notifications: {{count}}{{/if notifications}}", + ) + .unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("notifications".to_string(), "true".to_string()); + vars.insert_single("count".to_string(), "5".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "You have notifications: 5"); + } + + #[test] + fn test_foreach_block_basic() { + let template = Template::parse("{{#each items}}{{name}} {{/each items}}").unwrap(); + let mut vars = Variables::::new(); + + let items = vec![ + vec![("name".to_string(), "Item1".to_string())], + vec![("name".to_string(), "Item2".to_string())], + vec![("name".to_string(), "Item3".to_string())], + ]; + vars.insert_block("items".to_string(), items); + + let result = template.eval(&vars); + assert_eq!(result, "Item1 Item2 Item3 "); + } + + #[test] + fn test_foreach_block_multiple_variables() { + let template = Template::parse( + "{{#each notifications}}* {{name}} at {{time}}\n{{/each notifications}}", + ) + .unwrap(); + let mut vars = Variables::::new(); + + let notifications = vec![ + vec![ + ("name".to_string(), "Meeting".to_string()), + ("time".to_string(), "10:00".to_string()), + ], + vec![ + ("name".to_string(), "Call".to_string()), + ("time".to_string(), "14:30".to_string()), + ], + ]; + vars.insert_block("notifications".to_string(), notifications); + + let result = template.eval(&vars); + assert_eq!(result, "* Meeting at 10:00\n* Call at 14:30\n"); + } + + #[test] + fn test_foreach_block_empty() { + let template = Template::parse("{{#each items}}{{name}}{{/each items}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_block("items".to_string(), Vec::>::new()); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_foreach_block_missing_variable() { + let template = Template::parse("{{#each items}}{{name}}{{/each items}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_complex_template_example() { + let template_str = r#"Hello {{name}}, + +{{#if notifications}}You have the following notifications: +{{#each notifications}}* {{name}} at {{time}} +{{/each notifications}}{{/if notifications}} +Best regards"#; + + let template = Template::parse(template_str).unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Alice".to_string()); + vars.insert_single("notifications".to_string(), "true".to_string()); + + let notifications = vec![ + vec![ + ("name".to_string(), "Team Meeting".to_string()), + ("time".to_string(), "09:00".to_string()), + ], + vec![ + ("name".to_string(), "Doctor Appointment".to_string()), + ("time".to_string(), "15:30".to_string()), + ], + ]; + vars.insert_block("notifications".to_string(), notifications); + + let result = template.eval(&vars); + let expected = r#"Hello Alice, + +You have the following notifications: +* Team Meeting at 09:00 +* Doctor Appointment at 15:30 + +Best regards"#; + + assert_eq!(result, expected); + } + + #[test] + fn test_complex_template_no_notifications() { + let template_str = r#"Hello {{name}}, + +{{#if notifications}} +You have the following notifications: +{{#each notifications}} +* {{name}} at {{time}} +{{/each notifications}}{{/if notifications}} +Best regards"#; + + let template = Template::parse(template_str).unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Bob".to_string()); + + let result = template.eval(&vars); + let expected = r#"Hello Bob, + + +Best regards"#; + + assert_eq!(result, expected); + } + + #[test] + fn test_whitespace_handling() { + let template = Template::parse("{{ name }}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Test".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Test"); + } + + #[test] + fn test_whitespace_in_blocks() { + let template = Template::parse("{{# if condition }}Content{{/ if condition }}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("condition".to_string(), "true".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Content"); + } + + // Error handling tests + #[test] + fn test_unmatched_opening_brace() { + let result = Template::::parse("Hello {{name"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unmatched {{")); + } + + #[test] + fn test_invalid_block_start() { + let result = Template::::parse("{{#invalid block}}{{/invalid block}}"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid block start")); + } + + #[test] + fn test_invalid_block_end() { + let result = Template::::parse("{{#if test}}{{\\/invalid block}}"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unmatched")); + } + + #[test] + fn test_mismatched_block_names() { + let result = Template::::parse("{{#if test}}{{/if different}}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Block end does not match start") + ); + } + + #[test] + fn test_mismatched_block_types() { + let result = Template::::parse("{{#if test}}{{/each test}}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Block end does not match start") + ); + } + + #[test] + fn test_consecutive_braces() { + let template = Template::parse("{{}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_foreach_with_missing_inner_variables() { + let template = + Template::parse("{{#each items}}{{name}}: {{missing}}{{/each items}}").unwrap(); + let mut vars = Variables::::new(); + + let items = vec![ + vec![("name".to_string(), "Item1".to_string())], + vec![("name".to_string(), "Item2".to_string())], + ]; + vars.insert_block("items".to_string(), items); + + let result = template.eval(&vars); + assert_eq!(result, "Item1: Item2: "); + } + + /*#[test] + fn test_full() { + // Load static html in memory from resources/email-templates/calendar-alarm.html + let template_str = include_str!("../../../resources/email-templates/calendar-alarm.html"); + let template: Template = Template::parse(template_str).unwrap(); + + let mut vars = Variables::::new(); + vars.insert_single( + CalendarTemplateVariable::PageTitle, + "Test Event".to_string(), + ); + vars.insert_single(CalendarTemplateVariable::Header, "Event Header".to_string()); + vars.insert_single(CalendarTemplateVariable::Footer, "Event Footer".to_string()); + vars.insert_single( + CalendarTemplateVariable::EventTitle, + "Meeting with Team".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::EventDescription, + "Discuss project updates".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::EventDetails, + "Details about the event".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::ActionUrl, + "http://example.com/action".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::ActionName, + "Join Meeting".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::AttendeesTitle, + "Attendees".to_string(), + ); + vars.insert_block( + CalendarTemplateVariable::EventDetails, + vec![ + vec![ + (CalendarTemplateVariable::Key, "Location".to_string()), + ( + CalendarTemplateVariable::Value, + "Conference Room A".to_string(), + ), + ], + vec![ + (CalendarTemplateVariable::Key, "Time".to_string()), + ( + CalendarTemplateVariable::Value, + "10:00 AM - 11:00 AM".to_string(), + ), + ], + ], + ); + vars.insert_block( + CalendarTemplateVariable::Attendees, + vec![ + vec![ + (CalendarTemplateVariable::Key, "Alice".to_string()), + ( + CalendarTemplateVariable::Value, + "alice@domain.org".to_string(), + ), + ], + vec![ + (CalendarTemplateVariable::Key, "Bob".to_string()), + ( + CalendarTemplateVariable::Value, + "bob@domain.org".to_string(), + ), + ], + ], + ); + let result = template.eval(&vars); + // Write result to test.html + std::fs::write("test.html", result).expect("Unable to write file"); + }*/ +} diff --git a/crates/utils/src/tls.rs b/crates/utils/src/tls.rs new file mode 100644 index 0000000..2f3ef8d --- /dev/null +++ b/crates/utils/src/tls.rs @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use rustls::{ + ClientConfig, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, +}; +use rustls_platform_verifier::BuilderVerifierExt; +use std::sync::Arc; +use tokio_rustls::TlsConnector; + +pub fn rustls_client_config(allow_invalid_certs: bool) -> Result { + let config = ClientConfig::builder(); + + if !allow_invalid_certs { + config + .with_platform_verifier() + .map(|config| config.with_no_client_auth()) + .map_err(|err| format!("Failed to build platform verifier: {err}")) + } else { + Ok(config + .dangerous() + .with_custom_certificate_verifier(Arc::new(DummyVerifier {})) + .with_no_client_auth()) + } +} + +pub fn build_tls_connector(allow_invalid_certs: bool) -> Result { + rustls_client_config(allow_invalid_certs) + .map(Arc::new) + .map(TlsConnector::from) +} + +#[derive(Debug)] +struct DummyVerifier; + +impl ServerCertVerifier for DummyVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer<'_>, + _intermediates: &[rustls_pki_types::CertificateDer<'_>], + _server_name: &rustls_pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls_pki_types::UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA1, + SignatureScheme::ECDSA_SHA1_Legacy, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} diff --git a/crates/utils/src/topological.rs b/crates/utils/src/topological.rs new file mode 100644 index 0000000..eb17489 --- /dev/null +++ b/crates/utils/src/topological.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use std::{collections::VecDeque, hash::Hash}; + +#[derive(Debug)] +pub struct TopologicalSort { + edges: AHashMap>, + count: AHashMap, +} + +impl TopologicalSort { + pub fn with_capacity(capacity: usize) -> Self { + Self { + edges: AHashMap::with_capacity(capacity), + count: AHashMap::with_capacity(capacity), + } + } + + pub fn insert(&mut self, from: T, to: T) { + self.count.entry(from).or_insert(0); + self.edges.entry(from).or_default().push(to); + *self.count.entry(to).or_insert(0) += 1; + } + + pub fn into_iterator(mut self) -> TopologicalSortIterator { + let mut no_edges = VecDeque::with_capacity(self.count.len()); + self.count.retain(|node, count| { + if *count == 0 { + no_edges.push_back(*node); + false + } else { + true + } + }); + + TopologicalSortIterator { + edges: self.edges, + count: self.count, + no_edges, + } + } +} + +#[derive(Debug)] +pub struct TopologicalSortIterator { + edges: AHashMap>, + count: AHashMap, + no_edges: VecDeque, +} + +impl Iterator for TopologicalSortIterator { + type Item = T; + + fn next(&mut self) -> Option { + let no_edge = self.no_edges.pop_back()?; + + if let Some(edges) = self.edges.get(&no_edge) { + for neighbor in edges { + if let Some(count) = self.count.get_mut(neighbor) { + *count -= 1; + if *count == 0 { + self.count.remove(neighbor); + self.no_edges.push_front(*neighbor); + } + } + } + } + + Some(no_edge) + } +} + +impl TopologicalSortIterator { + pub fn is_valid(&self) -> bool { + self.count.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_topological_sort() { + let mut sort = TopologicalSort::with_capacity(6); + sort.insert(1, 2); + sort.insert(1, 3); + sort.insert(2, 4); + sort.insert(3, 4); + sort.insert(4, 5); + sort.insert(5, 6); + + let mut iter = sort.into_iterator(); + assert_eq!(iter.next(), Some(1)); + assert_eq!(iter.next(), Some(2)); + assert_eq!(iter.next(), Some(3)); + assert_eq!(iter.next(), Some(4)); + assert_eq!(iter.next(), Some(5)); + assert_eq!(iter.next(), Some(6)); + assert_eq!(iter.next(), None); + assert!(iter.is_valid(), "{:?}", iter); + } + + #[test] + fn test_topological_sort_cycle() { + let mut sort = TopologicalSort::with_capacity(6); + sort.insert(1, 2); + sort.insert(2, 3); + sort.insert(3, 1); + + let mut iter = sort.into_iterator(); + assert_eq!(iter.next(), None); + assert!(!iter.is_valid()); + } +} diff --git a/crates/utils/src/url_params.rs b/crates/utils/src/url_params.rs new file mode 100644 index 0000000..a1b2bca --- /dev/null +++ b/crates/utils/src/url_params.rs @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, collections::HashMap}; + +#[derive(Default)] +pub struct UrlParams<'x> { + params: HashMap, Cow<'x, str>>, +} + +impl<'x> UrlParams<'x> { + pub fn new(query: Option<&'x str>) -> Self { + if let Some(query) = query { + Self { + params: form_urlencoded::parse(query.as_bytes()) + .filter(|(_, value)| !value.is_empty()) + .collect(), + } + } else { + Self::default() + } + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.params.get(key).map(|v| v.as_ref()) + } + + pub fn has_key(&self, key: &str) -> bool { + self.params.contains_key(key) + } + + pub fn parse(&self, key: &str) -> Option + where + T: std::str::FromStr, + { + self.get(key).and_then(|v| v.parse().ok()) + } + + pub fn into_inner(self) -> HashMap, Cow<'x, str>> { + self.params + } +} diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..ec490a3 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,50 @@ +variable "TARGET" { + default = "$TARGET" +} +variable "GHCR_REPO" { + default = "$GHCR_REPO" +} +variable "BUILD_ENV" { + default = "$BUILD_ENV" +} +variable "SUFFIX" { + default = "$SUFFIX" +} +variable "DOCKER_PLATFORM" { + default = "$DOCKER_PLATFORM" +} +target "docker-metadata-action" {} +target "build" { + secret = [ + "type=env,id=ACTIONS_RESULTS_URL", + "type=env,id=ACTIONS_RUNTIME_TOKEN" + ] + args = { + TARGET = "${TARGET}" + BUILD_ENV = equal("", "${BUILD_ENV}") ? null : "${BUILD_ENV}" + } + target = "binaries" + cache-from = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET}" + ] + cache-to = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET},mode=max,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true,image-manifest=false" + ] + context = "./" + dockerfile = "Dockerfile.build" + output = ["./artifact"] +} +target "image" { + inherits = ["build","docker-metadata-action"] + cache-to = [""] + cache-from = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET}" + ] + target = equal("", "${SUFFIX}") ? "gnu" : "musl" + platforms = [ + "${DOCKER_PLATFORM}" + ] + output = [ + "" + ] +} diff --git a/img/demo.gif b/img/demo.gif new file mode 100644 index 0000000..861577e Binary files /dev/null and b/img/demo.gif differ diff --git a/img/logo-red.svg b/img/logo-red.svg new file mode 100644 index 0000000..eb8888a --- /dev/null +++ b/img/logo-red.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e362be2 --- /dev/null +++ b/install.sh @@ -0,0 +1,1069 @@ +#!/usr/bin/env sh +# shellcheck shell=dash + +# +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL +# + +# Stalwart install script -- based on the rustup installation script. + +set -e +set -u + +readonly BASE_URL="https://github.com/stalwartlabs/stalwart/releases/latest/download" + +main() { + downloader --check + need_cmd uname + need_cmd mktemp + need_cmd chmod + need_cmd chown + need_cmd mkdir + need_cmd rm + need_cmd tar + need_cmd cp + need_cmd hostname + + # Require root + if [ "$(id -u)" -ne 0 ]; then + err "❌ Install failed: This program needs to run as root." + fi + + # Detect OS + local _os _uname _account + _uname="$(uname)" + case "$_uname" in + Linux) _os="linux"; _account="stalwart" ;; + Darwin) _os="macos"; _account="_stalwart" ;; + FreeBSD) _os="freebsd"; _account="stalwart" ;; + *) err "❌ Install failed: Unsupported OS: $_uname" ;; + esac + + # Parse arguments + local _component="stalwart" + local _prefix="" + while [ $# -gt 0 ]; do + case "$1" in + --fdb) + _component="stalwart-foundationdb" + ;; + -h|--help) + print_usage + exit 0 + ;; + --*|-*) + err "❌ Unknown flag: $1 (try --help)" + ;; + *) + if [ -n "$_prefix" ]; then + err "❌ Only one prefix argument is allowed, got: $_prefix $1" + fi + _prefix="$1" + ;; + esac + shift + done + + # Derive install paths — FHS by default, self-contained under a custom prefix + local _bin_dir _bin_file _conf_dir _log_dir _data_dir _env_file _config_file + if [ -z "$_prefix" ]; then + _bin_dir="/usr/local/bin" + _log_dir="/var/log/stalwart" + if [ "$_os" = "freebsd" ]; then + # hier(7): third-party config lives under /usr/local/etc, + # variable data under /var/db + _conf_dir="/usr/local/etc/stalwart" + _data_dir="/var/db/stalwart" + else + _conf_dir="/etc/stalwart" + _data_dir="/var/lib/stalwart" + fi + else + _bin_dir="${_prefix}/bin" + _conf_dir="${_prefix}/etc" + _log_dir="${_prefix}/logs" + _data_dir="${_prefix}/data" + fi + _bin_file="${_bin_dir}/stalwart" + _config_file="${_conf_dir}/config.json" + _env_file="${_conf_dir}/stalwart.env" + + # Detect architecture + get_architecture || return 1 + local _arch="$RETVAL" + assert_nz "$_arch" "arch" + + # Create service account + create_account "$_os" "$_account" + + # Create directories + ensure mkdir -p "$_bin_dir" "$_conf_dir" "$_log_dir" "$_data_dir" + + # Download and install the binary + say "⏳ Downloading ${_component} for ${_arch}..." + local _tmp _tar _src_name + _tmp="$(mktemp -d)" + _tar="${_tmp}/stalwart.tar.gz" + ensure downloader "${BASE_URL}/${_component}-${_arch}.tar.gz" "$_tar" "$_arch" + ensure tar zxf "$_tar" -C "$_tmp" + _src_name="stalwart" + if [ "$_component" = "stalwart-foundationdb" ]; then + _src_name="stalwart-foundationdb" + fi + ensure cp "${_tmp}/${_src_name}" "$_bin_file" + ensure chmod 0755 "$_bin_file" + ensure rm -rf "$_tmp" + + # Create env file if absent (preserve user edits on reinstall) + if [ ! -e "$_env_file" ]; then + say "📝 Writing env file at ${_env_file}..." + write_env_file "$_env_file" + fi + + # Ownership and permissions + say "🔐 Setting permissions..." + ensure chown "${_account}:${_account}" "$_conf_dir" "$_log_dir" "$_data_dir" + ensure chmod 0750 "$_conf_dir" "$_log_dir" "$_data_dir" + ensure chown "root:${_account}" "$_env_file" + ensure chmod 0640 "$_env_file" + + # Install and start the service + say "🚀 Starting service..." + local _service_type="" + case "$_os" in + linux) + if check_cmd systemctl; then + create_service_linux_systemd "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="systemd" + else + create_service_linux_initd "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="initd" + fi + ;; + macos) + create_service_macos "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="launchd" + ;; + freebsd) + create_service_freebsd "$_bin_file" "$_config_file" "$_env_file" "$_account" "$_log_dir" + _service_type="rcd" + ;; + esac + + # Completion message + local _host + _host="$(hostname -f 2>/dev/null || hostname)" + say "" + say "🎉 Installation complete!" + say "" + say "Stalwart is running in bootstrap mode. A temporary administrator" + say "password was generated at startup and printed to the service logs." + say "" + say "👉 To find the password, inspect the service logs:" + case "$_service_type" in + systemd) + say " journalctl -u stalwart -n 200 | grep -A8 'bootstrap mode'" + ;; + initd) + say " grep -A8 'bootstrap mode' /var/log/syslog 2>/dev/null \\" + say " || grep -A8 'bootstrap mode' /var/log/messages" + ;; + launchd) + say " sudo log show --predicate 'process == \"stalwart\"' --last 5m" + ;; + rcd) + say " grep -A8 'bootstrap mode' ${_log_dir}/stalwart.log" + ;; + esac + say "" + say " Or set STALWART_RECOVERY_ADMIN=admin: in" + say " ${_env_file} and restart the service to pin a credential." + say "" + say " Finish setup at: http://${_host}:8080/admin" + say "" + + return 0 +} + +print_usage() { + cat <<'EOF' +Usage: install.sh [--fdb] [PREFIX] + +Install Stalwart into standard FHS paths or under a custom prefix. + +Options: + --fdb Install the FoundationDB build. + -h, --help Show this help. + +With no PREFIX, Stalwart is installed under standard FHS paths: + binary /usr/local/bin/stalwart + config /etc/stalwart/config.json (/usr/local/etc/stalwart/config.json on FreeBSD) + env /etc/stalwart/stalwart.env (/usr/local/etc/stalwart/stalwart.env on FreeBSD) + logs /var/log/stalwart/ + data /var/lib/stalwart/ (/var/db/stalwart on FreeBSD) + +When PREFIX is provided, a self-contained layout is used instead: + binary $PREFIX/bin/stalwart + config $PREFIX/etc/config.json + env $PREFIX/etc/stalwart.env + logs $PREFIX/logs/ + data $PREFIX/data/ +EOF +} + +write_env_file() { + cat > "$1" <<'EOF' +# Environment variables for the Stalwart service. +# Uncomment and edit an entry to override its default. + +# Override the hostname used in HTTP responses +#STALWART_HOSTNAME=mail.example.com + +# Override the public base URL published in OAuth, OIDC, and JMAP discovery +# documents. Accepts scheme, host, optional port, and optional path prefix. +#STALWART_PUBLIC_URL=https://mail.example.com + +# Enable bootstrap / recovery mode on startup. Accepted: 1, true. Default: false. +#STALWART_RECOVERY_MODE=true + +# Log level while in recovery mode. Default: info. +#STALWART_RECOVERY_MODE_LOG_LEVEL=debug + +# HTTP port used in recovery mode. Default: 8080. +#STALWART_RECOVERY_MODE_PORT=9090 + +# Fixed administrator credentials — format: username:password +# Default: a temporary random password is generated and printed to the logs. +#STALWART_RECOVERY_ADMIN=admin:changeme + +# Cluster role assigned to this node. Must match a role name defined in the +# cluster registry. Leave unset for a standalone (non-clustered) deployment. +#STALWART_ROLE=primary + +# Push-notification shard this node is responsible for, when running in a +# cluster. +#STALWART_PUSH_SHARD=1 +EOF +} + +create_account() { + local _os="$1" + local _account="$2" + if id -u "$_account" > /dev/null 2>&1; then + return 0 + fi + say "🖥️ Creating '${_account}' account..." + if [ "$_os" = "macos" ]; then + local _last_uid _last_gid _uid _gid + _last_uid="$(dscacheutil -q user | grep uid | awk '{print $2}' | sort -n | tail -n 1)" + _last_gid="$(dscacheutil -q group | grep gid | awk '{print $2}' | sort -n | tail -n 1)" + _uid="$((_last_uid+1))" + _gid="$((_last_gid+1))" + + ensure dscl /Local/Default -create Groups/_stalwart + ensure dscl /Local/Default -create Groups/_stalwart Password \* + ensure dscl /Local/Default -create Groups/_stalwart PrimaryGroupID $_gid + ensure dscl /Local/Default -create Groups/_stalwart RealName "Stalwart service" + ensure dscl /Local/Default -create Groups/_stalwart RecordName _stalwart stalwart + + ensure dscl /Local/Default -create Users/_stalwart + ensure dscl /Local/Default -create Users/_stalwart NFSHomeDirectory /var/empty + ensure dscl /Local/Default -create Users/_stalwart Password \* + ensure dscl /Local/Default -create Users/_stalwart PrimaryGroupID $_gid + ensure dscl /Local/Default -create Users/_stalwart RealName "Stalwart service" + ensure dscl /Local/Default -create Users/_stalwart RecordName _stalwart stalwart + ensure dscl /Local/Default -create Users/_stalwart UniqueID $_uid + ensure dscl /Local/Default -create Users/_stalwart UserShell /usr/bin/false + + ensure dscl /Local/Default -delete /Users/_stalwart AuthenticationAuthority + ensure dscl /Local/Default -delete /Users/_stalwart PasswordPolicyOptions + elif [ "$_os" = "freebsd" ]; then + ensure pw useradd -n "$_account" -c "Stalwart service" -d /nonexistent -s /usr/sbin/nologin -w no + else + ensure useradd "$_account" -s /usr/sbin/nologin -M -r -U + fi +} + +create_service_linux_systemd() { + local _bin="$1" _config="$2" _env="$3" _user="$4" + cat > /etc/systemd/system/stalwart.service < /etc/init.d/stalwart < /dev/null \\ + || return 1 + start-stop-daemon --start --quiet --pidfile \$PIDFILE --exec \$DAEMON \\ + --background --make-pidfile --chuid ${_user}:${_user} \\ + -- \$DAEMON_ARGS \\ + || return 2 +} + +do_stop() +{ + start-stop-daemon --stop --quiet --retry=INT/30/KILL/5 --pidfile \$PIDFILE --name stalwart + RETVAL="\$?" + [ "\$RETVAL" = 2 ] && return 2 + start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec \$DAEMON + [ "\$?" = 2 ] && return 2 + rm -f \$PIDFILE + return "\$RETVAL" +} + +case "\$1" in + start) + [ "\$VERBOSE" != no ] && log_daemon_msg "Starting Stalwart Server" "stalwart" + do_start + case "\$?" in + 0|1) [ "\$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "\$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "\$VERBOSE" != no ] && log_daemon_msg "Stopping Stalwart Server" "stalwart" + do_stop + case "\$?" in + 0|1) [ "\$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "\$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "\$DAEMON" "stalwart" && exit 0 || exit \$? + ;; + restart) + log_daemon_msg "Restarting Stalwart Server" "stalwart" + do_stop + case "\$?" in + 0|1) + do_start + case "\$?" in + 0) log_end_msg 0 ;; + *) log_end_msg 1 ;; + esac + ;; + *) + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: /etc/init.d/stalwart {start|stop|status|restart}" >&2 + exit 3 + ;; +esac + +exit 0 +EOF + chmod +x /etc/init.d/stalwart + update-rc.d stalwart defaults + service stalwart start +} + +create_service_macos() { + local _bin="$1" _config="$2" _env="$3" _user="$4" + local _plist="/Library/LaunchDaemons/stalwart.plist" + + # Remove any legacy LaunchDaemons from a prior install + if [ -f "$_plist" ]; then + launchctl bootout system/ "$_plist" 2>/dev/null || true + rm -f "$_plist" + fi + + # launchd has no EnvironmentFile equivalent — wrap with sh to source the env file + cat > "$_plist" < + + + + Label + stalwart + ServiceDescription + Stalwart + UserName + ${_user} + GroupName + ${_user} + ProgramArguments + + /bin/sh + -c + set -a; if [ -r "${_env}" ]; then . "${_env}"; fi; set +a; exec "${_bin}" --config="${_config}" + + RunAtLoad + + KeepAlive + + + +EOF + chmod 0644 "$_plist" + chown root:wheel "$_plist" + launchctl bootout system/ "$_plist" 2>/dev/null || true + launchctl bootstrap system/ "$_plist" +} + +create_service_freebsd() { + local _bin="$1" _config="$2" _env="$3" _user="$4" _log_dir="$5" + ensure mkdir -p /usr/local/etc/rc.d + cat > /usr/local/etc/rc.d/stalwart < /dev/null 2>&1 || true + service stalwart start +} + + +get_architecture() { + local _ostype _cputype _bitness _arch _clibtype + _ostype="$(uname -s)" + _cputype="$(uname -m)" + _clibtype="gnu" + + if [ "$_ostype" = Linux ]; then + if [ "$(uname -o)" = Android ]; then + _ostype=Android + fi + if ldd --version 2>&1 | grep -q 'musl'; then + _clibtype="musl" + fi + fi + + if [ "$_ostype" = Darwin ] && [ "$_cputype" = i386 ]; then + # Darwin `uname -m` lies + if sysctl hw.optional.x86_64 | grep -q ': 1'; then + _cputype=x86_64 + fi + fi + + if [ "$_ostype" = SunOS ]; then + # Both Solaris and illumos presently announce as "SunOS" in "uname -s" + # so use "uname -o" to disambiguate. We use the full path to the + # system uname in case the user has coreutils uname first in PATH, + # which has historically sometimes printed the wrong value here. + if [ "$(/usr/bin/uname -o)" = illumos ]; then + _ostype=illumos + fi + + # illumos systems have multi-arch userlands, and "uname -m" reports the + # machine hardware name; e.g., "i86pc" on both 32- and 64-bit x86 + # systems. Check for the native (widest) instruction set on the + # running kernel: + if [ "$_cputype" = i86pc ]; then + _cputype="$(isainfo -n)" + fi + fi + + case "$_ostype" in + + Android) + _ostype=linux-android + ;; + + Linux) + check_proc + _ostype=unknown-linux-$_clibtype + _bitness=$(get_bitness) + ;; + + FreeBSD) + _ostype=unknown-freebsd + ;; + + NetBSD) + _ostype=unknown-netbsd + ;; + + DragonFly) + _ostype=unknown-dragonfly + ;; + + Darwin) + _ostype=apple-darwin + ;; + + illumos) + _ostype=unknown-illumos + ;; + + MINGW* | MSYS* | CYGWIN* | Windows_NT) + _ostype=pc-windows-gnu + ;; + + *) + err "unrecognized OS type: $_ostype" + ;; + + esac + + case "$_cputype" in + + i386 | i486 | i686 | i786 | x86) + _cputype=i686 + ;; + + xscale | arm) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + fi + ;; + + armv6l) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + armv7l | armv8l) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + aarch64 | arm64) + _cputype=aarch64 + ;; + + x86_64 | x86-64 | x64 | amd64) + _cputype=x86_64 + ;; + + mips) + _cputype=$(get_endianness mips '' el) + ;; + + mips64) + if [ "$_bitness" -eq 64 ]; then + # only n64 ABI is supported for now + _ostype="${_ostype}abi64" + _cputype=$(get_endianness mips64 '' el) + fi + ;; + + ppc) + _cputype=powerpc + ;; + + ppc64) + _cputype=powerpc64 + ;; + + ppc64le) + _cputype=powerpc64le + ;; + + s390x) + _cputype=s390x + ;; + riscv64) + _cputype=riscv64gc + ;; + *) + err "unknown CPU type: $_cputype" + + esac + + # Detect 64-bit linux with 32-bit userland + if [ "${_ostype}" = unknown-linux-gnu ] && [ "${_bitness}" -eq 32 ]; then + case $_cputype in + x86_64) + if [ -n "${RUSTUP_CPUTYPE:-}" ]; then + _cputype="$RUSTUP_CPUTYPE" + else { + # 32-bit executable for amd64 = x32 + if is_host_amd64_elf; then { + echo "This host is running an x32 userland; as it stands, x32 support is poor," 1>&2 + echo "and there isn't a native toolchain -- you will have to install" 1>&2 + echo "multiarch compatibility with i686 and/or amd64, then select one" 1>&2 + echo "by re-running this script with the RUSTUP_CPUTYPE environment variable" 1>&2 + echo "set to i686 or x86_64, respectively." 1>&2 + echo 1>&2 + echo "You will be able to add an x32 target after installation by running" 1>&2 + echo " rustup target add x86_64-unknown-linux-gnux32" 1>&2 + exit 1 + }; else + _cputype=i686 + fi + }; fi + ;; + mips64) + _cputype=$(get_endianness mips '' el) + ;; + powerpc64) + _cputype=powerpc + ;; + aarch64) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + riscv64gc) + err "riscv64 with 32-bit userland unsupported" + ;; + esac + fi + + # Detect armv7 but without the CPU features Rust needs in that build, + # and fall back to arm. + # See https://github.com/rust-lang/rustup.rs/issues/587. + if [ "$_ostype" = "unknown-linux-gnueabihf" ] && [ "$_cputype" = armv7 ]; then + if ensure grep '^Features' /proc/cpuinfo | grep -q -v neon; then + # At least one processor does not have NEON. + _cputype=arm + fi + fi + + _arch="${_cputype}-${_ostype}" + + RETVAL="$_arch" +} + +check_proc() { + # Check for /proc by looking for the /proc/self/exe link + # This is only run on Linux + if ! test -L /proc/self/exe ; then + err "fatal: Unable to find /proc/self/exe. Is /proc mounted? Installation cannot proceed without /proc." + fi +} + +get_bitness() { + need_cmd head + # Architecture detection without dependencies beyond coreutils. + # ELF files start out "\x7fELF", and the following byte is + # 0x01 for 32-bit and + # 0x02 for 64-bit. + # The printf builtin on some shells like dash only supports octal + # escape sequences, so we use those. + local _current_exe_head + _current_exe_head=$(head -c 5 /proc/self/exe ) + if [ "$_current_exe_head" = "$(printf '\177ELF\001')" ]; then + echo 32 + elif [ "$_current_exe_head" = "$(printf '\177ELF\002')" ]; then + echo 64 + else + err "unknown platform bitness" + fi +} + +is_host_amd64_elf() { + need_cmd head + need_cmd tail + # ELF e_machine detection without dependencies beyond coreutils. + # Two-byte field at offset 0x12 indicates the CPU, + # but we're interested in it being 0x3E to indicate amd64, or not that. + local _current_exe_machine + _current_exe_machine=$(head -c 19 /proc/self/exe | tail -c 1) + [ "$_current_exe_machine" = "$(printf '\076')" ] +} + +get_endianness() { + local cputype=$1 + local suffix_eb=$2 + local suffix_el=$3 + + # detect endianness without od/hexdump, like get_bitness() does. + need_cmd head + need_cmd tail + + local _current_exe_endianness + _current_exe_endianness="$(head -c 6 /proc/self/exe | tail -c 1)" + if [ "$_current_exe_endianness" = "$(printf '\001')" ]; then + echo "${cputype}${suffix_el}" + elif [ "$_current_exe_endianness" = "$(printf '\002')" ]; then + echo "${cputype}${suffix_eb}" + else + err "unknown platform endianness" + fi +} + +say() { + printf '%s\n' "$1" +} + +err() { + say "$1" >&2 + exit 1 +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +assert_nz() { + if [ -z "$1" ]; then err "assert_nz $2"; fi +} + +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing +# command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +# This wraps curl or wget. Try curl first, if not installed, +# use wget instead. +downloader() { + local _dld + local _ciphersuites + local _err + local _status + local _retry + if check_cmd curl; then + _dld=curl + elif check_cmd wget; then + _dld=wget + else + _dld='curl or wget' # to be used in error message of need_cmd + fi + + if [ "$1" = --check ]; then + need_cmd "$_dld" + elif [ "$_dld" = curl ]; then + check_curl_for_retry_support + _retry="$RETVAL" + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" curl --proto --tlsv1.2; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q 404; then + err "❌ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + elif [ "$_dld" = wget ]; then + if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then + echo "Warning: using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + get_ciphersuites_for_wget + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" wget --https-only --secure-protocol; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _status=$? + fi + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q ' 404 Not Found'; then + err "❌ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + else + err "Unknown downloader" # should not reach here + fi +} + +# Check if curl supports the --retry flag, then pass it to the curl invocation. +check_curl_for_retry_support() { + local _retry_supported="" + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--retry"; then + _retry_supported="--retry 3" + fi + + RETVAL="$_retry_supported" + +} + +check_help_for() { + local _arch + local _cmd + local _arg + _arch="$1" + shift + _cmd="$1" + shift + + local _category + if "$_cmd" --help | grep -q 'For all options use the manual or "--help all".'; then + _category="all" + else + _category="" + fi + + case "$_arch" in + + *darwin*) + if check_cmd sw_vers; then + case $(sw_vers -productVersion) in + 10.*) + # If we're running on macOS, older than 10.13, then we always + # fail to find these options to force fallback + if [ "$(sw_vers -productVersion | cut -d. -f2)" -lt 13 ]; then + # Older than 10.13 + echo "Warning: Detected macOS platform older than 10.13" + return 1 + fi + ;; + 11.*) + # We assume Big Sur will be OK for now + ;; + *) + # Unknown product version, warn and continue + echo "Warning: Detected unknown macOS major version: $(sw_vers -productVersion)" + echo "Warning TLS capabilities detection may fail" + ;; + esac + fi + ;; + + esac + + for _arg in "$@"; do + if ! "$_cmd" --help $_category | grep -q -- "$_arg"; then + return 1 + fi + done + + true # not strictly needed +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these curl backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_curl() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _openssl_syntax="no" + local _gnutls_syntax="no" + local _backend_supported="yes" + if curl -V | grep -q ' OpenSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' LibreSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' BoringSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' GnuTLS/'; then + _gnutls_syntax="yes" + else + _backend_supported="no" + fi + + local _args_supported="no" + if [ "$_backend_supported" = "yes" ]; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--tlsv1.2" "--ciphers" "--proto"; then + _args_supported="yes" + fi + fi + + local _cs="" + if [ "$_args_supported" = "yes" ]; then + if [ "$_openssl_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "openssl") + elif [ "$_gnutls_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these wget backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_wget() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _cs="" + if wget -V | grep -q '\-DHAVE_LIBSSL'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "openssl") + fi + elif wget -V | grep -q '\-DHAVE_LIBGNUTLS'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return strong TLS 1.2-1.3 cipher suites in OpenSSL or GnuTLS syntax. TLS 1.2 +# excludes non-ECDHE and non-AEAD cipher suites. DHE is excluded due to bad +# DH params often found on servers (see RFC 7919). Sequence matches or is +# similar to Firefox 68 ESR with weak cipher suites disabled via about:config. +# $1 must be openssl or gnutls. +get_strong_ciphersuites_for() { + if [ "$1" = "openssl" ]; then + # OpenSSL is forgiving of unknown values, no problems with TLS 1.3 values on versions that don't support it yet. + echo "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384" + elif [ "$1" = "gnutls" ]; then + # GnuTLS isn't forgiving of unknown values, so this may require a GnuTLS version that supports TLS 1.3 even if wget doesn't. + # Begin with SECURE128 (and higher) then remove/add to build cipher suites. Produces same 9 cipher suites as OpenSSL but in slightly different order. + echo "SECURE128:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-DTLS-ALL:-CIPHER-ALL:-MAC-ALL:-KX-ALL:+AEAD:+ECDHE-ECDSA:+ECDHE-RSA:+AES-128-GCM:+CHACHA20-POLY1305:+AES-256-GCM" + fi +} + +# This is just for indicating that commands' results are being +# intentionally ignored. Usually, because it's being executed +# as part of error handling. +ignore() { + "$@" +} + +main "$@" || exit 1 diff --git a/resources/apparmor.d/stalwart-mail b/resources/apparmor.d/stalwart-mail new file mode 100644 index 0000000..f563931 --- /dev/null +++ b/resources/apparmor.d/stalwart-mail @@ -0,0 +1,59 @@ +#include + +profile stalwart flags=(attach_disconnected) { + #include + #include + #include + + # Allow network access + network inet stream, + network inet6 stream, + network inet dgram, + network inet6 dgram, + + # Outgoing access to port 25 and 443 + network tcp, + network udp, + owner /proc/*/net/if_inet6 r, + owner /proc/*/net/ipv6_route r, + + # Full write access to /opt/stalwart + /opt/stalwart/** rwk, + + # Allow creating directories under /tmp + /tmp/ r, + /tmp/** rwk, + + # Allow binding to specific ports + network inet stream bind port 25, + network inet stream bind port 587, + network inet stream bind port 465, + network inet stream bind port 143, + network inet stream bind port 993, + network inet stream bind port 110, + network inet stream bind port 995, + network inet stream bind port 4190, + network inet stream bind port 443, + network inet stream bind port 8080, + network inet6 stream bind port 25, + network inet6 stream bind port 587, + network inet6 stream bind port 465, + network inet6 stream bind port 143, + network inet6 stream bind port 993, + network inet6 stream bind port 110, + network inet6 stream bind port 995, + network inet6 stream bind port 4190, + network inet6 stream bind port 443, + network inet6 stream bind port 8080, + + # Allow UDP port 7911 + network inet dgram bind port 7911, + network inet6 dgram bind port 7911, + + # Basic system access + /usr/bin/stalwart rix, + /etc/stalwart/** r, + /var/log/stalwart/** w, + + # Additional permissions might be needed depending on specific requirements +} diff --git a/resources/html-templates/calendar-alarm.html b/resources/html-templates/calendar-alarm.html new file mode 100644 index 0000000..01438f2 --- /dev/null +++ b/resources/html-templates/calendar-alarm.html @@ -0,0 +1,218 @@ + + + + + + + + + + + {{page_title}} + + + + + + +
+ {{page_title}}
+ + + + + +
+ + + + + + + + + + + + {{#if header}} + + + + {{/if header}} + + {{#if event_title}} + + + + {{/if event_title}} + + {{#if event_description}} + + + + {{/if event_description}} + + {{#if event_details}} + + + + {{/if event_details}} + + {{#if attendees}} + + + + {{/if attendees}} + + + + + + + + + + + {{#if footer}} + + + + + + + + + {{/if footer}} + +
+ + + diff --git a/resources/html-templates/calendar-alarm.html.min b/resources/html-templates/calendar-alarm.html.min new file mode 100644 index 0000000..22896e3 --- /dev/null +++ b/resources/html-templates/calendar-alarm.html.min @@ -0,0 +1,5 @@ + {{page_title}}
{{page_title}}
{{#if header}} {{/if header}} {{#if event_title}} {{/if event_title}} {{#if event_description}} {{/if event_description}} {{#if event_details}} {{/if event_details}} {{#if attendees}} {{/if attendees}} {{#if footer}} {{/if footer}}
\ No newline at end of file diff --git a/resources/html-templates/calendar-invite.html b/resources/html-templates/calendar-invite.html new file mode 100644 index 0000000..c231faf --- /dev/null +++ b/resources/html-templates/calendar-invite.html @@ -0,0 +1,299 @@ + + + + + + + + + + + {{page_title}} + + + + + + +
+ {{page_title}}
+ + + + + +
+ + + + + + + + + + + + {{#if header}} + + + + {{/if header}} + + {{#if event_title}} + + + + {{/if event_title}} + + {{#if event_description}} + + + + {{/if event_description}} + + {{#if event_details}} + + + + {{/if event_details}} + + {{#if attendees}} + + + + {{/if attendees}} + + {{#if rsvp}} + + + + + + + + + + {{/if rsvp}} + + + + + + + {{#if footer}} + + {{#each footer}} + + + + {{/each footer}} + + + + + {{/if footer}} + +
+ + + diff --git a/resources/html-templates/calendar-invite.html.min b/resources/html-templates/calendar-invite.html.min new file mode 100644 index 0000000..3566d57 --- /dev/null +++ b/resources/html-templates/calendar-invite.html.min @@ -0,0 +1,5 @@ + {{page_title}}
{{page_title}}
{{#if header}} {{/if header}} {{#if event_title}} {{/if event_title}} {{#if event_description}} {{/if event_description}} {{#if event_details}} {{/if event_details}} {{#if attendees}} {{/if attendees}} {{#if rsvp}} {{/if rsvp}} {{#if footer}} {{#each footer}} {{/each footer}} {{/if footer}}
\ No newline at end of file diff --git a/resources/html-templates/calendar-rsvp.html b/resources/html-templates/calendar-rsvp.html new file mode 100644 index 0000000..430dc15 --- /dev/null +++ b/resources/html-templates/calendar-rsvp.html @@ -0,0 +1,1155 @@ + + + + + + + + + Respond to invitation + + + + +
+ + + + + diff --git a/resources/html-templates/calendar-rsvp.html.min b/resources/html-templates/calendar-rsvp.html.min new file mode 100644 index 0000000..d168707 --- /dev/null +++ b/resources/html-templates/calendar-rsvp.html.min @@ -0,0 +1 @@ + Respond to invitation
\ No newline at end of file diff --git a/resources/html-templates/calendar-rsvp.html.min.gz b/resources/html-templates/calendar-rsvp.html.min.gz new file mode 100644 index 0000000..a76d480 Binary files /dev/null and b/resources/html-templates/calendar-rsvp.html.min.gz differ diff --git a/resources/html-templates/login.html b/resources/html-templates/login.html new file mode 100644 index 0000000..f0d1fad --- /dev/null +++ b/resources/html-templates/login.html @@ -0,0 +1,606 @@ + + + + + + + + Sign in + + + + +
+
+ +
+

Sign in

+

Enter your credentials to continue

+ + + +
+
+ + +
+
+ + +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/resources/html-templates/login.html.min b/resources/html-templates/login.html.min new file mode 100644 index 0000000..1f43913 --- /dev/null +++ b/resources/html-templates/login.html.min @@ -0,0 +1 @@ + Sign in

Sign in

Enter your credentials to continue

\ No newline at end of file diff --git a/resources/html-templates/login.html.min.gz b/resources/html-templates/login.html.min.gz new file mode 100644 index 0000000..80d26e8 Binary files /dev/null and b/resources/html-templates/login.html.min.gz differ diff --git a/resources/locales/i18n.yml b/resources/locales/i18n.yml new file mode 100644 index 0000000..1893220 --- /dev/null +++ b/resources/locales/i18n.yml @@ -0,0 +1,2771 @@ +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + +calendar.alarm_subject_prefix: + en-US: Notification + es-ES: Notificación + fr-FR: Notification + de-DE: Benachrichtigung + it-IT: Notifica + pt-PT: Notificação + nl-NL: Melding + da-DK: Notifikation + ca-ES: Notificació + el-GR: Ειδοποίηση + sv-SE: Notifikation + pl-PL: Powiadomienie + pt-BR: Notificação + ru-RU: Уведомление + uk-UA: Сповіщення + bg-BG: Известие + cs-CZ: Oznámení + sk-SK: Oznámenie + sl-SI: Obvestilo + hr-HR: Obavijest + lt-LT: Pranešimas + hu-HU: Értesítés + ro-RO: Notificare + fi-FI: Ilmoitus + nb-NO: Varsel + tr-TR: Bildirim + zh-CN: 通知 + zh-TW: 通知 + ja-JP: 通知 + ko-KR: 알림 + th-TH: การแจ้งเตือน + vi-VN: Thông báo + id-ID: Notifikasi + hi-IN: सूचना + ar-SA: إشعار + he-IL: התראה + fa-IR: اعلان + +calendar.alarm_header: + en-US: You have an upcoming event + es-ES: Tienes un evento próximo + fr-FR: Vous avez un événement à venir + de-DE: Sie haben einen bevorstehenden Termin + it-IT: Hai un evento in programma + pt-PT: Tem um evento próximo + nl-NL: U heeft een aankomende gebeurtenis + da-DK: Du har en kommende begivenhed + ca-ES: Tens un esdeveniment pròxim + el-GR: Έχετε μία επερχόμενη εκδήλωση + sv-SE: Du har en kommande händelse + pl-PL: Masz nadchodzące wydarzenie + pt-BR: Você tem um evento próximo + ru-RU: У вас предстоящее событие + uk-UA: У вас незабаром подія + bg-BG: Имате предстоящо събитие + cs-CZ: Máte nadcházející událost + sk-SK: Máte nadchádzajúcu udalosť + sl-SI: Imate prihajajoč dogodek + hr-HR: Imate nadolazeći događaj + lt-LT: Turite artėjantį įvykį + hu-HU: Közelgő eseménye van + ro-RO: Aveți un eveniment în curând + fi-FI: Sinulla on tuleva tapahtuma + nb-NO: Du har en kommende hendelse + tr-TR: Yaklaşan bir etkinliğiniz var + zh-CN: 您有一个即将开始的活动 + zh-TW: 您有一個即將到來的活動 + ja-JP: まもなく予定があります + ko-KR: 다가오는 일정이 있습니다 + th-TH: คุณมีกิจกรรมที่กำลังจะมาถึง + vi-VN: Bạn có một sự kiện sắp diễn ra + id-ID: Anda memiliki acara mendatang + hi-IN: आपका एक आगामी इवेंट है + ar-SA: لديك حدث قادم + he-IL: יש לך אירוע קרוב + fa-IR: شما رویداد پیش‌رو دارید + +calendar.alarm_footer: + en-US: You are receiving this email because you have enabled calendar notifications. To stop receiving these emails, login to the self-service portal and disable event notifications. + es-ES: Recibes este correo porque has habilitado las notificaciones de calendario. Para dejar de recibir estos correos, inicia sesión en el portal de autoservicio y desactiva las notificaciones de eventos. + fr-FR: Vous recevez cet e-mail car vous avez activé les notifications de calendrier. Pour arrêter de recevoir ces e-mails, connectez-vous au portail libre-service et désactivez les notifications d'événements. + de-DE: Sie erhalten diese E-Mail, weil Sie Kalender-Benachrichtigungen aktiviert haben. Um diese E-Mails nicht mehr zu erhalten, melden Sie sich im Self-Service-Portal an und deaktivieren Sie Ereignisbenachrichtigungen. + it-IT: Ricevi questa email perché hai abilitato le notifiche del calendario. Per smettere di ricevere queste email, accedi al portale self-service e disabilita le notifiche degli eventi. + pt-PT: Está a receber este e-mail porque ativou as notificações do calendário. Para deixar de receber estes e-mails, inicie sessão no portal de autosserviço e desative as notificações de eventos. + nl-NL: U ontvangt deze e-mail omdat u kalendernotificaties heeft ingeschakeld. Om deze e-mails niet meer te ontvangen, logt u in op de selfservice-portal en schakelt u gebeurtenismeldingen uit. + da-DK: Du modtager denne e-mail, fordi du har aktiveret kalendernotifikationer. Du kan deaktivere notifikationer i selvbetjeningsportalen. + ca-ES: Estàs rebent aquest correu perquè tens habilitades les notificacions del calendari. Per no rebre més aquests correus, identifica't al portal d'autoservei i desactiva les notificacions d'esdeveniments. + el-GR: Λαμβάνετε αυτό το e-mail γιατί έχετε ενεργοποιήσει τις ειδοποιήσεις ημερολογίου. Για διακοπή της λήψης τους, μπείτε στην πύλη αυτοεξυπηρέτησης και απενεργοποιήστε τις ειδοποιήσεις εκδηλώσεων. + sv-SE: Du får det här mejlet eftersom du har slagit på kalendernotifikationer. Logga in i självbetjäningsportalen och stäng av händelsenotifikationer om du inte vill få fler. + pl-PL: Otrzymujesz tę wiadomość, ponieważ włączyłeś powiadomienia kalendarza. Aby przestać je otrzymywać, zaloguj się do portalu samoobsługowego i wyłącz powiadomienia o wydarzeniach. + pt-BR: Você está recebendo este e-mail porque ativou as notificações do calendário. Para parar de receber estes e-mails, faça login no portal de autoatendimento e desative as notificações de eventos. + ru-RU: Вы получаете это письмо, потому что включили уведомления календаря. Чтобы больше не получать такие письма, войдите в портал самообслуживания и отключите уведомления о событиях. + uk-UA: Ви отримуєте цей лист, тому що увімкнули сповіщення календаря. Щоб більше не отримувати такі листи, увійдіть на портал самообслуговування та вимкніть сповіщення про події. + bg-BG: Получавате този имейл, защото сте включили известията от календара. За да спрете да ги получавате, влезте в портала за самообслужване и изключете известията за събития. + cs-CZ: Tento e-mail dostáváte, protože jste zapnuli oznámení kalendáře. Pokud je již nechcete dostávat, přihlaste se do samoobslužného portálu a vypněte oznámení o událostech. + sk-SK: Tento e-mail dostávate, pretože ste zapli oznámenia kalendára. Ak ich už nechcete dostávať, prihláste sa do samoobslužného portálu a vypnite oznámenia o udalostiach. + sl-SI: To e-pošto prejemate, ker ste vklopili obvestila koledarja. Če jih ne želite več prejemati, se prijavite v samopostrežni portal in izklopite obvestila o dogodkih. + hr-HR: Ovu e-poštu primate jer ste uključili obavijesti kalendara. Ako ih više ne želite primati, prijavite se na samoposlužni portal i isključite obavijesti o događajima. + lt-LT: Šį el. laišką gaunate, nes įjungėte kalendoriaus pranešimus. Jei nebenorite jų gauti, prisijunkite prie savitarnos portalo ir išjunkite pranešimus apie įvykius. + hu-HU: Azért kapja ezt az e-mailt, mert bekapcsolta a naptárértesítéseket. Ha nem szeretne több ilyen e-mailt kapni, jelentkezzen be az önkiszolgáló portálra, és kapcsolja ki az eseményértesítéseket. + ro-RO: Primiți acest e-mail deoarece ați activat notificările de calendar. Pentru a nu le mai primi, conectați-vă la portalul de autoservire și dezactivați notificările despre evenimente. + fi-FI: Saat tämän sähköpostin, koska olet ottanut kalenteri-ilmoitukset käyttöön. Jos et halua enää saada näitä viestejä, kirjaudu itsepalveluportaaliin ja poista tapahtumailmoitukset käytöstä. + nb-NO: Du mottar denne e-posten fordi du har slått på kalendervarsler. Logg inn på selvbetjeningsportalen og slå av hendelsesvarsler hvis du ikke vil motta flere. + tr-TR: Takvim bildirimlerini açtığınız için bu e-postayı alıyorsunuz. Bu e-postaları almayı durdurmak için self servis portalında oturum açın ve etkinlik bildirimlerini kapatın. + zh-CN: 您收到此邮件是因为您启用了日历通知。如需停止接收此类邮件,请登录自助服务门户并关闭活动通知。 + zh-TW: 您收到這封郵件是因為您已啟用行事曆通知。若要停止接收這類郵件,請登入自助服務入口網站並關閉活動通知。 + ja-JP: カレンダー通知を有効にしているため、このメールが送信されています。今後このメールを受け取らないようにするには、セルフサービスポータルにログインして予定の通知を無効にしてください。 + ko-KR: 캘린더 알림을 사용 설정했기 때문에 이 이메일을 받고 있습니다. 더 이상 받지 않으려면 셀프 서비스 포털에 로그인하여 일정 알림을 사용 중지하세요. + th-TH: คุณได้รับอีเมลนี้เพราะคุณเปิดการแจ้งเตือนปฏิทินไว้ หากไม่ต้องการรับอีเมลเหล่านี้อีก โปรดเข้าสู่ระบบพอร์ทัลบริการตนเองและปิดการแจ้งเตือนกิจกรรม + vi-VN: Bạn nhận được email này vì bạn đã bật thông báo lịch. Để ngừng nhận những email này, hãy đăng nhập vào cổng tự phục vụ và tắt thông báo sự kiện. + id-ID: Anda menerima email ini karena Anda mengaktifkan notifikasi kalender. Untuk berhenti menerima email ini, masuk ke portal layanan mandiri dan nonaktifkan notifikasi acara. + hi-IN: आपको यह ईमेल इसलिए मिल रहा है क्योंकि आपने कैलेंडर सूचनाएँ चालू की हैं। ये ईमेल पाना बंद करने के लिए, सेल्फ-सर्विस पोर्टल में साइन इन करें और इवेंट सूचनाएँ बंद करें। + ar-SA: تتلقى هذه الرسالة لأنك فعّلت إشعارات التقويم. لإيقاف تلقي هذه الرسائل، سجّل الدخول إلى بوابة الخدمة الذاتية وعطّل إشعارات الأحداث. + he-IL: קיבלת הודעה זו כי הפעלת התראות יומן. כדי להפסיק לקבל הודעות אלה, יש להיכנס לפורטל השירות העצמי ולכבות את התראות האירועים. + fa-IR: این ایمیل را دریافت می‌کنید چون اعلان‌های تقویم را فعال کرده‌اید. برای توقف دریافت این ایمیل‌ها، به درگاه خودخدمت وارد شوید و اعلان‌های رویداد را غیرفعال کنید. + +calendar.alarm_open: + en-US: View Event + es-ES: Ver evento + fr-FR: Voir l'événement + de-DE: Termin anzeigen + it-IT: Visualizza evento + pt-PT: Ver evento + nl-NL: Gebeurtenis bekijken + da-DK: Vis begivenhed + ca-ES: Veure esdeveniment + el-GR: Προβολή εκδήλωσης + sv-SE: Visa händelse + pl-PL: Wyświetl wydarzenie + pt-BR: Ver evento + ru-RU: Открыть событие + uk-UA: Переглянути подію + bg-BG: Преглед на събитието + cs-CZ: Zobrazit událost + sk-SK: Zobraziť udalosť + sl-SI: Prikaži dogodek + hr-HR: Prikaži događaj + lt-LT: Peržiūrėti įvykį + hu-HU: Esemény megtekintése + ro-RO: Vezi evenimentul + fi-FI: Näytä tapahtuma + nb-NO: Vis hendelse + tr-TR: Etkinliği görüntüle + zh-CN: 查看活动 + zh-TW: 查看活動 + ja-JP: 予定を表示 + ko-KR: 일정 보기 + th-TH: ดูกิจกรรม + vi-VN: Xem sự kiện + id-ID: Lihat acara + hi-IN: इवेंट देखें + ar-SA: عرض الحدث + he-IL: הצגת האירוע + fa-IR: مشاهده رویداد + +calendar.organizer: + en-US: Organizer + es-ES: Organizador + fr-FR: Organisateur + de-DE: Organisator + it-IT: Organizzatore + pt-PT: Organizador + nl-NL: Organisator + da-DK: Arrangør + ca-ES: Organitzador + el-GR: Διοργανωτής + sv-SE: Arrangör + pl-PL: Organizator + pt-BR: Organizador + ru-RU: Организатор + uk-UA: Організатор + bg-BG: Организатор + cs-CZ: Organizátor + sk-SK: Organizátor + sl-SI: Organizator + hr-HR: Organizator + lt-LT: Organizatorius + hu-HU: Szervező + ro-RO: Organizator + fi-FI: Järjestäjä + nb-NO: Arrangør + tr-TR: Düzenleyen + zh-CN: 组织者 + zh-TW: 主辦人 + ja-JP: 主催者 + ko-KR: 주최자 + th-TH: ผู้จัด + vi-VN: Người tổ chức + id-ID: Penyelenggara + hi-IN: आयोजक + ar-SA: المنظم + he-IL: מארגן + fa-IR: برگزارکننده + +calendar.attendees: + en-US: Guests + es-ES: Invitados + fr-FR: Invités + de-DE: Gäste + it-IT: Ospiti + pt-PT: Convidados + nl-NL: Gasten + da-DK: Gæster + ca-ES: Invitats + el-GR: Συμμετέχοντες + sv-SE: Gäster + pl-PL: Goście + pt-BR: Convidados + ru-RU: Гости + uk-UA: Гості + bg-BG: Гости + cs-CZ: Hosté + sk-SK: Hostia + sl-SI: Gostje + hr-HR: Gosti + lt-LT: Svečiai + hu-HU: Vendégek + ro-RO: Invitați + fi-FI: Vieraat + nb-NO: Gjester + tr-TR: Konuklar + zh-CN: 来宾 + zh-TW: 來賓 + ja-JP: ゲスト + ko-KR: 참석자 + th-TH: ผู้เข้าร่วม + vi-VN: Khách mời + id-ID: Tamu + hi-IN: अतिथि + ar-SA: الضيوف + he-IL: אורחים + fa-IR: مهمانان + +calendar.start: + en-US: Start + es-ES: Inicio + fr-FR: Début + de-DE: Beginn + it-IT: Inizio + pt-PT: Início + nl-NL: Begin + da-DK: Start + ca-ES: Inici + el-GR: Έναρξη + sv-SE: Start + pl-PL: Początek + pt-BR: Início + ru-RU: Начало + uk-UA: Початок + bg-BG: Начало + cs-CZ: Začátek + sk-SK: Začiatok + sl-SI: Začetek + hr-HR: Početak + lt-LT: Pradžia + hu-HU: Kezdés + ro-RO: Început + fi-FI: Alkaa + nb-NO: Start + tr-TR: Başlangıç + zh-CN: 开始 + zh-TW: 開始 + ja-JP: 開始 + ko-KR: 시작 + th-TH: เริ่ม + vi-VN: Bắt đầu + id-ID: Mulai + hi-IN: प्रारंभ + ar-SA: البداية + he-IL: התחלה + fa-IR: شروع + +calendar.end: + en-US: End + es-ES: Fin + fr-FR: Fin + de-DE: Ende + it-IT: Fine + pt-PT: Fim + nl-NL: Einde + da-DK: Slut + ca-ES: Fi + el-GR: Λήξη + sv-SE: Slut + pl-PL: Koniec + pt-BR: Fim + ru-RU: Окончание + uk-UA: Завершення + bg-BG: Край + cs-CZ: Konec + sk-SK: Koniec + sl-SI: Konec + hr-HR: Kraj + lt-LT: Pabaiga + hu-HU: Befejezés + ro-RO: Sfârșit + fi-FI: Päättyy + nb-NO: Slutt + tr-TR: Bitiş + zh-CN: 结束 + zh-TW: 結束 + ja-JP: 終了 + ko-KR: 종료 + th-TH: สิ้นสุด + vi-VN: Kết thúc + id-ID: Selesai + hi-IN: समाप्ति + ar-SA: النهاية + he-IL: סיום + fa-IR: پایان + +calendar.location: + en-US: Location + es-ES: Ubicación + fr-FR: Lieu + de-DE: Ort + it-IT: Luogo + pt-PT: Local + nl-NL: Locatie + da-DK: Lokation + ca-ES: Lloc + el-GR: Τοποθεσία + sv-SE: Plats + pl-PL: Lokalizacja + pt-BR: Local + ru-RU: Место + uk-UA: Місце + bg-BG: Място + cs-CZ: Místo + sk-SK: Miesto + sl-SI: Kraj + hr-HR: Mjesto + lt-LT: Vieta + hu-HU: Helyszín + ro-RO: Locație + fi-FI: Sijainti + nb-NO: Sted + tr-TR: Konum + zh-CN: 地点 + zh-TW: 地點 + ja-JP: 場所 + ko-KR: 장소 + th-TH: สถานที่ + vi-VN: Địa điểm + id-ID: Lokasi + hi-IN: स्थान + ar-SA: الموقع + he-IL: מיקום + fa-IR: مکان + +calendar.conference: + en-US: Conference + es-ES: Conferencia + fr-FR: Conférence + de-DE: Konferenz + it-IT: Conferenza + pt-PT: Conferência + nl-NL: Conferentie + da-DK: Konference + ca-ES: Conferència + el-GR: Διάσκεψη + sv-SE: Konferens + pl-PL: Konferencja + pt-BR: Conferência + ru-RU: Конференция + uk-UA: Конференція + bg-BG: Конференция + cs-CZ: Konference + sk-SK: Konferencia + sl-SI: Konferenca + hr-HR: Konferencija + lt-LT: Konferencija + hu-HU: Konferencia + ro-RO: Conferință + fi-FI: Konferenssi + nb-NO: Konferanse + tr-TR: Konferans + zh-CN: 会议 + zh-TW: 會議 + ja-JP: 会議 + ko-KR: 회의 + th-TH: การประชุม + vi-VN: Hội nghị + id-ID: Konferensi + hi-IN: कॉन्फ़्रेंस + ar-SA: مؤتمر + he-IL: שיחת ועידה + fa-IR: کنفرانس + +calendar.invitation: + en-US: Invitation + es-ES: Invitación + fr-FR: Invitation + de-DE: Einladung + it-IT: Invito + pt-PT: Convite + nl-NL: Uitnodiging + da-DK: Invitation + ca-ES: Invitació + el-GR: Πρόσκληση + sv-SE: Inbjudan + pl-PL: Zaproszenie + pt-BR: Convite + ru-RU: Приглашение + uk-UA: Запрошення + bg-BG: Покана + cs-CZ: Pozvánka + sk-SK: Pozvánka + sl-SI: Vabilo + hr-HR: Pozivnica + lt-LT: Kvietimas + hu-HU: Meghívó + ro-RO: Invitație + fi-FI: Kutsu + nb-NO: Invitasjon + tr-TR: Davet + zh-CN: 邀请 + zh-TW: 邀請 + ja-JP: 招待 + ko-KR: 초대 + th-TH: คำเชิญ + vi-VN: Lời mời + id-ID: Undangan + hi-IN: आमंत्रण + ar-SA: دعوة + he-IL: הזמנה + fa-IR: دعوت‌نامه + +calendar.updated_invitation: + en-US: Updated invitation + es-ES: Invitación actualizada + fr-FR: Invitation mise à jour + de-DE: Aktualisierte Einladung + it-IT: Invito aggiornato + pt-PT: Convite atualizado + nl-NL: Bijgewerkte uitnodiging + da-DK: Opdateret invitation + ca-ES: Invitació actualitzada + el-GR: Ενημερωμένη πρόσκληση + sv-SE: Uppdaterad inbjudan + pl-PL: Zaktualizowane zaproszenie + pt-BR: Convite atualizado + ru-RU: Обновлённое приглашение + uk-UA: Оновлене запрошення + bg-BG: Актуализирана покана + cs-CZ: Aktualizovaná pozvánka + sk-SK: Aktualizovaná pozvánka + sl-SI: Posodobljeno vabilo + hr-HR: Ažurirana pozivnica + lt-LT: Atnaujintas kvietimas + hu-HU: Frissített meghívó + ro-RO: Invitație actualizată + fi-FI: Päivitetty kutsu + nb-NO: Oppdatert invitasjon + tr-TR: Güncellenmiş davet + zh-CN: 更新的邀请 + zh-TW: 已更新的邀請 + ja-JP: 更新された招待 + ko-KR: 업데이트된 초대 + th-TH: คำเชิญที่อัปเดต + vi-VN: Lời mời đã cập nhật + id-ID: Undangan yang diperbarui + hi-IN: अपडेट किया गया आमंत्रण + ar-SA: دعوة محدَّثة + he-IL: הזמנה מעודכנת + fa-IR: دعوت‌نامه به‌روزشده + +calendar.event_updated: + en-US: This event has been updated + es-ES: Este evento ha sido actualizado + fr-FR: Cet événement a été mis à jour + de-DE: Dieses Ereignis wurde aktualisiert + it-IT: Questo evento è stato aggiornato + pt-PT: Este evento foi atualizado + nl-NL: Dit evenement is bijgewerkt + da-DK: Denne begivenhed er blevet opdateret + ca-ES: Aquest esdeveniment s'ha actualitzat + el-GR: Αυτή η εκδήλωση ενημερώθηκε + sv-SE: Den här händelsen har blivit uppdaterad + pl-PL: To wydarzenie zostało zaktualizowane + pt-BR: Este evento foi atualizado + ru-RU: Это событие было обновлено + uk-UA: Цю подію оновлено + bg-BG: Това събитие беше актуализирано + cs-CZ: Tato událost byla aktualizována + sk-SK: Táto udalosť bola aktualizovaná + sl-SI: Ta dogodek je bil posodobljen + hr-HR: Ovaj događaj je ažuriran + lt-LT: Šis įvykis buvo atnaujintas + hu-HU: Ezt az eseményt frissítették + ro-RO: Acest eveniment a fost actualizat + fi-FI: Tätä tapahtumaa on päivitetty + nb-NO: Denne hendelsen er oppdatert + tr-TR: Bu etkinlik güncellendi + zh-CN: 此活动已更新 + zh-TW: 此活動已更新 + ja-JP: この予定は更新されました + ko-KR: 이 일정이 업데이트되었습니다 + th-TH: กิจกรรมนี้ได้รับการอัปเดตแล้ว + vi-VN: Sự kiện này đã được cập nhật + id-ID: Acara ini telah diperbarui + hi-IN: यह इवेंट अपडेट किया गया है + ar-SA: تم تحديث هذا الحدث + he-IL: האירוע עודכן + fa-IR: این رویداد به‌روزرسانی شد + +calendar.cancelled: + en-US: Cancelled + es-ES: Cancelado + fr-FR: Annulé + de-DE: Abgesagt + it-IT: Annullato + pt-PT: Cancelado + nl-NL: Geannuleerd + da-DK: Aflyst + ca-ES: Cancel·lat + el-GR: Ακυρώθηκε + sv-SE: Inställd + pl-PL: Odwołane + pt-BR: Cancelado + ru-RU: Отменено + uk-UA: Скасовано + bg-BG: Отменено + cs-CZ: Zrušeno + sk-SK: Zrušené + sl-SI: Odpovedano + hr-HR: Otkazano + lt-LT: Atšaukta + hu-HU: Lemondva + ro-RO: Anulat + fi-FI: Peruttu + nb-NO: Avlyst + tr-TR: İptal edildi + zh-CN: 已取消 + zh-TW: 已取消 + ja-JP: キャンセル済み + ko-KR: 취소됨 + th-TH: ยกเลิกแล้ว + vi-VN: Đã hủy + id-ID: Dibatalkan + hi-IN: रद्द + ar-SA: ملغى + he-IL: בוטל + fa-IR: لغو شد + +calendar.event_cancelled: + en-US: This event has been canceled + es-ES: Este evento ha sido cancelado + fr-FR: Cet événement a été annulé + de-DE: Dieses Ereignis wurde abgesagt + it-IT: Questo evento è stato annullato + pt-PT: Este evento foi cancelado + nl-NL: Dit evenement is geannuleerd + da-DK: Denne begivenhed er blevet aflyst + ca-ES: Aquest esdeveniment s'ha cancel·lat + el-GR: Αυτή η εκδήλωση ακυρώθηκε + sv-SE: Den här händelsen har blivit inställd + pl-PL: To wydarzenie zostało odwołane + pt-BR: Este evento foi cancelado + ru-RU: Это событие было отменено + uk-UA: Цю подію скасовано + bg-BG: Това събитие беше отменено + cs-CZ: Tato událost byla zrušena + sk-SK: Táto udalosť bola zrušená + sl-SI: Ta dogodek je bil odpovedan + hr-HR: Ovaj događaj je otkazan + lt-LT: Šis įvykis buvo atšauktas + hu-HU: Ezt az eseményt lemondták + ro-RO: Acest eveniment a fost anulat + fi-FI: Tämä tapahtuma on peruttu + nb-NO: Denne hendelsen er avlyst + tr-TR: Bu etkinlik iptal edildi + zh-CN: 此活动已取消 + zh-TW: 此活動已取消 + ja-JP: この予定はキャンセルされました + ko-KR: 이 일정이 취소되었습니다 + th-TH: กิจกรรมนี้ถูกยกเลิกแล้ว + vi-VN: Sự kiện này đã bị hủy + id-ID: Acara ini telah dibatalkan + hi-IN: यह इवेंट रद्द कर दिया गया है + ar-SA: تم إلغاء هذا الحدث + he-IL: האירוע בוטל + fa-IR: این رویداد لغو شد + +calendar.accepted: + en-US: Accepted + es-ES: Aceptado + fr-FR: Accepté + de-DE: Angenommen + it-IT: Accettato + pt-PT: Aceite + nl-NL: Geaccepteerd + da-DK: Accepteret + ca-ES: Acceptat + el-GR: Αποδοχή + sv-SE: Accepterat + pl-PL: Zaakceptowano + pt-BR: Aceito + ru-RU: Принято + uk-UA: Прийнято + bg-BG: Прието + cs-CZ: Přijato + sk-SK: Prijaté + sl-SI: Sprejeto + hr-HR: Prihvaćeno + lt-LT: Priimta + hu-HU: Elfogadva + ro-RO: Acceptat + fi-FI: Hyväksytty + nb-NO: Akseptert + tr-TR: Kabul edildi + zh-CN: 已接受 + zh-TW: 已接受 + ja-JP: 承諾 + ko-KR: 수락함 + th-TH: ตอบรับแล้ว + vi-VN: Đã chấp nhận + id-ID: Diterima + hi-IN: स्वीकृत + ar-SA: مقبول + he-IL: אושר + fa-IR: پذیرفته شد + +calendar.participant_accepted: + en-US: Participant $name accepted the invitation + es-ES: El participante $name aceptó la invitación + fr-FR: Le participant $name a accepté l'invitation + de-DE: Teilnehmer $name hat die Einladung angenommen + it-IT: Il partecipante $name ha accettato l'invito + pt-PT: O participante $name aceitou o convite + nl-NL: Deelnemer $name heeft de uitnodiging geaccepteerd + da-DK: Deltager $name accepterede invitationen + ca-ES: El participant $name ha acceptat la invitació + el-GR: Το συμμετέχον πρόσωπο $name αποδέχτηκε την πρόσκληση + sv-SE: Deltagaren $name har tackat ja till inbjudan + pl-PL: Uczestnik $name zaakceptował zaproszenie + pt-BR: O participante $name aceitou o convite + ru-RU: Участник $name принял приглашение + uk-UA: Учасник $name прийняв запрошення + bg-BG: Участникът $name прие поканата + cs-CZ: Účastník $name přijal pozvánku + sk-SK: Účastník $name prijal pozvánku + sl-SI: Udeleženec $name je sprejel vabilo + hr-HR: Sudionik $name prihvatio je pozivnicu + lt-LT: Dalyvis $name priėmė kvietimą + hu-HU: $name résztvevő elfogadta a meghívót + ro-RO: Participantul $name a acceptat invitația + fi-FI: Osallistuja $name hyväksyi kutsun + nb-NO: Deltakeren $name aksepterte invitasjonen + tr-TR: Katılımcı $name daveti kabul etti + zh-CN: 参与者 $name 已接受邀请 + zh-TW: 參與者 $name 已接受邀請 + ja-JP: 参加者 $name が招待を承諾しました + ko-KR: 참석자 $name 님이 초대를 수락했습니다 + th-TH: ผู้เข้าร่วม $name ตอบรับคำเชิญแล้ว + vi-VN: Người tham dự $name đã chấp nhận lời mời + id-ID: Peserta $name menerima undangan + hi-IN: प्रतिभागी $name ने आमंत्रण स्वीकार किया + ar-SA: قبل المشارك $name الدعوة + he-IL: המשתתף $name אישר את ההזמנה + fa-IR: شرکت‌کننده $name دعوت را پذیرفت + +calendar.declined: + en-US: Declined + es-ES: Rechazado + fr-FR: Refusé + de-DE: Abgelehnt + it-IT: Rifiutato + pt-PT: Recusado + nl-NL: Afgewezen + da-DK: Afvist + ca-ES: Rebutjat + el-GR: Απορρίφθηκε + sv-SE: Avböjt + pl-PL: Odrzucono + pt-BR: Recusado + ru-RU: Отклонено + uk-UA: Відхилено + bg-BG: Отхвърлено + cs-CZ: Odmítnuto + sk-SK: Odmietnuté + sl-SI: Zavrnjeno + hr-HR: Odbijeno + lt-LT: Atmesta + hu-HU: Elutasítva + ro-RO: Refuzat + fi-FI: Hylätty + nb-NO: Avslått + tr-TR: Reddedildi + zh-CN: 已拒绝 + zh-TW: 已拒絕 + ja-JP: 辞退 + ko-KR: 거절함 + th-TH: ปฏิเสธแล้ว + vi-VN: Đã từ chối + id-ID: Ditolak + hi-IN: अस्वीकृत + ar-SA: مرفوض + he-IL: נדחה + fa-IR: رد شد + +calendar.participant_declined: + en-US: Participant $name declined the invitation + es-ES: El participante $name rechazó la invitación + fr-FR: Le participant $name a refusé l'invitation + de-DE: Teilnehmer $name hat die Einladung abgelehnt + it-IT: Il partecipante $name ha rifiutato l'invito + pt-PT: O participante $name recusou o convite + nl-NL: Deelnemer $name heeft de uitnodiging afgewezen + da-DK: Deltager $name afslog invitationen + ca-ES: El participant $name ha rebutjat la invitació + el-GR: Το συμμετέχον πρόσωπο $name απέρριψε την πρόσκληση + sv-SE: Deltagaren $name har tackat nej till inbjudan + pl-PL: Uczestnik $name odrzucił zaproszenie + pt-BR: O participante $name recusou o convite + ru-RU: Участник $name отклонил приглашение + uk-UA: Учасник $name відхилив запрошення + bg-BG: Участникът $name отхвърли поканата + cs-CZ: Účastník $name odmítl pozvánku + sk-SK: Účastník $name odmietol pozvánku + sl-SI: Udeleženec $name je zavrnil vabilo + hr-HR: Sudionik $name odbio je pozivnicu + lt-LT: Dalyvis $name atmetė kvietimą + hu-HU: $name résztvevő elutasította a meghívót + ro-RO: Participantul $name a refuzat invitația + fi-FI: Osallistuja $name hylkäsi kutsun + nb-NO: Deltakeren $name avslo invitasjonen + tr-TR: Katılımcı $name daveti reddetti + zh-CN: 参与者 $name 已拒绝邀请 + zh-TW: 參與者 $name 已拒絕邀請 + ja-JP: 参加者 $name が招待を辞退しました + ko-KR: 참석자 $name 님이 초대를 거절했습니다 + th-TH: ผู้เข้าร่วม $name ปฏิเสธคำเชิญแล้ว + vi-VN: Người tham dự $name đã từ chối lời mời + id-ID: Peserta $name menolak undangan + hi-IN: प्रतिभागी $name ने आमंत्रण अस्वीकार किया + ar-SA: رفض المشارك $name الدعوة + he-IL: המשתתף $name דחה את ההזמנה + fa-IR: شرکت‌کننده $name دعوت را رد کرد + +calendar.tentative: + en-US: Tentative + es-ES: Provisional + fr-FR: Provisoire + de-DE: Vorläufig + it-IT: Provvisorio + pt-PT: Provisório + nl-NL: Voorlopig + da-DK: Foreløbig + ca-ES: Provisional + el-GR: Μη δεσμευτικά + sv-SE: Preliminärt + pl-PL: Wstępnie + pt-BR: Provisório + ru-RU: Предварительно + uk-UA: Попередньо + bg-BG: Условно + cs-CZ: Předběžně + sk-SK: Predbežne + sl-SI: Pogojno + hr-HR: Uvjetno + lt-LT: Preliminariai + hu-HU: Feltételes + ro-RO: Provizoriu + fi-FI: Alustava + nb-NO: Foreløpig + tr-TR: Kararsız + zh-CN: 待定 + zh-TW: 待定 + ja-JP: 仮承諾 + ko-KR: 미정 + th-TH: ยังไม่แน่นอน + vi-VN: Tạm thời + id-ID: Sementara + hi-IN: अस्थायी + ar-SA: مبدئي + he-IL: באופן זמני + fa-IR: موقت + +calendar.participant_tentative: + en-US: Participant $name tentatively accepted the invitation + es-ES: El participante $name aceptó provisionalmente la invitación + fr-FR: Le participant $name a accepté provisoirement l'invitation + de-DE: Teilnehmer $name hat die Einladung vorläufig angenommen + it-IT: Il partecipante $name ha accettato provvisoriamente l'invito + pt-PT: O participante $name aceitou provisoriamente o convite + nl-NL: Deelnemer $name heeft de uitnodiging voorlopig geaccepteerd + da-DK: Deltager $name accepterede foreløbigt invitationen + ca-ES: El participant $name ha acceptat provisionalment la invitació + el-GR: Το συμμετέχον πρόσωπο $name αποδέχτηκε μη δεσμευτικά την πρόσκληση + sv-SE: Deltagaren $name har preliminärt tackat ja till inbjudan + pl-PL: Uczestnik $name wstępnie zaakceptował zaproszenie + pt-BR: O participante $name aceitou provisoriamente o convite + ru-RU: Участник $name предварительно принял приглашение + uk-UA: Учасник $name попередньо прийняв запрошення + bg-BG: Участникът $name прие условно поканата + cs-CZ: Účastník $name předběžně přijal pozvánku + sk-SK: Účastník $name predbežne prijal pozvánku + sl-SI: Udeleženec $name je pogojno sprejel vabilo + hr-HR: Sudionik $name uvjetno je prihvatio pozivnicu + lt-LT: Dalyvis $name preliminariai priėmė kvietimą + hu-HU: $name résztvevő feltételesen elfogadta a meghívót + ro-RO: Participantul $name a acceptat provizoriu invitația + fi-FI: Osallistuja $name hyväksyi kutsun alustavasti + nb-NO: Deltakeren $name aksepterte invitasjonen foreløpig + tr-TR: Katılımcı $name daveti geçici olarak kabul etti + zh-CN: 参与者 $name 已暂定接受邀请 + zh-TW: 參與者 $name 已暫時接受邀請 + ja-JP: 参加者 $name が招待を仮承諾しました + ko-KR: 참석자 $name 님이 초대를 잠정 수락했습니다 + th-TH: ผู้เข้าร่วม $name ตอบรับคำเชิญเบื้องต้นแล้ว + vi-VN: Người tham dự $name đã tạm chấp nhận lời mời + id-ID: Peserta $name menerima undangan untuk sementara + hi-IN: प्रतिभागी $name ने आमंत्रण अस्थायी रूप से स्वीकार किया + ar-SA: قبل المشارك $name الدعوة مبدئيًا + he-IL: המשתתף $name אישר את ההזמנה באופן זמני + fa-IR: شرکت‌کننده $name دعوت را به‌طور موقت پذیرفت + +calendar.delegated: + en-US: Delegated + es-ES: Delegado + fr-FR: Délégué + de-DE: Delegiert + it-IT: Delegato + pt-PT: Delegado + nl-NL: Gedelegeerd + da-DK: Delegeret + ca-ES: Delegat + el-GR: Ανατέθηκε + sv-SE: Delegerat + pl-PL: Delegowane + pt-BR: Delegado + ru-RU: Делегировано + uk-UA: Делеговано + bg-BG: Делегирано + cs-CZ: Delegováno + sk-SK: Delegované + sl-SI: Preneseno + hr-HR: Delegirano + lt-LT: Deleguota + hu-HU: Átruházva + ro-RO: Delegat + fi-FI: Delegoitu + nb-NO: Delegert + tr-TR: Devredildi + zh-CN: 已转交 + zh-TW: 已轉交 + ja-JP: 委任 + ko-KR: 위임됨 + th-TH: มอบหมายแล้ว + vi-VN: Đã ủy quyền + id-ID: Didelegasikan + hi-IN: प्रत्यायोजित + ar-SA: مفوَّض + he-IL: הואצל + fa-IR: واگذارشده + +calendar.participant_delegated: + en-US: Participant $name delegated the invitation + es-ES: El participante $name delegó la invitación + fr-FR: Le participant $name a délégué l'invitation + de-DE: Teilnehmer $name hat die Einladung delegiert + it-IT: Il partecipante $name ha delegato l'invito + pt-PT: O participante $name delegou o convite + nl-NL: Deelnemer $name heeft de uitnodiging gedelegeerd + da-DK: Deltager $name delegerede invitationen + ca-ES: El participant $name ha delegat la invitació + el-GR: Το συμμετέχον πρόσωπο $name ανέθεσε την πρόσκληση + sv-SE: Deltagaren $name delegerade inbjudan + pl-PL: Uczestnik $name przekazał zaproszenie + pt-BR: O participante $name delegou o convite + ru-RU: Участник $name делегировал приглашение + uk-UA: Учасник $name делегував запрошення + bg-BG: Участникът $name делегира поканата + cs-CZ: Účastník $name delegoval pozvánku + sk-SK: Účastník $name delegoval pozvánku + sl-SI: Udeleženec $name je vabilo prenesel naprej + hr-HR: Sudionik $name proslijedio je pozivnicu + lt-LT: Dalyvis $name perdavė kvietimą + hu-HU: $name résztvevő továbbadta a meghívót + ro-RO: Participantul $name a delegat invitația + fi-FI: Osallistuja $name välitti kutsun eteenpäin + nb-NO: Deltakeren $name delegerte invitasjonen + tr-TR: Katılımcı $name daveti devretti + zh-CN: 参与者 $name 已转交邀请 + zh-TW: 參與者 $name 已轉交邀請 + ja-JP: 参加者 $name が招待を委任しました + ko-KR: 참석자 $name 님이 초대를 위임했습니다 + th-TH: ผู้เข้าร่วม $name มอบหมายคำเชิญแล้ว + vi-VN: Người tham dự $name đã chuyển lời mời + id-ID: Peserta $name mendelegasikan undangan + hi-IN: प्रतिभागी $name ने आमंत्रण किसी और को सौंपा + ar-SA: فوَّض المشارك $name الدعوة + he-IL: המשתתף $name העביר את ההזמנה + fa-IR: شرکت‌کننده $name دعوت را واگذار کرد + +calendar.reply: + en-US: Reply + es-ES: Respuesta + fr-FR: Réponse + de-DE: Antwort + it-IT: Risposta + pt-PT: Resposta + nl-NL: Antwoord + da-DK: Svar + ca-ES: Resposta + el-GR: Απάντηση + sv-SE: Svar + pl-PL: Odpowiedź + pt-BR: Resposta + ru-RU: Ответ + uk-UA: Відповідь + bg-BG: Отговор + cs-CZ: Odpověď + sk-SK: Odpoveď + sl-SI: Odgovor + hr-HR: Odgovor + lt-LT: Atsakymas + hu-HU: Válasz + ro-RO: Răspuns + fi-FI: Vastaus + nb-NO: Svar + tr-TR: Yanıt + zh-CN: 回复 + zh-TW: 回覆 + ja-JP: 返信 + ko-KR: 회신 + th-TH: ตอบกลับ + vi-VN: Phản hồi + id-ID: Balasan + hi-IN: उत्तर + ar-SA: رد + he-IL: תשובה + fa-IR: پاسخ + +calendar.participant_reply: + en-US: Participant $name replied to the invitation + es-ES: El participante $name respondió a la invitación + fr-FR: Le participant $name a répondu à l'invitation + de-DE: Teilnehmer $name hat auf die Einladung geantwortet + it-IT: Il partecipante $name ha risposto all'invito + pt-PT: O participante $name respondeu ao convite + nl-NL: Deelnemer $name heeft gereageerd op de uitnodiging + da-DK: Deltager $name svarede på invitationen + ca-ES: El participant $name ha respost a la invitació + el-GR: Το συμμετέχον πρόσωπο $name απάντησε στην πρόσκληση + sv-SE: Deltagaren $name har svarat på inbjudan + pl-PL: Uczestnik $name odpowiedział na zaproszenie + pt-BR: O participante $name respondeu ao convite + ru-RU: Участник $name ответил на приглашение + uk-UA: Учасник $name відповів на запрошення + bg-BG: Участникът $name отговори на поканата + cs-CZ: Účastník $name odpověděl na pozvánku + sk-SK: Účastník $name odpovedal na pozvánku + sl-SI: Udeleženec $name je odgovoril na vabilo + hr-HR: Sudionik $name odgovorio je na pozivnicu + lt-LT: Dalyvis $name atsakė į kvietimą + hu-HU: $name résztvevő válaszolt a meghívóra + ro-RO: Participantul $name a răspuns la invitație + fi-FI: Osallistuja $name vastasi kutsuun + nb-NO: Deltakeren $name svarte på invitasjonen + tr-TR: Katılımcı $name daveti yanıtladı + zh-CN: 参与者 $name 已回复邀请 + zh-TW: 參與者 $name 已回覆邀請 + ja-JP: 参加者 $name が招待に返信しました + ko-KR: 참석자 $name 님이 초대에 회신했습니다 + th-TH: ผู้เข้าร่วม $name ตอบกลับคำเชิญแล้ว + vi-VN: Người tham dự $name đã phản hồi lời mời + id-ID: Peserta $name membalas undangan + hi-IN: प्रतिभागी $name ने आमंत्रण का उत्तर दिया + ar-SA: رد المشارك $name على الدعوة + he-IL: המשתתף $name השיב להזמנה + fa-IR: شرکت‌کننده $name به دعوت پاسخ داد + +calendar.summary: + en-US: Summary + es-ES: Resumen + fr-FR: Résumé + de-DE: Zusammenfassung + it-IT: Riepilogo + pt-PT: Resumo + nl-NL: Samenvatting + da-DK: Resumé + ca-ES: Resum + el-GR: Περίληψη + sv-SE: Sammanfattning + pl-PL: Podsumowanie + pt-BR: Resumo + ru-RU: Сводка + uk-UA: Підсумок + bg-BG: Обобщение + cs-CZ: Souhrn + sk-SK: Zhrnutie + sl-SI: Povzetek + hr-HR: Sažetak + lt-LT: Santrauka + hu-HU: Összefoglaló + ro-RO: Rezumat + fi-FI: Yhteenveto + nb-NO: Sammendrag + tr-TR: Özet + zh-CN: 摘要 + zh-TW: 摘要 + ja-JP: 概要 + ko-KR: 요약 + th-TH: สรุป + vi-VN: Tóm tắt + id-ID: Ringkasan + hi-IN: सारांश + ar-SA: الملخص + he-IL: סיכום + fa-IR: خلاصه + +calendar.description: + en-US: Description + es-ES: Descripción + fr-FR: Description + de-DE: Beschreibung + it-IT: Descrizione + pt-PT: Descrição + nl-NL: Beschrijving + da-DK: Beskrivelse + ca-ES: Descripció + el-GR: Περιγραφή + sv-SE: Beskrivning + pl-PL: Opis + pt-BR: Descrição + ru-RU: Описание + uk-UA: Опис + bg-BG: Описание + cs-CZ: Popis + sk-SK: Popis + sl-SI: Opis + hr-HR: Opis + lt-LT: Aprašymas + hu-HU: Leírás + ro-RO: Descriere + fi-FI: Kuvaus + nb-NO: Beskrivelse + tr-TR: Açıklama + zh-CN: 描述 + zh-TW: 說明 + ja-JP: 説明 + ko-KR: 설명 + th-TH: รายละเอียด + vi-VN: Mô tả + id-ID: Deskripsi + hi-IN: विवरण + ar-SA: الوصف + he-IL: תיאור + fa-IR: توضیحات + +calendar.when: + en-US: When + es-ES: Cuándo + fr-FR: Quand + de-DE: Wann + it-IT: Quando + pt-PT: Quando + nl-NL: Wanneer + da-DK: Hvornår + ca-ES: Quan + el-GR: Πότε + sv-SE: När + pl-PL: Kiedy + pt-BR: Quando + ru-RU: Когда + uk-UA: Коли + bg-BG: Кога + cs-CZ: Kdy + sk-SK: Kedy + sl-SI: Kdaj + hr-HR: Kada + lt-LT: Kada + hu-HU: Mikor + ro-RO: Când + fi-FI: Milloin + nb-NO: Når + tr-TR: Ne zaman + zh-CN: 时间 + zh-TW: 時間 + ja-JP: 日時 + ko-KR: 일시 + th-TH: เมื่อใด + vi-VN: Thời gian + id-ID: Kapan + hi-IN: कब + ar-SA: الوقت + he-IL: מתי + fa-IR: زمان + +calendar.changed: + en-US: Changed + es-ES: Cambiado + fr-FR: Modifié + de-DE: Geändert + it-IT: Modificato + pt-PT: Alterado + nl-NL: Gewijzigd + da-DK: Ændret + ca-ES: Canviat + el-GR: Μεταβλήθηκε + sv-SE: Ändrat + pl-PL: Zmieniono + pt-BR: Alterado + ru-RU: Изменено + uk-UA: Змінено + bg-BG: Променено + cs-CZ: Změněno + sk-SK: Zmenené + sl-SI: Spremenjeno + hr-HR: Promijenjeno + lt-LT: Pakeista + hu-HU: Módosítva + ro-RO: Modificat + fi-FI: Muutettu + nb-NO: Endret + tr-TR: Değiştirildi + zh-CN: 已更改 + zh-TW: 已變更 + ja-JP: 変更 + ko-KR: 변경됨 + th-TH: เปลี่ยนแปลงแล้ว + vi-VN: Đã thay đổi + id-ID: Diubah + hi-IN: बदला गया + ar-SA: تم التغيير + he-IL: שונה + fa-IR: تغییر کرد + +calendar.reply_as: + en-US: Reply as $name for this event series + es-ES: Responder como $name para esta serie de eventos + fr-FR: Répondre en tant que $name pour cette série d'événements + de-DE: Als $name für diese Ereignisserie antworten + it-IT: Rispondi come $name per questa serie di eventi + pt-PT: Responder como $name para esta série de eventos + nl-NL: Antwoord als $name voor deze evenementenreeks + da-DK: Svar som $name for denne begivenhedsserie + ca-ES: Respondre com a $name per a aquesta sèrie d'esdeveniments + el-GR: Απάντηση ως $name για αυτή τη σειρά εκδηλώσεων + sv-SE: Svara som $name för den här händelseserien + pl-PL: Odpowiedz jako $name dla tej serii wydarzeń + pt-BR: Responder como $name para esta série de eventos + ru-RU: Ответить как $name для этой серии событий + uk-UA: Відповісти як $name для цієї серії подій + bg-BG: Отговор като $name за тази поредица от събития + cs-CZ: Odpovědět jako $name pro tuto řadu událostí + sk-SK: Odpovedať ako $name pre túto sériu udalostí + sl-SI: Odgovori kot $name za to serijo dogodkov + hr-HR: Odgovori kao $name za ovu seriju događaja + lt-LT: Atsakyti kaip $name į šią įvykių seriją + hu-HU: Válasz $name nevében erre az eseménysorozatra + ro-RO: Răspundeți ca $name pentru această serie de evenimente + fi-FI: Vastaa nimellä $name tähän tapahtumasarjaan + nb-NO: Svar som $name for denne hendelsesserien + tr-TR: Bu etkinlik serisi için $name olarak yanıtla + zh-CN: 以 $name 的身份回复此活动系列 + zh-TW: 以 $name 的身分回覆此活動系列 + ja-JP: この一連の予定に $name として返信 + ko-KR: 이 반복 일정에 $name 이름으로 회신 + th-TH: ตอบกลับในนาม $name สำหรับกิจกรรมชุดนี้ + vi-VN: Trả lời với tư cách $name cho chuỗi sự kiện này + id-ID: Balas sebagai $name untuk rangkaian acara ini + hi-IN: इस इवेंट शृंखला के लिए $name के रूप में उत्तर दें + ar-SA: الرد باسم $name على سلسلة الأحداث هذه + he-IL: תשובה בשם $name לסדרת האירועים הזו + fa-IR: پاسخ به این مجموعه رویداد با نام $name + +calendar.yes: + en-US: Yes + es-ES: Sí + fr-FR: Oui + de-DE: Ja + it-IT: Sì + pt-PT: Sim + nl-NL: Ja + da-DK: Ja + ca-ES: Sí + el-GR: Ναι + sv-SE: Ja + pl-PL: Tak + pt-BR: Sim + ru-RU: Да + uk-UA: Так + bg-BG: Да + cs-CZ: Ano + sk-SK: Áno + sl-SI: Da + hr-HR: Da + lt-LT: Taip + hu-HU: Igen + ro-RO: Da + fi-FI: Kyllä + nb-NO: Ja + tr-TR: Evet + zh-CN: 是 + zh-TW: 是 + ja-JP: はい + ko-KR: 예 + th-TH: ใช่ + vi-VN: Có + id-ID: Ya + hi-IN: हाँ + ar-SA: نعم + he-IL: כן + fa-IR: بله + +calendar.no: + en-US: No + es-ES: No + fr-FR: Non + de-DE: Nein + it-IT: No + pt-PT: Não + nl-NL: Nee + da-DK: Nej + ca-ES: No + el-GR: Όχι + sv-SE: Nej + pl-PL: Nie + pt-BR: Não + ru-RU: Нет + uk-UA: Ні + bg-BG: Не + cs-CZ: Ne + sk-SK: Nie + sl-SI: Ne + hr-HR: Ne + lt-LT: Ne + hu-HU: Nem + ro-RO: Nu + fi-FI: Ei + nb-NO: Nei + tr-TR: Hayır + zh-CN: 否 + zh-TW: 否 + ja-JP: いいえ + ko-KR: 아니요 + th-TH: ไม่ + vi-VN: Không + id-ID: Tidak + hi-IN: नहीं + ar-SA: لا + he-IL: לא + fa-IR: خیر + +calendar.maybe: + en-US: Maybe + es-ES: Quizás + fr-FR: Peut-être + de-DE: Vielleicht + it-IT: Forse + pt-PT: Talvez + nl-NL: Misschien + da-DK: Måske + ca-ES: Potser + el-GR: Ίσως + sv-SE: Kanske + pl-PL: Może + pt-BR: Talvez + ru-RU: Возможно + uk-UA: Можливо + bg-BG: Може би + cs-CZ: Možná + sk-SK: Možno + sl-SI: Mogoče + hr-HR: Možda + lt-LT: Galbūt + hu-HU: Talán + ro-RO: Poate + fi-FI: Ehkä + nb-NO: Kanskje + tr-TR: Belki + zh-CN: 可能 + zh-TW: 可能 + ja-JP: 未定 + ko-KR: 미정 + th-TH: อาจจะ + vi-VN: Có thể + id-ID: Mungkin + hi-IN: शायद + ar-SA: ربما + he-IL: אולי + fa-IR: شاید + +calendar.imip_footer_1: + en-US: You're receiving this e-mail as you're listed as a participant for this event. + es-ES: Recibes este correo electrónico porque estás registrado como participante de este evento. + fr-FR: Vous recevez cet e-mail car vous êtes inscrit comme participant à cet événement. + de-DE: Sie erhalten diese E-Mail, weil Sie als Teilnehmer für dieses Ereignis aufgeführt sind. + it-IT: Ricevi questa e-mail perché sei elencato come partecipante a questo evento. + pt-PT: Está a receber este e-mail porque está indicado como participante neste evento. + nl-NL: U ontvangt deze e-mail omdat u staat vermeld als deelnemer aan dit evenement. + da-DK: Du modtager denne e-mail, fordi du er opført som deltager i denne begivenhed. + ca-ES: Reps aquest correu electrònic perquè estàs registrat com a participant d'aquest esdeveniment. + el-GR: Λαμβάνετε αυτό το e-mail καθώς είστε στη λίστα συμμετεχόντων αυτής της εκδήλωσης. + sv-SE: Du får det här mejlet eftersom du är uppskriven som deltagare i den här händelsen. + pl-PL: Otrzymujesz tego e-maila, ponieważ jesteś uczestnikiem tego wydarzenia. + pt-BR: Você está recebendo este e-mail porque está listado como participante deste evento. + ru-RU: Вы получаете это письмо, так как указаны участником этого события. + uk-UA: Ви отримуєте цей лист, оскільки вас зазначено учасником цієї події. + bg-BG: Получавате този имейл, защото сте посочени като участник в това събитие. + cs-CZ: Tento e-mail dostáváte, protože jste uvedeni jako účastník této události. + sk-SK: Tento e-mail dostávate, pretože ste uvedený ako účastník tejto udalosti. + sl-SI: To e-pošto prejemate, ker ste navedeni kot udeleženec tega dogodka. + hr-HR: Ovu e-poštu primate jer ste navedeni kao sudionik ovog događaja. + lt-LT: Šį el. laišką gaunate, nes esate nurodytas kaip šio įvykio dalyvis. + hu-HU: Azért kapja ezt az e-mailt, mert az esemény résztvevői között szerepel. + ro-RO: Primiți acest e-mail deoarece sunteți listat ca participant la acest eveniment. + fi-FI: Saat tämän sähköpostin, koska sinut on merkitty tämän tapahtuman osallistujaksi. + nb-NO: Du mottar denne e-posten fordi du er oppført som deltaker på denne hendelsen. + tr-TR: Bu etkinliğin katılımcısı olarak listelendiğiniz için bu e-postayı alıyorsunuz. + zh-CN: 您收到此邮件是因为您被列为此活动的参与者。 + zh-TW: 您收到這封郵件是因為您被列為此活動的參與者。 + ja-JP: この予定の参加者として登録されているため、このメールが送信されています。 + ko-KR: 이 일정의 참석자로 등록되어 있어 이 이메일을 받고 있습니다. + th-TH: คุณได้รับอีเมลนี้เพราะคุณอยู่ในรายชื่อผู้เข้าร่วมกิจกรรมนี้ + vi-VN: Bạn nhận được email này vì bạn có tên trong danh sách người tham dự sự kiện này. + id-ID: Anda menerima email ini karena Anda terdaftar sebagai peserta acara ini. + hi-IN: आपको यह ईमेल इसलिए मिल रहा है क्योंकि आप इस इवेंट में प्रतिभागी के रूप में सूचीबद्ध हैं। + ar-SA: تتلقى هذه الرسالة لأنك مُدرَج كمشارك في هذا الحدث. + he-IL: קיבלת הודעה זו כי את/ה רשום/ה כמשתתף באירוע זה. + fa-IR: این ایمیل را دریافت می‌کنید چون به‌عنوان شرکت‌کننده این رویداد فهرست شده‌اید. + +calendar.imip_footer_2: + en-US: Forwarding this e-mail could allow any recipient to reply to the organizer, join the guest list, extend the invitation to others, or alter your RSVP. + es-ES: Reenviar este correo electrónico podría permitir que cualquier destinatario responda al organizador, se una a la lista de invitados, extienda la invitación a otros o modifique tu confirmación de asistencia. + fr-FR: Le transfert de cet e-mail pourrait permettre à tout destinataire de répondre à l'organisateur, de rejoindre la liste des invités, d'étendre l'invitation à d'autres ou de modifier votre réponse. + de-DE: Das Weiterleiten dieser E-Mail könnte es jedem Empfänger ermöglichen, dem Organisator zu antworten, der Gästeliste beizutreten, die Einladung an andere weiterzugeben oder Ihre Antwort zu ändern. + it-IT: L'inoltro di questa e-mail potrebbe consentire a qualsiasi destinatario di rispondere all'organizzatore, unirsi alla lista degli ospiti, estendere l'invito ad altri o modificare la tua conferma di partecipazione. + pt-PT: Encaminhar este e-mail pode permitir que qualquer destinatário responda ao organizador, se junte à lista de convidados, estenda o convite a outros ou altere a sua confirmação de presença. + nl-NL: Het doorsturen van deze e-mail kan elke ontvanger in staat stellen om te reageren op de organisator, deel te nemen aan de gastenlijst, de uitnodiging aan anderen door te geven, of uw antwoord te wijzigen. + da-DK: Videresendelse af denne e-mail kan give andre mulighed for at svare arrangøren, tilslutte sig gæstelisten, videreformidle invitationen eller ændre din tilmelding. + ca-ES: Reenviar aquest correu electrònic podria permetre que qualsevol destinatari respongui a l'organitzador, s'afegeixi a la llista de convidats, estengui la invitació a altres persones o modifiqui la teva confirmació d'assistència. + el-GR: Η προώθηση αυτού του e-mail μπορεί να επιτρέψει σε οποιονδήποτε παραλήπτη να απαντήσει στον διοργανωτή, να μπει στη λίστα καλεσμένων, να επεκτείνει την πρόσκληση σε άλλους ή να αλλάξει τη δήλωση παρουσίας σας. + sv-SE: Att vidarebefordra det här mejlet kan göra det möjligt för vilken mottagare som helst att skicka svar till arrangören, lägga till sig själv på gästlistan, skicka inbjudan vidare till andra, samt ändra ditt svar. + pl-PL: Przekazanie tej wiadomości e-mail może umożliwić każdemu odbiorcy odpowiedź do organizatora, dołączenie do listy gości, przesłanie zaproszenia innym osobom lub zmianę Twojej odpowiedzi RSVP. + pt-BR: Encaminhar este e-mail pode permitir que qualquer destinatário responda ao organizador, entre na lista de convidados, estenda o convite a outros ou altere sua confirmação de presença. + ru-RU: Пересылка этого письма может позволить любому получателю ответить организатору, добавиться в список гостей, переслать приглашение другим или изменить ваш ответ. + uk-UA: Пересилання цього листа може дозволити будь-якому одержувачу відповісти організатору, долучитися до списку гостей, переслати запрошення іншим або змінити вашу відповідь. + bg-BG: Препращането на този имейл може да позволи на всеки получател да отговори на организатора, да се включи в списъка с гости, да препрати поканата на други или да промени вашия отговор. + cs-CZ: Přeposlání tohoto e-mailu může komukoli z příjemců umožnit odpovědět organizátorovi, přidat se na seznam hostů, předat pozvánku dalším nebo změnit vaši odpověď. + sk-SK: Preposlanie tohto e-mailu môže komukoľvek z príjemcov umožniť odpovedať organizátorovi, pridať sa do zoznamu hostí, poslať pozvánku ďalším alebo zmeniť vašu odpoveď. + sl-SI: Posredovanje te e-pošte lahko vsakemu prejemniku omogoči, da odgovori organizatorju, se doda na seznam gostov, vabilo posreduje drugim ali spremeni vaš odgovor. + hr-HR: Prosljeđivanje ove e-pošte može svakom primatelju omogućiti da odgovori organizatoru, pridruži se popisu gostiju, proslijedi pozivnicu drugima ili promijeni vaš odgovor. + lt-LT: Persiuntus šį el. laišką bet kuris gavėjas galės atsakyti organizatoriui, prisidėti prie svečių sąrašo, persiųsti kvietimą kitiems arba pakeisti jūsų atsakymą. + hu-HU: Az e-mail továbbítása lehetővé teheti bármely címzettnek, hogy válaszoljon a szervezőnek, felkerüljön a vendéglistára, továbbküldje a meghívót másoknak, vagy módosítsa az Ön válaszát. + ro-RO: Redirecționarea acestui e-mail ar putea permite oricărui destinatar să răspundă organizatorului, să se adauge pe lista de invitați, să trimită invitația altora sau să vă modifice răspunsul. + fi-FI: Tämän viestin välittäminen voi antaa kenelle tahansa vastaanottajalle mahdollisuuden vastata järjestäjälle, liittyä vieraslistalle, välittää kutsun muille tai muuttaa vastaustasi. + nb-NO: Videresending av denne e-posten kan gi enhver mottaker mulighet til å svare arrangøren, bli med på gjestelisten, sende invitasjonen videre til andre eller endre svaret ditt. + tr-TR: Bu e-postayı iletmek, herhangi bir alıcının düzenleyene yanıt vermesine, konuk listesine katılmasına, daveti başkalarına iletmesine veya yanıtınızı değiştirmesine olanak tanıyabilir. + zh-CN: 转发此邮件可能会使任何收件人能够回复组织者、加入来宾名单、将邀请转发给他人或更改您的回复。 + zh-TW: 轉寄這封郵件可能會讓任何收件者回覆主辦人、加入來賓名單、將邀請轉寄給他人或變更您的回覆。 + ja-JP: このメールを転送すると、受信者が主催者に返信したり、ゲストリストに参加したり、他の人に招待を転送したり、あなたの出欠を変更したりできる場合があります。 + ko-KR: 이 이메일을 전달하면 수신자가 주최자에게 회신하거나, 참석자 목록에 참여하거나, 다른 사람에게 초대를 전달하거나, 회신을 변경할 수 있습니다. + th-TH: การส่งต่ออีเมลนี้อาจทำให้ผู้รับคนใดก็ตามตอบกลับผู้จัด เข้าร่วมรายชื่อผู้เข้าร่วม ส่งต่อคำเชิญให้ผู้อื่น หรือเปลี่ยนการตอบรับของคุณได้ + vi-VN: Việc chuyển tiếp email này có thể cho phép bất kỳ người nhận nào trả lời người tổ chức, tham gia danh sách khách mời, chuyển lời mời cho người khác hoặc thay đổi phản hồi của bạn. + id-ID: Meneruskan email ini dapat memungkinkan penerima mana pun membalas penyelenggara, bergabung dengan daftar tamu, meneruskan undangan kepada orang lain, atau mengubah jawaban Anda. + hi-IN: इस ईमेल को अग्रेषित करने से कोई भी प्राप्तकर्ता आयोजक को उत्तर दे सकता है, अतिथि सूची में शामिल हो सकता है, आमंत्रण दूसरों को भेज सकता है या आपका उत्तर बदल सकता है। + ar-SA: قد تتيح إعادة توجيه هذه الرسالة لأي مستلم الرد على المنظم، أو الانضمام إلى قائمة الضيوف، أو إرسال الدعوة إلى آخرين، أو تغيير ردك. + he-IL: העברת הודעה זו עלולה לאפשר לכל נמען להשיב למארגן, להצטרף לרשימת האורחים, להעביר את ההזמנה לאחרים או לשנות את תשובתך. + fa-IR: هدایت این ایمیل ممکن است به هر گیرنده‌ای اجازه دهد به برگزارکننده پاسخ دهد، به فهرست مهمانان بپیوندد، دعوت را برای دیگران بفرستد یا پاسخ شما را تغییر دهد. + +calendar.rsvp_recorded: + en-US: Your RSVP has been recorded. + es-ES: Tu confirmación de asistencia ha sido registrada. + fr-FR: Votre réponse a été enregistrée. + de-DE: Ihre Antwort wurde gespeichert. + it-IT: La tua conferma di partecipazione è stata registrata. + pt-PT: A sua confirmação de presença foi registada. + nl-NL: Uw antwoord is geregistreerd. + da-DK: Din tilmelding er blevet registreret. + ca-ES: La teva confirmació d'assistència s'ha registrat. + el-GR: Η κοινοποίηση της παρουσίας σας καταγράφηκε. + sv-SE: Ditt svar har registrerats. + pl-PL: Twoja odpowiedź RSVP została zarejestrowana. + pt-BR: Sua confirmação de presença foi registrada. + ru-RU: Ваш ответ записан. + uk-UA: Вашу відповідь записано. + bg-BG: Отговорът ви беше записан. + cs-CZ: Vaše odpověď byla zaznamenána. + sk-SK: Vaša odpoveď bola zaznamenaná. + sl-SI: Vaš odgovor je bil zabeležen. + hr-HR: Vaš odgovor je zabilježen. + lt-LT: Jūsų atsakymas užregistruotas. + hu-HU: A válaszát rögzítettük. + ro-RO: Răspunsul dumneavoastră a fost înregistrat. + fi-FI: Vastauksesi on kirjattu. + nb-NO: Svaret ditt er registrert. + tr-TR: Yanıtınız kaydedildi. + zh-CN: 您的回复已记录。 + zh-TW: 您的回覆已記錄。 + ja-JP: 出欠の返信を記録しました。 + ko-KR: 회신이 기록되었습니다. + th-TH: บันทึกการตอบรับของคุณแล้ว + vi-VN: Phản hồi của bạn đã được ghi nhận. + id-ID: Jawaban Anda telah dicatat. + hi-IN: आपका उत्तर दर्ज कर लिया गया है। + ar-SA: تم تسجيل ردك. + he-IL: תשובתך נרשמה. + fa-IR: پاسخ شما ثبت شد. + +calendar.rsvp_failed: + en-US: Failed to record your RSVP. + es-ES: No se pudo registrar tu confirmación de asistencia. + fr-FR: Impossible d'enregistrer votre réponse. + de-DE: Ihre Antwort konnte nicht gespeichert werden. + it-IT: Impossibile registrare la tua conferma di partecipazione. + pt-PT: Falha ao registar a sua confirmação de presença. + nl-NL: Kan uw antwoord niet registreren. + da-DK: Kunne ikke registrere din tilmelding. + ca-ES: No s'ha pogut registrar la teva confirmació d'assistència. + el-GR: Αποτυχία κατά την καταγραφή της παρουσίας σας. + sv-SE: Det gick inte att registrera ditt svar. + pl-PL: Nie udało się zarejestrować Twojej odpowiedzi RSVP. + pt-BR: Falha ao registrar sua confirmação de presença. + ru-RU: Не удалось записать ваш ответ. + uk-UA: Не вдалося записати вашу відповідь. + bg-BG: Записването на отговора ви беше неуспешно. + cs-CZ: Vaši odpověď se nepodařilo zaznamenat. + sk-SK: Vašu odpoveď sa nepodarilo zaznamenať. + sl-SI: Vašega odgovora ni bilo mogoče zabeležiti. + hr-HR: Bilježenje vašeg odgovora nije uspjelo. + lt-LT: Nepavyko užregistruoti jūsų atsakymo. + hu-HU: Nem sikerült rögzíteni a válaszát. + ro-RO: Înregistrarea răspunsului dumneavoastră a eșuat. + fi-FI: Vastauksesi kirjaaminen epäonnistui. + nb-NO: Kunne ikke registrere svaret ditt. + tr-TR: Yanıtınız kaydedilemedi. + zh-CN: 无法记录您的回复。 + zh-TW: 無法記錄您的回覆。 + ja-JP: 出欠の返信を記録できませんでした。 + ko-KR: 회신을 기록하지 못했습니다. + th-TH: ไม่สามารถบันทึกการตอบรับของคุณได้ + vi-VN: Không ghi nhận được phản hồi của bạn. + id-ID: Gagal mencatat jawaban Anda. + hi-IN: आपका उत्तर दर्ज नहीं किया जा सका। + ar-SA: تعذر تسجيل ردك. + he-IL: רישום תשובתך נכשל. + fa-IR: ثبت پاسخ شما ناموفق بود. + +calendar.event_not_found: + en-US: The event you are trying to RSVP to was not found. + es-ES: No se encontró el evento al que intentas confirmar asistencia. + fr-FR: L'événement auquel vous essayez de répondre n'a pas été trouvé. + de-DE: Das Ereignis, auf das Sie antworten möchten, wurde nicht gefunden. + it-IT: L'evento a cui stai cercando di confermare la partecipazione non è stato trovato. + pt-PT: O evento para o qual está a tentar confirmar presença não foi encontrado. + nl-NL: Het evenement waarop u probeert te reageren is niet gevonden. + da-DK: Begivenheden du forsøger at tilmelde dig blev ikke fundet. + ca-ES: No s'ha trobat l'esdeveniment al qual intentes confirmar l'assistència. + el-GR: Η εκδήλωση που θέλετε να κοινοποιήσετε την παρουσία σας δεν υπάρχει. + sv-SE: Händelsen du försöker OSA till kunde inte hittas. + pl-PL: Wydarzenie, na które próbujesz odpowiedzieć RSVP, nie zostało znalezione. + pt-BR: O evento para o qual você está tentando confirmar presença não foi encontrado. + ru-RU: Событие, на которое вы пытаетесь ответить, не найдено. + uk-UA: Подію, на яку ви намагаєтеся відповісти, не знайдено. + bg-BG: Събитието, на което се опитвате да отговорите, не беше намерено. + cs-CZ: Událost, na kterou se pokoušíte odpovědět, nebyla nalezena. + sk-SK: Udalosť, na ktorú sa pokúšate odpovedať, sa nenašla. + sl-SI: Dogodka, na katerega želite odgovoriti, ni bilo mogoče najti. + hr-HR: Događaj na koji pokušavate odgovoriti nije pronađen. + lt-LT: Įvykis, į kurį bandote atsakyti, nerastas. + hu-HU: Az esemény, amelyre válaszolni próbál, nem található. + ro-RO: Evenimentul la care încercați să răspundeți nu a fost găsit. + fi-FI: Tapahtumaa, johon yrität vastata, ei löytynyt. + nb-NO: Hendelsen du prøver å svare på, ble ikke funnet. + tr-TR: Yanıtlamaya çalıştığınız etkinlik bulunamadı. + zh-CN: 未找到您要回复的活动。 + zh-TW: 找不到您要回覆的活動。 + ja-JP: 返信しようとしている予定が見つかりませんでした。 + ko-KR: 회신하려는 일정을 찾을 수 없습니다. + th-TH: ไม่พบกิจกรรมที่คุณต้องการตอบรับ + vi-VN: Không tìm thấy sự kiện bạn muốn phản hồi. + id-ID: Acara yang ingin Anda jawab tidak ditemukan. + hi-IN: जिस इवेंट का आप उत्तर देना चाहते हैं वह नहीं मिला। + ar-SA: لم يتم العثور على الحدث الذي تحاول الرد عليه. + he-IL: האירוע שאליו ניסית להשיב לא נמצא. + fa-IR: رویدادی که می‌خواهید به آن پاسخ دهید یافت نشد. + +calendar.invalid_rsvp: + en-US: The RSVP request was invalid or malformed. + es-ES: La solicitud de confirmación de asistencia era inválida o estaba mal formada. + fr-FR: La demande de réponse était invalide ou mal formée. + de-DE: Die Antwortanfrage war ungültig oder fehlerhaft. + it-IT: La richiesta di conferma di partecipazione era non valida o mal formata. + pt-PT: O pedido de confirmação de presença era inválido ou estava mal formado. + nl-NL: Het antwoord was ongeldig of onjuist opgemaakt. + da-DK: Tilmeldingsanmodningen var ugyldig eller forkert udformet. + ca-ES: La sol·licitud de confirmació d'assistència no era vàlida o estava mal formada. + el-GR: Η αίτηση κοινοποίησης παρουσίας ήταν άκυρη ή εσφαλμένα διαμορφωμένη. + sv-SE: Svaret på inbjudan var ogiltigt eller i fel format. + pl-PL: Żądanie RSVP było nieprawidłowe lub źle sformułowane. + pt-BR: A solicitação de confirmação de presença era inválida ou estava malformada. + ru-RU: Запрос ответа недействителен или составлен неверно. + uk-UA: Запит на відповідь недійсний або хибно сформований. + bg-BG: Заявката за отговор беше невалидна или неправилно оформена. + cs-CZ: Požadavek na odpověď byl neplatný nebo poškozený. + sk-SK: Požiadavka na odpoveď bola neplatná alebo poškodená. + sl-SI: Zahteva za odgovor je bila neveljavna ali napačno oblikovana. + hr-HR: Zahtjev za odgovor bio je neispravan ili neispravno oblikovan. + lt-LT: Atsakymo užklausa buvo netinkama arba su klaidomis. + hu-HU: A válaszkérés érvénytelen vagy hibás volt. + ro-RO: Cererea de răspuns a fost invalidă sau incorect formată. + fi-FI: Vastauspyyntö oli virheellinen tai puutteellinen. + nb-NO: Svarforespørselen var ugyldig eller feilformatert. + tr-TR: Yanıt isteği geçersiz veya hatalı biçimlendirilmiş. + zh-CN: 回复请求无效或格式错误。 + zh-TW: 回覆要求無效或格式錯誤。 + ja-JP: 返信のリクエストが無効か、形式が正しくありません。 + ko-KR: 회신 요청이 잘못되었거나 형식이 올바르지 않습니다. + th-TH: คำขอตอบรับไม่ถูกต้องหรือมีรูปแบบผิดพลาด + vi-VN: Yêu cầu phản hồi không hợp lệ hoặc sai định dạng. + id-ID: Permintaan jawaban tidak valid atau salah format. + hi-IN: उत्तर अनुरोध अमान्य या त्रुटिपूर्ण था। + ar-SA: طلب الرد غير صالح أو تالف. + he-IL: בקשת התשובה אינה תקינה או פגומה. + fa-IR: درخواست پاسخ نامعتبر یا نادرست بود. + +calendar.not_participant: + en-US: You are no longer a participant in this event. + es-ES: Ya no eres participante de este evento. + fr-FR: Vous n'êtes plus participant à cet événement. + de-DE: Sie sind kein Teilnehmer dieses Ereignisses mehr. + it-IT: Non sei più un partecipante a questo evento. + pt-PT: Já não é participante deste evento. + nl-NL: U bent geen deelnemer meer aan dit evenement. + da-DK: Du deltager ikke længere i denne begivenhed. + ca-ES: Ja no ets un participant d'aquest esdeveniment. + el-GR: Δε συμμετέχετε πια σε αυτή την εκδήλωση. + sv-SE: Du är inte längre deltagare i den här händelsen. + pl-PL: Nie jesteś już uczestnikiem tego wydarzenia. + pt-BR: Você não é mais um participante deste evento. + ru-RU: Вы больше не участник этого события. + uk-UA: Ви більше не є учасником цієї події. + bg-BG: Вече не сте участник в това събитие. + cs-CZ: Již nejste účastníkem této události. + sk-SK: Už nie ste účastníkom tejto udalosti. + sl-SI: Niste več udeleženec tega dogodka. + hr-HR: Više niste sudionik ovog događaja. + lt-LT: Jūs nebesate šio įvykio dalyvis. + hu-HU: Ön már nem résztvevője ennek az eseménynek. + ro-RO: Nu mai sunteți participant la acest eveniment. + fi-FI: Et ole enää tämän tapahtuman osallistuja. + nb-NO: Du er ikke lenger deltaker på denne hendelsen. + tr-TR: Artık bu etkinliğin katılımcısı değilsiniz. + zh-CN: 您已不再是此活动的参与者。 + zh-TW: 您已不再是此活動的參與者。 + ja-JP: あなたはこの予定の参加者ではなくなりました。 + ko-KR: 더 이상 이 일정의 참석자가 아닙니다. + th-TH: คุณไม่ได้เป็นผู้เข้าร่วมกิจกรรมนี้อีกต่อไป + vi-VN: Bạn không còn là người tham dự sự kiện này. + id-ID: Anda bukan lagi peserta acara ini. + hi-IN: अब आप इस इवेंट के प्रतिभागी नहीं हैं। + ar-SA: لم تعد مشاركًا في هذا الحدث. + he-IL: אינך עוד משתתף באירוע זה. + fa-IR: شما دیگر شرکت‌کننده این رویداد نیستید. + +calendar.rsvp_send: + en-US: Send response + es-ES: Enviar respuesta + fr-FR: Envoyer la réponse + de-DE: Antwort senden + it-IT: Invia risposta + pt-PT: Enviar resposta + nl-NL: Antwoord versturen + da-DK: Send svar + ca-ES: Envia la resposta + el-GR: Αποστολή απάντησης + sv-SE: Skicka svar + pl-PL: Wyślij odpowiedź + pt-BR: Enviar resposta + ru-RU: Отправить ответ + uk-UA: Надіслати відповідь + bg-BG: Изпращане на отговор + cs-CZ: Odeslat odpověď + sk-SK: Odoslať odpoveď + sl-SI: Pošlji odgovor + hr-HR: Pošalji odgovor + lt-LT: Siųsti atsakymą + hu-HU: Válasz küldése + ro-RO: Trimite răspunsul + fi-FI: Lähetä vastaus + nb-NO: Send svar + tr-TR: Yanıt gönder + zh-CN: 发送回复 + zh-TW: 傳送回覆 + ja-JP: 返信を送信 + ko-KR: 회신 보내기 + th-TH: ส่งการตอบกลับ + vi-VN: Gửi phản hồi + id-ID: Kirim jawaban + hi-IN: उत्तर भेजें + ar-SA: إرسال الرد + he-IL: שליחת תשובה + fa-IR: ارسال پاسخ + +calendar.rsvp_update: + en-US: Update response + es-ES: Actualizar respuesta + fr-FR: Mettre à jour la réponse + de-DE: Antwort aktualisieren + it-IT: Aggiorna risposta + pt-PT: Atualizar resposta + nl-NL: Antwoord bijwerken + da-DK: Opdater svar + ca-ES: Actualitza la resposta + el-GR: Ενημέρωση απάντησης + sv-SE: Uppdatera svar + pl-PL: Zaktualizuj odpowiedź + pt-BR: Atualizar resposta + ru-RU: Обновить ответ + uk-UA: Оновити відповідь + bg-BG: Актуализиране на отговора + cs-CZ: Aktualizovat odpověď + sk-SK: Aktualizovať odpoveď + sl-SI: Posodobi odgovor + hr-HR: Ažuriraj odgovor + lt-LT: Atnaujinti atsakymą + hu-HU: Válasz frissítése + ro-RO: Actualizează răspunsul + fi-FI: Päivitä vastaus + nb-NO: Oppdater svar + tr-TR: Yanıtı güncelle + zh-CN: 更新回复 + zh-TW: 更新回覆 + ja-JP: 返信を更新 + ko-KR: 회신 업데이트 + th-TH: อัปเดตการตอบกลับ + vi-VN: Cập nhật phản hồi + id-ID: Perbarui jawaban + hi-IN: उत्तर अपडेट करें + ar-SA: تحديث الرد + he-IL: עדכון התשובה + fa-IR: به‌روزرسانی پاسخ + +calendar.rsvp_change: + en-US: Change my response + es-ES: Cambiar mi respuesta + fr-FR: Modifier ma réponse + de-DE: Meine Antwort ändern + it-IT: Cambia la mia risposta + pt-PT: Alterar a minha resposta + nl-NL: Mijn antwoord wijzigen + da-DK: Ret mit svar + ca-ES: Canvia la meva resposta + el-GR: Αλλαγή της απάντησής μου + sv-SE: Ändra mitt svar + pl-PL: Zmień moją odpowiedź + pt-BR: Alterar minha resposta + ru-RU: Изменить мой ответ + uk-UA: Змінити мою відповідь + bg-BG: Промяна на отговора ми + cs-CZ: Změnit mou odpověď + sk-SK: Zmeniť moju odpoveď + sl-SI: Spremeni moj odgovor + hr-HR: Promijeni moj odgovor + lt-LT: Keisti mano atsakymą + hu-HU: Válaszom módosítása + ro-RO: Schimbă răspunsul meu + fi-FI: Muuta vastaustani + nb-NO: Endre svaret mitt + tr-TR: Yanıtımı değiştir + zh-CN: 更改我的回复 + zh-TW: 變更我的回覆 + ja-JP: 返信を変更 + ko-KR: 회신 변경 + th-TH: เปลี่ยนการตอบกลับของฉัน + vi-VN: Thay đổi phản hồi của tôi + id-ID: Ubah jawaban saya + hi-IN: मेरा उत्तर बदलें + ar-SA: تغيير ردي + he-IL: שינוי התשובה שלי + fa-IR: تغییر پاسخ من + +calendar.rsvp_comment: + en-US: Note to the organizer (optional) + es-ES: Nota para el organizador (opcional) + fr-FR: Message pour l'organisateur (facultatif) + de-DE: Nachricht an den Organisator (optional) + it-IT: Nota per l'organizzatore (facoltativa) + pt-PT: Nota para o organizador (opcional) + nl-NL: Bericht aan de organisator (optioneel) + da-DK: Besked til arrangøren (valgfri) + ca-ES: Nota per a l'organitzador (opcional) + el-GR: Σημείωμα προς τον διοργανωτή (προαιρετικό) + sv-SE: Meddelande till arrangören (valfritt) + pl-PL: Wiadomość do organizatora (opcjonalnie) + pt-BR: Nota para o organizador (opcional) + ru-RU: Заметка организатору (необязательно) + uk-UA: Нотатка організатору (необов'язково) + bg-BG: Бележка до организатора (незадължително) + cs-CZ: Poznámka pro organizátora (nepovinné) + sk-SK: Poznámka pre organizátora (nepovinné) + sl-SI: Opomba organizatorju (neobvezno) + hr-HR: Napomena organizatoru (nije obavezno) + lt-LT: Pastaba organizatoriui (nebūtina) + hu-HU: Üzenet a szervezőnek (nem kötelező) + ro-RO: Notă pentru organizator (opțional) + fi-FI: Viesti järjestäjälle (valinnainen) + nb-NO: Melding til arrangøren (valgfritt) + tr-TR: Düzenleyene not (isteğe bağlı) + zh-CN: 给组织者的备注(可选) + zh-TW: 給主辦人的備註(選填) + ja-JP: 主催者へのメモ(任意) + ko-KR: 주최자에게 보낼 메모 (선택사항) + th-TH: ข้อความถึงผู้จัด (ไม่บังคับ) + vi-VN: Ghi chú cho người tổ chức (không bắt buộc) + id-ID: Catatan untuk penyelenggara (opsional) + hi-IN: आयोजक के लिए टिप्पणी (वैकल्पिक) + ar-SA: ملاحظة إلى المنظم (اختياري) + he-IL: הערה למארגן (לא חובה) + fa-IR: یادداشت برای برگزارکننده (اختیاری) + +calendar.rsvp_comment_hint: + en-US: Add a short message for the organizer. + es-ES: Añade un mensaje breve para el organizador. + fr-FR: Ajoutez un court message pour l'organisateur. + de-DE: Fügen Sie eine kurze Nachricht für den Organisator hinzu. + it-IT: Aggiungi un breve messaggio per l'organizzatore. + pt-PT: Adicione uma mensagem breve para o organizador. + nl-NL: Voeg een kort bericht toe voor de organisator. + da-DK: Tilføj en kort besked til arrangøren. + ca-ES: Afegeix un missatge breu per a l'organitzador. + el-GR: Προσθέστε ένα σύντομο μήνυμα για τον διοργανωτή. + sv-SE: Lägg till ett kort meddelande till arrangören. + pl-PL: Dodaj krótką wiadomość do organizatora. + pt-BR: Adicione uma mensagem breve para o organizador. + ru-RU: Добавьте короткое сообщение для организатора. + uk-UA: Додайте коротке повідомлення для організатора. + bg-BG: Добавете кратко съобщение за организатора. + cs-CZ: Přidejte krátkou zprávu pro organizátora. + sk-SK: Pridajte krátku správu pre organizátora. + sl-SI: Dodajte kratko sporočilo za organizatorja. + hr-HR: Dodajte kratku poruku za organizatora. + lt-LT: Pridėkite trumpą žinutę organizatoriui. + hu-HU: Írjon rövid üzenetet a szervezőnek. + ro-RO: Adăugați un scurt mesaj pentru organizator. + fi-FI: Lisää lyhyt viesti järjestäjälle. + nb-NO: Legg til en kort melding til arrangøren. + tr-TR: Düzenleyen için kısa bir mesaj ekleyin. + zh-CN: 为组织者添加一条简短留言。 + zh-TW: 為主辦人新增一則簡短訊息。 + ja-JP: 主催者への短いメッセージを追加します。 + ko-KR: 주최자에게 보낼 짧은 메시지를 추가하세요. + th-TH: เพิ่มข้อความสั้น ๆ ถึงผู้จัด + vi-VN: Thêm một tin nhắn ngắn cho người tổ chức. + id-ID: Tambahkan pesan singkat untuk penyelenggara. + hi-IN: आयोजक के लिए एक छोटा संदेश जोड़ें। + ar-SA: أضف رسالة قصيرة إلى المنظم. + he-IL: אפשר להוסיף הודעה קצרה למארגן. + fa-IR: یک پیام کوتاه برای برگزارکننده بنویسید. + +calendar.rsvp_expired: + en-US: This RSVP link has expired. Ask the organizer to send a new invitation. + es-ES: Este enlace de confirmación ha caducado. Pide al organizador que envíe una nueva invitación. + fr-FR: Ce lien de réponse a expiré. Demandez à l'organisateur d'envoyer une nouvelle invitation. + de-DE: Dieser Antwortlink ist abgelaufen. Bitten Sie den Organisator um eine neue Einladung. + it-IT: Questo collegamento di risposta è scaduto. Chiedi all'organizzatore di inviare un nuovo invito. + pt-PT: Esta ligação de resposta expirou. Peça ao organizador que envie um novo convite. + nl-NL: Deze antwoordlink is verlopen. Vraag de organisator om een nieuwe uitnodiging. + da-DK: Dette svarlink er udløbet. Bed arrangøren om at sende en ny invitation. + ca-ES: Aquest enllaç de resposta ha caducat. Demana a l'organitzador que enviï una nova invitació. + el-GR: Αυτός ο σύνδεσμος απάντησης έχει λήξει. Ζητήστε από τον διοργανωτή να στείλει νέα πρόσκληση. + sv-SE: Den här svarslänken har gått ut. Be arrangören skicka en ny inbjudan. + pl-PL: Ten link odpowiedzi wygasł. Poproś organizatora o nowe zaproszenie. + pt-BR: Este link de resposta expirou. Peça ao organizador que envie um novo convite. + ru-RU: Срок действия этой ссылки истёк. Попросите организатора отправить новое приглашение. + uk-UA: Термін дії цього посилання минув. Попросіть організатора надіслати нове запрошення. + bg-BG: Срокът на тази връзка изтече. Помолете организатора да изпрати нова покана. + cs-CZ: Platnost tohoto odkazu vypršela. Požádejte organizátora o novou pozvánku. + sk-SK: Platnosť tohto odkazu vypršala. Požiadajte organizátora o novú pozvánku. + sl-SI: Ta povezava je potekla. Prosite organizatorja, naj pošlje novo vabilo. + hr-HR: Ova poveznica je istekla. Zatražite od organizatora novu pozivnicu. + lt-LT: Šios nuorodos galiojimas baigėsi. Paprašykite organizatoriaus atsiųsti naują kvietimą. + hu-HU: Ez a hivatkozás lejárt. Kérje meg a szervezőt, hogy küldjön új meghívót. + ro-RO: Acest link a expirat. Cereți organizatorului să trimită o nouă invitație. + fi-FI: Tämä linkki on vanhentunut. Pyydä järjestäjää lähettämään uusi kutsu. + nb-NO: Denne lenken er utløpt. Be arrangøren sende en ny invitasjon. + tr-TR: Bu bağlantının süresi doldu. Düzenleyenden yeni bir davet göndermesini isteyin. + zh-CN: 此链接已过期。请联系组织者重新发送邀请。 + zh-TW: 此連結已過期。請主辦人重新寄送邀請。 + ja-JP: このリンクは有効期限が切れています。主催者に新しい招待を送ってもらってください。 + ko-KR: 이 링크는 만료되었습니다. 주최자에게 새 초대를 요청하세요. + th-TH: ลิงก์นี้หมดอายุแล้ว โปรดขอให้ผู้จัดส่งคำเชิญใหม่ + vi-VN: Liên kết này đã hết hạn. Hãy đề nghị người tổ chức gửi lời mời mới. + id-ID: Tautan ini telah kedaluwarsa. Mintalah penyelenggara mengirim undangan baru. + hi-IN: यह लिंक समाप्त हो चुका है। आयोजक से नया आमंत्रण भेजने के लिए कहें। + ar-SA: انتهت صلاحية هذا الرابط. اطلب من المنظم إرسال دعوة جديدة. + he-IL: תוקף הקישור פג. יש לבקש מהמארגן לשלוח הזמנה חדשה. + fa-IR: این پیوند منقضی شده است. از برگزارکننده بخواهید دعوت‌نامه جدیدی بفرستد. + +calendar.rsvp_error: + en-US: Temporary server failure. If the problem persists, contact your administrator. + es-ES: Fallo temporal del servidor. Si el problema persiste, contacta con tu administrador. + fr-FR: Panne temporaire du serveur. Si le problème persiste, contactez votre administrateur. + de-DE: Vorübergehender Serverfehler. Wenn das Problem weiterhin besteht, wenden Sie sich an Ihren Administrator. + it-IT: Errore temporaneo del server. Se il problema persiste, contatta il tuo amministratore. + pt-PT: Falha temporária do servidor. Se o problema persistir, contacte o seu administrador. + nl-NL: Tijdelijke serverfout. Neem contact op met uw beheerder als het probleem aanhoudt. + da-DK: Midlertidig serverfejl. Kontakt din administrator, hvis problemet fortsætter. + ca-ES: Error temporal del servidor. Si el problema persisteix, contacta amb el teu administrador. + el-GR: Προσωρινή αποτυχία διακομιστή. Αν το πρόβλημα παραμείνει, επικοινωνήστε με τον διαχειριστή σας. + sv-SE: Tillfälligt serverfel. Kontakta din administratör om problemet kvarstår. + pl-PL: Tymczasowy błąd serwera. Jeśli problem będzie się powtarzał, skontaktuj się z administratorem. + pt-BR: Falha temporária do servidor. Se o problema persistir, entre em contato com seu administrador. + ru-RU: Временный сбой сервера. Если проблема повторяется, обратитесь к администратору. + uk-UA: Тимчасовий збій сервера. Якщо проблема не зникає, зверніться до адміністратора. + bg-BG: Временна сървърна грешка. Ако проблемът продължава, свържете се с администратора си. + cs-CZ: Dočasná chyba serveru. Pokud problém přetrvává, obraťte se na správce. + sk-SK: Dočasná chyba servera. Ak problém pretrváva, obráťte sa na správcu. + sl-SI: Začasna napaka strežnika. Če težava ne izgine, se obrnite na skrbnika. + hr-HR: Privremena pogreška poslužitelja. Ako se problem nastavi, obratite se administratoru. + lt-LT: Laikina serverio klaida. Jei problema kartojasi, kreipkitės į administratorių. + hu-HU: Átmeneti szerverhiba. Ha a probléma továbbra is fennáll, forduljon a rendszergazdához. + ro-RO: Eroare temporară de server. Dacă problema persistă, contactați administratorul. + fi-FI: Tilapäinen palvelinvirhe. Jos ongelma jatkuu, ota yhteyttä järjestelmänvalvojaan. + nb-NO: Midlertidig serverfeil. Kontakt administratoren hvis problemet vedvarer. + tr-TR: Geçici sunucu hatası. Sorun devam ederse yöneticinizle iletişime geçin. + zh-CN: 服务器暂时出错。如果问题仍然存在,请联系管理员。 + zh-TW: 伺服器暫時發生錯誤。若問題持續發生,請聯絡管理員。 + ja-JP: サーバーの一時的なエラーです。問題が解決しない場合は管理者にお問い合わせください。 + ko-KR: 일시적인 서버 오류입니다. 문제가 계속되면 관리자에게 문의하세요. + th-TH: เซิร์ฟเวอร์ขัดข้องชั่วคราว หากยังพบปัญหา โปรดติดต่อผู้ดูแลระบบ + vi-VN: Máy chủ tạm thời gặp sự cố. Nếu vấn đề vẫn tiếp diễn, hãy liên hệ quản trị viên. + id-ID: Kegagalan server sementara. Jika masalah berlanjut, hubungi administrator Anda. + hi-IN: सर्वर में अस्थायी गड़बड़ी। समस्या बनी रहे तो अपने व्यवस्थापक से संपर्क करें। + ar-SA: عطل مؤقت في الخادم. إذا استمرت المشكلة، فاتصل بالمسؤول. + he-IL: תקלה זמנית בשרת. אם הבעיה נמשכת, יש לפנות למנהל המערכת. + fa-IR: خطای موقت سرور. اگر مشکل ادامه داشت، با مدیر خود تماس بگیرید. + +calendar.rsvp_notified: + en-US: The organizer has been notified. + es-ES: Se ha notificado al organizador. + fr-FR: L'organisateur a été prévenu. + de-DE: Der Organisator wurde benachrichtigt. + it-IT: L'organizzatore è stato avvisato. + pt-PT: O organizador foi notificado. + nl-NL: De organisator is op de hoogte gebracht. + da-DK: Arrangøren er blevet underrettet. + ca-ES: S'ha notificat l'organitzador. + el-GR: Ο διοργανωτής ειδοποιήθηκε. + sv-SE: Arrangören har underrättats. + pl-PL: Organizator został powiadomiony. + pt-BR: O organizador foi notificado. + ru-RU: Организатор уведомлён. + uk-UA: Організатора сповіщено. + bg-BG: Организаторът беше уведомен. + cs-CZ: Organizátor byl informován. + sk-SK: Organizátor bol informovaný. + sl-SI: Organizator je bil obveščen. + hr-HR: Organizator je obaviješten. + lt-LT: Organizatorius informuotas. + hu-HU: A szervezőt értesítettük. + ro-RO: Organizatorul a fost notificat. + fi-FI: Järjestäjälle on ilmoitettu. + nb-NO: Arrangøren er varslet. + tr-TR: Düzenleyen bilgilendirildi. + zh-CN: 已通知组织者。 + zh-TW: 已通知主辦人。 + ja-JP: 主催者に通知しました。 + ko-KR: 주최자에게 알렸습니다. + th-TH: แจ้งผู้จัดแล้ว + vi-VN: Người tổ chức đã được thông báo. + id-ID: Penyelenggara telah diberi tahu. + hi-IN: आयोजक को सूचित कर दिया गया है। + ar-SA: تم إشعار المنظم. + he-IL: המארגן קיבל הודעה. + fa-IR: برگزارکننده مطلع شد. + +calendar.rsvp_you: + en-US: you + es-ES: tú + fr-FR: vous + de-DE: Sie + it-IT: tu + pt-PT: você + nl-NL: u + da-DK: dig + ca-ES: tu + el-GR: εσείς + sv-SE: du + pl-PL: Ty + pt-BR: você + ru-RU: вы + uk-UA: ви + bg-BG: вие + cs-CZ: vy + sk-SK: vy + sl-SI: vi + hr-HR: vi + lt-LT: jūs + hu-HU: Ön + ro-RO: dumneavoastră + fi-FI: sinä + nb-NO: du + tr-TR: siz + zh-CN: 您 + zh-TW: 您 + ja-JP: あなた + ko-KR: 나 + th-TH: คุณ + vi-VN: bạn + id-ID: Anda + hi-IN: आप + ar-SA: أنت + he-IL: את/ה + fa-IR: شما + +calendar.show_more: + en-US: Show more + es-ES: Mostrar más + fr-FR: Afficher plus + de-DE: Mehr anzeigen + it-IT: Mostra altro + pt-PT: Mostrar mais + nl-NL: Meer tonen + da-DK: Vis mere + ca-ES: Mostra'n més + el-GR: Εμφάνιση περισσότερων + sv-SE: Visa mer + pl-PL: Pokaż więcej + pt-BR: Mostrar mais + ru-RU: Показать больше + uk-UA: Показати більше + bg-BG: Показване на още + cs-CZ: Zobrazit více + sk-SK: Zobraziť viac + sl-SI: Prikaži več + hr-HR: Prikaži više + lt-LT: Rodyti daugiau + hu-HU: Több megjelenítése + ro-RO: Afișează mai mult + fi-FI: Näytä enemmän + nb-NO: Vis mer + tr-TR: Daha fazla göster + zh-CN: 显示更多 + zh-TW: 顯示更多 + ja-JP: さらに表示 + ko-KR: 더 보기 + th-TH: แสดงเพิ่มเติม + vi-VN: Hiển thị thêm + id-ID: Tampilkan lebih banyak + hi-IN: और दिखाएँ + ar-SA: عرض المزيد + he-IL: הצגת עוד + fa-IR: نمایش بیشتر + +calendar.show_less: + en-US: Show less + es-ES: Mostrar menos + fr-FR: Afficher moins + de-DE: Weniger anzeigen + it-IT: Mostra meno + pt-PT: Mostrar menos + nl-NL: Minder tonen + da-DK: Vis mindre + ca-ES: Mostra'n menys + el-GR: Εμφάνιση λιγότερων + sv-SE: Visa mindre + pl-PL: Pokaż mniej + pt-BR: Mostrar menos + ru-RU: Показать меньше + uk-UA: Показати менше + bg-BG: Показване на по-малко + cs-CZ: Zobrazit méně + sk-SK: Zobraziť menej + sl-SI: Prikaži manj + hr-HR: Prikaži manje + lt-LT: Rodyti mažiau + hu-HU: Kevesebb megjelenítése + ro-RO: Afișează mai puțin + fi-FI: Näytä vähemmän + nb-NO: Vis mindre + tr-TR: Daha az göster + zh-CN: 显示更少 + zh-TW: 顯示較少 + ja-JP: 表示を減らす + ko-KR: 간략히 보기 + th-TH: แสดงน้อยลง + vi-VN: Thu gọn + id-ID: Tampilkan lebih sedikit + hi-IN: कम दिखाएँ + ar-SA: عرض أقل + he-IL: הצגה מצומצמת + fa-IR: نمایش کمتر + +calendar.rrule_secondly: + en-US: one=Every second;other=Every $n seconds + es-ES: one=Cada segundo;other=Cada $n segundos + fr-FR: one=Toutes les secondes;other=Toutes les $n secondes + de-DE: one=Sekündlich;other=Alle $n Sekunden + it-IT: one=Ogni secondo;other=Ogni $n secondi + pt-PT: one=Todos os segundos;other=A cada $n segundos + nl-NL: one=Elke seconde;other=Elke $n seconden + da-DK: one=Hvert sekund;other=Hvert $n. sekund + ca-ES: one=Cada segon;other=Cada $n segons + el-GR: one=Κάθε δευτερόλεπτο;other=Κάθε $n δευτερόλεπτα + sv-SE: one=Varje sekund;other=Var $n:e sekund + pl-PL: one=Co sekundę;few=Co $n sekundy;many=Co $n sekund;other=Co $n sekundy + pt-BR: one=A cada segundo;other=A cada $n segundos + ru-RU: one=Каждую секунду;few=Каждые $n секунды;many=Каждые $n секунд;other=Каждые $n секунды + uk-UA: one=Щосекунди;few=Кожні $n секунди;many=Кожні $n секунд;other=Кожні $n секунди + bg-BG: one=Всяка секунда;other=На всеки $n секунди + cs-CZ: one=Každou sekundu;few=Každé $n sekundy;other=Každých $n sekund + sk-SK: one=Každú sekundu;few=Každé $n sekundy;other=Každých $n sekúnd + sl-SI: one=Vsako sekundo;two=Vsaki $n sekundi;few=Vsake $n sekunde;other=Vsakih $n sekund + hr-HR: one=Svake sekunde;few=Svake $n sekunde;other=Svakih $n sekundi + lt-LT: one=Kas sekundę;few=Kas $n sekundes;other=Kas $n sekundžių + hu-HU: one=Másodpercenként;other=Minden $n. másodpercben + ro-RO: one=În fiecare secundă;few=La fiecare $n secunde;other=La fiecare $n de secunde + fi-FI: one=Joka sekunti;other=$n sekunnin välein + nb-NO: one=Hvert sekund;other=Hvert $n. sekund + tr-TR: one=Her saniye;other=Her $n saniyede bir + zh-CN: other=每$n秒 + zh-TW: other=每$n秒 + ja-JP: other=$n秒ごと + ko-KR: other=$n초마다 + th-TH: other=ทุก $n วินาที + vi-VN: other=Mỗi $n giây + id-ID: other=Setiap $n detik + hi-IN: one=हर सेकंड;other=हर $n सेकंड + ar-SA: one=كل ثانية;two=كل ثانيتين;few=كل $n ثوانٍ;many=كل $n ثانية;other=كل $n ثانية + he-IL: one=בכל שנייה;two=בכל שתי שניות;other=בכל $n שניות + fa-IR: one=هر ثانیه;other=هر $n ثانیه + +calendar.rrule_minutely: + en-US: one=Every minute;other=Every $n minutes + es-ES: one=Cada minuto;other=Cada $n minutos + fr-FR: one=Toutes les minutes;other=Toutes les $n minutes + de-DE: one=Minütlich;other=Alle $n Minuten + it-IT: one=Ogni minuto;other=Ogni $n minuti + pt-PT: one=Todos os minutos;other=A cada $n minutos + nl-NL: one=Elke minuut;other=Elke $n minuten + da-DK: one=Hvert minut;other=Hvert $n. minut + ca-ES: one=Cada minut;other=Cada $n minuts + el-GR: one=Κάθε λεπτό;other=Κάθε $n λεπτά + sv-SE: one=Varje minut;other=Var $n:e minut + pl-PL: one=Co minutę;few=Co $n minuty;many=Co $n minut;other=Co $n minuty + pt-BR: one=A cada minuto;other=A cada $n minutos + ru-RU: one=Каждую минуту;few=Каждые $n минуты;many=Каждые $n минут;other=Каждые $n минуты + uk-UA: one=Щохвилини;few=Кожні $n хвилини;many=Кожні $n хвилин;other=Кожні $n хвилини + bg-BG: one=Всяка минута;other=На всеки $n минути + cs-CZ: one=Každou minutu;few=Každé $n minuty;other=Každých $n minut + sk-SK: one=Každú minútu;few=Každé $n minúty;other=Každých $n minút + sl-SI: one=Vsako minuto;two=Vsaki $n minuti;few=Vsake $n minute;other=Vsakih $n minut + hr-HR: one=Svake minute;few=Svake $n minute;other=Svakih $n minuta + lt-LT: one=Kas minutę;few=Kas $n minutes;other=Kas $n minučių + hu-HU: one=Percenként;other=Minden $n. percben + ro-RO: one=În fiecare minut;few=La fiecare $n minute;other=La fiecare $n de minute + fi-FI: one=Joka minuutti;other=$n minuutin välein + nb-NO: one=Hvert minutt;other=Hvert $n. minutt + tr-TR: one=Her dakika;other=Her $n dakikada bir + zh-CN: other=每$n分钟 + zh-TW: other=每$n分鐘 + ja-JP: other=$n分ごと + ko-KR: other=$n분마다 + th-TH: other=ทุก $n นาที + vi-VN: other=Mỗi $n phút + id-ID: other=Setiap $n menit + hi-IN: one=हर मिनट;other=हर $n मिनट + ar-SA: one=كل دقيقة;two=كل دقيقتين;few=كل $n دقائق;many=كل $n دقيقة;other=كل $n دقيقة + he-IL: one=בכל דקה;two=בכל שתי דקות;other=בכל $n דקות + fa-IR: one=هر دقیقه;other=هر $n دقیقه + +calendar.rrule_hourly: + en-US: one=Every hour;other=Every $n hours + es-ES: one=Cada hora;other=Cada $n horas + fr-FR: one=Toutes les heures;other=Toutes les $n heures + de-DE: one=Stündlich;other=Alle $n Stunden + it-IT: one=Ogni ora;other=Ogni $n ore + pt-PT: one=Todas as horas;other=A cada $n horas + nl-NL: one=Elk uur;other=Elke $n uur + da-DK: one=Hver time;other=Hver $n. time + ca-ES: one=Cada hora;other=Cada $n hores + el-GR: one=Κάθε ώρα;other=Κάθε $n ώρες + sv-SE: one=Varje timme;other=Var $n:e timme + pl-PL: one=Co godzinę;few=Co $n godziny;many=Co $n godzin;other=Co $n godziny + pt-BR: one=A cada hora;other=A cada $n horas + ru-RU: one=Каждый час;few=Каждые $n часа;many=Каждые $n часов;other=Каждые $n часа + uk-UA: one=Щогодини;few=Кожні $n години;many=Кожні $n годин;other=Кожні $n години + bg-BG: one=Всеки час;other=На всеки $n часа + cs-CZ: one=Každou hodinu;few=Každé $n hodiny;other=Každých $n hodin + sk-SK: one=Každú hodinu;few=Každé $n hodiny;other=Každých $n hodín + sl-SI: one=Vsako uro;two=Vsaki $n uri;few=Vsake $n ure;other=Vsakih $n ur + hr-HR: one=Svakog sata;few=Svaka $n sata;other=Svakih $n sati + lt-LT: one=Kas valandą;few=Kas $n valandas;other=Kas $n valandų + hu-HU: one=Óránként;other=Minden $n. órában + ro-RO: one=În fiecare oră;few=La fiecare $n ore;other=La fiecare $n de ore + fi-FI: one=Joka tunti;other=$n tunnin välein + nb-NO: one=Hver time;other=Hver $n. time + tr-TR: one=Her saat;other=Her $n saatte bir + zh-CN: other=每$n小时 + zh-TW: other=每$n小時 + ja-JP: other=$n時間ごと + ko-KR: other=$n시간마다 + th-TH: other=ทุก $n ชั่วโมง + vi-VN: other=Mỗi $n giờ + id-ID: other=Setiap $n jam + hi-IN: one=हर घंटे;other=हर $n घंटे + ar-SA: one=كل ساعة;two=كل ساعتين;few=كل $n ساعات;many=كل $n ساعة;other=كل $n ساعة + he-IL: one=בכל שעה;two=בכל שעתיים;other=בכל $n שעות + fa-IR: one=هر ساعت;other=هر $n ساعت + +calendar.rrule_daily: + en-US: one=Every day;other=Every $n days + es-ES: one=Todos los días;other=Cada $n días + fr-FR: one=Tous les jours;other=Tous les $n jours + de-DE: one=Täglich;other=Alle $n Tage + it-IT: one=Ogni giorno;other=Ogni $n giorni + pt-PT: one=Todos os dias;other=A cada $n dias + nl-NL: one=Elke dag;other=Elke $n dagen + da-DK: one=Hver dag;other=Hver $n. dag + ca-ES: one=Cada dia;other=Cada $n dies + el-GR: one=Κάθε μέρα;other=Κάθε $n μέρες + sv-SE: one=Varje dag;other=Var $n:e dag + pl-PL: one=Codziennie;few=Co $n dni;many=Co $n dni;other=Co $n dni + pt-BR: one=Todos os dias;other=A cada $n dias + ru-RU: one=Каждый день;few=Каждые $n дня;many=Каждые $n дней;other=Каждые $n дня + uk-UA: one=Щодня;few=Кожні $n дні;many=Кожні $n днів;other=Кожні $n дні + bg-BG: one=Всеки ден;other=На всеки $n дни + cs-CZ: one=Každý den;few=Každé $n dny;other=Každých $n dní + sk-SK: one=Každý deň;few=Každé $n dni;other=Každých $n dní + sl-SI: one=Vsak dan;two=Vsaka $n dneva;few=Vsake $n dni;other=Vsakih $n dni + hr-HR: one=Svakog dana;few=Svaka $n dana;other=Svakih $n dana + lt-LT: one=Kas dieną;few=Kas $n dienas;other=Kas $n dienų + hu-HU: one=Naponta;other=Minden $n. napon + ro-RO: one=În fiecare zi;few=La fiecare $n zile;other=La fiecare $n de zile + fi-FI: one=Joka päivä;other=$n päivän välein + nb-NO: one=Hver dag;other=Hver $n. dag + tr-TR: one=Her gün;other=Her $n günde bir + zh-CN: other=每$n天 + zh-TW: other=每$n天 + ja-JP: other=$n日ごと + ko-KR: other=$n일마다 + th-TH: other=ทุก $n วัน + vi-VN: other=Mỗi $n ngày + id-ID: other=Setiap $n hari + hi-IN: one=हर दिन;other=हर $n दिन + ar-SA: one=كل يوم;two=كل يومين;few=كل $n أيام;many=كل $n يومًا;other=كل $n يوم + he-IL: one=בכל יום;two=בכל יומיים;other=בכל $n ימים + fa-IR: one=هر روز;other=هر $n روز + +calendar.rrule_weekly: + en-US: one=Every week;other=Every $n weeks + es-ES: one=Cada semana;other=Cada $n semanas + fr-FR: one=Toutes les semaines;other=Toutes les $n semaines + de-DE: one=Wöchentlich;other=Alle $n Wochen + it-IT: one=Ogni settimana;other=Ogni $n settimane + pt-PT: one=Todas as semanas;other=A cada $n semanas + nl-NL: one=Elke week;other=Elke $n weken + da-DK: one=Hver uge;other=Hver $n. uge + ca-ES: one=Cada setmana;other=Cada $n setmanes + el-GR: one=Κάθε εβδομάδα;other=Κάθε $n εβδομάδες + sv-SE: one=Varje vecka;other=Var $n:e vecka + pl-PL: one=Co tydzień;few=Co $n tygodnie;many=Co $n tygodni;other=Co $n tygodnia + pt-BR: one=Toda semana;other=A cada $n semanas + ru-RU: one=Каждую неделю;few=Каждые $n недели;many=Каждые $n недель;other=Каждые $n недели + uk-UA: one=Щотижня;few=Кожні $n тижні;many=Кожні $n тижнів;other=Кожні $n тижні + bg-BG: one=Всяка седмица;other=На всеки $n седмици + cs-CZ: one=Každý týden;few=Každé $n týdny;other=Každých $n týdnů + sk-SK: one=Každý týždeň;few=Každé $n týždne;other=Každých $n týždňov + sl-SI: one=Vsak teden;two=Vsaka $n tedna;few=Vsake $n tedne;other=Vsakih $n tednov + hr-HR: one=Svakog tjedna;few=Svaka $n tjedna;other=Svakih $n tjedana + lt-LT: one=Kas savaitę;few=Kas $n savaites;other=Kas $n savaičių + hu-HU: one=Hetente;other=Minden $n. héten + ro-RO: one=În fiecare săptămână;few=La fiecare $n săptămâni;other=La fiecare $n de săptămâni + fi-FI: one=Joka viikko;other=$n viikon välein + nb-NO: one=Hver uke;other=Hver $n. uke + tr-TR: one=Her hafta;other=Her $n haftada bir + zh-CN: other=每$n周 + zh-TW: other=每$n週 + ja-JP: other=$n週間ごと + ko-KR: other=$n주마다 + th-TH: other=ทุก $n สัปดาห์ + vi-VN: other=Mỗi $n tuần + id-ID: other=Setiap $n minggu + hi-IN: one=हर सप्ताह;other=हर $n सप्ताह + ar-SA: one=كل أسبوع;two=كل أسبوعين;few=كل $n أسابيع;many=كل $n أسبوعًا;other=كل $n أسبوع + he-IL: one=בכל שבוע;two=בכל שבועיים;other=בכל $n שבועות + fa-IR: one=هر هفته;other=هر $n هفته + +calendar.rrule_monthly: + en-US: one=Every month;other=Every $n months + es-ES: one=Cada mes;other=Cada $n meses + fr-FR: one=Tous les mois;other=Tous les $n mois + de-DE: one=Monatlich;other=Alle $n Monate + it-IT: one=Ogni mese;other=Ogni $n mesi + pt-PT: one=Todos os meses;other=A cada $n meses + nl-NL: one=Elke maand;other=Elke $n maanden + da-DK: one=Hver måned;other=Hver $n. måned + ca-ES: one=Cada mes;other=Cada $n mesos + el-GR: one=Κάθε μήνα;other=Κάθε $n μήνες + sv-SE: one=Varje månad;other=Var $n:e månad + pl-PL: one=Co miesiąc;few=Co $n miesiące;many=Co $n miesięcy;other=Co $n miesiąca + pt-BR: one=Todo mês;other=A cada $n meses + ru-RU: one=Каждый месяц;few=Каждые $n месяца;many=Каждые $n месяцев;other=Каждые $n месяца + uk-UA: one=Щомісяця;few=Кожні $n місяці;many=Кожні $n місяців;other=Кожні $n місяці + bg-BG: one=Всеки месец;other=На всеки $n месеца + cs-CZ: one=Každý měsíc;few=Každé $n měsíce;other=Každých $n měsíců + sk-SK: one=Každý mesiac;few=Každé $n mesiace;other=Každých $n mesiacov + sl-SI: one=Vsak mesec;two=Vsaka $n meseca;few=Vsake $n mesece;other=Vsakih $n mesecev + hr-HR: one=Svakog mjeseca;few=Svaka $n mjeseca;other=Svakih $n mjeseci + lt-LT: one=Kas mėnesį;few=Kas $n mėnesius;other=Kas $n mėnesių + hu-HU: one=Havonta;other=Minden $n. hónapban + ro-RO: one=În fiecare lună;few=La fiecare $n luni;other=La fiecare $n de luni + fi-FI: one=Joka kuukausi;other=$n kuukauden välein + nb-NO: one=Hver måned;other=Hver $n. måned + tr-TR: one=Her ay;other=Her $n ayda bir + zh-CN: other=每$n个月 + zh-TW: other=每$n個月 + ja-JP: other=$nか月ごと + ko-KR: other=$n개월마다 + th-TH: other=ทุก $n เดือน + vi-VN: other=Mỗi $n tháng + id-ID: other=Setiap $n bulan + hi-IN: one=हर महीने;other=हर $n महीने + ar-SA: one=كل شهر;two=كل شهرين;few=كل $n أشهر;many=كل $n شهرًا;other=كل $n شهر + he-IL: one=בכל חודש;two=בכל חודשיים;other=בכל $n חודשים + fa-IR: one=هر ماه;other=هر $n ماه + +calendar.rrule_yearly: + en-US: one=Every year;other=Every $n years + es-ES: one=Cada año;other=Cada $n años + fr-FR: one=Tous les ans;other=Tous les $n ans + de-DE: one=Jährlich;other=Alle $n Jahre + it-IT: one=Ogni anno;other=Ogni $n anni + pt-PT: one=Todos os anos;other=A cada $n anos + nl-NL: one=Elk jaar;other=Elke $n jaar + da-DK: one=Hvert år;other=Hvert $n. år + ca-ES: one=Cada any;other=Cada $n anys + el-GR: one=Κάθε χρόνο;other=Κάθε $n χρόνια + sv-SE: one=Varje år;other=Vart $n:e år + pl-PL: one=Co rok;few=Co $n lata;many=Co $n lat;other=Co $n roku + pt-BR: one=Todo ano;other=A cada $n anos + ru-RU: one=Каждый год;few=Каждые $n года;many=Каждые $n лет;other=Каждые $n года + uk-UA: one=Щороку;few=Кожні $n роки;many=Кожні $n років;other=Кожні $n роки + bg-BG: one=Всяка година;other=На всеки $n години + cs-CZ: one=Každý rok;few=Každé $n roky;other=Každých $n let + sk-SK: one=Každý rok;few=Každé $n roky;other=Každých $n rokov + sl-SI: one=Vsako leto;two=Vsaki $n leti;few=Vsaka $n leta;other=Vsakih $n let + hr-HR: one=Svake godine;few=Svake $n godine;other=Svakih $n godina + lt-LT: one=Kas metus;few=Kas $n metus;other=Kas $n metų + hu-HU: one=Évente;other=Minden $n. évben + ro-RO: one=În fiecare an;few=La fiecare $n ani;other=La fiecare $n de ani + fi-FI: one=Joka vuosi;other=$n vuoden välein + nb-NO: one=Hvert år;other=Hvert $n. år + tr-TR: one=Her yıl;other=Her $n yılda bir + zh-CN: other=每$n年 + zh-TW: other=每$n年 + ja-JP: other=$n年ごと + ko-KR: other=$n년마다 + th-TH: other=ทุก $n ปี + vi-VN: other=Mỗi $n năm + id-ID: other=Setiap $n tahun + hi-IN: one=हर साल;other=हर $n साल + ar-SA: one=كل سنة;two=كل سنتين;few=كل $n سنوات;many=كل $n سنة;other=كل $n سنة + he-IL: one=בכל שנה;two=בכל שנתיים;other=בכל $n שנים + fa-IR: one=هر سال;other=هر $n سال + +calendar.rrule_count: + en-US: one=$n time;other=$n times + es-ES: one=$n vez;other=$n veces + fr-FR: one=$n fois;other=$n fois + de-DE: one=$n Mal;other=$n Mal + it-IT: one=$n volta;other=$n volte + pt-PT: one=$n vez;other=$n vezes + nl-NL: one=$n keer;other=$n keer + da-DK: one=$n gang;other=$n gange + ca-ES: one=$n vegada;other=$n vegades + el-GR: one=$n φορά;other=$n φορές + sv-SE: one=$n gång;other=$n gånger + pl-PL: one=$n raz;few=$n razy;many=$n razy;other=$n razy + pt-BR: one=$n vez;other=$n vezes + ru-RU: one=$n раз;few=$n раза;many=$n раз;other=$n раза + uk-UA: one=$n раз;few=$n рази;many=$n разів;other=$n раза + bg-BG: one=$n път;other=$n пъти + cs-CZ: one=$nkrát;other=$nkrát + sk-SK: one=$n-krát;other=$n-krát + sl-SI: one=$n-krat;other=$n-krat + hr-HR: one=$n put;few=$n puta;other=$n puta + lt-LT: one=$n kartą;few=$n kartus;other=$n kartų + hu-HU: one=$n alkalommal;other=$n alkalommal + ro-RO: one=o dată;few=de $n ori;other=de $n de ori + fi-FI: one=$n kerta;other=$n kertaa + nb-NO: one=$n gang;other=$n ganger + tr-TR: one=$n kez;other=$n kez + zh-CN: other=共$n次 + zh-TW: other=共$n次 + ja-JP: other=$n回 + ko-KR: other=$n회 + th-TH: other=$n ครั้ง + vi-VN: other=$n lần + id-ID: other=$n kali + hi-IN: one=$n बार;other=$n बार + ar-SA: one=مرة واحدة;two=مرتين;few=$n مرات;many=$n مرة;other=$n مرة + he-IL: one=פעם אחת;two=פעמיים;other=$n פעמים + fa-IR: one=$n بار;other=$n بار + +calendar.rrule_ordinal: + en-US: one=$nst;two=$nnd;few=$nrd;other=$nth + es-ES: other=$n.º + fr-FR: one=$ner;other=$ne + de-DE: other=$n. + it-IT: other=$nº + pt-PT: other=$n.º + nl-NL: other=$ne + da-DK: other=$n. + ca-ES: one=$nr;two=$nn;few=$nt;other=$nè + el-GR: other=$nη + sv-SE: other=$n:e + pl-PL: other=$n. + pt-BR: other=$nº + ru-RU: other=$n-й + uk-UA: other=$n-й + bg-BG: other=$n-и + cs-CZ: other=$n. + sk-SK: other=$n. + sl-SI: other=$n. + hr-HR: other=$n. + lt-LT: other=$n. + hu-HU: other=$n. + ro-RO: other=a $n-a + fi-FI: other=$n. + nb-NO: other=$n. + tr-TR: other=$n. + zh-CN: other=第$n + zh-TW: other=第$n + ja-JP: other=第$n + ko-KR: other=$n번째 + th-TH: other=ที่ $n + vi-VN: other=thứ $n + id-ID: other=ke-$n + hi-IN: other=$nवाँ + ar-SA: other=$n + he-IL: other=ה-$n + fa-IR: other=$nم + +calendar.rrule_on: + en-US: on $list + es-ES: los $list + fr-FR: le $list + de-DE: am $list + it-IT: il $list + pt-PT: às $list + nl-NL: op $list + da-DK: på $list + ca-ES: els $list + el-GR: κάθε $list + sv-SE: på $list + pl-PL: w $list + pt-BR: às $list + ru-RU: в $list + uk-UA: у $list + bg-BG: в $list + cs-CZ: v $list + sk-SK: v $list + sl-SI: v $list + hr-HR: u $list + lt-LT: $list + hu-HU: $list + ro-RO: $list + fi-FI: $list + nb-NO: på $list + tr-TR: $list günleri + zh-CN: 在$list + zh-TW: 在$list + ja-JP: $list + ko-KR: $list + th-TH: $list + vi-VN: vào $list + id-ID: pada $list + hi-IN: $list को + ar-SA: في $list + he-IL: ב$list + fa-IR: در $list + +calendar.rrule_on_the: + en-US: on the $list + es-ES: el día $list + fr-FR: le $list + de-DE: am $list + it-IT: il giorno $list + pt-PT: no dia $list + nl-NL: op de $list + da-DK: den $list + ca-ES: el dia $list + el-GR: στις $list + sv-SE: den $list + pl-PL: dnia $list + pt-BR: no dia $list + ru-RU: $list числа + uk-UA: $list числа + bg-BG: на $list + cs-CZ: dne $list + sk-SK: dňa $list + sl-SI: dne $list + hr-HR: dana $list + lt-LT: $list dieną + hu-HU: a hónap $list napján + ro-RO: în ziua $list + fi-FI: kuukauden $list päivänä + nb-NO: den $list + tr-TR: ayın $list günü + zh-CN: $list + zh-TW: $list + ja-JP: $list + ko-KR: $list + th-TH: วัน$list + vi-VN: vào ngày $list + id-ID: pada tanggal $list + hi-IN: $list को + ar-SA: في اليوم $list + he-IL: ביום $list + fa-IR: در روز $list + +calendar.rrule_in: + en-US: in $list + es-ES: en $list + fr-FR: en $list + de-DE: im $list + it-IT: a $list + pt-PT: em $list + nl-NL: in $list + da-DK: i $list + ca-ES: al $list + el-GR: τον $list + sv-SE: i $list + pl-PL: w $list + pt-BR: em $list + ru-RU: в $list + uk-UA: у $list + bg-BG: през $list + cs-CZ: v $list + sk-SK: v $list + sl-SI: v $list + hr-HR: u $list + lt-LT: $list + hu-HU: $list hónapban + ro-RO: în $list + fi-FI: $list + nb-NO: i $list + tr-TR: $list ayında + zh-CN: $list + zh-TW: $list + ja-JP: $list + ko-KR: $list + th-TH: ใน $list + vi-VN: vào $list + id-ID: pada $list + hi-IN: $list में + ar-SA: في $list + he-IL: ב$list + fa-IR: در $list + +calendar.rrule_at: + en-US: at $list + es-ES: a las $list + fr-FR: à $list + de-DE: um $list + it-IT: alle $list + pt-PT: às $list + nl-NL: om $list + da-DK: kl. $list + ca-ES: a les $list + el-GR: στις $list + sv-SE: kl. $list + pl-PL: o $list + pt-BR: às $list + ru-RU: в $list + uk-UA: о $list + bg-BG: в $list + cs-CZ: v $list + sk-SK: o $list + sl-SI: ob $list + hr-HR: u $list + lt-LT: $list val. + hu-HU: $list órakor + ro-RO: la $list + fi-FI: klo $list + nb-NO: kl. $list + tr-TR: saat $list + zh-CN: $list + zh-TW: $list + ja-JP: $list + ko-KR: $list + th-TH: เวลา $list + vi-VN: lúc $list + id-ID: pukul $list + hi-IN: $list बजे + ar-SA: في الساعة $list + he-IL: בשעה $list + fa-IR: ساعت $list + +calendar.rrule_nth_weekday: + en-US: the $ordinal $weekday + es-ES: $ordinal $weekday + fr-FR: $ordinal $weekday + de-DE: $ordinal $weekday + it-IT: $ordinal $weekday + pt-PT: $ordinal $weekday + nl-NL: de $ordinal $weekday + da-DK: den $ordinal $weekday + ca-ES: $ordinal $weekday + el-GR: $ordinal $weekday + sv-SE: den $ordinal $weekday + pl-PL: $ordinal $weekday + pt-BR: $ordinal $weekday + ru-RU: $ordinal $weekday + uk-UA: $ordinal $weekday + bg-BG: $ordinal $weekday + cs-CZ: $ordinal $weekday + sk-SK: $ordinal $weekday + sl-SI: $ordinal $weekday + hr-HR: $ordinal $weekday + lt-LT: $ordinal $weekday + hu-HU: a $ordinal $weekday + ro-RO: $ordinal $weekday + fi-FI: $ordinal $weekday + nb-NO: den $ordinal $weekday + tr-TR: $ordinal $weekday + zh-CN: $ordinal个$weekday + zh-TW: $ordinal個$weekday + ja-JP: $ordinal$weekday + ko-KR: $ordinal $weekday + th-TH: $weekday$ordinal + vi-VN: $weekday lần $ordinal + id-ID: $weekday $ordinal + hi-IN: $ordinal $weekday + ar-SA: $weekday رقم $ordinal + he-IL: $weekday $ordinal + fa-IR: $weekday $ordinal + +calendar.rrule_from_end: + en-US: $ordinal from the end + es-ES: $ordinal desde el final + fr-FR: $ordinal en partant de la fin + de-DE: $ordinal von hinten + it-IT: $ordinal dalla fine + pt-PT: $ordinal a contar do fim + nl-NL: $ordinal vanaf het einde + da-DK: $ordinal fra slutningen + ca-ES: $ordinal des del final + el-GR: $ordinal από το τέλος + sv-SE: $ordinal från slutet + pl-PL: $ordinal od końca + pt-BR: $ordinal a partir do fim + ru-RU: $ordinal с конца + uk-UA: $ordinal з кінця + bg-BG: $ordinal от края + cs-CZ: $ordinal od konce + sk-SK: $ordinal od konca + sl-SI: $ordinal od konca + hr-HR: $ordinal od kraja + lt-LT: $ordinal nuo pabaigos + hu-HU: $ordinal a végétől + ro-RO: $ordinal de la sfârșit + fi-FI: $ordinal lopusta + nb-NO: $ordinal fra slutten + tr-TR: sondan $ordinal + zh-CN: 倒数$ordinal + zh-TW: 倒數$ordinal + ja-JP: 最後から$ordinal + ko-KR: 마지막에서 $ordinal + th-TH: $ordinal จากท้าย + vi-VN: $ordinal từ cuối + id-ID: $ordinal dari akhir + hi-IN: अंत से $ordinal + ar-SA: $ordinal من النهاية + he-IL: $ordinal מהסוף + fa-IR: $ordinal از آخر + +calendar.rrule_and: + en-US: $a and $b + es-ES: $a y $b + fr-FR: $a et $b + de-DE: $a und $b + it-IT: $a e $b + pt-PT: $a e $b + nl-NL: $a en $b + da-DK: $a og $b + ca-ES: $a i $b + el-GR: $a και $b + sv-SE: $a och $b + pl-PL: $a i $b + pt-BR: $a e $b + ru-RU: $a и $b + uk-UA: $a і $b + bg-BG: $a и $b + cs-CZ: $a a $b + sk-SK: $a a $b + sl-SI: $a in $b + hr-HR: $a i $b + lt-LT: $a ir $b + hu-HU: $a és $b + ro-RO: $a și $b + fi-FI: $a ja $b + nb-NO: $a og $b + tr-TR: $a ve $b + zh-CN: $a和$b + zh-TW: $a和$b + ja-JP: $aと$b + ko-KR: $a 및 $b + th-TH: $a และ $b + vi-VN: $a và $b + id-ID: $a dan $b + hi-IN: $a और $b + ar-SA: $a و$b + he-IL: $a ו-$b + fa-IR: $a و $b + +calendar.rrule_week_no: + en-US: week $n + es-ES: semana $n + fr-FR: semaine $n + de-DE: Woche $n + it-IT: settimana $n + pt-PT: semana $n + nl-NL: week $n + da-DK: uge $n + ca-ES: setmana $n + el-GR: εβδομάδα $n + sv-SE: vecka $n + pl-PL: tydzień $n + pt-BR: semana $n + ru-RU: неделя $n + uk-UA: тиждень $n + bg-BG: седмица $n + cs-CZ: týden $n + sk-SK: týždeň $n + sl-SI: teden $n + hr-HR: tjedan $n + lt-LT: $n savaitė + hu-HU: $n. hét + ro-RO: săptămâna $n + fi-FI: viikko $n + nb-NO: uke $n + tr-TR: $n. hafta + zh-CN: 第$n周 + zh-TW: 第$n週 + ja-JP: 第$n週 + ko-KR: $n주차 + th-TH: สัปดาห์ที่ $n + vi-VN: tuần $n + id-ID: minggu $n + hi-IN: सप्ताह $n + ar-SA: الأسبوع $n + he-IL: שבוע $n + fa-IR: هفته $n + +calendar.rrule_year_day: + en-US: day $n of the year + es-ES: día $n del año + fr-FR: jour $n de l'année + de-DE: Tag $n des Jahres + it-IT: giorno $n dell'anno + pt-PT: dia $n do ano + nl-NL: dag $n van het jaar + da-DK: dag $n i året + ca-ES: dia $n de l'any + el-GR: ημέρα $n του έτους + sv-SE: dag $n på året + pl-PL: dzień $n roku + pt-BR: dia $n do ano + ru-RU: день $n года + uk-UA: день $n року + bg-BG: ден $n от годината + cs-CZ: den $n v roce + sk-SK: deň $n v roku + sl-SI: dan $n v letu + hr-HR: dan $n u godini + lt-LT: metų $n diena + hu-HU: az év $n. napja + ro-RO: ziua $n din an + fi-FI: vuoden $n. päivä + nb-NO: dag $n i året + tr-TR: yılın $n. günü + zh-CN: 一年中的第$n天 + zh-TW: 一年中的第$n天 + ja-JP: 年間の$n日目 + ko-KR: 연중 $n일째 + th-TH: วันที่ $n ของปี + vi-VN: ngày $n của năm + id-ID: hari ke-$n dalam setahun + hi-IN: वर्ष का $nवाँ दिन + ar-SA: اليوم $n من السنة + he-IL: יום $n בשנה + fa-IR: روز $n سال + +calendar.rrule_setpos: + en-US: limited to occurrence $list + es-ES: limitado a la aparición $list + fr-FR: limité à l'occurrence $list + de-DE: begrenzt auf Vorkommen $list + it-IT: limitato all'occorrenza $list + pt-PT: limitado à ocorrência $list + nl-NL: beperkt tot voorkomen $list + da-DK: begrænset til forekomst $list + ca-ES: limitat a l'aparició $list + el-GR: περιορισμένο στην εμφάνιση $list + sv-SE: begränsat till förekomst $list + pl-PL: ograniczone do wystąpienia $list + pt-BR: limitado à ocorrência $list + ru-RU: ограничено вхождением $list + uk-UA: обмежено входженням $list + bg-BG: ограничено до появяване $list + cs-CZ: omezeno na výskyt $list + sk-SK: obmedzené na výskyt $list + sl-SI: omejeno na pojavitev $list + hr-HR: ograničeno na pojavljivanje $list + lt-LT: apribota iki pasikartojimo $list + hu-HU: csak a $list előfordulás + ro-RO: limitat la apariția $list + fi-FI: rajattu esiintymään $list + nb-NO: begrenset til forekomst $list + tr-TR: $list tekrarla sınırlı + zh-CN: 仅限$list次 + zh-TW: 僅限$list次 + ja-JP: $list番目のみ + ko-KR: $list 발생만 + th-TH: จำกัดเฉพาะครั้ง$list + vi-VN: giới hạn ở lần xuất hiện $list + id-ID: terbatas pada kemunculan $list + hi-IN: केवल $list पुनरावृत्ति + ar-SA: مقصور على التكرار $list + he-IL: מוגבל למופע $list + fa-IR: محدود به تکرار $list + +calendar.rsvp_reply_as: + en-US: Replying as $name + es-ES: Respondiendo como $name + fr-FR: Réponse en tant que $name + de-DE: Antwort als $name + it-IT: Stai rispondendo come $name + pt-PT: A responder como $name + nl-NL: Antwoorden als $name + da-DK: Svarer som $name + ca-ES: Responent com a $name + el-GR: Απάντηση ως $name + sv-SE: Svarar som $name + pl-PL: Odpowiadasz jako $name + pt-BR: Respondendo como $name + ru-RU: Отвечаете как $name + uk-UA: Відповідаєте як $name + bg-BG: Отговаряте като $name + cs-CZ: Odpovídáte jako $name + sk-SK: Odpovedáte ako $name + sl-SI: Odgovarjate kot $name + hr-HR: Odgovarate kao $name + lt-LT: Atsakote kaip $name + hu-HU: Válasz $name nevében + ro-RO: Răspundeți ca $name + fi-FI: Vastaat nimellä $name + nb-NO: Svarer som $name + tr-TR: $name olarak yanıtlıyorsunuz + zh-CN: 正在以 $name 的身份回复 + zh-TW: 正在以 $name 的身分回覆 + ja-JP: $name として返信中 + ko-KR: $name 이름으로 회신 중 + th-TH: กำลังตอบกลับในนาม $name + vi-VN: Đang trả lời với tư cách $name + id-ID: Membalas sebagai $name + hi-IN: $name के रूप में उत्तर दिया जा रहा है + ar-SA: الرد باسم $name + he-IL: משיב בשם $name + fa-IR: در حال پاسخ با نام $name diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz new file mode 100644 index 0000000..3c19a47 Binary files /dev/null and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 new file mode 100644 index 0000000..a276e88 --- /dev/null +++ b/resources/schema/schema.json.sha256 @@ -0,0 +1 @@ +zUWyYdvOBMVeP1H7DNb7OqUThdpIaKjph4RyqBSKZAA \ No newline at end of file diff --git a/resources/scripts/decompress_blob.py b/resources/scripts/decompress_blob.py new file mode 100644 index 0000000..3aa6f4f --- /dev/null +++ b/resources/scripts/decompress_blob.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Decode a raw Stalwart blob object into its original bytes. + +Stalwart appends a one-byte compression marker to every blob it writes to the +blob store (see BlobStore::put_blob in crates/store/src/dispatch/blob.rs): + + 0x00 (NONE_MARKER) the preceding bytes are the verbatim payload + 0xa1 (LZ4_MARKER) the preceding bytes are an lz4_flex block prefixed with a + little-endian u32 holding the uncompressed size + other a legacy blob stored without a marker; emitted unchanged + +This mirrors the read path, which inspects the last byte, strips the marker and, +for LZ4, decompresses the size-prepended block. + +Reads the object from a file (or stdin with "-") and writes the decoded payload +to stdout (or to a file with -o). +""" + +import argparse +import sys + +NONE_MARKER = 0x00 +LZ4_MARKER = 0xA1 + + +def decode(data): + if not data: + return data + marker = data[-1] + if marker == LZ4_MARKER: + import lz4.block + return lz4.block.decompress(data[:-1]) + if marker == NONE_MARKER: + return data[:-1] + print(f"warning: no known compression marker (last byte 0x{marker:02x}); " + "emitting bytes unchanged", file=sys.stderr) + return data + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("input", help='raw blob file, or "-" to read from stdin') + ap.add_argument("-o", "--output", + help="write the decoded payload here (default: stdout)") + args = ap.parse_args() + + if args.input == "-": + data = sys.stdin.buffer.read() + else: + with open(args.input, "rb") as fh: + data = fh.read() + + out = decode(data) + + if args.output: + with open(args.output, "wb") as fh: + fh.write(out) + else: + sys.stdout.buffer.write(out) + + +if __name__ == "__main__": + main() diff --git a/resources/scripts/imap-log-sanitizer.py b/resources/scripts/imap-log-sanitizer.py new file mode 100644 index 0000000..ab7debd --- /dev/null +++ b/resources/scripts/imap-log-sanitizer.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +IMAP Log sanitizer - Extracts and groups IMAP transactions from log files +""" + +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + +import re +import json +from collections import defaultdict +from datetime import datetime +import argparse + +def unescape_imap_content(content): + + if content.startswith('"') and content.endswith('"'): + content = content[1:-1] + + replacements = { + '\\r\\n': '\r\n', + '\\n': '\n', + '\\r': '\r', + '\\t': '\t', + '\\"': '"', + '\\\\': '\\' + } + + for escaped, unescaped in replacements.items(): + content = content.replace(escaped, unescaped) + + return content + +def parse_imap_log_line(line): + + pattern = r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+TRACE\s+Raw IMAP\s+(input received|output sent)\s+.*?remoteIp\s*=\s*([^,]+),\s*remotePort\s*=\s*(\d+).*?contents\s*=\s*(.+)$' + + match = re.search(pattern, line) + if not match: + return None + + timestamp, direction, remote_ip, remote_port, contents = match.groups() + + return { + 'timestamp': timestamp, + 'direction': direction, + 'remote_ip': remote_ip.strip(), + 'remote_port': int(remote_port), + 'contents': unescape_imap_content(contents.strip()), + 'raw_line': line.strip() + } + +def group_by_connection(log_entries): + + connections = defaultdict(list) + + for entry in log_entries: + if entry: + key = f"{entry['remote_ip']}:{entry['remote_port']}" + connections[key].append(entry) + + for key in connections: + connections[key].sort(key=lambda x: x['timestamp']) + + return dict(connections) + +def format_imap_transaction(entries): + + transaction = [] + + for entry in entries: + direction_symbol = "C: " if "input received" in entry['direction'] else "S: " + timestamp = entry['timestamp'] + content = entry['contents'] + + if content.endswith('\\r\\n') or content.endswith('\r\n'): + content = content.rstrip('\\r\\n\r\n') + + transaction.append(f"[{timestamp}] {direction_symbol}{content}") + + return transaction + +def write_output_file(connections, output_file): + + with open(output_file, 'w', encoding='utf-8') as f: + f.write("IMAP Transaction Log Analysis\n") + f.write("=" * 50 + "\n\n") + + for connection_key, entries in connections.items(): + f.write(f"Connection: {connection_key}\n") + f.write("-" * 30 + "\n") + f.write(f"Total messages: {len(entries)}\n") + f.write(f"Duration: {entries[0]['timestamp']} to {entries[-1]['timestamp']}\n\n") + + transaction = format_imap_transaction(entries) + for line in transaction: + f.write(line + "\n") + + f.write("\n" + "=" * 50 + "\n\n") + +def main(): + parser = argparse.ArgumentParser(description='Parse IMAP log files and group transactions by connection') + parser.add_argument('input_file', help='Input log file path') + parser.add_argument('-o', '--output', default='imap_transactions.txt', + help='Output file path (default: imap_transactions.txt)') + parser.add_argument('-j', '--json', action='store_true', + help='Also output raw data as JSON') + parser.add_argument('-v', '--verbose', action='store_true', + help='Enable verbose output') + + args = parser.parse_args() + + if args.verbose: + print(f"Reading log file: {args.input_file}") + + log_entries = [] + imap_line_count = 0 + + try: + with open(args.input_file, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + if 'Raw IMAP' in line: + imap_line_count += 1 + parsed_entry = parse_imap_log_line(line) + if parsed_entry: + log_entries.append(parsed_entry) + elif args.verbose: + print(f"Warning: Could not parse line {line_num}: {line.strip()}") + + except FileNotFoundError: + print(f"Error: File '{args.input_file}' not found") + return 1 + except Exception as e: + print(f"Error reading file: {e}") + return 1 + + if args.verbose: + print(f"Found {imap_line_count} Raw IMAP lines") + print(f"Successfully parsed {len(log_entries)} entries") + + connections = group_by_connection(log_entries) + + if args.verbose: + print(f"Found {len(connections)} unique connections:") + for conn_key, entries in connections.items(): + print(f" {conn_key}: {len(entries)} messages") + + try: + write_output_file(connections, args.output) + print(f"IMAP transactions written to: {args.output}") + + if args.json: + json_file = args.output.rsplit('.', 1)[0] + '.json' + with open(json_file, 'w', encoding='utf-8') as f: + json.dump(connections, f, indent=2, ensure_ascii=False) + print(f"Raw data written to: {json_file}") + + except Exception as e: + print(f"Error writing output: {e}") + return 1 + + return 0 + +if __name__ == "__main__": + exit(main()) diff --git a/resources/scripts/list_active_blobs.py b/resources/scripts/list_active_blobs.py new file mode 100644 index 0000000..05f4a9a --- /dev/null +++ b/resources/scripts/list_active_blobs.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""List the S3 object keys of every blob that is still actively linked. + +Stalwart reference-counts blobs through link entries kept in the data store +under the SUBSPACE_BLOB_LINK subspace (the PostgreSQL table named "k"). A blob +is garbage-collectable once it has no surviving link; this tool reports the +opposite set: the S3 keys of blobs that are still referenced, so they can be +diffed against the actual contents of an S3 bucket to find orphans. + +Key layout in table "k" (the subspace byte is NOT stored, it only selects the +table). All integers are big-endian: + + 32 bytes Commit : (blob exists marker) + 40 bytes Id link : + 41 bytes Doc link: + 44 bytes Tmp link: (active iff until > now) + +A blob is active when it has at least one Id/Doc link, or a Temporary link whose +"until" (unix seconds) is still in the future. The 32-byte Commit marker alone +does not keep a blob alive. + +The S3 object key is the optional configured key prefix (literal string) +followed by the custom-base32 encoding of the 32-byte blob hash, matching +S3Store::build_key in crates/store/src/backend/s3/mod.rs. +""" + +import argparse +import os +import sys +import time + +BLOB_HASH_LEN = 32 +ID_LINK = BLOB_HASH_LEN + 8 # 40 +DOC_LINK = BLOB_HASH_LEN + 8 + 1 # 41 +TEMP_LINK = BLOB_HASH_LEN + 4 + 8 # 44 + +BASE32_ALPHABET = b"abcdefghijklmnopqrstuvwxyz792013" + + +class Base32Writer: + """Faithful port of utils::codec::base32_custom::Base32Writer.""" + + def __init__(self, prefix=""): + self.last_byte = 0 + self.pos = 0 + self.out = [prefix] if prefix else [] + + def _push_byte(self, byte, is_remainder): + p = self.pos % 5 + if p == 0: + ch1 = (byte & 0xF8) >> 3 + ch2 = 0xFF + elif p == 1: + ch1 = ((self.last_byte & 0x07) << 2) | ((byte & 0xC0) >> 6) + ch2 = (byte & 0x3E) >> 1 + elif p == 2: + ch1 = ((self.last_byte & 0x01) << 4) | ((byte & 0xF0) >> 4) + ch2 = 0xFF + elif p == 3: + ch1 = ((self.last_byte & 0x0F) << 1) | (byte >> 7) + ch2 = (byte & 0x7C) >> 2 + else: + ch1 = ((self.last_byte & 0x03) << 3) | ((byte & 0xE0) >> 5) + ch2 = byte & 0x1F + + self.out.append(chr(BASE32_ALPHABET[ch1])) + if not is_remainder: + if ch2 != 0xFF: + self.out.append(chr(BASE32_ALPHABET[ch2])) + self.last_byte = byte + self.pos += 1 + + def write(self, data): + for byte in data: + self._push_byte(byte, False) + return self + + def finalize(self): + if self.pos % 5 != 0: + self._push_byte(0, True) + return "".join(self.out) + + +def s3_key(blob_hash, prefix=""): + return Base32Writer(prefix).write(blob_hash).finalize() + + +def be_u64(b): + return int.from_bytes(b, "big") + + +def collect_active_hashes(rows, now, include_expired_temporary=False): + active = set() + unknown = 0 + for (key,) in rows: + key = bytes(key) + n = len(key) + if n == BLOB_HASH_LEN: + continue + if n in (ID_LINK, DOC_LINK): + active.add(key[:BLOB_HASH_LEN]) + elif n == TEMP_LINK: + until = be_u64(key[BLOB_HASH_LEN + 4:BLOB_HASH_LEN + 12]) + if include_expired_temporary or until > now: + active.add(key[:BLOB_HASH_LEN]) + else: + unknown += 1 + if unknown: + print(f"warning: skipped {unknown} key(s) of unexpected length", + file=sys.stderr) + return active + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", default=os.environ.get("PGHOST", "localhost")) + ap.add_argument("--port", type=int, default=int(os.environ.get("PGPORT", "5432"))) + ap.add_argument("--user", default=os.environ.get("PGUSER", "stalwart")) + ap.add_argument("--password", default=os.environ.get("PGPASSWORD", "stalwart")) + ap.add_argument("--dbname", default=os.environ.get("PGDATABASE", "stalwart")) + ap.add_argument("--table", default="k", + help="SUBSPACE_BLOB_LINK table name (default: k)") + ap.add_argument("--prefix", default="", + help="S3 key_prefix configured on the blob store (default: none)") + ap.add_argument("--now", type=int, default=None, + help="override unix-seconds used to expire temporary links") + ap.add_argument("--include-expired-temporary", action="store_true", + help="treat expired temporary links as active too") + args = ap.parse_args() + + try: + import psycopg2 + conn = psycopg2.connect(host=args.host, port=args.port, user=args.user, + password=args.password, dbname=args.dbname) + except ImportError: + import psycopg + conn = psycopg.connect(host=args.host, port=args.port, user=args.user, + password=args.password, dbname=args.dbname) + + now = args.now if args.now is not None else int(time.time()) + + with conn, conn.cursor() as cur: + cur.execute(f'SELECT k FROM "{args.table}"') + rows = cur.fetchall() + conn.close() + + active = collect_active_hashes(rows, now, + include_expired_temporary=args.include_expired_temporary) + + for h in sorted(active): + print(s3_key(h, args.prefix)) + + +if __name__ == "__main__": + main() diff --git a/resources/scripts/migrate_v016.py b/resources/scripts/migrate_v016.py new file mode 100644 index 0000000..01c54b6 --- /dev/null +++ b/resources/scripts/migrate_v016.py @@ -0,0 +1,2158 @@ +#!/usr/bin/env python3 +""" +Stalwart v0.16 migration helper. + +Two modes: + + dump — pull all settings and principals from a Stalwart server via the + management API into two JSON files. + + convert — read those two JSON files and emit: + * config.json — plain DataStore object (Stalwart's main config) + * export.json — NDJSON stream of `update`/`create` ops for + everything else, one op per line, in load order. + +Usage: + python migrate_v016.py dump --url https://mail.example.com \ + --username admin --password s3cret \ + --settings settings.json --principals principals.json + + python migrate_v016.py convert \ + --settings settings.json --principals principals.json \ + --config config.json --output export.json +""" + +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import urllib.parse +from collections import defaultdict +from typing import Any + +import requests +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +ALL_PRINCIPAL_TYPES = [ + "individual", + "group", + "resource", + "location", + "list", + "other", + "domain", + "tenant", + "role", + "apiKey", + "oauthClient", +] + +PAGE_SIZE = 200 +REQUEST_TIMEOUT = 60 + +class ApiError(RuntimeError): + pass + +class ConvertError(RuntimeError): + pass + +class StalwartClient: + def __init__( + self, + base_url: str, + *, + token: str | None = None, + username: str | None = None, + password: str | None = None, + verify: bool = False, + ): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.verify = verify + self.session.headers.update({"Accept": "application/json"}) + if token: + self.session.headers["Authorization"] = f"Bearer {token}" + elif username is not None and password is not None: + self.session.auth = (username, password) + else: + raise ValueError("need either a token or username/password") + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: Any = None, + ) -> Any: + resp = self.session.request( + method, + self.base_url + path, + params=params, + json=json_body, + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code == 401: + raise ApiError(f"401 Unauthorized for {method} {path}") + if resp.status_code == 403: + raise ApiError(f"403 Forbidden for {method} {path}") + if resp.status_code == 404: + raise ApiError(f"404 Not Found for {method} {path}") + if not resp.ok: + raise ApiError( + f"{resp.status_code} {resp.reason} for {method} {path}: {resp.text[:500]}" + ) + try: + payload = resp.json() + except ValueError as exc: + raise ApiError(f"Non-JSON response from {path}: {exc}") + if isinstance(payload, dict) and "error" in payload and "data" not in payload: + raise ApiError(f"Server error on {path}: {payload}") + if isinstance(payload, dict) and "data" in payload: + return payload["data"] + return payload + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return self._request("GET", path, params=params) + + def dump_all_settings(self) -> dict[str, str]: + + merged: dict[str, str] = {} + page = 1 + last_progress_page = 0 + while True: + data = self.get( + "/api/settings/list", + params={ + "prefix": "", + "page": str(page), + "limit": str(PAGE_SIZE), + }, + ) + items = data.get("items", {}) or {} + total = int(data.get("total", len(items)) or 0) + + before = len(merged) + merged.update(items) + gained = len(merged) - before + + if not items: + break + if len(merged) >= total: + break + if gained == 0: + raise ApiError( + f"Settings pagination made no progress on page {page} " + f"(have {len(merged)}/{total}). Server may not support " + "paging /api/settings/list with page/limit." + ) + last_progress_page = page + page += 1 + + if page - last_progress_page > 5: + raise ApiError( + f"Settings pagination stalled at page {page} " + f"(have {len(merged)}/{total})." + ) + return merged + + def list_principal_names(self) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for principal_type in ALL_PRINCIPAL_TYPES: + try: + self._list_principals_of_type(principal_type, out, seen) + except ApiError as exc: + print( + f" WARN skipping principal type {principal_type!r}: {exc}", + file=sys.stderr, + ) + return out + + def _list_principals_of_type( + self, + principal_type: str, + out: list[tuple[str, str]], + seen: set[tuple[str, str]], + ) -> None: + before = len(out) + page = 1 + while True: + data = self.get( + "/api/principal", + params={ + "page": str(page), + "limit": str(PAGE_SIZE), + "types": principal_type, + }, + ) + items = data.get("items", []) or [] + total = int(data.get("total", 0) or 0) + for p in items: + typ = p.get("type") or principal_type + name = _principal_name(p) + if not name: + continue + key = (typ, name) + if key in seen: + continue + seen.add(key) + out.append(key) + if not items: + break + if (len(out) - before) >= total: + break + page += 1 + + def get_principal(self, name: str) -> dict[str, Any]: + quoted = urllib.parse.quote(name, safe="") + return self.get(f"/api/principal/{quoted}") + +def _principal_name(p: dict[str, Any]) -> str: + v = p.get("name") + if isinstance(v, str): + return v + if isinstance(v, dict): + if isinstance(v.get("string"), str): + return v["string"] + sl = v.get("stringList") + if isinstance(sl, list) and sl: + return sl[0] + if isinstance(v, list) and v: + return v[0] + return "" + +def cmd_dump(args: argparse.Namespace) -> int: + if args.token: + client = StalwartClient(args.url, token=args.token, verify=False) + elif args.username and args.password: + client = StalwartClient( + args.url, + username=args.username, + password=args.password, + verify=False, + ) + else: + print("error: either --token or --username/--password is required", + file=sys.stderr) + return 2 + + print("Fetching settings...", file=sys.stderr) + settings = client.dump_all_settings() + with open(args.settings, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, sort_keys=True, ensure_ascii=False) + print(f" wrote {len(settings)} settings keys to {args.settings}", + file=sys.stderr) + + print("Listing principals...", file=sys.stderr) + names = client.list_principal_names() + print(f" found {len(names)} principals across all types", file=sys.stderr) + + principals: list[dict[str, Any]] = [] + for i, (typ, name) in enumerate(names, 1): + try: + full = client.get_principal(name) + except ApiError as exc: + print(f" [{i}/{len(names)}] WARN failed to fetch {typ} {name!r}: {exc}", + file=sys.stderr) + continue + principals.append(full) + if i % 50 == 0 or i == len(names): + print(f" [{i}/{len(names)}] fetched {typ} {name}", file=sys.stderr) + + missing_id = [p for p in principals if p.get("id") is None] + if missing_id: + print( + f"warning: {len(missing_id)} principal(s) returned no 'id' field", + file=sys.stderr, + ) + + with open(args.principals, "w", encoding="utf-8") as f: + json.dump(principals, f, indent=2, ensure_ascii=False) + print(f" wrote {len(principals)} principals to {args.principals}", + file=sys.stderr) + + return 0 + +_DURATION_UNITS_MS = { + "ms": 1, + "s": 1000, + "m": 60_000, + "h": 3_600_000, + "d": 86_400_000, + "w": 604_800_000, +} + +_SIZE_UNITS_BYTES = { + "": 1, + "b": 1, + "kb": 1024, + "mb": 1024 * 1024, + "gb": 1024 * 1024 * 1024, +} + +def parse_duration_ms(s: str | None) -> int | None: + + if s is None: + return None + s = str(s).strip().lower() + if not s: + return None + m = re.fullmatch(r"(-?\d+)(ms|s|m|h|d|w)?", s) + if not m: + raise ConvertError(f"Could not parse duration {s!r}") + n = int(m.group(1)) + unit = m.group(2) or "ms" + return n * _DURATION_UNITS_MS[unit] + +def parse_size_bytes(s: str | None) -> int | None: + + if s is None: + return None + s = str(s).strip().lower() + if not s: + return None + m = re.fullmatch(r"(-?\d+)\s*(b|kb|mb|gb)?", s) + if not m: + raise ConvertError(f"Could not parse size {s!r}") + return int(m.group(1)) * _SIZE_UNITS_BYTES[m.group(2) or ""] + +def parse_int(s: str | None) -> int | None: + if s is None: + return None + s = str(s).strip() + if not s: + return None + try: + return int(s) + except ValueError as exc: + raise ConvertError(f"Could not parse integer {s!r}") from exc + +def parse_bool(s: str | None) -> bool | None: + if s is None: + return None + v = str(s).strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off", ""): + return False + raise ConvertError(f"Could not parse bool {s!r}") + +def pv_string(v: Any) -> str: + if v is None: + return "" + if isinstance(v, str): + return v + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, dict): + if isinstance(v.get("string"), str): + return v["string"] + sl = v.get("stringList") + if isinstance(sl, list) and sl: + return str(sl[0]) + iv = v.get("integer") + if isinstance(iv, int): + return str(iv) + if isinstance(v, list) and v: + return pv_string(v[0]) + return "" + +def pv_int(v: Any) -> int | None: + if v is None: + return None + if isinstance(v, bool): + return int(v) + if isinstance(v, int): + return v + if isinstance(v, str): + try: + return int(v) + except ValueError: + return None + if isinstance(v, dict): + iv = v.get("integer") + if isinstance(iv, int): + return iv + return None + +def pv_list(v: Any) -> list: + + if v is None: + return [] + if isinstance(v, list): + return list(v) + if isinstance(v, dict): + sl = v.get("stringList") + if isinstance(sl, list): + return list(sl) + il = v.get("integerList") + if isinstance(il, list): + return list(il) + if "string" in v and isinstance(v["string"], str): + return [v["string"]] + if "integer" in v and isinstance(v["integer"], int): + return [v["integer"]] + return [] + + return [v] + +def split_email(addr: str) -> tuple[str, str] | None: + + if "@" not in addr: + return None + local, _, domain = addr.rpartition("@") + domain = domain.strip().lower() + if not domain: + return None + return (local, domain) + +_LABEL_RE = re.compile(r"^[^\W_](?:(?:[^\W_]|-){0,61}[^\W_])?$") +_RFC6761_RESERVED_TLDS = {"test", "local", "localhost", "invalid", "example"} + +def is_valid_domain_name(name: str) -> bool: + + if not name or len(name) > 253: + return False + name = name.strip(".").lower() + if not name: + return False + labels = name.split(".") + if len(labels) < 2: + return False + for label in labels: + if not _LABEL_RE.match(label): + return False + return True + +def is_valid_local_part(local: str) -> bool: + + return bool(local) and "@" not in local + +def parse_path_patch(arg: str) -> tuple[str, str]: + + if "=" not in arg: + raise argparse.ArgumentTypeError( + f"--patch-paths expects SOURCE=DEST, got {arg!r}" + ) + src, _, dst = arg.partition("=") + src = src.rstrip("/") + dst = dst.rstrip("/") + if not src or not dst: + raise argparse.ArgumentTypeError( + f"--patch-paths expects non-empty SOURCE and DEST, got {arg!r}" + ) + return (src, dst) + +def apply_path_patches(value: Any, patches: list[tuple[str, str]]) -> tuple[Any, int]: + + count = 0 + if isinstance(value, str): + for src, dst in patches: + if value == src or value.startswith(src + "/"): + return (dst + value[len(src):], 1) + return (value, 0) + if isinstance(value, list): + new_list: list[Any] = [] + for item in value: + patched, n = apply_path_patches(item, patches) + new_list.append(patched) + count += n + return (new_list, count) + if isinstance(value, dict): + new_dict: dict[Any, Any] = {} + for k, v in value.items(): + patched, n = apply_path_patches(v, patches) + new_dict[k] = patched + count += n + return (new_dict, count) + return (value, 0) + +def detect_legacy_paths(settings: dict[str, str], prefix: str = "/opt/stalwart") -> int: + + needle = prefix.rstrip("/") + "/" + exact = prefix.rstrip("/") + return sum( + 1 + for v in settings.values() + if isinstance(v, str) and (needle in v or v.endswith(exact)) + ) + +_CONSUMED_PREFIXES: tuple[str, ...] = ( + "acme.", + "certificate.", + "enterprise.", + "lookup.default.", + "server.hostname", + "signature.", + "storage.", + "store.", + "version.", +) + +def is_consumed_setting_key(key: str) -> bool: + + for prefix in _CONSUMED_PREFIXES: + if prefix.endswith(".") and key.startswith(prefix): + return True + if not prefix.endswith(".") and (key == prefix or key.startswith(prefix + ".")): + return True + return False + +def compute_unmigrated_keys(settings: dict[str, str]) -> list[str]: + + return sorted(k for k in settings if not is_consumed_setting_key(k)) + +def write_unmigrated_summary(unmigrated: list[str], path: str) -> None: + + counts: dict[str, int] = {} + for k in unmigrated: + parts = k.split(".", 2) + prefix = ".".join(parts[:2]) if len(parts) >= 2 else parts[0] + counts[prefix] = counts.get(prefix, 0) + 1 + + width = max((len(p) for p in counts), default=0) + with open(path, "w", encoding="utf-8") as f: + f.write( + "# Unmigrated v0.15 settings\n" + "\n" + "These v0.15 settings were not migrated by the script and must be\n" + "reviewed manually. The list below is grouped by the first two\n" + "segments of the setting key. Each prefix maps to one or more v0.16\n" + "objects; consult UPGRADING/v0_16.md and the v0.16 reference docs\n" + "to identify the equivalent on the new schema.\n" + "\n" + f"Total unmigrated keys: {len(unmigrated)} across " + f"{len(counts)} prefixes.\n" + "\n" + ) + for prefix in sorted(counts): + f.write(f" {prefix:<{width}} {counts[prefix]:5d} keys\n") + +def group_settings_by_prefix(settings: dict[str, str], prefix: str) -> dict[str, dict[str, str]]: + + raise NotImplementedError + +def build_sub_trees( + settings: dict[str, str], + prefix: str, + discriminator: str, +) -> dict[str, dict[str, str]]: + + record_ids: list[str] = [] + disc_suffix = "." + discriminator + prefix_dot = prefix + "." + for k in settings: + if k.startswith(prefix_dot) and k.endswith(disc_suffix): + rid = k[len(prefix_dot):-len(disc_suffix)] + if rid: + record_ids.append(rid) + + record_ids.sort(key=lambda s: (-len(s), s)) + + trees: dict[str, dict[str, str]] = {rid: {} for rid in record_ids} + claimed: set[str] = set() + for rid in record_ids: + head = prefix_dot + rid + "." + for k, v in settings.items(): + if k in claimed: + continue + if k.startswith(head): + sub = k[len(head):] + trees[rid][sub] = v + claimed.add(k) + return trees + +def collect_array(sub: dict[str, str], field: str) -> list[str]: + + items: list[tuple[int, str]] = [] + head = field + "." + for k, v in sub.items(): + if k.startswith(head): + tail = k[len(head):] + if tail.isdigit(): + items.append((int(tail), v)) + items.sort() + return [v for _, v in items] + +_PEM_RE = re.compile( + r"-----BEGIN (?P[A-Z][A-Z0-9 ]*?)-----" + r"[\s\S]+?" + r"-----END (?P=kind)-----", + re.MULTILINE, +) + + +def split_pem_bundle(blob: str) -> tuple[str, str]: + certs: list[str] = [] + key: str | None = None + for m in _PEM_RE.finditer(blob): + kind = m.group("kind") + block = m.group(0) + "\n" + if "PRIVATE KEY" in kind: + if key is None: + key = block + elif kind == "CERTIFICATE": + certs.append(block) + return ("".join(certs), key or "") + + +def _make_certificate_object(cert_pem: str, key_pem: str) -> dict[str, Any]: + return { + "certificate": {"@type": "Text", "value": cert_pem}, + "privateKey": {"@type": "Text", "secret": key_pem}, + } + + +def is_app_password(secret: str) -> bool: + return secret.startswith("$app$") + +def is_otpauth(secret: str) -> bool: + return secret.startswith("otpauth://") + +def secret_key_optional(value: str | None) -> dict[str, Any]: + + if value is None or value == "": + return {"@type": "None"} + return {"@type": "Value", "secret": value} + +def public_string_optional(value: str | None) -> dict[str, Any]: + + if value is None or value == "": + return {"@type": "None"} + return {"@type": "Value", "value": value} + +def secret_key(value: str | None) -> dict[str, Any]: + + if value is None or value == "": + return {"@type": "None"} + return {"@type": "Value", "secret": value} + +def secret_text(value: str | None) -> dict[str, Any]: + + if value is None or value == "": + return {"@type": "None"} + return {"@type": "Text", "secret": value} + +_MACRO_RE = re.compile(r"%\{(cfg|env|file):([^}]*)\}%") + +def resolve_macros( + value: str | None, + settings: dict[str, str], + _seen: frozenset[str] = frozenset(), +) -> tuple[str | None, list[str]]: + if value is None or "%{" not in value: + return value, [] + errors: list[str] = [] + + def repl(m: "re.Match[str]") -> str: + kind = m.group(1) + arg = m.group(2).strip() + if kind == "cfg": + if arg in _seen: + errors.append(f"circular %{{cfg:{arg}}}% reference") + return "" + raw = settings.get(arg) + if raw is None: + errors.append(f"unknown setting referenced by %{{cfg:{arg}}}%") + return "" + nested, nested_errors = resolve_macros( + raw, settings, _seen | {arg} + ) + errors.extend(nested_errors) + return nested or "" + if kind == "env": + env = os.environ.get(arg) + if env is None: + errors.append( + f"environment variable {arg!r} (from %{{env:{arg}}}%) " + f"is not set" + ) + return "" + return env + try: + with open(arg, "r", encoding="utf-8") as fh: + return fh.read() + except OSError as exc: + errors.append(f"cannot read file {arg!r} (from %{{file:...}}%): {exc}") + return "" + + prev = value + for _ in range(8): + cur = _MACRO_RE.sub(repl, prev) + if cur == prev: + break + prev = cur + return prev, errors + +_REDIS_PROTOCOL_MAP = { + "resp2": "resp2", + "resp3": "resp3", +} + +_S3_REGION_MAP = { + "us-east-1": "UsEast1", "us-east-2": "UsEast2", + "us-west-1": "UsWest1", "us-west-2": "UsWest2", + "ca-central-1": "CaCentral1", + "af-south-1": "AfSouth1", + "ap-east-1": "ApEast1", "ap-south-1": "ApSouth1", + "ap-northeast-1": "ApNortheast1", "ap-northeast-2": "ApNortheast2", + "ap-northeast-3": "ApNortheast3", + "ap-southeast-1": "ApSoutheast1", "ap-southeast-2": "ApSoutheast2", + "cn-north-1": "CnNorth1", "cn-northwest-1": "CnNorthwest1", + "eu-north-1": "EuNorth1", + "eu-central-1": "EuCentral1", "eu-central-2": "EuCentral2", + "eu-west-1": "EuWest1", "eu-west-2": "EuWest2", "eu-west-3": "EuWest3", + "il-central-1": "IlCentral1", + "me-south-1": "MeSouth1", + "sa-east-1": "SaEast1", + "do-nyc3": "DoNyc3", "do-ams3": "DoAms3", + "do-sgp1": "DoSgp1", "do-fra1": "DoFra1", + "yandex": "Yandex", + "wa-us-east-1": "WaUsEast1", "wa-us-east-2": "WaUsEast2", + "wa-us-central-1": "WaUsCentral1", "wa-us-west-1": "WaUsWest1", + "wa-ca-central-1": "WaCaCentral1", + "wa-eu-central-1": "WaEuCentral1", "wa-eu-central-2": "WaEuCentral2", + "wa-eu-west-1": "WaEuWest1", "wa-eu-west-2": "WaEuWest2", + "wa-ap-northeast-1": "WaApNortheast1", "wa-ap-northeast-2": "WaApNortheast2", + "wa-ap-southeast-1": "WaApSoutheast1", "wa-ap-southeast-2": "WaApSoutheast2", +} + +class Converter: + def __init__( + self, + principals: list[dict[str, Any]], + settings: dict[str, str], + ): + self.principals = principals + self.settings = settings + + self.by_name: dict[str, dict[str, Any]] = {} + self.by_id: dict[int, dict[str, Any]] = {} + for p in principals: + n = pv_string(p.get("name")) + if n: + self.by_name[n] = p + pid = pv_int(p.get("id")) + if pid is not None: + self.by_id[pid] = p + + self.tenant_name_to_cid: dict[str, str] = {} + self.domain_name_to_cid: dict[str, str] = {} + self.domain_cid_to_name: dict[str, str] = {} + self.domain_cid_to_tenant_cid: dict[str, str] = {} + self.default_domain_cid: str | None = None + self._create_counter = 0 + + def _next_create_cid(self) -> str: + cid = f"create-{self._create_counter}" + self._create_counter += 1 + return cid + + @staticmethod + def _account_cid(old_id: int) -> str: + return f"restore-{old_id}" + + def run(self) -> dict[str, Any]: + tenants = self._build_tenants() + domains = self._build_domains() + self._pick_default_domain() + accounts = self._build_accounts() + mailing_lists = self._build_mailing_lists() + dkim_signatures = self._build_dkim_signatures() + certificates = self._build_certificates() + + self._check_duplicate_emails(accounts, mailing_lists) + self._validate_records(domains, accounts, mailing_lists, dkim_signatures) + + data_store = self._build_data_store() + blob_store = self._build_blob_store() + in_memory_store = self._build_in_memory_store() + search_store = self._build_search_store() + metrics_store = self._build_metrics_store() + tracing_store = self._build_tracing_store() + enterprise = self._build_enterprise() + system_settings = self._build_system_settings() + + out: dict[str, Any] = {} + if system_settings is not None: + out["SystemSettings"] = system_settings + if enterprise is not None: + out["Enterprise"] = enterprise + if data_store is not None: + out["DataStore"] = data_store + if blob_store is not None: + out["BlobStore"] = blob_store + if in_memory_store is not None: + out["InMemoryStore"] = in_memory_store + if search_store is not None: + out["SearchStore"] = search_store + if metrics_store is not None: + out["MetricsStore"] = metrics_store + if tracing_store is not None: + out["TracingStore"] = tracing_store + if tenants: + out["Tenant"] = tenants + if domains: + out["Domain"] = domains + if accounts: + out["Account"] = accounts + if mailing_lists: + out["MailingList"] = mailing_lists + if dkim_signatures: + out["DkimSignature"] = dkim_signatures + if certificates: + out["Certificate"] = certificates + return out + + def _build_tenants(self) -> dict[str, dict[str, Any]]: + tenants = [p for p in self.principals if p.get("type") == "tenant"] + tenants.sort(key=lambda p: pv_string(p.get("name"))) + out: dict[str, dict[str, Any]] = {} + for p in tenants: + name = pv_string(p.get("name")) + if not name: + continue + cid = self._next_create_cid() + self.tenant_name_to_cid[name] = cid + obj: dict[str, Any] = {"name": name} + logo = pv_string(p.get("picture")) + if logo: + obj["logo"] = logo + quotas: dict[str, int] = {} + q = pv_int(p.get("quota")) + if q: + quotas["maxDiskQuota"] = q + obj["quotas"] = quotas + out[cid] = obj + return out + + def _collect_domain_names(self) -> set[str]: + names: set[str] = set() + + def add(addr: str) -> None: + parts = split_email(addr) + if parts is None: + return + _, dom = parts + if dom: + names.add(dom) + + for p in self.principals: + t = p.get("type") + if t == "domain": + n = pv_string(p.get("name")).strip().lower() + if n: + names.add(n) + elif t in ("individual", "group", "list"): + + nm = pv_string(p.get("name")) + if "@" in nm: + add(nm) + for addr in pv_list(p.get("emails")): + if isinstance(addr, str): + add(addr) + + sigs = build_sub_trees(self.settings, "signature", "algorithm") + for _, sub in sigs.items(): + d = sub.get("domain", "").strip().lower() + if d: + names.add(d) + + return names + + def _build_domains(self) -> dict[str, dict[str, Any]]: + declared: dict[str, dict[str, Any]] = {} + for p in self.principals: + if p.get("type") == "domain": + n = pv_string(p.get("name")).strip().lower() + if n: + declared[n] = p + + names = sorted(self._collect_domain_names()) + out: dict[str, dict[str, Any]] = {} + for dname in names: + cid = self._next_create_cid() + self.domain_name_to_cid[dname] = cid + self.domain_cid_to_name[cid] = dname + obj: dict[str, Any] = {"name": dname} + p = declared.get(dname) + if p is not None: + desc = pv_string(p.get("description")) + if desc: + obj["description"] = desc + logo = pv_string(p.get("picture")) + if logo: + obj["logo"] = logo + tname = pv_string(p.get("tenant")) + if tname and tname in self.tenant_name_to_cid: + t_cid = self.tenant_name_to_cid[tname] + obj["memberTenantId"] = "#" + t_cid + self.domain_cid_to_tenant_cid[cid] = t_cid + out[cid] = obj + return out + + def _pick_default_domain(self) -> None: + if not self.domain_name_to_cid: + self.default_domain_cid = None + return + if len(self.domain_name_to_cid) == 1: + self.default_domain_cid = next(iter(self.domain_name_to_cid.values())) + return + + counts: dict[str, int] = defaultdict(int) + for p in self.principals: + t = p.get("type") + if t not in ("individual", "group", "list"): + continue + d = self._infer_primary_domain(p) + if d is not None: + counts[d] += 1 + if not counts: + first = sorted(self.domain_name_to_cid.keys())[0] + self.default_domain_cid = self.domain_name_to_cid[first] + return + + best = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0] + self.default_domain_cid = self.domain_name_to_cid[best] + + def _infer_primary_domain(self, p: dict[str, Any]) -> str | None: + + nm = pv_string(p.get("name")) + if "@" in nm: + parts = split_email(nm) + if parts and parts[1]: + return parts[1] + + fallback: str | None = None + for addr in pv_list(p.get("emails")): + if not isinstance(addr, str): + continue + parts = split_email(addr) + if parts is None: + continue + local, dom = parts + if not dom: + continue + if local and local == nm: + return dom + if fallback is None: + fallback = dom + return fallback + + def _tenant_default_domain_cid(self, p: dict[str, Any]) -> str | None: + tname = pv_string(p.get("tenant")) + if not tname: + return None + t_cid = self.tenant_name_to_cid.get(tname) + if t_cid is None: + return None + candidates = [ + (self.domain_cid_to_name.get(cid, ""), cid) + for cid, owner in self.domain_cid_to_tenant_cid.items() + if owner == t_cid + ] + if not candidates: + return None + candidates.sort() + return candidates[0][1] + + def _resolve_name_and_domain(self, p: dict[str, Any]) -> tuple[str, str]: + nm = pv_string(p.get("name")) + + if "@" in nm: + parts = split_email(nm) + if parts is None or not parts[1]: + raise ConvertError(f"principal name {nm!r} is malformed") + local, dom = parts + if dom not in self.domain_name_to_cid: + raise ConvertError(f"domain {dom!r} missing from domain index") + return (local, self.domain_name_to_cid[dom]) + + dom = self._infer_primary_domain(p) + if dom: + if dom not in self.domain_name_to_cid: + raise ConvertError(f"domain {dom!r} missing from domain index") + return (nm, self.domain_name_to_cid[dom]) + + tenant_default = self._tenant_default_domain_cid(p) + if tenant_default is not None: + return (nm, tenant_default) + + if self.default_domain_cid is None: + raise ConvertError( + f"principal {nm!r} has no domain and no default domain is set" + ) + return (nm, self.default_domain_cid) + + def _build_aliases( + self, + p: dict[str, Any], + primary_name: str, + primary_domain_cid: str, + ) -> dict[str, dict[str, Any]]: + aliases: dict[str, dict[str, Any]] = {} + idx = 0 + seen: set[tuple[str, str]] = set() + for addr in pv_list(p.get("emails")): + if not isinstance(addr, str): + continue + parts = split_email(addr) + if parts is None: + continue + local, dom = parts + if local == "": + continue + if dom not in self.domain_name_to_cid: + continue + d_cid = self.domain_name_to_cid[dom] + if local == primary_name and d_cid == primary_domain_cid: + continue + key = (local, d_cid) + if key in seen: + continue + seen.add(key) + aliases[str(idx)] = {"name": local, "domainId": "#" + d_cid} + idx += 1 + return aliases + + def _build_accounts(self) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for p in self.principals: + t = p.get("type") + if t == "individual": + cid, obj = self._build_user(p) + elif t == "group": + cid, obj = self._build_group(p) + else: + continue + if cid in out: + raise ConvertError(f"duplicate account client-id {cid!r}") + out[cid] = obj + return out + + def _build_user(self, p: dict[str, Any]) -> tuple[str, dict[str, Any]]: + local, dom_cid = self._resolve_name_and_domain(p) + uid = pv_int(p.get("id")) + if uid is None: + raise ConvertError(f"user {pv_string(p.get('name'))!r} has no id") + body: dict[str, Any] = { + "@type": "User", + "name": local, + "domainId": "#" + dom_cid, + "aliases": self._build_aliases(p, local, dom_cid), + "credentials": self._build_credentials(p), + "memberGroupIds": self._build_member_group_ids(p), + "quotas": self._build_account_quotas(p), + } + desc = pv_string(p.get("description")) + if desc: + body["description"] = desc + tname = pv_string(p.get("tenant")) + if tname and tname in self.tenant_name_to_cid: + body["memberTenantId"] = "#" + self.tenant_name_to_cid[tname] + return (self._account_cid(uid), body) + + def _build_group(self, p: dict[str, Any]) -> tuple[str, dict[str, Any]]: + local, dom_cid = self._resolve_name_and_domain(p) + gid = pv_int(p.get("id")) + if gid is None: + raise ConvertError(f"group {pv_string(p.get('name'))!r} has no id") + body: dict[str, Any] = { + "@type": "Group", + "name": local, + "domainId": "#" + dom_cid, + "aliases": self._build_aliases(p, local, dom_cid), + "quotas": self._build_account_quotas(p), + } + desc = pv_string(p.get("description")) + if desc: + body["description"] = desc + tname = pv_string(p.get("tenant")) + if tname and tname in self.tenant_name_to_cid: + body["memberTenantId"] = "#" + self.tenant_name_to_cid[tname] + return (self._account_cid(gid), body) + + def _build_account_quotas(self, p: dict[str, Any]) -> dict[str, int]: + quotas: dict[str, int] = {} + q = pv_int(p.get("quota")) + if q: + quotas["maxDiskQuota"] = q + return quotas + + def _build_credentials(self, p: dict[str, Any]) -> dict[str, dict[str, Any]]: + + secrets = [s for s in pv_list(p.get("secrets")) if isinstance(s, str)] + password = next( + (s for s in secrets if not is_app_password(s) and not is_otpauth(s)), + None, + ) + otp = next((s for s in secrets if is_otpauth(s)), None) + if password is None: + return {} + cred: dict[str, Any] = { + "@type": "Password", + "secret": password, + } + if otp is not None: + cred["otpAuth"] = otp + return {"0": cred} + + def _build_member_group_ids(self, p: dict[str, Any]) -> dict[str, bool]: + out: dict[str, bool] = {} + for ref in pv_list(p.get("memberOf")): + target = self._resolve_principal_ref(ref) + if target is None: + continue + if target.get("type") != "group": + continue + gid = pv_int(target.get("id")) + if gid is None: + continue + out["#" + self._account_cid(gid)] = True + return out + + def _resolve_principal_ref(self, ref: Any) -> dict[str, Any] | None: + if isinstance(ref, str): + return self.by_name.get(ref) + if isinstance(ref, int): + return self.by_id.get(ref) + if isinstance(ref, dict): + if "string" in ref: + return self.by_name.get(str(ref["string"])) + if "integer" in ref: + return self.by_id.get(int(ref["integer"])) + return None + + def _build_mailing_lists(self) -> dict[str, dict[str, Any]]: + lists = [p for p in self.principals if p.get("type") == "list"] + lists.sort(key=lambda p: (pv_int(p.get("id")) or 0)) + out: dict[str, dict[str, Any]] = {} + for p in lists: + try: + local, dom_cid = self._resolve_name_and_domain(p) + except ConvertError: + print( + f"warning: skipping mailing list {pv_string(p.get('name'))!r} " + f"(cannot resolve its domain)", + file=sys.stderr, + ) + continue + body: dict[str, Any] = { + "name": local, + "domainId": "#" + dom_cid, + "aliases": self._build_aliases(p, local, dom_cid), + "recipients": self._build_recipients(p), + } + desc = pv_string(p.get("description")) + if desc: + body["description"] = desc + tname = pv_string(p.get("tenant")) + if tname and tname in self.tenant_name_to_cid: + body["memberTenantId"] = "#" + self.tenant_name_to_cid[tname] + out[self._next_create_cid()] = body + return out + + def _build_recipients(self, p: dict[str, Any]) -> dict[str, bool]: + out: dict[str, bool] = {} + for ref in pv_list(p.get("members")): + target = self._resolve_principal_ref(ref) + if target is None: + continue + if target.get("type") not in ("individual", "group"): + continue + try: + local, dom_cid = self._resolve_name_and_domain(target) + except ConvertError: + continue + dname = self.domain_cid_to_name.get(dom_cid) + if dname is None: + continue + out[f"{local}@{dname}"] = True + + for addr in pv_list(p.get("externalMembers")): + if isinstance(addr, str) and "@" in addr: + out[addr] = True + return out + + def _build_dkim_signatures(self) -> dict[str, dict[str, Any]]: + sigs = build_sub_trees(self.settings, "signature", "algorithm") + ids = sorted(sigs.keys()) + out: dict[str, dict[str, Any]] = {} + for sid in ids: + sub = sigs[sid] + algo = sub.get("algorithm", "").strip().lower() + if algo == "rsa-sha1": + continue + if algo == "ed25519-sha256": + tag = "Dkim1Ed25519Sha256" + elif algo == "rsa-sha256": + tag = "Dkim1RsaSha256" + else: + print(f"warning: skipping DKIM signature {sid!r}: " + f"unknown algorithm {algo!r}", file=sys.stderr) + continue + selector = sub.get("selector", "").strip() + if not selector: + print(f"warning: skipping DKIM signature {sid!r}: no selector", + file=sys.stderr) + continue + domain = sub.get("domain", "").strip().lower() + if domain not in self.domain_name_to_cid: + print(f"warning: skipping DKIM signature {sid!r}: " + f"unknown domain {domain!r}", file=sys.stderr) + continue + dom_cid = self.domain_name_to_cid[domain] + canon = sub.get("canonicalization", "relaxed/relaxed").strip().lower() + if not canon: + canon = "relaxed/relaxed" + private_key, key_errors = resolve_macros( + sub.get("private-key"), self.settings + ) + if private_key is not None: + private_key = private_key.strip() + if key_errors: + print( + f"warning: skipping DKIM signature {sid!r}: could not " + f"resolve private-key: {'; '.join(key_errors)}", + file=sys.stderr, + ) + continue + if not private_key or "%{" in private_key: + print( + f"warning: skipping DKIM signature {sid!r}: private-key is " + f"empty or still contains an unresolved macro", + file=sys.stderr, + ) + continue + body: dict[str, Any] = { + "@type": tag, + "canonicalization": canon, + "domainId": "#" + dom_cid, + "privateKey": secret_text(private_key), + "selector": selector, + } + t_cid = self.domain_cid_to_tenant_cid.get(dom_cid) + if t_cid is not None: + body["memberTenantId"] = "#" + t_cid + out[self._next_create_cid()] = body + return out + + def _stores(self) -> dict[str, dict[str, str]]: + return build_sub_trees(self.settings, "store", "type") + + def _referenced_store_id(self, key: str) -> str | None: + v = self.settings.get(key) + if v is None: + return None + v = v.strip() + return v or None + + def _build_data_store(self) -> dict[str, Any] | None: + sid = self._referenced_store_id("storage.data") + if sid is None: + return None + stores = self._stores() + if sid not in stores: + raise ConvertError( + f"storage.data = {sid!r} but no store.{sid}.type is defined" + ) + sub = stores[sid] + stype = sub.get("type", "").strip().lower() + if stype == "rocksdb": + return self._build_rocksdb(sub) + if stype == "sqlite": + return self._build_sqlite(sub) + if stype == "foundationdb": + return self._build_foundationdb(sub) + if stype == "postgresql": + return self._build_postgresql(sub) + if stype == "mysql": + return self._build_mysql(sub) + raise ConvertError( + f"storage.data points at store {sid!r} of unsupported type {stype!r} " + f"(DataStore requires rocksdb/sqlite/foundationdb/postgresql/mysql)" + ) + + def _is_same_kv_store_as_data(self, sub: dict[str, str]) -> bool: + data_sid = self._referenced_store_id("storage.data") + if data_sid is None: + return False + data_sub = self._stores().get(data_sid) + if data_sub is None: + return False + if data_sub.get("type", "").strip().lower() != sub.get("type", "").strip().lower(): + return False + data_path = data_sub.get("path", "").strip() + sub_path = sub.get("path", "").strip() + return bool(data_path) and data_path == sub_path + + def _build_blob_store(self) -> dict[str, Any] | None: + sid = self._referenced_store_id("storage.blob") + if sid is None: + return {"@type": "Default"} + data_sid = self._referenced_store_id("storage.data") + if sid == data_sid: + return {"@type": "Default"} + stores = self._stores() + if sid not in stores: + raise ConvertError( + f"storage.blob = {sid!r} but no store.{sid}.type is defined" + ) + sub = stores[sid] + stype = sub.get("type", "").strip().lower() + if stype == "s3": + return self._build_s3(sub) + if stype == "azure": + return self._build_azure(sub) + if stype == "fs": + return self._build_fs(sub) + if stype == "foundationdb": + return self._build_foundationdb(sub, for_blob=True) + if stype == "postgresql": + return self._build_postgresql(sub, for_blob=True) + if stype == "mysql": + return self._build_mysql(sub, for_blob=True) + if stype in ("rocksdb", "sqlite"): + if self._is_same_kv_store_as_data(sub): + return {"@type": "Default"} + raise ConvertError( + f"storage.blob points at store {sid!r} of type {stype!r}, " + f"but v0.16 does not support a separate {stype} blob store. " + "Consolidate blob data into the data store, or configure a " + "filesystem/s3/azure/foundationdb/postgresql/mysql blob " + "store and migrate the blobs before running this script." + ) + raise ConvertError( + f"storage.blob points at store {sid!r} of unsupported type " + f"{stype!r} (BlobStore requires s3/azure/fs/foundationdb/" + "postgresql/mysql, or rocksdb/sqlite sharing the data store path)" + ) + + def _build_in_memory_store(self) -> dict[str, Any] | None: + sid = self._referenced_store_id("storage.lookup") + if sid is None: + return {"@type": "Default"} + data_sid = self._referenced_store_id("storage.data") + if sid == data_sid: + return {"@type": "Default"} + stores = self._stores() + if sid not in stores: + raise ConvertError( + f"storage.lookup = {sid!r} but no store.{sid}.type is defined" + ) + sub = stores[sid] + stype = sub.get("type", "").strip().lower() + if stype == "redis": + redis_type = sub.get("redis-type", "single").strip().lower() + if redis_type == "cluster": + return self._build_redis_cluster(sub) + return self._build_redis_single(sub) + return {"@type": "Default"} + + def _build_search_store(self) -> dict[str, Any] | None: + sid = self._referenced_store_id("storage.fts") + if sid is None: + return {"@type": "Default"} + data_sid = self._referenced_store_id("storage.data") + if sid == data_sid: + return {"@type": "Default"} + stores = self._stores() + if sid not in stores: + raise ConvertError( + f"storage.fts = {sid!r} but no store.{sid}.type is defined" + ) + sub = stores[sid] + stype = sub.get("type", "").strip().lower() + if stype == "elasticsearch": + return self._build_elasticsearch(sub) + if stype == "meilisearch": + return self._build_meilisearch(sub) + if stype == "foundationdb": + return self._build_foundationdb(sub, for_search=True) + if stype == "postgresql": + return self._build_postgresql(sub, for_search=True) + if stype == "mysql": + return self._build_mysql(sub, for_search=True) + if stype in ("rocksdb", "sqlite"): + if self._is_same_kv_store_as_data(sub): + return {"@type": "Default"} + raise ConvertError( + f"storage.fts points at store {sid!r} of type {stype!r}, " + f"but v0.16 does not support a separate {stype} search " + "store. Consolidate the full-text index into the data store, " + "or configure an elasticsearch/meilisearch/foundationdb/" + "postgresql/mysql search store before running this script." + ) + raise ConvertError( + f"storage.fts points at store {sid!r} of unsupported type " + f"{stype!r} (SearchStore requires elasticsearch/meilisearch/" + "foundationdb/postgresql/mysql, or rocksdb/sqlite sharing the " + "data store path)" + ) + + def _build_history_store( + self, store_key: str, enable_key: str, role: str + ) -> dict[str, Any] | None: + enable = parse_bool(self.settings.get(enable_key)) + sid = self._referenced_store_id(store_key) + if enable is False: + return {"@type": "Disabled"} + if sid is None: + if enable is True: + return {"@type": "Default"} + return None + data_sid = self._referenced_store_id("storage.data") + if sid == data_sid: + return {"@type": "Default"} + stores = self._stores() + if sid not in stores: + raise ConvertError( + f"{store_key} = {sid!r} but no store.{sid}.type is defined" + ) + sub = stores[sid] + stype = sub.get("type", "").strip().lower() + if stype == "foundationdb": + return self._build_foundationdb(sub) + if stype == "postgresql": + return self._build_postgresql(sub) + if stype == "mysql": + return self._build_mysql(sub) + if stype in ("rocksdb", "sqlite"): + if self._is_same_kv_store_as_data(sub): + return {"@type": "Default"} + raise ConvertError( + f"{store_key} points at store {sid!r} of type {stype!r}, " + f"but v0.16 does not support a separate {stype} {role} " + "store. Point this setting at the data store, or configure " + "a foundationdb/postgresql/mysql store." + ) + raise ConvertError( + f"{store_key} points at store {sid!r} of unsupported type " + f"{stype!r} ({role} store requires foundationdb/postgresql/" + "mysql, or rocksdb/sqlite sharing the data store path)" + ) + + def _build_metrics_store(self) -> dict[str, Any] | None: + return self._build_history_store( + "metrics.history.store", "metrics.history.enable", "metrics" + ) + + def _build_tracing_store(self) -> dict[str, Any] | None: + return self._build_history_store( + "tracing.history.store", "tracing.history.enable", "tracing" + ) + + def _build_rocksdb(self, sub: dict[str, str]) -> dict[str, Any]: + path = sub.get("path", "").strip() + if not path: + raise ConvertError("rocksdb store missing required 'path'") + body: dict[str, Any] = {"@type": "RocksDb", "path": path} + bs = parse_size_bytes(sub.get("settings.min-blob-size")) + if bs is not None: + body["blobSize"] = bs + wb = parse_size_bytes(sub.get("settings.write-buffer-size")) + if wb is not None: + body["bufferSize"] = wb + pw = parse_int(sub.get("pool.workers")) + if pw is not None: + body["poolWorkers"] = pw + return body + + def _build_sqlite(self, sub: dict[str, str]) -> dict[str, Any]: + path = sub.get("path", "").strip() + if not path: + raise ConvertError("sqlite store missing required 'path'") + body: dict[str, Any] = {"@type": "Sqlite", "path": path} + pmc = parse_int(sub.get("pool.max-connections")) + if pmc is not None: + body["poolMaxConnections"] = pmc + pw = parse_int(sub.get("pool.workers")) + if pw is not None: + body["poolWorkers"] = pw + return body + + def _build_foundationdb( + self, + sub: dict[str, str], + *, + for_blob: bool = False, + for_search: bool = False, + ) -> dict[str, Any]: + body: dict[str, Any] = {"@type": "FoundationDb"} + cf = sub.get("cluster-file", "").strip() + if cf: + body["clusterFile"] = cf + dc = sub.get("ids.datacenter", "").strip() + if dc: + body["datacenterId"] = dc + mid = sub.get("ids.machine", "").strip() + if mid: + body["machineId"] = mid + trd = parse_duration_ms(sub.get("transaction.max-retry-delay")) + if trd is not None: + body["transactionRetryDelay"] = trd + trl = parse_int(sub.get("transaction.retry-limit")) + if trl is not None: + body["transactionRetryLimit"] = trl + tt = parse_duration_ms(sub.get("transaction.timeout")) + if tt is not None: + body["transactionTimeout"] = tt + return body + + def _build_sql_common(self, sub: dict[str, str]) -> dict[str, Any]: + out: dict[str, Any] = {} + host = sub.get("host", "").strip() + if not host: + raise ConvertError("SQL store missing required 'host'") + out["host"] = host + db = sub.get("database", "").strip() + if not db: + raise ConvertError("SQL store missing required 'database'") + out["database"] = db + port = parse_int(sub.get("port")) + if port is not None: + out["port"] = port + user = sub.get("user", "").strip() + if user: + out["authUsername"] = user + out["authSecret"] = secret_key_optional(sub.get("password")) + tls_enable = parse_bool(sub.get("tls.enable")) + if tls_enable is not None: + out["useTls"] = tls_enable + tls_invalid = parse_bool(sub.get("tls.allow-invalid-certs")) + if tls_invalid is not None: + out["allowInvalidCerts"] = tls_invalid + tout = parse_duration_ms(sub.get("timeout")) + if tout is not None: + out["timeout"] = tout + pmc = parse_int(sub.get("pool.max-connections")) + if pmc is not None: + out["poolMaxConnections"] = pmc + return out + + def _build_postgresql( + self, + sub: dict[str, str], + *, + for_blob: bool = False, + for_search: bool = False, + ) -> dict[str, Any]: + body = {"@type": "PostgreSql"} + body.update(self._build_sql_common(sub)) + return body + + def _build_mysql( + self, + sub: dict[str, str], + *, + for_blob: bool = False, + for_search: bool = False, + ) -> dict[str, Any]: + body = {"@type": "MySql"} + body.update(self._build_sql_common(sub)) + map_ = parse_size_bytes(sub.get("max-allowed-packet")) + if map_ is not None: + body["maxAllowedPacket"] = map_ + pmin = parse_int(sub.get("pool.min-connections")) + if pmin is not None: + body["poolMinConnections"] = pmin + return body + + def _build_s3(self, sub: dict[str, str]) -> dict[str, Any]: + body: dict[str, Any] = {"@type": "S3"} + bucket = sub.get("bucket", "").strip() + if not bucket: + raise ConvertError("s3 store missing required 'bucket'") + body["bucket"] = bucket + body["accessKey"] = public_string_optional(sub.get("access-key", "").strip()) + body["secretKey"] = secret_key_optional(sub.get("secret-key")) + body["securityToken"] = secret_key_optional(sub.get("security-token")) + profile = sub.get("profile", "").strip() + if profile: + body["profile"] = profile + kp = sub.get("key-prefix", "").strip() + if kp: + body["keyPrefix"] = kp + mr = parse_int(sub.get("max-retries")) + if mr is not None: + body["maxRetries"] = mr + to = parse_duration_ms(sub.get("timeout")) + if to is not None: + body["timeout"] = to + region_raw = sub.get("region", "").strip().lower() + endpoint = sub.get("endpoint", "").strip() + if endpoint: + body["region"] = { + "@type": "Custom", + "customEndpoint": endpoint, + "customRegion": region_raw or "custom", + } + elif region_raw in _S3_REGION_MAP: + body["region"] = {"@type": _S3_REGION_MAP[region_raw]} + elif region_raw: + + body["region"] = { + "@type": "Custom", + "customEndpoint": "", + "customRegion": region_raw, + } + return body + + def _build_azure(self, sub: dict[str, str]) -> dict[str, Any]: + body: dict[str, Any] = {"@type": "Azure"} + sa = sub.get("storage-account", "").strip() + if not sa: + raise ConvertError("azure store missing required 'storage-account'") + body["storageAccount"] = sa + cont = sub.get("container", "").strip() + if not cont: + raise ConvertError("azure store missing required 'container'") + body["container"] = cont + body["accessKey"] = secret_key_optional(sub.get("azure-access-key")) + body["sasToken"] = secret_key_optional(sub.get("sas-token")) + kp = sub.get("key-prefix", "").strip() + if kp: + body["keyPrefix"] = kp + mr = parse_int(sub.get("max-retries")) + if mr is not None: + body["maxRetries"] = mr + to = parse_duration_ms(sub.get("timeout")) + if to is not None: + body["timeout"] = to + return body + + def _build_fs(self, sub: dict[str, str]) -> dict[str, Any]: + path = sub.get("path", "").strip() + if not path: + raise ConvertError("fs store missing required 'path'") + body: dict[str, Any] = {"@type": "FileSystem", "path": path} + depth = parse_int(sub.get("depth")) + if depth is not None: + body["depth"] = depth + return body + + def _build_redis_single(self, sub: dict[str, str]) -> dict[str, Any]: + urls = collect_array(sub, "urls") + if not urls: + raise ConvertError("redis store missing required 'urls'") + body: dict[str, Any] = {"@type": "Redis", "url": urls[0]} + to = parse_duration_ms(sub.get("timeout")) + if to is not None: + body["timeout"] = to + return body + + def _build_redis_cluster(self, sub: dict[str, str]) -> dict[str, Any]: + urls = collect_array(sub, "urls") + if not urls: + raise ConvertError("redis-cluster store missing required 'urls'") + body: dict[str, Any] = { + "@type": "RedisCluster", + "urls": {u: True for u in urls}, + } + body["authSecret"] = secret_key_optional(sub.get("password")) + user = sub.get("user", "").strip() + if user: + body["authUsername"] = user + mr = parse_int(sub.get("retry.total")) + if mr is not None: + body["maxRetries"] = mr + mxw = parse_duration_ms(sub.get("retry.max-wait")) + if mxw is not None: + body["maxRetryWait"] = mxw + mnw = parse_duration_ms(sub.get("retry.min-wait")) + if mnw is not None: + body["minRetryWait"] = mnw + rfr = parse_bool(sub.get("read-from-replicas")) + if rfr is not None: + body["readFromReplicas"] = rfr + pv = sub.get("protocol-version", "").strip().lower() + if pv in _REDIS_PROTOCOL_MAP: + body["protocolVersion"] = _REDIS_PROTOCOL_MAP[pv] + to = parse_duration_ms(sub.get("timeout")) + if to is not None: + body["timeout"] = to + return body + + def _build_http_auth(self, sub: dict[str, str]) -> dict[str, Any]: + token = sub.get("auth.token", "").strip() + if token: + return {"@type": "Bearer", "bearerToken": secret_key(token)} + username = sub.get("auth.username", "").strip() + secret = sub.get("auth.secret", "") + if username: + return {"@type": "Basic", "username": username, + "secret": secret_key(secret)} + return {"@type": "Unauthenticated"} + + def _build_elasticsearch(self, sub: dict[str, str]) -> dict[str, Any]: + url = sub.get("url", "").strip() + if not url: + raise ConvertError("elasticsearch store missing required 'url'") + body: dict[str, Any] = { + "@type": "ElasticSearch", + "url": url, + "httpAuth": self._build_http_auth(sub), + } + aic = parse_bool(sub.get("tls.allow-invalid-certs")) + if aic is not None: + body["allowInvalidCerts"] = aic + nr = parse_int(sub.get("index.replicas")) + if nr is not None: + body["numReplicas"] = nr + ns = parse_int(sub.get("index.shards")) + if ns is not None: + body["numShards"] = ns + return body + + def _build_meilisearch(self, sub: dict[str, str]) -> dict[str, Any]: + url = sub.get("url", "").strip() + if not url: + raise ConvertError("meilisearch store missing required 'url'") + body: dict[str, Any] = { + "@type": "Meilisearch", + "url": url, + "httpAuth": self._build_http_auth(sub), + } + aic = parse_bool(sub.get("tls.allow-invalid-certs")) + if aic is not None: + body["allowInvalidCerts"] = aic + pi = parse_duration_ms(sub.get("task.poll-interval")) + if pi is not None: + body["pollInterval"] = pi + return body + + def _build_enterprise(self) -> dict[str, Any] | None: + lk = self.settings.get("enterprise.license-key", "").strip() + ak = self.settings.get("enterprise.api-key", "").strip() + lu = self.settings.get("enterprise.logo-url", "").strip() + if not (lk or ak or lu): + return None + body: dict[str, Any] = { + "licenseKey": secret_key_optional(lk), + "apiKey": secret_key_optional(ak), + } + if lu: + body["logoUrl"] = lu + return body + + def _build_system_settings(self) -> dict[str, Any] | None: + if self.default_domain_cid is None: + return None + hostname = self.settings.get("server.hostname", "").strip() + body: dict[str, Any] = { + "defaultDomainId": "#" + self.default_domain_cid, + "defaultHostname": hostname, + } + return body + + def _build_certificates(self) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + + for sid, sub in sorted( + build_sub_trees(self.settings, "certificate", "cert").items() + ): + cert, cert_errors = resolve_macros( + sub.get("cert", ""), self.settings + ) + key, key_errors = resolve_macros( + sub.get("private-key", ""), self.settings + ) + cert = (cert or "").strip() + key = (key or "").strip() + if cert_errors or key_errors: + print( + f"warning: skipping certificate.{sid}: could not resolve " + f"value: {'; '.join(cert_errors + key_errors)}", + file=sys.stderr, + ) + continue + if not cert or not key: + print( + f"warning: skipping certificate.{sid}: " + "missing cert or private-key", + file=sys.stderr, + ) + continue + if "-----BEGIN " not in cert or "-----BEGIN " not in key: + print( + f"warning: skipping certificate.{sid}: value is not PEM " + "(likely a file or env placeholder)", + file=sys.stderr, + ) + continue + out[self._next_create_cid()] = _make_certificate_object(cert, key) + + for sid, sub in sorted( + build_sub_trees(self.settings, "acme", "cert").items() + ): + blob_b64 = sub.get("cert", "").strip() + if not blob_b64: + continue + padded = blob_b64 + "=" * (-len(blob_b64) % 4) + blob = None + for decoder in (base64.b64decode, base64.urlsafe_b64decode): + try: + blob = decoder(padded).decode("latin-1") + break + except Exception as exc: + last_err = exc + if blob is None: + print( + f"warning: skipping acme.{sid}: base64 decode failed: {last_err}", + file=sys.stderr, + ) + continue + cert_pem, key_pem = split_pem_bundle(blob) + if not cert_pem or not key_pem: + print( + f"warning: skipping acme.{sid}: decoded bundle lacks " + "cert or private key", + file=sys.stderr, + ) + continue + out[self._next_create_cid()] = _make_certificate_object( + cert_pem, key_pem + ) + + return out + + def _check_duplicate_emails( + self, + accounts: dict[str, dict[str, Any]], + mailing_lists: dict[str, dict[str, Any]], + ) -> None: + owners: dict[tuple[str, str], str] = {} + + def claim(local: str, domain_ref: str, owner: str) -> None: + d_cid = domain_ref[1:] if domain_ref.startswith("#") else domain_ref + key = (local, d_cid) + if key in owners: + dname = self.domain_cid_to_name.get(d_cid, d_cid) + raise ConvertError( + f"duplicate email address {local}@{dname!s} — " + f"claimed by both {owners[key]} and {owner}" + ) + owners[key] = owner + + for cid, obj in accounts.items(): + kind = obj.get("@type", "Account") + claim(obj["name"], obj["domainId"], f"{kind} {cid} ({obj['name']})") + for alias in obj.get("aliases", {}).values(): + claim(alias["name"], alias["domainId"], + f"alias of {kind} {cid} ({obj['name']})") + + for cid, obj in mailing_lists.items(): + claim(obj["name"], obj["domainId"], + f"MailingList {cid} ({obj['name']})") + for alias in obj.get("aliases", {}).values(): + claim(alias["name"], alias["domainId"], + f"alias of MailingList {cid} ({obj['name']})") + + def _validate_records( + self, + domains: dict[str, dict[str, Any]], + accounts: dict[str, dict[str, Any]], + mailing_lists: dict[str, dict[str, Any]], + dkim_signatures: dict[str, dict[str, Any]], + ) -> None: + bad_domain_cids: set[str] = set() + rejected_domains = 0 + for cid in list(domains.keys()): + name = domains[cid].get("name", "") + if not is_valid_domain_name(name): + print( + f"warning: dropping domain {name!r}: not a valid v0.16 hostname " + f"(must have at least two labels, valid characters)", + file=sys.stderr, + ) + bad_domain_cids.add(cid) + rejected_domains += 1 + del domains[cid] + + renamed_accounts = 0 + dropped_accounts: list[str] = [] + for cid in list(accounts.keys()): + obj = accounts[cid] + name = obj.get("name", "") + kind = obj.get("@type", "Account") + domain_ref = obj.get("domainId", "") + d_cid = domain_ref[1:] if domain_ref.startswith("#") else domain_ref + if d_cid in bad_domain_cids: + print( + f"warning: dropping {kind} {name!r}: its domain was rejected", + file=sys.stderr, + ) + dropped_accounts.append(cid) + del accounts[cid] + continue + if "@" in name: + trimmed = name.replace("@", "").strip() + if trimmed and is_valid_local_part(trimmed): + print( + f"warning: renaming {kind} {name!r} to {trimmed!r} " + f"(local-part must not contain '@')", + file=sys.stderr, + ) + obj["name"] = trimmed + renamed_accounts += 1 + else: + print( + f"warning: dropping {kind} {name!r}: invalid local-part, " + f"rename in v0.15 before retrying", + file=sys.stderr, + ) + dropped_accounts.append(cid) + del accounts[cid] + continue + new_aliases: dict[str, dict[str, Any]] = {} + for idx, alias in obj.get("aliases", {}).items(): + a_dom = alias.get("domainId", "") + a_dcid = a_dom[1:] if a_dom.startswith("#") else a_dom + if a_dcid in bad_domain_cids: + print( + f"warning: dropping alias of {kind} {name!r}: domain rejected", + file=sys.stderr, + ) + continue + if "@" in alias.get("name", ""): + print( + f"warning: dropping alias {alias.get('name')!r} of " + f"{kind} {name!r}: invalid local-part", + file=sys.stderr, + ) + continue + new_aliases[idx] = alias + obj["aliases"] = new_aliases + + for cid in list(mailing_lists.keys()): + obj = mailing_lists[cid] + domain_ref = obj.get("domainId", "") + d_cid = domain_ref[1:] if domain_ref.startswith("#") else domain_ref + if d_cid in bad_domain_cids: + print( + f"warning: dropping MailingList {obj.get('name')!r}: domain rejected", + file=sys.stderr, + ) + del mailing_lists[cid] + + dropped_dkim = 0 + for cid in list(dkim_signatures.keys()): + obj = dkim_signatures[cid] + domain_ref = obj.get("domainId", "") + d_cid = domain_ref[1:] if domain_ref.startswith("#") else domain_ref + if d_cid and d_cid in bad_domain_cids: + print( + f"warning: dropping DkimSignature for rejected domain", + file=sys.stderr, + ) + del dkim_signatures[cid] + dropped_dkim += 1 + + if rejected_domains or renamed_accounts or dropped_accounts or dropped_dkim: + print( + f"validation summary: {rejected_domains} domain(s) rejected, " + f"{renamed_accounts} account(s) renamed, " + f"{len(dropped_accounts)} account(s) dropped, " + f"{dropped_dkim} DKIM signature(s) dropped", + file=sys.stderr, + ) + print( + "review the warnings above; fix them in the source v0.15 deployment " + "and rerun if any of the rejections are unintentional.", + file=sys.stderr, + ) + +SINGLETON_ORDER = [ + "SystemSettings", + "Enterprise", + "BlobStore", + "InMemoryStore", + "SearchStore", + "MetricsStore", + "TracingStore", +] + +COLLECTION_ORDER = [ + "Tenant", + "Domain", + "Account", + "MailingList", + "DkimSignature", + "Certificate", +] + +def build_export_ops(result: dict[str, Any]) -> list[dict[str, Any]]: + ops: list[dict[str, Any]] = [] + for name in COLLECTION_ORDER: + if name not in result: + continue + records: dict[str, dict[str, Any]] = result[name] + if not records: + continue + if name == "Account": + groups = {c: r for c, r in records.items() if r.get("@type") == "Group"} + users = {c: r for c, r in records.items() if r.get("@type") == "User"} + if groups: + ops.append({"@type": "create", "object": name, "value": groups}) + if users: + ops.append({"@type": "create", "object": name, "value": users}) + else: + ops.append({"@type": "create", "object": name, "value": records}) + for name in SINGLETON_ORDER: + if name in result: + ops.append({ + "@type": "update", + "object": name, + "value": result[name], + }) + return ops + +def cmd_convert(args: argparse.Namespace) -> int: + with open(args.settings, "r", encoding="utf-8") as f: + settings = json.load(f) + if not isinstance(settings, dict): + print(f"error: {args.settings} is not a JSON object", file=sys.stderr) + return 2 + settings = {str(k): ("" if v is None else str(v)) for k, v in settings.items()} + + with open(args.principals, "r", encoding="utf-8") as f: + principals = json.load(f) + if not isinstance(principals, list): + print(f"error: {args.principals} is not a JSON array", file=sys.stderr) + return 2 + + conv = Converter(principals, settings) + result = conv.run() + + data_store = result.pop("DataStore", None) + if data_store is None: + raise ConvertError( + "DataStore could not be built (storage.data missing or invalid)" + ) + + patches: list[tuple[str, str]] = list(args.patch_paths or []) + if not patches and not args.keep_paths: + legacy = detect_legacy_paths(settings, "/opt/stalwart") + if legacy: + print( + f"notice: detected legacy Docker paths under /opt/stalwart in " + f"{legacy} settings.\n" + f" the v0.16 Docker image mounts persistent data at " + f"/var/lib/stalwart.\n" + f" rerun with --patch-paths /opt/stalwart=/var/lib/stalwart " + f"to rewrite,\n" + f" or pass --keep-paths to suppress this notice " + f"(e.g. for binary deployments).", + file=sys.stderr, + ) + + if patches: + data_store, n_cfg = apply_path_patches(data_store, patches) + print(f" patched {n_cfg} path(s) in {args.config}", file=sys.stderr) + + with open(args.config, "w", encoding="utf-8") as f: + json.dump(data_store, f, indent=2, ensure_ascii=False) + print(f"wrote {args.config} (DataStore: @type={data_store.get('@type')!r})", + file=sys.stderr) + + ops = build_export_ops(result) + if patches: + ops, n_ops = apply_path_patches(ops, patches) + print(f" patched {n_ops} path(s) in {args.output}", file=sys.stderr) + with open(args.output, "w", encoding="utf-8") as f: + for op in ops: + f.write(json.dumps(op, ensure_ascii=False)) + f.write("\n") + print(f"wrote {args.output} ({len(ops)} ops, NDJSON)", file=sys.stderr) + for op in ops: + kind = op["@type"] + name = op["object"] + if kind == "update": + print(f" update {name}", file=sys.stderr) + else: + print(f" create {name}: {len(op['value'])} records", + file=sys.stderr) + + if args.unmigrated_output: + unmigrated = compute_unmigrated_keys(settings) + if unmigrated: + write_unmigrated_summary(unmigrated, args.unmigrated_output) + print( + f"wrote {args.unmigrated_output} ({len(unmigrated)} v0.15 " + f"settings keys not migrated; review and recreate manually)", + file=sys.stderr, + ) + return 0 + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Dump / migrate a Stalwart server via its management API.", + ) + sub = p.add_subparsers(dest="command", required=True) + + d = sub.add_parser("dump", help="Dump settings and principals to JSON files.") + d.add_argument("--url", required=True, + help="Base URL of the Stalwart server, e.g. https://mail.example.com") + d.add_argument("--token", help="Bearer token.") + d.add_argument("--username", help="Admin username for HTTP Basic auth.") + d.add_argument("--password", help="Admin password for HTTP Basic auth.") + d.add_argument("--settings", default="settings.json", + help="Output file for settings (default: settings.json).") + d.add_argument("--principals", default="principals.json", + help="Output file for principals (default: principals.json).") + d.set_defaults(func=cmd_dump) + + c = sub.add_parser("convert", + help="Convert dumped JSON into the new JMAP-object format.") + c.add_argument("--settings", default="settings.json", + help="Input settings JSON (default: settings.json).") + c.add_argument("--principals", default="principals.json", + help="Input principals JSON (default: principals.json).") + c.add_argument("--config", default="config.json", + help="Output file for the DataStore object " + "(default: config.json).") + c.add_argument("--output", default="export.json", + help="Output NDJSON file with one operation per line " + "(default: export.json).") + c.add_argument("--patch-paths", action="append", type=parse_path_patch, + metavar="SOURCE=DEST", + help="Rewrite paths beginning with SOURCE to DEST in both " + "config.json and export.json. May be passed multiple " + "times. Use to migrate Docker deployments from " + "/opt/stalwart to /var/lib/stalwart.") + c.add_argument("--keep-paths", action="store_true", + help="Suppress the legacy-path detection notice. Use when " + "the on-disk paths in the v0.15 deployment are also " + "valid for the v0.16 deployment (typical for binary " + "installs).") + c.add_argument("--unmigrated-output", default="unmigrated.txt", + help="Output file listing v0.15 setting prefixes that the " + "script did not migrate (default: unmigrated.txt). " + "Pass an empty string to skip writing this file.") + c.set_defaults(func=cmd_convert) + + return p + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + return args.func(args) + except (ApiError, ConvertError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/scripts/minify_html.sh b/resources/scripts/minify_html.sh new file mode 100755 index 0000000..98940d3 --- /dev/null +++ b/resources/scripts/minify_html.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Minify a self-contained HTML file (with inlined
I have the most brilliant plan.= + Let me tell you all about it. What we do is, we
+--boundary_0-- diff --git a/tests/resources/jmap/email_set/headers.jmap b/tests/resources/jmap/email_set/headers.jmap new file mode 100644 index 0000000..6063a62 --- /dev/null +++ b/tests/resources/jmap/email_set/headers.jmap @@ -0,0 +1,307 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": { + "$draft": true, + "$seen": true + }, + "receivedAt": "2018-07-10T01:03:11Z", + "messageId": [ + "my-message-id" + ], + "inReplyTo": [ + "other-message-id", + "yet-another-message-id" + ], + "references": [ + "first-message-id", + "second-message-id" + ], + "sender": [ + { + "name": "ハロー・ワールド", + "email": "joe@example.com" + } + ], + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "to": [ + { + "name": "Greg Vaudreuil", + "email": "gvaudre@NRI.Reston.VA.US" + }, + { + "name": "Ned Freed", + "email": "ned@innosoft.com" + }, + { + "name": "Keith Moore", + "email": "moore@cs.utk.edu" + } + ], + "cc": [ + { + "name": "Привет, мир", + "email": "addr0@example.com" + } + ], + "bcc": [ + { + "name": "¡El ñandú comió ñoquis!", + "email": "addr1@example.com" + } + ], + "replyTo": [ + { + "name": "안녕하세요 세계", + "email": "addr2@example.com" + }, + { + "name": "Antoine de Saint-Exupéry", + "email": "addr3@example.com" + } + ], + "subject": "Headers test", + "sentAt": "2018-07-10T01:03:11Z", + "bodyStructure": { + "headers": [ + { + "name": "Bcc", + "value": " =?utf-8?B?wqFFbCDDsWFuZMO6IGNvbWnDsyDDsW9xdWlzIQ==?=\r\n " + }, + { + "name": "Cc", + "value": " =?utf-8?B?0J/RgNC40LLQtdGCLCDQvNC40YA=?= " + }, + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "In-Reply-To", + "value": " " + }, + { + "name": "List-Owner", + "value": " ,\r\n " + }, + { + "name": "List-Subscribe", + "value": " ,\r\n " + }, + { + "name": "List-Subscribe", + "value": " " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "References", + "value": " " + }, + { + "name": "Reply-To", + "value": " =?utf-8?B?7JWI64WV7ZWY7IS47JqUIOyEuOqzhA==?= ,\r\n =?utf-8?Q?Antoine_de_Saint-Exup=C3=A9ry?= " + }, + { + "name": "Resent-Date", + "value": " Sat, 2 Jul 2005 09:52:37 +0000" + }, + { + "name": "Resent-Date", + "value": " Sun, 3 Jul 2005 09:52:37 +0000" + }, + { + "name": "Resent-Date", + "value": " Mon, 4 Jul 2005 09:52:37 +0000" + }, + { + "name": "Sender", + "value": " =?utf-8?B?44OP44Ot44O844O744Ov44O844Or44OJ?= " + }, + { + "name": "Subject", + "value": " Headers test" + }, + { + "name": "To", + "value": " \"Greg Vaudreuil\" , \"Ned Freed\"\r\n , \"Keith Moore\" " + }, + { + "name": "X-AddressesGroup", + "value": " \"A Group\": \"Ed Jones\" , , \"John\"\r\n ;" + }, + { + "name": "X-AddressesGroup", + "value": " \"List 1\": , ;, \"List 2\":\r\n , ;, , " + }, + { + "name": "X-References", + "value": " <1234@local.machine.example> <3456@example.net>" + }, + { + "name": "X-References", + "value": " <789@local.machine.example> " + }, + { + "name": "X-Text", + "value": " a b" + }, + { + "name": "X-Text", + "value": " this is some text" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " multipart/alternative;\r\n boundary=\"boundary_0\"" + } + ], + "type": "multipart/alternative", + "subParts": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 81, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"us-ascii\"" + }, + { + "name": "X-Header", + "value": " just a value" + }, + { + "name": "X-Text", + "value": " more text" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "language": [ + "en" + ] + }, + { + "partId": "2", + "blobId": "blob_1", + "size": 218, + "headers": [ + { + "name": "Content-Location", + "value": " https://example.com/html-body.html" + }, + { + "name": "Content-Type", + "value": " text/html; charset=\"utf-8\"; name=\"html-body.html\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "name": "html-body.html", + "type": "text/html", + "charset": "utf-8", + "location": "https://example.com/html-body.html" + } + ] + }, + "bodyValues": { + "1": { + "value": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "isEncodingProblem": false, + "isTruncated": false + }, + "2": { + "value": "...", + "isEncodingProblem": false, + "isTruncated": true + } + }, + "textBody": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 81, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"us-ascii\"" + }, + { + "name": "X-Header", + "value": " just a value" + }, + { + "name": "X-Text", + "value": " more text" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "language": [ + "en" + ] + } + ], + "htmlBody": [ + { + "partId": "2", + "blobId": "blob_1", + "size": 218, + "headers": [ + { + "name": "Content-Location", + "value": " https://example.com/html-body.html" + }, + { + "name": "Content-Type", + "value": " text/html; charset=\"utf-8\"; name=\"html-body.html\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "name": "html-body.html", + "type": "text/html", + "charset": "utf-8", + "location": "https://example.com/html-body.html" + } + ], + "attachments": [], + "hasAttachment": false, + "preview": "I have the most brilliant plan. Let me tell you all about it. What we do is, we" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/headers.json b/tests/resources/jmap/email_set/headers.json new file mode 100644 index 0000000..ac98a05 --- /dev/null +++ b/tests/resources/jmap/email_set/headers.json @@ -0,0 +1,186 @@ +{ + "keywords": { + "$seen": true, + "$draft": true + }, + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "subject": "Headers test", + "receivedAt": "2018-07-10T01:03:11Z", + "sentAt": "2018-07-10T11:03:11+10:00", + "messageId": [ + "my-message-id" + ], + "inReplyTo": [ + "other-message-id", + "yet-another-message-id" + ], + "references": [ + "first-message-id", + "second-message-id" + ], + "sender": [ + { + "name": "ハロー・ワールド", + "email": "joe@example.com" + } + ], + "to": [ + { + "email": "gvaudre@NRI.Reston.VA.US", + "name": "Greg Vaudreuil" + }, + { + "email": "ned@innosoft.com", + "name": "Ned Freed" + }, + { + "email": "moore@cs.utk.edu", + "name": "Keith Moore" + } + ], + "cc": [ + { + "name": "Привет, мир", + "email": "addr0@example.com" + } + ], + "bcc": [ + { + "name": "¡El ñandú comió ñoquis!", + "email": "addr1@example.com" + } + ], + "replyTo": [ + { + "name": "안녕하세요 세계", + "email": "addr2@example.com" + }, + { + "name": "Antoine de Saint-Exupéry", + "email": "addr3@example.com" + } + ], + "header:Resent-Date:asDate:all": [ + "2005-07-02T11:52:37+02:00", + "2005-07-03T12:52:37+03:00", + "2005-07-04T13:52:37+04:00" + ], + "header:X-Text:asText:all": [ + "this is some text", + "a b" + ], + "header:List-Owner:asURLs": [ + "http://www.host.com/list.cgi?cmd=sub&lst=list", + "mailto:list-manager@host.com?body=subscribe%20list" + ], + "header:List-Subscribe:asURLs:all": [ + [ + "mailto:list-manager@host.com?body=subscribe%20list" + ], + [ + "ftp://ftp.host.com/list.txt", + "mailto:list@host.com?subject=subscribe" + ] + ], + "header:X-References:asMessageIds:all": [ + [ + "1234@local.machine.example", + "3456@example.net" + ], + [ + "789@local.machine.example", + "abcd@example.net" + ] + ], + "header:X-AddressesGroup:asGroupedAddresses:all": [ + [ + { + "addresses": [ + { + "email": "c@a.test", + "name": "Ed Jones" + }, + { + "email": "joe@where.test", + "name": null + }, + { + "email": "jdoe@one.test", + "name": "John" + } + ], + "name": "A Group" + } + ], + [ + { + "addresses": [ + { + "email": "addr1@test.com", + "name": null + }, + { + "email": "addr2@test.com", + "name": null + } + ], + "name": "List 1" + }, + { + "addresses": [ + { + "email": "addr3@test.com", + "name": null + }, + { + "email": "addr4@test.com", + "name": null + } + ], + "name": "List 2" + }, + { + "addresses": [ + { + "email": "addr5@test.com", + "name": null + }, + { + "email": "addr6@test.com", + "name": null + } + ], + "name": null + } + ] + ], + "textBody": [ + { + "type": "text/plain", + "blobId": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "charset": "us-ascii", + "header:Content-Language": "en", + "header:X-Header": "just a value", + "header:X-Text": "more text" + } + ], + "htmlBody": [ + { + "type": "text/html", + "partId": "a49d", + "location": "https://example.com/html-body.html", + "name": "html-body.html" + } + ], + "bodyValues": { + "a49d": { + "value": "
I have the most brilliant plan. Let me tell you all about it. What we do is, we
", + "isTruncated": false + } + } +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/minimal.eml b/tests/resources/jmap/email_set/minimal.eml new file mode 100644 index 0000000..ab86676 --- /dev/null +++ b/tests/resources/jmap/email_set/minimal.eml @@ -0,0 +1,7 @@ +Date: Tue, 10 Jul 2018 01:03:11 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + + diff --git a/tests/resources/jmap/email_set/minimal.jmap b/tests/resources/jmap/email_set/minimal.jmap new file mode 100644 index 0000000..1fbd9f2 --- /dev/null +++ b/tests/resources/jmap/email_set/minimal.jmap @@ -0,0 +1,112 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": {}, + "receivedAt": "2018-07-10T01:03:11Z", + "messageId": [ + "my-message-id" + ], + "sentAt": "2018-07-10T01:03:11Z", + "bodyStructure": { + "partId": "0", + "blobId": "blob_0", + "size": 2, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "utf-8" + }, + "bodyValues": { + "0": { + "value": "\n", + "isEncodingProblem": false, + "isTruncated": false + } + }, + "textBody": [ + { + "partId": "0", + "blobId": "blob_0", + "size": 2, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "utf-8" + } + ], + "htmlBody": [ + { + "partId": "0", + "blobId": "blob_0", + "size": 2, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "utf-8" + } + ], + "attachments": [], + "hasAttachment": false, + "preview": "\n" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/minimal.json b/tests/resources/jmap/email_set/minimal.json new file mode 100644 index 0000000..a39ef7b --- /dev/null +++ b/tests/resources/jmap/email_set/minimal.json @@ -0,0 +1,7 @@ +{ + "receivedAt": "2018-07-10T01:03:11Z", + "sentAt": "2018-07-10T11:03:11+10:00", + "messageId": [ + "my-message-id" + ] +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/mixed.eml b/tests/resources/jmap/email_set/mixed.eml new file mode 100644 index 0000000..263c989 --- /dev/null +++ b/tests/resources/jmap/email_set/mixed.eml @@ -0,0 +1,53 @@ +Date: Sat, 20 Nov 2021 22:22:01 +0000 +From: "Art Vandelay (Vandelay Industries)" +Message-ID: +Subject: =?utf-8?Q?Why_not_both_importing_AND_exporting=3F_=E2=98=BA?= +To: "Colleagues": "James Smythe" ;, "Friends": + , =?utf-8?Q?John_Sm=C3=AEth?= ; +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary_0" + + +--boundary_0 +Content-Type: multipart/alternative; + boundary="boundary_1" + + +--boundary_1 +Content-Language: en +Content-Type: text/plain +Content-Transfer-Encoding: quoted-printable + +I was thinking about quitting the =E2=80=9Cexporting=E2=80=9D to focus just= + on the =E2=80=9Cimporting=E2=80=9D, +but then I thought, why not do both? =E2=98=BA + +--boundary_1 +Content-Language: en_US +Content-Type: text/html +Content-Transfer-Encoding: quoted-printable + +

I was thinking about quitting the “exporting” to focus= + just on the “importing”,

but then I thought, why not do = +both? ☺

+--boundary_1-- + +--boundary_0 +Content-ID: +Content-Type: image/png +Content-Transfer-Encoding: base64 + +aGVyZSBhcmUgdGhlIGVtYmVkZGVkIGltYWdlIGNvbnRlbnRzIQ== + +--boundary_0 +Content-Disposition: attachment; + filename*=UTF-8''Book%20about%20%E2%98%95%20tables.pdf +Content-Type: x-document/pdf +Content-Transfer-Encoding: base64 + +PGh0bWw+PGJvZHk+4oSM8J2UovCdlKnwnZStIPCdlKrwnZSiIPCdlKLwnZS18J2UrfCdlKzwnZSv +8J2UsSDwnZSq8J2UtiDwnZSf8J2UrPCdlKzwnZSoIPCdlK3wnZSp8J2UovCdlJ7wnZSw8J2UoiE8 +L2JvZHk+PC9odG1sPg== + +--boundary_0-- diff --git a/tests/resources/jmap/email_set/mixed.jmap b/tests/resources/jmap/email_set/mixed.jmap new file mode 100644 index 0000000..43eaecd --- /dev/null +++ b/tests/resources/jmap/email_set/mixed.jmap @@ -0,0 +1,326 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": { + "$draft": true, + "$seen": true, + "my-tag": true + }, + "receivedAt": "2021-11-20T22:22:01Z", + "messageId": [ + "my-message-id" + ], + "from": [ + { + "name": "Art Vandelay (Vandelay Industries)", + "email": "art@vandelay.com" + } + ], + "to": [ + { + "name": "James Smythe", + "email": "james@vandelay.com" + }, + { + "name": null, + "email": "jane@example.com" + }, + { + "name": "John Smîth", + "email": "john@example.com" + } + ], + "subject": "Why not both importing AND exporting? ☺", + "sentAt": "2021-11-20T22:22:01Z", + "bodyStructure": { + "headers": [ + { + "name": "Date", + "value": " Sat, 20 Nov 2021 22:22:01 +0000" + }, + { + "name": "From", + "value": " \"Art Vandelay (Vandelay Industries)\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " =?utf-8?Q?Why_not_both_importing_AND_exporting=3F_=E2=98=BA?=" + }, + { + "name": "To", + "value": " \"Colleagues\": \"James Smythe\" ;, \"Friends\":\r\n , =?utf-8?Q?John_Sm=C3=AEth?= ;" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " multipart/mixed;\r\n boundary=\"boundary_0\"" + } + ], + "type": "multipart/mixed", + "subParts": [ + { + "headers": [ + { + "name": "Content-Type", + "value": " multipart/alternative;\r\n boundary=\"boundary_1\"" + } + ], + "type": "multipart/alternative", + "subParts": [ + { + "partId": "2", + "blobId": "blob_0", + "size": 131, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "language": [ + "en" + ] + }, + { + "partId": "3", + "blobId": "blob_1", + "size": 175, + "headers": [ + { + "name": "Content-Language", + "value": " en_US" + }, + { + "name": "Content-Type", + "value": " text/html" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/html", + "charset": "us-ascii", + "language": [ + "en_US" + ] + } + ] + }, + { + "partId": "4", + "blobId": "blob_2", + "size": 37, + "headers": [ + { + "name": "Content-ID", + "value": " " + }, + { + "name": "Content-Type", + "value": " image/png" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/png", + "cid": "cid:1234-5678-9012-3456" + }, + { + "partId": "5", + "blobId": "blob_3", + "size": 127, + "headers": [ + { + "name": "Content-Disposition", + "value": " attachment;\r\n filename*=UTF-8''Book%20about%20%E2%98%95%20tables.pdf" + }, + { + "name": "Content-Type", + "value": " x-document/pdf" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "name": "Book about ☕ tables.pdf", + "type": "x-document/pdf", + "disposition": "attachment" + } + ] + }, + "bodyValues": { + "2": { + "value": "I was thinking about quitting the “exporting” to focus just on the “importing”,\nbut then ...", + "isEncodingProblem": false, + "isTruncated": true + }, + "3": { + "value": "

I was thinking about quitting the “exporting” to focus just on the “im...", + "isEncodingProblem": false, + "isTruncated": true + } + }, + "textBody": [ + { + "partId": "2", + "blobId": "blob_0", + "size": 131, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "language": [ + "en" + ] + }, + { + "partId": "4", + "blobId": "blob_2", + "size": 37, + "headers": [ + { + "name": "Content-ID", + "value": " " + }, + { + "name": "Content-Type", + "value": " image/png" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/png", + "cid": "cid:1234-5678-9012-3456" + } + ], + "htmlBody": [ + { + "partId": "3", + "blobId": "blob_1", + "size": 175, + "headers": [ + { + "name": "Content-Language", + "value": " en_US" + }, + { + "name": "Content-Type", + "value": " text/html" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/html", + "charset": "us-ascii", + "language": [ + "en_US" + ] + }, + { + "partId": "4", + "blobId": "blob_2", + "size": 37, + "headers": [ + { + "name": "Content-ID", + "value": " " + }, + { + "name": "Content-Type", + "value": " image/png" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/png", + "cid": "cid:1234-5678-9012-3456" + } + ], + "attachments": [ + { + "partId": "4", + "blobId": "blob_2", + "size": 37, + "headers": [ + { + "name": "Content-ID", + "value": " " + }, + { + "name": "Content-Type", + "value": " image/png" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/png", + "cid": "cid:1234-5678-9012-3456" + }, + { + "partId": "5", + "blobId": "blob_3", + "size": 127, + "headers": [ + { + "name": "Content-Disposition", + "value": " attachment;\r\n filename*=UTF-8''Book%20about%20%E2%98%95%20tables.pdf" + }, + { + "name": "Content-Type", + "value": " x-document/pdf" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "name": "Book about ☕ tables.pdf", + "type": "x-document/pdf", + "disposition": "attachment" + } + ], + "hasAttachment": true, + "preview": "I was thinking about quitting the “exporting” to focus just on the “importing”,\nbut then I thought, why not do both? ☺\n" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/mixed.json b/tests/resources/jmap/email_set/mixed.json new file mode 100644 index 0000000..198fe0b --- /dev/null +++ b/tests/resources/jmap/email_set/mixed.json @@ -0,0 +1,70 @@ +{ + "keywords": { + "$seen": true, + "$draft": true, + "my-tag": true, + "ignore-me": false + }, + "from": [ + { + "name": "Art Vandelay (Vandelay Industries)", + "email": "art@vandelay.com" + } + ], + "header:To:asGroupedAddresses": [ + { + "name": "Colleagues", + "addresses": [ + { + "name": "James Smythe", + "email": "james@vandelay.com" + } + ] + }, + { + "name": "Friends", + "addresses": [ + { + "email": "jane@example.com" + }, + { + "name": "John Smîth", + "email": "john@example.com" + } + ] + } + ], + "subject": "Why not both importing AND exporting? ☺", + "receivedAt": "2021-11-20T14:22:01-08:00", + "sentAt": "2021-11-20T14:22:01-08:00", + "messageId": [ + "my-message-id" + ], + "textBody": [ + { + "type": "text/plain", + "blobId": "I was thinking about quitting the “exporting” to focus just on the “importing”,\nbut then I thought, why not do both? ☺\n", + "header:Content-Language": "en" + } + ], + "htmlBody": [ + { + "type": "text/html", + "blobId": "

I was thinking about quitting the “exporting” to focus just on the “importing”,

but then I thought, why not do both? ☺

", + "header:Content-Language": "en_US" + } + ], + "attachments": [ + { + "type": "image/png", + "blobId": "here are the embedded image contents!", + "cid": "cid:1234-5678-9012-3456" + }, + { + "type": "x-document/pdf", + "blobId": "ℌ𝔢𝔩𝔭 𝔪𝔢 𝔢𝔵𝔭𝔬𝔯𝔱 𝔪𝔶 𝔟𝔬𝔬𝔨 𝔭𝔩𝔢𝔞𝔰𝔢!", + "disposition": "attachment", + "name": "Book about ☕ tables.pdf" + } + ] +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/nested_body.eml b/tests/resources/jmap/email_set/nested_body.eml new file mode 100644 index 0000000..154e4f8 --- /dev/null +++ b/tests/resources/jmap/email_set/nested_body.eml @@ -0,0 +1,99 @@ +Date: Tue, 10 Jul 2018 01:03:11 +0000 +From: "Joe Bloggs" +Message-ID: +Subject: RFC 8621 Section 4.1.4 test +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary_0" + + +--boundary_0 +Content-Disposition: inline +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Part A +--boundary_0 +Content-Type: multipart/mixed; + boundary="boundary_1" + + +--boundary_1 +Content-Type: multipart/alternative; + boundary="boundary_2" + + +--boundary_2 +Content-Type: multipart/mixed; + boundary="boundary_3" + + +--boundary_3 +Content-Disposition: inline +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Part B +--boundary_3 +Content-Disposition: inline +Content-Type: image/jpeg +Content-Transfer-Encoding: base64 + +UGFydCBD + +--boundary_3 +Content-Disposition: inline +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Part D +--boundary_3-- + +--boundary_2 +Content-Type: multipart/related; + boundary="boundary_4" + + +--boundary_4 +Content-Type: text/html +Content-Transfer-Encoding: 7bit + +Part E +--boundary_4 +Content-Type: image/jpeg +Content-Transfer-Encoding: base64 + +UGFydCBG + +--boundary_4-- + +--boundary_2-- + +--boundary_1 +Content-Disposition: attachment +Content-Type: image/jpeg +Content-Transfer-Encoding: base64 + +UGFydCBH + +--boundary_1 +Content-Type: application/x-excel +Content-Transfer-Encoding: base64 + +UGFydCBI + +--boundary_1 +Content-Type: x-message/rfc822 +Content-Transfer-Encoding: base64 + +UGFydCBK + +--boundary_1-- + +--boundary_0 +Content-Disposition: inline +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Part K +--boundary_0-- diff --git a/tests/resources/jmap/email_set/nested_body.jmap b/tests/resources/jmap/email_set/nested_body.jmap new file mode 100644 index 0000000..cbeb212 --- /dev/null +++ b/tests/resources/jmap/email_set/nested_body.jmap @@ -0,0 +1,586 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": { + "$draft": true, + "$seen": true + }, + "receivedAt": "2018-07-10T01:03:11Z", + "messageId": [ + "my-message-id" + ], + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "subject": "RFC 8621 Section 4.1.4 test", + "sentAt": "2018-07-10T01:03:11Z", + "bodyStructure": { + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " RFC 8621 Section 4.1.4 test" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " multipart/mixed;\r\n boundary=\"boundary_0\"" + } + ], + "type": "multipart/mixed", + "subParts": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "headers": [ + { + "name": "Content-Type", + "value": " multipart/mixed;\r\n boundary=\"boundary_1\"" + } + ], + "type": "multipart/mixed", + "subParts": [ + { + "headers": [ + { + "name": "Content-Type", + "value": " multipart/alternative;\r\n boundary=\"boundary_2\"" + } + ], + "type": "multipart/alternative", + "subParts": [ + { + "headers": [ + { + "name": "Content-Type", + "value": " multipart/mixed;\r\n boundary=\"boundary_3\"" + } + ], + "type": "multipart/mixed", + "subParts": [ + { + "partId": "5", + "blobId": "blob_1", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "partId": "6", + "blobId": "blob_2", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg", + "disposition": "inline" + }, + { + "partId": "7", + "blobId": "blob_3", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + } + ] + }, + { + "headers": [ + { + "name": "Content-Type", + "value": " multipart/related;\r\n boundary=\"boundary_4\"" + } + ], + "type": "multipart/related", + "subParts": [ + { + "partId": "9", + "blobId": "blob_4", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " text/html" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/html", + "charset": "us-ascii" + }, + { + "partId": "10", + "blobId": "blob_5", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg" + } + ] + } + ] + }, + { + "partId": "11", + "blobId": "blob_6", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " attachment" + }, + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg", + "disposition": "attachment" + }, + { + "partId": "12", + "blobId": "blob_7", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " application/x-excel" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "application/x-excel" + }, + { + "partId": "13", + "blobId": "blob_8", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " x-message/rfc822" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "x-message/rfc822" + } + ] + }, + { + "partId": "14", + "blobId": "blob_9", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + } + ] + }, + "bodyValues": { + "1": { + "value": "Part A", + "isEncodingProblem": false, + "isTruncated": false + }, + "14": { + "value": "Part K", + "isEncodingProblem": false, + "isTruncated": false + }, + "5": { + "value": "Part B", + "isEncodingProblem": false, + "isTruncated": false + }, + "7": { + "value": "Part D", + "isEncodingProblem": false, + "isTruncated": false + }, + "9": { + "value": "Part E", + "isEncodingProblem": false, + "isTruncated": false + } + }, + "textBody": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "partId": "5", + "blobId": "blob_1", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "partId": "6", + "blobId": "blob_2", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg", + "disposition": "inline" + }, + { + "partId": "7", + "blobId": "blob_3", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "partId": "14", + "blobId": "blob_9", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + } + ], + "htmlBody": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + }, + { + "partId": "9", + "blobId": "blob_4", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " text/html" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/html", + "charset": "us-ascii" + }, + { + "partId": "14", + "blobId": "blob_9", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " text/plain" + }, + { + "name": "Content-Transfer-Encoding", + "value": " 7bit" + } + ], + "type": "text/plain", + "charset": "us-ascii", + "disposition": "inline" + } + ], + "attachments": [ + { + "partId": "6", + "blobId": "blob_2", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " inline" + }, + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg", + "disposition": "inline" + }, + { + "partId": "10", + "blobId": "blob_5", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg" + }, + { + "partId": "11", + "blobId": "blob_6", + "size": 6, + "headers": [ + { + "name": "Content-Disposition", + "value": " attachment" + }, + { + "name": "Content-Type", + "value": " image/jpeg" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "image/jpeg", + "disposition": "attachment" + }, + { + "partId": "12", + "blobId": "blob_7", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " application/x-excel" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "application/x-excel" + }, + { + "partId": "13", + "blobId": "blob_8", + "size": 6, + "headers": [ + { + "name": "Content-Type", + "value": " x-message/rfc822" + }, + { + "name": "Content-Transfer-Encoding", + "value": " base64" + } + ], + "type": "x-message/rfc822" + } + ], + "hasAttachment": true, + "preview": "Part A" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/nested_body.json b/tests/resources/jmap/email_set/nested_body.json new file mode 100644 index 0000000..5ddc98d --- /dev/null +++ b/tests/resources/jmap/email_set/nested_body.json @@ -0,0 +1,89 @@ +{ + "keywords": { + "$seen": true, + "$draft": true + }, + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "subject": "RFC 8621 Section 4.1.4 test", + "receivedAt": "2018-07-10T01:03:11Z", + "sentAt": "2018-07-10T11:03:11+10:00", + "messageId": [ + "my-message-id" + ], + "bodyStructure": { + "subParts": [ + { + "blobId": "Part A", + "disposition": "inline", + "type": "text/plain" + }, + { + "subParts": [ + { + "subParts": [ + { + "subParts": [ + { + "blobId": "Part B", + "disposition": "inline", + "type": "text/plain" + }, + { + "blobId": "Part C", + "disposition": "inline", + "type": "image/jpeg" + }, + { + "blobId": "Part D", + "disposition": "inline", + "type": "text/plain" + } + ], + "type": "multipart/mixed" + }, + { + "subParts": [ + { + "blobId": "Part E", + "type": "text/html" + }, + { + "blobId": "Part F", + "type": "image/jpeg" + } + ], + "type": "multipart/related" + } + ], + "type": "multipart/alternative" + }, + { + "blobId": "Part G", + "disposition": "attachment", + "type": "image/jpeg" + }, + { + "blobId": "Part H", + "type": "application/x-excel" + }, + { + "blobId": "Part J", + "type": "x-message/rfc822" + } + ], + "type": "multipart/mixed" + }, + { + "blobId": "Part K", + "disposition": "inline", + "type": "text/plain" + } + ], + "type": "multipart/mixed" + } +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/rfc8621_1.eml b/tests/resources/jmap/email_set/rfc8621_1.eml new file mode 100644 index 0000000..5e82164 --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_1.eml @@ -0,0 +1,11 @@ +Date: Tue, 10 Jul 2018 01:03:11 +0000 +From: "Joe Bloggs" +Message-ID: +Subject: World domination +MIME-Version: 1.0 +Content-Language: en +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +I have the most brilliant plan. Let me tell you all about it. What we do = +is, we \ No newline at end of file diff --git a/tests/resources/jmap/email_set/rfc8621_1.jmap b/tests/resources/jmap/email_set/rfc8621_1.jmap new file mode 100644 index 0000000..dab70a4 --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_1.jmap @@ -0,0 +1,167 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": { + "$draft": true, + "$seen": true + }, + "receivedAt": "2018-07-10T01:03:11Z", + "messageId": [ + "my-message-id" + ], + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "subject": "World domination", + "sentAt": "2018-07-10T01:03:11Z", + "bodyStructure": { + "partId": "0", + "blobId": "blob_0", + "size": 81, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " World domination" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "utf-8", + "language": [ + "en" + ] + }, + "bodyValues": { + "0": { + "value": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "isEncodingProblem": false, + "isTruncated": false + } + }, + "textBody": [ + { + "partId": "0", + "blobId": "blob_0", + "size": 81, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " World domination" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "utf-8", + "language": [ + "en" + ] + } + ], + "htmlBody": [ + { + "partId": "0", + "blobId": "blob_0", + "size": 81, + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:03:11 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " World domination" + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "utf-8", + "language": [ + "en" + ] + } + ], + "attachments": [], + "hasAttachment": false, + "preview": "I have the most brilliant plan. Let me tell you all about it. What we do is, we" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/rfc8621_1.json b/tests/resources/jmap/email_set/rfc8621_1.json new file mode 100644 index 0000000..6ae384c --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_1.json @@ -0,0 +1,29 @@ +{ + "keywords": { + "$seen": true, + "$draft": true + }, + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "subject": "World domination", + "receivedAt": "2018-07-10T01:03:11Z", + "sentAt": "2018-07-10T11:03:11+10:00", + "messageId": [ + "my-message-id" + ], + "bodyStructure": { + "type": "text/plain", + "partId": "bd48", + "header:Content-Language": "en" + }, + "bodyValues": { + "bd48": { + "value": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "isTruncated": false + } + } +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/rfc8621_2.eml b/tests/resources/jmap/email_set/rfc8621_2.eml new file mode 100644 index 0000000..19342c2 --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_2.eml @@ -0,0 +1,26 @@ +Date: Tue, 10 Jul 2018 01:05:08 +0000 +From: "Joe Bloggs" +Message-ID: +Subject: World domination +To: "John" +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary_0" + + +--boundary_0 +Content-Language: en +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +
I have the most brilliant plan.= + Let me tell you all about it. What we do is, we
+--boundary_0 +Content-Language: en +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +I have the most brilliant plan. Let me tell you all about it. What we do = +is, we +--boundary_0-- diff --git a/tests/resources/jmap/email_set/rfc8621_2.jmap b/tests/resources/jmap/email_set/rfc8621_2.jmap new file mode 100644 index 0000000..01a7287 --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_2.jmap @@ -0,0 +1,177 @@ +{ + "mailboxIds": { + "a": true + }, + "keywords": { + "$draft": true, + "$seen": true + }, + "receivedAt": "2018-07-10T01:05:08Z", + "messageId": [ + "my-message-id" + ], + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "to": [ + { + "name": "John", + "email": "john@example.com" + } + ], + "subject": "World domination", + "sentAt": "2018-07-10T01:05:08Z", + "bodyStructure": { + "headers": [ + { + "name": "Date", + "value": " Tue, 10 Jul 2018 01:05:08 +0000" + }, + { + "name": "From", + "value": " \"Joe Bloggs\" " + }, + { + "name": "Message-ID", + "value": " " + }, + { + "name": "Subject", + "value": " World domination" + }, + { + "name": "To", + "value": " \"John\" " + }, + { + "name": "MIME-Version", + "value": " 1.0" + }, + { + "name": "Content-Type", + "value": " multipart/alternative;\r\n boundary=\"boundary_0\"" + } + ], + "type": "multipart/alternative", + "subParts": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 218, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/html; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/html", + "charset": "utf-8", + "language": [ + "en" + ] + }, + { + "partId": "2", + "blobId": "blob_1", + "size": 81, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "utf-8", + "language": [ + "en" + ] + } + ] + }, + "bodyValues": { + "1": { + "value": "...", + "isEncodingProblem": false, + "isTruncated": true + }, + "2": { + "value": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "isEncodingProblem": false, + "isTruncated": false + } + }, + "textBody": [ + { + "partId": "2", + "blobId": "blob_1", + "size": 81, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/plain; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/plain", + "charset": "utf-8", + "language": [ + "en" + ] + } + ], + "htmlBody": [ + { + "partId": "1", + "blobId": "blob_0", + "size": 218, + "headers": [ + { + "name": "Content-Language", + "value": " en" + }, + { + "name": "Content-Type", + "value": " text/html; charset=\"utf-8\"" + }, + { + "name": "Content-Transfer-Encoding", + "value": " quoted-printable" + } + ], + "type": "text/html", + "charset": "utf-8", + "language": [ + "en" + ] + } + ], + "attachments": [], + "hasAttachment": false, + "preview": "I have the most brilliant plan. Let me tell you all about it. What we do is, we" +} \ No newline at end of file diff --git a/tests/resources/jmap/email_set/rfc8621_2.json b/tests/resources/jmap/email_set/rfc8621_2.json new file mode 100644 index 0000000..4171ed7 --- /dev/null +++ b/tests/resources/jmap/email_set/rfc8621_2.json @@ -0,0 +1,49 @@ +{ + "keywords": { + "$seen": true, + "$draft": true + }, + "from": [ + { + "name": "Joe Bloggs", + "email": "joe@example.com" + } + ], + "to": [ + { + "name": "John", + "email": "john@example.com" + } + ], + "messageId": [ + "my-message-id" + ], + "subject": "World domination", + "receivedAt": "2018-07-10T01:05:08Z", + "sentAt": "2018-07-10T11:05:08+10:00", + "bodyStructure": { + "type": "multipart/alternative", + "subParts": [ + { + "partId": "a49d", + "type": "text/html", + "header:Content-Language": "en" + }, + { + "partId": "bd48", + "type": "text/plain", + "header:Content-Language": "en" + } + ] + }, + "bodyValues": { + "bd48": { + "value": "I have the most brilliant plan. Let me tell you all about it. What we do is, we", + "isTruncated": false + }, + "a49d": { + "value": "
I have the most brilliant plan. Let me tell you all about it. What we do is, we
", + "isTruncated": false + } + } +} \ No newline at end of file diff --git a/tests/resources/jmap/email_snippet/html.eml b/tests/resources/jmap/email_snippet/html.eml new file mode 100644 index 0000000..00376e8 --- /dev/null +++ b/tests/resources/jmap/email_snippet/html.eml @@ -0,0 +1,36 @@ +Message-Id: <4.2.0.58.20000519003052.00a89c40@pop.example.com> +X-Sender: dwsauder@pop.example.com (Unverified) +X-Mailer: QUALCOMM Windows Eudora Pro Version 4.2.0.58 +X-Priority: 2 (High) +Date: Fri, 19 May 2000 00:31:00 -0400 +To: Heinz =?iso-8859-1?Q?M=FCller?= +From: Doug Sauder +Subject: =?iso-8859-1?Q?Die_Hasen_und_die_Fr=F6sche?= +Mime-Version: 1.0 +Content-Language: de +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + +Die Hasen und = +die +Fr=F6sche
+
+
Die Hasen klagten einst =FCber +ihre mi=DFliche Lage; "wir leben", sprach ein Redner, "in +steter Furcht vor Menschen und Tieren, eine Beute der Hunde, der Adler, +ja fast aller Raubtiere! Unsere stete Angst ist =E4rger als der Tod selbst. +Auf, la=DFt uns ein f=FCr allemal sterben."
+
+In einem nahen Teich wollten sie sich nun ers=E4ufen; sie eilten ihm zu; +allein das au=DFerordentliche Get=F6se und ihre wunderbare Gestalt +erschreckte eine Menge Fr=F6sche, die am Ufer sa=DFen, so sehr, da=DF sie au= +fs +schnellste untertauchten.
+
+"Halt", rief nun eben dieser Sprecher, "wir wollen das +Ers=E4ufen noch ein wenig aufschieben, denn auch uns f=FCrchten, wie ihr +seht, einige Tiere, welche also wohl noch ungl=FCcklicher sein m=FCssen als +wir."
+
+
diff --git a/tests/resources/jmap/email_snippet/mixed.eml b/tests/resources/jmap/email_snippet/mixed.eml new file mode 100644 index 0000000..220b79b --- /dev/null +++ b/tests/resources/jmap/email_snippet/mixed.eml @@ -0,0 +1,77 @@ +MIME-Version: 1.0 +Date: Sat, 13 Aug 2021 15:51:01 +0200 +Message-ID: +Subject: Biblioteca de Babel +From: Jorge Luis Borges +To: Julio Cortázar +Content-Language: es +Content-Type: multipart/alternative; boundary="0000000000006adf7205e61fb0a1" + +--0000000000006adf7205e61fb0a1 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +El universo (que otros llaman la *Biblioteca*) se compone de un n=C3=BAmero +indefinido, y +tal vez infinito, de galer=C3=ADas hexagonales, con vastos pozos de ventila= +ci=C3=B3n +en el medio, +cercados por barandas baj=C3=ADsimas. Desde cualquier hex=C3=A1gono se ven = +los pisos +inferiores y +superiores: interminablemente. La distribuci=C3=B3n de las galer=C3=ADas es +invariable. Veinte +anaqueles, a cinco largos anaqueles por lado, cubren todos los lados menos +dos; su altura, +que es la de los pisos, excede apenas la de un bibliotecario normal. Una de +las caras libres +da a un angosto zagu=C3=A1n, que desemboca en otra galer=C3=ADa, id=C3=A9nt= +ica a la +primera y a todas. A +izquierda y a derecha del zagu=C3=A1n hay dos gabinetes min=C3=BAsculos. Un= +o permite +dormir de +pie; otro, satisfacer las necesidades finales. Por ah=C3=AD pasa la escaler= +a +espiral, que se abisma +y se eleva hacia lo remoto. En el zagu=C3=A1n hay un espejo, que fielmente +duplica las +apariencias. Los hombres suelen inferir de ese espejo que la *Biblioteca* +no es infinita (si +lo fuera realmente =C2=BFa qu=C3=A9 esa duplicaci=C3=B3n ilusoria?); yo pre= +fiero so=C3=B1ar que +las superficies +bru=C3=B1idas figuran y prometen el infinito... La luz procede de unas frut= +as +esf=C3=A9ricas que +llevan el nombre de l=C3=A1mparas. Hay dos en cada hex=C3=A1gono: transvers= +ales. La +luz que +emiten es insuficiente, incesante. + +--0000000000006adf7205e61fb0a1 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +
El universo (que otros llaman la Biblioteca) se com= +pone de un n=C3=BAmero indefinido, y
tal vez infinito, de galer=C3=ADas = +hexagonales, con vastos pozos de ventilaci=C3=B3n en el medio,
cercados = +por barandas baj=C3=ADsimas. Desde cualquier hex=C3=A1gono se ven los pisos= + inferiores y
superiores: interminablemente. La distribuci=C3=B3n de las= + galer=C3=ADas es invariable. Veinte
anaqueles, a cinco largos anaqueles= + por lado, cubren todos los lados menos dos; su altura,
que es la de los= + pisos, excede apenas la de un bibliotecario normal. Una de las caras libre= +s
da a un angosto zagu=C3=A1n, que desemboca en otra galer=C3=ADa, id=C3= +=A9ntica a la primera y a todas. A
izquierda y a derecha del zagu=C3=A1n= + hay dos gabinetes min=C3=BAsculos. Uno permite dormir de
pie; otro, sat= +isfacer las necesidades finales. Por ah=C3=AD pasa la escalera espiral, que= + se abisma
y se eleva hacia lo remoto. En el zagu=C3=A1n hay un espejo, = +que fielmente duplica las
apariencias. Los hombres suelen inferir de ese= + espejo que la Biblioteca no es infinita (si
lo fuera realmente = +=C2=BFa qu=C3=A9 esa duplicaci=C3=B3n ilusoria?); yo prefiero so=C3=B1ar qu= +e las superficies
bru=C3=B1idas figuran y prometen el infinito... La luz= + procede de unas frutas esf=C3=A9ricas que
llevan el nombre de l=C3=A1mp= +aras. Hay dos en cada hex=C3=A1gono: transversales. La luz que
emit= +en es insuficiente, incesante.

+ +--0000000000006adf7205e61fb0a1-- diff --git a/tests/resources/jmap/email_snippet/subpart.eml b/tests/resources/jmap/email_snippet/subpart.eml new file mode 100644 index 0000000..358f18e --- /dev/null +++ b/tests/resources/jmap/email_snippet/subpart.eml @@ -0,0 +1,73 @@ +From: Al Gore +To: White House Transportation Coordinator + +Subject: [Fwd: Map of Argentina with Description] +Content-Language: en +Content-Type: multipart/mixed; + boundary="D7F------------D7FD5A0B8AB9C65CCDBFA872" + +This is a multi-part message in MIME format. +--D7F------------D7FD5A0B8AB9C65CCDBFA872 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +Fred, + +Fire up Air Force One! We're going South! + +Thanks, +Al +--D7F------------D7FD5A0B8AB9C65CCDBFA872 +Content-Type: message/rfc822 +Content-Transfer-Encoding: 7bit +Content-Disposition: inline + +Return-Path: +Received: from mailhost.whitehouse.gov ([192.168.51.200]) + by heartbeat.whitehouse.gov (8.8.8/8.8.8) with ESMTP id SAA22453 + for ; + Mon, 13 Aug 1998 l8:14:23 +1000 +Received: from the_big_box.whitehouse.gov ([192.168.51.50]) + by mailhost.whitehouse.gov (8.8.8/8.8.7) with ESMTP id RAA20366 + for vice-president@whitehouse.gov; Mon, 13 Aug 1998 17:42:41 +1000 +Date: Mon, 13 Aug 1998 17:42:41 +1000 +Message-Id: <199804130742.RAA20366@mai1host.whitehouse.gov> +From: Bill Clinton +To: A1 (The Enforcer) Gore +Subject: Map of Argentina with Description +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="DC8------------DC8638F443D87A7F0726DEF7" + +This is a multi-part message in MIME format. +--DC8------------DC8638F443D87A7F0726DEF7 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +Hi A1, + +I finally figured out this MIME thing. Pretty cool. I'll send you +some sax music in .au files next week! + +Anyway, the attached image is really too small to get a good look at +Argentina. Try this for a much better map: + + http://www.1one1yp1anet.com/dest/sam/graphics/map-arg.htm + +Then again, shouldn't the CIA have something like that? + +Bill +--DC8------------DC8638F443D87A7F0726DEF7 +Content-Type: image/gif; name="map_of_Argentina.gif" +Content-Transfer-Encoding: base64 +Content-Disposition: inline; fi1ename="map_of_Argentina.gif" + +R01GOD1hJQA1AKIAAP/////78P/omn19fQAAAAAAAAAAAAAAACwAAAAAJQA1AAAD7Qi63P5w +wEmjBCLrnQnhYCgM1wh+pkgqqeC9XrutmBm7hAK3tP31gFcAiFKVQrGFR6kscnonTe7FAAad +GugmRu3CmiBt57fsVq3Y0VFKnpYdxPC6M7Ze4crnnHum4oN6LFJ1bn5NXTN7OF5fQkN5WYow +BEN2dkGQGWJtSzqGTICJgnQuTJN/WJsojad9qXMuhIWdjXKjY4tenjo6tjVssk2gaWq3uGNX +U6ZGxseyk8SasGw3J9GRzdTQky1iHNvcPNNI4TLeKdfMvy0vMqLrItvuxfDW8ubjueDtJufz +7itICBxISKDBgwgTKjyYAAA7 +--DC8------------DC8638F443D87A7F0726DEF7-- + +--D7F------------D7FD5A0B8AB9C65CCDBFA872-- diff --git a/tests/resources/jmap/email_snippet/text_plain.eml b/tests/resources/jmap/email_snippet/text_plain.eml new file mode 100644 index 0000000..29b4886 --- /dev/null +++ b/tests/resources/jmap/email_snippet/text_plain.eml @@ -0,0 +1,20 @@ +From: Abidjan Prince +To: Bill Foobar +Content-Language: en +Subject: Help a friend from Abidjan Côte d'Ivoire + +When my mother died when she was given birth to me, my father took me so +special because I am motherless. Before the death of my late father on 22nd June +2013 in a private hospital here in Abidjan Côte d'Ivoire. He secretly called me on his +bedside and told me that he has a sum of $7.5M (Seven Million five Hundred +Thousand Dollars) left in a suspense account in a local bank here in Abidjan Côte +d'Ivoire, that he used my name as his only daughter for the next of kin in deposit of +the fund. +I am 24year old. Dear I am honorably seeking your assistance in the following ways. +1) To provide any bank account where this money would be transferred into. +2) To serve as the guardian of this fund. +3) To make arrangement for me to come over to your country to further my +education and to secure a residential permit for me in your country. +Moreover, I am willing to offer you 30 percent of the total sum as compensation for +your effort input after the successful transfer of this fund to your nominated +account overseas. diff --git a/tests/resources/jmap/email_snippet/text_plain_chinese.eml b/tests/resources/jmap/email_snippet/text_plain_chinese.eml new file mode 100644 index 0000000..357719d --- /dev/null +++ b/tests/resources/jmap/email_snippet/text_plain_chinese.eml @@ -0,0 +1,18 @@ +From: "孫子" +To: "Bill Foobar" +Content-Language: zh +Subject: 孫子兵法 + +<"孫子兵法:"> +孫子曰:兵者,國之大事,死生之地,存亡之道,不可不察也。 +孫子曰:凡用兵之法,馳車千駟,革車千乘,帶甲十萬;千里饋糧,則內外之費賓客之用,膠漆之材, +車甲之奉,日費千金,然後十萬之師舉矣。 +孫子曰:凡用兵之法,全國為上,破國次之;全旅為上,破旅次之;全卒為上,破卒次之;全伍為上,破伍次之。 +是故百戰百勝,非善之善者也;不戰而屈人之兵,善之善者也。 +孫子曰:昔之善戰者,先為不可勝,以待敵之可勝,不可勝在己,可勝在敵。故善戰者,能為不可勝,不能使敵必可勝。 +故曰:勝可知,而不可為。 +兵者,詭道也。故能而示之不能,用而示之不用,近而示之遠,遠而示之近。利而誘之,亂而取之,實而備之,強而避之, +怒而撓之,卑而驕之,佚而勞之,親而離之。攻其無備,出其不意,此兵家之勝,不可先傳也。 +夫未戰而廟算勝者,得算多也;未戰而廟算不勝者,得算少也;多算勝,少算不勝,而況於無算乎?吾以此觀之,勝負見矣。 +孫子曰:凡治眾如治寡,分數是也。鬥眾如鬥寡,形名是也。三軍之眾,可使必受敵而無敗者,奇正是也。兵之所加, +如以碬投卵者,虛實是也。 diff --git a/tests/resources/jmap/sieve/test_discard_reject.sieve b/tests/resources/jmap/sieve/test_discard_reject.sieve new file mode 100644 index 0000000..bc3cb21 --- /dev/null +++ b/tests/resources/jmap/sieve/test_discard_reject.sieve @@ -0,0 +1,41 @@ +require ["duplicate", "ihave", "reject", "body"]; + +if body :contains "TPS" { + if duplicate :handle "one_sec_expire" :seconds 1 { + error "one_sec_expire handle should not be duplicate."; + } + + if duplicate :uniqueid "one_sec_expire" :seconds 1 { + error "one_sec_expire uniqueid should not be duplicate."; + } + + if duplicate :handle "five_secs_expire" :seconds 5 { + error "five_secs_expire handle should not be duplicate."; + } + + if duplicate :uniqueid "five_secs_expire" :seconds 5 { + error "five_secs_expire uniqueid should not be duplicate."; + } + + discard; +} elsif body :contains "T.P.S." { + if duplicate :handle "one_sec_expire" :seconds 1 { + error "one_sec_expire handle should have expired."; + } + + if duplicate :uniqueid "one_sec_expire" :seconds 1 { + error "one_sec_expire uniqueid should have expired."; + } + + if not duplicate :handle "five_secs_expire" :seconds 5 { + error "five_secs_expire handle should be duplicate."; + } + + if not duplicate :uniqueid "five_secs_expire" :seconds 5 { + error "five_secs_expire uniqueid should be duplicate."; + } + + reject "No soup for you, next!"; +} else { + error "Unexpected body contents."; +} diff --git a/tests/resources/jmap/sieve/test_include.sieve b/tests/resources/jmap/sieve/test_include.sieve new file mode 100644 index 0000000..c92dbe2 --- /dev/null +++ b/tests/resources/jmap/sieve/test_include.sieve @@ -0,0 +1,5 @@ +require ["include", "ihave"]; + +include "test_include_this"; + +error "'stop' within included script ignored or include failed."; diff --git a/tests/resources/jmap/sieve/test_include_case.sieve b/tests/resources/jmap/sieve/test_include_case.sieve new file mode 100644 index 0000000..27c10c0 --- /dev/null +++ b/tests/resources/jmap/sieve/test_include_case.sieve @@ -0,0 +1,5 @@ +require ["include", "ihave"]; + +include :personal "Test Script"; + +error "'stop' within included script ignored or mixed-case include failed."; diff --git a/tests/resources/jmap/sieve/test_include_global.sieve b/tests/resources/jmap/sieve/test_include_global.sieve new file mode 100644 index 0000000..7dafbc1 --- /dev/null +++ b/tests/resources/jmap/sieve/test_include_global.sieve @@ -0,0 +1,5 @@ +require ["include", "ihave"]; + +include :global "common"; + +error "'stop' within included script ignored or global include failed."; diff --git a/tests/resources/jmap/sieve/test_include_this.sieve b/tests/resources/jmap/sieve/test_include_this.sieve new file mode 100644 index 0000000..d0d73c7 --- /dev/null +++ b/tests/resources/jmap/sieve/test_include_this.sieve @@ -0,0 +1,4 @@ +require "reject"; + +reject "Rejected from an included script."; +stop; diff --git a/tests/resources/jmap/sieve/test_mailbox.sieve b/tests/resources/jmap/sieve/test_mailbox.sieve new file mode 100644 index 0000000..c3e38a5 --- /dev/null +++ b/tests/resources/jmap/sieve/test_mailbox.sieve @@ -0,0 +1,81 @@ +require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.stalwart.expressions"]; + +# SpecialUse extension tests +if not specialuse_exists ["inbox", "trash"] { + error "Special-use mailboxes INBOX or TRASH do not exist (lowercase)."; +} + +if not anyof(specialuse_exists "Inbox" "inbox", + specialuse_exists "Deleted Items" "trash") { + error "Special-use mailboxes INBOX or TRASH do not exist (mixed-case)."; +} + +if specialuse_exists "dingleberry" { + error "An invalid special-use exists."; +} + +if specialuse_exists "archive" { + error "A non-existent special-use exists."; +} + +if not specialuse_exists ["\\Inbox", "\\Trash"] { + error "Special-use mailboxes INBOX or TRASH do not exist (RFC 8579 attribute form)."; +} + +if not specialuse_exists "Deleted Items" "\\Trash" { + error "Deleted Items has no Trash special-use (RFC 8579 attribute form)."; +} + +if specialuse_exists "\\Archive" { + error "A non-existent special-use exists (RFC 8579 attribute form)."; +} + +# MailboxId tests +if not mailboxidexists "a" { + error "Inbox not found by mailboxid."; +} + +if not mailboxidexists ["a", "b"] { + error "Inbox and Trash mailboxes not found by mailboxid."; +} + +# MailboxExists tests +if not mailboxexists "Inbox" { + error "Inbox not found by name."; +} + +if not mailboxexists ["Drafts", "Sent Items"] { + error "Drafts and Sent Items not found by name."; +} + +# File into a special-use mailbox using the RFC 8579 attribute form +fileinto :specialuse "\\Drafts" "specialuse-fallback"; + +# File into new mailboxes using flags +fileinto :create "INBOX / Folder "; +fileinto :flags ["$important", "\\Seen"] :create "My/Nested/Mailbox/with/multiple/levels"; + +# Make sure all mailboxes were created +if not mailboxexists "Inbox/Folder" { + error "'Inbox/Folder' not found."; +} + +if not mailboxexists "My/Nested/Mailbox/with/multiple/levels" { + error "'My/Nested/Mailbox/with/multiple/levels' not found."; +} + +if not mailboxexists "My/Nested/Mailbox/with/multiple" { + error "'My/Nested/Mailbox/with/multiple' not found."; +} + +if not mailboxexists "My/Nested" { + error "'My/Nested' not found."; +} + +if not mailboxexists "My" { + error "'My' not found."; +} + +if eval "llm_prompt('echo-test', 'hello world', 0.5) != 'hello world'" { + error "llm_prompt is unavailable."; +} diff --git a/tests/resources/jmap/sieve/test_notify_fcc.sieve b/tests/resources/jmap/sieve/test_notify_fcc.sieve new file mode 100644 index 0000000..5285fe3 --- /dev/null +++ b/tests/resources/jmap/sieve/test_notify_fcc.sieve @@ -0,0 +1,13 @@ +require ["enotify", "fcc", "mailbox", "editheader", "imap4flags"]; + +if header :matches "Subject" "*TPS*" { + notify :message "It's time to file your TPS report." + :fcc "Notifications" :create + "mailto:sms_gateway@remote.org?subject=It's%20TPS-o-clock"; + + deleteheader "Subject"; + addheader "Subject" "${1}**censored**${2}"; + setflag "$seen"; +} + +keep; diff --git a/tests/resources/jmap/sieve/test_redirect_enclose.sieve b/tests/resources/jmap/sieve/test_redirect_enclose.sieve new file mode 100644 index 0000000..c9c9e6d --- /dev/null +++ b/tests/resources/jmap/sieve/test_redirect_enclose.sieve @@ -0,0 +1,5 @@ +require ["enclose"]; + +enclose :subject "Check this out" "Attached you'll find a message I just received."; +redirect "jane@remote.org"; +discard; diff --git a/tests/resources/jmap/sieve/validate_error.sieve b/tests/resources/jmap/sieve/validate_error.sieve new file mode 100644 index 0000000..0d6507c --- /dev/null +++ b/tests/resources/jmap/sieve/validate_error.sieve @@ -0,0 +1 @@ +keep :invalidtag; diff --git a/tests/resources/jmap/sieve/validate_ok.sieve b/tests/resources/jmap/sieve/validate_ok.sieve new file mode 100644 index 0000000..e458933 --- /dev/null +++ b/tests/resources/jmap/sieve/validate_ok.sieve @@ -0,0 +1,3 @@ +if true { + keep; +} diff --git a/tests/resources/ldap/ldap.cfg b/tests/resources/ldap/ldap.cfg new file mode 100644 index 0000000..753eb9e --- /dev/null +++ b/tests/resources/ldap/ldap.cfg @@ -0,0 +1,126 @@ +################# +# LDAP test config + +################# +# General configuration. +debug = true +watchconfig = true + +################# +# Server configuration. +[ldap] + enabled = true + # run on a non privileged port + listen = "0.0.0.0:3893" + +[ldaps] +# to enable ldaps genrerate a certificate, eg. with: +# openssl req -x509 -newkey rsa:4096 -keyout example.key -out example.crt -days 365 -nodes -subj '/CN=`hostname`' + enabled = false + listen = "0.0.0.0:3894" + cert = "example.crt" + key = "example.key" + +################# +# The backend section controls the data store. +[backend] + datastore = "config" + baseDN = "dc=example,dc=org" + nameformat = "cn" + groupformat = "ou" + +[behaviors] + # Ignore all capabilities restrictions, for instance allowing every user to perform a search + IgnoreCapabilities = false + # Enable a "fail2ban" type backoff mechanism temporarily banning repeated failed login attempts + LimitFailedBinds = true + # How many failed login attempts are allowed before a ban is imposed + NumberOfFailedBinds = 3 + # How long (in seconds) is the window for failed login attempts + PeriodOfFailedBinds = 10 + # How long (in seconds) is the ban duration + BlockFailedBindsFor = 60 + # Clean learnt IP addresses every N seconds + PruneSourceTableEvery = 600 + # Clean learnt IP addresses not seen in N seconds + PruneSourcesOlderThan = 600 + +################# +# The users section contains a hardcoded list of valid users. +[[users]] + name = "john" + givenname = "john.doe@example.org" + sn = "info@example.org" + uidnumber = 2 + primarygroup = 5 + mail = "john@example.org" + [[users.customattributes]] + principalName = ["John Doe"] + userPassword = ["12345"] + +[[users]] + name = "jane" + sn = "info@example.org" + mail = "jane@example.org" + uidnumber = 3 + primarygroup = 5 + [[users.customattributes]] + otherGroups = ["support"] + principalName = ["Jane Doe"] + userPassword = ["abcde"] + +[[users]] + name = "bill" + sn = "info@example.org" + mail = "bill@example.org" + uidnumber = 4 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + [[users.customattributes]] + principalName = ["Bill Foobar"] + diskQuota = [500000] + userPassword = ["$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"] + +[[users]] + name = "robert" + sn = "@catchall.org" + mail = "robert@catchall.org" + uidnumber = 7 + [[users.customattributes]] + principalName = ["Robect Foobar"] + userPassword = ["nopass"] + +[[users]] + name = "serviceuser" + mail = "serviceuser@example.org" + uidnumber = 5003 + primarygroup = 5502 + passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret + [[users.capabilities]] + action = "search" + object = "*" + + +################# +# The groups section contains a hardcoded list of valid users. +[[groups]] + name = "sales" + gidnumber = 5 + +[[groups]] + name = "support" + gidnumber = 6 + +[[groups]] + name = "svcaccts" + gidnumber = 5502 + + +################# +# Enable and configure the optional REST API here. +[api] + enabled = false + internals = true # debug application performance + tls = false # enable TLS for production!! + listen = "0.0.0.0:5555" + cert = "cert.pem" + key = "key.pem" diff --git a/tests/resources/ldap/run_glauth.sh b/tests/resources/ldap/run_glauth.sh new file mode 100644 index 0000000..a802fbb --- /dev/null +++ b/tests/resources/ldap/run_glauth.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +~/utils/glauth/glauth-darwin-arm64 -c tests/resources/ldap/ldap.cfg diff --git a/tests/resources/mailbox.gz b/tests/resources/mailbox.gz new file mode 100644 index 0000000..0a07e9a Binary files /dev/null and b/tests/resources/mailbox.gz differ diff --git a/tests/resources/otel/docker-compose.yaml b/tests/resources/otel/docker-compose.yaml new file mode 100644 index 0000000..edeeb41 --- /dev/null +++ b/tests/resources/otel/docker-compose.yaml @@ -0,0 +1,45 @@ +# docker compose up -d + +version: "2" +services: + + # Jaeger + jaeger-all-in-one: + image: jaegertracing/all-in-one:latest + restart: always + network_mode: host + ports: + - "16686:16686" + - "14268" + - "14250" + + # Zipkin + zipkin-all-in-one: + image: openzipkin/zipkin:latest + restart: always + network_mode: host + ports: + - "9411:9411" + + # Collector + otel-collector: + image: otel/opentelemetry-collector:latest + restart: always + network_mode: host + command: + [ + "--config=/etc/otel-collector-config.yaml", + "${OTELCOL_ARGS}" + ] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "1888:1888" # pprof extension + - "8888:8888" # Prometheus metrics exposed by the collector + - "8889:8889" # Prometheus exporter metrics + - "13133:13133" # health_check extension + - "4317:4317" # OTLP gRPC receiver + - "55679:55679" # zpages extension + depends_on: + - jaeger-all-in-one + - zipkin-all-in-one diff --git a/tests/resources/otel/otel-collector-config.yaml b/tests/resources/otel/otel-collector-config.yaml new file mode 100644 index 0000000..4c320c2 --- /dev/null +++ b/tests/resources/otel/otel-collector-config.yaml @@ -0,0 +1,44 @@ +# docker run -p 4317:4317 --network host --rm -v $(pwd)/tests/resources/otel/otel-collector-config.yaml:/etc/otelcol/config.yaml otel/opentelemetry-collector + +receivers: + otlp: + protocols: + grpc: + +exporters: + zipkin: + endpoint: "http://zipkin-all-in-one:9411/api/v2/spans" + format: proto + + otlp: + endpoint: jaeger-all-in-one:4317 + tls: + insecure: true + debug: + verbosity: detailed + +processors: + batch: + +extensions: + health_check: + pprof: + endpoint: :1888 + zpages: + endpoint: :55679 + +service: + extensions: [pprof, zpages, health_check] + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [zipkin, otlp] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug] diff --git a/tests/resources/otel/stalwart-config.toml b/tests/resources/otel/stalwart-config.toml new file mode 100644 index 0000000..85e8145 --- /dev/null +++ b/tests/resources/otel/stalwart-config.toml @@ -0,0 +1,7 @@ +tracer.otel.type = "otel" +tracer.otel.transport = "grpc" +tracer.otel.endpoint = "http://127.0.0.1:4317" +tracer.otel.level = "trace" +metrics.open-telemetry.interval = "10s" +metrics.open-telemetry.endpoint = "http://127.0.0.1:4317" +metrics.open-telemetry.transport = "grpc" diff --git a/tests/resources/proxy-protocol/Docker.haproxy b/tests/resources/proxy-protocol/Docker.haproxy new file mode 100644 index 0000000..b2a1cc8 --- /dev/null +++ b/tests/resources/proxy-protocol/Docker.haproxy @@ -0,0 +1,6 @@ +# docker build -t test-haproxy -f Docker.haproxy . +# docker run -it --rm --name haproxy-syntax-check test-haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg +# docker run -d -p 1111:1111 --name some-haproxy --sysctl net.ipv4.ip_unprivileged_port_start=0 test-haproxy + +FROM haproxy:2.3 +COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg diff --git a/tests/resources/proxy-protocol/haproxy.cfg b/tests/resources/proxy-protocol/haproxy.cfg new file mode 100644 index 0000000..54edec3 --- /dev/null +++ b/tests/resources/proxy-protocol/haproxy.cfg @@ -0,0 +1,18 @@ +global + log stdout format raw local0 + +defaults + log global + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms + +frontend tcp_in + bind *:1111 + mode tcp + option tcplog + default_backend tcp_out + +backend tcp_out + mode tcp + server docker_server host.docker.internal:143 send-proxy diff --git a/tests/resources/scripts/imap_import.py b/tests/resources/scripts/imap_import.py new file mode 100644 index 0000000..3798ec4 --- /dev/null +++ b/tests/resources/scripts/imap_import.py @@ -0,0 +1,55 @@ +import imaplib +import socket +import time +import threading +from email.message import Message +from email.utils import formatdate +from datetime import datetime, timedelta + +def append_message(thread_id, start, end): + conn = imaplib.IMAP4('localhost') + conn.login('john', '12345') + conn.socket().setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + start_time = time.time() + + base_date = datetime(2000, 1, 1) + + for n in range(start, end): + current_date = base_date + timedelta(hours=n) + + msg = Message() + msg['From'] = 'somebody@some.where' + msg['To'] = 'john@example.org' + msg['Message-Id'] = f'unique.message.id.{n}@nowhere' + msg['Date'] = formatdate(time.mktime(current_date.timetuple()), localtime=False, usegmt=True) + msg['Subject'] = f"This is message #{n}" + msg.set_payload('...nothing...') + + response_code, response_details = conn.append('INBOX', '', imaplib.Time2Internaldate(time.mktime(current_date.timetuple())), str(msg).encode('utf-8')) + if response_code != 'OK': + print(f'Thread {thread_id}: Error while appending message #{n}: {response_code} {response_details}') + break + if n != 0 and n % 100 == 0: + elapsed_time = (time.time() - start_time) * 1000 + print(f'Thread {thread_id}: Inserting batch {n} took {elapsed_time} ms.', flush=True) + start_time = time.time() + + conn.logout() + +num_threads = 5 +num_messages = 10000 +messages_per_thread = num_messages // num_threads + +threads = [] +for i in range(num_threads): + start = i * messages_per_thread + end = start + messages_per_thread + thread = threading.Thread(target=append_message, args=(i, start, end)) + threads.append(thread) + thread.start() + +for thread in threads: + thread.join() + +print("All messages appended.") + diff --git a/tests/resources/scripts/imap_import_single.py b/tests/resources/scripts/imap_import_single.py new file mode 100644 index 0000000..e45c598 --- /dev/null +++ b/tests/resources/scripts/imap_import_single.py @@ -0,0 +1,27 @@ +import imaplib +import socket +import time +from email.message import Message +from email.utils import formatdate +from datetime import datetime, timedelta + +conn = imaplib.IMAP4('localhost') +conn.login('john', '12345') +conn.socket().setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) +current_date = datetime.now() +timestamp = current_date.timestamp() + +msg = Message() +msg['From'] = 'somebody@some.where' +msg['To'] = 'john@example.org' +msg['Message-Id'] = f'unique.message.id.{current_date}@nowhere' +msg['Date'] = formatdate(time.mktime(current_date.timetuple()), localtime=False, usegmt=True) +msg['Subject'] = f"This is message #{timestamp}" +msg.set_payload('...nothing...') + +response_code, response_details = conn.append('INBOX', '', imaplib.Time2Internaldate(time.mktime(current_date.timetuple())), str(msg).encode('utf-8')) +if response_code != 'OK': + print(f'Error while appending message: {response_code} {response_details}') + +print("Message appended.") +conn.logout() diff --git a/tests/resources/scripts/smtp_stress_test.py b/tests/resources/scripts/smtp_stress_test.py new file mode 100644 index 0000000..b03ee19 --- /dev/null +++ b/tests/resources/scripts/smtp_stress_test.py @@ -0,0 +1,627 @@ +import argparse +import math +import multiprocessing +import os +import queue +import random +import re +import shutil +import smtplib +import ssl +import sys +import tempfile +import threading +import time +from email.utils import formatdate, make_msgid + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 25 +DEFAULT_THREADS = 5 +DEFAULT_PROCESSES = 1 +DEFAULT_MESSAGES = 100 +DEFAULT_MIN_SIZE = 1024 +DEFAULT_MAX_SIZE = 51200 +DEFAULT_POOL_SIZE = 64 +DEFAULT_SENDER = "stress-test@example.com" +DEFAULT_USERS_FILE = "users.txt" +DEFAULT_DICTIONARY = "/usr/share/dict/words" +DEFAULT_TIMEOUT = 60 +LINE_WIDTH = 72 + +FALLBACK_WORDS = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam " + "quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo" +).split() + +WORDS = FALLBACK_WORDS + +DOT_LINE = re.compile(br"(?m)^\.") +STOP_EVENT = threading.Event() + + +class SmtpError(Exception): + pass + + +class AsyncLogger: + def __init__(self, enabled): + self.enabled = enabled + self._queue = queue.Queue() if enabled else None + self._thread = None + + def start(self): + if not self.enabled: + return + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def log(self, message): + if self.enabled: + self._queue.put(message) + + def _run(self): + while True: + message = self._queue.get() + if message is None: + break + print(message, file=sys.stderr, flush=True) + + def stop(self): + if not self.enabled: + return + self._queue.put(None) + if self._thread is not None: + self._thread.join() + + +def load_words(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as file: + words = [w.strip() for w in file if w.strip().isalpha()] + except OSError: + words = [] + if not words: + print( + f"WARNING: word list '{path}' not found or empty; " + f"falling back to built-in lorem ipsum words. " + f"Override with --dict .", + file=sys.stderr, + ) + return list(FALLBACK_WORDS) + return words + + +def random_subject(): + return " ".join(random.choices(WORDS, k=random.randint(3, 10))) + + +def random_body(target_size): + lines = [] + total = 0 + line = "" + while total < target_size: + word = random.choice(WORDS) + if line and len(line) + 1 + len(word) > LINE_WIDTH: + lines.append(line) + total += len(line) + 2 + line = word + elif line: + line = f"{line} {word}" + else: + line = word + if line: + lines.append(line) + return "\r\n".join(lines) + "\r\n" + + +def quote_periods(data): + return DOT_LINE.sub(b"..", data) + + +def build_headers(sender, recipient): + return ( + f"From: {sender}\r\n" + f"To: {recipient}\r\n" + f"Subject: {random_subject()}\r\n" + f"Date: {formatdate(localtime=True)}\r\n" + f"Message-ID: {make_msgid(domain='stress.test')}\r\n" + f"MIME-Version: 1.0\r\n" + f"Content-Type: text/plain; charset=us-ascii\r\n" + f"\r\n" + ).encode("ascii", "replace") + + +def build_body(size): + body = quote_periods(random_body(size).encode("ascii", "replace")) + if not body.endswith(b"\r\n"): + body += b"\r\n" + return body + + +class MemoryStore: + backend = "memory" + + def __init__(self): + self._items = [] + + def add(self, data): + self._items.append(data) + + def get(self, index): + return self._items[index] + + def __len__(self): + return len(self._items) + + def cleanup(self): + self._items = [] + + +class DiskStore: + def __init__(self, root): + self._dir = tempfile.mkdtemp(prefix="smtp_stress_", dir=root) + self.backend = self._dir + self._paths = [] + + def add(self, data): + path = os.path.join(self._dir, f"msg_{len(self._paths):09d}.eml") + with open(path, "wb") as handle: + handle.write(data) + self._paths.append(path) + + def get(self, index): + with open(self._paths[index], "rb") as handle: + return handle.read() + + def __len__(self): + return len(self._paths) + + def cleanup(self): + shutil.rmtree(self._dir, ignore_errors=True) + + +def build_body_store(ctx): + count = min(ctx.pool_size, ctx.messages) + if ctx.spool_dir is not None: + store = DiskStore(ctx.spool_dir) + else: + store = MemoryStore() + for _ in range(count): + if ctx.fixed_size is not None: + size = ctx.fixed_size + else: + size = random.randint(ctx.min_size, ctx.max_size) + store.add(build_body(size)) + return store + + +def make_tls_context(): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +class Stats: + def __init__(self): + self._lock = threading.Lock() + self.latencies = [] + self.errors = 0 + self.bytes = 0 + + def record(self, latency_ms, num_bytes): + with self._lock: + self.latencies.append(latency_ms) + self.bytes += num_bytes + + def record_error(self): + with self._lock: + self.errors += 1 + + def snapshot(self): + with self._lock: + return list(self.latencies), self.errors, self.bytes + + +class Counter: + def __init__(self, total): + self._lock = threading.Lock() + self._remaining = total + + def claim(self): + with self._lock: + if self._remaining <= 0: + return False + self._remaining -= 1 + return True + + +def read_recipients(file_path): + recipients = [] + try: + with open(file_path, "r") as file: + for line in file: + line = line.strip() + if not line: + continue + recipients.append(line.split(":", 1)[0]) + except OSError as e: + raise SystemExit(f"Could not read recipients from '{file_path}': {e}") + if not recipients: + raise SystemExit(f"No recipients found in '{file_path}'.") + return recipients + + +def connect(ctx): + server = smtplib.SMTP(ctx.host, ctx.port, timeout=ctx.timeout) + server.ehlo() + if ctx.starttls: + if not server.has_extn("starttls"): + server.quit() + raise SmtpError("server does not advertise STARTTLS") + server.starttls(context=ctx.tls_context) + server.ehlo() + return server + + +def send_one(server, sender, recipient, header, body): + code, resp = server.mail(sender) + if code != 250: + server.rset() + raise SmtpError(f"MAIL FROM rejected: {code} {resp!r}") + code, resp = server.rcpt(recipient) + if code not in (250, 251): + server.rset() + raise SmtpError(f"RCPT TO rejected: {code} {resp!r}") + code, resp = server.docmd("DATA") + if code != 354: + raise SmtpError(f"DATA rejected: {code} {resp!r}") + server.send(header) + server.send(body) + start = time.monotonic() + server.send(b".\r\n") + code, resp = server.getreply() + elapsed_ms = (time.monotonic() - start) * 1000 + if code != 250: + raise SmtpError(f"message rejected: {code} {resp!r}") + return elapsed_ms + + +def worker(ctx, counter, recipients, stats, store, logger): + pool_len = len(store) + while not STOP_EVENT.is_set() and counter.claim(): + server = None + try: + server = connect(ctx) + recipient = random.choice(recipients) + body = store.get(random.randrange(pool_len)) + header = build_headers(ctx.sender, recipient) + elapsed_ms = send_one(server, ctx.sender, recipient, header, body) + num_bytes = len(header) + len(body) + stats.record(elapsed_ms, num_bytes) + if logger.enabled: + logger.log(f"OK {elapsed_ms:9.2f}ms {num_bytes:>9}B -> {recipient}") + except (SmtpError, smtplib.SMTPException, OSError) as e: + stats.record_error() + if logger.enabled: + logger.log(f"ERR {e}") + finally: + if server is not None: + try: + server.quit() + except Exception: + try: + server.close() + except Exception: + pass + + +def percentile(sorted_values, pct): + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + rank = (len(sorted_values) - 1) * (pct / 100.0) + low = math.floor(rank) + high = math.ceil(rank) + if low == high: + return sorted_values[int(rank)] + return sorted_values[low] * (high - rank) + sorted_values[high] * (rank - low) + + +def stddev(values, mean): + if len(values) < 2: + return 0.0 + variance = sum((v - mean) ** 2 for v in values) / (len(values) - 1) + return math.sqrt(variance) + + +def print_report( + latencies, + errors, + total_bytes, + send_seconds, + gen_seconds, + pool_count, + workers, + storage, + report_header=None, +): + count = len(latencies) + mb = total_bytes / (1024 * 1024) + throughput = count / send_seconds if send_seconds > 0 else 0.0 + mb_per_sec = mb / send_seconds if send_seconds > 0 else 0.0 + + line = "-" * 60 + print() + if report_header: + print(report_header) + print(line) + print("SMTP ingestion stress test report") + print(line) + print(f"{'Workers':<26}{workers}") + print(f"{'Message store':<26}{storage}") + print(f"{'Messages OK':<26}{count}") + print(f"{'Messages failed':<26}{errors}") + print(f"{'Bodies pregenerated':<26}{pool_count}") + print(f"{'Pool gen time (s)':<26}{gen_seconds:.2f}") + print(f"{'Send wall time (s)':<26}{send_seconds:.2f}") + print(f"{'Throughput (msg/s)':<26}{throughput:.2f}") + print(f"{'Data sent (MB)':<26}{mb:.2f}") + print(f"{'Data rate (MB/s)':<26}{mb_per_sec:.2f}") + print(line) + print("Ingestion time (DATA terminator to server OK), milliseconds") + print(line) + if count: + ordered = sorted(latencies) + mean = sum(ordered) / count + rows = [ + ("min", ordered[0]), + ("max", ordered[-1]), + ("avg", mean), + ("median", percentile(ordered, 50)), + ("p95", percentile(ordered, 95)), + ("p99", percentile(ordered, 99)), + ("stddev", stddev(ordered, mean)), + ] + for name, value in rows: + print(f"{name:<22}{value:.2f}") + else: + print("no messages were ingested") + print(line) + sys.stdout.flush() + + +class Context: + def __init__(self, args, messages): + self.host = args.host + self.port = args.port + self.threads = args.threads + self.sender = args.sender + self.starttls = not args.no_starttls + self.timeout = args.timeout + self.min_size = args.min_size + self.max_size = args.max_size + self.fixed_size = args.size + self.pool_size = args.pool_size + self.spool_dir = args.spool_dir + self.messages = messages + self.tls_context = make_tls_context() if self.starttls else None + + +def run_threads(ctx, recipients, message_count, store, logger): + stats = Stats() + counter = Counter(message_count) + threads = [ + threading.Thread( + target=worker, + args=(ctx, counter, recipients, stats, store, logger), + daemon=True, + ) + for _ in range(ctx.threads) + ] + for thread in threads: + thread.start() + try: + while any(t.is_alive() for t in threads): + for t in threads: + t.join(timeout=0.2) + except KeyboardInterrupt: + logger.log("Stopping...") + STOP_EVENT.set() + for t in threads: + t.join() + return stats + + +def child_main(args, recipients, message_count, barrier, result_queue): + global WORDS + WORDS = load_words(args.dict) + ctx = Context(args, message_count) + logger = AsyncLogger(not args.quiet) + store = build_body_store(ctx) + try: + logger.start() + try: + barrier.wait() + except threading.BrokenBarrierError: + result_queue.put(([], 0, 0, len(store))) + return + stats = run_threads(ctx, recipients, message_count, store, logger) + logger.stop() + latencies, errors, total_bytes = stats.snapshot() + result_queue.put((latencies, errors, total_bytes, len(store))) + finally: + store.cleanup() + + +def distribute(total, parts): + base, remainder = divmod(total, parts) + return [base + (1 if i < remainder else 0) for i in range(parts)] + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Concurrent SMTP ingestion stress test over port 25 with STARTTLS." + ) + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--threads", type=int, default=DEFAULT_THREADS) + parser.add_argument( + "--processes", + type=int, + default=DEFAULT_PROCESSES, + help="Worker processes to spawn (each runs --threads threads). Scales past the GIL.", + ) + parser.add_argument( + "--messages", + type=int, + default=DEFAULT_MESSAGES, + help="Total messages to send, distributed across the threads.", + ) + parser.add_argument("--sender", default=DEFAULT_SENDER, help="Envelope MAIL FROM address.") + parser.add_argument("--users-file", default=DEFAULT_USERS_FILE) + parser.add_argument( + "--size", + type=int, + help="Fixed message body size in bytes; overrides --min-size/--max-size.", + ) + parser.add_argument("--min-size", type=int, default=DEFAULT_MIN_SIZE) + parser.add_argument("--max-size", type=int, default=DEFAULT_MAX_SIZE) + parser.add_argument( + "--pool-size", + type=int, + default=DEFAULT_POOL_SIZE, + help="Distinct message bodies pregenerated before timing (reused at random). " + "Each sent message gets a fresh unique Message-ID regardless of this.", + ) + parser.add_argument( + "--spool-dir", + nargs="?", + const=tempfile.gettempdir(), + default=None, + help="Spool pregenerated messages to disk instead of memory. " + "With no value uses the system temp dir; pass a path to override.", + ) + parser.add_argument("--dict", default=DEFAULT_DICTIONARY) + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument( + "--header", + help="Optional header line printed at the top of the final report on stdout.", + ) + parser.add_argument( + "--no-starttls", + action="store_true", + help="Send over plaintext instead of upgrading with STARTTLS.", + ) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + if args.threads < 1: + parser.error("--threads must be at least 1") + if args.processes < 1: + parser.error("--processes must be at least 1") + if args.messages < 1: + parser.error("--messages must be at least 1") + if args.pool_size < 1: + parser.error("--pool-size must be at least 1") + if args.size is None and args.min_size > args.max_size: + parser.error("--min-size must not exceed --max-size") + return args + + +def run_single_process(args, recipients): + global WORDS + WORDS = load_words(args.dict) + ctx = Context(args, args.messages) + logger = AsyncLogger(not args.quiet) + + gen_start = time.monotonic() + store = build_body_store(ctx) + gen_seconds = time.monotonic() - gen_start + + try: + logger.start() + start = time.monotonic() + stats = run_threads(ctx, recipients, args.messages, store, logger) + send_seconds = time.monotonic() - start + logger.stop() + latencies, errors, total_bytes = stats.snapshot() + workers = f"1 process x {args.threads} threads" + print_report( + latencies, errors, total_bytes, send_seconds, gen_seconds, len(store), + workers, store.backend, args.header, + ) + finally: + store.cleanup() + + +def run_multi_process(args, recipients): + nproc = min(args.processes, args.messages) + shares = distribute(args.messages, nproc) + barrier = multiprocessing.Barrier(nproc + 1) + result_queue = multiprocessing.Queue() + procs = [] + for share in shares: + proc = multiprocessing.Process( + target=child_main, + args=(args, recipients, share, barrier, result_queue), + daemon=False, + ) + proc.start() + procs.append(proc) + + gen_start = time.monotonic() + interrupted = False + try: + barrier.wait() + except KeyboardInterrupt: + interrupted = True + barrier.abort() + gen_seconds = time.monotonic() - gen_start + + start = time.monotonic() + results = [] + try: + for _ in procs: + results.append(result_queue.get()) + except KeyboardInterrupt: + interrupted = True + for proc in procs: + proc.terminate() + send_seconds = time.monotonic() - start + + for proc in procs: + proc.join() + + latencies = [] + errors = 0 + total_bytes = 0 + pool_count = 0 + for lat, err, nbytes, pool_len in results: + latencies.extend(lat) + errors += err + total_bytes += nbytes + pool_count += pool_len + + if interrupted: + print("Interrupted.", file=sys.stderr, flush=True) + workers = f"{nproc} processes x {args.threads} threads" + storage = "memory" if args.spool_dir is None else f"disk ({args.spool_dir})" + print_report( + latencies, errors, total_bytes, send_seconds, gen_seconds, pool_count, + workers, storage, args.header, + ) + + +def main(): + args = parse_args() + recipients = read_recipients(args.users_file) + if args.processes == 1: + run_single_process(args, recipients) + else: + run_multi_process(args, recipients) + + +if __name__ == "__main__": + main() diff --git a/tests/resources/scripts/stress_test.py b/tests/resources/scripts/stress_test.py new file mode 100644 index 0000000..3ad608b --- /dev/null +++ b/tests/resources/scripts/stress_test.py @@ -0,0 +1,490 @@ +import argparse +import imaplib +import math +import os +import random +import smtplib +import ssl +import threading +import time +from collections import defaultdict +from email.mime.text import MIMEText + +DEFAULT_SMTP_SERVER = "127.0.0.1" +DEFAULT_SMTP_PORT = 465 +DEFAULT_IMAP_SERVER = "127.0.0.1" +DEFAULT_IMAP_PORT = 993 +DEFAULT_THREADS = 5 +DEFAULT_RUNS = 10 +DEFAULT_DICTIONARY = "/usr/share/dict/words" + +FALLBACK_WORDS = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam " + "quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo" +).split() + +WORDS = FALLBACK_WORDS + +SMTP_SEND = "SMTP SEND" +IMAP_APPEND = "IMAP APPEND" +IMAP_FETCH = "IMAP FETCH" +IMAP_DELETE = "IMAP DELETE" +ACTIONS = (SMTP_SEND, IMAP_APPEND, IMAP_FETCH, IMAP_DELETE) + + +class Stats: + def __init__(self): + self._lock = threading.Lock() + self._latencies = defaultdict(list) + self._errors = defaultdict(int) + self._skips = defaultdict(int) + self._bytes = defaultdict(int) + + def record(self, action, latency_ms, num_bytes=0): + with self._lock: + self._latencies[action].append(latency_ms) + self._bytes[action] += num_bytes + + def record_error(self, action): + with self._lock: + self._errors[action] += 1 + + def record_skip(self, action): + with self._lock: + self._skips[action] += 1 + + def snapshot(self): + with self._lock: + return ( + {k: list(v) for k, v in self._latencies.items()}, + dict(self._errors), + dict(self._skips), + dict(self._bytes), + ) + + +PRINT_LOCK = threading.Lock() +STOP_EVENT = threading.Event() + + +def read_credentials(file_path): + if not os.path.exists(file_path): + raise SystemExit( + f"Credentials file '{file_path}' not found. " + f"Run stress_test_prepare.py first to create users." + ) + credentials = [] + with open(file_path, "r") as file: + for line in file: + line = line.strip() + if not line: + continue + parts = line.split(":", 1) + if len(parts) != 2: + continue + credentials.append((parts[0], parts[1])) + if not credentials: + raise SystemExit(f"No valid credentials found in '{file_path}'.") + return credentials + + +def allow_invalid_certificates(): + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def load_words(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as file: + words = [w.strip() for w in file if w.strip().isalpha()] + except OSError: + words = [] + if not words: + print( + f"WARNING: word list '{path}' not found or empty; " + f"falling back to built-in lorem ipsum words. " + f"Override with --dict ." + ) + return list(FALLBACK_WORDS) + return words + + +def random_words(min_size, max_size): + target = random.randint(min_size, max_size) + parts = [] + length = 0 + while length < target: + for word in random.choices(WORDS, k=64): + parts.append(word) + length += len(word) + 1 + if length >= target: + break + return " ".join(parts) + + +def generate_email(username, recipient, max_content_size): + subject = random_words(10, 100) + content = random_words(100, max_content_size) + message = MIMEText(content) + message["Subject"] = subject + message["From"] = username + message["To"] = recipient + return message.as_string() + + +def log_ok(stats, action, latency_ms, detail="", num_bytes=0, verbose=True): + stats.record(action, latency_ms, num_bytes) + if verbose: + with PRINT_LOCK: + print(f"OK {latency_ms:9.2f}ms {action} {detail}") + + +def log_err(stats, action, error, verbose=True): + stats.record_error(action) + if verbose: + with PRINT_LOCK: + print(f"ERR {action} {error}") + + +def smtp_send_message(ctx, username, password, recipient): + try: + with smtplib.SMTP_SSL( + ctx.smtp_server, ctx.smtp_port, context=allow_invalid_certificates() + ) as server: + server.login(username, password) + payload = generate_email(username, recipient, ctx.max_content_size) + start_time = time.monotonic() + server.sendmail(username, recipient, payload) + elapsed_ms = (time.monotonic() - start_time) * 1000 + log_ok( + ctx.stats, + SMTP_SEND, + elapsed_ms, + f"{username} -> {recipient}", + len(payload), + ctx.verbose, + ) + except Exception as e: + log_err(ctx.stats, SMTP_SEND, e, ctx.verbose) + + +def imap_append_message(ctx, username, password, recipient): + try: + with imaplib.IMAP4_SSL( + ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates() + ) as imap: + imap.login(username, password) + payload = generate_email(username, recipient, ctx.max_content_size).encode("utf-8") + start_time = time.monotonic() + imap.append("INBOX", None, imaplib.Time2Internaldate(time.time()), payload) + elapsed_ms = (time.monotonic() - start_time) * 1000 + log_ok(ctx.stats, IMAP_APPEND, elapsed_ms, username, len(payload), ctx.verbose) + except Exception as e: + log_err(ctx.stats, IMAP_APPEND, e, ctx.verbose) + + +def imap_list_fetch(ctx, username, password, recipient): + try: + with imaplib.IMAP4_SSL( + ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates() + ) as imap: + imap.login(username, password) + imap.select("INBOX") + start_time = time.monotonic() + typ, data = imap.search(None, "ALL") + if data and data[0]: + messages = data[0].split() + random_msg_num = random.choice(messages) + imap.fetch(random_msg_num, "(RFC822)") + elapsed_ms = (time.monotonic() - start_time) * 1000 + log_ok( + ctx.stats, + IMAP_FETCH, + elapsed_ms, + f"{username} {random_msg_num.decode()}", + verbose=ctx.verbose, + ) + else: + ctx.stats.record_skip(IMAP_FETCH) + except Exception as e: + log_err(ctx.stats, IMAP_FETCH, e, ctx.verbose) + + +def imap_delete_message(ctx, username, password, recipient): + try: + with imaplib.IMAP4_SSL( + ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates() + ) as imap: + imap.login(username, password) + imap.select("INBOX") + start_time = time.monotonic() + typ, data = imap.search(None, "ALL") + if data and data[0]: + messages = data[0].split() + random_msg_num = random.choice(messages) + imap.store(random_msg_num, "+FLAGS", "\\Deleted") + imap.expunge() + elapsed_ms = (time.monotonic() - start_time) * 1000 + log_ok( + ctx.stats, + IMAP_DELETE, + elapsed_ms, + f"{username} {random_msg_num.decode()}", + verbose=ctx.verbose, + ) + else: + ctx.stats.record_skip(IMAP_DELETE) + except Exception as e: + log_err(ctx.stats, IMAP_DELETE, e, ctx.verbose) + + +ACTION_FUNCS = ( + smtp_send_message, + imap_append_message, + imap_list_fetch, + imap_delete_message, +) + + +def pick_recipient(credentials, sender): + if len(credentials) == 1: + return credentials[0][0] + while True: + recipient = random.choice(credentials)[0] + if recipient != sender: + return recipient + + +def perform_random_action(ctx, credentials): + username, password = random.choice(credentials) + recipient = pick_recipient(credentials, username) + action = random.choice(ACTION_FUNCS) + action(ctx, username, password, recipient) + + +def thread_function(ctx, credentials): + count = 0 + while not STOP_EVENT.is_set(): + if ctx.runs is not None and count >= ctx.runs: + break + perform_random_action(ctx, credentials) + count += 1 + + +def percentile(sorted_values, pct): + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + rank = (len(sorted_values) - 1) * (pct / 100.0) + low = math.floor(rank) + high = math.ceil(rank) + if low == high: + return sorted_values[int(rank)] + return sorted_values[low] * (high - rank) + sorted_values[high] * (rank - low) + + +def stddev(values, mean): + if len(values) < 2: + return 0.0 + variance = sum((v - mean) ** 2 for v in values) / (len(values) - 1) + return math.sqrt(variance) + + +def summarize(action, latencies, errors, skips, total_bytes, wall_seconds): + count = len(latencies) + summary = { + "action": action, + "count": count, + "errors": errors, + "skips": skips, + "mb": total_bytes / (1024 * 1024), + } + if count == 0: + for key in ("min", "max", "avg", "median", "p95", "p99", "stddev", "ops"): + summary[key] = 0.0 + return summary + ordered = sorted(latencies) + mean = sum(ordered) / count + summary.update( + { + "min": ordered[0], + "max": ordered[-1], + "avg": mean, + "median": percentile(ordered, 50), + "p95": percentile(ordered, 95), + "p99": percentile(ordered, 99), + "stddev": stddev(ordered, mean), + "ops": count / wall_seconds if wall_seconds > 0 else 0.0, + } + ) + return summary + + +def print_report(stats, wall_seconds): + latencies, errors, skips, byte_counts = stats.snapshot() + + rows = [] + all_latencies = [] + total_errors = 0 + total_skips = 0 + total_bytes = 0 + for action in ACTIONS: + action_latencies = latencies.get(action, []) + all_latencies.extend(action_latencies) + total_errors += errors.get(action, 0) + total_skips += skips.get(action, 0) + total_bytes += byte_counts.get(action, 0) + rows.append( + summarize( + action, + action_latencies, + errors.get(action, 0), + skips.get(action, 0), + byte_counts.get(action, 0), + wall_seconds, + ) + ) + rows.append( + summarize( + "TOTAL", + all_latencies, + total_errors, + total_skips, + total_bytes, + wall_seconds, + ) + ) + + headers = [ + "Action", + "OK", + "Err", + "Skip", + "Min ms", + "Max ms", + "Avg ms", + "Med ms", + "P95 ms", + "P99 ms", + "Std ms", + "Ops/s", + "MB", + ] + fmt = "{:<12} {:>7} {:>5} {:>5} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}" + line = "-" * 122 + + print() + print(line) + print(f"Stress test report (wall time: {wall_seconds:.2f}s)") + print(line) + print(fmt.format(*headers)) + print(line) + for r in rows: + if r["action"] == "TOTAL": + print(line) + print( + fmt.format( + r["action"], + r["count"], + r["errors"], + r["skips"], + f"{r['min']:.2f}", + f"{r['max']:.2f}", + f"{r['avg']:.2f}", + f"{r['median']:.2f}", + f"{r['p95']:.2f}", + f"{r['p99']:.2f}", + f"{r['stddev']:.2f}", + f"{r['ops']:.1f}", + f"{r['mb']:.1f}", + ) + ) + print(line) + + +class Context: + def __init__(self, args, stats): + self.smtp_server = args.smtp_server + self.smtp_port = args.smtp_port + self.imap_server = args.imap_server + self.imap_port = args.imap_port + self.runs = args.runs + self.max_content_size = args.max_content_size + self.verbose = not args.quiet + self.stats = stats + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Concurrent SMTP/IMAP stress test for Stalwart." + ) + parser.add_argument("--smtp-server", default=DEFAULT_SMTP_SERVER) + parser.add_argument("--smtp-port", type=int, default=DEFAULT_SMTP_PORT) + parser.add_argument("--imap-server", default=DEFAULT_IMAP_SERVER) + parser.add_argument("--imap-port", type=int, default=DEFAULT_IMAP_PORT) + parser.add_argument("--threads", type=int, default=DEFAULT_THREADS) + parser.add_argument( + "--runs", + type=int, + default=DEFAULT_RUNS, + help="Actions per thread. Use 0 for an infinite loop (stop with Ctrl-C).", + ) + parser.add_argument("--credentials", default="users.txt") + parser.add_argument( + "--max-content-size", + type=int, + default=1048576, + help="Maximum random message body size in bytes.", + ) + parser.add_argument( + "--dict", + default=DEFAULT_DICTIONARY, + help="Word list used to generate message text, one word per line.", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress per-operation logging; print only the final report.", + ) + return parser.parse_args() + + +def main(): + global WORDS + args = parse_args() + if args.runs == 0: + args.runs = None + WORDS = load_words(args.dict) + credentials = read_credentials(args.credentials) + stats = Stats() + ctx = Context(args, stats) + + threads = [ + threading.Thread(target=thread_function, args=(ctx, credentials), daemon=True) + for _ in range(args.threads) + ] + + start = time.monotonic() + for thread in threads: + thread.start() + + try: + while any(t.is_alive() for t in threads): + for t in threads: + t.join(timeout=0.2) + except KeyboardInterrupt: + print("\nStopping...") + STOP_EVENT.set() + for t in threads: + t.join() + + wall_seconds = time.monotonic() - start + print_report(stats, wall_seconds) + + +if __name__ == "__main__": + main() diff --git a/tests/resources/scripts/stress_test_prepare.py b/tests/resources/scripts/stress_test_prepare.py new file mode 100644 index 0000000..426a5ee --- /dev/null +++ b/tests/resources/scripts/stress_test_prepare.py @@ -0,0 +1,244 @@ +import argparse +import base64 +import json +import random +import ssl +import string +import urllib.error +import urllib.request + +CORE = "urn:ietf:params:jmap:core" +STALWART = "urn:stalwart:jmap" +USING = [CORE, STALWART] + +DEFAULT_BASE_URL = "https://127.0.0.1" +DEFAULT_NUM_USERS = 1000 +DEFAULT_OUTPUT = "users.txt" +DEFAULT_PASSWORD_LENGTH = 16 +DEFAULT_PREFIX = "test" + +CREATE_RETRIES = 5 + + +class AccountError(Exception): + pass + + +def generate_password(length): + return "".join(random.choices(string.ascii_letters + string.digits, k=length)) + + +def primary_account(session): + accounts = session.get("primaryAccounts") or {} + if "urn:ietf:params:jmap:mail" in accounts: + return accounts["urn:ietf:params:jmap:mail"] + all_accounts = session.get("accounts") or {} + return next(iter(all_accounts), None) + + +def build_opener(verify): + context = ssl.create_default_context() + if not verify: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + handler = urllib.request.HTTPSHandler(context=context) + return urllib.request.build_opener(handler) + + +class JmapClient: + def __init__(self, base_url, auth_header, verify): + self.base_url = base_url.rstrip("/") + self.auth_header = auth_header + self.opener = build_opener(verify) + self.api_url = None + self.account_id = None + self._discover() + + def _http(self, url, method, body=None): + headers = {"Authorization": self.auth_header} + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with self.opener.open(request, timeout=60) as response: + return response.status, response.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + except urllib.error.URLError as e: + raise SystemExit(f"Request to {url} failed: {e.reason}") + + def _discover(self): + if "/.well-known/jmap" in self.base_url: + candidates = [self.base_url] + else: + candidates = [self.base_url, f"{self.base_url}/.well-known/jmap"] + last = "" + for url in candidates: + status, text = self._http(url, "GET") + if status == 200: + try: + data = json.loads(text) + except ValueError: + last = f"{url}: invalid JSON" + continue + if data.get("apiUrl") and data.get("accounts"): + self.api_url = data["apiUrl"] + self.account_id = primary_account(data) + if not self.account_id: + raise SystemExit(f"JMAP session at {url} has no accounts.") + return + last = f"{url}: status {status}" + raise SystemExit(f"Could not discover JMAP session ({last}).") + + def request(self, method_calls): + body = {"using": USING, "methodCalls": method_calls} + status, text = self._http(self.api_url, "POST", body) + if status != 200: + raise SystemExit(f"JMAP request failed: status {status}: {text}") + return json.loads(text) + + def call(self, method, args): + args = dict(args) + args["accountId"] = self.account_id + parsed = self.request([[method, args, "c0"]]) + responses = parsed.get("methodResponses") or [] + if not responses: + raise SystemExit(f"{method}: empty methodResponses") + name, payload = responses[0][0], responses[0][1] + if name == "error": + raise SystemExit(f"{method} error: {payload}") + return payload + + def domain_id(self, name): + calls = [ + [ + "x:Domain/query", + {"accountId": self.account_id, "filter": {"name": name}}, + "q", + ], + [ + "x:Domain/get", + { + "accountId": self.account_id, + "#ids": { + "resultOf": "q", + "name": "x:Domain/query", + "path": "/ids", + }, + "properties": ["id", "name"], + }, + "g", + ], + ] + parsed = self.request(calls) + responses = parsed.get("methodResponses") or [] + if len(responses) < 2: + raise SystemExit(f"Domain lookup failed: {parsed}") + get = responses[1][1] + for entry in get.get("list") or []: + if entry.get("name") == name: + return entry.get("id") + return None + + def create_account(self, localpart, domain_id, password): + create = { + "a": { + "@type": "User", + "name": localpart, + "domainId": domain_id, + "credentials": {"0": {"@type": "Password", "secret": password}}, + "encryptionAtRest": {"@type": "Disabled"}, + "permissions": {"@type": "Inherit"}, + "roles": {"@type": "User"}, + "locale": "en_US", + } + } + response = self.call("x:Account/set", {"create": create}) + not_created = response.get("notCreated") or {} + if not_created: + raise AccountError(f"{not_created.get('a', not_created)}") + created = (response.get("created") or {}).get("a") or {} + return created.get("id") + + def invalidate_caches(self): + self.call( + "x:Action/set", + {"create": {"c": {"@type": "InvalidateCaches"}}}, + ) + + +def build_auth_header(args): + if args.token: + return f"Bearer {args.token}" + raw = f"{args.user}:{args.password}".encode("utf-8") + return "Basic " + base64.b64encode(raw).decode("ascii") + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Provision test accounts for the stress test using the JMAP API." + ) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument( + "--domain", + required=True, + help="Existing domain name; resolved to a domain id via JMAP.", + ) + parser.add_argument("--token", help="OAuth bearer token for the JMAP API.") + parser.add_argument("--user", help="Basic auth username for the JMAP API.") + parser.add_argument("--password", help="Basic auth password for the JMAP API.") + parser.add_argument("--num-users", type=int, default=DEFAULT_NUM_USERS) + parser.add_argument("--prefix", default=DEFAULT_PREFIX) + parser.add_argument("--password-length", type=int, default=DEFAULT_PASSWORD_LENGTH) + parser.add_argument("--output", default=DEFAULT_OUTPUT) + parser.add_argument( + "--verify", + action="store_true", + help="Verify TLS certificates (disabled by default for self-signed servers).", + ) + args = parser.parse_args() + if not args.token and not (args.user and args.password): + parser.error("provide either --token or both --user and --password") + return args + + +def main(): + args = parse_args() + client = JmapClient(args.base_url, build_auth_header(args), args.verify) + domain_id = client.domain_id(args.domain) + if not domain_id: + raise SystemExit(f"Domain '{args.domain}' not found via JMAP.") + + created = 0 + failed = 0 + with open(args.output, "w") as file: + for i in range(1, args.num_users + 1): + localpart = f"{args.prefix}{i}" + email = f"{localpart}@{args.domain}" + password = None + last_error = None + for _ in range(CREATE_RETRIES): + password = generate_password(args.password_length) + try: + client.create_account(localpart, domain_id, password) + last_error = None + break + except AccountError as e: + last_error = e + if last_error is not None: + failed += 1 + print(f"FAIL {email}: {last_error}") + continue + file.write(f"{email}:{password}\n") + file.flush() + created += 1 + print(f"OK {email}") + + client.invalidate_caches() + print(f"\nCreated {created} accounts ({failed} failed). Written to {args.output}.") + + +if __name__ == "__main__": + main() diff --git a/tests/resources/smtp/antispam/bounce.test b/tests/resources/smtp/antispam/bounce.test new file mode 100644 index 0000000..76886e6 --- /dev/null +++ b/tests/resources/smtp/antispam/bounce.test @@ -0,0 +1,99 @@ +expect SUBJ_BOUNCE_WORDS SINGLE_SHORT_PART + +Subject: Delivery Status Notification (Failure) + +Test + +expect BOUNCE SINGLE_SHORT_PART IS_DSN + +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e" + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Your message could not be delivered. + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e-- + +envelope_from spammer@domain.com +expect SINGLE_SHORT_PART IS_DSN + +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e" + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Your message could not be delivered. + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e-- + +expect BOUNCE SINGLE_SHORT_PART + +From: MDaemon +X-MDDSN-Message: True +Subject: Something went wrong + +Your message could not be delivered. + + +expect BOUNCE SUBJ_BOUNCE_WORDS SINGLE_SHORT_PART + +From: Automated +Subject: Delivery failure + +Your message could not be delivered. + + +expect BOUNCE HAS_ATTACHMENT HAS_MESSAGE_PARTS + +MIME-Version: 1.0 +From: Automated +Subject: Something unexpected happened +Content-Type: multipart/mixed; + boundary="176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e" + + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Your message could not be delivered to the following recipients: + + (TLS error from 'inc.test.com': STARTTLS not advertised by host.) + + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e +Content-Type: message/delivery-status +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns;mail.stalw.art +Arrival-Date: Mon, 3 Jul 2023 16:11:29 +0000 + +Final-Recipient: rfc822;user@test.com +Action: failed +Status: 5.0.0 +Remote-MTA: dns;inc.test.com + + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e +Content-Type: message/rfc822 +Content-Transfer-Encoding: 7bit + +From: Test +Content-Type: text/plain; + charset=us-ascii +Content-Transfer-Encoding: quoted-printable +Mime-Version: 1.0 (Mac OS X Mail 16.0 \(3731.600.7\)) +Subject: Re: Test +Date: Mon, 3 Jul 2023 18:11:18 +0200 +References: <86a9efeb-1cd4-2aee-fdc4-10dc133d4c1e@test.com> +To: test +In-Reply-To: <86a9efeb-1cd4-2aee-fdc4-10dc133d4c1e@test.com> + +--176e677bbd667276_87a2ed9cf1f4ecb_a49e592dab77f72e-- diff --git a/tests/resources/smtp/antispam/classifier.ham b/tests/resources/smtp/antispam/classifier.ham new file mode 100644 index 0000000..ae8a593 --- /dev/null +++ b/tests/resources/smtp/antispam/classifier.ham @@ -0,0 +1,59 @@ +Message-ID: +Subject: i have been trying to research via sa mirrors and search engines + +if a canned script exists giving clients access to their user_prefs options via a web based cgi interface numerous isps provide this feature to clients but so far i can find nothing our configuration uses amavis postfix and clamav for virus filtering and procmail with spamassassin for spam filtering i would prefer not to have to write a script myself but will appreciate any suggestions this URL email is sponsored by osdn tired of that same old cell phone get a new here for free URL _______________________________________________ spamassassin talk mailing list spamassassin talk URL URL + + +Message-ID: mid2@foobar.org +Subject: hello + +have you seen and discussed this article and his approach thank you URL hell there are no rules here we re trying to accomplish something thomas alva edison this URL email is sponsored by osdn tired of that same old cell phone get a new here for free URL _______________________________________________ spamassassin devel mailing list spamassassin devel URL URL + + +Message-ID: +Subject: hi all apologies for the possible silly question + +i don t think it is but but is eircom s adsl service nat ed and what implications would that have for voip i know there are difficulties with voip or connecting to clients connected to a nat ed network from the internet wild i e machines with static real ips any help pointers would be helpful cheers rgrds bernard bernard tyers national centre for sensor research p NUMBER NUMBER NUMBER NUMBER e bernard tyers URL w URL l nNUMBER _______________________________________________ iiu mailing list iiu URL URL + + +Message-ID: +Subject: can someone explain + +what type of operating system solaris is as ive never seen or used it i dont know wheather to get a server from sun or from dell i would prefer a linux based server and sun seems to be the one for that but im not sure if solaris is a distro of linux or a completely different operating system can someone explain kiall mac innes irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + + +Message-ID: +Subject: folks my first time posting + +have a bit of unix experience but am new to linux just got a new pc at home dell box with windows xp added a second hard disk for linux partitioned the disk and have installed suse NUMBER NUMBER from cd which went fine except it didn t pick up my monitor i have a dell branded eNUMBERfpp NUMBER lcd flat panel monitor and a nvidia geforceNUMBER tiNUMBER video card both of which are probably too new to feature in suse s default set i downloaded a driver from the nvidia website and installed it using rpm then i ran saxNUMBER as was recommended in some postings i found on the net but it still doesn t feature my video card in the available list what next another problem i have a dell branded keyboard and if i hit caps lock twice the whole machine crashes in linux not windows even the on off switch is inactive leaving me to reach for the power cable instead if anyone can help me in any way with these probs i d be really grateful i ve searched the net but have run out of ideas or should i be going for a different version of linux such as redhat opinions welcome thanks a lot peter irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + + +Message-ID: +Subject: has anyone + +seen heard of used some package that would let a random person go to a webpage create a mailing list then administer that list also of course let ppl sign up for the lists and manage their subscriptions similar to the old URL but i d like to have it running on my server not someone elses chris URL + + +Message-ID: +Subject: hi thank you for the useful replies + +i have found some interesting tutorials in the ibm developer connection URL and URL registration is needed i will post the same message on the web application security list as suggested by someone for now i thing i will use mdNUMBER for password checking i will use the approach described in secure programmin fo linux and unix how to i will separate the authentication module so i can change its implementation at anytime thank you again mario torre please avoid sending me word or powerpoint attachments see URL + + +Message-ID: +Subject: hehe sorry + +but if you hit caps lock twice the computer crashes theres one ive never heard before have you tryed dell support yet i think dell computers prefer redhat dell provide some computers pre loaded with red hat i dont know for sure tho so get someone elses opnion as well as mine original message from ilug admin URL mailto ilug admin URL on behalf of peter staunton sent NUMBER august NUMBER NUMBER NUMBER to ilug URL subject ilug newbie seeks advice suse NUMBER NUMBER folks my first time posting have a bit of unix experience but am new to linux just got a new pc at home dell box with windows xp added a second hard disk for linux partitioned the disk and have installed suse NUMBER NUMBER from cd which went fine except it didn t pick up my monitor i have a dell branded eNUMBERfpp NUMBER lcd flat panel monitor and a nvidia geforceNUMBER tiNUMBER video card both of which are probably too new to feature in suse s default set i downloaded a driver from the nvidia website and installed it using rpm then i ran saxNUMBER as was recommended in some postings i found on the net but it still doesn t feature my video card in the available list what next another problem i have a dell branded keyboard and if i hit caps lock twice the whole machine crashes in linux not windows even the on off switch is inactive leaving me to reach for the power cable instead if anyone can help me in any way with these probs i d be really grateful i ve searched the net but have run out of ideas or should i be going for a different version of linux such as redhat opinions welcome thanks a lot peter irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + + +Message-ID: +Subject: it will function as a router + +if that is what you wish it even looks like the modem s embedded os is some kind of linux being that it has interesting interfaces like ethNUMBER i don t use it as a router though i just have it do the absolute minimum dsl stuff and do all the really fun stuff like pppoe on my linux box also the manual tells you what the default password is don t forget to run pppoe over the alcatel speedtouch NUMBERi as in my case you have to have a bridge configured in the router modem s software this lists your vci values etc also does anyone know if the high end speedtouch with NUMBER ethernet ports can act as a full router or do i still need to run a pppoe stack on the linux box regards vin irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + + +Message-ID: +Subject: all is it just me + +or has there been a massive increase in the amount of email being falsely bounced around the place i ve already received email from a number of people i don t know asking why i am sending them email these can be explained by servers from russia and elsewhere coupled with the false emails i received myself it s really starting to annoy me am i the only one seeing an increase in recent weeks martin martin whelan déise design URL tel NUMBER NUMBER our core product déiseditor allows organisations to publish information to their web site in a fast and cost effective manner there is no need for a full time web developer as the site can be easily updated by the organisations own staff instant updates to keep site information fresh sites which are updated regularly bring users back visit URL for a demonstration déiseditor managing your information _______________________________________________ iiu mailing list iiu URL URL ,0 + diff --git a/tests/resources/smtp/antispam/classifier.spam b/tests/resources/smtp/antispam/classifier.spam new file mode 100644 index 0000000..9cadf34 --- /dev/null +++ b/tests/resources/smtp/antispam/classifier.spam @@ -0,0 +1,50 @@ +Subject: save up to NUMBER on life insurance + +why spend more than you have to life quote savings ensuring your family s financial security is very important life quote savings makes buying life insurance simple and affordable we provide free access to the very best companies and the lowest rates life quote savings is fast easy and saves you money let us help you get started with the best values in the country on new coverage you can save hundreds or even thousands of dollars by requesting a free quote from lifequote savings our service will take you less than NUMBER minutes to complete shop and compare save up to NUMBER on all types of life insurance hyperlink click here for your free quote protecting your family is the best investment you ll ever make if you are in receipt of this email in error and or wish to be removed from our list hyperlink please click here and type remove if you reside in any state which prohibits e mail solicitations for insurance please disregard this email + + +Subject: a powerhouse gifting program + +you don t want to miss get in with the founders the major players are on this one for once be where the players are this is your private invitation experts are calling this the fastest way to huge cash flow ever conceived leverage NUMBER NUMBER into NUMBER NUMBER over and over again the question here is you either want to be wealthy or you don t which one are you i am tossing you a financial lifeline and for your sake i hope you grab onto it and hold on tight for the ride of your life testimonials hear what average people are doing their first few days we ve received NUMBER NUMBER in NUMBER day and we are doing that over and over again q s in al i m a single mother in fl and i ve received NUMBER NUMBER in the last NUMBER days d s in fl i was not sure about this when i sent off my NUMBER NUMBER pledge but i got back NUMBER NUMBER the very next day l l in ky i didn t have the money so i found myself a partner to work this with we have received NUMBER NUMBER over the last NUMBER days i think i made the right decision don t you k c in fl i pick up NUMBER NUMBER my first day and i they gave me free leads and all the training you can too j w in ca announcing we will close your sales for you and help you get a fax blast immediately upon your entry you make the money free leads training don t wait call now fax back to NUMBER NUMBER NUMBER NUMBER or call NUMBER NUMBER NUMBER NUMBER name__________________________________phone___________________________________________ fax_____________________________________email____________________________________________ best time to call_________________________time zone________________________________________ this message is sent in compliance of the new e mail bill per section NUMBER paragraph a NUMBER c of s NUMBER further transmissions by the sender of this email may be stopped at no cost to you by sending a reply to this email address with the word remove in the subject line errors omissions and exceptions excluded this is not spam i have compiled this list from our replicate database relative to seattle marketing group the gigt or turbo team for the sole purpose of these communications your continued inclusion is only by your gracious permission if you wish to not receive this mail from me please send an email to tesrewinter URL with remove in the subject and you will be deleted immediately + + +Subject: help wanted + +we are a NUMBER year old fortune NUMBER company that is growing at a tremendous rate we are looking for individuals who want to work from home this is an opportunity to make an excellent income no experience is required we will train you so if you are looking to be employed from home with a career that has vast opportunities then go URL we are looking for energetic and self motivated people if that is you than click on the link and fill out the form and one of our employement specialist will contact you to be removed from our link simple go to URL + + +Subject: tired of the bull out there + +want to stop losing money want a real money maker receive NUMBER NUMBER NUMBER NUMBER today experts are calling this the fastest way to huge cash flow ever conceived a powerhouse gifting program you don t want to miss we work as a team this is your private invitation get in with the founders this is where the big boys play the major players are on this one for once be where the players are this is a system that will drive NUMBER NUMBER s to your doorstep in a short period of time leverage NUMBER NUMBER into NUMBER NUMBER over and over again the question here is you either want to be wealthy or you don t which one are you i am tossing you a financial lifeline and for your sake i hope you grab onto it and hold on tight for the ride of your life testimonials hear what average people are doing their first few days we ve received NUMBER NUMBER in NUMBER day and we are doing that over and over again q s in al i m a single mother in fl and i ve received NUMBER NUMBER in the last NUMBER days d s in fl i was not sure about this when i sent off my NUMBER NUMBER pledge but i got back NUMBER NUMBER the very next day l l in ky i didn t have the money so i found myself a partner to work this with we have received NUMBER NUMBER over the last NUMBER days i think i made the right decision don t you k c in fl i pick up NUMBER NUMBER my first day and i they gave me free leads and all the training you can too j w in ca this will be the most important call you make this year free leads training announcing we will close your sales for you and help you get a fax blast immediately upon your entry you make the money free leads training don t wait call now NUMBER NUMBER NUMBER NUMBER print and fax to NUMBER NUMBER NUMBER NUMBER or send an email requesting more information to successleads URL please include your name and telephone number receive NUMBER NUMBER free leads just for responding a NUMBER NUMBER value name___________________________________ phone___________________________________ fax_____________________________________ email___________________________________ this message is sent in compliance of the new e mail bill per section NUMBER paragraph a NUMBER c of s NUMBER further transmissions by the sender of this email may be stopped at no cost to you by sending a reply to this email address with the word remove in the subject line errors omissions and exceptions excluded this is not spam i have compiled this list from our replicate database relative to seattle marketing group the gigt or turbo team for the sole purpose of these communications your continued inclusion is only by your gracious permission if you wish to not receive this mail from me please send an email to tesrewinter URL with remove in the subject and you will be deleted immediately + + +Subject: cellular phone accessories + +all at below wholesale prices http NUMBER NUMBER NUMBER NUMBER NUMBER sites merchant sales hands free ear buds NUMBER NUMBER phone holsters NUMBER NUMBER booster antennas only NUMBER NUMBER phone cases NUMBER NUMBER car chargers NUMBER NUMBER face plates as low as NUMBER NUMBER lithium ion batteries as low as NUMBER NUMBER http NUMBER NUMBER NUMBER NUMBER NUMBER sites merchant sales click below for accessories on all nokia motorola lg nextel samsung qualcomm ericsson audiovox phones at below wholesale prices http NUMBER NUMBER NUMBER NUMBER NUMBER sites merchant sales if you need assistance please call us NUMBER NUMBER NUMBER to be removed from future mailings please send your remove request to remove me now NUMBER URL thank you and have a super day + + +Subject: conferencing made easy + +only NUMBER cents per minute including long distance no setup fees no contracts or monthly fees call anytime from anywhere to anywhere connects up to NUMBER participants simplicity in set up and administration operator help available NUMBER NUMBER the highest quality service for the lowest rate in the industry fill out the form below to find out how you can lower your phone bill every month required input field name web address company name state business phone home phone email address type of business to be removed from our distribution lists please hyperlink click here + + +Subject: dear friend + +i am mrs sese seko widow of late president mobutu sese seko of zaire now known as democratic republic of congo drc i am moved to write you this letter this was in confidence considering my presentcircumstance and situation i escaped along with my husband and two of our sons george kongolo and basher out of democratic republic of congo drc to abidjan cote d ivoire where my family and i settled while we later moved to settled in morroco where my husband later died of cancer disease however due to this situation we decided to changed most of my husband s billions of dollars deposited in swiss bank and other countries into other forms of money coded for safe purpose because the new head of state of dr mr laurent kabila has made arrangement with the swiss government and other european countries to freeze all my late husband s treasures deposited in some european countries hence my children and i decided laying low in africa to study the situation till when things gets better like now that president kabila is dead and the son taking over joseph kabila one of my late husband s chateaux in southern france was confiscated by the french government and as such i had to change my identity so that my investment will not be traced and confiscated i have deposited the sum eighteen million united state dollars us NUMBER NUMBER NUMBER NUMBER with a security company for safekeeping the funds are security coded to prevent them from knowing the content what i want you to do is to indicate your interest that you will assist us by receiving the money on our behalf acknowledge this message so that i can introduce you to my son kongolo who has the out modalities for the claim of the said funds i want you to assist in investing this money but i will not want my identity revealed i will also want to buy properties and stock in multi national companies and to engage in other safe and non speculative investments may i at this point emphasise the high level of confidentiality which this business demands and hope you will not betray the trust and confidence which i repose in you in conclusion if you want to assist us my son shall put you in the picture of the business tell you where the funds are currently being maintained and also discuss other modalities including remunerationfor your services for this reason kindly furnish us your contact information that is your personal telephone and fax number for confidential URL regards mrs m sese seko + + +Subject: lowest rates available for term life insurance + +take a moment and fill out our online form to see the low rate you qualify for save up to NUMBER from regular rates smokers accepted URL representing quality nationwide carriers act now to easily remove your address from the list go to URL please allow NUMBER NUMBER hours for removal + + +Subject: central bank of nigeria foreign remittance + +dept tinubu square lagos nigeria email smith_j URL NUMBERth of august NUMBER attn president ceo strictly private business proposal i am mr johnson s abu the bills and exchange director at the foreignremittance department of the central bank of nigeria i am writingyou this letter to ask for your support and cooperation to carrying thisbusiness opportunity in my department we discovered abandoned the sumof us NUMBER NUMBER NUMBER NUMBER thirty seven million four hundred thousand unitedstates dollars in an account that belong to one of our foreign customers an american late engr john creek junior an oil merchant with the federal government of nigeria who died along with his entire family of a wifeand two children in kenya airbus aNUMBER NUMBER flight kqNUMBER in novemberNUMBER since we heard of his death we have been expecting his next of kin tocome over and put claims for his money as the heir because we cannotrelease the fund from his account unless someone applies for claims asthe next of kin to the deceased as indicated in our banking guidelines unfortunately neither their family member nor distant relative hasappeared to claim the said fund upon this discovery i and other officialsin my department have agreed to make business with you release the totalamount into your account as the heir of the fund since no one came forit or discovered either maintained account with our bank other wisethe fund will be returned to the bank treasury as unclaimed fund we have agreed that our ratio of sharing will be as stated thus NUMBER for you as foreign partner and NUMBER for us the officials in my department upon the successful completion of this transfer my colleague and i willcome to your country and mind our share it is from our NUMBER we intendto import computer accessories into my country as way of recycling thefund to commence this transaction we require you to immediately indicateyour interest by calling me or sending me a fax immediately on the abovetelefax and enclose your private contact telephone fax full nameand address and your designated banking co ordinates to enable us fileletter of claim to the appropriate department for necessary approvalsbefore the transfer can be made note also this transaction must be kept strictly confidential becauseof its nature nb please remember to give me your phone and fax no mr johnson smith abu irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + + +Subject: dear stuart + +are you tired of searching for love in all the wrong places find love now at URL URL browse through thousands of personals in your area join for free URL search e mail chat use URL to meet cool guys and hot girls go NUMBER on NUMBER or use our private chat rooms click on the link to get started URL find love now you have received this email because you have registerd with emailrewardz or subscribed through one of our marketing partners if you have received this message in error or wish to stop receiving these great offers please click the remove link above to unsubscribe from these mailings please click here URL + + diff --git a/tests/resources/smtp/antispam/classifier.test b/tests/resources/smtp/antispam/classifier.test new file mode 100644 index 0000000..d3a6d66 --- /dev/null +++ b/tests/resources/smtp/antispam/classifier.test @@ -0,0 +1,22 @@ +envelope_to hello@world.com +expect PROB_SPAM_HIGH + +Subject: save up to NUMBER on life insurance + +why spend more than you have to life quote savings ensuring your family s financial security is very important life quote savings makes buying life insurance simple and affordable we provide free access to the very best companies and the lowest rates life quote savings is fast easy and saves you money let us help you get started with the best values in the country on new coverage you can save hundreds or even thousands of dollars by requesting a free quote from lifequote savings our service will take you less than NUMBER minutes to complete shop and compare save up to NUMBER on all types of life insurance hyperlink click here for your free quote protecting your family is the best investment you ll ever make if you are in receipt of this email in error and or wish to be removed from our list hyperlink please click here and type remove if you reside in any state which prohibits e mail solicitations for insurance please disregard this email + + +envelope_to hello@world.com +expect PROB_HAM_HIGH + +Subject: can someone explain + +what type of operating system solaris is as ive never seen or used it i dont know wheather to get a server from sun or from dell i would prefer a linux based server and sun seems to be the one for that but im not sure if solaris is a distro of linux or a completely different operating system can someone explain kiall mac innes irish linux users group ilug URL URL for un subscription information list maintainer listmaster URL + +envelope_to hello@world.com +expect PROB_SPAM_UNCERTAIN + +Subject: Lorem ipsum dolor sit amet, consectetur adipiscing elit + +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + diff --git a/tests/resources/smtp/antispam/classifier_features.test b/tests/resources/smtp/antispam/classifier_features.test new file mode 100644 index 0000000..28ebc73 --- /dev/null +++ b/tests/resources/smtp/antispam/classifier_features.test @@ -0,0 +1,2751 @@ +From: bill@example.com +To: jdoe@example.com +Subject: TPS Report + +I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great. + +[ + { + "type": "word", + "value": "_allcaps" + }, + { + "type": "word", + "value": "asap" + }, + { + "type": "word", + "value": "could" + }, + { + "type": "word", + "value": "go" + }, + { + "type": "word", + "value": "great" + }, + { + "type": "word", + "value": "need" + }, + { + "type": "word", + "value": "report" + }, + { + "type": "word", + "value": "tps" + }, + { + "type": "sender", + "value": "bill@example.com" + }, + { + "type": "sender", + "value": "example.com" + } +] + +From: Hendrik +To: Harrie +Date: Sat, 11 Oct 2010 00:31:44 +0200 +Subject: One Two Three Four +Content-Type: multipart/mixed; boundary=AA + +This is a multi-part message in MIME format. +--AA +Content-Type: multipart/mixed; boundary=BB + +This is a multi-part message in MIME format. +--BB +Content-Type: text/plain; charset="us-ascii" + +This is the first message part containing +plain text. + +--BB +Content-Type: text/plain; charset="us-ascii" + +This is another plain text message part. + +--BB-- +This is the end of MIME multipart. + +--AA +Content-Type: text/html; charset="us-ascii" + + +This is a piece of HTML text. + + +--AA-- +This is the end of MIME multipart. + + +[ + { + "type": "word", + "value": "anoth" + }, + { + "type": "word", + "value": "contain" + }, + { + "type": "word", + "value": "first" + }, + { + "type": "word", + "value": "four" + }, + { + "type": "word", + "value": "html" + }, + { + "type": "word", + "value": "messag" + }, + { + "type": "word", + "value": "one" + }, + { + "type": "word", + "value": "part" + }, + { + "type": "word", + "value": "piec" + }, + { + "type": "word", + "value": "plain" + }, + { + "type": "word", + "value": "text" + }, + { + "type": "word", + "value": "three" + }, + { + "type": "word", + "value": "two" + }, + { + "type": "sender", + "value": "example.com" + }, + { + "type": "sender", + "value": "hendrik@example.com" + }, + { + "type": "mime_type", + "value": "multipart/mixed" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + } +] + +Content-Type: text/html; charset="utf-8" +Subject: IPs in HTML are not urls + + +Das System wurde um 01.01.1970 08:28:00 für die IP-Adresse +123.123.123.123 gesperrt.
+
+Der Besucher hat versucht, sich mit folgenden Daten anzumelden.
+Partner: 12345678
+Portal: IP-Sperre einsehen + + + +[ + { + "type": "word", + "value": "adress" + }, + { + "type": "word", + "value": "anzumeld" + }, + { + "type": "word", + "value": "besuch" + }, + { + "type": "word", + "value": "dat" + }, + { + "type": "word", + "value": "einseh" + }, + { + "type": "word", + "value": "folgend" + }, + { + "type": "word", + "value": "gesperrt" + }, + { + "type": "word", + "value": "html" + }, + { + "type": "word", + "value": "ip" + }, + { + "type": "word", + "value": "partn" + }, + { + "type": "word", + "value": "portal" + }, + { + "type": "word", + "value": "sperr" + }, + { + "type": "word", + "value": "syst" + }, + { + "type": "word", + "value": "url" + }, + { + "type": "word", + "value": "versucht" + }, + { + "type": "word", + "value": "wurd" + }, + { + "type": "number", + "code": [ + 105, + 2 + ] + }, + { + "type": "number", + "code": [ + 105, + 4 + ] + }, + { + "type": "number", + "code": [ + 105, + 8 + ] + }, + { + "type": "url", + "value": "!ip" + }, + { + "type": "url", + "value": "_example" + }, + { + "type": "url", + "value": "_php" + }, + { + "type": "url", + "value": "localhost.de" + }, + { + "type": "url", + "value": "www.localhost.de" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "html_anchor", + "href": "https" + } +] + +X-Spam-Result: DMARC_POLICY_ALLOW (-0.50), + TEST (0.0), + SOURCE_ASN_123 (1.00) +From: Client Services +To: user@domain.org +Subject: Tether Important Update ! +Content-Type: text/html +Content-Transfer-Encoding: quoted-printable + + + + + + + +3D"If + + +[ + { + "type": "word", + "value": "_allcaps" + }, + { + "type": "word", + "value": "_null" + }, + { + "type": "word", + "value": "browser" + }, + { + "type": "word", + "value": "cashback" + }, + { + "type": "word", + "value": "click" + }, + { + "type": "word", + "value": "import" + }, + { + "type": "word", + "value": "messag" + }, + { + "type": "word", + "value": "open" + }, + { + "type": "word", + "value": "pleas" + }, + { + "type": "word", + "value": "read" + }, + { + "type": "word", + "value": "reward" + }, + { + "type": "word", + "value": "tether" + }, + { + "type": "word", + "value": "updat" + }, + { + "type": "sender", + "value": "noreply@tetheer.com" + }, + { + "type": "sender", + "value": "tetheer.com" + }, + { + "type": "asn", + "number": [ + 0, + 0, + 0, + 123 + ] + }, + { + "type": "url", + "value": "metaskwap.online" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "html_image", + "src": "data" + }, + { + "type": "html_anchor", + "href": "https" + } +] + +From: "BBVA" +Reply-To: noreply@grupokonecta.net +Content-Type: multipart/alternative; charset="UTF-8"; boundary="b1_3d217f30a568faa9ce3dd7dc73399561" +Content-Transfer-Encoding: quoted-printable + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/plain; format=flowed; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Tarjeta de cr=C3=A9dito BBVA + +La tarjeta de cr=C3=A9dito para viajar con tus consumos +Pedila 100% online y empez=C3=A1 a disfrutar + +Conocer oferta +Un mundo de beneficios con las tarjetas de cr=C3=A9dito BBVA +compras en cuotas +Compras en cuotas +Pod=C3=A9s disfrutar hoy de los productos =E2=80=A8que quer=C3=A9s y pagarl= +os en cuotas +descuentos y reintegros +Descuentos y reintegros +Entretenimiento, gastronom=C3=ADa, farmacia, ropa =E2=80=A8y m=C3=A1s rubro= +s con promociones exclusivas +puntos bbva +Viajes con Puntos BBVA +Vuelos, alojamientos y mucho m=C3=A1s canjeando Puntos BBVA que sum=C3= +=A1s con tus compras +Conocer oferta +Descubr=C3=AD la tarjeta que mejor se adapta a vos +Todas las tarjetas + +Black + +Platinum + +Gold + +Internacional + +Todas las tarjetas +visa black +Tarjeta Visa Signature +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + + Conocer m=C3=A1s +mastercard black +Tarjeta Mastercard Black +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + Conocer m=C3=A1s +tarjeta platinum visa +Tarjeta Visa Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 170.000 USD +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta platinum mastercard +Tarjeta Mastercard Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 50.000 USD y 30.000 EUR +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta gold visa +Tarjeta Visa Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s +tarjeta gold mastercard +Tarjeta Mastercard Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $35.000 + + Conocer m=C3=A1s + +Tarjeta Visa Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +Tarjeta Mastercard Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + + + + +
+ + + + +
3D"CLICK
+
+ + + +--b1_3d217f30a568faa9ce3dd7dc73399561-- + + +[ + { + "type": "word", + "value": "_allcaps" + }, + { + "type": "word", + "value": "_null" + }, + { + "type": "word", + "value": "acces" + }, + { + "type": "word", + "value": "acumul" + }, + { + "type": "word", + "value": "adapt" + }, + { + "type": "word", + "value": "adicional" + }, + { + "type": "word", + "value": "aeropuert" + }, + { + "type": "word", + "value": "aloj" + }, + { + "type": "word", + "value": "aqu" + }, + { + "type": "word", + "value": "asistent" + }, + { + "type": "word", + "value": "atencion" + }, + { + "type": "word", + "value": "bbva" + }, + { + "type": "word", + "value": "benefici" + }, + { + "type": "word", + "value": "black" + }, + { + "type": "word", + "value": "cajer" + }, + { + "type": "word", + "value": "canj" + }, + { + "type": "word", + "value": "click" + }, + { + "type": "word", + "value": "cobertur" + }, + { + "type": "word", + "value": "compr" + }, + { + "type": "word", + "value": "conoc" + }, + { + "type": "word", + "value": "consult" + }, + { + "type": "word", + "value": "consum" + }, + { + "type": "word", + "value": "cost" + }, + { + "type": "word", + "value": "credit" + }, + { + "type": "word", + "value": "cuot" + }, + { + "type": "word", + "value": "descubr" + }, + { + "type": "word", + "value": "descuent" + }, + { + "type": "word", + "value": "disfrut" + }, + { + "type": "word", + "value": "efect" + }, + { + "type": "word", + "value": "empez" + }, + { + "type": "word", + "value": "entreten" + }, + { + "type": "word", + "value": "es" + }, + { + "type": "word", + "value": "esperando" + }, + { + "type": "word", + "value": "estaba" + }, + { + "type": "word", + "value": "eur" + }, + { + "type": "word", + "value": "exclus" + }, + { + "type": "word", + "value": "exterior" + }, + { + "type": "word", + "value": "extra" + }, + { + "type": "word", + "value": "extraccion" + }, + { + "type": "word", + "value": "farmaci" + }, + { + "type": "word", + "value": "gastronom" + }, + { + "type": "word", + "value": "gold" + }, + { + "type": "word", + "value": "hoy" + }, + { + "type": "word", + "value": "iacut" + }, + { + "type": "word", + "value": "ingres" + }, + { + "type": "word", + "value": "internacional" + }, + { + "type": "word", + "value": "la" + }, + { + "type": "word", + "value": "limit" + }, + { + "type": "word", + "value": "mastercard" + }, + { + "type": "word", + "value": "mejor" + }, + { + "type": "word", + "value": "mensual" + }, + { + "type": "word", + "value": "minim" + }, + { + "type": "word", + "value": "mund" + }, + { + "type": "word", + "value": "necesari" + }, + { + "type": "word", + "value": "ofert" + }, + { + "type": "word", + "value": "onlin" + }, + { + "type": "word", + "value": "oportunidad" + }, + { + "type": "word", + "value": "pag" + }, + { + "type": "word", + "value": "pedil" + }, + { + "type": "word", + "value": "personaliz" + }, + { + "type": "word", + "value": "platinum" + }, + { + "type": "word", + "value": "podes" + }, + { + "type": "word", + "value": "product" + }, + { + "type": "word", + "value": "promocion" + }, + { + "type": "word", + "value": "proteg" + }, + { + "type": "word", + "value": "punt" + }, + { + "type": "word", + "value": "que" + }, + { + "type": "word", + "value": "queres" + }, + { + "type": "word", + "value": "reintegr" + }, + { + "type": "word", + "value": "resolv" + }, + { + "type": "word", + "value": "rob" + }, + { + "type": "word", + "value": "rop" + }, + { + "type": "word", + "value": "rubr" + }, + { + "type": "word", + "value": "sal" + }, + { + "type": "word", + "value": "segur" + }, + { + "type": "word", + "value": "signatur" + }, + { + "type": "word", + "value": "sumas" + }, + { + "type": "word", + "value": "tarjet" + }, + { + "type": "word", + "value": "tod" + }, + { + "type": "word", + "value": "usd" + }, + { + "type": "word", + "value": "viaj" + }, + { + "type": "word", + "value": "vip" + }, + { + "type": "word", + "value": "vis" + }, + { + "type": "word", + "value": "vos" + }, + { + "type": "word", + "value": "vuel" + }, + { + "type": "number", + "code": [ + 105, + 1 + ] + }, + { + "type": "number", + "code": [ + 105, + 2 + ] + }, + { + "type": "number", + "code": [ + 105, + 3 + ] + }, + { + "type": "unicode_category", + "value": "Sc" + }, + { + "type": "sender", + "value": "grupokonecta.net" + }, + { + "type": "sender", + "value": "noreply@grupokonecta.net" + }, + { + "type": "url", + "value": "_aff" + }, + { + "type": "url", + "value": "_jpg" + }, + { + "type": "url", + "value": "i.imgur.com" + }, + { + "type": "url", + "value": "imgur.com" + }, + { + "type": "url", + "value": "leadsinbx.com" + }, + { + "type": "url", + "value": "track.leadsinbx.com" + }, + { + "type": "mime_type", + "value": "multipart/alternative" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + }, + { + "type": "html_image", + "src": "https" + }, + { + "type": "html_anchor", + "href": "http" + } +] + +From: Spammer Systems Iran +Subject: =?utf-8?b?2KfZgdiy2YjZhtmH4oCM2YfYp9uMINin2LPZhdin2LHYqtix2YXbjNmEIHw=?= + =?utf-8?b?INiq2YjYs9i52Ycg24zYp9mB2KrZhyDYqtmI2LPYtyDYotix2qnYpw==?= +Message-Id: +To: spam@target.org +Reply-To: Spammer Systems Iran +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +vEⓡ𝔂 𝔽𝕌Ňℕy ţ乇𝕏𝓣 + +=D8=A7=D9=81=D8=B2=D9=88=D9=86=D9=87=E2=80=8C=D9=87=D8=A7=DB=8C SpammerMail= + =D8=AA=D9=88=D8=B3=D8=B9=D9=87 =DB=8C=D8=A7=D9=81=D8=AA=D9=87 =D8=AA=D9=88= +=D8=B3=D8=B7 =D8=A2=D8=B1=DA=A9=D8=A7 + +=D8=B1=D8=A7=DB=8C=D8=A7=D9=86 =D8=B3=D8=A7=D9=85=D8=A7=D9=86=D9=87 =D8=A2= +=D8=B1=DA=A9=D8=A7 | =D8=AA=D9=85=D8=A7=D8=B3: 91300476-021 | =D8=A7=DB=8C= +=D9=85=DB=8C=D9=84: info@spammy.ir + +[Telegram] [Instagram] [LinkedIn] [Email] + + +[ + { + "type": "word", + "value": "email" + }, + { + "type": "word", + "value": "funny" + }, + { + "type": "word", + "value": "instagram" + }, + { + "type": "word", + "value": "linkedin" + }, + { + "type": "word", + "value": "spammermail" + }, + { + "type": "word", + "value": "telegram" + }, + { + "type": "word", + "value": "text" + }, + { + "type": "word", + "value": "very" + }, + { + "type": "word", + "value": "آرکا" + }, + { + "type": "word", + "value": "اسمارترمی" + }, + { + "type": "word", + "value": "افزونه" + }, + { + "type": "word", + "value": "ایمیل" + }, + { + "type": "word", + "value": "تماس" + }, + { + "type": "word", + "value": "توسط" + }, + { + "type": "word", + "value": "توسعه" + }, + { + "type": "word", + "value": "رایان" + }, + { + "type": "word", + "value": "سامانه" + }, + { + "type": "word", + "value": "های" + }, + { + "type": "word", + "value": "یافته" + }, + { + "type": "number", + "code": [ + 105, + 3 + ] + }, + { + "type": "number", + "code": [ + 105, + 8 + ] + }, + { + "type": "unicode_category", + "value": "Cf" + }, + { + "type": "unicode_category", + "value": "Sm" + }, + { + "type": "sender", + "value": "marketing@spammer.ir" + }, + { + "type": "sender", + "value": "spammer.ir" + }, + { + "type": "email", + "value": "info@spammy.ir" + }, + { + "type": "email", + "value": "spammy.ir" + }, + { + "type": "mime_type", + "value": "text/plain" + } +] + +Received: from localhost ([217.61.8.72]) + by Consip with ESMTP + id PMLhve2ETFdIAPMLyvhh0c; Sat, 29 Nov 2025 15:55:14 +0100 +Received: from zspmta-mint02.ad.aruba.it ([127.0.0.1]) + by localhost (zspmta-mint02.ad.aruba.it [127.0.0.1]) (amavis, port 10026) + with ESMTP id UV6fqMysWqKE; Sat, 29 Nov 2025 15:55:13 +0100 (CET) +Received: from zspmbx-mint11.ad.aruba.it (unknown [10.202.133.51]) + by zspmta-mint02.ad.aruba.it (Postfix) with ESMTP id 3042B120F77; + Sat, 29 Nov 2025 15:54:59 +0100 (CET) +Date: Sat, 29 Nov 2025 15:54:59 +0100 (CET) +From: gianfranco.mangini@interno.it +Reply-To: "Hr. Charles Jackson Jr." +Message-ID: <1933878358.10239097.1764428099117.JavaMail.zimbra@interno.it> +Subject: +Content-Type: multipart/alternative; + boundary="=_bf54163b-f3b6-421f-bc9d-b64439167a39" + +--=_bf54163b-f3b6-421f-bc9d-b64439167a39 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + +Hvorfor har du ikke modtaget donationen p=C3=A5 =E2=82=AC955.000,00 fra hr.= + Charles Jackson Jr.? Bankdirekt=C3=B8ren informerede mig i g=C3=A5r om, at= + en af =E2=80=8B=E2=80=8Bmodtagerne ikke havde gjort krav p=C3=A5 donatione= +n. Efter at have gennemg=C3=A5et mine optegnelser opdagede jeg, at du var b= +landt de ber=C3=B8rte, og jeg er meget ked af at h=C3=B8re dette. Bem=C3=A6= +rk venligst, at der ikke kr=C3=A6ves nogen betaling; et simpelt bekr=C3=A6f= +telsesstempel er alt, hvad der skal til for at pengene kan frigives og kred= +iteres din bankkonto inden for 24 timer.=20 + +Bem=C3=A6rk: For yderligere information og for at sikre, at din donation kr= +editeres inden for 24 timer, anbefaler jeg, at du sender mig dine oplysning= +er med det samme via e-mail til ferassutti34@gmail.com=20 + +Jeg =C3=B8nsker dig en velsignet m=C3=A5ned med stor succes.=20 +Hr. Charles Jackson Jr.=20 + +--=_bf54163b-f3b6-421f-bc9d-b64439167a39 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +


Hvorfor har du ikke modtaget donationen p=C3=A5 =E2=82=AC9= +55.000,00 fra hr. Charles Jackson Jr.? Bankdirekt=C3=B8ren informerede mig = +i g=C3=A5r om, at en af =E2=80=8B=E2=80=8Bmodtagerne ikke havde gjort krav = +p=C3=A5 donationen. Efter at have gennemg=C3=A5et mine optegnelser opdagede= + jeg, at du var blandt de ber=C3=B8rte, og jeg er meget ked af at h=C3=B8re= + dette. Bem=C3=A6rk venligst, at der ikke kr=C3=A6ves nogen betaling; et si= +mpelt bekr=C3=A6ftelsesstempel er alt, hvad der skal til for at pengene kan= + frigives og krediteres din bankkonto inden for 24 timer.

Bem=C3=A6r= +k: For yderligere information og for at sikre, at din donation krediteres i= +nden for 24 timer, anbefaler jeg, at du sender mig dine oplysninger med det= + samme via e-mail til ferassutti34@gmail.com

Jeg =C3=B8nsker dig en = +velsignet m=C3=A5ned med stor succes.
Hr. Charles Jackson Jr.
+--=_bf54163b-f3b6-421f-bc9d-b64439167a39-- + +[ + { + "type": "word", + "value": "anbefal" + }, + { + "type": "word", + "value": "bankdirektør" + }, + { + "type": "word", + "value": "bankkonto" + }, + { + "type": "word", + "value": "bekræftelsesstem" + }, + { + "type": "word", + "value": "bemærk" + }, + { + "type": "word", + "value": "berørt" + }, + { + "type": "word", + "value": "betaling" + }, + { + "type": "word", + "value": "bland" + }, + { + "type": "word", + "value": "charl" + }, + { + "type": "word", + "value": "din" + }, + { + "type": "word", + "value": "donation" + }, + { + "type": "word", + "value": "e" + }, + { + "type": "word", + "value": "frigiv" + }, + { + "type": "word", + "value": "gennemgå" + }, + { + "type": "word", + "value": "gjort" + }, + { + "type": "word", + "value": "går" + }, + { + "type": "word", + "value": "hr" + }, + { + "type": "word", + "value": "hvorfor" + }, + { + "type": "word", + "value": "hør" + }, + { + "type": "word", + "value": "ind" + }, + { + "type": "word", + "value": "inform" + }, + { + "type": "word", + "value": "information" + }, + { + "type": "word", + "value": "jackson" + }, + { + "type": "word", + "value": "jr" + }, + { + "type": "word", + "value": "kan" + }, + { + "type": "word", + "value": "ked" + }, + { + "type": "word", + "value": "krav" + }, + { + "type": "word", + "value": "kredit" + }, + { + "type": "word", + "value": "kræv" + }, + { + "type": "word", + "value": "mail" + }, + { + "type": "word", + "value": "modtag" + }, + { + "type": "word", + "value": "måned" + }, + { + "type": "word", + "value": "nog" + }, + { + "type": "word", + "value": "opdaged" + }, + { + "type": "word", + "value": "oplysning" + }, + { + "type": "word", + "value": "optegn" + }, + { + "type": "word", + "value": "peng" + }, + { + "type": "word", + "value": "sam" + }, + { + "type": "word", + "value": "send" + }, + { + "type": "word", + "value": "sikr" + }, + { + "type": "word", + "value": "simpelt" + }, + { + "type": "word", + "value": "stor" + }, + { + "type": "word", + "value": "suc" + }, + { + "type": "word", + "value": "tim" + }, + { + "type": "word", + "value": "velsign" + }, + { + "type": "word", + "value": "ven" + }, + { + "type": "word", + "value": "via" + }, + { + "type": "word", + "value": "yder" + }, + { + "type": "word", + "value": "ønsk" + }, + { + "type": "number", + "code": [ + 105, + 2 + ] + }, + { + "type": "number", + "code": [ + 105, + 3 + ] + }, + { + "type": "unicode_category", + "value": "Cf" + }, + { + "type": "unicode_category", + "value": "Sc" + }, + { + "type": "sender", + "value": "ferassutti34@gmail.com" + }, + { + "type": "sender", + "value": "gianfranco.mangini@interno.it" + }, + { + "type": "sender", + "value": "gmail.com" + }, + { + "type": "sender", + "value": "interno.it" + }, + { + "type": "hostname", + "value": "aruba.it" + }, + { + "type": "hostname", + "value": "interno.it" + }, + { + "type": "hostname", + "value": "zspmbx-mint11.ad.aruba.it" + }, + { + "type": "hostname", + "value": "zspmta-mint02.ad.aruba.it" + }, + { + "type": "mime_type", + "value": "multipart/alternative" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + } +] + +Delivered-To: mcfadden@domain.com +Received: from gamma.stellaryx.space (unknown [85.120.227.61] (AS6718 NAV COMMUNICATIONS SRL, RO)) + by mail.stalw.art (Stalwart SMTP) with ESMTP id 3D6018102E32AFD; + Tue, 25 Nov 2025 14:56:37 +0000 +Return-Path: <102356-235606-568806-22158-mcfadden=domain.com@mail.stellaryx.space> +Content-Type: multipart/alternative; boundary="4521ddb80d67f83dc7585cae40234a09_39856_8ade6" +Date: Tue, 25 Nov 2025 15:56:05 +0100 +From: "ZenFluff" +Reply-To: "ZenFluff" +Subject: Sleep better with FluffCo +To: +Message-ID: + +--4521ddb80d67f83dc7585cae40234a09_39856_8ade6 +Content-Type: text/plain; +Content-Transfer-Encoding: 8bit + +Sleep better with FluffCo + +http://stellaryx.space/Bm6NYOrhicX--9bFn47T2mFlb-Soxhs-FJ8RCnM_SXJyHmebLw + +http://stellaryx.space/Y46ntHIiWyxTreKwOcyT4txY9f-M-eCwfHuhx0SMoyGjXy1iuA + +--4521ddb80d67f83dc7585cae40234a09_39856_8ade6 +Content-Type: text/html; +Content-Transfer-Encoding: 8bit + + + + + Newsletter + + + +
+
Sleep better with FluffCo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +--4521ddb80d67f83dc7585cae40234a09_39856_8ade6-- + + +[ + { + "type": "word", + "value": "better" + }, + { + "type": "word", + "value": "fluffco" + }, + { + "type": "word", + "value": "sleep" + }, + { + "type": "sender", + "value": "fluffcopartner@stellaryx.space" + }, + { + "type": "sender", + "value": "fluffcopromo@stellaryx.space" + }, + { + "type": "sender", + "value": "stellaryx.space" + }, + { + "type": "url", + "value": "_jpg" + }, + { + "type": "url", + "value": "_sxjyhmeblw" + }, + { + "type": "url", + "value": "stellaryx.space" + }, + { + "type": "url", + "value": "www.stellaryx.space" + }, + { + "type": "hostname", + "value": "gamma.stellaryx.space" + }, + { + "type": "hostname", + "value": "stellaryx.space" + }, + { + "type": "mime_type", + "value": "multipart/alternative" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + }, + { + "type": "html_image", + "src": "http" + }, + { + "type": "html_anchor", + "href": "http" + } +] + +Delivered-To: mcfadden@domain.com +Received: from cache.agelessknees.za.com (unknown [193.36.60.184] (AS210107 PLUSWEB SUNUCU INTERNET HIZMETLERI TICARET LIMITED SIRKETI, TR)) + by mail.stalw.art (Stalwart SMTP) with ESMTP id 3CB95BA83AFB5B8; + Sun, 9 Nov 2025 10:25:14 +0000 +Return-Path: <2993-2338-35418-95-mcfadden=domain.com@mail.agelessknees.za.com> +Content-Type: multipart/alternative; boundary="f2a4125f95dc25c4cd4f09657da6c1b1" +Date: Sun, 9 Nov 2025 02:05:37 -0800 +From: "ENLARGED PROSTATE" +Reply-To: "ENLARGED PROSTATE" +Subject: 90% Success Rate: Shrink Your Prostate by 68%... +To: +Message-ID: <8btb35y8w3xurryz-6647pok20gxdwmew-8a5a@agelessknees.za.com> + +--f2a4125f95dc25c4cd4f09657da6c1b1 +Content-Type: text/plain; +Content-Transfer-Encoding: 8bit + +http://agelessknees.za.com/WEIPdYsx_Fz316dLbPRtpgbW8tLjN6VeuG_xqNr-08jn + + +http://[Unsubscribe]] + +--f2a4125f95dc25c4cd4f09657da6c1b1 +Content-Type: text/html; +Content-Transfer-Encoding: 8bit + + + + + +
+
+Urologists are in complete shock after this classified 1970 study  has been
+accidentally released to the public.
 
+ 
+In the study, almost 90% of the men emptied their bladders fully…
+and stopped nighttime pee trips!
 
+ 
+And it’s because of a bizarre “Brazilian Jelly”... 
+
+click here to watch video, Picture
+
+Which not only helps you pee like a racehorse, but it also shrinks your
+prostate size by 68%,
almost overnight. 
+ 
+So, as you can imagine, this prostate-shrinking method
+is spreading like wildfire..
 
+ 
+And that’s why over 45,000 men have managed to
+get rid of prostate problems…
 
+ 

+Without painful medical procedures or Rapaflo, Uroxatral, and other toxic medications. So, while this video is still up…
+ 
+So, while this video is still up… 
+ 
+[WATCH NOW]
+
+to see how this Brazilian Jelly can help shrink your
+enlarged prostate as well.

+
+
+
+
+
+
+
+
+
+
+unsubscribe

+1770 Walnut Hill Drive Dayton, OH 45406 + + +--f2a4125f95dc25c4cd4f09657da6c1b1-- + + +[ + { + "type": "word", + "value": "_allcaps" + }, + { + "type": "word", + "value": "accident" + }, + { + "type": "word", + "value": "almost" + }, + { + "type": "word", + "value": "also" + }, + { + "type": "word", + "value": "bizarr" + }, + { + "type": "word", + "value": "bladder" + }, + { + "type": "word", + "value": "brazilian" + }, + { + "type": "word", + "value": "classifi" + }, + { + "type": "word", + "value": "click" + }, + { + "type": "word", + "value": "complet" + }, + { + "type": "word", + "value": "dayton" + }, + { + "type": "word", + "value": "drive" + }, + { + "type": "word", + "value": "empti" + }, + { + "type": "word", + "value": "enlarg" + }, + { + "type": "word", + "value": "fulli" + }, + { + "type": "word", + "value": "get" + }, + { + "type": "word", + "value": "help" + }, + { + "type": "word", + "value": "hill" + }, + { + "type": "word", + "value": "http" + }, + { + "type": "word", + "value": "imagin" + }, + { + "type": "word", + "value": "jelli" + }, + { + "type": "word", + "value": "like" + }, + { + "type": "word", + "value": "manag" + }, + { + "type": "word", + "value": "medic" + }, + { + "type": "word", + "value": "men" + }, + { + "type": "word", + "value": "method" + }, + { + "type": "word", + "value": "nighttim" + }, + { + "type": "word", + "value": "oh" + }, + { + "type": "word", + "value": "overnight" + }, + { + "type": "word", + "value": "pain" + }, + { + "type": "word", + "value": "pee" + }, + { + "type": "word", + "value": "pictur" + }, + { + "type": "word", + "value": "problem" + }, + { + "type": "word", + "value": "procedur" + }, + { + "type": "word", + "value": "prostat" + }, + { + "type": "word", + "value": "public" + }, + { + "type": "word", + "value": "racehors" + }, + { + "type": "word", + "value": "rapaflo" + }, + { + "type": "word", + "value": "rate" + }, + { + "type": "word", + "value": "releas" + }, + { + "type": "word", + "value": "rid" + }, + { + "type": "word", + "value": "see" + }, + { + "type": "word", + "value": "shock" + }, + { + "type": "word", + "value": "shrink" + }, + { + "type": "word", + "value": "size" + }, + { + "type": "word", + "value": "spread" + }, + { + "type": "word", + "value": "still" + }, + { + "type": "word", + "value": "stop" + }, + { + "type": "word", + "value": "studi" + }, + { + "type": "word", + "value": "success" + }, + { + "type": "word", + "value": "toxic" + }, + { + "type": "word", + "value": "trip" + }, + { + "type": "word", + "value": "unsubscrib" + }, + { + "type": "word", + "value": "urologist" + }, + { + "type": "word", + "value": "uroxatr" + }, + { + "type": "word", + "value": "video" + }, + { + "type": "word", + "value": "walnut" + }, + { + "type": "word", + "value": "watch" + }, + { + "type": "word", + "value": "well" + }, + { + "type": "word", + "value": "wildfir" + }, + { + "type": "word", + "value": "without" + }, + { + "type": "number", + "code": [ + 105, + 2 + ] + }, + { + "type": "number", + "code": [ + 105, + 3 + ] + }, + { + "type": "number", + "code": [ + 105, + 4 + ] + }, + { + "type": "number", + "code": [ + 105, + 5 + ] + }, + { + "type": "sender", + "value": "agelessknees.za.com" + }, + { + "type": "sender", + "value": "prostate@agelessknees.za.com" + }, + { + "type": "url", + "value": "_png" + }, + { + "type": "url", + "value": "_weipdysx" + }, + { + "type": "url", + "value": "agelessknees.za.com" + }, + { + "type": "hostname", + "value": "agelessknees.za.com" + }, + { + "type": "hostname", + "value": "cache.agelessknees.za.com" + }, + { + "type": "mime_type", + "value": "multipart/alternative" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + }, + { + "type": "html_image", + "src": "http" + }, + { + "type": "html_anchor", + "href": "http" + } +] + +Delivered-To: hello@stalw.art +Received: from mail-wm1-x32d.google.com (mail-wm1-x32d.google.com [2a00:1450:4864:20::32d] (AS15169 Google LLC)) + (using TLSv1.3 with cipher TLS13_AES_256_GCM_SHA384) + by mail.stalw.art (Stalwart SMTP) with ESMTPS id 3BBB57D01CB793C; + Wed, 15 Oct 2025 18:31:22 +0000 +Return-Path: +Received: by mail-wm1-x32d.google.com with SMTP id 5b1f17b1804b1-4710683a644so7830385e9.0 + for ; Wed, 15 Oct 2025 11:31:19 -0700 (PDT) +Received: from 52669349336 named unknown by gmailapi.google.com with HTTPREST; + Wed, 15 Oct 2025 14:31:17 -0400 +Received: from 52669349336 named unknown by gmailapi.google.com with HTTPREST; + Wed, 15 Oct 2025 14:31:16 -0400 +MIME-Version: 1.0 +Sender: Yash from SpamTest +From: Yash from SpamTest +Reply-To: yashbansal@spamtest.com +Date: Wed, 15 Oct 2025 14:31:17 -0400 +Message-ID: +Subject: SpamTest Open-Source Sponsorships for Stalwart +To: Hello +Content-Type: multipart/alternative; boundary="000000000000d8b207064136b41e" + +--000000000000d8b207064136b41e +Content-Type: text/plain; charset="UTF-8" + +Hi Team, + +I'm Yash from SpamTest, a GenAI-powered quality engineering platform. +We've been following the excellent work you're doing with Stalwart and +would like to support your project through our Open Source Program. + +What we're offering: + + - Free SpamTest licenses for your testing infrastructure + - Financial sponsorship for your project + - Co-marketing initiative to amplify your project's reach + +In return, we'd appreciate featuring the SpamTest logo in the ReadMe file +and under your sponsors section. + +Would you be interested in a quick call to discuss how we can support? +Here's my Calendly: https://calendly.com/yashbansal-spamtest/ + + +Regards, +Yash +[image: beacon] + +--000000000000d8b207064136b41e +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +
= +Hi Team,

I'= +;m Yash from SpamTest, a=C2=A0GenAI-powered quality engineering platform.= + We've been following the excellent work you're doing with Stalwart= + and would like to support your project through our Open Source Program.

What we're o= +ffering:
  • Free SpamTest licenses for y= +our testing infrastructure
  • Financial sponsorship for your project
  • Co-marketing initiative to amplify your project's reach= +
In return, we'd app= +reciate featuring the SpamTest logo in the ReadMe file and under your spo= +nsors section.

Would you be interested in a quick call to discuss how we can support? Her= +e's my Calendly: https:/= +/calendly.com/yashbansal-spamtest/

Regards,
Yash
+3D"beacon" + +--000000000000d8b207064136b41e-- + + +[ + { + "type": "word", + "value": "amplifi" + }, + { + "type": "word", + "value": "appreci" + }, + { + "type": "word", + "value": "beacon" + }, + { + "type": "word", + "value": "calend" + }, + { + "type": "word", + "value": "call" + }, + { + "type": "word", + "value": "co" + }, + { + "type": "word", + "value": "discuss" + }, + { + "type": "word", + "value": "engin" + }, + { + "type": "word", + "value": "excel" + }, + { + "type": "word", + "value": "featur" + }, + { + "type": "word", + "value": "file" + }, + { + "type": "word", + "value": "financi" + }, + { + "type": "word", + "value": "follow" + }, + { + "type": "word", + "value": "free" + }, + { + "type": "word", + "value": "genai" + }, + { + "type": "word", + "value": "hi" + }, + { + "type": "word", + "value": "imag" + }, + { + "type": "word", + "value": "infrastructur" + }, + { + "type": "word", + "value": "initi" + }, + { + "type": "word", + "value": "interest" + }, + { + "type": "word", + "value": "licens" + }, + { + "type": "word", + "value": "like" + }, + { + "type": "word", + "value": "logo" + }, + { + "type": "word", + "value": "market" + }, + { + "type": "word", + "value": "offer" + }, + { + "type": "word", + "value": "open" + }, + { + "type": "word", + "value": "platform" + }, + { + "type": "word", + "value": "power" + }, + { + "type": "word", + "value": "program" + }, + { + "type": "word", + "value": "project" + }, + { + "type": "word", + "value": "qualiti" + }, + { + "type": "word", + "value": "quick" + }, + { + "type": "word", + "value": "reach" + }, + { + "type": "word", + "value": "readm" + }, + { + "type": "word", + "value": "regard" + }, + { + "type": "word", + "value": "return" + }, + { + "type": "word", + "value": "section" + }, + { + "type": "word", + "value": "sourc" + }, + { + "type": "word", + "value": "spamtest" + }, + { + "type": "word", + "value": "sponsor" + }, + { + "type": "word", + "value": "sponsorship" + }, + { + "type": "word", + "value": "stalwart" + }, + { + "type": "word", + "value": "support" + }, + { + "type": "word", + "value": "team" + }, + { + "type": "word", + "value": "test" + }, + { + "type": "word", + "value": "work" + }, + { + "type": "word", + "value": "would" + }, + { + "type": "word", + "value": "yash" + }, + { + "type": "unicode_category", + "value": "Sm" + }, + { + "type": "sender", + "value": "spamtest.com" + }, + { + "type": "sender", + "value": "yashbansal@spamtest.com" + }, + { + "type": "url", + "value": "calendly.com" + }, + { + "type": "url", + "value": "spamtest-dot-yamm-track.appspot.com" + }, + { + "type": "hostname", + "value": "gmail.com" + }, + { + "type": "hostname", + "value": "mail.gmail.com" + }, + { + "type": "mime_type", + "value": "multipart/alternative" + }, + { + "type": "mime_type", + "value": "text/html" + }, + { + "type": "mime_type", + "value": "text/plain" + }, + { + "type": "html_image", + "src": "https" + }, + { + "type": "html_anchor", + "href": "https" + } +] + +Delivered-To: hello@stalw.art +Received: from Beijing--------chsi.com.cn (unknown [182.107.82.163] (AS4134 Chinanet, CN)) + by mail.stalw.art (Stalwart SMTP) with ESMTP id 3D6F265DB441EFD; + Thu, 27 Nov 2025 02:01:35 +0000 +Received-SPF: none (mail.stalw.art: no SPF records found for hello-------锟斤拷锟斤拷------kefu@beijing--------chsi.com.cn) + receiver=mail.stalw.art; client-ip=182.107.82.163; envelope-from="hello-------锟斤拷锟斤拷------kefu@beijing--------chsi.com.cn"; helo=Beijing--------chsi.com.cn; +Return-Path: +Message-ID: <187bbaa5967a07c4.15a3b47be71017b4.d92ade25f52781da@mail.stalw.art> +From: =?GB2312?B?zOGwzr36yf2439C9x+HLyciruOO2qDEwOjAxOjM0?= + +Subject: + =?GB2312?B?0afA+tGnzrvLq9akyKu5+rbAvNLL2bDssb6/xsu2yr+yqcq/ICC547jm?= AD hello +To: hello@stalw.art +Content-Type: multipart/mixed; + boundary="=_NextPart_2rfkindysadvnqw3nerasdf";charset="GB2312" +MIME-Version: 1.0 +Date: Thu, 27 Nov 2025 10:01:37 +0800 + +This is a multi-part message in MIME format + +--=_NextPart_2rfkindysadvnqw3nerasdf +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +10:01:34 hello AD + +--=_NextPart_2rfkindysadvnqw3nerasdf +Content-Type: application/octet-stream; + name="独家速办学历学位 学信网永久查询 本科硕博士高薪晋升职称轻松快速全搞定.txt" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="独家速办学历学位 学信网永久查询 本科硕博士高薪晋升职称轻松快速全搞定.txt" + +5YWo5Zu954us5a625p2D5aiB5Luj5Yqe77yB77yBDQoNCueLrOWutuWFqOWbvemrmOagoemZouez +u+S8mOi0qOi1hOa6kOa4oOmBk++8jOWFqOWbveeLrOWutuadg+WogeS7o+WKnu+8gQ0KDQrpq5jo +lqrlt6XkvZzvvIzkvJjljprogYzkvY3vvIzmj5Dmi5TmmYvljYfvvIzogYznp7Dor4TlrprvvIzm +iLflj6Plip7nkIbvvIzlh7rlm73np7vmsJEg6L275p2+5YWo5pCe5a6a77yB77yBDQoNCuS9oOaY +r+WQpuWboOS4uuayoeacieWkp+WtpuWtpuWOhuWSjOWtpuS9jeiAjOaJvuS4jeWIsOS4gOS7veeQ +huaDs+W3peS9nO+8jOaIluiAheWwveeuoeS9oOWcqOWunumZheW3peS9nOS4reenr+e0r+S6huS4 +sOWvjOe7j+mqjOWNtOWboOayoeacieWtpuWOhuWtpuS9jeivgeS5puiAjOWkseWOu+aPkOaLlOaZ +i+WNh+eahOacuuS8mj8g5oul5pyJ5LiA5Liq5aSn5a2m5a2m5Y6G77yI5a2m5L2N77yJ77yM56Gu +5L+d5L2g5Zyo5bel5L2c5LqL5Lia5LiK55qE5b+r6YCf5oiQ5Yqf77yBDQoNCuS4uuWuouaIt+in +o+WGs+WPkeWxleeTtumiiOacn+mXrumimO+8jOW/q+mAn+i9u+advuWunueOsOiBjOWKoeWNh+i/ +ge+8jOiBjOensOivhOWumuWyl+S9jeaZi+WNh++8jOWkh+WPl+engeS8geWkluS8geeMjuWktOaO +qOW0h++8jOaKlei1hOS6juWtpuWOhuWtpuS9jeS6p+eUn+eahOWbnuaKpeWSjOS7t+WAvOS5i+mr +mOi/nOi2heS7u+S9leaKlei1hOWTgeenje+8jOS4lOS4gOasoeaKlei1hOe7iOi6q+WPl+ebiuWP +r+aMgee7reWPkeWxle+8ge+8geaXouWPr+S7peeri+erv+ingeW9seWcqOiBjOWcuuWVhuWcuuWm +gumxvOW+l+awtO+8jOS5n+WPr+S7peS4uuS7iuWQjueahOaKpeiAg+WNh+i/geaPkOS+m+WdmuWu +nueahOWfuuehgO+8ge+8gQ0KDQrkvaDkuLrmsqHmnInlrabljobmib7kuI3liLDlpb3lt6XkvZzn +g6bmgbzlkJc/6L+Y5Zug5Li65rKh5pyJ5a2m5Y6G5peg5rOV5aSn5bmF5o+Q6auY5b6F6YGH5pS2 +5YWl6ICM5b+n6JmR5ZCX77yf6L+Y5Li65rKh5pyJ5q2j6KeE5aSn5a2m5a2m5Y6G5a2m5L2N6ICM +5peg5rOV5a6e546w6IGM56ew6K+E5a6a77yM5o+Q5ouU6YeN55So77yM5pmL57qn5Yqg6Jaq6ICM +54Om5oG85ZCX77yfIOWtpuWOhuaUueWPmOWRvei/kOWIm+mAoOS7t+WAvO+8ge+8geacrOWFrOWP +uOWPr+WKqeS9oOaIkOWKn+i+ieeFjO+8ge+8gQ0KDQrmnIDmnYPlqIHni6zlrrblnoTmlq3lhajl +m73pq5jmoKHpmaLns7votYTmupDmuKDpgZPvvIzlr7nmsYLogYzmmYvljYfot7Pmp73ljYfogYzm +tqjolqrmj5Dmi5Tpg73og73otbfliLDmnoHlhbboh7PlhbPph43opoHnmoTkvZznlKjvvIzku6Pn +kIbllYbliqDnm5/lvoXpgYfkvJjljprlm57miqXkuLDljprvvIHvvIHliJvpgKDku7flgLzov5zo +v5znianotoXmiYDlgLzvvIznu4jouqvlj5fnm4rvvIwg5pys56eR5Y2H56GV5aOr77yM56GV5aOr +5Y2H5Y2a5aOr77yM6IGM56ew5b6F6YGH57qn5Yir5Lmf6YO955u45bqU5b+r6YCf5o+Q5Y2H77yB +77yBDQoNCuWtpuS/oee9kemmlumhteacgOW6leagj+S4gOagj+KAnOiBlOezu+aIkeS7rOKAneS4 +iuacieWcsOWbvuaMh+W8leWcsOWdgOS7peWPiueUteivnemCrueuse+8jOWtpuS/oeWFrOWPuOWP +keW4g+W5v+WRiueahOmCrueuseaYr0BjaHNpLmNvbS5jbuWfn+WQjeWQjue8gOeahOWtpuS/oee9 +keacjeWKoeWZqO+8jOWbnuWkjeWNj+iuruS7peWPiuaUr+S7mOWuneaIlumTtuihjOi0puWPt+ea +hOmCrueuseaYr+WtpuS/oeWFrOWPuOWvueWkluWFrOW8gOeahOS8geS4mumCrueusWtlZnVAY2hz +aS5jb20uY24NCg0K5YWo5Zu954us5a625bi45bm05Yqe55CGOg0KDQrlhajml6XliLbnu5/mi5vp +h43ngrnpmaLmoKE5ODUgMjExIOWPjOS4gOa1gSDlhajlm73pq5jnrYnpmaLmoKHlrabljoblrabk +vY3or4HkuabvvIzmnKznp5Hlrabljoblj4zor4HlkKvlrablo6vlrabkvY3vvIjlt6Xlrablrabl +o6ss55CG5a2m5a2m5aOrLOWGnOWtpuWtpuWjqyznrqHnkIblrablrablo6ss57uP5rWO5a2m5a2m +5aOrLOWMu+WtpuWtpuWjqyzmlZnogrLlrablrablo6ss5paH5a2m5a2m5aOrLOazleWtpuWtpuWj +q+WtpuS9jeetie+8iQ0K5Y+M6K+B5YWo5pel5Yi257uf5oub56GV5aOrL+WcqOiBjOehleWjq+eg +lOeptueUn++8iOW3peWVhueuoeeQhuehleWjq++8iE1CQe+8ieaVmeiCsuehleWjq++8iE1FQe+8 +ieazleW+i+ehleWjq++8iEpN77yJ6YeR6J6N566h55CG56GV5aOrRk1CQe+8jOazleWtpuehleWj +q++8jOWFrOWFseeuoeeQhuehleWjq01QQe+8jOWFrOWFseWNq+eUn+ehleWjq++8jOW3peeoi+eh +leWjq++8jOS8muiuoeehleWjq01QQWNj77yM5bu6562R5a2m56GV5aOr77yM5Li05bqK5Yy75a2m +56GV5aOr77yM6Im65pyv56GV5aOrTUZB562J77yJ5YWo5pel5Yi25oiW5Zyo6IGM5Y+M6K+B5Y2a +5aOr5LiT5Lia5Z6L5Y2a5aOr77ya5bel56iL5Y2a5aOr77yIRW5nRO+8ieWMu+WtpuWNmuWjq++8 +iE1E77yJ5pWZ6IKy5Y2a5aOr77yIRWRE77yJ5a2m5pyv5Z6L5Y2a5aOr77ya5aaC5ZOy5a2m5Y2a +5aOrUGhE77yI57uP5rWO5a2m5Y2a5aOr77yMIOeuoeeQhuWtpuWNmuWjq++8jOS8muiuoeWtpuWN +muWjq+ivreiogOWtpuWNmuWjq++8jOS4tOW6iuWMu+WtpuWNmuWjq++8jOW3peWVhueuoeeQhuWN +muWjq++8jOmHkeiejeWtpuWNmuWjq+W/g+eQhuWtpuWNmuWjq++8jOekvuS8muWtpuWNmuWjq++8 +jOaWsOmXu+WtpuWNmuWjq++8jOazleWtpuWNmuWjq++8jOWFrOWFseeuoeeQhuWNmuWjq++8jOaV +meiCsuWtpuWNmuWjq++8jOiuoeeul+acuuWNmuWjq++8jOaWh+WtpuWNmuWjq+etie+8iQ0K5a2m +5L2N6K+B5Lmm77yI5a2m5aOr77yM56GV5aOrLCDljZrlo6vvvIkNCg0K5Z2H5o+Q5L6b5a6M5aSH +5a2m57GN5qGj5qGI5oiQ57up5Y2V77yM5aSn5a2m6Iux6K+t5Zub5YWt57qnY2V0NCxjZXQ25ZCI +5qC85oiQ57up5Y2V6K+B5piO77yM5rS+6YGj6K+B77yM55S15a2Q5rOo5YaM5YWl5a2m5L+h572R +5pWw5o2u5bqT77yM57uI6Lqr5rC45LmF5Y+v5p+l77yM5Y+v57uP5YWs6K+B5aSE5YWs6K+B77yM +56Gu5L+d6aG65Yip6YCa6L+H5ZCE56eN5b2i5byP55qE5a6h5p+l6aqM6K+B77yM5Y+v55So5LqO +5oql6ICD5YWs5Yqh5ZGY77yM5ZCE57G76LWE5qC86ICD6K+V77yM6ICD56CU77yM5Y2H6IGM77yM +6K+E6IGM56ew562J55So6YCU77yM5omA5Yqe6K+B5Lmm55Sx5YWo5Zu95ZCE5Zyw5Zu956uL5YWs +5Yqe6Zmi5qCh5YaF6YOo5rig6YGT5YWz57O75Yqe55CG77yM5qyi6L+O6ZW/5pyf5Luj55CG5ZCI +5L2c5Zue5oql5Liw5Y6a77yB5YWo5Zu96L+R55m+5a625Luj55CG5py65p6E77yM5Lia5Yqh6YGN +5biD5YWo5Zu977ya5YyX5LqsIOS4iua1tyDmt7HlnLMg5aSp5rSlIOadreW3niAg5Y2X5LqsICDl +jqbpl6ggIOW5v+W3niAg5q2m5rGJICDmiJDpg70gIOmDkeW3niDkuJzojp4gIOa1juWNlyAg56aP +5bee562J5Zyw5Yy677yB77yB6LWE5rqQ5oyB57ut5aKe6ZW/77yBDQoNCuacrOWFrOWPuOS4muWK +oemAgueUqOS6juWQhOexu+mrmOerr+WuouaIt+e+pCjkuJPkuJrlrp7ot7Xog73lipvlvLog5LyB +5Lia5Li75ZKM5ZCE6KGM5Lia6YeR6aKG562JKeeahOWtpuWOhuWtpuS9jeWumuWQkeS8mOWMluaV +tOWQiOWNh+e6p++8jOWbnuaKpeeOh+mrmO+8jOW5s+WPsOi1hOa6kOaWueWQkeeahOmAieaLqeWG +s+WumuS6huiBjOWcuuWVhuWcuuS4iueahOmjjueUn+awtOi1t+S4gOmprOW5s+W3ne+8ge+8gemA +ieaLqeavlOWKquWKm+mHjeimgSznq5nlnKjlt6jkurrogqnohoDkuIrmiY3og73po57lvpfmm7Tp +q5gs5Luj55CG5ZWG5pS/562W5LyY5Y6a6L+U5Yip5Liw5Y6a77yM5qyi6L+O6L2s5Y+R5o6o6I2Q +77yM6ZW/5pyf5qyi6L+O5ZCE55WM5oul5pyJ5a6i5oi36LWE5rqQ5rig6YGT55qE5Luj55CG5Yqg +55uf77yB77yBDQoNCuWFqOWll+aho+ahiOWtpuexjeWtpuWOhuWtpuS9jeS7t+agvO+8mg0KDQrk +u7fkvY3mjInkuI3lkIzmoIflh4Y5ODUgMjExIOWPjOS4gOa1gSDmma7pgJrph43ngrnlkI3niYzp +maLmoKHkuInmoaPlt67liKvlkozkuJPkuJrng63luqblt67liKvvvIzlhajluKblrabljoblrabk +vY3lrabnsY3moaPmoYjliqDlvIDpgJrlrabkv6HnvZHnu4jouqvmsLjkuYXmlbDmja7ms6jlhozm +n6Xor6INCg0KdW5kZXJncmFkdWF0ZSDmnKznp5Hlrablo6vlrabkvY3lj4zor4Ey5LiHNei1tyAg +5qC55o2u6Zmi5qCh5LiT5Lia54Ot6Zeo56iL5bqm5bGC5qyh6LCD5pW0ICDlpoLvvJrljJfkuqzl +jJfkuqznkIblt6XlpKflraYt6K6h566X5py65a2m6ZmiLeiuoeeul+acuuenkeWtpuS4juaKgOac +r+acrOenkS3lt6Xlrablrablo6vlrabkvY3vvIzkuIrmtbflkIzmtY7lpKflraYt57uP5rWO566h +55CG5a2m6ZmiLeeJqea1geeuoeeQhuacrOenkS3nrqHnkIblrablrablo6vlrabkvY0gIOWMl+S6 +rOWNj+WSjOWMu+WtpumZoi3kuLTluorljLvlraYt5Yy75a2m5a2m5aOr5a2m5L2NICDmuIXljY7l +pKflraYgIOS4reWbveS6uuawkeWkp+WtpiAg5YyX5Lqs5biI6IyD5aSn5a2mICDlpI3ml6blpKfl +raYgIOS4iua1t+S6pOmAmuWkp+WtpiDkuK3lsbHlpKflraYg5Y2O5Y2X55CG5bel5aSn5a2m5Lit +5Zu956eR5oqA5aSn5a2mICDkuK3ljZflpKflraYg5bGx5Lic5aSn5a2mIOWNl+S6rOWkp+WtpiDl +jY7kuK3np5HmioDlpKflraYgIOWbvemYsuenkeaKgOWkp+WtpiDljZflvIDlpKflraYg5Lit5Zu9 +5Yac5Lia5aSn5a2mIOetiSANCg0KZ3JhZHVhdGUg5YWo5pel5Yi256GV5aOr5Zyo6IGM56GV5aOr +M+S4hzUtLTXkuIfotbcgIOehleWjq+eglOeptueUn+WtpuWOhuWtpuS9jeWPjOivgSAg5Zyo6IGM +56CU56m255Sf5a2m5Y6G5a2m5L2N5Y+M6K+BIOWmguW3peWVhueuoeeQhuehleWjq01CQeOAgeWF +rOWFseeuoeeQhuehleWjq01QQSAgICAg5Y6f5aeL5a2m5Y6G5qC55o2u5LiN5ZCM5LiT5Lia6KaB +5rGC6ZyA6KaB5pys56eR5a2m5Y6G5oiW5a2m5aOr5a2m5L2NICDlpoLvvJrkuK3lm73kurrmsJHl +pKflraYt5ZWG5a2m6ZmiLeW3peWVhueuoeeQhuWtpuehleWjqyBNQkEsRU1CQe+8iOWcqOiBjCDl +hajml6XliLbvvIksIOWMl+S6rOmmlumDvee7j+a1jui0uOaYk+Wkp+Wtpi3nu4/mtY7lrabpmaIt +IOS6p+S4mue7j+a1juWtpu+8iOi0uOaYk+e7j+a1ju+8ieehleWjq++8iOWcqOiBjCDlhajml6Xl +iLbvvInvvIzljJfkuqzlpKflraYt5YWJ5Y2O566h55CG5a2m6ZmiLU1CQSBFTUJBICAg5LiK5rW3 +5aSN5pem5aSn5a2mLeaWsOmXu+WtpumZoi3mlrDpl7vlrabkuJPkuJrnoZXlo6sgICDljY7kuJzl +uIjojIPlpKflraYg5YyX5Lqs6Iiq56m66Iiq5aSp5aSn5a2mIOS4iua1t+i0oue7j+Wkp+WtpiAg +5q2m5rGJ5aSn5a2mICAg5rWZ5rGf5aSn5a2mIOetiQ0KDQpEciDljZrlo6vnoJTnqbbnlJ/lrabl +joblrabkvY3lj4zor4EgNuS4hyDotbcgIOmcgOacieehleWjq+WtpuWOhuaIluWtpuS9jSAg5aaC +77ya5YyX5Lqs5aSn5a2m57uP5rWO5a2m6Zmi57uP5rWO5a2m5Y2a5aOrICAg5Lit5aSu6LSi57uP +5aSn5a2m6YeR6J6N5a2m6Zmi6YeR6J6N5bel56iL5LiT5Lia5Y2a5aOrICAg5Lit5bGx5aSn5a2m +5Yy75a2m6Zmi5Yy75a2m5Y2a5aOrICDljY7ljZfnkIblt6XlpKflraYgICDmtZnmsZ/lpKflraYt +6K6h566X5py656eR5a2m5LiO5oqA5pyv5a2m6ZmiLeeUteWtkOS/oeaBr+W3peeoi+WNmuWjqyAg +5Y2X5Lqs5aSn5a2mLeWVhuWtpumZoi3lupTnlKjnu4/mtY7lrabljZrlo6sgICDlk4jlsJTmu6jl +t6XkuJrlpKflraYgICDkuK3lm73mtbfmtIvlpKflraYgICDlpKnmtKXlpKflraYgICDljqbpl6jl +pKflraYgICDkuK3lm73np5HlrabmioDmnK/lpKflrabnrYkNCg0K5Yqe55CG6Z2e5bi45b+r5o23 +77yMMS0z5Liq5bel5L2c5pel5Y2z5Y+v5Yqe5aW95qGj5qGI6K+B5Lmm5a2m5L2N5a2m57GN6Iux +6K+t6K+B5Lmm562J5Y6f5Lu25bm25rOo5YaM5byA6YCa5a2m5L+h572R6K6k6K+B5pWw5o2u5bqT +5p+l6K+i77yM57uI6Lqr5rC45LmF5pyJ5pWI5p+l6K+i77yM5pys5YWs5Y+45omL5py65Y+35b6u +5L+h5Y+36ZW/5pyf5a6e5ZCN6K6k6K+B77yM5LyB5Lia6YKu566x5a2m5L+h572R5Z+f5ZCN5pyN +5Yqh5ZmoQGNoc2kuY29tLmNuIOWunuWQjeWkh+ahiO+8jOWvueWFrOi0puWPt+aUtuasvu+8jOWF +qOmdouaUr+aMgeaUr+S7mOWuneW+ruS/oeaJq+eggeWSjOe9keS4iumTtuihjOaJi+acuumTtuih +jEFQUOaUr+S7mCzmrKLov47lhajlm73ku6PnkIbllYbliqDnm5/lkIjkvZzvvIHvvIENCg0K5Yqe +55CG5rWB56iLOg0KDQrlpIfpvZDnlLPor7fmnZDmlpnihpLlrqHmoLjpgJrov4fihpLpppbku5gz +MCXlrabnsY3ms6jlhozotLnnlKjvvIjlr7nlhazotKbmiLfmlLbmrL7vvInihpLlip7lpb3lj5Hp +gIHor4Hkuabmiavmj4/ku7bmn6Xor6Lpqozor4Hmu6HmhI/ihpLmlK/ku5jkvZnmrL7ihpLlj5Hp +obrkuLDlv6vpgJLmlLblj5blhajlpZfor4Hkuabljp/ku7bvvIzljJfkuqzkuIrmtbfmt7HlnLPl +nLDljLrpl6rpgIEgICANCg0K5YyX5Lqs5oC76YOo5Zyw5Z2A77ya5YyX5Lqs5biC6KW/5Z+O5Yy6 +6KW/55u06Zeo5aSW5aSn6KGXMTjlj7fph5HotLjlpKfljqZDM+W6p+OAgA0K5LiK5rW35YWs5Y+4 +5Zyw5Z2A77ya5LiK5rW35biC5rWm5Lic5paw5Yy65rWm5Lic5Y2X6LevMTA3OOWPt+S4reiejeWk +p+WOpjYwOA0K5rex5Zyz5Yqe5YWs5Zyw5Z2A77ya5rex5Zyz5biC5Y2X5bGx5Yy65rex5Zyz5aSn +5a2m5Z+O5a2m6IuR5aSn6YGTMTA2OOWPt0bmoIsxODA45a6kDQoNCuWtpuS/oee9keezu+aVmeiC +sumDqOaMh+WumuWUr+S4gOWtpuWOhuiupOivgeafpeivoue9keerme+8jOe9keWdgCB3d3cuY2hz +aS5jb20uY24gICANCg0K5pS25qy+6LSm5Y+3IA0K5oi35ZCN77ya5YyX5Lqs5a2m5L+h5ZKo6K+i +5pyN5Yqh5pyJ6ZmQ5YWs5Y+4ICAg5oi35ZCN77ya5rex5Zyz5biC5pm65L+h5paw5L+h5oGv5oqA +5pyv5pyJ6ZmQ5YWs5Y+4ICAg5oi35ZCN77ya5LiK5rW35a2m5L+h5pWZ6IKy56eR5oqA5pyJ6ZmQ +5YWs5Y+4DQrlvIDmiLfooYzvvJrkuK3lm73msJHnlJ/pk7booYzljJfkuqzluILopb/ln47ljLrl +ub/lronpl6jmlK/ooYwgIOW8gOaIt+ihjO+8muW3peWVhumTtuihjOa3seWcs+W4guWNl+WxseaU +r+ihjCAg5byA5oi36KGM77ya5oub5ZWG6ZO26KGM5LiK5rW35biC5rWm5Lic5aSn6YGT5pSv6KGM +DQoNCuWKnueQhuWtpuWOhuWtpuS9jeivt+iBlOezuyDljJfkuqzmgLvpg6jnlLXor506IDEzOTgz +MTI1MTUx77yI5b6u5L+h5ZCM5Y+377yJIOW+ruS/oe+8mmNoc2l4dyDnjovlu7rmtpvogIHluIgg +77yI5Li75Lu76LSf6LSj5Lq6ICDlrabkv6HnvZHmlbDmja7lupPnoJTlj5Hnu7TmiqTljYfnuqcg +77yJIOW+ruS/oeWPt++8mmNoc2l4dyAgICDpgq7nrrE6IGtlZnVAY2hzaS5jb20uY24gICAgICAg +IFFROjY2ODg4OCAgIA0KDQrlrqLmiLcv5Luj55CG5ZWG6YGN5biD5YWo5Zu977ya5YyX5LqsIOS4 +iua1tyDmt7HlnLMg5aSp5rSlIOadreW3niDljZfkuqwg6IuP5beeIOWOpumXqCDlub/lt54g6YeN +5bqGIOatpuaxiSDmiJDpg70g6YOR5beeIOS4nOiOniDpnZLlspsg5rWO5Y2XIOetieWQhOWkp+WM +ug0KDQrmt7vliqDlvq7kv6Hpobvnn6XvvJrliqDlvq7kv6Hlkqjor6Llip7nkIbliY3vvIzor7fl +hYjnoa7lrprlrqLmiLflubTpvoTvvJ/mhI/lkJHlrabljobnmoTmgKfotKjvvIjlhajml6XliLbn +u5/mi5sg6Ieq6ICD77yJ77yf6Zmi5qCh5Zyw5Yy65LiT5Lia77yfIOW3peS9nOS6uuWRmOS8muWF +iOaKpeS7t++8jOWGs+WumuWKnueQhueahOWuouaIt+ivt+aJk+W8gOWtpuS/oee9keeZu+W9lemm +lumhteW3puS4iuinkuWtpuWOhuafpeivoumhtemdouWQjuadpeeUteivne+8jOe7meaIkeS7rOWK +nuWFrOS8geS4mumCrueusWtlZnVAY2hzaS5jb20uY27lj5HpgIHlpIfpvZDnmoTnlLPlip7mnZDm +lpnpgq7ku7blkI7vvIzmiJHku6zlj6/ku6Xnu5nlrqLmiLfmn6XnnIvov5HmnJ/lip7lpb3nmoTl +rabljobmoLfmnKwg6L6T5YWl5aeT5ZCN6K+B5Lmm57yW5Y+35Y2z5Y+v5p+l6K+i6aqM6K+B77yM +5qyi6L+O5pyJ5a6i5oi36LWE5rqQ5a6e5Yqb55qE5py65p6E5Liq5Lq65Yqg55uf5Luj55CG5aSn +5bGV5a6P5Zu+77yB77yBDQoNCuacrOWFrOWPuOaJi+acuuWPt+W+ruS/oeWPt+mVv+acn+WunuWQ +jeiupOivge+8jOS8geS4mumCrueuseWtpuS/oee9keWfn+WQjeacjeWKoeWZqEBjaHNpLmNvbS5j +bumVv+acn+WunuWQjeWkh+ahiO+8jOWunuWQjei0puWPt+WFqOmdouaUr+aMgeaUr+S7mOWunemT +tuiBlOe9kemTtuaUr+S7mCzku6PnkIbllYbplb/mnJ/lkIjkvZzlronlhajlv6vmjbfvvIENCg0K +5pyA5aW955qE5Y+j56KR5ZKM5L+h6KqJLCDni6zlrrbpm4TljprotYTmupAs5bey5oiQ5Yqf5Li6 +5aSn6YeP5rW35YaF5aSW5a6i5oi35ZyG5ruh5LqG5qKm5oOz77yMIOS4gOOAgeaVmeiCsumDqOiu +pOivgee9keWSjOWtpuagoee9keWdh+WPr+S7peafpeivou+8jOWPr+S+m+eUqOS6uuWNleS9jeWS +jOacieWFs+mDqOmXqOeUteivneWSqOivouWSjOS4iue9keiwg+afpSAg5LqM44CB5pyJ5a6M5pW0 +6b2Q5YWo55qE5qGj5qGI44CB5a2m57GN44CB6ICD6K+V5oiQ57up5Y2V44CB5YWl5a2m55m76K6w +6KGo44CB5q+V5Lia55m76K6w6KGo562J44CC5a+55rGC6IGM44CB5bCx5Lia44CB5bqU6IGY44CB +5pmL57qn44CB5rao6Jaq44CB6IGM56ew6K+E5a6a44CB6LWE5qC85oql6ICD44CB562J57qn6K6k +6K+B44CB5Ye65Zu944CB55WZ5a2m44CB56e75rCR44CB5a2m5Y6GIOWFrOivgeetiemDveWFt+ac +ieaViOWKm+OAgiDkuInjgIHkv53or4Hlv6vmjbfku7fkvJjvvJrlm6DkuLrmmK/lrabmoKHnm7Tm +jqXlh7ror4Hnm7TmjqXlip7nkIbvvIzmiYDku6Xkv53or4Hkuoblh7ror4Hlv6vpgJ/vvIzku7fm +oLzkvJjmg6DjgIIg5biC5Zy65peg5Y+v6ZmQ6YeP77yM5qyi6L+O5Yqg55uf5Luj55CG77yM5LiA +5qyh5om56YeP5o+Q5Lqk5Yqe55CG5a6i5oi377yM5Y+v5p2l5pys5YWs5Y+46Z2i6LCI562+57qm +77yM5Luj55CG5ZWG5Yqg55uf5b6F6YGH5LyY5Y6a5Zue5oql5Liw5Y6a77yB77yBDQoNCui/keW5 +tOadpeWBh+ivgeS5puaXqeW3sue7j+W9u+W6leiiq+a3mOaxsO+8jOaXoOiuuue6uOW8oOinhOag +vOi0qOWcsOmYsuS8quawtOWNsOi/mOaYr+avleS4muivgeS5pueahOe8luWPt+WtpuS9jeivgeS5 +pueahOe8luWPt++8jOi/mOacieWtpuexjeWPt+aho+ahiOe8luWPt++8jOmDveaXqeW3suWFqOmD +qOiBlOe9keWIsOaVmeiCsumDqOWtpuS/oee9keeahOaVsOaNruW6k+S6hu+8jOaXoOiuuuaYr+aK +peiAg+i/mOaYr+W6lOiBmOmdouivleaIluaYr+WFrOivge+8jOebuOWFs+W3peS9nOS6uuWRmOmD +veaYr+eZu+mZhuWtpuS/oee9keaVsOaNruW6k+W5s+WPsOadpeafpemqjOWtpuWOhuivgeS5puea +hOecn+S8quOAgg0KDQrmnKzlpITni6zlrrbnmoTotYTmupDmnYPpmZDkvb/lvpflrqLmiLfkuI3n +lKjlho3ovpvoi6blpIfogIPogJfotLnml7bpl7Tnsr7lipvlj4LliqDmvKvplb/nuYHnkJDnmoTl +rabljobogIPor5XvvIzlj6ropoHkvaDlhbflpIfkuIDlrprnmoTkuJPkuJrln7rnoYDvvIzop4Tl +iJLorr7orqHmnIDkvbPnmoTogYzkuJrmlrnlkJHvvIzkuLrkuI3lkIzlrqLmiLfmjqjojZDorqLl +iLbkuI7ogYzkuJrlkozmnKrmnaXlj5HlsZXpq5jluqbljLnphY3nmoTlrabljoblrabkvY3vvIzn +u4jouqvmsLjkuYXlrabkv6HnvZHmn6Xor6LvvIzmnKzlrabljoblrabkvY3kuJrliqHmnIDpgILl +kIjlhbflpIfovoPlvLrlt6XkvZzog73lipvmnInovoPlpb3ku47kuJrlsaXljobnmoTpq5jnq6/l +rqLmiLfvvIzljIXmi6zmjIflrprpmaLns7vkuJPkuJrnmoTlnKjogYznu5/mi5vlhajml6XliLbm +nKznp5HnoZXlo6vljZrlo6vnoJTnqbbnlJ/np4HkurrlrprliLbvvIzluK7liqnlub/lpKfog73l +ipvlh7rkvJfnu4/mtY7kvJjotornmoTlrqLmiLflrp7njrDkuobogYzlnLrpo57ot4PllYblnLro +hb7po57ku5XpgJTlubPmraXpnZLkupHvvIHvvIHpgInmi6nmr5Tliqrlipvmm7Tph43opoHvvIzk +uI7ml7bkv7Hov5vnq5nlnKjlt6jkurrnmoTogqnohoDkuIrkvaDlj6/ku6Xpo57lvpfmm7Tpq5jv +vIHvvIEgDQoNCuWKnueQhuWtpuWOhuWtpuS9jeivt+iBlOezuyDljJfkuqzmgLvpg6jnlLXor506 +IDEzOTgzMTI1MTUx77yI5b6u5L+h5ZCM5Y+377yJIOW+ruS/oe+8mmNoc2l4dyDnjovlu7rmtpvo +gIHluIgg77yI5Li75Lu76LSf6LSj5Lq6ICDlrabkv6HnvZHmlbDmja7lupPnoJTlj5Hnu7TmiqTl +jYfnuqcg77yJIOW+ruS/oeWPt++8mmNoc2l4dyAgICDpgq7nrrE6IGtlZnVAY2hzaS5jb20uY27v +vIjkvIHkuJrpgq7nrrFsZDg4ODhAMTg4LmNvbe+8iSAgICAgICAgUVE6NjY4ODg4ICAgDQoNCua3 +u+WKoOW+ruS/oemhu+efpe+8muWKoOW+ruS/oeWSqOivouWKnueQhuWJje+8jOivt+WFiOehruWu +muWuouaIt+eahOW5tOm+hCDmiYDlip7mhI/lkJHlrabljobnmoTmgKfotKjvvIjlhajml6XliLbn +u5/mi5sg6Ieq6ICD77yJIOmZouagoeWcsOWMuuS4k+S4miDlt6XkvZzkurrlkZjkvJrlhYjlm57l +pI3miqXku7fvvIzlhrPlrprlip7nkIbnmoTlrqLmiLfor7fmiZPlvIDlrabkv6HnvZHnmbvlvZXp +ppbpobXlt6bkuIrop5Llrabljobmn6Xor6LpobXpnaLlkI7mnaXnlLXor53vvIznu5nmiJHku6zl +t6XkvZzpgq7nrrFrZWZ1QGNoc2kuY29tLmNu5Y+R6YCB5aSH6b2Q55qE55Sz5Yqe5p2Q5paZ6YKu +5Lu25ZCO77yM5oiR5Lus5Y+v5Lul57uZ5a6i5oi35p+l55yL5oiR5Lus6L+R5pyf5Yqe5aW955qE +5a2m5Y6G5qC35pys6L6T5YWl5aeT5ZCN5q+V5Lia6K+B5Lmm57yW5Y+35Y2z5Y+v5p+l6K+i6aqM +6K+B77yM5qyi6L+O5pyJ5a6i5oi36LWE5rqQ5a6e5Yqb55qE5py65p6E5Yqg55uf5Luj55CG5aSn +5bGV5a6P5Zu+ISENCg0K6ZmEOiDnlLPlip7lrabljobmiYDpnIDmnZDmlpkNCg0KMS7lrabljobm +gKfotKjvvIjnu5/mi5sgIOaIkOS6uuaVmeiCsi/lnKjogYwgIOiHquWtpuiAg+ivle+8iQ0KDQrp +maLmoKHlkI3np7DvvIjlkITlnLDljLrlm73nq4vlhazlip7pmaLmoKHvvIkNCg0K5a2m5Y6G5bGC +5qyh77yI5LiT56eRIOacrOenkeWtpuWjqyDnoZXlo6vnoJTnqbbnlJ8g5Y2a5aOr56CU56m255Sf +IOWmgk1CQSBFTUJBIOWQhOexu+W3peeoi+ehleWjq++8iQ0KDQrmr5XkuJrml7bpl7TvvIjoh6ro +gIPkuLrmr4/lubQ25pyI5bqVMTLmnIjlupXlkITmr5XkuJrnmbvorrDkuIDmrKEg57uf5oubL+aI +kOaVmS/lnKjogYzkuLrmr4/lubQ35pyI77yJDQoNCjIuIOiTneiJsuW6leS4pOWvuOaVsOeggeiv +geS7tuW9qeeFp++8iOWbvueJh+aWh+S7tuWPr+WOi+e8qeWQjueUqOmCruS7tumZhOS7tuS4iuS8 +oOWPkeadpe+8iQ0KDQrouqvku73or4HmraPpnaLmiavmj4/ku7bvvIjnlKjpgq7ku7bpmYTku7bk +uIrkvKDlj5HmnaXvvIkNCg0KMy4g5Y6f5aeL5a2m5Y6G5a2m5L2N5Y+R5p2l5LiO5ZCm6KeG5oiQ +5Lq65pWZ6IKyL+S4k+WNh+acrC/lnKjogYznoZXlo6vnrYnlrabljobnmoTkuI3lkIzlhbfkvZPo +poHmsYINCg== + +--=_NextPart_2rfkindysadvnqw3nerasdf-- + + +[ + { + "type": "word", + "value": "ad" + }, + { + "type": "word", + "value": "hello" + }, + { + "type": "word", + "value": "全国" + }, + { + "type": "word", + "value": "博士" + }, + { + "type": "word", + "value": "双" + }, + { + "type": "word", + "value": "学位" + }, + { + "type": "word", + "value": "学历" + }, + { + "type": "word", + "value": "广告" + }, + { + "type": "word", + "value": "本科" + }, + { + "type": "word", + "value": "独家" + }, + { + "type": "word", + "value": "硕士" + }, + { + "type": "word", + "value": "证" + }, + { + "type": "word", + "value": "速办" + }, + { + "type": "number", + "code": [ + 105, + 2 + ] + }, + { + "type": "sender", + "value": "beijing--------chsi.com.cn" + }, + { + "type": "sender", + "value": "hello-------北京------kefu@beijing--------chsi.com.cn" + }, + { + "type": "hostname", + "value": "beijing--------chsi.com.cn" + }, + { + "type": "attachment", + "value": "!txt" + }, + { + "type": "attachment", + "value": "_信" + }, + { + "type": "attachment", + "value": "_全" + }, + { + "type": "attachment", + "value": "_博士" + }, + { + "type": "attachment", + "value": "_学" + }, + { + "type": "attachment", + "value": "_学位" + }, + { + "type": "attachment", + "value": "_学历" + }, + { + "type": "attachment", + "value": "_快速" + }, + { + "type": "attachment", + "value": "_搞定" + }, + { + "type": "attachment", + "value": "_晋升" + }, + { + "type": "attachment", + "value": "_本科" + }, + { + "type": "attachment", + "value": "_查询" + }, + { + "type": "attachment", + "value": "_永久" + }, + { + "type": "attachment", + "value": "_独家" + }, + { + "type": "attachment", + "value": "_硕" + }, + { + "type": "attachment", + "value": "_网" + }, + { + "type": "attachment", + "value": "_职称" + }, + { + "type": "attachment", + "value": "_轻松" + }, + { + "type": "attachment", + "value": "_速办" + }, + { + "type": "attachment", + "value": "_高薪" + }, + { + "type": "mime_type", + "value": "application/octet-stream" + }, + { + "type": "mime_type", + "value": "multipart/mixed" + }, + { + "type": "mime_type", + "value": "text/plain" + } +] + diff --git a/tests/resources/smtp/antispam/classifier_html.test b/tests/resources/smtp/antispam/classifier_html.test new file mode 100644 index 0000000..477deb2 --- /dev/null +++ b/tests/resources/smtp/antispam/classifier_html.test @@ -0,0 +1,695 @@ +hello
world
+ +[ + { + "type": "StartTag", + "name": 1819112552, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "hello" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": true + }, + { + "type": "Text", + "text": "world" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": true + }, + { + "type": "EndTag", + "name": 1819112552 + } +] + +using <>
+ +[ + { + "type": "StartTag", + "name": 1819112552, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "using <>" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": true + }, + { + "type": "EndTag", + "name": 1819112552 + } +] + +test tag
+ +[ + { + "type": "Text", + "text": "test" + }, + { + "type": "StartTag", + "name": 7630702, + "attributes": [ + [ + 29282, + null + ] + ], + "is_self_closing": true + }, + { + "type": "Text", + "text": " tag" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": true + } +] + +<>< >>hello world< br + /> + +[ + { + "type": "StartTag", + "name": 6775156, + "attributes": [], + "is_self_closing": true + }, + { + "type": "Text", + "text": ">hello world" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": true + } +] + +ignore headxyz

<body>

+ +[ + { + "type": "StartTag", + "name": 1684104552, + "attributes": [], + "is_self_closing": false + }, + { + "type": "StartTag", + "name": 435611265396, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "ignore head" + }, + { + "type": "EndTag", + "name": 435611265396 + }, + { + "type": "StartTag", + "name": 7630702, + "attributes": [ + [ + 1684104552, + null + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "xyz" + }, + { + "type": "EndTag", + "name": 7630702 + }, + { + "type": "EndTag", + "name": 1684104552 + }, + { + "type": "StartTag", + "name": 12648, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "" + }, + { + "type": "EndTag", + "name": 12648 + } +] + +

what is ♥?

ßĂΒγ don't hurt me.

+ +[ + { + "type": "StartTag", + "name": 112, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "what is ♥?" + }, + { + "type": "EndTag", + "name": 112 + }, + { + "type": "StartTag", + "name": 112, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "ßĂΒγ don't hurt me." + }, + { + "type": "EndTag", + "name": 112 + } +] + +this is the actual text + +[ + { + "type": "Comment", + "text": "!--[if mso]> < < < < ignore > -> here --" + }, + { + "type": "Text", + "text": " the actual" + }, + { + "type": "Comment", + "text": "!--" + }, + { + "type": "Text", + "text": " text" + } +] + + < p > hello < / p > < p > world < / p > !!! < br > + +[ + { + "type": "StartTag", + "name": 112, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "hello" + }, + { + "type": "EndTag", + "name": 112 + }, + { + "type": "StartTag", + "name": 112, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": " world" + }, + { + "type": "EndTag", + "name": 112 + }, + { + "type": "Text", + "text": " !!!" + }, + { + "type": "StartTag", + "name": 29282, + "attributes": [], + "is_self_closing": false + } +] + +

please unsubscribe here.

+ +[ + { + "type": "StartTag", + "name": 112, + "attributes": [], + "is_self_closing": false + }, + { + "type": "Text", + "text": "please unsubscribe" + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "#" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": " here" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "Text", + "text": "." + }, + { + "type": "EndTag", + "name": 112 + } +] + +texttexttexttext< a href = "e" >texttext< anchor href = "x">text + +[ + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "a" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "b" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "c" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "d" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "e" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 125779835187816, + "ignore" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 125822818283105, + "attributes": [ + [ + 1717924456, + "x" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + } +] + +texttexttexttext< a href = e >texttexttext + +[ + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "a" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "b" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "c" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "d" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "e" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 125779835187816, + "ignore" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 125822818283105, + "attributes": [ + [ + 1717924456, + "x" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + } +] + +text< a href = test ignore>text< a href = fudge href ignore>text a href = "unknown" + +[ + { + "type": "Comment", + "text": "!-- texttext--text--" + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "hello world" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "test" + ], + [ + 111542170183529, + null + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "fudge" + ], + [ + 1717924456, + null + ], + [ + 111542170183529, + null + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "text" + }, + { + "type": "EndTag", + "name": 97 + }, + { + "type": "StartTag", + "name": 97, + "attributes": [ + [ + 1717924456, + "foobar" + ] + ], + "is_self_closing": false + }, + { + "type": "Text", + "text": "a href = \"unknown\"" + }, + { + "type": "EndTag", + "name": 97 + } +] + diff --git a/tests/resources/smtp/antispam/combined.test b/tests/resources/smtp/antispam/combined.test new file mode 100644 index 0000000..ee411e8 --- /dev/null +++ b/tests/resources/smtp/antispam/combined.test @@ -0,0 +1,1111 @@ +envelope_from noreply@tetheer.com +envelope_to licensing@stalw.art +helo_domain yphoo.vps.wbsprt.com +iprev.result permerror +spf.result none +spf_ehlo.result none +dmarc.result none +remote_ip 195.210.29.48 +expect_header X-Spam-Result: ARC_NA (0.00), DKIM2_NA (0.00), DKIM_NA (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_DATA_URI (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), SPF_NA (0.00), SUBJECT_ENDS_EXCLAIM (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), MIME_HTML_ONLY (0.20), HELO_NORES_A_OR_MX (0.30), AUTH_NA (1.00), DATE_IN_PAST (1.00), DMARC_NA (1.00), MID_RHS_MATCH_FROM (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00), PYZOR (3.50) +expect_header X-Spam-Score: spam, score=13.70 + +From: Client Services +To: licensing@stalw.art +Subject: Tether Important Update ! +Date: 16 Oct 2023 06:40:52 +0200 +Message-ID: <20231016064052.403F7FEF5F005EFB@tetheer.com> +MIME-Version: 1.0 +Content-Type: text/html +Content-Transfer-Encoding: quoted-printable + + + + + + + +3D"If + + +envelope_from l.chant@tenthrevolution.com +envelope_to joe@domain.org +helo_domain eu-smtp-delivery-181.mimecast.com +iprev.result pass +spf.result pass +spf_ehlo.result pass +dkim.result pass +dkim.domains tenthrevolution.com +dmarc.result pass +remote_ip 185.58.86.181 +tls.version TLSv1.3 +expect_header X-Spam-Result: DMARC_POLICY_ALLOW (-0.50), DKIM_ALLOW (-0.20), SPF_ALLOW (-0.20), MIME_GOOD (-0.10), ARC_NA (0.00), DKIM2_NA (0.00), DKIM_SIGNED (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_ATTACHMENT (0.00), HTML_SHORT_2 (0.00), RCPT_COUNT_ONE (0.00), RCVD_COUNT_THREE (0.00), TO_DN_EQ_ADDR_ALL (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_NO_TLS_LAST (0.10), HELO_NORES_A_OR_MX (0.30), SUBJECT_ENDS_SPACES (0.50), URI_COUNT_ODD (0.50), DATE_IN_PAST (1.00), FORGED_RCVD_TRAIL (1.00), FROMHOST_NORES_A_OR_MX (1.50) +expect_header X-Spam-Score: ham, score=3.90 + +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=tenthrevolution.com; + s=mimecast20200102; t=1669138703; + h=from:from:reply-to:subject:subject:date:date:message-id:message-id: + to:to:cc:mime-version:mime-version:content-type:content-type; + bh=OiZa5h2Agb47dTPnaYIMS7L31ZUnIkX1UTz8yUhK3ME=; + b=Z0erZ14U5BYcY0CGysOw3K0A7wjF9qqRlOaI4+0XGUmM5QgmgN6UVJc6J5AkypPgwEfOWx + vsCbMrq14SF61IevT2cPrOwaphTL7s3Yf9YqKkk4N9bMiBVeikq1ks0kxJ8pbE8vYsiASn + GEkv9T3YWfRMQR/iH+oD1dVRnCljTxQ= +Received: from EUR01-DB5-obe.outbound.protection.outlook.com + (mail-db5eur01lp2053.outbound.protection.outlook.com [104.47.2.53]) by + relay.mimecast.com with ESMTP with STARTTLS (version=TLSv1.2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id + uk-mta-233-uW3qPnzqMdi1lL-mEjQyCA-4; Tue, 22 Nov 2022 17:38:17 +0000 +X-MC-Unique: uW3qPnzqMdi1lL-mEjQyCA-4 +Received: from AS8PR04MB8071.eurprd04.prod.outlook.com (2603:10a6:20b:3f9::15) + by DU2PR04MB8952.eurprd04.prod.outlook.com (2603:10a6:10:2e3::24) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5834.9; Tue, 22 Nov + 2022 17:37:46 +0000 +Received: from AS8PR04MB8071.eurprd04.prod.outlook.com + ([fe80::eb0e:b5f3:368c:ff6e]) by AS8PR04MB8071.eurprd04.prod.outlook.com + ([fe80::eb0e:b5f3:368c:ff6e%5]) with mapi id 15.20.5834.015; Tue, 22 Nov 2022 + 17:37:46 +0000 +From: Lee Chant +To: "joe@domain.org" +Subject: =?Windows-1252?Q?You=92re_missing_out_=96_nominate_yourself_or_your_team_?= + =?Windows-1252?Q?for_a_Digital_Revolution_Award_?= +Thread-Topic: =?Windows-1252?Q?You=92re_missing_out_=96_nominate_yourself_or_your_team_?= + =?Windows-1252?Q?for_a_Digital_Revolution_Award_?= +Thread-Index: Adj+l09eH9aJFGDfS8upOMyJ89uCyw== +Date: Tue, 22 Nov 2022 17:37:46 +0000 +Message-ID: +Accept-Language: en-GB, en-US +X-MS-Has-Attach: yes +X-MS-TNEF-Correlator: +MIME-Version: 1.0 +X-OriginatorOrg: tenthrevolution.com +X-Mimecast-Spam-Score: 0 +X-Mimecast-Originator: tenthrevolution.com +Content-Language: en-US +Content-Type: multipart/related; + boundary="_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_"; + type="multipart/alternative" + +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: multipart/alternative; + boundary="_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_" + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: text/plain; charset=WINDOWS-1252 +Content-Transfer-Encoding: quoted-printable + +Hi Joe +My name is Lee and I=92m the Managing Director of Global Customer Solutions= + at Tenth Revolution Group. +I wanted to drop you a note to personally let you know that we have extende= +d the nomination deadline for the Digital Revolution Awards & Fundraiser until 30th November 2022. +These not-for-profit awards celebrate excellence in the cloud and bring tog= +ether the tech community for a night of celebration and fundraising for som= +e truly worthy causes. Founded back in 2020, we have garnered support from = +IBM, AWS, Salesforce and Microsoft and it=92s a perfect opportunity to netw= +ork with likeminded individuals like yourself. +Our 20 specialist categories cover topics from tech for good to the climate emergency, ED&I= +, allyship, outstanding leadership to name but a few. If you or your team w= +ould be interested in discussing a nomination for the Digital Revolution Aw= +ards, do let me know. +With kind regards, +Lee + +Lee Chant +MD - Global Customer Solutions + +Mobile: +44 (0)7971 373432 +Email: l.chant@tenthrevolution.com +Website: www.tenthrevolution.com + +[Tenth Revolution Group] + +Disclaimer +This email and any attachments are confidential and intended for the use of= + the named recipient only. If you have received this email and any attachme= +nts in error, please inform us immediately and then delete it. Any views or= + opinions are solely those of the author and do not necessarily represent t= +hose of Frank Recruitment Group Services Limited or its affiliates, divisio= +ns or brands. Company Registration No. 08142375. +Registered Office: Floor 2, The St. Nicholas Building, St. Nicholas Street,= + Newcastle Upon Tyne, Tyne and Wear, NE1 1RF. +Business registration information of Frank Recruitment Group Services Ltd c= +ompanies and associated brands in the UK, Europe, Singapore, Australia, Jap= +an and North America can be found here. +Our Privacy Notice can be= + found at www.tenthrevolution.com/privacy-notice + + + + + + + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: text/html; charset=WINDOWS-1252 +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

Hi Joe

+

My name is Lee and I=92m the Managing Director of Gl= +obal Customer Solutions at +Tenth Revolution Group. +

+

I wanted to drop you a note to personally let you kn= +ow that we have extended the nomination deadline for the +Digital Revolution Awa= +rds & Fundraiser until 30th November 2022. + 

+

These not-for-profit awards celebrate excellence in = +the cloud and bring together the tech community for a night of celebration = +and fundraising for some truly worthy causes. Founded back in 2020, we have= + garnered support from IBM, AWS, Salesforce + and Microsoft and it=92s a perfect opportunity to network with likeminded = +individuals like yourself. +

+

Our 20 specialist +categories cover topics from tech for good to the climate emergency, ED= +&I, allyship, outstanding leadership to name but a few. If you or your = +team would be interested in discussing a nomination for the Digital Revolut= +ion Awards, do let me know. +

+

With kind regards,

+

Lee

+

Lee Chant
+MD - Global Customer Solutio= +ns

+

Mobile: +44 (= +0)7971 373432
+Email:
l.chant@tenthrevolution.com
+Website:
www.tenthrevolution.com

+

+=3D"Tenth

+

Disclaimer
+
This email and any= + attachments are confidential and intended for the use of the named recipie= +nt only. If you have received this email and any attachments in error, plea= +se inform us immediately and then + delete it. Any views or opinions are solely those of the author and do not= + necessarily represent those of Frank Recruitment Group Services Limited or= + its affiliates, divisions or brands. Company Registration No. 08142375. +
+Registered Office: Floor 2, The St. Nicholas Building, St. Nicholas Street,= + Newcastle Upon Tyne, Tyne and Wear, NE1 1RF.
+Business registration information of Frank Recruitment Group Services Ltd c= +ompanies and associated brands in the UK, Europe, Singapore, Australia, Jap= +an and North America can be found 
here.
+
Our Privacy Notice= + can be found at +www.tenthrevolution.com/privacy-notice
+

+

 

+

 

+

 

+

 

+

 

+
+ + + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_-- + +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: image/jpeg; name="image001.jpg" +Content-Description: image001.jpg +Content-Disposition: inline; filename="image001.jpg"; size=13809; + creation-date="Tue, 22 Nov 2022 17:37:45 GMT"; + modification-date="Tue, 22 Nov 2022 17:37:46 GMT" +Content-ID: +Content-Transfer-Encoding: base64 + +/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAPAAA/+EDLGh0dHA6Ly9ucy5hZG9i +ZS5jb20veGFwLzEuMC8APD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6 +TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0i +QWRvYmUgWE1QIENvcmUgNi4wLWMwMDIgNzkuMTY0MzUyLCAyMDIwLzAxLzMwLTE1OjUwOjM4ICAg +ICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjIt +cmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBN +TT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9u +cy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDov +L25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo4ODIyQUZF +QTc3RkIxMUVBOUQ1REY1N0FCOTE5REQwQiIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo4ODIy +QUZFOTc3RkIxMUVBOUQ1REY1N0FCOTE5REQwQiIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90 +b3Nob3AgMjEuMCAoV2luZG93cykiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJ +RD0ieG1wLmlpZDozRDE0N0RCRDMzOEExMUVBQTVDMEQyQjIxRUU5OTM5QyIgc3RSZWY6ZG9jdW1l +bnRJRD0ieG1wLmRpZDozRDE0N0RCRTMzOEExMUVBQTVDMEQyQjIxRUU5OTM5QyIvPiA8L3JkZjpE +ZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/u +AA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwO +DxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx8BBwcHDQwNGBAQGBoVERUaHx8fHx8fHx8fHx8fHx8f +Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fH//AABEIAGUBwgMBEQACEQEDEQH/xAC5 +AAEAAgMBAQEAAAAAAAAAAAAABAUDBgcCCAEBAQEBAQEBAAAAAAAAAAAAAAABAgMEBRAAAQMDAwID +AwQKDQoGAwAAAgEDBAARBSESBhMHMUEiUWEUMiMVCHGBkUJS07R1FjehsWJysjNTs3SUVhcYgqLS +c5MkNDU2duFDRFWVJtQlVxEAAgIBAwIEBQMCBQQDAAAAAAERAiExEgNBUWFxIhOBobHBBJHhMvDR +QlJicoLxotIU4jND/9oADAMBAAIRAxEAPwD6poBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgF +AKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCg +FAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQC +gFAKAUAoBQCgFAKAUB85f4y8R/ZiR/Wg/F1rac/cH+MvEf2Ykf1oPxdNo9w2/k31iMfguF8a5QeF +dfa5GjxNxRfESZ6Coi7iUF3Xv7KbSu+DUP8AGXiP7MSP60H4um0nuHQO0/e+F3D+mPh8U5j/AKIb +adPqPC71Orv0Swja3TqNQaraSv7Y/WP41zfPLg3YTmHnOjugo86LgPkl1JtCQQsdtUTz1+3XUlby +dcItoqXsS9ZNnIuFfWIx/KcHyfKtYV2KPGoSznGifE1eRBcLYKoCbf4rx1rTqYV5NQ/xl4j+zEj+ +tB+LptJ7g/xl4j+zEj+tB+LptHuG78z7+QeMZrj2Lcw7skuQRY0tt0XhBGklOKCCqKC7ttqiRXaD +q1Q2c1/vsh/3vf3b/RTnxG/Z9I9Udn/CfFX6e2/h6flVYwZ3Zg6VUNGp9w+dv8Sj4pYuKPMTcxOD +HRIbbwMKrrgEQ+txNv3ltbVUiNwVC8+7niikXbWXtTVduUx5Lb3Iha0gkvsXXCe4mI5UcyGEeTjM +3jVEclhp4dKSzu+SdkVUIC8iFfuXo0VOSs5P3JzWO5n+imD4w9n5wwAyTpNy2IqC0bpM/wDn2RbE +KeC+dII3kwfp53R//msn/wCVx/8ApUgS+xMzncWfx7i2N5ByDAu44JM1uJlIyyGnSgtPOq2EgzbQ +gMPkqqCv3yJSA2bv46pUNGvc95lD4fxmRm5LJSibJtqNCbWzj7zxoANhouqqt/DwqpEbgmZjkuLw +OAPNcgeDGxWGxOUplvQCJE+bFRS5lu0RBS6+SVBJpTPc/nuUBJfHe3c6Zij1ZlT5sbGuuD5EDDu8 +7F4oqqlWCbn2Ng4bz0eRSZeOl4efg8zAETlwZzSoOw1VBNp8NzTgqqLay30XTSjRUyRz3mLPEcB9 +MOxSlh8RHjdECQFvIdFpCuqF8ndeiQbg2KoUreT5sMDxzKZs2lfDFxH5hMCu1TRhtXFFCVFtfbah +Gz3x/LBmcDjcuDashkYrEsWVXcoI+2LiCq6XtutQqJGQlpDgSZijvSM0bygi2VUAVK1/fagNVhc6 +y+V4HjOU4Hj7uTkZIQcHFDJZZMALcikrr2wF27f2asEnBreV7v8AN8VMx0PIdvZLEnLP/DY9v6Sh +F1HkFS23FSQfSniVkpBnc+xsvHeWc7yGWai5bhL+Ggmhq5kHJ8OQIKIqop02iU13Em3ShpNltieW +YzK8izeCioaycB8MM5wksHUlATggHmu0BRVX31IEnjmHLoXF8WE6RElz3HnRjxYUBgpD7rxoqiAo +PpG+1dSVEqpBuDVF7h90QD4pztpJ+j09SqGTiHL2e34ZEuq/ud16QSX2Nn4Zzvj/AC+C7IxZuBIi +n0shjpIKzLiu/wAm+0uorovuXyWjRU5KLuB3fw/B+RYbF5WI6UTKiZvZFtbjGACEN7gWVVHcaXVF +0T20SI7Qb406060DrRi404KE24KookJJdFRU0VFSoaNe5vzNnisbFPuxSlJlMnFxQiJoGwpSqiOL +dFug7fCqkRuDY6hTFLlxYcV2XLeCPFYAnH33CQAABS5ERLoiIlAc4DvFls2ZlwTh8/kkECUUyjzr +WNhuKK2VWXZCKpon72rBnd2JMTuTzWNMYZ5JwDIY6PIcBpJkGTHyjYKZIKE70dhACKupW0pAl9jY +e4XMWeGcPyHJXopTG4CNKUYCQCLqvAzoSoVrdS/hRIrcI15Ofd0FS6dtZVl1T/8Aa4//AEqQSX2J +3Fe58PL5wuN5fFy+OckRtXmsdPQVR9sflHGebUgdQfO3v9i0gKxfcq5bgOK4dzL5yUkWG2qAOikb +jhfJbbBLkZl5In7VQrcGnN9x+5M8ElYbtvLdxx6suZDIRcfIIfb8OaOEP+UtWCS+xZ8W7pY3LZn9 +Hcvj5XHOTbFcDF5ARTrgnyijPAqtvClvLXx00WkBWLPuDzFnhvE5nInopTG4hMCscCQFLrvgynqV +C8OpfwokVuDYqhSm5lyRvjHFsnn3GFkt41gnyjiSApoPkhKi2+5REbg9P8hba4k5yNWVVtuAuR+H +3JuUUZ62zdbx8r2oJwaRiO6XcDMYuLlcd26kvwJrQvxXvpOCO9s0uJbTUSS6e1KsE3PsT8X3Z25y +Hg+WYCbxbIZEunjXJRNPxH3fJoJLKqHUXyFf27Ugbjf6ho0nvHHjj2s5SotAipjn7Kgpf5NVamba +GhfVFZZPtpPU2xJfph9LqiL/AOmj+2rYnHoUX1yhEcXxURREFHplkTRPktUqTkJWE+tf2+gYaBBe +xGTJ2LGZYcIW4yipNtoKql3kW10ptCujq3B+f4fnXEJWdxMZ+LF3PR1bkiAubmwRVWzZGlvX7ajR +tOT4u4RwHkHI8NnM3gDNcjxpYsoYzV0eMDV1SNkh16jSsoSInjrbWyLts4JH1R2K7zMc7wJ47JuC +3yjHNf72Gg/ENJokgE/YNE8F9ypWGjrW0nNfqaohZHlSKl0ViIiov752tWM8Z0P60jDAdpJZA2Il +8XF1RERf4ypXU1fQtfq7x2C7N8cImxIlCTdVFFX/AIt6pbUtNDj31qZbUHunxmY4Kq1FhMPGIWuo +tzHSVBvZL2StV0MX1N3/AMYPbn/2vMf7KL/+RU2mvcRzfhXLcfy/60sHkWOaeZhz3TJpqQgi6nTx +hNLuQCMflAvgVVrBhObH17WDsc17x/8ANu3v/dET+bdqozbodKqGjlzrseV9YqN9GKhvQMA63nnG +/AUcfEo7Tip9/f1Ii62q9DPUrORcwwnFu/z83Lk8LD3GGWW1jsPSS3rOMtRZEyRLCuq1ehG4ZsP9 +/Pbz+VyH/wAZO/E1ILuRtXI8Jj+V8UnYiRf4PLRSbQlFUIeoNwPatrEBWJL+aVCtSa52W5FNyvCm +oGUW2d4885hssCrdetDXYJLfVd7e1b+a3qslXgquR/8A27vHhuOj68Rw5tM3lk8RWc6m2C0X7oBV +XU91OgeWZM/Eb5P3qxmEyCI7h+NYz6aSIWoOzn3lYZIxXQkaAVIfYtOgeWdNqGjR+9GPlSO3mTnw +pLkTI4JEzEF9slGzsH56xIi2MVESTaunn5VUZtoa/wB5smmV7P4/KIOxJ8jESUD2dZ9o7f51Vakt +odYrJs1fup+rLln5nn/kx1US2hn7c/q94x+aYP5MFGFoWPI/+nsp/RH/AOaKoVmrdjv1S8X/AKEP +8Iqr1M10K7ut/wBYduPz2X5OVELdDpVQ0cz7cfrW7n/0nF/kZVXoZWrMvagpGYzfMeVy3jN2VlXc +VDZUi6bUPGL02kQL7UIiIyL/AMVoxU6PUNHMudRG+Pdy+IcqgIjLubl/o/mmw0SS2+2RxzNE8SaN +r5XjbTwqoy9TBzWBCyHe3iUGcyEiHKxGVakMOJuAwMUQhJF8lSi0D1MfFJ8zttyRjgubeN7iuSNU +4dl3Vv0iVbrjXzXzG/zSr4+HuG6kWMEzvv8A8r4l/wB04r+GdRFsdMqGjl/ecDzOY4XwdwyDHcjy +DjmUEVUVdjY5tHzYVU8jVU+5VRm3Y6JJkYzCYd2Q4gxMXjI5OOI2C7GmGAUl2gCKthAfAU+xUNGj +l9YPtAKKRZ9ERPFViTUT+Zq7WZ3ojfWJdbe7I8gebXc24EMwLwuJTGFRdaV1F9DpbX8UH71P2qho +5h3Vdjv897cwIaoefbyyykENXAx4NF8UpW1EDRETXxt7qqM21Qcit8n77vNT0R3H8LxzD0KKWofH +ziUviFRdFUGxsPsWypToNWdRqGjn/fHANZDgE/Ksr0MxxwFy+JnDo4y7E+dLavsMAVFTw8PZVRmy +wUnejKrl/q/vZZR2LkGMVKUE8E68qM5b7W6qtSW0Ot1k2aR3t/VPyj+guftpVWpm2hln/qckf9un ++QrTqOh77P8A6rOK/myN/NpR6lroa99YtyOfb4cc2qFnZ+QhN8faT+NWYkkF3Np46N7rr7/fSpL6 +HUKho03vL+qrlX5uf/g1VqS2hoP1Qv1ZZD88P/k0arYzx6FB9cz/AJZxb/XTP4DVKk5C9wPdr6uM +fB45iY9ASW1FZCQhYqQS9QW0Q7kkZb+rzvSGVWqbzw7uB215JByUDhclpwYbJPSWGIr0UB6iKKFZ +xpkVVdvlUaKmnocb+pj/AMTy395A/bkVqxjjHfPthluFZ8O5nBt0Vpt3rZKOymkd01sTqCmisu3s +4Pgl/wAFdCYtWMoxfUz/AOZ8p/1MP+G7Sw4zov1p/wBUcv8ApcX+cqV1NX0Lb6un6meN/vJP5Y9U +tqWmhyD6zwAfd/iQGKEBRookJJdFRZriKiotaroYvqfR36G8Q/8AY8f/AFVn/RrEnSEfN8SHEh/X +ECNEYbjR23l6bLQiADfE3WwiiImq3rfQ5/4j6orB1OVd+Mf9InwaB8S/D+J5HGa+KiH0pDe5p1N7 +Tll2knktVGLGfI9k5TsCQ1C55ypqWbZDHceyhuNo4qelTAQBSG/iiElJLt8TB2HcwuOx+R4u7jhx +XMsW7/8AYmSInHZZL8iaLrikbjbqLdNbCq+xUuYqSmCEfrFzFJURP0Ua8fzgVOg6nSeq1+GP3UqG +j0hIqXRbp7UoDkXIctD7cd1pGdmn0ONcugOFNP70MljG1MV9iK8x6UTxIq1qjDwy47H4manGZPKs +qG3NcvknlpSL4gy5pFaRfwQaso+zdUZaknk2GyGK7iY3nUUmhxIY+RjuTk84jaNQ27ympA3+UoOI +qL52X2XVAesm3YbOYfNwG8hiJrM6E6iKD7BoY6+S28F9qLqlQ1JzrujylvkgH234o+M7OZmzGWfY +XqNY+CpJ8Q6+Y+lCILiIXvr9i9RmznB676Qo8DtYxCjptjxZuLZZH2A3JbEU+4lELaHT+q1+GP3U +qGio5hijznEc3ho5j18jAkxGlulkN5kmxVftlREZrHZjmOLy3DcZhidGNyDBxm8dlcS6qBJadiAj +Kqra+raWzcipp5eKLVaJV4JndTnGG41xae2+8LmWnMORsVi213yJEh4VbbEGkuSpuJLrb9miQs4J +/bXAS+P8BwOGmJaZDhNBJFNdrqjuMbp47SJUoypYNa7rf9YduPz2X5OVES3Q6VUNHM+3H61u5/8A +ScX+RlVehlash4nMR+2vN8xh88XwnF+TzjymDzDmkdqXIRFkxHjX0t+odzd9LVdSaM6i5PgtQ1mu +SWghoO9ZJGKNIP4W9V22996ybNAkNJz3mXGczipUeZwvAFJlnLYcQ1eyYXYba220FpFVxD8C+5em +dWYeTfr64X+bMn+0NOgepufMOJYflnH5WDyze+LJT0mOjjTg6g62X3pguqL9rwqJlak4Ry3lGdjj +xvgPLiVzkmI5JinoeSsuzI49HDAJKL/KDdBcT2+3WtGG+h9IVk6HPu8GAzb8XDcp4/HWZm+JzPjm +oI/KkRjHZKYD90YeH2LJraqjNkbBw7n3FuX48JeFmg6dvn4RqgyWC++B5lV3Corp7PYq0aKnJr/f +yZEj9puRA++20b8bYwJkIqZq4PpBFX1L7kotSW0Krvt+oDK/0bH/AJVHqrUW0J4dmRVsV/TjlyXR +PDLF7P8AV1JG017tJjoHEecZXjPJG1e5nKQn8byWU4465lIF7oIG6R7HGtvzjY+Nr62vVZK4ZZ8y +ku8E7lM86faM+LZmI3i+QPtiprEeaO8aUYjdemqL019n2VRFiK8OTpmOyeOyURuZjpTUyI6l25DB +i42SL7CFVSoaOad2OWNZ6K5244q8E/kecRI04mV6jUCESokh6SQ6D6PSg3vr9i9Rmz6Fn3X4dJnd +nclxzCNk49EiR0gsolyNIDjbogiJ4kQs2RPbRPIssGwcI5zgOYYRjJ4qSDhGArKibk60d23radD5 +QqJaa+PimlGipyaj315LBXiknhsAxm8p5Hsg4/FtKhO/OGm91wUuoNiCKu4tP2aIln0Np5PBSB2z +y0FC3JEwshhC9vTikN/2KFehzvtr2oHI9v8Aj0/9MOTw/ioDDvwsTJq1Hb3Ai7Gm+mu0U8kqtmVX +Bu3HO0vF8LmAzbrs7N5toVCPk8xJOY+0K6KjW+wh9lBvUkqqbpUNEXK4rH5bGycZkWUkQZbZNSWC +VUQwJLKKqKotAQuL8R45xXHnjuPwQx8Jx0nzZbUyRXSERUvWpL8kBSkkSgwcr4JxLlrcZvkWNbyI +RFIowuEY7FcREK2wh8dqVZDUmu/3A9n/AOzMf/aP/jKSybEXnGO2/COLHKPAYpqAU0EblK2ThbwG +6oi7yL20kqSR74p2+4bxIpJccxjeOKYgJJVsnC3o3u2X3kXhvWkhJIvpEdiSw5HkNi8w8JNutGiE +JgSWISFdFRUXVKhSg4p284ZxNyS5x3Ft445iCMlWycLegKqii7yLw3L4VZIkkT+R8ZwXJcYWLzkQ +Z0AyEyjmpIikC3FbiorpUDUmTBYHEYHEx8TiIwxMdFQkYjgpKIoZKZWUlJdSJV8aFSKzkPbvhfIs +rFy2axbc3IwhEYsgycQgEDVwURBIU0JVXVKskaRsVQprv93nDP0r/S36Lb/SK+76R3Ob79Lo3tu2 +fxfp8KskhGxVCkDK4HEZZ2C7kYwyHMbIGZBIlJOnIBFQXEsqaohL40EE+gKuRxfASOQRuQuwwXNR +GiYYnCpA4jRXu2W1UQx1XQr0JBVcn7XcB5TkQyOfw7U+aDQsA84TgqjYkRIPoIU0U1qyHVMqP7ge +z/8AZmP/ALR/8ZSWTYjbeOcZwXGsYOLwcQYMACIxjgpKiEa3JbkpLrUKlBj5PxHjfKceGP5BAbyE +NtxHgac3IguCiihIoqK3sSp40kNSWrTTTLQMtAgNNigNgKWQRFLIiJ7EShT0qISKJJdF0VF8FSgN +DyfYrtPkphy5HHmW3nVu78K6/EAr+O5uO40C3+xVlmdqNm45xPjXGoSwsDjWMdGVbmDAIKmqeZl8 +o195KtSSpQZORcbwfI8W5is3EGbj3SEnI5qSIpAu4VuKiuip7aBqTUf7gez/APZmP/tH/wAZVlk2 +IvOK9t+EcTkvyePYprHvyQRt82ycJSBF3Ii7yLzpJUkjFyntdwDlMhJWdwrEqYiInxY72X1RPBFd +ZJtxbeV1pIdUzxxntR284xL+NwuEYjzU+TLcVyQ8N0su118nTH7S0kKqRtlQpAyWAxGTlQJc6ML8 +jFvfEQHCUkVp1RUdyWVPJfOggn0BXwOP4eBksjk4cYWZ2WJs8i+ikqukyGxtSRVVE2jppQQZ8li8 +blITsHJRWpsJ5LOxnwFxsk94kipQGjh2A7QBJSQnHGlJC3I2T0kmb/6knVa/zassztRvcOFDgxWo +kJhuNFZHYzHZAW2wFPIRFERE+xUNEWRx/DyM1Ezb0YTysFtxmJKVS3A29/GCiIu31W80oILCgKjO +8R41npECTl8e1MkYt5JGPeO6Gy6ioSEJCqL4ii28NKEaLehRQGpcl7T9uuSylmZjBR35hLc5be+O +8S+0nWCaMvtrVkjqmQ8V2P7VYuWMuPx5h2SCoouSzemWVPBUSSbqIv2qSybUbTnuP4bkGHfw+YjD +LxklAR+MSkIkjZo4OoKJaECLotQ00WCIiIiJ4JolAVWc4rx/OuwXsrDGQ/jHkk49/cbbjLqffAba +iSeCXS9loRos3WmnmjaeAXGnBUXGzRCEhVLKiouiotCmhTewnaWXJOQfH22TdW7gRX5MZovd0mHW +27f5NWWZ2o2jjXEOMcYiLEwGMj45grK4jAIhGqeCma3M195KtSSpQW9Cmm8h7OdtOQTyyGTwTJTn +FUnJTBuxXDJfEjKObSkvvKrJHVE7ivbfg3EzNzj+HYhPuJtOSm519UXxRXnVNy3u3UkJJF9Nhxp0 +N+FKBHYsps2X2lVUQm3BUSG6WXVFqFMeKxcDE42NjMeykeDDbFmMwiqqA2CWEUUlVdE9q0BKoBQC +gFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQ +CgFAKAUAoBQCgFAKAUAoBQCgFAKAqT5JDDIO4/4eUUpkEdMAZIvmyIhE0VPFFUFtW/bcSed/k13O +sOV4EvH5WDkEcSM4quMqgvsmJNutqqXRDbNBMb+V018qlqtanTj5a306EusnQrmMs65nJGLcjdNG +mAkNv70LeJmQfJRPTqHtrbr6ZONeVvkdGukkiDNOUL6lGejdF42UR5ERTQFsjgWUrgX3q1myg3S+ +6cNQ+v1JNQ2KAUBGcmGGQahpGeIHWzcWUKJ0QUVRNhLe+4r6aVYxJh39SrD8+hJqGxQCgFAYpbr7 +Uc3GGuu6KXBrcgbv8pb2qozdtKUpMGFyKZPEQskjatJMYbfRpV3KKOChWvpe16t6w2jHDyb6K3+Z +STKydRQCgK5jKvHm38Y5G6aNMjIbf3oW8SNQ+SienUfbW3X0yca8rfI6NdJJzrzTIb3TFsFIQQiV +ETcZIIpr5kSoie+sJHVtLU90KQ8vOegY2RMaY+JKOBOK1vQLiKKq+pUXyStVUuDnzXdKuyUwZ4kj +4iIzI27Os2Lm297bkRbX+3UahmqWlJ9zLUNCgPBPMi6DJGKOuIRA2qpuJBtuVE87bkvSCSpg90KK +AUBXfSzqcgHEnG2tuRnJLUrei7ukbYEOy101e8b+Vb2+mTh7r9zZHSZ8o/uWNYO54deaZFDdMWxU +hBCJURNxkgAOvmREiJ76JEbS1MMmabEqKwMZ54ZJEJPNoig1tFS3OKqoqItrJZF1qpSjNrw0obn5 +eZJqGxQCgFAKAUAoBQCgFAKAUB4eeaZaJ14xbaBNxuGqCKInmqrRIjaSlkHLZZ6A9CFI3WZlvhHN +1DQemri2Rdtl3VutZk5cvK6NYlNwWNYOwoBQCgK5vLPFnXMW5G2CLHxDUjei703oCptRNNV9tb2+ +mTiuV+5sjpMljWDsV0zKvRsvAgrG3MziMEk70TaYNm7bZa66B7a2qym+xxvyut61jFuvwksawdhQ +CgKJj/rmb+bIn5RJrq/4Lzf2PJX/AO9/7K/WxHyzoROZ4ySGiuwZqTUHxVlhWjBV/emSon75atVN +H5oxyvbz1f8AptPkoK8M3k3sEOXZdnOZNxpJTOPbhvLGLcm8Y6L0dUVPT1N/jre2lb2JWjEeZxXP +d8e9O26Jja48tPnJciilzSQiKoquMZsSWunz7vtulc/8HxPV/wDu/wDYvqyrI+Ruceyxxsi+5kMf +LkIwexje62yqL0lTpbLkKKiKgpqtb9O5SsNHnnlfHaLPdWz7ZjpoWHxjkmVBfiZB5YDcNJsnaLKo +6BW6O67d0V2xqu1U+T5ViITlZk7b3Z1as9u2Xpnt065/QqQzmTfwI5hl6ceTcZSUxj24bxRl3JvG +Pfo+pCT09Tf46ottK6bErRiPM8657vj3p23RMbXHlp85LGYOae5QsBnIusQZENXzRBZ3tKLiBZle +mvyt2qnu93tTCjbMZk7XV3y7VZqrrPTHlj6yZWkyULkmNhOZF6XHfgySdB0WURXI5xxFy7bYFcuq +V7rao4dW46/3NLdXlrV2bTq+3Tb4eJHgR81MyeZgu5iQMWHIbFlxsGBfs5Hbc2qfT2bRU9LBf2rV +s0knBjjre1r1d3CfhOifb7HhvNZP9FYb7sjY+cv4KXkNopsAJJRye2qmxFLZ5ptRVva1XYtz8vsR +c1vaTbzuhv8A5RJJiJNTlBwwyb78KNHB91leiSI6ZECA4fT32IU3Cm5F09ipWXG2YydKbvd27m6p +T0+eCZm5soJOPx0RzoPZF0xKRZCVtpptXDUEJFHetkFLoqa31tWaJQ2+h057tOtVh2evlkyxoWRj +Snd0xZOPJlNoPIiug8irdUMUG4EPkvgvhRtNaZNVpareZrHXWSgw06WHGOI46I50HcjFYA5CIhE2 +01F6hqCEijuXagpdFRL38q63S3Wb6f3PHw3a4uKqxuSz4Kslk47Nx+ZhQFluvxcmLzYE5sV1l5oO +ohASClxIUK6Ei6onlpWITTcaHdu1Lqstq0/BoxY+TkX+KTHXJzvxTDs8AmILXU2xpTrbd02dP5La +Ivpq2SVljt9DPHaz4W28p2zjpZ+EdDymQyUwMFjm5JMPT4azJssBDqbGhaQkBCQgEjcfTXbol7U2 +pS+zJ7lrbKpw7Vlvyj6tnrHRn43MZDTklyUP0c0rZuoO9EV9z0qoIKF4ey9LOafEvHV15mm59K+r +PXNYzz0CJslOxx+PgCQNo3YlKY1Yl3ga3FdU1t7UWpwvL8n9C/m1bqsteqvb/MvAy5H4iMcNl/Km +EVRcQ7CKzJDt0UBAW2/kiO6+wL+HvqVzOP7GuSawnbH/AHN/BfREONPkzOKZtJBm6UX42ODro7HC +bAVUFMUQfVtJEXRK26xZfA5V5HbhvPTchKyzrDeDxjTjrCS4qvvvx2SfdRtgWx2gIg5ZSJ0fUo6J +fzVFqKsyxbla2VUqVOFOkefc9sSMm6WSiMyZgRQjg9EyD0fpOg4qmhtJ12kE0sArdRVdV18KNLDw +WtrPck7REpxnyyskH4jPBxbH8gdyjhSenDeciiDQxzB1W0MSRRU1IhNfVvTXwRPCtRXc6x3OW7kX +FXkds+nGIzBMykGU9zTHoGRkMCcGaYi2jFg2uRRVB3tHoV7ruuvstWatbHjqvudeWjfPX1Nem3bv +XwLrNOZFvESzxodSeLRLGDRVU0TSyLZFX2X0rnSJU6Hp5nZUe3+UYKOFnYzbUyczkXpTUGI69Nxs +sUbkg4CIYlsUGzFFQTRdNvhauro8KNWeWnOknZWb21bdXr9P2Pcn6bi8ePNLPNyezHWY7GVASMSC +HUJlB27kG3pEt27zVV8Ki2u22MFt7lePfu9SUx07wZusMjluNeC6C9ipRivmiE9FVKkRR+a+5qZ5 +qvvS31qZMCc6VEyTD0143GZj0dqVtaR0QDbbRA6fn+BS8JrHQ1wO1lZNvFmpx/aPkUcpzIZLh3HZ +j054H5D2NWQrYsohm4+0u9UJstRLVLae1FrooV7KO55bO1+Hjs7OW6du68C5m/SELMYFocg+8xIe +eYktOCxZxEjPOiRKDYEhIQJ4KiWTwrmoaeD033VvRbm0209OzfY/Ybk3MS8gfxjsSNDkFEjtMbEV +SaEd7pqYndd5KiD4WTW9HFUsCjtyO2WknCj6lbNy2acwDr7UtWchjciEJ4gBvpPp8S23cxISIUJt +xFVAJNb1tVru0w19jhflu+OU4tW0eD9SX07E8xymOzmLRzIuy2sgbrEhh0WhbEhZN4TaQAEht01G +ykui6661jDq8aHZq9OSs2b3Snp2nH6EOVk5Y5KXGm5B7FTCf24pTAUhuN2HYiOKBCRFqhCRbr+Hl +WlVRKU9+5ytyvc1azo59P+X+vmbTJV9IzqsKCPoBdJXL7EO3p3W1238a4rU99phxqavGyj7WSwoN +ZF7IBOcOPMcVtPhjJIzjyGyaAI/LasiASpbxrs64eIg8NeVq1Is7bsPto3jHgToZzsw/PdSY7EjR +ZDkSM0wgIqkzYTdcUxO6772HwsnnWXFYwdaO3I7OWknCjw6kXJryVh/j7f0ggyZTpRJwtgHQJUjP +Ok8KECuIadO6Ju238qtdrnBz5fdTot2W4fb+Lc9+nkZUDJN5VjBDkn3AJp2bImOI119m8QbYBRAR +RLkqqW2+mnuYjdBqLK649z0bbxPloHpuZhzMhiozizZKwDmYontqEjoqrfSNRQEId6gqKuuq3WiS +aTeM5DvetrUXqe2az9PoecDkRlTEFjJPuPtsks3GTwFp9HFUdh7NgKIou5F2+nXSl6wtPiicHJut +izmM1th/T9jBgMlIkSoTMvIvR8wNyymLlALaHZstyR02JuEXNqiQEvp8VWresJwsdGZ4OR2aTs1f +/FV+XT49uhM50w67xuUoSHGEFB3i2jdjQjFLFvE1+5as8L9R0/Oq3xPMGLkkeWxBxrQSifkLko+y +RIEFVFJVTUWhaRUT2fs1eNpt+RPyatVqpl71r+0GZhMjA5HEiuZB6bGnRpBuA+jSbHWCa2kHTBuy +KjqoqfYqOHWYiDVd1OVJ2dlZPWOkdl4lcxmZOSiSJwy50d1XHhgsx4brjIi0ZAG9UZNHFPbcvVpe +yWtetuiTjH6nCvM7p2my1iKuMfDJtGNkSJOOiyJLSx5DzLbjzCoqK2ZCikCouvpVbVwsoZ7+OztV +NqG0al9IZR/AQckGUeayc6Syy7EbRlUFXXkB5gANs7EwCkt1uvpuWld9qVmowj5/uXfHW257rNKM +d8rTp9sly02TfLm2yMnSDGbVcO24lR5E3FtQUuvuSsP+HxPSlHN/w+5HxK5bM4ZvLt5ByNImAr8N +kRbVlsCurQGKipHcbb13X8du2raKuIMcW/kpv3Q3ldvD9yK9PezTXE5zJrDdmGbhEKCZApQ3VNB3 +XG/iiKqL9iqq7dy/rU5vkfKuKyxu/wDFkiTkpeAyDrUmS5OgnAkzmurs6oHD2K4CEAjcTF1LXTRU +qKqssYcx+pu3I+KzTe6u128fT/1IqZTKJj485p+dJyRK0bsNIbwxiEyHqNDdlNoiKrtPffS6qqaV +rapjEeZz92+1WTs7YxtceWnzk3CvOfSKMsLmkzkjKtZCMJPMBGFoorhILbbhuDdUkDcvnVuv7Fdd +9dsR8/2PL7N/cd1ZZUfx8/8AV4kqBhAZlPTpbyzJ8gEaceMUERaRb9JttNBC63XVVXzVdKza8qFo +b4+GG7NzZ/TsiJj8HmcfHDHxckCYxn0R0NhTkttJ8lsXVc6a7U0FSbXTxvWrXTy1k58fBei2q3pX +hmO0zHyMw4jJJn3Mr8Yz0zYSMkf4c9yAJEYr1Ot43PX01Ny2xBv2re5vlaRp+56wWJm474z4mU3J +SVIOSnTZJraTi3JPU47dPZUvZOIHBxWpMuZc6R92MFgGMSxIYFxXgedMm0JE+bZVV6bCfuG0VUSl +77hwfjrjTWsv5dvgR8fg8xj2Ax8TJNji2vTHQ2FOS015Ni6rmxdqaCpNrp43rVrp5ayY4+C9FtVv +T5ZXhMx8jOWJyC8iHKpLaRgWFjfDKwSlsUkNV6nVtuun4FTctsQb9q3ub5URER95+x+ycTPdz8TK +BKaBmK06wkdWSIiB8miP5zqil7spt9GnvqKy2wLcVnyK8qEmojvHWfDsecfiMnEm5OUcxlwsgQuC +KRzFG3AaBob/ADxbh2tpdNNfNKtrJpKNCcfFetrOV6vDwjuU0/GzcbhImJcmtOOzcinRfVhQZRXH +TlkL4K6e8CUVFEQkvonvrpWydm40X7Hm5OO1KKja9V+2NXbOSZFcy+KyUDHu/AORpxuCrUKOcY29 +jROdVRV14SC4IK+GpJWXFk3nB1o78dq1e2LdlHTXVlnmcQuQCObL6xZsN3rxJKCh7T2qBIQKqbgI +CUSS6fZvWKWjyO/Nw74hxZOUz8ai59W3VfnR1fIdrQtxiRkdUVSIVdUyW2iWNET2LRuvYiryRmyn +yx9fuQYfF5DGGxkIpglMw+xIEwGVBEEGujtcbVw96GCqhWJPdZUvWnyS241OVPxWqVrOaaOPCM5J +jOJkuZJrIZF8HnYwGERpltW2wVy2813E4pEqDZNUsl9POsuyiEdVxN2VrOY0IKcbygNToLOSBrFz +HH3kBGLyAWSRG4Auq5s27zVf4u9ltfzrXuLDjJy/9a6Tqrelz0znxn7GVOPTQiYxW5jY5PFNqy1J +RlekbRCgk2bSuKViQBXQ/lIi+6nuKXjDL/69kqw/VXrGP0n7nuLhsu3nFyr89l1XGBjvRxjkIoIG +Rp0y6qqOp67t32qjutsQWnDdcm92WkafTP8Acl5vGHkYPQadRh4HmJDLqjvFHI7ovDuG43FVCy6p +WaWhnXn4t9YThyn+jkhv4bMlkI+TZmxwnAwcZ9DjmbSgRoaKA9YSEktZfUqF7q0rqIjBytw33Kya +3RGmPqYQ41k24GUgt5Fsm8kThq69HUnBJ8EF2+xxoV9o2FLe+r7ilONDK/Gsq2qrfy8O+vVGRzj0 +0o+OMJoN5TGCoMSxZXpG2QoJtuMq4qqJIIqtjRboipTes4wyv8e0Vz669Y+qn7ktIeaOJIB+aysl +4UBtRjkjIJ5rsV3eRKi/ylvDT25ms6HTZdpy1L8MfX7kB3jWRPi8fBJPZRWAaaWSscl3Axt2ejrJ +YvRqu77VaXIt26Dk/wAaz4lx7liMx2+JLm4nIuzoOQYlMtzYrL0d1TZI2jF9WyJUBHBIVQmUVPUt +RWUNdDpfis7Kya3JNad48fAnZCNIkQzZjyFjPrtUH0TdZRJC1G43RbWVL+FYq4Z15Kt1hOGV/wBB +Oyp6TMo40+ox3YgtMtk2KtyFFXN6kZqV+mlk0tr41vfChHH2Ha268PDWnfUwnx7Iu4z6HenieLUE +YNekqSTYRNvTJzftuo+kjQNU9i61fcUzGTL/AB7Omx29OmmY7TPzgzuYaeufj5NqUy3HjsHFGL0C +Uum6TZl84jqJuuym30WT2LU3rbBp8NvcV01CURHl4+HYxN4PKx5kxYmQBmBOeWQ42rO58DIRE0ad +U9iIW2/qbW1N6aUrKIuC6s9torZzpn4OfsYmeLym+Mw8OswCfx5xziyUZVB/3UxMENtXCUr7LFYk ++1VfItzcamV+K1xKk5rEOO3xJEzEZiVLxkopscDx7hPEKRjVDI23Glt8/wClNjvv1S/uqKySajU3 +fhvZ1cr0+Hmu/ie0xM6LLlP4yS003NPrPMPtE6IvbUBTBRNpU3IKbhXz101qbk1noX2rVbdXG7uu +v6oiy+LPHhUxsWYLZuSBlypTzSuk48j4yFKwuNINzG1vIdErS5My0c7/AIr2bU+stxMuZ7ok5HEZ +OXPxksJjLf0eSuECxyLqGbRtHZesO0drmia2XzWpWySajU3ycNrWq5Xp8PCO5hn4HJToUvGyJzbm +PmE7vU2SJ8WnTUlbE1c2+lCsBbdNNNKtbpNOMmeTgtarq36XPTOfj+hZ5bHhkcXLx5mTYS2XGCcD +5Qo4Kiqp9i9YraGmd+Xj30de6gqHcBnXygPP5Jj4jGuo5GFuKoMrdo2SVwOqpKuxxbbTRE9i1tXq +pxqed/j8j2t2U1eMY0jv49zP9C5OLOlSMVNaYZmn1pEaQwTwi8qIJONqDjKju2puFb666VN6aytD +Xs3rZujSVujU58MoZDCZSS/jHW8g2hY5xX1V6OrhOOE04yqrsdZQR2urZET7dK3SnGo5OG9nVq38 +c6a4a7ruSchinHpsfIxHkjzo4G0hGHUbNpxRUgMUIF+UCKKoui+66VK2xD0OnJxN2Vk4svoR1wUt +w5kt6btycllI7MhkFAGGxVSRABSJVuRXJVLX3Vd6wowY9huW36monsfp4WdKmtTJ0pvqxmnm4yxm +iaVFfFBI1IjcXRE0FLa+2m9JQg+G1rK1nonELv8AE8phci+7ALIy2nwxziPNq2yQOG4IE2hEROHZ +PWqqiJr7baU3pTC1Hs2bruae3w/cm5rG/SeKkwep0VfCwu23bSRUUV23S9lTwvWaWhydObj30ddJ +IORw+bnsxEcnRm3YsgJKqMZxRJW1uI2V9FRPbr9ytVvVTj+v0OXJw3ullYc6f/Iyy8TkH85CyQy2 +gahg438OrBERI9s3+vqoiL836fTp76KyVWoNX4rPkVpWPDvHj4GGPg8tAN5rGT2mse86bwsPsK6b +JOkpuI0YuNptUiVUQhW32NKrunqsma8F6SqWW191MT2yi4IuhGUjUnekFyKyKZbU1WwoiXX3JXPV +nomEaXi2sxEwAZyPJxZAsdZKPPxj65Ad3NrskHkRS1sRbPHyr0Wh225/rwPmcSvXj9xOmk5Wfjaf +sXkPH5SRlo+ccfbZB2KLRQCYLqAJqjiirnVtvQtL7PtVzdkltPXTju7rkmPTpHx1n7H6zgslEhnj +oE8GICqSM3ZU32QNVVQbPeg+m/oUgW3nuqO6blrIXBatdtbRXyyvn+mP1PEjjT6HiRx0sIcXDiqR +mVZV1VXpEym8lcG47S1S1/3SVVyaz1Jb8Z+na9qppjwjv/XczM4J16W9Lyzzct1yOUQGmm1aaBlx +UV1EQjcJScUR3LfySye2O+IRpcDbdrucR4R1/U84/EZyG0zCTJg5AY2g0RMXldMfkgTquK2q2S27 +p6/Z1pa1XmMk4+HkqlXd6V4Z/WY+RdVzPUKAUAoBQCgFAKAUAoBQGORGjyWSYktA8yaWNpwUMVT3 +it0WqnBm1VZQ1KMMPFYyCpLChsRVOyGrLYN3RPC+1EquzerM04qV/ikvIlVk6CgFAKAUAoBQCgFA +KAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgII4LBjJ+JHHRkk7t/XRltD3 +fhbrXv761vtpJy9jjmdqnyJ1ZOooBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCg +FAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQC +gFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQCgFAKAUAoBQ +CgFAKAUAoBQCgFAKA//Z +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_-- + + +envelope_from noreply@grupokonecta.net +envelope_to joe@domain.org +helo_domain rheology.yeloweditions.com +iprev.result fail +dkim.result pass +dkim.domains rheology.yeloweditions.com +spf.result softfail +spf_ehlo.result softfail +dmarc.result fail +dmarc.policy reject +remote_ip 51.89.165.39 +tls.version TLS1_2 +expect_header X-Spam-Result: DKIM_ALLOW (-0.20), HAS_LIST_UNSUB (-0.01), ARC_NA (0.00), DKIM2_NA (0.00), DKIM_SIGNED (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_EXTERNAL_IMG (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HAS_REPLYTO (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), REPLYTO_ADDR_EQ_FROM (0.00), REPLYTO_EQ_FROM (0.00), SPF_SOFTFAIL (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), HELO_NORES_A_OR_MX (0.30), DATE_IN_PAST (1.00), MID_RHS_MATCH_FROM (1.00), PARTS_DIFFER (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00), VIOLATED_DIRECT_SPF (3.50), DMARC_POLICY_REJECT (4.00) +expect_header X-Spam-Score: spam, score=16.29 + +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; s=sectionalism; d=grupokonecta.net; + h=To:Subject:Message-ID:Date:From:Reply-To:MIME-Version:List-Unsubscribe: + Content-Type:Content-Transfer-Encoding; i=noreply@grupokonecta.net; + bh=dP5ixF9HZsWlj/p3qHOLHJE+ZBdEajVqh0aowOaVxc4=; + b=nxR6ACs3hInMKEyEK9yQIEgu4RuffMNmnA6a2mC6Z1c9Qbao8AsmuWct1i7bvn5lU1mmDSXx6PwO + /w2Z6TSZIW1jZkhQwDo5sJWO/0f26zabNJ3bAsZnN/Yyy0Lf4oUJuaZ97cwHiMPlDIPF++gyuY6s + 30mpjo29dX3ZCSCkMkcmox55AsQSK2nJ2ZIv8dqep02kiXjldaiW5hIRzHwjeh+REy3Mb5zCuIVJ + 4dcKomEGK3JsnnY1mdMVHB1ghlVMnms+HSu2AYi7186j0QSdobMxNuBzAKqzDRfunSljG1IL1im8 + bnZoghgIpHHfEKY5+b5hStqgBQoPTh2CJMAGWNDkdvoFK3m606RRRiKIOP69M4v5a1UjBBoyP7QO + AUNbgcjHWQCl83a1ofW2LKSDPcyfj2TVK4yjQlPAH3Q0TJgeig1beio7Ahnz581H22kkyxPtMB56 + YVA2cG7FvBB40HLmuX1YKu4TAwJ8ZJdCOnt2xZ9lYLb8LuduCFrd6RZH/44lUu/DpnM1lN3Q3oXe + moOQCtyUsI77tu7rzy0vgzZME4v0j3S57CYHJktZGvk/3uFHTWhStYlWNk+Ks/rCfkQjSmQV1u+T + ozHF7td0rxpD0b8534vjwABr/NwpjlSLIgBvt9d0nBtipYYOumSdXpCj9mB6uZbGVq9K0mdusbY= +DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; s=sectionalism; + d=rheology.yeloweditions.com; + h=To:Subject:Message-ID:Date:From:Reply-To:MIME-Version:List-Unsubscribe: + Content-Type:Content-Transfer-Encoding; + bh=BwKu50FuSdWv9iToeKQHRPlPqN0=; + b=UT4zz+mBbtBTs9S/YEiEH7B3N9gklIJBZLKAyaCOvddh0wWb33fT3qyqCKo9Vfyhp+gBHlQ/pfvZ + xLRSO8SjfAOLs/0Xs0sy5uVLuNXh5UfdT+IwG5KSgA+NpRvOTmHvfUzFP5DoiykHP0eutX2TxkRh + dJHjLIthv+EISFEubGu1ZgFUBReFTCvhZCrYPYJlxze/tmWWChRK7r804EBR3gJiLEvgXQho5Qh5 + ksdnvvqAAVTHNOWAFbxoojBmNCs/gn0BbXHeJGuR/ZRXUbC1ZzQ4hw/9xNSCHI9n24IoD+vfhldb + k2hSVGQisRxn9vTISgDaNjEgFdr+0hUTqSTvrVEDdJYjCmYK/HrJrb8oeh+G/mz30pYQJlJUYIuJ + VcGkqXdFQdenNJPPD1UccGCpWblaaSlqY2AVk3mjX08Du2jbuzEFnX2g5UA5okGOmUi3h5C1v3vG + zLMuOT3686ZovcbxlW+K1GkOL1IZTkgXiw2X4EDZ6nE191uyVpIi3NTBkRIIWsni2lpDfg9+qq/J + /viVYHHmyG7xpB+gmS9r46HZqZYcYA9g5NQuTaYPIzngwlODdoSVsDb9X7/aU2Mhvp+ayq5g5/fa + 4cbe7mAJMaCifxx5YA2KqkYLqRS3Cm0D2AJGqk9DpkpncHL+g8+jOAtoWCPFa5L+yVnQIexiJqE= +DomainKey-Signature: a=rsa-sha1; c=nofws; q=dns; s=sectionalism; d=grupokonecta.net; + b=k7FzASi0Vd8+owLAAzg7gG2fHkmp7q+kS7fNXMORbpbqrny/kgSjEkfLUDJdmPFyY2PlDQ+/dZcs + W6kMg8n/+wKKEMenIz1O0RSCbKoV5N2gchXV1nEevqyZ+Ndf4XwLhHY+Ttlh6dnlMNblWnXZcxjs + 2bvwWSAVakpbSIbRkYeMHJOfQTw7efqEEhKRqXCWHnHJuC9zkf4C3bUe6WcTjBmlCe12dU+HD18K + lkc7TJKwIm81vBgE+HXpVC3YuJsfsNLM4bb/bZmt3yu5SgfEWss6DgOsOa2yNzLWMx6byVhx3zvg + u9k+c+seOdotD0Rphz6F2GaW3LJwOz+Uuhntwir3FZ45GEeyn2V0jiXwVpg8L2RREs3bqCUm+THD + 8p0gSDNbB3SExCuvRmRnvPCogYvO9XGbwX6eh4gl9dGMrmVSxpjnIoJjoAF/AZSPV2FGBhwFWzjr + P+lD8QOzpAbKXSArKcDmJU6ehafQW1fknB1colA97NVan2HCj6B1YdaKv7BtPPjh+L1hwVd3mFaD + Kp4/hGJnW/UE12ZUaLIFVsioZWf6TcNvFaZQzTlL8U5fShX8iQInbAWh9+c0YGGLzcGcjqUV3BCA + ZXLFzE9yrfkpMDPInBqLOXbIiWzIUopFoCPHuEyEDpalYQQx3rXbUKsNnGs8xEipZueJWmdhaUc=; +To: joe@domain.org +Subject: =?UTF-8?B?UGVkw60gb25saW5lIHR1IFRhcmpldGEgQkJWQSAxMDAlIEJPTklGSUNBREEu?= +Message-ID: <61805635a4a8f8915503cb518cbbaeecc82ce04e861805635@grupokonecta.net> +Return-Path: noreply@grupokonecta.net +Date: Tue, 10 Oct 2023 11:52:03 +0000 +From: "BBVA" +Reply-To: noreply@grupokonecta.net +MIME-Version: 1.0 +List-Unsubscribe: , +Content-Type: multipart/alternative; charset="UTF-8"; boundary="b1_3d217f30a568faa9ce3dd7dc73399561" +Content-Transfer-Encoding: quoted-printable + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/plain; format=flowed; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Tarjeta de cr=C3=A9dito BBVA + +La tarjeta de cr=C3=A9dito para viajar con tus consumos +Pedila 100% online y empez=C3=A1 a disfrutar + +Conocer oferta +Un mundo de beneficios con las tarjetas de cr=C3=A9dito BBVA +compras en cuotas +Compras en cuotas +Pod=C3=A9s disfrutar hoy de los productos =E2=80=A8que quer=C3=A9s y pagarl= +os en cuotas +descuentos y reintegros +Descuentos y reintegros +Entretenimiento, gastronom=C3=ADa, farmacia, ropa =E2=80=A8y m=C3=A1s rubro= +s con promociones exclusivas +puntos bbva +Viajes con Puntos BBVA +Vuelos, alojamientos y mucho m=C3=A1s canjeando Puntos BBVA que sum=C3= +=A1s con tus compras +Conocer oferta +Descubr=C3=AD la tarjeta que mejor se adapta a vos +Todas las tarjetas + +Black + +Platinum + +Gold + +Internacional + +Todas las tarjetas +visa black +Tarjeta Visa Signature +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + + Conocer m=C3=A1s +mastercard black +Tarjeta Mastercard Black +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + Conocer m=C3=A1s +tarjeta platinum visa +Tarjeta Visa Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 170.000 USD +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta platinum mastercard +Tarjeta Mastercard Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 50.000 USD y 30.000 EUR +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta gold visa +Tarjeta Visa Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s +tarjeta gold mastercard +Tarjeta Mastercard Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $35.000 + + Conocer m=C3=A1s + +Tarjeta Visa Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +Tarjeta Mastercard Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + + + + +
+ + + + +
3D"CLICK
+
+ + + +--b1_3d217f30a568faa9ce3dd7dc73399561-- + + +envelope_from miah.join@outlook.com +envelope_to hello@stalw.art +helo_domain HK2PR02CU002.outbound.protection.outlook.com +iprev.result pass +dkim.result pass +dkim.domains outlook.com +spf.result pass +spf_ehlo.result pass +dmarc.result pass +dmarc.policy reject +remote_ip 52.103.64.5 +tls.version TLS1_2 +expect_header X-Spam-Result: DMARC_POLICY_ALLOW (-0.50), DKIM_ALLOW (-0.20), SPF_ALLOW (-0.20), ARC_NA (0.00), ARC_SIGNED (0.00), DKIM2_NA (0.00), DKIM_SIGNED (0.00), FREEMAIL_FROM (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_SEO_WORD (0.00), HAS_X_PRIO_ONE (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROMTLD (0.00), MID_RHS_MATCH_FROMTLD (0.00), RCPT_COUNT_ONE (0.00), RCPT_IN_BODY (0.00), RCVD_COUNT_TWO (0.00), TO_DN_EQ_ADDR_ALL (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_NO_TLS_LAST (0.10), HELO_NORES_A_OR_MX (0.30), DATE_IN_PAST (1.00), FROMHOST_NORES_A_OR_MX (1.50), SEO_SPAM (5.00) +expect_header X-Spam-Score: spam, score=7.00 + +Return-Path: +ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none; + b=sTW55J00fLHM5CSFAdYk6Kpyecib7sSXQWU51a+Eo6514pesoEtpNxM3eYurQfYQY7j+MMcwJ50u9fzJPOUm0JInaQMoDrUWJ5dObEglZtxbN1fpwHLOOP5rjWm+zd9p02jLCCpvoHnu4rIZmog1MO/pCiVRMWemUMzJ2O7mk2zbmode8ryb9tT1ho8XNeCYK9zKmoHwCl2p6TjO4HFQ4SU2hYIWd3//6gfnPDN2qIOgw6Z51zgsEtUYYENIKuHswZFWjt7925Wq380r5Fi+fsaKT8xAWFTq9igFNWKDVU2k7ZL6QlCsXpTRS57rrl1dBYAod1byHHbCOqa+g+VOAA== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; + s=arcselector10001; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; + bh=SVZW4LFWvuTy4mbM+Q+E+yH3a0DZrHFN3U3wHGbAHMQ=; + b=NavrW7s0URdfDuEFuzkxV7EUwJtiynvH1o9mzF39USQEfd9l1KyQpzOxo9Po8Dar1qqa/4ECNeUSmetx+NBDArmlTpBak+BKYXAXVRlHheyxILyU/f0RX01+7aifIzLj7LWv7Sx66b9D9/DjaVDbtMvFGPFUzk0JtiATahe7ZU0iKBvsRbGJjS9r0Sq2vHY/SQEUxOxKXUhUQBepSf9k7ibBZK27OhSz9v/jjSDCL/mh5MoOgbq7S8lbxUGS356c/Rm3ZWEInIRcbVqI+P75abEvzRNhRyBDId74h9IZnv+wz9QfGnk8TaFAExRBJ5BzIKlDibTZ+Kzuc+7mvOAKLw== +ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none; + dkim=none; arc=none +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=outlook.com; + s=selector1; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; + bh=SVZW4LFWvuTy4mbM+Q+E+yH3a0DZrHFN3U3wHGbAHMQ=; + b=Q6QpEIbyyvkSgmfTsPeVdPuTyh+6lA/+qAoEm5k5gEDuyqmLjwVELDsOJQAZzwfQfmxN02O5dpbD0mDWKLFR7Ft//121jF9EV06fbGMNXuuBpfZJ24npu+bPbHs66D7USSMEE6zvuf4bnlhVV0iTTxWwhNEawPfaFpuukvlVO1GtPOjH0SeymOnfHM3LrGSkwYpw5aeEGjrJLFQRSN+k8mD7PyoOkJFFBUyqySWdkRsQ5aw9+7f3wbHbDOb4rqkmkC6fUZSMcqpTSpFFS3fDlQQrcnwhh8ir/tq74AuVYyMoUMns81tExoILI78twEHxyGN2zgLw7K9QojJCf+IIEQ== +Received: from SEYPR04MB7496.apcprd04.prod.outlook.com (2603:1096:101:1db::7) + by PUZPR04MB6246.apcprd04.prod.outlook.com (2603:1096:301:ec::5) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.8293.20; Tue, 31 Dec + 2024 08:24:54 +0000 +Received: from SEYPR04MB7496.apcprd04.prod.outlook.com + ([fe80::1aab:cf90:44d8:af4e]) by SEYPR04MB7496.apcprd04.prod.outlook.com + ([fe80::1aab:cf90:44d8:af4e%6]) with mapi id 15.20.8293.000; Tue, 31 Dec 2024 + 08:24:54 +0000 +From: Miah Join +To: "hello@stalw.art" +Subject: SEO-Inquiry +Thread-Topic: SEO-Inquiry +Thread-Index: AdtbXSTUEOZ2HAOVQCqvPiaCPhJ+xw== +Importance: high +X-Priority: 1 +Sensitivity: private +Date: Tue, 31 Dec 2024 08:24:22 +0000 +Message-ID: + +Accept-Language: en-US +Content-Language: en-US +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-ms-publictraffictype: Email +x-ms-traffictypediagnostic: SEYPR04MB7496:EE_|PUZPR04MB6246:EE_ +x-ms-office365-filtering-correlation-id: d8480a0c-4e45-4278-ad1b-08dd29749a89 +x-ms-exchange-slblob-mailprops: + SoURN12IA8vYLoDX/njsxmIBKrA5xww7SXj4BirxWi/11KcMwu1z/THqGhGNTFhVj0W89DLwoSD+IqZucSTc96MoKe9ia4xsvj73oWgcR/6suK5u7GoMSUHmwhrgVtaAkaceSYHenT+iTqqsr6L31R3bTGzhR1r7TEKkTLaJO6pXyfeXy8wUEdMEUOpfggfX/BBwA6KLTGvIE9euGA810m3+uPbUX+cE3WNT2yFsiC+H/YFGbcISQcbwlqT/7fAjiNT6fore4X1HnccC1CzArvmHrHF37pjc/JTEQFUxg7woH0GKJtvsUFbDUPvXjKp9rTRqLHTb8fYVSRdeivWB4lJocoW5VqJ9YichWUGp1ELEDuto7T/ourT4i6Q/Z+D+3/Q67J0SAdD8+XAXHfYKG2rq14F+tz4KxbO6EPp7O6HpaHMSbpzps5/sOJ2gmeHucnICmy81mKE9aq2sfJPPdymPBczv4hB7QO+xQDHFNpypNedZ8cTbtW5MlVn18lgmP0BuDBEgU4WcbKu2bMzgl6HJRQctSSldfkkyfGI/URoo69SEmOZiDuuCzhKQQ09ho6xRG3kpsD9DyRAUQyyo3Wq4J1UgLuRyRVFYwPI4zL+e47La1ilj199UD/SJrbHMMTt6itlRBpnH4N7Ev1ELztRfH5VjQ5aG +x-microsoft-antispam: + BCL:0;ARA:14566002|8060799006|7092599003|8062599003|5062599005|15080799006|461199028|19110799003|440099028|3412199025|102099032; +x-microsoft-antispam-message-info: + =?us-ascii?Q?sSaF+W5EA6WGixAhyYLVcDu/rxF60TPo55Pe5lDbuGq4SQKPFczK5OPYJQHf?= + =?us-ascii?Q?6w0tFaoHE+b0IsosJe0H/dJXTmVvChLHdVnaTZiPcDKrZVOcLyvm2WnEoemj?= + =?us-ascii?Q?m21GgioEUF/NQdwaXt6AAYv18zt1GxKufXRYIpLkmKM8gV1XQMXPJteH53ax?= + =?us-ascii?Q?vifrj3RXruNdM31oe0LrZdIbsbKuvKDSdByf1mB2PVfCgEXIjvfFLgJidnah?= + =?us-ascii?Q?d30fWN6LcYhQvtVNfpiJwtroUR5oox/HxRtDbU+fkvn0LLoFX7nxdECueURA?= + =?us-ascii?Q?/67CsMYT5/NKrE+oaYmPjDYkLmS+YDBmWdUyuQDJIQ0Lb7vKisWAOFrweDgc?= + =?us-ascii?Q?pJUyUtAA5UliC4AO+gI+AH3cv2JaMn+Z7gCf9WpcTCk8PkOEtg4Y1661t4T3?= + =?us-ascii?Q?rOHV1Q7ibkz6JYCiIVGl1macRWWcIl1GeUWLUiHduVR/ax3PLUYLHYblCHv0?= + =?us-ascii?Q?XVrSCAAa8+vce6+c7ymVd8uBAvfTT/hfYKwSGR2R6V9clhEDjE5TXsIjx1qc?= + =?us-ascii?Q?AsCWjvexOp/pWalj3jwJzOE/xKHehhzyIeOJ0kN8snZBJiZpD9VnhYE6Lt8I?= + =?us-ascii?Q?4ttzIth0+B9mw24tVTPg2cZ3N9yFsznBvKxtuFYl3Oj3YzacJVbwk+XyRsjQ?= + =?us-ascii?Q?LXmGP3d8NPm2Nhy0yfrt4/wxf2th/e+/I3siWDuHcwJiDFdhvoT/AOuUoFl9?= + =?us-ascii?Q?5Vs6Amo3CmVo4TGEf+EQNPoLsXjVAMzpk/5TgnKQ2sanXVv1zPGrTT7aErrv?= + =?us-ascii?Q?m2fE/VhGQOqKiizPYaMaC6W4YPdrAYOOrpZFOgzFfMoNmyDmIfIucke8DJTg?= + =?us-ascii?Q?K630mHxneVrofGWREkohZiDUe/tQsjp/0Hy2x2792Pg2aumt5nc1Aw/QJrwr?= + =?us-ascii?Q?VHaKD7O2hL1QLJFbojSSKlWyGQ/CE0DsyUVihC0dWqfdWa8gPlHZ3uzzMN5J?= + =?us-ascii?Q?BazCEPgYBVnmiQw/ejBmBpm5iKIA0zgNKGva/8tDikzhviHpCe9lijBuNsu3?= + =?us-ascii?Q?OIfW8bDcJpyKB+g5S7EVo9K+lwYq0i86di3dpDvmSBfB99PFK9kqzaAEfAIU?= + =?us-ascii?Q?Duh11rVb5jqL5hm7Zhsb9T2kGOiAorAjmrAHQFSyCNO910vF+8s=3D?= +x-ms-exchange-antispam-messagedata-chunkcount: 1 +x-ms-exchange-antispam-messagedata-0: + =?us-ascii?Q?9c/kgRHXQSmmVIMMaHuipN3QMSRVkf870g3+luQav+fyRaGeQw2TxAqSMXIQ?= + =?us-ascii?Q?MqoXTcYcUTW8oy80k0fPln9Hz61fG51WDZ2vzG1wnYvfHer1zkAdp/njN/D2?= + =?us-ascii?Q?jXEtZZP4w7Fi1Tf3oyRzBLQhMtORyV3RQU/uWIRpjvP/jlu3zfVeIyFtON/q?= + =?us-ascii?Q?oJqPzYVyxeLv832rNiqEVsAPLOar26932L6xXlandRXwk6WZTN7J76uUF+i0?= + =?us-ascii?Q?FpKfqvx2IHOT/Qc8dJLf3H/iBywKPBINfmUdTdX83EzX9DYFmCJ0mJHhgmFV?= + =?us-ascii?Q?2r/JQEN6c+ziSN/sqFBrNvpWaEUGzyB7k4HT+HV9bv/8nx0jVYVdb8Jg/Lxl?= + =?us-ascii?Q?nt9FxkqJPCdTjYYQwCzM+74raMHz5o3URpFYGfEYsgBBrqZPa6oNgxZBrLU6?= + =?us-ascii?Q?w/m7VsQI9AbLh/jteycxh6INOvPy6SS3m7+FGFpFjwNAfSQLEyghWO0hyyk3?= + =?us-ascii?Q?75mJwXHJ53u+J6jMQQPEz6h2SFHN64nrGRbPq6ruSdyEhSj9BdkuEnKb/yAR?= + =?us-ascii?Q?07pJ7iBfQVTfQ04xXejXErXvrDZGCcVy3/1AOajGLmcW1iTgoRwF3KKTmKct?= + =?us-ascii?Q?g+/IaxelPpT95uCPhSWREdVLoxuQW6pBltFeXod8ou3YpiOoss2h0EplB4Xa?= + =?us-ascii?Q?TKPKZSDdPBeCbOwx9GQUhySu57WwarR4Q74+MQwx8zDHdlytk9bLFZrCSCnt?= + =?us-ascii?Q?OixF+n8R+5U1D+Vub74mpSguv+2efQNt2lwJei3iDd1mC6WRXdGaC4+WdCEt?= + =?us-ascii?Q?uPRtELZCDsLSUE+097ixYl7uMLCe8nHUFuECfu41T1yX9PPMEmpadpxazQCd?= + =?us-ascii?Q?USBa0u5BAMvXiZJHfPgf3y255JPI079k+DdhGkhF2cDwLciCHELy5rM52TGT?= + =?us-ascii?Q?IrCNmfxb/RmOWBVEsvbhV4glTLJlORemnfACjPfh8SzldzzIj2W8zUUrxJQt?= + =?us-ascii?Q?6bdoxj9FbxsJ3wvYQTDqF9olDDPh/2z1g28uXkknEgvcdU/XvlzE+bz3ACnC?= + =?us-ascii?Q?yoxmdm3P7/9aIojcU9CeOsnQBDHgBYOxXIK5vNogdrZV9Ew4G2w/gOevLmvA?= + =?us-ascii?Q?HAbDE1IaI2hhX0zhg2D/QCZZNpvlDgJzESkffQNSOYsIWlHeWBzOJeq7iM0Y?= + =?us-ascii?Q?pXEltktrRe3doxEsnAYKPirbULWz4u+XJXzAOddWBWiYGNi6ua5wuyv7pyRX?= + =?us-ascii?Q?CMc1g/sPb+5aGiPpbK4Si7KWv6HpNO3v0ACS0qrmdaaPWSx+wL8tsxO4KBI?= + =?us-ascii?Q?=3D?= +Content-Type: multipart/alternative; + boundary="_000_SEYPR04MB74966F6A34B2B0AC5DA8E6DDFD0A2SEYPR04MB7496apcp_" +MIME-Version: 1.0 +X-OriginatorOrg: outlook.com +X-MS-Exchange-CrossTenant-AuthAs: Internal +X-MS-Exchange-CrossTenant-AuthSource: SEYPR04MB7496.apcprd04.prod.outlook.com +X-MS-Exchange-CrossTenant-RMS-PersistedConsumerOrg: 00000000-0000-0000-0000-000000000000 +X-MS-Exchange-CrossTenant-Network-Message-Id: d8480a0c-4e45-4278-ad1b-08dd29749a89 +X-MS-Exchange-CrossTenant-originalarrivaltime: 31 Dec 2024 08:24:22.5882 + (UTC) +X-MS-Exchange-CrossTenant-fromentityheader: Hosted +X-MS-Exchange-CrossTenant-id: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa +X-MS-Exchange-CrossTenant-rms-persistedconsumerorg: 00000000-0000-0000-0000-000000000000 +X-MS-Exchange-Transport-CrossTenantHeadersStamped: PUZPR04MB6246 + +--_000_SEYPR04MB74966F6A34B2B0AC5DA8E6DDFD0A2SEYPR04MB7496apcp_ +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hello, hello@stalw.art + + + +I'm just checking with you to see if you're interested in (SEO) search engi= +ne optimization, or if you're interested in Google 1st page for better busi= +ness. + + + +If so, I'd love to tell you a little bit more about my abilities and show y= +ou some of my work. I am a very + +Skilled SEO expert with various abilities and can (1st Page on Google). any= +thing. + + + +I look forward to hearing from you. + + + +Thanks, + +Miah + +--_000_SEYPR04MB74966F6A34B2B0AC5DA8E6DDFD0A2SEYPR04MB7496apcp_ +Content-Type: text/html; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

Hello, hello@stalw.art  = +;    

+

 

+

I’m just checking with= + you to see if you’re interested in +(SEO) search engi= +ne optimization, or if you’re interes= +ted in Google 1st page for better business.  +

+

 

+

If so, I’d love to tel= +l you a little bit more about my abilities and show you some of my work. I = +am a very  +

+

Skilled SEO expert with vari= +ous abilities and can (1st Page on Google). anything.  +

+

 

+

I look forward to hearing fr= +om you.  +

+

 

+

Thanks,

+

Miah

+
+ + + +--_000_SEYPR04MB74966F6A34B2B0AC5DA8E6DDFD0A2SEYPR04MB7496apcp_-- + + +envelope_from marketing@landeray.com +envelope_to hello@stalw.art +helo_domain terminal4.landeray.com +iprev.result pass +dkim.result pass +dkim.domains outlook.com +spf.result pass +spf_ehlo.result pass +dmarc.result pass +dmarc.policy reject +remote_ip 173.224.123.255 +tls.version TLS1_2 +expect_header X-Spam-Result: DMARC_POLICY_ALLOW (-0.50), DKIM_ALLOW (-0.20), SPF_ALLOW (-0.20), ARC_NA (0.00), DKIM2_NA (0.00), DKIM_SIGNED (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_EXTERNAL_IMG (0.00), HAS_REPLYTO (0.00), HAS_X_PRIO_THREE (0.00), HTML_SHORT_1 (0.00), RCPT_COUNT_ONE (0.00), REPLYTO_DN_EQ_FROM_DN (0.00), REPLYTO_DOM_EQ_FROM_DOM (0.00), TO_DN_ALL (0.00), TO_EQ_FROM (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), HELO_NORES_A_OR_MX (0.30), MID_RHS_NOT_FQDN (0.50), UNPARSABLE_URL (0.50), DATE_IN_PAST (1.00), FROMHOST_NORES_A_OR_MX (1.50), DIRECT_TO_MX (2.00), FORGED_RECIPIENTS (2.00), SUBJ_ALL_CAPS (3.00) +expect_header X-Spam-Score: spam, score=10.10 + +Return-Path: +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; s=default; d=landeray.com; + h=Message-ID:Reply-To:From:To:Subject:Date:MIME-Version:Content-Type; + i=marketing@landeray.com; + bh=nlE2QLcR2WpTkMQ4zUwkF7IBIw4eOeAhkKS/HXiMvSs=; + b=EwWEML/WtTA6nI6CuagI2LRBWAZuRHk6IJiDNA3R77dTpa80gHPkfzF3NBcKcVIiCYcf6i4HCniZ + bF4DT8VZrFfLCSykeL2t5FLjYwFcjXjX7qCvyaR0hLfyFRSCDO9RytMw8Q3WoODc/jMXWrdxYpqu + phP9D5Q/N8vzSneReGOCV62Me1ss947uc43zYZXvVwuXeyLbU0yPzJtTTEODFXmVp2Ul/M7w963E + UxzrNlTd/lEIUXVvxAOL2DKGxY8oXslevI0euO8E8TQms3SlY6LySkDia1h+N+mVpbPPeE3txZhS + LJwn2MBX59b+W3aMafAt4Ae/UVOOBlMT7FhhvA== +Message-ID: +Reply-To: "RESERVAS Y CONSULTAS" +From: "RESERVAS Y CONSULTAS" +To: "Marketing Online" +Subject: BRISZA ASIA TEMPORADA 2025 +Date: Fri, 3 Jan 2025 14:06:03 +0000 +Organization: CLIENTE-DISCOTECA +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_0E52_01DB5DE8.9FAFDC60" +X-Priority: 3 +X-MSMail-Priority: Normal +X-Mailer: Microsoft Outlook Express 6.00.2900.5512 +X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.5579 + +This is a multi-part message in MIME format. + +------=_NextPart_000_0E52_01DB5DE8.9FAFDC60 +Content-Type: text/plain; + charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +Consulta v=EDa WhatsApp: https://api.whatsapp.com/send?phone=3D5199504167= +6&text=3DDeseo realizar una reserva en BRISZA + + +Si no puedes visualizar la imagen haz click aqu=ED https://i.imgur.com/O1= +2POZ2.png + + + +Si esta informaci=F3n no es de su inter=E9s. Favor de escribirnos un corr= +eo en blanco a eliminarcorreo2016@gmail.com con el Asunto "REMOVER". Grac= +ias. + +------=_NextPart_000_0E52_01DB5DE8.9FAFDC60 +Content-Type: text/html; + charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
Consulta v=EDa WhatsApp: https://api.whatsapp.com/send?phone=3D51995= +041676&text=3DDeseo=20 +realizar una reserva en BRISZA
+
 
+
 
+
Si no puedes visualizar la image= +n haz click=20 +aqu=ED https://i.imgur.com/O12POZ2.png<= +/A>
+
 
+
3D""=20
+
 
+
+
Si esta informaci= +=F3n no es de=20 +su inter=E9s. Favor de escribirnos un correo en blanco a eliminarcorreo2016@gmail.com con el Asunto "REMOVER".=20 +Gracias.
+ +------=_NextPart_000_0E52_01DB5DE8.9FAFDC60-- diff --git a/tests/resources/smtp/antispam/date.test b/tests/resources/smtp/antispam/date.test new file mode 100644 index 0000000..d6dc17f --- /dev/null +++ b/tests/resources/smtp/antispam/date.test @@ -0,0 +1,23 @@ +expect MISSING_DATE + +X-Date: Tue, 1 Jul 2003 10:52:37 +0200 + +Test + +expect INVALID_DATE + +Date: blah blah blah + +Test + +expect DATE_IN_PAST + +Date: Tue, 1 Jul 2003 10:52:37 +0200 + +Test + +expect DATE_IN_FUTURE + +Date: Tue, 1 Jul 2999 10:52:37 +0200 + +Test diff --git a/tests/resources/smtp/antispam/dmarc.test b/tests/resources/smtp/antispam/dmarc.test new file mode 100644 index 0000000..feaa121 --- /dev/null +++ b/tests/resources/smtp/antispam/dmarc.test @@ -0,0 +1,140 @@ +expect DMARC_NA SPF_NA DKIM_NA ARC_NA AUTH_NA DKIM2_NA + +Subject: test + +Test + + +spf.result pass +dkim.result pass +arc.result pass +dmarc.result pass +expect DKIM_SIGNED ARC_SIGNED DKIM_ALLOW SPF_ALLOW ARC_ALLOW DMARC_POLICY_ALLOW DKIM2_NA + +DKIM-Signature: abc +ARC-Seal: xyz +Subject: test + +Test + + +spf.result fail +dkim.result fail +arc.result fail +dmarc.result fail +dmarc.policy quarantine +expect SPF_FAIL ARC_REJECT DKIM_REJECT DMARC_POLICY_QUARANTINE DKIM2_NA + +Subject: test + +Test + + +spf.result neutral +dkim.result temperror +arc.result permerror +dmarc.result fail +dmarc.policy reject +expect DKIM_TEMPFAIL SPF_NEUTRAL ARC_INVALID DMARC_POLICY_REJECT DKIM2_NA + +Subject: test + +Test + + +spf.result softfail +dkim.result permerror +arc.result temperror +dmarc.result permerror +expect ARC_DNSFAIL DMARC_BAD_POLICY DKIM_PERMFAIL SPF_SOFTFAIL DKIM2_NA + +Subject: test + +Test + +dkim.result pass +dkim.domains spf-dkim-allow.org +spf.result pass +expect DKIM_ALLOW SPF_ALLOW ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +dkim.result pass +spf.result pass +arc.result pass +expect DKIM_ALLOW SPF_ALLOW ARC_ALLOW DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +spf.result pass +dkim.result fail +expect DKIM_REJECT SPF_ALLOW ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +spf.result pass +dkim.result temperror +expect DKIM_TEMPFAIL SPF_ALLOW ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +dkim.result pass +dkim.domains spf-dkim-allow.org +spf.result fail +expect DKIM_ALLOW SPF_FAIL ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +dkim.result pass +dkim.domains spf-dkim-allow.org +spf.result temperror +expect DKIM_ALLOW SPF_DNSFAIL ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +dkim.result fail +spf.result fail +expect DKIM_REJECT SPF_FAIL ARC_NA DMARC_NA DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +dkim.result temperror +spf.result temperror +expect DKIM_TEMPFAIL SPF_DNSFAIL ARC_NA DMARC_NA AUTH_NA_OR_FAIL DKIM2_NA + +From: user@spf-dkim-allow.org +Subject: test + +Test + +spf.result pass +dkim.result pass +arc.result pass +dmarc.result pass +envelope_from hello@stalw.art +expect TRUSTED_DOMAIN DMARC_POLICY_ALLOW DKIM_ALLOW SPF_ALLOW ARC_ALLOW DKIM2_NA + +From: + +Test diff --git a/tests/resources/smtp/antispam/from.test b/tests/resources/smtp/antispam/from.test new file mode 100644 index 0000000..578b04e --- /dev/null +++ b/tests/resources/smtp/antispam/from.test @@ -0,0 +1,232 @@ +expect MISSING_FROM + +X-From: hello@domain.org + +Test + +envelope_from hello@domain.org +expect MULTIPLE_FROM FROM_EQ_ENV_FROM FROM_NO_DN + +From: hello@domain.org +From: hello@domain.org + +Test + +envelope_from test +expect FROM_INVALID ENV_FROM_INVALID + +From: test + +Test + +envelope_from www-data@domain.org +expect FROM_SERVICE_ACCT FROM_HAS_DN FROM_EQ_ENV_FROM + +From: "WWW DATA" + +Test + +envelope_from hello@domain.org +expect FROM_DN_EQ_ADDR FROM_EQ_ENV_FROM + +From: "hello@domain.org" + +Test + +envelope_from hello@domain.org +expect SPOOF_DISPLAY_NAME FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "hello@otherdomain.org" + +Test + +envelope_from hello@domain.co.uk +expect FROM_NEQ_DISPLAY_NAME FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "hello@other.domain.co.uk" + +Test + +helo_domain mx.domain.co.uk +expect FROMTLD_EQ_ENV_FROMTLD FROM_NEQ_DISPLAY_NAME FROM_HAS_DN FROM_BOUNCE + +From: "postmaster@mx.domain.co.uk" + +Test + +helo_domain mx.domain.co.uk +expect FROMTLD_EQ_ENV_FROMTLD FROM_HAS_DN FROM_BOUNCE + +From: "Mailer Daemon" + +Test + +envelope_from mrspammer@domain.org +expect FROM_NAME_HAS_TITLE FROM_NAME_EXCESS_SPACE FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Mr. Money Maker" + +Test + +envelope_from hello+world@domain.org +expect TAGGED_FROM FROM_EQ_ENV_FROM FROM_NO_DN + +From: hello+world@domain.org + +Test + +envelope_from hello@domain.org +expect TO_EQ_FROM FROM_EQ_ENV_FROM FROM_NO_DN + +From: hello@domain.org +To: hello@domain.org + +Test + +envelope_from hello@domain.org +expect FROM_EQ_ENV_FROM FROM_NO_DN + +From: hello@domain.org +To: hello@domain.org, bye@domain.org + +Test + +envelope_from hello@domain.org +expect FROM_NEEDS_ENCODING FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hélló" + +Test + +param.smtputf8 1 +envelope_from hello@domain.org +expect FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hélló" + +Test + +envelope_from hello@domain.org +expect FROM_EXCESS_QP FROM_EQ_ENV_FROM FROM_HAS_DN + +From: =?iso-8859-1?Q?Die_Hasen_und_die_Froesche?= + +Test + +envelope_from hello@domain.org +expect FROM_EXCESS_BASE64 FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "=?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?=" + +Test + +envelope_from hello@domain.org +expect FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "=?iso-8859-1?Q?Die_Hasen_und_die_Fr=F6sche?=" + +Test + +envelope_from hello@domain.org +expect NO_SPACE_IN_FROM FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hello" + +Test + +envelope_from hello@domain.org +expect FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hello" + + +Test + +envelope_from hello@domain.org +expect HEADER_RCONFIRM_MISMATCH FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hello" +X-Confirm-Reading-To: + +Test + +envelope_from hello@domain.org +expect HEADER_FORGED_MDN FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hello" +Disposition-Notification-To: + +Test + +envelope_from anonymous@domain.org +expect FROM_SERVICE_ACCT WWW_DOT_DOMAIN FROM_EQ_ENV_FROM FROM_HAS_DN + +From: "Hello" +Reply-to: + +Test + +envelope_from hello@custom.disposable.org +expect FREEMAIL_FROM DISPOSABLE_ENV_FROM FROM_NEQ_ENV_FROM FROM_NO_DN FORGED_SENDER + +From: hello@gmail.com + +Test + +envelope_from hello@gmail.com +expect DISPOSABLE_FROM FREEMAIL_ENV_FROM FROM_NEQ_ENV_FROM FROM_NO_DN FORGED_SENDER + +From: hello@custom.disposable.org + +Test + +envelope_from hello@nomx.org +expect FROMHOST_NORES_A_OR_MX FROM_EQ_ENV_FROM FROM_NO_DN + +From: hello@nomx.org + +Test + +envelope_from baz@domain.org +expect SPOOF_DISPLAY_NAME FROM_HAS_DN FROM_EQ_ENV_FROM + +From: "Foo (foo@bar.com)" + +Test + +envelope_from baz@domain.org +expect SPOOF_DISPLAY_NAME FROM_HAS_DN FROM_EQ_ENV_FROM + +From: Foo (foo@bar.com) + +Test + +envelope_from baz@domain.org +expect SPOOF_DISPLAY_NAME FROM_HAS_DN FROM_EQ_ENV_FROM + +From: "Foo foo@bar.com" + +Test + +envelope_from baz@domain.org +expect SPOOF_DISPLAY_NAME FROM_HAS_DN FROM_EQ_ENV_FROM + +From: "Foo 'foo@bar.com'" + +Test + +envelope_from hello@xn--eebajf.xn--9dbq2a +expect FROM_EQ_ENV_FROM FROM_HAS_DN FROM_NEEDS_ENCODING FROMHOST_NORES_A_OR_MX + +From: "Hello" +X-Confirm-Reading-To: + +Test + +envelope_from hello@xn--eebajf.xn--9dbq2a +expect FROM_EQ_ENV_FROM FROM_HAS_DN FROM_NEEDS_ENCODING FROMHOST_NORES_A_OR_MX + +From: "Hello" +Disposition-Notification-To: + +Test diff --git a/tests/resources/smtp/antispam/headers.test b/tests/resources/smtp/antispam/headers.test new file mode 100644 index 0000000..a15423f --- /dev/null +++ b/tests/resources/smtp/antispam/headers.test @@ -0,0 +1,90 @@ +expect HAS_X_PRIO_ONE + +X-Priority: 1 +From: test@test.com +To: test@test.com + +Test + +expect MULTIPLE_UNIQUE_HEADERS HAS_X_PRIO_TWO + +X-Mailer: my mailer 1 +X-Priority: 2 +From: test@test.com +From: test@test.com +To: test@test.com + +Test + +expect XM_CASE HAS_LIST_UNSUB PRECEDENCE_BULK MULTIPLE_UNIQUE_HEADERS + +X-mailer: my mailer 1 +List-Unsubscribe: +Precedence: bulk +Subject: first subject +Subject: second subject + +Test + +expect KLMS_SPAM UNITEDINTERNET_SPAM SPAM_FLAG XM_UA_NO_VERSION + +X-Mailer: my mailer +X-KLMS-AntiSpam-Status: spam +X-Spam: Yes +X-UI-Filterresults: JUNK +Subject: test + +Test + +expect X_PHP_EVAL HIDDEN_SOURCE_OBJ HAS_X_GMSV HAS_X_AS + +X-PHP-Script: sendmail.php +X-PHP-Originating-Script: eval() +X-Source-Args: ../script +X-Authenticated-Sender: sender: test@test.org +X-Get-Message-Sender-Via: authenticated_id: 123 +X-AntiAbuse: 1 +X-Authentication-Warning: 1 +Subject: test + +Test + +expect HEADER_EMPTY_DELIMITER + +Subject:test + +Test + +expect + +Subject: + test + +Test + +expect MAILLIST + +List-Archive: 1 +List-Owner: 1 +List-Help: 1 +List-Post: 1 +X-Loop: 1 +List-Id: 1 +Subject: test + +Test + +expect MAILLIST HAS_LIST_UNSUB + +List-Id: 1 +List-Subscribe: 1 +List-Unsubscribe: 1 +Subject: test + +Test + +expect MISSING_ESSENTIAL_HEADERS + +X-Other: test + +Test diff --git a/tests/resources/smtp/antispam/helo.test b/tests/resources/smtp/antispam/helo.test new file mode 100644 index 0000000..9ed17f8 --- /dev/null +++ b/tests/resources/smtp/antispam/helo.test @@ -0,0 +1,65 @@ +helo_domain localhost +expect HELO_NOT_FQDN + +Subject: test + +test + +helo_domain user +expect RCVD_HELO_USER HELO_NOT_FQDN + +Subject: test + +test + +helo_domain 8.8.8.8 +remote_ip 8.8.8.8 +expect HELO_BAREIP + +Subject: test + +test + +helo_domain 8.8.8.8 +remote_ip 1.1.1.1 +expect HELO_IP_A HELO_BAREIP + +Subject: test + +test + +helo_domain domain.org +iprev.ptr domain.org +remote_ip 1.1.1.1 +expect + +Subject: test + +test + +helo_domain domain.org +iprev.ptr otherdomain.org +remote_ip 1.1.1.1 +expect HELO_IPREV_MISMATCH + +Subject: test + +test + +helo_domain otherdomain.org +iprev.ptr otherdomain.org +remote_ip 1.1.1.1 +expect HELO_NORES_A_OR_MX + +Subject: test + +test + +helo_domain otherdomain.org +iprev.ptr otherdomain.net +remote_ip 1.1.1.1 +expect HELO_NORES_A_OR_MX HELO_IPREV_MISMATCH + +Subject: test + +test diff --git a/tests/resources/smtp/antispam/html.test b/tests/resources/smtp/antispam/html.test new file mode 100644 index 0000000..b0a50da --- /dev/null +++ b/tests/resources/smtp/antispam/html.test @@ -0,0 +1,335 @@ +expect MIME_HTML_ONLY HTML_SHORT_1 + +Message-Id: <4.2.0.58.20000519002557.00a88870@pop.example.com> +X-Sender: dwsauder@pop.example.com (Unverified) +X-Mailer: QUALCOMM Windows Eudora Pro Version 4.2.0.58 +X-Priority: 2 (High) +Date: Fri, 19 May 2000 00:29:55 -0400 +To: Heinz =?iso-8859-1?Q?M=FCller?= +From: Doug Sauder +Subject: =?iso-8859-1?Q?Die_Hasen_und_die_Fr=F6sche?= +Mime-Version: 1.0 +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + +Die Hasen und = +die +Fr=F6sche
+
+
Die Hasen klagten einst =FCber +ihre mi=DFliche Lage; "wir leben", sprach ein Redner, "in +steter Furcht vor Menschen und Tieren, eine Beute der Hunde, der Adler, +ja fast aller Raubtiere! Unsere stete Angst ist =E4rger als der Tod selbst. +Auf, la=DFt uns ein f=FCr allemal sterben."
+
+In einem nahen Teich wollten sie sich nun ers=E4ufen; sie eilten ihm zu; +allein das au=DFerordentliche Get=F6se und ihre wunderbare Gestalt +erschreckte eine Menge Fr=F6sche, die am Ufer sa=DFen, so sehr, da=DF sie au= +fs +schnellste untertauchten.
+
+"Halt", rief nun eben dieser Sprecher, "wir wollen das +Ers=E4ufen noch ein wenig aufschieben, denn auch uns f=FCrchten, wie ihr +seht, einige Tiere, welche also wohl noch ungl=FCcklicher sein m=FCssen als +wir."
+
+
+ +expect HTTP_TO_HTTPS HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+https://mydomain.com + +--=====================_714967308==_.ALT-- + +expect HTTP_TO_IP HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + + +

some text

+https://8.8.8.8 + + +--=====================_714967308==_.ALT-- + +expect EXT_CSS HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +

some text

+https://mydomain.com + +--=====================_714967308==_.ALT-- + +expect EXT_CSS HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +

some text

+https://mydomain.com + +--=====================_714967308==_.ALT-- + +expect HTML_UNBALANCED_TAG HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +hello +https://mydomain.com + +--=====================_714967308==_.ALT-- + +expect HTML_UNBALANCED_TAG HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +https://mydomain.com + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +hello +https://mydomain.com + +--=====================_714967308==_.ALT-- + +expect HTML_SHORT_LINK_IMG_1 HTML_SHORT_1 HAS_LINK_TO_LARGE_IMG + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Testing + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +Test + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam

+ + + + +--=====================_714967308==_.ALT-- + +expect BODY_URI_ONLY HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Testing + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +Test + +

http://myurl.com + + + +--=====================_714967308==_.ALT-- + +expect HTML_TEXT_IMG_RATIO HTML_SHORT_1 + +Content-Type: multipart/alternative; + boundary="=====================_714967308==_.ALT" + +--=====================_714967308==_.ALT +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Testing + +--=====================_714967308==_.ALT +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + +Test + + + + + + + +--=====================_714967308==_.ALT-- + +expect HTML_META_REFRESH_URL MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + + + + +

some text

+ + +expect MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + + + + +

some text

+ + +expect HAS_DATA_URI DATA_URI_OBFU MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + + + + +

some text

+Click me for a hello message + + +expect HAS_DATA_URI MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + + + + + +

some text and a lovely explanation to avoid the text to image ratio tag

+Red dot +Click me for a hello message + + +expect PHISHING MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

some text

+https://domain2.com/otherquery + + +expect MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

some text

+https://subdomain.domain1.co.uk/otherquery + + +expect PHISHING MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

some text

+domain2.com/otherquery + + +expect MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

some text

+subdomain.domain1.co.uk/otherquery + + +expect MIME_HTML_ONLY HTML_SHORT_1 + +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 8bit + +

some text

+normal text + diff --git a/tests/resources/smtp/antispam/ip.test b/tests/resources/smtp/antispam/ip.test new file mode 100644 index 0000000..12ecb16 --- /dev/null +++ b/tests/resources/smtp/antispam/ip.test @@ -0,0 +1,17 @@ +remote_ip 8.8.8.8 +iprev.result temperror +expect RDNS_DNSFAIL + +Subject: test + +Test + + +remote_ip 8.8.8.8 +iprev.result fail +expect RDNS_NONE + +Subject: test + +Test + diff --git a/tests/resources/smtp/antispam/llm.test b/tests/resources/smtp/antispam/llm.test new file mode 100644 index 0000000..6fd6216 --- /dev/null +++ b/tests/resources/smtp/antispam/llm.test @@ -0,0 +1,72 @@ +expect LLM_UNSOLICITED_HIGH + +Subject: Unsolicited,High,Test + +Test + + +expect LLM_COMMERCIAL_HIGH + +Subject: Commercial,High,Test + +Test + +expect LLM_HARMFUL_HIGH + +Subject: Harmful,High,Test + +Test + +expect LLM_LEGITIMATE_HIGH + +Subject: Legitimate,High,Test + +Test + +expect LLM_UNSOLICITED_MEDIUM + +Subject: Unsolicited,Medium,Test + +Test + +expect LLM_COMMERCIAL_MEDIUM + +Subject: Commercial,Medium,Test + +Test + +expect LLM_HARMFUL_MEDIUM + +Subject: Harmful,Medium,Test + +Test + +expect LLM_LEGITIMATE_MEDIUM + +Subject: Legitimate,Medium,Test + +Test + +expect LLM_UNSOLICITED_LOW + +Subject: Unsolicited,Low,Test + +Test + +expect LLM_COMMERCIAL_LOW + +Subject: Commercial,Low,Test + +Test + +expect LLM_HARMFUL_LOW + +Subject: Harmful,Low,Test + +Test + +expect LLM_LEGITIMATE_LOW + +Subject: Legitimate,Low,Test + +Test diff --git a/tests/resources/smtp/antispam/messageid.test b/tests/resources/smtp/antispam/messageid.test new file mode 100644 index 0000000..80e2cd4 --- /dev/null +++ b/tests/resources/smtp/antispam/messageid.test @@ -0,0 +1,104 @@ +expect MISSING_MID + +X-Message-ID: + +Test + +expect MID_RHS_IP_LITERAL + +Message-ID: + +Test + +expect MID_BARE_IP + +Message-ID: + +Test + +expect MID_RHS_NOT_FQDN + +Message-ID: + +Test + +expect MID_RHS_WWW + +Message-ID: + +Test + +expect INVALID_MSGID + +Message-ID: <@domain.com> + +Test + +expect INVALID_MSGID + +Message-ID: + +Test + + +expect INVALID_MSGID + +Message-ID: (hello world) + +Test + +expect MID_RHS_TOO_LONG + +Message-ID: + +Test + +expect MID_MISSING_BRACKETS + +Message-ID: hello@domain.com + +Test + +expect MID_CONTAINS_FROM + +From: +Message-ID: + +Test + +expect MID_RHS_MATCH_FROM + +From: +Message-ID: + +Test + +expect MID_RHS_MATCH_FROMTLD + +From: +Message-ID: <1234@host.domain.co.uk> + +Test + +envelope_from hello@domain.co.uk +expect MID_RHS_MATCH_ENV_FROMTLD + +Message-ID: <1234@host.domain.co.uk> + +Test + +expect MID_CONTAINS_TO + +To: User +Message-ID: + +Test + +expect MID_RHS_MATCH_TO + +From: Myself +To: User +Cc: John , Jane , Bill +Message-ID: + +Test diff --git a/tests/resources/smtp/antispam/mime.test b/tests/resources/smtp/antispam/mime.test new file mode 100644 index 0000000..8273645 --- /dev/null +++ b/tests/resources/smtp/antispam/mime.test @@ -0,0 +1,566 @@ +expect MISSING_MIME_VERSION SINGLE_SHORT_PART + +Content-Type: text/plain; charset="us-ascii" + +Test + +expect MV_CASE SINGLE_SHORT_PART + +Content-Type: text/plain; charset="us-ascii" +Mime-Version: 1.0 + +Test + +expect CTE_CASE CT_EXTRA_SEMI SINGLE_SHORT_PART + +Content-Type: text/plain; charset="us-ascii"; +Content-Transfer-Encoding: 7Bit +MIME-Version: 1.0 + +Test + +expect BROKEN_CONTENT_TYPE SINGLE_SHORT_PART + +Content-Type: ; tag=1 +Content-Transfer-Encoding: 7bit +MIME-Version: 1.0 + +Test + +expect MIME_HEADER_CTYPE_ONLY MISSING_MIME_VERSION SINGLE_SHORT_PART + +Content-Type: text/html; charset="us-ascii" + +Test + +expect BAD_CTE_7BIT SINGLE_SHORT_PART + +Content-Type: text/plain +Content-Transfer-Encoding: 7bit +MIME-Version: 1.0 + +Téstíng + +expect MISSING_CHARSET SINGLE_SHORT_PART + +Content-Type: text/plain +Content-Transfer-Encoding: 8bit +MIME-Version: 1.0 + +Test + +expect MIME_BASE64_TEXT_BOGUS SINGLE_SHORT_PART + +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: base64 +MIME-Version: 1.0 + +aGVsbG8gd29ybGQK + + +expect MIME_BASE64_TEXT SINGLE_SHORT_PART + +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: base64 +MIME-Version: 1.0 + +aMOpbGzDsyB3w7NybGQK + + +expect + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum.

+ + +--boundary-- + +expect MIME_MA_MISSING_TEXT + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum.

+ + +--boundary-- + +expect MIME_MA_MISSING_HTML + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. + +--boundary-- + +expect PARTS_DIFFER + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Lorem ipsum dolor sit Ramet, Rcnsectetur Radipiscing elit, Rsed do Reiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum.

+ +--boundary-- + +expect URI_COUNT_ODD + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Find me at http://www.example.com or http://www.example.org + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

Find me at http://www.example.com or

+ +--boundary-- + +expect + +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Find me at http://www.example.com or http://www.example.org + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

Find me at http://www.example.com or http://example.org

+ +--boundary-- + +expect CTYPE_MIXED_BOGUS + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: 7bit + + +

this is a test

+ +--boundary-- + +expect + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +another test + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +last test + +--boundary-- + +expect HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: application/octet-stream +Content-Disposition: attachment +Content-Transfer-Encoding: 7bit + + +

this is a test

+ +--boundary-- + +expect CTYPE_MISSING_DISPOSITION HAS_ATTACHMENT SINGLE_SHORT_PART + +Content-Type: application/octet-stream +MIME-Version: 1.0 + +Test + +expect ENCRYPTED_PGP ENCRYPTED_SMIME SIGNED_PGP SIGNED_SMIME HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/encrypted; + boundary="boundary" + +--boundary +Content-Type: application/pkcs7-mime +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: application/pkcs7-signature +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: application/pgp-encrypted +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: application/pgp-signature +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: application/octet-stream +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary-- + +expect CTYPE_MISSING_DISPOSITION HAS_ATTACHMENT SINGLE_SHORT_PART + +Content-Type: application/octet-stream +MIME-Version: 1.0 + +Test + +expect BOGUS_ENCRYPTED_AND_TEXT ENCRYPTED_SMIME + +MIME-Version: 1.0 +Content-Type: multipart/encrypted; + boundary="boundary" + +--boundary +Content-Type: application/pkcs7-mime +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary +Content-Type: text/html +Content-Transfer-Encoding: 7bit + +this is a test + +--boundary-- + +expect MIXED_CHARSET SINGLE_SHORT_PART + +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit +MIME-Version: 1.0 + +Tést 孔子 + + +expect MIME_BAD_EXTENSION MIME_GOOD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Disposition: attachment; filename="test.html" +Content-Transfer-Encoding: 8bit + + +

hello world

+ +--boundary-- + + +expect MIME_BAD_ATTACHMENT HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: text/x-plain; charset="utf-8" +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: 8bit + +hello world +--boundary-- + + +expect HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: 8bit + +hello world +--boundary-- + + +expect MIME_DOUBLE_BAD_EXTENSION MIME_GOOD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: text/html; charset="utf-8" +Content-Disposition: attachment; filename="test.html.html" +Content-Transfer-Encoding: 8bit + + +

hello world

+ +--boundary-- + + +expect MIME_ARCHIVE_IN_ARCHIVE MIME_GOOD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: application/zip +Content-Disposition: attachment; filename="test.zip.zip" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAAALN6RlcAAAAAAAAAAAAAAAAIABwAdGVzdC5iaW5VVAkAA+IJI +GXiCSBldXgLAAEE9QEAAAQUAAAAUEsBAh4DCgAAAAAAs3pGVwAAAAAAAAAAAA +AAAAgAGAAAAAAAAAAAAKSBAAAAAHRlc3QuYmluVVQFAAPiCSBldXgLAAEE9QE +AAAQUAAAAUEsFBgAAAAABAAEATgAAAEIAAAAAAA== +--boundary-- + +expect MIME_BAD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: image/png +Content-Disposition: attachment; filename="test.png" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAAALN6RlcAAAAAAAAAAAAAAAAIABwAdGVzdC5iaW5VVAkAA+IJI +GXiCSBldXgLAAEE9QEAAAQUAAAAUEsBAh4DCgAAAAAAs3pGVwAAAAAAAAAAAA +AAAAgAGAAAAAAAAAAAAKSBAAAAAHRlc3QuYmluVVQFAAPiCSBldXgLAAEE9QE +AAAQUAAAAUEsFBgAAAAABAAEATgAAAEIAAAAAAA== +--boundary-- + +expect MIME_GOOD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: audio/x-wave +Content-Disposition: attachment; filename="voicemail.wav" +Content-Transfer-Encoding: base64 + +UklGRqQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YYAAAAAAAQID +BAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIz +NDU2Nzg5Ojs8PT4/AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIj +JCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw== +--boundary-- + +expect MIME_GOOD HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: application/x-zip-compressed +Content-Disposition: attachment; filename="test.zip" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAAALN6RlcAAAAAAAAAAAAAAAAIABwAdGVzdC5iaW5VVAkAA+IJI +GXiCSBldXgLAAEE9QEAAAQUAAAAUEsBAh4DCgAAAAAAs3pGVwAAAAAAAAAAAA +AAAAgAGAAAAAAAAAAAAKSBAAAAAHRlc3QuYmluVVQFAAPiCSBldXgLAAEE9QE +AAAQUAAAAUEsFBgAAAAABAAEATgAAAEIAAAAAAA== +--boundary-- + +expect HAS_ATTACHMENT + +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +simple text + +--boundary +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document +Content-Disposition: attachment; filename="test.docx" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAAALN6RlcAAAAAAAAAAAAAAAAIABwAdGVzdC5iaW5VVAkAA+IJI +GXiCSBldXgLAAEE9QEAAAQUAAAAUEsBAh4DCgAAAAAAs3pGVwAAAAAAAAAAAA +AAAAgAGAAAAAAAAAAAAKSBAAAAAHRlc3QuYmluVVQFAAPiCSBldXgLAAEE9QE +AAAQUAAAAUEsFBgAAAAABAAEATgAAAEIAAAAAAA== +--boundary-- diff --git a/tests/resources/smtp/antispam/pyzor.test b/tests/resources/smtp/antispam/pyzor.test new file mode 100644 index 0000000..e82c084 --- /dev/null +++ b/tests/resources/smtp/antispam/pyzor.test @@ -0,0 +1,52 @@ +expect PYZOR + +Subject: test + +Testa Testb Testc Testd + + +expect PYZOR + +Subject: test + +Test1 Test1 Test2 Test3 + + +expect + +Subject: test + +TestX TestY TestZ TestW + + +expect + +MIME-Version: 1.0 +X-Received: by 2002:a05:6870:c7a6:b0:1e9:8f74:ce15 with SMTP id + dy38-20020a056870c7a600b001e98f74ce15mr2429202oab.11.1697967052502; Sun, 22 + Oct 2023 02:30:52 -0700 (PDT) +Date: Sat, 21 Oct 2023 16:59:59 -0700 +Message-ID: <17434871060391156945@google.com> +Subject: Report domain: stalw.art Submitter: google.com Report-ID: 17434871060391156945 +From: noreply-dmarc-support@google.com +To: domains@stalw.art +Content-Type: application/zip; + name="google.com!stalw.art!1697846400!1697932799.zip" +Content-Disposition: attachment; + filename="google.com!stalw.art!1697846400!1697932799.zip" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAIABdJVldhOnFiLAIAAG4JAAAuAAAAZ29vZ2xlLmNvbSFzdGFsdy5hcnQhMTY5Nzg0 +NjQwMCExNjk3OTMyNzk5LnhtbO1WwXKbMBC95ys8vhshDBgYRempX9CeGRkE1hgkjSTs5O8rIgnT +pMl0ptOcfEK83X27+/TwGD09j8PmQpVmgj9uYRRvN5Q3omW8f9z+/PF9V2w3T/gBdZS2R9Kc8cNm +gxSVQpl6pIa0xJAZs6hQfc3JSHEvRD/QqBEjAgvocuhI2IC5sAzDy64diWp2epIz3bd1mcvzNc9G +kboR3JDG1Ix3Ap+MkboCwJdGt1JAAOH6ShVI0jzPithyva93xH4N1mJ4SPdpcYBxHu9LCLO8TDME +bnGXb3eltSK899tY6Eh7xjHMy0OR5mlsuzkkxClvX6PlPjmUpZ2FBzLwO9vSbS0qkmJgzUstp+PA +9IkugwgrD8fakOEaEWUsmUNcmLRnNmKFgDt4UMvuFZufDpL2IjhFQPp3HQAdENkYDOet5oODeEji +0s39pxmttI1QYVwlrosgWkyqoTWTGO6zCBYwgmUWJbHV5hYKyY2YuMF7BNwhwL4jvZBhsiK2ITAr +w7QUmhnrZj/mGlnlzcJIorVNWDTyInQ+sAi12vJNT3trYTfEWsoN65j9lpayEyUtVXWnxLi+rTXs +ed5VIzKZU62ongZzI3wz7OdG8CafGfxK/mW1LR1oY4TCFzqwM9NKEp4kdveALwKsO6OVNP88xUpo +6843S8/JwUl/Y6qExHEF0yyu0iJPqySuqiQ9fOwt+OXe6uwP291b/8FbTtiv9RZM87u37t76zFsI +3P46/QJQSwECCgAKAAAACAAXSVZXYTpxYiwCAABuCQAALgAAAAAAAAAAAAAAAAAAAAAAZ29vZ2xl +LmNvbSFzdGFsdy5hcnQhMTY5Nzg0NjQwMCExNjk3OTMyNzk5LnhtbFBLBQYAAAAAAQABAFwAAAB4 +AgAAAAA= diff --git a/tests/resources/smtp/antispam/rbl.test b/tests/resources/smtp/antispam/rbl.test new file mode 100644 index 0000000..4999a0c --- /dev/null +++ b/tests/resources/smtp/antispam/rbl.test @@ -0,0 +1,82 @@ +remote_ip 20.11.0.1 +expect RCVD_IN_DNSWL_LOW RBL_SENDERSCORE_REPUT_0 + +Subject: test + +test + + +remote_ip 20.11.0.2 +expect RBL_SENDERSCORE_REPUT_0 RBL_SEM RBL_SPAMHAUS_SBL RBL_BARRACUDA RBL_BLOCKLISTDE RBL_VIRUSFREE_BOTNET RBL_SPAMCOP RCVD_IN_DNSWL_MED + +Subject: test + +test + + +remote_ip 20.11.0.14 +expect RBL_SENDERSCORE_REPUT_1 RWL_MAILSPIKE_NEUTRAL RECEIVED_SPAMHAUS_SBL RECEIVED_SPAMHAUS_XBL RECEIVED_BLOCKLISTDE RCVD_IN_DNSWL_MED + +Received: from Agni (localhost [20.11.0.5]) (TLS: TLSv1/SSLv3, 168bits,DES-CBC3-SHA) by agni.forevermore.net + with esmtp; Mon, 28 Oct 2002 14:48:52 -0800 +Received: from [20.11.0.14] (79.sub-174-252-72.myvzw.com [20.11.0.8]) by mx.google.com + with ESMTPS id m16sm345129qck.28.2011.06.15.07.42.02 (version=TLSv1/SSLv3 cipher=OTHER); Wed, 15 Jun 2011 07:42:08 -0700 (PDT) +Received: from user (20.11.0.2) by DB6PR07MB3384.eurprd07.prod.outlook.com ([20.11.0.2]) + with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.1143.11; Thu, 13 Sep 2018 14:47:44 +0000 +Subject: test + +test + + +envelope_from user@surbl-abuse.com +expect URIBL_GREY ABUSE_SURBL DBL_MALWARE SEM_URIBL_FRESH15 SEM_URIBL + +From: user@uribl-grey.com +Subject: check my website sh-malware.com/login.php + +My e-mail is spammer@sem-uribl.com +And my website is https://sem-fresh15.com/offers.html +Try cheating with a trusted domain user@dkimtrusted.org + + +expect DBL_MALWARE + +From: user@example.net +Subject: test + +Write to us at P.O.Box 25050, or call us during office hours. +The attached report.zip and clip.mov cover the rest, and surbl-abuse.com +is only named in passing rather than linked. +Our actual website is https://sh-malware.com/login.php + + +dkim.result pass +dkim.domains dkimtrusted.org +expect DWL_DNSWL_HI + +From: user@dkimtrusted.org +Subject: test + +test + + +expect MSBL_EBL MSBL_EBL_GREY + +From: spammer1@spamcorp.net +Reply-To: User +Subject: test + +test + + +expect SURBL_HASHBL_ABUSE SURBL_HASHBL_MALWARE SURBL_HASHBL_PHISH URL_ONLY REDIRECTOR_URL + +From: spammer@spamcorp.net +Reply-To: User +Subject: test +Content-Type: text/html; charset="utf-8" + + +test +https://cf-ipfs.com/other/path?query=true diff --git a/tests/resources/smtp/antispam/received.test b/tests/resources/smtp/antispam/received.test new file mode 100644 index 0000000..3a77584 --- /dev/null +++ b/tests/resources/smtp/antispam/received.test @@ -0,0 +1,83 @@ +expect RCVD_COUNT_THREE RCVD_NO_TLS_LAST + +Received: from BAY0-HMR08.bay0.hotmail.com (bay0-hmr08.bay0.hotmail.com [65.54.241.207]) + by dogma.slashnull.org (8.11.6/8.11.6) + with ESMTP id h2DBpvs24047 for ; Thu, 13 Mar 2003 11:51:57 GMT +Received: from BAY0-HMR08.bay0.hotmail.com (bay0-hmr08.bay0.hotmail.com [65.54.241.207]) + by dogma.slashnull.org (8.11.6/8.11.6) + with ESMTP id h2DBpvs24047 for ; Thu, 13 Mar 2003 11:51:57 GMT +Received: from BAY0-HMR08.bay0.hotmail.com (bay0-hmr08.bay0.hotmail.com [65.54.241.207]) + by dogma.slashnull.org (8.11.6/8.11.6) + with ESMTP id h2DBpvs24047 for ; Thu, 13 Mar 2003 11:51:57 GMT + +test + +authenticated_as john@doe.com +tls.version TLSv1.3 +expect RCVD_VIA_SMTP_AUTH RCVD_COUNT_ONE RCVD_TLS_LAST + +Received: from BAY0-HMR08.bay0.hotmail.com (bay0-hmr08.bay0.hotmail.com [65.54.241.207]) + by dogma.slashnull.org (8.11.6/8.11.6) + with ESMTP id h2DBpvs24047 for ; Thu, 13 Mar 2003 11:51:57 GMT + +test + +expect RCVD_ILLEGAL_CHARS RCVD_COUNT_ONE RCVD_NO_TLS_LAST + +Received: from BAY0-HMR08.bay0.hótmail.com (bay0-hmr08.bay0.hótmail.com [65.54.241.207]) + by dogma.slashnull.org (8.11.6/8.11.6) + with ESMTP id h2DBpvs24047 for ; Thu, 13 Mar 2003 11:51:57 GMT + +test + + +tls.version TLVv1.3 +expect RCVD_TLS_ALL RCVD_HELO_USER RCVD_DOUBLE_IP_SPAM FORGED_RCVD_TRAIL PREVIOUSLY_DELIVERED RCVD_COUNT_FIVE + +Received: from Agni (localhost [::ffff:127.0.0.1]) (TLS: TLSv1/SSLv3, 168bits,DES-CBC3-SHA) by agni.forevermore.net + with esmtp; Mon, 28 Oct 2002 14:48:52 -0800 +Received: from [10.231.252.223] (79.sub-174-252-72.myvzw.com [174.252.72.79]) by mx.google.com + with ESMTPS id m16sm345129qck.28.2011.06.15.07.42.02 (version=TLSv1/SSLv3 cipher=OTHER); Wed, 15 Jun 2011 07:42:08 -0700 (PDT) +Received: from other.myvzw.com (79.sub-174-252-72.myvzw.com [174.252.72.79]) by mx.google.com + with ESMTPS id m16sm345129qck.28.2011.06.15.07.42.02 (version=TLSv1/SSLv3 cipher=OTHER); Wed, 15 Jun 2011 07:42:08 -0700 (PDT) +Received: from user (10.175.233.33) by DB6PR07MB3384.eurprd07.prod.outlook.com (10.175.234.11) + with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.1143.11; Thu, 13 Sep 2018 14:47:44 +0000 +Received: from [94.198.96.74] (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange ECDHE (P-256) server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) + by ietfa.amsl.com (Postfix) with ESMTPS id 10B7AC151535 for ; Mon, 28 Aug 2023 02:21:23 -0700 (PDT) +To: user@domain.com +Subject: test + +test + + +expect DIRECT_TO_MX RCVD_COUNT_ZERO RCVD_NO_TLS_LAST + +To: user@domain.com +X-Mailer: MUA 1.2 +Subject: test + +test + +expect RCVD_UNPARSABLE RCVD_NO_TLS_LAST RCVD_COUNT_ONE + +To: user@domain.com +Received: invalid + +test + +param.smtputf8 1 +expect RCVD_COUNT_ONE RCVD_NO_TLS_LAST + +Received: from mail-pl1-f179.google.com (mail-pl1-f179.google.com [209.85.214.179]) + by mail.מייל.קום (Postfix) + with UTF8SMTP id h2DBpvs24047 for <יוסי@מייל.קום>; Thu, 13 Mar 2003 11:51:57 GMT + +test + +expect RCVD_ILLEGAL_CHARS RCVD_COUNT_ONE RCVD_NO_TLS_LAST + +Received: from mail-pl1-f179.google.com (mail-pl1-f179.google.com [209.85.214.179]) + by mail.מייל.קום (Postfix) + with UTF8SMTP id h2DBpvs24047 for <יוסי@מייל.קום>; Thu, 13 Mar 2003 11:51:57 GMT + +test diff --git a/tests/resources/smtp/antispam/recipient.test b/tests/resources/smtp/antispam/recipient.test new file mode 100644 index 0000000..f3797b5 --- /dev/null +++ b/tests/resources/smtp/antispam/recipient.test @@ -0,0 +1,235 @@ +expect MISSING_TO RCPT_COUNT_ZERO + +X-To: hello@world.com +Subject: Hi + +Test + +expect RCPT_COUNT_ONE TO_DN_ALL + +To: "Hello World" +Subject: Hi + +Test + +expect RCPT_COUNT_ONE TO_DN_NONE TAGGED_RCPT + +To: hello+there@world.com +Subject: Hi + +Test + +envelope_from user@domain.org +expect TO_DN_RECIPIENTS RCPT_COUNT_TWO TO_DN_SOME + +To: "recipients" +Cc: other@user.org +Subject: Hi + +Test + +expect RCPT_IN_SUBJECT TO_DN_NONE RCPT_COUNT_ONE + +To: hello@world.com +Subject: Special offer for HELLO@world.com + +Test + +expect RCPT_LOCAL_IN_SUBJECT TO_DN_NONE RCPT_COUNT_ONE + +To: hello@world.com +Subject: Special offer for hello + +Test + +envelope_from +envelope_to hello@world.com +envelope_to goodbye@world.com +expect RCPT_BOUNCEMOREONE TO_MATCH_ENVRCPT_ALL TO_DN_NONE RCPT_COUNT_TWO + +To: hello@world.com +Cc: goodbye@world.com +Subject: Hi + +Test + +envelope_from postmaster@domain.org +envelope_to hello@world.com +envelope_to goodbye@world.com +expect RCPT_BOUNCEMOREONE TO_MATCH_ENVRCPT_SOME TO_DN_NONE RCPT_COUNT_THREE + +To: hello@world.com, test@domain.com +Cc: goodbye@world.com +Subject: Hi + +Test + +expect RCPT_COUNT_ZERO UNDISC_RCPT + +To: Undisclosed recipients:; +Subject: Hi + +Test + +envelope_from list@domain.org +envelope_to hello@world.com +expect TO_DN_ALL RCPT_COUNT_ONE + +List-Id: +To: "Mailing List" +Subject: Hi + +Test + +envelope_from spammer@domain.org +envelope_to hello@world.com +expect FORGED_RECIPIENTS TO_NEEDS_ENCODING TO_DN_ALL RCPT_COUNT_ONE + +To: "Thé Spámmer" +Subject: Hi + +Test + +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "Hello World" +Subject: Hi + +Test + +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_EXCESS_QP TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "=?iso-8859-1?Q?Die_Hasen_und_die_Froesche?=" +Subject: Hi + +Test + +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_EXCESS_BASE64 TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "=?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?=" +Subject: Hi + +Test + +envelope_from test@test.com +expect FREEMAIL_TO DISPOSABLE_CC RCPT_COUNT_TWO TO_DN_NONE + +To: user@gmail.com +Cc: otheruser@guerrillamail.com +Subject: Hi + +Test + +envelope_from test@test.com +expect FREEMAIL_CC DISPOSABLE_TO DISPOSABLE_BCC RCPT_COUNT_THREE TO_DN_NONE + +To: otheruser@guerrillamail.com +Cc: user@gmail.com +Bcc: some@guerrillamail.com +Subject: Hi + +Test + +envelope_from test@test.com +expect SORTED_RECIPS RCPT_COUNT_SEVEN TO_DN_NONE + +To: a@domain.com, b@domain.com, c@domain.com, d@domain.com +Cc: e@domain.com, f@domain.com, g@domain.com +Subject: Hi + +Test + +envelope_from test@test.com +expect RCPT_COUNT_SEVEN TO_DN_NONE + +To: tom@domain.com, mark@domain.com, bill@domain.com, peter@domain.com +Cc: jane@domain.com, mary@domain.com, lucy@domain.com +Subject: Hi + +Test + +envelope_from test@test.com +expect SUSPICIOUS_RECIPS RCPT_COUNT_SEVEN TO_DN_NONE + +To: tim@domain.com, tom@domain.com, tum@domain.com, tem@domain.com +Cc: tam@domain.com, tron@domain.com, tym@domain.com +Subject: Hi + +Test + +envelope_from info@notalist.org +envelope_to info@notalist.org +expect INFO_TO_INFO_LU RCPT_COUNT_ONE TO_MATCH_ENVRCPT_ALL TO_DN_NONE + +From: info@notalist.org +To: info@notalist.org +List-Unsubscribe: +Subject: Hi + +Test + +envelope_from info@notalist.org +envelope_to info@notalist.org +expect RCPT_COUNT_ONE TO_MATCH_ENVRCPT_ALL TO_DN_NONE + +From: info@notalist.org +To: info@notalist.org +Subject: Hi + +Test + +envelope_from hello@test.org +envelope_to user@test.org +envelope_to test@test.org +expect TO_WRAPPED_IN_SPACES RCPT_COUNT_TWO TO_MATCH_ENVRCPT_ALL TO_DN_NONE + +From: hello@test.org +To: < user@test.org > +Cc: test@test.org +Subject: Hi + +Test + +envelope_from hello@test.org +envelope_to user@test.org +envelope_to test@test.org +expect TO_WRAPPED_IN_SPACES RCPT_COUNT_TWO TO_MATCH_ENVRCPT_ALL TO_DN_SOME + +From: hello@test.org +To: user@test.org +Cc: "Test" +Subject: Hi + +Test + +expect RCPT_IN_BODY TO_DN_NONE RCPT_COUNT_ONE + +To: hello@world.com +Subject: Special offer + +An offer for hello@world.com + +expect RCPT_DOMAIN_IN_MESSAGE RCPT_IN_BODY RCPT_DOMAIN_IN_SUBJECT RCPT_COUNT_ONE TO_DN_NONE + +To: hello@world.com +Subject: Message for world.com + +An offer for hello@world.com + + +expect RCPT_DOMAIN_IN_MESSAGE RCPT_DOMAIN_IN_BODY RCPT_DOMAIN_IN_SUBJECT RCPT_COUNT_ONE TO_DN_NONE + +To: hello@world.com +Subject: Message for world.com + +An offer for world.com + diff --git a/tests/resources/smtp/antispam/replyto.test b/tests/resources/smtp/antispam/replyto.test new file mode 100644 index 0000000..4fbf81b --- /dev/null +++ b/tests/resources/smtp/antispam/replyto.test @@ -0,0 +1,112 @@ +expect REPLYTO_UNPARSABLE + +Reply-to: hello + +Test + +expect REPLYTO_EQ_FROM HAS_REPLYTO + +From: hello@domain.org +Reply-to: hello@domain.org + +Test + +expect REPLYTO_DOM_EQ_FROM_DOM REPLYTO_DN_EQ_FROM_DN HAS_REPLYTO + +From: "Hello" +Reply-to: "Hello" + +Test + +envelope_from hello@otherdomain.org.uk +envelope_to user@somedomain.com +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: hello@otherdomain.org.uk +To: user@somedomain.com, hello@otherdomain.org.uk +Reply-to: hello@domain.org.uk + +Test + +envelope_from sender@foo.org +envelope_to user@somedomain.com +expect REPLYTO_EQ_TO_ADDR SPOOF_REPLYTO HAS_REPLYTO + +From: sender@foo.org +To: user@somedomain.com +Reply-to: user@somedomain.com + +Test + +envelope_from list@foo.org +envelope_to user@somedomain.com +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: list@foo.org +List-Unsubscribe: unsubcribe@foo.org +To: user@somedomain.com +Reply-to: user@somedomain.com + +Test + +envelope_from user@foo.org +envelope_to other@foo.org +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: user@foo.org +To: otheruser@foo.org +Reply-to: user@otherdomain.org + +Test + +envelope_from user@foo.org +envelope_to otheruser@domain.org +expect SPOOF_REPLYTO REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: user@foo.org +To: otheruser@domain.org +Reply-to: user@otherdomain.org + +Test + +expect REPLYTO_EXCESS_QP REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: =?iso-8859-1?Q?Die_Hasen_und_die_Froesche?= + +Test + +expect REPLYTO_EXCESS_BASE64 REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: "=?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?=" + +Test + +expect REPLYTO_EMAIL_HAS_TITLE REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: "Mr. Hello" + +Test + +expect FREEMAIL_REPLY_TO FREEMAIL_FROM REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@gmail.com +Reply-to: bye@gmail.com + +Test + +expect DISPOSABLE_REPLY_TO DISPOSABLE_FROM REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@custom.disposable.org +Reply-to: bye@custom.disposable.org + +Test + +expect FREEMAIL_REPLY_TO_NEQ_FROM_DOM FREEMAIL_REPLY_TO FREEMAIL_FROM REPLYTO_DOM_NEQ_FROM_DOM SPOOF_REPLYTO HAS_REPLYTO + +From: hello@gmail.com +Reply-to: hello@yahoomail.com + +Test diff --git a/tests/resources/smtp/antispam/spamtrap.test b/tests/resources/smtp/antispam/spamtrap.test new file mode 100644 index 0000000..b6f3c26 --- /dev/null +++ b/tests/resources/smtp/antispam/spamtrap.test @@ -0,0 +1,8 @@ +envelope_from spammer@domain.com +envelope_to spamtrap@foobar.org +expect SPAM_TRAP + +Subject: save up to NUMBER on life insurance + +why spend more than you have to life quote savings ensuring your family s financial security is very important life quote savings makes buying life insurance simple and affordable we provide free access to the very best companies and the lowest rates life quote savings is fast easy and saves you money let us help you get started with the best values in the country on new coverage you can save hundreds or even thousands of dollars by requesting a free quote from lifequote savings our service will take you less than NUMBER minutes to complete shop and compare save up to NUMBER on all types of life insurance hyperlink click here for your free quote protecting your family is the best investment you ll ever make if you are in receipt of this email in error and or wish to be removed from our list hyperlink please click here and type remove if you reside in any state which prohibits e mail solicitations for insurance please disregard this email + diff --git a/tests/resources/smtp/antispam/subject.test b/tests/resources/smtp/antispam/subject.test new file mode 100644 index 0000000..a98d973 --- /dev/null +++ b/tests/resources/smtp/antispam/subject.test @@ -0,0 +1,138 @@ +expect SUBJ_ALL_CAPS + +Subject: HELLO WORLD + +Test + +expect LONG_SUBJ + +Subject: this is an extremely long subject line that + should be truncated to 80 characters and folded + in order to be RFC compliant and avoid the SPAM filter + that is looking for long subject lines like this one + which by the way, it is ridiculously long + +Test + +expect SUBJECT_NEEDS_ENCODING + +Subject: thís líné shóúld bé éncódéd + +Test + +param.smtputf8 1 +expect + +Subject: thís líné shóúld bé éncódéd + +Test + +param.8bitmime 1 +expect + +Subject: thís líné shóúld bé éncódéd + +Test + +expect + +Subject: =?iso-8859-1?Q?Die_Hasen_und_die_Fr=F6sche?= + +Test + +expect URL_IN_SUBJECT + +Subject: check out my url HTTPS://SPAMMER.COM + +Test + +expect URL_IN_SUBJECT + +Subject: check out my url HTTP://SPAMMER.COM + +Test + +expect MISSING_SUBJECT + +X-Subject: missing subject + +Test + +expect EMPTY_SUBJECT + +Subject: + +Test + +expect SUBJ_EXCESS_QP + +Subject: =?iso-8859-1?Q?Die_Hasen_und_die_Froesche?= + +Test + +expect SUBJ_EXCESS_BASE64 + +Subject: =?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?= + +Test + +expect FAKE_REPLY + +Subject: Re: about your question + +Test + +expect + +In-Reply-To: +Subject: Re: about your question + +Test + +expect + +References: +Subject: Re: about your question + +Test + +expect SUBJECT_ENDS_SPACES + +Subject: =?iso-8859-1?Q?Die_Hasen_und_die_Fr=F6sche_?= + +Test + +param.smtputf8 1 +expect SUBJECT_HAS_CURRENCY SUBJECT_ENDS_EXCLAIM + +Subject: You have won £200! + +Test + +param.smtputf8 1 +expect SUBJECT_HAS_CURRENCY SUBJECT_ENDS_QUESTION + +Subject: Have you won $200? + +Test + +expect RCPT_IN_SUBJECT + +To: hello@world.org +Subject: Great offers for hello@world.org + +Test + +expect RCPT_DOMAIN_IN_SUBJECT + +To: hello@world.org +Subject: Great offers for world.org + +Test + +expect + +To: hello@world.org +Subject: Question about other@domain.net + +Test diff --git a/tests/resources/smtp/antispam/url.test b/tests/resources/smtp/antispam/url.test new file mode 100644 index 0000000..9aa2dc2 --- /dev/null +++ b/tests/resources/smtp/antispam/url.test @@ -0,0 +1,124 @@ +expect URL_ONLY + +Subject: test + +https://url.org + +expect + +Subject: test + +my site is https://url.org + +expect SUSPICIOUS_URL + +Subject: test + +my site is https://192.168.1.1 + +expect HOMOGRAPH_URL + +Subject: test + +my site is https://xn--youtue-tg7b.com + +expect MIXED_CHARSET_URL + +Subject: test + +my site is https://www.xn--1ca81o6aa92e.com/ + +expect UNPARSABLE_URL + +Subject: test + +login to your account at https://bánk.com/ + +expect URL_REDIRECTOR_NESTED REDIRECTOR_URL + +Subject: nested redirect + +login to https://redirect.com/?https://redirect.org/?https://redirect.net/?https://redirect.io/?https://redirect.me/?https://redirect.com + +expect REDIRECTOR_URL HOMOGRAPH_URL + +Subject: redirect to omograph + +login to https://www.redirect.com/?https://xn--twiter-507b.com + +expect HAS_ONION_URI HAS_ANON_DOMAIN + +Subject: url in title darkweb.onion/login + +test + +expect HAS_IPFS_GATEWAY_URL HAS_WP_URI URI_HIDDEN_PATH + +Content-Type: text/html; charset="utf-8" +Subject: html test + + + + + +expect HAS_GUC_PROXY_URI HAS_GOOGLE_FIREBASE_URL HAS_GOOGLE_REDIR HAS_ANON_DOMAIN URL_ONLY + +Content-Type: text/html; charset="utf-8" +Subject: mixed urls googleusercontent.com/proxy/url + +google.com/url?otherurl.org + + +expect WP_COMPROMISED + +Subject: plain test + +http://url.com/Well-known/../assetlinks.json +http://wp.com/WP-content/content.pdf + + +expect HAS_WP_URI + +Subject: plain test + +http://url.com/Well-known/../assetlinks.json +http://wp.com/WP-other/content.pdf + + +expect PHISHED_OPENPHISH PHISHED_PHISHTANK + +Subject: plain test + +https://phishing-open.org +https://phishing-tank.com + + +expect + +Subject: IPs are not urls + +192.168.1.1 + + +expect + +Content-Type: text/html; charset="utf-8" +Subject: IPs in HTML are not urls + + +Das System wurde um 01.01.1970 08:28:00 für die IP-Adresse +123.123.123.123 gesperrt.
+
+Der Besucher hat versucht, sich mit folgenden Daten anzumelden.
+Partner: 12345678
+Portal: IP-Sperre einsehen + + + +expect RCPT_DOMAIN_IN_BODY + +To: hello@world.com +Subject: Special offer + +An offer for world.com + diff --git a/tests/resources/smtp/certs/tls_cert.pem b/tests/resources/smtp/certs/tls_cert.pem new file mode 100644 index 0000000..02b9b96 --- /dev/null +++ b/tests/resources/smtp/certs/tls_cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFCTCCAvGgAwIBAgIUCgHGQYUqtelbHGVSzCVwBL3fyEUwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDUxNjExNDAzNFoXDTIzMDUx +NjExNDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAtwS0Fzl3SjaCuKEXgZ/fdWbDoj/qDphyNCAKNevQ0+D0 +STNkWCO04aFSH0zcL8zoD9gokNos0i7OU9//ZhZQmex4V6EFdZn8bFwUWN/scUvW +HEFXVjtHldO2isZgIxH9LuwRv7KAgkISuWahqerOVDhe7SeQUV0AJGNEh3cT9PZr +gSY931BxB7n+5k8eoSk8Z1gtBzQzL62kVGpHDKfw8yX8m65owF9eLUBrNzgxmXfC +xpuHwj7hmVhS09PPKeN/RsFS8PsYO7bo0u8jEKalteumjRT7RyUEbioqfo6ZFOGj +FHPIq/uKXS9zN1fpoyNh3ur5hMznQhrqlwBM9KlM7GdBJ0pZ3ad0YjT8IL/GnGKR +85J2WZdLqaQdUZo7nV67FhqdDlNE4MdwiykTMjfmLRXGAVhAzJHKyRKNwmkI2aqe +S7aqeNgvuDBwY80Q9a2rb5py1Aw+L8yCkUBuHboToDpxSVRDNN8DrWNmmsXnxsOG +wRDODy4GICKyxlP+RFSM8xWSQ6y9ktS2OfDBm+Eqcw+3pZKhdz2wgxLkUBJ8X1eh +kJrCA/6LTuhy6m6mMjAfoSOFU7fu88jxaWPgvP7GKyH+LM/t9eucobz2ks5rtSjz +V4Dc5DCS94/OpVRHwHdaFSPbJKBN9Ev8gnNrAyx/aBPGoHBPG/QUiU7dcUNIPt0C +AwEAAaNTMFEwHQYDVR0OBBYEFI167IxBmErB11EqiPPqFLa31ZaMMB8GA1UdIwQY +MBaAFI167IxBmErB11EqiPPqFLa31ZaMMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggIBALU00IOiH5ubEauVCmakms5ermNTZfculnhnDfWTLMeh2+a7 +G4cqADErfMhm/mmLbrw33t9s6tCAhQltvewKR40ST9uMPSyiQbYaCXd5DXnuI6Ox +JtNW+UOWIaMf8abnkdLvREOvb8dVQS1i3xq14tAjY5XgpGwCPP8m54b7N3Q7soLn +e5PDhPNTnhRIn2RLuYoZmQmMA5fcqEUDYff4epUww7PhrM1QckZligI3566NlGOf +j1G9JrivBtY0eaJtamIFnGMBT0ThDudxVja2Nv0C2Elry0p4T/o4nc4M67BJ/y1R +vjNLAgFhbxssemU3lZqSd+pykpJBwDBjFSPrZZmQcbk7H6Uz8V1xr/xuzfw6fA13 +NWZ5vLgP/DQ13sM+XFlxThKfbPMPVe/UCTvfGtNW+3XyBgPntEkR+fNEawQmzbYl +R+X1ymT9MZnEZqRMf7/UD/SYek1aUJefoew3upjMgxYVvh4F8dqJ+39F+xoFzIA2 +1dDAEMzXtjA3zKhZ2cycZbEzpJvYA3eGLuR16Suqfi4kPvfwK0mOhCxQmpayt7/X +vuEzW6dPCH8Hgbb0WvsSppGOvhdbDaZFNfFc5eNSxhyKzu3H3ACNImZRtZE+yixx +0fR8+xz9kDLf8xupV+X9heyFGHSyYU2Lveaevtr2Ij3weLRgJ6LbNALoeKXk +-----END CERTIFICATE----- diff --git a/tests/resources/smtp/certs/tls_privatekey.pem b/tests/resources/smtp/certs/tls_privatekey.pem new file mode 100644 index 0000000..3f9ff47 --- /dev/null +++ b/tests/resources/smtp/certs/tls_privatekey.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC3BLQXOXdKNoK4 +oReBn991ZsOiP+oOmHI0IAo169DT4PRJM2RYI7ThoVIfTNwvzOgP2CiQ2izSLs5T +3/9mFlCZ7HhXoQV1mfxsXBRY3+xxS9YcQVdWO0eV07aKxmAjEf0u7BG/soCCQhK5 +ZqGp6s5UOF7tJ5BRXQAkY0SHdxP09muBJj3fUHEHuf7mTx6hKTxnWC0HNDMvraRU +akcMp/DzJfybrmjAX14tQGs3ODGZd8LGm4fCPuGZWFLT088p439GwVLw+xg7tujS +7yMQpqW166aNFPtHJQRuKip+jpkU4aMUc8ir+4pdL3M3V+mjI2He6vmEzOdCGuqX +AEz0qUzsZ0EnSlndp3RiNPwgv8acYpHzknZZl0uppB1RmjudXrsWGp0OU0Tgx3CL +KRMyN+YtFcYBWEDMkcrJEo3CaQjZqp5Ltqp42C+4MHBjzRD1ratvmnLUDD4vzIKR +QG4duhOgOnFJVEM03wOtY2aaxefGw4bBEM4PLgYgIrLGU/5EVIzzFZJDrL2S1LY5 +8MGb4SpzD7elkqF3PbCDEuRQEnxfV6GQmsID/otO6HLqbqYyMB+hI4VTt+7zyPFp +Y+C8/sYrIf4sz+3165yhvPaSzmu1KPNXgNzkMJL3j86lVEfAd1oVI9skoE30S/yC +c2sDLH9oE8agcE8b9BSJTt1xQ0g+3QIDAQABAoICABq5oxqpF5RMtXYEgAw7rkPU +h8jPkHwlIrgd3Z/WGZ53APUXfhWo0ScJiZZsgNKyF0kJBZNxaI4gq5xv3zmnFIoF +j+Ur7EIqBERGheoceMhqjI9/syMycNeeHM/S/ALjA5ewfT8C7+UVhOpx5DWNxidi +O+phlp9q9zRZEo69grqIqVYooWxUsMyyCljTQOPDw8BLjfe5VagmsRJqmolslLDM +4UBSjZVZ18S/3Wgo2oVQia660244BHWCAkZQbbXuNI2+eUAbSoSdxw3WQcaSrywL +hzyezbqr2yPDIIVuiUgVUt0Ps0P57VCCN07jlYhvCEGnClysFzD+ATefoZ0wg7za +dQu2E+d166rAjnssyhzcHMn3pxgSdtXD+dQR/xfIGbPABucCupEFqKmhLdMm9+ud +lHay87qzMpIa8cITJwEQROfXqWAhNUU98pKCOx1SVXBqQC7QVqGQ5solDf0eMSVh +ngQ6Dz2WUI2ty75LteiFwlyTgnU9nyPN0NXsrMEET2BHWre7ufTQqiULtQ7+9BwH +AMxEKvrQHjMUjdfbXuzdyc5w5mPYJZfFVSQ1HMslx66h9yCpRIsBZvUGvoaP8Tpe +nQ66FTYRbiOkkdJ7k8DtrnhsJI1oOGjnvj/rvZ8D2pvrlJcIH2AyN3MOL8Jp5Oj1 +nCFt77TwpF92pgl0g9gBAoIBAQDcarmP54QboaIQ9S2gE/4gSVC5i44iDJuSRdI8 +K081RQcWiNzqQXTRc5nqJ7KzLyPiGlg+6rWsBKLos5l4t+MdhhH+KUvk/OtT/g8V +0NZBNXLIbSb8j8ix4v3/f2qKHN3Co6QOlxb3gFvobKDdoKqUNiSH1zTZ8/Y/BzkM +jqWKhTdaLz6eyzhKfOTA4LO8kJ3VF8HUM1N9/e8Gjorl+gZpJUXUQS0+AIi8W76C +OwDrVb3BPGVnApQJfWF78h4g20RwXrx/GYUW2vOMcLjXXDV5U7+nobPUoJnLxoZC +16o88y0Ivan8dBNXsc1epyPvvEqp6MJbAyyVuNeuRJcgYA0BAoIBAQDUkGRV7fLG +wCr5rNysUO+FKzVtTJnf9KEsqAqUmmVnG4oubxAJJtiB5n2+DT+CtO8Nrtz05BbR +uxfWm+lbEw6lVMj63bywtp0NdULg7/2t+oq2Svv16KrZIRJttXMkdEiFFmkVAEhX +l8Fyl6PJPfSMwbPdXEUPUAaNrXweVFffXczHc4W2G212ZzDB0z7QQSgEntbTDFB/ +2Cg5dvuojlM9zw0fuEyLwItZs7n16j/ONZLgBHyroMU9ZPxbnLrVyoZlqtob+RWm +Ju2fSIL9QqG6O4td1TqcUBGvFQYjGvKA+q5fsG26NBJ0Ac48cNK6PS4lMkN3Av2J +ccloYaMEHAXdAoIBAE8WMCy1Ok6byUXiYxOL+OPmyoM40q/e7DcovE2AkLQhZ3Cr +fPDEucCphPFiexkV8f8fysgQeU0WgMmUH54UBPbD81LJyISKR3nkr875Ftdg8SV/ +HL0EblN9ifuR4U1bHCrJgoUFq2T09oVH7NR44Ju7bZIcIseNZK6qzcp2qGkycXD3 +gLWDX1hCxeV6+qLPFQKvuomEPRH4+jnVDXuFIaW6jPqixDP6BxXmqU2bFDJcmnBq +VkwGvc1F4qORdUP+yOi05VeJdZqEx1x92aTUXg+BgEQKnjbNxUE7o1L6hQfHjUIU +o5iEoagWkQTEXf2YBwY+EPaNBgNWxnSuAbfJHwECggEBALOF95ezTVWauzD/U6ic ++o3n/kl/Zn4FJ5KFodn7xCSe18d7uXlhO34KYqx+l+MWWMefpbGWacdcUjfImf93 +SulLgCqP12sP7/iLzp4XUpL7hOeM0NvRU2nqSpwpoUNqik0Mrlc0U+TWoGTduVCf +aMjwV65e3VyfY8mIeclLxqM5n1fcM1OoOnzDjiRE+0n7nYa5eAnq3pn6v4449TZY +belH03e0ucFWLtrltesBmj3YdWGJqJlzQOInRhNBfXJOh8+ZynfRmP0o54udPDQV +cG3PGFd5XPTjkuvhv7sqaSGRlm/um92lWOhtFfdp+i+cuDpmByCef+7zEP19aKZx +3GkCggEAFTs7KNMfvIEaLH0yQUFeq2gLmtcMofmOmeoIECycN1rG7iJo07lJLIs0 +bVODH8Z0kX8llu3cjGMAH/6R2uugJSxkmFiZKrngTzKmxDPvTCKWR4RFwXH9j8IO +cPq7FtKN4SgrPy9ciAPdkcGmu3zz/sBKOaoPwvU2PdBRT+v/aoz+GCLXAvzFlKVe +9/7zdg87ilo8+AtV+71EJeR3kyBPKS9JrWYUKfiams12+uuH4/53rMFZfNCAaZ3Z +1sdXEO4o3Loc5TX4DbO9FVdBSBe6klEXx4T0QJboO6uBvTBnnRL2SQriJQQFwYT6 +XzVV5pwOxkIDBWDIqMUfwJDChBKfpw== +-----END PRIVATE KEY----- diff --git a/tests/resources/smtp/dane/dns.txt b/tests/resources/smtp/dane/dns.txt new file mode 100644 index 0000000..4167f96 --- /dev/null +++ b/tests/resources/smtp/dane/dns.txt @@ -0,0 +1,3 @@ +_25._tcp.internet.nl 2 1 1 E1AE9C3DE848ECE1BA72E0D991AE4D0D9EC547C6BAD1DDDAB9D6BEB0A7E0E0D8 +_25._tcp.internet.nl 3 1 1 D6FEA64D4E68CAEAB7CBB2E0F905D7F3CA3308B12FD88C5B469F08AD7E05C7C7 +_25._tcp.mail.ietf.org 3 1 1 0C72AC70B745AC19998811B131D662C9AC69DBDBE7CB23E5B514B56664C5D3D6 diff --git a/tests/resources/smtp/dane/internet.nl.0.cert b/tests/resources/smtp/dane/internet.nl.0.cert new file mode 100644 index 0000000..55830fc Binary files /dev/null and b/tests/resources/smtp/dane/internet.nl.0.cert differ diff --git a/tests/resources/smtp/dane/internet.nl.1.cert b/tests/resources/smtp/dane/internet.nl.1.cert new file mode 100644 index 0000000..993b2eb Binary files /dev/null and b/tests/resources/smtp/dane/internet.nl.1.cert differ diff --git a/tests/resources/smtp/dane/mail.ietf.org.0.cert b/tests/resources/smtp/dane/mail.ietf.org.0.cert new file mode 100644 index 0000000..8928c78 Binary files /dev/null and b/tests/resources/smtp/dane/mail.ietf.org.0.cert differ diff --git a/tests/resources/smtp/dane/mail.ietf.org.1.cert b/tests/resources/smtp/dane/mail.ietf.org.1.cert new file mode 100644 index 0000000..371f36a Binary files /dev/null and b/tests/resources/smtp/dane/mail.ietf.org.1.cert differ diff --git a/tests/resources/smtp/dane/mail.ietf.org.2.cert b/tests/resources/smtp/dane/mail.ietf.org.2.cert new file mode 100644 index 0000000..9ceb8ee Binary files /dev/null and b/tests/resources/smtp/dane/mail.ietf.org.2.cert differ diff --git a/tests/resources/smtp/dane/mail.ietf.org.3.cert b/tests/resources/smtp/dane/mail.ietf.org.3.cert new file mode 100644 index 0000000..53a86ac Binary files /dev/null and b/tests/resources/smtp/dane/mail.ietf.org.3.cert differ diff --git a/tests/resources/smtp/dsn/delay.eml b/tests/resources/smtp/dsn/delay.eml new file mode 100644 index 0000000..d593a51 --- /dev/null +++ b/tests/resources/smtp/dsn/delay.eml @@ -0,0 +1,50 @@ +From: "Mail Delivery Subsystem" +To: sender@foobar.org +Auto-Submitted: auto-generated +Subject: Warning: Delay in message delivery +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="mime_boundary" + + +--mime_boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +There was a temporary problem delivering your message to the following reci= +pients: + + (connection to 'mx.domain.org' failed: Connection ti= +meout) + + +--mime_boundary +Content-Type: message/delivery-status; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns;mx.example.org +Arrival-Date: + +Original-Recipient: rfc822;jdoe@example.org +Final-Recipient: rfc822;john.doe@example.org +Action: delayed +Status: 4.0.0 +Remote-MTA: dns;mx.domain.org +Will-Retry-Until: + + +--mime_boundary +Content-Type: message/rfc822; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Disclose-recipients: prohibited +From: Message Router Submission Agent +Subject: Status of: Re: Battery current sense +To: owner-ups-mib@CS.UTK.EDU +Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com> +MIME-version: 1.0 +Content-Type: text/plain + + +--mime_boundary-- + diff --git a/tests/resources/smtp/dsn/failure.eml b/tests/resources/smtp/dsn/failure.eml new file mode 100644 index 0000000..5b1b890 --- /dev/null +++ b/tests/resources/smtp/dsn/failure.eml @@ -0,0 +1,48 @@ +From: "Mail Delivery Subsystem" +To: sender@foobar.org +Auto-Submitted: auto-generated +Subject: Failed to deliver message +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="mime_boundary" + + +--mime_boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Your message could not be delivered to the following recipients: + + (host 'mx.example.org' rejected command 'RCPT TO:' with code 550 (5.1.2) 'User does not exist') + + +--mime_boundary +Content-Type: message/delivery-status; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns;mx.example.org +Arrival-Date: + +Final-Recipient: rfc822;foobar@example.org +Action: failed +Status: 5.1.2 +Diagnostic-Code: smtp;550 User does not exist +Remote-MTA: dns;mx.example.org + + +--mime_boundary +Content-Type: message/rfc822; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Disclose-recipients: prohibited +From: Message Router Submission Agent +Subject: Status of: Re: Battery current sense +To: owner-ups-mib@CS.UTK.EDU +Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com> +MIME-version: 1.0 +Content-Type: text/plain + + +--mime_boundary-- + diff --git a/tests/resources/smtp/dsn/mixed.eml b/tests/resources/smtp/dsn/mixed.eml new file mode 100644 index 0000000..4e91161 --- /dev/null +++ b/tests/resources/smtp/dsn/mixed.eml @@ -0,0 +1,69 @@ +From: "Mail Delivery Subsystem" +To: sender@foobar.org +Auto-Submitted: auto-generated +Subject: Partially delivered message +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="mime_boundary" + + +--mime_boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Your message has been partially delivered: + + ----- Delivery to the following addresses was successful ----- + (delivered to 'mx2.example.org' with code 250 (2.1.5) 'M= +essage accepted for delivery') + + ----- There was a temporary problem delivering to these addresses ----- + (connection to 'mx.domain.org' failed: Connection ti= +meout) + + ----- Delivery to the following addresses failed ----- + (host 'mx.example.org' rejected command 'RCPT TO:' with code 550 (5.1.2) 'User does not exist') + + +--mime_boundary +Content-Type: message/delivery-status; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns;mx.example.org +Arrival-Date: + +Final-Recipient: rfc822;foobar@example.org +Action: failed +Status: 5.1.2 +Diagnostic-Code: smtp;550 User does not exist +Remote-MTA: dns;mx.example.org + +Final-Recipient: rfc822;jane@example.org +Action: delivered +Status: 2.1.5 +Remote-MTA: dns;mx2.example.org + +Original-Recipient: rfc822;jdoe@example.org +Final-Recipient: rfc822;john.doe@example.org +Action: delayed +Status: 4.0.0 +Remote-MTA: dns;mx.domain.org +Will-Retry-Until: + + +--mime_boundary +Content-Type: message/rfc822; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Disclose-recipients: prohibited +From: Message Router Submission Agent +Subject: Status of: Re: Battery current sense +To: owner-ups-mib@CS.UTK.EDU +Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com> +MIME-version: 1.0 +Content-Type: text/plain + + +--mime_boundary-- + diff --git a/tests/resources/smtp/dsn/original.txt b/tests/resources/smtp/dsn/original.txt new file mode 100644 index 0000000..170f308 --- /dev/null +++ b/tests/resources/smtp/dsn/original.txt @@ -0,0 +1,10 @@ +Disclose-recipients: prohibited +Date: Fri, 08 Jul 1994 09:21:25 -0400 (EDT) +From: Message Router Submission Agent +Subject: Status of: Re: Battery current sense +To: owner-ups-mib@CS.UTK.EDU +Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com> +MIME-version: 1.0 +Content-Type: text/plain + + diff --git a/tests/resources/smtp/dsn/success.eml b/tests/resources/smtp/dsn/success.eml new file mode 100644 index 0000000..68839e7 --- /dev/null +++ b/tests/resources/smtp/dsn/success.eml @@ -0,0 +1,47 @@ +From: "Mail Delivery Subsystem" +To: sender@foobar.org +Auto-Submitted: auto-generated +Subject: Successfully delivered message +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="delivery-status"; + boundary="mime_boundary" + + +--mime_boundary +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Your message has been successfully delivered to the following recipients: + + (delivered to 'mx2.example.org' with code 250 (2.1.5) 'M= +essage accepted for delivery') + + +--mime_boundary +Content-Type: message/delivery-status; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Reporting-MTA: dns;mx.example.org +Arrival-Date: + +Final-Recipient: rfc822;jane@example.org +Action: delivered +Status: 2.1.5 +Remote-MTA: dns;mx2.example.org + + +--mime_boundary +Content-Type: message/rfc822; charset="utf-8" +Content-Transfer-Encoding: 7bit + +Disclose-recipients: prohibited +From: Message Router Submission Agent +Subject: Status of: Re: Battery current sense +To: owner-ups-mib@CS.UTK.EDU +Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com> +MIME-version: 1.0 +Content-Type: text/plain + + +--mime_boundary-- + diff --git a/tests/resources/smtp/lists/test-list1.txt b/tests/resources/smtp/lists/test-list1.txt new file mode 100644 index 0000000..4e191fb --- /dev/null +++ b/tests/resources/smtp/lists/test-list1.txt @@ -0,0 +1,2 @@ +user1@domain.org +user2@domain.org diff --git a/tests/resources/smtp/lists/test-list2.txt b/tests/resources/smtp/lists/test-list2.txt new file mode 100644 index 0000000..6eeca46 --- /dev/null +++ b/tests/resources/smtp/lists/test-list2.txt @@ -0,0 +1,3 @@ +user3@example.net +user4@example.net +user5@example.net diff --git a/tests/resources/smtp/messages/arc.eml b/tests/resources/smtp/messages/arc.eml new file mode 100644 index 0000000..17488ad --- /dev/null +++ b/tests/resources/smtp/messages/arc.eml @@ -0,0 +1,34 @@ +ARC-Seal: i=2; a=rsa-sha256; s=rsa; d=manchego.org; cv=pass; + b=wpAAy6QusmF4O8SeziNaKxXL6EleeBYxQ0HrXl2cDgzHLOvYG0N1Wpz0bpVbA8VgteD2X8XCW + yrdlZ5dIPTcCvgfLGLXLRTIcYUdKyfFh5IVEciaUOUsxlSRPpekENZKzdHFkL4j1mAAvpDNJ7Ft + OFIp0ku5dACn80g7D4cSEU0=; +ARC-Message-Signature: i=2; a=rsa-sha256; s=rsa; d=manchego.org; c=relaxed/relaxed; + h=Subject:To:From:DKIM-Signature; t=1674137914; bh=4ET7siw2kYV7jcN+fzsuYng/ + sr/BmIzzEjh43dVAv40=; b=V3tMBI1RsyJJY7HUABcebHf0mDJ9odbPm++ZMY5AsCaUYNoSsAm + wCf5wYlJQ26KmsluOYXoPwML0a/xvnMXPv6Rs4Z9k4IwzpzhGLsijDXymGPsW3hgq/6ivVTPkwU + +pGSCC70rHNrAFFk5P67Ly0tbGYjJ0wZVHBzqL8IJBXK4=; +ARC-Authentication-Results: i=2; manchego.org; + dkim=pass header.d=manchego.org header.s=rsa header.b=IN4oMvqq +Authentication-Results: manchego.org; + dkim=pass header.d=manchego.org header.s=rsa header.b=IN4oMvqq +ARC-Seal: i=1; a=ed25519-sha256; s=ed; d=scamorza.org; cv=none; + b=k/MAHECtaer9v4oczoe00a6XMjrxU4QUVVPlZI8XYegbiOgDSaeR6IrwBSKVcN0ELYU+HXlNW + RuUGkRuZXQODA==; +ARC-Message-Signature: i=1; a=ed25519-sha256; s=ed; d=scamorza.org; c=relaxed/relaxed; + h=Subject:To:From:DKIM-Signature; t=1674137914; bh=4ET7siw2kYV7jcN+fzsuYng/ + sr/BmIzzEjh43dVAv40=; b=ZVPqB/5+mbOEKIgBsq+S71Sfj2JZUlGmYEA0Ygbj0S1VmTAnsVu + FQSInMY4/qcIeqU23BtzMgCFVZfAg5i3zDw==; +ARC-Authentication-Results: i=1; scamorza.org; + dkim=pass header.d=manchego.org header.s=rsa header.b=IN4oMvqq +Authentication-Results: scamorza.org; + dkim=pass header.d=manchego.org header.s=rsa header.b=IN4oMvqq +DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=manchego.org; c=relaxed/relaxed; + h=Subject:To:From; t=1674137914; bh=4ET7siw2kYV7jcN+fzsuYng/sr/BmIzzEjh43dV + Av40=; b=IN4oMvqqxWCEyC38F7fZecYJcnq+7zP3G/xjcI64M3/Dzys2lmQeLYAXipwwYvEa5a + VwCcJ7XUX0kSxtr6igC8FIJEDI6UmdvJgMEj/hnEjXR8m4GPrphigjJy7hagaQymBT9WhlzsDPI + QRlUVoW0y5v1aDp3KF9bLVCKTELJPM=; +From: queso@manchego.org +To: affumicata@scamorza.org +Subject: Say cheese + +We need to settle which one of us is tastier. diff --git a/tests/resources/smtp/messages/dkim.eml b/tests/resources/smtp/messages/dkim.eml new file mode 100644 index 0000000..49c2760 --- /dev/null +++ b/tests/resources/smtp/messages/dkim.eml @@ -0,0 +1,14 @@ +DKIM-Signature: v=1; a=rsa-sha256; s=default; d=example.com; c=relaxed/relaxed; r=y; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=m5lYqx81xqAIo4ZBC9FDiFIBrRnnep+taSsutc5MkbBQvf9/Lb54AXHhruEdO2EGkG + xUxL1c8QDH3eLz84fPTUgZue84tAsAa0q4gJFIYM5q2/GJvJ6cBvsXKZj82FjRTIz4wlLjzkW7p + NdR9C5CID2PO4sW+GymS45F8hwqSj4=; +DKIM-Signature: v=1; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/relaxed; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=t8z1AsaxeWek+gMSVojbs2QJu+orzeR4CiHVquJYvXzv+Eb52Wq0fEmaOxoyY1teVL + Odp57Vq/zTLjMMZ2hbBQ==; +From: bill@example.com +To: jdoe@example.com +Subject: TPS Report + +I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great. diff --git a/tests/resources/smtp/messages/idn_from.eml b/tests/resources/smtp/messages/idn_from.eml new file mode 100644 index 0000000..6ddc394 --- /dev/null +++ b/tests/resources/smtp/messages/idn_from.eml @@ -0,0 +1,7 @@ +From: Yossi +To: John Doe +Subject: Shalom +Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) +Message-ID: <20030712040037.46341.5F8J@xn--eebajf.xn--9dbq2a> + +Test message from an internationalized domain. diff --git a/tests/resources/smtp/messages/invalid_arc.eml b/tests/resources/smtp/messages/invalid_arc.eml new file mode 100644 index 0000000..6e65fd7 --- /dev/null +++ b/tests/resources/smtp/messages/invalid_arc.eml @@ -0,0 +1,25 @@ +ARC-Seal: i=1; a=rsa-sha256; s=rsa; d=manchego.org; cv=fail; + b=wpAAy6QusmF4O8SeziNaKxXL6EleeBYxQ0HrXl2cDgzHLOvYG0N1Wpz0bpVbA8VgteD2X8XCW + yrdlZ5dIPTcCvgfLGLXLRTIcYUdKyfFh5IVEciaUOUsxlSRPpekENZKzdHFkL4j1mAAvpDNJ7Ft + OFIp0ku5dACn80g7D4cSEU0=; +ARC-Message-Signature: i=1; a=rsa-sha256; s=rsa; d=manchego.org; c=relaxed/relaxed; + h=Subject:To:From:DKIM-Signature; t=1674137914; bh=4ET7siw2kYV7jcN+fzsuYng/ + sr/BmIzzEjh43dVAv40=; b=V3tMBI1RsyJJY7HUABcebHf0mDJ9odbPm++ZMY5AsCaUYNoSsAm + wCf5wYlJQ26KmsluOYXoPwML0a/xvnMXPv6Rs4Z9k4IwzpzhGLsijDXymGPsW3hgq/6ivVTPkwU + +pGSCC70rHNrAFFk5P67Ly0tbGYjJ0wZVHBzqL8IJBXK4=; +ARC-Authentication-Results: i=1; manchego.org; + dkim=pass header.d=manchego.org header.s=rsa header.b=IN4oMvqq +DKIM-Signature: v=1; a=rsa-sha256; s=default; d=example.com; c=relaxed/relaxed; r=y; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=m5lYqx81xqAIo4ZBC9FDiFIBrRnnep+taSsutc5MkbBQvf9/Lb54AXHhruEdO2EGkG + xUxL1c8QDH3eLz84fPTUgZue84tAsAa0q4gJFIYM5q2/GJvJ6cBvsXKZj82FjRTIz4wlLjzkW7p + NdR9C5CID2PO4sW+GymS45F8hwqSj4=; +DKIM-Signature: v=1; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/relaxed; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=t8z1AsaxeWek+gMSVojbs2QJu+orzeR4CiHVquJYvXzv+Eb52Wq0fEmaOxoyY1teVL + Odp57Vq/zTLjMMZ2hbBQ==; +From: bill@example.com +To: jdoe@example.com +Subject: TPS Report + +I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great. diff --git a/tests/resources/smtp/messages/invalid_dkim.eml b/tests/resources/smtp/messages/invalid_dkim.eml new file mode 100644 index 0000000..4e6034d --- /dev/null +++ b/tests/resources/smtp/messages/invalid_dkim.eml @@ -0,0 +1,14 @@ +DKIM-Signature: v=1; a=rsa-sha256; s=default; d=example.com; c=relaxed/relaxed; r=y; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=m5lYqx81xqAIo4ZBC9FDiFIBrRnnep+taSsutc5MkbBQvf9/Lb54AXHhruEdO2EGkG + xUxL1c8QDH3eLz84fPTUgZue84tAsAa0q4gJFIYM5q2/GJvJ6cBvsXKZj82FjRTIz4wlLjzkW7p + NdR9C5CID2PO4sW+GymS45F8hwqSj4=; +DKIM-Signature: v=1; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/relaxed; + h=Subject:To:From; t=1674122129; bh=Xcxymouf0VhlJ7c/vHLAM3LPTUR4LKFKX7PRNni + WCEs=; b=t8z1AsaxeWek+gMSVojbs2QJu+orzeR4CiHVquJYvXzv+Eb52Wq0fEmaOxoyY1teVL + Odp57Vq/zTLjMMZ2hbBQ==; +From: bill@example.com +To: jdoe@example.com +Subject: TPS Report + +Body hash will not match. diff --git a/tests/resources/smtp/messages/loop.eml b/tests/resources/smtp/messages/loop.eml new file mode 100644 index 0000000..fadbd66 --- /dev/null +++ b/tests/resources/smtp/messages/loop.eml @@ -0,0 +1,23 @@ +Received: from client1.football.example.com [192.0.2.1] + by submitserver.example.com with SUBMISSION; + Fri, 11 Jul 2003 21:01:54 -0700 (PDT) +Received: from client1.football.example.com [192.0.2.1] + by submitserver.example.com with SUBMISSION; + Fri, 11 Jul 2003 21:01:54 -0700 (PDT) +Received: from client1.football.example.com [192.0.2.1] + by submitserver.example.com with SUBMISSION; + Fri, 11 Jul 2003 21:01:54 -0700 (PDT) +Received: from client1.football.example.com [192.0.2.1] + by submitserver.example.com with SUBMISSION; + Fri, 11 Jul 2003 21:01:54 -0700 (PDT) +From: Joe SixPack +To: Suzie Q +Subject: Is dinner ready? +Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) +Message-ID: <20030712040037.46341.5F8J@football.example.com> + +Hi. + +We lost the game. Are you hungry yet? + +Joe. diff --git a/tests/resources/smtp/messages/multipart.eml b/tests/resources/smtp/messages/multipart.eml new file mode 100644 index 0000000..6e73529 --- /dev/null +++ b/tests/resources/smtp/messages/multipart.eml @@ -0,0 +1,34 @@ +From: Hendrik +To: Harrie +Date: Sat, 11 Oct 2010 00:31:44 +0200 +Subject: One Two Three Four +Content-Type: multipart/mixed; boundary=AA + +This is a multi-part message in MIME format. +--AA +Content-Type: multipart/mixed; boundary=BB + +This is a multi-part message in MIME format. +--BB +Content-Type: text/plain; charset="us-ascii" + +This is the first message part containing +plain text. + +--BB +Content-Type: text/plain; charset="us-ascii" + +This is another plain text message part. + +--BB-- +This is the end of MIME multipart. + +--AA +Content-Type: text/html; charset="us-ascii" + + +This is a piece of HTML text. + + +--AA-- +This is the end of MIME multipart. diff --git a/tests/resources/smtp/messages/no_dkim.eml b/tests/resources/smtp/messages/no_dkim.eml new file mode 100644 index 0000000..af0c7e3 --- /dev/null +++ b/tests/resources/smtp/messages/no_dkim.eml @@ -0,0 +1,14 @@ +Received: from client1.football.example.com [192.0.2.1] + by submitserver.example.com with SUBMISSION; + Fri, 11 Jul 2003 21:01:54 -0700 (PDT) +From: Joe SixPack +To: Suzie Q +Subject: Is dinner ready? +Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) +Message-ID: <20030712040037.46341.5F8J@football.example.com> + +Hi. + +We lost the game. Are you hungry yet? + +Joe. diff --git a/tests/resources/smtp/messages/no_msgid.eml b/tests/resources/smtp/messages/no_msgid.eml new file mode 100644 index 0000000..e1adc0b --- /dev/null +++ b/tests/resources/smtp/messages/no_msgid.eml @@ -0,0 +1,9 @@ +From: Joe SixPack +To: Suzie Q +Subject: Is dinner ready? + +Hi. + +We lost the game. Are you hungry yet? + +Joe. diff --git a/tests/resources/smtp/milter/message.eml b/tests/resources/smtp/milter/message.eml new file mode 100644 index 0000000..834aec8 --- /dev/null +++ b/tests/resources/smtp/milter/message.eml @@ -0,0 +1,11 @@ +From: John Doe +To: Mary Smith +References: a +References: b +X-Mailer: Test +X-1: 1 +X-2: 2 +X-3: 3 +Subject: Saying Hello + +This is a message just to say hello. diff --git a/tests/resources/smtp/milter/message.json b/tests/resources/smtp/milter/message.json new file mode 100644 index 0000000..03a7acc --- /dev/null +++ b/tests/resources/smtp/milter/message.json @@ -0,0 +1,149 @@ +[ + { + "modifications": [ + { + "AddHeader": { + "name": "X-Hello", + "value": "World" + } + }, + { + "AddHeader": { + "name": "X-CR", + "value": "LF\r\n" + } + } + ], + "result": "X-Hello: World\r\nX-CR: LF\r\nFrom: John Doe \r\nTo: Mary Smith \r\nReferences: a\r\nReferences: b\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: 3\r\nSubject: Saying Hello\r\n\r\nThis is a message just to say hello.\r\n" + }, + { + "modifications": [ + { + "ReplaceBody": { + "value": [ + 49, + 50, + 51 + ] + } + } + ], + "result": "From: John Doe \r\nTo: Mary Smith \r\nReferences: a\r\nReferences: b\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: 3\r\nSubject: Saying Hello\r\n\r\n123" + }, + { + "modifications": [ + { + "AddHeader": { + "name": "X-Spam", + "value": "Yes" + } + }, + { + "ReplaceBody": { + "value": [ + 49, + 50, + 51 + ] + } + }, + { + "ReplaceBody": { + "value": [ + 52, + 53, + 54 + ] + } + } + ], + "result": "X-Spam: Yes\r\nFrom: John Doe \r\nTo: Mary Smith \r\nReferences: a\r\nReferences: b\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: 3\r\nSubject: Saying Hello\r\n\r\n123456" + }, + { + "modifications": [ + { + "ChangeHeader": { + "index": 1, + "name": "References", + "value": "" + } + }, + { + "ChangeHeader": { + "index": 1, + "name": "References", + "value": "z" + } + }, + { + "ChangeHeader": { + "index": 1, + "name": "Subject", + "value": "[SPAM] Saying Hello" + } + } + ], + "result": "From: John Doe \r\nTo: Mary Smith \r\nReferences: z\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: 3\r\nSubject: [SPAM] Saying Hello\r\n\r\nThis is a message just to say hello.\r\n" + }, + { + "modifications": [ + { + "ChangeHeader": { + "index": 1, + "name": "X-Some-Header", + "value": "Some Value" + } + }, + { + "InsertHeader": { + "index": 2, + "name": "References", + "value": "" + } + }, + { + "InsertHeader": { + "index": 10, + "name": "X-3", + "value": "z" + } + }, + { + "ReplaceBody": { + "value": [ + 52, + 53, + 54 + ] + } + }, + { + "ReplaceBody": { + "value": [ + 49, + 50, + 51 + ] + } + } + ], + "result": "X-Some-Header: Some Value\r\nFrom: John Doe \r\nTo: Mary Smith \r\nReferences: a\r\nReferences: \r\nReferences: b\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: z\r\nX-3: 3\r\nSubject: Saying Hello\r\n\r\n456123" + }, + { + "modifications": [ + { + "Quarantine": { + "reason": "Virus found!" + } + }, + { + "InsertHeader": { + "index": 1, + "name": "References", + "value": "" + } + } + ], + "result": "X-Quarantine: Virus found!\r\nFrom: John Doe \r\nTo: Mary Smith \r\nReferences: \r\nReferences: a\r\nReferences: b\r\nX-Mailer: Test\r\nX-1: 1\r\nX-2: 2\r\nX-3: 3\r\nSubject: Saying Hello\r\n\r\nThis is a message just to say hello.\r\n" + } +] \ No newline at end of file diff --git a/tests/resources/smtp/reports/arf1.eml b/tests/resources/smtp/reports/arf1.eml new file mode 100644 index 0000000..a40fda2 --- /dev/null +++ b/tests/resources/smtp/reports/arf1.eml @@ -0,0 +1,44 @@ +From: +Date: Thu, 8 Mar 2005 17:40:36 EDT +Subject: FW: Earn money +To: +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=feedback-report; + boundary="part1_13d.2e68ed54_boundary" + +--part1_13d.2e68ed54_boundary +Content-Type: text/plain; charset="US-ASCII" +Content-Transfer-Encoding: 7bit + +This is an email abuse report for an email message received from IP +192.0.2.1 on Thu, 8 Mar 2005 14:00:00 EDT. For more information +about this format please see http://www.mipassoc.org/arf/. + +--part1_13d.2e68ed54_boundary +Content-Type: message/feedback-report + +Feedback-Type: abuse +User-Agent: SomeGenerator/1.0 +Version: 1 + +--part1_13d.2e68ed54_boundary +Content-Type: message/rfc822 +Content-Disposition: inline + +Received: from mailserver.example.net + (mailserver.example.net [192.0.2.1]) + by example.com with ESMTP id M63d4137594e46; + Thu, 08 Mar 2005 14:00:00 -0400 +From: +To: +Subject: Earn money +MIME-Version: 1.0 +Content-type: text/plain +Message-ID: 8787KJKJ3K4J3K4J3K4J3.mail@example.net +Date: Thu, 02 Sep 2004 12:31:03 -0500 + +Spam Spam Spam +Spam Spam Spam +Spam Spam Spam +Spam Spam Spam +--part1_13d.2e68ed54_boundary-- diff --git a/tests/resources/smtp/reports/arf2.eml b/tests/resources/smtp/reports/arf2.eml new file mode 100644 index 0000000..d95f2f2 --- /dev/null +++ b/tests/resources/smtp/reports/arf2.eml @@ -0,0 +1,55 @@ +From: +Date: Thu, 8 Mar 2005 17:40:36 EDT +Subject: FW: Earn money +To: +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=feedback-report; + boundary="part1_13d.2e68ed54_boundary" + +--part1_13d.2e68ed54_boundary +Content-Type: text/plain; charset="US-ASCII" +Content-Transfer-Encoding: 7bit + +This is an email abuse report for an email message received from IP +192.0.2.1 on Thu, 8 Mar 2005 14:00:00 EDT. For more information +about this format please see http://www.mipassoc.org/arf/. + +--part1_13d.2e68ed54_boundary +Content-Type: message/feedback-report + +Feedback-Type: abuse +User-Agent: SomeGenerator/1.0 +Version: 1 +Original-Mail-From: +Original-Rcpt-To: +Arrival-Date: Thu, 8 Mar 2005 14:00:00 EDT +Reporting-MTA: dns; mail.example.com +Source-IP: 192.0.2.1 +Authentication-Results: mail.example.com; + spf=fail smtp.mail=somespammer@example.com +Reported-Domain: example.net +Reported-Uri: http://example.net/earn_money.html +Reported-Uri: mailto:user@example.com +Removal-Recipient: user@example.com + +--part1_13d.2e68ed54_boundary +Content-Type: message/rfc822 +Content-Disposition: inline + +From: +Received: from mailserver.example.net (mailserver.example.net + [192.0.2.1]) by example.com with ESMTP id M63d4137594e46; + Thu, 08 Mar 2005 14:00:00 -0400 + +To: +Subject: Earn money +MIME-Version: 1.0 +Content-type: text/plain +Message-ID: 8787KJKJ3K4J3K4J3K4J3.mail@example.net +Date: Thu, 02 Sep 2004 12:31:03 -0500 + +Spam Spam Spam +Spam Spam Spam +Spam Spam Spam +Spam Spam Spam +--part1_13d.2e68ed54_boundary-- diff --git a/tests/resources/smtp/reports/arf3.eml b/tests/resources/smtp/reports/arf3.eml new file mode 100644 index 0000000..d0f8cbf --- /dev/null +++ b/tests/resources/smtp/reports/arf3.eml @@ -0,0 +1,58 @@ +From: arf-daemon@example.com +To: recipient@example.net +Subject: This is a test +Date: Wed, 14 Apr 2010 12:17:45 -0700 (PDT) +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=feedback-report; + boundary="part1_13d.2e68ed54_boundary" + +--part1_13d.2e68ed54_boundary +Content-Type: text/plain; charset="US-ASCII" +Content-Transfer-Encoding: 7bit + +This is an email abuse report for an email message received +from IP 192.0.2.1 on Wed, 14 Apr 2010 12:15:31 PDT. For more +information about this format please see +http://www.mipassoc.org/arf/. + +--part1_13d.2e68ed54_boundary +Content-Type: message/feedback-report + +Feedback-Type: auth-failure +User-Agent: SomeDKIMFilter/1.0 +Version: 1 +Original-Mail-From: +Original-Rcpt-To: +Received-Date: Wed, 14 Apr 2010 12:15:31 -0700 (PDT) +Source-IP: 192.0.2.1 +Authentication-Results: mail.example.com; dkim=fail + header.d=example.net +Reported-Domain: example.net +DKIM-Domain: example.net +Auth-Failure: bodyhash + +--part1_13d.2e68ed54_boundary +Content-Type: message/rfc822 + +DKIM-Signature: v=1; c=relaxed/simple; a=rsa-sha256; + s=testkey; d=example.net; h=From:To:Subject:Date; + bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; + b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB + 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut + KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV + 4bmp/YzhwvcubU4= +Received: from smtp-out.example.net by mail.example.com + with SMTP id o3F52gxO029144; + Wed, 14 Apr 2010 12:15:31 -0700 (PDT) +Received: from internal-client-001.example.com + by mail.example.com + with SMTP id o3F3BwdY028431; + Wed, 14 Apr 2010 12:12:09 -0700 (PDT) +From: randomuser@example.net +To: user@example.com +Date: Wed, 14 Apr 2010 12:12:09 -0700 (PDT) +Subject: This is a test + +Hi, just making sure DKIM is working! + +--part1_13d.2e68ed54_boundary-- diff --git a/tests/resources/smtp/reports/arf4.eml b/tests/resources/smtp/reports/arf4.eml new file mode 100644 index 0000000..cb1883a --- /dev/null +++ b/tests/resources/smtp/reports/arf4.eml @@ -0,0 +1,73 @@ +Return-Path: +Received: by box.mydomain.name (Postfix, from userid 116) + id CF8FA658E0; Tue, 5 Oct 2021 17:37:02 +1300 (NZDT) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=box.mydomain.name; + s=mail; t=1633408622; + bh=yDlkGfe4dFwlsFeoKaIHG6xiRgQs2/PqPnLtiPm5ewk=; + h=From:To:Date:Subject:From; + b=TwXvJJFoFwJDcb6IKMKsxp2BiRDsrjLOESyQPh/Cc4tRZltVAud/k6f0XP4l5a/T8 + kh0iDOGImc0O1WZNFt0MUcwLsfW4qbYjCBtthQDnbPApvv6MJDASwau+wipu5Nrkjc + flg+nMaD97pVgR0LevMoVIWoiy1f5PNC/z0xkY2wnvyoGn91WuDsdocOqyoPo4RmIT + A/f3M4CjOv/QmMEAWBIsa7kAZwf+rNmzDahFOtp2vFLqHt0iZi5vs40fa6O/I0snTM + fRkv2GMZAug7NMU8MN/MhuO87FV6ATZXvB0Kxvsy9z0zZYK7tM1OYHjiCYot45erG3 + dlKrYiXsfd3BQ== +From: OpenDMARC Filter +To: postmaster@vericty.interpublication.org +Date: Tue, 5 Oct 2021 17:37:02 +1300 (NZDT) +Subject: FW: Wir kaufen dein Auto! +MIME-Version: 1.0 +Content-Type: multipart/report; + report-type=feedback-report; + boundary="box.mydomain.name:8BE2660E72" +Message-Id: <20211005043702.CF8FA658E0@box.mydomain.name> + +--box.mydomain.name:8BE2660E72 +Content-Type: text/plain + +This is an authentication failure report for an email message received from IP +148.163.85.135 on Tue, 5 Oct 2021 17:37:02 +1300 (NZDT). + +--box.mydomain.name:8BE2660E72 +Content-Type: message/feedback-report + +Feedback-Type: auth-failure +Version: 1 +User-Agent: OpenDMARC-Filter/1.3.2 +Auth-Failure: dmarc +Authentication-Results: box.mydomain.name; dmarc=fail header.from=interpublication.org +Original-Envelope-Id: 8BE2660E72 +Original-Mail-From: info@interpublication.org +Source-IP: 148.163.85.135 (sainay.interpublication.org) +Reported-Domain: interpublication.org + +--box.mydomain.name:8BE2660E72 +Content-Type: text/rfc822-headers + +Authentication-Results: box.mydomain.name; + dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header.d=interpublication.org header.i=@interpublication.org header.b="PrsTNnuH"; + dkim-atps=neutral +Received: from dslb-002-202-150-127.002.202.pools.vodafone-ip.de (dslb-188-099-080-029.188.099.pools.vodafone-ip.de [188.99.80.29]) + by sainay.interpublication.org (Postfix) with ESMTPA id 6BB23A2D3 + for ; Tue, 5 Oct 2021 00:36:52 -0400 (EDT) +DKIM-Filter: OpenDKIM Filter v2.11.0 sainay.interpublication.org 6BB23A2D3 +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=interpublication.org; s=default; t=1633408612; + bh=q1/OPSn+VXteY2+DHXqOIgs5LsNCJisEcQIKVW9it6I=; + h=From:Subject:To:Reply-To:Date:From; + b=PrsTNnuH8D0Ch3gcWqGmXiYc2Kvu1CHGJBsqS521uYazd3G/urp7MHQvmNwK0r1gS + DR3A3KwGejI5uuqzxDCqz28Mq6AkdTkOjFyXw65MLlsKTQddWTgciVnoqJempa6yzw + PSM5550XqVFqqkNxEcYBUBYEwUdy1tY8rc4zhq8cIrsonQVxJJSbc3cdonICM1kLBV + WASv16p3376ZBcKqFLc8UQ58YQKaFm51VZGEjtabfmWbgOQ7VikFFECDG3aRt8fZa6 + D03MrzUSngwPUdcRQZuqS/sApW/a9N2YwdbR51OFzPBr4ypUEIw/qprgBG4BfQQKeS + 1PhinNvVtgQpQ== +From: "Rolf Bader" +Subject: Wir kaufen dein Auto! +To: "address" +Content-Type: multipart/alternative; boundary="TD6gM3Blv=_XBZYNFT7dCsH1DHHOKUuSyA" +MIME-Version: 1.0 +Reply-To: "Rolf Bader" +Organization: AutoTEAM24 +Date: Tue, 5 Oct 2021 06:36:51 +0200 + +--box.mydomain.name:8BE2660E72-- + diff --git a/tests/resources/smtp/reports/arf5.eml b/tests/resources/smtp/reports/arf5.eml new file mode 100644 index 0000000..50dfd02 --- /dev/null +++ b/tests/resources/smtp/reports/arf5.eml @@ -0,0 +1,87 @@ +Message-ID: <433689.81121.example@mta.mail.receiver.example> +From: "SomeISP Antispam Feedback" +To: arf-failure@sender.example +Subject: FW: You have a new bill from your bank +Date: Sat, 8 Oct 2011 15:15:59 -0500 (CDT) +MIME-Version: 1.0 +Content-Type: multipart/report; + boundary="------------Boundary-00=_3BCR4Y7kX93yP9uUPRhg"; + report-type=feedback-report +Content-Transfer-Encoding: 7bit + +--------------Boundary-00=_3BCR4Y7kX93yP9uUPRhg +Content-Type: text/plain; charset="us-ascii" +Content-Disposition: inline +Content-Transfer-Encoding: 7bit + +This is an authentication failure report for an email message +received from a.sender.example on 8 Oct 2011 20:15:58 +0000 (GMT). +For more information about this format, please see [RFC6591]. + +--------------Boundary-00=_3BCR4Y7kX93yP9uUPRhg +Content-Type: message/feedback-report +Content-Transfer-Encoding: 7bit + +Feedback-Type: auth-failure +User-Agent: Someisp!Mail-Feedback/1.0 +Version: 1 +Original-Mail-From: anexample.reply@a.sender.example +Original-Envelope-Id: o3F52gxO029144 +Authentication-Results: mta1011.mail.tp2.receiver.example; + dkim=fail (bodyhash) header.d=sender.example +Auth-Failure: bodyhash +DKIM-Canonicalized-Body: VGhpcyBpcyBhIG1lc3NhZ2UgYm9keSB0 + aGF0IGdvdCBtb2RpZmllZCBpbiB0cmFuc2l0LgoKQXQgdGhlIHNhbWU + gdGltZSB0aGF0IHRoZSBib2R5aGFzaCBmYWlscyB0byB2ZXJpZnksIH + RoZQptZXNzYWdlIGNvbnRlbnQgaXMgY2xlYXJseSBhYnVzaXZlIG9yI + HBoaXNoeSwgYXMgdGhlClN1YmplY3QgYWxyZWFkeSBoaW50cy4gIElu + ZGVlZCwgdGhpcyBib2R5IGFsc28gY29udGFpbnMKdGhlIGZvbGxvd2l + uZyB0ZXh0OgoKICAgUGxlYXNlIGVudGVyIHlvdXIgZnVsbCBiYW5rIG + NyZWRlbnRpYWxzIGF0CiAgIGh0dHA6Ly93d3cuc2VuZGVyLmV4YW1wb + GUvCgpXZSBhcmUgaW1wbHlpbmcgdGhhdCwgYWx0aG91Z2ggbXVsdGlw + bGUgZmFpbHVyZXMKcmVxdWlyZSBtdWx0aXBsZSByZXBvcnRzLCBhIHN + pbmdsZSBmYWlsdXJlIGNhbiBiZQpyZXBvcnRlZCBhbG9uZyB3aXRoIH + BoaXNoaW5nIGluIGEgc2luZ2xlIHJlcG9ydC4K +DKIM-Domain: sender.example +DKIM-Identity: @sender.example +DKIM-Selector: testkey +Arrival-Date: 8 Oct 2011 20:15:58 +0000 (GMT) +Source-IP: 192.0.2.1 +Reported-Domain: a.sender.example +Reported-URI: http://www.sender.example/ + +--------------Boundary-00=_3BCR4Y7kX93yP9uUPRhg +Content-Type: text/rfc822-headers +Content-Transfer-Encoding: 7bit + +Authentication-Results: mta1011.mail.tp2.receiver.example; + dkim=fail (bodyhash) header.d=sender.example; + spf=pass smtp.mailfrom=anexample.reply@a.sender.example +Received: from smtp-out.sender.example + by mta1011.mail.tp2.receiver.example + with SMTP id oB85W8xV000169; + Sat, 08 Oct 2011 13:15:58 -0700 (PDT) +DKIM-Signature: v=1; c=relaxed/simple; a=rsa-sha256; + s=testkey; d=sender.example; h=From:To:Subject:Date; + bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; + b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB + 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut + KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV + 4bmp/YzhwvcubU4= +Received: from mail.sender.example + by smtp-out.sender.example + with SMTP id o3F52gxO029144; + Sat, 08 Oct 2011 13:15:31 -0700 (PDT) + Received: from internal-client-001.sender.example + by mail.sender.example + with SMTP id o3F3BwdY028431; + Sat, 08 Oct 2011 13:15:24 -0700 (PDT) +Date: Sat, 8 Oct 2011 16:15:24 -0400 (EDT) +Reply-To: anexample.reply@a.sender.example +From: anexample@a.sender.example +To: someuser@receiver.example +Subject: You have a new bill from your bank +Message-ID: <87913910.1318094604546@out.sender.example> + +--------------Boundary-00=_3BCR4Y7kX93yP9uUPRhg-- + diff --git a/tests/resources/smtp/reports/dmarc1.eml b/tests/resources/smtp/reports/dmarc1.eml new file mode 100644 index 0000000..9a2ed1d --- /dev/null +++ b/tests/resources/smtp/reports/dmarc1.eml @@ -0,0 +1,66 @@ +Received: from mail.stalw.art ([mail.stalw.art]) + by 127.0.0.1 (Stalwart JMAP) with LMTP; + Mon, 28 Nov 2022 10:51:56 +0000 +Received: from mail-qv1-xf4a.google.com (mail-qv1-xf4a.google.com [IPv6:2607:f8b0:4864:20::f4a]) + (using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits) + key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) + (No client certificate requested) + by mail.stalw.art (Postfix) with ESMTPS id 1145E7CC0B + for ; Mon, 28 Nov 2022 10:51:53 +0000 (UTC) +Received: by mail-qv1-xf4a.google.com with SMTP id 71-20020a0c804d000000b004b2fb260447so12985969qva.10 + for ; Mon, 28 Nov 2022 02:51:52 -0800 (PST) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=google.com; s=20210112; + h=content-transfer-encoding:content-disposition:to:from:subject + :message-id:date:mime-version:from:to:cc:subject:date:message-id + :reply-to; + bh=sMF/38UFRhmUYFRJST4vLBu/U1BXgsdCUE02HF8nXx8=; + b=I7WONP7tMsULp4eKjJeeKtM+nDYqMSIYMxqNHqCP1bTsnUiW2xM278I2+F8EjtFNYf + XOgusNn8kqbSnA4w1+q4G87zTF4K3tGnxNpuUMQ7GzcofBKtr7VPv9XFqvTPJ+N8YSwe + 926ec7xi71BpSHAgqp5Wqocj8ruIVjcCZ37hYrG0C4s+FVBtbaU3EeyPpkESaaY2vE5y + Qa2KsrMsyJXlbyW/sFJ7AGDDuXwyGkTa+btP/xIiQM2HlBKy7vNOFZKkxInOuQsXJgZy + 3H7ivlpD3hMrszwU77o5jBArVwN0RIkUSosAPQf+pzgvRlkseRlDrmzKQutvYWIaTP3/ + FHPA== +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=1e100.net; s=20210112; + h=content-transfer-encoding:content-disposition:to:from:subject + :message-id:date:mime-version:x-gm-message-state:from:to:cc:subject + :date:message-id:reply-to; + bh=sMF/38UFRhmUYFRJST4vLBu/U1BXgsdCUE02HF8nXx8=; + b=D3oClvT5AKcTpEjjffHQqPPQ9j5mmtExiviSq7iBYkoq+322LtR2hGqGxtvlAwRDsQ + VIfuKVExygw3c9bckjzKtJYX128HGK35gHnmsrzqvCC93JlRaC/55kcM9Bhks0xJnl7i + yNFHPZ0DY/jasdUdQ1QqnI+8qiPy+/12JvD+/TGlaDuS+RWYFU4/ky46S3vMXwXmRt6D + IGggXoW7snSaM4s88DzMUl0U7DH823UPQrUxnA5Oxscwn9M1ENJUWD/3EJo5ZEUMw0ll + Y8AlyhjWFgVqs1Y4V/LVWeXdF10fpm78+jm8QyIZYZJjh4I33AekdsWVM71ZNNYGL0+8 + +GDg== +X-Gm-Message-State: ANoB5pn0zRZSWXdFXd9G0tawbSeUYuhxToVkIYoLf8OJzoBLcIc0wKcB + AfU9Coz5vAuiM1mASWJhbg== +X-Google-Smtp-Source: AA0mqf6WWsnqMD4cHE40jB89/zblmT7yNKeHKlsvvCmlYANKmpKLTQTaCm5qCA0mVmxR/PTQogsNndWH/qe0ug== +MIME-Version: 1.0 +X-Received: by 2002:ac8:5182:0:b0:39c:cb6a:300b with SMTP id + c2-20020ac85182000000b0039ccb6a300bmr48409299qtn.181.1669632711968; Mon, 28 + Nov 2022 02:51:51 -0800 (PST) +Date: Sun, 27 Nov 2022 15:59:59 -0800 +Message-ID: <5264580628977113351@google.com> +Subject: Report domain: stalw.art Submitter: google.com Report-ID: 5264580628977113351 +From: noreply-dmarc-support@google.com +To: domains@stalw.art +Content-Type: application/zip; + name="google.com!stalw.art!1669507200!1669593599.zip" +Content-Disposition: attachment; + filename="google.com!stalw.art!1669507200!1669593599.zip" +Content-Transfer-Encoding: base64 + +UEsDBAoAAAAIAHFUfFWAeOSU8QEAAKkEAAAuAAAAZ29vZ2xlLmNvbSFzdGFsdy5hcnQhMTY2OTUw +NzIwMCExNjY5NTkzNTk5LnhtbKVUwZKjIBC9z1ekck9Qk5hoMcye9gt2zxbB1lBBoACTmb9fHNCw +ma257El83f2632sUv70PYnUDY7mSr+t8m61XIJlquexf179//dyc1qs38oI7gPZM2ZW8rFbYgFbG +NQM42lJHJ8yjyvSNpAOQXqlewJapAaMFDDkwUC6IVJ5BfGzagRq2saOe6H6kZSEv1rw7QxumpKPM +NVx2ilyc07ZGKJZuH6WIIirtHQwq9mV5OGWe62t9II4yeEsORbn3uWVxqo7HPN/tDjlGj3BI91Kh +MVT2UYyHztBzSfKyrA7Zsch8s4DMcZBtiFa7Q1X5UeRMhv5mW7qlnmKtBGcfjR7PgtsLLIMo744k +1lFx31LjPFlAQpi2Vz4Qg1E4RNDq7hObngHSfg8SMNLx3c6AnRHNHMknVdPhc8p/TeR9ZMrMwxl1 +X+RbNRoGDdekoFle77uqZlme1+f9jtW1t/iRMJcwNUrfFKNwmOHYF25UjN64dg5MbnCrleXOX+A4 +f4okeZMZmlrrExZfovAuBhZzEq1PPf2mZoWYtyAd77j/fJayC9AWTNMZNaQbSuHI86Ua09FdGgN2 +FO5B+DTs98uP93piiJLiS6IWBDCnDLmB4FdujaayKLz2GV8MSDvjxJr/niIx2t/IJ9FTcrhPGD3+ +On8AUEsBAgoACgAAAAgAcVR8VYB45JTxAQAAqQQAAC4AAAAAAAAAAAAAAAAAAAAAAGdvb2dsZS5j +b20hc3RhbHcuYXJ0ITE2Njk1MDcyMDAhMTY2OTU5MzU5OS54bWxQSwUGAAAAAAEAAQBcAAAAPQIA +AAAA \ No newline at end of file diff --git a/tests/resources/smtp/reports/dmarc2.eml b/tests/resources/smtp/reports/dmarc2.eml new file mode 100644 index 0000000..3a66174 --- /dev/null +++ b/tests/resources/smtp/reports/dmarc2.eml @@ -0,0 +1,68 @@ +Received: from mail.stalw.art ([mail.stalw.art]) + by 127.0.0.1 (Stalwart JMAP) with LMTP; + Thu, 10 Nov 2022 03:27:19 +0000 +Received: from mx0.backschues.net (lnxs001.backschues.net [85.183.142.13]) + (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) + key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) + (No client certificate requested) + by mail.stalw.art (Postfix) with ESMTPS id 6DD117CC0B + for ; Thu, 10 Nov 2022 03:27:16 +0000 (UTC) +Received: from mx0.backschues.net (localhost [127.0.0.1]) + by mx0.backschues.net with SMTP id 4N76hg4lNgz9ryP + for ; Thu, 10 Nov 2022 04:27:15 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=backschues.net; + s=mail-2014-01; t=1668050835; + h=from:from:reply-to:subject:subject:date:date:message-id:message-id: + to:to:cc:mime-version:mime-version:content-type:content-type; + bh=LTj1tdFz9JQFL/mVJASN0b9hGcolcCtY5v0bhnChJYY=; + b=AtRegYc51PTYqDOy/6fB4xETTWAbVc2ivf8AfF4ygu3+6+oqBPyloTuOnEt7xYmjLFnll/ + SMZFFpRETsMlkiVg/1O0VpPRpIpiTbh4dwtUrRyo1Uw/cDJv5auz4rBMxcRNnDKypHwUKs + BUahHWsVKH/TL5SzV79kqyjlYAs1HdJvS+wRINYBaptkeT6UeHGZakL21NnQUdOGt0fj4y + eJvWVtCYHZ5DUJ8K8h2W1NlTAWP8nTBoQVVDQrI5Zi1AEvnUWw+H7E8d/q2cF756/IBYso + rT56D3PYo2iuSt3aIBth1wL7/GJwc6N4JHcNpJ9XPV6xQbt+lm2b3+W59osL0Q== +DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=backschues.net; + s=ed25519-mail-2018-10; t=1668050835; + h=from:from:reply-to:subject:subject:date:date:message-id:message-id: + to:to:cc:mime-version:mime-version:content-type:content-type; + bh=LTj1tdFz9JQFL/mVJASN0b9hGcolcCtY5v0bhnChJYY=; + b=y7d79OWWCrDX40k91FoBdGnUcrjN7xvWYyqskPfQmMoaSqFNSlTHH8gMXC/vXwiYIP3Oxp + d/hVvEuIIQBlwMDQ== +From: "DMARC Aggregate Report" +To: domains@stalw.art +Subject: Report Domain: stalw.art + Submitter: backschues.net + Report-ID: stalw.art.1667948400.1668034800 +Date: Thu, 10 Nov 2022 03:27:02 GMT +MIME-Version: 1.0 +Message-ID: +Content-Type: multipart/mixed; + boundary="----=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303" + +This is a multipart message in MIME format. + +------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: 7bit + +This is an aggregate report from backschues.net. + +Report domain: stalw.art +Submitter: backschues.net +Report ID: stalw.art.1667948400.1668034800 + +------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303 +Content-Type: application/gzip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="backschues.net!stalw.art!1667948400!1668034800.xml.gz" + +H4sIAAAAAAAAA5VUsXLbMAzd/RU6D9ksSo6b2heG6dKOndJZR5OQzYtEsiSVNH9fUqQoqXWHT +gIfgAfgASf8/KvvijcwVij5tK3LaluAZIoLeXna/nj5tjtui2eywS0AP1P2SjZFgQ1oZVzTg6 +OcOhowjypzaSTtgdz9HJR7DNGWXQew5fevLxhld4yGnoqOSOW5uo8d76lhOzvoQPxlkSrBYRR +jY16qLTixjnbvJTWurB8ePp8Ox0NVBfNY3R+OVYXRHBpTfa/QGCovqQcPneEiJJnzMYrI5AfJ +yZIyvCMZWrPlaktRsFadYB+NHs6dsFfIjSg/kJwH8GQRiW7KX0VPDEbRSKDV7YiFb4S0l08CR +jq97QTYCdHMkTr0HYyxy7878ooyZXhcrHqfuNRgGDRCk09Vud/fl/X+VNangyfPnhjJ1CB9FY +yikQrHMvBGu8HrxLOgXFitrHD+3FKzSyRHhblbv3TvzhKME7YJnlVAt2r5dcRRsOAgnWiFP/G +UcAXKwTStUf1yBUt4ZPgjE9PBXRsDdujcRLVqLu1QgGtLf+zrpY6XG1KJptaGaxkf0y0Fnpts +/7iRmS7KcYNukxX7zwbjWtaMicaf30qEEBaPB6P8h/gNNHLX4VQEAAA= +------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303-- diff --git a/tests/resources/smtp/reports/dmarc3.eml b/tests/resources/smtp/reports/dmarc3.eml new file mode 100644 index 0000000..7acd93e --- /dev/null +++ b/tests/resources/smtp/reports/dmarc3.eml @@ -0,0 +1,52 @@ +Received: from mail.stalw.art ([mail.stalw.art]) + by 127.0.0.1 (Stalwart JMAP) with LMTP; + Tue, 08 Nov 2022 23:26:41 +0000 +Received: from relay7.m.smailru.net (relay7.m.smailru.net [94.100.178.51]) + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) + (No client certificate requested) + by mail.stalw.art (Postfix) with ESMTPS id DD4337CC09 + for ; Tue, 8 Nov 2022 23:26:38 +0000 (UTC) +DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=corp.mail.ru; s=mail4; + h=Date:Message-ID:To:From:Subject:MIME-Version:Content-Type:From:Subject:Content-Type:Content-Transfer-Encoding:To:Cc; bh=fooa0+RBCZvyV2mP8Nx/UsLQ5RhazFg+SPGNtxZrCX0=; + t=1667950001;x=1668040001; + b=J9aMEkY9eVdOxjkNxaPFJ2Yk+/NCux9uOZl3iJXI0hEFaeYj9g7l+WtmXczk+YvgH3yhVhtvONUEYFsValRWWCAfmePm429N3mSuclVktk7t6RPJ4O5EcMjwrD9882vmX1xpI7ecPOzd5AD67HPt5SIA1RIa5injaOI5CWUXBBa5c0zDfmciyANAiDw0gm1axEMK4AUc61txPsX7H1qRq/FxGNITnnpYdqkkT2lR8sTl5HPwTjEsw4sYGKr5SiMpROhhbLZTM8RpojkP73bmw3UBZ9FI8iKApJUFB8i9tu0hjzHkev4uoDXgOXFYs/RAI1JkCWEp2Rjb3LpTSHT6cA==; +Received: from [10.161.4.115] (port=60844 helo=60) + by relay7.m.smailru.net with esmtp (envelope-from ) + id 1osXzK-0007VC-BD + for domains@stalw.art; Wed, 09 Nov 2022 02:26:38 +0300 +Content-Type: multipart/mixed; boundary="===============5640625649776607409==" +MIME-Version: 1.0 +Subject: Report Domain: stalw.art; Submitter: Mail.Ru; + Report-ID: 28551467700969547611667865600 +From: dmarc_support@corp.mail.ru +To: domains@stalw.art +Message-ID: +Date: Wed, 09 Nov 2022 02:26:38 +0300 +Auto-Submitted: auto-generated +Authentication-Results: relay7.m.smailru.net; auth=pass smtp.auth=dmarc_support@corp.mail.ru smtp.mailfrom=dmarc_support@corp.mail.ru; iprev=pass policy.iprev=10.161.4.115 + +--===============5640625649776607409== +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhbiBhZ2dyZWdhdGUgcmVwb3J0IGZyb20gTWFpbC5SdS4= + +--===============5640625649776607409== +Content-Type: application/gzip +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="mail.ru!stalw.art!1667865600!1667952000.xml.gz" + +H4sICK7lamMC/21haWwucnUhc3RhbHcuYXJ0ITE2Njc4NjU2MDAhMTY2Nzk1MjAwMC54bWwAdVNB +cqMwELzvK3LzKQhYg01qouwHctkPULIYjMogqSThJL/fEQSC18kFzbRmWt0jAS/vQ/9wReeV0c+7 +LEl3D6ilaZQ+P+/G0D4edy/8F7SIzUnICweH1rhQDxhEI4LgYNy51mJA/ipUn/wdga0I4EAYbwbh +ZO1HGzv/SONsEvHEUe1cAfgenKil0UHIUCvdGt6FYJ8Y67Bfy1lcHyNCjfcdizbV8PxYFNm+PBzS +tCqrYn8os6wsD8eyKNMU2FchkAmsndBnknvCs9J8WzgjgLqZ4KrI0wjHHNi2ld3NxZpeyY/ajqde ++Q7jUYb0a+6D6N8S4QIxzAiI5qIG7oDNAQhv2ymNK1iujUZgloNfYgrAysCzKCcG9L070CENO67m +jVrN6CTWyvIiTfL8d5LlVZJVe+Jad0CaURMpsDlYTOBV9CO5jSaUt8arQO/lU8oWgUl/S9dE+GQl +OpjzyQu7Z2STPNWgDqpV9BQ5dCgadHXrzLAd1xYGdtMhxtDVDv3YB/+pYpm3wtAm9Ca/xu2xRxmM +m7bI7JrDzMCt8D5e6ZQsTm5Iv7nEleWKvboo76zQef4N+zyO/9in6fysWBqLfIjOiXBKftA6T/l2 +HGx5CGz9j/8BQWPZIPkDAAA= +--===============5640625649776607409==-- \ No newline at end of file diff --git a/tests/resources/smtp/reports/dmarc4.eml b/tests/resources/smtp/reports/dmarc4.eml new file mode 100644 index 0000000..2fe512f --- /dev/null +++ b/tests/resources/smtp/reports/dmarc4.eml @@ -0,0 +1,126 @@ +Received: from mail.stalw.art ([mail.stalw.art]) by 127.0.0.1 (Stalwart JMAP) with LMTP; Tue, 25 Oct 2022 04:08:22 +0000 +Received: from NAM12-MW2-obe.outbound.protection.outlook.com (mail-mw2nam12on2073.outbound.protection.outlook.com [40.107.244.73]) + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) + (No client certificate requested) + by mail.stalw.art (Postfix) with ESMTPS id A24107CC0A + for ; Tue, 25 Oct 2022 04:08:22 +0000 (UTC) +ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none; + b=SvolQ1oIEgdfCI6dbwmJ1jS0ovWmprW6kT3q9NgrbX+CMhIsdrqyS3Q1sO16KT2wCQAyNofiEZ5tKY0e1PzzMqeR29jUWvEye9T43fCfUeLFx9b45YrfkGYwqLeDIq0Ywl+ggVmsm7X83XqI6+9EC6qMukCb0cbLazu3rW/Rbyc6d5+fq6QTFZovATGRvHz71H9t7e//hYI23XjU5Q3Enw0Qq3xPSyusWDi3t7CfGXn9i2120XlNLnPxef5PCmwy4E+OTJ5qC5WtMthOskKKuFvx8onOYmc/JjJ3VrtZwALx9C+ulzix5US6H7pFvZ2jtDbMnW4U7ir/hp5xn5adFw== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; + s=arcselector9901; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; + bh=65M39Uvc5w8zbmK6TxEdoblSMXlIyqTXJHNalJ80wk4=; + b=PN7QPeXJLr6tmH2CxydbDQjHqBtFKNN9HjGimeUHaIeSr82WHf4R295QbVX7gxw6sFE7Z9lZMTrMSqbRVI7rhbx+SEkxCfAothf9207FDX6t37Zt0wd/5EwR6dzfbcNJBL+U0/iG4J03L5b1geWY+e68mHKYH4/ybGcr+SBKuv/LgfZNtOfbQ3ioiKvFcpSDqd/qGUs4U9l2tVlXgbcKkct04sCuPciqgLEuIGirPLLbDUaBRJc51ZZB6CeporySRdHp6uFXyy3VBvvLVuwDNnnPrW4BUL05AuutzK7rc8ZQEpWf7r0gUEg2ArSrvs6Znnfe97oRa01L2SeFwuZsMA== +ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none + action=none header.from=microsoft.com; dkim=none (message not signed); + arc=none +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=notification.microsoft.com; s=selector1; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; + bh=65M39Uvc5w8zbmK6TxEdoblSMXlIyqTXJHNalJ80wk4=; + b=NjqsA7D6sEq1WgCZ/E1f5/B+XUXe5F4uv6CF2KQYVuyRnxItdox09LCWqZQ+fNQ6BbJ4Ne05Cb1BPbPP9yvb8Y6B1s2QvuxkUb69UFbAhoFgsRT6A4K76ykKQQyiPoYpxlO6FEyy+gel4y7c9XRLiWW6OxMIBcjBGB5ziP7mGFaJx4qXJ2mROfO7uZfrCu5pzOimkjPw6extWv4i0Kl3XKvBtXZnsr9eoC10mJvEAp7E2cpnaZnP46RQc9cmXzlmvhKPvCQCUWipJN9f1BTTvFjJ9ff6ehmN9RSzCckj3SZGw9XAnd0WYqh4evt6Y1RxQ4iQDSaZHNRpyMOtmkWc/w== +Authentication-Results: dkim=none (message not signed) + header.d=none;dmarc=none action=none header.from=microsoft.com; +Received: from BN9PR03CA0046.namprd03.prod.outlook.com (2603:10b6:408:fb::21) + by SJ0PR18MB3916.namprd18.prod.outlook.com (2603:10b6:a03:2c9::21) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.21; Tue, 25 Oct + 2022 04:08:19 +0000 +Received: from BN7NAM10FT048.eop-nam10.prod.protection.outlook.com + (2603:10b6:408:fb:cafe::d7) by BN9PR03CA0046.outlook.office365.com + (2603:10b6:408:fb::21) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.27 via Frontend + Transport; Tue, 25 Oct 2022 04:08:19 +0000 +Received: from nam10.map.protection.outlook.com (2a01:111:f400:fe53::30) by + BN7NAM10FT048.mail.protection.outlook.com (2a01:111:e400:7e8f::199) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.16 via Frontend + Transport; Tue, 25 Oct 2022 04:08:19 +0000 +Message-ID: <725cbfbe133940149987cfc528387235@microsoft.com> +X-Sender: XATTRDIRECT=Originating XATTRORGID=xorgid:96f9e21d-a1c4-44a3-99e4-37191ac61848 +MIME-Version: 1.0 +From: "DMARC Aggregate Report" +To: +Subject: =?utf-8?B?UmVwb3J0IERvbWFpbjogc3RhbHcuYXJ0IFN1Ym1pdHRlcjogcHJvdGVjdGlvbi5vdXRsb29rLmNvbSBSZXBvcnQtSUQ6IDcyNWNiZmJlMTMzOTQwMTQ5OTg3Y2ZjNTI4Mzg3MjM1?= +Content-Type: multipart/mixed; + boundary="_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_" +Date: Tue, 25 Oct 2022 04:08:19 +0000 +X-EOPAttributedMessage: 0 +X-MS-PublicTrafficType: Email +X-MS-TrafficTypeDiagnostic: BN7NAM10FT048:EE_|SJ0PR18MB3916:EE_ +X-MS-Office365-Filtering-Correlation-Id: 7f843e40-ccc6-4c17-1ce7-08dab63e8cd1 +X-MS-Exchange-SenderADCheck: 2 +X-MS-Exchange-AntiSpam-Relay: 0 +X-Microsoft-Antispam: BCL:0; +X-Microsoft-Antispam-Message-Info: + PSiI3L4DKj/cyRBl8/bmbyQMrr1DvsYEB1+aTn/3Y39oHnyJ5HXcxu6jNUl32WcPW6Gfqmhc6P1RFE5L/9ev0cWnqh4GgIs2qmHicLexPmMjP8viPdjb1N7TSSOv1hhXMT+gVLx889X5sltd4qpfIAWhoxNonjQpVIgt4VOVnbCWTu1hyOjOVplq0rKqIF04BQGHZnBRfkcD1No+mZrvx8RLWIwInU3fpPeGz77Wn3TIvHtzypR/d22WpZ8eHk3aIxxdjwp5WLg4unpiJaieyQN7BRhD/v6b3pLFVJP8Ii2+FGjTsKASczEL4dHnIoIrHYE0wwaFFPcSNzovLhzYguDV42EGS8Fm7soiew4ch+hICM0LPNTGTZIDe7wm2eSwhN2tkJK4QCfh1DON39jXninVr88ZlzMcDXnXpgvWHHiur8az7Gvs9zHH/1tFMsPVSh7BS+8fHEcBYpdtihrP22GcjbOd98IiTAs/dVzSy0TUg6WEgJO6oUklGjqVbi99CrNZI1BtLP4vH4aSlz9JYg4et6SxiJlKyoSzqUr2NN9/pyFdQ//5d/EEjKJz8CAcQmCjjPEObGFttT3maY2+zsa2THodZgpfMyDbA3WUKxE= +X-Forefront-Antispam-Report: + CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:nam10.map.protection.outlook.com;PTR:;CAT:NONE;SFS:(13230022)(396003)(39860400002)(346002)(34036004)(366004)(376002)(136003)(47540400005)(451199015)(2616005)(52230400001)(121820200001)(83380400001)(166002)(86362001)(41300700001)(2906002)(4001150100001)(8936002)(316002)(5660300002)(235185007)(41320700001)(508600001)(6486002)(6512007)(6506007)(24736004)(108616005)(68406010)(85236043)(8676002)(10290500003)(6916009)(36736006)(36756003)(66899015);DIR:OUT;SFP:1101; +X-OriginatorOrg: dmarcrep.onmicrosoft.com +X-MS-Exchange-CrossTenant-OriginalArrivalTime: 25 Oct 2022 04:08:19.1682 + (UTC) +X-MS-Exchange-CrossTenant-Network-Message-Id: 7f843e40-ccc6-4c17-1ce7-08dab63e8cd1 +X-MS-Exchange-CrossTenant-AuthSource: BN7NAM10FT048.eop-nam10.prod.protection.outlook.com +X-MS-Exchange-CrossTenant-AuthAs: Internal +X-MS-Exchange-CrossTenant-Id: 96f9e21d-a1c4-44a3-99e4-37191ac61848 +X-MS-Exchange-CrossTenant-FromEntityHeader: Internet +X-MS-Exchange-Transport-CrossTenantHeadersStamped: SJ0PR18MB3916 + +This is a multi-part message in MIME format. + +--_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_ +Content-Type: multipart/related; + boundary="_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_" + +--_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_ +Content-Type: multipart/alternative; + boundary="_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_" + +--_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_ + + +--_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_ +Content-Type: text/html; charset=us-ascii +Content-Transfer-Encoding: base64 + +PGRpdiBzdHlsZSA9ImZvbnQtZmFtaWx5OlNlZ29lIFVJOyBmb250LXNpemU6MTRweDsiPlRoaXMgaX +MgYSBETUFSQyBhZ2dyZWdhdGUgcmVwb3J0IGZyb20gTWljcm9zb2Z0IENvcnBvcmF0aW9uLiBGb3Ig +RW1haWxzIHJlY2VpdmVkIGJldHdlZW4gMjAyMi0xMC0yMyAwMDowMDowMCBVVEMgdG8gMjAyMi0xMC +0yNCAwMDowMDowMCBVVEMuPC8gZGl2PjxiciAvPjxiciAvPllvdSdyZSByZWNlaXZpbmcgdGhpcyBl +bWFpbCBiZWNhdXNlIHlvdSBoYXZlIGluY2x1ZGVkIHlvdXIgZW1haWwgYWRkcmVzcyBpbiB0aGUgJ3 +J1YScgdGFnIG9mIHlvdXIgRE1BUkMgcmVjb3JkIGluIEROUyBmb3Igc3RhbHcuYXJ0LiBQbGVhc2Ug +cmVtb3ZlIHlvdXIgZW1haWwgYWRkcmVzcyBmcm9tIHRoZSAncnVhJyB0YWcgaWYgeW91IGRvbid0IH +dhbnQgdG8gcmVjZWl2ZSB0aGlzIGVtYWlsLjxiciAvPjxiciAvPjxkaXYgc3R5bGUgPSJmb250LWZh +bWlseTpTZWdvZSBVSTsgZm9udC1zaXplOjEycHg7IGNvbG9yOiM2NjY2NjY7Ij5QbGVhc2UgZG8gbm +90IHJlc3BvbmQgdG8gdGhpcyBlLW1haWwuIFRoaXMgbWFpbGJveCBpcyBub3QgbW9uaXRvcmVkIGFu +ZCB5b3Ugd2lsbCBub3QgcmVjZWl2ZSBhIHJlc3BvbnNlLiBGb3IgYW55IGZlZWRiYWNrL3N1Z2dlc3 +Rpb25zLCBraW5kbHkgbWFpbCB0byBkbWFyY3JlcG9ydGZlZWRiYWNrQG1pY3Jvc29mdC5jb20uPGJy +IC8+PGJyIC8+TWljcm9zb2Z0IHJlc3BlY3RzIHlvdXIgcHJpdmFjeS4gUmV2aWV3IG91ciBPbmxpbm +UgU2VydmljZXMgPGEgaHJlZiA9Imh0dHBzOi8vcHJpdmFjeS5taWNyb3NvZnQuY29tL2VuLXVzL3By +aXZhY3lzdGF0ZW1lbnQiPlByaXZhY3kgU3RhdGVtZW50PC9hPi48YnIgLz5PbmUgTWljcm9zb2Z0IF +dheSwgUmVkbW9uZCwgV0EsIFVTQSA5ODA1Mi48LyBkaXYgPg== + +--_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_-- + +--_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_-- + +--_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_ +Content-Type: application/gzip +Content-Transfer-Encoding: base64 +Content-ID: <3ff45643-7977-4f3c-a97d-14b9e7faa5e7> +Content-Description: protection.outlook.com!stalw.art!1666483200!1666569600.xml.gz +Content-Disposition: attachment; filename="protection.outlook.com!stalw.art!1666483200!1666569600.xml.gz"; + +H4sIAAAAAAAEAM1VzY7bIBi8V+o7RLnXxHZ+VyzbB2jVQy+9WQTjBMUGBDjZvn0/G4JJsu3usZcE5h +vzDcNg45fXrp2dubFCyed5ni3mL+TzJ9xwXu8pO82gLO3Tq62f50fn9BNCl8slu5SZMgdULBY5+vX9 +20925B2dR7J4n/xFSOuoZHwO7WYzHCQQUIDRdTJWDNfKuKrjjtbU0REEGJasJO04+dG7VqlTxlSHUU +QDCzqJltQdNcyv87UTzCirGucf8ITADq1ETTbFiu2bPc/Lcrdc5MvdbrthDVsV23K7KcoVRhM3PAzi +eGWoPFybA7bnBwF7Wq/Xy20JBmDkkUjgsh7Lq/VuPZSHeVgP3S0YW944gbVqBftd6X7fCnvkkxwFO5 +METG4vGTUO1vNIqNP6JDpiMPKDK2p1M4LDf8A0kUpyjPQVsFfERkgzR/JhA8MgYI0iAMCvV/+mULCc +KRNFG3WZvLGqN4xXQpPVIiuKMsuLXZbvltA3ViKZqV6CBIz8IOKhKz/Ttgc/61gZLBJWKyvcEDW/oR +RJiYNDDQQFGJNZwYsmVCbHkt3e94VDjFvEoubSiUZA2tNEnHmrNK+cIiqNdlp4ZDdGdURw1wx3LSGP +eKQfOa258WCSjBS+6nwUh2nvjpXhtm9dIvjekRCzSctN7rxpvOXMKTOS4MziPOH4PkRTa4fkj5PJ3p +um/7GEv12/Ww1wVuIkrNFUFsU/tfiovaMlTeIH3WCQFdINAYD24+TDPiRvCvSQkIEfLji8CsJHhfwB +wJC79XYGAAA= + +--_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_-- diff --git a/tests/resources/smtp/reports/dmarc5.eml b/tests/resources/smtp/reports/dmarc5.eml new file mode 100644 index 0000000..3463d8b --- /dev/null +++ b/tests/resources/smtp/reports/dmarc5.eml @@ -0,0 +1,54 @@ +Received: from mail.stalw.art ([mail.stalw.art]) by 127.0.0.1 (Stalwart JMAP) with LMTP; Tue, 20 Sep 2022 10:28:19 +0000 +Received: from a14-92.smtp-out.amazonses.com (a14-92.smtp-out.amazonses.com [54.240.14.92]) + (using TLSv1.2 with cipher ECDHE-RSA-AES128-SHA256 (128/128 bits)) + (No client certificate requested) + by mail.stalw.art (Postfix) with ESMTPS id 1337D7E19D + for ; Tue, 20 Sep 2022 10:28:18 +0000 (UTC) +DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple; + s=a66wkfbz3zwxdt2n5p6d7lj2ja7sdwuc; d=amazonses.com; t=1663669697; + h=From:To:Message-ID:Subject:MIME-Version:Content-Type:Date; + bh=h9v7dueDYfUxVokuKSLTqLuOwisdgdDRQ6TLwJOzXes=; + b=dHR5EJhoY9s8g2/Y4K4rHdz44k67r7fyC4wr2AWZmemrVBoxYHJPwa295S2VJQtY + kxTxppN2GEcNxhUMw8TXBrRwNKdoOLU38ZtrAN1a4hWVxmlwky1dtjXETQ/qJ257Nzg + bsXkAo4S1RABFmkQQJ0zSPZGkMW+lpZTBCDzlOHU= +DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple; + s=6gbrjpgwjskckoa6a5zn6fwqkn67xbtw; d=amazonses.com; t=1663669697; + h=From:To:Message-ID:Subject:MIME-Version:Content-Type:Date:Feedback-ID; + bh=h9v7dueDYfUxVokuKSLTqLuOwisdgdDRQ6TLwJOzXes=; + b=UDIvc6rvbihyGbzGRsmSSSzVNFgpfb3V3j0UivcNjlX2y63vjLinol463Z/+3Xh3 + BmxAOiLHF/DbVnqqNg5ygdxsa7MBHXEJ5we3W8vQr37xNk5DqhV7HPBSFttWP5sy0dg + rdjyfMIjqJ1J/2+aM4opFA/6EWif7TGmjo7N1KKM= +From: postmaster@amazonses.com +To: domains@stalw.art +Message-ID: <010001835a70fc8d-a3d7eff5-7adb-41cc-87bd-a646d9776a69-000000@email.amazonses.com> +Subject: Dmarc Aggregate Report Domain: {stalw.art} Submitter: {Amazon SES} + Date: {2022-09-19} Report-ID: {6b06c366-0631-4ca0-8337-f5aecf137918} +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_42492_694130218.1663669697673" +Date: Tue, 20 Sep 2022 10:28:17 +0000 +Feedback-ID: 1.us-east-1.CTa/CO4t1eWkL0VlHBu5/eINCZhxZraAIsQC/FZHIgk=:AmazonSES +X-SES-Outgoing: 2022.09.20-54.240.14.92 + +------=_Part_42492_694130218.1663669697673 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +This MIME email was sent through Amazon SES. +------=_Part_42492_694130218.1663669697673 +Content-Type: application/octet-stream; + name=amazonses.com!stalw.art!1663545600!1663632000.xml.gz +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename=amazonses.com!stalw.art!1663545600!1663632000.xml.gz + +H4sIAAAAAAAAAG1TwXLbIBA9O1/RyV1CWLHszlDSHHJMe8itFw1GK5uJBAwgp+3XlwXJVjK9SOzb +1b59vBV7/D0OXy7gvDL62z0tq/tHfsd6gO4o5Bu/27A5yauSMrIEEXdgjQvtCEF0IogIbZhxp1aL +EfjTy9Ovnz+K1+dXRq4gVsAo1MCt8WEUPoD7Lkbx12gPvpRmZCTnsXLurzreHKtG1k1TVE1Niwcp +quJQ1/ui3wmQPa33X+mBkVs9fh1HgtYJfUq0G3aEk9KcNk29e9g1VcVIRlISdJdSTb2tMIUxNiEf +ulwpVpKZNYOSf1o7HQflzzCTm6hCcx/E8F4KF2KjjGBSdG9q5I6RfEiQt31C8I2A5dpoYMSmyC+h +z7GVgVOcEw8I9IbHKD5xyP9MFO9SGpdnc+Y9i/ZmchJaZfm22pd0T0t6OJRb7HtLpUppJh0ZGcmH +hM0scBHDFC8p9UblykdvVcAdyTOvkbkGZffR5picbyCJ7GdwvoSblA8k0YWsgKkOdFC9iiu52HiB +wVhoe2dGnher1AP6uU6k2jOIDlwGVj6t4UT2iYSJKZxbB34awsy6jHu1fUV8sz0tNH7FrfAeVykF +WediO/nUHcuycdHd5Zf8BxMenbqzAwAA +------=_Part_42492_694130218.1663669697673-- diff --git a/tests/resources/smtp/reports/tls1.eml b/tests/resources/smtp/reports/tls1.eml new file mode 100644 index 0000000..e54c9e8 --- /dev/null +++ b/tests/resources/smtp/reports/tls1.eml @@ -0,0 +1,41 @@ +From: tlsrpt@mail.sender.example.com +Date: Fri, May 09 2017 16:54:30 -0800 +To: mts-sts-tlsrpt@example.net +Subject: Report Domain: example.net + Submitter: mail.sender.example.com + Report-ID: <735ff.e317+bf22029@example.net> +TLS-Report-Domain: example.net +TLS-Report-Submitter: mail.sender.example.com +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="tlsrpt"; + boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00" +Content-Language: en-us + +This is a multipart message in MIME format. + +------=_NextPart_000_024E_01CC9B0A.AFE54C00 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: 7bit + +This is an aggregate TLS report from mail.sender.example.com + +------=_NextPart_000_024E_01CC9B0A.AFE54C00 +Content-Type: application/tlsrpt+gzip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="mail.sender.example!example.com!1013662812!1013749130.json.gz" + +H4sICCpFtWMAA3JwdDAxLmpzb24uMQCtVVtr2zAYfe+vEN7bmFzZjt3EMLbRhu1hdCUJI+soRpGU +VMy2jCSHZCX/fZIvzVLHpc0WDDHSOfqOznfxwxkwP0fIFc75b6y5yGGOM+bEwLkUWYHzLZw772oU +xZpBifOV3X6o1qp1pbHU0O5qXlN95EUQDSDyZgjF1XPbnFIxWE778H4QhyPz3DoVfNfEJiLXmGjI +86WwDKUVlKwQUvN89ZE0Ujcu2+CsSFkruYZATi0nRFE48C8I9AMawMEFwXARMQRHg4hhxIZksHgk +FiLlhDNleD8fde/vvMdsD7x4sgf1tmCN3L/u/xSltDS3OAh1AFszqUxmYjCdTdfekYMqVCYoi4Fm +ylrSC9rE4K2bYZ66rWnbJ6Z1OXiT4JU5exgNEHI6oLv+m1FhQuXWgZdEM+rgvVC634le6V1RByu7 +w2COKrMMy57caaFxClVJCFNqWZpX8287g4gyt+LCwI+OqK95SyOwlKxDClDwrKSWR5k2b+qoB12x +FVUyVab6sdgIM12x5MS2K9sUXDLal1plOtFUC8w0hryoWxF5MV0MY7wg1PSt58dxb8lJRhhfVwfU +mWtnR7bxXllk9vqMdlzzEOrgd90jXmZMNah0qmAutMlvYWfDP3kTnOaN/0pv9ke1OgIXuZ4XuGH0 +Sj/NFXoImFJu578pYTtkZVZ9DWy4e60LFZ+f18NUuZ1p2+wklgc+AE7Be9AMW0AABH4AaPAGTBv7 +r4WetuaDbueenN41Tjmtv2FNM708td5o6Iaea8rNjfwT8jA8oQzgApNfZfF/GiV4Bm7HimRYVXBa +RZ+HaJR8T8aTSXIz+Tb/kdx8mn1Jvo6vP5u/8fxyPL4aXx3JzcHKfsbW63dnuz+8byfQUQgAAA== + +------=_NextPart_000_024E_01CC9B0A.AFE54C00-- diff --git a/tests/resources/smtp/reports/tls2.eml b/tests/resources/smtp/reports/tls2.eml new file mode 100644 index 0000000..afb8687 --- /dev/null +++ b/tests/resources/smtp/reports/tls2.eml @@ -0,0 +1,64 @@ +From: tlsrpt@mail.sender.example.com +Date: Fri, May 09 2017 16:54:30 -0800 +To: mts-sts-tlsrpt@example.net +Subject: Report Domain: example.net + Submitter: mail.sender.example.com + Report-ID: <735ff.e317+bf22029@example.net> +TLS-Report-Domain: example.net +TLS-Report-Submitter: mail.sender.example.com +MIME-Version: 1.0 +Content-Type: multipart/report; report-type="tlsrpt"; + boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00" +Content-Language: en-us + +This is a multipart message in MIME format. + +------=_NextPart_000_024E_01CC9B0A.AFE54C00 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: 7bit + +This is an aggregate TLS report from mail.sender.example.com + +------=_NextPart_000_024E_01CC9B0A.AFE54C00 +Content-Type: application/tlsrpt +Content-Disposition: attachment; + filename="mail.sender.example!example.com!1013662812!1013749130.json" + +{ + "report-id": "2020-01-01T00:00:00Z_example.com", + "date-range": { + "start-datetime": "2020-01-01T00:00:00Z", + "end-datetime": "2020-01-07T23:59:59Z" + }, + "organization-name": "Google Inc.", + "contact-info": "smtp-tls-reporting@google.com", + "policies": [ + { + "policy": { + "policy-type": "sts", + "policy-string": [ + "version: STSv1", + "mode: enforce", + "mx: demo.example.com", + "max_age: 604800" + ], + "policy-domain": "example.com" + }, + "summary": { + "total-successful-session-count": 23, + "total-failure-session-count": 1 + }, + "failure-details": [ + { + "result-type": "certificate-host-mismatch", + "sending-mta-ip": "123.123.123.123", + "receiving-ip": "234.234.234.234", + "receiving-mx-hostname": "demo.example.com", + "failed-session-count": 1 + } + ] + } + ] +} + +------=_NextPart_000_024E_01CC9B0A.AFE54C00-- diff --git a/tests/resources/smtp/sieve/awl.sieve b/tests/resources/smtp/sieve/awl.sieve new file mode 100644 index 0000000..d3ab078 --- /dev/null +++ b/tests/resources/smtp/sieve/awl.sieve @@ -0,0 +1,32 @@ +require ["variables", "include", "vnd.stalwart.expressions", "reject"]; + +global "score"; + +# Create AWL table +if eval "!query('sql', 'CREATE TABLE awl (score FLOAT, count INT, sender TEXT NOT NULL, ip TEXT NOT NULL, PRIMARY KEY (sender, ip))' , [])" { + reject "create table query failed"; + stop; +} + + +set "score" "1.1"; +include "awl_include"; +if eval "score != 1.1" { + reject "awl_include #1 set score to ${score}"; + stop; +} + +set "score" "2.2"; +include "awl_include"; +if eval "score != 1.6500000000000001" { + reject "awl_include #2 set score to ${score}"; + stop; +} + +set "score" "9.3"; +include "awl_include"; +if eval "score != 5.4750000000000005" { + reject "awl_include #3 set score to ${score}"; + stop; +} + diff --git a/tests/resources/smtp/sieve/awl_include.sieve b/tests/resources/smtp/sieve/awl_include.sieve new file mode 100644 index 0000000..64ff476 --- /dev/null +++ b/tests/resources/smtp/sieve/awl_include.sieve @@ -0,0 +1,20 @@ +require ["variables", "include", "vnd.stalwart.expressions", "reject"]; + +global "score"; +set "awl_factor" "0.5"; + +let "result" "query('sql', 'SELECT score, count FROM awl WHERE sender = ? AND ip = ?', [env.from, env.remote_ip])"; + +let "awl_score" "result[0]"; +let "awl_count" "result[1]"; + +if eval "awl_count > 0" { + if eval "!query('sql', 'UPDATE awl SET score = score + ?, count = count + 1 WHERE sender = ? AND ip = ?', [score, env.from, env.remote_ip])" { + reject "update query failed"; + stop; + } + let "score" "score + ((awl_score / awl_count) - score) * awl_factor"; +} elsif eval "!query('sql', 'INSERT INTO awl (score, count, sender, ip) VALUES (?, 1, ?, ?)', [score, env.from, env.remote_ip])" { + reject "insert query failed"; + stop; +} diff --git a/tests/resources/smtp/sieve/spamtest_include.sieve b/tests/resources/smtp/sieve/spamtest_include.sieve new file mode 100644 index 0000000..e51503f --- /dev/null +++ b/tests/resources/smtp/sieve/spamtest_include.sieve @@ -0,0 +1,14 @@ +require ["variables", "spamtest", "spamtestplus", "reject"]; + +set "level" "none"; +set "percent" "none"; + +if spamtest :matches "*" { + set "level" "${0}"; +} + +if spamtest :percent :matches "*" { + set "percent" "${0}"; +} + +reject "spamtest=${level} percent=${percent} score=${env.spam.score} is_spam=${env.spam.is_spam}"; diff --git a/tests/resources/smtp/sieve/stage_connect.sieve b/tests/resources/smtp/sieve/stage_connect.sieve new file mode 100644 index 0000000..1e43ab6 --- /dev/null +++ b/tests/resources/smtp/sieve/stage_connect.sieve @@ -0,0 +1,5 @@ +require ["variables", "reject"]; + +if string "${env.remote_ip}" "10.0.0.88" { + reject "Your IP '${env.remote_ip}' is not welcomed here."; +} diff --git a/tests/resources/smtp/sieve/stage_data.sieve b/tests/resources/smtp/sieve/stage_data.sieve new file mode 100644 index 0000000..6ab2b28 --- /dev/null +++ b/tests/resources/smtp/sieve/stage_data.sieve @@ -0,0 +1,35 @@ +require ["envelope", "reject", "variables", "replace", "mime", "foreverypart", "editheader", "extracttext", "enotify"]; + +if envelope :localpart :is "to" "thomas" { + deleteheader "from"; + addheader "From" "no-reply@my.domain"; + redirect "redirect@here.email"; + discard; +} + +if envelope :localpart :is "to" "bob" { + redirect "redirect@somewhere.email"; + discard; +} + +if envelope :localpart :is "to" "bill" { + reject "Bill cannot receive messages."; + stop; +} + +if envelope :localpart :is "to" "jane" { + set "counter" "a"; + foreverypart { + if header :mime :contenttype "content-type" "text/html" { + extracttext :upper "text_content"; + replace "${text_content}"; + } + set :length "part_num" "${counter}"; + addheader :last "X-Part-Number" "${part_num}"; + set "counter" "${counter}a"; + } +} + +if envelope :domain :is "to" "foobar.net" { + notify "mailto:john@example.net?cc=jane@example.org&subject=You%20have%20got%20mail"; +} diff --git a/tests/resources/smtp/sieve/stage_ehlo.sieve b/tests/resources/smtp/sieve/stage_ehlo.sieve new file mode 100644 index 0000000..309f2ea --- /dev/null +++ b/tests/resources/smtp/sieve/stage_ehlo.sieve @@ -0,0 +1,5 @@ +require ["variables", "extlists", "reject"]; + +if eval "contains(['spammer.org', 'spammer.net'], env.helo_domain)" { + reject "551 5.1.1 Your domain '${env.helo_domain}' has been blocklisted."; +} diff --git a/tests/resources/smtp/sieve/stage_mail.sieve b/tests/resources/smtp/sieve/stage_mail.sieve new file mode 100644 index 0000000..7c67b38 --- /dev/null +++ b/tests/resources/smtp/sieve/stage_mail.sieve @@ -0,0 +1,17 @@ +require ["variables", "envelope", "reject", "vnd.stalwart.expressions"]; + +if envelope :localpart :is "from" "spammer" { + reject "450 4.1.1 Invalid address"; +} + +eval "query('sql', 'CREATE TABLE IF NOT EXISTS blocked_senders (addr TEXT PRIMARY KEY)', [])"; +eval "query('sql', 'INSERT OR IGNORE INTO blocked_senders (addr) VALUES (?)', 'marketing@spam-domain.com')"; + +if eval "query('sql', 'SELECT 1 FROM blocked_senders WHERE addr=? LIMIT 1', [envelope.from])" { + reject "Your address has been blocked."; +} + +if eval "!is_local_domain('foobar.org') || is_local_domain('other.org')" { + let "reason" "'result: ' + is_local_domain('foobar.org') + ' ' + is_local_domain('other.org')"; + reject "is_local_domain function failed: ${reason}"; +} diff --git a/tests/resources/smtp/sieve/stage_rcpt.sieve b/tests/resources/smtp/sieve/stage_rcpt.sieve new file mode 100644 index 0000000..c95d441 --- /dev/null +++ b/tests/resources/smtp/sieve/stage_rcpt.sieve @@ -0,0 +1,12 @@ +require ["variables", "envelope", "reject", "vnd.stalwart.expressions"]; + +if envelope :domain :is "to" "foobar.org" { + eval "query('sql', 'CREATE TABLE IF NOT EXISTS greylist (addr TEXT PRIMARY KEY)', [])"; + + set "triplet" "${env.remote_ip}.${envelope.from}.${envelope.to}"; + + if eval "!query('sql', 'SELECT 1 FROM greylist WHERE addr=? LIMIT 1', [triplet])" { + eval "query('sql', 'INSERT INTO greylist (addr) VALUES (?)', [triplet])"; + reject "422 4.2.2 You have been greylisted '${triplet}'."; + } +} diff --git a/tests/resources/tls_cert.pem b/tests/resources/tls_cert.pem new file mode 100644 index 0000000..5c8104b --- /dev/null +++ b/tests/resources/tls_cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFCzCCAvOgAwIBAgIUSL6bpA76cxsPc2XRu46iEcU2fccwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDYyNTE3MDgxOFoYDzIxMjYw +NjAxMTcwODE4WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQC3BLQXOXdKNoK4oReBn991ZsOiP+oOmHI0IAo169DT +4PRJM2RYI7ThoVIfTNwvzOgP2CiQ2izSLs5T3/9mFlCZ7HhXoQV1mfxsXBRY3+xx +S9YcQVdWO0eV07aKxmAjEf0u7BG/soCCQhK5ZqGp6s5UOF7tJ5BRXQAkY0SHdxP0 +9muBJj3fUHEHuf7mTx6hKTxnWC0HNDMvraRUakcMp/DzJfybrmjAX14tQGs3ODGZ +d8LGm4fCPuGZWFLT088p439GwVLw+xg7tujS7yMQpqW166aNFPtHJQRuKip+jpkU +4aMUc8ir+4pdL3M3V+mjI2He6vmEzOdCGuqXAEz0qUzsZ0EnSlndp3RiNPwgv8ac +YpHzknZZl0uppB1RmjudXrsWGp0OU0Tgx3CLKRMyN+YtFcYBWEDMkcrJEo3CaQjZ +qp5Ltqp42C+4MHBjzRD1ratvmnLUDD4vzIKRQG4duhOgOnFJVEM03wOtY2aaxefG +w4bBEM4PLgYgIrLGU/5EVIzzFZJDrL2S1LY58MGb4SpzD7elkqF3PbCDEuRQEnxf +V6GQmsID/otO6HLqbqYyMB+hI4VTt+7zyPFpY+C8/sYrIf4sz+3165yhvPaSzmu1 +KPNXgNzkMJL3j86lVEfAd1oVI9skoE30S/yCc2sDLH9oE8agcE8b9BSJTt1xQ0g+ +3QIDAQABo1MwUTAdBgNVHQ4EFgQUjXrsjEGYSsHXUSqI8+oUtrfVlowwHwYDVR0j +BBgwFoAUjXrsjEGYSsHXUSqI8+oUtrfVlowwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAaXocvWeEiqOHhV6miozqNo3H1sGs9VrSRP8+mpIHpW5f +NulmMNgqfH+4MIzR5QMqj6n6do8F72s+K3xQDELT6468KtGfM8q0gGGhkvHxv5Wp +9Iibj5MGn3J8oZvtp1NKSLdURjHD+H46zSAHQszdkFUavidv7irAVDAgY4Yh+AGv +H4GA4VbRsjv5KFuMgXGbwNQxrbRT52+D5fzw9EObh6TBhEpxANyrjGtzRCVoTclS +R9N6KUd4cDjvif1Sdrnp3VONZ/Bf3BXij7PbgSSbzVdxEd82E0TQKLofeTlRcbT2 +IzIb8jqwAiOU4Q9BqjHB8tmJTg6DK4a2mU9q0pRjjtV34lzSTfM0ERnuwjIp0WK9 +Ot138lFOpblB4FNrpm+uxdMzti/wkaJ9JNfe11aLS7xtnDKiEFXdVatMhJ8S3MFf +DWb+hFdtvqYEOtc60ZGEsQTR6NdRgjg8JWLMWbN/gL1vH8FdyAmGZAa5m9OWmVn8 +lDrOewStid7o12nsyH0h8HSU3VkC9EpnAQG5MBMwNTVTPycYZluSx3EM1iOrNb/3 +keMF8oetq+LSAhpspD/l0OPIMFk53u62R8yAS/tKwG2q+li/k51ImD0fY+G3329n +2f0vMZDA+RjI8zIQIvrQWOr7AqkytZ/ETWz0c2GlWbJEgEA1Y2Lo/qGrLBji4wQ= +-----END CERTIFICATE----- diff --git a/tests/resources/tls_privatekey.pem b/tests/resources/tls_privatekey.pem new file mode 100644 index 0000000..3f9ff47 --- /dev/null +++ b/tests/resources/tls_privatekey.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC3BLQXOXdKNoK4 +oReBn991ZsOiP+oOmHI0IAo169DT4PRJM2RYI7ThoVIfTNwvzOgP2CiQ2izSLs5T +3/9mFlCZ7HhXoQV1mfxsXBRY3+xxS9YcQVdWO0eV07aKxmAjEf0u7BG/soCCQhK5 +ZqGp6s5UOF7tJ5BRXQAkY0SHdxP09muBJj3fUHEHuf7mTx6hKTxnWC0HNDMvraRU +akcMp/DzJfybrmjAX14tQGs3ODGZd8LGm4fCPuGZWFLT088p439GwVLw+xg7tujS +7yMQpqW166aNFPtHJQRuKip+jpkU4aMUc8ir+4pdL3M3V+mjI2He6vmEzOdCGuqX +AEz0qUzsZ0EnSlndp3RiNPwgv8acYpHzknZZl0uppB1RmjudXrsWGp0OU0Tgx3CL +KRMyN+YtFcYBWEDMkcrJEo3CaQjZqp5Ltqp42C+4MHBjzRD1ratvmnLUDD4vzIKR +QG4duhOgOnFJVEM03wOtY2aaxefGw4bBEM4PLgYgIrLGU/5EVIzzFZJDrL2S1LY5 +8MGb4SpzD7elkqF3PbCDEuRQEnxfV6GQmsID/otO6HLqbqYyMB+hI4VTt+7zyPFp +Y+C8/sYrIf4sz+3165yhvPaSzmu1KPNXgNzkMJL3j86lVEfAd1oVI9skoE30S/yC +c2sDLH9oE8agcE8b9BSJTt1xQ0g+3QIDAQABAoICABq5oxqpF5RMtXYEgAw7rkPU +h8jPkHwlIrgd3Z/WGZ53APUXfhWo0ScJiZZsgNKyF0kJBZNxaI4gq5xv3zmnFIoF +j+Ur7EIqBERGheoceMhqjI9/syMycNeeHM/S/ALjA5ewfT8C7+UVhOpx5DWNxidi +O+phlp9q9zRZEo69grqIqVYooWxUsMyyCljTQOPDw8BLjfe5VagmsRJqmolslLDM +4UBSjZVZ18S/3Wgo2oVQia660244BHWCAkZQbbXuNI2+eUAbSoSdxw3WQcaSrywL +hzyezbqr2yPDIIVuiUgVUt0Ps0P57VCCN07jlYhvCEGnClysFzD+ATefoZ0wg7za +dQu2E+d166rAjnssyhzcHMn3pxgSdtXD+dQR/xfIGbPABucCupEFqKmhLdMm9+ud +lHay87qzMpIa8cITJwEQROfXqWAhNUU98pKCOx1SVXBqQC7QVqGQ5solDf0eMSVh +ngQ6Dz2WUI2ty75LteiFwlyTgnU9nyPN0NXsrMEET2BHWre7ufTQqiULtQ7+9BwH +AMxEKvrQHjMUjdfbXuzdyc5w5mPYJZfFVSQ1HMslx66h9yCpRIsBZvUGvoaP8Tpe +nQ66FTYRbiOkkdJ7k8DtrnhsJI1oOGjnvj/rvZ8D2pvrlJcIH2AyN3MOL8Jp5Oj1 +nCFt77TwpF92pgl0g9gBAoIBAQDcarmP54QboaIQ9S2gE/4gSVC5i44iDJuSRdI8 +K081RQcWiNzqQXTRc5nqJ7KzLyPiGlg+6rWsBKLos5l4t+MdhhH+KUvk/OtT/g8V +0NZBNXLIbSb8j8ix4v3/f2qKHN3Co6QOlxb3gFvobKDdoKqUNiSH1zTZ8/Y/BzkM +jqWKhTdaLz6eyzhKfOTA4LO8kJ3VF8HUM1N9/e8Gjorl+gZpJUXUQS0+AIi8W76C +OwDrVb3BPGVnApQJfWF78h4g20RwXrx/GYUW2vOMcLjXXDV5U7+nobPUoJnLxoZC +16o88y0Ivan8dBNXsc1epyPvvEqp6MJbAyyVuNeuRJcgYA0BAoIBAQDUkGRV7fLG +wCr5rNysUO+FKzVtTJnf9KEsqAqUmmVnG4oubxAJJtiB5n2+DT+CtO8Nrtz05BbR +uxfWm+lbEw6lVMj63bywtp0NdULg7/2t+oq2Svv16KrZIRJttXMkdEiFFmkVAEhX +l8Fyl6PJPfSMwbPdXEUPUAaNrXweVFffXczHc4W2G212ZzDB0z7QQSgEntbTDFB/ +2Cg5dvuojlM9zw0fuEyLwItZs7n16j/ONZLgBHyroMU9ZPxbnLrVyoZlqtob+RWm +Ju2fSIL9QqG6O4td1TqcUBGvFQYjGvKA+q5fsG26NBJ0Ac48cNK6PS4lMkN3Av2J +ccloYaMEHAXdAoIBAE8WMCy1Ok6byUXiYxOL+OPmyoM40q/e7DcovE2AkLQhZ3Cr +fPDEucCphPFiexkV8f8fysgQeU0WgMmUH54UBPbD81LJyISKR3nkr875Ftdg8SV/ +HL0EblN9ifuR4U1bHCrJgoUFq2T09oVH7NR44Ju7bZIcIseNZK6qzcp2qGkycXD3 +gLWDX1hCxeV6+qLPFQKvuomEPRH4+jnVDXuFIaW6jPqixDP6BxXmqU2bFDJcmnBq +VkwGvc1F4qORdUP+yOi05VeJdZqEx1x92aTUXg+BgEQKnjbNxUE7o1L6hQfHjUIU +o5iEoagWkQTEXf2YBwY+EPaNBgNWxnSuAbfJHwECggEBALOF95ezTVWauzD/U6ic ++o3n/kl/Zn4FJ5KFodn7xCSe18d7uXlhO34KYqx+l+MWWMefpbGWacdcUjfImf93 +SulLgCqP12sP7/iLzp4XUpL7hOeM0NvRU2nqSpwpoUNqik0Mrlc0U+TWoGTduVCf +aMjwV65e3VyfY8mIeclLxqM5n1fcM1OoOnzDjiRE+0n7nYa5eAnq3pn6v4449TZY +belH03e0ucFWLtrltesBmj3YdWGJqJlzQOInRhNBfXJOh8+ZynfRmP0o54udPDQV +cG3PGFd5XPTjkuvhv7sqaSGRlm/um92lWOhtFfdp+i+cuDpmByCef+7zEP19aKZx +3GkCggEAFTs7KNMfvIEaLH0yQUFeq2gLmtcMofmOmeoIECycN1rG7iJo07lJLIs0 +bVODH8Z0kX8llu3cjGMAH/6R2uugJSxkmFiZKrngTzKmxDPvTCKWR4RFwXH9j8IO +cPq7FtKN4SgrPy9ciAPdkcGmu3zz/sBKOaoPwvU2PdBRT+v/aoz+GCLXAvzFlKVe +9/7zdg87ilo8+AtV+71EJeR3kyBPKS9JrWYUKfiams12+uuH4/53rMFZfNCAaZ3Z +1sdXEO4o3Loc5TX4DbO9FVdBSBe6klEXx4T0QJboO6uBvTBnnRL2SQriJQQFwYT6 +XzVV5pwOxkIDBWDIqMUfwJDChBKfpw== +-----END PRIVATE KEY----- diff --git a/tests/src/automation/acme.rs b/tests/src/automation/acme.rs new file mode 100644 index 0000000..80fa4ca --- /dev/null +++ b/tests/src/automation/acme.rs @@ -0,0 +1,871 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use ahash::AHashSet; +use common::network::dns::update::DNS_RECORDS; +use dns_update::DnsRecord; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::{AcmeChallengeType, AcmeRenewBefore, DnsRecordType}, + prelude::{ObjectType, Property}, + structs::{ + AcmeProvider, Action, Certificate, CertificateManagement, + CertificateManagementProperties, DkimManagement, DnsManagement, + DnsManagementProperties, DnsServer, DnsServerCloudflare, Domain, PublicText, + PublicTextValue, SecretKey, SecretKeyValue, SecretText, SecretTextValue, Task, + TaskDomainManagement, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId, map::Map}, +}; +use serde_json::json; +use store::{registry::write::RegistryWrite, write::now}; +use x509_parser::parse_x509_certificate; +use x509_parser::pem::Pem; + +pub async fn test(test: &TestServer) { + println!("Running ACME tests..."); + let account = test.account("admin@example.org"); + + // Manually insert certificates with different expiration dates + let now = now() as i64; + test.server + .registry() + .write(RegistryWrite::insert( + &Certificate { + certificate: PublicText::Text(PublicTextValue { + value: ISSUER_ABC_CERT.to_string(), + }), + private_key: SecretText::Text(SecretTextValue { + secret: ISSUER_ABC_PK.to_string(), + }), + issuer: "Issuer ABC".to_string(), + not_valid_after: UTCDateTime::from_timestamp(now + 86400), + not_valid_before: UTCDateTime::from_timestamp(now - 10), + subject_alternative_names: Map::new(vec!["mail.example.org".to_string()]), + } + .into(), + )) + .await + .unwrap(); + test.server + .registry() + .write(RegistryWrite::insert( + &Certificate { + certificate: PublicText::Text(PublicTextValue { + value: ISSUER_XYZ_CERT.to_string(), + }), + private_key: SecretText::Text(SecretTextValue { + secret: ISSUER_XYZ_PK.to_string(), + }), + issuer: "Issuer XYZ".to_string(), + not_valid_after: UTCDateTime::from_timestamp(now + (2 * 86400)), + not_valid_before: UTCDateTime::from_timestamp(now - 10), + subject_alternative_names: Map::new(vec!["mail.example.org".to_string()]), + } + .into(), + )) + .await + .unwrap(); + test.server + .registry() + .write(RegistryWrite::insert( + &Certificate { + certificate: PublicText::Text(PublicTextValue { + value: ISSUER_123_CERT.to_string(), + }), + private_key: SecretText::Text(SecretTextValue { + secret: ISSUER_123_PK.to_string(), + }), + issuer: "Issuer 123".to_string(), + not_valid_after: UTCDateTime::from_timestamp(now - 86400), + not_valid_before: UTCDateTime::from_timestamp(now - 100), + subject_alternative_names: Map::new(vec!["mail.example.org".to_string()]), + } + .into(), + )) + .await + .unwrap(); + assert_eq!( + account + .registry_get_all::() + .await + .into_iter() + .map(|(_, c)| c.issuer) + .collect::>(), + AHashSet::from_iter(vec![ + "Issuer ABC".to_string(), + "Issuer XYZ".to_string(), + "Issuer 123".to_string() + ]) + ); + + // Reload certificates and make XYZ is used for mail.example.org + account + .registry_create_object(Action::ReloadTlsCertificates) + .await; + assert_eq!( + parse_x509_certificate( + test.server + .resolve_certificate("mail.example.org") + .unwrap() + .end_entity_cert() + .unwrap() + ) + .unwrap() + .1 + .tbs_certificate + .issuer + .to_string(), + "CN=Issuer XYZ CA" + ); + + // Make sure the expired certificate was deleted + assert_eq!( + account + .registry_get_all::() + .await + .into_iter() + .map(|(_, c)| c.issuer) + .collect::>(), + AHashSet::from_iter(vec!["Issuer ABC".to_string(), "Issuer XYZ".to_string()]) + ); + account.registry_destroy_all(ObjectType::Certificate).await; + + // Create test Pebble and In Memory DNS servers + let pebble_dns_id = account + .registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare { + secret: SecretKey::Value(SecretKeyValue { + secret: "test@pebble.org".into(), + }), + description: "Pebble DNS server".to_string(), + ..Default::default() + })) + .await; + let in_memory_dns_id = account + .registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare { + secret: SecretKey::Value(SecretKeyValue { + secret: "test@memory.org".into(), + }), + description: "In-memory DNS server".to_string(), + ..Default::default() + })) + .await; + + // ACME provider creation should fail without a contact email + account + .registry_create_object_expect_err(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + ..Default::default() + }) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("At least one contact email is required"); + + // Create an ACME provider using TLS-ALPN-01 challenge + let tls_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@tls.org".to_string()]), + challenge_type: AcmeChallengeType::TlsAlpn01, + renew_before: AcmeRenewBefore::R12, + ..Default::default() + }) + .await; + let provider = account.registry_get::(tls_acme_id).await; + assert_eq!(provider.directory, "https://localhost:14000/dir"); + assert_eq!( + provider.contact, + Map::new(vec!["mailto:hello@tls.org".to_string()]) + ); + assert!( + provider.account_uri.starts_with("https://localhost:14000"), + "Provider {:?} has invalid account URI", + provider + ); + + // Create a domain and trigger TLS-ALPN-01 ACME renewal + let tls_domain_id = account + .registry_create_object(Domain { + name: "tls.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: tls_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: in_memory_dns_id, + publish_records: Map::new(vec![ + DnsRecordType::Tlsa, + DnsRecordType::AutoConfig, + DnsRecordType::AutoConfigLegacy, + DnsRecordType::AutoDiscover, + DnsRecordType::MtaSts, + ]), + ..Default::default() + }), + ..Default::default() + }) + .await; + test.wait_for_tasks_skip_not_due().await; + let certificate = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1; + let mut sans = certificate.subject_alternative_names.into_inner(); + sans.sort(); + assert_eq!( + sans, + vec![ + "autoconfig.tls.org".to_string(), + "autodiscover.tls.org".to_string(), + "mta-sts.tls.org".to_string(), + "ua-auto-config.tls.org".to_string() + ] + ); + + // Make sure the TLSA records were added to the in-memory DNS server + let records = DNS_RECORDS.lock().unwrap().clone(); + for record in [ + "_443._tcp.mta-sts.tls.org.", + "_443._tcp.autoconfig.tls.org.", + "_443._tcp.ua-auto-config.tls.org.", + "_443._tcp.autodiscover.tls.org.", + ] { + if records + .iter() + .find(|r| r.name == record && matches!(r.record, DnsRecord::TLSA(_))) + .is_none() + { + panic!( + "Expected TLSA record for {} not found in DNS records: {:?}", + record, records + ); + } + } + + // Make sure a task was created to renew the certificate before it expires + let tasks = account.registry_get_all::().await; + assert_eq!( + tasks.len(), + 1, + "Expected 1 task, found {}: {:?}", + tasks.len(), + tasks + ); + let task = tasks.into_iter().next().unwrap().1; + if let Task::AcmeRenewal(TaskDomainManagement { + domain_id: task_domain_id, + .. + }) = task + { + assert_eq!( + task_domain_id, tls_domain_id, + "ACME renewal task has incorrect domain ID" + ); + } else { + panic!("Expected ACME renewal task, found: {:?}", task); + } + let not_valid_after = certificate.not_valid_after.timestamp(); + let not_valid_before = certificate.not_valid_before.timestamp(); + let length = not_valid_after - not_valid_before; + assert_eq!( + not_valid_before + length / 2, + task.due_timestamp() as i64, + "ACME renewal task has incorrect due timestamp, expected around {} but found {}", + not_valid_before + length / 2, + task.due_timestamp() as i64 + ); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + + // Test ACME using HTTP-01 challenge and the server domain "mail.example.org" + let http_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@example.org".to_string()]), + challenge_type: AcmeChallengeType::Http01, + ..Default::default() + }) + .await; + let domain_id = account.find_or_create_domain("example.org").await; + account.registry_update_object(ObjectType::Domain, domain_id, json!({ + Property::CertificateManagement: CertificateManagement::Automatic(CertificateManagementProperties { + acme_provider_id: http_acme_id, + subject_alternative_names: Default::default(), + }), + })).await; + test.wait_for_tasks_skip_not_due().await; + let certificate = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1; + let mut sans = certificate.subject_alternative_names.into_inner(); + sans.sort(); + assert_eq!( + sans, + vec![ + "autoconfig.example.org".to_string(), + "autodiscover.example.org".to_string(), + "imap.example.org".to_string(), + "mail.example.org".to_string(), + "mta-sts.example.org".to_string(), + "mx1.example.org".to_string(), + "mx2.example.org".to_string(), + "pop3.example.org".to_string(), + "smtp.example.org".to_string(), + "ua-auto-config.example.org".to_string() + ] + ); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + + // Test ACME using DNS-01 challenge + let dns_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@dns.org".to_string()]), + challenge_type: AcmeChallengeType::Dns01, + ..Default::default() + }) + .await; + let dns_domain_id = account + .registry_create_object(Domain { + name: "dns.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: dns_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: pebble_dns_id, + publish_records: Map::new(vec![DnsRecordType::Caa]), + ..Default::default() + }), + ..Default::default() + }) + .await; + test.wait_for_tasks_skip_not_due().await; + let certificate = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1; + let mut sans = certificate.subject_alternative_names.into_inner(); + sans.sort(); + assert_eq!(sans, vec!["*.dns.org".to_string(), "dns.org".to_string()]); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + + // Test ACME using DNS-01 challenge + let dns_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@persist.org".to_string()]), + challenge_type: AcmeChallengeType::DnsPersist01, + ..Default::default() + }) + .await; + let persist_domain_id = account + .registry_create_object(Domain { + name: "persist.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: dns_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: pebble_dns_id, + publish_records: Map::new(vec![DnsRecordType::Caa]), + ..Default::default() + }), + ..Default::default() + }) + .await; + test.wait_for_tasks_skip_not_due().await; + let certificate = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1; + let mut sans = certificate.subject_alternative_names.into_inner(); + sans.sort(); + assert_eq!( + sans, + vec!["*.persist.org".to_string(), "persist.org".to_string()] + ); + + // Test preferred chain selection against the alternate chains Pebble offers (RFC 8555 7.4.2) + let pebble_roots = pebble_root_common_names().await; + assert!( + pebble_roots.len() >= 2, + "Expected Pebble to offer multiple root chains, found: {:?}", + pebble_roots + ); + + // Renew without a preferred chain to discover the default root + let default_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@chain.org".to_string()]), + challenge_type: AcmeChallengeType::TlsAlpn01, + ..Default::default() + }) + .await; + let default_domain_id = account + .registry_create_object(Domain { + name: "chain.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: default_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: in_memory_dns_id, + ..Default::default() + }), + ..Default::default() + }) + .await; + test.wait_for_tasks_skip_not_due().await; + let default_chain = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1 + .certificate + .value() + .await + .unwrap() + .into_owned(); + let default_root = top_issuer_common_name(&default_chain) + .expect("default chain should expose a top issuer common name"); + assert!( + pebble_roots.contains(&default_root), + "Default root {:?} not among Pebble roots {:?}", + default_root, + pebble_roots + ); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + + // Renew with a preferred chain pointing at an alternate root and verify it is honored + let preferred_root = pebble_roots + .iter() + .find(|cn| **cn != default_root) + .cloned() + .expect("an alternate root distinct from the default"); + let preferred_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@chainalt.org".to_string()]), + challenge_type: AcmeChallengeType::TlsAlpn01, + preferred_chain: Some(preferred_root.clone()), + ..Default::default() + }) + .await; + let preferred_domain_id = account + .registry_create_object(Domain { + name: "chainalt.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: preferred_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: in_memory_dns_id, + ..Default::default() + }), + ..Default::default() + }) + .await; + test.wait_for_tasks_skip_not_due().await; + let preferred_chain = account + .registry_get_all::() + .await + .into_iter() + .next() + .unwrap() + .1 + .certificate + .value() + .await + .unwrap() + .into_owned(); + let selected_root = top_issuer_common_name(&preferred_chain) + .expect("preferred chain should expose a top issuer common name"); + assert_eq!( + selected_root, preferred_root, + "ACME did not select the preferred certificate chain" + ); + assert_ne!( + selected_root, default_root, + "Preferred chain matches the default; selection was not exercised" + ); + account + .registry_destroy(ObjectType::Domain, [default_domain_id, preferred_domain_id]) + .await + .assert_destroyed(&[default_domain_id, preferred_domain_id]); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + account.registry_destroy_all(ObjectType::AcmeProvider).await; + + // reuse_key: the keypair (and thus the SPKI published in DANE "3 1 1" records) must + // stay stable across renewals when enabled, and rotate when disabled. + for (reuse_key, expect_stable) in [(true, true), (false, false)] { + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + + let reuse_acme_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@reuse.org".to_string()]), + challenge_type: AcmeChallengeType::TlsAlpn01, + reuse_key, + ..Default::default() + }) + .await; + let reuse_domain_id = account + .registry_create_object(Domain { + name: "reuse.org".to_string(), + certificate_management: CertificateManagement::Automatic( + CertificateManagementProperties { + acme_provider_id: reuse_acme_id, + subject_alternative_names: Default::default(), + }, + ), + dkim_management: DkimManagement::Manual, + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id: in_memory_dns_id, + ..Default::default() + }), + ..Default::default() + }) + .await; + + // Initial issuance + test.wait_for_tasks_skip_not_due().await; + let (first_id, first_cert) = account + .registry_get_all::() + .await + .into_iter() + .next() + .expect("a certificate to be issued"); + let first_chain = first_cert.certificate.value().await.unwrap().into_owned(); + let first_key = leaf_public_key(&first_chain); + + // Backdate the stored certificate so a renewal is immediately due, then renew it. + // The reuse path must locate this certificate by its SANs and reuse its private key. + let reference = store::write::now() as i64; + let object_id = ObjectId::new(ObjectType::Certificate, first_id); + let old = test + .server + .registry() + .get(object_id) + .await + .unwrap() + .expect("stored certificate"); + let mut backdated = Certificate::from(old.clone()); + backdated.not_valid_before = UTCDateTime::from_timestamp(reference - 1_000_000); + backdated.not_valid_after = UTCDateTime::from_timestamp(reference - 10); + test.server + .registry() + .write(RegistryWrite::update(first_id, &backdated.into(), &old)) + .await + .unwrap(); + test.server + .acme_renew(reuse_domain_id) + .await + .ok() + .expect("certificate renewal to succeed"); + + let (_, renewed_cert) = account + .registry_get_all::() + .await + .into_iter() + .find(|(id, _)| *id != first_id) + .expect("a renewed certificate"); + let renewed_chain = renewed_cert.certificate.value().await.unwrap().into_owned(); + let second_key = leaf_public_key(&renewed_chain); + + if expect_stable { + assert_eq!( + first_key, second_key, + "reuse_key=true must preserve the certificate public key across renewals" + ); + } else { + assert_ne!( + first_key, second_key, + "reuse_key=false must rotate the certificate public key on renewal" + ); + } + + account + .registry_destroy(ObjectType::Domain, [reuse_domain_id]) + .await + .assert_destroyed(&[reuse_domain_id]); + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; + account.registry_destroy_all(ObjectType::AcmeProvider).await; + } + + // Cleanup + account + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::CertificateManagement: CertificateManagement::Manual, + }), + ) + .await; + account + .registry_destroy( + ObjectType::Domain, + [tls_domain_id, dns_domain_id, persist_domain_id], + ) + .await + .assert_destroyed(&[tls_domain_id, dns_domain_id, persist_domain_id]); + account.registry_destroy_all(ObjectType::DnsServer).await; + account.registry_destroy_all(ObjectType::AcmeProvider).await; + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; +} + +const ISSUER_ABC_CERT: &str = r#"-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIURV4DMcpCSV95vODPEDWtzZ0XTjIwDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNSXNzdWVyIEFCQyBDQTAeFw0yNjA0MDMxMjQ1MjVaFw0y +NzA0MDMxMjQ1MjVaMBgxFjAUBgNVBAMMDUlzc3VlciBBQkMgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+iuw9/2hAtmt/1+K26N/XNuWRaUvJFfqs +cV5ZXzcRywXvamHivbL7OcVf96D9y67vh+beYReYo4N+ObtWJRA+5+SeBjmfEdDf +sgLn5lABvzQmFUDBIbGLN9xjYSLYcfTpN0Edla/mRJf70fxzniTFUbrtnEZ4G19Y +oDVb9V9hyTG35ak+mm20boIJkgbTW4G1xD/Q3eaWKXeKNLDxBI3wBWg1xGpMB58l +z3IiHRUtzzE5V5jtSy0oQ4+VR0u9WJdYhPqxMNzixuzEeMveB2Xd+Mf4FuvQy+wg +xU2Sb1ZqnK14+vGNAbA7mHIBvAfUMZSuYnCIGvr37XpjMBc7nm+JAgMBAAGjcDBu +MB0GA1UdDgQWBBRW3foBUlBYWVKpuJTo175EYhv7mDAfBgNVHSMEGDAWgBRW3foB +UlBYWVKpuJTo175EYhv7mDAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEG1h +aWwuZXhhbXBsZS5vcmcwDQYJKoZIhvcNAQELBQADggEBAANV8NOesHrSbqtqkrXW +nIfriEr5a7mVW8FIsyhDxMTOeRjkM+8nFFsjNvTe3HDvF8zDGPmCMKuxQHQ+8NAA +CKjcQEkv5PBb8gMRRQUexSPJF1hrqFA/cQn+lVnv6eZ2r/K7NlM80otvZIRtJbWi +1hlwE2EBEq9tWgrPUEjStlYzO5rAmxM2/yprbzYMiL0g4d8VIseVaQl9C/M00VLU +r9fw/Rz43kBGcDE5T7Gb2T8pUmZhhZykADglgU8MrPp6VD2oOTF5Qxl6CMd/bG+B +YyEBkY27+hfdf68rIrjOJJ518/gYKGVVHP3FDWPlus4hURn+g85CKu4p3a3TPbl3 +VWw= +-----END CERTIFICATE----- +"#; + +const ISSUER_ABC_PK: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC+iuw9/2hAtmt/ +1+K26N/XNuWRaUvJFfqscV5ZXzcRywXvamHivbL7OcVf96D9y67vh+beYReYo4N+ +ObtWJRA+5+SeBjmfEdDfsgLn5lABvzQmFUDBIbGLN9xjYSLYcfTpN0Edla/mRJf7 +0fxzniTFUbrtnEZ4G19YoDVb9V9hyTG35ak+mm20boIJkgbTW4G1xD/Q3eaWKXeK +NLDxBI3wBWg1xGpMB58lz3IiHRUtzzE5V5jtSy0oQ4+VR0u9WJdYhPqxMNzixuzE +eMveB2Xd+Mf4FuvQy+wgxU2Sb1ZqnK14+vGNAbA7mHIBvAfUMZSuYnCIGvr37Xpj +MBc7nm+JAgMBAAECggEAAOWJGddxOuWVbbPB+T+6NsgU9Ry/1zmT7lzgEJrP6yKN +QqJC15YgAgW0dwdkmd5HdxDfr2xHCkFNS2yDn44wBaILOBy3vntqllAITxxohAtN +io3oQzT3fU5UBeoftWOIv1JXWSL4SuS1hjyVVRIXMEbxSX4GKLl1G3Ae/LrmCffu +IFpQdidxY/ZpCXLqWLK3ysmA1Sq0BmJeIB3EwsrwJn/OK6nJC8N35bTpOx5ZVnUL +CZGQFmUZRpSFs+wAWy0f0mGrMOWVjN0nUZUj52ZZoyZ1sxc7DLxDGoqO72Mia+/3 +xtAOwq4kkonCEVRNGDOOOiVOSufz6dGv4dJ2WYCvJQKBgQD8XtHu/Ro6QOPDb2k8 +ovHnlCAe0cFk8kNlQhsKVJ5ybDWUJPOxjfgv8LudUSExdMtryus9KkGHqJ5pMLzC +x/Y3492pQpYY303lSo4crUWt/W6BUhWIfeA2827eQRTbccz00Evy9NugK8RCUJTO +Ek1BKUVWkugFEria5XETFNiZ5QKBgQDBSHb9q1SGIDE9yQLdEZ2FjWYvrcKqa792 +lwxw/QAzDBNXZbsN2PAkYbyDltbtOOvDBrHmGWR2EECAW5ifo5rhmakgKfshzMDj +w2piXz1QhzFuPolozp10iE7GpKH/s0NI5RA8CFLMKy9wsbTw8VmyqtOg6XZ3yhXX ++6MQodaU1QKBgQCF0MlEDZSgmtOqRyLn8gaOom49qT8AhazSvjCUU7YIOfRW7xkA +ZqTY1q7EhcYx8RoDt/7v2b4RbolAgYU1Ss31aK+aFiJ8Ybtt/xBHiGDQFvdHPv0H ++KawvHdnBd9HVJo2nVQIKWljDpHsD8o3UmEAUh/f/dllBz43c713PrBzOQKBgQCG +wLnM0zVDqZALDmiDrfNPmCxlE1TDsgkzac3PvGP2MvYNGazW06dhBg8DAxfnHacp +OjKvRIbI1T3S/4khy1OA87t45Cvk/baBVM2HtfSufwLUZJ8yRdJ620loroEPH3DK +koDGCduH4pfZjtuim/G4YebXqczhaS/fe93NC7fp3QKBgQCu+Mw7NPuNI8og453k +jsxBVlh52xvw9Y26OTFfuwBB05iDOLK6Qh+VpQEhI26uSESgwjy0m+VEev4uQYPD +5kPQpyO84CcbIsdfsJysbA48sn5Wzg1MGYJUc97eKu70cUs3yXQsuU6cs3M4C2Sf +j+UnghyPLhlHVTL/Xw60mZc+xw== +-----END PRIVATE KEY----- +"#; + +const ISSUER_XYZ_CERT: &str = r#"-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIUGDz02vh30maG1BceejuWOSUO6ZQwDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNSXNzdWVyIFhZWiBDQTAeFw0yNjA0MDMxMjQ3MzRaFw0y +NzA0MDMxMjQ3MzRaMBgxFjAUBgNVBAMMDUlzc3VlciBYWVogQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBUPhu/4n3ZMRUGLToU/0iUbXk6e7yJTvv +RJrrn5FFGrulxGcdZaKdmh5ZBiQu15xjXz7IZBmkXiyUy/4LMznvAC8OBeSut1pZ +f8D3Jox7PRAMPuTfUh9C9qBMFhDj+pXOd/fHy4JgJ22rDQQsCLV8N0JAkBEEvRnF +GDeriSWIReUbluRVblgR2jeVXwkvGeiNcrlbE3+zAPKX4JXmLJYgfFwjjZyvCo7L +P/tqLdR31bxpjtrpY3VjbpsqMh3qiLhsfzxBwy3vQCBzQ77thjUU77Ixrhp0dkY1 +DonTDuIxvvMNLZkX+EgonuGgtNwolWoOb4FNKJINdYW8JtknVnT/AgMBAAGjcDBu +MB0GA1UdDgQWBBTOZ3eOed6PrLVb0iysnnGcEm0ylzAfBgNVHSMEGDAWgBTOZ3eO +ed6PrLVb0iysnnGcEm0ylzAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEG1h +aWwuZXhhbXBsZS5vcmcwDQYJKoZIhvcNAQELBQADggEBAI9aZDT21yXxl67sDHSj +IGGAqhcpfNQdqCAvNbYdeiXTHZE7SHndF2efMRj1iJ9lAsYalFi0jbNCoU/KVDv8 +V7ApxhNlxl5kHmtKBJJLxXyklX+Fic10nUQY5EqU351Rn6Lapp2jn5DmXxlrsy+x +CSYVSU8l3ag3Wzdnl2rua6PlLYiFJIKsmqyUBNhvuXVsRkf+y2BVLglOTc3cXdBh +iCgOds4SjP8DCBmFqeBIKrcuzXeWU7WQL6XruuQyV3QGghEw3YxQpbbsbDtHFgZx +kKO1vQmsym1pUFXV2Drg03FA1oxXCBiRJMbNWSsFZhllKlPpbkV4+IqTMd3u9+JN +Fhk= +-----END CERTIFICATE----- +"#; + +const ISSUER_XYZ_PK: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDBUPhu/4n3ZMRU +GLToU/0iUbXk6e7yJTvvRJrrn5FFGrulxGcdZaKdmh5ZBiQu15xjXz7IZBmkXiyU +y/4LMznvAC8OBeSut1pZf8D3Jox7PRAMPuTfUh9C9qBMFhDj+pXOd/fHy4JgJ22r +DQQsCLV8N0JAkBEEvRnFGDeriSWIReUbluRVblgR2jeVXwkvGeiNcrlbE3+zAPKX +4JXmLJYgfFwjjZyvCo7LP/tqLdR31bxpjtrpY3VjbpsqMh3qiLhsfzxBwy3vQCBz +Q77thjUU77Ixrhp0dkY1DonTDuIxvvMNLZkX+EgonuGgtNwolWoOb4FNKJINdYW8 +JtknVnT/AgMBAAECggEAI06ICTHHv3TafKeEhvgr/9Qfnf7xwqz1PNZxWv5qOE5R +Hk34LUDOSe2HhGfgPOPpLqcLHutVWZVPnB+DtwT0nEeS0INYCGb5a+Yu1pTmTG3T +HAFyqKzlg8Pqc+sFy7WNHcgAa6+qEKiy2W5HqEkw6E5pXcRSL2TgK4SoSj6CqdgU +2O44I1FD4+zQNJHWKnKGAHr4ZyTdqLOCogWPH0cNHmF3o8sXDDnuRNn94BN/9EE+ +QHEkbxqVoOUmuVSqyj2FNThQmyth4LMTQbGOqBisokHyDdfCxXrXa2IRVtWGNZKb +u73LCTqjUqiV1I/oYnoSL3qDFIX9fQt8CaZc8kKZzQKBgQDmHJk747QUyOs0hSjg +qYz2OfR7RsrX0N/hgi2AaUPyqD1pagCEWAJioB/RY42nsAqW4n27z/BLntUTgKzY +xLO9Rcu+xAk5QOYiTnOSHyqtcYHDYfTdqj8QlJBD5L3whsWLs5u40wSCamEnEEP/ +yQXvpRYFsOisZ6FxA4EkDz9d0wKBgQDXEKJftDPeap1qRatQpk1BT5GvuGWDEtt/ +AZHSr9x4nvStzt6pEw01naP2AnJyh+z4mUqdP9ycNsWePL6/OFNfbBfQGfa3m1QE +DT94FmUYjVsKdaR/6PgAE/n0FRLYCxgsju56lMG2oxdm+lu7Hi3h9X+2TCP26Cm3 +Kyt5hBuUpQKBgCWSs96YjpX6PJWFzKfqZ27pBad/Zq2YmIXM6LsX9RVRLT3iJhV1 +/WW3OZrKA55G22PJtbgN+vGODMKzdwWqEKMuG1eZ0Nu9YhZl8u8AEcriRsrtWrs7 +CjMd3vSHDBCTIPxvplSmeA0Ha7eiK0g/va1kqlThjwxfm3fFl7eYmcMrAoGAU2Q7 +0ehKBIBKsZ56IzeY1S5JS12w3vY87i5poMxYLN1V+t8wL1cX1CZgIVApmIdkN7EY +4YiVvmzui8D6JaNtkJ6VTTgEFoXAOiH47lOgt7h4CMI9Guv23fDhBuf0+piysvRp +PFbaFjt/I4sIbrHxEKDk+IblmyPCqSewH9o955ECgYEAyX4UO6jcBCrSVGiMBnBm +RU1EcAdWxAwu4LRvhxJFBGBMHI36ECrMmPmbVvyzTfGSQfLo8Ov2z0T3Ec2jR86V +0Wu4jn4vxDVj4OD+oGScc3JuaVQJ5Mj7KP2HdXw9Z7pQ9LQYCzmwLoWOq3iYDrsJ +RcWUqLOFB3faKiFxfcjnOrU= +-----END PRIVATE KEY----- +"#; + +const ISSUER_123_CERT: &str = r#"-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIUKcxy6IK4xfMTqdlbvTi8zvDgaz0wDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNSXNzdWVyIDEyMyBDQTAeFw0yNjA0MDMxMjQ4MDZaFw0y +NzA0MDMxMjQ4MDZaMBgxFjAUBgNVBAMMDUlzc3VlciAxMjMgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCnXWNMF4m8e2VPVlM8DWuaZPaBx1vqWQCG +1MjInefh5FIAtimCl43cRd7Iolb8k+qdWm5xfWaOsh6fDVJqF9DLXweVOk58tYMr +Dg2buT7W5SfUS1fcg+rdv/0IRQc9LV9l4sLsW9L1Lv/2nltDnEVwypOxyFLHQdhb +SdCxQky8oPWkb6BQlK4Tths0iwY+4vQDpzv8WRY0h0nlLN54OQcCeOtUUjkEu6L5 +6goUM8SVlH1aQmBPXEIaUXKa36IevRirLAXw7d6BLijWwMJU2EXvrl+xVgomf0Md +pRRLaPjLS5vPnoJ7ZbQOA3AMKoIwwXE50xckirINUBXK7jocwXBzAgMBAAGjcDBu +MB0GA1UdDgQWBBRKWPP6BQ3xNVaJ6Rjjy5E27bUW9TAfBgNVHSMEGDAWgBRKWPP6 +BQ3xNVaJ6Rjjy5E27bUW9TAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEG1h +aWwuZXhhbXBsZS5vcmcwDQYJKoZIhvcNAQELBQADggEBAI6UqptJYfQ5bWn3SAU1 +sT2GTaOlQDPHI08v3fAGomzL5COsx9WgdgBuO3yjcNPXrlyTCdpXLVgQDeIbfoHf +B2FLSBXgtkDFCwTX5P+D4odif7zt8Fr0Zbgo7NEi+TJvQRron3bvbY78JUOujIj2 +MNJRntcl1cp7aZSNaNhCogFxY5t01fqJuaW//QdfApvOXB6mWjONGo5p01jkK2HA +AoiHdZMGGnTdb2Usx6ZkENwgwKHY3TMXMQvUysH5STUrw4/eBfNLnCZgK8GRK8nC +kqPwO96ZdZ9EmzOMxUCCVrFyXwThnNu7aHbWKDq1KQsRvHMWjCwn0gxoqZixoA8K +gpg= +-----END CERTIFICATE----- +"#; + +const ISSUER_123_PK: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCnXWNMF4m8e2VP +VlM8DWuaZPaBx1vqWQCG1MjInefh5FIAtimCl43cRd7Iolb8k+qdWm5xfWaOsh6f +DVJqF9DLXweVOk58tYMrDg2buT7W5SfUS1fcg+rdv/0IRQc9LV9l4sLsW9L1Lv/2 +nltDnEVwypOxyFLHQdhbSdCxQky8oPWkb6BQlK4Tths0iwY+4vQDpzv8WRY0h0nl +LN54OQcCeOtUUjkEu6L56goUM8SVlH1aQmBPXEIaUXKa36IevRirLAXw7d6BLijW +wMJU2EXvrl+xVgomf0MdpRRLaPjLS5vPnoJ7ZbQOA3AMKoIwwXE50xckirINUBXK +7jocwXBzAgMBAAECggEAEre5jr2FLHy6cFyNs4Ymth47iXkj2Po6Oloa2ID8eYGB +AE5fJxlPBscTqumpA9eBIjcGag/lw+i8zvn88RuXwN7waKqcM4tTT6HjOLrHgd4g +Dbida26fekxp/p5FuHxWEn2BYlDEr6Ihg1HjkBSumXY2fWgThfBhw5fmTKTHE0NY +c2Q57UOw94lkPCPrmP25wEh+4DwUNIuV3wKQq9t8Qq5XkOlFd9V4957mz9qLH4G3 +oXUU/yQToEKPbA62WqAUbE26nnJ03Xr+r2+waDR+VfaNtAWKdoQAtgI7Ge02obHZ +jO5T8/pFVchJxNjKKRp2hdPYn6y7JZOAsXqEt5eawQKBgQDr6ps/AW4WyK6o2g/t +NstPtXkBSLrwesrpg7AiEUh3eqximjtOF7ksvJ1kqhkzTvVfB2/h8EO8eJ4SsxLD +VsHUTduWXyASeh392RuMsvHigfTi3//lsaYSSRBEsruZAXjqjWtAh5Ap76F2BpcB +wQNCb1D6NyILshMyepXnEGNbswKBgQC1nND2rSLYfpY4X3hUiI+yNMef6b1AlJAg +X4Qd4xVetrPrTO4Fn8UVgUFQiDdZMsDjbg7idyh975UIq5kp1AikfRfAXilCw+uT +ZBvu2xjScls2fScu4Vn1wBky/n83NLx8zi0CepN2qYvjPpNYIlJ2Ych+SWCoXxeT +fPCZzqg4QQKBgQCKdecn028JcD8SWul+D+rDnX6ngkg1W9w7sU5usDYX6afDN0IX +U1UbLJgzvKGNu4nHfFXuBVW8CA6+attYSlL4h7mZR7tLHOD9W68PpPbSOfPANDe8 +V8dgdAFYUI5J/tM41kdcWDQEaOAapUN7hAylsS+Vq0YQFzOtLMVOGBA4gQKBgQCj +COjqWVkrws/2QXZTZNii4RDH9NwpanTMKxL+hYn8ocV4mXIf6GLTwFozAmW1lINm +Z7nDAbd+/qHqy6lOzIMJryawUZd20Uzc3wTYcyWgXnqVutp/Elxg6hd1GNR5acU/ +wRLU49cXsnLbCKTbfMxMa9HB1PuJivwuMf4IBWYsQQKBgQC5KNAWEHWrnxiNeCS0 +4VzOCTCUEQ+Axq7g5bFKzJqRfHDXlDsNaiK6q4vGDp4HjFawPoyrmnR5/OE/O8PV +0OstcrM2EBik9YbORVpAJ2yl90ZTKevSxQ9+n2Ip/pwLz/oRywxF/dYYTbwYReO5 +9MBumBf1lgiJZSsloOKWQvLchg== +-----END PRIVATE KEY----- +"#; + +async fn pebble_root_common_names() -> Vec { + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to build HTTP client"); + let mut common_names = Vec::new(); + for index in 0.. { + let response = client + .get(format!("https://localhost:15000/roots/{index}")) + .send() + .await + .expect("Failed to query Pebble management API"); + if !response.status().is_success() { + break; + } + let pem = response.text().await.expect("Failed to read Pebble root"); + match subject_common_name(&pem) { + Some(common_name) => common_names.push(common_name), + None => break, + } + } + common_names +} + +fn subject_common_name(pem: &str) -> Option { + let block = Pem::iter_from_buffer(pem.as_bytes()).next()?.ok()?; + let cert = block.parse_x509().ok()?; + cert.subject() + .iter_common_name() + .filter_map(|cn| cn.as_str().ok()) + .next() + .map(str::to_string) +} + +fn leaf_public_key(chain: &str) -> Vec { + let block = Pem::iter_from_buffer(chain.as_bytes()) + .next() + .expect("certificate chain should contain a leaf") + .expect("valid PEM block"); + let cert = block.parse_x509().expect("valid leaf certificate"); + cert.public_key().raw.to_vec() +} + +fn top_issuer_common_name(chain: &str) -> Option { + let block = Pem::iter_from_buffer(chain.as_bytes()) + .filter_map(Result::ok) + .last()?; + let cert = block.parse_x509().ok()?; + cert.issuer() + .iter_common_name() + .filter_map(|cn| cn.as_str().ok()) + .next() + .map(str::to_string) +} diff --git a/tests/src/automation/dkim.rs b/tests/src/automation/dkim.rs new file mode 100644 index 0000000..4963a06 --- /dev/null +++ b/tests/src/automation/dkim.rs @@ -0,0 +1,414 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{account::Account, server::TestServer}; +use ahash::AHashSet; +use common::{config::smtp::auth::Dkim1Signer, network::dns::update::DNS_RECORDS}; +use dns_update::{DnsRecord, NamedDnsRecord}; +use registry::{ + schema::{ + enums::{DkimRotationStage, DnsRecordType}, + prelude::ObjectType, + structs::{ + CertificateManagement, Dkim1Signature, DkimManagement, DkimManagementProperties, + DkimSignature, DnsManagement, DnsManagementProperties, DnsServer, DnsServerCloudflare, + Domain, SecretKey, SecretKeyValue, + }, + }, + types::duration::Duration, +}; +use store::write::now; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running DKIM Management tests..."); + let account = test.account("admin@example.org"); + DNS_RECORDS.lock().unwrap().clear(); + + // Create test In Memory DNS servers + let dns_server_id = account + .registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare { + secret: SecretKey::Value(SecretKeyValue { + secret: "test@memory.org".into(), + }), + description: "In-memory DNS server".to_string(), + ..Default::default() + })) + .await; + account.dkim_signatures().await.assert_total(0, 0); + + // Create a domain and trigger DKIM key generation + let now = now(); + let selector_rsa = format!("dummy-v1-rsa-{}", now); + let selector_ed = format!("dummy-v1-ed25519-{}", now); + let domain_id = account + .registry_create_object(Domain { + name: "dkim.org".to_string(), + certificate_management: CertificateManagement::Manual, + dkim_management: DkimManagement::Automatic(DkimManagementProperties { + delete_after: Duration::from_millis(2_000), + retire_after: Duration::from_millis(2_000), + rotate_after: Duration::from_millis(2_000), + selector_template: "dummy-v{version}-{algorithm}-{epoch}".to_string(), + ..Default::default() + }), + dns_management: DnsManagement::Automatic(DnsManagementProperties { + dns_server_id, + ..Default::default() + }), + ..Default::default() + }) + .await; + + // Make sure two DKIM keys were created + let rot1_signatures = account + .wait_for_dkim_signatures(&DkimSignatures::default(), 2) + .await + .assert_total(1, 1) + .assert_stage_count(DkimRotationStage::Active, 2); + assert_eq!( + rot1_signatures.v1_rsa[0].selector, selector_rsa, + "Unexpected RSA selector: {}", + rot1_signatures.v1_rsa[0].selector + ); + assert_eq!( + rot1_signatures.v1_ed25519[0].selector, selector_ed, + "Unexpected Ed25519 selector: {}", + rot1_signatures.v1_ed25519[0].selector + ); + assert_eq!( + rot1_signatures.v1_rsa[0] + .next_transition_at + .unwrap() + .timestamp() + - rot1_signatures.v1_rsa[0].created_at.timestamp(), + 2 + ); + test.assert_has_signers( + "dkim.org", + &[ + &rot1_signatures.v1_rsa[0].selector, + &rot1_signatures.v1_ed25519[0].selector, + ], + ) + .await; + + // Make sure the DNS records were created + let records = DNS_RECORDS.lock().unwrap().clone(); + assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]); + + // Expect a rotation to happen and new keys to be created + let rot2_signatures = account + .wait_for_dkim_signatures(&rot1_signatures, 4) + .await + .assert_total(2, 2) + .assert_stage_count(DkimRotationStage::Active, 2) + .assert_stage_count(DkimRotationStage::Retiring, 2); + + // Make sure both old and new keys have DNS records + let records = DNS_RECORDS.lock().unwrap().clone(); + assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]); + assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]); + + // Make sure only the new keys are being used for signing + assert_ne!( + rot1_signatures.v1_rsa[0].selector, rot2_signatures.v1_rsa[0].selector, + "Expected a new RSA selector to be generated during rotation" + ); + assert_ne!( + rot1_signatures.v1_ed25519[0].selector, rot2_signatures.v1_ed25519[0].selector, + "Expected a new Ed25519 selector to be generated during rotation" + ); + test.assert_has_signers( + "dkim.org", + &[ + &rot2_signatures.v1_rsa[0].selector, + &rot2_signatures.v1_ed25519[0].selector, + ], + ) + .await; + + // Wait until the previous key is retired + let rot3_signatures = account + .wait_for_dkim_signatures(&rot2_signatures, 6) + .await + .assert_total(3, 3) + .assert_stage_count(DkimRotationStage::Active, 2) + .assert_stage_count(DkimRotationStage::Retiring, 2) + .assert_stage_count(DkimRotationStage::Retired, 2); + + // Make sure the old records were deleted + let records = DNS_RECORDS.lock().unwrap().clone(); + assert_key_has_no_dns_record(&records, &rot1_signatures.v1_rsa[0]); + assert_key_has_no_dns_record(&records, &rot1_signatures.v1_ed25519[0]); + assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]); + assert_key_has_dns_record(&records, &rot3_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot3_signatures.v1_ed25519[0]); + + // Make sure the DNS management task does not republish the retired keys + let (published, zone_file) = test.published_dkim_records(domain_id).await; + assert_key_has_no_dns_record(&published, &rot1_signatures.v1_rsa[0]); + assert_key_has_no_dns_record(&published, &rot1_signatures.v1_ed25519[0]); + assert_key_has_dns_record(&published, &rot3_signatures.v1_rsa[0]); + assert_key_has_dns_record(&published, &rot3_signatures.v1_ed25519[0]); + assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_rsa[0]); + assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_ed25519[0]); + + // Make sure only the new keys are being used for signing + assert_ne!( + rot2_signatures.v1_rsa[0].selector, rot3_signatures.v1_rsa[0].selector, + "Expected a new RSA selector to be generated during rotation" + ); + assert_ne!( + rot2_signatures.v1_ed25519[0].selector, rot3_signatures.v1_ed25519[0].selector, + "Expected a new Ed25519 selector to be generated during rotation" + ); + test.assert_has_signers( + "dkim.org", + &[ + &rot3_signatures.v1_rsa[0].selector, + &rot3_signatures.v1_ed25519[0].selector, + ], + ) + .await; + + // Wait until the first key is deleted + let rot4_signatures = account + .wait_for_dkim_signatures(&rot3_signatures, 6) + .await + .assert_total(3, 3) + .assert_stage_count(DkimRotationStage::Active, 2) + .assert_stage_count(DkimRotationStage::Retiring, 2) + .assert_stage_count(DkimRotationStage::Retired, 2) + .assert_selector_missing(&rot1_signatures.v1_rsa[0].selector) + .assert_selector_missing(&rot1_signatures.v1_ed25519[0].selector); + + // Make sure the old records were updated + let records = DNS_RECORDS.lock().unwrap().clone(); + assert_key_has_dns_record(&records, &rot4_signatures.v1_rsa[0]); + assert_key_has_dns_record(&records, &rot4_signatures.v1_ed25519[0]); + assert_key_has_no_dns_record(&records, &rot2_signatures.v1_rsa[0]); + assert_key_has_no_dns_record(&records, &rot2_signatures.v1_ed25519[0]); + + // Make sure the DNS management task does not republish the retired keys + let (published, zone_file) = test.published_dkim_records(domain_id).await; + assert_key_has_no_dns_record(&published, &rot2_signatures.v1_rsa[0]); + assert_key_has_no_dns_record(&published, &rot2_signatures.v1_ed25519[0]); + assert_key_has_dns_record(&published, &rot4_signatures.v1_rsa[0]); + assert_key_has_dns_record(&published, &rot4_signatures.v1_ed25519[0]); + assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_rsa[0]); + assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_ed25519[0]); + + // Make sure only the new keys are being used for signing + assert_ne!( + rot3_signatures.v1_rsa[0].selector, rot4_signatures.v1_rsa[0].selector, + "Expected a new RSA selector to be generated during rotation" + ); + assert_ne!( + rot3_signatures.v1_ed25519[0].selector, rot4_signatures.v1_ed25519[0].selector, + "Expected a new Ed25519 selector to be generated during rotation" + ); + test.assert_has_signers( + "dkim.org", + &[ + &rot4_signatures.v1_rsa[0].selector, + &rot4_signatures.v1_ed25519[0].selector, + ], + ) + .await; + + // Cleanup + account + .registry_destroy_all(ObjectType::DkimSignature) + .await; + account + .registry_destroy(ObjectType::Domain, [domain_id]) + .await + .assert_destroyed(&[domain_id]); + account.registry_destroy_all(ObjectType::DnsServer).await; +} + +#[derive(Debug, PartialEq, Eq, Default)] +struct DkimSignatures { + v1_rsa: Vec, + v1_ed25519: Vec, +} + +impl Account { + async fn wait_for_dkim_signatures( + &self, + last_signatures: &DkimSignatures, + expected_total: usize, + ) -> DkimSignatures { + let mut signatures = self.dkim_signatures().await; + for _ in 0..10 { + if signatures != *last_signatures + && signatures.v1_rsa.len() + signatures.v1_ed25519.len() == expected_total + { + return signatures; + } + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + signatures = self.dkim_signatures().await; + } + panic!( + "DKIM signatures did not change after waiting (total {}, expected {}): {:#?}", + signatures.v1_rsa.len() + signatures.v1_ed25519.len(), + expected_total, + signatures + ); + } + + async fn dkim_signatures(&self) -> DkimSignatures { + let signatures = self.registry_get_all::().await; + let mut v1_rsa = Vec::new(); + let mut v1_ed25519 = Vec::new(); + for (_, signature) in signatures { + match signature { + DkimSignature::Dkim1RsaSha256(sig) => v1_rsa.push(sig), + DkimSignature::Dkim1Ed25519Sha256(sig) => v1_ed25519.push(sig), + DkimSignature::Dkim2Ed25519Sha256(_) | DkimSignature::Dkim2RsaSha256(_) => todo!(), + } + } + + // Sort in descending order of creation time + v1_rsa.sort_by_key(|s| std::cmp::Reverse(s.created_at)); + v1_ed25519.sort_by_key(|s| std::cmp::Reverse(s.created_at)); + + DkimSignatures { v1_rsa, v1_ed25519 } + } +} + +impl DkimSignatures { + fn assert_stage_count(self, stage: DkimRotationStage, count: usize) -> Self { + let actual_count = self.v1_rsa.iter().filter(|s| s.stage == stage).count() + + self.v1_ed25519.iter().filter(|s| s.stage == stage).count(); + assert_eq!( + actual_count, count, + "Expected {} signatures in stage {:?}, found {}: {:#?}", + count, stage, actual_count, self + ); + self + } + + fn assert_total(self, total_rsa: usize, total_ed25519: usize) -> Self { + assert_eq!( + self.v1_rsa.len(), + total_rsa, + "Expected {} RSA signatures, found {:?}", + total_rsa, + self.v1_rsa + ); + assert_eq!( + self.v1_ed25519.len(), + total_ed25519, + "Expected {} Ed25519 signatures, found {:?}", + total_ed25519, + self.v1_ed25519 + ); + self + } + + fn assert_selector_missing(self, selector: &str) -> Self { + assert!( + !self.v1_rsa.iter().any(|s| s.selector == selector) + && !self.v1_ed25519.iter().any(|s| s.selector == selector), + "Selector {} was unexpectedly found in signatures: {:#?}", + selector, + self + ); + self + } +} + +impl TestServer { + async fn published_dkim_records(&self, domain_id: Id) -> (Vec, String) { + let domain = self + .server + .registry() + .object::(domain_id) + .await + .unwrap() + .expect("Domain not found"); + let records = self + .server + .build_dns_records(domain_id, &domain, &[DnsRecordType::Dkim]) + .await + .unwrap(); + let zone_file = self + .server + .build_bind_dns_records(domain_id, &domain) + .await + .unwrap(); + + (records, zone_file) + } + + async fn assert_has_signers(&self, domain: &str, selectors: &[&str]) { + assert_eq!( + self.server + .dkim_signers(domain) + .await + .unwrap() + .unwrap_or_else(|| panic!("No signatures found: {:?}", selectors)) + .dkim1 + .iter() + .map(|s| match s { + Dkim1Signer::RsaSha256(s) => s.template.s.as_str(), + Dkim1Signer::Ed25519Sha256(s) => s.template.s.as_str(), + }) + .collect::>(), + selectors.iter().copied().collect::>() + ); + } +} + +fn assert_key_has_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) { + let expected = format!("{}._domainkey.dkim.org.", key.selector); + for record in records { + if record.name == expected + && let DnsRecord::TXT(txt) = &record.record + && ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p=")) + || (key.selector.contains("ed25519") + && txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p="))) + { + return; + } + } + panic!( + "No DNS record found for DKIM key with selector {}, records: {:#?}", + key.selector, records + ); +} + +fn assert_zone_file_omits_key(zone_file: &str, key: &Dkim1Signature) { + assert!( + !zone_file.contains(&key.selector), + "Unexpected zone file entry for DKIM key with selector {}, zone file: {}", + key.selector, + zone_file + ); +} + +fn assert_key_has_no_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) { + let expected = format!("{}._domainkey.dkim.org.", key.selector); + for record in records { + if record.name == expected + && let DnsRecord::TXT(txt) = &record.record + && ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p=")) + || (key.selector.contains("ed25519") + && txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p="))) + { + panic!( + "Unexpected DNS record found for DKIM key with selector {}, records: {:#?}", + key.selector, records + ); + } + } +} diff --git a/tests/src/automation/dns.rs b/tests/src/automation/dns.rs new file mode 100644 index 0000000..4b06b69 --- /dev/null +++ b/tests/src/automation/dns.rs @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use common::network::dns::update::DNS_RECORDS; +use dns_update::{CAARecord, bind::BindSerializer}; +use registry::{ + schema::{ + enums::{AcmeChallengeType, DnsRecordType}, + prelude::{ObjectType, Property}, + structs::{ + AcmeProvider, CertificateManagement, CertificateManagementProperties, DkimManagement, + DkimManagementProperties, DnsManagement, DnsManagementProperties, DnsServer, + DnsServerCloudflare, SecretKey, SecretKeyValue, + }, + }, + types::map::Map, +}; +use serde_json::json; + +const EXPECTED_ZONE: &str = r#"example.org. IN CAA 0 iodef "mailto:postmaster@example.org" +example.org. IN CAA 0 issue "pebble.letsencrypt.org" +autoconfig.example.org. IN CNAME mail.example.org. +autodiscover.example.org. IN CNAME mail.example.org. +mta-sts.example.org. IN CNAME mail.example.org. +ua-auto-config.example.org. IN CNAME mail.example.org. +example.org. IN MX 10 mx1.example.org. +example.org. IN MX 20 mx2.example.org. +_caldavs._tcp.example.org. IN SRV 0 1 443 mail.example.org. +_carddavs._tcp.example.org. IN SRV 0 1 443 mail.example.org. +_imaps._tcp.example.org. IN SRV 0 1 993 imap.example.org. +_jmap._tcp.example.org. IN SRV 0 1 443 mail.example.org. +_pop3s._tcp.example.org. IN SRV 0 1 995 pop3.example.org. +_submissions._tcp.example.org. IN SRV 0 1 465 smtp.example.org. +_25._tcp.mx1.example.org. IN TLSA 3 1 1 +_25._tcp.mx1.example.org. IN TLSA 2 1 1 +_25._tcp.mx2.example.org. IN TLSA 3 1 1 +_25._tcp.mx2.example.org. IN TLSA 2 1 1 +_443._tcp.autoconfig.example.org. IN TLSA 3 1 1 +_443._tcp.autoconfig.example.org. IN TLSA 2 1 1 +_443._tcp.autodiscover.example.org. IN TLSA 3 1 1 +_443._tcp.autodiscover.example.org. IN TLSA 2 1 1 +_443._tcp.mail.example.org. IN TLSA 3 1 1 +_443._tcp.mail.example.org. IN TLSA 2 1 1 +_443._tcp.mta-sts.example.org. IN TLSA 3 1 1 +_443._tcp.mta-sts.example.org. IN TLSA 2 1 1 +_443._tcp.ua-auto-config.example.org. IN TLSA 3 1 1 +_443._tcp.ua-auto-config.example.org. IN TLSA 2 1 1 +_465._tcp.smtp.example.org. IN TLSA 3 1 1 +_465._tcp.smtp.example.org. IN TLSA 2 1 1 +_993._tcp.imap.example.org. IN TLSA 3 1 1 +_993._tcp.imap.example.org. IN TLSA 2 1 1 +_995._tcp.pop3.example.org. IN TLSA 3 1 1 +_995._tcp.pop3.example.org. IN TLSA 2 1 1 +_dmarc.example.org. IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@example.org" +_mta-sts.example.org. IN TXT "v=STSv1; id=12942536112359691423" +_smtp._tls.example.org. IN TXT "v=TLSRPTv1; rua=mailto:postmaster@example.org" +_ua-auto-config.example.org. IN TXT "v=UAAC1; a=sha256; d=9X2mMgWAc10oSPuRKZSFBwPXEQpnxkS7SXPO8PC7euM=" +_validation-persist.example.org. IN TXT "pebble.letsencrypt.org; accounturi=REDACTED" +dummy-v1-ed25519._domainkey.example.org. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=REDACTED" +dummy-v1-rsa._domainkey.example.org. IN TXT "v=DKIM1; k=rsa; h=sha256; p=REDACTED" +example.org. IN TXT "v=spf1 mx -all" +mx1.example.org. IN TXT "v=spf1 a -all" +mx2.example.org. IN TXT "v=spf1 a -all" +"#; + +pub async fn test(test: &TestServer) { + println!("Running DNS Management tests..."); + let account = test.account("admin@example.org"); + DNS_RECORDS.lock().unwrap().clear(); + let domain_id = account.find_or_create_domain("example.org").await; + account + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::CertificateManagement: CertificateManagement::Manual, + Property::DnsManagement: DnsManagement::Manual, + Property::DkimManagement: DkimManagement::Manual, + }), + ) + .await; + + // Create test In Memory DNS servers + let dns_server_id = account + .registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare { + secret: SecretKey::Value(SecretKeyValue { + secret: "test@memory.org".into(), + }), + description: "In-memory DNS server".to_string(), + ..Default::default() + })) + .await; + let acme_provider_id = account + .registry_create_object(AcmeProvider { + directory: "https://localhost:14000/dir".to_string(), + contact: Map::new(vec!["mailto:hello@example.org".to_string()]), + challenge_type: AcmeChallengeType::TlsAlpn01, + ..Default::default() + }) + .await; + + let cert = CertificateManagement::Automatic(CertificateManagementProperties { + acme_provider_id, + subject_alternative_names: Default::default(), + }); + let dns = DnsManagement::Automatic(DnsManagementProperties { + dns_server_id, + publish_records: Map::new(vec![ + DnsRecordType::Dkim, + DnsRecordType::Tlsa, + DnsRecordType::Spf, + DnsRecordType::Mx, + DnsRecordType::Dmarc, + DnsRecordType::Srv, + DnsRecordType::MtaSts, + DnsRecordType::TlsRpt, + DnsRecordType::Caa, + DnsRecordType::AutoConfig, + DnsRecordType::AutoConfigLegacy, + DnsRecordType::AutoDiscover, + ]), + ..Default::default() + }); + let dkim = DkimManagement::Automatic(DkimManagementProperties { + selector_template: "dummy-v{version}-{algorithm}-{epoch}".to_string(), + ..Default::default() + }); + account + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::CertificateManagement: cert, + Property::DnsManagement: dns, + Property::DkimManagement: dkim, + }), + ) + .await; + test.wait_for_tasks_skip_not_due().await; + + let mut records = DNS_RECORDS + .lock() + .unwrap() + .iter() + .map(|r| { + let mut r = r.clone(); + + if r.name.starts_with("dummy-") { + let (selector, domain) = r.name.split_once("._domainkey.").unwrap(); + let selector = selector.rsplit_once('-').unwrap().0; + r.name = format!("{selector}._domainkey.{domain}"); + } + + match &mut r.record { + dns_update::DnsRecord::TXT(r) => { + if r.starts_with("v=DKIM1;") { + *r = r.split_once("; p=").unwrap().0.to_string() + "; p=REDACTED"; + } else if r.contains("letsencrypt.org") { + *r = r.split_once("; accounturi=").unwrap().0.to_string() + + "; accounturi=REDACTED"; + } + } + dns_update::DnsRecord::TLSA(r) => { + r.cert_data.clear(); + } + dns_update::DnsRecord::CAA(CAARecord::Issue { options, .. }) => { + options.clear(); + } + _ => (), + } + r + }) + .collect::>(); + + records.sort_unstable_by_key(|r| format!("{:?}-{}-{:?}", r.record.as_type(), r.name, r.record)); + + for record in &records { + let target = match &record.record { + dns_update::DnsRecord::CNAME(target) | dns_update::DnsRecord::NS(target) => { + Some(target.as_str()) + } + dns_update::DnsRecord::MX(mx) => Some(mx.exchange.as_str()), + dns_update::DnsRecord::SRV(srv) => Some(srv.target.as_str()), + _ => None, + }; + + if let Some(target) = target { + assert!( + target.ends_with('.'), + "Published target {target:?} for {} is not fully qualified", + record.name + ); + } + } + + assert_eq!(BindSerializer::serialize(&records), EXPECTED_ZONE); + + // Cleanup + account + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::CertificateManagement: CertificateManagement::Manual, + Property::DnsManagement: DnsManagement::Manual, + Property::DkimManagement: DkimManagement::Manual, + }), + ) + .await; + account.registry_destroy_all(ObjectType::DnsServer).await; + account.registry_destroy_all(ObjectType::AcmeProvider).await; + account.registry_destroy_all(ObjectType::Certificate).await; + account.registry_destroy_all(ObjectType::Task).await; +} diff --git a/tests/src/automation/mod.rs b/tests/src/automation/mod.rs new file mode 100644 index 0000000..5eab677 --- /dev/null +++ b/tests/src/automation/mod.rs @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acme; +pub mod dkim; +pub mod dns; +pub mod rfc2136; + +use registry::{ + schema::{ + enums::{NetworkListenerProtocol, ServiceProtocol}, + prelude::Property, + structs::{MailExchanger, Service, SystemSettings}, + }, + types::list::List, +}; +use utils::map::vec_map::VecMap; + +use crate::utils::server::TestServerBuilder; + +#[tokio::test(flavor = "multi_thread")] +async fn automation_tests() { + crate::utils::containers::ensure_acme().await; + let mut test = TestServerBuilder::new("automation_tests") + .await + .with_listener(NetworkListenerProtocol::Http, "http", 8898, false) + .await + .with_default_listeners() + .await + .build() + .await; + + // Create admin account + let account = test.create_admin_account("admin@example.org").await; + account + .registry_update_setting( + SystemSettings { + mail_exchangers: List::from_iter([ + MailExchanger { + priority: 10u64, + hostname: "mx1.example.org".to_string().into(), + }, + MailExchanger { + priority: 20u64, + hostname: "mx2.example.org".to_string().into(), + }, + ]), + services: VecMap::from_iter([ + ( + ServiceProtocol::Caldav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Carddav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Imap, + Service { + cleartext: false, + hostname: "imap.example.org".to_string().into(), + }, + ), + ( + ServiceProtocol::Jmap, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Managesieve, + Service { + cleartext: false, + ..Default::default() + }, + ), + ( + ServiceProtocol::Pop3, + Service { + cleartext: false, + hostname: "pop3.example.org".to_string().into(), + }, + ), + ( + ServiceProtocol::Smtp, + Service { + cleartext: false, + hostname: "smtp.example.org".to_string().into(), + }, + ), + ( + ServiceProtocol::Webdav, + Service { + cleartext: false, + ..Default::default() + }, + ), + ]), + + ..Default::default() + }, + &[Property::MailExchangers, Property::Services], + ) + .await; + account.reload_settings().await; + test.insert_account(account); + + acme::test(&test).await; + dkim::test(&test).await; + dns::test(&test).await; + rfc2136::test(&test).await; +} diff --git a/tests/src/automation/rfc2136.rs b/tests/src/automation/rfc2136.rs new file mode 100644 index 0000000..e83ac5c --- /dev/null +++ b/tests/src/automation/rfc2136.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{net::Ipv4Addr, time::Duration as StdDuration}; + +use common::network::dns::update::DnsUpdater; +use dns_update::{DnsRecord, DnsRecordType, TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector}; +use registry::{ + schema::{ + enums::{IpProtocol, TsigAlgorithm}, + structs::{DnsServer, DnsServerTsig, SecretKey, SecretKeyValue}, + }, + types::duration::Duration, +}; + +use crate::utils::server::TestServer; + +const ZONE: &str = "stalwart.test"; +const KEY_B64: &str = "c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw"; + +pub async fn test(test: &TestServer) { + println!("Running RFC2136 (PowerDNS) tests..."); + crate::utils::containers::ensure_powerdns().await; + + let udp = DnsUpdater::build(dns_server(IpProtocol::Udp), test.server.core.clone()) + .await + .expect("Failed to build UDP RFC2136 updater"); + + // Create, replace and delete an A record over UDP (TSIG signed) + let a_name = "rfc2136-a.stalwart.test"; + udp.updater + .set_rrset( + a_name, + DnsRecordType::A, + 60, + vec![DnsRecord::A(Ipv4Addr::new(10, 0, 0, 1))], + ZONE, + ) + .await + .expect("set A"); + assert_eq!( + list(&udp, a_name, DnsRecordType::A, 1).await, + vec![DnsRecord::A(Ipv4Addr::new(10, 0, 0, 1))] + ); + + udp.updater + .set_rrset( + a_name, + DnsRecordType::A, + 60, + vec![DnsRecord::A(Ipv4Addr::new(10, 0, 0, 2))], + ZONE, + ) + .await + .expect("replace A"); + assert_eq!( + list(&udp, a_name, DnsRecordType::A, 1).await, + vec![DnsRecord::A(Ipv4Addr::new(10, 0, 0, 2))] + ); + + udp.updater + .set_rrset(a_name, DnsRecordType::A, 0, vec![], ZONE) + .await + .expect("delete A"); + assert!(list(&udp, a_name, DnsRecordType::A, 0).await.is_empty()); + + // Publish two TLSA records at the same owner in a single set_rrset call + let tlsa_name = "_25._tcp.rfc2136-tlsa.stalwart.test"; + let leaf: Vec = (0..32).collect(); + let intermediate: Vec = (32..64).collect(); + udp.updater + .set_rrset( + tlsa_name, + DnsRecordType::TLSA, + 60, + vec![ + DnsRecord::TLSA(TLSARecord { + cert_usage: TlsaCertUsage::DaneEe, + selector: TlsaSelector::Spki, + matching: TlsaMatching::Sha256, + cert_data: leaf.clone(), + }), + DnsRecord::TLSA(TLSARecord { + cert_usage: TlsaCertUsage::DaneTa, + selector: TlsaSelector::Spki, + matching: TlsaMatching::Sha256, + cert_data: intermediate.clone(), + }), + ], + ZONE, + ) + .await + .expect("set TLSA"); + let tlsa = list(&udp, tlsa_name, DnsRecordType::TLSA, 2).await; + let cert_datas: Vec> = tlsa + .iter() + .filter_map(|r| match r { + DnsRecord::TLSA(t) => Some(t.cert_data.clone()), + _ => None, + }) + .collect(); + assert!(cert_datas.contains(&leaf), "leaf TLSA missing: {tlsa:?}"); + assert!( + cert_datas.contains(&intermediate), + "intermediate TLSA missing: {tlsa:?}" + ); + udp.updater + .set_rrset(tlsa_name, DnsRecordType::TLSA, 0, vec![], ZONE) + .await + .expect("cleanup TLSA"); + + // The TCP transport must also attach the TSIG signer + let tcp = DnsUpdater::build(dns_server(IpProtocol::Tcp), test.server.core.clone()) + .await + .expect("Failed to build TCP RFC2136 updater"); + let txt_name = "rfc2136-txt.stalwart.test"; + tcp.updater + .set_rrset( + txt_name, + DnsRecordType::TXT, + 60, + vec![DnsRecord::TXT("rfc2136-tcp-signed".to_string())], + ZONE, + ) + .await + .expect("set TXT over TCP"); + assert_eq!( + list(&tcp, txt_name, DnsRecordType::TXT, 1).await, + vec![DnsRecord::TXT("rfc2136-tcp-signed".to_string())] + ); + tcp.updater + .set_rrset(txt_name, DnsRecordType::TXT, 0, vec![], ZONE) + .await + .expect("cleanup TXT"); +} + +async fn list( + updater: &DnsUpdater, + name: &str, + record_type: DnsRecordType, + expected_len: usize, +) -> Vec { + let mut latest = Vec::new(); + for _ in 0..20 { + latest = updater + .updater + .list_rrset(name, record_type, ZONE) + .await + .unwrap_or_default(); + if latest.len() == expected_len { + return latest; + } + tokio::time::sleep(StdDuration::from_millis(50)).await; + } + latest +} + +fn dns_server(protocol: IpProtocol) -> DnsServer { + DnsServer::Tsig(DnsServerTsig { + host: "127.0.0.1".parse().unwrap(), + port: 5300, + key_name: "stalwart-update-key".to_string(), + key: SecretKey::Value(SecretKeyValue { + secret: KEY_B64.into(), + }), + protocol, + tsig_algorithm: TsigAlgorithm::HmacSha256, + description: "Test RFC2136 DNS server".to_string(), + member_tenant_id: None, + timeout: Duration::from_millis(10_000), + ttl: Duration::from_millis(60_000), + polling_interval: Duration::from_millis(100), + propagation_timeout: Duration::from_millis(10_000), + propagation_delay: None, + }) +} diff --git a/tests/src/cluster/broadcast.rs b/tests/src/cluster/broadcast.rs new file mode 100644 index 0000000..7fb2f6f --- /dev/null +++ b/tests/src/cluster/broadcast.rs @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + imap::idle, + utils::{ + imap::{ImapConnection, Type}, + server::TestServerBuilder, + }, +}; +use imap_proto::ResponseType; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::{ObjectType, Property, SocketAddr}, + structs::{ + ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup, + Coordinator, Imap, NatsCoordinator, NetworkListener, RedisStore, + }, + }, + types::map::Map, +}; +use serde_json::json; +use std::str::FromStr; +use store::registry::RegistryQuery; +use types::id::Id; + +pub const NUM_NODES: usize = 3; + +#[tokio::test(flavor = "multi_thread")] +pub async fn cluster_tests() { + println!("Running cluster broadcast tests..."); + let mut servers = Vec::with_capacity(NUM_NODES); + + let coordinator_id = std::env::var("COORDINATOR").expect(concat!( + "Missing coordinator type. Try running `STORE= ", + "COORDINATOR= cargo test`" + )); + let coordinator = match coordinator_id.as_str() { + "Nats" => { + crate::utils::containers::ensure_nats().await; + Coordinator::Nats(NatsCoordinator { + addresses: Map::new(vec!["127.0.0.1:4222".to_string()]), + use_tls: false, + ..Default::default() + }) + } + "Redis" => { + crate::utils::containers::ensure_redis().await; + Coordinator::Redis(RedisStore { + url: "redis://127.0.0.1".to_string(), + ..Default::default() + }) + } + _ => panic!("Unsupported coordinator type: {}", coordinator_id), + }; + + // Create initial server + let test = TestServerBuilder::new("cluster_test_0") + .await + .with_object(coordinator) + .await + .with_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await + .with_listener(NetworkListenerProtocol::Http, "http_0", 11000, true) + .await + .with_imap_listener(12000) + .await + .with_listener(NetworkListenerProtocol::Lmtp, "lmtp_0", 11200, false) + .await + .build() + .await; + let admin = test.account("admin"); + admin.mta_no_auth().await; + let account = admin + .create_user_account( + "jdoe@example.com", + "this is john's secret", + "John's account", + &[], + vec![], + ) + .await; + admin.reload_settings().await; + + // Create listeners + let mut listeners = vec![ + test.server + .registry() + .query::>(RegistryQuery::new(ObjectType::NetworkListener)) + .await + .unwrap(), + ]; + for node_id in 1..NUM_NODES { + let http_listener_id = admin + .registry_create_object(NetworkListener { + name: format!("http_{}", node_id), + bind: Map::new(vec![ + SocketAddr::from_str(&format!("127.0.0.1:1100{node_id}")).unwrap(), + ]), + protocol: NetworkListenerProtocol::Http, + tls_implicit: true, + use_tls: true, + ..Default::default() + }) + .await; + let imap_listener_id = admin + .registry_create_object(NetworkListener { + name: format!("imap_{}", node_id), + bind: Map::new(vec![ + SocketAddr::from_str(&format!("127.0.0.1:1200{node_id}")).unwrap(), + ]), + protocol: NetworkListenerProtocol::Imap, + tls_implicit: false, + use_tls: true, + ..Default::default() + }) + .await; + listeners.push(vec![http_listener_id, imap_listener_id]); + } + + // Create node roles + for (role_id, listener_ids) in listeners.into_iter().enumerate() { + admin + .registry_create_object(ClusterRole { + name: format!("role_{role_id}"), + listeners: ClusterListenerGroup::EnableSome(ClusterListenerGroupProperties { + listener_ids: Map::new(listener_ids), + }), + tasks: ClusterTaskGroup::EnableAll, + description: None, + }) + .await; + } + servers.push(test); + + // Build additional servers + for node_id in 1..NUM_NODES { + let test = TestServerBuilder::new_with_role( + &format!("cluster_test_{node_id}"), + format!("mail-{node_id}.example.com"), + Some(format!("role_{node_id}")), + false, + ) + .await + .build_with_opts(false) + .await; + + // Verify that the server was assigned the correct node id + assert_eq!(test.server.registry().node_id(), node_id as u16); + servers.push(test); + } + + // Verify cross-cluster cache invalidations + let admin = servers[0].account("admin"); + let server1 = &servers[1].server; + let server2 = &servers[2].server; + let account_id = account.id().document_id(); + assert_eq!( + server1 + .account(account_id) + .await + .unwrap() + .description + .as_deref(), + Some("John's account") + ); + assert_eq!( + server2 + .account(account_id) + .await + .unwrap() + .description + .as_deref(), + Some("John's account") + ); + admin + .registry_update_object( + ObjectType::Account, + account.id(), + json!({ + Property::Description: "John Doe" + }), + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + server1 + .account(account_id) + .await + .unwrap() + .description + .as_deref(), + Some("John Doe") + ); + assert_eq!( + server2 + .account(account_id) + .await + .unwrap() + .description + .as_deref(), + Some("John Doe") + ); + + // Run IMAP idle tests across nodes + let mut node1_client = imap_client("jdoe@example.com", "this is john's secret", 1).await; + let mut node2_client = imap_client("jdoe@example.com", "this is john's secret", 2).await; + idle::test(&mut node1_client, &mut node2_client, true).await; +} + +async fn imap_client(login: &str, secret: &str, node_id: u32) -> ImapConnection { + let mut conn = ImapConnection::connect_to(b"A1 ", format!("127.0.0.1:1200{node_id}")).await; + conn.assert_read(Type::Untagged, ResponseType::Ok).await; + conn.authenticate(login, secret).await; + conn +} diff --git a/tests/src/cluster/mod.rs b/tests/src/cluster/mod.rs new file mode 100644 index 0000000..95c1604 --- /dev/null +++ b/tests/src/cluster/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod broadcast; +pub mod stress; diff --git a/tests/src/cluster/stress.rs b/tests/src/cluster/stress.rs new file mode 100644 index 0000000..4160ed0 --- /dev/null +++ b/tests/src/cluster/stress.rs @@ -0,0 +1,447 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::{DestroyAllMailboxes, TestServer, TestServerBuilder}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + message::metadata::MessageData, +}; +use futures::future::join_all; +use jmap_client::{ + client::Client, + core::set::{SetErrorType, SetObject}, + mailbox::{self, Mailbox, Role}, +}; +use registry::schema::prelude::ObjectType; +use std::{str::FromStr, sync::Arc, time::Duration}; +use store::{ + ValueKey, + rand::{self, RngExt}, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive}, +}; +use types::{collection::Collection, id::Id}; + +const TEST_USER_ID: u32 = 1; +const NUM_PASSES: usize = 1; + +#[tokio::test(flavor = "multi_thread")] +pub async fn stress_tests() { + println!("Running concurrency stress tests..."); + + let mut test = TestServerBuilder::new("stress_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + admin + .registry_destroy_all(ObjectType::MtaConnectionStrategy) + .await; + admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + test.insert_account(admin); + + email_tests(&test).await; + mailbox_tests(&test).await; +} + +async fn email_tests(test: &TestServer) { + let server = &test.server; + let client = Arc::new(test.account("admin@example.com").jmap_client().await); + + for pass in 0..NUM_PASSES { + println!( + "----------------- EMAIL STRESS TEST {} -----------------", + pass + ); + let mailboxes = Arc::new(vec![ + client + .mailbox_create("Stress 1", None::, Role::None) + .await + .unwrap() + .take_id(), + client + .mailbox_create("Stress 2", None::, Role::None) + .await + .unwrap() + .take_id(), + client + .mailbox_create("Stress 3", None::, Role::None) + .await + .unwrap() + .take_id(), + ]); + let mut futures = Vec::new(); + + for num in 0..1000 { + match rand::rng().random_range(0..3) { + 0 => { + let client = client.clone(); + let mailboxes = mailboxes.clone(); + futures.push(tokio::spawn(async move { + let mailbox_num = rand::rng().random_range::(0..mailboxes.len()); + let _message_id = client + .email_import( + format!( + concat!( + "From: test@test.com\n", + "To: test@test.com\r\n", + "Subject: test {}\r\n\r\ntest {}\r\n" + ), + num, num + ) + .into_bytes(), + [&mailboxes[mailbox_num]], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + /*println!( + "Inserted message {}.", + Id::from_bytes(_message_id.as_bytes()) + .unwrap() + .document_id() + );*/ + })); + } + + 1 => { + let client = client.clone(); + futures.push(tokio::spawn(async move { + loop { + let mut req = client.build(); + req.query_email(); + let ids = req.send_query_email().await.unwrap().take_ids(); + if !ids.is_empty() { + let message_id = &ids[rand::rng().random_range(0..ids.len())]; + /*println!( + "Deleting message {}.", + Id::from_bytes(message_id.as_bytes()).unwrap().document_id() + );*/ + match client.email_destroy(message_id).await { + Ok(_) => { + break; + } + Err(jmap_client::Error::Set(err)) => match err.error() { + SetErrorType::NotFound => { + break; + } + SetErrorType::Forbidden => { + // Concurrency issue, try again. + //println!("Concurrent update, trying again."); + } + _ => { + panic!("Unexpected error: {:?}", err); + } + }, + Err(err) => { + panic!("Unexpected error: {:?}", err); + } + } + } else { + break; + } + } + })); + } + _ => { + let client = client.clone(); + let mailboxes = mailboxes.clone(); + futures.push(tokio::spawn(async move { + let mut req = client.build(); + let ref_id = req.query_email().result_reference(); + req.get_email() + .ids_ref(ref_id) + .properties([jmap_client::email::Property::MailboxIds]); + let emails = req + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_email() + .unwrap() + .take_list(); + + if !emails.is_empty() { + let message = &emails[rand::rng().random_range(0..emails.len())]; + let message_id = message.id().unwrap(); + let mailbox_ids = message.mailbox_ids(); + assert_eq!(mailbox_ids.len(), 1, "{:#?}", message); + let mailbox_id = mailbox_ids.last().unwrap(); + loop { + let new_mailbox_id = + &mailboxes[rand::rng().random_range(0..mailboxes.len())]; + if new_mailbox_id != mailbox_id { + /*println!( + "Moving message {} from {} to {}.", + Id::from_bytes(message_id.as_bytes()) + .unwrap() + .document_id(), + Id::from_bytes(mailbox_id.as_bytes()) + .unwrap() + .document_id(), + Id::from_bytes(new_mailbox_id.as_bytes()) + .unwrap() + .document_id() + );*/ + let mut req = client.build(); + req.set_email() + .update(message_id) + .mailbox_ids([new_mailbox_id]); + req.send_set_email().await.unwrap(); + + break; + } + } + } + })); + } + } + tokio::time::sleep(Duration::from_millis(rand::rng().random_range(5..10))).await; + } + + join_all(futures).await; + + let cache = server.get_cached_messages(TEST_USER_ID).await.unwrap(); + let email_ids = cache + .emails + .items + .iter() + .map(|e| e.document_id) + .collect::(); + let mailbox_ids = cache + .mailboxes + .items + .iter() + .map(|m| m.document_id) + .collect::(); + assert_eq!(mailbox_ids.len(), 8); + + for mailbox in mailboxes.iter() { + let mailbox_id = Id::from_str(mailbox).unwrap().document_id(); + let email_ids_in_mailbox = + RoaringBitmap::from_iter(cache.in_mailbox(mailbox_id).map(|m| m.document_id)); + let mut email_ids_check = email_ids_in_mailbox.clone(); + email_ids_check &= &email_ids; + assert_eq!(email_ids_in_mailbox, email_ids_check); + + //println!("Emails {:?}", email_ids_in_mailbox); + + for email_id in &email_ids_in_mailbox { + if let Some(mailbox_tags) = server + .store() + .get_value::>(ValueKey::archive( + TEST_USER_ID, + Collection::Email, + email_id, + )) + .await + .unwrap() + { + let mailbox_tags = mailbox_tags.deserialize::().unwrap().mailboxes; + if mailbox_tags.len() != 1 { + panic!( + "Email ORM has more than one mailbox {:?}! Id {} in mailbox {} with messages {:?}", + mailbox_tags, email_id, mailbox_id, email_ids_in_mailbox + ); + } + let mailbox_tag = mailbox_tags[0]; + assert!(mailbox_tag.uid != 0); + if mailbox_tag.mailbox_id != mailbox_id { + panic!( + concat!( + "Email ORM has an unexpected mailbox tag {:?}! Id {} in ", + "mailbox {} with messages {:?}" + ), + mailbox_tag, email_id, mailbox_id, email_ids_in_mailbox, + ); + } + } else { + panic!( + "Email tags not found! Id {} in mailbox {} with messages {:?}", + email_id, mailbox_id, email_ids_in_mailbox + ); + } + } + } + + test.wait_for_tasks().await; + client.destroy_all_mailboxes().await; + test.assert_is_empty().await; + } +} + +async fn mailbox_tests(test: &TestServer) { + let client = Arc::new(test.account("admin@example.com").jmap_client().await); + + let mailboxes = Arc::new(vec![ + "test/test1/test2/test3".to_string(), + "test1/test2/test3".to_string(), + "test2/test3/test4".to_string(), + "test3/test4/test5".to_string(), + "test4".to_string(), + "test5".to_string(), + ]); + let mut futures = Vec::new(); + + println!("----------------- MAILBOX STRESS TEST -----------------"); + + for _ in 0..1000 { + match rand::rng().random_range(0..=3) { + 0 => { + for pos in 0..mailboxes.len() { + let client = client.clone(); + let mailboxes = mailboxes.clone(); + futures.push(tokio::spawn(async move { + //println!("Creating mailbox {}.", mailboxes[pos]); + create_mailbox(&client, &mailboxes[pos]).await; + })); + } + } + + 1 => { + let client = client.clone(); + futures.push(tokio::spawn(async move { + //print!("Querying mailboxes..."); + query_mailboxes(&client).await; + })); + } + + 2 => { + let client = client.clone(); + futures.push(tokio::spawn(async move { + for mailbox_id in client + .mailbox_query(None::, None::>) + .await + .unwrap() + .take_ids() + { + let client = client.clone(); + tokio::spawn(async move { + //println!("Deleting mailbox {}.", mailbox_id); + delete_mailbox(&client, &mailbox_id).await; + }); + } + })); + } + + _ => { + let client = client.clone(); + futures.push(tokio::spawn(async move { + let mut ids = client + .mailbox_query(None::, None::>) + .await + .unwrap() + .take_ids(); + if !ids.is_empty() { + let id = ids.swap_remove(rand::rng().random_range(0..ids.len())); + let sort_order = rand::rng().random_range(0..100); + //println!("Updating mailbox {}.", id); + client.mailbox_update_sort_order(&id, sort_order).await.ok(); + } + })); + } + } + tokio::time::sleep(Duration::from_millis(rand::rng().random_range(5..10))).await; + } + + join_all(futures).await; + + test.wait_for_tasks().await; + for mailbox_id in client + .mailbox_query(None::, None::>) + .await + .unwrap() + .take_ids() + { + let _ = client.mailbox_move(&mailbox_id, None::).await; + } + for mailbox_id in client + .mailbox_query(None::, None::>) + .await + .unwrap() + .take_ids() + { + let _ = client.mailbox_destroy(&mailbox_id, true).await; + } + test.assert_is_empty().await; +} + +async fn create_mailbox(client: &Client, mailbox: &str) -> Vec { + let mut request = client.build(); + let mut create_ids: Vec = Vec::new(); + let set_request = request.set_mailbox(); + for path_item in mailbox.split('/') { + let create_item = set_request.create().name(path_item); + if let Some(create_id) = create_ids.last() { + create_item.parent_id_ref(create_id); + } + create_ids.push(create_item.create_id().unwrap()); + } + let mut response = request.send_set_mailbox().await.unwrap(); + let mut ids = Vec::with_capacity(create_ids.len()); + for create_id in create_ids { + if let Ok(mut id) = response.created(&create_id) { + ids.push(id.take_id()); + } + } + ids +} + +async fn query_mailboxes(client: &Client) -> Vec { + let mut request = client.build(); + let query_result = request + .query_mailbox() + .calculate_total(true) + .result_reference(); + request.get_mailbox().ids_ref(query_result).properties([ + jmap_client::mailbox::Property::Id, + jmap_client::mailbox::Property::Name, + jmap_client::mailbox::Property::IsSubscribed, + jmap_client::mailbox::Property::ParentId, + jmap_client::mailbox::Property::Role, + jmap_client::mailbox::Property::TotalEmails, + jmap_client::mailbox::Property::UnreadEmails, + ]); + + request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_mailbox() + .unwrap() + .take_list() +} + +async fn delete_mailbox(client: &Client, mailbox_id: &str) { + for _ in 0..3 { + match client.mailbox_destroy(mailbox_id, true).await { + Ok(_) => return, + Err(err) => match err { + jmap_client::Error::Set(_) => break, + jmap_client::Error::Transport(_) => { + let backoff = rand::rng().random_range(50..=300); + tokio::time::sleep(Duration::from_millis(backoff)).await; + } + _ => panic!("Failed: {:?}", err), + }, + } + } + /*println!( + "Warning: Too many transport errors while deleting mailbox {}.", + mailbox_id + );*/ +} diff --git a/tests/src/directory/discovery.rs b/tests/src/directory/discovery.rs new file mode 100644 index 0000000..0e97fc7 --- /dev/null +++ b/tests/src/directory/discovery.rs @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{http::HttpRequest, server::TestServerBuilder}; +use registry::{ + schema::structs::{Directory, OidcDirectory}, + types::map::Map, +}; + +const EXTERNAL_ENDPOINT: &str = + "http://localhost:9080/realms/stalwart/protocol/openid-connect/auth"; +const INTERNAL_ENDPOINT: &str = "https://127.0.0.1:8899/login"; + +pub async fn test() { + println!("Running OIDC account discovery tests..."); + crate::utils::containers::ensure_keycloak().await; + let test = TestServerBuilder::new("directory_discovery_test") + .await + .with_default_listeners() + .await + .disable_services() + .with_object(Directory::Oidc(oidc_test_directory())) + .await + .build() + .await; + assert!( + test.server + .get_default_directory() + .and_then(|directory| directory.oidc_discovery_document()) + .is_some_and(|discovery| discovery.document.authorization_endpoint + == EXTERNAL_ENDPOINT), + "The default directory is not the test OpenID Connect provider" + ); + let http = HttpRequest::new(); + + for (account_name, expected) in [ + ("john.doe@example.org", EXTERNAL_ENDPOINT), + ("John.Doe@Example.org", EXTERNAL_ENDPOINT), + ( + "jane.smith@example.org%john.doe@example.org", + EXTERNAL_ENDPOINT, + ), + ("admin", INTERNAL_ENDPOINT), + ("john.doe@example.org%admin", INTERNAL_ENDPOINT), + ("John.Doe@Example.org%Admin", INTERNAL_ENDPOINT), + ("john.doe@", INTERNAL_ENDPOINT), + ] { + assert_eq!( + authorization_endpoint(&http, account_name).await, + expected, + "Unexpected discovery document for {account_name:?}" + ); + } +} + +async fn authorization_endpoint(http: &HttpRequest, account_name: &str) -> String { + http.get::(&format!( + "/api/discover/{}", + account_name.replace('%', "%25") + )) + .await + .unwrap() + .get("authorization_endpoint") + .and_then(|endpoint| endpoint.as_str()) + .unwrap_or_else(|| panic!("No authorization endpoint returned for {account_name:?}")) + .to_string() +} + +fn oidc_test_directory() -> OidcDirectory { + OidcDirectory { + description: "Test OIDC directory".to_string(), + issuer_url: "http://localhost:9080/realms/stalwart".to_string(), + claim_username: "preferred_username".to_string(), + claim_name: Some("name".to_string()), + claim_groups: Some("groups".to_string()), + require_audience: Some("stalwart".to_string()), + require_scopes: Map::new(vec![ + "email".to_string(), + "profile".to_string(), + "openid".to_string(), + ]), + ..Default::default() + } +} diff --git a/tests/src/directory/integration.rs b/tests/src/directory/integration.rs new file mode 100644 index 0000000..702520b --- /dev/null +++ b/tests/src/directory/integration.rs @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + directory::ldap::ldap_test_directory, + utils::{server::TestServerBuilder, smtp::SmtpConnection}, +}; +use ahash::AHashMap; +use common::auth::{AuthRequest, RECOVERY_ADMIN_ID}; +use email::cache::MessageCacheFetch; +use registry::schema::structs::{Account, AccountSettings, Directory}; +use std::net::IpAddr; +use types::id::Id; + +pub async fn test() { + println!("Running directory integration tests..."); + crate::utils::containers::ensure_openldap().await; + let test = TestServerBuilder::new("directory_integration_test") + .await + .with_default_listeners() + .await + .with_object(Directory::Ldap(ldap_test_directory())) + .await + .build() + .await; + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin.mta_disable_spam_filter().await; + admin.reload_settings().await; + + // Test account creation by login + let account = crate::utils::account::Account::new( + "john.doe@example.org", + "this is John's LDAP password", + &[], + "", + Id::from(u32::MAX), + ); + assert_eq!( + account + .registry_get::(Id::singleton()) + .await + .description + .as_deref(), + Some("John Doe") + ); + + // Test account creation by rcpt + let mut lmtp = SmtpConnection::connect().await; + for rcpt in [ + "corporate@example.org", + "jane.smith@example.org", + "john@example.org", + "bill@example.org", + "sales@example.org", + ] { + lmtp.ingest( + "bill@remote.org", + &[rcpt], + &TEST_EMAIL.replace("$RCPT", rcpt), + ) + .await; + } + + // Fetch all accounts + let mut accounts = admin + .registry_get_all::() + .await + .into_iter() + .map(|(id, account)| { + ( + match &account { + Account::User(user_account) => user_account.name.clone(), + Account::Group(group_account) => group_account.name.clone(), + }, + (account, id), + ) + }) + .collect::>(); + assert_eq!(accounts.len(), 5, "Got: {accounts:#?}"); + + // Validate accounts + for (name, description, secret, groups, aliases) in [ + ( + "john.doe", + "John Doe", + "$app$8958830913002348890$", + &["sales"][..], + &["john"][..], + ), + ( + "jane.smith", + "Jane Smith", + "$app$4096614298472586996$", + &["sales", "corporate"][..], + &[][..], + ), + ( + "bill.foobar", + "Bill Foobar", + "", + &["corporate"][..], + &["bill"][..], + ), + ] { + let (account, id) = accounts + .remove(name) + .map(|(account, id)| (account.into_user().unwrap(), id)) + .unwrap(); + assert_eq!(account.description.as_deref(), Some(description)); + if !secret.is_empty() { + assert_eq!( + test.server + .registry() + .object::(id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap() + .credentials + .values() + .next() + .and_then(|v| v.as_main_credential()) + .map(|v| v.secret.as_str()), + Some(secret) + ); + } + for group in groups { + let id = accounts.get(*group).unwrap().1; + assert!( + account + .member_group_ids + .iter() + .any(|group_id| group_id == &id), + "Account {name} is not a member of group {group}" + ); + } + for alias in aliases { + assert!( + account + .aliases + .iter() + .any(|account_alias| account_alias.name == *alias), + "Account {name} does not have alias {alias}" + ); + } + assert_eq!( + test.server + .get_cached_messages(id.document_id()) + .await + .unwrap() + .emails + .index + .len(), + 1 + ); + } + + // Validate groups + for (name, description, aliases) in [ + ("sales", "sales", &[][..]), + ("corporate", "corporate", &["everyone"][..]), + ] { + let (account, id) = accounts + .remove(name) + .map(|(account, id)| (account.into_group().unwrap(), id)) + .unwrap(); + assert_eq!(account.description.as_deref(), Some(description)); + for alias in aliases { + assert!( + account + .aliases + .iter() + .any(|account_alias| account_alias.name == *alias), + "Group {name} does not have alias {alias}" + ); + } + assert_eq!( + test.server + .get_cached_messages(id.document_id()) + .await + .unwrap() + .emails + .index + .len(), + 1 + ); + } + + // Test recovery admin impersonation of an account that has not logged in before + assert!( + test.server + .account_id_from_email("multi.mail@example.org", false) + .await + .unwrap() + .is_none(), + "Account multi.mail@example.org exists before impersonation" + ); + let access_token = test + .server + .authenticate(&AuthRequest::from_plain( + "multi.mail@example.org%admin", + admin.secret(), + 0, + IpAddr::from([127, 0, 0, 1]), + )) + .await + .unwrap_or_else(|err| panic!("Failed to impersonate multi.mail@example.org: {err:?}")); + assert_ne!(access_token.account_id(), RECOVERY_ADMIN_ID); + assert_eq!( + test.server + .registry() + .object::(access_token.account_id().into()) + .await + .unwrap() + .and_then(|account| account.into_user()) + .map(|account| account.name), + Some("multi.mail".to_string()) + ); +} + +const TEST_EMAIL: &str = r#"From: bill@remote.org +To: $RCPT +Subject: TPS Report for $RCPT + +I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great. + +"#; diff --git a/tests/src/directory/ldap.rs b/tests/src/directory/ldap.rs new file mode 100644 index 0000000..f79746d --- /dev/null +++ b/tests/src/directory/ldap.rs @@ -0,0 +1,243 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use directory::{Account, Credentials, Group, Recipient, backend::ldap::LdapDirectory}; +use registry::{ + schema::structs::{self, SecretKeyOptional, SecretKeyValue}, + types::map::Map, +}; + +pub async fn test() { + println!("Running LDAP directory tests..."); + crate::utils::containers::ensure_openldap().await; + let mut config = ldap_test_directory(); + + // Test bind authentication + let ldap = LdapDirectory::open(config.clone()).await.unwrap(); + assert_eq!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is John's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("$app$8958830913002348890$".into()), + groups: Some(vec!["sales@example.org".into()]), + description: Some("John Doe".into()), + } + ); + assert_eq!( + ldap.authenticate(&Credentials::Basic { + username: "jane.smith@example.org".into(), + secret: "this is Jane's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "jane.smith@example.org".into(), + email_aliases: vec![], + secret: Some("$app$4096614298472586996$".into()), + groups: Some(vec![ + "sales@example.org".into(), + "corporate@example.org".into() + ]), + description: Some("Jane Smith".into()), + } + ); + assert!( + ldap.authenticate(&Credentials::Basic { + username: "jane.smith@example.org".into(), + secret: "this is a wrong LDAP password".into(), + mfa_token: None, + }) + .await + .is_err() + ); + assert!( + ldap.authenticate(&Credentials::Basic { + username: "jane.smith@example.org".into(), + secret: "".into(), + mfa_token: None, + }) + .await + .is_err(), + "Empty password accepted during bind authentication" + ); + + // Test direct authentication (without bind) + config.attr_secret = Map::new(vec!["userPassword".to_string()]); + config.attr_secret_changed = Map::new(vec![]); + config.bind_authentication = false; + let ldap = LdapDirectory::open(config.clone()).await.unwrap(); + assert_eq!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is John's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("this is John's LDAP password".into()), + groups: Some(vec!["sales@example.org".into()]), + description: Some("John Doe".into()), + } + ); + assert!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is a wrong LDAP password".into(), + mfa_token: None, + }) + .await + .is_err() + ); + assert!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "".into(), + mfa_token: None, + }) + .await + .is_err(), + "Empty password accepted during direct authentication" + ); + + // Test recipient lookup + assert_eq!( + ldap.recipient("john.doe@example.org").await.unwrap(), + Recipient::Account(Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("this is John's LDAP password".into()), + groups: Some(vec!["sales@example.org".into()]), + description: Some("John Doe".into()) + }) + ); + assert_eq!( + ldap.recipient("jane.smith@example.org").await.unwrap(), + Recipient::Account(Account { + email: "jane.smith@example.org".into(), + email_aliases: vec![], + secret: Some("this is Jane's LDAP password".into()), + groups: Some(vec![ + "sales@example.org".into(), + "corporate@example.org".into() + ]), + description: Some("Jane Smith".into()) + }) + ); + assert_eq!( + ldap.recipient("sales@example.org").await.unwrap(), + Recipient::Group(Group { + email: "sales@example.org".into(), + email_aliases: vec![], + description: Some("sales".into()) + }) + ); + assert_eq!( + ldap.recipient("corporate@example.org").await.unwrap(), + Recipient::Group(Group { + email: "corporate@example.org".into(), + email_aliases: vec!["everyone@example.org".into()], + description: Some("corporate".into()) + }) + ); + assert_eq!( + ldap.recipient("nonexistent@example.org").await.unwrap(), + Recipient::Invalid + ); + + const MULTI_MAIL: &[&str] = &[ + "mm@example.org", + "multi.mail@example.org", + "multi@example.org", + ]; + let mut config = ldap_test_directory(); + config.attr_secret = Map::new(vec!["userPassword".to_string()]); + config.attr_secret_changed = Map::new(vec![]); + config.bind_authentication = false; + let ldap_dedicated_attr = LdapDirectory::open(config.clone()).await.unwrap(); + config.attr_email_alias = Map::new(vec!["mail".to_string()]); + let ldap_overloaded_attr = LdapDirectory::open(config).await.unwrap(); + + for address in MULTI_MAIL { + let Recipient::Account(account) = ldap_dedicated_attr.recipient(address).await.unwrap() + else { + panic!("Expected an account for {address}"); + }; + assert!( + MULTI_MAIL.contains(&account.email.as_str()), + "Unexpected primary address {:?}", + account.email + ); + assert!( + account.email_aliases.is_empty(), + "Expected no aliases, got {:?}", + account.email_aliases + ); + + let Recipient::Account(account) = ldap_overloaded_attr.recipient(address).await.unwrap() + else { + panic!("Expected an account for {address}"); + }; + assert_eq!(sorted_addresses(&account), MULTI_MAIL); + } + + let account = ldap_overloaded_attr + .authenticate(&Credentials::Basic { + username: "mm@example.org".into(), + secret: "this is Multi's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(); + assert_eq!(sorted_addresses(&account), MULTI_MAIL); +} + +fn sorted_addresses(account: &Account) -> Vec { + let mut addresses = account.email_aliases.clone(); + addresses.push(account.email.clone()); + addresses.sort_unstable(); + addresses +} + +pub fn ldap_test_directory() -> structs::LdapDirectory { + structs::LdapDirectory { + url: "ldap://localhost".into(), + use_tls: false, + attr_class: Map::new(vec!["objectClass".to_string()]), + attr_description: Map::new(vec!["cn".to_string()]), + attr_email: Map::new(vec!["mail".to_string()]), + attr_email_alias: Map::new(vec!["mailAlias".to_string()]), + attr_member_of: Map::new(vec!["memberOf".to_string()]), + attr_secret: Map::new(vec![]), + attr_secret_changed: Map::new(vec!["shadowLastChange".to_string()]), + base_dn: "dc=stalwart,dc=test".into(), + bind_dn: "cn=admin,dc=stalwart,dc=test".to_string().into(), + bind_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "admin".into(), + }), + filter_member_of: "(&(objectClass=groupOfNames)(member=?))".to_string().into(), + filter_login: "(&(objectClass=inetOrgPerson)(mail=?))".into(), + filter_mailbox: concat!( + "(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))", + "(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))" + ) + .into(), + group_class: "groupOfNames".into(), + bind_authentication: true, + description: "Test LDAP directory".into(), + ..Default::default() + } +} diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs new file mode 100644 index 0000000..a03234b --- /dev/null +++ b/tests/src/directory/mod.rs @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod discovery; +pub mod integration; +pub mod ldap; +#[cfg(feature = "sqlite")] +pub mod sql; +pub mod synchronization; +pub mod unavailable; + +#[tokio::test(flavor = "multi_thread")] +pub async fn directory_tests() { + ldap::test().await; + oidc::test().await; + unavailable::test().await; + discovery::test().await; + #[cfg(feature = "sqlite")] + sql::test().await; + synchronization::test().await; + integration::test().await; +} diff --git a/tests/src/directory/sql.rs b/tests/src/directory/sql.rs new file mode 100644 index 0000000..3ed60a4 --- /dev/null +++ b/tests/src/directory/sql.rs @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use directory::{Account, Credentials, Group, Recipient, backend::sql::SqlDirectory}; +use registry::schema::structs::{self, SqlAuthStore}; +use store::{Store, backend::sqlite::SqliteStore}; + +pub async fn test() { + println!("Running SQL directory tests..."); + let sql_store = Store::SQLite(SqliteStore::open_memory().unwrap().into()); + + // Create test directory + for query in [ + concat!( + "CREATE TABLE accounts (name TEXT PRIMARY KEY, secret TEXT, description TEXT,", + " type TEXT NOT NULL, active BOOLEAN DEFAULT TRUE)" + ), + concat!( + "CREATE TABLE group_members (name TEXT NOT NULL, member_of ", + "TEXT NOT NULL, PRIMARY KEY (name, member_of))" + ), + concat!( + "CREATE TABLE emails (name TEXT NOT NULL, address TEXT NOT", + " NULL, PRIMARY KEY (name, address))" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('john@example.org', 'john secret', 'John Doe', 'individual')" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('jane@example.org', 'jane secret', 'Jane Doe', 'individual')" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('bob@example.org', 'bob secret', '', 'individual')" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('nobody@example.org', '', '', 'individual')" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('sales@example.org', NULL, 'Sales Team', 'group')" + ), + concat!( + "INSERT INTO accounts (name, secret, description, type) ", + "VALUES ('support@example.org', NULL, '', 'group')" + ), + concat!( + "INSERT INTO group_members (name, member_of) VALUES ", + "('john@example.org', 'sales@example.org')" + ), + concat!( + "INSERT INTO group_members (name, member_of) VALUES ", + "('jane@example.org', 'sales@example.org')" + ), + concat!( + "INSERT INTO emails (name, address) VALUES ", + "('john@example.org', 'john.doe@example.org')" + ), + ] { + sql_store + .sql_query::(query, vec![]) + .await + .unwrap_or_else(|_| panic!("failed for {query}")); + } + + let config = structs::SqlDirectory { + description: "Test SQL directory".to_string(), + query_login: concat!( + "SELECT name, secret, description, type FROM accounts ", + "WHERE name = $1 AND active = true" + ) + .into(), + query_recipient: concat!( + "SELECT name, secret, description, type FROM accounts ", + "WHERE name = $1 AND active = true" + ) + .into(), + query_email_aliases: concat!("SELECT address FROM emails ", "WHERE name = $1") + .to_string() + .into(), + query_member_of: concat!("SELECT member_of FROM group_members ", "WHERE name = $1") + .to_string() + .into(), + column_class: "type".to_string().into(), + column_description: "description".to_string().into(), + column_email: "name".into(), + column_secret: "secret".into(), + store: SqlAuthStore::Default, + member_tenant_id: None, + }; + + // Test authentication + let sql = SqlDirectory::open(config, &sql_store).await.unwrap(); + assert_eq!( + sql.authenticate(&Credentials::Basic { + username: "john@example.org".to_string(), + secret: "john secret".to_string(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "john@example.org".to_string(), + email_aliases: vec!["john.doe@example.org".to_string(),], + secret: Some("john secret".to_string()), + groups: Some(vec!["sales@example.org".to_string()]), + description: Some("John Doe".to_string()), + } + ); + assert!( + sql.authenticate(&Credentials::Basic { + username: "john@example.org".to_string(), + secret: "wrong secret".to_string(), + mfa_token: None, + }) + .await + .is_err() + ); + + // Empty columns are treated as missing values + assert_eq!( + sql.authenticate(&Credentials::Basic { + username: "bob@example.org".to_string(), + secret: "bob secret".to_string(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "bob@example.org".to_string(), + email_aliases: vec![], + secret: Some("bob secret".to_string()), + groups: Some(vec![]), + description: None, + } + ); + assert!( + sql.authenticate(&Credentials::Basic { + username: "nobody@example.org".to_string(), + secret: "".to_string(), + mfa_token: None, + }) + .await + .is_err() + ); + assert_eq!( + sql.recipient("nobody@example.org").await.unwrap(), + Recipient::Account(Account { + email: "nobody@example.org".to_string(), + email_aliases: vec![], + secret: None, + groups: Some(vec![]), + description: None, + }) + ); + assert_eq!( + sql.recipient("support@example.org").await.unwrap(), + Recipient::Group(Group { + email: "support@example.org".to_string(), + email_aliases: vec![], + description: None + }) + ); + + // Test recipient lookup + assert_eq!( + sql.recipient("john@example.org").await.unwrap(), + Recipient::Account(Account { + email: "john@example.org".to_string(), + email_aliases: vec!["john.doe@example.org".to_string()], + secret: Some("john secret".to_string()), + groups: Some(vec!["sales@example.org".to_string()]), + description: Some("John Doe".to_string()), + }) + ); + assert_eq!( + sql.recipient("jane@example.org").await.unwrap(), + Recipient::Account(Account { + email: "jane@example.org".to_string(), + email_aliases: vec![], + secret: Some("jane secret".to_string()), + groups: Some(vec!["sales@example.org".to_string()]), + description: Some("Jane Doe".to_string()), + }) + ); + assert_eq!( + sql.recipient("sales@example.org").await.unwrap(), + Recipient::Group(Group { + email: "sales@example.org".to_string(), + email_aliases: vec![], + description: Some("Sales Team".to_string()) + }) + ); + assert_eq!( + sql.recipient("unknown@example.org").await.unwrap(), + Recipient::Invalid + ); +} diff --git a/tests/src/directory/synchronization.rs b/tests/src/directory/synchronization.rs new file mode 100644 index 0000000..8072e3e --- /dev/null +++ b/tests/src/directory/synchronization.rs @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use registry::schema::{ + prelude::ObjectType, + structs::{Account, Domain, EmailAlias}, +}; +use types::id::Id; + +pub async fn test() { + println!("Running directory synchronization tests..."); + let test = TestServerBuilder::new("directory_synchronization_test") + .await + .with_default_listeners() + .await + .disable_services() + .build() + .await; + let admin = test.account("admin"); + + // Synchronizing an account with an unknown domain should fail + assert!( + test.server + .synchronize_account(directory::Account { + email: "john@unknown.org".to_string(), + email_aliases: vec![], + secret: "supersecret".to_string().into(), + groups: Some(vec![]), + description: "John Doe".to_string().into(), + }) + .await + .is_err() + ); + + // Initial account synchronization + let mut account_in = directory::Account { + email: "john@example.org".to_string(), + email_aliases: vec![ + "john.doe@example.org".to_string(), + "j.doe@example.org".to_string(), + ], + secret: "supersecret".to_string().into(), + groups: Some(vec![ + "corporate@example.org".to_string(), + "sales@example.org".to_string(), + ]), + description: "John Doe".to_string().into(), + }; + let result = test + .server + .synchronize_account(account_in.clone()) + .await + .unwrap(); + let account_id = Id::from(result.id); + let account_out = test + .server + .registry() + .object::(account_id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap(); + let domain_id = account_out.domain_id; + assert_eq!( + admin.registry_get::(domain_id).await.name, + "example.org" + ); + assert_eq!(account_out.name, "john"); + assert_eq!(account_out.description.as_deref(), Some("John Doe")); + assert_eq!( + account_out + .credentials + .values() + .next() + .and_then(|v| v.as_main_credential()) + .map(|c| c.secret.as_str()), + Some("supersecret") + ); + assert_eq!(account_out.aliases.len(), 2); + let aliases = account_out.aliases.iter().collect::>(); + assert_eq!( + aliases[0], + &EmailAlias { + description: None, + domain_id, + enabled: true, + name: "john.doe".to_string(), + } + ); + assert_eq!( + aliases[1], + &EmailAlias { + description: None, + domain_id, + enabled: true, + name: "j.doe".to_string(), + } + ); + assert_eq!(account_out.member_group_ids.len(), 2); + for (idx, group_id) in account_out.member_group_ids.iter().enumerate() { + let group = admin + .registry_get::(*group_id) + .await + .into_group() + .unwrap(); + assert_eq!(group.name, if idx == 0 { "corporate" } else { "sales" }); + assert_eq!(group.domain_id, domain_id); + } + assert_eq!( + test.server + .registry() + .count_object(ObjectType::Account) + .await + .unwrap(), + 3 + ); + assert_eq!( + test.server + .registry() + .count_object(ObjectType::Domain) + .await + .unwrap(), + 1 + ); + + // No changes should not cause any updates + assert_eq!( + test.server + .synchronize_account(account_in.clone()) + .await + .unwrap() + .id, + account_id.document_id() + ); + assert_eq!( + test.server + .registry() + .object::(account_id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap(), + account_out + ); + assert_eq!( + test.server + .registry() + .count_object(ObjectType::Account) + .await + .unwrap(), + 3 + ); + + // Make some changes and synchronize again + account_in.description = "Johnathan Doe".to_string().into(); + account_in + .email_aliases + .push("johnny@example.org".to_string()); + let groups = account_in.groups.get_or_insert_default(); + groups.pop(); + groups.push("support@example.org".to_string()); + account_in.secret = "evenmoresecret".to_string().into(); + assert_eq!( + test.server + .synchronize_account(account_in.clone()) + .await + .unwrap() + .id, + account_id.document_id() + ); + let account_out = test + .server + .registry() + .object::(account_id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap(); + assert_eq!( + account_out + .credentials + .values() + .next() + .and_then(|v| v.as_main_credential()) + .map(|c| c.secret.as_str()), + Some("evenmoresecret") + ); + assert_eq!(account_out.description.as_deref(), Some("Johnathan Doe")); + assert_eq!(account_out.aliases.len(), 3); + let aliases = account_out.aliases.iter().collect::>(); + assert_eq!( + aliases[2], + &EmailAlias { + description: None, + domain_id, + enabled: true, + name: "johnny".to_string(), + } + ); + assert_eq!(account_out.member_group_ids.len(), 2); + let account_groups = account_out + .member_group_ids + .iter() + .copied() + .collect::>(); + for (idx, group_id) in account_groups.iter().enumerate() { + let group = admin + .registry_get::(*group_id) + .await + .into_group() + .unwrap(); + assert_eq!(group.name, if idx == 0 { "corporate" } else { "support" }); + assert_eq!(group.domain_id, domain_id); + } + assert_eq!( + test.server + .registry() + .count_object(ObjectType::Account) + .await + .unwrap(), + 4 + ); + + account_in.groups = None; + test.server + .synchronize_account(account_in.clone()) + .await + .unwrap(); + let account_out = test + .server + .registry() + .object::(account_id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap(); + assert_eq!(account_out.member_group_ids.len(), 2); + + account_in.groups = Some(vec![]); + test.server + .synchronize_account(account_in.clone()) + .await + .unwrap(); + let account_out = test + .server + .registry() + .object::(account_id) + .await + .unwrap() + .unwrap() + .into_user() + .unwrap(); + assert_eq!(account_out.member_group_ids.len(), 0); + + // Synchronize a group + assert_eq!( + test.server + .synchronize_group(directory::Group { + email: "corporate@example.org".to_string(), + email_aliases: vec!["everyone@example.org".to_string()], + description: "Corporate Group".to_string().into(), + }) + .await + .unwrap(), + account_groups[0].document_id() + ); + let group_out = test + .server + .registry() + .object::(account_groups[0]) + .await + .unwrap() + .unwrap() + .into_group() + .unwrap(); + assert_eq!(group_out.name, "corporate"); + assert_eq!(group_out.description.as_deref(), Some("Corporate Group")); + assert_eq!(group_out.aliases.len(), 1); + let aliases = group_out.aliases.iter().collect::>(); + assert_eq!( + aliases[0], + &EmailAlias { + description: None, + domain_id, + enabled: true, + name: "everyone".to_string(), + } + ); + assert_eq!( + test.server + .registry() + .count_object(ObjectType::Account) + .await + .unwrap(), + 4 + ); +} diff --git a/tests/src/directory/unavailable.rs b/tests/src/directory/unavailable.rs new file mode 100644 index 0000000..23f0fab --- /dev/null +++ b/tests/src/directory/unavailable.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use directory::{Credentials, Directory, UnavailableDirectory, backend::oidc::OpenIdDirectory}; +use registry::{ + schema::{enums::DirectoryType, structs}, + types::map::Map, +}; +use std::time::{Duration, Instant}; + +const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30); + +pub async fn test() { + println!("Running unavailable directory tests..."); + + let started_at = Instant::now(); + let error = OpenIdDirectory::open(structs::OidcDirectory { + description: "Unreachable OIDC directory".to_string(), + issuer_url: "http://localhost:59999/realms/stalwart".to_string(), + claim_username: "preferred_username".to_string(), + claim_name: None, + claim_groups: None, + username_domain: None, + require_audience: None, + require_scopes: Map::new(vec![]), + member_tenant_id: None, + }) + .await + .expect_err("Discovery against an unreachable issuer must fail"); + let elapsed = started_at.elapsed(); + assert!( + elapsed >= DISCOVERY_RETRY_FOR, + "Discovery gave up after {elapsed:?}: {error}" + ); + + let oidc = Directory::Unavailable(UnavailableDirectory::new(DirectoryType::Oidc, error)); + assert!( + oidc.authenticate(&Credentials::Basic { + username: "john.doe@example.org".to_string(), + secret: "this is an OIDC password".to_string(), + mfa_token: None, + }) + .await + .is_err(), + "Password authentication must not fall back to internal credentials" + ); + assert!( + oidc.authenticate(&Credentials::Bearer { + username: None, + token: "not a token".to_string(), + }) + .await + .is_err() + ); + assert!(oidc.has_bearer_token_support()); + assert!(!oidc.can_lookup_recipients()); + assert!(oidc.oidc_discovery_document().is_none()); + + let ldap = Directory::Unavailable(UnavailableDirectory::new( + DirectoryType::Ldap, + "LDAP bind password is required when bind DN is set", + )); + assert!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".to_string(), + secret: "this is John's LDAP password".to_string(), + mfa_token: None, + }) + .await + .is_err() + ); + assert!(ldap.recipient("john.doe@example.org").await.is_err()); + assert!(ldap.can_lookup_recipients()); + assert!(!ldap.has_bearer_token_support()); +} diff --git a/tests/src/imap/acl.rs b/tests/src/imap/acl.rs new file mode 100644 index 0000000..33103ad --- /dev/null +++ b/tests/src/imap/acl.rs @@ -0,0 +1,391 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type, append::assert_append_message}; +use crate::utils::{server::TestServer, smtp::SmtpConnection}; +use imap_proto::ResponseType; + +pub async fn test( + mut imap_john: &mut ImapConnection, + _imap_check: &mut ImapConnection, + test: &TestServer, +) { + // Delivery to support account + println!("Running ACL tests..."); + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.com", + &["support@example.com"], + concat!( + "From: bill@example.com\r\n", + "To: support@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + // Connect to all test accounts + let mut imap_jane = test.account("jane.smith@example.com").imap_client().await; + let mut imap_bill = test.account("foobar@example.com").imap_client().await; + + // Jane should see the Support account + imap_jane.send("LIST \"\" \"*\"").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("Shared Folders/support@example.com/INBOX"); + imap_jane + .send("SELECT \"Shared Folders/support@example.com/INBOX\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_jane.send("FETCH 1 (PREVIEW)").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("TPS reports ASAP"); + imap_jane.send("UNSELECT").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Jane should be able to create folders under the Support account + imap_jane + .send("CREATE \"Shared Folders/support@example.com/inbox/Jane's Folder\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_jane.send("LIST \"\" \"*\"").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals( + "* LIST () \"/\" \"Shared Folders/support@example.com/INBOX/Jane's Folder\"", + ); + imap_jane + .send("DELETE \"Shared Folders/support@example.com/INBOX/Jane's Folder\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + // John should have no shared folders + imap_john.send("LIST \"\" \"*\"").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("Shared Folders", 0); + imap_john.send("NAMESPACE").await; + imap_john.assert_read(Type::Tagged, ResponseType::Ok).await; + + // List rights + imap_jane.send("LISTRIGHTS INBOX jdoe@example.com").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* LISTRIGHTS \"INBOX\" \"jdoe@example.com\" r l ws i et k x p a"); + + // Jane shares her Inbox to John, expect a Shared Folders item in John's list + imap_jane.send("SETACL INBOX jdoe@example.com lr").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john.send("LIST \"\" \"*\"").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* LIST (\\NoSelect) \"/\" \"Shared Folders\"") + .assert_equals("* LIST (\\NoSelect) \"/\" \"Shared Folders/jane.smith@example.com\"") + .assert_equals("* LIST () \"/\" \"Shared Folders/jane.smith@example.com/INBOX\""); + + // Grant access to Bill and check ACLs + imap_jane.send("GETACL INBOX").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"jdoe@example.com\" rl"); + + imap_jane + .send("SETACL INBOX foobar@example.com lrxtws") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane.send("GETACL INBOX").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"jdoe@example.com\" rl") + .assert_contains("\"foobar@example.com\" tewsrxl"); + + imap_bill.send("LIST \"\" \"*\"").await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("Shared Folders/jane.smith@example.com/INBOX"); + + // Namespace should now return the Shared Folders namespace + imap_john.send("NAMESPACE").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* NAMESPACE ((\"\" \"/\")) ((\"Shared Folders\" \"/\")) NIL"); + + // List John's right on Jane's Inbox + imap_john + .send("MYRIGHTS \"Shared Folders/jane.smith@example.com/INBOX\"") + .await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* MYRIGHTS \"Shared Folders/jane.smith@example.com/INBOX\" rl"); + + // John should not be able to append messages + assert_append_message( + imap_john, + "Shared Folders/jane.smith@example.com/INBOX", + "From: john\n\ncontents", + ResponseType::No, + ) + .await; + + // Grant insert access to John on Jane's Inbox, and try inserting the + // message again. + imap_jane.send("SETACL INBOX jdoe@example.com +i").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john + .send("MYRIGHTS \"Shared Folders/jane.smith@example.com/INBOX\"") + .await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* MYRIGHTS \"Shared Folders/jane.smith@example.com/INBOX\" rli"); + assert_append_message( + imap_john, + "Shared Folders/jane.smith@example.com/INBOX", + "From: john\n\ncontents", + ResponseType::Ok, + ) + .await; + + // Only Bill should be allowed to delete messages on Jane's Inbox + for imap in [&mut imap_john, &mut imap_bill] { + imap.send("SELECT \"Shared Folders/jane.smith@example.com/INBOX\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + } + imap_john.send("UID STORE 1 +FLAGS (\\Deleted)").await; + imap_john.assert_read(Type::Tagged, ResponseType::No).await; + + imap_bill.send("UID STORE 1 +FLAGS (\\Deleted)").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_john.send("UID EXPUNGE").await; + imap_john.assert_read(Type::Tagged, ResponseType::No).await; + + imap_john.send("UID FETCH 1 (PREVIEW)").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("contents"); + + imap_bill.send("UID EXPUNGE").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_bill.send("UID FETCH 1 (PREVIEW)").await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("contents", 0); + + imap_bill + .send("STATUS \"Shared Folders/jane.smith@example.com/INBOX\" (MESSAGES)") + .await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("(MESSAGES 0)"); + + // Test copying and moving between shared mailboxes + let uid = assert_append_message( + imap_john, + "INBOX", + "From: john\n\ncopy test", + ResponseType::Ok, + ) + .await + .into_append_uid(); + + imap_john.send("SELECT INBOX").await; + imap_john.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Copy from John's Inbox to Jane's Inbox + imap_john + .send(&format!( + "UID COPY {} \"Shared Folders/jane.smith@example.com/INBOX\"", + uid + )) + .await; + let uid = imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_copy_uid(); + + // Check that both Bill and Jane can see the message + imap_bill.send("NOOP").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_bill + .send(&format!("UID FETCH {} (PREVIEW)", uid)) + .await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("copy test"); + + imap_jane.send("SELECT INBOX").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane + .send(&format!("UID FETCH {} (PREVIEW)", uid)) + .await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("copy test"); + + // Bill now moves the message to his own Inbox + imap_bill.send(&format!("UID MOVE {} INBOX", uid)).await; + let uid_moved = imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_copy_uid(); + + // Both Jane and Bill should not see the message on Jane's Inbox anymore + imap_bill + .send(&format!("UID FETCH {} (PREVIEW)", uid)) + .await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("copy test", 0); + + imap_jane + .send(&format!("UID FETCH {} (PREVIEW)", uid)) + .await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("copy test", 0); + + // Check that the message has been moved to Bill's Inbox + imap_bill.send("SELECT INBOX").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_bill + .send(&format!("UID FETCH {} (PREVIEW)", uid_moved)) + .await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("copy test"); + + // Repeating a cross-account copy returns the existing destination UID + // instead of creating a duplicate, and moving a message that the destination + // account already holds still expunges the source. + let uid_dedup = assert_append_message( + imap_john, + "INBOX", + concat!( + "Message-ID: \n", + "From: john\n", + "Subject: dedup\n", + "\n", + "dedup test" + ), + ResponseType::Ok, + ) + .await + .into_append_uid(); + + imap_john + .send(&format!( + "UID COPY {} \"Shared Folders/jane.smith@example.com/INBOX\"", + uid_dedup + )) + .await; + let uid_dedup_dest = imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_copy_uid(); + + imap_john + .send(&format!( + "UID COPY {} \"Shared Folders/jane.smith@example.com/INBOX\"", + uid_dedup + )) + .await; + assert_eq!( + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_copy_uid(), + uid_dedup_dest + ); + + imap_jane.send("NOOP").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane.send("UID FETCH 1:* (PREVIEW)").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("dedup test", 1); + + imap_john + .send(&format!( + "UID MOVE {} \"Shared Folders/jane.smith@example.com/INBOX\"", + uid_dedup + )) + .await; + assert_eq!( + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_copy_uid(), + uid_dedup_dest + ); + + imap_john + .send(&format!("UID FETCH {} (PREVIEW)", uid_dedup)) + .await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("dedup test", 0); + + // Jane stops sharing with Bill, and removes Insert access to John + imap_jane.send("DELETEACL INBOX foobar@example.com").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane.send("SETACL INBOX jdoe@example.com -i").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane.send("GETACL INBOX").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"jdoe@example.com\" rl") + .assert_count("foobar@example.com", 0); + + // Bill should not have access to Jane's Inbox anymore + imap_bill.send("LIST \"\" \"*\"").await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("Shared Folders", 0); + + // And John should still have access + imap_john.send("LIST \"\" \"*\"").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("Shared Folders", 3); +} diff --git a/tests/src/imap/antispam.rs b/tests/src/imap/antispam.rs new file mode 100644 index 0000000..ad527e2 --- /dev/null +++ b/tests/src/imap/antispam.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + imap::Type, + system::antispam::{HAM, SPAM, TEST}, + utils::{imap::AssertResult, server::TestServer, smtp::SmtpConnection}, +}; +use common::{Server, manager::SPAM_TRAINER_KEY}; +use imap_proto::ResponseType; +use registry::schema::{ + enums::TaskSpamFilterMaintenanceType, + prelude::ObjectType, + structs::{Task, TaskSpamFilterMaintenance, TaskStatus}, +}; +use spam_filter::modules::classifier::SpamTrainer; +use store::{ + Deserialize, + write::{AlignedBytes, Archive}, +}; + +pub async fn test(test: &TestServer) { + println!("Running Spam classifier tests..."); + let admin = test.account("admin@example.com"); + let account = test.account("sgd@example.com"); + let mut imap = account.imap_client().await; + let account_id = account.id(); + + // Make sure there are no training samples + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + assert_eq!(admin.spam_training_samples().await, vec![]); + + // Train the classifier via APPEND + imap.append("INBOX", HAM[0]).await; + imap.append("Junk Mail", SPAM[0]).await; + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1); + + // Append two spam samples to "Drafts", then train the classifier via STORE and MOVE + imap.append("Drafts", SPAM[1]).await; + imap.append("Drafts", SPAM[2]).await; + imap.send_ok("SELECT Drafts").await; + imap.send_ok("STORE 1 +FLAGS ($Junk)").await; + imap.send_ok("MOVE 2 \"Junk Mail\"").await; + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 3); + + // Add the remaining messages via APPEND + for message in HAM.iter().skip(1) { + imap.append("INBOX", message).await; + } + for message in SPAM.iter().skip(3) { + imap.append("Junk Mail", message).await; + } + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 10); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10); + assert_eq!(samples.len(), 20); + assert!(samples.iter().all( + |(_, s)| s.blob_id.class.account_id() == account_id.document_id() && !s.delete_after_use + )); + + // Train the classifier + admin + .registry_create_object(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::Train, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + let model = spam_classifier_model(&test.server).await; + assert_eq!(model.reservoir.ham.total_seen, 10); + assert_eq!(model.reservoir.spam.total_seen, 10); + assert_eq!( + model.last_id, + samples.iter().map(|(id, _)| id.id()).max().unwrap() + ); + assert_eq!(account.spam_training_samples().await.len(), 20); + assert!(test.server.inner.data.spam_classifier.load().is_active()); + + // Send 3 test emails + for message in TEST { + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest("bill@example.com", &["sgd@example.com"], message) + .await; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + imap.send_ok("SELECT INBOX").await; + imap.send("FETCH 11 (FLAGS RFC822.TEXT)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("FLAGS ($Junk") + .assert_contains("Subject: can someone explain") + .assert_contains("X-Spam-Status: No") + .assert_contains("PROB_HAM_HIGH"); + imap.send("FETCH 12 (FLAGS RFC822.TEXT)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("FLAGS ($Junk") + .assert_contains("Subject: classifier test") + .assert_contains("X-Spam-Status: No") + .assert_contains_any(&["PROB_SPAM_UNCERTAIN", "PROB_HAM_LOW"]); + imap.send_ok("SELECT \"Junk Mail\"").await; + imap.send("FETCH 10 (FLAGS RFC822.TEXT)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FLAGS ($Junk") + .assert_contains("Subject: save up to") + .assert_contains("X-Spam-Status: Yes") + .assert_contains("PROB_SPAM_HIGH"); + imap.send_ok("MOVE 10 INBOX").await; + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 11); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10); + + // Make sure spam traps trigger spam classification + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest("bill@example.com", &["spamtrap@example.com"], SPAM[4]) + .await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let samples = admin.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 11); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11); + + // Global spam samples should not appear in the account + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 11); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10); +} + +pub async fn spam_classifier_model(server: &Server) -> SpamTrainer { + server + .blob_store() + .get_blob(SPAM_TRAINER_KEY, 0..usize::MAX) + .await + .and_then(|archive| match archive { + Some(archive) => as Deserialize>::deserialize(&archive) + .and_then(|archive| archive.deserialize_untrusted::()) + .map(Some), + None => Ok(None), + }) + .unwrap() + .unwrap() +} diff --git a/tests/src/imap/append.rs b/tests/src/imap/append.rs new file mode 100644 index 0000000..d6bcfba --- /dev/null +++ b/tests/src/imap/append.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type, resources_dir}; +use crate::utils::server::TestServer; +use imap_proto::ResponseType; +use std::{fs, io}; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, test: &TestServer) { + println!("Running APPEND tests..."); + + // Invalid APPEND commands + imap.send("APPEND \"Does not exist\" {1+}\r\na").await; + imap.assert_read(Type::Tagged, ResponseType::No) + .await + .assert_response_code("TRYCREATE"); + + // Import test messages + let mut entries = fs::read_dir(resources_dir()) + .unwrap() + .map(|res| res.map(|e| e.path())) + .collect::, io::Error>>() + .unwrap(); + + entries.sort(); + + let mut expected_uid = 1; + for file_name in entries.into_iter().take(20) { + if file_name.extension().is_none_or(|e| e != "txt") { + continue; + } + let raw_message = fs::read(&file_name).unwrap(); + + imap.send(&format!( + "APPEND INBOX (Flag_{}) {{{}}}", + file_name + .file_name() + .unwrap() + .to_str() + .unwrap() + .split_once('.') + .unwrap() + .0, + raw_message.len() + )) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged(std::str::from_utf8(&raw_message).unwrap()) + .await; + let result = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_response_code(); + let mut code = result.split(' '); + assert_eq!(code.next(), Some("APPENDUID")); + assert_ne!(code.next(), Some("0")); + assert_eq!(code.next(), Some(expected_uid.to_string().as_str())); + expected_uid += 1; + } + + test.wait_for_tasks().await; +} + +pub async fn assert_append_message( + imap: &mut ImapConnection, + folder: &str, + message: &str, + expected_response: ResponseType, +) -> Vec { + imap.send(&format!("APPEND \"{}\" {{{}}}", folder, message.len())) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged(message).await; + imap.assert_read(Type::Tagged, expected_response).await +} + +fn build_message(message: usize, in_reply_to: Option, thread_num: usize) -> String { + if let Some(in_reply_to) = in_reply_to { + format!( + "Message-ID: <{}@domain>\nReferences: <{}@domain>\nSubject: re: T{}\n\nreply\n", + message, in_reply_to, thread_num + ) + } else { + format!( + "Message-ID: <{}@domain>\nSubject: T{}\n\nmsg\n", + message, thread_num + ) + } +} + +pub fn build_messages() -> Vec { + let mut messages = Vec::new(); + for parent in 0..3 { + messages.push(build_message(parent, None, parent)); + for child in 0..3 { + messages.push(build_message( + ((parent + 1) * 10) + child, + parent.into(), + parent, + )); + } + } + messages +} diff --git a/tests/src/imap/basic.rs b/tests/src/imap/basic.rs new file mode 100644 index 0000000..60eed8c --- /dev/null +++ b/tests/src/imap/basic.rs @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use directory::Credentials; +use imap_proto::ResponseType; +use mail_parser::decoders::base64::base64_decode; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { + println!("Running basic tests..."); + + // Test OAuth Bearer decoding + assert_eq!( + Credentials::Bearer { + token: "vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(), + username: Some("user@example.com".to_string()), + }, + Credentials::decode_sasl_challenge_oauth( + &base64_decode( + concat!( + "bixhPXVzZXJAZXhhbXBsZS5jb20sAWhv", + "c3Q9c2VydmVyLmV4YW1wbGUuY29tAXBvcnQ9MTQzAWF1dGg9QmVhcmVyI", + "HZGOWRmdDRxbVRjMk52YjNSbGNrQmhiSFJoZG1semRHRXVZMjl0Q2c9PQ", + "EB" + ) + .as_bytes(), + ) + .unwrap(), + ) + .unwrap() + ); + + // Test CAPABILITY + imap.send("CAPABILITY").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Test NOOP + imap.send("NOOP").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Test ID + imap.send("ID").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* ID (\"name\" \"Stalwart\" \"version\" "); + + // Login should be disabled + imap.send("LOGIN jdoe@example.com secret").await; + imap.assert_read(Type::Tagged, ResponseType::No).await; + + // Try logging in with wrong password + imap.send("AUTHENTICATE PLAIN {24}").await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged("AGJvYXR5AG1jYm9hdGZhY2U=").await; + imap.assert_read(Type::Tagged, ResponseType::No).await; +} diff --git a/tests/src/imap/body_structure.rs b/tests/src/imap/body_structure.rs new file mode 100644 index 0000000..5eb6231 --- /dev/null +++ b/tests/src/imap/body_structure.rs @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::resources_dir; +use email::message::metadata::{MessageMetadata, build_metadata_contents}; +use imap::op::fetch::AsImapDataItem; +use imap_proto::{ + ResponseCode, StatusResponse, + protocol::fetch::{BodyContents, DataItem, Section}, +}; +use mail_parser::MessageParser; +use std::fs; +use store::{ + Deserialize, Serialize, + write::{Archive, Archiver}, +}; +use utils::chained_bytes::ChainedBytes; + +pub fn test() { + println!("Running BODYSTRUCTURE..."); + + for file_name in fs::read_dir(resources_dir()).unwrap() { + let mut file_name = file_name.as_ref().unwrap().path(); + if file_name.extension().is_none_or(|e| e != "txt") { + continue; + } + + let mut buf = Vec::new(); + let raw_message = fs::read(&file_name).unwrap(); + let message_ = MessageParser::new().parse(&raw_message).unwrap(); + let metadata = MessageMetadata { + preview: Default::default(), + raw_headers: message_ + .raw_message + .as_ref() + .get( + message_.root_part().offset_header as usize + ..message_.root_part().offset_body as usize, + ) + .unwrap_or_default() + .into(), + blob_hash: Default::default(), + blob_body_offset: message_.root_part().offset_body as u32, + contents: build_metadata_contents(message_), + rcvd_attach: 0, + }; + let metadata_ = + Archive::deserialize_owned(Archiver::new(metadata).serialize().unwrap()).unwrap(); + let metadata = metadata_.unarchive::().unwrap(); + let raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()).with_last( + raw_message + .get(metadata.blob_body_offset.to_native() as usize..) + .unwrap_or_default(), + ); + let decoded = metadata.decode_contents(raw_message); + + // Serialize body and bodystructure + for (is_extended, is_utf8) in [(false, false), (true, false), (true, true)] { + let mut buf_ = Vec::new(); + metadata.body_structure(&decoded, is_extended).serialize( + &mut buf_, + is_extended, + is_utf8, + ); + if !is_extended { + buf.extend_from_slice(b"BODY "); + } else if !is_utf8 { + buf.extend_from_slice(b"BODYSTRUCTURE "); + } else { + buf.extend_from_slice(b"BODYSTRUCTURE UTF8=ACCEPT "); + } + + // Poor man's indentation + let mut indent_count = 0; + let mut in_quote = false; + for ch in buf_ { + if ch == b'(' && !in_quote { + buf.extend_from_slice(b"(\n"); + indent_count += 1; + for _ in 0..indent_count { + buf.extend_from_slice(b" "); + } + } else if ch == b')' && !in_quote { + buf.push(b'\n'); + indent_count -= 1; + for _ in 0..indent_count { + buf.extend_from_slice(b" "); + } + buf.push(b')'); + } else { + if ch == b'"' { + in_quote = !in_quote; + } + buf.push(ch); + } + } + buf.extend_from_slice(b"\n\n"); + } + + // Serialize body parts + let mut iter = 1..9; + let mut stack = Vec::new(); + let mut sections = Vec::new(); + loop { + 'inner: while let Some(part_id) = iter.next() { + if part_id == 1 { + for section in [ + None, + Some(Section::Header), + Some(Section::Text), + Some(Section::Mime), + ] { + let mut body_sections = sections + .iter() + .map(|id| Section::Part { num: *id }) + .collect::>(); + let is_first = if let Some(section) = section { + body_sections.push(section); + false + } else { + true + }; + + if let Some(contents) = + metadata.body_section(&decoded, &body_sections, None) + { + DataItem::BodySection { + sections: body_sections, + origin_octet: None, + contents, + } + .serialize(&mut buf, false); + + if is_first { + match metadata.binary(&decoded, §ions, None) { + Ok(Some(contents)) => { + buf.push(b'\n'); + DataItem::Binary { + sections: sections.clone(), + offset: None, + contents: match contents { + BodyContents::Bytes(bytes) => BodyContents::Text( + std::str::from_utf8(bytes.as_ref()) + .unwrap_or("[binary content]") + .to_string() + .into(), + ), + text => text, + }, + } + .serialize(&mut buf, false); + } + Ok(None) => (), + Err(_) => { + buf.push(b'\n'); + buf.extend_from_slice( + &StatusResponse::no(format!( + "Failed to decode part {} of message {}.", + sections + .iter() + .map(|s| s.to_string()) + .collect::>() + .join("."), + 0 + )) + .with_code(ResponseCode::UnknownCte) + .serialize(Vec::new()), + ); + } + } + + if let Some(size) = metadata.binary_size(&decoded, §ions) { + buf.push(b'\n'); + DataItem::BinarySize { + sections: sections.clone(), + size, + } + .serialize(&mut buf, false); + } + } + + buf.extend_from_slice(b"\n----------------------------------\n"); + } else { + break 'inner; + } + } + } + sections.push(part_id); + stack.push(iter); + iter = 1..9; + } + if let Some(prev_iter) = stack.pop() { + sections.pop(); + iter = prev_iter; + } else { + break; + } + } + + // Check header fields and partial sections + for sections in [ + vec![Section::HeaderFields { + not: false, + fields: vec!["From".into(), "To".into()], + }], + vec![Section::HeaderFields { + not: true, + fields: vec!["Subject".into(), "Cc".into()], + }], + ] { + DataItem::BodySection { + contents: metadata.body_section(&decoded, §ions, None).unwrap(), + sections: sections.clone(), + origin_octet: None, + } + .serialize(&mut buf, false); + buf.extend_from_slice(b"\n----------------------------------\n"); + DataItem::BodySection { + contents: metadata + .body_section(&decoded, §ions, (10, 25).into()) + .unwrap(), + sections, + origin_octet: 10.into(), + } + .serialize(&mut buf, false); + buf.extend_from_slice(b"\n----------------------------------\n"); + } + + file_name.set_extension("imap"); + + let expected_result = fs::read(&file_name).unwrap(); + + if buf != expected_result { + file_name.set_extension("imap_failed"); + fs::write(&file_name, buf).unwrap(); + panic!("Failed test, written output to {}", file_name.display()); + } + } +} diff --git a/tests/src/imap/condstore.rs b/tests/src/imap/condstore.rs new file mode 100644 index 0000000..bb0dc16 --- /dev/null +++ b/tests/src/imap/condstore.rs @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use imap_proto::ResponseType; + +use crate::imap::{ + AssertResult, + append::{assert_append_message, build_messages}, +}; + +use super::{ImapConnection, Type}; + +pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) { + println!("Running CONDSTORE..."); + + // Test CONDSTORE parameter + imap.send("SELECT INBOX (CONDSTORE)").await; + let hms = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(); + + // Unselect + imap.send("UNSELECT").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Create test folders + imap.send("CREATE Pecorino").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Enable CONDSTORE and QRESYNC + imap.send("ENABLE CONDSTORE QRESYNC").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Make sure modseq did not change after creating a mailbox + imap.send("SELECT Pecorino").await; + assert_eq!( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + hms + ); + imap_check.send("LIST \"\" \"*\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("SELECT Pecorino (CONDSTORE)").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + // SEQ 0: Init + let mut messages = build_messages(); + let mut modseqs = vec![hms]; + + // SEQ 1: Append a message and make sure the modseq increased + assert_append_message(imap, "Pecorino", &messages.pop().unwrap(), ResponseType::Ok).await; + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + assert_ne!(modseqs[modseqs.len() - 1], modseqs[modseqs.len() - 2]); + + // SEQ 2: Move out the message and make sure the modseq increased + imap.send("UID MOVE 1 \"Deleted Items\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* VANISHED 1"); + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + assert_ne!(modseqs[modseqs.len() - 1], modseqs[modseqs.len() - 2]); + + // SEQ 3: Insert message + assert_append_message(imap, "Pecorino", &messages.pop().unwrap(), ResponseType::Ok).await; + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + + // SEQ 4: Insert message + assert_append_message(imap, "Pecorino", &messages.pop().unwrap(), ResponseType::Ok).await; + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + + // SEQ 5: Insert message + assert_append_message(imap, "Pecorino", &messages.pop().unwrap(), ResponseType::Ok).await; + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + + // SEQ 6: Change a message flag + imap.send("UID STORE 4 +FLAGS.SILENT (\\Answered)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_modseq(), + ); + + // SEQ 7: Insert message + assert_append_message(imap, "Pecorino", &messages.pop().unwrap(), ResponseType::Ok).await; + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + + // SEQ 8: Delete a message + imap.send("UID STORE 2 +FLAGS.SILENT (\\Deleted)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("VANISHED 2") + .assert_contains("* 3 EXISTS"); + imap.send("STATUS Pecorino (HIGHESTMODSEQ)").await; + modseqs.push( + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_highest_modseq(), + ); + + // Fetch changes since SEQ 0 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[0] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FETCH (", 3) + .assert_count("VANISHED", 0); + + // Fetch changes since SEQ 1, UID MOVE should count as a deletion + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[1] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 1) + .assert_contains("VANISHED (EARLIER) 1") + .assert_count("FETCH (", 3); + + // Fetch changes since SEQ 3 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[3] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 1) + .assert_contains("VANISHED (EARLIER) 2") + .assert_count("FETCH (", 3); + + // Fetch changes since SEQ 4 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[4] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 1) + .assert_contains("VANISHED (EARLIER) 2") + .assert_count("FETCH (", 2); + + // Fetch changes since SEQ 6 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[6] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 1) + .assert_contains("VANISHED (EARLIER) 2") + .assert_count("FETCH (", 1); + + // Fetch changes since SEQ 7 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[7] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 1) + .assert_contains("VANISHED (EARLIER) 2") + .assert_count("FETCH (", 0); + + // Fetch changes since SEQ 8 + imap.send(&format!( + "UID FETCH 1:* (FLAGS) (CHANGEDSINCE {} VANISHED)", + modseqs[8] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("VANISHED", 0) + .assert_count("FETCH (", 0); + + // Search since MODSEQ + imap.send(&format!("SEARCH RETURN (ALL) MODSEQ {}", modseqs[3])) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("ALL 1:3 MODSEQ"); + + imap_check.send("NOOP").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("3 EXISTS"); + imap_check + .send(&format!("SEARCH MODSEQ {}", modseqs[4])) + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("SEARCH 2 3 (MODSEQ"); + + // Store unchanged since + imap.send(&format!( + "UID STORE 2:5 (UNCHANGEDSINCE {}) +FLAGS.SILENT (\\Junk)", + modseqs[5] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 1 FETCH") + .assert_contains("(UID 3 MODSEQ") + .assert_count("FETCH (", 1) + .assert_contains("[MODIFIED 2,4:5]"); + + imap.send(&format!( + "UID STORE 4,5 (UNCHANGEDSINCE {}) -FLAGS.SILENT (\\Answered)", + modseqs[6] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 2 FETCH") + .assert_contains("(UID 4 MODSEQ") + .assert_count("FETCH (", 1) + .assert_contains("[MODIFIED 5]"); + + // QResync + imap.send("STATUS Pecorino (UIDVALIDITY)").await; + let uid_validity = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_uid_validity(); + + imap.send(&format!( + "SELECT Pecorino (QRESYNC ({} {} 1:5)) ", + uid_validity, modseqs[6] + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FETCH (", 3) + .assert_contains("VANISHED (EARLIER) 2"); +} diff --git a/tests/src/imap/copy_move.rs b/tests/src/imap/copy_move.rs new file mode 100644 index 0000000..a0ed38c --- /dev/null +++ b/tests/src/imap/copy_move.rs @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) { + println!("Running COPY/MOVE tests..."); + + // Check status + imap_check + .send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE RECENT))") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 RECENT 0 SIZE 12193)"); + + // Select INBOX + imap_check.send("SELECT INBOX").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Copying to the same mailbox should fail + imap_check.send("COPY 1:* INBOX").await; + imap_check + .assert_read(Type::Tagged, ResponseType::No) + .await + .assert_response_code("CANNOT"); + + // Copying to a non-existent mailbox should fail + imap_check.send("COPY 1:* \"/dev/null\"").await; + imap_check + .assert_read(Type::Tagged, ResponseType::No) + .await + .assert_response_code("TRYCREATE"); + + // Create test folders + imap_check.send("CREATE \"Scamorza Affumicata\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("CREATE \"Burrata al Tartufo\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Copy messages + imap_check + .send("COPY 1,3,5,7 \"Scamorza Affumicata\"") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COPYUID") + .assert_contains("1:4"); + + // Check status + imap_check + .send("STATUS \"Scamorza Affumicata\" (UIDNEXT MESSAGES UNSEEN SIZE RECENT)") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGES 4") + //.assert_contains("RECENT 4") + .assert_contains("UNSEEN 4") + .assert_contains("UIDNEXT 5") + .assert_contains("SIZE 5851"); + + // Check \Recent flag + /*imap_check.send("SELECT \"Scamorza Affumicata\"").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 4 RECENT"); + imap_check.send("FETCH 1:* (UID FLAGS)").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("\\Recent", 4); + imap_check.send("UNSELECT").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check + .send("STATUS \"Scamorza Affumicata\" (UIDNEXT MESSAGES UNSEEN SIZE RECENT)") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGES 4") + .assert_contains("RECENT 0") + .assert_contains("UNSEEN 4") + .assert_contains("UIDNEXT 5") + .assert_contains("SIZE 5851"); + imap_check.send("SELECT \"Scamorza Affumicata\"").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 0 RECENT"); + imap_check.send("FETCH 1:* (UID FLAGS)").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("\\Recent", 0);*/ + + // Move all messages to Burrata + imap_check.send("SELECT \"Scamorza Affumicata\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("MOVE 1:* \"Burrata al Tartufo\"").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* OK [COPYUID") + .assert_contains("1:4") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE"); + + // Check status + imap_check + .send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"Burrata al Tartufo\" (UIDNEXT 5 MESSAGES 4 UNSEEN 4 SIZE 5851)") + .assert_contains("\"Scamorza Affumicata\" (UIDNEXT 5 MESSAGES 0 UNSEEN 0 SIZE 0)") + .assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193)"); + + // Move the messages back to Scamorza, UIDNEXT should increase. + imap_check.send("SELECT \"Burrata al Tartufo\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_check.send("MOVE 1:* \"Scamorza Affumicata\"").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* OK [COPYUID") + .assert_contains("5:8") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 1 EXPUNGE"); + + // Check status + imap_check + .send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"Burrata al Tartufo\" (UIDNEXT 5 MESSAGES 0 UNSEEN 0 SIZE 0)") + .assert_contains("\"Scamorza Affumicata\" (UIDNEXT 9 MESSAGES 4 UNSEEN 4 SIZE 5851)") + .assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193)"); + + imap_check.send("SELECT \"Burrata al Tartufo\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("SELECT \"Scamorza Affumicata\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID MOVE 5 \"Burrata al Tartufo\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COPYUID"); + + imap_check.send("UID FETCH 1:* (UID)").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("UID 5"); + + imap.send("SELECT \"Burrata al Tartufo\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID MOVE 5 \"Scamorza Affumicata\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COPYUID"); +} diff --git a/tests/src/imap/fetch.rs b/tests/src/imap/fetch.rs new file mode 100644 index 0000000..a6b0436 --- /dev/null +++ b/tests/src/imap/fetch.rs @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) { + println!("Running FETCH tests..."); + + // Examine INBOX + imap.send("EXAMINE INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("10 EXISTS") + .assert_contains("[UIDNEXT 11]"); + + // Fetch all properties available from JMAP + imap.send(concat!( + "FETCH 10 (FLAGS INTERNALDATE PREVIEW OBJECTID ", + "RFC822.SIZE UID ENVELOPE BODYSTRUCTURE)" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FLAGS (Flag_009)") + .assert_contains("RFC822.SIZE 1457") + .assert_contains("UID 10") + .assert_contains("INTERNALDATE") + .assert_contains("OBJECTID (") + .assert_contains("EMAILID ") + .assert_contains("THREADID ") + .assert_contains("but then I thought, why not do both?") + .assert_contains(concat!( + "ENVELOPE (\"Sat, 20 Nov 2021 14:22:01 -0800\" ", + "\"Why not both importing AND exporting? ☺\" ", + "((\"Art Vandelay (Vandelay Industries)\" NIL \"art\" \"vandelay.com\")) ", + "((\"Art Vandelay (Vandelay Industries)\" NIL \"art\" \"vandelay.com\")) ", + "((\"Art Vandelay (Vandelay Industries)\" NIL \"art\" \"vandelay.com\")) ", + "((NIL NIL \"Colleagues\" NIL)", + "(\"James Smythe\" NIL \"james\" \"vandelay.com\")", + "(NIL NIL NIL NIL)(NIL NIL \"Friends\" NIL)", + "(NIL NIL \"jane\" \"example.com\")", + "(\"John Smîth\" NIL \"john\" \"example.com\")", + "(NIL NIL NIL NIL)) NIL NIL NIL NIL)" + )) + .assert_contains(concat!( + "BODYSTRUCTURE ((\"text\" \"html\" (\"charset\" \"us-ascii\") NIL NIL ", + "\"base64\" 239 3 \"07aab44e51c5f1833a5d19f2e1804c4b\" NIL NIL NIL)", + "(\"message\" \"rfc822\" NIL NIL NIL NIL 723 ", + "(NIL \"Exporting my book about coffee tables\" ", + "((\"Cosmo Kramer\" NIL \"kramer\" \"kramerica.com\")) ", + "((\"Cosmo Kramer\" NIL \"kramer\" \"kramerica.com\")) ", + "((\"Cosmo Kramer\" NIL \"kramer\" \"kramerica.com\")) ", + "NIL NIL NIL NIL NIL) ", + "((\"text\" \"plain\" (\"charset\" \"utf-16\") NIL NIL ", + "\"quoted-printable\" 228 3 \"3a942a99cdd8a099ae107d3867ec20fb\" NIL NIL NIL)", + "(\"image\" \"gif\" (\"name\" \"Book about ☕ tables.gif\") ", + "NIL NIL \"Base64\" 56 \"d40fa7f401e9dc2df56cbb740d65ff52\" ", + "(\"attachment\" NIL) NIL NIL) \"mixed\" (\"boundary\" \"giddyup\") NIL NIL NIL)", + " 0 \"cdb0382a03a15601fb1b3c7422521620\" NIL NIL NIL) ", + "\"mixed\" (\"boundary\" \"festivus\") NIL NIL NIL)" + )); + + imap_check.send("EXAMINE INBOX").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("FETCH 10 (ENVELOPE BODYSTRUCTURE)").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains( + "\"=?utf-8?B?V2h5IG5vdCBib3RoIGltcG9ydGluZyBBTkQgZXhwb3J0aW5nPyDimLo=?=\" ", + ) + .assert_contains("(\"=?utf-8?B?Sm9obiBTbcOudGg=?=\" NIL \"john\" \"example.com\")") + .assert_contains("(\"name\" \"=?utf-8?B?Qm9vayBhYm91dCDimJUgdGFibGVzLmdpZg==?=\")"); + + // Fetch bodyparts + imap.send(concat!( + "UID FETCH 10 (BINARY[1] BINARY.SIZE[1] BODY[1.TEXT] BODY[2.1.HEADER] ", + "BINARY[2.1] BODY[MIME] BODY[HEADER.FIELDS (From)]<10.8>)" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("BINARY[1] ~{175}") + .assert_contains("BINARY.SIZE[1] 175") + .assert_contains("BODY[1.TEXT] {239}") + .assert_contains("BODY[2.1.HEADER] {88}") + .assert_contains("BINARY[2.1] ~{108}") + .assert_contains("BODY[MIME] {54}") + .assert_contains("BODY[HEADER.FIELDS (FROM)]<10> {8}") + .assert_contains("“exporting”") + .assert_contains("PGh0bWw+PHA+") + .assert_contains("Content-Transfer-Encoding: quoted-printable") + .assert_contains("Vandelay"); + let fraktur_utf16_le: Vec = "ℌ𝔢𝔩𝔭 𝔪𝔢 𝔢𝔵𝔭𝔬𝔯𝔱 𝔪𝔶 𝔟𝔬𝔬𝔨" + .encode_utf16() + .flat_map(|c| c.to_le_bytes()) + .collect(); + imap.assert_last_contains_bytes(&fraktur_utf16_le); + + // We are in EXAMINE mode, fetching body should not set \Seen + imap.send("UID FETCH 10 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FLAGS (Flag_009)"); + + // Switch to SELECT mode + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Peek bodyparts + imap.send("UID FETCH 10 (BINARY.PEEK[1] BINARY.SIZE[1] BODY.PEEK[1.TEXT])") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("BINARY[1] ~{175}") + .assert_contains("BINARY.SIZE[1] 175") + .assert_contains("BODY[1.TEXT] {239}"); + + // PEEK was used, \Seen should not be set + imap.send("UID FETCH 10 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FLAGS (Flag_009)"); + + // Fetching a body section should set the \Seen flag + imap.send("UID FETCH 10 (BODY[1.TEXT])").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FLAGS") + .assert_contains("\\Seen"); + + // Fetch a sequence + imap.send("FETCH 1:5,7:10 (UID FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 1 FETCH (UID 1 ") + .assert_contains("* 2 FETCH (UID 2 ") + .assert_contains("* 3 FETCH (UID 3 ") + .assert_contains("* 4 FETCH (UID 4 ") + .assert_contains("* 5 FETCH (UID 5 ") + .assert_contains("* 7 FETCH (UID 7 ") + .assert_contains("* 8 FETCH (UID 8 ") + .assert_contains("* 9 FETCH (UID 9 ") + .assert_contains("* 10 FETCH (UID 10 ") + .assert_count("\\Recent", 0); + + imap.send("FETCH 7:* (UID FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 7 FETCH (UID 7 ") + .assert_contains("* 8 FETCH (UID 8 ") + .assert_contains("* 9 FETCH (UID 9 ") + .assert_contains("* 10 FETCH (UID 10 "); + + // Fetch using a saved search + imap.send("UID SEARCH RETURN (SAVE) FROM \"nathaniel\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("FETCH $ (UID PREVIEW)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 1 FETCH (UID 1 ") + .assert_contains("* 4 FETCH (UID 4 ") + .assert_contains("* 6 FETCH (UID 6 ") + .assert_contains("Some text appears here") + .assert_contains("plain text version of message goes here") + .assert_contains("This is implicitly typed plain US-ASCII text."); + + // A failing command in a pipelined batch does not swallow the tagged completion of the commands queued behind it + imap.send_raw(concat!( + "_p UID FETCH 1:* (UID) (CHANGEDSINCE 1 VANISHED)\r\n", + "_x FETCH 1 (UID)\r\n" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("_p BAD") + .assert_contains("* 1 FETCH (UID 1"); +} diff --git a/tests/src/imap/idle.rs b/tests/src/imap/idle.rs new file mode 100644 index 0000000..0aa47f9 --- /dev/null +++ b/tests/src/imap/idle.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::smtp::SmtpConnection; + +use super::{AssertResult, ImapConnection, Type}; +use imap_proto::ResponseType; +use std::time::Duration; + +const SLEEP: Duration = Duration::from_millis(200); + +pub async fn test( + imap: &mut ImapConnection, + imap_check: &mut ImapConnection, + is_cluster_test: bool, +) { + println!("Running IDLE tests..."); + + // Switch connection to IDLE mode + imap_check.send("CREATE Parmeggiano").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("SELECT Parmeggiano").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("NOOP").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_check.send("IDLE").await; + imap_check + .assert_read(Type::Continuation, ResponseType::Ok) + .await; + + // Expect a new mailbox update + imap.send("CREATE Provolone").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("LIST () \"/\" \"Provolone\""); + + // Insert a message in the new folder and expect an update + let message = "From: test@domain.com\nSubject: Test\n\nTest message\n"; + imap.send(&format!("APPEND Provolone {{{}}}", message.len())) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged(message).await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("STATUS \"Provolone\"") + .assert_contains("MESSAGES 1") + .assert_contains("UNSEEN 1") + .assert_contains("UIDNEXT 2"); + + // Change message to Seen and expect an update + imap.send("SELECT Provolone").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("STORE 1:* +FLAGS (\\Seen)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("STATUS \"Provolone\"") + .assert_contains("MESSAGES 1") + .assert_contains("UNSEEN 0") + .assert_contains("UIDNEXT 2"); + + // Delete message and expect an update + imap.send("STORE 1:* +FLAGS (\\Deleted)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("CLOSE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("STATUS \"Provolone\"") + .assert_contains("MESSAGES 0") + .assert_contains("UNSEEN 0") + .assert_contains("UIDNEXT 2"); + + // Delete folder and expect an update + imap.send("DELETE Provolone").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("LIST (\\NonExistent) \"/\" \"Provolone\""); + + // Add a message to Inbox and expect an update + imap.send(&format!("APPEND Parmeggiano {{{}}}", message.len())) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged(message).await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("MESSAGES 1") + .assert_contains("UNSEEN 1"); + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("* 1 EXISTS"); + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("* 1 FETCH (FLAGS () UID 1)"); + + // Delete message and expect an update + imap.send("SELECT Parmeggiano").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("STORE 1 +FLAGS (\\Deleted)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("* 1 FETCH (FLAGS (\\Deleted) UID 1)"); + + imap.send("UID EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 1 EXPUNGE") + .assert_contains("* 0 EXISTS"); + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("MESSAGES 0") + .assert_contains("UNSEEN 0"); + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("* 1 EXPUNGE"); + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("* 0 EXISTS"); + + // Test SMTP delivery notifications + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.com", + &["jdoe@example.com"], + concat!( + "From: bill@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "X-Spam-Status: No\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + if is_cluster_test { + tokio::time::sleep(SLEEP).await; + } + imap_check + .assert_read(Type::Status, ResponseType::Ok) + .await + .assert_contains("STATUS \"INBOX\"") + .assert_contains(if is_cluster_test { + "MESSAGES 1" + } else { + "MESSAGES 11" + }); + + // Stop IDLE mode + imap_check.send_raw("DONE").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_check.send("NOOP").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; +} diff --git a/tests/src/imap/mailbox.rs b/tests/src/imap/mailbox.rs new file mode 100644 index 0000000..18ee995 --- /dev/null +++ b/tests/src/imap/mailbox.rs @@ -0,0 +1,455 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use imap::op::list::matches_pattern; +use imap_proto::ResponseType; + +use crate::utils::server::TestServer; + +use super::{AssertResult, ImapConnection, Type}; + +pub async fn test( + mut imap: &mut ImapConnection, + mut imap_check: &mut ImapConnection, + test: &TestServer, +) { + println!("Running mailbox tests..."); + + // Pattern matching tests + mailbox_matches_pattern(); + + // Create third connection for testing + let mut other_conn = test.account("jdoe@example.com").imap_client().await; + + // List folders + imap.send("LIST \"\" \"*\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders([("INBOX", [""]), ("Deleted Items", [""])], true); + + // Create folders + imap.send("CREATE \"Tofu\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("CREATE \"Fruit\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("CREATE \"Fruit/Apple\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("CREATE \"Fruit/Apple/Green\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("CREATE \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Select folder from another connection + other_conn.send("SELECT \"Tofu\"").await; + other_conn.assert_read(Type::Tagged, ResponseType::Ok).await; + other_conn.send("SELECT \"L&APg-bende opgaver\"").await; + other_conn.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Make sure folders are visible + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST \"\" \"*\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", [""]), + ("Deleted Items", [""]), + ("Fruit", [""]), + ("Fruit/Apple", [""]), + ("Fruit/Apple/Green", [""]), + ("Tofu", [""]), + ("L&APg-bende opgaver", [""]), + ], + true, + ); + } + imap.send("DELETE \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Special use folders that already exist should not be allowed + imap.send("CREATE \"Second trash\" (USE (\\Trash))").await; + imap.assert_read(Type::Tagged, ResponseType::No).await; + + // Every command in a pipelined batch is answered, even after a failure + imap.send_raw(concat!( + "_p1 STATUS \"Tofu\" (MESSAGES)\r\n", + "_p2 STATUS \"Does Not Exist\" (MESSAGES)\r\n", + "_x STATUS \"Fruit/Apple\" (MESSAGES)\r\n" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("_p1 OK") + .assert_contains("_p2 NO [NONEXISTENT]") + .assert_contains("* STATUS \"Fruit/Apple\""); + + // Enable IMAP4rev2 + imap.send("ENABLE IMAP4rev2").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Create and delete using IMAP4rev2 + imap.send("CREATE \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("SELECT \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UNSELECT \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("DELETE \"L&APg-bende opgaver\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Create missing parent folders + imap.send("CREATE \"/Vegetable/Broccoli\" (USE (\\Important))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("CREATE \" Cars/Electric /4 doors/ Red/\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST \"\" \"*\" RETURN (CHILDREN SPECIAL-USE)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["HasNoChildren", ""]), + ("Deleted Items", ["HasNoChildren", "Trash"]), + ("Cars/Electric/4 doors/Red", ["HasNoChildren", ""]), + ("Cars/Electric/4 doors", ["HasChildren", ""]), + ("Cars/Electric", ["HasChildren", ""]), + ("Cars", ["HasChildren", ""]), + ("Fruit", ["HasChildren", ""]), + ("Fruit/Apple", ["HasChildren", ""]), + ("Fruit/Apple/Green", ["HasNoChildren", ""]), + ("Vegetable", ["HasChildren", ""]), + ("Vegetable/Broccoli", ["HasNoChildren", "\\Important"]), + ("Tofu", ["HasNoChildren", ""]), + ], + true, + ); + } + + // Rename folders + imap.send("RENAME \"Fruit/Apple/Green\" \"Fruit/Apple/Red\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("RENAME \"Cars\" \"Vehicles\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("RENAME \"Vegetable/Broccoli\" \"Veggies/Green/Broccoli\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("RENAME \"Tofu\" \"INBOX\"").await; + imap.assert_read(Type::Tagged, ResponseType::No).await; + imap.send("RENAME \"Tofu\" \"Inbox/Tofu\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("RENAME \"Deleted Items\" \"Recycle Bin\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST \"\" \"*\" RETURN (CHILDREN SPECIAL-USE)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["HasChildren", ""]), + ("INBOX/Tofu", ["HasNoChildren", ""]), + ("Recycle Bin", ["HasNoChildren", "Trash"]), + ("Vehicles/Electric/4 doors/Red", ["HasNoChildren", ""]), + ("Vehicles/Electric/4 doors", ["HasChildren", ""]), + ("Vehicles/Electric", ["HasChildren", ""]), + ("Vehicles", ["HasChildren", ""]), + ("Fruit", ["HasChildren", ""]), + ("Fruit/Apple", ["HasChildren", ""]), + ("Fruit/Apple/Red", ["HasNoChildren", ""]), + ("Vegetable", ["HasNoChildren", ""]), + ("Veggies", ["HasChildren", ""]), + ("Veggies/Green", ["HasChildren", ""]), + ("Veggies/Green/Broccoli", ["HasNoChildren", ""]), + ], + true, + ); + } + + // Delete folders + imap.send("DELETE \"INBOX/Tofu\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("DELETE \"Vegetable\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("DELETE \"Vehicles\"").await; + imap.assert_read(Type::Tagged, ResponseType::No).await; + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST \"\" \"*\" RETURN (CHILDREN SPECIAL-USE)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["HasNoChildren", ""]), + ("Recycle Bin", ["HasNoChildren", "Trash"]), + ("Vehicles/Electric/4 doors/Red", ["HasNoChildren", ""]), + ("Vehicles/Electric/4 doors", ["HasChildren", ""]), + ("Vehicles/Electric", ["HasChildren", ""]), + ("Vehicles", ["HasChildren", ""]), + ("Fruit", ["HasChildren", ""]), + ("Fruit/Apple", ["HasChildren", ""]), + ("Fruit/Apple/Red", ["HasNoChildren", ""]), + ("Veggies", ["HasChildren", ""]), + ("Veggies/Green", ["HasChildren", ""]), + ("Veggies/Green/Broccoli", ["HasNoChildren", ""]), + ], + true, + ); + } + + // Subscribe + imap.send("SUBSCRIBE \"INBOX\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("SUBSCRIBE \"Vehicles/Electric/4 doors/Red\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST \"\" \"*\" RETURN (SUBSCRIBED SPECIAL-USE)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["Subscribed", ""]), + ("Recycle Bin", ["", "Trash"]), + ("Vehicles/Electric/4 doors/Red", ["Subscribed", ""]), + ("Vehicles/Electric/4 doors", ["", ""]), + ("Vehicles/Electric", ["", ""]), + ("Vehicles", ["", ""]), + ("Fruit", ["", ""]), + ("Fruit/Apple", ["", ""]), + ("Fruit/Apple/Red", ["", ""]), + ("Veggies", ["", ""]), + ("Veggies/Green", ["", ""]), + ("Veggies/Green/Broccoli", ["", ""]), + ], + true, + ); + } + + // Filter by subscribed including children + imap.send("LIST (SUBSCRIBED) \"\" \"*\" RETURN (CHILDREN)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["Subscribed", "HasNoChildren"]), + ( + "Vehicles/Electric/4 doors/Red", + ["Subscribed", "HasNoChildren"], + ), + ], + true, + ); + + // Recursive match including children + imap.send("LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"*\" RETURN (CHILDREN)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", ["Subscribed", "HasNoChildren"]), + ( + "Vehicles/Electric/4 doors/Red", + ["Subscribed", "HasNoChildren"], + ), + ( + "Vehicles/Electric/4 doors", + ["\"CHILDINFO\" (\"SUBSCRIBED\")", "HasChildren"], + ), + ( + "Vehicles/Electric", + ["\"CHILDINFO\" (\"SUBSCRIBED\")", "HasChildren"], + ), + ( + "Vehicles", + ["\"CHILDINFO\" (\"SUBSCRIBED\")", "HasChildren"], + ), + ], + true, + ); + + // Imap4rev1 LSUB + imap.send("LSUB \"\" \"*\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [("INBOX", [""]), ("Vehicles/Electric/4 doors/Red", [""])], + true, + ); + + // Unsubscribe + imap.send("UNSUBSCRIBE \"Vehicles/Electric/4 doors/Red\"") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + for imap in [&mut imap, &mut imap_check] { + imap.send("LIST (SUBSCRIBED RECURSIVEMATCH) \"\" \"*\" RETURN (CHILDREN)") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders([("INBOX", ["Subscribed", "HasNoChildren"])], true); + } + + // LIST Filters + imap.send("LIST \"\" \"%\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", [""]), + ("Recycle Bin", [""]), + ("Vehicles", [""]), + ("Fruit", [""]), + ("Veggies", [""]), + ], + true, + ); + + imap.send("LIST \"\" \"*/Red\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("Vehicles/Electric/4 doors/Red", [""]), + ("Fruit/Apple/Red", [""]), + ], + true, + ); + + imap.send("LIST \"\" \"Fruit/*\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders([("Fruit/Apple/Red", [""]), ("Fruit/Apple", [""])], true); + + imap.send("LIST \"\" \"Fruit/%\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders([("Fruit/Apple", [""])], true); + + // Restore Trash folder's original name + imap.send("RENAME \"Recycle Bin\" \"Deleted Items\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Shared folder creation tests + let mut imap_jane = test.account("jane.smith@example.com").imap_client().await; + imap_jane + .send("CREATE \"Shared Folders/support@example.com/INBOX/Test\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane + .send("CREATE \"Shared Folders/support@example.com/Test\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap_jane + .send("CREATE \"Shared Folders/support@example.com/Test/TestSubfolder\"") + .await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_jane.send("LIST \"\" \"*\"").await; + imap_jane + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_folders( + [ + ("INBOX", [""]), + ("Deleted Items", [""]), + ("Drafts", [""]), + ("Junk Mail", [""]), + ("Sent Items", [""]), + ("Shared Folders", [""]), + ("Shared Folders/support@example.com", [""]), + ("Shared Folders/support@example.com/Deleted Items", [""]), + ("Shared Folders/support@example.com/Drafts", [""]), + ("Shared Folders/support@example.com/INBOX", [""]), + ("Shared Folders/support@example.com/INBOX/Test", [""]), + ("Shared Folders/support@example.com/Junk Mail", [""]), + ("Shared Folders/support@example.com/Sent Items", [""]), + ("Shared Folders/support@example.com/Test", [""]), + ( + "Shared Folders/support@example.com/Test/TestSubfolder", + [""], + ), + ], + true, + ); +} + +fn mailbox_matches_pattern() { + let mailboxes = [ + "imaptest", + "imaptest/test", + "imaptest/test2", + "imaptest/test3", + "imaptest/test3/test4", + "imaptest/test3/test4/test5", + "foobar/test", + "foobar/test/test", + "foobar/test1/test1", + ]; + + for (pattern, expected_match) in [ + ( + "imaptest/%", + vec!["imaptest/test", "imaptest/test2", "imaptest/test3"], + ), + ("imaptest/%/%", vec!["imaptest/test3/test4"]), + ( + "imaptest/*", + vec![ + "imaptest/test", + "imaptest/test2", + "imaptest/test3", + "imaptest/test3/test4", + "imaptest/test3/test4/test5", + ], + ), + ("imaptest/*test4", vec!["imaptest/test3/test4"]), + ( + "imaptest/*test*", + vec![ + "imaptest/test", + "imaptest/test2", + "imaptest/test3", + "imaptest/test3/test4", + "imaptest/test3/test4/test5", + ], + ), + ("imaptest/%3/%", vec!["imaptest/test3/test4"]), + ("imaptest/%3/%4", vec!["imaptest/test3/test4"]), + ("imaptest/%t*4", vec!["imaptest/test3/test4"]), + ("*st/%3/%4/%5", vec!["imaptest/test3/test4/test5"]), + ( + "*%*%*%", + vec![ + "imaptest", + "imaptest/test", + "imaptest/test2", + "imaptest/test3", + "imaptest/test3/test4", + "imaptest/test3/test4/test5", + "foobar/test", + "foobar/test/test", + "foobar/test1/test1", + ], + ), + ("foobar*test", vec!["foobar/test", "foobar/test/test"]), + ] { + let patterns = vec![pattern.into()]; + let mut matched_mailboxes = Vec::new(); + for mailbox in mailboxes { + if matches_pattern(&patterns, mailbox) { + matched_mailboxes.push(mailbox); + } + } + assert_eq!(matched_mailboxes, expected_match, "for pattern {}", pattern); + } +} diff --git a/tests/src/imap/managesieve.rs b/tests/src/imap/managesieve.rs new file mode 100644 index 0000000..88ef3c2 --- /dev/null +++ b/tests/src/imap/managesieve.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::AssertResult; +use crate::utils::{server::TestServer, sieve::SieveConnection}; +use imap_proto::ResponseType; + +pub async fn test(test: &TestServer) { + println!("Running ManageSieve tests..."); + + // Connect to ManageSieve + let mut sieve = SieveConnection::connect().await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("IMPLEMENTATION"); + + // Authenticate + let account = test.account("jdoe@example.com"); + sieve.authenticate(account.name(), account.secret()).await; + + // CheckScript + sieve.send("CHECKSCRIPT \"if true { keep; }\"").await; + sieve.assert_read(ResponseType::Ok).await; + sieve.send("CHECKSCRIPT \"keep :invalidtag;\"").await; + sieve.assert_read(ResponseType::No).await; + + // PutScript + sieve + .send_literal("PUTSCRIPT \"simple script\" ", "if true { keep; }\r\n") + .await; + sieve.assert_read(ResponseType::Ok).await; + + // PutScript should overwrite existing scripts + sieve.send("PUTSCRIPT \"holidays\" \"discard;\"").await; + sieve.assert_read(ResponseType::Ok).await; + sieve + .send_literal( + "PUTSCRIPT \"holidays\" ", + "require \"vacation\"; vacation \"Gone fishin'\";\r\n", + ) + .await; + sieve.assert_read(ResponseType::Ok).await; + + // GetScript + sieve.send("GETSCRIPT \"simple script\"").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("if true"); + sieve.send("GETSCRIPT \"holidays\"").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("Gone fishin'"); + sieve.send("GETSCRIPT \"dummy\"").await; + sieve.assert_read(ResponseType::No).await; + + // ListScripts + sieve.send("LISTSCRIPTS").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("simple script") + .assert_contains("holidays") + .assert_count("ACTIVE", 0); + + // RenameScript + sieve + .send("RENAMESCRIPT \"simple script\" \"minimalist script\"") + .await; + sieve.assert_read(ResponseType::Ok).await; + sieve + .send("RENAMESCRIPT \"holidays\" \"minimalist script\"") + .await; + sieve + .assert_read(ResponseType::No) + .await + .assert_contains("ALREADYEXISTS"); + + // SetActive + sieve.send("SETACTIVE \"holidays\"").await; + sieve.assert_read(ResponseType::Ok).await; + + sieve.send("LISTSCRIPTS").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("minimalist script") + .assert_contains("holidays\" ACTIVE"); + + // Deleting an active script should not be allowed + sieve.send("DELETESCRIPT \"holidays\"").await; + sieve + .assert_read(ResponseType::No) + .await + .assert_contains("ACTIVE"); + + // Deactivate all + sieve.send("SETACTIVE \"\"").await; + sieve.assert_read(ResponseType::Ok).await; + + sieve.send("LISTSCRIPTS").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_contains("minimalist script") + .assert_contains("holidays") + .assert_count("ACTIVE", 0); + + // DeleteScript + sieve.send("DELETESCRIPT \"holidays\"").await; + sieve.assert_read(ResponseType::Ok).await; + sieve.send("DELETESCRIPT \"minimalist script\"").await; + sieve.assert_read(ResponseType::Ok).await; + + sieve.send("LISTSCRIPTS").await; + sieve + .assert_read(ResponseType::Ok) + .await + .assert_count("minimalist script", 0) + .assert_count("holidays", 0); +} diff --git a/tests/src/imap/messagelimit.rs b/tests/src/imap/messagelimit.rs new file mode 100644 index 0000000..6c13898 --- /dev/null +++ b/tests/src/imap/messagelimit.rs @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type, expand_uid_list}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { + println!("Running MESSAGELIMIT tests..."); + + // Both limits are advertised, and SAVELIMIT must not be the stricter of the + // two or MESSAGELIMIT-only clients would hit unexpected COPY rejections + imap.send("CAPABILITY").await; + let capabilities = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGELIMIT=") + .assert_contains("SAVELIMIT="); + assert!( + advertised_limit(&capabilities, "SAVELIMIT=") + >= advertised_limit(&capabilities, "MESSAGELIMIT="), + "SAVELIMIT must be at least MESSAGELIMIT, got {capabilities:?}" + ); + + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // The test mailboxes hold far fewer messages than the limit, so no + // command should be truncated and no MESSAGELIMIT code should appear + for command in [ + "UID FETCH 1:* (UID)", + "UID SEARCH ALL", + "UID STORE 1:* +FLAGS.SILENT (\\Seen)", + "UID STORE 1:* -FLAGS.SILENT (\\Seen)", + ] { + imap.send(command).await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("MESSAGELIMIT"); + } + + // EXPUNGE and CLOSE are never limited + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("MESSAGELIMIT"); + + // COPY is governed by SAVELIMIT and stays well under it here + imap.send("CREATE Savelimit").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID COPY 1:* Savelimit").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("[COPYUID ") + .assert_not_contains("MESSAGELIMIT"); + imap.send("DELETE Savelimit").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // UIDAFTER and UIDBEFORE are the search criteria added by RFC 9738 + imap.send("UID SEARCH UIDAFTER 0").await; + let all = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("UID SEARCH ALL").await; + let expected = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert_eq!( + search_results(&all), + search_results(&expected), + "UIDAFTER 0 must match every message" + ); + + // UIDBEFORE 1 can never match anything + imap.send("UID SEARCH UIDBEFORE 1").await; + let none = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + search_results(&none).is_empty(), + "UIDBEFORE 1 must match nothing, got {none:?}" + ); + + // The two criteria partition the mailbox around a pivot UID + let pivot = search_results(&expected) + .into_iter() + .max() + .expect("INBOX should not be empty"); + + imap.send(&format!("UID SEARCH UIDBEFORE {pivot}")).await; + let before = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + search_results(&before).iter().all(|uid| *uid < pivot), + "UIDBEFORE {pivot} returned a UID at or above the pivot" + ); + + imap.send(&format!("UID SEARCH UIDAFTER {pivot}")).await; + let after = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + search_results(&after).is_empty(), + "UIDAFTER on the highest UID must match nothing, got {after:?}" + ); + + // Both criteria are still usable inside a boolean expression + imap.send(&format!("UID SEARCH UIDAFTER 0 UIDBEFORE {pivot}")) + .await; + let between = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert_eq!(search_results(&between), search_results(&before)); +} + +fn advertised_limit(response: &[String], capability: &str) -> u32 { + response + .iter() + .find_map(|line| { + line.split_whitespace() + .find_map(|token| token.strip_prefix(capability)) + .map(|limit| limit.trim_end_matches(']').parse().unwrap()) + }) + .unwrap_or_else(|| panic!("No {capability} capability in {response:?}")) +} + +fn search_results(response: &[String]) -> Vec { + let mut uids: Vec = response + .iter() + .find_map(|line| { + let line = line.trim_end(); + if let Some(list) = line.strip_prefix("* SEARCH") { + Some( + list.split_whitespace() + .filter_map(|uid| uid.parse().ok()) + .collect(), + ) + } else if line.starts_with("* ESEARCH") { + // * ESEARCH (TAG "_x") UID ALL 1:12 + Some( + line.split_once(" ALL ") + .map(|(_, list)| expand_uid_list(list).into_iter().collect()) + .unwrap_or_default(), + ) + } else { + None + } + }) + .unwrap_or_default(); + uids.sort_unstable(); + uids +} diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs new file mode 100644 index 0000000..5652bb6 --- /dev/null +++ b/tests/src/imap/mod.rs @@ -0,0 +1,323 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod antispam; +pub mod append; +pub mod basic; +pub mod body_structure; +pub mod condstore; +pub mod copy_move; +pub mod fetch; +pub mod idle; +pub mod mailbox; +pub mod managesieve; +pub mod messagelimit; +pub mod objectid; +pub mod pop; +pub mod search; +pub mod store; +pub mod thread; +pub mod uidbatches; +pub mod uidonly; + +use crate::utils::{ + imap::{AssertResult, ImapConnection, Type}, + server::TestServerBuilder, +}; +use ahash::AHashSet; +use imap_proto::ResponseType; +use registry::{ + schema::{ + enums::{Permission, SpecialUse}, + prelude::ObjectType, + structs::{ + Email, EmailFolder, Expression, Imap, MemoryLookupKey, MtaStageAuth, MtaStageData, + SpamClassifier, SpamTag, SpamTagScore, + }, + }, + types::float::Float, +}; +use serde_json::json; +use std::{path::PathBuf, time::Instant}; +use utils::map::vec_map::VecMap; + +#[tokio::test(flavor = "multi_thread")] +pub async fn imap_tests() { + let mut test = TestServerBuilder::new("imap_tests") + .await + .with_default_listeners() + .await + .build() + .await; + + // Create admin account + let admin = test.create_admin_account("admin@example.com").await; + + // Create test users + for (name, secret, description, aliases) in [ + ( + "jdoe@example.com", + "12345 + extra safety", + "John Doe", + &["john.doe@example.com"][..], + ), + ( + "jane.smith@example.com", + "abcde + extra safety", + "Jane Smith", + &["jane@example.com"][..], + ), + ( + "foobar@example.com", + "098765 + extra safety", + "Bill Foobar", + &["bill.foobar@example.com"][..], + ), + ( + "popper@example.com", + "a_pop3_safe_secret_with_extra_safety", + "Karl Popper", + &["karl.popper@example.com"][..], + ), + ( + "sgd@example.com", + "secret2 + extra safety", + "Sigmund Gudmund Dudmundsson", + &[][..], + ), + ( + "spamtrap@example.com", + "secret3 + extra safety", + "Spam Trap", + &[][..], + ), + ] { + let account = admin + .create_user_account( + name, + secret, + description, + aliases, + vec![Permission::UnlimitedRequests, Permission::UnlimitedUploads], + ) + .await; + test.insert_account(account); + } + + // Create test group + test.insert_account( + admin + .create_group_account("support@example.com", "Support Group", &[]) + .await, + ); + + // Add Jane to the Support group + let support_id = test.account("support@example.com").id(); + admin + .registry_update_object( + ObjectType::Account, + test.account("jane.smith@example.com").id(), + json!({ + "memberGroupIds": { support_id: true }, + }), + ) + .await; + + // Add test settings + admin + .registry_create_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(SpamClassifier { + min_ham_samples: 10, + min_spam_samples: 10, + ..Default::default() + }) + .await; + admin + .registry_create_object(Email { + default_folders: VecMap::from_iter( + [ + (SpecialUse::Inbox, "Inbox"), + (SpecialUse::Sent, "Sent Items"), + (SpecialUse::Trash, "Deleted Items"), + (SpecialUse::Junk, "Junk Mail"), + (SpecialUse::Drafts, "Drafts"), + ] + .into_iter() + .map(|(use_, name)| { + ( + use_, + EmailFolder { + name: name.into(), + subscribe: false, + ..Default::default() + }, + ) + }), + ), + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageData { + add_delivered_to_header: false, + enable_spam_filter: Expression { + else_: "recipients[0] != 'popper@example.com'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(SpamTag::Score(SpamTagScore { + score: Float::new(10.0), + tag: "PROB_SPAM_LOW".into(), + })) + .await; + admin + .registry_create_object(SpamTag::Score(SpamTagScore { + score: Float::new(10.0), + tag: "PROB_SPAM_HIGH".into(), + })) + .await; + admin + .registry_create_object(SpamTag::Score(SpamTagScore { + score: Float::new(100.0), + tag: "SPAM_TRAP".into(), + })) + .await; + admin + .registry_create_object(MemoryLookupKey { + is_glob_pattern: true, + key: "spamtrap@*".into(), + namespace: "spam-traps".into(), + }) + .await; + admin.reload_settings().await; + admin.reload_lookup_stores().await; + + test.insert_account(admin); + + let start_time = Instant::now(); + + // Body structure tests + body_structure::test(); + + // Connect to IMAP server + let mut imap_check = ImapConnection::connect(b"_y ").await; + let mut imap = ImapConnection::connect(b"_x ").await; + for imap in [&mut imap, &mut imap_check] { + imap.assert_read(Type::Untagged, ResponseType::Ok).await; + } + + // Unauthenticated tests + basic::test(&mut imap, &mut imap_check).await; + + // Login + let account = test.account("jdoe@example.com"); + for imap in [&mut imap, &mut imap_check] { + imap.authenticate(account.name(), account.secret()).await; + } + + // Test GETJMAPACCESS (RFC 9698) + imap.send("GETJMAPACCESS").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* JMAPACCESS \"") + .assert_contains("/.well-known/jmap\""); + + // Delete folders + for mailbox in ["Drafts", "Junk Mail", "Sent Items"] { + imap.send(&format!("DELETE \"{}\"", mailbox)).await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + } + + mailbox::test(&mut imap, &mut imap_check, &test).await; + append::test(&mut imap, &mut imap_check, &test).await; + search::test(&mut imap, &mut imap_check, &test).await; + fetch::test(&mut imap, &mut imap_check).await; + objectid::test(&test).await; + store::test(&mut imap, &mut imap_check, &test).await; + copy_move::test(&mut imap, &mut imap_check).await; + thread::test(&mut imap, &mut imap_check, &test).await; + idle::test(&mut imap, &mut imap_check, false).await; + condstore::test(&mut imap, &mut imap_check).await; + acl::test(&mut imap, &mut imap_check, &test).await; + uidbatches::test(&mut imap, &mut imap_check).await; + messagelimit::test(&mut imap, &mut imap_check).await; + + // UIDONLY cannot be disabled once enabled, so it uses its own connection + uidonly::test(&test).await; + + // Logout + for imap in [&mut imap, &mut imap_check] { + imap.send("UNAUTHENTICATE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("LOGOUT").await; + imap.assert_read(Type::Untagged, ResponseType::Bye).await; + } + + // Antispam training + antispam::test(&test).await; + + // Run ManageSieve tests + managesieve::test(&test).await; + + // Run POP3 tests + pop::test(&test).await; + + // Print elapsed time + let elapsed = start_time.elapsed(); + println!( + "Elapsed: {}.{:03}s", + elapsed.as_secs(), + elapsed.subsec_millis() + ); + + // Remove test data + if test.is_reset() { + test.temp_dir.delete(); + } +} + +pub fn expand_uid_list(list: &str) -> AHashSet { + let mut items = AHashSet::new(); + for uid in list.split(',') { + if let Some((start, end)) = uid.split_once(':') { + let start = start.parse::().unwrap(); + let end = end.parse::().unwrap(); + for uid in start..=end { + items.insert(uid); + } + } else { + items.insert(uid.parse::().unwrap()); + } + } + + items +} + +fn resources_dir() -> PathBuf { + let mut resources = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + resources.push("resources"); + resources.push("imap"); + resources +} diff --git a/tests/src/imap/objectid.rs b/tests/src/imap/objectid.rs new file mode 100644 index 0000000..124b43a --- /dev/null +++ b/tests/src/imap/objectid.rs @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, Type}; +use crate::utils::server::TestServer; +use imap_proto::ResponseType; + +pub async fn test(test: &TestServer) { + println!("Running OBJECTID+ tests..."); + + let account = test.account("jdoe@example.com"); + let account_id = account.id_string().to_string(); + let mut imap = account.imap_client().await; + + // OBJECTID+ is advertised + imap.send("CAPABILITY").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("OBJECTID+") + .assert_count("OBJECTID ", 0); + + // Before activation no object identifiers are leaked + imap.send("SELECT INBOX").await; + let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + !lines + .iter() + .any(|l| l.contains("OBJECTID") || l.contains("MAILBOXID")), + "Pre-activation SELECT leaked object identifiers: {lines:?}" + ); + + // Explicit activation via ENABLE + imap.send("ENABLE OBJECTID+").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("ENABLED OBJECTID+"); + + // SELECT now returns a compound OBJECTID with MAILBOXID and ACCOUNTID + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("[OBJECTID (") + .assert_contains("MAILBOXID ") + .assert_contains(&format!("ACCOUNTID {account_id}")); + + // STATUS OBJECTID returns the compound for the queried mailbox, and an + // already-activated session is not sent a second ENABLED response + imap.send("STATUS INBOX (OBJECTID)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("OBJECTID (") + .assert_contains("MAILBOXID ") + .assert_contains(&format!("ACCOUNTID {account_id}")) + .assert_not_contains("ENABLED OBJECTID+"); + + // FETCH OBJECTID returns EMAILID and THREADID but never ACCOUNTID + imap.send("UID FETCH 1 (OBJECTID)").await; + let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + lines + .iter() + .any(|l| l.contains("OBJECTID (") && l.contains("EMAILID ") && l.contains("THREADID ")), + "FETCH OBJECTID must include EMAILID and THREADID: {lines:?}" + ); + assert!( + !lines.iter().any(|l| l.contains("ACCOUNTID")), + "FETCH OBJECTID must not include ACCOUNTID: {lines:?}" + ); + + // Commands that do not request the OBJECTID item never emit it, even once activated + imap.send("UID FETCH 1 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("OBJECTID"); + imap.send("STATUS INBOX (MESSAGES)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("OBJECTID"); + + // EXAMINE returns the compound OBJECTID response code just like SELECT + imap.send("EXAMINE INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("[OBJECTID (") + .assert_contains("MAILBOXID ") + .assert_contains(&format!("ACCOUNTID {account_id}")); + + // CREATE returns the compound OBJECTID response code + imap.send("CREATE \"ObjIdTest\"").await; + let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + let mailbox_id = extract_value(&lines, "MAILBOXID "); + assert!( + lines + .iter() + .any(|l| l.contains(&format!("ACCOUNTID {account_id}"))), + "CREATE OBJECTID missing ACCOUNTID: {lines:?}" + ); + + // RENAME returns the compound OBJECTID response code and preserves the MAILBOXID + imap.send("RENAME \"ObjIdTest\" \"ObjIdRenamed\"").await; + let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + let renamed_id = extract_value(&lines, "MAILBOXID "); + assert_eq!( + mailbox_id, renamed_id, + "RENAME must preserve the MAILBOXID: {lines:?}" + ); + + // Identifier-based selection resolves the mailbox regardless of its current name + imap.send(&format!( + "SELECT \"DoesNotExist\" (OBJECTID (MAILBOXID {mailbox_id} ACCOUNTID {account_id}))" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")); + + // An unknown MAILBOXID falls back to selecting by name + imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (MAILBOXID abcdefgh ACCOUNTID abcdefgh))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")); + + // An undecodable identifier falls back to selecting by name instead of failing + imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (MAILBOXID 456))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")); + + // Unrecognised keys in the OBJECTID parameter are ignored + imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (FOOBAR baz MAILBOXID 456))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")); + + // Cleanup + imap.send("UNSELECT").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("DELETE \"ObjIdRenamed\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Implicit activation via the STATUS attribute on a fresh session + let mut imap2 = test.account("jdoe@example.com").imap_client().await; + imap2.send("STATUS INBOX (OBJECTID)").await; + let lines = imap2.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + lines.iter().any(|l| l.contains("ENABLED OBJECTID+")), + "STATUS did not implicitly activate OBJECTID+: {lines:?}" + ); + + // Implicit activation via the SELECT OBJECTID parameter on a fresh session + let mut imap3 = test.account("jdoe@example.com").imap_client().await; + imap3.send("SELECT INBOX (OBJECTID)").await; + let lines = imap3.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + lines.iter().any(|l| l.contains("ENABLED OBJECTID+")), + "SELECT (OBJECTID) did not implicitly activate OBJECTID+: {lines:?}" + ); + assert!( + lines.iter().any(|l| l.contains("[OBJECTID (")), + "SELECT (OBJECTID) did not return a compound OBJECTID: {lines:?}" + ); + + // Activation via FETCH then a plain SELECT/CREATE must still carry the compound code + let mut imap4 = test.account("jdoe@example.com").imap_client().await; + imap4.send("SELECT INBOX").await; + imap4.assert_read(Type::Tagged, ResponseType::Ok).await; + imap4.send("UID FETCH 1 (OBJECTID)").await; + let lines = imap4.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + lines.iter().any(|l| l.contains("ENABLED OBJECTID+")), + "FETCH (OBJECTID) did not implicitly activate OBJECTID+: {lines:?}" + ); + imap4.send("SELECT INBOX").await; + imap4 + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("[OBJECTID ("); + imap4.send("CREATE \"ObjIdTest4\"").await; + imap4 + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("[OBJECTID ("); + imap4.send("DELETE \"ObjIdTest4\"").await; + imap4.assert_read(Type::Tagged, ResponseType::Ok).await; +} + +fn extract_value(lines: &[String], key: &str) -> String { + for line in lines { + if let Some((_, rest)) = line.split_once(key) { + return rest + .split([' ', ')']) + .next() + .expect("Missing value delimiter") + .to_string(); + } + } + panic!("Key {key:?} not found in {lines:?}"); +} diff --git a/tests/src/imap/pop.rs b/tests/src/imap/pop.rs new file mode 100644 index 0000000..854b276 --- /dev/null +++ b/tests/src/imap/pop.rs @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + imap::AssertResult, + pop3::{Pop3Connection, ResponseType}, + server::TestServer, + smtp::SmtpConnection, +}; + +pub async fn test(test: &TestServer) { + println!("Running POP3 tests..."); + + // Send 3 test emails + for i in 0..3 { + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.com", + &["popper@example.com"], + &format!( + concat!( + "From: bill@example.com\r\n", + "To: popper@example.com\r\n", + "Subject: TPS Report {}\r\n", + "X-Spam-Status: No\r\n", + "\r\n", + "I'm going to need those TPS {} reports ASAP.\r\n", + "..\r\n", + "So, if you could do that, that'd be great." + ), + i, i + ), + ) + .await; + } + + // Connect to POP3 + let account = test.account("popper@example.com"); + let mut pop3 = Pop3Connection::connect().await; + + // Capabilities + pop3.send("CAPA").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("SASL PLAIN") + .assert_contains("IMPLEMENTATION"); + + // Noop + pop3.send("NOOP").await; + pop3.assert_read(ResponseType::Ok).await; + + // Authenticate user/pass + pop3.send("PASS secret").await; + pop3.assert_read(ResponseType::Err).await; + pop3.send("USER popper@example.com").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("PASS wrong_secret").await; + pop3.assert_read(ResponseType::Err).await; + pop3.send("USER popper@example.com").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send(&format!("PASS {}", account.secret())).await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("QUIT").await; + + // Authenticate using AUTH PLAIN + let mut pop3 = Pop3Connection::connect().await; + pop3.authenticate(account.name(), account.secret()).await; + + // STAT + pop3.send("STAT").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 3 609"); + + // UTF8 + pop3.send("UTF8").await; + pop3.assert_read(ResponseType::Ok).await; + + // LIST + pop3.send("LIST").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 3 messages") + .assert_contains("1 203") + .assert_contains("2 203") + .assert_contains("3 203"); + pop3.send("LIST 2").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 2 203"); + + // UIDL + pop3.send("UIDL").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 3 messages") + .assert_contains("1 ") + .assert_contains("2 ") + .assert_contains("3 "); + pop3.send("UIDL 2").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 2 "); + + // RETR + pop3.send("RETR 1").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 203 octets") + .assert_contains("I'm going to need those TPS 0 reports ASAP.") + .assert_contains("So, if you could do that, that'd be great."); + pop3.send("RETR 3").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 203 octets") + .assert_contains("I'm going to need those TPS 2 reports ASAP.") + .assert_contains("So, if you could do that, that'd be great."); + pop3.send("RETR 4").await; + pop3.assert_read(ResponseType::Err).await; + + // TOP + pop3.send("TOP 1 4").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 203 octets") + .assert_contains("Subject: TPS Report 0") + .assert_not_contains("I'm going to need those TPS 0 reports ASAP."); + pop3.send("TOP 3 4").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("+OK 203 octets") + .assert_contains("Subject: TPS Report 2") + .assert_not_contains("I'm going to need those TPS 2 reports ASAP."); + + // DELE + RSET + QUIT (should not delete messages) + pop3.send("DELE 1").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("DELE 4").await; + pop3.assert_read(ResponseType::Err).await; + pop3.send("RSET").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("QUIT").await; + let mut pop3 = Pop3Connection::connect().await; + pop3.authenticate(account.name(), account.secret()).await; + pop3.send("STAT").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 3 609"); + + // DELE + QUIT (should delete messages) + pop3.send("DELE 2").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("QUIT").await; + pop3.assert_read(ResponseType::Ok).await; + let mut pop3 = Pop3Connection::connect().await; + pop3.authenticate(account.name(), account.secret()).await; + pop3.send("STAT").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 2 406"); + pop3.send("TOP 1 4").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("TPS Report 0"); + pop3.send("TOP 2 4").await; + pop3.assert_read(ResponseType::Multiline) + .await + .assert_contains("TPS Report 2"); + + // DELE using pipelining + pop3.send("DELE 1\r\nDELE 2").await; + pop3.assert_read(ResponseType::Ok).await; + pop3.assert_read(ResponseType::Ok).await; + pop3.send("QUIT").await; + pop3.assert_read(ResponseType::Ok).await; + let mut pop3 = Pop3Connection::connect().await; + pop3.authenticate(account.name(), account.secret()).await; + pop3.send("STAT").await; + pop3.assert_read(ResponseType::Ok) + .await + .assert_contains("+OK 0 0"); + pop3.send("QUIT").await; +} diff --git a/tests/src/imap/search.rs b/tests/src/imap/search.rs new file mode 100644 index 0000000..7aa7e75 --- /dev/null +++ b/tests/src/imap/search.rs @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; + +use super::{AssertResult, ImapConnection, Type}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection, test: &TestServer) { + println!("Running SEARCH tests..."); + + // Searches without selecting a mailbox should fail. + imap.send("SEARCH RETURN (MIN MAX COUNT ALL) ALL").await; + imap.assert_read(Type::Tagged, ResponseType::Bad).await; + + // Select INBOX + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("10 EXISTS") + .assert_contains("[UIDNEXT 11]"); + imap_check.send("SELECT INBOX").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Min, Max and Count + imap.send("SEARCH RETURN (MIN MAX COUNT ALL) ALL").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COUNT 10 MIN 1 MAX 10 ALL 1,10"); + imap_check.send("UID SEARCH ALL").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 1 2 3 4 5 6 7 8 9 10"); + + // Filters + imap_check + .send("UID SEARCH OR FROM nathaniel SUBJECT argentina") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 1 3 4 6"); + + imap_check + .send("UID SEARCH UNSEEN OR KEYWORD Flag_007 KEYWORD Flag_004") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 5 8"); + + imap_check + .send("UID SEARCH TEXT coffee FROM vandelay SUBJECT exporting SENTON 20-Nov-2021") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 10"); + + imap_check + .send(concat!( + "UID SEARCH CHARSET UTF-8 TEXT {75+}\r\n", + "ℌ𝔢𝔩𝔭 𝔪𝔢 𝔢𝔵𝔭𝔬𝔯𝔱 𝔪𝔶 𝔟𝔬𝔬𝔨" + )) + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 10"); + + imap_check + .send("UID SEARCH NOT (FROM nathaniel ANSWERED)") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 2 3 5 7 8 9 10"); + + imap_check + .send("UID SEARCH UID 0:6 LARGER 1000 SMALLER 2000") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 1 2"); + + // Saved search + imap_check.send( + "UID SEARCH RETURN (SAVE ALL) OR OR FROM nathaniel FROM vandelay OR SUBJECT rfc FROM gore", + ) + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("1,3:4,6,8,10"); + + imap_check.send("UID SEARCH NOT $").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 2 5 7 9"); + + imap_check + .send("UID SEARCH $ SMALLER 1000 SUBJECT section") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SEARCH 8"); + + imap_check.send("UID SEARCH RETURN (MIN MAX) NOT $").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MIN 2 MAX 9"); + + // Sort + imap_check + .send("UID SORT (REVERSE SUBJECT REVERSE DATE) UTF-8 FROM Nathaniel") + .await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_equals("* SORT 6 4 1"); + + imap.send("UID SORT RETURN (COUNT ALL) (DATE SUBJECT) UTF-8 ALL") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(if !test.server.search_store().is_mysql() { + "COUNT 10 ALL 6,4:5,1,10,3,7:8,2,9" + } else { + "COUNT 10 ALL 9,3,7:8,2,6,4:5,1,10" + }); //6,4:5,1,10,9,3,7:8,2"); +} diff --git a/tests/src/imap/store.rs b/tests/src/imap/store.rs new file mode 100644 index 0000000..2b36171 --- /dev/null +++ b/tests/src/imap/store.rs @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use crate::utils::server::TestServer; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, test: &TestServer) { + println!("Running STORE tests..."); + + // Select INBOX + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("10 EXISTS") + .assert_contains("[UIDNEXT 11]"); + + // Set all messages to flag "Seen" + imap.send("UID STORE 1:10 +FLAGS.SILENT (\\Seen)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FLAGS", 0); + + // Check that the flags were set + imap.send("UID FETCH 1:* (Flags)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("\\Seen", 10); + + // Check status + imap.send("STATUS INBOX (UIDNEXT MESSAGES UNSEEN)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGES 10") + .assert_contains("UNSEEN 0") + .assert_contains("UIDNEXT 11"); + + // Remove Seen flag from all messages + imap.send("UID STORE 1:10 -FLAGS (\\Seen)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FLAGS", 10) + .assert_count("Seen", 0); + + // Check that the flags were removed + imap.send("UID FETCH 1:* (Flags)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("\\Seen", 0); + imap.send("STATUS INBOX (UIDNEXT MESSAGES UNSEEN)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGES 10") + .assert_contains("UNSEEN 10") + .assert_contains("UIDNEXT 11"); + + // Store using saved searches + test.wait_for_tasks().await; + imap.send("SEARCH RETURN (SAVE) FROM nathaniel").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID STORE $ +FLAGS (\\Answered)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FLAGS", 3); + + // Remove Answered flag + imap.send("UID STORE 1:* -FLAGS (\\Answered)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("FLAGS", 3) + .assert_count("Answered", 0); +} diff --git a/tests/src/imap/thread.rs b/tests/src/imap/thread.rs new file mode 100644 index 0000000..ddf306b --- /dev/null +++ b/tests/src/imap/thread.rs @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ImapConnection, Type, append::build_messages}; +use crate::{ + imap::{AssertResult, expand_uid_list}, + utils::server::TestServer, +}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, test: &TestServer) { + println!("Running THREAD tests..."); + + // Create test messages + let messages = build_messages(); + + // Insert messages using Multiappend + imap.send("CREATE Manchego").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + for (pos, message) in messages.iter().enumerate() { + if pos == 0 { + imap.send(&format!("APPEND Manchego {{{}}}", message.len())) + .await; + } else { + imap.send_untagged(&format!(" {{{}}}", message.len())).await; + } + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + if pos < messages.len() - 1 { + imap.send_raw(message).await; + } else { + imap.send_untagged(message).await; + assert_eq!( + expand_uid_list( + &imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .into_append_uid() + ) + .len(), + messages.len(), + ); + } + } + + // Obtain ThreadId and MessageId of the first message + test.wait_for_tasks().await; + imap.send("SELECT Manchego").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + let mut email_id = None; + let mut thread_id = None; + imap.send("UID FETCH 1 (OBJECTID)").await; + for line in imap.assert_read(Type::Tagged, ResponseType::Ok).await { + if let Some((_, value)) = line.split_once("EMAILID ") { + email_id = value + .split([' ', ')']) + .next() + .expect("Missing delimiter") + .to_string() + .into(); + } + if let Some((_, value)) = line.split_once("THREADID ") { + thread_id = value + .split([' ', ')']) + .next() + .expect("Missing delimiter") + .to_string() + .into(); + } + } + let email_id = email_id.expect("Missing EMAILID"); + let thread_id = thread_id.expect("Missing THREADID"); + + // 4 different threads are expected + imap.send("THREAD REFERENCES UTF-8 1:*").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("(1 2 3 4)") + .assert_contains("(5 6 7 8)") + .assert_contains("(9 10 11 12)"); + + // Filter by subject (mySQL does not support searching for short keywords) + if !test.server.search_store().is_mysql() { + imap.send("THREAD REFERENCES UTF-8 SUBJECT T1").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("(5 6 7 8)") + .assert_count("(1 2 3 4)", 0) + .assert_count("(9 10 11 12)", 0); + } + + // Filter by threadId and messageId + imap.send(&format!( + "UID THREAD REFERENCES UTF-8 THREADID {}", + thread_id + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("(1 2 3 4)") + .assert_count("(", 1); + + imap.send(&format!("UID THREAD REFERENCES UTF-8 EMAILID {}", email_id)) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("(1)") + .assert_count("(", 1); + + // Delete all messages + imap.send("STORE 1:* +FLAGS.SILENT (\\Deleted)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("EXPUNGE", 13); +} diff --git a/tests/src/imap/uidbatches.rs b/tests/src/imap/uidbatches.rs new file mode 100644 index 0000000..fc0c4a4 --- /dev/null +++ b/tests/src/imap/uidbatches.rs @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use imap_proto::ResponseType; + +pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { + println!("Running UIDBATCHES tests..."); + + // The capability is only advertised once authenticated + imap.send("CAPABILITY").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("UIDBATCHES"); + + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // A batch size below the configured minimum is rejected with TOOFEW + imap.send("UIDBATCHES 10").await; + imap.assert_read(Type::Tagged, ResponseType::No) + .await + .assert_response_code("TOOFEW"); + + // Reversed batch ranges are a client bug + imap.send("UIDBATCHES 500 20:10").await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_response_code("CLIENTBUG"); + + // More batches than the server is willing to return + imap.send("UIDBATCHES 500 1:100000").await; + imap.assert_read(Type::Tagged, ResponseType::No) + .await + .assert_response_code("TOOMANY"); + + // Malformed arguments + for command in ["UIDBATCHES", "UIDBATCHES abc", "UIDBATCHES 500 10"] { + imap.send(command).await; + imap.assert_read(Type::Tagged, ResponseType::Bad).await; + } + + // INBOX holds fewer messages than one batch, so a single range covering + // the whole UID space is returned and it always reaches down to UID 1 + imap.send("UIDBATCHES 500").await; + let response = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* UIDBATCHES (TAG ") + .assert_contains(":1"); + let ranges = parse_ranges(&response); + assert_eq!(ranges.len(), 1, "Expected a single batch, got {ranges:?}"); + assert_eq!(ranges[0].1, 1, "The last batch must reach UID 1"); + + // Requesting a batch range beyond what exists returns an empty response + imap.send("UIDBATCHES 500 50:60").await; + let response = imap + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* UIDBATCHES (TAG "); + assert!( + parse_ranges(&response).is_empty(), + "Expected no ranges, got {response:?}" + ); + + // Asking for the first batch explicitly matches the unbounded form + imap.send("UIDBATCHES 500 1:1").await; + let response = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + assert_eq!(parse_ranges(&response), ranges); + + // UIDBATCHES must never populate the SEARCHRES $ variable + imap.send("UID SEARCH RETURN (SAVE) ALL").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UIDBATCHES 500").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID FETCH $ (UID)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("FETCH"); + + // Every batch must tile the UID space with no gaps + imap.send("UIDBATCHES 500").await; + let response = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + let ranges = parse_ranges(&response); + for window in ranges.windows(2) { + assert_eq!( + window[1].0, + window[0].1 - 1, + "Batches must be contiguous, got {ranges:?}" + ); + } +} + +fn parse_ranges(response: &[String]) -> Vec<(u32, u32)> { + let line = response + .iter() + .find(|line| line.contains("* UIDBATCHES (TAG ")) + .unwrap_or_else(|| panic!("No UIDBATCHES response in {response:?}")); + let Some((_, list)) = line.split_once(") ") else { + return Vec::new(); + }; + + list.trim() + .split(',') + .filter(|range| !range.is_empty()) + .map(|range| { + let (high, low) = range + .split_once(':') + .unwrap_or_else(|| panic!("Malformed UID range {range:?}")); + (high.parse().unwrap(), low.parse().unwrap()) + }) + .collect() +} diff --git a/tests/src/imap/uidonly.rs b/tests/src/imap/uidonly.rs new file mode 100644 index 0000000..dbafeb5 --- /dev/null +++ b/tests/src/imap/uidonly.rs @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{AssertResult, ImapConnection, Type}; +use crate::utils::server::TestServer; +use imap_proto::ResponseType; + +pub async fn test(test: &TestServer) { + println!("Running UIDONLY tests..."); + + // UIDONLY is a one-way switch, so it runs on a connection of its own + let account = test.account("jdoe@example.com"); + let mut imap = ImapConnection::connect(b"_u ").await; + imap.assert_read(Type::Untagged, ResponseType::Ok).await; + imap.authenticate(account.name(), account.secret()).await; + + // The capability is only advertised once authenticated + imap.send("CAPABILITY").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("UIDONLY"); + + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Message numbers still work before UIDONLY is enabled + imap.send("FETCH 1 (UID)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(" FETCH (") + .assert_not_contains("UIDFETCH"); + + // Enable UIDONLY + imap.send("ENABLE UIDONLY").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* ENABLED UIDONLY"); + + // Every sequence-number command is now rejected with BAD [UIDREQUIRED] + for command in [ + "FETCH 1 (UID)", + "STORE 1 +FLAGS (\\Seen)", + "SEARCH ALL", + "COPY 1 \"Deleted Items\"", + "MOVE 1 \"Deleted Items\"", + "SORT (ARRIVAL) UTF-8 ALL", + "THREAD REFERENCES UTF-8 ALL", + ] { + imap.send(command).await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_response_code("UIDREQUIRED"); + } + + // The UID variants keep working and now answer with UIDFETCH + imap.send("UID FETCH 1:* (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(" UIDFETCH (") + .assert_not_contains(" FETCH ("); + + // The UID is the first token of a UIDFETCH response + imap.send("UID FETCH 1 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* 1 UIDFETCH ("); + + // UID STORE also answers with UIDFETCH + imap.send("UID STORE 1 +FLAGS (\\Answered)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(" UIDFETCH (") + .assert_not_contains(" FETCH ("); + imap.send("UID STORE 1 -FLAGS (\\Answered)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Plain EXPUNGE stays legal, it carries no message numbers + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // The bare sequence set criterion is banned + imap.send("UID SEARCH 1:*").await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_response_code("UIDREQUIRED"); + + // RFC 9586 names "UID " and ALL as the replacements, so both + // must keep working; UIDBATCHES results are consumed through the former + imap.send("UID SEARCH UID 1:*").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID SEARCH ALL").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID FETCH 1:* (UID)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // The SEARCHRES $ variable is not a sequence set either + imap.send("UID SEARCH RETURN (SAVE) ALL").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID SEARCH $").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // UIDNOTSTICKY must never be advertised alongside UIDONLY + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("UIDNOTSTICKY"); + + // Deletions are reported with VANISHED rather than EXPUNGE + imap.send("UID STORE 1 +FLAGS (\\Deleted)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("* VANISHED ") + .assert_not_contains("* 1 EXPUNGE"); + + // The QRESYNC sequence matching parameter is rejected once UIDONLY is on + imap.send("ENABLE QRESYNC").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("SELECT INBOX (QRESYNC (1 1 1:10 (1,2,3 1,2,3)))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_response_code("UIDREQUIRED"); + + // RFC 8437 requires UNAUTHENTICATE to clear every enabled extension, so + // message numbers must work again for the next user of this connection + imap.send("UNAUTHENTICATE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.authenticate(account.name(), account.secret()).await; + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("FETCH 1 (UID)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(" FETCH (") + .assert_not_contains("UIDFETCH"); + + imap.send("LOGOUT").await; + imap.assert_read(Type::Untagged, ResponseType::Bye).await; +} diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs new file mode 100644 index 0000000..13af879 --- /dev/null +++ b/tests/src/jmap/calendar/acl.rs @@ -0,0 +1,711 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use calcard::jscalendar::JSCalendarProperty; +use jmap_proto::{ + object::{calendar::CalendarProperty, share_notification::ShareNotificationProperty}, + request::method::MethodObject, +}; +use serde_json::json; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Calendar ACL tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + + // Create test calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_calendar_id = response.created(0).id().to_string(); + let john_event_id = john + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "a8df6573-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "John's Simple Event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &john_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = jane + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let jane_calendar_id = response.created(0).id().to_string(); + let jane_event_id = jane + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "uid": "a8df6575-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "Jane's Simple Event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &jane_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + // Verify myRights + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": true, + "mayWriteOwn": true, + "mayReadFreeBusy": true, + "mayUpdatePrivate": true, + "mayRSVP": true + }, + "shareWith": {} + })); + + // Obtain share notifications + let mut jane_share_change_id = jane + .jmap_get( + MethodObject::ShareNotification, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Make sure Jane has no access + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::Calendar, + Vec::<&str>::new(), + [john_calendar_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Share calendar with Jane + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + "shareWith": { + &jane_id : { + "mayReadItems": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "shareWith": { + &jane_id : { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + } + } + })); + + // Verify Jane can access the event + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + } + })); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [john_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_event_id, + "title": "John's Simple Event", + })); + + // Verify Jane received a share notification + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": false, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Updating and deleting should fail + assert_eq!( + jane.jmap_update_account( + john, + MethodObject::Calendar, + [(&john_calendar_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_calendar_id) + .description(), + "You are not allowed to modify this calendar." + ); + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::Calendar, + [&john_calendar_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_calendar_id) + .description(), + "You are not allowed to delete this calendar." + ); + assert!( + jane.jmap_update_account( + john, + MethodObject::CalendarEvent, + [(&john_event_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_event_id) + .description() + .contains("You are not allowed to modify calendar"), + ); + assert!( + jane.jmap_destroy_account( + john, + MethodObject::CalendarEvent, + [&john_event_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_event_id) + .description() + .contains("You are not allowed to remove events from calendar"), + ); + + // Grant Jane write access + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}/mayWriteAll"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + } + })); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Creating a root folder should fail + assert_eq!( + jane.jmap_create_account( + john, + MethodObject::Calendar, + [json!({ + "name": "A new shared calendar", + })], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Cannot create calendars in a shared account." + ); + + // Copy Jane's event into John's calendar + let john_copied_event_id = jane + .jmap_copy( + jane, + john, + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "calendarIds": { + &john_calendar_id: true + } + }), + )], + false, + ) + .await + .copied(&jane_event_id) + .id() + .to_string(); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::CalendarIds, + JSCalendarProperty::Title, + ], + [john_copied_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_copied_event_id, + "title": "Jane's Simple Event", + "calendarIds": { + &john_calendar_id: true + } + })); + + // Destroy the copied event + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::CalendarEvent, + [john_copied_event_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [&john_copied_event_id] + ); + + // Update John's event + jane.jmap_update_account( + john, + MethodObject::CalendarEvent, + [( + &john_event_id, + json!({ + "title": "John's Updated Event", + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_event_id); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [john_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_event_id, + "title": "John's Updated Event", + })); + + // Update John's calendar name + jane.jmap_update_account( + john, + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + "name": "Jane's version of John's Calendar", + "description": "This is John's calendar, but Jane can edit it now" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Jane's version of John's Calendar", + "description": "This is John's calendar, but Jane can edit it now" + })); + + // John should still see the old name + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "description": null + })); + + // Revoke Jane's access + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}"): () + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "shareWith": {} + })); + + // Verify Jane can no longer access the calendar or its events + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::Calendar, + Vec::<&str>::new(), + [john_calendar_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": false, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Grant Jane delete access once again + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}/mayReadItems"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + + // Verify Jane can delete the calendar + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::Calendar, + [john_calendar_id.as_str()], + [("onDestroyRemoveEvents", true)], + ) + .await + .destroyed() + .collect::>(), + [john_calendar_id.as_str()] + ); + + // Destroy all mailboxes + john.destroy_all_calendars().await; + jane.destroy_all_calendars().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/calendar/alarm.rs b/tests/src/jmap/calendar/alarm.rs new file mode 100644 index 0000000..10050b0 --- /dev/null +++ b/tests/src/jmap/calendar/alarm.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{IntoJmapSet, JmapUtils}, + server::TestServer, +}; +use futures::StreamExt; +use jmap_client::{ + CalendarAlert, PushObject, client_ws::WebSocketMessage, event_source::PushNotification, +}; +use jmap_proto::request::method::MethodObject; +use mail_parser::DateTime; +use serde_json::json; +use std::time::Instant; +use store::write::now; +use tokio::sync::mpsc; + +pub async fn test(test: &TestServer) { + println!("Running Calendar Alarm tests..."); + let account = test.account("jdoe@example.com"); + let account_id = account.id_string(); + let client = account.jmap_client().await; + let client_ws = account.jmap_client().await; + + // Create test calendar + let response = account + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Alarming Calendar", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let calendar_id = response.created(0).id().to_string(); + + // Connect to EventSource + let (event_tx, mut event_rx) = mpsc::channel::(100); + let mut notifications = client + .event_source(None::>, false, 1.into(), None) + .await + .unwrap(); + tokio::spawn(async move { + while let Some(notification) = notifications.next().await { + if let Err(_err) = event_tx.send(notification.unwrap()).await { + break; + } + } + }); + + // Connect to WebSocket + let mut ws_stream = client_ws.connect_ws().await.unwrap(); + let (stream_tx, mut stream_rx) = mpsc::channel::(100); + tokio::spawn(async move { + while let Some(change) = ws_stream.next().await { + if stream_tx.send(change.unwrap()).await.is_err() { + break; + } + } + }); + client_ws + .enable_push_ws(None::>, None::<&str>) + .await + .unwrap(); + + // Create test event + let response = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "calendarIds": ([calendar_id.as_str()].into_jmap_set()), + "description": "What mirror where?!", + "timeZone": "Etc/UTC", + "start": DateTime::from_timestamp(now() as i64 + 5) + .to_rfc3339().trim_end_matches("Z").to_string(), + "title": "See the pretty girl in that mirror there", + "alerts": { + "k1": { + "@type": "Alert", + "trigger": { + "@type": "OffsetTrigger", + "offset": "-PT2S" + }, + "action": "display" + }, + "k2": { + "trigger": { + "@type": "OffsetTrigger", + "offset": "-PT4S" + }, + "action": "display", + "@type": "Alert" + } + }, + "locations": { + "0b7168ae-ed3e-5eae-9540-89ba3a469b16": { + "name": "West Side", + "@type": "Location" + } + }, + "uid": "2371c2d9-a136-43b0-bba3-f6ab249ad46e", + "duration": "P1D" + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let event_id = response.created(0).id().to_string(); + + // Wait for alarm notifications + let start = Instant::now(); + let mut ws_events = Vec::new(); + let mut es_events = Vec::new(); + + while start.elapsed().as_secs() < 7 && (ws_events.len() < 2 || es_events.len() < 2) { + tokio::select! { + Some(notification) = event_rx.recv() => { + if let PushNotification::CalendarAlert(alert) = notification { + es_events.push(alert); + } + } + Some(message) = stream_rx.recv() => { + match message { + WebSocketMessage::PushNotification(PushObject::CalendarAlert(alert)) => { + ws_events.push(alert); + } + WebSocketMessage::PushNotification(PushObject::Group {entries} ) => { + ws_events.extend(entries.into_iter().filter_map(|entry| { + if let PushObject::CalendarAlert(alert) = entry { + Some(alert) + } else { + None + } + })); + } + _ => {} + } + } + _ = tokio::time::sleep(std::time::Duration::from_secs(6)) => { + break; + } + } + } + + let expected_alerts = vec![ + CalendarAlert { + account_id: account_id.to_string(), + calendar_event_id: event_id.clone(), + uid: "2371c2d9-a136-43b0-bba3-f6ab249ad46e".to_string(), + recurrence_id: None, + alert_id: "k2".to_string(), + }, + CalendarAlert { + account_id: account_id.to_string(), + calendar_event_id: event_id.clone(), + uid: "2371c2d9-a136-43b0-bba3-f6ab249ad46e".to_string(), + recurrence_id: None, + alert_id: "k1".to_string(), + }, + ]; + + assert_eq!( + es_events, expected_alerts, + "EventSource alarms do not match" + ); + assert_eq!(ws_events, expected_alerts, "WebSocket alarms do not match"); + + // Cleanup + account.destroy_all_calendars().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/calendar/calendars.rs b/tests/src/jmap/calendar/calendars.rs new file mode 100644 index 0000000..9ca9a8e --- /dev/null +++ b/tests/src/jmap/calendar/calendars.rs @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{ChangeType, JmapUtils}, + server::TestServer, +}; +use jmap_proto::{object::calendar::CalendarProperty, request::method::MethodObject}; +use serde_json::json; + +pub async fn test(test: &TestServer) { + println!("Running Calendar tests..."); + let account = test.account("jdoe@example.com"); + + // Make sure the default calendar exists + let response = account + .jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + CalendarProperty::SortOrder, + CalendarProperty::Color, + CalendarProperty::TimeZone, + CalendarProperty::IsSubscribed, + CalendarProperty::IsDefault, + CalendarProperty::IsVisible, + CalendarProperty::IncludeInAvailability, + CalendarProperty::DefaultAlertsWithTime, + CalendarProperty::DefaultAlertsWithoutTime, + ], + Vec::<&str>::new(), + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 1); + let default_calendar_id = list[0].id().to_string(); + assert_eq!( + list[0], + json!({ + "id": default_calendar_id, + "name": "Stalwart Calendar (jdoe@example.com)", + "description": null, + "sortOrder": 0, + "isSubscribed": true, + "isDefault": true, + "color": null, + "timeZone": null, + "isVisible": true, + "includeInAvailability": "all", + "defaultAlertsWithTime": {}, + "defaultAlertsWithoutTime": {} + }) + ); + let change_id = response.state(); + + // Create Calendar + let calendar_id = account + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test calendar", + "description": "My personal calendar", + "sortOrder": 1, + "isSubscribed": true, + "color": "#ff0000", + "timeZone": "Indian/Christmas", + "isVisible": false, + "includeInAvailability": "attending", + "defaultAlertsWithTime": { + "0": { + "action": "display", + "trigger": { + "relativeTo": "start", + "offset": "PT15M" + } + }, + "1": { + "action": "email", + "trigger": { + "relativeTo": "end", + "offset": "PT30M" + } + } + }, + "defaultAlertsWithoutTime": { + "0": { + "action": "display", + "trigger": { + "relativeTo": "start", + "offset": "P1D" + } + }, + "1": { + "action": "email", + "trigger": { + "relativeTo": "end", + "offset": "P2D" + } + } + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::Calendar, change_id) + .await + .changes() + .collect::>(), + [ChangeType::Created(&calendar_id)] + ); + + // Get Calendar + let response = account + .jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + CalendarProperty::SortOrder, + CalendarProperty::Color, + CalendarProperty::TimeZone, + CalendarProperty::IsSubscribed, + CalendarProperty::IsDefault, + CalendarProperty::IsVisible, + CalendarProperty::IncludeInAvailability, + CalendarProperty::DefaultAlertsWithTime, + CalendarProperty::DefaultAlertsWithoutTime, + ], + [&calendar_id], + ) + .await; + + response.list()[0].assert_is_equal(json!({ + "name": "Test calendar", + "description": "My personal calendar", + "sortOrder": 1, + "isSubscribed": true, + "isVisible": false, + "isDefault": false, + "color": "#ff0000", + "timeZone": "Indian/Christmas", + "includeInAvailability": "attending", + "defaultAlertsWithTime": { + "0": { + "@type": "Alert", + "action": "display", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "start", + "offset": "PT15M" + } + }, + "1": { + "@type": "Alert", + "action": "email", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "end", + "offset": "PT30M" + } + } + }, + "defaultAlertsWithoutTime": { + "0": { + "@type": "Alert", + "action": "display", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "start", + "offset": "P1D" + } + }, + "1": { + "@type": "Alert", + "action": "email", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "end", + "offset": "P2D" + } + } + }, + "id": calendar_id, + })); + + // Update Calendar and set it as default + account + .jmap_update( + MethodObject::Calendar, + [( + calendar_id.as_str(), + json!({ + "name": "Updated calendar", + "description": "My updated personal calendar", + "sortOrder": 2, + "isSubscribed": false, + "isVisible": true, + "timeZone": null, + "color": null, + "includeInAvailability": "none", + "defaultAlertsWithTime": { + "0": { + "action": "email", + "trigger": { + "relativeTo": "start", + "offset": "PT10M" + } + } + }, + "defaultAlertsWithoutTime/0": { + "action": "email", + "trigger": { + "relativeTo": "start", + "offset": "P3D" + } + }, + "defaultAlertsWithoutTime/1": null, + "defaultAlertsWithoutTime/2": { + "action": "display", + "trigger": { + "relativeTo": "end", + "offset": "P1W" + } + } + }), + )], + [("onSuccessSetIsDefault", calendar_id.as_str())], + ) + .await + .updated(&calendar_id); + + // Validate changes + let response = account + .jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + CalendarProperty::SortOrder, + CalendarProperty::Color, + CalendarProperty::TimeZone, + CalendarProperty::IsSubscribed, + CalendarProperty::IsDefault, + CalendarProperty::IsVisible, + CalendarProperty::IncludeInAvailability, + CalendarProperty::DefaultAlertsWithTime, + CalendarProperty::DefaultAlertsWithoutTime, + ], + [&calendar_id, &default_calendar_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": calendar_id, + "name": "Updated calendar", + "description": "My updated personal calendar", + "sortOrder": 2, + "isSubscribed": false, + "isDefault": true, + "color": null, + "timeZone": null, + "isVisible": true, + "includeInAvailability": "none", + "defaultAlertsWithTime": { + "0": { + "@type": "Alert", + "action": "email", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "start", + "offset": "PT10M" + } + } + }, + "defaultAlertsWithoutTime": { + "0": { + "@type": "Alert", + "action": "email", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "start", + "offset": "P3D" + } + }, + "2": { + "@type": "Alert", + "action": "display", + "trigger": { + "@type": "OffsetTrigger", + "relativeTo": "end", + "offset": "P1W" + } + } + } + })); + response.list()[1].assert_is_equal(json!({ + "id": default_calendar_id, + "name": "Stalwart Calendar (jdoe@example.com)", + "description": (), + "sortOrder": 0, + "isSubscribed": true, + "isDefault": false, + "color": null, + "timeZone": null, + "isVisible": true, + "includeInAvailability": "all", + "defaultAlertsWithTime": {}, + "defaultAlertsWithoutTime": {} + })); + + // Create an event + let _ = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "calendarIds": { + &calendar_id: true + }, + "@type": "Event", + "uid": "a8df6573-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "Some event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H" + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id(); + + // Try destroying the calendar (should fail) + assert_eq!( + account + .jmap_destroy( + MethodObject::Calendar, + [&calendar_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&calendar_id) + .typ(), + "calendarHasEvent" + ); + + // Destroy using force + assert_eq!( + account + .jmap_destroy( + MethodObject::Calendar, + [&calendar_id], + [("onDestroyRemoveEvents", true)], + ) + .await + .destroyed() + .collect::>(), + vec![&calendar_id] + ); + + // Destroy all mailboxes + account.destroy_all_calendars().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs new file mode 100644 index 0000000..3d74b89 --- /dev/null +++ b/tests/src/jmap/calendar/event.rs @@ -0,0 +1,1194 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{ChangeType, IntoJmapSet, JmapUtils}, + server::TestServer, +}; +use ahash::AHashSet; +use calcard::jscalendar::JSCalendarProperty; +use dav_proto::Depth; +use groupware::{DavResourceName, cache::GroupwareCache}; +use hyper::StatusCode; +use jmap_proto::request::method::MethodObject; +use serde_json::{Value, json}; +use std::str::FromStr; +use types::{collection::SyncCollection, id::Id}; + +pub async fn test(test: &TestServer) { + println!("Running Calendar Event tests..."); + let account = test.account("jdoe@example.com"); + + // Create test calendars + let response = account + .jmap_create( + MethodObject::Calendar, + [ + json!({ + "name": "Holy Calendar, Batman!", + "timeZone": "Europe/Vatican", + }), + json!({ + "name": "Calendar with Alerts", + "defaultAlertsWithTime": { + "abc": { + "action": "display", + "trigger": { + "relativeTo": "start", + "offset": "PT15M" + } + } + }, + }), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let calendar1_id = response.created(0).id().to_string(); + let calendar2_id = response.created(1).id().to_string(); + + // Obtain state + let change_id = account + .jmap_get( + MethodObject::CalendarEvent, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Create test events + let event_1 = test_jscalendar_1().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let event_2 = test_jscalendar_2().with_property( + JSCalendarProperty::::CalendarIds, + [calendar2_id.as_str()].into_jmap_set(), + ); + let event_3 = test_jscalendar_3().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str(), calendar2_id.as_str()].into_jmap_set(), + ); + let event_4 = test_jscalendar_4().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let response = account + .jmap_create( + MethodObject::CalendarEvent, + [ + event_1 + .clone() + .with_property(JSCalendarProperty::::IsDraft, true) + .with_property(JSCalendarProperty::::MayInviteSelf, true) + .with_property(JSCalendarProperty::::MayInviteOthers, true) + .with_property(JSCalendarProperty::::HideAttendees, true), + event_2 + .clone() + .with_property(JSCalendarProperty::::UseDefaultAlerts, true), + event_3 + .clone() + .with_property(JSCalendarProperty::::UseDefaultAlerts, false), + event_4, + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let event_1_id = response.created(0).id().to_string(); + let event_2_id = response.created(1).id().to_string(); + let event_3_id = response.created(2).id().to_string(); + let event_4_id = response.created(3).id().to_string(); + + // Destroy tmp event + test.wait_for_tasks().await; + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [event_4_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .next(), + Some(event_4_id.as_str()) + ); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::CalendarEvent, &change_id) + .await + .changes() + .collect::>(), + [ + ChangeType::Created(&event_1_id), + ChangeType::Created(&event_2_id), + ChangeType::Created(&event_3_id) + ] + .into_iter() + .collect::>(), + ); + + // Verify event contents + let response = account + .jmap_get( + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [&event_1_id, &event_2_id, &event_3_id], + ) + .await; + + assert_eq_ignoring_updated( + &response.list()[0], + event_1 + .with_property(JSCalendarProperty::::Id, event_1_id.as_str()) + .with_property(JSCalendarProperty::::IsDraft, true) + .with_property(JSCalendarProperty::::IsOrigin, true), + ); + assert_eq_ignoring_updated( + &response.list()[1], + event_2 + .with_property(JSCalendarProperty::::Id, event_2_id.as_str()) + .with_property(JSCalendarProperty::::IsDraft, false) + .with_property(JSCalendarProperty::::IsOrigin, true) + .with_property( + JSCalendarProperty::::Alerts, + json!({ + "k1": { + "action": "display", + "trigger": { + "@type": "OffsetTrigger", + "offset": "PT15M" + }, + "@type": "Alert" + } + }), + ), + ); + assert_eq_ignoring_updated( + &response.list()[2], + event_3 + .with_property(JSCalendarProperty::::Id, event_3_id.as_str()) + .with_property(JSCalendarProperty::::IsDraft, false) + .with_property(JSCalendarProperty::::IsOrigin, false), + ); + + // Verify JMAP for Calendars properties + let response = account + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::BaseEventId, + JSCalendarProperty::MayInviteSelf, + JSCalendarProperty::MayInviteOthers, + JSCalendarProperty::HideAttendees, + JSCalendarProperty::UseDefaultAlerts, + JSCalendarProperty::UtcStart, + JSCalendarProperty::UtcEnd, + ], + [&event_1_id, &event_2_id, &event_3_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": &event_1_id, + "baseEventId": null, + "mayInviteSelf": true, + "mayInviteOthers": true, + "hideAttendees": true, + "useDefaultAlerts": false, + "utcStart": "2006-01-02T15:00:00Z", + "utcEnd": "2006-01-02T16:00:00Z" + })); + response.list()[1].assert_is_equal(json!({ + "id": &event_2_id, + "baseEventId": null, + "mayInviteSelf": false, + "mayInviteOthers": false, + "hideAttendees": false, + "useDefaultAlerts": true, + "utcStart": "2006-01-02T17:00:00Z", + "utcEnd": "2006-01-02T18:00:00Z" + })); + response.list()[2].assert_is_equal(json!({ + "id": &event_3_id, + "baseEventId": null, + "mayInviteSelf": false, + "mayInviteOthers": false, + "hideAttendees": false, + "useDefaultAlerts": false, + "utcStart": "2006-01-04T15:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z" + })); + + // Test /get parameters + let response = account + .jmap_method_calls(json!([[ + "CalendarEvent/get", + { + "accountId": account.id_string(), + "properties": ["id", "title", "recurrenceOverrides", "participants"], + "ids": [&event_2_id, &event_3_id], + "recurrenceOverridesBefore": "2006-01-07T00:00:00Z", + "recurrenceOverridesAfter": "2006-01-06T00:00:00Z", + "reduceParticipants": true, + }, + "0" + ]])) + .await; + assert_eq_ignoring_updated( + response.list_array(), + json!([ + { + "title": "Event #2", + "recurrenceOverrides": { + "2006-01-06T12:00:00": { + "updated": "2006-02-06T00:11:21Z", + "start": "2006-01-06T14:00:00", + "title": "Event #2 bis bis", + "duration": "PT1H" + } + }, + "id": "c" + }, + { + "title": "Event #3", + "participants": { + "3f5bc8c0-c722-5345-b7d9-5a899db08a30": { + "calendarAddress": "mailto:cyrus@example.com", + "@type": "Participant", + "roles": { + "owner": true + } + } + }, + "id": "d" + } + ]), + ); + + let response = account + .jmap_method_calls(json!([[ + "CalendarEvent/get", + { + "accountId": account.id_string(), + "properties": [], + "ids": [&event_2_id, &event_3_id], + }, + "0" + ]])) + .await; + response.list()[0].assert_is_equal(json!({ "id": &event_2_id })); + response.list()[1].assert_is_equal(json!({ "id": &event_3_id })); + + // Creating an event without calendar should fail + assert_eq!( + account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "title": "Event #5", + "start": "2006-01-22T10:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "calendarIds": {}, + }),], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Event has to belong to at least one calendar." + ); + + // Creating an event with a duplicate UID should fail + assert_eq!( + account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "title": "Event #5", + "start": "2006-01-22T10:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "uid": "00959BC664CA650E933C892C@example.com", + "calendarIds": { + &calendar1_id: true + }, + })], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "An event with UID 00959BC664CA650E933C892C@example.com already exists." + ); + + // Patching tests + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [ + ( + &event_1_id, + json!({ + "isDraft": false, + "mayInviteSelf": false, + "mayInviteOthers": false, + "hideAttendees": false, + "useDefaultAlerts": true, + "description": null, + "title": "Event one", + "keywords": {"work": true}, + format!("calendarIds/{calendar2_id}"): true + }), + ), + ( + &event_2_id, + json!({ + "calendarIds": { + &calendar1_id: true, + &calendar2_id: true + }, + "title": "Event two", + "useDefaultAlerts": false, + "description": "Updated description", + "recurrenceOverrides/2006-01-04T12:00:00/title": + "Event two overridden", + "recurrenceOverrides/2006-01-06T12:00:00/title": + "Event two overridden twice", + + }), + ), + ( + &event_3_id, + json!({ + format!("calendarIds/{calendar2_id}"): false, + "title": "Event three", + "utcStart": "2006-01-04T14:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z", + "participants/3f5bc8c0-c722-5345-b7d9-5a899db08a30/roles/chair": false, + "participants/3f5bc8c0-c722-5345-b7d9-5a899db08a30/roles/owner": true, + "participants/ec5e7db5-22a3-5ed5-89bf-c8894ab86805" : null, + "participants/7f2bd210-6c66-5b64-8562-0176b74462b1": { + "calendarAddress": "mailto:rupert@example.com", + "@type": "Participant", + "participationStatus": "needs-action" + } + }), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + + response.updated(&event_1_id); + response.updated(&event_2_id); + response.updated(&event_3_id); + + // Verify patches + let response = account + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::CalendarIds, + JSCalendarProperty::Title, + JSCalendarProperty::Start, + JSCalendarProperty::Description, + JSCalendarProperty::Keywords, + JSCalendarProperty::RecurrenceOverrides, + JSCalendarProperty::Participants, + JSCalendarProperty::MayInviteOthers, + JSCalendarProperty::MayInviteSelf, + JSCalendarProperty::HideAttendees, + JSCalendarProperty::UseDefaultAlerts, + JSCalendarProperty::IsDraft, + ], + [&event_1_id, &event_2_id, &event_3_id], + ) + .await; + + response.list()[0].assert_is_equal(json!({ + "id": &event_1_id, + "calendarIds": { + &calendar1_id: true, + &calendar2_id: true + }, + "isDraft": false, + "mayInviteSelf": false, + "mayInviteOthers": false, + "hideAttendees": false, + "useDefaultAlerts": true, + "title": "Event one", + "start": "2006-01-02T10:00:00", + "keywords": { + "work": true + } + })); + + assert_eq_ignoring_updated( + &response.list()[1], + json!({ + "id": &event_2_id, + "calendarIds": { + &calendar1_id: true, + &calendar2_id: true + }, + "title": "Event two", + "start": "2006-01-02T12:00:00", + "description": "Updated description", + "recurrenceOverrides": { + "2006-01-04T12:00:00": { + "title": "Event two overridden", + "start": "2006-01-04T14:00:00", + "duration": "PT1H", + "updated": "2006-02-06T00:11:21Z" + }, + "2006-01-06T12:00:00": { + "title": "Event two overridden twice", + "start": "2006-01-06T14:00:00", + "duration": "PT1H", + "updated": "2006-02-06T00:11:21Z" + } + }, + "title": "Event two", + "start": "2006-01-02T12:00:00", + "mayInviteOthers": false, + "mayInviteSelf": false, + "hideAttendees": false, + "useDefaultAlerts": false, + "isDraft": false + }), + ); + + response.list()[2].assert_is_equal(json!({ + "id": event_3_id, + "calendarIds": { + &calendar1_id: true, + }, + "title": "Event three", + "start": "2006-01-04T09:00:00", + "participants": { + "3f5bc8c0-c722-5345-b7d9-5a899db08a30": { + "calendarAddress": "mailto:cyrus@example.com", + "@type": "Participant", + "roles": { + "owner": true + }, + "participationStatus": "accepted" + }, + "7f2bd210-6c66-5b64-8562-0176b74462b1": { + "calendarAddress": "mailto:rupert@example.com", + "@type": "Participant", + "participationStatus": "needs-action" + } + }, + "mayInviteOthers": false, + "mayInviteSelf": false, + "hideAttendees": false, + "useDefaultAlerts": false, + "isDraft": false + })); + + // Query tests + test.wait_for_tasks().await; + assert_eq!( + account + .jmap_query( + MethodObject::CalendarEvent, + [ + ("text", "Event one"), + ("inCalendar", calendar1_id.as_str()), + ("uid", "74855313FA803DA593CD579A@example.com"), + ("after", "2006-01-02T10:59:59"), + ("before", "2006-01-02T10:00:01"), + ], + ["start"], + [("timeZone", "US/Eastern")], + ) + .await + .ids() + .collect::>(), + [event_1_id.as_str()].into_iter().collect::>() + ); + + // Recurrence expansion tests + let response = account + .jmap_query( + MethodObject::CalendarEvent, + [ + ("after", "2006-01-01T00:00:00"), + ("before", "2006-01-08T00:00:00"), + ], + ["start"], + [ + ("timeZone", Value::String("US/Eastern".into())), + ("expandRecurrences", Value::Bool(true)), + ], + ) + .await; + let ids = response.ids().collect::>(); + assert_eq!(ids.len(), 7); + account + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::BaseEventId, + JSCalendarProperty::Start, + JSCalendarProperty::Duration, + JSCalendarProperty::TimeZone, + JSCalendarProperty::Title, + JSCalendarProperty::RecurrenceId, + ], + ids.clone(), + ) + .await + .list_array() + .assert_is_equal(json!([ + { + "duration": "PT1H", + "title": "Event one", + "start": "2006-01-02T10:00:00", + "timeZone": "US/Eastern", + "id": &ids[0], + "baseEventId": &event_1_id + }, + { + "recurrenceId": "2006-01-02T12:00:00", + "title": "Event two", + "duration": "PT1H", + "start": "2006-01-02T12:00:00", + "timeZone": "US/Eastern", + "id": &ids[1], + "baseEventId": &event_2_id + }, + { + "duration": "PT1H", + "start": "2006-01-03T12:00:00", + "timeZone": "US/Eastern", + "title": "Event two", + "recurrenceId": "2006-01-03T12:00:00", + "id": &ids[2], + "baseEventId": &event_2_id + }, + { + "start": "2006-01-04T09:00:00", + "timeZone": "US/Eastern", + "duration": "PT2H", + "title": "Event three", + "id": &ids[3], + "baseEventId": &event_3_id + }, + { + "recurrenceId": "2006-01-04T12:00:00", + "title": "Event two overridden", + "start": "2006-01-04T14:00:00", + "timeZone": "US/Eastern", + "duration": "PT1H", + "id": &ids[4], + "baseEventId": &event_2_id + }, + { + "recurrenceId": "2006-01-05T12:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "start": "2006-01-05T12:00:00", + "title": "Event two", + "id": &ids[5], + "baseEventId": &event_2_id + }, + { + "recurrenceId": "2006-01-06T12:00:00", + "duration": "PT1H", + "title": "Event two overridden twice", + "timeZone": "US/Eastern", + "start": "2006-01-06T14:00:00", + "id": &ids[6], + "baseEventId": &event_2_id + } + ])); + + // Parse tests + account + .jmap_method_calls(json!([ + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "ical": { + "data": [ + { + "data:asText": r#"BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:Networld+Interop Conference + and Exhibit\nAtlanta World Congress Center\n +Atlanta\, Georgia +END:VEVENT +END:VCALENDAR +"# + } + ] + } + } + }, + "S4" + ], + [ + "CalendarEvent/parse", + { + "accountId": account.id_string(), + "blobIds": [ + "#ical" + ] + }, + "G4" + ] + ])) + .await + .pointer("/methodResponses/1/1/parsed") + .unwrap() + .as_object() + .unwrap() + .iter() + .next() + .unwrap() + .1 + .assert_is_equal(json!([ + { + "updated": "1996-07-04T12:00:00Z", + "title": "Networld+Interop Conference", + "description": "Networld+Interop Conferenceand Exhibit\nAtlanta World Congress Center\n", + "timeZone": "Etc/UTC", + "start": "1996-09-18T14:30:00", + "status": "confirmed", + "iCalendar": { + "convertedProperties": { + "duration": { + "name": "dtend" + } + }, + "name": "vevent" + }, + "@type": "Event", + "uid": "uid1@example.com", + "participants": { + "25d7647e-52fc-559b-88df-d66f08da079c": { + "calendarAddress": "mailto:jsmith@example.com", + "@type": "Participant", + "roles": { + "owner": true + } + } + }, + "keywords": { + "CONFERENCE": true + }, + "organizerCalendarAddress": "mailto:jsmith@example.com", + "duration": "P2DT7H30M" + } +])); + + // Deletion tests + test.wait_for_tasks().await; + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [event_2_id.as_str(), event_3_id.as_str()], + Vec::<(&str, &str)>::new() + ) + .await + .destroyed() + .collect::>(), + [event_2_id.as_str(), event_3_id.as_str()] + .into_iter() + .collect::>() + ); + + // CardDAV compatibility tests + let account_id = account.id().document_id(); + let dav_client = account.webdav_client(); + let resources = test + .server + .fetch_dav_resources(account_id, account_id, SyncCollection::Calendar) + .await + .unwrap(); + let path = format!( + "{}{}", + resources.base_path, + resources + .paths + .iter() + .find(|v| v.parent_id.is_some()) + .unwrap() + .path + ); + + let ical = dav_client + .request("GET", &path, "") + .await + .with_status(StatusCode::OK) + .expect_body() + .lines() + .filter(|line| !line.starts_with("DTSTAMP")) + .map(String::from) + .collect::>(); + let expected_ical = TEST_ICAL_1 + .lines() + .filter(|line| !line.starts_with("DTSTAMP")) + .map(String::from) + .collect::>(); + assert_eq!(ical, expected_ical); + + // Organizer assignment tests + let response = account + .jmap_create( + MethodObject::CalendarEvent, + [ + test_jscalendar_participants("organizer-auto@example.com", None).with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ), + test_jscalendar_participants( + "organizer-explicit@example.com", + Some("mailto:cyrus@example.com"), + ) + .with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ), + test_jscalendar_4() + .with_property(JSCalendarProperty::::Uid, "organizer-none@example.com") + .with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let auto_event_id = response.created(0).id().to_string(); + let explicit_event_id = response.created(1).id().to_string(); + let no_participants_event_id = response.created(2).id().to_string(); + + let response = account + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::OrganizerCalendarAddress, + ], + [ + &auto_event_id, + &explicit_event_id, + &no_participants_event_id, + ], + ) + .await; + + // The server assigns an organizer when participants are present but none was supplied + response.list()[0].assert_is_equal(json!({ + "id": &auto_event_id, + "organizerCalendarAddress": "mailto:jdoe@example.com" + })); + + // An organizer supplied by the client is never overwritten + response.list()[1].assert_is_equal(json!({ + "id": &explicit_event_id, + "organizerCalendarAddress": "mailto:cyrus@example.com" + })); + + // An event without participants is left without an organizer + response.list()[2].assert_is_equal(json!({ + "id": &no_participants_event_id + })); + + // Adding participants to an event that had none assigns the organizer + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &no_participants_event_id, + json!({ + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "participationStatus": "accepted", + "roles": { + "chair": true, + "owner": true + }, + "@type": "Participant" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:rupert@example.com", + "participationStatus": "needs-action", + "kind": "individual", + "@type": "Participant" + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&no_participants_event_id); + + account + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::OrganizerCalendarAddress, + ], + [&no_participants_event_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &no_participants_event_id, + "organizerCalendarAddress": "mailto:jdoe@example.com" + })); + + // Moving an event between calendars tombstones the previous CalDAV href + test.wait_for_tasks().await; + let cal_base_path = format!("{}/jdoe%40example.com/", DavResourceName::Cal.base_path()); + let sync_token = dav_client + .sync_collection(&cal_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + let moved_event_id = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "d3a15a44-fe25-4b6a-9e2f-58d40f0f1d4c", + "title": "Moving Event", + "start": "2026-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &calendar1_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = dav_client + .sync_collection( + &cal_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(1); + let sync_token = response.sync_token().to_string(); + let href_in_calendar1 = response.hrefs()[0].to_string(); + + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &moved_event_id, + json!({ + "calendarIds": { + &calendar2_id: true + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&moved_event_id); + + let response = dav_client + .sync_collection( + &cal_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(2); + let sync_token = response.sync_token().to_string(); + let href_in_calendar2 = response + .hrefs() + .into_iter() + .find(|href| *href != href_in_calendar1) + .unwrap() + .to_string(); + let response = response.into_propfind_response(None); + response + .properties(&href_in_calendar1) + .with_status(StatusCode::NOT_FOUND); + response + .properties(&href_in_calendar2) + .with_status(StatusCode::OK); + + account + .jmap_destroy( + MethodObject::CalendarEvent, + [moved_event_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .assert_destroyed(&[Id::from_str(&moved_event_id).unwrap()]); + + dav_client + .sync_collection( + &cal_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(1) + .into_propfind_response(None) + .properties(&href_in_calendar2) + .with_status(StatusCode::NOT_FOUND); + + // Unbounded yearly recurrences remain queryable far beyond their first instance + let yearly_event_id = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "yearly-unbounded@example.com", + "title": "Unbounded yearly event", + "start": "2018-06-01T09:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "calendarIds": { + &calendar1_id: true + }, + "recurrenceRule": { + "@type": "RecurrenceRule", + "frequency": "yearly" + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + test.wait_for_tasks().await; + + assert!( + account + .jmap_query( + MethodObject::CalendarEvent, + [ + ("after", "2027-06-01T00:00:00"), + ("before", "2027-07-01T00:00:00"), + ], + ["start"], + [("timeZone", "Etc/UTC")], + ) + .await + .ids() + .any(|id| id == yearly_event_id), + "unbounded yearly event was pruned from a June 2027 query" + ); + + account + .jmap_destroy( + MethodObject::CalendarEvent, + [yearly_event_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .assert_destroyed(&[Id::from_str(&yearly_event_id).unwrap()]); + + // Clean up + test.wait_for_tasks().await; + account.destroy_all_calendars().await; + test.assert_is_empty().await; +} + +fn test_jscalendar_participants(uid: &str, organizer: Option<&str>) -> Value { + let mut event = json!({ + "@type": "Event", + "uid": uid, + "title": "Organizer assignment", + "start": "2006-01-04T10:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "updated": "2006-02-06T00:11:02Z", + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "participationStatus": "accepted", + "roles": { + "chair": true, + "owner": true + }, + "@type": "Participant" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:rupert@example.com", + "participationStatus": "needs-action", + "kind": "individual", + "@type": "Participant" + } + } + }); + + if let Some(organizer) = organizer { + event.as_object_mut().unwrap().insert( + "organizerCalendarAddress".to_string(), + Value::String(organizer.to_string()), + ); + } + + event +} + +pub fn assert_eq_ignoring_updated(got: &Value, expected: Value) { + strip_updated(got.clone()).assert_is_equal(strip_updated(expected)); +} + +fn strip_updated(mut value: Value) -> Value { + match &mut value { + Value::Object(map) => { + map.remove("updated"); + for entry in map.values_mut() { + *entry = strip_updated(std::mem::take(entry)); + } + } + Value::Array(array) => { + for entry in array.iter_mut() { + *entry = strip_updated(std::mem::take(entry)); + } + } + _ => {} + } + value +} + +pub fn test_jscalendar_1() -> Value { + json!({ + "duration": "PT1H", + "@type": "Event", + "description": "Go Steelers!", + "updated": "2006-02-06T00:11:02Z", + "timeZone": "US/Eastern", + "start": "2006-01-02T10:00:00", + "title": "Event #1", + "uid": "74855313FA803DA593CD579A@example.com" + }) +} + +pub fn test_jscalendar_2() -> Value { + json!({ + "title": "Event #2", + "duration": "PT1H", + "updated": "2006-02-06T00:11:21Z", + "recurrenceRule": { + "frequency": "daily", + "count": 5 + }, + "start": "2006-01-02T12:00:00", + "uid": "00959BC664CA650E933C892C@example.com", + "@type": "Event", + "timeZone": "US/Eastern", + "recurrenceOverrides": { + "2006-01-04T12:00:00": { + "title": "Event #2 bis", + "start": "2006-01-04T14:00:00", + "updated": "2006-02-06T00:11:21Z", + "duration": "PT1H" + }, + "2006-01-06T12:00:00": { + "title": "Event #2 bis bis", + "start": "2006-01-06T14:00:00", + "updated": "2006-02-06T00:11:21Z", + "duration": "PT1H" + } + } + }) +} + +pub fn test_jscalendar_3() -> Value { + json!({ + "duration": "PT1H", + "organizerCalendarAddress": "mailto:cyrus@example.com", + "@type": "Event", + "start": "2006-01-04T10:00:00", + "status": "tentative", + "uid": "DC6C50A017428C5216A2F1CD@example.com", + "sequence": 1, + "participants": { + "3f5bc8c0-c722-5345-b7d9-5a899db08a30": { + "calendarAddress": "mailto:cyrus@example.com", + "@type": "Participant", + "roles": { + "chair": true, + "owner": true + }, + "participationStatus": "accepted" + }, + "ec5e7db5-22a3-5ed5-89bf-c8894ab86805": { + "calendarAddress": "mailto:lisa@example.com", + "@type": "Participant", + "participationStatus": "needs-action" + } + }, + "title": "Event #3", + "updated": "2006-02-06T00:12:20Z", + "timeZone": "US/Eastern" + }) +} + +pub fn test_jscalendar_4() -> Value { + json!({ + "duration": "PT1H", + "@type": "Event", + "description": "Tmp Event", + "updated": "2006-02-06T00:11:02Z", + "timeZone": "US/Eastern", + "start": "2006-01-02T10:00:00", + "title": "Tmp Event", + "uid": "tmp-event@example.com" + }) +} + +const TEST_ICAL_1: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102T100000 +UID:74855313FA803DA593CD579A@example.com +DURATION:PT1H +SUMMARY:Event one +DTSTAMP:20060206T001102Z +CATEGORIES:work +END:VEVENT +END:VCALENDAR +"#; diff --git a/tests/src/jmap/calendar/identity.rs b/tests/src/jmap/calendar/identity.rs new file mode 100644 index 0000000..f3ad6b3 --- /dev/null +++ b/tests/src/jmap/calendar/identity.rs @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use jmap_proto::{ + object::participant_identity::ParticipantIdentityProperty, request::method::MethodObject, +}; +use serde_json::json; +use store::write::BatchBuilder; +use types::{collection::Collection, field::PrincipalField}; + +pub async fn test(test: &TestServer) { + println!("Running Participant Identity tests..."); + let account = test.account("jdoe@example.com"); + + // Obtain all identities + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": true + }, + { + "id": "b", + "name": "John Doe", + "calendarAddress": "mailto:john.doe@example.com", + "isDefault": false + } + ])); + + // Destroy identity b + let response = account + .jmap_destroy( + MethodObject::ParticipantIdentity, + ["b"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.destroyed().next(), Some("b")); + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": true + } + ])); + + // Creating a new identity with an unauthorized calendar address should fail + let response = account + .jmap_create( + MethodObject::ParticipantIdentity, + [ + json!({ + "name": "Work", + "calendarAddress": "mailto:work@example.com" + }), + json!({ + "name": "Work", + "calendarAddress": "work@example.com" + }), + ], + [("onSuccessSetIsDefault", "#i0")], + ) + .await; + assert_eq!( + response.not_created(0).description(), + "Calendar address not configured for this account." + ); + assert_eq!( + response.not_created(1).description(), + "Calendar address not configured for this account." + ); + + // Create a new identity and set it as default + let response = account + .jmap_create( + MethodObject::ParticipantIdentity, + [json!({ + "name": "Johnny B Goode", + "calendarAddress": "mailto:john.doe@example.com" + })], + [("onSuccessSetIsDefault", "#i0")], + ) + .await; + response.created(0); + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": false + }, + { + "id": "b", + "name": "Johnny B Goode", + "calendarAddress": "mailto:john.doe@example.com", + "isDefault": true + } + ])); + + // Cleanup + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account.id().document_id()) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::ParticipantIdentities); + test.server.commit_batch(batch).await.unwrap(); + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/calendar/instance.rs b/tests/src/jmap/calendar/instance.rs new file mode 100644 index 0000000..3fee891 --- /dev/null +++ b/tests/src/jmap/calendar/instance.rs @@ -0,0 +1,991 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::calendar::event::assert_eq_ignoring_updated, + utils::{account::Account, jmap::JmapUtils, server::TestServer}, +}; +use groupware::cache::GroupwareCache; +use hyper::StatusCode; +use jmap_proto::request::method::MethodObject; +use serde_json::{Value, json}; +use std::str::FromStr; +use types::{collection::SyncCollection, id::Id}; + +pub async fn test(test: &TestServer) { + println!("Running Calendar Event instance tests..."); + let account = test.account("jdoe@example.com"); + + let calendar_id = account + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Recurring events", + "timeZone": "US/Eastern", + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + let response = account + .jmap_create( + MethodObject::CalendarEvent, + [ + json!({ + "@type": "Event", + "uid": "recurring-instances@example.com", + "title": "Daily standup", + "start": "2007-03-05T12:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "updated": "2007-02-06T00:11:21Z", + "recurrenceRule": { + "frequency": "daily", + "count": 5 + }, + "locations": { + "loc1": { + "@type": "Location", + "name": "Room A" + } + }, + "recurrenceOverrides": { + "2007-03-07T12:00:00": { + "title": "Moved standup", + "start": "2007-03-07T15:00:00", + "duration": "PT1H", + "updated": "2007-02-06T00:11:21Z" + } + }, + "calendarIds": { + &calendar_id: true + } + }), + json!({ + "@type": "Event", + "uid": "single-instance@example.com", + "title": "One off", + "start": "2007-03-12T09:00:00", + "duration": "PT2H", + "timeZone": "US/Eastern", + "updated": "2007-02-06T00:11:21Z", + "calendarIds": { + &calendar_id: true + } + }), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let recurring_id = response.created(0).id().to_string(); + let single_id = response.created(1).id().to_string(); + test.wait_for_tasks().await; + + // The overridden occurrence keeps its original recurrence id + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-07T15:00:00", + "2007-03-08T12:00:00", + "2007-03-09T12:00:00", + "2007-03-12T09:00:00" + ] + ); + assert_eq!( + instance(&instances, "2007-03-07T15:00:00").text_field("recurrenceId"), + "2007-03-07T12:00:00" + ); + + // Synthetic instances return null recurrence properties when requested + for start in [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-07T15:00:00", + "2007-03-08T12:00:00", + "2007-03-09T12:00:00", + ] { + let instance = instance(&instances, start); + for property in ["recurrenceRule", "recurrenceOverrides"] { + assert_eq!( + instance.get(property), + Some(&Value::Null), + "{property} on {start}: {instance:?}" + ); + } + } + + // Unknown instances are reported as not found + let unknown_id = + Id::from_parts(1000, Id::from_str(&recurring_id).unwrap().document_id()).to_string(); + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [(&unknown_id, json!({"title": "Nope"}))], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.not_updated(&unknown_id).typ(), "notFound"); + + // Updating an instance generated by the recurrence rule creates an override + let id = instance_id(&instances, "2007-03-06T12:00:00"); + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &id, + json!({ + "title": "Standup with guests", + "locations/loc1/name": "Room B" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + let updated = instance(&instances, "2007-03-06T12:00:00"); + assert_eq!(updated.text_field("title"), "Standup with guests"); + assert_eq!(updated.text_field("duration"), "PT1H"); + assert_eq!( + updated + .pointer("/locations/loc1/name") + .and_then(|v| v.as_str()), + Some("Room B") + ); + assert_eq!( + instance(&instances, "2007-03-05T12:00:00").text_field("title"), + "Daily standup" + ); + + // Updating an occurrence that is already overridden patches the existing override + let id = instance_id(&instances, "2007-03-07T15:00:00"); + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &id, + json!({ + "title": "Moved standup, renamed" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + assert_eq_ignoring_updated( + &base_event(account, &recurring_id).await, + json!({ + "id": &recurring_id, + "title": "Daily standup", + "locations": { + "loc1": { + "@type": "Location", + "name": "Room A" + } + }, + "recurrenceOverrides": { + "2007-03-06T12:00:00": { + "title": "Standup with guests", + "start": "2007-03-06T12:00:00", + "duration": "PT1H", + "locations": { + "loc1": { + "@type": "Location", + "name": "Room B" + } + } + }, + "2007-03-07T12:00:00": { + "title": "Moved standup, renamed", + "start": "2007-03-07T15:00:00", + "duration": "PT1H" + } + } + }), + ); + + // A rejected instance is not written, even when another instance of the same event is + let instances = expand_instances(account, &calendar_id).await; + let good_id = instance_id(&instances, "2007-03-09T12:00:00"); + let bad_id = instance_id(&instances, "2007-03-08T12:00:00"); + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [ + (&good_id, json!({"title": "Applied"})), + ( + &bad_id, + json!({"title": "Rejected", "utcStart": "2007-03-08T17:00:00Z"}), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + response.updated(&good_id); + assert_eq!(response.not_updated(&bad_id).typ(), "invalidProperties"); + test.wait_for_tasks().await; + + let base = base_event(account, &recurring_id).await; + let mut overrides = base + .pointer("/recurrenceOverrides") + .and_then(|value| value.as_object()) + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + overrides.sort_unstable(); + assert_eq!( + overrides, + [ + "2007-03-06T12:00:00", + "2007-03-07T12:00:00", + "2007-03-09T12:00:00" + ] + ); + + // Destroying an instance removes just that occurrence + let instances = expand_instances(account, &calendar_id).await; + let id = instance_id(&instances, "2007-03-08T12:00:00"); + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [id.as_str()] + ); + test.wait_for_tasks().await; + assert_eq!( + starts(&expand_instances(account, &calendar_id).await), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-07T15:00:00", + "2007-03-09T12:00:00", + "2007-03-12T09:00:00" + ] + ); + + // Destroying an overridden instance does not bring back the original occurrence + let instances = expand_instances(account, &calendar_id).await; + let id = instance_id(&instances, "2007-03-07T15:00:00"); + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [id.as_str()] + ); + test.wait_for_tasks().await; + assert_eq!( + starts(&expand_instances(account, &calendar_id).await), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-09T12:00:00", + "2007-03-12T09:00:00" + ] + ); + + // Several instances of the same event may be changed in a single request + let instances = expand_instances(account, &calendar_id).await; + let update_id = instance_id(&instances, "2007-03-05T12:00:00"); + let destroy_id = instance_id(&instances, "2007-03-09T12:00:00"); + let response = account + .jmap_method_calls(json!([[ + "CalendarEvent/set", + { + "accountId": account.id_string(), + "update": { + &update_id: { + "title": "First standup" + } + }, + "destroy": [&destroy_id] + }, + "0" + ]])) + .await; + response.updated(&update_id); + assert_eq!( + response.destroyed().collect::>(), + [destroy_id.as_str()] + ); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-12T09:00:00" + ] + ); + assert_eq!( + instance(&instances, "2007-03-05T12:00:00").text_field("title"), + "First standup" + ); + + // Excluding the occurrence the event starts on is allowed + let id = instance_id(&instances, "2007-03-05T12:00:00"); + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [id.as_str()] + ); + test.wait_for_tasks().await; + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + ["2007-03-06T12:00:00", "2007-03-12T09:00:00"] + ); + + // A base event and its instances cannot be modified in the same request + let id = instance_id(&instances, "2007-03-06T12:00:00"); + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [ + (&recurring_id, json!({"title": "Renamed"})), + (&id, json!({"title": "Renamed instance"})), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + for id in [&recurring_id, &id] { + assert_eq!(response.not_updated(id).typ(), "invalidProperties"); + assert_eq!( + response.not_updated(id).description(), + "A base event and its instances cannot be modified in the same request." + ); + } + + // Properties that are not per-occurrence are rejected + for property in [ + json!({"calendarIds": {&calendar_id: true}}), + json!({"isDraft": true}), + json!({"utcStart": "2007-03-06T17:00:00Z"}), + json!({"utcEnd": "2007-03-06T18:00:00Z"}), + json!({"mayInviteSelf": true}), + json!({"useDefaultAlerts": true}), + ] { + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [(&id, property)], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.not_updated(&id).typ(), "invalidProperties"); + assert_eq!( + response.not_updated(&id).description(), + "This property cannot be modified on a single occurrence." + ); + } + + // Properties an occurrence inherits from the base event are ignored, not rejected + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &id, + json!({ + "@type": "Event", + "title": "Ignoring inherited properties", + "uid": "somebody-elses-uid@example.com", + "recurrenceRule": {"frequency": "weekly"}, + "privacy": "private", + "participants/xyz/calendarAddress": "mailto:nobody@example.com" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + let base = account + .jmap_get( + MethodObject::CalendarEvent, + ["id", "uid", "recurrenceRule", "privacy", "participants"], + [&recurring_id], + ) + .await; + let base = &base.list()[0]; + assert_eq!(base.text_field("uid"), "recurring-instances@example.com"); + assert_eq!( + base.pointer("/recurrenceRule/frequency") + .and_then(|v| v.as_str()), + Some("daily") + ); + assert_eq!(base.pointer("/privacy"), None); + assert_eq!(base.pointer("/participants"), None); + assert_eq!( + instance( + &expand_instances(account, &calendar_id).await, + "2007-03-06T12:00:00" + ) + .text_field("title"), + "Ignoring inherited properties" + ); + + // Destroying an event that is also being updated through one of its instances fails + let response = account + .jmap_method_calls(json!([[ + "CalendarEvent/set", + { + "accountId": account.id_string(), + "update": { + &id: { + "title": "Renamed instance" + } + }, + "destroy": [&recurring_id] + }, + "0" + ]])) + .await; + assert_eq!(response.not_updated(&id).typ(), "willDestroy"); + assert_eq!( + response.destroyed().collect::>(), + [recurring_id.as_str()] + ); + test.wait_for_tasks().await; + + // A synthetic id of a non-recurring event refers to the event itself + let instances = expand_instances(account, &calendar_id).await; + let id = instance_id(&instances, "2007-03-12T09:00:00"); + assert_ne!(id, single_id); + account + .jmap_update( + MethodObject::CalendarEvent, + [(&id, json!({"title": "One off, renamed"}))], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + assert_eq_ignoring_updated( + &base_event(account, &single_id).await, + json!({ + "id": &single_id, + "title": "One off, renamed" + }), + ); + + let instances = expand_instances(account, &calendar_id).await; + let id = instance_id(&instances, "2007-03-12T09:00:00"); + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [id.as_str()] + ); + test.wait_for_tasks().await; + assert!(expand_instances(account, &calendar_id).await.is_empty()); + + // Instances of floating all-day events keep their local time and duration + let all_day_id = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "all-day-instances@example.com", + "title": "Spring break", + "start": "2007-04-02T00:00:00", + "duration": "P1D", + "showWithoutTime": true, + "updated": "2007-02-06T00:11:21Z", + "recurrenceRule": { + "frequency": "daily", + "count": 3 + }, + "calendarIds": { + &calendar_id: true + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + [ + "2007-04-02T00:00:00", + "2007-04-03T00:00:00", + "2007-04-04T00:00:00" + ] + ); + + let update_id = instance_id(&instances, "2007-04-03T00:00:00"); + let destroy_id = instance_id(&instances, "2007-04-04T00:00:00"); + let response = account + .jmap_method_calls(json!([[ + "CalendarEvent/set", + { + "accountId": account.id_string(), + "update": { + &update_id: { + "title": "Spring break, day two" + } + }, + "destroy": [&destroy_id] + }, + "0" + ]])) + .await; + response.updated(&update_id); + assert_eq!( + response.destroyed().collect::>(), + [destroy_id.as_str()] + ); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + ["2007-04-02T00:00:00", "2007-04-03T00:00:00"] + ); + let updated = instance(&instances, "2007-04-03T00:00:00"); + assert_eq!(updated.text_field("title"), "Spring break, day two"); + assert_eq!(updated.text_field("duration"), "P1D"); + + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&all_day_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .next() + .unwrap(); + + // Occurrences covered by a this-and-future change cannot be modified individually + let dav_client = account.webdav_client(); + let account_id = account.id().document_id(); + let resources = test + .server + .fetch_dav_resources(account_id, account_id, SyncCollection::Calendar) + .await + .unwrap(); + let calendar_document_id = Id::from_str(&calendar_id).unwrap().document_id(); + let calendar_path = resources + .paths + .iter() + .find(|path| { + path.parent_id.is_none() + && resources.resources[path.resource_idx].document_id == calendar_document_id + }) + .map(|path| format!("{}{}", resources.base_path, path.path)) + .unwrap(); + + dav_client + .request( + "PUT", + &format!("{calendar_path}/this-and-future.ics"), + THIS_AND_FUTURE_ICAL, + ) + .await + .with_status(StatusCode::CREATED); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-07T14:00:00", + "2007-03-08T14:00:00", + "2007-03-09T14:00:00" + ] + ); + + assert_eq!( + instance(&instances, "2007-03-07T14:00:00").text_field("recurrenceId"), + "2007-03-07T12:00:00" + ); + for start in ["2007-03-08T14:00:00", "2007-03-09T14:00:00"] { + assert_eq!( + instance(&instances, start).text_field("recurrenceId"), + start + ); + let id = instance_id(&instances, start); + let response = account + .jmap_update( + MethodObject::CalendarEvent, + [(&id, json!({"title": "Should not apply"}))], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.not_updated(&id).typ(), "invalidProperties"); + assert_eq!( + response.not_updated(&id).description(), + "Occurrences of a this-and-future change cannot be modified individually." + ); + + let response = account + .jmap_destroy( + MethodObject::CalendarEvent, + [&id], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.not_destroyed(&id).typ(), "invalidProperties"); + } + + // The occurrence the this-and-future change starts on is still editable + let id = instance_id(&instances, "2007-03-07T14:00:00"); + account + .jmap_update( + MethodObject::CalendarEvent, + [(&id, json!({"title": "Split renamed"}))], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + [ + "2007-03-05T12:00:00", + "2007-03-06T12:00:00", + "2007-03-07T14:00:00", + "2007-03-08T14:00:00", + "2007-03-09T14:00:00" + ] + ); + assert_eq!( + instance(&instances, "2007-03-07T14:00:00").text_field("title"), + "Split renamed" + ); + + dav_client + .request( + "DELETE", + &format!("{calendar_path}/this-and-future.ics"), + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Patching an occurrence does not recompute its duration + dav_client + .request( + "PUT", + &format!("{calendar_path}/night-shift.ics"), + NIGHT_SHIFT_ICAL, + ) + .await + .with_status(StatusCode::CREATED); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &calendar_id).await; + assert_eq!( + starts(&instances), + ["2007-03-10T23:00:00", "2007-03-11T23:00:00"] + ); + assert_eq!( + instance(&instances, "2007-03-10T23:00:00").text_field("duration"), + "PT5H" + ); + + let id = instance_id(&instances, "2007-03-10T23:00:00"); + account + .jmap_update( + MethodObject::CalendarEvent, + [(&id, json!({"title": "Night shift, renamed"}))], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&id); + test.wait_for_tasks().await; + + let updated = expand_instances(account, &calendar_id).await; + let updated = instance(&updated, "2007-03-10T23:00:00"); + assert_eq!(updated.text_field("title"), "Night shift, renamed"); + assert_eq!(updated.text_field("duration"), "PT5H"); + + dav_client + .request("DELETE", &format!("{calendar_path}/night-shift.ics"), "") + .await + .with_status(StatusCode::NO_CONTENT); + + // Synthetic ids keep identifying the same occurrence across writes + let series_calendar_id = account + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Stable instance ids", + "timeZone": "US/Eastern", + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "stable-instance-ids@example.com", + "title": "Weekly sync", + "start": "2007-04-02T09:00:00", + "duration": "PT1H", + "timeZone": "US/Eastern", + "updated": "2007-02-06T00:11:21Z", + "recurrenceRule": { + "frequency": "weekly", + "count": 5 + }, + "calendarIds": { + &series_calendar_id: true + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0); + test.wait_for_tasks().await; + + let instances = expand_instances(account, &series_calendar_id).await; + let held_ids = instances + .iter() + .map(|instance| instance.id().to_string()) + .collect::>(); + assert_eq!( + starts(&instances), + [ + "2007-04-02T09:00:00", + "2007-04-09T09:00:00", + "2007-04-16T09:00:00", + "2007-04-23T09:00:00", + "2007-04-30T09:00:00" + ] + ); + + let moved_id = instance_id(&instances, "2007-04-09T09:00:00"); + account + .jmap_update( + MethodObject::CalendarEvent, + [( + &moved_id, + json!({ + "title": "Weekly sync, moved", + "start": "2007-04-09T14:00:00" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&moved_id); + test.wait_for_tasks().await; + + let held = account + .jmap_get( + MethodObject::CalendarEvent, + ["id", "start", "recurrenceId"], + held_ids.clone(), + ) + .await + .list() + .to_vec(); + let held_start = |id: &str| { + held.iter() + .find(|event| event.id() == id) + .unwrap_or_else(|| panic!("Missing instance {id}: {held:?}")) + .text_field("start") + }; + assert_eq!( + held_ids.iter().map(|id| held_start(id)).collect::>(), + [ + "2007-04-02T09:00:00", + "2007-04-09T14:00:00", + "2007-04-16T09:00:00", + "2007-04-23T09:00:00", + "2007-04-30T09:00:00" + ] + ); + assert_eq!( + held.iter() + .find(|event| event.id() == held_ids[1]) + .map(|event| event.text_field("recurrenceId")), + Some("2007-04-09T09:00:00") + ); + + // Destroying an id held across a write removes the occurrence it was issued for + assert_eq!( + account + .jmap_destroy( + MethodObject::CalendarEvent, + [&held_ids[2]], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [held_ids[2].as_str()] + ); + test.wait_for_tasks().await; + assert_eq!( + starts(&expand_instances(account, &series_calendar_id).await), + [ + "2007-04-02T09:00:00", + "2007-04-09T14:00:00", + "2007-04-23T09:00:00", + "2007-04-30T09:00:00" + ] + ); + + // Clean up + for calendar_id in [&calendar_id, &series_calendar_id] { + account + .jmap_destroy( + MethodObject::Calendar, + [calendar_id], + [("onDestroyRemoveEvents", true)], + ) + .await; + } +} + +async fn expand_instances(account: &Account, calendar_id: &str) -> Vec { + let ids = account + .jmap_query( + MethodObject::CalendarEvent, + [ + ("inCalendar", Value::String(calendar_id.to_string())), + ("after", Value::String("2007-03-01T00:00:00".to_string())), + ("before", Value::String("2007-05-01T00:00:00".to_string())), + ], + ["start"], + [ + ("timeZone", Value::String("US/Eastern".to_string())), + ("expandRecurrences", Value::Bool(true)), + ], + ) + .await + .ids() + .map(|id| id.to_string()) + .collect::>(); + if ids.is_empty() { + return Vec::new(); + } + + account + .jmap_get( + MethodObject::CalendarEvent, + [ + "id", + "baseEventId", + "start", + "duration", + "title", + "recurrenceId", + "locations", + "recurrenceRule", + "recurrenceOverrides", + ], + ids, + ) + .await + .list() + .to_vec() +} + +async fn base_event(account: &Account, id: &str) -> Value { + account + .jmap_get( + MethodObject::CalendarEvent, + ["id", "title", "locations", "recurrenceOverrides"], + [id], + ) + .await + .list()[0] + .clone() +} + +fn starts(instances: &[Value]) -> Vec<&str> { + instances + .iter() + .map(|instance| instance.text_field("start")) + .collect() +} + +fn instance<'x>(instances: &'x [Value], start: &str) -> &'x Value { + instances + .iter() + .find(|instance| instance.text_field("start") == start) + .unwrap_or_else(|| panic!("Missing instance starting at {start}: {instances:?}")) +} + +fn instance_id(instances: &[Value], start: &str) -> String { + instance(instances, start).id().to_string() +} + +const THIS_AND_FUTURE_ICAL: &str = concat!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n", + "BEGIN:VEVENT\r\nUID:this-and-future@example.com\r\nDTSTAMP:20070206T001121Z\r\n", + "SUMMARY:Standup\r\nDTSTART;TZID=US/Eastern:20070305T120000\r\nDURATION:PT1H\r\n", + "RRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n", + "BEGIN:VEVENT\r\nUID:this-and-future@example.com\r\nDTSTAMP:20070206T001121Z\r\n", + "SUMMARY:Standup moved\r\n", + "RECURRENCE-ID;TZID=US/Eastern;RANGE=THISANDFUTURE:20070307T120000\r\n", + "DTSTART;TZID=US/Eastern:20070307T140000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n", + "END:VCALENDAR\r\n" +); + +const NIGHT_SHIFT_ICAL: &str = concat!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n", + "BEGIN:VEVENT\r\nUID:night-shift@example.com\r\nDTSTAMP:20070206T001121Z\r\n", + "SUMMARY:Night shift\r\nDTSTART;TZID=US/Eastern:20070310T230000\r\n", + "DTEND;TZID=US/Eastern:20070311T050000\r\nRRULE:FREQ=DAILY;COUNT=2\r\n", + "END:VEVENT\r\nEND:VCALENDAR\r\n" +); diff --git a/tests/src/jmap/calendar/mod.rs b/tests/src/jmap/calendar/mod.rs new file mode 100644 index 0000000..611ab27 --- /dev/null +++ b/tests/src/jmap/calendar/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod alarm; +pub mod calendars; +pub mod event; +pub mod identity; +pub mod instance; +pub mod notification; diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs new file mode 100644 index 0000000..bdf7668 --- /dev/null +++ b/tests/src/jmap/calendar/notification.rs @@ -0,0 +1,582 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{IntoJmapSet, JmapUtils}, + server::TestServer, +}; +use calcard::jscalendar::JSCalendarProperty; +use jmap_proto::{ + object::calendar_event_notification::CalendarEventNotificationProperty, + request::method::MethodObject, +}; +use mail_parser::DateTime; +use serde_json::{Value, json}; +use store::write::now; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Calendar Event Notification tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); + + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + let bill_id = bill.id_string().to_string(); + + let mut john_change_id = String::new(); + let mut jane_change_id = String::new(); + let mut bill_change_id = String::new(); + + // Obtain share notification change ids for all accounts + for (change_id, client) in [ + (&mut john_change_id, john), + (&mut jane_change_id, jane), + (&mut bill_change_id, bill), + ] { + let response = client + .jmap_get( + MethodObject::CalendarEventNotification, + [CalendarEventNotificationProperty::Id], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([])); + *change_id = response.state().to_string(); + + let response = client + .jmap_changes(MethodObject::CalendarEventNotification, &change_id) + .await; + assert_eq!(response.changes().next(), None); + assert_eq!(response.new_state(), change_id.as_str()); + } + + // Create test calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test Calendar", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_calendar_id = response.created(0).id().to_string(); + + // Sent invitation to Jane and Bill + let john_event = test_event(); + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [john_event.clone().with_property( + JSCalendarProperty::::CalendarIds, + [john_calendar_id.as_str()].into_jmap_set(), + )], + [("sendSchedulingMessages", true)], + ) + .await; + let john_event_id = response.created(0).id().to_string(); + + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + test.wait_for_tasks().await; + + // Verify Jane and Bill received the share notification + let mut jane_event_id = String::new(); + let mut bill_event_id = String::new(); + for (change_id, event_id, client) in [ + (&mut jane_change_id, &mut jane_event_id, jane), + (&mut bill_change_id, &mut bill_event_id, bill), + ] { + // Obtain changes + let response = client + .jmap_changes(MethodObject::CalendarEventNotification, &change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + *change_id = response.new_state().to_string(); + let notification_id = changes[0].as_created(); + + // Obtain and verify notification + let response = client + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::Created, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + CalendarEventNotificationProperty::Event, + CalendarEventNotificationProperty::EventPatch, + ], + [notification_id], + ) + .await; + let notification = &response.list()[0]; + *event_id = notification.text_field("calendarEventId").to_string(); + notification.assert_is_equal(json!({ + "id": ¬ification_id, + "created": ¬ification.text_field("created"), + "changedBy": { + "name": "John Doe", + "email": "jdoe@example.com", + "principalId": &john_id + }, + "type": "created", + "calendarEventId": event_id, + "isDraft": false, + "event": john_event + .clone() + .with_property( + "updated", + notification + .text_field("event/updated") + ) + })); + + // Verify the event exists + let response = client + .jmap_get( + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [&event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": &event_id, + "title": "Lunch" + })); + } + + // Jane and Bill accept the invitation + let response = jane + .jmap_update( + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus": + "accepted"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&jane_event_id); + let response = bill + .jmap_update( + MethodObject::CalendarEvent, + [( + &bill_event_id, + json!({ + "participants/86720268-d67c-58c3-9217-03df7d7ee4d8/participationStatus": + "accepted"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&bill_event_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify John received two share notifications + let response = john + .jmap_changes(MethodObject::CalendarEventNotification, &john_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 2); + for (i, change) in changes.into_iter().enumerate() { + let notification_id = change.as_created(); + + // Obtain and verify notification + let response = john + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + ], + [notification_id], + ) + .await; + let changed_by = if i == 0 { + json!({ + "name": "Jane Smith", + "email": "jane.smith@example.com", + "principalId": &jane_id, + }) + } else { + json!({ + "name": "Bill Foobar", + "email": "bill@example.com", + "principalId": &bill_id, + }) + }; + + response.list()[0].assert_is_equal(json!({ + "id": ¬ification_id, + "changedBy": changed_by, + "type": "updated", + "calendarEventId": &john_event_id, + "isDraft": false + })); + } + + // Verify the event was updated + let response = john + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Participants, + ], + [&john_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "chair": true, + "owner": true + }, + "participationStatus": "accepted" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "participationStatus": "accepted", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "@type": "Participant", + "kind": "individual", + "participationStatus": "accepted" + } + }, + "title": "Lunch", + "id": &john_event_id + })); + + // Jane later declines the invitation + let response = jane + .jmap_update( + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus": + "declined"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&jane_event_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Make sure John received the update + let response = john + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Participants, + ], + [&john_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "chair": true, + "owner": true + }, + "participationStatus": "accepted" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "participationStatus": "declined", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "@type": "Participant", + "kind": "individual", + "participationStatus": "accepted" + } + }, + "title": "Lunch", + "id": &john_event_id + })); + + // John deletes the event + let response = john + .jmap_destroy( + MethodObject::CalendarEvent, + [&john_event_id], + [("sendSchedulingMessages", true)], + ) + .await; + assert_eq!(response.destroyed().collect::>(), [&john_event_id]); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify that only Bill received the cancellation + let response = jane + .jmap_changes(MethodObject::CalendarEventNotification, &jane_change_id) + .await; + assert_eq!(response.changes().next(), None); + let response = bill + .jmap_changes(MethodObject::CalendarEventNotification, &bill_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let notification_id = changes[0].as_created(); + let response = bill + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + ], + [notification_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": ¬ification_id, + "changedBy": { + "name": "John Doe", + "email": "jdoe@example.com", + "principalId": &john_id + }, + "type": "updated", + "calendarEventId": &bill_event_id, + "isDraft": false + })); + + // Verify Bill's event was updated + let response = bill + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Status, + ], + [&bill_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": &bill_event_id, + "title": "Lunch", + "status": "cancelled" + })); + + // Scheduling messages are sent for a server-assigned organizer + let jane_change_id = jane + .jmap_get( + MethodObject::CalendarEventNotification, + [CalendarEventNotificationProperty::Id], + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [test_event_without_organizer().with_property( + JSCalendarProperty::::CalendarIds, + [john_calendar_id.as_str()].into_jmap_set(), + )], + [("sendSchedulingMessages", true)], + ) + .await; + let john_event_id = response.created(0).id().to_string(); + + john.jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::OrganizerCalendarAddress, + ], + [&john_event_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &john_event_id, + "organizerCalendarAddress": "mailto:jdoe@example.com" + })); + + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + test.wait_for_tasks().await; + + let response = jane + .jmap_changes(MethodObject::CalendarEventNotification, &jane_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let notification_id = changes[0].as_created(); + let jane_event_id = jane + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::CalendarEventId, + ], + [notification_id], + ) + .await + .list()[0] + .text_field("calendarEventId") + .to_string(); + + jane.jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::OrganizerCalendarAddress, + ], + [&jane_event_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &jane_event_id, + "title": "Brunch", + "organizerCalendarAddress": "mailto:jdoe@example.com" + })); + + // An attendee replying to the invitation does not take over as organizer + jane.jmap_update( + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus": + "accepted"}), + )], + [("sendSchedulingMessages", true)], + ) + .await + .updated(&jane_event_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + jane.jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::OrganizerCalendarAddress, + ], + [&jane_event_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &jane_event_id, + "organizerCalendarAddress": "mailto:jdoe@example.com" + })); + + // Cleanup + test.wait_for_tasks().await; + for client in [john, jane, bill] { + client.destroy_all_calendars().await; + client.destroy_all_event_notifications().await; + test.destroy_all_mailboxes(client).await; + } + test.assert_is_empty().await; +} + +fn test_event_without_organizer() -> Value { + json!({ + "uid": "9263504FD3AE", + "title": "Brunch", + "timeZone": "Europe/London", + "start": DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339().trim_end_matches("Z").to_string(), + "duration": "PT1H", + "freeBusyStatus": "busy", + "updated": "2009-06-02T17:00:00Z", + "sequence": 0, + "@type": "Event", + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "participationStatus": "accepted", + "roles": { + "chair": true, + "owner": true + }, + "@type": "Participant" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "participationStatus": "needs-action", + "kind": "individual" + } + } + }) +} + +fn test_event() -> Value { + json!({ + "uid": "9263504FD3AD", + "title": "Lunch", + "timeZone": "Europe/London", + "start": DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339().trim_end_matches("Z").to_string(), + "duration": "PT1H", + "freeBusyStatus": "busy", + "updated": "2009-06-02T17:00:00Z", + "sequence": 0, + "@type": "Event", + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "participationStatus": "accepted", + "roles": { + "chair": true, + "owner": true + }, + "@type": "Participant" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "participationStatus": "needs-action", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "participationStatus": "needs-action", + "@type": "Participant", + "kind": "individual" + } + }, + "organizerCalendarAddress": "mailto:jdoe@example.com" + }) +} diff --git a/tests/src/jmap/compliance/binary.rs b/tests/src/jmap/compliance/binary.rs new file mode 100644 index 0000000..73dcd05 --- /dev/null +++ b/tests/src/jmap/compliance/binary.rs @@ -0,0 +1,275 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq, skip}; +use crate::utils::jmap::JmapUtils; +use serde_json::json; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] binary"); + + ctx.run("binary/upload-basic", upload_basic(ctx)).await; + ctx.run("binary/upload-binary-content", upload_binary_content(ctx)) + .await; + ctx.run("binary/upload-large-data", upload_large_data(ctx)) + .await; + ctx.run( + "binary/upload-preserves-content-type", + upload_preserves_content_type(ctx), + ) + .await; + ctx.run( + "binary/upload-returns-valid-blob-id", + upload_returns_valid_blob_id(ctx), + ) + .await; + + ctx.run("binary/download-uploaded-blob", download_uploaded_blob(ctx)) + .await; + ctx.run("binary/download-email-blob", download_email_blob(ctx)) + .await; + ctx.run( + "binary/download-nonexistent-blob", + download_nonexistent_blob(ctx), + ) + .await; + ctx.run( + "binary/download-respects-type-param", + download_respects_type_param(ctx), + ) + .await; + + ctx.run( + "binary/blob-copy-same-account-error", + blob_copy_same_account_error(ctx), + ) + .await; + ctx.run( + "binary/blob-copy-cross-account", + blob_copy_cross_account(ctx), + ) + .await; + ctx.run("binary/blob-copy-not-found", blob_copy_not_found(ctx)) + .await; + ctx.run( + "binary/blob-copy-response-structure", + blob_copy_response_structure(ctx), + ) + .await; +} + +const CORE: &str = "urn:ietf:params:jmap:core"; +const BLOB: &str = "urn:ietf:params:jmap:blob"; + +async fn upload_basic(ctx: &CompCtx<'_>) -> TestOutcome { + let data = b"Hello, JMAP upload test!".to_vec(); + let len = data.len() as i64; + let result = ctx.upload(ctx.primary, "text/plain", data).await; + check(!result.blob_id().is_empty(), "Must return blobId")?; + let typ = result.typ(); + check( + typ == "text/plain" || typ.starts_with("text/plain;"), + format!("Expected type to be text/plain (possibly with params), got \"{typ}\""), + )?; + check_eq(result.integer_field("size"), len, "size")?; + check_eq( + result.text_field("accountId"), + ctx.account_id(), + "accountId", + ) +} + +async fn upload_binary_content(ctx: &CompCtx<'_>) -> TestOutcome { + let data = vec![0x00u8, 0x01, 0x02, 0xff, 0xfe, 0xfd]; + let result = ctx + .upload(ctx.primary, "application/octet-stream", data) + .await; + check(!result.blob_id().is_empty(), "Must return blobId")?; + check_eq(result.integer_field("size"), 6, "size") +} + +async fn upload_large_data(ctx: &CompCtx<'_>) -> TestOutcome { + let len = 100 * 1024; + let data: Vec = (0..len).map(|i| (i & 0xff) as u8).collect(); + let result = ctx + .upload(ctx.primary, "application/octet-stream", data) + .await; + check(!result.blob_id().is_empty(), "Must return blobId")?; + check_eq(result.integer_field("size"), len as i64, "size") +} + +async fn upload_preserves_content_type(ctx: &CompCtx<'_>) -> TestOutcome { + let data = b"test".to_vec(); + let result = ctx.upload(ctx.primary, "text/html", data).await; + let typ = result.typ(); + check( + typ == "text/html" || typ.starts_with("text/html;"), + format!("Expected type to be text/html (possibly with params), got \"{typ}\""), + ) +} + +async fn upload_returns_valid_blob_id(ctx: &CompCtx<'_>) -> TestOutcome { + let data = b"test".to_vec(); + let result = ctx.upload(ctx.primary, "text/plain", data).await; + let blob_id = result.blob_id(); + check(!blob_id.is_empty(), "blobId must not be empty")?; + check(blob_id.len() <= 255, "blobId must be <= 255 chars") +} + +async fn download_uploaded_blob(ctx: &CompCtx<'_>) -> TestOutcome { + let original = b"Download test content 12345".to_vec(); + let upload = ctx + .upload(ctx.primary, "text/plain", original.clone()) + .await; + let url = ctx.download_url(ctx.account_id(), upload.blob_id(), "text/plain", "test.txt"); + let result = ctx.primary.http_get_raw(&url, None).await; + check_eq(result.status, 200, "status")?; + check_eq(result.body.len(), original.len(), "downloaded length")?; + check( + result.body == original, + "downloaded bytes must match original", + ) +} + +async fn download_email_blob(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = ctx.primary.jmap_get("Email", ["blobId"], [email_id]).await; + let email = &resp.list()[0]; + let blob_id = email.blob_id(); + check(!blob_id.is_empty(), "Must return blobId")?; + let url = ctx.download_url(ctx.account_id(), blob_id, "message/rfc5322", "email.eml"); + let download = ctx.primary.http_get_raw(&url, None).await; + check_eq(download.status, 200, "status")?; + check(!download.body.is_empty(), "body must not be empty") +} + +async fn download_nonexistent_blob(ctx: &CompCtx<'_>) -> TestOutcome { + let url = ctx.download_url( + ctx.account_id(), + "nonexistent-blob-id-xyz", + "application/octet-stream", + "missing.bin", + ); + let result = ctx.primary.http_get_raw(&url, None).await; + check_eq(result.status, 404, "status") +} + +async fn download_respects_type_param(ctx: &CompCtx<'_>) -> TestOutcome { + let data = b"type test".to_vec(); + let upload = ctx.upload(ctx.primary, "text/plain", data).await; + let url = ctx.download_url( + ctx.account_id(), + upload.blob_id(), + "application/octet-stream", + "test.bin", + ); + let result = ctx.primary.http_get_raw(&url, None).await; + check_eq(result.status, 200, "status")?; + let ct = result.content_type().unwrap_or(""); + check_contains(ct, "application/octet-stream", "content-type") +} + +async fn blob_copy_same_account_error(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &[CORE, BLOB], + json!([[ + "Blob/copy", + { + "fromAccountId": ctx.account_id(), + "accountId": ctx.account_id(), + "blobIds": ["placeholder"] + }, + "c0" + ]]), + ) + .await; + if resp.is_error_at(0) { + check_eq( + resp.error_type_at(0).unwrap_or(""), + "invalidArguments", + "Same-account copy must return invalidArguments", + ) + } else { + Ok(()) + } +} + +async fn blob_copy( + ctx: &CompCtx<'_>, + cross: &str, + blob_ids: &[&str], +) -> crate::utils::jmap::JmapResponse { + ctx.primary + .jmap_request( + &[CORE, BLOB], + json!([[ + "Blob/copy", + { + "fromAccountId": ctx.account_id(), + "accountId": cross, + "blobIds": blob_ids + }, + "c0" + ]]), + ) + .await +} + +async fn blob_copy_cross_account(ctx: &CompCtx<'_>) -> TestOutcome { + let Some(cross) = ctx.cross_account_id.as_deref() else { + return skip("No cross-account access available"); + }; + let upload = ctx + .upload( + ctx.primary, + "text/plain", + b"blob cross-account copy test".to_vec(), + ) + .await; + let blob_id = upload.blob_id(); + let resp = blob_copy(ctx, cross, &[blob_id]).await; + let r = resp.response_at(0); + check(!r["copied"].is_null(), "copied must not be null")?; + check( + !r["copied"][blob_id].is_null(), + "Blob should be in copied map", + ) +} + +async fn blob_copy_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let Some(cross) = ctx.cross_account_id.as_deref() else { + return skip("No cross-account access available"); + }; + let resp = blob_copy(ctx, cross, &["nonexistent-blob-xyz"]).await; + let r = resp.response_at(0); + check(!r["notCopied"].is_null(), "notCopied must be present")?; + let nc = &r["notCopied"]["nonexistent-blob-xyz"]; + check(!nc.is_null(), "Invalid blob should be in notCopied")?; + check_eq( + nc["type"].as_str().unwrap_or(""), + "blobNotFound", + "notCopied type", + ) +} + +async fn blob_copy_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let Some(cross) = ctx.cross_account_id.as_deref() else { + return skip("No cross-account access available"); + }; + let upload = ctx + .upload(ctx.primary, "text/plain", b"structure test".to_vec()) + .await; + let resp = blob_copy(ctx, cross, &[upload.blob_id()]).await; + let r = resp.response_at(0); + check_eq( + r["fromAccountId"].as_str().unwrap_or(""), + ctx.account_id(), + "fromAccountId", + )?; + check_eq(r["accountId"].as_str().unwrap_or(""), cross, "accountId") +} diff --git a/tests/src/jmap/compliance/core.rs b/tests/src/jmap/compliance/core.rs new file mode 100644 index 0000000..b28ff22 --- /dev/null +++ b/tests/src/jmap/compliance/core.rs @@ -0,0 +1,742 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq}; +use serde_json::json; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] core"); + + // --- echo --- + ctx.run("core/echo-basic", echo_basic(ctx)).await; + ctx.run("core/echo-empty", echo_empty(ctx)).await; + ctx.run("core/echo-nested", echo_nested(ctx)).await; + + // --- session --- + ctx.run( + "core/session-has-capabilities", + session_has_capabilities(ctx), + ) + .await; + ctx.run( + "core/session-has-core-capability", + session_has_core_capability(ctx), + ) + .await; + ctx.run( + "core/session-has-mail-capability", + session_has_mail_capability(ctx), + ) + .await; + ctx.run( + "core/session-core-capability-properties", + session_core_capability_properties(ctx), + ) + .await; + ctx.run( + "core/session-accounts-present", + session_accounts_present(ctx), + ) + .await; + ctx.run( + "core/session-account-properties", + session_account_properties(ctx), + ) + .await; + ctx.run( + "core/session-primary-accounts", + session_primary_accounts(ctx), + ) + .await; + ctx.run("core/session-username", session_username(ctx)) + .await; + ctx.run("core/session-api-url", session_api_url(ctx)).await; + ctx.run( + "core/session-download-url-template", + session_download_url_template(ctx), + ) + .await; + ctx.run("core/session-upload-url", session_upload_url(ctx)) + .await; + ctx.run( + "core/session-event-source-url", + session_event_source_url(ctx), + ) + .await; + ctx.run("core/session-state", session_state(ctx)).await; + ctx.run( + "core/session-account-capabilities-mail", + session_account_capabilities_mail(ctx), + ) + .await; + ctx.run( + "core/session-mail-capability-properties", + session_mail_capability_properties(ctx), + ) + .await; + + // --- request errors --- + ctx.run("core/error-not-json", error_not_json(ctx)).await; + ctx.run("core/error-not-request", error_not_request(ctx)) + .await; + ctx.run( + "core/error-unknown-capability", + error_unknown_capability(ctx), + ) + .await; + ctx.run("core/error-empty-using", error_empty_using(ctx)) + .await; + ctx.run( + "core/error-wrong-content-type", + error_wrong_content_type(ctx), + ) + .await; + ctx.run( + "core/error-method-calls-not-array", + error_method_calls_not_array(ctx), + ) + .await; + + // --- method errors --- + ctx.run("core/error-unknown-method", error_unknown_method(ctx)) + .await; + ctx.run( + "core/error-invalid-arguments-missing-account", + error_invalid_arguments_missing_account(ctx), + ) + .await; + ctx.run("core/error-account-not-found", error_account_not_found(ctx)) + .await; + ctx.run( + "core/error-invalid-arguments-bad-type", + error_invalid_arguments_bad_type(ctx), + ) + .await; + ctx.run( + "core/error-method-level-has-type", + error_method_level_has_type(ctx), + ) + .await; + ctx.run("core/error-state-mismatch", error_state_mismatch(ctx)) + .await; + ctx.run( + "core/error-multiple-method-responses", + error_multiple_method_responses(ctx), + ) + .await; + ctx.run( + "core/error-response-has-session-state", + error_response_has_session_state(ctx), + ) + .await; + + // --- result references --- + ctx.run("core/result-ref-simple", result_ref_simple(ctx)) + .await; + ctx.run("core/result-ref-chained", result_ref_chained(ctx)) + .await; + ctx.run( + "core/result-ref-invalid-result-of", + result_ref_invalid_result_of(ctx), + ) + .await; + ctx.run( + "core/result-ref-wrong-method-name", + result_ref_wrong_method_name(ctx), + ) + .await; + ctx.run( + "core/result-ref-path-single-value", + result_ref_path_single_value(ctx), + ) + .await; + ctx.run( + "core/result-ref-call-id-preserved", + result_ref_call_id_preserved(ctx), + ) + .await; +} + +const CORE: &str = "urn:ietf:params:jmap:core"; +const MAIL: &str = "urn:ietf:params:jmap:mail"; + +fn default_using() -> Vec<&'static str> { + vec![CORE, MAIL] +} + +// --- echo --- + +async fn echo_basic(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call("Core/echo", json!({ "hello": "world", "number": 42 })) + .await; + let r = resp.method_response(); + check_eq(&r["hello"], &json!("world"), "hello")?; + check_eq(&r["number"], &json!(42), "number") +} + +async fn echo_empty(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx.primary.jmap_method_call("Core/echo", json!({})).await; + check_eq(resp.method_response(), &json!({}), "echo empty") +} + +async fn echo_nested(ctx: &CompCtx<'_>) -> TestOutcome { + let args = json!({ + "string": "test", + "number": 42, + "bool": true, + "null": null, + "array": [1, "two", false], + "object": { "nested": { "deep": "value" } }, + }); + let resp = ctx + .primary + .jmap_method_call("Core/echo", args.clone()) + .await; + check_eq(resp.method_response(), &args, "echo nested") +} + +// --- session --- + +async fn session_has_capabilities(ctx: &CompCtx<'_>) -> TestOutcome { + let caps = &ctx.session["capabilities"]; + check(caps.is_object(), "capabilities must be object")?; + check( + caps.as_object().map(|o| !o.is_empty()).unwrap_or(false), + "capabilities must not be empty", + ) +} + +async fn session_has_core_capability(ctx: &CompCtx<'_>) -> TestOutcome { + check( + !ctx.session["capabilities"][CORE].is_null(), + "Must have core capability", + ) +} + +async fn session_has_mail_capability(ctx: &CompCtx<'_>) -> TestOutcome { + check( + !ctx.session["capabilities"][MAIL].is_null(), + "Must have mail capability", + ) +} + +async fn session_core_capability_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let core = &ctx.session["capabilities"][CORE]; + for prop in [ + "maxSizeUpload", + "maxConcurrentUpload", + "maxSizeRequest", + "maxConcurrentRequests", + "maxCallsInRequest", + "maxObjectsInGet", + "maxObjectsInSet", + ] { + check(core[prop].is_number(), format!("{prop} must be a number"))?; + } + check( + core["collationAlgorithms"].is_array(), + "collationAlgorithms must be an array", + ) +} + +async fn session_accounts_present(ctx: &CompCtx<'_>) -> TestOutcome { + let accounts = &ctx.session["accounts"]; + check(accounts.is_object(), "accounts must be object")?; + check( + accounts.as_object().map(|o| !o.is_empty()).unwrap_or(false), + "Must have at least one account", + ) +} + +async fn session_account_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let account = &ctx.session["accounts"][ctx.account_id()]; + check(account.is_object(), "Primary account must exist")?; + check(account["name"].is_string(), "name must be string")?; + check( + account["isPersonal"].is_boolean(), + "isPersonal must be bool", + )?; + check( + account["isReadOnly"].is_boolean(), + "isReadOnly must be bool", + )?; + check( + account["accountCapabilities"].is_object(), + "accountCapabilities must be object", + ) +} + +async fn session_primary_accounts(ctx: &CompCtx<'_>) -> TestOutcome { + let pa = &ctx.session["primaryAccounts"]; + check(pa.is_object(), "primaryAccounts must be object")?; + let mail_acct = &pa[MAIL]; + check(mail_acct.is_string(), "Must have primary mail account")?; + check_eq( + mail_acct.as_str().unwrap_or(""), + ctx.account_id(), + "primary mail account", + ) +} + +async fn session_username(ctx: &CompCtx<'_>) -> TestOutcome { + let u = &ctx.session["username"]; + check(u.is_string(), "username must be string")?; + check( + u.as_str().map(|s| !s.is_empty()).unwrap_or(false), + "username must not be empty", + ) +} + +async fn session_api_url(ctx: &CompCtx<'_>) -> TestOutcome { + let u = &ctx.session["apiUrl"]; + check(u.is_string(), "apiUrl must be string")?; + check( + u.as_str().map(|s| !s.is_empty()).unwrap_or(false), + "apiUrl must not be empty", + ) +} + +async fn session_download_url_template(ctx: &CompCtx<'_>) -> TestOutcome { + let u = ctx.session["downloadUrl"].as_str().unwrap_or(""); + check(!u.is_empty(), "downloadUrl must be string")?; + for v in ["{accountId}", "{blobId}", "{name}", "{type}"] { + check_contains(u, v, "downloadUrl template")?; + } + Ok(()) +} + +async fn session_upload_url(ctx: &CompCtx<'_>) -> TestOutcome { + let u = ctx.session["uploadUrl"].as_str().unwrap_or(""); + check(!u.is_empty(), "uploadUrl must be string")?; + check_contains(u, "{accountId}", "uploadUrl template") +} + +async fn session_event_source_url(ctx: &CompCtx<'_>) -> TestOutcome { + let u = &ctx.session["eventSourceUrl"]; + check(u.is_string(), "eventSourceUrl must be string")?; + check( + u.as_str().map(|s| !s.is_empty()).unwrap_or(false), + "eventSourceUrl must not be empty", + ) +} + +async fn session_state(ctx: &CompCtx<'_>) -> TestOutcome { + let u = &ctx.session["state"]; + check(u.is_string(), "state must be string")?; + check( + u.as_str().map(|s| !s.is_empty()).unwrap_or(false), + "state must not be empty", + ) +} + +async fn session_account_capabilities_mail(ctx: &CompCtx<'_>) -> TestOutcome { + let account = &ctx.session["accounts"][ctx.account_id()]; + check( + !account["accountCapabilities"][MAIL].is_null(), + "Account must have mail capability", + ) +} + +async fn session_mail_capability_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let account = &ctx.session["accounts"][ctx.account_id()]; + let mail = &account["accountCapabilities"][MAIL]; + check(mail.is_object(), "Account must have mail capability object")?; + check( + mail["maxMailboxesPerEmail"].is_null() || mail["maxMailboxesPerEmail"].is_number(), + "maxMailboxesPerEmail must be null or number", + )?; + check( + mail["maxMailboxDepth"].is_null() || mail["maxMailboxDepth"].is_number(), + "maxMailboxDepth must be null or number", + )?; + check(mail["maxSizeMailboxName"].is_number(), "maxSizeMailboxName")?; + check( + mail["maxSizeAttachmentsPerEmail"].is_number(), + "maxSizeAttachmentsPerEmail", + )?; + check( + mail["emailQuerySortOptions"].is_array(), + "emailQuerySortOptions must be an array", + )?; + check( + mail["mayCreateTopLevelMailbox"].is_boolean(), + "mayCreateTopLevelMailbox", + ) +} + +// --- request errors --- + +async fn error_not_json(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_raw_post("this is not json", "application/json") + .await; + check( + resp.is_client_error(), + format!("Expected 4xx client error, got {}", resp.status), + ) +} + +async fn error_not_request(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_raw_post(json!({ "foo": "bar" }).to_string(), "application/json") + .await; + check( + resp.is_client_error(), + format!("Expected 4xx client error, got {}", resp.status), + ) +} + +async fn error_unknown_capability(ctx: &CompCtx<'_>) -> TestOutcome { + let body = json!({ + "using": [CORE, "urn:fake:nonexistent"], + "methodCalls": [["Core/echo", {}, "c0"]] + }); + let resp = ctx + .primary + .jmap_raw_post(body.to_string(), "application/json") + .await; + check( + resp.is_client_error(), + format!( + "Expected HTTP 4xx for unknown capability, got {}", + resp.status + ), + ) +} + +async fn error_empty_using(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request(&[], json!([["Core/echo", {}, "c0"]])) + .await; + check_eq( + resp.name_at(0), + "error", + "With empty using, method call must return error", + )?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "unknownMethod", + "Error type must be unknownMethod when no capabilities in using", + ) +} + +async fn error_wrong_content_type(ctx: &CompCtx<'_>) -> TestOutcome { + let body = json!({ + "using": [CORE], + "methodCalls": [["Core/echo", {}, "c0"]] + }); + let resp = ctx + .primary + .jmap_raw_post(body.to_string(), "text/plain") + .await; + check( + resp.is_client_error(), + format!("Expected 4xx for wrong content type, got {}", resp.status), + ) +} + +async fn error_method_calls_not_array(ctx: &CompCtx<'_>) -> TestOutcome { + let body = json!({ + "using": [CORE], + "methodCalls": "not-an-array" + }); + let resp = ctx + .primary + .jmap_raw_post(body.to_string(), "application/json") + .await; + check( + resp.is_client_error(), + format!("Expected 4xx client error, got {}", resp.status), + ) +} + +// --- method errors --- + +async fn error_unknown_method(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request(&[CORE], json!([["Fake/nonexistent", {}, "c0"]])) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check_eq(resp.error_type_at(0).unwrap_or(""), "unknownMethod", "type") +} + +async fn error_invalid_arguments_missing_account(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request(&default_using(), json!([["Mailbox/get", {}, "c0"]])) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "invalidArguments", + "Missing accountId must return invalidArguments", + ) +} + +async fn error_account_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([["Mailbox/get", { "accountId": "nonexistent-account-id-xyz" }, "c0"]]), + ) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "accountNotFound", + "type", + ) +} + +async fn error_invalid_arguments_bad_type(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([["Mailbox/get", { "accountId": ctx.account_id(), "ids": "not-an-array" }, "c0"]]), + ) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "invalidArguments", + "type", + ) +} + +async fn error_method_level_has_type(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request(&[CORE], json!([["Fake/method", {}, "c0"]])) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check( + !resp.response_at(0)["type"].is_null(), + "Method-level error must include 'type'", + ) +} + +async fn error_state_mismatch(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([[ + "Mailbox/set", + { "accountId": ctx.account_id(), "ifInState": "invalid-state-that-does-not-exist", "update": {} }, + "c0" + ]]), + ) + .await; + match resp + .0 + .pointer("/methodResponses/0/0") + .and_then(|v| v.as_str()) + { + Some("error") => check_eq(resp.error_type_at(0).unwrap_or(""), "stateMismatch", "type"), + _ => Ok(()), + } +} + +async fn error_multiple_method_responses(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + ["Mailbox/get", { "accountId": ctx.account_id(), "ids": [] }, "call1"], + ["Core/echo", { "test": true }, "call2"], + ["Fake/nonexistent", {}, "call3"], + ]), + ) + .await; + check_eq(resp.num_responses(), 3, "must have 3 responses")?; + check_eq(resp.call_id_at(0), "call1", "call1")?; + check_eq(resp.call_id_at(1), "call2", "call2")?; + check_eq(resp.call_id_at(2), "call3", "call3")?; + check_eq(resp.name_at(2), "error", "third must be error") +} + +async fn error_response_has_session_state(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request(&[CORE], json!([["Core/echo", {}, "c0"]])) + .await; + check( + resp.session_state().map(|s| !s.is_empty()).unwrap_or(false), + "Response must include non-empty sessionState", + ) +} + +// --- result references --- + +async fn result_ref_simple(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + ["Mailbox/get", { "accountId": ctx.account_id(), "ids": null }, "getMailboxes"], + [ + "Mailbox/get", + { + "accountId": ctx.account_id(), + "#ids": { "resultOf": "getMailboxes", "name": "Mailbox/get", "path": "/list/*/id" } + }, + "getById" + ] + ]), + ) + .await; + check_eq(resp.num_responses(), 2, "responses")?; + check_eq(resp.name_at(0), "Mailbox/get", "name1")?; + check_eq(resp.name_at(1), "Mailbox/get", "name2")?; + let list2 = resp.response_at(1)["list"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + check(list2 > 0, "Should have resolved mailbox ids") +} + +async fn result_ref_chained(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + [ + "Email/query", + { "accountId": ctx.account_id(), "filter": { "inMailbox": ctx.role("inbox") }, "limit": 3 }, + "query" + ], + [ + "Email/get", + { + "accountId": ctx.account_id(), + "#ids": { "resultOf": "query", "name": "Email/query", "path": "/ids" }, + "properties": ["id", "subject"] + }, + "getEmails" + ] + ]), + ) + .await; + check_eq(resp.num_responses(), 2, "responses")?; + check_eq(resp.name_at(0), "Email/query", "name1")?; + check_eq(resp.name_at(1), "Email/get", "name2")?; + let query_ids = resp.response_at(0)["ids"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + let get_list = resp.response_at(1)["list"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + check_eq(get_list, query_ids, "get list length == query ids length") +} + +async fn result_ref_invalid_result_of(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([[ + "Email/get", + { + "accountId": ctx.account_id(), + "#ids": { "resultOf": "nonexistent", "name": "Email/query", "path": "/ids" } + }, + "c0" + ]]), + ) + .await; + check_eq(resp.name_at(0), "error", "name")?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "invalidResultReference", + "type", + ) +} + +async fn result_ref_wrong_method_name(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + [ + "Email/query", + { "accountId": ctx.account_id(), "filter": { "inMailbox": ctx.role("inbox") }, "limit": 1 }, + "query" + ], + [ + "Email/get", + { + "accountId": ctx.account_id(), + "#ids": { "resultOf": "query", "name": "Mailbox/get", "path": "/ids" } + }, + "get" + ] + ]), + ) + .await; + check_eq(resp.name_at(1), "error", "name")?; + check_eq( + resp.error_type_at(1).unwrap_or(""), + "invalidResultReference", + "type", + ) +} + +async fn result_ref_path_single_value(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + ["Mailbox/get", { "accountId": ctx.account_id(), "ids": [] }, "getState"], + [ + "Mailbox/changes", + { + "accountId": ctx.account_id(), + "#sinceState": { "resultOf": "getState", "name": "Mailbox/get", "path": "/state" } + }, + "changes" + ] + ]), + ) + .await; + check_eq(resp.num_responses(), 2, "responses")?; + check_eq(resp.name_at(1), "Mailbox/changes", "name2")?; + let r = resp.response_at(1); + check(!r["oldState"].is_null(), "Should have oldState")?; + check(!r["newState"].is_null(), "Should have newState") +} + +async fn result_ref_call_id_preserved(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([ + ["Core/echo", { "value": 1 }, "first"], + ["Core/echo", { "value": 2 }, "second"], + ["Core/echo", { "value": 3 }, "third"], + ]), + ) + .await; + check_eq(resp.num_responses(), 3, "responses")?; + check_eq(resp.call_id_at(0), "first", "first")?; + check_eq(resp.call_id_at(1), "second", "second")?; + check_eq(resp.call_id_at(2), "third", "third") +} diff --git a/tests/src/jmap/compliance/email/inspect.rs b/tests/src/jmap/compliance/email/inspect.rs new file mode 100644 index 0000000..8d99675 --- /dev/null +++ b/tests/src/jmap/compliance/email/inspect.rs @@ -0,0 +1,1147 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::compliance::{CompCtx, TestOutcome, check, check_contains, check_eq}; +use crate::utils::jmap::JmapResponse; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + ctx.run("email/get-by-id", get_by_id(ctx)).await; + ctx.run( + "email/get-metadata-properties", + get_metadata_properties(ctx), + ) + .await; + ctx.run("email/get-mailbox-ids", get_mailbox_ids(ctx)).await; + ctx.run("email/get-keywords", get_keywords(ctx)).await; + ctx.run( + "email/get-has-attachment-true", + get_has_attachment_true(ctx), + ) + .await; + ctx.run( + "email/get-has-attachment-false", + get_has_attachment_false(ctx), + ) + .await; + ctx.run( + "email/get-thread-id-consistent", + get_thread_id_consistent(ctx), + ) + .await; + ctx.run("email/get-not-found", get_not_found(ctx)).await; + ctx.run("email/get-properties-filter", get_properties_filter(ctx)) + .await; + ctx.run("email/get-preview-is-text", get_preview_is_text(ctx)) + .await; + ctx.run( + "email/get-received-at-is-utc-date", + get_received_at_is_utc_date(ctx), + ) + .await; + ctx.run("email/get-multiple-emails", get_multiple_emails(ctx)) + .await; + ctx.run("email/get-state-returned", get_state_returned(ctx)) + .await; + + ctx.run("email/body-structure", body_structure(ctx)).await; + ctx.run("email/body-text-body", body_text_body(ctx)).await; + ctx.run("email/body-html-body", body_html_body(ctx)).await; + ctx.run("email/body-attachments", body_attachments(ctx)) + .await; + ctx.run("email/body-values-text", body_values_text(ctx)) + .await; + ctx.run("email/body-values-html", body_values_html(ctx)) + .await; + ctx.run("email/body-values-all", body_values_all(ctx)).await; + ctx.run( + "email/body-max-body-value-bytes", + body_max_body_value_bytes(ctx), + ) + .await; + ctx.run("email/body-properties-filter", body_properties_filter(ctx)) + .await; + ctx.run( + "email/body-multipart-alternative-text-and-html", + body_multipart_alternative_text_and_html(ctx), + ) + .await; + ctx.run( + "email/body-inline-attachment-cid", + body_inline_attachment_cid(ctx), + ) + .await; + ctx.run( + "email/body-attachment-blob-id", + body_attachment_blob_id(ctx), + ) + .await; + ctx.run("email/body-non-utf8-charset", body_non_utf8_charset(ctx)) + .await; + ctx.run( + "email/body-invalid-ascii-handling", + body_invalid_ascii_handling(ctx), + ) + .await; + + ctx.run("email/header-from", header_from(ctx)).await; + ctx.run("email/header-to", header_to(ctx)).await; + ctx.run("email/header-cc", header_cc(ctx)).await; + ctx.run("email/header-subject", header_subject(ctx)).await; + ctx.run("email/header-subject-empty", header_subject_empty(ctx)) + .await; + ctx.run("email/header-sent-at", header_sent_at(ctx)).await; + ctx.run("email/header-message-id", header_message_id(ctx)) + .await; + ctx.run("email/header-in-reply-to", header_in_reply_to(ctx)) + .await; + ctx.run("email/header-references", header_references(ctx)) + .await; + ctx.run("email/header-raw-access", header_raw_access(ctx)) + .await; + ctx.run("email/header-as-addresses", header_as_addresses(ctx)) + .await; + ctx.run("email/header-as-message-ids", header_as_message_ids(ctx)) + .await; + ctx.run("email/header-as-date", header_as_date(ctx)).await; + ctx.run("email/header-as-urls", header_as_urls(ctx)).await; + ctx.run("email/header-custom-header", header_custom_header(ctx)) + .await; + ctx.run( + "email/header-intl-from-decoded", + header_intl_from_decoded(ctx), + ) + .await; + ctx.run( + "email/header-as-grouped-addresses", + header_as_grouped_addresses(ctx), + ) + .await; + ctx.run("email/header-raw-form", header_raw_form(ctx)).await; + ctx.run( + "email/header-case-insensitive", + header_case_insensitive(ctx), + ) + .await; + ctx.run("email/header-bcc", header_bcc(ctx)).await; + + ctx.run("email/parse-valid-message", parse_valid_message(ctx)) + .await; + ctx.run("email/parse-null-metadata", parse_null_metadata(ctx)) + .await; + ctx.run("email/parse-not-found", parse_not_found(ctx)).await; + ctx.run("email/parse-not-parsable", parse_not_parsable(ctx)) + .await; + ctx.run("email/parse-body-values", parse_body_values(ctx)) + .await; + ctx.run( + "email/parse-response-structure", + parse_response_structure(ctx), + ) + .await; +} + +async fn email_get(ctx: &CompCtx<'_>, mut args: Value) -> JmapResponse { + if let Value::Object(map) = &mut args { + map.insert("accountId".to_string(), json!(ctx.account_id())); + } + ctx.primary.jmap_method_call("Email/get", args).await +} + +async fn email_parse(ctx: &CompCtx<'_>, mut args: Value) -> JmapResponse { + if let Value::Object(map) = &mut args { + map.insert("accountId".to_string(), json!(ctx.account_id())); + } + ctx.primary.jmap_method_call("Email/parse", args).await +} + +async fn upload_message(ctx: &CompCtx<'_>, content_type: &str, data: &str) -> String { + let upload = ctx + .upload(ctx.primary, content_type, data.as_bytes().to_vec()) + .await; + upload["blobId"] + .as_str() + .unwrap_or_else(|| panic!("upload missing blobId: {upload}")) + .to_string() +} + +fn first_email(resp: &JmapResponse) -> &Value { + resp.list() + .first() + .unwrap_or_else(|| panic!("Email/get returned empty list: {resp:?}")) +} + +fn count(v: &Value) -> usize { + v.as_array().map(|a| a.len()).unwrap_or(0) +} + +fn date_is_valid(s: &str) -> bool { + s.len() >= 10 && s.as_bytes()[4] == b'-' +} + +fn find_part<'a>(part: &'a Value, type_: &str) -> Option<&'a Value> { + if part["type"] + .as_str() + .map(|t| t.contains(type_)) + .unwrap_or(false) + { + return Some(part); + } + if let Some(sub_parts) = part["subParts"].as_array() { + for sub in sub_parts { + if let Some(found) = find_part(sub, type_) { + return Some(found); + } + } + } + None +} + +async fn get_by_id(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id] })).await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + check_eq(list[0]["id"].as_str().unwrap_or(""), email_id, "id") +} + +async fn get_metadata_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": [ + "id", "blobId", "threadId", "mailboxIds", "keywords", + "size", "receivedAt", "hasAttachment", "preview" + ] + }), + ) + .await; + let email = first_email(&resp); + check(email["id"].is_string(), "id must be string")?; + check(email["blobId"].is_string(), "blobId must be string")?; + check(email["threadId"].is_string(), "threadId must be string")?; + check(email["mailboxIds"].is_object(), "mailboxIds must be object")?; + check(email["keywords"].is_object(), "keywords must be object")?; + check(email["size"].is_number(), "size must be number")?; + check( + email["size"].as_i64().unwrap_or(0) > 0, + "size must be greater than 0", + )?; + check(email["receivedAt"].is_string(), "receivedAt must be string")?; + check( + email["hasAttachment"].is_boolean(), + "hasAttachment must be boolean", + )?; + check(email["preview"].is_string(), "preview must be string") +} + +async fn get_mailbox_ids(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("multi-mailbox"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["mailboxIds"] }), + ) + .await; + let email = first_email(&resp); + let mailbox_ids = &email["mailboxIds"]; + check_eq(&mailbox_ids[ctx.role("inbox")], &json!(true), "inbox")?; + check_eq( + &mailbox_ids[ctx.mailbox("folderA")], + &json!(true), + "folderA", + ) +} + +async fn get_keywords(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("custom-keywords"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["keywords"] }), + ) + .await; + let email = first_email(&resp); + let keywords = &email["keywords"]; + check_eq(&keywords["$seen"], &json!(true), "$seen")?; + check_eq(&keywords["$forwarded"], &json!(true), "$forwarded")?; + check_eq(&keywords["custom_label"], &json!(true), "custom_label") +} + +async fn get_has_attachment_true(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["hasAttachment"] }), + ) + .await; + let email = first_email(&resp); + check_eq(&email["hasAttachment"], &json!(true), "hasAttachment") +} + +async fn get_has_attachment_false(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["hasAttachment"] }), + ) + .await; + let email = first_email(&resp); + check_eq(&email["hasAttachment"], &json!(false), "hasAttachment") +} + +async fn get_thread_id_consistent(ctx: &CompCtx<'_>) -> TestOutcome { + let ids = [ + ctx.email("thread-starter"), + ctx.email("thread-reply-1"), + ctx.email("thread-reply-2"), + ]; + let resp = email_get(ctx, json!({ "ids": ids, "properties": ["threadId"] })).await; + let list = resp.list(); + check_eq(list.len(), 3, "list length")?; + check_eq(&list[0]["threadId"], &list[1]["threadId"], "thread 0 == 1")?; + check_eq(&list[1]["threadId"], &list[2]["threadId"], "thread 1 == 2") +} + +async fn get_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_get(ctx, json!({ "ids": ["nonexistent-email-xyz"] })).await; + let not_found = &resp.method_response()["notFound"]; + check( + not_found.is_array(), + format!("notFound must be an array, got {not_found}"), + )?; + check( + not_found + .as_array() + .map(|a| { + a.iter() + .any(|v| v.as_str() == Some("nonexistent-email-xyz")) + }) + .unwrap_or(false), + "notFound must include nonexistent-email-xyz", + ) +} + +async fn get_properties_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["id", "subject"] }), + ) + .await; + let email = first_email(&resp); + check(!email["id"].is_null(), "id must be present")?; + check(email.get("subject").is_some(), "subject must be defined") +} + +async fn get_preview_is_text(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["preview"] })).await; + let email = first_email(&resp); + let preview = email["preview"].as_str().unwrap_or(""); + check(email["preview"].is_string(), "preview must be string")?; + check(!preview.is_empty(), "preview length must be greater than 0")?; + check(!preview.contains(""), "Preview should be plain text") +} + +async fn get_received_at_is_utc_date(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["receivedAt"] }), + ) + .await; + let email = first_email(&resp); + let received = email["receivedAt"].as_str().unwrap_or(""); + check(date_is_valid(received), "receivedAt must be a valid date") +} + +async fn get_multiple_emails(ctx: &CompCtx<'_>) -> TestOutcome { + let ids = [ + ctx.email("plain-simple"), + ctx.email("html-attachment"), + ctx.email("thread-starter"), + ]; + let resp = email_get(ctx, json!({ "ids": ids, "properties": ["id"] })).await; + check_eq(resp.list().len(), 3, "list length") +} + +async fn get_state_returned(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_get(ctx, json!({ "ids": [] })).await; + check( + resp.method_response()["state"].is_string(), + "state must be string", + ) +} + +async fn body_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["bodyStructure"], + "bodyProperties": ["partId", "type", "name", "disposition", "size", "subParts"] + }), + ) + .await; + let email = first_email(&resp); + let bs = &email["bodyStructure"]; + check(bs.is_object(), "bodyStructure must be present")?; + check(bs["type"].is_string(), "type must be string") +} + +async fn body_text_body(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody"], + "bodyProperties": ["partId", "type"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + let text_body = &email["textBody"]; + check(text_body.is_array(), "textBody must be array")?; + check( + count(text_body) > 0, + "textBody length must be greater than 0", + )?; + check_contains( + text_body[0]["type"].as_str().unwrap_or(""), + "text/plain", + "type", + ) +} + +async fn body_html_body(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-only"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["htmlBody"], + "bodyProperties": ["partId", "type"], + "fetchHTMLBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + let html_body = &email["htmlBody"]; + check(html_body.is_array(), "htmlBody must be array")?; + check( + count(html_body) > 0, + "htmlBody length must be greater than 0", + )?; + check_contains( + html_body[0]["type"].as_str().unwrap_or(""), + "text/html", + "type", + ) +} + +async fn body_attachments(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["attachments"], + "bodyProperties": ["partId", "type", "name", "disposition", "size"] + }), + ) + .await; + let email = first_email(&resp); + let attachments = &email["attachments"]; + check(attachments.is_array(), "attachments must be array")?; + check( + count(attachments) > 0, + "attachments length must be greater than 0", + )?; + check_eq(&attachments[0]["name"], &json!("report.pdf"), "name") +} + +async fn body_values_text(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody", "bodyValues"], + "bodyProperties": ["partId"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + let body_values = &email["bodyValues"]; + check(body_values.is_object(), "bodyValues must be present")?; + let map = body_values.as_object().unwrap(); + check(!map.is_empty(), "Must have at least one body value")?; + let first = map.values().next().unwrap(); + check(first["value"].is_string(), "value must be string")?; + check_contains( + first["value"].as_str().unwrap_or(""), + "conference room", + "value", + )?; + check( + first["isEncodingProblem"].is_boolean(), + "isEncodingProblem must be boolean", + )?; + check( + first["isTruncated"].is_boolean(), + "isTruncated must be boolean", + )?; + check_eq(&first["isTruncated"], &json!(false), "isTruncated") +} + +async fn body_values_html(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-only"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["htmlBody", "bodyValues"], + "bodyProperties": ["partId"], + "fetchHTMLBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + let map = email["bodyValues"] + .as_object() + .unwrap_or_else(|| panic!("bodyValues missing: {email}")); + check(!map.is_empty(), "Must have at least one body value")?; + let first = map.values().next().unwrap(); + check_contains( + first["value"].as_str().unwrap_or(""), + "Weekly Digest", + "value", + ) +} + +async fn body_values_all(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-only"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody", "htmlBody", "bodyValues"], + "bodyProperties": ["partId", "type"], + "fetchAllBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + let len = email["bodyValues"] + .as_object() + .map(|m| m.len()) + .unwrap_or(0); + check(len >= 2, "Must have values for both text and HTML parts") +} + +async fn body_max_body_value_bytes(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("large-email"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody", "bodyValues"], + "bodyProperties": ["partId"], + "fetchTextBodyValues": true, + "maxBodyValueBytes": 100 + }), + ) + .await; + let email = first_email(&resp); + let map = email["bodyValues"] + .as_object() + .unwrap_or_else(|| panic!("bodyValues missing: {email}")); + check(!map.is_empty(), "bodyValues must have keys")?; + let bv = map.values().next().unwrap(); + check( + bv["value"].as_str().map(|s| s.len()).unwrap_or(0) <= 200, + "Body should be truncated near maxBodyValueBytes", + )?; + check_eq(&bv["isTruncated"], &json!(true), "isTruncated") +} + +async fn body_properties_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["bodyStructure"], + "bodyProperties": ["partId", "type"] + }), + ) + .await; + let email = first_email(&resp); + let bs = &email["bodyStructure"]; + check(bs.is_object(), "bodyStructure must be present")?; + if !bs["type"].is_null() { + check(bs["type"].is_string(), "type must be string")?; + } + Ok(()) +} + +async fn body_multipart_alternative_text_and_html(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-only"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody", "htmlBody"], + "bodyProperties": ["partId", "type"] + }), + ) + .await; + let email = first_email(&resp); + let text_body = &email["textBody"]; + let html_body = &email["htmlBody"]; + check( + count(text_body) > 0, + "textBody length must be greater than 0", + )?; + check( + count(html_body) > 0, + "htmlBody length must be greater than 0", + )?; + check_contains( + text_body[0]["type"].as_str().unwrap_or(""), + "text/plain", + "textBody type", + )?; + check_contains( + html_body[0]["type"].as_str().unwrap_or(""), + "text/html", + "htmlBody type", + ) +} + +async fn body_inline_attachment_cid(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("multipart-related"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["bodyStructure"], + "bodyProperties": ["partId", "type", "cid", "disposition", "subParts"] + }), + ) + .await; + let email = first_email(&resp); + let bs = &email["bodyStructure"]; + check(bs.is_object(), "bodyStructure must be present")?; + if let Some(image_part) = find_part(bs, "image/jpeg") { + check(!image_part["cid"].is_null(), "Inline image should have cid")?; + } + Ok(()) +} + +async fn body_attachment_blob_id(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["attachments"], + "bodyProperties": ["partId", "blobId", "type", "name", "size"] + }), + ) + .await; + let email = first_email(&resp); + let attachments = &email["attachments"]; + check( + count(attachments) > 0, + "attachments length must be greater than 0", + )?; + check( + !attachments[0]["blobId"].is_null(), + "Attachment must have blobId", + ) +} + +async fn body_non_utf8_charset(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("korean-euckr"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["textBody", "bodyValues", "from", "subject"], + "bodyProperties": ["partId", "charset"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + if let Some(map) = email["bodyValues"].as_object() + && let Some(first) = map.values().next() + { + check( + first["isEncodingProblem"].is_boolean(), + "isEncodingProblem must be boolean", + )?; + } + Ok(()) +} + +async fn body_invalid_ascii_handling(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("invalid-ascii"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["subject", "textBody", "bodyValues"], + "bodyProperties": ["partId"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = first_email(&resp); + check_eq(&email["subject"], &json!("Malformed email test"), "subject") +} + +async fn header_from(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["from"] })).await; + let email = first_email(&resp); + let from = &email["from"]; + check(from.is_array(), "from must be an array")?; + check(count(from) > 0, "from length must be greater than 0")?; + check_eq(&from[0]["email"], &json!("alice@example.com"), "email")?; + check_eq(&from[0]["name"], &json!("Alice Sender"), "name") +} + +async fn header_to(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["to"] })).await; + let email = first_email(&resp); + let to = &email["to"]; + check(to.is_array(), "to must be an array")?; + check(count(to) > 0, "to length must be greater than 0")?; + check_eq(&to[0]["email"], &json!("testuser@example.com"), "email") +} + +async fn header_cc(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("html-attachment"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["cc"] })).await; + let email = first_email(&resp); + let cc = &email["cc"]; + check(cc.is_array(), "cc must be an array")?; + check(count(cc) > 0, "cc length must be greater than 0")?; + check_eq(&cc[0]["email"], &json!("charlie@example.net"), "email") +} + +async fn header_subject(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["subject"] })).await; + let email = first_email(&resp); + check_eq( + &email["subject"], + &json!("Meeting tomorrow morning"), + "subject", + ) +} + +async fn header_subject_empty(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("no-subject"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["subject"] })).await; + let email = first_email(&resp); + let subject = &email["subject"]; + check( + subject == &json!("") || subject.is_null(), + format!("Expected empty/null subject, got {subject}"), + ) +} + +async fn header_sent_at(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["sentAt"] })).await; + let email = first_email(&resp); + check(email["sentAt"].is_string(), "sentAt must be string")?; + check( + date_is_valid(email["sentAt"].as_str().unwrap_or("")), + "sentAt must be a valid date", + ) +} + +async fn header_message_id(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("thread-starter"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["messageId"] }), + ) + .await; + let email = first_email(&resp); + let msg_id = &email["messageId"]; + check(msg_id.is_array(), "messageId must be an array")?; + check(count(msg_id) > 0, "messageId length must be greater than 0")?; + check_contains( + msg_id[0].as_str().unwrap_or(""), + "thread-alpha-001@test", + "messageId", + ) +} + +async fn header_in_reply_to(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("thread-reply-1"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["inReplyTo"] }), + ) + .await; + let email = first_email(&resp); + let in_reply_to = &email["inReplyTo"]; + check(in_reply_to.is_array(), "inReplyTo must be an array")?; + check_contains( + in_reply_to[0].as_str().unwrap_or(""), + "thread-alpha-001@test", + "inReplyTo", + ) +} + +async fn header_references(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("thread-reply-2"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["references"] }), + ) + .await; + let email = first_email(&resp); + let refs = &email["references"]; + check(refs.is_array(), "references must be an array")?; + check(count(refs) >= 2, "references length must be at least 2") +} + +async fn header_raw_access(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:Subject:asText"] }), + ) + .await; + let email = first_email(&resp); + let header_value = &email["header:Subject:asText"]; + check(header_value.is_string(), "header value must be string")?; + check_contains( + header_value.as_str().unwrap_or(""), + "Meeting tomorrow morning", + "header value", + ) +} + +async fn header_as_addresses(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:From:asAddresses"] }), + ) + .await; + let email = first_email(&resp); + let addrs = &email["header:From:asAddresses"]; + check(addrs.is_array(), "asAddresses must return array")?; + check_eq(&addrs[0]["email"], &json!("alice@example.com"), "email") +} + +async fn header_as_message_ids(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("thread-starter"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:Message-ID:asMessageIds"] }), + ) + .await; + let email = first_email(&resp); + let ids = &email["header:Message-ID:asMessageIds"]; + check(ids.is_array(), "asMessageIds must return array")?; + check_contains( + ids[0].as_str().unwrap_or(""), + "thread-alpha-001@test", + "messageId", + ) +} + +async fn header_as_date(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:Date:asDate"] }), + ) + .await; + let email = first_email(&resp); + let date_str = &email["header:Date:asDate"]; + check(date_str.is_string(), "asDate must be string")?; + check( + date_is_valid(date_str.as_str().unwrap_or("")), + "asDate must return valid date", + ) +} + +async fn header_as_urls(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("special-headers"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:List-Unsubscribe:asURLs"] }), + ) + .await; + let email = first_email(&resp); + let urls = &email["header:List-Unsubscribe:asURLs"]; + check(urls.is_array(), "asURLs must return array")?; + check(count(urls) > 0, "asURLs length must be greater than 0")?; + check_contains(urls[0].as_str().unwrap_or(""), "example.com/unsub", "url") +} + +async fn header_custom_header(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("special-headers"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:X-Custom-Header:asText"] }), + ) + .await; + let email = first_email(&resp); + let value = &email["header:X-Custom-Header:asText"]; + check(value.is_string(), "value must be string")?; + check_contains(value.as_str().unwrap_or(""), "custom-value-12345", "value") +} + +async fn header_intl_from_decoded(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("intl-sender"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["from"] })).await; + let email = first_email(&resp); + let from = &email["from"]; + check(count(from) > 0, "from length must be greater than 0")?; + check_eq(&from[0]["email"], &json!("kaneshiro@example.com"), "email")?; + if let Some(name) = from[0]["name"].as_str() { + check(!name.is_empty(), "Decoded name should not be empty")?; + } + Ok(()) +} + +async fn header_as_grouped_addresses(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:From:asGroupedAddresses"] }), + ) + .await; + let email = first_email(&resp); + let groups = &email["header:From:asGroupedAddresses"]; + check(groups.is_array(), "asGroupedAddresses must return array")?; + check(count(groups) > 0, "groups length must be greater than 0")?; + check( + groups[0]["addresses"].is_array(), + "Each group must have addresses array", + ) +} + +async fn header_raw_form(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ "ids": [email_id], "properties": ["header:Subject"] }), + ) + .await; + let email = first_email(&resp); + let raw = &email["header:Subject"]; + check(raw.is_string(), "raw must be string")?; + check_contains( + raw.as_str().unwrap_or(""), + "Meeting tomorrow morning", + "raw", + ) +} + +async fn header_case_insensitive(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let resp = email_get( + ctx, + json!({ + "ids": [email_id], + "properties": ["header:subject:asText", "header:SUBJECT:asText"] + }), + ) + .await; + let email = first_email(&resp); + let lower = email["header:subject:asText"].as_str().unwrap_or(""); + let upper = email["header:SUBJECT:asText"].as_str().unwrap_or(""); + check( + !lower.is_empty() || !upper.is_empty(), + "At least one form should return a value", + ) +} + +async fn header_bcc(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("bcc-email"); + let resp = email_get(ctx, json!({ "ids": [email_id], "properties": ["bcc"] })).await; + let email = first_email(&resp); + if let Some(arr) = email["bcc"].as_array() + && !arr.is_empty() + { + check_eq(&arr[0]["email"], &json!("secret@example.com"), "email")?; + } + Ok(()) +} + +async fn parse_valid_message(ctx: &CompCtx<'_>) -> TestOutcome { + let message = [ + "From: Parser Test ", + "To: testuser@example.com", + "Subject: Parse test message", + "Date: Mon, 01 Jan 2026 12:00:00 +0000", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + "This is a message to be parsed.", + ] + .join("\r\n"); + let blob_id = upload_message(ctx, "message/rfc5322", &message).await; + let resp = email_parse( + ctx, + json!({ + "blobIds": [blob_id], + "properties": ["subject", "from", "to", "textBody", "bodyValues"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = &resp.method_response()["parsed"][&blob_id]; + check(!email.is_null(), "parsed entry must be present")?; + check_eq(&email["subject"], &json!("Parse test message"), "subject")?; + check_eq( + &email["from"][0]["email"], + &json!("parse@example.com"), + "from email", + ) +} + +async fn parse_null_metadata(ctx: &CompCtx<'_>) -> TestOutcome { + let message = [ + "From: meta@example.com", + "To: testuser@example.com", + "Subject: Metadata parse test", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "body", + ] + .join("\r\n"); + let blob_id = upload_message(ctx, "message/rfc5322", &message).await; + let resp = email_parse( + ctx, + json!({ + "blobIds": [blob_id], + "properties": [ + "id", "blobId", "threadId", "mailboxIds", "keywords", + "receivedAt", "subject" + ] + }), + ) + .await; + let email = &resp.method_response()["parsed"][&blob_id]; + check(email["id"].is_null(), "id must be null")?; + check(email["threadId"].is_null(), "threadId must be null")?; + check(email["mailboxIds"].is_null(), "mailboxIds must be null")?; + check(email["keywords"].is_null(), "keywords must be null")?; + check(email["receivedAt"].is_null(), "receivedAt must be null")?; + check_eq(&email["subject"], &json!("Metadata parse test"), "subject") +} + +async fn parse_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_parse(ctx, json!({ "blobIds": ["nonexistent-blob-xyz"] })).await; + let not_found = &resp.method_response()["notFound"]; + check( + not_found.is_array(), + "Expected notFound to contain nonexistent-blob-xyz, but got null", + )?; + check( + not_found + .as_array() + .map(|a| a.iter().any(|v| v.as_str() == Some("nonexistent-blob-xyz"))) + .unwrap_or(false), + "notFound must include nonexistent-blob-xyz", + ) +} + +async fn parse_not_parsable(ctx: &CompCtx<'_>) -> TestOutcome { + let blob_id = upload_message( + ctx, + "application/octet-stream", + "this is not an email at all, just random text", + ) + .await; + let resp = email_parse(ctx, json!({ "blobIds": [blob_id] })).await; + let not_parsable = &resp.method_response()["notParsable"]; + check( + count(not_parsable) > 0, + "Server MUST return notParsable for non-email blob", + )?; + check( + not_parsable + .as_array() + .map(|a| a.iter().any(|v| v.as_str() == Some(blob_id.as_str()))) + .unwrap_or(false), + "notParsable must include blob id", + ) +} + +async fn parse_body_values(ctx: &CompCtx<'_>) -> TestOutcome { + let message = [ + "From: bv@example.com", + "To: testuser@example.com", + "Subject: Body values parse", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "The body content for parsing.", + ] + .join("\r\n"); + let blob_id = upload_message(ctx, "message/rfc5322", &message).await; + let resp = email_parse( + ctx, + json!({ + "blobIds": [blob_id], + "properties": ["textBody", "bodyValues"], + "bodyProperties": ["partId"], + "fetchTextBodyValues": true + }), + ) + .await; + let email = &resp.method_response()["parsed"][&blob_id]; + let map = email["bodyValues"] + .as_object() + .unwrap_or_else(|| panic!("bodyValues missing: {email}")); + check(!map.is_empty(), "bodyValues must have keys")?; + let first = map.values().next().unwrap(); + check_contains( + first["value"].as_str().unwrap_or(""), + "body content for parsing", + "value", + ) +} + +async fn parse_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let blob_id = upload_message( + ctx, + "message/rfc5322", + "From: x@example.com\r\nTo: y@example.com\r\nSubject: test\r\n\r\nbody", + ) + .await; + let resp = email_parse(ctx, json!({ "blobIds": [blob_id] })).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check( + !r["parsed"].is_null() || !r["notParsable"].is_null(), + "Must have parsed or notParsable", + ) +} diff --git a/tests/src/jmap/compliance/email/mod.rs b/tests/src/jmap/compliance/email/mod.rs new file mode 100644 index 0000000..cc845c0 --- /dev/null +++ b/tests/src/jmap/compliance/email/mod.rs @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::compliance::CompCtx; + +pub mod inspect; +pub mod mutate; +pub mod query; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] email"); + inspect::run(ctx).await; + mutate::run(ctx).await; + query::run(ctx).await; +} diff --git a/tests/src/jmap/compliance/email/mutate.rs b/tests/src/jmap/compliance/email/mutate.rs new file mode 100644 index 0000000..9183453 --- /dev/null +++ b/tests/src/jmap/compliance/email/mutate.rs @@ -0,0 +1,1671 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::compliance::{CompCtx, TestOutcome, check, check_eq, check_ne, skip}; +use crate::utils::jmap::{ChangeType, JmapResponse, JmapUtils}; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + ctx.run("email/set-create-plain-text", set_create_plain_text(ctx)) + .await; + ctx.run("email/set-create-html", set_create_html(ctx)).await; + ctx.run( + "email/set-create-multipart-alternative", + set_create_multipart_alternative(ctx), + ) + .await; + ctx.run( + "email/set-create-with-keywords", + set_create_with_keywords(ctx), + ) + .await; + ctx.run( + "email/set-create-server-set-properties", + set_create_server_set_properties(ctx), + ) + .await; + ctx.run( + "email/set-create-with-attachment", + set_create_with_attachment(ctx), + ) + .await; + ctx.run( + "email/set-create-state-changes", + set_create_state_changes(ctx), + ) + .await; + ctx.run( + "email/set-create-creation-id-reference", + set_create_creation_id_reference(ctx), + ) + .await; + + ctx.run("email/set-update-add-keyword", set_update_add_keyword(ctx)) + .await; + ctx.run( + "email/set-update-remove-keyword", + set_update_remove_keyword(ctx), + ) + .await; + ctx.run( + "email/set-update-replace-keywords", + set_update_replace_keywords(ctx), + ) + .await; + ctx.run( + "email/set-update-move-mailbox", + set_update_move_mailbox(ctx), + ) + .await; + ctx.run("email/set-update-add-mailbox", set_update_add_mailbox(ctx)) + .await; + ctx.run( + "email/set-update-remove-mailbox", + set_update_remove_mailbox(ctx), + ) + .await; + ctx.run("email/set-update-if-in-state", set_update_if_in_state(ctx)) + .await; + ctx.run("email/set-update-not-found", set_update_not_found(ctx)) + .await; + + ctx.run("email/set-destroy-single", set_destroy_single(ctx)) + .await; + ctx.run("email/set-destroy-multiple", set_destroy_multiple(ctx)) + .await; + ctx.run("email/set-destroy-not-found", set_destroy_not_found(ctx)) + .await; + ctx.run( + "email/set-destroy-removes-from-all-mailboxes", + set_destroy_removes_from_all_mailboxes(ctx), + ) + .await; + + ctx.run("email/import-valid-message", import_valid_message(ctx)) + .await; + ctx.run("email/import-sets-mailbox", import_sets_mailbox(ctx)) + .await; + ctx.run("email/import-sets-keywords", import_sets_keywords(ctx)) + .await; + ctx.run( + "email/import-sets-received-at", + import_sets_received_at(ctx), + ) + .await; + ctx.run("email/import-invalid-blob", import_invalid_blob(ctx)) + .await; + ctx.run("email/import-not-found-blob", import_not_found_blob(ctx)) + .await; + ctx.run("email/import-multiple", import_multiple(ctx)).await; + ctx.run("email/import-state-changes", import_state_changes(ctx)) + .await; + + ctx.run( + "email/copy-same-account-error", + copy_same_account_error(ctx), + ) + .await; + ctx.run("email/copy-cross-account", copy_cross_account(ctx)) + .await; + ctx.run("email/copy-not-found", copy_not_found(ctx)).await; + + ctx.run("email/changes-no-changes", changes_no_changes(ctx)) + .await; + ctx.run( + "email/changes-after-keyword-change", + changes_after_keyword_change(ctx), + ) + .await; + ctx.run( + "email/changes-response-structure", + changes_response_structure(ctx), + ) + .await; + ctx.run( + "email/changes-after-create-and-destroy", + changes_after_create_and_destroy(ctx), + ) + .await; + + ctx.run( + "email/query-changes-no-changes", + query_changes_no_changes(ctx), + ) + .await; + ctx.run( + "email/query-changes-after-add", + query_changes_after_add(ctx), + ) + .await; + ctx.run( + "email/query-changes-after-remove", + query_changes_after_remove(ctx), + ) + .await; + ctx.run( + "email/query-changes-filter-null-accepted", + query_changes_filter_null_accepted(ctx), + ) + .await; + ctx.run( + "email/query-changes-response-structure", + query_changes_response_structure(ctx), + ) + .await; +} + +async fn destroy_email(ctx: &CompCtx<'_>, id: &str) { + ctx.primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": [id] }), + ) + .await; +} + +async fn email_get_property(ctx: &CompCtx<'_>, id: &str, property: &str) -> Value { + let resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ + "accountId": ctx.account_id(), + "ids": [id], + "properties": [property] + }), + ) + .await; + resp.list()[0][property].clone() +} + +fn plain_create(ctx: &CompCtx<'_>, subject: &str) -> Value { + json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": subject, + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + }) +} + +async fn set_create_plain_text(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test User", "email": "test@example.com" }], + "to": [{ "name": "Recipient", "email": "recipient@example.com" }], + "subject": "Plain text creation test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "This is a plain text body created via Email/set." } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let created = resp.created(0); + let outcome = check(!created["id"].is_null(), "id must be set") + .and(check(!created["blobId"].is_null(), "blobId must be set")) + .and(check( + !created["threadId"].is_null(), + "threadId must be set", + )) + .and(check(created["size"].is_number(), "size must be a number")); + let id = created.id().to_string(); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_html(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "Recipient", "email": "recipient@example.com" }], + "subject": "HTML creation test", + "bodyStructure": { "type": "text/html", "partId": "1" }, + "bodyValues": { "1": { "value": "

Hello

HTML body.

" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + let outcome = check(!id.is_empty(), "htmlDraft must be created"); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_multipart_alternative(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "Recipient", "email": "recipient@example.com" }], + "subject": "Multipart alternative test", + "bodyStructure": { + "type": "multipart/alternative", + "subParts": [ + { "type": "text/plain", "partId": "text" }, + { "type": "text/html", "partId": "html" } + ] + }, + "bodyValues": { + "text": { "value": "Plain text version" }, + "html": { "value": "

HTML version

" } + } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + + let get_resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ + "accountId": ctx.account_id(), + "ids": [id], + "properties": ["textBody", "htmlBody"], + "bodyProperties": ["type"] + }), + ) + .await; + let email = &get_resp.list()[0]; + let text_len = email["textBody"].as_array().map(|a| a.len()).unwrap_or(0); + let html_len = email["htmlBody"].as_array().map(|a| a.len()).unwrap_or(0); + let outcome = check(text_len > 0, "textBody must have parts") + .and(check(html_len > 0, "htmlBody must have parts")); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_with_keywords(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "keywords": { "$draft": true, "$seen": true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Keywords test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + let keywords = email_get_property(ctx, &id, "keywords").await; + let outcome = check_eq(&keywords["$draft"], &json!(true), "$draft").and(check_eq( + &keywords["$seen"], + &json!(true), + "$seen", + )); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_server_set_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Server-set props", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let created = resp.created(0); + let outcome = check(!created["id"].is_null(), "id must be server-set") + .and(check( + !created["blobId"].is_null(), + "blobId must be server-set", + )) + .and(check( + !created["threadId"].is_null(), + "threadId must be server-set", + )) + .and(check(created["size"].is_number(), "size must be a number")); + let id = created.id().to_string(); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_with_attachment(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "With attachment", + "bodyStructure": { + "type": "multipart/mixed", + "subParts": [ + { "type": "text/plain", "partId": "text" }, + { + "type": "application/pdf", + "blobId": ctx.blob("pdf"), + "name": "test.pdf", + "disposition": "attachment" + } + ] + }, + "bodyValues": { "text": { "value": "See attached PDF." } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + + let get_resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ + "accountId": ctx.account_id(), + "ids": [id], + "properties": ["hasAttachment", "attachments"], + "bodyProperties": ["type", "name"] + }), + ) + .await; + let email = &get_resp.list()[0]; + let outcome = check_eq(&email["hasAttachment"], &json!(true), "hasAttachment"); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_state_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Email", + [plain_create(ctx, "State change test")], + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + let old_state = r["oldState"].clone(); + let new_state = r["newState"].clone(); + let outcome = check(!old_state.is_null(), "oldState must be set") + .and(check(!new_state.is_null(), "newState must be set")) + .and(check_ne(old_state, new_state, "oldState != newState")); + let id = resp.created(0).id().to_string(); + destroy_email(ctx, &id).await; + outcome +} + +async fn set_create_creation_id_reference(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_calls(json!([ + [ + "Mailbox/set", + { + "accountId": ctx.account_id(), + "create": { "newMb": { "name": "Creation Ref Test", "parentId": null } } + }, + "mb" + ], + [ + "Email/set", + { + "accountId": ctx.account_id(), + "create": { + "refEmail": { + "mailboxIds": { "#newMb": true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Creation ref test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + } + } + }, + "em" + ] + ])) + .await; + + let mb_id = resp + .response_at(0) + .pointer("/created/newMb/id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let em_id = resp + .response_at(1) + .pointer("/created/refEmail/id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let outcome = check(mb_id.is_some(), "Mailbox should be created") + .and(check(em_id.is_some(), "Email should be created")); + + if let Some(id) = &em_id { + destroy_email(ctx, id).await; + } + if let Some(id) = &mb_id { + ctx.primary + .jmap_method_call( + "Mailbox/set", + json!({ "accountId": ctx.account_id(), "destroy": [id] }), + ) + .await; + } + outcome +} + +async fn set_update_add_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$flagged": true }))], + Vec::<(String, Value)>::new(), + ) + .await; + let keywords = email_get_property(ctx, email_id, "keywords").await; + let outcome = check_eq(&keywords["$flagged"], &json!(true), "$flagged"); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$flagged": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + outcome +} + +async fn set_update_remove_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("custom-keywords"); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$forwarded": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + let keywords = email_get_property(ctx, email_id, "keywords").await; + let outcome = check( + keywords["$forwarded"].is_null() || keywords["$forwarded"] == json!(false), + "$forwarded must be removed", + ); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$forwarded": true }))], + Vec::<(String, Value)>::new(), + ) + .await; + outcome +} + +async fn set_update_replace_keywords(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + ctx.primary + .jmap_update( + "Email", + [( + email_id, + json!({ "keywords": { "$seen": true, "$flagged": true } }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + let keywords = email_get_property(ctx, email_id, "keywords").await; + let outcome = check_eq(&keywords["$seen"], &json!(true), "$seen").and(check_eq( + &keywords["$flagged"], + &json!(true), + "$flagged", + )); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords": { "$seen": true } }))], + Vec::<(String, Value)>::new(), + ) + .await; + outcome +} + +async fn set_update_move_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [plain_create(ctx, "Move test")], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_resp.created(0).id().to_string(); + + ctx.primary + .jmap_update( + "Email", + [( + email_id.as_str(), + json!({ "mailboxIds": { ctx.mailbox("folderA"): true } }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let mailbox_ids = email_get_property(ctx, &email_id, "mailboxIds").await; + let outcome = check_eq( + &mailbox_ids[ctx.mailbox("folderA")], + &json!(true), + "must be in folderA", + ) + .and(check( + mailbox_ids[ctx.role("inbox")].is_null(), + "must not be in inbox", + )); + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_update_add_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [plain_create(ctx, "Add mailbox test")], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_resp.created(0).id().to_string(); + + ctx.primary + .jmap_update( + "Email", + [( + email_id.as_str(), + json!({ format!("mailboxIds/{}", ctx.mailbox("folderB")): true }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let mailbox_ids = email_get_property(ctx, &email_id, "mailboxIds").await; + let outcome = check_eq( + &mailbox_ids[ctx.role("inbox")], + &json!(true), + "must be in inbox", + ) + .and(check_eq( + &mailbox_ids[ctx.mailbox("folderB")], + &json!(true), + "must be in folderB", + )); + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_update_remove_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true, ctx.mailbox("folderA"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Remove mailbox test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_resp.created(0).id().to_string(); + + ctx.primary + .jmap_update( + "Email", + [( + email_id.as_str(), + json!({ format!("mailboxIds/{}", ctx.mailbox("folderA")): null }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let mailbox_ids = email_get_property(ctx, &email_id, "mailboxIds").await; + let outcome = check_eq( + &mailbox_ids[ctx.role("inbox")], + &json!(true), + "must be in inbox", + ) + .and(check( + mailbox_ids[ctx.mailbox("folderA")].is_null(), + "must not be in folderA", + )); + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_update_if_in_state(ctx: &CompCtx<'_>) -> TestOutcome { + let get_resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ "accountId": ctx.account_id(), "ids": [] }), + ) + .await; + let state = get_resp.state().to_string(); + + let email_id = ctx.email("plain-simple"); + let resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ + "accountId": ctx.account_id(), + "ifInState": state, + "update": { email_id: { "keywords/$flagged": true } } + }), + ) + .await; + let outcome = check( + !resp.method_response()["newState"].is_null(), + "newState must be set", + ); + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$flagged": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + outcome +} + +async fn set_update_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_update( + "Email", + [("nonexistent-email-xyz", json!({ "keywords/$seen": true }))], + Vec::<(String, Value)>::new(), + ) + .await; + let not_updated = resp.not_updated("nonexistent-email-xyz"); + check_eq(¬_updated["type"], &json!("notFound"), "type") +} + +async fn set_destroy_single(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [plain_create(ctx, "Destroy me")], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_resp.created(0).id().to_string(); + + let destroy_resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": [email_id] }), + ) + .await; + check( + destroy_resp.method_response()["destroyed"].is_array(), + "destroyed must be an array", + )?; + let destroyed: Vec<&str> = destroy_resp.destroyed().collect(); + check( + destroyed.contains(&email_id.as_str()), + "destroyed must include the email", + )?; + + let get_resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ "accountId": ctx.account_id(), "ids": [email_id] }), + ) + .await; + check( + get_resp.method_response()["notFound"].is_array(), + "notFound must be an array", + )?; + let not_found: Vec<&str> = get_resp.not_found().collect(); + check( + not_found.contains(&email_id.as_str()), + "notFound must include the email", + ) +} + +async fn set_destroy_multiple(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [ + plain_create(ctx, "Destroy batch 1"), + plain_create(ctx, "Destroy batch 2"), + ], + Vec::<(String, Value)>::new(), + ) + .await; + let id1 = create_resp.created(0).id().to_string(); + let id2 = create_resp.created(1).id().to_string(); + + let destroy_resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": [id1, id2] }), + ) + .await; + check( + destroy_resp.method_response()["destroyed"].is_array(), + "destroyed must be an array", + )?; + let count = destroy_resp.method_response()["destroyed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + check_eq(count, 2, "destroyed length") +} + +async fn set_destroy_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": ["nonexistent-email-xyz"] }), + ) + .await; + let not_destroyed = resp.not_destroyed("nonexistent-email-xyz"); + check_eq(¬_destroyed["type"], &json!("notFound"), "type") +} + +async fn set_destroy_removes_from_all_mailboxes(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true, ctx.mailbox("folderA"): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Multi-mailbox destroy", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_resp.created(0).id().to_string(); + + destroy_email(ctx, &email_id).await; + + let q1 = ctx + .primary + .jmap_query( + "Email", + [("inMailbox", json!(ctx.role("inbox")))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let q1_ids: Vec<&str> = q1.ids().collect(); + check( + !q1_ids.contains(&email_id.as_str()), + "must not be in inbox query", + )?; + + let q2 = ctx + .primary + .jmap_query( + "Email", + [("inMailbox", json!(ctx.mailbox("folderA")))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let q2_ids: Vec<&str> = q2.ids().collect(); + check( + !q2_ids.contains(&email_id.as_str()), + "must not be in folderA query", + ) +} + +fn rfc5322(lines: &[&str]) -> Vec { + lines.join("\r\n").into_bytes() +} + +async fn import_one(ctx: &CompCtx<'_>, creation_id: &str, email: Value) -> JmapResponse { + ctx.primary + .jmap_method_call( + "Email/import", + json!({ + "accountId": ctx.account_id(), + "emails": { creation_id: email } + }), + ) + .await +} + +async fn import_valid_message(ctx: &CompCtx<'_>) -> TestOutcome { + let message = rfc5322(&[ + "From: import-test@example.com", + "To: testuser@example.com", + "Subject: Import test message", + "Date: Thu, 01 Jan 2026 12:00:00 +0000", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + "This is an imported message.", + ]); + let upload = ctx.upload(ctx.primary, "message/rfc5322", message).await; + + let resp = import_one( + ctx, + "imp1", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.role("inbox"): true }, + "keywords": { "$seen": true }, + "receivedAt": "2026-01-01T12:00:00Z" + }), + ) + .await; + let created = resp + .method_response() + .pointer("/created/imp1") + .cloned() + .unwrap_or(Value::Null); + let mut outcome = check(!created.is_null(), "imp1 must be created") + .and(check(!created["id"].is_null(), "id must be set")) + .and(check(!created["blobId"].is_null(), "blobId must be set")) + .and(check(created["size"].is_number(), "size must be a number")); + let id = created.id().to_string(); + + let subject = email_get_property(ctx, &id, "subject").await; + outcome = outcome.and(check_eq(&subject, &json!("Import test message"), "subject")); + destroy_email(ctx, &id).await; + outcome +} + +async fn import_sets_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let message = rfc5322(&[ + "From: import-mb@example.com", + "To: testuser@example.com", + "Subject: Import mailbox test", + "Date: Thu, 01 Jan 2026 12:00:00 +0000", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Imported to specific mailbox.", + ]); + let upload = ctx.upload(ctx.primary, "message/rfc5322", message).await; + + let resp = import_one( + ctx, + "mbImp", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.mailbox("folderB"): true } + }), + ) + .await; + let created = resp + .method_response() + .pointer("/created/mbImp") + .cloned() + .unwrap_or(Value::Null); + let mut outcome = check(!created.is_null(), "mbImp must be created"); + let id = created.id().to_string(); + + let mailbox_ids = email_get_property(ctx, &id, "mailboxIds").await; + outcome = outcome.and(check_eq( + &mailbox_ids[ctx.mailbox("folderB")], + &json!(true), + "must be in folderB", + )); + destroy_email(ctx, &id).await; + outcome +} + +async fn import_sets_keywords(ctx: &CompCtx<'_>) -> TestOutcome { + let message = rfc5322(&[ + "From: import-kw@example.com", + "To: testuser@example.com", + "Subject: Import keywords test", + "Date: Thu, 01 Jan 2026 12:00:00 +0000", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Keywords test.", + ]); + let upload = ctx.upload(ctx.primary, "message/rfc5322", message).await; + + let resp = import_one( + ctx, + "kwImp", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.role("inbox"): true }, + "keywords": { "$seen": true, "$flagged": true } + }), + ) + .await; + let id = resp + .method_response() + .pointer("/created/kwImp/id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let keywords = email_get_property(ctx, &id, "keywords").await; + let outcome = check_eq(&keywords["$seen"], &json!(true), "$seen").and(check_eq( + &keywords["$flagged"], + &json!(true), + "$flagged", + )); + destroy_email(ctx, &id).await; + outcome +} + +async fn import_sets_received_at(ctx: &CompCtx<'_>) -> TestOutcome { + let message = rfc5322(&[ + "From: import-date@example.com", + "To: testuser@example.com", + "Subject: Import date test", + "Date: Wed, 15 Jan 2025 10:30:00 +0000", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Date test.", + ]); + let upload = ctx.upload(ctx.primary, "message/rfc5322", message).await; + + let received_at = "2025-06-15T10:30:00Z"; + let resp = import_one( + ctx, + "dateImp", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.role("inbox"): true }, + "receivedAt": received_at + }), + ) + .await; + let id = resp + .method_response() + .pointer("/created/dateImp/id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let actual = email_get_property(ctx, &id, "receivedAt").await; + let outcome = check_eq(&actual, &json!("2025-06-15T10:30:00Z"), "receivedAt"); + destroy_email(ctx, &id).await; + outcome +} + +async fn import_invalid_blob(ctx: &CompCtx<'_>) -> TestOutcome { + let upload = ctx + .upload( + ctx.primary, + "application/octet-stream", + b"this is not an email".to_vec(), + ) + .await; + + let resp = import_one( + ctx, + "badImp", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.role("inbox"): true } + }), + ) + .await; + let not_created = resp + .method_response() + .pointer("/notCreated/badImp") + .cloned() + .unwrap_or(Value::Null); + let outcome = check(!not_created.is_null(), "badImp must be in notCreated").and(check( + !not_created["type"].is_null(), + "error must have a type", + )); + + if let Some(id) = resp + .method_response() + .pointer("/created/badImp/id") + .and_then(|v| v.as_str()) + { + destroy_email(ctx, id).await; + } + outcome +} + +async fn import_not_found_blob(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = import_one( + ctx, + "notFound", + json!({ + "blobId": "nonexistent-blob-xyz", + "mailboxIds": { ctx.role("inbox"): true } + }), + ) + .await; + let not_created = resp + .method_response() + .pointer("/notCreated/notFound") + .cloned() + .unwrap_or(Value::Null); + check(!not_created.is_null(), "notFound must be in notCreated")?; + check_eq(¬_created["type"], &json!("invalidProperties"), "type") +} + +async fn import_multiple(ctx: &CompCtx<'_>) -> TestOutcome { + let msg1 = rfc5322(&[ + "From: batch1@example.com", + "To: testuser@example.com", + "Subject: Batch import 1", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Batch 1", + ]); + let msg2 = rfc5322(&[ + "From: batch2@example.com", + "To: testuser@example.com", + "Subject: Batch import 2", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Batch 2", + ]); + let up1 = ctx.upload(ctx.primary, "message/rfc5322", msg1).await; + let up2 = ctx.upload(ctx.primary, "message/rfc5322", msg2).await; + + let resp = ctx + .primary + .jmap_method_call( + "Email/import", + json!({ + "accountId": ctx.account_id(), + "emails": { + "b1": { "blobId": up1.blob_id(), "mailboxIds": { ctx.role("inbox"): true } }, + "b2": { "blobId": up2.blob_id(), "mailboxIds": { ctx.role("inbox"): true } } + } + }), + ) + .await; + let b1 = resp + .method_response() + .pointer("/created/b1") + .cloned() + .unwrap_or(Value::Null); + let b2 = resp + .method_response() + .pointer("/created/b2") + .cloned() + .unwrap_or(Value::Null); + let outcome = + check(!b1.is_null(), "b1 must be created").and(check(!b2.is_null(), "b2 must be created")); + + let mut destroy = Vec::new(); + if let Some(id) = b1["id"].as_str() { + destroy.push(id.to_string()); + } + if let Some(id) = b2["id"].as_str() { + destroy.push(id.to_string()); + } + if !destroy.is_empty() { + ctx.primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": destroy }), + ) + .await; + } + outcome +} + +async fn import_state_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let message = rfc5322(&[ + "From: state@example.com", + "To: testuser@example.com", + "Subject: State test", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "body", + ]); + let upload = ctx.upload(ctx.primary, "message/rfc5322", message).await; + + let resp = import_one( + ctx, + "st", + json!({ + "blobId": upload.blob_id(), + "mailboxIds": { ctx.role("inbox"): true } + }), + ) + .await; + let outcome = check( + !resp.method_response()["newState"].is_null(), + "newState must be set", + ); + + if let Some(id) = resp + .method_response() + .pointer("/created/st/id") + .and_then(|v| v.as_str()) + { + destroy_email(ctx, id).await; + } + outcome +} + +async fn copy_same_account_error(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "Email/copy", + json!({ + "fromAccountId": ctx.account_id(), + "accountId": ctx.account_id(), + "create": { + "x": { + "id": "placeholder", + "mailboxIds": { ctx.role("inbox"): true } + } + } + }), + ) + .await; + if resp.is_error_at(0) { + check_eq( + resp.error_type_at(0).unwrap_or(""), + "invalidArguments", + "Same-account copy must return invalidArguments", + ) + } else { + Ok(()) + } +} + +async fn cross_inbox(ctx: &CompCtx<'_>, cross: &str) -> Option { + let resp = ctx + .primary + .jmap_method_call( + "Mailbox/get", + json!({ "accountId": cross, "ids": null, "properties": ["id", "role"] }), + ) + .await; + resp.0 + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .and_then(|list| list.iter().find(|m| m["role"] == "inbox")) + .map(|m| m.id().to_string()) +} + +async fn copy_cross_account(ctx: &CompCtx<'_>) -> TestOutcome { + let Some(cross) = ctx.cross_account_id.as_deref() else { + return skip("No cross-account access available"); + }; + let src_id = ctx.email("plain-simple"); + let Some(crossbox) = cross_inbox(ctx, cross).await else { + return check(false, "Cross account must have an inbox"); + }; + let resp = ctx + .primary + .jmap_method_call( + "Email/copy", + json!({ + "fromAccountId": ctx.account_id(), + "accountId": cross, + "create": { + "copied": { + "id": src_id, + "mailboxIds": { crossbox: true }, + "keywords": { "$seen": true } + } + } + }), + ) + .await; + let r = resp.method_response(); + let result = check( + !r["created"]["copied"].is_null(), + "copied email must be in created map", + ) + .and(check( + !r["created"]["copied"]["id"].is_null(), + "copied email must have an id", + )); + if let Some(cid) = r["created"]["copied"]["id"].as_str() { + ctx.primary + .jmap_method_call("Email/set", json!({ "accountId": cross, "destroy": [cid] })) + .await; + } + result +} + +async fn copy_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let Some(cross) = ctx.cross_account_id.as_deref() else { + return skip("No cross-account access available"); + }; + let Some(crossbox) = cross_inbox(ctx, cross).await else { + return check(false, "Cross account must have an inbox"); + }; + let resp = ctx + .primary + .jmap_method_call( + "Email/copy", + json!({ + "fromAccountId": ctx.account_id(), + "accountId": cross, + "create": { + "bad": { + "id": "nonexistent-email-xyz", + "mailboxIds": { crossbox: true } + } + } + }), + ) + .await; + check( + !resp.method_response()["notCreated"]["bad"].is_null(), + "notCreated must contain the bad entry", + ) +} + +async fn email_state(ctx: &CompCtx<'_>) -> String { + let resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ "accountId": ctx.account_id(), "ids": [] }), + ) + .await; + resp.state().to_string() +} + +async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let state = email_state(ctx).await; + let resp = ctx.primary.jmap_changes("Email", &state).await; + let r = resp.method_response(); + check_eq(&r["oldState"], &json!(state), "oldState")?; + check_eq( + r["created"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "created length", + )?; + check_eq( + r["updated"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "updated length", + )?; + check_eq( + r["destroyed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "destroyed length", + ) +} + +async fn changes_after_keyword_change(ctx: &CompCtx<'_>) -> TestOutcome { + let old_state = email_state(ctx).await; + let email_id = ctx.email("plain-simple"); + + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$flagged": true }))], + Vec::<(String, Value)>::new(), + ) + .await; + + let changes = ctx.primary.jmap_changes("Email", &old_state).await; + let updated: Vec<&str> = changes + .changes() + .filter_map(|c| match c { + ChangeType::Updated(id) => Some(id), + _ => None, + }) + .collect(); + let outcome = check( + updated.contains(&email_id), + "updated must include the email", + ); + + ctx.primary + .jmap_update( + "Email", + [(email_id, json!({ "keywords/$flagged": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + outcome +} + +async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let state = email_state(ctx).await; + let resp = ctx.primary.jmap_changes("Email", &state).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["oldState"].is_string(), "oldState must be string")?; + check(r["newState"].is_string(), "newState must be string")?; + check( + r["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + )?; + check(r["created"].is_array(), "created must be array")?; + check(r["updated"].is_array(), "updated must be array")?; + check(r["destroyed"].is_array(), "destroyed must be array") +} + +async fn changes_after_create_and_destroy(ctx: &CompCtx<'_>) -> TestOutcome { + let old_state = email_state(ctx).await; + + let create_resp = ctx + .primary + .jmap_create( + "Email", + [plain_create(ctx, "Changes test email")], + Vec::<(String, Value)>::new(), + ) + .await; + let temp_id = create_resp.created(0).id().to_string(); + let mid_state = create_resp.method_response()["newState"] + .as_str() + .unwrap_or("") + .to_string(); + + let changes1 = ctx.primary.jmap_changes("Email", &old_state).await; + let created1: Vec<&str> = changes1 + .changes() + .filter_map(|c| match c { + ChangeType::Created(id) => Some(id), + _ => None, + }) + .collect(); + let r1 = check( + created1.contains(&temp_id.as_str()), + "created must include the temp email", + ); + + destroy_email(ctx, &temp_id).await; + + let changes2 = ctx.primary.jmap_changes("Email", &mid_state).await; + let destroyed2: Vec<&str> = changes2 + .changes() + .filter_map(|c| match c { + ChangeType::Destroyed(id) => Some(id), + _ => None, + }) + .collect(); + let r2 = check( + destroyed2.contains(&temp_id.as_str()), + "destroyed must include the temp email", + ); + r1.and(r2) +} + +async fn email_query_state(ctx: &CompCtx<'_>, filter: Value) -> String { + let resp = ctx + .primary + .jmap_method_call( + "Email/query", + json!({ + "accountId": ctx.account_id(), + "filter": filter, + "sort": [{ "property": "receivedAt", "isAscending": false }] + }), + ) + .await; + resp.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string() +} + +async fn query_changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let filter = json!({ "inMailbox": ctx.role("inbox") }); + let query_state = email_query_state(ctx, filter.clone()).await; + + let resp = ctx + .primary + .jmap_method_call( + "Email/queryChanges", + json!({ + "accountId": ctx.account_id(), + "filter": filter, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "sinceQueryState": query_state + }), + ) + .await; + let r = resp.method_response(); + check_eq(&r["oldQueryState"], &json!(query_state), "oldQueryState")?; + check_eq( + r["removed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "removed length", + )?; + check_eq( + r["added"].as_array().map(|a| a.len()).unwrap_or(usize::MAX), + 0, + "added length", + ) +} + +async fn query_changes_after_add(ctx: &CompCtx<'_>) -> TestOutcome { + let filter = json!({ "inMailbox": ctx.role("inbox") }); + let old_query_state = email_query_state(ctx, filter.clone()).await; + + let create_resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "QC Test", "email": "qc@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "QueryChanges test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "test" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let new_id = create_resp.created(0).id().to_string(); + + let changes = ctx + .primary + .jmap_method_call( + "Email/queryChanges", + json!({ + "accountId": ctx.account_id(), + "filter": filter, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "sinceQueryState": old_query_state + }), + ) + .await; + let added_ids: Vec<&str> = changes.method_response()["added"] + .as_array() + .map(|a| a.iter().filter_map(|x| x["id"].as_str()).collect()) + .unwrap_or_default(); + let outcome = check( + added_ids.contains(&new_id.as_str()), + "added must include the new email", + ); + + destroy_email(ctx, &new_id).await; + outcome +} + +async fn query_changes_after_remove(ctx: &CompCtx<'_>) -> TestOutcome { + let filter = json!({ "inMailbox": ctx.role("inbox") }); + + let create_resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { ctx.role("inbox"): true }, + "from": [{ "name": "RM Test", "email": "rm@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Will be removed", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "test" } } + })], + Vec::<(String, Value)>::new(), + ) + .await; + let rm_id = create_resp.created(0).id().to_string(); + + let old_query_state = email_query_state(ctx, filter.clone()).await; + + destroy_email(ctx, &rm_id).await; + + let changes = ctx + .primary + .jmap_method_call( + "Email/queryChanges", + json!({ + "accountId": ctx.account_id(), + "filter": filter, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "sinceQueryState": old_query_state + }), + ) + .await; + let removed: Vec<&str> = changes.method_response()["removed"] + .as_array() + .map(|a| a.iter().filter_map(|x| x.as_str()).collect()) + .unwrap_or_default(); + check( + removed.contains(&rm_id.as_str()), + "removed must include the removed email", + ) +} + +async fn query_changes_filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_method_call( + "Email/query", + json!({ "accountId": ctx.account_id(), "filter": null }), + ) + .await; + let query_state = query.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let resp = ctx + .primary + .jmap_method_call( + "Email/queryChanges", + json!({ + "accountId": ctx.account_id(), + "filter": null, + "sinceQueryState": query_state + }), + ) + .await; + let r = resp.method_response(); + check( + r["oldQueryState"].is_string(), + "oldQueryState must be string", + )?; + check( + r["newQueryState"].is_string(), + "newQueryState must be string", + )?; + check(r["removed"].is_array(), "removed must be array")?; + check(r["added"].is_array(), "added must be array") +} + +async fn query_changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_method_call( + "Email/query", + json!({ "accountId": ctx.account_id(), "filter": {} }), + ) + .await; + let query_state = query.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let resp = ctx + .primary + .jmap_method_call( + "Email/queryChanges", + json!({ + "accountId": ctx.account_id(), + "filter": {}, + "sinceQueryState": query_state + }), + ) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check( + r["oldQueryState"].is_string(), + "oldQueryState must be string", + )?; + check( + r["newQueryState"].is_string(), + "newQueryState must be string", + )?; + check(r["removed"].is_array(), "removed must be array")?; + check(r["added"].is_array(), "added must be array") +} diff --git a/tests/src/jmap/compliance/email/query.rs b/tests/src/jmap/compliance/email/query.rs new file mode 100644 index 0000000..09aa9b3 --- /dev/null +++ b/tests/src/jmap/compliance/email/query.rs @@ -0,0 +1,1293 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::compliance::{CompCtx, TestOutcome, check, check_eq}; +use crate::utils::jmap::JmapResponse; +use chrono::{Duration as ChronoDuration, Utc}; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + ctx.run("email/filter-in-mailbox", filter_in_mailbox(ctx)) + .await; + ctx.run( + "email/filter-in-mailbox-other-than", + filter_in_mailbox_other_than(ctx), + ) + .await; + ctx.run("email/filter-before", filter_before(ctx)).await; + ctx.run("email/filter-after", filter_after(ctx)).await; + ctx.run("email/filter-min-size", filter_min_size(ctx)).await; + ctx.run("email/filter-max-size", filter_max_size(ctx)).await; + ctx.run("email/filter-has-keyword", filter_has_keyword(ctx)) + .await; + ctx.run("email/filter-not-keyword", filter_not_keyword(ctx)) + .await; + ctx.run( + "email/filter-has-attachment-true", + filter_has_attachment_true(ctx), + ) + .await; + ctx.run( + "email/filter-has-attachment-false", + filter_has_attachment_false(ctx), + ) + .await; + ctx.run( + "email/filter-text-search-headers", + filter_text_search_headers(ctx), + ) + .await; + ctx.run( + "email/filter-text-search-body", + filter_text_search_body(ctx), + ) + .await; + ctx.run("email/filter-from", filter_from(ctx)).await; + ctx.run("email/filter-to", filter_to(ctx)).await; + ctx.run("email/filter-cc", filter_cc(ctx)).await; + ctx.run("email/filter-subject", filter_subject(ctx)).await; + ctx.run("email/filter-body", filter_body(ctx)).await; + ctx.run( + "email/filter-header-name-only", + filter_header_name_only(ctx), + ) + .await; + ctx.run( + "email/filter-header-name-value", + filter_header_name_value(ctx), + ) + .await; + ctx.run( + "email/filter-some-in-thread-have-keyword", + filter_some_in_thread_have_keyword(ctx), + ) + .await; + ctx.run( + "email/filter-none-in-thread-have-keyword", + filter_none_in_thread_have_keyword(ctx), + ) + .await; + ctx.run("email/filter-operator-and", filter_operator_and(ctx)) + .await; + ctx.run("email/filter-operator-or", filter_operator_or(ctx)) + .await; + ctx.run("email/filter-operator-not", filter_operator_not(ctx)) + .await; + ctx.run( + "email/filter-nested-operators", + filter_nested_operators(ctx), + ) + .await; + ctx.run( + "email/filter-empty-matches-all", + filter_empty_matches_all(ctx), + ) + .await; + ctx.run( + "email/filter-multiple-conditions-on-one-filter", + filter_multiple_conditions_on_one_filter(ctx), + ) + .await; + ctx.run("email/filter-custom-keyword", filter_custom_keyword(ctx)) + .await; + ctx.run( + "email/filter-in-child-mailbox", + filter_in_child_mailbox(ctx), + ) + .await; + ctx.run( + "email/filter-before-and-after", + filter_before_and_after(ctx), + ) + .await; + ctx.run("email/filter-null-accepted", filter_null_accepted(ctx)) + .await; + ctx.run( + "email/filter-from-display-name", + filter_from_display_name(ctx), + ) + .await; + + ctx.run("email/sort-received-at-desc", sort_received_at_desc(ctx)) + .await; + ctx.run("email/sort-received-at-asc", sort_received_at_asc(ctx)) + .await; + ctx.run("email/sort-size", sort_size(ctx)).await; + ctx.run("email/sort-from", sort_from(ctx)).await; + ctx.run("email/sort-to", sort_to(ctx)).await; + ctx.run("email/sort-subject", sort_subject(ctx)).await; + ctx.run("email/sort-sent-at", sort_sent_at(ctx)).await; + ctx.run("email/sort-has-keyword", sort_has_keyword(ctx)) + .await; + ctx.run("email/sort-multi-property", sort_multi_property(ctx)) + .await; + ctx.run("email/sort-default-no-sort", sort_default_no_sort(ctx)) + .await; + + ctx.run("email/paging-position-zero", paging_position_zero(ctx)) + .await; + ctx.run( + "email/paging-positive-position", + paging_positive_position(ctx), + ) + .await; + ctx.run( + "email/paging-negative-position", + paging_negative_position(ctx), + ) + .await; + ctx.run("email/paging-limit", paging_limit(ctx)).await; + ctx.run("email/paging-anchor", paging_anchor(ctx)).await; + ctx.run("email/paging-anchor-offset", paging_anchor_offset(ctx)) + .await; + ctx.run("email/paging-calculate-total", paging_calculate_total(ctx)) + .await; + ctx.run( + "email/paging-anchor-not-found", + paging_anchor_not_found(ctx), + ) + .await; + ctx.run( + "email/paging-position-beyond-total", + paging_position_beyond_total(ctx), + ) + .await; + ctx.run( + "email/paging-response-position", + paging_response_position(ctx), + ) + .await; + + ctx.run("email/collapse-threads-basic", collapse_threads_basic(ctx)) + .await; + ctx.run( + "email/collapse-threads-one-per-thread", + collapse_threads_one_per_thread(ctx), + ) + .await; + ctx.run( + "email/collapse-threads-with-filter", + collapse_threads_with_filter(ctx), + ) + .await; + ctx.run( + "email/collapse-threads-sort-determines-representative", + collapse_threads_sort_determines_representative(ctx), + ) + .await; + ctx.run( + "email/collapse-threads-calculate-total", + collapse_threads_calculate_total(ctx), + ) + .await; +} + +async fn email_query(ctx: &CompCtx<'_>, body: Value) -> JmapResponse { + let mut obj = body; + if let Value::Object(map) = &mut obj { + map.insert("accountId".to_string(), json!(ctx.account_id())); + } + ctx.primary.jmap_method_call("Email/query", obj).await +} + +fn query_ids(resp: &JmapResponse) -> Vec { + resp.method_response()["ids"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() +} + +fn has_id(ids: &[String], id: &str) -> bool { + ids.iter().any(|i| i == id) +} + +async fn get_emails(ctx: &CompCtx<'_>, ids: &[String], properties: Value) -> Vec { + let resp = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ + "accountId": ctx.account_id(), + "ids": ids, + "properties": properties, + }), + ) + .await; + resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default() +} + +fn find_email<'a>(list: &'a [Value], id: &str) -> Option<&'a Value> { + list.iter().find(|e| e["id"].as_str() == Some(id)) +} + +fn display_value(email: &Value, field: &str) -> String { + let addr = &email[field][0]; + if let Some(name) = addr["name"].as_str() + && !name.is_empty() + { + return name.to_string(); + } + addr["email"].as_str().unwrap_or("").to_string() +} + +async fn filter_in_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "inMailbox": ctx.role("inbox") }, "calculateTotal": true }), + ) + .await; + let ids = query_ids(&resp); + check(!ids.is_empty(), "expected at least one email")?; + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should be in inbox", + )?; + check( + !has_id(&ids, ctx.email("very-old")), + "very-old should not be in inbox", + ) +} + +async fn filter_in_mailbox_other_than(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "inMailboxOtherThan": [ctx.role("inbox")] } }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("very-old")), + "very-old should appear", + ) +} + +async fn filter_before(ctx: &CompCtx<'_>) -> TestOutcome { + let five_days_ago = (Utc::now() - ChronoDuration::days(5)).to_rfc3339(); + let resp = email_query(ctx, json!({ "filter": { "before": five_days_ago } })).await; + let ids = query_ids(&resp); + check(has_id(&ids, ctx.email("very-old")), "very-old should match")?; + check( + !has_id(&ids, ctx.email("custom-keywords")), + "custom-keywords should not match", + ) +} + +async fn filter_after(ctx: &CompCtx<'_>) -> TestOutcome { + let five_days_ago = (Utc::now() - ChronoDuration::days(5)).to_rfc3339(); + let resp = email_query(ctx, json!({ "filter": { "after": five_days_ago } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("custom-keywords")), + "custom-keywords should match", + )?; + check( + !has_id(&ids, ctx.email("very-old")), + "very-old should not match", + ) +} + +async fn filter_min_size(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "minSize": 10000 } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("large-email")), + "large-email should match", + )?; + check( + !has_id(&ids, ctx.email("plain-simple")), + "plain-simple should not match", + ) +} + +async fn filter_max_size(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "maxSize": 1000 } })).await; + let ids = query_ids(&resp); + check( + !has_id(&ids, ctx.email("large-email")), + "large-email should not match", + ) +} + +async fn filter_has_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "hasKeyword": "$flagged" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + )?; + check( + !has_id(&ids, ctx.email("plain-simple")), + "plain-simple should not match", + ) +} + +async fn filter_not_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "notKeyword": "$seen" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should match", + )?; + check( + !has_id(&ids, ctx.email("plain-simple")), + "plain-simple should not match", + ) +} + +async fn filter_has_attachment_true(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "hasAttachment": true } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment (has PDF attachment) should be in hasAttachment=true results", + )?; + let list = get_emails(ctx, &ids, json!(["subject", "from", "hasAttachment"])).await; + for email in &list { + check( + email["hasAttachment"].as_bool().unwrap_or(false), + format!( + "hasAttachment=true query returned email without attachment: {}", + email["subject"].as_str().unwrap_or("") + ), + )?; + } + Ok(()) +} + +async fn filter_has_attachment_false(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "hasAttachment": false } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple (no attachment) should be in hasAttachment=false results", + )?; + let list = get_emails(ctx, &ids, json!(["subject", "from", "hasAttachment"])).await; + for email in &list { + check( + !email["hasAttachment"].as_bool().unwrap_or(false), + format!( + "hasAttachment=false query returned email WITH attachment: {}", + email["subject"].as_str().unwrap_or("") + ), + )?; + } + Ok(()) +} + +async fn filter_text_search_headers(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "text": "Project Alpha" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-reply-2")), + "thread-reply-2 should match", + ) +} + +async fn filter_text_search_body(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "text": "xylophone" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-reply-2")), + "thread-reply-2 should match", + ) +} + +async fn filter_from(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "from": "alice@example.com" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should match", + )?; + check( + has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should match", + ) +} + +async fn filter_to(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "to": "alice@example.com" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-starter")), + "thread-starter should match", + ) +} + +async fn filter_cc(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "cc": "charlie@example.net" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + ) +} + +async fn filter_subject(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "subject": "Financial Report" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + ) +} + +async fn filter_body(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "body": "conference room" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should match", + ) +} + +async fn filter_header_name_only(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "header": ["X-Custom-Header"] } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("special-headers")), + "special-headers should match", + )?; + check( + !has_id(&ids, ctx.email("plain-simple")), + "plain-simple should not match", + ) +} + +async fn filter_header_name_value(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "header": ["X-Custom-Header", "custom-value-12345"] } }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("special-headers")), + "special-headers should match", + ) +} + +async fn filter_some_in_thread_have_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "someInThreadHaveKeyword": "$answered" } }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-starter")), + "thread-starter should match", + )?; + check( + has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should match", + )?; + check( + has_id(&ids, ctx.email("thread-reply-2")), + "thread-reply-2 should match", + ) +} + +async fn filter_none_in_thread_have_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "noneInThreadHaveKeyword": "$answered" } }), + ) + .await; + let ids = query_ids(&resp); + check( + !has_id(&ids, ctx.email("thread-starter")), + "thread-starter should be excluded", + )?; + check( + !has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should be excluded", + )?; + check( + !has_id(&ids, ctx.email("thread-reply-2")), + "thread-reply-2 should be excluded", + )?; + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should be included", + ) +} + +async fn filter_operator_and(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { + "operator": "AND", + "conditions": [ + { "hasKeyword": "$seen" }, + { "inMailbox": ctx.role("inbox") } + ] + } + }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should match", + )?; + check( + !has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should not match", + ) +} + +async fn filter_operator_or(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { + "operator": "OR", + "conditions": [ + { "hasKeyword": "$flagged" }, + { "hasKeyword": "$answered" } + ] + } + }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + )?; + check( + has_id(&ids, ctx.email("thread-reply-2")), + "thread-reply-2 should match", + ) +} + +async fn filter_operator_not(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { + "operator": "NOT", + "conditions": [{ "hasKeyword": "$seen" }] + } + }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should match", + )?; + check( + !has_id(&ids, ctx.email("plain-simple")), + "plain-simple should not match", + ) +} + +async fn filter_nested_operators(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { + "operator": "OR", + "conditions": [ + { + "operator": "AND", + "conditions": [ + { "inMailbox": ctx.role("inbox") }, + { "hasKeyword": "$seen" } + ] + }, + { "hasKeyword": "$flagged" } + ] + } + }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should match", + )?; + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + ) +} + +async fn filter_empty_matches_all(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": {}, "calculateTotal": true })).await; + let total = resp.method_response()["total"].as_u64().unwrap_or(0); + check( + total >= ctx.email_ids.len() as u64, + "Null filter should return all emails", + ) +} + +async fn filter_multiple_conditions_on_one_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { + "inMailbox": ctx.role("inbox"), + "hasKeyword": "$flagged" + } + }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("html-attachment")), + "html-attachment should match", + ) +} + +async fn filter_custom_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "hasKeyword": "custom_label" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("custom-keywords")), + "custom-keywords should match", + )?; + check_eq(ids.len(), 1, "exactly one match") +} + +async fn filter_in_child_mailbox(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": { "inMailbox": ctx.mailbox("child1") } }), + ) + .await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("child-mailbox-email")), + "child-mailbox-email should match", + ) +} + +async fn filter_before_and_after(ctx: &CompCtx<'_>) -> TestOutcome { + let eight_days_ago = (Utc::now() - ChronoDuration::days(8)).to_rfc3339(); + let two_days_ago = (Utc::now() - ChronoDuration::days(2)).to_rfc3339(); + let resp = email_query( + ctx, + json!({ "filter": { "after": eight_days_ago, "before": two_days_ago } }), + ) + .await; + let ids = query_ids(&resp); + check( + !has_id(&ids, ctx.email("very-old")), + "very-old should be excluded", + )?; + check( + !has_id(&ids, ctx.email("custom-keywords")), + "custom-keywords should be excluded", + ) +} + +async fn filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": null, "calculateTotal": true })).await; + check( + resp.method_response()["ids"].is_array(), + "ids must be array", + )?; + let total = resp.method_response()["total"].as_u64().unwrap_or(0); + check( + total >= ctx.email_ids.len() as u64, + "Null filter should return all emails", + ) +} + +async fn filter_from_display_name(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "from": "Alice Sender" } })).await; + let ids = query_ids(&resp); + check( + has_id(&ids, ctx.email("plain-simple")), + "plain-simple should match", + ) +} + +async fn sort_received_at_desc(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.role("inbox") }, + "sort": [{ "property": "receivedAt", "isAscending": false }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let head = ids.iter().take(5).cloned().collect::>(); + let list = get_emails(ctx, &head, json!(["receivedAt"])).await; + let dates = ordered_text(&head, &list, "receivedAt"); + for i in 1..head.len() { + check(dates[i - 1] >= dates[i], "receivedAt should be descending")?; + } + Ok(()) +} + +async fn sort_received_at_asc(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "receivedAt", "isAscending": true }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["receivedAt"])).await; + let dates = ordered_text(&ids, &list, "receivedAt"); + for i in 1..ids.len() { + check(dates[i - 1] <= dates[i], "receivedAt should be ascending")?; + } + Ok(()) +} + +async fn sort_size(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "size", "isAscending": true }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["size"])).await; + let sizes = ordered_num(&ids, &list, "size"); + for i in 1..ids.len() { + check( + sizes[i - 1] <= sizes[i], + format!("Expected size {} <= {}", sizes[i - 1], sizes[i]), + )?; + } + Ok(()) +} + +async fn sort_from(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "from", "isAscending": true }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["from"])).await; + let displays = ordered_display(&ids, &list, "from"); + for i in 1..ids.len() { + check( + displays[i - 1] <= displays[i], + format!( + "Expected from '{}' <= '{}' in ascending from sort", + displays[i - 1], + displays[i] + ), + )?; + } + Ok(()) +} + +async fn sort_to(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "to", "isAscending": true }], + "limit": 10 + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["to"])).await; + let displays = ordered_display(&ids, &list, "to"); + for i in 1..ids.len() { + check( + displays[i - 1] <= displays[i], + format!( + "Expected to '{}' <= '{}' in ascending to sort", + displays[i - 1], + displays[i] + ), + )?; + } + Ok(()) +} + +async fn sort_subject(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "subject", "isAscending": true }] + }), + ) + .await; + let ids = query_ids(&resp); + let list = get_emails(ctx, &ids, json!(["subject"])).await; + let subjects = ordered_text(&ids, &list, "subject"); + for i in 1..ids.len() { + check( + subjects[i - 1] <= subjects[i], + format!( + "Expected '{}' <= '{}' in subject sort", + subjects[i - 1], + subjects[i] + ), + )?; + } + Ok(()) +} + +async fn sort_sent_at(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "sentAt", "isAscending": true }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["sentAt"])).await; + let dates = ordered_text(&ids, &list, "sentAt"); + for i in 1..ids.len() { + check(dates[i - 1] <= dates[i], "sentAt should be ascending")?; + } + Ok(()) +} + +async fn sort_has_keyword(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.mailbox("folderB") }, + "sort": [{ "property": "hasKeyword", "keyword": "$flagged", "isAscending": false }] + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["keywords"])).await; + let mut seen_non_flagged = false; + for id in &ids { + let flagged = find_email(&list, id) + .map(|e| e["keywords"]["$flagged"].as_bool().unwrap_or(false)) + .unwrap_or(false); + if !flagged { + seen_non_flagged = true; + } else { + check( + !seen_non_flagged, + "Flagged email appeared after non-flagged email in descending hasKeyword sort", + )?; + } + } + check_eq( + ids.first().map(|s| s.as_str()).unwrap_or(""), + ctx.email("sort-test-2"), + "sort-test-2 ($flagged) should be first in descending hasKeyword sort", + ) +} + +async fn sort_multi_property(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [ + { "property": "receivedAt", "isAscending": false }, + { "property": "subject", "isAscending": true } + ], + "limit": 10 + }), + ) + .await; + let ids = query_ids(&resp); + check(ids.len() > 1, "expected more than one email")?; + let list = get_emails(ctx, &ids, json!(["receivedAt", "subject"])).await; + let dates = ordered_text(&ids, &list, "receivedAt"); + for i in 1..ids.len() { + check( + dates[i - 1] >= dates[i], + "Primary sort receivedAt should be descending", + )?; + } + Ok(()) +} + +async fn sort_default_no_sort(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": { "inMailbox": ctx.role("inbox") } })).await; + let ids = query_ids(&resp); + check(!ids.is_empty(), "Should return emails with no sort") +} + +async fn paging_position_zero(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "position": 0, + "limit": 5, + "calculateTotal": true + }), + ) + .await; + check_eq(&resp.method_response()["position"], &json!(0), "position")?; + let ids = query_ids(&resp); + check(!ids.is_empty(), "expected at least one email")?; + check(ids.len() <= 5, "Should respect limit") +} + +async fn paging_positive_position(ctx: &CompCtx<'_>) -> TestOutcome { + let all = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }] + }), + ) + .await; + let all_ids = query_ids(&all); + if all_ids.len() < 3 { + return Ok(()); + } + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "position": 2, + "limit": 3 + }), + ) + .await; + check_eq(&resp.method_response()["position"], &json!(2), "position")?; + let ids = query_ids(&resp); + check_eq( + ids.first().map(|s| s.as_str()).unwrap_or(""), + all_ids[2].as_str(), + "first id", + ) +} + +async fn paging_negative_position(ctx: &CompCtx<'_>) -> TestOutcome { + let all = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "calculateTotal": true + }), + ) + .await; + let all_ids = query_ids(&all); + let total = all.method_response()["total"].as_u64().unwrap_or(0) as usize; + if total < 3 { + return Ok(()); + } + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "position": -3, + "calculateTotal": true + }), + ) + .await; + let ids = query_ids(&resp); + check_eq( + ids.first().map(|s| s.as_str()).unwrap_or(""), + all_ids[total - 3].as_str(), + "first id", + ) +} + +async fn paging_limit(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": {}, "limit": 3, "calculateTotal": true }), + ) + .await; + let ids = query_ids(&resp); + let total = resp.method_response()["total"].as_u64().unwrap_or(0); + check( + ids.len() <= 3, + format!("Expected at most 3, got {}", ids.len()), + )?; + if total > 3 { + check_eq(ids.len(), 3, "should be exactly 3")?; + } + Ok(()) +} + +async fn paging_anchor(ctx: &CompCtx<'_>) -> TestOutcome { + let all = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }] + }), + ) + .await; + let all_ids = query_ids(&all); + if all_ids.len() < 3 { + return Ok(()); + } + let anchor = all_ids[2].clone(); + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "anchor": anchor, + "limit": 3 + }), + ) + .await; + let ids = query_ids(&resp); + check_eq( + ids.first().map(|s| s.as_str()).unwrap_or(""), + anchor.as_str(), + "first id should be anchor", + )?; + check_eq(&resp.method_response()["position"], &json!(2), "position") +} + +async fn paging_anchor_offset(ctx: &CompCtx<'_>) -> TestOutcome { + let all = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }] + }), + ) + .await; + let all_ids = query_ids(&all); + if all_ids.len() < 5 { + return Ok(()); + } + let anchor = all_ids[3].clone(); + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "anchor": anchor, + "anchorOffset": -1, + "limit": 3 + }), + ) + .await; + let ids = query_ids(&resp); + check_eq( + ids.first().map(|s| s.as_str()).unwrap_or(""), + all_ids[2].as_str(), + "first id", + ) +} + +async fn paging_calculate_total(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": {}, "limit": 1, "calculateTotal": true }), + ) + .await; + check( + resp.method_response()["total"].is_number(), + "total must be a number", + )?; + let total = resp.method_response()["total"].as_u64().unwrap_or(0); + check( + total >= ctx.email_ids.len() as u64, + "total should be at least the seeded count", + ) +} + +async fn paging_anchor_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "anchor": "nonexistent-email-xyz" })).await; + check_eq( + resp.name_at(0), + "error", + "Server MUST return error for invalid anchor", + )?; + check_eq( + resp.error_type_at(0).unwrap_or(""), + "anchorNotFound", + "type", + ) +} + +async fn paging_position_beyond_total(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": {}, "position": 99999 })).await; + let ids = query_ids(&resp); + check_eq(ids.len(), 0, "expected empty result") +} + +async fn paging_response_position(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query(ctx, json!({ "filter": {}, "position": 0, "limit": 3 })).await; + check_eq(&resp.method_response()["position"], &json!(0), "position") +} + +async fn collapse_threads_basic(ctx: &CompCtx<'_>) -> TestOutcome { + let expanded = email_query( + ctx, + json!({ + "filter": { "subject": "Project Alpha Discussion" }, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": false, + "calculateTotal": true + }), + ) + .await; + let expanded_total = expanded.method_response()["total"].as_u64().unwrap_or(0); + + let collapsed = email_query( + ctx, + json!({ + "filter": { "subject": "Project Alpha Discussion" }, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": true, + "calculateTotal": true + }), + ) + .await; + let collapsed_total = collapsed.method_response()["total"].as_u64().unwrap_or(0); + + check( + collapsed_total < expanded_total, + "Collapsed total should be less than expanded", + ) +} + +async fn collapse_threads_one_per_thread(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": true + }), + ) + .await; + let ids = query_ids(&resp); + let list = get_emails(ctx, &ids, json!(["threadId"])).await; + let thread_ids = list + .iter() + .map(|e| e["threadId"].as_str().unwrap_or("").to_string()) + .collect::>(); + let unique = thread_ids.iter().collect::>(); + check_eq( + thread_ids.len(), + unique.len(), + "Each thread should appear only once", + ) +} + +async fn collapse_threads_with_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": { "inMailbox": ctx.role("inbox") }, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": true + }), + ) + .await; + let ids = query_ids(&resp); + let thread_email_ids = [ctx.email("thread-reply-1"), ctx.email("thread-reply-2")]; + let matching = ids + .iter() + .filter(|id| thread_email_ids.contains(&id.as_str())) + .count(); + check( + matching <= 1, + "At most one email from the thread should appear", + ) +} + +async fn collapse_threads_sort_determines_representative(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ + "filter": {}, + "sort": [{ "property": "receivedAt", "isAscending": false }], + "collapseThreads": true + }), + ) + .await; + let ids = query_ids(&resp); + if has_id(&ids, ctx.email("thread-reply-2")) { + check( + !has_id(&ids, ctx.email("thread-reply-1")), + "thread-reply-1 should not appear", + )?; + check( + !has_id(&ids, ctx.email("thread-starter")), + "thread-starter should not appear", + )?; + } + Ok(()) +} + +async fn collapse_threads_calculate_total(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = email_query( + ctx, + json!({ "filter": {}, "collapseThreads": true, "calculateTotal": true }), + ) + .await; + let total = resp.method_response()["total"].as_u64().unwrap_or(0) as usize; + let ids = query_ids(&resp); + check_eq(total, ids.len(), "total should equal ids length") +} + +fn ordered_text(ids: &[String], list: &[Value], field: &str) -> Vec { + ids.iter() + .map(|id| { + find_email(list, id) + .and_then(|e| e[field].as_str()) + .unwrap_or("") + .to_string() + }) + .collect() +} + +fn ordered_num(ids: &[String], list: &[Value], field: &str) -> Vec { + ids.iter() + .map(|id| { + find_email(list, id) + .and_then(|e| e[field].as_i64()) + .unwrap_or(0) + }) + .collect() +} + +fn ordered_display(ids: &[String], list: &[Value], field: &str) -> Vec { + ids.iter() + .map(|id| { + find_email(list, id) + .map(|e| display_value(e, field)) + .unwrap_or_default() + }) + .collect() +} diff --git a/tests/src/jmap/compliance/expected.rs b/tests/src/jmap/compliance/expected.rs new file mode 100644 index 0000000..925a691 --- /dev/null +++ b/tests/src/jmap/compliance/expected.rs @@ -0,0 +1,317 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub const EXPECTED: &[(&str, &str)] = &[ + ("binary/blob-copy-cross-account", "SKIP"), + ("binary/blob-copy-not-found", "SKIP"), + ("binary/blob-copy-response-structure", "SKIP"), + ("binary/blob-copy-same-account-error", "PASS"), + ("binary/download-email-blob", "PASS"), + ("binary/download-nonexistent-blob", "PASS"), + ("binary/download-respects-type-param", "PASS"), + ("binary/download-uploaded-blob", "PASS"), + ("binary/upload-basic", "PASS"), + ("binary/upload-binary-content", "PASS"), + ("binary/upload-large-data", "PASS"), + ("binary/upload-preserves-content-type", "PASS"), + ("binary/upload-returns-valid-blob-id", "PASS"), + ("core/echo-basic", "PASS"), + ("core/echo-empty", "PASS"), + ("core/echo-nested", "PASS"), + ("core/error-account-not-found", "FAIL"), + ("core/error-empty-using", "FAIL"), + ("core/error-invalid-arguments-bad-type", "PASS"), + ("core/error-invalid-arguments-missing-account", "FAIL"), + ("core/error-method-calls-not-array", "PASS"), + ("core/error-method-level-has-type", "PASS"), + ("core/error-multiple-method-responses", "PASS"), + ("core/error-not-json", "PASS"), + ("core/error-not-request", "FAIL"), + ("core/error-response-has-session-state", "PASS"), + ("core/error-state-mismatch", "PASS"), + ("core/error-unknown-capability", "PASS"), + ("core/error-unknown-method", "PASS"), + ("core/error-wrong-content-type", "FAIL"), + ("core/result-ref-call-id-preserved", "PASS"), + ("core/result-ref-chained", "PASS"), + ("core/result-ref-invalid-result-of", "PASS"), + ("core/result-ref-path-single-value", "PASS"), + ("core/result-ref-simple", "PASS"), + ("core/result-ref-wrong-method-name", "PASS"), + ("core/session-account-capabilities-mail", "PASS"), + ("core/session-account-properties", "PASS"), + ("core/session-accounts-present", "PASS"), + ("core/session-api-url", "PASS"), + ("core/session-core-capability-properties", "PASS"), + ("core/session-download-url-template", "PASS"), + ("core/session-event-source-url", "PASS"), + ("core/session-has-capabilities", "PASS"), + ("core/session-has-core-capability", "PASS"), + ("core/session-has-mail-capability", "PASS"), + ("core/session-mail-capability-properties", "PASS"), + ("core/session-primary-accounts", "PASS"), + ("core/session-state", "PASS"), + ("core/session-upload-url", "PASS"), + ("core/session-username", "PASS"), + ("email/body-attachment-blob-id", "PASS"), + ("email/body-attachments", "PASS"), + ("email/body-html-body", "PASS"), + ("email/body-inline-attachment-cid", "PASS"), + ("email/body-invalid-ascii-handling", "PASS"), + ("email/body-max-body-value-bytes", "PASS"), + ("email/body-multipart-alternative-text-and-html", "PASS"), + ("email/body-non-utf8-charset", "PASS"), + ("email/body-properties-filter", "PASS"), + ("email/body-structure", "PASS"), + ("email/body-text-body", "PASS"), + ("email/body-values-all", "PASS"), + ("email/body-values-html", "PASS"), + ("email/body-values-text", "PASS"), + ("email/changes-after-create-and-destroy", "PASS"), + ("email/changes-after-keyword-change", "PASS"), + ("email/changes-no-changes", "PASS"), + ("email/changes-response-structure", "PASS"), + ("email/collapse-threads-basic", "FAIL"), + ("email/collapse-threads-calculate-total", "FAIL"), + ("email/collapse-threads-one-per-thread", "PASS"), + ("email/collapse-threads-sort-determines-representative", "PASS"), + ("email/collapse-threads-with-filter", "PASS"), + ("email/copy-cross-account", "SKIP"), + ("email/copy-not-found", "SKIP"), + ("email/copy-same-account-error", "PASS"), + ("email/filter-after", "PASS"), + ("email/filter-before", "PASS"), + ("email/filter-before-and-after", "PASS"), + ("email/filter-body", "PASS"), + ("email/filter-cc", "PASS"), + ("email/filter-custom-keyword", "PASS"), + ("email/filter-empty-matches-all", "PASS"), + ("email/filter-from", "PASS"), + ("email/filter-from-display-name", "PASS"), + ("email/filter-has-attachment-false", "FAIL"), + ("email/filter-has-attachment-true", "FAIL"), + ("email/filter-has-keyword", "PASS"), + ("email/filter-header-name-only", "FAIL"), + ("email/filter-header-name-value", "FAIL"), + ("email/filter-in-child-mailbox", "PASS"), + ("email/filter-in-mailbox", "PASS"), + ("email/filter-in-mailbox-other-than", "PASS"), + ("email/filter-max-size", "PASS"), + ("email/filter-min-size", "PASS"), + ("email/filter-multiple-conditions-on-one-filter", "PASS"), + ("email/filter-nested-operators", "PASS"), + ("email/filter-none-in-thread-have-keyword", "PASS"), + ("email/filter-not-keyword", "PASS"), + ("email/filter-null-accepted", "FAIL"), + ("email/filter-operator-and", "PASS"), + ("email/filter-operator-not", "PASS"), + ("email/filter-operator-or", "PASS"), + ("email/filter-some-in-thread-have-keyword", "PASS"), + ("email/filter-subject", "PASS"), + ("email/filter-text-search-body", "PASS"), + ("email/filter-text-search-headers", "PASS"), + ("email/filter-to", "PASS"), + ("email/get-by-id", "PASS"), + ("email/get-has-attachment-false", "PASS"), + ("email/get-has-attachment-true", "PASS"), + ("email/get-keywords", "PASS"), + ("email/get-mailbox-ids", "PASS"), + ("email/get-metadata-properties", "PASS"), + ("email/get-multiple-emails", "PASS"), + ("email/get-not-found", "FAIL"), + ("email/get-preview-is-text", "PASS"), + ("email/get-properties-filter", "PASS"), + ("email/get-received-at-is-utc-date", "PASS"), + ("email/get-state-returned", "PASS"), + ("email/get-thread-id-consistent", "PASS"), + ("email/header-as-addresses", "PASS"), + ("email/header-as-date", "PASS"), + ("email/header-as-grouped-addresses", "PASS"), + ("email/header-as-message-ids", "PASS"), + ("email/header-as-urls", "PASS"), + ("email/header-bcc", "PASS"), + ("email/header-case-insensitive", "PASS"), + ("email/header-cc", "PASS"), + ("email/header-custom-header", "PASS"), + ("email/header-from", "PASS"), + ("email/header-in-reply-to", "PASS"), + ("email/header-intl-from-decoded", "PASS"), + ("email/header-message-id", "PASS"), + ("email/header-raw-access", "PASS"), + ("email/header-raw-form", "PASS"), + ("email/header-references", "PASS"), + ("email/header-sent-at", "PASS"), + ("email/header-subject", "PASS"), + ("email/header-subject-empty", "PASS"), + ("email/header-to", "PASS"), + ("email/import-invalid-blob", "FAIL"), + ("email/import-multiple", "PASS"), + ("email/import-not-found-blob", "PASS"), + ("email/import-sets-keywords", "PASS"), + ("email/import-sets-mailbox", "PASS"), + ("email/import-sets-received-at", "PASS"), + ("email/import-state-changes", "PASS"), + ("email/import-valid-message", "PASS"), + ("email/paging-anchor", "FAIL"), + ("email/paging-anchor-not-found", "FAIL"), + ("email/paging-anchor-offset", "FAIL"), + ("email/paging-calculate-total", "PASS"), + ("email/paging-limit", "PASS"), + ("email/paging-negative-position", "PASS"), + ("email/paging-position-beyond-total", "PASS"), + ("email/paging-position-zero", "PASS"), + ("email/paging-positive-position", "PASS"), + ("email/paging-response-position", "PASS"), + ("email/parse-body-values", "PASS"), + ("email/parse-not-found", "FAIL"), + ("email/parse-not-parsable", "FAIL"), + ("email/parse-null-metadata", "PASS"), + ("email/parse-response-structure", "PASS"), + ("email/parse-valid-message", "PASS"), + ("email/query-changes-after-add", "PASS"), + ("email/query-changes-after-remove", "PASS"), + ("email/query-changes-filter-null-accepted", "FAIL"), + ("email/query-changes-no-changes", "PASS"), + ("email/query-changes-response-structure", "PASS"), + ("email/set-create-creation-id-reference", "PASS"), + ("email/set-create-html", "PASS"), + ("email/set-create-multipart-alternative", "PASS"), + ("email/set-create-plain-text", "PASS"), + ("email/set-create-server-set-properties", "PASS"), + ("email/set-create-state-changes", "PASS"), + ("email/set-create-with-attachment", "PASS"), + ("email/set-create-with-keywords", "PASS"), + ("email/set-destroy-multiple", "PASS"), + ("email/set-destroy-not-found", "FAIL"), + ("email/set-destroy-removes-from-all-mailboxes", "PASS"), + ("email/set-destroy-single", "PASS"), + ("email/set-update-add-keyword", "PASS"), + ("email/set-update-add-mailbox", "PASS"), + ("email/set-update-if-in-state", "PASS"), + ("email/set-update-move-mailbox", "PASS"), + ("email/set-update-not-found", "FAIL"), + ("email/set-update-remove-keyword", "PASS"), + ("email/set-update-remove-mailbox", "PASS"), + ("email/set-update-replace-keywords", "PASS"), + ("email/sort-default-no-sort", "PASS"), + ("email/sort-from", "PASS"), + ("email/sort-has-keyword", "FAIL"), + ("email/sort-multi-property", "PASS"), + ("email/sort-received-at-asc", "PASS"), + ("email/sort-received-at-desc", "PASS"), + ("email/sort-sent-at", "PASS"), + ("email/sort-size", "PASS"), + ("email/sort-subject", "PASS"), + ("email/sort-to", "PASS"), + ("identity/changes-after-update", "PASS"), + ("identity/changes-no-changes", "PASS"), + ("identity/changes-response-structure", "PASS"), + ("identity/get-all-identities", "PASS"), + ("identity/get-identity-by-id", "PASS"), + ("identity/get-identity-email-matches", "PASS"), + ("identity/get-identity-not-found", "FAIL"), + ("identity/get-identity-properties", "PASS"), + ("identity/set-not-found", "FAIL"), + ("identity/set-update-html-signature", "PASS"), + ("identity/set-update-name", "PASS"), + ("identity/set-update-reply-to", "PASS"), + ("identity/set-update-text-signature", "PASS"), + ("mailbox/changes-after-create", "PASS"), + ("mailbox/changes-after-rename", "PASS"), + ("mailbox/changes-has-more-changes", "PASS"), + ("mailbox/changes-no-changes", "PASS"), + ("mailbox/changes-response-structure", "PASS"), + ("mailbox/get-account-id-returned", "PASS"), + ("mailbox/get-all", "PASS"), + ("mailbox/get-by-ids", "PASS"), + ("mailbox/get-inbox-exists", "PASS"), + ("mailbox/get-mailbox-properties", "PASS"), + ("mailbox/get-not-found", "FAIL"), + ("mailbox/get-parent-id-correct", "PASS"), + ("mailbox/get-properties-filter", "PASS"), + ("mailbox/get-state-returned", "PASS"), + ("mailbox/get-total-emails-accurate", "PASS"), + ("mailbox/query-all", "PASS"), + ("mailbox/query-changes-after-create", "PASS"), + ("mailbox/query-changes-filter-null-accepted", "FAIL"), + ("mailbox/query-changes-no-changes", "PASS"), + ("mailbox/query-changes-response-structure", "PASS"), + ("mailbox/query-filter-by-name", "PASS"), + ("mailbox/query-filter-by-parent-id", "PASS"), + ("mailbox/query-filter-by-parent-id-null", "PASS"), + ("mailbox/query-filter-by-role", "PASS"), + ("mailbox/query-filter-has-any-role", "PASS"), + ("mailbox/query-filter-has-any-role-false", "PASS"), + ("mailbox/query-filter-null-accepted", "FAIL"), + ("mailbox/query-limit", "PASS"), + ("mailbox/query-position", "PASS"), + ("mailbox/query-response-structure", "PASS"), + ("mailbox/query-sort-by-name", "PASS"), + ("mailbox/query-sort-by-sort-order", "PASS"), + ("mailbox/set-cannot-destroy-with-children", "PASS"), + ("mailbox/set-change-sort-order", "PASS"), + ("mailbox/set-create-child", "PASS"), + ("mailbox/set-create-returns-server-set-props", "PASS"), + ("mailbox/set-create-top-level", "PASS"), + ("mailbox/set-destroy-empty", "PASS"), + ("mailbox/set-destroy-not-found", "FAIL"), + ("mailbox/set-duplicate-name-same-parent", "FAIL"), + ("mailbox/set-move-parent", "PASS"), + ("mailbox/set-on-destroy-remove-emails", "PASS"), + ("mailbox/set-on-destroy-remove-emails-with-children", "PASS"), + ("mailbox/set-rename", "PASS"), + ("mailbox/set-state-changes", "PASS"), + ("push-eventsource/eventsource-closeafter", "PASS"), + ("push-eventsource/eventsource-connect", "PASS"), + ("push-eventsource/eventsource-receives-state-change", "PASS"), + ("push-eventsource/eventsource-types-filter", "PASS"), + ("push-subscription/push-subscription-create", "PASS"), + ("push-subscription/push-subscription-destroy", "PASS"), + ("push-subscription/push-subscription-get", "PASS"), + ("push-subscription/push-subscription-receives-notification", "PASS"), + ("push-subscription/push-subscription-reject-non-https", "PASS"), + ("push-subscription/push-subscription-types-filter", "PASS"), + ("push-subscription/push-subscription-verification", "PASS"), + ("search-snippet/snippet-body-match", "PASS"), + ("search-snippet/snippet-mark-tags", "PASS"), + ("search-snippet/snippet-not-found", "FAIL"), + ("search-snippet/snippet-null-when-no-match", "FAIL"), + ("search-snippet/snippet-response-structure", "FAIL"), + ("search-snippet/snippet-subject-match", "PASS"), + ("submission/changes-no-changes", "PASS"), + ("submission/changes-response-structure", "PASS"), + ("submission/get-empty", "PASS"), + ("submission/get-not-found", "FAIL"), + ("submission/get-response-structure", "PASS"), + ("submission/query-all", "PASS"), + ("submission/query-filter-null-accepted", "FAIL"), + ("submission/query-filter-undo-status", "PASS"), + ("submission/query-response-structure", "PASS"), + ("submission/set-create-submission", "FAIL"), + ("submission/set-create-with-envelope", "PASS"), + ("submission/set-no-recipients-error", "PASS"), + ("submission/set-on-success-update-email", "PASS"), + ("submission/set-submission-properties", "PASS"), + ("thread/changes-after-email-destroy", "FAIL"), + ("thread/changes-after-new-email", "PASS"), + ("thread/changes-no-changes", "PASS"), + ("thread/changes-response-structure", "PASS"), + ("thread/get-single-email-thread", "PASS"), + ("thread/get-thread-by-id", "PASS"), + ("thread/get-thread-email-ids-order", "PASS"), + ("thread/get-thread-not-found", "FAIL"), + ("thread/get-thread-response-structure", "PASS"), + ("vacation/get-not-found-invalid-id", "FAIL"), + ("vacation/get-singleton", "FAIL"), + ("vacation/get-singleton-null-ids", "FAIL"), + ("vacation/get-singleton-properties", "FAIL"), + ("vacation/set-cannot-create", "FAIL"), + ("vacation/set-cannot-destroy", "FAIL"), + ("vacation/set-dates", "PASS"), + ("vacation/set-disable-vacation", "PASS"), + ("vacation/set-enable-vacation", "FAIL"), + ("vacation/set-html-body", "PASS"), +]; diff --git a/tests/src/jmap/compliance/identity.rs b/tests/src/jmap/compliance/identity.rs new file mode 100644 index 0000000..21b52a4 --- /dev/null +++ b/tests/src/jmap/compliance/identity.rs @@ -0,0 +1,452 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq, skip}; +use serde_json::json; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] identity"); + + ctx.run("identity/get-all-identities", get_all_identities(ctx)) + .await; + ctx.run("identity/get-identity-by-id", get_identity_by_id(ctx)) + .await; + ctx.run( + "identity/get-identity-email-matches", + get_identity_email_matches(ctx), + ) + .await; + ctx.run( + "identity/get-identity-not-found", + get_identity_not_found(ctx), + ) + .await; + ctx.run( + "identity/get-identity-properties", + get_identity_properties(ctx), + ) + .await; + + ctx.run("identity/changes-after-update", changes_after_update(ctx)) + .await; + ctx.run("identity/changes-no-changes", changes_no_changes(ctx)) + .await; + ctx.run( + "identity/changes-response-structure", + changes_response_structure(ctx), + ) + .await; + + ctx.run("identity/set-not-found", set_not_found(ctx)).await; + ctx.run( + "identity/set-update-html-signature", + set_update_html_signature(ctx), + ) + .await; + ctx.run("identity/set-update-name", set_update_name(ctx)) + .await; + ctx.run("identity/set-update-reply-to", set_update_reply_to(ctx)) + .await; + ctx.run( + "identity/set-update-text-signature", + set_update_text_signature(ctx), + ) + .await; +} + +async fn get_all_identities(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Identity", Vec::::new(), Vec::::new()) + .await; + let len = resp.method_response()["list"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + check(len > 0, "Must have at least one identity") +} + +async fn get_identity_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Identity", Vec::::new(), Vec::::new()) + .await; + let identity = &resp.method_response()["list"][0]; + check(identity["id"].is_string(), "id must be string")?; + check(identity["name"].is_string(), "name must be string")?; + check(identity["email"].is_string(), "email must be string")?; + check( + identity["textSignature"].is_string(), + "textSignature must be string", + )?; + check( + identity["htmlSignature"].is_string(), + "htmlSignature must be string", + )?; + check( + identity["mayDelete"].is_boolean(), + "mayDelete must be boolean", + )?; + check( + identity["replyTo"].is_null() || identity["replyTo"].is_array(), + "replyTo must be null or array", + )?; + check( + identity["bcc"].is_null() || identity["bcc"].is_array(), + "bcc must be null or array", + ) +} + +async fn get_identity_by_id(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let id = &ctx.identity_ids[0]; + let resp = ctx + .primary + .jmap_get("Identity", Vec::::new(), [id]) + .await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + check_eq(list[0]["id"].as_str().unwrap_or(""), id.as_str(), "id") +} + +async fn get_identity_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get( + "Identity", + Vec::::new(), + ["nonexistent-identity-xyz"], + ) + .await; + let not_found = &resp.method_response()["notFound"]; + check( + not_found.is_array(), + format!("Identity/get notFound MUST be a String[], got {not_found}"), + )?; + let found = resp.not_found().any(|id| id == "nonexistent-identity-xyz"); + check(found, "notFound must include nonexistent-identity-xyz") +} + +async fn get_identity_email_matches(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let id = &ctx.identity_ids[0]; + let resp = ctx + .primary + .jmap_get("Identity", Vec::::new(), [id]) + .await; + let email = resp.method_response()["list"][0]["email"] + .as_str() + .unwrap_or(""); + check_contains(email, "@", "email must contain @") +} + +async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), Vec::::new()) + .await; + let state = get_result.state().to_string(); + let resp = ctx.primary.jmap_changes("Identity", &state).await; + let r = resp.method_response(); + check_eq( + r["oldState"].as_str().unwrap_or(""), + state.as_str(), + "oldState", + )?; + check_eq( + r["created"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "created length", + )?; + check_eq( + r["updated"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "updated length", + )?; + check_eq( + r["destroyed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "destroyed length", + ) +} + +async fn changes_after_update(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), Vec::::new()) + .await; + let old_state = get_result.state().to_string(); + + let identity_id = ctx.identity_ids[0].clone(); + let identity_get = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let old_name = identity_get.method_response()["list"][0]["name"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "name": "Updated Name For Test" }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + let changes = ctx.primary.jmap_changes("Identity", &old_state).await; + let updated_contains = changes.method_response()["updated"] + .as_array() + .map(|a| a.iter().any(|v| v.as_str() == Some(identity_id.as_str()))) + .unwrap_or(false); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "name": old_name }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + check(updated_contains, "updated must include the identity id") +} + +async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), Vec::::new()) + .await; + let state = get_result.state().to_string(); + let resp = ctx.primary.jmap_changes("Identity", &state).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["oldState"].is_string(), "oldState must be string")?; + check(r["newState"].is_string(), "newState must be string")?; + check( + r["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + ) +} + +async fn set_update_name(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let identity_id = ctx.identity_ids[0].clone(); + + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let original_name = get_result.method_response()["list"][0]["name"] + .as_str() + .unwrap_or("") + .to_string(); + + let set_result = ctx + .primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "name": "Test Updated Name" }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + let updated_truthy = set_result.method_response()["updated"] + .as_object() + .map(|o| !o.is_empty()) + .unwrap_or(false); + + let verify_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let new_name = verify_result.method_response()["list"][0]["name"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "name": original_name }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + check(updated_truthy, "updated must be present")?; + check_eq(new_name.as_str(), "Test Updated Name", "name") +} + +async fn set_update_text_signature(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let identity_id = ctx.identity_ids[0].clone(); + + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let original_sig = get_result.method_response()["list"][0]["textSignature"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [( + &identity_id, + json!({ "textSignature": "-- \nTest Signature" }), + )], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let sig = verify.method_response()["list"][0]["textSignature"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "textSignature": original_sig }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + check_contains(&sig, "Test Signature", "textSignature") +} + +async fn set_update_html_signature(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let identity_id = ctx.identity_ids[0].clone(); + + let get_result = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let original_sig = get_result.method_response()["list"][0]["htmlSignature"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [( + &identity_id, + json!({ "htmlSignature": "

Test HTML Signature

" }), + )], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let sig = verify.method_response()["list"][0]["htmlSignature"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "htmlSignature": original_sig }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + check_contains(&sig, "HTML Signature", "htmlSignature") +} + +async fn set_update_reply_to(ctx: &CompCtx<'_>) -> TestOutcome { + if ctx.identity_ids.is_empty() { + return skip("No identities available"); + } + let identity_id = ctx.identity_ids[0].clone(); + + ctx.primary + .jmap_update( + "Identity", + [( + &identity_id, + json!({ "replyTo": [{ "name": "Reply Test", "email": "reply@example.com" }] }), + )], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get("Identity", Vec::::new(), [&identity_id]) + .await; + let reply_to = verify.method_response()["list"][0]["replyTo"].clone(); + + ctx.primary + .jmap_update( + "Identity", + [(&identity_id, json!({ "replyTo": null }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + check( + reply_to.is_array() && !reply_to.as_array().unwrap().is_empty(), + "replyTo must be a non-empty array", + )?; + check_eq( + reply_to[0]["email"].as_str().unwrap_or(""), + "reply@example.com", + "replyTo email", + ) +} + +async fn set_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_update( + "Identity", + [("nonexistent-identity-xyz", json!({ "name": "test" }))], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + let not_updated = &resp.method_response()["notUpdated"]; + check( + not_updated.is_object(), + "notUpdated must not be null when updating a nonexistent id", + )?; + check( + !not_updated["nonexistent-identity-xyz"].is_null(), + "Expected notUpdated to contain error for 'nonexistent-identity-xyz'", + ) +} diff --git a/tests/src/jmap/compliance/mailbox.rs b/tests/src/jmap/compliance/mailbox.rs new file mode 100644 index 0000000..3877d01 --- /dev/null +++ b/tests/src/jmap/compliance/mailbox.rs @@ -0,0 +1,1304 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_eq, check_ne}; +use crate::utils::jmap::JmapUtils; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] mailbox"); + + ctx.run("mailbox/get-all", get_all(ctx)).await; + ctx.run("mailbox/get-by-ids", get_by_ids(ctx)).await; + ctx.run("mailbox/get-inbox-exists", get_inbox_exists(ctx)) + .await; + ctx.run("mailbox/get-not-found", get_not_found(ctx)).await; + ctx.run( + "mailbox/get-mailbox-properties", + get_mailbox_properties(ctx), + ) + .await; + ctx.run("mailbox/get-parent-id-correct", get_parent_id_correct(ctx)) + .await; + ctx.run("mailbox/get-properties-filter", get_properties_filter(ctx)) + .await; + ctx.run("mailbox/get-state-returned", get_state_returned(ctx)) + .await; + ctx.run( + "mailbox/get-account-id-returned", + get_account_id_returned(ctx), + ) + .await; + ctx.run( + "mailbox/get-total-emails-accurate", + get_total_emails_accurate(ctx), + ) + .await; + + ctx.run("mailbox/query-all", query_all(ctx)).await; + ctx.run("mailbox/query-filter-by-name", query_filter_by_name(ctx)) + .await; + ctx.run( + "mailbox/query-filter-by-parent-id", + query_filter_by_parent_id(ctx), + ) + .await; + ctx.run( + "mailbox/query-filter-by-parent-id-null", + query_filter_by_parent_id_null(ctx), + ) + .await; + ctx.run("mailbox/query-filter-by-role", query_filter_by_role(ctx)) + .await; + ctx.run( + "mailbox/query-filter-has-any-role", + query_filter_has_any_role(ctx), + ) + .await; + ctx.run( + "mailbox/query-filter-has-any-role-false", + query_filter_has_any_role_false(ctx), + ) + .await; + ctx.run( + "mailbox/query-filter-null-accepted", + query_filter_null_accepted(ctx), + ) + .await; + ctx.run("mailbox/query-limit", query_limit(ctx)).await; + ctx.run("mailbox/query-position", query_position(ctx)).await; + ctx.run( + "mailbox/query-response-structure", + query_response_structure(ctx), + ) + .await; + ctx.run("mailbox/query-sort-by-name", query_sort_by_name(ctx)) + .await; + ctx.run( + "mailbox/query-sort-by-sort-order", + query_sort_by_sort_order(ctx), + ) + .await; + + ctx.run("mailbox/set-create-top-level", set_create_top_level(ctx)) + .await; + ctx.run("mailbox/set-create-child", set_create_child(ctx)) + .await; + ctx.run( + "mailbox/set-create-returns-server-set-props", + set_create_returns_server_set_props(ctx), + ) + .await; + ctx.run("mailbox/set-rename", set_rename(ctx)).await; + ctx.run("mailbox/set-change-sort-order", set_change_sort_order(ctx)) + .await; + ctx.run("mailbox/set-move-parent", set_move_parent(ctx)) + .await; + ctx.run("mailbox/set-destroy-empty", set_destroy_empty(ctx)) + .await; + ctx.run("mailbox/set-destroy-not-found", set_destroy_not_found(ctx)) + .await; + ctx.run( + "mailbox/set-duplicate-name-same-parent", + set_duplicate_name_same_parent(ctx), + ) + .await; + ctx.run( + "mailbox/set-cannot-destroy-with-children", + set_cannot_destroy_with_children(ctx), + ) + .await; + ctx.run( + "mailbox/set-on-destroy-remove-emails", + set_on_destroy_remove_emails(ctx), + ) + .await; + ctx.run( + "mailbox/set-on-destroy-remove-emails-with-children", + set_on_destroy_remove_emails_with_children(ctx), + ) + .await; + ctx.run("mailbox/set-state-changes", set_state_changes(ctx)) + .await; + + ctx.run("mailbox/changes-after-create", changes_after_create(ctx)) + .await; + ctx.run("mailbox/changes-after-rename", changes_after_rename(ctx)) + .await; + ctx.run( + "mailbox/changes-has-more-changes", + changes_has_more_changes(ctx), + ) + .await; + ctx.run("mailbox/changes-no-changes", changes_no_changes(ctx)) + .await; + ctx.run( + "mailbox/changes-response-structure", + changes_response_structure(ctx), + ) + .await; + + ctx.run( + "mailbox/query-changes-after-create", + query_changes_after_create(ctx), + ) + .await; + ctx.run( + "mailbox/query-changes-filter-null-accepted", + query_changes_filter_null_accepted(ctx), + ) + .await; + ctx.run( + "mailbox/query-changes-no-changes", + query_changes_no_changes(ctx), + ) + .await; + ctx.run( + "mailbox/query-changes-response-structure", + query_changes_response_structure(ctx), + ) + .await; +} + +const CORE: &str = "urn:ietf:params:jmap:core"; +const MAIL: &str = "urn:ietf:params:jmap:mail"; + +fn default_using() -> Vec<&'static str> { + vec![CORE, MAIL] +} + +async fn get_all(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + check(!resp.list().is_empty(), "Must have at least one mailbox") +} + +async fn get_by_ids(ctx: &CompCtx<'_>) -> TestOutcome { + let inbox = ctx.role("inbox").to_string(); + let folder_a = ctx.mailbox("folderA").to_string(); + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&inbox, &folder_a]) + .await; + let list = resp.list(); + check_eq(list.len(), 2, "list length")?; + let ids = list.iter().map(|m| m.id()).collect::>(); + check(ids.contains(&inbox.as_str()), "list includes inbox")?; + check(ids.contains(&folder_a.as_str()), "list includes folderA") +} + +async fn get_inbox_exists(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [ctx.role("inbox")]) + .await; + check_eq(&resp.list()[0]["role"], &json!("inbox"), "role") +} + +async fn get_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), ["nonexistent-mailbox-xyz"]) + .await; + check( + resp.method_response()["notFound"].is_array(), + "notFound must be a String[]", + )?; + let not_found = resp.not_found().collect::>(); + check( + not_found.contains(&"nonexistent-mailbox-xyz"), + "notFound includes the requested id", + ) +} + +async fn get_mailbox_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [ctx.role("inbox")]) + .await; + let mb = &resp.list()[0]; + check(mb["id"].is_string(), "id must be string")?; + check(mb["name"].is_string(), "name must be string")?; + check( + mb["parentId"].is_null() || mb["parentId"].is_string(), + "parentId must be null or string", + )?; + check( + mb["role"].is_null() || mb["role"].is_string(), + "role must be null or string", + )?; + check(mb["sortOrder"].is_number(), "sortOrder must be number")?; + check(mb["totalEmails"].is_number(), "totalEmails must be number")?; + check( + mb["unreadEmails"].is_number(), + "unreadEmails must be number", + )?; + check( + mb["totalThreads"].is_number(), + "totalThreads must be number", + )?; + check( + mb["unreadThreads"].is_number(), + "unreadThreads must be number", + )?; + check( + mb["isSubscribed"].is_boolean(), + "isSubscribed must be boolean", + )?; + let rights = &mb["myRights"]; + for prop in [ + "mayReadItems", + "mayAddItems", + "mayRemoveItems", + "maySetSeen", + "maySetKeywords", + "mayCreateChild", + "mayRename", + "mayDelete", + "maySubmit", + ] { + check(rights[prop].is_boolean(), format!("{prop} must be boolean"))?; + } + Ok(()) +} + +async fn get_parent_id_correct(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [ctx.mailbox("child1")]) + .await; + check_eq( + &resp.list()[0]["parentId"], + &json!(ctx.mailbox("folderA")), + "parentId", + ) +} + +async fn get_properties_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", ["id", "name", "role"], [ctx.role("inbox")]) + .await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + let mb = &list[0]; + check(mb["id"].is_string(), "id present")?; + check(mb["name"].is_string(), "name present")?; + check( + mb.get("totalEmails").is_none(), + "totalEmails must not be returned when not requested", + )?; + check( + mb.get("unreadEmails").is_none(), + "unreadEmails must not be returned when not requested", + )?; + check( + mb.get("sortOrder").is_none(), + "sortOrder must not be returned when not requested", + ) +} + +async fn get_state_returned(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + let state = &resp.method_response()["state"]; + check(state.is_string(), "state must be string")?; + check( + state.as_str().map(|s| !s.is_empty()).unwrap_or(false), + "state must not be empty", + ) +} + +async fn get_account_id_returned(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + check_eq( + &resp.method_response()["accountId"], + &json!(ctx.account_id()), + "accountId", + ) +} + +async fn get_total_emails_accurate(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_query( + "Email", + [("inMailbox", json!(ctx.role("inbox")))], + Vec::::new(), + [("calculateTotal", json!(true))], + ) + .await; + let total = query.method_response()["total"].clone(); + let mb = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [ctx.role("inbox")]) + .await; + check_eq( + &mb.list()[0]["totalEmails"], + &total, + "totalEmails == query total", + ) +} + +async fn query_all(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + Vec::::new(), + [("calculateTotal", json!(true))], + ) + .await; + let ids = resp.ids().count(); + let total = resp.method_response()["total"].as_u64().unwrap_or(0) as usize; + check(ids > 0, "Must have at least one mailbox")?; + check_eq(ids, total, "ids length == total") +} + +async fn query_filter_by_name(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("name", json!("Test Folder A"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().collect::>(); + check(ids.contains(&ctx.mailbox("folderA")), "includes folderA") +} + +async fn query_filter_by_parent_id(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("parentId", json!(ctx.mailbox("folderA")))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().collect::>(); + check(ids.contains(&ctx.mailbox("child1")), "includes child1")?; + check(ids.contains(&ctx.mailbox("child2")), "includes child2")?; + check_eq(ids.len(), 2, "ids length") +} + +async fn query_filter_by_parent_id_null(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("parentId", Value::Null)], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().collect::>(); + check(ids.contains(&ctx.role("inbox")), "includes inbox")?; + check(ids.contains(&ctx.mailbox("folderA")), "includes folderA")?; + check(ids.contains(&ctx.mailbox("folderB")), "includes folderB")?; + check(!ids.contains(&ctx.mailbox("child1")), "excludes child1")?; + check(!ids.contains(&ctx.mailbox("child2")), "excludes child2") +} + +async fn query_filter_by_role(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("role", json!("inbox"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().collect::>(); + check_eq(ids.len(), 1, "ids length")?; + check_eq(ids[0], ctx.role("inbox"), "id is inbox") +} + +async fn query_filter_has_any_role(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("hasAnyRole", json!(true))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().map(|s| s.to_string()).collect::>(); + let get = ctx.primary.jmap_get("Mailbox", ["id", "role"], &ids).await; + for mb in get.list() { + check( + !mb["role"].is_null(), + format!("Mailbox {} should have a role", mb.id()), + )?; + } + Ok(()) +} + +async fn query_filter_has_any_role_false(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + [("hasAnyRole", json!(false))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().collect::>(); + check(ids.contains(&ctx.mailbox("folderA")), "includes folderA")?; + check(ids.contains(&ctx.mailbox("folderB")), "includes folderB") +} + +async fn query_filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_request( + &default_using(), + json!([[ + "Mailbox/query", + { "accountId": ctx.account_id(), "filter": null }, + "0" + ]]), + ) + .await; + let ids = &resp.response_at(0)["ids"]; + check(ids.is_array(), "ids must be array")?; + check( + ids.as_array().map(|a| !a.is_empty()).unwrap_or(false), + "Null filter should return mailboxes", + ) +} + +async fn query_limit(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + Vec::::new(), + [("limit", json!(2)), ("calculateTotal", json!(true))], + ) + .await; + let ids = resp.ids().count(); + check(ids <= 2, format!("Expected at most 2 results, got {ids}")) +} + +async fn query_position(ctx: &CompCtx<'_>) -> TestOutcome { + let all = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["name"], + Vec::<(String, Value)>::new(), + ) + .await; + let all_ids = all.ids().map(|s| s.to_string()).collect::>(); + if all_ids.len() < 2 { + return Ok(()); + } + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["name"], + [("position", json!(1))], + ) + .await; + let ids = resp.ids().collect::>(); + check_eq(ids[0], all_ids[1].as_str(), "first id at position 1")?; + check_eq(&resp.method_response()["position"], &json!(1), "position") +} + +async fn query_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["queryState"].is_string(), "queryState must be string")?; + check( + r["canCalculateChanges"].is_boolean(), + "canCalculateChanges must be boolean", + )?; + check(r["position"].is_number(), "position must be number")?; + check(r["ids"].is_array(), "ids must be array") +} + +async fn query_sort_by_name(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["name"], + Vec::<(String, Value)>::new(), + ) + .await; + let ids = resp.ids().map(|s| s.to_string()).collect::>(); + let get = ctx.primary.jmap_get("Mailbox", ["id", "name"], &ids).await; + let mut name_map = std::collections::HashMap::new(); + for mb in get.list() { + name_map.insert(mb.id().to_string(), mb.text_field("name").to_string()); + } + for i in 1..ids.len() { + let prev = name_map.get(&ids[i - 1]).cloned().unwrap_or_default(); + let curr = name_map.get(&ids[i]).cloned().unwrap_or_default(); + check( + prev <= curr, + format!("Expected '{prev}' <= '{curr}' in sort order"), + )?; + } + Ok(()) +} + +async fn query_sort_by_sort_order(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["sortOrder"], + Vec::<(String, Value)>::new(), + ) + .await; + check(resp.ids().count() > 0, "Must have at least one mailbox") +} + +async fn set_create_top_level(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Set Test Top Level", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let created = resp.created(0); + check(created["id"].is_string(), "created must have id")?; + let id = created.id().to_string(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + Ok(()) +} + +async fn set_create_child(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Set Test Child", "parentId": ctx.mailbox("folderA") })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&id]) + .await; + let parent = get.list()[0]["parentId"].clone(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + check_eq(&parent, &json!(ctx.mailbox("folderA")), "parentId") +} + +async fn set_create_returns_server_set_props(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Server Set Props", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let created = resp.created(0); + check(created["id"].is_string(), "Server must set id")?; + let id = created.id().to_string(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + Ok(()) +} + +async fn set_rename(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Before Rename", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + ctx.primary + .jmap_update( + "Mailbox", + [(id.clone(), json!({ "name": "After Rename" }))], + Vec::<(String, Value)>::new(), + ) + .await; + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&id]) + .await; + let name = get.list()[0]["name"].clone(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + check_eq(&name, &json!("After Rename"), "name") +} + +async fn set_change_sort_order(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Sort Order Test", "parentId": null, "sortOrder": 10 })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + ctx.primary + .jmap_update( + "Mailbox", + [(id.clone(), json!({ "sortOrder": 99 }))], + Vec::<(String, Value)>::new(), + ) + .await; + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&id]) + .await; + let sort_order = get.list()[0]["sortOrder"].clone(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + check_eq(&sort_order, &json!(99), "sortOrder") +} + +async fn set_move_parent(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Movable Folder", "parentId": ctx.mailbox("folderA") })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + ctx.primary + .jmap_update( + "Mailbox", + [(id.clone(), json!({ "parentId": ctx.mailbox("folderB") }))], + Vec::<(String, Value)>::new(), + ) + .await; + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&id]) + .await; + let parent = get.list()[0]["parentId"].clone(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + check_eq(&parent, &json!(ctx.mailbox("folderB")), "parentId") +} + +async fn set_destroy_empty(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Destroy Me", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let id = resp.created(0).id().to_string(); + let destroy = ctx + .primary + .jmap_destroy("Mailbox", [&id], Vec::<(String, Value)>::new()) + .await; + check( + destroy.method_response()["destroyed"].is_array(), + "destroyed must be an array", + )?; + let destroyed = destroy.destroyed().collect::>(); + check( + destroyed.contains(&id.as_str()), + "destroyed includes the id", + ) +} + +async fn set_destroy_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_destroy( + "Mailbox", + ["nonexistent-mailbox-xyz"], + Vec::<(String, Value)>::new(), + ) + .await; + let not_destroyed = &resp.method_response()["notDestroyed"]; + check( + !not_destroyed.is_null(), + "notDestroyed must not be null when destroying a nonexistent id", + )?; + check( + !not_destroyed["nonexistent-mailbox-xyz"].is_null(), + "notDestroyed must contain error for the id", + )?; + check_eq( + ¬_destroyed["nonexistent-mailbox-xyz"]["type"], + &json!("notFound"), + "type", + ) +} + +async fn set_duplicate_name_same_parent(ctx: &CompCtx<'_>) -> TestOutcome { + let create1 = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Duplicate Name Test", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let id1 = create1.created(0).id().to_string(); + + let create2 = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Duplicate Name Test", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let not_created = &create2.method_response()["notCreated"]["i0"]; + let outcome = check( + !not_created.is_null(), + "Server MUST reject duplicate mailbox name under same parent", + ) + .and_then(|_| check_eq(¬_created["type"], &json!("alreadyExists"), "type")); + + if let Some(created) = create2.method_response()["created"]["i0"].as_object() + && let Some(id) = created.get("id").and_then(|v| v.as_str()) + { + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + } + ctx.primary + .jmap_destroy("Mailbox", [id1], Vec::<(String, Value)>::new()) + .await; + outcome +} + +async fn set_cannot_destroy_with_children(ctx: &CompCtx<'_>) -> TestOutcome { + let parent = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Parent With Child", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let parent_id = parent.created(0).id().to_string(); + let child = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "The Child", "parentId": parent_id.clone() })], + Vec::<(String, Value)>::new(), + ) + .await; + let child_id = child.created(0).id().to_string(); + + let destroy = ctx + .primary + .jmap_destroy("Mailbox", [&parent_id], Vec::<(String, Value)>::new()) + .await; + let not_destroyed = &destroy.method_response()["notDestroyed"][&parent_id]; + let outcome = check( + !not_destroyed.is_null(), + "Server MUST refuse to destroy mailbox that has child mailboxes", + ) + .and_then(|_| check_eq(¬_destroyed["type"], &json!("mailboxHasChild"), "type")); + + ctx.primary + .jmap_destroy("Mailbox", [&child_id], Vec::<(String, Value)>::new()) + .await; + ctx.primary + .jmap_destroy("Mailbox", [&parent_id], Vec::<(String, Value)>::new()) + .await; + outcome +} + +async fn set_on_destroy_remove_emails(ctx: &CompCtx<'_>) -> TestOutcome { + let create_mb = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Temp With Email", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let mb_id = create_mb.created(0).id().to_string(); + + ctx.primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { (mb_id.clone()): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Temp email for destroy test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "Temporary email body" } }, + })], + Vec::<(String, Value)>::new(), + ) + .await; + + let destroy = ctx + .primary + .jmap_destroy( + "Mailbox", + [&mb_id], + [("onDestroyRemoveEmails", json!(true))], + ) + .await; + check( + destroy.method_response()["destroyed"].is_array(), + "destroyed must be an array", + )?; + let destroyed = destroy.destroyed().collect::>(); + check( + destroyed.contains(&mb_id.as_str()), + "destroyed includes mailbox", + ) +} + +async fn set_on_destroy_remove_emails_with_children(ctx: &CompCtx<'_>) -> TestOutcome { + let create_parent = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Parent For Destroy Child Test", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let parent_id = create_parent.created(0).id().to_string(); + let create_child = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Child Of Destroy Test", "parentId": parent_id.clone() })], + Vec::<(String, Value)>::new(), + ) + .await; + let child_id = create_child.created(0).id().to_string(); + + let create_email = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { (parent_id.clone()): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Email in parent with child", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } }, + })], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = create_email.created(0).id().to_string(); + + let destroy = ctx + .primary + .jmap_destroy( + "Mailbox", + [&parent_id], + [("onDestroyRemoveEmails", json!(true))], + ) + .await; + let not_destroyed = &destroy.method_response()["notDestroyed"][&parent_id]; + let outcome = check( + !not_destroyed.is_null(), + "Server MUST refuse to destroy mailbox with children even with onDestroyRemoveEmails", + ) + .and_then(|_| check_eq(¬_destroyed["type"], &json!("mailboxHasChild"), "type")); + + let child_get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), [&child_id]) + .await; + let child_exists = child_get.list().len(); + let email_get = ctx.primary.jmap_get("Email", ["id"], [&email_id]).await; + let email_exists = email_get.list().len(); + + ctx.primary + .jmap_destroy("Email", [&email_id], Vec::<(String, Value)>::new()) + .await; + ctx.primary + .jmap_destroy("Mailbox", [&child_id], Vec::<(String, Value)>::new()) + .await; + ctx.primary + .jmap_destroy("Mailbox", [&parent_id], Vec::<(String, Value)>::new()) + .await; + + outcome?; + check_eq(child_exists, 1, "Child mailbox must still exist")?; + check_eq(email_exists, 1, "Email in parent must still exist") +} + +async fn set_state_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "State Test", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + let old_state = r["oldState"].clone(); + let new_state = r["newState"].clone(); + let id = resp.created(0).id().to_string(); + ctx.primary + .jmap_destroy("Mailbox", [id], Vec::<(String, Value)>::new()) + .await; + check(!old_state.is_null(), "oldState present")?; + check(!new_state.is_null(), "newState present")?; + check_ne(&old_state, &new_state, "oldState != newState") +} + +async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + let state = get.state().to_string(); + let resp = ctx.primary.jmap_changes("Mailbox", &state).await; + let r = resp.method_response(); + check_eq(&r["oldState"], &json!(state), "oldState")?; + check(!r["newState"].is_null(), "newState present")?; + check_eq( + r["created"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "created empty", + )?; + check_eq( + r["updated"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "updated empty", + )?; + check_eq( + r["destroyed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "destroyed empty", + )?; + check_eq(&r["hasMoreChanges"], &json!(false), "hasMoreChanges") +} + +async fn changes_after_create(ctx: &CompCtx<'_>) -> TestOutcome { + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + let old_state = get.state().to_string(); + let set = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Temp Changes Test", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let temp_id = set.created(0).id().to_string(); + + let changes = ctx.primary.jmap_changes("Mailbox", &old_state).await; + let created = changes.method_response()["created"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + let outcome = check( + created.contains(&temp_id.as_str()), + "created includes temp mailbox", + ); + + ctx.primary + .jmap_destroy("Mailbox", [temp_id], Vec::<(String, Value)>::new()) + .await; + outcome +} + +async fn changes_after_rename(ctx: &CompCtx<'_>) -> TestOutcome { + let set = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "Before Rename", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let mb_id = set.created(0).id().to_string(); + let mid_state = set.method_response()["newState"] + .as_str() + .unwrap_or("") + .to_string(); + + ctx.primary + .jmap_update( + "Mailbox", + [(mb_id.clone(), json!({ "name": "After Rename" }))], + Vec::<(String, Value)>::new(), + ) + .await; + + let changes = ctx.primary.jmap_changes("Mailbox", &mid_state).await; + let updated = changes.method_response()["updated"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + let outcome = check( + updated.contains(&mb_id.as_str()), + "updated includes mailbox", + ); + + ctx.primary + .jmap_destroy("Mailbox", [mb_id], Vec::<(String, Value)>::new()) + .await; + outcome +} + +async fn changes_has_more_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + let state = get.state().to_string(); + let resp = ctx.primary.jmap_changes("Mailbox", &state).await; + check( + resp.method_response()["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + ) +} + +async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let get = ctx + .primary + .jmap_get("Mailbox", Vec::::new(), Vec::::new()) + .await; + let state = get.state().to_string(); + let resp = ctx.primary.jmap_changes("Mailbox", &state).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["oldState"].is_string(), "oldState must be string")?; + check(r["newState"].is_string(), "newState must be string")?; + check( + r["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + )?; + check(r["created"].is_array(), "created must be array")?; + check(r["updated"].is_array(), "updated must be array")?; + check(r["destroyed"].is_array(), "destroyed must be array") +} + +fn mailbox_query_changes( + ctx: &CompCtx<'_>, + filter: Value, + sort: Option, + since_query_state: &str, +) -> Value { + let mut args = json!({ + "accountId": ctx.account_id(), + "filter": filter, + "sinceQueryState": since_query_state, + }); + if let Some(sort) = sort { + args.as_object_mut().unwrap().insert("sort".into(), sort); + } + json!([["Mailbox/queryChanges", args, "0"]]) +} + +async fn query_changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let sort = json!([{ "property": "name", "isAscending": true }]); + let query = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["name"], + Vec::<(String, Value)>::new(), + ) + .await; + let query_state = query.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let calls = mailbox_query_changes(ctx, json!({}), Some(sort), &query_state); + let resp = ctx.primary.jmap_request(&default_using(), calls).await; + let r = resp.response_at(0); + check_eq(&r["oldQueryState"], &json!(query_state), "oldQueryState")?; + check_eq( + r["removed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "removed empty", + )?; + check_eq( + r["added"].as_array().map(|a| a.len()).unwrap_or(usize::MAX), + 0, + "added empty", + ) +} + +async fn query_changes_after_create(ctx: &CompCtx<'_>) -> TestOutcome { + let sort = json!([{ "property": "name", "isAscending": true }]); + let query = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + ["name"], + Vec::<(String, Value)>::new(), + ) + .await; + let old_query_state = query.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let set = ctx + .primary + .jmap_create( + "Mailbox", + [json!({ "name": "QC Test Mailbox", "parentId": null })], + Vec::<(String, Value)>::new(), + ) + .await; + let new_id = set.created(0).id().to_string(); + + let calls = mailbox_query_changes(ctx, json!({}), Some(sort), &old_query_state); + let resp = ctx.primary.jmap_request(&default_using(), calls).await; + let added_ids = resp.response_at(0)["added"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v["id"].as_str()) + .map(|s| s.to_string()) + .collect::>() + }) + .unwrap_or_default(); + let outcome = check( + added_ids.contains(&new_id), + "added includes the new mailbox", + ); + + ctx.primary + .jmap_destroy("Mailbox", [new_id], Vec::<(String, Value)>::new()) + .await; + outcome +} + +async fn query_changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_query( + "Mailbox", + Vec::<(String, Value)>::new(), + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let query_state = query.method_response()["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let calls = mailbox_query_changes(ctx, json!({}), None, &query_state); + let resp = ctx.primary.jmap_request(&default_using(), calls).await; + let r = resp.response_at(0); + check(r["accountId"].is_string(), "accountId must be string")?; + check( + r["oldQueryState"].is_string(), + "oldQueryState must be string", + )?; + check( + r["newQueryState"].is_string(), + "newQueryState must be string", + )?; + check(r["removed"].is_array(), "removed must be array")?; + check(r["added"].is_array(), "added must be array") +} + +async fn query_changes_filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_request( + &default_using(), + json!([[ + "Mailbox/query", + { "accountId": ctx.account_id(), "filter": null }, + "0" + ]]), + ) + .await; + let query_state = query.response_at(0)["queryState"] + .as_str() + .unwrap_or("") + .to_string(); + + let calls = mailbox_query_changes(ctx, Value::Null, None, &query_state); + let resp = ctx.primary.jmap_request(&default_using(), calls).await; + let r = resp.response_at(0); + check( + r["oldQueryState"].is_string(), + "oldQueryState must be string", + )?; + check( + r["newQueryState"].is_string(), + "newQueryState must be string", + )?; + check(r["removed"].is_array(), "removed must be array")?; + check(r["added"].is_array(), "added must be array") +} diff --git a/tests/src/jmap/compliance/mod.rs b/tests/src/jmap/compliance/mod.rs new file mode 100644 index 0000000..5f23f4c --- /dev/null +++ b/tests/src/jmap/compliance/mod.rs @@ -0,0 +1,1181 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::account::Account; +use crate::utils::jmap::JmapUtils; +use crate::utils::server::{DestroyAllMailboxes, TestServer}; +use base64::{Engine, engine::general_purpose}; +use chrono::{Duration as ChronoDuration, Utc}; +use futures::FutureExt; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::Action, +}; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::future::Future; +use std::panic::AssertUnwindSafe; + +pub mod binary; +pub mod core; +pub mod email; +pub mod identity; +pub mod mailbox; +pub mod push; +pub mod search_snippet; +pub mod submission; +pub mod thread; +pub mod vacation; + +#[derive(Debug)] +pub enum Fail { + Assert(String), + Skip(String), +} + +pub type TestOutcome = Result<(), Fail>; + +pub fn check(cond: bool, msg: impl std::fmt::Display) -> TestOutcome { + if cond { + Ok(()) + } else { + Err(Fail::Assert(msg.to_string())) + } +} + +pub fn check_eq( + actual: T, + expected: T, + msg: impl std::fmt::Display, +) -> TestOutcome { + if actual == expected { + Ok(()) + } else { + Err(Fail::Assert(format!( + "{msg}: expected {expected:?}, got {actual:?}" + ))) + } +} + +pub fn check_ne( + actual: T, + not_expected: T, + msg: impl std::fmt::Display, +) -> TestOutcome { + if actual != not_expected { + Ok(()) + } else { + Err(Fail::Assert(format!( + "{msg}: expected value to differ from {not_expected:?}" + ))) + } +} + +pub fn check_contains(haystack: &str, needle: &str, msg: impl std::fmt::Display) -> TestOutcome { + if haystack.contains(needle) { + Ok(()) + } else { + Err(Fail::Assert(format!( + "{msg}: expected to contain {needle:?}, got {:?}", + haystack.chars().take(200).collect::() + ))) + } +} + +pub fn skip(reason: impl std::fmt::Display) -> TestOutcome { + Err(Fail::Skip(reason.to_string())) +} + +#[derive(Clone)] +struct Record { + id: String, + status: &'static str, + detail: String, +} + +pub struct CompCtx<'x> { + pub primary: &'x Account, + pub secondary: &'x Account, + pub account_id: String, + pub secondary_account_id: String, + pub session: Value, + pub upload_url_tmpl: String, + pub download_url_tmpl: String, + pub event_source_url_tmpl: String, + pub mailbox_ids: HashMap, + pub email_ids: HashMap, + pub blob_ids: HashMap, + pub role_mailboxes: HashMap, + pub identity_ids: Vec, + pub identity_email: String, + pub secondary_email: String, + pub cross_account_id: Option, + results: RefCell>, +} + +impl<'x> CompCtx<'x> { + pub fn account_id(&self) -> &str { + &self.account_id + } + + pub fn mailbox(&self, key: &str) -> &str { + self.mailbox_ids + .get(key) + .unwrap_or_else(|| panic!("Seed mailbox {key} not found")) + } + + pub fn role(&self, key: &str) -> &str { + self.role_mailboxes + .get(key) + .unwrap_or_else(|| panic!("Role mailbox {key} not found")) + } + + pub fn role_opt(&self, key: &str) -> Option<&str> { + self.role_mailboxes.get(key).map(|s| s.as_str()) + } + + pub fn email(&self, key: &str) -> &str { + self.email_ids + .get(key) + .unwrap_or_else(|| panic!("Seed email {key} not found")) + } + + pub fn blob(&self, key: &str) -> &str { + self.blob_ids + .get(key) + .unwrap_or_else(|| panic!("Seed blob {key} not found")) + } + + pub fn upload_url(&self, account_id: &str) -> String { + self.upload_url_tmpl.replace("{accountId}", account_id) + } + + pub fn download_url(&self, account_id: &str, blob_id: &str, type_: &str, name: &str) -> String { + self.download_url_tmpl + .replace("{accountId}", account_id) + .replace("{blobId}", blob_id) + .replace("{type}", &pct(type_)) + .replace("{name}", &pct(name)) + } + + pub fn event_source_url(&self, types: &str, closeafter: &str, ping: &str) -> String { + self.event_source_url_tmpl + .replace("{types}", types) + .replace("{closeafter}", closeafter) + .replace("{ping}", ping) + } + + pub async fn upload(&self, account: &Account, content_type: &str, data: Vec) -> Value { + let url = self.upload_url(account.id_string()); + let resp = account.http_post_raw(&url, content_type, data).await; + resp.json().unwrap_or_else(|| { + panic!( + "Upload returned non-JSON ({}): {}", + resp.status, + resp.text() + ) + }) + } + + pub async fn run>(&self, id: &'static str, fut: F) { + let result = AssertUnwindSafe(fut).catch_unwind().await; + let (status, detail): (&'static str, String) = match result { + Ok(Ok(())) => ("PASS", String::new()), + Ok(Err(Fail::Assert(m))) => ("FAIL", m), + Ok(Err(Fail::Skip(m))) => ("SKIP", m), + Err(panic) => ("FAIL", panic_to_string(panic)), + }; + let marker = match status { + "PASS" => "\u{2713}", + "SKIP" => "\u{2298}", + _ => "\u{2717}", + }; + if detail.is_empty() { + println!(" [{marker}] {status:<4} {id}"); + } else { + let first = detail.lines().next().unwrap_or(""); + let first: String = first.chars().take(160).collect(); + println!(" [{marker}] {status:<4} {id} ({first})"); + } + self.results.borrow_mut().push(Record { + id: id.to_string(), + status, + detail, + }); + } + + fn summary(&self) -> usize { + let results = self.results.borrow(); + let (mut pass, mut fail, mut skip_n) = (0u32, 0u32, 0u32); + for r in results.iter() { + match r.status { + "PASS" => pass += 1, + "FAIL" => fail += 1, + _ => skip_n += 1, + } + } + + println!("\n================ JMAP Compliance Summary ================"); + println!("Ported tests run: {}", results.len()); + println!(" PASS: {pass} FAIL: {fail} SKIP: {skip_n}"); + println!("\nMachine-readable results (COMPLIANCE_RESULT lines):"); + for r in results.iter() { + println!("COMPLIANCE_RESULT\t{}\t{}", r.status, r.id); + } + println!("\nFailure details:"); + for r in results + .iter() + .filter(|r| r.status == "FAIL" && !r.detail.is_empty()) + { + println!( + "COMPLIANCE_DETAIL\t{}\t{}", + r.id, + r.detail.replace('\n', " ") + ); + } + println!("========================================================\n"); + fail as usize + } +} + +fn panic_to_string(e: Box) -> String { + if let Some(s) = e.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = e.downcast_ref::() { + s.clone() + } else { + "panic (non-string payload)".to_string() + } +} + +fn pct(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +pub async fn test(test: &TestServer) { + println!("Running JMAP compliance tests (port of jmap-test-suite)..."); + + // Suppress panic backtraces during the run; failing tests panic by design. + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + + let primary = test.account("jdoe@example.com"); + let secondary = test.account("jane.smith@example.com"); + + let cross_account_id = setup_cross_account(test, primary).await; + + let ctx = build_ctx(primary, secondary, cross_account_id).await; + + test.wait_for_tasks().await; + + core::run(&ctx).await; + identity::run(&ctx).await; + mailbox::run(&ctx).await; + thread::run(&ctx).await; + binary::run(&ctx).await; + search_snippet::run(&ctx).await; + vacation::run(&ctx).await; + submission::run(&ctx).await; + push::run(test, &ctx).await; + email::run(&ctx).await; + + let failed = ctx.summary(); + + std::panic::set_hook(prev_hook); + + teardown(&ctx).await; + + if failed > 0 { + panic!( + "{failed} JMAP compliance test(s) failed (see COMPLIANCE_RESULT/COMPLIANCE_DETAIL above)" + ); + } +} + +async fn setup_cross_account(test: &TestServer, primary: &Account) -> Option { + let admin = test.account("admin"); + let group = test.account("sales@example.com"); + let group_id = group.id_string().to_string(); + + let mut member_groups = serde_json::Map::new(); + member_groups.insert(group_id.clone(), Value::Bool(true)); + admin + .registry_update( + ObjectType::Account, + [( + primary.id(), + json!({ Property::MemberGroupIds: member_groups }), + )], + ) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; + + let mb = primary + .jmap_method_call( + "Mailbox/get", + json!({ "accountId": group_id, "ids": null, "properties": ["id", "role"] }), + ) + .await; + let has_inbox = + mb.0.pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .map(|a| a.iter().any(|m| m["role"] == "inbox")) + .unwrap_or(false); + if !has_inbox { + primary + .jmap_method_call( + "Mailbox/set", + json!({ + "accountId": group_id, + "create": { "inbox": { "name": "Inbox", "role": "inbox" } } + }), + ) + .await; + } + + Some(group_id) +} + +async fn build_ctx<'x>( + primary: &'x Account, + secondary: &'x Account, + cross_account_id: Option, +) -> CompCtx<'x> { + let session = primary.jmap_session_object().await.into_inner(); + let account_id = primary.id_string().to_string(); + let secondary_account_id = secondary.id_string().to_string(); + + let upload_url_tmpl = session + .pointer("/uploadUrl") + .and_then(|v| v.as_str()) + .unwrap_or("https://127.0.0.1:8899/jmap/upload/{accountId}/") + .to_string(); + let download_url_tmpl = session + .pointer("/downloadUrl") + .and_then(|v| v.as_str()) + .unwrap_or("https://127.0.0.1:8899/jmap/download/{accountId}/{blobId}/{type}/{name}") + .to_string(); + let event_source_url_tmpl = session + .pointer("/eventSourceUrl") + .and_then(|v| v.as_str()) + .unwrap_or( + "https://127.0.0.1:8899/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", + ) + .to_string(); + + let mut ctx = CompCtx { + primary, + secondary, + account_id, + secondary_account_id, + session, + upload_url_tmpl, + download_url_tmpl, + event_source_url_tmpl, + mailbox_ids: HashMap::new(), + email_ids: HashMap::new(), + blob_ids: HashMap::new(), + role_mailboxes: HashMap::new(), + identity_ids: Vec::new(), + identity_email: String::new(), + secondary_email: String::new(), + cross_account_id, + results: RefCell::new(Vec::new()), + }; + + discover_roles(&mut ctx).await; + seed_mailboxes(&mut ctx).await; + seed_blobs(&mut ctx).await; + seed_emails(&mut ctx).await; + discover_identities(&mut ctx).await; + + println!( + "Seeded: {} mailboxes, {} emails, {} blobs, {} identities (inbox role: {})", + ctx.mailbox_ids.len(), + ctx.email_ids.len(), + ctx.blob_ids.len(), + ctx.identity_ids.len(), + ctx.role_mailboxes.contains_key("inbox"), + ); + + ctx +} + +async fn discover_roles(ctx: &mut CompCtx<'_>) { + let resp = ctx + .primary + .jmap_get("Mailbox", ["id", "name", "role"], Vec::::new()) + .await; + if let Some(list) = resp + .0 + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + { + for mb in list { + if let (Some(id), Some(role)) = ( + mb.pointer("/id").and_then(|v| v.as_str()), + mb.pointer("/role").and_then(|v| v.as_str()), + ) { + ctx.role_mailboxes.insert(role.to_string(), id.to_string()); + } + } + } +} + +async fn seed_mailboxes(ctx: &mut CompCtx<'_>) { + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [ + json!({ "name": "Test Folder A", "parentId": null }), + json!({ "name": "Test Folder B", "parentId": null }), + ], + Vec::<(String, Value)>::new(), + ) + .await; + let folder_a = resp.created(0).id().to_string(); + let folder_b = resp.created(1).id().to_string(); + ctx.mailbox_ids.insert("folderA".into(), folder_a.clone()); + ctx.mailbox_ids.insert("folderB".into(), folder_b.clone()); + + let resp = ctx + .primary + .jmap_create( + "Mailbox", + [ + json!({ "name": "Child 1", "parentId": folder_a }), + json!({ "name": "Child 2", "parentId": folder_a }), + ], + Vec::<(String, Value)>::new(), + ) + .await; + ctx.mailbox_ids + .insert("child1".into(), resp.created(0).id().to_string()); + ctx.mailbox_ids + .insert("child2".into(), resp.created(1).id().to_string()); +} + +async fn seed_blobs(ctx: &mut CompCtx<'_>) { + let mut pdf = vec![ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a, + ]; + pdf.extend(std::iter::repeat_n(0x20, 100)); + let pdf_resp = ctx.upload(ctx.primary, "application/pdf", pdf).await; + ctx.blob_ids + .insert("pdf".into(), pdf_resp.blob_id().to_string()); + + let jpeg = vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, + ]; + let jpeg_resp = ctx.upload(ctx.primary, "image/jpeg", jpeg).await; + ctx.blob_ids + .insert("jpeg".into(), jpeg_resp.blob_id().to_string()); +} + +struct SeedEmail { + key: &'static str, + rfc5322: String, + mailbox_ids: Vec, + keywords: Vec<&'static str>, + received_at: String, +} + +async fn seed_emails(ctx: &mut CompCtx<'_>) { + let now = Utc::now(); + let days_ago = |d: i64| (now - ChronoDuration::days(d)).to_rfc3339(); + let hours_ago = |h: i64| (now - ChronoDuration::hours(h)).to_rfc3339(); + let date_days_ago = |d: i64| (now - ChronoDuration::days(d)).to_rfc2822(); + let date_hours_ago = |h: i64| (now - ChronoDuration::hours(h)).to_rfc2822(); + + let inbox = ctx.role("inbox").to_string(); + let drafts = ctx.role_opt("drafts").unwrap_or(&inbox).to_string(); + let folder_a = ctx.mailbox("folderA").to_string(); + let folder_b = ctx.mailbox("folderB").to_string(); + let child1 = ctx.mailbox("child1").to_string(); + + let emails: Vec = vec![ + SeedEmail { + key: "plain-simple", + rfc5322: build_message(MsgOpts { + from: "Alice Sender ", + to: "testuser@example.com", + subject: "Meeting tomorrow morning", + date: &date_days_ago(10), + message_id: "", + body: "Let's meet tomorrow at 9am in the conference room.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: days_ago(10), + }, + SeedEmail { + key: "html-attachment", + rfc5322: build_multipart_mixed( + "Bob Jones ", + "testuser@example.com", + Some("charlie@example.net"), + "Q3 Financial Report", + &date_days_ago(9), + "", + "

Q3 Report

Please find the report attached.

", + "report.pdf", + "application/pdf", + ), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen", "$flagged"], + received_at: days_ago(9), + }, + SeedEmail { + key: "thread-starter", + rfc5322: build_message(MsgOpts { + from: "testuser@example.com", + to: "alice@example.com", + subject: "Project Alpha Discussion", + date: &date_days_ago(8), + message_id: "", + body: "I'd like to discuss the Project Alpha timeline.", + ..Default::default() + }), + mailbox_ids: vec![folder_a.clone()], + keywords: vec!["$seen"], + received_at: days_ago(8), + }, + SeedEmail { + key: "thread-reply-1", + rfc5322: build_message(MsgOpts { + from: "Alice Sender ", + to: "testuser@example.com", + subject: "Re: Project Alpha Discussion", + date: &date_days_ago(7), + message_id: "", + in_reply_to: "", + references: "", + body: "Sure, let's discuss. How about Thursday?", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec![], + received_at: days_ago(7), + }, + SeedEmail { + key: "thread-reply-2", + rfc5322: build_message(MsgOpts { + from: "Bob Jones ", + to: "testuser@example.com, alice@example.com", + subject: "Re: Project Alpha Discussion", + date: &date_days_ago(6), + message_id: "", + in_reply_to: "", + references: " ", + body: "Thursday works for me. I'll bring the xylophone presentation materials.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$answered"], + received_at: days_ago(6), + }, + SeedEmail { + key: "multi-mailbox", + rfc5322: build_message(MsgOpts { + from: "David Cross ", + to: "testuser@example.com", + subject: "Cross-filed document", + date: &date_days_ago(5), + message_id: "", + body: "This document should appear in multiple folders.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone(), folder_a.clone()], + keywords: vec!["$seen"], + received_at: days_ago(5), + }, + SeedEmail { + key: "large-email", + rfc5322: build_message(MsgOpts { + from: "Eve Large ", + to: "testuser@example.com", + subject: "Detailed analysis with data", + date: &date_days_ago(4), + message_id: "", + body: &format!( + "Start of analysis. {}End of analysis.", + "This is a detailed paragraph of analysis text that covers various topics. " + .repeat(700) + ), + ..Default::default() + }), + mailbox_ids: vec![folder_b.clone()], + keywords: vec![], + received_at: days_ago(4), + }, + SeedEmail { + key: "html-only", + rfc5322: build_multipart_alternative( + "Frank Newsletter ", + "testuser@example.com", + "Newsletter: Weekly Digest", + &date_days_ago(3), + "", + "Weekly Digest - plain text version", + "

Weekly Digest

Here is your weekly digest of news.

", + ), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: days_ago(3), + }, + SeedEmail { + key: "no-subject", + rfc5322: build_message(MsgOpts { + from: "Grace Minimal ", + to: "testuser@example.com", + subject: "", + date: &date_days_ago(2), + message_id: "", + body: "This message has no subject.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: days_ago(2), + }, + SeedEmail { + key: "custom-keywords", + rfc5322: build_message(MsgOpts { + from: "Henry Tags ", + to: "testuser@example.com", + subject: "Tagged message", + date: &date_days_ago(1), + message_id: "", + body: "This message has custom keywords applied.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen", "$forwarded", "custom_label"], + received_at: days_ago(1), + }, + SeedEmail { + key: "very-old", + rfc5322: build_message(MsgOpts { + from: "Iris Archive ", + to: "testuser@example.com", + subject: "Archived correspondence", + date: &date_days_ago(30), + message_id: "", + body: "This is an old archived email from a month ago.", + ..Default::default() + }), + mailbox_ids: vec![folder_a.clone()], + keywords: vec!["$seen"], + received_at: days_ago(30), + }, + SeedEmail { + key: "bcc-email", + rfc5322: build_message(MsgOpts { + from: "testuser@example.com", + to: "jack@example.com", + bcc: "secret@example.com", + subject: "Confidential note", + date: &date_days_ago(2), + message_id: "", + body: "This is a confidential message with a BCC recipient.", + ..Default::default() + }), + mailbox_ids: vec![folder_a.clone()], + keywords: vec!["$seen", "$draft"], + received_at: days_ago(2), + }, + SeedEmail { + key: "special-headers", + rfc5322: build_message(MsgOpts { + from: "List Admin ", + to: "testuser@example.com", + subject: "Mailing list post", + date: &date_days_ago(1), + message_id: "", + body: "This is a post from a mailing list.", + extra_headers: vec![ + "List-Post: ", + "List-Unsubscribe: ", + "X-Custom-Header: custom-value-12345", + ], + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: days_ago(1), + }, + SeedEmail { + key: "multipart-related", + rfc5322: build_multipart_related( + "Kate Images ", + "testuser@example.com", + "Image embedded email", + &date_hours_ago(12), + "", + "

See the image below:

", + "image001@test", + ), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: hours_ago(12), + }, + SeedEmail { + key: "intl-sender", + rfc5322: build_message(MsgOpts { + from: "=?UTF-8?B?6YeR5Z+O5q2m?= ", + to: "testuser@example.com", + subject: "=?UTF-8?B?44GT44KT44Gr44Gh44Gv?=", + date: &date_hours_ago(6), + message_id: "", + body: "This message has an internationalized sender name and subject.", + ..Default::default() + }), + mailbox_ids: vec![inbox.clone()], + keywords: vec![], + received_at: hours_ago(6), + }, + SeedEmail { + key: "sort-test-1", + rfc5322: build_message(MsgOpts { + from: "Zara First ", + to: "testuser@example.com", + subject: "Alpha sort test", + date: &date_days_ago(5), + message_id: "", + body: &"A".repeat(100), + ..Default::default() + }), + mailbox_ids: vec![folder_b.clone()], + keywords: vec!["$seen"], + received_at: days_ago(3), + }, + SeedEmail { + key: "sort-test-2", + rfc5322: build_message(MsgOpts { + from: "Amy Second ", + to: "testuser@example.com", + subject: "Beta sort test", + date: &date_days_ago(3), + message_id: "", + body: &"B".repeat(500), + ..Default::default() + }), + mailbox_ids: vec![folder_b.clone()], + keywords: vec!["$seen", "$flagged"], + received_at: days_ago(2), + }, + SeedEmail { + key: "sort-test-3", + rfc5322: build_message(MsgOpts { + from: "Mike Third ", + to: "testuser@example.com", + subject: "Gamma sort test", + date: &date_days_ago(1), + message_id: "", + body: &"C".repeat(50), + ..Default::default() + }), + mailbox_ids: vec![folder_b.clone()], + keywords: vec![], + received_at: days_ago(1), + }, + SeedEmail { + key: "draft-for-submission", + rfc5322: build_message(MsgOpts { + from: "jdoe@example.com", + to: "jane.smith@example.com", + subject: "Test submission email", + date: &date_hours_ago(1), + message_id: "", + body: "This email will be used for submission testing.", + ..Default::default() + }), + mailbox_ids: vec![drafts.clone()], + keywords: vec!["$seen", "$draft"], + received_at: hours_ago(1), + }, + SeedEmail { + key: "child-mailbox-email", + rfc5322: build_message(MsgOpts { + from: "Nancy Nested ", + to: "testuser@example.com", + subject: "In nested folder", + date: &date_days_ago(5), + message_id: "", + body: "This email lives in a nested child mailbox.", + ..Default::default() + }), + mailbox_ids: vec![child1.clone()], + keywords: vec!["$seen"], + received_at: days_ago(5), + }, + SeedEmail { + key: "korean-euckr", + rfc5322: { + let body_b64 = general_purpose::STANDARD.encode([ + 0xc5, 0xd7, 0xbd, 0xba, 0xc6, 0xae, 0x20, 0xc0, 0xcc, 0xb8, 0xde, 0xc0, 0xcf, + 0xc0, 0xd4, 0xb4, 0xcf, 0xb4, 0xd9, + ]); + [ + "From: =?EUC-KR?B?seS/tbjR?= ", + "To: testuser@example.com", + "Subject: =?EUC-KR?B?sNa0z7TZx9Cw+A==?=", + &format!("Date: {}", date_hours_ago(5)), + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=EUC-KR", + "Content-Transfer-Encoding: base64", + "", + &body_b64, + ] + .join("\r\n") + }, + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: hours_ago(5), + }, + SeedEmail { + key: "invalid-ascii", + rfc5322: [ + "From: broken@example.com", + "To: testuser@example.com", + "Subject: Malformed email test", + &format!("Date: {}", date_hours_ago(4)), + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=us-ascii", + "X-Broken-Header: value with \u{01}\u{02} control chars", + "", + "This email has some issues.\r\n", + &format!( + "It has a line that is way too long: {}\r\n", + "x".repeat(1000) + ), + "And some 8-bit chars in ASCII: caf\u{e9} na\u{ef}ve r\u{e9}sum\u{e9}\r\n", + "End of message.", + ] + .join("\r\n"), + mailbox_ids: vec![inbox.clone()], + keywords: vec!["$seen"], + received_at: hours_ago(4), + }, + ]; + + for batch in emails.chunks(5) { + import_batch(ctx, batch).await; + } +} + +async fn import_batch(ctx: &mut CompCtx<'_>, batch: &[SeedEmail]) { + let mut blob_ids: HashMap<&str, String> = HashMap::new(); + for email in batch { + let resp = ctx + .upload( + ctx.primary, + "message/rfc5322", + email.rfc5322.clone().into_bytes(), + ) + .await; + blob_ids.insert(email.key, resp.blob_id().to_string()); + } + + let mut import_map = serde_json::Map::new(); + for email in batch { + let keywords: serde_json::Map = email + .keywords + .iter() + .map(|k| (k.to_string(), Value::Bool(true))) + .collect(); + let mailboxes: serde_json::Map = email + .mailbox_ids + .iter() + .map(|m| (m.clone(), Value::Bool(true))) + .collect(); + import_map.insert( + email.key.to_string(), + json!({ + "blobId": blob_ids[email.key], + "mailboxIds": mailboxes, + "keywords": keywords, + "receivedAt": email.received_at, + }), + ); + } + + let resp = ctx + .primary + .jmap_method_call( + "Email/import", + json!({ "accountId": ctx.account_id, "emails": import_map }), + ) + .await; + + for email in batch { + if let Some(id) = resp + .0 + .pointer(&format!("/methodResponses/0/1/created/{}/id", email.key)) + .and_then(|v| v.as_str()) + { + ctx.email_ids.insert(email.key.to_string(), id.to_string()); + } else if let Some(err) = resp + .0 + .pointer(&format!("/methodResponses/0/1/notCreated/{}", email.key)) + { + println!(" Warning: failed to import '{}': {}", email.key, err); + } + } +} + +async fn discover_identities(ctx: &mut CompCtx<'_>) { + let resp = ctx + .primary + .jmap_method_call( + "Identity/get", + json!({ "accountId": ctx.account_id, "ids": null }), + ) + .await; + if let Some(list) = resp + .0 + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + { + for ident in list { + if let Some(id) = ident.pointer("/id").and_then(|v| v.as_str()) { + ctx.identity_ids.push(id.to_string()); + } + } + if let Some(email) = list + .first() + .and_then(|i| i.pointer("/email")) + .and_then(|v| v.as_str()) + { + ctx.identity_email = email.to_string(); + } + } + + let resp = ctx + .secondary + .jmap_method_call( + "Identity/get", + json!({ "accountId": ctx.secondary_account_id, "ids": null }), + ) + .await; + if let Some(email) = resp + .0 + .pointer("/methodResponses/0/1/list/0/email") + .and_then(|v| v.as_str()) + { + ctx.secondary_email = email.to_string(); + } +} + +async fn teardown(ctx: &CompCtx<'_>) { + for account in [ctx.primary, ctx.secondary] { + let emails = account + .jmap_query( + "Email", + Vec::<(String, Value)>::new(), + Vec::::new(), + [("limit", Value::from(10000))], + ) + .await; + let ids: Vec = emails + .0 + .pointer("/methodResponses/0/1/ids") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + if !ids.is_empty() { + account + .jmap_destroy("Email", ids, Vec::<(String, Value)>::new()) + .await; + } + account.jmap_client().await.destroy_all_mailboxes().await; + } +} + +// --- RFC 5322 message builders (ports of seed-data.ts) --- + +#[derive(Default)] +struct MsgOpts<'a> { + from: &'a str, + to: &'a str, + cc: &'a str, + bcc: &'a str, + subject: &'a str, + date: &'a str, + message_id: &'a str, + in_reply_to: &'a str, + references: &'a str, + body: &'a str, + extra_headers: Vec<&'a str>, +} + +fn build_message(o: MsgOpts) -> String { + let mut lines = vec![format!("From: {}", o.from), format!("To: {}", o.to)]; + if !o.cc.is_empty() { + lines.push(format!("Cc: {}", o.cc)); + } + if !o.bcc.is_empty() { + lines.push(format!("Bcc: {}", o.bcc)); + } + lines.push(format!("Subject: {}", o.subject)); + lines.push(format!("Date: {}", o.date)); + lines.push(format!("Message-ID: {}", o.message_id)); + if !o.in_reply_to.is_empty() { + lines.push(format!("In-Reply-To: {}", o.in_reply_to)); + } + if !o.references.is_empty() { + lines.push(format!("References: {}", o.references)); + } + lines.push("MIME-Version: 1.0".into()); + lines.push("Content-Type: text/plain; charset=UTF-8".into()); + lines.push("Content-Transfer-Encoding: 7bit".into()); + for h in &o.extra_headers { + lines.push(h.to_string()); + } + lines.push(String::new()); + lines.push(o.body.to_string()); + lines.join("\r\n") +} + +#[allow(clippy::too_many_arguments)] +fn build_multipart_mixed( + from: &str, + to: &str, + cc: Option<&str>, + subject: &str, + date: &str, + message_id: &str, + html_body: &str, + attachment_name: &str, + attachment_type: &str, +) -> String { + let boundary = "----=_Part_001_boundary"; + let mut lines = vec![format!("From: {from}"), format!("To: {to}")]; + if let Some(cc) = cc { + lines.push(format!("Cc: {cc}")); + } + lines.push(format!("Subject: {subject}")); + lines.push(format!("Date: {date}")); + lines.push(format!("Message-ID: {message_id}")); + lines.push("MIME-Version: 1.0".into()); + lines.push(format!( + "Content-Type: multipart/mixed; boundary=\"{boundary}\"" + )); + lines.push(String::new()); + lines.push(format!("--{boundary}")); + lines.push("Content-Type: text/html; charset=UTF-8".into()); + lines.push("Content-Transfer-Encoding: 7bit".into()); + lines.push(String::new()); + lines.push(html_body.to_string()); + lines.push(format!("--{boundary}")); + lines.push(format!( + "Content-Type: {attachment_type}; name=\"{attachment_name}\"" + )); + lines.push(format!( + "Content-Disposition: attachment; filename=\"{attachment_name}\"" + )); + lines.push("Content-Transfer-Encoding: base64".into()); + lines.push(String::new()); + lines.push( + general_purpose::STANDARD + .encode("%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"), + ); + lines.push(format!("--{boundary}--")); + lines.join("\r\n") +} + +fn build_multipart_alternative( + from: &str, + to: &str, + subject: &str, + date: &str, + message_id: &str, + text_body: &str, + html_body: &str, +) -> String { + let boundary = "----=_Alt_001_boundary"; + vec![ + format!("From: {from}"), + format!("To: {to}"), + format!("Subject: {subject}"), + format!("Date: {date}"), + format!("Message-ID: {message_id}"), + "MIME-Version: 1.0".into(), + format!("Content-Type: multipart/alternative; boundary=\"{boundary}\""), + String::new(), + format!("--{boundary}"), + "Content-Type: text/plain; charset=UTF-8".into(), + "Content-Transfer-Encoding: 7bit".into(), + String::new(), + text_body.to_string(), + format!("--{boundary}"), + "Content-Type: text/html; charset=UTF-8".into(), + "Content-Transfer-Encoding: 7bit".into(), + String::new(), + html_body.to_string(), + format!("--{boundary}--"), + ] + .join("\r\n") +} + +fn build_multipart_related( + from: &str, + to: &str, + subject: &str, + date: &str, + message_id: &str, + html_body: &str, + inline_image_cid: &str, +) -> String { + let boundary = "----=_Rel_001_boundary"; + let jpeg = general_purpose::STANDARD.encode([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xd9, + ]); + vec![ + format!("From: {from}"), + format!("To: {to}"), + format!("Subject: {subject}"), + format!("Date: {date}"), + format!("Message-ID: {message_id}"), + "MIME-Version: 1.0".into(), + format!("Content-Type: multipart/related; boundary=\"{boundary}\""), + String::new(), + format!("--{boundary}"), + "Content-Type: text/html; charset=UTF-8".into(), + "Content-Transfer-Encoding: 7bit".into(), + String::new(), + html_body.to_string(), + format!("--{boundary}"), + "Content-Type: image/jpeg".into(), + format!("Content-ID: <{inline_image_cid}>"), + "Content-Disposition: inline".into(), + "Content-Transfer-Encoding: base64".into(), + String::new(), + jpeg, + format!("--{boundary}--"), + ] + .join("\r\n") +} diff --git a/tests/src/jmap/compliance/push.rs b/tests/src/jmap/compliance/push.rs new file mode 100644 index 0000000..6c9ce80 --- /dev/null +++ b/tests/src/jmap/compliance/push.rs @@ -0,0 +1,628 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq}; +use crate::{AssertConfig, utils::server::TestServer}; +use common::{config::server::Listeners, network::SessionData}; +use futures::StreamExt; +use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body}; +use hyper::{body, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::{ObjectType, SocketAddr}, + structs::{NetworkListener, SystemSettings}, + }, + types::{id::ObjectId, map::Map}, +}; +use serde_json::{Value, json}; +use std::{str::FromStr, time::Duration}; +use store::registry::{RegistryObject, bootstrap::Bootstrap}; +use tokio::sync::{Mutex, mpsc}; + +const PUSH_URL: &str = "https://127.0.0.1:19000/push"; + +struct PushState { + rx: Mutex>, +} + +#[derive(Clone)] +struct SessionManager { + tx: mpsc::Sender, +} + +pub async fn run(test: &TestServer, ctx: &CompCtx<'_>) { + println!("[compliance] push"); + + let (event_tx, event_rx) = mpsc::channel::(100); + + let mut bp = Bootstrap::new_uninitialized(test.server.registry().clone()); + let mut servers = Listeners::default(); + servers.parse_server( + &mut bp, + RegistryObject { + id: ObjectId::new(ObjectType::NetworkListener, 0u64.into()), + object: NetworkListener { + name: "mock-push-compliance".into(), + bind: Map::new(vec![SocketAddr::from_str("127.0.0.1:19000").unwrap()]), + protocol: NetworkListenerProtocol::Http, + tls_implicit: true, + use_tls: true, + socket_reuse_address: true, + socket_reuse_port: true, + ..Default::default() + }, + revision: 0, + }, + &SystemSettings::default(), + ); + servers + .parse_tcp_acceptors(&mut bp, test.server.inner.clone()) + .await; + servers.bind_and_drop_priv(&mut bp); + bp.assert_no_errors(); + let _shutdown_tx = servers.spawn(|server, acceptor, shutdown_rx| { + server.spawn( + SessionManager { + tx: event_tx.clone(), + }, + test.server.inner.clone(), + acceptor, + shutdown_rx, + ); + }); + + let state = PushState { + rx: Mutex::new(event_rx), + }; + + ctx.run( + "push-subscription/push-subscription-reject-non-https", + reject_non_https(ctx), + ) + .await; + ctx.run( + "push-subscription/push-subscription-receives-notification", + receives_notification(ctx, &state), + ) + .await; + ctx.run("push-subscription/push-subscription-create", create(ctx)) + .await; + ctx.run("push-subscription/push-subscription-get", get(ctx)) + .await; + ctx.run("push-subscription/push-subscription-destroy", destroy(ctx)) + .await; + ctx.run( + "push-subscription/push-subscription-types-filter", + types_filter(ctx), + ) + .await; + ctx.run( + "push-subscription/push-subscription-verification", + verification(ctx), + ) + .await; + + ctx.run( + "push-eventsource/eventsource-connect", + eventsource_connect(ctx), + ) + .await; + ctx.run( + "push-eventsource/eventsource-receives-state-change", + eventsource_receives_state_change(ctx), + ) + .await; + ctx.run( + "push-eventsource/eventsource-types-filter", + eventsource_types_filter(ctx), + ) + .await; + ctx.run( + "push-eventsource/eventsource-closeafter", + eventsource_closeafter(ctx), + ) + .await; +} + +impl common::network::SessionManager for SessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let tx = self.tx; + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|mut req: hyper::Request| { + let tx = tx.clone(); + async move { + let body = fetch_body(&mut req, 1024 * 1024, 0).await.unwrap(); + if let Ok(message) = serde_json::from_slice::(&body) { + let _ = tx.send(message).await; + } + Ok::<_, hyper::Error>( + HtmlResponse::new("ok".to_string()) + .into_http_response() + .build(), + ) + } + }), + ) + .await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} + +async fn expect_push(state: &PushState, predicate: impl Fn(&Value) -> bool) -> Option { + let mut rx = state.rx.lock().await; + let deadline = Duration::from_secs(10); + loop { + match tokio::time::timeout(deadline, rx.recv()).await { + Ok(Some(message)) => { + if predicate(&message) { + return Some(message); + } + } + _ => return None, + } + } +} + +async fn destroy_subscription(ctx: &CompCtx<'_>, id: &str) { + ctx.primary + .jmap_method_call("PushSubscription/set", json!({ "destroy": [id] })) + .await; +} + +async fn reject_non_https(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "bad": { + "deviceClientId": "jmap-test-reject-http", + "url": "http://example.com/push" + } + } + }), + ) + .await; + check( + !resp.method_response()["notCreated"]["bad"].is_null(), + "Server MUST reject PushSubscription with non-https URL", + ) +} + +async fn receives_notification(ctx: &CompCtx<'_>, state: &PushState) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "psNotify": { + "deviceClientId": "jmap-test-device-notify", + "url": PUSH_URL, + "types": null + } + } + }), + ) + .await; + let ps_id = create_resp.method_response()["created"]["psNotify"]["id"] + .as_str() + .map(|s| s.to_string()); + let ps_id = match ps_id { + Some(id) => id, + None => { + return check(false, "Subscription should be created"); + } + }; + + let verification = expect_push(state, |e| { + e["@type"] == json!("PushVerification") && e["pushSubscriptionId"] == json!(ps_id) + }) + .await; + if let Some(verification) = verification + && let Some(code) = verification["verificationCode"].as_str() + { + let mut update = serde_json::Map::new(); + update.insert(ps_id.clone(), json!({ "verificationCode": code })); + ctx.primary + .jmap_method_call("PushSubscription/set", json!({ "update": update })) + .await; + } + + let mut mailbox_ids = serde_json::Map::new(); + mailbox_ids.insert(ctx.role("inbox").to_string(), json!(true)); + let email_resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ + "accountId": ctx.account_id(), + "create": { + "pushEmail": { + "mailboxIds": mailbox_ids, + "from": [{ "name": "Push", "email": "push@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "Push notification test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "trigger push" } } + } + } + }), + ) + .await; + let email_id = email_resp.method_response()["created"]["pushEmail"]["id"] + .as_str() + .map(|s| s.to_string()); + + let notification = expect_push(state, |e| e["@type"] == json!("StateChange")).await; + + let result = match ¬ification { + Some(n) => check_eq(&n["@type"], &json!("StateChange"), "@type"), + None => check( + false, + "Server MUST send push notification after state change", + ), + }; + + destroy_subscription(ctx, &ps_id).await; + if let Some(email_id) = email_id { + ctx.primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": [email_id] }), + ) + .await; + } + + result +} + +async fn create(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "ps1": { + "deviceClientId": "jmap-test-device-001", + "url": PUSH_URL, + "types": null + } + } + }), + ) + .await; + let created = &resp.method_response()["created"]["ps1"]; + let outcome = check(!created.is_null(), "Subscription should be created") + .and_then(|_| check(created["id"].is_string(), "Subscription should have an id")); + if let Some(id) = created["id"].as_str() { + destroy_subscription(ctx, id).await; + } + outcome +} + +async fn get(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "psGet": { + "deviceClientId": "jmap-test-device-get", + "url": PUSH_URL, + "types": ["Email"] + } + } + }), + ) + .await; + let ps_id = match resp.method_response()["created"]["psGet"]["id"].as_str() { + Some(id) => id.to_string(), + None => return check(false, "Subscription should be created"), + }; + + let get_resp = ctx + .primary + .jmap_method_call("PushSubscription/get", json!({ "ids": [ps_id.clone()] })) + .await; + let list = get_resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + + let outcome = (|| { + check_eq(list.len(), 1, "list length")?; + check_eq(&list[0]["id"], &json!(ps_id), "id")?; + check_eq( + &list[0]["deviceClientId"], + &json!("jmap-test-device-get"), + "deviceClientId", + )?; + if let Some(url) = list[0]["url"].as_str() { + check_contains(url, PUSH_URL, "url")?; + } + Ok(()) + })(); + + destroy_subscription(ctx, &ps_id).await; + outcome +} + +async fn destroy(ctx: &CompCtx<'_>) -> TestOutcome { + let create_resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "psDel": { + "deviceClientId": "jmap-test-device-del", + "url": PUSH_URL + } + } + }), + ) + .await; + let ps_id = match create_resp.method_response()["created"]["psDel"]["id"].as_str() { + Some(id) => id.to_string(), + None => return check(false, "Subscription should be created"), + }; + + let destroy_resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ "destroy": [ps_id.clone()] }), + ) + .await; + let destroyed = &destroy_resp.method_response()["destroyed"]; + check( + destroyed.is_array(), + format!( + "PushSubscription/set destroyed must be an array, got {}", + destroyed + ), + )?; + check( + destroyed + .as_array() + .map(|a| a.iter().any(|v| v == &json!(ps_id))) + .unwrap_or(false), + "destroyed must include subscription id", + )?; + + let get_resp = ctx + .primary + .jmap_method_call("PushSubscription/get", json!({ "ids": [ps_id.clone()] })) + .await; + let not_found = &get_resp.method_response()["notFound"]; + check( + not_found.is_array(), + format!( + "PushSubscription/get notFound MUST be a String[], got {}", + not_found + ), + )?; + check( + not_found + .as_array() + .map(|a| a.iter().any(|v| v == &json!(ps_id))) + .unwrap_or(false), + "notFound must include subscription id", + ) +} + +async fn types_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "psTypes": { + "deviceClientId": "jmap-test-device-types", + "url": PUSH_URL, + "types": ["Email", "Mailbox"] + } + } + }), + ) + .await; + let created = &resp.method_response()["created"]["psTypes"]; + let outcome = check(!created.is_null(), "Subscription should be created"); + if let Some(id) = created["id"].as_str() { + destroy_subscription(ctx, id).await; + } + outcome +} + +async fn verification(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "PushSubscription/set", + json!({ + "create": { + "psVerify": { + "deviceClientId": "jmap-test-device-verify", + "url": PUSH_URL + } + } + }), + ) + .await; + let created = &resp.method_response()["created"]["psVerify"]; + if created.is_null() { + return Ok(()); + } + let outcome = check(created["id"].is_string(), "Subscription should have an id"); + if let Some(id) = created["id"].as_str() { + destroy_subscription(ctx, id).await; + } + outcome +} + +fn event_source_client() -> reqwest::Client { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_secs(10)) + .build() + .unwrap() +} + +async fn eventsource_connect(ctx: &CompCtx<'_>) -> TestOutcome { + let url = ctx.event_source_url("*", "no", "0"); + let response = event_source_client() + .get(&url) + .header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth()) + .header(reqwest::header::ACCEPT, "text/event-stream") + .send() + .await + .map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + drop(response); + + check_eq(status, 200, "status")?; + check_contains(&content_type, "text/event-stream", "content-type") +} + +async fn eventsource_receives_state_change(ctx: &CompCtx<'_>) -> TestOutcome { + let url = ctx.event_source_url("*", "no", "0"); + let response = event_source_client() + .get(&url) + .header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth()) + .header(reqwest::header::ACCEPT, "text/event-stream") + .send() + .await + .map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?; + check_eq(response.status().as_u16(), 200, "status")?; + + let mut stream = response.bytes_stream(); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut mailbox_ids = serde_json::Map::new(); + mailbox_ids.insert(ctx.role("inbox").to_string(), json!(true)); + let email_resp = ctx + .primary + .jmap_method_call( + "Email/set", + json!({ + "accountId": ctx.account_id(), + "create": { + "esTest": { + "mailboxIds": mailbox_ids, + "from": [{ "name": "ES", "email": "es@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": "EventSource test", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "trigger state change" } } + } + } + }), + ) + .await; + let email_id = email_resp.method_response()["created"]["esTest"]["id"] + .as_str() + .map(|s| s.to_string()); + + let mut buffer = String::new(); + let mut state_change: Option = None; + + let read_result = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(_) => break, + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(idx) = buffer.find('\n') { + let line = buffer[..idx].trim().to_string(); + buffer = buffer[idx + 1..].to_string(); + if let Some(data) = line.strip_prefix("data:") + && let Ok(value) = serde_json::from_str::(data.trim()) + && value["@type"] == json!("StateChange") + { + state_change = Some(value); + return; + } + } + } + }) + .await; + + let _ = read_result; + drop(stream); + + if let Some(email_id) = email_id { + ctx.primary + .jmap_method_call( + "Email/set", + json!({ "accountId": ctx.account_id(), "destroy": [email_id] }), + ) + .await; + } + + let value = state_change + .ok_or_else(|| super::Fail::Assert("Did not receive StateChange event".to_string()))?; + check_eq(&value["@type"], &json!("StateChange"), "@type")?; + check( + !value["changed"].is_null(), + "StateChange must have changed property", + ) +} + +async fn eventsource_types_filter(ctx: &CompCtx<'_>) -> TestOutcome { + let url = ctx.event_source_url("Email", "no", "0"); + let response = event_source_client() + .get(&url) + .header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth()) + .header(reqwest::header::ACCEPT, "text/event-stream") + .send() + .await + .map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?; + let status = response.status().as_u16(); + drop(response); + check_eq(status, 200, "status") +} + +async fn eventsource_closeafter(ctx: &CompCtx<'_>) -> TestOutcome { + let url = ctx.event_source_url("*", "state", "0"); + let response = event_source_client() + .get(&url) + .header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth()) + .header(reqwest::header::ACCEPT, "text/event-stream") + .send() + .await + .map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?; + let status = response.status().as_u16(); + drop(response); + check_eq(status, 200, "status") +} diff --git a/tests/src/jmap/compliance/search_snippet.rs b/tests/src/jmap/compliance/search_snippet.rs new file mode 100644 index 0000000..8f2f2e2 --- /dev/null +++ b/tests/src/jmap/compliance/search_snippet.rs @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq}; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] search-snippet"); + + ctx.run("search-snippet/snippet-body-match", snippet_body_match(ctx)) + .await; + ctx.run( + "search-snippet/snippet-subject-match", + snippet_subject_match(ctx), + ) + .await; + ctx.run("search-snippet/snippet-mark-tags", snippet_mark_tags(ctx)) + .await; + ctx.run("search-snippet/snippet-not-found", snippet_not_found(ctx)) + .await; + ctx.run( + "search-snippet/snippet-null-when-no-match", + snippet_null_when_no_match(ctx), + ) + .await; + ctx.run( + "search-snippet/snippet-response-structure", + snippet_response_structure(ctx), + ) + .await; +} + +async fn snippet_body_match(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_query( + "Email", + [("text", json!("xylophone"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let email_ids = query.ids().map(|s| s.to_string()).collect::>(); + check( + !email_ids.is_empty(), + "expected at least one matching email", + )?; + + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": email_ids, + "filter": { "text": "xylophone" } + }), + ) + .await; + let list = resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + check(!list.is_empty(), "expected at least one snippet")?; + + let snippet = list + .iter() + .find(|s| s["emailId"].as_str() == Some(ctx.email("thread-reply-2"))); + check(snippet.is_some(), "Should have snippet for matching email")?; + + let snippet = snippet.unwrap(); + if let Some(preview) = snippet["preview"].as_str() { + check( + preview.to_lowercase().contains("xylophone") || preview.contains(""), + "Preview should highlight the match", + )?; + } + Ok(()) +} + +async fn snippet_subject_match(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_query( + "Email", + [("text", json!("Financial Report"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let email_ids = query.ids().map(|s| s.to_string()).collect::>(); + + if email_ids.is_empty() { + return Ok(()); + } + + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": email_ids, + "filter": { "text": "Financial Report" } + }), + ) + .await; + let list = resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + + let snippet = list + .iter() + .find(|s| s["emailId"].as_str() == Some(ctx.email("html-attachment"))); + if let Some(snippet) = snippet + && let Some(subject) = snippet["subject"].as_str() + { + check( + subject.contains("Financial") || subject.contains(""), + "Subject snippet should highlight match", + )?; + } + Ok(()) +} + +async fn snippet_null_when_no_match(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": [ctx.email("plain-simple")], + "filter": { "text": "xylophone" } + }), + ) + .await; + let list = resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + if !list.is_empty() { + let snippet = list[0].as_object(); + check_eq( + snippet.and_then(|o| o.get("subject")), + Some(&json!(null)), + "subject must be present and null", + )?; + check_eq( + snippet.and_then(|o| o.get("preview")), + Some(&json!(null)), + "preview must be present and null", + )?; + } + Ok(()) +} + +async fn snippet_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": [ctx.email("plain-simple")], + "filter": { "text": "meeting" } + }), + ) + .await; + let obj = resp.method_response().as_object(); + check( + obj.and_then(|o| o.get("accountId")) + .map(|v| v.is_string()) + .unwrap_or(false), + "accountId must be a string", + )?; + check( + obj.and_then(|o| o.get("list")) + .map(|v| v.is_array()) + .unwrap_or(false), + "list must be array", + )?; + let not_found = obj.and_then(|o| o.get("notFound")); + check( + matches!(not_found, Some(Value::Null)) + || not_found + .and_then(|v| v.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false), + "notFound must be present and null or a non-empty array", + ) +} + +async fn snippet_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": ["nonexistent-email-xyz"], + "filter": { "text": "test" } + }), + ) + .await; + let r = resp.method_response(); + check( + r["notFound"].is_array(), + "Expected notFound to contain 'nonexistent-email-xyz', but got null (server claims all email ids were found)", + )?; + let not_found = r["notFound"].as_array().cloned().unwrap_or_default(); + check( + not_found + .iter() + .any(|v| v.as_str() == Some("nonexistent-email-xyz")), + "notFound must include nonexistent-email-xyz", + ) +} + +async fn snippet_mark_tags(ctx: &CompCtx<'_>) -> TestOutcome { + let query = ctx + .primary + .jmap_query( + "Email", + [("text", json!("conference"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let email_ids = query.ids().map(|s| s.to_string()).collect::>(); + if email_ids.is_empty() { + return Ok(()); + } + + let resp = ctx + .primary + .jmap_method_call( + "SearchSnippet/get", + json!({ + "accountId": ctx.account_id(), + "emailIds": email_ids, + "filter": { "text": "conference" } + }), + ) + .await; + let list = resp.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + + let snippet = list.iter().find(|s| !s["preview"].is_null()); + if let Some(snippet) = snippet + && let Some(preview) = snippet["preview"].as_str() + { + check_contains(preview, "", "preview should contain ")?; + check_contains(preview, "", "preview should contain ")?; + } + Ok(()) +} diff --git a/tests/src/jmap/compliance/submission.rs b/tests/src/jmap/compliance/submission.rs new file mode 100644 index 0000000..707110b --- /dev/null +++ b/tests/src/jmap/compliance/submission.rs @@ -0,0 +1,569 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_eq, skip}; +use crate::utils::jmap::JmapUtils; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] submission"); + + ctx.run("submission/get-empty", get_empty(ctx)).await; + ctx.run("submission/get-not-found", get_not_found(ctx)) + .await; + ctx.run( + "submission/get-response-structure", + get_response_structure(ctx), + ) + .await; + + ctx.run( + "submission/set-create-submission", + set_create_submission(ctx), + ) + .await; + ctx.run( + "submission/set-create-with-envelope", + set_create_with_envelope(ctx), + ) + .await; + ctx.run( + "submission/set-no-recipients-error", + set_no_recipients_error(ctx), + ) + .await; + ctx.run( + "submission/set-on-success-update-email", + set_on_success_update_email(ctx), + ) + .await; + ctx.run( + "submission/set-submission-properties", + set_submission_properties(ctx), + ) + .await; + + ctx.run("submission/query-all", query_all(ctx)).await; + ctx.run( + "submission/query-filter-undo-status", + query_filter_undo_status(ctx), + ) + .await; + ctx.run( + "submission/query-filter-null-accepted", + query_filter_null_accepted(ctx), + ) + .await; + ctx.run( + "submission/query-response-structure", + query_response_structure(ctx), + ) + .await; + + ctx.run("submission/changes-no-changes", changes_no_changes(ctx)) + .await; + ctx.run( + "submission/changes-response-structure", + changes_response_structure(ctx), + ) + .await; +} + +fn drafts_or_inbox(ctx: &CompCtx<'_>) -> String { + ctx.role_opt("drafts") + .unwrap_or_else(|| ctx.role("inbox")) + .to_string() +} + +async fn create_draft(ctx: &CompCtx<'_>, mailbox: &str, subject: &str, with_to: bool) -> String { + let mut email = json!({ + "mailboxIds": { (mailbox): true }, + "from": [{ "name": "Test", "email": ctx.identity_email }], + "subject": subject, + "keywords": { "$seen": true, "$draft": true }, + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "Test email body" } }, + }); + if with_to { + email["to"] = json!([{ "name": "Secondary", "email": ctx.secondary_email }]); + } + let resp = ctx + .primary + .jmap_create("Email", [email], Vec::<(String, Value)>::new()) + .await; + resp.created(0).id().to_string() +} + +async fn destroy_email(ctx: &CompCtx<'_>, email_id: &str) { + ctx.primary + .jmap_destroy("Email", [email_id], Vec::<(String, Value)>::new()) + .await; +} + +async fn destroy_submission(ctx: &CompCtx<'_>, sub_id: &str) { + ctx.primary + .jmap_destroy("EmailSubmission", [sub_id], Vec::<(String, Value)>::new()) + .await; +} + +async fn get_empty(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get( + "EmailSubmission", + Vec::::new(), + Vec::::new(), + ) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["state"].is_string(), "state must be string")?; + check(r["list"].is_array(), "list must be array") +} + +async fn get_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get( + "EmailSubmission", + Vec::::new(), + ["nonexistent-submission-xyz"], + ) + .await; + let r = resp.method_response(); + check( + r["notFound"].is_array(), + format!("notFound must be a String[], got {}", r["notFound"]), + )?; + let found = r["notFound"] + .as_array() + .map(|a| { + a.iter() + .any(|v| v.as_str() == Some("nonexistent-submission-xyz")) + }) + .unwrap_or(false); + check(found, "notFound must include nonexistent-submission-xyz") +} + +async fn get_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "EmailSubmission/get", + json!({ "accountId": ctx.account_id(), "ids": [] }), + ) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["state"].is_string(), "state must be string")?; + check(r["list"].is_array(), "list must be array")?; + check(r["notFound"].is_array(), "notFound must be array") +} + +async fn set_create_submission(ctx: &CompCtx<'_>) -> TestOutcome { + let identity = match ctx.identity_ids.first() { + Some(id) => id, + None => return skip("No identities available"), + }; + let mailbox = drafts_or_inbox(ctx); + let email_id = create_draft(ctx, &mailbox, "Submission test", true).await; + + let resp = ctx + .primary + .jmap_create( + "EmailSubmission", + [json!({ "identityId": identity, "emailId": email_id })], + Vec::<(String, Value)>::new(), + ) + .await; + + let created = resp.pointer("/methodResponses/0/1/created/i0").cloned(); + let outcome = match &created { + Some(sub) => { + let mut res = check(sub["id"].is_string(), "id is set by server and required"); + if res.is_ok() { + res = check( + sub["sendAt"].is_string(), + "sendAt is set by server and required", + ); + } + if res.is_ok() { + let undo = sub["undoStatus"].as_str().unwrap_or(""); + res = check( + undo == "pending" || undo == "final", + "undoStatus must be pending or final", + ); + } + res + } + None => check(false, "Submission should be created"), + }; + + if let Some(sub) = &created + && let Some(id) = sub["id"].as_str() + { + destroy_submission(ctx, id).await; + } + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_create_with_envelope(ctx: &CompCtx<'_>) -> TestOutcome { + let identity = match ctx.identity_ids.first() { + Some(id) => id, + None => return skip("No identities available"), + }; + let mailbox = drafts_or_inbox(ctx); + let email_id = create_draft(ctx, &mailbox, "Envelope test", true).await; + + let resp = ctx + .primary + .jmap_create( + "EmailSubmission", + [json!({ + "identityId": identity, + "emailId": email_id, + "envelope": { + "mailFrom": { "email": ctx.identity_email, "parameters": null }, + "rcptTo": [{ "email": ctx.secondary_email, "parameters": null }], + }, + })], + Vec::<(String, Value)>::new(), + ) + .await; + + let created = resp.pointer("/methodResponses/0/1/created/i0").cloned(); + let outcome = check(created.is_some(), "Submission should be created"); + + if let Some(sub) = &created + && let Some(id) = sub["id"].as_str() + { + destroy_submission(ctx, id).await; + } + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_no_recipients_error(ctx: &CompCtx<'_>) -> TestOutcome { + let identity = match ctx.identity_ids.first() { + Some(id) => id, + None => return skip("No identities available"), + }; + let resp = ctx + .primary + .jmap_create( + "Email", + [json!({ + "mailboxIds": { (ctx.role("inbox")): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "subject": "No recipients", + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } }, + })], + Vec::<(String, Value)>::new(), + ) + .await; + let email_id = resp.created(0).id().to_string(); + + let resp = ctx + .primary + .jmap_create( + "EmailSubmission", + [json!({ "identityId": identity, "emailId": email_id })], + Vec::<(String, Value)>::new(), + ) + .await; + + let not_created = resp.pointer("/methodResponses/0/1/notCreated/i0").cloned(); + let outcome = match ¬_created { + Some(err) => check( + err["type"].is_string(), + "Server MUST reject submission of email with no recipients", + ), + None => check( + false, + "Server MUST reject submission of email with no recipients", + ), + }; + + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_on_success_update_email(ctx: &CompCtx<'_>) -> TestOutcome { + let identity = match ctx.identity_ids.first() { + Some(id) => id, + None => return skip("No identities available"), + }; + let sent_mailbox = match ctx.role_opt("sent") { + Some(id) => id.to_string(), + None => return skip("No sent mailbox found"), + }; + let drafts = drafts_or_inbox(ctx); + let email_id = create_draft(ctx, &drafts, "onSuccess test", true).await; + + let resp = ctx + .primary + .jmap_method_calls(json!([[ + "EmailSubmission/set", + { + "accountId": ctx.account_id(), + "create": { + "osuSub": { "identityId": identity, "emailId": email_id } + }, + "onSuccessUpdateEmail": { + "#osuSub": { + (format!("mailboxIds/{sent_mailbox}")): true, + (format!("mailboxIds/{drafts}")): null, + "keywords/$draft": null + } + } + }, + "submit" + ]])) + .await; + + let mut outcome = check( + resp.num_responses() >= 2, + "Response must include both EmailSubmission/set and implicit Email/set", + ); + if outcome.is_ok() { + outcome = check_eq( + resp.name_at(0), + "EmailSubmission/set", + "first response name", + ); + } + if outcome.is_ok() { + let has_email_set = (0..resp.num_responses()).any(|n| resp.name_at(n) == "Email/set"); + outcome = check( + has_email_set, + "Implicit Email/set from onSuccessUpdateEmail must appear in methodResponses", + ); + } + + if outcome.is_ok() { + let get_result = ctx + .primary + .jmap_method_call( + "Email/get", + json!({ + "accountId": ctx.account_id(), + "ids": [email_id], + "properties": ["mailboxIds", "keywords"] + }), + ) + .await; + let list = get_result.method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default(); + if let Some(email) = list.first() { + let in_sent = email["mailboxIds"][sent_mailbox.as_str()] + .as_bool() + .unwrap_or(false); + if in_sent { + outcome = check_eq( + email["mailboxIds"][sent_mailbox.as_str()] + .as_bool() + .unwrap_or(false), + true, + "email in sent", + ); + } + if outcome.is_ok() { + outcome = check( + !email["keywords"]["$draft"].as_bool().unwrap_or(false), + "$draft should be removed", + ); + } + } + } + + let sub_id = resp + .pointer("/methodResponses/0/1/created/osuSub/id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(id) = sub_id { + destroy_submission(ctx, &id).await; + } + destroy_email(ctx, &email_id).await; + outcome +} + +async fn set_submission_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let identity = match ctx.identity_ids.first() { + Some(id) => id, + None => return skip("No identities available"), + }; + let email_id = create_draft(ctx, ctx.role("inbox"), "Properties test", true).await; + + let resp = ctx + .primary + .jmap_create( + "EmailSubmission", + [json!({ "identityId": identity, "emailId": email_id })], + Vec::<(String, Value)>::new(), + ) + .await; + + let created = resp.pointer("/methodResponses/0/1/created/i0").cloned(); + let mut outcome = Ok(()); + let mut sub_id_for_cleanup = None; + + if let Some(sub) = &created { + outcome = check(sub["id"].is_string(), "submission id must be string"); + if outcome.is_ok() { + let sub_id = sub["id"].as_str().unwrap_or("").to_string(); + sub_id_for_cleanup = Some(sub_id.clone()); + let get_result = ctx + .primary + .jmap_get("EmailSubmission", Vec::::new(), [sub_id.clone()]) + .await; + let fetched = get_result.method_response()["list"] + .as_array() + .and_then(|a| a.first()) + .cloned(); + match fetched { + Some(f) => { + outcome = check(f["identityId"].is_string(), "identityId"); + if outcome.is_ok() { + outcome = check(f["emailId"].is_string(), "emailId"); + } + if outcome.is_ok() { + outcome = check(!f["sendAt"].is_null(), "sendAt"); + } + if outcome.is_ok() { + outcome = check(!f["undoStatus"].is_null(), "undoStatus"); + } + } + None => { + let not_found = get_result.method_response()["notFound"].clone(); + outcome = check_eq( + ¬_found, + &json!([sub_id]), + "Item not returned and missing from notFound", + ); + } + } + } + } + + if let Some(id) = sub_id_for_cleanup { + destroy_submission(ctx, &id).await; + } + destroy_email(ctx, &email_id).await; + outcome +} + +async fn query_all(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "EmailSubmission", + Vec::<(String, Value)>::new(), + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + check(r["queryState"].is_string(), "queryState must be string")?; + check(r["ids"].is_array(), "ids must be array") +} + +async fn query_filter_undo_status(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "EmailSubmission", + [("undoStatus", json!("final"))], + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + check(r["ids"].is_array(), "ids must be array") +} + +async fn query_filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_method_call( + "EmailSubmission/query", + json!({ "accountId": ctx.account_id(), "filter": null }), + ) + .await; + let r = resp.method_response(); + check(r["queryState"].is_string(), "queryState must be string")?; + check(r["ids"].is_array(), "ids must be array") +} + +async fn query_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_query( + "EmailSubmission", + Vec::<(String, Value)>::new(), + Vec::::new(), + Vec::<(String, Value)>::new(), + ) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["queryState"].is_string(), "queryState must be string")?; + check( + r["canCalculateChanges"].is_boolean(), + "canCalculateChanges must be boolean", + )?; + check(r["position"].is_number(), "position must be number")?; + check(r["ids"].is_array(), "ids must be array") +} + +async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get( + "EmailSubmission", + Vec::::new(), + Vec::::new(), + ) + .await; + let state = get_result.state().to_string(); + + let resp = ctx.primary.jmap_changes("EmailSubmission", &state).await; + let r = resp.method_response(); + check_eq( + r["oldState"].as_str().unwrap_or(""), + state.as_str(), + "oldState", + )?; + let count = |k: &str| r[k].as_array().map(|a| a.len()).unwrap_or(usize::MAX); + check_eq(count("created"), 0, "created length")?; + check_eq(count("updated"), 0, "updated length")?; + check_eq(count("destroyed"), 0, "destroyed length") +} + +async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get( + "EmailSubmission", + Vec::::new(), + Vec::::new(), + ) + .await; + let state = get_result.state().to_string(); + + let resp = ctx.primary.jmap_changes("EmailSubmission", &state).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["oldState"].is_string(), "oldState must be string")?; + check(r["newState"].is_string(), "newState must be string")?; + check( + r["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + ) +} diff --git a/tests/src/jmap/compliance/thread.rs b/tests/src/jmap/compliance/thread.rs new file mode 100644 index 0000000..1b51b3d --- /dev/null +++ b/tests/src/jmap/compliance/thread.rs @@ -0,0 +1,314 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_eq}; +use crate::utils::jmap::JmapUtils; +use serde_json::json; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] thread"); + + ctx.run("thread/get-thread-by-id", get_thread_by_id(ctx)) + .await; + ctx.run( + "thread/get-single-email-thread", + get_single_email_thread(ctx), + ) + .await; + ctx.run( + "thread/get-thread-email-ids-order", + get_thread_email_ids_order(ctx), + ) + .await; + ctx.run( + "thread/get-thread-response-structure", + get_thread_response_structure(ctx), + ) + .await; + ctx.run("thread/get-thread-not-found", get_thread_not_found(ctx)) + .await; + + ctx.run( + "thread/changes-after-new-email", + changes_after_new_email(ctx), + ) + .await; + ctx.run( + "thread/changes-after-email-destroy", + changes_after_email_destroy(ctx), + ) + .await; + ctx.run("thread/changes-no-changes", changes_no_changes(ctx)) + .await; + ctx.run( + "thread/changes-response-structure", + changes_response_structure(ctx), + ) + .await; +} + +async fn thread_id_of(ctx: &CompCtx<'_>, email_key: &str) -> String { + let resp = ctx + .primary + .jmap_get("Email", ["threadId"], [ctx.email(email_key)]) + .await; + resp.list()[0].text_field("threadId").to_string() +} + +async fn get_thread_by_id(ctx: &CompCtx<'_>) -> TestOutcome { + let thread_id = thread_id_of(ctx, "thread-starter").await; + let resp = ctx + .primary + .jmap_get("Thread", Vec::::new(), [thread_id.as_str()]) + .await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + check_eq(list[0].text_field("id"), thread_id.as_str(), "thread id")?; + let email_ids = list[0]["emailIds"].as_array(); + check(email_ids.is_some(), "emailIds must be array")?; + check( + email_ids.map(|a| a.len()).unwrap_or(0) >= 3, + "Thread should have at least 3 emails", + ) +} + +async fn get_single_email_thread(ctx: &CompCtx<'_>) -> TestOutcome { + let email_id = ctx.email("plain-simple"); + let thread_id = thread_id_of(ctx, "plain-simple").await; + let resp = ctx + .primary + .jmap_get("Thread", Vec::::new(), [thread_id.as_str()]) + .await; + let list = resp.list(); + let email_ids = list[0]["emailIds"].as_array().map(|a| a.len()).unwrap_or(0); + check_eq(email_ids, 1, "single email thread length")?; + check_eq( + list[0]["emailIds"][0].as_str().unwrap_or(""), + email_id, + "emailIds[0]", + ) +} + +async fn get_thread_email_ids_order(ctx: &CompCtx<'_>) -> TestOutcome { + let thread_id = thread_id_of(ctx, "thread-starter").await; + let resp = ctx + .primary + .jmap_get("Thread", Vec::::new(), [thread_id.as_str()]) + .await; + let email_ids = resp.list()[0]["emailIds"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + let get_result = ctx + .primary + .jmap_get("Email", ["receivedAt"], email_ids.iter()) + .await; + let mut id_to_date = std::collections::HashMap::new(); + for email in get_result.list() { + let id = email.text_field("id").to_string(); + let received = email["receivedAt"].as_str().unwrap_or("").to_string(); + id_to_date.insert(id, received); + } + + for i in 1..email_ids.len() { + let prev = id_to_date.get(&email_ids[i - 1]); + let curr = id_to_date.get(&email_ids[i]); + if let (Some(prev), Some(curr)) = (prev, curr) { + check(prev <= curr, "emailIds should be ordered by receivedAt")?; + } + } + Ok(()) +} + +async fn get_thread_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Thread", Vec::::new(), Vec::::new()) + .await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["state"].is_string(), "state must be string")?; + check(r["list"].is_array(), "list must be array")?; + check(r["notFound"].is_array(), "notFound must be array") +} + +async fn get_thread_not_found(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("Thread", Vec::::new(), ["nonexistent-thread-xyz"]) + .await; + let r = resp.method_response(); + check( + r["notFound"].is_array(), + format!( + "Thread/get notFound MUST be a String[] (RFC 8620 5.1), got {}", + r["notFound"] + ), + )?; + let not_found = resp.not_found().collect::>(); + check( + not_found.contains(&"nonexistent-thread-xyz"), + "notFound should include nonexistent-thread-xyz", + ) +} + +async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get("Thread", Vec::::new(), Vec::::new()) + .await; + let state = get_result.state().to_string(); + + let resp = ctx.primary.jmap_changes("Thread", &state).await; + let r = resp.method_response(); + check_eq( + r["oldState"].as_str().unwrap_or(""), + state.as_str(), + "oldState", + )?; + check_eq( + r["created"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "created length", + )?; + check_eq( + r["updated"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "updated length", + )?; + check_eq( + r["destroyed"] + .as_array() + .map(|a| a.len()) + .unwrap_or(usize::MAX), + 0, + "destroyed length", + ) +} + +fn email_create_item(ctx: &CompCtx<'_>, subject: &str) -> serde_json::Value { + json!({ + "mailboxIds": { (ctx.role("inbox")): true }, + "from": [{ "name": "Test", "email": "test@example.com" }], + "to": [{ "name": "User", "email": "user@example.com" }], + "subject": subject, + "bodyStructure": { "type": "text/plain", "partId": "1" }, + "bodyValues": { "1": { "value": "body" } }, + }) +} + +async fn changes_after_new_email(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get("Thread", Vec::::new(), Vec::::new()) + .await; + let old_state = get_result.state().to_string(); + + let create_result = ctx + .primary + .jmap_create( + "Email", + [email_create_item(ctx, "New thread for changes test")], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + let email_id = create_result.created(0).text_field("id").to_string(); + + let changes = ctx.primary.jmap_changes("Thread", &old_state).await; + let created_len = changes.method_response()["created"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + + let outcome = check(created_len > 0, "Should have at least one new thread"); + + ctx.primary + .jmap_destroy( + "Email", + [email_id.as_str()], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + outcome +} + +async fn changes_after_email_destroy(ctx: &CompCtx<'_>) -> TestOutcome { + let create_result = ctx + .primary + .jmap_create( + "Email", + [email_create_item(ctx, "Thread to destroy")], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + let email_id = create_result.created(0).text_field("id").to_string(); + + let email_get = ctx + .primary + .jmap_get("Email", ["threadId"], [email_id.as_str()]) + .await; + let thread_id = email_get.list()[0].text_field("threadId").to_string(); + + let thread_get = ctx + .primary + .jmap_get("Thread", Vec::::new(), Vec::::new()) + .await; + let mid_state = thread_get.state().to_string(); + + ctx.primary + .jmap_destroy( + "Email", + [email_id.as_str()], + Vec::<(String, serde_json::Value)>::new(), + ) + .await; + + let changes = ctx.primary.jmap_changes("Thread", &mid_state).await; + let destroyed = changes.method_response()["destroyed"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + check( + destroyed.contains(&thread_id), + format!("destroyed should include {thread_id}"), + ) +} + +async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get("Thread", Vec::::new(), Vec::::new()) + .await; + let state = get_result.state().to_string(); + + let resp = ctx.primary.jmap_changes("Thread", &state).await; + let r = resp.method_response(); + check(r["accountId"].is_string(), "accountId must be string")?; + check(r["oldState"].is_string(), "oldState must be string")?; + check(r["newState"].is_string(), "newState must be string")?; + check( + r["hasMoreChanges"].is_boolean(), + "hasMoreChanges must be boolean", + )?; + check(r["created"].is_array(), "created must be array")?; + check(r["updated"].is_array(), "updated must be array")?; + check(r["destroyed"].is_array(), "destroyed must be array") +} diff --git a/tests/src/jmap/compliance/vacation.rs b/tests/src/jmap/compliance/vacation.rs new file mode 100644 index 0000000..15c4293 --- /dev/null +++ b/tests/src/jmap/compliance/vacation.rs @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{CompCtx, TestOutcome, check, check_contains, check_eq}; +use crate::utils::jmap::JmapUtils; +use serde_json::{Value, json}; + +pub async fn run(ctx: &CompCtx<'_>) { + println!("[compliance] vacation"); + + ctx.run("vacation/get-singleton", get_singleton(ctx)).await; + ctx.run( + "vacation/get-singleton-null-ids", + get_singleton_null_ids(ctx), + ) + .await; + ctx.run( + "vacation/get-singleton-properties", + get_singleton_properties(ctx), + ) + .await; + ctx.run( + "vacation/get-not-found-invalid-id", + get_not_found_invalid_id(ctx), + ) + .await; + ctx.run("vacation/set-enable-vacation", set_enable_vacation(ctx)) + .await; + ctx.run("vacation/set-disable-vacation", set_disable_vacation(ctx)) + .await; + ctx.run("vacation/set-dates", set_dates(ctx)).await; + ctx.run("vacation/set-html-body", set_html_body(ctx)).await; + ctx.run("vacation/set-cannot-create", set_cannot_create(ctx)) + .await; + ctx.run("vacation/set-cannot-destroy", set_cannot_destroy(ctx)) + .await; +} + +async fn get_singleton(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("VacationResponse", Vec::::new(), ["singleton"]) + .await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + check_eq(list[0].id(), "singleton", "id") +} + +async fn get_singleton_null_ids(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let list = resp.list(); + check_eq(list.len(), 1, "list length")?; + check_eq(list[0].id(), "singleton", "id") +} + +async fn get_singleton_properties(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let vr = &resp.list()[0]; + check_eq(vr.id(), "singleton", "id")?; + check(vr["isEnabled"].is_boolean(), "isEnabled must be boolean")?; + check( + vr["fromDate"].is_null() || vr["fromDate"].is_string(), + "fromDate must be null or string", + )?; + check( + vr["toDate"].is_null() || vr["toDate"].is_string(), + "toDate must be null or string", + )?; + check( + vr["subject"].is_null() || vr["subject"].is_string(), + "subject must be null or string", + )?; + check( + vr["textBody"].is_null() || vr["textBody"].is_string(), + "textBody must be null or string", + )?; + check( + vr["htmlBody"].is_null() || vr["htmlBody"].is_string(), + "htmlBody must be null or string", + ) +} + +async fn get_not_found_invalid_id(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_get("VacationResponse", Vec::::new(), ["not-singleton"]) + .await; + let not_found = &resp.method_response()["notFound"]; + check( + not_found.is_array(), + format!( + "VacationResponse/get notFound MUST be a String[], got {}", + not_found + ), + )?; + let contains = not_found + .as_array() + .map(|a| a.iter().any(|v| v.as_str() == Some("not-singleton"))) + .unwrap_or(false); + check(contains, "notFound must include not-singleton") +} + +async fn set_enable_vacation(ctx: &CompCtx<'_>) -> TestOutcome { + let get_result = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let original = get_result.list()[0].clone(); + + ctx.primary + .jmap_update( + "VacationResponse", + [( + "singleton", + json!({ + "isEnabled": true, + "subject": "Out of Office - Test", + "textBody": "I am currently out of the office for testing.", + }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let vr = &verify.list()[0]; + let outcome = check_eq(&vr["isEnabled"], &json!(true), "isEnabled") + .and_then(|_| check_eq(&vr["subject"], &json!("Out of Office - Test"), "subject")); + + ctx.primary + .jmap_update( + "VacationResponse", + [( + "singleton", + json!({ + "isEnabled": original["isEnabled"], + "subject": original["subject"], + "textBody": original["textBody"], + }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + outcome +} + +async fn set_disable_vacation(ctx: &CompCtx<'_>) -> TestOutcome { + ctx.primary + .jmap_update( + "VacationResponse", + [("singleton", json!({ "isEnabled": false }))], + Vec::<(String, Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let vr = &verify.list()[0]; + check_eq(&vr["isEnabled"], &json!(false), "isEnabled") +} + +async fn set_dates(ctx: &CompCtx<'_>) -> TestOutcome { + let from_date = "2026-03-01T00:00:00Z"; + let to_date = "2026-03-15T00:00:00Z"; + + ctx.primary + .jmap_update( + "VacationResponse", + [( + "singleton", + json!({ "fromDate": from_date, "toDate": to_date }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let vr = &verify.list()[0]; + let outcome = check_eq(&vr["fromDate"], &json!(from_date), "fromDate") + .and_then(|_| check_eq(&vr["toDate"], &json!(to_date), "toDate")); + + ctx.primary + .jmap_update( + "VacationResponse", + [("singleton", json!({ "fromDate": null, "toDate": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + + outcome +} + +async fn set_html_body(ctx: &CompCtx<'_>) -> TestOutcome { + ctx.primary + .jmap_update( + "VacationResponse", + [( + "singleton", + json!({ "htmlBody": "

I am out of office.

" }), + )], + Vec::<(String, Value)>::new(), + ) + .await; + + let verify = ctx + .primary + .jmap_get( + "VacationResponse", + Vec::::new(), + Vec::::new(), + ) + .await; + let vr = &verify.list()[0]; + let outcome = check_contains( + vr["htmlBody"].as_str().unwrap_or(""), + "out of office", + "htmlBody", + ); + + ctx.primary + .jmap_update( + "VacationResponse", + [("singleton", json!({ "htmlBody": null }))], + Vec::<(String, Value)>::new(), + ) + .await; + + outcome +} + +async fn set_cannot_create(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_create( + "VacationResponse", + [json!({ "isEnabled": false })], + Vec::<(String, Value)>::new(), + ) + .await; + let not_created = resp.not_created(0); + check( + !not_created.is_null(), + "Should not allow creating new VacationResponse", + )?; + check_eq(not_created.typ(), "singleton", "type") +} + +async fn set_cannot_destroy(ctx: &CompCtx<'_>) -> TestOutcome { + let resp = ctx + .primary + .jmap_destroy( + "VacationResponse", + ["singleton"], + Vec::<(String, Value)>::new(), + ) + .await; + let not_destroyed = resp.not_destroyed("singleton"); + check( + !not_destroyed.is_null(), + "Should not allow destroying VacationResponse singleton", + )?; + check_eq(not_destroyed.typ(), "singleton", "type") +} diff --git a/tests/src/jmap/contacts/acl.rs b/tests/src/jmap/contacts/acl.rs new file mode 100644 index 0000000..bd7f7a4 --- /dev/null +++ b/tests/src/jmap/contacts/acl.rs @@ -0,0 +1,674 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use calcard::jscontact::JSContactProperty; +use jmap_proto::{ + object::{addressbook::AddressBookProperty, share_notification::ShareNotificationProperty}, + request::method::MethodObject, +}; +use serde_json::json; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Contacts ACL tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + + // Create test address books + let response = john + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_book_id = response.created(0).id().to_string(); + let john_contact_id = john + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "abc123", + "name": { + "full": "John's Simple Contact", + }, + "addressBookIds": { + &john_book_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = jane + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let jane_book_id = response.created(0).id().to_string(); + let jane_contact_id = jane + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "abc456", + "name": { + "full": "Jane's Simple Contact", + }, + "addressBookIds": { + &jane_book_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + // Verify myRights + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": true + }, + "shareWith": {} + })); + + // Obtain share notifications + let mut jane_share_change_id = jane + .jmap_get( + MethodObject::ShareNotification, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Make sure Jane has no access + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::AddressBook, + Vec::<&str>::new(), + [john_book_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Share address book with Jane + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + "shareWith": { + &jane_id : { + "mayRead": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "shareWith": { + &jane_id : { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + } + } + })); + + // Verify Jane can access the contact + jane.jmap_get_account( + john, + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + } + })); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [AddressBookProperty::Id, AddressBookProperty::Name], + [john_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_contact_id, + "name": { + "full": "John's Simple Contact" + }, + })); + + // Verify Jane received a share notification + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": false, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "name": null + })); + + // Updating and deleting should fail + assert_eq!( + jane.jmap_update_account( + john, + MethodObject::AddressBook, + [(&john_book_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_book_id) + .description(), + "You are not allowed to modify this address book." + ); + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::AddressBook, + [&john_book_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_book_id) + .description(), + "You are not allowed to delete this address book." + ); + assert!( + jane.jmap_update_account( + john, + MethodObject::ContactCard, + [(&john_contact_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_contact_id) + .description() + .contains("You are not allowed to modify address book"), + ); + assert!( + jane.jmap_destroy_account( + john, + MethodObject::ContactCard, + [&john_contact_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_contact_id) + .description() + .contains("You are not allowed to remove contacts from address book"), + ); + + // Grant Jane write access + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}/mayWrite"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + jane.jmap_get_account( + john, + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + } + })); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + }, + "name": null + })); + + // Creating a root folder should fail + assert_eq!( + jane.jmap_create_account( + john, + MethodObject::AddressBook, + [json!({ + "name": "A new shared address book", + })], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Cannot create address books in a shared account." + ); + + // Copy Jane's contact into John's address book + let john_copied_contact_id = jane + .jmap_copy( + jane, + john, + MethodObject::ContactCard, + [( + &jane_contact_id, + json!({ + "addressBookIds": { + &john_book_id: true + } + }), + )], + false, + ) + .await + .copied(&jane_contact_id) + .id() + .to_string(); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [ + JSContactProperty::::Id, + JSContactProperty::AddressBookIds, + JSContactProperty::Name, + ], + [john_copied_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_copied_contact_id, + "name": { + "full": "Jane's Simple Contact" + }, + "addressBookIds": { + &john_book_id: true + } + })); + + // Destroy the copied contact + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::ContactCard, + [john_copied_contact_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [&john_copied_contact_id] + ); + + // Update John's contact + jane.jmap_update_account( + john, + MethodObject::ContactCard, + [( + &john_contact_id, + json!({ + "name": { + "full": "John's Updated Contact", + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_contact_id); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [JSContactProperty::::Id, JSContactProperty::Name], + [john_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_contact_id, + "name": { + "full": "John's Updated Contact" + }, + })); + + // Update John's address book name + jane.jmap_update_account( + john, + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + "name": "Jane's version of John's Address Book", + "description": "This is John's address book, but Jane can edit it now" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + jane.jmap_get_account( + john, + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Jane's version of John's Address Book", + "description": "This is John's address book, but Jane can edit it now" + })); + + // John should still see the old name + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "description": null + })); + + // Revoke Jane's access + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}"): () + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "shareWith": {} + })); + + // Verify Jane can no longer access the address book or its contacts + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::AddressBook, + Vec::<&str>::new(), + [john_book_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + }, + "newRights": { + "mayRead": false, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "name": null + })); + + // Grant Jane delete access once again + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}/mayRead"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + + // Verify Jane can delete the address book + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::AddressBook, + [john_book_id.as_str()], + [("onDestroyRemoveContents", true)], + ) + .await + .destroyed() + .collect::>(), + [john_book_id.as_str()] + ); + + // Destroy all mailboxes + john.destroy_all_addressbooks().await; + jane.destroy_all_addressbooks().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/contacts/addressbook.rs b/tests/src/jmap/contacts/addressbook.rs new file mode 100644 index 0000000..4a4bad8 --- /dev/null +++ b/tests/src/jmap/contacts/addressbook.rs @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{ChangeType, JmapUtils}, + server::TestServer, +}; +use jmap_proto::{object::addressbook::AddressBookProperty, request::method::MethodObject}; +use serde_json::json; + +pub async fn test(test: &TestServer) { + println!("Running AddressBook tests..."); + let account = test.account("jdoe@example.com"); + + // Make sure the default address book exists + let response = account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 1); + let default_addressbook_id = list[0].id().to_string(); + assert_eq!( + list[0], + json!({ + "name": "Stalwart Address Book (jdoe@example.com)", + "description": (), + "sortOrder": 0, + "isSubscribed": true, + "isDefault": true, + "id": default_addressbook_id, + }) + ); + let change_id = response.state(); + + // Create Address Book + let addressbook_id = account + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test address book", + "description": "My personal address book", + "sortOrder": 1, + "isSubscribed": true + + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::AddressBook, change_id) + .await + .changes() + .collect::>(), + [ChangeType::Created(&addressbook_id)] + ); + + // Get Address Book + let response = account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + [&addressbook_id], + ) + .await; + assert_eq!( + response.list()[0], + json!({ + "name": "Test address book", + "description": "My personal address book", + "sortOrder": 1, + "isSubscribed": true, + "isDefault": false, + "id": addressbook_id, + }) + ); + + // Update Address Book and set it as default + account + .jmap_update( + MethodObject::AddressBook, + [( + addressbook_id.as_str(), + json!({ + "name": "Updated address book", + "description": "My updated personal address book", + "sortOrder": 2, + "isSubscribed": false + }), + )], + [("onSuccessSetIsDefault", addressbook_id.as_str())], + ) + .await + .updated(&addressbook_id); + + // Validate changes + assert_eq!( + account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + [&addressbook_id, &default_addressbook_id], + ) + .await + .list(), + vec![ + json!({ + "name": "Updated address book", + "description": "My updated personal address book", + "sortOrder": 2, + "isSubscribed": false, + "isDefault": true, + "id": addressbook_id, + }), + json!({ + "name": "Stalwart Address Book (jdoe@example.com)", + "description": (), + "sortOrder": 0, + "isSubscribed": true, + "isDefault": false, + "id": default_addressbook_id, + }) + ] + ); + + // Create a contact + let _ = account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "addressBookIds": { + &addressbook_id: true + }, + "name": { + "components": [ + { "kind": "given", "value": "Joe" }, + { "kind": "surname", "value": "Bloggs" } + ] + }, + "emails": { + "0": { + "address": "joe.bloggs@example.com" + } + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id(); + + // Try destroying the address book (should fail) + assert_eq!( + account + .jmap_destroy( + MethodObject::AddressBook, + [&addressbook_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&addressbook_id) + .typ(), + "addressBookHasContents" + ); + + // Destroy using force + assert_eq!( + account + .jmap_destroy( + MethodObject::AddressBook, + [&addressbook_id], + [("onDestroyRemoveContents", true)], + ) + .await + .destroyed() + .collect::>(), + vec![&addressbook_id] + ); + + // Destroy all mailboxes + account.destroy_all_addressbooks().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/contacts/contact.rs b/tests/src/jmap/contacts/contact.rs new file mode 100644 index 0000000..a12d028 --- /dev/null +++ b/tests/src/jmap/contacts/contact.rs @@ -0,0 +1,1361 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{ChangeType, IntoJmapSet, JmapUtils}, + server::TestServer, +}; +use ahash::AHashSet; +use calcard::jscontact::JSContactProperty; +use dav_proto::Depth; +use groupware::{DavResourceName, cache::GroupwareCache}; +use hyper::StatusCode; +use jmap_proto::request::method::MethodObject; +use serde_json::{Value, json}; +use std::str::FromStr; +use types::{collection::SyncCollection, id::Id}; + +pub async fn test(test: &TestServer) { + println!("Running Contact Card tests..."); + let account = test.account("jdoe@example.com"); + + // Create test address books + let response = account + .jmap_create( + MethodObject::AddressBook, + [ + json!({ + "name": "Test #1", + }), + json!({ + "name": "Test #2", + }), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let book1_id = response.created(0).id().to_string(); + let book2_id = response.created(1).id().to_string(); + + // Obtain state + let change_id = account + .jmap_get( + MethodObject::ContactCard, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Create test contacts + let sarah_contact = test_jscontact_1().with_property( + JSContactProperty::::AddressBookIds, + [book1_id.as_str()].into_jmap_set(), + ); + let carlos_contact = test_jscontact_2().with_property( + JSContactProperty::::AddressBookIds, + [book2_id.as_str()].into_jmap_set(), + ); + let acme_contact = test_jscontact_3().with_property( + JSContactProperty::::AddressBookIds, + [book1_id.as_str(), book2_id.as_str()].into_jmap_set(), + ); + let tmp_contact = test_jscontact_4().with_property( + JSContactProperty::::AddressBookIds, + [book2_id.as_str()].into_jmap_set(), + ); + let response = account + .jmap_create( + MethodObject::ContactCard, + [ + sarah_contact.clone(), + carlos_contact.clone(), + acme_contact.clone(), + tmp_contact, + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let sarah_contact_id = response.created(0).id().to_string(); + let carlos_contact_id = response.created(1).id().to_string(); + let acme_contact_id = response.created(2).id().to_string(); + let tmp_contact_id = response.created(3).id().to_string(); + + // Destroy tmp contact + test.wait_for_tasks().await; + assert_eq!( + account + .jmap_destroy( + MethodObject::ContactCard, + [tmp_contact_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .next(), + Some(tmp_contact_id.as_str()) + ); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::ContactCard, &change_id) + .await + .changes() + .collect::>(), + [ + ChangeType::Created(&sarah_contact_id), + ChangeType::Created(&carlos_contact_id), + ChangeType::Created(&acme_contact_id) + ] + .into_iter() + .collect::>(), + ); + + // Fetch contacts and verify + let response = account + .jmap_get( + MethodObject::ContactCard, + Vec::<&str>::new(), + [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], + ) + .await; + response.list()[0].assert_is_equal( + sarah_contact.with_property(JSContactProperty::::Id, sarah_contact_id.as_str()), + ); + response.list()[1].assert_is_equal( + carlos_contact.with_property(JSContactProperty::::Id, carlos_contact_id.as_str()), + ); + response.list()[2].assert_is_equal( + acme_contact.with_property(JSContactProperty::::Id, acme_contact_id.as_str()), + ); + + let response = account + .jmap_method_calls(json!([[ + "ContactCard/get", + { + "accountId": account.id_string(), + "properties": [], + "ids": [&sarah_contact_id, &carlos_contact_id], + }, + "0" + ]])) + .await; + response.list()[0].assert_is_equal(json!({ "id": &sarah_contact_id })); + response.list()[1].assert_is_equal(json!({ "id": &carlos_contact_id })); + + // Creating a contact without address book should fail + assert_eq!( + account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "name": { + "full": "Simple Contact", + }, + "addressBookIds": {}, + }),], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Contact has to belong to at least one address book." + ); + + // Creating a contact with a duplicate UID should fail + assert!( + account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "name": { + "full": "Simple Contact", + }, + "addressBookIds": { + &book1_id: true + }, + }),], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description() + .contains( + "Contact with UID urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 already exists" + ), + ); + + // Patching tests + let response = account + .jmap_update( + MethodObject::ContactCard, + [ + ( + &sarah_contact_id, + json!({ + "name/full": "Sarah O'Connor", + "name/components/0/value": "O'Connor", + format!("addressBookIds/{book2_id}"): true + }), + ), + ( + &carlos_contact_id, + json!({ + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + "nicknames/k1": (), + "nicknames/k2": { + "name": "Carlitos" + }, + }), + ), + ( + &acme_contact_id, + json!({ + format!("addressBookIds/{book2_id}"): false, + "keywords/B2B": false, + "keywords/B2C": true, + }), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + response.updated(&sarah_contact_id); + response.updated(&carlos_contact_id); + response.updated(&acme_contact_id); + + // Verify patches + let response = account + .jmap_get( + MethodObject::ContactCard, + [ + JSContactProperty::::Id, + JSContactProperty::AddressBookIds, + JSContactProperty::Name, + JSContactProperty::Keywords, + JSContactProperty::Nicknames, + ], + [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], + ) + .await; + + response.list()[0].assert_is_equal(json!({ + "id": &sarah_contact_id, + "name": { + "full": "Sarah O'Connor", + "components": [ + { + "kind": "surname", + "value": "O'Connor" + }, + { + "kind": "given", + "value": "Sarah" + }, + { + "kind": "given2", + "value": "Marie" + }, + { + "kind": "title", + "value": "Dr." + }, + { + "kind": "credential", + "value": "Ph.D." + } + ], + "isOrdered": true + }, + "nicknames": { + "k1": { + "name": "Sadie" + } + }, + "keywords": { + "Work": true, + "Research": true, + "VIP": true + }, + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + })); + + response.list()[1].assert_is_equal(json!({ + "id": &carlos_contact_id, + "name": { + "components": [ + { + "kind": "surname", + "value": "Rodriguez-Martinez" + }, + { + "kind": "given", + "value": "Carlos" + }, + { + "kind": "given2", + "value": "Alberto" + }, + { + "kind": "title", + "value": "Mr." + }, + { + "kind": "credential", + "value": "Jr." + } + ], + "isOrdered": true, + "full": "Carlos Rodriguez-Martinez" + }, + "keywords": { + "Marketing": true, + "Management": true, + "International": true + }, + "nicknames": { + "k2": { + "name": "Carlitos" + } + }, + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + })); + + response.list()[2].assert_is_equal(json!({ + "id": acme_contact_id, + "addressBookIds": { + &book1_id: true, + }, + "name": { + "full": "Acme Business Solutions Ltd." + }, + "keywords": { + "Technology": true, + "B2C": true, + "Solutions": true, + "Services": true + } + })); + + // Query tests + test.wait_for_tasks().await; + let email = if !test.server.search_store().is_mysql() { + "sarah.johnson@example.com" + } else { + "sarah.johnson@example" + }; + assert_eq!( + account + .jmap_query( + MethodObject::ContactCard, + [ + ("text", "Sarah"), + ("inAddressBook", book1_id.as_str()), + ("uid", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + ("email", email), + ], + ["created"], + Vec::<(&str, &str)>::new(), + ) + .await + .ids() + .collect::>(), + [sarah_contact_id.as_str()] + .into_iter() + .collect::>() + ); + + // Parse tests + account + .jmap_method_calls(json!([ + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "vcard": { + "data": [ + { + "data:asText": r#"BEGIN:VCARD +VERSION:4.0 +KIND:individual +FN:Jane Doe +ORG:ABC\, Inc.;North American Division;Marketing +END:VCARD"# + } + ] + } + } + }, + "S4" + ], + [ + "ContactCard/parse", + { + "accountId": account.id_string(), + "blobIds": [ + "#vcard" + ] + }, + "G4" + ] + ])) + .await + .pointer("/methodResponses/1/1/parsed") + .unwrap() + .as_object() + .unwrap() + .iter() + .next() + .unwrap() + .1 + .assert_is_equal(json!({ + "name": { + "full": "Jane Doe" + }, + "version": "1.0", + "vCard": { + "properties": [ + [ + "version", + {}, + "unknown", + "4.0" + ] + ] + }, + "organizations": { + "k1": { + "name": "ABC, Inc.", + "units": [ + { + "name": "North American Division" + }, + { + "name": "Marketing" + } + ] + } + }, + "@type": "Card", + "kind": "individual" + })); + + // Deletion tests + test.wait_for_tasks().await; + assert_eq!( + account + .jmap_destroy( + MethodObject::ContactCard, + [carlos_contact_id.as_str(), acme_contact_id.as_str()], + Vec::<(&str, &str)>::new() + ) + .await + .destroyed() + .collect::>(), + [carlos_contact_id.as_str(), acme_contact_id.as_str()] + .into_iter() + .collect::>() + ); + + // CardDAV compatibility tests + let account_id = account.id().document_id(); + let dav_client = account.webdav_client(); + let resources = test + .server + .fetch_dav_resources(account_id, account_id, SyncCollection::AddressBook) + .await + .unwrap(); + let path = format!( + "{}{}", + resources.base_path, + resources + .paths + .iter() + .find(|v| v.parent_id.is_some()) + .unwrap() + .path + ); + let vcard = dav_client + .request("GET", &path, "") + .await + .with_status(StatusCode::OK) + .expect_body() + .lines() + .map(String::from) + .collect::>(); + let expected_vcard = TEST_VCARD_1 + .lines() + .map(String::from) + .collect::>(); + assert_eq!(vcard, expected_vcard); + + // Moving a card between address books tombstones the previous CardDAV href + test.wait_for_tasks().await; + let card_base_path = format!("{}/jdoe%40example.com/", DavResourceName::Card.base_path()); + let sync_token = dav_client + .sync_collection(&card_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + let moved_card_id = account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "@type": "Card", + "uid": "9b1f6c22-2f0e-4d6b-8f3a-1c7f2b5d9e10", + "name": { + "full": "Moving Contact", + }, + "addressBookIds": { + &book1_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = dav_client + .sync_collection( + &card_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(1); + let sync_token = response.sync_token().to_string(); + let href_in_book1 = response.hrefs()[0].to_string(); + + account + .jmap_update( + MethodObject::ContactCard, + [( + &moved_card_id, + json!({ + "addressBookIds": { + &book2_id: true + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&moved_card_id); + + let response = dav_client + .sync_collection( + &card_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(2); + let sync_token = response.sync_token().to_string(); + let href_in_book2 = response + .hrefs() + .into_iter() + .find(|href| *href != href_in_book1) + .unwrap() + .to_string(); + let response = response.into_propfind_response(None); + response + .properties(&href_in_book1) + .with_status(StatusCode::NOT_FOUND); + response + .properties(&href_in_book2) + .with_status(StatusCode::OK); + + account + .jmap_destroy( + MethodObject::ContactCard, + [moved_card_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .assert_destroyed(&[Id::from_str(&moved_card_id).unwrap()]); + + dav_client + .sync_collection( + &card_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(1) + .into_propfind_response(None) + .properties(&href_in_book2) + .with_status(StatusCode::NOT_FOUND); + + // Clean up + test.wait_for_tasks().await; + account.destroy_all_addressbooks().await; + test.assert_is_empty().await; +} + +fn test_jscontact_1() -> Value { + json!({ + "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "@type": "Card", + "preferredLanguages": { + "k1": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "fr", + "contexts": { + "work": true + }, + "pref": 2 + } + }, + "name": { + "full": "Sarah Johnson", + "components": [ + { + "kind": "surname", + "value": "Johnson" + }, + { + "kind": "given", + "value": "Sarah" + }, + { + "kind": "given2", + "value": "Marie" + }, + { + "kind": "title", + "value": "Dr." + }, + { + "kind": "credential", + "value": "Ph.D." + } + ], + "isOrdered": true + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=sarah.johnson@example.com", + "contexts": { + "pgp": true + } + } + }, + "keywords": { + "Work": true, + "Research": true, + "VIP": true + }, + "anniversaries": { + "k1": { + "date": { + "@type": "PartialDate", + "year": 1985, + "month": 4, + "day": 15 + }, + "kind": "birth" + }, + "k2": { + "date": { + "@type": "PartialDate", + "year": 2010, + "month": 6, + "day": 10 + }, + "kind": "wedding" + } + }, + "links": { + "k1": { + "uri": "https://www.example.com/staff/sjohnson", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://www.sarahjohnson.example.com", + "contexts": { + "private": true + } + } + }, + "organizations": { + "k1": { + "name": "Acme Technologies Inc.", + "units": [ + { + "name": "Research Department" + } + ] + } + }, + "emails": { + "k1": { + "address": "sarah.johnson@example.com", + "contexts": { + "work": true + } + }, + "k2": { + "address": "sarahjpersonal@example.com", + "contexts": { + "private": true, + "pref": true + } + } + }, + "phones": { + "k1": { + "number": "+1-555-123-4567", + "contexts": { + "pref": true + }, + "features": { + "mobile": true, + "voice": true + } + }, + "k2": { + "number": "+1-555-987-6543", + "contexts": { + "work": true + }, + "features": { + "voice": true + } + }, + "k3": { + "number": "+1-555-456-7890", + "contexts": { + "private": true + }, + "features": { + "voice": true + } + } + }, + "version": "1.0", + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA", + "components": [ + { + "kind": "name", + "value": "123 Business Ave" + }, + { + "kind": "locality", + "value": "New York" + }, + { + "kind": "region", + "value": "NY" + }, + { + "kind": "postcode", + "value": "10001" + }, + { + "kind": "country", + "value": "USA" + } + ], + "timeZone": "Etc/GMT+5", + "coordinates": "40.7128;-74.0060", + "isOrdered": true + }, + "k2": { + "contexts": { + "private": true, + "pref": true + }, + "full": "456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA", + "components": [ + { + "kind": "name", + "value": "456 Residential St" + }, + { + "kind": "locality", + "value": "Brooklyn" + }, + { + "kind": "region", + "value": "NY" + }, + { + "kind": "postcode", + "value": "11201" + }, + { + "kind": "country", + "value": "USA" + } + ], + "isOrdered": true + } + }, + "titles": { + "k1": { + "name": "Senior Research Scientist", + "kind": "title" + }, + "k2": { + "name": "Team Lead", + "kind": "role", + "organizationId": "k1" + } + }, + "nicknames": { + "k1": { + "name": "Sadie" + } + }, + "notes": { + "k1": { + "note": "Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST." + } + }, + "updated": "2022-03-15T13:30:00Z" + }) +} + +fn test_jscontact_2() -> Value { + json!({ + "phones": { + "k1": { + "number": "+34-611-234-567", + "contexts": { + "pref": true + }, + "features": { + "mobile": true, + "voice": true + } + }, + "k2": { + "number": "+34-911-876-543", + "contexts": { + "work": true + }, + "features": { + "voice": true + } + }, + "k3": { + "number": "+34-644-321-987", + "contexts": { + "private": true + }, + "features": { + "voice": true + } + }, + "k4": { + "number": "+34-911-876-544", + "features": { + "fax": true + } + } + }, + "keywords": { + "Marketing": true, + "Management": true, + "International": true + }, + "kind": "individual", + "anniversaries": { + "k1": { + "date": { + "@type": "PartialDate", + "month": 6, + "day": 23 + }, + "kind": "birth" + }, + "k2": { + "date": { + "@type": "PartialDate", + "year": 2015, + "month": 8, + "day": 9 + }, + "kind": "wedding" + } + }, + "members": { + "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true + }, + "uid": "urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a", + "name": { + "components": [ + { + "kind": "surname", + "value": "Rodriguez-Martinez" + }, + { + "kind": "given", + "value": "Carlos" + }, + { + "kind": "given2", + "value": "Alberto" + }, + { + "kind": "title", + "value": "Mr." + }, + { + "kind": "credential", + "value": "Jr." + } + ], + "full": "Carlos Rodriguez-Martinez", + "isOrdered": true + }, + "nicknames": { + "k1": { + "name": "Charlie" + } + }, + "relatedTo": { + "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": { + "relation": { + "friend": true + } + } + }, + "emails": { + "k1": { + "address": "carlos.rodriguez@example-corp.com", + "contexts": { + "work": true, + "pref": true + } + }, + "k2": { + "address": "carlosrm@personalmail.example", + "contexts": { + "private": true + } + } + }, + "directories": { + "k1": { + "uri": "https://contacts.example.com/carlosrodriguez.vcf", + "kind": "entry" + } + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=carlos.rodriguez@example-corp.com", + "contexts": { + "pgp": true + } + } + }, + "version": "1.0", + "notes": { + "k1": { + "note": "Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET." + } + }, + "updated": "2023-07-12T09:21:35Z", + "links": { + "k1": { + "uri": "https://www.example-corp.com/team/carlos", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://www.carlosrodriguez.example", + "contexts": { + "private": true + } + }, + "k3": { + "uri": "https://linkedin.com/in/carlosrodriguezm", + "contexts": { + "social": true + } + } + }, + "@type": "Card", + "titles": { + "k1": { + "name": "Digital Marketing Director", + "kind": "title" + }, + "k2": { + "name": "Department Head", + "kind": "role", + "organizationId": "k1" + } + }, + "preferredLanguages": { + "k1": { + "language": "es", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 2 + }, + "k3": { + "language": "pt", + "contexts": { + "work": true + }, + "pref": 3 + } + }, + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain", + "components": [ + { + "kind": "name", + "value": "Calle Empresarial 42" + }, + { + "kind": "locality", + "value": "Madrid" + }, + { + "kind": "postcode", + "value": "28001" + }, + { + "kind": "country", + "value": "Spain" + } + ], + "timeZone": "Etc/GMT-1", + "coordinates": "40.4168;-3.7038", + "isOrdered": true + }, + "k2": { + "contexts": { + "private": true, + "pref": true + }, + "full": "Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain", + "components": [ + { + "kind": "name", + "value": "Avenida Residencial 15" + }, + { + "kind": "locality", + "value": "Madrid" + }, + { + "kind": "postcode", + "value": "28045" + }, + { + "kind": "country", + "value": "Spain" + } + ], + "isOrdered": true + } + }, + "organizations": { + "k1": { + "name": "Global Solutions S.L.", + "units": [ + { + "name": "Marketing Division" + } + ] + } + } + }) +} + +fn test_jscontact_3() -> Value { + json!({ + "kind": "org", + "organizations": { + "k1": { + "name": "Acme Business Solutions Ltd.", + "units": [ + { + "name": "Technology Division" + } + ] + } + }, + "preferredLanguages": { + "k1": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "de", + "contexts": { + "work": true + }, + "pref": 2 + }, + "k3": { + "language": "fr", + "contexts": { + "work": true + }, + "pref": 3 + } + }, + "directories": { + "k1": { + "uri": "https://directory.example.com/acme.vcf", + "kind": "entry" + } + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=info@acme-solutions.example", + "contexts": { + "pgp": true + } + } + }, + "links": { + "k1": { + "uri": "https://www.acme-solutions.example", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://support.acme-solutions.example", + "contexts": { + "support": true + } + } + }, + "name": { + "full": "Acme Business Solutions Ltd." + }, + "notes": { + "k1": { + "note": "Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VAT Reg: GB123456789" + } + }, + "uid": "urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2", + "@type": "Card", + "prodId": "-//Example Corp.//Contact Manager 3.0//EN", + "version": "1.0", + "emails": { + "k1": { + "address": "info@acme-solutions.example", + "contexts": { + "work": true, + "pref": true + } + }, + "k2": { + "address": "support@acme-solutions.example", + "contexts": { + "support": true + } + }, + "k3": { + "address": "sales@acme-solutions.example", + "contexts": { + "sales": true + } + } + }, + "phones": { + "k1": { + "number": "+44-20-1234-5678", + "contexts": { + "work": true, + "pref": true + }, + "features": { + "voice": true + } + }, + "k2": { + "number": "+44-20-1234-5679", + "features": { + "fax": true + } + }, + "k3": { + "number": "+44-800-987-6543", + "contexts": { + "support": true + } + } + }, + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited Kingdom", + "components": [ + { + "kind": "name", + "value": "10 Enterprise Way, Tech Park" + }, + { + "kind": "locality", + "value": "London" + }, + { + "kind": "postcode", + "value": "EC1A 1BB" + }, + { + "kind": "country", + "value": "United Kingdom" + } + ], + "timeZone": "Etc/UTC", + "coordinates": "51.5074;-0.1278", + "isOrdered": true + }, + "k2": { + "contexts": { + "branch": true + }, + "full": "25 Innovation Street\nManchester, M1 5QF\nUnited Kingdom", + "components": [ + { + "kind": "name", + "value": "25 Innovation Street" + }, + { + "kind": "locality", + "value": "Manchester" + }, + { + "kind": "postcode", + "value": "M1 5QF" + }, + { + "kind": "country", + "value": "United Kingdom" + } + ], + "isOrdered": true + } + }, + "updated": "2023-04-15T15:30:00Z", + "keywords": { + "Technology": true, + "B2B": true, + "Solutions": true, + "Services": true + }, + "relatedTo": { + "urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1": { + "relation": { + "contact": true + } + }, + "urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2": { + "relation": { + "contact": true + } + } + } + }) +} + +fn test_jscontact_4() -> Value { + json!({ + "@type": "Card", + "version": "1.0", + "kind": "individual", + "name": { + "@type": "Name", + "full": "Temporary Contact" + }}) +} + +const TEST_VCARD_1: &str = r#"BEGIN:VCARD +VERSION:4.0 +UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +LANG;TYPE=WORK;PREF=1;PROP-ID=k1:en +LANG;TYPE=WORK;PREF=2;PROP-ID=k2:fr +FN:Sarah O'Connor +N;JSCOMPS=";0;1;2;3;4":O'Connor;Sarah;Marie;Dr.;Ph.D.;; +KEY;TYPE=PGP;PROP-ID=k1:https://pgp.example.com/pks/lookup?op=get&search=sa + rah.johnson@example.com +CATEGORIES:Work,Research,VIP +BDAY;PROP-ID=k1:19850415 +ANNIVERSARY;PROP-ID=k2:20100610 +URL;TYPE=WORK;PROP-ID=k1:https://www.example.com/staff/sjohnson +URL;TYPE=HOME;PROP-ID=k2:https://www.sarahjohnson.example.com +ORG;PROP-ID=k1:Acme Technologies Inc.;Research Department +EMAIL;TYPE=WORK;PROP-ID=k1:sarah.johnson@example.com +EMAIL;TYPE=HOME,PREF;PROP-ID=k2:sarahjpersonal@example.com +TEL;TYPE=PREF,CELL,VOICE;PROP-ID=k1:+1-555-123-4567 +TEL;TYPE=WORK,VOICE;PROP-ID=k2:+1-555-987-6543 +TEL;TYPE=HOME,VOICE;PROP-ID=k3:+1-555-456-7890 +ADR;TYPE=WORK;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA"; + TZ=Etc/GMT+5;GEO="40.7128;-74.0060";PROP-ID=k1;JSCOMPS=";11;3;4;5;6":;;123 + Business Ave;New York;NY;10001;USA;;;;;123 Business Ave;;;;;; +ADR;TYPE=HOME,PREF;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nU + SA";PROP-ID=k2;JSCOMPS=";11;3;4;5;6":;;456 Residential St;Brooklyn;NY;1120 + 1;USA;;;;;456 Residential St;;;;;; +TITLE;PROP-ID=k1:Senior Research Scientist +JSPROP;JSPTR=titles/k2/organizationId:"k1" +ROLE;PROP-ID=k2:Team Lead +NICKNAME;PROP-ID=k1:Sadie +NOTE;PROP-ID=k1:Sarah prefers video calls over phone calls. Available Mon-T + hu 9-5 EST. +REV:20220315T133000Z +END:VCARD +"#; diff --git a/tests/src/jmap/contacts/mod.rs b/tests/src/jmap/contacts/mod.rs new file mode 100644 index 0000000..d44c474 --- /dev/null +++ b/tests/src/jmap/contacts/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod addressbook; +pub mod contact; diff --git a/tests/src/jmap/core/blob.rs b/tests/src/jmap/core/blob.rs new file mode 100644 index 0000000..5bebcca --- /dev/null +++ b/tests/src/jmap/core/blob.rs @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use email::mailbox::INBOX_ID; +use serde_json::{Value, json}; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running blob tests..."); + let account = test.account("jdoe@example.com"); + test.blob_expire_all().await; + + // Blob/set simple test + let response = account.jmap_method_call("Blob/upload", json!({ + "accountId": account.id_string(), + "create": { + "abc": { + "data" : [ + { + "data:asBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlN/gFy0ywAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=" + } + ], + "type": "image/png" + } + } + })).await; + + assert_eq!( + response + .pointer("/methodResponses/0/1/created/abc/type") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "image/png", + "Response: {:?}", + response + ); + assert_eq!( + response + .pointer("/methodResponses/0/1/created/abc/size") + .and_then(|v| v.as_i64()) + .unwrap_or_default(), + 95, + "Response: {:?}", + response + ); + + // Blob/get simple test + let blob_id = account + .jmap_method_call( + "Blob/upload", + json!({ + "accountId": account.id_string(), + "create": { + "abc": { + "data" : [ + { + "data:asText": "The quick brown fox jumped over the lazy dog." + } + ] + } + } + }), + ) + .await + .pointer("/methodResponses/0/1/created/abc/id") + .and_then(|v| v.as_str()) + .unwrap() + .to_string(); + + let response = account + .jmap_method_calls(json!([[ + "Blob/get", + { + "accountId": account.id_string(), + "ids" : [ + blob_id + ], + "properties" : [ + "data:asText", + "digest:sha", + "size" + ] + }, + "R1" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "ids" : [ + blob_id + ], + "properties" : [ + "data:asText", + "digest:sha", + "digest:sha-256", + "size" + ], + "offset" : 4, + "length" : 9 + }, + "R2" + ] + ])) + .await; + + for (pointer, expected) in [ + ( + "/methodResponses/0/1/list/0/data:asText", + "The quick brown fox jumped over the lazy dog.", + ), + ( + "/methodResponses/0/1/list/0/digest:sha", + "wIVPufsDxBzOOALLDSIFKebu+U4=", + ), + ("/methodResponses/0/1/list/0/size", "45"), + ("/methodResponses/1/1/list/0/data:asText", "quick bro"), + ( + "/methodResponses/1/1/list/0/digest:sha", + "QiRAPtfyX8K6tm1iOAtZ87Xj3Ww=", + ), + ( + "/methodResponses/1/1/list/0/digest:sha-256", + "gdg9INW7lwHK6OQ9u0dwDz2ZY/gubi0En0xlFpKt0OA=", + ), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + + test.blob_expire_all().await; + + // Blob/upload Complex Example + let response = account + .jmap_method_calls(json!([ + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "b4": { + "data": [ + { + "data:asText": "The quick brown fox jumped over the lazy dog." + } + ] + } + } + }, + "S4" + ], + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "cat": { + "data": [ + { + "data:asText": "How" + }, + { + "blobId": "#b4", + "length": 7, + "offset": 3 + }, + { + "data:asText": "was t" + }, + { + "blobId": "#b4", + "length": 1, + "offset": 1 + }, + { + "data:asBase64": "YXQ/" + } + ] + } + } + }, + "CAT" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "properties": [ + "data:asText", + "size" + ], + "ids": [ + "#cat" + ] + }, + "G4" + ] + ])) + .await; + + for (pointer, expected) in [ + ( + "/methodResponses/2/1/list/0/data:asText", + "How quick was that?", + ), + ("/methodResponses/2/1/list/0/size", "19"), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + test.blob_expire_all().await; + + // Blob/get Example with Range and Encoding Errors + let response = account.jmap_method_calls(json!([ + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "b1": { + "data": [ + { + "data:asBase64": "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==" + } + ] + }, + "b2": { + "data": [ + { + "data:asText": "hello world" + } + ], + "type" : "text/plain" + } + } + }, + "S1" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "ids": [ + "#b1", + "#b2" + ] + }, + "G1" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "ids": [ + "#b1", + "#b2" + ], + "properties": [ + "data:asText", + "size" + ] + }, + "G2" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "ids": [ + "#b1", + "#b2" + ], + "properties": [ + "data:asBase64", + "size" + ] + }, + "G3" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "offset": 0, + "length": 5, + "ids": [ + "#b1", + "#b2" + ] + }, + "G4" + ], + [ + "Blob/get", + { + "accountId": account.id_string(), + "offset": 20, + "length": 100, + "ids": [ + "#b1", + "#b2" + ] + }, + "G5" + ] + ])).await; + + for (pointer, expected) in [ + ( + "/methodResponses/1/1/list/0/data:asBase64", + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==", + ), + ("/methodResponses/1/1/list/1/data:asText", "hello world"), + ("/methodResponses/2/1/list/0/isEncodingProblem", "true"), + ("/methodResponses/2/1/list/1/data:asText", "hello world"), + ( + "/methodResponses/3/1/list/0/data:asBase64", + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==", + ), + ( + "/methodResponses/3/1/list/1/data:asBase64", + "aGVsbG8gd29ybGQ=", + ), + ("/methodResponses/4/1/list/0/data:asText", "The q"), + ("/methodResponses/4/1/list/1/data:asText", "hello"), + ("/methodResponses/5/1/list/0/isEncodingProblem", "true"), + ("/methodResponses/5/1/list/0/isTruncated", "true"), + ("/methodResponses/5/1/list/1/isTruncated", "true"), + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| match v { + Value::String(s) => Some(s.to_string()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + }) + .unwrap_or_default(), + expected, + "Pointer {pointer:?} Response: {response:?}", + ); + } + test.blob_expire_all().await; + + // Blob/lookup + let client = account.jmap_client().await; + let blob_id = client + .email_import( + concat!( + "From: bill@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ) + .as_bytes() + .to_vec(), + [&Id::from(INBOX_ID).to_string()], + None::>, + None, + ) + .await + .unwrap() + .take_blob_id(); + + let response = account + .jmap_method_call( + "Blob/lookup", + json!({ + "accountId": account.id_string(), + "typeNames": [ + "Mailbox", + "Thread", + "Email" + ], + "ids": [ + blob_id, + "not-a-blob" + ] + }), + ) + .await; + + for pointer in [ + "/methodResponses/0/1/list/0/matchedIds/Email", + "/methodResponses/0/1/list/0/matchedIds/Mailbox", + "/methodResponses/0/1/list/0/matchedIds/Thread", + ] { + assert_eq!( + response + .pointer(pointer) + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + .unwrap_or_default(), + 1, + "Pointer {pointer:?} Response: {response:#?}", + ); + } + + // Remove test data + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/core/event_source.rs b/tests/src/jmap/core/event_source.rs new file mode 100644 index 0000000..6b02bd3 --- /dev/null +++ b/tests/src/jmap/core/event_source.rs @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use email::mailbox::INBOX_ID; +use futures::StreamExt; +use jmap_client::{ + DataType, + event_source::{Changes, PushNotification}, + mailbox::Role, +}; +use std::time::Duration; +use store::ahash::AHashSet; +use tokio::sync::mpsc; +use types::id::Id; + +use crate::utils::{server::TestServer, smtp::SmtpConnection}; + +pub async fn test(test: &TestServer) { + println!("Running EventSource tests..."); + + // Create test account + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + let mut changes = client + .event_source(None::>, false, 1.into(), None) + .await + .unwrap(); + + let (event_tx, mut event_rx) = mpsc::channel::(100); + + tokio::spawn(async move { + while let Some(change) = changes.next().await { + if let Err(_err) = event_tx + .send(match change.unwrap() { + PushNotification::StateChange(changes) => changes, + PushNotification::CalendarAlert(_) => unreachable!(), + }) + .await + { + //println!("Error sending event: {}", _err); + break; + } + } + }); + + assert_ping(&mut event_rx).await; + + // Create mailbox and expect state change + let mailbox_id = client + .mailbox_create("EventSource Test", None::, Role::None) + .await + .unwrap() + .take_id(); + assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await; + + // Multiple changes should be grouped and delivered in intervals + for num in 0..5 { + client + .mailbox_update_sort_order(&mailbox_id, num) + .await + .unwrap(); + } + assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await; + assert_ping(&mut event_rx).await; // Pings are only received in cfg(test) + + // Ingest email and expect state change + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.com", + &["jdoe@example.com"], + concat!( + "From: bill@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + lmtp.quit().await; + + assert_state( + &mut event_rx, + account.id_string(), + &[ + DataType::EmailDelivery, + DataType::Email, + DataType::Thread, + DataType::Mailbox, + ], + ) + .await; + assert_ping(&mut event_rx).await; + + // Destroy mailbox + client.mailbox_destroy(&mailbox_id, true).await.unwrap(); + assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await; + + // Destroy Inbox + client + .mailbox_destroy(&Id::from(INBOX_ID).to_string(), true) + .await + .unwrap(); + assert_state( + &mut event_rx, + account.id_string(), + &[DataType::Email, DataType::Thread, DataType::Mailbox], + ) + .await; + assert_ping(&mut event_rx).await; + assert_ping(&mut event_rx).await; + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +async fn assert_state( + event_rx: &mut mpsc::Receiver, + account_id: &str, + state: &[DataType], +) { + match tokio::time::timeout(Duration::from_millis(700), event_rx.recv()).await { + Ok(Some(changes)) => { + assert_eq!( + changes + .changes(account_id) + .unwrap() + .map(|x| x.0) + .collect::>(), + state.iter().collect::>() + ); + } + result => { + panic!("Timeout waiting for event {:?}: {:?}", state, result); + } + } +} + +async fn assert_ping(event_rx: &mut mpsc::Receiver) { + match tokio::time::timeout(Duration::from_millis(1100), event_rx.recv()).await { + Ok(Some(changes)) => { + assert!(changes.changes("ping").is_some(),); + } + _ => { + panic!("Did not receive ping."); + } + } +} diff --git a/tests/src/jmap/core/mod.rs b/tests/src/jmap/core/mod.rs new file mode 100644 index 0000000..0e8c9a9 --- /dev/null +++ b/tests/src/jmap/core/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod blob; +pub mod event_source; +pub mod push_subscription; +pub mod websocket; diff --git a/tests/src/jmap/core/push_subscription.rs b/tests/src/jmap/core/push_subscription.rs new file mode 100644 index 0000000..3d59eb8 --- /dev/null +++ b/tests/src/jmap/core/push_subscription.rs @@ -0,0 +1,786 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + AssertConfig, + utils::{server::TestServer, smtp::SmtpConnection}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use common::{config::server::Listeners, network::SessionData}; +use ece::EcKeyComponents; +use email::push::{EmailPush, Urgency}; +use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body}; +use hyper::{ + StatusCode, body, + header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE}, + server::conn::http1, + service::service_fn, +}; +use hyper_util::rt::TokioIo; +use jmap_client::{mailbox::Role, push_subscription::Keys}; +use jmap_proto::{ + method::query::Filter, + object::{email::EmailFilter, push_subscription::EmailPushProperty}, + request::capability::{Capabilities, Capability}, + types::state::State, +}; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::{ObjectType, SocketAddr}, + structs::{NetworkListener, SystemSettings}, + }, + types::{id::ObjectId, map::Map}, +}; +use serde_json::json; +use services::state_manager::ece::ece_encrypt; +use services::state_manager::email_push::build_email_push_object; +use std::{ + str::FromStr, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use store::{ + ahash::AHashSet, + registry::{RegistryObject, bootstrap::Bootstrap}, +}; +use tokio::sync::mpsc; +use types::{id::Id, keyword::Keyword, type_state::DataType}; +use utils::map::vec_map::VecMap; + +pub async fn test(test: &TestServer) { + println!("Running Push Subscription tests..."); + + // ECE roundtrip test + ece_roundtrip(); + + // Create test account + let account = test.account("robert@example.com"); + let client = account.jmap_client().await; + + // Create channels + let (event_tx, mut event_rx) = mpsc::channel::(100); + + // Create subscription keys + let (keypair, auth_secret) = ece::generate_keypair_and_auth_secret().unwrap(); + let pubkey = keypair.pub_as_raw().unwrap(); + let keys = Keys::new(&pubkey, &auth_secret); + + // The server must expose a VAPID key and advertise it in the session capabilities + let vapid_public_key = test + .server + .core + .jmap + .vapid + .as_ref() + .expect("A VAPID key must be configured") + .public_key() + .to_string(); + let advertised_key = test + .server + .core + .jmap + .capabilities + .session + .iter() + .find_map( + |(capability, capabilities)| match (capability, capabilities) { + (Capability::WebPushVapid, Capabilities::WebPush(webpush)) => { + Some(webpush.application_server_key.as_str()) + } + _ => None, + }, + ) + .expect("The webpush-vapid capability must be advertised"); + assert_eq!( + advertised_key, vapid_public_key, + "The advertised applicationServerKey must match the signing key" + ); + + let push_server = Arc::new(PushServer { + keypair: keypair.raw_components().unwrap(), + auth_secret: auth_secret.to_vec(), + vapid_public_key, + endpoint_origin: "https://127.0.0.1:19000".to_string(), + tx: event_tx, + fail_requests: false.into(), + }); + + // Start mock push server + let mut bp = Bootstrap::new_uninitialized(test.server.registry().clone()); + let mut servers = Listeners::default(); + servers.parse_server( + &mut bp, + RegistryObject { + id: ObjectId::new(ObjectType::NetworkListener, 0u64.into()), + object: NetworkListener { + name: "mock-push".into(), + bind: Map::new(vec![SocketAddr::from_str("127.0.0.1:19000").unwrap()]), + protocol: NetworkListenerProtocol::Http, + tls_implicit: true, + use_tls: true, + socket_reuse_address: true, + socket_reuse_port: true, + ..Default::default() + }, + revision: 0, + }, + &SystemSettings::default(), + ); + servers + .parse_tcp_acceptors(&mut bp, test.server.inner.clone()) + .await; + servers.bind_and_drop_priv(&mut bp); + bp.assert_no_errors(); + let _shutdown_tx = servers.spawn(|server, acceptor, shutdown_rx| { + server.spawn( + SessionManager::from(push_server.clone()), + test.server.inner.clone(), + acceptor, + shutdown_rx, + ); + }); + + // Register push notification (no encryption) + let push_id = client + .push_subscription_create("123", "https://127.0.0.1:19000/push", None) + .await + .unwrap() + .take_id(); + + // Expect push verification + let verification = expect_push(&mut event_rx).await.unwrap_verification(); + assert_eq!(verification.push_subscription_id, push_id); + + // Update verification code + client + .push_subscription_verify(&push_id, verification.verification_code) + .await + .unwrap(); + + // Create a mailbox and expect a state change + let mailbox_id = client + .mailbox_create("PushSubscription Test", None::, Role::None) + .await + .unwrap() + .take_id(); + + assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await; + + // Receive states just for the requested types + client + .push_subscription_update_types(&push_id, [jmap_client::DataType::Email].into()) + .await + .unwrap(); + client + .mailbox_update_sort_order(&mailbox_id, 123) + .await + .unwrap(); + expect_nothing(&mut event_rx).await; + + // Destroy subscription + client.push_subscription_destroy(&push_id).await.unwrap(); + + // Only one verification per minute is allowed + let push_id = client + .push_subscription_create("invalid", "https://127.0.0.1:19000/push", None) + .await + .unwrap() + .take_id(); + expect_nothing(&mut event_rx).await; + client.push_subscription_destroy(&push_id).await.unwrap(); + + // Register push notification (with encryption) + let push_id = client + .push_subscription_create( + "123", + "https://127.0.0.1:19000/push?skip_checks=true", // skip_checks only works in cfg(test) + keys.into(), + ) + .await + .unwrap() + .take_id(); + + // Expect push verification + let verification = expect_push(&mut event_rx).await.unwrap_verification(); + assert_eq!(verification.push_subscription_id, push_id); + + // Update verification code + client + .push_subscription_verify(&push_id, verification.verification_code) + .await + .unwrap(); + + // Failed deliveries should be re-attempted + push_server.fail_requests.store(true, Ordering::Relaxed); + client + .mailbox_update_sort_order(&mailbox_id, 101) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + push_server.fail_requests.store(false, Ordering::Relaxed); + assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await; + + // Make a mailbox change and expect state change + client + .mailbox_rename(&mailbox_id, "My Mailbox") + .await + .unwrap(); + assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await; + //expect_nothing(&mut event_rx).await; + + // Multiple change updates should be grouped and pushed in intervals + for num in 0..5 { + client + .mailbox_update_sort_order(&mailbox_id, num) + .await + .unwrap(); + } + assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await; + expect_nothing(&mut event_rx).await; + + // Destroy mailbox + client.push_subscription_destroy(&push_id).await.unwrap(); + client.mailbox_destroy(&mailbox_id, true).await.unwrap(); + expect_nothing(&mut event_rx).await; + + let account_id_str = account.id_string().to_string(); + let p256dh = URL_SAFE_NO_PAD.encode(&pubkey); + let auth = URL_SAFE_NO_PAD.encode(auth_secret); + let create = account + .jmap_request( + &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:emailpush", + ], + json!([[ + "PushSubscription/set", + { + "create": { + "i0": { + "deviceClientId": "emailpush", + "url": "https://127.0.0.1:19000/push?skip_checks=true", + "keys": { "p256dh": p256dh, "auth": auth }, + "types": [], + "emailPush": { + (account_id_str.clone()): { + "filter": { "subject": "urgent" }, + "properties": ["from", "subject", "id"], + "urgency": "high" + } + } + } + } + }, + "0" + ]]), + ) + .await; + let ep_id = create.created_id(0); + + let verification = expect_push(&mut event_rx).await.unwrap_verification(); + assert_eq!(verification.push_subscription_id, ep_id.to_string()); + account + .jmap_request( + &["urn:ietf:params:jmap:core"], + json!([[ + "PushSubscription/set", + { "update": { (ep_id.to_string()): { "verificationCode": verification.verification_code } } }, + "0" + ]]), + ) + .await; + + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "sender@example.com", + &["robert@example.com"], + concat!( + "From: Sender \r\n", + "To: robert@example.com\r\n", + "Subject: Urgent: action required\r\n", + "\r\n", + "Please respond as soon as possible." + ), + ) + .await; + lmtp.quit().await; + + let (push_account, emails, state) = expect_push(&mut event_rx).await.unwrap_email_push(); + assert_eq!(push_account.to_string(), account_id_str); + assert!(state.is_some(), "EmailPush must carry the Email state"); + assert_eq!(emails.len(), 1, "expected exactly one email in the push"); + let email = emails[0].to_string(); + assert!( + email.contains("Urgent: action required"), + "subject missing: {email}" + ); + assert!( + email.contains("sender@example.com"), + "from missing: {email}" + ); + + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "sender@example.com", + &["robert@example.com"], + concat!( + "From: Sender \r\n", + "To: robert@example.com\r\n", + "Subject: weekly newsletter\r\n", + "\r\n", + "Nothing important here." + ), + ) + .await; + lmtp.quit().await; + expect_nothing(&mut event_rx).await; + + account + .jmap_request( + &["urn:ietf:params:jmap:core"], + json!([[ + "PushSubscription/set", + { "destroy": [ep_id.to_string()] }, + "0" + ]]), + ) + .await; + + // Test the EmailPush object builder (filters and size limits) directly + test_email_push_object(test).await; + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +async fn test_email_push_object(test: &TestServer) { + let account = test.account("robert@example.com"); + let client = account.jmap_client().await; + let account_id = account.id().document_id(); + + let mailbox_id = client + .mailbox_create("EmailPush Object Test", None::, Role::None) + .await + .unwrap() + .take_id(); + let mailbox_doc_id = Id::from_str(&mailbox_id).unwrap().document_id(); + + let email_id = client + .email_import( + b"From: Alice \r\nTo: robert@example.com\r\nSubject: Urgent meeting tonight\r\n\r\nPlease join the urgent meeting tonight.".to_vec(), + [&mailbox_id], + Some(["$notify"]), + None, + ) + .await + .unwrap() + .take_id(); + let document_id = Id::from_str(&email_id).unwrap().document_id(); + test.wait_for_tasks().await; + + let properties = vec![ + EmailPushProperty::Id, + EmailPushProperty::From, + EmailPushProperty::Subject, + ]; + let config = |filter: Vec>| EmailPush { + account_id, + properties: properties.clone(), + filter, + urgency: Urgency::Normal, + }; + + // Matching subject (case-insensitive substring), full object is produced + let value = build_email_push_object( + &test.server, + account_id, + document_id, + &config(vec![Filter::Property(EmailFilter::Subject( + "URGENT".into(), + ))]), + 4096, + ) + .await + .unwrap() + .expect("matching subject filter must produce an object"); + let json = serde_json::to_string(&value).unwrap(); + assert!( + json.contains("Urgent meeting tonight"), + "subject missing: {json}" + ); + assert!(json.contains("alice@example.com"), "from missing: {json}"); + + // A collection of filters that should each either match (Some) or not (None) + for (expected_match, filter) in [ + ( + false, + vec![Filter::Property(EmailFilter::Subject( + "does-not-appear".into(), + ))], + ), + ( + true, + vec![Filter::Property(EmailFilter::InMailbox(Id::from( + mailbox_doc_id, + )))], + ), + ( + false, + vec![Filter::Property(EmailFilter::InMailbox(Id::from( + mailbox_doc_id + 1, + )))], + ), + ( + true, + vec![Filter::Property(EmailFilter::HasKeyword(Keyword::parse( + "$notify", + )))], + ), + ( + false, + vec![Filter::Property(EmailFilter::NotKeyword(Keyword::parse( + "$notify", + )))], + ), + (true, vec![]), + ( + true, + vec![ + Filter::Or, + Filter::Property(EmailFilter::Subject("urgent".into())), + Filter::Property(EmailFilter::From("nobody@example.com".into())), + Filter::Close, + ], + ), + ( + false, + vec![ + Filter::And, + Filter::Property(EmailFilter::Subject("urgent".into())), + Filter::Property(EmailFilter::From("nobody@example.com".into())), + Filter::Close, + ], + ), + ] { + let result = build_email_push_object( + &test.server, + account_id, + document_id, + &config(filter.clone()), + 4096, + ) + .await + .unwrap(); + assert_eq!( + result.is_some(), + expected_match, + "filter produced the wrong match result: {filter:?}" + ); + } + + // Size limit: a generous budget keeps every property, a tiny budget drops some (in order) + let (full, _) = + build_email_push_object(&test.server, account_id, document_id, &config(vec![]), 4096) + .await + .unwrap() + .expect("object"); + assert_eq!( + full.as_object().unwrap().as_vec().len(), + 3, + "all requested properties must fit under a generous budget" + ); + let (truncated, _) = + build_email_push_object(&test.server, account_id, document_id, &config(vec![]), 50) + .await + .unwrap() + .expect("object"); + assert!( + truncated.as_object().unwrap().as_vec().len() < 3, + "a tiny size budget must drop properties: {}", + serde_json::to_string(&truncated).unwrap() + ); +} + +#[derive(Clone)] +pub struct SessionManager { + pub inner: Arc, +} + +impl From> for SessionManager { + fn from(inner: Arc) -> Self { + SessionManager { inner } + } +} +pub struct PushServer { + keypair: EcKeyComponents, + auth_secret: Vec, + vapid_public_key: String, + endpoint_origin: String, + tx: mpsc::Sender, + fail_requests: AtomicBool, +} + +#[derive(serde::Deserialize, Debug)] +#[serde(untagged)] +enum PushMessage { + PushObject(PushObject), + Verification(PushVerification), +} + +#[allow(dead_code)] +#[derive(serde::Deserialize, Debug)] +#[serde(tag = "@type")] +enum PushObject { + StateChange { + changed: VecMap>, + }, + EmailPush { + #[serde(rename = "accountId")] + account_id: Id, + #[serde(default)] + emails: Vec, + #[serde(default)] + state: Option, + }, + CalendarAlert { + #[serde(rename = "accountId")] + account_id: Id, + #[serde(rename = "calendarEventId")] + calendar_event_id: Id, + uid: String, + #[serde(rename = "recurrenceId")] + recurrence_id: Option, + #[serde(rename = "alertId")] + alert_id: String, + }, +} + +impl PushMessage { + pub fn unwrap_state_change(self) -> VecMap> { + match self { + PushMessage::PushObject(PushObject::StateChange { changed }) => changed, + _ => panic!("Expected PushObject"), + } + } + + pub fn unwrap_verification(self) -> PushVerification { + match self { + PushMessage::Verification(verification) => verification, + _ => panic!("Expected Verification"), + } + } + + pub fn unwrap_email_push(self) -> (Id, Vec, Option) { + match self { + PushMessage::PushObject(PushObject::EmailPush { + account_id, + emails, + state, + }) => (account_id, emails, state), + other => panic!("Expected EmailPush, got: {other:?}"), + } + } +} + +#[derive(serde::Deserialize, Debug)] +enum PushVerificationType { + PushVerification, +} + +#[derive(serde::Deserialize, Debug)] +struct PushVerification { + #[serde(rename = "@type")] + _type: PushVerificationType, + #[serde(rename = "pushSubscriptionId")] + pub push_subscription_id: String, + #[serde(rename = "verificationCode")] + pub verification_code: String, +} + +impl common::network::SessionManager for SessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let push = self.inner; + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|mut req: hyper::Request| { + let push = push.clone(); + + async move { + if push.fail_requests.load(Ordering::Relaxed) { + return Ok(HtmlResponse::with_status( + StatusCode::TOO_MANY_REQUESTS, + "too many requests".to_string(), + ) + .into_http_response() + .build()); + } + + // Every push POST must be authenticated with a VAPID token (RFC 9749) + let authorization = req + .headers() + .get(AUTHORIZATION) + .map(|value| value.to_str().unwrap().to_string()) + .expect("Push POST must carry a VAPID Authorization header"); + assert_vapid_authorization( + &authorization, + &push.vapid_public_key, + &push.endpoint_origin, + ); + + let is_encrypted = req + .headers() + .get(CONTENT_ENCODING) + .is_some_and(|encoding| encoding.to_str().unwrap() == "aes128gcm"); + + let content_type = req + .headers() + .get(CONTENT_TYPE) + .map(|value| value.to_str().unwrap()) + .expect("Push POST must carry a Content-Type header"); + assert_eq!( + content_type, + if is_encrypted { + "application/octet-stream" + } else { + "application/json" + }, + "unexpected Content-Type for encrypted={is_encrypted} push" + ); + let body = fetch_body(&mut req, 1024 * 1024, 0).await.unwrap(); + let message = serde_json::from_slice::(&if is_encrypted { + ece::decrypt(&push.keypair, &push.auth_secret, &body).unwrap() + } else { + body + }) + .unwrap(); + + //println!("Push received ({}): {:?}", is_encrypted, message); + + push.tx.send(message).await.unwrap(); + + Ok::<_, hyper::Error>( + HtmlResponse::new("ok".to_string()) + .into_http_response() + .build(), + ) + } + }), + ) + .await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} + +fn assert_vapid_authorization(header: &str, expected_key: &str, expected_origin: &str) { + let (token, key) = header + .strip_prefix("vapid ") + .and_then(|rest| rest.split_once(", ")) + .expect("VAPID header must be 'vapid t=, k='"); + let jwt = token.strip_prefix("t=").expect("Missing t= parameter"); + let key = key.strip_prefix("k=").expect("Missing k= parameter"); + assert_eq!( + key, expected_key, + "The k= parameter must match the advertised applicationServerKey" + ); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3, "A JWT must have three parts"); + let decode = |part: &str| { + URL_SAFE_NO_PAD + .decode(part) + .expect("Each JWT part must be base64url encoded") + }; + assert_eq!( + decode(parts[0]), + br#"{"typ":"JWT","alg":"ES256"}"#, + "The JWT header must declare typ JWT and alg ES256" + ); + + let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap(); + assert_eq!( + claims["aud"], expected_origin, + "The aud claim must be the push endpoint origin" + ); + let now = store::write::now(); + let exp = claims["exp"] + .as_u64() + .expect("The exp claim must be a number"); + assert!( + exp > now && exp <= now + 24 * 3600, + "The exp claim must be no more than 24 hours in the future (exp={exp}, now={now})" + ); +} + +async fn expect_push(event_rx: &mut mpsc::Receiver) -> PushMessage { + match tokio::time::timeout(Duration::from_millis(1500), event_rx.recv()).await { + Ok(Some(push)) => { + //println!("Push received: {:?}", push); + push + } + result => { + panic!("Timeout waiting for push: {:?}", result); + } + } +} + +async fn expect_nothing(event_rx: &mut mpsc::Receiver) { + match tokio::time::timeout(Duration::from_millis(1000), event_rx.recv()).await { + Err(_) => {} + message => { + panic!("Received a message when expecting nothing: {:?}", message); + } + } +} + +async fn assert_state(event_rx: &mut mpsc::Receiver, id: Id, state: &[DataType]) { + assert_eq!( + expect_push(event_rx) + .await + .unwrap_state_change() + .get(&id) + .unwrap() + .iter() + .map(|x| x.0) + .collect::>(), + state.iter().collect::>() + ); +} + +fn ece_roundtrip() { + for len in [1, 2, 5, 16, 256, 1024, 2048, 4096, 1024 * 1024] { + let (keypair, auth_secret) = ece::generate_keypair_and_auth_secret().unwrap(); + + let bytes: Vec = (0..len).map(|_| store::rand::random::()).collect(); + + let encrypted_bytes = + ece_encrypt(&keypair.pub_as_raw().unwrap(), &auth_secret, &bytes).unwrap(); + + let decrypted_bytes = ece::decrypt( + &keypair.raw_components().unwrap(), + &auth_secret, + &encrypted_bytes, + ) + .unwrap(); + + assert_eq!(bytes, decrypted_bytes, "len: {}", len); + } +} diff --git a/tests/src/jmap/core/websocket.rs b/tests/src/jmap/core/websocket.rs new file mode 100644 index 0000000..c2ac2e0 --- /dev/null +++ b/tests/src/jmap/core/websocket.rs @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use ahash::AHashSet; +use futures::StreamExt; +use jmap_client::{ + DataType, PushObject, + client_ws::WebSocketMessage, + core::{ + response::{Response, TaggedMethodResponse}, + set::SetObject, + }, +}; +use std::time::Duration; +use tokio::sync::mpsc; + +pub async fn test(test: &TestServer) { + println!("Running WebSockets tests..."); + + // Authenticate all accounts + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + let mut ws_stream = client.connect_ws().await.unwrap(); + + let (stream_tx, mut stream_rx) = mpsc::channel::(100); + + tokio::spawn(async move { + while let Some(change) = ws_stream.next().await { + stream_tx.send(change.unwrap()).await.unwrap(); + } + }); + + // Create mailbox + let mut request = client.build(); + let create_id = request + .set_mailbox() + .create() + .name("WebSocket Test") + .create_id() + .unwrap(); + let request_id = request.send_ws().await.unwrap(); + let mut response = expect_response(&mut stream_rx).await; + assert_eq!(request_id, response.request_id().unwrap()); + let mailbox_id = response + .pop_method_response() + .unwrap() + .unwrap_set_mailbox() + .unwrap() + .created(&create_id) + .unwrap() + .take_id(); + + // Enable push notifications + client + .enable_push_ws(None::>, None::<&str>) + .await + .unwrap(); + + // Make changes over standard HTTP and expect a push notification via WebSockets + client + .mailbox_update_sort_order(&mailbox_id, 1) + .await + .unwrap(); + assert_state(&mut stream_rx, account.id_string(), &[DataType::Mailbox]).await; + + // Multiple changes should be grouped and delivered in intervals + for num in 0..5 { + client + .mailbox_update_sort_order(&mailbox_id, num) + .await + .unwrap(); + } + tokio::time::sleep(Duration::from_millis(500)).await; + assert_state(&mut stream_rx, account.id_string(), &[DataType::Mailbox]).await; + expect_nothing(&mut stream_rx).await; + + // Disable push notifications + client.disable_push_ws().await.unwrap(); + + // No more changes should be received + let mut request = client.build(); + request.set_mailbox().destroy([&mailbox_id]); + request.send_ws().await.unwrap(); + expect_response(&mut stream_rx) + .await + .pop_method_response() + .unwrap() + .unwrap_set_mailbox() + .unwrap() + .destroyed(&mailbox_id) + .unwrap(); + expect_nothing(&mut stream_rx).await; + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +async fn expect_response( + stream_rx: &mut mpsc::Receiver, +) -> Response { + match tokio::time::timeout(Duration::from_millis(100), stream_rx.recv()).await { + Ok(Some(message)) => match message { + WebSocketMessage::Response(response) => response, + _ => panic!("Expected response, got: {:?}", message), + }, + result => { + panic!("Timeout waiting for websocket: {:?}", result); + } + } +} + +async fn assert_state( + stream_rx: &mut mpsc::Receiver, + id: &str, + state: &[DataType], +) { + match tokio::time::timeout(Duration::from_millis(700), stream_rx.recv()).await { + Ok(Some(message)) => match message { + WebSocketMessage::PushNotification(PushObject::StateChange { changed }) => { + assert_eq!( + changed + .get(id) + .unwrap() + .keys() + .collect::>(), + state.iter().collect::>() + ); + } + _ => panic!("Expected state change, got: {:?}", message), + }, + result => { + panic!("Timeout waiting for websocket: {:?}", result); + } + } +} + +async fn expect_nothing(stream_rx: &mut mpsc::Receiver) { + match tokio::time::timeout(Duration::from_millis(1000), stream_rx.recv()).await { + Err(_) => {} + message => { + panic!("Received a message when expecting nothing: {:?}", message); + } + } +} diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs new file mode 100644 index 0000000..3df14bc --- /dev/null +++ b/tests/src/jmap/files/acl.rs @@ -0,0 +1,586 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use jmap_proto::{ + object::{file_node::FileNodeProperty, share_notification::ShareNotificationProperty}, + request::method::MethodObject, +}; +use serde_json::json; + +pub async fn test(test: &TestServer) { + println!("Running File Storage ACL tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + + // Create test folders + let response = john + .jmap_create( + MethodObject::FileNode, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_folder_id = response.created(0).id().to_string(); + + // Verify myRights + john.jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::MyRights, + FileNodeProperty::ShareWith, + ], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, + "mayShare": true + }, + "shareWith": {} + })); + + // Obtain share notifications + let mut jane_share_change_id = jane + .jmap_get( + MethodObject::ShareNotification, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Make sure Jane has no access + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::FileNode, + Vec::<&str>::new(), + [john_folder_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Share folder with Jane + john.jmap_update( + MethodObject::FileNode, + [( + &john_folder_id, + json!({ + "shareWith": { + &jane_id : { + "mayRead": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_folder_id); + john.jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ShareWith, + ], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Test #1", + "shareWith": { + &jane_id : { + "mayRead": true, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + } + } + })); + + // Verify Jane can access the contact + jane.jmap_get_account( + john, + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::MyRights, + ], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + } + })); + + // Verify Jane received a share notification + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "FileNode", + "objectAccountId": &john_id, + "objectId": &john_folder_id, + "oldRights": { + "mayRead": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + }, + "name": null + })); + + // Updating and deleting should fail + assert_eq!( + jane.jmap_update_account( + john, + MethodObject::FileNode, + [(&john_folder_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_folder_id) + .description(), + "You are not allowed to modify this file node." + ); + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::FileNode, + [&john_folder_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_folder_id) + .description(), + "You are not allowed to delete this file node." + ); + + // Grant Jane write access + john.jmap_update( + MethodObject::FileNode, + [( + &john_folder_id, + json!({ + format!("shareWith/{jane_id}/mayAddChildren"): true, + format!("shareWith/{jane_id}/mayRename"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + format!("shareWith/{jane_id}/mayModifyContent"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_folder_id); + jane.jmap_get_account( + john, + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::MyRights, + ], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, + "mayShare": false + } + })); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "FileNode", + "objectAccountId": &john_id, + "objectId": &john_folder_id, + "oldRights": { + "mayRead": true, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, + "mayShare": false + }, + "name": null + })); + + // Creating a root folder should fail + assert_eq!( + jane.jmap_create_account( + john, + MethodObject::FileNode, + [json!({ + "name": "A new shared folder", + })], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Cannot create top-level folder in a shared account." + ); + + // Update John's folder name + jane.jmap_update_account( + john, + MethodObject::FileNode, + [( + &john_folder_id, + json!({ + "name": "Jane's updated name", + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_folder_id); + jane.jmap_get_account( + john, + MethodObject::FileNode, + [FileNodeProperty::Id, FileNodeProperty::Name], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Jane's updated name", + })); + + // Revoke Jane's access + john.jmap_update( + MethodObject::FileNode, + [( + &john_folder_id, + json!({ + format!("shareWith/{jane_id}"): () + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_folder_id); + john.jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ShareWith, + ], + [john_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_folder_id, + "name": "Jane's updated name", + "shareWith": {} + })); + + // Verify Jane can no longer access the folder or its contacts + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::FileNode, + Vec::<&str>::new(), + [john_folder_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "FileNode", + "objectAccountId": &john_id, + "objectId": &john_folder_id, + "oldRights": { + "mayRead": true, + "mayAddChildren": true, + "mayRename": true, + "mayDelete": true, + "mayModifyContent": true, + "mayShare": false + }, + "newRights": { + "mayRead": false, + "mayAddChildren": false, + "mayRename": false, + "mayDelete": false, + "mayModifyContent": false, + "mayShare": false + }, + "name": null + })); + + // Grant Jane delete access once again + john.jmap_update( + MethodObject::FileNode, + [( + &john_folder_id, + json!({ + format!("shareWith/{jane_id}/mayRead"): true, + format!("shareWith/{jane_id}/mayAddChildren"): true, + format!("shareWith/{jane_id}/mayRename"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + format!("shareWith/{jane_id}/mayModifyContent"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_folder_id); + + // FileNode/copy: Jane copies a node from her own account into John's shared folder + let jane_folder_id = jane + .jmap_create( + MethodObject::FileNode, + [json!({"name": "jane-src"})], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let copied = jane + .jmap_copy( + jane, + john, + MethodObject::FileNode, + [( + &jane_folder_id, + json!({ "parentId": &john_folder_id, "name": "copied-here" }), + )], + false, + ) + .await; + let copied_id = copied.copied(&jane_folder_id).id().to_string(); + assert_ne!(copied_id, jane_folder_id); + jane.jmap_get_account( + john, + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + ], + [copied_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &copied_id, + "name": "copied-here", + "parentId": &john_folder_id, + })); + // Original still exists in Jane's account (onSuccessDestroyOriginal=false) + jane.jmap_get( + MethodObject::FileNode, + [FileNodeProperty::Id], + [jane_folder_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ "id": &jane_folder_id })); + + // onExists=rename on copy: colliding into John's folder again must echo the new name + let renamed_copy = jane + .jmap_method_calls(json!([[ + "FileNode/copy", + { + "fromAccountId": jane.id_string(), + "accountId": john.id_string(), + "onExists": "rename", + "create": { + &jane_folder_id: { "parentId": &john_folder_id, "name": "copied-here" } + } + }, + "0" + ]])) + .await; + let renamed_entry = renamed_copy.copied(&jane_folder_id); + let renamed_copy_id = renamed_entry.id().to_string(); + assert_eq!(renamed_entry.text_field("name"), "copied-here (2)"); + + jane.jmap_destroy( + MethodObject::FileNode, + [&jane_folder_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Verify Jane can delete the folder (and the node copied into it) + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::FileNode, + [john_folder_id.as_str()], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .collect::>(), + [ + john_folder_id.as_str(), + copied_id.as_str(), + renamed_copy_id.as_str() + ] + .into_iter() + .collect::>() + ); + + // Destroy all mailboxes + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/files/mod.rs b/tests/src/jmap/files/mod.rs new file mode 100644 index 0000000..96aff77 --- /dev/null +++ b/tests/src/jmap/files/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod node; diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs new file mode 100644 index 0000000..e2accdf --- /dev/null +++ b/tests/src/jmap/files/node.rs @@ -0,0 +1,729 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + jmap::{ChangeType, JmapUtils}, + server::TestServer, +}; +use ahash::AHashSet; +use jmap_proto::{object::file_node::FileNodeProperty, request::method::MethodObject}; +use serde_json::json; + +pub async fn test(test: &TestServer) { + println!("Running File Storage tests..."); + let account = test.account("jdoe@example.com"); + + // Obtain change id + let change_id = account + .jmap_get( + MethodObject::FileNode, + [FileNodeProperty::Id], + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + let response = account + .jmap_changes(MethodObject::FileNode, &change_id) + .await; + assert_eq!(response.changes().next(), None); + assert_eq!(response.new_state(), change_id); + + // Create test folders + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({ + "name": "Root Folder", + "parentId": null, + }), + json!({ + "name": "Sub Folder", + "parentId": "#i0", + }), + json!({ + "name": "Sub-sub Folder", + "parentId": "#i1", + }), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let root_folder_id = response.created(0).id().to_string(); + let sub_folder_id = response.created(1).id().to_string(); + let sub_sub_folder_id = response.created(2).id().to_string(); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::FileNode, change_id) + .await + .changes() + .collect::>(), + [ + ChangeType::Created(&root_folder_id), + ChangeType::Created(&sub_folder_id), + ChangeType::Created(&sub_sub_folder_id) + ] + .into_iter() + .collect::>() + ); + + // Verify folder structure + let response = account + .jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + ], + [&root_folder_id, &sub_folder_id, &sub_sub_folder_id], + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 3); + list[0].assert_is_equal(json!({ + "id": &root_folder_id, + "name": "Root Folder", + "parentId": null, + })); + list[1].assert_is_equal(json!({ + "id": &sub_folder_id, + "name": "Sub Folder", + "parentId": &root_folder_id, + })); + list[2].assert_is_equal(json!({ + "id": &sub_sub_folder_id, + "name": "Sub-sub Folder", + "parentId": &sub_folder_id, + })); + + // Create file in root folder + let response = account + .jmap_method_calls(json!([ + [ + "Blob/upload", + { + "accountId": account.id_string(), + "create": { + "hello": { + "data": [ + { + "data:asText": r#"hello world"# + } + ] + } + } + }, + "S4" + ], + [ + "FileNode/set", + { + "accountId": account.id_string(), + "create": { + "i0": { + "name": "hello.txt", + "parentId": &root_folder_id, + "blobId": "#hello", + "type": "text/plain", + } + } + }, + "G4" + ] + ])) + .await; + let file_id = response + .pointer("/methodResponses/1/1/created/i0") + .unwrap() + .id() + .to_string(); + + // Verify file creation + let response = account + .jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::BlobId, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + FileNodeProperty::Type, + FileNodeProperty::Size, + ], + [&file_id], + ) + .await; + let blob_id = response.list()[0].blob_id().to_string(); + response.list()[0].assert_is_equal(json!({ + "id": &file_id, + "name": "hello.txt", + "parentId": &root_folder_id, + "type": "text/plain", + "size": 11, + "blobId": &blob_id, + })); + assert_eq!( + account + .jmap_get(MethodObject::Blob, ["data:asText"], [&blob_id]) + .await + .list()[0] + .text_field("data:asText"), + "hello world" + ); + + // Creating folders with invalid names or parent ids should fail + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({ + "name": "Sub Folder", + "parentId": &root_folder_id, + }), + json!({ + "name": "Folder under file", + "parentId": &file_id, + }), + json!({ + "name": "My/Sub/Folder", + }), + json!({ + "name": ".", + }), + json!({ + "name": "..", + }), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), sub_folder_id.as_str()); + assert_eq!( + response.not_created(1).description(), + "Parent ID does not exist or is not a folder." + ); + assert_eq!( + response.not_created(2).description(), + "Name contains a forbidden character." + ); + assert_eq!( + response.not_created(3).description(), + "Name is reserved and cannot be used." + ); + assert_eq!( + response.not_created(4).description(), + "Name is reserved and cannot be used." + ); + + // Circular folder references should fail + let response = account + .jmap_update( + MethodObject::FileNode, + [( + &root_folder_id, + json!({ + "parentId": &sub_sub_folder_id, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!( + response.not_updated(&root_folder_id).description(), + "Circular reference in parent ids." + ); + + // Rename folder and file + let response = account + .jmap_update( + MethodObject::FileNode, + [ + ( + &sub_folder_id, + json!({ + "name": "Renamed Sub Folder", + }), + ), + ( + &file_id, + json!({ + "name": "renamed-hello.txt", + }), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + response.updated(&sub_folder_id); + response.updated(&file_id); + + // Verify rename + let response = account + .jmap_get( + MethodObject::FileNode, + [ + FileNodeProperty::Id, + FileNodeProperty::Name, + FileNodeProperty::ParentId, + ], + [&sub_folder_id, &file_id], + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 2); + list[0].assert_is_equal(json!({ + "id": &sub_folder_id, + "name": "Renamed Sub Folder", + "parentId": &root_folder_id, + })); + list[1].assert_is_equal(json!({ + "id": &file_id, + "name": "renamed-hello.txt", + "parentId": &root_folder_id, + })); + + // Destroying a folder with children should fail + assert_eq!( + account + .jmap_destroy( + MethodObject::FileNode, + [&root_folder_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&root_folder_id) + .description(), + "Cannot delete non-empty folder." + ); + + // Delete file and sub folders + assert_eq!( + account + .jmap_destroy( + MethodObject::FileNode, + [&file_id], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .collect::>(), + [file_id.as_str(),].into_iter().collect::>() + ); + assert_eq!( + account + .jmap_destroy( + MethodObject::FileNode, + [&root_folder_id], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .collect::>(), + [ + sub_sub_folder_id.as_str(), + sub_folder_id.as_str(), + root_folder_id.as_str() + ] + .into_iter() + .collect::>() + ); + + // fetchParents: requesting a leaf should return its ancestors too + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "fp-root"}), + json!({"name": "fp-sub", "parentId": "#i0"}), + json!({"name": "fp-leaf", "parentId": "#i1"}), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let fp_root = response.created(0).id().to_string(); + let fp_sub = response.created(1).id().to_string(); + let fp_leaf = response.created(2).id().to_string(); + let response = account + .jmap_method_calls(json!([[ + "FileNode/get", + { + "accountId": account.id_string(), + "ids": [&fp_leaf], + "fetchParents": true, + "properties": ["id"] + }, + "0" + ]])) + .await; + let ids = response + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .map(|list| { + list.iter() + .map(|n| n.text_field("id").to_string()) + .collect::>() + }) + .expect("fetchParents response"); + assert_eq!( + ids, + [fp_leaf.as_str(), fp_sub.as_str(), fp_root.as_str()] + .into_iter() + .map(str::to_string) + .collect::>() + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&fp_root], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .for_each(drop); + + // onExists=rename should produce a unique sibling name + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt", "parentId": null, "blobId": null})], + Vec::<(&str, &str)>::new(), + ) + .await; + let dupe_orig = response.created(0).id().to_string(); + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + [("onExists", "rename")], + ) + .await; + let dupe_renamed = response.created(0); + let dupe_renamed_id = dupe_renamed.id().to_string(); + assert_eq!(dupe_renamed.text_field("name"), "dupe (2).txt"); + + // onExists=reject (default) should return alreadyExists with existingId + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), dupe_orig.as_str()); + + // onExists=replace should destroy the existing sibling + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "dupe.txt"})], + [("onExists", "replace")], + ) + .await; + let dupe_replacement = response.created(0).id().to_string(); + let destroyed = response.destroyed().collect::>(); + assert!( + destroyed.contains(dupe_orig.as_str()), + "Expected old id {dupe_orig} to be destroyed, got {destroyed:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&dupe_renamed_id, &dupe_replacement], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .for_each(drop); + + // compareCaseInsensitively should treat sibling names as case-insensitive + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "CASE"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let case_id = response.created(0).id().to_string(); + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "case"})], + [("compareCaseInsensitively", true)], + ) + .await; + let err = response.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), case_id.as_str()); + account + .jmap_destroy( + MethodObject::FileNode, + [&case_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Reject: two creates with the same name in one batch, default onExists + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-reject"}), + json!({"name": "twin-reject"}), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let twin_first = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + assert!( + err.pointer("/existingId").is_none(), + "Pending Create collision has no committed existingId, got {err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_first], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Rename: second create within the batch should auto-rename + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-rename"}), + json!({"name": "twin-rename"}), + ], + [("onExists", "rename")], + ) + .await; + let twin_a = response.created(0).id().to_string(); + let twin_b_entry = response.created(1); + let twin_b = twin_b_entry.id().to_string(); + assert_eq!(twin_b_entry.text_field("name"), "twin-rename (2)"); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_a, &twin_b], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Replace: within-batch replace is intentionally not supported; second is rejected + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-replace"}), + json!({"name": "twin-replace"}), + ], + [("onExists", "replace")], + ) + .await; + let twin_survivor = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + assert!( + err.pointer("/existingId").is_none(), + "Pending Create + Replace returns alreadyExists with no existingId, got {err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_survivor], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Pending+Newest: in-batch newest comparison is intentionally not supported; second is rejected + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "twin-newest", "modified": "2020-01-01T00:00:00Z"}), + json!({"name": "twin-newest", "modified": "2040-01-01T00:00:00Z"}), + ], + [("onExists", "newest")], + ) + .await; + let twin_keep = response.created(0).id().to_string(); + let err = response.not_created(1); + assert_eq!(err.typ(), "alreadyExists"); + account + .jmap_destroy( + MethodObject::FileNode, + [&twin_keep], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // Create+Update collision in one batch + let setup = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "lhs"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let lhs_id = setup.created(0).id().to_string(); + let response = account + .jmap_method_calls(json!([[ + "FileNode/set", + { + "accountId": account.id_string(), + "update": { &lhs_id: { "name": "merged" } }, + "create": { "new1": { "name": "merged" } } + }, + "0" + ]])) + .await; + let created_new = response + .pointer("/methodResponses/0/1/created/new1") + .expect("new1 should be in created"); + let new1_id = created_new.id().to_string(); + let upd_err = response + .pointer(&format!("/methodResponses/0/1/notUpdated/{lhs_id}")) + .expect("update should fail"); + assert_eq!(upd_err.typ(), "alreadyExists"); + assert!( + upd_err.pointer("/existingId").is_none(), + "Pending-from-Create collision has no existingId, got {upd_err:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&lhs_id, &new1_id], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // compareCaseInsensitively + Pending: in-batch "FOO"/"foo" collide when the flag is set + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "FOO"}), json!({"name": "foo"})], + [("compareCaseInsensitively", true)], + ) + .await; + let case_keep = response.created(0).id().to_string(); + assert_eq!(response.not_created(1).typ(), "alreadyExists"); + account + .jmap_destroy( + MethodObject::FileNode, + [&case_keep], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // onExists=newest: incoming must have a strictly later modified to win + let response = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2030-01-01T00:00:00Z"})], + Vec::<(&str, &str)>::new(), + ) + .await; + let stamped_id = response.created(0).id().to_string(); + let older_attempt = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2020-01-01T00:00:00Z"})], + [("onExists", "newest")], + ) + .await; + let err = older_attempt.not_created(0); + assert_eq!(err.typ(), "alreadyExists"); + assert_eq!(err.text_field("existingId"), stamped_id.as_str()); + let newer_attempt = account + .jmap_create( + MethodObject::FileNode, + [json!({"name": "stamped", "modified": "2040-01-01T00:00:00Z"})], + [("onExists", "newest")], + ) + .await; + let stamped_winner = newer_attempt.created(0).id().to_string(); + assert_ne!(stamped_winner, stamped_id); + let destroyed = newer_attempt.destroyed().collect::>(); + assert!( + destroyed.contains(stamped_id.as_str()), + "Expected {stamped_id} to be destroyed by newer onExists=newest, got {destroyed:?}" + ); + account + .jmap_destroy( + MethodObject::FileNode, + [&stamped_winner], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + + // WebDAV compatibility: names created over JMAP are percent-encoded in hrefs + let dav_client = account.webdav_client(); + let response = account + .jmap_create( + MethodObject::FileNode, + [ + json!({"name": "Ünterlagen 2026", "parentId": null}), + json!({"name": "Q1 & Q2 (final)", "parentId": "#i0"}), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let dav_parent_id = response.created(0).id().to_string(); + response.created(1); + let dav_parent_path = "/dav/file/jdoe%40example.com/%C3%9Cnterlagen%202026"; + let dav_child_path = + "/dav/file/jdoe%40example.com/%C3%9Cnterlagen%202026/Q1%20%26%20Q2%20%28final%29"; + dav_client + .propfind(dav_parent_path, ["D:getetag"]) + .await + .with_hrefs([ + format!("{dav_parent_path}/").as_str(), + format!("{dav_child_path}/").as_str(), + ]); + dav_client + .propfind(dav_child_path, ["D:getetag"]) + .await + .with_hrefs([format!("{dav_child_path}/").as_str()]); + account + .jmap_destroy( + MethodObject::FileNode, + [&dav_parent_id], + [("onDestroyRemoveChildren", true)], + ) + .await + .destroyed() + .for_each(drop); + + // Make sure everything is gone + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs new file mode 100644 index 0000000..0ab4b4c --- /dev/null +++ b/tests/src/jmap/mail/acl.rs @@ -0,0 +1,755 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use ::email::mailbox::{INBOX_ID, TRASH_ID}; +use jmap_client::{ + core::{ + error::{MethodError, MethodErrorType}, + set::{SetError, SetErrorType}, + }, + email::{self, Property, import::EmailImportResponse, query::Filter}, + mailbox::{self, Role}, + principal::ACL, +}; +use registry::schema::prelude::ObjectType; +use serde_json::json; +use std::fmt::Debug; +use store::ahash::AHashMap; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running ACL tests..."); + + // Create a group and three test accounts + let inbox_id = Id::new(INBOX_ID as u64).to_string(); + let trash_id = Id::new(TRASH_ID as u64).to_string(); + + let admin = test.account("admin@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); + let sales = test.account("sales@example.com"); + + // Authenticate all accounts + let mut john_client = john.jmap_client().await; + let mut jane_client = jane.jmap_client().await; + let mut bill_client = bill.jmap_client().await; + + // Insert two emails in each account + let mut email_ids = AHashMap::default(); + for (client, account_id, name) in [ + (&mut john_client, john.id(), "john"), + (&mut jane_client, jane.id(), "jane"), + (&mut bill_client, bill.id(), "bill"), + (&mut admin.jmap_client().await, sales.id(), "sales"), + ] { + let user_name = client.session().username().to_string(); + let mut ids = Vec::with_capacity(2); + for (mailbox_id, mailbox_name) in [(&inbox_id, "inbox"), (&trash_id, "trash")] { + ids.push( + client + .set_default_account_id(account_id.to_string()) + .email_import( + format!( + concat!( + "From: acl_test@example.com\r\n", + "To: {}\r\n", + "Subject: Owned by {} in {}\r\n", + "\r\n", + "This message is owned by {}.", + ), + user_name, name, mailbox_name, name + ) + .into_bytes(), + [mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + email_ids.insert(name, ids); + } + + // John should have access to his emails only + assert_eq!( + john_client + .email_get( + email_ids.get("john").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by john in inbox" + ); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_get(&inbox_id, None::>) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(sales.id_string()) + .email_get( + email_ids.get("sales").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(sales.id_string()) + .mailbox_get(&inbox_id, None::>) + .await, + ); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_query(None::, None::>) + .await, + ); + + // Jane grants Inbox ReadItems access to John + jane_client + .mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems]) + .await + .unwrap(); + + // John should have ReadItems access to Inbox + assert_eq!( + john_client + .set_default_account_id(jane.id_string()) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by jane in inbox" + ); + assert_eq!( + john_client + .set_default_account_id(jane.id_string()) + .email_query(None::, None::>) + .await + .unwrap() + .ids(), + [email_ids.get("jane").unwrap().first().unwrap().as_str()] + ); + + // John's session resource should contain Jane's account details + john_client.refresh_session().await.unwrap(); + assert_eq!( + john_client + .session() + .account(jane.id_string()) + .unwrap() + .name(), + "jane.smith@example.com" + ); + + // John should not have access to emails in Jane's Trash folder + assert!( + john_client + .set_default_account_id(jane.id_string()) + .email_get( + email_ids.get("jane").unwrap().last().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .is_none() + ); + + // Email/changes must not leak ids of emails in folders John cannot read + let jane_inbox_email = email_ids.get("jane").unwrap().first().unwrap().clone(); + let jane_trash_email = email_ids.get("jane").unwrap().last().unwrap().clone(); + let changed_ids = john_client + .set_default_account_id(jane.id_string()) + .email_changes("n", None) + .await + .unwrap() + .created() + .to_vec(); + assert!( + changed_ids.contains(&jane_inbox_email), + "Email/changes should report the shared Inbox email" + ); + assert!( + !changed_ids.contains(&jane_trash_email), + "Email/changes leaked the id of a non-shared Trash email" + ); + + // John should only be able to copy blobs he has access to + let blob_id = jane_client + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::BlobId].into(), + ) + .await + .unwrap() + .unwrap() + .take_blob_id(); + john_client + .set_default_account_id(john.id_string()) + .blob_copy(jane.id_string(), &blob_id) + .await + .unwrap(); + let blob_id = jane_client + .email_get( + email_ids.get("jane").unwrap().last().unwrap(), + [Property::BlobId].into(), + ) + .await + .unwrap() + .unwrap() + .take_blob_id(); + assert_forbidden( + john_client + .set_default_account_id(john.id_string()) + .blob_copy(jane.id_string(), &blob_id) + .await, + ); + + // John only has ReadItems access to Inbox + jane_client + .mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems]) + .await + .unwrap(); + assert_eq!( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list(), + vec![ACL::ReadItems] + ); + + // Try to add items using import and copy + let blob_id = john_client + .set_default_account_id(john.id_string()) + .upload( + Some(john.id_string()), + concat!( + "From: acl_test@example.com\r\n", + "To: jane.smith@example.com\r\n", + "Subject: Created by john in jane's inbox\r\n", + "\r\n", + "This message is owned by jane.", + ) + .as_bytes() + .to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let mut request = john_client.set_default_account_id(jane.id_string()).build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + assert_forbidden( + request + .send_single::() + .await + .unwrap() + .created(&email_id), + ); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_copy( + john.id_string(), + email_ids.get("john").unwrap().last().unwrap(), + [&inbox_id], + None::>, + None, + ) + .await, + ); + + // Grant access and try again + jane_client + .mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems, ACL::AddItems]) + .await + .unwrap(); + + let mut request = john_client.set_default_account_id(jane.id_string()).build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + let email_id = request + .send_single::() + .await + .unwrap() + .created(&email_id) + .unwrap() + .take_id(); + let email_id_2 = john_client + .set_default_account_id(jane.id_string()) + .email_copy( + john.id_string(), + email_ids.get("john").unwrap().last().unwrap(), + [&inbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + + assert_eq!( + jane_client + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in jane's inbox" + ); + assert_eq!( + jane_client + .email_get(&email_id_2, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by john in trash" + ); + + // Try removing items + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_destroy(&email_id) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + john.id_string(), + [ACL::ReadItems, ACL::AddItems, ACL::RemoveItems], + ) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .email_destroy(&email_id) + .await + .unwrap(); + + // Try to set keywords + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_set_keyword(&email_id_2, "$seen", true) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + john.id_string(), + [ + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::SetKeywords, + ], + ) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .email_set_keyword(&email_id_2, "$seen", true) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .email_set_keyword(&email_id_2, "my-keyword", true) + .await + .unwrap(); + + // Try to create a child + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_create("John's mailbox", None::<&str>, Role::None) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + john.id_string(), + [ + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::SetKeywords, + ACL::CreateChild, + ], + ) + .await + .unwrap(); + let mailbox_id = john_client + .set_default_account_id(jane.id_string()) + .mailbox_create("John's mailbox", Some(&inbox_id), Role::None) + .await + .unwrap() + .take_id(); + + // Try renaming a mailbox + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_rename(&mailbox_id, "John's private mailbox") + .await, + ); + jane_client + .mailbox_update_acl(&mailbox_id, john.id_string(), [ACL::ReadItems, ACL::Rename]) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .mailbox_rename(&mailbox_id, "John's private mailbox") + .await + .unwrap(); + + // Try moving a message + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_set_mailbox(&email_id_2, &mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + john.id_string(), + [ACL::ReadItems, ACL::Rename, ACL::AddItems], + ) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .email_set_mailbox(&email_id_2, &mailbox_id, true) + .await + .unwrap(); + + // Try deleting a mailbox + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_destroy(&mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + john.id_string(), + [ACL::ReadItems, ACL::Rename, ACL::AddItems, ACL::Delete], + ) + .await + .unwrap(); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_destroy(&mailbox_id, true) + .await, + ); + jane_client + .mailbox_update_acl( + &mailbox_id, + john.id_string(), + [ + ACL::ReadItems, + ACL::Rename, + ACL::AddItems, + ACL::Delete, + ACL::RemoveItems, + ], + ) + .await + .unwrap(); + john_client + .set_default_account_id(jane.id_string()) + .mailbox_destroy(&mailbox_id, true) + .await + .unwrap(); + + // Try changing ACL + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems]) + .await, + ); + assert_forbidden( + bill_client + .set_default_account_id(jane.id_string()) + .email_query(None::, None::>) + .await, + ); + jane_client + .mailbox_update_acl( + &inbox_id, + john.id_string(), + [ + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::SetKeywords, + ACL::CreateChild, + ACL::Rename, + ACL::Administer, + ], + ) + .await + .unwrap(); + assert_eq!( + john_client + .set_default_account_id(jane.id_string()) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list(), + vec![ + ACL::ReadItems, + ACL::AddItems, + ACL::RemoveItems, + ACL::SetSeen, + ACL::SetKeywords, + ACL::CreateChild, + ACL::Rename + ] + ); + john_client + .set_default_account_id(jane.id_string()) + .mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems]) + .await + .unwrap(); + assert_eq!( + bill_client + .set_default_account_id(jane.id_string()) + .email_query( + None::, + vec![email::query::Comparator::subject()].into() + ) + .await + .unwrap() + .ids(), + [ + email_ids.get("jane").unwrap().first().unwrap().as_str(), + &email_id_2 + ] + ); + + // Revoke all access to John + jane_client + .mailbox_update_acl(&inbox_id, john.id_string(), []) + .await + .unwrap(); + assert_forbidden( + john_client + .set_default_account_id(jane.id_string()) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await, + ); + john_client.refresh_session().await.unwrap(); + assert!(john_client.session().account(jane.id_string()).is_none()); + assert_eq!( + bill_client + .set_default_account_id(jane.id_string()) + .email_get( + email_ids.get("jane").unwrap().first().unwrap(), + [Property::Subject].into(), + ) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Owned by jane in inbox" + ); + + // Add John and Jane to the Sales group + let sales_id = test.account("sales@example.com").id(); + for name in ["jdoe@example.com", "jane.smith@example.com"] { + admin + .registry_update_object( + ObjectType::Account, + test.account(name).id(), + json!({ + "memberGroupIds": { sales_id: true }, + }), + ) + .await; + } + john_client.refresh_session().await.unwrap(); + jane_client.refresh_session().await.unwrap(); + bill_client.refresh_session().await.unwrap(); + assert_eq!( + john_client + .session() + .account(sales.id_string()) + .unwrap() + .name(), + "sales@example.com" + ); + assert!( + !john_client + .session() + .account(sales.id_string()) + .unwrap() + .is_personal() + ); + assert_eq!( + jane_client + .session() + .account(sales.id_string()) + .unwrap() + .name(), + "sales@example.com" + ); + assert!(bill_client.session().account(sales.id_string()).is_none()); + + // Insert a message in Sales's inbox + let blob_id = john_client + .set_default_account_id(sales.id_string()) + .upload( + Some(sales.id_string()), + concat!( + "From: acl_test@example.com\r\n", + "To: sales@example.com\r\n", + "Subject: Created by john in sales\r\n", + "\r\n", + "This message is owned by sales.", + ) + .as_bytes() + .to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let mut request = john_client.build(); + let email_id = request + .import_email() + .email(&blob_id) + .mailbox_ids([&inbox_id]) + .create_id(); + let email_id = request + .send_single::() + .await + .unwrap() + .created(&email_id) + .unwrap() + .take_id(); + + // Both Jane and John should be able to see this message, but not Bill + assert_eq!( + john_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in sales" + ); + assert_eq!( + jane_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into(),) + .await + .unwrap() + .unwrap() + .subject() + .unwrap(), + "Created by john in sales" + ); + assert_forbidden( + bill_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + + // Remove John from the sales group + admin + .registry_update_object( + ObjectType::Account, + test.account("jdoe@example.com").id(), + json!({ + "memberGroupIds": { sales_id: false }, + }), + ) + .await; + + assert_forbidden( + john_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + + // Destroy test account data + for account in [john, bill, jane, sales] { + admin + .destroy_all_mailboxes_for_account(account.id().document_id()) + .await; + } + test.assert_is_empty().await; +} + +pub fn assert_forbidden(result: Result) { + if !matches!( + result, + Err(jmap_client::Error::Method(MethodError { + p_type: MethodErrorType::Forbidden + })) | Err(jmap_client::Error::Set(SetError { + type_: SetErrorType::BlobNotFound | SetErrorType::Forbidden, + .. + })) + ) { + panic!("Expected forbidden, got {:?}", result); + } +} diff --git a/tests/src/jmap/mail/changes.rs b/tests/src/jmap/mail/changes.rs new file mode 100644 index 0000000..e58802c --- /dev/null +++ b/tests/src/jmap/mail/changes.rs @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use jmap_proto::types::state::State; +use std::str::FromStr; +use store::{ahash::AHashSet, write::BatchBuilder}; +use types::{ + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub async fn test(test: &TestServer) { + println!("Running Email Changes tests..."); + + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let mut states = vec![State::Initial]; + + for (changes, expected_changelog) in [ + ( + vec![ + LogAction::Insert(0), + LogAction::Insert(1), + LogAction::Insert(2), + ], + vec![vec![vec![0, 1, 2], vec![], vec![]]], + ), + ( + vec![ + LogAction::Move(0, 3), + LogAction::Insert(4), + LogAction::Insert(5), + LogAction::Update(1), + LogAction::Update(2), + ], + vec![ + vec![vec![1, 2, 3, 4, 5], vec![], vec![]], + vec![vec![3, 4, 5], vec![1, 2], vec![0]], + ], + ), + ( + vec![ + LogAction::Delete(1), + LogAction::Insert(6), + LogAction::Insert(7), + LogAction::Update(2), + ], + vec![ + vec![vec![2, 3, 4, 5, 6, 7], vec![], vec![]], + vec![vec![3, 4, 5, 6, 7], vec![2], vec![0, 1]], + vec![vec![6, 7], vec![2], vec![1]], + ], + ), + ( + vec![ + LogAction::Update(4), + LogAction::Update(5), + LogAction::Update(6), + LogAction::Update(7), + ], + vec![ + vec![vec![2, 3, 4, 5, 6, 7], vec![], vec![]], + vec![vec![3, 4, 5, 6, 7], vec![2], vec![0, 1]], + vec![vec![6, 7], vec![2, 4, 5], vec![1]], + vec![vec![], vec![4, 5, 6, 7], vec![]], + ], + ), + ( + vec![ + LogAction::Delete(4), + LogAction::Delete(5), + LogAction::Delete(6), + LogAction::Delete(7), + ], + vec![ + vec![vec![2, 3], vec![], vec![]], + vec![vec![3], vec![2], vec![0, 1]], + vec![vec![], vec![2], vec![1, 4, 5]], + vec![vec![], vec![], vec![4, 5, 6, 7]], + vec![vec![], vec![], vec![4, 5, 6, 7]], + ], + ), + ( + vec![ + LogAction::Insert(8), + LogAction::Insert(9), + LogAction::Insert(10), + LogAction::Update(3), + ], + vec![ + vec![vec![2, 3, 8, 9, 10], vec![], vec![]], + vec![vec![3, 8, 9, 10], vec![2], vec![0, 1]], + vec![vec![8, 9, 10], vec![2, 3], vec![1, 4, 5]], + vec![vec![8, 9, 10], vec![3], vec![4, 5, 6, 7]], + vec![vec![8, 9, 10], vec![3], vec![4, 5, 6, 7]], + vec![vec![8, 9, 10], vec![3], vec![]], + ], + ), + ( + vec![LogAction::Update(2), LogAction::Update(8)], + vec![ + vec![vec![2, 3, 8, 9, 10], vec![], vec![]], + vec![vec![3, 8, 9, 10], vec![2], vec![0, 1]], + vec![vec![8, 9, 10], vec![2, 3], vec![1, 4, 5]], + vec![vec![8, 9, 10], vec![2, 3], vec![4, 5, 6, 7]], + vec![vec![8, 9, 10], vec![2, 3], vec![4, 5, 6, 7]], + vec![vec![8, 9, 10], vec![2, 3], vec![]], + vec![vec![], vec![2, 8], vec![]], + ], + ), + ( + vec![ + LogAction::Move(9, 11), + LogAction::Move(10, 12), + LogAction::Delete(8), + ], + vec![ + vec![vec![2, 3, 11, 12], vec![], vec![]], + vec![vec![3, 11, 12], vec![2], vec![0, 1]], + vec![vec![11, 12], vec![2, 3], vec![1, 4, 5]], + vec![vec![11, 12], vec![2, 3], vec![4, 5, 6, 7]], + vec![vec![11, 12], vec![2, 3], vec![4, 5, 6, 7]], + vec![vec![11, 12], vec![2, 3], vec![]], + vec![vec![11, 12], vec![2], vec![8, 9, 10]], + vec![vec![11, 12], vec![], vec![8, 9, 10]], + ], + ), + ] + .into_iter() + { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account.id().document_id()) + .with_collection(Collection::Email); + + for change in changes { + match change { + LogAction::Insert(id) => { + batch + .with_document(id as u32) + .log_item_insert(SyncCollection::Email, None); + } + LogAction::Update(id) => { + batch + .with_document(id as u32) + .log_item_update(SyncCollection::Email, None); + } + LogAction::Delete(id) => { + batch + .with_document(id as u32) + .log_item_delete(SyncCollection::Email, None); + } + LogAction::UpdateChild(id) => { + batch.log_container_property_change(SyncCollection::Email, id as u32); + } + LogAction::Move(old_id, new_id) => { + batch + .with_document(old_id as u32) + .log_item_delete(SyncCollection::Email, None) + .with_document(new_id as u32) + .log_item_insert(SyncCollection::Email, None); + } + } + } + + server + .core + .storage + .data + .write(batch.build_all()) + .await + .unwrap(); + + let mut new_state = State::Initial; + for (test_num, state) in (states).iter().enumerate() { + let changes = client.email_changes(state.to_string(), None).await.unwrap(); + + assert_eq!( + expected_changelog[test_num], + [changes.created(), changes.updated(), changes.destroyed()] + .into_iter() + .map(|list| { + let mut list = list + .iter() + .map(|i| Id::from_str(i).unwrap().into()) + .collect::>(); + list.sort_unstable(); + list + }) + .collect::>>(), + "test_num: {}, state: {:?}", + test_num, + state + ); + + if &State::Initial == state { + new_state = State::parse_str(changes.new_state()).unwrap(); + } + + for max_changes in 1..=8 { + let mut insertions = expected_changelog[test_num][0] + .iter() + .copied() + .collect::>(); + let mut updates = expected_changelog[test_num][1] + .iter() + .copied() + .collect::>(); + let mut deletions = expected_changelog[test_num][2] + .iter() + .copied() + .collect::>(); + + let mut int_state = state.clone(); + + for _ in 0..100 { + let changes = client + .email_changes(int_state.to_string(), max_changes.into()) + .await + .unwrap(); + + assert!( + changes.created().len() + + changes.updated().len() + + changes.destroyed().len() + <= max_changes, + "{} > {}", + changes.created().len() + + changes.updated().len() + + changes.destroyed().len(), + max_changes + ); + + changes.created().iter().for_each(|id| { + assert!( + insertions.remove(&Id::from_str(id).unwrap()), + "{:?} != {}", + insertions, + Id::from_str(id).unwrap() + ); + }); + changes.updated().iter().for_each(|id| { + assert!( + updates.remove(&Id::from_str(id).unwrap()), + "{:?} != {}", + updates, + Id::from_str(id).unwrap() + ); + }); + changes.destroyed().iter().for_each(|id| { + assert!( + deletions.remove(&Id::from_str(id).unwrap()), + "{:?} != {}", + deletions, + Id::from_str(id).unwrap() + ); + }); + + int_state = State::parse_str(changes.new_state()).unwrap(); + + if !changes.has_more_changes() { + break; + } + } + + assert_eq!( + insertions.len(), + 0, + "test_num: {}, state: {:?}, pending: {:?}", + test_num, + state, + insertions + ); + assert_eq!( + updates.len(), + 0, + "test_num: {}, state: {:?}, pending: {:?}", + test_num, + state, + updates + ); + assert_eq!( + deletions.len(), + 0, + "test_num: {}, state: {:?}, pending: {:?}", + test_num, + state, + deletions + ); + } + } + + states.push(new_state); + } + + let changes = client + .email_changes(State::Initial.to_string(), None) + .await + .unwrap(); + let mut created = changes + .created() + .iter() + .map(|i| Id::from_str(i).unwrap().into()) + .collect::>(); + created.sort_unstable(); + + assert_eq!(created, vec![2, 3, 11, 12]); + assert_eq!(changes.updated(), Vec::::new()); + assert_eq!(changes.destroyed(), Vec::::new()); + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +#[derive(Debug, Clone, Copy)] +pub enum LogAction { + Insert(u64), + Update(u64), + Delete(u64), + UpdateChild(u64), + Move(u64, u64), +} + +pub trait ParseState: Sized { + fn parse_str(state: &str) -> Option; +} + +impl ParseState for State { + fn parse_str(state: &str) -> Option { + State::parse(state) + } +} diff --git a/tests/src/jmap/mail/copy.rs b/tests/src/jmap/mail/copy.rs new file mode 100644 index 0000000..9379990 --- /dev/null +++ b/tests/src/jmap/mail/copy.rs @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use jmap_client::mailbox::Role; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Email Copy tests..."); + let account = test.account("admin@example.com"); + let mut client = account.jmap_client().await; + + // Create a mailbox on account 1 + let ac1_mailbox_id = client + .set_default_account_id(Id::new(1).to_string()) + .mailbox_create("Copy Test Ac# 1", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Insert a message on account 1 + let ac1_email_id = client + .email_import( + concat!( + "From: bill@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ) + .as_bytes() + .to_vec(), + [&ac1_mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + + // Create a mailbox on account 2 + let ac2_mailbox_id = client + .set_default_account_id(Id::new(2).to_string()) + .mailbox_create("Copy Test Ac# 2", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Copy the email and delete it from the first account + let mut request = client.build(); + request + .copy_email(Id::new(1).to_string()) + .on_success_destroy_original(true) + .create(&ac1_email_id) + .mailbox_id(&ac2_mailbox_id, true) + .keyword("$draft", true) + .received_at(311923920); + let ac2_email_id = request + .send() + .await + .unwrap() + .method_response_by_pos(0) + .unwrap_copy_email() + .unwrap() + .created(&ac1_email_id) + .unwrap() + .take_id(); + + // Check that the email was copied + let email = client + .email_get(&ac2_email_id, None::>) + .await + .unwrap() + .unwrap(); + assert_eq!( + email.preview().unwrap(), + "I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great." + ); + assert_eq!(email.subject().unwrap(), "TPS Report"); + assert_eq!(email.mailbox_ids(), &[&ac2_mailbox_id]); + assert_eq!(email.keywords(), &["$draft"]); + assert_eq!(email.received_at().unwrap(), 311923920); + + // Check that the email was deleted + assert!( + client + .set_default_account_id(Id::new(1).to_string()) + .email_get(&ac1_email_id, None::>) + .await + .unwrap() + .is_none() + ); + + // Empty store + account.destroy_all_mailboxes_for_account(1).await; + account.destroy_all_mailboxes_for_account(2).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mail/get.rs b/tests/src/jmap/mail/get.rs new file mode 100644 index 0000000..89b041e --- /dev/null +++ b/tests/src/jmap/mail/get.rs @@ -0,0 +1,338 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{jmap::replace_blob_ids, utils::server::TestServer}; +use ::email::mailbox::INBOX_ID; +use jmap_client::email::{self, Header, HeaderForm, import::EmailImportResponse}; +use mail_parser::HeaderName; +use registry::schema::prelude::ObjectType; +use std::{fs, path::PathBuf}; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Email Get tests..."); + + let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_dir.push("resources"); + test_dir.push("jmap"); + test_dir.push("email_get"); + + let mailbox_id = Id::from(INBOX_ID).to_string(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + for file_name in fs::read_dir(&test_dir).unwrap() { + let mut file_name = file_name.as_ref().unwrap().path(); + if file_name.extension().is_none_or(|e| e != "eml") { + continue; + } + let is_headers_test = file_name.file_name().unwrap() == "headers.eml"; + + let blob = fs::read(&file_name).unwrap(); + let blob_len = blob.len(); + + // Import email + let mut request = client.build(); + let import_request = request + .import_email() + .email( + client + .upload(None, blob, None) + .await + .unwrap() + .take_blob_id(), + ) + .mailbox_ids([mailbox_id.clone()]) + .keywords(["tag".to_string()]) + .received_at((blob_len * 1000000) as i64); + let id = import_request.create_id(); + let mut response = request.send_single::().await.unwrap(); + assert_ne!(response.old_state(), Some(response.new_state())); + let email = response.created(&id).unwrap(); + + let mut request = client.build(); + request + .get_email() + .ids([email.id().unwrap()]) + .properties([ + email::Property::Id, + email::Property::BlobId, + email::Property::ThreadId, + email::Property::MailboxIds, + email::Property::Keywords, + email::Property::Size, + email::Property::ReceivedAt, + email::Property::MessageId, + email::Property::InReplyTo, + email::Property::References, + email::Property::Sender, + email::Property::From, + email::Property::To, + email::Property::Cc, + email::Property::Bcc, + email::Property::ReplyTo, + email::Property::Subject, + email::Property::SentAt, + email::Property::HasAttachment, + email::Property::Preview, + email::Property::BodyValues, + email::Property::TextBody, + email::Property::HtmlBody, + email::Property::Attachments, + email::Property::BodyStructure, + ]) + .arguments() + .body_properties(if !is_headers_test { + [ + email::BodyProperty::PartId, + email::BodyProperty::BlobId, + email::BodyProperty::Size, + email::BodyProperty::Name, + email::BodyProperty::Type, + email::BodyProperty::Charset, + email::BodyProperty::Headers, + email::BodyProperty::Disposition, + email::BodyProperty::Cid, + email::BodyProperty::Language, + email::BodyProperty::Location, + ] + } else { + [ + email::BodyProperty::PartId, + email::BodyProperty::Size, + email::BodyProperty::Name, + email::BodyProperty::Type, + email::BodyProperty::Charset, + email::BodyProperty::Disposition, + email::BodyProperty::Cid, + email::BodyProperty::Language, + email::BodyProperty::Location, + email::BodyProperty::Header(Header { + name: "X-Custom-Header".into(), + form: HeaderForm::Raw, + all: false, + }), + email::BodyProperty::Header(Header { + name: "X-Custom-Header-2".into(), + form: HeaderForm::Raw, + all: false, + }), + ] + }) + .fetch_all_body_values(true) + .max_body_value_bytes(100); + + let mut result = request + .send_get_email() + .await + .unwrap() + .take_list() + .pop() + .unwrap() + .into_test(); + + if is_headers_test { + for property in all_headers() { + let mut request = client.build(); + request + .get_email() + .ids([email.id().unwrap()]) + .properties([property]); + result.headers.extend( + request + .send_get_email() + .await + .unwrap() + .take_list() + .pop() + .unwrap() + .into_test() + .headers, + ); + } + } + + let result = replace_blob_ids(serde_json::to_string_pretty(&result).unwrap()); + + file_name.set_extension("json"); + + if fs::read(&file_name).unwrap() != result.as_bytes() { + file_name.set_extension("failed"); + fs::write(&file_name, result.as_bytes()).unwrap(); + panic!("Test failed, output saved to {}", file_name.display()); + } + } + + test.destroy_all_mailboxes(account).await; + test.account("admin@example.com") + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + test.assert_is_empty().await; +} + +pub fn all_headers() -> Vec { + let mut properties = Vec::new(); + + for header in [ + HeaderName::From, + HeaderName::To, + HeaderName::Cc, + HeaderName::Bcc, + HeaderName::Other("X-Address-Single".into()), + HeaderName::Other("X-Address".into()), + HeaderName::Other("X-AddressList-Single".into()), + HeaderName::Other("X-AddressList".into()), + HeaderName::Other("X-AddressesGroup-Single".into()), + HeaderName::Other("X-AddressesGroup".into()), + ] { + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Addresses, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Addresses, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::GroupedAddresses, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::GroupedAddresses, + name: header.as_str().to_string(), + all: false, + })); + } + + for header in [ + HeaderName::ListPost, + HeaderName::ListSubscribe, + HeaderName::ListUnsubscribe, + HeaderName::ListOwner, + HeaderName::Other("X-List-Single".into()), + HeaderName::Other("X-List".into()), + ] { + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::URLs, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::URLs, + name: header.as_str().to_string(), + all: false, + })); + } + + for header in [ + HeaderName::Date, + HeaderName::ResentDate, + HeaderName::Other("X-Date-Single".into()), + HeaderName::Other("X-Date".into()), + ] { + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Date, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Date, + name: header.as_str().to_string(), + all: false, + })); + } + + for header in [ + HeaderName::MessageId, + HeaderName::References, + HeaderName::Other("X-Id-Single".into()), + HeaderName::Other("X-Id".into()), + ] { + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::MessageIds, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::MessageIds, + name: header.as_str().to_string(), + all: false, + })); + } + + for header in [ + HeaderName::Subject, + HeaderName::Keywords, + HeaderName::Other("X-Text-Single".into()), + HeaderName::Other("X-Text".into()), + ] { + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Raw, + name: header.as_str().to_string(), + all: false, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Text, + name: header.as_str().to_string(), + all: true, + })); + properties.push(email::Property::Header(Header { + form: HeaderForm::Text, + name: header.as_str().to_string(), + all: false, + })); + } + + properties +} diff --git a/tests/src/jmap/mail/mailbox.rs b/tests/src/jmap/mail/mailbox.rs new file mode 100644 index 0000000..81e5fa7 --- /dev/null +++ b/tests/src/jmap/mail/mailbox.rs @@ -0,0 +1,768 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use jmap_client::{ + Error, Set, + client::Client, + core::{ + query::Filter, + set::{SetError, SetErrorType, SetObject, SetRequest}, + }, + mailbox::{self, Mailbox, Role}, +}; +use jmap_proto::types::state::State; +use serde::{Deserialize, Serialize}; +use store::ahash::AHashMap; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Mailbox tests..."); + let account = test.account("admin@example.com"); + let client = account.jmap_client().await; + + // Create test mailboxes + test.destroy_all_mailboxes(account).await; + let id_map = create_test_mailboxes(&client).await; + + // Sort by name + assert_eq!( + client + .mailbox_query( + None::, + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + [ + "drafts", + "spam2", + "inbox", + "l.1", + "l.2", + "l.3", + "sent", + "spam", + "1.1", + "1.2", + "trash", + "spam1", + "1.1.1.1", + "1.1.1.1.1", + "1.1.1", + "1.2.1" + ] + ); + + // Sort by name as tree + let mut request = client.build(); + request + .query_mailbox() + .sort([mailbox::query::Comparator::name()]) + .arguments() + .sort_as_tree(true); + assert_eq!( + request + .send_query_mailbox() + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + [ + "drafts", + "inbox", + "l.1", + "1.1", + "1.1.1", + "1.1.1.1", + "1.1.1.1.1", + "1.2", + "1.2.1", + "l.2", + "l.3", + "sent", + "spam", + "spam1", + "spam2", + "trash" + ] + ); + + // Sort as tree with filters + let mut request = client.build(); + request + .query_mailbox() + .filter(mailbox::query::Filter::name("level")) + .sort([mailbox::query::Comparator::name()]) + .arguments() + .sort_as_tree(true); + assert_eq!( + request + .send_query_mailbox() + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + [ + "l.1", + "1.1", + "1.1.1", + "1.1.1.1", + "1.1.1.1.1", + "1.2", + "1.2.1", + "l.2", + "l.3" + ] + ); + + // Filter as tree + let mut request = client.build(); + request + .query_mailbox() + .filter(mailbox::query::Filter::name("spam")) + .sort([mailbox::query::Comparator::name()]) + .arguments() + .filter_as_tree(true) + .sort_as_tree(true); + assert_eq!( + request + .send_query_mailbox() + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["spam", "spam1", "spam2"] + ); + + let mut request = client.build(); + request + .query_mailbox() + .filter(mailbox::query::Filter::name("level")) + .sort([mailbox::query::Comparator::name()]) + .arguments() + .filter_as_tree(true) + .sort_as_tree(true); + assert_eq!( + request.send_query_mailbox().await.unwrap().ids(), + Vec::<&str>::new() + ); + + // Filter by role + assert_eq!( + client + .mailbox_query( + mailbox::query::Filter::role(Role::Inbox).into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["inbox"] + ); + + assert_eq!( + client + .mailbox_query( + mailbox::query::Filter::has_any_role(true).into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["drafts", "inbox", "sent", "spam", "trash"] + ); + + // Duplicate role + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["sent"]) + .role(Role::Inbox); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["sent"]), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + // Duplicate name + let mut request = client.build(); + request.set_mailbox().update(&id_map["l.2"]).name("Level 3"); + let result = request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["l.2"]); + assert!( + matches!( + result, + Err(Error::Set(SetError { + type_: SetErrorType::AlreadyExists, + .. + })) + ), + "{result:?}", + ); + + // Circular relationship + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["l.1"]) + .parent_id((&id_map["1.1.1.1.1"]).into()); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["l.1"]), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["l.1"]) + .parent_id((&id_map["l.1"]).into()); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["l.1"]), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + // Invalid parentId + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["l.1"]) + .parent_id(Id::new(u64::MAX).to_string().into()); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["l.1"]), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + // Obtain state + let state = client + .mailbox_changes(State::Initial.to_string(), 0) + .await + .unwrap() + .new_state() + .to_string(); + + // Rename and move mailbox + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["1.1.1.1.1"]) + .name("Renamed and moved") + .parent_id((&id_map["l.2"]).into()); + assert!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["1.1.1.1.1"]) + .is_ok() + ); + + // Verify changes + let state = client.mailbox_changes(state, 0).await.unwrap(); + assert_eq!(state.created().len(), 0); + assert_eq!(state.updated().len(), 1); + assert_eq!(state.destroyed().len(), 0); + assert_eq!(state.arguments().updated_properties(), None); + let state = state.new_state().to_string(); + + // Insert email into Inbox + let mail_id = client + .email_import( + b"From: test@test.com\nSubject: hey\n\ntest".to_vec(), + [&id_map["inbox"]], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + + // Inbox's total and unread count should have increased + let inbox = client + .mailbox_get( + &id_map["inbox"], + [ + mailbox::Property::TotalEmails, + mailbox::Property::UnreadEmails, + mailbox::Property::TotalThreads, + mailbox::Property::UnreadThreads, + ] + .into(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(inbox.total_emails(), 1); + assert_eq!(inbox.unread_emails(), 1); + assert_eq!(inbox.total_threads(), 1); + assert_eq!(inbox.unread_threads(), 1); + + // Set email to read and fetch properties again + client + .email_set_keyword(&mail_id, "$seen", true) + .await + .unwrap(); + let inbox = client + .mailbox_get( + &id_map["inbox"], + [ + mailbox::Property::TotalEmails, + mailbox::Property::UnreadEmails, + mailbox::Property::TotalThreads, + mailbox::Property::UnreadThreads, + ] + .into(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(inbox.total_emails(), 1); + assert_eq!(inbox.unread_emails(), 0); + assert_eq!(inbox.total_threads(), 1); + assert_eq!(inbox.unread_threads(), 0); + + // Only email properties must have changed + let prev_state = state.clone(); + let state = client.mailbox_changes(state, 0).await.unwrap(); + assert_eq!(state.created().len(), 0); + assert_eq!( + state + .updated() + .iter() + .map(|s| s.as_str()) + .collect::>(), + &[&id_map["inbox"]] + ); + assert_eq!(state.destroyed().len(), 0); + assert_eq!( + state.arguments().updated_properties(), + Some( + &[ + mailbox::Property::TotalEmails, + mailbox::Property::UnreadEmails, + mailbox::Property::TotalThreads, + mailbox::Property::UnreadThreads, + ][..] + ) + ); + let state = state.new_state().to_string(); + + // Use updatedProperties in a query + let mut request = client.build(); + let changes_request = request.changes_mailbox(prev_state).max_changes(0); + let properties_ref = changes_request.updated_properties_reference(); + let updated_ref = changes_request.updated_reference(); + request + .get_mailbox() + .ids_ref(updated_ref) + .properties_ref(properties_ref); + let mut changed_mailboxes = request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_mailbox() + .unwrap() + .take_list(); + assert_eq!(changed_mailboxes.len(), 1); + let inbox = changed_mailboxes.pop().unwrap(); + assert_eq!(inbox.id().unwrap(), &id_map["inbox"]); + assert_eq!(inbox.total_emails(), 1); + assert_eq!(inbox.unread_emails(), 0); + assert_eq!(inbox.total_threads(), 1); + assert_eq!(inbox.unread_threads(), 0); + assert_eq!(inbox.name(), None); + assert_eq!(inbox.my_rights(), None); + + // Move email from Inbox to Trash + client + .email_set_mailboxes(&mail_id, [&id_map["trash"]]) + .await + .unwrap(); + + // E-mail properties of both Inbox and Trash must have changed + let state = client.mailbox_changes(state, 0).await.unwrap(); + assert_eq!(state.created().len(), 0); + assert_eq!(state.updated().len(), 2); + assert_eq!(state.destroyed().len(), 0); + let mut folder_ids = vec![&id_map["trash"], &id_map["inbox"]]; + let mut updated_ids = state + .updated() + .iter() + .map(|s| s.as_str()) + .collect::>(); + updated_ids.sort_unstable(); + folder_ids.sort_unstable(); + assert_eq!(updated_ids, folder_ids); + assert_eq!( + state.arguments().updated_properties(), + Some( + &[ + mailbox::Property::TotalEmails, + mailbox::Property::UnreadEmails, + mailbox::Property::TotalThreads, + mailbox::Property::UnreadThreads, + ][..] + ) + ); + + // Deleting folders with children is not allowed + let mut request = client.build(); + request.set_mailbox().destroy([&id_map["l.1"]]); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .destroyed(&id_map["l.1"]), + Err(Error::Set(SetError { + type_: SetErrorType::MailboxHasChild, + .. + })) + )); + + // Deleting folders with contents is not allowed (unless remove_emails is true) + let mut request = client.build(); + request.set_mailbox().destroy([&id_map["trash"]]); + assert!(matches!( + request + .send_set_mailbox() + .await + .unwrap() + .destroyed(&id_map["trash"]), + Err(Error::Set(SetError { + type_: SetErrorType::MailboxHasEmail, + .. + })) + )); + + // Delete Trash folder and its contents + let mut request = client.build(); + request + .set_mailbox() + .destroy([&id_map["trash"]]) + .arguments() + .on_destroy_remove_emails(true); + assert!( + request + .send_set_mailbox() + .await + .unwrap() + .destroyed(&id_map["trash"]) + .is_ok() + ); + + // Verify that Trash folder and its contents are gone + assert!( + client + .mailbox_get(&id_map["trash"], None::>) + .await + .unwrap() + .is_none() + ); + assert!( + client + .email_get(&mail_id, None::>) + .await + .unwrap() + .is_none() + ); + + // Check search results after changing folder properties + let mut request = client.build(); + request + .set_mailbox() + .update(&id_map["drafts"]) + .name("Borradores") + .sort_order(100) + .parent_id((&id_map["l.2"]).into()) + .role(Role::None); + assert!( + request + .send_set_mailbox() + .await + .unwrap() + .updated(&id_map["drafts"]) + .is_ok() + ); + assert_eq!( + client + .mailbox_query( + Filter::and([ + mailbox::query::Filter::name("Borradores").into(), + mailbox::query::Filter::parent_id((&id_map["l.2"]).into()).into(), + Filter::not([mailbox::query::Filter::has_any_role(true)]) + ]) + .into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["drafts"] + ); + assert!( + client + .mailbox_query( + mailbox::query::Filter::name("Drafts").into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .is_empty() + ); + assert!( + client + .mailbox_query( + mailbox::query::Filter::role(Role::Drafts).into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .is_empty() + ); + assert_eq!( + client + .mailbox_query( + mailbox::query::Filter::parent_id(None::<&str>).into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["inbox", "sent", "spam"] + ); + assert_eq!( + client + .mailbox_query( + mailbox::query::Filter::has_any_role(true).into(), + [mailbox::query::Comparator::name()].into() + ) + .await + .unwrap() + .ids() + .iter() + .map(|id| id_map.get(id).unwrap()) + .collect::>(), + ["inbox", "sent", "spam"] + ); + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +async fn create_test_mailboxes(client: &Client) -> AHashMap { + let mut mailbox_map = AHashMap::default(); + let mut request = client.build(); + build_create_query( + request.set_mailbox(), + &mut mailbox_map, + serde_json::from_slice(TEST_MAILBOXES).unwrap(), + None, + ); + let mut result = request.send_set_mailbox().await.unwrap(); + let mut id_map = AHashMap::with_capacity(mailbox_map.len()); + for (create_id, local_id) in mailbox_map { + let server_id = result.created(&create_id).unwrap().take_id(); + id_map.insert(local_id.clone(), server_id.clone()); + id_map.insert(server_id, local_id); + } + id_map +} + +fn build_create_query( + request: &mut SetRequest>, + mailbox_map: &mut AHashMap, + mailboxes: Vec, + parent_id: Option, +) { + for mailbox in mailboxes { + let create_mailbox = request + .create() + .name(mailbox.name) + .sort_order(mailbox.order); + if let Some(role) = mailbox.role { + create_mailbox.role(role); + } + if let Some(parent_id) = &parent_id { + create_mailbox.parent_id_ref(parent_id); + } + let create_mailbox_id = create_mailbox.create_id().unwrap(); + mailbox_map.insert(create_mailbox_id.clone(), mailbox.id); + + if let Some(children) = mailbox.children { + build_create_query(request, mailbox_map, children, create_mailbox_id.into()); + } + } +} + +#[derive(Serialize, Deserialize)] +struct TestMailbox { + id: String, + name: String, + role: Option, + order: u32, + children: Option>, +} + +const TEST_MAILBOXES: &[u8] = br#" +[ + { + "id": "inbox", + "name": "Inbox", + "role": "INBOX", + "order": 5, + "children": [ + { + "name": "Level 1", + "id": "l.1", + "order": 4, + "children": [ + { + "name": "Sub-Level 1.1", + "id": "1.1", + + "order": 3, + "children": [ + { + "name": "Z-Sub-Level 1.1.1", + "id": "1.1.1", + "order": 2, + "children": [ + { + "name": "X-Sub-Level 1.1.1.1", + "id": "1.1.1.1", + "order": 1, + "children": [ + { + "name": "Y-Sub-Level 1.1.1.1.1", + "id": "1.1.1.1.1", + "order": 0 + } + ] + } + ] + } + ] + }, + { + "name": "Sub-Level 1.2", + "id": "1.2", + "order": 7, + "children": [ + { + "name": "Z-Sub-Level 1.2.1", + "id": "1.2.1", + "order": 6 + } + ] + } + ] + }, + { + "name": "Level 2", + "id": "l.2", + "order": 8 + }, + { + "name": "Level 3", + "id": "l.3", + "order": 9 + } + ] + }, + { + "id": "sent", + "name": "Sent", + "role": "SENT", + "order": 15 + }, + { + "id": "drafts", + "name": "Drafts", + "role": "DRAFTS", + "order": 14 + }, + { + "id": "trash", + "name": "Trash", + "role": "TRASH", + "order": 13 + }, + { + "id": "spam", + "name": "Spam", + "role": "JUNK", + "order": 12, + "children": [{ + "id": "spam1", + "name": "Work Spam", + "order": 11, + "children": [{ + "id": "spam2", + "name": "Friendly Spam", + "order": 10 + }] + }] + } +] +"#; diff --git a/tests/src/jmap/mail/mod.rs b/tests/src/jmap/mail/mod.rs new file mode 100644 index 0000000..7c6bc07 --- /dev/null +++ b/tests/src/jmap/mail/mod.rs @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acl; +pub mod changes; +pub mod copy; +pub mod get; +pub mod mailbox; +pub mod parse; +pub mod query; +pub mod query_changes; +pub mod search_snippet; +pub mod set; +pub mod sieve_script; +pub mod submission; +pub mod thread_get; +pub mod thread_merge; +pub mod vacation_response; diff --git a/tests/src/jmap/mail/parse.rs b/tests/src/jmap/mail/parse.rs new file mode 100644 index 0000000..b657d55 --- /dev/null +++ b/tests/src/jmap/mail/parse.rs @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{mail::get::all_headers, replace_blob_ids}, + utils::server::TestServer, +}; +use jmap_client::{ + email::{self, Header, HeaderForm}, + mailbox::Role, +}; +use std::{fs, path::PathBuf}; + +pub async fn test(test: &TestServer) { + println!("Running Email Parse tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_dir.push("resources"); + test_dir.push("jmap"); + test_dir.push("email_parse"); + + let mailbox_id = client + .mailbox_create("JMAP Parse", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Test parsing an email attachment + for test_name in ["attachment.eml", "attachment_b64.eml"] { + let mut test_file = test_dir.clone(); + test_file.push(test_name); + + let email = client + .email_import( + fs::read(&test_file).unwrap(), + [mailbox_id.clone()], + None::>, + None, + ) + .await + .unwrap(); + + let blob_id = client + .email_get(email.id().unwrap(), Some([email::Property::Attachments])) + .await + .unwrap() + .unwrap() + .attachments() + .unwrap() + .first() + .unwrap() + .blob_id() + .unwrap() + .to_string(); + + let email = client + .email_parse( + &blob_id, + [ + email::Property::Id, + email::Property::BlobId, + email::Property::ThreadId, + email::Property::MailboxIds, + email::Property::Keywords, + email::Property::Size, + email::Property::ReceivedAt, + email::Property::MessageId, + email::Property::InReplyTo, + email::Property::References, + email::Property::Sender, + email::Property::From, + email::Property::To, + email::Property::Cc, + email::Property::Bcc, + email::Property::ReplyTo, + email::Property::Subject, + email::Property::SentAt, + email::Property::HasAttachment, + email::Property::Preview, + email::Property::BodyValues, + email::Property::TextBody, + email::Property::HtmlBody, + email::Property::Attachments, + email::Property::BodyStructure, + ] + .into(), + [ + email::BodyProperty::PartId, + email::BodyProperty::BlobId, + email::BodyProperty::Size, + email::BodyProperty::Name, + email::BodyProperty::Type, + email::BodyProperty::Charset, + email::BodyProperty::Headers, + email::BodyProperty::Disposition, + email::BodyProperty::Cid, + email::BodyProperty::Language, + email::BodyProperty::Location, + ] + .into(), + 100.into(), + ) + .await + .unwrap(); + + if !test_name.contains("_b64") { + for parts in [ + email.text_body().unwrap(), + email.html_body().unwrap(), + email.attachments().unwrap(), + ] { + for part in parts { + let blob_id = part.blob_id().unwrap(); + + let inner_blob = client.download(blob_id).await.unwrap(); + + test_file.set_extension(format!("part{}", part.part_id().unwrap())); + + //fs::write(&test_file, inner_blob).unwrap(); + let expected_inner_blob = fs::read(&test_file).unwrap(); + + assert_eq!( + inner_blob, + expected_inner_blob, + "file: {}", + test_file.display() + ); + } + } + } + + test_file.set_extension("json"); + + let result = replace_blob_ids(serde_json::to_string_pretty(&email.into_test()).unwrap()); + + if fs::read(&test_file).unwrap() != result.as_bytes() { + test_file.set_extension("failed"); + fs::write(&test_file, result.as_bytes()).unwrap(); + panic!("Test failed, output saved to {}", test_file.display()); + } + } + + // Test header parsing on a temporary blob + let mut test_file = test_dir; + test_file.push("headers.eml"); + let blob_id = client + .upload(None, fs::read(&test_file).unwrap(), None) + .await + .unwrap() + .take_blob_id(); + + let mut email = client + .email_parse( + &blob_id, + [ + email::Property::Id, + email::Property::MessageId, + email::Property::InReplyTo, + email::Property::References, + email::Property::Sender, + email::Property::From, + email::Property::To, + email::Property::Cc, + email::Property::Bcc, + email::Property::ReplyTo, + email::Property::Subject, + email::Property::SentAt, + email::Property::Preview, + email::Property::TextBody, + email::Property::HtmlBody, + email::Property::Attachments, + ] + .into(), + [ + email::BodyProperty::Size, + email::BodyProperty::Name, + email::BodyProperty::Type, + email::BodyProperty::Charset, + email::BodyProperty::Disposition, + email::BodyProperty::Cid, + email::BodyProperty::Language, + email::BodyProperty::Location, + email::BodyProperty::Header(Header { + name: "X-Custom-Header".into(), + form: HeaderForm::Raw, + all: false, + }), + email::BodyProperty::Header(Header { + name: "X-Custom-Header-2".into(), + form: HeaderForm::Raw, + all: false, + }), + ] + .into(), + 100.into(), + ) + .await + .unwrap() + .into_test(); + + for property in all_headers() { + email.headers.extend( + client + .email_parse(&blob_id, [property].into(), [].into(), None) + .await + .unwrap() + .into_test() + .headers, + ); + } + + test_file.set_extension("json"); + + let result = replace_blob_ids(serde_json::to_string_pretty(&email).unwrap()); + + if fs::read(&test_file).unwrap() != result.as_bytes() { + test_file.set_extension("failed"); + fs::write(&test_file, result.as_bytes()).unwrap(); + panic!("Test failed, output saved to {}", test_file.display()); + } + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mail/query.rs b/tests/src/jmap/mail/query.rs new file mode 100644 index 0000000..27eefcc --- /dev/null +++ b/tests/src/jmap/mail/query.rs @@ -0,0 +1,908 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::store::{deflate_test_resource, query::FIELDS}; +use crate::utils::account::Account; +use crate::utils::server::TestServer; +use ::email::{cache::MessageCacheFetch, mailbox::Mailbox}; +use ahash::AHashSet; +use common::storage::index::ObjectIndexBuilder; +use jmap_client::{ + client::Client, + core::query::{Comparator, Filter}, + email, +}; +use mail_builder::{ + MessageBuilder, + headers::{date::Date, message_id::MessageId, text::Text}, +}; +use mail_parser::HeaderName; +use std::{collections::hash_map::Entry, str::FromStr, time::Instant}; +use store::{ + ahash::AHashMap, + write::{BatchBuilder, now}, +}; +use types::{collection::Collection, id::Id, special_use::SpecialUse}; + +const MAX_THREADS: usize = 100; +const MAX_MESSAGES: usize = 1000; +const MAX_MESSAGES_PER_THREAD: usize = 100; + +pub async fn test(test: &TestServer) { + println!("Running Email Query tests..."); + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + if test.is_reset() { + // Add some "virtual" mailbox ids so create doesn't fail + let mut batch = BatchBuilder::new(); + let account_id = Id::from_str(client.default_account_id()) + .unwrap() + .document_id(); + batch + .with_account_id(account_id) + .with_collection(Collection::Mailbox); + for mailbox_id in 1545..3010 { + batch + .with_document(mailbox_id) + .custom(ObjectIndexBuilder::<(), _>::new().with_changes(Mailbox { + name: format!("Mailbox {mailbox_id}"), + role: SpecialUse::None, + parent_id: 0, + sort_order: None, + uid_validity: 0, + subscribers: vec![], + acls: vec![], + })) + .unwrap(); + } + server + .core + .storage + .data + .write(batch.build_all()) + .await + .unwrap(); + + // Create test messages + println!("Inserting JMAP Mail query test messages..."); + create(test, account).await; + + assert_eq!( + test.server + .get_cached_messages(account_id) + .await + .unwrap() + .emails + .items + .iter() + .map(|m| m.thread_id) + .collect::>() + .len(), + MAX_THREADS + ); + + // Wait for indexing to complete + test.wait_for_tasks().await; + } + + let can_stem = !test.server.search_store().is_mysql(); + + println!("Running JMAP Mail query tests..."); + query(&client, can_stem).await; + + println!("Running JMAP Mail query options tests..."); + query_options(&client).await; + + println!("Deleting all messages..."); + let mut request = client.build(); + let result_ref = request.query_email().result_reference(); + request.set_email().destroy_ref(result_ref); + let response = request.send().await.unwrap(); + response + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_set_email() + .unwrap(); + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +pub async fn query(client: &Client, can_stem: bool) { + for (filter, sort, expected_results) in [ + ( + Filter::and(vec![ + (email::query::Filter::after(1850)), + (email::query::Filter::from("george")), + ]), + vec![ + email::query::Comparator::subject(), + email::query::Comparator::sent_at(), + ], + vec![ + "N01389", "T10115", "N00618", "N03500", "T01587", "T00397", "N01561", "N05250", + "N03973", "N04973", "N04057", "N01940", "N01539", "N01612", "N04484", "N01954", + "N05998", "T02053", "AR00171", "AR00172", "AR00176", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::in_mailbox(Id::new(1768u64).to_string())), + (email::query::Filter::cc("canvas")), + ]), + vec![ + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec!["T01882", "N04689", "T00925", "N00121"], + ), + ( + Filter::and(vec![ + (email::query::Filter::text(if can_stem { "study" } else { "studies" })), + (email::query::Filter::in_mailbox_other_than(vec![ + Id::new(1991).to_string(), + Id::new(1870).to_string(), + Id::new(2011).to_string(), + Id::new(1951).to_string(), + Id::new(1902).to_string(), + Id::new(1808).to_string(), + Id::new(1963).to_string(), + ])), + ]), + vec![ + email::query::Comparator::subject(), + email::query::Comparator::sent_at(), + ], + if can_stem { + vec![ + /*"T10330", "N01744", "N01743", "N04885", "N02688", "N02122", "A00059", "A00058", + "N02123", "T00651", "T09439", "N05001", "T05848", "T05508",*/ + "T09187", "T10330", "N01744", "N01743", "N04885", "N02688", "N02122", "A00059", + "A00057", "A00058", "N02123", "T00651", "T09439", "N05001", "A01072", "A01061", + "AR00050", "T02310", "T05848", "T05508", "P20078", "P20079", + ] + } else { + vec!["T10330", "N02122", "N02123", "T09439"] + }, + ), + ( + Filter::and(vec![ + (email::query::Filter::has_keyword("N0")).into(), + Filter::not(vec![(email::query::Filter::from("collins"))]), + (email::query::Filter::body("bequeathed")).into(), + ]), + vec![ + email::query::Comparator::subject(), + email::query::Comparator::sent_at(), + ], + vec![ + "N02640", "A01020", "N01250", "T03430", "N01800", "N00620", "N05250", "N04630", + "A01040", + ], + ), + ( + email::query::Filter::not_keyword("artist").into(), + vec![ + email::query::Comparator::subject(), + email::query::Comparator::sent_at(), + ], + vec!["T08626", "T09334", "T09455", "N01737", "T10965"], + ), + ( + Filter::and(vec![ + (email::query::Filter::after(1970)), + (email::query::Filter::before(1972)), + (email::query::Filter::text("colour")), + ]), + vec![ + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec!["T01745", "P01436", "P01437"], + ), + ( + Filter::and(vec![(email::query::Filter::text("'cats and dogs'"))]), + vec![email::query::Comparator::from()], + vec!["P77623"], + ), + ( + Filter::and(vec![ + (email::query::Filter::header( + HeaderName::Comments.to_string(), + Some("attributed"), + )), + (email::query::Filter::from("john")), + (email::query::Filter::cc("oil")), + ]), + vec![email::query::Comparator::from()], + vec!["T10965"], + ), + ( + Filter::and(vec![ + (email::query::Filter::all_in_thread_have_keyword("N")), + (email::query::Filter::before(1800)), + ]), + vec![ + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273", "N01453", + "N02984", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::none_in_thread_have_keyword("N")), + (email::query::Filter::after(1995)), + ]), + vec![ + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "AR00163", "AR00164", "AR00472", "P11481", "AR00066", "AR00178", "P77895", + "P77896", "P77897", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::some_in_thread_have_keyword("Bronze")), + (email::query::Filter::before(1878)), + ]), + vec![ + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "N04326", "N01610", "N02920", "N01587", "T00167", "T00168", "N01554", "N01535", + "N01536", "N01622", "N01754", "N01594", + ], + ), + // Sorting tests + ( + email::query::Filter::before(1800).into(), + vec![ + email::query::Comparator::all_in_thread_have_keyword("N"), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "T09417", "T01882", "T08820", "N04689", "T08891", "T00986", "N00316", "N03544", + "N04296", "N04297", "T08234", "N00112", "T00211", "N01497", "N02639", "N02640", + "T00925", "T11683", "T08269", "D00001", "D00002", "D00046", "N00121", "N00126", + "T08626", "N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273", + "N01453", "N02984", + ], + ), + ( + email::query::Filter::before(1800).into(), + vec![ + email::query::Comparator::all_in_thread_have_keyword("N").descending(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273", "N01453", + "N02984", "T09417", "T01882", "T08820", "N04689", "T08891", "T00986", "N00316", + "N03544", "N04296", "N04297", "T08234", "N00112", "T00211", "N01497", "N02639", + "N02640", "T00925", "T11683", "T08269", "D00001", "D00002", "D00046", "N00121", + "N00126", "T08626", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::after(1875)), + (email::query::Filter::before(1878)), + ]), + vec![ + email::query::Comparator::some_in_thread_have_keyword("Bronze"), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "N01559", "N02123", "N01940", "N03594", "N01494", "N04271", "N04326", "N01610", + "N02920", "N01587", "T00167", "T00168", "N01554", "N01535", "N01536", "N01622", + "N01754", "N01594", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::after(1875)), + (email::query::Filter::before(1878)), + ]), + vec![ + email::query::Comparator::some_in_thread_have_keyword("Bronze").descending(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "N04326", "N01610", "N02920", "N01587", "T00167", "T00168", "N01554", "N01535", + "N01536", "N01622", "N01754", "N01594", "N01559", "N02123", "N01940", "N03594", + "N01494", "N04271", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::after(1786)), + (email::query::Filter::before(1840)), + (email::query::Filter::has_keyword("T")), + ]), + vec![ + email::query::Comparator::has_keyword("attributed to"), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "T09417", "T08951", "T01851", "T01852", "T08761", "T08123", "T08756", "T10561", + "T10562", "T10563", "T00986", "T03424", "T03427", "T08234", "T08133", "T06866", + "T08897", "T00996", "T00997", "T01095", "T03393", "T09456", "T00188", "T02362", + "T09065", "T09547", "T10330", "T09187", "T03433", "T08635", "T02366", "T03436", + "T09150", "T01861", "T09759", "T11683", "T02368", "T02369", "T08269", "T01018", + "T10066", "T01710", "T01711", "T05764", "T09455", "T09334", "T10965", "T08626", + ], + ), + ( + Filter::and(vec![ + (email::query::Filter::after(1786)), + (email::query::Filter::before(1840)), + (email::query::Filter::has_keyword("T")), + ]), + vec![ + email::query::Comparator::has_keyword("attributed to").descending(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + vec![ + "T09455", "T09334", "T10965", "T08626", "T09417", "T08951", "T01851", "T01852", + "T08761", "T08123", "T08756", "T10561", "T10562", "T10563", "T00986", "T03424", + "T03427", "T08234", "T08133", "T06866", "T08897", "T00996", "T00997", "T01095", + "T03393", "T09456", "T00188", "T02362", "T09065", "T09547", "T10330", "T09187", + "T03433", "T08635", "T02366", "T03436", "T09150", "T01861", "T09759", "T11683", + "T02368", "T02369", "T08269", "T01018", "T10066", "T01710", "T01711", "T05764", + ], + ), + ] { + let mut request = client.build(); + let query_request = request + .query_email() + .filter(filter.clone()) + .sort(sort.clone()) + .calculate_total(true); + query_request.arguments().collapse_threads(false); + let query_result_ref = query_request.result_reference(); + request + .get_email() + .ids_ref(query_result_ref) + .properties([email::Property::MessageId]); + let results = request + .send() + .await + .unwrap_or_else(|_| panic!("invalid response for {filter:?}")) + .unwrap_method_responses() + .pop() + .unwrap_or_else(|| panic!("invalid response for {filter:?}")) + .unwrap_get_email() + .unwrap_or_else(|_| panic!("invalid response for {filter:?}")) + .take_list() + .into_iter() + .map(|e| e.message_id().unwrap().first().unwrap().to_string()) + .collect::>(); + + let mut missing = Vec::new(); + let mut extra = Vec::new(); + for &expected in &expected_results { + if !results.iter().any(|r| r.as_str() == expected) { + missing.push(expected); + } + } + for result in &results { + if !expected_results.contains(&result.as_str()) { + extra.push(result.as_str()); + } + } + + assert_eq!( + results, expected_results, + "failed test!\nfilter: {filter:?}\nsort: {sort:?}\nmissing: {missing:?}\nextra: {extra:?}" + ); + } +} + +pub async fn query_options(client: &Client) { + for (query, expected_results, expected_results_collapsed) in [ + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: None, + anchor_offset: 0, + limit: 10, + }, + vec![ + "N01496", "N01320", "N01321", "N05916", "N00273", "N01453", "N02984", "T08820", + "N00112", "T00211", + ], + vec![ + "N01496", "N01320", "N05916", "N01453", "T08820", "N01046", "N00675", "T08891", + "T01882", "N04296", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 10, + anchor: None, + anchor_offset: 0, + limit: 10, + }, + vec![ + "N01046", "N00675", "T08891", "N00126", "T01882", "N04689", "T00925", "N00121", + "N04296", "N04297", + ], + vec![ + "T08234", "T09417", "N01110", "T08123", "N01039", "T09456", "T08951", "N01273", + "N00373", "T09547", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: -10, + anchor: None, + anchor_offset: 0, + limit: 0, + }, + vec![ + "T07236", "P11481", "AR00066", "P77895", "P77896", "P77897", "AR00163", "AR00164", + "AR00472", "AR00178", + ], + vec![ + "P07639", "P07522", "AR00089", "P02949", "T05820", "P11441", "T06971", "P11481", + "AR00163", "AR00164", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: -20, + anchor: None, + anchor_offset: 0, + limit: 10, + }, + vec![ + "P20079", "AR00024", "AR00182", "P20048", "P20044", "P20045", "P20046", "T06971", + "AR00177", "P77935", + ], + vec![ + "T00300", "P06033", "T02310", "T02135", "P04006", "P03166", "P01358", "P07133", + "P03138", "T03562", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: -100000, + anchor: None, + anchor_offset: 0, + limit: 1, + }, + vec!["N01496"], + vec!["N01496"], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: -1, + anchor: None, + anchor_offset: 0, + limit: 100000, + }, + vec!["AR00178"], + vec!["AR00164"], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "N01205").await, + anchor_offset: 0, + limit: 10, + }, + vec![ + "N01205", "N01976", "T01139", "N01525", "T00176", "N01405", "N02396", "N04885", + "N01526", "N02134", + ], + vec![ + "N01205", "N01526", "T01455", "N01969", "N05250", "N01781", "N00759", "A00057", + "N03527", "N01558", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "N01205").await, + anchor_offset: 10, + limit: 10, + }, + vec![ + "N01933", "N03618", "T03904", "N02398", "N02399", "N02688", "T01455", "N03051", + "N01500", "N03411", + ], + vec![ + "N01559", "N04326", "N06017", "N01553", "N01617", "N01528", "N01539", "T09439", + "N01593", "N03988", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "N01205").await, + anchor_offset: -10, + limit: 10, + }, + vec![ + "T03614", "N05779", "N04652", "N01534", "A00845", "N03409", "N03410", "N02061", + "N02426", "N00662", + ], + vec![ + "N00436", "N00443", "N02237", "T03025", "N01722", "N01356", "N01800", "T05475", + "T01587", "N05779", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "N01496").await, + anchor_offset: -10, + limit: 10, + }, + vec![ + "N01496", "N01320", "N01321", "N05916", "N00273", "N01453", "N02984", "T08820", + "N00112", "T00211", + ], + vec![ + "N01496", "N01320", "N05916", "N01453", "T08820", "N01046", "N00675", "T08891", + "T01882", "N04296", + ], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "AR00164").await, + anchor_offset: 10, + limit: 10, + }, + vec![], + vec![], + ), + ( + EmailQuery { + filter: None, + sort: vec![ + email::query::Comparator::subject(), + email::query::Comparator::from(), + email::query::Comparator::sent_at(), + ], + position: 0, + anchor: get_anchor(client, "AR00164").await, + anchor_offset: 0, + limit: 0, + }, + vec!["AR00164", "AR00472", "AR00178"], + vec!["AR00164"], + ), + ] { + for (test_num, expected_results) in [expected_results, expected_results_collapsed] + .into_iter() + .enumerate() + { + let mut request = client.build(); + let query_request = request + .query_email() + .sort(query.sort.clone()) + .position(query.position) + .calculate_total(true); + if query.limit > 0 { + query_request.limit(query.limit); + } + if let Some(filter) = query.filter.as_ref() { + query_request.filter(filter.clone()); + } + if let Some(anchor) = query.anchor.as_ref() { + query_request.anchor(anchor); + query_request.anchor_offset(query.anchor_offset); + } + query_request.arguments().collapse_threads(test_num == 1); + + if !expected_results.is_empty() { + let query_result_ref = query_request.result_reference(); + request + .get_email() + .ids_ref(query_result_ref) + .properties([email::Property::MessageId]); + + assert_eq!( + request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_email() + .unwrap() + .take_list() + .into_iter() + .map(|e| e.message_id().unwrap().first().unwrap().to_string()) + .collect::>(), + expected_results, + "{:#?} ({})", + query, + test_num == 1 + ); + } else { + assert_eq!( + request.send_query_email().await.unwrap().ids(), + Vec::<&str>::new() + ); + } + } + } +} + +pub async fn create(test: &TestServer, account: &Account) { + let sent_at = now(); + let now = Instant::now(); + let mut fields = AHashMap::default(); + for (field_num, field) in FIELDS.iter().enumerate() { + fields.insert(field.to_string(), field_num); + } + + let mut total_messages = 0; + let mut total_threads = 0; + let mut thread_count = AHashMap::default(); + let mut artist_count = AHashMap::default(); + + let mut messages = Vec::new(); + let mut chunks = Vec::new(); + + 'outer: for (idx, record) in csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(&deflate_test_resource("artwork_data.csv.gz")[..]) + .records() + .enumerate() + { + let record = record.unwrap(); + let mut values_str = AHashMap::default(); + let mut values_int = AHashMap::default(); + + for field_name in [ + "year", + "acquisitionYear", + "accession_number", + "artist", + "artistRole", + "medium", + "title", + "creditLine", + "inscription", + ] { + let field = record.get(fields[field_name]).unwrap(); + if field.is_empty() + || (field_name == "title" && (field.contains('[') || field.contains(']'))) + { + continue 'outer; + } else if field_name == "year" || field_name == "acquisitionYear" { + let field = field.parse::().unwrap_or(0); + if field < 1000 { + continue 'outer; + } + values_int.insert(field_name.to_string(), field); + } else { + values_str.insert(field_name.to_string(), field.to_string()); + } + } + + let val = artist_count + .entry(values_str["artist"].clone()) + .or_insert(0); + if *val == 3 { + continue; + } + *val += 1; + + match thread_count.entry(values_int["year"]) { + Entry::Occupied(mut e) => { + let messages_per_thread = e.get_mut(); + if *messages_per_thread == MAX_MESSAGES_PER_THREAD { + continue; + } + *messages_per_thread += 1; + } + Entry::Vacant(e) => { + if total_threads == MAX_THREADS { + continue; + } + total_threads += 1; + e.insert(1); + } + } + + total_messages += 1; + + let mut keywords = Vec::new(); + for keyword in [ + values_str["medium"].to_string(), + values_str["artistRole"].to_string(), + values_str["accession_number"][0..1].to_string(), + format!( + "N{}", + &values_str["accession_number"][values_str["accession_number"].len() - 1..] + ), + ] { + if keyword == "attributed to" + || keyword == "T" + || keyword == "N0" + || keyword == "N" + || keyword == "artist" + || keyword == "Bronze" + { + keywords.push(keyword); + } + } + + let message = MessageBuilder::new() + .from((values_str["artist"].as_str(), "artist@domain.com")) + .cc((values_str["medium"].as_str(), "cc@domain.com")) + .subject(format!("Year {}", values_int["year"])) + .date(Date::new(sent_at as i64 + idx as i64)) + .message_id(values_str["accession_number"].as_str()) + .header("References", MessageId::new(values_int["year"].to_string())) + .header("Comments", Text::new(values_str["artistRole"].as_str())) + .text_body(format!( + "{}\n{}\n", + values_str["creditLine"], values_str["inscription"] + )) + .attachment("text/plain", "details.txt", values_str["title"].as_bytes()) + .write_to_vec() + .unwrap(); + + messages.push(( + message, + [ + Id::new(values_int["year"] as u64).to_string(), + Id::new((values_int["acquisitionYear"] + 1000) as u64).to_string(), + ], + keywords, + values_int["year"] as i64, + )); + + if messages.len() == 100 { + chunks.push(messages); + messages = Vec::new(); + } + + if total_messages == MAX_MESSAGES { + break; + } + } + + if !messages.is_empty() { + chunks.push(messages); + } + + let mut tasks = Vec::new(); + for chunk in chunks { + let client = account.jmap_client().await; + tasks.push(tokio::spawn(async move { + for (raw_message, mailbox_ids, keywords, sent_at) in chunk { + client + .email_import(raw_message, mailbox_ids, keywords.into(), Some(sent_at)) + .await + .unwrap(); + } + })); + } + + for task in tasks { + task.await.unwrap(); + } + + test.wait_for_tasks().await; + + println!( + "Imported {} messages in {} ms (single thread).", + total_messages, + now.elapsed().as_millis() + ); +} + +async fn get_anchor(client: &Client, anchor: &str) -> Option { + client + .email_query( + email::query::Filter::header("Message-Id", anchor.into()).into(), + None::>, + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap() + .into() +} + +#[derive(Debug, Clone)] +pub struct EmailQuery { + pub filter: Option>, + pub sort: Vec>, + pub position: i32, + pub anchor: Option, + pub anchor_offset: i32, + pub limit: usize, +} diff --git a/tests/src/jmap/mail/query_changes.rs b/tests/src/jmap/mail/query_changes.rs new file mode 100644 index 0000000..0a65024 --- /dev/null +++ b/tests/src/jmap/mail/query_changes.rs @@ -0,0 +1,337 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::mail::changes::{LogAction, ParseState}, + utils::server::TestServer, +}; +use ::email::message::metadata::MessageData; +use common::storage::index::ObjectIndexBuilder; +use jmap_client::{ + core::query::{Comparator, Filter}, + email, + mailbox::Role, +}; +use jmap_proto::types::state::State; +use std::str::FromStr; +use store::{ + ValueKey, + ahash::{AHashMap, AHashSet}, + write::{AlignedBytes, Archive, BatchBuilder}, +}; +use types::{ + collection::{Collection, SyncCollection}, + id::Id, +}; + +pub async fn test(test: &TestServer) { + println!("Running Email QueryChanges tests..."); + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + let mailbox1_id = client + .mailbox_create("JMAP Changes 1", None::, Role::None) + .await + .unwrap() + .take_id(); + let mailbox2_id = client + .mailbox_create("JMAP Changes 2", None::, Role::None) + .await + .unwrap() + .take_id(); + + let mut states = vec![State::Initial]; + let mut id_map = AHashMap::default(); + + let mut updated_ids = AHashSet::default(); + let mut removed_ids = AHashSet::default(); + let mut type1_ids = AHashSet::default(); + let mut thread_id_map: AHashMap = AHashMap::default(); + + let mut thread_id = 100; + + for (change_num, change) in [ + LogAction::Insert(0), + LogAction::Insert(1), + LogAction::Insert(2), + LogAction::Move(0, 3), + LogAction::Insert(4), + LogAction::Insert(5), + LogAction::Update(1), + LogAction::Update(2), + LogAction::Delete(1), + LogAction::Insert(6), + LogAction::Insert(7), + LogAction::Update(2), + LogAction::Update(4), + LogAction::Update(5), + LogAction::Update(6), + LogAction::Update(7), + LogAction::Delete(4), + LogAction::Delete(5), + LogAction::Delete(6), + LogAction::Insert(8), + LogAction::Insert(9), + LogAction::Insert(10), + LogAction::Update(3), + LogAction::Update(2), + LogAction::Update(8), + LogAction::Move(9, 11), + LogAction::Move(10, 12), + LogAction::Delete(8), + ] + .iter() + .enumerate() + { + match &change { + LogAction::Insert(id) => { + let jmap_id = Id::from_str( + client + .email_import( + format!( + "From: test_{}\nSubject: test_{}\n\ntest", + if change_num % 2 == 0 { 1 } else { 2 }, + *id + ) + .into_bytes(), + [if change_num % 2 == 0 { + &mailbox1_id + } else { + &mailbox2_id + }], + [if change_num % 2 == 0 { "1" } else { "2" }].into(), + Some(*id as i64), + ) + .await + .unwrap() + .id() + .unwrap(), + ) + .unwrap(); + + id_map.insert(*id, jmap_id); + if change_num % 2 == 0 { + type1_ids.insert(jmap_id); + } + thread_id_map.entry(jmap_id.prefix_id()).or_insert(jmap_id); + } + LogAction::Update(id) => { + let id = *id_map.get(id).unwrap(); + let mut batch = BatchBuilder::new(); + batch + .with_document(id.document_id()) + .log_item_update(SyncCollection::Email, id.prefix_id().into()); + server.store().write(batch.build_all()).await.unwrap(); + updated_ids.insert(id); + } + LogAction::Delete(id) => { + let id = *id_map.get(id).unwrap(); + client.email_destroy(&id.to_string()).await.unwrap(); + removed_ids.insert(id); + } + LogAction::Move(from, to) => { + let id = *id_map.get(from).unwrap(); + let new_id = Id::from_parts(thread_id, id.document_id()); + + //let new_thread_id = store::rand::random::(); + + let old_message_ = server + .store() + .get_value::>(ValueKey::archive( + account.id().document_id(), + Collection::Email, + id.document_id(), + )) + .await + .unwrap() + .unwrap(); + let old_message = old_message_.to_unarchived::().unwrap(); + let mut new_message = old_message.deserialize::().unwrap(); + new_message.thread_id = thread_id; + + server + .core + .storage + .data + .write( + BatchBuilder::new() + .with_account_id(account.id().document_id()) + .with_collection(Collection::Email) + .with_document(id.document_id()) + .custom( + ObjectIndexBuilder::new() + .with_current(old_message) + .with_changes(new_message), + ) + .unwrap() + .build_all(), + ) + .await + .unwrap(); + + id_map.insert(*to, new_id); + if type1_ids.contains(&id) { + type1_ids.insert(new_id); + } + removed_ids.insert(id); + thread_id_map.insert(new_id.prefix_id(), new_id); + thread_id += 1; + } + LogAction::UpdateChild(_) => unreachable!(), + } + + let mut new_state = State::Initial; + for state in &states { + for (test_num, query) in vec![ + QueryChanges { + filter: None, + sort: vec![email::query::Comparator::received_at()], + since_query_state: state.clone(), + max_changes: 0, + up_to_id: None, + collapse_threads: false, + }, + QueryChanges { + filter: Some(email::query::Filter::from("test_1").into()), + sort: vec![email::query::Comparator::received_at()], + since_query_state: state.clone(), + max_changes: 0, + up_to_id: None, + collapse_threads: false, + }, + QueryChanges { + filter: Some(email::query::Filter::in_mailbox(&mailbox1_id).into()), + sort: vec![email::query::Comparator::received_at()], + since_query_state: state.clone(), + max_changes: 0, + up_to_id: None, + collapse_threads: false, + }, + QueryChanges { + filter: None, + sort: vec![email::query::Comparator::received_at()], + since_query_state: state.clone(), + max_changes: 0, + up_to_id: id_map + .get(&7) + .map(|id| id.to_string().into()) + .unwrap_or(None), + collapse_threads: false, + }, + QueryChanges { + filter: None, + sort: vec![email::query::Comparator::received_at()], + since_query_state: state.clone(), + max_changes: 0, + up_to_id: None, + collapse_threads: true, + }, + ] + .into_iter() + .enumerate() + { + if (test_num == 3 || test_num == 4) && query.up_to_id.is_none() { + continue; + } + if test_num == 4 && !query.collapse_threads { + continue; + } + let mut request = client.build(); + let query_request = request + .query_email_changes(query.since_query_state.to_string()) + .sort(query.sort); + + if let Some(filter) = query.filter { + query_request.filter(filter); + } + + if let Some(up_to_id) = query.up_to_id { + query_request.up_to_id(up_to_id); + } + + if query.collapse_threads { + query_request.arguments().collapse_threads(true); + } + + let changes = request.send_query_email_changes().await.unwrap(); + + if test_num == 0 || test_num == 1 { + // Immutable filters should not return modified ids, only deletions. + for id in changes.removed() { + let id = Id::from_str(id).unwrap(); + assert!( + removed_ids.contains(&id), + "{:?} (id: {:?})", + changes, + id_map.iter().find(|(_, v)| **v == id).map(|(k, _)| k) + ); + } + } + if test_num == 1 || test_num == 2 { + // Only type 1 results should be added to the list. + for item in changes.added() { + let id = Id::from_str(item.id()).unwrap(); + assert!( + type1_ids.contains(&id), + "{:?} (id: {:?})", + changes, + id_map.iter().find(|(_, v)| **v == id).map(|(k, _)| k) + ); + } + } + if test_num == 3 { + // Only ids up to 7 should be added to the list. + for item in changes.added() { + let item_id = Id::from_str(item.id()).unwrap(); + let id = id_map.iter().find(|(_, v)| **v == item_id).unwrap().0; + assert!(id <= &7, "{:?} (id: {})", changes, id); + } + } + if test_num == 4 { + // With collapse_threads, only first email per thread should be added. + let mut seen_threads = AHashSet::new(); + for item in changes.added() { + let item_id = Id::from_str(item.id()).unwrap(); + let thread_id = item_id.prefix_id(); + assert!( + seen_threads.insert(thread_id), + "Thread {} appears multiple times with collapse_threads: {:?}", + thread_id, + changes + ); + // Verify this is the first email in this thread + assert_eq!( + thread_id_map.get(&thread_id), + Some(&item_id), + "Expected first email in thread {}, got {:?}", + thread_id, + item_id + ); + } + } + + if let State::Initial = state { + new_state = State::parse_str(changes.new_query_state()).unwrap(); + } + } + } + states.push(new_state); + } + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +#[derive(Debug, Clone)] +pub struct QueryChanges { + pub filter: Option>, + pub sort: Vec>, + pub since_query_state: State, + pub max_changes: usize, + pub up_to_id: Option, + pub collapse_threads: bool, +} diff --git a/tests/src/jmap/mail/search_snippet.rs b/tests/src/jmap/mail/search_snippet.rs new file mode 100644 index 0000000..ec707fb --- /dev/null +++ b/tests/src/jmap/mail/search_snippet.rs @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use email::mailbox::INBOX_ID; +use jmap_client::{core::query, email::query::Filter}; +use std::{fs, path::PathBuf}; +use store::ahash::AHashMap; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running SearchSnippet tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let mailbox_id = Id::from(INBOX_ID).to_string(); + + let mut email_ids = AHashMap::default(); + + let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_dir.push("resources"); + test_dir.push("jmap"); + test_dir.push("email_snippet"); + + // Import test messages + for email_name in [ + "html", + "subpart", + "mixed", + "text_plain", + "text_plain_chinese", + ] { + let mut file_name = test_dir.clone(); + file_name.push(format!("{}.eml", email_name)); + let email_id = client + .email_import( + fs::read(&file_name).unwrap(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + email_ids.insert(email_name, email_id); + } + test.wait_for_tasks().await; + + let can_stem = test.server.search_store().internal_fts().is_some(); + + // Run tests + for (filter, email_name, snippet_subject, snippet_preview) in [ + ( + query::Filter::or(vec![ + query::Filter::or(vec![Filter::subject("friend"), Filter::subject("help")]), + query::Filter::or(vec![Filter::body("secret"), Filter::body("call")]), + ]), + "text_plain", + Some("Help a friend from Abidjan Côte d'Ivoire"), + Some(concat!( + "d'Ivoire. He secretly called me on his bedside ", + "and told me that he has a sum of $7.5M (Seven Million five Hundred Thousand", + " Dollars) left in a suspense account in a local bank here in Abidjan Côte ", + "d'Ivoire, that he used my name a" + )), + ), + ( + Filter::text("côte").into(), + "text_plain", + Some("Help a friend from Abidjan Côte d'Ivoire"), + Some(concat!( + "in Abidjan Côte d'Ivoire. He secretly called me on ", + "his bedside and told me that he has a sum of $7.5M (Seven ", + "Million five Hundred Thousand Dollars) left in a suspense ", + "account in a local bank here in Abidjan Côte d'Ivoire, that " + )), + ), + ( + Filter::text("\"your country\"").into(), + "text_plain", + None, + Some(concat!( + "over to your country to further my education and ", + "to secure a residential permit for me in your country", + ". Moreover, I am willing to offer you 30 percent of the total sum as ", + "compensation for your effort inp", + )), + ), + ( + Filter::text("overseas").into(), + "text_plain", + None, + Some("nominated account overseas. "), + ), + ( + Filter::text("孫子兵法").into(), + "text_plain_chinese", + Some("兵法"), + Some(concat!( + "<"兵法:"> ", + "曰:兵者,國之大事,死生之地,存亡之道,", + "不可不察也。 曰:凡用兵之法,馳車千駟" + )), + ), + ( + Filter::text("cia").into(), + "subpart", + None, + Some("shouldn't the CIA have something like that? Bill"), + ), + ( + Filter::text("frösche").into(), + "html", + Some("Die Hasen und die Frösche"), + Some(concat!( + "und die Frösche Die Hasen klagten einst über ihre mißliche Lage; ", + ""wir leben", sprach ein Redner, "in steter Furcht vor Menschen und ", + "Tieren, eine Beute der Hunde, der Adler, ja fast aller Raubtiere! ", + "Unsere stete Angst ist är" + )), + ), + ( + Filter::text(if can_stem { + "es:galería vasto biblioteca" + } else { + "es:galería vastos biblioteca" + }) + .into(), + "mixed", + Some("Biblioteca de Babel"), + Some(concat!( + "llaman la *Biblioteca*) se compone de un número indefinido, y tal ", + "vez infinito, de galerías hexagonales, con vastos ", + "pozos de ventilación en el medio, cercados por barandas bajísimas. Desde ", + "cualquier hexágono se " + )), + ), + ] { + let mut request = client.build(); + let result_ref = request + .query_email() + .filter(filter.clone()) + .result_reference(); + request + .get_search_snippet() + .filter(filter) + .email_ids_ref(result_ref); + let response = request + .send() + .await + .unwrap() + .unwrap_method_responses() + .pop() + .unwrap() + .unwrap_get_search_snippet() + .unwrap(); + let snippet = response + .snippet(email_ids.get(email_name).unwrap()) + .unwrap_or_else(|| panic!("No snippet for {}", email_name)); + assert_eq!(snippet_subject, snippet.subject()); + assert_eq!(snippet_preview, snippet.preview()); + assert!( + snippet.preview().map_or(0, |p| p.len()) <= 255, + "len: {}", + snippet.preview().map_or(0, |p| p.len()) + ); + } + + // Destroy test data + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mail/set.rs b/tests/src/jmap/mail/set.rs new file mode 100644 index 0000000..69c6f07 --- /dev/null +++ b/tests/src/jmap/mail/set.rs @@ -0,0 +1,327 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{find_values, replace_blob_ids, replace_boundaries, replace_values}, + utils::server::TestServer, +}; +use ::email::mailbox::INBOX_ID; +use ahash::AHashSet; +use jmap_client::{ + Error, Set, + client::Client, + core::set::{SetError, SetErrorType}, + email::{self, Email}, + mailbox::Role, +}; +use registry::schema::prelude::ObjectType; +use std::{fs, path::PathBuf}; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Email Set tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let mailbox_id = Id::from(INBOX_ID).to_string(); + + create(&client, &mailbox_id).await; + update(&client, &mailbox_id).await; + + test.destroy_all_mailboxes(account).await; + test.account("admin@example.com") + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + test.assert_is_empty().await; +} + +async fn create(client: &Client, mailbox_id: &str) { + let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_dir.push("resources"); + test_dir.push("jmap"); + test_dir.push("email_set"); + + for file_name in fs::read_dir(&test_dir).unwrap() { + let mut file_name = file_name.as_ref().unwrap().path(); + if file_name.extension().is_none_or(|e| e != "json") { + continue; + } + println!("Creating email from {:?}", file_name); + + // Upload blobs + let mut json_request = String::from_utf8(fs::read(&file_name).unwrap()).unwrap(); + let blob_values = find_values(&json_request, "\"blobId\""); + if !blob_values.is_empty() { + let mut blob_ids = Vec::with_capacity(blob_values.len()); + for blob_value in &blob_values { + let blob_value = blob_value.replace("\\r", "\r").replace("\\n", "\n"); + blob_ids.push( + client + .upload(None, blob_value.into_bytes(), None) + .await + .unwrap() + .take_blob_id(), + ); + } + json_request = replace_values(json_request, &blob_values, &blob_ids); + } + + // Create message and obtain its blobId + let mut request = client.build(); + let mut create_item = + serde_json::from_slice::>(json_request.as_bytes()).unwrap(); + create_item.mailbox_ids([mailbox_id]); + let create_id = request.set_email().create_item(create_item); + let created_email = request + .send_set_email() + .await + .unwrap() + .created(&create_id) + .unwrap(); + + // Download raw message + let raw_message = client + .download(created_email.blob_id().unwrap()) + .await + .unwrap(); + + // Fetch message + let mut request = client.build(); + request + .get_email() + .ids([created_email.id().unwrap()]) + .properties([ + email::Property::Id, + email::Property::BlobId, + email::Property::ThreadId, + email::Property::MailboxIds, + email::Property::Keywords, + email::Property::ReceivedAt, + email::Property::MessageId, + email::Property::InReplyTo, + email::Property::References, + email::Property::Sender, + email::Property::From, + email::Property::To, + email::Property::Cc, + email::Property::Bcc, + email::Property::ReplyTo, + email::Property::Subject, + email::Property::SentAt, + email::Property::HasAttachment, + email::Property::Preview, + email::Property::BodyValues, + email::Property::TextBody, + email::Property::HtmlBody, + email::Property::Attachments, + email::Property::BodyStructure, + ]) + .arguments() + .body_properties([ + email::BodyProperty::PartId, + email::BodyProperty::BlobId, + email::BodyProperty::Size, + email::BodyProperty::Name, + email::BodyProperty::Type, + email::BodyProperty::Charset, + email::BodyProperty::Headers, + email::BodyProperty::Disposition, + email::BodyProperty::Cid, + email::BodyProperty::Language, + email::BodyProperty::Location, + ]) + .fetch_all_body_values(true) + .max_body_value_bytes(100); + let email = request + .send_get_email() + .await + .unwrap() + .pop() + .unwrap() + .into_test(); + + // Compare raw message + file_name.set_extension("eml"); + let result = replace_boundaries(String::from_utf8(raw_message).unwrap()); + + if fs::read(&file_name).unwrap() != result.as_bytes() { + file_name.set_extension("eml_failed"); + fs::write(&file_name, result.as_bytes()).unwrap(); + panic!("Test failed, output saved to {}", file_name.display()); + } + + // Compare response + file_name.set_extension("jmap"); + let result = replace_blob_ids(replace_boundaries( + serde_json::to_string_pretty(&email).unwrap(), + )); + if fs::read(&file_name).unwrap() != result.as_bytes() { + file_name.set_extension("jmap_failed"); + fs::write(&file_name, result.as_bytes()).unwrap(); + panic!("Test failed, output saved to {}", file_name.display()); + } + } +} + +async fn update(client: &Client, root_mailbox_id: &str) { + // Obtain all messageIds previously created + let mailbox = client + .email_query( + email::query::Filter::in_mailbox(root_mailbox_id).into(), + None::>, + ) + .await + .unwrap(); + + // Create two test mailboxes + let test_mailbox1_id = client + .mailbox_create("Test 1", None::, Role::None) + .await + .unwrap() + .take_id(); + let test_mailbox2_id = client + .mailbox_create("Test 2", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Set keywords and mailboxes + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .mailbox_ids([&test_mailbox1_id, &test_mailbox2_id]) + .keywords(["test1", "test2"]); + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)) + .unwrap(); + assert_email_properties( + client, + mailbox.id(0), + &[&test_mailbox1_id, &test_mailbox2_id], + &["test1", "test2"], + ) + .await; + + // Patch keywords and mailboxes + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .mailbox_id(&test_mailbox1_id, false) + .keyword("test1", true) + .keyword("test2", false) + .keyword("test3", true); + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)) + .unwrap(); + assert_email_properties( + client, + mailbox.id(0), + &[&test_mailbox2_id], + &["test1", "test3"], + ) + .await; + + // Orphan messages should not be permitted + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .mailbox_id(&test_mailbox2_id, false); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + // Updating and destroying the same item should not be allowed + let mut request = client.build(); + let set_email_request = request.set_email(); + set_email_request + .update(mailbox.id(0)) + .mailbox_id(&test_mailbox2_id, false); + set_email_request.destroy([mailbox.id(0)]); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::WillDestroy, + .. + })) + )); + + // Delete some messages + let mut request = client.build(); + request.set_email().destroy([mailbox.id(1), mailbox.id(2)]); + assert_eq!( + request + .send_set_email() + .await + .unwrap() + .destroyed_ids() + .unwrap() + .count(), + 2 + ); + let mut request = client.build(); + request.get_email().ids([mailbox.id(1), mailbox.id(2)]); + assert_eq!(request.send_get_email().await.unwrap().not_found().len(), 2); + + // Destroy test mailboxes + client + .mailbox_destroy(&test_mailbox1_id, true) + .await + .unwrap(); + client + .mailbox_destroy(&test_mailbox2_id, true) + .await + .unwrap(); +} + +pub async fn assert_email_properties( + client: &Client, + message_id: &str, + mailbox_ids: &[&str], + keywords: &[&str], +) { + let result = client + .email_get( + message_id, + [email::Property::MailboxIds, email::Property::Keywords].into(), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + mailbox_ids.iter().copied().collect::>(), + result + .mailbox_ids() + .iter() + .copied() + .collect::>() + ); + + assert_eq!( + keywords.iter().copied().collect::>(), + result.keywords().iter().copied().collect::>() + ); +} diff --git a/tests/src/jmap/mail/sieve_script.rs b/tests/src/jmap/mail/sieve_script.rs new file mode 100644 index 0000000..556c051 --- /dev/null +++ b/tests/src/jmap/mail/sieve_script.rs @@ -0,0 +1,581 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::mail::submission::{MockMessage, assert_message_delivery, spawn_mock_smtp_server}, + utils::{dns::DnsCache, server::TestServer, smtp::SmtpConnection}, +}; +use jmap_client::{ + Error, + core::set::{SetError, SetErrorType}, + email, mailbox, + sieve::query::{Comparator, Filter}, +}; +use registry::schema::{prelude::ObjectType, structs::SieveUserScript}; +use std::{ + fs, + path::PathBuf, + time::{Duration, Instant}, +}; + +pub async fn test(test: &TestServer) { + println!("Running Sieve tests..."); + + // Create a global script + let admin = test.account("admin@example.com"); + admin + .registry_create_object(SieveUserScript { + contents: "require \"reject\";\nreject \"Rejected from a global script.\";\nstop;\n" + .into(), + description: None, + is_active: true, + name: "common".into(), + }) + .await; + admin.reload_settings().await; + + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + // Validate scripts + client + .sieve_script_validate(get_script("validate_ok")) + .await + .unwrap(); + assert!(matches!( + client + .sieve_script_validate(get_script("validate_error")) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::InvalidScript, + .. + })) + )); + + // Create 5 Sieve scripts, all deactivated. + let mut script_ids = Vec::new(); + for i in 0..5 { + script_ids.push( + client + .sieve_script_create( + format!("script_{}", i + 1), + format!("require \"fileinto\"; fileinto \"{}\";", i + 1).into_bytes(), + false, + ) + .await + .unwrap() + .take_id(), + ); + } + + let response = client + .sieve_script_query(Filter::is_active(false).into(), [Comparator::name()].into()) + .await + .unwrap(); + assert_eq!(response.ids().len(), 5); + for (pos, id) in response.ids().iter().enumerate() { + let script = client + .sieve_script_get(id, None::>) + .await + .unwrap() + .unwrap(); + assert_eq!(script.name().unwrap(), format!("script_{}", pos + 1)); + assert_eq!( + String::from_utf8(client.download(script.blob_id().unwrap()).await.unwrap()).unwrap(), + format!("require \"fileinto\"; fileinto \"{}\";", pos + 1) + ); + } + + // Activate last script twice and then the first script + for _ in 0..2 { + client + .sieve_script_activate(script_ids.last().unwrap()) + .await + .unwrap(); + assert_eq!( + client + .sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into()) + .await + .unwrap() + .ids(), + vec![script_ids.last().unwrap().to_string()] + ); + } + client + .sieve_script_activate(script_ids.first().unwrap()) + .await + .unwrap(); + assert_eq!( + client + .sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into()) + .await + .unwrap() + .ids(), + vec![script_ids.first().unwrap().to_string()] + ); + + // Destroying an active script should not work + assert!(matches!( + client + .sieve_script_destroy(script_ids.first().unwrap()) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::ScriptIsActive, + .. + })) + )); + + // Deactivate all scripts + client.sieve_script_deactivate().await.unwrap(); + assert_eq!( + client + .sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into()) + .await + .unwrap() + .ids(), + Vec::::new() + ); + + // Connect to LMTP service + let mut lmtp = SmtpConnection::connect().await; + + // Run mailbox, fileinto, flags tests + client + .sieve_script_create("test_mailbox", get_script("test_mailbox"), true) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + // Make sure all folders were created + let mailbox_names = "My/Nested/Mailbox/with/multiple/levels/Folder" + .split('/') + .collect::>(); + let mut mailbox_ids = Vec::new(); + for &mailbox in &mailbox_names { + let mut response = client + .mailbox_query(mailbox::query::Filter::name(mailbox).into(), None::>) + .await + .unwrap(); + assert!( + !response.ids().is_empty(), + "Mailbox {} was not created.", + mailbox + ); + mailbox_ids.extend(response.take_ids()); + } + assert_eq!(mailbox_ids.len(), mailbox_names.len()); + + // Make sure the message was delivered to the right folders + let message_ids = client + .email_query(None::, None::>) + .await + .unwrap() + .take_ids(); + assert_eq!(message_ids.len(), 1, "too many messages {:?}", message_ids); + let email = client + .email_get( + message_ids.last().unwrap(), + [email::Property::MailboxIds, email::Property::Keywords].into(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + email.keywords().len(), + 2, + "Expected 2 keywords, found {:?}.", + email.keywords() + ); + for keyword in ["$important", "$seen"] { + if !email.keywords().contains(&keyword) { + panic!("Keyword {} not found in {:?}.", keyword, email.keywords()); + } + } + assert_eq!( + email.mailbox_ids().len(), + 3, + "Expected 3 mailbox ids, found {:?}.", + email.mailbox_ids() + ); + let drafts_id = client + .mailbox_query( + mailbox::query::Filter::name("Drafts").into(), + None::>, + ) + .await + .unwrap() + .take_ids() + .pop() + .expect("Drafts mailbox not found."); + assert!( + email.mailbox_ids().contains(&drafts_id.as_str()), + "Drafts mailbox {} not found in {:?}.", + drafts_id, + email.mailbox_ids() + ); + for mailbox_pos in [mailbox_ids.len() - 1, mailbox_ids.len() - 2] { + if !email + .mailbox_ids() + .contains(&mailbox_ids[mailbox_pos].as_str()) + { + panic!( + "Mailbox {} ({}) not found in {:?}.", + mailbox_names[mailbox_pos], + mailbox_ids[mailbox_pos], + email.keywords() + ); + } + } + + // Run discard and duplicate tests + client + .sieve_script_create( + "test_discard_reject", + get_script("test_discard_reject"), + true, + ) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Message-ID: <1234@example.com>\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your TPS reports before ", + "going on holidays." + ), + ) + .await; + assert_eq!( + client + .email_query(None::, None::>) + .await + .unwrap() + .ids() + .len(), + 1, + "Discard failed." + ); + + // Let one sec duplicate ids expire + tokio::time::sleep(Duration::from_millis(1100)).await; + + // Start mock SMTP server + let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server(); + server.ipv4_add( + "localhost", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + // Run reject and duplicate check tests + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Message-ID: <1234@example.com>\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your T.P.S. reports before ", + "going on holidays." + ), + ) + .await; + + assert_eq!( + client + .email_query(None::, None::>) + .await + .unwrap() + .ids() + .len(), + 1, + "Reject failed." + ); + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("<>", [""], "@No soup for you"), + ) + .await; + + // Run include tests + client + .sieve_script_create("test_include_this", get_script("test_include_this"), false) + .await + .unwrap(); + client + .sieve_script_create("test_include", get_script("test_include"), true) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Message-ID: <1234@example.com>\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your T.P.S. reports before ", + "going on holidays." + ), + ) + .await; + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "<>", + [""], + "@Rejected from an included script", + ), + ) + .await; + + client + .sieve_script_create( + "Test Script", + concat!( + "require \"reject\";\n", + "reject \"Rejected from a mixed-case included script.\";\n", + "stop;\n" + ) + .as_bytes() + .to_vec(), + false, + ) + .await + .unwrap(); + client + .sieve_script_create("test_include_case", get_script("test_include_case"), true) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Message-ID: <5678@example.com>\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your T.P.S. reports before ", + "going on holidays." + ), + ) + .await; + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "<>", + [""], + "@Rejected from a mixed-case included script", + ), + ) + .await; + + // Run include global tests + client + .sieve_script_create( + "test_include_global", + get_script("test_include_global"), + true, + ) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Message-ID: <1234@example.com>\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your T.P.S. reports before ", + "going on holidays." + ), + ) + .await; + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "<>", + [""], + "@Rejected from a global script", + ), + ) + .await; + + // Run enclose + redirect tests + client + .sieve_script_create( + "test_redirect_enclose", + get_script("test_redirect_enclose"), + true, + ) + .await + .unwrap(); + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "", + [""], + "@Attached you'll find", + ), + ) + .await; + assert_eq!( + client + .email_query(None::, None::>) + .await + .unwrap() + .ids() + .len(), + 1, + "Redirected message was stored." + ); + + // Run notify + editheader + notify + fcc tests + client + .sieve_script_create("test_notify_fcc", get_script("test_notify_fcc"), true) + .await + .unwrap(); + smtp_settings.lock().do_stop = true; + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: Urgently I need those TPS Reports\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "", + [""], + "@It's TPS-o-clock", + ), + ) + .await; + + let mut request = client.build(); + request.get_email().properties([ + email::Property::MailboxIds, + email::Property::Keywords, + email::Property::Subject, + ]); + let emails = request.send_get_email().await.unwrap().take_list(); + + assert_eq!( + emails.len(), + 3, + "Two new messages were expected: {:#?}.", + emails + ); + + 'outer: for (subject, folder, keywords) in [ + ("It's TPS-o-clock", "Notifications", ""), + ( + "Urgently I need those **censored** Reports", + "Inbox", + "$seen", + ), + ] { + for email in &emails { + if email.subject().unwrap().eq(subject) { + if !keywords.is_empty() && !email.keywords().contains(&keywords) { + panic!("Keyword {:?} not found in: {:#?}", keywords, email); + } + + let mailbox_id = client + .mailbox_query( + mailbox::query::Filter::name(folder.to_string()).into(), + None::>, + ) + .await + .unwrap() + .take_ids() + .pop() + .unwrap_or_else(|| panic!("Mailbox {:?} not found", folder)); + + if !email.mailbox_ids().contains(&mailbox_id.as_str()) { + panic!( + "Mailbox {:?} ({}) not found in: {:#?}", + folder, mailbox_id, email + ); + } + + continue 'outer; + } + } + panic!("Email {:?} not found in: {:#?}", subject, emails); + } + + // Remove test data + client.sieve_script_deactivate().await.unwrap(); + let mut request = client.build(); + request.query_sieve_script(); + for id in request.send_query_sieve_script().await.unwrap().take_ids() { + client.sieve_script_destroy(&id).await.unwrap(); + } + test.destroy_all_mailboxes(account).await; + admin + .registry_destroy_all(ObjectType::SieveUserScript) + .await; + test.assert_is_empty().await; +} + +fn get_script(name: &str) -> Vec { + let mut script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + script_path.push("resources"); + script_path.push("jmap"); + script_path.push("sieve"); + script_path.push(format!("{}.sieve", name)); + fs::read(script_path).unwrap() +} diff --git a/tests/src/jmap/mail/submission.rs b/tests/src/jmap/mail/submission.rs new file mode 100644 index 0000000..110390a --- /dev/null +++ b/tests/src/jmap/mail/submission.rs @@ -0,0 +1,762 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::mail::set::assert_email_properties, + utils::{dns::DnsCache, server::TestServer}, +}; +use ahash::AHashMap; +use common::auth::{AccountCache, EmailAddress}; +use jmap_client::{ + Error, + client::Client, + core::{ + response::IdentityGetResponse, + set::{SetError, SetErrorType, SetObject}, + }, + email_submission::{Address, Delivered, DeliveryStatus, Displayed, UndoStatus, query::Filter}, + mailbox::Role, +}; +use mail_parser::DateTime; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use store::{parking_lot::Mutex, write::BatchBuilder}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, + sync::mpsc, +}; +use types::{collection::Collection, field::PrincipalField, id::Id}; + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct MockMessage { + pub mail_from: String, + pub rcpt_to: Vec, + pub message: String, +} + +impl MockMessage { + pub fn new(mail_from: T, rcpt_to: U, message: T) -> Self + where + T: Into, + U: IntoIterator, + { + Self { + mail_from: mail_from.into(), + rcpt_to: rcpt_to.into_iter().map(|s| s.into()).collect(), + message: message.into(), + } + } +} + +#[derive(Default)] +pub struct MockSMTPSettings { + pub fail_mail_from: bool, + pub fail_rcpt_to: bool, + pub fail_message: bool, + pub do_stop: bool, +} + +#[allow(clippy::disallowed_types)] +pub async fn test(test: &TestServer) { + println!("Running E-mail submissions tests..."); + // Start mock SMTP server + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server(); + server.ipv4_add( + "localhost", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + std::time::Duration::from_secs(10), + ); + + // Test automatic identity creation + for (identity_id, email) in [(2u64, "jdoe@example.com"), (1u64, "john.doe@example.com")] { + let identity = client + .identity_get(&Id::from(identity_id).to_string(), None) + .await + .unwrap() + .unwrap(); + assert_eq!(identity.email().unwrap(), email); + assert_eq!(identity.name().unwrap(), "John Doe"); + } + + // Users should be allowed to create identities only + // using email addresses associated to their principal + let iid1 = client + .identity_create("John Doe", "jdoe@example.com") + .await + .unwrap() + .take_id(); + let iid2 = client + .identity_create("John Doe (secondary)", "john.doe@example.com") + .await + .unwrap() + .take_id(); + assert!(matches!( + client + .identity_create("John the Spammer", "spammy@mcspamface.com") + .await, + Err(jmap_client::Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + client.identity_destroy(&iid1).await.unwrap(); + client.identity_destroy(&iid2).await.unwrap(); + + // Create an identity without using a valid address should fail + match client + .identity_create("John Doe", "someaddress@domain.com") + .await + .unwrap_err() + { + Error::Set(err) => assert_eq!(err.error(), &SetErrorType::InvalidProperties), + err => panic!("Unexpected error: {:?}", err), + } + + // Create an identity + let identity_id = client + .identity_create("John Doe (manually created)", "jdoe@example.com") + .await + .unwrap() + .take_id(); + + // Create test mailboxes + let mailbox_id = client + .mailbox_create("JMAP EmailSubmission", None::, Role::None) + .await + .unwrap() + .take_id(); + let mailbox_id_2 = client + .mailbox_create("JMAP EmailSubmission 2", None::, Role::None) + .await + .unwrap() + .take_id(); + + // Import an email without any recipients + let email_id = client + .email_import( + b"From: jdoe@example.com\nSubject: hey\n\ntest".to_vec(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + + // Submission without a valid emailId or identityId should fail + assert!(matches!( + client + .email_submission_create(Id::new(123456).to_string(), &identity_id) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + assert!(matches!( + client + .email_submission_create(&email_id, Id::new(123456).to_string()) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + + // Submissions of e-mails without any recipients should fail + assert!(matches!( + client + .email_submission_create(&email_id, &identity_id) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::NoRecipients, + .. + })) + )); + + // Submissions with an envelope that does not match + // the identity from address should fail + assert!(matches!( + client + .email_submission_create_envelope( + &email_id, + &identity_id, + "other_address@example.com", + Vec::
::new(), + ) + .await, + Err(Error::Set(SetError { + type_: SetErrorType::ForbiddenFrom, + .. + })) + )); + + // Submit a valid message submission + let email_body = concat!( + "From: jdoe@example.com\r\n", + "To: jane_smith@remote.org\r\n", + "Bcc: bill@remote.org\r\n", + "Subject: hey\r\n\r\n", + "test" + ); + let email_id = client + .email_import( + email_body.as_bytes().to_vec(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + client + .email_submission_create(&email_id, &identity_id) + .await + .unwrap(); + + // Confirm that the message has been delivered + let email_body = email_body.replace("Bcc: bill@remote.org\r\n", ""); + assert_message_delivery( + &mut smtp_rx, + MockMessage::new( + "", + ["", ""], + &email_body, + ), + ) + .await; + + // Manually add recipients to the envelope and confirm submission + let email_submission_id = client + .email_submission_create_envelope( + &email_id, + &identity_id, + "jdoe@example.com", + [ + "tim@foobar.com", // Should be de-duplicated + "tim@foobar.com", + "tim@foobar.com ", + " james@other_domain.com ", // Should be sanitized + " secret_rcpt@test.com ", + ], + ) + .await + .unwrap() + .take_id(); + + for _ in 0..3 { + let mut message = expect_message_delivery(&mut smtp_rx).await; + + assert_eq!(message.mail_from, ""); + let rcpt_to = message.rcpt_to.pop().unwrap(); + assert!( + [ + "", + "", + "", + ] + .contains(&rcpt_to.as_str()) + ); + + assert!( + message.message.contains(&email_body), + "Got [{}], Expected[{}]", + message.message, + email_body + ); + } + + // Confirm that the email submission status was updated + tokio::time::sleep(Duration::from_millis(100)).await; + let email_submission = client + .email_submission_get(&email_submission_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(email_submission.undo_status().unwrap(), &UndoStatus::Final); + assert_eq!( + email_submission.delivery_status().unwrap(), + &AHashMap::from_iter([ + ( + "tim@foobar.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ( + "secret_rcpt@test.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ( + "james@other_domain.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ]) + ); + + // SMTP rejects some of the recipients + let email_submission_id = client + .email_submission_create_envelope( + &email_id, + &identity_id, + "jdoe@example.com", + [ + "nonexistant@example.com", + "delay@other_domain.com", + "fail@test.com", + "tim@foobar.com", + ], + ) + .await + .unwrap() + .take_id(); + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("", [""], &email_body), + ) + .await; + expect_nothing(&mut smtp_rx).await; + + // Verify SMTP replies + tokio::time::sleep(Duration::from_millis(100)).await; + let email_submission = client + .email_submission_get(&email_submission_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!( + email_submission.undo_status().unwrap(), + &UndoStatus::Pending + ); + assert_eq!( + email_submission.delivery_status().unwrap(), + &AHashMap::from_iter([ + ( + "nonexistant@example.com".to_string(), + DeliveryStatus::new( + "550 5.1.2 Mailbox does not exist.", + Delivered::No, + Displayed::Unknown + ) + ), + ( + "delay@other_domain.com".to_string(), + DeliveryStatus::new( + "Code: 451, Enhanced code: 4.5.3, Message: Try again later.", + Delivered::Queued, + Displayed::Unknown + ) + ), + ( + "fail@test.com".to_string(), + DeliveryStatus::new( + "Code: 550, Enhanced code: 0.0.0, Message: I refuse to accept that recipient.", + Delivered::No, + Displayed::Unknown + ) + ), + ( + "tim@foobar.com".to_string(), + DeliveryStatus::new( + "Code: 250, Enhanced code: 0.0.0, Message: OK", + Delivered::Yes, + Displayed::Unknown + ) + ), + ]) + ); + + // Cancel submission + client + .email_submission_change_status(&email_submission_id, UndoStatus::Canceled) + .await + .unwrap(); + let email_submission = client + .email_submission_get(&email_submission_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!( + email_submission.undo_status().unwrap(), + &UndoStatus::Canceled + ); + assert_eq!( + email_submission.delivery_status().unwrap(), + &AHashMap::from_iter([ + ( + "nonexistant@example.com".to_string(), + DeliveryStatus::new( + "550 5.1.2 Mailbox does not exist.", + Delivered::No, + Displayed::Unknown + ) + ), + ( + "delay@other_domain.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ( + "fail@test.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ( + "tim@foobar.com".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown) + ), + ]) + ); + + // Confirm that the sendAt property is updated when using FUTURERELEASE + let hold_until_date = "2079-11-20T05:00:00Z"; + let hold_until = DateTime::parse_rfc3339(hold_until_date) + .unwrap() + .to_timestamp(); + let email_submission_id = client + .email_submission_create_envelope( + &email_id, + &identity_id, + Address::new("jdoe@example.com") + .parameter("HOLDUNTIL", Some(hold_until_date.to_string())), + ["jane_smith@remote.org"], + ) + .await + .unwrap() + .take_id(); + tokio::time::sleep(Duration::from_millis(100)).await; + let email_submission = client + .email_submission_get(&email_submission_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!(email_submission.send_at().unwrap(), hold_until); + assert_eq!( + email_submission.undo_status().unwrap(), + &UndoStatus::Pending + ); + assert_eq!( + email_submission.delivery_status().unwrap(), + &AHashMap::from_iter([( + "jane_smith@remote.org".to_string(), + DeliveryStatus::new("250 2.1.5 Queued", Delivered::Queued, Displayed::Unknown) + ),]) + ); + + // Confirm that the query undoStatus filter agrees with EmailSubmission/get + assert!( + client + .email_submission_query( + Filter::undo_status(UndoStatus::Pending).into(), + None::> + ) + .await + .unwrap() + .take_ids() + .contains(&email_submission_id) + ); + assert!( + !client + .email_submission_query( + Filter::undo_status(UndoStatus::Final).into(), + None::> + ) + .await + .unwrap() + .take_ids() + .contains(&email_submission_id) + ); + + // Verify onSuccessUpdateEmail action + let mut request = client.build(); + let set_request = request.set_email_submission(); + let create_id = set_request + .create() + .email_id(&email_id) + .identity_id(&identity_id) + .create_id() + .unwrap(); + set_request + .arguments() + .on_success_update_email(&create_id) + .keyword("$draft", true) + .mailbox_id(&mailbox_id, false) + .mailbox_id(&mailbox_id_2, true); + request.send().await.unwrap().unwrap_method_responses(); + + assert_email_properties(&client, &email_id, &[&mailbox_id_2], &["$draft"]).await; + + // Verify onSuccessDestroyEmail action + let mut request = client.build(); + let set_request = request.set_email_submission(); + let create_id = set_request + .create() + .email_id(&email_id) + .identity_id(&identity_id) + .create_id() + .unwrap(); + set_request.arguments().on_success_destroy_email(&create_id); + request.send().await.unwrap().unwrap_method_responses(); + + assert!( + client + .email_get(&email_id, None::>) + .await + .unwrap() + .is_none() + ); + smtp_settings.lock().do_stop = true; + + // Identities are created and destroyed as the account's addresses change + let account_id = account.id().document_id(); + let account_cache = test.server.inner.cache.accounts.get(&account_id).unwrap(); + let mut updated_cache = AccountCache::clone(&account_cache); + updated_cache.addresses = updated_cache + .addresses + .iter() + .cloned() + .chain([EmailAddress { + local_part: "jdoe.temp".into(), + domain_id: account_cache.addresses.first().unwrap().domain_id, + }]) + .collect(); + test.server + .inner + .cache + .accounts + .insert(account_id, Arc::new(updated_cache)); + assert!( + identity_id_by_email(&client, "jdoe.temp@example.com") + .await + .is_some(), + "Identity was not created for the new address" + ); + + test.server + .inner + .cache + .accounts + .insert(account_id, account_cache); + assert!( + identity_id_by_email(&client, "jdoe.temp@example.com") + .await + .is_none(), + "Identity was not destroyed for the removed address" + ); + assert!( + identity_id_by_email(&client, "jdoe@example.com") + .await + .is_some(), + "Identity for a valid address was destroyed" + ); + + // Destroy the created mailbox, identity and all submissions + for identity_id in [ + identity_id, + Id::from(1u64).to_string(), + Id::from(2u64).to_string(), + ] { + client.identity_destroy(&identity_id).await.unwrap(); + } + for id in client + .email_submission_query(None::, None::>) + .await + .unwrap() + .take_ids() + { + let _ = client + .email_submission_change_status(&id, UndoStatus::Canceled) + .await; + client.email_submission_destroy(&id).await.unwrap(); + } + test.destroy_all_mailboxes(account).await; + + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account.id().document_id()) + .with_collection(Collection::Principal) + .with_document(0) + .clear(PrincipalField::IdentityAddresses); + test.server.commit_batch(batch).await.unwrap(); + + test.assert_is_empty().await; +} + +pub fn spawn_mock_smtp_server() -> (mpsc::Receiver, Arc>) { + // Create channels + let (event_tx, event_rx) = mpsc::channel::(100); + let _settings = Arc::new(Mutex::new(MockSMTPSettings::default())); + let settings = _settings.clone(); + + // Start mock SMTP server + tokio::spawn(async move { + let listener = TcpListener::bind("127.0.0.1:9999") + .await + .unwrap_or_else(|e| { + panic!("Failed to bind mock SMTP server to 127.0.0.1:9999: {}", e); + }); + + while let Ok((mut stream, _)) = listener.accept().await { + let (rx, mut tx) = stream.split(); + let mut rx = BufReader::new(rx); + let mut buf = String::with_capacity(128); + let mut message = MockMessage::default(); + + tx.write_all(b"220 [127.0.0.1] Clueless host service ready\r\n") + .await + .unwrap(); + + while rx.read_line(&mut buf).await.is_ok() { + //print!("-> {}", buf); + if buf.starts_with("EHLO") { + tx.write_all(b"250 Hi there, but I have no extensions to offer :-(\r\n") + .await + .unwrap(); + } else if buf.starts_with("MAIL FROM") { + if settings.lock().fail_mail_from { + tx.write_all("552-I do not\r\n552 like that MAIL FROM.\r\n".as_bytes()) + .await + .unwrap(); + } else { + message.mail_from = buf.split_once(':').unwrap().1.trim().to_string(); + tx.write_all(b"250 OK\r\n").await.unwrap(); + } + } else if buf.starts_with("RCPT TO") { + if buf.contains("fail@") { + tx.write_all( + "550-I refuse to\r\n550 accept that recipient.\r\n".as_bytes(), + ) + .await + .unwrap(); + } else if buf.contains("delay@") { + tx.write_all("451 4.5.3 Try again later.\r\n".as_bytes()) + .await + .unwrap(); + } else { + message + .rcpt_to + .push(buf.split(':').nth(1).unwrap().trim().to_string()); + tx.write_all(b"250 OK\r\n").await.unwrap(); + } + } else if buf.starts_with("DATA") { + if settings.lock().fail_message { + tx.write_all( + "503-Thank you but I am\r\n503 saving myself for dessert.\r\n" + .as_bytes(), + ) + .await + .unwrap(); + } else if !message.mail_from.is_empty() && !message.rcpt_to.is_empty() { + tx.write_all(b"354 Start feeding me now some quality content please\r\n") + .await + .unwrap(); + buf.clear(); + while rx.read_line(&mut buf).await.is_ok() { + if buf.starts_with('.') && buf.len() < 4 { + message.message = message.message.trim().to_string(); + break; + } else { + message.message += buf.as_str(); + buf.clear(); + } + } + tx.write_all(b"250 Great success!\r\n").await.unwrap(); + message.rcpt_to.sort_unstable(); + event_tx.send(message).await.unwrap(); + message = MockMessage::default(); + } else { + tx.write_all("554 You forgot to tell me a few things.\r\n".as_bytes()) + .await + .unwrap(); + } + } else if buf.starts_with("QUIT") { + tx.write_all("250 Arrivederci!\r\n".as_bytes()) + .await + .unwrap(); + break; + } else if buf.starts_with("RSET") { + tx.write_all("250 Your wish is my command.\r\n".as_bytes()) + .await + .unwrap(); + message = MockMessage::default(); + } else { + println!("Unknown command: {}", buf.trim()); + } + buf.clear(); + } + + if settings.lock().do_stop { + //println!("Mock SMTP server stopped."); + break; + } + } + }); + + (event_rx, _settings) +} + +pub async fn expect_message_delivery(event_rx: &mut mpsc::Receiver) -> MockMessage { + match tokio::time::timeout(Duration::from_millis(3000), event_rx.recv()).await { + Ok(Some(message)) => { + //println!("Got message [{}]", message.message); + + message + } + result => { + panic!("Timeout waiting for message, got: {:?}", result); + } + } +} + +pub async fn assert_message_delivery( + event_rx: &mut mpsc::Receiver, + expected_message: MockMessage, +) { + let message = expect_message_delivery(event_rx).await; + + assert_eq!(message.mail_from, expected_message.mail_from); + assert_eq!(message.rcpt_to, expected_message.rcpt_to); + + if let Some(needle) = expected_message.message.strip_prefix('@') { + assert!( + message.message.contains(needle), + "[{}] needle = {:?}", + message.message, + needle + ); + } else { + assert!( + message.message.contains(&expected_message.message), + "Got [{}], Expected[{}]", + message.message, + expected_message.message + ); + } +} + +pub async fn expect_nothing(event_rx: &mut mpsc::Receiver) { + match tokio::time::timeout(Duration::from_millis(500), event_rx.recv()).await { + Err(_) => {} + message => { + panic!("Received a message when expecting nothing: {:?}", message); + } + } +} + +async fn identity_id_by_email(client: &Client, email: &str) -> Option { + let mut request = client.build(); + request.get_identity(); + request + .send_single::() + .await + .unwrap() + .take_list() + .into_iter() + .find(|identity| identity.email() == Some(email)) + .map(|mut identity| identity.take_id()) +} diff --git a/tests/src/jmap/mail/thread_get.rs b/tests/src/jmap/mail/thread_get.rs new file mode 100644 index 0000000..6e2450e --- /dev/null +++ b/tests/src/jmap/mail/thread_get.rs @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use jmap_client::mailbox::Role; + +pub async fn test(test: &TestServer) { + println!("Running Email Thread tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + let mailbox_id = client + .mailbox_create("JMAP Get", None::, Role::None) + .await + .unwrap() + .take_id(); + + let mut expected_result = vec!["".to_string(); 5]; + let mut thread_id = "".to_string(); + + for num in [5, 3, 1, 2, 4] { + let mut email = client + .email_import( + format!("Subject: test\nReferences: <1234>\n\n{}", num).into_bytes(), + [&mailbox_id], + None::>, + Some(10000i64 + num as i64), + ) + .await + .unwrap(); + thread_id = email.thread_id().unwrap().to_string(); + expected_result[num - 1] = email.take_id(); + } + + test.wait_for_tasks().await; + + assert_eq!( + client + .thread_get(&thread_id) + .await + .unwrap() + .unwrap() + .email_ids(), + expected_result + ); + + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs new file mode 100644 index 0000000..1a4f461 --- /dev/null +++ b/tests/src/jmap/mail/thread_merge.rs @@ -0,0 +1,864 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + store::deflate_test_resource, + utils::server::{DestroyAllMailboxes, TestServer}, +}; +use ::email::{ + cache::MessageCacheFetch, + mailbox::INBOX_ID, + message::ingest::{EmailIngest, IngestEmail, IngestSource}, +}; +use common::auth::AccessToken; +use jmap_client::{email, mailbox::Role}; +use mail_parser::{MessageParser, mailbox::mbox::MessageIterator}; +use std::{io::Cursor, str::FromStr, time::Duration}; +use store::{ + ahash::AHashSet, + rand::{self, RngExt}, +}; +use types::id::Id; + +pub async fn test(test: &TestServer) { + test_single_thread(test).await; + test_multi_thread(test).await; +} + +async fn test_single_thread(test_server: &TestServer) { + println!("Running Email Merge Threads tests..."); + let account = test_server.account("admin@example.com"); + let mut client = account.jmap_client().await; + + let mut account_ids = Vec::new(); + for name in [ + "admin@example.com", + "jdoe@example.com", + "jane.smith@example.com", + "bill@example.com", + "robert@example.com", + "sales@example.com", + ] { + account_ids.push(test_server.account(name).id_string()); + } + + for (test_group_num, test) in [test_1(), test_2(), test_3()].iter().enumerate() { + let mut messages = Vec::new(); + let mut total_messages = 0; + let mut messages_per_thread = + build_messages(test, &mut messages, &mut total_messages, None, 0); + messages_per_thread.sort_unstable(); + + let mut mailbox_ids = Vec::with_capacity(6); + + for account_id in &account_ids { + mailbox_ids.push( + client + .set_default_account_id(*account_id) + .mailbox_create("Thread nightmare", None::, Role::None) + .await + .unwrap() + .take_id(), + ); + } + + for message in &messages { + client + .set_default_account_id(account_ids[0]) + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[0].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + + for message in messages.iter().rev() { + client + .set_default_account_id(account_ids[1]) + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[1].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + + for chunk in messages.chunks(5) { + client.set_default_account_id(account_ids[2]); + for message in chunk { + client + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[2].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + + client.set_default_account_id(account_ids[3]); + for message in chunk.iter().rev() { + client + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[3].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + } + + for chunk in messages.chunks(5).rev() { + client.set_default_account_id(account_ids[4]); + for message in chunk { + client + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[4].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + + client.set_default_account_id(account_ids[5]); + for message in chunk.iter().rev() { + client + .email_import( + message.to_string().into_bytes(), + [mailbox_ids[5].clone()], + None::>, + None, + ) + .await + .unwrap(); + } + } + test_server.wait_for_tasks().await; + + for test_num in 0..=5 { + let result = client + .set_default_account_id(account_ids[test_num]) + .email_query( + email::query::Filter::in_mailbox(mailbox_ids[test_num].clone()).into(), + None::>, + ) + .await + .unwrap(); + + assert_eq!( + result.ids().len(), + total_messages, + "test# {}/{}", + test_group_num, + test_num + ); + + let thread_ids: AHashSet = result + .ids() + .iter() + .map(|id| Id::from_str(id).unwrap().prefix_id()) + .collect(); + + let mut messages_per_thread_db = Vec::new(); + + for thread_id in thread_ids { + messages_per_thread_db.push( + client + .thread_get(&Id::new(thread_id as u64).to_string()) + .await + .unwrap() + .unwrap() + .email_ids() + .len(), + ); + } + messages_per_thread_db.sort_unstable(); + + assert_eq!(messages_per_thread_db, messages_per_thread); + println!("passed test# {}/{}", test_group_num, test_num); + } + + for account_id in &account_ids { + client + .set_default_account_id(*account_id) + .destroy_all_mailboxes() + .await; + } + test_server.wait_for_tasks().await; + test_server.assert_is_empty().await; + } + + test_server.assert_is_empty().await; +} + +#[allow(dead_code)] +async fn test_multi_thread(test: &TestServer) { + println!("Running Email Merge Threads tests (multi-threaded)..."); + let mut handles = vec![]; + let account = test.account("jdoe@example.com"); + let account_id = account.id().document_id(); + let mailbox_id = INBOX_ID; + + for message in MessageIterator::new(Cursor::new(deflate_test_resource("mailbox.gz"))) + .collect::>() + .into_iter() + { + let message = message.unwrap(); + let server = test.server.clone(); + handles.push(tokio::task::spawn(async move { + let mut retry_count = 0; + loop { + match server + .email_ingest(IngestEmail { + raw_message: message.contents(), + message: MessageParser::new().parse(message.contents()), + blob_hash: None, + access_token: &AccessToken::from_id_maybe_invalid(account_id), + mailbox_ids: vec![mailbox_id], + keywords: vec![], + received_at: None, + source: IngestSource::Smtp { + deliver_to: "test@domain.org", + is_sender_authenticated: true, + is_spam: false, + }, + session_id: 0, + }) + .await + { + Ok(_) => break, + Err(err) => { + if err.is_assertion_failure() && retry_count < 10 { + //println!("Retrying ingest for {}...", message.from()); + let backoff = rand::rng().random_range(50..=300); + tokio::time::sleep(Duration::from_millis(backoff)).await; + retry_count += 1; + continue; + } + panic!("Failed to ingest message: {:?}", err); + } + } + } + })); + } + // Wait for all tasks to complete + let messages = handles.len(); + println!("Waiting for {} tasks to complete...", messages); + for handle in handles { + handle.await.expect("Task panicked"); + } + assert_eq!( + messages, + test.server + .get_cached_messages(account_id) + .await + .unwrap() + .emails + .items + .len(), + ); + println!("Deleting all messages..."); + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} + +fn build_message(message: usize, in_reply_to: Option, thread_num: usize) -> String { + if let Some(in_reply_to) = in_reply_to { + format!( + "Message-ID: <{}>\nReferences: <{}>\nSubject: re: T{}\n\nreply\n", + message, in_reply_to, thread_num + ) + } else { + format!( + "Message-ID: <{}>\nSubject: T{}\n\nmsg\n", + message, thread_num + ) + } +} + +fn build_messages( + three: &ThreadTest, + messages: &mut Vec, + total_messages: &mut usize, + in_reply_to: Option, + thread_num: usize, +) -> Vec { + let mut messages_per_thread = Vec::new(); + match three { + ThreadTest::Message => { + *total_messages += 1; + messages.push(build_message(*total_messages, in_reply_to, thread_num)); + } + ThreadTest::MessageWithReplies(replies) => { + *total_messages += 1; + messages.push(build_message(*total_messages, in_reply_to, thread_num)); + let in_reply_to = Some(*total_messages); + for reply in replies { + build_messages(reply, messages, total_messages, in_reply_to, thread_num); + } + } + ThreadTest::Root(items) => { + for (thread_num, item) in items.iter().enumerate() { + let count_start = *total_messages; + build_messages(item, messages, total_messages, None, thread_num); + messages_per_thread.push(*total_messages - count_start); + } + } + } + messages_per_thread +} + +pub fn build_thread_test_messages() -> Vec { + let mut messages = Vec::new(); + let mut total_messages = 0; + build_messages(&test_3(), &mut messages, &mut total_messages, None, 0); + messages +} + +pub enum ThreadTest { + Message, + MessageWithReplies(Vec), + Root(Vec), +} + +fn test_1() -> ThreadTest { + ThreadTest::Root(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]) +} + +fn test_2() -> ThreadTest { + ThreadTest::Root(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies( + vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ], + )]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ]), + ]), + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ])]), + ]), + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]), + ]) +} + +fn test_3() -> ThreadTest { + ThreadTest::Root(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies( + vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ])], + )]), + ThreadTest::Message, + ThreadTest::Message, + ])]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ]), + ]), + ]), + ]), + ThreadTest::Message, + ThreadTest::Message, + ])]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ])]), + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]), + ThreadTest::Message, + ThreadTest::Message, + ])]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies( + vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ], + )]), + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies( + vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ], + )]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ])]), + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ThreadTest::Message]), + ThreadTest::Message, + ]), + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ]), + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ + ThreadTest::Message, + ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies( + vec![ThreadTest::Message, ThreadTest::Message], + )]), + ThreadTest::Message, + ]), + ThreadTest::Message, + ]), + ]), + ]), + ]), + ]) +} diff --git a/tests/src/jmap/mail/vacation_response.rs b/tests/src/jmap/mail/vacation_response.rs new file mode 100644 index 0000000..0eb0650 --- /dev/null +++ b/tests/src/jmap/mail/vacation_response.rs @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::mail::submission::{ + MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server, + }, + utils::{dns::DnsCache, server::TestServer, smtp::SmtpConnection}, +}; +use chrono::{TimeDelta, Utc}; +use std::time::Instant; + +pub async fn test(test: &TestServer) { + println!("Running Vacation Response tests..."); + + // Create test account + let server = test.server.clone(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + + // Start mock SMTP server + let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server(); + server.ipv4_add( + "localhost", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + std::time::Duration::from_secs(10), + ); + + // Let people know that we'll be down in Kokomo + client + .vacation_response_enable( + "Off the Florida Keys there's a place called Kokomo", + "That's where you wanna go to get away from it all".into(), + "That's where you wanna go to get away from it all".into(), + ) + .await + .unwrap(); + + // Connect to LMTP service + let mut lmtp = SmtpConnection::connect().await; + + // Send a message + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + // Await vacation response + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("", [""], "@Kokomo"), + ) + .await; + + // Further messages from the same recipient should not + // trigger a vacation response + lmtp.ingest( + "bill@remote.org", + &["jdoe@example.com"], + concat!( + "From: bill@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: TPS Report -- friendly reminder\r\n", + "\r\n", + "Listen, are you gonna have those TPS reports for us this afternoon?", + ), + ) + .await; + + expect_nothing(&mut smtp_rx).await; + + // Messages from MAILER-DAEMON should not + // trigger a vacation response + lmtp.ingest( + "MAILER-DAEMON@remote.org", + &["jdoe@example.com"], + concat!( + "From: MAILER-DAEMON@example.com\r\n", + "To: jdoe@example.com\r\n", + "Subject: Delivery Failure\r\n", + "\r\n", + "I tried so hard and got so far but in the end it wasn't delivered.", + ), + ) + .await; + + expect_nothing(&mut smtp_rx).await; + + // Vacation responses should honor the configured date ranges + client + .vacation_response_set_dates( + (Utc::now() + TimeDelta::try_days(1).unwrap_or_default()) + .timestamp() + .into(), + None, + ) + .await + .unwrap(); + lmtp.ingest( + "jane_smith@remote.org", + &["jdoe@example.com"], + concat!( + "From: jane_smith@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: When were you going on holidays?\r\n", + "\r\n", + "I'm asking because Bill really wants those TPS reports.", + ), + ) + .await; + + expect_nothing(&mut smtp_rx).await; + + client + .vacation_response_set_dates( + (Utc::now() - TimeDelta::try_days(1).unwrap_or_default()) + .timestamp() + .into(), + None, + ) + .await + .unwrap(); + smtp_settings.lock().do_stop = true; + lmtp.ingest( + "jane_smith@remote.org", + &["jdoe@example.com"], + concat!( + "From: jane_smith@remote.org\r\n", + "To: jdoe@example.com\r\n", + "Subject: When were you going on holidays?\r\n", + "\r\n", + "I'm asking because Bill really wants those TPS reports.", + ), + ) + .await; + lmtp.quit().await; + + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("", [""], "@Kokomo"), + ) + .await; + + // Remove test data + client.vacation_response_disable().await.unwrap(); + client.sieve_script_deactivate().await.unwrap(); + let mut request = client.build(); + request.query_sieve_script(); + for id in request.send_query_sieve_script().await.unwrap().take_ids() { + client.sieve_script_destroy(&id).await.unwrap(); + } + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs new file mode 100644 index 0000000..4dd9cdc --- /dev/null +++ b/tests/src/jmap/mod.rs @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use registry::{ + schema::{ + enums::{MtaProtocol, Permission}, + properties::Property, + structs::{ + CalendarAlarm, Expression, ExpressionMatch, Imap, Jmap, MtaExtensions, + MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageAuth, Sharing, + }, + }, + types::list::List, +}; + +pub mod calendar; +pub mod compliance; +pub mod contacts; +pub mod core; +pub mod files; +pub mod mail; +pub mod principal; + +#[tokio::test(flavor = "multi_thread")] +pub async fn jmap_tests() { + let mut test = TestServerBuilder::new("jmap_tests") + .await + .with_default_listeners() + .await + .build() + .await; + + // Create admin account + let admin = test.create_admin_account("admin@example.com").await; + + // Create test users + for (name, secret, description, aliases) in [ + ( + "jdoe@example.com", + "12345 + extra safety", + "John Doe", + &["john.doe@example.com"][..], + ), + ( + "jane.smith@example.com", + "abcde + extra safety", + "Jane Smith", + &["jane@example.com"], + ), + ( + "bill@example.com", + "098765 + extra safety", + "Bill Foobar", + &["bill.foobar@example.com"], + ), + ( + "robert@example.com", + "aabbcc + extra safety", + "Robert Foobar", + &[][..], + ), + ] { + let account = admin + .create_user_account( + name, + secret, + description, + aliases, + vec![Permission::UnlimitedRequests, Permission::UnlimitedUploads], + ) + .await; + test.insert_account(account); + } + + // Create test group + test.insert_account( + admin + .create_group_account("sales@example.com", "Sales Group", &[]) + .await, + ); + + // Add test settings + admin + .registry_create_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await; + admin + .registry_update_setting( + Jmap { + set_max_objects: 100_000, + get_max_results: 100_000, + event_source_throttle: 500u64.into(), + push_throttle: 500u64.into(), + websocket_throttle: 500u64.into(), + push_attempt_wait: 500u64.into(), + ..Default::default() + }, + &[ + Property::SetMaxObjects, + Property::GetMaxResults, + Property::EventSourceThrottle, + Property::PushThrottle, + Property::WebsocketThrottle, + Property::PushAttemptWait, + ], + ) + .await; + admin + .registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(CalendarAlarm { + min_trigger_interval: 1000u64.into(), + ..Default::default() + }) + .await; + admin + .registry_create_object(Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + route: Expression { + match_: List::from_iter([ + ExpressionMatch { + if_: "rcpt_domain == 'example.com'".into(), + then: "'local'".into(), + }, + ExpressionMatch { + if_: concat!( + "contains(['remote.org', 'foobar.com', ", + "'test.com', 'other_domain.com'], rcpt_domain)" + ) + .into(), + then: "'mock-smtp'".into(), + }, + ]), + else_: "'mx'".to_string(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaRoute::Relay(MtaRouteRelay { + address: "127.0.0.1".into(), + port: 9999, + allow_invalid_certs: true, + implicit_tls: false, + name: "mock-smtp".into(), + protocol: MtaProtocol::Smtp, + ..Default::default() + })) + .await; + admin + .registry_create_object(MtaExtensions { + future_release: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".into(), + then: "99999999d".into(), + }]), + else_: "false".to_string(), + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + + test.insert_account(admin); + + mail::get::test(&test).await; + mail::set::test(&test).await; + mail::parse::test(&test).await; + mail::query::test(&test).await; + mail::search_snippet::test(&test).await; + mail::changes::test(&test).await; + mail::query_changes::test(&test).await; + mail::copy::test(&test).await; + mail::thread_get::test(&test).await; + mail::thread_merge::test(&test).await; + mail::mailbox::test(&test).await; + mail::acl::test(&test).await; + mail::sieve_script::test(&test).await; + mail::vacation_response::test(&test).await; + mail::submission::test(&test).await; + + core::event_source::test(&test).await; + core::websocket::test(&test).await; + core::push_subscription::test(&test).await; + core::blob::test(&test).await; + + contacts::addressbook::test(&test).await; + contacts::contact::test(&test).await; + contacts::acl::test(&test).await; + + files::node::test(&test).await; + files::acl::test(&test).await; + + calendar::calendars::test(&test).await; + calendar::event::test(&test).await; + calendar::instance::test(&test).await; + calendar::notification::test(&test).await; + calendar::alarm::test(&test).await; + + calendar::identity::test(&test).await; + calendar::acl::test(&test).await; + + principal::get::test(&test).await; + principal::availability::test(&test).await; + + compliance::test(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} + +pub fn find_values(string: &str, name: &str) -> Vec { + let mut last_pos = 0; + let mut values = Vec::new(); + + while let Some(pos) = string[last_pos..].find(name) { + let mut value = string[last_pos + pos + name.len()..] + .split('"') + .nth(1) + .unwrap(); + if value.ends_with('\\') { + value = &value[..value.len() - 1]; + } + values.push(value.to_string()); + last_pos += pos + name.len(); + } + + values +} + +pub fn replace_values(mut string: String, find: &[String], replace: &[String]) -> String { + for (find, replace) in find.iter().zip(replace.iter()) { + string = string.replace(find, replace); + } + string +} + +pub fn replace_boundaries(string: String) -> String { + let values = find_values(&string, "boundary="); + if !values.is_empty() { + replace_values( + string, + &values, + &(0..values.len()) + .map(|i| format!("boundary_{}", i)) + .collect::>(), + ) + } else { + string + } +} + +pub fn replace_blob_ids(string: String) -> String { + let values = find_values(&string, "blobId\":"); + if !values.is_empty() { + replace_values( + string, + &values, + &(0..values.len()) + .map(|i| format!("blob_{}", i)) + .collect::>(), + ) + } else { + string + } +} diff --git a/tests/src/jmap/principal/availability.rs b/tests/src/jmap/principal/availability.rs new file mode 100644 index 0000000..ec24cae --- /dev/null +++ b/tests/src/jmap/principal/availability.rs @@ -0,0 +1,266 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::calendar::event::*, + utils::{ + jmap::{IntoJmapSet, JmapUtils}, + server::TestServer, + }, +}; +use calcard::jscalendar::JSCalendarProperty; +use jmap_proto::request::method::MethodObject; +use serde_json::json; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Principal Availability tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + + // Create test calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test Calendar", + "includeInAvailability": "all" + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let calendar1_id = response.created(0).id().to_string(); + + // Create test events + let event_1 = test_jscalendar_1().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let event_2 = test_jscalendar_2().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let event_3 = test_jscalendar_3() + .with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ) + .with_property( + JSCalendarProperty::::Participants, + json!({ + "3f5bc8c0-c722-5345-b7d9-5a899db08a30": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "attendee": true, + "chair": true + }, + "participationStatus": "accepted" + } + }), + ); + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [event_1, event_2, event_3], + Vec::<(&str, &str)>::new(), + ) + .await; + let _event_1_id = response.created(0).id().to_string(); + let _event_2_id = response.created(1).id().to_string(); + let event_3_id = response.created(2).id().to_string(); + + // Jane should not have access to John's availability + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "accountId": &jane_id, + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Grant Jane free/busy access + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "shareWith": { + &jane_id : { + "mayReadFreeBusy": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should see John's availability now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "accountId": &jane_id, + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([ + { + "utcStart": "2006-01-02T15:00:00Z", + "utcEnd": "2006-01-02T16:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-02T17:00:00Z", + "utcEnd": "2006-01-02T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-03T17:00:00Z", + "utcEnd": "2006-01-03T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-04T15:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-04T19:00:00Z", + "utcEnd": "2006-01-04T20:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-05T17:00:00Z", + "utcEnd": "2006-01-05T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-06T19:00:00Z", + "utcEnd": "2006-01-06T20:00:00Z", + "busyStatus": "confirmed", + "event": null + } + ])); + + // Update availability to none + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "includeInAvailability": "none" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should not see any events now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "accountId": &jane_id, + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Update availability to attending + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "includeInAvailability": "attending" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should only see events where John is attending + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "accountId": &jane_id, + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([ + { + "utcStart": "2006-01-04T15:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z", + "busyStatus": "confirmed", + "event": null + } + ])); + + // Update attending event to not attending + john.jmap_update( + MethodObject::CalendarEvent, + [( + &event_3_id, + json!({ + "participants/3f5bc8c0-c722-5345-b7d9-5a899db08a30/participationStatus": "declined" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&event_3_id); + + // Jane should not see any events now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "accountId": &jane_id, + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Cleanup + john.destroy_all_calendars().await; + test.assert_is_empty().await; +} diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs new file mode 100644 index 0000000..a739c60 --- /dev/null +++ b/tests/src/jmap/principal/get.rs @@ -0,0 +1,471 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::{object::principal::PrincipalProperty, request::method::MethodObject}; +use serde_json::json; + +use crate::utils::{jmap::JmapUtils, server::TestServer}; + +pub async fn test(test: &TestServer) { + println!("Running Principal get/query tests..."); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); + let sales = test.account("sales@example.com"); + + let john_id = john.id_string(); + let jane_id = jane.id_string(); + let bill_id = bill.id_string(); + let sales_id = sales.id_string(); + + // Validate session object capabilities + let response = john.jmap_session_object().await.into_inner(); + let application_server_key = + response["capabilities"]["urn:ietf:params:jmap:webpush-vapid"]["applicationServerKey"] + .clone(); + response.assert_is_equal(json!({ + "capabilities": { + "urn:ietf:params:jmap:core": { + "maxSizeUpload": 50000000, + "maxConcurrentUpload": 4, + "maxSizeRequest": 10000000, + "maxConcurrentRequests": 4, + "maxCallsInRequest": 16, + "maxObjectsInGet": 100000, + "maxObjectsInSet": 100000, + "collationAlgorithms": [ + "i;ascii-numeric", + "i;ascii-casemap", + "i;unicode-casemap" + ] + }, + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:calendars:parse": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:contacts:parse": {}, + "urn:ietf:params:jmap:emailpush": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:availability": {}, + "urn:ietf:params:jmap:submission": {}, + "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:sieve": { + "implementation": "Stalwart v1.0.0" + }, + "urn:ietf:params:jmap:blob": {}, + "urn:ietf:params:jmap:quota": {}, + "urn:ietf:params:jmap:webpush-vapid": { + "applicationServerKey": application_server_key + }, + "urn:ietf:params:jmap:websocket": { + "url": "wss://127.0.0.1:8899/jmap/ws", + "supportsPush": true + } + }, + "accounts": { + john_id: { + "name": "jdoe@example.com", + "isPersonal": true, + "isReadOnly": false, + "accountCapabilities": { + "urn:ietf:params:jmap:mail": { + "maxMailboxesPerEmail": null, + "maxMailboxDepth": 10, + "maxSizeMailboxName": 255, + "maxSizeAttachmentsPerEmail": 50000000, + "emailQuerySortOptions": [ + "receivedAt", + "size", + "from", + "to", + "subject", + "sentAt", + "hasKeyword", + "allInThreadHaveKeyword", + "someInThreadHaveKeyword" + ], + "mayCreateTopLevelMailbox": true + }, + "urn:ietf:params:jmap:submission": { + "maxDelayedSend": 2592000, + "submissionExtensions": { + "FUTURERELEASE": [], + "SIZE": [], + "DSN": [], + "DELIVERYBY": [], + "MT-PRIORITY": [ + "MIXER" + ], + "REQUIRETLS": [] + } + }, + "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:contacts": { + "maxAddressBooksPerCard": null, + "mayCreateAddressBook": true + }, + "urn:ietf:params:jmap:contacts:parse": {}, + "urn:ietf:params:jmap:emailpush": {}, + "urn:ietf:params:jmap:calendars": { + "maxCalendarsPerEvent": null, + "minDateTime": "0001-01-01T00:00:00Z", + "maxDateTime": "9999-12-31T23:59:59Z", + "maxExpandedQueryDuration": "P52W1D", + "maxParticipantsPerEvent": 20, + "mayCreateCalendar": true + }, + "urn:ietf:params:jmap:calendars:parse": {}, + "urn:ietf:params:jmap:websocket": {}, + "urn:ietf:params:jmap:sieve": { + "maxSizeScriptName": 512, + "maxSizeScript": 102400, + "maxNumberScripts": 100, + "maxNumberRedirects": 1, + "sieveExtensions": [ + "body", + "comparator-elbonia", + "comparator-i;ascii-casemap", + "comparator-i;ascii-numeric", + "comparator-i;octet", + "convert", + "copy", + "date", + "duplicate", + "editheader", + "enclose", + "encoded-character", + "enotify", + "envelope", + "envelope-deliverby", + "envelope-dsn", + "environment", + "ereject", + "extlists", + "extracttext", + "fcc", + "fileinto", + "foreverypart", + "ihave", + "imap4flags", + "imapsieve", + "include", + "index", + "mailbox", + "mailboxid", + "mboxmetadata", + "mime", + "redirect-deliverby", + "redirect-dsn", + "regex", + "reject", + "relational", + "replace", + "servermetadata", + "spamtest", + "spamtestplus", + "special-use", + "subaddress", + "vacation", + "vacation-seconds", + "variables", + "virustest" + ], + "notificationMethods": [ + "mailto" + ], + "externalLists": null + }, + "urn:ietf:params:jmap:blob": { + "maxSizeBlobSet": 7499488, + "maxDataSources": 16, + "supportedTypeNames": [ + "Email", + "Thread", + "SieveScript" + ], + "supportedDigestAlgorithms": [ + "sha", + "sha-256", + "sha-512" + ] + }, + "urn:ietf:params:jmap:quota": {}, + "urn:ietf:params:jmap:principals": { + "currentUserPrincipalId": john_id + }, + "urn:ietf:params:jmap:principals:availability": { + "maxAvailabilityDuration": "P52W1D", + }, + "urn:ietf:params:jmap:filenode": { + "maxFileNodeDepth": null, + "maxSizeFileNodeName": 255, + "forbiddenNameChars": "/<>:\"\\|?*", + "forbiddenNodeNames": [ + ".", + "..", + "CON", + "PRN", + "AUX", + "NUL", + "COM0", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT0", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9" + ], + "fileNodeQuerySortOptions": [ + "name", + "size", + "nodeType" + ], + "mayCreateTopLevelFileNode": true, + "caseInsensitiveNames": false, + "webTrashUrl": null, + "webUrlTemplate": null, + "webWriteUrlTemplate": null + }, + "urn:ietf:params:jmap:mail:share": {}, + "urn:stalwart:jmap": {} + } + } + }, + "primaryAccounts": { + "urn:ietf:params:jmap:mail": john_id, + "urn:ietf:params:jmap:submission": john_id, + "urn:ietf:params:jmap:vacationresponse": john_id, + "urn:ietf:params:jmap:contacts": john_id, + "urn:ietf:params:jmap:contacts:parse": john_id, + "urn:ietf:params:jmap:emailpush": john_id, + "urn:ietf:params:jmap:calendars": john_id, + "urn:ietf:params:jmap:calendars:parse": john_id, + "urn:ietf:params:jmap:websocket": john_id, + "urn:ietf:params:jmap:sieve": john_id, + "urn:ietf:params:jmap:blob": john_id, + "urn:ietf:params:jmap:quota": john_id, + "urn:ietf:params:jmap:principals": john_id, + "urn:ietf:params:jmap:principals:availability": john_id, + "urn:ietf:params:jmap:filenode": john_id, + "urn:ietf:params:jmap:mail:share": john_id, + "urn:stalwart:jmap": john_id + }, + "username": "jdoe@example.com", + "apiUrl": "https://127.0.0.1:8899/jmap/", + "downloadUrl": + "https://127.0.0.1:8899/jmap/download/{accountId}/{blobId}/{name}?accept={type}", + "uploadUrl": + "https://127.0.0.1:8899/jmap/upload/{accountId}/", + "eventSourceUrl": + "https://127.0.0.1:8899/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", + "state": response.text_field("state") + })); + + // Obtain principal ids for Jane, Bill and the sales group + let response = john + .jmap_query( + MethodObject::Principal, + [("email", "john.doe@example.com")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [john_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("name", "bill@example.com")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [bill_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("accountIds", [jane_id])], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [jane_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("text", "sales group")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [sales_id]); + + // Validate principal contents + let response = john + .jmap_get( + MethodObject::Principal, + [ + PrincipalProperty::Id, + PrincipalProperty::Type, + PrincipalProperty::Email, + PrincipalProperty::Description, + PrincipalProperty::Name, + PrincipalProperty::Timezone, + PrincipalProperty::Capabilities, + PrincipalProperty::Accounts, + ], + [john_id, jane_id, bill_id, sales_id], + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 4); + + list[0].assert_is_equal(json!({ + "id": john_id, + "type": "individual", + "email": "jdoe@example.com", + "description": "John Doe", + "name": "jdoe@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + john_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": john_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:jdoe@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": john_id, + "principalId": john_id + } + } + } + })); + list[1].assert_is_equal(json!({ + "id": jane_id, + "type": "individual", + "email": "jane.smith@example.com", + "description": "Jane Smith", + "name": "jane.smith@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + jane_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": jane_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:jane.smith@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": jane_id, + "principalId": jane_id + } + } + } + })); + list[2].assert_is_equal(json!({ + "id": bill_id, + "type": "individual", + "email": "bill@example.com", + "description": "Bill Foobar", + "name": "bill@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + bill_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": bill_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:bill@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": bill_id, + "principalId": bill_id + } + } + } + })); + list[3].assert_is_equal(json!({ + "id": sales_id, + "type": "group", + "email": "sales@example.com", + "description": "Sales Group", + "name": "sales@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + sales_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": sales_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:sales@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": sales_id, + "principalId": sales_id + } + } + } + })); +} diff --git a/tests/src/jmap/principal/mod.rs b/tests/src/jmap/principal/mod.rs new file mode 100644 index 0000000..6077706 --- /dev/null +++ b/tests/src/jmap/principal/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod availability; +pub mod get; diff --git a/tests/src/lib.rs b/tests/src/lib.rs new file mode 100644 index 0000000..994523f --- /dev/null +++ b/tests/src/lib.rs @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[cfg(test)] +use ::store::registry::bootstrap::Bootstrap; +#[cfg(not(any(target_env = "msvc", target_os = "freebsd")))] +use tikv_jemallocator::Jemalloc; + +#[cfg(not(any(target_env = "msvc", target_os = "freebsd")))] +#[global_allocator] +static GLOBAL: Jemalloc = Jemalloc; + +#[cfg(test)] +pub mod automation; +#[cfg(test)] +pub mod cluster; +#[cfg(test)] +pub mod directory; +#[cfg(test)] +pub mod imap; +#[cfg(test)] +pub mod jmap; +#[cfg(test)] +pub mod smtp; +#[cfg(test)] +pub mod store; +#[cfg(test)] +pub mod system; +#[cfg(test)] +pub mod telemetry; +#[cfg(test)] +pub mod utils; +#[cfg(test)] +pub mod webdav; + +#[cfg(test)] +pub trait AssertConfig { + fn assert_no_errors(self) -> Self; + fn assert_no_warnings(self) -> Self; +} + +#[cfg(test)] +impl AssertConfig for Bootstrap { + fn assert_no_errors(self) -> Self { + if !self.errors.is_empty() { + panic!("Errors: {:#?}", self.errors); + } + self + } + + fn assert_no_warnings(self) -> Self { + if !self.warnings.is_empty() { + panic!("Warnings: {:#?}", self.warnings); + } + self + } +} diff --git a/tests/src/scim/conformance.rs b/tests/src/scim/conformance.rs new file mode 100644 index 0000000..efb372a --- /dev/null +++ b/tests/src/scim/conformance.rs @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + scim::{SCIM_DOMAIN, ScimTest}, + utils::containers, +}; +use ahash::AHashMap; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::Value; + +const DRIVER: &str = include_str!("../../docker/scim/driver.py"); + +const URL: &str = "https://host.docker.internal:8899/scim/v2"; +const NON_MAILBOX_USER_NAME: &str = "is not a valid email address"; +const STRICT_TAGS: [&str; 5] = [ + "discovery", + "service-provider-config", + "resource-types", + "schemas", + "misc", +]; + +pub fn is_enabled() -> bool { + std::env::var("SCIM_CONFORMANCE").is_ok_and(|value| value == "1") +} + +pub async fn test(scim: &ScimTest) { + println!("Running SCIM third party conformance tests..."); + containers::ensure_scim_tester().await; + + the_lifecycle_survives_a_third_party_client(scim).await; + real_client_payloads_are_accepted(scim).await; + the_conformance_checker_reports_no_failures(scim).await; +} + +async fn real_client_payloads_are_accepted(scim: &ScimTest) { + let report = run(scim, "clients").await; + let steps = report["steps"] + .as_array() + .unwrap_or_else(|| panic!("Missing steps in {report}")); + + assert!(!steps.is_empty(), "The driver ran no steps: {report}"); + for step in steps { + if step["ok"] != Value::Bool(true) { + panic!( + "The {} payload was refused:\n{}", + step["step"].as_str().unwrap_or_default(), + step["detail"].as_str().unwrap_or_default() + ); + } + } + + println!(" replayed {} real client payloads", steps.len()); +} + +async fn the_lifecycle_survives_a_third_party_client(scim: &ScimTest) { + let report = run(scim, "lifecycle").await; + let steps = report["steps"] + .as_array() + .unwrap_or_else(|| panic!("Missing steps in {report}")); + + assert!(!steps.is_empty(), "The driver ran no steps: {report}"); + for step in steps { + if step["ok"] != Value::Bool(true) { + panic!( + "scim2-client step '{}' failed:\n{}", + step["step"].as_str().unwrap_or_default(), + step["detail"].as_str().unwrap_or_default() + ); + } + } + + println!(" scim2-client completed {} lifecycle steps", steps.len()); +} + +async fn the_conformance_checker_reports_no_failures(scim: &ScimTest) { + let report = run(scim, "conformance").await; + let checks = report["checks"] + .as_array() + .unwrap_or_else(|| panic!("Missing checks in {report}")); + + assert!(!checks.is_empty(), "The checker ran no checks: {report}"); + + let mut totals: AHashMap = AHashMap::new(); + let mut expected = 0; + let mut failures = Vec::new(); + let mut strict_checks = 0; + + for check in checks { + let status = check["status"].as_str().unwrap_or_default(); + let reason = check["reason"].as_str().unwrap_or_default(); + let title = check["title"].as_str().unwrap_or_default(); + let tags = check["tags"] + .as_array() + .map(|tags| { + tags.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + + *totals.entry(status.to_string()).or_default() += 1; + if tags.iter().any(|tag| STRICT_TAGS.contains(&tag.as_str())) { + strict_checks += 1; + } + + if !matches!(status, "ERROR" | "CRITICAL" | "DEVIATION") { + continue; + } + + if status == "ERROR" && reason.contains(NON_MAILBOX_USER_NAME) { + expected += 1; + continue; + } + + failures.push(format!( + " [{status}] {title} ({}): {reason}", + tags.join(",") + )); + } + + let mut summary = totals.into_iter().collect::>(); + summary.sort(); + println!( + " scim2-tester ran {} checks ({strict_checks} of them on the discovery endpoints): {}", + checks.len(), + summary + .iter() + .map(|(status, count)| format!("{status}={count}")) + .collect::>() + .join(" ") + ); + println!(" {expected} checks failed because the generated userName is not a mailbox address"); + + assert!( + failures.is_empty(), + "scim2-tester reported unexplained failures:\n{}", + failures.join("\n") + ); + assert!( + strict_checks > 0, + "The checker ran no discovery checks: {report}" + ); +} + +async fn run(scim: &ScimTest, mode: &str) -> Value { + let command = format!( + "echo '{}' | base64 -d > /tmp/driver.py && exec python /tmp/driver.py \ + --url {URL} --token {} --domain {SCIM_DOMAIN} --mode {mode}", + STANDARD.encode(DRIVER.as_bytes()), + scim.token, + ); + let (stdout, stderr) = containers::scim_tester_exec(&["sh", "-c", &command]).await; + + let report = stdout + .lines() + .rev() + .find(|line| line.starts_with('{')) + .unwrap_or_else(|| { + panic!("The SCIM driver produced no report.\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + + let report = serde_json::from_str::(report).unwrap_or_else(|err| { + panic!("The SCIM driver report is not valid JSON: {err}\n{stdout}\n{stderr}") + }); + + if let Some(error) = report.get("error").and_then(Value::as_str) { + panic!("The SCIM driver failed: {error}"); + } + + report +} diff --git a/tests/src/scim/oidc.rs b/tests/src/scim/oidc.rs new file mode 100644 index 0000000..62ea911 --- /dev/null +++ b/tests/src/scim/oidc.rs @@ -0,0 +1,332 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + scim::{ScimTest, patch_body, query}, + utils::{containers, server::TestServer}, +}; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{self, Action, OidcDirectory, UserAccount}, + }, + types::map::Map, +}; +use scim_proto::SCHEMA_USER; +use serde_json::json; +use std::str::FromStr; +use types::id::Id; + +pub const OIDC_DOMAIN: &str = "example.org"; +const KEYCLOAK_PASSWORD: &str = "this is an OIDC password"; +const JOHN: &str = "john.doe@example.org"; +const BILL: &str = "bill.foobar@example.org"; +const SCIM_DISPLAY_NAME: &str = "Provisioned By SCIM"; +const SCIM_GROUP: &str = "Provisioned Group"; + +pub async fn test(test: &TestServer, scim: &ScimTest) { + println!("Running SCIM OIDC interaction tests..."); + containers::ensure_keycloak().await; + + let domain_id = bind_directory(test).await; + + just_in_time_account_creation_is_disabled(test, scim).await; + scim_managed_attributes_survive_a_login(test, scim).await; + clearing_the_flag_restores_just_in_time_provisioning(test, scim, domain_id).await; + + cleanup(test, scim, domain_id).await; +} + +async fn bind_directory(test: &TestServer) -> Id { + let admin = test.account("admin"); + let directory_id = admin + .registry_create_object(structs::Directory::Oidc(OidcDirectory { + description: "SCIM test OIDC directory".to_string(), + issuer_url: "http://localhost:9080/realms/stalwart".to_string(), + claim_username: "email".to_string(), + claim_name: Some("name".to_string()), + claim_groups: Some("groups".to_string()), + username_domain: None, + require_audience: Some("stalwart".to_string()), + require_scopes: Map::new(vec![ + "email".to_string(), + "profile".to_string(), + "openid".to_string(), + ]), + member_tenant_id: None, + })) + .await; + + let domain_id = admin.find_or_create_domain(OIDC_DOMAIN).await; + admin + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::DirectoryId: directory_id.to_string(), + Property::AllowScimProvisioning: true, + }), + ) + .await; + admin.reload_settings().await; + admin.registry_create_object(Action::InvalidateCaches).await; + + domain_id +} + +async fn just_in_time_account_creation_is_disabled(test: &TestServer, scim: &ScimTest) { + let token = keycloak_token(BILL).await; + + assert_eq!( + bearer_session_status(&token).await, + 401, + "A domain provisioned through SCIM must not create accounts on login" + ); + + let response = scim + .client + .get(&query("/Users", &format!("userName eq \"{BILL}\""))) + .await; + response.assert_status(200); + assert_eq!( + response.total_results(), + 0, + "The login created an account: {}", + response.body + ); + assert!(!account_exists(test, BILL).await); + assert!(!account_exists(test, "corporate@example.org").await); +} + +async fn scim_managed_attributes_survive_a_login(test: &TestServer, scim: &ScimTest) { + let user_id = scim + .client + .post( + "/Users", + json!({ + "schemas": [SCHEMA_USER], + "userName": JOHN, + "displayName": SCIM_DISPLAY_NAME, + "externalId": "OIDC-1", + }), + ) + .await + .assert_status(201) + .id(); + let group_id = scim.create_group(SCIM_GROUP).await; + + scim.client + .patch( + &format!("/Groups/{group_id}"), + patch_body(json!([{"op": "add", "path": "members", "value": [{"value": user_id}]}])), + ) + .await + .assert_status(200); + + let before = scim.client.get(&format!("/Users/{user_id}")).await; + before.assert_status(200); + + let token = keycloak_token(JOHN).await; + assert_eq!( + bearer_session_status(&token).await, + 200, + "A SCIM provisioned account must still authenticate over OIDC" + ); + + let after = scim.client.get(&format!("/Users/{user_id}")).await; + after.assert_status(200); + assert_eq!( + after.json["displayName"], + json!(SCIM_DISPLAY_NAME), + "The OIDC name claim overwrote the SCIM displayName" + ); + assert_eq!( + after.json["groups"], before.json["groups"], + "The OIDC groups claim overwrote the SCIM membership" + ); + assert_eq!(after.json["groups"][0]["display"], json!(SCIM_GROUP)); + assert_eq!(after.etag(), before.etag()); + + assert!( + !account_exists(test, "sales@example.org").await, + "The OIDC groups claim created a group account" + ); + + assert_eq!( + bearer_session_status(&keycloak_token(JOHN).await).await, + 200, + "A second login must be equally inert" + ); + let repeated = scim.client.get(&format!("/Users/{user_id}")).await; + assert_eq!(repeated.etag(), before.etag()); +} + +async fn clearing_the_flag_restores_just_in_time_provisioning( + test: &TestServer, + scim: &ScimTest, + domain_id: Id, +) { + let john_id = scim + .client + .get(&query("/Users", &format!("userName eq \"{JOHN}\""))) + .await + .resource_ids() + .remove(0); + + let admin = test.account("admin"); + admin + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ Property::AllowScimProvisioning: false }), + ) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; + + assert_eq!( + bearer_session_status(&keycloak_token(BILL).await).await, + 200, + "Just-in-time provisioning must be unchanged when the flag is off" + ); + + let bill = user_account( + test, + account_id(test, BILL).await.expect("Bill was not created"), + ) + .await; + assert_eq!(bill.description.as_deref(), Some("Bill Foobar")); + let corporate = account_id(test, "corporate@example.org") + .await + .expect("The groups claim did not create a group account"); + assert!(bill.member_group_ids.contains(&corporate)); + + assert_eq!( + bearer_session_status(&keycloak_token(JOHN).await).await, + 200 + ); + + let john = user_account(test, Id::from_str(&john_id).unwrap()).await; + assert_eq!( + john.description.as_deref(), + Some("John Doe"), + "Without the flag the name claim must win, which is what the flag exists to prevent" + ); + let sales = account_id(test, "sales@example.org") + .await + .expect("The groups claim did not create a group account"); + assert_eq!( + john.member_group_ids.iter().copied().collect::>(), + vec![sales], + "Without the flag the groups claim must replace the SCIM membership" + ); + + admin + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ Property::AllowScimProvisioning: true }), + ) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; +} + +async fn user_account(test: &TestServer, id: Id) -> UserAccount { + match test + .server + .registry() + .object::(id) + .await + .unwrap() + .expect("The account no longer exists") + { + structs::Account::User(account) => account, + other => panic!("Expected a user account but got {other:?}"), + } +} + +async fn cleanup(test: &TestServer, scim: &ScimTest, domain_id: Id) { + let admin = test.account("admin"); + + for user_name in [JOHN, BILL] { + let ids = scim + .client + .get(&query("/Users", &format!("userName eq \"{user_name}\""))) + .await + .resource_ids(); + for id in ids { + scim.client.delete(&format!("/Users/{id}")).await; + } + } + + let group_ids = scim + .client + .get(&query( + "/Groups", + &format!("displayName eq \"{SCIM_GROUP}\""), + )) + .await + .resource_ids(); + for id in group_ids { + scim.client.delete(&format!("/Groups/{id}")).await; + } + + for address in ["sales@example.org", "corporate@example.org"] { + if let Some(id) = account_id(test, address).await { + admin.registry_destroy(ObjectType::Account, [id]).await; + } + } + + admin + .registry_update_object( + ObjectType::Domain, + domain_id, + json!({ + Property::DirectoryId: Option::::None, + Property::AllowScimProvisioning: false, + }), + ) + .await; + admin.reload_settings().await; + admin.registry_create_object(Action::InvalidateCaches).await; +} + +async fn account_exists(test: &TestServer, address: &str) -> bool { + account_id(test, address).await.is_some() +} + +async fn account_id(test: &TestServer, address: &str) -> Option { + test.server + .account_id_from_email(address, false) + .await + .unwrap_or(None) + .map(Id::from) +} + +async fn keycloak_token(username: &str) -> String { + let response = reqwest::Client::new() + .post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token") + .form(&[ + ("grant_type", "password"), + ("client_id", "stalwart"), + ("client_secret", "stalwart-secret"), + ("username", username), + ("password", KEYCLOAK_PASSWORD), + ("scope", "openid email profile"), + ]) + .send() + .await + .expect("Failed to request a Keycloak token"); + let body = response.text().await.expect("Failed to read the token"); + + serde_json::from_str::(&body) + .ok() + .and_then(|json| json["access_token"].as_str().map(str::to_string)) + .unwrap_or_else(|| panic!("No access_token in the Keycloak response: {body}")) +} + +async fn bearer_session_status(token: &str) -> u16 { + crate::scim::jmap_session_status(&format!("Bearer {token}")).await +} diff --git a/tests/src/scim/tenant.rs b/tests/src/scim/tenant.rs new file mode 100644 index 0000000..e5adf0b --- /dev/null +++ b/tests/src/scim/tenant.rs @@ -0,0 +1,362 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + scim::{SCIM_DOMAIN, ScimClient, ScimTest, api_key, group_body, patch_body, query, user_body}, + utils::{account::Account, server::TestServer}, +}; +use registry::{ + schema::{ + enums::{Permission, StorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + self, Action, CertificateManagement, DkimManagement, DnsManagement, Domain, + PasswordCredential, Permissions, PermissionsList, Tenant, UserAccount, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use scim_proto::{MESSAGE_BULK_REQUEST, SCHEMA_USER}; +use serde_json::json; +use types::id::Id; + +const TENANT_DOMAIN: &str = "acme.example.com"; +const TENANT_PRINCIPAL: &str = "scim-svc@acme.example.com"; +const TENANT_SECRET: &str = "these_pretzels_are_making_me_thirsty"; + +pub async fn test(test: &TestServer, scim: &ScimTest) { + println!("Running SCIM tenant isolation tests..."); + + let fixture = TenantFixture::new(test).await; + + a_tenant_client_only_sees_its_own_tenant(&fixture, scim).await; + a_tenant_client_cannot_reach_another_tenant(&fixture, scim).await; + a_tenant_client_cannot_provision_outside_its_domains(&fixture).await; + a_tenant_client_provisions_inside_its_own_domain(&fixture).await; + + fixture.cleanup(test).await; +} + +struct TenantFixture { + client: ScimClient, + tenant_id: Id, + domain_id: Id, + principal_id: Id, + resident_id: Id, +} + +impl TenantFixture { + async fn new(test: &TestServer) -> Self { + let admin = test.account("admin"); + let tenant_id = admin + .registry_create_object(Tenant { + name: "acme".to_string(), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Default::default(), + enabled_permissions: Map::new(vec![ + Permission::ScimAccess, + Permission::UnlimitedRequests, + ]), + }), + ..Default::default() + }) + .await; + + let domain_id = admin + .registry_create_object(Domain { + is_enabled: true, + name: TENANT_DOMAIN.to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + member_tenant_id: Some(tenant_id), + allow_scim_provisioning: true, + ..Default::default() + }) + .await; + + let principal_id = create_account( + admin, + "scim-svc", + domain_id, + tenant_id, + "Tenant SCIM Service Principal", + vec![ + Permission::ScimAccess, + Permission::SysAccountGet, + Permission::SysAccountCreate, + Permission::SysAccountUpdate, + Permission::SysAccountDestroy, + Permission::UnlimitedRequests, + ], + ) + .await; + let resident_id = create_account( + admin, + "resident", + domain_id, + tenant_id, + "Tenant Resident", + vec![], + ) + .await; + + admin + .registry_update_object( + ObjectType::Account, + principal_id, + json!({ Property::Quotas: { StorageQuota::MaxApiKeys.as_str(): 20 } }), + ) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; + + let mut principal = Account::new(TENANT_PRINCIPAL, TENANT_SECRET, &[], "", principal_id); + principal.http_listener_port = crate::scim::HTTP_PORT; + let token = api_key(admin, &principal, json!({ "@type": "Inherit" })).await; + + TenantFixture { + client: ScimClient::bearer(&token), + tenant_id, + domain_id, + principal_id, + resident_id, + } + } + + async fn cleanup(&self, test: &TestServer) { + let admin = test.account("admin"); + for id in [self.resident_id, self.principal_id] { + admin.registry_destroy(ObjectType::Account, [id]).await; + } + admin + .registry_destroy(ObjectType::Domain, [self.domain_id]) + .await; + admin + .registry_destroy(ObjectType::Tenant, [self.tenant_id]) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; + } +} + +async fn a_tenant_client_only_sees_its_own_tenant(fixture: &TenantFixture, scim: &ScimTest) { + let outsider = scim.create_user("tenant.outsider@scim.example.com").await; + + let listing = fixture.client.get("/Users?count=200").await; + listing.assert_status(200); + listing.assert_contains_id(&fixture.resident_id.to_string()); + listing.assert_contains_id(&fixture.principal_id.to_string()); + listing.assert_lacks_id(&outsider); + assert_eq!( + listing.total_results(), + 2, + "A tenant client must see only its own accounts: {}", + listing.body + ); + + for filter in [ + "userName eq \"tenant.outsider@scim.example.com\"", + &format!("id eq \"{outsider}\""), + "emails eq \"tenant.outsider@scim.example.com\"", + ] { + let response = fixture.client.get(&query("/Users", filter)).await; + response.assert_status(200); + assert_eq!(response.total_results(), 0, "{filter}: {}", response.body); + } + + let searched = fixture + .client + .post( + "/.search", + json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], + "count": 200, + }), + ) + .await; + searched.assert_status(200); + searched.assert_lacks_id(&outsider); + + scim.destroy(&format!("/Users/{outsider}")).await; +} + +async fn a_tenant_client_cannot_reach_another_tenant(fixture: &TenantFixture, scim: &ScimTest) { + let outsider = scim.create_user("tenant.target@scim.example.com").await; + let outsider_group = scim.create_group("Untenanted Team").await; + let path = format!("/Users/{outsider}"); + + fixture.client.get(&path).await.assert_error(404, None); + fixture + .client + .patch( + &path, + patch_body(json!([{"op": "replace", "path": "displayName", "value": "Crossed"}])), + ) + .await + .assert_error(404, None); + fixture + .client + .put(&path, user_body("tenant.target@scim.example.com")) + .await + .assert_error(404, None); + fixture.client.delete(&path).await.assert_error(404, None); + + fixture + .client + .get(&format!("/Groups/{outsider_group}")) + .await + .assert_error(404, None); + fixture + .client + .delete(&format!("/Groups/{outsider_group}")) + .await + .assert_error(404, None); + + let bulk = fixture + .client + .post( + "/Bulk", + json!({ + "schemas": [MESSAGE_BULK_REQUEST], + "Operations": [ + {"method": "DELETE", "path": path}, + {"method": "PATCH", "path": path, "data": patch_body( + json!([{"op": "replace", "path": "active", "value": false}]) + )}, + ], + }), + ) + .await; + bulk.assert_status(200); + for operation in bulk.json["Operations"].as_array().unwrap() { + assert_eq!(operation["status"], json!("404"), "{operation}"); + } + + let group = fixture + .client + .post("/Groups", group_body("Tenant Team")) + .await + .assert_status(201) + .id(); + fixture + .client + .patch( + &format!("/Groups/{group}"), + patch_body(json!([{"op": "add", "path": "members", "value": [{"value": outsider}]}])), + ) + .await + .assert_error(400, Some("invalidValue")); + fixture.client.delete(&format!("/Groups/{group}")).await; + + let survivor = scim.client.get(&path).await; + survivor.assert_status(200); + assert!( + survivor.json.get("displayName").is_none(), + "The cross tenant patch was applied: {}", + survivor.body + ); + + scim.destroy(&format!("/Groups/{outsider_group}")).await; + scim.destroy(&path).await; +} + +async fn a_tenant_client_cannot_provision_outside_its_domains(fixture: &TenantFixture) { + let response = fixture + .client + .post("/Users", user_body(&format!("intruder@{SCIM_DOMAIN}"))) + .await; + response.assert_error(404, None); + response.assert_detail_contains(SCIM_DOMAIN); + + let response = fixture + .client + .post( + "/Users", + json!({ + "schemas": [SCHEMA_USER], + "userName": format!("aliased@{TENANT_DOMAIN}"), + "emails": [{"value": format!("intruder@{SCIM_DOMAIN}")}], + }), + ) + .await; + response.assert_error(404, None); + response.assert_detail_contains(SCIM_DOMAIN); +} + +async fn a_tenant_client_provisions_inside_its_own_domain(fixture: &TenantFixture) { + let response = fixture + .client + .post("/Users", user_body(&format!("newcomer@{TENANT_DOMAIN}"))) + .await; + response.assert_status(201); + let id = response.id(); + + fixture + .client + .get(&format!("/Users/{id}")) + .await + .assert_status(200); + + let group = fixture + .client + .post("/Groups", group_body("Acme Team")) + .await + .assert_status(201) + .id(); + fixture + .client + .patch( + &format!("/Groups/{group}"), + patch_body(json!([{"op": "add", "path": "members", "value": [{"value": id}]}])), + ) + .await + .assert_status(200); + fixture + .client + .patch( + &format!("/Groups/{group}"), + patch_body(json!([{"op": "remove", "path": "members"}])), + ) + .await + .assert_status(200); + + fixture + .client + .delete(&format!("/Groups/{group}")) + .await + .assert_status(204); + fixture + .client + .delete(&format!("/Users/{id}")) + .await + .assert_status(204); +} + +async fn create_account( + admin: &Account, + name: &str, + domain_id: Id, + tenant_id: Id, + description: &str, + permissions: Vec, +) -> Id { + admin + .registry_create_object(structs::Account::User(UserAccount { + name: name.to_string(), + domain_id, + member_tenant_id: Some(tenant_id), + description: Some(description.to_string()), + credentials: List::from_iter([structs::Credential::Password(PasswordCredential { + secret: TENANT_SECRET.to_string(), + ..Default::default() + })]), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Default::default(), + enabled_permissions: Map::new(permissions), + }), + ..Default::default() + })) + .await +} diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs new file mode 100644 index 0000000..97a03d9 --- /dev/null +++ b/tests/src/smtp/inbound/antispam.rs @@ -0,0 +1,825 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + dns::DnsCache, + http_server::{HttpMessage, spawn_mock_http_server}, + server::TestServerBuilder, +}; +use ahash::AHashSet; +use common::{ + Server, + auth::{AccountCache, AccountInfo}, + config::mailstore::spamfilter::SpamFilterAction, + enterprise::llm::{ + ChatCompletionChoice, ChatCompletionRequest, ChatCompletionResponse, Message, + }, +}; +use http_proto::{JsonResponse, ToHttpResponse}; +use hyper::Method; +use mail_auth::{ + ArcOutput, DkimOutput, DkimResult, DmarcResult, DnssecStatus, IprevOutput, IprevResult, MX, + SpfOutput, SpfResult, dkim::Signature, dmarc::Policy, +}; +use mail_parser::MessageParser; +use registry::{ + schema::{ + enums::{AiModelType, TaskSpamFilterMaintenanceType}, + prelude::{ObjectType, Property}, + structs::{ + self, AiModel, MemoryLookupKey, SpamLlm, SpamLlmProperties, SpamSettings, Task, + TaskSpamFilterMaintenance, TaskStatus, + }, + }, + types::{float::Float, map::Map}, +}; +use serde_json::json; +use smtp::core::SessionAddress; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_SMTPUTF8}; +use spam_filter::{ + SpamFilterInput, + analysis::{ + classifier::SpamFilterAnalyzeClassify, date::SpamFilterAnalyzeDate, + dmarc::SpamFilterAnalyzeDmarc, domain::SpamFilterAnalyzeDomain, + ehlo::SpamFilterAnalyzeEhlo, from::SpamFilterAnalyzeFrom, + headers::SpamFilterAnalyzeHeaders, html::SpamFilterAnalyzeHtml, init::SpamFilterInit, + ip::SpamFilterAnalyzeIp, llm::SpamFilterAnalyzeLlm, messageid::SpamFilterAnalyzeMid, + mime::SpamFilterAnalyzeMime, pyzor::SpamFilterAnalyzePyzor, + received::SpamFilterAnalyzeReceived, recipient::SpamFilterAnalyzeRecipient, + replyto::SpamFilterAnalyzeReplyTo, rules::SpamFilterAnalyzeRules, + score::SpamFilterAnalyzeScore, subject::SpamFilterAnalyzeSubject, + url::SpamFilterAnalyzeUrl, + }, + modules::{ + classifier::{SpamClassifier, Token}, + html::{HtmlToken, html_to_tokens}, + }, +}; +use std::{ + fs, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; + +#[tokio::test(flavor = "multi_thread")] +async fn antispam() { + let mut test = TestServerBuilder::new("smtp_antispam_test") + .await + .with_http_listener(19048) + .await + .build() + .await; + + let admin = test.account("admin"); + admin + .registry_create_object(SpamSettings { + score_spam: Float::new(5.0), + spam_filter_rules_url: std::env::var("SPAM_RULES_URL") + .unwrap_or_else(|_| { + "file:///Users/me/code/spam-filter/spam-filter-rules.json.gz".to_string() + }) + .into(), + ..Default::default() + }) + .await; + admin + .registry_create_object(structs::SpamClassifier { + min_ham_samples: 10, + min_spam_samples: 10, + ..Default::default() + }) + .await; + let model_id = admin + .registry_create_object(AiModel { + model_type: AiModelType::Chat, + allow_invalid_certs: true, + model: "gpt-dummy".to_string(), + name: "dummy".to_string(), + url: "https://127.0.0.1:9090/v1/chat/completions".to_string(), + ..Default::default() + }) + .await; + admin + .registry_create_object(SpamLlm::Enable(SpamLlmProperties { + categories: Map::new(vec![ + "Unsolicited".to_string(), + "Commercial".to_string(), + "Harmful".to_string(), + "Legitimate".to_string(), + ]), + confidence: Map::new(vec![ + "High".to_string(), + "Medium".to_string(), + "Low".to_string(), + ]), + model_id, + prompt: "You are an AI assistant specialized in analyzing email content to detect spam" + .to_string(), + response_pos_category: 0, + response_pos_confidence: 1.into(), + response_pos_explanation: 2.into(), + separator: ",".to_string(), + ..Default::default() + })) + .await; + admin + .registry_create_object(MemoryLookupKey { + is_glob_pattern: true, + key: "spamtrap@*".into(), + namespace: "spam-traps".into(), + }) + .await; + admin + .registry_create_object(MemoryLookupKey { + is_glob_pattern: true, + key: "redirect.*".into(), + namespace: "url-redirectors".into(), + }) + .await; + admin.mta_allow_relaying().await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.reload_settings().await; + + // Fetch rules + admin + .registry_create_object(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + admin.reload_settings().await; + admin.reload_lookup_stores().await; + test.reload_core(); + let admin = test.account("admin"); + + // Add mock DNS entries + for (domain, ip) in [ + ("bank.com", "127.0.0.1"), + ("apple.com", "127.0.0.1"), + ("youtube.com", "127.0.0.1"), + ("twitter.com", "127.0.0.3"), + ("dkimtrusted.org.dwl.dnswl.org", "127.0.0.3"), + ("sh-malware.com.dbl.spamhaus.org", "127.0.1.5"), + ("surbl-abuse.com.multi.surbl.org", "127.0.0.64"), + ("uribl-grey.com.multi.uribl.com", "127.0.0.4"), + ("sem-uribl.com.uribl.spameatingmonkey.net", "127.0.0.2"), + ("sem-fresh15.com.fresh15.spameatingmonkey.net", "127.0.0.2"), + ( + "b4a64d60f67529b0b18df66ea2f292e09e43c975.ebl.msbl.org", + "127.0.0.2", + ), + ( + "a95bd658068a8315dc1864d6bb79632f47692621.ebl.msbl.org", + "127.0.1.3", + ), + ( + "ba76e47680ba70a0cbff8d6c92139683.hashbl.surbl.org", + "127.0.0.16", + ), + ( + "0ac5b387a1c6d8461a78bbf7b172a2a1.hashbl.surbl.org", + "127.0.0.64", + ), + ( + "ef6f530a68b77d782983e8712ff31fe5.hashbl.surbl.org", + "127.0.0.8", + ), + ] { + test.server.ipv4_add( + domain, + vec![ip.parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + test.server.dnsbl_add( + domain, + vec![ip.parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + } + for mx in [ + "domain.org", + "domain.co.uk", + "gmail.com", + "custom.disposable.org", + ] { + test.server.mx_add( + mx, + vec![MX { + exchanges: vec!["127.0.0.1".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(100), + ); + } + + // Spawn mock OpenAI server + let _tx = spawn_mock_http_server( + &test, + Arc::new(|req: HttpMessage| { + assert_eq!(req.uri.path(), "/v1/chat/completions"); + assert_eq!(req.method, Method::POST); + let req = serde_json::from_slice::(req.body.as_ref().unwrap()) + .unwrap(); + assert_eq!(req.model, "gpt-dummy"); + let message = &req.messages[0].content; + assert!(message.contains("You are an AI assistant specialized in analyzing email")); + + JsonResponse::new(&ChatCompletionResponse { + created: 0, + object: String::new(), + id: String::new(), + model: req.model, + choices: vec![ChatCompletionChoice { + index: 0, + finish_reason: "stop".to_string(), + message: Message { + role: "assistant".to_string(), + content: message.split_once("Subject: ").unwrap().1.to_string(), + }, + }], + }) + .into_http_response() + }), + 9090, + ) + .await; + + // Run tests + let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("antispam"); + let filter_test = std::env::var("TEST_NAME").ok(); + + for test_name in [ + "combined", + "ip", + "helo", + "received", + "messageid", + "date", + "from", + "subject", + "replyto", + "recipient", + "headers", + "url", + "html", + "mime", + "bounce", + "dmarc", + "rbl", + "spamtrap", + "classifier_html", + "classifier_features", + "classifier", + "pyzor", + "llm", + ] { + if filter_test + .as_ref() + .is_some_and(|s| !s.eq_ignore_ascii_case(test_name)) + { + continue; + } + + println!("===== {test_name} ====="); + let contents = fs::read_to_string(base_path.join(format!("{test_name}.test"))).unwrap(); + + match test_name { + "classifier_html" => { + html_tokens(contents); + continue; + } + "classifier_features" => { + classifier_features(&test.server, contents).await; + continue; + } + "classifier" => { + for class in ["spam", "ham"] { + let contents = + fs::read_to_string(base_path.join(format!("classifier.{class}"))).unwrap(); + for sample in contents.split("") { + let sample = sample.trim_start(); + if sample.is_empty() { + continue; + } + + let blob_id = test + .server + .put_jmap_blob(u32::MAX, sample.as_bytes()) + .await + .unwrap(); + admin + .registry_create_many( + ObjectType::SpamTrainingSample, + [json!({ + Property::BlobId: blob_id, + Property::IsSpam: class == "spam", + })], + ) + .await + .created_id(0); + } + } + admin + .registry_create_object(Task::SpamFilterMaintenance( + TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::Train, + status: TaskStatus::now(), + }, + )) + .await; + test.wait_for_tasks().await; + } + _ => {} + } + + let mut lines = contents.lines(); + let mut has_more = true; + + while has_more { + let mut message = String::new(); + let mut in_params = true; + + // Build session + let mut session = test.new_mta_session(); + let mut arc_result = None; + let mut dkim_result = None; + let mut dkim_signatures = vec![]; + let mut dmarc_result = None; + let mut dmarc_policy = None; + let mut expected_tags: AHashSet = AHashSet::new(); + let mut expect_headers = String::new(); + let mut body_params = 0; + let mut is_tls = false; + + for line in lines.by_ref() { + if in_params { + if line.is_empty() { + in_params = false; + continue; + } + let (param, value) = line.split_once(' ').unwrap(); + let value = value.trim(); + match param { + "remote_ip" => { + session.data.remote_ip_str = value.to_string(); + session.data.remote_ip = value.parse().unwrap(); + } + "helo_domain" => { + session.data.helo_domain = value.to_string(); + } + "authenticated_as" => { + session.data.authenticated_as = Some(AccountInfo { + account_id: u32::MAX, + addresses: vec![value.to_string()], + account: Arc::new(AccountCache { + name: value.into(), + ..Default::default() + }), + }); + } + "spf.result" | "spf_ehlo.result" => { + session.data.spf_mail_from = + Some(SpfOutput::default().with_result(SpfResult::from_str(value))); + } + "iprev.result" => { + session + .data + .iprev + .get_or_insert(IprevOutput { + result: IprevResult::None, + ptr: None, + }) + .result = IprevResult::from_str(value); + } + "dkim.result" => { + dkim_result = match DkimResult::from_str(value) { + DkimResult::Pass => DkimOutput::pass(), + DkimResult::Neutral(error) => DkimOutput::neutral(error), + DkimResult::Fail(error) => DkimOutput::fail(error), + DkimResult::PermError(error) => DkimOutput::perm_err(error), + DkimResult::TempError(error) => DkimOutput::temp_err(error), + DkimResult::None => unreachable!(), + } + .into(); + } + "arc.result" => { + arc_result = ArcOutput::default() + .with_result(DkimResult::from_str(value)) + .into(); + } + "dkim.domains" => { + dkim_signatures = value + .split_ascii_whitespace() + .map(|s| Signature { + d: s.to_lowercase(), + ..Default::default() + }) + .collect(); + } + "envelope_from" => { + session.data.mail_from = Some(SessionAddress::new(value.to_string())); + } + "envelope_to" => { + session + .data + .rcpt_to + .push(SessionAddress::new(value.to_string())); + } + "iprev.ptr" => { + session + .data + .iprev + .get_or_insert(IprevOutput { + result: IprevResult::None, + ptr: None, + }) + .ptr = Some(Arc::from(vec![value.into()])); + } + "dmarc.result" => { + dmarc_result = DmarcResult::from_str(value).into(); + } + "dmarc.policy" => { + dmarc_policy = Policy::from_str(value).into(); + } + "expect" => { + expected_tags + .extend(value.split_ascii_whitespace().map(|v| v.to_uppercase())); + } + "expect_header" => { + let value = value.trim(); + if !value.is_empty() { + if !expect_headers.is_empty() { + expect_headers.push(' '); + } + expect_headers.push_str(value); + } + } + "param.smtputf8" => { + body_params |= MAIL_SMTPUTF8; + } + "param.8bitmime" => { + body_params |= MAIL_BODY_8BITMIME; + } + "tls.version" => { + is_tls = true; + } + _ => panic!("Invalid parameter {param:?}"), + } + } else { + has_more = line.trim().eq_ignore_ascii_case(""); + if !has_more { + message.push_str(line); + message.push_str("\r\n"); + } else { + break; + } + } + } + + if message.is_empty() { + panic!("No message found"); + } + + if body_params != 0 { + session + .data + .mail_from + .get_or_insert_with(|| SessionAddress::new("".to_string())) + .flags = body_params; + } + + // Build input + let mut dkim_domains = vec![]; + if let Some(dkim_result) = dkim_result { + if dkim_signatures.is_empty() { + dkim_signatures.push(Signature { + d: "unknown.org".to_string(), + ..Default::default() + }); + } + + for signature in &dkim_signatures { + dkim_domains.push(dkim_result.clone().with_signature(signature)); + } + } + let parsed_message = MessageParser::new().parse(&message).unwrap(); + + // Combined tests + if test_name == "combined" { + match session + .spam_classify( + &parsed_message, + &dkim_domains, + None, + arc_result.as_ref(), + dmarc_result.as_ref(), + dmarc_policy.as_ref(), + ) + .await + { + SpamFilterAction::Allow(score) => { + let mut last_ch = 'x'; + let mut result = String::with_capacity(score.headers.len()); + for ch in score.headers.chars() { + if !ch.is_whitespace() { + if last_ch.is_whitespace() { + result.push(' '); + } + result.push(ch); + } + last_ch = ch; + } + assert_eq!(result, expect_headers); + } + other => panic!("Unexpected action {other:?}"), + } + continue; + } + + // Initialize filter + let mut spam_input = session.build_spam_input( + &parsed_message, + &dkim_domains, + None, + arc_result.as_ref(), + dmarc_result.as_ref(), + dmarc_policy.as_ref(), + ); + spam_input.is_tls = is_tls; + let server = &test.server; + let mut spam_ctx = server.spam_filter_init(spam_input); + match test_name { + "html" => { + server.spam_filter_analyze_html(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + } + "subject" => { + server.spam_filter_analyze_headers(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_")); + server.spam_filter_analyze_subject(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "received" => { + server.spam_filter_analyze_headers(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_")); + server.spam_filter_analyze_received(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "messageid" => { + server.spam_filter_analyze_message_id(&mut spam_ctx).await; + } + "date" => { + server.spam_filter_analyze_date(&mut spam_ctx).await; + } + "from" => { + server.spam_filter_analyze_from(&mut spam_ctx).await; + server.spam_filter_analyze_domain(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + } + "replyto" => { + server.spam_filter_analyze_reply_to(&mut spam_ctx).await; + server.spam_filter_analyze_domain(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + } + "recipient" => { + server.spam_filter_analyze_headers(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_")); + server.spam_filter_analyze_recipient(&mut spam_ctx).await; + server.spam_filter_analyze_domain(&mut spam_ctx).await; + server.spam_filter_analyze_subject(&mut spam_ctx).await; + server.spam_filter_analyze_url(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "mime" => { + server.spam_filter_analyze_mime(&mut spam_ctx).await; + } + "headers" => { + server.spam_filter_analyze_headers(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "url" => { + server.spam_filter_analyze_url(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + } + "dmarc" => { + server.spam_filter_analyze_dmarc(&mut spam_ctx).await; + server.spam_filter_analyze_headers(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "ip" => { + server.spam_filter_analyze_ip(&mut spam_ctx).await; + } + "helo" => { + server.spam_filter_analyze_ehlo(&mut spam_ctx).await; + } + "bounce" => { + server.spam_filter_analyze_mime(&mut spam_ctx).await; + server.spam_filter_analyze_headers(&mut spam_ctx).await; + server.spam_filter_analyze_rules(&mut spam_ctx).await; + spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_")); + } + "rbl" => { + server.spam_filter_analyze_url(&mut spam_ctx).await; + server.spam_filter_analyze_ip(&mut spam_ctx).await; + server.spam_filter_analyze_domain(&mut spam_ctx).await; + } + "spamtrap" => { + server.spam_filter_analyze_spam_trap(&mut spam_ctx).await; + server.spam_filter_finalize(&mut spam_ctx).await; + } + "classifier" => { + server.spam_filter_analyze_classify(&mut spam_ctx).await; + match server.spam_filter_finalize(&mut spam_ctx).await { + SpamFilterAction::Allow(r) => spam_ctx.result.tags.extend( + r.headers + .split_ascii_whitespace() + .filter(|t| t.starts_with("PROB_")) + .map(|t| t.to_string()), + ), + _ => unreachable!(), + } + } + "pyzor" => { + server.spam_filter_analyze_pyzor(&mut spam_ctx).await; + } + "llm" => { + server.spam_filter_analyze_llm(&mut spam_ctx).await; + } + _ => panic!("Invalid test {test_name:?}"), + } + + // Compare tags + if spam_ctx.result.tags != expected_tags { + for tag in &spam_ctx.result.tags { + if !expected_tags.contains(tag) { + println!("Unexpected tag: {tag:?}"); + } + } + + for tag in &expected_tags { + if !spam_ctx.result.tags.contains(tag) { + println!("Missing tag: {tag:?}"); + } + } + + panic!("Tags mismatch, expected {expected_tags:?}"); + } else { + println!("Tags matched: {expected_tags:?}"); + } + } + } +} + +async fn classifier_features(server: &Server, contents: String) { + let mut num_tests = 0; + + for test in contents.split("") { + let test = test.trim(); + if test.is_empty() { + continue; + } + + let (input, expected) = test.split_once("").unwrap(); + let input = input.trim(); + let expected = expected.trim(); + + // Build features + let message = MessageParser::new().parse(input).unwrap_or_default(); + let mut ctx = + server.spam_filter_init(SpamFilterInput::from_message(&message, 0).train_mode()); + server.spam_filter_analyze_domain(&mut ctx).await; + server.spam_filter_analyze_url(&mut ctx).await; + let mut tokens = server + .spam_build_tokens(&ctx) + .await + .0 + .into_keys() + .collect::>(); + tokens.sort(); + + assert!(!tokens.is_empty(), "No tokens parsed for input: {}", input); + let expected_tokens: Vec> = serde_json::from_str(expected).unwrap(); + + if tokens != expected_tokens { + eprintln!("Input: {}", input); + eprintln!("Expected Tokens: {}", expected); + eprintln!( + "Parsed Tokens: {}", + serde_json::to_string_pretty(&tokens).unwrap() + ); + panic!("Tokens do not match"); + } + num_tests += 1; + } + + assert_eq!(num_tests, 11, "Expected number of tests to run"); +} + +fn html_tokens(contents: String) { + let mut num_tests = 0; + + for test in contents.split("") { + let test = test.trim(); + if test.is_empty() { + continue; + } + + let (input, expected) = test.split_once("").unwrap(); + let input = input.trim(); + let expected = expected.trim(); + + let tokens = html_to_tokens(input); + assert!(!tokens.is_empty(), "No tokens parsed for input: {}", input); + let expected_tokens: Vec = serde_json::from_str(expected).unwrap(); + + assert_eq!(tokens, expected_tokens, "Input: {}", input); + num_tests += 1; + } + + assert_eq!(num_tests, 12, "Expected number of tests to run"); +} + +trait ParseConfigValue: Sized { + fn from_str(value: &str) -> Self; +} + +impl ParseConfigValue for SpfResult { + fn from_str(value: &str) -> Self { + match value { + "pass" => SpfResult::Pass, + "fail" => SpfResult::Fail, + "softfail" => SpfResult::SoftFail, + "neutral" => SpfResult::Neutral, + "none" => SpfResult::None, + "temperror" => SpfResult::TempError, + "permerror" => SpfResult::PermError, + _ => panic!("Invalid SPF result"), + } + } +} + +impl ParseConfigValue for IprevResult { + fn from_str(value: &str) -> Self { + match value { + "pass" => IprevResult::Pass, + "fail" => IprevResult::Fail(mail_auth::Error::NotAligned), + "temperror" => IprevResult::TempError(mail_auth::Error::NotAligned), + "permerror" => IprevResult::PermError(mail_auth::Error::NotAligned), + "none" => IprevResult::None, + _ => panic!("Invalid IPREV result"), + } + } +} + +impl ParseConfigValue for DkimResult { + fn from_str(value: &str) -> Self { + match value { + "pass" => DkimResult::Pass, + "none" => DkimResult::None, + "neutral" => DkimResult::Neutral(mail_auth::Error::NotAligned), + "fail" => DkimResult::Fail(mail_auth::Error::NotAligned), + "permerror" => DkimResult::PermError(mail_auth::Error::NotAligned), + "temperror" => DkimResult::TempError(mail_auth::Error::NotAligned), + _ => panic!("Invalid DKIM result"), + } + } +} + +impl ParseConfigValue for DmarcResult { + fn from_str(value: &str) -> Self { + match value { + "pass" => DmarcResult::Pass, + "fail" => DmarcResult::Fail(mail_auth::Error::NotAligned), + "temperror" => DmarcResult::TempError(mail_auth::Error::NotAligned), + "permerror" => DmarcResult::PermError(mail_auth::Error::NotAligned), + "none" => DmarcResult::None, + _ => panic!("Invalid DMARC result"), + } + } +} + +impl ParseConfigValue for Policy { + fn from_str(value: &str) -> Self { + match value { + "reject" => Policy::Reject, + "quarantine" => Policy::Quarantine, + "none" => Policy::None, + _ => panic!("Invalid DMARC policy"), + } + } +} diff --git a/tests/src/smtp/inbound/asn.rs b/tests/src/smtp/inbound/asn.rs new file mode 100644 index 0000000..9d9722a --- /dev/null +++ b/tests/src/smtp/inbound/asn.rs @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use registry::{ + schema::structs::{Asn, AsnDns, AsnResource}, + types::map::Map, +}; +use std::time::{Duration, Instant}; + +#[ignore] +#[tokio::test] +async fn asn() { + let mut test = TestServerBuilder::new("smtp_asn_test") + .await + .with_http_listener(19011) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + admin + .registry_create_object(Asn::Dns(AsnDns { + index_asn: 0, + index_asn_name: 3.into(), + index_country: 2.into(), + separator: '|'.to_string(), + zone_ip_v4: "origin.asn.cymru.com".to_string(), + zone_ip_v6: "origin6.asn.cymru.com".to_string(), + })) + .await; + admin.reload_settings().await; + test.reload_core(); + let admin = test.account("admin"); + + for (ip, asn, asn_name, country) in [ + ("8.8.8.8", 15169, "arin", "US"), + ("1.1.1.1", 13335, "apnic", "AU"), + ("2a01:4f9:c011:b43c::1", 24940, "ripencc", "DE"), + ("1.33.1.1", 2514, "apnic", "JP"), + ] { + let result = test.server.lookup_asn_country(ip.parse().unwrap()).await; + println!("{ip}: {result:?}"); + assert_eq!(result.asn.as_ref().map(|r| r.id), Some(asn)); + assert_eq!( + result.asn.as_ref().and_then(|r| r.name.as_deref()), + Some(asn_name) + ); + assert_eq!(result.country.as_ref().map(|s| s.as_str()), Some(country)); + } + + admin + .registry_create_object(Asn::Resource(AsnResource { + asn_urls: Map::new(vec![ + common::manager::defaults::ASN_IPV4.to_string(), + common::manager::defaults::ASN_IPV6.to_string(), + ]), + expires: 86_400_100u64.into(), + geo_urls: Map::new(vec![ + common::manager::defaults::GEO_IPV4.to_string(), + common::manager::defaults::GEO_IPV6.to_string(), + ]), + max_size: 100 * 1024 * 1024, + timeout: 100_000u64.into(), + ..Default::default() + })) + .await; + admin.reload_settings().await; + test.reload_core(); + + test.server + .lookup_asn_country("8.8.8.8".parse().unwrap()) + .await; + let time = Instant::now(); + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + if test.server.inner.data.asn_geo_data.lock.available_permits() > 0 { + break; + } + } + println!("Fetch took {:?}", time.elapsed()); + + for (ip, asn, asn_name, country) in [ + ("8.8.8.8", 15169, "Google LLC", "US"), + ("1.1.1.1", 13335, "Cloudflare, Inc.", "AU"), + ("2a01:4f9:c011:b43c::1", 24940, "Hetzner Online GmbH", "FI"), + ( + "1.33.1.1", + 2514, + "InfoSphere - NTT PC Communications, Inc.", + "JP", + ), + ] { + let result = test.server.lookup_asn_country(ip.parse().unwrap()).await; + println!("{ip}: {result:?}"); + assert_eq!(result.asn.as_ref().map(|r| r.id), Some(asn)); + assert_eq!( + result.asn.as_ref().and_then(|r| r.name.as_deref()), + Some(asn_name) + ); + assert_eq!(result.country.as_ref().map(|s| s.as_str()), Some(country)); + } +} diff --git a/tests/src/smtp/inbound/auth.rs b/tests/src/smtp/inbound/auth.rs new file mode 100644 index 0000000..b8b4eeb --- /dev/null +++ b/tests/src/smtp/inbound/auth.rs @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::server::TestServerBuilder, +}; +use registry::{ + schema::structs::{Expression, ExpressionMatch, MtaExtensions, MtaStageAuth}, + types::list::List, +}; +use smtp::core::State; + +#[tokio::test] +async fn auth() { + let mut test = TestServerBuilder::new("smtp_auth_test") + .await + .with_http_listener(19001) + .await + .disable_services() + .build() + .await; + + // Create test users + let admin = test.account("admin"); + for (name, secret, description, aliases) in [ + ( + "john@example.org", + "12345 + extra safety", + "John Doe", + &["john.doe@example.org"][..], + ), + ( + "jane@example.org", + "abcde + extra safety", + "Jane Smith", + &["jane@example.org"], + ), + ] { + admin + .create_user_account(name, secret, description, aliases, vec![]) + .await; + } + + // Add test settings + admin + .registry_create_object(MtaStageAuth { + max_failures: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "2".into(), + }]), + else_: "3".into(), + }, + must_match_sender: Expression { + else_: "true".into(), + ..Default::default() + }, + require: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + sasl_mechanisms: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1' && is_tls".into(), + then: "[plain, login]".into(), + }]), + else_: "0".into(), + }, + wait_on_fail: Expression { + else_: "100ms".into(), + ..Default::default() + }, + }) + .await; + admin + .registry_create_object(MtaExtensions { + future_release: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "!is_empty(authenticated_as)".into(), + then: "1d".into(), + }]), + else_: "false".into(), + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + // EHLO should not advertise plain text auth without TLS + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.stream.tls = false; + session + .ehlo("mx.foobar.org") + .await + .assert_not_contains(" PLAIN") + .assert_not_contains(" LOGIN"); + + // EHLO should advertise AUTH for 10.0.0.1 + session.stream.tls = true; + session + .ehlo("mx.foobar.org") + .await + .assert_contains("AUTH ") + .assert_contains(" PLAIN") + .assert_contains(" LOGIN") + .assert_not_contains("FUTURERELEASE"); + + // Invalid password should be rejected + session + .auth_plain("john@example.org", "wrong pass", "535 5.7.8") + .await; + + // Session should be disconnected after second invalid auth attempt + session + .ingest(b"AUTH PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz\r\n") + .await + .unwrap_err(); + session.response().assert_code("455 4.3.0"); + + // Should not be able to send without authenticating + session.state = State::default(); + session.mail_from("bill@foobar.org", "503 5.5.1").await; + + // Successful PLAIN authentication + session.data.auth_errors = 0; + session + .auth_plain("john@example.org", "12345 + extra safety", "235 2.7.0") + .await; + + // Users should be able to send emails only from their own email addresses + session.mail_from("bill@foobar.org", "501 5.5.4").await; + session.mail_from("john@example.org", "250").await; + session.data.mail_from.take(); + + // Should not be able to authenticate twice + session + .auth_plain("john@example.org", "12345 + extra safety", "503 5.5.1") + .await; + + // FUTURERELEASE extension should be available after authenticating + session + .ehlo("mx.foobar.org") + .await + .assert_not_contains("AUTH ") + .assert_not_contains(" PLAIN") + .assert_not_contains(" LOGIN") + .assert_contains("FUTURERELEASE 86400"); + + // Successful LOGIN authentication + session.data.authenticated_as.take(); + session + .auth_login("john@example.org", "12345 + extra safety", "235 2.7.0") + .await; + + // Login should not be advertised to 10.0.0.2 + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session.stream.tls = true; + session + .ehlo("mx.foobar.org") + .await + .assert_not_contains("AUTH ") + .assert_not_contains(" PLAIN") + .assert_not_contains(" LOGIN"); + session + .auth_plain("john@example.org", "12345 + extra safety", "503 5.5.1") + .await; +} diff --git a/tests/src/smtp/inbound/basic.rs b/tests/src/smtp/inbound/basic.rs new file mode 100644 index 0000000..6d14894 --- /dev/null +++ b/tests/src/smtp/inbound/basic.rs @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::server::TestServerBuilder, +}; +use common::auth::{AccountCache, AccountInfo}; +use mail_auth::SpfOutput; +use smtp::core::SessionAddress; +use std::sync::Arc; + +#[tokio::test] +async fn basic_commands() { + let test = TestServerBuilder::new("smtp_basic_test") + .await + .with_http_listener(19002) + .await + .disable_services() + .build() + .await; + + let mut session = test.new_mta_session(); + + // STARTTLS should be available on clear text connections + session.stream.tls = false; + session + .ehlo("mx.foobar.org") + .await + .assert_contains("STARTTLS"); + session.data.mail_from = Some(SessionAddress::new("attacker@evil.tld".to_string())); + session + .data + .rcpt_to + .push(SessionAddress::new("victim@foobar.org".to_string())); + session.data.spf_ehlo = Some(SpfOutput::default()); + session.data.authenticated_as = Some(AccountInfo { + account_id: u32::MAX, + addresses: vec!["attacker@evil.tld".to_string()], + account: Arc::new(AccountCache { + name: "attacker".into(), + ..Default::default() + }), + }); + session.data.bytes_left = 12345; + session.data.rcpt_errors = 2; + session.data.auth_errors = 1; + + assert!(!session.ingest(b"STARTTLS\r\n").await.unwrap()); + session.response().assert_contains("220 2.0.0"); + + assert!(session.data.mail_from.is_none()); + assert!(session.data.rcpt_to.is_empty()); + assert!(session.data.helo_domain.is_empty()); + assert!(session.data.spf_ehlo.is_none()); + assert!(session.data.authenticated_as.is_none()); + + assert_eq!(session.data.bytes_left, 12345); + assert_eq!(session.data.rcpt_errors, 2); + assert_eq!(session.data.auth_errors, 1); + + // STARTTLS should not be offered on TLS connections + session.stream.tls = true; + session + .ehlo("mx.foobar.org") + .await + .assert_not_contains("STARTTLS"); + session.cmd("STARTTLS", "504 5.7.4").await; + + // Test NOOP + session.cmd("NOOP", "250").await; + + // Test RSET + session.cmd("RSET", "250").await; + + // Test HELP + session.cmd("HELP QUIT", "250").await; + + // Test LHLO on SMTP channel + session.cmd("LHLO domain.org", "502").await; + + // Test QUIT + session.ingest(b"QUIT\r\n").await.unwrap_err(); + session.response().assert_code("221"); +} diff --git a/tests/src/smtp/inbound/data.rs b/tests/src/smtp/inbound/data.rs new file mode 100644 index 0000000..a9596b2 --- /dev/null +++ b/tests/src/smtp/inbound/data.rs @@ -0,0 +1,391 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse, load_test_message}, + }, + utils::server::TestServerBuilder, +}; +use registry::{ + schema::{ + enums::MtaQueueQuotaKey, + prelude::ObjectType, + structs::{ + Expression, ExpressionMatch, MtaQueueQuota, MtaStageData, SenderAuth, SpamSettings, + }, + }, + types::{list::List, map::Map}, +}; + +#[tokio::test] +async fn data() { + let mut test = TestServerBuilder::new("smtp_data_test") + .await + .with_http_listener(19004) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Create test users + let admin = test.account("admin"); + for (name, secret, description, aliases) in [ + ("john@foobar.org", "12345 + extra safety", "John Doe", &[]), + ("jane@domain.net", "abcde + extra safety", "Jane Smith", &[]), + ( + "bill@foobar.org", + "p4ssw0rd + extra safety", + "Bill Foobar", + &[], + ), + ( + "mike@test.com", + "p4ssw0rd + extra safety", + "Mike Foobar", + &[], + ), + ] { + admin + .create_user_account(name, secret, description, aliases, vec![]) + .await; + } + + // Add test settings + admin.mta_no_auth().await; + admin + .registry_create_object(SpamSettings { + enable: false, + ..Default::default() + }) + .await; + admin + .registry_create_object(SenderAuth { + dmarc_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + reverse_ip_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_ehlo_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_from_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageData { + add_auth_results_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + add_date_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + add_message_id_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + add_received_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + add_received_spf_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + add_return_path_header: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + max_messages: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "1".into(), + }]), + else_: "100".into(), + }, + max_received_headers: Expression { + else_: "3".into(), + ..Default::default() + }, + max_message_size: Expression { + match_: List::from_iter([ + ExpressionMatch { + if_: "remote_ip = '10.0.0.4'".into(), + then: "100".into(), + }, + ExpressionMatch { + if_: "remote_ip = '10.0.0.5'".into(), + then: "0".into(), + }, + ]), + else_: "104857600".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaQueueQuota { + description: None, + enable: true, + key: Map::new(vec![MtaQueueQuotaKey::Sender]), + match_: Expression { + else_: "sender = 'john@doe.org'".into(), + ..Default::default() + }, + messages: Some(1), + size: None, + }) + .await; + admin + .registry_create_object(MtaQueueQuota { + description: None, + enable: true, + key: Map::new(vec![MtaQueueQuotaKey::RcptDomain]), + match_: Expression { + else_: "rcpt_domain = 'foobar.org'".into(), + ..Default::default() + }, + messages: None, + size: Some(450), + }) + .await; + admin + .registry_create_object(MtaQueueQuota { + description: None, + enable: true, + key: Map::new(vec![MtaQueueQuotaKey::Rcpt]), + match_: Expression { + else_: "rcpt = 'jane@domain.net'".into(), + ..Default::default() + }, + messages: None, + size: Some(450), + }) + .await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Test queue message builder + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.test_builder().await; + + // Send DATA without RCPT + session.ehlo("mx.doe.org").await; + session.ingest(b"DATA\r\n").await.unwrap(); + session.response().assert_code("503 5.5.1"); + + // Send BDAT without MAIL FROM + session.ingest(b"BDAT 10\r\n0123456789").await.unwrap(); + session + .response() + .assert_code("503 5.5.1") + .assert_contains("MAIL is required") + .assert_not_contains("552"); + + // Send BDAT without RCPT + session.mail_from("john@doe.org", "250").await; + session.ingest(b"BDAT 10\r\n0123456789").await.unwrap(); + session + .response() + .assert_code("503 5.5.1") + .assert_contains("RCPT is required") + .assert_not_contains("552"); + session.rset().await; + + // Send a BDAT chunk exceeding the maximum message size + let mut size_session = test.new_mta_session(); + size_session.data.remote_ip_str = "10.0.0.4".into(); + size_session.eval_session_params().await; + size_session.ehlo("mx.doe.org").await; + size_session.mail_from("john@doe.org", "250").await; + size_session.rcpt_to("bill@foobar.org", "250").await; + let mut chunk = b"BDAT 200 LAST\r\n".to_vec(); + chunk.extend_from_slice(&[b'A'; 200]); + size_session.ingest(&chunk).await.unwrap(); + size_session.response().assert_code("552 5.3.4"); + size_session.rset().await; + + // A maximum message size of zero disables the limit + size_session.data.remote_ip_str = "10.0.0.5".into(); + size_session.eval_session_params().await; + size_session + .ingest(b"MAIL FROM: SIZE=1073741824\r\n") + .await + .unwrap(); + size_session.response().assert_code("250"); + size_session.rset().await; + size_session + .send_message("bill@doe.org", &["mike@test.com"], "test:no_dkim", "250") + .await; + test.expect_message().await; + + // Send broken message + session + .send_message("john@doe.org", &["bill@foobar.org"], "invalid", "550 5.7.7") + .await; + + // Naive Loop detection + session + .send_message( + "john@doe.org", + &["bill@foobar.org"], + "test:loop", + "450 4.4.6", + ) + .await; + + // No headers should be added to messages from 10.0.0.1 + session + .send_message("john@test.org", &["mike@test.com"], "test:no_msgid", "250") + .await; + assert_eq!( + test.expect_message().await.read_message(&test).await, + format!("{}\r\n", load_test_message("no_msgid", "messages")) + ); + + // Maximum one message per session is allowed for 10.0.0.1 + session.mail_from("john@doe.org", "250").await; + session.rcpt_to("bill@foobar.org", "250").await; + session.ingest(b"DATA\r\n").await.unwrap(); + session.response().assert_code("452 4.4.5"); + session.rset().await; + + // Headers should be added to messages from 10.0.0.3 + session.data.remote_ip_str = "10.0.0.3".into(); + session.eval_session_params().await; + session + .send_message("bill@doe.org", &["mike@test.com"], "test:no_msgid", "250") + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("From: ") + .assert_contains("To: ") + .assert_contains("Subject: ") + .assert_contains("Date: ") + .assert_contains("Message-ID: ") + .assert_contains("Return-Path: ") + .assert_contains("Received: ") + .assert_contains("Authentication-Results: ") + .assert_contains("Received-SPF: "); + + // Send a message using multiple BDAT chunks + session.mail_from("bill@doe.org", "250").await; + session.rcpt_to("mike@test.com", "250").await; + let message = load_test_message("no_msgid", "messages"); + let (first, last) = message.as_bytes().split_at(message.len() / 2); + let mut chunk = format!("BDAT {}\r\n", first.len()).into_bytes(); + chunk.extend_from_slice(first); + session.ingest(&chunk).await.unwrap(); + session.response().assert_code("250 2.6.0"); + let mut chunk = format!("BDAT {} LAST\r\n", last.len()).into_bytes(); + chunk.extend_from_slice(last); + session.ingest(&chunk).await.unwrap(); + session.response().assert_code("250"); + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("Subject: ") + .assert_contains("Received: "); + + // Only one message is allowed in the queue from john@doe.org + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session + .send_message("john@doe.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + session + .send_message( + "john@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "452 4.3.1", + ) + .await; + + // Release quota + test.clear_queue().await; + + // Only 1500 bytes are allowed in the queue to domain foobar.org + session + .send_message( + "jane@foobar.org", + &["bill@foobar.org"], + "test:no_dkim", + "250", + ) + .await; + session + .send_message( + "jane@foobar.org", + &["bill@foobar.org"], + "test:no_dkim", + "452 4.3.1", + ) + .await; + + // Only 1500 bytes are allowed in the queue to recipient jane@domain.net + session + .send_message( + "jane@foobar.org", + &["jane@domain.net"], + "test:no_dkim", + "250", + ) + .await; + session + .send_message( + "jane@foobar.org", + &["jane@domain.net"], + "test:no_dkim", + "452 4.3.1", + ) + .await; + + // Make sure store is empty + test.clear_queue().await; + let admin = test.account("admin"); + admin.registry_destroy_all(ObjectType::MtaQueueQuota).await; + admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + test.assert_is_empty().await; +} diff --git a/tests/src/smtp/inbound/dkim2.rs b/tests/src/smtp/inbound/dkim2.rs new file mode 100644 index 0000000..74d2a32 --- /dev/null +++ b/tests/src/smtp/inbound/dkim2.rs @@ -0,0 +1,633 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{inbound::TestMessage, session::TestSession}, + utils::{account::Account, dns::DnsCache, server::TestServer, server::TestServerBuilder}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use mail_auth::{ + DnssecStatus, MX, + common::{crypto::Ed25519Key, parse::TxtRecordParser, verify::DomainKey}, + dkim2::{Dkim2Signer, Hop}, +}; +use registry::schema::{ + enums::{DkimCanonicalization, DkimRotationStage}, + structs::{ + CertificateManagement, Dkim1Signature, Dkim2Signature, DkimManagement, DkimSignature, + DnsManagement, Domain, DsnReportSettings, Expression, SecretText, SecretTextValue, + SenderAuth, + }, +}; +use std::time::{Duration, Instant}; +use types::id::Id; + +const ED_PRIVATE: &str = concat!( + "-----BEGIN PRIVATE KEY-----\n", + "MC4CAQAwBQYDK2VwBCIEIIOQVf8MDGvvmIkpUbgoqtyUIxjlzRqaBR6aP12tcGGE\n", + "-----END PRIVATE KEY-----\n" +); +const ED_PUBLIC: &str = "hwjviTXyzUXSCWayBqE17s/4NSynQKxw58jayHudRAI="; + +#[tokio::test] +#[serial_test::serial] +async fn dkim2_all_disclosed() { + let (mut local, remote) = build_signer_and_verifier(19040, 19041, false).await; + + let delivered = deliver_and_collect( + &mut local, + &remote, + "john@example.com", + &["alice@foobar.org", "bob@foobar.org"], + &message("To: Alice , Bob \r\n"), + 1, + ) + .await; + + assert_eq!(delivered.len(), 1); + let msg = &delivered[0]; + assert_eq!(msg.recipients, vec!["alice@foobar.org", "bob@foobar.org"]); + assert!( + msg.body.contains("DKIM2-Signature"), + "missing DKIM2 signature: {}", + msg.body + ); + assert!( + without_whitespace(&msg.body).contains("dkim2=pass"), + "verifier did not report dkim2=pass: {}", + msg.body + ); + + // Both disclosed recipients belong to the same shared signature + let stripped = without_whitespace(&msg.body); + assert!(stripped.contains(&rt_token("alice@foobar.org"))); + assert!(stripped.contains(&rt_token("bob@foobar.org"))); +} + +#[tokio::test] +#[serial_test::serial] +async fn dkim2_mixed_recipients_do_not_leak_bcc() { + let (mut local, remote) = build_signer_and_verifier(19042, 19043, false).await; + + let delivered = deliver_and_collect( + &mut local, + &remote, + "john@example.com", + &[ + "alice@foobar.org", + "bob@foobar.org", + "eve@foobar.org", + "mallory@foobar.org", + ], + &message("To: Alice \r\nCc: Bob \r\n"), + 3, + ) + .await; + + assert_eq!(delivered.len(), 3); + + let eve = rt_token("eve@foobar.org"); + let mallory = rt_token("mallory@foobar.org"); + + for msg in &delivered { + let stripped = without_whitespace(&msg.body); + assert!( + stripped.contains("dkim2=pass"), + "verifier did not report dkim2=pass for {:?}: {}", + msg.recipients, + msg.body + ); + + if msg.recipients == vec!["alice@foobar.org", "bob@foobar.org"] { + // Disclosed copy: the two Bcc recipients must not appear anywhere + assert!( + !stripped.contains(&eve) && !stripped.contains(&mallory), + "Bcc recipient leaked into the disclosed signature: {}", + msg.body + ); + assert!(!msg.body.contains("eve@foobar.org")); + assert!(!msg.body.contains("mallory@foobar.org")); + assert!(stripped.contains(&rt_token("alice@foobar.org"))); + assert!(stripped.contains(&rt_token("bob@foobar.org"))); + } else if msg.recipients == vec!["eve@foobar.org"] { + // Bcc copy: only Eve's address is in this signature + assert!(stripped.contains(&eve)); + assert!(!stripped.contains(&mallory)); + assert!(!msg.body.contains("mallory@foobar.org")); + } else if msg.recipients == vec!["mallory@foobar.org"] { + assert!(stripped.contains(&mallory)); + assert!(!stripped.contains(&eve)); + assert!(!msg.body.contains("eve@foobar.org")); + } else { + panic!("unexpected recipient grouping: {:?}", msg.recipients); + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn dkim2_all_undisclosed_do_not_leak() { + let (mut local, remote) = build_signer_and_verifier(19044, 19045, false).await; + + let rcpts = ["carol@foobar.org", "dave@foobar.org", "frank@foobar.org"]; + let delivered = deliver_and_collect( + &mut local, + &remote, + "john@example.com", + &rcpts, + &message("To: undisclosed-recipients:;\r\n"), + 3, + ) + .await; + + assert_eq!(delivered.len(), 3); + + for msg in &delivered { + assert_eq!(msg.recipients.len(), 1, "expected one recipient per copy"); + let own = msg.recipients[0].as_str(); + let stripped = without_whitespace(&msg.body); + assert!( + stripped.contains("dkim2=pass"), + "verifier did not report dkim2=pass for {own}: {}", + msg.body + ); + assert!(stripped.contains(&rt_token(own))); + + // No other recipient may appear in this copy + for other in rcpts.iter().filter(|r| **r != own) { + assert!( + !stripped.contains(&rt_token(other)), + "recipient {other} leaked into the copy for {own}: {}", + msg.body + ); + assert!( + !msg.body.contains(other), + "recipient {other} leaked into the copy for {own}: {}", + msg.body + ); + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn dkim1_and_dkim2_signed_together() { + let (mut local, remote) = build_signer_and_verifier(19046, 19047, true).await; + + let delivered = deliver_and_collect( + &mut local, + &remote, + "john@example.com", + &["alice@foobar.org", "bob@foobar.org"], + &message("To: Alice , Bob \r\n"), + 1, + ) + .await; + + assert_eq!(delivered.len(), 1); + let stripped = without_whitespace(&delivered[0].body); + assert!( + delivered[0].body.contains("DKIM-Signature"), + "missing DKIM1 signature: {}", + delivered[0].body + ); + assert!( + delivered[0].body.contains("DKIM2-Signature"), + "missing DKIM2 signature: {}", + delivered[0].body + ); + assert!( + stripped.contains("dkim=pass"), + "DKIM1 did not pass: {}", + delivered[0].body + ); + assert!( + stripped.contains("dkim2=pass"), + "DKIM2 did not pass: {}", + delivered[0].body + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn dkim2_dsn_is_signed() { + let mut local = TestServerBuilder::new("dkim2_dsn_signer") + .await + .with_http_listener(19048) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = local.account("admin"); + admin.mta_allow_relaying().await; + admin.mta_no_auth().await; + admin.mta_all_extensions().await; + admin.mta_disable_spam_filter().await; + admin.mta_add_all_headers().await; + let domain_id = admin.create_signing_domain(false).await; + admin + .registry_create_object(DsnReportSettings { + dkim_sign_domain: expr("'example.com'"), + ..Default::default() + }) + .await; + let _ = domain_id; + admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + // Deliver to a domain with no DNS records: the lookup fails permanently and + // a DSN is generated for the sender. + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session + .send_message( + "john@example.com", + &["bob@does-not-resolve.invalid"], + &message("To: Bob \r\n"), + "250", + ) + .await; + + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + let dsn = local.expect_message().await; + assert!( + dsn.message.return_path.is_empty(), + "expected a DSN (null return path)" + ); + let body = dsn.read_message(&local).await; + assert!( + body.contains("Content-Type: multipart/report"), + "not a DSN: {body}" + ); + assert!( + body.contains("DKIM2-Signature"), + "the generated DSN was not DKIM2 signed: {body}" + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn dkim2_inbound_dsn_validation() { + let mut server = TestServerBuilder::new("dkim2_dsn_receiver") + .await + .with_http_listener(19049) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = server.account("admin"); + admin.mta_allow_relaying().await; + admin.mta_no_auth().await; + admin.mta_all_extensions().await; + admin.mta_disable_spam_filter().await; + admin.mta_add_all_headers().await; + admin.configure_sender_auth("''").await; + admin.reload_settings().await; + server.reload_core(); + server.expect_reload_settings().await; + + // The returned message is signed by us (example.com); the DSN is signed by + // the bouncing domain (foobar.org). Publish both keys. + server + .server + .txt_add("ed._domainkey.example.com", dkim_dns_record(), valid()); + server + .server + .txt_add("ed._domainkey.foobar.org", dkim_dns_record(), valid()); + + let returned = dkim2_sign( + "example.com", + "ed", + RETURNED_PLAIN.as_bytes(), + "john@example.com", + &["bob@foobar.org"], + ); + + // A well-formed, aligned DSN is accepted + let dsn_ok = build_dsn(&returned, true); + let mut session = server.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.foobar.org").await; + session + .send_message( + "<>", + &["john@example.com"], + &String::from_utf8(dsn_ok).unwrap(), + "250", + ) + .await; + + // Tampering the returned message body breaks its signature chain, so the DSN + // is rejected. + let returned_tampered = String::from_utf8(returned) + .unwrap() + .replace("DKIM2-ORIGINAL-BODY-CONTENT", "DKIM2-TAMPERED-BODY-CONTENT") + .into_bytes(); + let dsn_bad = build_dsn(&returned_tampered, true); + session + .send_message( + "<>", + &["john@example.com"], + &String::from_utf8(dsn_bad).unwrap(), + "550", + ) + .await; +} + +impl Account { + async fn create_signing_domain(&self, with_dkim1: bool) -> Id { + let domain_id = self + .registry_create_object(Domain { + name: "example.com".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + allow_relaying: true, + ..Default::default() + }) + .await; + + self.registry_create_object(DkimSignature::Dkim2Ed25519Sha256(Dkim2Signature { + stage: DkimRotationStage::Active, + selector: "ed2".to_string(), + domain_id, + private_key: SecretText::Text(SecretTextValue { + secret: ED_PRIVATE.to_string(), + }), + ..Default::default() + })) + .await; + + if with_dkim1 { + self.registry_create_object(DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature { + stage: DkimRotationStage::Active, + selector: "ed1".to_string(), + canonicalization: DkimCanonicalization::RelaxedRelaxed, + domain_id, + private_key: SecretText::Text(SecretTextValue { + secret: ED_PRIVATE.to_string(), + }), + ..Default::default() + })) + .await; + } + + domain_id + } + + async fn configure_sender_auth(&self, dkim_sign_domain: &str) { + self.registry_create_object(SenderAuth { + dmarc_verify: expr("relaxed"), + reverse_ip_verify: expr("relaxed"), + spf_ehlo_verify: expr("relaxed"), + spf_from_verify: expr("relaxed"), + arc_verify: expr("relaxed"), + dkim_sign_domain: expr(dkim_sign_domain), + dkim_verify: expr("relaxed"), + dkim_strict: false, + }) + .await; + } +} + +async fn build_signer_and_verifier( + http_local: u16, + http_remote: u16, + with_dkim1: bool, +) -> (TestServer, TestServer) { + let mut local = TestServerBuilder::new("dkim2_signer") + .await + .with_http_listener(http_local) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("dkim2_verifier") + .await + .with_http_listener(http_remote) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Signer (originating MTA) + let admin = local.account("admin"); + admin.mta_allow_relaying().await; + admin.mta_no_auth().await; + admin.mta_all_extensions().await; + admin.mta_disable_spam_filter().await; + admin.mta_add_all_headers().await; + admin.create_signing_domain(with_dkim1).await; + admin.configure_sender_auth("'example.com'").await; + admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + // Verifier (receiving MTA) + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.mta_all_extensions().await; + remote_admin.mta_disable_spam_filter().await; + remote_admin.mta_add_all_headers().await; + remote_admin.configure_sender_auth("''").await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Publish the signer's public keys in the verifier's DNS + remote + .server + .txt_add("ed2._domainkey.example.com", dkim_dns_record(), valid()); + if with_dkim1 { + remote + .server + .txt_add("ed1._domainkey.example.com", dkim_dns_record(), valid()); + } + + // Route foobar.org deliveries back to the local (in-process) receiver + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + valid(), + ); + local + .server + .ipv4_add("mx.foobar.org", vec!["127.0.0.1".parse().unwrap()], valid()); + + (local, remote) +} + +fn message(to_header: &str) -> String { + format!( + concat!( + "From: John Doe \r\n", + "{}", + "Subject: DKIM2 privacy test\r\n", + "\r\n", + "This is a DKIM2 test message.\r\n", + ), + to_header + ) +} + +fn build_dsn(returned: &[u8], sign_dsn: bool) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: text/plain\r\n\r\n"); + body.extend_from_slice(b"Delivery to bob@foobar.org failed.\r\n"); + body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/delivery-status\r\n\r\n"); + body.extend_from_slice(b"Reporting-MTA: dns; foobar.org\r\n\r\n"); + body.extend_from_slice(b"Final-Recipient: rfc822; bob@foobar.org\r\n"); + body.extend_from_slice(b"Action: failed\r\nStatus: 5.1.1\r\n"); + body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/rfc822\r\n\r\n"); + body.extend_from_slice(returned); + body.extend_from_slice(b"\r\n--BOUNDARY--\r\n"); + + let mut dsn = Vec::new(); + dsn.extend_from_slice(b"From: postmaster@foobar.org\r\n"); + dsn.extend_from_slice(b"To: john@example.com\r\n"); + dsn.extend_from_slice(b"Subject: Delivery Status Notification (Failure)\r\n"); + dsn.extend_from_slice(b"Date: Sat, 01 Mar 2026 12:05:00 +0000\r\n"); + dsn.extend_from_slice(b"Message-ID: \r\n"); + dsn.extend_from_slice( + b"Content-Type: multipart/report; report-type=delivery-status; boundary=\"BOUNDARY\"\r\n\r\n", + ); + dsn.extend_from_slice(&body); + + if sign_dsn { + dkim2_sign("foobar.org", "ed", &dsn, "<>", &["john@example.com"]) + } else { + dsn + } +} + +fn ed25519_key() -> Ed25519Key { + let der = STANDARD + .decode("MC4CAQAwBQYDK2VwBCIEIIOQVf8MDGvvmIkpUbgoqtyUIxjlzRqaBR6aP12tcGGE") + .unwrap(); + Ed25519Key::from_pkcs8_maybe_unchecked_der(&der).unwrap() +} + +fn dkim2_sign( + domain: &str, + selector: &str, + message: &[u8], + mail_from: &str, + rcpt_to: &[&str], +) -> Vec { + let signed = Dkim2Signer::from_key(ed25519_key()) + .domain(domain) + .selector(selector) + .sign(message, Hop::real(mail_from, rcpt_to)) + .expect("dkim2 sign"); + let mut out = signed.to_header().into_bytes(); + out.extend_from_slice(message); + out +} + +const RETURNED_PLAIN: &str = concat!( + "From: John Doe \r\n", + "To: Bob \r\n", + "Subject: Original message\r\n", + "Date: Sat, 01 Mar 2026 12:00:00 +0000\r\n", + "Message-ID: \r\n", + "\r\n", + "DKIM2-ORIGINAL-BODY-CONTENT\r\n", +); + +struct Delivered { + recipients: Vec, + body: String, +} + +async fn deliver_and_collect( + local: &mut TestServer, + remote: &TestServer, + from: &str, + rcpts: &[&str], + raw: &str, + expected: usize, +) -> Vec { + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session.send_message(from, rcpts, raw, "250").await; + + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + let mut delivered = Vec::new(); + for _ in 0..expected { + let mut waited = 0; + let msg = loop { + if let Some(msg) = remote.read_queued_messages().await.into_iter().next() { + break msg; + } + assert!(waited < 100, "timed out waiting for a delivered message"); + tokio::time::sleep(Duration::from_millis(50)).await; + waited += 1; + }; + let body = msg.read_message(remote).await; + let mut recipients = msg + .message + .recipients + .iter() + .map(|r| r.address().to_string()) + .collect::>(); + recipients.sort(); + let due = remote.message_due(msg.queue_id).await; + msg.clone().remove(&remote.server, due.into()).await; + delivered.push(Delivered { recipients, body }); + } + + delivered +} + +fn rt_token(address: &str) -> String { + STANDARD.encode(format!("<{address}>")) +} + +fn without_whitespace(value: &str) -> String { + value.chars().filter(|c| !c.is_whitespace()).collect() +} + +fn dkim_dns_record() -> DomainKey { + DomainKey::parse(format!("v=DKIM1; k=ed25519; p={ED_PUBLIC}").as_bytes()).unwrap() +} + +fn valid() -> Instant { + Instant::now() + Duration::from_secs(300) +} + +fn expr(value: &str) -> Expression { + Expression { + else_: value.into(), + ..Default::default() + } +} diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs new file mode 100644 index 0000000..f29c950 --- /dev/null +++ b/tests/src/smtp/inbound/dmarc.rs @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestReportingEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::config::smtp::report::AggregateFrequency; +use mail_auth::{ + common::{parse::TxtRecordParser, verify::DomainKey}, + dkim::DomainKeyReport, + dmarc::Dmarc, + report::DmarcResult, + spf::Spf, +}; +use registry::{ + schema::structs::{ + CertificateManagement, DkimManagement, DkimReportSettings, DmarcReportSettings, + DnsManagement, Domain, Expression, ExpressionMatch, SenderAuth, SpfReportSettings, + }, + types::list::List, +}; +use std::time::{Duration, Instant}; + +#[tokio::test] +async fn dmarc() { + let mut test = TestServerBuilder::new("smtp_dmarc_test") + .await + .with_http_listener(19012) + .await + .disable_services() + .capture_queue() + .capture_reporting() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + let domain_id = admin + .registry_create_object(Domain { + name: "localdomain.org".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + allow_relaying: true, + ..Default::default() + }) + .await; + admin.create_dkim_signatures(domain_id).await; + admin.mta_no_auth().await; + admin.mta_add_all_headers().await; + admin + .registry_create_object(SenderAuth { + dmarc_verify: Expression { + else_: "strict".into(), + ..Default::default() + }, + reverse_ip_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_ehlo_verify: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "strict".into(), + }]), + else_: "relaxed".into(), + }, + spf_from_verify: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "strict".into(), + }]), + else_: "relaxed".into(), + }, + arc_verify: Expression { + else_: "strict".into(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "'localdomain.org'".into(), + ..Default::default() + }, + dkim_verify: Expression { + match_: List::from_iter([ + ExpressionMatch { + if_: "sender_domain = 'test.net'".into(), + then: "relaxed".into(), + }, + ExpressionMatch { + if_: "sender_domain = 'xn--eebajf.xn--9dbq2a'".into(), + then: "relaxed".into(), + }, + ExpressionMatch { + if_: "sender_domain = 'tmp._dns_error.test'".into(), + then: "relaxed".into(), + }, + ]), + else_: "strict".into(), + }, + dkim_strict: false, + }) + .await; + admin + .registry_create_object(DkimReportSettings { + dkim_sign_domain: Expression { + else_: "'localdomain.org'".into(), + ..Default::default() + }, + send_frequency: Expression { + else_: "[1, 1s]".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(SpfReportSettings { + dkim_sign_domain: Expression { + else_: "'localdomain.org'".into(), + ..Default::default() + }, + send_frequency: Expression { + else_: "[1, 1s]".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(DmarcReportSettings { + failure_dkim_sign_domain: Expression { + else_: "'localdomain.org'".into(), + ..Default::default() + }, + failure_send_frequency: Expression { + else_: "[1, 1s]".into(), + ..Default::default() + }, + aggregate_send_frequency: Expression { + else_: "daily".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Add SPF, DKIM and DMARC records + test.server.txt_add( + "mx.example.com", + Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "example.com", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all ra=spf-failures rr=e:f:s:n").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "foobar.com", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "ed._domainkey.example.com", + DomainKey::parse( + concat!( + "v=DKIM1; k=ed25519; ", + "p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "default._domainkey.example.com", + DomainKey::parse( + concat!( + "v=DKIM1; t=s; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ", + "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt", + "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v", + "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi", + "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB", + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_report._domainkey.example.com", + DomainKeyReport::parse(b"ra=dkim-failures; rp=100; rr=d:o:p:s:u:v:x;").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_dmarc.example.com", + Dmarc::parse( + concat!( + "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;", + "rua=mailto:dmarc-feedback@example.com;", + "ruf=mailto:dmarc-failures@example.com" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + + // SPF must pass + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.2".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session.mail_from("bill@example.com", "550 5.7.23").await; + + // Expect SPF auth failure report + let message = test.expect_message().await; + assert_eq!( + message.message.recipients.last().unwrap().address(), + "spf-failures@example.com" + ); + message + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;") + .assert_contains("To: spf-failures@example.com") + .assert_contains("Feedback-Type: auth-failure") + .assert_contains("Auth-Failure: spf"); + + // Second DKIM failure report should be rate limited + session.mail_from("bill@example.com", "550 5.7.23").await; + test.assert_no_events(); + + // Invalid DKIM signatures should be rejected + session.data.remote_ip_str = "10.0.0.1".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.eval_session_params().await; + session + .send_message( + "bill@example.com", + &["jdoe@localdomain.org"], + "test:invalid_dkim", + "550 5.7.20", + ) + .await; + + // Expect DKIM auth failure report + let message = test.expect_message().await; + assert_eq!( + message.message.recipients.last().unwrap().address(), + "dkim-failures@example.com" + ); + message + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;") + .assert_contains("To: dkim-failures@example.com") + .assert_contains("Feedback-Type: auth-failure") + .assert_contains("Auth-Failure: bodyhash"); + + // Second DKIM failure report should be rate limited + session + .send_message( + "bill@example.com", + &["jdoe@localdomain.org"], + "test:invalid_dkim", + "550 5.7.20", + ) + .await; + test.assert_no_events(); + + // Invalid ARC should be rejected + session + .send_message( + "bill@example.com", + &["jdoe@localdomain.org"], + "test:invalid_arc", + "550 5.7.29", + ) + .await; + test.assert_no_events(); + + // Unaligned DMARC should be rejected + test.server.txt_add( + "test.net", + Spf::parse(b"v=spf1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "joe@test.net", + &["jdoe@localdomain.org"], + "test:invalid_dkim", + "550 5.7.1", + ) + .await; + + // Expect DMARC auth failure report + let message = test.expect_message().await; + assert_eq!( + message.message.recipients.last().unwrap().address(), + "dmarc-failures@example.com" + ); + message + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;") + .assert_contains("To: dmarc-failures@example.com") + .assert_contains("Feedback-Type: auth-failure") + .assert_contains("Auth-Failure: dmarc") + .assert_contains("dmarc=3Dfail"); + + // Expect DMARC aggregate report + let report = test.read_report().await.unwrap_dmarc(); + assert_eq!(report.domain, "example.com"); + assert_eq!(report.interval, AggregateFrequency::Daily); + assert_eq!(report.dmarc_record.rua().len(), 1); + assert_eq!(report.report_record.dmarc_spf_result(), DmarcResult::Fail); + + // Second DMARC failure report should be rate limited + session + .send_message( + "joe@test.net", + &["jdoe@localdomain.org"], + "test:invalid_dkim", + "550 5.7.1", + ) + .await; + test.assert_no_events(); + + // Messages passing DMARC should be accepted + session + .send_message( + "bill@example.com", + &["jdoe@localdomain.org"], + "test:dkim", + "250", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("dkim=pass") + .assert_contains("spf=pass") + .assert_contains("dmarc=pass") + .assert_contains("Received-SPF: pass"); + + // A mechanism that authenticates an unaligned identity is reported as failed + test.server.txt_add( + "_dmarc.example.com", + Dmarc::parse( + concat!( + "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;", + "rua=mailto:dmarc-feedback@example.com;", + "ruf=mailto:dmarc-unaligned@example.com" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "ed._domainkey.example.com", + DomainKey::parse( + concat!( + "v=DKIM1; k=ed25519; ", + "p=21qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_report._domainkey.example.com", + DomainKeyReport::parse(b"ra=dkim-failures; rp=0; rr=d:o:p:s:u:v:x;").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "joe@foobar.com", + &["jdoe@localdomain.org"], + "test:dkim", + "250", + ) + .await; + + let mut failure_report = None; + for _ in 0..2 { + let message = test.consume_message().await; + let rcpt = message + .message + .recipients + .last() + .unwrap() + .address() + .to_string(); + if rcpt == "dmarc-unaligned@example.com" { + failure_report = Some(message.read_lines(&test).await); + } + } + failure_report + .expect("no DMARC failure report was sent") + .assert_contains("Feedback-Type: auth-failure") + .assert_contains("Auth-Failure: dmarc") + .assert_contains("Identity-Alignment: spf") + .assert_contains("SPF-DNS: txt : foobar.com") + .assert_not_contains("DKIM-Domain:"); + + // Aggregate reports identify an IDN author domain by its A-label + test.server.txt_add( + "xn--eebajf.xn--9dbq2a", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_dmarc.xn--eebajf.xn--9dbq2a", + Dmarc::parse( + concat!( + "v=DMARC1; p=none; aspf=s; adkim=s; fo=1;", + "rua=mailto:dmarc-feedback@xn--eebajf.xn--9dbq2a" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "yossi@xn--eebajf.xn--9dbq2a", + &["jdoe@localdomain.org"], + "test:idn_from", + "250", + ) + .await; + test.consume_message().await; + + let mut idn_report = None; + for _ in 0..10 { + let Some(report) = test.try_read_report().await else { + break; + }; + let report = report.unwrap_dmarc(); + if report.domain == "xn--eebajf.xn--9dbq2a" { + idn_report = Some(report); + break; + } + } + let report = idn_report.expect("no aggregate report for the IDN author domain"); + assert_eq!(report.report_record.header_from(), "xn--eebajf.xn--9dbq2a"); + assert_eq!( + report.report_record.envelope_from(), + "xn--eebajf.xn--9dbq2a" + ); + + // An aligned SPF temperror under p=reject is temporarily rejected in strict mode + test.server.txt_add( + "_dmarc.tmp._dns_error.test", + Dmarc::parse(b"v=DMARC1; p=reject; psd=n").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "joe@tmp._dns_error.test", + &["jdoe@localdomain.org"], + "From: joe@tmp._dns_error.test\r\nTo: jdoe@localdomain.org\r\nSubject: test\r\n\r\ntest", + "451 4.7.1", + ) + .await; + test.assert_no_events(); +} diff --git a/tests/src/smtp/inbound/ehlo.rs b/tests/src/smtp/inbound/ehlo.rs new file mode 100644 index 0000000..7a24b4c --- /dev/null +++ b/tests/src/smtp/inbound/ehlo.rs @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{SpfResult, common::parse::TxtRecordParser, spf::Spf}; +use mail_parser::DateTime; +use registry::{ + schema::structs::{ + Expression, ExpressionMatch, MtaExtensions, MtaStageData, MtaStageEhlo, SenderAuth, + }, + types::list::List, +}; +use std::time::{Duration, Instant, SystemTime}; + +#[tokio::test] +async fn ehlo() { + let mut test = TestServerBuilder::new("smtp_ehlo_test") + .await + .with_http_listener(19005) + .await + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin + .registry_create_object(MtaExtensions { + future_release: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "1h".into(), + }]), + else_: "false".into(), + }, + mt_priority: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "nsep".into(), + }]), + else_: "false".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageEhlo { + reject_non_fqdn: Expression { + else_: "starts_with(remote_ip, '10.0.0.')".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageData { + max_message_size: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "1024".into(), + }]), + else_: "2048".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(SenderAuth { + dmarc_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + reverse_ip_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_ehlo_verify: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "strict".into(), + }]), + else_: "relaxed".into(), + }, + spf_from_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + test.server.txt_add( + "mx1.foobar.org", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "mx2.foobar.org", + Spf::parse(b"v=spf1 ip4:10.0.0.2 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + + // Reject non-FQDN domains + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.stream.tls = false; + session.eval_session_params().await; + session.cmd("EHLO domain", "550 5.5.0").await; + + // EHLO capabilities evaluation + let response = session + .cmd("EHLO mx1.foobar.org", "250") + .await + .assert_contains("SIZE 1024") + .assert_contains("MT-PRIORITY NSEP") + .assert_contains("FUTURERELEASE 3600 ") + .assert_contains("STARTTLS"); + + // The advertised max-future-release-date-time is an RFC 3339 date-time + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) as i64; + let max_datetime = response + .iter() + .find_map(|line| line.split("FUTURERELEASE 3600 ").nth(1)) + .and_then(|v| v.split_whitespace().next()) + .and_then(DateTime::parse_rfc3339) + .expect("FUTURERELEASE did not advertise an RFC 3339 date-time") + .to_timestamp(); + assert!( + ((now + 3595)..=(now + 3605)).contains(&max_datetime), + "unexpected max-future-release-date-time {max_datetime}, now is {now}" + ); + + // SPF should be a Pass for 10.0.0.1 + assert_eq!( + session.data.spf_ehlo.as_ref().unwrap().result(), + SpfResult::Pass + ); + + // Test SPF strict mode + session.data.helo_domain = "".into(); + session.data.remote_ip_str = "10.0.0.2".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.stream.tls = true; + session.eval_session_params().await; + session.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap(); + session.response().assert_code("550 5.7.23"); + + // EHLO capabilities evaluation + session.ingest(b"EHLO mx2.foobar.org\r\n").await.unwrap(); + assert_eq!( + session.data.spf_ehlo.as_ref().unwrap().result(), + SpfResult::Pass + ); + session + .response() + .assert_code("250") + .assert_contains("SIZE 2048") + .assert_not_contains("MT-PRIORITY") + .assert_not_contains("FUTURERELEASE") + .assert_not_contains("STARTTLS"); +} diff --git a/tests/src/smtp/inbound/limits.rs b/tests/src/smtp/inbound/limits.rs new file mode 100644 index 0000000..e8eedd6 --- /dev/null +++ b/tests/src/smtp/inbound/limits.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::server::TestServerBuilder, +}; +use registry::{ + schema::structs::{Expression, ExpressionMatch, MtaInboundSession}, + types::list::List, +}; +use std::time::{Duration, Instant}; + +#[tokio::test] +async fn limits() { + let mut test = TestServerBuilder::new("smtp_inbound_limits_test") + .await + .with_http_listener(19013) + .await + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin + .registry_create_object(MtaInboundSession { + max_duration: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.3'".into(), + then: "500ms".into(), + }]), + else_: "60m".into(), + }, + timeout: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "500ms".into(), + }]), + else_: "30m".into(), + }, + transfer_limit: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "10".into(), + }]), + else_: "1024".into(), + }, + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + // Exceed max line length + let (mut session, _tx) = test.new_mta_session_with_shutdown(); + session.data.remote_ip_str = "10.0.0.1".into(); + let mut buf = vec![b'A'; 4097]; + session.ingest(&buf).await.unwrap(); + session.ingest(b"\r\n").await.unwrap(); + session.response().assert_code("554 5.3.4"); + + // Invalid command + buf.extend_from_slice(b"\r\n"); + session.ingest(&buf).await.unwrap(); + session.response().assert_code("500 5.5.1"); + + // Exceed transfer quota + session.eval_session_params().await; + session.write_rx("MAIL FROM:\r\n"); + session.handle_conn().await; + session.response().assert_code("452 4.7.28"); + + // Loitering + session.data.remote_ip_str = "10.0.0.3".into(); + session.data.valid_until = Instant::now(); + session.eval_session_params().await; + tokio::time::sleep(Duration::from_millis(600)).await; + session.write_rx("MAIL FROM:\r\n"); + session.handle_conn().await; + session.response().assert_code("421 4.3.2"); + + // Timeout + session.data.remote_ip_str = "10.0.0.2".into(); + session.data.valid_until = Instant::now(); + session.eval_session_params().await; + session.write_rx("MAIL FROM:\r\n"); + session.handle_conn().await; + session.response().assert_code("221 2.0.0"); +} diff --git a/tests/src/smtp/inbound/mail.rs b/tests/src/smtp/inbound/mail.rs new file mode 100644 index 0000000..9e89128 --- /dev/null +++ b/tests/src/smtp/inbound/mail.rs @@ -0,0 +1,423 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{IprevResult, SpfResult, common::parse::TxtRecordParser, spf::Spf}; +use mail_parser::DateTime; +use registry::{ + schema::{ + enums::MtaInboundThrottleKey, + structs::{ + Expression, ExpressionMatch, MtaExtensions, MtaInboundThrottle, MtaStageData, + MtaStageEhlo, MtaStageMail, Rate, SenderAuth, + }, + }, + types::{list::List, map::Map}, +}; +use smtp_proto::{MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_REQUIRETLS}; +use std::time::{Duration, Instant, SystemTime}; + +#[tokio::test] +async fn mail() { + let mut test = TestServerBuilder::new("smtp_mail_from_test") + .await + .with_http_listener(19003) + .await + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin + .registry_create_object(MtaStageEhlo { + require: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin + .registry_create_object(SenderAuth { + reverse_ip_verify: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "strict".into(), + }]), + else_: "relaxed".into(), + }, + spf_ehlo_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_from_verify: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "strict".into(), + }]), + else_: "relaxed".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaExtensions { + deliver_by: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "1d".into(), + }]), + else_: "false".into(), + }, + future_release: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "1d".into(), + }]), + else_: "false".into(), + }, + mt_priority: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "nsep".into(), + }]), + else_: "false".into(), + }, + require_tls: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageMail { + is_sender_allowed: Expression { + else_: "sender_domain != 'blocked.com'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageData { + max_message_size: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.2'".into(), + then: "2048".into(), + }]), + else_: "1024".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaInboundThrottle { + description: "Test throttle".into(), + enable: true, + key: Map::new(vec![MtaInboundThrottleKey::Sender]), + match_: Expression { + else_: "remote_ip = '10.0.0.1'".into(), + ..Default::default() + }, + rate: Rate { + count: 2, + period: 1000u64.into(), + }, + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + test.server.txt_add( + "foobar.org", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "mx1.foobar.org", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.ptr_add( + "10.0.0.1".parse().unwrap(), + vec!["mx1.foobar.org.".to_string()], + Instant::now() + Duration::from_secs(5), + ); + test.server.ipv4_add( + "mx1.foobar.org.", + vec!["10.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(5), + ); + test.server.ptr_add( + "10.0.0.2".parse().unwrap(), + vec!["mx2.foobar.org.".to_string()], + Instant::now() + Duration::from_secs(5), + ); + + // Be rude and do not say EHLO + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.eval_session_params().await; + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("503 5.5.1"); + + // Test sender not allowed + session.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap(); + session.response().assert_code("250"); + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("550 5.7.1"); + + // Both IPREV and SPF should pass + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert_eq!( + session.data.spf_ehlo.as_ref().unwrap().result(), + SpfResult::Pass + ); + assert_eq!( + session.data.spf_mail_from.as_ref().unwrap().result(), + SpfResult::Pass + ); + assert_eq!( + session.data.iprev.as_ref().unwrap().result(), + &IprevResult::Pass + ); + + // Multiple MAIL FROMs should not be allowed + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("503 5.5.1"); + + // Test rate limit + for n in 0..2 { + session.rset().await; + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session + .response() + .assert_code(if n == 0 { "250" } else { "452 4.4.5" }); + } + + // Test disabled extensions + for param in [ + "HOLDFOR=123", + "HOLDUNTIL=2079-11-20T05:00:00Z", + "MT-PRIORITY=3", + "BY=120;R", + "REQUIRETLS", + ] { + session + .ingest(format!("MAIL FROM: {param}\r\n").as_bytes()) + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + } + + // Test size with a large value + session + .ingest(b"MAIL FROM: SIZE=1512\r\n") + .await + .unwrap(); + session.response().assert_code("552 5.3.4"); + + // Test strict IPREV + session.data.remote_ip_str = "10.0.0.2".into(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + session.data.iprev = None; + session.eval_session_params().await; + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("550 5.7.25"); + session.data.iprev = None; + test.server.ipv4_add( + "mx2.foobar.org.", + vec!["10.0.0.2".parse().unwrap()], + Instant::now() + Duration::from_secs(5), + ); + + // Test strict SPF + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("550 5.7.23"); + test.server.txt_add( + "foobar.org", + Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + let mail_from = session.data.mail_from.as_ref().unwrap(); + assert_eq!(mail_from.domain, "foobar.org"); + assert_eq!(mail_from.address, "Jane@FooBar.org"); + assert_eq!(mail_from.address_lcase, "jane@foobar.org"); + session.rset().await; + + // Test SIZE extension + session + .ingest(b"MAIL FROM: SIZE=1023\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + session.rset().await; + + // Test MT-PRIORITY extension + session + .ingest(b"MAIL FROM: MT-PRIORITY=-3\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert_eq!(session.data.priority, -3); + session.rset().await; + + // Test REQUIRETLS extension + session + .ingest(b"MAIL FROM: REQUIRETLS\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_REQUIRETLS) != 0); + session.rset().await; + + // Test DELIVERBY extension with by-mode=R + session + .ingest(b"MAIL FROM: BY=120;R\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_BY_RETURN) != 0); + assert_eq!(session.data.delivery_by, 120); + session.rset().await; + + // Test DELIVERBY extension with by-mode=N + session + .ingest(b"MAIL FROM: BY=-456;N\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_BY_NOTIFY) != 0); + assert_eq!(session.data.delivery_by, -456); + session.rset().await; + + // Test DELIVERBY extension with invalid by-mode=R + session + .ingest(b"MAIL FROM: BY=-1;R\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + session + .ingest(b"MAIL FROM: BY=99999;R\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with HOLDFOR + session + .ingest(b"MAIL FROM: HOLDFOR=1234\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + assert_eq!(session.data.future_release, 1234); + session.rset().await; + + // Test FUTURERELEASE extension with invalid HOLDFOR falue + session + .ingest(b"MAIL FROM: HOLDFOR=99999\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with HOLDUNTIL + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let hold_until = |offset: u64| { + format!( + "MAIL FROM: HOLDUNTIL={}\r\n", + DateTime::from_timestamp((now + offset) as i64).to_rfc3339() + ) + }; + session.ingest(hold_until(10).as_bytes()).await.unwrap(); + session.response().assert_code("250"); + assert!((9..=10).contains(&session.data.future_release)); + session.rset().await; + + // Test FUTURERELEASE extension with invalid HOLDUNTIL value + session.ingest(hold_until(99999).as_bytes()).await.unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with a HOLDUNTIL value that is not an RFC 3339 date-time + session + .ingest(format!("MAIL FROM: HOLDUNTIL={}\r\n", now + 10).as_bytes()) + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with a HOLDUNTIL value in the past + session + .ingest(b"MAIL FROM: HOLDUNTIL=2020-01-01T00:00:00Z\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with both HOLDFOR and HOLDUNTIL + session + .ingest( + format!( + "MAIL FROM: HOLDFOR=1234 HOLDUNTIL={}\r\n", + DateTime::from_timestamp((now + 10) as i64).to_rfc3339() + ) + .as_bytes(), + ) + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; + + // Test FUTURERELEASE extension with a HOLDFOR value that is not a positive integer + session + .ingest(b"MAIL FROM: HOLDFOR=0\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + session.rset().await; +} diff --git a/tests/src/smtp/inbound/milter.rs b/tests/src/smtp/inbound/milter.rs new file mode 100644 index 0000000..3fc4443 --- /dev/null +++ b/tests/src/smtp/inbound/milter.rs @@ -0,0 +1,971 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse, load_test_message}, + }, + utils::server::TestServerBuilder, +}; +use ahash::AHashSet; +use common::{ + config::smtp::session::{Milter, MilterVersion, Stage}, + expr::if_block::IfBlock, + manager::application::Resource, +}; +use http_proto::{ToHttpResponse, request::fetch_body}; +use hyper::{body, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use mail_auth::AuthenticatedMessage; +use mail_parser::MessageParser; +use registry::{ + schema::{ + enums::{self, MtaStage}, + prelude::{ObjectType, Property}, + structs::{Expression, MtaHook, MtaMilter, MtaStageRcpt}, + }, + types::map::Map, +}; +use serde::Deserialize; +use smtp::{ + core::SessionData, + inbound::{ + hooks::{self, Request, SmtpResponse}, + milter::{ + Action, Command, Macros, MilterClient, Modification, Options, Response, + receiver::{FrameResult, Receiver}, + }, + }, +}; +use std::{fs, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::watch, +}; + +#[derive(Debug, Deserialize)] +struct HeaderTest { + modifications: Vec, + result: String, +} + +#[tokio::test] +async fn milter_session() { + let mut test = TestServerBuilder::new("smtp_milter_test") + .await + .with_http_listener(19014) + .await + .capture_queue() + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin.mta_allow_relaying().await; + admin + .registry_create_object(MtaMilter { + enable: Expression { + else_: "true".into(), + ..Default::default() + }, + hostname: "127.0.0.1".into(), + port: 9332, + use_tls: false, + stages: Map::new(vec![MtaStage::Data]), + protocol_version: enums::MilterVersion::V6, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + let _rx = spawn_mock_milter_server(); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Build session + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.doe.org").await; + + // Test reject + session + .send_message( + "reject@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "503 5.5.3", + ) + .await; + test.assert_no_events(); + + // Test discard + session + .send_message( + "discard@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.assert_no_events(); + + // Test temp fail + session + .send_message( + "temp_fail@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "451 4.3.5", + ) + .await; + test.assert_no_events(); + + // Test shutdown + session + .send_message( + "shutdown@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "421 4.3.0", + ) + .await; + test.assert_no_events(); + + // Test reply code + session + .send_message( + "reply_code@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "321", + ) + .await; + test.assert_no_events(); + + // Test accept with header addition + session + .send_message( + "0@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("X-Hello: World") + .assert_contains("Subject: Is dinner ready?") + .assert_contains("Are you hungry yet?"); + + // Test accept with header replacement + session + .send_message( + "3@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("Subject: [SPAM] Saying Hello") + .assert_count("References: ", 1) + .assert_contains("Are you hungry yet?"); + + // Test accept with body replacement + session + .send_message( + "2@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("X-Spam: Yes") + .assert_contains("123456"); +} + +#[tokio::test] +async fn mta_hook_session() { + let mut test = TestServerBuilder::new("smtp_mta_hook_test") + .await + .with_http_listener(19015) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin + .registry_create_object(MtaStageRcpt { + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaHook { + enable: Expression { + else_: "true".into(), + ..Default::default() + }, + url: "http://127.0.0.1:9333".into(), + stages: Map::new(vec![MtaStage::Data]), + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + let _rx = spawn_mock_mta_hook_server(); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Build session + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.doe.org").await; + + // Test reject + session + .send_message( + "reject@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "503 5.5.3", + ) + .await; + test.assert_no_events(); + + // Test discard + session + .send_message( + "discard@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.assert_no_events(); + + // Test temp fail + session + .send_message( + "temp_fail@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "451 4.3.5", + ) + .await; + test.assert_no_events(); + + // Test shutdown + session + .send_message( + "shutdown@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "421 4.3.0", + ) + .await; + test.assert_no_events(); + + // Test reply code + session + .send_message( + "reply_code@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "321", + ) + .await; + test.assert_no_events(); + + // Test accept with header addition + session + .send_message( + "0@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("X-Hello: World") + .assert_contains("Subject: Is dinner ready?") + .assert_contains("Are you hungry yet?"); + + // Test accept with header replacement + session + .send_message( + "3@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("Subject: [SPAM] Saying Hello") + .assert_count("References: ", 1) + .assert_contains("Are you hungry yet?"); + + // Test accept with body replacement + session + .send_message( + "2@doe.org", + &["bill@foobar.org"], + "test:no_dkim", + "250 2.0.0", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("X-Spam: Yes") + .assert_contains("123456"); +} + +#[test] +fn milter_address_modifications() { + let test_message = fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("milter") + .join("message.eml"), + ) + .unwrap(); + let parsed_test_message = AuthenticatedMessage::parse(test_message.as_bytes()).unwrap(); + + let mut data = SessionData::new( + "127.0.0.1".parse().unwrap(), + 0, + "127.0.0.1".parse().unwrap(), + 0, + Default::default(), + 0, + ); + + // ChangeFrom + assert!( + data.apply_milter_modifications( + vec![Modification::ChangeFrom { + sender: "<>".into(), + args: "".into(), + }], + &parsed_test_message + ) + .is_none() + ); + let addr = data.mail_from.as_ref().unwrap(); + assert_eq!(addr.address_lcase, ""); + assert_eq!(addr.dsn_info, None); + assert_eq!(addr.flags, 0); + + // ChangeFrom with parameters + assert!( + data.apply_milter_modifications( + vec![Modification::ChangeFrom { + sender: "john@example.org".into(), + args: "REQUIRETLS ENVID=abc123".into(), //"NOTIFY=SUCCESS,FAILURE ENVID=abc123\n".into() + }], + &parsed_test_message + ) + .is_none() + ); + let addr = data.mail_from.as_ref().unwrap(); + assert_eq!(addr.address_lcase, "john@example.org"); + assert_ne!(addr.flags, 0); + assert_eq!(addr.dsn_info, Some("abc123".into())); + + // Add recipients + assert!( + data.apply_milter_modifications( + vec![ + Modification::AddRcpt { + recipient: "bill@example.org".into(), + args: "".into(), + }, + Modification::AddRcpt { + recipient: "jane@foobar.org".into(), + args: "NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;Jane.Doe@Foobar.org".into(), + }, + Modification::AddRcpt { + recipient: "".into(), + args: "".into(), + }, + Modification::AddRcpt { + recipient: "<>".into(), + args: "".into(), + }, + ], + &parsed_test_message + ) + .is_none() + ); + assert_eq!(data.rcpt_to.len(), 2); + let addr = data.rcpt_to.first().unwrap(); + assert_eq!(addr.address_lcase, "bill@example.org"); + assert_eq!(addr.dsn_info, None); + assert_eq!(addr.flags, 0); + let addr = data.rcpt_to.last().unwrap(); + assert_eq!(addr.address_lcase, "jane@foobar.org"); + assert_ne!(addr.flags, 0); + assert_eq!(addr.dsn_info, Some("Jane.Doe@Foobar.org".into())); + + // Remove recipients + assert!( + data.apply_milter_modifications( + vec![ + Modification::DeleteRcpt { + recipient: "bill@example.org".into(), + }, + Modification::DeleteRcpt { + recipient: "<>".into(), + }, + ], + &parsed_test_message + ) + .is_none() + ); + assert_eq!(data.rcpt_to.len(), 1); + let addr = data.rcpt_to.last().unwrap(); + assert_eq!(addr.address_lcase, "jane@foobar.org"); + assert_ne!(addr.flags, 0); + assert_eq!(addr.dsn_info, Some("Jane.Doe@Foobar.org".into())); +} + +#[test] +fn milter_message_modifications() { + // Read test message + let milter_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("milter"); + let test_message = fs::read_to_string(milter_path.join("message.eml")).unwrap(); + let tests = serde_json::from_str::>( + &fs::read_to_string(milter_path.join("message.json")).unwrap(), + ) + .unwrap(); + let parsed_test_message = AuthenticatedMessage::parse(test_message.as_bytes()).unwrap(); + let mut session_data = SessionData::new( + "127.0.0.1".parse().unwrap(), + 0, + "127.0.0.1".parse().unwrap(), + 0, + Default::default(), + 0, + ); + + for test in tests { + assert_eq!( + test.result, + String::from_utf8( + session_data + .apply_milter_modifications(test.modifications, &parsed_test_message) + .unwrap() + ) + .unwrap() + ) + } +} + +#[test] +fn milter_frame_receiver() { + let mut stream = Vec::new(); + + for i in 0u32..100u32 { + stream.extend_from_slice((i + 1).to_be_bytes().as_ref()); + stream.push(i as u8); + for v in 0..i { + stream.push(v as u8); + } + } + + for chunk_size in [stream.len(), 1, 2, 3, 4, 10, 20, 30, 40, 100, 200, 300, 400] { + let mut receiver = Receiver::with_max_frame_len(100); + let mut frame_num = 0; + + 'outer: for chunk in stream.chunks(chunk_size) { + loop { + match receiver.read_frame(chunk) { + FrameResult::Frame(bytes) => { + /*println!( + "frame {frame_num}, chunk: {chunk_size}, {}", + if matches!(bytes, std::borrow::Cow::Borrowed(_)) { + "borrowed" + } else { + "owned" + } + );*/ + assert_eq!(*bytes.first().unwrap(), frame_num); + assert_eq!(bytes.len(), frame_num as usize + 1); + frame_num += 1; + } + FrameResult::Incomplete => continue 'outer, + FrameResult::TooLarge(size) => { + panic!("Frame too large: {size}") + } + } + } + } + + assert_eq!(frame_num, 100, "chunk_size: {}", chunk_size); + } +} + +#[tokio::test] +#[ignore] +async fn milter_client_test() { + //const PORT : u16 = 11332; + const PORT: u16 = 7357; + let mut client = MilterClient::connect( + &Milter { + enable: IfBlock::empty(ObjectType::MtaMilter.singleton(), Property::Enable), + id: ObjectType::MtaMilter.singleton(), + addrs: vec![SocketAddr::from(([127, 0, 0, 1], PORT))], + hostname: "localhost".into(), + port: PORT, + timeout_connect: Duration::from_secs(10), + timeout_command: Duration::from_secs(30), + timeout_data: Duration::from_secs(30), + tls: false, + tls_allow_invalid_certs: false, + tempfail_on_error: false, + max_frame_len: 5000000, + protocol_version: MilterVersion::V6, + flags_actions: None, + flags_protocol: None, + run_on_stage: AHashSet::from([Stage::Data]), + }, + 0, + ) + .await + .unwrap(); + client.init().await.unwrap(); + + let raw_message = load_test_message("arc", "messages"); + let message = MessageParser::new().parse(raw_message.as_bytes()).unwrap(); + + let r = client + .connection( + "gmail.com", + "127.0.0.1".parse().unwrap(), + 1235, + Macros::new(), + ) + .await + .unwrap(); + println!("CONNECT: {:?}", r); + let r = client + .mail_from("john@gmail.com", None::<&[&str]>, Macros::new()) + .await + .unwrap(); + println!("MAIL FROM: {:?}", r); + let r = client + .rcpt_to("user@gmail.com", None::<&[&str]>, Macros::new()) + .await + .unwrap(); + println!("RCPT TO: {:?}", r); + + let r = client.data().await.unwrap(); + println!("DATA: {:?}", r); + let r = client.headers(message.headers_raw()).await.unwrap(); + println!("HEADERS: {:?}", r); + let r = client + .body(&message.raw_message()[message.root_part().raw_body_offset() as usize..]) + .await + .unwrap(); + println!("BODY: {:?}", r); + + client.quit().await.unwrap(); +} + +pub fn spawn_mock_milter_server() -> watch::Sender { + let (tx, rx) = watch::channel(true); + let tests = Arc::new( + serde_json::from_str::>( + &fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("milter") + .join("message.json"), + ) + .unwrap(), + ) + .unwrap(), + ); + + tokio::spawn(async move { + let listener = TcpListener::bind("127.0.0.1:9332") + .await + .unwrap_or_else(|e| { + panic!("Failed to bind mock Milter server to 127.0.0.1:9332: {e}"); + }); + let mut rx_ = rx.clone(); + //println!("Mock Milter server listening on port 9332"); + loop { + tokio::select! { + stream = listener.accept() => { + match stream { + Ok((stream, _)) => { + tokio::spawn(accept_milter(stream, rx.clone(), tests.clone())); + } + Err(err) => { + panic!("Something went wrong: {err}" ); + } + } + }, + _ = rx_.changed() => { + //println!("Mock Milter server stopping"); + break; + } + }; + } + }); + + tx +} + +async fn accept_milter( + mut stream: TcpStream, + mut rx: watch::Receiver, + tests: Arc>, +) { + let mut buf = vec![0u8; 1024]; + let mut receiver = Receiver::with_max_frame_len(5000000); + let mut action = None; + let mut modifications = None; + + 'outer: loop { + let br = tokio::select! { + br = stream.read(&mut buf) => { + match br { + Ok(br) => { + br + } + Err(_) => { + break; + } + } + }, + _ = rx.changed() => { + break; + } + }; + + if br == 0 { + break; + } + + loop { + match receiver.read_frame(&buf[..br]) { + FrameResult::Frame(bytes) => { + let cmd = Command::deserialize(bytes.as_ref()); + println!("CMD: {cmd}"); + + let response = match cmd { + Command::Abort | Command::Macro { .. } => continue, + Command::Body { .. } + | Command::Data + | Command::Connect { .. } + | Command::Header { .. } + | Command::Helo { .. } + | Command::Rcpt { .. } + | Command::QuitNewConnection + | Command::EndOfHeader => Response::Action(Action::Accept), + Command::OptionNegotiation(_) => Response::OptionNegotiation(Options { + version: 6, + actions: 0, + protocol: 0, + }), + Command::MailFrom { sender, .. } => { + let sender = std::str::from_utf8(sender).unwrap(); + action = match sender + .strip_prefix('<') + .unwrap() + .split_once('@') + .unwrap() + .0 + { + "accept" => Action::Accept, + "reject" => Action::Reject, + "discard" => Action::Discard, + "temp_fail" => Action::TempFail, + "shutdown" => Action::Shutdown, + "conn_fail" => Action::ConnectionFailure, + "reply_code" => Action::ReplyCode { + code: *b"321", + text: "test".into(), + }, + test_num => { + modifications = tests[test_num.parse::().unwrap()] + .modifications + .clone() + .into(); + Action::Accept + } + } + .into(); + Response::Action(Action::Accept) + } + Command::Quit => break 'outer, + Command::EndOfBody => { + if let Some(modifications) = modifications.take() { + for modification in modifications { + // Write modifications + stream + .write_all( + &Response::Modification(modification).serialize(), + ) + .await + .unwrap(); + } + } + + Response::Action(action.take().unwrap()) + } + }; + + // Write response + stream.write_all(&response.serialize()).await.unwrap(); + } + FrameResult::Incomplete => continue 'outer, + FrameResult::TooLarge(size) => { + panic!("Frame too large: {size}") + } + } + } + } +} + +pub fn spawn_mock_mta_hook_server() -> watch::Sender { + let (tx, rx) = watch::channel(true); + let tests = Arc::new( + serde_json::from_str::>( + &fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("milter") + .join("message.json"), + ) + .unwrap(), + ) + .unwrap(), + ); + + tokio::spawn(async move { + let listener = TcpListener::bind("127.0.0.1:9333") + .await + .unwrap_or_else(|e| { + panic!("Failed to bind mock Milter server to 127.0.0.1:9333: {e}"); + }); + let mut rx_ = rx.clone(); + //println!("Mock jMilter server listening on port 9333"); + loop { + tokio::select! { + stream = listener.accept() => { + match stream { + Ok((stream, _)) => { + + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(stream), + service_fn(|mut req: hyper::Request| { + let tests = tests.clone(); + + async move { + + let request = serde_json::from_slice::(&fetch_body(&mut req, 1024 * 1024,0).await.unwrap()) + .unwrap(); + let response = handle_mta_hook(request, tests); + + Ok::<_, hyper::Error>( + Resource::new("application/json", serde_json::to_string(&response).unwrap().into_bytes()) + .into_http_response().build(), + ) + } + }), + ) + .await; + } + Err(err) => { + panic!("Something went wrong: {err}" ); + } + } + }, + _ = rx_.changed() => { + //println!("Mock jMilter server stopping"); + break; + } + }; + } + }); + + tx +} + +fn handle_mta_hook(request: Request, tests: Arc>) -> hooks::Response { + match request + .envelope + .unwrap() + .from + .address + .split_once('@') + .unwrap() + .0 + { + "accept" => hooks::Response { + action: hooks::Action::Accept, + response: None, + modifications: vec![], + }, + "reject" => hooks::Response { + action: hooks::Action::Reject, + response: None, + modifications: vec![], + }, + "discard" => hooks::Response { + action: hooks::Action::Discard, + response: None, + modifications: vec![], + }, + "temp_fail" => hooks::Response { + action: hooks::Action::Reject, + response: SmtpResponse { + status: 451.into(), + enhanced_status: Some("4.3.5".into()), + message: Some("Unable to accept message at this time.".into()), + disconnect: false, + } + .into(), + modifications: vec![], + }, + "shutdown" => hooks::Response { + action: hooks::Action::Reject, + response: SmtpResponse { + status: 421.into(), + enhanced_status: Some("4.3.0".into()), + message: Some("Server shutting down".into()), + disconnect: false, + } + .into(), + modifications: vec![], + }, + "conn_fail" => hooks::Response { + action: hooks::Action::Accept, + response: SmtpResponse { + disconnect: true, + ..Default::default() + } + .into(), + modifications: vec![], + }, + "reply_code" => hooks::Response { + action: hooks::Action::Reject, + response: SmtpResponse { + status: 321.into(), + enhanced_status: Some("3.1.1".into()), + message: Some("Test".into()), + disconnect: false, + } + .into(), + modifications: vec![], + }, + test_num => hooks::Response { + action: hooks::Action::Accept, + response: None, + modifications: tests[test_num.parse::().unwrap()] + .modifications + .iter() + .map(|m| match m { + Modification::ChangeFrom { sender, args } => hooks::Modification::ChangeFrom { + value: sender.clone(), + parameters: args + .split_whitespace() + .map(|arg| { + let (key, value) = arg.split_once('=').unwrap(); + (key.into(), Some(value.into())) + }) + .collect(), + }, + Modification::AddRcpt { recipient, args } => { + hooks::Modification::AddRecipient { + value: recipient.clone(), + parameters: args + .split_whitespace() + .map(|arg| { + let (key, value) = arg.split_once('=').unwrap(); + (key.into(), Some(value.into())) + }) + .collect(), + } + } + Modification::DeleteRcpt { recipient } => { + hooks::Modification::DeleteRecipient { + value: recipient.clone(), + } + } + Modification::ReplaceBody { value } => hooks::Modification::ReplaceContents { + value: String::from_utf8(value.clone()).unwrap(), + }, + Modification::AddHeader { name, value } => hooks::Modification::AddHeader { + name: name.clone(), + value: value.clone(), + }, + Modification::InsertHeader { index, name, value } => { + hooks::Modification::InsertHeader { + index: *index, + name: name.clone(), + value: value.clone(), + } + } + Modification::ChangeHeader { index, name, value } => { + hooks::Modification::ChangeHeader { + index: *index, + name: name.clone(), + value: value.clone(), + } + } + Modification::Quarantine { reason } => hooks::Modification::AddHeader { + name: "X-Quarantine".into(), + value: reason.clone(), + }, + }) + .collect(), + }, + } +} diff --git a/tests/src/smtp/inbound/mod.rs b/tests/src/smtp/inbound/mod.rs new file mode 100644 index 0000000..a3048dc --- /dev/null +++ b/tests/src/smtp/inbound/mod.rs @@ -0,0 +1,411 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use common::{ + config::smtp::queue::QueueName, + ipc::{DmarcEvent, QueueEvent, QueueEventStatus, ReportingEvent, TlsEvent}, +}; +use registry::{schema::prelude::ObjectType, types::ObjectImpl}; +use smtp::queue::{Message, MessageWrapper, QueueId, QueuedMessage}; +use std::time::Duration; +use store::{ + Deserialize, IterateParams, U64_LEN, ValueKey, + write::{AlignedBytes, Archive, QueueClass, ValueClass, key::DeserializeBigEndian}, +}; +use tokio::sync::mpsc::error::TryRecvError; +use types::id::Id; + +pub mod antispam; +pub mod asn; +pub mod auth; +pub mod basic; +pub mod data; +pub mod dkim2; +pub mod dmarc; +pub mod ehlo; +pub mod limits; +pub mod mail; +pub mod milter; +pub mod rcpt; +pub mod rewrite; +pub mod scripts; +pub mod sign; +pub mod throttle; +pub mod vrfy; + +const EVENT_TIMEOUT: Duration = Duration::from_secs(5); + +impl TestServer { + pub async fn read_event(&mut self) -> QueueEvent { + if let Some(event) = self.queue_events.pop_front() { + return event; + } + + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => panic!("Channel closed."), + Err(_) => panic!("No queue event received."), + } + } + + pub async fn read_event_matching( + &mut self, + expected: impl Fn(&QueueEvent) -> bool, + ) -> QueueEvent { + if let Some(idx) = self.queue_events.iter().position(&expected) { + return self.queue_events.remove(idx).unwrap(); + } + + loop { + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) => { + if expected(&event) { + return event; + } + self.queue_events.push_back(event); + } + Ok(None) => panic!("Channel closed."), + Err(_) => panic!( + "No matching queue event received, pending events: {:?}", + self.queue_events + ), + } + } + } + + pub async fn try_read_event(&mut self) -> Option { + if let Some(event) = self.queue_events.pop_front() { + return Some(event); + } + + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) => Some(event), + Ok(None) => panic!("Channel closed."), + Err(_) => None, + } + } + + pub fn assert_no_events(&mut self) { + if let Some(event) = self.queue_events.pop_front() { + panic!("Expected empty queue but got {event:?}"); + } + + match self.queue_rx.try_recv() { + Err(TryRecvError::Empty) => (), + Ok(event) => panic!("Expected empty queue but got {event:?}"), + Err(err) => panic!("Queue error: {err:?}"), + } + } + + pub async fn assert_queue_is_empty(&self) { + assert_eq!(self.read_queued_messages().await, vec![]); + assert_eq!(self.read_queued_events().await, vec![]); + } + + pub async fn assert_report_is_empty(&self) { + assert_eq!(self.read_report_events::().await, vec![]); + } + + pub async fn expect_reload_settings(&mut self) { + self.read_event_matching(QueueEvent::is_reload_settings) + .await; + } + + pub async fn expect_refresh(&mut self) { + self.read_event_matching(QueueEvent::is_refresh).await; + } + + pub async fn expect_message(&mut self) -> MessageWrapper { + self.expect_refresh().await; + self.last_queued_message().await + } + + pub async fn consume_message(&mut self) -> MessageWrapper { + self.expect_refresh().await; + let message = self.last_queued_message().await; + message + .clone() + .remove(&self.server, self.last_queued_due().await.into()) + .await; + message + } + + pub async fn expect_message_then_deliver(&mut self) -> QueuedMessage { + let message = self.expect_message().await; + + self.delivery_attempt(message.queue_id).await + } + + pub async fn delivery_attempt(&mut self, queue_id: u64) -> QueuedMessage { + QueuedMessage { + due: self.message_due(queue_id).await, + queue_id, + queue_name: QueueName::new("remote").unwrap(), + } + } + + pub async fn expect_message_for_queue_then_deliver( + &mut self, + queue_name: &str, + ) -> QueuedMessage { + let message = self.expect_message().await; + + self.delivery_attempt_for_queue(message.queue_id, queue_name) + .await + } + + pub async fn delivery_attempt_for_queue( + &mut self, + queue_id: u64, + queue_name: &str, + ) -> QueuedMessage { + QueuedMessage { + due: self.message_due(queue_id).await, + queue_id, + queue_name: QueueName::new(queue_name).unwrap(), + } + } + + pub async fn read_queued_events(&self) -> Vec { + let mut events = Vec::new(); + + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: 0, + queue_id: 0, + queue_name: [0; 8], + }, + ))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: u64::MAX, + queue_id: u64::MAX, + queue_name: [u8::MAX; 8], + }, + ))); + + self.server + .store() + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + events.push(store::write::QueueEvent { + due: key.deserialize_be_u64(0)?, + queue_id: key.deserialize_be_u64(U64_LEN)?, + queue_name: key[U64_LEN + 1..U64_LEN + 9] + .try_into() + .expect("Queue name must be 8 bytes"), + }); + Ok(true) + }, + ) + .await + .unwrap(); + + events + } + + pub async fn read_queued_messages(&self) -> Vec { + let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0))); + let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))); + let mut messages = Vec::new(); + + self.server + .store() + .iterate( + IterateParams::new(from_key, to_key).descending(), + |key, value| { + messages.push(MessageWrapper { + queue_id: key.deserialize_be_u64(0)?, + queue_name: Default::default(), + is_multi_queue: false, + span_id: 0, + message: as Deserialize>::deserialize(value)? + .deserialize::()?, + }); + Ok(true) + }, + ) + .await + .unwrap(); + + messages + } + + pub async fn read_report_events(&self) -> Vec<(Id, T)> { + self.account("admin").registry_get_all().await + } + + pub async fn last_queued_message(&self) -> MessageWrapper { + self.read_queued_messages() + .await + .into_iter() + .next() + .expect("No messages found in queue") + } + + pub async fn last_queued_due(&self) -> u64 { + self.message_due(self.last_queued_message().await.queue_id) + .await + } + + pub async fn message_due(&self, queue_id: QueueId) -> u64 { + self.read_queued_events() + .await + .iter() + .find_map(|event| { + if event.queue_id == queue_id { + Some(event.due) + } else { + None + } + }) + .expect("No event found in queue for message") + } + + pub async fn clear_queue(&self) { + self.account("admin") + .registry_destroy_all(ObjectType::QueuedMessage) + .await; + } + + pub async fn read_report(&mut self) -> ReportingEvent { + match tokio::time::timeout(EVENT_TIMEOUT, self.report_rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => panic!("Channel closed."), + Err(_) => panic!("No report event received."), + } + } + + pub async fn try_read_report(&mut self) -> Option { + match tokio::time::timeout(EVENT_TIMEOUT, self.report_rx.recv()).await { + Ok(Some(event)) => Some(event), + Ok(None) => panic!("Channel closed."), + Err(_) => None, + } + } + pub fn assert_no_reports(&mut self) { + match self.report_rx.try_recv() { + Err(TryRecvError::Empty) => (), + Ok(event) => panic!("Expected no reports but got {event:?}"), + Err(err) => panic!("Report error: {err:?}"), + } + } +} + +pub trait TestQueueEvent { + fn assert_reload_settings(self); + fn assert_refresh(self); + fn assert_done(self); + fn assert_refresh_or_done(self); + fn is_reload_settings(&self) -> bool; + fn is_refresh(&self) -> bool; +} + +impl TestQueueEvent for QueueEvent { + fn is_reload_settings(&self) -> bool { + matches!(self, QueueEvent::ReloadSettings) + } + + fn is_refresh(&self) -> bool { + matches!( + self, + QueueEvent::Refresh + | QueueEvent::WorkerDone { + status: QueueEventStatus::Deferred, + .. + } + ) + } + + fn assert_refresh(self) { + match self { + QueueEvent::Refresh + | QueueEvent::WorkerDone { + status: QueueEventStatus::Deferred, + .. + } => (), + e => panic!("Unexpected event: {e:?}"), + } + } + + fn assert_reload_settings(self) { + match self { + QueueEvent::ReloadSettings => (), + e => panic!("Unexpected event: {e:?}"), + } + } + + fn assert_done(self) { + match self { + QueueEvent::WorkerDone { + status: QueueEventStatus::Completed, + .. + } => (), + e => panic!("Unexpected event: {e:?}"), + } + } + + fn assert_refresh_or_done(self) { + match self { + QueueEvent::WorkerDone { + status: QueueEventStatus::Completed | QueueEventStatus::Deferred, + .. + } => (), + e => panic!("Unexpected event: {e:?}"), + } + } +} + +pub trait TestReportingEvent { + fn unwrap_dmarc(self) -> Box; + fn unwrap_tls(self) -> Box; +} + +impl TestReportingEvent for ReportingEvent { + fn unwrap_dmarc(self) -> Box { + match self { + ReportingEvent::Dmarc(event) => event, + e => panic!("Unexpected event: {e:?}"), + } + } + + fn unwrap_tls(self) -> Box { + match self { + ReportingEvent::Tls(event) => event, + e => panic!("Unexpected event: {e:?}"), + } + } +} + +#[allow(async_fn_in_trait)] +pub trait TestMessage { + async fn read_message(&self, core: &TestServer) -> String; + async fn read_lines(&self, core: &TestServer) -> Vec; +} + +impl TestMessage for MessageWrapper { + async fn read_message(&self, core: &TestServer) -> String { + String::from_utf8( + core.server + .blob_store() + .get_blob(self.message.blob_hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .expect("Message blob not found"), + ) + .unwrap() + } + + async fn read_lines(&self, core: &TestServer) -> Vec { + self.read_message(core) + .await + .split('\n') + .map(|l| l.to_string()) + .collect() + } +} diff --git a/tests/src/smtp/inbound/rcpt.rs b/tests/src/smtp/inbound/rcpt.rs new file mode 100644 index 0000000..5f0041c --- /dev/null +++ b/tests/src/smtp/inbound/rcpt.rs @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::server::TestServerBuilder, +}; +use registry::{ + schema::{ + enums::MtaInboundThrottleKey, + structs::{ + Expression, ExpressionMatch, MtaExtensions, MtaInboundThrottle, MtaStageRcpt, Rate, + }, + }, + types::{list::List, map::Map}, +}; +use smtp::core::State; +use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS}; +use std::time::Duration; + +#[tokio::test] +async fn rcpt() { + let mut test = TestServerBuilder::new("smtp_rcpt_test") + .await + .with_http_listener(18999) + .await + .disable_services() + .build() + .await; + + // Create test users + let admin = test.account("admin"); + for (name, secret, description, aliases) in [ + ("john@foobar.org", "12345 + extra safety", "John Doe", &[]), + ("jane@foobar.org", "abcde + extra safety", "Jane Smith", &[]), + ( + "bill@foobar.org", + "p4ssw0rd + extra safety", + "Bill Foobar", + &[], + ), + ( + "mike@foobar.org", + "p4ssw0rd + extra safety", + "Mike Foobar", + &[], + ), + ( + "cornelius@straß6.de", + "p4ssw0rd + extra safety", + "Cornelius Strauss", + &[], + ), + ] { + admin + .create_user_account(name, secret, description, aliases, vec![]) + .await; + } + + // Add test settings + admin.mta_no_auth().await; + admin + .registry_create_object(MtaStageRcpt { + allow_relaying: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "false".into(), + }]), + else_: "true".into(), + }, + max_failures: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "3".into(), + }]), + else_: "100".into(), + }, + max_recipients: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "3".into(), + }]), + else_: "5".into(), + }, + wait_on_fail: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "5ms".into(), + }]), + else_: "1s".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaExtensions { + dsn: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "false".into(), + }]), + else_: "true".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaInboundThrottle { + description: "Test throttle".into(), + enable: true, + key: Map::new(vec![MtaInboundThrottleKey::Sender]), + match_: Expression { + else_: "remote_ip = '10.0.0.1' && !is_empty(rcpt)".into(), + ..Default::default() + }, + rate: Rate { + count: 2, + period: 1000u64.into(), + }, + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + // RCPT without MAIL FROM + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx1.foobar.org").await; + session.rcpt_to("jane@foobar.org", "503 5.5.1").await; + + // Relaying is disabled for 10.0.0.1 + session.mail_from("john@example.net", "250").await; + session.rcpt_to("external@domain.com", "550 5.1.2").await; + + // DSN is disabled for 10.0.0.1 + session + .ingest(b"RCPT TO: NOTIFY=SUCCESS,FAILURE,DELAY\r\n") + .await + .unwrap(); + session.response().assert_code("501 5.5.4"); + + // Send to non-existing user + session.rcpt_to("tom@foobar.org", "550 5.1.2").await; + + // Exceeding max number of errors + session + .ingest(b"RCPT TO:\r\n") + .await + .unwrap_err(); + session.response().assert_code("451 4.3.0"); + + // Rate limit + session.data.rcpt_errors = 0; + session.state = State::default(); + session.rcpt_to("Jane@FooBar.org", "250").await; + session.rcpt_to("Bill@FooBar.org", "250").await; + session.rcpt_to("Mike@FooBar.org", "452 4.4.5").await; + + // Restore rate limit + tokio::time::sleep(Duration::from_millis(1100)).await; + session.rcpt_to("Mike@FooBar.org", "250").await; + session.rcpt_to("john@foobar.org", "455 4.5.3").await; + + // Check recipients + assert_eq!(session.data.rcpt_to.len(), 3); + for (rcpt, expected) in + session + .data + .rcpt_to + .iter() + .zip(["Jane@FooBar.org", "Bill@FooBar.org", "Mike@FooBar.org"]) + { + assert_eq!(rcpt.address, expected); + assert_eq!(rcpt.domain, "foobar.org"); + assert_eq!(rcpt.address_lcase, expected.to_lowercase()); + } + + // Relaying should be allowed for 10.0.0.2 + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session.rset().await; + session.mail_from("john@example.net", "250").await; + session.rcpt_to("external@domain.com", "250").await; + + // DSN is enabled for 10.0.0.2 + session + .ingest(b"RCPT TO: NOTIFY=SUCCESS,FAILURE,DELAY ORCPT=rfc822;Jane.Doe@Foobar.org\r\n") + .await + .unwrap(); + session.response().assert_code("250"); + let rcpt = session.data.rcpt_to.last().unwrap(); + assert!((rcpt.flags & (RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE)) != 0); + assert_eq!(rcpt.dsn_info.as_ref().unwrap(), "Jane.Doe@Foobar.org"); + + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx1.foobar.org").await; + session.mail_from("idn@example.net", "250").await; + session.rcpt_to("cornelius@straß6.de", "250").await; + session.rcpt_to("cornelius@xn--stra6-oqa.de", "250").await; + assert_eq!(session.data.rcpt_to.len(), 1); + let rcpt = session.data.rcpt_to.last().unwrap(); + assert_eq!(rcpt.address_lcase, "cornelius@xn--stra6-oqa.de"); + assert_eq!(rcpt.domain, "xn--stra6-oqa.de"); + + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx1.foobar.org").await; + session.mail_from("idn2@example.net", "250").await; + session.rcpt_to("nobody@straß6.de", "550 5.1.2").await; + session + .rcpt_to("nobody@xn--stra6-oqa.de", "550 5.1.2") + .await; +} diff --git a/tests/src/smtp/inbound/rewrite.rs b/tests/src/smtp/inbound/rewrite.rs new file mode 100644 index 0000000..07198e9 --- /dev/null +++ b/tests/src/smtp/inbound/rewrite.rs @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{smtp::session::TestSession, utils::server::TestServerBuilder}; +use registry::{ + schema::structs::{ + Expression, ExpressionMatch, MtaStageMail, MtaStageRcpt, SieveSystemInterpreter, + SieveSystemScript, + }, + types::list::List, +}; + +const MAIL_SCRIPT: &str = r#"require ["variables", "envelope"]; +if allof( envelope :domain :is "from" "foobar.org", + envelope :localpart :contains "from" "admin" ) { + set "envelope.from" "MAILER-DAEMON@foobar.org"; +} +"#; +const MAIL_RCPT: &str = r#"require ["variables", "envelope", "regex"]; +if allof( envelope :localpart :contains "to" ".", + envelope :regex "to" "(.+)@(.+)$") { + set :replace "." "" "to" "${1}"; + set "envelope.to" "${to}@${2}"; +} +"#; + +#[tokio::test(flavor = "multi_thread")] +pub async fn address_rewrite() { + let mut test = TestServerBuilder::new("smtp_rewrite_test") + .await + .with_http_listener(19007) + .await + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin + .registry_create_object(MtaStageMail { + rewrite: Expression { + match_: List::from_iter([ExpressionMatch { + if_: concat!( + "ends_with(sender_domain, '.foobar.net') & ", + "matches('^([^.]+)@([^.]+)\\.(.+)$', sender)" + ) + .into(), + then: "$1 + '+' + $2 + '@' + $3".into(), + }]), + else_: "false".into(), + }, + script: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "sender_domain = 'foobar.org'".into(), + then: "'mail'".into(), + }]), + else_: "false".into(), + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageRcpt { + rewrite: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "rcpt_domain = 'foobar.net' & matches('^([^.]+)\\.([^.]+)@(.+)$', rcpt)" + .into(), + then: "$1 + '+' + $2 + '@' + $3".into(), + }]), + else_: "false".into(), + }, + script: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "rcpt_domain = 'foobar.org'".into(), + then: "'rcpt'".into(), + }]), + else_: "false".into(), + }, + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(SieveSystemInterpreter { + default_from_address: Expression { + else_: "'sieve@foobar.org'".into(), + ..Default::default() + }, + + default_from_name: Expression { + else_: "'Sieve Daemon'".into(), + ..Default::default() + }, + default_return_path: Expression { + else_: "''".into(), + ..Default::default() + }, + message_id_hostname: Some("'mx.foobar.org'".into()), + duplicate_expiry: (86_400u64 * 100 * 7).into(), + max_cpu_cycles: 10000, + max_nested_includes: 5, + max_out_messages: 5, + max_received_headers: 50, + max_redirects: 3, + ..Default::default() + }) + .await; + for (name, contents) in [("mail", MAIL_SCRIPT), ("rcpt", MAIL_RCPT)] { + admin + .registry_create_object(SieveSystemScript { + name: name.to_string(), + contents: contents.to_string(), + is_active: true, + ..Default::default() + }) + .await; + } + admin.reload_settings().await; + test.reload_core(); + + // Init session + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.doe.org").await; + + // Sender rewrite using regex + session.mail_from("bill@doe.foobar.net", "250").await; + assert_eq!( + session.data.mail_from.as_ref().unwrap().address, + "bill+doe@foobar.net" + ); + session.reset(); + + // Sender rewrite using sieve + session.mail_from("this_is_admin@foobar.org", "250").await; + assert_eq!( + session.data.mail_from.as_ref().unwrap().address_lcase, + "mailer-daemon@foobar.org" + ); + + // Recipient rewrite using regex + session.rcpt_to("mary.smith@foobar.net", "250").await; + assert_eq!( + session.data.rcpt_to.last().unwrap().address, + "mary+smith@foobar.net" + ); + + // Remove duplicates + session.rcpt_to("mary.smith@foobar.net", "250").await; + assert_eq!(session.data.rcpt_to.len(), 1); + + // Recipient rewrite using sieve + session.rcpt_to("m.a.r.y.s.m.i.t.h@foobar.org", "250").await; + assert_eq!( + session.data.rcpt_to.last().unwrap().address, + "marysmith@foobar.org" + ); +} diff --git a/tests/src/smtp/inbound/scripts.rs b/tests/src/smtp/inbound/scripts.rs new file mode 100644 index 0000000..cecf60a --- /dev/null +++ b/tests/src/smtp/inbound/scripts.rs @@ -0,0 +1,478 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse}, + }, + utils::server::TestServerBuilder, +}; +use common::config::mailstore::spamfilter::spam_status; +use core::panic; +use registry::schema::structs::{ + CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, LookupStore, + MtaStageConnect, MtaStageData, MtaStageEhlo, MtaStageMail, MtaStageRcpt, + SieveSystemInterpreter, SieveSystemScript, SqliteStore, StoreLookup, +}; +use smtp::scripts::{ScriptResult, event_loop::RunScript}; +use std::{fs, path::PathBuf}; + +#[tokio::test] +async fn sieve_scripts() { + let mut test = TestServerBuilder::new("smtp_sieve_test") + .await + .with_http_listener(19008) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Create test data + let admin = test.account("admin"); + let domain_id = admin + .registry_create_object(Domain { + name: "foobar.org".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + allow_relaying: true, + ..Default::default() + }) + .await; + admin.create_dkim_signatures(domain_id).await; + admin.mta_no_auth().await; + admin + .registry_create_object(SieveSystemInterpreter { + default_from_address: Expression { + else_: "'sieve@foobar.org'".into(), + ..Default::default() + }, + + default_from_name: Expression { + else_: "'Sieve Daemon'".into(), + ..Default::default() + }, + default_return_path: Expression { + else_: "''".into(), + ..Default::default() + }, + message_id_hostname: Some("'mx.foobar.org'".into()), + dkim_sign_domain: Expression { + else_: "'foobar.org'".into(), + ..Default::default() + }, + duplicate_expiry: (86_400u64 * 100 * 7).into(), + max_cpu_cycles: 10000, + max_nested_includes: 5, + max_out_messages: 5, + max_received_headers: 50, + max_redirects: 3, + ..Default::default() + }) + .await; + admin + .registry_create_object(StoreLookup { + namespace: "sql".into(), + store: LookupStore::Sqlite(SqliteStore { + path: format!("{}/smtp_sieve.db", test.tmp_dir()), + pool_max_connections: 10, + pool_workers: None, + }), + }) + .await; + admin + .registry_create_object(MtaStageConnect { + script: Expression { + else_: "'stage_connect'".into(), + ..Default::default() + }, + smtp_greeting: Expression { + else_: "'mx.example.org at your service'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageEhlo { + script: Expression { + else_: "'stage_ehlo'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageMail { + script: Expression { + else_: "'stage_mail'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageRcpt { + script: Expression { + else_: "'stage_rcpt'".into(), + ..Default::default() + }, + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageData { + script: Expression { + else_: "'stage_data'".into(), + ..Default::default() + }, + add_date_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_message_id_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_spf_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_auth_results_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_return_path_header: Expression { + else_: "false".into(), + ..Default::default() + }, + enable_spam_filter: Expression { + else_: "false".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + // Add test scripts + for entry in fs::read_dir( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("smtp") + .join("sieve"), + ) + .unwrap() + { + let entry = entry.unwrap(); + admin + .registry_create_object(SieveSystemScript { + contents: fs::read_to_string(entry.path()).unwrap(), + description: None, + is_active: true, + name: entry + .file_name() + .to_str() + .unwrap() + .split_once('.') + .unwrap() + .0 + .to_string(), + }) + .await; + } + admin.reload_settings().await; + admin.reload_lookup_stores().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Build session + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.88".parse().unwrap(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + assert!(!session.init_conn().await); + + // Run tests + for (name, script) in &test.server.core.sieve.trusted_scripts { + if name.starts_with("stage_") || name.ends_with("_include") { + continue; + } + let script = script.clone(); + let params = session + .build_script_parameters("data") + .set_variable("from", "john.doe@example.org") + .with_envelope(&test.server, &session, 0) + .await; + match test.server.run_script(name.into(), script, params).await { + ScriptResult::Accept { .. } => (), + ScriptResult::Reject(message) => panic!("{}", message), + err => { + panic!("Unexpected script result {err:?}"); + } + } + } + + // Test spamtest normalization + let spamtest_script = test + .server + .core + .sieve + .trusted_scripts + .get("spamtest_include") + .expect("spamtest_include script not found") + .clone(); + for (percentage, score, expected) in [ + (None, 0.0, "spamtest=0 percent=0 score= is_spam="), + (Some(0), -3.5, "spamtest=1 percent=0 score=-3.5 is_spam=0"), + (Some(25), 2.5, "spamtest=2 percent=25 score=2.5 is_spam=0"), + (Some(49), 4.9, "spamtest=4 percent=49 score=4.9 is_spam=0"), + (Some(50), 5.0, "spamtest=5 percent=50 score=5 is_spam=1"), + (Some(75), 7.5, "spamtest=7 percent=75 score=7.5 is_spam=1"), + (Some(99), 9.9, "spamtest=9 percent=99 score=9.9 is_spam=1"), + ( + Some(100), + 10.0, + "spamtest=10 percent=100 score=10 is_spam=1", + ), + ] { + let mut params = session + .build_script_parameters("data") + .with_spam_status(spam_status(percentage)); + if percentage.is_some() { + params = params + .set_variable("spam.score", score) + .set_variable("spam.is_spam", score >= 5.0); + } + + match test + .server + .run_script("spamtest_include".into(), spamtest_script.clone(), params) + .await + { + ScriptResult::Reject(message) => { + assert!( + message.contains(expected), + "expected {expected:?} for {percentage:?}, got {message:?}" + ); + } + other => panic!("Unexpected script result {other:?} for {percentage:?}"), + } + } + + // Test connect script + session + .response() + .assert_contains("503 5.5.3 Your IP '10.0.0.88' is not welcomed here"); + session.data.remote_ip_str = "10.0.0.5".parse().unwrap(); + session.data.remote_ip = session.data.remote_ip_str.parse().unwrap(); + assert!(session.init_conn().await); + session + .response() + .assert_contains("220 mx.example.org at your service"); + + // Test EHLO script + session + .cmd( + "EHLO spammer.org", + "551 5.1.1 Your domain 'spammer.org' has been blocklisted", + ) + .await; + session.cmd("EHLO foobar.net", "250").await; + + // Test MAIL-FROM script + session + .mail_from("spammer@domain.com", "450 4.1.1 Invalid address") + .await; + session + .mail_from( + "marketing@spam-domain.com", + "503 5.5.3 Your address has been blocked", + ) + .await; + session.mail_from("bill@foobar.org", "250").await; + + // Test RCPT-TO script + session + .rcpt_to( + "jane@foobar.org", + "422 4.2.2 You have been greylisted '10.0.0.5.bill@foobar.org.jane@foobar.org'.", + ) + .await; + session.rcpt_to("jane@foobar.org", "250").await; + + // Expect a modified message + session.data("test:multipart", "250").await; + + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("X-Part-Number: 5") + .assert_contains("THIS IS A PIECE OF HTML TEXT"); + test.assert_no_events(); + + // Expect rejection for bill@foobar.net + session + .send_message( + "test@example.net", + &["bill@foobar.net"], + "test:multipart", + "503 5.5.3 Bill cannot receive messages", + ) + .await; + test.assert_no_events(); + test.clear_queue().await; + + // Expect message delivery plus a notification + session + .send_message( + "test@example.net", + &["john@foobar.net"], + "test:multipart", + "250", + ) + .await; + test.expect_refresh().await; + test.expect_refresh().await; + let messages = test.read_queued_messages().await; + assert_eq!(messages.len(), 2); + let mut messages = messages.into_iter(); + let notification = messages.next().unwrap(); + assert_eq!(notification.message.return_path.as_ref(), ""); + assert_eq!(notification.message.recipients.len(), 2); + assert_eq!( + notification.message.recipients.first().unwrap().address(), + "john@example.net" + ); + assert_eq!( + notification.message.recipients.last().unwrap().address(), + "jane@example.org" + ); + notification + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=foobar.org;") + .assert_contains("From: \"Sieve Daemon\" ") + .assert_contains("To: ") + .assert_contains("Cc: ") + .assert_contains("Subject: You have got mail") + .assert_contains("One Two Three Four"); + + messages + .next() + .unwrap() + .read_lines(&test) + .await + .assert_contains("One Two Three Four") + .assert_contains("multi-part message in MIME format") + .assert_not_contains("X-Part-Number: 5") + .assert_not_contains("THIS IS A PIECE OF HTML TEXT"); + test.assert_no_events(); + test.clear_queue().await; + + // Expect a modified message delivery plus a notification + session + .send_message( + "test@example.net", + &["jane@foobar.net"], + "test:multipart", + "250", + ) + .await; + test.expect_refresh().await; + test.expect_refresh().await; + let messages = test.read_queued_messages().await; + assert_eq!(messages.len(), 2); + let mut messages = messages.into_iter(); + + messages + .next() + .unwrap() + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=foobar.org;") + .assert_contains("From: \"Sieve Daemon\" ") + .assert_contains("To: ") + .assert_contains("Cc: ") + .assert_contains("Subject: You have got mail") + .assert_contains("One Two Three Four"); + + messages + .next() + .unwrap() + .read_lines(&test) + .await + .assert_contains("X-Part-Number: 5") + .assert_contains("THIS IS A PIECE OF HTML TEXT") + .assert_not_contains("X-My-Header: true"); + test.clear_queue().await; + + // Expect a modified redirected message + session + .send_message( + "test@example.net", + &["thomas@foobar.gov"], + "test:no_dkim", + "250", + ) + .await; + + let redirect = test.expect_message().await; + assert_eq!(redirect.message.return_path.as_ref(), ""); + assert_eq!(redirect.message.recipients.len(), 1); + assert_eq!( + redirect.message.recipients.first().unwrap().address(), + "redirect@here.email" + ); + redirect + .read_lines(&test) + .await + .assert_contains("From: no-reply@my.domain") + .assert_contains("To: Suzie Q ") + .assert_contains("Subject: Is dinner ready?") + .assert_contains("Message-ID: <20030712040037.46341.5F8J@football.example.com>") + .assert_contains("Received: ") + .assert_not_contains("From: Joe SixPack "); + test.assert_no_events(); + + // Expect an intact redirected message + session + .send_message( + "test@example.net", + &["bob@foobar.gov"], + "test:no_dkim", + "250", + ) + .await; + + let redirect = test.expect_message().await; + assert_eq!(redirect.message.return_path.as_ref(), ""); + assert_eq!(redirect.message.recipients.len(), 1); + assert_eq!( + redirect.message.recipients.first().unwrap().address(), + "redirect@somewhere.email" + ); + redirect + .read_lines(&test) + .await + .assert_not_contains("From: no-reply@my.domain") + .assert_contains("To: Suzie Q ") + .assert_contains("Subject: Is dinner ready?") + .assert_contains("Message-ID: <20030712040037.46341.5F8J@football.example.com>") + .assert_contains("From: Joe SixPack ") + .assert_contains("Received: ") + .assert_contains("Authentication-Results: "); + test.assert_no_events(); +} diff --git a/tests/src/smtp/inbound/sign.rs b/tests/src/smtp/inbound/sign.rs new file mode 100644 index 0000000..6272fd6 --- /dev/null +++ b/tests/src/smtp/inbound/sign.rs @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse}, + }, + utils::{account::Account, dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{ + common::{parse::TxtRecordParser, verify::DomainKey}, + spf::Spf, +}; +use registry::schema::{ + enums::{DkimCanonicalization, DkimRotationStage}, + structs::{ + CertificateManagement, Dkim1Signature, DkimManagement, DkimSignature, DnsManagement, + Domain, Expression, SecretText, SecretTextValue, SenderAuth, + }, +}; +use std::time::{Duration, Instant}; +use types::id::Id; + +#[tokio::test] +async fn sign_and_seal() { + let mut test = TestServerBuilder::new("smtp_sign_test") + .await + .with_http_listener(19010) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + let domain_id = admin + .registry_create_object(Domain { + name: "example.com".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + allow_relaying: true, + ..Default::default() + }) + .await; + admin.create_dkim_signatures(domain_id).await; + admin.mta_no_auth().await; + admin.mta_add_all_headers().await; + admin + .registry_create_object(SenderAuth { + dmarc_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + reverse_ip_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_ehlo_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + spf_from_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + arc_verify: Expression { + else_: "strict".into(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "'example.com'".into(), + ..Default::default() + }, + dkim_verify: Expression { + else_: "relaxed".into(), + ..Default::default() + }, + dkim_strict: false, + }) + .await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Add SPF, DKIM and DMARC records + test.server.txt_add( + "mx.example.com", + Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "example.com", + Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "ed._domainkey.scamorza.org", + DomainKey::parse( + concat!( + "v=DKIM1; k=ed25519; ", + "p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=" + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "rsa._domainkey.manchego.org", + DomainKey::parse( + concat!( + "v=DKIM1; t=s; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ", + "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt", + "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v", + "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi", + "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB", + ) + .as_bytes(), + ) + .unwrap(), + Instant::now() + Duration::from_secs(5), + ); + + // Test DKIM signing + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session + .send_message( + "bill@foobar.org", + &["jdoe@example.com"], + "test:no_dkim", + "250", + ) + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains( + "DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com; c=simple/relaxed;", + ); + + // Test ARC verify + session + .send_message("bill@foobar.org", &["jdoe@example.com"], "test:arc", "250") + .await; + test.expect_message().await; + + /* + // DMARC WG is ending the ARC experiment + + .read_lines(&test) + .await + .assert_contains("ARC-Seal: i=3; a=ed25519-sha256; s=ed; d=example.com; cv=pass;") + .assert_contains( + "ARC-Message-Signature: i=3; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/simple;", + ); + + // Test ARC sealing of a DKIM signed message + session + .send_message("bill@foobar.org", &["jdoe@example.com"], "test:dkim", "250") + .await; + test.expect_message() + .await + .read_lines(&test) + .await + .assert_contains("ARC-Seal: i=1; a=ed25519-sha256; s=ed; d=example.com; cv=none;") + .assert_contains( + "ARC-Message-Signature: i=1; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/simple;", + );*/ +} + +impl Account { + pub async fn create_dkim_signatures(&self, domain_id: Id) -> Vec { + let rsa_id = self + .registry_create_object(DkimSignature::Dkim1RsaSha256(Dkim1Signature { + stage: DkimRotationStage::Active, + selector: "rsa".to_string(), + canonicalization: DkimCanonicalization::SimpleRelaxed, + domain_id, + private_key: SecretText::Text(SecretTextValue { + secret: RSA_KEY.to_string(), + }), + ..Default::default() + })) + .await; + + let ed_id = self + .registry_create_object(DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature { + stage: DkimRotationStage::Active, + selector: "ed".to_string(), + canonicalization: DkimCanonicalization::RelaxedSimple, + domain_id, + private_key: SecretText::Text(SecretTextValue { + secret: ED25519_KEY.to_string(), + }), + ..Default::default() + })) + .await; + + vec![rsa_id, ed_id] + } +} + +const RSA_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAv9XYXG3uK95115mB4nJ37nGeNe2CrARm1agrbcnSk5oIaEfM +ZLUR/X8gPzoiNHZcfMZEVR6bAytxUhc5EvZIZrjSuEEeny+fFd/cTvcm3cOUUbIa +UmSACj0dL2/KwW0LyUaza9z9zor7I5XdIl1M53qVd5GI62XBB76FH+Q0bWPZNkT4 +NclzTLspD/MTpNCCPhySM4Kdg5CuDczTH4aNzyS0TqgXdtw6A4Sdsp97VXT9fkPW +9rso3lrkpsl/9EQ1mR/DWK6PBmRfIuSFuqnLKY6v/z2hXHxF7IoojfZLa2kZr9Ae +d4l9WheQOTA19k5r2BmlRw/W9CrgCBo0Sdj+KQIDAQABAoIBAFPChEi/OvnulReB +ECQWhOUYuNKlFKQU++2YEvZJ4+bMn5UgnE7wfJ1pj2Pr9xlfALz+OMHNrjMxGbaV +KzdrT2uCkYcf78XjnhuH9gKIiXDUv4L4N+P3u6w8yOx4bFgOS9IjS53yDOPM7SC5 +g6dIg5aigHaHlffqIuFFv4yQMI/+Ai+zBKxS7wRhxK/7nnAuo28fe5MEdp57ho9/ +AGlDNsdg9zCgjwhokwFE3+AaD+bkUFm4gQ1XjkUFrlmnQn8vDQ0i9toEWhCj+UPY +iOKL63MJnr90MXTXWLHoFj99wBp//mYygbF9Lj8fa28/oa8LWp3Jhb7QeMgH46iv +3aLHbTECgYEA5M2dAw+nyMw9vYlkMejhwObKYP8Mr/6zcGMLCalYvRJM5iUAM0JI +H6sM6pV9/nv167cbKocj3xYPdtE7FPOn4132MLM8Ne1f8nPE64Qrcbj5WBXvLnU8 +hpWbwe2Z8h7UUMKx6q4F1/TXYkc3ScxYwfjM4mP/pLsAOgVzRSEEgrUCgYEA1qNQ +xaQHNWZ1O8WuTnqWd5JSsic6iURAmUcLeFDZY2PWhVoaQ8L/xMQhDYs1FIbLWArW +4Qq3Ibu8AbSejAKuaJz7Uf26PX+PYVUwAOO0qamCJ8d/qd6So7qWMDyAY2yXI39Y +1nMqRjr7bkEsggAZao7BKqA7ZtmogjOusBT38iUCgYEA06agJ8TDoKvOMRZ26PRU +YO0dKLzGL8eclcoI29cbj0rud7aiiMg3j5PbTuUat95TjsjDCIQaWrM9etvxm2AJ +Xfn9Uu96MyhyKQWOk46f4YMKpMElkARDCPw8KRhx39dE77AqhLyWCz8iPndCXbH6 +KPTOEl4OjYOuof2Is9nnIkECgYBh948RdsnXhNlzm8nwhiGRmBbou+EK8D0v+O5y +Tyy6IcKzgSnFzgZh8EdJ4EUtBk1f9SqY8wQdgIvSl3daXorusuA/TzkngsaV3YUY +ktZOLlF7CKLrjOyPkMWmZKcROmpNyH1q/IvKHHfQnizLdXIkYd4nL5WNX0F7lE1i +j1+QhQKBgB2lviBK7rJFwlFYdQUP1NAN2dKxMZk8uJS8JglHrM0+8nRI83HbTdEQ +vB0ManEKBkbS4T5n+gRtdEqKSDmWDTXDlrBfcdCHNQLwYtBpOotCqQn/AmfjcPBl +byAbwh4+HiZ5JISoRZpiZqy67aJNVoXmdtb/E9mi7ozzytpxMNql +-----END RSA PRIVATE KEY----- +"#; + +const ED25519_KEY: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIAO3hAf144lTAVjTkht3ZwBTK0CMCCd1bI0alggneN3B +-----END PRIVATE KEY----- +"#; diff --git a/tests/src/smtp/inbound/throttle.rs b/tests/src/smtp/inbound/throttle.rs new file mode 100644 index 0000000..4d03db4 --- /dev/null +++ b/tests/src/smtp/inbound/throttle.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use registry::{ + schema::{ + enums::MtaInboundThrottleKey, + structs::{Expression, MtaInboundThrottle, Rate}, + }, + types::map::Map, +}; +use smtp::core::SessionAddress; +use std::time::Duration; + +#[tokio::test] +async fn throttle_inbound() { + let mut test = TestServerBuilder::new("smtp_inbound_throttle_test") + .await + .with_http_listener(19009) + .await + .disable_services() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin + .registry_create_object(MtaInboundThrottle { + description: "Test throttle".into(), + enable: true, + key: Map::new(vec![MtaInboundThrottleKey::RemoteIp]), + match_: Expression { + else_: "remote_ip = '10.0.0.1'".into(), + ..Default::default() + }, + rate: Rate { + count: 2, + period: 1000u64.into(), + }, + }) + .await; + + admin + .registry_create_object(MtaInboundThrottle { + description: "Test throttle".into(), + enable: true, + key: Map::new(vec![MtaInboundThrottleKey::Sender]), + rate: Rate { + count: 2, + period: 1000u64.into(), + }, + ..Default::default() + }) + .await; + + admin + .registry_create_object(MtaInboundThrottle { + enable: true, + key: Map::new(vec![ + MtaInboundThrottleKey::RemoteIp, + MtaInboundThrottleKey::Rcpt, + ]), + rate: Rate { + count: 2, + period: 1000u64.into(), + }, + description: "Test throttle".into(), + ..Default::default() + }) + .await; + + admin.reload_settings().await; + test.reload_core(); + + // Test connection rate limit + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(!session.is_allowed().await, "Rate limiter failed."); + tokio::time::sleep(Duration::from_millis(1100)).await; + assert!( + session.is_allowed().await, + "Rate limiter did not restore quota." + ); + + // Test mail from rate limit + session.data.mail_from = SessionAddress { + address: "sender@test.org".into(), + address_lcase: "sender@test.org".into(), + domain: "test.org".into(), + flags: 0, + dsn_info: None, + } + .into(); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(!session.is_allowed().await, "Rate limiter failed."); + session.data.mail_from = SessionAddress { + address: "other-sender@test.org".into(), + address_lcase: "other-sender@test.org".into(), + domain: "test.org".into(), + flags: 0, + dsn_info: None, + } + .into(); + assert!(session.is_allowed().await, "Rate limiter failed."); + + // Test recipient rate limit + session.data.rcpt_to.push(SessionAddress { + address: "recipient@example.org".into(), + address_lcase: "recipient@example.org".into(), + domain: "example.org".into(), + flags: 0, + dsn_info: None, + }); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(session.is_allowed().await, "Rate limiter too strict."); + assert!(!session.is_allowed().await, "Rate limiter failed."); + session.data.remote_ip_str = "10.0.0.2".into(); + assert!(session.is_allowed().await, "Rate limiter too strict."); +} diff --git a/tests/src/smtp/inbound/vrfy.rs b/tests/src/smtp/inbound/vrfy.rs new file mode 100644 index 0000000..13262ef --- /dev/null +++ b/tests/src/smtp/inbound/vrfy.rs @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::{TestSession, VerifyResponse}, + utils::server::TestServerBuilder, +}; +use registry::{ + schema::structs::{Expression, ExpressionMatch, MailingList, MtaExtensions}, + types::{list::List, map::Map}, +}; + +#[tokio::test] +async fn vrfy_expn() { + let mut test = TestServerBuilder::new("smtp_vrfy_test") + .await + .with_http_listener(19006) + .await + .disable_services() + .build() + .await; + + // Create test users + let admin = test.account("admin"); + for (name, secret, description, aliases) in [ + ("john@foobar.org", "12345 + extra safety", "John Doe", &[]), + ("jane@foobar.org", "abcde + extra safety", "Jane Smith", &[]), + ( + "bill@foobar.org", + "p4ssw0rd + extra safety", + "Bill Foobar", + &[], + ), + ] { + admin + .create_user_account(name, secret, description, aliases, vec![]) + .await; + } + let domain_id = admin.find_or_create_domain("foobar.org").await; + admin + .registry_create_object(MailingList { + domain_id, + name: "sales".into(), + recipients: Map::new(vec![ + "john@foobar.org".into(), + "jane@foobar.org".into(), + "bill@foobar.org".into(), + ]), + ..Default::default() + }) + .await; + + // Add test settings + admin.mta_no_auth().await; + admin + .registry_create_object(MtaExtensions { + vrfy: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + expn: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "remote_ip = '10.0.0.1'".into(), + then: "true".into(), + }]), + else_: "false".into(), + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + // EHLO should not advertise VRFY/EXPN to 10.0.0.2 + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session + .ehlo("mx.foobar.org") + .await + .assert_not_contains("EXPN") + .assert_not_contains("VRFY"); + session.cmd("VRFY john@foobar.org", "252 2.5.1").await; + session.cmd("EXPN sales@foobar.org", "252 2.5.1").await; + + // EHLO should advertise VRFY/EXPN for 10.0.0.1 + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session + .ehlo("mx.foobar.org") + .await + .assert_contains("EXPN") + .assert_contains("VRFY"); + + // Successful VRFY + session + .cmd("VRFY john@foobar.org", "250 john@foobar.org") + .await; + + // Successful EXPN + session + .cmd("EXPN sales@foobar.org", "250") + .await + .assert_contains("250-john@foobar.org") + .assert_contains("250-jane@foobar.org") + .assert_contains("250 bill@foobar.org"); + + // Non-existent VRFY + session.cmd("VRFY robert", "550 5.1.2").await; + + // Non-existent EXPN + session.cmd("EXPN procurement", "550 5.1.2").await; +} diff --git a/tests/src/smtp/lookup/expressions.rs b/tests/src/smtp/lookup/expressions.rs new file mode 100644 index 0000000..89ec004 --- /dev/null +++ b/tests/src/smtp/lookup/expressions.rs @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{dns::DnsCache, server::TestServerBuilder}; +use common::expr::{tokenizer::TokenMap, *}; +use mail_auth::{DnssecStatus, MX}; +use registry::schema::{ + enums::ExpressionVariable, + prelude::{ObjectType, Property}, + structs::{LookupStore, SqliteStore, StoreLookup}, +}; +use smtp::queue::RecipientDomain; +use std::time::{Duration, Instant}; + +const TESTS: &[(&str, &str)] = &[ + ("dns_query(rcpt_domain, 'mx')[0]", "mx.foobar.org"), + ( + "key_get('sql', 'hello') + '-' + key_exists('sql', 'hello') + '-' + key_set('sql', 'hello', 'world') + '-' + key_get('sql', 'hello') + '-' + key_exists('sql', 'hello')", + "-0-1-world-1", + ), + ( + "counter_get('sql', 'county') + '-' + counter_incr('sql', 'county', 1) + '-' + counter_incr('sql', 'county', 1) + '-' + counter_get('sql', 'county')", + "0-1-2-2", + ), + ( + "sql_query('sql', 'SELECT description FROM domains WHERE name = ?', 'foobar.org')", + "Main domain", + ), + ( + "is_local_domain('foobar.org') + '-' + is_local_domain('unknown.org') + '-' + is_local_address('john@foobar.org') + '-' + is_local_address('unknown@foobar.org')", + "1-0-1-0", + ), + ( + "is_local_domain('FooBar.org') + '-' + is_local_address('John@FooBar.org') + '-' + is_local_address('JOHN@FOOBAR.ORG')", + "1-1-1", + ), +]; + +#[tokio::test] +async fn expressions() { + let mut test = TestServerBuilder::new("smtp_lookup_test") + .await + .with_http_listener(19017) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Create test data + let admin = test.account("admin"); + for (name, secret, description, aliases) in [ + ("john@foobar.org", "12345 + extra safety", "John Doe", &[]), + ("jane@domain.net", "abcde + extra safety", "Jane Smith", &[]), + ] { + admin + .create_user_account(name, secret, description, aliases, vec![]) + .await; + } + admin + .registry_create_object(StoreLookup { + namespace: "sql".into(), + store: LookupStore::Sqlite(SqliteStore { + path: format!("{}/smtp_sql.db", test.tmp_dir()), + pool_max_connections: 10, + pool_workers: None, + }), + }) + .await; + admin.reload_lookup_stores().await; + test.reload_core(); + + test.server.mx_add( + "test.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + + let sql = test + .server + .get_lookup_store("sql") + .unwrap() + .into_store() + .unwrap(); + sql.create_tables().await.unwrap(); + for query in [ + "CREATE TABLE domains (name TEXT PRIMARY KEY, description TEXT);", + "INSERT INTO domains (name, description) VALUES ('foobar.org', 'Main domain');", + "INSERT INTO domains (name, description) VALUES ('foobar.net', 'Secondary domain');", + "CREATE TABLE allowed_ips (addr TEXT PRIMARY KEY);", + "INSERT INTO allowed_ips (addr) VALUES ('10.0.0.50');", + ] { + sql.sql_query::(query, Vec::new()).await.unwrap(); + } + + // Test expression functions + let token_map = TokenMap::default().with_variables(&[ + ExpressionVariable::Rcpt, + ExpressionVariable::RcptDomain, + ExpressionVariable::Sender, + ExpressionVariable::SenderDomain, + ExpressionVariable::Mx, + ExpressionVariable::HeloDomain, + ExpressionVariable::AuthenticatedAs, + ExpressionVariable::Listener, + ExpressionVariable::RemoteIp, + ExpressionVariable::LocalIp, + ExpressionVariable::Priority, + ]); + for (expr, expected) in TESTS { + let e = Expression::parse(&token_map, expr); + assert_eq!( + test.server + .eval_expr::( + &e, + &RecipientDomain::new("test.org"), + ObjectType::Account.singleton(), + Property::AccountName, + 0 + ) + .await + .unwrap(), + *expected, + "failed for '{}'", + expr + ); + } +} diff --git a/tests/src/smtp/lookup/mod.rs b/tests/src/smtp/lookup/mod.rs new file mode 100644 index 0000000..8811acc --- /dev/null +++ b/tests/src/smtp/lookup/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod expressions; +pub mod utils; diff --git a/tests/src/smtp/lookup/utils.rs b/tests/src/smtp/lookup/utils.rs new file mode 100644 index 0000000..51a1967 --- /dev/null +++ b/tests/src/smtp/lookup/utils.rs @@ -0,0 +1,358 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use ::smtp::outbound::NextHop; +use common::config::smtp::{ + queue::{MxConfig, QueueExpiry, QueueName}, + report::AggregateFrequency, + resolver::{Mode, MxPattern, Policy}, +}; +use mail_auth::{DnssecStatus, IpLookupStrategy, MX, RecordSet}; +use mail_parser::DateTime; +use registry::{ + schema::{ + enums::MtaIpStrategy, + structs::{ + Expression, MtaConnectionIpHost, MtaConnectionStrategy, MtaOutboundStrategy, MtaRoute, + MtaRouteMx, + }, + }, + types::{ipaddr::IpAddr, list::List}, +}; +use smtp::{ + outbound::{ + lookup::{SourceIp, ToNextHop}, + mta_sts::parse::ParsePolicy, + }, + queue::{ + Error, ErrorDetails, FROM_AUTHENTICATED, Message, QueueEnvelope, Recipient, Schedule, + Status, + }, + reporting::AggregateTimestamp, +}; +use std::{str::FromStr, sync::Arc}; +use store::write::now; + +#[tokio::test] +async fn strategies() { + let ipv6: [IpAddr; 4] = [ + "a:b::1".parse().unwrap(), + "a:b::2".parse().unwrap(), + "a:b::3".parse().unwrap(), + "a:b::4".parse().unwrap(), + ]; + let ipv4: [IpAddr; 4] = [ + "10.0.0.1".parse().unwrap(), + "10.0.0.2".parse().unwrap(), + "10.0.0.3".parse().unwrap(), + "10.0.0.4".parse().unwrap(), + ]; + let ipv4_hosts = [ + "test1.example.com".to_string(), + "test2.example.com".to_string(), + "test3.example.com".to_string(), + "test4.example.com".to_string(), + ]; + let ipv6_hosts = [ + "test5.example.com".to_string(), + "test6.example.com".to_string(), + "test7.example.com".to_string(), + "test8.example.com".to_string(), + ]; + + let mut test = TestServerBuilder::new("smtp_strategies_test") + .await + .with_http_listener(19016) + .await + .disable_services() + .capture_queue() + .build() + .await; + + // Add test settings + let admin = test.account("admin"); + admin.mta_no_auth().await; + admin + .registry_create_object(MtaConnectionStrategy { + name: "test".into(), + ehlo_hostname: "test.example.com".to_string().into(), + connect_timeout: 10_000u64.into(), + source_ips: List::from_iter([ + MtaConnectionIpHost { + ehlo_hostname: "test1.example.com".to_string().into(), + source_ip: IpAddr::from_str("10.0.0.1").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test2.example.com".to_string().into(), + source_ip: IpAddr::from_str("10.0.0.2").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test3.example.com".to_string().into(), + source_ip: IpAddr::from_str("10.0.0.3").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test4.example.com".to_string().into(), + source_ip: IpAddr::from_str("10.0.0.4").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test5.example.com".to_string().into(), + source_ip: IpAddr::from_str("a:b::1").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test6.example.com".to_string().into(), + source_ip: IpAddr::from_str("a:b::2").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test7.example.com".to_string().into(), + source_ip: IpAddr::from_str("a:b::3").unwrap(), + }, + MtaConnectionIpHost { + ehlo_hostname: "test8.example.com".to_string().into(), + source_ip: IpAddr::from_str("a:b::4").unwrap(), + }, + ]), + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaRoute::Mx(MtaRouteMx { + ip_lookup_strategy: MtaIpStrategy::V4ThenV6, + name: "test-v4".into(), + ..Default::default() + })) + .await; + admin + .registry_create_object(MtaRoute::Mx(MtaRouteMx { + ip_lookup_strategy: MtaIpStrategy::V6ThenV4, + name: "test-v6".into(), + ..Default::default() + })) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + else_: concat!( + "source + ' ' + received_from_ip + ' ' + ", + "received_via_port + ' ' + queue_name + ' ' + ", + "last_error + ' ' + rcpt_domain + ' ' + size + ' ' + queue_age" + ) + .into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.reload_settings().await; + test.reload_core(); + + let conn = test + .server + .core + .smtp + .queue + .connection_strategy + .get("test") + .unwrap(); + + assert_eq!(conn.ehlo_hostname.as_ref().unwrap(), "test.example.com"); + + for is_ipv4 in [true, false] { + for _ in 0..10 { + let ip_host = conn.source_ip(is_ipv4).unwrap(); + if is_ipv4 { + assert_eq!( + &ipv4_hosts[ipv4 + .iter() + .position(|&ip| ip.into_inner() == ip_host.ip) + .unwrap()], + ip_host.host.as_ref().unwrap() + ); + } else { + assert_eq!( + &ipv6_hosts[ipv6 + .iter() + .position(|&ip| ip.into_inner() == ip_host.ip) + .unwrap()], + ip_host.host.as_ref().unwrap() + ); + } + } + } + + // Test strategy resolution + let message = Message { + created: now() - 123, + blob_hash: Default::default(), + received_from_ip: "1.2.3.4".parse().unwrap(), + received_via_port: 7911, + return_path: "test@example.com".into(), + recipients: vec![Recipient { + address: "recipient@foobar.com".into(), + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Ttl(3600), + queue: QueueName::new("test").unwrap(), + status: Status::TemporaryFailure(ErrorDetails { + entity: "test.example.com".into(), + details: Error::TlsError("TLS handshake failed".into()), + }), + flags: 0, + orcpt: None, + }], + flags: FROM_AUTHENTICATED, + env_id: None, + priority: 0, + size: 978, + metadata: Default::default(), + }; + + assert_eq!( + test.server + .eval_if::( + &test.server.core.smtp.queue.queue, + &QueueEnvelope::new(&message, &message.recipients[0]), + 0, + ) + .await + .unwrap_or_else(|| "default".to_string()), + "authenticated 1.2.3.4 7911 test tls foobar.com 978 123" + ); +} + +#[test] +fn to_remote_hosts() { + let mx: RecordSet = RecordSet { + rrset: Arc::from(vec![ + MX { + exchanges: vec!["mx1".into(), "mx2".into()].into_boxed_slice(), + preference: 10, + }, + MX { + exchanges: vec!["mx3".into(), "mx4".into(), "mx5".into(), "mx6".into()] + .into_boxed_slice(), + preference: 20, + }, + MX { + exchanges: vec!["mx7".into(), "mx8".into()].into_boxed_slice(), + preference: 10, + }, + MX { + exchanges: vec!["mx9".into(), "mxA".into()].into_boxed_slice(), + preference: 10, + }, + ]), + dnssec_status: DnssecStatus::Indeterminate, + }; + let mx_config = MxConfig { + max_mx: 7, + max_multi_homed: 2, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }; + let hosts = mx.to_remote_hosts("domain", &mx_config).unwrap(); + assert_eq!(hosts.len(), 7); + for host in hosts { + if let NextHop::MX { host, .. } = host { + assert!((*host.as_bytes().last().unwrap() - b'0') <= 8); + } + } + let mx: RecordSet = RecordSet { + rrset: Arc::from(vec![MX { + exchanges: vec![".".into()].into_boxed_slice(), + preference: 0, + }]), + dnssec_status: DnssecStatus::Indeterminate, + }; + assert!(mx.to_remote_hosts("domain", &mx_config).is_none()); +} + +#[test] +fn parse_policy() { + for (policy, expected_policy) in [ + ( + r"version: STSv1 +mode: enforce +mx: mail.example.com +mx: *.example.net +mx: backupmx.example.com +max_age: 604800", + Policy { + id: "abc".to_string(), + mode: Mode::Enforce, + mx: vec![ + MxPattern::Equals("mail.example.com".to_string()), + MxPattern::StartsWith("example.net".to_string()), + MxPattern::Equals("backupmx.example.com".to_string()), + ] + .into_boxed_slice(), + max_age: 604800, + }, + ), + ( + r"version: STSv1 +mode: testing +mx: gmail-smtp-in.l.google.com +mx: *.gmail-smtp-in.l.google.com +max_age: 86400 +", + Policy { + id: "abc".to_string(), + mode: Mode::Testing, + mx: vec![ + MxPattern::Equals("gmail-smtp-in.l.google.com".to_string()), + MxPattern::StartsWith("gmail-smtp-in.l.google.com".to_string()), + ] + .into_boxed_slice(), + max_age: 86400, + }, + ), + ] { + assert_eq!( + Policy::parse(policy, expected_policy.id.to_string()).unwrap(), + expected_policy + ); + } +} + +#[test] +fn aggregate_to_timestamp() { + for (freq, date, expected) in [ + ( + AggregateFrequency::Hourly, + "2023-01-24T09:10:40Z", + "2023-01-24T09:00:00Z", + ), + ( + AggregateFrequency::Daily, + "2023-01-24T09:10:40Z", + "2023-01-24T00:00:00Z", + ), + ( + AggregateFrequency::Weekly, + "2023-01-24T09:10:40Z", + "2023-01-22T00:00:00Z", + ), + ( + AggregateFrequency::Weekly, + "2023-01-28T23:59:59Z", + "2023-01-22T00:00:00Z", + ), + ( + AggregateFrequency::Weekly, + "2023-01-22T23:59:59Z", + "2023-01-22T00:00:00Z", + ), + ] { + assert_eq!( + DateTime::from_timestamp( + freq.to_timestamp_(DateTime::parse_rfc3339(date).unwrap()) as i64 + ) + .to_rfc3339(), + expected, + "failed for {freq:?} {date} {expected}" + ); + } +} diff --git a/tests/src/smtp/management/mod.rs b/tests/src/smtp/management/mod.rs new file mode 100644 index 0000000..3cb9e02 --- /dev/null +++ b/tests/src/smtp/management/mod.rs @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod queue; +pub mod report; diff --git a/tests/src/smtp/management/queue.rs b/tests/src/smtp/management/queue.rs new file mode 100644 index 0000000..f2e4ee9 --- /dev/null +++ b/tests/src/smtp/management/queue.rs @@ -0,0 +1,625 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::TestSession, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use ahash::{AHashMap, HashMap, HashSet}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::{ObjectType, Property}, + structs::{ + Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule, + MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy, + MtaStageRcpt, MtaVirtualQueue, QueueExpiry, QueuedMessage, RecipientStatus, + }, + }, + types::{EnumImpl, datetime::UTCDateTime, list::List}, +}; +use serde_json::json; +use std::time::{Duration, Instant}; +use types::id::Id; + +#[tokio::test] +#[serial_test::serial] +async fn manage_queue() { + let mut local = TestServerBuilder::new("smtp_manage_queue_local") + .await + .with_http_listener(19049) + .await + .disable_services() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_manage_queue_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19050) + .await + .with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + let admin = local.account("admin"); + admin + .registry_create_object(MtaExtensions { + dsn: Expression { + else_: "true".into(), + ..Default::default() + }, + future_release: Expression { + else_: "1h".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaStageRcpt { + max_recipients: Expression { + else_: "100".into(), + ..Default::default() + }, + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let queue_id = admin + .registry_create_object(MtaVirtualQueue { + name: "myqueue".into(), + threads_per_node: 25, + description: None, + }) + .await; + admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 2_000_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 3_000_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + else_: "'default'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.reload_settings().await; + local.reload_core(); + let admin = local.account("admin"); + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx1.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + + local.server.ipv4_add( + "mx1.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + // Send test messages + let envelopes = HashMap::from_iter([ + ( + "a", + ( + "bill1@foobar.net", + vec![ + "rcpt1@example1.org", + "rcpt1@example2.org", + "rcpt2@example2.org", + ], + ), + ), + ( + "b", + ( + "bill2@foobar.net", + vec!["rcpt3@example1.net", "rcpt4@example1.net"], + ), + ), + ( + "c", + ( + "bill3@foobar.net", + vec![ + "rcpt5@example1.com", + "rcpt6@example2.com", + "rcpt7@example2.com", + "rcpt8@example3.com", + "rcpt9@example4.com", + ], + ), + ), + ("d", ("bill4@foobar.net", vec!["delay@foobar.org"])), + ("e", ("bill5@foobar.net", vec!["john@foobar.org"])), + ("f", ("", vec!["success@foobar.org", "delay@foobar.org"])), + ]); + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("foobar.net").await; + for test_num in 0..6 { + let env_id = char::from(b'a' + test_num).to_string(); + let hold_for = ((test_num + 1) as u32) * 100; + let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap(); + session + .send_message( + &if env_id != "f" { + format!("<{sender}> ENVID={env_id} HOLDFOR={hold_for}") + } else { + format!("<{sender}> ENVID={env_id}") + }, + recipients, + "test:no_dkim", + "250", + ) + .await; + } + + // Expect delivery to success@foobar.org + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + remote + .consume_message() + .await + .message + .recipients + .into_iter() + .map(|r| r.address().to_string()) + .collect::>(), + vec!["success@foobar.org"] + ); + + // Fetch and validate messages + assert_eq!( + admin + .registry_query_ids( + ObjectType::QueuedMessage, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new() + ) + .await + .len(), + 6 + ); + let messages = admin.registry_get_all::().await; + assert_eq!(messages.len(), 6); + let mut id_map = AHashMap::new(); + let mut id_map_rev = AHashMap::new(); + let mut test_search = String::new(); + for (id, message) in messages { + let env_id = message.env_id.as_ref().unwrap().clone(); + + // Validate return path and recipients + let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap(); + assert_eq!( + &message.return_path, + if !sender.is_empty() { sender } else { "<>" } + ); + 'outer: for recipient in recipients { + for (address, _) in message.recipients.iter() { + if address == recipient { + continue 'outer; + } + } + panic!("Recipient {recipient} not found in message."); + } + + // Validate status and datetimes + let created = message.created_at.timestamp(); + let hold_for = (env_id.as_bytes().first().unwrap() - b'a' + 1) as i64 * 100; + let next_retry = created + hold_for; + let next_notify = created + 2000 + hold_for; + let expires = created + 3000 + hold_for; + for (rcpt_address, rcpt) in message.recipients.iter() { + if env_id == "c" { + let mut dt = rcpt.retry_due; + dt.add_seconds(-1); + test_search = dt.to_string(); + } + if env_id != "f" { + // HOLDFOR messages + assert_eq!(rcpt.retry_count, 0); + assert_timestamp(rcpt.retry_due.timestamp(), next_retry, "retry", &message); + assert_timestamp(rcpt.notify_due.timestamp(), next_notify, "notify", &message); + assert_timestamp( + match &rcpt.expires { + QueueExpiry::Ttl(ttl) => ttl.expires_at.timestamp(), + QueueExpiry::Attempts(_) => unreachable!(), + }, + expires, + "expires", + &message, + ); + assert_eq!(&rcpt.status, &RecipientStatus::Scheduled, "{message:#?}"); + } else if rcpt_address == "success@foobar.org" { + assert_eq!(rcpt.retry_count, 0); + assert!( + matches!(&rcpt.status, RecipientStatus::Completed(_)), + "{:?}", + rcpt.status + ); + } else { + assert_eq!(rcpt.retry_count, 1); + assert!( + matches!(&rcpt.status, RecipientStatus::TemporaryFailure(_)), + "{:?}", + rcpt.status + ); + } + } + + id_map.insert(env_id.clone(), id); + id_map_rev.insert(id, env_id); + } + assert_eq!(id_map.len(), 6); + + // Test list search + for (query, expected_ids) in [ + ( + vec![(Property::ReturnPath.as_str(), "bill1@foobar.net")], + vec!["a"], + ), + ( + vec![(Property::To.as_str(), "foobar.org")], + vec!["d", "e", "f"], + ), + ( + vec![ + (Property::ReturnPath.as_str(), "bill3@foobar.net"), + (Property::To.as_str(), "rcpt5@example1.com"), + ], + vec!["c"], + ), + ( + vec![("dueIsLessThan", test_search.as_str())], + vec!["a", "b"], + ), + ( + vec![("dueIsGreaterThan", test_search.as_str())], + vec!["d", "e", "f", "c"], + ), + ] { + let ids = admin + .registry_query_ids(ObjectType::QueuedMessage, query.clone(), Vec::<&str>::new()) + .await; + assert_eq!( + HashSet::from_iter(ids.iter().map(|id| id_map_rev.get(id).unwrap().as_str())), + HashSet::from_iter(expected_ids.into_iter()), + "failed for query {query:?}" + ); + } + + // Test pagination (forward and reverse) + let asc_order: Vec = admin + .registry_query_paginated( + ObjectType::QueuedMessage, + "due", + true, + None, + None, + None, + None, + false, + ) + .await + .object_ids() + .collect(); + assert_eq!(asc_order.len(), 6, "expected 6 messages, got {asc_order:?}"); + let desc_order: Vec = asc_order.iter().rev().copied().collect(); + + for chunk_start in [0usize, 2, 4] { + let asc = admin + .registry_query_paginated( + ObjectType::QueuedMessage, + "due", + true, + Some(chunk_start as i32), + Some(2), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + asc, + asc_order[chunk_start..chunk_start + 2], + "ascending position={chunk_start} limit=2", + ); + + let desc = admin + .registry_query_paginated( + ObjectType::QueuedMessage, + "due", + false, + Some(chunk_start as i32), + Some(2), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + desc, + desc_order[chunk_start..chunk_start + 2], + "descending position={chunk_start} limit=2", + ); + } + + for anchor_idx in [1usize, 3] { + let asc = admin + .registry_query_paginated( + ObjectType::QueuedMessage, + "due", + true, + None, + Some(2), + Some(asc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + asc, + asc_order[anchor_idx + 1..anchor_idx + 3], + "ascending anchor={} offset=1 limit=2", + asc_order[anchor_idx], + ); + + let desc = admin + .registry_query_paginated( + ObjectType::QueuedMessage, + "due", + false, + None, + Some(2), + Some(desc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + desc, + desc_order[anchor_idx + 1..anchor_idx + 3], + "descending anchor={} offset=1 limit=2", + desc_order[anchor_idx], + ); + } + + // Retry delivery + admin + .registry_update_object( + ObjectType::QueuedMessage, + id_map["e"], + json!({ + "recipients/john@foobar.org/retryDue": UTCDateTime::now() + }), + ) + .await; + admin + .registry_update_object( + ObjectType::QueuedMessage, + id_map["f"], + json!({ + "recipients/delay@foobar.org/retryDue": UTCDateTime::now() + }), + ) + .await; + admin + .registry_update_object( + ObjectType::QueuedMessage, + id_map["a"], + json!({ + "recipients/rcpt1@example1.org/retryDue": "2200-01-01T00:00:00Z", + }), + ) + .await; + + // Expect delivery to john@foobar.org + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + remote + .consume_message() + .await + .message + .recipients + .into_iter() + .map(|r| r.address().to_string()) + .collect::>(), + vec!["john@foobar.org".to_string()] + ); + + // Message 'e' should be gone, 'f' should have retry_count == 2 + // while 'a' should have a retry time of 2200-01-01T00:00:00Z + assert_eq!( + admin + .registry_get_many(ObjectType::QueuedMessage, [id_map["e"]]) + .await + .not_found() + .next() + .unwrap(), + id_map["e"].to_string() + ); + assert_eq!( + admin + .registry_get::(id_map["f"]) + .await + .recipients + .values() + .next() + .unwrap() + .retry_count, + 2 + ); + for (rcpt_address, rcpt) in admin + .registry_get::(id_map["a"]) + .await + .recipients + { + let next_retry = rcpt.retry_due.to_string(); + let matched = + ["2200-01-01T00:00:00Z", "2199-12-31T23:59:59Z"].contains(&next_retry.as_str()); + if rcpt_address.ends_with("example1.org") { + assert!(matched, "{next_retry}"); + } else { + assert!(!matched, "{next_retry}"); + } + } + + // Cancel deliveries + for (id, filter) in [ + ("a", &["rcpt1@example2.org", "rcpt2@example2.org"][..]), + ("b", &["rcpt3@example1.net", "rcpt4@example1.net"][..]), + ("c", &["rcpt6@example2.com"][..]), + ] { + let mut map = serde_json::Map::new(); + for i in filter { + map.insert(format!("recipients/{i}"), serde_json::Value::Null); + } + + admin + .registry_update_object( + ObjectType::QueuedMessage, + id_map[id], + serde_json::Value::Object(map), + ) + .await; + } + + admin + .registry_destroy(ObjectType::QueuedMessage, [id_map["d"]]) + .await + .assert_destroyed(&[id_map["d"]]); + + tokio::time::sleep(Duration::from_millis(200)).await; + + assert_eq!(admin.registry_get_all::().await.len(), 3); + assert_eq!( + admin + .registry_query_ids( + ObjectType::QueuedMessage, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new() + ) + .await + .len(), + 3 + ); + for id in ["b", "d"] { + assert_eq!( + admin + .registry_get_many(ObjectType::QueuedMessage, [id_map[id]]) + .await + .not_found() + .next() + .unwrap(), + id_map[id].to_string() + ); + } + for id in ["a", "c"] { + let message = admin.registry_get::(id_map[id]).await; + + assert!(!message.recipients.is_empty()); + for (rcpt_address, rcpt) in message.recipients { + match id { + "a" => { + if rcpt_address.ends_with("example2.org") { + assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_))); + } else { + assert!(matches!(&rcpt.status, RecipientStatus::Scheduled)); + } + } + "c" => { + if rcpt_address.ends_with("example2.com") { + if rcpt_address == "rcpt6@example2.com" { + assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_))); + } else { + assert!(matches!(&rcpt.status, RecipientStatus::Scheduled)); + } + } else { + assert!(matches!(&rcpt.status, RecipientStatus::Scheduled)); + } + } + _ => unreachable!(), + } + } + } + + // Bulk cancel + admin.registry_destroy_all(ObjectType::QueuedMessage).await; + assert_eq!( + admin + .registry_query_ids( + ObjectType::QueuedMessage, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new() + ) + .await + .len(), + 0 + ); +} + +fn assert_timestamp(timestamp: i64, expected: i64, ctx: &str, message: &QueuedMessage) { + let diff = timestamp - expected; + if ![-2, -1, 0, 1, 2].contains(&diff) { + panic!( + "Got timestamp {timestamp}, expected {expected} (diff {diff} for {ctx}) for {message:?}" + ); + } +} diff --git a/tests/src/smtp/management/report.rs b/tests/src/smtp/management/report.rs new file mode 100644 index 0000000..5daf348 --- /dev/null +++ b/tests/src/smtp/management/report.rs @@ -0,0 +1,257 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use ahash::AHashMap; +use common::{ + config::smtp::report::AggregateFrequency, + ipc::{DmarcEvent, PolicyType, TlsEvent}, +}; +use mail_auth::{ + common::parse::TxtRecordParser, + dmarc::Dmarc, + mta_sts::TlsRpt, + report::{ + ActionDisposition, DmarcResult, Record, + tlsrpt::{FailureDetails, ResultType}, + }, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{ + DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings, + }, +}; +use smtp::reporting::send::MtaReportSend; +use std::sync::Arc; + +#[tokio::test] +#[serial_test::serial] +async fn manage_reports() { + let mut test = TestServerBuilder::new("smtp_report_manage") + .await + .with_http_listener(19048) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + admin + .registry_create_object(TlsReportSettings { + max_report_size: Expression { + else_: "1024".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(DmarcReportSettings { + aggregate_max_report_size: Expression { + else_: "1024".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_allow_relaying().await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + let admin = test.account("admin"); + + // Send test reporting events + test.server + .schedule_report(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("192.168.1.2".parse().unwrap()) + .with_action_disposition(ActionDisposition::Pass) + .with_dmarc_dkim_result(DmarcResult::Pass) + .with_dmarc_spf_result(DmarcResult::Fail) + .with_envelope_from("hello@example.org") + .with_envelope_to("other@example.org") + .with_header_from("bye@example.org"), + dmarc_record: Arc::new( + Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:reports@foobar.org").unwrap(), + ), + interval: AggregateFrequency::Daily, + span_id: 0, + }) + .await; + test.server + .schedule_report(DmarcEvent { + domain: "foobar.net".to_string(), + report_record: Record::new() + .with_source_ip("a:b:c::e:f".parse().unwrap()) + .with_action_disposition(ActionDisposition::Reject) + .with_dmarc_dkim_result(DmarcResult::Fail) + .with_dmarc_spf_result(DmarcResult::Pass), + dmarc_record: Arc::new( + Dmarc::parse( + concat!( + "v=DMARC1; p=quarantine; rua=mailto:reports", + "@foobar.net,mailto:reports@example.net" + ) + .as_bytes(), + ) + .unwrap(), + ), + interval: AggregateFrequency::Weekly, + span_id: 0, + }) + .await; + test.server + .schedule_report(TlsEvent { + domain: "foobar.org".to_string(), + policy: PolicyType::None, + failure: None, + tls_record: Arc::new( + TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:reports@foobar.org").unwrap(), + ), + interval: AggregateFrequency::Daily, + span_id: 0, + }) + .await; + test.server + .schedule_report(TlsEvent { + domain: "foobar.net".to_string(), + policy: PolicyType::Sts(None), + failure: FailureDetails::new(ResultType::StsPolicyInvalid).into(), + tls_record: Arc::new( + TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:reports@foobar.net").unwrap(), + ), + interval: AggregateFrequency::Weekly, + span_id: 0, + }) + .await; + + // List DMARC reports + let mut dmarc_name_to_id = AHashMap::new(); + let mut dmarc_id_to_name = AHashMap::new(); + for (id, report) in admin.registry_get_all::().await { + let diff = + report.report.date_range_end.timestamp() - report.report.date_range_begin.timestamp(); + if report.domain == "foobar.org" { + assert_eq!(diff, 86400); + } else { + assert_eq!(diff, 7 * 86400); + } + dmarc_name_to_id.insert(report.domain.clone(), id); + dmarc_id_to_name.insert(id, report.domain); + } + assert_eq!(dmarc_name_to_id.len(), 2); + + // List TLS reports + let mut tls_name_to_id = AHashMap::new(); + let mut tls_id_to_name = AHashMap::new(); + for (id, report) in admin.registry_get_all::().await { + let diff = + report.report.date_range_end.timestamp() - report.report.date_range_start.timestamp(); + if report.domain == "foobar.org" { + assert_eq!(diff, 86400); + } else { + assert_eq!(diff, 7 * 86400); + } + tls_name_to_id.insert(report.domain.clone(), id); + tls_id_to_name.insert(id, report.domain); + } + assert_eq!(tls_name_to_id.len(), 2); + + // Test list search + for (object, query, expected_ids) in [ + ( + ObjectType::DmarcInternalReport, + vec![], + vec![ + dmarc_name_to_id["foobar.org"], + dmarc_name_to_id["foobar.net"], + ], + ), + ( + ObjectType::TlsInternalReport, + vec![], + vec![tls_name_to_id["foobar.org"], tls_name_to_id["foobar.net"]], + ), + ( + ObjectType::DmarcInternalReport, + vec![(Property::Domain, "foobar.org".to_string())], + vec![dmarc_name_to_id["foobar.org"]], + ), + ( + ObjectType::DmarcInternalReport, + vec![(Property::Domain, "foobar.net".to_string())], + vec![dmarc_name_to_id["foobar.net"]], + ), + ( + ObjectType::TlsInternalReport, + vec![(Property::Domain, "foobar.org".to_string())], + vec![tls_name_to_id["foobar.org"]], + ), + ( + ObjectType::TlsInternalReport, + vec![(Property::Domain, "foobar.net".to_string())], + vec![tls_name_to_id["foobar.net"]], + ), + ] { + assert_eq!( + admin + .registry_query_ids(object, query.clone(), Vec::<&str>::new()) + .await, + expected_ids, + "failed for {object:?} with query {query:?}" + ); + } + + // Cancel reports + for (object, id) in [ + ( + ObjectType::DmarcInternalReport, + dmarc_name_to_id["foobar.org"], + ), + (ObjectType::TlsInternalReport, tls_name_to_id["foobar.org"]), + ] { + admin + .registry_destroy(object, vec![id]) + .await + .assert_destroyed(&[id]); + } + for (object, id) in [ + ( + ObjectType::DmarcInternalReport, + dmarc_name_to_id["foobar.net"], + ), + (ObjectType::TlsInternalReport, tls_name_to_id["foobar.net"]), + ] { + assert_eq!( + admin + .registry_query_ids(object, Vec::<(&str, &str)>::new(), Vec::<&str>::new()) + .await, + vec![id], + "failed for {object:?}" + ); + } + + // Cancel all reports + admin + .registry_destroy_all(ObjectType::DmarcInternalReport) + .await; + admin + .registry_destroy_all(ObjectType::TlsInternalReport) + .await; + assert_eq!( + admin.registry_get_all::().await, + Vec::new() + ); + assert_eq!( + admin.registry_get_all::().await, + Vec::new() + ); +} diff --git a/tests/src/smtp/mod.rs b/tests/src/smtp/mod.rs new file mode 100644 index 0000000..3e831ea --- /dev/null +++ b/tests/src/smtp/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod inbound; +pub mod lookup; +pub mod management; +pub mod outbound; +pub mod queue; +pub mod reporting; +pub mod session; diff --git a/tests/src/smtp/outbound/dane.rs b/tests/src/smtp/outbound/dane.rs new file mode 100644 index 0000000..6ec3118 --- /dev/null +++ b/tests/src/smtp/outbound/dane.rs @@ -0,0 +1,1669 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestQueueEvent, TestReportingEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{ + config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching}, + ipc::PolicyType, +}; +use mail_auth::{ + DnssecStatus, MX, + common::parse::TxtRecordParser, + mta_sts::{ReportUri, TlsRpt}, + report::tlsrpt::ResultType, +}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, + KeyUsagePurpose, PublicKeyData, date_time_ymd, +}; +use registry::schema::{ + enums::MtaRequiredOrOptional, + prelude::ObjectType, + structs::{Expression, MtaTlsStrategy, TlsReportSettings}, +}; +use rustls_pki_types::CertificateDer; +use sha2::{Digest, Sha256}; +use smtp::outbound::dane::{ + dnssec::{TlsaLookup, TlsaResult}, + verify::TlsaVerify, +}; +use smtp::queue::{Error, ErrorDetails, Status}; +use std::{ + collections::BTreeSet, + fs::{self, File}, + io::{BufRead, BufReader}, + num::ParseIntError, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +use store::write::now; + +#[tokio::test] +#[serial_test::serial] +async fn dane_verify() { + let mut local = TestServerBuilder::new("smtp_dane_verify_local") + .await + .with_http_listener(19018) + .await + .disable_services() + .capture_queue() + .capture_reporting() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_dane_verify_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19019) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin + .registry_create_object(TlsReportSettings { + send_frequency: Expression { + else_: "weekly".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Require; + tls_strategy.start_tls = MtaRequiredOrOptional::Require; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_add_all_headers().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + local.server.txt_add( + "_smtp._tls.foobar.org", + TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:reports@foobar.org").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + local.assert_no_events(); + + // Expect TLS failure report + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.domain, "foobar.org"); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DaneRequired + ); + assert_eq!( + report.failure.as_ref().unwrap().receiving_mx_hostname, + Some("mx.foobar.org".to_string()) + ); + assert_eq!( + report.tls_record.rua, + vec![ReportUri::Mail("reports@foobar.org".to_string())] + ); + + // DANE failure with no matching certificates + let tlsa = Arc::new(Tlsa { + entries: vec![TlsaEntry { + is_end_entity: true, + is_spki: true, + matching: TlsaMatching::Sha256, + data: vec![1, 2, 3], + }], + has_end_entities: true, + has_intermediates: false, + }); + local.server.tlsa_add( + "_25._tcp.mx.foobar.org", + tlsa.clone(), + Instant::now() + Duration::from_secs(10), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + local.assert_no_events(); + + // Expect TLS failure report + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.policy, PolicyType::Tlsa(tlsa.into())); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::ValidationFailure + ); + remote.assert_no_events(); + + // DANE successful delivery + let tlsa = Arc::new(Tlsa { + entries: vec![TlsaEntry { + is_end_entity: true, + is_spki: true, + matching: TlsaMatching::Sha256, + data: vec![ + 73, 186, 44, 106, 13, 198, 100, 180, 0, 44, 158, 188, 15, 195, 39, 198, 61, 254, + 215, 237, 100, 26, 15, 155, 219, 235, 120, 64, 128, 172, 17, 0, + ], + }], + has_end_entities: true, + has_intermediates: false, + }); + local.server.tlsa_add( + "_25._tcp.mx.foobar.org", + tlsa.clone(), + Instant::now() + Duration::from_secs(10), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_done(); + local.assert_no_events(); + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_contains("using TLSv1.3 with cipher"); + + // Expect TLS success report + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.policy, PolicyType::Tlsa(tlsa.into())); + assert!(report.failure.is_none()); + + // An insecure (non-DNSSEC) MX zone must not honor TLSA records, + // even when valid records are cached. + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Insecure, + Instant::now() + Duration::from_secs(10), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + local.assert_no_events(); + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DaneRequired + ); + remote.assert_no_events(); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_tlsa_lookup_error_defers() { + let mut local = TestServerBuilder::new("smtp_dane_downgrade_local") + .await + .with_http_listener(19020) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_dane_downgrade_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19021) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Optional; + tls_strategy.start_tls = MtaRequiredOrOptional::Require; + tls_strategy.allow_invalid_certs = true; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_add_all_headers().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx._dns_error.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add_dnssec( + "mx._dns_error.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + remote.assert_no_events(); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_skipped_when_mx_host_zone_is_insecure() { + let mut local = TestServerBuilder::new("smtp_dane_insecure_host_local") + .await + .with_http_listener(19053) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_dane_insecure_host_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19054) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Optional; + tls_strategy.start_tls = MtaRequiredOrOptional::Require; + tls_strategy.allow_invalid_certs = true; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_add_all_headers().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx._dns_error.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add_dnssec( + "mx._dns_error.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + DnssecStatus::Insecure, + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_done(); + local.assert_no_events(); + + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_contains("using TLSv1.3 with cipher"); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_required_fails_when_mx_host_zone_is_insecure() { + let mut local = TestServerBuilder::new("smtp_dane_insecure_required_local") + .await + .with_http_listener(19055) + .await + .disable_services() + .capture_queue() + .capture_reporting() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_dane_insecure_required_remote") + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .with_http_listener(19056) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Require; + tls_strategy.allow_invalid_certs = true; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin + .registry_create_object(TlsReportSettings { + send_frequency: Expression { + else_: "weekly".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_add_all_headers().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx._dns_error.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add_dnssec( + "mx._dns_error.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + DnssecStatus::Insecure, + Instant::now() + Duration::from_secs(10), + ); + local.server.txt_add( + "_smtp._tls.foobar.org", + TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:reports@foobar.org").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + local.assert_no_events(); + + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.domain, "foobar.org"); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DaneRequired + ); + assert_eq!( + report.failure.as_ref().unwrap().failure_reason_code, + Some("MX host is not in a DNSSEC signed zone.".to_string()) + ); + remote.assert_no_events(); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_bogus_dnssec_temp_fails() { + let mut local = TestServerBuilder::new("smtp_dane_bogus_local") + .await + .with_http_listener(19022) + .await + .disable_services() + .capture_queue() + .capture_reporting() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin + .registry_create_object(TlsReportSettings { + send_frequency: Expression { + else_: "weekly".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.dane = MtaRequiredOrOptional::Require; + tls_strategy.start_tls = MtaRequiredOrOptional::Require; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + local.server.txt_add( + "_smtp._tls.foobar.org", + TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:reports@foobar.org").unwrap(), + Instant::now() + Duration::from_secs(30), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(30), + ); + local.server.ipv4_add( + "mx._dnssec_bogus.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(30), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Bogus, + Instant::now() + Duration::from_secs(30), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.domain, "foobar.org"); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DnssecInvalid + ); + assert_eq!( + report.failure.as_ref().unwrap().receiving_mx_hostname, + Some("mx.foobar.org".to_string()) + ); + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx._dnssec_bogus.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(30), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let retry = local.expect_message().await; + assert!(retry.message.recipients[0].retry.due > now()); + + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.domain, "foobar.org"); + assert_eq!(report.policy, PolicyType::Tlsa(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::DnssecInvalid + ); + assert_eq!( + report.failure.as_ref().unwrap().receiving_mx_hostname, + Some("mx._dnssec_bogus.foobar.org".to_string()) + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn dane_test() { + let test = TestServerBuilder::new("smtp_dane_remote") + .await + .with_http_listener(19036) + .await + .disable_services() + .build() + .await; + + // Add dns entries + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("resources"); + path.push("smtp"); + path.push("dane"); + let mut file = path.clone(); + file.push("dns.txt"); + + let mut hosts = BTreeSet::new(); + let mut tlsa = Tlsa { + entries: Vec::new(), + has_end_entities: false, + has_intermediates: false, + }; + let mut hostname = String::new(); + + for line in BufReader::new(File::open(file).unwrap()).lines() { + let line = line.unwrap(); + let mut is_end_entity = false; + for (pos, item) in line.split_whitespace().enumerate() { + match pos { + 0 => { + if hostname != item && !hostname.is_empty() { + test.server.tlsa_add( + hostname, + tlsa.into(), + Instant::now() + Duration::from_secs(30), + ); + tlsa = Tlsa { + entries: Vec::new(), + has_end_entities: false, + has_intermediates: false, + }; + } + hosts.insert(item.strip_prefix("_25._tcp.").unwrap().to_string()); + hostname = item.to_string(); + } + 1 => { + is_end_entity = item == "3"; + } + 4 => { + if is_end_entity { + tlsa.has_end_entities = true; + } else { + tlsa.has_intermediates = true; + } + tlsa.entries.push(TlsaEntry { + is_end_entity, + is_spki: true, + matching: TlsaMatching::Sha256, + data: decode_hex(item).unwrap(), + }); + } + _ => (), + } + } + } + test.server.tlsa_add( + hostname, + tlsa.into(), + Instant::now() + Duration::from_secs(30), + ); + + // Add certificates + assert!(!hosts.is_empty()); + for host in hosts { + // Add certificates + let mut certs = Vec::new(); + for num in 0..6 { + let mut file = path.clone(); + file.push(format!("{host}.{num}.cert")); + if file.exists() { + certs.push(CertificateDer::from(fs::read(file).unwrap())); + } else { + break; + } + } + + // Successful DANE verification (end-entity match, RFC 7671 Section 5.1) + let tlsa = match test + .server + .tlsa_lookup(format!("_25._tcp.{host}.")) + .await + .unwrap() + { + TlsaResult::Secure(tlsa) => tlsa, + _ => panic!("expected secure TLSA records"), + }; + + assert_eq!( + tlsa.verify(0, &host, &[host.as_str()], Some(&certs)), + Ok(()) + ); + + // Failed DANE verification + certs.remove(0); + assert_eq!( + tlsa.verify(0, &host, &[host.as_str()], Some(&certs)), + Err(Status::TemporaryFailure(ErrorDetails { + entity: host.into(), + details: Error::DaneError("No matching certificates found in TLSA records".into()) + })) + ); + } +} + +pub fn decode_hex(s: &str) -> Result, ParseIntError> { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) + .collect() +} + +struct TestCa { + issuer: Issuer<'static, KeyPair>, + der: CertificateDer<'static>, + spki: Vec, +} + +#[test] +fn dane_ee_matches_leaf_ignoring_name() { + let root = root_ca("EE Root"); + let intermediate = sub_ca("EE Intermediate", &root); + let (leaf_der, leaf_spki) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone()]; + + let record = tlsa(vec![ee_spki_sha256(&leaf_spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["name.mismatch.example"], Some(&chain)) + .is_ok() + ); + + let wrong = tlsa(vec![ee_spki_sha256(&root.spki)]); + assert!( + wrong + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ee_ignores_expiration() { + let root = root_ca("Expired EE Root"); + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(vec!["mx.foobar.org".to_string()]).unwrap(); + params.not_before = date_time_ymd(2018, 1, 1); + params.not_after = date_time_ymd(2021, 1, 1); + params + .distinguished_name + .push(DnType::CommonName, "mx.foobar.org"); + let leaf = params.signed_by(&key, &root.issuer).unwrap(); + let leaf_spki = key.subject_public_key_info(); + let chain = vec![leaf.der().clone()]; + + let record = tlsa(vec![ee_spki_sha256(&leaf_spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_anchors_at_private_intermediate() { + let root = root_ca("TA Private Root"); + let intermediate = sub_ca("TA Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_matches_when_ee_present_but_unmatched() { + let root = root_ca("Mixed Root"); + let intermediate = sub_ca("Mixed Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ + ee_spki_sha256(&root.spki), + ta_full_sha256(&intermediate.der), + ]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_requires_name_match() { + let root = root_ca("Name Root"); + let intermediate = sub_ca("Name Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["other.example"], Some(&chain)) + .is_err() + ); + assert!( + record + .verify( + 0, + "mx.foobar.org", + &["other.example", "mx.foobar.org"], + Some(&chain) + ) + .is_ok() + ); +} + +#[test] +fn dane_ta_rejects_unrelated_ca_matching_hash() { + let root = root_ca("Forgery Root"); + let intermediate = sub_ca("Forgery Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let unrelated = root_ca("Unrelated CA"); + let chain = vec![leaf_der, intermediate.der.clone(), unrelated.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&unrelated.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_rejects_expired_intermediate() { + let root = root_ca("Validity Root"); + let int_key = KeyPair::generate().unwrap(); + let mut int_params = ca_params("Expired Intermediate"); + int_params.not_before = date_time_ymd(2018, 1, 1); + int_params.not_after = date_time_ymd(2021, 1, 1); + let int_cert = int_params.signed_by(&int_key, &root.issuer).unwrap(); + let intermediate = TestCa { + der: int_cert.der().clone(), + spki: int_key.subject_public_key_info(), + issuer: Issuer::new(int_params, int_key), + }; + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&root.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_bare_key_anchors_chain() { + let root = root_ca("Bare Key Root"); + let intermediate = sub_ca("Bare Key Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone()]; + + let record = tlsa(vec![ta_spki_full(&root.spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); + + let unrelated = root_ca("Bare Key Unrelated"); + let wrong = tlsa(vec![ta_spki_full(&unrelated.spki)]); + assert!( + wrong + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +fn ca_params(common_name: &str) -> CertificateParams { + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params +} + +fn root_ca(common_name: &str) -> TestCa { + let key = KeyPair::generate().unwrap(); + let params = ca_params(common_name); + let cert = params.self_signed(&key).unwrap(); + let der = cert.der().clone(); + let spki = key.subject_public_key_info(); + TestCa { + issuer: Issuer::new(params, key), + der, + spki, + } +} + +fn sub_ca(common_name: &str, parent: &TestCa) -> TestCa { + let key = KeyPair::generate().unwrap(); + let params = ca_params(common_name); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + let der = cert.der().clone(); + let spki = key.subject_public_key_info(); + TestCa { + issuer: Issuer::new(params, key), + der, + spki, + } +} + +fn sub_ca_path_len(common_name: &str, parent: &TestCa, path_len: u8) -> TestCa { + let key = KeyPair::generate().unwrap(); + let mut params = ca_params(common_name); + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(path_len)); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + let der = cert.der().clone(); + let spki = key.subject_public_key_info(); + TestCa { + issuer: Issuer::new(params, key), + der, + spki, + } +} + +fn leaf_cert(san: &str, parent: &TestCa) -> (CertificateDer<'static>, Vec) { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(vec![san.to_string()]).unwrap(); + params.distinguished_name.push(DnType::CommonName, san); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + (cert.der().clone(), key.subject_public_key_info()) +} + +fn ee_spki_sha256(spki: &[u8]) -> TlsaEntry { + TlsaEntry { + is_end_entity: true, + is_spki: true, + matching: TlsaMatching::Sha256, + data: Sha256::digest(spki).to_vec(), + } +} + +fn ta_full_sha256(der: &CertificateDer<'_>) -> TlsaEntry { + TlsaEntry { + is_end_entity: false, + is_spki: false, + matching: TlsaMatching::Sha256, + data: Sha256::digest(der.as_ref()).to_vec(), + } +} + +fn ta_spki_full(spki: &[u8]) -> TlsaEntry { + TlsaEntry { + is_end_entity: false, + is_spki: true, + matching: TlsaMatching::Full, + data: spki.to_vec(), + } +} + +fn tlsa(entries: Vec) -> Tlsa { + Tlsa { + has_end_entities: entries.iter().any(|entry| entry.is_end_entity), + has_intermediates: entries.iter().any(|entry| !entry.is_end_entity), + entries, + } +} + +#[test] +fn dane_ta_does_not_match_leaf() { + let root = root_ca("Depth Root"); + let intermediate = sub_ca("Depth Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der.clone(), intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&leaf_der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); + + let ee = tlsa(vec![ee_full_sha256(&leaf_der)]); + assert!( + ee.verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_bare_key_in_chain() { + let root = root_ca("In Chain Root"); + let intermediate = sub_ca("In Chain Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_spki_full(&intermediate.spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_bare_key_anchors_deep_chain() { + let root = root_ca("Deep Bare Key Root"); + let intermediate1 = sub_ca("Deep Bare Key Intermediate 1", &root); + let intermediate2 = sub_ca("Deep Bare Key Intermediate 2", &intermediate1); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate2); + let chain = vec![ + leaf_der, + intermediate2.der.clone(), + intermediate1.der.clone(), + ]; + + let record = tlsa(vec![ta_spki_full(&root.spki)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); + + let unrelated = root_ca("Deep Bare Key Unrelated"); + let wrong = tlsa(vec![ta_spki_full(&unrelated.spki)]); + assert!( + wrong + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_rejects_path_len_violation() { + let root = root_ca("Path Len Root"); + let constrained = sub_ca_path_len("Path Len Constrained", &root, 0); + let extra = sub_ca("Path Len Extra", &constrained); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &extra); + let chain = vec![ + leaf_der, + extra.der.clone(), + constrained.der.clone(), + root.der.clone(), + ]; + + let record = tlsa(vec![ta_full_sha256(&root.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); + + let allowed = root_ca("Path Len Allowed Root"); + let constrained = sub_ca_path_len("Path Len Allowed Constrained", &allowed, 0); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &constrained); + let chain = vec![leaf_der, constrained.der.clone(), allowed.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&allowed.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_validates_unordered_padded_chain() { + let root = root_ca("Unordered Root"); + let intermediate = sub_ca("Unordered Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &intermediate); + let noise = root_ca("Unordered Noise"); + let chain = vec![ + leaf_der, + root.der.clone(), + noise.der.clone(), + intermediate.der.clone(), + ]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_rejects_non_ca_issuer() { + let root = root_ca("Non CA Root"); + let forged = sub_ca_non_ca("Non CA Intermediate", &root); + let (leaf_der, _) = leaf_cert("mx.foobar.org", &forged); + let chain = vec![leaf_der, forged.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&root.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_rejects_ee_without_server_auth_eku() { + let root = root_ca("EKU Root"); + let intermediate = sub_ca("EKU Intermediate", &root); + let (leaf_der, _) = leaf_cert_eku( + "mx.foobar.org", + &intermediate, + vec![ExtendedKeyUsagePurpose::ClientAuth], + ); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +#[test] +fn dane_ta_accepts_ee_with_server_auth_eku() { + let root = root_ca("EKU OK Root"); + let intermediate = sub_ca("EKU OK Intermediate", &root); + let (leaf_der, _) = leaf_cert_eku( + "mx.foobar.org", + &intermediate, + vec![ExtendedKeyUsagePurpose::ServerAuth], + ); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_ok() + ); +} + +#[test] +fn dane_ta_ignores_common_name() { + let root = root_ca("CN Root"); + let intermediate = sub_ca("CN Intermediate", &root); + let leaf_der = leaf_cert_cn_only("mx.foobar.org", &intermediate); + let chain = vec![leaf_der, intermediate.der.clone(), root.der.clone()]; + + let record = tlsa(vec![ta_full_sha256(&intermediate.der)]); + assert!( + record + .verify(0, "mx.foobar.org", &["mx.foobar.org"], Some(&chain)) + .is_err() + ); +} + +fn sub_ca_non_ca(common_name: &str, parent: &TestCa) -> TestCa { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::ExplicitNoCa; + params + .distinguished_name + .push(DnType::CommonName, common_name); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + let der = cert.der().clone(); + let spki = key.subject_public_key_info(); + TestCa { + issuer: Issuer::new(params, key), + der, + spki, + } +} + +fn leaf_cert_eku( + san: &str, + parent: &TestCa, + ekus: Vec, +) -> (CertificateDer<'static>, Vec) { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(vec![san.to_string()]).unwrap(); + params.distinguished_name.push(DnType::CommonName, san); + params.extended_key_usages = ekus; + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + (cert.der().clone(), key.subject_public_key_info()) +} + +fn leaf_cert_cn_only(common_name: &str, parent: &TestCa) -> CertificateDer<'static> { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params + .distinguished_name + .push(DnType::CommonName, common_name); + let cert = params.signed_by(&key, &parent.issuer).unwrap(); + cert.der().clone() +} + +fn ee_full_sha256(der: &CertificateDer<'_>) -> TlsaEntry { + TlsaEntry { + is_end_entity: true, + is_spki: false, + matching: TlsaMatching::Sha256, + data: Sha256::digest(der.as_ref()).to_vec(), + } +} + +#[tokio::test] +#[ignore = "live network test: requires outbound TCP port 25 and a DNSSEC-validating resolver path"] +async fn dane_live_smtp_hosts() { + use mail_auth::hickory_resolver::{ + TokioResolver, + config::{CLOUDFLARE, ResolverConfig, ResolverOpts}, + net::runtime::TokioRuntimeProvider, + proto::rr::{ + Name, RData, + rdata::tlsa::{CertUsage, Matching, Selector}, + }, + }; + use smtp::outbound::client::{SmtpClient, StartTlsResult}; + use std::net::{IpAddr, SocketAddr}; + use tokio::io::AsyncWriteExt; + use tokio_rustls::TlsConnector; + use utils::tls::build_tls_connector; + + enum DaneProbe { + Verified { + mx: String, + usages: Vec, + chain_len: usize, + native_ee: bool, + native_ta: bool, + webpki_forced: Option, + }, + Skipped(String), + Failed { + mx: String, + reason: String, + }, + } + + async fn probe(resolver: &TokioResolver, connector: &TlsConnector, domain: &str) -> DaneProbe { + let mx_lookup = match resolver.mx_lookup(format!("{domain}.")).await { + Ok(lookup) => lookup, + Err(err) => return DaneProbe::Skipped(format!("MX lookup failed: {err}")), + }; + let mut mx_hosts: Vec<(u16, String)> = mx_lookup + .answers() + .iter() + .filter_map(|record| match &record.data { + RData::MX(mx) => Some(( + mx.preference, + mx.exchange.to_string().trim_end_matches('.').to_string(), + )), + _ => None, + }) + .collect(); + mx_hosts.sort_by_key(|(preference, _)| *preference); + if mx_hosts.is_empty() { + mx_hosts.push((0, domain.to_string())); + } + + for (_, mx) in &mx_hosts { + let tlsa_name = match Name::from_str_relaxed(format!("_25._tcp.{mx}.")) { + Ok(name) => name, + Err(err) => { + return DaneProbe::Skipped(format!("invalid TLSA name for {mx}: {err}")); + } + }; + let tlsa_lookup = match resolver.tlsa_lookup(tlsa_name).await { + Ok(lookup) => lookup, + Err(_) => continue, + }; + + let mut entries = Vec::new(); + let mut usages = Vec::new(); + let mut has_end_entities = false; + let mut has_intermediates = false; + for record in tlsa_lookup.answers() { + if let RData::TLSA(tlsa) = &record.data { + if !record.proof.is_secure() { + continue; + } + let is_end_entity = match tlsa.cert_usage { + CertUsage::DaneEe => true, + CertUsage::DaneTa => false, + _ => continue, + }; + let matching = match tlsa.matching { + Matching::Raw => TlsaMatching::Full, + Matching::Sha256 => TlsaMatching::Sha256, + Matching::Sha512 => TlsaMatching::Sha512, + _ => continue, + }; + let is_spki = match tlsa.selector { + Selector::Spki => true, + Selector::Full => false, + _ => continue, + }; + if is_end_entity { + has_end_entities = true; + } else { + has_intermediates = true; + } + let usage = format!( + "{} {} {}", + if is_end_entity { 3 } else { 2 }, + if is_spki { 1 } else { 0 }, + match matching { + TlsaMatching::Full => 0, + TlsaMatching::Sha256 => 1, + TlsaMatching::Sha512 => 2, + } + ); + if !usages.contains(&usage) { + usages.push(usage); + } + entries.push(TlsaEntry { + is_end_entity, + is_spki, + matching, + data: tlsa.cert_data.clone(), + }); + } + } + if entries.is_empty() { + continue; + } + let tlsa = Tlsa { + entries, + has_end_entities, + has_intermediates, + }; + + let mut ips: Vec = match resolver.lookup_ip(format!("{mx}.")).await { + Ok(ips) => ips.iter().collect(), + Err(err) => { + return DaneProbe::Skipped(format!("address lookup failed for {mx}: {err}")); + } + }; + if ips.is_empty() { + return DaneProbe::Skipped(format!("no A/AAAA records for {mx}")); + } + ips.sort_by_key(|ip| ip.is_ipv6()); + + let mut connected = None; + let mut last_error = String::new(); + for ip in ips { + match SmtpClient::connect(SocketAddr::new(ip, 25), Duration::from_secs(20), 0).await + { + Ok(client) => { + connected = Some(client); + break; + } + Err(err) => { + last_error = format!("connect to [{ip}]:25 ({mx}) failed: {err:?}"); + } + } + } + let mut client = match connected { + Some(client) => client, + None => return DaneProbe::Skipped(last_error), + }; + if let Err(err) = client.read_greeting(mx).await { + return DaneProbe::Skipped(format!("greeting from {mx} failed: {err:?}")); + } + if client + .stream + .write_all(b"EHLO dane-live-test.invalid\r\n") + .await + .is_err() + { + return DaneProbe::Skipped(format!("EHLO write to {mx} failed")); + } + let _ = client.stream.flush().await; + let capabilities = match client.read_ehlo().await { + Ok(capabilities) => capabilities, + Err(err) => return DaneProbe::Skipped(format!("EHLO to {mx} failed: {err:?}")), + }; + + let tls_client = match client.try_start_tls(connector, mx, &capabilities).await { + StartTlsResult::Success { smtp_client } => smtp_client, + StartTlsResult::Unavailable { .. } => { + return DaneProbe::Skipped(format!("{mx} does not offer STARTTLS")); + } + StartTlsResult::Error { error } => { + return DaneProbe::Skipped(format!("STARTTLS with {mx} failed: {error:?}")); + } + }; + + let certificates = match tls_client.tls_connection().peer_certificates() { + Some(certificates) => certificates.to_vec(), + None => { + return DaneProbe::Failed { + mx: mx.clone(), + reason: "server presented no certificates after TLS handshake".into(), + }; + } + }; + + let reference_ids = [mx.as_str(), domain]; + + if let Err(status) = tlsa.verify(0, mx, &reference_ids, Some(&certificates)) { + return DaneProbe::Failed { + mx: mx.clone(), + reason: format!("TLSA verification rejected a live DANE host: {status:?}"), + }; + } + + let verify_subset = |keep_end_entity: bool| { + let entries: Vec = tlsa + .entries + .iter() + .filter(|entry| entry.is_end_entity == keep_end_entity) + .cloned() + .collect(); + if entries.is_empty() { + return false; + } + Tlsa { + has_end_entities: keep_end_entity, + has_intermediates: !keep_end_entity, + entries, + } + .verify(0, mx, &reference_ids, Some(&certificates)) + .is_ok() + }; + let native_ee = verify_subset(true); + let native_ta = verify_subset(false); + + // Force the rustls-webpki trust-chain path + let webpki_forced = (certificates.len() >= 2).then(|| { + let anchor = certificates.last().unwrap(); + Tlsa { + entries: vec![TlsaEntry { + is_end_entity: false, + is_spki: false, + matching: TlsaMatching::Sha256, + data: Sha256::digest(anchor.as_ref()).to_vec(), + }], + has_end_entities: false, + has_intermediates: true, + } + .verify(0, mx, &reference_ids, Some(&certificates)) + .is_ok() + }); + + return DaneProbe::Verified { + mx: mx.clone(), + usages, + chain_len: certificates.len(), + native_ee, + native_ta, + webpki_forced, + }; + } + + DaneProbe::Skipped("no MX host published usable secure TLSA records".into()) + } + + let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut opts = ResolverOpts::default(); + opts.validate = true; + opts.cache_size = 0; + let resolver = TokioResolver::builder_with_config( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + TokioRuntimeProvider::default(), + ) + .with_options(opts) + .build() + .expect("failed to build DNSSEC-validating resolver"); + let connector = build_tls_connector(true).expect("failed to build TLS connector"); + + let domains = [ + "dukhovni.org", + "nlnetlabs.nl", + "debian.org", + "freebsd.org", + "posteo.de", + "mailbox.org", + ]; + + let mut verified = 0usize; + let mut webpki_chain_validated = 0usize; + let mut hard_failures = Vec::new(); + for domain in domains { + match probe(&resolver, &connector, domain).await { + DaneProbe::Verified { + mx, + usages, + chain_len, + native_ee, + native_ta, + webpki_forced, + } => { + verified += 1; + let native_path = match (native_ee, native_ta) { + (true, true) => "EE+TA", + (true, false) => "EE (no webpki)", + (false, true) => "TA (webpki)", + (false, false) => "?", + }; + let forced = match webpki_forced { + Some(true) => { + webpki_chain_validated += 1; + "PASS" + } + Some(false) => "FAIL", + None => "n/a (single-cert chain)", + }; + if native_ta { + webpki_chain_validated += 1; + } + println!( + "[ OK ] {domain}: MX {mx} | TLSA [{}] | chain {chain_len} certs | native path: {native_path} | forced webpki-TA vs real chain: {forced}", + usages.join(", ") + ); + if webpki_forced == Some(false) { + hard_failures.push(format!( + "{domain} (MX {mx}): rustls-webpki rejected the server's own presented chain" + )); + } + } + DaneProbe::Skipped(reason) => { + println!("[SKIP] {domain}: {reason}"); + } + DaneProbe::Failed { mx, reason } => { + println!("[FAIL] {domain} (MX {mx}): {reason}"); + hard_failures.push(format!("{domain} (MX {mx}): {reason}")); + } + } + } + + assert!( + hard_failures.is_empty(), + "DANE verification rejected hosts that published valid secure TLSA records: {hard_failures:#?}" + ); + assert!( + verified > 0, + "no DANE-enabled host could be reached and verified; check outbound port 25 and DNSSEC connectivity" + ); + assert!( + webpki_chain_validated > 0, + "no host exercised the rustls-webpki trust-chain path; the live test only covered DANE-EE direct matches" + ); +} diff --git a/tests/src/smtp/outbound/extensions.rs b/tests/src/smtp/outbound/extensions.rs new file mode 100644 index 0000000..07037d1 --- /dev/null +++ b/tests/src/smtp/outbound/extensions.rs @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestQueueEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{DnssecStatus, MX}; +use registry::schema::structs::{Expression, MtaStageData}; +use smtp_proto::{MAIL_REQUIRETLS, MAIL_RET_HDRS, MAIL_SMTPUTF8, RCPT_NOTIFY_NEVER}; +use std::time::{Duration, Instant}; + +#[tokio::test] +#[serial_test::serial] +async fn extensions() { + let mut local = TestServerBuilder::new("smtp_ext_local") + .await + .with_http_listener(19020) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_ext_remote") + .await + .with_http_listener(19021) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin.mta_all_extensions().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_all_extensions().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin + .registry_create_object(MtaStageData { + max_message_size: Expression { + else_: "1500".into(), + ..Default::default() + }, + add_date_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_message_id_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_spf_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_auth_results_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_return_path_header: Expression { + else_: "false".into(), + ..Default::default() + }, + enable_spam_filter: Expression { + else_: "false".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message( + "john@test.org", + &[" NOTIFY=SUCCESS,FAILURE"], + "test:no_dkim", + "250", + ) + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (delivered to") + .assert_contains("Final-Recipient: rfc822;bill@foobar.org") + .assert_contains("Action: delivered"); + local.read_event().await.assert_done(); + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_contains("using TLSv1.3 with cipher"); + + // Test SIZE extension + session + .send_message("john@test.org", &["bill@foobar.org"], "test:arc", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (host 'mx.foobar.org' rejected command 'MAIL FROM:") + .assert_contains("Action: failed") + .assert_contains("Diagnostic-Code: smtp;552") + .assert_contains("Status: 5.3.4"); + local.read_event().await.assert_done(); + remote.assert_no_events(); + + // Test DSN, SMTPUTF8 and REQUIRETLS extensions + session + .send_message( + " ENVID=abc123 RET=HDRS REQUIRETLS SMTPUTF8", + &[" NOTIFY=NEVER"], + "test:no_dkim", + "250", + ) + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_done(); + let message = remote.expect_message().await; + assert_eq!(message.message.env_id, Some("abc123".into())); + assert!((message.message.flags & MAIL_RET_HDRS) != 0); + assert!((message.message.flags & MAIL_REQUIRETLS) != 0); + assert!((message.message.flags & MAIL_SMTPUTF8) != 0); + assert!((message.message.recipients.last().unwrap().flags & RCPT_NOTIFY_NEVER) != 0); +} diff --git a/tests/src/smtp/outbound/fallback_relay.rs b/tests/src/smtp/outbound/fallback_relay.rs new file mode 100644 index 0000000..a636399 --- /dev/null +++ b/tests/src/smtp/outbound/fallback_relay.rs @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::TestSession, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::MtaProtocol, + structs::{ + Expression, ExpressionMatch, MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt, + }, + }, + types::list::List, +}; +use std::time::{Duration, Instant}; +use store::write::now; + +#[tokio::test] +#[serial_test::serial] +async fn fallback_relay() { + let mut local = TestServerBuilder::new("smtp_fallback_local") + .await + .with_http_listener(19022) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_fallback_remote") + .await + .with_http_listener(19023) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin + .registry_create_object(MtaStageRcpt { + max_recipients: Expression { + else_: "100".into(), + ..Default::default() + }, + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaOutboundStrategy { + route: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "retry_num > 0".into(), + then: "'fallback'".into(), + }]), + else_: "'mx'".into(), + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaRoute::Relay(MtaRouteRelay { + address: "fallback.foobar.org".into(), + implicit_tls: false, + allow_invalid_certs: true, + name: "fallback".into(), + port: 9925, + protocol: MtaProtocol::Smtp, + ..Default::default() + })) + .await; + local_admin.mta_no_auth().await; + local_admin.mta_all_extensions().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.mta_all_extensions().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["_dns_error.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "fallback.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + let mut retry = local.expect_message().await; + let prev_due = retry.message.recipients[0].retry.due; + let next_due = now(); + let queue_id = retry.queue_id; + retry.message.recipients[0].retry.due = next_due; + retry.save_changes(&local.server, prev_due.into()).await; + local + .delivery_attempt(queue_id) + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + remote.expect_message().await; +} diff --git a/tests/src/smtp/outbound/ip_lookup.rs b/tests/src/smtp/outbound/ip_lookup.rs new file mode 100644 index 0000000..550b11a --- /dev/null +++ b/tests/src/smtp/outbound/ip_lookup.rs @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::TestSession, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::MtaIpStrategy, + prelude::{ObjectType, Property}, + structs::MtaRoute, + }, + types::EnumImpl, +}; +use serde_json::json; +use std::time::{Duration, Instant}; + +#[tokio::test] +#[serial_test::serial] +async fn ip_lookup_strategy() { + let mut local = TestServerBuilder::new("smtp_iplookup_local") + .await + .with_http_listener(19024) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_iplookup_remote") + .await + .with_http_listener(19025) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin.reload_settings().await; + let (mx_route_id, _) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| matches!(s, MtaRoute::Mx(_))) + .unwrap(); + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + for strategy in [MtaIpStrategy::V6Only, MtaIpStrategy::V6ThenV4] { + local + .account("admin") + .registry_update_object( + ObjectType::MtaRoute, + mx_route_id, + json!({ + Property::IpLookupStrategy: strategy.as_str(), + }), + ) + .await; + local.account("admin").reload_settings().await; + local.expect_reload_settings().await; + + println!("-> Strategy: {:?}", strategy); + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + if matches!(strategy, MtaIpStrategy::V6ThenV4) { + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + } + local.server.ipv6_add( + "mx.foobar.org", + vec!["::1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + // Retry on failed STARTTLS + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + if matches!(strategy, MtaIpStrategy::V6ThenV4) { + remote.expect_message().await; + } else { + let message = local.last_queued_message().await; + let status = message.message.recipients[0].status.to_string(); + assert!( + status.contains("Connection refused"), + "Message: {:?}", + message + ); + local.expect_refresh().await; + } + } +} diff --git a/tests/src/smtp/outbound/lmtp.rs b/tests/src/smtp/outbound/lmtp.rs new file mode 100644 index 0000000..e2aee41 --- /dev/null +++ b/tests/src/smtp/outbound/lmtp.rs @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{config::smtp::queue::QueueName, ipc::QueueEvent}; +use registry::{ + schema::{ + enums::{MtaProtocol, NetworkListenerProtocol}, + structs::{ + Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliveryExpiration, + MtaDeliveryExpirationTtl, MtaDeliverySchedule, MtaDeliveryScheduleInterval, + MtaDeliveryScheduleIntervals, MtaDeliveryScheduleIntervalsOrDefault, + MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt, MtaVirtualQueue, + }, + }, + types::list::List, +}; +use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool}; +use std::time::{Duration, Instant}; +use store::write::now; + +#[tokio::test] +#[serial_test::serial] +async fn lmtp_delivery() { + let mut local = TestServerBuilder::new("lmtp_delivery_local") + .await + .with_http_listener(19026) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("lmtp_delivery_remote") + .await + .with_http_listener(19027) + .await + .with_listener(NetworkListenerProtocol::Lmtp, "lmtp-debug", 9924, true) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin + .registry_create_object(MtaStageRcpt { + max_recipients: Expression { + else_: "100".into(), + + ..Default::default() + }, + allow_relaying: Expression { + else_: "true".into(), + + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaOutboundStrategy { + route: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "rcpt_domain = 'foobar.org'".into(), + then: "'lmtp'".into(), + }]), + else_: "'mx'".into(), + }, + schedule: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "rcpt_domain = 'foobar.org'".into(), + then: "'foobar'".into(), + }]), + else_: "'default'".into(), + }, + connection: Expression { + else_: "'impatient'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaRoute::Relay(MtaRouteRelay { + address: "lmtp.foobar.org".into(), + allow_invalid_certs: true, + implicit_tls: true, + name: "lmtp".into(), + port: 9924, + protocol: MtaProtocol::Lmtp, + ..Default::default() + })) + .await; + let queue_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "foobar".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 2_000u64.into(), + }, + ]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 4_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 5_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaConnectionStrategy { + name: "impatient".into(), + connect_timeout: 1_000u64.into(), + data_timeout: 50u64.into(), + ..Default::default() + }) + .await; + local_admin.mta_no_auth().await; + local_admin.mta_all_extensions().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.mta_all_extensions().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.ipv4_add( + "lmtp.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message( + "john@test.org", + &[ + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + ], + "test:no_dkim", + "250", + ) + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(500)).await; + let mut dsn = Vec::new(); + loop { + match local.try_read_event().await { + Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {} + Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(), + None | Some(QueueEvent::Stop) => break, + } + + let mut events = local.all_queued_messages().await; + if events.messages.is_empty() { + let now = now(); + if events.next_refresh < now + QUEUE_REFRESH { + tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await; + events = local.all_queued_messages().await; + } else { + break; + } + } + for event in events.messages { + let message = local + .server + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message + .clone() + .remove(&local.server, event.due.into()) + .await; + dsn.push(message); + } else { + event.try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + local.assert_queue_is_empty().await; + assert_eq!(dsn.len(), 4); + + let mut dsn = dsn.into_iter(); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (delivered to") + .assert_contains(" (delivered to") + .assert_contains(" (delivered to") + .assert_contains(" (failed to lookup") + .assert_contains(" (host 'lmtp.foobar.org' rejected command"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host 'lmtp.foobar.org' rejected") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host 'lmtp.foobar.org' rejected") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host 'lmtp.foobar.org' rejected") + .assert_contains("Action: failed"); + + assert_eq!( + remote + .expect_message() + .await + .message + .recipients + .into_iter() + .map(|r| r.address().to_string()) + .collect::>(), + vec![ + "bill@foobar.org".to_string(), + "jane@foobar.org".to_string(), + "john@foobar.org".to_string() + ] + ); + remote.assert_no_events(); +} diff --git a/tests/src/smtp/outbound/mod.rs b/tests/src/smtp/outbound/mod.rs new file mode 100644 index 0000000..32e2aa5 --- /dev/null +++ b/tests/src/smtp/outbound/mod.rs @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod dane; +pub mod extensions; +pub mod fallback_relay; +pub mod ip_lookup; +pub mod lmtp; +pub mod mta_sts; +pub mod smtp; +pub mod throttle; +pub mod tls; diff --git a/tests/src/smtp/outbound/mta_sts.rs b/tests/src/smtp/outbound/mta_sts.rs new file mode 100644 index 0000000..48feaa3 --- /dev/null +++ b/tests/src/smtp/outbound/mta_sts.rs @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestQueueEvent, TestReportingEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{config::smtp::resolver::Policy, ipc::PolicyType}; +use mail_auth::{ + DnssecStatus, MX, + common::parse::TxtRecordParser, + mta_sts::{MtaSts, ReportUri, TlsRpt}, + report::tlsrpt::ResultType, +}; +use registry::{ + schema::{ + enums::{MtaRequiredOrOptional, NetworkListenerProtocol}, + prelude::ObjectType, + structs::{Expression, MtaTlsStrategy, NetworkListener, TlsReportSettings}, + }, + types::{map::Map, socketaddr::SocketAddr}, +}; +use smtp::outbound::mta_sts::{lookup::STS_TEST_POLICY, parse::ParsePolicy}; +use std::{ + str::FromStr, + sync::Arc, + time::{Duration, Instant}, +}; + +#[tokio::test] +#[serial_test::serial] +async fn mta_sts_verify() { + let mut local = TestServerBuilder::new("smtp_mta_sts_local") + .await + .with_http_listener(19028) + .await + .disable_services() + .capture_queue() + .capture_reporting() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_mta_sts_remote") + .await + .with_http_listener(19029) + .await + .with_smtp_listener(9925) + .await + .with_dummy_tls_cert(["*.foobar.org"]) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin + .registry_create_object(TlsReportSettings { + send_frequency: Expression { + else_: "weekly".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let (tls_strategy_id, mut tls_strategy) = local_admin + .registry_get_all::() + .await + .into_iter() + .find(|(_, s)| s.name == "default") + .unwrap(); + tls_strategy.mta_sts = MtaRequiredOrOptional::Require; + tls_strategy.allow_invalid_certs = false; + let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap(); + tls_strategy + .as_object_mut() + .unwrap() + .retain(|k, _| k != "name"); + local_admin + .registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.mta_add_all_headers().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + local.server.txt_add( + "_smtp._tls.foobar.org", + TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:reports@foobar.org").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (MTA-STS failed to authenticate") + .assert_contains("Record not =") + .assert_contains("found"); + local.read_event().await.assert_done(); + + // Expect TLS failure report + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.domain, "foobar.org"); + assert_eq!(report.policy, PolicyType::Sts(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::Other + ); + assert_eq!( + report.tls_record.rua, + vec![ReportUri::Mail("reports@foobar.org".to_string())] + ); + + // MTA-STS policy fetch failure + local.server.txt_add( + "_mta-sts.foobar.org", + MtaSts::parse(b"v=STSv1; id=policy_will_fail;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (MTA-STS failed to authenticate") + .assert_contains("No 'mx' entries found"); + local.read_event().await.assert_done(); + + // Expect TLS failure report + let report = local.read_report().await.unwrap_tls(); + assert_eq!(report.policy, PolicyType::Sts(None)); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::StsPolicyInvalid + ); + + // MTA-STS policy does not authorize mx.foobar.org + let policy = concat!( + "version: STSv1\n", + "mode: enforce\n", + "mx: mail.foobar.net\n", + "max_age: 604800\n" + ); + STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes()); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local + .expect_message() + .await + .read_lines(&local) + .await + .assert_contains(" (MTA-STS failed to authenticate") + .assert_contains("not authorized by policy"); + local.read_event().await.assert_done(); + + // Expect TLS failure report + let report = local.read_report().await.unwrap_tls(); + assert_eq!( + report.policy, + PolicyType::Sts( + Arc::new(Policy::parse(policy, "policy_will_fail".to_string()).unwrap()).into() + ) + ); + assert_eq!( + report.failure.as_ref().unwrap().receiving_mx_hostname, + Some("mx.foobar.org".to_string()) + ); + assert_eq!( + report.failure.as_ref().unwrap().result_type, + ResultType::ValidationFailure + ); + remote.assert_no_events(); + + // MTA-STS successful validation + local.server.txt_add( + "_mta-sts.foobar.org", + MtaSts::parse(b"v=STSv1; id=policy_will_work;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + let policy = concat!( + "version: STSv1\n", + "mode: enforce\n", + "mx: *.foobar.org\n", + "max_age: 604800\n" + ); + STS_TEST_POLICY.lock().clear(); + STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes()); + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_done(); + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_contains("using TLSv1.3 with cipher"); + + // Expect TLS success report + let report = local.read_report().await.unwrap_tls(); + assert_eq!( + report.policy, + PolicyType::Sts( + Arc::new(Policy::parse(policy, "policy_will_work".to_string()).unwrap()).into() + ) + ); + assert!(report.failure.is_none()); +} + +#[tokio::test] +#[serial_test::serial] +async fn mta_sts_testing_mode_does_not_enforce_tls() { + let mut local = TestServerBuilder::new("smtp_mta_sts_testing_local") + .await + .with_http_listener(19051) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_mta_sts_testing_remote") + .await + .with_http_listener(19052) + .await + .with_object(NetworkListener { + bind: Map::new(vec![SocketAddr::from_str("0.0.0.0:9925").unwrap()]), + name: "smtp".to_string(), + protocol: NetworkListenerProtocol::Smtp, + use_tls: false, + tls_implicit: false, + ..Default::default() + }) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_no_auth().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.mta_add_all_headers().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + local.server.txt_add( + "_mta-sts.foobar.org", + MtaSts::parse(b"v=STSv1; id=policy_in_testing;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + + STS_TEST_POLICY.lock().clear(); + STS_TEST_POLICY.lock().extend_from_slice( + concat!( + "version: STSv1\n", + "mode: testing\n", + "mx: *.foobar.org\n", + "max_age: 604800\n" + ) + .as_bytes(), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_then_deliver() + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_done(); + + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_not_contains("using TLSv1.3 with cipher"); + + STS_TEST_POLICY.lock().clear(); +} diff --git a/tests/src/smtp/outbound/smtp.rs b/tests/src/smtp/outbound/smtp.rs new file mode 100644 index 0000000..334fbbd --- /dev/null +++ b/tests/src/smtp/outbound/smtp.rs @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestQueueEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{config::smtp::queue::QueueName, ipc::QueueEvent}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + structs::{ + Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, + MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy, + MtaStageRcpt, MtaVirtualQueue, + }, + }, + types::list::List, +}; +use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool}; +use std::time::{Duration, Instant}; +use store::write::now; + +const SMUGGLER: &str = r#"From: Joe SixPack +To: Suzie Q +Subject: Is dinner ready? + +Hi. + +We lost the game. Are you hungry yet? +.hey +Joe. + +. +MAIL FROM: +RCPT TO: +DATA +From: Joe SixPack +To: Suzie Q +Subject: smuggled message + +This is a smuggled message +"#; + +#[tokio::test] +#[serial_test::serial] +async fn smtp_delivery() { + let mut local = TestServerBuilder::new("smtp_delivery_local") + .await + .with_http_listener(19030) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_delivery_remote") + .await + .with_http_listener(19031) + .await + .with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin + .registry_create_object(MtaStageRcpt { + max_recipients: Expression { + else_: "100".into(), + + ..Default::default() + }, + allow_relaying: Expression { + else_: "true".into(), + + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + match_: List::from_iter([ + ExpressionMatch { + if_: "rcpt_domain == 'foobar.org'".into(), + then: "'foobar-org'".into(), + }, + ExpressionMatch { + if_: "rcpt_domain == 'foobar.com'".into(), + then: "'foobar-com'".into(), + }, + ]), + else_: "'default'".into(), + }, + ..Default::default() + }) + .await; + let queue_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 7_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "foobar-org".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 2_000u64.into(), + }, + ]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 6_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "foobar-com".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 5_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 6_000u64.into(), + }, + ]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 7_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin.mta_no_auth().await; + local_admin.mta_all_extensions().await; + local_admin.mta_disable_spam_filter().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_no_auth().await; + remote_admin.mta_disable_spam_filter().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin + .registry_create_object(MtaExtensions { + chunking: Expression { + else_: "false".into(), + ..Default::default() + }, + dsn: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + for domain in ["foobar.org", "foobar.net", "foobar.com"] { + local.server.mx_add( + domain, + vec![MX { + exchanges: vec![ + format!("mx1.{domain}").into(), + format!("mx2.{domain}").into(), + ] + .into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + format!("mx1.{domain}"), + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(30), + ); + local.server.ipv4_add( + format!("mx2.{domain}"), + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(30), + ); + } + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message( + "john@test.org", + &[ + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + " NOTIFY=SUCCESS,DELAY,FAILURE", + ], + "test:no_dkim", + "250", + ) + .await; + let message = local.expect_message().await; + let num_recipients = message.message.recipients.len(); + assert_eq!(num_recipients, 7); + local + .delivery_attempt_for_queue(message.queue_id, "default") + .await + .try_deliver(local.server.clone()); + let mut dsn = Vec::new(); + let mut rcpt_retries = vec![0; num_recipients]; + loop { + match local.try_read_event().await { + Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {} + Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(), + None | Some(QueueEvent::Stop) => { + break; + } + } + + let mut events = local.all_queued_messages().await; + if events.messages.is_empty() { + let now = now(); + if events.next_refresh < now + QUEUE_REFRESH { + tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await; + events = local.all_queued_messages().await; + } else { + break; + } + } + for event in events.messages { + let message = local + .server + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message + .clone() + .remove(&local.server, event.due.into()) + .await; + dsn.push(message); + } else { + for (idx, rcpt) in message.message.recipients.iter().enumerate() { + rcpt_retries[idx] = rcpt.retry.inner; + } + event.try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + assert_eq!(rcpt_retries[0], 0, "retries {rcpt_retries:?}"); + assert!(rcpt_retries[1] >= 5, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[2], 0, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[3], 0, "retries {rcpt_retries:?}"); + assert!(rcpt_retries[4] >= 5, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[5], 0, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[6], 0, "retries {rcpt_retries:?}"); + assert!( + rcpt_retries[1] >= rcpt_retries[4], + "retries {rcpt_retries:?}" + ); + + local.assert_queue_is_empty().await; + assert_eq!(dsn.len(), 5); + + let mut dsn = dsn.into_iter(); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (delivered to") + .assert_contains(" (delivered to") + .assert_contains(" (failed to lookup") + .assert_contains(" (host ") + .assert_contains(" (host "); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host ") + .assert_contains(" (host ") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host ") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host "); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (host ") + .assert_contains("Action: failed"); + + let mut recipients = remote + .consume_message() + .await + .message + .recipients + .into_iter() + .map(|r| r.address().to_string()) + .collect::>(); + recipients.extend( + remote + .consume_message() + .await + .message + .recipients + .into_iter() + .map(|r| r.address().to_string()), + ); + recipients.sort(); + assert_eq!( + recipients, + vec!["ok@foobar.net".to_string(), "ok@foobar.org".to_string()] + ); + + remote.assert_no_events(); + + // SMTP smuggling + for separator in ["\n", "\r"].iter() { + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + + let out_message = SMUGGLER + .replace('\r', "") + .replace('\n', "\r\n") + .replace("", separator); + + session + .send_message("john@doe.org", &["bill@foobar.com"], &out_message, "250") + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + local.read_event().await.assert_refresh_or_done(); + + let message = remote.consume_message().await.read_message(&remote).await; + + assert!( + message.contains("This is a smuggled message"), + "message: {:?}", + message + ); + assert!( + message.contains("We lost the game."), + "message: {:?}", + message + ); + assert!( + message.contains(&format!("{separator}..\r\nMAIL FROM:<",)), + "Message {message:?} does not contain separator {:?}", + format!("{separator}..\r\nMAIL FROM:<",) + ); + } +} diff --git a/tests/src/smtp/outbound/throttle.rs b/tests/src/smtp/outbound/throttle.rs new file mode 100644 index 0000000..1025ece --- /dev/null +++ b/tests/src/smtp/outbound/throttle.rs @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + queue::{build_rcpt, new_message}, + session::TestSession, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::config::smtp::queue::QueueName; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::MtaOutboundThrottleKey, + structs::{ + Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule, + MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaOutboundThrottle, + MtaVirtualQueue, Rate, + }, + }, + types::{list::List, map::Map}, +}; +use smtp::queue::{Message, QueueEnvelope, Recipient, throttle::IsAllowed}; +use std::{ + net::{IpAddr, Ipv4Addr}, + time::{Duration, Instant}, +}; +use store::write::now; + +#[tokio::test] +async fn throttle_outbound() { + let mut local = TestServerBuilder::new("smtp_throttle_outbound") + .await + .with_http_listener(19032) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = local.account("admin"); + let queue_id = admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 3_600_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 3_600_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 3_600_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + else_: "'default'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + for (expr, key, rate_count, rate_duration) in [ + ( + "sender_domain = 'foobar.net'", + MtaOutboundThrottleKey::SenderDomain, + 1, + 30 * 60 * 1000, + ), + ( + "rcpt_domain = 'example.net'", + MtaOutboundThrottleKey::RcptDomain, + 1, + 40 * 60 * 1000, + ), + ("mx = 'mx.test.org'", MtaOutboundThrottleKey::Mx, 1, 99999), + ( + "mx = 'mx.test.net'", + MtaOutboundThrottleKey::Mx, + 1, + 50 * 60 * 1000, + ), + ] { + admin + .registry_create_object(MtaOutboundThrottle { + enable: true, + key: Map::new(vec![key]), + match_: Expression { + else_: expr.into(), + ..Default::default() + }, + rate: Rate { + count: rate_count, + period: rate_duration.into(), + }, + description: "Test throttle".into(), + }) + .await; + } + admin.mta_no_auth().await; + admin.mta_allow_relaying().await; + admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + // Build test message + let mut test_message = new_message(0).message; + test_message.return_path = "test@foobar.org".into(); + test_message + .recipients + .push(build_rcpt("bill@test.org", 0, 0, 0)); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@foobar.org", &["bill@test.org"], "test:no_dkim", "250") + .await; + assert_eq!(local.last_queued_due().await as i64 - now() as i64, 0); + + // Throttle sender + let core = local.server.core.clone(); + let throttle = &core.smtp.queue.outbound_limiters; + for t in &throttle.sender { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[0], ""), + 0, + ) + .await + .unwrap(); + } + + // Expect rate limit throttle for sender domain 'foobar.net' + test_message.return_path = "test@foobar.net".into(); + for t in &throttle.sender { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[0], ""), + 0, + ) + .await + .unwrap(); + } + test_message.recipients.clear(); + + session + .send_message("john@foobar.net", &["bill@test.org"], "test:no_dkim", "250") + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + local.expect_refresh().await; + let due = local.last_queued_due().await - now(); + assert!(due > 0, "Due: {}", due); + + // Expect concurrency throttle for recipient domain 'example.org' + test_message.return_path = "test@test.net".into(); + test_message + .recipients + .push(build_rcpt("test@example.org", 0, 0, 0)); + for t in &throttle.rcpt { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[0], ""), + 0, + ) + .await + .unwrap(); + } + + // Expect rate limit throttle for recipient domain 'example.net' + test_message + .recipients + .push(build_rcpt("test@example.net", 0, 0, 0)); + for t in &throttle.rcpt { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[1], ""), + 0, + ) + .await + .unwrap(); + } + + session + .send_message( + "john@test.net", + &["jane@example.net"], + "test:no_dkim", + "250", + ) + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + local.expect_refresh().await; + let due = local.last_queued_due().await - now(); + assert!(due > 0, "Due: {}", due); + + // Expect concurrency throttle for mx 'mx.test.org' + local.server.mx_add( + "test.org", + vec![MX { + exchanges: vec!["mx.test.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.test.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + test_message + .recipients + .push(build_rcpt("test@test.org", 0, 0, 0)); + + for t in &throttle.remote { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[2], "mx.test.org"), + 0, + ) + .await + .unwrap(); + } + + // Expect rate limit throttle for mx 'mx.test.net' + local.server.mx_add( + "test.net", + vec![MX { + exchanges: vec!["mx.test.net".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.test.net", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + for t in &throttle.remote { + local + .server + .is_allowed( + t, + &QueueEnvelope::test(&test_message, &test_message.recipients[1], "mx.test.net"), + 0, + ) + .await + .unwrap(); + } + + session + .send_message("john@test.net", &["jane@test.net"], "test:no_dkim", "250") + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + + tokio::time::sleep(Duration::from_millis(100)).await; + local.expect_refresh().await; + let due = local.last_queued_due().await - now(); + assert!(due > 0, "Due: {}", due); +} + +#[tokio::test] +async fn throttle_outbound_queue_name() { + let mut local = TestServerBuilder::new("smtp_throttle_outbound_queue_name") + .await + .with_http_listener(19033) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = local.account("admin"); + let queue_id = admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Default, + notify: MtaDeliveryScheduleIntervalsOrDefault::Default, + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 3_600_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + else_: "'default'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(MtaOutboundThrottle { + enable: true, + key: Map::new(vec![MtaOutboundThrottleKey::SenderDomain]), + match_: Expression { + else_: "queue_name == 'default'".into(), + ..Default::default() + }, + rate: Rate { + count: 1, + period: (30u64 * 60 * 1000).into(), + }, + description: "queue_name throttle".into(), + }) + .await; + admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let core = local.server.core.clone(); + let throttle = &core.smtp.queue.outbound_limiters; + assert_eq!(throttle.sender.len(), 1); + assert!(throttle.rcpt.is_empty()); + assert!(throttle.remote.is_empty()); + + let matching = new_message(0); + assert_eq!(matching.queue_name, QueueName::default()); + local + .server + .is_allowed(&throttle.sender[0], &matching, 0) + .await + .unwrap(); + assert!( + local + .server + .is_allowed(&throttle.sender[0], &matching, 0) + .await + .is_err(), + "sender-bucket throttle failed to resolve queue_name and never engaged" + ); + + let mut other_queue = new_message(0); + other_queue.queue_name = QueueName::new("remote").unwrap(); + local + .server + .is_allowed(&throttle.sender[0], &other_queue, 0) + .await + .unwrap(); +} + +pub trait TestQueueEnvelope<'x> { + fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self; +} + +impl<'x> TestQueueEnvelope<'x> for QueueEnvelope<'x> { + fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self { + QueueEnvelope { + message, + mx, + remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + domain: rcpt.domain_part(), + rcpt, + } + } +} diff --git a/tests/src/smtp/outbound/tls.rs b/tests/src/smtp/outbound/tls.rs new file mode 100644 index 0000000..fdf08e6 --- /dev/null +++ b/tests/src/smtp/outbound/tls.rs @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::TestMessage, + session::{TestSession, VerifyResponse}, + }, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::MtaRequiredOrOptional, + structs::{ + Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliverySchedule, + MtaOutboundStrategy, MtaTlsStrategy, MtaVirtualQueue, + }, + }, + types::list::List, +}; +use std::time::{Duration, Instant}; +use store::write::now; + +#[tokio::test] +#[serial_test::serial] +async fn starttls_optional() { + let mut local = TestServerBuilder::new("smtp_starttls_local") + .await + .with_http_listener(19034) + .await + .disable_services() + .capture_queue() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_starttls_remote") + .await + .with_http_listener(19035) + .await + .with_smtp_listener(9925) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_no_auth().await; + local_admin.mta_allow_relaying().await; + local_admin + .registry_create_object(MtaOutboundStrategy { + tls: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "retry_num > 0 && last_error == 'tls'".into(), + then: "'no-tls'".into(), + }]), + else_: "'default'".into(), + }, + connection: Expression { + else_: "'badtls'".into(), + ..Default::default() + }, + schedule: Expression { + else_: "'default'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let queue_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + queue_id, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaConnectionStrategy { + name: "badtls".into(), + ehlo_hostname: "badtls.foobar.org".to_string().into(), + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaTlsStrategy { + name: "no-tls".into(), + allow_invalid_certs: true, + start_tls: MtaRequiredOrOptional::Disable, + ..Default::default() + }) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let remote_admin = remote.account("admin"); + remote_admin.mta_no_auth().await; + remote_admin.mta_allow_relaying().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.mta_all_extensions().await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(10), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(10), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + local + .expect_message_for_queue_then_deliver("default") + .await + .try_deliver(local.server.clone()); + let mut retry = local.expect_message().await; + let prev_due = retry.message.recipients[0].retry.due; + let next_due = now(); + let queue_id = retry.queue_id; + retry.message.recipients[0].retry.due = next_due; + retry.save_changes(&local.server, prev_due.into()).await; + local + .delivery_attempt_for_queue(queue_id, "default") + .await + .try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + remote + .expect_message() + .await + .read_lines(&remote) + .await + .assert_not_contains("using TLSv1.3 with cipher"); +} diff --git a/tests/src/smtp/queue/concurrent.rs b/tests/src/smtp/queue/concurrent.rs new file mode 100644 index 0000000..35a8de0 --- /dev/null +++ b/tests/src/smtp/queue/concurrent.rs @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::TestSession, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{BuildServer, ipc::QueueEvent}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::ObjectType, + structs::{ + Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule, + MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData, + MtaVirtualQueue, + }, + }, + types::list::List, +}; +use smtp::queue::manager::Queue; +use std::time::{Duration, Instant}; + +const NUM_MESSAGES: usize = 100; +const NUM_QUEUES: usize = 10; + +#[tokio::test(flavor = "multi_thread", worker_threads = 18)] +#[serial_test::serial] +async fn concurrent_queue() { + let mut local = TestServerBuilder::new("smtp_concurrent_queue_local") + .await + .with_http_listener(19037) + .await + .disable_services() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_concurrent_queue_remote") + .await + .with_http_listener(19038) + .await + .with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin + .registry_create_object(MtaStageData { + max_messages: Expression { + else_: "2000".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + else_: "'default'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let queue_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 4, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 86_400_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 86_400_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin.mta_allow_relaying().await; + local_admin.mta_disable_spam_filter().await; + local_admin.mta_allow_non_fqdn().await; + local_admin.mta_no_auth().await; + local_admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + local_admin.reload_settings().await; + local.reload_core(); + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_disable_spam_filter().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.mta_no_auth().await; + remote_admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(100), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + + // Spawn concurrent queues + let mut inners = vec![]; + for _ in 0..NUM_QUEUES { + let (inner, rxs) = local.inner_with_rxs().await; + let server = inner.build_server(); + server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(100), + ); + server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + inners.push(inner.clone()); + tokio::spawn(async move { + Queue::new(inner, rxs.queue_rx.unwrap()).start().await; + }); + } + + tokio::time::sleep(Duration::from_millis(200)).await; + + // Send 1000 test messages + for _ in 0..(NUM_MESSAGES / 2) { + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + } + + // Wake up all queues + for inner in &inners { + inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap(); + } + for _ in 0..(NUM_MESSAGES / 2) { + session + .send_message( + "john@test.org", + &["delay-random@foobar.org"], + "test:no_dkim", + "250", + ) + .await; + } + + loop { + tokio::time::sleep(Duration::from_millis(1500)).await; + + let m = local.read_queued_messages().await.len(); + let e = local.read_queued_events().await.len(); + + if m + e != 0 { + println!("Queue still has {} messages and {} events", m, e); + /*for inner in &inners { + inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap(); + }*/ + } else { + break; + } + } + + local.assert_queue_is_empty().await; + let remote_messages = remote.read_queued_messages().await; + assert_eq!(remote_messages.len(), NUM_MESSAGES); + + // Make sure local store is queue + local + .account("admin") + .registry_destroy_all(ObjectType::MtaConnectionStrategy) + .await; + local.assert_is_empty().await; +} diff --git a/tests/src/smtp/queue/dsn.rs b/tests/src/smtp/queue/dsn.rs new file mode 100644 index 0000000..8d9bc72 --- /dev/null +++ b/tests/src/smtp/queue/dsn.rs @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::{TestServer, TestServerBuilder}; +use common::config::smtp::queue::{QueueExpiry, QueueName}; +use registry::schema::{ + enums::CompressionAlgo, + structs::{DsnReportSettings, Expression, ReportSettings}, +}; +use smtp::queue::{ + Error, ErrorDetails, HostResponse, Message, MessageWrapper, Recipient, Schedule, Status, + UnexpectedResponse, dsn::SendDsn, +}; +use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response}; +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + time::SystemTime, +}; +use store::write::now; +use types::blob_hash::BlobHash; + +#[tokio::test] +async fn generate_dsn() { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("resources"); + path.push("smtp"); + path.push("dsn"); + path.push("original.txt"); + let size = fs::metadata(&path).unwrap().len() as u64; + let dsn_original = fs::read_to_string(&path).unwrap(); + + let flags = RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS; + let mut message = MessageWrapper { + queue_id: 0, + span_id: 0, + is_multi_queue: false, + queue_name: QueueName::default(), + message: Message { + size, + created: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + return_path: "sender@foobar.org".into(), + recipients: vec![Recipient { + address: "foobar@example.org".into(), + status: Status::PermanentFailure(ErrorDetails { + entity: "mx.example.org".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "RCPT TO:".into(), + response: Response { + code: 550, + esc: [5, 1, 2], + message: "User does not exist".into(), + }, + }), + }), + flags: 0, + orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Ttl(10), + queue: QueueName::default(), + }], + flags: 0, + env_id: None, + priority: 0, + blob_hash: BlobHash::generate(dsn_original.as_bytes()), + metadata: Default::default(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, + }; + + let mut local = TestServerBuilder::new("smtp_queue_dsn") + .await + .with_http_listener(19039) + .await + .disable_services() + .capture_queue() + .build() + .await; + let local_admin = local.account("admin"); + local_admin + .registry_create_object(ReportSettings { + outbound_report_submitter: Expression { + else_: "'mx.example.org'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(DsnReportSettings { + dkim_sign_domain: Expression { + else_: "'example.org'".into(), + ..Default::default() + }, + from_address: Expression { + else_: "'MAILER-DAEMON@example.org'".into(), + ..Default::default() + }, + from_name: Expression { + else_: "'Mail Delivery Subsystem'".into(), + ..Default::default() + }, + }) + .await; + let domain_id = local_admin.find_or_create_domain("example.org").await; + local_admin.create_dkim_signatures(domain_id).await; + local_admin.mta_allow_non_fqdn().await; + local_admin.mta_allow_relaying().await; + local_admin.reload_settings().await; + local_admin.mta_allow_relaying().await; + local.reload_core(); + local.expect_reload_settings().await; + + // Create temp dir for queue + local + .server + .blob_store() + .put_blob( + message.message.blob_hash.as_slice(), + dsn_original.as_bytes(), + CompressionAlgo::Lz4, + ) + .await + .unwrap(); + + // Disabled DSN + local.server.send_dsn(&mut message).await; + local.assert_no_events(); + local.assert_queue_is_empty().await; + + // Failure DSN + message.message.recipients[0].flags = flags; + local.server.send_dsn(&mut message).await; + let dsn_message = local.expect_message().await; + local.compare_dsn(dsn_message.message, "failure.eml").await; + + // Success DSN + message.message.recipients.push(Recipient { + address: "jane@example.org".into(), + status: Status::Completed(HostResponse { + hostname: "mx2.example.org".into(), + response: Response { + code: 250, + esc: [2, 1, 5], + message: "Message accepted for delivery".into(), + }, + }), + flags, + orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Ttl(10), + queue: QueueName::default(), + }); + local.server.send_dsn(&mut message).await; + let dsn_message = local.expect_message().await; + local.compare_dsn(dsn_message.message, "success.eml").await; + + // Delay DSN + message.message.recipients.push(Recipient { + address: "john.doe@example.org".into(), + status: Status::TemporaryFailure(ErrorDetails { + entity: "mx.domain.org".into(), + details: Error::ConnectionError("Connection timeout".into()), + }), + flags, + orcpt: Some("jdoe@example.org".into()), + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Ttl(10), + queue: QueueName::default(), + }); + local.server.send_dsn(&mut message).await; + let dsn_message = local.expect_message().await; + local.compare_dsn(dsn_message.message, "delay.eml").await; + + // Mixed DSN + for rcpt in &mut message.message.recipients { + rcpt.flags = flags; + } + message.message.recipients.last_mut().unwrap().notify.due = now(); + local.server.send_dsn(&mut message).await; + let dsn_message = local.expect_message().await; + local.compare_dsn(dsn_message.message, "mixed.eml").await; + + // Load queue + let queue = local.read_queued_messages().await; + assert_eq!(queue.len(), 4); +} + +impl TestServer { + async fn compare_dsn(&self, message: Message, test: &str) { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("resources"); + path.push("smtp"); + path.push("dsn"); + path.push(test); + + let bytes = self + .server + .blob_store() + .get_blob(message.blob_hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .unwrap(); + + let dsn = remove_ids(bytes); + let dsn_expected = fs::read_to_string(&path).unwrap(); + + if dsn != dsn_expected { + let mut failed = PathBuf::from(&path); + failed.set_extension("failed"); + fs::write(&failed, dsn.as_bytes()).unwrap(); + panic!( + "Failed for {}, output saved to {}", + path.display(), + failed.display() + ); + } + } +} + +fn remove_ids(message: Vec) -> String { + let old_message = String::from_utf8(message).unwrap(); + let mut message = String::with_capacity(old_message.len()); + let mut found_dkim = 0; + let mut skip = false; + + let mut boundary = ""; + for line in old_message.split("\r\n") { + if skip { + if line.chars().next().unwrap().is_ascii_whitespace() { + continue; + } else { + skip = false; + } + } + if line.starts_with("Date:") || line.starts_with("Message-ID:") { + continue; + } else if found_dkim < 2 && line.starts_with("DKIM-Signature:") { + found_dkim += 1; + skip = true; + continue; + } else if line.starts_with("--") { + message.push_str(&line.replace(boundary, "mime_boundary")); + } else if let Some((_, boundary_)) = line.split_once("boundary=\"") { + boundary = boundary_.split_once('"').unwrap().0; + message.push_str(&line.replace(boundary, "mime_boundary")); + } else if line.starts_with("Arrival-Date:") { + message.push_str("Arrival-Date: "); + } else if line.starts_with("Will-Retry-Until:") { + message.push_str("Will-Retry-Until: "); + } else { + message.push_str(line); + } + message.push_str("\r\n"); + } + + if found_dkim == 0 { + panic!("No DKIM signature found in: {old_message}"); + } + + message +} diff --git a/tests/src/smtp/queue/manager.rs b/tests/src/smtp/queue/manager.rs new file mode 100644 index 0000000..5efa808 --- /dev/null +++ b/tests/src/smtp/queue/manager.rs @@ -0,0 +1,209 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::queue::{build_rcpt, new_message}, + utils::server::TestServerBuilder, +}; +use common::config::smtp::queue::QueueName; +use smtp::queue::{Error, ErrorDetails, Message, Recipient, Status, spool::SmtpSpool}; +use std::time::Duration; +use store::write::now; + +#[tokio::test] +async fn queue_due() { + let mut local = TestServerBuilder::new("smtp_queue_manager") + .await + .with_http_listener(19040) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_allow_non_fqdn().await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let mut message = new_message(0); + message.message.recipients.push(build_rcpt("c", 3, 8, 9)); + message.save_changes(&local.server, 0.into()).await; + + let mut message = new_message(1); + message.message.recipients.push(build_rcpt("b", 2, 6, 7)); + message.save_changes(&local.server, 0.into()).await; + + let mut message = new_message(2); + message.message.recipients.push(build_rcpt("a", 1, 4, 5)); + message.save_changes(&local.server, 0.into()).await; + + for domain in vec!["a", "b", "c"].into_iter() { + let now = now(); + let queued = local.all_queued_messages().await; + if queued.messages.is_empty() { + let wake_up = queued.next_refresh - now; + assert_eq!(wake_up, 1); + std::thread::sleep(Duration::from_secs(wake_up)); + } + + for queue_event in local.all_queued_messages().await.messages { + if let Some(message) = local + .server + .read_message(queue_event.queue_id, QueueName::default()) + .await + { + message.message.rcpt(domain); + message.remove(&local.server, queue_event.due.into()).await; + } else { + panic!("Message not found"); + } + } + } + + local.assert_queue_is_empty().await; +} + +#[test] +fn delivery_events() { + let mut message = new_message(0).message; + message.created = now(); + + message.recipients.push(build_rcpt("a", 1, 2, 3)); + message.recipients.push(build_rcpt("b", 4, 5, 6)); + message.recipients.push(build_rcpt("c", 7, 8, 9)); + + for t in 0..2 { + assert_eq!( + message.next_event(None).unwrap(), + message.rcpt("a").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("a").retry.due + ); + assert_eq!( + next_event_after( + &message, + None, + message.rcpt("a").expiration_time(message.created).unwrap() + ) + .unwrap(), + message.rcpt("b").retry.due + ); + assert_eq!( + next_event_after( + &message, + None, + message.rcpt("b").expiration_time(message.created).unwrap() + ) + .unwrap(), + message.rcpt("c").retry.due + ); + assert_eq!( + next_event_after(&message, None, message.rcpt("c").notify.due).unwrap(), + message.rcpt("c").expiration_time(message.created).unwrap() + ); + assert!( + next_event_after( + &message, + None, + message.rcpt("c").expiration_time(message.created).unwrap() + ) + .is_none() + ); + + if t == 0 { + message.recipients.reverse(); + } else { + message.recipients.swap(0, 1); + } + } + + message.rcpt_mut("a").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert_eq!( + message.next_event(None).unwrap(), + message.rcpt("b").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("b").retry.due + ); + + message.rcpt_mut("b").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert_eq!( + message.next_event(None).unwrap(), + message.rcpt("c").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("c").retry.due + ); + + message.rcpt_mut("c").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert!(message.next_event(None).is_none()); +} + +fn next_event_after(message: &Message, queue: Option, instant: u64) -> Option { + let mut next_event = None; + + for rcpt in &message.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + { + if rcpt.retry.due > instant + && next_event.as_ref().is_none_or(|ne| rcpt.retry.due.lt(ne)) + { + next_event = rcpt.retry.due.into(); + } + if rcpt.notify.due > instant + && next_event.as_ref().is_none_or(|ne| rcpt.notify.due.lt(ne)) + { + next_event = rcpt.notify.due.into(); + } + if let Some(expires) = rcpt.expiration_time(message.created) + && expires > instant + && next_event.as_ref().is_none_or(|ne| expires.lt(ne)) + { + next_event = expires.into(); + } + } + } + + next_event +} + +pub trait TestMessage { + fn rcpt(&self, name: &str) -> &Recipient; + fn rcpt_mut(&mut self, name: &str) -> &mut Recipient; +} + +impl TestMessage for Message { + fn rcpt(&self, name: &str) -> &Recipient { + self.recipients + .iter() + .find(|d| d.address() == name) + .unwrap_or_else(|| panic!("Expected rcpt {name} not found in {:?}", self.recipients)) + } + + fn rcpt_mut(&mut self, name: &str) -> &mut Recipient { + self.recipients + .iter_mut() + .find(|d| d.address() == name) + .unwrap() + } +} diff --git a/tests/src/smtp/queue/mod.rs b/tests/src/smtp/queue/mod.rs new file mode 100644 index 0000000..e734d73 --- /dev/null +++ b/tests/src/smtp/queue/mod.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::config::smtp::queue::{QueueExpiry, QueueName}; +use smtp::queue::{Message, MessageWrapper, Recipient, Schedule, Status}; +use std::net::{IpAddr, Ipv4Addr}; +use store::write::now; + +pub mod concurrent; +pub mod dsn; +pub mod manager; +pub mod retry; +pub mod virtualq; + +pub fn build_rcpt(address: &str, retry: u64, notify: u64, expires: u64) -> Recipient { + Recipient { + address: address.into(), + retry: Schedule::later(retry), + notify: Schedule::later(notify), + expires: QueueExpiry::Ttl(expires), + status: Status::Scheduled, + flags: 0, + orcpt: None, + queue: QueueName::default(), + } +} + +pub fn new_message(queue_id: u64) -> MessageWrapper { + MessageWrapper { + queue_id, + span_id: 0, + queue_name: QueueName::default(), + is_multi_queue: false, + message: Message { + size: 0, + created: now(), + return_path: "sender@foobar.org".into(), + recipients: vec![], + flags: 0, + env_id: None, + priority: 0, + metadata: Default::default(), + blob_hash: Default::default(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, + } +} diff --git a/tests/src/smtp/queue/retry.rs b/tests/src/smtp/queue/retry.rs new file mode 100644 index 0000000..0b1910e --- /dev/null +++ b/tests/src/smtp/queue/retry.rs @@ -0,0 +1,321 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{ + inbound::{TestMessage, TestQueueEvent}, + session::{TestSession, VerifyResponse}, + }, + utils::server::TestServerBuilder, +}; +use ahash::AHashSet; +use common::{ + config::smtp::queue::QueueName, + ipc::{QueueEvent, QueueEventStatus}, +}; +use registry::{ + schema::structs::{ + Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, + MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy, MtaVirtualQueue, + }, + types::list::List, +}; +use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool}; +use std::time::Duration; +use store::write::now; + +#[tokio::test] +async fn queue_retry() { + let mut local = TestServerBuilder::new("smtp_queue_retry") + .await + .with_http_listener(19041) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin.mta_allow_relaying().await; + local_admin.mta_allow_non_fqdn().await; + local_admin.mta_no_auth().await; + local_admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "sender_domain == 'test.org'".into(), + then: "'sender-test'".into(), + }]), + else_: "'sender-default'".into(), + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaExtensions { + deliver_by: Expression { + else_: "1h".into(), + ..Default::default() + }, + future_release: Expression { + else_: "1h".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let queue_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "default".into(), + threads_per_node: 25, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "sender-default".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 2_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 3_000u64.into(), + }, + ]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: (15 * 60 * 60 * 1000u64).into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 86_400_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "sender-test".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 2_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 3_000u64.into(), + }, + ]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([ + MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }, + MtaDeliveryScheduleInterval { + duration: 2_000u64.into(), + }, + ]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 6_000u64.into(), + }), + queue_id, + description: None, + }) + .await; + local_admin.reload_settings().await; + local.reload_core(); + local.expect_reload_settings().await; + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + let attempt = local.expect_message_for_queue_then_deliver("default").await; + + // Expect a failed DSN + attempt.try_deliver(local.server.clone()); + let message = local.expect_message().await; + assert_eq!(message.message.return_path.as_ref(), ""); + assert_eq!( + message.message.recipients.first().unwrap().address(), + "john@test.org" + ); + message + .read_lines(&local) + .await + .assert_contains("Content-Type: multipart/report") + .assert_contains("Final-Recipient: rfc822;bill@foobar.org") + .assert_contains("Action: failed"); + local.read_event().await.assert_done(); + local.clear_queue().await; + + // Expect a failed DSN for foobar.org, followed by two delayed DSN and + // a final failed DSN for _dns_error.org. + session + .send_message( + "john@test.org", + &["bill@foobar.org", "jane@_dns_error.org"], + "test:no_dkim", + "250", + ) + .await; + let mut in_fight = AHashSet::new(); + let attempt = local.expect_message_for_queue_then_deliver("default").await; + let mut dsn = Vec::new(); + let mut retries = Vec::new(); + in_fight.insert(attempt.queue_id); + attempt.try_deliver(local.server.clone()); + + loop { + match local.try_read_event().await { + Some(QueueEvent::WorkerDone { + queue_id, status, .. + }) => { + in_fight.remove(&queue_id); + match &status { + QueueEventStatus::Completed | QueueEventStatus::Deferred => (), + _ => panic!("unexpected status {queue_id}: {status:?}"), + } + } + Some(QueueEvent::Refresh) | Some(QueueEvent::ReloadSettings) => (), + None | Some(QueueEvent::Stop) | Some(QueueEvent::Paused(_)) => break, + } + + let now = now(); + let mut events = local.all_queued_messages().await; + if events.messages.is_empty() { + if events.next_refresh < now + QUEUE_REFRESH { + tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await; + events = local.all_queued_messages().await; + } else if in_fight.is_empty() { + break; + } + } + + for event in events.messages { + if in_fight.contains(&event.queue_id) { + continue; + } + + let message = local + .server + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message + .clone() + .remove(&local.server, event.due.into()) + .await; + dsn.push(message); + } else { + retries.push(event.due.saturating_sub(now)); + in_fight.insert(event.queue_id); + event.try_deliver(local.server.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + local.assert_queue_is_empty().await; + assert_eq!(retries, vec![1, 2, 3]); + assert_eq!(dsn.len(), 4); + let mut dsn = dsn.into_iter(); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (failed to lookup 'foobar.org'") + .assert_contains("Final-Recipient: rfc822;bill@foobar.org") + .assert_contains("Action: failed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (failed to lookup '_dns_error.org'") + .assert_contains("Final-Recipient: rfc822;jane@_dns_error.org") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (failed to lookup '_dns_error.org'") + .assert_contains("Final-Recipient: rfc822;jane@_dns_error.org") + .assert_contains("Action: delayed"); + + dsn.next() + .unwrap() + .read_lines(&local) + .await + .assert_contains(" (failed to lookup '_dns_error.org'") + .assert_contains("Final-Recipient: rfc822;jane@_dns_error.org") + .assert_contains("Action: failed"); + + // Test FUTURERELEASE + DELIVERBY (RETURN) + session.data.remote_ip_str = "10.0.0.2".into(); + session.eval_session_params().await; + session + .send_message( + " HOLDFOR=60 BY=3600;R", + &["john@test.net"], + "test:no_dkim", + "250", + ) + .await; + let now_ = now(); + let message = local.expect_message().await; + assert!([59, 60].contains(&(local.message_due(message.queue_id).await - now_))); + assert!([59, 60].contains(&(message.message.next_delivery_event(None).unwrap() - now_))); + assert!( + [3599, 3600].contains( + &(message + .message + .recipients + .first() + .unwrap() + .expiration_time(message.message.created) + .unwrap() + - now_) + ) + ); + assert!( + [54059, 54060].contains(&(message.message.recipients.first().unwrap().notify.due - now_)), + "diff: {}", + message.message.recipients.first().unwrap().notify.due - now_ + ); + + // Test DELIVERBY (NOTIFY) + session + .send_message( + " BY=3600;N", + &["john@test.net"], + "test:no_dkim", + "250", + ) + .await; + let schedule = local.expect_message().await; + assert!( + [3599, 3600].contains(&(schedule.message.recipients.first().unwrap().notify.due - now())), + "diff: {}", + schedule.message.recipients.first().unwrap().notify.due - now() + ); +} diff --git a/tests/src/smtp/queue/virtualq.rs b/tests/src/smtp/queue/virtualq.rs new file mode 100644 index 0000000..4cf110a --- /dev/null +++ b/tests/src/smtp/queue/virtualq.rs @@ -0,0 +1,286 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::session::TestSession, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{BuildServer, config::smtp::queue::QueueName, ipc::QueueEvent}; +use mail_auth::{DnssecStatus, MX}; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::ObjectType, + structs::{ + Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, + MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals, + MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData, + MtaVirtualQueue, + }, + }, + types::list::List, +}; +use smtp::queue::manager::Queue; +use std::time::{Duration, Instant}; + +const NUM_MESSAGES: usize = 100; +const NUM_QUEUES: usize = 10; + +#[tokio::test(flavor = "multi_thread", worker_threads = 18)] +#[serial_test::serial] +async fn virtual_queue() { + let mut local = TestServerBuilder::new("smtp_virtual_queue_local") + .await + .with_http_listener(19042) + .await + .disable_services() + .build() + .await; + let mut remote = TestServerBuilder::new("smtp_virtual_queue_remote") + .await + .with_http_listener(19043) + .await + .with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let local_admin = local.account("admin"); + local_admin + .registry_create_object(MtaOutboundStrategy { + schedule: Expression { + match_: List::from_iter([ExpressionMatch { + if_: "rcpt == 'delay-random@foobar.org'".into(), + then: "'q2'".into(), + }]), + else_: "'q1'".into(), + }, + ..Default::default() + }) + .await; + local_admin + .registry_create_object(MtaStageData { + max_messages: Expression { + else_: "2000".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + let queue1_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "q1".into(), + threads_per_node: 5, + description: None, + }) + .await; + let queue2_id = local_admin + .registry_create_object(MtaVirtualQueue { + name: "q2".into(), + threads_per_node: 4, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "q1".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 86_400_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 86_400_000u64.into(), + }), + queue_id: queue1_id, + description: None, + }) + .await; + local_admin + .registry_create_object(MtaDeliverySchedule { + name: "q2".into(), + retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 1_000u64.into(), + }]), + }), + notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals { + intervals: List::from_iter([MtaDeliveryScheduleInterval { + duration: 86_400_000u64.into(), + }]), + }), + expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl { + expire: 86_400_000u64.into(), + }), + queue_id: queue2_id, + description: None, + }) + .await; + local_admin.mta_allow_relaying().await; + local_admin.mta_disable_spam_filter().await; + local_admin.mta_allow_non_fqdn().await; + local_admin.mta_no_auth().await; + local_admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + local_admin.reload_settings().await; + local.reload_core(); + + let remote_admin = remote.account("admin"); + remote_admin.mta_allow_relaying().await; + remote_admin.mta_disable_spam_filter().await; + remote_admin.mta_allow_non_fqdn().await; + remote_admin.mta_no_auth().await; + remote_admin + .registry_destroy_all(ObjectType::MtaInboundThrottle) + .await; + remote_admin.reload_settings().await; + remote.reload_core(); + remote.expect_reload_settings().await; + + // Validate parsing + for value in ["a", "ab", "abcdefgh"] { + let queue_name = QueueName::new(value).unwrap(); + assert_eq!(queue_name.to_string(), value); + } + assert_eq!( + local + .server + .core + .smtp + .queue + .virtual_queues + .get(&QueueName::new("q1").unwrap()) + .unwrap() + .threads, + 5 + ); + assert_eq!( + local + .server + .core + .smtp + .queue + .virtual_queues + .get(&QueueName::new("q2").unwrap()) + .unwrap() + .threads, + 4 + ); + + // Add mock DNS entries + local.server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(100), + ); + local.server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + + let mut session = local.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + + // Spawn concurrent queues + let mut inners = vec![]; + for _ in 0..NUM_QUEUES { + let (inner, rxs) = local.inner_with_rxs().await; + let server = inner.build_server(); + server.mx_add( + "foobar.org", + vec![MX { + exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(), + preference: 10, + }], + DnssecStatus::Secure, + Instant::now() + Duration::from_secs(100), + ); + server.ipv4_add( + "mx.foobar.org", + vec!["127.0.0.1".parse().unwrap()], + Instant::now() + Duration::from_secs(100), + ); + inners.push(inner.clone()); + tokio::spawn(async move { + Queue::new(inner, rxs.queue_rx.unwrap()).start().await; + }); + } + + tokio::time::sleep(Duration::from_millis(200)).await; + + // Send 1000 test messages + for _ in 0..(NUM_MESSAGES / 2) { + session + .send_message( + "john@test.org", + &["bill@foobar.org", "delay-random@foobar.org"], + "test:no_dkim", + "250", + ) + .await; + } + + // Wake up all queues + for inner in &inners { + inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap(); + } + for _ in 0..(NUM_MESSAGES / 2) { + session + .send_message( + "john@test.org", + &["bill@foobar.org", "delay-random@foobar.org"], + "test:no_dkim", + "250", + ) + .await; + } + + loop { + tokio::time::sleep(Duration::from_millis(1500)).await; + + let m = local.read_queued_messages().await; + let e = local.read_queued_events().await; + + if m.len() + e.len() != 0 { + println!( + "Queue still has {} messages and {} events", + m.len(), + e.len() + ); + /*for inner in &inners { + inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap(); + }*/ + } else { + break; + } + } + + local.assert_queue_is_empty().await; + let remote_messages = remote.read_queued_messages().await; + assert_eq!(remote_messages.len(), NUM_MESSAGES * 2); + + // Make sure local store is queue + local + .account("admin") + .registry_destroy_all(ObjectType::MtaConnectionStrategy) + .await; + local.assert_is_empty().await; +} diff --git a/tests/src/smtp/reporting/analyze.rs b/tests/src/smtp/reporting/analyze.rs new file mode 100644 index 0000000..b756811 --- /dev/null +++ b/tests/src/smtp/reporting/analyze.rs @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{smtp::session::TestSession, utils::server::TestServerBuilder}; +use ahash::AHashMap; +use flate2::{Compression, Crc, write::GzEncoder}; +use mail_builder::{ + MessageBuilder, + mime::{BodyPart, MimePart}, +}; +use registry::{ + schema::{ + enums::TaskStoreMaintenanceType, + structs::{ + ArfExternalReport, DataRetention, DmarcExternalReport, Expression, MtaStageData, + ReportSettings, Task, TaskStatus, TaskStoreMaintenance, TlsExternalReport, + }, + }, + types::map::Map, +}; +use std::{io::Write, time::Duration}; + +const MAX_REPORT_SIZE: i64 = 65536; + +const DMARC_REPORT: &str = concat!( + r#""#, + r#"Exampledmarc@example.org"#, + r#"112"#, + r#"foobar.org"#, + r#""# +); + +fn report_message(content_type: &str, file_name: &str, payload: &[u8]) -> String { + MessageBuilder::new() + .from(("Reporter", "reporter@test.org")) + .to("reports@foobar.org") + .subject("Report Domain: foobar.org") + .body(MimePart::new( + "multipart/report", + BodyPart::Multipart(vec![ + MimePart::new("text/plain", BodyPart::Text("Report attached.".into())), + MimePart::new(content_type, BodyPart::Binary(payload.into())).attachment(file_name), + ]), + )) + .write_to_string() + .unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +async fn report_analyze() { + let mut test = TestServerBuilder::new("smtp_analyze_report_test") + .await + .with_http_listener(19044) + .await + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + admin + .registry_create_object(MtaStageData { + max_messages: Expression { + else_: "100".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(ReportSettings { + inbound_report_addresses: Map::new(vec![ + "reports@*".to_string(), + "*@dmarc.foobar.org".to_string(), + "feedback@foobar.org".to_string(), + ]), + inbound_report_forwarding: false, + inbound_report_max_size: MAX_REPORT_SIZE, + ..Default::default() + }) + .await; + admin + .registry_create_object(DataRetention { + hold_mta_reports_for: Some(1u64.into()), + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.mta_allow_relaying().await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Create test message + let mut session = test.new_mta_session(); + session.data.remote_ip_str = "10.0.0.1".into(); + session.eval_session_params().await; + session.ehlo("mx.test.org").await; + + let addresses = [ + "reports@foobar.org", + "rep@dmarc.foobar.org", + "feedback@foobar.org", + ]; + let mut ac = 0; + let mut total_reports_received: AHashMap<&str, usize> = AHashMap::new(); + for (test_name, num_tests) in [("arf", 5), ("dmarc", 5), ("tls", 2)] { + for num_test in 1..=num_tests { + *total_reports_received.entry(test_name).or_insert(0) += 1; + session + .send_message( + "john@test.org", + &[addresses[ac % addresses.len()]], + &format!("report:{test_name}{num_test}"), + "250", + ) + .await; + test.assert_no_events(); + ac += 1; + } + } + + // Report ingestion is asynchronous, poll until the reports are stored + let admin = test.account("admin"); + for _ in 0..50 { + if admin.registry_get_all::().await.len() + == total_reports_received["dmarc"] + && admin.registry_get_all::().await.len() + == total_reports_received["tls"] + && admin.registry_get_all::().await.len() + == total_reports_received["arf"] + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Purging the database shouldn't remove the reports + admin + .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeData, + shard_index: None, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + + // Make sure the reports are in the store + assert_eq!( + admin.registry_get_all::().await.len(), + total_reports_received["dmarc"] + ); + assert_eq!( + admin.registry_get_all::().await.len(), + total_reports_received["tls"] + ); + assert_eq!( + admin.registry_get_all::().await.len(), + total_reports_received["arf"] + ); + + // Wait one second, purge, and make sure they are gone + tokio::time::sleep(Duration::from_secs(1)).await; + admin + .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeData, + shard_index: None, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + assert_eq!( + admin.registry_get_all::().await, + vec![] + ); + assert_eq!(admin.registry_get_all::().await, vec![]); + assert_eq!(admin.registry_get_all::().await, vec![]); + + // Reports that lie about their size or exceed the limit must not be ingested + let attachment_name = "mx.test.org!foobar.org!1!2.xml"; + for payload in [ + report_message( + "application/zip", + &format!("{attachment_name}.zip"), + &zip("report.xml", DMARC_REPORT.as_bytes(), None, Some(u32::MAX)), + ), + report_message( + "application/gzip", + &format!("{attachment_name}.gz"), + &gzip(&vec![b' '; MAX_REPORT_SIZE as usize * 2]), + ), + ] { + session + .send_message("john@test.org", &["reports@foobar.org"], &payload, "250") + .await; + test.assert_no_events(); + } + + // A report within the limit is still ingested + session + .send_message( + "john@test.org", + &["reports@foobar.org"], + &report_message( + "application/zip", + &format!("{attachment_name}.zip"), + &zip("report.xml", DMARC_REPORT.as_bytes(), None, None), + ), + "250", + ) + .await; + test.assert_no_events(); + + let admin = test.account("admin"); + for _ in 0..50 { + if !admin + .registry_get_all::() + .await + .is_empty() + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!( + admin.registry_get_all::().await.len(), + 1 + ); + + // Redeliveries of a previously stored report must not be imported again + session + .send_message( + "john@test.org", + &["reports@foobar.org"], + &report_message( + "application/zip", + &format!("{attachment_name}.zip"), + &zip("report.xml", DMARC_REPORT.as_bytes(), None, None), + ), + "250", + ) + .await; + test.assert_no_events(); + + let admin = test.account("admin"); + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + admin.registry_get_all::().await.len(), + 1 + ); + } + + // Test delivery to non-report addresses + session + .send_message("john@test.org", &["bill@foobar.org"], "test:no_dkim", "250") + .await; + test.expect_refresh().await; + test.last_queued_message().await; + + // Messages sent to a report address that contain no report must be delivered + session + .send_message( + "john@test.org", + &["reports@foobar.org"], + concat!( + "From: john@test.org\r\n", + "To: reports@foobar.org\r\n", + "Subject: Your MX is refusing my connections\r\n", + "\r\n", + "Could you have a look at this?" + ), + "250", + ) + .await; + let message = test.expect_message().await; + assert_eq!( + message.message.recipients.last().unwrap().address(), + "reports@foobar.org" + ); + + // Reports addressed to both a report address and a regular mailbox are + // discarded only for the report address + session + .send_message( + "john@test.org", + &["reports@foobar.org", "bill@foobar.org"], + &report_message( + "application/zip", + &format!("{attachment_name}.zip"), + &zip("report.xml", DMARC_REPORT.as_bytes(), None, None), + ), + "250", + ) + .await; + let message = test.expect_message().await; + assert_eq!( + message + .message + .recipients + .iter() + .map(|rcpt| rcpt.address()) + .collect::>(), + vec!["bill@foobar.org"] + ); +} + +fn gzip(data: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() +} + +fn zip( + name: &str, + data: &[u8], + compressed_size: Option, + uncompressed_size: Option, +) -> Vec { + let mut crc = Crc::new(); + crc.update(data); + let crc = crc.sum(); + let compressed_size = compressed_size.unwrap_or(data.len() as u32); + let uncompressed_size = uncompressed_size.unwrap_or(data.len() as u32); + let name = name.as_bytes(); + + let mut out = Vec::new(); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&compressed_size.to_le_bytes()); + out.extend_from_slice(&uncompressed_size.to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name); + out.extend_from_slice(data); + + let central_offset = out.len() as u32; + out.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&compressed_size.to_le_bytes()); + out.extend_from_slice(&uncompressed_size.to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(name); + + let central_size = out.len() as u32 - central_offset; + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(¢ral_size.to_le_bytes()); + out.extend_from_slice(¢ral_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + + out +} diff --git a/tests/src/smtp/reporting/dmarc.rs b/tests/src/smtp/reporting/dmarc.rs new file mode 100644 index 0000000..52b20f0 --- /dev/null +++ b/tests/src/smtp/reporting/dmarc.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{inbound::TestMessage, session::VerifyResponse}, + utils::{dns::DnsCache, server::TestServerBuilder}, +}; +use common::{config::smtp::report::AggregateFrequency, ipc::DmarcEvent}; +use mail_auth::{ + common::parse::TxtRecordParser, + dmarc::Dmarc, + report::{ActionDisposition, Disposition, DmarcResult, Record, Report}, +}; +use registry::schema::structs::{ + DmarcInternalReport, DmarcReportSettings, Expression, ReportSettings, +}; +use smtp::reporting::dmarc::DmarcReporting; +use std::{ + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; + +#[tokio::test] +async fn report_dmarc() { + let mut test = TestServerBuilder::new("smtp_report_dmarc_test") + .await + .with_http_listener(19045) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + let domain_id = admin.find_or_create_domain("example.org").await; + admin.create_dkim_signatures(domain_id).await; + admin + .registry_create_object(ReportSettings { + outbound_report_submitter: Expression { + else_: "'mx.example.org'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(DmarcReportSettings { + aggregate_contact_info: Expression { + else_: "'https://foobar.org/contact'".into(), + ..Default::default() + }, + aggregate_dkim_sign_domain: Expression { + else_: "'example.org'".into(), + ..Default::default() + }, + aggregate_from_address: Expression { + else_: "'reports@' + system('domain')".into(), + ..Default::default() + }, + aggregate_from_name: Expression { + else_: "'DMARC Report'".into(), + ..Default::default() + }, + aggregate_max_report_size: Expression { + else_: "4096".into(), + ..Default::default() + }, + aggregate_org_name: Expression { + else_: "'Foobar, Inc.'".into(), + ..Default::default() + }, + aggregate_send_frequency: Expression { + else_: "daily".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.mta_allow_relaying().await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Authorize external report for foobar.org + test.server.txt_add( + "foobar.org._report._dmarc.foobar.net", + Dmarc::parse(b"v=DMARC1;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + + // Schedule two events with a same policy and another one with a different policy + let dmarc_record = Arc::new( + Dmarc::parse( + b"v=DMARC1; p=quarantine; rua=mailto:reports@foobar.net,mailto:reports@example.net", + ) + .unwrap(), + ); + assert_eq!(dmarc_record.rua().len(), 2); + for _ in 0..2 { + test.server + .schedule_dmarc(Box::new(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("192.168.1.2".parse().unwrap()) + .with_action_disposition(ActionDisposition::Pass) + .with_dmarc_dkim_result(DmarcResult::Pass) + .with_dmarc_spf_result(DmarcResult::Fail) + .with_envelope_from("hello@example.org") + .with_envelope_to("other@example.org") + .with_header_from("bye@example.org"), + dmarc_record: dmarc_record.clone(), + interval: AggregateFrequency::Weekly, + span_id: 0, + })) + .await; + } + test.server + .schedule_dmarc(Box::new(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("a:b:c::e:f".parse().unwrap()) + .with_action_disposition(ActionDisposition::Reject) + .with_dmarc_dkim_result(DmarcResult::Fail) + .with_dmarc_spf_result(DmarcResult::Pass), + dmarc_record: dmarc_record.clone(), + interval: AggregateFrequency::Weekly, + span_id: 0, + })) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + let reports = test.read_report_events::().await; + assert_eq!(reports.len(), 1); + test.server + .send_dmarc_aggregate_report(reports.first().unwrap().0.id()) + .await + .unwrap(); + + // Expect report + let message = test.expect_message().await; + test.assert_no_events(); + assert_eq!(message.message.recipients.len(), 1); + assert_eq!( + message.message.recipients.last().unwrap().address(), + "reports@foobar.net" + ); + assert_eq!(message.message.return_path.as_ref(), "reports@example.org"); + message + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;") + .assert_contains("To: ") + .assert_contains("Report Domain: foobar.org") + .assert_contains("Submitter: mx.example.org"); + + // Verify generated report + let report = + Report::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap(); + assert_eq!(report.domain(), "foobar.org"); + assert_eq!(report.email(), "reports@example.org"); + assert_eq!(report.org_name(), "Foobar, Inc."); + assert_eq!( + report.extra_contact_info().unwrap(), + "https://foobar.org/contact" + ); + assert_eq!(report.p(), Disposition::Quarantine); + assert_eq!(report.records().len(), 2, "records: {:?}", report.records()); + for record in report.records() { + let source_ip = record.source_ip().unwrap(); + if source_ip == "192.168.1.2".parse::().unwrap() { + assert_eq!(record.count(), 2); + assert_eq!(record.action_disposition(), ActionDisposition::Pass); + assert_eq!(record.envelope_from(), "hello@example.org"); + assert_eq!(record.header_from(), "bye@example.org"); + assert_eq!(record.envelope_to().unwrap(), "other@example.org"); + } else if source_ip == "a:b:c::e:f".parse::().unwrap() { + assert_eq!(record.count(), 1); + assert_eq!(record.action_disposition(), ActionDisposition::Reject); + } else { + panic!("unexpected ip {source_ip}"); + } + } + test.assert_report_is_empty::().await; +} diff --git a/tests/src/smtp/reporting/mod.rs b/tests/src/smtp/reporting/mod.rs new file mode 100644 index 0000000..0972ccc --- /dev/null +++ b/tests/src/smtp/reporting/mod.rs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod analyze; +pub mod dmarc; +pub mod scheduler; +pub mod tls; diff --git a/tests/src/smtp/reporting/scheduler.rs b/tests/src/smtp/reporting/scheduler.rs new file mode 100644 index 0000000..5c32d47 --- /dev/null +++ b/tests/src/smtp/reporting/scheduler.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use common::{ + config::smtp::report::AggregateFrequency, + ipc::{DmarcEvent, PolicyType, TlsEvent}, +}; +use mail_auth::{ + common::parse::TxtRecordParser, + dmarc::Dmarc, + mta_sts::TlsRpt, + report::{ActionDisposition, DmarcResult, Record}, +}; +use registry::schema::structs::{ + DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings, +}; +use smtp::reporting::{dmarc::DmarcReporting, tls::TlsReporting}; +use std::sync::Arc; + +#[tokio::test] +async fn report_scheduler() { + let mut test = TestServerBuilder::new("smtp_report_queue_test") + .await + .with_http_listener(19046) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + admin + .registry_create_object(DmarcReportSettings { + aggregate_max_report_size: Expression { + else_: "500".into(), + ..Default::default() + }, + aggregate_send_frequency: Expression { + else_: "daily".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(TlsReportSettings { + max_report_size: Expression { + else_: "550".into(), + ..Default::default() + }, + send_frequency: Expression { + else_: "daily".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.mta_allow_relaying().await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Schedule two events with a same policy and another one with a different policy + let dmarc_record = + Arc::new(Dmarc::parse(b"v=DMARC1; p=quarantine; rua=mailto:dmarc@foobar.org").unwrap()); + test.server + .schedule_dmarc(Box::new(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("192.168.1.2".parse().unwrap()) + .with_action_disposition(ActionDisposition::Pass) + .with_dmarc_dkim_result(DmarcResult::Pass) + .with_dmarc_spf_result(DmarcResult::Fail) + .with_envelope_from("hello@example.org") + .with_envelope_to("other@example.org") + .with_header_from("bye@example.org"), + dmarc_record: dmarc_record.clone(), + interval: AggregateFrequency::Weekly, + span_id: 0, + })) + .await; + + // No records should be added once the 550 bytes max size is reached + for _ in 0..10 { + test.server + .schedule_dmarc(Box::new(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("192.168.1.2".parse().unwrap()) + .with_action_disposition(ActionDisposition::Pass) + .with_dmarc_dkim_result(DmarcResult::Pass) + .with_dmarc_spf_result(DmarcResult::Fail) + .with_envelope_from("hello@example.org") + .with_envelope_to("other@example.org") + .with_header_from("bye@example.org"), + dmarc_record: dmarc_record.clone(), + interval: AggregateFrequency::Weekly, + span_id: 0, + })) + .await; + } + let dmarc_record = + Arc::new(Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:dmarc@foobar.org").unwrap()); + test.server + .schedule_dmarc(Box::new(DmarcEvent { + domain: "foobar.org".to_string(), + report_record: Record::new() + .with_source_ip("a:b:c::e:f".parse().unwrap()) + .with_action_disposition(ActionDisposition::Reject) + .with_dmarc_dkim_result(DmarcResult::Fail) + .with_dmarc_spf_result(DmarcResult::Pass), + dmarc_record: dmarc_record.clone(), + interval: AggregateFrequency::Weekly, + span_id: 0, + })) + .await; + + // Schedule TLS event + let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:reports@foobar.org").unwrap()); + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: PolicyType::Tlsa(None), + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: PolicyType::Tlsa(None), + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: PolicyType::Sts(None), + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: PolicyType::None, + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + + // Verify sizes and counts + let mut total_tls = 0; + let mut total_tls_policies = 0; + let mut total_dmarc_policies = 0; + for (_, report) in test.read_report_events::().await { + total_dmarc_policies += 1; + assert_eq!( + report.deliver_at.timestamp() - report.created_at.timestamp(), + 7 * 86400 + ); + } + for (_, report) in test.read_report_events::().await { + total_tls += 1; + total_tls_policies += report.report.policies.len(); + assert_eq!( + report.deliver_at.timestamp() - report.created_at.timestamp(), + 86400 + ); + } + assert_eq!(total_tls, 1); + assert_eq!(total_tls_policies, 3); + assert_eq!(total_dmarc_policies, 2); +} diff --git a/tests/src/smtp/reporting/tls.rs b/tests/src/smtp/reporting/tls.rs new file mode 100644 index 0000000..e6f4801 --- /dev/null +++ b/tests/src/smtp/reporting/tls.rs @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + smtp::{inbound::TestMessage, session::VerifyResponse}, + utils::server::TestServerBuilder, +}; +use common::{config::smtp::report::AggregateFrequency, ipc::TlsEvent}; +use mail_auth::{ + common::parse::TxtRecordParser, + flate2::read::GzDecoder, + mta_sts::TlsRpt, + report::tlsrpt::{FailureDetails, PolicyType, ResultType, TlsReport}, +}; +use registry::schema::structs::{Expression, ReportSettings, TlsInternalReport, TlsReportSettings}; +use smtp::reporting::tls::{TLS_HTTP_REPORT, TlsReporting}; +use std::{io::Read, sync::Arc, time::Duration}; + +#[tokio::test] +async fn report_tls() { + let mut test = TestServerBuilder::new("smtp_report_tls_test") + .await + .with_http_listener(19047) + .await + .disable_services() + .capture_queue() + .build() + .await; + + let admin = test.account("admin"); + let domain_id = admin.find_or_create_domain("example.org").await; + admin.create_dkim_signatures(domain_id).await; + admin + .registry_create_object(ReportSettings { + outbound_report_submitter: Expression { + else_: "'mx.example.org'".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(TlsReportSettings { + contact_info: Expression { + else_: "'https://foobar.org/contact'".into(), + ..Default::default() + }, + dkim_sign_domain: Expression { + else_: "'example.org'".into(), + ..Default::default() + }, + from_address: Expression { + else_: "'reports@example.org'".into(), + ..Default::default() + }, + from_name: Expression { + else_: "'Report Subsystem'".into(), + ..Default::default() + }, + org_name: Expression { + else_: "'Foobar, Inc.'".into(), + ..Default::default() + }, + send_frequency: Expression { + else_: "daily".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin.mta_no_auth().await; + admin.mta_allow_non_fqdn().await; + admin.mta_allow_relaying().await; + admin.reload_settings().await; + test.reload_core(); + test.expect_reload_settings().await; + + // Schedule TLS reports to be delivered via email + let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:reports@foobar.org").unwrap()); + + for _ in 0..2 { + // Add two successful records + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: common::ipc::PolicyType::None, + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + } + + for (policy, rt) in [ + ( + common::ipc::PolicyType::None, // Quota limited at 1532 bytes, this should not be included in the report. + ResultType::CertificateExpired, + ), + (common::ipc::PolicyType::Tlsa(None), ResultType::TlsaInvalid), + ( + common::ipc::PolicyType::Sts(None), + ResultType::StsPolicyFetchError, + ), + ( + common::ipc::PolicyType::Sts(None), + ResultType::StsPolicyInvalid, + ), + ( + common::ipc::PolicyType::Sts(None), + ResultType::StsWebpkiInvalid, + ), + ] { + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy, + failure: FailureDetails::new(rt).into(), + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + } + + // Wait for flush + tokio::time::sleep(Duration::from_millis(200)).await; + let reports = test.read_report_events::().await; + assert_eq!(reports.len(), 1); + let (report_id, report) = reports.into_iter().next().unwrap(); + assert_eq!(report.report.policies.len(), 3); + test.server + .send_tls_aggregate_report(report_id.id()) + .await + .unwrap(); + + // Expect report + let message = test.expect_message().await; + assert_eq!( + message.message.recipients.last().unwrap().address(), + "reports@foobar.org" + ); + assert_eq!(message.message.return_path.as_ref(), "reports@example.org"); + message + .read_lines(&test) + .await + .assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;") + .assert_contains("To: ") + .assert_contains("Report Domain: foobar.org") + .assert_contains("Submitter: mx.example.org"); + + // Verify generated report + let report = + TlsReport::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap(); + assert_eq!(report.organization_name.unwrap(), "Foobar, Inc."); + assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact"); + assert_eq!(report.policies.len(), 3); + let mut seen = [false; 3]; + for policy in report.policies { + match policy.policy.policy_type { + PolicyType::Tlsa => { + seen[0] = true; + assert_eq!(policy.summary.total_failure, 1); + assert_eq!(policy.summary.total_success, 0); + assert_eq!(policy.policy.policy_domain, "foobar.org"); + assert_eq!(policy.failure_details.len(), 1); + assert_eq!( + policy.failure_details.first().unwrap().result_type, + ResultType::TlsaInvalid + ); + } + PolicyType::Sts => { + seen[1] = true; + assert_eq!(policy.summary.total_failure, 3); + assert_eq!(policy.summary.total_success, 0); + assert_eq!(policy.policy.policy_domain, "foobar.org"); + assert_eq!(policy.failure_details.len(), 3); + assert!( + policy + .failure_details + .iter() + .any(|d| d.result_type == ResultType::StsPolicyFetchError) + ); + assert!( + policy + .failure_details + .iter() + .any(|d| d.result_type == ResultType::StsPolicyInvalid) + ); + assert!( + policy + .failure_details + .iter() + .any(|d| d.result_type == ResultType::StsWebpkiInvalid) + ); + } + PolicyType::NoPolicyFound => { + seen[2] = true; + assert_eq!(policy.summary.total_failure, 1); + assert_eq!(policy.summary.total_success, 2); + assert_eq!(policy.policy.policy_domain, "foobar.org"); + assert_eq!(policy.failure_details.len(), 1); + /*assert_eq!( + policy.failure_details.first().unwrap().result_type, + ResultType::CertificateExpired + );*/ + } + PolicyType::Other => unreachable!(), + } + } + + assert!(seen[0]); + assert!(seen[1]); + assert!(seen[2]); + + // Schedule TLS reports to be delivered via https + let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=https://127.0.0.1/tls").unwrap()); + + for _ in 0..2 { + // Add two successful records + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: common::ipc::PolicyType::None, + failure: None, + tls_record: tls_record.clone(), + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + } + + let reports = test.read_report_events::().await; + assert_eq!(reports.len(), 1); + test.server + .send_tls_aggregate_report(reports.first().unwrap().0.id()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Uncompress report + { + let gz_report = TLS_HTTP_REPORT.lock(); + let mut file = GzDecoder::new(&gz_report[..]); + let mut buf = Vec::new(); + file.read_to_end(&mut buf).unwrap(); + let report = TlsReport::parse_json(&buf).unwrap(); + assert_eq!(report.organization_name.unwrap(), "Foobar, Inc."); + assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact"); + assert_eq!(report.policies.len(), 1); + } + test.assert_report_is_empty::().await; +} diff --git a/tests/src/smtp/session.rs b/tests/src/smtp/session.rs new file mode 100644 index 0000000..5bf2515 --- /dev/null +++ b/tests/src/smtp/session.rs @@ -0,0 +1,392 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use common::{ + Server, + config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol}, + network::{ServerInstance, SessionStream, TcpAcceptor, limiter::ConcurrencyLimiter}, +}; +use rustls::{ServerConfig, server::ResolvesServerCert}; +use smtp::core::{Session, SessionAddress, SessionData, SessionParameters, State}; +use smtp::queue::MessageSource; +use std::{borrow::Cow, path::PathBuf, sync::Arc}; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + sync::watch, +}; +use tokio_rustls::TlsAcceptor; +use utils::snowflake::SnowflakeIdGenerator; + +pub struct DummyIo { + pub tx_buf: Vec, + pub rx_buf: Vec, + pub tls: bool, +} + +impl AsyncRead for DummyIo { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + if !self.rx_buf.is_empty() { + buf.put_slice(&self.rx_buf); + self.rx_buf.clear(); + std::task::Poll::Ready(Ok(())) + } else { + std::task::Poll::Pending + } + } +} + +impl AsyncWrite for DummyIo { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + self.tx_buf.extend_from_slice(buf); + std::task::Poll::Ready(Ok(buf.len())) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +impl SessionStream for DummyIo { + fn is_tls(&self) -> bool { + self.tls + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + ("".into(), "".into()) + } +} + +impl Unpin for DummyIo {} + +#[allow(async_fn_in_trait)] +pub trait TestSession { + fn test(server: Server) -> Self; + fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver) -> Self; + fn response(&mut self) -> Vec; + fn write_rx(&mut self, data: &str); + async fn rset(&mut self); + async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec; + async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str); + async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str); + async fn ehlo(&mut self, host: &str) -> Vec; + async fn mail_from(&mut self, from: &str, expected_code: &str); + async fn rcpt_to(&mut self, to: &str, expected_code: &str); + async fn data(&mut self, data: &str, expected_code: &str); + async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str); + async fn test_builder(&self); +} + +impl TestSession for Session { + fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver) -> Self { + Self { + state: State::default(), + instance: Arc::new(ServerInstance::test_with_shutdown(shutdown_rx)), + server, + stream: DummyIo { + rx_buf: vec![], + tx_buf: vec![], + tls: false, + }, + data: SessionData::new( + "127.0.0.1".parse().unwrap(), + 0, + "127.0.0.1".parse().unwrap(), + 0, + Default::default(), + 0, + ), + params: SessionParameters::default(), + hostname: "localhost".into(), + } + } + + fn test(server: Server) -> Self { + Self::test_with_shutdown(server, watch::channel(false).1) + } + + fn response(&mut self) -> Vec { + if !self.stream.tx_buf.is_empty() { + let response = std::str::from_utf8(&self.stream.tx_buf) + .unwrap() + .split("\r\n") + .filter_map(|r| { + if !r.is_empty() { + r.to_string().into() + } else { + None + } + }) + .collect::>(); + self.stream.tx_buf.clear(); + response + } else { + panic!("There was no response."); + } + } + + fn write_rx(&mut self, data: &str) { + self.stream.rx_buf.extend_from_slice(data.as_bytes()); + } + + async fn rset(&mut self) { + self.ingest(b"RSET\r\n").await.unwrap(); + self.response().assert_code("250"); + } + + async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec { + self.ingest(format!("{cmd}\r\n").as_bytes()).await.unwrap(); + self.response().assert_code(expected_code) + } + + async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str) { + let cmd = format!( + "AUTH PLAIN {}", + general_purpose::STANDARD.encode(format!("\0{username}\0{secret}")) + ); + self.cmd(&cmd, expected_code).await; + } + + async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str) { + self.cmd("AUTH LOGIN", "334").await; + self.cmd(&general_purpose::STANDARD.encode(username), "334") + .await; + self.cmd(&general_purpose::STANDARD.encode(secret), expected_code) + .await; + } + + async fn ehlo(&mut self, host: &str) -> Vec { + self.ingest(format!("EHLO {host}\r\n").as_bytes()) + .await + .unwrap(); + self.response().assert_code("250") + } + + async fn mail_from(&mut self, from: &str, expected_code: &str) { + self.ingest( + if !from.starts_with('<') { + format!("MAIL FROM:<{from}>\r\n") + } else { + format!("MAIL FROM:{from}\r\n") + } + .as_bytes(), + ) + .await + .unwrap(); + self.response().assert_code(expected_code); + } + + async fn rcpt_to(&mut self, to: &str, expected_code: &str) { + self.ingest( + if !to.starts_with('<') { + format!("RCPT TO:<{to}>\r\n") + } else { + format!("RCPT TO:{to}\r\n") + } + .as_bytes(), + ) + .await + .unwrap(); + self.response().assert_code(expected_code); + } + + async fn data(&mut self, data: &str, expected_code: &str) { + self.ingest(b"DATA\r\n").await.unwrap(); + self.response().assert_code("354"); + if let Some(file) = data.strip_prefix("test:") { + self.ingest(load_test_message(file, "messages").as_bytes()) + .await + .unwrap(); + } else if let Some(file) = data.strip_prefix("report:") { + self.ingest(load_test_message(file, "reports").as_bytes()) + .await + .unwrap(); + } else { + self.ingest(data.as_bytes()).await.unwrap(); + } + self.ingest(b"\r\n.\r\n").await.unwrap(); + self.response().assert_code(expected_code); + } + + async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str) { + self.mail_from(from, "250").await; + for to in to { + self.rcpt_to(to, "250").await; + } + self.data(data, expected_code).await; + } + + async fn test_builder(&self) { + let message = self + .build_message( + SessionAddress { + address: "bill@foobar.org".into(), + address_lcase: "bill@foobar.org".into(), + domain: "foobar.org".into(), + flags: 123, + dsn_info: Some("envelope1".into()), + }, + vec![ + SessionAddress { + address: "a@foobar.org".into(), + address_lcase: "a@foobar.org".into(), + domain: "foobar.org".into(), + flags: 1, + dsn_info: None, + }, + SessionAddress { + address: "b@test.net".into(), + address_lcase: "b@test.net".into(), + domain: "test.net".into(), + flags: 2, + dsn_info: None, + }, + SessionAddress { + address: "c@foobar.org".into(), + address_lcase: "c@foobar.org".into(), + domain: "foobar.org".into(), + flags: 3, + dsn_info: None, + }, + SessionAddress { + address: "d@test.net".into(), + address_lcase: "d@test.net".into(), + domain: "test.net".into(), + flags: 4, + dsn_info: None, + }, + ], + MessageSource::Authenticated, + self.server.inner.data.queue_id_gen.generate(), + 0, + ) + .await; + + let rcpts = ["a@foobar.org", "b@test.net", "c@foobar.org", "d@test.net"]; + for rcpt in &message.message.recipients { + let idx = (rcpt.flags - 1) as usize; + assert_eq!(rcpts[idx], rcpt.address()); + } + } +} + +pub fn load_test_message(file: &str, test: &str) -> String { + let mut test_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_file.push("resources"); + test_file.push("smtp"); + test_file.push(test); + test_file.push(format!("{file}.eml")); + std::fs::read_to_string(test_file).unwrap() +} + +pub trait VerifyResponse { + fn assert_code(self, expected_code: &str) -> Self; + fn assert_contains(self, expected_text: &str) -> Self; + fn assert_not_contains(self, expected_text: &str) -> Self; + fn assert_count(self, text: &str, occurrences: usize) -> Self; +} + +impl VerifyResponse for Vec { + fn assert_code(self, expected_code: &str) -> Self { + if self.last().expect("response").starts_with(expected_code) { + self + } else { + panic!("Expected {:?} but got {}.", expected_code, self.join("\n")); + } + } + + fn assert_contains(self, expected_text: &str) -> Self { + if self.iter().any(|line| line.contains(expected_text)) { + self + } else { + panic!("Expected {:?} but got {}.", expected_text, self.join("\n")); + } + } + + fn assert_not_contains(self, expected_text: &str) -> Self { + if !self.iter().any(|line| line.contains(expected_text)) { + self + } else { + panic!( + "Not expecting {:?} but got it {}.", + expected_text, + self.join("\n") + ); + } + } + + fn assert_count(self, text: &str, occurrences: usize) -> Self { + assert_eq!( + self.iter().filter(|l| l.contains(text)).count(), + occurrences, + "Expected {} occurrences of {:?}, found {}.", + occurrences, + text, + self.iter().filter(|l| l.contains(text)).count() + ); + self + } +} + +pub trait TestServerInstance { + fn test_with_shutdown(shutdown_rx: watch::Receiver) -> Self; +} + +impl TestServerInstance for ServerInstance { + fn test_with_shutdown(shutdown_rx: watch::Receiver) -> Self { + let tls_config = Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(DummyCertResolver)), + ); + + Self { + id: "smtp".to_string(), + protocol: ServerProtocol::Smtp, + acceptor: TcpAcceptor::Tls { + config: tls_config.clone(), + acceptor: TlsAcceptor::from(tls_config), + implicit: false, + }, + limiter: ConcurrencyLimiter::new(100), + tls_timeout: DEFAULT_TLS_TIMEOUT, + shutdown_rx, + proxy_networks: vec![], + span_id_gen: Arc::new(SnowflakeIdGenerator::new()), + } + } +} + +#[derive(Debug)] +pub struct DummyCertResolver; + +impl ResolvesServerCert for DummyCertResolver { + fn resolve(&self, _: rustls::server::ClientHello) -> Option> { + None + } +} + +pub fn test_server_instance() -> ServerInstance { + ServerInstance::test_with_shutdown(watch::channel(false).1) +} diff --git a/tests/src/store/blob.rs b/tests/src/store/blob.rs new file mode 100644 index 0000000..b257543 --- /dev/null +++ b/tests/src/store/blob.rs @@ -0,0 +1,533 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{cleanup::store_destroy, server::TestServerBuilder}; +use ahash::AHashMap; +use email::message::metadata::MessageMetadata; +use registry::{ + schema::{enums::CompressionAlgo, structs::Jmap}, + types::duration::Duration, +}; +use services::task_manager::destroy_account::destroy_account_blobs; +use store::{ + BlobStore, Serialize, SerializeInfallible, + write::{Archiver, BatchBuilder, BlobLink, BlobOp, ValueClass, now}, +}; +use types::{blob::BlobClass, blob_hash::BlobHash, collection::Collection, field::EmailField}; + +#[tokio::test] +pub async fn blob_tests() { + let test = TestServerBuilder::new("blob_tests") + .await + .with_object(Jmap { + upload_quota: 1024, + upload_ttl: Duration::from_millis(1000), + ..Default::default() + }) + .await + .build() + .await; + + let store = test.server.core.storage.data.clone(); + let blob_store = test.server.core.storage.blob.clone(); + + println!( + "Testing blob store {} with data store {}...", + std::env::var("BLOB_STORE").unwrap_or_else(|_| "default".to_string()), + std::env::var("STORE").unwrap() + ); + + // Test blob quota + assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); + assert!(!test.server.blob_has_quota(0, 1024).await.unwrap().allowed); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); + + // Test and reset store + test_store(blob_store.clone()).await; + store_destroy(&store).await; + + // Blob hash exists + let hash = BlobHash::generate(b"abc".as_slice()); + assert!(!store.blob_exists(&hash).await.unwrap()); + + // Reserve blob + let until = now() + 1; + store + .write( + BatchBuilder::new() + .with_account_id(0) + .set( + BlobOp::Link { + to: BlobLink::Temporary { until }, + hash: hash.clone(), + }, + 1024u32.serialize(), + ) + .build_all(), + ) + .await + .unwrap(); + + // Uncommitted blob, should not exist + assert!(!store.blob_exists(&hash).await.unwrap()); + + // Write blob to store + blob_store + .put_blob(hash.as_ref(), b"abc", CompressionAlgo::Lz4) + .await + .unwrap(); + + // Commit blob + store + .write( + BatchBuilder::new() + .set(BlobOp::Commit { hash: hash.clone() }, Vec::new()) + .build_all(), + ) + .await + .unwrap(); + + // Blob hash should now exist + assert!(store.blob_exists(&hash).await.unwrap()); + assert!( + blob_store + .get_blob(hash.as_ref(), 0..usize::MAX) + .await + .unwrap() + .is_some() + ); + + // AccountId 0 should be able to read blob + assert!( + store + .blob_has_access( + &hash, + BlobClass::Reserved { + account_id: 0, + expires: until + } + ) + .await + .unwrap() + ); + + // AccountId 1 should not be able to read blob + assert!( + !store + .blob_has_access( + &hash, + BlobClass::Reserved { + account_id: 1, + expires: until + } + ) + .await + .unwrap() + ); + + // Purge expired blobs + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + store + .purge_blobs_all_shards(blob_store.clone()) + .await + .unwrap(); + + // Blob hash should no longer exist + assert!(!store.blob_exists(&hash).await.unwrap()); + + // AccountId 0 should not be able to read blob + assert!( + !store + .blob_has_access( + &hash, + BlobClass::Reserved { + account_id: 0, + expires: until + } + ) + .await + .unwrap() + ); + + // Blob should no longer be in store + assert!( + blob_store + .get_blob(hash.as_ref(), 0..usize::MAX) + .await + .unwrap() + .is_none() + ); + + // Upload one linked blob to accountId 1, two linked blobs to accountId 0, and three unlinked (reserved) blobs to accountId 2 + let expiry_times = AHashMap::from_iter([ + (b"abc", now() - 10), + (b"efg", now() + 10), + (b"hij", now() + 10), + ]); + for (document_id, (blob, _)) in [ + (b"123", vec![]), + (b"456", vec![]), + (b"789", vec![]), + (b"abc", 5000u32.serialize()), + (b"efg", 1000u32.serialize()), + (b"hij", 2000u32.serialize()), + ] + .into_iter() + .enumerate() + { + let hash = BlobHash::generate(blob.as_slice()); + let mut batch = BatchBuilder::new(); + batch + .with_account_id(if document_id > 0 { 0 } else { 1 }) + .with_collection(Collection::Email) + .with_document(document_id as u32); + if let Some(until) = expiry_times.get(blob) { + batch.set( + BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Temporary { until: *until }, + }, + vec![], + ); + } else { + batch + .set( + BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Document, + }, + vec![], + ) + .set( + ValueClass::Property(EmailField::Metadata.into()), + Archiver::new(MessageMetadata { + contents: Default::default(), + rcvd_attach: Default::default(), + blob_hash: hash.clone(), + blob_body_offset: Default::default(), + preview: Default::default(), + raw_headers: Default::default(), + }) + .serialize() + .unwrap(), + ); + }; + batch.set(BlobOp::Commit { hash: hash.clone() }, vec![]); + + store.write(batch.build_all()).await.unwrap(); + blob_store + .put_blob(hash.as_ref(), blob.as_slice(), CompressionAlgo::Lz4) + .await + .unwrap(); + } + + // Purge expired blobs and make sure nothing else is deleted + store + .purge_blobs_all_shards(blob_store.clone()) + .await + .unwrap(); + for (pos, (blob, blob_class)) in [ + ( + b"abc", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"abc"], + }, + ), + ( + b"123", + BlobClass::Linked { + account_id: 1, + collection: 0, + document_id: 0, + }, + ), + ( + b"456", + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 1, + }, + ), + ( + b"789", + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 2, + }, + ), + ( + b"efg", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"efg"], + }, + ), + ( + b"hij", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"hij"], + }, + ), + ] + .into_iter() + .enumerate() + { + let hash = BlobHash::generate(blob.as_slice()); + let ct = pos == 0; + assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); + assert!(store.blob_exists(&hash).await.unwrap() ^ ct); + assert!( + blob_store + .get_blob(hash.as_ref(), 0..usize::MAX) + .await + .unwrap() + .is_some() + ^ ct + ); + } + + // AccountId 0 should not have access to accountId 1's blobs + assert!( + !store + .blob_has_access( + BlobHash::generate(b"123".as_slice()), + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 0, + } + ) + .await + .unwrap() + ); + + // Unlink blob + store + .write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(2) + .clear(BlobOp::Link { + hash: BlobHash::generate(b"789".as_slice()), + to: BlobLink::Document, + }) + .build_all(), + ) + .await + .unwrap(); + + // Purge and make sure blob is deleted + store + .purge_blobs_all_shards(blob_store.clone()) + .await + .unwrap(); + for (pos, (blob, blob_class)) in [ + ( + b"789", + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 2, + }, + ), + ( + b"123", + BlobClass::Linked { + account_id: 1, + collection: 0, + document_id: 0, + }, + ), + ( + b"456", + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 1, + }, + ), + ( + b"efg", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"efg"], + }, + ), + ( + b"hij", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"hij"], + }, + ), + ] + .into_iter() + .enumerate() + { + let ct = pos == 0; + let hash = BlobHash::generate(blob.as_slice()); + assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); + assert!(store.blob_exists(&hash).await.unwrap() ^ ct); + assert!( + blob_store + .get_blob(hash.as_ref(), 0..usize::MAX) + .await + .unwrap() + .is_some() + ^ ct + ); + } + + // Unlink all blobs from accountId 1 and purge + destroy_account_blobs(&test.server, 1).await.unwrap(); + store + .purge_blobs_all_shards(blob_store.clone()) + .await + .unwrap(); + + // Make sure only accountId 0's blobs are left + for (pos, (blob, blob_class)) in [ + ( + b"123", + BlobClass::Linked { + account_id: 1, + collection: 0, + document_id: 0, + }, + ), + ( + b"456", + BlobClass::Linked { + account_id: 0, + collection: 0, + document_id: 1, + }, + ), + ( + b"efg", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"efg"], + }, + ), + ( + b"hij", + BlobClass::Reserved { + account_id: 0, + expires: expiry_times[&b"hij"], + }, + ), + ] + .into_iter() + .enumerate() + { + let ct = pos == 0; + let hash = BlobHash::generate(blob.as_slice()); + assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); + assert!(store.blob_exists(&hash).await.unwrap() ^ ct); + assert!( + blob_store + .get_blob(hash.as_ref(), 0..usize::MAX) + .await + .unwrap() + .is_some() + ^ ct + ); + } + + test.temp_dir.delete(); +} + +async fn test_store(store: BlobStore) { + // Test small blob + const DATA: &[u8] = b"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce erat nisl, dignissim a porttitor id, varius nec arcu. Sed mauris."; + let hash = BlobHash::generate(DATA); + + store + .put_blob(hash.as_slice(), DATA, CompressionAlgo::Lz4) + .await + .unwrap(); + assert_eq!( + String::from_utf8( + store + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .unwrap() + ) + .unwrap(), + std::str::from_utf8(DATA).unwrap() + ); + assert_eq!( + String::from_utf8( + store + .get_blob(hash.as_slice(), 11..57) + .await + .unwrap() + .unwrap() + ) + .unwrap(), + std::str::from_utf8(&DATA[11..57]).unwrap() + ); + assert!(store.delete_blob(hash.as_slice()).await.unwrap()); + assert!( + store + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .is_none() + ); + + // Test large blob + let mut data = Vec::with_capacity(50 * 1024 * 1024); + while data.len() < 50 * 1024 * 1024 { + data.extend_from_slice(DATA); + let marker = format!(" [{}] ", data.len()); + data.extend_from_slice(marker.as_bytes()); + } + let hash = BlobHash::generate(&data); + store + .put_blob(hash.as_slice(), &data, CompressionAlgo::Lz4) + .await + .unwrap(); + assert_eq!( + String::from_utf8( + store + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .unwrap() + ) + .unwrap(), + std::str::from_utf8(&data).unwrap() + ); + + assert_eq!( + String::from_utf8( + store + .get_blob(hash.as_slice(), 3000111..4000999) + .await + .unwrap() + .unwrap() + ) + .unwrap(), + std::str::from_utf8(&data[3000111..4000999]).unwrap() + ); + assert!(store.delete_blob(hash.as_slice()).await.unwrap()); + assert!( + store + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + .unwrap() + .is_none() + ); +} diff --git a/tests/src/store/import_export.rs b/tests/src/store/import_export.rs new file mode 100644 index 0000000..326bab2 --- /dev/null +++ b/tests/src/store/import_export.rs @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + cleanup::{store_assert_is_empty, store_destroy}, + server::TestServer, + temp_dir::TempDir, +}; +use ::registry::schema::enums::CompressionAlgo; +use ahash::AHashSet; +use common::{DATABASE_SCHEMA_VERSION, manager::backup::BackupParams}; +use store::{ + rand, + write::{ + AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent, + RegistryClass, ValueClass, key::KeySerializer, + }, + *, +}; +use types::{ + blob_hash::BlobHash, + collection::{Collection, SyncCollection}, + field::{Field, MailboxField}, +}; + +pub async fn test(test: &TestServer) { + // Make sure the store is empty + store_assert_is_empty(test.server.store(), test.server.blob_store().clone(), true).await; + let db = test.server.store().clone(); + + // Create blobs + println!("Creating blobs..."); + let mut batch = BatchBuilder::new(); + batch.set( + ValueClass::Any(AnyClass { + subspace: SUBSPACE_PROPERTY, + key: vec![0u8], + }), + DATABASE_SCHEMA_VERSION.serialize(), + ); + let mut blob_hashes = Vec::new(); + for blob_size in [16, 128, 1024, 2056, 102400] { + let data = random_bytes(blob_size); + let hash = BlobHash::generate(data.as_slice()); + blob_hashes.push(hash.clone()); + test.server + .blob_store() + .put_blob(hash.as_ref(), &data, CompressionAlgo::Lz4) + .await + .unwrap(); + batch.set(ValueClass::Blob(BlobOp::Commit { hash }), vec![]); + } + db.write(batch.build_all()).await.unwrap(); + + // Create account data + println!("Creating account data..."); + for account_id in 0u32..10u32 { + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + + // Create properties of different sizes + for collection in [ + Collection::Email, + Collection::Mailbox, + Collection::Thread, + Collection::Identity, + ] { + batch.with_collection(collection); + + for document_id in [0, 10, 20, 30, 40] { + batch.with_document(document_id); + + if collection == Collection::Mailbox { + batch + .set( + ValueClass::Property(Field::ARCHIVE.into()), + random_bytes(10), + ) + .add( + ValueClass::Property(MailboxField::UidCounter.into()), + rand::random(), + ); + } + + for (idx, value_size) in [16, 128, 1024, 2056, 102400].into_iter().enumerate() { + batch.set(ValueClass::Property(idx as u8), random_bytes(value_size)); + } + + for grant_account_id in 0u32..10u32 { + if account_id != grant_account_id { + batch.set( + ValueClass::Acl(grant_account_id), + vec![account_id as u8, grant_account_id as u8, document_id as u8], + ); + } + } + + for hash in &blob_hashes { + batch.set( + ValueClass::Blob(BlobOp::Link { + hash: hash.clone(), + to: BlobLink::Document, + }), + vec![], + ); + } + + batch.log_item_insert(SyncCollection::from(collection), None); + + for field in 0..5 { + batch.any_op(Operation::Index { + field, + key: random_bytes(field as usize + 2), + set: true, + }); + } + } + } + + db.write(batch.build_all()).await.unwrap(); + } + + // Create queue, config and lookup data + println!("Creating queue, config and lookup data..."); + let mut batch = BatchBuilder::new(); + for idx in [1, 2, 3, 4, 5] { + batch.set( + ValueClass::Queue(QueueClass::Message(rand::random())), + random_bytes(idx), + ); + batch.set( + ValueClass::Queue(QueueClass::MessageEvent(QueueEvent { + due: rand::random(), + queue_id: rand::random(), + queue_name: rand::random(), + })), + random_bytes(idx), + ); + batch.set( + ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: 1, + }), + random_bytes(idx + 10), + ); + batch.set( + ValueClass::Registry(RegistryClass::IndexId { + object_id: idx as u16, + item_id: idx as u64 * 100, + }), + vec![], + ); + for index_id in 0u16..3 { + batch.set( + ValueClass::Registry(RegistryClass::Index { + object_id: idx as u16, + index_id, + key: random_bytes(idx + index_id as usize), + item_id: idx as u64 * 100, + }), + vec![], + ); + } + } + db.write(batch.build_all()).await.unwrap(); + + // Create directory data + println!("Creating directory data..."); + let mut batch = BatchBuilder::new(); + batch + .with_account_id(u32::MAX) + .with_collection(Collection::Principal); + + for account_id in [1, 2, 3, 4, 5] { + batch + .with_document(account_id) + .add(ValueClass::Quota, account_id as i64 * 1000); + } + db.write(batch.build_all()).await.unwrap(); + + // Obtain store hash + println!("Calculating store hash..."); + let snapshot = Snapshot::new(&db).await; + assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",); + + // Export store + println!("Exporting store..."); + let temp_dir = TempDir::new("art_vandelay_tests", true); + test.server + .core + .backup(BackupParams::new(temp_dir.path.clone())) + .await; + + // Destroy store + println!("Destroying store..."); + store_destroy(&db).await; + store_assert_is_empty(&db, db.clone().into(), true).await; + + // Import store over a node id lease + println!("Importing store..."); + let mut batch = BatchBuilder::new(); + batch.set( + ValueClass::NodeId(0), + KeySerializer::new(U64_LEN + 9) + .write(0u64) + .write("localhost") + .finalize(), + ); + db.write(batch.build_all()).await.unwrap(); + test.server.core.restore(temp_dir.path.clone()).await; + let mut batch = BatchBuilder::new(); + batch.clear(ValueClass::NodeId(0)); + db.write(batch.build_all()).await.unwrap(); + + // Verify hash + print!("Verifying store hash..."); + snapshot.assert_is_eq(&Snapshot::new(&db).await); + println!(" GREAT SUCCESS!"); + + // Destroy store + store_destroy(&db).await; + store_assert_is_empty(&db, db.clone().into(), true).await; + temp_dir.delete(); +} + +#[derive(Debug, PartialEq, Eq)] +struct Snapshot { + keys: AHashSet, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct KeyValue { + subspace: u8, + key: Vec, + value: Vec, +} + +impl Snapshot { + async fn new(db: &Store) -> Self { + let is_sql = db.is_sql(); + + let mut keys = AHashSet::new(); + + for (subspace, with_values) in [ + (SUBSPACE_ACL, true), + (SUBSPACE_TASK_QUEUE, true), + (SUBSPACE_INDEXES, false), + (SUBSPACE_DELETED_ITEMS, true), + (SUBSPACE_SPAM_SAMPLES, true), + (SUBSPACE_BLOB_LINK, true), + (SUBSPACE_BLOBS, true), + (SUBSPACE_LOGS, true), + (SUBSPACE_COUNTER, !is_sql), + (SUBSPACE_IN_MEMORY_COUNTER, !is_sql), + (SUBSPACE_IN_MEMORY_VALUE, true), + (SUBSPACE_PROPERTY, true), + (SUBSPACE_REGISTRY, true), + (SUBSPACE_REGISTRY_IDX, !is_sql), + (SUBSPACE_REGISTRY_PK, true), + (SUBSPACE_QUEUE_MESSAGE, true), + (SUBSPACE_QUEUE_EVENT, true), + (SUBSPACE_QUOTA, !is_sql), + (SUBSPACE_REPORT_OUT, true), + (SUBSPACE_REPORT_IN, true), + ] { + let from_key = AnyKey { + subspace, + key: vec![0u8], + }; + let to_key = AnyKey { + subspace, + key: vec![u8::MAX; 10], + }; + + db.iterate( + IterateParams::new(from_key, to_key).set_values(with_values), + |key, value| { + keys.insert(KeyValue { + subspace, + key: key.to_vec(), + value: value.to_vec(), + }); + + Ok(true) + }, + ) + .await + .unwrap(); + } + + Snapshot { keys } + } + + fn assert_is_eq(&self, other: &Self) { + let mut is_err = false; + for key in &self.keys { + if !other.keys.contains(key) { + println!( + "Subspace {}, Key {:?} not found in restored snapshot", + char::from(key.subspace), + key.key, + ); + is_err = true; + } + } + for key in &other.keys { + if !self.keys.contains(key) { + println!( + "Subspace {}, Key {:?} not found in original snapshot", + char::from(key.subspace), + key.key, + ); + is_err = true; + } + } + + if is_err { + panic!("Snapshot mismatch"); + } + } +} + +fn random_bytes(len: usize) -> Vec { + (0..len).map(|_| rand::random::()).collect() +} diff --git a/tests/src/store/lookup.rs b/tests/src/store/lookup.rs new file mode 100644 index 0000000..eb3c478 --- /dev/null +++ b/tests/src/store/lookup.rs @@ -0,0 +1,300 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + cleanup::{store_assert_is_empty, store_destroy}, + server::TestServerBuilder, +}; +use registry::schema::structs::Rate; +use registry::types::duration::Duration; +use store::{InMemoryStore, dispatch::lookup::KeyValue}; + +#[tokio::test] +pub async fn lookup_tests() { + let test = TestServerBuilder::new("lookup_tests").await.build().await; + let store = test.server.in_memory_store().clone(); + let rate = Rate { + count: 1, + period: Duration::from_millis(1000), + }; + + println!( + "Testing in-memory store {}...", + std::env::var("MEMORY_STORE").unwrap_or_else(|_| "default".to_string()) + ); + if let InMemoryStore::Store(store) = &store { + store_destroy(store).await; + } else { + // Reset redis counter + store + .key_set(KeyValue::new("abc", "0".as_bytes().to_vec())) + .await + .unwrap(); + } + + // Test key + let key = "xyz".as_bytes().to_vec(); + store + .key_set(KeyValue::new(key.clone(), "world".to_string().into_bytes())) + .await + .unwrap(); + store.purge_in_memory_store().await.unwrap(); + assert_eq!( + store.key_get::(key.clone()).await.unwrap(), + Some("world".to_string()) + ); + + // Test value expiry + store + .key_set(KeyValue::new(key.clone(), "hello".to_string().into_bytes()).expires(1)) + .await + .unwrap(); + assert_eq!( + store.key_get::(key.clone()).await.unwrap(), + Some("hello".to_string()) + ); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + assert_eq!(None, store.key_get::(key.clone()).await.unwrap()); + + store.purge_in_memory_store().await.unwrap(); + if let InMemoryStore::Store(store) = &store { + store_assert_is_empty(store, store.clone().into(), false).await; + } + + // Test counter + let key = "abc".as_bytes().to_vec(); + store + .counter_incr(KeyValue::new(key.clone(), 1), true) + .await + .unwrap(); + assert_eq!(1, store.counter_get(key.clone()).await.unwrap()); + store + .counter_incr(KeyValue::new(key.clone(), 2), true) + .await + .unwrap(); + assert_eq!(3, store.counter_get(key.clone()).await.unwrap()); + store + .counter_incr(KeyValue::new(key.clone(), -3), false) + .await + .unwrap(); + assert_eq!(0, store.counter_get(key.clone()).await.unwrap()); + + // Test counter expiry + let key = "fgh".as_bytes().to_vec(); + store + .counter_incr(KeyValue::new(key.clone(), 1).expires(1), false) + .await + .unwrap(); + assert_eq!(1, store.counter_get(key.clone()).await.unwrap()); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + store.purge_in_memory_store().await.unwrap(); + assert_eq!(0, store.counter_get(key.clone()).await.unwrap()); + + // Test rate limiter + assert!( + store + .is_rate_allowed(0, "rate".as_bytes(), &rate, false) + .await + .unwrap() + .is_none() + ); + assert!( + store + .is_rate_allowed(0, "rate".as_bytes(), &rate, false) + .await + .unwrap() + .is_some() + ); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + assert!( + store + .is_rate_allowed(0, "rate".as_bytes(), &rate, false) + .await + .unwrap() + .is_none() + ); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + store.purge_in_memory_store().await.unwrap(); + if let InMemoryStore::Store(store) = &store { + store_assert_is_empty(store, store.clone().into(), false).await; + } + + // Test locking + for iteration in [1, 2] { + let mut tasks = Vec::new(); + for _ in 0..100 { + let store = store.clone(); + tasks.push(tokio::spawn(async move { + store.try_lock(0, "lock".as_bytes(), 1).await.unwrap() + })); + } + // Only one should return true + let mut count = 0; + for task in tasks { + if task.await.unwrap() { + count += 1; + } + } + assert_eq!(1, count, "Iteration {}", iteration); + + // Wait 2 seconds for the lock to expire + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + store.purge_in_memory_store().await.unwrap(); + + // Failed lock attempts must not extend the lock expiry + assert!(store.try_lock(0, "lock".as_bytes(), 2).await.unwrap()); + let mut acquired = false; + for _ in 0..12 { + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + if store.try_lock(0, "lock".as_bytes(), 2).await.unwrap() { + acquired = true; + break; + } + } + assert!(acquired, "Abandoned lock was never released"); + store.remove_lock(0, "lock".as_bytes()).await.unwrap(); + store.purge_in_memory_store().await.unwrap(); + if let InMemoryStore::Store(store) = &store { + store_assert_is_empty(store, store.clone().into(), false).await; + } + + // Test prefix delete + store + .key_set(KeyValue::with_prefix( + 1, + [0], + "hello".to_string().into_bytes(), + )) + .await + .unwrap(); + for v in 0u32..2020u32 { + store + .key_set(KeyValue::with_prefix( + 0, + pack_u32(0, v), + "world".to_string().into_bytes(), + )) + .await + .unwrap(); + store + .counter_incr( + KeyValue::with_prefix(0, pack_u32(1, v), 123).expires(3600), + false, + ) + .await + .unwrap(); + } + + // Make sure the keys are there + assert_eq!( + Some("hello"), + store + .key_get::(KeyValue::<()>::build_key(1, [0])) + .await + .unwrap() + .as_deref() + ); + for v in [0, 1000, 1001, 2000, 2001] { + assert_eq!( + Some("world"), + store + .key_get::(KeyValue::<()>::build_key(0, pack_u32(0, v))) + .await + .unwrap() + .as_deref() + ); + } + for v in [0, 1000, 1001, 2000, 2001] { + assert_ne!( + 0, + store + .counter_get(KeyValue::<()>::build_key(0, pack_u32(1, v))) + .await + .unwrap() + ); + } + + // Delete [0, 0, 0, 0, 1] prefix and make sure only the keys with that prefix are gone + store + .key_delete_prefix(&KeyValue::<()>::build_key(0, 1u32.to_be_bytes())) + .await + .unwrap(); + + assert_eq!( + Some("hello"), + store + .key_get::(KeyValue::<()>::build_key(1, [0])) + .await + .unwrap() + .as_deref() + ); + for v in [0, 1000, 1001, 2000, 2001] { + assert_eq!( + Some("world"), + store + .key_get::(KeyValue::<()>::build_key(0, pack_u32(0, v))) + .await + .unwrap() + .as_deref() + ); + } + + for v in [0, 1000, 1001, 2000, 2001] { + assert_eq!( + 0, + store + .counter_get(KeyValue::<()>::build_key(0, pack_u32(1, v))) + .await + .unwrap() + ); + } + + // Delete [0, 0, 0, 0, 0] prefix and make sure only the keys with that prefix are gone + store + .key_delete_prefix(&KeyValue::<()>::build_key(0, 0u32.to_be_bytes())) + .await + .unwrap(); + + assert_eq!( + Some("hello"), + store + .key_get::(KeyValue::<()>::build_key(1, [0])) + .await + .unwrap() + .as_deref() + ); + for v in [0, 1000, 1001, 2000, 2001] { + assert_eq!( + None, + store + .key_get::(KeyValue::<()>::build_key(0, pack_u32(0, v))) + .await + .unwrap() + .as_deref() + ); + } + + // Delete [1, ...] prefix and make sure it's all gone + store.key_delete_prefix(&[1u8]).await.unwrap(); + + assert_eq!( + None, + store + .key_get::(KeyValue::<()>::build_key(1, [0])) + .await + .unwrap() + .as_deref() + ); + + if let InMemoryStore::Store(store) = &store { + store_assert_is_empty(store, store.clone().into(), false).await; + } +} + +fn pack_u32(a: u32, b: u32) -> Vec { + (((a as u64) << 32) | b as u64).to_be_bytes().to_vec() +} diff --git a/tests/src/store/mod.rs b/tests/src/store/mod.rs new file mode 100644 index 0000000..8919d71 --- /dev/null +++ b/tests/src/store/mod.rs @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod blob; +pub mod import_export; +pub mod lookup; +pub mod ops; +pub mod query; +pub mod registry; +#[cfg(any(feature = "postgres", feature = "mysql"))] +pub mod sql_timeout; + +use crate::utils::server::TestServerBuilder; +use std::io::Read; + +#[tokio::test(flavor = "multi_thread")] +pub async fn store_tests() { + let test = TestServerBuilder::new("store_tests").await.build().await; + + println!("Testing store {}...", std::env::var("STORE").unwrap()); + + test.destroy_store().await; + + registry::test(&test).await; + import_export::test(&test).await; + ops::test(&test).await; + #[cfg(any(feature = "postgres", feature = "mysql"))] + sql_timeout::test(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} + +#[tokio::test(flavor = "multi_thread")] +pub async fn search_tests() { + let test = TestServerBuilder::new("search_store_tests") + .await + .build() + .await; + + println!( + "Testing search store {}...", + std::env::var("SEARCH_STORE").unwrap_or("default".to_string()) + ); + + query::test(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} + +pub fn deflate_test_resource(name: &str) -> Vec { + let mut csv_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + csv_path.push("resources"); + csv_path.push(name); + + let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new( + std::fs::File::open(csv_path).unwrap(), + )); + let mut result = Vec::new(); + decoder.read_to_end(&mut result).unwrap(); + result +} diff --git a/tests/src/store/ops.rs b/tests/src/store/ops.rs new file mode 100644 index 0000000..01439f7 --- /dev/null +++ b/tests/src/store/ops.rs @@ -0,0 +1,801 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{cleanup::store_assert_is_empty, server::TestServer}; +use ahash::AHashSet; +use std::collections::HashSet; +use store::{ + ValueKey, + rand::{self, RngExt}, + write::{AlignedBytes, Archive, Archiver, BatchBuilder, MergeResult, Params, ValueClass}, +}; +use types::collection::Collection; +use types::collection::SyncCollection; + +// FDB max value +const MAX_VALUE_SIZE: usize = 100000; + +#[cfg(feature = "foundationdb")] +fn value_gen(chunks: impl IntoIterator) -> Vec { + let mut value = Vec::new(); + for (byte, size) in chunks { + value.extend(std::iter::repeat_n(byte, size)); + } + value +} + +pub async fn test(test: &TestServer) { + let db = test.server.store().clone(); + + #[cfg(feature = "foundationdb")] + if matches!(db, store::Store::FoundationDb(_)) { + use store::write::RegistryClass; + println!("Running FoundationDB chunked iterator test..."); + let kvs = [ + (1, value_gen([(b'a', 1)])), + (2, value_gen([(b'b', MAX_VALUE_SIZE), (b'0', 1)])), + ( + 3, + value_gen([ + (b'c', MAX_VALUE_SIZE), + (b'1', MAX_VALUE_SIZE), + (b'2', MAX_VALUE_SIZE), + ]), + ), + ( + 4, + value_gen([(b'd', MAX_VALUE_SIZE), (b'3', MAX_VALUE_SIZE)]), + ), + (5, value_gen([(b'e', 1)])), + ]; + let mut batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0); + + for (key, value) in &kvs { + batch.set( + ValueClass::Registry(RegistryClass::Item { + object_id: *key, + item_id: 0, + }), + value.clone(), + ); + } + db.write(batch.build_all()).await.unwrap(); + + // Iterate over all keys + let mut results = Vec::new(); + db.iterate( + store::IterateParams::new( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: 0, + }), + }, + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: u16::MAX, + item_id: u64::MAX, + }), + }, + ), + |key, value| { + results.push((String::from_utf8(key.to_vec()).unwrap(), value.to_vec())); + Ok(true) + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), kvs.len()); + + db.delete_range( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: 0, + }), + }, + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: u16::MAX, + item_id: u64::MAX, + }), + }, + ) + .await + .unwrap(); + + // Read-your-writes through the cached read version: overwrite a key in a tight loop + println!("Running FoundationDB read-your-writes test..."); + for n in 0u64..200 { + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .set( + ValueClass::Registry(RegistryClass::Item { + object_id: 100, + item_id: 0, + }), + n.to_be_bytes().to_vec(), + ) + .build_all(), + ) + .await + .unwrap(); + + let got = db + .get_value::(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: 100, + item_id: 0, + }), + }) + .await + .unwrap() + .unwrap(); + assert_eq!(got, n, "stale read: wrote {n} but read back {got}"); + } + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id: 100, + item_id: 0, + })) + .build_all(), + ) + .await + .unwrap(); + + // Read-version cache monotonicity under concurrency: while a writer increments a counter + println!("Running FoundationDB read-version monotonicity test..."); + let n_increments = 500u64; + + let writer = { + let db = db.clone(); + tokio::spawn(async move { + for _ in 0..n_increments { + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(5000) + .add_and_get(ValueClass::Quota, 1) + .build_all(), + ) + .await + .unwrap(); + } + }) + }; + + let mut readers = Vec::new(); + for _ in 0..16 { + let db = db.clone(); + readers.push(tokio::spawn(async move { + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500); + let mut last = 0i64; + while std::time::Instant::now() < deadline { + let current = db + .get_counter(ValueKey { + account_id: 0, + collection: 0, + document_id: 5000, + class: ValueClass::Quota, + }) + .await + .unwrap(); + assert!( + current >= last, + "read version regressed: counter went from {last} to {current}" + ); + last = current; + } + })); + } + + writer.await.unwrap(); + for reader in readers { + reader.await.unwrap(); + } + + assert_eq!( + db.get_counter(ValueKey { + account_id: 0, + collection: 0, + document_id: 5000, + class: ValueClass::Quota, + }) + .await + .unwrap(), + n_increments as i64, + "counter did not reach the expected total" + ); + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(5000) + .clear(ValueClass::Quota) + .build_all(), + ) + .await + .unwrap(); + + // Overwriting a chunked value with a shorter one must not leave orphaned chunks behind + println!("Running FoundationDB orphaned chunk test..."); + const ORPHAN_FIELD: u8 = 200; + let orphan_range = |document_id: u32| ValueKey { + account_id: 0, + collection: 0, + document_id, + class: ValueClass::Property(ORPHAN_FIELD), + }; + let marker = value_gen([(b'z', 16)]); + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(1) + .set(ValueClass::Property(ORPHAN_FIELD), marker.clone()) + .build_all(), + ) + .await + .unwrap(); + + for (byte, size) in [ + (b'a', MAX_VALUE_SIZE * 3), + (b'b', MAX_VALUE_SIZE * 2), + (b'c', MAX_VALUE_SIZE / 2), + (b'd', MAX_VALUE_SIZE * 3 / 2), + (b'e', MAX_VALUE_SIZE), + (b'f', 1), + (b'g', MAX_VALUE_SIZE * 4), + (b'h', 0), + ] { + let value = value_gen([(byte, size)]); + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .set(ValueClass::Property(ORPHAN_FIELD), value.clone()) + .build_all(), + ) + .await + .unwrap(); + + let mut results = Vec::new(); + db.iterate( + store::IterateParams::new(orphan_range(0), orphan_range(u32::MAX)), + |key, value| { + results.push((key.to_vec(), value.to_vec())); + Ok(true) + }, + ) + .await + .unwrap(); + + assert_eq!( + results.len(), + 2, + "orphaned chunks surfaced as extra rows after writing {size} bytes" + ); + assert_eq!( + results[0].1.len(), + value.len(), + "stale chunk spliced onto the value after writing {size} bytes" + ); + assert_eq!(results[0].1, value, "value mismatch for {size} bytes"); + assert_eq!( + results[1].1, marker, + "neighbouring document corrupted after writing {size} bytes" + ); + assert_eq!( + results[0].0.len(), + results[1].0.len(), + "chunk key returned as a document key after writing {size} bytes" + ); + } + + // A short key sharing a subspace with longer structured keys must never clear them, + // as the database schema version does in the property subspace + println!("Running FoundationDB short key test..."); + for document_id in [0u32, 1, 0xFFFF, 0x10000] { + db.write( + BatchBuilder::new() + .with_account_id(document_id) + .with_collection(Collection::Email) + .with_document(document_id) + .set(ValueClass::Property(ORPHAN_FIELD), marker.clone()) + .build_all(), + ) + .await + .unwrap(); + } + + db.write( + BatchBuilder::new() + .set( + ValueClass::Any(store::write::AnyClass { + subspace: store::SUBSPACE_PROPERTY, + key: vec![0u8], + }), + vec![1u8], + ) + .build_all(), + ) + .await + .unwrap(); + + for document_id in [0u32, 1, 0xFFFF, 0x10000] { + let key = ValueKey { + account_id: document_id, + collection: 0, + document_id, + class: ValueClass::Property(ORPHAN_FIELD), + }; + let mut found = Vec::new(); + db.iterate( + store::IterateParams::new(key.clone(), key.clone()), + |_, value| { + found.push(value.to_vec()); + Ok(true) + }, + ) + .await + .unwrap(); + + assert_eq!( + found, + vec![marker.clone()], + "property key for account {document_id} was cleared by a shorter key" + ); + + db.write( + BatchBuilder::new() + .with_account_id(document_id) + .with_collection(Collection::Email) + .with_document(document_id) + .clear(ValueClass::Property(ORPHAN_FIELD)) + .build_all(), + ) + .await + .unwrap(); + } + + db.write( + BatchBuilder::new() + .clear(ValueClass::Any(store::write::AnyClass { + subspace: store::SUBSPACE_PROPERTY, + key: vec![0u8], + })) + .build_all(), + ) + .await + .unwrap(); + + db.delete_range(orphan_range(0), orphan_range(u32::MAX)) + .await + .unwrap(); + + if std::env::var("SLOW_FDB_TRX").is_ok() { + println!("Running FoundationDB slow transaction tests..."); + // Create 900000 keys + let mut batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0); + for n in 0..900000 { + batch.set( + ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: n, + }), + format!("value{n:10}").into_bytes(), + ); + + if n % 10000 == 0 { + db.write(batch.build_all()).await.unwrap(); + batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0); + } + } + db.write(batch.build_all()).await.unwrap(); + + println!("Created 900.000 keys..."); + + // Iterate over all keys + let mut n = 0; + db.iterate( + store::IterateParams::new( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: 0, + }), + }, + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: u64::MAX, + }), + }, + ), + |key, value| { + assert_eq!(std::str::from_utf8(key).unwrap(), format!("key{n:10}")); + assert_eq!(std::str::from_utf8(value).unwrap(), format!("value{n:10}")); + n += 1; + if n % 10000 == 0 { + println!("Iterated over {n} keys"); + std::thread::sleep(std::time::Duration::from_millis(1000)); + } + Ok(true) + }, + ) + .await + .unwrap(); + + // Delete 100 keys + let mut batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0); + for n in 0..900000 { + batch.clear(ValueClass::Registry(RegistryClass::Item { + object_id: 0, + item_id: n, + })); + + if n % 10000 == 0 { + db.write(batch.build_all()).await.unwrap(); + batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0); + } + } + db.write(batch.build_all()).await.unwrap(); + } + } + + // Merge values 1000 times concurrently + let mut handles = Vec::new(); + println!("Merge values 1000 times concurrently..."); + for _ in 0..1000 { + handles.push({ + let db = db.clone(); + tokio::spawn(async move { + for _ in 0..5 { + let mut builder = BatchBuilder::new(); + builder + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .merge_fnc( + ValueClass::Property(3), + Params::with_capacity(0), + |_, _, bytes| { + if let Some(bytes) = bytes { + Ok(MergeResult::Update( + (u64::from_be_bytes(bytes.try_into().unwrap()) + 1) + .to_be_bytes() + .to_vec(), + )) + } else { + Ok(MergeResult::Update(0u64.to_be_bytes().to_vec())) + } + }, + ); + + match db.write(builder.build_all()).await { + Ok(_) => { + break; + } + Err(e) if e.is_assertion_failure() => { + // Retry on assertion failures + continue; + } + Err(e) => { + panic!("Merge failed: {:?}", e); + } + } + } + }) + }); + } + + for handle in handles { + handle.await.unwrap(); + } + + assert_eq!( + 999, + db.get_value::(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Property(3), + }) + .await + .unwrap() + .unwrap() + ); + + // Increment a counter 1000 times concurrently + let mut handles = Vec::new(); + let mut assigned_ids = HashSet::new(); + println!("Incrementing counter 1000 times concurrently..."); + for _ in 0..1000 { + handles.push({ + let db = db.clone(); + tokio::spawn(async move { + let mut builder = BatchBuilder::new(); + builder + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .add_and_get(ValueClass::Quota, 1); + db.write(builder.build_all()) + .await + .unwrap() + .last_counter_id() + .unwrap() + }) + }); + } + + for handle in handles { + let assigned_id = handle.await.unwrap(); + assert!( + assigned_ids.insert(assigned_id), + "counter assigned {assigned_id} twice or more times." + ); + } + assert_eq!(assigned_ids.len(), 1000); + assert_eq!( + db.get_counter(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Quota, + }) + .await + .unwrap(), + 1000 + ); + + // Concurrent changelog + let mut handles = Vec::new(); + let mut assigned_ids = AHashSet::new(); + print!("Incrementing changeId 1000 times concurrently..."); + let time = std::time::Instant::now(); + for document_id in 0..1000 { + handles.push({ + let db = db.clone(); + tokio::spawn(async move { + let mut builder = BatchBuilder::new(); + let value = if document_id != 0 { + (0..rand::rng().random_range(1..=100)) + .map(|_| rand::rng().random_range(0..=255)) + .collect::>() + } else { + vec![0u8; 100000] + }; + + let (offset, archived_value) = Archiver::new(value).serialize_versioned().unwrap(); + + builder + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(document_id) + .set_fnc( + ValueClass::Property(5), + Params::with_capacity(2) + .with_bytes(archived_value) + .with_u64(offset), + |params, ids| { + let change_id = ids.current_change_id()?; + let archive = params.bytes(0); + let offset = params.u64(1); + + let mut bytes = Vec::with_capacity(archive.len()); + bytes.extend_from_slice(&archive[..offset as usize]); + bytes.extend_from_slice(&change_id.to_be_bytes()[..]); + bytes.push(archive.last().copied().unwrap()); // Marker + Ok(bytes) + }, + ) + .log_container_insert(SyncCollection::Email); + db.write(builder.build_all()) + .await + .unwrap() + .last_change_id(0) + .unwrap() + }) + }); + } + for handle in handles { + let assigned_id = handle.await.unwrap(); + assert!( + assigned_ids.insert(assigned_id), + "counter assigned {assigned_id} twice or more times: {:?}.", + assigned_ids + ); + } + assert_eq!(assigned_ids.len(), 1000); + println!(" done in {:?}ms", time.elapsed().as_millis()); + let mut change_ids = AHashSet::new(); + for document_id in 0..1000 { + let archive = db + .get_value::>(ValueKey { + account_id: 0, + collection: 0, + document_id, + class: ValueClass::Property(5), + }) + .await + .unwrap() + .unwrap(); + change_ids.insert(archive.version.change_id().unwrap()); + archive.unarchive_untrusted::>().unwrap(); + } + assert_eq!(change_ids, assigned_ids); + + println!("Running chunking tests..."); + for (test_num, value) in [ + vec![b'A'; 0], + vec![b'A'; 1], + vec![b'A'; 100], + vec![b'A'; MAX_VALUE_SIZE], + vec![b'B'; MAX_VALUE_SIZE + 1], + vec![b'C'; MAX_VALUE_SIZE] + .into_iter() + .chain(vec![b'D'; MAX_VALUE_SIZE]) + .chain(vec![b'E'; MAX_VALUE_SIZE]) + .collect::>(), + vec![b'F'; MAX_VALUE_SIZE] + .into_iter() + .chain(vec![b'G'; MAX_VALUE_SIZE]) + .chain(vec![b'H'; MAX_VALUE_SIZE + 1]) + .collect::>(), + ] + .into_iter() + .enumerate() + { + // Write value + let test_len = value.len(); + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .set(ValueClass::Property(1), value.as_slice()) + .set(ValueClass::Property(0), "check1".as_bytes()) + .set(ValueClass::Property(2), "check2".as_bytes()) + .build_all(), + ) + .await + .unwrap(); + + // Fetch value + assert_eq!( + String::from_utf8(value).unwrap(), + db.get_value::(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Property(1), + }) + .await + .unwrap() + .unwrap_or_else(|| panic!("no value for test {test_num} with value length {test_len}")), + "failed for test {test_num} with value length {test_len}" + ); + + // Delete value + db.write( + BatchBuilder::new() + .with_account_id(0) + .with_collection(Collection::Email) + .with_document(0) + .clear(ValueClass::Property(1)) + .build_all(), + ) + .await + .unwrap(); + + // Make sure value is deleted + assert_eq!( + None, + db.get_value::(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Property(1), + }) + .await + .unwrap() + ); + + // Make sure other values are still there + for (class, value) in [ + (ValueClass::Property(0), "check1"), + (ValueClass::Property(2), "check2"), + ] { + assert_eq!( + Some(value.to_string()), + db.get_value::(ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class, + }) + .await + .unwrap() + ); + } + + // Delete everything + let mut batch = BatchBuilder::new(); + batch + .with_account_id(0) + .with_collection(Collection::Email) + .with_account_id(0) + .with_document(0) + .clear(ValueClass::Property(0)) + .clear(ValueClass::Property(2)) + .clear(ValueClass::Property(3)) + .clear(ValueClass::Quota) + .clear(ValueClass::ChangeId); + + for document_id in 0..1000 { + batch + .with_document(document_id) + .clear(ValueClass::Property(5)); + } + + db.write(batch.build_all()).await.unwrap(); + + // Make sure everything is deleted + store_assert_is_empty(&db, db.clone().into(), false).await; + } +} diff --git a/tests/src/store/query.rs b/tests/src/store/query.rs new file mode 100644 index 0000000..1115fb1 --- /dev/null +++ b/tests/src/store/query.rs @@ -0,0 +1,811 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{store::deflate_test_resource, utils::server::TestServer}; +use ahash::AHashSet; +use nlp::language::Language; +use std::{ + io::Write, + sync::{Arc, Mutex}, + time::Instant, +}; +use store::{ + SearchStore, + ahash::AHashMap, + rand::{self, RngExt, distr::Alphanumeric}, + roaring::RoaringBitmap, + search::{ + EmailSearchField, IndexDocument, SearchComparator, SearchField, SearchFilter, + SearchOperator, SearchQuery, SearchValue, TracingSearchField, + }, + write::SearchIndex, +}; +use utils::map::vec_map::VecMap; + +pub const FIELDS: [&str; 20] = [ + "id", + "accession_number", + "artist", + "artistRole", + "artistId", + "title", + "dateText", + "medium", + "creditLine", + "year", + "acquisitionYear", + "dimensions", + "width", + "height", + "depth", + "units", + "inscription", + "thumbnailCopyright", + "thumbnailUrl", + "url", +]; + +/* + "title", // Subject + "year". // ReceivedAt + "width", // Size + "height", // SentAt + "artist" // Headers + "artistRole" // Cc + "medium", // From + "creditLine" // Body + "acquisitionYear" // Bcc + "accession_number" // To +*/ + +const FIELD_MAPPINGS: [EmailSearchField; 20] = [ + EmailSearchField::HasAttachment, // "id", + EmailSearchField::To, // "accession_number", + EmailSearchField::Headers, // "artist", + EmailSearchField::Cc, // "artistRole", + EmailSearchField::HasAttachment, // "artistId", + EmailSearchField::Subject, // "title", + EmailSearchField::HasAttachment, // "dateText", + EmailSearchField::From, // "medium", + EmailSearchField::Body, // "creditLine", + EmailSearchField::ReceivedAt, // "year", + EmailSearchField::Bcc, // "acquisitionYear", + EmailSearchField::HasAttachment, // "dimensions", + EmailSearchField::Size, // "width", + EmailSearchField::SentAt, // "height", + EmailSearchField::HasAttachment, // "depth", + EmailSearchField::HasAttachment, // "units", + EmailSearchField::HasAttachment, // "inscription", + EmailSearchField::HasAttachment, // "thumbnailCopyright", + EmailSearchField::HasAttachment, // "thumbnailUrl", + EmailSearchField::HasAttachment, // "url", +]; + +const ALL_IDS: &[&str] = &[ + "p11293", "p79426", "p79427", "p79428", "p79429", "p79430", "d05503", "d00399", "d05352", + "p01764", "t05843", "n02478", "n02479", "n03568", "n03658", "n04327", "n04328", "n04721", + "n04739", "n05095", "n05096", "n05145", "n05157", "n05158", "n05159", "n05298", "n05303", + "n06070", "t01181", "t03571", "t05805", "t05806", "t12147", "t12154", "t12155", "ar00039", + "t12600", "p80203", "t13209", "t13560", "t13561", "t13655", "t13811", "p13352", "p13351", + "p13350", "p13349", "p13348", "p13347", "p13346", "p13345", "p13344", "p13342", "p13341", + "p13340", "p13339", "p13338", "p13337", "p13336", "p13335", "p13334", "p13333", "p13332", + "p13331", "p13330", "p13329", "p13328", "p13327", "p13326", "p13325", "p13324", "p13323", + "t13786", "p13322", "p13321", "p13320", "p13319", "p13318", "p13317", "p13316", "p13315", + "p13314", "t13588", "t13587", "t13586", "t13585", "t13584", "t13540", "t13444", "ar01154", + "ar01153", "t03681", "t12601", "ar00166", "t12625", "t12915", "p04182", "t06483", "ar00703", + "t07671", "ar00021", "t05557", "t07918", "p06298", "p05465", "p06640", "t12855", "t01355", + "t12800", "t12557", "t02078", "ar00052", "ar00627", "t00352", "t07275", "t12318", "t04931", + "t13683", "t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t13769", "t13773", + "t07151", "t13684", "t07523", "t12369", "t12567", "ar00627", "ar00052", "t00352", "t07275", + "t12318", "t04931", "t13683", "t13686", "t13687", "t13688", "t13689", "t13690", "t13691", + "t07766", "t07918", "t12993", "ar00044", "t13326", "t07614", "t12414", +]; + +#[allow(clippy::mutex_atomic)] +pub async fn test(test: &TestServer) { + let store = test.server.search_store().clone(); + println!("Running Store query tests..."); + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(8) + .build() + .unwrap(); + let now = Instant::now(); + let documents = Arc::new(Mutex::new(Vec::new())); + let mut mask = RoaringBitmap::new(); + let mut fields = AHashMap::new(); + + // Global ids test + println!("Running global id filtering tests..."); + test_global(store.clone()).await; + + // Large document insert test + println!("Running large document insert tests..."); + let mut large_text = String::with_capacity(20 * 1024 * 1024); + while large_text.len() < 20 * 1024 * 1024 { + let word = rand::rng() + .sample_iter(&Alphanumeric) + .take(rand::rng().random_range(3..10)) + .map(char::from) + .collect::(); + large_text.push_str(&word); + large_text.push(' '); + } + let mut document = IndexDocument::new(SearchIndex::Email) + .with_account_id(1) + .with_document_id(1); + for field in [ + EmailSearchField::From, + EmailSearchField::To, + EmailSearchField::Cc, + EmailSearchField::Bcc, + EmailSearchField::Subject, + ] { + document.index_text(field, &large_text[..10 * 1024], Language::English); + } + for field in [EmailSearchField::Body, EmailSearchField::Attachment] { + document.index_text(field, &large_text, Language::English); + } + for field in [ + EmailSearchField::ReceivedAt, + EmailSearchField::SentAt, + EmailSearchField::Size, + ] { + document.index_unsigned(field, rand::rng().random_range(100u64..1_000_000u64)); + } + store.index(vec![document]).await.unwrap(); + // Refresh + if let SearchStore::ElasticSearch(store) = &store { + store.refresh_index(SearchIndex::Email).await.unwrap(); + } + + println!("Running account filtering tests..."); + let filter_ids = std::env::var("QUICK_TEST").is_ok().then(|| { + let mut ids = AHashSet::new(); + for &id in ALL_IDS { + ids.insert(id.to_string()); + let id = id.as_bytes(); + if id.last().unwrap() > &b'0' { + let mut alt_id = id.to_vec(); + *alt_id.last_mut().unwrap() -= 1; + ids.insert(String::from_utf8(alt_id).unwrap()); + } + if id.last().unwrap() < &b'9' { + let mut alt_id = id.to_vec(); + *alt_id.last_mut().unwrap() += 1; + ids.insert(String::from_utf8(alt_id).unwrap()); + } + } + + ids + }); + + pool.scope_fifo(|s| { + for (document_id, record) in csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(&deflate_test_resource("artwork_data.csv.gz")[..]) + .records() + .enumerate() + { + let record = record.unwrap(); + let documents = documents.clone(); + + if let Some(filter_ids) = &filter_ids { + let id = record.get(1).unwrap().to_lowercase(); + if !filter_ids.contains(&id) { + continue; + } + } + + s.spawn_fifo(move |_| { + let mut document = IndexDocument::new(SearchIndex::Email) + .with_account_id(0) + .with_document_id(document_id as u32); + for (pos, field) in record.iter().enumerate() { + match FIELD_MAPPINGS[pos] { + EmailSearchField::From + | EmailSearchField::To + | EmailSearchField::Cc + | EmailSearchField::Bcc => { + document.index_text( + FIELD_MAPPINGS[pos].clone(), + &field.to_lowercase(), + Language::None, + ); + } + EmailSearchField::Subject + | EmailSearchField::Body + | EmailSearchField::Attachment => { + document.index_text( + FIELD_MAPPINGS[pos].clone(), + &field + .replace(|ch: char| !ch.is_alphanumeric(), " ") + .to_lowercase(), + Language::English, + ); + } + EmailSearchField::Headers => { + document.insert_key_value( + EmailSearchField::Headers, + "artist", + field.to_lowercase(), + ); + } + EmailSearchField::ReceivedAt + | EmailSearchField::SentAt + | EmailSearchField::Size => { + document.index_unsigned( + FIELD_MAPPINGS[pos].clone(), + field.parse::().unwrap_or(0), + ); + } + _ => { + continue; + } + }; + } + + documents.lock().unwrap().push(document); + }); + } + }); + + println!( + "Parsed {} entries in {} ms.", + documents.lock().unwrap().len(), + now.elapsed().as_millis() + ); + + let now = Instant::now(); + let batches = documents.lock().unwrap().drain(..).collect::>(); + + print!("Inserting... ",); + let mut chunks = Vec::new(); + let mut chunk = Vec::new(); + for document in batches { + let mut document_id = None; + let mut to_field = None; + + for (key, value) in document.fields() { + if key == &SearchField::DocumentId { + if let SearchValue::Uint(id) = value { + document_id = Some(*id as u32); + } + } else if key == &SearchField::Email(EmailSearchField::To) + && let SearchValue::Text { value, .. } = value + { + to_field = Some(value.to_string()); + } + } + let document_id = document_id.unwrap(); + let to_field = to_field.unwrap(); + mask.insert(document_id); + fields.insert(document_id, to_field); + + chunk.push(document); + if chunk.len() == 10 { + chunks.push(chunk); + chunk = Vec::new(); + } + } + if !chunk.is_empty() { + chunks.push(chunk); + } + + if test.is_reset() { + let mut tasks = Vec::new(); + for chunk in chunks { + let chunk_instance = Instant::now(); + tasks.push({ + let db = store.clone(); + tokio::spawn(async move { db.index(chunk).await }) + }); + + if tasks.len() == 100 { + for handle in tasks { + handle.await.unwrap().unwrap(); + } + print!(" [{} ms]", chunk_instance.elapsed().as_millis()); + std::io::stdout().flush().unwrap(); + tasks = Vec::new(); + } + } + + if !tasks.is_empty() { + for handle in tasks { + handle.await.unwrap().unwrap(); + } + } + + // Refresh + if let SearchStore::ElasticSearch(store) = &store { + store.refresh_index(SearchIndex::Email).await.unwrap(); + } + + println!("\nInsert took {} ms.", now.elapsed().as_millis()); + } + + if store.internal_fts().is_none() { + let ids = store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(vec![SearchFilter::eq(SearchField::AccountId, 0u32)]) + .with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt)) + .with_mask(mask.clone()), + ) + .await + .unwrap() + .into_iter() + .collect::(); + assert_eq!(ids, mask); + let ids = store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::ge(SearchField::DocumentId, 0u32), + ]) + .with_mask(mask.clone()), + ) + .await + .unwrap() + .into_iter() + .collect::(); + assert_eq!(ids, mask); + } + + println!("Running account filter tests..."); + let now = Instant::now(); + test_filter(store.clone(), &fields, &mask).await; + println!("Filtering took {} ms.", now.elapsed().as_millis()); + + println!("Running account sort tests..."); + let now = Instant::now(); + test_sort(store.clone(), &fields, &mask).await; + println!("Sorting took {} ms.", now.elapsed().as_millis()); + + println!("Running unindex tests..."); + let now = Instant::now(); + test_unindex(store.clone(), &fields).await; + println!("Unindexing took {} ms.", now.elapsed().as_millis()); +} + +async fn test_filter(store: SearchStore, fields: &AHashMap, mask: &RoaringBitmap) { + let can_stem = !store.is_mysql(); + let can_negate_text = !store.is_meilisearch(); + + let tests = [ + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::has_english_text(EmailSearchField::Subject, "water"), + SearchFilter::eq(EmailSearchField::ReceivedAt, 1979u32), + ], + vec!["p11293"], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::has_keyword(EmailSearchField::From, "gelatin"), + SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32), + SearchFilter::lt(EmailSearchField::Size, 180u32), + SearchFilter::gt(EmailSearchField::Size, 0u32), + ], + vec!["p79426", "p79427", "p79428", "p79429", "p79430"], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::has_english_text(EmailSearchField::Subject, "'rustic bridge'"), + ], + vec!["d05503"], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::has_english_text(EmailSearchField::Subject, "'rustic'"), + SearchFilter::has_english_text( + EmailSearchField::Subject, + if can_stem { "study" } else { "studies" }, + ), + ], + vec!["d00399", "d05352"], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::cond( + EmailSearchField::Headers, + SearchOperator::Contains, + SearchValue::KeyValues(VecMap::from_iter([( + "artist".to_string(), + "kunst, mauro".to_string(), + )])), + ), + SearchFilter::has_keyword(EmailSearchField::Cc, "artist"), + SearchFilter::Or, + SearchFilter::eq(EmailSearchField::ReceivedAt, 1969u32), + SearchFilter::eq(EmailSearchField::ReceivedAt, 1971u32), + SearchFilter::End, + ], + vec!["p01764", "t05843"], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::Not, + SearchFilter::has_keyword(EmailSearchField::From, "oil"), + SearchFilter::End, + SearchFilter::has_english_text( + EmailSearchField::Body, + if can_stem { "bequeath" } else { "bequeathed" }, + ), + SearchFilter::Or, + SearchFilter::And, + SearchFilter::ge(EmailSearchField::ReceivedAt, 1900u32), + SearchFilter::lt(EmailSearchField::ReceivedAt, 1910u32), + SearchFilter::End, + SearchFilter::And, + SearchFilter::ge(EmailSearchField::ReceivedAt, 2000u32), + SearchFilter::lt(EmailSearchField::ReceivedAt, 2010u32), + SearchFilter::End, + SearchFilter::End, + ], + vec![ + "n02478", "n02479", "n03568", "n03658", "n04327", "n04328", "n04721", "n04739", + "n05095", "n05096", "n05145", "n05157", "n05158", "n05159", "n05298", "n05303", + "n06070", "t01181", "t03571", "t05805", "t05806", "t12147", "t12154", "t12155", + ], + ), + ( + vec![ + SearchFilter::And, + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::cond( + EmailSearchField::Headers, + SearchOperator::Contains, + SearchValue::KeyValues(VecMap::from_iter([( + "artist".to_string(), + "warhol".to_string(), + )])), + ), + SearchFilter::Not, + SearchFilter::has_english_text(EmailSearchField::Subject, "'campbell'"), + SearchFilter::End, + SearchFilter::Not, + SearchFilter::Or, + SearchFilter::gt(EmailSearchField::ReceivedAt, 1980u32), + SearchFilter::And, + SearchFilter::gt(EmailSearchField::Size, 500u32), + SearchFilter::gt(EmailSearchField::SentAt, 500u32), + SearchFilter::End, + SearchFilter::End, + SearchFilter::End, + SearchFilter::eq(EmailSearchField::Bcc, "2008".to_string()), + SearchFilter::End, + ], + vec!["ar00039", "t12600"], + ), + ( + if can_stem { + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::has_english_text(EmailSearchField::Subject, "study"), + SearchFilter::has_keyword(EmailSearchField::From, "paper"), + SearchFilter::has_english_text(EmailSearchField::Body, "'purchased'"), + SearchFilter::Not, + SearchFilter::Or, + SearchFilter::has_english_text(EmailSearchField::Subject, "'anatomical'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'discarded'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'untitled'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'girl'"), + SearchFilter::End, + SearchFilter::End, + SearchFilter::gt(EmailSearchField::ReceivedAt, 1900u32), + SearchFilter::gt(EmailSearchField::Bcc, "2008".to_string()), + ] + } else { + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::Or, + SearchFilter::has_english_text(EmailSearchField::Subject, "study"), + SearchFilter::has_english_text(EmailSearchField::Subject, "studies"), + SearchFilter::End, + SearchFilter::has_keyword(EmailSearchField::From, "paper"), + SearchFilter::has_english_text(EmailSearchField::Body, "'purchased'"), + SearchFilter::Not, + SearchFilter::Or, + SearchFilter::has_english_text(EmailSearchField::Subject, "'anatomical'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'discarded'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'untitled'"), + SearchFilter::has_english_text(EmailSearchField::Subject, "'girl'"), + SearchFilter::End, + SearchFilter::End, + SearchFilter::gt(EmailSearchField::ReceivedAt, 1900u32), + SearchFilter::gt(EmailSearchField::Bcc, "2008".to_string()), + ] + }, + vec!["p80203", "t13209", "t13560", "t13561"], + ), + ]; + + for (filters, expected_results) in tests { + if !can_negate_text && has_negated_text(&filters) { + continue; + } + + //println!("Running test: {:?}", filter); + let ids = store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(filters) + .with_comparator(SearchComparator::ascending(EmailSearchField::To)) + .with_mask(mask.clone()), + ) + .await + .unwrap(); + + let mut results = Vec::new(); + for document_id in ids { + results.push(fields.get(&document_id).unwrap()); + } + assert_eq!(results, expected_results); + } +} + +fn has_negated_text(filters: &[SearchFilter]) -> bool { + let mut stack = Vec::new(); + let mut negated = 0; + + for filter in filters { + match filter { + SearchFilter::Not => { + stack.push(true); + negated += 1; + } + SearchFilter::And | SearchFilter::Or => { + stack.push(false); + } + SearchFilter::End => { + if stack.pop().unwrap_or(false) { + negated -= 1; + } + } + SearchFilter::Operator { + op: SearchOperator::Equal | SearchOperator::Contains, + value: SearchValue::Text { .. }, + .. + } if negated > 0 => { + return true; + } + _ => (), + } + } + + false +} + +async fn test_sort(store: SearchStore, fields: &AHashMap, mask: &RoaringBitmap) { + let is_reversed = store.is_postgres(); + + let tests = [ + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::gt(EmailSearchField::ReceivedAt, 0u32), + SearchFilter::gt(EmailSearchField::Bcc, "0000".to_string()), + SearchFilter::gt(EmailSearchField::Size, 0u32), + ], + vec![ + SearchComparator::descending(EmailSearchField::ReceivedAt), + SearchComparator::ascending(EmailSearchField::Bcc), + SearchComparator::ascending(EmailSearchField::Size), + SearchComparator::descending(EmailSearchField::To), + ], + vec![ + "t13655", "t13811", "p13352", "p13351", "p13350", "p13349", "p13348", "p13347", + "p13346", "p13345", "p13344", "p13342", "p13341", "p13340", "p13339", "p13338", + "p13337", "p13336", "p13335", "p13334", "p13333", "p13332", "p13331", "p13330", + "p13329", "p13328", "p13327", "p13326", "p13325", "p13324", "p13323", "t13786", + "p13322", "p13321", "p13320", "p13319", "p13318", "p13317", "p13316", "p13315", + "p13314", "t13588", "t13587", "t13586", "t13585", "t13584", "t13540", "t13444", + "ar01154", "ar01153", + ], + ), + ( + vec![ + SearchFilter::eq(SearchField::AccountId, 0u32), + SearchFilter::gt(EmailSearchField::Size, 0u32), + SearchFilter::gt(EmailSearchField::SentAt, 0u32), + ], + vec![ + SearchComparator::descending(EmailSearchField::Size), + SearchComparator::ascending(EmailSearchField::SentAt), + ], + vec![ + "t03681", "t12601", "ar00166", "t12625", "t12915", "p04182", "t06483", "ar00703", + "t07671", "ar00021", "t05557", "t07918", "p06298", "p05465", "p06640", "t12855", + "t01355", "t12800", "t12557", "t02078", + ], + ), + ( + vec![SearchFilter::eq(SearchField::AccountId, 0u32)], + vec![ + SearchComparator::descending(EmailSearchField::From), + SearchComparator::descending(EmailSearchField::Cc), + SearchComparator::ascending(EmailSearchField::To), + ], + if is_reversed { + vec![ + "ar00052", "ar00627", "t00352", "t07275", "t12318", "t04931", "t13683", + "t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t13769", "t13773", + "t07151", "t13684", "t07523", "t12369", "t12567", + ] + } else { + vec![ + "ar00627", "ar00052", "t00352", "t07275", "t12318", "t04931", "t13683", + "t13686", "t13687", "t13688", "t13689", "t13690", "t13691", "t07766", "t07918", + "t12993", "ar00044", "t13326", "t07614", "t12414", + ] + }, + ), + ]; + + for (filters, comparators, expected_results) in tests { + //println!("Running test: {:?}", sort); + let ids = store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(filters) + .with_comparators(comparators) + .with_mask(mask.clone()), + ) + .await + .unwrap(); + + let mut results = Vec::new(); + for document_id in ids.into_iter().take(expected_results.len()) { + results.push(fields.get(&document_id).unwrap()); + } + assert_eq!(results, expected_results); + } +} + +async fn test_unindex(store: SearchStore, fields: &AHashMap) { + let ids = store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_mask(RoaringBitmap::from_iter(fields.keys().copied())) + .with_filters(vec![ + SearchFilter::has_keyword(EmailSearchField::From, "gelatin"), + SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32), + SearchFilter::lt(EmailSearchField::Size, 180u32), + SearchFilter::gt(EmailSearchField::Size, 0u32), + ]) + .with_account_id(0), + ) + .await + .unwrap(); + assert!(!ids.is_empty()); + let expected_count = ids.len().saturating_sub(10); + + let mut query = SearchQuery::new(SearchIndex::Email) + .with_account_id(0) + .with_filter(SearchFilter::Or); + for id in ids.into_iter().take(10) { + query = query.with_filter(SearchFilter::eq(SearchField::DocumentId, id)); + } + query = query.with_filter(SearchFilter::End); + + store.unindex(query).await.unwrap(); + + // Refresh + if let SearchStore::ElasticSearch(store) = &store { + store.refresh_index(SearchIndex::Email).await.unwrap(); + } + + assert_eq!( + store + .query_account( + SearchQuery::new(SearchIndex::Email) + .with_filters(vec![ + SearchFilter::has_keyword(EmailSearchField::From, "gelatin"), + SearchFilter::gt(EmailSearchField::ReceivedAt, 2000u32), + SearchFilter::lt(EmailSearchField::Size, 180u32), + SearchFilter::gt(EmailSearchField::Size, 0u32), + ]) + .with_account_id(0) + .with_mask(RoaringBitmap::from_iter(fields.keys().copied())), + ) + .await + .unwrap() + .len(), + expected_count + ); +} + +async fn test_global(store: SearchStore) { + // Insert global ids + for (id, queue_id, etyp, keywords) in [ + (0, 1000u64, 1u64, "init start"), + (1, 1000u64, 2u64, "init complete"), + (2, 1001u64, 1u64, "process start"), + (3, 1001u64, 2u64, "process complete"), + (4, 1002u64, 1u64, "cleanup start"), + (5, 1002u64, 2u64, "cleanup complete"), + ] { + let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(id); + document.index_unsigned(TracingSearchField::QueueId, queue_id); + document.index_unsigned(TracingSearchField::EventType, etyp); + document.index_text(TracingSearchField::Keywords, keywords, Language::None); + store.index(vec![document]).await.unwrap(); + } + + // Refresh + if let SearchStore::ElasticSearch(store) = &store { + store.refresh_index(SearchIndex::Tracing).await.unwrap(); + } + + // Query all + assert_eq!( + store + .query_global( + SearchQuery::new(SearchIndex::Tracing) + .with_filter(SearchFilter::ge(SearchField::Id, 0u64)) + ) + .await + .unwrap() + .into_iter() + .collect::>(), + AHashSet::from_iter([0, 1, 2, 3, 4, 5]) + ); + + // Query with filter + assert_eq!( + store + .query_global( + SearchQuery::new(SearchIndex::Tracing) + .with_filter(SearchFilter::gt(SearchField::Id, 1u64)) + .with_filter(SearchFilter::lt(SearchField::Id, 5u64)) + .with_filter(SearchFilter::has_keyword( + TracingSearchField::Keywords, + "start", + )), + ) + .await + .unwrap() + .into_iter() + .collect::>(), + AHashSet::from_iter([2, 4]) + ); + + // Delete by filter + store + .unindex( + SearchQuery::new(SearchIndex::Tracing) + .with_filter(SearchFilter::lt(SearchField::Id, 3u64)), + ) + .await + .unwrap(); + + // Refresh + if let SearchStore::ElasticSearch(store) = &store { + store.refresh_index(SearchIndex::Tracing).await.unwrap(); + } + + assert_eq!( + store + .query_global( + SearchQuery::new(SearchIndex::Tracing) + .with_filter(SearchFilter::ge(SearchField::Id, 0u64)) + ) + .await + .unwrap() + .into_iter() + .collect::>(), + AHashSet::from_iter([3, 4, 5]) + ); +} diff --git a/tests/src/store/registry.rs b/tests/src/store/registry.rs new file mode 100644 index 0000000..fddc527 --- /dev/null +++ b/tests/src/store/registry.rs @@ -0,0 +1,804 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{registry::UnwrapRegistryId, server::TestServer}; +use jmap_tools::JsonPointer; +use registry::{ + jmap::{IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch}, + pickle::{Pickle, PickledStream}, + schema::{ + enums::{AccountType, Locale, Permission, StorageQuota}, + prelude::{Object, ObjectType, Property}, + structs::{ + Account, CertificateManagement, Credential, CredentialPermissions, + CredentialPermissionsList, CustomRoles, DkimManagement, DnsManagement, Domain, + EmailAlias, EncryptionAtRest, EncryptionSettings, GroupAccount, MailingList, + PasswordCredential, Permissions, PermissionsList, PublicKey, SecondaryCredential, + SieveUserScript, UserAccount, UserRoles, + }, + }, + types::{ + EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId, ipmask::IpAddrOrMask, + list::List, map::Map, + }, +}; +use std::str::FromStr; +use store::{ + registry::{ + RegistryQuery, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::now, +}; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub async fn test(test: &TestServer) { + let r = test.server.registry(); + + println!("Registry tests..."); + + test_patch_regressions(); + + // Pickle-unpickle test + let mut account = Account::User(UserAccount { + aliases: List::from_iter([ + EmailAlias { + description: "Test Alias 1".to_string().into(), + domain_id: 1000u64.into(), + enabled: true, + name: "alias1".into(), + }, + EmailAlias { + description: "Test Alias 2".to_string().into(), + domain_id: 1001u64.into(), + enabled: true, + name: "alias2".into(), + }, + ]), + created_at: UTCDateTime::now(), + credentials: List::from_iter([ + Credential::Password(PasswordCredential { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.1").unwrap()]), + credential_id: 3u64.into(), + expires_at: None, + otp_auth: "otpauth://totp/test?secret=SECRET".to_string().into(), + secret: "secret".into(), + }), + Credential::AppPassword(SecondaryCredential { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("192.168.1.0/24").unwrap()]), + created_at: UTCDateTime::now(), + credential_id: 4u64.into(), + description: "App Password".into(), + expires_at: Some(UTCDateTime::from_timestamp((now() + 1000) as i64)), + permissions: CredentialPermissions::Disable(CredentialPermissionsList { + permissions: Map::new(vec![ + Permission::Authenticate, + Permission::ActionClassifySpam, + ]), + }), + secret: "app_password_secret".into(), + }), + ]), + description: "This is a test Account".to_string().into(), + domain_id: 1004u64.into(), + encryption_at_rest: EncryptionAtRest::Aes128(EncryptionSettings { + allow_spam_training: true, + encrypt_on_append: false, + public_key: 0u64.into(), + }), + external_id: "8f7c1e2a-4b3d-4f1a-9c2e-7d5b6a8f0e11".to_string().into(), + locale: Locale::EnUS, + member_group_ids: Map::new(vec![2000u64.into(), 2001u64.into()]), + member_tenant_id: None, + name: "user".into(), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Map::new(vec![Permission::Impersonate]), + enabled_permissions: Map::new(vec![Permission::JmapBlobGet]), + }), + quotas: VecMap::from_iter([ + (StorageQuota::MaxDiskQuota, 1024u64), + (StorageQuota::MaxApiKeys, 3u64), + ]), + roles: UserRoles::Custom(CustomRoles { + role_ids: Map::new(vec![5000u64.into()]), + }), + time_zone: None, + }); + let account_pickle = account.to_pickled_vec(); + assert_eq!( + account, + Account::unpickle(&mut PickledStream::new(&account_pickle).unwrap()).unwrap() + ); + + // Pickle compression test + let script = SieveUserScript { + contents: "A".repeat(100_000), + description: "B".repeat(100_000).into(), + is_active: true, + name: "C".repeat(100_000), + }; + let script_pickle = script.to_pickled_vec(); + assert!( + script_pickle.len() < 8_192, + "Pickle was not compressed: {} bytes", + script_pickle.len() + ); + assert_eq!( + script, + SieveUserScript::unpickle(&mut PickledStream::new(&script_pickle).unwrap()).unwrap() + ); + + // Create a domain and a group + let domain_id = r + .write(RegistryWrite::insert( + &Domain { + name: "test.org".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + is_enabled: true, + ..Default::default() + } + .into(), + )) + .await + .unwrap() + .unwrap_id(trc::location!()); + let domain_id_2 = r + .write(RegistryWrite::insert( + &Domain { + name: "test.net".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + is_enabled: true, + ..Default::default() + } + .into(), + )) + .await + .unwrap() + .unwrap_id(trc::location!()); + let group_id = r + .write(RegistryWrite::insert( + &Account::Group(GroupAccount { + name: "group".into(), + domain_id, + ..Default::default() + }) + .into(), + )) + .await + .unwrap() + .unwrap_id(trc::location!()); + + // Inserting an account linking non-existing ids should fail + test.assert_registry_insert_error( + account.clone(), + RegistryWriteResult::InvalidForeignKey { + object_id: ObjectId::new(ObjectType::Account, Id::new(2000)), + }, + trc::location!(), + ) + .await; + account.assert_patch( + &format!("memberGroupIds/{}", Id::new(2000)), + false, + trc::location!(), + ); + account.assert_patch( + &format!("memberGroupIds/{}", Id::new(2001)), + false, + trc::location!(), + ); + account.assert_patch( + &format!("memberGroupIds/{}", group_id), + true, + trc::location!(), + ); + + test.assert_registry_insert_error( + account.clone(), + RegistryWriteResult::InvalidForeignKey { + object_id: ObjectId::new(ObjectType::Domain, Id::new(1000)), + }, + trc::location!(), + ) + .await; + account.assert_patch("aliases/0/domainId", domain_id, trc::location!()); + account.assert_patch("aliases/1/domainId", domain_id, trc::location!()); + + test.assert_registry_insert_error( + account.clone(), + RegistryWriteResult::InvalidForeignKey { + object_id: ObjectId::new(ObjectType::Domain, Id::new(1004)), + }, + trc::location!(), + ) + .await; + account.assert_patch("domainId", domain_id, trc::location!()); + + test.assert_registry_insert_error( + account.clone(), + RegistryWriteResult::InvalidForeignKey { + object_id: ObjectId::new(ObjectType::PublicKey, Id::new(0)), + }, + trc::location!(), + ) + .await; + account.assert_patch( + "encryptionAtRest", + EncryptionAtRest::Disabled.into_value(), + trc::location!(), + ); + + test.assert_registry_insert_error( + account.clone(), + RegistryWriteResult::InvalidForeignKey { + object_id: ObjectId::new(ObjectType::Role, Id::new(5000)), + }, + trc::location!(), + ) + .await; + account.assert_patch("roles", UserRoles::User.into_value(), trc::location!()); + + let account_id = r + .write(RegistryWrite::insert(&account.into())) + .await + .unwrap() + .unwrap_id(trc::location!()); + + // Deleting linked objects should fail + test.assert_registry_delete_error( + ObjectType::Domain, + domain_id, + RegistryWriteResult::CannotDeleteLinked { + object_id: ObjectId::new(ObjectType::Domain, domain_id), + linked_objects: vec![ + ObjectId::new(ObjectType::Account, group_id), + ObjectId::new(ObjectType::Account, account_id), + ], + }, + trc::location!(), + ) + .await; + + // Primary key violations should not be allowed + test.assert_registry_insert_error( + Domain { + name: "test.org".into(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }, + RegistryWriteResult::PrimaryKeyConflict { + property: Property::Name, + existing_id: ObjectId::new(ObjectType::Domain, domain_id), + }, + trc::location!(), + ) + .await; + test.assert_registry_insert_error( + Account::Group(GroupAccount { + name: "group".into(), + domain_id, + ..Default::default() + }), + RegistryWriteResult::PrimaryKeyConflict { + property: Property::Email, + existing_id: ObjectId::new(ObjectType::Account, group_id), + }, + trc::location!(), + ) + .await; + test.assert_registry_insert_error( + MailingList { + name: "user".into(), + domain_id, + recipients: Map::new(vec!["rcpt@domain.org".into()]), + ..Default::default() + }, + RegistryWriteResult::PrimaryKeyConflict { + property: Property::Email, + existing_id: ObjectId::new(ObjectType::Account, account_id), + }, + trc::location!(), + ) + .await; + test.assert_registry_insert_error( + MailingList { + name: "mailing-list".into(), + domain_id, + aliases: List::from_iter([EmailAlias { + description: "Test Alias 1".to_string().into(), + domain_id, + enabled: true, + name: "alias1".into(), + }]), + recipients: Map::new(vec!["rcpt@domain.org".into()]), + ..Default::default() + }, + RegistryWriteResult::PrimaryKeyConflict { + property: Property::Email, + existing_id: ObjectId::new(ObjectType::Account, account_id), + }, + trc::location!(), + ) + .await; + + // Create a public key and link it to the account + let pk_id = r + .write(RegistryWrite::insert( + &PublicKey { + account_id, + key: "secret".into(), + description: "Test Key".into(), + ..Default::default() + } + .into(), + )) + .await + .unwrap() + .unwrap_id(trc::location!()); + let old_account = r + .get(ObjectId::new(ObjectType::Account, account_id)) + .await + .unwrap() + .unwrap(); + let mut account = old_account.clone(); + assert_obj_patch( + &mut account, + "encryptionAtRest", + EncryptionAtRest::Aes128(EncryptionSettings { + allow_spam_training: true, + encrypt_on_append: false, + public_key: pk_id, + }) + .into_value(), + trc::location!(), + ); + r.write(RegistryWrite::update(account_id, &account, &old_account)) + .await + .unwrap() + .unwrap_id(trc::location!()); + + // Search tests + assert_eq!( + r.query::>(RegistryQuery::new(ObjectType::Domain)) + .await + .unwrap(), + vec![domain_id, domain_id_2] + ); + assert_eq!( + r.query::>(RegistryQuery::new(ObjectType::Domain).equal_pk( + Property::Name, + "test.org".to_string(), + true, + )) + .await + .unwrap(), + vec![domain_id] + ); + assert_eq!( + r.query::>(RegistryQuery::new(ObjectType::Account)) + .await + .unwrap(), + vec![group_id, account_id] + ); + assert_eq!( + r.query::>( + RegistryQuery::new(ObjectType::Account) + .equal(Property::Type, AccountType::User.to_id()) + .text(Property::Text, "this is a test") + .equal(Property::Name, "user") + ) + .await + .unwrap(), + vec![account_id] + ); + + // Sort test + assert_eq!( + r.sort_by_index(ObjectType::Account, Property::Type, None, true) + .await + .unwrap(), + vec![account_id, group_id] + ); + assert_eq!( + r.sort_by_index( + ObjectType::Account, + Property::Type, + Some(vec![group_id, account_id]), + true + ) + .await + .unwrap(), + vec![account_id, group_id] + ); + assert_eq!( + r.sort_by_index(ObjectType::Account, Property::Name, None, true) + .await + .unwrap(), + vec![group_id, account_id] + ); + assert_eq!( + r.sort_by_pk(ObjectType::Domain, Property::Name, None, true) + .await + .unwrap(), + vec![domain_id_2, domain_id] + ); + assert_eq!( + r.sort_by_pk( + ObjectType::Domain, + Property::Name, + Some(vec![domain_id, domain_id_2]), + true + ) + .await + .unwrap(), + vec![domain_id_2, domain_id] + ); + + // Delete everything + let old_account = r + .get(ObjectId::new(ObjectType::Account, account_id)) + .await + .unwrap() + .unwrap(); + let mut account = old_account.clone(); + assert_obj_patch( + &mut account, + "encryptionAtRest", + EncryptionAtRest::Disabled.into_value(), + trc::location!(), + ); + r.write(RegistryWrite::update(account_id, &account, &old_account)) + .await + .unwrap() + .unwrap_id(trc::location!()); + r.write(RegistryWrite::delete(ObjectId::new( + ObjectType::PublicKey, + pk_id, + ))) + .await + .unwrap() + .unwrap_id(trc::location!()); + r.write(RegistryWrite::delete(ObjectId::new( + ObjectType::Account, + account_id, + ))) + .await + .unwrap() + .unwrap_id(trc::location!()); + r.write(RegistryWrite::delete(ObjectId::new( + ObjectType::Account, + group_id, + ))) + .await + .unwrap() + .unwrap_id(trc::location!()); + r.write(RegistryWrite::delete(ObjectId::new( + ObjectType::Domain, + domain_id, + ))) + .await + .unwrap() + .unwrap_id(trc::location!()); + r.write(RegistryWrite::delete(ObjectId::new( + ObjectType::Domain, + domain_id_2, + ))) + .await + .unwrap() + .unwrap_id(trc::location!()); + + test.assert_is_empty().await; +} + +impl TestServer { + pub async fn assert_registry_insert_error( + &self, + obj: impl Into, + result: RegistryWriteResult, + location: &str, + ) { + let obj = obj.into(); + + assert_eq!( + self.server + .registry() + .write(RegistryWrite::insert(&obj)) + .await + .unwrap(), + result, + "{}", + location + ); + } + + pub async fn assert_registry_delete_error( + &self, + object_type: ObjectType, + id: Id, + result: RegistryWriteResult, + location: &str, + ) { + assert_eq!( + self.server + .registry() + .write(RegistryWrite::delete(ObjectId::new(object_type, id))) + .await + .unwrap(), + result, + "{}", + location + ); + } +} + +fn test_patch_regressions() { + fn fresh_account() -> Account { + Account::User(UserAccount { + credentials: List::from_iter([ + Credential::Password(PasswordCredential { + allowed_ips: Map::new(vec![ + IpAddrOrMask::from_str("192.168.1.1").unwrap(), + IpAddrOrMask::from_str("192.168.1.2").unwrap(), + ]), + credential_id: 3u64.into(), + expires_at: None, + otp_auth: None, + secret: "secret".into(), + }), + Credential::Password(PasswordCredential { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.1").unwrap()]), + credential_id: 4u64.into(), + expires_at: None, + otp_auth: None, + secret: "another".into(), + }), + ]), + domain_id: 1u64.into(), + name: "patch-target".into(), + ..Default::default() + }) + } + + fn user(account: &Account) -> &UserAccount { + match account { + Account::User(u) => u, + _ => panic!("expected user account"), + } + } + + fn user_mut(account: &mut Account) -> &mut UserAccount { + match account { + Account::User(u) => u, + _ => panic!("expected user account"), + } + } + + fn password_at(account: &Account, idx: u32) -> &PasswordCredential { + let cred = user(account) + .credentials + .0 + .get(&idx) + .expect("credential at index"); + match cred { + Credential::Password(p) => p, + _ => panic!("expected password credential at idx {idx}"), + } + } + + // Leaf-null patch into a List entry removes only the leaf not the whole entry. + let mut account = fresh_account(); + account.assert_patch( + "credentials/0/allowedIps/192.168.1.1", + JmapValue::Null, + trc::location!(), + ); + { + let cred = password_at(&account, 0); + assert_eq!(cred.allowed_ips.len(), 1, "one ip should remain"); + assert!( + cred.allowed_ips + .contains(&IpAddrOrMask::from_str("192.168.1.2").unwrap()), + "remaining ip survived" + ); + assert!( + !cred + .allowed_ips + .contains(&IpAddrOrMask::from_str("192.168.1.1").unwrap()), + "targeted ip removed" + ); + } + // The sibling credential is untouched. + { + let cred = password_at(&account, 1); + assert_eq!(cred.allowed_ips.len(), 1); + assert!( + cred.allowed_ips + .contains(&IpAddrOrMask::from_str("10.0.0.1").unwrap()) + ); + } + + // Removing every leaf still leaves the entry in place with an empty map. + let mut account = fresh_account(); + account.assert_patch( + "credentials/0/allowedIps/192.168.1.1", + JmapValue::Null, + trc::location!(), + ); + account.assert_patch( + "credentials/0/allowedIps/192.168.1.2", + JmapValue::Null, + trc::location!(), + ); + { + assert_eq!( + user(&account).credentials.len(), + 2, + "credential entry retained" + ); + let cred = password_at(&account, 0); + assert!(cred.allowed_ips.is_empty(), "leaf map drained"); + } + + // Direct removal of a list entry with no remaining segments still works. + let mut account = fresh_account(); + account.assert_patch("credentials/0", JmapValue::Null, trc::location!()); + { + assert_eq!(user(&account).credentials.len(), 1, "credential 0 removed"); + let cred = password_at(&account, 1); + assert_eq!(cred.allowed_ips.len(), 1); + } + + // Leaf-null patch into a scalar property of a list entry clears only that property. + let mut account = fresh_account(); + { + let cred = user_mut(&mut account) + .credentials + .inner_mut() + .get_mut(&0) + .expect("credential at 0"); + if let Credential::Password(p) = cred { + p.expires_at = Some(UTCDateTime::from_timestamp(now() as i64)); + } + } + account.assert_patch("credentials/0/expiresAt", JmapValue::Null, trc::location!()); + { + let cred = password_at(&account, 0); + assert!(cred.expires_at.is_none(), "expiresAt cleared"); + assert_eq!(cred.allowed_ips.len(), 2, "siblings untouched"); + } + + // Map set-style patches + fn account_with_groups() -> Account { + let mut account = match fresh_account() { + Account::User(u) => u, + _ => unreachable!(), + }; + account.member_group_ids = Map::new(vec![Id::new(2000), Id::new(2001)]); + Account::User(account) + } + + let mut account = account_with_groups(); + account.assert_patch( + &format!("memberGroupIds/{}", Id::new(2000)), + JmapValue::Null, + trc::location!(), + ); + assert_eq!( + user(&account).member_group_ids.len(), + 1, + "one member removed" + ); + assert!( + user(&account).member_group_ids.contains(&Id::new(2001)), + "sibling preserved" + ); + + let mut account = account_with_groups(); + let extra_path = format!("memberGroupIds/{}/extra", Id::new(2000)); + let ptr = JsonPointer::parse(&extra_path); + let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Null); + assert!(outcome.is_err(), "extra segments must error on remove"); + assert_eq!( + user(&account).member_group_ids.len(), + 2, + "membership unchanged after rejected patch" + ); + + let mut account = account_with_groups(); + let ptr = JsonPointer::parse(&format!("memberGroupIds/{}/extra", Id::new(2002))); + let outcome = account.patch(JsonPointerPatch::new(&ptr), JmapValue::Bool(true)); + assert!(outcome.is_err(), "extra segments must error on add"); + assert_eq!( + user(&account).member_group_ids.len(), + 2, + "membership unchanged after rejected add" + ); + + // Direct adds and removes still work. + let mut account = account_with_groups(); + account.assert_patch( + &format!("memberGroupIds/{}", Id::new(2002)), + true, + trc::location!(), + ); + assert_eq!(user(&account).member_group_ids.len(), 3, "member added"); + assert!(user(&account).member_group_ids.contains(&Id::new(2002))); +} + +trait AssertPatch { + fn assert_patch(&mut self, patch: &str, value: impl Into>, location: &str); +} + +impl AssertPatch for T { + fn assert_patch(&mut self, patch: &str, value: impl Into>, location: &str) { + let ptr = JsonPointer::parse(patch); + let patch = JsonPointerPatch::new(&ptr); + let value = value.into(); + match self.patch(patch, value) { + Ok(maybe_unpatched) => { + match maybe_unpatched { + MaybeUnpatched::Patched => { + // Patch succeeded + } + MaybeUnpatched::Unpatched { property, value } => { + panic!( + "Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}", + location, property, value + ); + } + MaybeUnpatched::UnpatchedMany { properties } => { + panic!( + "Expected patch to succeed but it was unpatched at {}: properties: {:?}", + location, properties + ); + } + } + } + Err(err) => panic!("Patch failed at {}: {:?}", location, err), + } + } +} + +fn assert_obj_patch( + obj: &mut Object, + patch: &str, + value: impl Into>, + location: &str, +) { + let ptr = JsonPointer::parse(patch); + let patch = JsonPointerPatch::new(&ptr); + let value = value.into(); + match obj.patch(patch, value) { + Ok(maybe_unpatched) => { + match maybe_unpatched { + MaybeUnpatched::Patched => { + // Patch succeeded + } + MaybeUnpatched::Unpatched { property, value } => { + panic!( + "Expected patch to succeed but it was unpatched at {}: property: {}, value: {:?}", + location, property, value + ); + } + MaybeUnpatched::UnpatchedMany { properties } => { + panic!( + "Expected patch to succeed but it was unpatched at {}: properties: {:?}", + location, properties + ); + } + } + } + Err(err) => panic!("Patch failed at {}: {:?}", location, err), + } +} diff --git a/tests/src/store/sql_timeout.rs b/tests/src/store/sql_timeout.rs new file mode 100644 index 0000000..d69f0a4 --- /dev/null +++ b/tests/src/store/sql_timeout.rs @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, storage::build_data_store}; +use futures::FutureExt; +use registry::schema::structs::DataStore; +use std::{panic::AssertUnwindSafe, time::Duration}; +use store::{ + IterateParams, Key, Rows, SUBSPACE_COUNTER, SUBSPACE_PROPERTY, Store, U32_LEN, Value, ValueKey, + write::ValueClass, +}; +use types::collection::Collection; + +const ACCOUNT_ID: u32 = 90210; +const NUM_KEYS: u32 = 300000; +const VALUE_SIZE: usize = 64; +const PROPERTY: u8 = 128; +const STATEMENT_TIMEOUT: Duration = Duration::from_millis(10); + +#[derive(Clone, Copy)] +enum Backend { + #[cfg(feature = "postgres")] + Postgres, + #[cfg(feature = "mysql")] + MariaDb, +} + +pub async fn test(test: &TestServer) { + let Some(backend) = Backend::detect(test.server.store()) else { + return; + }; + + println!("Running SQL statement timeout tests..."); + + let admin = Store::build(backend.data_store(false).await) + .await + .expect("Failed to connect to the statement timeout store"); + admin.create_tables().await.unwrap(); + admin.delete_range(key(0), key(u32::MAX)).await.unwrap(); + for query in [backend.populate_query(), backend.populate_counters_query()] { + admin + .sql_query::(&query, vec![]) + .await + .expect("Failed to populate the statement timeout store"); + } + backend.set_timeout(&admin, true).await; + + let slow = Store::build(backend.data_store(true).await) + .await + .expect("Failed to open the statement timeout connection pool"); + let result = AssertUnwindSafe(scenarios(&slow, backend)) + .catch_unwind() + .await; + + backend.set_timeout(&admin, false).await; + + let mut remaining = 0; + let mut counters = String::new(); + if result.is_ok() { + admin + .iterate( + IterateParams::new(key(0), key(u32::MAX)).no_values(), + |_, _| { + remaining += 1; + Ok(true) + }, + ) + .await + .expect("Failed to count remaining keys"); + counters = admin + .sql_query::(&backend.count_counters_query(), range_params()) + .await + .expect("Failed to count remaining counters") + .rows + .into_iter() + .next() + .and_then(|row| row.values.into_iter().next()) + .map(|value| value.to_str().into_owned()) + .unwrap_or_default(); + } + + admin.delete_range(key(0), key(u32::MAX)).await.unwrap(); + admin + .sql_query::(&backend.purge_query(), vec![]) + .await + .unwrap(); + + if let Err(err) = result { + std::panic::resume_unwind(err); + } + + assert_eq!(remaining, 0, "Keys left behind by delete_range"); + assert_eq!(counters, "0", "Counters left behind by purge_store"); +} + +async fn scenarios(db: &Store, backend: Backend) { + match db + .sql_query::(&backend.scan_query(), range_params()) + .await + { + Ok(rows) => panic!( + "Unable to reproduce a statement timeout: scanning {} keys completed within a \ + {STATEMENT_TIMEOUT:?} statement timeout", + rows.rows.len() + ), + Err(err) => backend.assert_timeout(&err), + } + + let mut ids = Vec::with_capacity(NUM_KEYS as usize); + db.iterate(IterateParams::new(key(0), key(u32::MAX)), |key, value| { + let document_id = document_id(key); + assert_eq!(value.len(), VALUE_SIZE, "document {document_id}"); + assert_eq!( + value.get(..U32_LEN), + Some(document_id.to_be_bytes().as_slice()), + "document {document_id}" + ); + ids.push(document_id); + Ok(true) + }) + .await + .expect("Failed to iterate in ascending order"); + assert_ids(&ids, 0..NUM_KEYS); + + ids.clear(); + db.iterate( + IterateParams::new(key(0), key(u32::MAX)).descending(), + |key, _| { + ids.push(document_id(key)); + Ok(true) + }, + ) + .await + .expect("Failed to iterate in descending order"); + assert_ids(&ids, (0..NUM_KEYS).rev()); + + ids.clear(); + db.iterate( + IterateParams::new(key(0), key(u32::MAX)).no_values(), + |key, value| { + assert!(value.is_empty()); + ids.push(document_id(key)); + Ok(true) + }, + ) + .await + .expect("Failed to iterate over keys"); + assert_ids(&ids, 0..NUM_KEYS); + + ids.clear(); + db.iterate(IterateParams::new(key(0), key(u32::MAX)), |key, _| { + ids.push(document_id(key)); + Ok(ids.len() < 10) + }) + .await + .expect("Failed to stop iterating"); + assert_ids(&ids, 0..10); + + match db + .sql_query::(&backend.delete_query(), range_params()) + .await + { + Ok(deleted) => panic!( + "Unable to reproduce a statement timeout: deleting {deleted} keys completed within \ + a {STATEMENT_TIMEOUT:?} statement timeout" + ), + Err(err) => backend.assert_timeout(&err), + } + + db.delete_range(key(0), key(u32::MAX)) + .await + .expect("Failed to delete range"); + + match db.sql_query::(&backend.purge_query(), vec![]).await { + Ok(deleted) => panic!( + "Unable to reproduce a statement timeout: purging {deleted} counters completed \ + within a {STATEMENT_TIMEOUT:?} statement timeout" + ), + Err(err) => backend.assert_timeout(&err), + } + + db.purge_store().await.expect("Failed to purge store"); +} + +fn assert_ids(ids: &[u32], expected: impl ExactSizeIterator) { + assert_eq!(ids.len(), expected.len(), "Unexpected number of keys"); + + for (position, (id, expected)) in ids.iter().zip(expected).enumerate() { + assert_eq!(*id, expected, "Unexpected key at position {position}"); + } +} + +fn key(document_id: u32) -> ValueKey { + ValueKey { + account_id: ACCOUNT_ID, + collection: Collection::Email.into(), + document_id, + class: ValueClass::Property(PROPERTY), + } +} + +fn document_id(key: &[u8]) -> u32 { + u32::from_be_bytes(key[key.len() - U32_LEN..].try_into().unwrap()) +} + +fn key_prefix() -> String { + let key = key(0).serialize(0); + key[..key.len() - U32_LEN] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn range_params() -> Vec> { + vec![ + Value::Blob(key(0).serialize(0).into()), + Value::Blob(key(u32::MAX).serialize(0).into()), + ] +} + +impl Backend { + fn detect(db: &Store) -> Option { + match db { + #[cfg(feature = "postgres")] + Store::PostgreSQL(_) => Some(Backend::Postgres), + #[cfg(feature = "mysql")] + Store::MySQL(_) => Some(Backend::MariaDb), + _ => None, + } + } + + async fn data_store(&self, slow: bool) -> DataStore { + let mut config = build_data_store( + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => "PostgreSql", + #[cfg(feature = "mysql")] + Backend::MariaDb => "MariaDb", + }, + "", + ) + .await; + + if slow { + match &mut config { + DataStore::PostgreSql(config) => { + config.options = Some(format!( + "-c statement_timeout={}ms", + STATEMENT_TIMEOUT.as_millis() + )); + } + DataStore::MySql(_) => (), + _ => unreachable!(), + } + } + + config + } + + async fn set_timeout(&self, _admin: &Store, _enable: bool) { + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => (), + #[cfg(feature = "mysql")] + Backend::MariaDb => { + let timeout = if _enable { + STATEMENT_TIMEOUT.as_secs_f64() + } else { + 0.0 + }; + + _admin + .sql_query::( + &format!("SET GLOBAL max_statement_time = {timeout}"), + vec![], + ) + .await + .unwrap(); + } + } + } + + fn populate_query(&self) -> String { + let table = char::from(SUBSPACE_PROPERTY); + let prefix = key_prefix(); + let padding = VALUE_SIZE - U32_LEN; + let last = NUM_KEYS - 1; + + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => format!( + "INSERT INTO {table} (k, v) SELECT \ + decode('{prefix}' || lpad(to_hex(id), 8, '0'), 'hex'), \ + decode(lpad(to_hex(id), 8, '0') || repeat('76', {padding}), 'hex') \ + FROM generate_series(0, {last}) id" + ), + #[cfg(feature = "mysql")] + Backend::MariaDb => format!( + "INSERT INTO {table} (k, v) SELECT \ + UNHEX(CONCAT('{prefix}', LPAD(HEX(seq), 8, '0'))), \ + UNHEX(CONCAT(LPAD(HEX(seq), 8, '0'), REPEAT('76', {padding}))) \ + FROM seq_0_to_{last}" + ), + } + } + + fn populate_counters_query(&self) -> String { + let table = char::from(SUBSPACE_COUNTER); + let prefix = key_prefix(); + let last = NUM_KEYS - 1; + + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => format!( + "INSERT INTO {table} (k, v) SELECT \ + decode('{prefix}' || lpad(to_hex(id), 8, '0'), 'hex'), 0 \ + FROM generate_series(0, {last}) id" + ), + #[cfg(feature = "mysql")] + Backend::MariaDb => format!( + "INSERT INTO {table} (k, v) SELECT \ + UNHEX(CONCAT('{prefix}', LPAD(HEX(seq), 8, '0'))), 0 FROM seq_0_to_{last}" + ), + } + } + + fn purge_query(&self) -> String { + format!("DELETE FROM {} WHERE v = 0", char::from(SUBSPACE_COUNTER)) + } + + fn count_counters_query(&self) -> String { + let table = char::from(SUBSPACE_COUNTER); + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => format!("SELECT COUNT(*) FROM {table} WHERE k >= $1 AND k <= $2"), + #[cfg(feature = "mysql")] + Backend::MariaDb => format!("SELECT COUNT(*) FROM {table} WHERE k >= ? AND k <= ?"), + } + } + + fn scan_query(&self) -> String { + let table = char::from(SUBSPACE_PROPERTY); + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => { + format!("SELECT k, v FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC") + } + #[cfg(feature = "mysql")] + Backend::MariaDb => { + format!("SELECT k, v FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC") + } + } + } + + fn delete_query(&self) -> String { + let table = char::from(SUBSPACE_PROPERTY); + match self { + #[cfg(feature = "postgres")] + Backend::Postgres => format!("DELETE FROM {table} WHERE k >= $1 AND k <= $2"), + #[cfg(feature = "mysql")] + Backend::MariaDb => format!("DELETE FROM {table} WHERE k >= ? AND k <= ?"), + } + } + + fn assert_timeout(&self, err: &trc::Error) { + let (event_type, marker) = match self { + #[cfg(feature = "postgres")] + Backend::Postgres => (trc::StoreEvent::PostgresqlError, "57014"), + #[cfg(feature = "mysql")] + Backend::MariaDb => ( + trc::StoreEvent::MysqlError, + "Query execution was interrupted", + ), + }; + let details = format!("{err:?}"); + + assert!( + err.matches(trc::EventType::Store(event_type)) && details.contains(marker), + "Expected a statement timeout, got: {details}" + ); + } +} diff --git a/tests/src/system/antispam.rs b/tests/src/system/antispam.rs new file mode 100644 index 0000000..99bfe04 --- /dev/null +++ b/tests/src/system/antispam.rs @@ -0,0 +1,816 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use email::mailbox::{DRAFTS_ID, INBOX_ID, JUNK_ID}; +use registry::{ + schema::{ + enums::{Permission, TaskSpamFilterMaintenanceType, TaskStoreMaintenanceType}, + prelude::{ObjectType, Property}, + structs::{ + Permissions, PermissionsList, SpamTrainingSample, Task, TaskSpamFilterMaintenance, + TaskStatus, TaskStoreMaintenance, + }, + }, + types::map::Map, +}; +use serde_json::json; +use store::write::now; +use types::{id::Id, keyword::Keyword}; + +pub async fn test(test: &mut TestServer) { + println!("Running Email Spam classifier tests..."); + + // Create test accounts + let admin = test.account("admin@example.org"); + let account = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &[], + "jdoe@example.org", + ) + .await; + let other_account = test + .create_user_account( + "admin@example.org", + "jane@example.org", + "this is a very strong password", + &[], + "jane@example.org", + ) + .await; + let client = account.jmap_client().await; + let account_id = account.id().document_id(); + + // Make sure there are no spam training samples + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + assert!( + admin + .registry_query( + ObjectType::SpamTrainingSample, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .ids() + .next() + .is_none() + ); + + // Import samples + let mut spam_ids = vec![]; + let mut ham_ids = vec![]; + for (idx, samples) in [&SPAM, &HAM].into_iter().enumerate() { + let is_spam = idx == 0; + for (num, sample) in samples.iter().enumerate() { + let mut mailbox_ids = vec![]; + let mut keywords = vec![]; + + if num == 0 { + if is_spam { + mailbox_ids.push(Id::from(JUNK_ID).to_string()); + keywords.push(Keyword::Junk.to_string()); + } else { + mailbox_ids.push(Id::from(INBOX_ID).to_string()); + keywords.push(Keyword::NotJunk.to_string()); + } + } else { + mailbox_ids.push(Id::from(DRAFTS_ID).to_string()); + } + + let mail_id = client + .email_import( + sample.as_bytes().to_vec(), + &mailbox_ids, + Some(&keywords), + None, + ) + .await + .unwrap() + .take_id(); + if is_spam { + spam_ids.push(mail_id); + } else { + ham_ids.push(mail_id); + } + } + } + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1); + + // Other users should no see the training samples + let samples = other_account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 0); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0); + + // The admin user should see all training samples + let samples = admin.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1); + + // Train the classifier via JMAP + for (ids, is_spam) in [(&spam_ids, true), (&ham_ids, false)] { + for (idx, id) in ids.iter().skip(1).enumerate() { + // Set keywords and mailboxes + let mut request = client.build(); + let req = request.set_email().update(id); + if idx < 5 || !is_spam { + // Update via keywords + let keyword = if is_spam { + Keyword::Junk + } else { + Keyword::NotJunk + } + .to_string(); + req.keywords([&keyword]); + } else { + // Update via mailbox + let mailbox_id = if is_spam { JUNK_ID } else { INBOX_ID }; + req.mailbox_ids([&Id::from(mailbox_id).to_string()]); + } + + request.send_set_email().await.unwrap().updated(id).unwrap(); + } + } + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 10); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10); + + // Make sure the email details are available in the sample + assert_eq!(samples[0].1.subject, "save up to = on life insurance"); + assert_eq!(samples[0].1.from, "spammy@mcspamface.net"); + + // Reclassifying an email should not add a new sample + let mut request = client.build(); + request + .set_email() + .update(&ham_ids[0]) + .keywords([Keyword::Junk.to_string()]); + request + .send_set_email() + .await + .unwrap() + .updated(&ham_ids[0]) + .unwrap(); + + admin + .registry_create_object(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { + maintenance_type: TaskSpamFilterMaintenanceType::Train, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + + let samples = account.spam_training_samples().await; + assert_eq!(samples.len(), 20); + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11); + let hold_for = test + .server + .core + .spam + .classifier + .as_ref() + .unwrap() + .hold_samples_for; + assert!( + hold_for > 2 * 86400, + "hold for {} should be greater than 2 days", + hold_for + ); + let hold_until = now() + hold_for; + let hold_range = (hold_until - 86400)..=hold_until; + + assert!(samples.iter().all(|(_, s)| { + s.blob_id.class.account_id() == account_id + && !s.delete_after_use + && hold_range.contains(&(s.expires_at.timestamp() as u64)) + })); + + // Purging blobs should not remove training samples + admin + .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeBlob, + shard_index: None, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11); + assert_eq!(samples.len(), 20); + + // Adding a training sample without permissions should fail + assert_eq!( + account + .registry_create_many( + ObjectType::SpamTrainingSample, + [json!({ + Property::BlobId: samples[0].1.blob_id.clone(), + })], + ) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + + // Update permissions and try again + admin + .registry_update_object( + ObjectType::Account, + account.id(), + json!({ + Property::Permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Map::default(), + enabled_permissions: Map::new(vec![Permission::SysSpamTrainingSampleCreate]), + }) + }), + ) + .await; + let sample_id = account + .registry_create_many( + ObjectType::SpamTrainingSample, + [json!({ + Property::BlobId: samples[0].1.blob_id.clone(), + Property::IsSpam: true, + })], + ) + .await + .created_id(0); + let sample = account.registry_get::(sample_id).await; + assert_eq!(sample.subject, "save up to = on life insurance"); + assert_eq!(sample.from, "spammy@mcspamface.net"); + let samples = account.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 12); + assert_eq!(samples.len(), 21); + + // Delete account + test.destroy_all_mailboxes(&account).await; + account + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + test.assert_is_empty().await; + + admin.destroy_account(account).await; + admin.destroy_account(other_account).await; + test.cleanup().await; +} + +pub const SPAM: [&str; 10] = [ + concat!( + "From: spammy@mcspamface.net\r\n", + "Subject: save up to = on life insurance\r\n\r\n wh", + "y spend more than you have to life quote savings e", + "nsuring your family s financial security is very i", + "mportant life quote savings makes buying life insu", + "rance simple and affordable we provide free access", + " to the very best companies and the lowest rates l", + "ife quote savings is fast easy and saves you money", + " let us help you get started with the best values ", + "in the country on new coverage you can save hundre", + "ds or even thousands of dollars by requesting a fr", + "ee quote from lifequote savings our service will t", + "ake you less than = minutes to complete shop ", + "and compare save up to = on all types of life", + " insurance hyperlink click here for your free quot", + "e protecting your family is the best investment yo", + "u ll ever make if you are in receipt of this email", + " in error and or wish to be removed from our list ", + "hyperlink please click here and type remove if you", + " reside in any state which prohibits e mail solici", + "tations for insurance please disregard this email\r\n", + " \r\n" + ), + concat!( + "Subject: a powerhouse gifting program\r\n\r\nyou don t ", + "want to miss get in with the founders the major pl", + "ayers are on this one for once be where the player", + "s are this is your private invitation experts are ", + "calling this the fastest way to huge cash flow eve", + "r conceived leverage = = into = NUM", + "BER over and over again the question here is you e", + "ither want to be wealthy or you don t which one ar", + "e you i am tossing you a financial lifeline and fo", + "r your sake i hope you grab onto it and hold on ti", + "ght for the ride of your life testimonials hear wh", + "at average people are doing their first few days w", + "e ve received = = in = day and we a", + "re doing that over and over again q s in al i m a ", + "single mother in fl and i ve received = NUMBE", + "R in the last = days d s in fl i was not sure", + " about this when i sent off my = = pledg", + "e but i got back = = the very next day l", + " l in ky i didn t have the money so i found myself", + " a partner to work this with we have received NUMB", + "ER = over the last = days i think i made", + " the right decision don t you k c in fl i pick up ", + "= = my first day and i they gave me free", + " leads and all the training you can too j w in ca ", + "announcing we will close your sales for you and he", + "lp you get a fax blast immediately upon your entry", + " you make the money free leads training don t wait", + " call now fax back to = = = = ", + "or call = = = = name__________", + "________________________phone_____________________", + "______________________ fax________________________", + "_____________email________________________________", + "____________ best time to call____________________", + "_____time zone____________________________________", + "____ this message is sent in compliance of the new", + " e mail bill per section = paragraph a =", + " c of s = further transmissions by the sender", + " of this email may be stopped at no cost to you by", + " sending a reply to this email address with the wo", + "rd remove in the subject line errors omissions and", + " exceptions excluded this is not spam i have compi", + "led this list from our replicate database relative", + " to seattle marketing group the gigt or turbo team", + " for the sole purpose of these communications your", + " continued inclusion is only by your gracious perm", + "ission if you wish to not receive this mail from m", + "e please send an email to tesrewinter with rem", + "ove in the subject and you will be deleted immedia", + "tely\r\n\r\n" + ), + concat!( + "Subject: help wanted \r\n\r\nwe are a = year old f", + "ortune = company that is growing at a tremend", + "ous rate we are looking for individuals who want t", + "o work from home this is an opportunity to make an", + " excellent income no experience is required we wil", + "l train you so if you are looking to be employed f", + "rom home with a career that has vast opportunities", + " then go we are looking for energetic and self", + " motivated people if that is you than click on the", + " link and fill out the form and one of our employe", + "ment specialist will contact you to be removed fro", + "m our link simple go to \r\n\r\n" + ), + concat!( + "Subject: tired of the bull out there\r\n\r\n want to st", + "op losing money want a real money maker receive NU", + "MBER = = = today experts are callin", + "g this the fastest way to huge cash flow ever conc", + "eived a powerhouse gifting program you don t want ", + "to miss we work as a team this is your private inv", + "itation get in with the founders this is where the", + " big boys play the major players are on this one f", + "or once be where the players are this is a system ", + "that will drive = = s to your doorstep i", + "n a short period of time leverage = = in", + "to = = over and over again the question ", + "here is you either want to be wealthy or you don t", + " which one are you i am tossing you a financial li", + "feline and for your sake i hope you grab onto it a", + "nd hold on tight for the ride of your life testimo", + "nials hear what average people are doing their fir", + "st few days we ve received = = in =", + " day and we are doing that over and over again q s", + " in al i m a single mother in fl and i ve received", + " = = in the last = days d s in fl i", + " was not sure about this when i sent off my =", + " = pledge but i got back = = the ve", + "ry next day l l in ky i didn t have the money so i", + " found myself a partner to work this with we have ", + "received = = over the last = days i", + " think i made the right decision don t you k c in ", + "fl i pick up = = my first day and i they", + " gave me free leads and all the training you can t", + "oo j w in ca this will be the most important call ", + "you make this year free leads training announcing ", + "we will close your sales for you and help you get ", + "a fax blast immediately upon your entry you make t", + "he money free leads training don t wait call now N", + "UMBER = = = print and fax to =", + " = = = or send an email requesting ", + "more information to successleads please includ", + "e your name and telephone number receive = NU", + "MBER free leads just for responding a = NUMBE", + "R value name___________________________________ ph", + "one___________________________________ fax________", + "_____________________________ email_______________", + "____________________ this message is sent in compl", + "iance of the new e mail bill per section = pa", + "ragraph a = c of s = further transmissio", + "ns by the sender of this email may be stopped at n", + "o cost to you by sending a reply to this email add", + "ress with the word remove in the subject line erro", + "rs omissions and exceptions excluded this is not s", + "pam i have compiled this list from our replicate d", + "atabase relative to seattle marketing group the gi", + "gt or turbo team for the sole purpose of these com", + "munications your continued inclusion is only by yo", + "ur gracious permission if you wish to not receive ", + "this mail from me please send an email to tesrewin", + "ter with remove in the subject and you will be", + " deleted immediately\r\n\r\n" + ), + concat!( + "Subject: cellular phone accessories \r\n\r\n all at bel", + "ow wholesale prices http = = = NUMB", + "ER = sites merchant sales hands free ear buds", + " = = phone holsters = = booste", + "r antennas only = = phone cases = N", + "UMBER car chargers = = face plates as lo", + "w as = = lithium ion batteries as low as", + " = = http = = = = NU", + "MBER sites merchant sales click below for accessor", + "ies on all nokia motorola lg nextel samsung qualco", + "mm ericsson audiovox phones at below wholesale pri", + "ces http = = = = = sites ", + "merchant sales if you need assistance please call ", + "us = = = to be removed from future ", + "mailings please send your remove request to remove", + " me now = thank you and have a super day\r\n", + " \r\n" + ), + concat!( + "Subject: conferencing made easy\r\n\r\n only = cen", + "ts per minute including long distance no setup fee", + "s no contracts or monthly fees call anytime from a", + "nywhere to anywhere connects up to = particip", + "ants simplicity in set up and administration opera", + "tor help available = = the highest quali", + "ty service for the lowest rate in the industry fil", + "l out the form below to find out how you can lower", + " your phone bill every month required input field ", + "name web address company name state business phone", + " home phone email address type of business to be r", + "emoved from our distribution lists please hyperlin", + "k click here\r\n\r\n" + ), + concat!( + "Subject: dear friend\r\n\r\n i am mrs sese seko widow o", + "f late president mobutu sese seko of zaire now kno", + "wn as democratic republic of congo drc i am moved ", + "to write you this letter this was in confidence co", + "nsidering my presentcircumstance and situation i e", + "scaped along with my husband and two of our sons g", + "eorge kongolo and basher out of democratic republi", + "c of congo drc to abidjan cote d ivoire where my f", + "amily and i settled while we later moved to settle", + "d in morroco where my husband later died of cancer", + " disease however due to this situation we decided ", + "to changed most of my husband s billions of dollar", + "s deposited in swiss bank and other countries into", + " other forms of money coded for safe purpose becau", + "se the new head of state of dr mr laurent kabila h", + "as made arrangement with the swiss government and ", + "other european countries to freeze all my late hus", + "band s treasures deposited in some european countr", + "ies hence my children and i decided laying low in ", + "africa to study the situation till when things get", + "s better like now that president kabila is dead an", + "d the son taking over joseph kabila one of my late", + " husband s chateaux in southern france was confisc", + "ated by the french government and as such i had to", + " change my identity so that my investment will not", + " be traced and confiscated i have deposited the su", + "m eighteen million united state dollars us = ", + "= = = with a security company for s", + "afekeeping the funds are security coded to prevent", + " them from knowing the content what i want you to ", + "do is to indicate your interest that you will assi", + "st us by receiving the money on our behalf acknowl", + "edge this message so that i can introduce you to m", + "y son kongolo who has the out modalities for the c", + "laim of the said funds i want you to assist in inv", + "esting this money but i will not want my identity ", + "revealed i will also want to buy properties and st", + "ock in multi national companies and to engage in o", + "ther safe and non speculative investments may i at", + " this point emphasise the high level of confidenti", + "ality which this business demands and hope you wil", + "l not betray the trust and confidence which i repo", + "se in you in conclusion if you want to assist us m", + "y son shall put you in the picture of the business", + " tell you where the funds are currently being main", + "tained and also discuss other modalities including", + " remunerationfor your services for this reason kin", + "dly furnish us your contact information that is yo", + "ur personal telephone and fax number for confident", + "ial regards mrs m sese seko\r\n\r\n" + ), + concat!( + "Subject: lowest rates available for term life insu", + "rance\r\n\r\n take a moment and fill out our online for", + "m to see the low rate you qualify for save up to N", + "UMBER from regular rates smokers accepted repr", + "esenting quality nationwide carriers act now to ea", + "sily remove your address from the list go to p", + "lease allow = = hours for removal\r\n\r\n" + ), + concat!( + "Subject: central bank of nigeria foreign remittanc", + "e \r\n\r\n dept tinubu square lagos nigeria email smith", + "_j =th of august = attn president ce", + "o strictly private business proposal i am mr johns", + "on s abu the bills and exchange director at the fo", + "reignremittance department of the central bank of ", + "nigeria i am writingyou this letter to ask for you", + "r support and cooperation to carrying thisbusiness", + " opportunity in my department we discovered abando", + "ned the sumof us = = = = thirt", + "y seven million four hundred thousand unitedstates", + " dollars in an account that belong to one of our f", + "oreign customers an american late engr john creek ", + "junior an oil merchant with the federal government", + " of nigeria who died along with his entire family ", + "of a wifeand two children in kenya airbus a= ", + "= flight kq= in november= since we ", + "heard of his death we have been expecting his next", + " of kin tocome over and put claims for his money a", + "s the heir because we cannotrelease the fund from ", + "his account unless someone applies for claims asth", + "e next of kin to the deceased as indicated in our ", + "banking guidelines unfortunately neither their fam", + "ily member nor distant relative hasappeared to cla", + "im the said fund upon this discovery i and other o", + "fficialsin my department have agreed to make busin", + "ess with you release the totalamount into your acc", + "ount as the heir of the fund since no one came for", + "it or discovered either maintained account with ou", + "r bank other wisethe fund will be returned to the ", + "bank treasury as unclaimed fund we have agreed tha", + "t our ratio of sharing will be as stated thus NUMB", + "ER for you as foreign partner and = for us th", + "e officials in my department upon the successful c", + "ompletion of this transfer my colleague and i will", + "come to your country and mind our share it is from", + " our = we intendto import computer accessorie", + "s into my country as way of recycling thefund to c", + "ommence this transaction we require you to immedia", + "tely indicateyour interest by calling me or sendin", + "g me a fax immediately on the abovetelefax and enc", + "lose your private contact telephone fax full namea", + "nd address and your designated banking co ordinate", + "s to enable us fileletter of claim to the appropri", + "ate department for necessary approvalsbefore the t", + "ransfer can be made note also this transaction mus", + "t be kept strictly confidential becauseof its natu", + "re nb please remember to give me your phone and fa", + "x no mr johnson smith abu irish linux users group ", + "ilug for un subscription information list ", + "maintainer listmaster \r\n\r\n" + ), + concat!( + "Subject: dear stuart\r\n\r\n are you tired of searching", + " for love in all the wrong places find love now at", + " browse through thousands of personals in ", + "your area join for free search e mail chat use", + " to meet cool guys and hot girls go = on ", + "= or use our private chat rooms click on the ", + "link to get started find love now you have rec", + "eived this email because you have registerd with e", + "mailrewardz or subscribed through one of our marke", + "ting partners if you have received this message in", + " error or wish to stop receiving these great offer", + "s please click the remove link above to unsubscrib", + "e from these mailings please click here \r\n\r\n" + ), +]; + +pub const HAM: [&str; 10] = [ + concat!( + "Message-ID: \r\nSubject: i have been", + " trying to research via sa mirrors and search engi", + "nes\r\n\r\nif a canned script exists giving clients acce", + "ss to their user_prefs options via a web based cgi", + " interface numerous isps provide this feature to c", + "lients but so far i can find nothing our configura", + "tion uses amavis postfix and clamav for virus filt", + "ering and procmail with spamassassin for spam filt", + "ering i would prefer not to have to write a script", + " myself but will appreciate any suggestions this U", + "RL email is sponsored by osdn tired of that same o", + "ld cell phone get a new here for free ________", + "_______________________________________ spamassass", + "in talk mailing list spamassassin talk \r\n\r\n" + ), + concat!( + "Message-ID: mid2@foobar.org\r\nSubject: hello\r\n\r\nhave y", + "ou seen and discussed this article and his approac", + "h thank you hell there are no rules here we re", + " trying to accomplish something thomas alva edison", + " this email is sponsored by osdn tired of that", + " same old cell phone get a new here for free _", + "______________________________________________ spa", + "massassin devel mailing list spamassassin devel UR", + "L \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: hi all apol", + "ogies for the possible silly question\r\n\r\ni don t thi", + "nk it is but but is eircom s adsl service nat ed a", + "nd what implications would that have for voip i kn", + "ow there are difficulties with voip or connecting ", + "to clients connected to a nat ed network from the ", + "internet wild i e machines with static real ips an", + "y help pointers would be helpful cheers rgrds bern", + "ard bernard tyers national centre for sensor resea", + "rch p = = = = e bernard tyers ", + " w l n= ______________________________", + "_________________ iiu mailing list iiu \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: can someone", + " explain\r\n\r\nwhat type of operating system solaris is", + " as ive never seen or used it i dont know wheather", + " to get a server from sun or from dell i would pre", + "fer a linux based server and sun seems to be the o", + "ne for that but im not sure if solaris is a distro", + " of linux or a completely different operating syst", + "em can someone explain kiall mac innes irish linux", + " users group ilug for un subscription info", + "rmation list maintainer listmaster \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: folks my fi", + "rst time posting\r\n\r\nhave a bit of unix experience bu", + "t am new to linux just got a new pc at home dell b", + "ox with windows xp added a second hard disk for li", + "nux partitioned the disk and have installed suse N", + "UMBER = from cd which went fine except it did", + "n t pick up my monitor i have a dell branded eNUMB", + "ERfpp = lcd flat panel monitor and a nvidia g", + "eforce= ti= video card both of which are", + " probably too new to feature in suse s default set", + " i downloaded a driver from the nvidia website and", + " installed it using rpm then i ran sax= as wa", + "s recommended in some postings i found on the net ", + "but it still doesn t feature my video card in the ", + "available list what next another problem i have a ", + "dell branded keyboard and if i hit caps lock twice", + " the whole machine crashes in linux not windows ev", + "en the on off switch is inactive leaving me to rea", + "ch for the power cable instead if anyone can help ", + "me in any way with these probs i d be really grate", + "ful i ve searched the net but have run out of idea", + "s or should i be going for a different version of ", + "linux such as redhat opinions welcome thanks a lot", + " peter irish linux users group ilug for un", + " subscription information list maintainer listmast", + "er \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: has anyone\r\n", + "\r\nseen heard of used some package that would let a ", + "random person go to a webpage create a mailing lis", + "t then administer that list also of course let ppl", + " sign up for the lists and manage their subscripti", + "ons similar to the old but i d like to have it", + " running on my server not someone elses chris ", + "\r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: hi thank yo", + "u for the useful replies\r\n\r\ni have found some intere", + "sting tutorials in the ibm developer connection UR", + "L and registration is needed i will post the s", + "ame message on the web application security list a", + "s suggested by someone for now i thing i will use ", + "md= for password checking i will use the appr", + "oach described in secure programmin fo linux and u", + "nix how to i will separate the authentication modu", + "le so i can change its implementation at anytime t", + "hank you again mario torre please avoid sending me", + " word or powerpoint attachments see \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: hehe sorry\r\n", + "\r\nbut if you hit caps lock twice the computer crash", + "es theres one ive never heard before have you trye", + "d dell support yet i think dell computers prefer r", + "edhat dell provide some computers pre loaded with ", + "red hat i dont know for sure tho so get someone el", + "ses opnion as well as mine original message from i", + "lug admin mailto ilug admin on behalf of p", + "eter staunton sent = august = = NUM", + "BER to ilug subject ilug newbie seeks advice s", + "use = = folks my first time posting have", + " a bit of unix experience but am new to linux just", + " got a new pc at home dell box with windows xp add", + "ed a second hard disk for linux partitioned the di", + "sk and have installed suse = = from cd w", + "hich went fine except it didn t pick up my monitor", + " i have a dell branded e=fpp = lcd flat ", + "panel monitor and a nvidia geforce= ti= ", + "video card both of which are probably too new to f", + "eature in suse s default set i downloaded a driver", + " from the nvidia website and installed it using rp", + "m then i ran sax= as was recommended in some ", + "postings i found on the net but it still doesn t f", + "eature my video card in the available list what ne", + "xt another problem i have a dell branded keyboard ", + "and if i hit caps lock twice the whole machine cra", + "shes in linux not windows even the on off switch i", + "s inactive leaving me to reach for the power cable", + " instead if anyone can help me in any way with the", + "se probs i d be really grateful i ve searched the ", + "net but have run out of ideas or should i be going", + " for a different version of linux such as redhat o", + "pinions welcome thanks a lot peter irish linux use", + "rs group ilug for un subscription informat", + "ion list maintainer listmaster irish linux use", + "rs group ilug for un subscription informat", + "ion list maintainer listmaster \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: it will fun", + "ction as a router\r\n\r\nif that is what you wish it eve", + "n looks like the modem s embedded os is some kind ", + "of linux being that it has interesting interfaces ", + "like eth= i don t use it as a router though i", + " just have it do the absolute minimum dsl stuff an", + "d do all the really fun stuff like pppoe on my lin", + "ux box also the manual tells you what the default ", + "password is don t forget to run pppoe over the alc", + "atel speedtouch =i as in my case you have to ", + "have a bridge configured in the router modem s sof", + "tware this lists your vci values etc also does any", + "one know if the high end speedtouch with = et", + "hernet ports can act as a full router or do i stil", + "l need to run a pppoe stack on the linux box regar", + "ds vin irish linux users group ilug for un", + " subscription information list maintainer listmast", + "er irish linux users group ilug for un", + " subscription information list maintainer listmast", + "er \r\n\r\n" + ), + concat!( + "Message-ID: \r\nSubject: all is it ", + "just me\r\n\r\nor has there been a massive increase in t", + "he amount of email being falsely bounced around th", + "e place i ve already received email from a number ", + "of people i don t know asking why i am sending the", + "m email these can be explained by servers from rus", + "sia and elsewhere coupled with the false emails i ", + "received myself it s really starting to annoy me a", + "m i the only one seeing an increase in recent week", + "s martin martin whelan déise design tel NUMBE", + "R = our core product déiseditor allows organ", + "isations to publish information to their web site ", + "in a fast and cost effective manner there is no ne", + "ed for a full time web developer as the site can b", + "e easily updated by the organisations own staff in", + "stant updates to keep site information fresh sites", + " which are updated regularly bring users back visi", + "t for a demonstration déiseditor managing you", + "r information ____________________________________", + "___________ iiu mailing list iiu ,0\r\n" + ), +]; + +pub const TEST: [&str; 3] = [ + concat!( + "From: spammy@mcspamface.net\r\n", + "Subject: save up to = on life insurance\r\n\r\nwhy ", + "spend more than you have to life quote savings ens", + "uring your family s financial security is very imp", + "ortant life quote savings makes buying life insura", + "nce simple and affordable we provide free access t", + "o the very best companies and the lowest rates lif", + "e quote savings is fast easy and saves you money l", + "et us help you get started with the best values in", + " the country on new coverage you can save hundreds", + " or even thousands of dollars by requesting a free", + " quote from lifequote savings our service will tak", + "e you less than = minutes to complete shop an", + "d compare save up to = on all types of life i", + "nsurance hyperlink click here for your free quote ", + "protecting your family is the best investment you ", + "ll ever make if you are in receipt of this email i", + "n error and or wish to be removed from our list hy", + "perlink please click here and type remove if you r", + "eside in any state which prohibits e mail solicita", + "tions for insurance please disregard this email\r\n" + ), + concat!( + "Subject: can someone explain\r\n\r\nwhat type of operati", + "ng system solaris is as ive never seen or used it ", + "i dont know wheather to get a server from sun or f", + "rom dell i would prefer a linux based server and s", + "un seems to be the one for that but im not sure if", + " solaris is a distro of linux or a completely diff", + "erent operating system can someone explain kiall m", + "ac innes irish linux users group ilug for ", + "un subscription information list maintainer listma", + "ster \r\n" + ), + concat!( + "Subject: classifier test\r\n\r\nthis is a novel text tha", + "t the sgd classifier has never seen before, it s", + "hould be classified as ham or non-ham\r\n" + ), +]; diff --git a/tests/src/system/authentication.rs b/tests/src/system/authentication.rs new file mode 100644 index 0000000..44e53f2 --- /dev/null +++ b/tests/src/system/authentication.rs @@ -0,0 +1,431 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use common::auth::credential::{ApiKey, AppPassword}; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::StorageQuota, + prelude::{ObjectType, Property}, + structs::{ + self, Account, Credential, Http, PasswordCredential, SecondaryCredential, UserAccount, + }, + }, + types::{EnumImpl, datetime::UTCDateTime, ipmask::IpAddrOrMask, list::List, map::Map}, +}; +use serde_json::json; +use std::str::FromStr; +use store::write::now; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Authentication tests..."); + + let admin = test.account("admin@example.org"); + let domain_id = admin.find_or_create_domain("example.org").await; + + // Enable X-Forwarded-For processing to test IP-based access restrictions + admin + .registry_update_setting( + Http { + use_x_forwarded: true, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin.reload_settings().await; + + // Weak passwords should be rejected + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "12345".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long."); + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "12345678".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains(concat!( + "Password is too weak. This is a top-10 common password. ", + "Add another word or two. Uncommon words are better." + )); + + // Adding secondary credentials should not be allowed + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::AppPassword(SecondaryCredential { + description: "Test app password".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Secondary credentials cannot be set directly"); + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::ApiKey(SecondaryCredential { + description: "Test API key".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Secondary credentials cannot be set directly"); + + // Creating a user with a valid password should succeed + let user_id = admin + .registry_create_object(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await; + validate_password("user@example.org", "this is a very strong password", true).await; + validate_password("user@example.org", "wrong password", false).await; + + // Change password as admin + admin + .registry_update_object_expect_err( + ObjectType::Account, + user_id, + json!({ + "credentials/0/secret": "12345" + }), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long."); + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/secret": "very strong password indeed" + }), + ) + .await; + validate_password("user@example.org", "this is a very strong password", false).await; + validate_password("user@example.org", "very strong password indeed", true).await; + + // Set password expiration in two seconds and verify it works + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/expiresAt": UTCDateTime::from_timestamp((now() + 2) as i64) + }), + ) + .await; + let mut user = crate::utils::account::Account::new( + "user@example.org", + "very strong password indeed", + &[], + "User", + user_id, + ); + user.registry_query_ids( + ObjectType::PublicKey, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + assert_eq!( + user.registry_query( + ObjectType::PublicKey, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + + // Password updates should require the old password + user.registry_update_object_expect_err( + ObjectType::AccountPassword, + Id::singleton(), + json!({ + Property::Secret: "12345" + }), + ) + .await + .assert_type(SetErrorType::Forbidden) + .assert_description_contains( + "Current secret must be provided to change the password or OTP auth.", + ); + + // Password policies should be enforced when changing password + user.registry_update_object_expect_err( + ObjectType::AccountPassword, + Id::singleton(), + json!({ + Property::CurrentSecret: "very strong password indeed", + Property::Secret: "12345" + }), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long."); + + // Perform a valid password update + user.registry_update_object( + ObjectType::AccountPassword, + Id::singleton(), + json!({ + Property::CurrentSecret: "very strong password indeed", + Property::Secret: "user provided strong password" + }), + ) + .await; + validate_password("user@example.org", "very strong password indeed", false).await; + validate_password("user@example.org", "user provided strong password", true).await; + user.update_secret("user provided strong password"); + + // After a successful password change, the user permissions should be restored + user.registry_query_ids( + ObjectType::PublicKey, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await; + + // Limit login to specific IPs and set credential quotas + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/allowedIps": {"192.168.1.1": true}, + Property::Quotas: { + StorageQuota::MaxApiKeys.as_str(): 1, + StorageQuota::MaxAppPasswords.as_str(): 1, + } + }), + ) + .await; + validate_password_with_ip( + "user@example.org", + "user provided strong password", + "192.168.1.1", + true, + ) + .await; + validate_password_with_ip( + "user@example.org", + "user provided strong password", + "192.168.1.2", + false, + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/allowedIps": {}, + }), + ) + .await; + + // Create an IP-restricted App Password and verify it works + let response = user + .registry_create([structs::AppPassword { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]), + description: "My app password".to_string(), + ..Default::default() + }]) + .await; + let app_password = response.created(0); + let app_password_id = app_password.object_id(); + let app_password_secret = app_password.text_field("secret").to_string(); + let _ = AppPassword::parse(&app_password_secret).unwrap(); + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", true).await; + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.3", false).await; + + // Create an IP-restricted API key and verify it works + let response = user + .registry_create([structs::ApiKey { + allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]), + description: "My API key".to_string(), + ..Default::default() + }]) + .await; + let api_key = response.created(0); + let api_key_id = api_key.object_id(); + let api_key_secret = api_key.text_field("secret").to_string(); + let _ = ApiKey::parse(&api_key_secret).unwrap(); + validate_token_with_ip(&api_key_secret, "10.0.0.2", true).await; + validate_token_with_ip(&api_key_secret, "10.0.0.3", false).await; + + // Creating more API keys or app passwords should fail due to quota + user.registry_create_object_expect_err(structs::AppPassword { + description: "Another app password".to_string(), + ..Default::default() + }) + .await + .assert_type(SetErrorType::OverQuota) + .assert_description_contains("You have exceeded your quota of 1 app passwords."); + user.registry_create_object_expect_err(structs::ApiKey { + description: "Another API key".to_string(), + ..Default::default() + }) + .await + .assert_type(SetErrorType::OverQuota) + .assert_description_contains("You have exceeded your quota of 1 API keys."); + + // Set a credential expiration in the past and verify it is rejected + for (credential_id, object_type) in [ + (app_password_id, ObjectType::AppPassword), + (api_key_id, ObjectType::ApiKey), + ] { + user.registry_update_object( + object_type, + credential_id, + json!({ + Property::ExpiresAt: UTCDateTime::now() + }), + ) + .await; + } + validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await; + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", false).await; + + // Destroy the API key and app password, then verify they no longer work + for (credential_id, object_type) in [ + (app_password_id, ObjectType::AppPassword), + (api_key_id, ObjectType::ApiKey), + ] { + let response = user.registry_destroy(object_type, [credential_id]).await; + assert_eq!( + vec![credential_id], + response.destroyed_ids().collect::>() + ); + } + validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await; + validate_password_with_ip("user@example.org", &app_password_secret, "10.0.0.2", false).await; + validate_password("user@example.org", "user provided strong password", true).await; + + // Clean up + assert_eq!( + admin + .registry_destroy(ObjectType::Account, [user_id]) + .await + .destroyed_ids() + .collect::>(), + vec![user_id] + ); + validate_password("user@example.org", "user provided strong password", false).await; + + // Disable X-Forwarded-For processing + admin + .registry_update_setting( + Http { + use_x_forwarded: false, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin.reload_settings().await; + + test.cleanup().await; +} + +pub async fn validate_password(username: &str, password: &str, is_valid: bool) { + validate_password_with_ip(username, password, "127.0.0.1", is_valid).await; +} + +pub async fn validate_password_with_ip( + username: &str, + password: &str, + remote_ip: &str, + is_valid: bool, +) { + let response = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .get("https://127.0.0.1:8899/.well-known/jmap") + .basic_auth(username, Some(password)) + .header("X-Forwarded-For", remote_ip) + .send() + .await + .unwrap(); + + let status = response.status(); + if status.is_success() != is_valid { + let text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + panic!( + "Expected password to be {}. Server responded with status {}: {}", + if is_valid { "valid" } else { "invalid" }, + status, + text + ); + } +} + +pub async fn validate_token_with_ip(token: &str, remote_ip: &str, is_valid: bool) { + let response = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .get("https://127.0.0.1:8899/.well-known/jmap") + .bearer_auth(token) + .header("X-Forwarded-For", remote_ip) + .send() + .await + .unwrap(); + + let status = response.status(); + if status.is_success() != is_valid { + let text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + panic!( + "Expected token to be {}. Server responded with status {}: {}", + if is_valid { "valid" } else { "invalid" }, + status, + text + ); + } +} diff --git a/tests/src/system/authorization.rs b/tests/src/system/authorization.rs new file mode 100644 index 0000000..0eafc3a --- /dev/null +++ b/tests/src/system/authorization.rs @@ -0,0 +1,391 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use ahash::AHashMap; +use common::auth::{BuildAccessToken, permissions::DefaultPermissions}; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::Permission, + prelude::{ObjectType, Property}, + structs::{ + self, AccountSettings, Credential, CustomRoles, PasswordCredential, Role, UserAccount, + UserRoles, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use serde_json::json; +use std::str::FromStr; +use types::id::Id; + +pub async fn test(test: &mut TestServer) { + println!("Running Authorization tests..."); + + let admin = test.account("admin@example.org"); + let domain_id = admin.find_or_create_domain("example.org").await; + + // Create nested roles + let l3_role_id = admin + .registry_create_object(Role { + description: "Level 3 role".to_string(), + enabled_permissions: Map::new(vec![Permission::SysAccountSettingsGet]), + ..Default::default() + }) + .await; + let l2_role_id = admin + .registry_create_object(Role { + description: "Level 2 role".to_string(), + enabled_permissions: Map::new(vec![ + Permission::AuthenticateWithAlias, + Permission::SysAccountSettingsUpdate, + ]), + role_ids: Map::new(vec![l3_role_id]), + ..Default::default() + }) + .await; + let l1_role_id = admin + .registry_create_object(Role { + description: "Level 1 role".to_string(), + enabled_permissions: Map::new(vec![Permission::Authenticate]), + role_ids: Map::new(vec![l2_role_id]), + ..Default::default() + }) + .await; + + // Create a user with the nested role + let user_id = admin + .registry_create_object(structs::Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password".to_string(), + ..Default::default() + })]), + roles: UserRoles::Custom(CustomRoles { + role_ids: Map::new(vec![l1_role_id]), + }), + ..Default::default() + })) + .await; + let user = crate::utils::account::Account::new( + "user@example.org", + "this is a very strong password", + &[], + "User", + user_id, + ); + + // Verify user permissions include all permissions from the nested roles + user.registry_update_object( + ObjectType::AccountSettings, + Id::singleton(), + json!({ + Property::Description: "Updated description" + }), + ) + .await; + assert_eq!( + user.registry_get::(Id::singleton()) + .await + .description + .as_deref(), + Some("Updated description") + ); + + // Remove read permissions from the l3 role and verify the user can no longer read account settings + admin + .registry_update_object( + ObjectType::Role, + l3_role_id, + json!({ + Property::EnabledPermissions: {} + }), + ) + .await; + assert_eq!( + user.registry_get_many(ObjectType::AccountSettings, [Id::singleton()]) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + + // User should still be able to update account settings due to permissions from the l2 role + user.registry_update_object( + ObjectType::AccountSettings, + Id::singleton(), + json!({ + Property::Description: "Updated description v2" + }), + ) + .await; + + // Disable account settings update permission in the l3 role + admin + .registry_update_object( + ObjectType::Role, + l3_role_id, + json!({ + Property::DisabledPermissions: Map::new(vec![Permission::SysAccountSettingsUpdate]), + }), + ) + .await; + assert_eq!( + user.registry_update( + ObjectType::AccountSettings, + [( + Id::singleton(), + json!({ + Property::Description: "Updated description v3" + }) + )] + ) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + + // Assign user to the default user role + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + Property::Roles: UserRoles::User + }), + ) + .await; + + // Make sure the user does not have any administrator permissions + let permissions = DefaultPermissions::default(); + let mut num_permissions_verified = 0; + let mut num_objects_verified = 0; + let user_access_token = test + .server + .access_token(user_id.document_id()) + .await + .unwrap() + .build(); + for permission in permissions.superuser { + if permissions.user.contains(&permission) { + continue; + } + num_permissions_verified += 1; + assert!( + !user_access_token.has_permission(permission), + "User should not have {:?} permission", + permission + ); + + if let Some(name) = permission + .as_str() + .strip_prefix("sys") + .and_then(|perm| perm.strip_suffix("Get")) + { + let object_type = ObjectType::parse(name).unwrap(); + + assert_eq!( + user.registry_get_many(object_type, Vec::<&str>::new()) + .await + .method_response() + .text_field("type"), + "forbidden", + "User should not have permission to read {:?} objects", + object_type + ); + + num_objects_verified += 1; + } + } + assert_ne!( + num_permissions_verified, 0, + "No permissions were verified in the test" + ); + assert_ne!( + num_objects_verified, 0, + "No object read permissions were verified in the test" + ); + + // Deleting a linked role should not be allowed + admin + .registry_destroy_object_expect_err(ObjectType::Role, l2_role_id) + .await + .assert_type(SetErrorType::ObjectIsLinked); + + // Delete the account and roles in the correct order + admin.destroy_account(user).await; + for role_id in [l1_role_id, l2_role_id, l3_role_id] { + admin + .registry_destroy(ObjectType::Role, [role_id]) + .await + .assert_destroyed(&[role_id]); + } + + // Create test data for John and Jane + let john = test + .create_user_account( + "admin@example.org", + "john@example.org", + "this is john's secret", + &[], + "john@example.org", + ) + .await; + let jane = test + .create_user_account( + "admin@example.org", + "jane@example.org", + "this is jane's secret", + &[], + "jane@example.org", + ) + .await; + let mut john_ids = AHashMap::new(); + let mut jane_ids = AHashMap::new(); + for (account, ids) in [(&john, &mut john_ids), (&jane, &mut jane_ids)] { + let pk_id = account + .registry_create_many( + ObjectType::PublicKey, + [json!({ + Property::Description:"This is a public key", + Property::Key: SMIME_CERTIFICATE, + })], + ) + .await + .created(0) + .object_id(); + ids.insert(ObjectType::PublicKey, pk_id); + + let masked_id = account + .registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailDomain: "example.org", + })], + ) + .await + .created(0) + .object_id(); + ids.insert(ObjectType::MaskedEmail, masked_id); + } + + // John should not be able to see Jane's objects and vice versa + for (account, own_ids, other_ids) in + [(&john, &john_ids, &jane_ids), (&jane, &jane_ids, &john_ids)] + { + for (object_type, id) in own_ids { + assert_eq!( + account + .registry_query(*object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new()) + .await + .object_ids() + .collect::>(), + vec![*id] + ); + assert_eq!( + account + .registry_get_many(*object_type, Vec::<&str>::new()) + .await + .list() + .len(), + 1 + ); + } + + for (object_type, id) in other_ids { + assert_eq!( + account + .registry_get_many(*object_type, [*id]) + .await + .not_found() + .map(|id| Id::from_str(id).unwrap()) + .collect::>(), + vec![*id] + ); + + account + .registry_update_object_expect_err( + *object_type, + *id, + json!({ + Property::Description: "Hacked description" + }), + ) + .await + .assert_type(SetErrorType::NotFound); + + account + .registry_destroy_object_expect_err(*object_type, *id) + .await + .assert_type(SetErrorType::NotFound); + } + } + + // Admin should see all objects + for object_type in [ObjectType::PublicKey, ObjectType::MaskedEmail] { + let objects = admin + .registry_query(object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new()) + .await + .object_ids() + .collect::>(); + assert_eq!(objects.len(), 2); + assert!( + objects.contains(&john_ids[&object_type]) && objects.contains(&jane_ids[&object_type]), + ); + + // Filter by account id should work + let objects = admin + .registry_query( + object_type, + [(Property::AccountId, john.id().to_string())], + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(); + assert_eq!(objects, vec![john_ids[&object_type]]); + } + + // Destroy test data + for (account, ids) in [(&john, &john_ids), (&jane, &jane_ids)] { + for (object_type, id) in ids { + account + .registry_destroy(*object_type, [*id]) + .await + .assert_destroyed(&[*id]); + } + } + admin.destroy_account(john).await; + admin.destroy_account(jane).await; + + test.cleanup().await; +} + +const SMIME_CERTIFICATE: &str = "-----BEGIN CERTIFICATE----- +MIIDbjCCAlagAwIBAgIUZ4K0WXNSS8H0cUcZavD9EYqqTAswDQYJKoZIhvcNAQEN +BQAwLTErMCkGA1UEAxMiU2FtcGxlIExBTVBTIENlcnRpZmljYXRlIEF1dGhvcml0 +eTAgFw0xOTExMjAwNjU0MThaGA8yMDUyMDkyNzA2NTQxOFowGTEXMBUGA1UEAxMO +QWxpY2UgTG92ZWxhY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD +7q35ZdG2JAzzJGNZDZ9sV7AKh0hlRfoFjTZN5m4RegQAYSyag43ouWi1xRN0avf0 +UTYrwjK04qRdV7GzCACoEKq/xiNUOsjfJXzbCublN3fZMOXDshKKBqThlK75SjA9 +Czxg7ejGoiY/iidk0e91neK30SCCaBTJlfR2ZDrPk73IPMeksxoTatfF9hw9dDA+ +/Hi1yptN/aG0Q/s9icFrxr6y2zQXsjuQPmjMZgj10aD9cazWVgRYCgflhmA0V1uQ +l1wobYU8DAVxVn+GgabqyjGQMoythIK0Gn5+ofwxXXUM/zbU+g6+1ISdoXxRRFtq +2GzbIqkAHZZQm+BbnFrhAgMBAAGjgZcwgZQwDAYDVR0TAQH/BAIwADAeBgNVHREE +FzAVgRNhbGljZUBzbWltZS5leGFtcGxlMBMGA1UdJQQMMAoGCCsGAQUFBwMEMA8G +A1UdDwEB/wQFAwMHoAAwHQYDVR0OBBYEFKwuVFqk/VUYry7oZkQ40SXR1wB5MB8G +A1UdIwQYMBaAFLdSTXPAiD2yw3paDPOU9/eAonfbMA0GCSqGSIb3DQEBDQUAA4IB +AQB76o4Yz7yrVSFcpXqLrcGtdI4q93aKCXECCCzNQLp4yesh6brqaZHNJtwYcJ5T +qbUym9hJ70iJE4jGNN+yAZR1ltte0HFKYIBKM4EJumG++2hqbUaLz4tl06BHaQPC +v/9NiNY7q9R9c/B6s1YzHhwqkWht2a+AtgJ4BkpG+g+MmZMQV/Ao7RwLFKJ9OlMW +LBmEXFcpIJN0HpPasT0nEl/MmotSu+8RnClAi3yFfyTKb+8rD7VxuyXetqDZ6dU/ +9/iqD/SZS7OQIjywtd343mACz3B1RlFxMHSA6dQAf2btGumqR0KiAp3KkYRAePoa +JqYkB7Zad06ngFl0G0FHON+7 +-----END CERTIFICATE----- +"; diff --git a/tests/src/system/crypto.rs b/tests/src/system/crypto.rs new file mode 100644 index 0000000..eca57aa --- /dev/null +++ b/tests/src/system/crypto.rs @@ -0,0 +1,289 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer, smtp::SmtpConnection}; +use common::{ + auth::{ + ACCOUNT_FLAG_ENCRYPT_ALGO_AES128, ACCOUNT_FLAG_ENCRYPT_ALGO_AES256, + ACCOUNT_FLAG_ENCRYPT_METHOD_PGP, ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME, + }, + storage::encryption::{EncryptionMethod, parse_public_key}, +}; +use email::message::crypto::EncryptMessage; +use mail_parser::{MessageParser, MimeHeaders}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{EncryptionAtRest, EncryptionSettings, PublicKey}, +}; +use serde_json::json; +use std::path::PathBuf; +use types::id::Id; + +pub async fn test(test: &mut TestServer) { + println!("Running Encryption-at-rest tests..."); + + // Check encryption + check_is_encrypted(); + import_certs_and_encrypt().await; + + // Create test account + let account = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &[], + "jdoe@example.org", + ) + .await; + let client = account.jmap_client().await; + + // Import all certs + let mut cert_ids = Vec::new(); + let mut certs_parsed = Vec::new(); + for cert_file in ["cert_smime.pem", "cert_pgp.pem"] { + let certs = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("crypto") + .join(cert_file), + ) + .unwrap(); + + let params = parse_public_key(&PublicKey { + description: cert_file.to_string(), + key: certs.clone(), + ..Default::default() + }) + .unwrap() + .unwrap(); + certs_parsed.push(params.certs); + + let cert_id = account + .registry_create_many( + ObjectType::PublicKey, + [json!({ + Property::Description: "This is a public key", + Property::Key: certs + })], + ) + .await + .created(0) + .object_id(); + + cert_ids.push(cert_id); + } + + // Update encryption at rest settings + account + .registry_update_object( + ObjectType::AccountSettings, + Id::singleton(), + json!({ + Property::EncryptionAtRest: EncryptionAtRest::Aes256(EncryptionSettings { + allow_spam_training: true, + encrypt_on_append: true, + public_key: cert_ids[1], + }) + }), + ) + .await; + assert_eq!( + test.server + .account(account.id().document_id()) + .await + .unwrap() + .encryption_key + .as_ref() + .unwrap(), + &certs_parsed[1] + ); + + // Send a new message, which should be encrypted + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report (should be encrypted)\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + // Send an encrypted message + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report (already encrypted)\r\n", + "Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\r\n", + "\r\n", + "xjMEZMYfNhYJKwYBBAHaRw8BAQdAYyTN1HzqapLw8xwkCGwa0OjsgT/JqhcB/+Dy", + "Ga1fsBrNG0pvaG4gRG9lIDxqb2huQGV4YW1wbGUub3JnPsKJBBMWCAAxFiEEg836", + "pwbXpuQ/THMtpJwd4oBfIrUFAmTGHzYCGwMECwkIBwUVCAkKCwUWAgMBAAAKCRCk", + "nB3igF8itYhyAQD2jEdeYa3gyQ47X9YWZTK1wEJkN8W9//V1fYl2XQwqlQEA0qBv", + "Ai6nUh99oDw+/zQ8DFIKdeb5Ti4tu/X58PdpiQ7OOARkxh82EgorBgEEAZdVAQUB", + "AQdAvXz2FbFN0DovQF/ACnZyczTsSIQp0mvmF1PE+aijbC8DAQgHwngEGBYIACAW", + "IQSDzfqnBtem5D9Mcy2knB3igF8itQUCZMYfNgIbDAAKCRCknB3igF8itRnoAQC3", + "GzPmgx7TnB+SexPuJV/DoKSMJ0/X+hbEFcZkulxaDQEAh+xiJCvf+ZNAKw6kFhsL", + "UuZhEDktxnY6Ehz3aB7FawA=", + "=KGrr", + ), + ) + .await; + + // Disable encryption + account + .registry_update_object( + ObjectType::AccountSettings, + Id::singleton(), + json!({ + Property::EncryptionAtRest: EncryptionAtRest::Disabled + }), + ) + .await; + + // Send a new message, which should NOT be encrypted + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report (plain text)\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + // Check messages + let mut request = client.build(); + request.get_email(); + let emails = request.send_get_email().await.unwrap().take_list(); + assert_eq!(emails.len(), 3, "3 messages were expected: {:#?}.", emails); + + for email in emails { + let message = + String::from_utf8(client.download(email.blob_id().unwrap()).await.unwrap()).unwrap(); + if message.contains("should be encrypted") { + assert!( + message.contains("Content-Type: multipart/encrypted"), + "got message {message}, expected encrypted message" + ); + } else if message.contains("already encrypted") { + assert!( + message.contains("Content-Type: application/pkcs7-mime") + && message.contains("xjMEZMYfNhYJKwYBBAHaRw8BAQdAYy"), + "got message {message}, expected message to be left intact" + ); + } else if message.contains("plain text") { + assert!( + message.contains("I'm going to need those TPS reports ASAP."), + "got message {message}, expected plain text message" + ); + } else { + panic!("Unexpected message: {:#?}", message) + } + } + + test.account("admin@example.org") + .destroy_account(account) + .await; + test.cleanup().await; +} + +pub async fn import_certs_and_encrypt() { + for (name, method) in [ + ("cert_pgp.pem", EncryptionMethod::PGP), + //("cert_pgp.der", EncryptionMethod::PGP), + ("cert_smime.pem", EncryptionMethod::SMIME), + //("cert_smime.der", EncryptionMethod::SMIME), + ] { + let pk = PublicKey { + description: name.to_string(), + key: String::from_utf8( + std::fs::read( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("crypto") + .join(name), + ) + .unwrap(), + ) + .unwrap(), + ..Default::default() + }; + + let params = parse_public_key(&pk).unwrap().unwrap(); + assert_eq!(params.method, method); + + for mut flags in [ + ACCOUNT_FLAG_ENCRYPT_ALGO_AES128, + ACCOUNT_FLAG_ENCRYPT_ALGO_AES256, + ] { + let message = MessageParser::new() + .parse(b"Subject: test\r\ntest\r\n") + .unwrap(); + assert!(!message.is_encrypted()); + flags |= match method { + EncryptionMethod::PGP => ACCOUNT_FLAG_ENCRYPT_METHOD_PGP, + EncryptionMethod::SMIME => ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME, + }; + message.encrypt(¶ms.certs, flags).await.unwrap(); + } + } + + // S/MIME and PGP should not be allowed mixed + assert!( + parse_public_key(&PublicKey { + description: "err".into(), + key: String::from_utf8( + std::fs::read( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("crypto") + .join("cert_mixed.pem"), + ) + .unwrap() + ) + .unwrap(), + ..Default::default() + }) + .is_err() + ); +} + +pub fn check_is_encrypted() { + let messages = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("crypto") + .join("is_encrypted.txt"), + ) + .unwrap(); + + for raw_message in messages.split("!!!") { + let is_encrypted = raw_message.contains("TRUE"); + let message = MessageParser::new() + .parse(raw_message.trim().as_bytes()) + .unwrap(); + assert!(message.content_type().is_some()); + assert_eq!( + message.is_encrypted(), + is_encrypted, + "failed for {raw_message}" + ); + } +} diff --git a/tests/src/system/delivery.rs b/tests/src/system/delivery.rs new file mode 100644 index 0000000..47ee5cb --- /dev/null +++ b/tests/src/system/delivery.rs @@ -0,0 +1,705 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + account::Account, imap::AssertResult, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection, +}; +use common::{Server, auth::BuildAccessToken}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + mailbox::{INBOX_ID, JUNK_ID, SENT_ID}, + message::metadata::MessageMetadata, +}; +use groupware::DavResourceName; +use jmap::blob::download::BlobDownload; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::StorageQuota, + prelude::{ObjectType, Property}, + structs::{ + EmailAlias, Expression, MailingList, MtaExtensions, SpamTag, SpamTagScore, + SpamTrainingSample, + }, + }, + types::{EnumImpl, datetime::UTCDateTime, float::Float, list::List, map::Map}, +}; +use serde_json::json; +use std::time::Duration; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, now}, +}; +use types::{ + blob::{BlobClass, BlobId}, + collection::Collection, + field::EmailField, + id::Id, +}; +use utils::chained_bytes::ChainedBytes; + +pub async fn test(test: &mut TestServer) { + println!("Running Email delivery tests..."); + let admin = test.account("admin@example.org"); + + // Prepare tests + admin + .registry_create_object(SpamTag::Score(SpamTagScore { + score: Float::new(1000.0), + tag: "GTUBE_TEST".to_string(), + })) + .await; + admin + .registry_update_setting( + MtaExtensions { + expn: Expression { + else_: "true".to_string(), + ..Default::default() + }, + vrfy: Expression { + else_: "true".to_string(), + ..Default::default() + }, + ..Default::default() + }, + &[Property::Expn, Property::Vrfy], + ) + .await; + admin.reload_settings().await; + + // Create a domain name and a test account + let john = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &["john.doe@example.org"], + "jdoe@example.org", + ) + .await; + let jane = test + .create_user_account( + "admin@example.org", + "jane.smith@example.org", + "this is a very strong password", + &[], + "jane.smith@example.org", + ) + .await; + let bill = test + .create_user_account( + "admin@example.org", + "bill@example.org", + "this is a very strong password", + &[], + "bill@example.org", + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + john.id(), + json!({ + Property::Quotas: {StorageQuota::MaxMaskedAddresses.as_str(): 2} + }), + ) + .await; + + // Create a mailing list + let domain_id = admin.find_or_create_domain("example.org").await; + let list_id = admin + .registry_create_object(MailingList { + name: "members".to_string(), + recipients: Map::new(vec![ + "jdoe@example.org".to_string(), + "jane.smith@example.org".to_string(), + "bill@example.org".to_string(), + ]), + aliases: List::from_iter([EmailAlias { + name: "corporate".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + domain_id, + ..Default::default() + }) + .await; + + // Delivering to individuals + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + + assert_eq!(john_cache.emails.items.len(), 1); + assert_eq!(john_cache.in_mailbox(INBOX_ID).count(), 1); + assert_eq!(john_cache.in_mailbox(JUNK_ID).count(), 0); + + // Make sure there are no spam training samples + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + assert!( + admin + .registry_query( + ObjectType::SpamTrainingSample, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .ids() + .next() + .is_none() + ); + + // Masked email tests + john.registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailDomain: "invalid.org" + })], + ) + .await + .not_created(0) + .to_set_error() + .assert_type(SetErrorType::Forbidden) + .assert_description_contains("The specified domain is not valid for this account."); + + let response = john + .registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailDomain: "example.org", + Property::EmailPrefix: "secretive", + Property::ExpiresAt: UTCDateTime::from_timestamp((now() + 1) as i64) + })], + ) + .await; + let masked = response.created(0); + let masked_prefix_id = masked.object_id(); + let masked_prefix_email = masked.text_field("email").to_string(); + assert!( + masked_prefix_email.starts_with("secretive") + && masked_prefix_email.ends_with("@example.org"), + "Unexpected masked email: {masked_prefix_email}" + ); + + let response = john + .registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailDomain: "example.org", + })], + ) + .await; + let masked = response.created(0); + let masked_random_id = masked.object_id(); + let masked_random_email = masked.text_field("email").to_string(); + assert!( + masked_random_email.contains(".") && masked_random_email.ends_with("@example.org"), + "Unexpected masked email: {masked_random_email}" + ); + + john.registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailDomain: "example.org", + })], + ) + .await + .not_created(0) + .to_set_error() + .assert_type(SetErrorType::OverQuota); + + // Test spam filtering using masked email + lmtp.ingest( + "bill@example.org", + &[masked_prefix_email.as_str()], + concat!( + "From: bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 2); + assert_eq!(inbox_ids.len(), 1); + assert_eq!(junk_ids.len(), 1); + assert_message_headers_contains( + &test.server, + john.id().document_id(), + junk_ids.min().unwrap(), + "X-Spam-Status: Yes", + ) + .await; + assert_eq!(john.spam_training_samples().await, vec![]); + + // CardDAV spam override, using masked email + let dav_client = john.webdav_client(); + dav_client + .request( + "PUT", + &format!( + "{}/jdoe%40example.org/default/bill.vcf", + DavResourceName::Card.base_path() + ), + r#"BEGIN:VCARD +VERSION:4.0 +FN:Bill Foobar +EMAIL;TYPE=WORK:dmarc-bill@example.org +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686f +END:VCARD +"#, + ) + .await + .with_status(hyper::StatusCode::CREATED); + lmtp.ingest( + "dmarc-bill@example.org", + &[masked_random_email.as_str()], + concat!( + "From: dmarc-bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 3); + assert_eq!(inbox_ids.len(), 2); + assert_eq!(junk_ids.len(), 1); + dav_client.delete_default_containers().await; + assert_message_headers_contains( + &test.server, + john.id().document_id(), + inbox_ids.max().unwrap(), + "X-Spam-Status: No, reason=card-exists", + ) + .await; + let samples = john.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0); + + // Test trusted reply override + john.jmap_client() + .await + .email_import( + concat!( + "From: john.doe@example.org\r\n", + "To: dmarc-bill@example.org\r\n", + "Message-ID: \r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "This is a trusted reply." + ) + .as_bytes() + .to_vec(), + vec![Id::from(SENT_ID).to_string()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + assert_eq!( + test.server + .get_cached_messages(john.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + 4 + ); + lmtp.ingest( + "dmarc-bill@example.org", + &["john.doe@example.org"], + concat!( + "From: dmarc-bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Message-ID: \r\n", + "References: \r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 5); + assert_eq!(inbox_ids.len(), 3); + assert_eq!(junk_ids.len(), 1); + assert_message_headers_contains( + &test.server, + john.id().document_id(), + inbox_ids.max().unwrap(), + "X-Spam-Status: No, reason=trusted-reply", + ) + .await; + let samples = john.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 2); + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0); + + // EXPN and VRFY + lmtp.expn("members@example.org", 2) + .await + .assert_contains("jdoe@example.org") + .assert_contains("jane.smith@example.org") + .assert_contains("bill@example.org"); + lmtp.expn("non_existant@example.org", 5).await; + lmtp.expn("jdoe@example.org", 5).await; + lmtp.vrfy("jdoe@example.org", 2).await; + lmtp.vrfy("members@example.org", 5).await; + lmtp.vrfy("non_existant@example.org", 5).await; + lmtp.vrfy(masked_random_email.as_str(), 2).await; + lmtp.vrfy(masked_prefix_email.as_str(), 5).await; // Should have expired + + // Delivering to a mailing list + lmtp.ingest( + "bill@example.org", + &["members@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: members@example.org\r\n", + "Subject: WFH policy\r\n", + "\r\n", + "We need the entire staff back in the office, ", + "TPS reports cannot be filed properly from home." + ), + ) + .await; + + tokio::time::sleep(Duration::from_millis(200)).await; + + for (account, num_messages) in [(&john, 6), (&jane, 1), (&bill, 1)] { + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + num_messages, + "for {}", + account.id_string() + ); + } + + // Removing members from the mailing list and chunked ingest + admin + .registry_update_object( + ObjectType::MailingList, + list_id, + json!({ + "recipients/jdoe@example.org": false + }), + ) + .await; + lmtp.ingest_chunked( + "bill@example.org", + &["members@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: members@example.org\r\n", + "Subject: WFH policy (reminder)\r\n", + "\r\n", + "This is a reminder that we need the entire staff back in the office, ", + "TPS reports cannot be filed properly from home." + ), + 10, + ) + .await; + + for (account, num_messages) in [(&john, 6), (&jane, 2), (&bill, 2)] { + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + num_messages, + "for {}", + account.id_string() + ); + } + + // Deduplication of recipients + lmtp.ingest( + "bill@example.org", + &[ + "members@example.org", + "jdoe@example.org", + "john.doe@example.org", + "jane.smith@example.org", + "bill@example.org", + ], + concat!( + "From: bill@example.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your TPS reports before ", + "going on holidays." + ), + ) + .await; + + // Make sure blobs are properly linked + test.blob_expire_all().await; + + for (account, num_messages) in [(&john, 7), (&jane, 3), (&bill, 3)] { + let account_id = account.id().document_id(); + let cache = test.server.get_cached_messages(account_id).await.unwrap(); + assert_eq!( + cache.emails.items.len(), + num_messages, + "for {}", + account.id_string() + ); + let access_token = test.server.access_token(account_id).await.unwrap().build(); + + for document_id in cache.in_mailbox(INBOX_ID).map(|e| e.document_id) { + let metadata = message_metadata(&test.server, account_id, document_id).await; + let partial_message = test + .server + .blob_store() + .get_blob(metadata.blob_hash.0.as_ref(), 0..usize::MAX) + .await + .unwrap() + .unwrap(); + + assert_ne!(metadata.blob_body_offset, 0); + let expected_full_message = String::from_utf8( + ChainedBytes::new(metadata.raw_headers.as_ref()) + .with_last( + partial_message + .get(metadata.blob_body_offset as usize..) + .unwrap_or_default(), + ) + .to_bytes(), + ) + .unwrap(); + assert!( + expected_full_message.contains("Delivered-To:") + && expected_full_message.contains("Subject:"), + "for {account_id}: {expected_full_message}" + ); + let full_message = String::from_utf8( + test.server + .blob_download( + &BlobId { + hash: metadata.blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::Email.into(), + document_id, + }, + section: None, + }, + &access_token, + ) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(full_message, expected_full_message, "for {account_id}"); + } + } + + // Sub-addressed local members must resolve when the list is expanded + admin + .registry_create_object(MailingList { + name: "tps-reports".to_string(), + recipients: Map::new(vec!["bill+tps@example.org".to_string()]), + domain_id, + ..Default::default() + }) + .await; + + let bill_messages = test + .server + .get_cached_messages(bill.id().document_id()) + .await + .unwrap() + .emails + .items + .len(); + + lmtp.ingest( + "john.doe@example.org", + &["tps-reports@example.org"], + concat!( + "From: john.doe@example.org\r\n", + "To: tps-reports@example.org\r\n", + "Subject: Cover sheet\r\n", + "\r\n", + "Did you get the memo about the new cover sheet?" + ), + ) + .await; + + tokio::time::sleep(Duration::from_millis(200)).await; + + assert_eq!( + test.server + .get_cached_messages(bill.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + bill_messages + 1, + "sub-addressed mailing list member was not delivered" + ); + + // Remove test data + john.registry_destroy( + ObjectType::MaskedEmail, + [masked_prefix_id, masked_random_id], + ) + .await + .assert_destroyed(&[masked_prefix_id, masked_random_id]); + for account in [&john, &jane, &bill] { + test.destroy_all_mailboxes(account).await; + } + admin.registry_destroy_all(ObjectType::MailingList).await; + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + admin.registry_destroy_all(ObjectType::SpamTag).await; + test.assert_is_empty().await; + + for account in [john, jane, bill] { + admin.destroy_account(account).await; + } + + test.cleanup().await; +} + +impl Account { + pub async fn spam_training_sample_ids(&self) -> Vec { + self.registry_query_ids( + ObjectType::SpamTrainingSample, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + } + + pub async fn spam_training_samples(&self) -> Vec<(Id, SpamTrainingSample)> { + self.registry_get_all().await + } +} + +async fn assert_message_headers_contains( + server: &Server, + account_id: u32, + document_id: u32, + value: &str, +) { + let headers = message_headers(server, account_id, document_id).await; + assert!( + headers.contains(value), + "Expected message headers to contain {:?}, got {:?}", + value, + headers + ); +} + +async fn message_headers(server: &Server, account_id: u32, document_id: u32) -> String { + std::str::from_utf8( + message_metadata(server, account_id, document_id) + .await + .raw_headers + .as_ref(), + ) + .unwrap() + .to_string() +} + +async fn message_metadata(server: &Server, account_id: u32, document_id: u32) -> MessageMetadata { + server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await + .unwrap() + .unwrap() + .deserialize::() + .unwrap() +} diff --git a/tests/src/system/directory.rs b/tests/src/system/directory.rs new file mode 100644 index 0000000..fbc54c9 --- /dev/null +++ b/tests/src/system/directory.rs @@ -0,0 +1,680 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use common::{ + auth::{ACCOUNT_IS_USER, EmailAddress, EmailCache}, + network::RcptResolution, +}; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::{AccountType, StorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + Account, CertificateManagement, Credential, DkimManagement, DnsManagement, Domain, + EmailAlias, Expression, ExpressionMatch, GroupAccount, MailingList, PasswordCredential, + SubAddressing, SubAddressingCustom, UserAccount, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use serde_json::json; +use std::sync::Arc; +use utils::map::vec_map::VecMap; + +pub async fn test(test: &TestServer) { + println!("Running Directory tests..."); + let account = test.account("admin@example.org"); + + // Create a domain and make sure it's in the cache + let domain_id = account + .registry_create_object(Domain { + name: "example.com".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + aliases: Map::new(vec!["beispiel.de".to_string()]), + is_enabled: true, + catch_all_address: Some("catchy@example.com".to_string()), + sub_addressing: SubAddressing::Enabled, + ..Default::default() + }) + .await; + let domain_cache = test + .server + .domain_by_id(domain_id.document_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + &domain_cache.names, + &Box::from_iter(["example.com".into(), "beispiel.de".into()]) + ); + assert_eq!(domain_cache.id, domain_id.document_id()); + assert_eq!( + domain_cache.catch_all.as_deref(), + Some("catchy@example.com") + ); + + // Multiple domains with the same name should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.com".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // An alias matching another domain's name should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.net".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + aliases: Map::new(vec!["example.com".to_string()]), + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // An alias matching another domain's alias should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.net".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + aliases: Map::new(vec!["beispiel.de".to_string()]), + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // A domain name matching another domain's alias should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "beispiel.de".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // Invalid local part should not be allowed + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "!invalid".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "hello world".to_string(), + ..Default::default() + })]), + aliases: List::from_iter([EmailAlias { + name: "inva..lid".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidPatch) + .assert_description_contains("Invalid email local part"); + + // Valid account creation with local part sanitization + let account_id = account + .registry_create_object(Account::User(UserAccount { + name: " john doe".to_string(), + domain_id, + description: "John 'Johnny-D' Doe".to_string().into(), + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "hello world".to_string(), + ..Default::default() + })]), + aliases: List::from_iter([EmailAlias { + name: "jdoe".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + quotas: VecMap::from_iter([ + (StorageQuota::MaxDiskQuota, 1024u64), + (StorageQuota::MaxEmails, 100u64), + ]), + ..Default::default() + })) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert_eq!(account_cache.name.as_ref(), "johndoe@example.com"); + assert_eq!( + account_cache.description.as_deref(), + Some("John 'Johnny-D' Doe") + ); + assert_eq!(account_cache.id, account_id.document_id()); + assert_eq!(account_cache.quota_disk, 1024); + assert_eq!( + account_cache + .quota_objects + .as_ref() + .unwrap() + .get(StorageQuota::MaxEmails), + 100 + ); + assert_eq!( + account_cache.addresses, + vec![ + EmailAddress { + local_part: "johndoe".into(), + domain_id: domain_id.document_id(), + }, + EmailAddress { + local_part: "jdoe".into(), + domain_id: domain_id.document_id(), + } + ] + .into_boxed_slice() + ); + assert!(account_cache.flags & ACCOUNT_IS_USER != 0); + + // Duplicate account names should not be allowed + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "johndoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(Account::Group(GroupAccount { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(MailingList { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // Create a group and add it to the account + let group_id = account + .registry_create_object(Account::Group(GroupAccount { + name: "sales".to_string(), + domain_id, + ..Default::default() + })) + .await; + let account_cache = test.server.account(group_id.document_id()).await.unwrap(); + assert_eq!(account_cache.name.as_ref(), "sales@example.com"); + assert!(account_cache.flags & ACCOUNT_IS_USER == 0); + account + .registry_update_object( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + group_id: true + } + }), + ) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert_eq!( + account_cache.id_member_of.as_ref(), + &[group_id.document_id()] + ); + + // Linking invalid groups should not be allowed + account + .registry_update_object_expect_err( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + account_id: true + } + }), + ) + .await + .assert_type(SetErrorType::InvalidForeignKey); + + // Remove the group membership and make sure it's gone + account + .registry_update_object( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + group_id: false + } + }), + ) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert!(account_cache.id_member_of.as_ref().is_empty()); + + // Create a masked email + let john = crate::utils::account::Account::new( + "johndoe@example.com", + "hello world", + &[], + "John", + account_id, + ); + let response = john + .registry_create_many( + ObjectType::MaskedEmail, + [json!({ + Property::EmailPrefix: "test", + })], + ) + .await; + let masked = response.created(0); + let masked_id = masked.object_id(); + let masked_email = masked.text_field("email").to_string(); + assert_eq!( + test.server + .account_id_from_email("johndoe@example.com", true) + .await + .unwrap(), + Some(account_id.document_id()) + ); + assert_eq!( + test.server + .account_id_from_email(&masked_email, true) + .await + .unwrap(), + Some(account_id.document_id()) + ); + + // Create a mailing list + let list_id = account + .registry_create_object(MailingList { + name: "newsletter".to_string(), + domain_id, + recipients: Map::new(vec!["jdoe@example.com".to_string()]), + ..Default::default() + }) + .await; + let list_cache = test + .server + .try_list(list_id.document_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + &list_cache.recipients, + &Arc::from(Box::from_iter(["jdoe@example.com".into()])) + ); + + // Update mailing list + account + .registry_update_object( + ObjectType::MailingList, + list_id, + json!({ + "recipients/sales@example.com": true + }), + ) + .await; + let list_cache = test + .server + .try_list(list_id.document_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + &list_cache.recipients, + &Arc::from(Box::from_iter([ + "jdoe@example.com".into(), + "sales@example.com".into() + ])) + ); + + // Verify that alias domains resolve without the primary name warming the cache + for address in [ + "johndoe@beispiel.de", + "sales@beispiel.de", + "newsletter@beispiel.de", + ] { + test.server.invalidate_all_local_caches(); + assert!( + test.server.domain("beispiel.de").await.unwrap().is_some(), + "Alias domain failed to resolve on a cold cache" + ); + + test.server.invalidate_all_local_caches(); + assert!( + test.server + .rcpt_id_from_email(address) + .await + .unwrap() + .is_some(), + "Cold cache resolution failed for {address}" + ); + } + + // Verify RCPT expansion + for (address, expected) in [ + ( + "johndoe@example.com", + EmailCache::Account(account_id.document_id()), + ), + ( + "jdoe@example.com", + EmailCache::Account(account_id.document_id()), + ), + ( + "johndoe@beispiel.de", + EmailCache::Account(account_id.document_id()), + ), + ( + "jdoe@beispiel.de", + EmailCache::Account(account_id.document_id()), + ), + ( + "sales@example.com", + EmailCache::Account(group_id.document_id()), + ), + ( + "sales@beispiel.de", + EmailCache::Account(group_id.document_id()), + ), + ( + "newsletter@example.com", + EmailCache::MailingList(list_id.document_id()), + ), + ( + "newsletter@beispiel.de", + EmailCache::MailingList(list_id.document_id()), + ), + ] { + assert_eq!( + test.server.rcpt_id_from_email(address).await.unwrap(), + Some(expected), + "Unexpected result for address: {address}" + ); + } + assert_eq!( + test.server + .rcpt_id_from_email("unknown@example.com") + .await + .unwrap(), + None + ); + assert_eq!( + test.server + .rcpt_id_from_email("unknown@unknown.com") + .await + .unwrap(), + None + ); + + // MTA rcpt resolve + let domain_2_id = account + .registry_create_object(Domain { + name: "another-example.com".to_string(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + sub_addressing: SubAddressing::Custom(SubAddressingCustom { + custom_rule: Expression { + else_: "false".to_string(), + match_: List::from_iter([ExpressionMatch { + if_: "matches('^([^.]+)\\.([^.]+)', rcpt)".to_string(), + then: "$1".to_string(), + }]), + }, + }), + ..Default::default() + }) + .await; + let account_2_id = account + .registry_create_object(Account::User(UserAccount { + name: "subaddresser".to_string(), + domain_id: domain_2_id, + ..Default::default() + })) + .await; + assert_eq!( + test.server.rcpt_resolve("unknown", true, 0).await.unwrap(), + RcptResolution::UnknownDomain + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@unknown.org", true, 0) + .await + .unwrap(), + RcptResolution::UnknownDomain + ); + assert_eq!( + test.server + .rcpt_resolve("jdoe@example.com", true, 0) + .await + .unwrap(), + RcptResolution::Accept + ); + assert_eq!( + test.server + .rcpt_resolve("johndoe@beispiel.de", true, 0) + .await + .unwrap(), + RcptResolution::Accept + ); + assert_eq!( + test.server + .rcpt_resolve("sales@example.com", true, 0) + .await + .unwrap(), + RcptResolution::Accept + ); + assert_eq!( + test.server + .rcpt_resolve("jdoe+promotions@example.com", true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("jdoe@example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("newsletter@example.com", true, 0) + .await + .unwrap(), + RcptResolution::Expand(Arc::from(Box::from_iter([ + "jdoe@example.com".into(), + "sales@example.com".into() + ]))) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@example.com", true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("catchy@example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@example.com", false, 0) + .await + .unwrap(), + RcptResolution::UnknownRecipient + ); + assert_eq!( + test.server + .rcpt_resolve("jdoe+promotions@example.com", false, 0) + .await + .unwrap(), + RcptResolution::Rewrite("jdoe@example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("subaddresser.ignoreme@another-example.com", true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("subaddresser@another-example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@another-example.com", true, 0) + .await + .unwrap(), + RcptResolution::UnknownRecipient + ); + assert_eq!( + test.server + .rcpt_resolve(masked_email.as_str(), true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("johndoe@example.com".into()) + ); + + // Catch-all addresses have to be resolved rather than accepted verbatim + let domain_3_id = account + .registry_create_object(Domain { + name: "list-catch-all.com".to_string(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + catch_all_address: Some("newsletter@example.com".to_string()), + ..Default::default() + }) + .await; + let domain_4_id = account + .registry_create_object(Domain { + name: "subaddress-catch-all.com".to_string(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + catch_all_address: Some("jdoe+catchall@example.com".to_string()), + ..Default::default() + }) + .await; + assert_eq!( + test.server + .rcpt_resolve("unknown@list-catch-all.com", true, 0) + .await + .unwrap(), + RcptResolution::Expand(Arc::from(Box::from_iter([ + "jdoe@example.com".into(), + "sales@example.com".into() + ]))) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@subaddress-catch-all.com", true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("jdoe@example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@list-catch-all.com", false, 0) + .await + .unwrap(), + RcptResolution::UnknownRecipient + ); + + // Query tests + assert_eq!( + account + .registry_query_ids( + ObjectType::Domain, + [(Property::Name, "example.com")], + [Property::Name] + ) + .await, + vec![domain_id] + ); + assert_eq!( + account + .registry_query_ids( + ObjectType::Account, + [ + (Property::Name, "johndoe"), + (Property::Type, AccountType::User.as_str()), + (Property::Text, "johnny") + ], + [Property::Name] + ) + .await, + vec![account_id] + ); + + // Delete everything + john.registry_destroy(ObjectType::MaskedEmail, [masked_id]) + .await + .assert_destroyed(&[masked_id]); + account + .registry_destroy(ObjectType::MailingList, [list_id]) + .await + .assert_destroyed(&[list_id]); + account + .registry_destroy(ObjectType::Account, [group_id, account_id, account_2_id]) + .await + .assert_destroyed(&[group_id, account_id, account_2_id]); + account + .registry_destroy( + ObjectType::Domain, + [domain_id, domain_2_id, domain_3_id, domain_4_id], + ) + .await + .assert_destroyed(&[domain_id, domain_2_id, domain_3_id, domain_4_id]); + assert!( + test.server + .try_list(list_id.document_id()) + .await + .unwrap() + .is_none() + ); + assert!( + test.server + .try_account(account_id.document_id()) + .await + .unwrap() + .is_none() + ); + assert!( + test.server + .try_account(group_id.document_id()) + .await + .unwrap() + .is_none() + ); + assert!(test.server.domain("example.com").await.unwrap().is_none()); + assert!( + test.server + .domain("another-example.com") + .await + .unwrap() + .is_none() + ); + + test.cleanup().await; +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs new file mode 100644 index 0000000..a287321 --- /dev/null +++ b/tests/src/system/mod.rs @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod antispam; +pub mod authentication; +pub mod authorization; +pub mod crypto; +pub mod delivery; +pub mod directory; +pub mod oidc; +pub mod purge; +pub mod quota; +pub mod security; +pub mod task; + +use crate::utils::server::TestServerBuilder; +use registry::schema::structs::{Expression, Imap, MtaStageAuth}; + +#[tokio::test(flavor = "multi_thread")] +pub async fn system_tests() { + let mut test = TestServerBuilder::new("system_tests") + .await + .with_default_listeners() + .await + .with_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await + .with_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + + // Create admin account + let admin = test + .create_user_account( + "admin", + "admin@example.org", + "these_pretzels_are_making_me_thirsty", + &[], + "Admin", + ) + .await; + test.account("admin") + .assign_roles_to_account(admin.id(), &["user", "system"]) + .await; + test.insert_account(admin); + + directory::test(&test).await; + authentication::test(&test).await; + oidc::test(&mut test).await; + authorization::test(&mut test).await; + tenant::test(&mut test).await; + security::test(&mut test).await; + quota::test(&mut test).await; + purge::test(&mut test).await; + delivery::test(&mut test).await; + crypto::test(&mut test).await; + antispam::test(&mut test).await; + archiving::test(&mut test).await; + task::test(&mut test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs new file mode 100644 index 0000000..41d783d --- /dev/null +++ b/tests/src/system/oidc.rs @@ -0,0 +1,1131 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + http::HttpRequest, + imap::{ImapConnection, Type}, + pop3::Pop3Connection, + server::TestServer, + smtp::SmtpConnection, +}; +use base64::{Engine, engine::general_purpose}; +use biscuit::{JWT, SingleOrMultiple, jwk::JWKSet}; +use bytes::Bytes; +use common::auth::oauth::{ + introspect::OAuthIntrospect, + oidc::StandardClaims, + registration::{ + ClientRegistrationRequest, ClientRegistrationResponse, TokenEndpointAuthMethod, + }, +}; +use http::auth::oauth::{ + DeviceAuthResponse, ErrorType, TokenResponse, + auth::{LoginRequest, LoginResponse}, +}; +use imap_proto::ResponseType; +use jmap_client::{ + client::{Client, Credentials}, + mailbox::query::Filter, +}; +use registry::schema::{ + enums::JwtSignatureAlgorithm, + prelude::{ObjectType, Property}, + structs::{OAuthClient, OidcProvider, SecretText, SecretTextValue}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::time::{Duration, Instant}; +use store::ahash::AHashMap; + +#[derive(Debug, serde::Deserialize)] +pub struct OAuthMetadata { + pub issuer: String, + pub token_endpoint: String, + pub authorization_endpoint: String, + pub device_authorization_endpoint: String, + pub registration_endpoint: String, + pub introspection_endpoint: String, + pub grant_types_supported: Vec, + pub response_types_supported: Vec, + pub scopes_supported: Vec, + pub token_endpoint_auth_methods_supported: Vec, + pub code_challenge_methods_supported: Vec, + pub authorization_response_iss_parameter_supported: bool, +} + +#[derive(Debug, serde::Deserialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Vec, + pub scopes_supported: Vec, + pub bearer_methods_supported: Vec, +} + +const PKCE_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const PKCE_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; +const PROFILE_SCOPE: &str = "urn:ietf:params:oauth:scope:mail offline_access"; + +#[derive(Debug, serde::Deserialize)] +pub struct OpenIdMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, + pub jwks_uri: String, + pub registration_endpoint: String, + pub device_authorization_endpoint: String, + pub scopes_supported: Vec, + pub response_types_supported: Vec, + pub subject_types_supported: Vec, + pub grant_types_supported: Vec, + pub token_endpoint_auth_methods_supported: Vec, + pub id_token_signing_alg_values_supported: Vec, + pub claims_supported: Vec, + pub code_challenge_methods_supported: Vec, + pub authorization_response_iss_parameter_supported: bool, +} + +pub async fn test(test: &mut TestServer) { + println!("Running OIDC tests..."); + + let admin = test.account("admin@example.org"); + + // Set test parameters + let settings = OidcProvider { + access_token_expiry: registry::schema::prelude::Duration::from_millis(1000), + auth_code_expiry: registry::schema::prelude::Duration::from_millis(1000), + auth_code_max_attempts: 1, + user_code_expiry: registry::schema::prelude::Duration::from_millis(1000), + refresh_token_expiry: registry::schema::prelude::Duration::from_millis(3000), + refresh_token_renewal: registry::schema::prelude::Duration::from_millis(2000), + anonymous_client_registration: true, + require_client_registration: true, + signature_algorithm: JwtSignatureAlgorithm::Rs256, + signature_key: SecretText::Text(SecretTextValue { + secret: OIDC_SIGNATURE_KEY_RS256.to_string(), + }), + ..Default::default() + }; + admin + .registry_update_setting( + settings, + &[ + Property::AccessTokenExpiry, + Property::AuthCodeExpiry, + Property::AuthCodeMaxAttempts, + Property::UserCodeExpiry, + Property::RefreshTokenExpiry, + Property::RefreshTokenRenewal, + Property::AnonymousClientRegistration, + Property::RequireClientRegistration, + Property::SignatureAlgorithm, + Property::SignatureKey, + ], + ) + .await; + admin.reload_settings().await; + + // Create test account + let user = test + .create_user_account( + "admin@example.org", + "user@example.org", + "this is a very strong password", + &[], + "user@example.org", + ) + .await; + let user_id = user.id(); + + // Build API + let http = HttpRequest::new(); + + // Obtain OAuth metadata + let metadata: OAuthMetadata = + get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await; + let oidc_metadata: OpenIdMetadata = + get("https://127.0.0.1:8899/.well-known/openid-configuration").await; + let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await; + + // OAuth Public Clients profile: the authorization server metadata must advertise the + // mandatory properties (RFC 8414 + draft-ietf-mailmaint-oauth-public). + assert!( + metadata + .grant_types_supported + .iter() + .any(|g| g == "authorization_code") + ); + assert!( + metadata + .grant_types_supported + .iter() + .any(|g| g == "refresh_token") + ); + assert!( + metadata + .response_types_supported + .iter() + .any(|r| r == "code") + ); + assert!( + metadata + .token_endpoint_auth_methods_supported + .iter() + .any(|m| m == "none") + ); + assert!( + metadata + .code_challenge_methods_supported + .iter() + .any(|m| m == "S256") + ); + assert!(metadata.authorization_response_iss_parameter_supported); + for scope in [ + "urn:ietf:params:oauth:scope:mail", + "urn:ietf:params:oauth:scope:contacts", + "urn:ietf:params:oauth:scope:calendars", + "offline_access", + ] { + assert!( + metadata.scopes_supported.iter().any(|s| s == scope), + "missing scope {scope}" + ); + } + assert!( + oidc_metadata + .grant_types_supported + .iter() + .any(|g| g == "refresh_token") + ); + assert!( + oidc_metadata + .token_endpoint_auth_methods_supported + .iter() + .any(|m| m == "none") + ); + assert!(oidc_metadata.authorization_response_iss_parameter_supported); + + // Protected Resource Metadata (RFC 9728) + let resource_metadata: ProtectedResourceMetadata = + get("https://127.0.0.1:8899/.well-known/oauth-protected-resource").await; + assert_eq!( + resource_metadata.authorization_servers, + vec![metadata.issuer.clone()] + ); + assert!( + resource_metadata + .bearer_methods_supported + .iter() + .any(|m| m == "header") + ); + assert!(!resource_metadata.resource.is_empty()); + + // Dynamic Client Registration: invalid redirect URIs are rejected (RFC 7591 §3.2.2) + for bad_uri in [ + "http://example.com/cb", + "http://127.0.0.1/cb#frag", + "http://127.0.0.1/../cb", + ] { + let (status, body) = post_json_raw( + &metadata.registration_endpoint, + &ClientRegistrationRequest { + redirect_uris: vec![bad_uri.to_string()], + ..Default::default() + }, + ) + .await; + assert_eq!(status, 400, "expected rejection for {bad_uri}: {body}"); + assert_eq!(body["error"], "invalid_redirect_uri", "for {bad_uri}"); + } + + // A loopback redirect URI is accepted and registration returns 201 Created, + // including loopback URIs that specify an ephemeral port (RFC 8252 §7.3). + for good_uri in [ + "http://127.0.0.1/cb", + "http://127.0.0.1:54321/cb", + "http://[::1]:8080/cb", + ] { + let (status, body) = post_json_raw( + &metadata.registration_endpoint, + &ClientRegistrationRequest { + redirect_uris: vec![good_uri.to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + ..Default::default() + }, + ) + .await; + assert_eq!( + status, 201, + "registration should return 201 for {good_uri}: {body}" + ); + } + + // Register the client used for the flow with a private-use scheme redirect URI + let registration: ClientRegistrationResponse = post_json( + &metadata.registration_endpoint, + None, + &ClientRegistrationRequest { + redirect_uris: vec!["com.example.app:/cb".to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + ..Default::default() + }, + ) + .await; + let client_id = registration.client_id; + + // Public client ids are stateless (self-describing) and issued deterministically + assert!( + client_id.starts_with("swc1."), + "expected stateless client id, got {client_id}" + ); + let registration2: ClientRegistrationResponse = post_json( + &metadata.registration_endpoint, + None, + &ClientRegistrationRequest { + redirect_uris: vec!["com.example.app:/cb".to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + ..Default::default() + }, + ) + .await; + assert_eq!( + registration2.client_id, client_id, + "identical registration must be deterministic" + ); + + /*println!("OAuth metadata: {:#?}", metadata); + println!("OpenID metadata: {:#?}", oidc_metadata); + println!("JWKSet: {:#?}", jwk_set);*/ + + // ------------------------ + // Authorization code flow + // ------------------------ + + // A redirect URI that does not match the client registration must be rejected + // and the authorization server must not issue a code (OAuth Public Clients §3.4) + let (status, _) = post_login_raw(&LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: "com.example.app:/evil".to_string().into(), + nonce: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), + state: None, + resource: vec![], + }) + .await; + assert_ne!( + status, 200, + "mismatched redirect URI must not be authorized" + ); + + // An unknown resource indicator must be rejected (RFC 8707) + let (status, _) = post_login_raw(&LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: "com.example.app:/cb".to_string().into(), + nonce: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), + state: None, + resource: vec!["https://evil.example.com/jmap".to_string()], + }) + .await; + assert_ne!( + status, 200, + "unknown resource indicator must not be authorized" + ); + + // Authenticate with the correct password, PKCE (S256), scope and a valid resource indicator + let response = http + .post::( + "/api/auth", + &LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: "com.example.app:/cb".to_string().into(), + nonce: "abc1234".to_string().into(), + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), + state: None, + resource: vec!["https://mail.example.org/jmap/session".to_string()], + }, + ) + .await + .unwrap(); + + // The issuer returned in the authorization response must match the metadata issuer (RFC 9207) + if let LoginResponse::Authenticated { iss, .. } = &response { + assert_eq!(iss, &metadata.issuer); + } else { + panic!("Expected an authenticated response, got {response:?}"); + } + + // Both client_id and redirect_uri have to match + let mut token_params = AHashMap::from_iter([ + ("client_id".to_string(), "invalid_client".to_string()), + ( + "redirect_uri".to_string(), + "com.example.app:/cb".to_string(), + ), + ("grant_type".to_string(), "authorization_code".to_string()), + ("code".to_string(), response.unwrap_code()), + ("code_verifier".to_string(), PKCE_VERIFIER.to_string()), + ]); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + } + ); + token_params.insert("client_id".to_string(), client_id.to_string()); + token_params.insert( + "redirect_uri".to_string(), + "com.example.app:/other".to_string(), + ); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + } + ); + + // A missing or invalid PKCE verifier must be rejected (RFC 7636) + token_params.insert( + "redirect_uri".to_string(), + "com.example.app:/cb".to_string(), + ); + token_params.insert( + "code_verifier".to_string(), + "the-wrong-verifier".to_string(), + ); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + } + ); + + // Obtain token and verify the granted scope is echoed back + token_params.insert("code_verifier".to_string(), PKCE_VERIFIER.to_string()); + let granted = post::(&metadata.token_endpoint, &token_params).await; + if let TokenResponse::Granted(response) = &granted { + assert_eq!(response.scope.as_deref(), Some(PROFILE_SCOPE)); + } + let (token, refresh_token, id_token) = unwrap_oidc_token_response(granted); + + // Connect to account using token and attempt to search + let john_client = Client::new() + .credentials(Credentials::bearer(&token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + assert_eq!(john_client.default_account_id(), user_id.to_string()); + assert!( + !john_client + .mailbox_query(None::, None::>) + .await + .unwrap() + .ids() + .is_empty() + ); + + // Verify ID token using the JWK set + let id_token = JWT::::new_encoded(&id_token) + .decode_with_jwks(&jwk_set, None) + .unwrap(); + let claims = id_token.payload().unwrap(); + let registered_claims = &claims.registered; + let private_claims = &claims.private; + assert_eq!(registered_claims.issuer, Some(oidc_metadata.issuer)); + assert_eq!( + registered_claims.subject, + Some(user_id.document_id().to_string()) + ); + assert_eq!( + registered_claims.audience, + Some(SingleOrMultiple::Single(client_id.to_string())) + ); + assert_eq!(private_claims.nonce, Some("abc1234".into())); + assert_eq!( + private_claims.preferred_username, + Some("user@example.org".into()) + ); + assert_eq!(private_claims.email, Some("user@example.org".into())); + + // Introspect token + let access_introspect: OAuthIntrospect = post_with_auth::( + &metadata.introspection_endpoint, + token.as_str().into(), + &AHashMap::from_iter([("token".to_string(), token.to_string())]), + ) + .await; + assert_eq!(access_introspect.username.unwrap(), "user@example.org"); + assert_eq!(access_introspect.token_type.unwrap(), "bearer"); + assert!(access_introspect.client_id.is_none()); + assert!(access_introspect.active); + let refresh_introspect = post_with_auth::( + &metadata.introspection_endpoint, + token.as_str().into(), + &AHashMap::from_iter([("token".to_string(), refresh_token.unwrap())]), + ) + .await; + assert_eq!(refresh_introspect.username.unwrap(), "user@example.org"); + assert!(refresh_introspect.client_id.is_none()); + assert!(refresh_introspect.active); + assert_eq!( + refresh_introspect.iat.unwrap(), + access_introspect.iat.unwrap() + ); + + // Try SMTP OAUTHBEARER auth + let oauth_bearer_invalid_sasl = general_purpose::STANDARD.encode(format!( + "n,a={},\u{1}auth=Bearer {}\u{1}\u{1}", + "user@domain", "invalid_token" + )); + let oauth_bearer_sasl = general_purpose::STANDARD.encode(format!( + "n,a={},\u{1}auth=Bearer {}\u{1}\u{1}", + "user@domain", token + )); + let mut smtp = SmtpConnection::connect().await; + smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_invalid_sasl}",)) + .await; + smtp.read(1, 4).await; + smtp.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}",)) + .await; + smtp.read(1, 2).await; + + // Try IMAP OAUTHBEARER auth + let mut imap = ImapConnection::connect(b"_x ").await; + imap.assert_read(Type::Untagged, ResponseType::Ok).await; + imap.send(&format!("AUTHENTICATE OAUTHBEARER {oauth_bearer_sasl}")) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + + // Try POP3 OAUTHBEARER auth + let mut pop3 = Pop3Connection::connect().await; + pop3.send(&format!("AUTH OAUTHBEARER {oauth_bearer_sasl}")) + .await; + pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await; + + // ------------------------ + // Confidential client with client_secret + // ------------------------ + + // Registering a confidential client requires authentication and returns a + // generated client_secret exactly once. Web (https) redirect URIs are allowed. + let confidential_redirect = "https://confidential.example.org/callback"; + let confidential: ClientRegistrationResponse = post_json_basic( + &metadata.registration_endpoint, + "admin", + "popolna_zapora", + &ClientRegistrationRequest { + redirect_uris: vec![confidential_redirect.to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + token_endpoint_auth_method: Some(TokenEndpointAuthMethod::ClientSecretPost), + ..Default::default() + }, + ) + .await; + let confidential_id = confidential.client_id; + let confidential_secret = confidential + .client_secret + .expect("confidential client must receive a client_secret"); + assert!( + !confidential_id.starts_with("swc1."), + "confidential client id must be registry-backed, got {confidential_id}" + ); + assert!( + confidential_secret.len() >= 40, + "client secret is too short: {confidential_secret}" + ); + + // Registering a confidential client anonymously must be rejected + let (status, _) = post_json_raw( + &metadata.registration_endpoint, + &ClientRegistrationRequest { + redirect_uris: vec![confidential_redirect.to_string()], + token_endpoint_auth_method: Some(TokenEndpointAuthMethod::ClientSecretBasic), + ..Default::default() + }, + ) + .await; + assert_ne!( + status, 201, + "anonymous confidential client registration must be rejected" + ); + + let base_params = || { + AHashMap::from_iter([ + ("client_id".to_string(), confidential_id.to_string()), + ( + "redirect_uri".to_string(), + confidential_redirect.to_string(), + ), + ("grant_type".to_string(), "authorization_code".to_string()), + ]) + }; + + // A confidential client that omits its secret must be rejected + let mut params = base_params(); + params.insert( + "code".to_string(), + obtain_auth_code(&http, &confidential_id, confidential_redirect).await, + ); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "token request without client_secret must be rejected" + ); + + // A confidential client that presents a wrong secret must be rejected + let mut params = base_params(); + params.insert( + "code".to_string(), + obtain_auth_code(&http, &confidential_id, confidential_redirect).await, + ); + params.insert("client_secret".to_string(), "not-the-secret".to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "token request with a wrong client_secret must be rejected" + ); + + // The correct secret in the request body (client_secret_post) grants a usable token + let mut params = base_params(); + params.insert( + "code".to_string(), + obtain_auth_code(&http, &confidential_id, confidential_redirect).await, + ); + params.insert("client_secret".to_string(), confidential_secret.to_string()); + let (token, _, _) = unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + let confidential_client = Client::new() + .credentials(Credentials::bearer(&token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + assert_eq!( + confidential_client.default_account_id(), + user_id.to_string() + ); + + // The correct secret in the Authorization header (client_secret_basic) also works + let mut params = base_params(); + params.remove("client_id"); + params.insert( + "code".to_string(), + obtain_auth_code(&http, &confidential_id, confidential_redirect).await, + ); + let granted: TokenResponse = post_form_basic( + &metadata.token_endpoint, + &confidential_id, + &confidential_secret, + ¶ms, + ) + .await; + unwrap_token_response(granted); + + // A confidential client created through the management API must have its + // secret hashed before storage; authenticating with the plaintext secret + // only succeeds if the stored value is a verifiable hash. + let managed_secret = "managed-client-secret-abcdefghijklmnopqrstuvwxyz"; + let managed_id = "managed-confidential-client"; + admin + .registry_create_object(OAuthClient { + client_id: managed_id.to_string(), + redirect_uris: vec![confidential_redirect.to_string()].into(), + secret: Some(managed_secret.to_string()), + ..Default::default() + }) + .await; + + let managed_params = || { + AHashMap::from_iter([ + ("client_id".to_string(), managed_id.to_string()), + ( + "redirect_uri".to_string(), + confidential_redirect.to_string(), + ), + ("grant_type".to_string(), "authorization_code".to_string()), + ]) + }; + + let mut params = managed_params(); + params.insert( + "code".to_string(), + obtain_auth_code(&http, managed_id, confidential_redirect).await, + ); + params.insert("client_secret".to_string(), "wrong-secret".to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "management-api client must reject a wrong secret" + ); + + let mut params = managed_params(); + params.insert( + "code".to_string(), + obtain_auth_code(&http, managed_id, confidential_redirect).await, + ); + params.insert("client_secret".to_string(), managed_secret.to_string()); + unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + + // ------------------------ + // Device code flow + // ------------------------ + + // Request a device code + let device_code_params = + AHashMap::from_iter([("client_id".to_string(), client_id.to_string())]); + let device_response: DeviceAuthResponse = + post(&metadata.device_authorization_endpoint, &device_code_params).await; + //println!("Device response: {:#?}", device_response); + + // Status should be pending + let mut token_params = AHashMap::from_iter([ + ("client_id".to_string(), client_id.to_string()), + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ), + ( + "device_code".to_string(), + device_response.device_code.to_string(), + ), + ]); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::AuthorizationPending + } + ); + + // Let the code expire and make sure it's invalidated + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!( + http.post::( + "/api/auth", + &LoginRequest::AuthDevice { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + code: device_response.user_code.clone(), + }, + ) + .await + .unwrap(), + LoginResponse::Failure + ); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::ExpiredToken + } + ); + + // Authenticate account using a valid code + let device_response: DeviceAuthResponse = + post(&metadata.device_authorization_endpoint, &device_code_params).await; + token_params.insert( + "device_code".to_string(), + device_response.device_code.to_string(), + ); + assert_eq!( + http.post::( + "/api/auth", + &LoginRequest::AuthDevice { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + code: device_response.user_code.clone(), + }, + ) + .await + .unwrap(), + LoginResponse::Verified + ); + + // Obtain token + let time_first_token = Instant::now(); + let (token, refresh_token, _) = + unwrap_token_response(post(&metadata.token_endpoint, &token_params).await); + let refresh_token = refresh_token.unwrap(); + + // Authorization codes can only be used once + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::ExpiredToken + } + ); + + // Connect to account using token and attempt to search + let john_client = Client::new() + .credentials(Credentials::bearer(&token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + assert_eq!(john_client.default_account_id(), user_id.to_string()); + assert!( + !john_client + .mailbox_query(None::, None::>) + .await + .unwrap() + .ids() + .is_empty() + ); + + // Connecting using the refresh token should not work + assert_unauthorized("https://127.0.0.1:8899", &refresh_token).await; + + // Refreshing a token using the access token should not work + assert_eq!( + post::( + &metadata.token_endpoint, + &AHashMap::from_iter([ + ("client_id".to_string(), client_id.to_string()), + ("grant_type".to_string(), "refresh_token".to_string()), + ("refresh_token".to_string(), token), + ]), + ) + .await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + } + ); + + // Refreshing the access token before expiration should not include a new refresh token + let refresh_params = AHashMap::from_iter([ + ("client_id".to_string(), client_id.to_string()), + ("grant_type".to_string(), "refresh_token".to_string()), + ("refresh_token".to_string(), refresh_token), + ]); + let time_before_post: Instant = Instant::now(); + let (token, new_refresh_token, _) = + unwrap_token_response(post(&metadata.token_endpoint, &refresh_params).await); + assert_eq!( + new_refresh_token, + None, + "Refreshed token in {:?}, since start {:?}", + time_before_post.elapsed(), + time_first_token.elapsed() + ); + + // Wait 1 second and make sure the access token expired + tokio::time::sleep(Duration::from_secs(1)).await; + assert_unauthorized("https://127.0.0.1:8899", &token).await; + + // Wait another second for the refresh token to be about to expire + // and expect a new refresh token + tokio::time::sleep(Duration::from_secs(1)).await; + let (_, new_refresh_token, _) = + unwrap_token_response(post(&metadata.token_endpoint, &refresh_params).await); + //println!("New refresh token: {:?}", new_refresh_token); + assert_ne!(new_refresh_token, None); + + // Wait another second and make sure the refresh token expired + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!( + post::(&metadata.token_endpoint, &refresh_params).await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + } + ); + + // Clean up + admin.registry_destroy_all(ObjectType::OAuthClient).await; + admin.destroy_account(user).await; + test.cleanup().await; +} + +async fn post_bytes( + url: &str, + auth_token: Option<&str>, + params: &AHashMap, +) -> Bytes { + let mut client = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url); + + if let Some(auth_token) = auth_token { + client = client.bearer_auth(auth_token); + } + + client + .form(params) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() +} + +async fn post_json( + url: &str, + auth_token: Option<&str>, + body: &impl Serialize, +) -> D { + let mut client = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url); + + if let Some(auth_token) = auth_token { + client = client.bearer_auth(auth_token); + } + + serde_json::from_slice( + &client + .body(serde_json::to_string(body).unwrap().into_bytes()) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(), + ) + .unwrap() +} + +async fn post_json_basic( + url: &str, + username: &str, + password: &str, + body: &impl Serialize, +) -> D { + let response = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url) + .basic_auth(username, Some(password)) + .body(serde_json::to_string(body).unwrap().into_bytes()) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + serde_json::from_slice(&response).unwrap() +} + +async fn post_form_basic( + url: &str, + username: &str, + password: &str, + params: &AHashMap, +) -> T { + let response = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url) + .basic_auth(username, Some(password)) + .form(params) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + serde_json::from_slice(&response).unwrap() +} + +async fn obtain_auth_code(http: &HttpRequest, client_id: &str, redirect_uri: &str) -> String { + http.post::( + "/api/auth", + &LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: redirect_uri.to_string().into(), + nonce: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: None, + code_challenge_method: None, + state: None, + resource: vec![], + }, + ) + .await + .unwrap() + .unwrap_code() +} + +async fn post_json_raw(url: &str, body: &impl Serialize) -> (u16, serde_json::Value) { + let response = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url) + .body(serde_json::to_string(body).unwrap().into_bytes()) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let value = + serde_json::from_slice(&response.bytes().await.unwrap()).unwrap_or(serde_json::Value::Null); + (status, value) +} + +async fn post_login_raw(body: &impl Serialize) -> (u16, serde_json::Value) { + post_json_raw("https://127.0.0.1:8899/api/auth", body).await +} + +async fn post(url: &str, params: &AHashMap) -> T { + post_with_auth(url, None, params).await +} +async fn post_with_auth( + url: &str, + auth_token: Option<&str>, + params: &AHashMap, +) -> T { + serde_json::from_slice(&post_bytes(url, auth_token, params).await).unwrap() +} + +async fn get_bytes(url: &str) -> Bytes { + reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .get(url) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() +} + +async fn get(url: &str) -> T { + serde_json::from_slice(&get_bytes(url).await).unwrap() +} + +async fn assert_unauthorized(base_url: &str, token: &str) { + match Client::new() + .credentials(Credentials::bearer(token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect(base_url) + .await + { + Ok(_) => panic!("Expected unauthorized access."), + Err(err) => { + let err = err.to_string(); + assert!(err.contains("Unauthorized"), "{}", err); + } + } +} + +fn unwrap_token_response(response: TokenResponse) -> (String, Option, u64) { + match response { + TokenResponse::Granted(granted) => { + assert_eq!(granted.token_type, "bearer"); + ( + granted.access_token, + granted.refresh_token, + granted.expires_in, + ) + } + TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error), + } +} + +fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option, String) { + match response { + TokenResponse::Granted(granted) => { + assert_eq!(granted.token_type, "bearer"); + ( + granted.access_token, + granted.refresh_token, + granted.id_token.unwrap(), + ) + } + TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error), + } +} + +pub trait LoginResponseTest { + fn unwrap_code(self) -> String; +} + +impl LoginResponseTest for LoginResponse { + fn unwrap_code(self) -> String { + match self { + LoginResponse::Authenticated { client_code, .. } => client_code, + _ => panic!("Expected auth code response, got {:?}", self), + } + } +} + +const OIDC_SIGNATURE_KEY_RS256: &str = "-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF +Ze/6493VjL+jHkFMP2Pc7fLwRF1fhkuIdYTp69LabzrSEJCRCz0UI2NHqPOgtOta ++zRHKAMr7c7Z6uKO0K+aXiQYHw4Y70uSG8CnmNl7kb4OM/CAcoO6fePmvBsyESfn +TmkJ5bfHEZQFDQEAoDlDjtjxuwYsAQQVQXuAydi8j8pyTWKAJ1RDgnUT+HbOub7j +JrQ7sPe6MPCjXv5N76v9RMHKktfYwRNMlkLkxImQU55+vlvghNztgFlIlJDFfNiy +UQPV5FTEZJli9BzMoj1JQK3sZyV8WV0W1zN41QQ+glAAC6+K7iTDPRMINBSwbHyn +6Lb9Q6U7AgMBAAECggEAB93qZ5xrhYgEFeoyKO4mUdGsu4qZyJB0zNeWGgdaXCfZ +zC4l8zFM+R6osix0EY6lXRtC95+6h9hfFQNa5FWseupDzmIQiEnim1EowjWef87l +Eayi0nDRB8TjqZKjR/aLOUhzrPlXHKrKEUk/RDkacCiDklwz9S0LIfLOSXlByBDM +/n/eczfX2gUATexMHSeIXs8vN2jpuiVv0r+FPXcRvqdzDZnYSzS8BJ9k6RYXVQ4o +NzCbfqgFIpVryB7nHgSTrNX9G7299If8/dXmesXWSFEJvvDSSpcBoINKbfgSlrxd +6ubjiotcEIBUSlbaanRrydwShhLHnXyupNAb7tlvyQKBgQDsIipSK4+H9FGl1rAk +Gg9DLJ7P/94sidhoq1KYnj/CxwGLoRq22khZEUYZkSvYXDu1Qkj9Avi3TRhw8uol +l2SK1VylL5FQvTLKhWB7b2hjrUd5llMRgS3/NIdLhOgDMB7w3UxJnCA/df/Rj+dM +WhkyS1f0x3t7XPLwWGurW0nJcwKBgQDdjhrNfabrK7OQvDpAvNJizuwZK9WUL7CD +rR0V0MpDGYW12BTEOY6tUK6XZgiRitAXf4EkEI6R0Q0bFzwDDLrg7TvGdTuzNeg/ +8vm8IlRlOkrdihtHZI4uRB7Ytmz24vzywEBE0p6enA7v4oniscUks/KKmDGr0V90 +yT9gIVrjGQKBgQCjnWC5otlHGLDiOgm+WhgtMWOxN9dYAQNkMyF+Alinu4CEoVKD +VGhA3sk1ufMpbW8pvw4X0dFIITFIQeift3DBCemxw23rBc2FqjkaDi3EszINO22/ +eUTHyjvcxfCFFPi7aHsNnhJyJm7lY9Kegudmg/Ij93zGE7d5darVBuHvpQKBgBBY +YovUgFMLR1UfPeD2zUKy52I4BKrJFemxBNtOKw3mPSIcTfPoFymcMTVENs+eARoq +svlZK1uAo8ni3e+Pqd3cQrOyhHQFPxwwrdH+amGJemp7vOV4erDZH7l3Q/S27Fhw +bI1nSIKFGukBupB58wRxLiyha9C0QqmYC0/pRg5JAn8Rbj5tP26oVCXjZEfWJL8J +axxSxsGA4Vol6i6LYnVgZG+1ez2rP8vUORo1lRzmdeP4o1BSJf9TPwXkuppE5J+t +UZVKtYGlEn1RqwGNd8I9TiWvU84rcY9nsxlDR86xwKRWFvYqVOiGYtzRyewYRdjU +rTs9aqB3v1+OVxGxR6Na +-----END PRIVATE KEY----- +"; + +#[allow(dead_code)] +const OIDC_SIGNATURE_KEY_ES256: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggybcqc86ulFFiOon +WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn +0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA +-----END PRIVATE KEY----- +"; diff --git a/tests/src/system/purge.rs b/tests/src/system/purge.rs new file mode 100644 index 0000000..edc2acc --- /dev/null +++ b/tests/src/system/purge.rs @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + imap::{AssertResult, ImapConnection, Type}, + server::TestServer, +}; +use ahash::AHashSet; +use common::Server; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + mailbox::{INBOX_ID, JUNK_ID, TRASH_ID}, +}; +use imap_proto::ResponseType; +use registry::schema::{ + enums::{TaskAccountMaintenanceType, TaskStoreMaintenanceType}, + prelude::Property, + structs::{ + DataRetention, SpamClassifier, Task, TaskAccountMaintenance, TaskStatus, + TaskStoreMaintenance, + }, +}; +use store::{IterateParams, LogKey, U32_LEN, U64_LEN, write::key::DeserializeBigEndian}; +use types::id::Id; + +const EXPUNGE_TRASH_AFTER: std::time::Duration = std::time::Duration::from_secs(10); + +pub async fn test(test: &mut TestServer) { + println!("Running Account purge tests..."); + let inbox_id = Id::from(INBOX_ID).to_string(); + let trash_id = Id::from(TRASH_ID).to_string(); + let junk_id = Id::from(JUNK_ID).to_string(); + let admin = test.account("admin@example.org"); + + // Set test settings + admin + .registry_update_setting( + DataRetention { + max_changes_history: Some(1), + expunge_trash_after: Some(EXPUNGE_TRASH_AFTER.into()), + ..Default::default() + }, + &[Property::MaxChangesHistory, Property::ExpungeTrashAfter], + ) + .await; + admin + .registry_update_setting( + SpamClassifier { + hold_samples_for: 1u64.into(), + ..Default::default() + }, + &[Property::HoldSamplesFor], + ) + .await; + admin.reload_settings().await; + + // Create test account + let account = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &[], + "jdoe@example.org", + ) + .await; + let client = account.jmap_client().await; + + let mut imap = ImapConnection::connect(b"_x ").await; + imap.assert_read(Type::Untagged, ResponseType::Ok).await; + imap.authenticate("jdoe@example.org", "this is a very strong password") + .await; + imap.send("STATUS INBOX (UIDNEXT MESSAGES UNSEEN)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("MESSAGES 0"); + + // Create test messages + let mut message_ids = Vec::new(); + let mut pass = 0; + let mut changes = AHashSet::new(); + + loop { + pass += 1; + for folder_id in [&inbox_id, &trash_id, &junk_id] { + message_ids.push( + client + .email_import( + format!( + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report #{} {}\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + pass, folder_id + ) + .into_bytes(), + [folder_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + + if pass == 1 { + let (changes_, is_truncated) = get_changes(&test.server).await; + assert!(!is_truncated); + changes = changes_; + tokio::time::sleep(EXPUNGE_TRASH_AFTER + std::time::Duration::from_secs(1)).await; + } else { + break; + } + } + + // Check IMAP status + imap.send("LIST \"\" \"*\" RETURN (STATUS (MESSAGES))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"INBOX\" (MESSAGES 2)") + .assert_contains("\"Deleted Items\" (MESSAGES 2)") + .assert_contains("\"Junk Mail\" (MESSAGES 2)"); + + // Make sure both messages and changes are present + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + 6 + ); + + // Purge junk/trash messages and old changes + admin + .registry_create_object(Task::AccountMaintenance(TaskAccountMaintenance { + account_id: account.id(), + maintenance_type: TaskAccountMaintenanceType::Purge, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + let cache = test + .server + .get_cached_messages(account.id().document_id()) + .await + .unwrap(); + + // Only 4 messages should remain + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + 4 + ); + assert_eq!(cache.in_mailbox(INBOX_ID).count(), 2); + assert_eq!(cache.in_mailbox(TRASH_ID).count(), 1); + assert_eq!(cache.in_mailbox(JUNK_ID).count(), 1); + + // Check IMAP status + imap.send("LIST \"\" \"*\" RETURN (STATUS (MESSAGES))") + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\"INBOX\" (MESSAGES 2)") + .assert_contains("\"Deleted Items\" (MESSAGES 1)") + .assert_contains("\"Junk Mail\" (MESSAGES 1)"); + + // Compare changes + let (new_changes, is_truncated) = get_changes(&test.server).await; + assert!(!changes.is_empty()); + assert!(!new_changes.is_empty()); + assert!(is_truncated); + for change in &changes { + assert!( + !new_changes.contains(change), + "Change {change:?} was not purged, expected {} changes, got {}", + changes.len(), + new_changes.len() + ); + } + + // Delete expired training samples + admin + .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeBlob, + shard_index: None, + status: TaskStatus::now(), + })) + .await; + + // Delete account + admin.destroy_account(account).await; + test.wait_for_tasks().await; + test.assert_is_empty().await; + + // Reset settings + admin + .registry_update_setting(SpamClassifier::default(), &[Property::HoldSamplesFor]) + .await; + test.cleanup().await; +} + +async fn get_changes(server: &Server) -> (AHashSet<(u64, u8)>, bool) { + let mut changes = AHashSet::new(); + let mut is_truncated = false; + server + .core + .storage + .data + .iterate( + IterateParams::new( + LogKey { + account_id: 0, + collection: 0, + change_id: 0, + }, + LogKey { + account_id: u32::MAX, + collection: u8::MAX, + change_id: u64::MAX, + }, + ) + .ascending(), + |key, value| { + if !value.is_empty() { + changes.insert(( + key.deserialize_be_u64(key.len() - U64_LEN).unwrap(), + key[U32_LEN], + )); + } else { + is_truncated = true; + } + Ok(true) + }, + ) + .await + .unwrap(); + (changes, is_truncated) +} diff --git a/tests/src/system/quota.rs b/tests/src/system/quota.rs new file mode 100644 index 0000000..13ee6f6 --- /dev/null +++ b/tests/src/system/quota.rs @@ -0,0 +1,511 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + account::Account, http::HttpRequest, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection, +}; +use email::{cache::MessageCacheFetch, mailbox::INBOX_ID}; +use jmap::blob::upload::DISABLE_UPLOAD_QUOTA; +use jmap_client::{ + core::set::{SetErrorType, SetObject}, + email::EmailBodyPart, +}; +use registry::{ + schema::{ + enums::{Permission, StorageQuota, TaskAccountMaintenanceType}, + prelude::{ObjectType, Property}, + structs::{ + self, Credential, Jmap, PasswordCredential, PermissionsList, Task, + TaskAccountMaintenance, TaskStatus, UserAccount, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use serde_json::json; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub async fn test(test: &mut TestServer) { + println!("Running Quota tests..."); + let admin = test.account("admin@example.org"); + let domain_id = admin.find_or_create_domain("example.org").await; + + // Set test settings + admin + .registry_update_setting( + Jmap { + upload_quota: 50000, + max_upload_count: 3, + upload_ttl: registry::types::duration::Duration::from_millis(1000), + ..Default::default() + }, + &[ + Property::UploadQuota, + Property::MaxUploadCount, + Property::UploadTtl, + ], + ) + .await; + admin.reload_settings().await; + + // Create test accounts + let account_id = admin + .registry_create_object(structs::Account::User(UserAccount { + name: "user1".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password1".to_string(), + ..Default::default() + })]), + quotas: VecMap::from_iter([(StorageQuota::MaxDiskQuota, 1024)]), + permissions: structs::Permissions::Merge(PermissionsList { + enabled_permissions: Map::new(vec![Permission::Impersonate]), + disabled_permissions: Default::default(), + }), + ..Default::default() + })) + .await; + let other_account_id = admin + .registry_create_object(structs::Account::User(UserAccount { + name: "user2".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password2".to_string(), + ..Default::default() + })]), + permissions: structs::Permissions::Merge(PermissionsList { + enabled_permissions: Map::new(vec![Permission::Impersonate]), + disabled_permissions: Default::default(), + }), + ..Default::default() + })) + .await; + let account = Account::new( + "user1@example.org", + "this is a very strong password1", + &[], + "User1", + account_id, + ); + let other_account = Account::new( + "user2@example.org", + "this is a very strong password2", + &[], + "User2", + other_account_id, + ); + + // Delete temporary blobs from previous tests + test.blob_expire_all().await; + + // Test temporary blob quota (3 files) + DISABLE_UPLOAD_QUOTA.store(false, std::sync::atomic::Ordering::Relaxed); + let client = account.jmap_client().await; + let raw_http = + HttpRequest::with_credentials(8899, "user1@example.org", "this is a very strong password1"); + let upload_url = format!("/jmap/upload/{account_id}"); + for i in 0..3 { + assert_eq!( + client + .upload(None, vec![b'A' + i; 1024], None) + .await + .unwrap() + .size(), + 1024 + ); + } + let resp = raw_http + .send_full( + hyper::Method::POST, + &upload_url, + Some(vec![b'Z'; 1024]), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 429, + "blob-files-quota body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers)); + assert!( + policy.contains("\"blob-upload-files\";q=3"), + "RateLimit-Policy = {policy}" + ); + assert!( + policy.contains("\"blob-upload-bytes\";q=50000") + && policy.contains(r#"qu="content-bytes""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers)); + assert!( + state.contains("\"blob-upload-files\";r=0") && state.contains("t="), + "RateLimit = {state}" + ); + assert!( + resp.retry_after().is_some(), + "missing Retry-After on {:?}", + resp.headers + ); + assert!(resp.body.contains("quota"), "body = {}", resp.body); + test.blob_expire_all().await; + + // Test temporary blob quota (50000 bytes) + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + for i in 0..2 { + assert_eq!( + client + .upload(None, vec![b'a' + i; 25000], None) + .await + .unwrap() + .size(), + 25000 + ); + } + let resp = raw_http + .send_full( + hyper::Method::POST, + &upload_url, + Some(vec![b'z'; 1024]), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 429, + "blob-bytes-quota body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers)); + assert!( + policy.contains("\"blob-upload-bytes\";q=50000") + && policy.contains(r#"qu="content-bytes""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers)); + assert!( + state.contains("\"blob-upload-bytes\";r=0") && state.contains("t="), + "RateLimit = {state}" + ); + assert!( + resp.retry_after().is_some(), + "missing Retry-After on {:?}", + resp.headers + ); + test.blob_expire_all().await; + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + + // Test JMAP Quotas extension + let response = account + .jmap_method_call( + "Quota/get", + json!({ + "accountId": account.id_string(), + "ids": null + }), + ) + .await + .to_string(); + assert!(response.contains("\"used\":0"), "{}", response); + assert!(response.contains("\"hardLimit\":1024"), "{}", response); + assert!(response.contains("\"scope\":\"account\""), "{}", response); + assert!( + response.contains("\"name\":\"user1@example.org\""), + "{}", + response + ); + + // Test Email/import quota + let inbox_id = Id::new(INBOX_ID as u64).to_string(); + let mut message_ids = Vec::new(); + for i in 0..2 { + message_ids.push( + client + .email_import( + create_message_with_size( + "user2@example.org", + "user1@example.org", + &format!("Test {i}"), + 512, + ), + vec![&inbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + + assert_over_quota( + client + .email_import( + create_message_with_size("test@example.org", "user2@example.org", "Test 3", 100), + vec![&inbox_id], + None::>, + None, + ) + .await, + ); + + // Test JMAP Quotas extension + let response = account + .jmap_method_call( + "Quota/get", + json!({ + "accountId": account.id_string(), + "ids": null + }), + ) + .await + .to_string(); + assert!(response.contains("\"used\":1024"), "{}", response); + assert!(response.contains("\"hardLimit\":1024"), "{}", response); + + // Test registry quota + assert_eq!( + admin + .registry_get_many(ObjectType::Account, [account_id]) + .await + .list()[0] + .integer_field(Property::UsedDiskQuota.as_str()), + 1024 + ); + + // Delete messages and check available quota + test.wait_for_tasks().await; + for message_id in message_ids { + client.email_destroy(&message_id).await.unwrap(); + } + + // Wait for pending index tasks + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + 0 + ); + + // Test Email/set quota + let mut message_ids = Vec::new(); + for i in 0..2 { + let mut request = client.build(); + let create_item = request.set_email().create(); + create_item + .mailbox_ids([&inbox_id]) + .subject(format!("Test {i}")) + .from(["user2@example.org"]) + .to(["user1@example.org"]) + .body_value("a".to_string(), String::from_utf8(vec![b'A'; 200]).unwrap()) + .text_body(EmailBodyPart::new().part_id("a")); + let create_id = create_item.create_id().unwrap(); + message_ids.push( + request + .send_set_email() + .await + .unwrap() + .created(&create_id) + .unwrap() + .take_id(), + ); + } + let mut request = client.build(); + let create_item = request.set_email().create(); + create_item + .mailbox_ids([&inbox_id]) + .subject("Test 3") + .from(["user2@example.org"]) + .to(["user1@example.org"]) + .body_value("a".to_string(), String::from_utf8(vec![b'A'; 400]).unwrap()) + .text_body(EmailBodyPart::new().part_id("a")); + let create_id = create_item.create_id().unwrap(); + assert_over_quota(request.send_set_email().await.unwrap().created(&create_id)); + + // Recalculate quota + let prev_quota = test + .server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(); + admin + .registry_create_object(Task::AccountMaintenance(TaskAccountMaintenance { + account_id, + maintenance_type: TaskAccountMaintenanceType::RecalculateQuota, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + prev_quota + ); + + // Delete messages and check available quota + test.wait_for_tasks().await; + for message_id in message_ids { + client.email_destroy(&message_id).await.unwrap(); + } + // Wait for pending index tasks + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + 0 + ); + + // Test Email/copy quota + let other_client = other_account.jmap_client().await; + let mut other_message_ids = Vec::new(); + let mut message_ids = Vec::new(); + for i in 0..3 { + other_message_ids.push( + other_client + .email_import( + create_message_with_size( + "jane@example.org", + "user2@example.org", + &format!("Other Test {i}"), + 512, + ), + vec![&inbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + for id in other_message_ids.iter().take(2) { + message_ids.push( + client + .email_copy( + other_account.id_string(), + id, + vec![&inbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(), + ); + } + assert_over_quota( + client + .email_copy( + other_account.id_string(), + &other_message_ids[2], + vec![&inbox_id], + None::>, + None, + ) + .await, + ); + + // Delete messages and check available quota + test.wait_for_tasks().await; + for message_id in message_ids { + client.email_destroy(&message_id).await.unwrap(); + } + // Wait for pending index tasks + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + 0 + ); + + // Test delivery quota + let mut lmtp = SmtpConnection::connect().await; + for i in 0..2 { + lmtp.ingest( + "jane@example.org", + &["user1@example.org"], + &String::from_utf8(create_message_with_size( + "jane@example.org", + "user1@example.org", + &format!("Ingest test {i}"), + 500, + )) + .unwrap(), + ) + .await; + } + let quota = test + .server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(); + assert!(quota > 0 && quota <= 1024, "Quota is {}", quota); + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + 1, + ); + + DISABLE_UPLOAD_QUOTA.store(true, std::sync::atomic::Ordering::Relaxed); + + // Remove test data + test.wait_for_tasks().await; + test.destroy_all_mailboxes(&account).await; + test.destroy_all_mailboxes(&other_account).await; + admin.registry_destroy_all(ObjectType::QueuedMessage).await; + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + test.assert_is_empty().await; + + admin + .registry_destroy(ObjectType::Account, [account_id, other_account_id]) + .await + .assert_destroyed(&[account_id, other_account_id]); + test.cleanup().await; +} + +fn assert_over_quota(result: Result) { + match result { + Ok(result) => panic!("Expected error, got {:?}", result), + Err(jmap_client::Error::Set(err)) if err.error() == &SetErrorType::OverQuota => (), + Err(err) => panic!("Expected OverQuota SetError, got {:?}", err), + } +} + +fn create_message_with_size(from: &str, to: &str, subject: &str, size: usize) -> Vec { + let mut message = format!( + "From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n", + from, to, subject + ); + for _ in 0..size - message.len() { + message.push('A'); + } + + message.into_bytes() +} diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs new file mode 100644 index 0000000..b85a92f --- /dev/null +++ b/tests/src/system/security.rs @@ -0,0 +1,391 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + system::authentication::validate_password_with_ip, + utils::{ + http::HttpRequest, + imap::{ImapConnection, Type}, + registry::UnwrapRegistryId, + server::TestServer, + }, +}; +use common::ipc::RegistryChange; +use imap_proto::ResponseType; +use jmap_client::{ + client::{Client, Credentials}, + mailbox::{self}, +}; +use registry::{ + schema::{ + enums::BlockReason, + prelude::{ObjectType, Property}, + structs::{Action, BlockedIp, Http, Jmap}, + }, + types::ipmask::IpAddrOrMask, +}; +use serde_json::json; +use std::{net::Ipv4Addr, sync::Arc, time::Duration}; +use store::{registry::write::RegistryWrite, write::now}; +use types::id::Id; + +pub async fn test(test: &mut TestServer) { + println!("Running Security tests..."); + + let admin = test.account("admin@example.org"); + + // Set security settings + admin + .registry_update_setting( + Http { + use_x_forwarded: true, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin + .registry_update_setting( + Jmap { + max_concurrent_uploads: Some(4), + max_concurrent_requests: Some(8), + max_upload_size: 5000000, + ..Default::default() + }, + &[ + Property::MaxConcurrentUploads, + Property::MaxConcurrentRequests, + Property::MaxUploadSize, + ], + ) + .await; + admin.reload_settings().await; + + // Create a test user + let user = test + .create_user_account( + "admin@example.org", + "user@example.org", + "this is a very strong password", + &[], + "user@example.org", + ) + .await; + let user_id = user.id(); + + // Incorrect passwords should be rejected with a 401 error + assert!(matches!( + Client::new() + .credentials(Credentials::basic("user@example.org", "abcde")) + .accept_invalid_certs(true) .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await, + Err(jmap_client::Error::Problem(err)) if err.status() == Some(401))); + + // Wait until the beginning of the 5 seconds bucket + const LIMIT: u64 = 5; + let now = now(); + let range_start = now / LIMIT; + let range_end = (range_start * LIMIT) + LIMIT; + tokio::time::sleep(Duration::from_secs(range_end - now)).await; + + // Make sure that the IP address is not blocked before the test + assert_eq!( + admin + .registry_query_ids( + ObjectType::BlockedIp, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new() + ) + .await, + Vec::::new() + ); + + for _ in 0..98 { + validate_password_with_ip("unknown@example.org", "wrong password", "127.0.0.1", false) + .await; + } + + let mut imap = ImapConnection::connect(b"_x ").await; + imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz") + .await; + imap.assert_read(Type::Tagged, ResponseType::No).await; + + // There are already 100 failed login attempts for this IP address + // so the next one should be rejected, even if done over IMAP + imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz") + .await; + imap.assert_disconnect().await; + + // Make sure the IP address is blocked + let blocked_id = test + .server + .registry() + .primary_key( + ObjectType::BlockedIp.into(), + Property::Address, + IpAddrOrMask::from_ip(Ipv4Addr::LOCALHOST.into()).to_index_key(), + ) + .await + .unwrap() + .expect("Blocked IP should have been created after too many failed login attempts"); + let blocked_ip = test + .server + .registry() + .object::(blocked_id.id()) + .await + .unwrap() + .unwrap(); + assert_eq!(blocked_ip.reason, BlockReason::AuthFailure); + + ImapConnection::connect(b"_y ") + .await + .assert_disconnect() + .await; + + // Lift ban + test.server + .registry() + .write(RegistryWrite::delete(blocked_id)) + .await + .unwrap() + .unwrap_id(trc::location!()); + test.server + .reload_registry(RegistryChange::Delete(blocked_id)) + .await + .unwrap(); + + // Valid authentication requests should not be rate limited + for _ in 0..110 { + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "127.0.0.1", + true, + ) + .await; + } + + // Set fail2ban expiration + admin + .registry_update_object( + ObjectType::Security, + Id::singleton(), + json!({ + Property::AuthBanPeriod: registry::types::duration::Duration::from_millis(1000) + }), + ) + .await; + admin.reload_settings().await; + + // Block IP 10.0.0.2 + for _ in 0..105 { + validate_password_with_ip("unknown@example.org", "wrong password", "10.0.0.2", false).await; + } + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + false, + ) + .await; + + // Check that the IP is blocked + let blocked_ids = admin + .registry_query_ids( + ObjectType::BlockedIp, + [(Property::Address, "10.0.0.2")], + Vec::<&str>::new(), + ) + .await; + assert_eq!(blocked_ids.len(), 1); + let blocked_ip = admin.registry_get::(blocked_ids[0]).await; + assert_eq!(blocked_ip.reason, BlockReason::AuthFailure); + assert!(blocked_ip.expires_at.is_some()); + + // After 1 second the ban should be lifted + tokio::time::sleep(Duration::from_secs(2)).await; + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + true, + ) + .await; + + // Make sure the IP remains unblocked after reload + admin.registry_create_object(Action::ReloadBlockedIps).await; + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + true, + ) + .await; + + // Login with the correct credentials + let client = Client::new() + .credentials(Credentials::basic( + "user@example.org", + "this is a very strong password", + )) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + assert_eq!(client.session().username(), "user@example.org"); + assert_eq!( + client + .session() + .account(&user_id.to_string()) + .unwrap() + .name(), + "user@example.org" + ); + assert!( + client + .session() + .account(&user_id.to_string()) + .unwrap() + .is_personal() + ); + + // Uploads up to 5000000 bytes should be allowed + assert_eq!( + client + .upload(None, vec![b'A'; 5000000], None) + .await + .unwrap() + .size(), + 5000000 + ); + assert!( + client + .upload(None, vec![b'A'; 5000001], None) + .await + .is_err() + ); + + // Concurrent requests check + let client = Arc::new(client); + let raw_http = + HttpRequest::with_credentials(8899, "user@example.org", "this is a very strong password"); + for _ in 0..8 { + let client_ = client.clone(); + tokio::spawn(async move { + let _ = client_ + .mailbox_query( + mailbox::query::Filter::name("__sleep").into(), + [mailbox::query::Comparator::name()].into(), + ) + .await; + }); + } + tokio::time::sleep(Duration::from_millis(500)).await; + let body = serde_json::to_vec(&json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Mailbox/query", { + "accountId": user_id.to_string(), + "filter": { "name": "__sleep" } + }, "c1"] + ] + })) + .unwrap(); + let resp = raw_http + .send_full( + hyper::Method::POST, + "/jmap/", + Some(body), + Some("application/json"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 400, + "concurrent-requests body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers)); + assert!( + policy.contains("\"concurrent-requests\"") && policy.contains("q=8"), + "RateLimit-Policy = {policy}" + ); + assert!( + policy.contains(r#"qu="concurrent-requests""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers)); + assert!( + state.contains("\"concurrent-requests\"") && state.contains("r=0"), + "RateLimit = {state}" + ); + + // Wait for sleep to be done + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Concurrent upload test + for _ in 0..4 { + let client_ = client.clone(); + tokio::spawn(async move { + client_.upload(None, b"sleep".to_vec(), None).await.unwrap(); + }); + } + tokio::time::sleep(Duration::from_millis(500)).await; + let resp = raw_http + .send_full( + hyper::Method::POST, + &format!("/jmap/upload/{user_id}"), + Some(b"sleep".to_vec()), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 400, + "concurrent-uploads body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers)); + assert!( + policy.contains("\"concurrent-uploads\"") && policy.contains("q=4"), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers)); + assert!( + state.contains("\"concurrent-uploads\"") && state.contains("r=0"), + "RateLimit = {state}" + ); + + // Wait for sleep to be done before continuing + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Disable X-Forwarded-For processing + admin + .registry_update_setting( + Http { + use_x_forwarded: false, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin.reload_settings().await; + + // Destroy account + admin.destroy_account(user).await; + + test.cleanup().await; +} diff --git a/tests/src/system/task.rs b/tests/src/system/task.rs new file mode 100644 index 0000000..3443904 --- /dev/null +++ b/tests/src/system/task.rs @@ -0,0 +1,395 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{account::Account, server::TestServer}; +use registry::{ + schema::{ + enums::TaskStoreMaintenanceType, + prelude::{ObjectType, Property}, + structs::{ + Task, TaskManager, TaskRetryStrategy, TaskRetryStrategyFixed, TaskStatus, + TaskStatusFailed, TaskStatusPending, TaskStatusRetry, TaskStoreMaintenance, + }, + }, + types::datetime::UTCDateTime, +}; +use serde_json::json; +use store::write::now; +use types::id::Id; + +const TASK_WAIT_ATTEMPTS: usize = 100; +const TASK_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); + +const TASK_SUCCESS: u64 = 0; +const TASK_TEMP_FAIL: u64 = 1; +const TASK_PERM_FAIL: u64 = 2; + +pub async fn test(test: &mut TestServer) { + println!("Running Task manager tests..."); + let admin = test.account("admin@example.org"); + + // Make sure there are no existing tasks + admin.assert_no_tasks().await; + + // Create a successful task for immediate execution + admin.schedule_test_task(TASK_SUCCESS, 0).await; + admin.assert_no_tasks().await; + + // Create a successful task for future execution + admin.schedule_test_task(TASK_SUCCESS, 1).await; + admin.assert_has_tasks(1).await; + admin.assert_no_tasks().await; + + // Create a permanent failure task for immediate execution + admin.schedule_test_task(TASK_PERM_FAIL, 0).await; + let task = admin.assert_has_failed_task().await; + assert_eq!( + task.task.status().unwrap_failed().failure_reason, + "Simulated permanent failure" + ); + + // Reschedule the failed task for retry + admin + .registry_update_object( + ObjectType::Task, + task.id, + json!({ + Property::ShardIndex: TASK_SUCCESS, + Property::Status: { + "@type": "Pending", + "due": UTCDateTime::from_timestamp((now() + 1) as i64), + } + }), + ) + .await; + test.wait_for_tasks().await; + admin.assert_no_tasks().await; + + // Test attempt limits strategy + admin + .registry_update_setting( + TaskManager { + max_attempts: 3, + strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed { + delay: 1_000u64.into(), + }), + total_deadline: 86_400_000u64.into(), // 24 hours + }, + &[], + ) + .await; + admin.reload_settings().await; + + // Create a temporary failure task for immediate execution + admin.schedule_test_task(TASK_TEMP_FAIL, 0).await; + let task = admin.assert_has_retried_task().await; + let task_status = task.task.status().unwrap_retry(); + assert_eq!(task_status.failure_reason, "Simulated temporary failure"); + assert_eq!(task_status.attempt_number, 1); + + // Wait until the max attempts is reached + test.wait_for_tasks_skip_failures().await; + let task = admin.assert_has_failed_task().await; + let task_status = task.task.status().unwrap_failed(); + assert_eq!(task_status.failure_reason, "Simulated temporary failure"); + assert_eq!(task_status.failed_attempt_number, 3); + admin + .registry_destroy(ObjectType::Task, [task.id]) + .await + .assert_destroyed(&[task.id]); + + // Test attempt limits strategy + admin + .registry_update_setting( + TaskManager { + max_attempts: 100, + strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed { + delay: 1_000u64.into(), + }), + total_deadline: 2_000u64.into(), // 2 seconds + }, + &[], + ) + .await; + admin.reload_settings().await; + + // Create a temporary failure task for immediate execution + admin.schedule_test_task(TASK_TEMP_FAIL, 0).await; + let task = admin.assert_has_retried_task().await; + let task_status = task.task.status().unwrap_retry(); + assert_eq!(task_status.failure_reason, "Simulated temporary failure"); + assert_eq!(task_status.attempt_number, 1); + + // Wait until 2 seconds deadline is reached + test.wait_for_tasks_skip_failures().await; + let task = admin.assert_has_failed_task().await; + let task_status = task.task.status().unwrap_failed(); + assert_eq!(task_status.failure_reason, "Simulated temporary failure"); + assert_eq!(task_status.failed_attempt_number, 2); + admin + .registry_destroy(ObjectType::Task, [task.id]) + .await + .assert_destroyed(&[task.id]); + + pagination_test(test).await; + + test.cleanup().await; +} + +async fn pagination_test(test: &mut TestServer) { + println!("Running Task pagination tests..."); + let admin = test.account("admin@example.org"); + + admin.assert_no_tasks().await; + + let mut created = Vec::with_capacity(12); + for i in 0..12u64 { + created.push(admin.schedule_test_task(TASK_SUCCESS, 3600 + i).await); + } + + let asc_order: Vec = admin + .registry_query_paginated(ObjectType::Task, "due", true, None, None, None, None, false) + .await + .object_ids() + .collect(); + assert_eq!( + asc_order.len(), + 12, + "expected 12 tasks, got {}", + asc_order.len() + ); + + let desc_order: Vec = asc_order.iter().rev().copied().collect(); + + for chunk_start in [0usize, 5, 10] { + let chunk_size = std::cmp::min(5, 12 - chunk_start); + + let asc = admin + .registry_query_paginated( + ObjectType::Task, + "due", + true, + Some(chunk_start as i32), + Some(5), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + asc, + asc_order[chunk_start..chunk_start + chunk_size], + "ascending position={chunk_start} limit=5", + ); + + let desc = admin + .registry_query_paginated( + ObjectType::Task, + "due", + false, + Some(chunk_start as i32), + Some(5), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + desc, + desc_order[chunk_start..chunk_start + chunk_size], + "descending position={chunk_start} limit=5", + ); + } + + for anchor_idx in [4usize, 9] { + let chunk_size = std::cmp::min(5, 12 - anchor_idx - 1); + + let asc = admin + .registry_query_paginated( + ObjectType::Task, + "due", + true, + None, + Some(5), + Some(asc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + asc, + asc_order[anchor_idx + 1..anchor_idx + 1 + chunk_size], + "ascending anchor={} offset=1 limit=5", + asc_order[anchor_idx], + ); + + let desc = admin + .registry_query_paginated( + ObjectType::Task, + "due", + false, + None, + Some(5), + Some(desc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + desc, + desc_order[anchor_idx + 1..anchor_idx + 1 + chunk_size], + "descending anchor={} offset=1 limit=5", + desc_order[anchor_idx], + ); + } + + let response = admin + .registry_query_paginated( + ObjectType::Task, + "due", + true, + Some(0), + Some(5), + None, + None, + true, + ) + .await; + let total = response + .pointer("/methodResponses/0/1/total") + .and_then(|v| v.as_u64()); + assert_eq!(total, Some(12), "expected calculateTotal=12"); + + admin + .registry_destroy(ObjectType::Task, created.clone()) + .await + .assert_destroyed(&created); + admin.assert_no_tasks().await; +} + +impl Account { + async fn schedule_test_task(&self, test_type: u64, schedule_in: u64) -> Id { + self.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::RemoveLockDav, + shard_index: Some(test_type), + status: TaskStatus::at((now() + schedule_in) as i64), + })) + .await + } + + pub async fn task_ids(&self) -> Vec { + self.registry_query_ids( + ObjectType::Task, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + } + + pub async fn tasks(&self) -> Vec { + let ids = self.task_ids().await; + let mut results = Vec::with_capacity(ids.len()); + for id in ids { + let sample = self.registry_get::(id).await; + results.push(TaskId { id, task: sample }); + } + results + } + + async fn assert_no_tasks(&self) { + self.await_tasks(0, |_| true).await; + } + + async fn assert_has_tasks(&self, count: usize) -> Vec { + self.await_tasks(count, |_| true).await + } + + async fn assert_has_failed_task(&self) -> TaskId { + self.await_tasks(1, |task| { + matches!(task.task.status(), TaskStatus::Failed(_)) + }) + .await + .into_iter() + .next() + .unwrap() + } + + async fn assert_has_retried_task(&self) -> TaskId { + self.await_tasks(1, |task| matches!(task.task.status(), TaskStatus::Retry(_))) + .await + .into_iter() + .next() + .unwrap() + } + + async fn await_tasks( + &self, + count: usize, + is_expected: impl Fn(&TaskId) -> bool, + ) -> Vec { + let mut attempt = 0; + loop { + let tasks = self.tasks().await; + if tasks.len() == count && tasks.iter().all(&is_expected) { + return tasks; + } + + attempt += 1; + assert!( + attempt < TASK_WAIT_ATTEMPTS, + "Expected {} tasks, found {}: {:?}", + count, + tasks.len(), + tasks + ); + tokio::time::sleep(TASK_WAIT_INTERVAL).await; + } + } +} + +#[derive(Debug)] +pub struct TaskId { + pub id: Id, + pub task: Task, +} + +#[allow(dead_code)] +trait UnwrapTaskStatus { + fn unwrap_pending(&self) -> &TaskStatusPending; + fn unwrap_retry(&self) -> &TaskStatusRetry; + fn unwrap_failed(&self) -> &TaskStatusFailed; +} + +impl UnwrapTaskStatus for TaskStatus { + fn unwrap_pending(&self) -> &TaskStatusPending { + match self { + TaskStatus::Pending(status) => status, + _ => panic!("Expected TaskStatus::Pending, found {:?}", self), + } + } + + fn unwrap_retry(&self) -> &TaskStatusRetry { + match self { + TaskStatus::Retry(status) => status, + _ => panic!("Expected TaskStatus::Retry, found {:?}", self), + } + } + + fn unwrap_failed(&self) -> &TaskStatusFailed { + match self { + TaskStatus::Failed(status) => status, + _ => panic!("Expected TaskStatus::Failed, found {:?}", self), + } + } +} diff --git a/tests/src/telemetry/alerts.rs b/tests/src/telemetry/alerts.rs new file mode 100644 index 0000000..446a5f5 --- /dev/null +++ b/tests/src/telemetry/alerts.rs @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use common::BuildServer; +use registry::{ + schema::{ + prelude::ObjectType, + structs::{ + Alert, AlertEmail, AlertEmailProperties, AlertEvent, AlertEventProperties, Expression, + }, + }, + types::map::Map, +}; +use trc::{ClusterEvent, Collector, EventType, MetricType}; + +pub async fn test(test: &TestServer) { + println!("Running Alerts tests..."); + + // Create alerts + let admin = test.account("admin@example.org"); + admin + .registry_create_object(Alert { + enable: true, + condition: Expression { + else_: "metric('domain.count') > 1 && metric('cluster.publisher-error') > 3".into(), + ..Default::default() + }, + email_alert: AlertEmail::Enabled(AlertEmailProperties { + body: concat!( + "Sorry for the bad news, but we found %{domain.count}% ", + "domains and %{cluster.publisher-error}% cluster errors." + ) + .to_string(), + from_address: "alert@example.com".to_string(), + from_name: "Alert Subsystem".to_string().into(), + subject: "Found %{cluster.publisher-error}% cluster errors".to_string(), + to: Map::new(vec!["jdoe@example.com".to_string()]), + }), + event_alert: AlertEvent::Enabled(AlertEventProperties { + event_message: "Yikes! Found %{cluster.publisher-error}% cluster errors!" + .to_string() + .into(), + }), + }) + .await; + admin + .registry_create_object(Alert { + enable: true, + condition: Expression { + else_: "metric('domain.count') < 1 || metric('cluster.publisher-error') < 3".into(), + ..Default::default() + }, + email_alert: AlertEmail::Disabled, + event_alert: AlertEvent::Enabled(AlertEventProperties { + event_message: "this should not have happened".to_string().into(), + }), + }) + .await; + admin.reload_settings().await; + + // Make sure the required metrics are set to 0 + assert_eq!( + Collector::read_metric(MetricType::ClusterPublisherError), + 0.0 + ); + assert_eq!(Collector::read_metric(MetricType::DomainCount), 1.0); + assert_eq!(Collector::read_metric(MetricType::TelemetryAlertEvent), 0.0); + + // Increment metrics to trigger alerts + Collector::update_event_counter(EventType::Cluster(ClusterEvent::PublisherError), 5); + Collector::update_gauge(MetricType::DomainCount, 3); + + // Make sure the values were set + assert_eq!( + Collector::read_metric(MetricType::ClusterPublisherError), + 5.0 + ); + assert_eq!(Collector::read_metric(MetricType::DomainCount), 3.0); + + // Process alerts + let message = test + .server + .inner + .build_server() + .process_alerts() + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(message.from, "alert@example.com"); + assert_eq!(message.to, vec!["jdoe@example.com".to_string()]); + let body = String::from_utf8(message.body).unwrap(); + assert!( + body.contains("Sorry for the bad news, but we found 3 domains and 5 cluster errors."), + "{body:?}" + ); + assert!(body.contains("Subject: Found 5 cluster errors"), "{body:?}"); + assert!( + body.contains("From: \"Alert Subsystem\" "), + "{body:?}" + ); + assert!(body.contains("To: "), "{body:?}"); + + // Make sure the event was triggered + assert_eq!(Collector::read_metric(MetricType::TelemetryAlertEvent), 1.0); + + // Cleanup + admin.registry_destroy_all(ObjectType::Alert).await; + admin.reload_settings().await; + test.cleanup().await; +} diff --git a/tests/src/telemetry/metrics.rs b/tests/src/telemetry/metrics.rs new file mode 100644 index 0000000..f1ef62c --- /dev/null +++ b/tests/src/telemetry/metrics.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use common::telemetry::metrics::store::MetricsStore; +use registry::{schema::prelude::ObjectType, types::datetime::UTCDateTime}; +use std::time::Duration; +use store::write::now; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Metrics tests..."); + + // Make sure there are no span entries in the db + let admin = test.account("admin@example.org"); + assert_eq!( + admin + .registry_query( + ObjectType::Metric, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(), + Vec::::new() + ); + + // Insert test metrics + test.server.insert_test_metrics().await; + + // Fetch all metrics + let metric_ids = admin + .registry_query( + ObjectType::Metric, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(); + let response = admin + .registry_get_many(ObjectType::Metric, Vec::<&str>::new()) + .await; + let metrics = response.list(); + assert!( + metrics.len() > 2000, + "Found {} metrics, expected more than 2000", + metrics.len() + ); + assert_eq!(metrics.len(), metric_ids.len()); + + // Fetch the last 48 hours of metrics + let metric_ids = admin + .registry_query( + ObjectType::Metric, + [( + "timestampIsGreaterThan", + UTCDateTime::from_timestamp((now() - (2 * 86400)) as i64).to_string(), + )], + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(); + assert!( + metric_ids.len() > 20 && metric_ids.len() < 2000, + "Found {} metrics, expected more than 20 and less than 2000", + metric_ids.len() + ); + + // Test pagination (forward and reverse) + let asc_order: Vec = admin + .registry_query_paginated( + ObjectType::Metric, + "timestamp", + true, + None, + None, + None, + None, + false, + ) + .await + .object_ids() + .collect(); + assert!( + asc_order.len() > 100, + "expected >100 metrics, got {}", + asc_order.len() + ); + let desc_order: Vec = asc_order.iter().rev().copied().collect(); + let total = asc_order.len(); + let limit = 25usize; + + for chunk_start in [0usize, limit, total - limit] { + let asc = admin + .registry_query_paginated( + ObjectType::Metric, + "timestamp", + true, + Some(chunk_start as i32), + Some(limit), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + asc, + asc_order[chunk_start..chunk_start + limit], + "ascending position={chunk_start} limit={limit}", + ); + + let desc = admin + .registry_query_paginated( + ObjectType::Metric, + "timestamp", + false, + Some(chunk_start as i32), + Some(limit), + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert_eq!( + desc, + desc_order[chunk_start..chunk_start + limit], + "descending position={chunk_start} limit={limit}", + ); + } + + for anchor_idx in [limit - 1, total - limit - 1] { + let asc = admin + .registry_query_paginated( + ObjectType::Metric, + "timestamp", + true, + None, + Some(limit), + Some(asc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + let asc_size = std::cmp::min(limit, total - anchor_idx - 1); + assert_eq!( + asc, + asc_order[anchor_idx + 1..anchor_idx + 1 + asc_size], + "ascending anchor={} offset=1 limit={limit}", + asc_order[anchor_idx], + ); + + let desc = admin + .registry_query_paginated( + ObjectType::Metric, + "timestamp", + false, + None, + Some(limit), + Some(desc_order[anchor_idx]), + Some(1), + false, + ) + .await + .object_ids() + .collect::>(); + let desc_size = std::cmp::min(limit, total - anchor_idx - 1); + assert_eq!( + desc, + desc_order[anchor_idx + 1..anchor_idx + 1 + desc_size], + "descending anchor={} offset=1 limit={limit}", + desc_order[anchor_idx], + ); + } + + // Purge metrics and make sure they are gone + test.server + .metrics_store() + .purge_metrics(Duration::from_secs(0)) + .await + .unwrap(); + assert_eq!( + admin + .registry_query( + ObjectType::Metric, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(), + Vec::::new() + ); +} diff --git a/tests/src/telemetry/mod.rs b/tests/src/telemetry/mod.rs new file mode 100644 index 0000000..86201fe --- /dev/null +++ b/tests/src/telemetry/mod.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod alerts; +pub mod metrics; +pub mod tracing; +pub mod webhooks; + +use crate::utils::server::TestServerBuilder; +use registry::schema::structs::{Expression, Jmap, MetricsStore, MtaStageAuth, TracingStore}; + +#[tokio::test(flavor = "multi_thread")] +pub async fn telemetry_tests() { + let mut test = TestServerBuilder::new("telemetry_tests") + .await + .with_logging() + .with_default_listeners() + .await + .with_object(MetricsStore::Default) + .await + .with_object(TracingStore::Default) + .await + .with_object(Jmap { + get_max_results: 100_000, + query_max_results: 100_000, + ..Default::default() + }) + .await + .with_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + + // Create admin account + let admin = test + .create_user_account( + "admin", + "admin@example.org", + "these_pretzels_are_making_me_thirsty", + &[], + "Admin", + ) + .await; + test.account("admin") + .assign_roles_to_account(admin.id(), &["user", "system"]) + .await; + test.insert_account(admin); + + alerts::test(&test).await; + metrics::test(&test).await; + tracing::test(&test).await; + webhooks::test(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/telemetry/tracing.rs b/tests/src/telemetry/tracing.rs new file mode 100644 index 0000000..b479381 --- /dev/null +++ b/tests/src/telemetry/tracing.rs @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, smtp::SmtpConnection}; +use common::telemetry::tracers::store::TracingStore; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::Trace, +}; +use std::time::Duration; +use trc::{DeliveryEvent, EventType, SmtpEvent}; +use types::id::Id; + +pub async fn test(test: &TestServer) { + println!("Running Tracing tests..."); + + // Create test accounts + let admin = test.account("admin@example.org"); + let account = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &[], + "jdoe@example.org", + ) + .await; + + // Make sure there are no span entries in the db + test.server + .tracing_store() + .purge_spans(Duration::from_secs(0), test.server.search_store().into()) + .await + .unwrap(); + assert_eq!( + admin + .registry_query( + ObjectType::Trace, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(), + Vec::::new() + ); + + // Send an email + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report\r\n", + "X-Spam-Status: No\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + lmtp.quit().await; + tokio::time::sleep(Duration::from_millis(300)).await; + test.server.notify_task_queue(); + test.wait_for_tasks().await; + + // There should be 2 spans + assert_eq!( + admin + .registry_query( + ObjectType::Trace, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .count(), + 2 + ); + + // Purge should not delete anything at this point + test.server + .tracing_store() + .purge_spans(Duration::from_secs(2), test.server.search_store().into()) + .await + .unwrap(); + + // There should be 2 spans + assert_eq!( + admin + .registry_query( + ObjectType::Trace, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .count(), + 2 + ); + + // Search by spam type + for span_type in [ + EventType::Delivery(DeliveryEvent::AttemptStart), + EventType::Smtp(SmtpEvent::ConnectionStart), + ] { + let span_ids = admin + .registry_query( + ObjectType::Trace, + [(Property::Event, span_type.as_str())], + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(); + + assert_eq!(span_ids.len(), 1, "{span_type:?}"); + let trace = admin.registry_get::(span_ids[0]).await; + + assert_eq!(trace.events.iter().next().unwrap().event, span_type); + } + + // Try searching + for keyword in ["bill@example.org", "jdoe@example.org", "example.org"] { + let span_ids = admin + .registry_query( + ObjectType::Trace, + [(Property::Text, keyword)], + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(); + + assert_eq!(span_ids.len(), 2, "keyword: {keyword}"); + + let trace_1 = admin.registry_get::(span_ids[0]).await; + let trace_2 = admin.registry_get::(span_ids[1]).await; + + assert!(trace_1 != trace_2, "keyword: {keyword}"); + } + + // Purge should delete the span entries + tokio::time::sleep(Duration::from_millis(800)).await; + test.server + .tracing_store() + .purge_spans(Duration::from_secs(1), test.server.search_store().into()) + .await + .unwrap(); + + assert_eq!( + admin + .registry_query( + ObjectType::Trace, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .object_ids() + .collect::>(), + Vec::::new() + ); + + admin.destroy_account(account).await; + test.cleanup().await; +} diff --git a/tests/src/telemetry/webhooks.rs b/tests/src/telemetry/webhooks.rs new file mode 100644 index 0000000..b20bde5 --- /dev/null +++ b/tests/src/telemetry/webhooks.rs @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use crate::utils::smtp::SmtpConnection; +use aws_lc_rs::hmac; +use base64::{Engine, engine::general_purpose::STANDARD}; +use common::{manager::application::Resource, telemetry::tracers::store::TracingStore}; +use http_proto::{ToHttpResponse, request::fetch_body}; +use hyper::{body, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use jmap::api::ToJmapHttpResponse; +use jmap_proto::error::request::RequestError; +use registry::{ + schema::{ + enums::EventPolicy, + prelude::ObjectType, + structs::{SecretKeyOptional, SecretKeyValue, WebHook}, + }, + types::map::Map, +}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use store::parking_lot::Mutex; +use tokio::{net::TcpListener, sync::watch}; +use trc::EventType; + +struct MockWebhookEndpoint { + pub _tx: watch::Sender, + pub events: Mutex>, + pub reject: AtomicBool, +} + +pub async fn test(test: &TestServer) { + println!("Running Webhooks tests..."); + + // Spawn mock webhook endpoint + let webhook = spawn_mock_webhook_endpoint(); + + // Add telemetry webhook + let admin = test.account("admin@example.org"); + admin + .registry_create_object(WebHook { + enable: true, + url: "http://127.0.0.1:8821/hook".into(), + signature_key: SecretKeyOptional::Value(SecretKeyValue { + secret: "ovos-moles".into(), + }), + throttle: 100u64.into(), + allow_invalid_certs: true, + events: Map::new( + EventType::variants() + .iter() + .filter(|ev| { + let ev = ev.as_str(); + ev.starts_with("smtp.connection-") + || ev.starts_with("delivery.dsn") + || ev.starts_with("message-ingest.") + }) + .copied() + .collect(), + ), + events_policy: EventPolicy::Include, + ..Default::default() + }) + .await; + admin.reload_settings().await; + + // Send test email + let john = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &["john.doe@example.org"], + "jdoe@example.org", + ) + .await; + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + test.wait_for_tasks().await; + + // Enable the webhook + webhook.assert_is_empty(); + webhook.accept(); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Check for events + webhook.assert_contains(&[ + "smtp.connection-start", + "message-ingest.", + "delivery.dsn", + "\"from\": \"bill@example.org\"", + "\"jdoe@example.org\"", + ]); + + // Cleanup + admin.registry_destroy_all(ObjectType::WebHook).await; + admin.reload_settings().await; + admin.destroy_account(john).await; + test.server + .tracing_store() + .purge_spans(Duration::from_secs(0), test.server.search_store().into()) + .await + .unwrap(); + test.cleanup().await; +} + +impl MockWebhookEndpoint { + pub fn assert_contains(&self, expected: &[&str]) { + let events = + serde_json::to_string_pretty(&self.events.lock().drain(..).collect::>()) + .unwrap(); + + for string in expected { + if !events.contains(string) { + panic!( + "Expected events to contain '{}', but it did not. Events: {}", + string, events + ); + } + } + } + + pub fn accept(&self) { + self.reject.store(false, Ordering::Relaxed); + } + + /*pub fn reject(&self) { + self.reject.store(true, Ordering::Relaxed); + } + + pub fn clear(&self) { + self.events.lock().clear(); + }*/ + + pub fn assert_is_empty(&self) { + assert!(self.events.lock().is_empty()); + } +} + +fn spawn_mock_webhook_endpoint() -> Arc { + let (_tx, rx) = watch::channel(true); + let endpoint_ = Arc::new(MockWebhookEndpoint { + _tx, + events: Mutex::new(vec![]), + reject: true.into(), + }); + + let endpoint = endpoint_.clone(); + + tokio::spawn(async move { + let listener = TcpListener::bind("127.0.0.1:8821") + .await + .unwrap_or_else(|e| { + panic!("Failed to bind mock Webhooks server to 127.0.0.1:8821: {e}"); + }); + let mut rx_ = rx.clone(); + + loop { + tokio::select! { + stream = listener.accept() => { + match stream { + Ok((stream, _)) => { + + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(stream), + service_fn(|mut req: hyper::Request| { + let endpoint = endpoint.clone(); + + async move { + // Verify HMAC signature + let key = hmac::Key::new(hmac::HMAC_SHA256, "ovos-moles".as_bytes()); + let body = fetch_body(&mut req, usize::MAX, 0).await.unwrap(); + let tag = STANDARD.decode(req.headers().get("X-Signature").unwrap().to_str().unwrap()).unwrap(); + hmac::verify(&key, &body, &tag).expect("Invalid signature"); + + // Deserialize JSON + #[derive(serde::Deserialize)] + struct WebhookRequest { + events: Vec, + } + let request = serde_json::from_slice::(&body) + .expect("Failed to parse JSON"); + + if !endpoint.reject.load(Ordering::Relaxed) { + //let c = print!("received webhook: {}", serde_json::to_string_pretty(&request).unwrap()); + + // Add events + endpoint.events.lock().extend(request.events); + + Ok::<_, hyper::Error>( + Resource::new("application/json", "[]".to_string().into_bytes()) + .into_http_response().build(), + ) + } else { + //let c = print!("rejected webhook: {}", serde_json::to_string_pretty(&request).unwrap()); + + Ok::<_, hyper::Error>( + RequestError::not_found().into_http_response().build() + ) + } + + } + }), + ) + .await; + } + Err(err) => { + panic!("Something went wrong: {err}" ); + } + } + }, + _ = rx_.changed() => { + break; + } + }; + } + }); + + endpoint_ +} diff --git a/tests/src/utils/account.rs b/tests/src/utils/account.rs new file mode 100644 index 0000000..ccf8e3e --- /dev/null +++ b/tests/src/utils/account.rs @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{imap::ImapConnection, server::TestServer, webdav::DummyWebDavClient}; +use ahash::AHashMap; +use jmap_client::client::{Client, Credentials}; +use registry::{ + schema::{ + enums::Permission, + prelude::{ObjectType, Property}, + structs::{ + self, CertificateManagement, Credential, CustomRoles, DkimManagement, DnsManagement, + Domain, EmailAlias, GroupAccount, PasswordCredential, Permissions, PermissionsList, + Roles, UserAccount, + }, + }, + types::{list::List, map::Map}, +}; +use serde_json::json; +use std::time::Duration; +use types::id::Id; + +pub struct Account { + name: &'static str, + secret: &'static str, + emails: &'static [&'static str], + description: &'static str, + id: Id, + id_string: String, + pub http_listener_port: u16, +} + +impl TestServer { + pub async fn create_user_account( + &self, + using_account: &str, + name: &'static str, + secret: &'static str, + aliases: &'static [&'static str], + description: &'static str, + ) -> Account { + self.account(using_account) + .create_user_account(name, secret, description, aliases, vec![]) + .await + } + + pub async fn create_admin_account(&self, name: &'static str) -> Account { + let admin = self + .create_user_account( + "admin", + name, + "these_pretzels_are_making_me_thirsty", + &[], + "Admin", + ) + .await; + self.account("admin") + .assign_roles_to_account(admin.id(), &["user", "system"]) + .await; + admin + } + + pub fn insert_account(&mut self, account: Account) { + self.accounts.insert(account.name(), account); + } +} + +impl Account { + pub fn new( + name: &'static str, + secret: &'static str, + emails: &'static [&'static str], + description: &'static str, + id: Id, + ) -> Self { + Self { + name, + secret, + emails, + description, + id, + id_string: id.to_string(), + http_listener_port: 8899, + } + } + + pub fn update_secret(&mut self, new_secret: &'static str) { + self.secret = new_secret; + } + + pub fn id(&self) -> Id { + self.id + } + + pub fn id_string(&self) -> &str { + &self.id_string + } + + pub fn name(&self) -> &'static str { + self.name + } + + pub fn description(&self) -> &'static str { + self.description + } + + pub fn secret(&self) -> &'static str { + self.secret + } + + pub fn emails(&self) -> &'static [&'static str] { + self.emails + } + + pub async fn find_or_create_domain(&self, name: &'static str) -> Id { + let ids = self + .registry_query_ids( + ObjectType::Domain, + [(Property::Name, name)], + Vec::<&str>::new(), + ) + .await; + + match ids.len() { + 0 => self.create_domain(name).await, + 1 => ids[0], + _ => panic!("Multiple domains with name {name} found"), + } + } + + pub async fn create_user_account( + &self, + name: &'static str, + secret: &'static str, + description: &'static str, + aliases: &'static [&'static str], + extra_permissions: Vec, + ) -> Account { + let mut domains = AHashMap::from_iter(aliases.iter().copied().chain([name]).map(|email| { + let domain = email.split('@').nth(1).expect("Invalid email address"); + (domain, Id::singleton()) + })); + for (name, id) in &mut domains { + *id = self.find_or_create_domain(name).await; + } + let (account_name, domain_id) = name + .rsplit_once('@') + .map(|(name, domain)| (name.to_string(), *domains.get(domain).unwrap())) + .unwrap(); + let account_aliases = aliases.iter().filter(|email| **email != name).map(|email| { + let (name, domain_id) = email + .rsplit_once('@') + .map(|(name, domain)| (name.to_string(), *domains.get(domain).unwrap())) + .unwrap(); + EmailAlias { + name, + domain_id, + enabled: true, + ..Default::default() + } + }); + + let account_id = self + .registry_create_object(structs::Account::User(UserAccount { + name: account_name, + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: secret.to_string(), + ..Default::default() + })]), + aliases: List::from_iter(account_aliases), + description: description.to_string().into(), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Default::default(), + enabled_permissions: Map::new(extra_permissions), + }), + ..Default::default() + })) + .await; + + let mut account = Account::new(name, secret, aliases, description, account_id); + account.http_listener_port = self.http_listener_port; + account + } + + pub async fn create_group_account( + &self, + name: &'static str, + description: &'static str, + aliases: &'static [&'static str], + ) -> Account { + let mut domains = AHashMap::from_iter(aliases.iter().copied().chain([name]).map(|email| { + let domain = email.split('@').nth(1).expect("Invalid email address"); + (domain, Id::singleton()) + })); + for (name, id) in &mut domains { + *id = self.find_or_create_domain(name).await; + } + let (account_name, domain_id) = name + .rsplit_once('@') + .map(|(name, domain)| (name.to_string(), *domains.get(domain).unwrap())) + .unwrap(); + let account_aliases = aliases.iter().map(|email| { + let (name, domain_id) = email + .rsplit_once('@') + .map(|(name, domain)| (name.to_string(), *domains.get(domain).unwrap())) + .unwrap(); + EmailAlias { + name, + domain_id, + enabled: true, + ..Default::default() + } + }); + + let account_id = self + .registry_create_object(structs::Account::Group(GroupAccount { + name: account_name, + domain_id, + aliases: List::from_iter(account_aliases), + description: description.to_string().into(), + ..Default::default() + })) + .await; + + Account::new(name, "", aliases, description, account_id) + } + + pub async fn create_domain(&self, name: &'static str) -> Id { + self.registry_create_object(Domain { + is_enabled: true, + name: name.to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + } + + pub async fn assign_roles_to_account(&self, account_id: Id, names: &[&str]) { + let mut role_ids = Vec::new(); + for name in names { + let role_id = *self + .registry_query_ids( + ObjectType::Role, + [(Property::Description, *name)], + Vec::<&str>::new(), + ) + .await + .first() + .unwrap_or_else(|| panic!("Role {name} not found")); + role_ids.push(role_id); + } + + self.registry_update( + ObjectType::Account, + [( + account_id, + json!({ + Property::Roles: Roles::Custom(CustomRoles { role_ids: Map::new(role_ids) }) + }), + )], + ) + .await + .updated_id(account_id); + } + + pub fn webdav_client(&self) -> DummyWebDavClient { + DummyWebDavClient::new( + self.id.document_id(), + self.name(), + self.secret(), + self.emails()[0], + ) + } + + pub async fn imap_client(&self) -> ImapConnection { + let mut imap = ImapConnection::connect(b"_x ").await; + imap.authenticate(self.name(), self.secret()).await; + imap + } + + pub async fn jmap_client(&self) -> Client { + let mut client = Client::new() + .credentials(Credentials::basic(self.name(), self.secret())) + .timeout(Duration::from_secs(3600)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect(&format!("https://127.0.0.1:{}", self.http_listener_port)) + .await + .unwrap(); + client.set_default_account_id(self.id_string()); + client + } +} diff --git a/tests/src/utils/cleanup.rs b/tests/src/utils/cleanup.rs new file mode 100644 index 0000000..723fae3 --- /dev/null +++ b/tests/src/utils/cleanup.rs @@ -0,0 +1,428 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ::registry::{ + schema::prelude::{OBJ_SINGLETON, ObjectType}, + types::EnumImpl, +}; +use store::{ + ValueKey, + write::{key::DeserializeBigEndian, *}, + *, +}; +use trc::AddContext; +use types::blob_hash::{BLOB_HASH_LEN, BlobHash}; + +pub async fn store_destroy(store: &Store) { + store_destroy_sql_indexes(store).await; + + for subspace in [ + SUBSPACE_ACL, + SUBSPACE_TASK_QUEUE, + SUBSPACE_INDEXES, + SUBSPACE_DELETED_ITEMS, + SUBSPACE_SPAM_SAMPLES, + SUBSPACE_BLOB_LINK, + SUBSPACE_LOGS, + SUBSPACE_IN_MEMORY_COUNTER, + SUBSPACE_IN_MEMORY_VALUE, + SUBSPACE_COUNTER, + SUBSPACE_PROPERTY, + SUBSPACE_REGISTRY, + SUBSPACE_BLOBS, + SUBSPACE_QUEUE_MESSAGE, + SUBSPACE_QUEUE_EVENT, + SUBSPACE_QUOTA, + SUBSPACE_REPORT_OUT, + SUBSPACE_REPORT_IN, + SUBSPACE_TELEMETRY_SPAN, + SUBSPACE_TELEMETRY_METRIC, + SUBSPACE_SEARCH_INDEX, + SUBSPACE_REGISTRY_IDX, + SUBSPACE_REGISTRY_PK, + SUBSPACE_DIRECTORY, + ] { + if subspace == SUBSPACE_SEARCH_INDEX && store.is_pg_or_mysql() { + continue; + } + + store + .delete_range( + AnyKey { + subspace, + key: vec![0u8], + }, + AnyKey { + subspace, + key: vec![u8::MAX; 16], + }, + ) + .await + .unwrap(); + } +} + +pub async fn search_store_destroy(store: &SearchStore) { + match &store { + SearchStore::Store(store) => { + store_destroy_sql_indexes(store).await; + } + SearchStore::ElasticSearch(store) => { + if let Err(err) = store.drop_indexes().await { + eprintln!("Failed to drop elasticsearch indexes: {}", err); + } + store.create_indexes().await.unwrap(); + } + SearchStore::MeiliSearch(store) => { + if let Err(err) = store.drop_indexes().await { + eprintln!("Failed to drop meilisearch indexes: {}", err); + } + store.create_indexes().await.unwrap(); + } + } +} + +#[allow(unused_variables)] +async fn store_destroy_sql_indexes(store: &Store) { + #[cfg(any(feature = "postgres", feature = "mysql"))] + { + if store.is_pg_or_mysql() { + for index in [ + SearchIndex::Email, + SearchIndex::Calendar, + SearchIndex::Contacts, + SearchIndex::Tracing, + ] { + #[cfg(feature = "postgres")] + let table = index.psql_table(); + #[cfg(feature = "mysql")] + let table = index.mysql_table(); + + let _ = store + .sql_query::(&format!("TRUNCATE TABLE {table}"), vec![]) + .await; + } + } + } +} + +pub async fn store_blob_expire_all(store: &Store) { + // Delete all temporary hashes + let from_key = ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Commit { + hash: BlobHash::default(), + }), + }; + let to_key = ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Blob(BlobOp::Link { + hash: BlobHash::new_max(), + to: BlobLink::Document, + }), + }; + let mut batch = BatchBuilder::new(); + let mut last_account_id = u32::MAX; + store + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, _| { + if key.len() == BLOB_HASH_LEN + U32_LEN + U64_LEN { + let account_id = key + .deserialize_be_u32(BLOB_HASH_LEN) + .caused_by(trc::location!())?; + if account_id != last_account_id { + last_account_id = account_id; + batch.with_account_id(account_id); + } + let hash = + BlobHash::try_from_hash_slice(key.get(..BLOB_HASH_LEN).unwrap()).unwrap(); + let until = key + .deserialize_be_u64(BLOB_HASH_LEN + U32_LEN) + .caused_by(trc::location!())?; + + batch.clear(ValueClass::Blob(BlobOp::Link { + hash, + to: BlobLink::Temporary { until }, + })); + } + + Ok(true) + }, + ) + .await + .unwrap(); + store.write(batch.build_all()).await.unwrap(); +} + +pub async fn store_lookup_expire_all(store: &Store) { + // Delete all temporary counters + let from_key = ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(vec![0u8]))); + let to_key = ValueKey::from(ValueClass::InMemory(InMemoryClass::Key(vec![u8::MAX; 10]))); + + let mut expired_keys = Vec::new(); + let mut expired_counters = Vec::new(); + + store + .iterate(IterateParams::new(from_key, to_key), |key, value| { + let expiry = value.deserialize_be_u64(0).caused_by(trc::location!())?; + if expiry == 0 { + expired_counters.push(key.to_vec()); + } else if expiry != u64::MAX { + expired_keys.push(key.to_vec()); + } + Ok(true) + }) + .await + .unwrap(); + + if !expired_keys.is_empty() { + let mut batch = BatchBuilder::new(); + for key in expired_keys { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key)), + op: ValueOp::Clear, + }); + if batch.is_large_batch() { + store.write(batch.build_all()).await.unwrap(); + batch = BatchBuilder::new(); + } + } + if !batch.is_empty() { + store.write(batch.build_all()).await.unwrap(); + } + } + + if !expired_counters.is_empty() { + let mut batch = BatchBuilder::new(); + for key in expired_counters { + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Counter(key.clone())), + op: ValueOp::Clear, + }); + batch.any_op(Operation::Value { + class: ValueClass::InMemory(InMemoryClass::Key(key)), + op: ValueOp::Clear, + }); + if batch.is_large_batch() { + store.write(batch.build_all()).await.unwrap(); + batch = BatchBuilder::new(); + } + } + if !batch.is_empty() { + store.write(batch.build_all()).await.unwrap(); + } + } +} + +#[allow(unused_variables)] +pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include_registry: bool) { + store_blob_expire_all(store).await; + store_lookup_expire_all(store).await; + for shard_idx in 0..=u8::MAX { + store + .purge_blobs(blob_store.clone(), shard_idx) + .await + .unwrap(); + } + store.purge_store().await.unwrap(); + + let store = store.clone(); + let mut failed = false; + let mut delete_batch = BatchBuilder::new(); + + for (subspace, with_values) in [ + (SUBSPACE_ACL, true), + (SUBSPACE_TASK_QUEUE, true), + (SUBSPACE_IN_MEMORY_VALUE, true), + (SUBSPACE_IN_MEMORY_COUNTER, false), + (SUBSPACE_PROPERTY, true), + (SUBSPACE_QUEUE_MESSAGE, true), + (SUBSPACE_QUEUE_EVENT, true), + (SUBSPACE_REPORT_OUT, true), + (SUBSPACE_REPORT_IN, true), + (SUBSPACE_DELETED_ITEMS, true), + (SUBSPACE_SPAM_SAMPLES, true), + (SUBSPACE_BLOB_LINK, true), + (SUBSPACE_BLOBS, true), + (SUBSPACE_COUNTER, false), + (SUBSPACE_QUOTA, false), + (SUBSPACE_INDEXES, false), + (SUBSPACE_TELEMETRY_SPAN, true), + (SUBSPACE_TELEMETRY_METRIC, true), + (SUBSPACE_SEARCH_INDEX, true), + (SUBSPACE_REGISTRY, true), + (SUBSPACE_REGISTRY_IDX, false), + (SUBSPACE_REGISTRY_PK, true), + (SUBSPACE_DIRECTORY, true), + ] { + if subspace == SUBSPACE_SEARCH_INDEX && store.is_pg_or_mysql() { + continue; + } + + let from_key = AnyKey { + subspace, + key: vec![0u8], + }; + let to_key = AnyKey { + subspace, + key: vec![u8::MAX; 10], + }; + + store + .iterate( + IterateParams::new(from_key, to_key).set_values(with_values), + |key, value| { + match subspace { + SUBSPACE_COUNTER + if key.len() == U32_LEN + 1 + || key.len() == U32_LEN + || key.len() == U16_LEN => + { + // Message ID, change ID counters and registry counters + if key.len() != U16_LEN { + // Keep registry counters, delete the rest + delete_batch.clear(ValueClass::Any(AnyClass { + subspace, + key: key.to_vec(), + })); + } + return Ok(true); + } + SUBSPACE_INDEXES => { + println!( + concat!( + "Found index key, account {}, collection {}, ", + "document {}, property {}, value {:?}: {:?}" + ), + u32::from_be_bytes(key[0..4].try_into().unwrap()), + key[4], + u32::from_be_bytes(key[key.len() - 4..].try_into().unwrap()), + key[5], + String::from_utf8_lossy(&key[6..key.len() - 4]), + key + ); + } + SUBSPACE_REGISTRY | SUBSPACE_DIRECTORY | SUBSPACE_SPAM_SAMPLES => { + let object_id = + ObjectType::from_id(key.deserialize_be_u16(0).unwrap()).unwrap(); + + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + let item_id = key.deserialize_be_u64(U16_LEN).unwrap(); + + println!( + "Found registry item for object type {:?} and id {}", + object_id, item_id + ); + } + SUBSPACE_REGISTRY_IDX => { + let mut id = key.deserialize_be_u16(0).unwrap(); + if id == u16::MAX { + id = key.deserialize_be_u16(U16_LEN).unwrap(); + } + + let object_id = ObjectType::from_id(id).unwrap(); + + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + + println!( + "Found registry index for object type {:?}: {:?}", + object_id, key + ); + } + SUBSPACE_REGISTRY_PK => { + let mut id = key.deserialize_be_u16(0).unwrap(); + if id == u16::MAX { + id = value.deserialize_be_u16(0).unwrap(); + } + let object_id = ObjectType::from_id(id).unwrap(); + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + + println!( + "Found registry primary key for object type {:?}: {:?}", + object_id, key + ); + } + _ => { + println!( + "Found key in {:?}: {:?} ({:?}) = {:?} ({:?})", + char::from(subspace), + key, + String::from_utf8_lossy(key), + value, + String::from_utf8_lossy(value) + ); + } + } + failed = true; + + Ok(true) + }, + ) + .await + .unwrap(); + } + + // Delete logs and counters + store + .delete_range( + AnyKey { + subspace: SUBSPACE_LOGS, + key: &[0u8], + }, + AnyKey { + subspace: SUBSPACE_LOGS, + key: &[ + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + u8::MAX, + ], + }, + ) + .await + .unwrap(); + + if !delete_batch.is_empty() { + store.write(delete_batch.build_all()).await.unwrap(); + } + + if failed { + panic!("Store is not empty."); + } +} + +fn is_allowed_registry_type(object_type: ObjectType) -> bool { + (object_type.flags() & OBJ_SINGLETON) != 0 + || matches!( + object_type, + ObjectType::Role + | ObjectType::Account + | ObjectType::NetworkListener + | ObjectType::MtaDeliverySchedule + | ObjectType::MtaRoute + | ObjectType::MtaTlsStrategy + | ObjectType::MtaVirtualQueue + | ObjectType::MtaConnectionStrategy + | ObjectType::MtaInboundThrottle + | ObjectType::MtaQueueQuota + | ObjectType::Tracer + | ObjectType::Domain + ) +} diff --git a/tests/src/utils/containers.rs b/tests/src/utils/containers.rs new file mode 100644 index 0000000..57a2e43 --- /dev/null +++ b/tests/src/utils/containers.rs @@ -0,0 +1,540 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::{Duration, Instant}; + +use testcontainers::{ + ContainerAsync, GenericBuildableImage, GenericImage, ImageExt, ReuseDirective, + core::{CmdWaitFor, ExecCommand, Host, IntoContainerPort, WaitFor}, + runners::{AsyncBuilder, AsyncRunner}, +}; +use tokio::{net::TcpStream, sync::OnceCell}; + +const ACME_NETWORK: &str = "stalwart-test-acme"; + +const READY_TIMEOUT: Duration = Duration::from_secs(180); + +static FOUNDATIONDB: OnceCell> = OnceCell::const_new(); +static POSTGRES: OnceCell> = OnceCell::const_new(); +static MYSQL: OnceCell> = OnceCell::const_new(); +static MARIADB: OnceCell> = OnceCell::const_new(); +static REDIS: OnceCell> = OnceCell::const_new(); +static NATS: OnceCell> = OnceCell::const_new(); +static MINIO: OnceCell> = OnceCell::const_new(); +static OPENSEARCH: OnceCell> = OnceCell::const_new(); +static MEILISEARCH: OnceCell> = OnceCell::const_new(); +static KEYCLOAK: OnceCell> = OnceCell::const_new(); +static OPENLDAP: OnceCell> = OnceCell::const_new(); +static CHALLTESTSRV: OnceCell> = OnceCell::const_new(); +static PEBBLE: OnceCell> = OnceCell::const_new(); +static POWERDNS: OnceCell> = OnceCell::const_new(); +static SCIM_TESTER: OnceCell> = OnceCell::const_new(); + +const OPENLDAP_LDAPI_URL: &str = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi/"; + +const OPENLDAP_ALLOW_UNAUTHENTICATED_BIND: &str = r#"set -e +ldapmodify -Y EXTERNAL -Q -H "$LDAPI" <<'EOF' +dn: cn=config +changetype: modify +replace: olcAllows +olcAllows: bind_anon_dn +EOF +test "$(ldapwhoami -x -H "$LDAPI" -D uid=john.doe,ou=users,dc=stalwart,dc=test -w '')" = anonymous +"#; + +const POWERDNS_ZONE_INIT: &str = r#"set -e +for i in $(seq 1 60); do + pdnsutil list-all-zones >/dev/null 2>&1 && break + sleep 1 +done +if pdnsutil list-zone stalwart.test >/dev/null 2>&1; then + exit 0 +fi +pdnsutil create-zone stalwart.test ns1.stalwart.test +pdnsutil set-kind stalwart.test native +pdnsutil replace-rrset stalwart.test '' SOA 'ns1.stalwart.test. admin.stalwart.test. 2024010101 3600 900 604800 86400' +pdnsutil add-record stalwart.test 'ns1' A '127.0.0.1' +pdnsutil add-record stalwart.test '' A '127.0.0.1' +pdnsutil add-record stalwart.test '' MX '10 mail.stalwart.test.' +pdnsutil add-record stalwart.test 'mail' A '127.0.0.1' +pdnsutil import-tsig-key stalwart-update-key hmac-sha256 'c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw' +pdnsutil activate-tsig-key stalwart.test stalwart-update-key primary +pdnsutil set-meta stalwart.test TSIG-ALLOW-DNSUPDATE stalwart-update-key +pdnsutil set-meta stalwart.test ALLOW-DNSUPDATE-FROM '0.0.0.0/0' +"#; + +pub async fn ensure_foundationdb() { + let container = FOUNDATIONDB + .get_or_init(|| async { + GenericImage::new("foundationdb/foundationdb", "7.4.6") + .with_env_var("FDB_NETWORKING_MODE", "container") + .with_mapped_port(4500, 4500.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-foundationdb") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start FoundationDB container") + }) + .await; + + let start = Instant::now(); + loop { + if fdbcli(container, "status minimal") + .await + .contains("The database is available") + { + return; + } + let created = fdbcli(container, "configure new single memory").await; + if created.contains("Database created") || created.contains("Already exists") { + continue; + } + if start.elapsed() > READY_TIMEOUT { + panic!("Timed out configuring FoundationDB: {created}"); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +async fn fdbcli(container: &ContainerAsync, command: &str) -> String { + let mut result = container + .exec( + ExecCommand::new(["fdbcli", "--exec", command, "--timeout", "5"]) + .with_cmd_ready_condition(CmdWaitFor::exit()), + ) + .await + .expect("Failed to exec fdbcli"); + let stdout = result.stdout_to_vec().await.unwrap_or_default(); + let stderr = result.stderr_to_vec().await.unwrap_or_default(); + format!( + "{}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ) +} + +pub async fn ensure_postgres() { + POSTGRES + .get_or_init(|| async { + GenericImage::new("postgres", "16-alpine") + .with_wait_for(WaitFor::message_on_stderr( + "database system is ready to accept connections", + )) + .with_wait_for(WaitFor::message_on_stderr( + "database system is ready to accept connections", + )) + .with_env_var("POSTGRES_USER", "stalwart") + .with_env_var("POSTGRES_PASSWORD", "stalwart") + .with_env_var("POSTGRES_DB", "stalwart") + .with_mapped_port(5432, 5432.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-postgres") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start PostgreSQL container") + }) + .await; + wait_for_tcp(5432).await; +} + +pub async fn ensure_mysql() { + MYSQL + .get_or_init(|| async { + GenericImage::new("mysql", "8.0") + .with_wait_for(WaitFor::message_on_stderr("port: 3306 MySQL")) + .with_env_var("MYSQL_ROOT_PASSWORD", "password") + .with_env_var("MYSQL_DATABASE", "stalwart") + .with_cmd(["--default-authentication-plugin=mysql_native_password"]) + .with_mapped_port(3307, 3306.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-mysql") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start MySQL container") + }) + .await; + wait_for_tcp(3307).await; +} + +pub async fn ensure_mariadb() { + MARIADB + .get_or_init(|| async { + GenericImage::new("mariadb", "11.4") + .with_wait_for(WaitFor::message_on_stderr("port: 3306 mariadb.org")) + .with_env_var("MARIADB_ROOT_PASSWORD", "password") + .with_env_var("MARIADB_DATABASE", "stalwart") + .with_mapped_port(3308, 3306.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-mariadb") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start MariaDB container") + }) + .await; + wait_for_tcp(3308).await; +} + +pub async fn ensure_redis() { + REDIS + .get_or_init(|| async { + GenericImage::new("redis", "7-alpine") + .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections")) + .with_cmd(["redis-server", "--save", "", "--appendonly", "no"]) + .with_mapped_port(6379, 6379.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-redis") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start Redis container") + }) + .await; + wait_for_tcp(6379).await; +} + +pub async fn ensure_nats() { + NATS.get_or_init(|| async { + GenericImage::new("nats", "latest") + .with_wait_for(WaitFor::message_on_stderr("Server is ready")) + .with_cmd(["--addr", "0.0.0.0", "--port", "4222", "--http_port", "8222"]) + .with_mapped_port(4222, 4222.tcp()) + .with_mapped_port(8222, 8222.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-nats") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start NATS container") + }) + .await; + wait_for_tcp(4222).await; +} + +pub async fn ensure_minio() { + MINIO + .get_or_init(|| async { + GenericImage::new("minio/minio", "latest") + .with_env_var("MINIO_ROOT_USER", "minioadmin") + .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") + .with_cmd(["server", "/data", "--console-address", ":9001"]) + .with_mapped_port(9000, 9000.tcp()) + .with_mapped_port(9001, 9001.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-minio") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start MinIO container") + }) + .await; + wait_for_http("http://localhost:9000/minio/health/live").await; + create_minio_bucket().await; +} + +pub async fn ensure_opensearch() { + OPENSEARCH + .get_or_init(|| async { + GenericImage::new("opensearchproject/opensearch", "2") + .with_env_var("discovery.type", "single-node") + .with_env_var("DISABLE_SECURITY_PLUGIN", "true") + .with_env_var("OPENSEARCH_JAVA_OPTS", "-Xms1g -Xmx1g") + .with_env_var("DISABLE_INSTALL_DEMO_CONFIG", "true") + .with_mapped_port(9200, 9200.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-opensearch") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start OpenSearch container") + }) + .await; + wait_for_http("http://localhost:9200").await; +} + +pub async fn ensure_meilisearch() { + MEILISEARCH + .get_or_init(|| async { + GenericImage::new("getmeili/meilisearch", "latest") + .with_env_var("MEILI_ENV", "development") + .with_env_var("MEILI_NO_ANALYTICS", "true") + .with_env_var("MEILI_MASTER_KEY", "stalwart-master-key") + .with_mapped_port(7700, 7700.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-meilisearch") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start Meilisearch container") + }) + .await; + wait_for_http("http://localhost:7700/health").await; +} + +pub async fn ensure_keycloak() { + KEYCLOAK + .get_or_init(|| async { + GenericImage::new("quay.io/keycloak/keycloak", "latest") + .with_env_var("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") + .with_env_var("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") + .with_env_var("KC_HTTP_PORT", "9080") + .with_env_var("KC_HEALTH_ENABLED", "true") + .with_cmd(["start-dev", "--import-realm"]) + .with_copy_to( + "/opt/keycloak/data/import/stalwart-realm.json", + include_bytes!("../../docker/keycloak/stalwart-realm.json").to_vec(), + ) + .with_mapped_port(9080, 9080.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-keycloak") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start Keycloak container") + }) + .await; + wait_for_http("http://localhost:9080/realms/stalwart/.well-known/openid-configuration").await; +} + +pub async fn ensure_scim_tester() -> &'static ContainerAsync { + SCIM_TESTER + .get_or_init(|| async { + let image = GenericBuildableImage::new("stalwart-test-scim-tester", "local") + .with_dockerfile_string(include_str!("../../docker/scim/Dockerfile")) + .build_image() + .await + .expect("Failed to build the SCIM tester image"); + image + .with_host("host.docker.internal", Host::HostGateway) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-scim-tester") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start the SCIM tester container") + }) + .await +} + +pub async fn scim_tester_exec(args: &[&str]) -> (String, String) { + let mut result = ensure_scim_tester() + .await + .exec(ExecCommand::new(args.iter().copied()).with_cmd_ready_condition(CmdWaitFor::exit())) + .await + .expect("Failed to exec the SCIM driver"); + let stdout = result.stdout_to_vec().await.unwrap_or_default(); + let stderr = result.stderr_to_vec().await.unwrap_or_default(); + + ( + String::from_utf8_lossy(&stdout).into_owned(), + String::from_utf8_lossy(&stderr).into_owned(), + ) +} + +pub async fn ensure_acme() { + ensure_challtestsrv().await; + ensure_pebble().await; +} + +async fn ensure_challtestsrv() { + CHALLTESTSRV + .get_or_init(|| async { + let image = GenericBuildableImage::new("stalwart-test-challtestsrv", "local") + .with_dockerfile_string(include_str!("../../docker/pebble/Dockerfile.challtestsrv")) + .build_image() + .await + .expect("Failed to build challtestsrv image"); + image + .with_network(ACME_NETWORK) + .with_host("host.docker.internal", Host::HostGateway) + .with_mapped_port(8055, 8055.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-challtestsrv") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start challtestsrv container") + }) + .await; + wait_for_tcp(8055).await; +} + +async fn ensure_pebble() { + PEBBLE + .get_or_init(|| async { + GenericImage::new("ghcr.io/letsencrypt/pebble", "latest") + .with_env_var("PEBBLE_VA_NOSLEEP", "1") + .with_env_var("PEBBLE_WFE_NONCEREJECT", "0") + .with_env_var("PEBBLE_ALTERNATE_ROOTS", "2") + .with_cmd([ + "-config", + "/test/config/pebble-config.json", + "-dnsserver", + "stalwart-test-challtestsrv:8053", + ]) + .with_copy_to( + "/test/config/pebble-config.json", + include_bytes!("../../docker/pebble/pebble-config.json").to_vec(), + ) + .with_network(ACME_NETWORK) + .with_host("host.docker.internal", Host::HostGateway) + .with_mapped_port(14000, 14000.tcp()) + .with_mapped_port(15000, 15000.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-pebble") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start Pebble container") + }) + .await; + wait_for_tcp(14000).await; +} + +pub async fn ensure_powerdns() { + let container = POWERDNS + .get_or_init(|| async { + GenericImage::new("powerdns/pdns-auth-49", "latest") + .with_wait_for(WaitFor::message_on_stderr("Creating backend connection")) + .with_env_var("PDNS_AUTH_API_KEY", "stalwart-api-key") + .with_copy_to( + "/etc/powerdns/pdns.d/stalwart.conf", + include_bytes!("../../docker/powerdns/pdns.conf").to_vec(), + ) + .with_mapped_port(5300, 53.tcp()) + .with_mapped_port(5300, 53.udp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-powerdns") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start PowerDNS container") + }) + .await; + + let mut result = container + .exec( + ExecCommand::new(["bash", "-c", POWERDNS_ZONE_INIT]) + .with_cmd_ready_condition(CmdWaitFor::exit()), + ) + .await + .expect("Failed to exec PowerDNS zone init"); + if result.exit_code().await.ok().flatten() != Some(0) { + let stdout = + String::from_utf8_lossy(&result.stdout_to_vec().await.unwrap_or_default()).into_owned(); + let stderr = + String::from_utf8_lossy(&result.stderr_to_vec().await.unwrap_or_default()).into_owned(); + panic!("PowerDNS zone init failed:\n{stdout}\n{stderr}"); + } + wait_for_tcp(5300).await; +} + +pub async fn ensure_openldap() { + const BOOTSTRAP_DIR: &str = "/container/service/slapd/assets/config/bootstrap/ldif/custom"; + let container = OPENLDAP + .get_or_init(|| async { + GenericImage::new("osixia/openldap", "1.5.0") + .with_wait_for(WaitFor::message_on_stderr("slapd starting")) + .with_env_var("LDAP_ORGANISATION", "Stalwart Test") + .with_env_var("LDAP_DOMAIN", "stalwart.test") + .with_env_var("LDAP_BASE_DN", "dc=stalwart,dc=test") + .with_env_var("LDAP_ADMIN_PASSWORD", "admin") + .with_env_var("LDAP_TLS", "false") + .with_copy_to( + format!("{BOOTSTRAP_DIR}/50-users.ldif"), + include_bytes!("../../docker/ldap/50-users.ldif").to_vec(), + ) + .with_copy_to( + format!("{BOOTSTRAP_DIR}/60-groups.ldif"), + include_bytes!("../../docker/ldap/60-groups.ldif").to_vec(), + ) + .with_mapped_port(389, 389.tcp()) + .with_startup_timeout(READY_TIMEOUT) + .with_container_name("stalwart-test-openldap") + .with_reuse(ReuseDirective::Always) + .start() + .await + .expect("Failed to start OpenLDAP container") + }) + .await; + wait_for_tcp(389).await; + + let setup = format!("LDAPI={OPENLDAP_LDAPI_URL}\n{OPENLDAP_ALLOW_UNAUTHENTICATED_BIND}"); + let mut result = container + .exec( + ExecCommand::new(["bash", "-c", setup.as_str()]) + .with_cmd_ready_condition(CmdWaitFor::exit()), + ) + .await + .expect("Failed to exec OpenLDAP unauthenticated bind setup"); + if result.exit_code().await.ok().flatten() != Some(0) { + let stdout = + String::from_utf8_lossy(&result.stdout_to_vec().await.unwrap_or_default()).into_owned(); + let stderr = + String::from_utf8_lossy(&result.stderr_to_vec().await.unwrap_or_default()).into_owned(); + panic!("OpenLDAP unauthenticated bind setup failed:\n{stdout}\n{stderr}"); + } +} + +async fn create_minio_bucket() { + use s3::{Bucket, BucketConfiguration, Region, creds::Credentials}; + + let region = Region::Custom { + region: "eu-central-1".to_string(), + endpoint: "http://localhost:9000".to_string(), + }; + let credentials = Credentials::new(Some("minioadmin"), Some("minioadmin"), None, None, None) + .expect("Failed to build MinIO credentials"); + + match Bucket::create_with_path_style( + "stalwart", + region, + credentials, + BucketConfiguration::default(), + ) + .await + { + Ok(response) if response.success() => {} + Ok(_) => {} + Err(s3::error::S3Error::HttpFailWithBody(409, _)) => {} + Err(err) => panic!("Failed to create MinIO bucket: {err:?}"), + } +} + +async fn wait_for_tcp(port: u16) { + let start = Instant::now(); + loop { + if TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + return; + } + if start.elapsed() > READY_TIMEOUT { + panic!("Timed out waiting for TCP port {port}"); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +async fn wait_for_http(url: &str) { + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to build HTTP client"); + let start = Instant::now(); + loop { + if let Ok(response) = client.get(url).send().await + && response.status().is_success() + { + return; + } + if start.elapsed() > READY_TIMEOUT { + panic!("Timed out waiting for {url}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} diff --git a/tests/src/utils/dns.rs b/tests/src/utils/dns.rs new file mode 100644 index 0000000..4645ccd --- /dev/null +++ b/tests/src/utils/dns.rs @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use common::{ + Server, + config::{mailstore::spamfilter::IpResolver, smtp::resolver::Tlsa}, +}; +use mail_auth::{DnssecStatus, MX, RecordSet, Txt, common::resolver::ToFqdn}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, +}; + +pub trait DnsCache { + fn txt_add(&self, name: impl ToFqdn, value: impl Into, valid_until: std::time::Instant); + fn ipv4_add(&self, name: impl ToFqdn, value: Vec, valid_until: std::time::Instant); + fn ipv6_add(&self, name: impl ToFqdn, value: Vec, valid_until: std::time::Instant); + fn ipv4_add_dnssec( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ); + fn ipv6_add_dnssec( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ); + fn dnsbl_add(&self, name: &str, value: Vec, valid_until: std::time::Instant); + fn ptr_add(&self, name: IpAddr, value: Vec, valid_until: std::time::Instant); + fn mx_add( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ); + fn tlsa_add(&self, name: impl ToFqdn, value: Arc, valid_until: std::time::Instant); +} + +impl DnsCache for Server { + fn txt_add(&self, name: impl ToFqdn, value: impl Into, valid_until: std::time::Instant) { + self.inner.cache.dns_txt.insert_with_expiry( + name.to_fqdn().into_owned().into_boxed_str(), + value.into(), + valid_until, + ); + } + + fn ipv4_add(&self, name: impl ToFqdn, value: Vec, valid_until: std::time::Instant) { + self.ipv4_add_dnssec(name, value, DnssecStatus::Secure, valid_until); + } + + fn ipv4_add_dnssec( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ) { + self.inner.cache.dns_ipv4.insert_with_expiry( + name.to_fqdn().into_owned().into_boxed_str(), + RecordSet { + rrset: Arc::from(value), + dnssec_status, + }, + valid_until, + ); + } + + fn dnsbl_add(&self, name: &str, value: Vec, valid_until: std::time::Instant) { + self.inner.cache.dns_rbl.insert_with_expiry( + name.into(), + Some(Arc::new(IpResolver::new( + value + .iter() + .copied() + .next() + .unwrap_or(Ipv4Addr::BROADCAST) + .into(), + ))), + valid_until, + ); + } + + fn ipv6_add(&self, name: impl ToFqdn, value: Vec, valid_until: std::time::Instant) { + self.ipv6_add_dnssec(name, value, DnssecStatus::Secure, valid_until); + } + + fn ipv6_add_dnssec( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ) { + self.inner.cache.dns_ipv6.insert_with_expiry( + name.to_fqdn().into_owned().into_boxed_str(), + RecordSet { + rrset: Arc::from(value), + dnssec_status, + }, + valid_until, + ); + } + + fn ptr_add(&self, name: IpAddr, value: Vec, valid_until: std::time::Instant) { + self.inner.cache.dns_ptr.insert_with_expiry( + name, + RecordSet { + rrset: Arc::from(value.into_iter().map(Into::into).collect::>()), + dnssec_status: DnssecStatus::Indeterminate, + }, + valid_until, + ); + } + + fn mx_add( + &self, + name: impl ToFqdn, + value: Vec, + dnssec_status: DnssecStatus, + valid_until: std::time::Instant, + ) { + self.inner.cache.dns_mx.insert_with_expiry( + name.to_fqdn().into_owned().into_boxed_str(), + RecordSet { + rrset: Arc::from(value), + dnssec_status, + }, + valid_until, + ); + } + + fn tlsa_add(&self, name: impl ToFqdn, value: Arc, valid_until: std::time::Instant) { + self.inner.cache.dns_tlsa.insert_with_expiry( + name.to_fqdn().into_owned().into_boxed_str(), + value, + valid_until, + ); + } +} diff --git a/tests/src/utils/http.rs b/tests/src/utils/http.rs new file mode 100644 index 0000000..86f664e --- /dev/null +++ b/tests/src/utils/http.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use hyper::Method; +use reqwest::header::HeaderMap; +use serde::{Serialize, de::DeserializeOwned}; +use std::time::Duration; + +pub struct HttpRequest { + pub port: u16, + pub username: Option, + pub password: Option, +} + +pub struct HttpResponseFull { + pub status: reqwest::StatusCode, + pub headers: HeaderMap, + pub body: String, +} + +impl HttpResponseFull { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|v| v.to_str().ok()) + } + + pub fn rate_limit_policy(&self) -> Option<&str> { + self.header("RateLimit-Policy") + } + + pub fn rate_limit(&self) -> Option<&str> { + self.header("RateLimit") + } + + pub fn retry_after(&self) -> Option { + self.header("Retry-After").and_then(|v| v.parse().ok()) + } +} + +impl Default for HttpRequest { + fn default() -> Self { + Self { + port: 8899, + username: None, + password: None, + } + } +} + +impl HttpRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn with_credentials(port: u16, username: &str, password: &str) -> Self { + Self { + port, + username: Some(username.to_string()), + password: Some(password.to_string()), + } + } + + pub async fn post( + &self, + query: &str, + body: &impl Serialize, + ) -> Result { + self.request_raw( + Method::POST, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn patch( + &self, + query: &str, + body: &impl Serialize, + ) -> Result { + self.request_raw( + Method::PATCH, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn delete(&self, query: &str) -> Result { + self.request_raw(Method::DELETE, query, None) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn get(&self, query: &str) -> Result { + self.request_raw(Method::GET, query, None) + .await + .map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + pub async fn request( + &self, + method: Method, + query: &str, + ) -> Result { + self.request_raw(method, query, None).await.map(|result| { + serde_json::from_str::(&result).unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn send_full( + &self, + method: Method, + query: &str, + body: Option>, + content_type: Option<&str>, + ) -> HttpResponseFull { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request(method, format!("https://127.0.0.1:{}{query}", self.port)); + + if let Some(body) = body { + request = request.body(body); + } + + if let Some(ct) = content_type { + request = request.header(hyper::header::CONTENT_TYPE, ct); + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + request = request.basic_auth(username, Some(password)); + } + + let response = request.send().await.expect("HTTP request failed"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response.text().await.unwrap_or_default(); + HttpResponseFull { + status, + headers, + body, + } + } + + async fn request_raw( + &self, + method: Method, + query: &str, + body: Option, + ) -> Result { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request(method, format!("https://127.0.0.1:{}{query}", self.port)); + + if let Some(body) = body { + request = request.body(body); + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + request = request.basic_auth(username, Some(password)); + } + + request + .send() + .await + .map_err(|err| err.to_string())? + .bytes() + .await + .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) + .map_err(|err| err.to_string()) + } +} diff --git a/tests/src/utils/http_server.rs b/tests/src/utils/http_server.rs new file mode 100644 index 0000000..70df1a5 --- /dev/null +++ b/tests/src/utils/http_server.rs @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{AssertConfig, utils::server::TestServer}; +use ahash::AHashMap; +use common::{config::server::Listeners, network::SessionData}; +use http_proto::{HttpResponse, request::fetch_body}; +use hyper::{Method, Uri, body, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use registry::{ + schema::{ + enums::NetworkListenerProtocol, + prelude::{ObjectType, SocketAddr}, + structs::{NetworkListener, SystemSettings}, + }, + types::{id::ObjectId, map::Map}, +}; +use std::{str::FromStr, sync::Arc}; +use store::registry::{RegistryObject, bootstrap::Bootstrap}; +use tokio::sync::watch; + +#[derive(Clone)] +pub struct HttpSessionManager { + inner: HttpRequestHandler, +} + +pub type HttpRequestHandler = Arc HttpResponse + Sync + Send>; + +#[derive(Debug)] +pub struct HttpMessage { + pub method: Method, + pub headers: AHashMap, + pub uri: Uri, + pub body: Option>, +} + +impl HttpMessage { + pub fn get_url_encoded(&self, key: &str) -> Option { + form_urlencoded::parse(self.body.as_ref()?.as_slice()) + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) + } +} + +pub async fn spawn_mock_http_server( + test: &TestServer, + handler: HttpRequestHandler, + port: u16, +) -> (watch::Sender, watch::Receiver) { + // Start mock HTTP server + let mut bp = Bootstrap::new_uninitialized(test.server.registry().clone()); + let mut servers = Listeners::default(); + servers.parse_server( + &mut bp, + RegistryObject { + id: ObjectId::new(ObjectType::NetworkListener, 0u64.into()), + object: NetworkListener { + name: "mock-http".into(), + bind: Map::new(vec![ + SocketAddr::from_str(&format!("127.0.0.1:{port}")).unwrap(), + ]), + protocol: NetworkListenerProtocol::Http, + tls_implicit: true, + use_tls: true, + socket_reuse_address: true, + socket_reuse_port: true, + ..Default::default() + }, + revision: 0, + }, + &SystemSettings::default(), + ); + servers + .parse_tcp_acceptors(&mut bp, test.server.inner.clone()) + .await; + servers.bind_and_drop_priv(&mut bp); + bp.assert_no_errors(); + servers.spawn(|server, acceptor, shutdown_rx| { + server.spawn( + HttpSessionManager { + inner: handler.clone(), + }, + test.server.inner.clone(), + acceptor, + shutdown_rx, + ); + }) +} + +impl common::network::SessionManager for HttpSessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let sender = self.inner; + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|mut req: hyper::Request| { + let sender = sender.clone(); + + async move { + let response = sender(HttpMessage { + method: req.method().clone(), + uri: req.uri().clone(), + headers: req + .headers() + .iter() + .map(|(k, v)| { + (k.as_str().to_lowercase(), v.to_str().unwrap().to_string()) + }) + .collect(), + body: fetch_body(&mut req, 1024 * 1024, 0).await, + }); + + Ok::<_, hyper::Error>(response.build()) + } + }), + ) + .await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} diff --git a/tests/src/utils/imap.rs b/tests/src/utils/imap.rs new file mode 100644 index 0000000..785c530 --- /dev/null +++ b/tests/src/utils/imap.rs @@ -0,0 +1,386 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use imap_proto::ResponseType; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf}, + net::TcpStream, +}; + +pub struct ImapConnection { + tag: &'static [u8], + reader: BufReader>, + writer: WriteHalf, + last_raw: Vec, +} + +async fn read_lossy_line( + reader: &mut BufReader>, +) -> std::io::Result)>> { + let mut buf = Vec::new(); + let n = reader.read_until(b'\n', &mut buf).await?; + if n == 0 { + return Ok(None); + } + let mut trimmed = buf.as_slice(); + if trimmed.last() == Some(&b'\n') { + trimmed = &trimmed[..trimmed.len() - 1]; + } + if trimmed.last() == Some(&b'\r') { + trimmed = &trimmed[..trimmed.len() - 1]; + } + Ok(Some((String::from_utf8_lossy(trimmed).into_owned(), buf))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Type { + Tagged, + Untagged, + Continuation, + Status, +} + +impl ImapConnection { + pub async fn connect(tag: &'static [u8]) -> Self { + Self::connect_to(tag, "127.0.0.1:9991").await + } + + pub async fn connect_to(tag: &'static [u8], addr: impl AsRef) -> Self { + let (reader, writer) = tokio::io::split(TcpStream::connect(addr.as_ref()).await.unwrap()); + ImapConnection { + tag, + reader: BufReader::new(reader), + writer, + last_raw: Vec::new(), + } + } + + pub fn assert_last_contains_bytes(&self, pattern: &[u8]) -> &Self { + if !self.last_raw.windows(pattern.len()).any(|w| w == pattern) { + panic!( + "Expected byte sequence {:02x?} not found in last response ({} bytes).", + pattern, + self.last_raw.len() + ); + } + self + } + + pub async fn assert_read(&mut self, t: Type, rt: ResponseType) -> Vec { + let lines = self.read(t).await; + let mut buf = Vec::with_capacity(10); + buf.extend_from_slice(match t { + Type::Tagged => self.tag, + Type::Untagged | Type::Status => b"* ", + Type::Continuation => b"+ ", + }); + if !matches!(t, Type::Continuation | Type::Status) { + rt.serialize(&mut buf); + } + if lines + .last() + .unwrap() + .starts_with(&String::from_utf8(buf).unwrap()) + { + lines + } else { + panic!("Expected {:?}/{:?} from server but got: {:?}", t, rt, lines); + } + } + + pub async fn assert_disconnect(&mut self) { + match tokio::time::timeout( + Duration::from_millis(1500), + read_lossy_line(&mut self.reader), + ) + .await + { + Ok(Ok(None)) => {} + Ok(Ok(Some((line, _)))) => { + panic!("Expected connection to be closed, but got {:?}", line); + } + Ok(Err(err)) => { + panic!("Connection broken: {:?}", err); + } + Err(_) => panic!("Timeout while waiting for server response."), + } + } + + pub async fn read(&mut self, t: Type) -> Vec { + let mut lines = Vec::new(); + self.last_raw.clear(); + loop { + match tokio::time::timeout( + Duration::from_millis(1500), + read_lossy_line(&mut self.reader), + ) + .await + { + Ok(Ok(Some((line, raw)))) => { + self.last_raw.extend_from_slice(&raw); + let is_done = line.starts_with(match t { + Type::Tagged => std::str::from_utf8(self.tag).unwrap(), + Type::Untagged | Type::Status => "* ", + Type::Continuation => "+ ", + }); + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn authenticate(&mut self, user: &str, pass: &str) { + let creds = general_purpose::STANDARD.encode(format!("\0{user}\0{pass}")); + self.send(&format!( + "AUTHENTICATE PLAIN {{{}+}}\r\n{creds}", + creds.len() + )) + .await; + self.assert_read(Type::Tagged, ResponseType::Ok).await; + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {}{:?}", std::str::from_utf8(self.tag).unwrap(), text); + self.writer.write_all(self.tag).await.unwrap(); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_untagged(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } + + pub async fn append(&mut self, mailbox: &str, message: &str) { + self.send_ok(&format!( + "APPEND {:?} {{{}+}}\r\n{}", + mailbox, + message.len(), + message + )) + .await; + } + + pub async fn send_ok(&mut self, cmd: &str) { + self.send(cmd).await; + self.assert_read(Type::Tagged, ResponseType::Ok).await; + } +} + +pub trait AssertResult: Sized { + fn assert_folders<'x>( + self, + expected: impl IntoIterator)>, + match_all: bool, + ) -> Self; + + fn assert_response_code(self, code: &str) -> Self; + fn assert_contains(self, text: &str) -> Self; + fn assert_contains_any(self, expected_texts: &[&str]) -> Self; + fn assert_not_contains(self, expected_text: &str) -> Self; + fn assert_count(self, text: &str, occurrences: usize) -> Self; + fn assert_equals(self, text: &str) -> Self; + fn into_response_code(self) -> String; + fn into_highest_modseq(self) -> String; + fn into_uid_validity(self) -> String; + fn into_append_uid(self) -> String; + fn into_copy_uid(self) -> String; + fn into_modseq(self) -> String; +} + +impl AssertResult for Vec { + fn assert_folders<'x>( + self, + expected: impl IntoIterator)>, + match_all: bool, + ) -> Self { + let mut match_count = 0; + 'outer: for (mailbox_name, flags) in expected.into_iter() { + for result in self.iter() { + if result.contains(&format!("\"{}\"", mailbox_name)) { + for flag in flags { + if !flag.is_empty() && !result.contains(flag) { + panic!("Expected mailbox {} to have flag {}", mailbox_name, flag); + } + } + match_count += 1; + continue 'outer; + } + } + panic!("Mailbox {} is not present.", mailbox_name); + } + if match_all && match_count != self.len() - 1 { + panic!( + "Expected {} mailboxes, but got {}: {:?}", + match_count, + self.len() - 1, + self.iter().collect::>() + ); + } + self + } + + fn assert_response_code(self, code: &str) -> Self { + if !self.last().unwrap().contains(&format!("[{}]", code)) { + panic!( + "Response code {:?} not found, got {:?}", + code, + self.last().unwrap() + ); + } + self + } + + fn assert_contains(self, expected_text: &str) -> Self { + if self.iter().any(|line| line.contains(expected_text)) { + self + } else { + panic!("Expected {:?} but got {}.", expected_text, self.join("\n")); + } + } + + fn assert_contains_any(self, expected_texts: &[&str]) -> Self { + if self + .iter() + .any(|line| expected_texts.iter().any(|text| line.contains(text))) + { + self + } else { + panic!( + "Expected any of {:?} but got {}.", + expected_texts, + self.join("\n") + ); + } + } + + fn assert_not_contains(self, expected_text: &str) -> Self { + if !self.iter().any(|line| line.contains(expected_text)) { + self + } else { + panic!( + "Not expecting {:?} but got it {}.", + expected_text, + self.join("\n") + ); + } + } + + fn assert_count(self, text: &str, occurrences: usize) -> Self { + assert_eq!( + self.iter().filter(|l| l.contains(text)).count(), + occurrences, + "Expected {} occurrences of {:?}, found {} in {:?}.", + occurrences, + text, + self.iter().filter(|l| l.contains(text)).count(), + self + ); + self + } + + fn assert_equals(self, text: &str) -> Self { + for line in &self { + if line == text { + return self; + } + } + panic!("Expected response to be {:?}, got {:?}", text, self); + } + + fn into_response_code(self) -> String { + if let Some((_, code)) = self.last().unwrap().split_once('[') + && let Some((code, _)) = code.split_once(']') + { + return code.to_string(); + } + panic!("No response code found in {:?}", self.last().unwrap()); + } + + fn into_append_uid(self) -> String { + if let Some((_, code)) = self.last().unwrap().split_once("[APPENDUID ") + && let Some((code, _)) = code.split_once(']') + && let Some((_, uid)) = code.split_once(' ') + { + return uid.to_string(); + } + panic!("No APPENDUID found in {:?}", self.last().unwrap()); + } + + fn into_copy_uid(self) -> String { + for line in &self { + if let Some((_, code)) = line.split_once("[COPYUID ") + && let Some((code, _)) = code.split_once(']') + && let Some((_, uid)) = code.rsplit_once(' ') + { + return uid.to_string(); + } + } + panic!("No COPYUID found in {:?}", self); + } + + fn into_highest_modseq(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("HIGHESTMODSEQ ") { + if let Some((value, _)) = value.split_once(']') { + return value.to_string(); + } else if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No HIGHESTMODSEQ delimiter found in {:?}", line); + } + } + } + panic!("No HIGHESTMODSEQ entries found in {:?}", self); + } + + fn into_modseq(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("MODSEQ (") { + if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No MODSEQ delimiter found in {:?}", line); + } + } + } + panic!("No MODSEQ entries found in {:?}", self); + } + + fn into_uid_validity(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("UIDVALIDITY ") { + if let Some((value, _)) = value.split_once(']') { + return value.to_string(); + } else if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No UIDVALIDITY delimiter found in {:?}", line); + } + } + } + panic!("No UIDVALIDITY entries found in {:?}", self); + } +} diff --git a/tests/src/utils/jmap.rs b/tests/src/utils/jmap.rs new file mode 100644 index 0000000..2b6c019 --- /dev/null +++ b/tests/src/utils/jmap.rs @@ -0,0 +1,945 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::account::Account; +use base64::{Engine, engine::general_purpose}; +use hyper::header; +use jmap_proto::error::set::SetErrorType; +use registry::types::error::ValidationError; +use registry::types::id::ObjectId; +use serde_json::{Value, json}; +use std::{fmt::Display, str::FromStr, time::Duration}; +use types::id::Id; + +pub struct JmapResponse(pub Value); + +pub struct RawResponse { + pub status: u16, + pub headers: reqwest::header::HeaderMap, + pub body: Vec, +} + +impl RawResponse { + async fn from_response(response: reqwest::Response) -> Self { + RawResponse { + status: response.status().as_u16(), + headers: response.headers().clone(), + body: response.bytes().await.unwrap().to_vec(), + } + } + + pub fn json(&self) -> Option { + serde_json::from_slice(&self.body).ok() + } + + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.body).to_string() + } + + pub fn is_client_error(&self) -> bool { + (400..500).contains(&self.status) + } + + pub fn content_type(&self) -> Option<&str> { + self.headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ChangeType<'x> { + Created(&'x str), + Updated(&'x str), + Destroyed(&'x str), +} + +impl Account { + pub async fn jmap_get( + &self, + object: impl Display, + properties: impl IntoIterator, + ids: impl IntoIterator, + ) -> JmapResponse { + self.jmap_get_account(self, object, properties, ids).await + } + + pub async fn jmap_get_account( + &self, + account: &Account, + object: impl Display, + properties: impl IntoIterator, + ids: impl IntoIterator, + ) -> JmapResponse { + let ids = ids + .into_iter() + .map(|id| Value::String(id.to_string())) + .collect::>(); + let properties = properties + .into_iter() + .map(|p| Value::String(p.to_string())) + .collect::>(); + let properties = if properties.is_empty() { + Value::Null + } else { + Value::Array(properties) + }; + + if account.id().document_id() != u32::MAX { + self.jmap_method_calls(json!([[ + format!("{object}/get"), + { + "accountId": account.id_string(), + "properties": properties, + "ids": if !ids.is_empty() { Some(ids) } else { None } + }, + "0" + ]])) + .await + } else { + self.jmap_method_calls(json!([[ + format!("{object}/get"), + { + "properties": properties, + "ids": if !ids.is_empty() { Some(ids) } else { None } + }, + "0" + ]])) + .await + } + } + + pub async fn jmap_query( + &self, + object: impl Display, + filter: impl IntoIterator)>, + sort_by: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let filter = filter + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect::>(); + let sort_by = sort_by + .into_iter() + .map(|id| { + json! ({ + "property": id.to_string() + }) + }) + .collect::>(); + let arguments = [ + ("accountId".to_string(), self.id_string().into()), + ("filter".to_string(), Value::Object(filter)), + ("sort".to_string(), Value::Array(sort_by)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/query"), arguments, "0"]])) + .await + } + + pub async fn jmap_create( + &self, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + self.jmap_create_account(self, object, items, arguments) + .await + } + + pub async fn jmap_create_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let create = items + .into_iter() + .enumerate() + .map(|(i, item)| (format!("i{i}"), item)) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("create".to_string(), Value::Object(create)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/set"), arguments, "0"]])) + .await + } + + pub async fn jmap_update( + &self, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + self.jmap_update_account(self, object, items, arguments) + .await + } + + pub async fn jmap_update_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let update = items + .into_iter() + .map(|(i, item)| (i.to_string(), item)) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("update".to_string(), Value::Object(update)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/set"), arguments, "0"]])) + .await + } + + pub async fn jmap_destroy( + &self, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + self.jmap_destroy_account(self, object, items, arguments) + .await + } + + pub async fn jmap_destroy_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let destroy = items + .into_iter() + .map(|id| Value::String(id.to_string())) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("destroy".to_string(), Value::Array(destroy)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/set"), arguments, "0"]])) + .await + } + + pub async fn jmap_copy( + &self, + from_account: &Account, + to_account: &Account, + object: impl Display, + items: impl IntoIterator, + on_success_destroy: bool, + ) -> JmapResponse { + self.jmap_method_calls(json!([[ + format!("{object}/copy"), + { + "fromAccountId": from_account.id_string(), + "accountId": to_account.id_string(), + "onSuccessDestroyOriginal": on_success_destroy, + "create": items + .into_iter() + .map(|(i, item)| (i.to_string(), item)).collect::>() + }, + "0" + ]])) + .await + } + + pub async fn jmap_changes(&self, object: impl Display, state: impl Display) -> JmapResponse { + self.jmap_method_calls(json!([[ + format!("{object}/changes"), + { + "accountId": self.id_string(), + "sinceState": state.to_string() + }, + "0" + ]])) + .await + } + + pub async fn jmap_method_call(&self, method_name: &str, body: Value) -> JmapResponse { + self.jmap_method_calls(json!([[method_name, body, "0"]])) + .await + } + + pub fn basic_auth(&self) -> String { + format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", self.name(), self.secret())) + ) + } + + pub fn base_url(&self) -> String { + format!("https://127.0.0.1:{}", self.http_listener_port) + } + + pub fn api_url(&self) -> String { + format!("{}/jmap", self.base_url()) + } + + fn http_client(&self, timeout_ms: u64) -> reqwest::Client { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_millis(timeout_ms)) + .build() + .unwrap() + } + + pub async fn http_get_raw(&self, url: &str, accept: Option<&str>) -> RawResponse { + let mut req = self + .http_client(5000) + .get(url) + .header(header::AUTHORIZATION, self.basic_auth()); + if let Some(accept) = accept { + req = req.header(header::ACCEPT, accept); + } + RawResponse::from_response(req.send().await.unwrap()).await + } + + pub async fn http_post_raw( + &self, + url: &str, + content_type: &str, + body: impl Into>, + ) -> RawResponse { + RawResponse::from_response( + self.http_client(5000) + .post(url) + .header(header::AUTHORIZATION, self.basic_auth()) + .header(header::CONTENT_TYPE, content_type) + .body(body.into()) + .send() + .await + .unwrap(), + ) + .await + } + + pub async fn jmap_raw_post(&self, body: impl Into>, content_type: &str) -> RawResponse { + let url = self.api_url(); + self.http_post_raw(&url, content_type, body).await + } + + pub async fn jmap_request(&self, using: &[&str], calls: Value) -> JmapResponse { + let body = json!({ + "using": using, + "methodCalls": calls + }); + let raw = self + .jmap_raw_post(body.to_string(), "application/json") + .await; + JmapResponse( + raw.json() + .unwrap_or_else(|| panic!("Response was not valid JSON: {}", raw.text())), + ) + } + + pub async fn jmap_method_calls(&self, calls: Value) -> JmapResponse { + let mut headers = header::HeaderMap::new(); + + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&self.basic_auth()).unwrap(), + ); + + let body = json!({ + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:submission", + "urn:ietf:params:jmap:vacationresponse", + "urn:ietf:params:jmap:contacts", + "urn:ietf:params:jmap:contacts:parse", + "urn:ietf:params:jmap:calendars", + "urn:ietf:params:jmap:calendars:parse", + "urn:ietf:params:jmap:websocket", + "urn:ietf:params:jmap:sieve", + "urn:ietf:params:jmap:blob", + "urn:ietf:params:jmap:quota", + "urn:ietf:params:jmap:principals", + "urn:ietf:params:jmap:principals:owner", + "urn:ietf:params:jmap:principals:availability", + "urn:ietf:params:jmap:filenode", + "urn:ietf:params:jmap:mail:share", + "urn:stalwart:jmap" + ], + "methodCalls": calls + }); + + JmapResponse( + serde_json::from_slice( + &reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_millis(5000)) + .default_headers(headers) + .build() + .unwrap() + .post(format!( + "https://127.0.0.1:{}/jmap", + self.http_listener_port + )) + .body(body.to_string()) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(), + ) + .unwrap(), + ) + } + + pub async fn jmap_session_object(&self) -> JmapResponse { + let mut headers = header::HeaderMap::new(); + + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", self.name(), self.secret())) + )) + .unwrap(), + ); + + JmapResponse( + serde_json::from_slice( + &reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_millis(1000)) + .default_headers(headers) + .build() + .unwrap() + .get(format!( + "https://127.0.0.1:{}/jmap/session", + self.http_listener_port + )) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(), + ) + .unwrap(), + ) + } + + pub async fn destroy_all_addressbooks(&self) { + self.jmap_method_calls(json!([[ + "AddressBook/get", + { + "accountId": self.id_string(), + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + "AddressBook/set", + { + "accountId": self.id_string(), + "#destroy" : { + "resultOf": "R1", + "name": "AddressBook/get", + "path": "/list/*/id" + }, + "onDestroyRemoveContents" : true + }, + "R2" + ] + ])) + .await; + } + + pub async fn destroy_all_calendars(&self) { + self.jmap_method_calls(json!([[ + "Calendar/get", + { + "accountId": self.id_string(), + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + "Calendar/set", + { + "accountId": self.id_string(), + "#destroy" : { + "resultOf": "R1", + "name": "Calendar/get", + "path": "/list/*/id" + }, + "onDestroyRemoveEvents" : true + }, + "R2" + ] + ])) + .await; + } + + pub async fn destroy_all_event_notifications(&self) { + self.jmap_method_calls(json!([[ + "CalendarEventNotification/get", + { + "accountId": self.id_string(), + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + "CalendarEventNotification/set", + { + "accountId": self.id_string(), + "#destroy" : { + "resultOf": "R1", + "name": "CalendarEventNotification/get", + "path": "/list/*/id" + } + }, + "R2" + ] + ])) + .await; + } +} + +impl JmapResponse { + pub fn created(&self, item_idx: u32) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/created/i{item_idx}")) + .unwrap_or_else(|| panic!("Missing created item {item_idx}: {self:?}")) + } + + pub fn created_id(&self, item_idx: u32) -> Id { + Id::from_str(self.created(item_idx).id()).unwrap_or_else(|_| { + panic!("Created item {item_idx} does not have a valid id: {self:?}") + }) + } + + pub fn not_created(&self, item_idx: u32) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notCreated/i{item_idx}")) + .unwrap_or_else(|| panic!("Missing not created item {item_idx}: {self:?}")) + } + + pub fn updated(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/updated/{id}")) + .unwrap_or_else(|| panic!("Missing updated item {id}: {self:?}")) + } + + pub fn updated_id(&self, id: Id) -> &Value { + self.updated(&id.to_string()) + } + + pub fn not_updated(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notUpdated/{id}")) + .unwrap_or_else(|| panic!("Missing not updated item {id}: {self:?}")) + } + + pub fn copied(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/created/{id}")) + .unwrap_or_else(|| panic!("Missing created item {id}: {self:?}")) + } + + pub fn method_response(&self) -> &Value { + self.0 + .pointer("/methodResponses/0/1") + .unwrap_or_else(|| panic!("Missing method response in response: {self:?}")) + } + + pub fn num_responses(&self) -> usize { + self.0 + .pointer("/methodResponses") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + } + + pub fn name_at(&self, n: usize) -> &str { + self.0 + .pointer(&format!("/methodResponses/{n}/0")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing method name at {n}: {self:?}")) + } + + pub fn response_at(&self, n: usize) -> &Value { + self.0 + .pointer(&format!("/methodResponses/{n}/1")) + .unwrap_or_else(|| panic!("Missing method response at {n}: {self:?}")) + } + + pub fn call_id_at(&self, n: usize) -> &str { + self.0 + .pointer(&format!("/methodResponses/{n}/2")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing call id at {n}: {self:?}")) + } + + pub fn is_error_at(&self, n: usize) -> bool { + self.0 + .pointer(&format!("/methodResponses/{n}/0")) + .and_then(|v| v.as_str()) + == Some("error") + } + + pub fn error_type_at(&self, n: usize) -> Option<&str> { + self.0 + .pointer(&format!("/methodResponses/{n}/1/type")) + .and_then(|v| v.as_str()) + } + + pub fn session_state(&self) -> Option<&str> { + self.0.pointer("/sessionState").and_then(|v| v.as_str()) + } + + pub fn list_array(&self) -> &Value { + self.0 + .pointer("/methodResponses/0/1/list") + .unwrap_or_else(|| panic!("Missing list in response: {self:?}")) + } + + pub fn list(&self) -> &[Value] { + self.0 + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing list in response: {self:?}")) + } + + pub fn not_found(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/notFound") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing notFound in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn ids(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/ids") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing ids in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn object_ids(&self) -> impl Iterator { + self.ids().map(move |id| { + Id::from_str(id).unwrap_or_else(|_| panic!("Invalid id {id} in response: {self:?}")) + }) + } + + pub fn destroyed(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/destroyed") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing destroyed in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn destroyed_ids(&self) -> impl Iterator { + self.destroyed().map(move |id| { + Id::from_str(id).unwrap_or_else(|_| panic!("Invalid id {id} in response: {self:?}")) + }) + } + + pub fn assert_destroyed(&self, expected: &[Id]) -> &Self { + let destroyed_ids = self.destroyed_ids().collect::>(); + for expected in expected { + if !destroyed_ids.contains(expected) { + panic!( + "Expected id {expected} to be destroyed but got destroyed ids {destroyed_ids:?}: {self:?}" + ); + } + } + self + } + + pub fn not_destroyed(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notDestroyed/{id}")) + .unwrap_or_else(|| panic!("Missing not destroyed item {id}: {self:?}")) + } + + pub fn state(&self) -> &str { + self.0 + .pointer("/methodResponses/0/1/state") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing state in response: {self:?}")) + } + + pub fn new_state(&self) -> &str { + self.0 + .pointer("/methodResponses/0/1/newState") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing new state in response: {self:?}")) + } + + pub fn changes(&self) -> impl Iterator> { + self.changes_by_type("created") + .map(ChangeType::Created) + .chain(self.changes_by_type("updated").map(ChangeType::Updated)) + .chain(self.changes_by_type("destroyed").map(ChangeType::Destroyed)) + } + + fn changes_by_type(&self, typ: &str) -> impl Iterator { + self.0 + .pointer(&format!("/methodResponses/0/1/{typ}")) + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing {typ} changes in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn pointer(&self, pointer: &str) -> Option<&Value> { + self.0.pointer(pointer) + } + + pub fn into_inner(self) -> Value { + self.0 + } +} + +#[derive(Debug, PartialEq, Eq, serde::Deserialize)] +pub struct JmapSetError { + #[serde(rename = "type")] + pub type_: SetErrorType, + + #[serde(default)] + pub description: Option, + + #[serde(default)] + pub properties: Option>, + + #[serde(rename = "existingId")] + #[serde(default)] + pub existing_id: Option, + + #[serde(rename = "objectId")] + #[serde(default)] + pub object_id: Option, + + #[serde(default)] + #[serde(rename = "linkedObjects")] + pub linked_objects: Vec, + + #[serde(default)] + #[serde(rename = "validationErrors")] + pub validation_errors: Vec, +} + +impl JmapSetError { + pub fn assert_type(&self, expected: SetErrorType) -> &Self { + if self.type_ != expected { + panic!("Expected error type {expected:?} but got {self:?}"); + } + self + } + + pub fn assert_description_contains(&self, expected: &str) -> &Self { + if let Some(description) = &self.description { + if !description.contains(expected) { + panic!("Expected error description to contain {expected} but got {description}"); + } + } else { + panic!("Expected error description to contain {expected} but got no description"); + } + self + } + + pub fn assert_properties(&self, expected: &[&str]) -> &Self { + let properties = self.properties.as_ref().unwrap_or_else(|| { + panic!("Expected error to have properties {expected:?} but got no properties: {self:?}") + }); + for expected in expected { + if !properties.contains(&expected.to_string()) { + panic!( + "Expected error to have property {expected} but got properties {properties:?}: {self:?}" + ); + } + } + self + } +} + +pub trait JmapUtils { + fn id(&self) -> &str { + self.text_field("id") + } + + fn object_id(&self) -> Id { + self.id() + .parse() + .unwrap_or_else(|_| panic!("Invalid id {} in object", self.id())) + } + + fn blob_id(&self) -> &str { + self.text_field("blobId") + } + + fn typ(&self) -> &str { + self.text_field("type") + } + + fn description(&self) -> &str { + self.text_field("description") + } + + fn to_set_error(&self) -> JmapSetError; + + fn with_property(self, field: impl Display, value: impl Into) -> Self; + + fn text_field(&self, field: &str) -> &str; + + fn integer_field(&self, field: &str) -> i64; + + fn assert_is_equal(&self, other: Value); +} + +impl JmapUtils for Value { + fn text_field(&self, field: &str) -> &str { + self.pointer(&format!("/{field}")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing {field} in object: {self:?}")) + } + + fn integer_field(&self, field: &str) -> i64 { + self.pointer(&format!("/{field}")) + .and_then(|v| v.as_i64()) + .unwrap_or_else(|| panic!("Missing {field} in object: {self:?}")) + } + + fn to_set_error(&self) -> JmapSetError { + serde_json::from_str(&self.to_string()).expect("Failed to deserialize set error") + } + + fn assert_is_equal(&self, expected: Value) { + if self != &expected { + panic!( + "Values are not equal:\ngot: {}\nexpected: {}", + serde_json::to_string_pretty(self).unwrap(), + serde_json::to_string_pretty(&expected).unwrap() + ); + } + } + + fn with_property(mut self, field: impl Display, value: impl Into) -> Self { + if let Value::Object(map) = &mut self { + map.insert(field.to_string(), value.into()); + } else { + panic!("Not an object: {self:?}"); + } + self + } +} + +impl<'x> ChangeType<'x> { + pub fn as_created(&self) -> &str { + match self { + ChangeType::Created(id) => id, + _ => panic!("Not a created change: {self:?}"), + } + } + + pub fn as_updated(&self) -> &str { + match self { + ChangeType::Updated(id) => id, + _ => panic!("Not an updated change: {self:?}"), + } + } + + pub fn as_destroyed(&self) -> &str { + match self { + ChangeType::Destroyed(id) => id, + _ => panic!("Not a destroyed change: {self:?}"), + } + } +} + +impl Display for JmapResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::fmt::Debug for JmapResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + serde_json::to_string_pretty(&self.0) + .map_err(|_| std::fmt::Error) + .and_then(|s| std::fmt::Display::fmt(&s, f)) + } +} + +pub trait IntoJmapSet { + fn into_jmap_set(self) -> Value; +} + +impl> IntoJmapSet for T { + fn into_jmap_set(self) -> Value { + Value::Object( + self.into_iter() + .map(|id| (id.to_string(), Value::Bool(true))) + .collect::>(), + ) + } +} diff --git a/tests/src/utils/mod.rs b/tests/src/utils/mod.rs new file mode 100644 index 0000000..538fa94 --- /dev/null +++ b/tests/src/utils/mod.rs @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod account; +pub mod cleanup; +pub mod containers; +pub mod dns; +pub mod http; +pub mod http_server; +pub mod imap; +pub mod jmap; +pub mod pop3; +pub mod registry; +pub mod server; +pub mod sieve; +pub mod smtp; +pub mod storage; +pub mod temp_dir; +pub mod webdav; diff --git a/tests/src/utils/pop3.rs b/tests/src/utils/pop3.rs new file mode 100644 index 0000000..388553b --- /dev/null +++ b/tests/src/utils/pop3.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use rustls_pki_types::ServerName; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; +use tokio_rustls::client::TlsStream; +use utils::tls::build_tls_connector; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseType { + Ok, + Multiline, + Err, +} + +pub struct Pop3Connection { + reader: Lines>>>, + writer: WriteHalf>, +} + +impl Pop3Connection { + pub async fn connect() -> Self { + let (reader, writer) = tokio::io::split( + build_tls_connector(true) + .unwrap() + .connect( + ServerName::try_from("pop3.example.org").unwrap().to_owned(), + TcpStream::connect("127.0.0.1:4110").await.unwrap(), + ) + .await + .unwrap(), + ); + + let mut conn = Pop3Connection { + reader: BufReader::new(reader).lines(), + writer, + }; + + conn.assert_read(ResponseType::Ok).await; + conn + } + + pub async fn authenticate(&mut self, user: &str, pass: &str) { + let creds = general_purpose::STANDARD.encode(format!("\0{user}\0{pass}")); + self.send(&format!("AUTH PLAIN {creds}")).await; + self.assert_read(ResponseType::Ok).await; + } + + pub async fn assert_read(&mut self, rt: ResponseType) -> Vec { + let lines = self.read(matches!(rt, ResponseType::Multiline)).await; + if lines.last().unwrap().starts_with(match rt { + ResponseType::Ok => "+OK", + ResponseType::Multiline => ".", + ResponseType::Err => "-ERR", + }) { + lines + } else { + panic!("Expected {:?} from server but got: {:?}", rt, lines); + } + } + + pub async fn read(&mut self, is_multiline: bool) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = (!is_multiline && line.starts_with("+OK")) + || (is_multiline && line == ".") + || line.starts_with("-ERR"); + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } +} diff --git a/tests/src/utils/registry.rs b/tests/src/utils/registry.rs new file mode 100644 index 0000000..21808d3 --- /dev/null +++ b/tests/src/utils/registry.rs @@ -0,0 +1,470 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + account::Account, + jmap::{JmapResponse, JmapSetError, JmapUtils}, +}; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{ + Action, Expression, MtaExtensions, MtaStageAuth, MtaStageData, MtaStageEhlo, + MtaStageRcpt, SpamSettings, + }, + }, + types::{EnumImpl, ObjectImpl}, +}; +use serde_json::{Value, json}; +use std::fmt::Display; +use store::registry::write::RegistryWriteResult; +use types::id::Id; + +impl Account { + pub async fn registry_create( + &self, + items: impl IntoIterator, + ) -> JmapResponse { + let typ = T::OBJECT; + let name = typ.as_str(); + + self.jmap_create_account( + self, + format!("x:{name}"), + items.into_iter().map(|item| { + let mut item = + serde_json::to_value(item).expect("Failed to serialize item to JSON"); + remove_server_set_props(typ, &mut item); + item + }), + Vec::<(&str, &str)>::new(), + ) + .await + } + + pub async fn registry_create_many( + &self, + object_type: ObjectType, + items: impl IntoIterator, + ) -> JmapResponse { + let name = object_type.as_str(); + self.jmap_create_account(self, format!("x:{name}"), items, Vec::<(&str, &str)>::new()) + .await + } + + pub async fn registry_get(&self, id: Id) -> T { + let name = T::OBJECT.as_str(); + + let value = self + .jmap_get_account(self, format!("x:{name}"), Vec::<&str>::new(), vec![id]) + .await + .list()[0] + .to_string(); + serde_json::from_str(&value).unwrap_or_else(|_| { + panic!("Failed to deserialize {value}"); + }) + } + + pub async fn registry_get_all(&self) -> Vec<(Id, T)> { + let name = T::OBJECT.as_str(); + + let response = self + .jmap_get_account( + self, + format!("x:{name}"), + Vec::<&str>::new(), + Vec::::new(), + ) + .await; + let mut items = Vec::with_capacity(response.list().len()); + for item in response.list() { + let id = item.object_id(); + let item = serde_json::from_str(&item.to_string()).unwrap_or_else(|err| { + panic!("Failed to deserialize {item} : {err}"); + }); + items.push((id, item)); + } + items + } + + pub async fn registry_get_many( + &self, + object_type: ObjectType, + ids: impl IntoIterator, + ) -> JmapResponse { + self.jmap_get_account( + self, + format!("x:{}", object_type.as_str()), + Vec::<&str>::new(), + ids, + ) + .await + } + + pub async fn registry_update( + &self, + object: ObjectType, + items: impl IntoIterator, + ) -> JmapResponse { + let name = object.as_str(); + + self.jmap_update_account(self, format!("x:{name}"), items, Vec::<(&str, &str)>::new()) + .await + } + + pub async fn registry_query_ids( + &self, + object: ObjectType, + filter: impl IntoIterator)>, + sort_by: impl IntoIterator, + ) -> Vec { + self.registry_query(object, filter, sort_by) + .await + .object_ids() + .collect() + } + + pub async fn registry_query( + &self, + object: ObjectType, + filter: impl IntoIterator)>, + sort_by: impl IntoIterator, + ) -> JmapResponse { + let name = object.as_str(); + + self.jmap_query( + format!("x:{name}"), + filter, + sort_by, + Vec::<(&str, &str)>::new(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn registry_query_paginated( + &self, + object: ObjectType, + sort_property: &str, + sort_ascending: bool, + position: Option, + limit: Option, + anchor: Option, + anchor_offset: Option, + calculate_total: bool, + ) -> JmapResponse { + let name = object.as_str(); + let mut args = serde_json::Map::new(); + args.insert("filter".into(), json!({})); + args.insert( + "sort".into(), + json!([{ "property": sort_property, "isAscending": sort_ascending }]), + ); + if let Some(p) = position { + args.insert("position".into(), json!(p)); + } + if let Some(l) = limit { + args.insert("limit".into(), json!(l)); + } + if let Some(a) = anchor { + args.insert("anchor".into(), json!(a.to_string())); + } + if let Some(ao) = anchor_offset { + args.insert("anchorOffset".into(), json!(ao)); + } + if calculate_total { + args.insert("calculateTotal".into(), json!(true)); + } + + self.jmap_method_calls(json!([[ + format!("x:{name}/query"), + Value::Object(args), + "0" + ]])) + .await + } + + pub async fn registry_destroy( + &self, + object: ObjectType, + items: impl IntoIterator, + ) -> JmapResponse { + let name = object.as_str(); + + self.jmap_destroy_account(self, format!("x:{name}"), items, Vec::<(&str, &str)>::new()) + .await + } + + pub async fn registry_destroy_all(&self, object: ObjectType) { + let name = object.as_str(); + self.jmap_method_calls(json!([[ + format!("x:{name}/get"), + { + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + format!("x:{name}/set"), + { + "#destroy" : { + "resultOf": "R1", + "name": format!("x:{name}/get"), + "path": "/list/*/id" + }, + }, + "R2" + ] + ])) + .await; + } + + pub async fn registry_create_object(&self, item: T) -> Id { + self.registry_create([item]).await.created_id(0) + } + + pub async fn registry_create_object_expect_err(&self, item: T) -> JmapSetError { + self.registry_create([item]) + .await + .not_created(0) + .to_set_error() + } + + pub async fn registry_update_object(&self, object: ObjectType, id: Id, item: Value) { + self.registry_update(object, [(id, item)]) + .await + .updated_id(id); + } + + pub async fn registry_update_setting( + &self, + setting: T, + properties: &[Property], + ) { + let mut item = serde_json::to_value(setting).expect("Failed to serialize setting to JSON"); + + if !properties.is_empty() { + // Only include the specified properties in the update + if let Value::Object(obj) = &mut item { + obj.retain(|k, _| properties.iter().any(|p| p.as_str() == k)); + } + } + + self.registry_update(T::OBJECT, [(Id::singleton(), item)]) + .await + .updated_id(Id::singleton()); + } + + pub async fn reload_settings(&self) { + self.registry_create_object(Action::ReloadSettings).await; + } + + pub async fn reload_lookup_stores(&self) { + self.registry_create_object(Action::ReloadLookupStores) + .await; + } + + pub async fn registry_update_object_expect_err( + &self, + object: ObjectType, + id: Id, + item: Value, + ) -> JmapSetError { + self.registry_update(object, [(id, item)]) + .await + .not_updated(&id.to_string()) + .to_set_error() + } + + pub async fn registry_destroy_object_expect_err( + &self, + object: ObjectType, + id: Id, + ) -> JmapSetError { + self.registry_destroy(object, [id]) + .await + .not_destroyed(&id.to_string()) + .to_set_error() + } + + pub async fn destroy_account(&self, account: Account) { + let account_id = account.id(); + self.registry_destroy(ObjectType::Account, [account_id]) + .await + .assert_destroyed(&[account_id]); + } + + pub async fn mta_allow_relaying(&self) { + self.registry_create_object(MtaStageRcpt { + allow_relaying: Expression { + else_: "true".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + } + + pub async fn mta_disable_spam_filter(&self) { + self.registry_create_object(SpamSettings { + enable: false, + ..Default::default() + }) + .await; + } + + pub async fn mta_no_auth(&self) { + self.registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + } + + pub async fn mta_all_extensions(&self) { + self.registry_create_object(MtaExtensions { + chunking: Expression { + else_: "true".into(), + ..Default::default() + }, + deliver_by: Expression { + else_: "true".into(), + ..Default::default() + }, + dsn: Expression { + else_: "true".into(), + ..Default::default() + }, + expn: Expression { + else_: "true".into(), + ..Default::default() + }, + future_release: Expression { + else_: "true".into(), + ..Default::default() + }, + mt_priority: Expression { + else_: "true".into(), + ..Default::default() + }, + no_soliciting: Expression { + else_: "true".into(), + ..Default::default() + }, + pipelining: Expression { + else_: "true".into(), + ..Default::default() + }, + require_tls: Expression { + else_: "true".into(), + ..Default::default() + }, + vrfy: Expression { + else_: "true".into(), + ..Default::default() + }, + }) + .await; + } + + pub async fn mta_allow_non_fqdn(&self) { + self.registry_create_object(MtaStageEhlo { + reject_non_fqdn: Expression { + else_: "false".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + } + + pub async fn mta_add_all_headers(&self) { + self.registry_create_object(MtaStageData { + add_date_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_message_id_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_received_spf_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_auth_results_header: Expression { + else_: "true".into(), + ..Default::default() + }, + add_return_path_header: Expression { + else_: "false".into(), + ..Default::default() + }, + enable_spam_filter: Expression { + else_: "false".into(), + ..Default::default() + }, + ..Default::default() + }) + .await; + } +} + +impl JmapResponse { + pub fn objects(&self) -> impl Iterator { + self.list() + .iter() + .map(|item| serde_json::from_value(item.clone()).expect("Failed to deserialize item")) + } +} + +pub trait UnwrapRegistryId { + fn unwrap_id(self, location: &str) -> Id; +} + +impl UnwrapRegistryId for RegistryWriteResult { + fn unwrap_id(self, location: &str) -> Id { + match self { + RegistryWriteResult::Success(id) => id, + err => panic!("Expected success at {location} but got {err}"), + } + } +} + +fn remove_server_set_props(typ: ObjectType, value: &mut serde_json::Value) { + if let Value::Object(obj) = value { + let is_app_pass = matches!(typ, ObjectType::AppPassword | ObjectType::ApiKey) + || obj + .get("@type") + .and_then(|v| v.as_str()) + .is_some_and(|t| ["AppPassword", "ApiKey"].contains(&t)); + obj.retain(|k, v| { + !([ + "createdAt", + "credentialId", + "retireAt", + "accountKey", + "accountUri", + ] + .contains(&k.as_str()) + || (is_app_pass && k == "secret") + || (k == "memberTenantId" && v.is_null())) + }); + for v in obj.values_mut() { + remove_server_set_props(typ, v); + } + } +} diff --git a/tests/src/utils/server.rs b/tests/src/utils/server.rs new file mode 100644 index 0000000..8d3aaeb --- /dev/null +++ b/tests/src/utils/server.rs @@ -0,0 +1,622 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + AssertConfig, + smtp::session::{DummyIo, TestSession}, + utils::{ + account::Account, + cleanup::{search_store_destroy, store_blob_expire_all, store_destroy}, + registry::UnwrapRegistryId, + storage::{RegistryEnvStores, assert_is_empty, build_data_store, wait_for_tasks}, + temp_dir::TempDir, + }, +}; +use ahash::AHashMap; +use common::{ + BuildServer, Caches, Core, Data, DavResources, Inner, Server, + auth::RECOVERY_ADMIN_ID, + config::{ + server::{Listeners, ServerProtocol}, + storage::Storage, + telemetry::Telemetry, + }, + ipc::{QueueEvent, ReportingEvent}, + manager::{ + boot::{IpcReceivers, build_ipc}, + defaults::BootstrapDefaults, + }, + psl, +}; +use email::message::metadata::MessageMetadata; +use groupware::cache::GroupwareCache; +use http::HttpSessionManager; +use imap::core::ImapSessionManager; +use jmap_client::client::Client; +use managesieve::core::ManageSieveSessionManager; +use pop3::Pop3SessionManager; +use registry::{ + schema::{ + enums::{EventPolicy, NetworkListenerProtocol, TracingLevel}, + prelude::{Object, ObjectType, SocketAddr}, + structs::{ + Authentication, Certificate, Domain, NetworkListener, PublicText, SecretKeyFile, + SecretText, SystemSettings, Tracer, TracerStdout, + }, + }, + types::{EnumImpl, datetime::UTCDateTime, map::Map}, +}; +use services::{SpawnServices, broadcast::subscriber::spawn_broadcast_subscriber}; +use smtp::{ + SpawnQueueManager, + core::{Session, SmtpSessionManager}, + queue::{ + manager::{Queue, SpawnQueue}, + spool::{QueuedMessages, SmtpSpool}, + }, + reporting::scheduler::SpawnReport, +}; +use std::{collections::VecDeque, path::PathBuf, str::FromStr, sync::Arc}; +use store::{ + RegistryStore, Store, ValueKey, + registry::{RegistryQuery, bootstrap::Bootstrap, write::RegistryWrite}, + write::{AlignedBytes, Archive, now}, +}; +use tokio::sync::{mpsc, watch}; +use trc::EventType; +use types::{collection::Collection, field::EmailField, id::Id}; + +pub struct TestServer { + pub server: Server, + pub accounts: AHashMap<&'static str, Account>, + pub temp_dir: TempDir, + pub queue_rx: mpsc::Receiver, + pub queue_events: VecDeque, + pub report_rx: mpsc::Receiver, + shutdown_tx: watch::Sender, + reset: bool, +} + +pub struct TestServerBuilder { + bootstrap: Bootstrap, + temp_dir: TempDir, + http_listener_port: u16, + reset: bool, + logging_enabled: bool, + capture_queue: bool, + capture_reporting: bool, + disable_services: bool, +} + +impl TestServerBuilder { + pub async fn new(test_name: &str) -> Self { + let reset = std::env::var("NO_INSERT").is_err(); + + Self::new_with_role(test_name, "mail.example.org".to_string(), None, reset).await + } + + pub async fn new_with_role( + test_name: &str, + hostname: String, + node_role: Option, + reset: bool, + ) -> Self { + let temp_dir = TempDir::new(test_name, reset); + let path = temp_dir.path.to_string_lossy().to_string(); + let store_type = std::env::var("STORE").expect(concat!( + "Missing or invalid store type. Try ", + "running `STORE= cargo test`" + )); + let data_store = build_data_store(&store_type, &path).await; + let store = Store::build(data_store).await.unwrap(); + + store.create_tables().await.unwrap(); + + // Delete old store if requested + if reset { + store_destroy(&store).await; + } + + Self { + bootstrap: Bootstrap::new( + RegistryStore::new(&path, store, hostname, 1, node_role).await, + ) + .await, + http_listener_port: 8899, + temp_dir, + reset, + logging_enabled: false, + capture_queue: false, + capture_reporting: false, + disable_services: false, + } + } + + pub async fn with_default_listeners(self) -> Self { + let mut this = self; + for (protocol, name, port, use_tls) in [ + (NetworkListenerProtocol::Http, "jmap", 8899, true), + (NetworkListenerProtocol::Imap, "imap", 9991, false), + (NetworkListenerProtocol::Imap, "imaptls", 9992, true), + (NetworkListenerProtocol::ManageSieve, "sieve", 4190, true), + (NetworkListenerProtocol::Pop3, "pop3", 4110, true), + (NetworkListenerProtocol::Lmtp, "lmtp-debug", 11200, false), + ] { + this = this.with_listener(protocol, name, port, use_tls).await; + } + this + } + + pub async fn with_http_listener(self, port: u16) -> Self { + self.with_listener(NetworkListenerProtocol::Http, "jmap", port, true) + .await + } + + pub async fn with_smtp_listener(self, port: u16) -> Self { + self.with_listener(NetworkListenerProtocol::Smtp, "smtp", port, false) + .await + } + + pub async fn with_imap_listener(self, port: u16) -> Self { + self.with_listener(NetworkListenerProtocol::Imap, "imap", port, false) + .await + } + + pub async fn with_dummy_tls_cert(self, sans: impl IntoIterator) -> Self { + let mut cert_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + cert_path.push("resources"); + let mut cert = cert_path.clone(); + cert.push("tls_cert.pem"); + let mut pk = cert_path.clone(); + pk.push("tls_privatekey.pem"); + + self.with_object(Certificate { + private_key: SecretText::File(SecretKeyFile { + file_path: pk.to_string_lossy().to_string(), + }), + certificate: PublicText::File(SecretKeyFile { + file_path: cert.to_string_lossy().to_string(), + }), + issuer: "Stalwart Test CA".to_string(), + not_valid_after: UTCDateTime::from_timestamp((now() + 86400) as i64), + subject_alternative_names: Map::new( + sans.into_iter().map(|san| san.to_string()).collect(), + ), + ..Default::default() + }) + .await + } + + pub async fn with_listener( + mut self, + protocol: NetworkListenerProtocol, + name: &str, + port: u16, + tls_implicit: bool, + ) -> Self { + if protocol == NetworkListenerProtocol::Http { + self.http_listener_port = port; + } + self.insert_object(NetworkListener { + bind: Map::new(vec![ + SocketAddr::from_str(&format!("0.0.0.0:{port}")).unwrap(), + ]), + name: name.to_string(), + protocol, + use_tls: true, + tls_implicit, + ..Default::default() + }) + .await; + self + } + + pub async fn with_object(self, object: impl Into) -> Self { + self.insert_object(object).await; + self + } + + pub fn with_logging(mut self) -> Self { + self.logging_enabled = true; + self + } + + pub fn capture_queue(mut self) -> Self { + self.capture_queue = true; + self + } + + pub fn capture_reporting(mut self) -> Self { + self.capture_reporting = true; + self + } + + pub fn disable_services(mut self) -> Self { + self.disable_services = true; + self + } + + pub async fn insert_object(&self, object: impl Into) -> Id { + self.bootstrap + .registry + .write(RegistryWrite::insert(&object.into())) + .await + .unwrap() + .unwrap_id(trc::location!()) + } + + pub async fn build(self) -> TestServer { + self.build_with_opts(true).await + } + + pub async fn build_with_opts(mut self, init_store: bool) -> TestServer { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + if init_store { + // Register stores from environment + self.bootstrap.registry.insert_stores_from_env().await; + + // Enable logging if requested + let level = std::env::var("LOG") + .map(|log| TracingLevel::parse(&log).expect("Invalid log level")) + .ok(); + + // Add default domain + let default_domain = psl::domain_str(self.bootstrap.registry.local_hostname()).unwrap(); + let default_domain_id = self + .insert_object(Domain { + name: default_domain.to_string(), + ..Default::default() + }) + .await; + self.insert_object(SystemSettings { + default_hostname: self.bootstrap.registry.local_hostname().to_string(), + default_domain_id, + ..Default::default() + }) + .await; + + self.insert_object(Tracer::Stdout(TracerStdout { + enable: level.is_some() || self.logging_enabled, + level: level.unwrap_or(TracingLevel::Info), + ansi: true, + multiline: false, + events: Map::new( + EventType::variants() + .iter() + .filter(|ev| { + let ev = ev.as_str(); + ev.starts_with("network.") + || ev.starts_with("http.connection-") + || ev == "telemetry.webhook-error" + || ev == "http.request-body" + || ev == "http.request-url" + || ev == "tls.no-certificates-available" + || ev == "store.cache-hit" + }) + .copied() + .collect(), + ), + events_policy: EventPolicy::Exclude, + ..Default::default() + })) + .await; + } + + // Start listeners + let mut servers = Listeners::parse(&mut self.bootstrap).await; + servers.bind_and_drop_priv(&mut self.bootstrap); + + // Set HTTP port + self.bootstrap.registry = self + .bootstrap + .registry + .clone_with_public_url(format!("https://127.0.0.1:{}", self.http_listener_port)); + + if init_store { + // Add safe defaults if missing + self.bootstrap.insert_safe_defaults().await; + + // Add directory + if let Some(directory_id) = self + .bootstrap + .registry + .query::>(RegistryQuery::new(ObjectType::Directory)) + .await + .unwrap() + .first() + { + let mut auth = self + .bootstrap + .registry + .object::(Id::singleton()) + .await + .unwrap() + .unwrap(); + auth.directory_id = Some(*directory_id); + self.bootstrap + .registry + .write(RegistryWrite::insert(&auth.into())) + .await + .unwrap(); + } + } + + // Parse storage + let storage = Storage::parse(&mut self.bootstrap).await; + + // Reset search store + if init_store && self.reset { + search_store_destroy(&storage.search).await; + } + + // Parse telemetry + let telemetry = Telemetry::parse(&mut self.bootstrap, &storage).await; + + // Parse components + let core = Box::pin(Core::parse(&mut self.bootstrap, storage)).await; + let data = Data::parse(&mut self.bootstrap).await; + let cache = Caches::parse(&mut self.bootstrap).await; + + // Enable telemetry + telemetry.enable(true); + + // Build inner + let (ipc, mut ipc_rxs) = build_ipc(!core.storage.coordinator.is_none()); + let inner = Arc::new(Inner { + shared_core: core.into_shared(), + data, + ipc, + cache, + }); + + // Parse TCP acceptors + servers + .parse_tcp_acceptors(&mut self.bootstrap, inner.clone()) + .await; + + // Start services + self.bootstrap.assert_no_errors(); + if !self.disable_services { + ipc_rxs.spawn_services(inner.clone()); + } + + // Spawn queue manager if not capturing + let (_, mut queue_rx) = mpsc::channel(100); + let (_, mut report_rx) = mpsc::channel(100); + if !self.capture_queue && !self.capture_reporting { + ipc_rxs.spawn_queue_manager(inner.clone()); + } else { + let queue_rx_ = ipc_rxs.queue_rx.take().unwrap(); + let report_rx_ = ipc_rxs.report_rx.take().unwrap(); + if !self.capture_queue { + queue_rx_.spawn(inner.clone()); + } else { + queue_rx = queue_rx_; + } + if !self.capture_reporting { + report_rx_.spawn(inner.clone()); + } else { + report_rx = report_rx_; + } + } + + // Spawn servers + let (shutdown_tx, shutdown_rx) = servers.spawn(|server, acceptor, shutdown_rx| { + match &server.protocol { + ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn( + SmtpSessionManager::new(inner.clone()), + inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Http => server.spawn( + HttpSessionManager::new(inner.clone()), + inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Imap => server.spawn( + ImapSessionManager::new(inner.clone()), + inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::Pop3 => server.spawn( + Pop3SessionManager::new(inner.clone()), + inner.clone(), + acceptor, + shutdown_rx, + ), + ServerProtocol::ManageSieve => server.spawn( + ManageSieveSessionManager::new(inner.clone()), + inner.clone(), + acceptor, + shutdown_rx, + ), + }; + }); + + // Start broadcast subscriber + if !self.disable_services { + spawn_broadcast_subscriber(inner.clone(), shutdown_rx); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let mut admin = Account::new( + "admin", + "popolna_zapora", + &[], + "Recovery Admin", + Id::from(RECOVERY_ADMIN_ID), + ); + admin.http_listener_port = self.http_listener_port; + + TestServer { + server: inner.build_server(), + temp_dir: self.temp_dir, + accounts: AHashMap::from_iter([("admin", admin)]), + queue_rx, + queue_events: VecDeque::new(), + report_rx, + shutdown_tx, + reset: self.reset, + } + } +} + +impl TestServer { + pub fn reload_core(&mut self) { + self.server = self.server.inner.build_server(); + } + + pub fn account(&self, name: &str) -> &Account { + self.accounts.get(name).unwrap() + } + + pub async fn wait_for_tasks(&self) { + wait_for_tasks(&self.server, false, false).await; + } + + pub async fn wait_for_tasks_skip_failures(&self) { + wait_for_tasks(&self.server, false, true).await; + } + + pub async fn wait_for_tasks_skip_not_due(&self) { + wait_for_tasks(&self.server, true, false).await; + } + + pub async fn blob_expire_all(&self) { + store_blob_expire_all(&self.server.core.storage.data).await; + } + + pub async fn assert_is_empty(&self) { + assert_is_empty(&self.server, true).await; + } + + pub async fn cleanup(&self) { + self.assert_is_empty().await; + self.server.invalidate_all_local_caches(); + } + + pub async fn destroy_store(&self) { + store_destroy(self.server.store()).await; + } + + pub fn is_reset(&self) -> bool { + self.reset + } + + pub fn tmp_dir(&self) -> &str { + self.temp_dir.path.as_os_str().to_str().unwrap() + } + + pub fn shutdown(&self) { + let _ = self.shutdown_tx.send(true); + } + + pub fn new_mta_session(&self) -> Session { + Session::test(self.server.clone()) + } + + pub fn new_mta_session_with_shutdown(&self) -> (Session, watch::Sender) { + let (tx, rx) = watch::channel(true); + (Session::test_with_shutdown(self.server.clone(), rx), tx) + } + + pub async fn resources(&self, name: &'static str, collection: Collection) -> Arc { + let account_id = self.account(name).id().document_id(); + self.server + .fetch_dav_resources(account_id, account_id, collection.into()) + .await + .unwrap() + } + + pub async fn fetch_email(&self, account_id: u32, document_id: u32) -> Vec { + let metadata_ = self + .server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await + .unwrap() + .unwrap(); + self.server + .blob_store() + .get_blob( + metadata_ + .unarchive::() + .unwrap() + .blob_hash + .0 + .as_slice(), + 0..usize::MAX, + ) + .await + .unwrap() + .unwrap() + } + + pub async fn all_queued_messages(&self) -> QueuedMessages { + self.server + .next_event(&mut Queue::new( + self.server.inner.clone(), + mpsc::channel(100).1, + )) + .await + } + + pub async fn destroy_all_mailboxes(&self, account: &Account) { + self.wait_for_tasks().await; + account.jmap_client().await.destroy_all_mailboxes().await; + } + + pub async fn inner_with_rxs(&self) -> (Arc, IpcReceivers) { + let (ipc, ipc_rxs) = build_ipc(false); + + let mut bp = Bootstrap::new_uninitialized(self.server.registry().clone()); + + ( + Inner { + shared_core: self.server.core.as_ref().clone().into_shared(), + data: Default::default(), + ipc, + cache: Caches::parse(&mut bp).await, + } + .into(), + ipc_rxs, + ) + } +} + +impl Account { + pub async fn destroy_all_mailboxes_for_account(&self, account_id: u32) { + let mut client = self.jmap_client().await; + client.set_default_account_id(Id::from(account_id)); + client.destroy_all_mailboxes().await; + } +} + +pub trait DestroyAllMailboxes { + fn destroy_all_mailboxes(&self) -> impl Future; +} + +impl DestroyAllMailboxes for Client { + async fn destroy_all_mailboxes(&self) { + let mut request = self.build(); + request.query_mailbox().arguments().sort_as_tree(true); + let mut ids = request.send_query_mailbox().await.unwrap().take_ids(); + ids.reverse(); + for id in ids { + self.mailbox_destroy(&id, true).await.unwrap(); + } + } +} diff --git a/tests/src/utils/sieve.rs b/tests/src/utils/sieve.rs new file mode 100644 index 0000000..d9877b9 --- /dev/null +++ b/tests/src/utils/sieve.rs @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose}; +use imap_proto::ResponseType; +use rustls_pki_types::ServerName; +use std::time::Duration; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; +use tokio_rustls::client::TlsStream; +use utils::tls::build_tls_connector; + +pub struct SieveConnection { + reader: Lines>>>, + writer: WriteHalf>, +} + +impl SieveConnection { + pub async fn connect() -> Self { + let (reader, writer) = tokio::io::split( + build_tls_connector(true) + .unwrap() + .connect( + ServerName::try_from("imap.example.org").unwrap().to_owned(), + TcpStream::connect("127.0.0.1:4190").await.unwrap(), + ) + .await + .unwrap(), + ); + SieveConnection { + reader: BufReader::new(reader).lines(), + writer, + } + } + + pub async fn authenticate(&mut self, user: &str, pass: &str) { + let creds = general_purpose::STANDARD.encode(format!("\0{user}\0{pass}")); + self.send(&format!( + "AUTHENTICATE PLAIN {{{}+}}\r\n{creds}", + creds.len() + )) + .await; + self.assert_read(ResponseType::Ok).await; + } + + pub async fn assert_read(&mut self, rt: ResponseType) -> Vec { + let lines = self.read().await; + let mut buf = Vec::with_capacity(10); + rt.serialize(&mut buf); + if lines + .last() + .unwrap() + .starts_with(&String::from_utf8(buf).unwrap()) + { + lines + } else { + panic!("Expected {:?} from server but got: {:?}", rt, lines); + } + } + + pub async fn read(&mut self) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = + line.starts_with("OK") || line.starts_with("NO") || line.starts_with("BYE"); + //println!("<- {:?}", line); + lines.push(line); + if is_done { + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn send(&mut self, text: &str) { + //println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } + + pub async fn send_literal(&mut self, text: &str, literal: &str) { + self.send(&format!("{}{{{}+}}\r\n{}", text, literal.len(), literal)) + .await; + } +} diff --git a/tests/src/utils/smtp.rs b/tests/src/utils/smtp.rs new file mode 100644 index 0000000..11894fd --- /dev/null +++ b/tests/src/utils/smtp.rs @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, + net::TcpStream, +}; + +pub struct SmtpConnection { + reader: Lines>>, + writer: WriteHalf, +} + +impl SmtpConnection { + pub async fn ingest_with_code( + &mut self, + from: &str, + recipients: &[&str], + message: &str, + code: u8, + ) -> Vec { + self.mail_from(from, 2).await; + for recipient in recipients { + self.rcpt_to(recipient, 2).await; + } + self.data(3).await; + let result = self.data_bytes(message, recipients.len(), code).await; + tokio::time::sleep(Duration::from_millis(500)).await; + result + } + + pub async fn ingest(&mut self, from: &str, recipients: &[&str], message: &str) { + self.ingest_with_code(from, recipients, message, 2).await; + } + + pub async fn ingest_chunked( + &mut self, + from: &str, + recipients: &[&str], + message: &str, + chunk_size: usize, + ) { + self.mail_from(from, 2).await; + for recipient in recipients { + self.rcpt_to(recipient, 2).await; + } + for chunk in message.as_bytes().chunks(chunk_size) { + self.bdat(std::str::from_utf8(chunk).unwrap(), 2).await; + } + self.bdat_last("", recipients.len(), 2).await; + tokio::time::sleep(Duration::from_millis(500)).await; + } + + pub async fn connect() -> Self { + SmtpConnection::connect_port(11200).await + } + + pub async fn connect_port(port: u16) -> Self { + let (reader, writer) = tokio::io::split( + TcpStream::connect(&format!("127.0.0.1:{port}")) + .await + .unwrap(), + ); + let mut conn = SmtpConnection { + reader: BufReader::new(reader).lines(), + writer, + }; + conn.read(1, 2).await; + conn.lhlo().await; + conn + } + + pub async fn lhlo(&mut self) -> Vec { + self.send("LHLO localhost").await; + self.read(1, 2).await + } + + pub async fn mail_from(&mut self, sender: &str, code: u8) -> Vec { + self.send(&format!("MAIL FROM:<{}>", sender)).await; + self.read(1, code).await + } + + pub async fn rcpt_to(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("RCPT TO:<{}>", rcpt)).await; + self.read(1, code).await + } + + pub async fn vrfy(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("VRFY {}", rcpt)).await; + self.read(1, code).await + } + + pub async fn expn(&mut self, rcpt: &str, code: u8) -> Vec { + self.send(&format!("EXPN {}", rcpt)).await; + self.read(1, code).await + } + + pub async fn data(&mut self, code: u8) -> Vec { + self.send("DATA").await; + self.read(1, code).await + } + + pub async fn data_bytes( + &mut self, + message: &str, + num_responses: usize, + code: u8, + ) -> Vec { + self.send_raw(message).await; + self.send_raw("\r\n.\r\n").await; + self.read(num_responses, code).await + } + + pub async fn bdat(&mut self, chunk: &str, code: u8) -> Vec { + self.send_raw(&format!("BDAT {}\r\n{}", chunk.len(), chunk)) + .await; + self.read(1, code).await + } + + pub async fn bdat_last(&mut self, chunk: &str, num_responses: usize, code: u8) -> Vec { + self.send_raw(&format!("BDAT {} LAST\r\n{}", chunk.len(), chunk)) + .await; + self.read(num_responses, code).await + } + + pub async fn rset(&mut self) -> Vec { + self.send("RSET").await; + self.read(1, 2).await + } + + pub async fn noop(&mut self) -> Vec { + self.send("NOOP").await; + self.read(1, 2).await + } + + pub async fn quit(&mut self) -> Vec { + self.send("QUIT").await; + self.read(1, 2).await + } + + pub async fn read(&mut self, mut num_responses: usize, code: u8) -> Vec { + let mut lines = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { + Ok(Ok(Some(line))) => { + let is_done = line.as_bytes()[3] == b' '; + //let c = println!("<- {:?}", line); + lines.push(line); + if is_done { + num_responses -= 1; + if num_responses != 0 { + continue; + } + + if code != u8::MAX { + for line in &lines { + if line.as_bytes()[0] - b'0' != code { + panic!("Expected completion code {}, got {:?}.", code, lines); + } + } + } + return lines; + } + } + Ok(Ok(None)) => { + panic!("Invalid response: {:?}.", lines); + } + Ok(Err(err)) => { + panic!("Connection broken: {} ({:?})", err, lines); + } + Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), + } + } + } + + pub async fn send(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + self.writer.write_all(b"\r\n").await.unwrap(); + self.writer.flush().await.unwrap(); + } + + pub async fn send_raw(&mut self, text: &str) { + //let c = println!("-> {:?}", text); + self.writer.write_all(text.as_bytes()).await.unwrap(); + } +} diff --git a/tests/src/utils/storage.rs b/tests/src/utils/storage.rs new file mode 100644 index 0000000..061342d --- /dev/null +++ b/tests/src/utils/storage.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::cleanup::{search_store_destroy, store_assert_is_empty}; +use crate::utils::registry::UnwrapRegistryId; +use common::Server; +use registry::schema::structs::{Task, TaskStatus}; +use registry::{ + schema::{ + enums::{BlobStoreType, DataStoreType, InMemoryStoreType, SearchStoreType}, + prelude::Object, + structs::{ + BlobStore, DataStore, ElasticSearchStore, FileSystemStore, FoundationDbStore, HttpAuth, + HttpAuthBasic, HttpAuthBearer, InMemoryStore, MeilisearchStore, MySqlStore, + PostgreSqlStore, PublicStringOptional, PublicStringValue, RedisStore, RocksDbStore, + S3Store, S3StoreCustomRegion, S3StoreRegion, SearchStore, SecretKey, SecretKeyOptional, + SecretKeyValue, SqliteStore, + }, + }, + types::{EnumImpl, duration::Duration}, +}; +use store::write::now; +use store::{ + Deserialize, IterateParams, ValueKey, + write::{TaskQueueClass, ValueClass}, +}; +use store::{RegistryStore, registry::write::RegistryWrite}; + +pub trait RegistryEnvStores { + fn insert_stores_from_env(&self) -> impl Future; +} + +impl RegistryEnvStores for RegistryStore { + async fn insert_stores_from_env(&self) { + let path = self.path().as_os_str().to_str().unwrap(); + let mut search_store = None; + if let Ok(store) = std::env::var("SEARCH_STORE") { + let store = SearchStoreType::parse(&store).expect("Invalid store type"); + search_store = Some(Object::from(build_search_store(store, path).await)); + } + let mut blob_store = None; + if let Ok(store) = std::env::var("BLOB_STORE") { + let store = BlobStoreType::parse(&store).expect("Invalid store type"); + blob_store = Some(Object::from(build_blob_store(store, path).await)); + } + let mut in_memory = None; + if let Ok(store) = std::env::var("MEMORY_STORE") { + let store = InMemoryStoreType::parse(&store).expect("Invalid store type"); + in_memory = Some(Object::from(build_in_memory_store(store, path).await)); + } + + for store in [search_store, blob_store, in_memory].into_iter().flatten() { + self.write(RegistryWrite::insert(&store)) + .await + .expect("Failed to insert store into registry") + .unwrap_id(trc::location!()); + } + } +} + +pub async fn build_data_store(typ: &str, path: &str) -> DataStore { + if typ == "MariaDb" { + crate::utils::containers::ensure_mariadb().await; + return DataStore::MySql(MySqlStore { + host: "localhost".into(), + port: 3308, + auth_username: "root".to_string().into(), + auth_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "password".into(), + }), + database: "stalwart".into(), + use_tls: false, + allow_invalid_certs: true, + ..Default::default() + }); + } + + match DataStoreType::parse(typ).expect("Invalid store type") { + DataStoreType::RocksDb => DataStore::RocksDb(RocksDbStore { + path: format!("{path}/rocks.db"), + ..Default::default() + }), + DataStoreType::Sqlite => DataStore::Sqlite(SqliteStore { + path: format!("{path}/sqlite.db"), + ..Default::default() + }), + DataStoreType::FoundationDb => { + crate::utils::containers::ensure_foundationdb().await; + DataStore::FoundationDb(FoundationDbStore::default()) + } + DataStoreType::PostgreSql => { + crate::utils::containers::ensure_postgres().await; + DataStore::PostgreSql(PostgreSqlStore { + host: "localhost".into(), + port: 5432, + auth_username: "stalwart".to_string().into(), + auth_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "stalwart".into(), + }), + database: "stalwart".into(), + use_tls: false, + allow_invalid_certs: true, + ..Default::default() + }) + } + DataStoreType::MySql => { + crate::utils::containers::ensure_mysql().await; + DataStore::MySql(MySqlStore { + host: "localhost".into(), + port: 3307, + auth_username: "root".to_string().into(), + auth_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "password".into(), + }), + database: "stalwart".into(), + use_tls: false, + allow_invalid_certs: true, + ..Default::default() + }) + } + } +} + +async fn build_blob_store(typ: BlobStoreType, path: &str) -> BlobStore { + match typ { + BlobStoreType::S3 => { + crate::utils::containers::ensure_minio().await; + BlobStore::S3(S3Store { + access_key: PublicStringOptional::Value(PublicStringValue { + value: "minioadmin".into(), + }), + bucket: "stalwart".into(), + region: S3StoreRegion::Custom(S3StoreCustomRegion { + custom_endpoint: "http://localhost:9000".into(), + custom_region: "eu-central-1".into(), + }), + secret_key: SecretKeyOptional::Value(SecretKeyValue { + secret: "minioadmin".into(), + }), + allow_invalid_certs: true, + ..Default::default() + }) + } + BlobStoreType::FileSystem => BlobStore::FileSystem(FileSystemStore { + path: path.to_string(), + ..Default::default() + }), + _ => unreachable!(), + } +} + +async fn build_in_memory_store(typ: InMemoryStoreType, _path: &str) -> InMemoryStore { + match typ { + InMemoryStoreType::Redis => { + crate::utils::containers::ensure_redis().await; + InMemoryStore::Redis(RedisStore { + url: "redis://127.0.0.1".into(), + ..Default::default() + }) + } + _ => unreachable!(), + } +} + +async fn build_search_store(typ: SearchStoreType, _path: &str) -> SearchStore { + match typ { + SearchStoreType::ElasticSearch => { + crate::utils::containers::ensure_opensearch().await; + SearchStore::ElasticSearch(ElasticSearchStore { + url: "http://localhost:9200".into(), + allow_invalid_certs: true, + http_auth: HttpAuth::Basic(HttpAuthBasic { + username: "elastic".into(), + secret: SecretKey::Value(SecretKeyValue { + secret: "changeme".into(), + }), + }), + ..Default::default() + }) + } + SearchStoreType::Meilisearch => { + crate::utils::containers::ensure_meilisearch().await; + SearchStore::Meilisearch(MeilisearchStore { + url: "http://localhost:7700".into(), + allow_invalid_certs: true, + poll_interval: Duration::from_millis(100), + http_auth: HttpAuth::Bearer(HttpAuthBearer { + bearer_token: SecretKey::Value(SecretKeyValue { + secret: "stalwart-master-key".into(), + }), + }), + ..Default::default() + }) + } + _ => unreachable!(), + } +} + +pub async fn wait_for_tasks(server: &Server, skip_not_due: bool, skip_permanent_failures: bool) { + let mut count = 0; + loop { + let mut has_index_tasks = None; + server + .core + .storage + .data + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: 0 })), + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), + ) + .ascending(), + |_, value| { + let task = Task::deserialize(value)?; + if (skip_permanent_failures && matches!(task.status(), TaskStatus::Failed(_))) + || (skip_not_due && task.due_timestamp() > now()) + { + Ok(true) + } else { + has_index_tasks = Some(task); + + Ok(false) + } + }, + ) + .await + .unwrap(); + + if let Some(task) = has_index_tasks { + count += 1; + if count % 10 == 0 { + println!("Waiting for pending task {:?}...", task); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } else { + break; + } + } +} + +pub async fn assert_is_empty(server: &Server, include_registry: bool) { + // Wait for pending index tasks + wait_for_tasks(server, false, false).await; + + // Assert is empty + store_assert_is_empty( + server.store(), + server.core.storage.blob.clone(), + include_registry, + ) + .await; + search_store_destroy(server.search_store()).await; + + // Clean caches + for cache in [ + &server.inner.cache.events, + &server.inner.cache.contacts, + &server.inner.cache.files, + &server.inner.cache.scheduling, + ] { + cache.clear(); + } + server.inner.cache.messages.clear(); +} diff --git a/tests/src/utils/temp_dir.rs b/tests/src/utils/temp_dir.rs new file mode 100644 index 0000000..aeb818f --- /dev/null +++ b/tests/src/utils/temp_dir.rs @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub struct TempDir { + pub path: std::path::PathBuf, + pub delete: bool, +} + +impl TempDir { + pub fn new(name: &str, delete_if_exists: bool) -> Self { + let mut path = std::env::temp_dir(); + path.push(name); + if delete_if_exists && path.exists() { + std::fs::remove_dir_all(&path).unwrap(); + } + std::fs::create_dir_all(&path).unwrap(); + Self { + path, + delete: delete_if_exists, + } + } + + pub fn delete(&self) { + std::fs::remove_dir_all(&self.path).unwrap(); + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + if self.delete { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} diff --git a/tests/src/utils/webdav.rs b/tests/src/utils/webdav.rs new file mode 100644 index 0000000..8407cad --- /dev/null +++ b/tests/src/utils/webdav.rs @@ -0,0 +1,1418 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::{AHashMap, AHashSet}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use dav_proto::{ + Depth, + schema::property::{CalDavProperty, DavProperty, WebDavProperty}, + xml_pretty_print, +}; +use groupware::DavResourceName; +use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION}; +use quick_xml::{NsReader, Reader, XmlVersion, events::Event, name::ResolveResult}; +use std::{borrow::Cow, time::Duration}; +use store::rand::{RngExt, distr::Alphanumeric, rng}; + +#[allow(dead_code)] +#[derive(Debug)] +pub struct DummyWebDavClient { + pub account_id: u32, + pub name: &'static str, + pub email: &'static str, + pub credentials: String, +} + +#[derive(Debug)] +pub struct DavResponse { + pub headers: AHashMap, + pub status: StatusCode, + pub body: Result, + pub xml: Vec<(String, String)>, +} + +#[derive(Debug)] +pub struct DavMultiStatus { + pub response: DavResponse, + pub hrefs: AHashMap, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavItem { + #[serde(serialize_with = "serialize_status_code")] + pub status: StatusCode, + pub values: AHashMap>, + pub error: Vec, + pub description: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavProperties { + #[serde(skip)] + status: StatusCode, + props: Vec, +} + +pub struct DavPropertyResult<'x> { + pub response: &'x DavResponse, + pub properties: &'x DavProperties, +} + +pub struct DavQueryResult<'x> { + pub response: &'x DavResponse, + pub prop: &'x DavItem, + pub values: &'x [String], +} + +impl DummyWebDavClient { + pub fn new( + account_id: u32, + name: &'static str, + secret: &'static str, + email: &'static str, + ) -> Self { + Self { + account_id, + name, + email, + credentials: format!( + "Basic {}", + STANDARD.encode(format!("{name}:{secret}").as_bytes()) + ), + } + } + + pub async fn request(&self, method: &str, query: &str, body: impl Into) -> DavResponse { + self.request_with_headers(method, query, [], body).await + } + + pub async fn request_with_headers( + &self, + method: &str, + query: &str, + headers: impl IntoIterator, + body: impl Into, + ) -> DavResponse { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request( + Method::from_bytes(method.as_bytes()).unwrap(), + format!("https://127.0.0.1:8899{query}"), + ); + + let body = body.into(); + if !body.is_empty() { + request = request.body(body); + } + + let mut request_headers = HeaderMap::new(); + for (key, value) in headers { + request_headers.insert(key, value.parse().unwrap()); + } + request_headers.insert(AUTHORIZATION, self.credentials.parse().unwrap()); + + let response = request.headers(request_headers).send().await.unwrap(); + let status = response.status(); + let headers = response + .headers() + .iter() + .map(|(k, v)| { + ( + k.to_string().to_lowercase(), + v.to_str().unwrap().to_string(), + ) + }) + .collect(); + let body = response + .bytes() + .await + .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) + .map_err(|err| err.to_string()); + let xml = match &body { + Ok(body) if body.starts_with(" { + if let Some(prefix) = undeclared_xml_prefix(body) { + panic!( + "Response uses undeclared namespace prefix {prefix:?}: {}", + xml_pretty_print(body) + ); + } + flatten_xml(body) + } + _ => vec![], + }; + + DavResponse { + headers, + status, + body, + xml, + } + } + + pub async fn available_quota(&self, path: &str) -> u64 { + self.propfind( + path, + [DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)], + ) + .await + .properties(path) + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .value() + .parse() + .unwrap() + } + + pub async fn create_hierarchy( + &self, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + ) -> (String, Vec<(String, String)>) { + let resource_type = if base_path.starts_with("/dav/card/") { + DavResourceName::Card + } else if base_path.starts_with("/dav/cal/") { + DavResourceName::Cal + } else { + DavResourceName::File + }; + + let mut created_resources = Vec::new(); + + self.create_hierarchy_recursive( + resource_type, + base_path, + max_depth, + containers_per_level, + files_per_container, + 0, + &mut created_resources, + ) + .await; + + let root_folder = created_resources.first().unwrap().0.clone(); + created_resources.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + (root_folder, created_resources) + } + + #[allow(clippy::too_many_arguments)] + async fn create_hierarchy_recursive( + &self, + resource_type: DavResourceName, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + current_depth: usize, + created_resources: &mut Vec<(String, String)>, + ) { + let folder_name = generate_random_name(4); + let folder_path = format!("{base_path}/Folder_{folder_name}"); + + self.mkcol("MKCOL", &folder_path, [], []) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((format!("{folder_path}/"), "".to_string())); + + for _ in 0..files_per_container { + let file_name = generate_random_name(8); + let file_path = format!( + "{folder_path}/{file_name}.{}", + match resource_type { + DavResourceName::Card => "vcf", + DavResourceName::Cal => "ics", + DavResourceName::File => "txt", + _ => unreachable!(), + } + ); + let content = match resource_type { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 500), + _ => unreachable!(), + }; + + self.request("PUT", &file_path, &content) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((file_path, content)); + } + + if current_depth < max_depth { + for _ in 0..containers_per_level { + Box::pin(self.create_hierarchy_recursive( + resource_type, + &folder_path, + max_depth, + containers_per_level, + files_per_container, + current_depth + 1, + created_resources, + )) + .await; + } + } + } + + pub async fn validate_values(&self, items: &[(String, String)]) { + for (path, value) in items { + if !path.ends_with('/') { + self.request("GET", path, "") + .await + .with_status(StatusCode::OK) + .with_body(value); + } + } + } + + pub async fn delete_default_containers(&self) { + self.delete_default_containers_by_account(self.name).await; + } + + pub async fn delete_default_containers_by_account(&self, account: &str) { + for col in ["card", "cal"] { + self.request("DELETE", &format!("/dav/{col}/{account}/default"), "") + .await + .with_status(StatusCode::NO_CONTENT); + } + } + + pub async fn lock_create( + &self, + path: &str, + owner: &str, + is_exclusive: bool, + depth: &str, + timeout: &str, + ) -> DavResponse { + let lock_request = LOCK_REQUEST + .replace("$TYPE", if is_exclusive { "exclusive" } else { "shared" }) + .replace("$OWNER", owner); + self.request_with_headers( + "LOCK", + path, + [("depth", depth), ("timeout", timeout)], + &lock_request, + ) + .await + } + + pub async fn lock_refresh( + &self, + path: &str, + lock_token: &str, + depth: &str, + timeout: &str, + ) -> DavResponse { + let condition = format!("(<{lock_token}>)"); + self.request_with_headers( + "LOCK", + path, + [ + ("if", condition.as_str()), + ("depth", depth), + ("timeout", timeout), + ], + "", + ) + .await + } + + pub async fn unlock(&self, path: &str, lock_token: &str) -> DavResponse { + let condition = format!("<{lock_token}>"); + self.request_with_headers("UNLOCK", path, [("lock-token", condition.as_str())], "") + .await + } + + pub async fn mkcol( + &self, + method: &str, + path: &str, + resource_types: impl IntoIterator, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + let mut has_resource_type = false; + for (idx, resource_type) in resource_types.into_iter().enumerate() { + if idx == 0 { + request.push_str(""); + } + request.push_str(&format!("<{resource_type}/>")); + has_resource_type = true; + } + + if has_resource_type { + request.push_str(""); + } + + for (key, value) in properties { + request.push_str(&format!("<{key}>{value}")); + } + request.push_str(""); + + if method == "MKCALENDAR" { + request = request.replace("D:mkcol", "A:mkcalendar"); + } + + self.request(method, path, &request).await + } + + pub async fn patch_and_check( + &self, + path: &str, + properties: impl IntoIterator, + ) where + T: AsRef + Clone, + { + let mut expect_set = Vec::new(); + let mut expect_remove = Vec::new(); + + for (key, value) in properties { + if !value.is_empty() { + expect_set.push((key, value)); + } else { + expect_remove.push(key); + } + } + + let response = self + .proppatch( + path, + expect_set.iter().cloned(), + expect_remove.iter().cloned(), + [], + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + let patch_prop = response.properties(path); + for (key, _) in &expect_set { + patch_prop.get(key.as_ref()).with_status(StatusCode::OK); + } + for key in &expect_remove { + patch_prop + .get(key.as_ref()) + .with_status(StatusCode::NO_CONTENT); + } + + let response = self + .propfind( + path, + expect_set + .iter() + .map(|(k, _)| k) + .chain(expect_remove.iter()), + ) + .await; + let prop = response.properties(path); + + for (key, value) in expect_set { + prop.get(key.as_ref()) + .with_values([value]) + .with_status(StatusCode::OK); + } + + for key in expect_remove { + prop.get(key.as_ref()).with_status(StatusCode::NOT_FOUND); + } + } + + pub async fn propfind(&self, path: &str, properties: I) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + self.propfind_with_headers(path, properties, []).await + } + + pub async fn propfind_with_headers( + &self, + path: &str, + properties: I, + headers: impl IntoIterator, + ) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + self.request_with_headers("PROPFIND", path, headers, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn proppatch( + &self, + path: &str, + set: impl IntoIterator, + clear: impl IntoIterator, + headers: impl IntoIterator, + ) -> DavResponse + where + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in clear { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + for (key, value) in set { + let key = key.as_ref(); + request.push_str(&format!("<{key}>{value}")); + } + + request.push_str(""); + + self.request_with_headers("PROPPATCH", path, headers, &request) + .await + } + + pub async fn multiget_calendar(&self, path: &str, uris: &[&str]) -> DavMultiStatus { + let mut paths = String::new(); + for uri in uris { + paths.push_str(&format!("{}", uri)); + } + + self.request("REPORT", path, &MULTIGET_CALENDAR.replace("$PATH", &paths)) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn multiget_addressbook(&self, path: &str, uris: &[&str]) -> DavMultiStatus { + let mut paths = String::new(); + for uri in uris { + paths.push_str(&format!("{}", uri)); + } + + self.request( + "REPORT", + path, + &MULTIGET_ADDRESSBOOK.replace("$PATH", &paths), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn sync_collection( + &self, + path: &str, + sync_token: &str, + depth: Depth, + limit: Option, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{property}/>")); + } + + request.push_str(""); + request.push_str(sync_token); + request.push_str(""); + request.push_str(match depth { + Depth::One => "1", + Depth::Infinity => "infinite", + _ => "0", + }); + request.push_str(""); + + if let Some(limit) = limit { + request.push_str(""); + request.push_str(&limit.to_string()); + request.push_str(""); + } + + request.push_str(""); + + self.request("REPORT", path, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + } + + pub async fn acl<'x>( + &self, + query: &str, + principal_href: &str, + grant: impl IntoIterator, + ) -> DavResponse { + let body = ACL_QUERY.replace("$HREF", principal_href).replace( + "$GRANT", + &grant.into_iter().fold(String::new(), |mut output, g| { + use std::fmt::Write; + let _ = write!(output, ""); + output + }), + ); + self.request("ACL", query, &body).await + } +} + +impl DavResponse { + pub fn with_status(self, status: StatusCode) -> Self { + if self.status != status { + self.dump_response(); + panic!("Expected {status} but got {}", self.status) + } + self + } + + pub fn with_redirect_to(self, url: &str) -> Self { + self.with_status(StatusCode::TEMPORARY_REDIRECT) + .with_header("location", url) + } + + pub fn with_header(self, header: &str, value: &str) -> Self { + if self.headers.get(header).is_some_and(|v| v == value) { + self + } else { + self.dump_response(); + panic!("Header {header}:{value} not found.") + } + } + + pub fn with_body(self, expect_body: impl AsRef) -> Self { + let expect_body = expect_body.as_ref(); + if let Ok(body) = &self.body { + if body != expect_body { + self.dump_response(); + assert_eq!(body, &expect_body); + } + self + } else { + self.dump_response(); + panic!("Expected body {expect_body:?} but no body was returned.") + } + } + + pub fn with_empty_body(self) -> Self { + if let Ok(body) = &self.body { + if !body.is_empty() { + self.dump_response(); + panic!("Expected empty body but got {body:?}"); + } + self + } else { + self.dump_response(); + panic!("Expected empty body but no body was returned.") + } + } + + pub fn expect_body(&self) -> &str { + if let Ok(body) = &self.body { + body + } else { + self.dump_response(); + panic!("Expected body but no body was returned.") + } + } + + pub fn header(&self, header: &str) -> &str { + if let Some(value) = self.headers.get(header) { + value + } else { + self.dump_response(); + panic!("Header {header} not found.") + } + } + + pub fn etag(&self) -> &str { + self.header("etag") + } + + pub fn lock_token(&self) -> &str { + self.value("D:prop.D:lockdiscovery.D:activelock.D:locktoken.D:href") + } + + pub fn sync_token(&self) -> &str { + self.find_keys("D:multistatus.D:sync-token") + .next() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| { + self.dump_response(); + panic!("Sync token not found.") + }) + } + + pub fn hrefs(&self) -> Vec<&str> { + let mut hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + hrefs.sort_unstable(); + hrefs + } + + pub fn with_href_count(self, count: usize) -> Self { + let href_count = self.find_keys("D:multistatus.D:response.D:href").count(); + if href_count != count { + self.dump_response(); + panic!("Expected {} hrefs but got {}", count, href_count); + } + self + } + + pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator) -> Self { + let expected_hrefs = hrefs.into_iter().collect::>(); + let hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + if expected_hrefs != hrefs { + self.dump_response(); + + println!("\nMissing: {:?}", expected_hrefs.difference(&hrefs)); + println!("\nExtra: {:?}", hrefs.difference(&expected_hrefs)); + + panic!( + "Hierarchy mismatch: expected {} items, received {} items", + expected_hrefs.len(), + hrefs.len() + ); + } + self + } + + fn dump_response(&self) { + eprintln!("-------------------------------------"); + eprintln!("Status: {}", self.status); + eprintln!("Headers:"); + for (key, value) in self.headers.iter() { + eprintln!(" {}: {:?}", key, value); + } + if !self.xml.is_empty() { + eprintln!("XML: {}", xml_pretty_print(self.body.as_ref().unwrap())); + + for (key, value) in self.xml.iter() { + eprintln!("{} -> {:?}", key, value); + } + } else { + eprintln!("Body: {:?}", self.body); + } + } + + fn find_keys(&self, name: &str) -> impl Iterator { + self.xml + .iter() + .filter(move |(key, _)| name == key) + .map(|(_, value)| value.as_str()) + } + + pub fn value(&self, name: &str) -> &str { + self.find_keys(name).next().unwrap_or_else(|| { + self.dump_response(); + panic!("Key {name} not found.") + }) + } + + // Poor man's XPath + pub fn with_value(self, query: &str, expect: impl AsRef) -> Self { + let expect = expect.as_ref(); + if let Some(value) = self.find_keys(query).next() { + if value != expect { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {value:?}"); + } + } else { + self.dump_response(); + panic!("Key {query} not found."); + } + self + } + + pub fn with_any_value<'x>( + self, + query: &str, + expect: impl IntoIterator, + ) -> Self { + let expect = expect.into_iter().collect::>(); + if let Some(value) = self.find_keys(query).next() { + if !expect.contains(value) { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {value:?}"); + } + } else { + self.dump_response(); + panic!("Key {query} not found."); + } + self + } + + pub fn with_values(self, query: &str, expect: I) -> Self + where + I: IntoIterator, + T: AsRef, + { + let expect_owned: Vec = expect.into_iter().collect(); + let expect = expect_owned.iter().map(|s| s.as_ref()).collect::>(); + let found = self.find_keys(query).collect::>(); + if expect != found { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {found:?}"); + } + self + } + + pub fn with_failed_precondition(self, precondition: &str, value: &str) -> Self { + let error = format!("D:error.{precondition}"); + if self.find_keys(&error).next().is_none_or(|v| v != value) { + self.dump_response(); + panic!("Precondition {precondition} did not match."); + } + self + } + + pub fn into_propfind_response(mut self, prop_prefix: Option<&str>) -> DavMultiStatus { + if let Some(prop_prefix) = prop_prefix { + for (key, _) in self.xml.iter_mut() { + if let Some(suffix) = key.strip_prefix(prop_prefix) { + *key = format!("D:multistatus.D:response{suffix}"); + } + } + self.xml.push(( + "D:multistatus.D:response.D:href".to_string(), + "".to_string(), + )); + } + + let mut result = DavMultiStatus { + response: self, + hrefs: AHashMap::new(), + }; + let mut href = None; + let mut href_status = StatusCode::OK; + let mut props = Vec::new(); + let mut prop = DavItem::default(); + + for (key, value) in &result.response.xml { + match key.as_str() { + "D:multistatus.D:response.D:href" => { + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(std::mem::take(&mut prop)); + } + result.hrefs.insert( + href, + DavProperties { + status: href_status, + props: std::mem::take(&mut props), + }, + ); + href_status = StatusCode::OK; + } + href = Some(value.to_string()); + } + "D:multistatus.D:response.D:status" => { + href_status = value + .split_ascii_whitespace() + .nth(1) + .unwrap_or_default() + .parse() + .unwrap(); + } + "D:multistatus.D:response.D:propstat.D:status" => { + prop.status = value + .split_ascii_whitespace() + .nth(1) + .unwrap_or_default() + .parse() + .unwrap(); + } + "D:multistatus.D:response.D:propstat.D:responsedescription" => { + prop.description = Some(value.to_string()); + } + _ => { + if let Some(prop_name) = + key.strip_prefix("D:multistatus.D:response.D:propstat.D:prop.") + { + if prop.status != StatusCode::PROXY_AUTHENTICATION_REQUIRED { + props.push(std::mem::take(&mut prop)); + } + + let (prop_name, prop_value) = + if let Some((prop_name, prop_sub_name)) = prop_name.split_once('.') { + if value.is_empty() { + (prop_name, prop_sub_name.to_string()) + } else { + (prop_name, format!("{}:{}", prop_sub_name, value)) + } + } else { + (prop_name, value.to_string()) + }; + prop.values + .entry(prop_name.to_string()) + .or_default() + .push(prop_value); + } + } + } + } + + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(prop); + } + result.hrefs.insert( + href, + DavProperties { + status: href_status, + props, + }, + ); + } + + result + } +} + +impl DavPropertyResult<'_> { + pub fn get(&self, name: impl AsRef) -> DavQueryResult<'_> { + let name = name.as_ref(); + self.properties + .props + .iter() + .find_map(|prop| { + prop.values.get(name).map(|values| DavQueryResult { + response: self.response, + prop, + values, + }) + }) + .unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No property found for name: {name} in {}", + serde_json::to_string_pretty(&self.properties.props).unwrap() + ) + }) + } + + pub fn with_status(&self, status: StatusCode) -> &Self { + if self.properties.status != status { + self.response.dump_response(); + panic!( + "Expected status {status}, but got {}", + self.properties.status + ); + } + self + } + + pub fn is_defined(&self, name: impl AsRef) -> &Self { + if self + .properties + .props + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self + } else { + self.response.dump_response(); + panic!("Expected property {} to be defined", name.as_ref()); + } + } + + pub fn is_undefined(&self, name: impl AsRef) -> &Self { + if self + .properties + .props + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self.response.dump_response(); + panic!("Expected property {} to be undefined", name.as_ref()); + } + self + } + + pub fn calendar_data(&self) -> DavQueryResult<'_> { + self.get(DavProperty::CalDav(CalDavProperty::CalendarData( + Default::default(), + ))) + } +} + +impl<'x> DavQueryResult<'x> { + pub fn with_values(&self, expected_values: impl IntoIterator) -> &Self { + let expected_values = AHashSet::from_iter(expected_values); + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if values != expected_values { + self.response.dump_response(); + assert_eq!(values, expected_values,); + } + self + } + + pub fn with_some_values(&self, expected_values: impl IntoIterator) -> &Self { + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + for expected_value in expected_values { + if !values.contains(expected_value) { + self.response.dump_response(); + panic!("Expected at least one of {expected_value:?} values, but got {values:?}",); + } + } + + self + } + + pub fn with_any_values(&self, expected_values: impl IntoIterator) -> &Self { + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + let expected_values = AHashSet::from_iter(expected_values); + + if values.is_disjoint(&expected_values) { + self.response.dump_response(); + panic!("Expected at least one of {expected_values:?} values, but got {values:?}",); + } + + self + } + + pub fn without_values(&self, expected_values: impl IntoIterator) -> &Self { + let expected_values = AHashSet::from_iter(expected_values); + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if !expected_values.is_disjoint(&values) { + self.response.dump_response(); + panic!("Expected no {expected_values:?} values, but got {values:?}",); + } + self + } + + pub fn is_not_empty(&self) -> &Self { + if self.values.is_empty() || self.values.iter().all(|s| s.is_empty()) { + self.response.dump_response(); + panic!("Expected non-empty values, but got {:?}", self.values); + } + self + } + + pub fn value(&self) -> &str { + if let Some(value) = self.values.iter().find(|s| !s.is_empty()) { + value + } else { + self.response.dump_response(); + panic!("Expected a value, but got {:?}", self.values); + } + } + + pub fn with_status(&self, status: StatusCode) -> &Self { + if self.prop.status != status { + self.response.dump_response(); + panic!("Expected status {status}, but got {}", self.prop.status); + } + self + } + + pub fn with_description(&self, description: &str) -> &Self { + if self.prop.description.as_deref() != Some(description) { + self.response.dump_response(); + panic!( + "Expected description {description}, but got {:?}", + self.prop.description + ); + } + self + } + pub fn with_error(&self, error: &str) -> &Self { + if !self.prop.error.contains(&error.to_string()) { + self.response.dump_response(); + panic!("Expected error {error}, but got {:?}", self.prop.error); + } + self + } +} + +impl DavMultiStatus { + pub fn properties(&self, href: &str) -> DavPropertyResult<'_> { + DavPropertyResult { + response: &self.response, + properties: self.hrefs.get(href).unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No properties found for href: {href} in {}", + serde_json::to_string_pretty(&self.hrefs).unwrap() + ) + }), + } + } + + pub fn with_hrefs<'x>(&self, expect_hrefs: impl IntoIterator) -> &Self { + let expect_hrefs: AHashSet<_> = expect_hrefs.into_iter().collect(); + let hrefs: AHashSet<_> = self.hrefs.keys().map(|s| s.as_str()).collect(); + if hrefs != expect_hrefs { + self.response.dump_response(); + panic!("Expected hrefs {expect_hrefs:?}, but got {hrefs:?}",); + } + self + } +} + +impl DavItem { + pub fn is_empty(&self) -> bool { + self.values.is_empty() + && self.status == StatusCode::PROXY_AUTHENTICATION_REQUIRED + && self.error.is_empty() + && self.description.is_none() + } +} + +impl Default for DavItem { + fn default() -> Self { + DavItem { + status: StatusCode::PROXY_AUTHENTICATION_REQUIRED, + values: AHashMap::new(), + error: Vec::new(), + description: None, + } + } +} + +fn undeclared_xml_prefix(xml: &str) -> Option { + let mut reader = NsReader::from_str(xml); + let mut buf = Vec::new(); + + loop { + let (resolved, event) = reader.read_resolved_event_into(&mut buf).unwrap(); + let name = match &event { + Event::Start(e) | Event::Empty(e) => e.name(), + Event::Eof => return None, + _ => { + buf.clear(); + continue; + } + }; + + if matches!(resolved, ResolveResult::Unknown(_)) { + return Some(String::from_utf8_lossy(name.as_ref()).into_owned()); + } + + buf.clear(); + } +} + +fn flatten_xml(xml: &str) -> Vec<(String, String)> { + let mut reader = Reader::from_str(xml); + + let mut path: Vec = Vec::new(); + let mut result: Vec<(String, String)> = Vec::new(); + let mut buf = Vec::new(); + let mut text_content: Option = None; + + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) => { + let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); + path.push(name); + let base_path = path.join("."); + for attr in e.attributes() { + let attr = attr.unwrap(); + let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); + let value = attr.normalized_value(XmlVersion::Implicit1_0).unwrap(); + let value_str = value.trim().to_string(); + + result.push((format!("{}.[{}]", base_path, key), value_str)); + } + text_content = None; + } + Event::Empty(ref e) => { + let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); + let base_path = format!("{}.{}", path.join("."), name); + let mut has_attrs = false; + + for attr in e.attributes() { + let attr = attr.unwrap(); + let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); + let value = attr.normalized_value(XmlVersion::Implicit1_0).unwrap(); + let value_str = value.trim().to_string(); + has_attrs = true; + result.push((format!("{}.[{}]", base_path, key), value_str)); + } + + if !has_attrs { + result.push((base_path, "".to_string())); + } + } + Event::Text(e) => { + let text = e.xml_content(XmlVersion::Implicit1_0).unwrap(); + let trimmed = text.trim(); + if !trimmed.is_empty() { + if let Some(text_content) = text_content.as_mut() { + text_content.push_str(trimmed); + } else { + text_content = Some(trimmed.to_string()); + } + } + } + Event::GeneralRef(entity) => { + let entity_slice: &[u8] = entity.as_ref(); + let value: Cow = match entity_slice { + b"lt" => "<".into(), + b"gt" => ">".into(), + b"amp" => "&".into(), + b"apos" => "'".into(), + b"quot" => "\"".into(), + _ => { + if let Ok(Some(gr)) = entity.resolve_char_ref() { + gr.to_string().into() + } else { + std::str::from_utf8(entity.as_ref()) + .unwrap_or_default() + .into() + } + } + }; + + if let Some(text_content) = text_content.as_mut() { + text_content.push_str(value.as_ref()); + } else { + text_content = Some(value.into_owned()); + } + } + Event::CData(e) => { + text_content = Some(std::str::from_utf8(e.as_ref()).unwrap().to_string()); + } + Event::End(_) => { + if let Some(text) = text_content.take() { + result.push((path.join("."), text)); + } + + if !path.is_empty() { + path.pop(); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + result +} + +pub trait GenerateTestDavResource { + fn generate(&self) -> String; +} + +impl GenerateTestDavResource for DavResourceName { + fn generate(&self) -> String { + match self { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 200), + _ => unreachable!(), + } + } +} + +fn generate_random_vcard() -> String { + r#"BEGIN:VCARD +VERSION:4.0 +UID:$UID +FN:$NAME +END:VCARD +"# + .replace("$UID", &generate_random_name(8)) + .replace("$NAME", &generate_random_name(10)) + .replace('\n', "\r\n") +} + +fn generate_random_ical() -> String { + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:$UID +SUMMARY:$SUMMARY +DESCRIPTION:$DESCRIPTION +END:VEVENT +END:VCALENDAR +"# + .replace("$UID", &generate_random_name(8)) + .replace("$SUMMARY", &generate_random_name(10)) + .replace("$DESCRIPTION", &generate_random_name(20)) + .replace('\n', "\r\n") +} + +fn generate_random_content(min_chars: usize, max_chars: usize) -> String { + let mut rng = rng(); + let length = rng.random_range(min_chars..=max_chars); + + let words = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + "ut", + "enim", + "ad", + "minim", + "veniam", + "quis", + "nostrud", + "exercitation", + "ullamco", + "laboris", + "nisi", + "ut", + "aliquip", + "ex", + "ea", + "commodo", + "consequat", + ]; + + let mut content = String::with_capacity(length); + + while content.len() < length { + let word_idx = rng.random_range(0..words.len()); + if !content.is_empty() { + content.push(' '); + } + if rng.random_ratio(1, 10) { + content.push('.'); + let word = words[word_idx]; + let mut chars = word.chars(); + if let Some(first_char) = chars.next() { + content.push_str(&first_char.to_uppercase().to_string()); + content.push_str(chars.as_str()); + } + } else { + content.push_str(words[word_idx]); + } + } + + if !content.ends_with('.') { + content.push('.'); + } + + content +} + +fn generate_random_name(length: usize) -> String { + let mut rng = rng(); + (0..length) + .map(|_| rng.sample(Alphanumeric) as char) + .collect() +} + +fn serialize_status_code(status_code: &StatusCode, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&status_code.to_string()) +} + +const MULTIGET_CALENDAR: &str = r#" + + + + + + $PATH + +"#; +const MULTIGET_ADDRESSBOOK: &str = r#" + + + + + + $PATH + +"#; + +const ACL_QUERY: &str = r#" + + + + $HREF + + + $GRANT + + + "#; + +const LOCK_REQUEST: &str = r#" + + + + + $OWNER + + "#; diff --git a/tests/src/webdav/acl.rs b/tests/src/webdav/acl.rs new file mode 100644 index 0000000..13498c1 --- /dev/null +++ b/tests/src/webdav/acl.rs @@ -0,0 +1,438 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, webdav::GenerateTestDavResource}; +use dav_proto::schema::property::{DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + let owner_client = test.account("bill@example.com").webdav_client(); + let sharee_client = test.account("john@example.com").webdav_client(); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!("Running ACL tests ({})...", resource_type.base_path()); + let is_file = resource_type == DavResourceName::File; + let sharee_principal = format!( + "{}/john%40example.com/", + DavResourceName::Principal.base_path() + ); + let sharee_base_path = format!("{}/john%40example.com/", resource_type.base_path()); + let owner_principal = format!( + "{}/bill%40example.com/", + DavResourceName::Principal.base_path() + ); + let owner_base_path = format!("{}/bill%40example.com/", resource_type.base_path()); + + // Create a resource for the owner + let owner_folder = format!("{owner_base_path}test-shared/"); + let owner_folder_private = format!("{owner_base_path}test-private/"); + let owner_file = format!("{owner_folder}test-file"); + let owner_file_content = resource_type.generate(); + let owner_file_private = format!("{owner_folder_private}test-file-private"); + let owner_file_content_private = resource_type.generate(); + let sharee_created_file = format!("{owner_folder}test-file-sharee"); + for (folder, file, content) in [ + (&owner_folder, &owner_file, &owner_file_content), + ( + &owner_folder_private, + &owner_file_private, + &owner_file_content_private, + ), + ] { + owner_client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + owner_client + .request("PUT", file, content) + .await + .with_status(StatusCode::CREATED); + } + + // Create a resource for the sharee + let sharee_folder = format!("{sharee_base_path}test-folder/"); + let sharee_file = format!("{sharee_folder}test-file"); + let sharee_file_content = resource_type.generate(); + sharee_client + .request("MKCOL", &sharee_folder, "") + .await + .with_status(StatusCode::CREATED); + sharee_client + .request("PUT", &sharee_file, &sharee_file_content) + .await + .with_status(StatusCode::CREATED); + + // Test 1: Sharee should only see their own resources + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str()]); + + // Test 2: Share a resource and make sure the root folder is visible + owner_client + .acl(&owner_folder, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl(&owner_file, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + } + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]); + + // Test 3: Verify that only the shared resource is visible + sharee_client + .propfind_with_headers( + &owner_base_path, + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([owner_folder.as_str()]); + + // Test 4: Verify that the sharee can access the shared resource + sharee_client + .propfind( + &owner_folder, + [DavProperty::WebDav(WebDavProperty::GetETag)], + ) + .await + .with_hrefs([owner_folder.as_str(), owner_file.as_str()]); + sharee_client + .request("GET", &owner_file, "") + .await + .with_status(StatusCode::OK) + .with_body(&owner_file_content); + match resource_type { + DavResourceName::Cal => { + sharee_client + .multiget_calendar(&owner_folder, &[&owner_file]) + .await + .properties(&owner_file) + .with_status(StatusCode::OK) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)); + sharee_client + .request("REPORT", &owner_folder, CALENDAR_QUERY_ANY_VEVENT) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([owner_file.as_str()]); + } + DavResourceName::Card => { + sharee_client + .multiget_addressbook(&owner_folder, &[&owner_file]) + .await + .properties(&owner_file) + .with_status(StatusCode::OK) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)); + sharee_client + .request("REPORT", &owner_folder, ADDRESSBOOK_QUERY_ANY_FN) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([owner_file.as_str()]); + } + _ => {} + } + + // Test 5: Read ACL as owner + let response = owner_client + .propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + response + .properties(&owner_folder) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_values([ + format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(), + "D:ace.D:grant.D:privilege.D:read", + "D:ace.D:grant.D:privilege.D:read-current-user-privilege-set", + ]); + + // Test 6: acl-principal-prop-set REPORT + let response = owner_client + .request("REPORT", &owner_folder, ACL_PRINCIPAL_QUERY) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response + .properties(&sharee_principal) + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values(["John Doe"]); + + // Test 7: Verify current-user-privilege-set and owner + let response = sharee_client + .propfind( + &owner_folder, + [ + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Owner), + ], + ) + .await; + for href in [owner_folder.as_str(), owner_file.as_str()] { + let props = response.properties(href); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:read", + "D:privilege.D:read-current-user-privilege-set", + ]); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([format!("D:href:{owner_principal}").as_str()]); + } + + // Test 8: Write operations should fail + for (path, dest, dest_copy) in [ + ( + &owner_folder, + &sharee_folder, + Some(format!("{sharee_base_path}copied/")), + ), + (&owner_file, &sharee_file, None), + ] { + sharee_client + .proppatch( + path, + [(DavProperty::WebDav(WebDavProperty::DisplayName), "test")], + [], + [], + ) + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request("DELETE", path, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers("MOVE", path, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::FORBIDDEN); + if let Some(dest_copy) = dest_copy { + sharee_client + .request_with_headers("COPY", path, [("destination", dest_copy.as_str())], "") + .await + .with_status(StatusCode::CREATED); + } + } + sharee_client + .request("PUT", &owner_file, resource_type.generate()) + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request("PUT", &sharee_created_file, resource_type.generate()) + .await + .with_status(StatusCode::FORBIDDEN); + + // Test 9: Grant write access to the sharee + owner_client + .acl( + &owner_folder, + sharee_principal.as_str(), + ["read", "write-content", "write-properties"], + ) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl( + &owner_file, + sharee_principal.as_str(), + ["read", "write-content", "write-properties"], + ) + .await + .with_status(StatusCode::OK); + } + let response = owner_client + .propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + response + .properties(&owner_folder) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_values([ + format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(), + "D:ace.D:grant.D:privilege.D:read", + "D:ace.D:grant.D:privilege.D:read-current-user-privilege-set", + "D:ace.D:grant.D:privilege.D:write-content", + "D:ace.D:grant.D:privilege.D:write-properties", + ]); + let response = sharee_client + .propfind( + &owner_folder, + [DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)], + ) + .await; + for href in [owner_folder.as_str(), owner_file.as_str()] { + response + .properties(href) + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:read", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-content", + "D:privilege.D:write-properties", + ]); + } + + // Test 10: Delete operations should fail + for (path, dest) in [(&owner_folder, &sharee_folder), (&owner_file, &sharee_file)] { + sharee_client + .proppatch( + path, + [(DavProperty::WebDav(WebDavProperty::DisplayName), "test")], + [], + [], + ) + .await + .with_status(StatusCode::MULTI_STATUS); + sharee_client + .request("DELETE", path, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers("MOVE", path, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::FORBIDDEN); + } + sharee_client + .request("PUT", &owner_file, &owner_file_content) + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("PUT", &sharee_created_file, resource_type.generate()) + .await + .with_status(StatusCode::CREATED); + + // Test 11: Grant delete access to the sharee and verify + owner_client + .acl(&owner_folder, sharee_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl(&owner_file, sharee_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + owner_client + .acl( + &sharee_created_file, + sharee_principal.as_str(), + ["read", "write"], + ) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request_with_headers( + "MOVE", + &owner_file, + [("destination", sharee_file.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &sharee_created_file, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &owner_folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 12: Share and unshare a resource + owner_client + .acl(&owner_folder_private, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]); + sharee_client + .propfind_with_headers( + &owner_base_path, + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([owner_folder_private.as_str()]); + owner_client + .acl(&owner_folder_private, sharee_principal.as_str(), []) + .await + .with_status(StatusCode::OK); + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str()]); + + // Delete resources + owner_client + .request("DELETE", &owner_folder_private, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &sharee_folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &format!("{sharee_base_path}copied/"), "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + sharee_client.delete_default_containers().await; + owner_client.delete_default_containers().await; + test.assert_is_empty().await; +} + +const ACL_PRINCIPAL_QUERY: &str = r#" + + + + + "#; + +const CALENDAR_QUERY_ANY_VEVENT: &str = r#" + + + + + + + + "#; + +const ADDRESSBOOK_QUERY_ANY_FN: &str = r#" + + + + + + "#; diff --git a/tests/src/webdav/basic.rs b/tests/src/webdav/basic.rs new file mode 100644 index 0000000..0e3ba73 --- /dev/null +++ b/tests/src/webdav/basic.rs @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use dav_proto::Depth; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + println!("Running basic tests..."); + let john = test.account("john@example.com").webdav_client(); + let jane = test.account("jane@example.com").webdav_client(); + + // Test OPTIONS request + john.request("OPTIONS", "/dav/file", "") + .await + .with_header( + "dav", + concat!( + "1, 2, 3, access-control, extended-mkcol, calendar-access, ", + "calendar-auto-schedule, calendar-no-timezone, addressbook" + ), + ) + .with_header( + "allow", + concat!( + "OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, ", + "MKCALENDAR, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL" + ), + ); + + // Test Discovery + john.request("PROPFIND", "/.well-known/carddav", "") + .await + .with_values( + "D:multistatus.D:response.D:href", + ["/dav/card/", "/dav/card/john%40example.com/"], + ); + jane.request("PROPFIND", "/.well-known/caldav", "") + .await + .with_values( + "D:multistatus.D:response.D:href", + [ + "/dav/cal/", + "/dav/cal/jane%40example.com/", + "/dav/cal/support%40example.com/", + ], + ); + + // Test 404 responses + jane.sync_collection( + "/dav/cal/jane%40example.com/default/", + "", + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + jane.sync_collection( + "/dav/cal/jane%40example.com/test-404/", + "", + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + jane.request("PROPFIND", "/dav/cal/jane%40example.com/default/", "") + .await + .with_status(StatusCode::MULTI_STATUS); + jane.request( + "REPORT", + "/dav/cal/jane%40example.com/default/", + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ), + ) + .await + .with_status(StatusCode::MULTI_STATUS); + jane.request( + "REPORT", + "/dav/cal/jane%40example.com/test-404/", + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ), + ) + .await + .with_status(StatusCode::MULTI_STATUS); + jane.request("PROPFIND", "/dav/cal/jane%40example.com/test-404/", "") + .await + .with_status(StatusCode::NOT_FOUND); + + john.delete_default_containers().await; + jane.delete_default_containers().await; + jane.delete_default_containers_by_account("support@example.com") + .await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/cal_alarm.rs b/tests/src/webdav/cal_alarm.rs new file mode 100644 index 0000000..654200b --- /dev/null +++ b/tests/src/webdav/cal_alarm.rs @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use email::cache::MessageCacheFetch; +use hyper::StatusCode; +use mail_parser::{DateTime, MessageParser}; +use store::write::now; + +pub async fn test(test: &TestServer) { + println!("Running calendar e-mail alarms tests..."); + let account = test.account("john@example.com"); + let client = account.webdav_client(); + client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/its-alarming-how-charming-i-feel.ics", + [("content-type", "text/calendar; charset=utf-8")], + TEST_ALARM_1.replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 5) + .to_rfc3339() + .replace(['-', ':'], ""), + ), + ) + .await + .with_status(StatusCode::CREATED); + + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + + // Check that the alarm was sent + let messages = test + .server + .get_cached_messages(client.account_id) + .await + .unwrap(); + assert_eq!(messages.emails.items.len(), 2); + + for (idx, message) in messages.emails.items.iter().enumerate() { + let contents = test + .fetch_email(client.account_id, message.document_id) + .await; + + let message = MessageParser::new().parse(&contents).unwrap(); + let contents = message + .html_bodies() + .next() + .unwrap() + .text_contents() + .unwrap(); + + if idx == 0 { + // First alarm does not have a summary or description + assert!( + contents.contains("See the pretty girl in that mirror there"), + "failed for {contents}" + ); + assert!( + contents.contains("What mirror where?!"), + "failed for {contents}" + ); + } else { + assert!( + contents.contains("I feel pretty and witty and gay"), + "failed for {contents}" + ); + assert!( + contents.contains("It's alarming how charming I feel."), + "failed for {contents}" + ); + } + assert!( + contents.contains(concat!( + "/dav/cal/john%40example.com/default/", + "its-alarming-how-charming-i-feel.ics" + )), + "failed for {contents}" + ); + + // The logo is an inline part, so the template must reference it by cid: URI + let html = message.html_bodies().next().unwrap().contents().to_vec(); + let html = String::from_utf8(html).unwrap(); + assert!( + html.contains("src=\"cid:logo."), + "alarm logo must be referenced as a cid: URI: {html}" + ); + + // A conference URI is rendered as a hyperlink + assert!( + html.contains("href=\"https://meet.example.com/west-side\""), + "alarm must link the conference URI: {html}" + ); + + if let Some(out_dir) = super::template_out_dir() { + let path = out_dir.join(format!("alarm_template_{idx}.html")); + std::fs::write(&path, &html).expect("Failed to write alarm template to file"); + println!("Alarm template {idx} -> {}", path.display()); + } + } + + client.delete_default_containers().await; + test.destroy_all_mailboxes(account).await; + test.assert_is_empty().await +} + +const TEST_ALARM_1: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e +SUMMARY:See the pretty girl in that mirror there +DESCRIPTION:What mirror where?! +DTSTART:$START +DTEND;TZID=America/New_York:21250221T180000 +LOCATION:West Side +CONFERENCE;VALUE=URI;FEATURE=VIDEO:https://meet.example.com/west-side +BEGIN:VALARM +TRIGGER:-P2S +ACTION:EMAIL +ATTENDEE:mailto:john_doe@unknown.com +SUMMARY:I feel pretty and witty and gay +DESCRIPTION:I feel charming, Oh, so charming, It's alarming how charming I feel. +END:VALARM +BEGIN:VALARM +TRIGGER:-P4S +ACTION:EMAIL +END:VALARM +END:VEVENT +END:VCALENDAR +"#; diff --git a/tests/src/webdav/cal_itip.rs b/tests/src/webdav/cal_itip.rs new file mode 100644 index 0000000..b0671f5 --- /dev/null +++ b/tests/src/webdav/cal_itip.rs @@ -0,0 +1,451 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use calcard::{ + common::{IanaString, PartialDateTime}, + icalendar::{ICalendar, ICalendarProperty, ICalendarValue}, +}; +use groupware::scheduling::{ + ItipMessage, ItipSummary, + event_cancel::itip_cancel, + event_create::itip_create, + event_update::itip_update, + inbound::{MergeResult, itip_import_message, itip_merge_changes, itip_process_message}, + itip::itip_set_unreachable_status, + snapshot::itip_snapshot, +}; +use std::{collections::hash_map::Entry, path::PathBuf}; + +struct Test { + test_name: String, + command: Command, + line_num: usize, + parameters: Vec, + payload: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum Command { + Put, + Get, + Delete(bool), + Expect, + Send, + Reset, + Itip, +} + +pub fn test() { + for entry in std::fs::read_dir( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("itip"), + ) + .unwrap() + { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "txt") { + continue; + } + let file_name = path.file_name().unwrap().to_str().unwrap(); + let rules = std::fs::read_to_string(&path).unwrap(); + let mut last_comment = ""; + let mut last_command = ""; + let mut last_line_num = 0; + let mut payload = String::new(); + let mut commands = Vec::new(); + + for (line_num, line) in rules.lines().enumerate() { + if line.starts_with('#') { + last_comment = line.trim_start_matches('#').trim(); + } else if let Some(command) = line.strip_prefix("> ") { + last_command = command.trim(); + last_line_num = line_num; + } else if !line.is_empty() { + payload.push_str(line); + payload.push('\n'); + } else { + if last_command.is_empty() && payload.is_empty() { + continue; + } + let mut command_and_args = last_command.split_whitespace(); + let command = match command_and_args + .next() + .expect("Command should not be empty") + { + "put" => Command::Put, + "get" => Command::Get, + "expect" => Command::Expect, + "send" => Command::Send, + "delete" => Command::Delete(false), + "delete-force-send" => Command::Delete(true), + "reset" => Command::Reset, + "itip" => Command::Itip, + _ => panic!("Unknown command: {}", last_command), + }; + + commands.push(Test { + command, + test_name: last_comment.to_string(), + line_num: last_line_num, + parameters: command_and_args.map(String::from).collect(), + payload: payload.trim().to_string(), + }); + + last_command = ""; + last_line_num = 0; + payload.clear(); + } + } + + if commands.is_empty() { + panic!("No commands found in file: {}", file_name); + } else if !last_command.is_empty() { + panic!( + "File ended with command '{}' at line {} without payload", + last_command, last_line_num + ); + } + + println!("====== Running test: {} ======", file_name); + + let mut store: AHashMap> = AHashMap::new(); + let mut dtstamp_map: AHashMap = AHashMap::new(); + let mut last_itip = None; + + for command in &commands { + if command.command != Command::Put { + println!("{} (line {})", command.test_name, command.line_num); + } + match command.command { + Command::Put => { + let account = command + .parameters + .first() + .expect("Account parameter is required"); + let name = command + .parameters + .get(1) + .expect("Name parameter is required"); + let mut ical = ICalendar::parse(&command.payload) + .expect("Failed to parse iCalendar payload"); + match store + .entry(account.to_string()) + .or_default() + .entry(name.to_string()) + { + Entry::Occupied(mut entry) => { + last_itip = Some(itip_update( + &mut ical, + entry.get_mut(), + std::slice::from_ref(account), + )); + itip_set_unreachable_status(&mut ical, std::slice::from_ref(account)); + entry.insert(ical); + } + Entry::Vacant(entry) => { + last_itip = Some(itip_create(&mut ical, std::slice::from_ref(account))); + itip_set_unreachable_status(&mut ical, std::slice::from_ref(account)); + entry.insert(ical); + } + } + } + Command::Get => { + let account = command + .parameters + .first() + .expect("Account parameter is required") + .as_str(); + let name = command + .parameters + .get(1) + .expect("Name parameter is required") + .as_str(); + let ical = ICalendar::parse(&command.payload) + .expect("Failed to parse iCalendar payload") + .to_string() + .replace("\r\n", "\n"); + store + .get(account) + .and_then(|account_store| account_store.get(name)) + .map(|stored_ical| { + let stored_ical = normalize_ical(stored_ical.clone(), &mut dtstamp_map); + if stored_ical != ical { + panic!( + "ICalendar mismatch for {}: expected {}, got {}", + command.test_name, ical, stored_ical + ); + } + }) + .unwrap_or_else(|| { + panic!( + "ICalendar not found for account: {}, name: {}", + account, name + ); + }); + } + Command::Delete(force_send) => { + let account = command + .parameters + .first() + .expect("Account parameter is required") + .as_str(); + let name = command + .parameters + .get(1) + .expect("Name parameter is required") + .as_str(); + let store = store.get_mut(account).expect("Account not found in store"); + + if let Some(ical) = store.remove(name) { + last_itip = Some( + itip_cancel(&ical, &[account.to_string()], force_send) + .map(|message| vec![message]), + ); + } else { + panic!( + "ICalendar not found for account: {}, name: {}", + account, name + ); + } + } + Command::Expect => { + let last_itip_str = match last_itip + .as_ref() + .expect("No last iTIP message to compare against") + { + Ok(m) => { + let mut result = String::new(); + for (i, m) in m.iter().enumerate() { + if i > 0 { + result.push_str("================================\n"); + } + result.push_str(&m.to_string(&mut dtstamp_map)); + } + result + } + Err(e) => format!("{e:?}"), + }; + + assert_eq!( + command.payload.trim(), + last_itip_str.trim(), + "iTIP message mismatch for {} at line {}\nEXPECTED {}\n\nRECEIVED {}", + command.test_name, + command.line_num, + command.payload, + last_itip_str + ); + } + Command::Send => { + let mut results = String::new(); + match last_itip { + Some(Ok(messages)) => { + for message in messages { + for rcpt in &message.to { + let result = match itip_snapshot( + &message.message, + std::slice::from_ref(rcpt), + false, + ) { + Ok(itip_snapshots) => { + match store + .entry(rcpt.to_string()) + .or_default() + .entry(itip_snapshots.uid.to_string()) + { + Entry::Occupied(mut entry) => { + let ical = entry.get_mut(); + let snapshots = itip_snapshot( + ical, + std::slice::from_ref(rcpt), + false, + ) + .expect("Failed to create iTIP snapshot"); + + match itip_process_message( + ical, + snapshots, + &message.message, + itip_snapshots, + message.from.clone(), + ) { + Ok(result) => match result { + MergeResult::Actions(changes) => { + itip_merge_changes(ical, changes); + Ok(None) + } + MergeResult::Message(message) => { + Ok(Some(message)) + } + MergeResult::None => Ok(None), + }, + Err(err) => Err(err), + } + } + Entry::Vacant(entry) => { + let mut message = message.message.clone(); + itip_import_message(&mut message) + .expect("Failed to import iTIP message"); + entry.insert(message); + Ok(None) + } + } + } + Err(err) => Err(err), + }; + + match result { + Ok(Some(itip_message)) => { + results.push_str( + &itip_message.to_string(&mut dtstamp_map), + ); + } + Ok(None) => {} + Err(e) => { + results.push_str(&format!("{e:?}")); + } + } + } + } + + assert_eq!( + results.trim(), + command.payload.trim(), + "iTIP send result mismatch for {} at line {}: expected {}, got {}", + command.test_name, + command.line_num, + command.payload, + results + ); + } + Some(Err(e)) => { + panic!( + "Failed to create iTIP message for {} at line {}: {:?}", + command.test_name, command.line_num, e + ); + } + None => { + panic!( + "No iTIP message to send for {} at line {}", + command.test_name, command.line_num + ); + } + } + last_itip = None; + } + Command::Itip => { + let mut commands = command.parameters.iter(); + last_itip = Some(Ok(vec![ItipMessage { + from_organizer: false, + from: commands + .next() + .expect("From parameter is required") + .to_string(), + to: commands.map(|s| s.to_string()).collect::>(), + summary: ItipSummary::Invite(vec![]), + message: ICalendar::parse(&command.payload) + .expect("Failed to parse iCalendar payload"), + }])) + } + Command::Reset => { + store.clear(); + dtstamp_map.clear(); + last_itip = None; + } + } + } + } +} + +trait ItipMessageExt { + fn to_string(&self, map: &mut AHashMap) -> String; +} + +impl ItipMessageExt for ItipMessage { + fn to_string(&self, map: &mut AHashMap) -> String { + use std::fmt::Write; + let mut f = String::new(); + let mut to = self.to.iter().map(|t| t.as_str()).collect::>(); + to.sort_unstable(); + writeln!(&mut f, "from: {}", self.from).unwrap(); + writeln!(&mut f, "to: {}", to.join(", ")).unwrap(); + write!(&mut f, "summary: ").unwrap(); + let mut fields = Vec::new(); + match &self.summary { + ItipSummary::Invite(itip_fields) => { + writeln!(&mut f, "invite").unwrap(); + fields.push(itip_fields); + } + ItipSummary::Update { + method, + current, + previous, + } => { + writeln!(&mut f, "update {}", method.as_str()).unwrap(); + fields.push(current); + fields.push(previous); + } + ItipSummary::Cancel(itip_fields) => { + writeln!(&mut f, "cancel").unwrap(); + fields.push(itip_fields); + } + ItipSummary::Rsvp { part_stat, current } => { + writeln!(&mut f, "rsvp {}", part_stat.as_str()).unwrap(); + fields.push(current); + } + } + for (pos, fields) in fields.into_iter().enumerate() { + let prefix = if pos > 0 { "~summary." } else { "summary." }; + let mut fields = fields + .iter() + .map(|f| format!("{}: {:?}", f.name.as_str().to_lowercase(), f.value)) + .collect::>(); + fields.sort_unstable(); + for field in fields { + writeln!(&mut f, "{prefix}{}", field).unwrap(); + } + } + + write!(&mut f, "{}", normalize_ical(self.message.clone(), map)).unwrap(); + f + } +} + +fn normalize_ical(mut ical: ICalendar, map: &mut AHashMap) -> String { + let mut comps = ical + .components + .iter() + .enumerate() + .filter(|(comp_id, _)| { + ical.components[0] + .component_ids + .contains(&(*comp_id as u32)) + }) + .collect::>(); + comps.sort_unstable_by_key(|(_, comp)| *comp); + ical.components[0].component_ids = comps.iter().map(|(comp_id, _)| *comp_id as u32).collect(); + + for comp in &mut ical.components { + for entry in &mut comp.entries { + if let (ICalendarProperty::Dtstamp, Some(ICalendarValue::PartialDateTime(dt))) = + (&entry.name, entry.values.first()) + { + if let Some(index) = map.get(dt) { + entry.values = vec![ICalendarValue::Integer(*index as i64)]; + } else { + let index = map.len(); + map.insert(dt.as_ref().clone(), index); + entry.values = vec![ICalendarValue::Integer(index as i64)]; + } + } + } + comp.entries.sort_unstable(); + } + ical.to_string().replace("\r\n", "\n") +} diff --git a/tests/src/webdav/cal_query.rs b/tests/src/webdav/cal_query.rs new file mode 100644 index 0000000..8c34904 --- /dev/null +++ b/tests/src/webdav/cal_query.rs @@ -0,0 +1,1200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; + +use ahash::AHashSet; +use calcard::{common::timezone::Tz, icalendar::ICalendar}; +use groupware::{ + DavResourceName, + calendar::{CalendarEventData, alarm::ExpandAlarm, expand::CalendarEventExpansion}, +}; +use hyper::StatusCode; +use std::str::FromStr; +use store::write::serialize::rkyv_unarchive; +use types::TimeRange; + +pub async fn test(test: &TestServer) { + println!("Running REPORT calendar-query & free-busy-query tests..."); + let client = test.account("john@example.com").webdav_client(); + let cal_path = format!( + "{}/john%40example.com/default/", + DavResourceName::Cal.base_path() + ); + + #[allow(clippy::never_loop)] + for (num, ics) in [ + (1, ICAL_RFC_ABCD1_ICS), + (2, ICAL_RFC_ABCD2_ICS), + (3, ICAL_RFC_ABCD3_ICS), + (4, ICAL_RFC_ABCD4_ICS), + (5, ICAL_RFC_ABCD5_ICS), + (6, ICAL_RFC_ABCD6_ICS), + (7, ICAL_RFC_ABCD7_ICS), + (8, ICAL_RFC_ABCD8_ICS), + ] { + roundtrip_expansion(ics, false); + client + .request("PUT", &rfc_file_name(num), ics) + .await + .with_status(StatusCode::CREATED); + } + + // Test 1: Partial Retrieval of Events by Time Range + let response = client + .request("REPORT", &cal_path, REPORT_1) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_1_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_1_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 2: Partial Retrieval of Recurring Events + let response = client + .request("REPORT", &cal_path, REPORT_2) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_2_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_2_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 3: Expanded Retrieval of Recurring Events + let response = client + .request("REPORT", &cal_path, REPORT_3) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_3_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_3_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 4: Partial Retrieval of Stored Free Busy Components + let response = client + .request("REPORT", &cal_path, REPORT_4) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(8).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(8)) + .calendar_data() + .with_values([REPORT_4_EXPECTED_ABCD8.replace('\n', "\r\n").as_str()]); + + // Test 5: Retrieval of To-Dos by Alarm Time Range + let response = client + .request("REPORT", &cal_path, REPORT_5) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(5).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(5)) + .calendar_data() + .with_values([ICAL_RFC_ABCD5_ICS.replace('\n', "\r\n").as_str()]); + + // Test 6: Retrieval of Event by UID + client + .request("REPORT", &cal_path, REPORT_6) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(3).as_str()]) + .into_propfind_response(None); + + // Test 7: Retrieval of Events by PARTSTAT + client + .request("REPORT", &cal_path, REPORT_7) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(3).as_str()]) + .into_propfind_response(None); + + // Test 8: Retrieval of Events Only + client + .request("REPORT", &cal_path, REPORT_8) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([ + rfc_file_name(1).as_str(), + rfc_file_name(2).as_str(), + rfc_file_name(3).as_str(), + ]) + .into_propfind_response(None); + + // Test 9: Retrieval of All Pending To-Dos + client + .request("REPORT", &cal_path, REPORT_9) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(4).as_str(), rfc_file_name(5).as_str()]) + .into_propfind_response(None); + + // Test 10: Successful CALDAV:free-busy-query REPORT + assert_eq!( + remove_dtstamp( + client + .request("REPORT", &cal_path, REPORT_10) + .await + .with_status(StatusCode::OK) + .body + .as_ref() + .unwrap() + ), + remove_dtstamp(REPORT_10_RESPONSE) + ); + assert_eq!( + remove_dtstamp( + client + .request("REPORT", &cal_path, REPORT_11) + .await + .with_status(StatusCode::OK) + .body + .as_ref() + .unwrap() + ), + remove_dtstamp(REPORT_11_RESPONSE) + ); + + // Test 12: JMAP-style event stored with an entry-less VCALENDAR wrapper + client + .request("PUT", &rfc_file_name(9), ICAL_JMAP_NO_VERSION) + .await + .with_status(StatusCode::CREATED); + let response = client + .request("REPORT", &cal_path, REPORT_12) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(9).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(9)) + .calendar_data() + .is_not_empty(); + + // Test 13: Unbounded yearly recurrences are visible far beyond their first instance + client + .request("PUT", &rfc_file_name(10), ICAL_UNBOUNDED_YEARLY_ICS) + .await + .with_status(StatusCode::CREATED); + client + .request("REPORT", &cal_path, REPORT_13) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(10).as_str()]) + .into_propfind_response(None) + .properties(&rfc_file_name(10)) + .calendar_data() + .is_not_empty(); + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +#[test] +#[ignore] +fn ical_roundtrip_expansion() { + for entry in std::fs::read_dir("/Users/me/code/calcard/resources/ical").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "ics") { + println!("Testing: {:?}", path); + let input = match String::from_utf8(std::fs::read(&path).unwrap()) { + Ok(input) => input, + Err(err) => { + // ISO-8859-1 + err.as_bytes() + .iter() + .map(|&b| b as char) + .collect::() + } + }; + roundtrip_expansion(&input, true); + } + } +} + +fn roundtrip_expansion(ics: &str, ignore_errors: bool) { + let ical = if let Ok(ical) = ICalendar::parse(ics) { + ical + } else if ignore_errors { + return; + } else { + panic!("Failed to parse ICalendar {}", ics); + }; + let expanded = ical.expand_dates(Tz::UTC, 100); + if !ignore_errors { + assert!(expanded.errors.is_empty()); + } + let mut min_utc = i64::MAX; + let mut max_utc = i64::MIN; + let mut events = expanded + .events + .into_iter() + .map(|e| { + let e = e.try_into_date_time().unwrap(); + let start = e.start.timestamp(); + let end = e.end.timestamp(); + let mut min = std::cmp::min(start, end); + let mut max = std::cmp::max(start, end); + + for alarm in ical.alarms_for_id(e.comp_id) { + if let Some(alarm_time) = alarm + .expand_alarm(0, 0) + .and_then(|alarm| alarm.delta.to_timestamp(start, end, Tz::UTC)) + { + if alarm_time < min { + min = alarm_time; + } + + if alarm_time > max { + max = alarm_time; + } + } + } + + if min < min_utc { + min_utc = min; + } + if max > max_utc { + max_utc = max; + } + CalendarEventExpansion { + comp_id: e.comp_id, + own_recurrence_id: None, + start, + end, + start_naive: 0, + } + }) + .collect::>(); + + // Verify min/max UTC timestamps + let event_data = CalendarEventData::new(ical, Tz::UTC, 100, &mut None); + let from_time = event_data.base_time_utc as i64 + event_data.base_offset; + let to_time = from_time + event_data.duration as i64; + + if min_utc != i64::MAX { + assert_eq!( + from_time, + min_utc, + "diff: {}, failed for {}", + from_time - min_utc, + ics + ); + assert_eq!( + to_time, + max_utc, + "diff: {}, failed for {}", + to_time - max_utc, + ics + ); + } + + // Validate archive expansion + let expanded_bytes = rkyv::to_bytes::(&event_data).unwrap(); + let expanded_archive = rkyv_unarchive::(&expanded_bytes).unwrap(); + let mut events_archive = expanded_archive + .expand( + Tz::UTC, + TimeRange { + start: i64::MIN, + end: i64::MAX, + }, + ) + .unwrap(); + + assert_eq!( + events_archive, + event_data + .expand_from_ids( + &mut events_archive + .iter() + .filter_map(|e| e.recurrence_key()) + .collect::>(), + Tz::UTC + ) + .unwrap() + ); + events.sort_by(|a, b| { + if a.comp_id == b.comp_id { + a.start.cmp(&b.start) + } else { + a.comp_id.cmp(&b.comp_id) + } + }); + events_archive.sort_by(|a, b| { + if a.comp_id == b.comp_id { + a.start.cmp(&b.start) + } else { + a.comp_id.cmp(&b.comp_id) + } + }); + for event in events.iter_mut().chain(events_archive.iter_mut()) { + event.own_recurrence_id = None; + event.start_naive = 0; + } + + assert_eq!(events, events_archive); +} + +#[test] +fn calendar_expand_dst_fallback() { + let akl = Tz::from_str("Pacific/Auckland").unwrap(); + let ical = ICalendar::parse(ICAL_DST_FALLBACK_ICS).unwrap(); + let event_data = CalendarEventData::new(ical, akl, 1000, &mut None); + let expanded_bytes = rkyv::to_bytes::(&event_data).unwrap(); + let archive = rkyv_unarchive::(&expanded_bytes).unwrap(); + + let window = archive + .expand( + akl, + TimeRange { + start: 1782777600, + end: 1786579200, + }, + ) + .unwrap(); + assert!( + !window.is_empty(), + "recurrence expansion aborted across the Pacific/Auckland DST fall-back overlap" + ); + + let across_overlap = archive + .expand( + akl, + TimeRange { + start: 1775260800, + end: 1775433600, + }, + ) + .unwrap(); + assert_eq!( + across_overlap.len(), + 1, + "the ambiguous fall-back occurrence must resolve to its earliest instant" + ); +} + +#[test] +fn calendar_expand_beyond_indexable_span() { + let ical = ICalendar::parse(ICAL_UNBOUNDED_YEARLY_ICS).unwrap(); + let event_data = CalendarEventData::new(ical, Tz::UTC, 3000, &mut None); + let expanded_bytes = rkyv::to_bytes::(&event_data).unwrap(); + let archive = rkyv_unarchive::(&expanded_bytes).unwrap(); + + let instances = archive + .expand( + Tz::UTC, + TimeRange { + start: i64::MIN, + end: i64::MAX, + }, + ) + .unwrap(); + + assert_eq!(instances.first().unwrap().start, FIRST_YEARLY_INSTANCE); + assert_eq!(instances.last().unwrap().start, LAST_YEARLY_INSTANCE); + assert_eq!(instances.len(), 137); + assert!( + instances.windows(2).all(|w| w[0].start < w[1].start), + "instance offsets wrapped around u32" + ); + + assert_eq!(event_data.event_range_start(), FIRST_YEARLY_INSTANCE); + assert!( + event_data.event_range_end() >= instances.last().unwrap().end, + "indexed range ends at {}, before its last instance at {}", + event_data.event_range_end(), + instances.last().unwrap().end + ); + + let in_2027 = archive + .expand( + Tz::UTC, + TimeRange { + start: JUNE_2027_START, + end: JUNE_2027_END, + }, + ) + .unwrap(); + assert_eq!(in_2027.len(), 1); + assert_eq!(in_2027[0].start, JUNE_2027_INSTANCE); + assert!( + event_data.event_range_start() < JUNE_2027_END + && event_data.event_range_end() > JUNE_2027_START, + "indexed range {}..{} does not overlap June 2027", + event_data.event_range_start(), + event_data.event_range_end() + ); +} + +const FIRST_YEARLY_INSTANCE: i64 = 1527843600; +const LAST_YEARLY_INSTANCE: i64 = 5819590800; +const JUNE_2027_START: i64 = 1811808000; +const JUNE_2027_END: i64 = 1814400000; +const JUNE_2027_INSTANCE: i64 = 1811840400; + +fn rfc_file_name(num: usize) -> String { + format!( + "{}/john%40example.com/default/abcd{num}.ics", + DavResourceName::Cal.base_path() + ) +} + +const REPORT_1: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +const REPORT_1_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060106T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060106T120000 +SUMMARY:Event #2 bis bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_1_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_2: &str = r#" + + + + + + + + + + + + + + +"#; + +const REPORT_2_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_2_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_3: &str = r#" + + + + + + + + + + + + + + + + + +"#; + +const REPORT_3_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTART:20060103T170000Z +RECURRENCE-ID:20060103T170000Z +DTSTAMP:20060206T001121Z +DURATION:PT1H +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART:20060104T190000Z +RECURRENCE-ID:20060104T190000Z +DTSTAMP:20060206T001121Z +DURATION:PT1H +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_3_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTART:20060104T150000Z +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_4: &str = r#" + + + + + + + + + + + + + + +"#; + +const REPORT_4_EXPECTED_ABCD8: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VFREEBUSY +ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com +UID:76ef34-54a3d2@example.com +DTSTAMP:20050530T123421Z +DTSTART:20060101T000000Z +DTEND:20060108T000000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const REPORT_5: &str = r#" + + + + + + + + + + + + + + + +"#; + +const REPORT_6: &str = r#" + + + + + + + + + + DC6C50A017428C5216A2F1CD@example.com + + + + + +"#; + +const REPORT_7: &str = r#" + + + + + + + + + + mailto:lisa@example.com + + NEEDS-ACTION + + + + + + +"#; + +const REPORT_8: &str = r#" + + + + + + + + + + + +"#; + +const REPORT_9: &str = r#" + + + + + + + + + + + + + CANCELLED + + + + + +"#; + +const REPORT_12: &str = r#" + + + + + + + + + + JMAP-NO-VERSION-EVENT@example.com + + + + + +"#; + +const ICAL_JMAP_NO_VERSION: &str = r#"BEGIN:VCALENDAR +BEGIN:VEVENT +UID:JMAP-NO-VERSION-EVENT@example.com +SUMMARY:JMAP created event +DTSTART:20060107T120000Z +DTEND:20060107T130000Z +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_10: &str = r#" + + + +"#; + +const REPORT_10_RESPONSE: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +BEGIN:VFREEBUSY +DTSTART:20060104T140000Z +DTEND:20060105T220000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060104T150000Z/20060104T160000Z +FREEBUSY;FBTYPE=BUSY:20060104T190000Z/20060104T200000Z,20060105T170000Z/200 + 60105T180000Z +FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:20060105T100000Z/20060105T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const REPORT_11: &str = r#" + + + +"#; + +const REPORT_11_RESPONSE: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +BEGIN:VFREEBUSY +DTSTART:20060101T000000Z +DTEND:20060104T140000Z +DTSTAMP:20250505T105255Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +FREEBUSY;FBTYPE=BUSY:20060102T150000Z/20060102T160000Z,20060102T170000Z/200 + 60102T180000Z,20060103T100000Z/20060103T120000Z,20060103T170000Z/20060103T + 180000Z,20060104T100000Z/20060104T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const REPORT_13: &str = r#" + + + + + + + + + + + + + +"#; + +const ICAL_UNBOUNDED_YEARLY_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTAMP:20180601T000000Z +DTSTART:20180601T090000Z +DTEND:20180601T100000Z +RRULE:FREQ=YEARLY +SUMMARY:Unbounded yearly event +UID:yearly-unbounded@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_DST_FALLBACK_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTAMP:20260108T000000Z +DTSTART;TZID=Pacific/Auckland:20260108T022000 +DURATION:PT2H10M +RRULE:FREQ=DAILY;INTERVAL=3 +SUMMARY:Auckland DST fall-back recurrence +UID:auckland-dst-fallback@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD1_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001102Z +DTSTART;TZID=US/Eastern:20060102T100000 +DURATION:PT1H +SUMMARY:Event #1 +Description:Go Steelers! +UID:74855313FA803DA593CD579A@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD2_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060106T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060106T120000 +SUMMARY:Event #2 bis bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD3_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD4_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235335Z +DUE;VALUE=DATE:20060104 +STATUS:NEEDS-ACTION +SUMMARY:Task #1 +UID:DDDEEB7915FA61233B861457@example.com +BEGIN:VALARM +ACTION:AUDIO +TRIGGER;RELATED=START:-PT10M +END:VALARM +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD5_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235300Z +DUE;TZID=US/Eastern:20060106T120000 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:NEEDS-ACTION +SUMMARY:Task #2 +UID:E10BA47467C5C69BB74E8720@example.com +BEGIN:VALARM +ACTION:AUDIO +TRIGGER;RELATED=START:-PT10M +END:VALARM +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD6_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +COMPLETED:20051223T122322Z +DTSTAMP:20060205T235400Z +DUE;VALUE=DATE:20051225 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:COMPLETED +SUMMARY:Task #3 +UID:E10BA47467C5C69BB74E8722@example.com +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD7_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235600Z +DUE;VALUE=DATE:20060101 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:CANCELLED +SUMMARY:Task #4 +UID:E10BA47467C5C69BB74E8725@example.com +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD8_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VFREEBUSY +ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com +UID:76ef34-54a3d2@example.com +DTSTAMP:20050530T123421Z +DTSTART:20060101T000000Z +DTEND:20060108T000000Z +FREEBUSY:20050531T230000Z/20050601T010000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +FREEBUSY:20060103T100000Z/20060103T120000Z +FREEBUSY:20060104T100000Z/20060104T120000Z +FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:20060105T100000Z/20060105T120000Z +FREEBUSY:20060106T100000Z/20060106T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +fn remove_dtstamp(ics: &str) -> AHashSet { + let mut result = AHashSet::new(); + for line in ics.lines() { + if !line.starts_with("DTSTAMP:") { + result.insert(line.to_string()); + } + } + result +} diff --git a/tests/src/webdav/cal_scheduling.rs b/tests/src/webdav/cal_scheduling.rs new file mode 100644 index 0000000..a05ec66 --- /dev/null +++ b/tests/src/webdav/cal_scheduling.rs @@ -0,0 +1,1400 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + utils::{server::TestServer, webdav::DummyWebDavClient}, + webdav::prop::ALL_DAV_PROPERTIES, +}; +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendarDay, ICalendarFrequency, ICalendarMethod, ICalendarParticipationStatus, + ICalendarProperty, ICalendarRecurrenceRule, ICalendarWeekday, + }, +}; +use dav_proto::schema::property::{CalDavProperty, DavProperty, WebDavProperty}; +use email::cache::MessageCacheFetch; +use groupware::{ + cache::GroupwareCache, + calendar::{CalendarEvent, EVENT_HIDE_ATTENDEES, itip::ItipIngest}, + scheduling::{ItipField, ItipParticipant, ItipSummary, ItipTime, ItipValue}, +}; +use hyper::StatusCode; +use mail_parser::{DateTime, MessageParser}; +use serde_json::{Value, json}; +use services::task_manager::imip::build_itip_template; +use std::str::FromStr; +use store::{ + ValueKey, + write::{AlignedBytes, Archive, BatchBuilder, now}, +}; +use types::collection::{Collection, SyncCollection}; + +async fn set_hide_attendees(test: &TestServer, account_id: u32, document_id: u32) { + let archive = test + .server + .store() + .get_value::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + document_id, + )) + .await + .unwrap() + .expect("Missing event"); + let previous = archive.to_unarchived::().unwrap(); + let mut event = previous.deserialize::().unwrap(); + event.flags |= EVENT_HIDE_ATTENDEES; + + let account_info = test.server.account_info(account_id).await.unwrap(); + let mut batch = BatchBuilder::new(); + event + .update( + account_info.account_tenant_ids(), + previous, + account_id, + document_id, + &mut batch, + ) + .unwrap(); + test.server.commit_batch(batch).await.unwrap(); +} + +async fn rsvp_request(client: &DummyWebDavClient, body: &Value) -> Value { + let response = client + .request_with_headers( + "POST", + "/api/calendar/rsvp", + [("content-type", "application/json")], + body.to_string(), + ) + .await + .with_status(StatusCode::OK) + .body + .unwrap(); + + serde_json::from_str(&response).expect("Invalid JSON in RSVP response") +} + +fn unfold(ical: &str) -> String { + ical.replace("\r\n ", "") +} + +pub async fn test(test: &TestServer) { + println!("Running calendar scheduling tests..."); + let bill = test.account("bill@example.com"); + let jane = test.account("jane@example.com"); + let john = test.account("john@example.com"); + let bill_client = bill.webdav_client(); + let jane_client = jane.webdav_client(); + let john_client = john.webdav_client(); + + // Validate hierarchy of scheduling resources + let response = jane_client + .propfind_with_headers( + "/dav/itip/jane%40example.com/", + ALL_DAV_PROPERTIES, + [("depth", "1")], + ) + .await; + let properties = response + .with_hrefs([ + "/dav/itip/jane%40example.com/", + "/dav/itip/jane%40example.com/inbox/", + "/dav/itip/jane%40example.com/outbox/", + ]) + .properties("/dav/itip/jane%40example.com/inbox/"); + + // Validate schedule inbox properties + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection", "A:schedule-inbox"]); + properties + .get(DavProperty::CalDav( + CalDavProperty::ScheduleDefaultCalendarURL, + )) + .with_values(["D:href:/dav/cal/jane%40example.com/default/"]) + .with_status(StatusCode::OK); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .with_some_values([ + "D:supported-privilege.D:privilege.D:all", + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.D:read" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.A:schedule-deliver" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-deliver-invite" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-deliver-reply" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-query-freebusy" + ), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:write-properties", + "D:privilege.A:schedule-deliver-invite", + "D:privilege.D:write-content", + "D:privilege.A:schedule-deliver", + "D:privilege.D:read", + "D:privilege.D:all", + "D:privilege.A:schedule-query-freebusy", + "D:privilege.D:read-acl", + "D:privilege.D:write-acl", + "D:privilege.A:schedule-deliver-reply", + "D:privilege.D:write", + "D:privilege.D:read-current-user-privilege-set", + ]); + + // Validate schedule outbox properties + let properties = response.properties("/dav/itip/jane%40example.com/outbox/"); + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection", "A:schedule-outbox"]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .with_some_values([ + "D:supported-privilege.D:privilege.D:all", + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.D:read" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:privilege.A:schedule-send" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-invite" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-reply" + ), + concat!( + "D:supported-privilege.D:supported-privilege.", + "D:supported-privilege.D:privilege.A:schedule-send-freebusy" + ), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:write-properties", + "D:privilege.A:schedule-send-invite", + "D:privilege.D:write-content", + "D:privilege.A:schedule-send", + "D:privilege.D:read", + "D:privilege.D:all", + "D:privilege.A:schedule-send-freebusy", + "D:privilege.D:read-acl", + "D:privilege.D:write-acl", + "D:privilege.A:schedule-send-reply", + "D:privilege.D:write", + "D:privilege.D:read-current-user-privilege-set", + ]); + + // Send invitation to Bill and Mike + let test_itip = TEST_ITIP + .replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 5 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ); + john_client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/itip.ics", + [("content-type", "text/calendar; charset=utf-8")], + &test_itip, + ) + .await + .with_status(StatusCode::CREATED); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Check that the invitation was received by Bill and Mike + for client in [&bill_client, &jane_client] { + let messages = test + .server + .get_cached_messages(client.account_id) + .await + .unwrap(); + assert_eq!(messages.emails.items.len(), 1); + let events = test + .server + .fetch_dav_resources( + client.account_id, + client.account_id, + SyncCollection::Calendar, + ) + .await + .unwrap(); + assert_eq!(events.resources.len(), 2); + let events = test + .server + .fetch_dav_resources( + client.account_id, + client.account_id, + SyncCollection::CalendarEventNotification, + ) + .await + .unwrap(); + assert_eq!(events.resources.len(), 3); + } + + // Validate iTIP + let itips = jane_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + let itip = itips.first().unwrap(); + assert!( + unfold(itip).contains("SUMMARY:Lunch") && unfold(itip).contains("METHOD:REQUEST"), + "failed for itip: {itip}" + ); + + // Fetch added calendar entry + let cals = jane_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + + // Using an invalid schedule tag should fail + let rsvp_ical = cal.ical.replace( + "PARTSTAT=NEEDS-ACTION:mailto:jane.smith", + "PARTSTAT=ACCEPTED:mailto:jane.smith", + ); + jane_client + .request_with_headers( + "PUT", + &cal.href, + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-schedule-tag-match", "\"9999999\""), + ], + &rsvp_ical, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + // RSVP the invitation + jane_client + .request_with_headers( + "PUT", + &cal.href, + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-schedule-tag-match", cal.schedule_tag.as_str()), + ], + &rsvp_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Make sure that the schedule has not changed + assert_eq!( + jane_client.fetch_icals().await[0].schedule_tag, + cal.schedule_tag + ); + + // Check that John received the RSVP + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + test.wait_for_tasks().await; + let itips = john_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + assert!( + unfold(&itips[0]).contains("METHOD:REPLY") + && unfold(&itips[0]).contains("PARTSTAT=ACCEPTED:mailto:jane.smith"), + "failed for itip: {}", + itips[0] + ); + let cals = john_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + assert!( + unfold(&cals[0].ical).contains("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:jane"), + "failed for cal: {}", + cals[0].ical + ); + + // Changing the event name should not trigger a new iTIP + let updated_ical = rsvp_ical.replace("Lunch", "Dinner"); + jane_client + .request_with_headers( + "PUT", + &cal.href, + [("content-type", "text/calendar; charset=utf-8")], + &updated_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + john_client.fetch_and_remove_itips().await, + Vec::::new() + ); + + // Deleting the event should send a cancellation + jane_client + .request("DELETE", &cal.href, "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = john_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + assert!( + unfold(&itips[0]).contains("METHOD:REPLY") + && unfold(&itips[0]).contains("PARTSTAT=DECLINED:mailto:jane.smith"), + "failed for itip: {}", + itips[0] + ); + let cals = john_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + unfold(&cal.ical).contains("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:jane"), + "failed for cal: {}", + cal.ical + ); + + // Fetch Bill's email invitation and RSVP via HTTP + let document_id = test + .server + .get_cached_messages(bill_client.account_id) + .await + .unwrap() + .emails + .items[0] + .document_id; + let contents = test.fetch_email(bill_client.account_id, document_id).await; + let message = MessageParser::new().parse(&contents).unwrap(); + let contents = message + .html_bodies() + .next() + .unwrap() + .text_contents() + .unwrap(); + let url = contents + .split("href=\"") + .filter_map(|s| { + let url = s.split_once('\"').map(|(url, _)| url)?; + if url.contains("m=ACCEPTED") { + Some(url.strip_prefix("https://webdav.example.org").unwrap()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| { + panic!("Failed to find RSVP link in email contents: {contents}"); + }); + let bill_token = reqwest::Url::parse(&format!("https://webdav.example.org{url}")) + .expect("Invalid RSVP URL") + .query_pairs() + .find(|(key, _)| key == "i") + .map(|(_, token)| token.into_owned()) + .expect("Missing RSVP token"); + let details = rsvp_request(&jane_client, &json!({ "token": bill_token })).await; + assert_eq!(details["type"], "invitation", "failed for {details}"); + assert_eq!(details["summary"], "Lunch", "failed for {details}"); + let recorded = rsvp_request( + &jane_client, + &json!({ "token": bill_token, "partstat": "accepted", "comment": "Bringing dessert" }), + ) + .await; + assert_eq!(recorded["type"], "recorded", "failed for {recorded}"); + + // The attendee's own copy reflects the response + let cals = bill_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + unfold(&cal.ical).contains("PARTSTAT=ACCEPTED:mailto:bill"), + "failed for cal: {}", + cal.ical + ); + + // The reply lands in the organizer's scheduling inbox, carrying the attendee's comment + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = john_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + assert!( + unfold(&itips[0]).contains("METHOD:REPLY") + && unfold(&itips[0]).contains("PARTSTAT=ACCEPTED:mailto:bill") + && unfold(&itips[0]).contains("COMMENT:Bringing dessert") + && unfold(&itips[0]).contains("REQUEST-STATUS:2.0;Success"), + "failed for itip: {}", + itips[0] + ); + let cals = john_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + unfold(&cal.ical).contains("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:bill"), + "failed for cal: {}", + cal.ical + ); + + // RSVP on behalf of an attendee that has no local account + let test_itip_external = TEST_ITIP_EXTERNAL + .replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 5 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ); + john_client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/external.ics", + [("content-type", "text/calendar; charset=utf-8")], + &test_itip_external, + ) + .await + .with_status(StatusCode::CREATED); + let external_document_id = test + .server + .fetch_dav_resources( + john_client.account_id, + john_client.account_id, + SyncCollection::Calendar, + ) + .await + .unwrap() + .by_path("default/external.ics") + .unwrap() + .document_id(); + let url = test + .server + .http_rsvp_url( + john_client.account_id, + "john@example.com", + external_document_id, + "carol@remote.org", + ) + .await + .unwrap() + .url(&ICalendarParticipationStatus::Accepted); + let rsvp_token = reqwest::Url::parse(&url) + .expect("Invalid RSVP URL") + .query_pairs() + .find(|(key, _)| key == "i") + .map(|(_, token)| token.into_owned()) + .expect("Missing RSVP token"); + + // The RSVP page is static and must never record a response by itself + let page = john_client + .request("GET", "/calendar/rsvp", "") + .await + .with_status(StatusCode::OK) + .body + .unwrap(); + assert!( + page.contains("/api/calendar/rsvp"), + "failed for page: {page}" + ); + + // Fetching the invitation details must not change the participation status + let details = rsvp_request(&john_client, &json!({ "token": rsvp_token })).await; + assert_eq!(details["type"], "invitation", "failed for {details}"); + assert_eq!(details["summary"], "Brunch", "failed for {details}"); + assert_eq!(details["partstat"], "NEEDS-ACTION", "failed for {details}"); + assert_eq!( + details["attendee"]["email"], "carol@remote.org", + "failed for {details}" + ); + + // An unknown token is rejected without leaking whether the event exists + let invalid = rsvp_request(&john_client, &json!({ "token": "not-a-token" })).await; + assert_eq!(invalid["type"], "error", "failed for {invalid}"); + assert_eq!(invalid["reason"], "invalidLink", "failed for {invalid}"); + + // An unparseable participation status is an error, not a silent details response + let bad_status = rsvp_request( + &john_client, + &json!({ "token": rsvp_token, "partstat": "accpeted" }), + ) + .await; + assert_eq!(bad_status["type"], "error", "failed for {bad_status}"); + assert_eq!( + bad_status["reason"], "invalidPartStat", + "failed for {bad_status}" + ); + + // Record the response + let recorded = rsvp_request( + &john_client, + &json!({ "token": rsvp_token, "partstat": "accepted", "comment": "See you there" }), + ) + .await; + assert_eq!(recorded["type"], "recorded", "failed for {recorded}"); + assert_eq!(recorded["partstat"], "ACCEPTED", "failed for {recorded}"); + let external_cal = john_client + .fetch_icals() + .await + .into_iter() + .find(|cal| cal.href.ends_with("external.ics")) + .expect("Missing external event"); + assert!( + unfold(&external_cal.ical) + .contains("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:mailto:carol@remote.org"), + "failed for cal: {}", + external_cal.ical + ); + + // The reply from an external attendee also reaches the organizer's scheduling inbox + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = john_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + assert!( + unfold(&itips[0]).contains("METHOD:REPLY") + && unfold(&itips[0]).contains("PARTSTAT=ACCEPTED:mailto:carol@remote.org") + && unfold(&itips[0]).contains("COMMENT:See you there") + && unfold(&itips[0]).contains("REQUEST-STATUS:2.0;Success"), + "failed for itip: {}", + itips[0] + ); + + // Re-sending the same response is a no-op that produces no further reply + let repeated = rsvp_request( + &john_client, + &json!({ "token": rsvp_token, "partstat": "accepted" }), + ) + .await; + assert_eq!(repeated["type"], "recorded", "failed for {repeated}"); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + john_client.fetch_and_remove_itips().await, + Vec::::new() + ); + john_client + .request("DELETE", &external_cal.href, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // A recurring event is answered as a whole: the master and every overridden + // instance the attendee appears in must all be updated by a single RSVP + let ts = now() as i64; + let stamp = |offset: i64| { + DateTime::from_timestamp(ts + offset) + .to_rfc3339() + .replace(['-', ':'], "") + }; + let test_itip_recurring = TEST_ITIP_RECURRING + .replace("$START", &stamp(60 * 60)) + .replace("$END", &stamp(2 * 60 * 60)) + .replace("$SECOND_END", &stamp(7 * 24 * 60 * 60 + 3 * 60 * 60)) + .replace("$SECOND", &stamp(7 * 24 * 60 * 60 + 60 * 60)); + john_client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/recurring.ics", + [("content-type", "text/calendar; charset=utf-8")], + &test_itip_recurring, + ) + .await + .with_status(StatusCode::CREATED); + let recurring_document_id = test + .server + .fetch_dav_resources( + john_client.account_id, + john_client.account_id, + SyncCollection::Calendar, + ) + .await + .unwrap() + .by_path("default/recurring.ics") + .unwrap() + .document_id(); + let recurring_url = test + .server + .http_rsvp_url( + john_client.account_id, + "john@example.com", + recurring_document_id, + "carol@remote.org", + ) + .await + .unwrap() + .url(&ICalendarParticipationStatus::Accepted); + let recurring_token = reqwest::Url::parse(&recurring_url) + .expect("Invalid RSVP URL") + .query_pairs() + .find(|(key, _)| key == "i") + .map(|(_, token)| token.into_owned()) + .expect("Missing RSVP token"); + + let recorded = rsvp_request( + &john_client, + &json!({ "token": recurring_token, "partstat": "declined" }), + ) + .await; + assert_eq!(recorded["type"], "recorded", "failed for {recorded}"); + + let recurring_cal = john_client + .fetch_icals() + .await + .into_iter() + .find(|cal| cal.href.ends_with("recurring.ics")) + .expect("Missing recurring event"); + let recurring_ical = unfold(&recurring_cal.ical); + assert_eq!( + recurring_ical + .matches("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:carol@remote.org") + .count(), + 2, + "both the master and the override must be updated: {recurring_ical}" + ); + + // Repeating the same answer is still reported as recorded, but sends no second reply + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + john_client.fetch_and_remove_itips().await; + let repeated = rsvp_request( + &john_client, + &json!({ "token": recurring_token, "partstat": "declined" }), + ) + .await; + assert_eq!(repeated["type"], "recorded", "failed for {repeated}"); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + john_client.fetch_and_remove_itips().await, + Vec::::new(), + "a repeated RSVP must not produce another reply" + ); + john_client + .request("DELETE", &recurring_cal.href, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // hideAttendees restricts the RSVP response to the organizer and the requester + let test_itip_hidden = TEST_ITIP_HIDDEN + .replace("$START", &stamp(60 * 60)) + .replace("$END", &stamp(2 * 60 * 60)); + john_client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/hidden.ics", + [("content-type", "text/calendar; charset=utf-8")], + &test_itip_hidden, + ) + .await + .with_status(StatusCode::CREATED); + let hidden_document_id = test + .server + .fetch_dav_resources( + john_client.account_id, + john_client.account_id, + SyncCollection::Calendar, + ) + .await + .unwrap() + .by_path("default/hidden.ics") + .unwrap() + .document_id(); + let hidden_url = test + .server + .http_rsvp_url( + john_client.account_id, + "john@example.com", + hidden_document_id, + "carol@remote.org", + ) + .await + .unwrap() + .url(&ICalendarParticipationStatus::Accepted); + let hidden_token = reqwest::Url::parse(&hidden_url) + .expect("Invalid RSVP URL") + .query_pairs() + .find(|(key, _)| key == "i") + .map(|(_, token)| token.into_owned()) + .expect("Missing RSVP token"); + + // With the flag clear every participant is listed + let details = rsvp_request(&john_client, &json!({ "token": hidden_token })).await; + let emails = |details: &Value| { + details["attendees"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["email"].as_str().unwrap().to_string()) + .collect::>() + }; + assert_eq!( + emails(&details), + vec![ + "jdoe@example.com".to_string(), + "carol@remote.org".to_string(), + "dave@remote.org".to_string() + ], + "failed for {details}" + ); + + // With it set, the other attendee is withheld + set_hide_attendees(test, john_client.account_id, hidden_document_id).await; + let details = rsvp_request(&john_client, &json!({ "token": hidden_token })).await; + assert_eq!( + emails(&details), + vec![ + "jdoe@example.com".to_string(), + "carol@remote.org".to_string() + ], + "dave must not be disclosed when hideAttendees is set: {details}" + ); + john_client + .request( + "DELETE", + "/dav/cal/john%40example.com/default/hidden.ics", + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test the schedule outbox + let test_outbox = TEST_FREEBUSY + .replace( + "$START", + &DateTime::from_timestamp(now() as i64) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 100 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ); + let response = john_client + .request_with_headers( + "POST", + "/dav/itip/john%40example.com/outbox/", + [("content-type", "text/calendar; charset=utf-8")], + &test_outbox, + ) + .await + .with_status(StatusCode::OK); + let mut account = ""; + let mut found_data = false; + for (key, value) in &response.xml { + match key.as_str() { + "A:schedule-response.A:response.A:recipient.D:href" => { + account = value.strip_prefix("mailto:").unwrap(); + } + "A:schedule-response.A:response.A:request-status" => { + if account == "unknown@example.com" { + assert_eq!( + value, + "3.7;Invalid calendar user or insufficient permissions" + ); + } else { + assert_eq!(value, "2.0;Success"); + } + } + "A:schedule-response.A:response.A:calendar-data" => { + assert!( + unfold(value).contains("BEGIN:VFREEBUSY"), + "missing freebusy data in response: {response:?}" + ); + if account == "jdoe@example.com" { + assert!( + unfold(value).contains("FREEBUSY;FBTYPE=BUSY:"), + "missing freebusy data in response: {response:?}" + ); + found_data = true; + } + } + _ => {} + } + } + assert!( + found_data, + "Missing calendar data in response: {response:?}" + ); + + // Modifying john's event should only send updates to bill + let updated_ical = cal.ical.replace("Lunch", "Breakfast at Tiffany's"); + john_client + .request_with_headers( + "PUT", + &cal.href, + [("content-type", "text/calendar; charset=utf-8")], + &updated_ical, + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Make sure that the schedule has changed + assert_ne!( + john_client.fetch_icals().await[0].schedule_tag, + cal.schedule_tag + ); + let main_event_href = cal.href; + + // Check that Bill received the update + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + test.wait_for_tasks().await; + let mut itips = bill_client.fetch_and_remove_itips().await; + itips.sort_unstable_by(|a, _| { + if unfold(a).contains("Lunch") { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + assert_eq!(itips.len(), 2); + assert!( + unfold(&itips[0]).contains("METHOD:REQUEST") && unfold(&itips[0]).contains("Lunch"), + "failed for itip: {}", + itips[0] + ); + assert!( + unfold(&itips[1]).contains("METHOD:REQUEST") + && unfold(&itips[1]).contains("Breakfast at Tiffany's"), + "failed for itip: {}", + itips[1] + ); + let cals = bill_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + unfold(&cal.ical).contains("SUMMARY:Breakfast at Tiffany's") + && unfold(&cal.ical).contains("PARTSTAT=ACCEPTED:mailto:bill"), + "failed for cal: {}", + cal.ical + ); + let attendee_href = cal.href; + assert_eq!( + jane_client.fetch_and_remove_itips().await, + Vec::::new() + ); + + // Removing the event should from John's calendar send a cancellation to Bill + john_client + .request("DELETE", &main_event_href, "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let itips = bill_client.fetch_and_remove_itips().await; + assert_eq!(itips.len(), 1); + assert!( + unfold(&itips[0]).contains("METHOD:CANCEL") + && unfold(&itips[0]).contains("STATUS:CANCELLED"), + "failed for itip: {}", + itips[0] + ); + let cals = bill_client.fetch_icals().await; + assert_eq!(cals.len(), 1); + let cal = cals.into_iter().next().unwrap(); + assert!( + unfold(&cal.ical).contains("STATUS:CANCELLED"), + "failed for cal: {}", + cal.ical + ); + assert_eq!( + jane_client.fetch_and_remove_itips().await, + Vec::::new() + ); + + // Delete the event from Bill's calendar disabling schedule replies + bill_client + .request_with_headers("DELETE", &attendee_href, [("Schedule-Reply", "F")], "") + .await + .with_status(StatusCode::NO_CONTENT); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + john_client.fetch_and_remove_itips().await, + Vec::::new() + ); + + for client in [bill_client, jane_client, john_client] { + client.delete_default_containers().await; + } + for account in [bill, jane, john] { + test.destroy_all_mailboxes(account).await; + } + + test.assert_is_empty().await; +} + +impl DummyWebDavClient { + async fn fetch_and_remove_itips(&self) -> Vec { + let inbox_href = format!("/dav/itip/{}/inbox/", self.name.replace('@', "%40")); + let response = self + .propfind_with_headers(&inbox_href, ALL_DAV_PROPERTIES, [("depth", "1")]) + .await; + let mut itips = vec![]; + + for href in response.hrefs.keys().filter(|&href| href != &inbox_href) { + let itip = self + .request("GET", href, "") + .await + .with_status(StatusCode::OK) + .body + .expect("Missing body"); + self.request("DELETE", href, "") + .await + .with_status(StatusCode::NO_CONTENT); + itips.push(itip); + } + + itips + } +} + +#[derive(Debug)] +struct CalEntry { + href: String, + ical: String, + schedule_tag: String, +} + +impl DummyWebDavClient { + async fn fetch_icals(&self) -> Vec { + let cal_inbox = format!("/dav/cal/{}/default/", self.name.replace('@', "%40")); + let response = self + .propfind_with_headers(&cal_inbox, ALL_DAV_PROPERTIES, [("depth", "1")]) + .await; + let mut cals = vec![]; + + for href in response.hrefs.keys().filter(|&href| href != &cal_inbox) { + let ical = self + .request("GET", href, "") + .await + .with_status(StatusCode::OK) + .body + .expect("Missing body"); + let properties = response.properties(href); + + assert!( + !ical.contains("METHOD:"), + "iTIP method found in calendar entry: {ical}" + ); + + cals.push(CalEntry { + href: href.to_string(), + ical, + schedule_tag: properties + .get(DavProperty::CalDav(CalDavProperty::ScheduleTag)) + .value() + .to_string(), + }); + } + + cals + } +} + +pub async fn test_build_itip_templates(test: &TestServer) { + let account = test.account("john@example.com"); + let account_id = account.id().document_id(); + let account_info = test.server.account_info(account_id).await.unwrap(); + let out_dir = super::template_out_dir().expect("ITIP_TEMPLATES must be set"); + + for (idx, summary) in [ + ItipSummary::Invite(vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Lunch at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Location, + value: ItipValue::Text("Cafe Corner".to_string()), + }, + ItipField { + name: ICalendarProperty::Conference, + value: ItipValue::Text("https://meet.example.com/lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }], + ..Default::default() + })), + }, + ItipField { + name: ICalendarProperty::Attendee, + value: ItipValue::Participants(vec![ + ItipParticipant { + email: "jdoe@domain.com".to_string(), + name: Some("John Doe".to_string()), + is_organizer: true, + }, + ItipParticipant { + email: "jane@domain.com".to_string(), + name: Some("Jane Smith".to_string()), + is_organizer: false, + }, + ]), + }, + ]), + ItipSummary::Cancel(vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Lunch at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Location, + value: ItipValue::Text("Cafe Corner".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ]), + ItipSummary::Rsvp { + part_stat: ICalendarParticipationStatus::Accepted, + current: vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Lunch at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Location, + value: ItipValue::Text("Cafe Corner".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + until: None, + count: Some(2), + interval: Some(3), + bysecond: Default::default(), + byday: vec![ + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Monday, + }, + ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Wednesday, + }, + ], + ..Default::default() + })), + }, + ], + }, + ItipSummary::Rsvp { + part_stat: ICalendarParticipationStatus::Declined, + current: vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Lunch at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Location, + value: ItipValue::Text("Cafe Corner".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ], + }, + ItipSummary::Update { + method: ICalendarMethod::Request, + current: vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Lunch at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Location, + value: ItipValue::Text("Cafe Corner".to_string()), + }, + ItipField { + name: ICalendarProperty::Conference, + value: ItipValue::Text("https://meet.example.com/lunch".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }], + ..Default::default() + })), + }, + ItipField { + name: ICalendarProperty::Attendee, + value: ItipValue::Participants(vec![ + ItipParticipant { + email: "jdoe@domain.com".to_string(), + name: Some("John Doe".to_string()), + is_organizer: true, + }, + ItipParticipant { + email: "jane@domain.com".to_string(), + name: Some("Jane Smith".to_string()), + is_organizer: false, + }, + ]), + }, + ], + previous: vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Dinner".to_string()), + }, + ItipField { + name: ICalendarProperty::Description, + value: ItipValue::Text("Dinner at the cafe".to_string()), + }, + ItipField { + name: ICalendarProperty::Conference, + value: ItipValue::Text("https://meet.example.com/dinner".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750916068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + ..Default::default() + })), + }, + ], + }, + ItipSummary::Update { + method: ICalendarMethod::Request, + current: vec![ + ItipField { + name: ICalendarProperty::Summary, + value: ItipValue::Text("Standup".to_string()), + }, + ItipField { + name: ICalendarProperty::Dtstart, + value: ItipValue::Time(ItipTime { + start: 1750616068, + tz_id: Tz::from_str("New Zealand").unwrap().as_id(), + }), + }, + ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Weekly, + byday: vec![ICalendarDay { + ordwk: None, + weekday: ICalendarWeekday::Tuesday, + }], + ..Default::default() + })), + }, + ], + previous: vec![ItipField { + name: ICalendarProperty::Rrule, + value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule { + freq: ICalendarFrequency::Daily, + ..Default::default() + })), + }], + }, + ] + .into_iter() + .enumerate() + { + let html = build_itip_template( + &test.server, + &account_info, + account_id, + 1, + "john.doe@example.org", + "jane.smith@example.net", + &summary, + "124", + ) + .await + .expect("Failed to build iTIP template"); + + let path = out_dir.join(format!("itip_template_{idx}.html")); + std::fs::write(&path, html.body).expect("Failed to write iTIP template to file"); + println!( + "iTIP template {idx}: {} -> {}", + html.subject, + path.display() + ); + } +} + +const TEST_ITIP: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9263504FD3AD +SEQUENCE:0 +DTSTART:$START +DTEND:$END +DTSTAMP:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Lunch +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:jane.smith@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:bill@example.com +END:VEVENT +END:VCALENDAR +"#; + +const TEST_ITIP_EXTERNAL: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:AD9263504FD3 +SEQUENCE:0 +DTSTART:$START +DTEND:$END +DTSTAMP:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Brunch +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:carol@remote.org +END:VEVENT +END:VCALENDAR +"#; + +const TEST_ITIP_HIDDEN: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:HID9263504FD3 +SEQUENCE:0 +DTSTART:$START +DTEND:$END +DTSTAMP:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:All hands +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:carol@remote.org +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:dave@remote.org +END:VEVENT +END:VCALENDAR +"#; + +const TEST_ITIP_RECURRING: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:REC9263504FD3 +SEQUENCE:0 +DTSTART:$START +DTEND:$END +DTSTAMP:20090602T170000Z +RRULE:FREQ=WEEKLY;COUNT=4 +TRANSP:OPAQUE +SUMMARY:Weekly sync +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:carol@remote.org +END:VEVENT +BEGIN:VEVENT +UID:REC9263504FD3 +SEQUENCE:0 +RECURRENCE-ID:$SECOND +DTSTART:$SECOND +DTEND:$SECOND_END +DTSTAMP:20090602T170000Z +TRANSP:OPAQUE +SUMMARY:Weekly sync (moved) +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;CUTYPE=INDIVIDUAL:mailto:carol@remote.org +END:VEVENT +END:VCALENDAR +"#; + +const TEST_FREEBUSY: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +METHOD:REQUEST +BEGIN:VFREEBUSY +UID:4FD3AD926350 +DTSTAMP:20090602T190420Z +DTSTART:$START +DTEND:$END +ORGANIZER:mailto:jdoe@example.com +ATTENDEE:mailto:jdoe@example.com +ATTENDEE:mailto:jane.smith@example.com +ATTENDEE:mailto:bill@example.com +ATTENDEE:mailto:unknown@example.com +END:VFREEBUSY +END:VCALENDAR +"#; diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs new file mode 100644 index 0000000..39a8214 --- /dev/null +++ b/tests/src/webdav/card_query.rs @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; + +use dav_proto::schema::property::{CardDavProperty, DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + println!("Running REPORT addressbook-query tests..."); + let client = test.account("john@example.com").webdav_client(); + + // Create test data + let default_path = format!( + "{}/john%40example.com/default/", + DavResourceName::Card.base_path() + ); + let mut hrefs = Vec::with_capacity(3); + for (i, vcard) in [VCARD1, VCARD2, VCARD3].iter().enumerate() { + let href = format!("{default_path}contact-{i}.vcf",); + client + .request("PUT", &href, *vcard) + .await + .with_status(hyper::StatusCode::CREATED); + hrefs.push(href); + } + let uri_sarah = hrefs[0].as_str(); + let uri_carlos = hrefs[1].as_str(); + let uri_acme = hrefs[2].as_str(); + + // Test 1: RFC6352 8.6.3 example 1 + let response = client + .request("REPORT", &default_path, QUERY1) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_carlos]) + .into_propfind_response(None); + let props = response.properties(uri_carlos); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + .with_values([r#"BEGIN:VCARD +VERSION:4.0 +FN:Carlos Rodriguez-Martinez +NICKNAME:Charlie +EMAIL;TYPE=WORK,pref:carlos.rodriguez@example-corp.com +EMAIL;TYPE=HOME:carlosrm@personalmail.example +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + + // Test 2: RFC6352 8.6.3 example 2 + let response = client + .request("REPORT", &default_path, QUERY2) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_carlos, uri_sarah]) + .into_propfind_response(None); + let props = response.properties(uri_carlos); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + .with_values([r#"BEGIN:VCARD +FN:Carlos Rodriguez-Martinez +BDAY:--0623 +CATEGORIES:Marketing,Management,International +LANG;TYPE=WORK;PREF=1:es +LANG;TYPE=WORK;PREF=2:en +LANG;TYPE=WORK;PREF=3:pt +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + let props = response.properties(uri_sarah); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + .with_values([r#"BEGIN:VCARD +FN:Sarah Johnson +BDAY:19850415 +CATEGORIES:Work,Research,VIP +LANG;TYPE=WORK;PREF=1:en +LANG;TYPE=WORK;PREF=2:fr +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + + // Test 3: Search within parameters + let response = client + .request("REPORT", &default_path, QUERY3) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_acme]) + .into_propfind_response(None); + let props = response.properties(uri_acme); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + .with_values([VCARD3.replace('\n', "\r\n").as_str()]); + + // Test 4: Search using limit + client + .request("REPORT", &default_path, QUERY4) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 507 Insufficient Storage", + ) + .with_value( + "D:multistatus.D:response.D:error.D:number-of-matches-within-limits", + "", + ) + .with_value( + "D:multistatus.D:response.D:responsedescription", + "The number of matches exceeds the limit of 2", + ) + .with_href_count(3); + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +const QUERY1: &str = r#" + + + + + + + + + + + + + + charlie + + + "#; + +const QUERY2: &str = r#" + + + + + + + + + + + + + john + + + rodriguez + + + "#; + +const QUERY3: &str = r#" + + + + + + + + + enterprise + + + +"#; + +const QUERY4: &str = r#" + + + + + + + acme + + + global + + + + 2 + + "#; + +const VCARD1: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Sarah Johnson +N:Johnson;Sarah;Marie;Dr.;Ph.D. +NICKNAME:Sadie +GENDER:F +BDAY:19850415 +ANNIVERSARY:20100610 +EMAIL;TYPE=work:sarah.johnson@example.com +EMAIL;TYPE=home,pref:sarahjpersonal@example.com +TEL;TYPE=cell,voice,pref:+1-555-123-4567 +TEL;TYPE=work,voice:+1-555-987-6543 +TEL;TYPE=home,voice:+1-555-456-7890 +ADR;TYPE=work;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA":;;123 Business Ave;New York;NY;10001;USA +ADR;TYPE=home,pref;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA":;;456 Residential St;Brooklyn;NY;11201;USA +ORG:Acme Technologies Inc.;Research Department +TITLE:Senior Research Scientist +ROLE:Team Lead +CATEGORIES:Work,Research,VIP +URL;TYPE=work:https://www.example.com/staff/sjohnson +URL;TYPE=home:https://www.sarahjohnson.example.com +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=sarah.johnson@example.com +NOTE:Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST. +LANG;TYPE=work;PREF=1:en +LANG;TYPE=work;PREF=2:fr +TZ:-0500 +GEO:40.7128;-74.0060 +UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +REV:20220315T133000Z +END:VCARD +"#; + +const VCARD2: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Carlos Rodriguez-Martinez +N:Rodriguez-Martinez;Carlos;Alberto;Mr.;Jr. +NICKNAME:Charlie +GENDER:M +BDAY:--0623 +ANNIVERSARY:20150809 +EMAIL;TYPE=work,pref:carlos.rodriguez@example-corp.com +EMAIL;TYPE=home:carlosrm@personalmail.example +TEL;TYPE=cell,voice,pref:+34-611-234-567 +TEL;TYPE=work,voice:+34-911-876-543 +TEL;TYPE=home,voice:+34-644-321-987 +TEL;TYPE=fax:+34-911-876-544 +ADR;TYPE=work;LABEL="Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain":;;Calle Empresarial 42;Madrid;;28001;Spain +ADR;TYPE=home,pref;LABEL="Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain":;;Avenida Residencial 15;Madrid;;28045;Spain +ORG:Global Solutions S.L.;Marketing Division +TITLE:Digital Marketing Director +ROLE:Department Head +CATEGORIES:Marketing,Management,International +URL;TYPE=work:https://www.example-corp.com/team/carlos +URL;TYPE=home:https://www.carlosrodriguez.example +URL;TYPE=social:https://linkedin.com/in/carlosrodriguezm +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=carlos.rodriguez@example-corp.com +NOTE:Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET. +LANG;TYPE=work;PREF=1:es +LANG;TYPE=work;PREF=2:en +LANG;TYPE=work;PREF=3:pt +TZ:+0100 +GEO:40.4168;-3.7038 +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a +REV:20230712T092135Z +SOURCE:https://contacts.example.com/carlosrodriguez.vcf +KIND:individual +MEMBER:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af +RELATED;TYPE=friend:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +END:VCARD +"#; + +const VCARD3: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Acme Business Solutions Ltd. +N:;;;; +KIND:ORG +ORG:Acme Business Solutions Ltd.;Technology Division +EMAIL;TYPE=WORK,pref:info@acme-solutions.example +EMAIL;TYPE=support:support@acme-solutions.example +EMAIL;TYPE=sales:sales@acme-solutions.example +TEL;TYPE=WORK,VOICE,pref:+44-20-1234-5678 +TEL;TYPE=FAX:+44-20-1234-5679 +TEL;TYPE=support:+44-800-987-6543 +ADR;TYPE=WORK;LABEL="10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited + Kingdom":;;10 Enterprise Way\, Tech Park;London;;EC1A 1BB;United Kingdom +ADR;TYPE=branch;LABEL="25 Innovation Street\nManchester, M1 5QF\nUnited Kin + gdom":;;25 Innovation Street;Manchester;;M1 5QF;United Kingdom +URL;TYPE=WORK:https://www.acme-solutions.example +URL;TYPE=support:https://support.acme-solutions.example +CATEGORIES:Technology,B2B,Solutions,Services +NOTE:Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VA + T Reg: GB123456789 +TZ:Z +GEO:51.5074;-0.1278 +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=info@acme-sol + utions.example +UID:urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2 +REV:20230415T153000Z +LANG;TYPE=WORK;PREF=1:en +LANG;TYPE=WORK;PREF=2:de +LANG;TYPE=WORK;PREF=3:fr +SOURCE:https://directory.example.com/acme.vcf +RELATED;TYPE=CONTACT:urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1 +RELATED;TYPE=CONTACT:urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2 +X-ABLabel:Company +PRODID:-//Example Corp.//Contact Manager 3.0//EN +END:VCARD +"#; diff --git a/tests/src/webdav/copy_move.rs b/tests/src/webdav/copy_move.rs new file mode 100644 index 0000000..b949095 --- /dev/null +++ b/tests/src/webdav/copy_move.rs @@ -0,0 +1,904 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + server::TestServer, + webdav::{DavResponse, GenerateTestDavResource}, +}; +use ahash::AHashSet; +use dav_proto::Depth; +use groupware::DavResourceName; +use hyper::StatusCode; +use registry::schema::structs::Action; + +pub async fn test(test: &TestServer, assisted_discovery: bool) { + let admin = test.account("admin@example.com"); + let client = test.account("jane@example.com").webdav_client(); + let mike_noquota = test.account("mike@example.com").webdav_client(); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!("Running COPY/MOVE tests ({})...", resource_type.base_path()); + let user_base_path = format!("{}/jane%40example.com", resource_type.base_path()); + let group_base_path = format!("{}/support%40example.com", resource_type.base_path()); + let default_test_depth = if resource_type == DavResourceName::File { + 2 + } else { + 0 + }; + + // Obtain sync token + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + + // TODO: Fix tests for assisted discovery + assert_eq!( + response.hrefs().len(), + if resource_type == DavResourceName::File { + 1 + } else { + 2 + usize::from(assisted_discovery) + }, + "{:?}", + response.hrefs() + ); + + // Create nested files and folders + let (hierarchy_root, mut hierarchy) = client + .create_hierarchy(&user_base_path, default_test_depth, 2, 3) + .await; + let prev_sync_token = response.sync_token(); + let response = client + .sync_collection( + &user_base_path, + prev_sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + let sync_token = response.sync_token(); + let changed_hrefs = response.hrefs(); + assert_ne!(sync_token, prev_sync_token); + assert_eq!( + changed_hrefs, + hierarchy.iter().map(|x| x.0.as_str()).collect::>(), + "lengths {} & {}", + changed_hrefs.len(), + hierarchy.len() + ); + client.validate_values(&hierarchy).await; + + // Delete cache an resync + admin.registry_create_object(Action::InvalidateCaches).await; + let response = client + .sync_collection( + &user_base_path, + prev_sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + let sync_token = response.sync_token(); + let changed_hrefs = response.hrefs(); + assert_ne!(sync_token, prev_sync_token); + assert_eq!( + changed_hrefs, + hierarchy.iter().map(|x| x.0.as_str()).collect::>(), + "lengths {} & {}", + changed_hrefs.len(), + hierarchy.len() + ); + + // Copying and moving to the same or root containers is invalid + for method in ["COPY", "MOVE"] { + for destination in [ + "/dav", + "/dav/cal", + "/dav/card", + "/dav/file", + "/dav/pal", + hierarchy_root.as_str(), + ] { + client + .request_with_headers( + method, + &hierarchy_root, + [("destination", destination)], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + } + + // Test 1: Rename container + let new_hierarchy_root = format!("{user_base_path}/Test_Folder/"); + client + .request_with_headers( + "MOVE", + &hierarchy_root, + [("destination", new_hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + replace_prefix(&mut hierarchy, &hierarchy_root, &new_hierarchy_root); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + // Validate changes + let changes = client + .sync_collection( + &user_base_path, + sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(2) + .into_propfind_response(None); + changes + .properties(&hierarchy_root) + .with_status(StatusCode::NOT_FOUND); + changes + .properties(&new_hierarchy_root) + .with_status(StatusCode::OK); + let hierarchy_root = new_hierarchy_root; + + // Test 2: Copy container + let new_hierarchy_root = format!("{user_base_path}/Test_Folder_Copy/"); + client + .request_with_headers( + "COPY", + &hierarchy_root, + [("destination", new_hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + let mut copied_hierarchy = hierarchy.clone(); + replace_prefix(&mut copied_hierarchy, &hierarchy_root, &new_hierarchy_root); + copied_hierarchy.extend_from_slice(&hierarchy); + assert_result(&response, &copied_hierarchy); + client.validate_values(&copied_hierarchy).await; + + // Test 3: Delete original container + client + .request("DELETE", &new_hierarchy_root, "") + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 4: Create a shallow container and overwrite the previous one using MOVE + let (new_hierarchy_root, mut hierarchy) = + client.create_hierarchy(&user_base_path, 0, 0, 3).await; + let sync_token = client + .sync_collection( + &user_base_path, + sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .sync_token() + .to_string(); + client + .request_with_headers( + "MOVE", + &new_hierarchy_root, + [("destination", hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + replace_prefix(&mut hierarchy, &new_hierarchy_root, &hierarchy_root); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + // Validate changes + let changes = client + .sync_collection( + &user_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .into_propfind_response(None); + changes + .properties(&new_hierarchy_root) + .with_status(StatusCode::NOT_FOUND); + changes + .properties(&hierarchy_root) + .with_status(StatusCode::OK); + + // Test 5: Create a deep container and overwrite the previous one using COPY + let (new_hierarchy_root, new_hierarchy) = client + .create_hierarchy(&user_base_path, default_test_depth, 1, 2) + .await; + client + .request_with_headers( + "COPY", + &new_hierarchy_root, + [("destination", hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + let mut orig_hierarchy = new_hierarchy.clone(); + replace_prefix(&mut orig_hierarchy, &new_hierarchy_root, &hierarchy_root); + let mut full_hierarchy = new_hierarchy.clone(); + full_hierarchy.extend_from_slice(&orig_hierarchy); + assert_result(&response, &full_hierarchy); + client.validate_values(&full_hierarchy).await; + + // Test 6: Copy and move containers to a shared account + let shared_hierarchy_root_1 = format!("{group_base_path}/Test_Shared_Folder_1/"); + let shared_hierarchy_root_2 = format!("{group_base_path}/Test_Shared_Folder_2/"); + client + .request_with_headers( + "MOVE", + &new_hierarchy_root, + [("destination", shared_hierarchy_root_1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "COPY", + &hierarchy_root, + [("destination", shared_hierarchy_root_2.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &orig_hierarchy); + client.validate_values(&orig_hierarchy).await; + let response = client + .sync_collection(&group_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + replace_prefix( + &mut full_hierarchy, + &new_hierarchy_root, + &shared_hierarchy_root_1, + ); + replace_prefix( + &mut full_hierarchy, + &hierarchy_root, + &shared_hierarchy_root_2, + ); + assert_result(&response, &full_hierarchy); + client.validate_values(&full_hierarchy).await; + + // Delete all containers + for shared_container in [ + shared_hierarchy_root_1, + shared_hierarchy_root_2, + hierarchy_root, + ] { + client + .request("DELETE", &shared_container, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + // Create test containers + let mut hierarchy = vec![]; + for folder_name in ["folder1", "folder2", "folder3"] { + let folder_path = format!("{user_base_path}/{folder_name}/"); + + client + .mkcol("MKCOL", &folder_path, [], []) + .await + .with_status(StatusCode::CREATED); + + for file_name in ["file1", "file2", "file3"] { + let file_path = format!("{folder_path}{file_name}"); + let file_contents = resource_type.generate(); + client + .request("PUT", &file_path, &file_contents) + .await + .with_status(StatusCode::CREATED); + hierarchy.push((file_path, file_contents)); + } + + hierarchy.push((folder_path, "".to_string())); + } + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 7: Copying or moving files to the root container is not allowed + let folder1_file1 = format!("{user_base_path}/folder1/file1"); + if resource_type != DavResourceName::File { + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &folder1_file1, + [("destination", user_base_path.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + client + .request_with_headers( + method, + &folder1_file1, + [("destination", format!("{user_base_path}/folder2").as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + } + + // Test 8: Copying or moving to the same location is not allowed + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &folder1_file1, + [("destination", folder1_file1.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + + // Test 9: Rename file + let folder1_file1_new = format!("{user_base_path}/folder1/file1_new"); + client + .request_with_headers( + "MOVE", + &folder1_file1, + [("destination", folder1_file1_new.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + rename(&mut hierarchy, &folder1_file1, &folder1_file1_new); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 10: Move a file under a different container + let folder2_file1_from_folder1 = format!("{user_base_path}/folder2/file1_from_folder1"); + client + .request_with_headers( + "MOVE", + &folder1_file1_new, + [("destination", folder2_file1_from_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + rename( + &mut hierarchy, + &folder1_file1_new, + &folder2_file1_from_folder1, + ); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 11: Move and overwrite a file under a different container + let folder1_file2 = format!("{user_base_path}/folder1/file2"); + client + .request_with_headers( + "MOVE", + &folder2_file1_from_folder1, + [("destination", folder1_file2.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + delete(&mut hierarchy, &folder1_file2); + rename(&mut hierarchy, &folder2_file1_from_folder1, &folder1_file2); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 12: Copy a file under a different container + let file3_path = format!("{user_base_path}/folder1/file3"); + let folder3_file3_from_folder1 = format!("{user_base_path}/folder3/file3_from_folder1"); + client + .request_with_headers( + "COPY", + &file3_path, + [("destination", folder3_file3_from_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + copy(&mut hierarchy, &file3_path, &folder3_file3_from_folder1); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 12: Copy and overwrite a file under a different container + let folder2_file2 = format!("{user_base_path}/folder2/file2"); + client + .request_with_headers( + "COPY", + &folder3_file3_from_folder1, + [("destination", folder2_file2.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + delete(&mut hierarchy, &folder2_file2); + copy(&mut hierarchy, &folder3_file3_from_folder1, &folder2_file2); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 13: Copy and move files to a shared container + let shared_hierarchy_root = format!("{group_base_path}/Test_Child_Folder/"); + let folder3_file1 = format!("{user_base_path}/folder3/file1"); + let shared_file_1 = format!("{shared_hierarchy_root}shared_file_1"); + let shared_file_2 = format!("{shared_hierarchy_root}shared_file_2"); + client + .mkcol("MKCOL", &shared_hierarchy_root, [], []) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "MOVE", + &folder3_file1, + [("destination", shared_file_1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "COPY", + &folder1_file2, + [("destination", shared_file_2.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let shared_hierarchy = vec![ + (shared_hierarchy_root.clone(), "".to_string()), + ( + shared_file_1, + get_contents(&hierarchy, &folder3_file1).unwrap(), + ), + ( + shared_file_2, + get_contents(&hierarchy, &folder1_file2).unwrap(), + ), + ]; + delete(&mut hierarchy, &folder3_file1); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + let response = client + .sync_collection(&group_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &shared_hierarchy); + client.validate_values(&shared_hierarchy).await; + client + .request("DELETE", &shared_hierarchy_root, "") + .await + .with_status(StatusCode::NO_CONTENT); + + if resource_type == DavResourceName::File { + // Test 14: Move a container under a different container + let folder2 = format!("{user_base_path}/folder2/"); + let folder3 = format!("{user_base_path}/folder3/"); + let folder2_folder3 = format!("{user_base_path}/folder2/folder3/"); + client + .request_with_headers( + "MOVE", + &folder3, + [("destination", folder2_folder3.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + replace_prefix(&mut hierarchy, &folder3, &folder2_folder3); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 15: Moving or copying a parent under a child is not allowed + for method in ["MOVE", "COPY"] { + client + .request_with_headers( + method, + &folder2_folder3, + [("destination", folder2.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + + // Test 16: Copy a container under a different container + let folder1 = format!("{user_base_path}/folder1/"); + let folder2_folder1 = format!("{user_base_path}/folder2/folder1/"); + client + .request_with_headers( + "COPY", + &folder1, + [("destination", folder2_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + copy_prefix(&mut hierarchy, &folder1, &folder2_folder1); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + } else { + // Test 17: UID collision + let folder1 = format!("{user_base_path}/folder1/"); + let folder2 = format!("{user_base_path}/folder2/"); + let file_contents = resource_type.generate(); + for folder_path in [&folder1, &folder2] { + let file_path = format!("{folder_path}uid_test"); + client + .request("PUT", &file_path, file_contents.as_str()) + .await + .with_status(StatusCode::CREATED); + } + let uid_file_src = format!("{folder1}uid_test"); + let uid_file_dest = format!("{folder2}uid_test_dup"); + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &uid_file_src, + [("destination", uid_file_dest.as_str())], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition( + if resource_type == DavResourceName::Cal { + "A:no-uid-conflict.D:href" + } else { + "B:no-uid-conflict.D:href" + }, + &format!("{folder2}uid_test"), + ); + } + } + + // Delete all containers and create a new one + client + .request("DELETE", &format!("{user_base_path}/folder3/"), "") + .await + .with_status(if resource_type == DavResourceName::File { + StatusCode::NOT_FOUND + } else { + StatusCode::NO_CONTENT + }); + for folder in ["folder1", "folder2"] { + let folder_path = format!("{user_base_path}/{folder}/"); + client + .request("DELETE", &folder_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + // Create a new test container and file + let test_base_path = format!("{user_base_path}/My_Test_Folder/"); + client + .mkcol("MKCOL", &test_base_path, [], []) + .await + .with_status(StatusCode::CREATED); + let test_contents_1 = resource_type.generate(); + let test_contents_2 = resource_type.generate(); + let test_file1_path = format!("{test_base_path}test_file_1"); + let test_file2_path = format!("{test_base_path}test_file_2"); + let test_etag_1 = client + .request("PUT", &test_file1_path, test_contents_1.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + let test_etag_2 = client + .request("PUT", &test_file2_path, test_contents_2.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + + // Test 18: Failed DAV preconditions + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("overwrite", "F"), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if-none-match", "*"), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + let iff = format!( + "<{test_file1_path}> (Not [{test_etag_1}]) <{test_file2_path}> (Not [{test_etag_2}])", + ); + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if", iff.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + } + + // Test 18: Successful DAV preconditions + let iff = + format!("<{test_file1_path}> ([{test_etag_1}]) <{test_file2_path}> ([{test_etag_2}])",); + client + .request_with_headers( + "MOVE", + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if", iff.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Delete the test container + client + .request("DELETE", &test_base_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 19: Quota enforcement (on CalDAV/CardDAV items are linked, not copied therefore there is no quota increase) + if resource_type == DavResourceName::File { + let path = format!( + "{}/mike%40example.com/quota-test/", + resource_type.base_path() + ); + let content = resource_type.generate(); + mike_noquota + .mkcol("MKCOL", &path, [], []) + .await + .with_status(StatusCode::CREATED); + mike_noquota + .request_with_headers("PUT", &format!("{path}file"), [], &content) + .await + .with_status(StatusCode::CREATED); + let mut num_success = 0; + let mut did_fail = false; + + for i in 0..100 { + let response = mike_noquota + .request_with_headers( + "COPY", + &path, + [( + "destination", + format!( + "{}/mike%40example.com/quota-test{i}", + resource_type.base_path() + ) + .as_str(), + )], + &content, + ) + .await; + match response.status { + StatusCode::CREATED => { + num_success += 1; + } + StatusCode::PRECONDITION_FAILED => { + did_fail = true; + break; + } + _ => panic!("Unexpected status code: {:?}", response.status), + } + } + if !did_fail { + panic!("Quota test failed: {} files created", num_success); + } + if num_success == 0 { + panic!("Quota test failed: no files created"); + } + + mike_noquota + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + for i in 0..num_success { + mike_noquota + .request( + "DELETE", + &format!( + "{}/mike%40example.com/quota-test{i}", + resource_type.base_path() + ), + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + } + } + } + + client.delete_default_containers().await; + client + .delete_default_containers_by_account("support@example.com") + .await; + mike_noquota.delete_default_containers().await; + test.assert_is_empty().await; +} + +fn assert_result(response: &DavResponse, hierarchy: &[(String, String)]) { + assert!(!hierarchy.is_empty()); + let response = response + .hrefs() + .into_iter() + .filter(|h| { + !h.ends_with("/jane%40example.com/") + && !h.ends_with("/support%40example.com/") + && !h.ends_with("/default/") + }) + .collect::>(); + let hierarchy = hierarchy + .iter() + .map(|x| x.0.as_str()) + .collect::>(); + + if hierarchy != response { + println!("\nMissing: {:?}", hierarchy.difference(&response)); + println!("\nExtra: {:?}", response.difference(&hierarchy)); + + panic!( + "Hierarchy mismatch: expected {} items, received {} items", + hierarchy.len(), + response.len() + ); + } +} + +fn replace_prefix(items: &mut [(String, String)], old_prefix: &str, new_prefix: &str) { + let mut did_replace = false; + for (href, _) in items.iter_mut() { + if let Some(value) = href.strip_prefix(old_prefix) { + *href = format!("{new_prefix}{value}"); + did_replace = true; + } + } + if !did_replace { + panic!("Prefix not found: {}", old_prefix); + } +} + +fn rename(items: &mut [(String, String)], old_name: &str, new_name: &str) { + for (href, _) in items.iter_mut() { + if href == old_name { + *href = new_name.to_string(); + return; + } + } + panic!("Item not found: {}", old_name); +} + +fn delete(items: &mut Vec<(String, String)>, name: &str) { + let mut did_delete = false; + items.retain(|(href, _)| { + did_delete = did_delete || href == name; + href != name + }); + + if !did_delete { + panic!("Item not found: {}", name); + } +} + +fn copy(items: &mut Vec<(String, String)>, old_name: &str, new_name: &str) { + for (href, contents) in items.iter_mut() { + if href == old_name { + let value = (new_name.to_string(), contents.to_string()); + items.push(value); + return; + } + } + panic!("Item not found: {}", old_name); +} + +fn copy_prefix(items: &mut Vec<(String, String)>, old_prefix: &str, new_prefix: &str) { + let mut new_items = vec![]; + for (href, contents) in items.iter() { + if let Some(value) = href.strip_prefix(old_prefix) { + new_items.push((format!("{new_prefix}{value}"), contents.to_string())); + } + } + if !new_items.is_empty() { + items.extend(new_items); + } else { + panic!("Prefix not found: {}", old_prefix); + } +} + +fn get_contents(items: &[(String, String)], name: &str) -> Option { + for (href, contents) in items.iter() { + if href == name { + return Some(contents.to_string()); + } + } + None +} diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs new file mode 100644 index 0000000..c846966 --- /dev/null +++ b/tests/src/webdav/lock.rs @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, webdav::GenerateTestDavResource}; + +use dav_proto::schema::property::{DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + let client = test.account("john@example.com").webdav_client(); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running LOCK/UNLOCK tests ({})...", + resource_type.base_path() + ); + let base_path = format!("{}/john%40example.com", resource_type.base_path()); + + // Test 1: Creating a collection under an unmapped resource without providing a lock token should fail + let path = format!("{base_path}/do-not-write"); + let response = client + .lock_create(&path, "super-owner", true, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED); + let lock_token = response + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:owner.href", + "super-owner", + ) + .with_value("D:prop.D:lockdiscovery.D:activelock.D:depth", "infinity") + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:timeout", + "Second-123", + ) + .lock_token() + .to_string(); + + // Test 2: Refreshing a lock token with an invalid a lock token should fail + client + .lock_refresh(&path, "urn:stalwart:davlock:1234", "infinity", "Second-456") + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + // Test 3: Refreshing a lock token with valid a lock token should succeed + client + .lock_refresh(&path, &lock_token, "infinity", "Second-456") + .await + .with_status(StatusCode::OK) + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:owner.href", + "super-owner", + ) + .with_any_value( + "D:prop.D:lockdiscovery.D:activelock.D:timeout", + ["Second-456", "Second-455"], + ); + + // Test 3: Creating a collection under an unmapped resource with a lock token should fail + client + .request_with_headers("MKCOL", &path, [], "") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + + // Test 4: Creating a collection under a mapped resource with a lock token should succeed + client + .request_with_headers( + "MKCOL", + &path, + [("if", format!("(<{lock_token}>)").as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + + // Test 5: Creating a lock under an infinity locked resource should fail + let file_path = format!("{path}/file.txt"); + client + .lock_create(&file_path, "super-owner", true, "0", "Second-123") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + + // Test 6: Creating a file under a locked resource without a lock token should fail + let contents = resource_type.generate(); + client + .request("PUT", &file_path, &contents) + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + + // Test 7: Creating a file under a locked resource with a lock token should succeed + client + .request_with_headers( + "PUT", + &file_path, + [("if", format!("(<{lock_token}>)").as_str())], + &contents, + ) + .await + .with_status(StatusCode::CREATED); + + // Test 8: Locks should be included in propfind responses + let response = client + .propfind(&path, [DavProperty::WebDav(WebDavProperty::LockDiscovery)]) + .await; + for href in [path.clone() + "/", file_path] { + let props = response.properties(&href); + props + .get(DavProperty::WebDav(WebDavProperty::LockDiscovery)) + .with_some_values([ + "D:activelock.D:owner.href:super-owner", + "D:activelock.D:depth:infinity", + format!("D:activelock.D:locktoken.D:href:{lock_token}").as_str(), + format!("D:activelock.D:lockroot.D:href:{path}").as_str(), + "D:activelock.D:locktype.D:write", + "D:activelock.D:lockscope.D:exclusive", + ]) + .with_any_values([ + "D:activelock.D:timeout:Second-456", + "D:activelock.D:timeout:Second-455", + ]); + } + + // Test 9: Delete with and without a lock token + client + .request("DELETE", &path, "") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + client + .request_with_headers( + "DELETE", + &path, + [("if", format!("(<{lock_token}>)").as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 10: Unlock with and without a lock token + client + .unlock(&path, "urn:stalwart:davlock:1234") + .await + .with_status(StatusCode::CONFLICT) + .with_value("D:error.D:lock-token-matches-request-uri", ""); + client + .unlock(&path, &lock_token) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 11: Locking with a large dead property should fail + let path = format!("{base_path}/invalid-lock"); + client + .lock_create( + &path, + (0..=test.server.core.groupware.dead_property_size.unwrap() + 1) + .map(|_| "a") + .collect::() + .as_str(), + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::PAYLOAD_TOO_LARGE); + + // Test 12: Too many locks should fail + for i in 0..test.server.core.groupware.max_locks_per_user { + client + .lock_create( + &format!("{base_path}/invalid-lock-{i}"), + "super-owner", + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::CREATED); + } + client + .lock_create( + &format!("{base_path}/invalid-lock-greedy"), + "super-owner", + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::TOO_MANY_REQUESTS); + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs new file mode 100644 index 0000000..6a13235 --- /dev/null +++ b/tests/src/webdav/mkcol.rs @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use hyper::StatusCode; + +use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1}; + +use crate::utils::server::TestServer; + +pub async fn test(test: &TestServer) { + println!("Running MKCOL tests..."); + let client = test.account("john@example.com").webdav_client(); + + // Creating collections in root elements is not allowed + for path in [ + "/dav/file/test", + "/dav/card/test", + "/dav/cal/test", + "/dav/test", + ] { + client + .request("MKCOL", path, "") + .await + .with_status(StatusCode::NOT_FOUND); + } + + // Create collections using MKCOL (empty body) + for path in [ + "/dav/file/john%40example.com/my-files", + "/dav/card/john%40example.com/my-cards", + "/dav/cal/john%40example.com/my-events", + ] { + client + .request("MKCOL", path, "") + .await + .with_status(StatusCode::CREATED); + } + + // Create resources under the newly created collections + for (path, content) in [ + ( + "/dav/file/john%40example.com/my-files/file1.txt", + TEST_FILE_1, + ), + ( + "/dav/card/john%40example.com/my-cards/card1.vcf", + TEST_VCARD_1, + ), + ( + "/dav/cal/john%40example.com/my-events/event1.ics", + TEST_ICAL_1, + ), + ] { + client + .request("PUT", path, content) + .await + .with_status(StatusCode::CREATED); + } + + // Creating a collection on a mapped resource should fail + for path in [ + "/dav/file/john%40example.com/my-files", + "/dav/card/john%40example.com/my-cards", + "/dav/cal/john%40example.com/my-events", + "/dav/file/john%40example.com/my-files/file1.txt", + "/dav/card/john%40example.com/my-cards/card1.vcf", + "/dav/cal/john%40example.com/my-events/event1.ics", + ] { + client + .request("MKCOL", path, "") + .await + .with_status(StatusCode::METHOD_NOT_ALLOWED); + } + + // Creating a sub-collections is allowed in FileDAV but in CalDAV and CardDAV + for (path, expected_status) in [ + ( + "/dav/file/john%40example.com/my-files/my-sub-files", + StatusCode::CREATED, + ), + ( + "/dav/card/john%40example.com/my-cards/my-sub-cards", + StatusCode::METHOD_NOT_ALLOWED, + ), + ( + "/dav/cal/john%40example.com/my-events/my-sub-events", + StatusCode::METHOD_NOT_ALLOWED, + ), + ] { + client + .request("MKCOL", path, "") + .await + .with_status(expected_status); + } + + // Extended MKCOL with an unsupported resource types should fail + for (path, resource_type) in [ + ( + "/dav/file/john%40example.com/my-named-files", + "B:addressbook", + ), + ("/dav/card/john%40example.com/my-named-cards", "A:calendar"), + ( + "/dav/cal/john%40example.com/my-named-events", + "B:addressbook", + ), + ] { + client + .mkcol("MKCOL", path, ["D:collection", resource_type], []) + .await + .with_status(StatusCode::FORBIDDEN) + .with_value( + "D:mkcol-response.D:propstat.D:error.D:valid-resourcetype", + "", + ) + .with_value("D:mkcol-response.D:propstat.D:prop.D:resourcetype", ""); + } + + // Create using extended MKCOL + for (path, expected_properties, resource_types) in [ + ( + "/dav/file/john%40example.com/my-named-files/", + [("D:displayname", "Named Files")].as_slice(), + ["D:collection"].as_slice(), + ), + ( + "/dav/card/john%40example.com/my-named-cards/", + [ + ("D:displayname", "Named Cards"), + ("B:addressbook-description", "Some amazing contacts"), + ] + .as_slice(), + ["D:collection", "B:addressbook"].as_slice(), + ), + ( + "/dav/cal/john%40example.com/my-named-events/", + [ + ("D:displayname", "Named Events"), + ("A:calendar-description", "Some amazing events"), + ( + "A:calendar-timezone", + &TEST_VTIMEZONE_1.replace("\n", "\r\n"), + ), + ] + .as_slice(), + ["D:collection", "A:calendar"].as_slice(), + ), + ] { + let response = client + .mkcol( + "MKCOL", + path, + resource_types.iter().copied(), + expected_properties.iter().copied(), + ) + .await + .with_status(StatusCode::CREATED) + .into_propfind_response("D:mkcol-response".into()); + let properties = response.properties(""); + for (property, _) in expected_properties { + properties + .get(property) + .with_status(StatusCode::OK) + .with_values([""]); + } + + // Check the properties of the created collection + let response = client + .propfind(path, expected_properties.iter().map(|x| x.0)) + .await; + let properties = response.properties(path); + for (property, value) in expected_properties { + properties + .get(property) + .with_status(StatusCode::OK) + .with_values([*value]); + } + } + + // Test MKCALENDAR + client + .mkcol( + "MKCALENDAR", + "/dav/cal/john%40example.com/my-named-events2", + [], + [ + ("D:displayname", "Named Events 2"), + ("A:calendar-description", ""), + ], + ) + .await + .with_status(StatusCode::CREATED) + .with_value("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "") + .with_values( + "A:mkcalendar-response.D:propstat.D:status", + ["HTTP/1.1 200 OK"], + ); + client + .mkcol( + "MKCALENDAR", + "/dav/cal/john%40example.com/my-named-events3", + [], + [ + ("D:displayname", "Named Events 3"), + ( + "A:supported-calendar-component-set", + "", + ), + ], + ) + .await + .with_status(StatusCode::CREATED) + .with_value("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "") + .with_values( + "A:mkcalendar-response.D:propstat.D:status", + ["HTTP/1.1 200 OK"], + ); + // Check the properties of the created calendars + client + .propfind( + "/dav/cal/john%40example.com/my-named-events2/", + ["A:supported-calendar-component-set"], + ) + .await + .properties("/dav/cal/john%40example.com/my-named-events2/") + .get("A:supported-calendar-component-set") + .with_status(StatusCode::OK) + .with_values([ + "A:comp.[name]:VJOURNAL", + "A:comp.[name]:VTIMEZONE", + "A:comp.[name]:VAVAILABILITY", + "A:comp.[name]:VALARM", + "A:comp.[name]:VRESOURCE", + "A:comp.[name]:AVAILABLE", + "A:comp.[name]:VTODO", + "A:comp.[name]:VFREEBUSY", + "A:comp.[name]:VEVENT", + "A:comp.[name]:STANDARD", + "A:comp.[name]:DAYLIGHT", + "A:comp.[name]:VLOCATION", + "A:comp.[name]:PARTICIPANT", + ]); + client + .propfind( + "/dav/cal/john%40example.com/my-named-events3/", + ["A:supported-calendar-component-set"], + ) + .await + .properties("/dav/cal/john%40example.com/my-named-events3/") + .get("A:supported-calendar-component-set") + .with_status(StatusCode::OK) + .with_values(["A:comp.[name]:VEVENT", "A:comp.[name]:VTODO"]); + + // Resource names arriving through the URI are echoed back verbatim + for name in ["My%20Folder", "file(1)+a:b", "%C3%9Cnterlagen", "Q&A"] { + let path = format!("/dav/file/john%40example.com/{name}"); + let href = format!("{path}/"); + client + .request("MKCOL", &path, "") + .await + .with_status(StatusCode::CREATED); + client + .propfind(&path, ["D:getetag"]) + .await + .with_hrefs([href.as_str()]); + } + + // Delete everything + for path in [ + "/dav/file/john%40example.com/My%20Folder", + "/dav/file/john%40example.com/file(1)+a:b", + "/dav/file/john%40example.com/%C3%9Cnterlagen", + "/dav/file/john%40example.com/Q&A", + "/dav/file/john%40example.com/my-files", + "/dav/card/john%40example.com/my-cards", + "/dav/cal/john%40example.com/my-events", + "/dav/file/john%40example.com/my-named-files", + "/dav/card/john%40example.com/my-named-cards", + "/dav/cal/john%40example.com/my-named-events", + "/dav/cal/john%40example.com/my-named-events2", + "/dav/cal/john%40example.com/my-named-events3", + ] { + client + .request("DELETE", path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + client.delete_default_containers().await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs new file mode 100644 index 0000000..c9f495e --- /dev/null +++ b/tests/src/webdav/mod.rs @@ -0,0 +1,330 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServerBuilder; +use ahash::AHashMap; +use common::{DavResource, DavResources}; +use groupware::DavResourceName; +use hyper::StatusCode; +use registry::{ + schema::{ + enums::{Permission, StorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + CalendarAlarm, CalendarScheduling, Expression, MtaStageAuth, Sharing, SystemSettings, + WebDav, + }, + }, + types::EnumImpl, +}; +use serde_json::json; +use std::str; +use std::time::Instant; + +pub mod acl; +pub mod basic; +pub mod cal_alarm; +pub mod cal_itip; +pub mod cal_query; +pub mod cal_scheduling; +pub mod card_query; +pub mod copy_move; +pub mod lock; +pub mod mkcol; +pub mod multiget; +pub mod principals; +pub mod prop; +pub mod put_get; +pub mod sync; + +#[tokio::test(flavor = "multi_thread")] +pub async fn webdav_tests() { + // Prepare settings + let assisted_discovery = std::env::var("ASSISTED_DISCOVERY").unwrap_or_default() == "1"; + + let mut test = TestServerBuilder::new("webdav_tests") + .await + .with_default_listeners() + .await + .build() + .await; + + // Create admin account + let admin = test.create_admin_account("admin@example.com").await; + + // Create test users + for (name, secret, description, aliases) in [ + ( + "john@example.com", + "secret2 + some more text", + "John Doe", + &["jdoe@example.com"], + ), + ( + "jane@example.com", + "secret3 + some more text", + "Jane Doe-Smith", + &["jane.smith@example.com"], + ), + ( + "bill@example.com", + "secret4 + some more text", + "Bill Foobar", + &["bill@example.com"], + ), + ( + "mike@example.com", + "secret5 + some more text", + "Mike Noquota", + &["mike@example.com"], + ), + ] { + let account = admin + .create_user_account( + name, + secret, + description, + aliases, + vec![ + Permission::UnlimitedRequests, + Permission::UnlimitedUploads, + Permission::DavPrincipalList, + Permission::DavPrincipalSearch, + ], + ) + .await; + if name == "mike@example.com" { + admin + .registry_update_object( + ObjectType::Account, + account.id(), + json!({ + Property::Quotas: { StorageQuota::MaxDiskQuota.as_str(): 1024} + }), + ) + .await; + } + + test.insert_account(account); + } + + // Create test group + test.insert_account( + admin + .create_group_account("support@example.com", "Support Group", &[]) + .await, + ); + + // Add Jane to the Support group + let support_id = test.account("support@example.com").id(); + admin + .registry_update_object( + ObjectType::Account, + test.account("jane@example.com").id(), + json!({ + "memberGroupIds": { support_id: true }, + }), + ) + .await; + + // Add test settings + admin + .registry_update_setting( + SystemSettings { + default_hostname: "webdav.example.org".to_string(), + ..Default::default() + }, + &[Property::DefaultHostname], + ) + .await; + admin + .registry_create_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await; + admin + .registry_create_object(CalendarAlarm { + min_trigger_interval: 1000u64.into(), + ..Default::default() + }) + .await; + admin + .registry_create_object(Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await; + admin + .registry_create_object(CalendarScheduling { + auto_add_invitations: true, + ..Default::default() + }) + .await; + admin + .registry_create_object(WebDav { + enable_assisted_discovery: assisted_discovery, + ..Default::default() + }) + .await; + admin.reload_settings().await; + + test.insert_account(admin); + + let start_time = Instant::now(); + if std::env::var("ITIP_TEMPLATES").is_ok() { + cal_scheduling::test_build_itip_templates(&test).await; + } + basic::test(&test).await; + put_get::test(&test).await; + mkcol::test(&test).await; + copy_move::test(&test, assisted_discovery).await; + prop::test(&test, assisted_discovery).await; + multiget::test(&test).await; + sync::test(&test).await; + lock::test(&test).await; + principals::test(&test, assisted_discovery).await; + acl::test(&test).await; + card_query::test(&test).await; + cal_query::test(&test).await; + cal_alarm::test(&test).await; + cal_itip::test(); + cal_scheduling::test(&test).await; + + // Print elapsed time + let elapsed = start_time.elapsed(); + println!( + "Elapsed: {}.{:03}s", + elapsed.as_secs(), + elapsed.subsec_millis() + ); + + // Remove test data + if test.is_reset() { + test.temp_dir.delete(); + } +} + +pub trait DavResourcesTest { + fn items(&self) -> Vec; +} + +impl DavResourcesTest for DavResources { + fn items(&self) -> Vec { + self.resources.clone() + } +} + +pub fn template_out_dir() -> Option { + if std::env::var("ITIP_TEMPLATES").is_err() { + return None; + } + + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.ignore/itip_templates"); + std::fs::create_dir_all(&dir).expect("Failed to create template output directory"); + + Some(dir.canonicalize().unwrap_or(dir)) +} + +pub const TEST_VCARD_1: &str = r#"BEGIN:VCARD +VERSION:4.0 +UID:18F098B5-7383-4FD6-B482-48F2181D73AA +X-TEST:SEQ1 +N:Coyote;Wile;E.;; +FN:Wile E. Coyote +ORG:ACME Inc.; +END:VCARD +"#; + +pub const TEST_VCARD_2: &str = r#"BEGIN:VCARD +VERSION:4.0 +UID:6exhjr32bt783wwlr9u0sr8lfqse5x7zqc8y +X-TEST:SEQ1 +FN:Joe Citizen +N:Citizen;Joe;;; +NICKNAME:human_being +EMAIL;TYPE=pref:jcitizen@foo.com +REV:20200411T072429Z +END:VCARD +"#; + +pub const TEST_ICAL_1: &str = r#"BEGIN:VCALENDAR +SOURCE;VALUE=URI:http://calendar.example.com/event_with_html.ics +X-TEST:SEQ1 +BEGIN:VEVENT +UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e +SUMMARY:What a nice present: 🎁 +DTSTART;TZID=America/New_York:20190221T170000 +DTEND;TZID=America/New_York:20190221T180000 +LOCATION:Germany +DESCRIPTION:

Title

  • first Row
  • + second Row

+END:VEVENT +END:VCALENDAR +"#; + +pub const TEST_ICAL_2: &str = r#"BEGIN:VCALENDAR +X-TEST:SEQ1 +BEGIN:VEVENT +UID:0000001 +SUMMARY:Treasure Hunting +DTSTART;TZID=America/Los_Angeles:20150706T120000 +DTEND;TZID=America/Los_Angeles:20150706T130000 +RRULE:FREQ=DAILY;COUNT=10 +EXDATE;TZID=America/Los_Angeles:20150708T120000 +EXDATE;TZID=America/Los_Angeles:20150710T120000 +END:VEVENT +BEGIN:VEVENT +UID:0000001 +SUMMARY:More Treasure Hunting +LOCATION:The other island +DTSTART;TZID=America/Los_Angeles:20150709T150000 +DTEND;TZID=America/Los_Angeles:20150707T160000 +RECURRENCE-ID;TZID=America/Los_Angeles:20150707T120000 +END:VEVENT +END:VCALENDAR +"#; + +pub const TEST_FILE_1: &str = r#"this is a test file +with some text +and some more text + +X-TEST:SEQ1 +"#; + +pub const TEST_FILE_2: &str = r#"another test file +with amazing content +and some more text + +X-TEST:SEQ1 +"#; + +pub const TEST_VTIMEZONE_1: &str = r#"BEGIN:VCALENDAR +PRODID:-//Example Corp.//CalDAV Client//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:US-Eastern +LAST-MODIFIED:19870101T000000Z +BEGIN:STANDARD +DTSTART:19671029T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:Eastern Standard Time (US Canada) +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19870405T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:Eastern Daylight Time (US Canada) +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR +"#; diff --git a/tests/src/webdav/multiget.rs b/tests/src/webdav/multiget.rs new file mode 100644 index 0000000..d95ec7d --- /dev/null +++ b/tests/src/webdav/multiget.rs @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, webdav::GenerateTestDavResource}; +use dav_proto::schema::property::{CalDavProperty, CardDavProperty, DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + let client = test.account("john@example.com").webdav_client(); + + for resource_type in [DavResourceName::Cal, DavResourceName::Card] { + println!( + "Running REPORT multiget tests ({})...", + resource_type.base_path() + ); + + let mut paths = Vec::new(); + for name in ["file1", "file2"] { + let contents = resource_type.generate(); + let path = format!( + "{}/john%40example.com/default/{}", + resource_type.base_path(), + name + ); + let etag = client + .request("PUT", &path, contents.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + paths.push((path, etag, contents)); + } + + if resource_type == DavResourceName::Cal { + let path = format!("{}/john%40example.com", resource_type.base_path()); + let response = client + .multiget_calendar(&path, &[&paths[0].0, &paths[1].0]) + .await; + for (path, etag, contents) in paths { + let props = response.properties(&path); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + props + .get(DavProperty::CalDav(CalDavProperty::CalendarData( + Default::default(), + ))) + .with_values([contents.as_str()]); + } + } else { + let path = format!("{}/john%40example.com", resource_type.base_path()); + let response = client + .multiget_addressbook(&path, &[&paths[0].0, &paths[1].0]) + .await; + for (path, etag, contents) in paths { + let props = response.properties(&path); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData { + properties: Default::default(), + version: None, + })) + .with_values([contents.as_str()]); + } + } + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/principals.rs b/tests/src/webdav/principals.rs new file mode 100644 index 0000000..78bb1cf --- /dev/null +++ b/tests/src/webdav/principals.rs @@ -0,0 +1,515 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use crate::webdav::prop::ALL_DAV_PROPERTIES; +use dav_proto::schema::property::{DavProperty, PrincipalProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer, assisted_discovery: bool) { + println!("Running principals tests..."); + let client = test.account("jane@example.com").webdav_client(); + let principal_path = format!("D:href:{}/", DavResourceName::Principal.base_path()); + let jane_principal_path = format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ); + + let path_support_card = format!( + "D:href:{}/support%40example.com/", + DavResourceName::Card.base_path() + ); + let path_support_cal = format!( + "D:href:{}/support%40example.com/", + DavResourceName::Cal.base_path() + ); + + // Test 1: PROPFIND on /dav/pal should return all principals + let response = client + .propfind( + DavResourceName::Principal.collection_path(), + ALL_DAV_PROPERTIES, + ) + .await; + for account_ in test.accounts.values().filter(|a| a.name().contains('@')) { + let account_name = account_.name().replace('@', "%40"); + let email = account_.name(); + let description = account_.description(); + + let props = response.properties(&format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + account_name + )); + let path_pal = format!( + "D:href:{}/{}/", + DavResourceName::Principal.base_path(), + account_name + ); + let path_card = format!( + "D:href:{}/{}/", + DavResourceName::Card.base_path(), + account_name + ); + let path_cal = format!( + "D:href:{}/{}/", + DavResourceName::Cal.base_path(), + account_name + ); + props + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([description]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::PrincipalURL)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + if account_name == "jane%40example.com" && !assisted_discovery { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str(), path_support_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str(), path_support_card.as_str()]) + .with_status(StatusCode::OK); + } else { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str()]) + .with_status(StatusCode::OK); + } + props + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values([ + "D:supported-report.D:report.D:principal-property-search", + "D:supported-report.D:report.D:principal-search-property-set", + "D:supported-report.D:report.D:principal-match", + ]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:principal", "D:collection"]) + .with_status(StatusCode::OK); + + // Scheduling properties + props + .get(DavProperty::Principal( + PrincipalProperty::CalendarUserAddressSet, + )) + .with_values([format!("D:href:mailto:{email}",).as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::CalendarUserType)) + .with_values([if account_name == "support%40example.com" { + "GROUP" + } else { + "INDIVIDUAL" + }]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::ScheduleInboxURL)) + .with_values([format!( + "D:href:{}/{account_name}/inbox/", + DavResourceName::Scheduling.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL)) + .with_values([format!( + "D:href:{}/{account_name}/outbox/", + DavResourceName::Scheduling.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); + } + + // Test 2: PROPFIND on /dav/[resource] should return user and shared resources + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + let supported_reports = match resource_type { + DavResourceName::File => [ + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + ] + .as_slice(), + DavResourceName::Cal => [ + "D:supported-report.D:report.A:free-busy-query", + "D:supported-report.D:report.A:calendar-query", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + "D:supported-report.D:report.A:calendar-multiget", + ] + .as_slice(), + DavResourceName::Card => [ + "D:supported-report.D:report.B:addressbook-query", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.B:addressbook-multiget", + "D:supported-report.D:report.D:principal-match", + "D:supported-report.D:report.D:sync-collection", + ] + .as_slice(), + _ => unreachable!(), + }; + let privilege_set = if resource_type == DavResourceName::Cal { + [ + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.A:read-free-busy", + "D:privilege.D:read-acl", + "D:privilege.D:write-properties", + "D:privilege.D:write", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:bind", + "D:privilege.D:unbind", + ] + .as_slice() + } else { + [ + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:write", + "D:privilege.D:write-properties", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:read-acl", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.D:bind", + "D:privilege.D:unbind", + ] + .as_slice() + }; + + let response = client + .propfind(resource_type.collection_path(), ALL_DAV_PROPERTIES) + .await; + let props = response.properties(resource_type.collection_path()); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(supported_reports.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection"]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + if assisted_discovery { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Cal.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Card.base_path() + ) + .as_str()]) + .with_status(StatusCode::OK); + } else { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([ + format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Cal.base_path() + ) + .as_str(), + format!( + "D:href:{}/support%40example.com/", + DavResourceName::Cal.base_path() + ) + .as_str(), + ]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([ + format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Card.base_path() + ) + .as_str(), + format!( + "D:href:{}/support%40example.com/", + DavResourceName::Card.base_path() + ) + .as_str(), + ]) + .with_status(StatusCode::OK); + } + + for account_ in test + .accounts + .values() + .filter(|account| ["jane@example.com", "support@example.com"].contains(&account.name())) + { + let account_name = account_.name().replace('@', "%40"); + let description = account_.description(); + + let path_card = format!( + "D:href:{}/{}/", + DavResourceName::Card.base_path(), + account_name + ); + let path_cal = format!( + "D:href:{}/{}/", + DavResourceName::Cal.base_path(), + account_name + ); + let path_pal = format!( + "D:href:{}/{}/", + DavResourceName::Principal.base_path(), + account_name + ); + let props = + response.properties(&format!("{}/{account_name}/", resource_type.base_path())); + + props + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([description]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection"]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values(privilege_set.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(supported_reports.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::PrincipalURL)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + if account_name == "jane%40example.com" && !assisted_discovery { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str(), path_support_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str(), path_support_card.as_str()]) + .with_status(StatusCode::OK); + } else { + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str()]) + .with_status(StatusCode::OK); + } + props + .get(DavProperty::WebDav(WebDavProperty::SyncToken)) + .with_status(StatusCode::OK) + .is_not_empty(); + props + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .with_status(StatusCode::NOT_FOUND); + props + .get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes)) + .with_status(StatusCode::OK) + .is_not_empty(); + } + + // Test 3: principal-match-query on resources + let response = client + .request( + "REPORT", + resource_type.collection_path(), + PRINCIPAL_MATCH_QUERY, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!("{}/jane%40example.com/", resource_type.base_path()).as_str(), + format!("{}/support%40example.com/", resource_type.base_path()).as_str(), + ]); + } + + // Test 4: principal-match-query on principals + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_MATCH_QUERY, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str(), + format!( + "{}/support%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str(), + ]); + + // Test 5: principal-search-property-set REPORT + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_SEARCH_PROPERTY_SET_QUERY, + ) + .await + .with_status(StatusCode::OK); + response + .with_value( + "D:principal-search-property-set.D:principal-search-property.D:prop.D:displayname", + "", + ) + .with_value( + "D:principal-search-property-set.D:principal-search-property.D:description", + "Account or Group name", + ); + + // Test 6: principal-property-search REPORT + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "doe"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str(), + format!( + "{}/john%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str(), + ]); + response + .properties(&format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + )) + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([test.account("jane@example.com").description()]) + .with_status(StatusCode::OK); + client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "support"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + .with_hrefs([format!( + "{}/support%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str()]); + + client.delete_default_containers().await; + client + .delete_default_containers_by_account("support@example.com") + .await; + test.assert_is_empty().await; +} + +const PRINCIPAL_MATCH_QUERY: &str = r#" + + + + + +"#; + +const PRINCIPAL_SEARCH_PROPERTY_SET_QUERY: &str = + r#""#; + +const PRINCIPAL_PROPERTY_SEARCH_QUERY: &str = r#" + + + + + + $NAME + + + + +"#; diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs new file mode 100644 index 0000000..a536634 --- /dev/null +++ b/tests/src/webdav/prop.rs @@ -0,0 +1,792 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; +use crate::utils::webdav::GenerateTestDavResource; +use crate::webdav::{TEST_ICAL_2, TEST_VTIMEZONE_1}; +use dav_proto::schema::property::{ + CalDavProperty, CardDavProperty, DavProperty, PrincipalProperty, WebDavProperty, +}; +use groupware::DavResourceName; +use hyper::StatusCode; +use types::dead_property::DeadElementTag; + +pub async fn test(test: &TestServer, assisted_discovery: bool) { + let client = test.account("jane@example.com").webdav_client(); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running PROPFIND/PROPPATCH tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/jane%40example.com", resource_type.base_path()); + let group_base_path = format!("{}/support%40example.com", resource_type.base_path()); + + // Create a new test container and file + let test_base_path = format!("{user_base_path}/PropFind_Folder/"); + let etag_folder = client + .mkcol("MKCOL", &test_base_path, [], []) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + let test_contents = resource_type.generate(); + let test_path = format!("{test_base_path}test_file"); + let etag_file = client + .request_with_headers( + "PUT", + &test_path, + [("content-type", "text/x-other")], + test_contents.as_str(), + ) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + + // Test 1: PROPFIND Depth 0 on root + client + .request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "0")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([resource_type.collection_path()]); + + // Test 2: PROPFIND Depth 0 on user base path + client + .request_with_headers("PROPFIND", &user_base_path, [("depth", "0")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([format!("{user_base_path}/").as_str()]); + + client + .propfind_with_headers( + &user_base_path, + [DavProperty::WebDav(WebDavProperty::GetCTag)], + [("depth", "0")], + ) + .await + .properties(&format!("{user_base_path}/")) + .get(DavProperty::WebDav(WebDavProperty::GetCTag)) + .is_not_empty(); + + // Test 3: PROPFIND Depth 1 on root + client + .request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "1")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([ + resource_type.collection_path(), + format!("{user_base_path}/").as_str(), + format!("{group_base_path}/").as_str(), + ]); + + // Test 4: Infinity depth is not allowed + for path in [resource_type.base_path(), user_base_path.as_str()] { + client + .request_with_headers("PROPFIND", path, [("depth", "infinity")], "") + .await + .with_status(StatusCode::FORBIDDEN); + } + + // Test 5: PROPFIND Depth 1 on user base path + client + .request_with_headers("PROPFIND", &user_base_path, [("depth", "1")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs( + [ + format!("{group_base_path}/default/").as_str(), + format!("{user_base_path}/default/").as_str(), + format!("{user_base_path}/").as_str(), + &test_base_path, + ] + .into_iter() + .skip(if resource_type == DavResourceName::File { + 2 + } else if !assisted_discovery { + 1 + } else { + 0 + }), + ); + + // Test 6: PROPFIND Depth 1 on created collection + client + .request_with_headers("PROPFIND", &test_base_path, [("depth", "1")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([test_base_path.as_str(), test_path.as_str()]); + + // Test 7: Infinity depth is not allowed on file containers + client + .request_with_headers("PROPFIND", &test_base_path, [("depth", "infinity")], "") + .await + .with_status(if resource_type == DavResourceName::File { + StatusCode::FORBIDDEN + } else { + StatusCode::MULTI_STATUS + }); + + // Test 8 PROPFIND with depth-no-root + client + .request_with_headers( + "PROPFIND", + &user_base_path, + [("depth", "1"), ("prefer", "depth-noroot")], + "", + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs( + [ + format!("{group_base_path}/default/").as_str(), + format!("{user_base_path}/default/").as_str(), + &test_base_path, + ] + .into_iter() + .skip(if resource_type == DavResourceName::File { + 2 + } else if !assisted_discovery { + 1 + } else { + 0 + }), + ); + client + .request_with_headers( + "PROPFIND", + &test_base_path, + [("depth", "1"), ("prefer", "depth-noroot")], + "", + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([test_path.as_str()]); + + // Test 8 PROPFIND with prefer return=minimal + let response = client + .propfind_with_headers(&test_base_path, ALL_DAV_PROPERTIES, []) + .await; + response + .properties(&test_base_path) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_defined(DavProperty::Principal(PrincipalProperty::GroupMembership)); + let response = client + .propfind_with_headers( + &test_base_path, + ALL_DAV_PROPERTIES, + [("prefer", "return=minimal")], + ) + .await; + response + .properties(&test_base_path) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_undefined(DavProperty::Principal(PrincipalProperty::GroupMembership)); + + // Test 9: Retrieve all static properties + for (path, etag, is_file) in [ + (&test_base_path, &etag_folder, false), + (&test_path, &etag_file, true), + ] { + let response = client.propfind(path, ALL_DAV_PROPERTIES).await; + let properties = response.properties(path); + properties + .get(DavProperty::WebDav(WebDavProperty::CreationDate)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::GetLastModified)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::SyncToken)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedLock)) + .with_values([ + "D:lockentry.D:lockscope.D:exclusive", + "D:lockentry.D:locktype.D:write", + "D:lockentry.D:lockscope.D:shared", + "D:lockentry.D:locktype.D:write", + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str()]); + properties + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([format!( + "D:href:{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ) + .as_str()]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::AclRestrictions)) + .with_values(["D:grant-only", "D:no-invert"]); + properties + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([ + format!("D:href:{}", DavResourceName::Principal.collection_path()).as_str(), + ]); + + if is_file { + // File specific properties + properties + .get(DavProperty::WebDav(WebDavProperty::GetContentType)) + .with_values([match resource_type { + DavResourceName::File => "text/x-other", + DavResourceName::Cal => "text/calendar", + DavResourceName::Card => "text/vcard", + _ => unreachable!(), + }]); + properties + .get(DavProperty::WebDav(WebDavProperty::GetContentLength)) + .with_values([test_contents.len().to_string().as_str()]); + } else { + // Collection specific properties + properties + .get(DavProperty::WebDav(WebDavProperty::GetCTag)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(match resource_type { + DavResourceName::File => ["D:collection"].as_slice().iter().copied(), + DavResourceName::Cal => { + ["D:collection", "A:calendar"].as_slice().iter().copied() + } + DavResourceName::Card => { + ["D:collection", "B:addressbook"].as_slice().iter().copied() + } + _ => unreachable!(), + }); + let used_bytes: u64 = properties + .get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes)) + .value() + .parse() + .unwrap(); + assert!(used_bytes > 0); + properties + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .with_status(StatusCode::NOT_FOUND); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(match resource_type { + DavResourceName::File => [ + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + ] + .as_slice() + .iter() + .copied(), + DavResourceName::Cal => [ + "D:supported-report.D:report.A:calendar-query", + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.A:free-busy-query", + "D:supported-report.D:report.A:calendar-multiget", + "D:supported-report.D:report.D:principal-match", + ] + .as_slice() + .iter() + .copied(), + DavResourceName::Card => [ + "D:supported-report.D:report.B:addressbook-multiget", + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + "D:supported-report.D:report.B:addressbook-query", + "D:supported-report.D:report.D:expand-property", + ] + .as_slice() + .iter() + .copied(), + _ => unreachable!(), + }); + + if resource_type == DavResourceName::Cal { + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:write", + "D:privilege.D:write-properties", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:read-acl", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.D:bind", + "D:privilege.D:unbind", + "D:privilege.A:read-free-busy", + ]); + properties + .get(DavProperty::CalDav( + CalDavProperty::SupportedCalendarComponentSet, + )) + .with_values([ + "A:comp.[name]:VAVAILABILITY", + "A:comp.[name]:AVAILABLE", + "A:comp.[name]:VRESOURCE", + "A:comp.[name]:VTODO", + "A:comp.[name]:DAYLIGHT", + "A:comp.[name]:STANDARD", + "A:comp.[name]:VLOCATION", + "A:comp.[name]:VTIMEZONE", + "A:comp.[name]:VFREEBUSY", + "A:comp.[name]:VEVENT", + "A:comp.[name]:VJOURNAL", + "A:comp.[name]:PARTICIPANT", + "A:comp.[name]:VALARM", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::SupportedCalendarData)) + .with_values([ + concat!("A:calendar-data-type.", "[content-type]:text/calendar"), + "A:calendar-data-type.[version]:2.0", + "A:calendar-data-type.[version]:1.0", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::SupportedCollationSet)) + .with_values([ + "A:supported-collation:i;unicode-casemap", + "A:supported-collation:i;ascii-casemap", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::MinDateTime)) + .with_values(["0001-01-01T00:00:00Z"]); + properties + .get(DavProperty::CalDav(CalDavProperty::MaxDateTime)) + .with_values(["9999-12-31T23:59:59Z"]); + for (key, value) in [ + ( + DavProperty::CalDav(CalDavProperty::MaxResourceSize), + test.server.core.groupware.max_ical_size, + ), + ( + DavProperty::CalDav(CalDavProperty::MaxInstances), + test.server.core.groupware.max_ical_instances, + ), + ( + DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), + test.server.core.groupware.max_ical_attendees_per_instance, + ), + ] { + properties + .get(key) + .with_values([value.to_string().as_str()]); + } + } else { + if resource_type == DavResourceName::Card { + properties + .get(DavProperty::CardDav(CardDavProperty::SupportedAddressData)) + .with_values([ + concat!("B:address-data-type.", "[content-type]:text/vcard"), + "B:address-data-type.[version]:3.0", + "B:address-data-type.[version]:4.0", + "B:address-data-type.[version]:2.1", + ]); + properties + .get(DavProperty::CardDav(CardDavProperty::SupportedCollationSet)) + .with_values([ + "B:supported-collation:i;unicode-casemap", + "B:supported-collation:i;ascii-casemap", + ]); + properties + .get(DavProperty::CardDav(CardDavProperty::MaxResourceSize)) + .with_values([test + .server + .core + .groupware + .max_vcard_size + .to_string() + .as_str()]); + } + + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:write", + "D:privilege.D:write-properties", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:read-acl", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.D:bind", + "D:privilege.D:unbind", + ]); + } + } + } + + // Test 10: expand-property report + for path in [&test_base_path, &test_path] { + let response = client + .request("REPORT", path, EXPAND_REPORT_QUERY) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + let properties = response.properties(path); + for prop in [ + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::Owner), + ] { + properties.get(prop).with_some_values([ + format!( + "D:response.D:href:{}/jane%40example.com/", + DavResourceName::Principal.base_path(), + ) + .as_str(), + "D:response.D:propstat.D:prop.D:displayname:Jane Doe-Smith", + ]); + } + } + + for (path, etag, is_file) in [ + (&test_base_path, &etag_folder, false), + (&test_path, &etag_file, true), + ] { + // Test 11: PROPPATCH should fail when a precondition fails + client + .proppatch( + path, + [( + DavProperty::WebDav(WebDavProperty::DisplayName), + "Magnific name", + )], + [], + [("if", format!("(Not [{etag}])").as_str())], + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + client + .proppatch( + path, + [( + DavProperty::WebDav(WebDavProperty::DisplayName), + "Magnific name - second try", + )], + [], + [("if", format!("([{etag}])").as_str())], + ) + .await + .with_status(StatusCode::MULTI_STATUS); + client + .propfind(path, [DavProperty::WebDav(WebDavProperty::GetETag)]) + .await + .properties(path) + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_status(StatusCode::OK) + .without_values([etag.as_str()]); + + // Test 12: PROPPATCH set on DAV properties + client + .patch_and_check( + path, + [ + ( + DavProperty::WebDav(WebDavProperty::DisplayName), + "New display name", + ), + ( + DavProperty::WebDav(WebDavProperty::CreationDate), + "2000-01-01T00:00:00Z", + ), + ( + DavProperty::DeadProperty(DeadElementTag::new( + "my-dead-element".to_string(), + Some("xmlns=\"http://example.com/ns/\" prop=\"abc\"".to_string()), + )), + "this is a dead but exciting element", + ), + ], + ) + .await; + client + .patch_and_check( + path, + [( + DavProperty::DeadProperty(DeadElementTag::new( + "my-dead-element".to_string(), + Some("xmlns=\"http://example.com/ns/\" prop=\"xyz\"".to_string()), + )), + "this is a modified dead but exciting element", + )], + ) + .await; + + // Test 13: PROPPATCH remove on DAV properties + let mut props = vec![ + ( + DavProperty::DeadProperty(DeadElementTag::new( + "my-dead-element".to_string(), + Some("xmlns=\"http://example.com/ns/\"".to_string()), + )), + "", + ), + (DavProperty::WebDav(WebDavProperty::DisplayName), ""), + ]; + if !is_file { + // DisplayName can't be removed from calendar/contact collections + props.pop(); + } + client.patch_and_check(path, props).await; + + match resource_type { + DavResourceName::File if is_file => { + // Test 14: Change a file's content-type + client + .patch_and_check( + path, + [( + DavProperty::WebDav(WebDavProperty::GetContentType), + "text/x-yadda-yadda", + )], + ) + .await; + } + DavResourceName::Cal if !is_file => { + // Test 15: Change a calendar's properties + client + .patch_and_check( + path, + [ + ( + DavProperty::CalDav(CalDavProperty::CalendarDescription), + "New calendar description", + ), + ( + DavProperty::CalDav(CalDavProperty::TimezoneId), + "Europe/Ljubljana", + ), + ], + ) + .await; + client + .patch_and_check( + path, + [ + (DavProperty::CalDav(CalDavProperty::CalendarDescription), ""), + (DavProperty::CalDav(CalDavProperty::TimezoneId), ""), + ], + ) + .await; + client + .patch_and_check( + path, + [( + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + TEST_VTIMEZONE_1.replace('\n', "\r\n").as_str(), + )], + ) + .await; + } + DavResourceName::Card if !is_file => { + // Test 16: Change an addressbook's properties + client + .patch_and_check( + path, + [( + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + "New calendar description", + )], + ) + .await; + client + .patch_and_check( + path, + [( + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + "", + )], + ) + .await; + } + _ => (), + } + + // Test 17: PROPPATCH should fail on large properties + let mut chunky_props = vec![ + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::DeadProperty(DeadElementTag::new( + "my-chunky-dead-element".to_string(), + Some("xmlns=\"http://example.com/ns/\"".to_string()), + )), + ]; + if !is_file { + if resource_type == DavResourceName::Cal { + chunky_props.push(DavProperty::CalDav(CalDavProperty::CalendarDescription)); + } else if resource_type == DavResourceName::Card { + chunky_props.push(DavProperty::CardDav( + CardDavProperty::AddressbookDescription, + )); + } + } + let chunky_live_contents = (0..=(test.server.core.groupware.live_property_size + 1)) + .map(|_| "a") + .collect::(); + let chunky_dead_contents = + (0..=(test.server.core.groupware.dead_property_size.unwrap() + 1)) + .map(|_| "a") + .collect::(); + let response = client + .proppatch( + path, + chunky_props.iter().map(|prop| { + ( + prop.clone(), + if matches!(prop, DavProperty::DeadProperty(_)) { + &chunky_dead_contents + } else { + &chunky_live_contents + } + .as_str(), + ) + }), + [], + [], + ) + .await + .into_propfind_response(None); + let props = response.properties(path); + for prop in chunky_props { + props + .get(prop) + .with_status(StatusCode::INSUFFICIENT_STORAGE) + .with_description("Property value is too long"); + } + + // Test 18: PROPPATCH should fail on invalid calendar property values + if !is_file && resource_type == DavResourceName::Cal { + let response = client + .proppatch( + path, + [ + ( + DavProperty::CalDav(CalDavProperty::TimezoneId), + "unknown/zone", + ), + ( + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + TEST_ICAL_2, + ), + ], + [], + [], + ) + .await + .into_propfind_response(None); + let props = response.properties(path); + props + .get(DavProperty::CalDav(CalDavProperty::TimezoneId)) + .with_status(StatusCode::PRECONDITION_FAILED) + .with_description("Invalid timezone ID"); + props + .get(DavProperty::CalDav(CalDavProperty::CalendarTimezone)) + .with_status(StatusCode::PRECONDITION_FAILED) + .with_description("Invalid calendar timezone"); + } + } + + client + .request("DELETE", &test_base_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + client.delete_default_containers().await; + client + .delete_default_containers_by_account("support@example.com") + .await; + test.assert_is_empty().await; +} + +const EXPAND_REPORT_QUERY: &str = r#" + + + + + + + + + +"#; + +pub const ALL_DAV_PROPERTIES: &[DavProperty] = &[ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes), + DavProperty::WebDav(WebDavProperty::QuotaUsedBytes), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::Group), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::GetCTag), + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + DavProperty::CardDav(CardDavProperty::SupportedAddressData), + DavProperty::CardDav(CardDavProperty::SupportedCollationSet), + DavProperty::CardDav(CardDavProperty::MaxResourceSize), + DavProperty::CalDav(CalDavProperty::CalendarDescription), + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet), + DavProperty::CalDav(CalDavProperty::SupportedCalendarData), + DavProperty::CalDav(CalDavProperty::SupportedCollationSet), + DavProperty::CalDav(CalDavProperty::MaxResourceSize), + DavProperty::CalDav(CalDavProperty::MinDateTime), + DavProperty::CalDav(CalDavProperty::MaxDateTime), + DavProperty::CalDav(CalDavProperty::MaxInstances), + DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), + DavProperty::CalDav(CalDavProperty::TimezoneServiceSet), + DavProperty::CalDav(CalDavProperty::TimezoneId), + DavProperty::CalDav(CalDavProperty::ScheduleDefaultCalendarURL), + DavProperty::CalDav(CalDavProperty::ScheduleTag), + DavProperty::CalDav(CalDavProperty::ScheduleCalendarTransp), + DavProperty::Principal(PrincipalProperty::AlternateURISet), + DavProperty::Principal(PrincipalProperty::PrincipalURL), + DavProperty::Principal(PrincipalProperty::GroupMemberSet), + DavProperty::Principal(PrincipalProperty::GroupMembership), + DavProperty::Principal(PrincipalProperty::CalendarHomeSet), + DavProperty::Principal(PrincipalProperty::AddressbookHomeSet), + DavProperty::Principal(PrincipalProperty::PrincipalAddress), + DavProperty::Principal(PrincipalProperty::CalendarUserAddressSet), + DavProperty::Principal(PrincipalProperty::CalendarUserType), + DavProperty::Principal(PrincipalProperty::ScheduleInboxURL), + DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL), +]; diff --git a/tests/src/webdav/put_get.rs b/tests/src/webdav/put_get.rs new file mode 100644 index 0000000..046e56f --- /dev/null +++ b/tests/src/webdav/put_get.rs @@ -0,0 +1,634 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use types::collection::Collection; + +use crate::utils::server::TestServer; + +use crate::utils::webdav::GenerateTestDavResource; +use crate::webdav::*; + +pub async fn test(test: &TestServer) { + println!("Running PUT/GET tests..."); + let client = test.account("john@example.com").webdav_client(); + + // Simple PUT + let mut files = AHashMap::new(); + for (path, ct, content) in [ + ( + "/dav/file/john%40example.com/file1.txt", + "text/plain", + TEST_FILE_1, + ), + ( + "/dav/file/john%40example.com/file2.txt", + "text/x-other", + TEST_FILE_2, + ), + ( + "/dav/card/john%40example.com/default/card1.vcf", + "text/vcard; charset=utf-8", + TEST_VCARD_1, + ), + ( + "/dav/card/john%40example.com/default/card2.vcf", + "text/vcard; charset=utf-8", + TEST_VCARD_2, + ), + ( + "/dav/cal/john%40example.com/default/event1.ics", + "text/calendar; charset=utf-8", + TEST_ICAL_1, + ), + ( + "/dav/cal/john%40example.com/default/event2.ics", + "text/calendar; charset=utf-8", + TEST_ICAL_2, + ), + ] { + let content = content.replace("\n", "\r\n"); + let etag = client + .request_with_headers("PUT", path, [("content-type", ct)], &content) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + files.insert(path, (content, ct, etag)); + } + + // Test GET + for (path, (content, ct, etag)) in &files { + client + .request("GET", path, "") + .await + .with_status(StatusCode::OK) + .with_header("etag", etag) + .with_header("content-type", ct) + .with_body(content); + } + + // Test GET with a Range header + let path = "/dav/file/john%40example.com/file1.txt"; + let (content, _, etag) = files.get(path).unwrap(); + let size = content.len(); + + for (range, expect_content_range, expect_body) in [ + ("bytes=0-4", format!("bytes 0-4/{size}"), &content[..5]), + ("bytes=0-0", format!("bytes 0-0/{size}"), &content[..1]), + ( + "bytes=5-", + format!("bytes 5-{}/{size}", size - 1), + &content[5..], + ), + ( + "bytes=-6", + format!("bytes {}-{}/{size}", size - 6, size - 1), + &content[size - 6..], + ), + ( + "bytes=0-100000", + format!("bytes 0-{}/{size}", size - 1), + &content[..], + ), + ] { + client + .request_with_headers("GET", path, [("range", range)], "") + .await + .with_status(StatusCode::PARTIAL_CONTENT) + .with_header("content-range", &expect_content_range) + .with_header("content-length", &expect_body.len().to_string()) + .with_header("accept-ranges", "bytes") + .with_header("etag", etag) + .with_body(expect_body); + } + + // Ranges outside the resource should fail + for range in ["bytes=100000-", &format!("bytes={size}-"), "bytes=-0"] { + client + .request_with_headers("GET", path, [("range", range)], "") + .await + .with_status(StatusCode::RANGE_NOT_SATISFIABLE) + .with_header("content-range", &format!("bytes */{size}")); + } + + // Multiple, invalid or unknown ranges should be ignored + for range in ["bytes=0-4,6-8", "items=0-4", "bytes=4-2", "bytes=abc"] { + client + .request_with_headers("GET", path, [("range", range)], "") + .await + .with_status(StatusCode::OK) + .with_header("accept-ranges", "bytes") + .with_body(content); + } + + // Ranges should be ignored on HEAD requests + client + .request_with_headers("HEAD", path, [("range", "bytes=0-4")], "") + .await + .with_status(StatusCode::OK) + .with_header("content-length", &size.to_string()) + .with_empty_body(); + + // Ranges should only be served when the If-Range validator matches + let weak_etag = format!("W/{etag}"); + let last_modified = client + .request("HEAD", path, "") + .await + .with_status(StatusCode::OK) + .header("last-modified") + .to_string(); + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + for (if_range, expect_status) in [ + (etag.as_str(), StatusCode::PARTIAL_CONTENT), + (last_modified.as_str(), StatusCode::PARTIAL_CONTENT), + (weak_etag.as_str(), StatusCode::OK), + ("\"invalid-etag\"", StatusCode::OK), + ("Sun, 09 Aug 2020 12:00:00 GMT", StatusCode::OK), + ] { + client + .request_with_headers( + "GET", + path, + [("range", "bytes=0-4"), ("if-range", if_range)], + "", + ) + .await + .with_status(expect_status); + } + + // If-Range without a Range header should be ignored + client + .request_with_headers("GET", path, [("if-range", "\"invalid-etag\"")], "") + .await + .with_status(StatusCode::OK) + .with_body(content); + + // Ranges on empty files should be ignored + let empty_path = "/dav/file/john%40example.com/empty.txt"; + client + .request_with_headers("PUT", empty_path, [("content-type", "text/plain")], "") + .await + .with_status(StatusCode::CREATED); + for range in ["bytes=0-4", "bytes=-5", "bytes=0-"] { + client + .request_with_headers("GET", empty_path, [("range", range)], "") + .await + .with_status(StatusCode::OK) + .with_header("accept-ranges", "bytes") + .with_empty_body(); + } + client + .request("DELETE", empty_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // PUT under a non-existing parent should fail + for (path, contents) in [ + ("/dav/file/john%40example.com/foo/file1.txt", TEST_FILE_1), + ("/dav/card/john%40example.com/foo/card1.vcf", TEST_VCARD_1), + ("/dav/cal/john%40example.com/foo/event1.ics", TEST_ICAL_1), + ] { + client + .request("PUT", path, contents) + .await + .with_status(StatusCode::CONFLICT); + } + + // PUT under resources should fail + for (path, contents) in [ + ( + "/dav/file/john%40example.com/file1.txt/other-file.txt", + TEST_FILE_1, + ), + ( + "/dav/card/john%40example.com/default/card1.vcf/other-file.vcf", + TEST_VCARD_1, + ), + ( + "/dav/cal/john%40example.com/default/event1.ics/other-file.ical", + TEST_ICAL_1, + ), + ] { + client + .request("PUT", path, contents) + .await + .with_status(StatusCode::METHOD_NOT_ALLOWED); + } + + // PUT a non-vCard/iCalendar file should fail + for (path, ct, content, precondition) in [ + ( + "/dav/card/john%40example.com/card3.vcf", + "text/vcard; charset=utf-8", + TEST_FILE_1, + "B:supported-address-data", + ), + ( + "/dav/cal/john%40example.com/event3.ics", + "text/calendar; charset=utf-8", + TEST_FILE_2, + "A:supported-calendar-data", + ), + ] { + client + .request_with_headers("PUT", path, [("content-type", ct)], content) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition(precondition, ""); + } + + // Exceeding the configured file limits should fail + let conf = &test.server.core.groupware; + for (path, contents, max_size, expect) in [ + ( + "/dav/file/john%40example.com/chunky-file1.txt", + TEST_FILE_1, + conf.max_file_size, + None, + ), + ( + "/dav/card/john%40example.com/chunky-card1.vcf", + TEST_VCARD_1, + conf.max_vcard_size, + Some("B:max-resource-size"), + ), + ( + "/dav/cal/john%40example.com/chunky-event1.ics", + TEST_ICAL_1, + conf.max_ical_size, + Some("A:max-resource-size"), + ), + ] { + let mut chunky_contents = String::with_capacity(max_size + contents.len()); + while chunky_contents.len() < max_size { + chunky_contents.push_str(contents); + } + let response = client + .request("PUT", path, chunky_contents) + .await + .with_status( + expect + .map(|_| StatusCode::PRECONDITION_FAILED) + .unwrap_or(StatusCode::PAYLOAD_TOO_LARGE), + ); + if let Some(expect) = expect { + response.with_failed_precondition(expect, &max_size.to_string()); + } + } + + // PUT requests cannot exceed quota + let mike_noquota = test.account("mike@example.com").webdav_client(); + for resource_type in [ + DavResourceName::File, + DavResourceName::Card, + DavResourceName::Cal, + ] { + let path = format!( + "{}/mike%40example.com/quota-test/", + resource_type.base_path() + ); + mike_noquota + .mkcol("MKCOL", &path, [], []) + .await + .with_status(StatusCode::CREATED); + let mut num_success = 0; + let mut did_fail = false; + + for i in 0..100 { + let content = resource_type.generate(); + let available = mike_noquota.available_quota(&path).await; + + let response = mike_noquota + .request_with_headers("PUT", &format!("{path}file{i}"), [], &content) + .await; + if available > content.len() as u64 { + num_success += 1; + response.with_status(StatusCode::CREATED); + } else { + response + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition("D:quota-not-exceeded", ""); + did_fail = true; + break; + } + } + if !did_fail { + panic!("Quota test failed: {} files created", num_success); + } + if num_success == 0 { + panic!("Quota test failed: no files created"); + } + + mike_noquota + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + // PUT precondition enforcement + let modseq = [ + test.resources("john@example.com", Collection::FileNode) + .await + .highest_change_id, + test.resources("john@example.com", Collection::Calendar) + .await + .highest_change_id, + test.resources("john@example.com", Collection::AddressBook) + .await + .highest_change_id, + ]; + for (path, ct, content) in [ + ( + "/dav/file/john%40example.com/file1.txt", + "text/plain", + TEST_FILE_1, + ), + ( + "/dav/card/john%40example.com/default/card1.vcf", + "text/vcard; charset=utf-8", + TEST_VCARD_1, + ), + ( + "/dav/cal/john%40example.com/default/event1.ics", + "text/calendar; charset=utf-8", + TEST_ICAL_1, + ), + ] { + let content = content.replace("\n", "\r\n"); + client + .request_with_headers( + "PUT", + path, + [("content-type", ct), ("if-none-match", "*")], + &content, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + client + .request_with_headers( + "PUT", + path, + [("content-type", ct), ("overwrite", "F")], + &content, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + client + .request_with_headers( + "PUT", + path, + [("content-type", ct), ("if", "([\"3827\"])")], + &content, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + client + .request_with_headers( + "PUT", + path, + [ + ("content-type", ct), + ("if", "([\"3827\"])"), + ("prefer", "return=representation"), + ], + &content, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_header("preference-applied", "return=representation") + .with_body(&content); + } + assert_eq!( + [ + test.resources("john@example.com", Collection::FileNode) + .await + .highest_change_id, + test.resources("john@example.com", Collection::Calendar) + .await + .highest_change_id, + test.resources("john@example.com", Collection::AddressBook) + .await + .highest_change_id, + ], + modseq + ); + + // Update files using etags + for (path, (content, ct, etag)) in &mut files { + let condition = format!("([{}])", etag); + *content = content.replace("X-TEST:SEQ1", "X-TEST:SEQ2"); + *etag = client + .request_with_headers( + "PUT", + path, + [("content-type", &**ct), ("if", condition.as_str())], + content.as_str(), + ) + .await + .with_status(StatusCode::NO_CONTENT) + .etag() + .to_string(); + } + + // Test GET + for (path, (content, ct, etag)) in &files { + client + .request("GET", path, "") + .await + .with_status(StatusCode::OK) + .with_header("etag", etag) + .with_header("content-type", ct) + .with_body(content); + } + + // PUT requests require unique UIDs + for (path, ct, content, precond_key, precond_value) in [ + ( + "/dav/card/john%40example.com/default/card5.vcf", + "text/vcard; charset=utf-8", + TEST_VCARD_1, + "B:no-uid-conflict.D:href", + "/dav/card/john%40example.com/default/card1.vcf", + ), + ( + "/dav/cal/john%40example.com/default/event5.ics", + "text/calendar; charset=utf-8", + TEST_ICAL_1, + "A:no-uid-conflict.D:href", + "/dav/cal/john%40example.com/default/event1.ics", + ), + ] { + client + .request_with_headers( + "PUT", + path, + [("content-type", ct), ("if-none-match", "*")], + content, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition(precond_key, precond_value); + } + + // iCal containing different component types should fail + client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/invalid.ics", + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-none-match", "*"), + ], + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:1234567890 +SUMMARY:Test Event +DTSTART;TZID=Europe/London:20231001T120000 +DTEND;TZID=Europe/London:20231001T130000 +END:VEVENT +BEGIN:VTODO +UID:1234567890 +SUMMARY:Test Task +DTSTART;TZID=Europe/London:20231001T120000 +DTEND;TZID=Europe/London:20231001T130000 +END:VTODO +END:VCALENDAR +"#, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition("A:valid-calendar-object-resource", ""); + + // iCal referencing more than one UID should fail + client + .request_with_headers( + "PUT", + "/dav/cal/john%40example.com/default/invalid.ics", + [ + ("content-type", "text/calendar; charset=utf-8"), + ("if-none-match", "*"), + ], + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:1234567890 +SUMMARY:Test Event 1 +DTSTART;TZID=Europe/London:20231001T120000 +DTEND;TZID=Europe/London:20231001T130000 +END:VEVENT +BEGIN:VEVENT +UID:1234567891 +SUMMARY:Test Event 2 +DTSTART;TZID=Europe/London:20231001T120000 +DTEND;TZID=Europe/London:20231001T130000 +END:VEVENT +END:VCALENDAR +"#, + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition("A:valid-calendar-object-resource", ""); + + // Deleting unknown/invalid destinations should fail + for (path, expect) in [ + ( + "/dav/file/john%40example.com/unknown.txt", + StatusCode::NOT_FOUND, + ), + ( + "/dav/card/john%40example.com/default/unknown.txt", + StatusCode::NOT_FOUND, + ), + ( + "/dav/cal/john%40example.com/default/unknown.txt", + StatusCode::NOT_FOUND, + ), + ("/dav/file/john%40example.com", StatusCode::FORBIDDEN), + ("/dav/cal/john%40example.com", StatusCode::FORBIDDEN), + ("/dav/card/john%40example.com", StatusCode::FORBIDDEN), + ( + "/dav/pal/john%40example.com", + StatusCode::METHOD_NOT_ALLOWED, + ), + ("/dav/file", StatusCode::FORBIDDEN), + ("/dav/cal", StatusCode::FORBIDDEN), + ("/dav/card", StatusCode::FORBIDDEN), + ("/dav/pal", StatusCode::METHOD_NOT_ALLOWED), + ] { + client.request("DELETE", path, "").await.with_status(expect); + } + + // Resource names containing characters that are legal in a path segment + for (resource_type, container, name) in [ + (DavResourceName::Cal, "default", "foo+bar(1)&x:y@z.ics"), + (DavResourceName::Card, "default", "foo+bar(1)&x:y@z.vcf"), + (DavResourceName::Cal, "cal+1(a)", "event.ics"), + (DavResourceName::Card, "book+1(a)", "card.vcf"), + ] { + let container_path = format!( + "{}/john%40example.com/{container}", + resource_type.base_path() + ); + if container != "default" { + client + .request("MKCOL", &container_path, "") + .await + .with_status(StatusCode::CREATED); + } + let path = format!("{container_path}/{name}"); + let content = resource_type.generate(); + client + .request("PUT", &path, &content) + .await + .with_status(StatusCode::CREATED); + client + .propfind(&path, ["D:getetag"]) + .await + .with_hrefs([path.as_str()]); + client + .request("GET", &path, "") + .await + .with_status(StatusCode::OK) + .with_body(&content); + client + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + if container != "default" { + client + .request("DELETE", &container_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + } + + // Delete files + for (path, (_, _, etag)) in &files { + client + .request_with_headers("DELETE", path, [("if", "([\"3827\"])")], "") + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + let condition = format!("([{}])", etag); + client + .request_with_headers("DELETE", path, [("if", condition.as_str())], "") + .await + .with_status(StatusCode::NO_CONTENT); + + client + .request("DELETE", path, "") + .await + .with_status(StatusCode::NOT_FOUND); + } + + client.delete_default_containers().await; + mike_noquota.delete_default_containers().await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/sync.rs b/tests/src/webdav/sync.rs new file mode 100644 index 0000000..5a86511 --- /dev/null +++ b/tests/src/webdav/sync.rs @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{server::TestServer, webdav::GenerateTestDavResource}; + +use ahash::AHashSet; +use dav_proto::Depth; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &TestServer) { + let client = test.account("john@example.com").webdav_client(); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running REPORT sync-collection tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/john%40example.com/", resource_type.base_path()); + + // Test 1: Initial sync + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, None, ["D:getetag"]) + .await; + assert_eq!( + response.hrefs().len(), + if resource_type == DavResourceName::File { + 1 + } else { + 2 + }, + "{:?}", + response.hrefs() + ); + let sync_token_1 = response.sync_token().to_string(); + + // Test 2: No changes since last sync + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), Vec::::new()); + + // Test 3: Create a collection and make sure it is synced + let new_collection = format!("{}new-collection/", user_base_path); + client + .mkcol("MKCOL", &new_collection, [], []) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + let sync_token_2 = response.sync_token().to_string(); + + // Test 4: Create a file and make sure it is synced + let new_file = format!("{new_collection}new-file"); + let contents = resource_type.generate(); + client + .request("PUT", &new_file, &contents) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + assert_eq!( + response.hrefs(), + vec![new_collection.clone(), new_file.clone()] + ); + let sync_token_3 = response.sync_token().to_string(); + let response = client + .sync_collection( + &user_base_path, + &sync_token_2, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_file.clone()]); + + // Test 5: sync-token with Depth 1 + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::One, + None, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + + // Test 6: sync-token with Depth 0 + let response = client + .sync_collection( + &new_collection, + &sync_token_1, + Depth::Zero, + None, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + + // Test 7: Outdated sync-token in If header should fail + let new_file2 = format!("{new_collection}new-file2"); + let contents = resource_type.generate(); + let condition = format!("(<{sync_token_2}>)"); + client + .request_with_headers( + "PUT", + &new_file2, + [("if", condition.as_str())], + contents.as_str(), + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + // Test 8: Correct sync-token in If header should work + let condition = format!("(<{sync_token_3}>)"); + client + .request_with_headers( + "PUT", + &new_file2, + [("if", condition.as_str())], + contents.as_str(), + ) + .await + .with_status(StatusCode::CREATED) + .with_empty_body(); + + // Test 9: Limit + let mut sync_token = client + .sync_collection( + &new_collection, + &sync_token_3, + Depth::Zero, + None, + ["D:getetag"], + ) + .await + .sync_token() + .to_string(); + let (folder_name, files) = client + .create_hierarchy(user_base_path.trim_end_matches('/'), 1, 0, 10) + .await; + let mut expected_changes = files + .iter() + .map(|x| x.0.as_str()) + .chain([folder_name.as_str()]) + .collect::>(); + for _ in 0..10 { + let response = client + .sync_collection( + &user_base_path, + &sync_token, + Depth::Infinity, + 2.into(), + ["D:getetag"], + ) + .await; + sync_token = response.sync_token().to_string(); + let hrefs = response.hrefs(); + if hrefs.is_empty() { + break; + } + let mut has_user_base_path = false; + let mut item_count = 0; + for href in hrefs { + if href == user_base_path { + has_user_base_path = true; + } else if expected_changes.remove(href) { + item_count += 1; + } else { + panic!("Unexpected href: {href}"); + } + } + if has_user_base_path { + assert_eq!(item_count, 2); + response + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 507 Insufficient Storage", + ) + .with_value( + "D:multistatus.D:response.D:error.D:number-of-matches-within-limits", + "", + ) + .with_value( + "D:multistatus.D:response.D:responsedescription", + "The number of matches exceeds the limit of 2", + ); + } else { + assert!(item_count <= 2); + break; + } + } + assert!(expected_changes.is_empty(), "{:?}", expected_changes); + + // Test 10: Expect changes after deletion + client + .request("DELETE", &new_file, "") + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection( + &user_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + sync_token = response.sync_token().to_string(); + response + .with_href_count(1) + .with_value("D:multistatus.D:response.D:href", &new_file) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 404 Not Found", + ); + client + .request("DELETE", &new_collection, "") + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection( + &user_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await; + sync_token = response.sync_token().to_string(); + response + .with_href_count(1) + .with_value("D:multistatus.D:response.D:href", &new_collection) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 404 Not Found", + ); + client + .request("DELETE", &folder_name, "") + .await + .with_status(StatusCode::NO_CONTENT); + client + .sync_collection( + &user_base_path, + &sync_token, + Depth::Infinity, + None, + ["D:getetag"], + ) + .await + .with_href_count(1) + .with_value("D:multistatus.D:response.D:href", &folder_name) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 404 Not Found", + ); + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +}
+
+ +
+ + + +
+
+
+
+
+ + + +
+